@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/README.md +54 -0
- package/authorization.ts +27 -38
- package/bin/graphiti-mem.ts +142 -0
- package/cli.ts +525 -0
- package/config.ts +13 -1
- package/docker/docker-compose.yml +3 -2
- package/graphiti.ts +80 -7
- package/index.ts +154 -361
- package/openclaw.plugin.json +15 -1
- package/package.json +11 -6
- package/scripts/dev-start.sh +4 -0
- package/search.ts +34 -18
- package/spicedb.ts +190 -9
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 {
|
|
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
|
-
//
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
352
|
+
id: Type.String({ description: "Fact ID to delete (e.g. 'fact:da8650cb-...')" }),
|
|
321
353
|
}),
|
|
322
354
|
async execute(_toolCallId, params) {
|
|
323
|
-
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
|
+
}
|
|
324
378
|
|
|
325
|
-
//
|
|
326
|
-
|
|
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
|
-
//
|
|
340
|
-
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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: `
|
|
357
|
-
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 },
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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
|
|
716
|
-
if (
|
|
717
|
-
currentSessionId =
|
|
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
|
|
792
|
-
if (
|
|
793
|
-
currentSessionId =
|
|
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
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
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("
|
|
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
|
}
|