memorix 1.6.0 → 1.6.1

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 (119) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/TEAM.md +86 -86
  3. package/dist/cli/index.js +317 -62
  4. package/dist/cli/memcode.js +12 -1
  5. package/dist/index.js +56 -9
  6. package/dist/maintenance-runner.js +54 -7
  7. package/dist/media-audio-runner.js +54 -7
  8. package/dist/media-video-runner.js +54 -7
  9. package/dist/memcode-runtime/CHANGELOG.md +24 -0
  10. package/dist/sdk.js +55 -8
  11. package/dist/vector-backfill-runner.js +54 -7
  12. package/docs/AGENT_OPERATOR_PLAYBOOK.md +11 -3
  13. package/docs/API_REFERENCE.md +1 -1
  14. package/docs/DESIGN_DECISIONS.md +357 -357
  15. package/docs/DOCKER.md +5 -1
  16. package/docs/PERFORMANCE.md +2 -2
  17. package/docs/README.md +3 -1
  18. package/docs/SETUP.md +8 -2
  19. package/package.json +1 -1
  20. package/plugins/claude/memorix/.claude-plugin/plugin.json +1 -1
  21. package/plugins/codebuddy/memorix/.codebuddy-plugin/plugin.json +1 -1
  22. package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
  23. package/plugins/copilot/memorix/plugin.json +1 -1
  24. package/plugins/gemini/memorix/gemini-extension.json +1 -1
  25. package/plugins/omp/memorix/package.json +1 -1
  26. package/plugins/openclaw/memorix/.codex-plugin/plugin.json +1 -1
  27. package/plugins/pi/memorix/package.json +1 -1
  28. package/server.json +2 -2
  29. package/src/audit/index.ts +156 -156
  30. package/src/cli/commands/agent-integrations.ts +9 -3
  31. package/src/cli/commands/doctor.ts +46 -15
  32. package/src/cli/commands/formation.ts +48 -48
  33. package/src/cli/commands/git-hook-install.ts +111 -111
  34. package/src/cli/commands/handoff.ts +54 -54
  35. package/src/cli/commands/hook.ts +13 -1
  36. package/src/cli/commands/hooks-status.ts +63 -63
  37. package/src/cli/commands/ingest.ts +44 -44
  38. package/src/cli/commands/integrate-shared.ts +15 -15
  39. package/src/cli/commands/lock.ts +82 -82
  40. package/src/cli/commands/message.ts +104 -104
  41. package/src/cli/commands/poll.ts +58 -58
  42. package/src/cli/commands/reasoning.ts +118 -118
  43. package/src/cli/commands/serve-http.ts +68 -7
  44. package/src/cli/commands/skills.ts +114 -114
  45. package/src/cli/commands/task.ts +167 -167
  46. package/src/cli/commands/transfer.ts +47 -47
  47. package/src/cli/heap.ts +76 -0
  48. package/src/cli/index.ts +406 -406
  49. package/src/cli/invocation.ts +7 -4
  50. package/src/cli/tui/ChatView.tsx +234 -234
  51. package/src/cli/tui/CommandBar.tsx +312 -312
  52. package/src/cli/tui/ContextRail.tsx +118 -118
  53. package/src/cli/tui/HeaderBar.tsx +72 -72
  54. package/src/cli/tui/LogoBanner.tsx +51 -51
  55. package/src/cli/tui/index.ts +41 -41
  56. package/src/cli/version.ts +7 -7
  57. package/src/cli/workbench.ts +1 -1
  58. package/src/dashboard/project-classification.ts +64 -64
  59. package/src/embedding/provider.ts +76 -6
  60. package/src/git/hooks-path.ts +85 -85
  61. package/src/hooks/handler.ts +9 -18
  62. package/src/hooks/lifecycle.ts +173 -0
  63. package/src/hooks/pattern-detector.ts +173 -173
  64. package/src/hooks/significance-filter.ts +250 -250
  65. package/src/llm/memory-manager.ts +328 -328
  66. package/src/llm/quality.ts +248 -248
  67. package/src/memory/attribution-guard.ts +249 -249
  68. package/src/memory/disclosure-policy.ts +135 -135
  69. package/src/memory/entity-extractor.ts +197 -197
  70. package/src/memory/formation/evaluate.ts +217 -217
  71. package/src/memory/freshness.ts +122 -122
  72. package/src/memory/graph.ts +197 -197
  73. package/src/memory/refs.ts +94 -94
  74. package/src/memory/secret-filter.ts +79 -79
  75. package/src/orchestrate/adapters/claude-stream.ts +192 -192
  76. package/src/orchestrate/adapters/claude.ts +111 -111
  77. package/src/orchestrate/adapters/codex-stream.ts +134 -134
  78. package/src/orchestrate/adapters/codex.ts +41 -41
  79. package/src/orchestrate/adapters/gemini-stream.ts +166 -166
  80. package/src/orchestrate/adapters/gemini.ts +42 -42
  81. package/src/orchestrate/adapters/index.ts +73 -73
  82. package/src/orchestrate/adapters/opencode-stream.ts +143 -143
  83. package/src/orchestrate/adapters/opencode.ts +47 -47
  84. package/src/orchestrate/adapters/types.ts +77 -77
  85. package/src/orchestrate/capability-router.ts +284 -284
  86. package/src/orchestrate/cost-tracker.ts +219 -219
  87. package/src/orchestrate/error-recovery.ts +191 -191
  88. package/src/orchestrate/evidence.ts +140 -140
  89. package/src/orchestrate/ledger.ts +110 -110
  90. package/src/orchestrate/memorix-bridge.ts +343 -343
  91. package/src/orchestrate/output-budget.ts +80 -80
  92. package/src/orchestrate/permission.ts +152 -152
  93. package/src/orchestrate/pipeline-trace.ts +131 -131
  94. package/src/orchestrate/prompt-builder.ts +155 -155
  95. package/src/orchestrate/ring-buffer.ts +37 -37
  96. package/src/orchestrate/task-graph.ts +389 -389
  97. package/src/project/aliases.ts +374 -374
  98. package/src/rules/adapters/trae.ts +56 -56
  99. package/src/rules/syncer.ts +235 -235
  100. package/src/search/query-expansion.ts +52 -52
  101. package/src/server/formation-timeout.ts +27 -27
  102. package/src/skills/mini-skills.ts +386 -386
  103. package/src/store/chat-store.ts +119 -119
  104. package/src/store/graph-store.ts +249 -249
  105. package/src/store/mini-skill-store.ts +349 -349
  106. package/src/store/persistence-json.ts +212 -212
  107. package/src/store/persistence.ts +291 -291
  108. package/src/store/project-affinity.ts +195 -195
  109. package/src/team/event-bus.ts +76 -76
  110. package/src/team/handoff.ts +161 -161
  111. package/src/team/poll.ts +132 -132
  112. package/src/workspace/mcp-adapters/codex.ts +191 -191
  113. package/src/workspace/mcp-adapters/copilot.ts +105 -105
  114. package/src/workspace/mcp-adapters/cursor.ts +53 -53
  115. package/src/workspace/mcp-adapters/kiro.ts +64 -64
  116. package/src/workspace/mcp-adapters/opencode.ts +123 -123
  117. package/src/workspace/mcp-adapters/trae.ts +134 -134
  118. package/src/workspace/mcp-adapters/windsurf.ts +91 -91
  119. package/src/workspace/sanitizer.ts +60 -60
@@ -1,64 +1,64 @@
1
- /**
2
- * Project classification helpers — shared by /api/projects and /api/identity.
3
- *
4
- * Three kinds:
5
- * - 'real': genuine user projects (e.g. AVIDS2/memorix, github.com/org/repo)
6
- * - 'temporary': test/demo/smoke/e2e scratch projects (local/task-*, local/smoke-*, etc.)
7
- * - 'placeholder': unresolved / obviously broken IDs (placeholder/*, __unresolved__, System32)
8
- *
9
- * A 'dirty' project is one with a clearly broken canonical ID (System32 etc.).
10
- * 'dirty' and 'temporary' are orthogonal axes:
11
- * - local/task-abc → temporary, NOT dirty
12
- * - placeholder/xxx → placeholder, dirty
13
- * - System32\something → real-looking location but dirty (broken ID)
14
- */
15
-
16
- export type ProjectKind = 'real' | 'temporary' | 'placeholder';
17
-
18
- /** Regex list — anything matching is temporary (scratch projects) */
19
- const TEMPORARY_PATTERNS: RegExp[] = [
20
- /^local\/task-/i,
21
- /^local\/smoke-/i,
22
- /^local\/release-smoke-/i,
23
- /^local\/memorix-e2e-/i,
24
- /^local\/orchestrate-/i,
25
- /^local\/scratch-/i,
26
- /^local\/tmp-/i,
27
- ];
28
-
29
- /** Regex list — anything matching is placeholder/unresolved */
30
- const PLACEHOLDER_PATTERNS: RegExp[] = [
31
- /^__unresolved__$/,
32
- /^placeholder\//i,
33
- ];
34
-
35
- /** Regex list — IDs that indicate a broken canonical ID (dirty). */
36
- const DIRTY_PATTERNS: RegExp[] = [
37
- /^placeholder\//i,
38
- /System32/i,
39
- /Microsoft VS Code/i,
40
- /node_modules/i,
41
- /\.vscode/i,
42
- /^local\/[A-Z]:\\/i,
43
- ];
44
-
45
- export function classifyProjectId(id: string): ProjectKind {
46
- if (!id) return 'placeholder';
47
- if (PLACEHOLDER_PATTERNS.some(p => p.test(id))) return 'placeholder';
48
- if (TEMPORARY_PATTERNS.some(p => p.test(id))) return 'temporary';
49
- return 'real';
50
- }
51
-
52
- export function isDirtyProjectId(id: string): boolean {
53
- if (!id) return false;
54
- return DIRTY_PATTERNS.some(p => p.test(id));
55
- }
56
-
57
- /** Friendly label for UI badges */
58
- export function projectKindLabel(kind: ProjectKind): string {
59
- switch (kind) {
60
- case 'real': return 'real';
61
- case 'temporary': return 'temporary';
62
- case 'placeholder': return 'placeholder';
63
- }
64
- }
1
+ /**
2
+ * Project classification helpers — shared by /api/projects and /api/identity.
3
+ *
4
+ * Three kinds:
5
+ * - 'real': genuine user projects (e.g. AVIDS2/memorix, github.com/org/repo)
6
+ * - 'temporary': test/demo/smoke/e2e scratch projects (local/task-*, local/smoke-*, etc.)
7
+ * - 'placeholder': unresolved / obviously broken IDs (placeholder/*, __unresolved__, System32)
8
+ *
9
+ * A 'dirty' project is one with a clearly broken canonical ID (System32 etc.).
10
+ * 'dirty' and 'temporary' are orthogonal axes:
11
+ * - local/task-abc → temporary, NOT dirty
12
+ * - placeholder/xxx → placeholder, dirty
13
+ * - System32\something → real-looking location but dirty (broken ID)
14
+ */
15
+
16
+ export type ProjectKind = 'real' | 'temporary' | 'placeholder';
17
+
18
+ /** Regex list — anything matching is temporary (scratch projects) */
19
+ const TEMPORARY_PATTERNS: RegExp[] = [
20
+ /^local\/task-/i,
21
+ /^local\/smoke-/i,
22
+ /^local\/release-smoke-/i,
23
+ /^local\/memorix-e2e-/i,
24
+ /^local\/orchestrate-/i,
25
+ /^local\/scratch-/i,
26
+ /^local\/tmp-/i,
27
+ ];
28
+
29
+ /** Regex list — anything matching is placeholder/unresolved */
30
+ const PLACEHOLDER_PATTERNS: RegExp[] = [
31
+ /^__unresolved__$/,
32
+ /^placeholder\//i,
33
+ ];
34
+
35
+ /** Regex list — IDs that indicate a broken canonical ID (dirty). */
36
+ const DIRTY_PATTERNS: RegExp[] = [
37
+ /^placeholder\//i,
38
+ /System32/i,
39
+ /Microsoft VS Code/i,
40
+ /node_modules/i,
41
+ /\.vscode/i,
42
+ /^local\/[A-Z]:\\/i,
43
+ ];
44
+
45
+ export function classifyProjectId(id: string): ProjectKind {
46
+ if (!id) return 'placeholder';
47
+ if (PLACEHOLDER_PATTERNS.some(p => p.test(id))) return 'placeholder';
48
+ if (TEMPORARY_PATTERNS.some(p => p.test(id))) return 'temporary';
49
+ return 'real';
50
+ }
51
+
52
+ export function isDirtyProjectId(id: string): boolean {
53
+ if (!id) return false;
54
+ return DIRTY_PATTERNS.some(p => p.test(id));
55
+ }
56
+
57
+ /** Friendly label for UI badges */
58
+ export function projectKindLabel(kind: ProjectKind): string {
59
+ switch (kind) {
60
+ case 'real': return 'real';
61
+ case 'temporary': return 'temporary';
62
+ case 'placeholder': return 'placeholder';
63
+ }
64
+ }
@@ -175,12 +175,26 @@ let initPromise: Promise<EmbeddingProvider | null> | null = null;
175
175
  type EmbeddingMode = 'off' | 'fastembed' | 'transformers' | 'api' | 'auto';
176
176
  type ProviderKind = 'api' | 'fastembed' | 'transformers' | 'unknown';
177
177
 
178
+ export type EmbeddingRuntimeStatus = 'disabled' | 'uninitialized' | 'ready' | 'degraded';
179
+ export type EmbeddingFailureKind = 'authorization' | 'billing' | 'timeout' | 'upstream';
180
+
181
+ export interface EmbeddingRuntimeHealth {
182
+ status: EmbeddingRuntimeStatus;
183
+ provider?: { name: string; dimensions: number };
184
+ failure?: {
185
+ kind: EmbeddingFailureKind;
186
+ occurredAt: string;
187
+ retryAfterMs: number;
188
+ };
189
+ }
190
+
178
191
  /**
179
192
  * Tracks whether the last init attempt resulted in a temporary failure
180
193
  * (mode != 'off' but provider returned null). When true, the next
181
194
  * getEmbeddingProvider() call will retry instead of returning cached null.
182
195
  */
183
196
  let lastInitWasTemporaryFailure = false;
197
+ let lastEmbeddingFailure: { kind: EmbeddingFailureKind; occurredAt: number } | null = null;
184
198
  let lastEmbeddingWarning = '';
185
199
  let lastEmbeddingWarningAt = 0;
186
200
  const WARNING_COOLDOWN_MS = 30_000;
@@ -271,13 +285,38 @@ function isTemporaryEmbeddingFailure(error: unknown): boolean {
271
285
  );
272
286
  }
273
287
 
288
+ function classifyEmbeddingFailure(error: unknown): EmbeddingFailureKind {
289
+ const message = error instanceof Error ? error.message : String(error);
290
+ if (/embedding api error \(401\)|invalid_api_key|incorrect api key|unauthorized/i.test(message)) {
291
+ return 'authorization';
292
+ }
293
+ if (/embedding api error \(402\)|quota exceeded|account balance/i.test(message)) {
294
+ return 'billing';
295
+ }
296
+ if (/embedding api timeout|abort/i.test(message)) {
297
+ return 'timeout';
298
+ }
299
+ return 'upstream';
300
+ }
301
+
274
302
  function markTemporaryFailure(reason: unknown): void {
275
303
  provider = null;
276
304
  initPromise = null;
277
305
  lastInitWasTemporaryFailure = true;
278
306
  lastFailureTimestamp = Date.now();
307
+ lastEmbeddingFailure = {
308
+ kind: classifyEmbeddingFailure(reason),
309
+ occurredAt: lastFailureTimestamp,
310
+ };
279
311
  const message = reason instanceof Error ? reason.message : String(reason);
280
- warnOnce(`[memorix] Embedding provider temporarily unavailable at runtime: ${message}`);
312
+ warnOnce(`[memorix] Embedding provider unavailable at runtime: ${message}`);
313
+ }
314
+
315
+ function markProviderReady(candidate: EmbeddingProvider): EmbeddingProvider {
316
+ provider = wrapProvider(candidate);
317
+ lastInitWasTemporaryFailure = false;
318
+ lastEmbeddingFailure = null;
319
+ return provider;
281
320
  }
282
321
 
283
322
  async function createFastEmbedProvider(): Promise<EmbeddingProvider | null> {
@@ -438,7 +477,7 @@ export async function getEmbeddingProvider(
438
477
  if (mode === 'fastembed') {
439
478
  const initialized = await createFastEmbedProvider();
440
479
  if (!initialized) return null;
441
- provider = wrapProvider(initialized);
480
+ provider = markProviderReady(initialized);
442
481
  warnOnce(`[memorix] Embedding provider: ${provider.name} (${provider.dimensions}d)`);
443
482
  return provider;
444
483
  }
@@ -447,7 +486,7 @@ export async function getEmbeddingProvider(
447
486
  if (mode === 'transformers') {
448
487
  const initialized = await createTransformersProvider();
449
488
  if (!initialized) return null;
450
- provider = wrapProvider(initialized);
489
+ provider = markProviderReady(initialized);
451
490
  warnOnce(`[memorix] Embedding provider: ${provider.name} (${provider.dimensions}d)`);
452
491
  return provider;
453
492
  }
@@ -456,7 +495,7 @@ export async function getEmbeddingProvider(
456
495
  if (mode === 'api') {
457
496
  const initialized = await createAPIProvider(options);
458
497
  if (!initialized) return null;
459
- provider = wrapProvider(initialized);
498
+ provider = markProviderReady(initialized);
460
499
  warnOnce(`[memorix] Embedding provider: ${provider.name} (${provider.dimensions}d)`);
461
500
  return provider;
462
501
  }
@@ -465,7 +504,7 @@ export async function getEmbeddingProvider(
465
504
  if (hasAPIEmbeddingConfig()) {
466
505
  const initialized = await createAPIProvider(options);
467
506
  if (initialized) {
468
- provider = wrapProvider(initialized);
507
+ provider = markProviderReady(initialized);
469
508
  warnOnce(`[memorix] Embedding provider: ${provider.name} (${provider.dimensions}d)`);
470
509
  return provider;
471
510
  }
@@ -473,7 +512,7 @@ export async function getEmbeddingProvider(
473
512
 
474
513
  const localFallback = await createLocalFallbackProvider();
475
514
  if (localFallback) {
476
- provider = wrapProvider(localFallback);
515
+ provider = markProviderReady(localFallback);
477
516
  warnOnce(`[memorix] Embedding provider: ${provider.name} (${provider.dimensions}d)`);
478
517
  return provider;
479
518
  }
@@ -507,6 +546,36 @@ export async function isVectorSearchAvailable(): Promise<boolean> {
507
546
  return p !== null;
508
547
  }
509
548
 
549
+ /**
550
+ * Process-local embedding health, intentionally read-only and network-free.
551
+ * It lets the control plane distinguish a configured provider from one that
552
+ * has degraded after a live credential, billing, timeout, or upstream error.
553
+ */
554
+ export function getEmbeddingRuntimeHealth(): EmbeddingRuntimeHealth {
555
+ const mode = getEmbeddingMode();
556
+ if (mode === 'off') return { status: 'disabled' };
557
+
558
+ if (lastInitWasTemporaryFailure && lastEmbeddingFailure) {
559
+ return {
560
+ status: 'degraded',
561
+ failure: {
562
+ kind: lastEmbeddingFailure.kind,
563
+ occurredAt: new Date(lastEmbeddingFailure.occurredAt).toISOString(),
564
+ retryAfterMs: Math.max(0, RETRY_COOLDOWN_MS - (Date.now() - lastEmbeddingFailure.occurredAt)),
565
+ },
566
+ };
567
+ }
568
+
569
+ if (provider) {
570
+ return {
571
+ status: 'ready',
572
+ provider: { name: provider.name, dimensions: provider.dimensions },
573
+ };
574
+ }
575
+
576
+ return { status: 'uninitialized' };
577
+ }
578
+
510
579
  /**
511
580
  * Check if embedding is explicitly disabled by configuration (mode === 'off').
512
581
  *
@@ -529,6 +598,7 @@ export function resetProvider(): void {
529
598
  initPromise = null;
530
599
  lastInitWasTemporaryFailure = false;
531
600
  lastFailureTimestamp = 0;
601
+ lastEmbeddingFailure = null;
532
602
  lastEmbeddingWarning = '';
533
603
  lastEmbeddingWarningAt = 0;
534
604
  }
@@ -1,85 +1,85 @@
1
- /**
2
- * Git hooks path resolver — worktree-safe.
3
- *
4
- * In a normal repo: .git is a directory → hooks at .git/hooks/
5
- * In a git worktree: .git is a FILE containing "gitdir: /path/to/actual/git/dir"
6
- * → hooks at that resolved path + /hooks/
7
- *
8
- * This utility ensures all hook install/uninstall/check operations
9
- * work correctly in both scenarios.
10
- */
11
-
12
- import { existsSync, readFileSync, statSync, mkdirSync } from 'node:fs';
13
- import path from 'node:path';
14
-
15
- /**
16
- * Resolve the actual .git directory path, following worktree indirection.
17
- *
18
- * @param projectRoot - The root of the working tree (where .git lives)
19
- * @returns Absolute path to the real git dir, or null if no .git found
20
- */
21
- export function resolveGitDir(projectRoot: string): string | null {
22
- const dotGit = path.join(projectRoot, '.git');
23
-
24
- if (!existsSync(dotGit)) return null;
25
-
26
- const stat = statSync(dotGit);
27
-
28
- if (stat.isDirectory()) {
29
- // Normal repo — .git is a directory
30
- return dotGit;
31
- }
32
-
33
- if (stat.isFile()) {
34
- // Worktree — .git is a file: "gitdir: /path/to/actual/git/dir"
35
- try {
36
- const content = readFileSync(dotGit, 'utf-8').trim();
37
- const match = content.match(/^gitdir:\s*(.+)$/);
38
- if (match) {
39
- const gitdir = match[1].trim();
40
- // Resolve relative paths against the project root
41
- const resolved = path.isAbsolute(gitdir)
42
- ? gitdir
43
- : path.resolve(projectRoot, gitdir);
44
- if (existsSync(resolved)) {
45
- return resolved;
46
- }
47
- }
48
- } catch {
49
- // Unreadable .git file — fall through
50
- }
51
- }
52
-
53
- return null;
54
- }
55
-
56
- /**
57
- * Resolve the hooks directory for a project (read-only — does NOT create directories).
58
- * Handles both normal repos and git worktrees.
59
- *
60
- * @param projectRoot - The root of the working tree
61
- * @returns Object with hooksDir and hookPath for post-commit, or null if no .git found
62
- */
63
- export function resolveHooksDir(projectRoot: string): { hooksDir: string; hookPath: string } | null {
64
- const gitDir = resolveGitDir(projectRoot);
65
- if (!gitDir) return null;
66
-
67
- const hooksDir = path.join(gitDir, 'hooks');
68
-
69
- return {
70
- hooksDir,
71
- hookPath: path.join(hooksDir, 'post-commit'),
72
- };
73
- }
74
-
75
- /**
76
- * Resolve hooks directory AND ensure it exists on disk.
77
- * Use this only in write paths (hook install / autoHook).
78
- */
79
- export function ensureHooksDir(projectRoot: string): { hooksDir: string; hookPath: string } | null {
80
- const resolved = resolveHooksDir(projectRoot);
81
- if (!resolved) return null;
82
-
83
- mkdirSync(resolved.hooksDir, { recursive: true });
84
- return resolved;
85
- }
1
+ /**
2
+ * Git hooks path resolver — worktree-safe.
3
+ *
4
+ * In a normal repo: .git is a directory → hooks at .git/hooks/
5
+ * In a git worktree: .git is a FILE containing "gitdir: /path/to/actual/git/dir"
6
+ * → hooks at that resolved path + /hooks/
7
+ *
8
+ * This utility ensures all hook install/uninstall/check operations
9
+ * work correctly in both scenarios.
10
+ */
11
+
12
+ import { existsSync, readFileSync, statSync, mkdirSync } from 'node:fs';
13
+ import path from 'node:path';
14
+
15
+ /**
16
+ * Resolve the actual .git directory path, following worktree indirection.
17
+ *
18
+ * @param projectRoot - The root of the working tree (where .git lives)
19
+ * @returns Absolute path to the real git dir, or null if no .git found
20
+ */
21
+ export function resolveGitDir(projectRoot: string): string | null {
22
+ const dotGit = path.join(projectRoot, '.git');
23
+
24
+ if (!existsSync(dotGit)) return null;
25
+
26
+ const stat = statSync(dotGit);
27
+
28
+ if (stat.isDirectory()) {
29
+ // Normal repo — .git is a directory
30
+ return dotGit;
31
+ }
32
+
33
+ if (stat.isFile()) {
34
+ // Worktree — .git is a file: "gitdir: /path/to/actual/git/dir"
35
+ try {
36
+ const content = readFileSync(dotGit, 'utf-8').trim();
37
+ const match = content.match(/^gitdir:\s*(.+)$/);
38
+ if (match) {
39
+ const gitdir = match[1].trim();
40
+ // Resolve relative paths against the project root
41
+ const resolved = path.isAbsolute(gitdir)
42
+ ? gitdir
43
+ : path.resolve(projectRoot, gitdir);
44
+ if (existsSync(resolved)) {
45
+ return resolved;
46
+ }
47
+ }
48
+ } catch {
49
+ // Unreadable .git file — fall through
50
+ }
51
+ }
52
+
53
+ return null;
54
+ }
55
+
56
+ /**
57
+ * Resolve the hooks directory for a project (read-only — does NOT create directories).
58
+ * Handles both normal repos and git worktrees.
59
+ *
60
+ * @param projectRoot - The root of the working tree
61
+ * @returns Object with hooksDir and hookPath for post-commit, or null if no .git found
62
+ */
63
+ export function resolveHooksDir(projectRoot: string): { hooksDir: string; hookPath: string } | null {
64
+ const gitDir = resolveGitDir(projectRoot);
65
+ if (!gitDir) return null;
66
+
67
+ const hooksDir = path.join(gitDir, 'hooks');
68
+
69
+ return {
70
+ hooksDir,
71
+ hookPath: path.join(hooksDir, 'post-commit'),
72
+ };
73
+ }
74
+
75
+ /**
76
+ * Resolve hooks directory AND ensure it exists on disk.
77
+ * Use this only in write paths (hook install / autoHook).
78
+ */
79
+ export function ensureHooksDir(projectRoot: string): { hooksDir: string; hookPath: string } | null {
80
+ const resolved = resolveHooksDir(projectRoot);
81
+ if (!resolved) return null;
82
+
83
+ mkdirSync(resolved.hooksDir, { recursive: true });
84
+ return resolved;
85
+ }
@@ -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,173 @@
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
+ embeddingWriteMode: 'deferred',
55
+ projectRoot,
56
+ };
57
+ }
58
+
59
+ type HookStdin = NodeJS.ReadableStream & {
60
+ pause?: () => void;
61
+ destroy?: (error?: Error) => void;
62
+ unref?: () => void;
63
+ isTTY?: boolean;
64
+ };
65
+
66
+ /**
67
+ * Read hook JSON from a pipe. Resolves on EOF, stream error, or idle timeout.
68
+ * Always releases the stream so an open stdin cannot keep Node alive.
69
+ */
70
+ export async function readHookStdin(stdin: HookStdin, timeoutMs: number): Promise<string> {
71
+ return new Promise<string>((resolve) => {
72
+ const chunks: Buffer[] = [];
73
+ let settled = false;
74
+
75
+ const finish = () => {
76
+ if (settled) return;
77
+ settled = true;
78
+ clearTimeout(timer);
79
+ stdin.removeListener('data', onData);
80
+ stdin.removeListener('end', finish);
81
+ stdin.removeListener('error', finish);
82
+ stdin.pause?.();
83
+ if (!stdin.isTTY) {
84
+ stdin.destroy?.();
85
+ } else {
86
+ stdin.unref?.();
87
+ }
88
+ resolve(Buffer.concat(chunks).toString('utf-8').trim());
89
+ };
90
+
91
+ const onData = (chunk: Buffer | string) => {
92
+ chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
93
+ };
94
+
95
+ const timer = setTimeout(finish, timeoutMs);
96
+ stdin.on('data', onData);
97
+ stdin.on('end', finish);
98
+ stdin.on('error', finish);
99
+ });
100
+ }
101
+
102
+ export interface HookDeadlineIo {
103
+ write(chunk: string): unknown;
104
+ }
105
+
106
+ export interface HookDeadlineOptions {
107
+ timeoutMs: number;
108
+ run: () => Promise<void>;
109
+ stdout?: HookDeadlineIo;
110
+ stderr?: HookDeadlineIo;
111
+ exit?: (code: number) => void;
112
+ }
113
+
114
+ function writeContinue(stdout: HookDeadlineIo): void {
115
+ try {
116
+ stdout.write(JSON.stringify({ continue: true }));
117
+ } catch {
118
+ // The host already closed the pipe. Exiting is still required.
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Run a hook body under a hard deadline, then force-exit.
124
+ * `withTimeout` cannot abort in-flight fetches; process.exit is the backstop.
125
+ */
126
+ export async function runHookWithDeadline(options: HookDeadlineOptions): Promise<void> {
127
+ const stdout = options.stdout ?? process.stdout;
128
+ const stderr = options.stderr ?? process.stderr;
129
+ const exit = options.exit ?? ((code: number) => { process.exit(code); });
130
+
131
+ await new Promise<void>((resolve) => {
132
+ let finished = false;
133
+
134
+ const settle = () => {
135
+ if (finished) return;
136
+ finished = true;
137
+ clearTimeout(timer);
138
+ resolve();
139
+ };
140
+
141
+ const timer = setTimeout(() => {
142
+ if (finished) return;
143
+ try {
144
+ stderr.write(`[memorix] hook timed out after ${options.timeoutMs}ms\n`);
145
+ } catch {
146
+ // stderr may already be closed
147
+ }
148
+ writeContinue(stdout);
149
+ exit(0);
150
+ settle();
151
+ }, options.timeoutMs);
152
+
153
+ options.run().then(
154
+ () => {
155
+ if (finished) return;
156
+ exit(0);
157
+ settle();
158
+ },
159
+ (error: unknown) => {
160
+ if (finished) return;
161
+ try {
162
+ const message = error instanceof Error ? error.message : String(error);
163
+ stderr.write(`[memorix] hook failed: ${message}\n`);
164
+ } catch {
165
+ // stderr may already be closed
166
+ }
167
+ writeContinue(stdout);
168
+ exit(0);
169
+ settle();
170
+ },
171
+ );
172
+ });
173
+ }