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.
- package/CHANGELOG.md +35 -0
- package/TEAM.md +86 -86
- package/dist/cli/index.js +2582 -1845
- package/dist/index.js +1355 -683
- package/dist/maintenance-runner.js +1621 -1096
- package/dist/media-audio-runner.js +1612 -1088
- package/dist/media-video-runner.js +1612 -1088
- package/dist/memcode-runtime/CHANGELOG.md +35 -0
- package/dist/sdk.js +2007 -1396
- package/dist/vector-backfill-runner.js +1610 -1086
- package/docs/DESIGN_DECISIONS.md +357 -357
- package/docs/PERFORMANCE.md +15 -0
- package/package.json +2 -1
- package/plugins/claude/memorix/.claude-plugin/plugin.json +1 -1
- package/plugins/codebuddy/memorix/.codebuddy-plugin/plugin.json +1 -1
- package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
- package/plugins/copilot/memorix/plugin.json +1 -1
- package/plugins/gemini/memorix/gemini-extension.json +1 -1
- package/plugins/omp/memorix/package.json +1 -1
- package/plugins/openclaw/memorix/.codex-plugin/plugin.json +1 -1
- package/plugins/pi/memorix/package.json +1 -1
- package/server.json +2 -2
- package/src/audit/index.ts +156 -156
- package/src/cli/commands/background.ts +51 -5
- package/src/cli/commands/formation.ts +48 -48
- package/src/cli/commands/git-hook-install.ts +111 -111
- package/src/cli/commands/handoff.ts +54 -54
- package/src/cli/commands/hooks-status.ts +63 -63
- package/src/cli/commands/ingest.ts +44 -44
- package/src/cli/commands/integrate-shared.ts +15 -15
- package/src/cli/commands/lock.ts +82 -82
- package/src/cli/commands/message.ts +104 -104
- package/src/cli/commands/operator-shared.ts +18 -11
- package/src/cli/commands/poll.ts +58 -58
- package/src/cli/commands/reasoning.ts +118 -118
- package/src/cli/commands/serve-http.ts +29 -7
- package/src/cli/commands/serve-shared.ts +49 -6
- package/src/cli/commands/serve.ts +4 -0
- package/src/cli/commands/skills.ts +114 -114
- package/src/cli/commands/task.ts +167 -167
- package/src/cli/commands/transfer.ts +47 -47
- package/src/cli/index.ts +406 -406
- package/src/cli/tui/ChatView.tsx +234 -234
- package/src/cli/tui/CommandBar.tsx +312 -312
- package/src/cli/tui/ContextRail.tsx +118 -118
- package/src/cli/tui/HeaderBar.tsx +72 -72
- package/src/cli/tui/LogoBanner.tsx +51 -51
- package/src/cli/tui/index.ts +41 -41
- package/src/cli/version.ts +7 -7
- package/src/cli/workbench.ts +1 -1
- package/src/dashboard/project-classification.ts +64 -64
- package/src/embedding/api-provider.ts +503 -27
- package/src/embedding/provider.ts +20 -3
- package/src/git/hooks-path.ts +85 -85
- package/src/hooks/lifecycle.ts +1 -0
- package/src/hooks/pattern-detector.ts +173 -173
- package/src/hooks/significance-filter.ts +250 -250
- package/src/llm/memory-manager.ts +328 -328
- package/src/llm/quality.ts +248 -248
- package/src/memory/attribution-guard.ts +249 -249
- package/src/memory/disclosure-policy.ts +135 -135
- package/src/memory/entity-extractor.ts +197 -197
- package/src/memory/formation/evaluate.ts +217 -217
- package/src/memory/freshness.ts +122 -122
- package/src/memory/graph.ts +197 -197
- package/src/memory/observations.ts +68 -18
- package/src/memory/refs.ts +94 -94
- package/src/memory/secret-filter.ts +79 -79
- package/src/memory/session.ts +13 -3
- package/src/orchestrate/adapters/claude-stream.ts +192 -192
- package/src/orchestrate/adapters/claude.ts +111 -111
- package/src/orchestrate/adapters/codex-stream.ts +134 -134
- package/src/orchestrate/adapters/codex.ts +41 -41
- package/src/orchestrate/adapters/gemini-stream.ts +166 -166
- package/src/orchestrate/adapters/gemini.ts +42 -42
- package/src/orchestrate/adapters/index.ts +73 -73
- package/src/orchestrate/adapters/opencode-stream.ts +143 -143
- package/src/orchestrate/adapters/opencode.ts +47 -47
- package/src/orchestrate/adapters/types.ts +77 -77
- package/src/orchestrate/capability-router.ts +284 -284
- package/src/orchestrate/cost-tracker.ts +219 -219
- package/src/orchestrate/error-recovery.ts +191 -191
- package/src/orchestrate/evidence.ts +140 -140
- package/src/orchestrate/ledger.ts +110 -110
- package/src/orchestrate/memorix-bridge.ts +343 -343
- package/src/orchestrate/output-budget.ts +80 -80
- package/src/orchestrate/permission.ts +152 -152
- package/src/orchestrate/pipeline-trace.ts +131 -131
- package/src/orchestrate/prompt-builder.ts +155 -155
- package/src/orchestrate/ring-buffer.ts +37 -37
- package/src/orchestrate/task-graph.ts +389 -389
- package/src/project/aliases.ts +374 -374
- package/src/project/launch-root.ts +115 -0
- package/src/rules/adapters/trae.ts +56 -56
- package/src/rules/syncer.ts +235 -235
- package/src/runtime/isolated-maintenance.ts +12 -1
- package/src/sdk.ts +12 -8
- package/src/search/query-expansion.ts +52 -52
- package/src/server/formation-timeout.ts +27 -27
- package/src/server.ts +38 -5
- package/src/skills/mini-skills.ts +386 -386
- package/src/store/chat-store.ts +119 -119
- package/src/store/graph-store.ts +249 -249
- package/src/store/mini-skill-store.ts +349 -349
- package/src/store/obs-store.ts +2 -2
- package/src/store/orama-store.ts +21 -3
- package/src/store/persistence-json.ts +212 -212
- package/src/store/persistence.ts +291 -291
- package/src/store/project-affinity.ts +195 -195
- package/src/store/sqlite-db.ts +2 -0
- package/src/store/sqlite-store.ts +19 -4
- package/src/team/event-bus.ts +76 -76
- package/src/team/handoff.ts +161 -161
- package/src/team/poll.ts +132 -132
- package/src/workspace/mcp-adapters/codex.ts +191 -191
- package/src/workspace/mcp-adapters/copilot.ts +105 -105
- package/src/workspace/mcp-adapters/cursor.ts +53 -53
- package/src/workspace/mcp-adapters/kiro.ts +64 -64
- package/src/workspace/mcp-adapters/opencode.ts +123 -123
- package/src/workspace/mcp-adapters/trae.ts +134 -134
- package/src/workspace/mcp-adapters/windsurf.ts +91 -91
- package/src/workspace/sanitizer.ts +60 -60
|
@@ -6,9 +6,11 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { createHash } from 'node:crypto';
|
|
9
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
158
|
-
|
|
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
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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<
|
|
178
|
-
if (diskCacheLoaded) return;
|
|
179
|
-
if (diskCacheLoadPromise) {
|
|
180
|
-
|
|
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
|
|
363
|
-
await
|
|
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
|
-
|
|
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
|
-
//
|
|
520
|
-
//
|
|
521
|
-
//
|
|
522
|
-
|
|
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
|
-
|
|
234
|
+
cachedEmbeddingMode = cfgMode();
|
|
235
|
+
cachedEmbeddingModeToken = token;
|
|
236
|
+
return cachedEmbeddingMode;
|
|
227
237
|
} catch {
|
|
228
238
|
// Fallback if config module not available
|
|
229
|
-
|
|
230
|
-
|
|
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;
|