@contextableai/openclaw-memory-rebac 0.1.0 → 0.1.2

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.
@@ -1,292 +0,0 @@
1
- import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
2
-
3
- vi.mock("node:crypto", async (importOriginal) => {
4
- const mod = await importOriginal<typeof import("node:crypto")>();
5
- return { ...mod, randomUUID: vi.fn(() => "test-uuid-000") };
6
- });
7
-
8
- import { GraphitiBackend } from "./graphiti.js";
9
- import type { GraphitiConfig } from "./graphiti.js";
10
-
11
- const defaultConfig: GraphitiConfig = {
12
- endpoint: "http://localhost:8000",
13
- defaultGroupId: "main",
14
- uuidPollIntervalMs: 100,
15
- uuidPollMaxAttempts: 5,
16
- requestTimeoutMs: 5000,
17
- };
18
-
19
- // Mock global fetch
20
- const mockFetch = vi.fn();
21
- global.fetch = mockFetch as unknown as typeof fetch;
22
-
23
- /** Helper to create a mock JSON response */
24
- function jsonResponse(body: unknown, status = 200) {
25
- return {
26
- ok: status >= 200 && status < 300,
27
- status,
28
- headers: new Map([["content-type", "application/json"]]),
29
- json: vi.fn().mockResolvedValue(body),
30
- text: vi.fn().mockResolvedValue(JSON.stringify(body)),
31
- };
32
- }
33
-
34
- describe("GraphitiBackend", () => {
35
- beforeEach(() => {
36
- mockFetch.mockReset();
37
- });
38
-
39
- afterEach(() => {
40
- vi.restoreAllMocks();
41
- });
42
-
43
- test("has correct name", () => {
44
- const backend = new GraphitiBackend(defaultConfig);
45
- expect(backend.name).toBe("graphiti");
46
- });
47
-
48
- describe("store", () => {
49
- test("returns StoreResult with fragmentId Promise", async () => {
50
- const backend = new GraphitiBackend(defaultConfig);
51
-
52
- // Mock POST /messages (202 Accepted)
53
- mockFetch.mockResolvedValueOnce(
54
- jsonResponse({ message: "Messages accepted", success: true }, 202),
55
- );
56
-
57
- // Mock GET /episodes polling (return episode on first poll)
58
- mockFetch.mockResolvedValueOnce(
59
- jsonResponse([
60
- { uuid: "real-uuid-123", name: "memory_test-uuid-000", group_id: "main", created_at: "2026-03-01" },
61
- ]),
62
- );
63
-
64
- const result = await backend.store({
65
- content: "Test memory",
66
- groupId: "main",
67
- });
68
-
69
- expect(result.fragmentId).toBeInstanceOf(Promise);
70
- const uuid = await result.fragmentId;
71
- expect(uuid).toBe("real-uuid-123");
72
-
73
- // Verify POST /messages was called
74
- const storeCall = mockFetch.mock.calls[0];
75
- expect(storeCall[0]).toBe("http://localhost:8000/messages");
76
- const body = JSON.parse(storeCall[1].body as string);
77
- expect(body.group_id).toBe("main");
78
- expect(body.messages).toHaveLength(1);
79
- expect(body.messages[0].content).toBe("Test memory");
80
- expect(body.messages[0].uuid).toBeUndefined();
81
- expect(body.messages[0].name).toBe("memory_test-uuid-000");
82
- expect(body.messages[0].timestamp).toBeDefined();
83
- });
84
-
85
- test("passes customPrompt in message content with instructions wrapper", async () => {
86
- const backend = new GraphitiBackend(defaultConfig);
87
-
88
- // Mock POST /messages
89
- mockFetch.mockResolvedValueOnce(
90
- jsonResponse({ message: "Accepted", success: true }, 202),
91
- );
92
-
93
- await backend.store({
94
- content: "Important fact",
95
- groupId: "main",
96
- customPrompt: "Extract only names",
97
- });
98
-
99
- const storeCall = mockFetch.mock.calls[0];
100
- const body = JSON.parse(storeCall[1].body as string);
101
- expect(body.messages[0].content).toContain("[Extraction Instructions]");
102
- expect(body.messages[0].content).toContain("Extract only names");
103
- expect(body.messages[0].content).toContain("[End Instructions]");
104
- expect(body.messages[0].content).toContain("Important fact");
105
- });
106
- });
107
-
108
- describe("searchGroup", () => {
109
- test("calls POST /search and returns facts as SearchResult[]", async () => {
110
- const backend = new GraphitiBackend(defaultConfig);
111
-
112
- mockFetch.mockResolvedValueOnce(
113
- jsonResponse({
114
- facts: [
115
- {
116
- uuid: "f1",
117
- name: "WORKS_AT",
118
- fact: "Mark works at Acme",
119
- valid_at: null,
120
- invalid_at: null,
121
- created_at: "2026-01-16",
122
- expired_at: null,
123
- },
124
- ],
125
- }),
126
- );
127
-
128
- const results = await backend.searchGroup({
129
- query: "Mark work",
130
- groupId: "g1",
131
- limit: 10,
132
- });
133
-
134
- expect(results).toHaveLength(1);
135
- expect(results[0]).toMatchObject({
136
- type: "fact",
137
- uuid: "f1",
138
- group_id: "g1",
139
- summary: "Mark works at Acme",
140
- context: "WORKS_AT",
141
- created_at: "2026-01-16",
142
- });
143
-
144
- // Verify POST /search was called with correct body
145
- const searchCall = mockFetch.mock.calls[0];
146
- expect(searchCall[0]).toBe("http://localhost:8000/search");
147
- const body = JSON.parse(searchCall[1].body as string);
148
- expect(body.group_ids).toEqual(["g1"]);
149
- expect(body.query).toBe("Mark work");
150
- expect(body.max_facts).toBe(10);
151
- });
152
-
153
- test("returns empty array when no facts found", async () => {
154
- const backend = new GraphitiBackend(defaultConfig);
155
-
156
- mockFetch.mockResolvedValueOnce(jsonResponse({ facts: [] }));
157
-
158
- const results = await backend.searchGroup({
159
- query: "nothing",
160
- groupId: "g1",
161
- limit: 10,
162
- });
163
-
164
- expect(results).toEqual([]);
165
- });
166
- });
167
-
168
- describe("healthCheck", () => {
169
- test("returns true when /healthcheck responds ok", async () => {
170
- const backend = new GraphitiBackend(defaultConfig);
171
- mockFetch.mockResolvedValueOnce({ ok: true });
172
-
173
- const healthy = await backend.healthCheck();
174
- expect(healthy).toBe(true);
175
- expect(mockFetch).toHaveBeenCalledWith(
176
- "http://localhost:8000/healthcheck",
177
- expect.objectContaining({ signal: expect.any(AbortSignal) }),
178
- );
179
- });
180
-
181
- test("returns false when /healthcheck fails", async () => {
182
- const backend = new GraphitiBackend(defaultConfig);
183
- mockFetch.mockResolvedValueOnce({ ok: false });
184
-
185
- const healthy = await backend.healthCheck();
186
- expect(healthy).toBe(false);
187
- });
188
-
189
- test("returns false when /healthcheck throws", async () => {
190
- const backend = new GraphitiBackend(defaultConfig);
191
- mockFetch.mockRejectedValueOnce(new Error("Network error"));
192
-
193
- const healthy = await backend.healthCheck();
194
- expect(healthy).toBe(false);
195
- });
196
- });
197
-
198
- describe("getStatus", () => {
199
- test("returns status object with backend name and healthy flag", async () => {
200
- const backend = new GraphitiBackend(defaultConfig);
201
- mockFetch.mockResolvedValueOnce({ ok: true });
202
-
203
- const status = await backend.getStatus();
204
- expect(status.backend).toBe("graphiti");
205
- expect(status.endpoint).toBe("http://localhost:8000");
206
- expect(status.healthy).toBe(true);
207
- });
208
-
209
- test("reports unhealthy when health check fails", async () => {
210
- const backend = new GraphitiBackend(defaultConfig);
211
- mockFetch.mockRejectedValueOnce(new Error("Down"));
212
-
213
- const status = await backend.getStatus();
214
- expect(status.healthy).toBe(false);
215
- });
216
- });
217
-
218
- describe("deleteFragment", () => {
219
- test("calls DELETE /episode/{uuid}", async () => {
220
- const backend = new GraphitiBackend(defaultConfig);
221
-
222
- mockFetch.mockResolvedValueOnce(
223
- jsonResponse({ message: "Deleted", success: true }),
224
- );
225
-
226
- const result = await backend.deleteFragment?.("episode-uuid-123");
227
- expect(result).toBe(true);
228
-
229
- const deleteCall = mockFetch.mock.calls[0];
230
- expect(deleteCall[0]).toBe("http://localhost:8000/episode/episode-uuid-123");
231
- expect(deleteCall[1].method).toBe("DELETE");
232
- });
233
- });
234
-
235
- describe("deleteGroup", () => {
236
- test("calls DELETE /group/{groupId}", async () => {
237
- const backend = new GraphitiBackend(defaultConfig);
238
-
239
- mockFetch.mockResolvedValueOnce(
240
- jsonResponse({ message: "Cleared", success: true }),
241
- );
242
-
243
- await backend.deleteGroup("old-group");
244
-
245
- const deleteCall = mockFetch.mock.calls[0];
246
- expect(deleteCall[0]).toBe("http://localhost:8000/group/old-group");
247
- expect(deleteCall[1].method).toBe("DELETE");
248
- });
249
- });
250
-
251
- describe("listGroups", () => {
252
- test("returns empty array (not implemented)", async () => {
253
- const backend = new GraphitiBackend(defaultConfig);
254
- const groups = await backend.listGroups();
255
- expect(groups).toEqual([]);
256
- });
257
- });
258
-
259
- describe("getConversationHistory", () => {
260
- test("maps getEpisodes to ConversationTurn[]", async () => {
261
- const backend = new GraphitiBackend(defaultConfig);
262
-
263
- mockFetch.mockResolvedValueOnce(
264
- jsonResponse([
265
- { uuid: "e1", name: "Turn 1", content: "User: Hi\nAssistant: Hello", created_at: "2026-01-15" },
266
- { uuid: "e2", name: "Turn 2", content: "User: Bye\nAssistant: Goodbye", created_at: "2026-01-16" },
267
- ]),
268
- );
269
-
270
- const turns = await backend.getConversationHistory("session-123", 10);
271
- expect(turns).toHaveLength(2);
272
- expect(turns[0]).toMatchObject({
273
- query: "Turn 1",
274
- answer: "User: Hi\nAssistant: Hello",
275
- created_at: "2026-01-15",
276
- });
277
-
278
- // Verify GET /episodes/session-session-123 was called
279
- const episodesCall = mockFetch.mock.calls[0];
280
- expect(episodesCall[0]).toBe("http://localhost:8000/episodes/session-session-123?last_n=10");
281
- expect(episodesCall[1].method).toBe("GET");
282
- });
283
-
284
- test("returns empty array on error", async () => {
285
- const backend = new GraphitiBackend(defaultConfig);
286
- mockFetch.mockRejectedValueOnce(new Error("Fail"));
287
-
288
- const turns = await backend.getConversationHistory("session-123");
289
- expect(turns).toEqual([]);
290
- });
291
- });
292
- });
@@ -1,345 +0,0 @@
1
- /**
2
- * GraphitiBackend — MemoryBackend implementation backed by the Graphiti FastAPI REST server.
3
- *
4
- * Graphiti communicates via standard HTTP REST endpoints.
5
- * Episodes are processed asynchronously by Graphiti's LLM pipeline;
6
- * the real server-side UUID is discovered by polling GET /episodes/{group_id}.
7
- *
8
- * store() returns immediately; fragmentId resolves once Graphiti finishes
9
- * processing and the UUID becomes visible in the episodes list.
10
- */
11
-
12
- import { randomUUID } from "node:crypto";
13
- import type { Command } from "commander";
14
- import type {
15
- MemoryBackend,
16
- SearchResult,
17
- StoreResult,
18
- ConversationTurn,
19
- BackendDataset,
20
- } from "../backend.js";
21
-
22
- // ============================================================================
23
- // Types (Graphiti REST API)
24
- // ============================================================================
25
-
26
- /** Matches the server's Message schema (from /openapi.json). */
27
- type GraphitiMessage = {
28
- content: string;
29
- role_type: "user" | "assistant" | "system";
30
- role: string | null;
31
- name?: string;
32
- timestamp?: string;
33
- source_description?: string;
34
- };
35
-
36
- type AddMessagesRequest = {
37
- group_id: string;
38
- messages: GraphitiMessage[];
39
- };
40
-
41
- type GraphitiEpisode = {
42
- uuid: string;
43
- name: string;
44
- content: string;
45
- source_description: string;
46
- group_id: string;
47
- created_at: string;
48
- };
49
-
50
- type FactResult = {
51
- uuid: string;
52
- name: string;
53
- fact: string;
54
- valid_at: string | null;
55
- invalid_at: string | null;
56
- created_at: string;
57
- expired_at: string | null;
58
- };
59
-
60
- type SearchRequest = {
61
- group_ids: string[];
62
- query: string;
63
- max_facts?: number;
64
- };
65
-
66
- type SearchResults = {
67
- facts: FactResult[];
68
- };
69
-
70
- type GraphitiResult = {
71
- message: string;
72
- success: boolean;
73
- };
74
-
75
- // ============================================================================
76
- // GraphitiBackend
77
- // ============================================================================
78
-
79
- export type GraphitiConfig = {
80
- endpoint: string;
81
- defaultGroupId: string;
82
- uuidPollIntervalMs: number;
83
- uuidPollMaxAttempts: number;
84
- requestTimeoutMs?: number;
85
- customInstructions: string;
86
- };
87
-
88
- export class GraphitiBackend implements MemoryBackend {
89
- readonly name = "graphiti";
90
-
91
- readonly uuidPollIntervalMs: number;
92
- readonly uuidPollMaxAttempts: number;
93
- private readonly requestTimeoutMs: number;
94
-
95
- constructor(private readonly config: GraphitiConfig) {
96
- this.uuidPollIntervalMs = config.uuidPollIntervalMs;
97
- this.uuidPollMaxAttempts = config.uuidPollMaxAttempts;
98
- this.requestTimeoutMs = config.requestTimeoutMs ?? 30000;
99
- }
100
-
101
- // --------------------------------------------------------------------------
102
- // REST transport
103
- // --------------------------------------------------------------------------
104
-
105
- private async restCall<T>(
106
- method: "GET" | "POST" | "DELETE",
107
- path: string,
108
- body?: unknown,
109
- ): Promise<T> {
110
- const url = `${this.config.endpoint}${path}`;
111
- const opts: RequestInit = {
112
- method,
113
- headers: { "Content-Type": "application/json" },
114
- signal: AbortSignal.timeout(this.requestTimeoutMs),
115
- };
116
- if (body !== undefined) {
117
- opts.body = JSON.stringify(body);
118
- }
119
- const response = await fetch(url, opts);
120
- if (!response.ok) {
121
- const text = await response.text().catch(() => "");
122
- throw new Error(`Graphiti REST ${method} ${path} failed: ${response.status} ${text}`);
123
- }
124
- const ct = response.headers.get("content-type") ?? "";
125
- if (ct.includes("application/json")) {
126
- return (await response.json()) as T;
127
- }
128
- return {} as T;
129
- }
130
-
131
- // --------------------------------------------------------------------------
132
- // MemoryBackend implementation
133
- // --------------------------------------------------------------------------
134
-
135
- async store(params: {
136
- content: string;
137
- groupId: string;
138
- sourceDescription?: string;
139
- customPrompt?: string;
140
- }): Promise<StoreResult> {
141
- const episodeName = `memory_${randomUUID()}`;
142
- let effectiveBody = params.content;
143
- if (params.customPrompt) {
144
- effectiveBody = `[Extraction Instructions]\n${params.customPrompt}\n[End Instructions]\n\n${params.content}`;
145
- }
146
-
147
- const request: AddMessagesRequest = {
148
- group_id: params.groupId,
149
- messages: [
150
- {
151
- name: episodeName,
152
- content: effectiveBody,
153
- timestamp: new Date().toISOString(),
154
- role_type: "user",
155
- role: "user",
156
- source_description: params.sourceDescription,
157
- },
158
- ],
159
- };
160
-
161
- await this.restCall<GraphitiResult>("POST", "/messages", request);
162
-
163
- // POST /messages returns 202 (async processing).
164
- // Poll GET /episodes until the episode appears, then return its real UUID.
165
- const fragmentId = this.resolveEpisodeUuid(episodeName, params.groupId);
166
- fragmentId.catch(() => {}); // Prevent unhandled rejection if caller drops it
167
-
168
- return { fragmentId };
169
- }
170
-
171
- private async resolveEpisodeUuid(name: string, groupId: string): Promise<string> {
172
- for (let i = 0; i < this.uuidPollMaxAttempts; i++) {
173
- await new Promise((r) => setTimeout(r, this.uuidPollIntervalMs));
174
- try {
175
- const episodes = await this.getEpisodes(groupId, 50);
176
- const match = episodes.find((ep) => ep.name === name);
177
- if (match) return match.uuid;
178
- } catch {
179
- // Transient error — keep polling
180
- }
181
- }
182
- throw new Error(`Timed out resolving episode UUID for "${name}" in group "${groupId}"`);
183
- }
184
-
185
- async searchGroup(params: {
186
- query: string;
187
- groupId: string;
188
- limit: number;
189
- sessionId?: string;
190
- }): Promise<SearchResult[]> {
191
- const { query, groupId, limit } = params;
192
-
193
- const searchRequest: SearchRequest = {
194
- group_ids: [groupId],
195
- query,
196
- max_facts: limit,
197
- };
198
-
199
- const response = await this.restCall<SearchResults>("POST", "/search", searchRequest);
200
- const facts = response.facts ?? [];
201
-
202
- return facts.map((f) => ({
203
- type: "fact" as const,
204
- uuid: f.uuid,
205
- group_id: groupId,
206
- summary: f.fact,
207
- context: f.name,
208
- created_at: f.created_at,
209
- }));
210
- }
211
-
212
- async getConversationHistory(sessionId: string, lastN = 10): Promise<ConversationTurn[]> {
213
- const sessionGroup = `session-${sessionId.replace(/[^a-zA-Z0-9_-]/g, "-")}`;
214
- try {
215
- const episodes = await this.getEpisodes(sessionGroup, lastN);
216
- return episodes.map((ep) => ({
217
- query: ep.name,
218
- answer: ep.content,
219
- created_at: ep.created_at,
220
- }));
221
- } catch {
222
- return [];
223
- }
224
- }
225
-
226
- async healthCheck(): Promise<boolean> {
227
- try {
228
- const response = await fetch(`${this.config.endpoint}/healthcheck`, {
229
- signal: AbortSignal.timeout(5000),
230
- });
231
- return response.ok;
232
- } catch {
233
- return false;
234
- }
235
- }
236
-
237
- async getStatus(): Promise<Record<string, unknown>> {
238
- return {
239
- backend: "graphiti",
240
- endpoint: this.config.endpoint,
241
- healthy: await this.healthCheck(),
242
- };
243
- }
244
-
245
- async deleteGroup(groupId: string): Promise<void> {
246
- await this.restCall<GraphitiResult>(
247
- "DELETE",
248
- `/group/${encodeURIComponent(groupId)}`,
249
- );
250
- }
251
-
252
- async listGroups(): Promise<BackendDataset[]> {
253
- // Graphiti has no list-groups API; the CLI can query SpiceDB for this
254
- return [];
255
- }
256
-
257
- async deleteFragment(uuid: string): Promise<boolean> {
258
- await this.restCall<GraphitiResult>(
259
- "DELETE",
260
- `/episode/${encodeURIComponent(uuid)}`,
261
- );
262
- return true;
263
- }
264
-
265
- // --------------------------------------------------------------------------
266
- // Graphiti-specific helpers (used by CLI commands and UUID polling)
267
- // --------------------------------------------------------------------------
268
-
269
- async getEpisodes(groupId: string, lastN: number): Promise<GraphitiEpisode[]> {
270
- return this.restCall<GraphitiEpisode[]>(
271
- "GET",
272
- `/episodes/${encodeURIComponent(groupId)}?last_n=${lastN}`,
273
- );
274
- }
275
-
276
- async getEntityEdge(uuid: string): Promise<FactResult> {
277
- return this.restCall<FactResult>(
278
- "GET",
279
- `/entity-edge/${encodeURIComponent(uuid)}`,
280
- );
281
- }
282
-
283
- // --------------------------------------------------------------------------
284
- // Backend-specific CLI commands
285
- // --------------------------------------------------------------------------
286
-
287
- registerCliCommands(cmd: Command): void {
288
- cmd
289
- .command("episodes")
290
- .description("[graphiti] List recent episodes for a group")
291
- .option("--last <n>", "Number of episodes", "10")
292
- .option("--group <id>", "Group ID")
293
- .action(async (opts: { last: string; group?: string }) => {
294
- const groupId = opts.group ?? this.config.defaultGroupId;
295
- const episodes = await this.getEpisodes(groupId, parseInt(opts.last));
296
- console.log(JSON.stringify(episodes, null, 2));
297
- });
298
-
299
- cmd
300
- .command("fact")
301
- .description("[graphiti] Get a specific fact (entity edge) by UUID")
302
- .argument("<uuid>", "Fact UUID")
303
- .action(async (uuid: string) => {
304
- try {
305
- const fact = await this.getEntityEdge(uuid);
306
- console.log(JSON.stringify(fact, null, 2));
307
- } catch (err) {
308
- console.error(`Failed to get fact: ${err instanceof Error ? err.message : String(err)}`);
309
- }
310
- });
311
-
312
- cmd
313
- .command("clear-graph")
314
- .description("[graphiti] Clear graph data for a group (destructive!)")
315
- .option("--group <id...>", "Group ID(s)")
316
- .option("--confirm", "Required safety flag", false)
317
- .action(async (opts: { group?: string[]; confirm: boolean }) => {
318
- if (!opts.confirm) {
319
- console.log("Destructive operation. Pass --confirm to proceed.");
320
- return;
321
- }
322
- const groups = opts.group ?? [];
323
- if (groups.length === 0) {
324
- console.log("No groups specified. Use --group <id> to specify groups.");
325
- return;
326
- }
327
- for (const g of groups) {
328
- await this.deleteGroup(g);
329
- console.log(`Cleared group: ${g}`);
330
- }
331
- });
332
- }
333
- }
334
-
335
- // ============================================================================
336
- // Backend module exports (used by backends/registry.ts)
337
- // ============================================================================
338
-
339
- import graphitiDefaults from "./graphiti.defaults.json" with { type: "json" };
340
-
341
- export const defaults: Record<string, unknown> = graphitiDefaults;
342
-
343
- export function create(config: Record<string, unknown>): MemoryBackend {
344
- return new GraphitiBackend(config as GraphitiConfig);
345
- }
@@ -1,36 +0,0 @@
1
- /**
2
- * Backend registry — loaded dynamically from backends.json.
3
- *
4
- * Call initRegistry() once (e.g. at the start of register()) before using
5
- * backendRegistry or createBackend(). No backend names appear in this file.
6
- *
7
- * To add a new backend:
8
- * 1. Create backends/<name>.ts (exports `defaults` and `create`)
9
- * 2. Create backends/<name>.defaults.json
10
- * 3. Add `"<name>": "./<name>.js"` to backends/backends.json
11
- * No TypeScript changes needed anywhere else.
12
- */
13
-
14
- import backendsJson from "./backends.json" with { type: "json" };
15
- import type { MemoryBackend } from "../backend.js";
16
-
17
- export type BackendModule = {
18
- create: (config: Record<string, unknown>) => MemoryBackend;
19
- defaults: Record<string, unknown>;
20
- };
21
-
22
- // Mutable backing store — populated by initRegistry().
23
- // backendRegistry is a live reference to the same object.
24
- const _registry: Record<string, BackendModule> = {};
25
-
26
- export async function initRegistry(): Promise<void> {
27
- if (Object.keys(_registry).length > 0) return;
28
- for (const [name, modulePath] of Object.entries(backendsJson as Record<string, string>)) {
29
- const url = new URL(modulePath, import.meta.url);
30
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
31
- const mod = await import(url.href) as any;
32
- _registry[name] = mod as BackendModule;
33
- }
34
- }
35
-
36
- export const backendRegistry: Readonly<Record<string, BackendModule>> = _registry;