Bug: Knowledge graph extraction never fires automatically
event::session::stopped listens on a topic (agentmemory.session.stopped) that nothing publishes, so mem::graph-extract is never invoked despite GRAPH_EXTRACTION_ENABLED=true.
Environment
agentmemory plugin v0.11.6 (server v0.9.21)
- Installed via Claude Code plugin marketplace (
rohitg00/agentmemory)
- macOS Darwin 24.6.0 (arm64), Node 25.8.1
- Flags set in
~/.agentmemory/.env:
GRAPH_EXTRACTION_ENABLED=true
CONSOLIDATION_ENABLED=true
AGENTMEMORY_AUTO_COMPRESS=true
- LLM provider: DeepSeek via MiniMax slot. Embeddings: local.
Symptom
After ~2 weeks of normal usage:
- 68 sessions, ~13,000 observations.
mem::compress ran successfully 9,977 times (each producing a compressed observation with a title — exactly what graph extraction expects as input).
- Dashboard at
:3113 reports Graph: 0 nodes, 0 edges.
GET /agentmemory/graph/stats returns:
{"totalNodes":0,"totalEdges":0,"nodesByType":{},"edgesByType":{}}
functionMetrics in /agentmemory/health shows only mem::summarize and mem::compress — mem::graph-extract is never tracked, indicating it has never been invoked.
Root cause
The session-end event handler in src/triggers/events.ts:47-76 is correctly wired to fire graph extraction:
sdk.registerFunction("event::session::stopped", async (data: { sessionId: string }) => {
const summary = await sdk.trigger({ function_id: "mem::summarize", payload: data });
if (isReflectEnabled()) { /* ... */ }
if (isGraphExtractionEnabled()) {
try {
const observations = await kv.list<CompressedObservation>(
KV.observations(data.sessionId),
);
const compressed = observations.filter((o) => o.title);
if (compressed.length > 0) {
sdk.triggerVoid("mem::graph-extract", { observations: compressed });
}
} catch (err) { /* ... */ }
}
return summary;
});
sdk.registerTrigger({
type: "durable:subscriber",
function_id: "event::session::stopped",
config: { topic: "agentmemory.session.stopped" }, // <-- subscribes here
});
But nothing publishes to agentmemory.session.stopped. Repo-wide search:
$ grep -rn "agentmemory.session.stopped" src/
src/triggers/events.ts:80: config: { topic: "agentmemory.session.stopped" },
Only the subscriber. No producer.
The actual session-shutdown path goes through api::session::end (src/triggers/api.ts:570-585), which only mutates KV:
sdk.registerFunction("api::session::end",
async (req: ApiRequest<{ sessionId: string }>): Promise<Response> => {
const sessionId = asNonEmptyString((req.body as Record<string, unknown>)?.sessionId);
if (!sessionId) { /* ... */ }
await kv.update(KV.sessions, sessionId, [
{ type: "set", path: "endedAt", value: new Date().toISOString() },
{ type: "set", path: "status", value: "completed" },
]);
return { status_code: 200, body: { success: true } };
},
);
No topic publication, no direct trigger of graph extraction.
The Claude Code plugin's plugin/scripts/session-end.mjs hook calls three endpoints:
POST /agentmemory/session/end → updates KV only
POST /agentmemory/crystals/auto → crystallization (works)
POST /agentmemory/consolidate-pipeline → semantic / reflect / decay (works)
It does not call /agentmemory/graph/extract. Combined with the dead topic, there is no code path that invokes mem::graph-extract automatically.
Proof the extractor itself works
Direct invocation of the REST endpoint succeeds and produces meaningful nodes/edges:
$ curl -s -X POST http://127.0.0.1:3111/agentmemory/graph/extract \
-H "Content-Type: application/json" \
-d '{"observations":[<5 compressed observations from one session>]}'
{"edgesAdded":10,"nodesAdded":12,"success":true}
$ curl -s http://127.0.0.1:3111/agentmemory/graph/stats
{
"totalNodes":12,
"totalEdges":10,
"nodesByType":{"concept":9,"file":3},
"edgesByType":{"related_to":10}
}
A second batch from another session (312 observations) added 3 more nodes (deduplication working as intended). The function is fully operational — only the trigger is broken.
Suggested fixes (any one of these resolves it)
-
Publish the missing topic — in api::session::end, after the KV update, publish to agentmemory.session.stopped. Smallest delta, preserves the event-driven design:
await kv.update(KV.sessions, sessionId, [...]);
await sdk.publish({ topic: "agentmemory.session.stopped", payload: { sessionId } });
-
Direct trigger in api::session::end — bypass topics, mirror what the dead event handler would have done. Same behavior, one fewer indirection.
-
Add graph/extract to the session-end hook script — patch plugin/scripts/session-end.mjs to POST /agentmemory/graph/extract after /session/end, using a paginated fetch of compressed observations. (This is what we did locally — see workaround below.)
Workaround for users hitting this now
Add a user-level Claude Code SessionEnd hook in ~/.claude/settings.json that runs alongside the plugin's hook:
"SessionEnd": [
{
"hooks": [
{
"type": "command",
"command": "node ~/.claude/hooks/agentmemory-graph-extract.mjs"
}
]
}
]
Where ~/.claude/hooks/agentmemory-graph-extract.mjs:
#!/usr/bin/env node
const REST_URL = process.env["AGENTMEMORY_URL"] || "http://localhost:3111";
const SECRET = process.env["AGENTMEMORY_SECRET"] || "";
const BATCH_SIZE = 50;
const LIMIT = 500;
function authHeaders() {
const h = { "Content-Type": "application/json" };
if (SECRET) h["Authorization"] = `Bearer ${SECRET}`;
return h;
}
async function main() {
let input = "";
for await (const chunk of process.stdin) input += chunk;
let data;
try { data = JSON.parse(input); } catch { return; }
if (process.env["AGENTMEMORY_SDK_CHILD"] === "1") return;
if (data?.entrypoint === "sdk-ts") return;
const sessionId = data.session_id;
if (!sessionId || sessionId === "unknown") return;
try {
const res = await fetch(
`${REST_URL}/agentmemory/observations?sessionId=${encodeURIComponent(sessionId)}&limit=${LIMIT}`,
{ headers: authHeaders(), signal: AbortSignal.timeout(30000) },
);
if (!res.ok) return;
const body = await res.json();
const observations = (body?.observations || []).filter(
(o) => o && typeof o.title === "string" && o.title.length > 0,
);
for (let i = 0; i < observations.length; i += BATCH_SIZE) {
await fetch(`${REST_URL}/agentmemory/graph/extract`, {
method: "POST",
headers: authHeaders(),
body: JSON.stringify({ observations: observations.slice(i, i + BATCH_SIZE) }),
signal: AbortSignal.timeout(120000),
});
}
} catch { /* best-effort */ }
}
main();
After installation, the graph populates on every session end. Plugin updates do not overwrite user settings.
Related observations (not bugs, just notes for maintainers)
- The dashboard headline panel labels
Memories, Lessons, Crystals, Graph Nodes mislead users when only the curated-layer counters are shown there. New users see "0 / 0 / 0 / 0" for weeks and assume the plugin is broken, even though semantic and insights are growing in the background. Surfacing those in the dashboard, or renaming the counters to reflect what they actually count, would reduce this confusion.
mem::compress failure rate on DeepSeek-v4-flash via MiniMax slot: 21.5% (2144 failures / 9977 calls). Circuit breaker stays closed but observations silently lose compression. Consider documenting expected failure rates per provider or adding a per-provider health hint.
Bug: Knowledge graph extraction never fires automatically
event::session::stoppedlistens on a topic (agentmemory.session.stopped) that nothing publishes, somem::graph-extractis never invoked despiteGRAPH_EXTRACTION_ENABLED=true.Environment
agentmemoryplugin v0.11.6 (server v0.9.21)rohitg00/agentmemory)~/.agentmemory/.env:Symptom
After ~2 weeks of normal usage:
mem::compressran successfully 9,977 times (each producing a compressed observation with atitle— exactly what graph extraction expects as input).:3113reports Graph: 0 nodes, 0 edges.GET /agentmemory/graph/statsreturns:{"totalNodes":0,"totalEdges":0,"nodesByType":{},"edgesByType":{}}functionMetricsin/agentmemory/healthshows onlymem::summarizeandmem::compress—mem::graph-extractis never tracked, indicating it has never been invoked.Root cause
The session-end event handler in
src/triggers/events.ts:47-76is correctly wired to fire graph extraction:But nothing publishes to
agentmemory.session.stopped. Repo-wide search:Only the subscriber. No producer.
The actual session-shutdown path goes through
api::session::end(src/triggers/api.ts:570-585), which only mutates KV:No topic publication, no direct trigger of graph extraction.
The Claude Code plugin's
plugin/scripts/session-end.mjshook calls three endpoints:POST /agentmemory/session/end→ updates KV onlyPOST /agentmemory/crystals/auto→ crystallization (works)POST /agentmemory/consolidate-pipeline→ semantic / reflect / decay (works)It does not call
/agentmemory/graph/extract. Combined with the dead topic, there is no code path that invokesmem::graph-extractautomatically.Proof the extractor itself works
Direct invocation of the REST endpoint succeeds and produces meaningful nodes/edges:
$ curl -s -X POST http://127.0.0.1:3111/agentmemory/graph/extract \ -H "Content-Type: application/json" \ -d '{"observations":[<5 compressed observations from one session>]}' {"edgesAdded":10,"nodesAdded":12,"success":true} $ curl -s http://127.0.0.1:3111/agentmemory/graph/stats { "totalNodes":12, "totalEdges":10, "nodesByType":{"concept":9,"file":3}, "edgesByType":{"related_to":10} }A second batch from another session (312 observations) added 3 more nodes (deduplication working as intended). The function is fully operational — only the trigger is broken.
Suggested fixes (any one of these resolves it)
Publish the missing topic — in
api::session::end, after the KV update, publish toagentmemory.session.stopped. Smallest delta, preserves the event-driven design:Direct trigger in
api::session::end— bypass topics, mirror what the dead event handler would have done. Same behavior, one fewer indirection.Add graph/extract to the session-end hook script — patch
plugin/scripts/session-end.mjsto POST/agentmemory/graph/extractafter/session/end, using a paginated fetch of compressed observations. (This is what we did locally — see workaround below.)Workaround for users hitting this now
Add a user-level Claude Code
SessionEndhook in~/.claude/settings.jsonthat runs alongside the plugin's hook:Where
~/.claude/hooks/agentmemory-graph-extract.mjs:After installation, the graph populates on every session end. Plugin updates do not overwrite user settings.
Related observations (not bugs, just notes for maintainers)
Memories,Lessons,Crystals,Graph Nodesmislead users when only the curated-layer counters are shown there. New users see "0 / 0 / 0 / 0" for weeks and assume the plugin is broken, even thoughsemanticandinsightsare growing in the background. Surfacing those in the dashboard, or renaming the counters to reflect what they actually count, would reduce this confusion.mem::compressfailure rate on DeepSeek-v4-flash via MiniMax slot: 21.5% (2144 failures / 9977 calls). Circuit breaker stays closed but observations silently lose compression. Consider documenting expected failure rates per provider or adding a per-provider health hint.