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
@@ -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
- return cfgMode();
234
+ cachedEmbeddingMode = cfgMode();
235
+ cachedEmbeddingModeToken = token;
236
+ return cachedEmbeddingMode;
213
237
  } catch {
214
238
  // Fallback if config module not available
215
- const env = process.env.MEMORIX_EMBEDDING?.toLowerCase()?.trim();
216
- 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;
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 temporarily unavailable at runtime: ${message}`);
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 = wrapProvider(initialized);
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 = wrapProvider(initialized);
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 = wrapProvider(initialized);
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 = wrapProvider(initialized);
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 = wrapProvider(localFallback);
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
  }
@@ -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
- // Read stdin with a timeout — some hosts (e.g. Gemini CLI) may not close
766
- // stdin promptly, causing `for await` to hang until the process is killed.
767
- const rawInput = await new Promise<string>((resolve) => {
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
- observations = await store.loadAll();
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
- observations = await store.loadAll();
550
- nextId = await store.loadIdCounter();
551
- if (upsertedInsideLock) {
552
- observation = observations.find((candidate) => candidate.id === observation.id)
553
- ?? observation;
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
- await insertObservation(doc);
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
- // Resolve data directory
350
- const path = await import('node:path');
351
- const os = await import('node:os');
352
- const dataDir = path.join(os.homedir(), '.memorix', 'data', project.id.replace(/\//g, path.sep));
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);
@@ -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 = deferCachedVectors
444
- ? attachCachedVectors(database, candidates).catch(() => {})
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(observations: any[]): Promise<number> {
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