@contextableai/openclaw-memory-rebac 0.1.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/README.md +464 -0
- package/authorization.ts +191 -0
- package/backend.ts +176 -0
- package/backends/backends.json +3 -0
- package/backends/graphiti.defaults.json +8 -0
- package/backends/graphiti.test.ts +292 -0
- package/backends/graphiti.ts +345 -0
- package/backends/registry.ts +36 -0
- package/bin/rebac-mem.ts +144 -0
- package/cli.ts +418 -0
- package/config.ts +141 -0
- package/docker/docker-compose.yml +17 -0
- package/docker/graphiti/Dockerfile +35 -0
- package/docker/graphiti/config_overlay.py +44 -0
- package/docker/graphiti/docker-compose.yml +101 -0
- package/docker/graphiti/graphiti_overlay.py +141 -0
- package/docker/graphiti/startup.py +222 -0
- package/docker/spicedb/docker-compose.yml +79 -0
- package/index.ts +711 -0
- package/openclaw.plugin.json +118 -0
- package/package.json +70 -0
- package/plugin.defaults.json +12 -0
- package/schema.zed +23 -0
- package/search.ts +139 -0
- package/spicedb.ts +355 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "openclaw-memory-rebac",
|
|
3
|
+
"kind": "memory",
|
|
4
|
+
"uiHints": {
|
|
5
|
+
"backend": {
|
|
6
|
+
"label": "Memory Backend",
|
|
7
|
+
"placeholder": "graphiti",
|
|
8
|
+
"help": "Storage engine: 'graphiti' (REST knowledge graph)"
|
|
9
|
+
},
|
|
10
|
+
"spicedb.endpoint": {
|
|
11
|
+
"label": "SpiceDB Endpoint",
|
|
12
|
+
"placeholder": "localhost:50051",
|
|
13
|
+
"help": "SpiceDB gRPC endpoint (host:port)"
|
|
14
|
+
},
|
|
15
|
+
"spicedb.token": {
|
|
16
|
+
"label": "SpiceDB Token",
|
|
17
|
+
"sensitive": true,
|
|
18
|
+
"placeholder": "dev_token",
|
|
19
|
+
"help": "SpiceDB pre-shared key (or use ${SPICEDB_TOKEN})"
|
|
20
|
+
},
|
|
21
|
+
"spicedb.insecure": {
|
|
22
|
+
"label": "Insecure Connection",
|
|
23
|
+
"help": "Allow insecure gRPC to localhost (dev mode)"
|
|
24
|
+
},
|
|
25
|
+
"graphiti.endpoint": {
|
|
26
|
+
"label": "Graphiti Endpoint",
|
|
27
|
+
"placeholder": "http://localhost:8000",
|
|
28
|
+
"help": "Graphiti REST server HTTP base URL"
|
|
29
|
+
},
|
|
30
|
+
"graphiti.defaultGroupId": {
|
|
31
|
+
"label": "Graphiti Default Group ID",
|
|
32
|
+
"placeholder": "main",
|
|
33
|
+
"help": "Default Graphiti group_id for memory isolation"
|
|
34
|
+
},
|
|
35
|
+
"graphiti.uuidPollIntervalMs": {
|
|
36
|
+
"label": "UUID Poll Interval (ms)",
|
|
37
|
+
"placeholder": "3000",
|
|
38
|
+
"help": "Polling interval for resolving episode UUIDs after store (default: 3000)",
|
|
39
|
+
"advanced": true
|
|
40
|
+
},
|
|
41
|
+
"graphiti.uuidPollMaxAttempts": {
|
|
42
|
+
"label": "UUID Poll Max Attempts",
|
|
43
|
+
"placeholder": "30",
|
|
44
|
+
"help": "Max polling attempts; total timeout = interval × attempts (default: 30 = 90s)",
|
|
45
|
+
"advanced": true
|
|
46
|
+
},
|
|
47
|
+
"graphiti.requestTimeoutMs": {
|
|
48
|
+
"label": "Request Timeout (ms)",
|
|
49
|
+
"placeholder": "30000",
|
|
50
|
+
"help": "HTTP request timeout for Graphiti REST calls (default: 30000)",
|
|
51
|
+
"advanced": true
|
|
52
|
+
},
|
|
53
|
+
"subjectType": {
|
|
54
|
+
"label": "Subject Type",
|
|
55
|
+
"placeholder": "agent",
|
|
56
|
+
"help": "SpiceDB subject object type (agent or person)",
|
|
57
|
+
"advanced": true
|
|
58
|
+
},
|
|
59
|
+
"subjectId": {
|
|
60
|
+
"label": "Subject ID",
|
|
61
|
+
"placeholder": "${OPENCLAW_AGENT_ID}",
|
|
62
|
+
"help": "SpiceDB subject ID for the current agent/person"
|
|
63
|
+
},
|
|
64
|
+
"autoCapture": {
|
|
65
|
+
"label": "Auto-Capture",
|
|
66
|
+
"help": "Automatically capture important information from conversations"
|
|
67
|
+
},
|
|
68
|
+
"autoRecall": {
|
|
69
|
+
"label": "Auto-Recall",
|
|
70
|
+
"help": "Automatically inject relevant memories into context"
|
|
71
|
+
},
|
|
72
|
+
"customInstructions": {
|
|
73
|
+
"label": "Custom Extraction Instructions",
|
|
74
|
+
"help": "Natural language rules for what the backend should extract from conversations",
|
|
75
|
+
"advanced": true
|
|
76
|
+
},
|
|
77
|
+
"maxCaptureMessages": {
|
|
78
|
+
"label": "Max Capture Messages",
|
|
79
|
+
"help": "Maximum number of recent messages to include in auto-capture (default: 10)",
|
|
80
|
+
"advanced": true
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
"configSchema": {
|
|
84
|
+
"type": "object",
|
|
85
|
+
"additionalProperties": true,
|
|
86
|
+
"properties": {
|
|
87
|
+
"backend": {
|
|
88
|
+
"type": "string"
|
|
89
|
+
},
|
|
90
|
+
"spicedb": {
|
|
91
|
+
"type": "object",
|
|
92
|
+
"additionalProperties": true,
|
|
93
|
+
"properties": {
|
|
94
|
+
"endpoint": { "type": "string" },
|
|
95
|
+
"token": { "type": "string" },
|
|
96
|
+
"insecure": { "type": "boolean" }
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
"graphiti": {
|
|
100
|
+
"type": "object",
|
|
101
|
+
"additionalProperties": true,
|
|
102
|
+
"properties": {
|
|
103
|
+
"endpoint": { "type": "string" },
|
|
104
|
+
"defaultGroupId": { "type": "string" },
|
|
105
|
+
"uuidPollIntervalMs": { "type": "integer", "minimum": 500, "maximum": 30000 },
|
|
106
|
+
"uuidPollMaxAttempts": { "type": "integer", "minimum": 1, "maximum": 200 },
|
|
107
|
+
"requestTimeoutMs": { "type": "integer", "minimum": 1000, "maximum": 300000 }
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
"subjectType": { "type": "string", "enum": ["agent", "person"] },
|
|
111
|
+
"subjectId": { "type": "string" },
|
|
112
|
+
"autoCapture": { "type": "boolean" },
|
|
113
|
+
"autoRecall": { "type": "boolean" },
|
|
114
|
+
"customInstructions": { "type": "string" },
|
|
115
|
+
"maxCaptureMessages": { "type": "integer", "minimum": 1, "maximum": 50 }
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@contextableai/openclaw-memory-rebac",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "OpenClaw two-layer memory plugin: SpiceDB ReBAC authorization + Graphiti knowledge graph",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/Contextable/openclaw-memory-rebac.git"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"openclaw",
|
|
13
|
+
"memory",
|
|
14
|
+
"graphiti",
|
|
15
|
+
"spicedb",
|
|
16
|
+
"rebac",
|
|
17
|
+
"knowledge-graph",
|
|
18
|
+
"authorization"
|
|
19
|
+
],
|
|
20
|
+
"files": [
|
|
21
|
+
"index.ts",
|
|
22
|
+
"cli.ts",
|
|
23
|
+
"config.ts",
|
|
24
|
+
"backend.ts",
|
|
25
|
+
"authorization.ts",
|
|
26
|
+
"search.ts",
|
|
27
|
+
"spicedb.ts",
|
|
28
|
+
"backends/",
|
|
29
|
+
"openclaw.plugin.json",
|
|
30
|
+
"plugin.defaults.json",
|
|
31
|
+
"schema.zed",
|
|
32
|
+
"docker/",
|
|
33
|
+
"scripts/",
|
|
34
|
+
"bin/"
|
|
35
|
+
],
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"openclaw": "*"
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"cli": "tsx bin/rebac-mem.ts",
|
|
41
|
+
"typecheck": "tsc --noEmit 2>&1 | grep -v '../openclaw/' | grep -c 'error TS' | xargs -I{} test {} -eq 0",
|
|
42
|
+
"test": "vitest run",
|
|
43
|
+
"test:watch": "vitest",
|
|
44
|
+
"test:e2e": "OPENCLAW_LIVE_TEST=1 vitest run --config vitest.e2e.config.ts"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"@authzed/authzed-node": "^1.2.0",
|
|
48
|
+
"@grpc/grpc-js": "^1.14.3",
|
|
49
|
+
"@sinclair/typebox": "0.34.48",
|
|
50
|
+
"commander": "^13.1.0"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"dotenv": "^17.3.1",
|
|
54
|
+
"typescript": "^5.9.3",
|
|
55
|
+
"vitest": "^4.0.18"
|
|
56
|
+
},
|
|
57
|
+
"publishConfig": {
|
|
58
|
+
"access": "public"
|
|
59
|
+
},
|
|
60
|
+
"openclaw": {
|
|
61
|
+
"extensions": [
|
|
62
|
+
"./index.ts"
|
|
63
|
+
],
|
|
64
|
+
"install": {
|
|
65
|
+
"npmSpec": "@contextableai/openclaw-memory-rebac",
|
|
66
|
+
"localPath": "extensions/openclaw-memory-rebac",
|
|
67
|
+
"defaultChoice": "npm"
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
package/schema.zed
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
definition person {}
|
|
2
|
+
|
|
3
|
+
definition agent {
|
|
4
|
+
relation owner: person
|
|
5
|
+
permission act_as = owner
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
definition group {
|
|
9
|
+
relation member: person | agent
|
|
10
|
+
permission access = member
|
|
11
|
+
permission contribute = member
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
definition memory_fragment {
|
|
15
|
+
relation source_group: group
|
|
16
|
+
relation involves: person | agent
|
|
17
|
+
relation shared_by: person | agent
|
|
18
|
+
|
|
19
|
+
// Can view if: directly involved, shared it, or have access to the source group
|
|
20
|
+
permission view = involves + shared_by + source_group->access
|
|
21
|
+
// Can delete if: you shared it (owner-level control)
|
|
22
|
+
permission delete = shared_by
|
|
23
|
+
}
|
package/search.ts
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parallel Multi-Group Search + Merge/Re-rank
|
|
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.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { MemoryBackend, SearchResult } from "./backend.js";
|
|
10
|
+
|
|
11
|
+
export type { SearchResult };
|
|
12
|
+
|
|
13
|
+
// ============================================================================
|
|
14
|
+
// Search options
|
|
15
|
+
// ============================================================================
|
|
16
|
+
|
|
17
|
+
export type SearchOptions = {
|
|
18
|
+
query: string;
|
|
19
|
+
groupIds: string[];
|
|
20
|
+
limit?: number;
|
|
21
|
+
sessionId?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// ============================================================================
|
|
25
|
+
// Search
|
|
26
|
+
// ============================================================================
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Search across multiple authorized group_ids in parallel.
|
|
30
|
+
* Merges and deduplicates results, returning up to `limit` items sorted by
|
|
31
|
+
* score (desc) then recency (desc).
|
|
32
|
+
*/
|
|
33
|
+
export async function searchAuthorizedMemories(
|
|
34
|
+
backend: MemoryBackend,
|
|
35
|
+
options: SearchOptions,
|
|
36
|
+
): Promise<SearchResult[]> {
|
|
37
|
+
const { query, groupIds, limit = 10, sessionId } = options;
|
|
38
|
+
|
|
39
|
+
if (groupIds.length === 0) {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Fan out parallel searches across all authorized groups
|
|
44
|
+
const promises = groupIds.map((groupId) =>
|
|
45
|
+
backend.searchGroup({ query, groupId, limit, sessionId }),
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const resultSets = await Promise.allSettled(promises);
|
|
49
|
+
|
|
50
|
+
// Collect all successful results — silently skip failed group searches
|
|
51
|
+
const allResults: SearchResult[] = [];
|
|
52
|
+
for (const result of resultSets) {
|
|
53
|
+
if (result.status === "fulfilled") {
|
|
54
|
+
allResults.push(...result.value);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Deduplicate by UUID
|
|
59
|
+
const seen = new Set<string>();
|
|
60
|
+
const deduped = allResults.filter((r) => {
|
|
61
|
+
if (seen.has(r.uuid)) return false;
|
|
62
|
+
seen.add(r.uuid);
|
|
63
|
+
return true;
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Sort: score descending (when available), then recency descending
|
|
67
|
+
deduped.sort((a, b) => {
|
|
68
|
+
if (a.score !== undefined && b.score !== undefined && a.score !== b.score) {
|
|
69
|
+
return b.score - a.score;
|
|
70
|
+
}
|
|
71
|
+
const dateA = new Date(a.created_at).getTime();
|
|
72
|
+
const dateB = new Date(b.created_at).getTime();
|
|
73
|
+
return dateB - dateA;
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
return deduped.slice(0, limit);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ============================================================================
|
|
80
|
+
// Format for agent context
|
|
81
|
+
// ============================================================================
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Format search results into a text block for injecting into agent context.
|
|
85
|
+
*/
|
|
86
|
+
export function formatResultsForContext(results: SearchResult[]): string {
|
|
87
|
+
if (results.length === 0) return "";
|
|
88
|
+
return results.map((r, i) => formatResultLine(r, i + 1)).join("\n");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Format results with long-term and session sections separated.
|
|
93
|
+
* Session group_ids start with "session-".
|
|
94
|
+
*/
|
|
95
|
+
export function formatDualResults(
|
|
96
|
+
longTermResults: SearchResult[],
|
|
97
|
+
sessionResults: SearchResult[],
|
|
98
|
+
): string {
|
|
99
|
+
const parts: string[] = [];
|
|
100
|
+
let idx = 1;
|
|
101
|
+
|
|
102
|
+
for (const r of longTermResults) {
|
|
103
|
+
parts.push(formatResultLine(r, idx++));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (sessionResults.length > 0) {
|
|
107
|
+
if (longTermResults.length > 0) parts.push("Session memories:");
|
|
108
|
+
for (const r of sessionResults) {
|
|
109
|
+
parts.push(formatResultLine(r, idx++));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return parts.join("\n");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Format a single search result line with type-prefixed UUID.
|
|
118
|
+
* e.g. "[fact:da8650cb-...] Eric's birthday is Dec 17th (Eric -[HAS_BIRTHDAY]→ Dec 17th)"
|
|
119
|
+
*/
|
|
120
|
+
function formatResultLine(r: SearchResult, idx: number): string {
|
|
121
|
+
const typeLabel =
|
|
122
|
+
r.type === "node" ? "entity" :
|
|
123
|
+
r.type === "fact" ? "fact" :
|
|
124
|
+
r.type === "chunk" ? "chunk" :
|
|
125
|
+
r.type === "summary" ? "summary" :
|
|
126
|
+
"completion";
|
|
127
|
+
return `${idx}. [${typeLabel}:${r.uuid}] ${r.summary} (${r.context})`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Deduplicate session results against long-term results (by UUID).
|
|
132
|
+
*/
|
|
133
|
+
export function deduplicateSessionResults(
|
|
134
|
+
longTermResults: SearchResult[],
|
|
135
|
+
sessionResults: SearchResult[],
|
|
136
|
+
): SearchResult[] {
|
|
137
|
+
const longTermIds = new Set(longTermResults.map((r) => r.uuid));
|
|
138
|
+
return sessionResults.filter((r) => !longTermIds.has(r.uuid));
|
|
139
|
+
}
|