@contextableai/openclaw-memory-rebac 0.1.0 → 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/dist/authorization.d.ts +57 -0
- package/dist/authorization.js +133 -0
- package/dist/backend.d.ts +135 -0
- package/dist/backend.js +11 -0
- package/dist/backends/graphiti.d.ts +72 -0
- package/dist/backends/graphiti.js +222 -0
- package/dist/backends/registry.d.ts +14 -0
- package/dist/backends/registry.js +12 -0
- package/dist/cli.d.ts +23 -0
- package/dist/cli.js +446 -0
- package/dist/config.d.ts +34 -0
- package/dist/config.js +97 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +638 -0
- package/dist/plugin.defaults.json +12 -0
- package/dist/search.d.ts +34 -0
- package/dist/search.js +98 -0
- package/dist/spicedb.d.ts +80 -0
- package/dist/spicedb.js +256 -0
- package/docker/graphiti/.env +50 -0
- package/docker/graphiti/docker-compose.yml +2 -2
- package/docker/graphiti/graphiti_overlay.py +26 -1
- package/docker/graphiti/startup.py +58 -4
- package/docker/spicedb/.env +14 -0
- package/package.json +8 -11
- package/authorization.ts +0 -191
- package/backend.ts +0 -176
- package/backends/backends.json +0 -3
- package/backends/graphiti.test.ts +0 -292
- package/backends/graphiti.ts +0 -345
- package/backends/registry.ts +0 -36
- package/cli.ts +0 -418
- package/config.ts +0 -141
- package/index.ts +0 -711
- package/search.ts +0 -139
- package/spicedb.ts +0 -355
- /package/{backends → dist/backends}/graphiti.defaults.json +0 -0
package/index.ts
DELETED
|
@@ -1,711 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* OpenClaw Memory (ReBAC) Plugin
|
|
3
|
-
*
|
|
4
|
-
* Two-layer memory architecture:
|
|
5
|
-
* - SpiceDB: authorization gateway (who can see what)
|
|
6
|
-
* - MemoryBackend: pluggable storage engine (Graphiti)
|
|
7
|
-
*
|
|
8
|
-
* SpiceDB determines which memories a subject can access.
|
|
9
|
-
* The backend stores the actual knowledge and handles search.
|
|
10
|
-
* Authorization is enforced at the data layer, not in prompts.
|
|
11
|
-
*
|
|
12
|
-
* Backend currently uses Graphiti for knowledge graph storage.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
16
|
-
import { Type } from "@sinclair/typebox";
|
|
17
|
-
import { randomUUID } from "node:crypto";
|
|
18
|
-
import { readFileSync } from "node:fs";
|
|
19
|
-
import { join, dirname } from "node:path";
|
|
20
|
-
import { fileURLToPath } from "node:url";
|
|
21
|
-
|
|
22
|
-
import { rebacMemoryConfigSchema, createBackend, defaultGroupId } from "./config.js";
|
|
23
|
-
import { initRegistry } from "./backends/registry.js";
|
|
24
|
-
import { SpiceDbClient } from "./spicedb.js";
|
|
25
|
-
import {
|
|
26
|
-
lookupAuthorizedGroups,
|
|
27
|
-
writeFragmentRelationships,
|
|
28
|
-
deleteFragmentRelationships,
|
|
29
|
-
canDeleteFragment,
|
|
30
|
-
canWriteToGroup,
|
|
31
|
-
ensureGroupMembership,
|
|
32
|
-
type Subject,
|
|
33
|
-
} from "./authorization.js";
|
|
34
|
-
import {
|
|
35
|
-
searchAuthorizedMemories,
|
|
36
|
-
formatDualResults,
|
|
37
|
-
deduplicateSessionResults,
|
|
38
|
-
} from "./search.js";
|
|
39
|
-
import { registerCommands } from "./cli.js";
|
|
40
|
-
|
|
41
|
-
// ============================================================================
|
|
42
|
-
// Session helpers
|
|
43
|
-
// ============================================================================
|
|
44
|
-
|
|
45
|
-
function sessionGroupId(sessionId: string): string {
|
|
46
|
-
const sanitized = sessionId.replace(/[^a-zA-Z0-9_-]/g, "-");
|
|
47
|
-
return `session-${sanitized}`;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function isSessionGroup(groupId: string): boolean {
|
|
51
|
-
return groupId.startsWith("session-");
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// ============================================================================
|
|
55
|
-
// Plugin Definition
|
|
56
|
-
// ============================================================================
|
|
57
|
-
|
|
58
|
-
const rebacMemoryPlugin = {
|
|
59
|
-
id: "openclaw-memory-rebac",
|
|
60
|
-
name: "Memory (ReBAC)",
|
|
61
|
-
description: "Two-layer memory: SpiceDB authorization + Graphiti knowledge graph",
|
|
62
|
-
kind: "memory" as const,
|
|
63
|
-
configSchema: rebacMemoryConfigSchema,
|
|
64
|
-
|
|
65
|
-
async register(api: OpenClawPluginApi) {
|
|
66
|
-
await initRegistry();
|
|
67
|
-
const cfg = rebacMemoryConfigSchema.parse(api.pluginConfig);
|
|
68
|
-
|
|
69
|
-
if (!cfg.spicedb.token) {
|
|
70
|
-
throw new Error(
|
|
71
|
-
'openclaw-memory-rebac: spicedb.token is not configured. Add a "config" block to ' +
|
|
72
|
-
"plugins.entries.openclaw-memory-rebac in ~/.openclaw/openclaw.json:\n" +
|
|
73
|
-
' "config": { "spicedb": { "token": "<your-preshared-key>", "insecure": true } }',
|
|
74
|
-
);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
const backend = createBackend(cfg);
|
|
78
|
-
const spicedb = new SpiceDbClient(cfg.spicedb);
|
|
79
|
-
const backendDefaultGroupId = defaultGroupId(cfg);
|
|
80
|
-
|
|
81
|
-
// Suppress transient gRPC rejections from @grpc/grpc-js during connection setup
|
|
82
|
-
const grpcRejectionHandler = (reason: unknown) => {
|
|
83
|
-
const msg = String(reason);
|
|
84
|
-
if (msg.includes("generateMetadata") || msg.includes("grpc")) {
|
|
85
|
-
api.logger.warn(`openclaw-memory-rebac: suppressed grpc-js rejection: ${msg}`);
|
|
86
|
-
} else {
|
|
87
|
-
throw reason;
|
|
88
|
-
}
|
|
89
|
-
};
|
|
90
|
-
process.on("unhandledRejection", grpcRejectionHandler);
|
|
91
|
-
const grpcGuardTimer = setTimeout(() => {
|
|
92
|
-
process.removeListener("unhandledRejection", grpcRejectionHandler);
|
|
93
|
-
}, 10_000);
|
|
94
|
-
grpcGuardTimer.unref();
|
|
95
|
-
|
|
96
|
-
const currentSubject: Subject = { type: cfg.subjectType, id: cfg.subjectId };
|
|
97
|
-
|
|
98
|
-
let currentSessionId: string | undefined;
|
|
99
|
-
let lastWriteToken: string | undefined;
|
|
100
|
-
|
|
101
|
-
api.logger.info(
|
|
102
|
-
`openclaw-memory-rebac: registered (backend: ${backend.name}, spicedb: ${cfg.spicedb.endpoint})`,
|
|
103
|
-
);
|
|
104
|
-
|
|
105
|
-
// ========================================================================
|
|
106
|
-
// Tools
|
|
107
|
-
// ========================================================================
|
|
108
|
-
|
|
109
|
-
api.registerTool(
|
|
110
|
-
{
|
|
111
|
-
name: "memory_recall",
|
|
112
|
-
label: "Memory Recall",
|
|
113
|
-
description:
|
|
114
|
-
"Search through memories using the knowledge graph. Returns results the current user is authorized to see. Supports session, long-term, or combined scope. REQUIRES a search query.",
|
|
115
|
-
parameters: Type.Object({
|
|
116
|
-
query: Type.String({ description: "REQUIRED: Search query for semantic matching" }),
|
|
117
|
-
limit: Type.Optional(Type.Number({ description: "Max results (default: 10)" })),
|
|
118
|
-
scope: Type.Optional(
|
|
119
|
-
Type.Union(
|
|
120
|
-
[Type.Literal("session"), Type.Literal("long-term"), Type.Literal("all")],
|
|
121
|
-
{ description: "Memory scope: 'session', 'long-term', or 'all' (default)" },
|
|
122
|
-
),
|
|
123
|
-
),
|
|
124
|
-
}),
|
|
125
|
-
async execute(_toolCallId, params) {
|
|
126
|
-
const { query, limit = 10, scope = "all" } = params as {
|
|
127
|
-
query: string;
|
|
128
|
-
limit?: number;
|
|
129
|
-
scope?: "session" | "long-term" | "all";
|
|
130
|
-
};
|
|
131
|
-
|
|
132
|
-
const authorizedGroups = await lookupAuthorizedGroups(spicedb, currentSubject, lastWriteToken);
|
|
133
|
-
|
|
134
|
-
if (authorizedGroups.length === 0) {
|
|
135
|
-
return {
|
|
136
|
-
content: [{ type: "text", text: "No accessible memory groups found." }],
|
|
137
|
-
details: { count: 0, authorizedGroups: [] },
|
|
138
|
-
};
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
let longTermGroups: string[];
|
|
142
|
-
let sessionGroups: string[];
|
|
143
|
-
|
|
144
|
-
if (scope === "session") {
|
|
145
|
-
longTermGroups = [];
|
|
146
|
-
sessionGroups = authorizedGroups.filter(isSessionGroup);
|
|
147
|
-
if (currentSessionId) {
|
|
148
|
-
const sg = sessionGroupId(currentSessionId);
|
|
149
|
-
if (!sessionGroups.includes(sg)) sessionGroups.push(sg);
|
|
150
|
-
}
|
|
151
|
-
} else if (scope === "long-term") {
|
|
152
|
-
longTermGroups = authorizedGroups.filter((g) => !isSessionGroup(g));
|
|
153
|
-
sessionGroups = [];
|
|
154
|
-
} else {
|
|
155
|
-
longTermGroups = authorizedGroups.filter((g) => !isSessionGroup(g));
|
|
156
|
-
sessionGroups = authorizedGroups.filter(isSessionGroup);
|
|
157
|
-
if (currentSessionId) {
|
|
158
|
-
const sg = sessionGroupId(currentSessionId);
|
|
159
|
-
if (!sessionGroups.includes(sg)) sessionGroups.push(sg);
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
const [longTermResults, rawSessionResults] = await Promise.all([
|
|
164
|
-
longTermGroups.length > 0
|
|
165
|
-
? searchAuthorizedMemories(backend, {
|
|
166
|
-
query,
|
|
167
|
-
groupIds: longTermGroups,
|
|
168
|
-
limit,
|
|
169
|
-
sessionId: currentSessionId,
|
|
170
|
-
})
|
|
171
|
-
: Promise.resolve([]),
|
|
172
|
-
sessionGroups.length > 0
|
|
173
|
-
? searchAuthorizedMemories(backend, {
|
|
174
|
-
query,
|
|
175
|
-
groupIds: sessionGroups,
|
|
176
|
-
limit,
|
|
177
|
-
sessionId: currentSessionId,
|
|
178
|
-
})
|
|
179
|
-
: Promise.resolve([]),
|
|
180
|
-
]);
|
|
181
|
-
|
|
182
|
-
const sessionResults = deduplicateSessionResults(longTermResults, rawSessionResults);
|
|
183
|
-
const totalCount = longTermResults.length + sessionResults.length;
|
|
184
|
-
|
|
185
|
-
if (totalCount === 0) {
|
|
186
|
-
return {
|
|
187
|
-
content: [{ type: "text", text: "No relevant memories found." }],
|
|
188
|
-
details: { count: 0, authorizedGroups },
|
|
189
|
-
};
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
const text = formatDualResults(longTermResults, sessionResults);
|
|
193
|
-
const allResults = [...longTermResults, ...sessionResults];
|
|
194
|
-
const sanitized = allResults.map((r) => ({
|
|
195
|
-
type: r.type,
|
|
196
|
-
uuid: r.uuid,
|
|
197
|
-
group_id: r.group_id,
|
|
198
|
-
summary: r.summary,
|
|
199
|
-
context: r.context,
|
|
200
|
-
}));
|
|
201
|
-
|
|
202
|
-
return {
|
|
203
|
-
content: [{ type: "text", text: `Found ${totalCount} memories:\n\n${text}` }],
|
|
204
|
-
details: {
|
|
205
|
-
count: totalCount,
|
|
206
|
-
memories: sanitized,
|
|
207
|
-
authorizedGroups,
|
|
208
|
-
longTermCount: longTermResults.length,
|
|
209
|
-
sessionCount: sessionResults.length,
|
|
210
|
-
},
|
|
211
|
-
};
|
|
212
|
-
},
|
|
213
|
-
},
|
|
214
|
-
{ name: "memory_recall" },
|
|
215
|
-
);
|
|
216
|
-
|
|
217
|
-
api.registerTool(
|
|
218
|
-
{
|
|
219
|
-
name: "memory_store",
|
|
220
|
-
label: "Memory Store",
|
|
221
|
-
description:
|
|
222
|
-
"Save information to the knowledge graph with authorization tracking. Use longTerm=false to store session-scoped memories.",
|
|
223
|
-
parameters: Type.Object({
|
|
224
|
-
content: Type.String({ description: "Information to remember" }),
|
|
225
|
-
source_description: Type.Optional(
|
|
226
|
-
Type.String({ description: "Context about the source (e.g., 'conversation with Mark')" }),
|
|
227
|
-
),
|
|
228
|
-
involves: Type.Optional(
|
|
229
|
-
Type.Array(Type.String(), { description: "Person/agent IDs involved in this memory" }),
|
|
230
|
-
),
|
|
231
|
-
group_id: Type.Optional(
|
|
232
|
-
Type.String({ description: "Target group ID (uses default group if omitted)" }),
|
|
233
|
-
),
|
|
234
|
-
longTerm: Type.Optional(
|
|
235
|
-
Type.Boolean({ description: "Store as long-term memory (default: true). Set to false for session-scoped." }),
|
|
236
|
-
),
|
|
237
|
-
}),
|
|
238
|
-
async execute(_toolCallId, params) {
|
|
239
|
-
const {
|
|
240
|
-
content,
|
|
241
|
-
source_description = "conversation",
|
|
242
|
-
involves = [],
|
|
243
|
-
group_id,
|
|
244
|
-
longTerm = true,
|
|
245
|
-
} = params as {
|
|
246
|
-
content: string;
|
|
247
|
-
source_description?: string;
|
|
248
|
-
involves?: string[];
|
|
249
|
-
group_id?: string;
|
|
250
|
-
longTerm?: boolean;
|
|
251
|
-
};
|
|
252
|
-
|
|
253
|
-
const sanitizeGroupId = (id?: string): string | undefined => {
|
|
254
|
-
if (!id) return undefined;
|
|
255
|
-
const trimmed = id.trim();
|
|
256
|
-
if (trimmed.includes(" ") || trimmed.toLowerCase().includes("configured")) {
|
|
257
|
-
return undefined;
|
|
258
|
-
}
|
|
259
|
-
return trimmed;
|
|
260
|
-
};
|
|
261
|
-
|
|
262
|
-
let targetGroupId: string;
|
|
263
|
-
const sanitizedGroupId = sanitizeGroupId(group_id);
|
|
264
|
-
if (sanitizedGroupId) {
|
|
265
|
-
targetGroupId = sanitizedGroupId;
|
|
266
|
-
} else if (!longTerm && currentSessionId) {
|
|
267
|
-
targetGroupId = sessionGroupId(currentSessionId);
|
|
268
|
-
} else {
|
|
269
|
-
targetGroupId = backendDefaultGroupId;
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
const isOwnSession =
|
|
273
|
-
isSessionGroup(targetGroupId) &&
|
|
274
|
-
currentSessionId != null &&
|
|
275
|
-
targetGroupId === sessionGroupId(currentSessionId);
|
|
276
|
-
|
|
277
|
-
if (isOwnSession) {
|
|
278
|
-
try {
|
|
279
|
-
const token = await ensureGroupMembership(spicedb, targetGroupId, currentSubject);
|
|
280
|
-
if (token) lastWriteToken = token;
|
|
281
|
-
} catch {
|
|
282
|
-
api.logger.warn(`openclaw-memory-rebac: failed to ensure membership in ${targetGroupId}`);
|
|
283
|
-
}
|
|
284
|
-
} else {
|
|
285
|
-
const allowed = await canWriteToGroup(spicedb, currentSubject, targetGroupId, lastWriteToken);
|
|
286
|
-
if (!allowed) {
|
|
287
|
-
return {
|
|
288
|
-
content: [{ type: "text", text: `Permission denied: cannot write to group "${targetGroupId}"` }],
|
|
289
|
-
details: { action: "denied", groupId: targetGroupId },
|
|
290
|
-
};
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
const involvedSubjects: Subject[] = involves.map((id) => ({ type: "person" as const, id }));
|
|
295
|
-
|
|
296
|
-
const result = await backend.store({
|
|
297
|
-
content,
|
|
298
|
-
groupId: targetGroupId,
|
|
299
|
-
sourceDescription: source_description,
|
|
300
|
-
customPrompt: (cfg.backendConfig["customInstructions"] as string) ?? "",
|
|
301
|
-
});
|
|
302
|
-
|
|
303
|
-
// Chain SpiceDB write to the fragmentId Promise — fires when backend has a stable UUID
|
|
304
|
-
result.fragmentId
|
|
305
|
-
.then(async (fragmentId) => {
|
|
306
|
-
const writeToken = await writeFragmentRelationships(spicedb, {
|
|
307
|
-
fragmentId,
|
|
308
|
-
groupId: targetGroupId,
|
|
309
|
-
sharedBy: currentSubject,
|
|
310
|
-
involves: involvedSubjects,
|
|
311
|
-
});
|
|
312
|
-
if (writeToken) lastWriteToken = writeToken;
|
|
313
|
-
})
|
|
314
|
-
.catch((err) => {
|
|
315
|
-
api.logger.warn(
|
|
316
|
-
`openclaw-memory-rebac: deferred SpiceDB write failed for memory_store: ${err}`,
|
|
317
|
-
);
|
|
318
|
-
});
|
|
319
|
-
|
|
320
|
-
return {
|
|
321
|
-
content: [{ type: "text", text: `Stored memory in group "${targetGroupId}": "${content.slice(0, 100)}..."` }],
|
|
322
|
-
details: {
|
|
323
|
-
action: "created",
|
|
324
|
-
groupId: targetGroupId,
|
|
325
|
-
backend: backend.name,
|
|
326
|
-
longTerm,
|
|
327
|
-
involves,
|
|
328
|
-
},
|
|
329
|
-
};
|
|
330
|
-
},
|
|
331
|
-
},
|
|
332
|
-
{ name: "memory_store" },
|
|
333
|
-
);
|
|
334
|
-
|
|
335
|
-
api.registerTool(
|
|
336
|
-
{
|
|
337
|
-
name: "memory_forget",
|
|
338
|
-
label: "Memory Forget",
|
|
339
|
-
description:
|
|
340
|
-
"Remove a memory fragment by ID. Use type-prefixed IDs from memory_recall (e.g. 'fact:UUID', 'chunk:UUID'). Always de-authorizes from SpiceDB; also deletes from storage if the backend supports individual deletion.",
|
|
341
|
-
parameters: Type.Object({
|
|
342
|
-
id: Type.String({ description: "Memory ID to forget (e.g. 'fact:da8650cb-...' or bare UUID)" }),
|
|
343
|
-
}),
|
|
344
|
-
async execute(_toolCallId, params) {
|
|
345
|
-
const { id } = params as { id: string };
|
|
346
|
-
|
|
347
|
-
// Parse optional type prefix — strip it to get the bare UUID
|
|
348
|
-
const colonIdx = id.indexOf(":");
|
|
349
|
-
let uuid: string;
|
|
350
|
-
if (colonIdx > 0 && colonIdx < 10) {
|
|
351
|
-
const prefix = id.slice(0, colonIdx);
|
|
352
|
-
// "entity" type cannot be deleted this way (graph-backend specific)
|
|
353
|
-
if (prefix === "entity") {
|
|
354
|
-
return {
|
|
355
|
-
content: [{ type: "text", text: "Entities cannot be deleted directly. Delete the facts connected to them instead." }],
|
|
356
|
-
details: { action: "error", id },
|
|
357
|
-
};
|
|
358
|
-
}
|
|
359
|
-
uuid = id.slice(colonIdx + 1);
|
|
360
|
-
} else {
|
|
361
|
-
uuid = id;
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
// Check SpiceDB delete permission
|
|
365
|
-
const allowed = await canDeleteFragment(spicedb, currentSubject, uuid, lastWriteToken);
|
|
366
|
-
if (!allowed) {
|
|
367
|
-
return {
|
|
368
|
-
content: [{ type: "text", text: `Permission denied: cannot delete fragment "${uuid}"` }],
|
|
369
|
-
details: { action: "denied", id },
|
|
370
|
-
};
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
// Attempt backend deletion (optional — not all backends support it)
|
|
374
|
-
if (backend.deleteFragment) {
|
|
375
|
-
try {
|
|
376
|
-
await backend.deleteFragment(uuid);
|
|
377
|
-
} catch (err) {
|
|
378
|
-
api.logger.warn(`openclaw-memory-rebac: backend deletion failed for ${uuid}: ${err}`);
|
|
379
|
-
// Continue to SpiceDB de-authorization even if backend deletion fails
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
// Always de-authorize in SpiceDB
|
|
384
|
-
const writeToken = await deleteFragmentRelationships(spicedb, uuid);
|
|
385
|
-
if (writeToken) lastWriteToken = writeToken;
|
|
386
|
-
|
|
387
|
-
return {
|
|
388
|
-
content: [{ type: "text", text: "Memory forgotten." }],
|
|
389
|
-
details: { action: "deleted", id, uuid },
|
|
390
|
-
};
|
|
391
|
-
},
|
|
392
|
-
},
|
|
393
|
-
{ name: "memory_forget" },
|
|
394
|
-
);
|
|
395
|
-
|
|
396
|
-
api.registerTool(
|
|
397
|
-
{
|
|
398
|
-
name: "memory_status",
|
|
399
|
-
label: "Memory Status",
|
|
400
|
-
description: "Check the health of the memory backend and SpiceDB.",
|
|
401
|
-
parameters: Type.Object({}),
|
|
402
|
-
async execute() {
|
|
403
|
-
const backendStatus = await backend.getStatus();
|
|
404
|
-
|
|
405
|
-
let spicedbOk = false;
|
|
406
|
-
try {
|
|
407
|
-
await spicedb.readSchema();
|
|
408
|
-
spicedbOk = true;
|
|
409
|
-
} catch {
|
|
410
|
-
// SpiceDB unreachable
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
const status = {
|
|
414
|
-
...backendStatus,
|
|
415
|
-
spicedb: spicedbOk ? "connected" : "unreachable",
|
|
416
|
-
endpoint_spicedb: cfg.spicedb.endpoint,
|
|
417
|
-
currentSessionId: currentSessionId ?? "none",
|
|
418
|
-
};
|
|
419
|
-
|
|
420
|
-
const statusText = [
|
|
421
|
-
`Backend (${backend.name}): ${backendStatus.healthy ? "connected" : "unreachable"}`,
|
|
422
|
-
`SpiceDB: ${spicedbOk ? "connected" : "unreachable"} (${cfg.spicedb.endpoint})`,
|
|
423
|
-
`Session: ${currentSessionId ?? "none"}`,
|
|
424
|
-
].join("\n");
|
|
425
|
-
|
|
426
|
-
return {
|
|
427
|
-
content: [{ type: "text", text: statusText }],
|
|
428
|
-
details: status,
|
|
429
|
-
};
|
|
430
|
-
},
|
|
431
|
-
},
|
|
432
|
-
{ name: "memory_status" },
|
|
433
|
-
);
|
|
434
|
-
|
|
435
|
-
// ========================================================================
|
|
436
|
-
// CLI Commands
|
|
437
|
-
// ========================================================================
|
|
438
|
-
|
|
439
|
-
api.registerCli(
|
|
440
|
-
({ program }) => {
|
|
441
|
-
const mem = program
|
|
442
|
-
.command("rebac-mem")
|
|
443
|
-
.description(`ReBAC memory plugin commands (backend: ${backend.name})`);
|
|
444
|
-
|
|
445
|
-
registerCommands(mem, {
|
|
446
|
-
backend,
|
|
447
|
-
spicedb,
|
|
448
|
-
cfg,
|
|
449
|
-
currentSubject,
|
|
450
|
-
getLastWriteToken: () => lastWriteToken,
|
|
451
|
-
});
|
|
452
|
-
},
|
|
453
|
-
{ commands: ["rebac-mem"] },
|
|
454
|
-
);
|
|
455
|
-
|
|
456
|
-
// ========================================================================
|
|
457
|
-
// Lifecycle Hooks
|
|
458
|
-
// ========================================================================
|
|
459
|
-
|
|
460
|
-
if (cfg.autoRecall) {
|
|
461
|
-
api.on("before_agent_start", async (event, ctx) => {
|
|
462
|
-
if (ctx?.sessionKey) currentSessionId = ctx.sessionKey;
|
|
463
|
-
|
|
464
|
-
if (!event.prompt || event.prompt.length < 5) return;
|
|
465
|
-
|
|
466
|
-
try {
|
|
467
|
-
const authorizedGroups = await lookupAuthorizedGroups(spicedb, currentSubject, lastWriteToken);
|
|
468
|
-
if (authorizedGroups.length === 0) return;
|
|
469
|
-
|
|
470
|
-
const longTermGroups = authorizedGroups.filter((g) => !isSessionGroup(g));
|
|
471
|
-
const sessionGroups = authorizedGroups.filter(isSessionGroup);
|
|
472
|
-
if (currentSessionId) {
|
|
473
|
-
const sg = sessionGroupId(currentSessionId);
|
|
474
|
-
if (!sessionGroups.includes(sg)) sessionGroups.push(sg);
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
const [longTermResults, rawSessionResults] = await Promise.all([
|
|
478
|
-
longTermGroups.length > 0
|
|
479
|
-
? searchAuthorizedMemories(backend, {
|
|
480
|
-
query: event.prompt,
|
|
481
|
-
groupIds: longTermGroups,
|
|
482
|
-
limit: 5,
|
|
483
|
-
sessionId: currentSessionId,
|
|
484
|
-
})
|
|
485
|
-
: Promise.resolve([]),
|
|
486
|
-
sessionGroups.length > 0
|
|
487
|
-
? searchAuthorizedMemories(backend, {
|
|
488
|
-
query: event.prompt,
|
|
489
|
-
groupIds: sessionGroups,
|
|
490
|
-
limit: 3,
|
|
491
|
-
sessionId: currentSessionId,
|
|
492
|
-
})
|
|
493
|
-
: Promise.resolve([]),
|
|
494
|
-
]);
|
|
495
|
-
|
|
496
|
-
const sessionResults = deduplicateSessionResults(longTermResults, rawSessionResults);
|
|
497
|
-
const totalCount = longTermResults.length + sessionResults.length;
|
|
498
|
-
|
|
499
|
-
const toolHint =
|
|
500
|
-
"<memory-tools>\n" +
|
|
501
|
-
"You have knowledge-graph memory tools. Use them proactively:\n" +
|
|
502
|
-
"- memory_recall: Search for facts, preferences, people, decisions, or past context. Use this BEFORE saying you don't know or remember something.\n" +
|
|
503
|
-
"- memory_store: Save important new information (preferences, decisions, facts about people).\n" +
|
|
504
|
-
"</memory-tools>";
|
|
505
|
-
|
|
506
|
-
if (totalCount === 0) return { prependContext: toolHint };
|
|
507
|
-
|
|
508
|
-
const memoryContext = formatDualResults(longTermResults, sessionResults);
|
|
509
|
-
api.logger.info?.(
|
|
510
|
-
`openclaw-memory-rebac: injecting ${totalCount} memories (${longTermResults.length} long-term, ${sessionResults.length} session)`,
|
|
511
|
-
);
|
|
512
|
-
|
|
513
|
-
return {
|
|
514
|
-
prependContext: `${toolHint}\n\n<relevant-memories>\nThe following memories may be relevant:\n${memoryContext}\n</relevant-memories>`,
|
|
515
|
-
};
|
|
516
|
-
} catch (err) {
|
|
517
|
-
api.logger.warn(`openclaw-memory-rebac: recall failed: ${String(err)}`);
|
|
518
|
-
}
|
|
519
|
-
});
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
if (cfg.autoCapture) {
|
|
523
|
-
api.on("agent_end", async (event, ctx) => {
|
|
524
|
-
if (ctx?.sessionKey) currentSessionId = ctx.sessionKey;
|
|
525
|
-
|
|
526
|
-
if (!event.success || !event.messages || event.messages.length === 0) return;
|
|
527
|
-
|
|
528
|
-
try {
|
|
529
|
-
const maxMessages = cfg.maxCaptureMessages;
|
|
530
|
-
const conversationLines: string[] = [];
|
|
531
|
-
let messageCount = 0;
|
|
532
|
-
|
|
533
|
-
for (const msg of [...event.messages].reverse()) {
|
|
534
|
-
if (messageCount >= maxMessages) break;
|
|
535
|
-
if (!msg || typeof msg !== "object") continue;
|
|
536
|
-
|
|
537
|
-
const msgObj = msg as Record<string, unknown>;
|
|
538
|
-
const role = msgObj.role;
|
|
539
|
-
if (role !== "user" && role !== "assistant") continue;
|
|
540
|
-
|
|
541
|
-
let text = "";
|
|
542
|
-
const content = msgObj.content;
|
|
543
|
-
if (typeof content === "string") {
|
|
544
|
-
text = content;
|
|
545
|
-
} else if (Array.isArray(content)) {
|
|
546
|
-
const textParts: string[] = [];
|
|
547
|
-
for (const block of content) {
|
|
548
|
-
if (
|
|
549
|
-
block && typeof block === "object" &&
|
|
550
|
-
"type" in block &&
|
|
551
|
-
(block as Record<string, unknown>).type === "text" &&
|
|
552
|
-
"text" in block &&
|
|
553
|
-
typeof (block as Record<string, unknown>).text === "string"
|
|
554
|
-
) {
|
|
555
|
-
textParts.push((block as Record<string, unknown>).text as string);
|
|
556
|
-
}
|
|
557
|
-
}
|
|
558
|
-
text = textParts.join("\n");
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
if (!text || text.length < 5) continue;
|
|
562
|
-
if (text.includes("<relevant-memories>")) continue;
|
|
563
|
-
|
|
564
|
-
const roleLabel = role === "user" ? "User" : "Assistant";
|
|
565
|
-
conversationLines.unshift(`${roleLabel}: ${text}`);
|
|
566
|
-
messageCount++;
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
if (conversationLines.length === 0) return;
|
|
570
|
-
|
|
571
|
-
const episodeBody = conversationLines.join("\n");
|
|
572
|
-
const targetGroupId = currentSessionId
|
|
573
|
-
? sessionGroupId(currentSessionId)
|
|
574
|
-
: backendDefaultGroupId;
|
|
575
|
-
|
|
576
|
-
const isOwnSession =
|
|
577
|
-
isSessionGroup(targetGroupId) &&
|
|
578
|
-
currentSessionId != null &&
|
|
579
|
-
targetGroupId === sessionGroupId(currentSessionId);
|
|
580
|
-
|
|
581
|
-
if (isOwnSession) {
|
|
582
|
-
try {
|
|
583
|
-
const token = await ensureGroupMembership(spicedb, targetGroupId, currentSubject);
|
|
584
|
-
if (token) lastWriteToken = token;
|
|
585
|
-
} catch {
|
|
586
|
-
// best-effort
|
|
587
|
-
}
|
|
588
|
-
} else {
|
|
589
|
-
const allowed = await canWriteToGroup(spicedb, currentSubject, targetGroupId, lastWriteToken);
|
|
590
|
-
if (!allowed) {
|
|
591
|
-
api.logger.warn(`openclaw-memory-rebac: auto-capture denied for group ${targetGroupId}`);
|
|
592
|
-
return;
|
|
593
|
-
}
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
const result = await backend.store({
|
|
597
|
-
content: episodeBody,
|
|
598
|
-
groupId: targetGroupId,
|
|
599
|
-
sourceDescription: "auto-captured conversation",
|
|
600
|
-
customPrompt: (cfg.backendConfig["customInstructions"] as string) ?? "",
|
|
601
|
-
});
|
|
602
|
-
|
|
603
|
-
// Chain SpiceDB write
|
|
604
|
-
result.fragmentId
|
|
605
|
-
.then(async (fragmentId) => {
|
|
606
|
-
const writeToken = await writeFragmentRelationships(spicedb, {
|
|
607
|
-
fragmentId,
|
|
608
|
-
groupId: targetGroupId,
|
|
609
|
-
sharedBy: currentSubject,
|
|
610
|
-
});
|
|
611
|
-
if (writeToken) lastWriteToken = writeToken;
|
|
612
|
-
})
|
|
613
|
-
.catch((err) => {
|
|
614
|
-
api.logger.warn(
|
|
615
|
-
`openclaw-memory-rebac: deferred SpiceDB write (auto-capture) failed: ${err}`,
|
|
616
|
-
);
|
|
617
|
-
});
|
|
618
|
-
|
|
619
|
-
// Backend-specific session enrichment (optional backend feature)
|
|
620
|
-
if (backend.enrichSession && currentSessionId) {
|
|
621
|
-
const lastUserMsg = [...event.messages]
|
|
622
|
-
.reverse()
|
|
623
|
-
.find((m) => (m as Record<string, unknown>).role === "user");
|
|
624
|
-
const lastAssistMsg = [...event.messages]
|
|
625
|
-
.reverse()
|
|
626
|
-
.find((m) => (m as Record<string, unknown>).role === "assistant");
|
|
627
|
-
|
|
628
|
-
const extractText = (m: unknown): string => {
|
|
629
|
-
if (!m || typeof m !== "object") return "";
|
|
630
|
-
const obj = m as Record<string, unknown>;
|
|
631
|
-
if (typeof obj.content === "string") return obj.content;
|
|
632
|
-
if (Array.isArray(obj.content)) {
|
|
633
|
-
return obj.content
|
|
634
|
-
.filter((b: unknown) =>
|
|
635
|
-
typeof b === "object" && b !== null &&
|
|
636
|
-
(b as Record<string, unknown>).type === "text",
|
|
637
|
-
)
|
|
638
|
-
.map((b: unknown) => (b as Record<string, unknown>).text as string)
|
|
639
|
-
.join("\n");
|
|
640
|
-
}
|
|
641
|
-
return "";
|
|
642
|
-
};
|
|
643
|
-
|
|
644
|
-
const userMsg = extractText(lastUserMsg);
|
|
645
|
-
const assistantMsg = extractText(lastAssistMsg);
|
|
646
|
-
|
|
647
|
-
if (userMsg && assistantMsg) {
|
|
648
|
-
backend.enrichSession({
|
|
649
|
-
sessionId: currentSessionId,
|
|
650
|
-
groupId: targetGroupId,
|
|
651
|
-
userMsg,
|
|
652
|
-
assistantMsg,
|
|
653
|
-
}).catch(() => {});
|
|
654
|
-
}
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
api.logger.info(
|
|
658
|
-
`openclaw-memory-rebac: auto-captured ${conversationLines.length} messages to ${targetGroupId}`,
|
|
659
|
-
);
|
|
660
|
-
} catch (err) {
|
|
661
|
-
api.logger.warn(`openclaw-memory-rebac: capture failed: ${String(err)}`);
|
|
662
|
-
}
|
|
663
|
-
});
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
// ========================================================================
|
|
667
|
-
// Service
|
|
668
|
-
// ========================================================================
|
|
669
|
-
|
|
670
|
-
api.registerService({
|
|
671
|
-
id: "openclaw-memory-rebac",
|
|
672
|
-
async start() {
|
|
673
|
-
const backendStatus = await backend.getStatus();
|
|
674
|
-
let spicedbOk = false;
|
|
675
|
-
try {
|
|
676
|
-
const existing = await spicedb.readSchema();
|
|
677
|
-
spicedbOk = true;
|
|
678
|
-
if (!existing || !existing.includes("memory_fragment")) {
|
|
679
|
-
api.logger.info("openclaw-memory-rebac: writing SpiceDB schema (first run)");
|
|
680
|
-
const schemaPath = join(dirname(fileURLToPath(import.meta.url)), "schema.zed");
|
|
681
|
-
const schema = readFileSync(schemaPath, "utf-8");
|
|
682
|
-
await spicedb.writeSchema(schema);
|
|
683
|
-
api.logger.info("openclaw-memory-rebac: SpiceDB schema written successfully");
|
|
684
|
-
}
|
|
685
|
-
} catch {
|
|
686
|
-
// Will be retried on first use
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
if (spicedbOk) {
|
|
690
|
-
try {
|
|
691
|
-
const token = await ensureGroupMembership(spicedb, backendDefaultGroupId, currentSubject);
|
|
692
|
-
if (token) lastWriteToken = token;
|
|
693
|
-
} catch {
|
|
694
|
-
api.logger.warn("openclaw-memory-rebac: failed to ensure default group membership");
|
|
695
|
-
}
|
|
696
|
-
}
|
|
697
|
-
|
|
698
|
-
api.logger.info(
|
|
699
|
-
`openclaw-memory-rebac: initialized (backend: ${backend.name} ${backendStatus.healthy ? "OK" : "UNREACHABLE"}, spicedb: ${spicedbOk ? "OK" : "UNREACHABLE"})`,
|
|
700
|
-
);
|
|
701
|
-
},
|
|
702
|
-
stop() {
|
|
703
|
-
clearTimeout(grpcGuardTimer);
|
|
704
|
-
process.removeListener("unhandledRejection", grpcRejectionHandler);
|
|
705
|
-
api.logger.info("openclaw-memory-rebac: stopped");
|
|
706
|
-
},
|
|
707
|
-
});
|
|
708
|
-
},
|
|
709
|
-
};
|
|
710
|
-
|
|
711
|
-
export default rebacMemoryPlugin;
|