@contextableai/openclaw-memory-graphiti 0.2.0 → 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.
@@ -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
 
@@ -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,
@@ -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 {
@@ -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,10 +79,6 @@ 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
83
  `memory-graphiti: registered (graphiti: ${cfg.graphiti.endpoint}, spicedb: ${cfg.spicedb.endpoint})`,
86
84
  );
@@ -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
+ `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
  },
@@ -729,7 +705,7 @@ 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")) {
708
+ if (!existing || !existing.includes("memory_fragment")) {
733
709
  api.logger.info("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");
@@ -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.1",
4
4
  "description": "OpenClaw two-layer memory plugin: SpiceDB authorization + Graphiti knowledge graph",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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
  */