memorix 1.5.1 → 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 (52) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +3 -3
  3. package/README.zh-CN.md +3 -3
  4. package/dist/cli/index.js +558 -102
  5. package/dist/cli/memcode.js +12 -1
  6. package/dist/index.js +221 -27
  7. package/dist/maintenance-runner.js +59 -11
  8. package/dist/media-audio-runner.js +59 -11
  9. package/dist/media-video-runner.js +59 -11
  10. package/dist/memcode-runtime/CHANGELOG.md +47 -0
  11. package/dist/sdk.js +220 -26
  12. package/dist/vector-backfill-runner.js +59 -11
  13. package/docs/1.2.4-PERSISTENT-MEMORY-DELIVERY.md +1 -1
  14. package/docs/AGENT_OPERATOR_PLAYBOOK.md +11 -3
  15. package/docs/API_REFERENCE.md +2 -2
  16. package/docs/DEVELOPMENT.md +5 -3
  17. package/docs/DOCKER.md +5 -1
  18. package/docs/PERFORMANCE.md +2 -2
  19. package/docs/README.md +3 -1
  20. package/docs/SETUP.md +8 -2
  21. package/package.json +1 -1
  22. package/plugins/claude/memorix/.claude-plugin/plugin.json +1 -1
  23. package/plugins/codebuddy/memorix/.codebuddy-plugin/plugin.json +1 -1
  24. package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
  25. package/plugins/copilot/memorix/plugin.json +1 -1
  26. package/plugins/gemini/memorix/gemini-extension.json +1 -1
  27. package/plugins/omp/memorix/package.json +1 -1
  28. package/plugins/openclaw/memorix/.codex-plugin/plugin.json +1 -1
  29. package/plugins/pi/memorix/package.json +1 -1
  30. package/server.json +2 -2
  31. package/src/cli/commands/agent-integrations.ts +9 -3
  32. package/src/cli/commands/codegraph.ts +48 -5
  33. package/src/cli/commands/context.ts +20 -4
  34. package/src/cli/commands/doctor.ts +46 -15
  35. package/src/cli/commands/hook.ts +13 -1
  36. package/src/cli/commands/resume.ts +4 -0
  37. package/src/cli/commands/serve-http.ts +68 -7
  38. package/src/cli/heap.ts +76 -0
  39. package/src/cli/invocation.ts +7 -4
  40. package/src/codegraph/auto-context.ts +4 -1
  41. package/src/codegraph/context-pack.ts +3 -1
  42. package/src/codegraph/external-provider.ts +93 -0
  43. package/src/embedding/provider.ts +76 -6
  44. package/src/hooks/handler.ts +9 -18
  45. package/src/hooks/installers/index.ts +1 -1
  46. package/src/hooks/lifecycle.ts +173 -0
  47. package/src/hooks/official-skills.ts +3 -3
  48. package/src/hooks/rules/memorix-agent-rules.md +1 -1
  49. package/src/knowledge/context-receipt.ts +41 -0
  50. package/src/knowledge/workflows.ts +6 -3
  51. package/src/knowledge/workset.ts +56 -2
  52. package/src/server.ts +15 -3
@@ -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
  }
@@ -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
@@ -1259,7 +1259,7 @@ function getAgentRulesContent(agent?: AgentName, scope: 'project' | 'global' = '
1259
1259
  '',
1260
1260
  `Default first step for non-trivial coding work: call \x60memorix_project_context\x60 with the user's actual task before progress files, dev-log reads, ad-hoc file reads, or git archaeology. Memorix will choose a task-lensed brief (bugfix, feature, release, onboarding, refactor, docs, test, or general). When the task is continuing prior work, the same brief also includes a bounded prior-work projection. Treat its "Start here" files as the first ${contextNoun} files to inspect.`,
1261
1261
  '',
1262
- 'If the MCP tool is not visible yet but the client supports tool discovery or dynamic loading, search/select \x60memorix_project_context\x60 first. Continuation fallback is mandatory: when the user asks to continue, resume, take over, or explain prior work and MCP cannot be called in this turn, run exactly one CLI brief with the user\'s real task before inspecting files, Git history, progress notes, or guessing: \x60memorix resume "<task>" --json\x60. For a new task, use \x60memorix context "<task>" --json\x60 instead. The absence of \x60.memorix\x60 or visible memory files never proves project memory is empty. If that one command fails, report it and proceed normally. Do not probe help, enumerate commands, chain broad searches, wait indefinitely on MCP startup, or hand-write tool-call syntax.',
1262
+ 'If the MCP tool is not visible yet but the client supports tool discovery or dynamic loading, search/select \x60memorix_project_context\x60 first. Continuation fallback is mandatory: when the user asks to continue, resume, take over, or explain prior work and MCP cannot be called in this turn, run exactly one CLI brief with the user\'s real task before inspecting files, Git history, progress notes, or guessing: \x60memorix resume "<task>" --brief-json\x60. For a new task, use \x60memorix context "<task>" --brief-json\x60 instead. The absence of \x60.memorix\x60 or visible memory files never proves project memory is empty. Use \x60--json\x60 only when a diagnostic needs the detailed legacy payload. If that one command fails, report it and proceed normally. Do not probe help, enumerate commands, chain broad searches, wait indefinitely on MCP startup, or hand-write tool-call syntax.',
1263
1263
  '',
1264
1264
  'After a successful \x60memorix_project_context\x60 result, the brief is the default retrieval boundary. Do not call more Memorix retrieval tools after a complete brief. Use \x60memorix_context_pack\x60, \x60memorix_search\x60, or \x60memorix_detail\x60 only when the brief lacks a specific reference, freshness field, or fact needed for the task, or when the user explicitly asks for deeper history. In MCP, name that missing fact in \x60purpose\x60 when intentionally expanding beyond the brief. Do not retrieve the same decision twice just to confirm an already-complete brief.',
1265
1265
  'If the user asks for read-only work or says not to modify files, do not call \x60memorix_store\x60 just to record an assessment. Store only when the user explicitly asks to preserve it.',
@@ -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
+ }
@@ -31,8 +31,8 @@ export const OFFICIAL_MEMORIX_SKILLS: OfficialMemorixSkill[] = [
31
31
  '',
32
32
  '| Situation | Prefer | CLI fallback |',
33
33
  '|---|---|---|',
34
- '| Starting a coding task and needing the best task-lensed project map | `memorix_project_context` with the user\'s task | `memorix context "<task>" --json` |',
35
- '| Continuing prior project work | `memorix_project_context` with the user\'s task | `memorix resume "<task>" --json` |',
34
+ '| Starting a coding task and needing the best task-lensed project map | `memorix_project_context` with the user\'s task | `memorix context "<task>" --brief-json` |',
35
+ '| Continuing prior project work | `memorix_project_context` with the user\'s task | `memorix resume "<task>" --brief-json` |',
36
36
  '| Need structured refs/freshness for code-bound memories | `memorix_context_pack` | `memorix codegraph context-pack --task "<topic>"` |',
37
37
  '| Explicit memory graph question | `memorix_graph_context` | `memorix memory graph-context --query "<topic>"` |',
38
38
  '| Specific past decision, bug, file, or change | `memorix_search` | `memorix memory search --query "<topic>"` |',
@@ -45,7 +45,7 @@ export const OFFICIAL_MEMORIX_SKILLS: OfficialMemorixSkill[] = [
45
45
  '',
46
46
  '- Default first step for non-trivial coding work: call `memorix_project_context` before progress files, dev-log reads, ad-hoc file reads, or git archaeology. Pass the user\'s actual task so Memorix can choose a bugfix, feature, release, onboarding, refactor, docs, test, or general brief. Start by reading its suggested files.',
47
47
  '- In Claude Code headless/print-mode, an initial MCP `pending` status is not a failure by itself. Prefer MCP first: use the client\'s tool discovery/dynamic loading to find `memorix_project_context` when it is not in the first visible tool list.',
48
- '- Continuation fallback is mandatory: when the user asks to continue, resume, take over, or explain prior work and MCP cannot be called in this turn, run `memorix resume "<task>" --json` exactly once before inspecting files, Git history, progress notes, or guessing. The absence of `.memorix` or visible memory files never proves project memory is empty. For a new task, use `memorix context "<task>" --json` instead. If that one command fails, report it and proceed normally; do not probe help, enumerate commands, or chain broad searches.',
48
+ '- Continuation fallback is mandatory: when the user asks to continue, resume, take over, or explain prior work and MCP cannot be called in this turn, run `memorix resume "<task>" --brief-json` exactly once before inspecting files, Git history, progress notes, or guessing. The absence of `.memorix` or visible memory files never proves project memory is empty. For a new task, use `memorix context "<task>" --brief-json` instead. Use `--json` only when a diagnostic needs the detailed legacy payload. If that one command fails, report it and proceed normally; do not probe help, enumerate commands, or chain broad searches.',
49
49
  '- After a successful `memorix_project_context`, the brief is the default retrieval boundary. Do not call more Memorix retrieval tools after a complete brief. Use Context Pack, search, or detail only when the brief lacks a specific reference, freshness field, or fact needed for the task, or when the user explicitly asks for deeper history. In MCP, name that missing fact in `purpose` when intentionally expanding beyond the brief. Do not retrieve the same decision twice just to confirm an already-complete brief.',
50
50
  '- If the user asks for read-only work or says not to modify files, do not call `memorix_store` just to record an assessment. Store only when the user explicitly asks to preserve it.',
51
51
  '- Use `memorix_search` for a specific decision, bug, file, or prior change that the project context did not answer. Fetch detail only for a result you still need after the brief.',
@@ -7,7 +7,7 @@ You have access to Memorix memory tools. Follow these rules to maintain persiste
7
7
  At the beginning of non-trivial coding work, use Memorix Memory Autopilot before local progress notes or broad file exploration. A session bind is not required for every conversation.
8
8
 
9
9
  1. Default first step for non-trivial coding work: call `memorix_project_context` with the user's actual task before progress files, dev-log reads, ad-hoc file reads, or git archaeology. Memorix will choose a task-lensed brief (bugfix, feature, release, onboarding, refactor, docs, test, or general). Treat its "Start here" files as the first code or docs to inspect.
10
- 2. If the MCP tool is not visible yet but the client supports tool discovery or dynamic loading, search/select `memorix_project_context` first. Continuation fallback is mandatory: when the user asks to continue, resume, take over, or explain prior work and MCP cannot be called in this turn, run exactly one CLI brief with the user's real task before inspecting files, Git history, progress notes, or guessing: use `memorix resume "<task>" --json`. For a new task, use `memorix context "<task>" --json` instead. The absence of `.memorix` or visible memory files never proves project memory is empty. If that one command fails, report it and proceed normally. Do not probe help, enumerate commands, chain broad searches, wait indefinitely on MCP startup, or hand-write tool-call syntax.
10
+ 2. If the MCP tool is not visible yet but the client supports tool discovery or dynamic loading, search/select `memorix_project_context` first. Continuation fallback is mandatory: when the user asks to continue, resume, take over, or explain prior work and MCP cannot be called in this turn, run exactly one CLI brief with the user's real task before inspecting files, Git history, progress notes, or guessing: use `memorix resume "<task>" --brief-json`. For a new task, use `memorix context "<task>" --brief-json` instead. The absence of `.memorix` or visible memory files never proves project memory is empty. If that one command fails, report it and proceed normally. Do not probe help, enumerate commands, chain broad searches, wait indefinitely on MCP startup, or hand-write tool-call syntax. Use `--json` only when a diagnostic needs the detailed legacy payload.
11
11
  3. After a successful `memorix_project_context` result, the brief is the default retrieval boundary. Do not call more Memorix retrieval tools after a complete brief. Use `memorix_context_pack`, `memorix_search`, or `memorix_detail` only when the brief lacks a specific reference, freshness field, or fact needed for the task, or when the user explicitly asks for deeper history. In MCP, name that missing fact in `purpose` when intentionally expanding beyond the brief. Do not retrieve the same decision twice just to confirm an already-complete brief.
12
12
  If the user asks for read-only work or says not to modify files, do not call `memorix_store` just to record an assessment. Store only when the user explicitly asks to preserve it.
13
13
  4. Use `memorix_context_pack` when you need structured refs and freshness for code-bound memories.
@@ -0,0 +1,41 @@
1
+ import type { CodeGraphProviderQuality } from '../codegraph/types.js';
2
+ import type { TaskWorkset } from './workset.js';
3
+
4
+ /**
5
+ * A small machine-readable counterpart to the agent-facing Workset prompt.
6
+ * Detailed CLI JSON remains available for diagnostics; this shape is for
7
+ * clients that need a bounded handoff without loading raw stores.
8
+ */
9
+ export interface BoundedContextReceipt {
10
+ schemaVersion: '1';
11
+ task: string;
12
+ brief: string;
13
+ receipt: TaskWorkset['receipt'];
14
+ code: {
15
+ selected: CodeGraphProviderQuality['selected'];
16
+ quality: CodeGraphProviderQuality['selectedQuality'];
17
+ externalState: CodeGraphProviderQuality['external']['state'];
18
+ snapshotId?: string;
19
+ };
20
+ loadout?: TaskWorkset['agentLoadout'];
21
+ }
22
+
23
+ export function buildBoundedContextReceipt(input: {
24
+ workset: TaskWorkset;
25
+ providerQuality?: CodeGraphProviderQuality;
26
+ }): BoundedContextReceipt {
27
+ const provider = input.providerQuality ?? input.workset.provenance.codeProvider;
28
+ return {
29
+ schemaVersion: '1',
30
+ task: input.workset.task,
31
+ brief: input.workset.prompt,
32
+ receipt: input.workset.receipt,
33
+ code: {
34
+ selected: provider?.selected ?? 'lite',
35
+ quality: provider?.selectedQuality ?? 'heuristic',
36
+ externalState: provider?.external.state ?? 'not-detected',
37
+ ...(input.workset.provenance.snapshotId ? { snapshotId: input.workset.provenance.snapshotId } : {}),
38
+ },
39
+ ...(input.workset.agentLoadout ? { loadout: input.workset.agentLoadout } : {}),
40
+ };
41
+ }
@@ -22,7 +22,7 @@ import type {
22
22
  } from './workflow-types.js';
23
23
  import type { KnowledgeWorkspace } from './workspace-types.js';
24
24
 
25
- const KNOWN_AGENTS: AgentTarget[] = [
25
+ export const WORKFLOW_AGENT_TARGETS: AgentTarget[] = [
26
26
  'windsurf',
27
27
  'cursor',
28
28
  'claude-code',
@@ -117,7 +117,7 @@ function optionalStatus(data: Record<string, unknown>): WorkflowStatus {
117
117
  }
118
118
 
119
119
  function normalizeAgents(values: string[]): AgentTarget[] {
120
- const unknown = values.filter(value => !KNOWN_AGENTS.includes(value as AgentTarget));
120
+ const unknown = values.filter(value => !WORKFLOW_AGENT_TARGETS.includes(value as AgentTarget));
121
121
  if (unknown.length > 0) {
122
122
  throw new Error('workflow frontmatter has unsupported agent: ' + unknown[0]);
123
123
  }
@@ -459,12 +459,15 @@ function taskScore(workflow: WorkflowSpec, task: string): { score: number; reaso
459
459
  export function selectWorkflows(input: {
460
460
  workflows: WorkflowSpec[];
461
461
  task: string;
462
+ /** Only select workflows that explicitly support the target agent. */
463
+ agent?: AgentTarget;
462
464
  projectId?: string;
463
465
  store?: WorkflowStore;
464
466
  limit?: number;
465
467
  }): WorkflowSelection[] {
466
468
  const selected = input.workflows
467
- .filter(workflow => workflow.status === 'active')
469
+ .filter(workflow => workflow.status === 'active'
470
+ && (!input.agent || workflow.compatibleAgents.includes(input.agent)))
468
471
  .map(workflow => {
469
472
  const match = taskScore(workflow, input.task);
470
473
  const cautions = input.store && input.projectId
@@ -8,6 +8,7 @@ import { WorkflowStore } from './workflow-store.js';
8
8
  import { OutcomeStore } from './outcome-store.js';
9
9
  import { qualityFromOutcome, type OutcomeQuality } from './outcome-types.js';
10
10
  import { selectWorkflows } from './workflows.js';
11
+ import type { AgentTarget } from '../types.js';
11
12
  import { renderLongTermMemorySummary, selectLongTermMemoriesForTask } from '../memory/long-term.js';
12
13
  import { governContextCandidates, type GovernancePlan } from './governor.js';
13
14
  import type {
@@ -73,6 +74,18 @@ export interface WorksetWorkflow {
73
74
  cautions: string[];
74
75
  }
75
76
 
77
+ /**
78
+ * A task-scoped plan assembled from existing workflow and wiki assets. It is
79
+ * descriptive rather than an authorization boundary: host agents retain their
80
+ * own tool and approval policies.
81
+ */
82
+ export interface WorksetAgentLoadout {
83
+ agent: AgentTarget;
84
+ workflowIds: string[];
85
+ requiredContext: string[];
86
+ allowedTools: string[];
87
+ }
88
+
76
89
  export interface WorksetMemorySource {
77
90
  id: number;
78
91
  title: string;
@@ -156,6 +169,7 @@ export interface TaskWorkset {
156
169
  pages: WorksetPage[];
157
170
  durableMemory: WorksetLongTermMemory[];
158
171
  workflows: WorksetWorkflow[];
172
+ agentLoadout?: WorksetAgentLoadout;
159
173
  cautions: WorksetCaution[];
160
174
  verification: string[];
161
175
  evidenceIds: string[];
@@ -179,6 +193,8 @@ export interface BuildTaskWorksetInput {
179
193
  projectId: string;
180
194
  dataDir: string;
181
195
  task?: string;
196
+ /** Optional target used to select workflows explicitly compatible with this agent. */
197
+ agent?: AgentTarget;
182
198
  lens: string;
183
199
  currentFacts?: string[];
184
200
  continuation?: WorksetContinuation;
@@ -313,6 +329,18 @@ function workflowOutput(selection: WorkflowSelection): WorksetWorkflow {
313
329
  };
314
330
  }
315
331
 
332
+ function workflowLoadout(agent: AgentTarget | undefined, selections: WorkflowSelection[]): WorksetAgentLoadout | undefined {
333
+ if (!agent) return undefined;
334
+ return {
335
+ agent,
336
+ workflowIds: selections.map(selection => selection.workflow.id),
337
+ requiredContext: unique(selections.flatMap(selection => selection.workflow.requiredContext))
338
+ .map(item => short(item, 18)).slice(0, 4),
339
+ allowedTools: unique(selections.flatMap(selection => selection.workflow.allowedTools))
340
+ .map(item => short(item, 12)).slice(0, 6),
341
+ };
342
+ }
343
+
316
344
  function appendLine(
317
345
  lines: string[],
318
346
  candidate: string,
@@ -853,6 +881,23 @@ export function renderTaskWorksetPrompt(input: Omit<TaskWorkset, 'prompt' | 'bud
853
881
  }
854
882
  }
855
883
 
884
+ if (input.agentLoadout) {
885
+ appendLine(lines, '', maxTokens, omitted, 'agent-loadout-heading');
886
+ appendLine(lines, 'Agent loadout (' + input.agentLoadout.agent + ')', maxTokens, omitted, 'agent-loadout-heading');
887
+ for (const required of input.agentLoadout.requiredContext.slice(0, 2)) {
888
+ appendLine(lines, '- Need: ' + required, maxTokens, omitted, 'agent-loadout-context');
889
+ }
890
+ if (input.agentLoadout.allowedTools.length > 0) {
891
+ appendLine(
892
+ lines,
893
+ '- Workflow tools: ' + input.agentLoadout.allowedTools.join(', '),
894
+ maxTokens,
895
+ omitted,
896
+ 'agent-loadout-tools',
897
+ );
898
+ }
899
+ }
900
+
856
901
  if (input.verification.length > 0) {
857
902
  appendLine(lines, '', maxTokens, omitted, 'verification-heading');
858
903
  appendLine(lines, 'Verify', maxTokens, omitted, 'verification-heading');
@@ -965,6 +1010,7 @@ export async function buildTaskWorkset(input: BuildTaskWorksetInput): Promise<Ta
965
1010
  let workspace: KnowledgeWorkspace | undefined;
966
1011
  let pages: WorksetPage[] = [];
967
1012
  let workflows: WorksetWorkflow[] = [];
1013
+ let workflowSelections: WorkflowSelection[] = [];
968
1014
  try {
969
1015
  workspace = await preferredWorkspace(input.projectId, input.dataDir);
970
1016
  if (workspace) {
@@ -985,14 +1031,15 @@ export async function buildTaskWorkset(input: BuildTaskWorksetInput): Promise<Ta
985
1031
  if (task) {
986
1032
  const workflowStore = new WorkflowStore();
987
1033
  await workflowStore.init(input.dataDir);
988
- const selected = selectWorkflows({
1034
+ workflowSelections = selectWorkflows({
989
1035
  workflows: workflowStore.listWorkflows(workspace.id, 'active'),
990
1036
  task,
991
1037
  projectId: input.projectId,
992
1038
  store: workflowStore,
1039
+ ...(input.agent ? { agent: input.agent } : {}),
993
1040
  limit: 2,
994
1041
  });
995
- workflows = selected.map(workflowOutput);
1042
+ workflows = workflowSelections.map(workflowOutput);
996
1043
  for (const workflow of workflows) {
997
1044
  for (const caution of workflow.cautions) {
998
1045
  cautions.push({ kind: 'workflow-failed-verification', message: caution });
@@ -1018,6 +1065,10 @@ export async function buildTaskWorkset(input: BuildTaskWorksetInput): Promise<Ta
1018
1065
  workflows,
1019
1066
  });
1020
1067
  const governedDurableIds = new Set(governed.durableMemory.map(memory => memory.id));
1068
+ const agentLoadout = workflowLoadout(
1069
+ input.agent,
1070
+ workflowSelections.filter(selection => governed.workflows.some(workflow => workflow.id === selection.workflow.id)),
1071
+ );
1021
1072
  const evidenceIds = unique(governed.claims.flatMap(claim => evidenceIdsForClaim(
1022
1073
  claim,
1023
1074
  evidenceByClaim.get(claim.id) ?? [],
@@ -1084,6 +1135,9 @@ export async function buildTaskWorkset(input: BuildTaskWorksetInput): Promise<Ta
1084
1135
  pages: pages.filter(page => page.claimIds.some(claimId => governed.claims.some(claim => claim.id === claimId))),
1085
1136
  durableMemory: governed.durableMemory,
1086
1137
  workflows: governed.workflows,
1138
+ ...(agentLoadout
1139
+ ? { agentLoadout }
1140
+ : {}),
1087
1141
  cautions: normalizedCautions,
1088
1142
  verification,
1089
1143
  evidenceIds,