@contextableai/openclaw-memory-graphiti 0.1.2 → 0.2.0
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 +140 -0
- package/cli.ts +522 -0
- package/docker/docker-compose.yml +2 -2
- package/graphiti.ts +75 -6
- package/index.ts +154 -337
- package/package.json +11 -6
- package/search.ts +17 -5
- 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";
|
|
@@ -35,6 +33,7 @@ import {
|
|
|
35
33
|
formatDualResults,
|
|
36
34
|
deduplicateSessionResults,
|
|
37
35
|
} from "./search.js";
|
|
36
|
+
import { registerCommands } from "./cli.js";
|
|
38
37
|
|
|
39
38
|
// ============================================================================
|
|
40
39
|
// Session helpers
|
|
@@ -74,6 +73,14 @@ const memoryGraphitiPlugin = {
|
|
|
74
73
|
// Track current session ID — updated from hook event context
|
|
75
74
|
let currentSessionId: string | undefined;
|
|
76
75
|
|
|
76
|
+
// Track most recent ZedToken from SpiceDB writes for causal consistency.
|
|
77
|
+
// Reads use at_least_as_fresh(token) after own writes, minimize_latency otherwise.
|
|
78
|
+
let lastWriteToken: string | undefined;
|
|
79
|
+
|
|
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
|
+
|
|
77
84
|
api.logger.info(
|
|
78
85
|
`memory-graphiti: registered (graphiti: ${cfg.graphiti.endpoint}, spicedb: ${cfg.spicedb.endpoint})`,
|
|
79
86
|
);
|
|
@@ -97,16 +104,28 @@ const memoryGraphitiPlugin = {
|
|
|
97
104
|
{ description: "Memory scope: 'session' (current session only), 'long-term' (persistent), or 'all' (both). Default: 'all'" },
|
|
98
105
|
),
|
|
99
106
|
),
|
|
107
|
+
entity_types: Type.Optional(
|
|
108
|
+
Type.Array(Type.String(), {
|
|
109
|
+
description: "Filter by entity type (e.g., 'Preference', 'Organization', 'Procedure')",
|
|
110
|
+
}),
|
|
111
|
+
),
|
|
112
|
+
center_node_uuid: Type.Optional(
|
|
113
|
+
Type.String({
|
|
114
|
+
description: "UUID of an entity node to center the fact search around",
|
|
115
|
+
}),
|
|
116
|
+
),
|
|
100
117
|
}),
|
|
101
118
|
async execute(_toolCallId, params) {
|
|
102
|
-
const { query, limit = 10, scope = "all" } = params as {
|
|
119
|
+
const { query, limit = 10, scope = "all", entity_types, center_node_uuid } = params as {
|
|
103
120
|
query: string;
|
|
104
121
|
limit?: number;
|
|
105
122
|
scope?: "session" | "long-term" | "all";
|
|
123
|
+
entity_types?: string[];
|
|
124
|
+
center_node_uuid?: string;
|
|
106
125
|
};
|
|
107
126
|
|
|
108
127
|
// 1. Get authorized groups for current subject
|
|
109
|
-
const authorizedGroups = await lookupAuthorizedGroups(spicedb, currentSubject);
|
|
128
|
+
const authorizedGroups = await lookupAuthorizedGroups(spicedb, currentSubject, lastWriteToken);
|
|
110
129
|
|
|
111
130
|
if (authorizedGroups.length === 0) {
|
|
112
131
|
return {
|
|
@@ -145,12 +164,13 @@ const memoryGraphitiPlugin = {
|
|
|
145
164
|
}
|
|
146
165
|
|
|
147
166
|
// 3. Parallel search across groups
|
|
167
|
+
const searchOpts = { entityTypes: entity_types, centerNodeUuid: center_node_uuid };
|
|
148
168
|
const [longTermResults, rawSessionResults] = await Promise.all([
|
|
149
169
|
longTermGroups.length > 0
|
|
150
|
-
? searchAuthorizedMemories(graphiti, { query, groupIds: longTermGroups, limit })
|
|
170
|
+
? searchAuthorizedMemories(graphiti, { query, groupIds: longTermGroups, limit, ...searchOpts })
|
|
151
171
|
: Promise.resolve([]),
|
|
152
172
|
sessionGroups.length > 0
|
|
153
|
-
? searchAuthorizedMemories(graphiti, { query, groupIds: sessionGroups, limit })
|
|
173
|
+
? searchAuthorizedMemories(graphiti, { query, groupIds: sessionGroups, limit, ...searchOpts })
|
|
154
174
|
: Promise.resolve([]),
|
|
155
175
|
]);
|
|
156
176
|
|
|
@@ -247,13 +267,14 @@ const memoryGraphitiPlugin = {
|
|
|
247
267
|
|
|
248
268
|
if (isOwnSession) {
|
|
249
269
|
try {
|
|
250
|
-
await ensureGroupMembership(spicedb, targetGroupId, currentSubject);
|
|
270
|
+
const token = await ensureGroupMembership(spicedb, targetGroupId, currentSubject);
|
|
271
|
+
if (token) lastWriteToken = token;
|
|
251
272
|
} catch {
|
|
252
273
|
api.logger.warn(`memory-graphiti: failed to ensure membership in ${targetGroupId}`);
|
|
253
274
|
}
|
|
254
275
|
} else {
|
|
255
276
|
// All other groups (non-session AND foreign session) require write permission
|
|
256
|
-
const allowed = await canWriteToGroup(spicedb, currentSubject, targetGroupId);
|
|
277
|
+
const allowed = await canWriteToGroup(spicedb, currentSubject, targetGroupId, lastWriteToken);
|
|
257
278
|
if (!allowed) {
|
|
258
279
|
return {
|
|
259
280
|
content: [
|
|
@@ -276,19 +297,35 @@ const memoryGraphitiPlugin = {
|
|
|
276
297
|
custom_extraction_instructions: cfg.customInstructions,
|
|
277
298
|
});
|
|
278
299
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
//
|
|
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.
|
|
282
305
|
const involvedSubjects: Subject[] = involves.map((id) => ({
|
|
283
306
|
type: "person" as const,
|
|
284
307
|
id,
|
|
285
308
|
}));
|
|
286
309
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
|
313
|
+
.then(async (realUuid) => {
|
|
314
|
+
const writeToken = await writeFragmentRelationships(spicedb, {
|
|
315
|
+
fragmentId: realUuid,
|
|
316
|
+
groupId: targetGroupId,
|
|
317
|
+
sharedBy: currentSubject,
|
|
318
|
+
involves: involvedSubjects,
|
|
319
|
+
});
|
|
320
|
+
if (writeToken) lastWriteToken = writeToken;
|
|
321
|
+
return realUuid;
|
|
322
|
+
});
|
|
323
|
+
|
|
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
|
+
);
|
|
292
329
|
});
|
|
293
330
|
|
|
294
331
|
return {
|
|
@@ -300,7 +337,7 @@ const memoryGraphitiPlugin = {
|
|
|
300
337
|
],
|
|
301
338
|
details: {
|
|
302
339
|
action: "created",
|
|
303
|
-
episodeId:
|
|
340
|
+
episodeId: result.episode_uuid,
|
|
304
341
|
groupId: targetGroupId,
|
|
305
342
|
longTerm,
|
|
306
343
|
involves,
|
|
@@ -315,15 +352,71 @@ const memoryGraphitiPlugin = {
|
|
|
315
352
|
{
|
|
316
353
|
name: "memory_forget",
|
|
317
354
|
label: "Memory Forget",
|
|
318
|
-
description:
|
|
355
|
+
description:
|
|
356
|
+
"Delete a memory episode or fact. Provide either episode_id or fact_id (not both). Requires delete/write permission.",
|
|
319
357
|
parameters: Type.Object({
|
|
320
|
-
episode_id: Type.String({ description: "Episode UUID to delete" }),
|
|
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" })),
|
|
321
360
|
}),
|
|
322
361
|
async execute(_toolCallId, params) {
|
|
323
|
-
const { episode_id } = params as { episode_id
|
|
362
|
+
const { episode_id, fact_id } = params as { episode_id?: string; fact_id?: string };
|
|
363
|
+
|
|
364
|
+
if (!episode_id && !fact_id) {
|
|
365
|
+
return {
|
|
366
|
+
content: [{ type: "text", text: "Either episode_id or fact_id must be provided." }],
|
|
367
|
+
details: { action: "error" },
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// --- Fact deletion ---
|
|
372
|
+
if (fact_id) {
|
|
373
|
+
// 1. Fetch fact to get group_id for authorization
|
|
374
|
+
let fact: Awaited<ReturnType<typeof graphiti.getEntityEdge>>;
|
|
375
|
+
try {
|
|
376
|
+
fact = await graphiti.getEntityEdge(fact_id);
|
|
377
|
+
} catch {
|
|
378
|
+
return {
|
|
379
|
+
content: [{ type: "text", text: `Fact ${fact_id} not found.` }],
|
|
380
|
+
details: { action: "error", factId: fact_id },
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// 2. Check write permission on the fact's group
|
|
385
|
+
const allowed = await canWriteToGroup(spicedb, currentSubject, fact.group_id, lastWriteToken);
|
|
386
|
+
if (!allowed) {
|
|
387
|
+
return {
|
|
388
|
+
content: [{ type: "text", text: `Permission denied: cannot delete fact ${fact_id}` }],
|
|
389
|
+
details: { action: "denied", factId: fact_id },
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
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
|
+
}
|
|
324
417
|
|
|
325
418
|
// 1. Check delete permission
|
|
326
|
-
const allowed = await canDeleteFragment(spicedb, currentSubject,
|
|
419
|
+
const allowed = await canDeleteFragment(spicedb, currentSubject, effectiveId, lastWriteToken);
|
|
327
420
|
if (!allowed) {
|
|
328
421
|
return {
|
|
329
422
|
content: [
|
|
@@ -337,15 +430,12 @@ const memoryGraphitiPlugin = {
|
|
|
337
430
|
}
|
|
338
431
|
|
|
339
432
|
// 2. Delete from Graphiti
|
|
340
|
-
await graphiti.deleteEpisode(
|
|
433
|
+
await graphiti.deleteEpisode(effectiveId);
|
|
341
434
|
|
|
342
435
|
// 3. Clean up SpiceDB relationships (best-effort)
|
|
343
436
|
try {
|
|
344
|
-
await deleteFragmentRelationships(spicedb,
|
|
345
|
-
|
|
346
|
-
groupId: cfg.graphiti.defaultGroupId,
|
|
347
|
-
sharedBy: currentSubject,
|
|
348
|
-
});
|
|
437
|
+
const deleteToken = await deleteFragmentRelationships(spicedb, effectiveId);
|
|
438
|
+
if (deleteToken) lastWriteToken = deleteToken;
|
|
349
439
|
} catch {
|
|
350
440
|
api.logger.warn(
|
|
351
441
|
`memory-graphiti: failed to clean up SpiceDB relationships for ${episode_id}`,
|
|
@@ -410,298 +500,13 @@ const memoryGraphitiPlugin = {
|
|
|
410
500
|
const mem = program
|
|
411
501
|
.command("graphiti-mem")
|
|
412
502
|
.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
|
-
});
|
|
503
|
+
registerCommands(mem, {
|
|
504
|
+
graphiti,
|
|
505
|
+
spicedb,
|
|
506
|
+
cfg,
|
|
507
|
+
currentSubject,
|
|
508
|
+
getLastWriteToken: () => lastWriteToken,
|
|
509
|
+
});
|
|
705
510
|
},
|
|
706
511
|
{ commands: ["graphiti-mem"] },
|
|
707
512
|
);
|
|
@@ -711,10 +516,10 @@ const memoryGraphitiPlugin = {
|
|
|
711
516
|
// ========================================================================
|
|
712
517
|
|
|
713
518
|
if (cfg.autoRecall) {
|
|
714
|
-
api.on("before_agent_start", async (event) => {
|
|
715
|
-
// Track session ID from
|
|
716
|
-
if (
|
|
717
|
-
currentSessionId =
|
|
519
|
+
api.on("before_agent_start", async (event, ctx) => {
|
|
520
|
+
// Track session ID from hook context
|
|
521
|
+
if (ctx?.sessionKey) {
|
|
522
|
+
currentSessionId = ctx.sessionKey;
|
|
718
523
|
}
|
|
719
524
|
|
|
720
525
|
if (!event.prompt || event.prompt.length < 5) {
|
|
@@ -722,7 +527,7 @@ const memoryGraphitiPlugin = {
|
|
|
722
527
|
}
|
|
723
528
|
|
|
724
529
|
try {
|
|
725
|
-
const authorizedGroups = await lookupAuthorizedGroups(spicedb, currentSubject);
|
|
530
|
+
const authorizedGroups = await lookupAuthorizedGroups(spicedb, currentSubject, lastWriteToken);
|
|
726
531
|
if (authorizedGroups.length === 0) {
|
|
727
532
|
return;
|
|
728
533
|
}
|
|
@@ -787,10 +592,10 @@ const memoryGraphitiPlugin = {
|
|
|
787
592
|
}
|
|
788
593
|
|
|
789
594
|
if (cfg.autoCapture) {
|
|
790
|
-
api.on("agent_end", async (event) => {
|
|
791
|
-
// Track session ID from
|
|
792
|
-
if (
|
|
793
|
-
currentSessionId =
|
|
595
|
+
api.on("agent_end", async (event, ctx) => {
|
|
596
|
+
// Track session ID from hook context
|
|
597
|
+
if (ctx?.sessionKey) {
|
|
598
|
+
currentSessionId = ctx.sessionKey;
|
|
794
599
|
}
|
|
795
600
|
|
|
796
601
|
if (!event.success || !event.messages || event.messages.length === 0) {
|
|
@@ -863,12 +668,13 @@ const memoryGraphitiPlugin = {
|
|
|
863
668
|
|
|
864
669
|
if (isOwnSession) {
|
|
865
670
|
try {
|
|
866
|
-
await ensureGroupMembership(spicedb, targetGroupId, currentSubject);
|
|
671
|
+
const token = await ensureGroupMembership(spicedb, targetGroupId, currentSubject);
|
|
672
|
+
if (token) lastWriteToken = token;
|
|
867
673
|
} catch {
|
|
868
674
|
// Best-effort
|
|
869
675
|
}
|
|
870
676
|
} else {
|
|
871
|
-
const allowed = await canWriteToGroup(spicedb, currentSubject, targetGroupId);
|
|
677
|
+
const allowed = await canWriteToGroup(spicedb, currentSubject, targetGroupId, lastWriteToken);
|
|
872
678
|
if (!allowed) {
|
|
873
679
|
api.logger.warn(`memory-graphiti: auto-capture denied for group ${targetGroupId}`);
|
|
874
680
|
return;
|
|
@@ -883,11 +689,21 @@ const memoryGraphitiPlugin = {
|
|
|
883
689
|
custom_extraction_instructions: cfg.customInstructions,
|
|
884
690
|
});
|
|
885
691
|
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
692
|
+
// SpiceDB writes use the real UUID once Graphiti finishes processing
|
|
693
|
+
result.resolvedUuid
|
|
694
|
+
.then(async (realUuid) => {
|
|
695
|
+
const writeToken = await writeFragmentRelationships(spicedb, {
|
|
696
|
+
fragmentId: realUuid,
|
|
697
|
+
groupId: targetGroupId,
|
|
698
|
+
sharedBy: currentSubject,
|
|
699
|
+
});
|
|
700
|
+
if (writeToken) lastWriteToken = writeToken;
|
|
701
|
+
})
|
|
702
|
+
.catch((err) => {
|
|
703
|
+
api.logger.warn(
|
|
704
|
+
`memory-graphiti: deferred SpiceDB write (auto-capture) failed: ${err}`,
|
|
705
|
+
);
|
|
706
|
+
});
|
|
891
707
|
|
|
892
708
|
api.logger.info(
|
|
893
709
|
`memory-graphiti: auto-captured ${conversationLines.length} messages as batch episode to ${targetGroupId}`,
|
|
@@ -927,11 +743,12 @@ const memoryGraphitiPlugin = {
|
|
|
927
743
|
// Ensure current subject is a member of the default group
|
|
928
744
|
if (spicedbOk) {
|
|
929
745
|
try {
|
|
930
|
-
await ensureGroupMembership(
|
|
746
|
+
const token = await ensureGroupMembership(
|
|
931
747
|
spicedb,
|
|
932
748
|
cfg.graphiti.defaultGroupId,
|
|
933
749
|
currentSubject,
|
|
934
750
|
);
|
|
751
|
+
if (token) lastWriteToken = token;
|
|
935
752
|
} catch {
|
|
936
753
|
api.logger.warn("memory-graphiti: failed to ensure default group membership");
|
|
937
754
|
}
|