@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.
@@ -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
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "backend": "graphiti",
3
+ "spicedb": {
4
+ "endpoint": "localhost:50051",
5
+ "insecure": true
6
+ },
7
+ "subjectType": "agent",
8
+ "subjectId": "default",
9
+ "autoCapture": true,
10
+ "autoRecall": true,
11
+ "maxCaptureMessages": 10
12
+ }
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
+ }