Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1363,7 +1363,7 @@ Create `~/.agentmemory/.env`:

<h2 id="api"><picture><source media="(prefers-color-scheme: dark)" srcset="assets/tags/light/section-api.svg"><img src="assets/tags/section-api.svg" alt="API" height="32" /></picture></h2>

124 endpoints on port `3111`. The REST API binds to `127.0.0.1` by default. Protected endpoints require `Authorization: Bearer <secret>` 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 <secret>` when `AGENTMEMORY_SECRET` is set, and mesh sync endpoints require `AGENTMEMORY_SECRET` on both peers.

<details>
<summary>Key endpoints</summary>
Expand Down
12 changes: 8 additions & 4 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1162,7 +1162,7 @@ async function runStatus() {
apiFetch<any>(base, "health"),
apiFetch<any>(base, "sessions"),
apiFetch<any>(base, "graph/stats"),
apiFetch<any>(base, "export"),
apiFetch<any>(base, "memories?count=true"),
apiFetch<any>(base, "config/flags"),
]);

Expand All @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
77 changes: 76 additions & 1 deletion src/triggers/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 } };
},
);
Expand Down Expand Up @@ -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<Response> => {
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<Session>(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<CompressedObservation>(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<Response> => {
const authErr = checkAuth(req, secret);
if (authErr) return authErr;
Expand Down
86 changes: 86 additions & 0 deletions test/session-end-triggers-graph.test.ts
Original file line number Diff line number Diff line change
@@ -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<Session>\(KV\.sessions\)/);
expect(api).toMatch(/kv\.list<CompressedObservation>\(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/);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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<any>\(base,\s*"memories\?count=true"\)/);
expect(cli).not.toMatch(/apiFetch<any>\(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/);
});
});
Loading