Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
200 changes: 158 additions & 42 deletions src/mcp/standalone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,22 @@ function parseLimit(raw: unknown, fallback = DEFAULT_LIMIT): number {
return Math.min(Math.floor(n), MAX_LIMIT);
}

function textResponse(payload: unknown, pretty = false): {
function parseTokenBudget(raw: unknown): number | undefined {
if (raw === undefined) return undefined;
if (typeof raw !== "number" && typeof raw !== "string") {
throw new Error("token_budget must be a positive integer");
}
const n = Number(raw);
if (!Number.isInteger(n) || n < 1) {
throw new Error("token_budget must be a positive integer");
}
return n;
}

function textResponse(
payload: unknown,
pretty = false,
): {
content: Array<{ type: string; text: string }>;
} {
return {
Expand All @@ -95,6 +110,43 @@ interface Validated {
reason?: string;
}

type LocalMemory = Record<string, unknown>;

function localMemoryMatches(
memory: LocalMemory,
queryWords: string[],
): boolean {
const text = [
typeof memory["title"] === "string" ? memory["title"] : "",
typeof memory["content"] === "string" ? memory["content"] : "",
Array.isArray(memory["files"]) ? memory["files"].join(" ") : "",
Array.isArray(memory["concepts"]) ? memory["concepts"].join(" ") : "",
Array.isArray(memory["sessionIds"]) ? memory["sessionIds"].join(" ") : "",
typeof memory["id"] === "string" ? memory["id"] : "",
]
.join(" ")
.toLowerCase();
return queryWords.every((word) => text.includes(word));
}

async function searchLocalMemories(
kvInstance: InMemoryKV,
v: Validated,
): Promise<LocalMemory[]> {
const queryWords = (v.query || "").toLowerCase().split(/\s+/);
const limit = v.limit ?? DEFAULT_LIMIT;
const all = await kvInstance.list<LocalMemory>("mem:memories");
return all
.filter((memory) => localMemoryMatches(memory, queryWords))
.slice(0, limit);
}

function firstSessionId(memory: LocalMemory): unknown {
return Array.isArray(memory["sessionIds"])
? memory["sessionIds"][0]
: undefined;
}

function validate(toolName: string, args: Record<string, unknown>): Validated {
if (!IMPLEMENTED_TOOLS.has(toolName)) {
throw new Error(`Unknown tool: ${toolName}`);
Expand All @@ -120,16 +172,29 @@ function validate(toolName: string, args: Record<string, unknown>): Validated {
}
v.query = query.trim();
v.limit = parseLimit(args["limit"]);
const fmt = args["format"];
if (typeof fmt === "string" && fmt.trim()) {
v.format = fmt.trim().toLowerCase();
}
const budget = args["token_budget"];
if (typeof budget === "number" && Number.isFinite(budget) && budget > 0) {
v.tokenBudget = Math.floor(budget);
} else if (typeof budget === "string" && budget.trim()) {
const n = Number(budget);
if (Number.isFinite(n) && n > 0) v.tokenBudget = Math.floor(n);
if (toolName === "memory_recall") {
const format =
typeof args["format"] === "string"
? args["format"].trim().toLowerCase()
: "full";
if (!["full", "compact", "narrative"].includes(format)) {
throw new Error("format must be one of: full, compact, narrative");
}
v.format = format as Validated["format"];
v.tokenBudget = parseTokenBudget(args["token_budget"]);
} else {
const budget = args["token_budget"];
if (
typeof budget === "number" &&
Number.isFinite(budget)
) {
const floorN = Math.floor(budget);
if (floorN > 0) v.tokenBudget = floorN;
} else if (typeof budget === "string" && budget.trim()) {
const n = Number(budget);
const floorN = Math.floor(n);
if (Number.isFinite(n) && floorN > 0) v.tokenBudget = floorN;
}
}
return v;
}
Expand Down Expand Up @@ -210,14 +275,15 @@ async function handleProxy(
return textResponse(result);
}
case "memory_export": {
const result = await handle.call("/agentmemory/export", { method: "GET" });
const result = await handle.call("/agentmemory/export", {
method: "GET",
});
return textResponse(result, true);
}
case "memory_audit": {
const result = await handle.call(
`/agentmemory/audit?limit=${v.limit}`,
{ method: "GET" },
);
const result = await handle.call(`/agentmemory/audit?limit=${v.limit}`, {
method: "GET",
});
return textResponse(result, true);
}
default:
Expand Down Expand Up @@ -251,30 +317,77 @@ async function handleLocal(
return textResponse({ saved: id });
}

case "memory_recall":
case "memory_smart_search": {
const query = (v.query || "").toLowerCase();
const limit = v.limit ?? DEFAULT_LIMIT;
const all =
await kvInstance.list<Record<string, unknown>>("mem:memories");
const results = all
.filter((m) => {
const text = [
typeof m["title"] === "string" ? m["title"] : "",
typeof m["content"] === "string" ? m["content"] : "",
Array.isArray(m["files"]) ? m["files"].join(" ") : "",
Array.isArray(m["concepts"]) ? m["concepts"].join(" ") : "",
Array.isArray(m["sessionIds"]) ? m["sessionIds"].join(" ") : "",
typeof m["id"] === "string" ? m["id"] : "",
]
.join(" ")
.toLowerCase();
return query.split(/\s+/).every((word) => text.includes(word));
})
.slice(0, limit);
const results = await searchLocalMemories(kvInstance, v);
return textResponse({ mode: "compact", results }, true);
}

case "memory_recall": {
const matches = await searchLocalMemories(kvInstance, v);

const format = v.format ?? "full";
if (format === "compact") {
return textResponse(
{
format,
results: matches.map((m) => ({
obsId: m["id"],
sessionId: firstSessionId(m),
title: m["title"],
type: m["type"],
score: 1,
timestamp: m["updatedAt"] || m["createdAt"],
})),
},
true,
);
}

if (format === "narrative") {
const results = matches.map((m) => ({
obsId: m["id"],
sessionId: firstSessionId(m),
title: m["title"],
narrative: m["content"],
score: 1,
timestamp: m["updatedAt"] || m["createdAt"],
}));
return textResponse(
{
format,
results,
text: results
.map(
(r, index) =>
`${index + 1}. ${String(r.title || r.obsId)}\n${String(r.narrative || "")}`,
)
.join("\n\n"),
},
true,
);
}

return textResponse(
{
format,
results: matches.map((m) => ({
observation: {
id: m["id"],
type: m["type"],
title: m["title"],
narrative: m["content"],
concepts: m["concepts"],
files: m["files"],
timestamp: m["updatedAt"] || m["createdAt"],
},
score: 1,
sessionId: firstSessionId(m),
})),
},
true,
);
}

case "memory_sessions": {
const sessions =
await kvInstance.list<Record<string, unknown>>("mem:sessions");
Expand Down Expand Up @@ -383,7 +496,9 @@ export async function handleToolCall(
}

export async function handleToolsList(): Promise<{ tools: unknown[] }> {
const debug = process.env["AGENTMEMORY_DEBUG"] === "1" || process.env["AGENTMEMORY_DEBUG"] === "true";
const debug =
process.env["AGENTMEMORY_DEBUG"] === "1" ||
process.env["AGENTMEMORY_DEBUG"] === "true";
const handle = await resolveHandle();
announceMode(handle);
if (debug) {
Expand All @@ -397,11 +512,12 @@ export async function handleToolsList(): Promise<{ tools: unknown[] }> {
method: "GET",
})) as { tools?: unknown } | null;
if (debug) {
const shape = remote === null
? "null"
: typeof remote !== "object"
? typeof remote
: `keys=${Object.keys(remote as object).join(",")} toolsType=${Array.isArray((remote as { tools?: unknown }).tools) ? `array(len=${((remote as { tools: unknown[] }).tools).length})` : typeof (remote as { tools?: unknown }).tools}`;
const shape =
remote === null
? "null"
: typeof remote !== "object"
? typeof remote
: `keys=${Object.keys(remote as object).join(",")} toolsType=${Array.isArray((remote as { tools?: unknown }).tools) ? `array(len=${(remote as { tools: unknown[] }).tools.length})` : typeof (remote as { tools?: unknown }).tools}`;
process.stderr.write(
`[@agentmemory/mcp] tools/list: remote response shape: ${shape}\n`,
);
Expand Down
Loading