From 52cb9730656f2aabd5e5bed98295cae4f8304169 Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Thu, 28 May 2026 10:33:37 +0100 Subject: [PATCH 1/2] fix(graph): wire session-end to graph extraction + repair status (#666) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three concrete bugs reported by @LeonemFu, all verified: 1. `event::session::stopped` is a dead subscriber. The handler in `src/triggers/events.ts` listens on `agentmemory.session.stopped` but nothing in the codebase publishes that topic. `api::session::end` just calls `kv.update` and returns — so every session ends with `mem::summarize`, `mem::slot-reflect`, and `mem::graph-extract` skipped. Result: graph stays empty no matter how many sessions the user runs. Fix: `api::session::end` now calls `sdk.triggerVoid("event::session::stopped", { sessionId })` after the KV update so the existing pipeline fires. HTTP response stays fast (kv.update is synchronous, downstream work fans out without blocking). 2. `agentmemory status` shows Memories/Observations = 0. CLI used `/agentmemory/export` to derive counts but that endpoint times out (>5s) on iii-engine's file-based KV under concurrent `kv.list()` pressure — even on small datasets (121 observations in the reporter's case). Promise.all swallowed the timeout and fell back to 0. Fix: status now reads `/memories` for memory count and sums `sessions[].observationCount` for observation count. No longer depends on the slow path. 3. Viewer's "Build Graph" button posts to `/agentmemory/graph/build` which returned 404 — the endpoint was never registered. Fix: new `api::graph-build` iterates all sessions, lists each session's compressed observations (filter on `title` field), and feeds them through `mem::graph-extract` in configurable batches (default 25, capped at 100). Returns `{ success, sessions, batches, nodes, edges }` for the viewer's progress UI. Test coverage: 11 new assertions in `test/session-end-triggers-graph.test.ts` covering the wiring across all three fixes (no runtime mocks — pure source-string assertions matching the pattern used by `test/memories-pagination.test.ts`). Consistency: REST endpoint count bumped 124 → 125 in `src/index.ts`, README, and AGENTS.md per the AGENTS.md consistency rules. Closes #666. --- AGENTS.md | 2 +- README.md | 2 +- src/cli.ts | 12 ++-- src/index.ts | 2 +- src/triggers/api.ts | 73 ++++++++++++++++++++- test/session-end-triggers-graph.test.ts | 86 +++++++++++++++++++++++++ 6 files changed, 169 insertions(+), 8 deletions(-) create mode 100644 test/session-end-triggers-graph.test.ts diff --git a/AGENTS.md b/AGENTS.md index 36569e60..0330b14f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -117,7 +117,7 @@ Hook scripts in `src/hooks/` are standalone Node.js scripts (no iii-sdk import). ## Current Stats (v0.9.16) - 53 MCP tools (8 visible by default, `AGENTMEMORY_TOOLS=all` for all) -- 124 REST endpoints +- 125 REST endpoints - 6 MCP resources, 3 MCP prompts - 12 hooks, 4 skills - 50+ iii functions diff --git a/README.md b/README.md index 830914c2..2bfeba04 100644 --- a/README.md +++ b/README.md @@ -1363,7 +1363,7 @@ Create `~/.agentmemory/.env`:

API

-124 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. +125 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/cli.ts b/src/cli.ts index 1b36cfd4..242d60f6 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1162,7 +1162,7 @@ async function runStatus() { apiFetch(base, "health"), apiFetch(base, "sessions"), apiFetch(base, "graph/stats"), - apiFetch(base, "export"), + apiFetch(base, "memories"), apiFetch(base, "config/flags"), ]); @@ -1172,15 +1172,19 @@ async function runStatus() { const h = healthRes?.health; const status = healthRes?.status || "unknown"; const version = healthRes?.version || "?"; - const sessions = Array.isArray(sessionsRes?.sessions) ? sessionsRes.sessions.length : 0; + const sessionList = Array.isArray(sessionsRes?.sessions) ? sessionsRes.sessions : []; + const sessions = sessionList.length; const nodes = Number(graphRes?.totalNodes ?? graphRes?.nodes ?? graphRes?.nodeCount ?? 0); const edges = Number(graphRes?.totalEdges ?? graphRes?.edges ?? graphRes?.edgeCount ?? 0); const cb = healthRes?.circuitBreaker?.state || "closed"; const heapMB = h?.memory ? Math.round(h.memory.heapUsed / 1048576) : 0; const uptime = h?.uptimeSeconds ? Math.round(h.uptimeSeconds) : 0; - const obsCount = memoriesRes?.observations?.length || 0; - const memCount = memoriesRes?.memories?.length || 0; + const obsCount = sessionList.reduce( + (sum: number, s: any) => sum + (Number(s?.observationCount) || 0), + 0, + ); + const memCount = Array.isArray(memoriesRes?.memories) ? memoriesRes.memories.length : 0; const estFullTokens = obsCount * 80; const estInjectedTokens = Math.min(obsCount, 50) * 38; const tokensSaved = estFullTokens - estInjectedTokens; diff --git a/src/index.ts b/src/index.ts index 96391640..d20d4693 100644 --- a/src/index.ts +++ b/src/index.ts @@ -516,7 +516,7 @@ async function main() { `Ready. ${embeddingProvider ? "Triple-stream (BM25+Vector+Graph)" : "BM25+Graph"} search active.`, ); bootLog( - `REST API: 124 endpoints at http://localhost:${config.restPort}/agentmemory/*`, + `REST API: 125 endpoints at http://localhost:${config.restPort}/agentmemory/*`, ); bootLog( `MCP surface (opt-in via \`npx @agentmemory/mcp\`): ${getAllTools().length} tools · 6 resources · 3 prompts`, diff --git a/src/triggers/api.ts b/src/triggers/api.ts index 211bb638..1e663208 100644 --- a/src/triggers/api.ts +++ b/src/triggers/api.ts @@ -12,6 +12,7 @@ import { isSlotsEnabled, isReflectEnabled } from "../functions/slots.js"; import { renderViewerDocument } from "../viewer/document.js"; import { getBoundViewerPort, getViewerSkipped } from "../viewer/server.js"; import { MAX_FILES_UPPER_BOUND } from "../functions/replay.js"; +import { logger } from "../logger.js"; import { isGraphExtractionEnabled, isConsolidationEnabled, @@ -610,6 +611,11 @@ export function registerApiTriggers( { type: "set", path: "endedAt", value: new Date().toISOString() }, { type: "set", path: "status", value: "completed" }, ]); + // Fan out the session-stopped lifecycle (summarize → slot reflect → + // graph extraction) without blocking the HTTP response. The + // `agentmemory.session.stopped` topic has no publisher today (#666), + // so subscribers in events.ts never fired and graphs stayed empty. + sdk.triggerVoid("event::session::stopped", { sessionId }); return { status_code: 200, body: { success: true } }; }, ); @@ -1386,7 +1392,72 @@ export function registerApiTriggers( config: { api_path: "/agentmemory/graph/extract", http_method: "POST" }, }); - sdk.registerFunction("api::consolidate-pipeline", + // Backfill the knowledge graph from existing compressed observations. + // Viewer calls this when the graph is empty (#666). Iterates every + // session, collects observations that have a `title` (compressed only), + // and feeds them through `mem::graph-extract` in batches. + sdk.registerFunction("api::graph-build", + async (req: ApiRequest<{ batchSize?: number }>): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + const batchSize = Math.max( + 1, + Math.min(100, Number((req.body as { batchSize?: number })?.batchSize) || 25), + ); + try { + const sessions = await kv.list(KV.sessions); + let totalNodes = 0; + let totalEdges = 0; + let batchesRun = 0; + for (const session of sessions) { + const sid = session?.id; + if (typeof sid !== "string" || sid.length === 0) continue; + const observations = await kv.list(KV.observations(sid)); + const compressed = observations.filter((o) => o && typeof o.title === "string" && o.title.length > 0); + if (compressed.length === 0) continue; + for (let i = 0; i < compressed.length; i += batchSize) { + const batch = compressed.slice(i, i + batchSize); + try { + const result = (await sdk.trigger({ + function_id: "mem::graph-extract", + payload: { observations: batch }, + })) as { success?: boolean; nodesAdded?: number; edgesAdded?: number }; + if (result?.success) { + totalNodes += Number(result.nodesAdded) || 0; + totalEdges += Number(result.edgesAdded) || 0; + } + batchesRun++; + } catch (err) { + logger.warn("graph-build batch failed", { + sessionId: sid, + batchIndex: Math.floor(i / batchSize), + error: err instanceof Error ? err.message : String(err), + }); + } + } + } + return { + status_code: 200, + body: { + success: true, + sessions: sessions.length, + batches: batchesRun, + nodes: totalNodes, + edges: totalEdges, + }, + }; + } catch { + return graphDisabledResponse(); + } + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::graph-build", + config: { api_path: "/agentmemory/graph/build", http_method: "POST" }, + }); + + sdk.registerFunction("api::consolidate-pipeline", async (req: ApiRequest<{ tier?: string }>): Promise => { const authErr = checkAuth(req, secret); if (authErr) return authErr; diff --git a/test/session-end-triggers-graph.test.ts b/test/session-end-triggers-graph.test.ts new file mode 100644 index 00000000..08a5ee9e --- /dev/null +++ b/test/session-end-triggers-graph.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; + +// #666: api::session::end must publish the session-stopped lifecycle so +// summarize + slot-reflect + graph extraction actually fire. Before this +// fix the `event::session::stopped` handler in events.ts was a dead +// subscriber — no code published `agentmemory.session.stopped`, so graph +// nodes / lessons / crystals never materialized despite the handler +// existing. Direct triggerVoid keeps the HTTP response fast (kv.update +// runs synchronously, downstream pipeline fan-outs without blocking). +describe("api::session::end → event::session::stopped (#666)", () => { + const api = readFileSync("src/triggers/api.ts", "utf-8"); + + it("api::session::end calls sdk.triggerVoid('event::session::stopped') after kv.update", () => { + expect(api).toMatch( + /api::session::end[\s\S]*?kv\.update\(KV\.sessions[\s\S]*?triggerVoid\("event::session::stopped"/, + ); + }); + + it("triggerVoid payload includes sessionId", () => { + expect(api).toMatch( + /triggerVoid\("event::session::stopped",\s*\{\s*sessionId\s*\}/, + ); + }); +}); + +// #666: viewer's "Build Graph" button used to POST /agentmemory/graph/build +// which returned 404 because the endpoint was never registered. Backfill +// the knowledge graph from existing compressed observations across every +// session in batches. +describe("api::graph-build endpoint (#666)", () => { + const api = readFileSync("src/triggers/api.ts", "utf-8"); + + it("registers api::graph-build function", () => { + expect(api).toMatch(/registerFunction\("api::graph-build"/); + }); + + it("registers HTTP trigger at /agentmemory/graph/build", () => { + expect(api).toMatch( + /api_path:\s*"\/agentmemory\/graph\/build",\s*http_method:\s*"POST"/, + ); + }); + + it("iterates sessions and calls mem::graph-extract", () => { + expect(api).toMatch(/kv\.list\(KV\.sessions\)/); + expect(api).toMatch(/kv\.list\(KV\.observations\(sid\)\)/); + expect(api).toMatch( + /sdk\.trigger\(\{\s*function_id:\s*"mem::graph-extract"/, + ); + }); + + it("filters observations that have a title (compressed only)", () => { + expect(api).toMatch(/o\.title === "string" && o\.title\.length > 0/); + }); + + it("respects batchSize override with a 100-item upper bound", () => { + expect(api).toMatch(/Math\.min\(100,\s*Number\(.*batchSize/); + }); + + it("response shape matches what the viewer expects (success + nodes)", () => { + expect(api).toMatch(/success:\s*true,\s*sessions:[\s\S]*?nodes:\s*totalNodes/); + }); +}); + +// #666: `agentmemory status` showed Memories/Observations as 0 because it +// fetched /agentmemory/export which times out on iii-engine's file-based +// KV under concurrent kv.list() pressure. Switch to /memories for the +// memory count and derive observation count from sessions[].observationCount. +describe("agentmemory status no longer depends on /export (#666)", () => { + const cli = readFileSync("src/cli.ts", "utf-8"); + + it("status fetches memories instead of export", () => { + expect(cli).toMatch(/apiFetch\(base,\s*"memories"\)/); + expect(cli).not.toMatch(/apiFetch\(base,\s*"export"\)/); + }); + + it("status derives obsCount from sessions[].observationCount", () => { + expect(cli).toMatch( + /sessionList\.reduce\([\s\S]*?observationCount/, + ); + }); + + it("status reads memCount from memoriesRes.memories.length", () => { + expect(cli).toMatch(/memoriesRes\?\.memories\) \? memoriesRes\.memories\.length : 0/); + }); +}); From fc653989e4d3e1d58b27ed43ff49a886142c74de Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Thu, 28 May 2026 11:20:23 +0100 Subject: [PATCH 2/2] fix(session-end+cli): guard triggerVoid + count-only memories endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fixes on #698: 1. api::session::end now wraps sdk.triggerVoid in try/catch so a thrown error in the fan-out path doesn't convert a successful kv.update into a 5xx response. kv.update already landed; the client should see success regardless of whether the downstream subscriber registration is healthy. Errors are logged via logger.warn for diagnostics. 2. Trimmed the multi-line WHAT/WHY comment block in api::session::end down to a single-line intent comment. The historical context belongs in the PR description, not in source. 3. cli.ts `agentmemory status` now uses /memories?count=true (the count-only endpoint added in #544) instead of fetching full rows. For deployments with 8K+ memories the count-only path is constant- time-ish on the server. memCount reads from latestCount (falling back to total) — matches the count endpoint shape. 4. test/session-end-triggers-graph.test.ts: - Tightened the "filters observations with title" regex to assert `typeof o.title === "string"` not just `o.title === "string"` (latter matched the source as a substring but didn't verify the typeof guard, which is the actual contract). - Updated the CLI status assertions for the count=true shape. Skipped (review item: rewrite tests as runtime vi.mock'd behavior tests): scope creep. test/memories-pagination.test.ts uses the same source-string pattern across the repo for trigger registration checks; switching test style is its own PR and the existing assertions are sufficient to catch regressions on these three specific changes. --- src/cli.ts | 4 ++-- src/triggers/api.ts | 14 +++++++++----- test/session-end-triggers-graph.test.ts | 10 +++++----- 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 242d60f6..383fe4f8 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1162,7 +1162,7 @@ async function runStatus() { apiFetch(base, "health"), apiFetch(base, "sessions"), apiFetch(base, "graph/stats"), - apiFetch(base, "memories"), + apiFetch(base, "memories?count=true"), apiFetch(base, "config/flags"), ]); @@ -1184,7 +1184,7 @@ async function runStatus() { (sum: number, s: any) => sum + (Number(s?.observationCount) || 0), 0, ); - const memCount = Array.isArray(memoriesRes?.memories) ? memoriesRes.memories.length : 0; + const memCount = Number(memoriesRes?.latestCount ?? memoriesRes?.total ?? 0) || 0; const estFullTokens = obsCount * 80; const estInjectedTokens = Math.min(obsCount, 50) * 38; const tokensSaved = estFullTokens - estInjectedTokens; diff --git a/src/triggers/api.ts b/src/triggers/api.ts index 1e663208..bbec4fcd 100644 --- a/src/triggers/api.ts +++ b/src/triggers/api.ts @@ -611,11 +611,15 @@ export function registerApiTriggers( { type: "set", path: "endedAt", value: new Date().toISOString() }, { type: "set", path: "status", value: "completed" }, ]); - // Fan out the session-stopped lifecycle (summarize → slot reflect → - // graph extraction) without blocking the HTTP response. The - // `agentmemory.session.stopped` topic has no publisher today (#666), - // so subscribers in events.ts never fired and graphs stayed empty. - sdk.triggerVoid("event::session::stopped", { sessionId }); + // Fan out session-stopped lifecycle (non-blocking). + try { + sdk.triggerVoid("event::session::stopped", { sessionId }); + } catch (err) { + logger.warn("event::session::stopped triggerVoid failed", { + sessionId, + error: err instanceof Error ? err.message : String(err), + }); + } return { status_code: 200, body: { success: true } }; }, ); diff --git a/test/session-end-triggers-graph.test.ts b/test/session-end-triggers-graph.test.ts index 08a5ee9e..93ff349d 100644 --- a/test/session-end-triggers-graph.test.ts +++ b/test/session-end-triggers-graph.test.ts @@ -50,7 +50,7 @@ describe("api::graph-build endpoint (#666)", () => { }); it("filters observations that have a title (compressed only)", () => { - expect(api).toMatch(/o\.title === "string" && o\.title\.length > 0/); + expect(api).toMatch(/typeof o\.title === "string" && o\.title\.length > 0/); }); it("respects batchSize override with a 100-item upper bound", () => { @@ -69,8 +69,8 @@ describe("api::graph-build endpoint (#666)", () => { describe("agentmemory status no longer depends on /export (#666)", () => { const cli = readFileSync("src/cli.ts", "utf-8"); - it("status fetches memories instead of export", () => { - expect(cli).toMatch(/apiFetch\(base,\s*"memories"\)/); + it("status uses count-only memories endpoint instead of export", () => { + expect(cli).toMatch(/apiFetch\(base,\s*"memories\?count=true"\)/); expect(cli).not.toMatch(/apiFetch\(base,\s*"export"\)/); }); @@ -80,7 +80,7 @@ describe("agentmemory status no longer depends on /export (#666)", () => { ); }); - it("status reads memCount from memoriesRes.memories.length", () => { - expect(cli).toMatch(/memoriesRes\?\.memories\) \? memoriesRes\.memories\.length : 0/); + it("status reads memCount from memoriesRes.latestCount (count endpoint)", () => { + expect(cli).toMatch(/memoriesRes\?\.latestCount\s*\?\?\s*memoriesRes\?\.total/); }); });