memorix 1.6.0 → 1.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/dist/cli/index.js +1888 -1122
  3. package/dist/cli/memcode.js +12 -1
  4. package/dist/index.js +641 -113
  5. package/dist/maintenance-runner.js +618 -90
  6. package/dist/media-audio-runner.js +618 -90
  7. package/dist/media-video-runner.js +618 -90
  8. package/dist/memcode-runtime/CHANGELOG.md +46 -0
  9. package/dist/sdk.js +674 -117
  10. package/dist/vector-backfill-runner.js +618 -90
  11. package/docs/AGENT_OPERATOR_PLAYBOOK.md +11 -3
  12. package/docs/API_REFERENCE.md +1 -1
  13. package/docs/DOCKER.md +5 -1
  14. package/docs/PERFORMANCE.md +17 -2
  15. package/docs/README.md +3 -1
  16. package/docs/SETUP.md +8 -2
  17. package/package.json +2 -1
  18. package/plugins/claude/memorix/.claude-plugin/plugin.json +1 -1
  19. package/plugins/codebuddy/memorix/.codebuddy-plugin/plugin.json +1 -1
  20. package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
  21. package/plugins/copilot/memorix/plugin.json +1 -1
  22. package/plugins/gemini/memorix/gemini-extension.json +1 -1
  23. package/plugins/omp/memorix/package.json +1 -1
  24. package/plugins/openclaw/memorix/.codex-plugin/plugin.json +1 -1
  25. package/plugins/pi/memorix/package.json +1 -1
  26. package/server.json +2 -2
  27. package/src/cli/commands/agent-integrations.ts +9 -3
  28. package/src/cli/commands/background.ts +34 -2
  29. package/src/cli/commands/doctor.ts +46 -15
  30. package/src/cli/commands/hook.ts +13 -1
  31. package/src/cli/commands/operator-shared.ts +6 -3
  32. package/src/cli/commands/serve-http.ts +72 -7
  33. package/src/cli/heap.ts +76 -0
  34. package/src/cli/invocation.ts +7 -4
  35. package/src/embedding/api-provider.ts +503 -27
  36. package/src/embedding/provider.ts +96 -9
  37. package/src/hooks/handler.ts +9 -18
  38. package/src/hooks/lifecycle.ts +174 -0
  39. package/src/memory/observations.ts +68 -18
  40. package/src/sdk.ts +12 -8
  41. package/src/store/orama-store.ts +21 -3
@@ -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
  }