diff --git a/AGENTS.md b/AGENTS.md index bb0c01fd..37991cb2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,7 +111,7 @@ Hook scripts in `src/hooks/` are standalone Node.js scripts (no iii-sdk import). ## Current Stats (v0.8.9) -- 44 MCP tools (8 visible by default, `AGENTMEMORY_TOOLS=all` for all) +- 45 MCP tools (8 visible by default, `AGENTMEMORY_TOOLS=all` for all) - 104 REST endpoints - 6 MCP resources, 3 MCP prompts - 12 hooks, 4 skills diff --git a/README.md b/README.md index 8e463090..1b824fde 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@

95.2% retrieval R@5 92% fewer tokens - 51 MCP tools + 52 MCP tools 12 auto hooks 0 external DBs 827 tests passing @@ -138,7 +138,7 @@ agentmemory works with any agent that supports hooks, MCP, or REST API. All agen AgentSDKProvider -REST API
+REST API
Any agent
REST API @@ -338,7 +338,7 @@ Implementation details live in `src/cli.ts` (see `runUpgrade` around the `src/cl ### Claude Code (one block, paste it) ``` -Install agentmemory: run `npx @agentmemory/agentmemory` in a separate terminal to start the memory server. Then run `/plugin marketplace add rohitg00/agentmemory` and `/plugin install agentmemory` — the plugin registers all 12 hooks, 4 skills, AND auto-wires the `@agentmemory/mcp` stdio server via its `.mcp.json`, so you get 51 MCP tools (memory_smart_search, memory_save, memory_sessions, memory_governance_delete, etc.) without any extra config step. Verify with `curl http://localhost:3111/agentmemory/health`. The real-time viewer is at http://localhost:3113. +Install agentmemory: run `npx @agentmemory/agentmemory` in a separate terminal to start the memory server. Then run `/plugin marketplace add rohitg00/agentmemory` and `/plugin install agentmemory` — the plugin registers all 12 hooks, 4 skills, AND auto-wires the `@agentmemory/mcp` stdio server via its `.mcp.json`, so you get 52 MCP tools (memory_smart_search, memory_save, memory_sessions, memory_governance_delete, etc.) without any extra config step. Verify with `curl http://localhost:3111/agentmemory/health`. The real-time viewer is at http://localhost:3113. ```

@@ -954,7 +954,7 @@ Create `~/.agentmemory/.env`:

API

-107 endpoints on port `3111`. The REST API binds to `127.0.0.1` by default. Protected endpoints require `Authorization: Bearer ` when `AGENTMEMORY_SECRET` is set, and mesh sync endpoints require `AGENTMEMORY_SECRET` on both peers. +108 endpoints on port `3111`. The REST API binds to `127.0.0.1` by default. Protected endpoints require `Authorization: Bearer ` when `AGENTMEMORY_SECRET` is set, and mesh sync endpoints require `AGENTMEMORY_SECRET` on both peers.
Key endpoints diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index b070050c..05b5efff 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "agentmemory", "version": "0.9.4", - "description": "Persistent memory for AI coding agents -- captures tool usage, compresses via LLM, injects context into future sessions. 12 hooks, 51 MCP tools, 4 skills, real-time viewer.", + "description": "Persistent memory for AI coding agents -- captures tool usage, compresses via LLM, injects context into future sessions. 12 hooks, 52 MCP tools, 4 skills, real-time viewer.", "author": { "name": "Rohit Ghumare", "url": "https://github.com/rohitg00" diff --git a/plugin/scripts/session-end.mjs b/plugin/scripts/session-end.mjs index 5a36612c..8e1de092 100755 --- a/plugin/scripts/session-end.mjs +++ b/plugin/scripts/session-end.mjs @@ -28,7 +28,7 @@ async function main() { method: "POST", headers: authHeaders(), body: JSON.stringify({ sessionId }), - signal: AbortSignal.timeout(5e3) + signal: AbortSignal.timeout(3e4) }); } catch {} if (process.env["CONSOLIDATION_ENABLED"] === "true") { @@ -37,7 +37,7 @@ async function main() { method: "POST", headers: authHeaders(), body: JSON.stringify({ olderThanDays: 0 }), - signal: AbortSignal.timeout(15e3) + signal: AbortSignal.timeout(6e4) }); } catch {} try { @@ -48,7 +48,7 @@ async function main() { tier: "all", force: true }), - signal: AbortSignal.timeout(3e4) + signal: AbortSignal.timeout(12e4) }); } catch {} } @@ -56,7 +56,7 @@ async function main() { await fetch(`${REST_URL}/agentmemory/claude-bridge/sync`, { method: "POST", headers: authHeaders(), - signal: AbortSignal.timeout(5e3) + signal: AbortSignal.timeout(3e4) }); } catch {} } diff --git a/plugin/scripts/stop.mjs b/plugin/scripts/stop.mjs index a234dbe5..e0ffa350 100755 --- a/plugin/scripts/stop.mjs +++ b/plugin/scripts/stop.mjs @@ -28,7 +28,7 @@ async function main() { method: "POST", headers: authHeaders(), body: JSON.stringify({ sessionId }), - signal: AbortSignal.timeout(3e4) + signal: AbortSignal.timeout(12e4) }); } catch {} } diff --git a/src/functions/concept-backfill.ts b/src/functions/concept-backfill.ts new file mode 100644 index 00000000..f5f4dbf3 --- /dev/null +++ b/src/functions/concept-backfill.ts @@ -0,0 +1,65 @@ +import type { ISdk } from "iii-sdk"; +import type { StateKV } from "../state/kv.js"; +import type { Memory } from "../types.js"; +import { KV } from "../state/schema.js"; +import { recordAudit } from "./audit.js"; +import { logger } from "../logger.js"; + +const CONFIG_KEY = "concept-backfill-done"; + +export function registerConceptBackfillFunction(sdk: ISdk, kv: StateKV): void { + sdk.registerFunction( + "mem::concept-backfill", + async () => { + const flag = await kv.get<{ done: boolean }>(KV.config, CONFIG_KEY); + if (flag?.done) { + return { success: true, skipped: true, reason: "already completed" }; + } + + const memories = await kv.list(KV.memories); + const eligible = memories.filter( + (m) => m.isLatest !== false && m.concepts && m.concepts.length >= 2, + ); + + let processed = 0; + let errors = 0; + const batchSize = 50; + + for (let i = 0; i < eligible.length; i += batchSize) { + const batch = eligible.slice(i, i + batchSize); + const results = await Promise.allSettled( + batch.map((m) => + sdk.trigger({ + function_id: "mem::concept-edge-upsert", + payload: { concepts: m.concepts }, + }) + ), + ); + for (const res of results) { + if (res.status === "rejected") errors++; + else processed++; + } + } + + if (errors > 0) { + throw new Error(`Concept backfill failed to process ${errors} items.`); + } + + await kv.set(KV.config, CONFIG_KEY, { done: true, completedAt: new Date().toISOString() }); + + try { + await recordAudit(kv, "concept_backfill", "mem::concept-backfill", [], { + memoriesProcessed: processed, + totalMemories: memories.length, + }); + } catch {} + + logger.info("Concept backfill completed", { + processed, + total: eligible.length, + }); + + return { success: true, processed, total: memories.length }; + }, + ); +} diff --git a/src/functions/concept-edges.ts b/src/functions/concept-edges.ts new file mode 100644 index 00000000..e1a547da --- /dev/null +++ b/src/functions/concept-edges.ts @@ -0,0 +1,84 @@ +import type { ISdk } from "iii-sdk"; +import type { StateKV } from "../state/kv.js"; +import type { ConceptEdge } from "../types.js"; +import { KV, fingerprintId } from "../state/schema.js"; +import { recordAudit } from "./audit.js"; + +function reinforceEdge(edge: ConceptEdge): void { + const now = new Date().toISOString(); + edge.reinforcements++; + edge.strength = Math.min( + 1.0, + edge.strength + 0.1 * (1 - edge.strength), + ); + edge.lastSeenAt = now; +} + +function generatePairs(concepts: string[]): Array<[string, string]> { + const normalized = [...new Set(concepts.map((c) => c.toLowerCase().trim()).filter(Boolean))]; + const pairs: Array<[string, string]> = []; + for (let i = 0; i < normalized.length; i++) { + for (let j = i + 1; j < normalized.length; j++) { + const [a, b] = normalized[i] < normalized[j] + ? [normalized[i], normalized[j]] + : [normalized[j], normalized[i]]; + pairs.push([a, b]); + } + } + return pairs; +} + +export function registerConceptEdgesFunctions(sdk: ISdk, kv: StateKV): void { + sdk.registerFunction( + "mem::concept-edge-upsert", + async (data: { concepts?: string[] }) => { + if (!data.concepts || !Array.isArray(data.concepts) || data.concepts.length < 2) { + return { success: false, error: "at least 2 concepts required" }; + } + + const pairs = generatePairs(data.concepts); + if (pairs.length === 0) { + return { success: true, created: 0, reinforced: 0 }; + } + + const now = new Date().toISOString(); + let created = 0; + let reinforced = 0; + + const edgeOps = pairs.map(async ([from, to]) => { + const id = fingerprintId("ce", `${from}|${to}`); + const existing = await kv.get(KV.conceptEdges, id); + + if (existing) { + reinforceEdge(existing); + await kv.set(KV.conceptEdges, id, existing); + reinforced++; + } else { + const edge: ConceptEdge = { + id, + from, + to, + strength: 0.5, + reinforcements: 0, + lastSeenAt: now, + createdAt: now, + }; + await kv.set(KV.conceptEdges, id, edge); + created++; + } + }); + + await Promise.all(edgeOps); + + try { + await recordAudit(kv, "concept_edge_upsert", "mem::concept-edge-upsert", [], { + pairs: pairs.length, + created, + reinforced, + }); + } catch {} + + return { success: true, created, reinforced }; + }, + ); +} diff --git a/src/functions/concept-graph-search.ts b/src/functions/concept-graph-search.ts new file mode 100644 index 00000000..2fce1d8e --- /dev/null +++ b/src/functions/concept-graph-search.ts @@ -0,0 +1,114 @@ +import type { ISdk } from "iii-sdk"; +import type { StateKV } from "../state/kv.js"; +import type { ConceptEdge, Memory } from "../types.js"; +import { KV } from "../state/schema.js"; + +const MAX_BFS_DEPTH = 2; +const MAX_NEIGHBORS_PER_NODE = 10; + +function decayedStrength(edge: ConceptEdge): number { + const timestamp = new Date(edge.lastSeenAt).getTime(); + if (Number.isNaN(timestamp)) return 0.05; + const daysSinceLastSeen = (Date.now() - timestamp) / (1000 * 60 * 60 * 24); + const decay = edge.strength * 0.05 * (daysSinceLastSeen / 7); + return Math.max(0.05, edge.strength - decay); +} + +export function registerConceptGraphSearchFunction(sdk: ISdk, kv: StateKV): void { + sdk.registerFunction( + "mem::concept-graph-search", + async (data: { concepts: string[]; depth?: number; limit?: number }) => { + if (!data.concepts || data.concepts.length === 0) { + return { success: false, error: "concepts array is required" }; + } + + const depth = data.depth ?? 2; + if (!Number.isInteger(depth) || depth < 1 || depth > MAX_BFS_DEPTH) { + return { + success: false, + error: "depth_out_of_range", + message: `BFS depth must be an integer between 1 and ${MAX_BFS_DEPTH}, got ${depth}`, + }; + } + + const limit = Math.max(1, Math.min(data.limit ?? 20, 100)); + const allEdges = await kv.list(KV.conceptEdges); + + const adjacency = new Map>(); + for (const edge of allEdges) { + const strength = decayedStrength(edge); + if (strength <= 0.05) continue; + + if (!adjacency.has(edge.from)) adjacency.set(edge.from, []); + if (!adjacency.has(edge.to)) adjacency.set(edge.to, []); + adjacency.get(edge.from)!.push({ concept: edge.to, strength }); + adjacency.get(edge.to)!.push({ concept: edge.from, strength }); + } + + const seedConcepts = data.concepts.map((c) => c.toLowerCase().trim()); + const visited = new Set(); + const conceptScores = new Map(); + + let frontier = new Set(); + for (const seed of seedConcepts) { + visited.add(seed); + conceptScores.set(seed, 1.0); + frontier.add(seed); + } + + for (let d = 0; d < depth; d++) { + const nextFrontier = new Set(); + for (const current of frontier) { + const neighbors = adjacency.get(current) || []; + const sorted = neighbors + .filter((n) => !visited.has(n.concept)) + .sort((a, b) => b.strength - a.strength) + .slice(0, MAX_NEIGHBORS_PER_NODE); + + for (const neighbor of sorted) { + if (visited.has(neighbor.concept)) continue; + visited.add(neighbor.concept); + + const parentScore = conceptScores.get(current) || 0; + conceptScores.set(neighbor.concept, parentScore * neighbor.strength); + nextFrontier.add(neighbor.concept); + } + } + frontier = nextFrontier; + } + + const expandedConcepts = [...conceptScores.keys()]; + + const allMemories = await kv.list(KV.memories); + const results: Array<{ memoryId: string; score: number; matchedConcepts: string[] }> = []; + + for (const memory of allMemories) { + if (memory.isLatest === false) continue; + const memoryConcepts = memory.concepts.map((c) => c.toLowerCase()); + const matched = memoryConcepts.filter((c) => expandedConcepts.includes(c)); + if (matched.length === 0) continue; + + let score = 0; + for (const mc of matched) { + score += conceptScores.get(mc) || 0; + } + score = score / matched.length; + + results.push({ + memoryId: memory.id, + score, + matchedConcepts: matched, + }); + } + + results.sort((a, b) => b.score - a.score); + + return { + success: true, + results: results.slice(0, limit), + expandedConcepts, + depth, + }; + }, + ); +} diff --git a/src/functions/remember.ts b/src/functions/remember.ts index 0fb389be..7bbfc65d 100644 --- a/src/functions/remember.ts +++ b/src/functions/remember.ts @@ -107,6 +107,14 @@ export function registerRememberFunction(sdk: ISdk, kv: StateKV): void { }); } + if (memory.concepts.length >= 2) { + sdk.trigger({ + function_id: "mem::concept-edge-upsert", + payload: { concepts: memory.concepts }, + action: TriggerAction.Void(), + }).catch(() => {}); + } + logger.info("Memory saved", { memId: memory.id, type: memory.type, diff --git a/src/functions/smart-search.ts b/src/functions/smart-search.ts index fdeed273..94a1d9e7 100644 --- a/src/functions/smart-search.ts +++ b/src/functions/smart-search.ts @@ -3,6 +3,7 @@ import type { CompactSearchResult, CompressedObservation, HybridSearchResult, + Memory, } from "../types.js"; import { KV } from "../state/schema.js"; import { StateKV } from "../state/kv.js"; @@ -19,6 +20,7 @@ export function registerSmartSearchFunction( query?: string; expandIds?: Array; limit?: number; + mode?: "graph"; }) => { if (data.expandIds && data.expandIds.length > 0) { @@ -84,11 +86,64 @@ export function registerSmartSearchFunction( compact.map((r) => r.obsId), ); + let finalMode = data.mode; + if (data.mode === "graph") { + let graphSuccess = false; + try { + const concepts = data.query + .toLowerCase() + .replace(/[^\w\s]/g, "") + .split(/\s+/) + .filter((t) => t.length > 1); + + const graphResult = await sdk.trigger< + { concepts: string[]; depth: number; limit: number }, + { success: boolean; results?: Array<{ memoryId: string; score: number; matchedConcepts: string[] }> } + >({ + function_id: "mem::concept-graph-search", + payload: { concepts, depth: 2, limit }, + }); + + if (graphResult.success && graphResult.results && graphResult.results.length > 0) { + graphSuccess = true; + const existingObsIds = new Set(compact.map((r) => r.obsId)); + const memories = await kv.list(KV.memories); + const memoryMap = new Map(memories.map((m) => [m.id, m])); + + for (const gr of graphResult.results) { + const mem = memoryMap.get(gr.memoryId); + if (!mem) continue; + for (const obsId of mem.sourceObservationIds || []) { + if (existingObsIds.has(obsId)) continue; + existingObsIds.add(obsId); + compact.push({ + obsId, + sessionId: "", + title: mem.title, + type: (mem.type as "observation" | "message" | "tool_call" | "tool_result" | "tool_error") || "observation", + score: gr.score * 0.8, + timestamp: mem.createdAt, + }); + } + } + + compact.sort((a, b) => b.score - a.score); + compact.splice(limit); + } + } catch (e) { + logger.error("Graph search failed, falling back to compact", { error: e }); + } + if (!graphSuccess) { + finalMode = "compact"; + } + } + logger.info("Smart search compact", { query: data.query, results: compact.length, + mode: finalMode, }); - return { mode: "compact", results: compact }; + return { mode: finalMode, results: compact }; }, ); } diff --git a/src/index.ts b/src/index.ts index 6385a96c..6ce1eb6c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -82,6 +82,9 @@ import { registerTemporalGraphFunctions } from "./functions/temporal-graph.js"; import { registerRetentionFunctions } from "./functions/retention.js"; import { registerCompressFileFunction } from "./functions/compress-file.js"; import { registerReplayFunctions } from "./functions/replay.js"; +import { registerConceptEdgesFunctions } from "./functions/concept-edges.js"; +import { registerConceptGraphSearchFunction } from "./functions/concept-graph-search.js"; +import { registerConceptBackfillFunction } from "./functions/concept-backfill.js"; import { registerApiTriggers } from "./triggers/api.js"; import { registerEventTriggers } from "./triggers/events.js"; import { registerMcpEndpoints } from "./mcp/server.js"; @@ -92,6 +95,7 @@ import { DedupMap } from "./functions/dedup.js"; import { registerHealthMonitor } from "./health/monitor.js"; import { initMetrics, OTEL_CONFIG } from "./telemetry/setup.js"; import { VERSION } from "./version.js"; +import { logger } from "./logger.js"; function hasGetMeter( sdk: unknown, @@ -281,6 +285,9 @@ async function main() { registerRetentionFunctions(sdk, kv); registerCompressFileFunction(sdk, kv, provider); registerReplayFunctions(sdk, kv); + registerConceptEdgesFunctions(sdk, kv); + registerConceptGraphSearchFunction(sdk, kv); + registerConceptBackfillFunction(sdk, kv); console.log( `[agentmemory] v0.6 advanced retrieval: sliding-window, query-expansion, temporal-graph, retention-scoring`, ); @@ -361,9 +368,13 @@ async function main() { `[agentmemory] Ready. ${embeddingProvider ? "Triple-stream (BM25+Vector+Graph)" : "BM25+Graph"} search active.`, ); console.log( - `[agentmemory] Endpoints: 107 REST + ${getAllTools().length} MCP tools + 6 MCP resources + 3 MCP prompts`, + `[agentmemory] Endpoints: 108 REST + ${getAllTools().length} MCP tools + 6 MCP resources + 3 MCP prompts`, ); + sdk.trigger({ function_id: "mem::concept-backfill", payload: {} }).catch((e) => { + logger.error("Concept backfill failed", { error: e }); + }); + const viewerPort = config.restPort + 2; const viewerServer = startViewerServer( viewerPort, @@ -380,7 +391,7 @@ async function main() { const autoForgetTimer = setInterval(async () => { try { await sdk.trigger({ function_id: "mem::auto-forget", payload: { dryRun: false } }); - } catch {} + } catch { } }, autoForgetIntervalMs); autoForgetTimer.unref(); console.log(`[agentmemory] Auto-forget: enabled (every ${autoForgetIntervalMs / 60000}m)`); @@ -390,7 +401,7 @@ async function main() { const lessonDecayTimer = setInterval(async () => { try { await sdk.trigger({ function_id: "mem::lesson-decay-sweep", payload: {} }); - } catch {} + } catch { } }, 86400000); lessonDecayTimer.unref(); console.log(`[agentmemory] Lesson decay sweep: enabled (every 24h)`); @@ -400,7 +411,7 @@ async function main() { const insightDecayTimer = setInterval(async () => { try { await sdk.trigger({ function_id: "mem::insight-decay-sweep", payload: {} }); - } catch {} + } catch { } }, 86400000); insightDecayTimer.unref(); } @@ -409,7 +420,7 @@ async function main() { const consolidationTimer = setInterval(async () => { try { await sdk.trigger({ function_id: "mem::consolidate-pipeline", payload: {} }); - } catch {} + } catch { } }, consolidationIntervalMs); consolidationTimer.unref(); console.log(`[agentmemory] Auto-consolidation: enabled (every ${consolidationIntervalMs / 60000}m)`); diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 21c2069a..b8ea9c00 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -460,6 +460,37 @@ export function registerMcpEndpoints( } } + case "memory_graph_search": { + if (typeof args.query !== "string") { + return { status_code: 400, body: { error: "query must be a string" } }; + } + const depth = asNumber(args.depth, 2) ?? 2; + if (!Number.isInteger(depth) || depth < 1 || depth > 2) { + return { + status_code: 400, + body: { error: "depth_out_of_range", message: `BFS depth must be an integer between 1 and 2, got ${depth}` }, + }; + } + const limit = Math.max(1, Math.min(100, asNumber(args.limit, 20) ?? 20)); + const concepts = args.query.split(/\s+/).map((t: string) => t.trim().toLowerCase()).filter((t: string) => t.length > 1); + if (concepts.length === 0) { + return { + status_code: 400, + body: { error: "query_empty", message: "Query must contain at least one valid concept word." }, + }; + } + const result = await sdk.trigger({ + function_id: "mem::concept-graph-search", + payload: { concepts, depth, limit }, + }); + return { + status_code: 200, + body: { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }, + }; + } + case "memory_consolidate": { try { const result = await sdk.trigger({ function_id: "mem::consolidate-pipeline", payload: { diff --git a/src/mcp/tools-registry.ts b/src/mcp/tools-registry.ts index 82d7e7ce..c85842f1 100644 --- a/src/mcp/tools-registry.ts +++ b/src/mcp/tools-registry.ts @@ -903,6 +903,26 @@ const ESSENTIAL_TOOLS = new Set([ "memory_reflect", ]); +export const CONCEPT_GRAPH_TOOLS: McpToolDef[] = [ + { + name: "memory_graph_search", + description: + "Graph-enhanced search that expands query concepts via co-occurrence relationships. Finds memories related to your query even when they use different terminology. Separate from memory_smart_search so latency profile is opt-in.", + inputSchema: { + type: "object", + properties: { + query: { type: "string", description: "Search query" }, + depth: { + type: "number", + description: "BFS expansion depth (1 or 2, default 2). Depth 3+ is refused.", + }, + limit: { type: "number", description: "Max results (default 20)" }, + }, + required: ["query"], + }, + }, +]; + export function getAllTools(): McpToolDef[] { return [ ...CORE_TOOLS, @@ -913,6 +933,7 @@ export function getAllTools(): McpToolDef[] { ...V070_TOOLS, ...V073_TOOLS, ...V010_SLOTS_TOOLS, + ...CONCEPT_GRAPH_TOOLS, ]; } diff --git a/src/state/schema.ts b/src/state/schema.ts index 27f1958d..099898a3 100644 --- a/src/state/schema.ts +++ b/src/state/schema.ts @@ -46,6 +46,7 @@ export const KV = { slots: "mem:slots", globalSlots: "mem:slots:global", state: "mem:state", + conceptEdges: "mem:concept-edges", } as const; export const STREAM = { diff --git a/src/triggers/api.ts b/src/triggers/api.ts index 6a65f233..d768caec 100644 --- a/src/triggers/api.ts +++ b/src/triggers/api.ts @@ -224,7 +224,7 @@ export function registerApiTriggers( }, }); - sdk.registerFunction("api::health", + sdk.registerFunction("api::health", async (req: ApiRequest): Promise => { const health = await getLatestHealth(kv); const functionMetrics = metricsStore ? await metricsStore.getAll() : []; @@ -335,7 +335,7 @@ export function registerApiTriggers( }, }); - sdk.registerFunction("api::search", + sdk.registerFunction("api::search", async ( req: ApiRequest<{ query: string; @@ -406,7 +406,7 @@ export function registerApiTriggers( }, }); - sdk.registerFunction("api::compress-file", + sdk.registerFunction("api::compress-file", async (req: ApiRequest<{ filePath: string }>): Promise => { const authErr = checkAuth(req, secret); if (authErr) return authErr; @@ -586,7 +586,7 @@ export function registerApiTriggers( }, }); - sdk.registerFunction("api::summarize", + sdk.registerFunction("api::summarize", async (req: ApiRequest<{ sessionId: string }>): Promise => { const sessionId = asNonEmptyString((req.body as Record)?.sessionId); if (!sessionId) { @@ -609,7 +609,7 @@ export function registerApiTriggers( }, }); - sdk.registerFunction("api::sessions", + sdk.registerFunction("api::sessions", async (req: ApiRequest): Promise => { const authErr = checkAuth(req, secret); if (authErr) return authErr; @@ -623,7 +623,7 @@ export function registerApiTriggers( config: { api_path: "/agentmemory/sessions", http_method: "GET" }, }); - sdk.registerFunction("api::observations", + sdk.registerFunction("api::observations", async (req: ApiRequest): Promise => { const authErr = checkAuth(req, secret); if (authErr) return authErr; @@ -642,7 +642,7 @@ export function registerApiTriggers( config: { api_path: "/agentmemory/observations", http_method: "GET" }, }); - sdk.registerFunction("api::file-context", + sdk.registerFunction("api::file-context", async ( req: ApiRequest<{ sessionId: string; files: string[] }>, ): Promise => { @@ -658,7 +658,7 @@ export function registerApiTriggers( config: { api_path: "/agentmemory/file-context", http_method: "POST" }, }); - sdk.registerFunction("api::enrich", + sdk.registerFunction("api::enrich", async ( req: ApiRequest<{ sessionId: string; @@ -703,7 +703,7 @@ export function registerApiTriggers( config: { api_path: "/agentmemory/enrich", http_method: "POST" }, }); - sdk.registerFunction("api::remember", + sdk.registerFunction("api::remember", async ( req: ApiRequest<{ content: string; @@ -731,7 +731,7 @@ export function registerApiTriggers( config: { api_path: "/agentmemory/remember", http_method: "POST" }, }); - sdk.registerFunction("api::forget", + sdk.registerFunction("api::forget", async ( req: ApiRequest<{ sessionId?: string; @@ -757,7 +757,7 @@ export function registerApiTriggers( config: { api_path: "/agentmemory/forget", http_method: "POST" }, }); - sdk.registerFunction("api::consolidate", + sdk.registerFunction("api::consolidate", async ( req: ApiRequest<{ project?: string; minObservations?: number }>, ): Promise => { @@ -773,7 +773,7 @@ export function registerApiTriggers( config: { api_path: "/agentmemory/consolidate", http_method: "POST" }, }); - sdk.registerFunction("api::patterns", + sdk.registerFunction("api::patterns", async (req: ApiRequest<{ project?: string }>): Promise => { const authErr = checkAuth(req, secret); if (authErr) return authErr; @@ -787,7 +787,7 @@ export function registerApiTriggers( config: { api_path: "/agentmemory/patterns", http_method: "POST" }, }); - sdk.registerFunction("api::generate-rules", + sdk.registerFunction("api::generate-rules", async (req: ApiRequest<{ project?: string }>): Promise => { const authErr = checkAuth(req, secret); if (authErr) return authErr; @@ -801,7 +801,7 @@ export function registerApiTriggers( config: { api_path: "/agentmemory/generate-rules", http_method: "POST" }, }); - sdk.registerFunction("api::migrate", + sdk.registerFunction("api::migrate", async (req: ApiRequest<{ dbPath: string }>): Promise => { const authErr = checkAuth(req, secret); if (authErr) return authErr; @@ -818,7 +818,7 @@ export function registerApiTriggers( config: { api_path: "/agentmemory/migrate", http_method: "POST" }, }); - sdk.registerFunction("api::evict", + sdk.registerFunction("api::evict", async (req: ApiRequest<{ dryRun?: boolean }>): Promise => { const authErr = checkAuth(req, secret); if (authErr) return authErr; @@ -834,9 +834,9 @@ export function registerApiTriggers( config: { api_path: "/agentmemory/evict", http_method: "POST" }, }); - sdk.registerFunction("api::smart-search", + sdk.registerFunction("api::smart-search", async ( - req: ApiRequest<{ query?: string; expandIds?: string[]; limit?: number }>, + req: ApiRequest<{ query?: string; expandIds?: string[]; limit?: number; mode?: string }>, ): Promise => { const authErr = checkAuth(req, secret); if (authErr) return authErr; @@ -849,7 +849,15 @@ export function registerApiTriggers( body: { error: "query or expandIds is required" }, }; } - const result = await sdk.trigger({ function_id: "mem::smart-search", payload: req.body }); + const body = (req.body ?? {}) as Record; + const payload: Record = {}; + if (typeof body.query === "string") payload.query = body.query; + if (Array.isArray(body.expandIds)) { + payload.expandIds = body.expandIds.filter((id) => typeof id === "string"); + } + if (typeof body.limit === "number") payload.limit = body.limit; + if (body.mode === "graph") payload.mode = "graph"; + const result = await sdk.trigger({ function_id: "mem::smart-search", payload }); return { status_code: 200, body: result }; }, ); @@ -859,7 +867,7 @@ export function registerApiTriggers( config: { api_path: "/agentmemory/smart-search", http_method: "POST" }, }); - sdk.registerFunction("api::timeline", + sdk.registerFunction("api::timeline", async ( req: ApiRequest<{ anchor: string; @@ -883,7 +891,7 @@ export function registerApiTriggers( config: { api_path: "/agentmemory/timeline", http_method: "POST" }, }); - sdk.registerFunction("api::profile", + sdk.registerFunction("api::profile", async (req: ApiRequest): Promise => { const authErr = checkAuth(req, secret); if (authErr) return authErr; @@ -904,7 +912,24 @@ export function registerApiTriggers( config: { api_path: "/agentmemory/profile", http_method: "GET" }, }); - sdk.registerFunction("api::export", + sdk.registerFunction("api::concept-graph-viewer", + async (req: ApiRequest): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + let limit = parseInt((req.query_params["limit"] as string) || "100", 10); + if (Number.isNaN(limit)) limit = 100; + limit = Math.max(1, Math.min(limit, 1000)); + const result = await sdk.trigger({ function_id: "mem::concept-graph-viewer", payload: { limit } }); + return { status_code: 200, body: result }; + } + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::concept-graph-viewer", + config: { api_path: "/agentmemory/concept-graph-viewer", http_method: "GET" }, + }); + + sdk.registerFunction("api::export", async (req: ApiRequest): Promise => { const authErr = checkAuth(req, secret); if (authErr) return authErr; @@ -918,7 +943,7 @@ export function registerApiTriggers( config: { api_path: "/agentmemory/export", http_method: "GET" }, }); - sdk.registerFunction("api::import", + sdk.registerFunction("api::import", async ( req: ApiRequest<{ exportData: unknown; @@ -940,7 +965,7 @@ export function registerApiTriggers( config: { api_path: "/agentmemory/import", http_method: "POST" }, }); - sdk.registerFunction("api::relations", + sdk.registerFunction("api::relations", async ( req: ApiRequest<{ sourceId: string; targetId: string; type: string }>, ): Promise => { @@ -962,7 +987,7 @@ export function registerApiTriggers( config: { api_path: "/agentmemory/relations", http_method: "POST" }, }); - sdk.registerFunction("api::evolve", + sdk.registerFunction("api::evolve", async ( req: ApiRequest<{ memoryId: string; @@ -988,7 +1013,7 @@ export function registerApiTriggers( config: { api_path: "/agentmemory/evolve", http_method: "POST" }, }); - sdk.registerFunction("api::auto-forget", + sdk.registerFunction("api::auto-forget", async (req: ApiRequest<{ dryRun?: boolean }>): Promise => { const authErr = checkAuth(req, secret); if (authErr) return authErr; @@ -1004,7 +1029,7 @@ export function registerApiTriggers( config: { api_path: "/agentmemory/auto-forget", http_method: "POST" }, }); - sdk.registerFunction("api::claude-bridge-read", + sdk.registerFunction("api::claude-bridge-read", async (req: ApiRequest): Promise => { const authErr = checkAuth(req, secret); if (authErr) return authErr; @@ -1025,7 +1050,7 @@ export function registerApiTriggers( config: { api_path: "/agentmemory/claude-bridge/read", http_method: "GET" }, }); - sdk.registerFunction("api::claude-bridge-sync", + sdk.registerFunction("api::claude-bridge-sync", async (req: ApiRequest): Promise => { const authErr = checkAuth(req, secret); if (authErr) return authErr; @@ -1049,7 +1074,7 @@ export function registerApiTriggers( }, }); - sdk.registerFunction("api::graph-query", + sdk.registerFunction("api::graph-query", async ( req: ApiRequest<{ startNodeId?: string; @@ -1074,7 +1099,7 @@ export function registerApiTriggers( config: { api_path: "/agentmemory/graph/query", http_method: "POST" }, }); - sdk.registerFunction("api::graph-stats", + sdk.registerFunction("api::graph-stats", async (req: ApiRequest): Promise => { const authErr = checkAuth(req, secret); if (authErr) return authErr; @@ -1092,7 +1117,7 @@ export function registerApiTriggers( config: { api_path: "/agentmemory/graph/stats", http_method: "GET" }, }); - sdk.registerFunction("api::graph-extract", + sdk.registerFunction("api::graph-extract", async (req: ApiRequest<{ observations: unknown[] }>): Promise => { const authErr = checkAuth(req, secret); if (authErr) return authErr; @@ -1119,13 +1144,14 @@ export function registerApiTriggers( config: { api_path: "/agentmemory/graph/extract", http_method: "POST" }, }); - sdk.registerFunction("api::consolidate-pipeline", + sdk.registerFunction("api::consolidate-pipeline", async (req: ApiRequest<{ tier?: string }>): Promise => { const authErr = checkAuth(req, secret); if (authErr) return authErr; try { - const result = await sdk.trigger({ function_id: "mem::consolidate-pipeline", payload: req.body || {}, - }); + const result = await sdk.trigger({ + function_id: "mem::consolidate-pipeline", payload: req.body || {}, + }); return { status_code: 200, body: result }; } catch { return consolidationDisabledResponse(); @@ -1141,7 +1167,7 @@ export function registerApiTriggers( }, }); - sdk.registerFunction("api::team-share", + sdk.registerFunction("api::team-share", async ( req: ApiRequest<{ itemId: string; itemType: string; project?: string }>, ): Promise => { @@ -1167,7 +1193,7 @@ export function registerApiTriggers( config: { api_path: "/agentmemory/team/share", http_method: "POST" }, }); - sdk.registerFunction("api::team-feed", + sdk.registerFunction("api::team-feed", async (req: ApiRequest): Promise => { const authErr = checkAuth(req, secret); if (authErr) return authErr; @@ -1187,7 +1213,7 @@ export function registerApiTriggers( config: { api_path: "/agentmemory/team/feed", http_method: "GET" }, }); - sdk.registerFunction("api::team-profile", + sdk.registerFunction("api::team-profile", async (req: ApiRequest): Promise => { const authErr = checkAuth(req, secret); if (authErr) return authErr; @@ -1210,10 +1236,12 @@ export function registerApiTriggers( const authErr = checkAuth(req, secret); if (authErr) return authErr; const parsedLimit = parseOptionalInt(req.query_params?.["limit"]); - const entries = await sdk.trigger({ function_id: "mem::audit-query", payload: { - operation: req.query_params?.["operation"], - limit: parsedLimit ?? 50, - } }); + const entries = await sdk.trigger({ + function_id: "mem::audit-query", payload: { + operation: req.query_params?.["operation"], + limit: parsedLimit ?? 50, + } + }); return { status_code: 200, body: { entries, success: true } }; }, ); @@ -1223,7 +1251,7 @@ export function registerApiTriggers( config: { api_path: "/agentmemory/audit", http_method: "GET" }, }); - sdk.registerFunction("api::governance-delete", + sdk.registerFunction("api::governance-delete", async ( req: ApiRequest<{ memoryIds: string[]; reason?: string }>, ): Promise => { @@ -1248,7 +1276,7 @@ export function registerApiTriggers( }, }); - sdk.registerFunction("api::governance-bulk", + sdk.registerFunction("api::governance-bulk", async ( req: ApiRequest<{ type?: string[]; @@ -1273,7 +1301,7 @@ export function registerApiTriggers( }, }); - sdk.registerFunction("api::snapshots", + sdk.registerFunction("api::snapshots", async (req: ApiRequest): Promise => { const authErr = checkAuth(req, secret); if (authErr) return authErr; @@ -1291,13 +1319,14 @@ export function registerApiTriggers( config: { api_path: "/agentmemory/snapshots", http_method: "GET" }, }); - sdk.registerFunction("api::snapshot-create", + sdk.registerFunction("api::snapshot-create", async (req: ApiRequest<{ message?: string }>): Promise => { const authErr = checkAuth(req, secret); if (authErr) return authErr; try { - const result = await sdk.trigger({ function_id: "mem::snapshot-create", payload: req.body || {}, - }); + const result = await sdk.trigger({ + function_id: "mem::snapshot-create", payload: req.body || {}, + }); return { status_code: 201, body: result }; } catch { return { status_code: 404, body: { error: "Snapshots not enabled" } }; @@ -1310,7 +1339,7 @@ export function registerApiTriggers( config: { api_path: "/agentmemory/snapshot/create", http_method: "POST" }, }); - sdk.registerFunction("api::snapshot-restore", + sdk.registerFunction("api::snapshot-restore", async (req: ApiRequest<{ commitHash: string }>): Promise => { const authErr = checkAuth(req, secret); if (authErr) return authErr; @@ -1667,7 +1696,7 @@ export function registerApiTriggers( config: { api_path: "/agentmemory/actions", http_method: "POST" }, }); - sdk.registerFunction("api::action-update", + sdk.registerFunction("api::action-update", async ( req: ApiRequest<{ actionId: string; @@ -1693,15 +1722,17 @@ export function registerApiTriggers( config: { api_path: "/agentmemory/actions/update", http_method: "POST" }, }); - sdk.registerFunction("api::action-list", + sdk.registerFunction("api::action-list", async (req: ApiRequest): Promise => { const authErr = checkAuth(req, secret); if (authErr) return authErr; - const result = await sdk.trigger({ function_id: "mem::action-list", payload: { - status: req.query_params?.["status"], - project: req.query_params?.["project"], - parentId: req.query_params?.["parentId"], - } }); + const result = await sdk.trigger({ + function_id: "mem::action-list", payload: { + status: req.query_params?.["status"], + project: req.query_params?.["project"], + parentId: req.query_params?.["parentId"], + } + }); return { status_code: 200, body: result }; }, ); @@ -1711,7 +1742,7 @@ export function registerApiTriggers( config: { api_path: "/agentmemory/actions", http_method: "GET" }, }); - sdk.registerFunction("api::action-get", + sdk.registerFunction("api::action-get", async (req: ApiRequest<{ actionId: string }>): Promise => { const authErr = checkAuth(req, secret); if (authErr) return authErr; @@ -1729,7 +1760,7 @@ export function registerApiTriggers( config: { api_path: "/agentmemory/actions/get", http_method: "GET" }, }); - sdk.registerFunction("api::action-edge", + sdk.registerFunction("api::action-edge", async ( req: ApiRequest<{ sourceActionId: string; @@ -1752,16 +1783,18 @@ export function registerApiTriggers( config: { api_path: "/agentmemory/actions/edges", http_method: "POST" }, }); - sdk.registerFunction("api::frontier", + sdk.registerFunction("api::frontier", async (req: ApiRequest): Promise => { const authErr = checkAuth(req, secret); if (authErr) return authErr; const parsedLimit = parseOptionalInt(req.query_params?.["limit"]); - const result = await sdk.trigger({ function_id: "mem::frontier", payload: { - project: req.query_params?.["project"], - agentId: req.query_params?.["agentId"], - limit: parsedLimit, - } }); + const result = await sdk.trigger({ + function_id: "mem::frontier", payload: { + project: req.query_params?.["project"], + agentId: req.query_params?.["agentId"], + limit: parsedLimit, + } + }); return { status_code: 200, body: result }; }, ); @@ -1771,14 +1804,16 @@ export function registerApiTriggers( config: { api_path: "/agentmemory/frontier", http_method: "GET" }, }); - sdk.registerFunction("api::next", + sdk.registerFunction("api::next", async (req: ApiRequest): Promise => { const authErr = checkAuth(req, secret); if (authErr) return authErr; - const result = await sdk.trigger({ function_id: "mem::next", payload: { - project: req.query_params?.["project"], - agentId: req.query_params?.["agentId"], - } }); + const result = await sdk.trigger({ + function_id: "mem::next", payload: { + project: req.query_params?.["project"], + agentId: req.query_params?.["agentId"], + } + }); return { status_code: 200, body: result }; }, ); @@ -1788,7 +1823,7 @@ export function registerApiTriggers( config: { api_path: "/agentmemory/next", http_method: "GET" }, }); - sdk.registerFunction("api::lease-acquire", + sdk.registerFunction("api::lease-acquire", async ( req: ApiRequest<{ actionId: string; agentId: string; ttlMs?: number }>, ): Promise => { @@ -1807,7 +1842,7 @@ export function registerApiTriggers( config: { api_path: "/agentmemory/leases/acquire", http_method: "POST" }, }); - sdk.registerFunction("api::lease-release", + sdk.registerFunction("api::lease-release", async ( req: ApiRequest<{ actionId: string; agentId: string; result?: string }>, ): Promise => { @@ -1826,7 +1861,7 @@ export function registerApiTriggers( config: { api_path: "/agentmemory/leases/release", http_method: "POST" }, }); - sdk.registerFunction("api::lease-renew", + sdk.registerFunction("api::lease-renew", async ( req: ApiRequest<{ actionId: string; agentId: string; ttlMs?: number }>, ): Promise => { @@ -1865,13 +1900,15 @@ export function registerApiTriggers( config: { api_path: "/agentmemory/routines", http_method: "POST" }, }); - sdk.registerFunction("api::routine-list", + sdk.registerFunction("api::routine-list", async (req: ApiRequest): Promise => { const authErr = checkAuth(req, secret); if (authErr) return authErr; - const result = await sdk.trigger({ function_id: "mem::routine-list", payload: { - frozen: req.query_params?.["frozen"] === "true" ? true : undefined, - } }); + const result = await sdk.trigger({ + function_id: "mem::routine-list", payload: { + frozen: req.query_params?.["frozen"] === "true" ? true : undefined, + } + }); return { status_code: 200, body: result }; }, ); @@ -1881,7 +1918,7 @@ export function registerApiTriggers( config: { api_path: "/agentmemory/routines", http_method: "GET" }, }); - sdk.registerFunction("api::routine-run", + sdk.registerFunction("api::routine-run", async ( req: ApiRequest<{ routineId: string; project?: string; initiatedBy?: string }>, ): Promise => { @@ -1900,7 +1937,7 @@ export function registerApiTriggers( config: { api_path: "/agentmemory/routines/run", http_method: "POST" }, }); - sdk.registerFunction("api::routine-status", + sdk.registerFunction("api::routine-status", async (req: ApiRequest): Promise => { const authErr = checkAuth(req, secret); if (authErr) return authErr; @@ -1918,7 +1955,7 @@ export function registerApiTriggers( config: { api_path: "/agentmemory/routines/status", http_method: "GET" }, }); - sdk.registerFunction("api::signal-send", + sdk.registerFunction("api::signal-send", async ( req: ApiRequest<{ from: string; @@ -1943,7 +1980,7 @@ export function registerApiTriggers( config: { api_path: "/agentmemory/signals/send", http_method: "POST" }, }); - sdk.registerFunction("api::signal-read", + sdk.registerFunction("api::signal-read", async (req: ApiRequest): Promise => { const authErr = checkAuth(req, secret); if (authErr) return authErr; @@ -1952,12 +1989,14 @@ export function registerApiTriggers( return { status_code: 400, body: { error: "agentId query param required" } }; } const parsedLimit = parseOptionalInt(req.query_params?.["limit"]); - const result = await sdk.trigger({ function_id: "mem::signal-read", payload: { - agentId, - unreadOnly: req.query_params?.["unreadOnly"] === "true", - threadId: req.query_params?.["threadId"], - limit: parsedLimit, - } }); + const result = await sdk.trigger({ + function_id: "mem::signal-read", payload: { + agentId, + unreadOnly: req.query_params?.["unreadOnly"] === "true", + threadId: req.query_params?.["threadId"], + limit: parsedLimit, + } + }); return { status_code: 200, body: result }; }, ); @@ -1967,7 +2006,7 @@ export function registerApiTriggers( config: { api_path: "/agentmemory/signals", http_method: "GET" }, }); - sdk.registerFunction("api::checkpoint-create", + sdk.registerFunction("api::checkpoint-create", async ( req: ApiRequest<{ name: string; @@ -1992,7 +2031,7 @@ export function registerApiTriggers( config: { api_path: "/agentmemory/checkpoints", http_method: "POST" }, }); - sdk.registerFunction("api::checkpoint-resolve", + sdk.registerFunction("api::checkpoint-resolve", async ( req: ApiRequest<{ checkpointId: string; @@ -2016,14 +2055,16 @@ export function registerApiTriggers( config: { api_path: "/agentmemory/checkpoints/resolve", http_method: "POST" }, }); - sdk.registerFunction("api::checkpoint-list", + sdk.registerFunction("api::checkpoint-list", async (req: ApiRequest): Promise => { const authErr = checkAuth(req, secret); if (authErr) return authErr; - const result = await sdk.trigger({ function_id: "mem::checkpoint-list", payload: { - status: req.query_params?.["status"], - type: req.query_params?.["type"], - } }); + const result = await sdk.trigger({ + function_id: "mem::checkpoint-list", payload: { + status: req.query_params?.["status"], + type: req.query_params?.["type"], + } + }); return { status_code: 200, body: result }; }, ); @@ -2033,7 +2074,7 @@ export function registerApiTriggers( config: { api_path: "/agentmemory/checkpoints", http_method: "GET" }, }); - sdk.registerFunction("api::mesh-register", + sdk.registerFunction("api::mesh-register", async ( req: ApiRequest<{ url: string; name: string; sharedScopes?: string[] }>, ): Promise => { @@ -2054,7 +2095,7 @@ export function registerApiTriggers( config: { api_path: "/agentmemory/mesh/peers", http_method: "POST" }, }); - sdk.registerFunction("api::mesh-list", + sdk.registerFunction("api::mesh-list", async (req: ApiRequest): Promise => { const secretErr = requireConfiguredSecret(secret, "mesh"); if (secretErr) return secretErr; @@ -2070,7 +2111,7 @@ export function registerApiTriggers( config: { api_path: "/agentmemory/mesh/peers", http_method: "GET" }, }); - sdk.registerFunction("api::mesh-sync", + sdk.registerFunction("api::mesh-sync", async ( req: ApiRequest<{ peerId?: string; direction?: string }>, ): Promise => { @@ -2088,7 +2129,7 @@ export function registerApiTriggers( config: { api_path: "/agentmemory/mesh/sync", http_method: "POST" }, }); - sdk.registerFunction("api::mesh-receive", + sdk.registerFunction("api::mesh-receive", async (req: ApiRequest): Promise => { const secretErr = requireConfiguredSecret(secret, "mesh"); if (secretErr) return secretErr; @@ -2104,7 +2145,7 @@ export function registerApiTriggers( config: { api_path: "/agentmemory/mesh/receive", http_method: "POST" }, }); - sdk.registerFunction("api::mesh-export", + sdk.registerFunction("api::mesh-export", async (req: ApiRequest): Promise => { const secretErr = requireConfiguredSecret(secret, "mesh"); if (secretErr) return secretErr; @@ -2153,7 +2194,7 @@ export function registerApiTriggers( config: { api_path: "/agentmemory/mesh/export", http_method: "GET" }, }); - sdk.registerFunction("api::flow-compress", + sdk.registerFunction("api::flow-compress", async ( req: ApiRequest<{ runId?: string; @@ -2180,7 +2221,7 @@ export function registerApiTriggers( config: { api_path: "/agentmemory/flow/compress", http_method: "POST" }, }); - sdk.registerFunction("api::branch-detect", + sdk.registerFunction("api::branch-detect", async (req: ApiRequest): Promise => { const authErr = checkAuth(req, secret); if (authErr) return authErr; @@ -2195,7 +2236,7 @@ export function registerApiTriggers( config: { api_path: "/agentmemory/branch/detect", http_method: "GET" }, }); - sdk.registerFunction("api::branch-worktrees", + sdk.registerFunction("api::branch-worktrees", async (req: ApiRequest): Promise => { const authErr = checkAuth(req, secret); if (authErr) return authErr; @@ -2210,7 +2251,7 @@ export function registerApiTriggers( config: { api_path: "/agentmemory/branch/worktrees", http_method: "GET" }, }); - sdk.registerFunction("api::branch-sessions", + sdk.registerFunction("api::branch-sessions", async (req: ApiRequest): Promise => { const authErr = checkAuth(req, secret); if (authErr) return authErr; @@ -2225,7 +2266,7 @@ export function registerApiTriggers( config: { api_path: "/agentmemory/branch/sessions", http_method: "GET" }, }); - sdk.registerFunction("api::viewer", + sdk.registerFunction("api::viewer", async (req: ApiRequest): Promise => { const denied = checkAuth(req, secret); if (denied) return denied; @@ -2255,7 +2296,7 @@ export function registerApiTriggers( config: { api_path: "/agentmemory/viewer", http_method: "GET" }, }); - sdk.registerFunction("api::sentinel-create", async (req: ApiRequest) => { + sdk.registerFunction("api::sentinel-create", async (req: ApiRequest) => { const denied = checkAuth(req, secret); if (denied) return denied; const body = req.body as Record; @@ -2265,7 +2306,7 @@ export function registerApiTriggers( }); sdk.registerTrigger({ type: "http", function_id: "api::sentinel-create", config: { api_path: "/agentmemory/sentinels", http_method: "POST" } }); - sdk.registerFunction("api::sentinel-trigger", async (req: ApiRequest) => { + sdk.registerFunction("api::sentinel-trigger", async (req: ApiRequest) => { const denied = checkAuth(req, secret); if (denied) return denied; const body = req.body as Record; @@ -2275,7 +2316,7 @@ export function registerApiTriggers( }); sdk.registerTrigger({ type: "http", function_id: "api::sentinel-trigger", config: { api_path: "/agentmemory/sentinels/trigger", http_method: "POST" } }); - sdk.registerFunction("api::sentinel-check", async (req: ApiRequest) => { + sdk.registerFunction("api::sentinel-check", async (req: ApiRequest) => { const denied = checkAuth(req, secret); if (denied) return denied; const result = await sdk.trigger({ function_id: "mem::sentinel-check", payload: {} }); @@ -2283,7 +2324,7 @@ export function registerApiTriggers( }); sdk.registerTrigger({ type: "http", function_id: "api::sentinel-check", config: { api_path: "/agentmemory/sentinels/check", http_method: "POST" } }); - sdk.registerFunction("api::sentinel-cancel", async (req: ApiRequest) => { + sdk.registerFunction("api::sentinel-cancel", async (req: ApiRequest) => { const denied = checkAuth(req, secret); if (denied) return denied; const body = req.body as Record; @@ -2293,7 +2334,7 @@ export function registerApiTriggers( }); sdk.registerTrigger({ type: "http", function_id: "api::sentinel-cancel", config: { api_path: "/agentmemory/sentinels/cancel", http_method: "POST" } }); - sdk.registerFunction("api::sentinel-list", async (req: ApiRequest) => { + sdk.registerFunction("api::sentinel-list", async (req: ApiRequest) => { const denied = checkAuth(req, secret); if (denied) return denied; const params = req.query_params || {}; @@ -2302,7 +2343,7 @@ export function registerApiTriggers( }); sdk.registerTrigger({ type: "http", function_id: "api::sentinel-list", config: { api_path: "/agentmemory/sentinels", http_method: "GET" } }); - sdk.registerFunction("api::sketch-create", async (req: ApiRequest) => { + sdk.registerFunction("api::sketch-create", async (req: ApiRequest) => { const denied = checkAuth(req, secret); if (denied) return denied; const body = req.body as Record; @@ -2312,7 +2353,7 @@ export function registerApiTriggers( }); sdk.registerTrigger({ type: "http", function_id: "api::sketch-create", config: { api_path: "/agentmemory/sketches", http_method: "POST" } }); - sdk.registerFunction("api::sketch-add", async (req: ApiRequest) => { + sdk.registerFunction("api::sketch-add", async (req: ApiRequest) => { const denied = checkAuth(req, secret); if (denied) return denied; const body = req.body as Record; @@ -2322,7 +2363,7 @@ export function registerApiTriggers( }); sdk.registerTrigger({ type: "http", function_id: "api::sketch-add", config: { api_path: "/agentmemory/sketches/add", http_method: "POST" } }); - sdk.registerFunction("api::sketch-promote", async (req: ApiRequest) => { + sdk.registerFunction("api::sketch-promote", async (req: ApiRequest) => { const denied = checkAuth(req, secret); if (denied) return denied; const body = req.body as Record; @@ -2332,7 +2373,7 @@ export function registerApiTriggers( }); sdk.registerTrigger({ type: "http", function_id: "api::sketch-promote", config: { api_path: "/agentmemory/sketches/promote", http_method: "POST" } }); - sdk.registerFunction("api::sketch-discard", async (req: ApiRequest) => { + sdk.registerFunction("api::sketch-discard", async (req: ApiRequest) => { const denied = checkAuth(req, secret); if (denied) return denied; const body = req.body as Record; @@ -2342,7 +2383,7 @@ export function registerApiTriggers( }); sdk.registerTrigger({ type: "http", function_id: "api::sketch-discard", config: { api_path: "/agentmemory/sketches/discard", http_method: "POST" } }); - sdk.registerFunction("api::sketch-list", async (req: ApiRequest) => { + sdk.registerFunction("api::sketch-list", async (req: ApiRequest) => { const denied = checkAuth(req, secret); if (denied) return denied; const params = req.query_params || {}; @@ -2351,7 +2392,7 @@ export function registerApiTriggers( }); sdk.registerTrigger({ type: "http", function_id: "api::sketch-list", config: { api_path: "/agentmemory/sketches", http_method: "GET" } }); - sdk.registerFunction("api::sketch-gc", async (req: ApiRequest) => { + sdk.registerFunction("api::sketch-gc", async (req: ApiRequest) => { const denied = checkAuth(req, secret); if (denied) return denied; const result = await sdk.trigger({ function_id: "mem::sketch-gc", payload: {} }); @@ -2359,7 +2400,7 @@ export function registerApiTriggers( }); sdk.registerTrigger({ type: "http", function_id: "api::sketch-gc", config: { api_path: "/agentmemory/sketches/gc", http_method: "POST" } }); - sdk.registerFunction("api::crystallize", async (req: ApiRequest) => { + sdk.registerFunction("api::crystallize", async (req: ApiRequest) => { const denied = checkAuth(req, secret); if (denied) return denied; const body = req.body as Record; @@ -2369,7 +2410,7 @@ export function registerApiTriggers( }); sdk.registerTrigger({ type: "http", function_id: "api::crystallize", config: { api_path: "/agentmemory/crystals/create", http_method: "POST" } }); - sdk.registerFunction("api::crystal-list", async (req: ApiRequest) => { + sdk.registerFunction("api::crystal-list", async (req: ApiRequest) => { const denied = checkAuth(req, secret); if (denied) return denied; const params = req.query_params || {}; @@ -2385,7 +2426,7 @@ export function registerApiTriggers( }); sdk.registerTrigger({ type: "http", function_id: "api::crystal-list", config: { api_path: "/agentmemory/crystals", http_method: "GET" } }); - sdk.registerFunction("api::auto-crystallize", async (req: ApiRequest) => { + sdk.registerFunction("api::auto-crystallize", async (req: ApiRequest) => { const denied = checkAuth(req, secret); if (denied) return denied; const body = req.body as Record; @@ -2394,7 +2435,7 @@ export function registerApiTriggers( }); sdk.registerTrigger({ type: "http", function_id: "api::auto-crystallize", config: { api_path: "/agentmemory/crystals/auto", http_method: "POST" } }); - sdk.registerFunction("api::diagnose", async (req: ApiRequest) => { + sdk.registerFunction("api::diagnose", async (req: ApiRequest) => { const denied = checkAuth(req, secret); if (denied) return denied; const body = req.body as Record; @@ -2403,7 +2444,7 @@ export function registerApiTriggers( }); sdk.registerTrigger({ type: "http", function_id: "api::diagnose", config: { api_path: "/agentmemory/diagnostics", http_method: "POST" } }); - sdk.registerFunction("api::heal", async (req: ApiRequest) => { + sdk.registerFunction("api::heal", async (req: ApiRequest) => { const denied = checkAuth(req, secret); if (denied) return denied; const body = req.body as Record; @@ -2412,7 +2453,7 @@ export function registerApiTriggers( }); sdk.registerTrigger({ type: "http", function_id: "api::heal", config: { api_path: "/agentmemory/diagnostics/heal", http_method: "POST" } }); - sdk.registerFunction("api::facet-tag", async (req: ApiRequest) => { + sdk.registerFunction("api::facet-tag", async (req: ApiRequest) => { const denied = checkAuth(req, secret); if (denied) return denied; const body = req.body as Record; @@ -2422,7 +2463,7 @@ export function registerApiTriggers( }); sdk.registerTrigger({ type: "http", function_id: "api::facet-tag", config: { api_path: "/agentmemory/facets", http_method: "POST" } }); - sdk.registerFunction("api::facet-untag", async (req: ApiRequest) => { + sdk.registerFunction("api::facet-untag", async (req: ApiRequest) => { const denied = checkAuth(req, secret); if (denied) return denied; const body = req.body as Record; @@ -2432,7 +2473,7 @@ export function registerApiTriggers( }); sdk.registerTrigger({ type: "http", function_id: "api::facet-untag", config: { api_path: "/agentmemory/facets/remove", http_method: "POST" } }); - sdk.registerFunction("api::facet-query", async (req: ApiRequest) => { + sdk.registerFunction("api::facet-query", async (req: ApiRequest) => { const denied = checkAuth(req, secret); if (denied) return denied; const body = req.body as Record; @@ -2441,7 +2482,7 @@ export function registerApiTriggers( }); sdk.registerTrigger({ type: "http", function_id: "api::facet-query", config: { api_path: "/agentmemory/facets/query", http_method: "POST" } }); - sdk.registerFunction("api::facet-get", async (req: ApiRequest) => { + sdk.registerFunction("api::facet-get", async (req: ApiRequest) => { const denied = checkAuth(req, secret); if (denied) return denied; const params = req.query_params || {}; @@ -2451,7 +2492,7 @@ export function registerApiTriggers( }); sdk.registerTrigger({ type: "http", function_id: "api::facet-get", config: { api_path: "/agentmemory/facets", http_method: "GET" } }); - sdk.registerFunction("api::facet-stats", async (req: ApiRequest) => { + sdk.registerFunction("api::facet-stats", async (req: ApiRequest) => { const denied = checkAuth(req, secret); if (denied) return denied; const params = req.query_params || {}; @@ -2460,7 +2501,7 @@ export function registerApiTriggers( }); sdk.registerTrigger({ type: "http", function_id: "api::facet-stats", config: { api_path: "/agentmemory/facets/stats", http_method: "GET" } }); - sdk.registerFunction("api::verify", async (req: ApiRequest) => { + sdk.registerFunction("api::verify", async (req: ApiRequest) => { const denied = checkAuth(req, secret); if (denied) return denied; const body = req.body as Record; @@ -2470,7 +2511,7 @@ export function registerApiTriggers( }); sdk.registerTrigger({ type: "http", function_id: "api::verify", config: { api_path: "/agentmemory/verify", http_method: "POST" } }); - sdk.registerFunction("api::cascade-update", async (req: ApiRequest) => { + sdk.registerFunction("api::cascade-update", async (req: ApiRequest) => { const denied = checkAuth(req, secret); if (denied) return denied; const body = req.body as Record; @@ -2482,7 +2523,7 @@ export function registerApiTriggers( }); sdk.registerTrigger({ type: "http", function_id: "api::cascade-update", config: { api_path: "/agentmemory/cascade-update", http_method: "POST" } }); - sdk.registerFunction("api::lesson-save", async (req: ApiRequest) => { + sdk.registerFunction("api::lesson-save", async (req: ApiRequest) => { const denied = checkAuth(req, secret); if (denied) return denied; const body = req.body as Record; @@ -2504,7 +2545,7 @@ export function registerApiTriggers( }); sdk.registerTrigger({ type: "http", function_id: "api::lesson-save", config: { api_path: "/agentmemory/lessons", http_method: "POST" } }); - sdk.registerFunction("api::lesson-list", async (req: ApiRequest) => { + sdk.registerFunction("api::lesson-list", async (req: ApiRequest) => { const denied = checkAuth(req, secret); if (denied) return denied; const params = req.query_params || {}; @@ -2522,17 +2563,19 @@ export function registerApiTriggers( body: { error: "invalid numeric parameter: limit" }, }; } - const result = await sdk.trigger({ function_id: "mem::lesson-list", payload: { - project: params.project, - source: params.source, - minConfidence, - limit, - } }); + const result = await sdk.trigger({ + function_id: "mem::lesson-list", payload: { + project: params.project, + source: params.source, + minConfidence, + limit, + } + }); return { status_code: 200, body: result }; }); sdk.registerTrigger({ type: "http", function_id: "api::lesson-list", config: { api_path: "/agentmemory/lessons", http_method: "GET" } }); - sdk.registerFunction("api::lesson-search", async (req: ApiRequest) => { + sdk.registerFunction("api::lesson-search", async (req: ApiRequest) => { const denied = checkAuth(req, secret); if (denied) return denied; const body = req.body as Record; @@ -2542,7 +2585,7 @@ export function registerApiTriggers( }); sdk.registerTrigger({ type: "http", function_id: "api::lesson-search", config: { api_path: "/agentmemory/lessons/search", http_method: "POST" } }); - sdk.registerFunction("api::lesson-strengthen", async (req: ApiRequest) => { + sdk.registerFunction("api::lesson-strengthen", async (req: ApiRequest) => { const denied = checkAuth(req, secret); if (denied) return denied; const body = req.body as Record; @@ -2569,19 +2612,21 @@ export function registerApiTriggers( }); sdk.registerTrigger({ type: "http", function_id: "api::obsidian-export", config: { api_path: "/agentmemory/obsidian/export", http_method: "POST" } }); - sdk.registerFunction("api::reflect", async (req: ApiRequest) => { + sdk.registerFunction("api::reflect", async (req: ApiRequest) => { const denied = checkAuth(req, secret); if (denied) return denied; const body = (req.body as Record) || {}; - const result = await sdk.trigger({ function_id: "mem::reflect", payload: { - project: typeof body.project === "string" ? body.project : undefined, - maxClusters: typeof body.maxClusters === "number" ? body.maxClusters : undefined, - } }); + const result = await sdk.trigger({ + function_id: "mem::reflect", payload: { + project: typeof body.project === "string" ? body.project : undefined, + maxClusters: typeof body.maxClusters === "number" ? body.maxClusters : undefined, + } + }); return { status_code: 200, body: result }; }); sdk.registerTrigger({ type: "http", function_id: "api::reflect", config: { api_path: "/agentmemory/reflect", http_method: "POST" } }); - sdk.registerFunction("api::insight-list", async (req: ApiRequest) => { + sdk.registerFunction("api::insight-list", async (req: ApiRequest) => { const denied = checkAuth(req, secret); if (denied) return denied; const params = req.query_params || {}; @@ -2599,26 +2644,30 @@ export function registerApiTriggers( body: { error: "invalid numeric parameter: limit" }, }; } - const result = await sdk.trigger({ function_id: "mem::insight-list", payload: { - project: params.project, - minConfidence, - limit, - } }); + const result = await sdk.trigger({ + function_id: "mem::insight-list", payload: { + project: params.project, + minConfidence, + limit, + } + }); return { status_code: 200, body: result }; }); sdk.registerTrigger({ type: "http", function_id: "api::insight-list", config: { api_path: "/agentmemory/insights", http_method: "GET" } }); - sdk.registerFunction("api::insight-search", async (req: ApiRequest) => { + sdk.registerFunction("api::insight-search", async (req: ApiRequest) => { const denied = checkAuth(req, secret); if (denied) return denied; const body = req.body as Record; if (!body?.query || typeof body.query !== "string") return { status_code: 400, body: { error: "query is required" } }; - const result = await sdk.trigger({ function_id: "mem::insight-search", payload: { - query: body.query, - project: typeof body.project === "string" ? body.project : undefined, - minConfidence: typeof body.minConfidence === "number" ? body.minConfidence : undefined, - limit: typeof body.limit === "number" ? body.limit : undefined, - } }); + const result = await sdk.trigger({ + function_id: "mem::insight-search", payload: { + query: body.query, + project: typeof body.project === "string" ? body.project : undefined, + minConfidence: typeof body.minConfidence === "number" ? body.minConfidence : undefined, + limit: typeof body.limit === "number" ? body.limit : undefined, + } + }); return { status_code: 200, body: result }; }); sdk.registerTrigger({ type: "http", function_id: "api::insight-search", config: { api_path: "/agentmemory/insights/search", http_method: "POST" } }); diff --git a/src/types.ts b/src/types.ts index 806dae6a..26e2a41d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -412,6 +412,16 @@ export interface GraphQueryResult { depth: number; } +export interface ConceptEdge { + id: string; + from: string; + to: string; + strength: number; + reinforcements: number; + lastSeenAt: string; + createdAt: string; +} + export type ConsolidationTier = | "working" | "episodic" @@ -522,7 +532,9 @@ export interface AuditEntry { | "slot_replace" | "slot_create" | "slot_delete" - | "slot_reflect"; + | "slot_reflect" + | "concept_edge_upsert" + | "concept_backfill"; userId?: string; functionId: string; targetIds: string[]; diff --git a/src/viewer/document.ts b/src/viewer/document.ts index f8da5aa4..535f50ed 100644 --- a/src/viewer/document.ts +++ b/src/viewer/document.ts @@ -34,9 +34,11 @@ export function renderViewerDocument(): } const nonce = createViewerNonce(); + const conceptGraphViewerEnabled = process.env.AGENTMEMORY_CONCEPT_GRAPH_VIEWER === "true" ? "true" : "false"; const html = template .replaceAll(VIEWER_NONCE_PLACEHOLDER, nonce) - .replaceAll(VIEWER_VERSION_PLACEHOLDER, VERSION); + .replaceAll(VIEWER_VERSION_PLACEHOLDER, VERSION) + .replaceAll("__AGENTMEMORY_CONCEPT_GRAPH_VIEWER__", conceptGraphViewerEnabled); return { found: true, html, diff --git a/src/viewer/index.html b/src/viewer/index.html index 5de2f87f..10ec6493 100644 --- a/src/viewer/index.html +++ b/src/viewer/index.html @@ -881,6 +881,7 @@

agentmemory

+ @@ -897,6 +898,20 @@

agentmemory

+ +
+
+
+ +
+
+

Concept Graph

+

Top co-occurring concepts.

+
+
+
+
+
@@ -994,6 +1009,7 @@

agentmemory

crystals: { loaded: false, items: [], search: '', lessonMap: {} }, profile: { loaded: false, projects: [], selectedProject: '', data: null }, replay: { loaded: false, sessions: [], selectedId: '', timeline: null, cursor: 0, playing: false, speed: 1, timer: null, startAt: 0, offsetAt: 0 }, + concepts: { loaded: false }, flagsConfig: null, ws: null }; @@ -1085,6 +1101,7 @@

agentmemory

case 'activity': if (!state.activity.loaded) await loadActivity(); break; case 'profile': if (!state.profile.loaded) await loadProfile(); break; case 'replay': if (!state.replay.loaded) await loadReplay(); break; + case 'concepts': if (!state.concepts.loaded) renderConcepts(); break; } } @@ -2033,6 +2050,111 @@

agentmemory

renderMemories(); } + function renderConcepts() { + var el = document.getElementById('view-concepts'); + if (state.concepts.loaded) return; + + apiGet('concept-graph-viewer?limit=50').then(function(data) { + if (!data || !data.success) { + document.getElementById('concepts-sidebar').innerHTML = '
Failed to load.
'; + return; + } + state.concepts.loaded = true; + var nodes = data.nodes.map(function(id) { return { id: id, x: Math.random() * 800, y: Math.random() * 600, vx: 0, vy: 0 }; }); + var edges = data.edges.map(function(e) { + return { + source: nodes.find(function(n) { return n.id === e.from; }), + target: nodes.find(function(n) { return n.id === e.to; }), + strength: e.strength + }; + }).filter(function(e) { return e.source && e.target; }); + + var canvas = document.getElementById('concepts-canvas'); + var ctx = canvas.getContext('2d'); + var rect = canvas.parentElement.getBoundingClientRect(); + canvas.width = rect.width * window.devicePixelRatio; + canvas.height = rect.height * window.devicePixelRatio; + ctx.scale(window.devicePixelRatio, window.devicePixelRatio); + + var cw = rect.width, ch = rect.height; + var dragging = null; + + function step() { + var k = Math.sqrt(cw * ch / nodes.length); + for (var i=0; iagentmemory else if (e.key === 'ArrowRight') { e.preventDefault(); stepReplay(1); } }); + if ('__AGENTMEMORY_CONCEPT_GRAPH_VIEWER__' === 'true') { + document.getElementById('tab-concepts').style.display = 'inline-block'; + } loadTab('dashboard'); connectWs(); startDashboardAutoRefresh(); diff --git a/test/concept-edges.test.ts b/test/concept-edges.test.ts new file mode 100644 index 00000000..d381c515 --- /dev/null +++ b/test/concept-edges.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { registerConceptEdgesFunctions } from "../src/functions/concept-edges.js"; +import { KV } from "../src/state/schema.js"; +import { InMemoryKV } from "../src/mcp/in-memory-kv.js"; + +const mockSdk = { + registerFunction: vi.fn(), + trigger: vi.fn(), +}; + +describe("Concept Edges", () => { + let kv: InMemoryKV; + + beforeEach(() => { + kv = new InMemoryKV(); + vi.clearAllMocks(); + registerConceptEdgesFunctions(mockSdk as any, kv as any); + }); + + it("registers upsert function", () => { + expect(mockSdk.registerFunction).toHaveBeenCalledWith( + "mem::concept-edge-upsert", + expect.any(Function), + ); + }); + + it("creates new edges for concepts", async () => { + const handler = mockSdk.registerFunction.mock.calls[0][1]; + const result = await handler({ concepts: ["auth", "jwt", "security"] }); + expect(result.success).toBe(true); + expect(result.created).toBe(3); // (auth, jwt), (auth, security), (jwt, security) + expect(result.reinforced).toBe(0); + + const edges = await kv.list(KV.conceptEdges); + expect(edges).toHaveLength(3); + edges.forEach((e) => { + expect(e.strength).toBe(0.5); + expect(e.reinforcements).toBe(0); + }); + }); + + it("reinforces existing edges", async () => { + const handler = mockSdk.registerFunction.mock.calls[0][1]; + await handler({ concepts: ["auth", "jwt"] }); // creates 1 edge + + const result2 = await handler({ concepts: ["jwt", "auth"] }); // order shouldn't matter + expect(result2.success).toBe(true); + expect(result2.created).toBe(0); + expect(result2.reinforced).toBe(1); + + const edges = await kv.list(KV.conceptEdges); + expect(edges).toHaveLength(1); + expect(edges[0].strength).toBeCloseTo(0.55); // 0.5 + 0.1*(1 - 0.5) + expect(edges[0].reinforcements).toBe(1); + }); + + it("requires at least 2 concepts", async () => { + const handler = mockSdk.registerFunction.mock.calls[0][1]; + const result = await handler({ concepts: ["auth"] }); + expect(result.success).toBe(false); + expect(result.error).toBe("at least 2 concepts required"); + }); +}); diff --git a/test/concept-graph-search.test.ts b/test/concept-graph-search.test.ts new file mode 100644 index 00000000..61bd3017 --- /dev/null +++ b/test/concept-graph-search.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { registerConceptGraphSearchFunction } from "../src/functions/concept-graph-search.js"; +import { KV } from "../src/state/schema.js"; +import { InMemoryKV } from "../src/mcp/in-memory-kv.js"; + +const mockSdk = { + registerFunction: vi.fn(), + trigger: vi.fn(), +}; + +describe("Concept Graph Search", () => { + let kv: InMemoryKV; + + beforeEach(() => { + kv = new InMemoryKV(); + vi.clearAllMocks(); + registerConceptGraphSearchFunction(mockSdk as any, kv as any); + }); + + it("registers search function", () => { + expect(mockSdk.registerFunction).toHaveBeenCalledWith( + "mem::concept-graph-search", + expect.any(Function), + ); + }); + + it("refuses depth > 2", async () => { + const handler = mockSdk.registerFunction.mock.calls[0][1]; + const result = await handler({ concepts: ["auth"], depth: 3 }); + expect(result.success).toBe(false); + expect(result.error).toBe("depth_out_of_range"); + }); + + it("expands concepts and finds related memories", async () => { + // Setup edges + const now = new Date().toISOString(); + await kv.set(KV.conceptEdges, "ce1", { + id: "ce1", + from: "auth", + to: "jwt", + strength: 0.8, + lastSeenAt: now, + }); + await kv.set(KV.conceptEdges, "ce2", { + id: "ce2", + from: "jwt", + to: "token", + strength: 0.7, + lastSeenAt: now, + }); + + // Setup memories + await kv.set(KV.memories, "m1", { + id: "m1", + concepts: ["token", "security"], + isLatest: true, + createdAt: now, + }); + + const handler = mockSdk.registerFunction.mock.calls[0][1]; + const result = await handler({ concepts: ["auth"], depth: 2 }); + + expect(result.success).toBe(true); + expect(result.expandedConcepts).toContain("auth"); + expect(result.expandedConcepts).toContain("jwt"); + expect(result.expandedConcepts).toContain("token"); + + expect(result.results).toHaveLength(1); + expect(result.results[0].memoryId).toBe("m1"); + // Initial score = 1.0 (auth) + // auth -> jwt score = 1.0 * 0.8 = 0.8 + // jwt -> token score = 0.8 * 0.7 = 0.56 + expect(result.results[0].score).toBeCloseTo(0.56); + }); +}); diff --git a/test/mcp-standalone.test.ts b/test/mcp-standalone.test.ts index 59bd985e..76329d0b 100644 --- a/test/mcp-standalone.test.ts +++ b/test/mcp-standalone.test.ts @@ -31,7 +31,7 @@ import { writeFileSync } from "node:fs"; describe("Tools Registry", () => { it("getAllTools returns all tools with unique names", () => { const tools = getAllTools(); - expect(tools.length).toBeGreaterThanOrEqual(41); + expect(tools.length).toBe(45); const names = new Set(tools.map((t) => t.name)); expect(names.size).toBe(tools.length); for (const required of [