@animalabs/connectome-host 0.7.4 → 0.8.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 (67) 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 +320 -0
  7. package/CONTRIBUTING.md +47 -19
  8. package/README.md +27 -0
  9. package/bun.lock +26 -32
  10. package/changelog.d/README.md +28 -0
  11. package/package.json +6 -6
  12. package/recipes/SETUP.md +11 -5
  13. package/recipes/TRIUMVIRATE-SETUP.md +68 -14
  14. package/recipes/knowledge-miner.json +0 -30
  15. package/recipes/mock-test.json +19 -0
  16. package/recipes/triumvirate.json +6 -1
  17. package/scripts/release-changelog.ts +210 -21
  18. package/src/cache-keepalive-log.ts +41 -0
  19. package/src/commands.ts +221 -32
  20. package/src/framework-agent-config.ts +3 -0
  21. package/src/framework-strategy.ts +42 -0
  22. package/src/gate-telemetry.ts +134 -0
  23. package/src/headless.ts +10 -0
  24. package/src/index.ts +194 -55
  25. package/src/mcpl-config.ts +99 -1
  26. package/src/modules/identity-module.ts +310 -2
  27. package/src/modules/instructions-module.ts +265 -0
  28. package/src/modules/mcpl-admin-module.ts +58 -11
  29. package/src/modules/subagent-module.ts +18 -0
  30. package/src/modules/web-ui-module.ts +32 -4
  31. package/src/recipe.ts +821 -25
  32. package/src/web/panel-data.ts +44 -1
  33. package/src/workspace-mounts.ts +73 -0
  34. package/test/audit-module-optins.test.ts +10 -3
  35. package/test/cache-keepalive-log.test.ts +83 -0
  36. package/test/commands-qa-family.test.ts +239 -0
  37. package/test/conversations-recipe.test.ts +142 -0
  38. package/test/count-tokens-model.test.ts +31 -0
  39. package/test/framework-fkm-composition.test.ts +35 -3
  40. package/test/framework-strategy-defaults.test.ts +60 -0
  41. package/test/gate-telemetry-adapter.test.ts +84 -0
  42. package/test/gate-telemetry.test.ts +124 -0
  43. package/test/identity-and-surfaces.test.ts +212 -1
  44. package/test/instructions-module.test.ts +258 -0
  45. package/test/mcpl-admin-module.test.ts +41 -0
  46. package/test/mcpl-agent-overlay.test.ts +51 -3
  47. package/test/mcpl-child-env.test.ts +64 -0
  48. package/test/nudge-command.test.ts +47 -0
  49. package/test/recipe-cache-keepalive.test.ts +59 -0
  50. package/test/recipe-compression-fallback.test.ts +19 -0
  51. package/test/recipe-hybrid-prose-routing.test.ts +12 -0
  52. package/test/recipe-instructions.test.ts +176 -0
  53. package/test/recipe-kv-unified.test.ts +87 -0
  54. package/test/recipe-mcp-source.test.ts +54 -0
  55. package/test/recipe-openai-compatible.test.ts +54 -0
  56. package/test/recipe-path-resolution.test.ts +19 -8
  57. package/test/recipe-provider.test.ts +14 -0
  58. package/test/recipe-save-unresolved.test.ts +244 -0
  59. package/test/recipe-source-only.test.ts +38 -0
  60. package/test/release-changelog.test.ts +202 -0
  61. package/test/subagent-prose-routing.test.ts +109 -0
  62. package/test/subconscious-recipe.test.ts +86 -0
  63. package/test/tool-wrapper-prose-guard-recipe.test.ts +37 -0
  64. package/test/web-ui-module.test.ts +41 -0
  65. package/test/workspace-mounts.test.ts +68 -0
  66. package/web/src/App.tsx +10 -0
  67. package/web/src/Health.tsx +61 -1
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,38 @@ 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
+ /** Context Manager split-stitch L1 fallback rung (default off). */
66
+ compressionSplitFallback?: boolean;
67
+ /** Allow a single-message placeholder inside a split-stitched L1 (default off). */
68
+ compressionSplitPlaceholder?: boolean;
69
+ /** Split-stitch: max sub-calls per chunk (default 40). */
70
+ compressionSplitMaxCallsPerChunk?: number;
71
+ /** Split-stitch: max sub-calls per strategy instance per 10-minute in-memory window (default 80). */
72
+ compressionSplitMaxCallsPer10Min?: number;
73
+ /** Token budget for prior recall-pair context in compression/merge
74
+ * requests (Context Manager `compressionRecallBudgetTokens`). */
75
+ compressionRecallBudgetTokens?: number;
53
76
  positionedRecallPairs?: boolean;
54
77
  recallHeaderTemplate?: string;
55
78
  targetChunkTokens?: number;
56
79
  mergeThreshold?: number;
80
+ mergeMaxSourceSpanMessages?: number;
57
81
  summaryTargetTokens?: number;
82
+ /** Standing production target: keep the summary forest deep enough to fit
83
+ * this budget, enabling a later live-budget descent with no fold-storm and
84
+ * a single KV invalidation (see context-manager productionBudgetTokens). */
85
+ productionBudgetTokens?: number;
58
86
  l1BudgetTokens?: number;
59
87
  l2BudgetTokens?: number;
60
88
  l3BudgetTokens?: number;
@@ -74,7 +102,10 @@ export interface RecipeStrategy {
74
102
  /** Adaptive-resolution fold planner. The host defaults this to 'kv-stable'
75
103
  * (cache-stable compile plans; see buildFrameworkStrategy) — set explicitly
76
104
  * only to opt into the legacy planners. */
77
- foldingStrategy?: 'flat-profile' | 'oldest-first' | 'kv-stable';
105
+ foldingStrategy?: 'flat-profile' | 'oldest-first' | 'kv-stable' | 'kv-unified';
106
+ /** Complete fail-closed policy for the kv-unified solver. No live defaults
107
+ * are supplied: selecting kv-unified without every field is invalid. */
108
+ kvUnified?: RecipeKvUnifiedConfig;
78
109
  speculativeProduction?: boolean;
79
110
  /** L1 production holdback: keep the newest N closed chunks out of the
80
111
  * speculative compression queue (default 1); demand still overrides. */
@@ -102,13 +133,53 @@ export interface RecipeStrategy {
102
133
  identityReminder?: string;
103
134
  }
104
135
 
136
+ export interface RecipeKvUnifiedConfig {
137
+ policy: {
138
+ alpha: number;
139
+ budgetLowRatio: number;
140
+ budgetHighRatio: number;
141
+ budgetUnderLambda: number;
142
+ budgetOverLambda: number;
143
+ cacheLambda: number;
144
+ cacheScale: number;
145
+ cacheReadPrice: number;
146
+ cacheWritePrice: number;
147
+ continuityLambda: number;
148
+ continuityScale: number;
149
+ continuityRecencyHalfLifeTokens: number;
150
+ continuityRecencyFloor: number;
151
+ continuityStableHalfLife: number;
152
+ continuityStableFloor: number;
153
+ };
154
+ tokenBucketSize: number;
155
+ continuityBucketSize: number;
156
+ fidelityBucketSize: number;
157
+ labelCeiling: number;
158
+ adoptEpsilon: number;
159
+ treeifyNonContiguousSummaries: boolean;
160
+ }
161
+
105
162
  export interface RecipeAgent {
106
163
  name?: string;
107
164
  model?: string;
108
165
  /** IANA zone used when rendering wall-clock times to the agent. */
109
166
  timezone?: string;
110
- /** Provider transport. Omitted preserves the historical Anthropic default. */
111
- provider?: 'anthropic' | 'openai-responses' | 'openai-codex' | 'openrouter' | 'bedrock';
167
+ /** Provider transport. Omitted preserves the historical Anthropic default.
168
+ * 'mock' wires membrane's MockAdapter canned/echo responses, no API key,
169
+ * no provider spend; for exercising the full host loop offline. */
170
+ provider?: 'anthropic' | 'openai-responses' | 'openai-codex' | 'openrouter' | 'bedrock' | 'openai-compatible' | 'mock';
171
+ /**
172
+ * Base URL of an OpenAI-compatible chat-completions endpoint, e.g.
173
+ * `http://localhost:11434/v1` (Ollama), a vLLM server, Together, Groq,
174
+ * NanoGPT... Required with `provider: 'openai-compatible'`, rejected with
175
+ * any other provider (those have their own `*_BASE_URL` env overrides).
176
+ * The API key comes from `OPENAI_COMPATIBLE_API_KEY` only — deliberately no
177
+ * `OPENAI_API_KEY` fallback, since `baseUrl` is recipe-controlled and a real
178
+ * OpenAI credential must never travel silently to an arbitrary endpoint.
179
+ * Local servers may need none. `agent.model` is required
180
+ * too — there is no sensible default model for an arbitrary endpoint.
181
+ */
182
+ baseUrl?: string;
112
183
  /** Message formatter. 'native' (default) = structured user/assistant turns.
113
184
  * 'anthropic-xml' = classic prefill format ("participant: text" runs, XML
114
185
  * tools) — for migrating prefill-era bots (chapterx borgs) with their exact
@@ -133,6 +204,28 @@ export interface RecipeAgent {
133
204
  * Not forwarded on bedrock — that transport only has the default 5m
134
205
  * cache and rejects the ttl field. */
135
206
  cacheTtl?: '5m' | '1h';
207
+ /**
208
+ * Prompt-cache keepalive. With `cacheTtl: '1h'`, an idle agent's cached
209
+ * prefix expires after an hour and its next wake pays a 2x cache write over
210
+ * the whole context. Reading an entry refreshes its TTL at 0.1x, so a
211
+ * periodic `max_tokens: 0` replay holds it warm for pennies.
212
+ *
213
+ * On by default for the anthropic provider (ignored elsewhere — bedrock has
214
+ * no 1h cache). Measured on fable-cm 2026-08-11..22: 49.7M tokens of cache
215
+ * writes followed a >1h idle gap, ~$944 of write premium at fable-5 rates
216
+ * that this converts to ~$308 of reads.
217
+ *
218
+ * Cost is proportional to actual idleness, not to `maxIdleHours` — a busy
219
+ * agent never fires one, because its own traffic already refreshes the TTL.
220
+ */
221
+ cacheKeepalive?: {
222
+ /** Default true (anthropic provider only). */
223
+ enabled?: boolean;
224
+ /** Stop refreshing this long after the last REAL request. Default 24. */
225
+ maxIdleHours?: number;
226
+ /** Refresh once untouched this long. Must be < 60 with a 1h TTL. Default 45. */
227
+ refreshAfterMinutes?: number;
228
+ };
136
229
  /**
137
230
  * Explicit prompt-caching override. Unset means provider-appropriate
138
231
  * default: on for everything except bedrock models that predate caching
@@ -149,9 +242,14 @@ export interface RecipeAgent {
149
242
  /**
150
243
  * Prose delivery mode (agent-framework docs/explicit-prose-routing.md).
151
244
  * 'explicit' = model prefixes plain text with `>>destination`; unprefixed
152
- * prose bounces to a clipboard instead of auto-routing. Default 'locus'.
245
+ * prose bounces to a clipboard instead of auto-routing.
246
+ * 'hybrid' = unprefixed prose keeps the current locus while an exact leading
247
+ * `>>>destination` envelope routes through the authorized channel registry.
248
+ * Default 'locus'.
153
249
  */
154
- proseRouting?: 'locus' | 'explicit';
250
+ proseRouting?: 'locus' | 'explicit' | 'hybrid' | 'disabled';
251
+ /** Default-off containment of whole-response prose wrappers for known tools. */
252
+ toolWrapperProseGuard?: boolean;
155
253
  /**
156
254
  * Extra Anthropic beta flags sent as the `anthropic-beta` header on every
157
255
  * request (e.g. `["context-1m-2025-08-07"]` for the 1M context window on
@@ -191,6 +289,15 @@ export interface RecipeAgent {
191
289
  codex?: {
192
290
  fastMode?: boolean;
193
291
  };
292
+ /** Mock-provider settings. Only used with `provider: "mock"`. The default
293
+ * (no block) echoes the last user message back — the most informative shape
294
+ * for interactive smoke runs, since you can see your own words complete the
295
+ * loop. `echoMode: false` returns `defaultResponse` instead, which gives
296
+ * deterministic output for scripted tests. */
297
+ mock?: {
298
+ echoMode?: boolean;
299
+ defaultResponse?: string;
300
+ };
194
301
  /**
195
302
  * Content-refusal handling. When `autoRewind` is on, a `stop_reason: refusal`
196
303
  * turn triggers an automatic rewind of the triggering turn + retry (keeping
@@ -267,13 +374,21 @@ export interface RecipeMcpServer {
267
374
 
268
375
  /**
269
376
  * How to obtain and install an MCP server at deploy time. Consumed by
270
- * build tooling like connectome-cook. All fields optional-at-the-schema-
271
- * layer except `url`; tools may require more depending on the install
272
- * pattern they're generating.
377
+ * build tooling like connectome-cook. Exactly one of `url` (git form) or
378
+ * `npm` (registry form) must be set; tools may require more depending on
379
+ * the install pattern they're generating.
273
380
  */
274
381
  export interface RecipeMcpServerSource {
275
- /** Git URL to clone from. */
276
- url: string;
382
+ /** Git URL to clone from. Mutually exclusive with `npm`. */
383
+ url?: string;
384
+ /**
385
+ * npm registry package spec (`pkg@version` / `@scope/pkg@version`) that
386
+ * build tooling bakes via a global install instead of a git clone —
387
+ * matches connectome-cook's `source.npm` grammar. The git-form fields
388
+ * (`ref`, `install`, `inContainer`, ...) don't apply. Mutually
389
+ * exclusive with `url`.
390
+ */
391
+ npm?: string;
277
392
  /**
278
393
  * Git ref: branch, tag, or commit SHA. Default: "main".
279
394
  * If the value starts with "refs/" (e.g. "refs/pull/3/head"), it's
@@ -388,8 +503,9 @@ export interface RecipeCredentialFileField {
388
503
 
389
504
  /**
390
505
  * Subset of MountConfig exposed to recipes.
391
- * Intentionally omits watchDebounceMs, followSymlinks, and maxFileSize
392
- * these are implementation details best left to framework defaults.
506
+ * Intentionally omits watchDebounceMs and followSymlinks. The maximum file
507
+ * size is operator-configurable because binary service artifacts may
508
+ * legitimately exceed the conservative framework default.
393
509
  */
394
510
  export interface RecipeWorkspaceMount {
395
511
  name: string;
@@ -397,6 +513,8 @@ export interface RecipeWorkspaceMount {
397
513
  mode?: 'read-write' | 'read-only';
398
514
  watch?: 'always' | 'on-agent-action' | 'never';
399
515
  ignore?: string[];
516
+ /** Maximum file size in bytes (defaults to the framework's 5 MiB limit). */
517
+ maxFileSize?: number;
400
518
  /**
401
519
  * Request inference when files in this mount change. Pair with
402
520
  * `watch: 'always'` so chokidar actually observes the mount.
@@ -441,6 +559,52 @@ export interface RecipeModules {
441
559
  };
442
560
  wake?: boolean | import('@animalabs/agent-framework').GateConfig;
443
561
  workspace?: boolean | { mounts: RecipeWorkspaceMount[]; configMount?: boolean };
562
+ /**
563
+ * Shared operating instructions. OPT-IN — off by default. Reads a living
564
+ * instructions document (a CLAUDE.md analogue maintained in a workspace
565
+ * mount) and injects its current content into EVERY agent's context on
566
+ * EVERY turn — the resident agent and all ephemeral subagents — via the
567
+ * gatherContext hook. Injections are per-turn overlays (not persisted to
568
+ * Chronicle), so edits to the file take effect on the next turn.
569
+ *
570
+ * Requires `workspace` (the path below is a workspace mount path);
571
+ * enabling this alongside `workspace: false` fails validation. The mount
572
+ * named by the path is cross-checked at load time in every configuration
573
+ * (explicit mounts and the implicit "input"+"products" default alike), so
574
+ * a path naming a nonexistent mount is a load error, never a silent
575
+ * no-injection. A read-write instructions mount must also set
576
+ * `autoMaterialize: true`: workspace writes are Chronicle-first and the
577
+ * injection reads disk, so without materialization an agent's own
578
+ * curation edits would never reach the injection. A read-only mount is
579
+ * the alternative when the file is maintained outside the agent (edits
580
+ * then propagate to the injection but not to `workspace--read`, which
581
+ * serves Chronicle). A missing FILE remains fail-open at runtime: no
582
+ * injection, warn once.
583
+ *
584
+ * Cache economics: with position 'system' the injected block sits in the
585
+ * prompt-cache prefix of every agent, so each EDIT to the file is a
586
+ * fleet-wide cache cold start on the next turn (steady state between
587
+ * edits caches normally). Curate in batches rather than per-message;
588
+ * 'afterUser' is the cache-cheap, lower-salience alternative.
589
+ */
590
+ instructions?: boolean | {
591
+ /** Workspace path "<mountName>/<relativePath>". Default "instructions/AGENTS.md". */
592
+ path?: string;
593
+ /**
594
+ * Heading line prepended to the injected block.
595
+ * Default "# Shared operating instructions (live document)".
596
+ */
597
+ header?: string;
598
+ /**
599
+ * Truncate content beyond this many bytes, appending a
600
+ * "[truncated: first N of M bytes]" marker (N may sit up to 3 bytes
601
+ * under the cap when a multibyte character straddles it). At most this
602
+ * many bytes are ever read from disk. Default 32768.
603
+ */
604
+ maxBytes?: number;
605
+ /** Where the block lands: 'system' (default) | 'beforeUser' | 'afterUser'. */
606
+ position?: 'system' | 'beforeUser' | 'afterUser';
607
+ };
444
608
  /**
445
609
  * Surface agent composition activity (typing indicators) to one or more
446
610
  * MCPL channels while inference is active. Opt-in per recipe; channel IDs
@@ -699,6 +863,65 @@ export interface RecipeCodeExecution {
699
863
  idleReclaimMs?: number;
700
864
  }
701
865
 
866
+ /**
867
+ * The subconscious resident (agent-framework FrameworkConfig.subconscious,
868
+ * issue agent-framework#77 — tune-out): a persistent same-model side-agent
869
+ * that receives traffic from channels the resident has tuned out and
870
+ * reports to them in its own voice. Passed through verbatim; the framework
871
+ * owns the defaults (name `Subconscious`, model = the resident's).
872
+ */
873
+ export interface RecipeSubconscious {
874
+ /** Master switch. Without it the `tune_out` tool is not offered. */
875
+ enabled: boolean;
876
+ /** Registry + participant name (default 'Subconscious'). */
877
+ name?: string;
878
+ /** Model id (default: the resident's model — same-model side-process). */
879
+ model?: string;
880
+ /** The voice/criteria mode block: report-shaped, second person toward the
881
+ * resident. Co-authored with the resident; canary before fleet use. */
882
+ systemPrompt: string;
883
+ /** Allow `speak_in_channel` (default false until the voice block has
884
+ * passed its canary). */
885
+ allowChannelSpeech?: boolean;
886
+ /** WindowedPassthroughStrategy re-anchor fraction in (0, 1] (default 0.5). */
887
+ reAnchorFraction?: number;
888
+ }
889
+
890
+ /**
891
+ * Per-channel conversation routing (agent-framework ConversationRouter):
892
+ * the recipe's agent becomes a dormant "trunk" template, and qualifying
893
+ * incoming channel messages spawn per-channel fork agents seeded from the
894
+ * trunk's current context. The host fills in what the framework needs but a
895
+ * recipe can't say: `templateAgent` is always the recipe's own agent, and
896
+ * `strategyFactory` builds a fresh instance of the recipe's `agent.strategy`
897
+ * per fork (strategy instances are stateful and must never be shared).
898
+ */
899
+ export interface RecipeConversations {
900
+ /** When an unbound channel acquires a fork.
901
+ * Defaults: dm 'always', groupDm 'always', channel 'mention'. */
902
+ bind?: {
903
+ dm?: 'always' | 'mention' | 'never';
904
+ groupDm?: 'always' | 'mention' | 'never';
905
+ channel?: 'always' | 'mention' | 'never';
906
+ };
907
+ /** When a message on a bound channel triggers inference (it always lands
908
+ * in the fork's context regardless).
909
+ * Defaults: dm 'always', groupDm 'mention', channel 'mention'. */
910
+ trigger?: {
911
+ dm?: 'always' | 'mention';
912
+ groupDm?: 'always' | 'mention';
913
+ channel?: 'always' | 'mention';
914
+ };
915
+ /** Idle time before a binding expires and the fork runs its closure turn.
916
+ * Default 12h. */
917
+ idleTtlMs?: number;
918
+ /** Final system-initiated user message sent to a fork on expiry. */
919
+ closurePrompt?: string;
920
+ /** Prefix for generated fork agent names (default 'conversation'). Also
921
+ * the Chronicle namespace segment, so it is restricted to [A-Za-z0-9_-]. */
922
+ agentPrefix?: string;
923
+ }
924
+
702
925
  export interface Recipe {
703
926
  name: string;
704
927
  description?: string;
@@ -711,6 +934,10 @@ export interface Recipe {
711
934
  sessionNaming?: { examples?: string[] };
712
935
  /** Client-side programmatic tool calling (code_execution tool). */
713
936
  codeExecution?: RecipeCodeExecution;
937
+ /** Per-channel conversation routing — fork-per-channel from this agent. */
938
+ conversations?: RecipeConversations;
939
+ /** Tune-out's subconscious resident (agent-framework#77). */
940
+ subconscious?: RecipeSubconscious;
714
941
  }
715
942
 
716
943
  // ---------------------------------------------------------------------------
@@ -812,6 +1039,27 @@ export function substituteEnvVars(value: unknown, source: string): unknown {
812
1039
  */
813
1040
  type RecipeSourceBase = { kind: 'file'; dir: string } | { kind: 'url'; base: string };
814
1041
 
1042
+ /**
1043
+ * A loaded recipe plus the form of it that is safe to persist.
1044
+ *
1045
+ * `recipe` is fully resolved: `${VAR}` env references substituted, relative
1046
+ * child/extension paths made absolute, URL systemPrompt fetched. It is what
1047
+ * the running host consumes — and it can contain secrets (API tokens pulled
1048
+ * from the environment), so it must never be written to disk.
1049
+ *
1050
+ * `persistable` is the raw pre-substitution recipe JSON with only the
1051
+ * source-relative paths (`modules.fleet.children[].recipe`,
1052
+ * `extensions[*].path`) resolved to their final absolute form — those need
1053
+ * the original source base, which a resumed session no longer has. Every
1054
+ * `${VAR}` reference and any URL systemPrompt stay unresolved, so the file
1055
+ * `saveRecipe` writes carries no secret material and re-resolves against the
1056
+ * *current* environment on resume.
1057
+ */
1058
+ export interface LoadedRecipe {
1059
+ recipe: Recipe;
1060
+ persistable: Record<string, unknown>;
1061
+ }
1062
+
815
1063
  /**
816
1064
  * Load a recipe from a URL or local file path.
817
1065
  * If the systemPrompt value is an HTTP(S) URL, fetches the text.
@@ -821,6 +1069,15 @@ type RecipeSourceBase = { kind: 'file'; dir: string } | { kind: 'url'; base: str
821
1069
  * parent recipe's directory (or URL base) so sibling recipes are portable.
822
1070
  */
823
1071
  export async function loadRecipe(source: string): Promise<Recipe> {
1072
+ return (await loadRecipeDetailed(source)).recipe;
1073
+ }
1074
+
1075
+ /**
1076
+ * Like loadRecipe, but also returns the persistable (unresolved) form —
1077
+ * see LoadedRecipe. Callers that snapshot the recipe to disk (index.ts's
1078
+ * resolveRecipe) must save `persistable`, never `recipe`.
1079
+ */
1080
+ export async function loadRecipeDetailed(source: string): Promise<LoadedRecipe> {
824
1081
  let raw: unknown;
825
1082
  let sourceBase: RecipeSourceBase;
826
1083
 
@@ -836,11 +1093,54 @@ export async function loadRecipe(source: string): Promise<Recipe> {
836
1093
  sourceBase = { kind: 'file', dir: dirname(path) };
837
1094
  }
838
1095
 
1096
+ // Snapshot the pre-substitution form before substituteEnvVars walks the
1097
+ // object — this is what gets persisted, so resolved secrets never do.
1098
+ const persistable = structuredClone(raw) as Record<string, unknown>;
1099
+
839
1100
  raw = substituteEnvVars(raw, source);
840
1101
  const recipe = validateRecipe(raw);
841
1102
  resolveChildRecipePaths(recipe, sourceBase);
842
1103
  resolveExtensionPaths(recipe, sourceBase);
843
- return resolveSystemPrompt(recipe);
1104
+ const resolved = await resolveSystemPrompt(recipe);
1105
+ copyResolvedPathsIntoRaw(persistable, resolved);
1106
+ return { recipe: resolved, persistable };
1107
+ }
1108
+
1109
+ /**
1110
+ * Copy the resolved `modules.fleet.children[].recipe` and
1111
+ * `extensions[*].path` values from the resolved recipe into the raw
1112
+ * pre-substitution snapshot. Those fields are resolved against the recipe's
1113
+ * original source base (its directory or URL), which is gone by resume time
1114
+ * — so the persisted form must carry them already-absolute. Substitution
1115
+ * never changes object shape (it is a per-string replacement), so the two
1116
+ * trees align index-for-index and key-for-key. Paths are not treated as
1117
+ * secret: an env value that was interpolated into a child-recipe or
1118
+ * extension path IS persisted in resolved form.
1119
+ */
1120
+ function copyResolvedPathsIntoRaw(raw: Record<string, unknown>, recipe: Recipe): void {
1121
+ const fleet = recipe.modules?.fleet;
1122
+ const resolvedChildren = (typeof fleet === 'object' && fleet !== null) ? fleet.children : undefined;
1123
+ if (Array.isArray(resolvedChildren)) {
1124
+ const rawModules = raw.modules as Record<string, unknown> | undefined;
1125
+ const rawFleet = rawModules?.fleet as Record<string, unknown> | undefined;
1126
+ const rawChildren = (typeof rawFleet === 'object' && rawFleet !== null) ? rawFleet.children : undefined;
1127
+ if (Array.isArray(rawChildren)) {
1128
+ for (let i = 0; i < rawChildren.length && i < resolvedChildren.length; i++) {
1129
+ const rawChild = rawChildren[i] as Record<string, unknown> | null;
1130
+ if (rawChild && typeof rawChild === 'object' && typeof resolvedChildren[i]?.recipe === 'string') {
1131
+ rawChild.recipe = resolvedChildren[i].recipe;
1132
+ }
1133
+ }
1134
+ }
1135
+ }
1136
+
1137
+ const rawExtensions = raw.extensions as Record<string, unknown> | undefined;
1138
+ for (const [name, ext] of Object.entries(recipe.extensions ?? {})) {
1139
+ const rawExt = rawExtensions?.[name] as Record<string, unknown> | undefined;
1140
+ if (rawExt && typeof rawExt === 'object') {
1141
+ rawExt.path = ext.path;
1142
+ }
1143
+ }
844
1144
  }
845
1145
 
846
1146
  /**
@@ -902,6 +1202,87 @@ async function resolveSystemPrompt(recipe: Recipe): Promise<Recipe> {
902
1202
  return recipe;
903
1203
  }
904
1204
 
1205
+ function validateKvUnifiedConfig(strategy: Record<string, unknown>): void {
1206
+ const selected = strategy.foldingStrategy === 'kv-unified';
1207
+ const raw = strategy.kvUnified;
1208
+ if (!selected) {
1209
+ if (raw !== undefined) {
1210
+ throw new Error('Recipe agent.strategy.kvUnified requires foldingStrategy "kv-unified".');
1211
+ }
1212
+ return;
1213
+ }
1214
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
1215
+ throw new Error(
1216
+ 'Recipe foldingStrategy "kv-unified" requires a complete agent.strategy.kvUnified object; defaults are forbidden.',
1217
+ );
1218
+ }
1219
+ const config = raw as Record<string, unknown>;
1220
+ if (!config.policy || typeof config.policy !== 'object' || Array.isArray(config.policy)) {
1221
+ throw new Error('Recipe agent.strategy.kvUnified.policy must be a complete object.');
1222
+ }
1223
+ const policy = config.policy as Record<string, unknown>;
1224
+ const policyNumbers = [
1225
+ 'alpha', 'budgetLowRatio', 'budgetHighRatio', 'budgetUnderLambda',
1226
+ 'budgetOverLambda', 'cacheLambda', 'cacheScale', 'cacheReadPrice',
1227
+ 'cacheWritePrice', 'continuityLambda', 'continuityScale',
1228
+ 'continuityRecencyHalfLifeTokens', 'continuityRecencyFloor',
1229
+ 'continuityStableHalfLife', 'continuityStableFloor',
1230
+ ] as const;
1231
+ for (const key of policyNumbers) {
1232
+ if (typeof policy[key] !== 'number' || !Number.isFinite(policy[key])) {
1233
+ throw new Error(`Recipe agent.strategy.kvUnified.policy.${key} must be a finite number.`);
1234
+ }
1235
+ }
1236
+ const nonNegative = [
1237
+ 'alpha', 'budgetUnderLambda', 'budgetOverLambda', 'cacheLambda',
1238
+ 'cacheReadPrice', 'cacheWritePrice', 'continuityLambda',
1239
+ ] as const;
1240
+ for (const key of nonNegative) {
1241
+ if ((policy[key] as number) < 0) {
1242
+ throw new Error(`Recipe agent.strategy.kvUnified.policy.${key} must be non-negative.`);
1243
+ }
1244
+ }
1245
+ for (const key of [
1246
+ 'cacheScale', 'continuityScale', 'continuityRecencyHalfLifeTokens',
1247
+ 'continuityStableHalfLife',
1248
+ ] as const) {
1249
+ if ((policy[key] as number) <= 0) {
1250
+ throw new Error(`Recipe agent.strategy.kvUnified.policy.${key} must be positive.`);
1251
+ }
1252
+ }
1253
+ const low = policy.budgetLowRatio as number;
1254
+ const high = policy.budgetHighRatio as number;
1255
+ if (low < 0 || high > 1 || low > high) {
1256
+ throw new Error('Recipe kvUnified budget ratios must satisfy 0 <= low <= high <= 1.');
1257
+ }
1258
+ for (const key of ['continuityRecencyFloor', 'continuityStableFloor'] as const) {
1259
+ const value = policy[key] as number;
1260
+ if (value < 0 || value > 1) {
1261
+ throw new Error(`Recipe agent.strategy.kvUnified.policy.${key} must be in [0, 1].`);
1262
+ }
1263
+ }
1264
+ for (const key of [
1265
+ 'tokenBucketSize', 'continuityBucketSize', 'fidelityBucketSize', 'labelCeiling',
1266
+ ] as const) {
1267
+ const value = config[key];
1268
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) {
1269
+ throw new Error(`Recipe agent.strategy.kvUnified.${key} must be a positive safe integer.`);
1270
+ }
1271
+ }
1272
+ if (
1273
+ typeof config.adoptEpsilon !== 'number' ||
1274
+ !Number.isFinite(config.adoptEpsilon) ||
1275
+ config.adoptEpsilon < 0
1276
+ ) {
1277
+ throw new Error('Recipe agent.strategy.kvUnified.adoptEpsilon must be a finite non-negative number.');
1278
+ }
1279
+ if (typeof config.treeifyNonContiguousSummaries !== 'boolean') {
1280
+ throw new Error(
1281
+ 'Recipe agent.strategy.kvUnified.treeifyNonContiguousSummaries must be an explicit boolean.',
1282
+ );
1283
+ }
1284
+ }
1285
+
905
1286
  /**
906
1287
  * Validate raw JSON and fill defaults.
907
1288
  */
@@ -917,8 +1298,13 @@ export function validateRecipe(raw: unknown): Recipe {
917
1298
  }
918
1299
 
919
1300
  const agent = obj.agent as Record<string, unknown>;
920
- if (typeof agent.systemPrompt !== 'string' || !agent.systemPrompt) {
921
- throw new Error('Recipe agent must have a "systemPrompt" string');
1301
+ // Absent or empty systemPrompt is a valid configuration: '' is dropped at
1302
+ // the provider boundary (membrane omits falsy `system`), so the wire
1303
+ // request carries no system block at all.
1304
+ if (agent.systemPrompt === undefined || agent.systemPrompt === null) {
1305
+ agent.systemPrompt = '';
1306
+ } else if (typeof agent.systemPrompt !== 'string') {
1307
+ throw new Error('Recipe agent "systemPrompt" must be a string when present');
922
1308
  }
923
1309
 
924
1310
  if (agent.provider !== undefined &&
@@ -926,13 +1312,29 @@ export function validateRecipe(raw: unknown): Recipe {
926
1312
  agent.provider !== 'openai-responses' &&
927
1313
  agent.provider !== 'openai-codex' &&
928
1314
  agent.provider !== 'openrouter' &&
929
- agent.provider !== 'bedrock') {
1315
+ agent.provider !== 'bedrock' &&
1316
+ agent.provider !== 'openai-compatible' &&
1317
+ agent.provider !== 'mock') {
930
1318
  throw new Error(
931
- `Recipe agent.provider must be 'anthropic', 'openai-responses', 'openai-codex', 'openrouter', or 'bedrock', ` +
1319
+ `Recipe agent.provider must be 'anthropic', 'openai-responses', 'openai-codex', 'openrouter', 'bedrock', 'openai-compatible', or 'mock', ` +
932
1320
  `got ${JSON.stringify(agent.provider)}.`,
933
1321
  );
934
1322
  }
935
1323
 
1324
+ if (
1325
+ agent.proseRouting !== undefined &&
1326
+ agent.proseRouting !== 'locus' &&
1327
+ agent.proseRouting !== 'explicit' &&
1328
+ agent.proseRouting !== 'hybrid' &&
1329
+ agent.proseRouting !== 'disabled'
1330
+ ) {
1331
+ throw new Error(`Recipe agent.proseRouting must be 'locus', 'explicit', 'hybrid', or 'disabled', got ${JSON.stringify(agent.proseRouting)}.`);
1332
+ }
1333
+
1334
+ if (agent.toolWrapperProseGuard !== undefined && typeof agent.toolWrapperProseGuard !== 'boolean') {
1335
+ throw new Error(`Recipe agent.toolWrapperProseGuard must be a boolean, got ${JSON.stringify(agent.toolWrapperProseGuard)}.`);
1336
+ }
1337
+
936
1338
  if (agent.timezone !== undefined) {
937
1339
  if (typeof agent.timezone !== 'string' || !agent.timezone.trim()) {
938
1340
  throw new Error('Recipe agent.timezone must be a non-empty IANA time zone string.');
@@ -979,6 +1381,20 @@ export function validateRecipe(raw: unknown): Recipe {
979
1381
  }
980
1382
  }
981
1383
 
1384
+ if (agent.mock !== undefined) {
1385
+ if (!agent.mock || typeof agent.mock !== 'object' || Array.isArray(agent.mock)) {
1386
+ throw new Error('Recipe agent.mock must be an object.');
1387
+ }
1388
+ const mock = agent.mock as Record<string, unknown>;
1389
+ if (mock.echoMode !== undefined && typeof mock.echoMode !== 'boolean') {
1390
+ throw new Error('Recipe agent.mock.echoMode must be a boolean.');
1391
+ }
1392
+ if (mock.defaultResponse !== undefined &&
1393
+ (typeof mock.defaultResponse !== 'string' || !mock.defaultResponse.trim())) {
1394
+ throw new Error('Recipe agent.mock.defaultResponse must be a non-empty string.');
1395
+ }
1396
+ }
1397
+
982
1398
  if (agent.maxStreamTokens !== undefined && (typeof agent.maxStreamTokens !== 'number' || agent.maxStreamTokens <= 0)) {
983
1399
  throw new Error('Recipe agent.maxStreamTokens must be a positive number.');
984
1400
  }
@@ -990,6 +1406,63 @@ export function validateRecipe(raw: unknown): Recipe {
990
1406
  }
991
1407
  agent.cacheTtl ??= '1h';
992
1408
 
1409
+ // openai-compatible: an endpoint the host knows nothing about, so the recipe
1410
+ // must say where it is and which model to ask for. Fail at load time, not as
1411
+ // a fetch to 'undefined/chat/completions' at first inference.
1412
+ if (agent.provider === 'openai-compatible') {
1413
+ if (typeof agent.baseUrl !== 'string' || !agent.baseUrl.trim()) {
1414
+ throw new Error("Recipe agent.baseUrl is required when agent.provider is 'openai-compatible' (e.g. \"http://localhost:11434/v1\").");
1415
+ }
1416
+ let parsed: URL;
1417
+ try {
1418
+ parsed = new URL(agent.baseUrl);
1419
+ } catch {
1420
+ throw new Error(`Recipe agent.baseUrl must be an absolute http(s) URL, got ${JSON.stringify(agent.baseUrl)}.`);
1421
+ }
1422
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
1423
+ throw new Error(`Recipe agent.baseUrl must use http or https, got ${JSON.stringify(agent.baseUrl)}.`);
1424
+ }
1425
+ if (typeof agent.model !== 'string' || !agent.model.trim()) {
1426
+ throw new Error("Recipe agent.model is required when agent.provider is 'openai-compatible' (no default model for an arbitrary endpoint).");
1427
+ }
1428
+ } else if (agent.baseUrl !== undefined) {
1429
+ throw new Error(
1430
+ `Recipe agent.baseUrl only applies to agent.provider 'openai-compatible' (got provider ${JSON.stringify(agent.provider ?? 'anthropic')}); ` +
1431
+ 'other providers take their endpoint from ANTHROPIC_BASE_URL / OPENAI_BASE_URL / BEDROCK_BASE_URL.',
1432
+ );
1433
+ }
1434
+
1435
+ // A keepalive that fires AFTER the entry has already expired is the worst of
1436
+ // both worlds: it pays a full 2x cache write on every poke, forever, and
1437
+ // reports success while doing it. Refuse the config rather than discover it
1438
+ // in a bill. (The runtime also self-checks — see membrane cache-keepalive —
1439
+ // but a typo'd recipe should never get that far.)
1440
+ const keepalive = agent.cacheKeepalive;
1441
+ if (keepalive !== undefined) {
1442
+ if (typeof keepalive !== 'object' || keepalive === null) {
1443
+ throw new Error(`Recipe agent.cacheKeepalive must be an object, got ${JSON.stringify(keepalive)}.`);
1444
+ }
1445
+ const { maxIdleHours, refreshAfterMinutes } = keepalive as {
1446
+ maxIdleHours?: unknown;
1447
+ refreshAfterMinutes?: unknown;
1448
+ };
1449
+ if (refreshAfterMinutes !== undefined) {
1450
+ if (typeof refreshAfterMinutes !== 'number' || !(refreshAfterMinutes > 0)) {
1451
+ throw new Error(`Recipe agent.cacheKeepalive.refreshAfterMinutes must be a positive number, got ${JSON.stringify(refreshAfterMinutes)}.`);
1452
+ }
1453
+ const ttlMinutes = agent.cacheTtl === '1h' ? 60 : 5;
1454
+ if (refreshAfterMinutes >= ttlMinutes) {
1455
+ throw new Error(
1456
+ `Recipe agent.cacheKeepalive.refreshAfterMinutes (${refreshAfterMinutes}) must be less than the ${agent.cacheTtl} cache TTL (${ttlMinutes}m), ` +
1457
+ 'or every keepalive would land after the entry expired and pay a full cache write instead of a read.',
1458
+ );
1459
+ }
1460
+ }
1461
+ if (maxIdleHours !== undefined && (typeof maxIdleHours !== 'number' || !(maxIdleHours > 0))) {
1462
+ throw new Error(`Recipe agent.cacheKeepalive.maxIdleHours must be a positive number, got ${JSON.stringify(maxIdleHours)}.`);
1463
+ }
1464
+ }
1465
+
993
1466
  if (agent.promptCaching !== undefined && typeof agent.promptCaching !== 'boolean') {
994
1467
  throw new Error(
995
1468
  `Recipe agent.promptCaching must be a boolean, got ${JSON.stringify(agent.promptCaching)}.`,
@@ -1083,6 +1556,21 @@ export function validateRecipe(raw: unknown): Recipe {
1083
1556
  `recipe's "extensions" block (none is declared).`,
1084
1557
  );
1085
1558
  }
1559
+ if (
1560
+ strategy.foldingStrategy !== undefined &&
1561
+ strategy.foldingStrategy !== 'flat-profile' &&
1562
+ strategy.foldingStrategy !== 'oldest-first' &&
1563
+ strategy.foldingStrategy !== 'kv-stable' &&
1564
+ strategy.foldingStrategy !== 'kv-unified'
1565
+ ) {
1566
+ throw new Error(
1567
+ `Recipe agent.strategy.foldingStrategy is invalid: ${JSON.stringify(strategy.foldingStrategy)}.`,
1568
+ );
1569
+ }
1570
+ if (strategy.foldingStrategy === 'kv-unified' && strategy.type === 'passthrough') {
1571
+ throw new Error('Recipe foldingStrategy "kv-unified" requires an autobiographical or frontdesk strategy.');
1572
+ }
1573
+ validateKvUnifiedConfig(strategy);
1086
1574
  if (
1087
1575
  strategy.compressionRefusalCurveFallbacks !== undefined
1088
1576
  && (
@@ -1103,6 +1591,34 @@ export function validateRecipe(raw: unknown): Recipe {
1103
1591
  ) {
1104
1592
  throw new Error('Recipe agent.strategy.compressionContextBudgetTokens must be a positive safe integer.');
1105
1593
  }
1594
+ for (const key of [
1595
+ 'compressionSourceOnly',
1596
+ 'compressionSourceOnlyFallback',
1597
+ 'compressionMergeSourceOnly',
1598
+ 'compressionMergeSourceOnlyFallback',
1599
+ 'compressionSplitFallback',
1600
+ 'compressionSplitPlaceholder',
1601
+ ] as const) {
1602
+ if (strategy[key] !== undefined && typeof strategy[key] !== 'boolean') {
1603
+ throw new Error(`Recipe agent.strategy.${key} must be a boolean.`);
1604
+ }
1605
+ }
1606
+ for (const key of ['compressionSplitMaxCallsPerChunk', 'compressionSplitMaxCallsPer10Min'] as const) {
1607
+ const value = strategy[key];
1608
+ if (value !== undefined && (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0)) {
1609
+ throw new Error(`Recipe agent.strategy.${key} must be a positive safe integer.`);
1610
+ }
1611
+ }
1612
+ if (
1613
+ strategy.compressionRecallBudgetTokens !== undefined
1614
+ && (
1615
+ typeof strategy.compressionRecallBudgetTokens !== 'number'
1616
+ || !Number.isSafeInteger(strategy.compressionRecallBudgetTokens)
1617
+ || strategy.compressionRecallBudgetTokens <= 0
1618
+ )
1619
+ ) {
1620
+ throw new Error('Recipe agent.strategy.compressionRecallBudgetTokens must be a positive safe integer.');
1621
+ }
1106
1622
  }
1107
1623
 
1108
1624
  const refusalHandling = agent.refusalHandling as Record<string, unknown> | undefined;
@@ -1130,8 +1646,16 @@ export function validateRecipe(raw: unknown): Recipe {
1130
1646
  throw new Error(`mcpServers.${id}.source must be an object`);
1131
1647
  }
1132
1648
  const src = server.source as Record<string, unknown>;
1133
- if (typeof src.url !== 'string' || !src.url) {
1134
- throw new Error(`mcpServers.${id}.source.url must be a non-empty string`);
1649
+ const hasSrcUrl = typeof src.url === 'string' && src.url;
1650
+ const hasSrcNpm = typeof src.npm === 'string' && src.npm;
1651
+ if (hasSrcUrl && hasSrcNpm) {
1652
+ throw new Error(`mcpServers.${id}.source must not set both "url" and "npm"`);
1653
+ }
1654
+ if (!hasSrcUrl && !hasSrcNpm) {
1655
+ throw new Error(
1656
+ `mcpServers.${id}.source must have a non-empty "url" (git clone) ` +
1657
+ `or "npm" (registry package spec) string`,
1658
+ );
1135
1659
  }
1136
1660
  if (src.ref !== undefined && typeof src.ref !== 'string') {
1137
1661
  throw new Error(`mcpServers.${id}.source.ref must be a string`);
@@ -1269,6 +1793,122 @@ export function validateRecipe(raw: unknown): Recipe {
1269
1793
  }
1270
1794
  }
1271
1795
 
1796
+ // Validate instructions if present. It reads through a workspace mount,
1797
+ // so pairing it with `workspace: false` is a config contradiction — fail
1798
+ // at load, not with a silent no-injection at runtime.
1799
+ if (mods.instructions !== undefined && mods.instructions !== false) {
1800
+ if (mods.instructions !== true
1801
+ && (typeof mods.instructions !== 'object' || Array.isArray(mods.instructions))) {
1802
+ throw new Error('modules.instructions must be a boolean or object');
1803
+ }
1804
+ if (mods.workspace === false) {
1805
+ throw new Error(
1806
+ 'modules.instructions requires modules.workspace: the instructions file is ' +
1807
+ 'read through a workspace mount, but this recipe sets workspace: false.',
1808
+ );
1809
+ }
1810
+ if (typeof mods.instructions === 'object') {
1811
+ const ins = mods.instructions as Record<string, unknown>;
1812
+ const allowedInstructionKeys = new Set(['path', 'header', 'maxBytes', 'position']);
1813
+ for (const key of Object.keys(ins)) {
1814
+ if (!allowedInstructionKeys.has(key)) {
1815
+ throw new Error(
1816
+ `modules.instructions has unknown field ${JSON.stringify(key)} ` +
1817
+ `(expected one of: ${[...allowedInstructionKeys].join(', ')}).`,
1818
+ );
1819
+ }
1820
+ }
1821
+ if (ins.path !== undefined) {
1822
+ if (typeof ins.path !== 'string' || !ins.path.trim()) {
1823
+ throw new Error('modules.instructions.path must be a non-empty string');
1824
+ }
1825
+ const slashIdx = ins.path.indexOf('/');
1826
+ if (slashIdx <= 0 || slashIdx === ins.path.length - 1) {
1827
+ throw new Error(
1828
+ 'modules.instructions.path must be a workspace path of the form ' +
1829
+ `"<mountName>/<relativePath>", got ${JSON.stringify(ins.path)}.`,
1830
+ );
1831
+ }
1832
+ }
1833
+ if (ins.header !== undefined && typeof ins.header !== 'string') {
1834
+ throw new Error('modules.instructions.header must be a string');
1835
+ }
1836
+ if (ins.maxBytes !== undefined
1837
+ && (typeof ins.maxBytes !== 'number' || !Number.isInteger(ins.maxBytes) || ins.maxBytes <= 0)) {
1838
+ throw new Error('modules.instructions.maxBytes must be a positive integer');
1839
+ }
1840
+ if (ins.position !== undefined
1841
+ && ins.position !== 'system' && ins.position !== 'beforeUser' && ins.position !== 'afterUser') {
1842
+ throw new Error(
1843
+ `modules.instructions.position must be 'system', 'beforeUser', or 'afterUser', ` +
1844
+ `got ${JSON.stringify(ins.position)}.`,
1845
+ );
1846
+ }
1847
+ }
1848
+
1849
+ // The mount the instructions path names is knowable at load time in
1850
+ // EVERY configuration — explicit mounts from the declaration, the
1851
+ // implicit default workspace from the fixed input/products pair the
1852
+ // host builds — so a typo, the default "instructions/…" path with no
1853
+ // matching mount, or `instructions: true` on the implicit workspace
1854
+ // (whose mount set can never contain "instructions") all fail here
1855
+ // instead of as a silent no-injection at runtime.
1856
+ //
1857
+ // A read-write instructions mount additionally requires
1858
+ // autoMaterialize: workspace write/edit are Chronicle-first and reach
1859
+ // disk only when the mount materializes, while this module reads disk
1860
+ // — without it, an agent's own curation edits would never appear in
1861
+ // the injection (and its workspace read would show the new content,
1862
+ // hiding the drift entirely). Read-only mounts are exempt: disk is
1863
+ // their only write path.
1864
+ {
1865
+ const effectivePath =
1866
+ typeof mods.instructions === 'object'
1867
+ && typeof (mods.instructions as { path?: unknown }).path === 'string'
1868
+ ? (mods.instructions as { path: string }).path
1869
+ : 'instructions/AGENTS.md'; // keep in sync with DEFAULT_INSTRUCTIONS_PATH
1870
+ const mountName = effectivePath.slice(0, effectivePath.indexOf('/'));
1871
+ // Reason over the SAME mount construction the runtime uses — one
1872
+ // builder, no validator-vs-host drift (the storePath placeholder is
1873
+ // irrelevant here; only names/modes/flags are consulted).
1874
+ const declaredExplicitly =
1875
+ !!mods.workspace && typeof mods.workspace === 'object' &&
1876
+ !!(mods.workspace as { mounts?: unknown }).mounts;
1877
+ const effectiveMounts =
1878
+ buildWorkspaceMounts(mods.workspace as RecipeModules['workspace'], '.') ?? [];
1879
+ const match = effectiveMounts.find((m) => m.name === mountName);
1880
+ if (!match) {
1881
+ throw new Error(
1882
+ `modules.instructions.path ${JSON.stringify(effectivePath)} names workspace mount ` +
1883
+ `${JSON.stringify(mountName)}, but the ${declaredExplicitly ? 'declared' : 'implicit default'} ` +
1884
+ `mounts are: ${effectiveMounts.map((m) => JSON.stringify(m.name)).join(', ')}. ` +
1885
+ `(The default path "instructions/AGENTS.md" requires a workspace mount named ` +
1886
+ `"instructions" — declare one under modules.workspace.mounts.)`,
1887
+ );
1888
+ }
1889
+ if (match.name === '_config') {
1890
+ throw new Error(
1891
+ `modules.instructions.path ${JSON.stringify(effectivePath)} reads through the ` +
1892
+ `host-managed "_config" mount, which does NOT auto-materialize: agent edits stay ` +
1893
+ `Chronicle-side and only reach disk after branch-changing commands, so the ` +
1894
+ `instructions injection would silently serve stale content. Use a dedicated ` +
1895
+ `instructions mount instead.`,
1896
+ );
1897
+ }
1898
+ if (match.mode !== 'read-only' && match.autoMaterialize !== true) {
1899
+ throw new Error(
1900
+ `modules.instructions.path ${JSON.stringify(effectivePath)} uses read-write mount ` +
1901
+ `${JSON.stringify(mountName)} without autoMaterialize. Workspace writes are ` +
1902
+ `Chronicle-first and reach disk only when the mount materializes, while the ` +
1903
+ `instructions injection reads disk — agent edits to the file would silently never ` +
1904
+ `take effect. Set autoMaterialize: true on the mount` +
1905
+ `${declaredExplicitly ? '' : ' (the implicit default workspace cannot; declare explicit mounts)'}, ` +
1906
+ `or make the mount read-only if the file is maintained outside the agent.`,
1907
+ );
1908
+ }
1909
+ }
1910
+ }
1911
+
1272
1912
  // Validate retrieval provider reasoning when configured.
1273
1913
  const retrieval = mods.retrieval;
1274
1914
  if (retrieval !== undefined && typeof retrieval !== 'boolean') {
@@ -1400,6 +2040,102 @@ export function validateRecipe(raw: unknown): Recipe {
1400
2040
  }
1401
2041
  }
1402
2042
 
2043
+ if (obj.subconscious !== undefined) {
2044
+ if (!obj.subconscious || typeof obj.subconscious !== 'object' || Array.isArray(obj.subconscious)) {
2045
+ throw new Error('Recipe subconscious must be an object.');
2046
+ }
2047
+ const sub = obj.subconscious as Record<string, unknown>;
2048
+ const allowedSubconsciousKeys = new Set([
2049
+ 'enabled', 'name', 'model', 'systemPrompt', 'allowChannelSpeech', 'reAnchorFraction',
2050
+ ]);
2051
+ for (const key of Object.keys(sub)) {
2052
+ if (!allowedSubconsciousKeys.has(key)) {
2053
+ throw new Error(
2054
+ `Recipe subconscious has unknown field ${JSON.stringify(key)} ` +
2055
+ `(expected one of: ${[...allowedSubconsciousKeys].join(', ')}).`,
2056
+ );
2057
+ }
2058
+ }
2059
+ if (typeof sub.enabled !== 'boolean') {
2060
+ throw new Error('Recipe subconscious.enabled must be a boolean.');
2061
+ }
2062
+ // The mode block is the subconscious's whole character; an enabled
2063
+ // subconscious without one would run on an empty system prompt.
2064
+ if (typeof sub.systemPrompt !== 'string' || !sub.systemPrompt.trim()) {
2065
+ throw new Error('Recipe subconscious.systemPrompt must be a non-empty string.');
2066
+ }
2067
+ for (const k of ['name', 'model'] as const) {
2068
+ if (sub[k] !== undefined && (typeof sub[k] !== 'string' || !(sub[k] as string).trim())) {
2069
+ throw new Error(`Recipe subconscious.${k} must be a non-empty string.`);
2070
+ }
2071
+ }
2072
+ if (sub.allowChannelSpeech !== undefined && typeof sub.allowChannelSpeech !== 'boolean') {
2073
+ throw new Error('Recipe subconscious.allowChannelSpeech must be a boolean.');
2074
+ }
2075
+ if (sub.reAnchorFraction !== undefined) {
2076
+ const f = sub.reAnchorFraction;
2077
+ if (typeof f !== 'number' || !(f > 0 && f <= 1)) {
2078
+ throw new Error('Recipe subconscious.reAnchorFraction must be a number in (0, 1].');
2079
+ }
2080
+ }
2081
+ }
2082
+
2083
+ if (obj.conversations !== undefined) {
2084
+ if (!obj.conversations || typeof obj.conversations !== 'object' || Array.isArray(obj.conversations)) {
2085
+ throw new Error('Recipe conversations must be an object.');
2086
+ }
2087
+ const conv = obj.conversations as Record<string, unknown>;
2088
+ const allowedConversationKeys = new Set(['bind', 'trigger', 'idleTtlMs', 'closurePrompt', 'agentPrefix']);
2089
+ for (const key of Object.keys(conv)) {
2090
+ if (!allowedConversationKeys.has(key)) {
2091
+ throw new Error(
2092
+ `Recipe conversations has unknown field ${JSON.stringify(key)} ` +
2093
+ `(expected one of: ${[...allowedConversationKeys].join(', ')}).`,
2094
+ );
2095
+ }
2096
+ }
2097
+ const kinds = ['dm', 'groupDm', 'channel'] as const;
2098
+ for (const [field, allowed] of [
2099
+ ['bind', ['always', 'mention', 'never']],
2100
+ ['trigger', ['always', 'mention']],
2101
+ ] as Array<[string, string[]]>) {
2102
+ const rules = conv[field];
2103
+ if (rules === undefined) continue;
2104
+ if (!rules || typeof rules !== 'object' || Array.isArray(rules)) {
2105
+ throw new Error(`Recipe conversations.${field} must be an object.`);
2106
+ }
2107
+ for (const [kind, rule] of Object.entries(rules as Record<string, unknown>)) {
2108
+ if (!(kinds as readonly string[]).includes(kind)) {
2109
+ throw new Error(
2110
+ `Recipe conversations.${field} has unknown channel kind ${JSON.stringify(kind)} ` +
2111
+ `(expected one of: ${kinds.join(', ')}).`,
2112
+ );
2113
+ }
2114
+ if (typeof rule !== 'string' || !allowed.includes(rule)) {
2115
+ throw new Error(
2116
+ `Recipe conversations.${field}.${kind} must be one of ${allowed.map(r => `'${r}'`).join(', ')}, ` +
2117
+ `got ${JSON.stringify(rule)}.`,
2118
+ );
2119
+ }
2120
+ }
2121
+ }
2122
+ if (conv.idleTtlMs !== undefined &&
2123
+ (typeof conv.idleTtlMs !== 'number' || !Number.isFinite(conv.idleTtlMs) ||
2124
+ !Number.isInteger(conv.idleTtlMs) || conv.idleTtlMs <= 0)) {
2125
+ throw new Error('Recipe conversations.idleTtlMs must be a positive finite integer.');
2126
+ }
2127
+ if (conv.closurePrompt !== undefined && (typeof conv.closurePrompt !== 'string' || !conv.closurePrompt.trim())) {
2128
+ throw new Error('Recipe conversations.closurePrompt must be a non-empty string.');
2129
+ }
2130
+ if (conv.agentPrefix !== undefined &&
2131
+ (typeof conv.agentPrefix !== 'string' || !/^[A-Za-z0-9_-]+$/.test(conv.agentPrefix))) {
2132
+ throw new Error(
2133
+ 'Recipe conversations.agentPrefix must be a non-empty string of [A-Za-z0-9_-] ' +
2134
+ '(it names fork agents and their Chronicle namespaces).',
2135
+ );
2136
+ }
2137
+ }
2138
+
1403
2139
  return obj as unknown as Recipe;
1404
2140
  }
1405
2141
 
@@ -1411,20 +2147,80 @@ function savedRecipePath(dataDir: string): string {
1411
2147
  return resolve(dataDir, '.recipe.json');
1412
2148
  }
1413
2149
 
1414
- export function saveRecipe(dataDir: string, recipe: Recipe): void {
2150
+ /**
2151
+ * Marker key stamped into saved `.recipe.json` snapshots that were written in
2152
+ * unresolved form (post-fix). Its presence tells loadSavedRecipe to run the
2153
+ * full substitute-and-validate pipeline on read; its absence means a legacy
2154
+ * snapshot saved fully resolved, which must be loaded verbatim — running
2155
+ * substitution over legacy content could hard-fail on a literal `${...}`
2156
+ * that survived in prose (e.g. a systemPrompt documenting env-var syntax).
2157
+ */
2158
+ export const SAVED_RECIPE_UNRESOLVED_KEY = '$unresolved';
2159
+
2160
+ /**
2161
+ * Snapshot a recipe to `$DATA_DIR/.recipe.json` so a later run without a
2162
+ * recipe argument resumes the same configuration.
2163
+ *
2164
+ * Pass the `persistable` half of loadRecipeDetailed(), NOT the resolved
2165
+ * recipe: `$DATA_DIR` is typically host-mounted and backed up, so the file
2166
+ * must never contain substituted secrets. The snapshot keeps `${VAR}`
2167
+ * references (and any URL systemPrompt) unresolved; loadSavedRecipe
2168
+ * re-resolves them against the environment current at resume time.
2169
+ *
2170
+ * The file is written 0600 and chmod'd to 0600 even when it already exists,
2171
+ * as defense in depth for legacy resolved snapshots being overwritten.
2172
+ */
2173
+ export function saveRecipe(dataDir: string, persistable: Record<string, unknown>): void {
1415
2174
  mkdirSync(dataDir, { recursive: true });
1416
- writeFileSync(savedRecipePath(dataDir), JSON.stringify(recipe, null, 2) + '\n', 'utf-8');
2175
+ const path = savedRecipePath(dataDir);
2176
+ const withMarker = { [SAVED_RECIPE_UNRESOLVED_KEY]: true, ...persistable };
2177
+ writeFileSync(path, JSON.stringify(withMarker, null, 2) + '\n', { encoding: 'utf-8', mode: 0o600 });
2178
+ // writeFileSync's mode only applies on creation — enforce on overwrite too.
2179
+ chmodSync(path, 0o600);
1417
2180
  }
1418
2181
 
1419
- export function loadSavedRecipe(dataDir: string): Recipe | null {
2182
+ /**
2183
+ * Load the `.recipe.json` snapshot for a resumed session, or null when there
2184
+ * is none (or it is unreadable/invalid).
2185
+ *
2186
+ * Snapshots carrying SAVED_RECIPE_UNRESOLVED_KEY are re-resolved through the
2187
+ * same pipeline loadRecipe uses — env substitution (so rotated secrets take
2188
+ * effect on restart), validation, and URL systemPrompt fetch. A missing
2189
+ * required `${VAR}` THROWS rather than returning null: silently falling back
2190
+ * to the default recipe would start a misconfigured agent, and the loud
2191
+ * failure names the variable to restore. A failed systemPrompt fetch throws
2192
+ * for the same reason.
2193
+ *
2194
+ * Legacy snapshots (no marker — saved fully resolved by older versions) are
2195
+ * validated and returned verbatim, exactly as before: no substitution, so a
2196
+ * literal `${...}` that survived resolution in prose cannot fail the load.
2197
+ */
2198
+ export async function loadSavedRecipe(dataDir: string): Promise<Recipe | null> {
1420
2199
  const path = savedRecipePath(dataDir);
1421
2200
  if (!existsSync(path)) return null;
2201
+ let raw: unknown;
1422
2202
  try {
1423
- const raw = JSON.parse(readFileSync(path, 'utf-8'));
1424
- return validateRecipe(raw);
2203
+ raw = JSON.parse(readFileSync(path, 'utf-8'));
1425
2204
  } catch {
1426
2205
  return null;
1427
2206
  }
2207
+
2208
+ if (!raw || typeof raw !== 'object' || !(SAVED_RECIPE_UNRESOLVED_KEY in raw)) {
2209
+ // Legacy resolved snapshot: load verbatim (no substitution).
2210
+ try {
2211
+ return validateRecipe(raw);
2212
+ } catch {
2213
+ return null;
2214
+ }
2215
+ }
2216
+
2217
+ const { [SAVED_RECIPE_UNRESOLVED_KEY]: _marker, ...unresolved } = raw as Record<string, unknown>;
2218
+ // Deliberately outside a try/catch: substitution and validation errors on
2219
+ // a snapshot we wrote ourselves are actionable operator errors (a rotated
2220
+ // secret removed from the environment), not corruption to shrug off.
2221
+ const substituted = substituteEnvVars(unresolved, path);
2222
+ const recipe = validateRecipe(substituted);
2223
+ return resolveSystemPrompt(recipe);
1428
2224
  }
1429
2225
 
1430
2226
  export function clearSavedRecipe(dataDir: string): void {