@contextableai/openclaw-memory-graphiti 0.1.2 → 0.2.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.
@@ -27,6 +27,18 @@
27
27
  "placeholder": "main",
28
28
  "help": "Default Graphiti group_id for memory isolation"
29
29
  },
30
+ "graphiti.uuidPollIntervalMs": {
31
+ "label": "UUID Poll Interval (ms)",
32
+ "placeholder": "3000",
33
+ "help": "Milliseconds between polling attempts when resolving episode UUIDs after memory_store (default: 3000)",
34
+ "advanced": true
35
+ },
36
+ "graphiti.uuidPollMaxAttempts": {
37
+ "label": "UUID Poll Max Attempts",
38
+ "placeholder": "30",
39
+ "help": "Maximum polling attempts for episode UUID resolution; total timeout = interval × attempts (default: 30 = 90s)",
40
+ "advanced": true
41
+ },
30
42
  "subjectType": {
31
43
  "label": "Subject Type",
32
44
  "placeholder": "agent",
@@ -76,7 +88,9 @@
76
88
  "additionalProperties": false,
77
89
  "properties": {
78
90
  "endpoint": { "type": "string" },
79
- "defaultGroupId": { "type": "string" }
91
+ "defaultGroupId": { "type": "string" },
92
+ "uuidPollIntervalMs": { "type": "integer", "minimum": 500, "maximum": 30000 },
93
+ "uuidPollMaxAttempts": { "type": "integer", "minimum": 1, "maximum": 200 }
80
94
  }
81
95
  },
82
96
  "subjectType": { "type": "string", "enum": ["agent", "person"] },
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.1",
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",
@@ -13,6 +13,7 @@
13
13
  # SPICEDB_DB_URI Postgres connection URI (when SPICEDB_DATASTORE=postgres)
14
14
  # FALKORDB_PORT Redis port (default: 6379)
15
15
  # GRAPHITI_PORT HTTP port (default: 8000)
16
+ # EPISODE_ID_PREFIX Prefix for Graphiti episode UUIDs (default: epi-)
16
17
  # -------------------------------------------------------------------
17
18
  set -euo pipefail
18
19
 
@@ -63,6 +64,8 @@ if is_running "$DEV_DIR/pids/falkordb.pid"; then
63
64
  echo "==> FalkorDB already running (pid $(cat "$DEV_DIR/pids/falkordb.pid"))"
64
65
  else
65
66
  echo "==> Starting FalkorDB on port $FALKORDB_PORT..."
67
+ # Redis 8.x requires a valid locale; default to C.utf8 if LANG is unset
68
+ export LC_ALL="${LC_ALL:-C.utf8}"
66
69
  redis-server \
67
70
  --loadmodule "$DEV_DIR/lib/falkordb.so" \
68
71
  --port "$FALKORDB_PORT" \
@@ -190,6 +193,7 @@ else
190
193
  # Set environment for Graphiti
191
194
  export OPENAI_API_KEY="${OPENAI_API_KEY:-}"
192
195
  export FALKORDB_URI="redis://localhost:$FALKORDB_PORT"
196
+ export EPISODE_ID_PREFIX="${EPISODE_ID_PREFIX:-epi-}"
193
197
 
194
198
  cd "$GRAPHITI_DIR"
195
199
  uv run main.py \
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
 
@@ -126,9 +138,10 @@ function nodeToResult(node: GraphitiNode): SearchResult {
126
138
  }
127
139
 
128
140
  function factToResult(fact: GraphitiFact): SearchResult {
129
- // Use node names if available, fall back to relationship name or UUIDs
130
- const source = fact.source_node_name ?? fact.source_node_uuid ?? "?";
131
- const target = fact.target_node_name ?? fact.target_node_uuid ?? "?";
141
+ // Use node names only never expose raw node UUIDs in the formatted output.
142
+ // Bare UUIDs in context strings cause LLMs to confuse node IDs with fact/episode IDs.
143
+ const source = fact.source_node_name ?? "?";
144
+ const target = fact.target_node_name ?? "?";
132
145
  const context = fact.name ? `${source} -[${fact.name}]→ ${target}` : `${source} → ${target}`;
133
146
  return {
134
147
  type: "fact",
@@ -152,12 +165,7 @@ export function formatResultsForContext(results: SearchResult[]): string {
152
165
  return "";
153
166
  }
154
167
 
155
- return results
156
- .map((r, i) => {
157
- const typeLabel = r.type === "node" ? "entity" : "fact";
158
- return `${i + 1}. [${typeLabel}] ${r.summary} (${r.context})`;
159
- })
160
- .join("\n");
168
+ return results.map((r, i) => formatResultLine(r, i + 1)).join("\n");
161
169
  }
162
170
 
163
171
  /**
@@ -173,22 +181,30 @@ export function formatDualResults(
173
181
 
174
182
  if (longTermResults.length > 0) {
175
183
  for (const r of longTermResults) {
176
- const typeLabel = r.type === "node" ? "entity" : "fact";
177
- parts.push(`${idx++}. [${typeLabel}] ${r.summary} (${r.context})`);
184
+ parts.push(formatResultLine(r, idx++));
178
185
  }
179
186
  }
180
187
 
181
188
  if (sessionResults.length > 0) {
182
189
  parts.push("Session memories:");
183
190
  for (const r of sessionResults) {
184
- const typeLabel = r.type === "node" ? "entity" : "fact";
185
- parts.push(`${idx++}. [${typeLabel}] ${r.summary} (${r.context})`);
191
+ parts.push(formatResultLine(r, idx++));
186
192
  }
187
193
  }
188
194
 
189
195
  return parts.join("\n");
190
196
  }
191
197
 
198
+ /**
199
+ * Format a single search result line with type-prefixed UUID.
200
+ * e.g. "[fact:da8650cb-...] Eric's birthday is Dec 17th (Eric -[HAS_BIRTHDAY]→ Dec 17th)"
201
+ * The type prefix tells the LLM which deletion method to use.
202
+ */
203
+ function formatResultLine(r: SearchResult, idx: number): string {
204
+ const typeLabel = r.type === "node" ? "entity" : "fact";
205
+ return `${idx}. [${typeLabel}:${r.uuid}] ${r.summary} (${r.context})`;
206
+ }
207
+
192
208
  /**
193
209
  * Deduplicate session results against long-term results (by UUID).
194
210
  */
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);