@remnic/server 9.54.4 → 9.54.6

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/src/index.ts ADDED
@@ -0,0 +1,1207 @@
1
+ /**
2
+ * @remnic/server
3
+ *
4
+ * Standalone Remnic memory server.
5
+ *
6
+ * Loads config from `remnic.config.json` (or env vars), creates an Orchestrator,
7
+ * and starts the HTTP access server with MCP endpoint — no OpenClaw required.
8
+ *
9
+ * Usage:
10
+ * npx --package @remnic/server remnic-server
11
+ * npx --package @remnic/server remnic-server --config ./my-remnic.json
12
+ * npx --package @remnic/server remnic-server --port 4320
13
+ */
14
+
15
+ import fs from "node:fs";
16
+ import path from "node:path";
17
+ import { parseConfig, isOpenaiApiKeyDisabled, resolveRemnicConfigRecord, Orchestrator, EngramAccessService, EngramAccessHttpServer, initLogger, log, getAllValidTokens, getAllValidTokenEntriesCached, loadTokenStore, expandTildePath, type PluginConfig, type RemnicAdminControls, type RemnicAdminDashboardStatus, type RemnicAdminModelOption, type RemnicAdminConfigPatch } from "@remnic/core";
18
+ import { probeBetterSqlite3Driver } from "@remnic/core/runtime/better-sqlite";
19
+ import { applyOAuthEnvOverrides, buildOAuthRequestHandler } from "./oauth.js";
20
+ import { envOverrides, readCompatEnv } from "./server-env.js";
21
+ import {
22
+ STARTUP_DEGRADED_AFTER_ATTEMPTS,
23
+ abortableDelay,
24
+ completeStartupReadiness,
25
+ runStartupSearchWarmup,
26
+ type StartupReadinessState,
27
+ } from "./startup-readiness.js";
28
+ import { createSupportPassportServerRuntime } from "./support-passport-runtime.js";
29
+ export { envOverrides };
30
+ export {
31
+ completeStartupReadiness,
32
+ runStartupSearchWarmup,
33
+ type StartupReadinessOutcome,
34
+ type StartupReadinessState,
35
+ } from "./startup-readiness.js";
36
+
37
+ // ── Config loading ──────────────────────────────────────────────────────────
38
+
39
+ export interface ServerConfig {
40
+ remnic: Record<string, unknown>;
41
+ server: {
42
+ host?: string;
43
+ port?: unknown;
44
+ authToken?: string;
45
+ principal?: string;
46
+ maxBodyBytes?: number;
47
+ /** Max write requests per rolling window before 429 write_rate_limited (issue #1937). */
48
+ writeRateLimitMaxRequests?: number;
49
+ /** Rolling window for the write rate limit, in ms (issue #1937). */
50
+ writeRateLimitWindowMs?: number;
51
+ adminConsoleEnabled?: boolean;
52
+ adminConsolePublicDir?: string;
53
+ adminConsolePrefillToken?: boolean;
54
+ readinessOverride?: boolean;
55
+ /**
56
+ * Failed search warm-up attempts before the init gate opens in degraded
57
+ * mode (issue #2215). 0 keeps the strict gate (health stays 503 until
58
+ * warm-up completes).
59
+ */
60
+ readinessDegradedAfterAttempts?: unknown;
61
+ /** OAuth authorization-server facade for ChatGPT dev-mode apps (parsed by oauth.ts). */
62
+ oauth?: unknown;
63
+ };
64
+ }
65
+
66
+ function parseServerPort(value: unknown, source: string): number {
67
+ const port = typeof value === "string" ? Number(value.trim()) : value;
68
+ if (
69
+ typeof port !== "number" ||
70
+ !Number.isInteger(port) ||
71
+ port < 1 ||
72
+ port > 65535
73
+ ) {
74
+ throw new Error(`Invalid ${source}: expected an integer port from 1 to 65535`);
75
+ }
76
+ return port;
77
+ }
78
+
79
+ function parseOptionalString(value: unknown, source: string): string | undefined {
80
+ if (value === undefined) return undefined;
81
+ if (typeof value !== "string") {
82
+ throw new Error(`Invalid ${source}: expected a string`);
83
+ }
84
+ return value;
85
+ }
86
+
87
+ function parseOptionalNonEmptyString(value: unknown, source: string): string | undefined {
88
+ const parsed = parseOptionalString(value, source);
89
+ if (parsed === undefined) return undefined;
90
+ if (parsed.trim() === "") {
91
+ throw new Error(`Invalid ${source}: expected a non-empty string`);
92
+ }
93
+ return parsed;
94
+ }
95
+
96
+ function parseOptionalPositiveInteger(value: unknown, source: string): number | undefined {
97
+ if (value === undefined) return undefined;
98
+ const parsed = typeof value === "string" ? Number(value.trim()) : value;
99
+ if (
100
+ typeof parsed !== "number" ||
101
+ !Number.isInteger(parsed) ||
102
+ parsed < 1
103
+ ) {
104
+ throw new Error(`Invalid ${source}: expected a positive integer`);
105
+ }
106
+ return parsed;
107
+ }
108
+
109
+ function parseOptionalBoolean(value: unknown, source: string): boolean | undefined {
110
+ if (value === undefined) return undefined;
111
+ if (typeof value === "boolean") return value;
112
+ if (typeof value === "string") {
113
+ const normalized = value.trim().toLowerCase();
114
+ if (["true", "1", "yes", "on"].includes(normalized)) return true;
115
+ if (["false", "0", "no", "off"].includes(normalized)) return false;
116
+ }
117
+ throw new Error(`Invalid ${source}: expected a boolean`);
118
+ }
119
+
120
+ function parseOptionalNonNegativeInteger(value: unknown, source: string): number | undefined {
121
+ if (value === undefined) return undefined;
122
+ // Reject blank strings BEFORE coercion: Number("") is 0, which would
123
+ // silently enable the 0-means-strict-gate semantics (codex review).
124
+ const parsed = typeof value === "string"
125
+ ? value.trim() === "" ? Number.NaN : Number(value.trim())
126
+ : value;
127
+ if (typeof parsed !== "number" || !Number.isInteger(parsed) || parsed < 0) {
128
+ throw new Error(`Invalid ${source}: expected a non-negative integer`);
129
+ }
130
+ return parsed;
131
+ }
132
+
133
+ export interface ParsedServerConfig {
134
+ host: string;
135
+ port: number;
136
+ authToken?: string;
137
+ principal?: string;
138
+ maxBodyBytes?: number;
139
+ writeRateLimitMaxRequests?: number;
140
+ writeRateLimitWindowMs?: number;
141
+ adminConsoleEnabled: boolean;
142
+ adminConsolePublicDir?: string;
143
+ adminConsolePrefillToken: boolean;
144
+ readinessOverride: boolean;
145
+ readinessDegradedAfterAttempts: number;
146
+ }
147
+
148
+ export function parseServerConfig(
149
+ raw: Partial<ServerConfig["server"]>,
150
+ options?: { portSource?: string },
151
+ ): ParsedServerConfig {
152
+ return {
153
+ host: parseOptionalNonEmptyString(raw.host, "server.host") ?? "127.0.0.1",
154
+ port: raw.port === undefined
155
+ ? 4318
156
+ : parseServerPort(raw.port, options?.portSource ?? "server.port"),
157
+ authToken: parseOptionalString(raw.authToken, "server.authToken"),
158
+ principal: parseOptionalString(raw.principal, "server.principal"),
159
+ maxBodyBytes: parseOptionalPositiveInteger(raw.maxBodyBytes, "server.maxBodyBytes"),
160
+ writeRateLimitMaxRequests: parseOptionalPositiveInteger(
161
+ raw.writeRateLimitMaxRequests,
162
+ "server.writeRateLimitMaxRequests",
163
+ ),
164
+ writeRateLimitWindowMs: parseOptionalPositiveInteger(
165
+ raw.writeRateLimitWindowMs,
166
+ "server.writeRateLimitWindowMs",
167
+ ),
168
+ adminConsoleEnabled: parseOptionalBoolean(raw.adminConsoleEnabled, "server.adminConsoleEnabled") ?? false,
169
+ adminConsolePublicDir: parseOptionalString(raw.adminConsolePublicDir, "server.adminConsolePublicDir"),
170
+ adminConsolePrefillToken: parseOptionalBoolean(raw.adminConsolePrefillToken, "server.adminConsolePrefillToken") ?? false,
171
+ readinessOverride: parseOptionalBoolean(raw.readinessOverride, "server.readinessOverride") ?? false,
172
+ readinessDegradedAfterAttempts:
173
+ parseOptionalNonNegativeInteger(
174
+ raw.readinessDegradedAfterAttempts,
175
+ "server.readinessDegradedAfterAttempts",
176
+ ) ?? STARTUP_DEGRADED_AFTER_ATTEMPTS,
177
+ };
178
+ }
179
+
180
+ interface ResolvedConfigPath {
181
+ path: string;
182
+ explicit: boolean;
183
+ source: string;
184
+ }
185
+
186
+ function resolveUserPath(value: string): string {
187
+ return path.resolve(expandTildePath(value));
188
+ }
189
+
190
+ function resolveConfigPath(cliPath?: string): ResolvedConfigPath {
191
+ if (cliPath) {
192
+ return { path: resolveUserPath(cliPath), explicit: true, source: "--config" };
193
+ }
194
+
195
+ const envPath = readCompatEnv("REMNIC_CONFIG_PATH", "ENGRAM_CONFIG_PATH");
196
+ if (envPath) {
197
+ return { path: resolveUserPath(envPath), explicit: true, source: "REMNIC_CONFIG_PATH/ENGRAM_CONFIG_PATH" };
198
+ }
199
+
200
+ const homeDir = process.env.HOME ?? "~";
201
+ const candidates = [
202
+ path.join(process.cwd(), "remnic.config.json"),
203
+ path.join(process.cwd(), "engram.config.json"),
204
+ path.join(homeDir, ".config", "remnic", "config.json"),
205
+ path.join(homeDir, ".config", "engram", "config.json"),
206
+ ];
207
+ for (const candidate of candidates) {
208
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
209
+ return { path: candidate, explicit: false, source: "auto-discovery" };
210
+ }
211
+ }
212
+
213
+ return { path: path.join(homeDir, ".config", "remnic", "config.json"), explicit: false, source: "auto-discovery" };
214
+ }
215
+
216
+ function isPlainRecord(value: unknown): value is Record<string, unknown> {
217
+ return !!value && typeof value === "object" && !Array.isArray(value);
218
+ }
219
+
220
+ function requirePlainConfigBlock(
221
+ raw: Record<string, unknown>,
222
+ key: "remnic" | "engram" | "server",
223
+ configPath: string,
224
+ ): Record<string, unknown> | undefined {
225
+ const value = raw[key];
226
+ if (value === undefined) return undefined;
227
+ if (!isPlainRecord(value)) {
228
+ throw new Error(`Invalid config file ${configPath}: ${key} must be a JSON object`);
229
+ }
230
+ return value;
231
+ }
232
+
233
+ export function loadConfigFile(configPath: string): ServerConfig {
234
+ const raw = JSON.parse(fs.readFileSync(configPath, "utf8"));
235
+ if (!isPlainRecord(raw)) {
236
+ throw new Error(`Invalid config file ${configPath}: top-level config must be a JSON object`);
237
+ }
238
+ requirePlainConfigBlock(raw, "remnic", configPath);
239
+ requirePlainConfigBlock(raw, "engram", configPath);
240
+ const server = requirePlainConfigBlock(raw, "server", configPath);
241
+ return {
242
+ remnic: resolveRemnicConfigRecord(raw),
243
+ server: server ?? {},
244
+ };
245
+ }
246
+
247
+ function loadResolvedConfig(resolved: ResolvedConfigPath): ServerConfig {
248
+ if (!fs.existsSync(resolved.path)) {
249
+ if (resolved.explicit) {
250
+ throw new Error(`Config file from ${resolved.source} not found: ${resolved.path}`);
251
+ }
252
+ return { remnic: {}, server: {} };
253
+ }
254
+
255
+ const stat = fs.statSync(resolved.path);
256
+ if (!stat.isFile()) {
257
+ if (!resolved.explicit) {
258
+ return { remnic: {}, server: {} };
259
+ }
260
+ throw new Error(`Config file from ${resolved.source} is not a regular file: ${resolved.path}`);
261
+ }
262
+
263
+ return loadConfigFile(resolved.path);
264
+ }
265
+ type ServerRuntimeOptions = {
266
+ configPath?: string;
267
+ host?: string;
268
+ port?: number;
269
+ authToken?: string;
270
+ };
271
+
272
+ type EffectiveServerRuntimeConfig = {
273
+ resolvedConfigPath: ResolvedConfigPath;
274
+ fileConfig: ServerConfig;
275
+ envRemnic: Record<string, unknown> | undefined;
276
+ serverConfig: Partial<ServerConfig["server"]>;
277
+ parsedServerConfig: ParsedServerConfig;
278
+ };
279
+
280
+ function resolveEffectiveServerRuntimeConfig(
281
+ options?: ServerRuntimeOptions,
282
+ ): EffectiveServerRuntimeConfig {
283
+ const resolvedConfigPath = resolveConfigPath(options?.configPath);
284
+ const fileConfig = loadResolvedConfig(resolvedConfigPath);
285
+ const { remnic: envRemnic, ...envServer } = envOverrides();
286
+ const cliServerConfig: Partial<ServerConfig["server"]> = {};
287
+ if (options?.host !== undefined) cliServerConfig.host = options.host;
288
+ if (options?.port !== undefined) cliServerConfig.port = parseServerPort(options.port, "options.port");
289
+ if (options?.authToken !== undefined) cliServerConfig.authToken = options.authToken;
290
+
291
+ const serverConfig = {
292
+ ...fileConfig.server,
293
+ ...envServer,
294
+ ...cliServerConfig,
295
+ };
296
+ const portSource = cliServerConfig.port !== undefined
297
+ ? "options.port"
298
+ : envServer.port !== undefined
299
+ ? "REMNIC_PORT/ENGRAM_PORT"
300
+ : "server.port";
301
+
302
+ return {
303
+ resolvedConfigPath,
304
+ fileConfig,
305
+ envRemnic,
306
+ serverConfig,
307
+ parsedServerConfig: parseServerConfig(serverConfig, { portSource }),
308
+ };
309
+ }
310
+
311
+ export function mergeRemnicConfigForServer(
312
+ fileRemnic: Record<string, unknown>,
313
+ envRemnic: Record<string, unknown> | undefined,
314
+ ): Record<string, unknown> {
315
+ const effectiveEnvRemnic = { ...(envRemnic ?? {}) };
316
+ if (isOpenaiApiKeyDisabled(fileRemnic.openaiApiKey)) {
317
+ // A local/gateway-only deployment can explicitly disable the direct
318
+ // OpenAI client. Preserve that opt-out even when the process has a
319
+ // global OPENAI_API_KEY for unrelated tools.
320
+ delete effectiveEnvRemnic.openaiApiKey;
321
+ }
322
+ return { ...fileRemnic, ...effectiveEnvRemnic };
323
+ }
324
+
325
+ // ── Helpers ─────────────────────────────────────────────────────────────────
326
+
327
+ const WRITABLE_BOOLEAN_CONFIG_KEYS = new Set([
328
+ "citationsAutoDetect",
329
+ "citationsEnabled",
330
+ "embeddingFallbackEnabled",
331
+ "enrichmentAutoOnCreate",
332
+ "enrichmentEnabled",
333
+ "feedbackEnabled",
334
+ "hostEmbeddingProviderEnabled",
335
+ "localLlmDisableThinking",
336
+ "localLlmEnabled",
337
+ "localLlmFallback",
338
+ "localLlmFastEnabled",
339
+ "memoryExtensionsEnabled",
340
+ "namespacesEnabled",
341
+ "qmdEnabled",
342
+ "queryExpansionEnabled",
343
+ "recallPlannerEnabled",
344
+ "recallPlannerLlmEnabled",
345
+ "recallPlannerTelemetryEnabled",
346
+ "rerankEnabled",
347
+ ]);
348
+
349
+ const WRITABLE_STRING_CONFIG_KEYS = new Set([
350
+ "embeddingFallbackModel",
351
+ "embeddingFallbackProvider",
352
+ "fastGatewayAgentId",
353
+ "gatewayAgentId",
354
+ "localLlmFastModel",
355
+ "localLlmModel",
356
+ "localLlmUrl",
357
+ "model",
358
+ "modelSource",
359
+ "openaiBaseUrl",
360
+ "qmdEmbedModel",
361
+ "qmdGenerateModel",
362
+ "qmdRerankModel",
363
+ "recallPlannerModel",
364
+ ]);
365
+
366
+ function isWritableConfigKey(key: string): boolean {
367
+ return WRITABLE_BOOLEAN_CONFIG_KEYS.has(key) || WRITABLE_STRING_CONFIG_KEYS.has(key);
368
+ }
369
+
370
+ function hasEnv(name: string): boolean {
371
+ const value = process.env[name];
372
+ return typeof value === "string" && value.trim().length > 0;
373
+ }
374
+
375
+ function fileExists(candidate: string): boolean {
376
+ try {
377
+ return fs.existsSync(expandTildePath(candidate));
378
+ } catch {
379
+ return false;
380
+ }
381
+ }
382
+
383
+ function canWriteConfigPath(configPath: string): boolean {
384
+ try {
385
+ if (fs.existsSync(configPath)) {
386
+ fs.accessSync(configPath, fs.constants.W_OK);
387
+ return true;
388
+ }
389
+ fs.accessSync(path.dirname(configPath), fs.constants.W_OK);
390
+ return true;
391
+ } catch {
392
+ return false;
393
+ }
394
+ }
395
+
396
+ function writeConfigFileAtomically(configPath: string, data: Record<string, unknown>): void {
397
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
398
+ const tmpPath = path.join(
399
+ path.dirname(configPath),
400
+ `.${path.basename(configPath)}.tmp-${process.pid}-${Date.now()}`,
401
+ );
402
+ let completed = false;
403
+ try {
404
+ fs.writeFileSync(tmpPath, `${JSON.stringify(data, null, 2)}\n`, {
405
+ encoding: "utf8",
406
+ flag: "wx",
407
+ mode: 0o600,
408
+ });
409
+ fs.renameSync(tmpPath, configPath);
410
+ try {
411
+ fs.chmodSync(configPath, 0o600);
412
+ } catch {
413
+ // Best effort for platforms/filesystems that do not support chmod.
414
+ }
415
+ completed = true;
416
+ } finally {
417
+ if (!completed) {
418
+ try {
419
+ fs.unlinkSync(tmpPath);
420
+ } catch {
421
+ // Best effort cleanup for failed writes.
422
+ }
423
+ }
424
+ }
425
+ }
426
+
427
+ function readJsonRecordIfPresent(configPath: string): Record<string, unknown> {
428
+ if (!fs.existsSync(configPath)) return {};
429
+ const parsed = JSON.parse(fs.readFileSync(configPath, "utf8"));
430
+ if (!isPlainRecord(parsed)) {
431
+ throw new Error(`Invalid config file ${configPath}: top-level config must be a JSON object`);
432
+ }
433
+ return parsed;
434
+ }
435
+
436
+ function resolveEditableRemnicBlock(root: Record<string, unknown>): Record<string, unknown> {
437
+ if (isPlainRecord(root.remnic)) return root.remnic;
438
+ if (isPlainRecord(root.engram)) return root.engram;
439
+ return root;
440
+ }
441
+
442
+ function normalizePatchValue(key: string, value: unknown): string | boolean | null {
443
+ if (!isWritableConfigKey(key)) {
444
+ throw new Error(`Unsupported admin config key: ${key}`);
445
+ }
446
+ if (value === null) return null;
447
+ if (WRITABLE_BOOLEAN_CONFIG_KEYS.has(key)) {
448
+ if (typeof value === "boolean") return value;
449
+ throw new Error(`Invalid ${key}: expected boolean or null`);
450
+ }
451
+ if (typeof value !== "string") {
452
+ throw new Error(`Invalid ${key}: expected string or null`);
453
+ }
454
+ const normalized = value.trim();
455
+ if (key === "modelSource" && normalized !== "plugin" && normalized !== "gateway") {
456
+ throw new Error("Invalid modelSource: expected plugin or gateway");
457
+ }
458
+ if (
459
+ key === "embeddingFallbackProvider" &&
460
+ normalized !== "auto" &&
461
+ normalized !== "openai" &&
462
+ normalized !== "local"
463
+ ) {
464
+ throw new Error("Invalid embeddingFallbackProvider: expected auto, openai, or local");
465
+ }
466
+ return normalized;
467
+ }
468
+
469
+ function publicConfigValues(config: PluginConfig, serverConfig: ParsedServerConfig): Record<string, string | number | boolean | null> {
470
+ return {
471
+ adminConsoleEnabled: serverConfig.adminConsoleEnabled,
472
+ memoryDir: config.memoryDir,
473
+ model: config.model,
474
+ modelSource: config.modelSource,
475
+ gatewayAgentId: config.gatewayAgentId || null,
476
+ fastGatewayAgentId: config.fastGatewayAgentId || null,
477
+ localLlmEnabled: config.localLlmEnabled,
478
+ localLlmUrl: config.localLlmUrl || null,
479
+ localLlmModel: config.localLlmModel || null,
480
+ localLlmFastEnabled: config.localLlmFastEnabled,
481
+ localLlmFastModel: config.localLlmFastModel || null,
482
+ localLlmFallback: config.localLlmFallback,
483
+ localLlmDisableThinking: config.localLlmDisableThinking,
484
+ qmdEnabled: config.qmdEnabled,
485
+ qmdEmbedModel: config.qmdEmbedModel || null,
486
+ qmdRerankModel: config.qmdRerankModel || null,
487
+ qmdGenerateModel: config.qmdGenerateModel || null,
488
+ embeddingFallbackEnabled: config.embeddingFallbackEnabled,
489
+ embeddingFallbackProvider: config.embeddingFallbackProvider,
490
+ embeddingFallbackModel: config.embeddingFallbackModel || null,
491
+ hostEmbeddingProviderEnabled: config.hostEmbeddingProviderEnabled,
492
+ namespacesEnabled: config.namespacesEnabled,
493
+ recallPlannerEnabled: config.recallPlannerEnabled,
494
+ recallPlannerLlmEnabled: config.recallPlannerLlmEnabled,
495
+ recallPlannerModel: config.recallPlannerModel || null,
496
+ citationsEnabled: config.citationsEnabled,
497
+ citationsAutoDetect: config.citationsAutoDetect,
498
+ queryExpansionEnabled: config.queryExpansionEnabled,
499
+ rerankEnabled: config.rerankEnabled,
500
+ feedbackEnabled: config.feedbackEnabled,
501
+ memoryExtensionsEnabled: config.memoryExtensionsEnabled,
502
+ enrichmentEnabled: config.enrichmentEnabled,
503
+ enrichmentAutoOnCreate: config.enrichmentAutoOnCreate,
504
+ };
505
+ }
506
+
507
+ function configuredModels(config: PluginConfig): RemnicAdminModelOption[] {
508
+ const models = new Map<string, RemnicAdminModelOption>();
509
+ const add = (id: string | undefined, provider: string, label: string, enabled: boolean, isDefault = false, source = "config") => {
510
+ const normalized = id?.trim();
511
+ if (!normalized) return;
512
+ const existing = models.get(`${provider}:${normalized}`);
513
+ models.set(`${provider}:${normalized}`, {
514
+ id: normalized,
515
+ provider,
516
+ label,
517
+ detected: existing?.detected ?? true,
518
+ enabled: existing?.enabled || enabled,
519
+ default: existing?.default || isDefault,
520
+ source,
521
+ });
522
+ };
523
+
524
+ add(config.model, "openai", config.model, !isOpenaiApiKeyDisabled(config.openaiApiKey), config.modelSource === "plugin");
525
+ add(config.gatewayAgentId, "gateway", config.gatewayAgentId, config.modelSource === "gateway", config.modelSource === "gateway");
526
+ add(config.fastGatewayAgentId, "gateway", config.fastGatewayAgentId, config.modelSource === "gateway");
527
+ add(config.localLlmModel, "local", config.localLlmModel, config.localLlmEnabled, config.localLlmEnabled);
528
+ add(config.localLlmFastModel, "local", `${config.localLlmFastModel} (fast)`, config.localLlmFastEnabled);
529
+ add(config.embeddingFallbackModel, config.embeddingFallbackProvider, `${config.embeddingFallbackModel} (embedding fallback)`, config.embeddingFallbackEnabled);
530
+ add(config.qmdEmbedModel, "qmd", `${config.qmdEmbedModel} (embed)`, config.qmdEnabled);
531
+ add(config.qmdRerankModel, "qmd", `${config.qmdRerankModel} (rerank)`, config.qmdEnabled);
532
+ add(config.qmdGenerateModel, "qmd", `${config.qmdGenerateModel} (generate)`, config.qmdEnabled);
533
+ add(config.recallPlannerModel, "planner", `${config.recallPlannerModel} (planner)`, config.recallPlannerLlmEnabled);
534
+
535
+ return [...models.values()].sort((a, b) => `${a.provider}:${a.id}`.localeCompare(`${b.provider}:${b.id}`));
536
+ }
537
+
538
+ function isLikelyOllamaEndpoint(endpoint: string): boolean {
539
+ return /(?:ollama|11434)/i.test(endpoint);
540
+ }
541
+
542
+ async function detectOllamaModels(baseUrl: string | undefined): Promise<RemnicAdminModelOption[]> {
543
+ const configuredEndpoint = baseUrl?.trim();
544
+ const envEndpoint = process.env.OLLAMA_HOST?.trim();
545
+ const endpoint =
546
+ configuredEndpoint && isLikelyOllamaEndpoint(configuredEndpoint)
547
+ ? configuredEndpoint
548
+ : envEndpoint;
549
+ if (!endpoint) return [];
550
+ const controller = new AbortController();
551
+ const timer = setTimeout(() => controller.abort(), 900);
552
+ try {
553
+ const url = new URL("/api/tags", endpoint.endsWith("/") ? endpoint : `${endpoint}/`);
554
+ const response = await fetch(url, { signal: controller.signal });
555
+ if (!response.ok) return [];
556
+ const payload = await response.json() as { models?: Array<{ name?: unknown; model?: unknown }> };
557
+ return (payload.models ?? [])
558
+ .map((model) => typeof model.name === "string" ? model.name : typeof model.model === "string" ? model.model : "")
559
+ .filter((name) => name.length > 0)
560
+ .map((name) => ({
561
+ id: name,
562
+ label: name,
563
+ provider: "ollama",
564
+ detected: true,
565
+ enabled: true,
566
+ source: "ollama",
567
+ endpoint,
568
+ }));
569
+ } catch {
570
+ return [];
571
+ } finally {
572
+ clearTimeout(timer);
573
+ }
574
+ }
575
+
576
+ function dashboardHarnesses(config: PluginConfig) {
577
+ const openclawDetected = hasEnv("OPENCLAW_HOME") || hasEnv("OPENCLAW_WORKSPACE") || fileExists("~/.openclaw");
578
+ const codexDetected = hasEnv("CODEX_HOME") || fileExists("~/.codex/auth.json") || fileExists("~/.codex");
579
+ return [
580
+ {
581
+ id: "remnic-http",
582
+ label: "Remnic HTTP API",
583
+ detected: true,
584
+ enabled: true,
585
+ source: "server",
586
+ detail: "MCP and REST access server",
587
+ },
588
+ {
589
+ id: "openclaw",
590
+ label: "OpenClaw",
591
+ detected: openclawDetected,
592
+ enabled: config.modelSource === "gateway" || config.hostEmbeddingProviderEnabled,
593
+ source: openclawDetected ? "host" : "not detected",
594
+ detail: "Gateway and host adapters",
595
+ },
596
+ {
597
+ id: "codex",
598
+ label: "Codex",
599
+ detected: codexDetected,
600
+ enabled: config.citationsEnabled || config.citationsAutoDetect,
601
+ source: codexDetected ? "host" : "not detected",
602
+ detail: "Citation-aware adapter",
603
+ },
604
+ {
605
+ id: "qmd",
606
+ label: "QMD Search",
607
+ detected: Boolean(config.qmdPath) || config.qmdEnabled,
608
+ enabled: config.qmdEnabled,
609
+ source: config.qmdPath ? "qmdPath" : "config",
610
+ detail: config.qmdSearchStrategy,
611
+ },
612
+ ];
613
+ }
614
+
615
+ function dashboardProviders(config: PluginConfig) {
616
+ const localLlmUrl = config.localLlmUrl || "";
617
+ const gatewayIds = [config.gatewayAgentId, config.fastGatewayAgentId].filter(Boolean).join(" ");
618
+ const openaiBaseUrl = process.env.OPENAI_BASE_URL || "";
619
+ const sageRouterDetected =
620
+ hasEnv("SAGE_ROUTER_URL") ||
621
+ hasEnv("SAGE_ROUTER_HOST") ||
622
+ /sage[-_ ]?router/i.test(`${gatewayIds} ${openaiBaseUrl}`);
623
+ const ollamaDetected = hasEnv("OLLAMA_HOST") || /(?:ollama|11434)/i.test(localLlmUrl);
624
+ const localDetected = Boolean(localLlmUrl.trim());
625
+
626
+ return [
627
+ {
628
+ id: "openai",
629
+ label: "OpenAI",
630
+ detected: !isOpenaiApiKeyDisabled(config.openaiApiKey),
631
+ enabled: config.modelSource === "plugin" && !isOpenaiApiKeyDisabled(config.openaiApiKey),
632
+ source: !isOpenaiApiKeyDisabled(config.openaiApiKey) ? "config/env" : "disabled",
633
+ detail: config.model,
634
+ },
635
+ {
636
+ id: "sage-router",
637
+ label: "Sage Router",
638
+ detected: sageRouterDetected,
639
+ enabled: config.modelSource === "gateway" && sageRouterDetected,
640
+ source: sageRouterDetected ? "gateway/env" : "not detected",
641
+ detail: config.gatewayAgentId || config.fastGatewayAgentId || openaiBaseUrl || "OpenAI-compatible provider router",
642
+ },
643
+ {
644
+ id: "ollama",
645
+ label: "Ollama",
646
+ detected: ollamaDetected,
647
+ enabled: config.localLlmEnabled && ollamaDetected,
648
+ source: ollamaDetected ? "localLlmUrl/OLLAMA_HOST" : "not detected",
649
+ detail: "Local or cloud-compatible Ollama endpoint",
650
+ },
651
+ {
652
+ id: "local-openai-compatible",
653
+ label: "Local OpenAI-compatible",
654
+ detected: localDetected && !ollamaDetected,
655
+ enabled: config.localLlmEnabled && localDetected && !ollamaDetected,
656
+ source: localDetected ? "localLlmUrl" : "not detected",
657
+ detail: localLlmUrl || "Local provider endpoint",
658
+ },
659
+ ];
660
+ }
661
+
662
+ function dashboardFeatures(config: PluginConfig) {
663
+ return [
664
+ ["localLlmEnabled", "Local LLM", config.localLlmEnabled],
665
+ ["localLlmFastEnabled", "Fast Local Tier", config.localLlmFastEnabled],
666
+ ["qmdEnabled", "QMD Search", config.qmdEnabled],
667
+ ["embeddingFallbackEnabled", "Embedding Fallback", config.embeddingFallbackEnabled],
668
+ ["hostEmbeddingProviderEnabled", "Host Embeddings", config.hostEmbeddingProviderEnabled],
669
+ ["namespacesEnabled", "Namespaces", config.namespacesEnabled],
670
+ ["recallPlannerEnabled", "Recall Planner", config.recallPlannerEnabled],
671
+ ["recallPlannerLlmEnabled", "Planner LLM", config.recallPlannerLlmEnabled],
672
+ ["citationsEnabled", "Citations", config.citationsEnabled],
673
+ ["citationsAutoDetect", "Citation Auto-detect", config.citationsAutoDetect],
674
+ ["queryExpansionEnabled", "Query Expansion", config.queryExpansionEnabled],
675
+ ["rerankEnabled", "Rerank", config.rerankEnabled],
676
+ ["feedbackEnabled", "Feedback", config.feedbackEnabled],
677
+ ["memoryExtensionsEnabled", "Memory Extensions", config.memoryExtensionsEnabled],
678
+ ["enrichmentEnabled", "Entity Enrichment", config.enrichmentEnabled],
679
+ ].map(([key, label, enabled]) => ({
680
+ key: String(key),
681
+ label: String(label),
682
+ enabled: enabled === true,
683
+ writable: WRITABLE_BOOLEAN_CONFIG_KEYS.has(String(key)),
684
+ restartRequired: true,
685
+ }));
686
+ }
687
+
688
+ export function createAdminControls(
689
+ configPath: string,
690
+ config: PluginConfig,
691
+ serverConfig: ParsedServerConfig,
692
+ ): RemnicAdminControls {
693
+ let restartRequired = false;
694
+ let displayConfig = config;
695
+ const status = async (): Promise<RemnicAdminDashboardStatus> => {
696
+ const models = configuredModels(displayConfig);
697
+ const ollamaModels = await detectOllamaModels(displayConfig.localLlmUrl);
698
+ const modelKeys = new Set(models.map((model) => `${model.provider}:${model.id}`));
699
+ for (const model of ollamaModels) {
700
+ if (!modelKeys.has(`${model.provider}:${model.id}`)) models.push(model);
701
+ }
702
+ return {
703
+ config: {
704
+ path: configPath,
705
+ exists: fs.existsSync(configPath),
706
+ writable: canWriteConfigPath(configPath),
707
+ restartRequired,
708
+ values: publicConfigValues(displayConfig, serverConfig),
709
+ },
710
+ harnesses: dashboardHarnesses(displayConfig),
711
+ providers: dashboardProviders(displayConfig),
712
+ models,
713
+ features: dashboardFeatures(displayConfig),
714
+ };
715
+ };
716
+
717
+ return {
718
+ status,
719
+ update: async (patch: RemnicAdminConfigPatch): Promise<RemnicAdminDashboardStatus> => {
720
+ if (!isPlainRecord(patch)) {
721
+ throw new Error("Admin config patch must be an object");
722
+ }
723
+ const normalizedEntries = Object.entries(patch).map(([key, value]) => [key, normalizePatchValue(key, value)] as const);
724
+ if (normalizedEntries.length === 0) return status();
725
+
726
+ const raw = readJsonRecordIfPresent(configPath);
727
+ const target = resolveEditableRemnicBlock(raw);
728
+ for (const [key, value] of normalizedEntries) {
729
+ if (value === null) {
730
+ delete target[key];
731
+ if (target !== raw) delete raw[key];
732
+ } else {
733
+ target[key] = value;
734
+ }
735
+ }
736
+
737
+ const nextDisplayConfig = parseConfig(resolveRemnicConfigRecord(raw));
738
+
739
+ writeConfigFileAtomically(configPath, raw);
740
+ displayConfig = nextDisplayConfig;
741
+ restartRequired = true;
742
+ return status();
743
+ },
744
+ };
745
+ }
746
+
747
+ async function cleanupFailedStartup(
748
+ orchestrator: Orchestrator,
749
+ httpServer: EngramAccessHttpServer,
750
+ ): Promise<void> {
751
+ try {
752
+ await httpServer.stop();
753
+ } catch (err) {
754
+ log.warn(`HTTP startup failure cleanup could not stop server: ${err}`);
755
+ }
756
+
757
+ try {
758
+ await orchestrator.destroy();
759
+ } catch (err) {
760
+ log.warn(`HTTP startup failure cleanup could not destroy orchestrator: ${err}`);
761
+ }
762
+ }
763
+
764
+ // ── Server startup ──────────────────────────────────────────────────────────
765
+
766
+ export interface ServerResult {
767
+ config: PluginConfig;
768
+ service: EngramAccessService;
769
+ httpServer: EngramAccessHttpServer;
770
+ host: string;
771
+ port: number;
772
+ /** Stop HTTP, cancel startup work, abort deferred init, and destroy the orchestrator. */
773
+ stop: () => Promise<void>;
774
+ /** Cancel any pending startup-sync retry timers. Called automatically on shutdown. */
775
+ cancelStartupSync: () => void;
776
+ /** Abort deferred orchestrator initialization (QMD sync, warmup, cache). */
777
+ abortDeferredInit: () => void;
778
+ }
779
+
780
+ export async function startServer(options?: ServerRuntimeOptions): Promise<ServerResult> {
781
+ initLogger();
782
+
783
+ // Startup driver-load check (issue #1829): attempt to load the better-sqlite3
784
+ // native binding under THIS process. A wrong-ABI build previously threw inside
785
+ // each projection open, was caught, and returned the same silent null as a
786
+ // missing file — so every memory list fell back to a full-corpus scan with no
787
+ // visible error. Probe once at startup and log LOUDLY. Do not crash: the
788
+ // full-corpus fallback still serves, and the projection-open path records the
789
+ // distinct rate-limited signal + doctor entry on its own.
790
+ const driverProbe = probeBetterSqlite3Driver();
791
+ if (!driverProbe.ok) {
792
+ const detailSuffix = driverProbe.detail ? ` (${driverProbe.detail})` : "";
793
+ const abiSuffix = driverProbe.nativeBindingMismatch
794
+ ? " — the binding was built for a different Node.js ABI; rebuild it (`node scripts/ensure-better-sqlite3.mjs` or `pnpm rebuild better-sqlite3`)"
795
+ : "";
796
+ log.error(
797
+ `better-sqlite3 native driver failed to load under the running process${detailSuffix}${abiSuffix}. SQLite-backed features (memory projection) will fall back to slower full-corpus scans until fixed.`,
798
+ );
799
+ }
800
+
801
+ const {
802
+ resolvedConfigPath,
803
+ fileConfig,
804
+ envRemnic,
805
+ serverConfig,
806
+ parsedServerConfig,
807
+ } = resolveEffectiveServerRuntimeConfig(options);
808
+ const remnicConfig = mergeRemnicConfigForServer(fileConfig.remnic, envRemnic);
809
+
810
+ const config = parseConfig(remnicConfig);
811
+ // Re-init now that config is known. The call at the top of startServer runs
812
+ // BEFORE the config file is read, so it could only ever default `debug` to
813
+ // false — `debug: true` was accepted, documented, and silently inert on the
814
+ // standalone daemon, which is exactly the flag you reach for when the daemon
815
+ // is misbehaving (issue #2209).
816
+ initLogger(undefined, config.debug);
817
+ log.debug(`debug logging enabled from config (${resolvedConfigPath.source})`);
818
+ const orchestrator = new Orchestrator(config);
819
+ await orchestrator.initialize();
820
+
821
+ // Start the HTTP server immediately so health checks, MCP handshakes,
822
+ // and liveness probes can connect while deferred init is still running.
823
+ const readiness: StartupReadinessState = { ready: false, warmupAttempts: 0, lastError: null, degraded: false };
824
+
825
+ const authToken = parsedServerConfig.authToken ?? readCompatEnv("REMNIC_AUTH_TOKEN", "ENGRAM_AUTH_TOKEN") ?? "";
826
+
827
+ // Connector tokens are loaded dynamically per request via authTokensGetter
828
+ // so that token generate/revoke takes effect without server restart
829
+ if (!authToken && getAllValidTokens().length === 0) {
830
+ log.warn("No auth token set — server will reject all requests. Set REMNIC_AUTH_TOKEN, server.authToken in config, or generate tokens with 'remnic token generate'.");
831
+ }
832
+ // OAuth facade (ChatGPT developer-mode apps): file block < REMNIC_OAUTH_* env.
833
+ // Parsed strictly — invalid values abort startup with a precise message.
834
+ const oauthConfig = applyOAuthEnvOverrides((serverConfig as { oauth?: unknown }).oauth);
835
+ const oauthRequestHandler = buildOAuthRequestHandler(oauthConfig);
836
+ const supportPassportRuntime = createSupportPassportServerRuntime(orchestrator, config, oauthRequestHandler), { service } = supportPassportRuntime;
837
+ const httpServer = new EngramAccessHttpServer({
838
+ service,
839
+ host: parsedServerConfig.host,
840
+ port: parsedServerConfig.port,
841
+ authToken: authToken || undefined,
842
+ // Entry-based getter: validation + connector identity from ONE cached
843
+ // snapshot (see tokens.ts). The path policy pins ChatGPT-minted OAuth
844
+ // tokens (connector "chatgpt") to the MCP endpoint only — they never
845
+ // authorize REST/admin routes. All other connector tokens keep full
846
+ // access, matching pre-OAuth behavior.
847
+ authTokenEntriesGetter: () => getAllValidTokenEntriesCached(),
848
+ tokenPathPolicy: (connector, pathname) => connector !== "chatgpt" || pathname === "/mcp",
849
+ readiness: () => readiness,
850
+ principal: parsedServerConfig.principal,
851
+ maxBodyBytes: parsedServerConfig.maxBodyBytes,
852
+ writeRateLimitMaxRequests: parsedServerConfig.writeRateLimitMaxRequests,
853
+ writeRateLimitWindowMs: parsedServerConfig.writeRateLimitWindowMs,
854
+ adminConsoleEnabled: parsedServerConfig.adminConsoleEnabled,
855
+ adminConsolePublicDir: parsedServerConfig.adminConsolePublicDir
856
+ ? path.resolve(expandTildePath(parsedServerConfig.adminConsolePublicDir))
857
+ : undefined,
858
+ adminConsolePrefillToken: parsedServerConfig.adminConsolePrefillToken,
859
+ adminControls: parsedServerConfig.adminConsoleEnabled
860
+ ? createAdminControls(resolvedConfigPath.path, config, parsedServerConfig)
861
+ : undefined,
862
+ citationsEnabled: config.citationsEnabled,
863
+ citationsAutoDetect: config.citationsAutoDetect,
864
+ emitLegacyTools: config.emitLegacyTools,
865
+ externalRequestHandler: supportPassportRuntime.externalRequestHandler,
866
+ ...(oauthConfig.enabled
867
+ ? {
868
+ resourceMetadataUrl: new URL(
869
+ "/.well-known/oauth-protected-resource/mcp",
870
+ oauthConfig.issuerUrl,
871
+ ).href,
872
+ }
873
+ : {}),
874
+ });
875
+
876
+ let host: string;
877
+ let port: number;
878
+ try {
879
+ ({ host, port } = await httpServer.start());
880
+ } catch (err) {
881
+ await cleanupFailedStartup(orchestrator, httpServer);
882
+ throw err;
883
+ }
884
+
885
+ // Fire-and-forget: wait for deferred init (QMD probe, collection setup,
886
+ // warmup) then check QMD availability and retry if needed. This does NOT
887
+ // block the server listener — connections are accepted immediately above.
888
+ // An AbortController allows the shutdown handler to cancel pending retries.
889
+ const startupSyncAbort = new AbortController();
890
+ const readinessAbort = new AbortController();
891
+ let startupSyncInFlight: Promise<boolean> | undefined;
892
+ const ensureStartupSync = async (signal: AbortSignal): Promise<boolean> => {
893
+ if (orchestrator.deferredSyncSucceeded) return true;
894
+ if (startupSyncInFlight) return startupSyncInFlight;
895
+ const attempt = orchestrator.startupSearchSync(signal).then((synced) => {
896
+ if (synced) orchestrator.deferredSyncSucceeded = true;
897
+ return synced;
898
+ });
899
+ startupSyncInFlight = attempt;
900
+ try {
901
+ return await attempt;
902
+ } finally {
903
+ if (startupSyncInFlight === attempt) startupSyncInFlight = undefined;
904
+ }
905
+ };
906
+ const readinessTask = completeStartupReadiness({
907
+ deferredReady: orchestrator.deferredReady,
908
+ warmup: (signal) =>
909
+ runStartupSearchWarmup({
910
+ signal,
911
+ isAvailable: () => orchestrator.qmd.isAvailable(),
912
+ search: (onDegradation) =>
913
+ orchestrator.qmd.search(
914
+ "remnic startup readiness",
915
+ config.defaultNamespace,
916
+ 1,
917
+ undefined,
918
+ {
919
+ signal,
920
+ onDegradation: (degradation) => onDegradation(degradation.code),
921
+ },
922
+ ),
923
+ }),
924
+ prepareWarmup: ensureStartupSync,
925
+ state: readiness,
926
+ override: parsedServerConfig.readinessOverride,
927
+ degradedAfterAttempts: parsedServerConfig.readinessDegradedAfterAttempts,
928
+ skipWarmup: () => orchestrator.qmd.debugStatus() === "backend=noop",
929
+ openGate: () => {
930
+ readiness.ready = true;
931
+ },
932
+ shutdownSignal: readinessAbort.signal,
933
+ });
934
+ // Wrap httpServer.stop() so that existing callers also get full lifecycle
935
+ // cleanup: retry timers, deferred init, HTTP listener, and orchestrator.
936
+ const originalStop = httpServer.stop.bind(httpServer);
937
+ let stopPromise: Promise<void> | undefined;
938
+ const stop = async (): Promise<void> => {
939
+ if (stopPromise) return stopPromise;
940
+ stopPromise = (async () => {
941
+ startupSyncAbort.abort();
942
+ readinessAbort.abort();
943
+ supportPassportRuntime.close();
944
+ orchestrator.abortDeferredInit();
945
+ try {
946
+ await originalStop();
947
+ } finally {
948
+ try {
949
+ await readinessTask;
950
+ } finally {
951
+ await orchestrator.destroy();
952
+ }
953
+ }
954
+ })();
955
+ return stopPromise;
956
+ };
957
+ httpServer.stop = stop;
958
+
959
+ orchestrator.deferredReady.then(() => {
960
+ if (startupSyncAbort.signal.aborted) {
961
+ log.debug("QMD startup-sync: cancelled before deferred init completed");
962
+ return;
963
+ }
964
+
965
+ // Skip retries when search is explicitly disabled via config or when the
966
+ // orchestrator already resolved to a noop backend (e.g. missing collection
967
+ // detected during deferredInitialize). Both cases mean no sync should ever
968
+ // run; scheduling retries would create misleading operational noise and
969
+ // unnecessary background work on every server start.
970
+ if (!config.qmdEnabled || orchestrator.qmd.debugStatus() === "backend=noop") {
971
+ log.debug("QMD startup-sync: search disabled or noop backend, skipping retries");
972
+ return;
973
+ }
974
+
975
+ // Retry when either: (a) QMD is not available yet (cold-start race), or
976
+ // (b) QMD is available but the deferred init sync step failed silently
977
+ // (e.g., update errors swallowed by backend, throttle skip, transient
978
+ // network failure). Without (b), the daemon permanently serves stale
979
+ // recall after a failed sync despite healthy QMD probe.
980
+ const needsRetry = !orchestrator.qmd.isAvailable() || !orchestrator.deferredSyncSucceeded;
981
+ if (!needsRetry) {
982
+ log.debug("QMD startup-sync: deferred init completed successfully, no retries needed");
983
+ return;
984
+ }
985
+
986
+ const RETRY_DELAYS_MS = [5_000, 15_000, 30_000, 60_000, 120_000];
987
+ if (startupSyncAbort.signal.aborted) {
988
+ log.debug("QMD startup-sync retry: cancelled before retry task started");
989
+ return;
990
+ }
991
+ (async () => {
992
+ for (const delay of RETRY_DELAYS_MS) {
993
+ await abortableDelay(delay, startupSyncAbort.signal);
994
+
995
+ if (startupSyncAbort.signal.aborted) {
996
+ log.debug("QMD startup-sync retry: cancelled by shutdown");
997
+ return;
998
+ }
999
+
1000
+ const synced = await ensureStartupSync(startupSyncAbort.signal);
1001
+ if (!synced) {
1002
+ if (orchestrator.qmd.debugStatus() === "backend=noop") {
1003
+ log.debug("QMD startup-sync retry: search intentionally disabled; stopping retries");
1004
+ return;
1005
+ }
1006
+ log.debug(`QMD startup-sync retry: not available yet (next retry in ${RETRY_DELAYS_MS[RETRY_DELAYS_MS.indexOf(delay) + 1] ?? "n/a"}ms)`);
1007
+ continue;
1008
+ }
1009
+
1010
+ return; // sync succeeded, stop retrying
1011
+ }
1012
+
1013
+ log.warn("QMD startup-sync retry: exhausted all retries; search index may be stale");
1014
+ })().catch((err: unknown) => {
1015
+ log.warn(`QMD startup-sync retry: unexpected error: ${err}`);
1016
+ });
1017
+ }).catch((err: unknown) => {
1018
+ log.warn(`Deferred init error: ${err}`);
1019
+ });
1020
+
1021
+ return { config, service, httpServer, host, port, stop, cancelStartupSync: () => startupSyncAbort.abort(), abortDeferredInit: () => orchestrator.abortDeferredInit() };
1022
+ }
1023
+
1024
+ const HEALTHCHECK_TIMEOUT_MS = 5_000;
1025
+ const HEALTHCHECK_PLACEHOLDER_TOKENS = new Set([
1026
+ "change-me",
1027
+ "changeme",
1028
+ "replace-me",
1029
+ "replace-this-token",
1030
+ "your-token",
1031
+ "your-token-here",
1032
+ ]);
1033
+
1034
+ function usableHealthcheckToken(value: string | undefined): string | undefined {
1035
+ const token = value?.trim();
1036
+ if (!token) return undefined;
1037
+ if (HEALTHCHECK_PLACEHOLDER_TOKENS.has(token.toLowerCase())) return undefined;
1038
+ if (/\$\{[^}]+\}|<[^>]+>/.test(token)) return undefined;
1039
+ return token;
1040
+ }
1041
+
1042
+ function resolveHealthcheckToken(configuredToken: string | undefined): string | undefined {
1043
+ const configured = usableHealthcheckToken(configuredToken);
1044
+ if (configured) return configured;
1045
+ const entry = loadTokenStore().tokens.find(
1046
+ ({ connector, token }) => connector !== "chatgpt" && usableHealthcheckToken(token) !== undefined,
1047
+ );
1048
+ return usableHealthcheckToken(entry?.token);
1049
+ }
1050
+
1051
+ export async function runServerHealthcheck(options?: {
1052
+ configPath?: string;
1053
+ port?: number;
1054
+ timeoutMs?: number;
1055
+ }): Promise<boolean> {
1056
+ const timeoutMs = options?.timeoutMs ?? HEALTHCHECK_TIMEOUT_MS;
1057
+ if (!Number.isFinite(timeoutMs) || !Number.isInteger(timeoutMs) || timeoutMs <= 0) {
1058
+ throw new Error("Invalid timeoutMs: expected a positive integer");
1059
+ }
1060
+ const { parsedServerConfig } = resolveEffectiveServerRuntimeConfig({
1061
+ configPath: options?.configPath,
1062
+ port: options?.port,
1063
+ });
1064
+ const token = resolveHealthcheckToken(parsedServerConfig.authToken);
1065
+ if (!token) return false;
1066
+
1067
+ try {
1068
+ const response = await fetch(
1069
+ `http://127.0.0.1:${parsedServerConfig.port}/engram/v1/health`,
1070
+ {
1071
+ headers: { authorization: `Bearer ${token}` },
1072
+ signal: AbortSignal.timeout(timeoutMs),
1073
+ },
1074
+ );
1075
+ return response.status === 200;
1076
+ } catch {
1077
+ return false;
1078
+ }
1079
+ }
1080
+
1081
+ // ── CLI entry point ──────────────────────────────────────────────────────────
1082
+
1083
+ const BOOLEAN_CLI_OPTIONS = new Set(["help", "healthcheck"]);
1084
+ const VALUE_CLI_OPTIONS = new Set(["config", "host", "port", "auth-token"]);
1085
+
1086
+ function parseCliArgs(argv: string[]): Record<string, string | undefined> {
1087
+ const args: Record<string, string | undefined> = {};
1088
+ for (let i = 0; i < argv.length; i++) {
1089
+ const token = argv[i];
1090
+ if (token === "-h") {
1091
+ args.help = "true";
1092
+ continue;
1093
+ }
1094
+
1095
+ if (token.startsWith("--")) {
1096
+ const [key, inlineValue] = token.slice(2).split(/=(.*)/s, 2);
1097
+ if (!key) {
1098
+ throw new Error(`Invalid option ${token}`);
1099
+ }
1100
+
1101
+ if (BOOLEAN_CLI_OPTIONS.has(key)) {
1102
+ if (inlineValue !== undefined) {
1103
+ throw new Error(`Option --${key} does not accept a value`);
1104
+ }
1105
+ args[key] = "true";
1106
+ continue;
1107
+ }
1108
+
1109
+ if (!VALUE_CLI_OPTIONS.has(key)) {
1110
+ throw new Error(`Unknown option --${key}`);
1111
+ }
1112
+
1113
+ const value = inlineValue ?? argv[i + 1];
1114
+ if (
1115
+ value === undefined ||
1116
+ (inlineValue === undefined && value.startsWith("--")) ||
1117
+ value.trim() === ""
1118
+ ) {
1119
+ throw new Error(`Missing value for --${key}`);
1120
+ }
1121
+
1122
+ args[key] = value;
1123
+ if (inlineValue === undefined) i++;
1124
+ }
1125
+ }
1126
+ return args;
1127
+ }
1128
+
1129
+ export async function cliMain(argv: string[] = process.argv.slice(2)): Promise<void> {
1130
+ const args = parseCliArgs(argv);
1131
+
1132
+ if (args.help) {
1133
+ console.log(`
1134
+ remnic-server — Standalone Remnic memory server
1135
+
1136
+ Usage:
1137
+ remnic-server [options]
1138
+
1139
+ Options:
1140
+ --config <path> Path to config file (default: remnic.config.json)
1141
+ --host <addr> Bind address (default: 127.0.0.1)
1142
+ --port <number> Port number (default: 4318)
1143
+ --auth-token <tok> Bearer token for auth (or set REMNIC_AUTH_TOKEN)
1144
+ --healthcheck Probe the protected health endpoint and exit
1145
+ --help Show this help
1146
+
1147
+ Environment:
1148
+ REMNIC_CONFIG_PATH Config file path (ENGRAM_CONFIG_PATH also supported)
1149
+ REMNIC_PORT Server port (ENGRAM_PORT also supported)
1150
+ REMNIC_HOST Bind address (ENGRAM_HOST also supported)
1151
+ REMNIC_AUTH_TOKEN Auth bearer token (ENGRAM_AUTH_TOKEN also supported)
1152
+ REMNIC_ADMIN_CONSOLE_PREFILL_TOKEN
1153
+ Prefill admin UI with REMNIC_AUTH_TOKEN when true
1154
+ REMNIC_MEMORY_DIR Override memory directory (ENGRAM_MEMORY_DIR also supported)
1155
+ OPENAI_API_KEY OpenAI API key for extraction; ignored when config sets openaiApiKey=false
1156
+ `);
1157
+ process.exit(0);
1158
+ }
1159
+
1160
+ if (args.healthcheck) {
1161
+ if (args["auth-token"] !== undefined) {
1162
+ throw new Error("Option --auth-token cannot be used with --healthcheck; use config or REMNIC_AUTH_TOKEN");
1163
+ }
1164
+ if (args.host !== undefined) {
1165
+ throw new Error("Option --host cannot be used with --healthcheck; loopback probing is automatic");
1166
+ }
1167
+ const healthy = await runServerHealthcheck({
1168
+ configPath: args.config,
1169
+ port: args.port === undefined ? undefined : parseServerPort(args.port, "--port"),
1170
+ });
1171
+ if (!healthy) throw new Error("Server healthcheck failed");
1172
+ return;
1173
+ }
1174
+
1175
+ const result = await startServer({
1176
+ configPath: args.config,
1177
+ host: args.host,
1178
+ port: args.port === undefined ? undefined : parseServerPort(args.port, "--port"),
1179
+ authToken: args["auth-token"],
1180
+ });
1181
+
1182
+ console.log(`Remnic server listening on http://${result.host}:${result.port}`);
1183
+
1184
+ // Graceful shutdown
1185
+ const shutdown = async (signal: string) => {
1186
+ console.log(`\nReceived ${signal}, shutting down...`);
1187
+ await result.stop();
1188
+ process.exit(0);
1189
+ };
1190
+
1191
+ process.on("SIGINT", () => shutdown("SIGINT"));
1192
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
1193
+ }
1194
+
1195
+ // Auto-run when executed directly
1196
+ // Matches direct execution of `node .../remnic-server/dist/index.js` or
1197
+ // `node .../remnic-server/src/index.ts`. Package command names are handled by
1198
+ // the bin wrappers in ../bin so importing this module cannot start twice.
1199
+ if (
1200
+ process.argv[1] &&
1201
+ /(?:remnic-server|engram-server)[\\/](?:dist|src)[\\/]index\.[jt]s$/.test(process.argv[1])
1202
+ ) {
1203
+ cliMain().catch((err) => {
1204
+ process.stderr.write(`Fatal: ${err instanceof Error ? err.message : String(err)}\n`);
1205
+ process.exit(1);
1206
+ });
1207
+ }