@contextableai/openclaw-memory-graphiti 0.1.1
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 +424 -0
- package/authorization.ts +202 -0
- package/config.ts +111 -0
- package/docker/.env.example +66 -0
- package/docker/docker-compose.yml +151 -0
- package/graphiti.ts +382 -0
- package/index.ts +942 -0
- package/openclaw.plugin.json +91 -0
- package/package.json +51 -0
- package/schema.zed +23 -0
- package/scripts/dev-setup.sh +146 -0
- package/scripts/dev-start.sh +236 -0
- package/scripts/dev-status.sh +57 -0
- package/scripts/dev-stop.sh +47 -0
- package/search.ts +201 -0
- package/spicedb.ts +174 -0
package/search.ts
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parallel Multi-Group Search + Merge/Re-rank
|
|
3
|
+
*
|
|
4
|
+
* Issues parallel search calls to Graphiti (one per authorized group_id),
|
|
5
|
+
* merges results, deduplicates, and re-ranks.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { GraphitiClient, GraphitiNode, GraphitiFact } from "./graphiti.js";
|
|
9
|
+
|
|
10
|
+
// ============================================================================
|
|
11
|
+
// Types
|
|
12
|
+
// ============================================================================
|
|
13
|
+
|
|
14
|
+
export type SearchResult = {
|
|
15
|
+
type: "node" | "fact";
|
|
16
|
+
uuid: string;
|
|
17
|
+
group_id: string;
|
|
18
|
+
summary: string;
|
|
19
|
+
/** Additional context: entity names for facts, node name for nodes */
|
|
20
|
+
context: string;
|
|
21
|
+
created_at: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type SearchOptions = {
|
|
25
|
+
query: string;
|
|
26
|
+
groupIds: string[];
|
|
27
|
+
limit?: number;
|
|
28
|
+
searchNodes?: boolean;
|
|
29
|
+
searchFacts?: boolean;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// ============================================================================
|
|
33
|
+
// Search
|
|
34
|
+
// ============================================================================
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Search across multiple authorized group_ids in parallel.
|
|
38
|
+
* Merges and deduplicates results, returning up to `limit` items.
|
|
39
|
+
*/
|
|
40
|
+
export async function searchAuthorizedMemories(
|
|
41
|
+
graphiti: GraphitiClient,
|
|
42
|
+
options: SearchOptions,
|
|
43
|
+
): Promise<SearchResult[]> {
|
|
44
|
+
const { query, groupIds, limit = 10, searchNodes = true, searchFacts = true } = options;
|
|
45
|
+
|
|
46
|
+
if (groupIds.length === 0) {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Fan out parallel searches: for each group_id, search nodes and/or facts
|
|
51
|
+
const promises: Promise<SearchResult[]>[] = [];
|
|
52
|
+
|
|
53
|
+
for (const groupId of groupIds) {
|
|
54
|
+
if (searchNodes) {
|
|
55
|
+
promises.push(searchNodesForGroup(graphiti, query, groupId, limit));
|
|
56
|
+
}
|
|
57
|
+
if (searchFacts) {
|
|
58
|
+
promises.push(searchFactsForGroup(graphiti, query, groupId, limit));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const resultSets = await Promise.allSettled(promises);
|
|
63
|
+
|
|
64
|
+
// Collect all successful results
|
|
65
|
+
const allResults: SearchResult[] = [];
|
|
66
|
+
for (const result of resultSets) {
|
|
67
|
+
if (result.status === "fulfilled") {
|
|
68
|
+
allResults.push(...result.value);
|
|
69
|
+
}
|
|
70
|
+
// Silently skip failed group searches — partial results are better than none
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Deduplicate by UUID
|
|
74
|
+
const seen = new Set<string>();
|
|
75
|
+
const deduped = allResults.filter((r) => {
|
|
76
|
+
if (seen.has(r.uuid)) {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
seen.add(r.uuid);
|
|
80
|
+
return true;
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// Sort by recency (most recent first) and trim to limit
|
|
84
|
+
deduped.sort((a, b) => {
|
|
85
|
+
const dateA = new Date(a.created_at).getTime();
|
|
86
|
+
const dateB = new Date(b.created_at).getTime();
|
|
87
|
+
return dateB - dateA;
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
return deduped.slice(0, limit);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ============================================================================
|
|
94
|
+
// Per-group search helpers
|
|
95
|
+
// ============================================================================
|
|
96
|
+
|
|
97
|
+
async function searchNodesForGroup(
|
|
98
|
+
graphiti: GraphitiClient,
|
|
99
|
+
query: string,
|
|
100
|
+
groupId: string,
|
|
101
|
+
limit: number,
|
|
102
|
+
): Promise<SearchResult[]> {
|
|
103
|
+
const nodes = await graphiti.searchNodes({ query, group_id: groupId, limit });
|
|
104
|
+
return nodes.map(nodeToResult);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function searchFactsForGroup(
|
|
108
|
+
graphiti: GraphitiClient,
|
|
109
|
+
query: string,
|
|
110
|
+
groupId: string,
|
|
111
|
+
limit: number,
|
|
112
|
+
): Promise<SearchResult[]> {
|
|
113
|
+
const facts = await graphiti.searchFacts({ query, group_id: groupId, limit });
|
|
114
|
+
return facts.map(factToResult);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function nodeToResult(node: GraphitiNode): SearchResult {
|
|
118
|
+
return {
|
|
119
|
+
type: "node",
|
|
120
|
+
uuid: node.uuid,
|
|
121
|
+
group_id: node.group_id,
|
|
122
|
+
summary: node.summary ?? node.name,
|
|
123
|
+
context: node.name,
|
|
124
|
+
created_at: node.created_at ?? new Date().toISOString(),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function factToResult(fact: GraphitiFact): SearchResult {
|
|
129
|
+
// Use node names if available, fall back to relationship name or UUIDs
|
|
130
|
+
const source = fact.source_node_name ?? fact.source_node_uuid ?? "?";
|
|
131
|
+
const target = fact.target_node_name ?? fact.target_node_uuid ?? "?";
|
|
132
|
+
const context = fact.name ? `${source} -[${fact.name}]→ ${target}` : `${source} → ${target}`;
|
|
133
|
+
return {
|
|
134
|
+
type: "fact",
|
|
135
|
+
uuid: fact.uuid,
|
|
136
|
+
group_id: fact.group_id,
|
|
137
|
+
summary: fact.fact,
|
|
138
|
+
context,
|
|
139
|
+
created_at: fact.created_at,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ============================================================================
|
|
144
|
+
// Format for agent context
|
|
145
|
+
// ============================================================================
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Format search results into a text block suitable for injecting into agent context.
|
|
149
|
+
*/
|
|
150
|
+
export function formatResultsForContext(results: SearchResult[]): string {
|
|
151
|
+
if (results.length === 0) {
|
|
152
|
+
return "";
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return results
|
|
156
|
+
.map((r, i) => {
|
|
157
|
+
const typeLabel = r.type === "node" ? "entity" : "fact";
|
|
158
|
+
return `${i + 1}. [${typeLabel}] ${r.summary} (${r.context})`;
|
|
159
|
+
})
|
|
160
|
+
.join("\n");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Format results with session and long-term sections separated.
|
|
165
|
+
* Session group_ids start with "session/".
|
|
166
|
+
*/
|
|
167
|
+
export function formatDualResults(
|
|
168
|
+
longTermResults: SearchResult[],
|
|
169
|
+
sessionResults: SearchResult[],
|
|
170
|
+
): string {
|
|
171
|
+
const parts: string[] = [];
|
|
172
|
+
let idx = 1;
|
|
173
|
+
|
|
174
|
+
if (longTermResults.length > 0) {
|
|
175
|
+
for (const r of longTermResults) {
|
|
176
|
+
const typeLabel = r.type === "node" ? "entity" : "fact";
|
|
177
|
+
parts.push(`${idx++}. [${typeLabel}] ${r.summary} (${r.context})`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (sessionResults.length > 0) {
|
|
182
|
+
parts.push("Session memories:");
|
|
183
|
+
for (const r of sessionResults) {
|
|
184
|
+
const typeLabel = r.type === "node" ? "entity" : "fact";
|
|
185
|
+
parts.push(`${idx++}. [${typeLabel}] ${r.summary} (${r.context})`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return parts.join("\n");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Deduplicate session results against long-term results (by UUID).
|
|
194
|
+
*/
|
|
195
|
+
export function deduplicateSessionResults(
|
|
196
|
+
longTermResults: SearchResult[],
|
|
197
|
+
sessionResults: SearchResult[],
|
|
198
|
+
): SearchResult[] {
|
|
199
|
+
const longTermIds = new Set(longTermResults.map((r) => r.uuid));
|
|
200
|
+
return sessionResults.filter((r) => !longTermIds.has(r.uuid));
|
|
201
|
+
}
|
package/spicedb.ts
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SpiceDB Client Wrapper
|
|
3
|
+
*
|
|
4
|
+
* Wraps @authzed/authzed-node for authorization operations:
|
|
5
|
+
* WriteSchema, WriteRelationships, LookupResources, CheckPermission.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { v1 } from "@authzed/authzed-node";
|
|
9
|
+
|
|
10
|
+
// ============================================================================
|
|
11
|
+
// Types
|
|
12
|
+
// ============================================================================
|
|
13
|
+
|
|
14
|
+
export type SpiceDbConfig = {
|
|
15
|
+
endpoint: string;
|
|
16
|
+
token: string;
|
|
17
|
+
insecure: boolean;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type RelationshipTuple = {
|
|
21
|
+
resourceType: string;
|
|
22
|
+
resourceId: string;
|
|
23
|
+
relation: string;
|
|
24
|
+
subjectType: string;
|
|
25
|
+
subjectId: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// ============================================================================
|
|
29
|
+
// Client
|
|
30
|
+
// ============================================================================
|
|
31
|
+
|
|
32
|
+
export class SpiceDbClient {
|
|
33
|
+
private client: ReturnType<typeof v1.NewClient>;
|
|
34
|
+
private promises: ReturnType<typeof v1.NewClient>["promises"];
|
|
35
|
+
|
|
36
|
+
constructor(config: SpiceDbConfig) {
|
|
37
|
+
if (config.insecure) {
|
|
38
|
+
this.client = v1.NewClient(
|
|
39
|
+
config.token,
|
|
40
|
+
config.endpoint,
|
|
41
|
+
v1.ClientSecurity.INSECURE_LOCALHOST_ALLOWED,
|
|
42
|
+
);
|
|
43
|
+
} else {
|
|
44
|
+
this.client = v1.NewClient(config.token, config.endpoint);
|
|
45
|
+
}
|
|
46
|
+
this.promises = this.client.promises;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// --------------------------------------------------------------------------
|
|
50
|
+
// Schema
|
|
51
|
+
// --------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
async writeSchema(schema: string): Promise<void> {
|
|
54
|
+
const request = v1.WriteSchemaRequest.create({ schema });
|
|
55
|
+
await this.promises.writeSchema(request);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async readSchema(): Promise<string> {
|
|
59
|
+
const request = v1.ReadSchemaRequest.create({});
|
|
60
|
+
const response = await this.promises.readSchema(request);
|
|
61
|
+
return response.schemaText;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// --------------------------------------------------------------------------
|
|
65
|
+
// Relationships
|
|
66
|
+
// --------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
async writeRelationships(tuples: RelationshipTuple[]): Promise<void> {
|
|
69
|
+
const updates = tuples.map((t) =>
|
|
70
|
+
v1.RelationshipUpdate.create({
|
|
71
|
+
operation: v1.RelationshipUpdate_Operation.TOUCH,
|
|
72
|
+
relationship: v1.Relationship.create({
|
|
73
|
+
resource: v1.ObjectReference.create({
|
|
74
|
+
objectType: t.resourceType,
|
|
75
|
+
objectId: t.resourceId,
|
|
76
|
+
}),
|
|
77
|
+
relation: t.relation,
|
|
78
|
+
subject: v1.SubjectReference.create({
|
|
79
|
+
object: v1.ObjectReference.create({
|
|
80
|
+
objectType: t.subjectType,
|
|
81
|
+
objectId: t.subjectId,
|
|
82
|
+
}),
|
|
83
|
+
}),
|
|
84
|
+
}),
|
|
85
|
+
}),
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const request = v1.WriteRelationshipsRequest.create({ updates });
|
|
89
|
+
await this.promises.writeRelationships(request);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async deleteRelationships(tuples: RelationshipTuple[]): Promise<void> {
|
|
93
|
+
const updates = tuples.map((t) =>
|
|
94
|
+
v1.RelationshipUpdate.create({
|
|
95
|
+
operation: v1.RelationshipUpdate_Operation.DELETE,
|
|
96
|
+
relationship: v1.Relationship.create({
|
|
97
|
+
resource: v1.ObjectReference.create({
|
|
98
|
+
objectType: t.resourceType,
|
|
99
|
+
objectId: t.resourceId,
|
|
100
|
+
}),
|
|
101
|
+
relation: t.relation,
|
|
102
|
+
subject: v1.SubjectReference.create({
|
|
103
|
+
object: v1.ObjectReference.create({
|
|
104
|
+
objectType: t.subjectType,
|
|
105
|
+
objectId: t.subjectId,
|
|
106
|
+
}),
|
|
107
|
+
}),
|
|
108
|
+
}),
|
|
109
|
+
}),
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const request = v1.WriteRelationshipsRequest.create({ updates });
|
|
113
|
+
await this.promises.writeRelationships(request);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// --------------------------------------------------------------------------
|
|
117
|
+
// Permissions
|
|
118
|
+
// --------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
async checkPermission(params: {
|
|
121
|
+
resourceType: string;
|
|
122
|
+
resourceId: string;
|
|
123
|
+
permission: string;
|
|
124
|
+
subjectType: string;
|
|
125
|
+
subjectId: string;
|
|
126
|
+
}): Promise<boolean> {
|
|
127
|
+
const request = v1.CheckPermissionRequest.create({
|
|
128
|
+
resource: v1.ObjectReference.create({
|
|
129
|
+
objectType: params.resourceType,
|
|
130
|
+
objectId: params.resourceId,
|
|
131
|
+
}),
|
|
132
|
+
permission: params.permission,
|
|
133
|
+
subject: v1.SubjectReference.create({
|
|
134
|
+
object: v1.ObjectReference.create({
|
|
135
|
+
objectType: params.subjectType,
|
|
136
|
+
objectId: params.subjectId,
|
|
137
|
+
}),
|
|
138
|
+
}),
|
|
139
|
+
consistency: v1.Consistency.create({
|
|
140
|
+
requirement: { oneofKind: "fullyConsistent", fullyConsistent: true },
|
|
141
|
+
}),
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const response = await this.promises.checkPermission(request);
|
|
145
|
+
return (
|
|
146
|
+
response.permissionship ===
|
|
147
|
+
v1.CheckPermissionResponse_Permissionship.HAS_PERMISSION
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async lookupResources(params: {
|
|
152
|
+
resourceType: string;
|
|
153
|
+
permission: string;
|
|
154
|
+
subjectType: string;
|
|
155
|
+
subjectId: string;
|
|
156
|
+
}): Promise<string[]> {
|
|
157
|
+
const request = v1.LookupResourcesRequest.create({
|
|
158
|
+
resourceObjectType: params.resourceType,
|
|
159
|
+
permission: params.permission,
|
|
160
|
+
subject: v1.SubjectReference.create({
|
|
161
|
+
object: v1.ObjectReference.create({
|
|
162
|
+
objectType: params.subjectType,
|
|
163
|
+
objectId: params.subjectId,
|
|
164
|
+
}),
|
|
165
|
+
}),
|
|
166
|
+
consistency: v1.Consistency.create({
|
|
167
|
+
requirement: { oneofKind: "fullyConsistent", fullyConsistent: true },
|
|
168
|
+
}),
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const results = await this.promises.lookupResources(request);
|
|
172
|
+
return results.map((r: { resourceObjectId: string }) => r.resourceObjectId);
|
|
173
|
+
}
|
|
174
|
+
}
|