@animalabs/connectome-host 0.7.3 → 0.8.0

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 (97) hide show
  1. package/.env.example +12 -5
  2. package/.github/PULL_REQUEST_TEMPLATE.md +3 -2
  3. package/.github/workflows/changelog.yml +9 -4
  4. package/.github/workflows/ci.yml +5 -3
  5. package/.github/workflows/publish.yml +12 -6
  6. package/CHANGELOG.md +401 -10
  7. package/CONTRIBUTING.md +47 -19
  8. package/HEADLESS-FLEET-PLAN.md +22 -0
  9. package/README.md +39 -1
  10. package/bun.lock +27 -31
  11. package/changelog.d/README.md +28 -0
  12. package/docs/AGENT-ONBOARDING.md +1 -1
  13. package/docs/debug-context-api.md +2 -2
  14. package/docs/retrieval-traces.md +173 -0
  15. package/docs/webui-deployment.md +2 -1
  16. package/package.json +6 -6
  17. package/recipes/SETUP.md +11 -5
  18. package/recipes/TRIUMVIRATE-SETUP.md +68 -14
  19. package/recipes/knowledge-miner.json +0 -30
  20. package/recipes/mock-test.json +19 -0
  21. package/recipes/triumvirate.json +6 -1
  22. package/scripts/audit-module-optins.ts +288 -0
  23. package/scripts/release-changelog.ts +210 -21
  24. package/src/cache-keepalive-log.ts +41 -0
  25. package/src/commands.ts +96 -0
  26. package/src/framework-strategy.ts +50 -4
  27. package/src/gate-telemetry.ts +106 -0
  28. package/src/headless.ts +24 -0
  29. package/src/index.ts +179 -64
  30. package/src/mcpl-config.ts +99 -1
  31. package/src/modules/fleet-module.ts +60 -1
  32. package/src/modules/fleet-types.ts +30 -1
  33. package/src/modules/identity-module.ts +310 -2
  34. package/src/modules/instructions-module.ts +265 -0
  35. package/src/modules/mcpl-admin-module.ts +89 -13
  36. package/src/modules/retrieval-module.ts +249 -51
  37. package/src/modules/retrieval-trace-page.ts +254 -0
  38. package/src/modules/retrieval-trace.ts +904 -0
  39. package/src/modules/subagent-module.ts +18 -0
  40. package/src/modules/tts-relay-module.ts +33 -18
  41. package/src/modules/web-ui-module.ts +445 -894
  42. package/src/recipe.ts +787 -29
  43. package/src/retrieval-config.ts +39 -0
  44. package/src/strategies/frontdesk-strategy.ts +34 -125
  45. package/src/tui.ts +325 -54
  46. package/src/web/panel-data.ts +1206 -0
  47. package/src/web/protocol.ts +75 -10
  48. package/src/workspace-mounts.ts +73 -0
  49. package/test/audit-module-optins.test.ts +174 -0
  50. package/test/cache-keepalive-log.test.ts +83 -0
  51. package/test/conversations-recipe.test.ts +142 -0
  52. package/test/fleet-panel-request.test.ts +90 -0
  53. package/test/framework-fkm-composition.test.ts +35 -3
  54. package/test/framework-strategy-defaults.test.ts +41 -0
  55. package/test/frontdesk-strategy.test.ts +25 -37
  56. package/test/gate-telemetry-adapter.test.ts +84 -0
  57. package/test/gate-telemetry.test.ts +91 -0
  58. package/test/headless-panel-request.test.ts +201 -0
  59. package/test/identity-and-surfaces.test.ts +212 -1
  60. package/test/instructions-module.test.ts +258 -0
  61. package/test/mcpl-admin-module.test.ts +64 -0
  62. package/test/mcpl-agent-overlay.test.ts +51 -3
  63. package/test/mcpl-child-env.test.ts +64 -0
  64. package/test/mock-headless-child.ts +14 -0
  65. package/test/nudge-command.test.ts +47 -0
  66. package/test/recipe-cache-keepalive.test.ts +59 -0
  67. package/test/recipe-compression-fallback.test.ts +19 -0
  68. package/test/recipe-hybrid-prose-routing.test.ts +12 -0
  69. package/test/recipe-instructions.test.ts +176 -0
  70. package/test/recipe-kv-unified.test.ts +87 -0
  71. package/test/recipe-mcp-source.test.ts +54 -0
  72. package/test/recipe-openai-compatible.test.ts +54 -0
  73. package/test/recipe-path-resolution.test.ts +19 -8
  74. package/test/recipe-provider.test.ts +14 -0
  75. package/test/recipe-save-unresolved.test.ts +244 -0
  76. package/test/recipe-source-only.test.ts +38 -0
  77. package/test/release-changelog.test.ts +202 -0
  78. package/test/retrieval-auth-loopback.test.ts +49 -0
  79. package/test/retrieval-config.test.ts +74 -0
  80. package/test/retrieval-module.test.ts +821 -0
  81. package/test/subagent-prose-routing.test.ts +109 -0
  82. package/test/tui-format.test.ts +106 -0
  83. package/test/web-ui-context-coverage.test.ts +1 -1
  84. package/test/web-ui-module.test.ts +189 -3
  85. package/test/web-ui-observers.test.ts +8 -5
  86. package/test/web-ui-protocol.test.ts +0 -0
  87. package/test/workspace-mounts.test.ts +68 -0
  88. package/web/src/App.tsx +160 -44
  89. package/web/src/Context.tsx +35 -8
  90. package/web/src/ContextDocument.tsx +20 -5
  91. package/web/src/Files.tsx +2 -8
  92. package/web/src/Health.tsx +61 -1
  93. package/web/src/Lessons.tsx +2 -38
  94. package/web/src/Mcpl.tsx +80 -14
  95. package/web/src/Pins.tsx +5 -0
  96. package/web/src/Settings.tsx +5 -0
  97. package/web/vite.config.ts +8 -2
package/src/recipe.ts CHANGED
@@ -11,8 +11,9 @@
11
11
  * - Built-in default (generic assistant)
12
12
  */
13
13
 
14
- import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from 'node:fs';
14
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, chmodSync } from 'node:fs';
15
15
  import { dirname, isAbsolute, resolve } from 'node:path';
16
+ import { buildWorkspaceMounts } from './workspace-mounts.js';
16
17
 
17
18
  // ---------------------------------------------------------------------------
18
19
  // Types
@@ -50,11 +51,29 @@ export interface RecipeStrategy {
50
51
  /** Complete provider-request admission ceiling for compression fallbacks,
51
52
  * including the output reserve. */
52
53
  compressionContextBudgetTokens?: number;
54
+ /** Residence-scoped direct source-only L1 compression: the summarizer sees
55
+ * only the compression marker + exact target chunk + directive (no head,
56
+ * recall, or non-target raw recent material). One call, not a refusal
57
+ * ladder. Source-preserving; L1 only. Default off. */
58
+ compressionSourceOnly?: boolean;
59
+ /** Preserve canonical + recall variants, then issue one source-only L1 request last. */
60
+ compressionSourceOnlyFallback?: boolean;
61
+ /** Legacy first-choice target-only merge request. */
62
+ compressionMergeSourceOnly?: boolean;
63
+ /** Preserve ordinary merge retries, then use target-only on the final attempt. */
64
+ compressionMergeSourceOnlyFallback?: boolean;
65
+ /** Token budget for prior recall-pair context in compression/merge
66
+ * requests (Context Manager `compressionRecallBudgetTokens`). */
67
+ compressionRecallBudgetTokens?: number;
53
68
  positionedRecallPairs?: boolean;
54
69
  recallHeaderTemplate?: string;
55
70
  targetChunkTokens?: number;
56
71
  mergeThreshold?: number;
57
72
  summaryTargetTokens?: number;
73
+ /** Standing production target: keep the summary forest deep enough to fit
74
+ * this budget, enabling a later live-budget descent with no fold-storm and
75
+ * a single KV invalidation (see context-manager productionBudgetTokens). */
76
+ productionBudgetTokens?: number;
58
77
  l1BudgetTokens?: number;
59
78
  l2BudgetTokens?: number;
60
79
  l3BudgetTokens?: number;
@@ -74,7 +93,10 @@ export interface RecipeStrategy {
74
93
  /** Adaptive-resolution fold planner. The host defaults this to 'kv-stable'
75
94
  * (cache-stable compile plans; see buildFrameworkStrategy) — set explicitly
76
95
  * only to opt into the legacy planners. */
77
- foldingStrategy?: 'flat-profile' | 'oldest-first' | 'kv-stable';
96
+ foldingStrategy?: 'flat-profile' | 'oldest-first' | 'kv-stable' | 'kv-unified';
97
+ /** Complete fail-closed policy for the kv-unified solver. No live defaults
98
+ * are supplied: selecting kv-unified without every field is invalid. */
99
+ kvUnified?: RecipeKvUnifiedConfig;
78
100
  speculativeProduction?: boolean;
79
101
  /** L1 production holdback: keep the newest N closed chunks out of the
80
102
  * speculative compression queue (default 1); demand still overrides. */
@@ -95,6 +117,37 @@ export interface RecipeStrategy {
95
117
  /** Override wording for the witnessed-record instruction ({targetTokens}
96
118
  * substituted). */
97
119
  witnessedInstruction?: string;
120
+ /** Identity reminder appended to every compression/merge instruction.
121
+ * For agents in multi-resident channels (and older models especially):
122
+ * names the agent and directs attribution so pure-witness chunks don't
123
+ * flip the summarizer into another speaker's identity. */
124
+ identityReminder?: string;
125
+ }
126
+
127
+ export interface RecipeKvUnifiedConfig {
128
+ policy: {
129
+ alpha: number;
130
+ budgetLowRatio: number;
131
+ budgetHighRatio: number;
132
+ budgetUnderLambda: number;
133
+ budgetOverLambda: number;
134
+ cacheLambda: number;
135
+ cacheScale: number;
136
+ cacheReadPrice: number;
137
+ cacheWritePrice: number;
138
+ continuityLambda: number;
139
+ continuityScale: number;
140
+ continuityRecencyHalfLifeTokens: number;
141
+ continuityRecencyFloor: number;
142
+ continuityStableHalfLife: number;
143
+ continuityStableFloor: number;
144
+ };
145
+ tokenBucketSize: number;
146
+ continuityBucketSize: number;
147
+ fidelityBucketSize: number;
148
+ labelCeiling: number;
149
+ adoptEpsilon: number;
150
+ treeifyNonContiguousSummaries: boolean;
98
151
  }
99
152
 
100
153
  export interface RecipeAgent {
@@ -102,8 +155,22 @@ export interface RecipeAgent {
102
155
  model?: string;
103
156
  /** IANA zone used when rendering wall-clock times to the agent. */
104
157
  timezone?: string;
105
- /** Provider transport. Omitted preserves the historical Anthropic default. */
106
- provider?: 'anthropic' | 'openai-responses' | 'openai-codex' | 'openrouter' | 'bedrock';
158
+ /** Provider transport. Omitted preserves the historical Anthropic default.
159
+ * 'mock' wires membrane's MockAdapter canned/echo responses, no API key,
160
+ * no provider spend; for exercising the full host loop offline. */
161
+ provider?: 'anthropic' | 'openai-responses' | 'openai-codex' | 'openrouter' | 'bedrock' | 'openai-compatible' | 'mock';
162
+ /**
163
+ * Base URL of an OpenAI-compatible chat-completions endpoint, e.g.
164
+ * `http://localhost:11434/v1` (Ollama), a vLLM server, Together, Groq,
165
+ * NanoGPT... Required with `provider: 'openai-compatible'`, rejected with
166
+ * any other provider (those have their own `*_BASE_URL` env overrides).
167
+ * The API key comes from `OPENAI_COMPATIBLE_API_KEY` only — deliberately no
168
+ * `OPENAI_API_KEY` fallback, since `baseUrl` is recipe-controlled and a real
169
+ * OpenAI credential must never travel silently to an arbitrary endpoint.
170
+ * Local servers may need none. `agent.model` is required
171
+ * too — there is no sensible default model for an arbitrary endpoint.
172
+ */
173
+ baseUrl?: string;
107
174
  /** Message formatter. 'native' (default) = structured user/assistant turns.
108
175
  * 'anthropic-xml' = classic prefill format ("participant: text" runs, XML
109
176
  * tools) — for migrating prefill-era bots (chapterx borgs) with their exact
@@ -128,6 +195,28 @@ export interface RecipeAgent {
128
195
  * Not forwarded on bedrock — that transport only has the default 5m
129
196
  * cache and rejects the ttl field. */
130
197
  cacheTtl?: '5m' | '1h';
198
+ /**
199
+ * Prompt-cache keepalive. With `cacheTtl: '1h'`, an idle agent's cached
200
+ * prefix expires after an hour and its next wake pays a 2x cache write over
201
+ * the whole context. Reading an entry refreshes its TTL at 0.1x, so a
202
+ * periodic `max_tokens: 0` replay holds it warm for pennies.
203
+ *
204
+ * On by default for the anthropic provider (ignored elsewhere — bedrock has
205
+ * no 1h cache). Measured on fable-cm 2026-08-11..22: 49.7M tokens of cache
206
+ * writes followed a >1h idle gap, ~$944 of write premium at fable-5 rates
207
+ * that this converts to ~$308 of reads.
208
+ *
209
+ * Cost is proportional to actual idleness, not to `maxIdleHours` — a busy
210
+ * agent never fires one, because its own traffic already refreshes the TTL.
211
+ */
212
+ cacheKeepalive?: {
213
+ /** Default true (anthropic provider only). */
214
+ enabled?: boolean;
215
+ /** Stop refreshing this long after the last REAL request. Default 24. */
216
+ maxIdleHours?: number;
217
+ /** Refresh once untouched this long. Must be < 60 with a 1h TTL. Default 45. */
218
+ refreshAfterMinutes?: number;
219
+ };
131
220
  /**
132
221
  * Explicit prompt-caching override. Unset means provider-appropriate
133
222
  * default: on for everything except bedrock models that predate caching
@@ -144,9 +233,12 @@ export interface RecipeAgent {
144
233
  /**
145
234
  * Prose delivery mode (agent-framework docs/explicit-prose-routing.md).
146
235
  * 'explicit' = model prefixes plain text with `>>destination`; unprefixed
147
- * prose bounces to a clipboard instead of auto-routing. Default 'locus'.
236
+ * prose bounces to a clipboard instead of auto-routing.
237
+ * 'hybrid' = unprefixed prose keeps the current locus while an exact leading
238
+ * `>>>destination` envelope routes through the authorized channel registry.
239
+ * Default 'locus'.
148
240
  */
149
- proseRouting?: 'locus' | 'explicit';
241
+ proseRouting?: 'locus' | 'explicit' | 'hybrid' | 'disabled';
150
242
  /**
151
243
  * Extra Anthropic beta flags sent as the `anthropic-beta` header on every
152
244
  * request (e.g. `["context-1m-2025-08-07"]` for the 1M context window on
@@ -186,6 +278,15 @@ export interface RecipeAgent {
186
278
  codex?: {
187
279
  fastMode?: boolean;
188
280
  };
281
+ /** Mock-provider settings. Only used with `provider: "mock"`. The default
282
+ * (no block) echoes the last user message back — the most informative shape
283
+ * for interactive smoke runs, since you can see your own words complete the
284
+ * loop. `echoMode: false` returns `defaultResponse` instead, which gives
285
+ * deterministic output for scripted tests. */
286
+ mock?: {
287
+ echoMode?: boolean;
288
+ defaultResponse?: string;
289
+ };
189
290
  /**
190
291
  * Content-refusal handling. When `autoRewind` is on, a `stop_reason: refusal`
191
292
  * turn triggers an automatic rewind of the triggering turn + retry (keeping
@@ -262,13 +363,21 @@ export interface RecipeMcpServer {
262
363
 
263
364
  /**
264
365
  * How to obtain and install an MCP server at deploy time. Consumed by
265
- * build tooling like connectome-cook. All fields optional-at-the-schema-
266
- * layer except `url`; tools may require more depending on the install
267
- * pattern they're generating.
366
+ * build tooling like connectome-cook. Exactly one of `url` (git form) or
367
+ * `npm` (registry form) must be set; tools may require more depending on
368
+ * the install pattern they're generating.
268
369
  */
269
370
  export interface RecipeMcpServerSource {
270
- /** Git URL to clone from. */
271
- url: string;
371
+ /** Git URL to clone from. Mutually exclusive with `npm`. */
372
+ url?: string;
373
+ /**
374
+ * npm registry package spec (`pkg@version` / `@scope/pkg@version`) that
375
+ * build tooling bakes via a global install instead of a git clone —
376
+ * matches connectome-cook's `source.npm` grammar. The git-form fields
377
+ * (`ref`, `install`, `inContainer`, ...) don't apply. Mutually
378
+ * exclusive with `url`.
379
+ */
380
+ npm?: string;
272
381
  /**
273
382
  * Git ref: branch, tag, or commit SHA. Default: "main".
274
383
  * If the value starts with "refs/" (e.g. "refs/pull/3/head"), it's
@@ -383,8 +492,9 @@ export interface RecipeCredentialFileField {
383
492
 
384
493
  /**
385
494
  * Subset of MountConfig exposed to recipes.
386
- * Intentionally omits watchDebounceMs, followSymlinks, and maxFileSize
387
- * these are implementation details best left to framework defaults.
495
+ * Intentionally omits watchDebounceMs and followSymlinks. The maximum file
496
+ * size is operator-configurable because binary service artifacts may
497
+ * legitimately exceed the conservative framework default.
388
498
  */
389
499
  export interface RecipeWorkspaceMount {
390
500
  name: string;
@@ -392,6 +502,8 @@ export interface RecipeWorkspaceMount {
392
502
  mode?: 'read-write' | 'read-only';
393
503
  watch?: 'always' | 'on-agent-action' | 'never';
394
504
  ignore?: string[];
505
+ /** Maximum file size in bytes (defaults to the framework's 5 MiB limit). */
506
+ maxFileSize?: number;
395
507
  /**
396
508
  * Request inference when files in this mount change. Pair with
397
509
  * `watch: 'always'` so chokidar actually observes the mount.
@@ -421,12 +533,67 @@ export interface RecipeModules {
421
533
  /**
422
534
  * Lesson retrieval-injection (requires `lessons`). OPT-IN — defaults to off
423
535
  * and is deliberately not part of the standard recipe: it injects
424
- * context-dependent content into every compile and spends two Haiku calls
425
- * per turn. Enable only for agents that actually curate a lesson library.
536
+ * context-dependent content into every compile and spends up to two
537
+ * configured retrieval-model calls. Enable only for agents that actually
538
+ * curate a lesson library.
426
539
  */
427
- retrieval?: boolean | { model?: string; maxInjected?: number };
540
+ retrieval?: boolean | {
541
+ model?: string;
542
+ maxInjected?: number;
543
+ /**
544
+ * Optional OpenAI Responses/Codex reasoning effort for both retrieval calls.
545
+ * Requires an explicit retrieval model.
546
+ */
547
+ reasoningEffort?: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max';
548
+ };
428
549
  wake?: boolean | import('@animalabs/agent-framework').GateConfig;
429
550
  workspace?: boolean | { mounts: RecipeWorkspaceMount[]; configMount?: boolean };
551
+ /**
552
+ * Shared operating instructions. OPT-IN — off by default. Reads a living
553
+ * instructions document (a CLAUDE.md analogue maintained in a workspace
554
+ * mount) and injects its current content into EVERY agent's context on
555
+ * EVERY turn — the resident agent and all ephemeral subagents — via the
556
+ * gatherContext hook. Injections are per-turn overlays (not persisted to
557
+ * Chronicle), so edits to the file take effect on the next turn.
558
+ *
559
+ * Requires `workspace` (the path below is a workspace mount path);
560
+ * enabling this alongside `workspace: false` fails validation. The mount
561
+ * named by the path is cross-checked at load time in every configuration
562
+ * (explicit mounts and the implicit "input"+"products" default alike), so
563
+ * a path naming a nonexistent mount is a load error, never a silent
564
+ * no-injection. A read-write instructions mount must also set
565
+ * `autoMaterialize: true`: workspace writes are Chronicle-first and the
566
+ * injection reads disk, so without materialization an agent's own
567
+ * curation edits would never reach the injection. A read-only mount is
568
+ * the alternative when the file is maintained outside the agent (edits
569
+ * then propagate to the injection but not to `workspace--read`, which
570
+ * serves Chronicle). A missing FILE remains fail-open at runtime: no
571
+ * injection, warn once.
572
+ *
573
+ * Cache economics: with position 'system' the injected block sits in the
574
+ * prompt-cache prefix of every agent, so each EDIT to the file is a
575
+ * fleet-wide cache cold start on the next turn (steady state between
576
+ * edits caches normally). Curate in batches rather than per-message;
577
+ * 'afterUser' is the cache-cheap, lower-salience alternative.
578
+ */
579
+ instructions?: boolean | {
580
+ /** Workspace path "<mountName>/<relativePath>". Default "instructions/AGENTS.md". */
581
+ path?: string;
582
+ /**
583
+ * Heading line prepended to the injected block.
584
+ * Default "# Shared operating instructions (live document)".
585
+ */
586
+ header?: string;
587
+ /**
588
+ * Truncate content beyond this many bytes, appending a
589
+ * "[truncated: first N of M bytes]" marker (N may sit up to 3 bytes
590
+ * under the cap when a multibyte character straddles it). At most this
591
+ * many bytes are ever read from disk. Default 32768.
592
+ */
593
+ maxBytes?: number;
594
+ /** Where the block lands: 'system' (default) | 'beforeUser' | 'afterUser'. */
595
+ position?: 'system' | 'beforeUser' | 'afterUser';
596
+ };
430
597
  /**
431
598
  * Surface agent composition activity (typing indicators) to one or more
432
599
  * MCPL channels while inference is active. Opt-in per recipe; channel IDs
@@ -685,6 +852,41 @@ export interface RecipeCodeExecution {
685
852
  idleReclaimMs?: number;
686
853
  }
687
854
 
855
+ /**
856
+ * Per-channel conversation routing (agent-framework ConversationRouter):
857
+ * the recipe's agent becomes a dormant "trunk" template, and qualifying
858
+ * incoming channel messages spawn per-channel fork agents seeded from the
859
+ * trunk's current context. The host fills in what the framework needs but a
860
+ * recipe can't say: `templateAgent` is always the recipe's own agent, and
861
+ * `strategyFactory` builds a fresh instance of the recipe's `agent.strategy`
862
+ * per fork (strategy instances are stateful and must never be shared).
863
+ */
864
+ export interface RecipeConversations {
865
+ /** When an unbound channel acquires a fork.
866
+ * Defaults: dm 'always', groupDm 'always', channel 'mention'. */
867
+ bind?: {
868
+ dm?: 'always' | 'mention' | 'never';
869
+ groupDm?: 'always' | 'mention' | 'never';
870
+ channel?: 'always' | 'mention' | 'never';
871
+ };
872
+ /** When a message on a bound channel triggers inference (it always lands
873
+ * in the fork's context regardless).
874
+ * Defaults: dm 'always', groupDm 'mention', channel 'mention'. */
875
+ trigger?: {
876
+ dm?: 'always' | 'mention';
877
+ groupDm?: 'always' | 'mention';
878
+ channel?: 'always' | 'mention';
879
+ };
880
+ /** Idle time before a binding expires and the fork runs its closure turn.
881
+ * Default 12h. */
882
+ idleTtlMs?: number;
883
+ /** Final system-initiated user message sent to a fork on expiry. */
884
+ closurePrompt?: string;
885
+ /** Prefix for generated fork agent names (default 'conversation'). Also
886
+ * the Chronicle namespace segment, so it is restricted to [A-Za-z0-9_-]. */
887
+ agentPrefix?: string;
888
+ }
889
+
688
890
  export interface Recipe {
689
891
  name: string;
690
892
  description?: string;
@@ -697,6 +899,8 @@ export interface Recipe {
697
899
  sessionNaming?: { examples?: string[] };
698
900
  /** Client-side programmatic tool calling (code_execution tool). */
699
901
  codeExecution?: RecipeCodeExecution;
902
+ /** Per-channel conversation routing — fork-per-channel from this agent. */
903
+ conversations?: RecipeConversations;
700
904
  }
701
905
 
702
906
  // ---------------------------------------------------------------------------
@@ -718,7 +922,7 @@ export const DEFAULT_RECIPE: Recipe = {
718
922
  },
719
923
  modules: {
720
924
  // subagents + lessons + retrieval deliberately omitted — all opt-in only
721
- // (retrieval additionally adds per-turn context churn + Haiku costs);
925
+ // (retrieval additionally adds per-turn context churn + retrieval-model costs);
722
926
  // see the RecipeModules field docs.
723
927
  wake: true,
724
928
  workspace: true,
@@ -798,6 +1002,27 @@ export function substituteEnvVars(value: unknown, source: string): unknown {
798
1002
  */
799
1003
  type RecipeSourceBase = { kind: 'file'; dir: string } | { kind: 'url'; base: string };
800
1004
 
1005
+ /**
1006
+ * A loaded recipe plus the form of it that is safe to persist.
1007
+ *
1008
+ * `recipe` is fully resolved: `${VAR}` env references substituted, relative
1009
+ * child/extension paths made absolute, URL systemPrompt fetched. It is what
1010
+ * the running host consumes — and it can contain secrets (API tokens pulled
1011
+ * from the environment), so it must never be written to disk.
1012
+ *
1013
+ * `persistable` is the raw pre-substitution recipe JSON with only the
1014
+ * source-relative paths (`modules.fleet.children[].recipe`,
1015
+ * `extensions[*].path`) resolved to their final absolute form — those need
1016
+ * the original source base, which a resumed session no longer has. Every
1017
+ * `${VAR}` reference and any URL systemPrompt stay unresolved, so the file
1018
+ * `saveRecipe` writes carries no secret material and re-resolves against the
1019
+ * *current* environment on resume.
1020
+ */
1021
+ export interface LoadedRecipe {
1022
+ recipe: Recipe;
1023
+ persistable: Record<string, unknown>;
1024
+ }
1025
+
801
1026
  /**
802
1027
  * Load a recipe from a URL or local file path.
803
1028
  * If the systemPrompt value is an HTTP(S) URL, fetches the text.
@@ -807,6 +1032,15 @@ type RecipeSourceBase = { kind: 'file'; dir: string } | { kind: 'url'; base: str
807
1032
  * parent recipe's directory (or URL base) so sibling recipes are portable.
808
1033
  */
809
1034
  export async function loadRecipe(source: string): Promise<Recipe> {
1035
+ return (await loadRecipeDetailed(source)).recipe;
1036
+ }
1037
+
1038
+ /**
1039
+ * Like loadRecipe, but also returns the persistable (unresolved) form —
1040
+ * see LoadedRecipe. Callers that snapshot the recipe to disk (index.ts's
1041
+ * resolveRecipe) must save `persistable`, never `recipe`.
1042
+ */
1043
+ export async function loadRecipeDetailed(source: string): Promise<LoadedRecipe> {
810
1044
  let raw: unknown;
811
1045
  let sourceBase: RecipeSourceBase;
812
1046
 
@@ -822,11 +1056,54 @@ export async function loadRecipe(source: string): Promise<Recipe> {
822
1056
  sourceBase = { kind: 'file', dir: dirname(path) };
823
1057
  }
824
1058
 
1059
+ // Snapshot the pre-substitution form before substituteEnvVars walks the
1060
+ // object — this is what gets persisted, so resolved secrets never do.
1061
+ const persistable = structuredClone(raw) as Record<string, unknown>;
1062
+
825
1063
  raw = substituteEnvVars(raw, source);
826
1064
  const recipe = validateRecipe(raw);
827
1065
  resolveChildRecipePaths(recipe, sourceBase);
828
1066
  resolveExtensionPaths(recipe, sourceBase);
829
- return resolveSystemPrompt(recipe);
1067
+ const resolved = await resolveSystemPrompt(recipe);
1068
+ copyResolvedPathsIntoRaw(persistable, resolved);
1069
+ return { recipe: resolved, persistable };
1070
+ }
1071
+
1072
+ /**
1073
+ * Copy the resolved `modules.fleet.children[].recipe` and
1074
+ * `extensions[*].path` values from the resolved recipe into the raw
1075
+ * pre-substitution snapshot. Those fields are resolved against the recipe's
1076
+ * original source base (its directory or URL), which is gone by resume time
1077
+ * — so the persisted form must carry them already-absolute. Substitution
1078
+ * never changes object shape (it is a per-string replacement), so the two
1079
+ * trees align index-for-index and key-for-key. Paths are not treated as
1080
+ * secret: an env value that was interpolated into a child-recipe or
1081
+ * extension path IS persisted in resolved form.
1082
+ */
1083
+ function copyResolvedPathsIntoRaw(raw: Record<string, unknown>, recipe: Recipe): void {
1084
+ const fleet = recipe.modules?.fleet;
1085
+ const resolvedChildren = (typeof fleet === 'object' && fleet !== null) ? fleet.children : undefined;
1086
+ if (Array.isArray(resolvedChildren)) {
1087
+ const rawModules = raw.modules as Record<string, unknown> | undefined;
1088
+ const rawFleet = rawModules?.fleet as Record<string, unknown> | undefined;
1089
+ const rawChildren = (typeof rawFleet === 'object' && rawFleet !== null) ? rawFleet.children : undefined;
1090
+ if (Array.isArray(rawChildren)) {
1091
+ for (let i = 0; i < rawChildren.length && i < resolvedChildren.length; i++) {
1092
+ const rawChild = rawChildren[i] as Record<string, unknown> | null;
1093
+ if (rawChild && typeof rawChild === 'object' && typeof resolvedChildren[i]?.recipe === 'string') {
1094
+ rawChild.recipe = resolvedChildren[i].recipe;
1095
+ }
1096
+ }
1097
+ }
1098
+ }
1099
+
1100
+ const rawExtensions = raw.extensions as Record<string, unknown> | undefined;
1101
+ for (const [name, ext] of Object.entries(recipe.extensions ?? {})) {
1102
+ const rawExt = rawExtensions?.[name] as Record<string, unknown> | undefined;
1103
+ if (rawExt && typeof rawExt === 'object') {
1104
+ rawExt.path = ext.path;
1105
+ }
1106
+ }
830
1107
  }
831
1108
 
832
1109
  /**
@@ -888,6 +1165,87 @@ async function resolveSystemPrompt(recipe: Recipe): Promise<Recipe> {
888
1165
  return recipe;
889
1166
  }
890
1167
 
1168
+ function validateKvUnifiedConfig(strategy: Record<string, unknown>): void {
1169
+ const selected = strategy.foldingStrategy === 'kv-unified';
1170
+ const raw = strategy.kvUnified;
1171
+ if (!selected) {
1172
+ if (raw !== undefined) {
1173
+ throw new Error('Recipe agent.strategy.kvUnified requires foldingStrategy "kv-unified".');
1174
+ }
1175
+ return;
1176
+ }
1177
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
1178
+ throw new Error(
1179
+ 'Recipe foldingStrategy "kv-unified" requires a complete agent.strategy.kvUnified object; defaults are forbidden.',
1180
+ );
1181
+ }
1182
+ const config = raw as Record<string, unknown>;
1183
+ if (!config.policy || typeof config.policy !== 'object' || Array.isArray(config.policy)) {
1184
+ throw new Error('Recipe agent.strategy.kvUnified.policy must be a complete object.');
1185
+ }
1186
+ const policy = config.policy as Record<string, unknown>;
1187
+ const policyNumbers = [
1188
+ 'alpha', 'budgetLowRatio', 'budgetHighRatio', 'budgetUnderLambda',
1189
+ 'budgetOverLambda', 'cacheLambda', 'cacheScale', 'cacheReadPrice',
1190
+ 'cacheWritePrice', 'continuityLambda', 'continuityScale',
1191
+ 'continuityRecencyHalfLifeTokens', 'continuityRecencyFloor',
1192
+ 'continuityStableHalfLife', 'continuityStableFloor',
1193
+ ] as const;
1194
+ for (const key of policyNumbers) {
1195
+ if (typeof policy[key] !== 'number' || !Number.isFinite(policy[key])) {
1196
+ throw new Error(`Recipe agent.strategy.kvUnified.policy.${key} must be a finite number.`);
1197
+ }
1198
+ }
1199
+ const nonNegative = [
1200
+ 'alpha', 'budgetUnderLambda', 'budgetOverLambda', 'cacheLambda',
1201
+ 'cacheReadPrice', 'cacheWritePrice', 'continuityLambda',
1202
+ ] as const;
1203
+ for (const key of nonNegative) {
1204
+ if ((policy[key] as number) < 0) {
1205
+ throw new Error(`Recipe agent.strategy.kvUnified.policy.${key} must be non-negative.`);
1206
+ }
1207
+ }
1208
+ for (const key of [
1209
+ 'cacheScale', 'continuityScale', 'continuityRecencyHalfLifeTokens',
1210
+ 'continuityStableHalfLife',
1211
+ ] as const) {
1212
+ if ((policy[key] as number) <= 0) {
1213
+ throw new Error(`Recipe agent.strategy.kvUnified.policy.${key} must be positive.`);
1214
+ }
1215
+ }
1216
+ const low = policy.budgetLowRatio as number;
1217
+ const high = policy.budgetHighRatio as number;
1218
+ if (low < 0 || high > 1 || low > high) {
1219
+ throw new Error('Recipe kvUnified budget ratios must satisfy 0 <= low <= high <= 1.');
1220
+ }
1221
+ for (const key of ['continuityRecencyFloor', 'continuityStableFloor'] as const) {
1222
+ const value = policy[key] as number;
1223
+ if (value < 0 || value > 1) {
1224
+ throw new Error(`Recipe agent.strategy.kvUnified.policy.${key} must be in [0, 1].`);
1225
+ }
1226
+ }
1227
+ for (const key of [
1228
+ 'tokenBucketSize', 'continuityBucketSize', 'fidelityBucketSize', 'labelCeiling',
1229
+ ] as const) {
1230
+ const value = config[key];
1231
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) {
1232
+ throw new Error(`Recipe agent.strategy.kvUnified.${key} must be a positive safe integer.`);
1233
+ }
1234
+ }
1235
+ if (
1236
+ typeof config.adoptEpsilon !== 'number' ||
1237
+ !Number.isFinite(config.adoptEpsilon) ||
1238
+ config.adoptEpsilon < 0
1239
+ ) {
1240
+ throw new Error('Recipe agent.strategy.kvUnified.adoptEpsilon must be a finite non-negative number.');
1241
+ }
1242
+ if (typeof config.treeifyNonContiguousSummaries !== 'boolean') {
1243
+ throw new Error(
1244
+ 'Recipe agent.strategy.kvUnified.treeifyNonContiguousSummaries must be an explicit boolean.',
1245
+ );
1246
+ }
1247
+ }
1248
+
891
1249
  /**
892
1250
  * Validate raw JSON and fill defaults.
893
1251
  */
@@ -903,8 +1261,13 @@ export function validateRecipe(raw: unknown): Recipe {
903
1261
  }
904
1262
 
905
1263
  const agent = obj.agent as Record<string, unknown>;
906
- if (typeof agent.systemPrompt !== 'string' || !agent.systemPrompt) {
907
- throw new Error('Recipe agent must have a "systemPrompt" string');
1264
+ // Absent or empty systemPrompt is a valid configuration: '' is dropped at
1265
+ // the provider boundary (membrane omits falsy `system`), so the wire
1266
+ // request carries no system block at all.
1267
+ if (agent.systemPrompt === undefined || agent.systemPrompt === null) {
1268
+ agent.systemPrompt = '';
1269
+ } else if (typeof agent.systemPrompt !== 'string') {
1270
+ throw new Error('Recipe agent "systemPrompt" must be a string when present');
908
1271
  }
909
1272
 
910
1273
  if (agent.provider !== undefined &&
@@ -912,13 +1275,25 @@ export function validateRecipe(raw: unknown): Recipe {
912
1275
  agent.provider !== 'openai-responses' &&
913
1276
  agent.provider !== 'openai-codex' &&
914
1277
  agent.provider !== 'openrouter' &&
915
- agent.provider !== 'bedrock') {
1278
+ agent.provider !== 'bedrock' &&
1279
+ agent.provider !== 'openai-compatible' &&
1280
+ agent.provider !== 'mock') {
916
1281
  throw new Error(
917
- `Recipe agent.provider must be 'anthropic', 'openai-responses', 'openai-codex', 'openrouter', or 'bedrock', ` +
1282
+ `Recipe agent.provider must be 'anthropic', 'openai-responses', 'openai-codex', 'openrouter', 'bedrock', 'openai-compatible', or 'mock', ` +
918
1283
  `got ${JSON.stringify(agent.provider)}.`,
919
1284
  );
920
1285
  }
921
1286
 
1287
+ if (
1288
+ agent.proseRouting !== undefined &&
1289
+ agent.proseRouting !== 'locus' &&
1290
+ agent.proseRouting !== 'explicit' &&
1291
+ agent.proseRouting !== 'hybrid' &&
1292
+ agent.proseRouting !== 'disabled'
1293
+ ) {
1294
+ throw new Error(`Recipe agent.proseRouting must be 'locus', 'explicit', 'hybrid', or 'disabled', got ${JSON.stringify(agent.proseRouting)}.`);
1295
+ }
1296
+
922
1297
  if (agent.timezone !== undefined) {
923
1298
  if (typeof agent.timezone !== 'string' || !agent.timezone.trim()) {
924
1299
  throw new Error('Recipe agent.timezone must be a non-empty IANA time zone string.');
@@ -965,6 +1340,20 @@ export function validateRecipe(raw: unknown): Recipe {
965
1340
  }
966
1341
  }
967
1342
 
1343
+ if (agent.mock !== undefined) {
1344
+ if (!agent.mock || typeof agent.mock !== 'object' || Array.isArray(agent.mock)) {
1345
+ throw new Error('Recipe agent.mock must be an object.');
1346
+ }
1347
+ const mock = agent.mock as Record<string, unknown>;
1348
+ if (mock.echoMode !== undefined && typeof mock.echoMode !== 'boolean') {
1349
+ throw new Error('Recipe agent.mock.echoMode must be a boolean.');
1350
+ }
1351
+ if (mock.defaultResponse !== undefined &&
1352
+ (typeof mock.defaultResponse !== 'string' || !mock.defaultResponse.trim())) {
1353
+ throw new Error('Recipe agent.mock.defaultResponse must be a non-empty string.');
1354
+ }
1355
+ }
1356
+
968
1357
  if (agent.maxStreamTokens !== undefined && (typeof agent.maxStreamTokens !== 'number' || agent.maxStreamTokens <= 0)) {
969
1358
  throw new Error('Recipe agent.maxStreamTokens must be a positive number.');
970
1359
  }
@@ -976,6 +1365,63 @@ export function validateRecipe(raw: unknown): Recipe {
976
1365
  }
977
1366
  agent.cacheTtl ??= '1h';
978
1367
 
1368
+ // openai-compatible: an endpoint the host knows nothing about, so the recipe
1369
+ // must say where it is and which model to ask for. Fail at load time, not as
1370
+ // a fetch to 'undefined/chat/completions' at first inference.
1371
+ if (agent.provider === 'openai-compatible') {
1372
+ if (typeof agent.baseUrl !== 'string' || !agent.baseUrl.trim()) {
1373
+ throw new Error("Recipe agent.baseUrl is required when agent.provider is 'openai-compatible' (e.g. \"http://localhost:11434/v1\").");
1374
+ }
1375
+ let parsed: URL;
1376
+ try {
1377
+ parsed = new URL(agent.baseUrl);
1378
+ } catch {
1379
+ throw new Error(`Recipe agent.baseUrl must be an absolute http(s) URL, got ${JSON.stringify(agent.baseUrl)}.`);
1380
+ }
1381
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
1382
+ throw new Error(`Recipe agent.baseUrl must use http or https, got ${JSON.stringify(agent.baseUrl)}.`);
1383
+ }
1384
+ if (typeof agent.model !== 'string' || !agent.model.trim()) {
1385
+ throw new Error("Recipe agent.model is required when agent.provider is 'openai-compatible' (no default model for an arbitrary endpoint).");
1386
+ }
1387
+ } else if (agent.baseUrl !== undefined) {
1388
+ throw new Error(
1389
+ `Recipe agent.baseUrl only applies to agent.provider 'openai-compatible' (got provider ${JSON.stringify(agent.provider ?? 'anthropic')}); ` +
1390
+ 'other providers take their endpoint from ANTHROPIC_BASE_URL / OPENAI_BASE_URL / BEDROCK_BASE_URL.',
1391
+ );
1392
+ }
1393
+
1394
+ // A keepalive that fires AFTER the entry has already expired is the worst of
1395
+ // both worlds: it pays a full 2x cache write on every poke, forever, and
1396
+ // reports success while doing it. Refuse the config rather than discover it
1397
+ // in a bill. (The runtime also self-checks — see membrane cache-keepalive —
1398
+ // but a typo'd recipe should never get that far.)
1399
+ const keepalive = agent.cacheKeepalive;
1400
+ if (keepalive !== undefined) {
1401
+ if (typeof keepalive !== 'object' || keepalive === null) {
1402
+ throw new Error(`Recipe agent.cacheKeepalive must be an object, got ${JSON.stringify(keepalive)}.`);
1403
+ }
1404
+ const { maxIdleHours, refreshAfterMinutes } = keepalive as {
1405
+ maxIdleHours?: unknown;
1406
+ refreshAfterMinutes?: unknown;
1407
+ };
1408
+ if (refreshAfterMinutes !== undefined) {
1409
+ if (typeof refreshAfterMinutes !== 'number' || !(refreshAfterMinutes > 0)) {
1410
+ throw new Error(`Recipe agent.cacheKeepalive.refreshAfterMinutes must be a positive number, got ${JSON.stringify(refreshAfterMinutes)}.`);
1411
+ }
1412
+ const ttlMinutes = agent.cacheTtl === '1h' ? 60 : 5;
1413
+ if (refreshAfterMinutes >= ttlMinutes) {
1414
+ throw new Error(
1415
+ `Recipe agent.cacheKeepalive.refreshAfterMinutes (${refreshAfterMinutes}) must be less than the ${agent.cacheTtl} cache TTL (${ttlMinutes}m), ` +
1416
+ 'or every keepalive would land after the entry expired and pay a full cache write instead of a read.',
1417
+ );
1418
+ }
1419
+ }
1420
+ if (maxIdleHours !== undefined && (typeof maxIdleHours !== 'number' || !(maxIdleHours > 0))) {
1421
+ throw new Error(`Recipe agent.cacheKeepalive.maxIdleHours must be a positive number, got ${JSON.stringify(maxIdleHours)}.`);
1422
+ }
1423
+ }
1424
+
979
1425
  if (agent.promptCaching !== undefined && typeof agent.promptCaching !== 'boolean') {
980
1426
  throw new Error(
981
1427
  `Recipe agent.promptCaching must be a boolean, got ${JSON.stringify(agent.promptCaching)}.`,
@@ -1069,6 +1515,21 @@ export function validateRecipe(raw: unknown): Recipe {
1069
1515
  `recipe's "extensions" block (none is declared).`,
1070
1516
  );
1071
1517
  }
1518
+ if (
1519
+ strategy.foldingStrategy !== undefined &&
1520
+ strategy.foldingStrategy !== 'flat-profile' &&
1521
+ strategy.foldingStrategy !== 'oldest-first' &&
1522
+ strategy.foldingStrategy !== 'kv-stable' &&
1523
+ strategy.foldingStrategy !== 'kv-unified'
1524
+ ) {
1525
+ throw new Error(
1526
+ `Recipe agent.strategy.foldingStrategy is invalid: ${JSON.stringify(strategy.foldingStrategy)}.`,
1527
+ );
1528
+ }
1529
+ if (strategy.foldingStrategy === 'kv-unified' && strategy.type === 'passthrough') {
1530
+ throw new Error('Recipe foldingStrategy "kv-unified" requires an autobiographical or frontdesk strategy.');
1531
+ }
1532
+ validateKvUnifiedConfig(strategy);
1072
1533
  if (
1073
1534
  strategy.compressionRefusalCurveFallbacks !== undefined
1074
1535
  && (
@@ -1089,6 +1550,26 @@ export function validateRecipe(raw: unknown): Recipe {
1089
1550
  ) {
1090
1551
  throw new Error('Recipe agent.strategy.compressionContextBudgetTokens must be a positive safe integer.');
1091
1552
  }
1553
+ for (const key of [
1554
+ 'compressionSourceOnly',
1555
+ 'compressionSourceOnlyFallback',
1556
+ 'compressionMergeSourceOnly',
1557
+ 'compressionMergeSourceOnlyFallback',
1558
+ ] as const) {
1559
+ if (strategy[key] !== undefined && typeof strategy[key] !== 'boolean') {
1560
+ throw new Error(`Recipe agent.strategy.${key} must be a boolean.`);
1561
+ }
1562
+ }
1563
+ if (
1564
+ strategy.compressionRecallBudgetTokens !== undefined
1565
+ && (
1566
+ typeof strategy.compressionRecallBudgetTokens !== 'number'
1567
+ || !Number.isSafeInteger(strategy.compressionRecallBudgetTokens)
1568
+ || strategy.compressionRecallBudgetTokens <= 0
1569
+ )
1570
+ ) {
1571
+ throw new Error('Recipe agent.strategy.compressionRecallBudgetTokens must be a positive safe integer.');
1572
+ }
1092
1573
  }
1093
1574
 
1094
1575
  const refusalHandling = agent.refusalHandling as Record<string, unknown> | undefined;
@@ -1116,8 +1597,16 @@ export function validateRecipe(raw: unknown): Recipe {
1116
1597
  throw new Error(`mcpServers.${id}.source must be an object`);
1117
1598
  }
1118
1599
  const src = server.source as Record<string, unknown>;
1119
- if (typeof src.url !== 'string' || !src.url) {
1120
- throw new Error(`mcpServers.${id}.source.url must be a non-empty string`);
1600
+ const hasSrcUrl = typeof src.url === 'string' && src.url;
1601
+ const hasSrcNpm = typeof src.npm === 'string' && src.npm;
1602
+ if (hasSrcUrl && hasSrcNpm) {
1603
+ throw new Error(`mcpServers.${id}.source must not set both "url" and "npm"`);
1604
+ }
1605
+ if (!hasSrcUrl && !hasSrcNpm) {
1606
+ throw new Error(
1607
+ `mcpServers.${id}.source must have a non-empty "url" (git clone) ` +
1608
+ `or "npm" (registry package spec) string`,
1609
+ );
1121
1610
  }
1122
1611
  if (src.ref !== undefined && typeof src.ref !== 'string') {
1123
1612
  throw new Error(`mcpServers.${id}.source.ref must be a string`);
@@ -1255,6 +1744,159 @@ export function validateRecipe(raw: unknown): Recipe {
1255
1744
  }
1256
1745
  }
1257
1746
 
1747
+ // Validate instructions if present. It reads through a workspace mount,
1748
+ // so pairing it with `workspace: false` is a config contradiction — fail
1749
+ // at load, not with a silent no-injection at runtime.
1750
+ if (mods.instructions !== undefined && mods.instructions !== false) {
1751
+ if (mods.instructions !== true
1752
+ && (typeof mods.instructions !== 'object' || Array.isArray(mods.instructions))) {
1753
+ throw new Error('modules.instructions must be a boolean or object');
1754
+ }
1755
+ if (mods.workspace === false) {
1756
+ throw new Error(
1757
+ 'modules.instructions requires modules.workspace: the instructions file is ' +
1758
+ 'read through a workspace mount, but this recipe sets workspace: false.',
1759
+ );
1760
+ }
1761
+ if (typeof mods.instructions === 'object') {
1762
+ const ins = mods.instructions as Record<string, unknown>;
1763
+ const allowedInstructionKeys = new Set(['path', 'header', 'maxBytes', 'position']);
1764
+ for (const key of Object.keys(ins)) {
1765
+ if (!allowedInstructionKeys.has(key)) {
1766
+ throw new Error(
1767
+ `modules.instructions has unknown field ${JSON.stringify(key)} ` +
1768
+ `(expected one of: ${[...allowedInstructionKeys].join(', ')}).`,
1769
+ );
1770
+ }
1771
+ }
1772
+ if (ins.path !== undefined) {
1773
+ if (typeof ins.path !== 'string' || !ins.path.trim()) {
1774
+ throw new Error('modules.instructions.path must be a non-empty string');
1775
+ }
1776
+ const slashIdx = ins.path.indexOf('/');
1777
+ if (slashIdx <= 0 || slashIdx === ins.path.length - 1) {
1778
+ throw new Error(
1779
+ 'modules.instructions.path must be a workspace path of the form ' +
1780
+ `"<mountName>/<relativePath>", got ${JSON.stringify(ins.path)}.`,
1781
+ );
1782
+ }
1783
+ }
1784
+ if (ins.header !== undefined && typeof ins.header !== 'string') {
1785
+ throw new Error('modules.instructions.header must be a string');
1786
+ }
1787
+ if (ins.maxBytes !== undefined
1788
+ && (typeof ins.maxBytes !== 'number' || !Number.isInteger(ins.maxBytes) || ins.maxBytes <= 0)) {
1789
+ throw new Error('modules.instructions.maxBytes must be a positive integer');
1790
+ }
1791
+ if (ins.position !== undefined
1792
+ && ins.position !== 'system' && ins.position !== 'beforeUser' && ins.position !== 'afterUser') {
1793
+ throw new Error(
1794
+ `modules.instructions.position must be 'system', 'beforeUser', or 'afterUser', ` +
1795
+ `got ${JSON.stringify(ins.position)}.`,
1796
+ );
1797
+ }
1798
+ }
1799
+
1800
+ // The mount the instructions path names is knowable at load time in
1801
+ // EVERY configuration — explicit mounts from the declaration, the
1802
+ // implicit default workspace from the fixed input/products pair the
1803
+ // host builds — so a typo, the default "instructions/…" path with no
1804
+ // matching mount, or `instructions: true` on the implicit workspace
1805
+ // (whose mount set can never contain "instructions") all fail here
1806
+ // instead of as a silent no-injection at runtime.
1807
+ //
1808
+ // A read-write instructions mount additionally requires
1809
+ // autoMaterialize: workspace write/edit are Chronicle-first and reach
1810
+ // disk only when the mount materializes, while this module reads disk
1811
+ // — without it, an agent's own curation edits would never appear in
1812
+ // the injection (and its workspace read would show the new content,
1813
+ // hiding the drift entirely). Read-only mounts are exempt: disk is
1814
+ // their only write path.
1815
+ {
1816
+ const effectivePath =
1817
+ typeof mods.instructions === 'object'
1818
+ && typeof (mods.instructions as { path?: unknown }).path === 'string'
1819
+ ? (mods.instructions as { path: string }).path
1820
+ : 'instructions/AGENTS.md'; // keep in sync with DEFAULT_INSTRUCTIONS_PATH
1821
+ const mountName = effectivePath.slice(0, effectivePath.indexOf('/'));
1822
+ // Reason over the SAME mount construction the runtime uses — one
1823
+ // builder, no validator-vs-host drift (the storePath placeholder is
1824
+ // irrelevant here; only names/modes/flags are consulted).
1825
+ const declaredExplicitly =
1826
+ !!mods.workspace && typeof mods.workspace === 'object' &&
1827
+ !!(mods.workspace as { mounts?: unknown }).mounts;
1828
+ const effectiveMounts =
1829
+ buildWorkspaceMounts(mods.workspace as RecipeModules['workspace'], '.') ?? [];
1830
+ const match = effectiveMounts.find((m) => m.name === mountName);
1831
+ if (!match) {
1832
+ throw new Error(
1833
+ `modules.instructions.path ${JSON.stringify(effectivePath)} names workspace mount ` +
1834
+ `${JSON.stringify(mountName)}, but the ${declaredExplicitly ? 'declared' : 'implicit default'} ` +
1835
+ `mounts are: ${effectiveMounts.map((m) => JSON.stringify(m.name)).join(', ')}. ` +
1836
+ `(The default path "instructions/AGENTS.md" requires a workspace mount named ` +
1837
+ `"instructions" — declare one under modules.workspace.mounts.)`,
1838
+ );
1839
+ }
1840
+ if (match.name === '_config') {
1841
+ throw new Error(
1842
+ `modules.instructions.path ${JSON.stringify(effectivePath)} reads through the ` +
1843
+ `host-managed "_config" mount, which does NOT auto-materialize: agent edits stay ` +
1844
+ `Chronicle-side and only reach disk after branch-changing commands, so the ` +
1845
+ `instructions injection would silently serve stale content. Use a dedicated ` +
1846
+ `instructions mount instead.`,
1847
+ );
1848
+ }
1849
+ if (match.mode !== 'read-only' && match.autoMaterialize !== true) {
1850
+ throw new Error(
1851
+ `modules.instructions.path ${JSON.stringify(effectivePath)} uses read-write mount ` +
1852
+ `${JSON.stringify(mountName)} without autoMaterialize. Workspace writes are ` +
1853
+ `Chronicle-first and reach disk only when the mount materializes, while the ` +
1854
+ `instructions injection reads disk — agent edits to the file would silently never ` +
1855
+ `take effect. Set autoMaterialize: true on the mount` +
1856
+ `${declaredExplicitly ? '' : ' (the implicit default workspace cannot; declare explicit mounts)'}, ` +
1857
+ `or make the mount read-only if the file is maintained outside the agent.`,
1858
+ );
1859
+ }
1860
+ }
1861
+ }
1862
+
1863
+ // Validate retrieval provider reasoning when configured.
1864
+ const retrieval = mods.retrieval;
1865
+ if (retrieval !== undefined && typeof retrieval !== 'boolean') {
1866
+ if (!retrieval || typeof retrieval !== 'object' || Array.isArray(retrieval)) {
1867
+ throw new Error('Recipe modules.retrieval must be a boolean or object.');
1868
+ }
1869
+ const retrievalConfig = retrieval as Record<string, unknown>;
1870
+ if (retrievalConfig.reasoningContext !== undefined) {
1871
+ throw new Error(
1872
+ 'modules.retrieval.reasoningContext is not supported: retrieval model calls ' +
1873
+ 'are independent one-shot requests with no earlier reasoning items.',
1874
+ );
1875
+ }
1876
+ const efforts = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
1877
+ if (retrievalConfig.reasoningEffort !== undefined &&
1878
+ (typeof retrievalConfig.reasoningEffort !== 'string'
1879
+ || !efforts.includes(retrievalConfig.reasoningEffort))) {
1880
+ throw new Error(`Invalid modules.retrieval.reasoningEffort ${JSON.stringify(retrievalConfig.reasoningEffort)}.`);
1881
+ }
1882
+ if (retrievalConfig.reasoningEffort !== undefined
1883
+ && agent.provider !== 'openai-responses'
1884
+ && agent.provider !== 'openai-codex') {
1885
+ throw new Error(
1886
+ 'modules.retrieval.reasoningEffort requires agent.provider ' +
1887
+ '"openai-responses" or "openai-codex".',
1888
+ );
1889
+ }
1890
+ if (retrievalConfig.reasoningEffort !== undefined
1891
+ && (typeof retrievalConfig.model !== 'string'
1892
+ || !retrievalConfig.model.trim())) {
1893
+ throw new Error(
1894
+ 'modules.retrieval.model must be a non-empty string when ' +
1895
+ 'modules.retrieval.reasoningEffort is configured.',
1896
+ );
1897
+ }
1898
+ }
1899
+
1258
1900
  // Validate ttsRelay if present — url + token are load-bearing, and a
1259
1901
  // recipe that names the module but can't reach a relay should fail at
1260
1902
  // load, not silently stream nowhere.
@@ -1349,6 +1991,62 @@ export function validateRecipe(raw: unknown): Recipe {
1349
1991
  }
1350
1992
  }
1351
1993
 
1994
+ if (obj.conversations !== undefined) {
1995
+ if (!obj.conversations || typeof obj.conversations !== 'object' || Array.isArray(obj.conversations)) {
1996
+ throw new Error('Recipe conversations must be an object.');
1997
+ }
1998
+ const conv = obj.conversations as Record<string, unknown>;
1999
+ const allowedConversationKeys = new Set(['bind', 'trigger', 'idleTtlMs', 'closurePrompt', 'agentPrefix']);
2000
+ for (const key of Object.keys(conv)) {
2001
+ if (!allowedConversationKeys.has(key)) {
2002
+ throw new Error(
2003
+ `Recipe conversations has unknown field ${JSON.stringify(key)} ` +
2004
+ `(expected one of: ${[...allowedConversationKeys].join(', ')}).`,
2005
+ );
2006
+ }
2007
+ }
2008
+ const kinds = ['dm', 'groupDm', 'channel'] as const;
2009
+ for (const [field, allowed] of [
2010
+ ['bind', ['always', 'mention', 'never']],
2011
+ ['trigger', ['always', 'mention']],
2012
+ ] as Array<[string, string[]]>) {
2013
+ const rules = conv[field];
2014
+ if (rules === undefined) continue;
2015
+ if (!rules || typeof rules !== 'object' || Array.isArray(rules)) {
2016
+ throw new Error(`Recipe conversations.${field} must be an object.`);
2017
+ }
2018
+ for (const [kind, rule] of Object.entries(rules as Record<string, unknown>)) {
2019
+ if (!(kinds as readonly string[]).includes(kind)) {
2020
+ throw new Error(
2021
+ `Recipe conversations.${field} has unknown channel kind ${JSON.stringify(kind)} ` +
2022
+ `(expected one of: ${kinds.join(', ')}).`,
2023
+ );
2024
+ }
2025
+ if (typeof rule !== 'string' || !allowed.includes(rule)) {
2026
+ throw new Error(
2027
+ `Recipe conversations.${field}.${kind} must be one of ${allowed.map(r => `'${r}'`).join(', ')}, ` +
2028
+ `got ${JSON.stringify(rule)}.`,
2029
+ );
2030
+ }
2031
+ }
2032
+ }
2033
+ if (conv.idleTtlMs !== undefined &&
2034
+ (typeof conv.idleTtlMs !== 'number' || !Number.isFinite(conv.idleTtlMs) ||
2035
+ !Number.isInteger(conv.idleTtlMs) || conv.idleTtlMs <= 0)) {
2036
+ throw new Error('Recipe conversations.idleTtlMs must be a positive finite integer.');
2037
+ }
2038
+ if (conv.closurePrompt !== undefined && (typeof conv.closurePrompt !== 'string' || !conv.closurePrompt.trim())) {
2039
+ throw new Error('Recipe conversations.closurePrompt must be a non-empty string.');
2040
+ }
2041
+ if (conv.agentPrefix !== undefined &&
2042
+ (typeof conv.agentPrefix !== 'string' || !/^[A-Za-z0-9_-]+$/.test(conv.agentPrefix))) {
2043
+ throw new Error(
2044
+ 'Recipe conversations.agentPrefix must be a non-empty string of [A-Za-z0-9_-] ' +
2045
+ '(it names fork agents and their Chronicle namespaces).',
2046
+ );
2047
+ }
2048
+ }
2049
+
1352
2050
  return obj as unknown as Recipe;
1353
2051
  }
1354
2052
 
@@ -1360,20 +2058,80 @@ function savedRecipePath(dataDir: string): string {
1360
2058
  return resolve(dataDir, '.recipe.json');
1361
2059
  }
1362
2060
 
1363
- export function saveRecipe(dataDir: string, recipe: Recipe): void {
2061
+ /**
2062
+ * Marker key stamped into saved `.recipe.json` snapshots that were written in
2063
+ * unresolved form (post-fix). Its presence tells loadSavedRecipe to run the
2064
+ * full substitute-and-validate pipeline on read; its absence means a legacy
2065
+ * snapshot saved fully resolved, which must be loaded verbatim — running
2066
+ * substitution over legacy content could hard-fail on a literal `${...}`
2067
+ * that survived in prose (e.g. a systemPrompt documenting env-var syntax).
2068
+ */
2069
+ export const SAVED_RECIPE_UNRESOLVED_KEY = '$unresolved';
2070
+
2071
+ /**
2072
+ * Snapshot a recipe to `$DATA_DIR/.recipe.json` so a later run without a
2073
+ * recipe argument resumes the same configuration.
2074
+ *
2075
+ * Pass the `persistable` half of loadRecipeDetailed(), NOT the resolved
2076
+ * recipe: `$DATA_DIR` is typically host-mounted and backed up, so the file
2077
+ * must never contain substituted secrets. The snapshot keeps `${VAR}`
2078
+ * references (and any URL systemPrompt) unresolved; loadSavedRecipe
2079
+ * re-resolves them against the environment current at resume time.
2080
+ *
2081
+ * The file is written 0600 and chmod'd to 0600 even when it already exists,
2082
+ * as defense in depth for legacy resolved snapshots being overwritten.
2083
+ */
2084
+ export function saveRecipe(dataDir: string, persistable: Record<string, unknown>): void {
1364
2085
  mkdirSync(dataDir, { recursive: true });
1365
- writeFileSync(savedRecipePath(dataDir), JSON.stringify(recipe, null, 2) + '\n', 'utf-8');
2086
+ const path = savedRecipePath(dataDir);
2087
+ const withMarker = { [SAVED_RECIPE_UNRESOLVED_KEY]: true, ...persistable };
2088
+ writeFileSync(path, JSON.stringify(withMarker, null, 2) + '\n', { encoding: 'utf-8', mode: 0o600 });
2089
+ // writeFileSync's mode only applies on creation — enforce on overwrite too.
2090
+ chmodSync(path, 0o600);
1366
2091
  }
1367
2092
 
1368
- export function loadSavedRecipe(dataDir: string): Recipe | null {
2093
+ /**
2094
+ * Load the `.recipe.json` snapshot for a resumed session, or null when there
2095
+ * is none (or it is unreadable/invalid).
2096
+ *
2097
+ * Snapshots carrying SAVED_RECIPE_UNRESOLVED_KEY are re-resolved through the
2098
+ * same pipeline loadRecipe uses — env substitution (so rotated secrets take
2099
+ * effect on restart), validation, and URL systemPrompt fetch. A missing
2100
+ * required `${VAR}` THROWS rather than returning null: silently falling back
2101
+ * to the default recipe would start a misconfigured agent, and the loud
2102
+ * failure names the variable to restore. A failed systemPrompt fetch throws
2103
+ * for the same reason.
2104
+ *
2105
+ * Legacy snapshots (no marker — saved fully resolved by older versions) are
2106
+ * validated and returned verbatim, exactly as before: no substitution, so a
2107
+ * literal `${...}` that survived resolution in prose cannot fail the load.
2108
+ */
2109
+ export async function loadSavedRecipe(dataDir: string): Promise<Recipe | null> {
1369
2110
  const path = savedRecipePath(dataDir);
1370
2111
  if (!existsSync(path)) return null;
2112
+ let raw: unknown;
1371
2113
  try {
1372
- const raw = JSON.parse(readFileSync(path, 'utf-8'));
1373
- return validateRecipe(raw);
2114
+ raw = JSON.parse(readFileSync(path, 'utf-8'));
1374
2115
  } catch {
1375
2116
  return null;
1376
2117
  }
2118
+
2119
+ if (!raw || typeof raw !== 'object' || !(SAVED_RECIPE_UNRESOLVED_KEY in raw)) {
2120
+ // Legacy resolved snapshot: load verbatim (no substitution).
2121
+ try {
2122
+ return validateRecipe(raw);
2123
+ } catch {
2124
+ return null;
2125
+ }
2126
+ }
2127
+
2128
+ const { [SAVED_RECIPE_UNRESOLVED_KEY]: _marker, ...unresolved } = raw as Record<string, unknown>;
2129
+ // Deliberately outside a try/catch: substitution and validation errors on
2130
+ // a snapshot we wrote ourselves are actionable operator errors (a rotated
2131
+ // secret removed from the environment), not corruption to shrug off.
2132
+ const substituted = substituteEnvVars(unresolved, path);
2133
+ const recipe = validateRecipe(substituted);
2134
+ return resolveSystemPrompt(recipe);
1377
2135
  }
1378
2136
 
1379
2137
  export function clearSavedRecipe(dataDir: string): void {