@contextableai/openclaw-memory-rebac 0.1.5 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/authorization.d.ts +5 -0
- package/dist/authorization.js +14 -0
- package/dist/backend.d.ts +6 -0
- package/dist/backends/graphiti.d.ts +1 -0
- package/dist/backends/graphiti.js +20 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.js +12 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.js +147 -72
- package/docker/graphiti/docker-compose.yml +1 -1
- package/docker/spicedb/docker-compose.yml +2 -2
- package/package.json +1 -1
package/dist/authorization.d.ts
CHANGED
|
@@ -22,6 +22,11 @@ export type FragmentRelationships = {
|
|
|
22
22
|
* Returns group resource IDs from SpiceDB where the subject has the "access" permission.
|
|
23
23
|
*/
|
|
24
24
|
export declare function lookupAuthorizedGroups(spicedb: SpiceDbClient, subject: Subject, zedToken?: string): Promise<string[]>;
|
|
25
|
+
/**
|
|
26
|
+
* Look up the owner person ID for an agent.
|
|
27
|
+
* Returns undefined if no owner relationship exists.
|
|
28
|
+
*/
|
|
29
|
+
export declare function lookupAgentOwner(spicedb: SpiceDbClient, agentId: string, zedToken?: string): Promise<string | undefined>;
|
|
25
30
|
/**
|
|
26
31
|
* Look up all memory fragment IDs that a subject can view.
|
|
27
32
|
* Used for fine-grained post-filtering when needed.
|
package/dist/authorization.js
CHANGED
|
@@ -28,6 +28,20 @@ export async function lookupAuthorizedGroups(spicedb, subject, zedToken) {
|
|
|
28
28
|
consistency: tokenConsistency(zedToken),
|
|
29
29
|
});
|
|
30
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* Look up the owner person ID for an agent.
|
|
33
|
+
* Returns undefined if no owner relationship exists.
|
|
34
|
+
*/
|
|
35
|
+
export async function lookupAgentOwner(spicedb, agentId, zedToken) {
|
|
36
|
+
const tuples = await spicedb.readRelationships({
|
|
37
|
+
resourceType: "agent",
|
|
38
|
+
resourceId: agentId,
|
|
39
|
+
relation: "owner",
|
|
40
|
+
consistency: tokenConsistency(zedToken),
|
|
41
|
+
});
|
|
42
|
+
const ownerTuple = tuples.find((t) => t.subjectType === "person");
|
|
43
|
+
return ownerTuple?.subjectId;
|
|
44
|
+
}
|
|
31
45
|
/**
|
|
32
46
|
* Look up all memory fragment IDs that a subject can view.
|
|
33
47
|
* Used for fine-grained post-filtering when needed.
|
package/dist/backend.d.ts
CHANGED
|
@@ -118,6 +118,12 @@ export interface MemoryBackend {
|
|
|
118
118
|
* Returns true if deleted, false if the backend doesn't support it.
|
|
119
119
|
*/
|
|
120
120
|
deleteFragment?(uuid: string, type?: string): Promise<boolean>;
|
|
121
|
+
/**
|
|
122
|
+
* Fetch fragment details by their IDs.
|
|
123
|
+
* Used for fragment-level recall (e.g., finding memories via `involves` permissions).
|
|
124
|
+
* Optional: not all backends support fetching individual fragments by ID.
|
|
125
|
+
*/
|
|
126
|
+
getFragmentsByIds?(ids: string[]): Promise<SearchResult[]>;
|
|
121
127
|
/**
|
|
122
128
|
* Discover fragment (fact/edge) UUIDs that were extracted from a stored episode.
|
|
123
129
|
* Called after store() resolves the episode ID to write per-fragment SpiceDB
|
|
@@ -63,6 +63,7 @@ export declare class GraphitiBackend implements MemoryBackend {
|
|
|
63
63
|
listGroups(): Promise<BackendDataset[]>;
|
|
64
64
|
deleteFragment(uuid: string, type?: string): Promise<boolean>;
|
|
65
65
|
getEpisodes(groupId: string, lastN: number): Promise<GraphitiEpisode[]>;
|
|
66
|
+
getFragmentsByIds(ids: string[]): Promise<SearchResult[]>;
|
|
66
67
|
discoverFragmentIds(episodeId: string): Promise<string[]>;
|
|
67
68
|
getEntityEdge(uuid: string): Promise<FactResult>;
|
|
68
69
|
registerCliCommands(cmd: Command): void;
|
|
@@ -159,6 +159,26 @@ export class GraphitiBackend {
|
|
|
159
159
|
async getEpisodes(groupId, lastN) {
|
|
160
160
|
return this.restCall("GET", `/episodes/${encodeURIComponent(groupId)}?last_n=${lastN}`);
|
|
161
161
|
}
|
|
162
|
+
async getFragmentsByIds(ids) {
|
|
163
|
+
const results = [];
|
|
164
|
+
for (const id of ids) {
|
|
165
|
+
try {
|
|
166
|
+
const fact = await this.getEntityEdge(id);
|
|
167
|
+
results.push({
|
|
168
|
+
type: "fact",
|
|
169
|
+
uuid: id,
|
|
170
|
+
group_id: "unknown",
|
|
171
|
+
summary: fact.fact,
|
|
172
|
+
context: fact.name,
|
|
173
|
+
created_at: fact.created_at,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
// Fragment not found or unreachable — skip
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return results;
|
|
181
|
+
}
|
|
162
182
|
async discoverFragmentIds(episodeId) {
|
|
163
183
|
const edges = await this.restCall("GET", `/episodes/${encodeURIComponent(episodeId)}/edges`);
|
|
164
184
|
return edges.map((e) => e.uuid);
|
package/dist/config.d.ts
CHANGED
|
@@ -16,6 +16,8 @@ export type RebacMemoryConfig = {
|
|
|
16
16
|
backendConfig: Record<string, unknown>;
|
|
17
17
|
subjectType: "agent" | "person";
|
|
18
18
|
subjectId: string;
|
|
19
|
+
/** Maps agent IDs to their owner person IDs (e.g., Slack user IDs). */
|
|
20
|
+
identities: Record<string, string>;
|
|
19
21
|
autoCapture: boolean;
|
|
20
22
|
autoRecall: boolean;
|
|
21
23
|
maxCaptureMessages: number;
|
package/dist/config.js
CHANGED
|
@@ -41,7 +41,7 @@ export const rebacMemoryConfigSchema = {
|
|
|
41
41
|
// Top-level allowed keys: shared keys + the backend name key
|
|
42
42
|
assertAllowedKeys(cfg, [
|
|
43
43
|
"backend", "spicedb",
|
|
44
|
-
"subjectType", "subjectId",
|
|
44
|
+
"subjectType", "subjectId", "identities",
|
|
45
45
|
"autoCapture", "autoRecall", "maxCaptureMessages",
|
|
46
46
|
backendName,
|
|
47
47
|
], "openclaw-memory-rebac config");
|
|
@@ -54,6 +54,16 @@ export const rebacMemoryConfigSchema = {
|
|
|
54
54
|
const backendConfig = { ...entry.defaults, ...backendRaw };
|
|
55
55
|
const subjectType = cfg.subjectType === "person" ? "person" : pluginDefaults.subjectType;
|
|
56
56
|
const subjectId = typeof cfg.subjectId === "string" ? resolveEnvVars(cfg.subjectId) : pluginDefaults.subjectId;
|
|
57
|
+
// Parse identities: { "main": "U0123ABC", "work": "U0456DEF" }
|
|
58
|
+
const identitiesRaw = cfg.identities;
|
|
59
|
+
const identities = {};
|
|
60
|
+
if (identitiesRaw && typeof identitiesRaw === "object" && !Array.isArray(identitiesRaw)) {
|
|
61
|
+
for (const [agentId, personId] of Object.entries(identitiesRaw)) {
|
|
62
|
+
if (typeof personId === "string" && personId.trim()) {
|
|
63
|
+
identities[agentId] = personId.trim();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
57
67
|
return {
|
|
58
68
|
backend: backendName,
|
|
59
69
|
spicedb: {
|
|
@@ -68,6 +78,7 @@ export const rebacMemoryConfigSchema = {
|
|
|
68
78
|
backendConfig,
|
|
69
79
|
subjectType,
|
|
70
80
|
subjectId,
|
|
81
|
+
identities,
|
|
71
82
|
autoCapture: cfg.autoCapture !== false,
|
|
72
83
|
autoRecall: cfg.autoRecall !== false,
|
|
73
84
|
maxCaptureMessages: typeof cfg.maxCaptureMessages === "number" && cfg.maxCaptureMessages > 0
|
package/dist/index.d.ts
CHANGED
|
@@ -10,6 +10,10 @@
|
|
|
10
10
|
* Authorization is enforced at the data layer, not in prompts.
|
|
11
11
|
*
|
|
12
12
|
* Backend currently uses Graphiti for knowledge graph storage.
|
|
13
|
+
*
|
|
14
|
+
* Per-agent identity: tools and hooks derive the SpiceDB subject from
|
|
15
|
+
* the runtime agentId (OpenClawPluginToolContext / PluginHookAgentContext),
|
|
16
|
+
* falling back to config-level subjectType/subjectId when agentId is absent.
|
|
13
17
|
*/
|
|
14
18
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
15
19
|
declare const rebacMemoryPlugin: {
|
package/dist/index.js
CHANGED
|
@@ -10,6 +10,10 @@
|
|
|
10
10
|
* Authorization is enforced at the data layer, not in prompts.
|
|
11
11
|
*
|
|
12
12
|
* Backend currently uses Graphiti for knowledge graph storage.
|
|
13
|
+
*
|
|
14
|
+
* Per-agent identity: tools and hooks derive the SpiceDB subject from
|
|
15
|
+
* the runtime agentId (OpenClawPluginToolContext / PluginHookAgentContext),
|
|
16
|
+
* falling back to config-level subjectType/subjectId when agentId is absent.
|
|
13
17
|
*/
|
|
14
18
|
import { Type } from "@sinclair/typebox";
|
|
15
19
|
import { readFileSync } from "node:fs";
|
|
@@ -17,7 +21,7 @@ import { join, dirname } from "node:path";
|
|
|
17
21
|
import { fileURLToPath } from "node:url";
|
|
18
22
|
import { rebacMemoryConfigSchema, createBackend, defaultGroupId } from "./config.js";
|
|
19
23
|
import { SpiceDbClient } from "./spicedb.js";
|
|
20
|
-
import { lookupAuthorizedGroups, writeFragmentRelationships, deleteFragmentRelationships, canDeleteFragment, canWriteToGroup, ensureGroupMembership, } from "./authorization.js";
|
|
24
|
+
import { lookupAuthorizedGroups, lookupViewableFragments, lookupAgentOwner, writeFragmentRelationships, deleteFragmentRelationships, canDeleteFragment, canWriteToGroup, ensureGroupMembership, } from "./authorization.js";
|
|
21
25
|
import { searchAuthorizedMemories, formatDualResults, deduplicateSessionResults, } from "./search.js";
|
|
22
26
|
import { registerCommands } from "./cli.js";
|
|
23
27
|
// ============================================================================
|
|
@@ -64,14 +68,32 @@ const rebacMemoryPlugin = {
|
|
|
64
68
|
process.removeListener("unhandledRejection", grpcRejectionHandler);
|
|
65
69
|
}, 10_000);
|
|
66
70
|
grpcGuardTimer.unref();
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
71
|
+
// Per-agent state: keyed by agentId (falls back to cfg.subjectId)
|
|
72
|
+
const agentStates = new Map();
|
|
73
|
+
function resolveSubject(agentId) {
|
|
74
|
+
if (agentId)
|
|
75
|
+
return { type: "agent", id: agentId };
|
|
76
|
+
return { type: cfg.subjectType, id: cfg.subjectId };
|
|
77
|
+
}
|
|
78
|
+
function getState(agentId) {
|
|
79
|
+
const key = agentId ?? cfg.subjectId;
|
|
80
|
+
let state = agentStates.get(key);
|
|
81
|
+
if (!state) {
|
|
82
|
+
state = {};
|
|
83
|
+
agentStates.set(key, state);
|
|
84
|
+
}
|
|
85
|
+
return state;
|
|
86
|
+
}
|
|
87
|
+
// Convenience: read state from the config-level default
|
|
88
|
+
// (used by service start and CLI where no agentId is available)
|
|
89
|
+
function getDefaultState() {
|
|
90
|
+
return getState(undefined);
|
|
91
|
+
}
|
|
70
92
|
api.logger.info(`openclaw-memory-rebac: registered (backend: ${backend.name}, spicedb: ${cfg.spicedb.endpoint})`);
|
|
71
93
|
// ========================================================================
|
|
72
|
-
// Tools
|
|
94
|
+
// Tools (registered as factories for per-agent identity)
|
|
73
95
|
// ========================================================================
|
|
74
|
-
api.registerTool({
|
|
96
|
+
api.registerTool((ctx) => ({
|
|
75
97
|
name: "memory_recall",
|
|
76
98
|
label: "Memory Recall",
|
|
77
99
|
description: "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.",
|
|
@@ -82,20 +104,16 @@ const rebacMemoryPlugin = {
|
|
|
82
104
|
}),
|
|
83
105
|
async execute(_toolCallId, params) {
|
|
84
106
|
const { query, limit = 10, scope = "all" } = params;
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
content: [{ type: "text", text: "No accessible memory groups found." }],
|
|
89
|
-
details: { count: 0, authorizedGroups: [] },
|
|
90
|
-
};
|
|
91
|
-
}
|
|
107
|
+
const subject = resolveSubject(ctx.agentId);
|
|
108
|
+
const state = getState(ctx.agentId);
|
|
109
|
+
const authorizedGroups = await lookupAuthorizedGroups(spicedb, subject, state.lastWriteToken);
|
|
92
110
|
let longTermGroups;
|
|
93
111
|
let sessionGroups;
|
|
94
112
|
if (scope === "session") {
|
|
95
113
|
longTermGroups = [];
|
|
96
114
|
sessionGroups = authorizedGroups.filter(isSessionGroup);
|
|
97
|
-
if (
|
|
98
|
-
const sg = sessionGroupId(
|
|
115
|
+
if (state.sessionId) {
|
|
116
|
+
const sg = sessionGroupId(state.sessionId);
|
|
99
117
|
if (!sessionGroups.includes(sg))
|
|
100
118
|
sessionGroups.push(sg);
|
|
101
119
|
}
|
|
@@ -107,19 +125,20 @@ const rebacMemoryPlugin = {
|
|
|
107
125
|
else {
|
|
108
126
|
longTermGroups = authorizedGroups.filter((g) => !isSessionGroup(g));
|
|
109
127
|
sessionGroups = authorizedGroups.filter(isSessionGroup);
|
|
110
|
-
if (
|
|
111
|
-
const sg = sessionGroupId(
|
|
128
|
+
if (state.sessionId) {
|
|
129
|
+
const sg = sessionGroupId(state.sessionId);
|
|
112
130
|
if (!sessionGroups.includes(sg))
|
|
113
131
|
sessionGroups.push(sg);
|
|
114
132
|
}
|
|
115
133
|
}
|
|
134
|
+
// Group-based search (existing path)
|
|
116
135
|
const [longTermResults, rawSessionResults] = await Promise.all([
|
|
117
136
|
longTermGroups.length > 0
|
|
118
137
|
? searchAuthorizedMemories(backend, {
|
|
119
138
|
query,
|
|
120
139
|
groupIds: longTermGroups,
|
|
121
140
|
limit,
|
|
122
|
-
sessionId:
|
|
141
|
+
sessionId: state.sessionId,
|
|
123
142
|
})
|
|
124
143
|
: Promise.resolve([]),
|
|
125
144
|
sessionGroups.length > 0
|
|
@@ -127,20 +146,45 @@ const rebacMemoryPlugin = {
|
|
|
127
146
|
query,
|
|
128
147
|
groupIds: sessionGroups,
|
|
129
148
|
limit,
|
|
130
|
-
sessionId:
|
|
149
|
+
sessionId: state.sessionId,
|
|
131
150
|
})
|
|
132
151
|
: Promise.resolve([]),
|
|
133
152
|
]);
|
|
134
153
|
const sessionResults = deduplicateSessionResults(longTermResults, rawSessionResults);
|
|
135
|
-
const
|
|
154
|
+
const groupResults = [...longTermResults, ...sessionResults];
|
|
155
|
+
// Owner-aware fragment search: if the subject is an agent with an owner,
|
|
156
|
+
// also find fragments where the owner is in `involves`.
|
|
157
|
+
let ownerFragmentResults = [];
|
|
158
|
+
if (subject.type === "agent" && backend.getFragmentsByIds) {
|
|
159
|
+
try {
|
|
160
|
+
const ownerId = await lookupAgentOwner(spicedb, subject.id, state.lastWriteToken);
|
|
161
|
+
if (ownerId) {
|
|
162
|
+
const ownerSubject = { type: "person", id: ownerId };
|
|
163
|
+
const viewableIds = await lookupViewableFragments(spicedb, ownerSubject, state.lastWriteToken);
|
|
164
|
+
if (viewableIds.length > 0) {
|
|
165
|
+
const groupResultIds = new Set(groupResults.map((r) => r.uuid));
|
|
166
|
+
const newIds = viewableIds.filter((id) => !groupResultIds.has(id));
|
|
167
|
+
if (newIds.length > 0) {
|
|
168
|
+
ownerFragmentResults = await backend.getFragmentsByIds(newIds);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
catch (err) {
|
|
174
|
+
api.logger.warn(`openclaw-memory-rebac: owner-aware recall failed: ${String(err)}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const allResults = [...groupResults, ...ownerFragmentResults];
|
|
178
|
+
const totalCount = allResults.length;
|
|
136
179
|
if (totalCount === 0) {
|
|
137
180
|
return {
|
|
138
181
|
content: [{ type: "text", text: "No relevant memories found." }],
|
|
139
182
|
details: { count: 0, authorizedGroups },
|
|
140
183
|
};
|
|
141
184
|
}
|
|
142
|
-
const text =
|
|
143
|
-
|
|
185
|
+
const text = ownerFragmentResults.length > 0
|
|
186
|
+
? formatDualResults(groupResults, ownerFragmentResults)
|
|
187
|
+
: formatDualResults(longTermResults, sessionResults);
|
|
144
188
|
const sanitized = allResults.map((r) => ({
|
|
145
189
|
type: r.type,
|
|
146
190
|
uuid: r.uuid,
|
|
@@ -156,11 +200,12 @@ const rebacMemoryPlugin = {
|
|
|
156
200
|
authorizedGroups,
|
|
157
201
|
longTermCount: longTermResults.length,
|
|
158
202
|
sessionCount: sessionResults.length,
|
|
203
|
+
ownerFragmentCount: ownerFragmentResults.length,
|
|
159
204
|
},
|
|
160
205
|
};
|
|
161
206
|
},
|
|
162
|
-
}, { name: "memory_recall" });
|
|
163
|
-
api.registerTool({
|
|
207
|
+
}), { name: "memory_recall" });
|
|
208
|
+
api.registerTool((ctx) => ({
|
|
164
209
|
name: "memory_store",
|
|
165
210
|
label: "Memory Store",
|
|
166
211
|
description: "Save information to the knowledge graph with authorization tracking. Use longTerm=false to store session-scoped memories.",
|
|
@@ -173,6 +218,8 @@ const rebacMemoryPlugin = {
|
|
|
173
218
|
}),
|
|
174
219
|
async execute(_toolCallId, params) {
|
|
175
220
|
const { content, source_description = "conversation", involves = [], group_id, longTerm = true, } = params;
|
|
221
|
+
const subject = resolveSubject(ctx.agentId);
|
|
222
|
+
const state = getState(ctx.agentId);
|
|
176
223
|
const sanitizeGroupId = (id) => {
|
|
177
224
|
if (!id)
|
|
178
225
|
return undefined;
|
|
@@ -187,27 +234,27 @@ const rebacMemoryPlugin = {
|
|
|
187
234
|
if (sanitizedGroupId) {
|
|
188
235
|
targetGroupId = sanitizedGroupId;
|
|
189
236
|
}
|
|
190
|
-
else if (!longTerm &&
|
|
191
|
-
targetGroupId = sessionGroupId(
|
|
237
|
+
else if (!longTerm && state.sessionId) {
|
|
238
|
+
targetGroupId = sessionGroupId(state.sessionId);
|
|
192
239
|
}
|
|
193
240
|
else {
|
|
194
241
|
targetGroupId = backendDefaultGroupId;
|
|
195
242
|
}
|
|
196
243
|
const isOwnSession = isSessionGroup(targetGroupId) &&
|
|
197
|
-
|
|
198
|
-
targetGroupId === sessionGroupId(
|
|
244
|
+
state.sessionId != null &&
|
|
245
|
+
targetGroupId === sessionGroupId(state.sessionId);
|
|
199
246
|
if (isOwnSession) {
|
|
200
247
|
try {
|
|
201
|
-
const token = await ensureGroupMembership(spicedb, targetGroupId,
|
|
248
|
+
const token = await ensureGroupMembership(spicedb, targetGroupId, subject);
|
|
202
249
|
if (token)
|
|
203
|
-
lastWriteToken = token;
|
|
250
|
+
state.lastWriteToken = token;
|
|
204
251
|
}
|
|
205
252
|
catch {
|
|
206
253
|
api.logger.warn(`openclaw-memory-rebac: failed to ensure membership in ${targetGroupId}`);
|
|
207
254
|
}
|
|
208
255
|
}
|
|
209
256
|
else {
|
|
210
|
-
const allowed = await canWriteToGroup(spicedb,
|
|
257
|
+
const allowed = await canWriteToGroup(spicedb, subject, targetGroupId, state.lastWriteToken);
|
|
211
258
|
if (!allowed) {
|
|
212
259
|
return {
|
|
213
260
|
content: [{ type: "text", text: `Permission denied: cannot write to group "${targetGroupId}"` }],
|
|
@@ -236,11 +283,11 @@ const rebacMemoryPlugin = {
|
|
|
236
283
|
const writeToken = await writeFragmentRelationships(spicedb, {
|
|
237
284
|
fragmentId: factId,
|
|
238
285
|
groupId: targetGroupId,
|
|
239
|
-
sharedBy:
|
|
286
|
+
sharedBy: subject,
|
|
240
287
|
involves: involvedSubjects,
|
|
241
288
|
});
|
|
242
289
|
if (writeToken)
|
|
243
|
-
lastWriteToken = writeToken;
|
|
290
|
+
state.lastWriteToken = writeToken;
|
|
244
291
|
}
|
|
245
292
|
api.logger.info(`openclaw-memory-rebac: wrote SpiceDB relationships for ${factIds.length} fact(s) from episode ${episodeId}`);
|
|
246
293
|
}
|
|
@@ -249,11 +296,11 @@ const rebacMemoryPlugin = {
|
|
|
249
296
|
const writeToken = await writeFragmentRelationships(spicedb, {
|
|
250
297
|
fragmentId: episodeId,
|
|
251
298
|
groupId: targetGroupId,
|
|
252
|
-
sharedBy:
|
|
299
|
+
sharedBy: subject,
|
|
253
300
|
involves: involvedSubjects,
|
|
254
301
|
});
|
|
255
302
|
if (writeToken)
|
|
256
|
-
lastWriteToken = writeToken;
|
|
303
|
+
state.lastWriteToken = writeToken;
|
|
257
304
|
}
|
|
258
305
|
})
|
|
259
306
|
.catch((err) => {
|
|
@@ -270,8 +317,8 @@ const rebacMemoryPlugin = {
|
|
|
270
317
|
},
|
|
271
318
|
};
|
|
272
319
|
},
|
|
273
|
-
}, { name: "memory_store" });
|
|
274
|
-
api.registerTool({
|
|
320
|
+
}), { name: "memory_store" });
|
|
321
|
+
api.registerTool((ctx) => ({
|
|
275
322
|
name: "memory_forget",
|
|
276
323
|
label: "Memory Forget",
|
|
277
324
|
description: "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.",
|
|
@@ -280,6 +327,8 @@ const rebacMemoryPlugin = {
|
|
|
280
327
|
}),
|
|
281
328
|
async execute(_toolCallId, params) {
|
|
282
329
|
const { id } = params;
|
|
330
|
+
const subject = resolveSubject(ctx.agentId);
|
|
331
|
+
const state = getState(ctx.agentId);
|
|
283
332
|
// Parse optional type prefix — strip it to get the bare UUID
|
|
284
333
|
const colonIdx = id.indexOf(":");
|
|
285
334
|
let uuid;
|
|
@@ -303,11 +352,11 @@ const rebacMemoryPlugin = {
|
|
|
303
352
|
// Fragment-level relationships may be missing (episode UUID vs fact UUID mismatch),
|
|
304
353
|
// so fall back to group-level authorization: if the subject can contribute to any
|
|
305
354
|
// group they have access to, allow deletion.
|
|
306
|
-
let allowed = await canDeleteFragment(spicedb,
|
|
355
|
+
let allowed = await canDeleteFragment(spicedb, subject, uuid, state.lastWriteToken);
|
|
307
356
|
if (!allowed) {
|
|
308
|
-
const groups = await lookupAuthorizedGroups(spicedb,
|
|
357
|
+
const groups = await lookupAuthorizedGroups(spicedb, subject, state.lastWriteToken);
|
|
309
358
|
for (const g of groups) {
|
|
310
|
-
if (await canWriteToGroup(spicedb,
|
|
359
|
+
if (await canWriteToGroup(spicedb, subject, g, state.lastWriteToken)) {
|
|
311
360
|
allowed = true;
|
|
312
361
|
break;
|
|
313
362
|
}
|
|
@@ -335,19 +384,20 @@ const rebacMemoryPlugin = {
|
|
|
335
384
|
// Always de-authorize in SpiceDB
|
|
336
385
|
const writeToken = await deleteFragmentRelationships(spicedb, uuid);
|
|
337
386
|
if (writeToken)
|
|
338
|
-
lastWriteToken = writeToken;
|
|
387
|
+
state.lastWriteToken = writeToken;
|
|
339
388
|
return {
|
|
340
389
|
content: [{ type: "text", text: "Memory forgotten." }],
|
|
341
390
|
details: { action: "deleted", id, uuid },
|
|
342
391
|
};
|
|
343
392
|
},
|
|
344
|
-
}, { name: "memory_forget" });
|
|
345
|
-
api.registerTool({
|
|
393
|
+
}), { name: "memory_forget" });
|
|
394
|
+
api.registerTool((ctx) => ({
|
|
346
395
|
name: "memory_status",
|
|
347
396
|
label: "Memory Status",
|
|
348
397
|
description: "Check the health of the memory backend and SpiceDB.",
|
|
349
398
|
parameters: Type.Object({}),
|
|
350
399
|
async execute() {
|
|
400
|
+
const state = getState(ctx.agentId);
|
|
351
401
|
const backendStatus = await backend.getStatus();
|
|
352
402
|
let spicedbOk = false;
|
|
353
403
|
try {
|
|
@@ -361,22 +411,25 @@ const rebacMemoryPlugin = {
|
|
|
361
411
|
...backendStatus,
|
|
362
412
|
spicedb: spicedbOk ? "connected" : "unreachable",
|
|
363
413
|
endpoint_spicedb: cfg.spicedb.endpoint,
|
|
364
|
-
currentSessionId:
|
|
414
|
+
currentSessionId: state.sessionId ?? "none",
|
|
415
|
+
agentId: ctx.agentId ?? "default",
|
|
365
416
|
};
|
|
366
417
|
const statusText = [
|
|
367
418
|
`Backend (${backend.name}): ${backendStatus.healthy ? "connected" : "unreachable"}`,
|
|
368
419
|
`SpiceDB: ${spicedbOk ? "connected" : "unreachable"} (${cfg.spicedb.endpoint})`,
|
|
369
|
-
`Session: ${
|
|
420
|
+
`Session: ${state.sessionId ?? "none"}`,
|
|
421
|
+
`Agent: ${ctx.agentId ?? "default"}`,
|
|
370
422
|
].join("\n");
|
|
371
423
|
return {
|
|
372
424
|
content: [{ type: "text", text: statusText }],
|
|
373
425
|
details: status,
|
|
374
426
|
};
|
|
375
427
|
},
|
|
376
|
-
}, { name: "memory_status" });
|
|
428
|
+
}), { name: "memory_status" });
|
|
377
429
|
// ========================================================================
|
|
378
430
|
// CLI Commands
|
|
379
431
|
// ========================================================================
|
|
432
|
+
const defaultSubject = { type: cfg.subjectType, id: cfg.subjectId };
|
|
380
433
|
api.registerCli(({ program }) => {
|
|
381
434
|
const mem = program
|
|
382
435
|
.command("rebac-mem")
|
|
@@ -385,8 +438,8 @@ const rebacMemoryPlugin = {
|
|
|
385
438
|
backend,
|
|
386
439
|
spicedb,
|
|
387
440
|
cfg,
|
|
388
|
-
currentSubject,
|
|
389
|
-
getLastWriteToken: () => lastWriteToken,
|
|
441
|
+
currentSubject: defaultSubject,
|
|
442
|
+
getLastWriteToken: () => getDefaultState().lastWriteToken,
|
|
390
443
|
});
|
|
391
444
|
}, { commands: ["rebac-mem"] });
|
|
392
445
|
// ========================================================================
|
|
@@ -394,18 +447,18 @@ const rebacMemoryPlugin = {
|
|
|
394
447
|
// ========================================================================
|
|
395
448
|
if (cfg.autoRecall) {
|
|
396
449
|
api.on("before_agent_start", async (event, ctx) => {
|
|
450
|
+
const subject = resolveSubject(ctx?.agentId);
|
|
451
|
+
const state = getState(ctx?.agentId);
|
|
397
452
|
if (ctx?.sessionKey)
|
|
398
|
-
|
|
453
|
+
state.sessionId = ctx.sessionKey;
|
|
399
454
|
if (!event.prompt || event.prompt.length < 5)
|
|
400
455
|
return;
|
|
401
456
|
try {
|
|
402
|
-
const authorizedGroups = await lookupAuthorizedGroups(spicedb,
|
|
403
|
-
if (authorizedGroups.length === 0)
|
|
404
|
-
return;
|
|
457
|
+
const authorizedGroups = await lookupAuthorizedGroups(spicedb, subject, state.lastWriteToken);
|
|
405
458
|
const longTermGroups = authorizedGroups.filter((g) => !isSessionGroup(g));
|
|
406
459
|
const sessionGroups = authorizedGroups.filter(isSessionGroup);
|
|
407
|
-
if (
|
|
408
|
-
const sg = sessionGroupId(
|
|
460
|
+
if (state.sessionId) {
|
|
461
|
+
const sg = sessionGroupId(state.sessionId);
|
|
409
462
|
if (!sessionGroups.includes(sg))
|
|
410
463
|
sessionGroups.push(sg);
|
|
411
464
|
}
|
|
@@ -415,7 +468,7 @@ const rebacMemoryPlugin = {
|
|
|
415
468
|
query: event.prompt,
|
|
416
469
|
groupIds: longTermGroups,
|
|
417
470
|
limit: 5,
|
|
418
|
-
sessionId:
|
|
471
|
+
sessionId: state.sessionId,
|
|
419
472
|
})
|
|
420
473
|
: Promise.resolve([]),
|
|
421
474
|
sessionGroups.length > 0
|
|
@@ -423,7 +476,7 @@ const rebacMemoryPlugin = {
|
|
|
423
476
|
query: event.prompt,
|
|
424
477
|
groupIds: sessionGroups,
|
|
425
478
|
limit: 3,
|
|
426
|
-
sessionId:
|
|
479
|
+
sessionId: state.sessionId,
|
|
427
480
|
})
|
|
428
481
|
: Promise.resolve([]),
|
|
429
482
|
]);
|
|
@@ -449,8 +502,10 @@ const rebacMemoryPlugin = {
|
|
|
449
502
|
}
|
|
450
503
|
if (cfg.autoCapture) {
|
|
451
504
|
api.on("agent_end", async (event, ctx) => {
|
|
505
|
+
const subject = resolveSubject(ctx?.agentId);
|
|
506
|
+
const state = getState(ctx?.agentId);
|
|
452
507
|
if (ctx?.sessionKey)
|
|
453
|
-
|
|
508
|
+
state.sessionId = ctx.sessionKey;
|
|
454
509
|
if (!event.success || !event.messages || event.messages.length === 0)
|
|
455
510
|
return;
|
|
456
511
|
try {
|
|
@@ -495,24 +550,24 @@ const rebacMemoryPlugin = {
|
|
|
495
550
|
if (conversationLines.length === 0)
|
|
496
551
|
return;
|
|
497
552
|
const episodeBody = conversationLines.join("\n");
|
|
498
|
-
const targetGroupId =
|
|
499
|
-
? sessionGroupId(
|
|
553
|
+
const targetGroupId = state.sessionId
|
|
554
|
+
? sessionGroupId(state.sessionId)
|
|
500
555
|
: backendDefaultGroupId;
|
|
501
556
|
const isOwnSession = isSessionGroup(targetGroupId) &&
|
|
502
|
-
|
|
503
|
-
targetGroupId === sessionGroupId(
|
|
557
|
+
state.sessionId != null &&
|
|
558
|
+
targetGroupId === sessionGroupId(state.sessionId);
|
|
504
559
|
if (isOwnSession) {
|
|
505
560
|
try {
|
|
506
|
-
const token = await ensureGroupMembership(spicedb, targetGroupId,
|
|
561
|
+
const token = await ensureGroupMembership(spicedb, targetGroupId, subject);
|
|
507
562
|
if (token)
|
|
508
|
-
lastWriteToken = token;
|
|
563
|
+
state.lastWriteToken = token;
|
|
509
564
|
}
|
|
510
565
|
catch {
|
|
511
566
|
// best-effort
|
|
512
567
|
}
|
|
513
568
|
}
|
|
514
569
|
else {
|
|
515
|
-
const allowed = await canWriteToGroup(spicedb,
|
|
570
|
+
const allowed = await canWriteToGroup(spicedb, subject, targetGroupId, state.lastWriteToken);
|
|
516
571
|
if (!allowed) {
|
|
517
572
|
api.logger.warn(`openclaw-memory-rebac: auto-capture denied for group ${targetGroupId}`);
|
|
518
573
|
return;
|
|
@@ -535,27 +590,27 @@ const rebacMemoryPlugin = {
|
|
|
535
590
|
const writeToken = await writeFragmentRelationships(spicedb, {
|
|
536
591
|
fragmentId: factId,
|
|
537
592
|
groupId: targetGroupId,
|
|
538
|
-
sharedBy:
|
|
593
|
+
sharedBy: subject,
|
|
539
594
|
});
|
|
540
595
|
if (writeToken)
|
|
541
|
-
lastWriteToken = writeToken;
|
|
596
|
+
state.lastWriteToken = writeToken;
|
|
542
597
|
}
|
|
543
598
|
}
|
|
544
599
|
else {
|
|
545
600
|
const writeToken = await writeFragmentRelationships(spicedb, {
|
|
546
601
|
fragmentId: episodeId,
|
|
547
602
|
groupId: targetGroupId,
|
|
548
|
-
sharedBy:
|
|
603
|
+
sharedBy: subject,
|
|
549
604
|
});
|
|
550
605
|
if (writeToken)
|
|
551
|
-
lastWriteToken = writeToken;
|
|
606
|
+
state.lastWriteToken = writeToken;
|
|
552
607
|
}
|
|
553
608
|
})
|
|
554
609
|
.catch((err) => {
|
|
555
610
|
api.logger.warn(`openclaw-memory-rebac: deferred SpiceDB write (auto-capture) failed: ${err}`);
|
|
556
611
|
});
|
|
557
612
|
// Backend-specific session enrichment (optional backend feature)
|
|
558
|
-
if (backend.enrichSession &&
|
|
613
|
+
if (backend.enrichSession && state.sessionId) {
|
|
559
614
|
const lastUserMsg = [...event.messages]
|
|
560
615
|
.reverse()
|
|
561
616
|
.find((m) => m.role === "user");
|
|
@@ -581,7 +636,7 @@ const rebacMemoryPlugin = {
|
|
|
581
636
|
const assistantMsg = extractText(lastAssistMsg);
|
|
582
637
|
if (userMsg && assistantMsg) {
|
|
583
638
|
backend.enrichSession({
|
|
584
|
-
sessionId:
|
|
639
|
+
sessionId: state.sessionId,
|
|
585
640
|
groupId: targetGroupId,
|
|
586
641
|
userMsg,
|
|
587
642
|
assistantMsg,
|
|
@@ -601,6 +656,7 @@ const rebacMemoryPlugin = {
|
|
|
601
656
|
api.registerService({
|
|
602
657
|
id: "openclaw-memory-rebac",
|
|
603
658
|
async start() {
|
|
659
|
+
const defaultState = getDefaultState();
|
|
604
660
|
const backendStatus = await backend.getStatus();
|
|
605
661
|
let spicedbOk = false;
|
|
606
662
|
try {
|
|
@@ -618,14 +674,33 @@ const rebacMemoryPlugin = {
|
|
|
618
674
|
// Will be retried on first use
|
|
619
675
|
}
|
|
620
676
|
if (spicedbOk) {
|
|
677
|
+
// Ensure the config-level subject is a member of the default group
|
|
621
678
|
try {
|
|
622
|
-
const token = await ensureGroupMembership(spicedb, backendDefaultGroupId,
|
|
679
|
+
const token = await ensureGroupMembership(spicedb, backendDefaultGroupId, defaultSubject);
|
|
623
680
|
if (token)
|
|
624
|
-
lastWriteToken = token;
|
|
681
|
+
defaultState.lastWriteToken = token;
|
|
625
682
|
}
|
|
626
683
|
catch {
|
|
627
684
|
api.logger.warn("openclaw-memory-rebac: failed to ensure default group membership");
|
|
628
685
|
}
|
|
686
|
+
// Write agent → owner relationships from identities config
|
|
687
|
+
for (const [agentId, personId] of Object.entries(cfg.identities)) {
|
|
688
|
+
try {
|
|
689
|
+
const token = await spicedb.writeRelationships([{
|
|
690
|
+
resourceType: "agent",
|
|
691
|
+
resourceId: agentId,
|
|
692
|
+
relation: "owner",
|
|
693
|
+
subjectType: "person",
|
|
694
|
+
subjectId: personId,
|
|
695
|
+
}]);
|
|
696
|
+
if (token)
|
|
697
|
+
defaultState.lastWriteToken = token;
|
|
698
|
+
api.logger.info(`openclaw-memory-rebac: linked agent:${agentId} → person:${personId}`);
|
|
699
|
+
}
|
|
700
|
+
catch (err) {
|
|
701
|
+
api.logger.warn(`openclaw-memory-rebac: failed to write owner for agent:${agentId}: ${err}`);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
629
704
|
}
|
|
630
705
|
api.logger.info(`openclaw-memory-rebac: initialized (backend: ${backend.name} ${backendStatus.healthy ? "OK" : "UNREACHABLE"}, spicedb: ${spicedbOk ? "OK" : "UNREACHABLE"})`);
|
|
631
706
|
},
|
|
@@ -56,8 +56,8 @@ services:
|
|
|
56
56
|
command: serve
|
|
57
57
|
restart: unless-stopped
|
|
58
58
|
ports:
|
|
59
|
-
- "
|
|
60
|
-
- "
|
|
59
|
+
- "127.0.0.1:${SPICEDB_GRPC_PORT:-50051}:50051" # gRPC
|
|
60
|
+
- "127.0.0.1:${SPICEDB_HTTP_PORT:-8080}:8080" # HTTP metrics / healthz
|
|
61
61
|
environment:
|
|
62
62
|
SPICEDB_GRPC_PRESHARED_KEY: ${SPICEDB_PRESHARED_KEY:-dev_token}
|
|
63
63
|
SPICEDB_DATASTORE_ENGINE: postgres
|
package/package.json
CHANGED