@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/cli.ts ADDED
@@ -0,0 +1,418 @@
1
+ /**
2
+ * Shared CLI command registration for rebac-mem.
3
+ *
4
+ * Registers backend-agnostic commands (search, status, schema-write, groups,
5
+ * add-member, import) then calls backend.registerCliCommands() for
6
+ * backend-specific extensions (e.g., graphiti: episodes, fact, clear-graph).
7
+ *
8
+ * Used by both the OpenClaw plugin (index.ts) and the standalone CLI
9
+ * (bin/rebac-mem.ts).
10
+ */
11
+
12
+ import type { Command } from "commander";
13
+ import { readFileSync } from "node:fs";
14
+ import { readdir, readFile, stat } from "node:fs/promises";
15
+ import { join, dirname, basename, resolve } from "node:path";
16
+ import { homedir } from "node:os";
17
+ import { fileURLToPath } from "node:url";
18
+
19
+ import type { MemoryBackend } from "./backend.js";
20
+ import type { SpiceDbClient, RelationshipTuple } from "./spicedb.js";
21
+ import type { RebacMemoryConfig } from "./config.js";
22
+ import { defaultGroupId } from "./config.js";
23
+ import {
24
+ lookupAuthorizedGroups,
25
+ writeFragmentRelationships,
26
+ ensureGroupMembership,
27
+ type Subject,
28
+ } from "./authorization.js";
29
+ import { searchAuthorizedMemories } from "./search.js";
30
+
31
+ // ============================================================================
32
+ // Session helper (mirrors index.ts — no shared module to avoid circular import)
33
+ // ============================================================================
34
+
35
+ function sessionGroupId(sessionId: string): string {
36
+ const sanitized = sessionId.replace(/[^a-zA-Z0-9_-]/g, "-");
37
+ return `session-${sanitized}`;
38
+ }
39
+
40
+ // ============================================================================
41
+ // CLI Context
42
+ // ============================================================================
43
+
44
+ export type CliContext = {
45
+ backend: MemoryBackend;
46
+ spicedb: SpiceDbClient;
47
+ cfg: RebacMemoryConfig;
48
+ currentSubject: Subject;
49
+ getLastWriteToken: () => string | undefined;
50
+ };
51
+
52
+ // ============================================================================
53
+ // Command Registration
54
+ // ============================================================================
55
+
56
+ export function registerCommands(cmd: Command, ctx: CliContext): void {
57
+ const { backend, spicedb, cfg, currentSubject, getLastWriteToken } = ctx;
58
+ const defGroupId = defaultGroupId(cfg);
59
+
60
+ // --------------------------------------------------------------------------
61
+ // search
62
+ // --------------------------------------------------------------------------
63
+
64
+ cmd
65
+ .command("search")
66
+ .description("Search memories with authorization")
67
+ .argument("<query>", "Search query")
68
+ .option("--limit <n>", "Max results", "10")
69
+ .action(async (query: string, opts: { limit: string }) => {
70
+ const authorizedGroups = await lookupAuthorizedGroups(spicedb, currentSubject, getLastWriteToken());
71
+ if (authorizedGroups.length === 0) {
72
+ console.log("No accessible memory groups.");
73
+ return;
74
+ }
75
+ console.log(`Searching ${authorizedGroups.length} authorized groups...`);
76
+ const results = await searchAuthorizedMemories(backend, {
77
+ query,
78
+ groupIds: authorizedGroups,
79
+ limit: parseInt(opts.limit),
80
+ });
81
+ if (results.length === 0) {
82
+ console.log("No results found.");
83
+ return;
84
+ }
85
+ console.log(JSON.stringify(results, null, 2));
86
+ });
87
+
88
+ // --------------------------------------------------------------------------
89
+ // status
90
+ // --------------------------------------------------------------------------
91
+
92
+ cmd
93
+ .command("status")
94
+ .description("Check backend + SpiceDB health")
95
+ .action(async () => {
96
+ const backendStatus = await backend.getStatus();
97
+ let spicedbOk = false;
98
+ try {
99
+ await spicedb.readSchema();
100
+ spicedbOk = true;
101
+ } catch {
102
+ // unreachable
103
+ }
104
+ console.log(`Backend (${backend.name}): ${backendStatus.healthy ? "OK" : "UNREACHABLE"}`);
105
+ for (const [k, v] of Object.entries(backendStatus)) {
106
+ if (k !== "backend" && k !== "healthy") console.log(` ${k}: ${v}`);
107
+ }
108
+ console.log(`SpiceDB: ${spicedbOk ? "OK" : "UNREACHABLE"} (${cfg.spicedb.endpoint})`);
109
+ });
110
+
111
+ // --------------------------------------------------------------------------
112
+ // schema-write
113
+ // --------------------------------------------------------------------------
114
+
115
+ cmd
116
+ .command("schema-write")
117
+ .description("Write/update SpiceDB authorization schema")
118
+ .action(async () => {
119
+ const schemaPath = join(dirname(fileURLToPath(import.meta.url)), "schema.zed");
120
+ const schema = readFileSync(schemaPath, "utf-8");
121
+ await spicedb.writeSchema(schema);
122
+ console.log("SpiceDB schema written successfully.");
123
+ });
124
+
125
+ // --------------------------------------------------------------------------
126
+ // groups
127
+ // --------------------------------------------------------------------------
128
+
129
+ cmd
130
+ .command("groups")
131
+ .description("List authorized groups for current subject")
132
+ .action(async () => {
133
+ const groups = await lookupAuthorizedGroups(spicedb, currentSubject, getLastWriteToken());
134
+ if (groups.length === 0) {
135
+ console.log("No authorized groups.");
136
+ return;
137
+ }
138
+ console.log(`Authorized groups for ${currentSubject.type}:${currentSubject.id}:`);
139
+ for (const g of groups) console.log(` - ${g}`);
140
+ });
141
+
142
+ // --------------------------------------------------------------------------
143
+ // add-member
144
+ // --------------------------------------------------------------------------
145
+
146
+ cmd
147
+ .command("add-member")
148
+ .description("Add a subject to a group")
149
+ .argument("<group-id>", "Group ID")
150
+ .argument("<subject-id>", "Subject ID")
151
+ .option("--type <type>", "Subject type (agent|person)", "person")
152
+ .action(async (groupId: string, subjectId: string, opts: { type: string }) => {
153
+ const subjectType = opts.type === "agent" ? "agent" : "person";
154
+ await ensureGroupMembership(spicedb, groupId, {
155
+ type: subjectType as "agent" | "person",
156
+ id: subjectId,
157
+ });
158
+ console.log(`Added ${subjectType}:${subjectId} to group:${groupId}`);
159
+ });
160
+
161
+ // --------------------------------------------------------------------------
162
+ // import — ingest workspace files + session transcripts into the backend
163
+ // --------------------------------------------------------------------------
164
+
165
+ cmd
166
+ .command("import")
167
+ .description("Import workspace markdown files (and optionally session transcripts) into the backend")
168
+ .option("--workspace <path>", "Workspace directory", join(homedir(), ".openclaw", "workspace"))
169
+ .option("--include-sessions", "Also import session JSONL transcripts", false)
170
+ .option("--sessions-only", "Only import session transcripts (skip workspace files)", false)
171
+ .option("--session-dir <path>", "Session transcripts directory", join(homedir(), ".openclaw", "agents", "main", "sessions"))
172
+ .option("--group <id>", "Target group for workspace files", defGroupId)
173
+ .option("--dry-run", "List files without importing", false)
174
+ .action(async (opts: {
175
+ workspace: string;
176
+ includeSessions: boolean;
177
+ sessionsOnly: boolean;
178
+ sessionDir: string;
179
+ group: string;
180
+ dryRun: boolean;
181
+ }) => {
182
+ const workspacePath = resolve(opts.workspace);
183
+ const targetGroup = opts.group;
184
+ const importSessions = opts.includeSessions || opts.sessionsOnly;
185
+ const importWorkspace = !opts.sessionsOnly;
186
+
187
+ let mdFiles: string[] = [];
188
+ try {
189
+ const entries = await readdir(workspacePath);
190
+ mdFiles = entries.filter((f) => f.endsWith(".md")).sort();
191
+ } catch {
192
+ console.error(`Cannot read workspace directory: ${workspacePath}`);
193
+ return;
194
+ }
195
+
196
+ // Also check memory/ subdirectory
197
+ try {
198
+ const memDir = join(workspacePath, "memory");
199
+ const memEntries = await readdir(memDir);
200
+ for (const f of memEntries) {
201
+ if (f.endsWith(".md")) mdFiles.push(join("memory", f));
202
+ }
203
+ } catch {
204
+ // No memory/ subdirectory — fine
205
+ }
206
+
207
+ if (importWorkspace) {
208
+ if (mdFiles.length === 0) {
209
+ console.log("No markdown files found in workspace.");
210
+ } else {
211
+ console.log(`Found ${mdFiles.length} workspace file(s) in ${workspacePath}:`);
212
+ for (const f of mdFiles) {
213
+ const filePath = join(workspacePath, f);
214
+ const info = await stat(filePath);
215
+ console.log(` ${f} (${info.size} bytes)`);
216
+ }
217
+ }
218
+ }
219
+
220
+ if (opts.dryRun) {
221
+ console.log("\n[dry-run] No files imported.");
222
+ if (importSessions) {
223
+ const sessionPath = resolve(opts.sessionDir);
224
+ try {
225
+ const sessions = (await readdir(sessionPath)).filter((f) => f.endsWith(".jsonl"));
226
+ console.log(`\nFound ${sessions.length} session transcript(s) in ${sessionPath}:`);
227
+ for (const f of sessions) {
228
+ const info = await stat(join(sessionPath, f));
229
+ console.log(` ${f} (${info.size} bytes)`);
230
+ }
231
+ } catch {
232
+ console.log(`\nCannot read session directory: ${sessionPath}`);
233
+ }
234
+ }
235
+ return;
236
+ }
237
+
238
+ // Phase 1: Store all content via backend.store(), collect fragmentId promises
239
+ type PendingResolution = {
240
+ fragmentId: Promise<string>;
241
+ groupId: string;
242
+ name: string;
243
+ };
244
+ const pending: PendingResolution[] = [];
245
+ const membershipGroups = new Set<string>();
246
+
247
+ // Phase 1a: Workspace files
248
+ if (importWorkspace && mdFiles.length > 0) {
249
+ if (importWorkspace) membershipGroups.add(targetGroup);
250
+ console.log(`\nPhase 1: Importing workspace files to ${backend.name} (group: ${targetGroup})...`);
251
+ let imported = 0;
252
+ for (const f of mdFiles) {
253
+ const filePath = join(workspacePath, f);
254
+ const content = await readFile(filePath, "utf-8");
255
+ if (!content.trim()) {
256
+ console.log(` Skipping ${f} (empty)`);
257
+ continue;
258
+ }
259
+ try {
260
+ const result = await backend.store({
261
+ content,
262
+ groupId: targetGroup,
263
+ sourceDescription: `Imported from workspace: ${f}`,
264
+ });
265
+ pending.push({ fragmentId: result.fragmentId, groupId: targetGroup, name: f });
266
+ console.log(` Queued ${f} (${content.length} bytes)`);
267
+ imported++;
268
+ } catch (err) {
269
+ console.error(` Failed to import ${f}: ${err instanceof Error ? err.message : String(err)}`);
270
+ }
271
+ }
272
+ console.log(`Workspace: ${imported}/${mdFiles.length} files ingested.`);
273
+ }
274
+
275
+ // Phase 1b: Session transcripts
276
+ if (importSessions) {
277
+ const sessionPath = resolve(opts.sessionDir);
278
+ let jsonlFiles: string[] = [];
279
+ try {
280
+ jsonlFiles = (await readdir(sessionPath)).filter((f) => f.endsWith(".jsonl")).sort();
281
+ } catch {
282
+ console.error(`\nCannot read session directory: ${sessionPath}`);
283
+ }
284
+
285
+ if (jsonlFiles.length === 0) {
286
+ console.log("\nNo session transcripts found.");
287
+ } else {
288
+ console.log(`\nPhase 1: Importing ${jsonlFiles.length} session transcript(s) to ${backend.name}...`);
289
+ let sessionsImported = 0;
290
+ for (const f of jsonlFiles) {
291
+ const sessionId = basename(f, ".jsonl");
292
+ const sessionGroup = sessionGroupId(sessionId);
293
+ const filePath = join(sessionPath, f);
294
+ const raw = await readFile(filePath, "utf-8");
295
+ const lines = raw.split("\n").filter(Boolean);
296
+
297
+ const conversationLines: string[] = [];
298
+ for (const line of lines) {
299
+ try {
300
+ const entry = JSON.parse(line) as Record<string, unknown>;
301
+ const msg = (entry.type === "message" && entry.message && typeof entry.message === "object")
302
+ ? entry.message as Record<string, unknown>
303
+ : entry;
304
+ const role = msg.role as string | undefined;
305
+ if (role !== "user" && role !== "assistant") continue;
306
+ const content = msg.content;
307
+ let text = "";
308
+ if (typeof content === "string") {
309
+ text = content;
310
+ } else if (Array.isArray(content)) {
311
+ text = content
312
+ .filter((b: unknown) =>
313
+ typeof b === "object" && b !== null &&
314
+ (b as Record<string, unknown>).type === "text" &&
315
+ typeof (b as Record<string, unknown>).text === "string",
316
+ )
317
+ .map((b: unknown) => (b as Record<string, unknown>).text as string)
318
+ .join("\n");
319
+ }
320
+ if (
321
+ text && text.length >= 5 &&
322
+ !text.includes("<relevant-memories>") &&
323
+ !text.includes("<memory-tools>")
324
+ ) {
325
+ const roleLabel = role === "user" ? "User" : "Assistant";
326
+ conversationLines.push(`${roleLabel}: ${text}`);
327
+ }
328
+ } catch {
329
+ // Skip malformed JSONL lines
330
+ }
331
+ }
332
+
333
+ if (conversationLines.length === 0) {
334
+ console.log(` Skipping ${f} (no user/assistant messages)`);
335
+ continue;
336
+ }
337
+
338
+ try {
339
+ const result = await backend.store({
340
+ content: conversationLines.join("\n"),
341
+ groupId: sessionGroup,
342
+ sourceDescription: `Imported session transcript: ${sessionId}`,
343
+ });
344
+ membershipGroups.add(sessionGroup);
345
+ pending.push({ fragmentId: result.fragmentId, groupId: sessionGroup, name: f });
346
+ console.log(` Queued ${f} (${conversationLines.length} messages) [group: ${sessionGroup}]`);
347
+ sessionsImported++;
348
+ } catch (err) {
349
+ console.error(` Failed to import ${f}: ${err instanceof Error ? err.message : String(err)}`);
350
+ }
351
+ }
352
+ console.log(`Sessions: ${sessionsImported}/${jsonlFiles.length} transcripts ingested.`);
353
+ }
354
+ }
355
+
356
+ // Phase 1.5: Await all fragmentIds concurrently
357
+ const pendingTuples: RelationshipTuple[] = [];
358
+ if (pending.length > 0) {
359
+ console.log(`\nResolving ${pending.length} fragment UUIDs...`);
360
+ const resolutions = await Promise.allSettled(pending.map((p) => p.fragmentId));
361
+ for (let i = 0; i < resolutions.length; i++) {
362
+ const r = resolutions[i];
363
+ if (r.status === "fulfilled") {
364
+ const fragmentId = r.value;
365
+ pendingTuples.push(
366
+ {
367
+ resourceType: "memory_fragment",
368
+ resourceId: fragmentId,
369
+ relation: "source_group",
370
+ subjectType: "group",
371
+ subjectId: pending[i].groupId,
372
+ },
373
+ {
374
+ resourceType: "memory_fragment",
375
+ resourceId: fragmentId,
376
+ relation: "shared_by",
377
+ subjectType: currentSubject.type,
378
+ subjectId: currentSubject.id,
379
+ },
380
+ );
381
+ console.log(` ${pending[i].name} → ${fragmentId}`);
382
+ } else {
383
+ console.warn(` Warning: could not resolve UUID for ${pending[i].name} — SpiceDB linkage skipped`);
384
+ }
385
+ }
386
+ }
387
+
388
+ // Phase 2: Bulk write SpiceDB relationships + memberships
389
+ if (pendingTuples.length > 0 || membershipGroups.size > 0) {
390
+ for (const groupId of membershipGroups) {
391
+ pendingTuples.push({
392
+ resourceType: "group",
393
+ resourceId: groupId,
394
+ relation: "member",
395
+ subjectType: currentSubject.type,
396
+ subjectId: currentSubject.id,
397
+ });
398
+ }
399
+
400
+ console.log(`\nPhase 2: Writing ${pendingTuples.length} SpiceDB relationships...`);
401
+ try {
402
+ const count = await spicedb.bulkImportRelationships(pendingTuples);
403
+ console.log(`SpiceDB: ${count} relationships written.`);
404
+ } catch (err) {
405
+ console.error(`SpiceDB bulk import failed: ${err instanceof Error ? err.message : String(err)}`);
406
+ console.error("Backend episodes were ingested but lack authorization. Re-run import or add relationships manually.");
407
+ }
408
+ }
409
+
410
+ console.log("\nImport complete.");
411
+ });
412
+
413
+ // --------------------------------------------------------------------------
414
+ // Backend-specific extension point
415
+ // --------------------------------------------------------------------------
416
+
417
+ backend.registerCliCommands?.(cmd);
418
+ }
package/config.ts ADDED
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Unified configuration for openclaw-memory-rebac.
3
+ *
4
+ * Backend-specific defaults live in backends/<name>.defaults.json.
5
+ * Available backends are listed in backends/backends.json.
6
+ * No backend names appear in this file.
7
+ */
8
+
9
+ import type { MemoryBackend } from "./backend.js";
10
+ import { backendRegistry } from "./backends/registry.js";
11
+ import pluginDefaults from "./plugin.defaults.json" with { type: "json" };
12
+
13
+ // ============================================================================
14
+ // Config type
15
+ // ============================================================================
16
+
17
+ export type RebacMemoryConfig = {
18
+ backend: string;
19
+ spicedb: {
20
+ endpoint: string;
21
+ token: string;
22
+ insecure: boolean;
23
+ };
24
+ backendConfig: Record<string, unknown>;
25
+ subjectType: "agent" | "person";
26
+ subjectId: string;
27
+ autoCapture: boolean;
28
+ autoRecall: boolean;
29
+ maxCaptureMessages: number;
30
+ };
31
+
32
+ // ============================================================================
33
+ // Helpers
34
+ // ============================================================================
35
+
36
+ function resolveEnvVars(value: string): string {
37
+ return value.replace(/\$\{([^}]+)\}/g, (_, envVar) => {
38
+ const envValue = process.env[envVar];
39
+ if (!envValue) {
40
+ throw new Error(`Environment variable ${envVar} is not set`);
41
+ }
42
+ return envValue;
43
+ });
44
+ }
45
+
46
+ function assertAllowedKeys(value: Record<string, unknown>, allowed: string[], label: string) {
47
+ const unknown = Object.keys(value).filter((key) => !allowed.includes(key));
48
+ if (unknown.length > 0) {
49
+ throw new Error(`${label} has unknown keys: ${unknown.join(", ")}`);
50
+ }
51
+ }
52
+
53
+ // ============================================================================
54
+ // Config schema
55
+ // ============================================================================
56
+
57
+ export const rebacMemoryConfigSchema = {
58
+ parse(value: unknown): RebacMemoryConfig {
59
+ if (Array.isArray(value)) {
60
+ throw new Error("openclaw-memory-rebac config must be an object, not an array");
61
+ }
62
+ const cfg = (value && typeof value === "object" ? value : {}) as Record<string, unknown>;
63
+
64
+ const backendName =
65
+ typeof cfg.backend === "string" ? cfg.backend : pluginDefaults.backend;
66
+
67
+ const entry = backendRegistry[backendName];
68
+ if (!entry) throw new Error(`Unknown backend: "${backendName}"`);
69
+
70
+ // Top-level allowed keys: shared keys + the backend name key
71
+ assertAllowedKeys(
72
+ cfg,
73
+ [
74
+ "backend", "spicedb",
75
+ "subjectType", "subjectId",
76
+ "autoCapture", "autoRecall", "maxCaptureMessages",
77
+ backendName,
78
+ ],
79
+ "openclaw-memory-rebac config",
80
+ );
81
+
82
+ // SpiceDB config (shared)
83
+ const spicedb = (cfg.spicedb as Record<string, unknown>) ?? {};
84
+ assertAllowedKeys(spicedb, ["endpoint", "token", "insecure"], "spicedb config");
85
+
86
+ // Backend config: user overrides merged over JSON defaults
87
+ const backendRaw = (cfg[backendName] as Record<string, unknown>) ?? {};
88
+ assertAllowedKeys(backendRaw, Object.keys(entry.defaults), `${backendName} config`);
89
+ const backendConfig = { ...entry.defaults, ...backendRaw };
90
+
91
+ const subjectType =
92
+ cfg.subjectType === "person" ? "person" : (pluginDefaults.subjectType as "agent" | "person");
93
+ const subjectId =
94
+ typeof cfg.subjectId === "string" ? resolveEnvVars(cfg.subjectId) : pluginDefaults.subjectId;
95
+
96
+ return {
97
+ backend: backendName,
98
+ spicedb: {
99
+ endpoint:
100
+ typeof spicedb.endpoint === "string"
101
+ ? spicedb.endpoint
102
+ : pluginDefaults.spicedb.endpoint,
103
+ token: typeof spicedb.token === "string" ? resolveEnvVars(spicedb.token) : "",
104
+ insecure:
105
+ typeof spicedb.insecure === "boolean"
106
+ ? spicedb.insecure
107
+ : pluginDefaults.spicedb.insecure,
108
+ },
109
+ backendConfig,
110
+ subjectType,
111
+ subjectId,
112
+ autoCapture: cfg.autoCapture !== false,
113
+ autoRecall: cfg.autoRecall !== false,
114
+ maxCaptureMessages:
115
+ typeof cfg.maxCaptureMessages === "number" && cfg.maxCaptureMessages > 0
116
+ ? cfg.maxCaptureMessages
117
+ : pluginDefaults.maxCaptureMessages,
118
+ };
119
+ },
120
+ };
121
+
122
+ // ============================================================================
123
+ // Backend factory
124
+ // ============================================================================
125
+
126
+ /**
127
+ * Instantiate the configured MemoryBackend from the parsed config.
128
+ * Call this once during plugin registration — the returned backend is stateful.
129
+ */
130
+ export function createBackend(cfg: RebacMemoryConfig): MemoryBackend {
131
+ const entry = backendRegistry[cfg.backend];
132
+ if (!entry) throw new Error(`Unknown backend: "${cfg.backend}"`);
133
+ return entry.create(cfg.backendConfig);
134
+ }
135
+
136
+ /**
137
+ * Return the default group ID for the active backend.
138
+ */
139
+ export function defaultGroupId(cfg: RebacMemoryConfig): string {
140
+ return (cfg.backendConfig["defaultGroupId"] as string) ?? "main";
141
+ }
@@ -0,0 +1,17 @@
1
+ ###############################################################################
2
+ # openclaw-memory-rebac — Combined stack (Graphiti + SpiceDB)
3
+ #
4
+ # Brings up the full memory service stack in a single command:
5
+ # - Graphiti FastAPI REST server + Neo4j
6
+ # - SpiceDB authorization engine + PostgreSQL
7
+ #
8
+ # Usage:
9
+ # docker compose up -d
10
+ #
11
+ # Graphiti endpoint: http://localhost:8000
12
+ # SpiceDB endpoint: localhost:50051 (insecure)
13
+ ###############################################################################
14
+
15
+ include:
16
+ - path: ./spicedb/docker-compose.yml
17
+ - path: ./graphiti/docker-compose.yml
@@ -0,0 +1,35 @@
1
+ ###############################################################################
2
+ # Custom Graphiti FastAPI server with per-component LLM/embedder/reranker
3
+ # configurability and BGE reranker support.
4
+ #
5
+ # Extends the base zepai/graphiti image to:
6
+ # 1. Install sentence-transformers for BGE reranker
7
+ # 2. Overlay per-component config + startup to wire separate clients
8
+ ###############################################################################
9
+
10
+ FROM zepai/graphiti:latest
11
+
12
+ # Base image runs as non-root "app" user; switch to root to install packages
13
+ USER root
14
+
15
+ # Ensure the venv is active for all subsequent RUN and CMD
16
+ ENV VIRTUAL_ENV=/app/.venv
17
+ ENV PATH="/app/.venv/bin:${PATH}"
18
+
19
+ # Install sentence-transformers for BGE reranker (runs locally, no API needed)
20
+ RUN uv pip install 'sentence-transformers>=3.2.1' \
21
+ && python -c "import sentence_transformers; print('OK')"
22
+
23
+ # Overlay: per-component config + startup
24
+ COPY config_overlay.py /app/graph_service/config_overlay.py
25
+ COPY graphiti_overlay.py /app/graph_service/graphiti_overlay.py
26
+ COPY startup.py /app/startup.py
27
+
28
+ # Fix ownership for app user
29
+ RUN chown -R app:app /app
30
+
31
+ # Switch back to non-root user
32
+ USER app
33
+
34
+ # Run startup directly via venv python (not `uv run`, which may reset the venv)
35
+ CMD ["/app/.venv/bin/python", "/app/startup.py"]
@@ -0,0 +1,44 @@
1
+ """
2
+ Extended Graphiti Settings with per-component embedder/reranker configuration.
3
+
4
+ Adds these env vars beyond the base Settings:
5
+ EMBEDDING_BASE_URL — separate base URL for embedder (defaults to OPENAI_BASE_URL)
6
+ EMBEDDING_API_KEY — separate API key for embedder (defaults to OPENAI_API_KEY)
7
+ EMBEDDING_DIM — embedding dimensions (default: unset, uses model default)
8
+ RERANKER_PROVIDER — "bge" (local, default) or "openai" (remote API)
9
+ RERANKER_MODEL — model name for remote reranker (ignored when provider=bge)
10
+ RERANKER_BASE_URL — base URL for remote reranker (ignored when provider=bge)
11
+ RERANKER_API_KEY — API key for remote reranker (ignored when provider=bge)
12
+ """
13
+
14
+ from pydantic import Field
15
+ from pydantic_settings import BaseSettings, SettingsConfigDict
16
+
17
+
18
+ class ExtendedSettings(BaseSettings):
19
+ # -- LLM (entity extraction) --
20
+ openai_api_key: str
21
+ openai_base_url: str | None = Field(None)
22
+ model_name: str | None = Field(None)
23
+
24
+ # -- Embedder --
25
+ embedding_model_name: str | None = Field(None)
26
+ embedding_base_url: str | None = Field(None)
27
+ embedding_api_key: str | None = Field(None)
28
+ embedding_dim: int | None = Field(None)
29
+
30
+ # -- Reranker / cross-encoder --
31
+ reranker_provider: str = "bge" # "bge" (local) or "openai" (remote)
32
+ reranker_model: str | None = Field(None)
33
+ reranker_base_url: str | None = Field(None)
34
+ reranker_api_key: str | None = Field(None)
35
+
36
+ # -- Neo4j --
37
+ neo4j_uri: str = "bolt://localhost:7687"
38
+ neo4j_user: str = "neo4j"
39
+ neo4j_password: str = "password"
40
+
41
+ # -- Server --
42
+ port: int = 8000
43
+
44
+ model_config = SettingsConfigDict(env_file=".env", extra="ignore")