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`:

-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/);
+ });
+});