@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contextableai/openclaw-memory-graphiti",
3
- "version": "0.1.2",
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
- "extensions": [
48
- "./index.ts"
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 { query, groupIds, limit = 10, searchNodes = true, searchFacts = true } = options;
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, LookupResources, CheckPermission.
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<void> {
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: v1.Consistency.create({
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: v1.Consistency.create({
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);