Skip to content

Bug: Knowledge graph extraction never fires automatically #600

Description

@pythonlessons

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

  1. POST /agentmemory/session/end → updates KV only
  2. POST /agentmemory/crystals/auto → crystallization (works)
  3. 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)

  1. 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 } });
  2. Direct trigger in api::session::end — bypass topics, mirror what the dead event handler would have done. Same behavior, one fewer indirection.

  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions