refactor(integrations): rename dsh-mem0 to deepseek-plugin, strands-mem0 to mem0-strands (#7098)
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Compact, token-cheap rendering of Mem0 results for the model context.
|
||||
*
|
||||
* Mirrors the format used by the sibling Mem0 plugins
|
||||
* (integrations/pi-agent-plugin/src/memory/formatting.ts) so a memory reads the
|
||||
* same way across every harness: `[category] text (age) [mem0:id]`. Dumping the
|
||||
* raw search envelope instead would spend most of the tokens on JSON scaffolding.
|
||||
*/
|
||||
|
||||
export interface MemoryLike {
|
||||
id: string;
|
||||
memory?: string;
|
||||
categories?: string[];
|
||||
createdAt?: Date | string;
|
||||
}
|
||||
|
||||
export function formatAge(date: Date | string): string {
|
||||
const d = typeof date === "string" ? new Date(date) : date;
|
||||
const ms = Date.now() - d.getTime();
|
||||
const minutes = Math.floor(ms / 60_000);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
export function formatMemoryCompact(mem: MemoryLike): string {
|
||||
const cat = mem.categories?.[0] ?? "uncategorized";
|
||||
const age = mem.createdAt ? ` (${formatAge(mem.createdAt)})` : "";
|
||||
return `[${cat}] ${mem.memory ?? "(empty)"}${age} [mem0:${mem.id}]`;
|
||||
}
|
||||
|
||||
export function formatMemoryList(memories: MemoryLike[]): string {
|
||||
if (memories.length === 0) return "No memories found.";
|
||||
return memories
|
||||
.map((m, i) => `${i + 1}. ${formatMemoryCompact(m)}`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* One-line confirmation for a write.
|
||||
*
|
||||
* `client.add` hits the async `/v3/memories/add/` endpoint, which returns
|
||||
* `{ event_id, status: "PENDING" }` — extraction runs server-side *after* the
|
||||
* call returns, so the extracted memories are not in this response. Report the
|
||||
* write as queued in that case; only render a list when the backend actually
|
||||
* returns memories (older / OSS shapes).
|
||||
*/
|
||||
export function formatAddResult(result: unknown): string {
|
||||
const items: MemoryLike[] = Array.isArray(result)
|
||||
? (result as MemoryLike[])
|
||||
: ((result as { results?: MemoryLike[] } | null)?.results ??
|
||||
(result ? [result as MemoryLike] : []));
|
||||
|
||||
const pending = items.find(
|
||||
(r) => (r as { status?: string }).status === "PENDING",
|
||||
) as { eventId?: string; event_id?: string } | undefined;
|
||||
if (pending) {
|
||||
// The SDK camel-cases response keys (event_id -> eventId); accept either.
|
||||
const id = pending.eventId ?? pending.event_id;
|
||||
const evt = id ? ` (event ${id})` : "";
|
||||
return `Memory queued for background extraction${evt}; it will be searchable shortly.`;
|
||||
}
|
||||
|
||||
if (items.length === 0) return "Memory stored.";
|
||||
const noun = items.length === 1 ? "memory" : "memories";
|
||||
return `Stored ${items.length} ${noun}:\n${formatMemoryList(items)}`;
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* deepseek-plugin: Mem0 long-term memory as a native DeepSeek Harness (Cordis) plugin.
|
||||
*
|
||||
* Registers two agent-callable tools backed by the Mem0 SDK:
|
||||
* - `search_memory` recalls facts relevant to a query
|
||||
* - `add_memory` stores a fact for future sessions
|
||||
*
|
||||
* A plugin is a Cordis module that exports `apply(ctx, config)`. Declaring
|
||||
* `inject = ['tools']` holds the plugin until the harness tool registry exists;
|
||||
* tools registered via `ctx.tools.register(...)` are auto-unregistered when the
|
||||
* plugin unmounts (Cordis revertible effects).
|
||||
*/
|
||||
import type { Context } from "@deepseek-ai/cordis";
|
||||
import { defineTool } from "@deepseek-ai/dsh-tools";
|
||||
import { MemoryClient } from "mem0ai";
|
||||
import { formatMemoryList, formatAddResult } from "./formatting.ts";
|
||||
import { truncateOutput } from "./output.ts";
|
||||
import { resolveSearchFilters, resolveAddParams } from "./scoping.ts";
|
||||
|
||||
export const name = "mem0";
|
||||
export const inject = ["tools"];
|
||||
|
||||
// Tags writes so Mem0's backend attributes them to this integration in
|
||||
// telemetry. The backend keeps recognized values via its KNOWN_EVENT_SOURCES
|
||||
// allowlist; unknown values bucket into "OTHERS", so "DEEPSEEK_HARNESS" must be
|
||||
// added to that allowlist for usage to surface by name (a one-line backend PR,
|
||||
// same pattern as the ZAPIER / STRANDS sources).
|
||||
const SOURCE = "DEEPSEEK_HARNESS";
|
||||
|
||||
const DEFAULT_SEARCH_LIMIT = 10;
|
||||
|
||||
export interface Config {
|
||||
/** Mem0 API key. Defaults to the MEM0_API_KEY env var. */
|
||||
apiKey?: string;
|
||||
/** Default entity that owns the memories (Mem0 user scope). */
|
||||
userId: string;
|
||||
/** Optional Mem0 Platform base-URL override (on-prem / dedicated); defaults to api.mem0.ai. Not a switch to self-hosted OSS. */
|
||||
host?: string;
|
||||
}
|
||||
|
||||
// Both tools return a single text string; the render is identical, so lift it
|
||||
// into one shared declaration instead of repeating the block per tool.
|
||||
const textOutput = {
|
||||
schema: { type: "string" } as const,
|
||||
render: (_args: unknown, value: string) => [
|
||||
{ type: "text" as const, text: value },
|
||||
],
|
||||
};
|
||||
|
||||
// Optional per-call scoping params, shared by both tools. A single harness
|
||||
// install can serve more than one entity, so the model may override the
|
||||
// mount-time default per call (see scoping.ts).
|
||||
const scopeParams = {
|
||||
userId: {
|
||||
type: "string",
|
||||
description:
|
||||
"Entity that owns the memory. Defaults to the plugin's configured userId; set this only to read or write another user's memories.",
|
||||
},
|
||||
agentId: {
|
||||
type: "string",
|
||||
description: "Optional agent scope, to partition memories by agent.",
|
||||
},
|
||||
runId: {
|
||||
type: "string",
|
||||
description: "Optional run/session scope, to partition memories by session.",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const apiKey = config.apiKey ?? process.env.MEM0_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new Error("deepseek-plugin: set config.apiKey or the MEM0_API_KEY env var");
|
||||
}
|
||||
const userId = config.userId;
|
||||
if (!userId) {
|
||||
throw new Error("deepseek-plugin: config.userId is required");
|
||||
}
|
||||
|
||||
const client = new MemoryClient({
|
||||
apiKey,
|
||||
...(config.host ? { host: config.host } : {}),
|
||||
});
|
||||
|
||||
// Recall. The platform rejects top-level entity params on search, so scope
|
||||
// goes inside `filters` (unlike add below, which takes them top-level).
|
||||
ctx.tools.register(
|
||||
defineTool({
|
||||
name: "search_memory",
|
||||
description:
|
||||
"Search the user's long-term Mem0 memory for facts relevant to a query. Use proactively before answering anything that may depend on what the user told you earlier.",
|
||||
parameters: {
|
||||
query: { type: "string", description: "What to recall.", required: true },
|
||||
limit: {
|
||||
type: "integer",
|
||||
description: `Max results to return (default ${DEFAULT_SEARCH_LIMIT}).`,
|
||||
},
|
||||
...scopeParams,
|
||||
},
|
||||
output: textOutput,
|
||||
async execute({ query, limit, userId: u, agentId, runId }) {
|
||||
const filters = resolveSearchFilters({ userId: u, agentId, runId }, userId);
|
||||
try {
|
||||
const topK = limit && limit > 0 ? limit : DEFAULT_SEARCH_LIMIT;
|
||||
const { results } = await client.search(query, { filters, topK });
|
||||
return truncateOutput(formatMemoryList(results ?? []));
|
||||
} catch (err) {
|
||||
return `search_memory failed: ${err instanceof Error ? err.message : String(err)}`;
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Write. `source` tags the memory for telemetry attribution.
|
||||
ctx.tools.register(
|
||||
defineTool({
|
||||
name: "add_memory",
|
||||
description:
|
||||
"Store a fact in the user's long-term Mem0 memory for later sessions. Extraction runs asynchronously server-side, so a stored fact may take a moment to become searchable; do not immediately search to confirm the write.",
|
||||
parameters: {
|
||||
text: { type: "string", description: "The fact to remember.", required: true },
|
||||
...scopeParams,
|
||||
},
|
||||
output: textOutput,
|
||||
async execute({ text, userId: u, agentId, runId }) {
|
||||
const addParams = resolveAddParams({ userId: u, agentId, runId }, userId);
|
||||
try {
|
||||
const result = await client.add([{ role: "user", content: text }], {
|
||||
...addParams,
|
||||
source: SOURCE,
|
||||
});
|
||||
return truncateOutput(formatAddResult(result));
|
||||
} catch (err) {
|
||||
return `add_memory failed: ${err instanceof Error ? err.message : String(err)}`;
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Hard cap on tool output before it reaches the model context.
|
||||
*
|
||||
* Same guard the sibling plugins apply (200 lines / 50KB, see
|
||||
* integrations/pi-agent-plugin/src/memory/tools.ts): a large recall or a wide
|
||||
* result set can otherwise flood the context window in a single tool call.
|
||||
*/
|
||||
|
||||
export const MAX_OUTPUT_LINES = 200;
|
||||
export const MAX_OUTPUT_BYTES = 50_000;
|
||||
|
||||
export function truncateOutput(text: string): string {
|
||||
const lines = text.split("\n");
|
||||
if (lines.length <= MAX_OUTPUT_LINES && text.length <= MAX_OUTPUT_BYTES) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const kept = lines.slice(0, MAX_OUTPUT_LINES);
|
||||
let result = kept.join("\n");
|
||||
const byteCapped = result.length > MAX_OUTPUT_BYTES;
|
||||
if (byteCapped) {
|
||||
result = result.slice(0, MAX_OUTPUT_BYTES);
|
||||
}
|
||||
|
||||
const dropped = lines.length - kept.length;
|
||||
const reasons: string[] = [];
|
||||
if (dropped > 0) reasons.push(`showing ${kept.length} of ${lines.length} lines`);
|
||||
if (byteCapped) reasons.push(`cut at ${Math.floor(MAX_OUTPUT_BYTES / 1000)}KB`);
|
||||
if (reasons.length > 0) {
|
||||
result += `\n\n[Output truncated: ${reasons.join(", ")}]`;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Per-call memory scoping.
|
||||
*
|
||||
* The plugin is mounted with one default `userId`, but a single harness install
|
||||
* can serve more than one entity, so both tools accept optional `userId` /
|
||||
* `agentId` / `runId` params that override the mount-time default per call.
|
||||
* A missing or blank param falls back to the configured user.
|
||||
*
|
||||
* The two call sites need different key casing, and it is deliberate rather than
|
||||
* incidental: search passes scope inside `filters`, sent to the platform raw, so
|
||||
* it must be snake_case; add takes the entity params top-level, through the
|
||||
* SDK's camel->snake converter, so it must be camelCase. Keeping the split
|
||||
* explicit (like integrations/pi-agent-plugin/src/memory/scoping.ts) means the
|
||||
* asymmetry is visible in the code, not load-bearing on a converter no-op.
|
||||
*/
|
||||
|
||||
export interface EntityParams {
|
||||
userId?: string;
|
||||
agentId?: string;
|
||||
runId?: string;
|
||||
}
|
||||
|
||||
const clean = (v: string | undefined) => v?.trim() || undefined;
|
||||
|
||||
/** Search: snake_case, spread into `filters` and passed to the platform raw. */
|
||||
export function resolveSearchFilters(
|
||||
params: EntityParams,
|
||||
defaultUserId: string,
|
||||
): Record<string, string> {
|
||||
const filters: Record<string, string> = {
|
||||
user_id: clean(params.userId) ?? defaultUserId,
|
||||
};
|
||||
const agentId = clean(params.agentId);
|
||||
if (agentId) filters.agent_id = agentId;
|
||||
const runId = clean(params.runId);
|
||||
if (runId) filters.run_id = runId;
|
||||
return filters;
|
||||
}
|
||||
|
||||
/** Add: camelCase, top-level params run through the SDK's camel->snake converter. */
|
||||
export function resolveAddParams(
|
||||
params: EntityParams,
|
||||
defaultUserId: string,
|
||||
): Record<string, string> {
|
||||
const out: Record<string, string> = {
|
||||
userId: clean(params.userId) ?? defaultUserId,
|
||||
};
|
||||
const agentId = clean(params.agentId);
|
||||
if (agentId) out.agentId = agentId;
|
||||
const runId = clean(params.runId);
|
||||
if (runId) out.runId = runId;
|
||||
return out;
|
||||
}
|
||||
Reference in New Issue
Block a user