@contextableai/openclaw-memory-graphiti 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -0
- package/authorization.ts +27 -38
- package/bin/graphiti-mem.ts +140 -0
- package/cli.ts +522 -0
- package/docker/docker-compose.yml +2 -2
- package/graphiti.ts +75 -6
- package/index.ts +154 -337
- package/package.json +11 -6
- package/search.ts +17 -5
- package/spicedb.ts +190 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@contextableai/openclaw-memory-graphiti",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "OpenClaw two-layer memory plugin: SpiceDB authorization + Graphiti knowledge graph",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
],
|
|
19
19
|
"files": [
|
|
20
20
|
"index.ts",
|
|
21
|
+
"cli.ts",
|
|
21
22
|
"config.ts",
|
|
22
23
|
"authorization.ts",
|
|
23
24
|
"graphiti.ts",
|
|
@@ -26,27 +27,31 @@
|
|
|
26
27
|
"openclaw.plugin.json",
|
|
27
28
|
"schema.zed",
|
|
28
29
|
"docker/",
|
|
29
|
-
"scripts/"
|
|
30
|
+
"scripts/",
|
|
31
|
+
"bin/"
|
|
30
32
|
],
|
|
31
33
|
"scripts": {
|
|
34
|
+
"cli": "tsx bin/graphiti-mem.ts",
|
|
35
|
+
"typecheck": "tsc --noEmit 2>&1 | grep -v '../openclaw/' | grep -c 'error TS' | xargs -I{} test {} -eq 0",
|
|
32
36
|
"test": "vitest run",
|
|
33
37
|
"test:watch": "vitest",
|
|
34
38
|
"test:e2e": "OPENCLAW_LIVE_TEST=1 vitest run --config vitest.e2e.config.ts"
|
|
35
39
|
},
|
|
36
40
|
"dependencies": {
|
|
37
41
|
"@authzed/authzed-node": "^1.2.0",
|
|
38
|
-
"@sinclair/typebox": "0.34.48"
|
|
42
|
+
"@sinclair/typebox": "0.34.48",
|
|
43
|
+
"commander": "^13.1.0"
|
|
39
44
|
},
|
|
40
45
|
"devDependencies": {
|
|
46
|
+
"typescript": "^5.9.3",
|
|
41
47
|
"vitest": "^4.0.18"
|
|
42
48
|
},
|
|
43
49
|
"publishConfig": {
|
|
44
50
|
"access": "public"
|
|
45
51
|
},
|
|
46
52
|
"openclaw": {
|
|
47
|
-
"
|
|
48
|
-
|
|
49
|
-
],
|
|
53
|
+
"_extensions_note": "Intentionally empty — explicit entries cause idHint to derive from the npm package name rather than the directory name, producing a mismatch with the manifest id. Previously: [\"./index.ts\"]",
|
|
54
|
+
"extensions": [],
|
|
50
55
|
"install": {
|
|
51
56
|
"npmSpec": "@contextableai/openclaw-memory-graphiti",
|
|
52
57
|
"localPath": "extensions/memory-graphiti",
|
package/search.ts
CHANGED
|
@@ -27,6 +27,8 @@ export type SearchOptions = {
|
|
|
27
27
|
limit?: number;
|
|
28
28
|
searchNodes?: boolean;
|
|
29
29
|
searchFacts?: boolean;
|
|
30
|
+
entityTypes?: string[];
|
|
31
|
+
centerNodeUuid?: string;
|
|
30
32
|
};
|
|
31
33
|
|
|
32
34
|
// ============================================================================
|
|
@@ -41,7 +43,15 @@ export async function searchAuthorizedMemories(
|
|
|
41
43
|
graphiti: GraphitiClient,
|
|
42
44
|
options: SearchOptions,
|
|
43
45
|
): Promise<SearchResult[]> {
|
|
44
|
-
const {
|
|
46
|
+
const {
|
|
47
|
+
query,
|
|
48
|
+
groupIds,
|
|
49
|
+
limit = 10,
|
|
50
|
+
searchNodes = true,
|
|
51
|
+
searchFacts = true,
|
|
52
|
+
entityTypes,
|
|
53
|
+
centerNodeUuid,
|
|
54
|
+
} = options;
|
|
45
55
|
|
|
46
56
|
if (groupIds.length === 0) {
|
|
47
57
|
return [];
|
|
@@ -52,10 +62,10 @@ export async function searchAuthorizedMemories(
|
|
|
52
62
|
|
|
53
63
|
for (const groupId of groupIds) {
|
|
54
64
|
if (searchNodes) {
|
|
55
|
-
promises.push(searchNodesForGroup(graphiti, query, groupId, limit));
|
|
65
|
+
promises.push(searchNodesForGroup(graphiti, query, groupId, limit, entityTypes));
|
|
56
66
|
}
|
|
57
67
|
if (searchFacts) {
|
|
58
|
-
promises.push(searchFactsForGroup(graphiti, query, groupId, limit));
|
|
68
|
+
promises.push(searchFactsForGroup(graphiti, query, groupId, limit, centerNodeUuid));
|
|
59
69
|
}
|
|
60
70
|
}
|
|
61
71
|
|
|
@@ -99,8 +109,9 @@ async function searchNodesForGroup(
|
|
|
99
109
|
query: string,
|
|
100
110
|
groupId: string,
|
|
101
111
|
limit: number,
|
|
112
|
+
entityTypes?: string[],
|
|
102
113
|
): Promise<SearchResult[]> {
|
|
103
|
-
const nodes = await graphiti.searchNodes({ query, group_id: groupId, limit });
|
|
114
|
+
const nodes = await graphiti.searchNodes({ query, group_id: groupId, limit, entity_types: entityTypes });
|
|
104
115
|
return nodes.map(nodeToResult);
|
|
105
116
|
}
|
|
106
117
|
|
|
@@ -109,8 +120,9 @@ async function searchFactsForGroup(
|
|
|
109
120
|
query: string,
|
|
110
121
|
groupId: string,
|
|
111
122
|
limit: number,
|
|
123
|
+
centerNodeUuid?: string,
|
|
112
124
|
): Promise<SearchResult[]> {
|
|
113
|
-
const facts = await graphiti.searchFacts({ query, group_id: groupId, limit });
|
|
125
|
+
const facts = await graphiti.searchFacts({ query, group_id: groupId, limit, center_node_uuid: centerNodeUuid });
|
|
114
126
|
return facts.map(factToResult);
|
|
115
127
|
}
|
|
116
128
|
|
package/spicedb.ts
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* SpiceDB Client Wrapper
|
|
3
3
|
*
|
|
4
4
|
* Wraps @authzed/authzed-node for authorization operations:
|
|
5
|
-
* WriteSchema, WriteRelationships,
|
|
5
|
+
* WriteSchema, WriteRelationships, DeleteRelationships, BulkImportRelationships,
|
|
6
|
+
* LookupResources, CheckPermission.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
import { v1 } from "@authzed/authzed-node";
|
|
@@ -25,6 +26,11 @@ export type RelationshipTuple = {
|
|
|
25
26
|
subjectId: string;
|
|
26
27
|
};
|
|
27
28
|
|
|
29
|
+
export type ConsistencyMode =
|
|
30
|
+
| { mode: "full" }
|
|
31
|
+
| { mode: "at_least_as_fresh"; token: string }
|
|
32
|
+
| { mode: "minimize_latency" };
|
|
33
|
+
|
|
28
34
|
// ============================================================================
|
|
29
35
|
// Client
|
|
30
36
|
// ============================================================================
|
|
@@ -65,7 +71,7 @@ export class SpiceDbClient {
|
|
|
65
71
|
// Relationships
|
|
66
72
|
// --------------------------------------------------------------------------
|
|
67
73
|
|
|
68
|
-
async writeRelationships(tuples: RelationshipTuple[]): Promise<
|
|
74
|
+
async writeRelationships(tuples: RelationshipTuple[]): Promise<string | undefined> {
|
|
69
75
|
const updates = tuples.map((t) =>
|
|
70
76
|
v1.RelationshipUpdate.create({
|
|
71
77
|
operation: v1.RelationshipUpdate_Operation.TOUCH,
|
|
@@ -86,7 +92,8 @@ export class SpiceDbClient {
|
|
|
86
92
|
);
|
|
87
93
|
|
|
88
94
|
const request = v1.WriteRelationshipsRequest.create({ updates });
|
|
89
|
-
await this.promises.writeRelationships(request);
|
|
95
|
+
const response = await this.promises.writeRelationships(request);
|
|
96
|
+
return response.writtenAt?.token;
|
|
90
97
|
}
|
|
91
98
|
|
|
92
99
|
async deleteRelationships(tuples: RelationshipTuple[]): Promise<void> {
|
|
@@ -113,16 +120,193 @@ export class SpiceDbClient {
|
|
|
113
120
|
await this.promises.writeRelationships(request);
|
|
114
121
|
}
|
|
115
122
|
|
|
123
|
+
async deleteRelationshipsByFilter(params: {
|
|
124
|
+
resourceType: string;
|
|
125
|
+
resourceId: string;
|
|
126
|
+
relation?: string;
|
|
127
|
+
}): Promise<string | undefined> {
|
|
128
|
+
const request = v1.DeleteRelationshipsRequest.create({
|
|
129
|
+
relationshipFilter: v1.RelationshipFilter.create({
|
|
130
|
+
resourceType: params.resourceType,
|
|
131
|
+
optionalResourceId: params.resourceId,
|
|
132
|
+
...(params.relation ? { optionalRelation: params.relation } : {}),
|
|
133
|
+
}),
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const response = await this.promises.deleteRelationships(request);
|
|
137
|
+
return response.deletedAt?.token;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// --------------------------------------------------------------------------
|
|
141
|
+
// Bulk Import
|
|
142
|
+
// --------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
private toRelationship(t: RelationshipTuple) {
|
|
145
|
+
return v1.Relationship.create({
|
|
146
|
+
resource: v1.ObjectReference.create({
|
|
147
|
+
objectType: t.resourceType,
|
|
148
|
+
objectId: t.resourceId,
|
|
149
|
+
}),
|
|
150
|
+
relation: t.relation,
|
|
151
|
+
subject: v1.SubjectReference.create({
|
|
152
|
+
object: v1.ObjectReference.create({
|
|
153
|
+
objectType: t.subjectType,
|
|
154
|
+
objectId: t.subjectId,
|
|
155
|
+
}),
|
|
156
|
+
}),
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Bulk import relationships using the streaming ImportBulkRelationships RPC.
|
|
162
|
+
* More efficient than individual writeRelationships calls for large batches.
|
|
163
|
+
* Falls back to batched writeRelationships if the streaming RPC is unavailable.
|
|
164
|
+
*/
|
|
165
|
+
async bulkImportRelationships(
|
|
166
|
+
tuples: RelationshipTuple[],
|
|
167
|
+
batchSize = 1000,
|
|
168
|
+
): Promise<number> {
|
|
169
|
+
if (tuples.length === 0) return 0;
|
|
170
|
+
|
|
171
|
+
// Try streaming bulk import first
|
|
172
|
+
if (typeof this.promises.bulkImportRelationships === "function") {
|
|
173
|
+
return this.bulkImportViaStream(tuples, batchSize);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Fallback: batched writeRelationships
|
|
177
|
+
return this.bulkImportViaWrite(tuples, batchSize);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private bulkImportViaStream(
|
|
181
|
+
tuples: RelationshipTuple[],
|
|
182
|
+
batchSize: number,
|
|
183
|
+
): Promise<number> {
|
|
184
|
+
return new Promise((resolve, reject) => {
|
|
185
|
+
const stream = this.promises.bulkImportRelationships(
|
|
186
|
+
(err: Error | null, response?: { numLoaded?: string }) => {
|
|
187
|
+
if (err) reject(err);
|
|
188
|
+
else resolve(Number(response?.numLoaded ?? tuples.length));
|
|
189
|
+
},
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
stream.on("error", (err: Error) => {
|
|
193
|
+
reject(err);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
for (let i = 0; i < tuples.length; i += batchSize) {
|
|
197
|
+
const chunk = tuples.slice(i, i + batchSize);
|
|
198
|
+
stream.write(
|
|
199
|
+
v1.BulkImportRelationshipsRequest.create({
|
|
200
|
+
relationships: chunk.map((t) => this.toRelationship(t)),
|
|
201
|
+
}),
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
stream.end();
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private async bulkImportViaWrite(
|
|
210
|
+
tuples: RelationshipTuple[],
|
|
211
|
+
batchSize: number,
|
|
212
|
+
): Promise<number> {
|
|
213
|
+
let total = 0;
|
|
214
|
+
for (let i = 0; i < tuples.length; i += batchSize) {
|
|
215
|
+
const chunk = tuples.slice(i, i + batchSize);
|
|
216
|
+
await this.writeRelationships(chunk);
|
|
217
|
+
total += chunk.length;
|
|
218
|
+
}
|
|
219
|
+
return total;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// --------------------------------------------------------------------------
|
|
223
|
+
// Read Relationships
|
|
224
|
+
// --------------------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Read relationships matching a filter. Returns all tuples that match the
|
|
228
|
+
* specified resource type, optional resource ID, optional relation, and
|
|
229
|
+
* optional subject filter. Used by the cleanup command to find which
|
|
230
|
+
* Graphiti episodes have SpiceDB authorization relationships.
|
|
231
|
+
*/
|
|
232
|
+
async readRelationships(params: {
|
|
233
|
+
resourceType: string;
|
|
234
|
+
resourceId?: string;
|
|
235
|
+
relation?: string;
|
|
236
|
+
subjectType?: string;
|
|
237
|
+
subjectId?: string;
|
|
238
|
+
consistency?: ConsistencyMode;
|
|
239
|
+
}): Promise<RelationshipTuple[]> {
|
|
240
|
+
const filterFields: Record<string, unknown> = {
|
|
241
|
+
resourceType: params.resourceType,
|
|
242
|
+
};
|
|
243
|
+
if (params.resourceId) {
|
|
244
|
+
filterFields.optionalResourceId = params.resourceId;
|
|
245
|
+
}
|
|
246
|
+
if (params.relation) {
|
|
247
|
+
filterFields.optionalRelation = params.relation;
|
|
248
|
+
}
|
|
249
|
+
if (params.subjectType) {
|
|
250
|
+
const subjectFilter: Record<string, unknown> = {
|
|
251
|
+
subjectType: params.subjectType,
|
|
252
|
+
};
|
|
253
|
+
if (params.subjectId) {
|
|
254
|
+
subjectFilter.optionalSubjectId = params.subjectId;
|
|
255
|
+
}
|
|
256
|
+
filterFields.optionalSubjectFilter = v1.SubjectFilter.create(subjectFilter);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const request = v1.ReadRelationshipsRequest.create({
|
|
260
|
+
relationshipFilter: v1.RelationshipFilter.create(filterFields),
|
|
261
|
+
consistency: this.buildConsistency(params.consistency),
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
const results = await this.promises.readRelationships(request);
|
|
265
|
+
const tuples: RelationshipTuple[] = [];
|
|
266
|
+
for (const r of results) {
|
|
267
|
+
const rel = r.relationship;
|
|
268
|
+
if (!rel?.resource || !rel.subject?.object) continue;
|
|
269
|
+
tuples.push({
|
|
270
|
+
resourceType: rel.resource.objectType,
|
|
271
|
+
resourceId: rel.resource.objectId,
|
|
272
|
+
relation: rel.relation,
|
|
273
|
+
subjectType: rel.subject.object.objectType,
|
|
274
|
+
subjectId: rel.subject.object.objectId,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
return tuples;
|
|
278
|
+
}
|
|
279
|
+
|
|
116
280
|
// --------------------------------------------------------------------------
|
|
117
281
|
// Permissions
|
|
118
282
|
// --------------------------------------------------------------------------
|
|
119
283
|
|
|
284
|
+
private buildConsistency(mode?: ConsistencyMode) {
|
|
285
|
+
if (!mode || mode.mode === "minimize_latency") {
|
|
286
|
+
return v1.Consistency.create({
|
|
287
|
+
requirement: { oneofKind: "minimizeLatency", minimizeLatency: true },
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
if (mode.mode === "at_least_as_fresh") {
|
|
291
|
+
return v1.Consistency.create({
|
|
292
|
+
requirement: {
|
|
293
|
+
oneofKind: "atLeastAsFresh",
|
|
294
|
+
atLeastAsFresh: v1.ZedToken.create({ token: mode.token }),
|
|
295
|
+
},
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
return v1.Consistency.create({
|
|
299
|
+
requirement: { oneofKind: "fullyConsistent", fullyConsistent: true },
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
120
303
|
async checkPermission(params: {
|
|
121
304
|
resourceType: string;
|
|
122
305
|
resourceId: string;
|
|
123
306
|
permission: string;
|
|
124
307
|
subjectType: string;
|
|
125
308
|
subjectId: string;
|
|
309
|
+
consistency?: ConsistencyMode;
|
|
126
310
|
}): Promise<boolean> {
|
|
127
311
|
const request = v1.CheckPermissionRequest.create({
|
|
128
312
|
resource: v1.ObjectReference.create({
|
|
@@ -136,9 +320,7 @@ export class SpiceDbClient {
|
|
|
136
320
|
objectId: params.subjectId,
|
|
137
321
|
}),
|
|
138
322
|
}),
|
|
139
|
-
consistency:
|
|
140
|
-
requirement: { oneofKind: "fullyConsistent", fullyConsistent: true },
|
|
141
|
-
}),
|
|
323
|
+
consistency: this.buildConsistency(params.consistency),
|
|
142
324
|
});
|
|
143
325
|
|
|
144
326
|
const response = await this.promises.checkPermission(request);
|
|
@@ -153,6 +335,7 @@ export class SpiceDbClient {
|
|
|
153
335
|
permission: string;
|
|
154
336
|
subjectType: string;
|
|
155
337
|
subjectId: string;
|
|
338
|
+
consistency?: ConsistencyMode;
|
|
156
339
|
}): Promise<string[]> {
|
|
157
340
|
const request = v1.LookupResourcesRequest.create({
|
|
158
341
|
resourceObjectType: params.resourceType,
|
|
@@ -163,9 +346,7 @@ export class SpiceDbClient {
|
|
|
163
346
|
objectId: params.subjectId,
|
|
164
347
|
}),
|
|
165
348
|
}),
|
|
166
|
-
consistency:
|
|
167
|
-
requirement: { oneofKind: "fullyConsistent", fullyConsistent: true },
|
|
168
|
-
}),
|
|
349
|
+
consistency: this.buildConsistency(params.consistency),
|
|
169
350
|
});
|
|
170
351
|
|
|
171
352
|
const results = await this.promises.lookupResources(request);
|