memorix 1.6.1 → 1.6.3

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.
Files changed (122) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/TEAM.md +86 -86
  3. package/dist/cli/index.js +2582 -1845
  4. package/dist/index.js +1355 -683
  5. package/dist/maintenance-runner.js +1621 -1096
  6. package/dist/media-audio-runner.js +1612 -1088
  7. package/dist/media-video-runner.js +1612 -1088
  8. package/dist/memcode-runtime/CHANGELOG.md +35 -0
  9. package/dist/sdk.js +2007 -1396
  10. package/dist/vector-backfill-runner.js +1610 -1086
  11. package/docs/DESIGN_DECISIONS.md +357 -357
  12. package/docs/PERFORMANCE.md +15 -0
  13. package/package.json +2 -1
  14. package/plugins/claude/memorix/.claude-plugin/plugin.json +1 -1
  15. package/plugins/codebuddy/memorix/.codebuddy-plugin/plugin.json +1 -1
  16. package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
  17. package/plugins/copilot/memorix/plugin.json +1 -1
  18. package/plugins/gemini/memorix/gemini-extension.json +1 -1
  19. package/plugins/omp/memorix/package.json +1 -1
  20. package/plugins/openclaw/memorix/.codex-plugin/plugin.json +1 -1
  21. package/plugins/pi/memorix/package.json +1 -1
  22. package/server.json +2 -2
  23. package/src/audit/index.ts +156 -156
  24. package/src/cli/commands/background.ts +51 -5
  25. package/src/cli/commands/formation.ts +48 -48
  26. package/src/cli/commands/git-hook-install.ts +111 -111
  27. package/src/cli/commands/handoff.ts +54 -54
  28. package/src/cli/commands/hooks-status.ts +63 -63
  29. package/src/cli/commands/ingest.ts +44 -44
  30. package/src/cli/commands/integrate-shared.ts +15 -15
  31. package/src/cli/commands/lock.ts +82 -82
  32. package/src/cli/commands/message.ts +104 -104
  33. package/src/cli/commands/operator-shared.ts +18 -11
  34. package/src/cli/commands/poll.ts +58 -58
  35. package/src/cli/commands/reasoning.ts +118 -118
  36. package/src/cli/commands/serve-http.ts +29 -7
  37. package/src/cli/commands/serve-shared.ts +49 -6
  38. package/src/cli/commands/serve.ts +4 -0
  39. package/src/cli/commands/skills.ts +114 -114
  40. package/src/cli/commands/task.ts +167 -167
  41. package/src/cli/commands/transfer.ts +47 -47
  42. package/src/cli/index.ts +406 -406
  43. package/src/cli/tui/ChatView.tsx +234 -234
  44. package/src/cli/tui/CommandBar.tsx +312 -312
  45. package/src/cli/tui/ContextRail.tsx +118 -118
  46. package/src/cli/tui/HeaderBar.tsx +72 -72
  47. package/src/cli/tui/LogoBanner.tsx +51 -51
  48. package/src/cli/tui/index.ts +41 -41
  49. package/src/cli/version.ts +7 -7
  50. package/src/cli/workbench.ts +1 -1
  51. package/src/dashboard/project-classification.ts +64 -64
  52. package/src/embedding/api-provider.ts +503 -27
  53. package/src/embedding/provider.ts +20 -3
  54. package/src/git/hooks-path.ts +85 -85
  55. package/src/hooks/lifecycle.ts +1 -0
  56. package/src/hooks/pattern-detector.ts +173 -173
  57. package/src/hooks/significance-filter.ts +250 -250
  58. package/src/llm/memory-manager.ts +328 -328
  59. package/src/llm/quality.ts +248 -248
  60. package/src/memory/attribution-guard.ts +249 -249
  61. package/src/memory/disclosure-policy.ts +135 -135
  62. package/src/memory/entity-extractor.ts +197 -197
  63. package/src/memory/formation/evaluate.ts +217 -217
  64. package/src/memory/freshness.ts +122 -122
  65. package/src/memory/graph.ts +197 -197
  66. package/src/memory/observations.ts +68 -18
  67. package/src/memory/refs.ts +94 -94
  68. package/src/memory/secret-filter.ts +79 -79
  69. package/src/memory/session.ts +13 -3
  70. package/src/orchestrate/adapters/claude-stream.ts +192 -192
  71. package/src/orchestrate/adapters/claude.ts +111 -111
  72. package/src/orchestrate/adapters/codex-stream.ts +134 -134
  73. package/src/orchestrate/adapters/codex.ts +41 -41
  74. package/src/orchestrate/adapters/gemini-stream.ts +166 -166
  75. package/src/orchestrate/adapters/gemini.ts +42 -42
  76. package/src/orchestrate/adapters/index.ts +73 -73
  77. package/src/orchestrate/adapters/opencode-stream.ts +143 -143
  78. package/src/orchestrate/adapters/opencode.ts +47 -47
  79. package/src/orchestrate/adapters/types.ts +77 -77
  80. package/src/orchestrate/capability-router.ts +284 -284
  81. package/src/orchestrate/cost-tracker.ts +219 -219
  82. package/src/orchestrate/error-recovery.ts +191 -191
  83. package/src/orchestrate/evidence.ts +140 -140
  84. package/src/orchestrate/ledger.ts +110 -110
  85. package/src/orchestrate/memorix-bridge.ts +343 -343
  86. package/src/orchestrate/output-budget.ts +80 -80
  87. package/src/orchestrate/permission.ts +152 -152
  88. package/src/orchestrate/pipeline-trace.ts +131 -131
  89. package/src/orchestrate/prompt-builder.ts +155 -155
  90. package/src/orchestrate/ring-buffer.ts +37 -37
  91. package/src/orchestrate/task-graph.ts +389 -389
  92. package/src/project/aliases.ts +374 -374
  93. package/src/project/launch-root.ts +115 -0
  94. package/src/rules/adapters/trae.ts +56 -56
  95. package/src/rules/syncer.ts +235 -235
  96. package/src/runtime/isolated-maintenance.ts +12 -1
  97. package/src/sdk.ts +12 -8
  98. package/src/search/query-expansion.ts +52 -52
  99. package/src/server/formation-timeout.ts +27 -27
  100. package/src/server.ts +38 -5
  101. package/src/skills/mini-skills.ts +386 -386
  102. package/src/store/chat-store.ts +119 -119
  103. package/src/store/graph-store.ts +249 -249
  104. package/src/store/mini-skill-store.ts +349 -349
  105. package/src/store/obs-store.ts +2 -2
  106. package/src/store/orama-store.ts +21 -3
  107. package/src/store/persistence-json.ts +212 -212
  108. package/src/store/persistence.ts +291 -291
  109. package/src/store/project-affinity.ts +195 -195
  110. package/src/store/sqlite-db.ts +2 -0
  111. package/src/store/sqlite-store.ts +19 -4
  112. package/src/team/event-bus.ts +76 -76
  113. package/src/team/handoff.ts +161 -161
  114. package/src/team/poll.ts +132 -132
  115. package/src/workspace/mcp-adapters/codex.ts +191 -191
  116. package/src/workspace/mcp-adapters/copilot.ts +105 -105
  117. package/src/workspace/mcp-adapters/cursor.ts +53 -53
  118. package/src/workspace/mcp-adapters/kiro.ts +64 -64
  119. package/src/workspace/mcp-adapters/opencode.ts +123 -123
  120. package/src/workspace/mcp-adapters/trae.ts +134 -134
  121. package/src/workspace/mcp-adapters/windsurf.ts +91 -91
  122. package/src/workspace/sanitizer.ts +60 -60
@@ -6,9 +6,11 @@
6
6
  */
7
7
 
8
8
  import { createHash } from 'node:crypto';
9
- import { readFile, writeFile, mkdir } from 'node:fs/promises';
9
+ import { createReadStream, createWriteStream, constants as fsConstants } from 'node:fs';
10
+ import { access, readFile, writeFile, mkdir, rename, stat, unlink } from 'node:fs/promises';
10
11
  import { join } from 'node:path';
11
12
  import { homedir } from 'node:os';
13
+ import { createInterface } from 'node:readline';
12
14
  import {
13
15
  EmbeddingInputError,
14
16
  UnsupportedEmbeddingModalityError,
@@ -26,19 +28,147 @@ function cacheDir(): string {
26
28
  function cacheFile(): string {
27
29
  return join(cacheDir(), '.embedding-api-cache.json');
28
30
  }
31
+ function cacheJsonlFile(): string {
32
+ return join(cacheDir(), '.embedding-api-cache.jsonl');
33
+ }
29
34
  function dimsCacheFile(): string {
30
35
  return join(cacheDir(), '.embedding-dims-cache.json');
31
36
  }
32
37
  function cacheMetaFile(): string {
33
38
  return join(cacheDir(), '.embedding-api-cache-meta.json');
34
39
  }
40
+ function cacheStatFile(): string {
41
+ return join(cacheDir(), '.embedding-api-cache-stat.json');
42
+ }
35
43
 
36
44
  const cache = new Map<string, number[]>();
37
- const MAX_CACHE_SIZE = 10000;
45
+ /** Hard entry ceiling. The real load cap is dimension-aware payload bytes. */
46
+ const MAX_CACHE_ENTRIES = 200000;
47
+ /** 256 MiB of Float64 payload. 4096d × 8 bytes → 8,192 entries, not 200k. */
48
+ const MAX_CACHE_PAYLOAD_BYTES = 256 * 1024 * 1024;
49
+ const BYTES_PER_DIM = 8;
50
+ const DEFAULT_ASSUMED_DIMENSIONS = 4096;
51
+ const MAX_MIGRATION_VALUE_BYTES = 16 * 1024 * 1024;
52
+
53
+ let cachePayloadBytes = 0;
54
+ let cacheDimensions: number | null = null;
55
+ let diskCacheEntryCount = 0;
38
56
  let diskCacheDirty = false;
39
57
  let diskSaveTimer: ReturnType<typeof setTimeout> | null = null;
40
58
  let diskCacheLoaded = false;
41
59
  let diskCacheLoadPromise: Promise<void> | null = null;
60
+ let diskCacheLoadCount = 0;
61
+ let jsonlLineCountCalls = 0;
62
+
63
+ interface CacheStat {
64
+ version: 1;
65
+ entryCount: number;
66
+ dimensions: number | null;
67
+ }
68
+
69
+ function payloadBytes(vector: number[]): number {
70
+ return vector.length * BYTES_PER_DIM;
71
+ }
72
+
73
+ function maxCachePayloadBytes(): number {
74
+ const raw = process.env.MEMORIX_EMBEDDING_CACHE_MAX_BYTES;
75
+ if (raw) {
76
+ const parsed = Number.parseInt(raw, 10);
77
+ if (Number.isFinite(parsed) && parsed > 0) return parsed;
78
+ }
79
+ return MAX_CACHE_PAYLOAD_BYTES;
80
+ }
81
+
82
+ function maxEntriesForDimensions(dimensions: number): number {
83
+ const dim = Math.max(1, Math.floor(dimensions));
84
+ const byBytes = Math.floor(maxCachePayloadBytes() / (dim * BYTES_PER_DIM));
85
+ return Math.max(1, Math.min(MAX_CACHE_ENTRIES, byBytes));
86
+ }
87
+
88
+ function rememberDimensions(vector: number[]): void {
89
+ if (vector.length > 0 && (cacheDimensions === null || vector.length > cacheDimensions)) {
90
+ cacheDimensions = vector.length;
91
+ }
92
+ }
93
+
94
+ function evictOldest(): void {
95
+ const firstKey = cache.keys().next().value;
96
+ if (firstKey === undefined) return;
97
+ const old = cache.get(firstKey);
98
+ cache.delete(firstKey);
99
+ if (old) cachePayloadBytes -= payloadBytes(old);
100
+ }
101
+
102
+ function evictToFit(incomingBytes: number, incomingDims: number): void {
103
+ const limit = maxEntriesForDimensions(incomingDims);
104
+ while (
105
+ cache.size > 0 &&
106
+ (cache.size >= limit || cachePayloadBytes + incomingBytes > maxCachePayloadBytes())
107
+ ) {
108
+ evictOldest();
109
+ }
110
+ }
111
+
112
+ function isNumberArray(value: unknown): value is number[] {
113
+ return Array.isArray(value) && value.every((entry): entry is number => typeof entry === 'number');
114
+ }
115
+
116
+ function parseCacheRow(raw: unknown): { hash: string; vector: number[] } | null {
117
+ if (Array.isArray(raw)) {
118
+ const hash = raw[0];
119
+ const vector = raw[1];
120
+ if (typeof hash === 'string' && isNumberArray(vector)) {
121
+ return { hash, vector };
122
+ }
123
+ return null;
124
+ }
125
+ if (raw && typeof raw === 'object') {
126
+ const row = raw as { h?: unknown; v?: unknown };
127
+ if (typeof row.h === 'string' && isNumberArray(row.v)) {
128
+ return { hash: row.h, vector: row.v };
129
+ }
130
+ }
131
+ return null;
132
+ }
133
+
134
+ function cacheInsert(hash: string, value: number[], dirty: boolean): void {
135
+ rememberDimensions(value);
136
+ const incoming = payloadBytes(value);
137
+ const existing = cache.get(hash);
138
+ if (existing) {
139
+ cachePayloadBytes -= payloadBytes(existing);
140
+ cache.delete(hash);
141
+ }
142
+ evictToFit(incoming, value.length || cacheDimensions || DEFAULT_ASSUMED_DIMENSIONS);
143
+ cache.set(hash, value);
144
+ cachePayloadBytes += incoming;
145
+ if (dirty) diskCacheDirty = true;
146
+ }
147
+
148
+ async function readCacheStat(): Promise<CacheStat | null> {
149
+ try {
150
+ const data = JSON.parse(await readFile(cacheStatFile(), 'utf-8')) as Partial<CacheStat>;
151
+ if (typeof data.entryCount === 'number' && Number.isFinite(data.entryCount) && data.entryCount >= 0) {
152
+ return {
153
+ version: 1,
154
+ entryCount: Math.floor(data.entryCount),
155
+ dimensions: isValidDimension(data.dimensions) ? data.dimensions : null,
156
+ };
157
+ }
158
+ } catch {
159
+ // Stat is an acceleration sidecar, not the source of truth for vectors.
160
+ }
161
+ return null;
162
+ }
163
+
164
+ async function writeCacheStat(stat: CacheStat): Promise<void> {
165
+ try {
166
+ await mkdir(cacheDir(), { recursive: true });
167
+ await writeFile(cacheStatFile(), JSON.stringify(stat));
168
+ } catch {
169
+ // Best-effort sidecar.
170
+ }
171
+ }
42
172
 
43
173
  const MAX_INPUT_CHARS = 32000;
44
174
  const MAX_CONCURRENCY = 4;
@@ -154,30 +284,341 @@ function toProviderInput(input: EmbeddingInput, baseUrl: string): unknown {
154
284
  return { type: input.modality, url: input.url };
155
285
  }
156
286
 
157
- async function loadDiskCache(): Promise<void> {
158
- if (diskCacheLoaded) return;
287
+ /**
288
+ * Parse a large embedding cache off the HTTP event loop.
289
+ * JSON.parse of a 300MB file on the serve-http thread makes /health time out
290
+ * and the LaunchAgent watchdog restart a healthy-but-busy control plane.
291
+ */
292
+ export function parseCacheJsonInWorker(filePath: string): Promise<[string, number[]][]> {
293
+ return new Promise((resolve, reject) => {
294
+ void import('node:worker_threads').then(({ Worker }) => {
295
+ const worker = new Worker(
296
+ `
297
+ const { parentPort, workerData } = require('node:worker_threads');
298
+ const { readFileSync } = require('node:fs');
299
+ try {
300
+ const raw = readFileSync(workerData.filePath, 'utf8');
301
+ parentPort.postMessage({ ok: true, entries: JSON.parse(raw) });
302
+ } catch (err) {
303
+ parentPort.postMessage({
304
+ ok: false,
305
+ error: err instanceof Error ? err.message : String(err),
306
+ });
307
+ }
308
+ `,
309
+ { eval: true, workerData: { filePath } },
310
+ );
311
+ worker.once('message', (msg: { ok?: boolean; entries?: [string, number[]][]; error?: string }) => {
312
+ void worker.terminate();
313
+ if (msg?.ok && Array.isArray(msg.entries)) {
314
+ resolve(msg.entries);
315
+ return;
316
+ }
317
+ reject(new Error(msg?.error || 'embedding cache worker parse failed'));
318
+ });
319
+ worker.once('error', reject);
320
+ }).catch(reject);
321
+ });
322
+ }
323
+
324
+ function findJsonValueEnd(source: string): number {
325
+ let i = 0;
326
+ while (i < source.length && /\s/.test(source[i])) i += 1;
327
+ if (i >= source.length) return -1;
328
+ const start = source[i];
329
+ if (start === '"') {
330
+ i += 1;
331
+ while (i < source.length) {
332
+ if (source[i] === '\\') {
333
+ i += 2;
334
+ continue;
335
+ }
336
+ if (source[i] === '"') return i + 1;
337
+ i += 1;
338
+ }
339
+ return -1;
340
+ }
341
+ if (start === '{' || start === '[') {
342
+ const close = start === '{' ? '}' : ']';
343
+ let depth = 0;
344
+ let inString = false;
345
+ for (; i < source.length; i += 1) {
346
+ const ch = source[i];
347
+ if (inString) {
348
+ if (ch === '\\') {
349
+ i += 1;
350
+ continue;
351
+ }
352
+ if (ch === '"') inString = false;
353
+ continue;
354
+ }
355
+ if (ch === '"') {
356
+ inString = true;
357
+ continue;
358
+ }
359
+ if (ch === start) depth += 1;
360
+ else if (ch === close) {
361
+ depth -= 1;
362
+ if (depth === 0) return i + 1;
363
+ }
364
+ }
365
+ return -1;
366
+ }
367
+ const rest = source.slice(i);
368
+ const match = rest.match(/^(?:-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|true|false|null)/);
369
+ return match ? i + match[0].length : -1;
370
+ }
371
+
372
+ async function streamJsonArrayToJsonl(filePath: string, tmpPath: string): Promise<number> {
373
+ const input = createReadStream(filePath, { encoding: 'utf8' });
374
+ const out = createWriteStream(tmpPath);
375
+ let buf = '';
376
+ let started = false;
377
+ let count = 0;
378
+ const writeLine = async (line: string): Promise<void> => {
379
+ if (!out.write(line)) {
380
+ await new Promise<void>((resolve, reject) => {
381
+ out.once('drain', resolve);
382
+ out.once('error', reject);
383
+ });
384
+ }
385
+ };
386
+
387
+ try {
388
+ for await (const chunk of input) {
389
+ buf += chunk;
390
+ if (!started) {
391
+ const idx = buf.search(/\S/);
392
+ if (idx < 0) continue;
393
+ if (buf[idx] !== '[') throw new Error('embedding cache is not a JSON array');
394
+ buf = buf.slice(idx + 1);
395
+ started = true;
396
+ }
397
+ while (true) {
398
+ buf = buf.replace(/^\s*,?\s*/, '');
399
+ if (!buf) break;
400
+ if (buf[0] === ']') {
401
+ buf = '';
402
+ break;
403
+ }
404
+ const end = findJsonValueEnd(buf);
405
+ if (end < 0) break;
406
+ const value = JSON.parse(buf.slice(0, end));
407
+ buf = buf.slice(end);
408
+ const row = parseCacheRow(value);
409
+ if (row) {
410
+ await writeLine(`${JSON.stringify({ h: row.hash, v: row.vector })}\n`);
411
+ count += 1;
412
+ if (count % 50 === 0) {
413
+ await new Promise<void>((resolve) => setImmediate(resolve));
414
+ }
415
+ }
416
+ }
417
+ if (buf.length > MAX_MIGRATION_VALUE_BYTES) {
418
+ throw new Error('embedding cache entry too large to migrate safely');
419
+ }
420
+ }
421
+ } finally {
422
+ await new Promise<void>((resolve, reject) => {
423
+ out.end(() => resolve());
424
+ out.once('error', reject);
425
+ });
426
+ }
427
+ return count;
428
+ }
429
+
430
+ /**
431
+ * Convert a JSON-array cache to JSONL with one-entry-at-a-time parsing,
432
+ * a temp file, and an atomic rename. The HTTP process never holds the full
433
+ * array plus a lines[] copy.
434
+ */
435
+ export async function migrateJsonArrayCacheToJsonl(filePath: string, jsonlPath: string): Promise<number> {
436
+ const tmpPath = `${jsonlPath}.tmp`;
437
+ try {
438
+ const count = await streamJsonArrayToJsonl(filePath, tmpPath);
439
+ await rename(tmpPath, jsonlPath);
440
+ diskCacheEntryCount = count;
441
+ await writeCacheStat({
442
+ version: 1,
443
+ entryCount: count,
444
+ dimensions: cacheDimensions,
445
+ });
446
+ return count;
447
+ } catch (err) {
448
+ await unlink(tmpPath).catch(() => undefined);
449
+ throw err;
450
+ }
451
+ }
452
+
453
+ /**
454
+ * Convert a giant JSON-array cache to JSONL on disk. Never postMessage the
455
+ * vectors — structured clone of 27k×1024 floats OOMs the HTTP process and
456
+ * the subsequent empty save overwrites the real cache.
457
+ */
458
+ export function convertArrayCacheToJsonl(filePath: string, jsonlPath: string): Promise<number> {
459
+ return migrateJsonArrayCacheToJsonl(filePath, jsonlPath);
460
+ }
461
+
462
+ async function loadCacheJsonl(filePath: string): Promise<number> {
463
+ const rl = createInterface({
464
+ input: createReadStream(filePath, { encoding: 'utf8' }),
465
+ crlfDelay: Infinity,
466
+ });
467
+ let loaded = 0;
468
+ let onDiskCount = 0;
469
+ for await (const line of rl) {
470
+ const trimmed = line.trim();
471
+ if (!trimmed) continue;
472
+ try {
473
+ const row = parseCacheRow(JSON.parse(trimmed));
474
+ if (!row) continue;
475
+ onDiskCount += 1;
476
+ cacheInsert(row.hash, row.vector, false);
477
+ loaded = cache.size;
478
+ } catch {
479
+ // Skip a corrupt line; the rest of the cache remains usable.
480
+ }
481
+ if (onDiskCount % 250 === 0) {
482
+ await new Promise<void>((resolve) => setImmediate(resolve));
483
+ }
484
+ }
485
+ diskCacheEntryCount = onDiskCount;
486
+ await writeCacheStat({
487
+ version: 1,
488
+ entryCount: onDiskCount,
489
+ dimensions: cacheDimensions,
490
+ });
491
+ return loaded;
492
+ }
493
+
494
+ async function countJsonlLines(filePath: string): Promise<number> {
495
+ jsonlLineCountCalls += 1;
496
+ try {
497
+ const rl = createInterface({
498
+ input: createReadStream(filePath, { encoding: 'utf8' }),
499
+ crlfDelay: Infinity,
500
+ });
501
+ let count = 0;
502
+ for await (const line of rl) {
503
+ if (line.trim()) count += 1;
504
+ }
505
+ return count;
506
+ } catch {
507
+ return 0;
508
+ }
509
+ }
510
+
511
+ async function resolveOnDiskCount(jsonlPath: string): Promise<number> {
512
+ if (diskCacheEntryCount > 0) return diskCacheEntryCount;
513
+ const sidecar = await readCacheStat();
514
+ if (sidecar) {
515
+ diskCacheEntryCount = sidecar.entryCount;
516
+ if (sidecar.dimensions && cacheDimensions === null) cacheDimensions = sidecar.dimensions;
517
+ return sidecar.entryCount;
518
+ }
519
+ try {
520
+ await access(jsonlPath, fsConstants.R_OK);
521
+ } catch {
522
+ return 0;
523
+ }
524
+ // Legacy JSONL without a sidecar: one-time count, then persist so saves stay O(1).
525
+ const counted = await countJsonlLines(jsonlPath);
526
+ diskCacheEntryCount = counted;
527
+ await writeCacheStat({
528
+ version: 1,
529
+ entryCount: counted,
530
+ dimensions: cacheDimensions,
531
+ });
532
+ return counted;
533
+ }
534
+
535
+ async function loadDiskCache(): Promise<number> {
536
+ if (diskCacheLoaded) return cache.size;
537
+ let loaded = 0;
159
538
  try {
160
- const raw = await readFile(cacheFile(), 'utf-8');
161
- const entries: [string, number[]][] = JSON.parse(raw);
162
- for (const [k, v] of entries) cache.set(k, v);
163
- console.error(`[memorix] Loaded ${entries.length} cached API embeddings from disk`);
539
+ const jsonlPath = cacheJsonlFile();
540
+ let hasJsonl = false;
541
+ try {
542
+ await access(jsonlPath, fsConstants.R_OK);
543
+ hasJsonl = true;
544
+ } catch {
545
+ hasJsonl = false;
546
+ }
547
+ if (hasJsonl) {
548
+ loaded = await loadCacheJsonl(jsonlPath);
549
+ } else {
550
+ const filePath = cacheFile();
551
+ await stat(filePath);
552
+ await migrateJsonArrayCacheToJsonl(filePath, jsonlPath);
553
+ loaded = await loadCacheJsonl(jsonlPath);
554
+ }
555
+ diskCacheLoadCount += 1;
556
+ console.error(`[memorix] Loaded ${loaded} cached API embeddings from disk`);
164
557
  } catch {
165
- // No cache file or corrupt cache; start fresh.
558
+ // No cache file or corrupt cache; start fresh. Never save an empty map
559
+ // over a larger on-disk cache — saveDiskCacheNow refuses that shrink.
166
560
  }
167
561
  diskCacheLoaded = true;
562
+ return loaded;
168
563
  }
169
564
 
170
565
  /** Start loading disk cache in background (non-blocking). */
171
566
  function startDiskCacheLoad(): void {
172
567
  if (diskCacheLoaded || diskCacheLoadPromise) return;
173
- diskCacheLoadPromise = loadDiskCache().catch(() => {});
568
+ diskCacheLoadPromise = loadDiskCache().then(() => undefined).catch(() => {});
174
569
  }
175
570
 
176
571
  /** Ensure disk cache is loaded (await if still in progress). */
177
- async function ensureDiskCacheLoaded(): Promise<void> {
178
- if (diskCacheLoaded) return;
179
- if (diskCacheLoadPromise) { await diskCacheLoadPromise; return; }
180
- await loadDiskCache();
572
+ export async function ensureDiskCacheLoaded(): Promise<number> {
573
+ if (diskCacheLoaded) return cache.size;
574
+ if (!diskCacheLoadPromise) {
575
+ diskCacheLoadPromise = loadDiskCache().then(() => undefined).catch(() => {});
576
+ }
577
+ await diskCacheLoadPromise;
578
+ return cache.size;
579
+ }
580
+
581
+ /** @internal How many times the on-disk embedding cache was parsed. */
582
+ export function getApiEmbeddingDiskCacheLoadCountForTests(): number {
583
+ return diskCacheLoadCount;
584
+ }
585
+
586
+ /** @internal Reset in-process API embedding cache between tests. */
587
+ export function resetApiEmbeddingCacheForTests(): void {
588
+ cache.clear();
589
+ cachePayloadBytes = 0;
590
+ cacheDimensions = null;
591
+ diskCacheEntryCount = 0;
592
+ diskCacheLoaded = false;
593
+ diskCacheLoadPromise = null;
594
+ diskCacheLoadCount = 0;
595
+ diskCacheDirty = false;
596
+ jsonlLineCountCalls = 0;
597
+ }
598
+
599
+ /** @internal Seed the in-memory API embedding cache for shrink-guard tests. */
600
+ export function seedApiEmbeddingCacheForTests(entries: Map<string, number[]>): void {
601
+ cache.clear();
602
+ cachePayloadBytes = 0;
603
+ cacheDimensions = null;
604
+ for (const [key, value] of entries) cacheInsert(key, value, true);
605
+ diskCacheLoaded = true;
606
+ diskCacheDirty = true;
607
+ }
608
+
609
+ /** @internal In-memory vector payload bytes retained after load/eviction. */
610
+ export function getApiEmbeddingCachePayloadBytesForTests(): number {
611
+ return cachePayloadBytes;
612
+ }
613
+
614
+ /** @internal How many times a full JSONL line scan ran (must stay off the save path). */
615
+ export function getApiEmbeddingJsonlLineCountCallsForTests(): number {
616
+ return jsonlLineCountCalls;
617
+ }
618
+
619
+ /** @internal Dimension-aware in-memory entry cap for a vector width. */
620
+ export function getApiEmbeddingMaxEntriesForDimensionsForTests(dimensions: number): number {
621
+ return maxEntriesForDimensions(dimensions);
181
622
  }
182
623
 
183
624
  function dimsCacheKey(config: Pick<APIEmbeddingConfig, 'baseUrl' | 'model' | 'requestedDimensions'>): string {
@@ -355,12 +796,49 @@ async function saveCachedDims(config: Pick<APIEmbeddingConfig, 'baseUrl' | 'mode
355
796
  await saveCachedVectorDimensions(config, dimensions);
356
797
  }
357
798
 
358
- async function saveDiskCacheNow(): Promise<void> {
799
+ export async function saveDiskCacheNow(): Promise<void> {
359
800
  if (!diskCacheDirty) return;
360
801
  try {
361
802
  await mkdir(cacheDir(), { recursive: true });
362
- const entries = Array.from(cache.entries());
363
- await writeFile(cacheFile(), JSON.stringify(entries));
803
+ const jsonlPath = cacheJsonlFile();
804
+ const existing = await resolveOnDiskCount(jsonlPath);
805
+ if (existing > 0 && cache.size < Math.ceil(existing * 0.5)) {
806
+ console.error(
807
+ `[memorix] Refusing to overwrite embedding cache (${existing} on disk vs ${cache.size} in memory)`,
808
+ );
809
+ diskCacheDirty = false;
810
+ return;
811
+ }
812
+ const tmpPath = `${jsonlPath}.tmp`;
813
+ await new Promise<void>((resolve, reject) => {
814
+ const out = createWriteStream(tmpPath);
815
+ const entries = cache.entries();
816
+ const writeBatch = (): void => {
817
+ for (let i = 0; i < 50; i++) {
818
+ const next = entries.next();
819
+ if (next.done) {
820
+ out.end();
821
+ return;
822
+ }
823
+ const [hash, vector] = next.value;
824
+ if (!out.write(`${JSON.stringify({ h: hash, v: vector })}\n`)) {
825
+ out.once('drain', writeBatch);
826
+ return;
827
+ }
828
+ }
829
+ setImmediate(writeBatch);
830
+ };
831
+ out.on('finish', resolve);
832
+ out.on('error', reject);
833
+ writeBatch();
834
+ });
835
+ await rename(tmpPath, jsonlPath);
836
+ diskCacheEntryCount = cache.size;
837
+ await writeCacheStat({
838
+ version: 1,
839
+ entryCount: cache.size,
840
+ dimensions: cacheDimensions,
841
+ });
364
842
  diskCacheDirty = false;
365
843
  } catch {
366
844
  // Cache persistence is best-effort only.
@@ -376,12 +854,7 @@ function scheduleDiskSave(): void {
376
854
  }
377
855
 
378
856
  function cacheSet(hash: string, value: number[]): void {
379
- if (cache.size >= MAX_CACHE_SIZE) {
380
- const firstKey = cache.keys().next().value;
381
- if (firstKey !== undefined) cache.delete(firstKey);
382
- }
383
- cache.set(hash, value);
384
- diskCacheDirty = true;
857
+ cacheInsert(hash, value, true);
385
858
  }
386
859
 
387
860
  interface EmbeddingAPIResponse {
@@ -516,10 +989,13 @@ export class APIEmbeddingProvider implements EmbeddingProvider {
516
989
  console.error(`[memorix] Dimension shortening: ${config.requestedDimensions}d requested`);
517
990
  }
518
991
 
519
- // The cache can only be used after dimensions are known. In cache-only
520
- // startup with no metadata, skip parsing the potentially large cache file
521
- // altogether and stay lexical until a normal embedding lane is needed.
522
- startDiskCacheLoad();
992
+ // Startup / CLI lexical lanes pass allowNetworkProbe=false. Do not begin
993
+ // the 300MB JSONL parse here — that keeps one-shot CLI processes alive
994
+ // after search already printed. getCachedEmbeddings() still loads the
995
+ // cache when a later hybrid/attach path actually needs vectors.
996
+ if (allowNetworkProbe) {
997
+ startDiskCacheLoad();
998
+ }
523
999
 
524
1000
  return new APIEmbeddingProvider(config, probeDimensions);
525
1001
  }
@@ -173,6 +173,8 @@ let provider: EmbeddingProvider | null = null;
173
173
  let initPromise: Promise<EmbeddingProvider | null> | null = null;
174
174
 
175
175
  type EmbeddingMode = 'off' | 'fastembed' | 'transformers' | 'api' | 'auto';
176
+ let cachedEmbeddingMode: EmbeddingMode | null = null;
177
+ let cachedEmbeddingModeToken: string | null = null;
176
178
  type ProviderKind = 'api' | 'fastembed' | 'transformers' | 'unknown';
177
179
 
178
180
  export type EmbeddingRuntimeStatus = 'disabled' | 'uninitialized' | 'ready' | 'degraded';
@@ -213,7 +215,13 @@ function warnOnce(message: string): void {
213
215
  * Get configured embedding mode from environment.
214
216
  * Default is 'off' to minimize resource usage.
215
217
  */
218
+ function embeddingModeToken(): string {
219
+ return process.env.MEMORIX_EMBEDDING?.toLowerCase()?.trim() ?? '';
220
+ }
221
+
216
222
  function getEmbeddingMode(): EmbeddingMode {
223
+ const token = embeddingModeToken();
224
+ if (cachedEmbeddingMode && cachedEmbeddingModeToken === token) return cachedEmbeddingMode;
217
225
  // Unified: env vars > config.json > 'off'
218
226
  try {
219
227
  let cfg: { getEmbeddingMode: () => EmbeddingMode };
@@ -223,11 +231,18 @@ function getEmbeddingMode(): EmbeddingMode {
223
231
  cfg = require('../config.js');
224
232
  }
225
233
  const { getEmbeddingMode: cfgMode } = cfg;
226
- return cfgMode();
234
+ cachedEmbeddingMode = cfgMode();
235
+ cachedEmbeddingModeToken = token;
236
+ return cachedEmbeddingMode;
227
237
  } catch {
228
238
  // Fallback if config module not available
229
- const env = process.env.MEMORIX_EMBEDDING?.toLowerCase()?.trim();
230
- if (env === 'fastembed' || env === 'transformers' || env === 'api' || env === 'auto') return env;
239
+ if (token === 'fastembed' || token === 'transformers' || token === 'api' || token === 'auto') {
240
+ cachedEmbeddingMode = token;
241
+ cachedEmbeddingModeToken = token;
242
+ return token;
243
+ }
244
+ cachedEmbeddingMode = 'off';
245
+ cachedEmbeddingModeToken = token;
231
246
  return 'off';
232
247
  }
233
248
  }
@@ -596,6 +611,8 @@ export function isEmbeddingExplicitlyDisabled(): boolean {
596
611
  export function resetProvider(): void {
597
612
  provider = null;
598
613
  initPromise = null;
614
+ cachedEmbeddingMode = null;
615
+ cachedEmbeddingModeToken = null;
599
616
  lastInitWasTemporaryFailure = false;
600
617
  lastFailureTimestamp = 0;
601
618
  lastEmbeddingFailure = null;