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.
- package/CHANGELOG.md +46 -0
- package/dist/cli/index.js +1888 -1122
- package/dist/cli/memcode.js +12 -1
- package/dist/index.js +641 -113
- package/dist/maintenance-runner.js +618 -90
- package/dist/media-audio-runner.js +618 -90
- package/dist/media-video-runner.js +618 -90
- package/dist/memcode-runtime/CHANGELOG.md +46 -0
- package/dist/sdk.js +674 -117
- package/dist/vector-backfill-runner.js +618 -90
- package/docs/AGENT_OPERATOR_PLAYBOOK.md +11 -3
- package/docs/API_REFERENCE.md +1 -1
- package/docs/DOCKER.md +5 -1
- package/docs/PERFORMANCE.md +17 -2
- package/docs/README.md +3 -1
- package/docs/SETUP.md +8 -2
- 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/cli/commands/agent-integrations.ts +9 -3
- package/src/cli/commands/background.ts +34 -2
- package/src/cli/commands/doctor.ts +46 -15
- package/src/cli/commands/hook.ts +13 -1
- package/src/cli/commands/operator-shared.ts +6 -3
- package/src/cli/commands/serve-http.ts +72 -7
- package/src/cli/heap.ts +76 -0
- package/src/cli/invocation.ts +7 -4
- package/src/embedding/api-provider.ts +503 -27
- package/src/embedding/provider.ts +96 -9
- package/src/hooks/handler.ts +9 -18
- package/src/hooks/lifecycle.ts +174 -0
- package/src/memory/observations.ts +68 -18
- package/src/sdk.ts +12 -8
- package/src/store/orama-store.ts +21 -3
|
@@ -173,14 +173,30 @@ 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
|
|
|
180
|
+
export type EmbeddingRuntimeStatus = 'disabled' | 'uninitialized' | 'ready' | 'degraded';
|
|
181
|
+
export type EmbeddingFailureKind = 'authorization' | 'billing' | 'timeout' | 'upstream';
|
|
182
|
+
|
|
183
|
+
export interface EmbeddingRuntimeHealth {
|
|
184
|
+
status: EmbeddingRuntimeStatus;
|
|
185
|
+
provider?: { name: string; dimensions: number };
|
|
186
|
+
failure?: {
|
|
187
|
+
kind: EmbeddingFailureKind;
|
|
188
|
+
occurredAt: string;
|
|
189
|
+
retryAfterMs: number;
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
178
193
|
/**
|
|
179
194
|
* Tracks whether the last init attempt resulted in a temporary failure
|
|
180
195
|
* (mode != 'off' but provider returned null). When true, the next
|
|
181
196
|
* getEmbeddingProvider() call will retry instead of returning cached null.
|
|
182
197
|
*/
|
|
183
198
|
let lastInitWasTemporaryFailure = false;
|
|
199
|
+
let lastEmbeddingFailure: { kind: EmbeddingFailureKind; occurredAt: number } | null = null;
|
|
184
200
|
let lastEmbeddingWarning = '';
|
|
185
201
|
let lastEmbeddingWarningAt = 0;
|
|
186
202
|
const WARNING_COOLDOWN_MS = 30_000;
|
|
@@ -199,7 +215,13 @@ function warnOnce(message: string): void {
|
|
|
199
215
|
* Get configured embedding mode from environment.
|
|
200
216
|
* Default is 'off' to minimize resource usage.
|
|
201
217
|
*/
|
|
218
|
+
function embeddingModeToken(): string {
|
|
219
|
+
return process.env.MEMORIX_EMBEDDING?.toLowerCase()?.trim() ?? '';
|
|
220
|
+
}
|
|
221
|
+
|
|
202
222
|
function getEmbeddingMode(): EmbeddingMode {
|
|
223
|
+
const token = embeddingModeToken();
|
|
224
|
+
if (cachedEmbeddingMode && cachedEmbeddingModeToken === token) return cachedEmbeddingMode;
|
|
203
225
|
// Unified: env vars > config.json > 'off'
|
|
204
226
|
try {
|
|
205
227
|
let cfg: { getEmbeddingMode: () => EmbeddingMode };
|
|
@@ -209,11 +231,18 @@ function getEmbeddingMode(): EmbeddingMode {
|
|
|
209
231
|
cfg = require('../config.js');
|
|
210
232
|
}
|
|
211
233
|
const { getEmbeddingMode: cfgMode } = cfg;
|
|
212
|
-
|
|
234
|
+
cachedEmbeddingMode = cfgMode();
|
|
235
|
+
cachedEmbeddingModeToken = token;
|
|
236
|
+
return cachedEmbeddingMode;
|
|
213
237
|
} catch {
|
|
214
238
|
// Fallback if config module not available
|
|
215
|
-
|
|
216
|
-
|
|
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;
|
|
217
246
|
return 'off';
|
|
218
247
|
}
|
|
219
248
|
}
|
|
@@ -271,13 +300,38 @@ function isTemporaryEmbeddingFailure(error: unknown): boolean {
|
|
|
271
300
|
);
|
|
272
301
|
}
|
|
273
302
|
|
|
303
|
+
function classifyEmbeddingFailure(error: unknown): EmbeddingFailureKind {
|
|
304
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
305
|
+
if (/embedding api error \(401\)|invalid_api_key|incorrect api key|unauthorized/i.test(message)) {
|
|
306
|
+
return 'authorization';
|
|
307
|
+
}
|
|
308
|
+
if (/embedding api error \(402\)|quota exceeded|account balance/i.test(message)) {
|
|
309
|
+
return 'billing';
|
|
310
|
+
}
|
|
311
|
+
if (/embedding api timeout|abort/i.test(message)) {
|
|
312
|
+
return 'timeout';
|
|
313
|
+
}
|
|
314
|
+
return 'upstream';
|
|
315
|
+
}
|
|
316
|
+
|
|
274
317
|
function markTemporaryFailure(reason: unknown): void {
|
|
275
318
|
provider = null;
|
|
276
319
|
initPromise = null;
|
|
277
320
|
lastInitWasTemporaryFailure = true;
|
|
278
321
|
lastFailureTimestamp = Date.now();
|
|
322
|
+
lastEmbeddingFailure = {
|
|
323
|
+
kind: classifyEmbeddingFailure(reason),
|
|
324
|
+
occurredAt: lastFailureTimestamp,
|
|
325
|
+
};
|
|
279
326
|
const message = reason instanceof Error ? reason.message : String(reason);
|
|
280
|
-
warnOnce(`[memorix] Embedding provider
|
|
327
|
+
warnOnce(`[memorix] Embedding provider unavailable at runtime: ${message}`);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function markProviderReady(candidate: EmbeddingProvider): EmbeddingProvider {
|
|
331
|
+
provider = wrapProvider(candidate);
|
|
332
|
+
lastInitWasTemporaryFailure = false;
|
|
333
|
+
lastEmbeddingFailure = null;
|
|
334
|
+
return provider;
|
|
281
335
|
}
|
|
282
336
|
|
|
283
337
|
async function createFastEmbedProvider(): Promise<EmbeddingProvider | null> {
|
|
@@ -438,7 +492,7 @@ export async function getEmbeddingProvider(
|
|
|
438
492
|
if (mode === 'fastembed') {
|
|
439
493
|
const initialized = await createFastEmbedProvider();
|
|
440
494
|
if (!initialized) return null;
|
|
441
|
-
provider =
|
|
495
|
+
provider = markProviderReady(initialized);
|
|
442
496
|
warnOnce(`[memorix] Embedding provider: ${provider.name} (${provider.dimensions}d)`);
|
|
443
497
|
return provider;
|
|
444
498
|
}
|
|
@@ -447,7 +501,7 @@ export async function getEmbeddingProvider(
|
|
|
447
501
|
if (mode === 'transformers') {
|
|
448
502
|
const initialized = await createTransformersProvider();
|
|
449
503
|
if (!initialized) return null;
|
|
450
|
-
provider =
|
|
504
|
+
provider = markProviderReady(initialized);
|
|
451
505
|
warnOnce(`[memorix] Embedding provider: ${provider.name} (${provider.dimensions}d)`);
|
|
452
506
|
return provider;
|
|
453
507
|
}
|
|
@@ -456,7 +510,7 @@ export async function getEmbeddingProvider(
|
|
|
456
510
|
if (mode === 'api') {
|
|
457
511
|
const initialized = await createAPIProvider(options);
|
|
458
512
|
if (!initialized) return null;
|
|
459
|
-
provider =
|
|
513
|
+
provider = markProviderReady(initialized);
|
|
460
514
|
warnOnce(`[memorix] Embedding provider: ${provider.name} (${provider.dimensions}d)`);
|
|
461
515
|
return provider;
|
|
462
516
|
}
|
|
@@ -465,7 +519,7 @@ export async function getEmbeddingProvider(
|
|
|
465
519
|
if (hasAPIEmbeddingConfig()) {
|
|
466
520
|
const initialized = await createAPIProvider(options);
|
|
467
521
|
if (initialized) {
|
|
468
|
-
provider =
|
|
522
|
+
provider = markProviderReady(initialized);
|
|
469
523
|
warnOnce(`[memorix] Embedding provider: ${provider.name} (${provider.dimensions}d)`);
|
|
470
524
|
return provider;
|
|
471
525
|
}
|
|
@@ -473,7 +527,7 @@ export async function getEmbeddingProvider(
|
|
|
473
527
|
|
|
474
528
|
const localFallback = await createLocalFallbackProvider();
|
|
475
529
|
if (localFallback) {
|
|
476
|
-
provider =
|
|
530
|
+
provider = markProviderReady(localFallback);
|
|
477
531
|
warnOnce(`[memorix] Embedding provider: ${provider.name} (${provider.dimensions}d)`);
|
|
478
532
|
return provider;
|
|
479
533
|
}
|
|
@@ -507,6 +561,36 @@ export async function isVectorSearchAvailable(): Promise<boolean> {
|
|
|
507
561
|
return p !== null;
|
|
508
562
|
}
|
|
509
563
|
|
|
564
|
+
/**
|
|
565
|
+
* Process-local embedding health, intentionally read-only and network-free.
|
|
566
|
+
* It lets the control plane distinguish a configured provider from one that
|
|
567
|
+
* has degraded after a live credential, billing, timeout, or upstream error.
|
|
568
|
+
*/
|
|
569
|
+
export function getEmbeddingRuntimeHealth(): EmbeddingRuntimeHealth {
|
|
570
|
+
const mode = getEmbeddingMode();
|
|
571
|
+
if (mode === 'off') return { status: 'disabled' };
|
|
572
|
+
|
|
573
|
+
if (lastInitWasTemporaryFailure && lastEmbeddingFailure) {
|
|
574
|
+
return {
|
|
575
|
+
status: 'degraded',
|
|
576
|
+
failure: {
|
|
577
|
+
kind: lastEmbeddingFailure.kind,
|
|
578
|
+
occurredAt: new Date(lastEmbeddingFailure.occurredAt).toISOString(),
|
|
579
|
+
retryAfterMs: Math.max(0, RETRY_COOLDOWN_MS - (Date.now() - lastEmbeddingFailure.occurredAt)),
|
|
580
|
+
},
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
if (provider) {
|
|
585
|
+
return {
|
|
586
|
+
status: 'ready',
|
|
587
|
+
provider: { name: provider.name, dimensions: provider.dimensions },
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
return { status: 'uninitialized' };
|
|
592
|
+
}
|
|
593
|
+
|
|
510
594
|
/**
|
|
511
595
|
* Check if embedding is explicitly disabled by configuration (mode === 'off').
|
|
512
596
|
*
|
|
@@ -527,8 +611,11 @@ export function isEmbeddingExplicitlyDisabled(): boolean {
|
|
|
527
611
|
export function resetProvider(): void {
|
|
528
612
|
provider = null;
|
|
529
613
|
initPromise = null;
|
|
614
|
+
cachedEmbeddingMode = null;
|
|
615
|
+
cachedEmbeddingModeToken = null;
|
|
530
616
|
lastInitWasTemporaryFailure = false;
|
|
531
617
|
lastFailureTimestamp = 0;
|
|
618
|
+
lastEmbeddingFailure = null;
|
|
532
619
|
lastEmbeddingWarning = '';
|
|
533
620
|
lastEmbeddingWarningAt = 0;
|
|
534
621
|
}
|
package/src/hooks/handler.ts
CHANGED
|
@@ -21,6 +21,11 @@ import { normalizeHookInput } from './normalizer.js';
|
|
|
21
21
|
import { detectBestPattern, patternToObservationType } from './pattern-detector.js';
|
|
22
22
|
import { isSignificantKnowledge, isRetrievedResult, isTrivialCommand } from './significance-filter.js';
|
|
23
23
|
import type { AgentName, HookEvent, HookOutput, NormalizedHookInput } from './types.js';
|
|
24
|
+
import {
|
|
25
|
+
hookObservationRuntimeOptions,
|
|
26
|
+
readHookStdin,
|
|
27
|
+
resolveHookStdinTimeoutMs,
|
|
28
|
+
} from './lifecycle.js';
|
|
24
29
|
|
|
25
30
|
type HookInjectionMode = 'full' | 'minimal' | 'silent';
|
|
26
31
|
type HookContextTarget = 'hook-session-start' | 'hook-user-prompt';
|
|
@@ -762,23 +767,9 @@ export function formatHookOutput(
|
|
|
762
767
|
* Called by the CLI: `memorix hook`
|
|
763
768
|
*/
|
|
764
769
|
export async function runHook(agentOverride?: string, eventOverride?: string): Promise<void> {
|
|
765
|
-
//
|
|
766
|
-
//
|
|
767
|
-
const rawInput = await
|
|
768
|
-
const chunks: Buffer[] = [];
|
|
769
|
-
const finish = () => resolve(Buffer.concat(chunks).toString('utf-8').trim());
|
|
770
|
-
|
|
771
|
-
// Hard timeout: resolve with whatever we have after 3 s
|
|
772
|
-
const timer = setTimeout(() => {
|
|
773
|
-
process.stdin.removeAllListeners('data');
|
|
774
|
-
process.stdin.removeAllListeners('end');
|
|
775
|
-
finish();
|
|
776
|
-
}, 3_000);
|
|
777
|
-
|
|
778
|
-
process.stdin.on('data', (chunk: Buffer) => { chunks.push(chunk); });
|
|
779
|
-
process.stdin.on('end', () => { clearTimeout(timer); finish(); });
|
|
780
|
-
process.stdin.on('error', () => { clearTimeout(timer); finish(); });
|
|
781
|
-
});
|
|
770
|
+
// Some hosts (e.g. Gemini CLI) may not close stdin promptly. Bound the
|
|
771
|
+
// read and release the stream so an open pipe cannot keep Node alive.
|
|
772
|
+
const rawInput = await readHookStdin(process.stdin, resolveHookStdinTimeoutMs());
|
|
782
773
|
|
|
783
774
|
if (!rawInput) {
|
|
784
775
|
process.stdout.write(JSON.stringify({ continue: true }));
|
|
@@ -841,7 +832,7 @@ export async function runHook(agentOverride?: string, eventOverride?: string): P
|
|
|
841
832
|
await initObservationStore(dataDir);
|
|
842
833
|
await initMSStore(dataDir);
|
|
843
834
|
await initSessStore(dataDir);
|
|
844
|
-
await initObservations(dataDir);
|
|
835
|
+
await initObservations(dataDir, hookObservationRuntimeOptions(rawProject.rootPath));
|
|
845
836
|
await storeObservation({ ...observation, projectId, sourceDetail: 'hook' });
|
|
846
837
|
// Automatic capture is deliberately quiet. Candidate state and later
|
|
847
838
|
// qualification are visible through Memorix inspection, not injected as
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process-level lifecycle for `memorix hook`.
|
|
3
|
+
*
|
|
4
|
+
* Hosts invoke this command on every agent event. It must read stdin, emit
|
|
5
|
+
* one JSON object, and exit. Leftover hook processes are a bug: they reserve
|
|
6
|
+
* heap, orphan onto PID 1, and can exhaust a laptop.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ObservationRuntimeOptions } from '../memory/observations.js';
|
|
10
|
+
|
|
11
|
+
export const DEFAULT_HOOK_TIMEOUT_MS = 20_000;
|
|
12
|
+
export const MIN_HOOK_TIMEOUT_MS = 1_000;
|
|
13
|
+
export const MAX_HOOK_TIMEOUT_MS = 120_000;
|
|
14
|
+
|
|
15
|
+
export const DEFAULT_HOOK_STDIN_TIMEOUT_MS = 3_000;
|
|
16
|
+
export const MIN_HOOK_STDIN_TIMEOUT_MS = 100;
|
|
17
|
+
export const MAX_HOOK_STDIN_TIMEOUT_MS = 30_000;
|
|
18
|
+
|
|
19
|
+
type EnvMap = NodeJS.ProcessEnv | Record<string, string | undefined>;
|
|
20
|
+
|
|
21
|
+
function parseBoundedMs(
|
|
22
|
+
raw: string | undefined,
|
|
23
|
+
fallback: number,
|
|
24
|
+
min: number,
|
|
25
|
+
max: number,
|
|
26
|
+
): number {
|
|
27
|
+
const value = raw?.trim();
|
|
28
|
+
if (!value) return fallback;
|
|
29
|
+
const parsed = Number(value);
|
|
30
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
|
|
31
|
+
return Math.max(min, Math.min(max, Math.floor(parsed)));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function resolveHookTimeoutMs(env: EnvMap = process.env): number {
|
|
35
|
+
return parseBoundedMs(
|
|
36
|
+
env.MEMORIX_HOOK_TIMEOUT_MS,
|
|
37
|
+
DEFAULT_HOOK_TIMEOUT_MS,
|
|
38
|
+
MIN_HOOK_TIMEOUT_MS,
|
|
39
|
+
MAX_HOOK_TIMEOUT_MS,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function resolveHookStdinTimeoutMs(env: EnvMap = process.env): number {
|
|
44
|
+
return parseBoundedMs(
|
|
45
|
+
env.MEMORIX_HOOK_STDIN_TIMEOUT_MS,
|
|
46
|
+
DEFAULT_HOOK_STDIN_TIMEOUT_MS,
|
|
47
|
+
MIN_HOOK_STDIN_TIMEOUT_MS,
|
|
48
|
+
MAX_HOOK_STDIN_TIMEOUT_MS,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function hookObservationRuntimeOptions(projectRoot: string): ObservationRuntimeOptions {
|
|
53
|
+
return {
|
|
54
|
+
skipCorpusLoad: true,
|
|
55
|
+
embeddingWriteMode: 'deferred',
|
|
56
|
+
projectRoot,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
type HookStdin = NodeJS.ReadableStream & {
|
|
61
|
+
pause?: () => void;
|
|
62
|
+
destroy?: (error?: Error) => void;
|
|
63
|
+
unref?: () => void;
|
|
64
|
+
isTTY?: boolean;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Read hook JSON from a pipe. Resolves on EOF, stream error, or idle timeout.
|
|
69
|
+
* Always releases the stream so an open stdin cannot keep Node alive.
|
|
70
|
+
*/
|
|
71
|
+
export async function readHookStdin(stdin: HookStdin, timeoutMs: number): Promise<string> {
|
|
72
|
+
return new Promise<string>((resolve) => {
|
|
73
|
+
const chunks: Buffer[] = [];
|
|
74
|
+
let settled = false;
|
|
75
|
+
|
|
76
|
+
const finish = () => {
|
|
77
|
+
if (settled) return;
|
|
78
|
+
settled = true;
|
|
79
|
+
clearTimeout(timer);
|
|
80
|
+
stdin.removeListener('data', onData);
|
|
81
|
+
stdin.removeListener('end', finish);
|
|
82
|
+
stdin.removeListener('error', finish);
|
|
83
|
+
stdin.pause?.();
|
|
84
|
+
if (!stdin.isTTY) {
|
|
85
|
+
stdin.destroy?.();
|
|
86
|
+
} else {
|
|
87
|
+
stdin.unref?.();
|
|
88
|
+
}
|
|
89
|
+
resolve(Buffer.concat(chunks).toString('utf-8').trim());
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const onData = (chunk: Buffer | string) => {
|
|
93
|
+
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const timer = setTimeout(finish, timeoutMs);
|
|
97
|
+
stdin.on('data', onData);
|
|
98
|
+
stdin.on('end', finish);
|
|
99
|
+
stdin.on('error', finish);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface HookDeadlineIo {
|
|
104
|
+
write(chunk: string): unknown;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface HookDeadlineOptions {
|
|
108
|
+
timeoutMs: number;
|
|
109
|
+
run: () => Promise<void>;
|
|
110
|
+
stdout?: HookDeadlineIo;
|
|
111
|
+
stderr?: HookDeadlineIo;
|
|
112
|
+
exit?: (code: number) => void;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function writeContinue(stdout: HookDeadlineIo): void {
|
|
116
|
+
try {
|
|
117
|
+
stdout.write(JSON.stringify({ continue: true }));
|
|
118
|
+
} catch {
|
|
119
|
+
// The host already closed the pipe. Exiting is still required.
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Run a hook body under a hard deadline, then force-exit.
|
|
125
|
+
* `withTimeout` cannot abort in-flight fetches; process.exit is the backstop.
|
|
126
|
+
*/
|
|
127
|
+
export async function runHookWithDeadline(options: HookDeadlineOptions): Promise<void> {
|
|
128
|
+
const stdout = options.stdout ?? process.stdout;
|
|
129
|
+
const stderr = options.stderr ?? process.stderr;
|
|
130
|
+
const exit = options.exit ?? ((code: number) => { process.exit(code); });
|
|
131
|
+
|
|
132
|
+
await new Promise<void>((resolve) => {
|
|
133
|
+
let finished = false;
|
|
134
|
+
|
|
135
|
+
const settle = () => {
|
|
136
|
+
if (finished) return;
|
|
137
|
+
finished = true;
|
|
138
|
+
clearTimeout(timer);
|
|
139
|
+
resolve();
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const timer = setTimeout(() => {
|
|
143
|
+
if (finished) return;
|
|
144
|
+
try {
|
|
145
|
+
stderr.write(`[memorix] hook timed out after ${options.timeoutMs}ms\n`);
|
|
146
|
+
} catch {
|
|
147
|
+
// stderr may already be closed
|
|
148
|
+
}
|
|
149
|
+
writeContinue(stdout);
|
|
150
|
+
exit(0);
|
|
151
|
+
settle();
|
|
152
|
+
}, options.timeoutMs);
|
|
153
|
+
|
|
154
|
+
options.run().then(
|
|
155
|
+
() => {
|
|
156
|
+
if (finished) return;
|
|
157
|
+
exit(0);
|
|
158
|
+
settle();
|
|
159
|
+
},
|
|
160
|
+
(error: unknown) => {
|
|
161
|
+
if (finished) return;
|
|
162
|
+
try {
|
|
163
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
164
|
+
stderr.write(`[memorix] hook failed: ${message}\n`);
|
|
165
|
+
} catch {
|
|
166
|
+
// stderr may already be closed
|
|
167
|
+
}
|
|
168
|
+
writeContinue(stdout);
|
|
169
|
+
exit(0);
|
|
170
|
+
settle();
|
|
171
|
+
},
|
|
172
|
+
);
|
|
173
|
+
});
|
|
174
|
+
}
|
|
@@ -37,7 +37,7 @@ import {
|
|
|
37
37
|
searchObservations,
|
|
38
38
|
updateObservationMetadata,
|
|
39
39
|
} from '../store/orama-store.js';
|
|
40
|
-
import { getObservationStore, initObservationStore } from '../store/obs-store.js';
|
|
40
|
+
import { getObservationStore, initObservationStore, type ObservationStore } from '../store/obs-store.js';
|
|
41
41
|
import { countTextTokens } from '../compact/token-budget.js';
|
|
42
42
|
import { extractEntities, enrichConcepts } from './entity-extractor.js';
|
|
43
43
|
import { getEmbeddingProvider, isEmbeddingExplicitlyDisabled, validateEmbeddingInput } from '../embedding/provider.js';
|
|
@@ -57,10 +57,32 @@ export interface ObservationRuntimeOptions {
|
|
|
57
57
|
embeddingWriteMode?: ObservationEmbeddingWriteMode;
|
|
58
58
|
/** Git root used by the detached vector worker. */
|
|
59
59
|
projectRoot?: string;
|
|
60
|
+
/**
|
|
61
|
+
* Open SQLite and the ID counter without materializing the full corpus.
|
|
62
|
+
* Hook writes use disk atomic() + findByTopicKey; they must not loadAll()
|
|
63
|
+
* tens of thousands of rows on every PostToolUse.
|
|
64
|
+
*/
|
|
65
|
+
skipCorpusLoad?: boolean;
|
|
60
66
|
}
|
|
61
67
|
|
|
62
68
|
let embeddingWriteMode: ObservationEmbeddingWriteMode = 'background';
|
|
63
69
|
let embeddingWorkerProjectRoot: string | undefined;
|
|
70
|
+
let corpusLoaded = false;
|
|
71
|
+
let loadedObservationStore: ObservationStore | null = null;
|
|
72
|
+
|
|
73
|
+
/** @internal Reset in-process observation state between tests. */
|
|
74
|
+
export function resetObservationRuntime(): void {
|
|
75
|
+
observations = [];
|
|
76
|
+
nextId = 1;
|
|
77
|
+
projectDir = null;
|
|
78
|
+
searchIndexPrepared = false;
|
|
79
|
+
corpusLoaded = false;
|
|
80
|
+
loadedObservationStore = null;
|
|
81
|
+
embeddingWriteMode = 'background';
|
|
82
|
+
embeddingWorkerProjectRoot = undefined;
|
|
83
|
+
vectorMissingIds.clear();
|
|
84
|
+
vectorSchemaUpgradePromise = null;
|
|
85
|
+
}
|
|
64
86
|
|
|
65
87
|
// ── Vector-missing tracking ──────────────────────────────────────
|
|
66
88
|
// Tracks observation IDs whose async embedding write failed or was skipped.
|
|
@@ -233,14 +255,26 @@ export async function initObservations(
|
|
|
233
255
|
): Promise<void> {
|
|
234
256
|
embeddingWriteMode = options.embeddingWriteMode ?? 'background';
|
|
235
257
|
embeddingWorkerProjectRoot = options.projectRoot;
|
|
236
|
-
if (projectDir === dir) return;
|
|
237
258
|
await initObservationStore(dir);
|
|
238
259
|
const store = getObservationStore();
|
|
239
|
-
|
|
260
|
+
|
|
261
|
+
if (projectDir === dir && loadedObservationStore === store) {
|
|
262
|
+
if (options.skipCorpusLoad || corpusLoaded) return;
|
|
263
|
+
}
|
|
264
|
+
|
|
240
265
|
nextId = await store.loadIdCounter();
|
|
241
266
|
projectDir = dir;
|
|
242
267
|
searchIndexPrepared = false;
|
|
243
268
|
vectorSchemaUpgradePromise = null;
|
|
269
|
+
if (options.skipCorpusLoad) {
|
|
270
|
+
observations = [];
|
|
271
|
+
corpusLoaded = false;
|
|
272
|
+
loadedObservationStore = store;
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
observations = await store.loadAll();
|
|
276
|
+
corpusLoaded = true;
|
|
277
|
+
loadedObservationStore = store;
|
|
244
278
|
}
|
|
245
279
|
|
|
246
280
|
function scheduleObservationEmbedding(input: {
|
|
@@ -252,14 +286,18 @@ function scheduleObservationEmbedding(input: {
|
|
|
252
286
|
const { observationId, projectId, searchableText, doc } = input;
|
|
253
287
|
vectorMissingIds.add(observationId);
|
|
254
288
|
|
|
289
|
+
// Ordinary store must not pay embedding-provider or cache I/O when vectors
|
|
290
|
+
// are disabled. Check before generateEmbedding() so MEMORIX_EMBEDDING=off
|
|
291
|
+
// stays on the lexical write path.
|
|
292
|
+
if (isEmbeddingExplicitlyDisabled()) {
|
|
293
|
+
vectorMissingIds.delete(observationId);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
255
297
|
// A network fetch keeps Node's event loop alive even when its Promise is not
|
|
256
298
|
// awaited. One-shot CLI commands therefore persist first and let a detached
|
|
257
299
|
// worker consume the same durable queue.
|
|
258
300
|
if (embeddingWriteMode === 'deferred') {
|
|
259
|
-
if (isEmbeddingExplicitlyDisabled()) {
|
|
260
|
-
vectorMissingIds.delete(observationId);
|
|
261
|
-
return;
|
|
262
|
-
}
|
|
263
301
|
queueVectorBackfill(projectId, {
|
|
264
302
|
detachedWorker: true,
|
|
265
303
|
projectRoot: embeddingWorkerProjectRoot,
|
|
@@ -322,7 +360,7 @@ function scheduleObservationEmbedding(input: {
|
|
|
322
360
|
* For DegradedBackend this is a no-op (always returns false).
|
|
323
361
|
*/
|
|
324
362
|
export async function ensureFreshObservations(): Promise<boolean> {
|
|
325
|
-
if (!projectDir) return false;
|
|
363
|
+
if (!projectDir || !corpusLoaded) return false;
|
|
326
364
|
try {
|
|
327
365
|
const store = getObservationStore();
|
|
328
366
|
const wasStale = await store.ensureFresh();
|
|
@@ -424,12 +462,13 @@ export async function storeObservation(input: {
|
|
|
424
462
|
|
|
425
463
|
// Sync the local cache before using it as the topicKey fast path. This costs a
|
|
426
464
|
// generation read in the normal case and only reloads when another process wrote.
|
|
465
|
+
// Write-only / hook processes skip the corpus cache; disk findByTopicKey is authoritative.
|
|
427
466
|
await ensureFreshObservations();
|
|
428
467
|
|
|
429
468
|
// Topic key upsert: fast-path check in-memory (optimistic, may be stale).
|
|
430
469
|
// A second authoritative check happens inside the file lock to prevent TOCTOU races
|
|
431
470
|
// where two concurrent calls with the same topicKey both miss this check.
|
|
432
|
-
if (input.topicKey) {
|
|
471
|
+
if (input.topicKey && corpusLoaded) {
|
|
433
472
|
const existing = observations.find(
|
|
434
473
|
o => o.topicKey === input.topicKey && o.projectId === input.projectId,
|
|
435
474
|
);
|
|
@@ -546,14 +585,18 @@ export async function storeObservation(input: {
|
|
|
546
585
|
observation.writeGeneration = store.getGeneration();
|
|
547
586
|
|
|
548
587
|
if (upsertedInsideLock || reloadCacheAfterCommit) {
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
588
|
+
if (corpusLoaded) {
|
|
589
|
+
observations = await store.loadAll();
|
|
590
|
+
nextId = await store.loadIdCounter();
|
|
591
|
+
if (upsertedInsideLock) {
|
|
592
|
+
observation = observations.find((candidate) => candidate.id === observation.id)
|
|
593
|
+
?? observation;
|
|
594
|
+
}
|
|
595
|
+
} else {
|
|
596
|
+
nextId = await store.loadIdCounter();
|
|
554
597
|
}
|
|
555
598
|
} else {
|
|
556
|
-
observations.push(observation);
|
|
599
|
+
if (corpusLoaded) observations.push(observation);
|
|
557
600
|
nextId = observation.id + 1;
|
|
558
601
|
}
|
|
559
602
|
|
|
@@ -627,7 +670,12 @@ export async function storeObservation(input: {
|
|
|
627
670
|
sharedWithAgentIds: JSON.stringify(input.sharedWithAgentIds ?? []),
|
|
628
671
|
};
|
|
629
672
|
|
|
630
|
-
|
|
673
|
+
// Hook / write-only processes persist SQLite only. The control plane
|
|
674
|
+
// picks the row up via ensureFresh. Creating Orama here would load the
|
|
675
|
+
// embedding provider and the on-disk vector cache in a 10s hook budget.
|
|
676
|
+
if (corpusLoaded) {
|
|
677
|
+
await insertObservation(doc);
|
|
678
|
+
}
|
|
631
679
|
};
|
|
632
680
|
|
|
633
681
|
await assignAndPersist();
|
|
@@ -1163,10 +1211,12 @@ export async function reindexObservations(): Promise<number> {
|
|
|
1163
1211
|
* coupled to embedding provider throughput. Missing vectors are queued for the
|
|
1164
1212
|
* existing background backfill cycle.
|
|
1165
1213
|
*/
|
|
1166
|
-
export async function prepareSearchIndex(): Promise<number> {
|
|
1214
|
+
export async function prepareSearchIndex(options: { skipCachedVectors?: boolean } = {}): Promise<number> {
|
|
1167
1215
|
if (searchIndexPrepared) return 0;
|
|
1168
1216
|
|
|
1169
|
-
const count = await hydrateIndexForStartup(observations as unknown as any[]
|
|
1217
|
+
const count = await hydrateIndexForStartup(observations as unknown as any[], {
|
|
1218
|
+
skipCachedVectors: options.skipCachedVectors,
|
|
1219
|
+
});
|
|
1170
1220
|
if (count === 0) {
|
|
1171
1221
|
searchIndexPrepared = true;
|
|
1172
1222
|
return 0;
|
package/src/sdk.ts
CHANGED
|
@@ -303,6 +303,14 @@ export class MemoryClient {
|
|
|
303
303
|
} catch {
|
|
304
304
|
// best-effort cleanup
|
|
305
305
|
}
|
|
306
|
+
// ObservationStore.close() only detaches from the shared handle.
|
|
307
|
+
// Release this dataDir so Windows can unlink memorix.db after close().
|
|
308
|
+
try {
|
|
309
|
+
const { closeDatabase } = await import('./store/sqlite-db.js');
|
|
310
|
+
closeDatabase(this._dataDir);
|
|
311
|
+
} catch {
|
|
312
|
+
// best-effort cleanup
|
|
313
|
+
}
|
|
306
314
|
}
|
|
307
315
|
}
|
|
308
316
|
|
|
@@ -346,14 +354,10 @@ export async function createMemoryClient(options: MemoryClientOptions): Promise<
|
|
|
346
354
|
);
|
|
347
355
|
}
|
|
348
356
|
|
|
349
|
-
//
|
|
350
|
-
|
|
351
|
-
const
|
|
352
|
-
const dataDir =
|
|
353
|
-
|
|
354
|
-
// Ensure data directory exists
|
|
355
|
-
const fs = await import('node:fs');
|
|
356
|
-
fs.mkdirSync(dataDir, { recursive: true });
|
|
357
|
+
// SDK, CLI, hooks, and HTTP MCP must share the same flat data directory.
|
|
358
|
+
// Project identity is stored in each row, not encoded in the directory.
|
|
359
|
+
const { getProjectDataDir } = await import('./store/persistence.js');
|
|
360
|
+
const dataDir = await getProjectDataDir(project.id);
|
|
357
361
|
|
|
358
362
|
const client = new MemoryClient(project.id, projectRoot, dataDir);
|
|
359
363
|
await client._init(silent);
|
package/src/store/orama-store.ts
CHANGED
|
@@ -331,6 +331,8 @@ export interface HydrateIndexOptions {
|
|
|
331
331
|
allowNetworkProbe?: boolean;
|
|
332
332
|
/** Attach cached vectors after lexical hydration instead of awaiting cache I/O. */
|
|
333
333
|
deferCachedVectors?: boolean;
|
|
334
|
+
/** Skip cached-vector attach entirely (one-shot CLI reads). */
|
|
335
|
+
skipCachedVectors?: boolean;
|
|
334
336
|
}
|
|
335
337
|
|
|
336
338
|
type HydrationCandidate = { observation: any; id: string };
|
|
@@ -378,6 +380,9 @@ async function attachCachedVectors(
|
|
|
378
380
|
} catch {
|
|
379
381
|
// Vector cache hydration is best-effort. The normal backfill lane owns misses.
|
|
380
382
|
}
|
|
383
|
+
if (index % 50 === 0) {
|
|
384
|
+
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
385
|
+
}
|
|
381
386
|
}
|
|
382
387
|
}
|
|
383
388
|
|
|
@@ -437,11 +442,20 @@ export async function hydrateIndex(
|
|
|
437
442
|
await insert(database, doc);
|
|
438
443
|
rememberObservationDoc(doc);
|
|
439
444
|
inserted++;
|
|
445
|
+
// Keep the HTTP event loop alive on large corpora so /health still answers.
|
|
446
|
+
if (inserted % 50 === 0) {
|
|
447
|
+
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
448
|
+
}
|
|
449
|
+
if (inserted % 200 === 0) {
|
|
450
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
|
451
|
+
}
|
|
440
452
|
} catch { /* skip malformed entries */ }
|
|
441
453
|
}
|
|
442
454
|
|
|
443
|
-
deferredCachedVectorHydration =
|
|
444
|
-
?
|
|
455
|
+
deferredCachedVectorHydration = options.skipCachedVectors
|
|
456
|
+
? null
|
|
457
|
+
: deferCachedVectors
|
|
458
|
+
? attachCachedVectors(database, candidates).catch(() => {})
|
|
445
459
|
: null;
|
|
446
460
|
return inserted;
|
|
447
461
|
}
|
|
@@ -450,10 +464,14 @@ export async function hydrateIndex(
|
|
|
450
464
|
* Prepare the lexical index without probing a remote embedding API. Cached
|
|
451
465
|
* vectors attach after the disk cache is ready, outside the startup path.
|
|
452
466
|
*/
|
|
453
|
-
export async function hydrateIndexForStartup(
|
|
467
|
+
export async function hydrateIndexForStartup(
|
|
468
|
+
observations: any[],
|
|
469
|
+
options: Pick<HydrateIndexOptions, 'skipCachedVectors'> = {},
|
|
470
|
+
): Promise<number> {
|
|
454
471
|
return hydrateIndex(observations, {
|
|
455
472
|
allowNetworkProbe: false,
|
|
456
473
|
deferCachedVectors: true,
|
|
474
|
+
skipCachedVectors: options.skipCachedVectors,
|
|
457
475
|
});
|
|
458
476
|
}
|
|
459
477
|
|