@contextableai/openclaw-memory-graphiti 0.2.0 → 0.2.2

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/README.md CHANGED
@@ -346,16 +346,16 @@ When running inside Docker Compose, use service hostnames in the plugin config:
346
346
 
347
347
  ### Selecting the Memory Slot
348
348
 
349
- OpenClaw has an exclusive `memory` slot — only one memory plugin is active at a time. To use memory-graphiti, set the slot in your OpenClaw config (`~/.openclaw/openclaw.json`):
349
+ OpenClaw has an exclusive `memory` slot — only one memory plugin is active at a time. To use openclaw-memory-graphiti, set the slot in your OpenClaw config (`~/.openclaw/openclaw.json`):
350
350
 
351
351
  ```json
352
352
  {
353
353
  "plugins": {
354
354
  "slots": {
355
- "memory": "memory-graphiti"
355
+ "memory": "openclaw-memory-graphiti"
356
356
  },
357
357
  "entries": {
358
- "memory-graphiti": {
358
+ "openclaw-memory-graphiti": {
359
359
  "enabled": true,
360
360
  "config": {
361
361
  "spicedb": {
@@ -378,7 +378,7 @@ OpenClaw has an exclusive `memory` slot — only one memory plugin is active at
378
378
  }
379
379
  ```
380
380
 
381
- The plugin must be discoverable — either symlinked into `extensions/memory-graphiti` in the OpenClaw installation, or loaded via `plugins.load.paths`.
381
+ The plugin must be discoverable — either symlinked into `extensions/openclaw-memory-graphiti` in the OpenClaw installation, or loaded via `plugins.load.paths`.
382
382
 
383
383
  ### Initialization
384
384
 
@@ -410,7 +410,7 @@ Workspace files are imported to the configured `defaultGroupId`. Session transcr
410
410
 
411
411
  ### Session Logging
412
412
 
413
- OpenClaw's JSONL session logging is always-on core behavior — memory-graphiti does not replace it. The plugin augments session context by:
413
+ OpenClaw's JSONL session logging is always-on core behavior — openclaw-memory-graphiti does not replace it. The plugin augments session context by:
414
414
 
415
415
  - **Auto-capture**: Extracting entities and facts from conversation turns into the knowledge graph (`agent_end` hook)
416
416
  - **Auto-recall**: Injecting relevant memories into the agent's context before each turn (`before_agent_start` hook)
@@ -126,6 +126,8 @@ try {
126
126
  }
127
127
 
128
128
  const graphiti = new GraphitiClient(cfg.graphiti.endpoint);
129
+ graphiti.uuidPollIntervalMs = cfg.graphiti.uuidPollIntervalMs;
130
+ graphiti.uuidPollMaxAttempts = cfg.graphiti.uuidPollMaxAttempts;
129
131
  const spicedb = new SpiceDbClient(cfg.spicedb);
130
132
  const currentSubject = { type: cfg.subjectType, id: cfg.subjectId } as const;
131
133
 
package/cli.ts CHANGED
@@ -26,7 +26,10 @@ import { searchAuthorizedMemories } from "./search.js";
26
26
  // ============================================================================
27
27
 
28
28
  function sessionGroupId(sessionId: string): string {
29
- return `session-${sessionId}`;
29
+ // Graphiti group_ids only allow alphanumeric, dashes, underscores.
30
+ // OpenClaw sessionKey can contain colons (e.g. "agent:main:main") — replace invalid chars.
31
+ const sanitized = sessionId.replace(/[^a-zA-Z0-9_-]/g, "-");
32
+ return `session-${sanitized}`;
30
33
  }
31
34
 
32
35
  // ============================================================================
@@ -334,7 +337,7 @@ export function registerCommands(cmd: Command, ctx: CliContext): void {
334
337
 
335
338
  // Ensure agent is a member of the target workspace group
336
339
  if (importWorkspace) {
337
- cmdbershipGroups.add(targetGroup);
340
+ membershipGroups.add(targetGroup);
338
341
  }
339
342
 
340
343
  // Phase 1a: Import workspace files to Graphiti
@@ -442,7 +445,7 @@ export function registerCommands(cmd: Command, ctx: CliContext): void {
442
445
  group_id: sessionGroup,
443
446
  source: "message",
444
447
  });
445
- cmdbershipGroups.add(sessionGroup);
448
+ membershipGroups.add(sessionGroup);
446
449
  pendingResolutions.push({
447
450
  resolvedUuid: result.resolvedUuid,
448
451
  groupId: sessionGroup,
package/config.ts CHANGED
@@ -7,6 +7,8 @@ export type GraphitiMemoryConfig = {
7
7
  graphiti: {
8
8
  endpoint: string;
9
9
  defaultGroupId: string;
10
+ uuidPollIntervalMs: number;
11
+ uuidPollMaxAttempts: number;
10
12
  };
11
13
  subjectType: "agent" | "person";
12
14
  subjectId: string;
@@ -19,6 +21,8 @@ export type GraphitiMemoryConfig = {
19
21
  const DEFAULT_SPICEDB_ENDPOINT = "localhost:50051";
20
22
  const DEFAULT_GRAPHITI_ENDPOINT = "http://localhost:8000";
21
23
  const DEFAULT_GROUP_ID = "main";
24
+ const DEFAULT_UUID_POLL_INTERVAL_MS = 3000;
25
+ const DEFAULT_UUID_POLL_MAX_ATTEMPTS = 30;
22
26
  const DEFAULT_SUBJECT_TYPE = "agent";
23
27
  const DEFAULT_MAX_CAPTURE_MESSAGES = 10;
24
28
 
@@ -51,7 +55,7 @@ function assertAllowedKeys(value: Record<string, unknown>, allowed: string[], la
51
55
  export const graphitiMemoryConfigSchema = {
52
56
  parse(value: unknown): GraphitiMemoryConfig {
53
57
  if (!value || typeof value !== "object" || Array.isArray(value)) {
54
- throw new Error("memory-graphiti config required");
58
+ throw new Error("openclaw-memory-graphiti config required");
55
59
  }
56
60
  const cfg = value as Record<string, unknown>;
57
61
  assertAllowedKeys(
@@ -60,7 +64,7 @@ export const graphitiMemoryConfigSchema = {
60
64
  "spicedb", "graphiti", "subjectType", "subjectId",
61
65
  "autoCapture", "autoRecall", "customInstructions", "maxCaptureMessages",
62
66
  ],
63
- "memory-graphiti config",
67
+ "openclaw-memory-graphiti config",
64
68
  );
65
69
 
66
70
  // SpiceDB config
@@ -72,7 +76,7 @@ export const graphitiMemoryConfigSchema = {
72
76
 
73
77
  // Graphiti config
74
78
  const graphiti = (cfg.graphiti as Record<string, unknown>) ?? {};
75
- assertAllowedKeys(graphiti, ["endpoint", "defaultGroupId"], "graphiti config");
79
+ assertAllowedKeys(graphiti, ["endpoint", "defaultGroupId", "uuidPollIntervalMs", "uuidPollMaxAttempts"], "graphiti config");
76
80
 
77
81
  // Subject
78
82
  const subjectType = cfg.subjectType === "person" ? "person" : DEFAULT_SUBJECT_TYPE;
@@ -93,6 +97,14 @@ export const graphitiMemoryConfigSchema = {
93
97
  typeof graphiti.defaultGroupId === "string"
94
98
  ? graphiti.defaultGroupId
95
99
  : DEFAULT_GROUP_ID,
100
+ uuidPollIntervalMs:
101
+ typeof graphiti.uuidPollIntervalMs === "number" && graphiti.uuidPollIntervalMs > 0
102
+ ? graphiti.uuidPollIntervalMs
103
+ : DEFAULT_UUID_POLL_INTERVAL_MS,
104
+ uuidPollMaxAttempts:
105
+ typeof graphiti.uuidPollMaxAttempts === "number" && graphiti.uuidPollMaxAttempts > 0
106
+ ? Math.round(graphiti.uuidPollMaxAttempts)
107
+ : DEFAULT_UUID_POLL_MAX_ATTEMPTS,
96
108
  },
97
109
  subjectType,
98
110
  subjectId,
@@ -44,7 +44,7 @@ SPICEDB_DB_PASSWORD=spicedb_dev
44
44
  # ===========================================================================
45
45
  #
46
46
  # When running via docker compose, services reach each other by hostname.
47
- # Configure the memory-graphiti plugin in OpenClaw with:
47
+ # Configure the openclaw-memory-graphiti plugin in OpenClaw with:
48
48
  #
49
49
  # {
50
50
  # "spicedb": {
@@ -1,4 +1,4 @@
1
- # memory-graphiti infrastructure: FalkorDB + Graphiti MCP + PostgreSQL + SpiceDB
1
+ # openclaw-memory-graphiti infrastructure: FalkorDB + Graphiti MCP + PostgreSQL + SpiceDB
2
2
  #
3
3
  # Usage:
4
4
  # cp .env.example .env # Fill in OPENAI_API_KEY at minimum
@@ -80,6 +80,7 @@ services:
80
80
  - "${GRAPHITI_PORT:-8000}:8000" # MCP HTTP endpoint
81
81
  environment:
82
82
  - OPENAI_API_KEY=${OPENAI_API_KEY}
83
+ - EPISODE_ID_PREFIX=${EPISODE_ID_PREFIX:-epi-}
83
84
  - FALKORDB_URI=redis://host.docker.internal:${FALKORDB_PORT:-6379}
84
85
  depends_on:
85
86
  falkordb:
package/graphiti.ts CHANGED
@@ -78,9 +78,9 @@ export class GraphitiClient {
78
78
  private initPromise: Promise<void> | null = null;
79
79
 
80
80
  /** Polling interval (ms) for UUID resolution after addEpisode. */
81
- uuidPollIntervalMs = 2000;
81
+ uuidPollIntervalMs = 3000;
82
82
  /** Max polling attempts for UUID resolution (total wait = interval * attempts). */
83
- uuidPollMaxAttempts = 15;
83
+ uuidPollMaxAttempts = 30;
84
84
 
85
85
  constructor(private readonly endpoint: string) {}
86
86
 
@@ -267,7 +267,7 @@ export class GraphitiClient {
267
267
  // Note: The Graphiti MCP server's uuid parameter is for re-processing
268
268
  // existing episodes, NOT for setting the UUID of new ones. We generate
269
269
  // a client-side tracking UUID instead (it won't match the server-side UUID).
270
- const trackingUuid = randomUUID();
270
+ const trackingUuid = `tmp-${randomUUID()}`;
271
271
 
272
272
  // Prepend custom extraction instructions to episode_body since the MCP
273
273
  // server doesn't support custom_extraction_instructions as a parameter.
@@ -309,18 +309,22 @@ export class GraphitiClient {
309
309
  }
310
310
 
311
311
  private async resolveEpisodeUuid(name: string, groupId: string): Promise<string> {
312
+ const totalTimeoutSec = (this.uuidPollMaxAttempts * this.uuidPollIntervalMs) / 1000;
312
313
  for (let i = 0; i < this.uuidPollMaxAttempts; i++) {
313
314
  await new Promise((r) => setTimeout(r, this.uuidPollIntervalMs));
314
315
  try {
315
316
  const episodes = await this.getEpisodes(groupId, 50);
316
317
  const match = episodes.find((ep) => ep.name === name);
317
318
  if (match) return match.uuid;
318
- } catch {
319
- // Retry on transient errors
319
+ } catch (err) {
320
+ // Log transient errors to aid debugging
321
+ console.warn(
322
+ `[graphiti] UUID poll ${i + 1}/${this.uuidPollMaxAttempts} for "${name}" in "${groupId}" failed: ${err instanceof Error ? err.message : String(err)}`,
323
+ );
320
324
  }
321
325
  }
322
326
  throw new Error(
323
- `Failed to resolve episode UUID for "${name}" in group "${groupId}" after ${(this.uuidPollMaxAttempts * this.uuidPollIntervalMs) / 1000}s`,
327
+ `Failed to resolve episode UUID for "${name}" in group "${groupId}" after ${totalTimeoutSec}s — episode not yet visible in get_episodes (Graphiti LLM processing may still be running)`,
324
328
  );
325
329
  }
326
330
 
package/index.ts CHANGED
@@ -22,8 +22,6 @@ import { SpiceDbClient } from "./spicedb.js";
22
22
  import {
23
23
  lookupAuthorizedGroups,
24
24
  writeFragmentRelationships,
25
- deleteFragmentRelationships,
26
- canDeleteFragment,
27
25
  canWriteToGroup,
28
26
  ensureGroupMembership,
29
27
  type Subject,
@@ -40,8 +38,10 @@ import { registerCommands } from "./cli.js";
40
38
  // ============================================================================
41
39
 
42
40
  function sessionGroupId(sessionId: string): string {
43
- // Use dash separator — Graphiti group_ids only allow alphanumeric, dashes, underscores
44
- 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}`;
45
45
  }
46
46
 
47
47
  function isSessionGroup(groupId: string): boolean {
@@ -53,7 +53,7 @@ function isSessionGroup(groupId: string): boolean {
53
53
  // ============================================================================
54
54
 
55
55
  const memoryGraphitiPlugin = {
56
- id: "memory-graphiti",
56
+ id: "openclaw-memory-graphiti",
57
57
  name: "Memory (Graphiti + SpiceDB)",
58
58
  description: "Two-layer memory: SpiceDB authorization + Graphiti knowledge graph",
59
59
  kind: "memory" as const,
@@ -63,6 +63,8 @@ const memoryGraphitiPlugin = {
63
63
  const cfg = graphitiMemoryConfigSchema.parse(api.pluginConfig);
64
64
 
65
65
  const graphiti = new GraphitiClient(cfg.graphiti.endpoint);
66
+ graphiti.uuidPollIntervalMs = cfg.graphiti.uuidPollIntervalMs;
67
+ graphiti.uuidPollMaxAttempts = cfg.graphiti.uuidPollMaxAttempts;
66
68
  const spicedb = new SpiceDbClient(cfg.spicedb);
67
69
 
68
70
  const currentSubject: Subject = {
@@ -77,12 +79,8 @@ const memoryGraphitiPlugin = {
77
79
  // Reads use at_least_as_fresh(token) after own writes, minimize_latency otherwise.
78
80
  let lastWriteToken: string | undefined;
79
81
 
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
-
84
82
  api.logger.info(
85
- `memory-graphiti: registered (graphiti: ${cfg.graphiti.endpoint}, spicedb: ${cfg.spicedb.endpoint})`,
83
+ `openclaw-memory-graphiti: registered (graphiti: ${cfg.graphiti.endpoint}, spicedb: ${cfg.spicedb.endpoint})`,
86
84
  );
87
85
 
88
86
  // ========================================================================
@@ -270,7 +268,7 @@ const memoryGraphitiPlugin = {
270
268
  const token = await ensureGroupMembership(spicedb, targetGroupId, currentSubject);
271
269
  if (token) lastWriteToken = token;
272
270
  } catch {
273
- api.logger.warn(`memory-graphiti: failed to ensure membership in ${targetGroupId}`);
271
+ api.logger.warn(`openclaw-memory-graphiti: failed to ensure membership in ${targetGroupId}`);
274
272
  }
275
273
  } else {
276
274
  // All other groups (non-session AND foreign session) require write permission
@@ -297,19 +295,17 @@ const memoryGraphitiPlugin = {
297
295
  custom_extraction_instructions: cfg.customInstructions,
298
296
  });
299
297
 
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.
298
+ // 2. Write authorization relationships in SpiceDB (background)
305
299
  const involvedSubjects: Subject[] = involves.map((id) => ({
306
300
  type: "person" as const,
307
301
  id,
308
302
  }));
309
303
 
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
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
313
309
  .then(async (realUuid) => {
314
310
  const writeToken = await writeFragmentRelationships(spicedb, {
315
311
  fragmentId: realUuid,
@@ -319,15 +315,13 @@ const memoryGraphitiPlugin = {
319
315
  });
320
316
  if (writeToken) lastWriteToken = writeToken;
321
317
  return realUuid;
318
+ })
319
+ .catch((err) => {
320
+ api.logger.warn(
321
+ `openclaw-memory-graphiti: deferred SpiceDB write failed for memory_store: ${err}`,
322
+ );
322
323
  });
323
324
 
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
- );
329
- });
330
-
331
325
  return {
332
326
  content: [
333
327
  {
@@ -353,98 +347,80 @@ const memoryGraphitiPlugin = {
353
347
  name: "memory_forget",
354
348
  label: "Memory Forget",
355
349
  description:
356
- "Delete a memory episode or fact. Provide either episode_id or fact_id (not both). Requires delete/write permission.",
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.",
357
351
  parameters: Type.Object({
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" })),
352
+ id: Type.String({ description: "Fact ID to delete (e.g. 'fact:da8650cb-...')" }),
360
353
  }),
361
354
  async execute(_toolCallId, params) {
362
- const { episode_id, fact_id } = params as { episode_id?: string; fact_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
+ }
363
378
 
364
- if (!episode_id && !fact_id) {
379
+ // --- Entity: not deletable via MCP server ---
380
+ if (idType === "entity") {
365
381
  return {
366
- content: [{ type: "text", text: "Either episode_id or fact_id must be provided." }],
367
- details: { action: "error" },
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 },
368
384
  };
369
385
  }
370
386
 
371
387
  // --- Fact deletion ---
372
- if (fact_id) {
373
- // 1. Fetch fact to get group_id for authorization
388
+ if (idType === "fact") {
374
389
  let fact: Awaited<ReturnType<typeof graphiti.getEntityEdge>>;
375
390
  try {
376
- fact = await graphiti.getEntityEdge(fact_id);
391
+ fact = await graphiti.getEntityEdge(uuid);
377
392
  } catch {
378
393
  return {
379
- content: [{ type: "text", text: `Fact ${fact_id} not found.` }],
380
- details: { action: "error", factId: fact_id },
394
+ content: [{ type: "text", text: `Fact ${uuid} not found.` }],
395
+ details: { action: "not_found", id },
381
396
  };
382
397
  }
383
398
 
384
- // 2. Check write permission on the fact's group
385
- const allowed = await canWriteToGroup(spicedb, currentSubject, fact.group_id, lastWriteToken);
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);
386
404
  if (!allowed) {
387
405
  return {
388
- content: [{ type: "text", text: `Permission denied: cannot delete fact ${fact_id}` }],
389
- details: { action: "denied", factId: fact_id },
406
+ content: [{ type: "text", text: `Permission denied: cannot delete fact in group "${effectiveGroupId}"` }],
407
+ details: { action: "denied", id },
390
408
  };
391
409
  }
392
410
 
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
- }
411
+ await graphiti.deleteEntityEdge(uuid);
417
412
 
418
- // 1. Check delete permission
419
- const allowed = await canDeleteFragment(spicedb, currentSubject, effectiveId, lastWriteToken);
420
- if (!allowed) {
421
413
  return {
422
- content: [
423
- {
424
- type: "text",
425
- text: `Permission denied: cannot delete episode ${episode_id}`,
426
- },
427
- ],
428
- details: { action: "denied", episodeId: episode_id },
414
+ content: [{ type: "text", text: `Fact forgotten.` }],
415
+ details: { action: "deleted", id, type: "fact" },
429
416
  };
430
417
  }
431
418
 
432
- // 2. Delete from Graphiti
433
- await graphiti.deleteEpisode(effectiveId);
434
-
435
- // 3. Clean up SpiceDB relationships (best-effort)
436
- try {
437
- const deleteToken = await deleteFragmentRelationships(spicedb, effectiveId);
438
- if (deleteToken) lastWriteToken = deleteToken;
439
- } catch {
440
- api.logger.warn(
441
- `memory-graphiti: failed to clean up SpiceDB relationships for ${episode_id}`,
442
- );
443
- }
444
-
419
+ // --- Bare UUID / episode: not supported via agent tool ---
420
+ // Episode deletion is an admin operation available via CLI (graphiti-mem cleanup).
445
421
  return {
446
- content: [{ type: "text", text: `Memory ${episode_id} forgotten.` }],
447
- 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 },
448
424
  };
449
425
  },
450
426
  },
@@ -579,14 +555,14 @@ const memoryGraphitiPlugin = {
579
555
 
580
556
  const memoryContext = formatDualResults(longTermResults, sessionResults);
581
557
  api.logger.info?.(
582
- `memory-graphiti: injecting ${totalCount} memories (${longTermResults.length} long-term, ${sessionResults.length} session)`,
558
+ `openclaw-memory-graphiti: injecting ${totalCount} memories (${longTermResults.length} long-term, ${sessionResults.length} session)`,
583
559
  );
584
560
 
585
561
  return {
586
562
  prependContext: `${toolHint}\n\n<relevant-memories>\nThe following memories from the knowledge graph may be relevant:\n${memoryContext}\n</relevant-memories>`,
587
563
  };
588
564
  } catch (err) {
589
- api.logger.warn(`memory-graphiti: recall failed: ${String(err)}`);
565
+ api.logger.warn(`openclaw-memory-graphiti: recall failed: ${String(err)}`);
590
566
  }
591
567
  });
592
568
  }
@@ -676,7 +652,7 @@ const memoryGraphitiPlugin = {
676
652
  } else {
677
653
  const allowed = await canWriteToGroup(spicedb, currentSubject, targetGroupId, lastWriteToken);
678
654
  if (!allowed) {
679
- api.logger.warn(`memory-graphiti: auto-capture denied for group ${targetGroupId}`);
655
+ api.logger.warn(`openclaw-memory-graphiti: auto-capture denied for group ${targetGroupId}`);
680
656
  return;
681
657
  }
682
658
  }
@@ -701,15 +677,15 @@ const memoryGraphitiPlugin = {
701
677
  })
702
678
  .catch((err) => {
703
679
  api.logger.warn(
704
- `memory-graphiti: deferred SpiceDB write (auto-capture) failed: ${err}`,
680
+ `openclaw-memory-graphiti: deferred SpiceDB write (auto-capture) failed: ${err}`,
705
681
  );
706
682
  });
707
683
 
708
684
  api.logger.info(
709
- `memory-graphiti: auto-captured ${conversationLines.length} messages as batch episode to ${targetGroupId}`,
685
+ `openclaw-memory-graphiti: auto-captured ${conversationLines.length} messages as batch episode to ${targetGroupId}`,
710
686
  );
711
687
  } catch (err) {
712
- api.logger.warn(`memory-graphiti: capture failed: ${String(err)}`);
688
+ api.logger.warn(`openclaw-memory-graphiti: capture failed: ${String(err)}`);
713
689
  }
714
690
  });
715
691
  }
@@ -719,7 +695,7 @@ const memoryGraphitiPlugin = {
719
695
  // ========================================================================
720
696
 
721
697
  api.registerService({
722
- id: "memory-graphiti",
698
+ id: "openclaw-memory-graphiti",
723
699
  async start() {
724
700
  // Verify connectivity on startup
725
701
  const graphitiOk = await graphiti.healthCheck();
@@ -729,12 +705,12 @@ const memoryGraphitiPlugin = {
729
705
  spicedbOk = true;
730
706
 
731
707
  // Auto-write schema if SpiceDB has no schema yet
732
- if (!existing || !existing.includes("memory_group")) {
733
- api.logger.info("memory-graphiti: writing SpiceDB schema (first run)");
708
+ if (!existing || !existing.includes("memory_fragment")) {
709
+ api.logger.info("openclaw-memory-graphiti: writing SpiceDB schema (first run)");
734
710
  const schemaPath = join(dirname(fileURLToPath(import.meta.url)), "schema.zed");
735
711
  const schema = readFileSync(schemaPath, "utf-8");
736
712
  await spicedb.writeSchema(schema);
737
- api.logger.info("memory-graphiti: SpiceDB schema written successfully");
713
+ api.logger.info("openclaw-memory-graphiti: SpiceDB schema written successfully");
738
714
  }
739
715
  } catch {
740
716
  // Will be retried on first use
@@ -750,16 +726,16 @@ const memoryGraphitiPlugin = {
750
726
  );
751
727
  if (token) lastWriteToken = token;
752
728
  } catch {
753
- api.logger.warn("memory-graphiti: failed to ensure default group membership");
729
+ api.logger.warn("openclaw-memory-graphiti: failed to ensure default group membership");
754
730
  }
755
731
  }
756
732
 
757
733
  api.logger.info(
758
- `memory-graphiti: initialized (graphiti: ${graphitiOk ? "OK" : "UNREACHABLE"}, spicedb: ${spicedbOk ? "OK" : "UNREACHABLE"})`,
734
+ `openclaw-memory-graphiti: initialized (graphiti: ${graphitiOk ? "OK" : "UNREACHABLE"}, spicedb: ${spicedbOk ? "OK" : "UNREACHABLE"})`,
759
735
  );
760
736
  },
761
737
  stop() {
762
- api.logger.info("memory-graphiti: stopped");
738
+ api.logger.info("openclaw-memory-graphiti: stopped");
763
739
  },
764
740
  });
765
741
  },
@@ -1,5 +1,5 @@
1
1
  {
2
- "id": "memory-graphiti",
2
+ "id": "openclaw-memory-graphiti",
3
3
  "kind": "memory",
4
4
  "uiHints": {
5
5
  "spicedb.endpoint": {
@@ -27,6 +27,18 @@
27
27
  "placeholder": "main",
28
28
  "help": "Default Graphiti group_id for memory isolation"
29
29
  },
30
+ "graphiti.uuidPollIntervalMs": {
31
+ "label": "UUID Poll Interval (ms)",
32
+ "placeholder": "3000",
33
+ "help": "Milliseconds between polling attempts when resolving episode UUIDs after memory_store (default: 3000)",
34
+ "advanced": true
35
+ },
36
+ "graphiti.uuidPollMaxAttempts": {
37
+ "label": "UUID Poll Max Attempts",
38
+ "placeholder": "30",
39
+ "help": "Maximum polling attempts for episode UUID resolution; total timeout = interval × attempts (default: 30 = 90s)",
40
+ "advanced": true
41
+ },
30
42
  "subjectType": {
31
43
  "label": "Subject Type",
32
44
  "placeholder": "agent",
@@ -76,7 +88,9 @@
76
88
  "additionalProperties": false,
77
89
  "properties": {
78
90
  "endpoint": { "type": "string" },
79
- "defaultGroupId": { "type": "string" }
91
+ "defaultGroupId": { "type": "string" },
92
+ "uuidPollIntervalMs": { "type": "integer", "minimum": 500, "maximum": 30000 },
93
+ "uuidPollMaxAttempts": { "type": "integer", "minimum": 1, "maximum": 200 }
80
94
  }
81
95
  },
82
96
  "subjectType": { "type": "string", "enum": ["agent", "person"] },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contextableai/openclaw-memory-graphiti",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "OpenClaw two-layer memory plugin: SpiceDB authorization + Graphiti knowledge graph",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -50,11 +50,10 @@
50
50
  "access": "public"
51
51
  },
52
52
  "openclaw": {
53
- "_extensions_note": "Intentionally empty — explicit entries cause idHint to derive from the npm package name rather than the directory name, producing a mismatch with the manifest id. Previously: [\"./index.ts\"]",
54
- "extensions": [],
53
+ "extensions": ["./index.ts"],
55
54
  "install": {
56
55
  "npmSpec": "@contextableai/openclaw-memory-graphiti",
57
- "localPath": "extensions/memory-graphiti",
56
+ "localPath": "extensions/openclaw-memory-graphiti",
58
57
  "defaultChoice": "npm"
59
58
  }
60
59
  }
@@ -13,6 +13,7 @@
13
13
  # SPICEDB_DB_URI Postgres connection URI (when SPICEDB_DATASTORE=postgres)
14
14
  # FALKORDB_PORT Redis port (default: 6379)
15
15
  # GRAPHITI_PORT HTTP port (default: 8000)
16
+ # EPISODE_ID_PREFIX Prefix for Graphiti episode UUIDs (default: epi-)
16
17
  # -------------------------------------------------------------------
17
18
  set -euo pipefail
18
19
 
@@ -63,6 +64,8 @@ if is_running "$DEV_DIR/pids/falkordb.pid"; then
63
64
  echo "==> FalkorDB already running (pid $(cat "$DEV_DIR/pids/falkordb.pid"))"
64
65
  else
65
66
  echo "==> Starting FalkorDB on port $FALKORDB_PORT..."
67
+ # Redis 8.x requires a valid locale; default to C.utf8 if LANG is unset
68
+ export LC_ALL="${LC_ALL:-C.utf8}"
66
69
  redis-server \
67
70
  --loadmodule "$DEV_DIR/lib/falkordb.so" \
68
71
  --port "$FALKORDB_PORT" \
@@ -190,6 +193,7 @@ else
190
193
  # Set environment for Graphiti
191
194
  export OPENAI_API_KEY="${OPENAI_API_KEY:-}"
192
195
  export FALKORDB_URI="redis://localhost:$FALKORDB_PORT"
196
+ export EPISODE_ID_PREFIX="${EPISODE_ID_PREFIX:-epi-}"
193
197
 
194
198
  cd "$GRAPHITI_DIR"
195
199
  uv run main.py \
package/search.ts CHANGED
@@ -138,9 +138,10 @@ function nodeToResult(node: GraphitiNode): SearchResult {
138
138
  }
139
139
 
140
140
  function factToResult(fact: GraphitiFact): SearchResult {
141
- // Use node names if available, fall back to relationship name or UUIDs
142
- const source = fact.source_node_name ?? fact.source_node_uuid ?? "?";
143
- const target = fact.target_node_name ?? fact.target_node_uuid ?? "?";
141
+ // Use node names only never expose raw node UUIDs in the formatted output.
142
+ // Bare UUIDs in context strings cause LLMs to confuse node IDs with fact/episode IDs.
143
+ const source = fact.source_node_name ?? "?";
144
+ const target = fact.target_node_name ?? "?";
144
145
  const context = fact.name ? `${source} -[${fact.name}]→ ${target}` : `${source} → ${target}`;
145
146
  return {
146
147
  type: "fact",
@@ -164,12 +165,7 @@ export function formatResultsForContext(results: SearchResult[]): string {
164
165
  return "";
165
166
  }
166
167
 
167
- return results
168
- .map((r, i) => {
169
- const typeLabel = r.type === "node" ? "entity" : "fact";
170
- return `${i + 1}. [${typeLabel}] ${r.summary} (${r.context})`;
171
- })
172
- .join("\n");
168
+ return results.map((r, i) => formatResultLine(r, i + 1)).join("\n");
173
169
  }
174
170
 
175
171
  /**
@@ -185,22 +181,30 @@ export function formatDualResults(
185
181
 
186
182
  if (longTermResults.length > 0) {
187
183
  for (const r of longTermResults) {
188
- const typeLabel = r.type === "node" ? "entity" : "fact";
189
- parts.push(`${idx++}. [${typeLabel}] ${r.summary} (${r.context})`);
184
+ parts.push(formatResultLine(r, idx++));
190
185
  }
191
186
  }
192
187
 
193
188
  if (sessionResults.length > 0) {
194
189
  parts.push("Session memories:");
195
190
  for (const r of sessionResults) {
196
- const typeLabel = r.type === "node" ? "entity" : "fact";
197
- parts.push(`${idx++}. [${typeLabel}] ${r.summary} (${r.context})`);
191
+ parts.push(formatResultLine(r, idx++));
198
192
  }
199
193
  }
200
194
 
201
195
  return parts.join("\n");
202
196
  }
203
197
 
198
+ /**
199
+ * Format a single search result line with type-prefixed UUID.
200
+ * e.g. "[fact:da8650cb-...] Eric's birthday is Dec 17th (Eric -[HAS_BIRTHDAY]→ Dec 17th)"
201
+ * The type prefix tells the LLM which deletion method to use.
202
+ */
203
+ function formatResultLine(r: SearchResult, idx: number): string {
204
+ const typeLabel = r.type === "node" ? "entity" : "fact";
205
+ return `${idx}. [${typeLabel}:${r.uuid}] ${r.summary} (${r.context})`;
206
+ }
207
+
204
208
  /**
205
209
  * Deduplicate session results against long-term results (by UUID).
206
210
  */