@contextableai/openclaw-memory-graphiti 0.1.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/index.ts ADDED
@@ -0,0 +1,942 @@
1
+ /**
2
+ * OpenClaw Memory (Graphiti + SpiceDB) Plugin
3
+ *
4
+ * Two-layer memory architecture:
5
+ * - SpiceDB: authorization gateway (who can see what)
6
+ * - Graphiti: knowledge graph storage (entities, facts, episodes)
7
+ *
8
+ * SpiceDB determines which memories a subject can access.
9
+ * Graphiti stores the actual conversational memory and entity relationships.
10
+ * Authorization is enforced at the data layer, not in prompts.
11
+ */
12
+
13
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
14
+ import { Type } from "@sinclair/typebox";
15
+ import { readFileSync } from "node:fs";
16
+ import { readdir, readFile, stat } from "node:fs/promises";
17
+ import { join, dirname, basename, resolve } from "node:path";
18
+ import { homedir } from "node:os";
19
+ import { fileURLToPath } from "node:url";
20
+
21
+ import { graphitiMemoryConfigSchema } from "./config.js";
22
+ import { GraphitiClient } from "./graphiti.js";
23
+ import { SpiceDbClient } from "./spicedb.js";
24
+ import {
25
+ lookupAuthorizedGroups,
26
+ writeFragmentRelationships,
27
+ deleteFragmentRelationships,
28
+ canDeleteFragment,
29
+ canWriteToGroup,
30
+ ensureGroupMembership,
31
+ type Subject,
32
+ } from "./authorization.js";
33
+ import {
34
+ searchAuthorizedMemories,
35
+ formatDualResults,
36
+ deduplicateSessionResults,
37
+ } from "./search.js";
38
+
39
+ // ============================================================================
40
+ // Session helpers
41
+ // ============================================================================
42
+
43
+ function sessionGroupId(sessionId: string): string {
44
+ // Use dash separator — Graphiti group_ids only allow alphanumeric, dashes, underscores
45
+ return `session-${sessionId}`;
46
+ }
47
+
48
+ function isSessionGroup(groupId: string): boolean {
49
+ return groupId.startsWith("session-");
50
+ }
51
+
52
+ // ============================================================================
53
+ // Plugin Definition
54
+ // ============================================================================
55
+
56
+ const memoryGraphitiPlugin = {
57
+ id: "memory-graphiti",
58
+ name: "Memory (Graphiti + SpiceDB)",
59
+ description: "Two-layer memory: SpiceDB authorization + Graphiti knowledge graph",
60
+ kind: "memory" as const,
61
+ configSchema: graphitiMemoryConfigSchema,
62
+
63
+ register(api: OpenClawPluginApi) {
64
+ const cfg = graphitiMemoryConfigSchema.parse(api.pluginConfig);
65
+
66
+ const graphiti = new GraphitiClient(cfg.graphiti.endpoint);
67
+ const spicedb = new SpiceDbClient(cfg.spicedb);
68
+
69
+ const currentSubject: Subject = {
70
+ type: cfg.subjectType,
71
+ id: cfg.subjectId,
72
+ };
73
+
74
+ // Track current session ID — updated from hook event context
75
+ let currentSessionId: string | undefined;
76
+
77
+ api.logger.info(
78
+ `memory-graphiti: registered (graphiti: ${cfg.graphiti.endpoint}, spicedb: ${cfg.spicedb.endpoint})`,
79
+ );
80
+
81
+ // ========================================================================
82
+ // Tools
83
+ // ========================================================================
84
+
85
+ api.registerTool(
86
+ {
87
+ name: "memory_recall",
88
+ label: "Memory Recall",
89
+ description:
90
+ "Search through memories using the knowledge graph. Returns entities and facts the current user is authorized to see. Supports session, long-term, or combined scope.",
91
+ parameters: Type.Object({
92
+ query: Type.String({ description: "Search query" }),
93
+ limit: Type.Optional(Type.Number({ description: "Max results (default: 10)" })),
94
+ scope: Type.Optional(
95
+ Type.Union(
96
+ [Type.Literal("session"), Type.Literal("long-term"), Type.Literal("all")],
97
+ { description: "Memory scope: 'session' (current session only), 'long-term' (persistent), or 'all' (both). Default: 'all'" },
98
+ ),
99
+ ),
100
+ }),
101
+ async execute(_toolCallId, params) {
102
+ const { query, limit = 10, scope = "all" } = params as {
103
+ query: string;
104
+ limit?: number;
105
+ scope?: "session" | "long-term" | "all";
106
+ };
107
+
108
+ // 1. Get authorized groups for current subject
109
+ const authorizedGroups = await lookupAuthorizedGroups(spicedb, currentSubject);
110
+
111
+ if (authorizedGroups.length === 0) {
112
+ return {
113
+ content: [{ type: "text", text: "No accessible memory groups found." }],
114
+ details: { count: 0, authorizedGroups: [] },
115
+ };
116
+ }
117
+
118
+ // 2. Filter groups by scope
119
+ let longTermGroups: string[];
120
+ let sessionGroups: string[];
121
+
122
+ if (scope === "session") {
123
+ longTermGroups = [];
124
+ sessionGroups = authorizedGroups.filter(isSessionGroup);
125
+ // Also include current session if not in authorized groups
126
+ if (currentSessionId) {
127
+ const sg = sessionGroupId(currentSessionId);
128
+ if (!sessionGroups.includes(sg)) {
129
+ sessionGroups.push(sg);
130
+ }
131
+ }
132
+ } else if (scope === "long-term") {
133
+ longTermGroups = authorizedGroups.filter((g) => !isSessionGroup(g));
134
+ sessionGroups = [];
135
+ } else {
136
+ // "all"
137
+ longTermGroups = authorizedGroups.filter((g) => !isSessionGroup(g));
138
+ sessionGroups = authorizedGroups.filter(isSessionGroup);
139
+ if (currentSessionId) {
140
+ const sg = sessionGroupId(currentSessionId);
141
+ if (!sessionGroups.includes(sg)) {
142
+ sessionGroups.push(sg);
143
+ }
144
+ }
145
+ }
146
+
147
+ // 3. Parallel search across groups
148
+ const [longTermResults, rawSessionResults] = await Promise.all([
149
+ longTermGroups.length > 0
150
+ ? searchAuthorizedMemories(graphiti, { query, groupIds: longTermGroups, limit })
151
+ : Promise.resolve([]),
152
+ sessionGroups.length > 0
153
+ ? searchAuthorizedMemories(graphiti, { query, groupIds: sessionGroups, limit })
154
+ : Promise.resolve([]),
155
+ ]);
156
+
157
+ // 4. Deduplicate session results against long-term
158
+ const sessionResults = deduplicateSessionResults(longTermResults, rawSessionResults);
159
+
160
+ const totalCount = longTermResults.length + sessionResults.length;
161
+ if (totalCount === 0) {
162
+ return {
163
+ content: [{ type: "text", text: "No relevant memories found." }],
164
+ details: { count: 0, authorizedGroups },
165
+ };
166
+ }
167
+
168
+ // 5. Format results with section separation
169
+ const text = formatDualResults(longTermResults, sessionResults);
170
+ const allResults = [...longTermResults, ...sessionResults];
171
+ const sanitized = allResults.map((r) => ({
172
+ type: r.type,
173
+ uuid: r.uuid,
174
+ group_id: r.group_id,
175
+ summary: r.summary,
176
+ context: r.context,
177
+ }));
178
+
179
+ return {
180
+ content: [{ type: "text", text: `Found ${totalCount} memories:\n\n${text}` }],
181
+ details: {
182
+ count: totalCount,
183
+ memories: sanitized,
184
+ authorizedGroups,
185
+ longTermCount: longTermResults.length,
186
+ sessionCount: sessionResults.length,
187
+ },
188
+ };
189
+ },
190
+ },
191
+ { name: "memory_recall" },
192
+ );
193
+
194
+ api.registerTool(
195
+ {
196
+ name: "memory_store",
197
+ label: "Memory Store",
198
+ description:
199
+ "Save information to the knowledge graph with authorization tracking. Stores episodes that are automatically broken into entities and facts. Use longTerm=false to store session-scoped memories.",
200
+ parameters: Type.Object({
201
+ content: Type.String({ description: "Information to remember" }),
202
+ source_description: Type.Optional(
203
+ Type.String({ description: "Context about the source (e.g., 'conversation with Mark')" }),
204
+ ),
205
+ involves: Type.Optional(
206
+ Type.Array(Type.String(), { description: "Person/agent IDs involved in this memory" }),
207
+ ),
208
+ group_id: Type.Optional(
209
+ Type.String({ description: "Target group for this memory (default: configured group)" }),
210
+ ),
211
+ longTerm: Type.Optional(
212
+ Type.Boolean({ description: "Store as long-term memory (default: true). Set to false for session-scoped." }),
213
+ ),
214
+ }),
215
+ async execute(_toolCallId, params) {
216
+ const {
217
+ content,
218
+ source_description = "conversation",
219
+ involves = [],
220
+ group_id,
221
+ longTerm = true,
222
+ } = params as {
223
+ content: string;
224
+ source_description?: string;
225
+ involves?: string[];
226
+ group_id?: string;
227
+ longTerm?: boolean;
228
+ };
229
+
230
+ // Resolve target group: explicit > longTerm flag > default
231
+ let targetGroupId: string;
232
+ if (group_id) {
233
+ targetGroupId = group_id;
234
+ } else if (!longTerm && currentSessionId) {
235
+ targetGroupId = sessionGroupId(currentSessionId);
236
+ } else {
237
+ targetGroupId = cfg.graphiti.defaultGroupId;
238
+ }
239
+
240
+ // Only auto-create membership for the agent's OWN current session.
241
+ // Foreign session groups (belonging to other agents) require explicit
242
+ // membership — prevents cross-agent session memory injection.
243
+ const isOwnSession =
244
+ isSessionGroup(targetGroupId) &&
245
+ currentSessionId != null &&
246
+ targetGroupId === sessionGroupId(currentSessionId);
247
+
248
+ if (isOwnSession) {
249
+ try {
250
+ await ensureGroupMembership(spicedb, targetGroupId, currentSubject);
251
+ } catch {
252
+ api.logger.warn(`memory-graphiti: failed to ensure membership in ${targetGroupId}`);
253
+ }
254
+ } else {
255
+ // All other groups (non-session AND foreign session) require write permission
256
+ const allowed = await canWriteToGroup(spicedb, currentSubject, targetGroupId);
257
+ if (!allowed) {
258
+ return {
259
+ content: [
260
+ {
261
+ type: "text",
262
+ text: `Permission denied: cannot write to group "${targetGroupId}"`,
263
+ },
264
+ ],
265
+ details: { action: "denied", groupId: targetGroupId },
266
+ };
267
+ }
268
+ }
269
+
270
+ // 1. Add episode to Graphiti
271
+ const result = await graphiti.addEpisode({
272
+ name: `memory_${Date.now()}`,
273
+ episode_body: content,
274
+ source_description,
275
+ group_id: targetGroupId,
276
+ custom_extraction_instructions: cfg.customInstructions,
277
+ });
278
+
279
+ const fragmentId = result.episode_uuid;
280
+
281
+ // 2. Write authorization relationships in SpiceDB
282
+ const involvedSubjects: Subject[] = involves.map((id) => ({
283
+ type: "person" as const,
284
+ id,
285
+ }));
286
+
287
+ await writeFragmentRelationships(spicedb, {
288
+ fragmentId,
289
+ groupId: targetGroupId,
290
+ sharedBy: currentSubject,
291
+ involves: involvedSubjects,
292
+ });
293
+
294
+ return {
295
+ content: [
296
+ {
297
+ type: "text",
298
+ text: `Stored memory in group "${targetGroupId}": "${content.slice(0, 100)}..."`,
299
+ },
300
+ ],
301
+ details: {
302
+ action: "created",
303
+ episodeId: fragmentId,
304
+ groupId: targetGroupId,
305
+ longTerm,
306
+ involves,
307
+ },
308
+ };
309
+ },
310
+ },
311
+ { name: "memory_store" },
312
+ );
313
+
314
+ api.registerTool(
315
+ {
316
+ name: "memory_forget",
317
+ label: "Memory Forget",
318
+ description: "Delete a memory episode. Requires delete permission.",
319
+ parameters: Type.Object({
320
+ episode_id: Type.String({ description: "Episode UUID to delete" }),
321
+ }),
322
+ async execute(_toolCallId, params) {
323
+ const { episode_id } = params as { episode_id: string };
324
+
325
+ // 1. Check delete permission
326
+ const allowed = await canDeleteFragment(spicedb, currentSubject, episode_id);
327
+ if (!allowed) {
328
+ 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 },
336
+ };
337
+ }
338
+
339
+ // 2. Delete from Graphiti
340
+ await graphiti.deleteEpisode(episode_id);
341
+
342
+ // 3. Clean up SpiceDB relationships (best-effort)
343
+ try {
344
+ await deleteFragmentRelationships(spicedb, episode_id, {
345
+ fragmentId: episode_id,
346
+ groupId: cfg.graphiti.defaultGroupId,
347
+ sharedBy: currentSubject,
348
+ });
349
+ } catch {
350
+ api.logger.warn(
351
+ `memory-graphiti: failed to clean up SpiceDB relationships for ${episode_id}`,
352
+ );
353
+ }
354
+
355
+ return {
356
+ content: [{ type: "text", text: `Memory ${episode_id} forgotten.` }],
357
+ details: { action: "deleted", episodeId: episode_id },
358
+ };
359
+ },
360
+ },
361
+ { name: "memory_forget" },
362
+ );
363
+
364
+ api.registerTool(
365
+ {
366
+ name: "memory_status",
367
+ label: "Memory Status",
368
+ description: "Check the health of the Graphiti and SpiceDB services.",
369
+ parameters: Type.Object({}),
370
+ async execute() {
371
+ const graphitiHealthy = await graphiti.healthCheck();
372
+
373
+ let spicedbHealthy = false;
374
+ try {
375
+ await spicedb.readSchema();
376
+ spicedbHealthy = true;
377
+ } catch {
378
+ // SpiceDB unreachable
379
+ }
380
+
381
+ const status = {
382
+ graphiti: graphitiHealthy ? "connected" : "unreachable",
383
+ spicedb: spicedbHealthy ? "connected" : "unreachable",
384
+ endpoint_graphiti: cfg.graphiti.endpoint,
385
+ endpoint_spicedb: cfg.spicedb.endpoint,
386
+ currentSessionId: currentSessionId ?? "none",
387
+ };
388
+
389
+ const statusText = [
390
+ `Graphiti MCP: ${status.graphiti} (${status.endpoint_graphiti})`,
391
+ `SpiceDB: ${status.spicedb} (${status.endpoint_spicedb})`,
392
+ `Session: ${status.currentSessionId}`,
393
+ ].join("\n");
394
+
395
+ return {
396
+ content: [{ type: "text", text: statusText }],
397
+ details: status,
398
+ };
399
+ },
400
+ },
401
+ { name: "memory_status" },
402
+ );
403
+
404
+ // ========================================================================
405
+ // CLI Commands
406
+ // ========================================================================
407
+
408
+ api.registerCli(
409
+ ({ program }) => {
410
+ const mem = program
411
+ .command("graphiti-mem")
412
+ .description("Graphiti + SpiceDB memory plugin commands");
413
+
414
+ mem
415
+ .command("search")
416
+ .description("Search memories with authorization")
417
+ .argument("<query>", "Search query")
418
+ .option("--limit <n>", "Max results", "10")
419
+ .option("--scope <scope>", "Memory scope: session, long-term, all", "all")
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
+ });
705
+ },
706
+ { commands: ["graphiti-mem"] },
707
+ );
708
+
709
+ // ========================================================================
710
+ // Lifecycle Hooks
711
+ // ========================================================================
712
+
713
+ if (cfg.autoRecall) {
714
+ api.on("before_agent_start", async (event) => {
715
+ // Track session ID from event context
716
+ if (event.ctx?.sessionKey) {
717
+ currentSessionId = event.ctx.sessionKey as string;
718
+ }
719
+
720
+ if (!event.prompt || event.prompt.length < 5) {
721
+ return;
722
+ }
723
+
724
+ try {
725
+ const authorizedGroups = await lookupAuthorizedGroups(spicedb, currentSubject);
726
+ if (authorizedGroups.length === 0) {
727
+ return;
728
+ }
729
+
730
+ // Separate long-term and session groups
731
+ const longTermGroups = authorizedGroups.filter((g) => !isSessionGroup(g));
732
+ const sessionGroups = authorizedGroups.filter(isSessionGroup);
733
+
734
+ // Include current session group if known
735
+ if (currentSessionId) {
736
+ const sg = sessionGroupId(currentSessionId);
737
+ if (!sessionGroups.includes(sg)) {
738
+ sessionGroups.push(sg);
739
+ }
740
+ }
741
+
742
+ // Dual search: long-term + session in parallel
743
+ const [longTermResults, rawSessionResults] = await Promise.all([
744
+ longTermGroups.length > 0
745
+ ? searchAuthorizedMemories(graphiti, {
746
+ query: event.prompt,
747
+ groupIds: longTermGroups,
748
+ limit: 5,
749
+ })
750
+ : Promise.resolve([]),
751
+ sessionGroups.length > 0
752
+ ? searchAuthorizedMemories(graphiti, {
753
+ query: event.prompt,
754
+ groupIds: sessionGroups,
755
+ limit: 3,
756
+ })
757
+ : Promise.resolve([]),
758
+ ]);
759
+
760
+ const sessionResults = deduplicateSessionResults(longTermResults, rawSessionResults);
761
+
762
+ const totalCount = longTermResults.length + sessionResults.length;
763
+
764
+ const toolHint =
765
+ "<memory-tools>\n" +
766
+ "You have knowledge-graph memory tools. Use them proactively:\n" +
767
+ "- memory_recall: Search for facts, preferences, people, decisions, or past context. Use this BEFORE saying you don't know or remember something.\n" +
768
+ "- memory_store: Save important new information (preferences, decisions, facts about people).\n" +
769
+ "</memory-tools>";
770
+
771
+ if (totalCount === 0) {
772
+ return { prependContext: toolHint };
773
+ }
774
+
775
+ const memoryContext = formatDualResults(longTermResults, sessionResults);
776
+ api.logger.info?.(
777
+ `memory-graphiti: injecting ${totalCount} memories (${longTermResults.length} long-term, ${sessionResults.length} session)`,
778
+ );
779
+
780
+ return {
781
+ prependContext: `${toolHint}\n\n<relevant-memories>\nThe following memories from the knowledge graph may be relevant:\n${memoryContext}\n</relevant-memories>`,
782
+ };
783
+ } catch (err) {
784
+ api.logger.warn(`memory-graphiti: recall failed: ${String(err)}`);
785
+ }
786
+ });
787
+ }
788
+
789
+ if (cfg.autoCapture) {
790
+ api.on("agent_end", async (event) => {
791
+ // Track session ID from event context
792
+ if (event.ctx?.sessionKey) {
793
+ currentSessionId = event.ctx.sessionKey as string;
794
+ }
795
+
796
+ if (!event.success || !event.messages || event.messages.length === 0) {
797
+ return;
798
+ }
799
+
800
+ try {
801
+ // Collect last N messages (user + assistant only), skip injected context
802
+ const maxMessages = cfg.maxCaptureMessages;
803
+ const conversationLines: string[] = [];
804
+ let messageCount = 0;
805
+
806
+ // Process messages in reverse to get the most recent ones
807
+ const messages = [...event.messages].reverse();
808
+
809
+ for (const msg of messages) {
810
+ if (messageCount >= maxMessages) break;
811
+ if (!msg || typeof msg !== "object") continue;
812
+
813
+ const msgObj = msg as Record<string, unknown>;
814
+ const role = msgObj.role;
815
+ if (role !== "user" && role !== "assistant") continue;
816
+
817
+ // Extract text content
818
+ let text = "";
819
+ const content = msgObj.content;
820
+ if (typeof content === "string") {
821
+ text = content;
822
+ } else if (Array.isArray(content)) {
823
+ const textParts: string[] = [];
824
+ for (const block of content) {
825
+ if (
826
+ block &&
827
+ typeof block === "object" &&
828
+ "type" in block &&
829
+ (block as Record<string, unknown>).type === "text" &&
830
+ "text" in block &&
831
+ typeof (block as Record<string, unknown>).text === "string"
832
+ ) {
833
+ textParts.push((block as Record<string, unknown>).text as string);
834
+ }
835
+ }
836
+ text = textParts.join("\n");
837
+ }
838
+
839
+ // Skip injected context and very short messages
840
+ if (!text || text.length < 5) continue;
841
+ if (text.includes("<relevant-memories>")) continue;
842
+
843
+ const roleLabel = role === "user" ? "User" : "Assistant";
844
+ conversationLines.unshift(`${roleLabel}: ${text}`);
845
+ messageCount++;
846
+ }
847
+
848
+ if (conversationLines.length === 0) return;
849
+
850
+ // Send as a single batch episode to Graphiti
851
+ const episodeBody = conversationLines.join("\n");
852
+
853
+ // Store to session group by default (if session is known), otherwise default group
854
+ const targetGroupId = currentSessionId
855
+ ? sessionGroupId(currentSessionId)
856
+ : cfg.graphiti.defaultGroupId;
857
+
858
+ // Only auto-create membership for the agent's own current session
859
+ const isOwnSession =
860
+ isSessionGroup(targetGroupId) &&
861
+ currentSessionId != null &&
862
+ targetGroupId === sessionGroupId(currentSessionId);
863
+
864
+ if (isOwnSession) {
865
+ try {
866
+ await ensureGroupMembership(spicedb, targetGroupId, currentSubject);
867
+ } catch {
868
+ // Best-effort
869
+ }
870
+ } else {
871
+ const allowed = await canWriteToGroup(spicedb, currentSubject, targetGroupId);
872
+ if (!allowed) {
873
+ api.logger.warn(`memory-graphiti: auto-capture denied for group ${targetGroupId}`);
874
+ return;
875
+ }
876
+ }
877
+
878
+ const result = await graphiti.addEpisode({
879
+ name: `auto_capture_${Date.now()}`,
880
+ episode_body: episodeBody,
881
+ source_description: "auto-captured conversation",
882
+ group_id: targetGroupId,
883
+ custom_extraction_instructions: cfg.customInstructions,
884
+ });
885
+
886
+ await writeFragmentRelationships(spicedb, {
887
+ fragmentId: result.episode_uuid,
888
+ groupId: targetGroupId,
889
+ sharedBy: currentSubject,
890
+ });
891
+
892
+ api.logger.info(
893
+ `memory-graphiti: auto-captured ${conversationLines.length} messages as batch episode to ${targetGroupId}`,
894
+ );
895
+ } catch (err) {
896
+ api.logger.warn(`memory-graphiti: capture failed: ${String(err)}`);
897
+ }
898
+ });
899
+ }
900
+
901
+ // ========================================================================
902
+ // Service
903
+ // ========================================================================
904
+
905
+ api.registerService({
906
+ id: "memory-graphiti",
907
+ async start() {
908
+ // Verify connectivity on startup
909
+ const graphitiOk = await graphiti.healthCheck();
910
+ let spicedbOk = false;
911
+ try {
912
+ await spicedb.readSchema();
913
+ spicedbOk = true;
914
+ } catch {
915
+ // Will be retried on first use
916
+ }
917
+
918
+ // Ensure current subject is a member of the default group
919
+ if (spicedbOk) {
920
+ try {
921
+ await ensureGroupMembership(
922
+ spicedb,
923
+ cfg.graphiti.defaultGroupId,
924
+ currentSubject,
925
+ );
926
+ } catch {
927
+ api.logger.warn("memory-graphiti: failed to ensure default group membership");
928
+ }
929
+ }
930
+
931
+ api.logger.info(
932
+ `memory-graphiti: initialized (graphiti: ${graphitiOk ? "OK" : "UNREACHABLE"}, spicedb: ${spicedbOk ? "OK" : "UNREACHABLE"})`,
933
+ );
934
+ },
935
+ stop() {
936
+ api.logger.info("memory-graphiti: stopped");
937
+ },
938
+ });
939
+ },
940
+ };
941
+
942
+ export default memoryGraphitiPlugin;