@contextableai/openclaw-memory-rebac 0.3.3 → 0.3.4

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.
@@ -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).
@@ -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
@@ -118,12 +118,6 @@ export interface MemoryBackend {
118
118
  * Returns true if deleted, false if the backend doesn't support it.
119
119
  */
120
120
  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
121
  /**
128
122
  * Discover fragment (fact/edge) UUIDs that were extracted from a stored episode.
129
123
  * Called after store() resolves the episode ID to write per-fragment SpiceDB
@@ -63,7 +63,6 @@ export declare class GraphitiBackend implements MemoryBackend {
63
63
  listGroups(): Promise<BackendDataset[]>;
64
64
  deleteFragment(uuid: string, type?: string): Promise<boolean>;
65
65
  getEpisodes(groupId: string, lastN: number): Promise<GraphitiEpisode[]>;
66
- getFragmentsByIds(ids: string[]): Promise<SearchResult[]>;
67
66
  discoverFragmentIds(episodeId: string): Promise<string[]>;
68
67
  getEntityEdge(uuid: string): Promise<FactResult>;
69
68
  registerCliCommands(cmd: Command): void;
@@ -159,26 +159,6 @@ export class GraphitiBackend {
159
159
  async getEpisodes(groupId, lastN) {
160
160
  return this.restCall("GET", `/episodes/${encodeURIComponent(groupId)}?last_n=${lastN}`);
161
161
  }
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
162
  async discoverFragmentIds(episodeId) {
183
163
  const edges = await this.restCall("GET", `/episodes/${encodeURIComponent(episodeId)}/edges`);
184
164
  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
- if (backend.getFragmentsByIds) {
76
- try {
77
- // Determine the person to search as
78
- let personSubject;
79
- if (subject.type === "person") {
80
- // Direct person search — look up fragments they can view
81
- personSubject = subject;
82
- }
83
- else if (subject.type === "agent") {
84
- // Agent search resolve owner identity first
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
- if (personSubject) {
91
- const viewableIds = await lookupViewableFragments(spicedb, personSubject, token);
92
- if (viewableIds.length > 0) {
93
- const groupResultIds = new Set(groupResults.map((r) => r.uuid));
94
- const newIds = viewableIds.filter((id) => !groupResultIds.has(id));
95
- if (newIds.length > 0) {
96
- fragmentResults = await backend.getFragmentsByIds(newIds);
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
- if (fragmentResults.length > 0) {
100
- console.log(` + ${fragmentResults.length} result(s) via involves (${personSubject.type}:${personSubject.id})`);
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
- catch {
105
- // Fragment lookup failed — proceed with group results only
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,7 +21,7 @@ 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";
24
+ import { lookupAuthorizedGroups, lookupViewableFragments, lookupFragmentSourceGroups, lookupAgentOwner, writeFragmentRelationships, deleteFragmentRelationships, canDeleteFragment, canWriteToGroup, ensureGroupMembership, } from "./authorization.js";
25
25
  import { searchAuthorizedMemories, formatDualResults, deduplicateSessionResults, } from "./search.js";
26
26
  import { registerCommands } from "./cli.js";
27
27
  // ============================================================================
@@ -154,8 +154,10 @@ const rebacMemoryPlugin = {
154
154
  const groupResults = [...longTermResults, ...sessionResults];
155
155
  // Owner-aware fragment search: if the subject is an agent with an owner,
156
156
  // also find fragments where the owner is in `involves`.
157
+ // Uses search-then-post-filter: search the source groups for query relevance,
158
+ // then intersect with the authorized fragment set for security.
157
159
  let ownerFragmentResults = [];
158
- if (subject.type === "agent" && backend.getFragmentsByIds) {
160
+ if (subject.type === "agent") {
159
161
  try {
160
162
  const ownerId = await lookupAgentOwner(spicedb, subject.id, state.lastWriteToken);
161
163
  if (ownerId) {
@@ -165,7 +167,20 @@ const rebacMemoryPlugin = {
165
167
  const groupResultIds = new Set(groupResults.map((r) => r.uuid));
166
168
  const newIds = viewableIds.filter((id) => !groupResultIds.has(id));
167
169
  if (newIds.length > 0) {
168
- ownerFragmentResults = await backend.getFragmentsByIds(newIds);
170
+ // Discover which groups the viewable fragments belong to
171
+ const ownerGroups = await lookupFragmentSourceGroups(spicedb, newIds, state.lastWriteToken);
172
+ const alreadySearched = new Set([...longTermGroups, ...sessionGroups]);
173
+ const newGroups = ownerGroups.filter(g => !alreadySearched.has(g));
174
+ if (newGroups.length > 0) {
175
+ const candidateResults = await searchAuthorizedMemories(backend, {
176
+ query,
177
+ groupIds: newGroups,
178
+ limit,
179
+ });
180
+ // Post-filter: only keep results the owner is authorized to view
181
+ const viewableSet = new Set(newIds);
182
+ ownerFragmentResults = candidateResults.filter(r => viewableSet.has(r.uuid));
183
+ }
169
184
  }
170
185
  }
171
186
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contextableai/openclaw-memory-rebac",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "description": "OpenClaw two-layer memory plugin: SpiceDB ReBAC authorization + Graphiti knowledge graph",
5
5
  "type": "module",
6
6
  "license": "MIT",