@contextableai/openclaw-memory-graphiti 0.1.2 → 0.2.1

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";
@@ -24,8 +22,6 @@ import { SpiceDbClient } from "./spicedb.js";
24
22
  import {
25
23
  lookupAuthorizedGroups,
26
24
  writeFragmentRelationships,
27
- deleteFragmentRelationships,
28
- canDeleteFragment,
29
25
  canWriteToGroup,
30
26
  ensureGroupMembership,
31
27
  type Subject,
@@ -35,14 +31,17 @@ import {
35
31
  formatDualResults,
36
32
  deduplicateSessionResults,
37
33
  } from "./search.js";
34
+ import { registerCommands } from "./cli.js";
38
35
 
39
36
  // ============================================================================
40
37
  // Session helpers
41
38
  // ============================================================================
42
39
 
43
40
  function sessionGroupId(sessionId: string): string {
44
- // Use dash separator — Graphiti group_ids only allow alphanumeric, dashes, underscores
45
- return `session-${sessionId}`;
41
+ // Graphiti group_ids only allow alphanumeric, dashes, underscores.
42
+ // OpenClaw sessionKey can contain colons (e.g. "agent:main:main") — replace invalid chars.
43
+ const sanitized = sessionId.replace(/[^a-zA-Z0-9_-]/g, "-");
44
+ return `session-${sanitized}`;
46
45
  }
47
46
 
48
47
  function isSessionGroup(groupId: string): boolean {
@@ -64,6 +63,8 @@ const memoryGraphitiPlugin = {
64
63
  const cfg = graphitiMemoryConfigSchema.parse(api.pluginConfig);
65
64
 
66
65
  const graphiti = new GraphitiClient(cfg.graphiti.endpoint);
66
+ graphiti.uuidPollIntervalMs = cfg.graphiti.uuidPollIntervalMs;
67
+ graphiti.uuidPollMaxAttempts = cfg.graphiti.uuidPollMaxAttempts;
67
68
  const spicedb = new SpiceDbClient(cfg.spicedb);
68
69
 
69
70
  const currentSubject: Subject = {
@@ -74,6 +75,10 @@ const memoryGraphitiPlugin = {
74
75
  // Track current session ID — updated from hook event context
75
76
  let currentSessionId: string | undefined;
76
77
 
78
+ // Track most recent ZedToken from SpiceDB writes for causal consistency.
79
+ // Reads use at_least_as_fresh(token) after own writes, minimize_latency otherwise.
80
+ let lastWriteToken: string | undefined;
81
+
77
82
  api.logger.info(
78
83
  `memory-graphiti: registered (graphiti: ${cfg.graphiti.endpoint}, spicedb: ${cfg.spicedb.endpoint})`,
79
84
  );
@@ -97,16 +102,28 @@ const memoryGraphitiPlugin = {
97
102
  { description: "Memory scope: 'session' (current session only), 'long-term' (persistent), or 'all' (both). Default: 'all'" },
98
103
  ),
99
104
  ),
105
+ entity_types: Type.Optional(
106
+ Type.Array(Type.String(), {
107
+ description: "Filter by entity type (e.g., 'Preference', 'Organization', 'Procedure')",
108
+ }),
109
+ ),
110
+ center_node_uuid: Type.Optional(
111
+ Type.String({
112
+ description: "UUID of an entity node to center the fact search around",
113
+ }),
114
+ ),
100
115
  }),
101
116
  async execute(_toolCallId, params) {
102
- const { query, limit = 10, scope = "all" } = params as {
117
+ const { query, limit = 10, scope = "all", entity_types, center_node_uuid } = params as {
103
118
  query: string;
104
119
  limit?: number;
105
120
  scope?: "session" | "long-term" | "all";
121
+ entity_types?: string[];
122
+ center_node_uuid?: string;
106
123
  };
107
124
 
108
125
  // 1. Get authorized groups for current subject
109
- const authorizedGroups = await lookupAuthorizedGroups(spicedb, currentSubject);
126
+ const authorizedGroups = await lookupAuthorizedGroups(spicedb, currentSubject, lastWriteToken);
110
127
 
111
128
  if (authorizedGroups.length === 0) {
112
129
  return {
@@ -145,12 +162,13 @@ const memoryGraphitiPlugin = {
145
162
  }
146
163
 
147
164
  // 3. Parallel search across groups
165
+ const searchOpts = { entityTypes: entity_types, centerNodeUuid: center_node_uuid };
148
166
  const [longTermResults, rawSessionResults] = await Promise.all([
149
167
  longTermGroups.length > 0
150
- ? searchAuthorizedMemories(graphiti, { query, groupIds: longTermGroups, limit })
168
+ ? searchAuthorizedMemories(graphiti, { query, groupIds: longTermGroups, limit, ...searchOpts })
151
169
  : Promise.resolve([]),
152
170
  sessionGroups.length > 0
153
- ? searchAuthorizedMemories(graphiti, { query, groupIds: sessionGroups, limit })
171
+ ? searchAuthorizedMemories(graphiti, { query, groupIds: sessionGroups, limit, ...searchOpts })
154
172
  : Promise.resolve([]),
155
173
  ]);
156
174
 
@@ -247,13 +265,14 @@ const memoryGraphitiPlugin = {
247
265
 
248
266
  if (isOwnSession) {
249
267
  try {
250
- await ensureGroupMembership(spicedb, targetGroupId, currentSubject);
268
+ const token = await ensureGroupMembership(spicedb, targetGroupId, currentSubject);
269
+ if (token) lastWriteToken = token;
251
270
  } catch {
252
271
  api.logger.warn(`memory-graphiti: failed to ensure membership in ${targetGroupId}`);
253
272
  }
254
273
  } else {
255
274
  // All other groups (non-session AND foreign session) require write permission
256
- const allowed = await canWriteToGroup(spicedb, currentSubject, targetGroupId);
275
+ const allowed = await canWriteToGroup(spicedb, currentSubject, targetGroupId, lastWriteToken);
257
276
  if (!allowed) {
258
277
  return {
259
278
  content: [
@@ -276,20 +295,32 @@ const memoryGraphitiPlugin = {
276
295
  custom_extraction_instructions: cfg.customInstructions,
277
296
  });
278
297
 
279
- const fragmentId = result.episode_uuid;
280
-
281
- // 2. Write authorization relationships in SpiceDB
298
+ // 2. Write authorization relationships in SpiceDB (background)
282
299
  const involvedSubjects: Subject[] = involves.map((id) => ({
283
300
  type: "person" as const,
284
301
  id,
285
302
  }));
286
303
 
287
- await writeFragmentRelationships(spicedb, {
288
- fragmentId,
289
- groupId: targetGroupId,
290
- sharedBy: currentSubject,
291
- involves: involvedSubjects,
292
- });
304
+ // Chain UUID resolution → SpiceDB write in the background.
305
+ // Graphiti processes episodes asynchronously, so the real UUID
306
+ // isn't available immediately. Once resolved, write SpiceDB
307
+ // relationships so authorization checks work for this fragment.
308
+ result.resolvedUuid
309
+ .then(async (realUuid) => {
310
+ const writeToken = await writeFragmentRelationships(spicedb, {
311
+ fragmentId: realUuid,
312
+ groupId: targetGroupId,
313
+ sharedBy: currentSubject,
314
+ involves: involvedSubjects,
315
+ });
316
+ if (writeToken) lastWriteToken = writeToken;
317
+ return realUuid;
318
+ })
319
+ .catch((err) => {
320
+ api.logger.warn(
321
+ `memory-graphiti: deferred SpiceDB write failed for memory_store: ${err}`,
322
+ );
323
+ });
293
324
 
294
325
  return {
295
326
  content: [
@@ -300,7 +331,7 @@ const memoryGraphitiPlugin = {
300
331
  ],
301
332
  details: {
302
333
  action: "created",
303
- episodeId: fragmentId,
334
+ episodeId: result.episode_uuid,
304
335
  groupId: targetGroupId,
305
336
  longTerm,
306
337
  involves,
@@ -315,46 +346,81 @@ const memoryGraphitiPlugin = {
315
346
  {
316
347
  name: "memory_forget",
317
348
  label: "Memory Forget",
318
- description: "Delete a memory episode. Requires delete permission.",
349
+ description:
350
+ "Delete a fact from the knowledge graph by ID. Use the type-prefixed IDs from memory_recall (e.g. 'fact:UUID'). Entities cannot be deleted directly — delete the facts connected to them instead.",
319
351
  parameters: Type.Object({
320
- episode_id: Type.String({ description: "Episode UUID to delete" }),
352
+ id: Type.String({ description: "Fact ID to delete (e.g. 'fact:da8650cb-...')" }),
321
353
  }),
322
354
  async execute(_toolCallId, params) {
323
- const { episode_id } = params as { episode_id: string };
355
+ const { id } = params as { id: string };
356
+
357
+ // Parse type prefix from ID (e.g. "fact:da8650cb-..." → type="fact", uuid="da8650cb-...")
358
+ const colonIdx = id.indexOf(":");
359
+ let idType: "fact" | "entity" | "episode";
360
+ let uuid: string;
361
+
362
+ if (colonIdx > 0 && colonIdx < 10) {
363
+ const prefix = id.slice(0, colonIdx);
364
+ uuid = id.slice(colonIdx + 1);
365
+ if (prefix === "fact") {
366
+ idType = "fact";
367
+ } else if (prefix === "entity") {
368
+ idType = "entity";
369
+ } else {
370
+ // Unknown prefix — treat as bare episode UUID
371
+ idType = "episode";
372
+ uuid = id;
373
+ }
374
+ } else {
375
+ idType = "episode";
376
+ uuid = id;
377
+ }
324
378
 
325
- // 1. Check delete permission
326
- const allowed = await canDeleteFragment(spicedb, currentSubject, episode_id);
327
- if (!allowed) {
379
+ // --- Entity: not deletable via MCP server ---
380
+ if (idType === "entity") {
328
381
  return {
329
- content: [
330
- {
331
- type: "text",
332
- text: `Permission denied: cannot delete episode ${episode_id}`,
333
- },
334
- ],
335
- details: { action: "denied", episodeId: episode_id },
382
+ content: [{ type: "text", text: `Entities cannot be deleted directly. To remove information about an entity, delete the specific facts (edges) connected to it.` }],
383
+ details: { action: "error", id },
336
384
  };
337
385
  }
338
386
 
339
- // 2. Delete from Graphiti
340
- await graphiti.deleteEpisode(episode_id);
387
+ // --- Fact deletion ---
388
+ if (idType === "fact") {
389
+ let fact: Awaited<ReturnType<typeof graphiti.getEntityEdge>>;
390
+ try {
391
+ fact = await graphiti.getEntityEdge(uuid);
392
+ } catch {
393
+ return {
394
+ content: [{ type: "text", text: `Fact ${uuid} not found.` }],
395
+ details: { action: "not_found", id },
396
+ };
397
+ }
341
398
 
342
- // 3. Clean up SpiceDB relationships (best-effort)
343
- try {
344
- await deleteFragmentRelationships(spicedb, episode_id, {
345
- fragmentId: episode_id,
346
- groupId: cfg.graphiti.defaultGroupId,
347
- sharedBy: currentSubject,
348
- });
349
- } catch {
350
- api.logger.warn(
351
- `memory-graphiti: failed to clean up SpiceDB relationships for ${episode_id}`,
352
- );
399
+ // Graphiti allows empty-string group_ids (its default for some backends),
400
+ // but SpiceDB ObjectIds require at least one character. Map empty to the
401
+ // configured default so the permission check doesn't fail with INVALID_ARGUMENT.
402
+ const effectiveGroupId = fact.group_id || cfg.graphiti.defaultGroupId;
403
+ const allowed = await canWriteToGroup(spicedb, currentSubject, effectiveGroupId, lastWriteToken);
404
+ if (!allowed) {
405
+ return {
406
+ content: [{ type: "text", text: `Permission denied: cannot delete fact in group "${effectiveGroupId}"` }],
407
+ details: { action: "denied", id },
408
+ };
409
+ }
410
+
411
+ await graphiti.deleteEntityEdge(uuid);
412
+
413
+ return {
414
+ content: [{ type: "text", text: `Fact forgotten.` }],
415
+ details: { action: "deleted", id, type: "fact" },
416
+ };
353
417
  }
354
418
 
419
+ // --- Bare UUID / episode: not supported via agent tool ---
420
+ // Episode deletion is an admin operation available via CLI (graphiti-mem cleanup).
355
421
  return {
356
- content: [{ type: "text", text: `Memory ${episode_id} forgotten.` }],
357
- details: { action: "deleted", episodeId: episode_id },
422
+ content: [{ type: "text", text: `Unrecognized ID format "${id}". Use IDs from memory_recall (e.g. 'fact:da8650cb-...'). Episode deletion is available via the CLI.` }],
423
+ details: { action: "error", id },
358
424
  };
359
425
  },
360
426
  },
@@ -410,298 +476,13 @@ const memoryGraphitiPlugin = {
410
476
  const mem = program
411
477
  .command("graphiti-mem")
412
478
  .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
- });
479
+ registerCommands(mem, {
480
+ graphiti,
481
+ spicedb,
482
+ cfg,
483
+ currentSubject,
484
+ getLastWriteToken: () => lastWriteToken,
485
+ });
705
486
  },
706
487
  { commands: ["graphiti-mem"] },
707
488
  );
@@ -711,10 +492,10 @@ const memoryGraphitiPlugin = {
711
492
  // ========================================================================
712
493
 
713
494
  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;
495
+ api.on("before_agent_start", async (event, ctx) => {
496
+ // Track session ID from hook context
497
+ if (ctx?.sessionKey) {
498
+ currentSessionId = ctx.sessionKey;
718
499
  }
719
500
 
720
501
  if (!event.prompt || event.prompt.length < 5) {
@@ -722,7 +503,7 @@ const memoryGraphitiPlugin = {
722
503
  }
723
504
 
724
505
  try {
725
- const authorizedGroups = await lookupAuthorizedGroups(spicedb, currentSubject);
506
+ const authorizedGroups = await lookupAuthorizedGroups(spicedb, currentSubject, lastWriteToken);
726
507
  if (authorizedGroups.length === 0) {
727
508
  return;
728
509
  }
@@ -787,10 +568,10 @@ const memoryGraphitiPlugin = {
787
568
  }
788
569
 
789
570
  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;
571
+ api.on("agent_end", async (event, ctx) => {
572
+ // Track session ID from hook context
573
+ if (ctx?.sessionKey) {
574
+ currentSessionId = ctx.sessionKey;
794
575
  }
795
576
 
796
577
  if (!event.success || !event.messages || event.messages.length === 0) {
@@ -863,12 +644,13 @@ const memoryGraphitiPlugin = {
863
644
 
864
645
  if (isOwnSession) {
865
646
  try {
866
- await ensureGroupMembership(spicedb, targetGroupId, currentSubject);
647
+ const token = await ensureGroupMembership(spicedb, targetGroupId, currentSubject);
648
+ if (token) lastWriteToken = token;
867
649
  } catch {
868
650
  // Best-effort
869
651
  }
870
652
  } else {
871
- const allowed = await canWriteToGroup(spicedb, currentSubject, targetGroupId);
653
+ const allowed = await canWriteToGroup(spicedb, currentSubject, targetGroupId, lastWriteToken);
872
654
  if (!allowed) {
873
655
  api.logger.warn(`memory-graphiti: auto-capture denied for group ${targetGroupId}`);
874
656
  return;
@@ -883,11 +665,21 @@ const memoryGraphitiPlugin = {
883
665
  custom_extraction_instructions: cfg.customInstructions,
884
666
  });
885
667
 
886
- await writeFragmentRelationships(spicedb, {
887
- fragmentId: result.episode_uuid,
888
- groupId: targetGroupId,
889
- sharedBy: currentSubject,
890
- });
668
+ // SpiceDB writes use the real UUID once Graphiti finishes processing
669
+ result.resolvedUuid
670
+ .then(async (realUuid) => {
671
+ const writeToken = await writeFragmentRelationships(spicedb, {
672
+ fragmentId: realUuid,
673
+ groupId: targetGroupId,
674
+ sharedBy: currentSubject,
675
+ });
676
+ if (writeToken) lastWriteToken = writeToken;
677
+ })
678
+ .catch((err) => {
679
+ api.logger.warn(
680
+ `memory-graphiti: deferred SpiceDB write (auto-capture) failed: ${err}`,
681
+ );
682
+ });
891
683
 
892
684
  api.logger.info(
893
685
  `memory-graphiti: auto-captured ${conversationLines.length} messages as batch episode to ${targetGroupId}`,
@@ -913,7 +705,7 @@ const memoryGraphitiPlugin = {
913
705
  spicedbOk = true;
914
706
 
915
707
  // Auto-write schema if SpiceDB has no schema yet
916
- if (!existing || !existing.includes("memory_group")) {
708
+ if (!existing || !existing.includes("memory_fragment")) {
917
709
  api.logger.info("memory-graphiti: writing SpiceDB schema (first run)");
918
710
  const schemaPath = join(dirname(fileURLToPath(import.meta.url)), "schema.zed");
919
711
  const schema = readFileSync(schemaPath, "utf-8");
@@ -927,11 +719,12 @@ const memoryGraphitiPlugin = {
927
719
  // Ensure current subject is a member of the default group
928
720
  if (spicedbOk) {
929
721
  try {
930
- await ensureGroupMembership(
722
+ const token = await ensureGroupMembership(
931
723
  spicedb,
932
724
  cfg.graphiti.defaultGroupId,
933
725
  currentSubject,
934
726
  );
727
+ if (token) lastWriteToken = token;
935
728
  } catch {
936
729
  api.logger.warn("memory-graphiti: failed to ensure default group membership");
937
730
  }