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.
- package/CHANGELOG.md +24 -0
- package/TEAM.md +86 -86
- package/dist/cli/index.js +317 -62
- package/dist/cli/memcode.js +12 -1
- package/dist/index.js +56 -9
- package/dist/maintenance-runner.js +54 -7
- package/dist/media-audio-runner.js +54 -7
- package/dist/media-video-runner.js +54 -7
- package/dist/memcode-runtime/CHANGELOG.md +24 -0
- package/dist/sdk.js +55 -8
- package/dist/vector-backfill-runner.js +54 -7
- package/docs/AGENT_OPERATOR_PLAYBOOK.md +11 -3
- package/docs/API_REFERENCE.md +1 -1
- package/docs/DESIGN_DECISIONS.md +357 -357
- package/docs/DOCKER.md +5 -1
- package/docs/PERFORMANCE.md +2 -2
- package/docs/README.md +3 -1
- package/docs/SETUP.md +8 -2
- package/package.json +1 -1
- package/plugins/claude/memorix/.claude-plugin/plugin.json +1 -1
- package/plugins/codebuddy/memorix/.codebuddy-plugin/plugin.json +1 -1
- package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
- package/plugins/copilot/memorix/plugin.json +1 -1
- package/plugins/gemini/memorix/gemini-extension.json +1 -1
- package/plugins/omp/memorix/package.json +1 -1
- package/plugins/openclaw/memorix/.codex-plugin/plugin.json +1 -1
- package/plugins/pi/memorix/package.json +1 -1
- package/server.json +2 -2
- package/src/audit/index.ts +156 -156
- package/src/cli/commands/agent-integrations.ts +9 -3
- package/src/cli/commands/doctor.ts +46 -15
- package/src/cli/commands/formation.ts +48 -48
- package/src/cli/commands/git-hook-install.ts +111 -111
- package/src/cli/commands/handoff.ts +54 -54
- package/src/cli/commands/hook.ts +13 -1
- package/src/cli/commands/hooks-status.ts +63 -63
- package/src/cli/commands/ingest.ts +44 -44
- package/src/cli/commands/integrate-shared.ts +15 -15
- package/src/cli/commands/lock.ts +82 -82
- package/src/cli/commands/message.ts +104 -104
- package/src/cli/commands/poll.ts +58 -58
- package/src/cli/commands/reasoning.ts +118 -118
- package/src/cli/commands/serve-http.ts +68 -7
- package/src/cli/commands/skills.ts +114 -114
- package/src/cli/commands/task.ts +167 -167
- package/src/cli/commands/transfer.ts +47 -47
- package/src/cli/heap.ts +76 -0
- package/src/cli/index.ts +406 -406
- package/src/cli/invocation.ts +7 -4
- package/src/cli/tui/ChatView.tsx +234 -234
- package/src/cli/tui/CommandBar.tsx +312 -312
- package/src/cli/tui/ContextRail.tsx +118 -118
- package/src/cli/tui/HeaderBar.tsx +72 -72
- package/src/cli/tui/LogoBanner.tsx +51 -51
- package/src/cli/tui/index.ts +41 -41
- package/src/cli/version.ts +7 -7
- package/src/cli/workbench.ts +1 -1
- package/src/dashboard/project-classification.ts +64 -64
- package/src/embedding/provider.ts +76 -6
- package/src/git/hooks-path.ts +85 -85
- package/src/hooks/handler.ts +9 -18
- package/src/hooks/lifecycle.ts +173 -0
- package/src/hooks/pattern-detector.ts +173 -173
- package/src/hooks/significance-filter.ts +250 -250
- package/src/llm/memory-manager.ts +328 -328
- package/src/llm/quality.ts +248 -248
- package/src/memory/attribution-guard.ts +249 -249
- package/src/memory/disclosure-policy.ts +135 -135
- package/src/memory/entity-extractor.ts +197 -197
- package/src/memory/formation/evaluate.ts +217 -217
- package/src/memory/freshness.ts +122 -122
- package/src/memory/graph.ts +197 -197
- package/src/memory/refs.ts +94 -94
- package/src/memory/secret-filter.ts +79 -79
- package/src/orchestrate/adapters/claude-stream.ts +192 -192
- package/src/orchestrate/adapters/claude.ts +111 -111
- package/src/orchestrate/adapters/codex-stream.ts +134 -134
- package/src/orchestrate/adapters/codex.ts +41 -41
- package/src/orchestrate/adapters/gemini-stream.ts +166 -166
- package/src/orchestrate/adapters/gemini.ts +42 -42
- package/src/orchestrate/adapters/index.ts +73 -73
- package/src/orchestrate/adapters/opencode-stream.ts +143 -143
- package/src/orchestrate/adapters/opencode.ts +47 -47
- package/src/orchestrate/adapters/types.ts +77 -77
- package/src/orchestrate/capability-router.ts +284 -284
- package/src/orchestrate/cost-tracker.ts +219 -219
- package/src/orchestrate/error-recovery.ts +191 -191
- package/src/orchestrate/evidence.ts +140 -140
- package/src/orchestrate/ledger.ts +110 -110
- package/src/orchestrate/memorix-bridge.ts +343 -343
- package/src/orchestrate/output-budget.ts +80 -80
- package/src/orchestrate/permission.ts +152 -152
- package/src/orchestrate/pipeline-trace.ts +131 -131
- package/src/orchestrate/prompt-builder.ts +155 -155
- package/src/orchestrate/ring-buffer.ts +37 -37
- package/src/orchestrate/task-graph.ts +389 -389
- package/src/project/aliases.ts +374 -374
- package/src/rules/adapters/trae.ts +56 -56
- package/src/rules/syncer.ts +235 -235
- package/src/search/query-expansion.ts +52 -52
- package/src/server/formation-timeout.ts +27 -27
- package/src/skills/mini-skills.ts +386 -386
- package/src/store/chat-store.ts +119 -119
- package/src/store/graph-store.ts +249 -249
- package/src/store/mini-skill-store.ts +349 -349
- package/src/store/persistence-json.ts +212 -212
- package/src/store/persistence.ts +291 -291
- package/src/store/project-affinity.ts +195 -195
- package/src/team/event-bus.ts +76 -76
- package/src/team/handoff.ts +161 -161
- package/src/team/poll.ts +132 -132
- package/src/workspace/mcp-adapters/codex.ts +191 -191
- package/src/workspace/mcp-adapters/copilot.ts +105 -105
- package/src/workspace/mcp-adapters/cursor.ts +53 -53
- package/src/workspace/mcp-adapters/kiro.ts +64 -64
- package/src/workspace/mcp-adapters/opencode.ts +123 -123
- package/src/workspace/mcp-adapters/trae.ts +134 -134
- package/src/workspace/mcp-adapters/windsurf.ts +91 -91
- package/src/workspace/sanitizer.ts +60 -60
|
@@ -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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
}
|
package/src/git/hooks-path.ts
CHANGED
|
@@ -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
|
+
}
|
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,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
|
+
}
|