@contextableai/openclaw-memory-rebac 0.3.4 → 0.3.5

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/backend.d.ts CHANGED
@@ -74,7 +74,7 @@ export interface MemoryBackend {
74
74
  }): Promise<StoreResult>;
75
75
  /**
76
76
  * Search within a single group's storage partition.
77
- * Called in parallel per-group by searchAuthorizedMemories() in search.ts.
77
+ * Fallback path when searchGroups() is not available.
78
78
  * Backends map their native result shape to SearchResult[].
79
79
  */
80
80
  searchGroup(params: {
@@ -83,6 +83,19 @@ export interface MemoryBackend {
83
83
  limit: number;
84
84
  sessionId?: string;
85
85
  }): Promise<SearchResult[]>;
86
+ /**
87
+ * Search across multiple groups in a single backend call.
88
+ * Preferred over per-group fan-out because the backend can apply its own
89
+ * cross-group relevance ranking (e.g. Graphiti's RRF: cosine + BM25).
90
+ * Results are ordered by relevance; response position is used as implicit score.
91
+ * Optional: falls back to per-group searchGroup() fan-out if not implemented.
92
+ */
93
+ searchGroups?(params: {
94
+ query: string;
95
+ groupIds: string[];
96
+ limit: number;
97
+ sessionId?: string;
98
+ }): Promise<SearchResult[]>;
86
99
  /**
87
100
  * Optional backend-specific session enrichment, called from agent_end
88
101
  * after store() for conversation auto-capture.
@@ -26,6 +26,7 @@ type FactResult = {
26
26
  invalid_at: string | null;
27
27
  created_at: string;
28
28
  expired_at: string | null;
29
+ group_id?: string;
29
30
  };
30
31
  export type GraphitiConfig = {
31
32
  endpoint: string;
@@ -56,6 +57,12 @@ export declare class GraphitiBackend implements MemoryBackend {
56
57
  limit: number;
57
58
  sessionId?: string;
58
59
  }): Promise<SearchResult[]>;
60
+ searchGroups(params: {
61
+ query: string;
62
+ groupIds: string[];
63
+ limit: number;
64
+ sessionId?: string;
65
+ }): Promise<SearchResult[]>;
59
66
  getConversationHistory(sessionId: string, lastN?: number): Promise<ConversationTurn[]>;
60
67
  healthCheck(): Promise<boolean>;
61
68
  getStatus(): Promise<Record<string, unknown>>;
@@ -107,6 +107,29 @@ export class GraphitiBackend {
107
107
  created_at: f.created_at,
108
108
  }));
109
109
  }
110
+ async searchGroups(params) {
111
+ const { query, groupIds, limit } = params;
112
+ if (groupIds.length === 0)
113
+ return [];
114
+ const searchRequest = {
115
+ group_ids: groupIds,
116
+ query,
117
+ max_facts: limit,
118
+ };
119
+ const response = await this.restCall("POST", "/search", searchRequest);
120
+ const facts = response.facts ?? [];
121
+ // Derive implicit relevance score from response position —
122
+ // Graphiti's /search returns results ranked by RRF (cosine + BM25).
123
+ return facts.map((f, index) => ({
124
+ type: "fact",
125
+ uuid: f.uuid,
126
+ group_id: f.group_id ?? groupIds[0],
127
+ summary: f.fact,
128
+ context: f.name,
129
+ created_at: f.created_at,
130
+ score: 1.0 - index / Math.max(facts.length, 1),
131
+ }));
132
+ }
110
133
  async getConversationHistory(sessionId, lastN = 10) {
111
134
  const sessionGroup = `session-${sessionId.replace(/[^a-zA-Z0-9_-]/g, "-")}`;
112
135
  try {
package/dist/index.js CHANGED
@@ -22,7 +22,7 @@ import { fileURLToPath } from "node:url";
22
22
  import { rebacMemoryConfigSchema, createBackend, defaultGroupId } from "./config.js";
23
23
  import { SpiceDbClient } from "./spicedb.js";
24
24
  import { lookupAuthorizedGroups, lookupViewableFragments, lookupFragmentSourceGroups, lookupAgentOwner, writeFragmentRelationships, deleteFragmentRelationships, canDeleteFragment, canWriteToGroup, ensureGroupMembership, } from "./authorization.js";
25
- import { searchAuthorizedMemories, formatDualResults, deduplicateSessionResults, } from "./search.js";
25
+ import { searchAuthorizedMemories, formatDualResults, } from "./search.js";
26
26
  import { registerCommands } from "./cli.js";
27
27
  // ============================================================================
28
28
  // Session helpers
@@ -131,27 +131,21 @@ const rebacMemoryPlugin = {
131
131
  sessionGroups.push(sg);
132
132
  }
133
133
  }
134
- // Group-based search (existing path)
135
- const [longTermResults, rawSessionResults] = await Promise.all([
136
- longTermGroups.length > 0
137
- ? searchAuthorizedMemories(backend, {
138
- query,
139
- groupIds: longTermGroups,
140
- limit,
141
- sessionId: state.sessionId,
142
- })
143
- : Promise.resolve([]),
144
- sessionGroups.length > 0
145
- ? searchAuthorizedMemories(backend, {
146
- query,
147
- groupIds: sessionGroups,
148
- limit,
149
- sessionId: state.sessionId,
150
- })
151
- : Promise.resolve([]),
152
- ]);
153
- const sessionResults = deduplicateSessionResults(longTermResults, rawSessionResults);
154
- const groupResults = [...longTermResults, ...sessionResults];
134
+ // Single multi-group search lets the backend rank all results together
135
+ const allGroups = [...longTermGroups, ...sessionGroups];
136
+ const allGroupResults = allGroups.length > 0
137
+ ? await searchAuthorizedMemories(backend, {
138
+ query,
139
+ groupIds: allGroups,
140
+ limit,
141
+ sessionId: state.sessionId,
142
+ })
143
+ : [];
144
+ // Split results by group type for formatting
145
+ const sessionGroupSet = new Set(sessionGroups);
146
+ const longTermResults = allGroupResults.filter((r) => !sessionGroupSet.has(r.group_id));
147
+ const sessionResults = allGroupResults.filter((r) => sessionGroupSet.has(r.group_id));
148
+ const groupResults = allGroupResults;
155
149
  // Owner-aware fragment search: if the subject is an agent with an owner,
156
150
  // also find fragments where the owner is in `involves`.
157
151
  // Uses search-then-post-filter: search the source groups for query relevance,
@@ -477,26 +471,20 @@ const rebacMemoryPlugin = {
477
471
  if (!sessionGroups.includes(sg))
478
472
  sessionGroups.push(sg);
479
473
  }
480
- const [longTermResults, rawSessionResults] = await Promise.all([
481
- longTermGroups.length > 0
482
- ? searchAuthorizedMemories(backend, {
483
- query: event.prompt,
484
- groupIds: longTermGroups,
485
- limit: 5,
486
- sessionId: state.sessionId,
487
- })
488
- : Promise.resolve([]),
489
- sessionGroups.length > 0
490
- ? searchAuthorizedMemories(backend, {
491
- query: event.prompt,
492
- groupIds: sessionGroups,
493
- limit: 3,
494
- sessionId: state.sessionId,
495
- })
496
- : Promise.resolve([]),
497
- ]);
498
- const sessionResults = deduplicateSessionResults(longTermResults, rawSessionResults);
499
- const totalCount = longTermResults.length + sessionResults.length;
474
+ const autoRecallLimit = 8;
475
+ const allGroups = [...longTermGroups, ...sessionGroups];
476
+ const allResults = allGroups.length > 0
477
+ ? await searchAuthorizedMemories(backend, {
478
+ query: event.prompt,
479
+ groupIds: allGroups,
480
+ limit: autoRecallLimit,
481
+ sessionId: state.sessionId,
482
+ })
483
+ : [];
484
+ const sessionGroupSet = new Set(sessionGroups);
485
+ const longTermResults = allResults.filter((r) => !sessionGroupSet.has(r.group_id));
486
+ const sessionResults = allResults.filter((r) => sessionGroupSet.has(r.group_id));
487
+ const totalCount = allResults.length;
500
488
  const toolHint = "<memory-tools>\n" +
501
489
  "You have knowledge-graph memory tools. Use them proactively:\n" +
502
490
  "- memory_recall: Search for facts, preferences, people, decisions, or past context. Use this BEFORE saying you don't know or remember something.\n" +
package/dist/search.d.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  /**
2
- * Parallel Multi-Group Search + Merge/Re-rank
2
+ * Multi-Group Search with backend-native relevance ranking.
3
3
  *
4
- * Backend-agnostic: delegates per-group search to MemoryBackend.searchGroup().
5
- * Issues parallel calls (one per authorized group_id), merges results,
6
- * deduplicates by UUID, and re-ranks by score then recency.
4
+ * Prefers MemoryBackend.searchGroups() a single call with all group_ids
5
+ * so the backend applies cross-group relevance ranking (e.g. Graphiti RRF).
6
+ * Falls back to per-group fan-out via searchGroup() when unavailable.
7
7
  */
8
8
  import type { MemoryBackend, SearchResult } from "./backend.js";
9
9
  export type { SearchResult };
@@ -14,9 +14,14 @@ export type SearchOptions = {
14
14
  sessionId?: string;
15
15
  };
16
16
  /**
17
- * Search across multiple authorized group_ids in parallel.
18
- * Merges and deduplicates results, returning up to `limit` items sorted by
19
- * score (desc) then recency (desc).
17
+ * Search across multiple authorized group_ids.
18
+ *
19
+ * Prefers backend.searchGroups() when available — sends all group_ids in a
20
+ * single call so the backend can apply cross-group relevance ranking
21
+ * (e.g. Graphiti's RRF: cosine similarity + BM25).
22
+ *
23
+ * Falls back to per-group fan-out via backend.searchGroup() when the backend
24
+ * doesn't implement multi-group search.
20
25
  */
21
26
  export declare function searchAuthorizedMemories(backend: MemoryBackend, options: SearchOptions): Promise<SearchResult[]>;
22
27
  /**
@@ -28,7 +33,3 @@ export declare function formatResultsForContext(results: SearchResult[]): string
28
33
  * Session group_ids start with "session-".
29
34
  */
30
35
  export declare function formatDualResults(longTermResults: SearchResult[], sessionResults: SearchResult[]): string;
31
- /**
32
- * Deduplicate session results against long-term results (by UUID).
33
- */
34
- export declare function deduplicateSessionResults(longTermResults: SearchResult[], sessionResults: SearchResult[]): SearchResult[];
package/dist/search.js CHANGED
@@ -1,24 +1,41 @@
1
1
  /**
2
- * Parallel Multi-Group Search + Merge/Re-rank
2
+ * Multi-Group Search with backend-native relevance ranking.
3
3
  *
4
- * Backend-agnostic: delegates per-group search to MemoryBackend.searchGroup().
5
- * Issues parallel calls (one per authorized group_id), merges results,
6
- * deduplicates by UUID, and re-ranks by score then recency.
4
+ * Prefers MemoryBackend.searchGroups() a single call with all group_ids
5
+ * so the backend applies cross-group relevance ranking (e.g. Graphiti RRF).
6
+ * Falls back to per-group fan-out via searchGroup() when unavailable.
7
7
  */
8
8
  // ============================================================================
9
9
  // Search
10
10
  // ============================================================================
11
11
  /**
12
- * Search across multiple authorized group_ids in parallel.
13
- * Merges and deduplicates results, returning up to `limit` items sorted by
14
- * score (desc) then recency (desc).
12
+ * Search across multiple authorized group_ids.
13
+ *
14
+ * Prefers backend.searchGroups() when available — sends all group_ids in a
15
+ * single call so the backend can apply cross-group relevance ranking
16
+ * (e.g. Graphiti's RRF: cosine similarity + BM25).
17
+ *
18
+ * Falls back to per-group fan-out via backend.searchGroup() when the backend
19
+ * doesn't implement multi-group search.
15
20
  */
16
21
  export async function searchAuthorizedMemories(backend, options) {
17
22
  const { query, groupIds, limit = 10, sessionId } = options;
18
23
  if (groupIds.length === 0) {
19
24
  return [];
20
25
  }
21
- // Fan out parallel searches across all authorized groups
26
+ // Prefer single multi-group search preserves backend relevance ranking
27
+ if (backend.searchGroups) {
28
+ const results = await backend.searchGroups({ query, groupIds, limit, sessionId });
29
+ // Deduplicate by UUID (defensive — single call shouldn't produce dupes)
30
+ const seen = new Set();
31
+ return results.filter((r) => {
32
+ if (seen.has(r.uuid))
33
+ return false;
34
+ seen.add(r.uuid);
35
+ return true;
36
+ }).slice(0, limit);
37
+ }
38
+ // Fallback: fan out parallel searches across all authorized groups
22
39
  const promises = groupIds.map((groupId) => backend.searchGroup({ query, groupId, limit, sessionId }));
23
40
  const resultSets = await Promise.allSettled(promises);
24
41
  // Collect all successful results — silently skip failed group searches
@@ -89,10 +106,3 @@ function formatResultLine(r, idx) {
89
106
  "completion";
90
107
  return `${idx}. [${typeLabel}:${r.uuid}] ${r.summary} (${r.context})`;
91
108
  }
92
- /**
93
- * Deduplicate session results against long-term results (by UUID).
94
- */
95
- export function deduplicateSessionResults(longTermResults, sessionResults) {
96
- const longTermIds = new Set(longTermResults.map((r) => r.uuid));
97
- return sessionResults.filter((r) => !longTermIds.has(r.uuid));
98
- }
@@ -5,9 +5,19 @@
5
5
  # Extends the base zepai/graphiti image to:
6
6
  # 1. Install sentence-transformers for BGE reranker
7
7
  # 2. Overlay per-component config + startup to wire separate clients
8
+ #
9
+ # UPGRADE AUDIT (vs upstream getzep/graphiti v0.28.2, audited 2026-03-21):
10
+ # Overlay patches in startup.py — 5 of 6 still needed:
11
+ # [NEEDED] AsyncWorker crash-on-error recovery (startup.py:109-126)
12
+ # [NEEDED] Neo4j nested attribute sanitization (startup.py:128-250)
13
+ # [SAFE] None-index extract_edges fix (startup.py:252-298)
14
+ # — fixed upstream (name-based model); patch self-disables
15
+ # [NEEDED] IS_DUPLICATE_OF edge filtering (startup.py:152-169,216-227,310-318)
16
+ # [NEEDED] Self-referential edge filtering (startup.py:228-239)
17
+ # [NEEDED] Singleton client / per-request fix (startup.py:38-87)
8
18
  ###############################################################################
9
19
 
10
- FROM zepai/graphiti:latest
20
+ FROM zepai/graphiti:0.22.0
11
21
 
12
22
  # Base image runs as non-root "app" user; switch to root to install packages
13
23
  USER root
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contextableai/openclaw-memory-rebac",
3
- "version": "0.3.4",
3
+ "version": "0.3.5",
4
4
  "description": "OpenClaw two-layer memory plugin: SpiceDB ReBAC authorization + Graphiti knowledge graph",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -43,16 +43,16 @@
43
43
  "test:e2e": "OPENCLAW_LIVE_TEST=1 vitest run --config vitest.e2e.config.ts"
44
44
  },
45
45
  "dependencies": {
46
- "@authzed/authzed-node": "^1.2.0",
47
- "@grpc/grpc-js": "^1.14.3",
46
+ "@authzed/authzed-node": "1.6.1",
47
+ "@grpc/grpc-js": "1.14.3",
48
48
  "@sinclair/typebox": "0.34.48",
49
- "commander": "^13.1.0"
49
+ "commander": "13.1.0"
50
50
  },
51
51
  "devDependencies": {
52
- "dotenv": "^17.3.1",
52
+ "dotenv": "17.3.1",
53
53
  "openclaw": "*",
54
- "typescript": "^5.9.3",
55
- "vitest": "^4.0.18"
54
+ "typescript": "5.9.3",
55
+ "vitest": "4.0.18"
56
56
  },
57
57
  "publishConfig": {
58
58
  "access": "public"
@@ -62,7 +62,7 @@
62
62
  "./dist/index.js"
63
63
  ],
64
64
  "install": {
65
- "npmSpec": "@contextableai/openclaw-memory-rebac",
65
+ "npmSpec": "@contextableai/openclaw-memory-rebac@0.3.5",
66
66
  "localPath": "extensions/openclaw-memory-rebac",
67
67
  "defaultChoice": "npm"
68
68
  }