@contextableai/openclaw-memory-rebac 0.3.3 → 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/authorization.d.ts +6 -0
- package/dist/authorization.js +21 -0
- package/dist/backend.d.ts +14 -7
- package/dist/backends/graphiti.d.ts +7 -1
- package/dist/backends/graphiti.js +23 -20
- package/dist/cli.js +39 -28
- package/dist/index.js +48 -45
- package/dist/search.d.ts +12 -11
- package/dist/search.js +25 -15
- package/docker/graphiti/Dockerfile +11 -1
- package/package.json +8 -8
package/dist/authorization.d.ts
CHANGED
|
@@ -55,6 +55,12 @@ export declare function canDeleteFragment(spicedb: SpiceDbClient, subject: Subje
|
|
|
55
55
|
* Used to gate writes to non-session groups — prevents unauthorized memory injection.
|
|
56
56
|
*/
|
|
57
57
|
export declare function canWriteToGroup(spicedb: SpiceDbClient, subject: Subject, groupId: string, zedToken?: string): Promise<boolean>;
|
|
58
|
+
/**
|
|
59
|
+
* Discover which groups a set of memory fragments belong to.
|
|
60
|
+
* Reads the `source_group` relation for each fragment ID.
|
|
61
|
+
* Returns deduplicated group IDs.
|
|
62
|
+
*/
|
|
63
|
+
export declare function lookupFragmentSourceGroups(spicedb: SpiceDbClient, fragmentIds: string[], zedToken?: string): Promise<string[]>;
|
|
58
64
|
/**
|
|
59
65
|
* Ensure a subject is registered as a member of a group.
|
|
60
66
|
* Idempotent (uses TOUCH operation).
|
package/dist/authorization.js
CHANGED
|
@@ -130,6 +130,27 @@ export async function canWriteToGroup(spicedb, subject, groupId, zedToken) {
|
|
|
130
130
|
consistency: tokenConsistency(zedToken),
|
|
131
131
|
});
|
|
132
132
|
}
|
|
133
|
+
/**
|
|
134
|
+
* Discover which groups a set of memory fragments belong to.
|
|
135
|
+
* Reads the `source_group` relation for each fragment ID.
|
|
136
|
+
* Returns deduplicated group IDs.
|
|
137
|
+
*/
|
|
138
|
+
export async function lookupFragmentSourceGroups(spicedb, fragmentIds, zedToken) {
|
|
139
|
+
const groupIds = new Set();
|
|
140
|
+
for (const fid of fragmentIds) {
|
|
141
|
+
const tuples = await spicedb.readRelationships({
|
|
142
|
+
resourceType: "memory_fragment",
|
|
143
|
+
resourceId: fid,
|
|
144
|
+
relation: "source_group",
|
|
145
|
+
consistency: tokenConsistency(zedToken),
|
|
146
|
+
});
|
|
147
|
+
for (const t of tuples) {
|
|
148
|
+
if (t.subjectType === "group")
|
|
149
|
+
groupIds.add(t.subjectId);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return Array.from(groupIds);
|
|
153
|
+
}
|
|
133
154
|
/**
|
|
134
155
|
* Ensure a subject is registered as a member of a group.
|
|
135
156
|
* Idempotent (uses TOUCH operation).
|
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
|
-
*
|
|
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.
|
|
@@ -118,12 +131,6 @@ export interface MemoryBackend {
|
|
|
118
131
|
* Returns true if deleted, false if the backend doesn't support it.
|
|
119
132
|
*/
|
|
120
133
|
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[]>;
|
|
127
134
|
/**
|
|
128
135
|
* Discover fragment (fact/edge) UUIDs that were extracted from a stored episode.
|
|
129
136
|
* Called after store() resolves the episode ID to write per-fragment SpiceDB
|
|
@@ -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>>;
|
|
@@ -63,7 +70,6 @@ export declare class GraphitiBackend implements MemoryBackend {
|
|
|
63
70
|
listGroups(): Promise<BackendDataset[]>;
|
|
64
71
|
deleteFragment(uuid: string, type?: string): Promise<boolean>;
|
|
65
72
|
getEpisodes(groupId: string, lastN: number): Promise<GraphitiEpisode[]>;
|
|
66
|
-
getFragmentsByIds(ids: string[]): Promise<SearchResult[]>;
|
|
67
73
|
discoverFragmentIds(episodeId: string): Promise<string[]>;
|
|
68
74
|
getEntityEdge(uuid: string): Promise<FactResult>;
|
|
69
75
|
registerCliCommands(cmd: Command): void;
|
|
@@ -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 {
|
|
@@ -159,26 +182,6 @@ export class GraphitiBackend {
|
|
|
159
182
|
async getEpisodes(groupId, lastN) {
|
|
160
183
|
return this.restCall("GET", `/episodes/${encodeURIComponent(groupId)}?last_n=${lastN}`);
|
|
161
184
|
}
|
|
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
|
-
}
|
|
182
185
|
async discoverFragmentIds(episodeId) {
|
|
183
186
|
const edges = await this.restCall("GET", `/episodes/${encodeURIComponent(episodeId)}/edges`);
|
|
184
187
|
return edges.map((e) => e.uuid);
|
package/dist/cli.js
CHANGED
|
@@ -14,7 +14,7 @@ import { join, dirname, basename, resolve } from "node:path";
|
|
|
14
14
|
import { homedir } from "node:os";
|
|
15
15
|
import { fileURLToPath } from "node:url";
|
|
16
16
|
import { defaultGroupId } from "./config.js";
|
|
17
|
-
import { lookupAuthorizedGroups, lookupAgentOwner, lookupViewableFragments, ensureGroupMembership, } from "./authorization.js";
|
|
17
|
+
import { lookupAuthorizedGroups, lookupAgentOwner, lookupViewableFragments, lookupFragmentSourceGroups, ensureGroupMembership, } from "./authorization.js";
|
|
18
18
|
import { searchAuthorizedMemories } from "./search.js";
|
|
19
19
|
// ============================================================================
|
|
20
20
|
// Session helper (mirrors index.ts — no shared module to avoid circular import)
|
|
@@ -71,39 +71,50 @@ export function registerCommands(cmd, ctx) {
|
|
|
71
71
|
})
|
|
72
72
|
: [];
|
|
73
73
|
// Fragment-based recall via involves/view permission
|
|
74
|
+
// Uses search-then-post-filter: search source groups for query relevance,
|
|
75
|
+
// then intersect with authorized fragment set for security.
|
|
74
76
|
let fragmentResults = [];
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const ownerId = await lookupAgentOwner(spicedb, subject.id, token);
|
|
86
|
-
if (ownerId) {
|
|
87
|
-
personSubject = { type: "person", id: ownerId };
|
|
88
|
-
}
|
|
77
|
+
try {
|
|
78
|
+
// Determine the person to search as
|
|
79
|
+
let personSubject;
|
|
80
|
+
if (subject.type === "person") {
|
|
81
|
+
personSubject = subject;
|
|
82
|
+
}
|
|
83
|
+
else if (subject.type === "agent") {
|
|
84
|
+
const ownerId = await lookupAgentOwner(spicedb, subject.id, token);
|
|
85
|
+
if (ownerId) {
|
|
86
|
+
personSubject = { type: "person", id: ownerId };
|
|
89
87
|
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
88
|
+
}
|
|
89
|
+
if (personSubject) {
|
|
90
|
+
const viewableIds = await lookupViewableFragments(spicedb, personSubject, token);
|
|
91
|
+
if (viewableIds.length > 0) {
|
|
92
|
+
const groupResultIds = new Set(groupResults.map((r) => r.uuid));
|
|
93
|
+
const newIds = viewableIds.filter((id) => !groupResultIds.has(id));
|
|
94
|
+
if (newIds.length > 0) {
|
|
95
|
+
// Discover which groups the viewable fragments belong to
|
|
96
|
+
const ownerGroups = await lookupFragmentSourceGroups(spicedb, newIds, token);
|
|
97
|
+
const alreadySearched = new Set(authorizedGroups);
|
|
98
|
+
const newGroups = ownerGroups.filter(g => !alreadySearched.has(g));
|
|
99
|
+
if (newGroups.length > 0) {
|
|
100
|
+
const candidateResults = await searchAuthorizedMemories(backend, {
|
|
101
|
+
query,
|
|
102
|
+
groupIds: newGroups,
|
|
103
|
+
limit: parseInt(opts.limit),
|
|
104
|
+
});
|
|
105
|
+
// Post-filter: only keep results the person is authorized to view
|
|
106
|
+
const viewableSet = new Set(newIds);
|
|
107
|
+
fragmentResults = candidateResults.filter(r => viewableSet.has(r.uuid));
|
|
97
108
|
}
|
|
98
109
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
}
|
|
110
|
+
}
|
|
111
|
+
if (fragmentResults.length > 0) {
|
|
112
|
+
console.log(` + ${fragmentResults.length} result(s) via involves (${personSubject.type}:${personSubject.id})`);
|
|
102
113
|
}
|
|
103
114
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
// Fragment lookup failed — proceed with group results only
|
|
107
118
|
}
|
|
108
119
|
const allResults = [...groupResults, ...fragmentResults];
|
|
109
120
|
if (allResults.length === 0) {
|
package/dist/index.js
CHANGED
|
@@ -21,8 +21,8 @@ import { join, dirname } from "node:path";
|
|
|
21
21
|
import { fileURLToPath } from "node:url";
|
|
22
22
|
import { rebacMemoryConfigSchema, createBackend, defaultGroupId } from "./config.js";
|
|
23
23
|
import { SpiceDbClient } from "./spicedb.js";
|
|
24
|
-
import { lookupAuthorizedGroups, lookupViewableFragments, lookupAgentOwner, writeFragmentRelationships, deleteFragmentRelationships, canDeleteFragment, canWriteToGroup, ensureGroupMembership, } from "./authorization.js";
|
|
25
|
-
import { searchAuthorizedMemories, formatDualResults,
|
|
24
|
+
import { lookupAuthorizedGroups, lookupViewableFragments, lookupFragmentSourceGroups, lookupAgentOwner, writeFragmentRelationships, deleteFragmentRelationships, canDeleteFragment, canWriteToGroup, ensureGroupMembership, } from "./authorization.js";
|
|
25
|
+
import { searchAuthorizedMemories, formatDualResults, } from "./search.js";
|
|
26
26
|
import { registerCommands } from "./cli.js";
|
|
27
27
|
// ============================================================================
|
|
28
28
|
// Session helpers
|
|
@@ -131,31 +131,27 @@ const rebacMemoryPlugin = {
|
|
|
131
131
|
sessionGroups.push(sg);
|
|
132
132
|
}
|
|
133
133
|
}
|
|
134
|
-
//
|
|
135
|
-
const [
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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`.
|
|
151
|
+
// Uses search-then-post-filter: search the source groups for query relevance,
|
|
152
|
+
// then intersect with the authorized fragment set for security.
|
|
157
153
|
let ownerFragmentResults = [];
|
|
158
|
-
if (subject.type === "agent"
|
|
154
|
+
if (subject.type === "agent") {
|
|
159
155
|
try {
|
|
160
156
|
const ownerId = await lookupAgentOwner(spicedb, subject.id, state.lastWriteToken);
|
|
161
157
|
if (ownerId) {
|
|
@@ -165,7 +161,20 @@ const rebacMemoryPlugin = {
|
|
|
165
161
|
const groupResultIds = new Set(groupResults.map((r) => r.uuid));
|
|
166
162
|
const newIds = viewableIds.filter((id) => !groupResultIds.has(id));
|
|
167
163
|
if (newIds.length > 0) {
|
|
168
|
-
|
|
164
|
+
// Discover which groups the viewable fragments belong to
|
|
165
|
+
const ownerGroups = await lookupFragmentSourceGroups(spicedb, newIds, state.lastWriteToken);
|
|
166
|
+
const alreadySearched = new Set([...longTermGroups, ...sessionGroups]);
|
|
167
|
+
const newGroups = ownerGroups.filter(g => !alreadySearched.has(g));
|
|
168
|
+
if (newGroups.length > 0) {
|
|
169
|
+
const candidateResults = await searchAuthorizedMemories(backend, {
|
|
170
|
+
query,
|
|
171
|
+
groupIds: newGroups,
|
|
172
|
+
limit,
|
|
173
|
+
});
|
|
174
|
+
// Post-filter: only keep results the owner is authorized to view
|
|
175
|
+
const viewableSet = new Set(newIds);
|
|
176
|
+
ownerFragmentResults = candidateResults.filter(r => viewableSet.has(r.uuid));
|
|
177
|
+
}
|
|
169
178
|
}
|
|
170
179
|
}
|
|
171
180
|
}
|
|
@@ -462,26 +471,20 @@ const rebacMemoryPlugin = {
|
|
|
462
471
|
if (!sessionGroups.includes(sg))
|
|
463
472
|
sessionGroups.push(sg);
|
|
464
473
|
}
|
|
465
|
-
const
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
sessionId: state.sessionId,
|
|
480
|
-
})
|
|
481
|
-
: Promise.resolve([]),
|
|
482
|
-
]);
|
|
483
|
-
const sessionResults = deduplicateSessionResults(longTermResults, rawSessionResults);
|
|
484
|
-
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;
|
|
485
488
|
const toolHint = "<memory-tools>\n" +
|
|
486
489
|
"You have knowledge-graph memory tools. Use them proactively:\n" +
|
|
487
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
|
-
*
|
|
2
|
+
* Multi-Group Search with backend-native relevance ranking.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
-
*
|
|
2
|
+
* Multi-Group Search with backend-native relevance ranking.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
-
//
|
|
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:
|
|
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.
|
|
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": "
|
|
47
|
-
"@grpc/grpc-js": "
|
|
46
|
+
"@authzed/authzed-node": "1.6.1",
|
|
47
|
+
"@grpc/grpc-js": "1.14.3",
|
|
48
48
|
"@sinclair/typebox": "0.34.48",
|
|
49
|
-
"commander": "
|
|
49
|
+
"commander": "13.1.0"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
|
-
"dotenv": "
|
|
52
|
+
"dotenv": "17.3.1",
|
|
53
53
|
"openclaw": "*",
|
|
54
|
-
"typescript": "
|
|
55
|
-
"vitest": "
|
|
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
|
}
|