From 843f622d8f0a135f23457ae044844a2735d11b15 Mon Sep 17 00:00:00 2001 From: Tanmay-008 Date: Thu, 14 May 2026 00:47:45 +0530 Subject: [PATCH 1/3] feat: implement concept graph search with depth-2 BFS expansion This feature improves search recall by utilizing concept co-occurrence relationships: - Added 'mem:concept-edges' KV scope to store graph data. - Automatically extract and reinforce concept pairs on 'mem::remember'. - Added 'mem::concept-graph-search' with Depth-2 BFS, lazy decay, and Top-10 node cap. - Integrated graph mode into 'smart-search' with a 0.8 relevance penalty. - Registered new 'memory_graph_search' MCP tool. - Added first-run auto-backfill migration for existing memories. --- AGENTS.md | 2 +- README.md | 4 +- plugin/.claude-plugin/plugin.json | 2 +- src/functions/concept-backfill.ts | 59 ++++++++++++++ src/functions/concept-edges.ts | 84 +++++++++++++++++++ src/functions/concept-graph-search.ts | 113 ++++++++++++++++++++++++++ src/functions/remember.ts | 8 ++ src/functions/smart-search.ts | 47 ++++++++++- src/index.ts | 8 ++ src/mcp/server.ts | 30 +++++++ src/mcp/tools-registry.ts | 21 +++++ src/state/schema.ts | 1 + src/triggers/api.ts | 10 ++- src/types.ts | 14 +++- test/concept-edges.test.ts | 63 ++++++++++++++ test/concept-graph-search.test.ts | 75 +++++++++++++++++ test/mcp-standalone.test.ts | 2 +- 17 files changed, 534 insertions(+), 9 deletions(-) create mode 100644 src/functions/concept-backfill.ts create mode 100644 src/functions/concept-edges.ts create mode 100644 src/functions/concept-graph-search.ts create mode 100644 test/concept-edges.test.ts create mode 100644 test/concept-graph-search.test.ts 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..46021051 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 @@ -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. ```

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/src/functions/concept-backfill.ts b/src/functions/concept-backfill.ts new file mode 100644 index 00000000..acf8267b --- /dev/null +++ b/src/functions/concept-backfill.ts @@ -0,0 +1,59 @@ +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; + const batchSize = 50; + + for (let i = 0; i < eligible.length; i += batchSize) { + const batch = eligible.slice(i, i + batchSize); + await Promise.all( + batch.map((m) => + sdk + .trigger({ + function_id: "mem::concept-edge-upsert", + payload: { concepts: m.concepts }, + }) + .catch(() => {}), + ), + ); + processed += batch.length; + } + + 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: memories.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..d429d015 --- /dev/null +++ b/src/functions/concept-graph-search.ts @@ -0,0 +1,113 @@ +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 daysSinceLastSeen = + (Date.now() - new Date(edge.lastSeenAt).getTime()) / (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 (depth > MAX_BFS_DEPTH) { + return { + success: false, + error: "depth_out_of_range", + message: `BFS depth ${depth} exceeds maximum of ${MAX_BFS_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..c8f176e4 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,54 @@ export function registerSmartSearchFunction( compact.map((r) => r.obsId), ); + if (data.mode === "graph") { + try { + 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: data.query.split(/\s+/).filter((t) => t.length > 1), + depth: 2, + limit, + }, + }); + + if (graphResult.results && graphResult.results.length > 0) { + 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 any, + score: gr.score * 0.8, + timestamp: mem.createdAt, + }); + } + } + + compact.sort((a, b) => b.score - a.score); + compact.splice(limit); + } + } catch {} + } + logger.info("Smart search compact", { query: data.query, results: compact.length, + mode: data.mode, }); - return { mode: "compact", results: compact }; + return { mode: data.mode === "graph" ? "graph" : "compact", results: compact }; }, ); } diff --git a/src/index.ts b/src/index.ts index 6385a96c..1e0ed5a4 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"; @@ -281,6 +284,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`, ); @@ -364,6 +370,8 @@ async function main() { `[agentmemory] Endpoints: 107 REST + ${getAllTools().length} MCP tools + 6 MCP resources + 3 MCP prompts`, ); + sdk.trigger({ function_id: "mem::concept-backfill", payload: {} }).catch(() => {}); + const viewerPort = config.restPort + 2; const viewerServer = startViewerServer( viewerPort, diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 21c2069a..7be11934 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -460,6 +460,36 @@ export function registerMcpEndpoints( } } + case "memory_graph_search": { + if (typeof args.query !== "string" || !args.query.trim()) { + return { + status_code: 400, + body: { error: "query is required for memory_graph_search" }, + }; + } + const depth = asNumber(args.depth, 2) ?? 2; + if (depth > 2) { + return { + status_code: 400, + body: { error: "depth_out_of_range", message: `BFS depth ${depth} exceeds maximum of 2` }, + }; + } + 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); + 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..519fbf75 100644 --- a/src/triggers/api.ts +++ b/src/triggers/api.ts @@ -836,7 +836,7 @@ export function registerApiTriggers( 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,13 @@ 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 (body.query !== undefined) payload.query = body.query; + if (body.expandIds !== undefined) payload.expandIds = body.expandIds; + if (body.limit !== undefined) 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 }; }, ); 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/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..1ca3b2d0 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).toBeGreaterThanOrEqual(42); const names = new Set(tools.map((t) => t.name)); expect(names.size).toBe(tools.length); for (const required of [ From d72701380af54f95302b4722e8e56189930976a3 Mon Sep 17 00:00:00 2001 From: Tanmay-008 Date: Thu, 14 May 2026 01:08:30 +0530 Subject: [PATCH 2/3] feat: add concept graph viewer UI and fix code review issues --- plugin/scripts/session-end.mjs | 8 +- plugin/scripts/stop.mjs | 2 +- src/functions/concept-backfill.ts | 22 +++-- src/functions/concept-graph-search.ts | 9 +- src/functions/smart-search.ts | 16 +++- src/index.ts | 4 +- src/mcp/server.ts | 21 +++-- src/triggers/api.ts | 23 ++++- src/viewer/document.ts | 4 +- src/viewer/index.html | 131 +++++++++++++++++++++++++- test/mcp-standalone.test.ts | 2 +- 11 files changed, 204 insertions(+), 38 deletions(-) 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 index acf8267b..8ec75fa6 100644 --- a/src/functions/concept-backfill.ts +++ b/src/functions/concept-backfill.ts @@ -22,21 +22,27 @@ export function registerConceptBackfillFunction(sdk: ISdk, kv: StateKV): void { ); 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); - await Promise.all( + const results = await Promise.allSettled( batch.map((m) => - sdk - .trigger({ - function_id: "mem::concept-edge-upsert", - payload: { concepts: m.concepts }, - }) - .catch(() => {}), + sdk.trigger({ + function_id: "mem::concept-edge-upsert", + payload: { concepts: m.concepts }, + }) ), ); - processed += batch.length; + 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() }); diff --git a/src/functions/concept-graph-search.ts b/src/functions/concept-graph-search.ts index d429d015..2fce1d8e 100644 --- a/src/functions/concept-graph-search.ts +++ b/src/functions/concept-graph-search.ts @@ -7,8 +7,9 @@ const MAX_BFS_DEPTH = 2; const MAX_NEIGHBORS_PER_NODE = 10; function decayedStrength(edge: ConceptEdge): number { - const daysSinceLastSeen = - (Date.now() - new Date(edge.lastSeenAt).getTime()) / (1000 * 60 * 60 * 24); + 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); } @@ -22,11 +23,11 @@ export function registerConceptGraphSearchFunction(sdk: ISdk, kv: StateKV): void } const depth = data.depth ?? 2; - if (depth > MAX_BFS_DEPTH) { + if (!Number.isInteger(depth) || depth < 1 || depth > MAX_BFS_DEPTH) { return { success: false, error: "depth_out_of_range", - message: `BFS depth ${depth} exceeds maximum of ${MAX_BFS_DEPTH}`, + message: `BFS depth must be an integer between 1 and ${MAX_BFS_DEPTH}, got ${depth}`, }; } diff --git a/src/functions/smart-search.ts b/src/functions/smart-search.ts index c8f176e4..2e3f471d 100644 --- a/src/functions/smart-search.ts +++ b/src/functions/smart-search.ts @@ -86,7 +86,9 @@ export function registerSmartSearchFunction( compact.map((r) => r.obsId), ); + let finalMode = data.mode; if (data.mode === "graph") { + let graphSuccess = false; try { const graphResult = await sdk.trigger< { concepts: string[]; depth: number; limit: number }, @@ -100,7 +102,8 @@ export function registerSmartSearchFunction( }, }); - if (graphResult.results && graphResult.results.length > 0) { + 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])); @@ -125,15 +128,20 @@ export function registerSmartSearchFunction( compact.sort((a, b) => b.score - a.score); compact.splice(limit); } - } catch {} + } catch (e) { + logger.error("Graph search failed, falling back to compact", { error: e }); + } + if (!graphSuccess && compact.length > 0) { + finalMode = "compact"; + } } logger.info("Smart search compact", { query: data.query, results: compact.length, - mode: data.mode, + mode: finalMode, }); - return { mode: data.mode === "graph" ? "graph" : "compact", results: compact }; + return { mode: finalMode, results: compact }; }, ); } diff --git a/src/index.ts b/src/index.ts index 1e0ed5a4..0b3b271c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -370,7 +370,9 @@ async function main() { `[agentmemory] Endpoints: 107 REST + ${getAllTools().length} MCP tools + 6 MCP resources + 3 MCP prompts`, ); - sdk.trigger({ function_id: "mem::concept-backfill", payload: {} }).catch(() => {}); + 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( diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 7be11934..b8ea9c00 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -461,21 +461,24 @@ export function registerMcpEndpoints( } case "memory_graph_search": { - if (typeof args.query !== "string" || !args.query.trim()) { - return { - status_code: 400, - body: { error: "query is required for 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 (depth > 2) { + if (!Number.isInteger(depth) || depth < 1 || depth > 2) { return { status_code: 400, - body: { error: "depth_out_of_range", message: `BFS depth ${depth} exceeds maximum of 2` }, + 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 }, @@ -483,9 +486,7 @@ export function registerMcpEndpoints( return { status_code: 200, body: { - content: [ - { type: "text", text: JSON.stringify(result, null, 2) }, - ], + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], }, }; } diff --git a/src/triggers/api.ts b/src/triggers/api.ts index 519fbf75..b64fcb11 100644 --- a/src/triggers/api.ts +++ b/src/triggers/api.ts @@ -851,9 +851,11 @@ export function registerApiTriggers( } const body = (req.body ?? {}) as Record; const payload: Record = {}; - if (body.query !== undefined) payload.query = body.query; - if (body.expandIds !== undefined) payload.expandIds = body.expandIds; - if (body.limit !== undefined) payload.limit = body.limit; + 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 }; @@ -910,6 +912,21 @@ export function registerApiTriggers( config: { api_path: "/agentmemory/profile", http_method: "GET" }, }); + sdk.registerFunction("api::concept-graph-viewer", + async (req: ApiRequest): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + const limit = parseInt((req.query_params["limit"] as string) || "100", 10); + 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); 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..27fd095d 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.

+
+
+
+
+
@@ -1085,6 +1100,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) await loadConcepts(); break; } } @@ -1114,6 +1130,10 @@

agentmemory

state.dashboard.crystals = (results[9] && results[9].crystals) || []; state.dashboard.relations = (results[7] && results[7].relations) || []; state.dashboard.loaded = true; + if (tabId === 'dashboard') renderDashboard(); + if (tabId === 'graph') renderGraph(); + if (tabId === 'concepts') renderConcepts(); + if (tabId === 'memories') renderMemories(); renderDashboard(); } @@ -1496,7 +1516,11 @@

agentmemory

graphSim.canvas = canvas; graphSim.ctx = canvas.getContext('2d'); - function resize() { + if ('__AGENTMEMORY_CONCEPT_GRAPH_VIEWER__' === 'true') { + document.getElementById('tab-concepts').style.display = 'inline-block'; + } + + function checkRestOnline() { var r = canvas.parentElement.getBoundingClientRect(); canvas.width = r.width * window.devicePixelRatio; canvas.height = r.height * window.devicePixelRatio; @@ -2033,6 +2057,111 @@

agentmemory

renderMemories(); } + function renderConcepts() { + var el = document.getElementById('view-concepts'); + if (el.dataset.loaded) return; + el.dataset.loaded = 'true'; + + apiGet('concept-graph-viewer?limit=50').then(function(data) { + if (!data || !data.success) { + document.getElementById('concepts-sidebar').innerHTML = '
Failed to load.
'; + return; + } + 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; i { it("getAllTools returns all tools with unique names", () => { const tools = getAllTools(); - expect(tools.length).toBeGreaterThanOrEqual(42); + expect(tools.length).toBe(45); const names = new Set(tools.map((t) => t.name)); expect(names.size).toBe(tools.length); for (const required of [ From 1f573d625c9b9edd351d880c4e33eac5bb29b945 Mon Sep 17 00:00:00 2001 From: Tanmay-008 Date: Thu, 14 May 2026 10:02:17 +0530 Subject: [PATCH 3/3] fix: address CodeRabbit AI review comments for concept graph search --- README.md | 4 +- src/functions/concept-backfill.ts | 2 +- src/functions/smart-search.ts | 16 +- src/index.ts | 11 +- src/triggers/api.ts | 328 ++++++++++++++++-------------- src/viewer/index.html | 20 +- 6 files changed, 203 insertions(+), 178 deletions(-) diff --git a/README.md b/README.md index 46021051..1b824fde 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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/src/functions/concept-backfill.ts b/src/functions/concept-backfill.ts index 8ec75fa6..f5f4dbf3 100644 --- a/src/functions/concept-backfill.ts +++ b/src/functions/concept-backfill.ts @@ -56,7 +56,7 @@ export function registerConceptBackfillFunction(sdk: ISdk, kv: StateKV): void { logger.info("Concept backfill completed", { processed, - total: memories.length, + total: eligible.length, }); return { success: true, processed, total: memories.length }; diff --git a/src/functions/smart-search.ts b/src/functions/smart-search.ts index 2e3f471d..94a1d9e7 100644 --- a/src/functions/smart-search.ts +++ b/src/functions/smart-search.ts @@ -90,16 +90,18 @@ export function registerSmartSearchFunction( 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: data.query.split(/\s+/).filter((t) => t.length > 1), - depth: 2, - limit, - }, + payload: { concepts, depth: 2, limit }, }); if (graphResult.success && graphResult.results && graphResult.results.length > 0) { @@ -118,7 +120,7 @@ export function registerSmartSearchFunction( obsId, sessionId: "", title: mem.title, - type: mem.type as any, + type: (mem.type as "observation" | "message" | "tool_call" | "tool_result" | "tool_error") || "observation", score: gr.score * 0.8, timestamp: mem.createdAt, }); @@ -131,7 +133,7 @@ export function registerSmartSearchFunction( } catch (e) { logger.error("Graph search failed, falling back to compact", { error: e }); } - if (!graphSuccess && compact.length > 0) { + if (!graphSuccess) { finalMode = "compact"; } } diff --git a/src/index.ts b/src/index.ts index 0b3b271c..6ce1eb6c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -95,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, @@ -367,7 +368,7 @@ 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) => { @@ -390,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)`); @@ -400,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)`); @@ -410,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(); } @@ -419,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/triggers/api.ts b/src/triggers/api.ts index b64fcb11..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,7 +834,7 @@ 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; mode?: string }>, ): Promise => { @@ -867,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; @@ -891,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; @@ -916,7 +916,9 @@ export function registerApiTriggers( async (req: ApiRequest): Promise => { const authErr = checkAuth(req, secret); if (authErr) return authErr; - const limit = parseInt((req.query_params["limit"] as string) || "100", 10); + 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 }; } @@ -927,7 +929,7 @@ export function registerApiTriggers( config: { api_path: "/agentmemory/concept-graph-viewer", http_method: "GET" }, }); - sdk.registerFunction("api::export", + sdk.registerFunction("api::export", async (req: ApiRequest): Promise => { const authErr = checkAuth(req, secret); if (authErr) return authErr; @@ -941,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; @@ -963,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 => { @@ -985,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; @@ -1011,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; @@ -1027,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; @@ -1048,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; @@ -1072,7 +1074,7 @@ export function registerApiTriggers( }, }); - sdk.registerFunction("api::graph-query", + sdk.registerFunction("api::graph-query", async ( req: ApiRequest<{ startNodeId?: string; @@ -1097,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; @@ -1115,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; @@ -1142,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(); @@ -1164,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 => { @@ -1190,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; @@ -1210,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; @@ -1233,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 } }; }, ); @@ -1246,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 => { @@ -1271,7 +1276,7 @@ export function registerApiTriggers( }, }); - sdk.registerFunction("api::governance-bulk", + sdk.registerFunction("api::governance-bulk", async ( req: ApiRequest<{ type?: string[]; @@ -1296,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; @@ -1314,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" } }; @@ -1333,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; @@ -1690,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; @@ -1716,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 }; }, ); @@ -1734,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; @@ -1752,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; @@ -1775,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 }; }, ); @@ -1794,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 }; }, ); @@ -1811,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 => { @@ -1830,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 => { @@ -1849,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 => { @@ -1888,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 }; }, ); @@ -1904,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 => { @@ -1923,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; @@ -1941,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; @@ -1966,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; @@ -1975,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 }; }, ); @@ -1990,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; @@ -2015,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; @@ -2039,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 }; }, ); @@ -2056,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 => { @@ -2077,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; @@ -2093,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 => { @@ -2111,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; @@ -2127,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; @@ -2176,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; @@ -2203,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; @@ -2218,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; @@ -2233,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; @@ -2248,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; @@ -2278,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; @@ -2288,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; @@ -2298,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: {} }); @@ -2306,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; @@ -2316,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 || {}; @@ -2325,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; @@ -2335,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; @@ -2345,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; @@ -2355,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; @@ -2365,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 || {}; @@ -2374,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: {} }); @@ -2382,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; @@ -2392,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 || {}; @@ -2408,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; @@ -2417,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; @@ -2426,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; @@ -2435,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; @@ -2445,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; @@ -2455,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; @@ -2464,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 || {}; @@ -2474,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 || {}; @@ -2483,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; @@ -2493,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; @@ -2505,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; @@ -2527,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 || {}; @@ -2545,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; @@ -2565,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; @@ -2592,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 || {}; @@ -2622,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/viewer/index.html b/src/viewer/index.html index 27fd095d..10ec6493 100644 --- a/src/viewer/index.html +++ b/src/viewer/index.html @@ -1009,6 +1009,7 @@

Concept Graph

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 }; @@ -1100,7 +1101,7 @@

Concept Graph

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) await loadConcepts(); break; + case 'concepts': if (!state.concepts.loaded) renderConcepts(); break; } } @@ -1130,10 +1131,6 @@

Concept Graph

state.dashboard.crystals = (results[9] && results[9].crystals) || []; state.dashboard.relations = (results[7] && results[7].relations) || []; state.dashboard.loaded = true; - if (tabId === 'dashboard') renderDashboard(); - if (tabId === 'graph') renderGraph(); - if (tabId === 'concepts') renderConcepts(); - if (tabId === 'memories') renderMemories(); renderDashboard(); } @@ -1516,11 +1513,7 @@

Concept Graph

graphSim.canvas = canvas; graphSim.ctx = canvas.getContext('2d'); - if ('__AGENTMEMORY_CONCEPT_GRAPH_VIEWER__' === 'true') { - document.getElementById('tab-concepts').style.display = 'inline-block'; - } - - function checkRestOnline() { + function resize() { var r = canvas.parentElement.getBoundingClientRect(); canvas.width = r.width * window.devicePixelRatio; canvas.height = r.height * window.devicePixelRatio; @@ -2059,14 +2052,14 @@

Concept Graph

function renderConcepts() { var el = document.getElementById('view-concepts'); - if (el.dataset.loaded) return; - el.dataset.loaded = 'true'; + 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 { @@ -3864,6 +3857,9 @@

Concept Graph

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();