refactor(integrations): rename dsh-mem0 to deepseek-plugin, strands-mem0 to mem0-strands (#7098)

This commit is contained in:
Kartik
2026-08-24 19:21:23 +05:30
committed by GitHub
parent dc82354e14
commit b717e38785
43 changed files with 127 additions and 127 deletions
@@ -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)}`;
}
+138
View File
@@ -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;
}