@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/README.md +424 -0
- package/authorization.ts +202 -0
- package/config.ts +111 -0
- package/docker/.env.example +66 -0
- package/docker/docker-compose.yml +151 -0
- package/graphiti.ts +382 -0
- package/index.ts +942 -0
- package/openclaw.plugin.json +91 -0
- package/package.json +51 -0
- package/schema.zed +23 -0
- package/scripts/dev-setup.sh +146 -0
- package/scripts/dev-start.sh +236 -0
- package/scripts/dev-status.sh +57 -0
- package/scripts/dev-stop.sh +47 -0
- package/search.ts +201 -0
- package/spicedb.ts +174 -0
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;
|