@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 +5 -5
- package/bin/graphiti-mem.ts +2 -0
- package/cli.ts +6 -3
- package/config.ts +15 -3
- package/docker/.env.example +1 -1
- package/docker/docker-compose.yml +2 -1
- package/graphiti.ts +10 -6
- package/index.ts +80 -104
- package/openclaw.plugin.json +16 -2
- package/package.json +3 -4
- package/scripts/dev-start.sh +4 -0
- package/search.ts +17 -13
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)
|
package/bin/graphiti-mem.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/docker/.env.example
CHANGED
|
@@ -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 =
|
|
81
|
+
uuidPollIntervalMs = 3000;
|
|
82
82
|
/** Max polling attempts for UUID resolution (total wait = interval * attempts). */
|
|
83
|
-
uuidPollMaxAttempts =
|
|
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
|
-
//
|
|
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 ${(
|
|
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
|
-
//
|
|
44
|
-
|
|
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
|
|
311
|
-
//
|
|
312
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
379
|
+
// --- Entity: not deletable via MCP server ---
|
|
380
|
+
if (idType === "entity") {
|
|
365
381
|
return {
|
|
366
|
-
content: [{ type: "text", text:
|
|
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 (
|
|
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(
|
|
391
|
+
fact = await graphiti.getEntityEdge(uuid);
|
|
377
392
|
} catch {
|
|
378
393
|
return {
|
|
379
|
-
content: [{ type: "text", text: `Fact ${
|
|
380
|
-
details: { action: "
|
|
394
|
+
content: [{ type: "text", text: `Fact ${uuid} not found.` }],
|
|
395
|
+
details: { action: "not_found", id },
|
|
381
396
|
};
|
|
382
397
|
}
|
|
383
398
|
|
|
384
|
-
//
|
|
385
|
-
|
|
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 ${
|
|
389
|
-
details: { action: "denied",
|
|
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
|
-
|
|
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
|
-
//
|
|
433
|
-
|
|
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: `
|
|
447
|
-
details: { action: "
|
|
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("
|
|
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
|
},
|
package/openclaw.plugin.json
CHANGED
|
@@ -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.
|
|
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
|
-
"
|
|
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
|
}
|
package/scripts/dev-start.sh
CHANGED
|
@@ -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
|
|
142
|
-
|
|
143
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
*/
|