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..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, "export"), + apiFetch(base, "memories?count=true"), 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 = 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/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..bbec4fcd 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,15 @@ export function registerApiTriggers( { type: "set", path: "endedAt", value: new Date().toISOString() }, { type: "set", path: "status", value: "completed" }, ]); + // 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 } }; }, ); @@ -1386,7 +1396,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..93ff349d --- /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(/typeof 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 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"\)/); + }); + + it("status derives obsCount from sessions[].observationCount", () => { + expect(cli).toMatch( + /sessionList\.reduce\([\s\S]*?observationCount/, + ); + }); + + it("status reads memCount from memoriesRes.latestCount (count endpoint)", () => { + expect(cli).toMatch(/memoriesRes\?\.latestCount\s*\?\?\s*memoriesRes\?\.total/); + }); +});