@cognee/cognee-openclaw 2026.4.13 → 2026.6.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,29 @@
1
- import { randomUUID } from "node:crypto";
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ // SyncResult is used as the return type of the per-agent sync helpers below.
2
4
  import { MEMORY_SCOPES } from "./types.js";
3
5
  import { CogneeHttpClient } from "./client.js";
4
6
  import { resolveConfig } from "./config.js";
5
7
  import { collectMemoryFiles } from "./files.js";
6
8
  import { buildMemoryFlushPlan } from "./flush-plan.js";
7
- import { loadDatasetState, loadScopedSyncIndexes, loadSyncIndex, saveDatasetState, saveScopedSyncIndexes, saveSyncIndex, migrateLegacyIndex, SYNC_INDEX_PATH, } from "./persistence.js";
8
- import { datasetNameForScope, fallbackDatasetIdForScope, isMultiScopeEnabled, routeFileToScope } from "./scope.js";
9
+ import { loadDatasetState, loadScopedSyncIndexes, loadSyncIndex, loadAgentSyncIndexes, saveDatasetState, saveScopedSyncIndexes, saveSyncIndex, saveAgentSyncIndexes, migrateLegacyIndex, migrateAgentScopeToPerAgent, SYNC_INDEX_PATH, } from "./persistence.js";
10
+ import { datasetNameForScope, isMultiScopeEnabled, normalizeAgentId, routeFileToScope } from "./scope.js";
9
11
  import { syncFiles, syncFilesScoped } from "./sync.js";
12
+ /** Expand a leading `~` in a workspace path to the user's home directory. */
13
+ function expandHome(p) {
14
+ if (!p)
15
+ return p;
16
+ if (p === "~")
17
+ return homedir();
18
+ if (p.startsWith("~/"))
19
+ return join(homedir(), p.slice(2));
20
+ return p;
21
+ }
22
+ // Module-scope dedupe so a duplicate register() (e.g. plugin loaded twice via
23
+ // different module specifiers) doesn't run startup auto-sync twice for the
24
+ // same workspace. The in-closure autoSyncStarted flag inside register() can't
25
+ // catch this because each register() call gets its own closure.
26
+ const autoSyncedWorkspaces = new Set();
10
27
  const memoryCogneePlugin = {
11
28
  id: "cognee-openclaw",
12
29
  name: "Memory (Cognee)",
@@ -14,6 +31,23 @@ const memoryCogneePlugin = {
14
31
  kind: "memory",
15
32
  register(api) {
16
33
  const cfg = resolveConfig(api.pluginConfig);
34
+ // Auto-enable per-agent memory when the gateway hosts more than one agent,
35
+ // unless the plugin config set `perAgentMemory` explicitly. This keeps
36
+ // single-agent installs (the common case) on the legacy shared behavior so
37
+ // the upgrade is non-breaking; multi-agent gateways get per-agent isolation.
38
+ const perAgentExplicit = typeof api.pluginConfig?.perAgentMemory === "boolean";
39
+ if (!perAgentExplicit) {
40
+ try {
41
+ const agentList = api.runtime?.config?.loadConfig?.()?.agents?.list;
42
+ if (Array.isArray(agentList) && agentList.length > 1) {
43
+ cfg.perAgentMemory = true;
44
+ api.logger.info?.(`cognee-openclaw: per-agent memory auto-enabled (${agentList.length} agents configured)`);
45
+ }
46
+ }
47
+ catch (error) {
48
+ api.logger.debug?.(`cognee-openclaw: could not read agents.list for perAgentMemory auto-enable: ${String(error)}`);
49
+ }
50
+ }
17
51
  const client = new CogneeHttpClient(cfg.baseUrl, cfg.apiKey, cfg.username, cfg.password, cfg.requestTimeoutMs, cfg.ingestionTimeoutMs, cfg.mode);
18
52
  const multiScope = isMultiScopeEnabled(cfg);
19
53
  api.registerMemoryFlushPlan?.(buildMemoryFlushPlan);
@@ -21,14 +55,46 @@ const memoryCogneePlugin = {
21
55
  // Legacy single-scope state
22
56
  let datasetId;
23
57
  let syncIndex = { entries: {} };
24
- // Multi-scope state
58
+ // Multi-scope state (company/user shared; agent scope lives in agentIndexes
59
+ // when perAgentMemory is on).
25
60
  let scopedIndexes = {};
61
+ // Per-agent agent-scope state (perAgentMemory mode), keyed by normalized agentId.
62
+ let agentIndexes = {};
63
+ const perAgentMemory = multiScope && cfg.perAgentMemory;
64
+ // Serialize sync work per agent so concurrent turns of the SAME agent don't
65
+ // double-run. Keyed by normalized agentId.
66
+ const agentLocks = new Map();
67
+ function withAgentLock(agentId, fn) {
68
+ const prev = agentLocks.get(agentId) ?? Promise.resolve();
69
+ const next = prev.catch(() => { }).then(fn);
70
+ agentLocks.set(agentId, next.catch(() => { }));
71
+ return next;
72
+ }
73
+ // Global lock around the read-modify-write of agent-sync-indexes.json.
74
+ // DIFFERENT agents are NOT serialized by agentLocks, so without this their
75
+ // concurrent load→mutate→save would clobber each other's bucket (they share
76
+ // one file). This makes the reload+set+save atomic so distinct buckets merge.
77
+ let indexSaveChain = Promise.resolve();
78
+ function withIndexSaveLock(fn) {
79
+ const next = indexSaveChain.catch(() => { }).then(fn);
80
+ indexSaveChain = next.catch(() => { });
81
+ return next;
82
+ }
26
83
  // Session state
27
84
  let sessionId;
85
+ // Cached as a fallback for paths that may lack ctx.
86
+ let lastAgentId;
87
+ let lastWorkspaceDir;
88
+ // Per-agent workspace cache (normalized agentId -> workspaceDir), populated
89
+ // on agent_end. session_end's ctx carries agentId but NOT workspaceDir, so
90
+ // this lets the final sweep find the right agent's workspace without falling
91
+ // back to a single global (which mis-attributes when >1 agent is active).
92
+ const agentWorkspaces = new Map();
28
93
  let resolvedWorkspaceDir;
29
94
  let resolveServiceReady;
30
95
  const serviceReady = new Promise((r) => { resolveServiceReady = r; });
31
- // Load persisted state on startup
96
+ // Hoisted so CLI processes can suppress the gateway's auto-sync timer.
97
+ let autoSyncStarted = false;
32
98
  const stateReady = Promise.all([
33
99
  loadDatasetState()
34
100
  .then((state) => {
@@ -52,6 +118,18 @@ const memoryCogneePlugin = {
52
118
  }
53
119
  }
54
120
  scopedIndexes = indexes;
121
+ })
122
+ .then(async () => {
123
+ if (!perAgentMemory)
124
+ return;
125
+ // Move any legacy shared `agent` scope entry into the per-agent map.
126
+ const migrated = await migrateAgentScopeToPerAgent(normalizeAgentId(undefined, cfg));
127
+ if (migrated) {
128
+ api.logger.info?.("cognee-openclaw: migrated shared agent scope index to per-agent");
129
+ // Reload shared indexes (migration removed the agent entry from them).
130
+ scopedIndexes = await loadScopedSyncIndexes();
131
+ }
132
+ agentIndexes = await loadAgentSyncIndexes();
55
133
  })
56
134
  .catch((error) => {
57
135
  api.logger.warn?.(`cognee-openclaw: failed to load scoped sync indexes: ${String(error)}`);
@@ -67,6 +145,15 @@ const memoryCogneePlugin = {
67
145
  api.logger.warn?.(`cognee-openclaw: failed to load sync index: ${String(error)}`);
68
146
  }),
69
147
  ]);
148
+ // Resolve the locally-cached fallback dataset id for a scope. For the agent
149
+ // scope under perAgentMemory, that's the per-agent index; otherwise the
150
+ // shared scoped index.
151
+ function scopeFallbackDatasetId(scope, runtimeAgentId) {
152
+ if (scope === "agent" && perAgentMemory) {
153
+ return agentIndexes[normalizeAgentId(runtimeAgentId, cfg)]?.datasetId;
154
+ }
155
+ return scopedIndexes[scope]?.datasetId;
156
+ }
70
157
  // Fix #8: Log when scopes have no dataset ID during recall
71
158
  async function getRecallDatasetIds(runtimeAgentId) {
72
159
  const state = await loadDatasetState();
@@ -75,7 +162,7 @@ const memoryCogneePlugin = {
75
162
  if (multiScope) {
76
163
  for (const scope of cfg.recallScopes) {
77
164
  const dsName = datasetNameForScope(scope, cfg, runtimeAgentId);
78
- const dsId = state[dsName] ?? fallbackDatasetIdForScope(scope, dsName, scopedIndexes[scope], runtimeAgentId);
165
+ const dsId = state[dsName] ?? scopeFallbackDatasetId(scope, runtimeAgentId);
79
166
  if (dsId) {
80
167
  ids.push(dsId);
81
168
  }
@@ -90,8 +177,79 @@ const memoryCogneePlugin = {
90
177
  }
91
178
  return { ids, missingScopes };
92
179
  }
93
- // Helper: run sync
94
- async function runSync(workspaceDir, logger) {
180
+ // Sync ONE agent's `agent`-scope files from its own workspace into its own
181
+ // dataset + per-agent index. Serialized per agentId. Used by per-agent mode.
182
+ async function syncAgentScope(workspaceDir, rawAgentId, logger) {
183
+ await stateReady;
184
+ const agentId = normalizeAgentId(rawAgentId, cfg);
185
+ return withAgentLock(agentId, async () => {
186
+ const allFiles = await collectMemoryFiles(workspaceDir);
187
+ const agentFiles = allFiles.filter((f) => routeFileToScope(f.path, cfg.scopeRouting, cfg.defaultWriteScope) === "agent");
188
+ // Start from this agent's latest persisted bucket.
189
+ const idx = (await loadAgentSyncIndexes())[agentId] ?? { entries: {} };
190
+ const dsName = datasetNameForScope("agent", cfg, agentId);
191
+ // syncFiles mutates `idx` in place (entries/dataIds) but does not persist
192
+ // it (persistIndex=false); we own persistence below.
193
+ const result = await syncFiles(client, agentFiles, agentFiles, idx, cfg, logger, dsName, false);
194
+ // Atomic merge-save: reload the latest on-disk map (may include other
195
+ // agents' buckets written meanwhile), set just our bucket, save.
196
+ await withIndexSaveLock(async () => {
197
+ const latest = await loadAgentSyncIndexes();
198
+ latest[agentId] = idx;
199
+ await saveAgentSyncIndexes(latest);
200
+ agentIndexes = latest;
201
+ });
202
+ return result;
203
+ });
204
+ }
205
+ // Sync ONLY the shared scopes (company/user) from a workspace. Never run
206
+ // from a per-agent workspace (it lacks company/user files and would forget
207
+ // them); only from the default/gateway workspace at startup.
208
+ async function syncSharedScopes(workspaceDir, logger) {
209
+ await stateReady;
210
+ const files = await collectMemoryFiles(workspaceDir);
211
+ return syncFilesScoped(client, files, files, scopedIndexes, cfg, logger, undefined, ["company", "user"]);
212
+ }
213
+ // Seed every configured agent's files from its own workspace (startup/CLI).
214
+ async function seedAllAgents(defaultWorkspace, logger) {
215
+ const config = api.runtime?.config?.loadConfig?.();
216
+ const list = config?.agents?.list;
217
+ const defWs = expandHome(config?.agents?.defaults?.workspace) || defaultWorkspace;
218
+ const agents = Array.isArray(list) && list.length > 0
219
+ ? list
220
+ : [{ id: cfg.agentId, workspace: defWs }];
221
+ for (const a of agents) {
222
+ const ws = expandHome(a.workspace) || defWs;
223
+ if (!ws)
224
+ continue;
225
+ try {
226
+ const r = await syncAgentScope(ws, a.id, logger);
227
+ logger.info?.(`cognee-openclaw: seeded agent "${normalizeAgentId(a.id, cfg)}": ${r.added} added, ${r.updated} updated, ${r.deleted} deleted, ${r.skipped} unchanged`);
228
+ }
229
+ catch (e) {
230
+ logger.warn?.(`cognee-openclaw: failed to seed agent "${a.id}": ${String(e)}`);
231
+ }
232
+ }
233
+ }
234
+ // Resolve an agent's workspace from OpenClaw config (agents.list[].workspace
235
+ // by agentId), with sensible fallbacks. Used by the per-agent file paths so
236
+ // startup seeding and the agent_end/session_end sweeps always read the SAME
237
+ // directory — otherwise a runtime ctx.workspaceDir that differs from the
238
+ // seed workspace makes the sweep see the seeded file as "missing" and forget
239
+ // it. Resolving from config (the single source of truth) avoids that.
240
+ function resolveAgentWorkspace(rawAgentId) {
241
+ const target = normalizeAgentId(rawAgentId, cfg);
242
+ try {
243
+ const config = api.runtime?.config?.loadConfig?.();
244
+ const list = config?.agents?.list;
245
+ const match = list?.find((a) => normalizeAgentId(a.id, cfg) === target);
246
+ return expandHome(match?.workspace) || expandHome(config?.agents?.defaults?.workspace) || resolvedWorkspaceDir;
247
+ }
248
+ catch {
249
+ return resolvedWorkspaceDir;
250
+ }
251
+ }
252
+ async function runSync(workspaceDir, logger, runtimeAgentId) {
95
253
  await stateReady;
96
254
  const files = await collectMemoryFiles(workspaceDir);
97
255
  if (files.length === 0) {
@@ -99,8 +257,14 @@ const memoryCogneePlugin = {
99
257
  return { added: 0, updated: 0, skipped: 0, errors: 0, deleted: 0 };
100
258
  }
101
259
  logger.info?.(`cognee-openclaw: found ${files.length} memory file(s), syncing...`);
102
- if (multiScope) {
103
- return syncFilesScoped(client, files, files, scopedIndexes, cfg, logger);
260
+ if (perAgentMemory) {
261
+ // Per-agent mode: this path syncs only the shared scopes (company/user)
262
+ // from the given workspace; the `agent` scope is handled per agent via
263
+ // syncAgentScope/seedAllAgents (each from its own workspace).
264
+ return syncFilesScoped(client, files, files, scopedIndexes, cfg, logger, runtimeAgentId, ["company", "user"]);
265
+ }
266
+ else if (multiScope) {
267
+ return syncFilesScoped(client, files, files, scopedIndexes, cfg, logger, runtimeAgentId);
104
268
  }
105
269
  else {
106
270
  const result = await syncFiles(client, files, files, syncIndex, cfg, logger);
@@ -113,10 +277,12 @@ const memoryCogneePlugin = {
113
277
  datasetId = undefined;
114
278
  syncIndex = { entries: {} };
115
279
  scopedIndexes = {};
280
+ agentIndexes = {};
116
281
  await Promise.all([
117
282
  saveDatasetState({}),
118
283
  saveSyncIndex({ entries: {} }),
119
284
  saveScopedSyncIndexes({}),
285
+ saveAgentSyncIndexes({}),
120
286
  ]);
121
287
  }
122
288
  async function clearLocalStateForDataset(datasetName) {
@@ -135,6 +301,8 @@ const memoryCogneePlugin = {
135
301
  if (multiScope) {
136
302
  let changed = false;
137
303
  for (const scope of MEMORY_SCOPES) {
304
+ if (scope === "agent" && perAgentMemory)
305
+ continue; // handled per-agent below
138
306
  const expectedName = datasetNameForScope(scope, cfg);
139
307
  const idx = scopedIndexes[scope];
140
308
  const actualName = idx?.datasetName ?? expectedName;
@@ -147,6 +315,20 @@ const memoryCogneePlugin = {
147
315
  await saveScopedSyncIndexes(scopedIndexes);
148
316
  }
149
317
  }
318
+ if (perAgentMemory) {
319
+ agentIndexes = await loadAgentSyncIndexes();
320
+ let agentChanged = false;
321
+ for (const [agentId, idx] of Object.entries(agentIndexes)) {
322
+ const expectedName = datasetNameForScope("agent", cfg, agentId);
323
+ const actualName = idx.datasetName ?? expectedName;
324
+ if (actualName === datasetName || expectedName === datasetName) {
325
+ delete agentIndexes[agentId];
326
+ agentChanged = true;
327
+ }
328
+ }
329
+ if (agentChanged)
330
+ await saveAgentSyncIndexes(agentIndexes);
331
+ }
150
332
  }
151
333
  // ------------------------------------------------------------------
152
334
  // CLI commands
@@ -154,10 +336,28 @@ const memoryCogneePlugin = {
154
336
  api.registerCli((ctx) => {
155
337
  const cognee = ctx.program.command("cognee").description("Cognee memory management");
156
338
  const cliWorkspaceDir = ctx.workspaceDir || process.cwd();
339
+ autoSyncStarted = true;
157
340
  cognee
158
341
  .command("index")
159
342
  .description("Sync memory files to Cognee (add new, update changed, skip unchanged)")
160
- .action(async () => {
343
+ .option("--agent <id>", "Per-agent mode: sync only this agent's workspace")
344
+ .action(async (opts) => {
345
+ if (perAgentMemory) {
346
+ if (opts.agent) {
347
+ // Resolve this agent's workspace from config; fall back to cwd.
348
+ const config = api.runtime?.config?.loadConfig?.();
349
+ const list = config?.agents?.list;
350
+ const match = list?.find((a) => normalizeAgentId(a.id, cfg) === normalizeAgentId(opts.agent, cfg));
351
+ const ws = expandHome(match?.workspace) || cliWorkspaceDir;
352
+ const r = await syncAgentScope(ws, opts.agent, ctx.logger);
353
+ console.log(`Sync complete [agent=${normalizeAgentId(opts.agent, cfg)}]: ${r.added} added, ${r.updated} updated, ${r.deleted} deleted, ${r.skipped} unchanged, ${r.errors} errors`);
354
+ process.exit(0);
355
+ }
356
+ const shared = await runSync(cliWorkspaceDir, ctx.logger); // company/user
357
+ await seedAllAgents(cliWorkspaceDir, ctx.logger); // each agent's own files
358
+ console.log(`Shared sync complete: ${shared.added} added, ${shared.updated} updated, ${shared.deleted} deleted, ${shared.skipped} unchanged. Per-agent files seeded (see log).`);
359
+ process.exit(0);
360
+ }
161
361
  const result = await runSync(cliWorkspaceDir, ctx.logger);
162
362
  const summary = `Sync complete: ${result.added} added, ${result.updated} updated, ${result.deleted} deleted, ${result.skipped} unchanged, ${result.errors} errors`;
163
363
  ctx.logger.info?.(summary);
@@ -172,7 +372,10 @@ const memoryCogneePlugin = {
172
372
  const files = await collectMemoryFiles(cliWorkspaceDir);
173
373
  if (multiScope) {
174
374
  const state = await loadDatasetState();
175
- for (const scope of MEMORY_SCOPES) {
375
+ // Shared scopes (company/user). In per-agent mode the agent scope is
376
+ // reported per-agent below instead of here.
377
+ const scopesToShow = perAgentMemory ? ["company", "user"] : MEMORY_SCOPES;
378
+ for (const scope of scopesToShow) {
176
379
  const dsName = datasetNameForScope(scope, cfg);
177
380
  const scopeIndex = scopedIndexes[scope] ?? { entries: {} };
178
381
  const entryCount = Object.keys(scopeIndex.entries).length;
@@ -192,6 +395,23 @@ const memoryCogneePlugin = {
192
395
  console.log(` New (unindexed): ${newCount}`);
193
396
  console.log(` Changed (dirty): ${dirty}`);
194
397
  }
398
+ if (perAgentMemory) {
399
+ agentIndexes = await loadAgentSyncIndexes();
400
+ const config = api.runtime?.config?.loadConfig?.();
401
+ const list = config?.agents?.list;
402
+ const agentKeys = new Set(Object.keys(agentIndexes));
403
+ for (const a of list ?? [])
404
+ agentKeys.add(normalizeAgentId(a.id, cfg));
405
+ if (agentKeys.size === 0)
406
+ agentKeys.add(normalizeAgentId(undefined, cfg));
407
+ for (const agentId of agentKeys) {
408
+ const idx = agentIndexes[agentId] ?? { entries: {} };
409
+ const dsName = datasetNameForScope("agent", cfg, agentId);
410
+ console.log(`\n[AGENT:${agentId}] Dataset: ${dsName}`);
411
+ console.log(` Dataset ID: ${state[dsName] ?? idx.datasetId ?? "(not set)"}`);
412
+ console.log(` Indexed files: ${Object.keys(idx.entries).length}`);
413
+ }
414
+ }
195
415
  }
196
416
  else {
197
417
  const entryCount = Object.keys(syncIndex.entries).length;
@@ -366,12 +586,31 @@ const memoryCogneePlugin = {
366
586
  console.log(`Forget failed: ${result.error ?? "unknown error"}`);
367
587
  process.exit(1);
368
588
  });
589
+ cognee
590
+ .command("improve")
591
+ .description("Bridge session-cache QAs (and any feedback) into the permanent graph. With --session-id, scopes to that session; otherwise improves the dataset in general.")
592
+ .option("--session-id <id>", "Session to bridge")
593
+ .option("--dataset <name>", "Dataset name (default: configured datasetName)")
594
+ .action(async (opts) => {
595
+ const dsName = opts.dataset ?? (multiScope ? datasetNameForScope("agent", cfg) : cfg.datasetName);
596
+ try {
597
+ const result = await client.improve({
598
+ datasetName: dsName,
599
+ ...(opts.sessionId ? { sessionIds: [opts.sessionId] } : {}),
600
+ });
601
+ console.log(`Improve dispatched for dataset "${dsName}"${opts.sessionId ? ` (sessionId=${opts.sessionId})` : ""} — status=${result.status ?? "?"}`);
602
+ process.exit(0);
603
+ }
604
+ catch (error) {
605
+ console.log(`Improve failed: ${error instanceof Error ? error.message : String(error)}`);
606
+ process.exit(1);
607
+ }
608
+ });
369
609
  }, { commands: ["cognee"] });
370
610
  // ------------------------------------------------------------------
371
611
  // Auto-sync on startup (with health check)
372
612
  // ------------------------------------------------------------------
373
613
  if (cfg.autoIndex) {
374
- let autoSyncStarted = false;
375
614
  const runAutoSync = async (workspaceDir) => {
376
615
  if (autoSyncStarted)
377
616
  return;
@@ -379,6 +618,12 @@ const memoryCogneePlugin = {
379
618
  resolvedWorkspaceDir = workspaceDir || process.cwd();
380
619
  resolveServiceReady?.();
381
620
  const logger = api.logger;
621
+ // Dedupe across duplicate register() calls in the same process.
622
+ if (autoSyncedWorkspaces.has(resolvedWorkspaceDir)) {
623
+ logger.debug?.(`cognee-openclaw: auto-sync already ran for ${resolvedWorkspaceDir} in this process, skipping`);
624
+ return;
625
+ }
626
+ autoSyncedWorkspaces.add(resolvedWorkspaceDir);
382
627
  try {
383
628
  await client.health();
384
629
  }
@@ -386,13 +631,14 @@ const memoryCogneePlugin = {
386
631
  logger.warn?.(`cognee-openclaw: Cognee API unreachable at ${cfg.baseUrl} — auto-sync disabled for this session. Error: ${String(error)}`);
387
632
  return;
388
633
  }
389
- if (cfg.enableSessions && !sessionId) {
390
- sessionId = `openclaw-${randomUUID()}`;
391
- logger.info?.(`cognee-openclaw: session ${sessionId}`);
392
- }
393
634
  try {
394
635
  const result = await runSync(resolvedWorkspaceDir, logger);
395
636
  logger.info?.(`cognee-openclaw: auto-sync complete: ${result.added} added, ${result.updated} updated, ${result.deleted} deleted, ${result.skipped} unchanged`);
637
+ // Per-agent mode: seed each configured agent's files from its OWN
638
+ // workspace (runSync above only handled the shared company/user scopes).
639
+ if (perAgentMemory) {
640
+ await seedAllAgents(resolvedWorkspaceDir, logger);
641
+ }
396
642
  }
397
643
  catch (error) {
398
644
  logger.warn?.(`cognee-openclaw: auto-sync failed: ${String(error)}`);
@@ -427,6 +673,9 @@ const memoryCogneePlugin = {
427
673
  if (cfg.autoRecall) {
428
674
  api.on("before_prompt_build", async (event, ctx) => {
429
675
  await stateReady;
676
+ // session_start isn't fired in every openclaw flow; sync from ctx on every hook.
677
+ if (cfg.enableSessions && ctx.sessionId)
678
+ sessionId = ctx.sessionId;
430
679
  if (!event.prompt || event.prompt.length < 5) {
431
680
  api.logger.debug?.("cognee-openclaw: skipping recall (prompt too short)");
432
681
  return;
@@ -446,7 +695,7 @@ const memoryCogneePlugin = {
446
695
  const state = await loadDatasetState();
447
696
  const searchPromises = cfg.recallScopes.map(async (scope) => {
448
697
  const dsName = datasetNameForScope(scope, cfg, ctx.agentId);
449
- const dsId = state[dsName] ?? fallbackDatasetIdForScope(scope, dsName, scopedIndexes[scope], ctx.agentId);
698
+ const dsId = state[dsName] ?? scopeFallbackDatasetId(scope, ctx.agentId);
450
699
  if (!dsId)
451
700
  return null;
452
701
  const results = await client.recall({
@@ -527,50 +776,31 @@ const memoryCogneePlugin = {
527
776
  if (!event.success)
528
777
  return;
529
778
  await Promise.all([stateReady, serviceReady]);
530
- const workspaceDir = resolvedWorkspaceDir;
531
- // Fix #4: Actually persist the session into the knowledge graph
532
- if (cfg.enableSessions && cfg.persistSessionsAfterEnd && sessionId) {
533
- try {
534
- // Determine target dataset for session persistence
535
- const targetDatasetIds = [];
536
- if (multiScope) {
537
- const state = await loadDatasetState();
538
- const agentDsName = datasetNameForScope("agent", cfg, ctx.agentId);
539
- const agentDsId = state[agentDsName] ?? fallbackDatasetIdForScope("agent", agentDsName, scopedIndexes.agent, ctx.agentId);
540
- if (agentDsId)
541
- targetDatasetIds.push(agentDsId);
542
- }
543
- else if (datasetId) {
544
- targetDatasetIds.push(datasetId);
545
- }
546
- if (targetDatasetIds.length > 0) {
547
- // Call Cognee's session persistence endpoint via the generic fetchJson
548
- await client.fetchAPI("/api/v1/sessions/persist", {
549
- method: "POST",
550
- headers: { "Content-Type": "application/json" },
551
- body: JSON.stringify({
552
- session_ids: [sessionId],
553
- dataset_ids: targetDatasetIds,
554
- }),
555
- }).catch(() => {
556
- // Session persistence endpoint may not exist on all Cognee versions.
557
- // Fail silently — this is an enhancement, not critical path.
558
- api.logger.debug?.("cognee-openclaw: session persistence endpoint not available");
559
- });
779
+ lastAgentId = ctx.agentId;
780
+ lastWorkspaceDir = ctx.workspaceDir || resolvedWorkspaceDir;
781
+ if (cfg.enableSessions && ctx.sessionId)
782
+ sessionId = ctx.sessionId;
783
+ const workspaceDir = ctx.workspaceDir || resolvedWorkspaceDir;
784
+ // Remember this agent's workspace so session_end can sweep the right one.
785
+ if (workspaceDir)
786
+ agentWorkspaces.set(normalizeAgentId(ctx.agentId, cfg), workspaceDir);
787
+ try {
788
+ if (perAgentMemory) {
789
+ // Sync ONLY this agent's agent-scope files from its OWN workspace
790
+ // into its own dataset + per-agent index. Resolve the workspace from
791
+ // config (not ctx.workspaceDir) so it matches the startup seed and a
792
+ // mismatched runtime cwd can't make the sweep "forget" the seed file.
793
+ const agentWs = resolveAgentWorkspace(ctx.agentId) || workspaceDir;
794
+ const result = await syncAgentScope(agentWs, ctx.agentId, api.logger);
795
+ if (result.added || result.updated || result.deleted) {
796
+ api.logger.info?.(`cognee-openclaw: post-agent sync [agent=${normalizeAgentId(ctx.agentId, cfg)}]: ${result.added} added, ${result.updated} updated, ${result.deleted} deleted`);
560
797
  }
561
798
  }
562
- catch (error) {
563
- api.logger.warn?.(`cognee-openclaw: session persistence failed: ${String(error)}`);
564
- }
565
- }
566
- // Sync file changes
567
- try {
568
- if (multiScope) {
799
+ else if (multiScope) {
569
800
  try {
570
- const freshIndexes = await loadScopedSyncIndexes();
571
- scopedIndexes = freshIndexes;
801
+ scopedIndexes = await loadScopedSyncIndexes();
572
802
  }
573
- catch { /* fall through */ }
803
+ catch { /* keep cached scopedIndexes */ }
574
804
  const files = await collectMemoryFiles(workspaceDir);
575
805
  let hasChanges = false;
576
806
  for (const file of files) {
@@ -610,7 +840,7 @@ const memoryCogneePlugin = {
610
840
  if (freshIndex.datasetName)
611
841
  syncIndex.datasetName = freshIndex.datasetName;
612
842
  }
613
- catch { /* fall through */ }
843
+ catch { /* keep cached syncIndex */ }
614
844
  const files = await collectMemoryFiles(workspaceDir);
615
845
  const changedFiles = files.filter((f) => {
616
846
  const existing = syncIndex.entries[f.path];
@@ -631,20 +861,59 @@ const memoryCogneePlugin = {
631
861
  api.logger.warn?.(`cognee-openclaw: post-agent sync failed: ${String(error)}`);
632
862
  }
633
863
  });
864
+ api.on("session_start", async (event) => {
865
+ if (cfg.enableSessions)
866
+ sessionId = event.sessionId;
867
+ });
634
868
  // Final sweep when the openclaw session closes. Catches memory file
635
- // edits that happened outside an agent_end (failed runs, manual
636
- // edits between turns, tool writes outside the agent loop).
637
- api.on("session_end", async () => {
869
+ // edits that happened outside an agent_end.
870
+ //
871
+ // CRITICAL: resolve the agent + session from THIS event's ctx, not the
872
+ // global lastAgentId. With >1 agent active, lastAgentId is whichever agent
873
+ // ran most recently, so using it would bridge one agent's session into
874
+ // another agent's dataset.
875
+ // PluginHookSessionContext carries agentId + sessionId, so prefer those.
876
+ api.on("session_end", async (event, ctx) => {
638
877
  await Promise.all([stateReady, serviceReady]);
639
- if (!resolvedWorkspaceDir)
640
- return;
641
- try {
642
- const result = await runSync(resolvedWorkspaceDir, api.logger);
643
- api.logger.info?.(`cognee-openclaw: session-end sync: ${result.added} added, ${result.updated} updated, ${result.deleted} deleted`);
878
+ const endAgentId = ctx?.agentId ?? lastAgentId;
879
+ const endSessionId = ctx?.sessionId ?? event.sessionId;
880
+ if (!ctx?.agentId) {
881
+ api.logger.debug?.(`cognee-openclaw: session_end without ctx.agentId; falling back to lastAgentId="${endAgentId ?? "(none)"}"`);
882
+ }
883
+ // Per-agent: resolve from config (matches the startup seed). Otherwise
884
+ // fall back to the cached workspace for this agent.
885
+ const sweepWorkspace = perAgentMemory
886
+ ? (resolveAgentWorkspace(endAgentId) || lastWorkspaceDir || resolvedWorkspaceDir)
887
+ : (agentWorkspaces.get(normalizeAgentId(endAgentId, cfg)) || lastWorkspaceDir || resolvedWorkspaceDir);
888
+ if (sweepWorkspace) {
889
+ try {
890
+ if (perAgentMemory) {
891
+ // Final sweep for THIS session's agent, from its own workspace.
892
+ const result = await syncAgentScope(sweepWorkspace, endAgentId, api.logger);
893
+ api.logger.info?.(`cognee-openclaw: session-end sync [agent=${normalizeAgentId(endAgentId, cfg)}]: ${result.added} added, ${result.updated} updated, ${result.deleted} deleted`);
894
+ }
895
+ else {
896
+ const result = await runSync(sweepWorkspace, api.logger, endAgentId);
897
+ api.logger.info?.(`cognee-openclaw: session-end sync: ${result.added} added, ${result.updated} updated, ${result.deleted} deleted`);
898
+ }
899
+ }
900
+ catch (error) {
901
+ api.logger.warn?.(`cognee-openclaw: session-end sync failed: ${String(error)}`);
902
+ }
644
903
  }
645
- catch (error) {
646
- api.logger.warn?.(`cognee-openclaw: session-end sync failed: ${String(error)}`);
904
+ // Bridge session-cache QAs (including any auto-captured feedback) into
905
+ // THIS session's agent dataset — keyed by endAgentId + endSessionId.
906
+ if (cfg.improveOnSessionEnd && endSessionId) {
907
+ const dsName = multiScope ? datasetNameForScope("agent", cfg, endAgentId) : cfg.datasetName;
908
+ try {
909
+ const result = await client.improve({ datasetName: dsName, sessionIds: [endSessionId] });
910
+ api.logger.info?.(`cognee-openclaw: session-end improve dispatched for session ${endSessionId} -> dataset "${dsName}" (status=${result.status ?? "?"})`);
911
+ }
912
+ catch (error) {
913
+ api.logger.warn?.(`cognee-openclaw: session-end improve failed: ${error instanceof Error ? error.message : String(error)}`);
914
+ }
647
915
  }
916
+ sessionId = undefined;
648
917
  });
649
918
  }
650
919
  },