@contextableai/openclaw-memory-graphiti 0.1.2 → 0.2.0

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.
package/index.ts CHANGED
@@ -13,9 +13,7 @@
13
13
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
14
14
  import { Type } from "@sinclair/typebox";
15
15
  import { readFileSync } from "node:fs";
16
- import { readdir, readFile, stat } from "node:fs/promises";
17
- import { join, dirname, basename, resolve } from "node:path";
18
- import { homedir } from "node:os";
16
+ import { join, dirname } from "node:path";
19
17
  import { fileURLToPath } from "node:url";
20
18
 
21
19
  import { graphitiMemoryConfigSchema } from "./config.js";
@@ -35,6 +33,7 @@ import {
35
33
  formatDualResults,
36
34
  deduplicateSessionResults,
37
35
  } from "./search.js";
36
+ import { registerCommands } from "./cli.js";
38
37
 
39
38
  // ============================================================================
40
39
  // Session helpers
@@ -74,6 +73,14 @@ const memoryGraphitiPlugin = {
74
73
  // Track current session ID — updated from hook event context
75
74
  let currentSessionId: string | undefined;
76
75
 
76
+ // Track most recent ZedToken from SpiceDB writes for causal consistency.
77
+ // Reads use at_least_as_fresh(token) after own writes, minimize_latency otherwise.
78
+ let lastWriteToken: string | undefined;
79
+
80
+ // Map tracking UUIDs to resolvedUuid promises so memory_forget can translate
81
+ // a tracking UUID (from memory_store) to the real server-side UUID.
82
+ const pendingResolutions = new Map<string, Promise<string>>();
83
+
77
84
  api.logger.info(
78
85
  `memory-graphiti: registered (graphiti: ${cfg.graphiti.endpoint}, spicedb: ${cfg.spicedb.endpoint})`,
79
86
  );
@@ -97,16 +104,28 @@ const memoryGraphitiPlugin = {
97
104
  { description: "Memory scope: 'session' (current session only), 'long-term' (persistent), or 'all' (both). Default: 'all'" },
98
105
  ),
99
106
  ),
107
+ entity_types: Type.Optional(
108
+ Type.Array(Type.String(), {
109
+ description: "Filter by entity type (e.g., 'Preference', 'Organization', 'Procedure')",
110
+ }),
111
+ ),
112
+ center_node_uuid: Type.Optional(
113
+ Type.String({
114
+ description: "UUID of an entity node to center the fact search around",
115
+ }),
116
+ ),
100
117
  }),
101
118
  async execute(_toolCallId, params) {
102
- const { query, limit = 10, scope = "all" } = params as {
119
+ const { query, limit = 10, scope = "all", entity_types, center_node_uuid } = params as {
103
120
  query: string;
104
121
  limit?: number;
105
122
  scope?: "session" | "long-term" | "all";
123
+ entity_types?: string[];
124
+ center_node_uuid?: string;
106
125
  };
107
126
 
108
127
  // 1. Get authorized groups for current subject
109
- const authorizedGroups = await lookupAuthorizedGroups(spicedb, currentSubject);
128
+ const authorizedGroups = await lookupAuthorizedGroups(spicedb, currentSubject, lastWriteToken);
110
129
 
111
130
  if (authorizedGroups.length === 0) {
112
131
  return {
@@ -145,12 +164,13 @@ const memoryGraphitiPlugin = {
145
164
  }
146
165
 
147
166
  // 3. Parallel search across groups
167
+ const searchOpts = { entityTypes: entity_types, centerNodeUuid: center_node_uuid };
148
168
  const [longTermResults, rawSessionResults] = await Promise.all([
149
169
  longTermGroups.length > 0
150
- ? searchAuthorizedMemories(graphiti, { query, groupIds: longTermGroups, limit })
170
+ ? searchAuthorizedMemories(graphiti, { query, groupIds: longTermGroups, limit, ...searchOpts })
151
171
  : Promise.resolve([]),
152
172
  sessionGroups.length > 0
153
- ? searchAuthorizedMemories(graphiti, { query, groupIds: sessionGroups, limit })
173
+ ? searchAuthorizedMemories(graphiti, { query, groupIds: sessionGroups, limit, ...searchOpts })
154
174
  : Promise.resolve([]),
155
175
  ]);
156
176
 
@@ -247,13 +267,14 @@ const memoryGraphitiPlugin = {
247
267
 
248
268
  if (isOwnSession) {
249
269
  try {
250
- await ensureGroupMembership(spicedb, targetGroupId, currentSubject);
270
+ const token = await ensureGroupMembership(spicedb, targetGroupId, currentSubject);
271
+ if (token) lastWriteToken = token;
251
272
  } catch {
252
273
  api.logger.warn(`memory-graphiti: failed to ensure membership in ${targetGroupId}`);
253
274
  }
254
275
  } else {
255
276
  // All other groups (non-session AND foreign session) require write permission
256
- const allowed = await canWriteToGroup(spicedb, currentSubject, targetGroupId);
277
+ const allowed = await canWriteToGroup(spicedb, currentSubject, targetGroupId, lastWriteToken);
257
278
  if (!allowed) {
258
279
  return {
259
280
  content: [
@@ -276,19 +297,35 @@ const memoryGraphitiPlugin = {
276
297
  custom_extraction_instructions: cfg.customInstructions,
277
298
  });
278
299
 
279
- const fragmentId = result.episode_uuid;
280
-
281
- // 2. Write authorization relationships in SpiceDB
300
+ // 2. Write authorization relationships in SpiceDB (background).
301
+ // Graphiti processes episodes asynchronously — the real UUID isn't
302
+ // available immediately. resolvedUuid polls in the background and
303
+ // writes SpiceDB relationships once the real UUID is known, so the
304
+ // tool response isn't blocked.
282
305
  const involvedSubjects: Subject[] = involves.map((id) => ({
283
306
  type: "person" as const,
284
307
  id,
285
308
  }));
286
309
 
287
- await writeFragmentRelationships(spicedb, {
288
- fragmentId,
289
- groupId: targetGroupId,
290
- sharedBy: currentSubject,
291
- involves: involvedSubjects,
310
+ // Chain UUID resolution → SpiceDB write, and store the promise so
311
+ // memory_forget can await both before checking permissions.
312
+ const deferredWrite = result.resolvedUuid
313
+ .then(async (realUuid) => {
314
+ const writeToken = await writeFragmentRelationships(spicedb, {
315
+ fragmentId: realUuid,
316
+ groupId: targetGroupId,
317
+ sharedBy: currentSubject,
318
+ involves: involvedSubjects,
319
+ });
320
+ if (writeToken) lastWriteToken = writeToken;
321
+ return realUuid;
322
+ });
323
+
324
+ pendingResolutions.set(result.episode_uuid, deferredWrite);
325
+ deferredWrite.catch((err) => {
326
+ api.logger.warn(
327
+ `memory-graphiti: deferred SpiceDB write failed for memory_store: ${err}`,
328
+ );
292
329
  });
293
330
 
294
331
  return {
@@ -300,7 +337,7 @@ const memoryGraphitiPlugin = {
300
337
  ],
301
338
  details: {
302
339
  action: "created",
303
- episodeId: fragmentId,
340
+ episodeId: result.episode_uuid,
304
341
  groupId: targetGroupId,
305
342
  longTerm,
306
343
  involves,
@@ -315,15 +352,71 @@ const memoryGraphitiPlugin = {
315
352
  {
316
353
  name: "memory_forget",
317
354
  label: "Memory Forget",
318
- description: "Delete a memory episode. Requires delete permission.",
355
+ description:
356
+ "Delete a memory episode or fact. Provide either episode_id or fact_id (not both). Requires delete/write permission.",
319
357
  parameters: Type.Object({
320
- episode_id: Type.String({ description: "Episode UUID to delete" }),
358
+ episode_id: Type.Optional(Type.String({ description: "Episode UUID to delete" })),
359
+ fact_id: Type.Optional(Type.String({ description: "Fact (entity edge) UUID to delete" })),
321
360
  }),
322
361
  async execute(_toolCallId, params) {
323
- const { episode_id } = params as { episode_id: string };
362
+ const { episode_id, fact_id } = params as { episode_id?: string; fact_id?: string };
363
+
364
+ if (!episode_id && !fact_id) {
365
+ return {
366
+ content: [{ type: "text", text: "Either episode_id or fact_id must be provided." }],
367
+ details: { action: "error" },
368
+ };
369
+ }
370
+
371
+ // --- Fact deletion ---
372
+ if (fact_id) {
373
+ // 1. Fetch fact to get group_id for authorization
374
+ let fact: Awaited<ReturnType<typeof graphiti.getEntityEdge>>;
375
+ try {
376
+ fact = await graphiti.getEntityEdge(fact_id);
377
+ } catch {
378
+ return {
379
+ content: [{ type: "text", text: `Fact ${fact_id} not found.` }],
380
+ details: { action: "error", factId: fact_id },
381
+ };
382
+ }
383
+
384
+ // 2. Check write permission on the fact's group
385
+ const allowed = await canWriteToGroup(spicedb, currentSubject, fact.group_id, lastWriteToken);
386
+ if (!allowed) {
387
+ return {
388
+ content: [{ type: "text", text: `Permission denied: cannot delete fact ${fact_id}` }],
389
+ details: { action: "denied", factId: fact_id },
390
+ };
391
+ }
392
+
393
+ // 3. Delete fact from Graphiti
394
+ await graphiti.deleteEntityEdge(fact_id);
395
+
396
+ return {
397
+ content: [{ type: "text", text: `Fact ${fact_id} forgotten.` }],
398
+ details: { action: "deleted", factId: fact_id },
399
+ };
400
+ }
401
+
402
+ // --- Episode deletion (existing flow) ---
403
+
404
+ // Resolve tracking UUID → real server-side UUID if this came
405
+ // from a recent memory_store call. Awaits the background
406
+ // resolution so permission checks use the correct UUID.
407
+ let effectiveId = episode_id!;
408
+ const pending = pendingResolutions.get(episode_id!);
409
+ if (pending) {
410
+ try {
411
+ effectiveId = await pending;
412
+ } catch {
413
+ // Resolution failed — try with original UUID
414
+ }
415
+ pendingResolutions.delete(episode_id!);
416
+ }
324
417
 
325
418
  // 1. Check delete permission
326
- const allowed = await canDeleteFragment(spicedb, currentSubject, episode_id);
419
+ const allowed = await canDeleteFragment(spicedb, currentSubject, effectiveId, lastWriteToken);
327
420
  if (!allowed) {
328
421
  return {
329
422
  content: [
@@ -337,15 +430,12 @@ const memoryGraphitiPlugin = {
337
430
  }
338
431
 
339
432
  // 2. Delete from Graphiti
340
- await graphiti.deleteEpisode(episode_id);
433
+ await graphiti.deleteEpisode(effectiveId);
341
434
 
342
435
  // 3. Clean up SpiceDB relationships (best-effort)
343
436
  try {
344
- await deleteFragmentRelationships(spicedb, episode_id, {
345
- fragmentId: episode_id,
346
- groupId: cfg.graphiti.defaultGroupId,
347
- sharedBy: currentSubject,
348
- });
437
+ const deleteToken = await deleteFragmentRelationships(spicedb, effectiveId);
438
+ if (deleteToken) lastWriteToken = deleteToken;
349
439
  } catch {
350
440
  api.logger.warn(
351
441
  `memory-graphiti: failed to clean up SpiceDB relationships for ${episode_id}`,
@@ -410,298 +500,13 @@ const memoryGraphitiPlugin = {
410
500
  const mem = program
411
501
  .command("graphiti-mem")
412
502
  .description("Graphiti + SpiceDB memory plugin commands");
413
-
414
- mem
415
- .command("search")
416
- .description("Search memories with authorization")
417
- .argument("<query>", "Search query")
418
- .option("--limit <n>", "Max results", "10")
419
- .option("--scope <scope>", "Memory scope: session, long-term, all", "all")
420
- .action(async (query: string, opts: { limit: string; scope: string }) => {
421
- const authorizedGroups = await lookupAuthorizedGroups(spicedb, currentSubject);
422
- if (authorizedGroups.length === 0) {
423
- console.log("No accessible memory groups.");
424
- return;
425
- }
426
-
427
- console.log(`Searching ${authorizedGroups.length} authorized groups...`);
428
- const results = await searchAuthorizedMemories(graphiti, {
429
- query,
430
- groupIds: authorizedGroups,
431
- limit: parseInt(opts.limit),
432
- });
433
-
434
- if (results.length === 0) {
435
- console.log("No results found.");
436
- return;
437
- }
438
-
439
- console.log(JSON.stringify(results, null, 2));
440
- });
441
-
442
- mem
443
- .command("episodes")
444
- .description("List recent episodes")
445
- .option("--last <n>", "Number of episodes", "10")
446
- .option("--group <id>", "Group ID", cfg.graphiti.defaultGroupId)
447
- .action(async (opts: { last: string; group: string }) => {
448
- const episodes = await graphiti.getEpisodes(opts.group, parseInt(opts.last));
449
- console.log(JSON.stringify(episodes, null, 2));
450
- });
451
-
452
- mem
453
- .command("status")
454
- .description("Check SpiceDB + Graphiti health")
455
- .action(async () => {
456
- const graphitiOk = await graphiti.healthCheck();
457
- let spicedbOk = false;
458
- try {
459
- await spicedb.readSchema();
460
- spicedbOk = true;
461
- } catch {
462
- // unreachable
463
- }
464
-
465
- console.log(`Graphiti MCP: ${graphitiOk ? "OK" : "UNREACHABLE"} (${cfg.graphiti.endpoint})`);
466
- console.log(`SpiceDB: ${spicedbOk ? "OK" : "UNREACHABLE"} (${cfg.spicedb.endpoint})`);
467
- });
468
-
469
- mem
470
- .command("schema-write")
471
- .description("Write/update SpiceDB authorization schema")
472
- .action(async () => {
473
- const schemaPath = join(dirname(fileURLToPath(import.meta.url)), "schema.zed");
474
- const schema = readFileSync(schemaPath, "utf-8");
475
- await spicedb.writeSchema(schema);
476
- console.log("SpiceDB schema written successfully.");
477
- });
478
-
479
- mem
480
- .command("groups")
481
- .description("List authorized groups for current subject")
482
- .action(async () => {
483
- const groups = await lookupAuthorizedGroups(spicedb, currentSubject);
484
- if (groups.length === 0) {
485
- console.log("No authorized groups.");
486
- return;
487
- }
488
- console.log(`Authorized groups for ${currentSubject.type}:${currentSubject.id}:`);
489
- for (const g of groups) {
490
- console.log(` - ${g}`);
491
- }
492
- });
493
-
494
- mem
495
- .command("add-member")
496
- .description("Add a subject to a group")
497
- .argument("<group-id>", "Group ID")
498
- .argument("<subject-id>", "Subject ID")
499
- .option("--type <type>", "Subject type (agent|person)", "person")
500
- .action(async (groupId: string, subjectId: string, opts: { type: string }) => {
501
- const subjectType = opts.type === "agent" ? "agent" : "person";
502
- await ensureGroupMembership(spicedb, groupId, {
503
- type: subjectType as "agent" | "person",
504
- id: subjectId,
505
- });
506
- console.log(`Added ${subjectType}:${subjectId} to group:${groupId}`);
507
- });
508
-
509
- mem
510
- .command("import")
511
- .description("Import workspace markdown files (and optionally session transcripts) into Graphiti")
512
- .option("--workspace <path>", "Workspace directory", join(homedir(), ".openclaw", "workspace"))
513
- .option("--include-sessions", "Also import session JSONL transcripts", false)
514
- .option("--sessions-only", "Only import session transcripts (skip workspace files)", false)
515
- .option("--session-dir <path>", "Session transcripts directory", join(homedir(), ".openclaw", "agents", "main", "sessions"))
516
- .option("--group <id>", "Target group for workspace files", cfg.graphiti.defaultGroupId)
517
- .option("--dry-run", "List files without importing", false)
518
- .action(async (opts: {
519
- workspace: string;
520
- includeSessions: boolean;
521
- sessionsOnly: boolean;
522
- sessionDir: string;
523
- group: string;
524
- dryRun: boolean;
525
- }) => {
526
- const workspacePath = resolve(opts.workspace);
527
- const targetGroup = opts.group;
528
- const importSessions = opts.includeSessions || opts.sessionsOnly;
529
- const importWorkspace = !opts.sessionsOnly;
530
-
531
- // Discover workspace markdown files
532
- let mdFiles: string[] = [];
533
- try {
534
- const entries = await readdir(workspacePath);
535
- mdFiles = entries.filter((f) => f.endsWith(".md")).sort();
536
- } catch {
537
- console.error(`Cannot read workspace directory: ${workspacePath}`);
538
- return;
539
- }
540
-
541
- // Also check for memory/ subdirectory
542
- try {
543
- const memDir = join(workspacePath, "memory");
544
- const memEntries = await readdir(memDir);
545
- for (const f of memEntries) {
546
- if (f.endsWith(".md")) {
547
- mdFiles.push(join("memory", f));
548
- }
549
- }
550
- } catch {
551
- // No memory/ subdirectory — that's fine
552
- }
553
-
554
- if (mdFiles.length === 0) {
555
- console.log("No markdown files found in workspace.");
556
- return;
557
- }
558
-
559
- console.log(`Found ${mdFiles.length} workspace file(s) in ${workspacePath}:`);
560
- for (const f of mdFiles) {
561
- const filePath = join(workspacePath, f);
562
- const info = await stat(filePath);
563
- console.log(` ${f} (${info.size} bytes)`);
564
- }
565
-
566
- if (opts.dryRun) {
567
- console.log("\n[dry-run] No files imported.");
568
- if (importSessions) {
569
- const sessionPath = resolve(opts.sessionDir);
570
- try {
571
- const sessions = (await readdir(sessionPath)).filter((f) => f.endsWith(".jsonl"));
572
- console.log(`\nFound ${sessions.length} session transcript(s) in ${sessionPath}:`);
573
- for (const f of sessions) {
574
- const info = await stat(join(sessionPath, f));
575
- console.log(` ${f} (${info.size} bytes)`);
576
- }
577
- } catch {
578
- console.log(`\nCannot read session directory: ${sessionPath}`);
579
- }
580
- }
581
- return;
582
- }
583
-
584
- // Import workspace files
585
- if (importWorkspace) {
586
- console.log(`\nImporting workspace files to group: ${targetGroup}`);
587
- let imported = 0;
588
- for (const f of mdFiles) {
589
- const filePath = join(workspacePath, f);
590
- const content = await readFile(filePath, "utf-8");
591
- if (!content.trim()) {
592
- console.log(` Skipping ${f} (empty)`);
593
- continue;
594
- }
595
- try {
596
- const result = await graphiti.addEpisode({
597
- name: f,
598
- episode_body: content,
599
- source_description: `Imported from OpenClaw workspace: ${f}`,
600
- group_id: targetGroup,
601
- source: "text",
602
- });
603
- await writeFragmentRelationships(spicedb, {
604
- fragmentId: result.episode_uuid,
605
- groupId: targetGroup,
606
- sharedBy: currentSubject,
607
- });
608
- console.log(` Imported ${f} (${content.length} bytes) → episode ${result.episode_uuid}`);
609
- imported++;
610
- } catch (err) {
611
- console.error(` Failed to import ${f}: ${err instanceof Error ? err.message : String(err)}`);
612
- }
613
- }
614
- console.log(`\nWorkspace import complete: ${imported}/${mdFiles.length} files.`);
615
- }
616
-
617
- // Import session transcripts
618
- if (importSessions) {
619
- const sessionPath = resolve(opts.sessionDir);
620
- let jsonlFiles: string[] = [];
621
- try {
622
- jsonlFiles = (await readdir(sessionPath)).filter((f) => f.endsWith(".jsonl")).sort();
623
- } catch {
624
- console.error(`\nCannot read session directory: ${sessionPath}`);
625
- return;
626
- }
627
-
628
- if (jsonlFiles.length === 0) {
629
- console.log("\nNo session transcripts found.");
630
- return;
631
- }
632
-
633
- console.log(`\nImporting ${jsonlFiles.length} session transcript(s)...`);
634
- let sessionsImported = 0;
635
- for (const f of jsonlFiles) {
636
- const sessionId = basename(f, ".jsonl");
637
- const sessionGroup = sessionGroupId(sessionId);
638
- const filePath = join(sessionPath, f);
639
- const raw = await readFile(filePath, "utf-8");
640
- const lines = raw.split("\n").filter(Boolean);
641
-
642
- // Extract user/assistant message text from JSONL
643
- const conversationLines: string[] = [];
644
- for (const line of lines) {
645
- try {
646
- const entry = JSON.parse(line) as Record<string, unknown>;
647
- // OpenClaw JSONL format: {"type":"message","message":{"role":"user","content":[...]}}
648
- const msg = (entry.type === "message" && entry.message && typeof entry.message === "object")
649
- ? entry.message as Record<string, unknown>
650
- : entry;
651
- const role = msg.role as string | undefined;
652
- if (role !== "user" && role !== "assistant") continue;
653
- const content = msg.content;
654
- let text = "";
655
- if (typeof content === "string") {
656
- text = content;
657
- } else if (Array.isArray(content)) {
658
- text = content
659
- .filter((b: unknown) =>
660
- typeof b === "object" && b !== null &&
661
- (b as Record<string, unknown>).type === "text" &&
662
- typeof (b as Record<string, unknown>).text === "string",
663
- )
664
- .map((b: unknown) => (b as Record<string, unknown>).text as string)
665
- .join("\n");
666
- }
667
- if (text && text.length >= 5 && !text.includes("<relevant-memories>") && !text.includes("<memory-tools>")) {
668
- const roleLabel = role === "user" ? "User" : "Assistant";
669
- conversationLines.push(`${roleLabel}: ${text}`);
670
- }
671
- } catch {
672
- // Skip malformed JSONL lines
673
- }
674
- }
675
-
676
- if (conversationLines.length === 0) {
677
- console.log(` Skipping ${f} (no user/assistant messages)`);
678
- continue;
679
- }
680
-
681
- try {
682
- await ensureGroupMembership(spicedb, sessionGroup, currentSubject);
683
- const episodeBody = conversationLines.join("\n");
684
- const result = await graphiti.addEpisode({
685
- name: `session_${sessionId}`,
686
- episode_body: episodeBody,
687
- source_description: `Imported session transcript: ${sessionId}`,
688
- group_id: sessionGroup,
689
- source: "message",
690
- });
691
- await writeFragmentRelationships(spicedb, {
692
- fragmentId: result.episode_uuid,
693
- groupId: sessionGroup,
694
- sharedBy: currentSubject,
695
- });
696
- console.log(` Imported ${f} (${conversationLines.length} messages) → episode ${result.episode_uuid} [group: ${sessionGroup}]`);
697
- sessionsImported++;
698
- } catch (err) {
699
- console.error(` Failed to import ${f}: ${err instanceof Error ? err.message : String(err)}`);
700
- }
701
- }
702
- console.log(`\nSession import complete: ${sessionsImported}/${jsonlFiles.length} transcripts.`);
703
- }
704
- });
503
+ registerCommands(mem, {
504
+ graphiti,
505
+ spicedb,
506
+ cfg,
507
+ currentSubject,
508
+ getLastWriteToken: () => lastWriteToken,
509
+ });
705
510
  },
706
511
  { commands: ["graphiti-mem"] },
707
512
  );
@@ -711,10 +516,10 @@ const memoryGraphitiPlugin = {
711
516
  // ========================================================================
712
517
 
713
518
  if (cfg.autoRecall) {
714
- api.on("before_agent_start", async (event) => {
715
- // Track session ID from event context
716
- if (event.ctx?.sessionKey) {
717
- currentSessionId = event.ctx.sessionKey as string;
519
+ api.on("before_agent_start", async (event, ctx) => {
520
+ // Track session ID from hook context
521
+ if (ctx?.sessionKey) {
522
+ currentSessionId = ctx.sessionKey;
718
523
  }
719
524
 
720
525
  if (!event.prompt || event.prompt.length < 5) {
@@ -722,7 +527,7 @@ const memoryGraphitiPlugin = {
722
527
  }
723
528
 
724
529
  try {
725
- const authorizedGroups = await lookupAuthorizedGroups(spicedb, currentSubject);
530
+ const authorizedGroups = await lookupAuthorizedGroups(spicedb, currentSubject, lastWriteToken);
726
531
  if (authorizedGroups.length === 0) {
727
532
  return;
728
533
  }
@@ -787,10 +592,10 @@ const memoryGraphitiPlugin = {
787
592
  }
788
593
 
789
594
  if (cfg.autoCapture) {
790
- api.on("agent_end", async (event) => {
791
- // Track session ID from event context
792
- if (event.ctx?.sessionKey) {
793
- currentSessionId = event.ctx.sessionKey as string;
595
+ api.on("agent_end", async (event, ctx) => {
596
+ // Track session ID from hook context
597
+ if (ctx?.sessionKey) {
598
+ currentSessionId = ctx.sessionKey;
794
599
  }
795
600
 
796
601
  if (!event.success || !event.messages || event.messages.length === 0) {
@@ -863,12 +668,13 @@ const memoryGraphitiPlugin = {
863
668
 
864
669
  if (isOwnSession) {
865
670
  try {
866
- await ensureGroupMembership(spicedb, targetGroupId, currentSubject);
671
+ const token = await ensureGroupMembership(spicedb, targetGroupId, currentSubject);
672
+ if (token) lastWriteToken = token;
867
673
  } catch {
868
674
  // Best-effort
869
675
  }
870
676
  } else {
871
- const allowed = await canWriteToGroup(spicedb, currentSubject, targetGroupId);
677
+ const allowed = await canWriteToGroup(spicedb, currentSubject, targetGroupId, lastWriteToken);
872
678
  if (!allowed) {
873
679
  api.logger.warn(`memory-graphiti: auto-capture denied for group ${targetGroupId}`);
874
680
  return;
@@ -883,11 +689,21 @@ const memoryGraphitiPlugin = {
883
689
  custom_extraction_instructions: cfg.customInstructions,
884
690
  });
885
691
 
886
- await writeFragmentRelationships(spicedb, {
887
- fragmentId: result.episode_uuid,
888
- groupId: targetGroupId,
889
- sharedBy: currentSubject,
890
- });
692
+ // SpiceDB writes use the real UUID once Graphiti finishes processing
693
+ result.resolvedUuid
694
+ .then(async (realUuid) => {
695
+ const writeToken = await writeFragmentRelationships(spicedb, {
696
+ fragmentId: realUuid,
697
+ groupId: targetGroupId,
698
+ sharedBy: currentSubject,
699
+ });
700
+ if (writeToken) lastWriteToken = writeToken;
701
+ })
702
+ .catch((err) => {
703
+ api.logger.warn(
704
+ `memory-graphiti: deferred SpiceDB write (auto-capture) failed: ${err}`,
705
+ );
706
+ });
891
707
 
892
708
  api.logger.info(
893
709
  `memory-graphiti: auto-captured ${conversationLines.length} messages as batch episode to ${targetGroupId}`,
@@ -927,11 +743,12 @@ const memoryGraphitiPlugin = {
927
743
  // Ensure current subject is a member of the default group
928
744
  if (spicedbOk) {
929
745
  try {
930
- await ensureGroupMembership(
746
+ const token = await ensureGroupMembership(
931
747
  spicedb,
932
748
  cfg.graphiti.defaultGroupId,
933
749
  currentSubject,
934
750
  );
751
+ if (token) lastWriteToken = token;
935
752
  } catch {
936
753
  api.logger.warn("memory-graphiti: failed to ensure default group membership");
937
754
  }