@animalabs/connectome-host 0.7.4 → 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.
- package/.env.example +12 -5
- package/.github/PULL_REQUEST_TEMPLATE.md +3 -2
- package/.github/workflows/changelog.yml +9 -4
- package/.github/workflows/ci.yml +5 -3
- package/.github/workflows/publish.yml +12 -6
- package/CHANGELOG.md +245 -0
- package/CONTRIBUTING.md +47 -19
- package/README.md +27 -0
- package/bun.lock +27 -31
- package/changelog.d/README.md +28 -0
- package/package.json +5 -5
- package/recipes/SETUP.md +11 -5
- package/recipes/TRIUMVIRATE-SETUP.md +68 -14
- package/recipes/knowledge-miner.json +0 -30
- package/recipes/mock-test.json +19 -0
- package/recipes/triumvirate.json +6 -1
- package/scripts/release-changelog.ts +210 -21
- package/src/cache-keepalive-log.ts +41 -0
- package/src/commands.ts +96 -0
- package/src/framework-strategy.ts +37 -0
- package/src/gate-telemetry.ts +106 -0
- package/src/headless.ts +10 -0
- package/src/index.ts +167 -55
- package/src/mcpl-config.ts +99 -1
- package/src/modules/identity-module.ts +310 -2
- package/src/modules/instructions-module.ts +265 -0
- package/src/modules/mcpl-admin-module.ts +58 -11
- package/src/modules/subagent-module.ts +18 -0
- package/src/recipe.ts +732 -25
- package/src/web/panel-data.ts +19 -0
- package/src/workspace-mounts.ts +73 -0
- package/test/audit-module-optins.test.ts +10 -3
- package/test/cache-keepalive-log.test.ts +83 -0
- package/test/conversations-recipe.test.ts +142 -0
- package/test/framework-fkm-composition.test.ts +35 -3
- package/test/framework-strategy-defaults.test.ts +19 -0
- package/test/gate-telemetry-adapter.test.ts +84 -0
- package/test/gate-telemetry.test.ts +91 -0
- package/test/identity-and-surfaces.test.ts +212 -1
- package/test/instructions-module.test.ts +258 -0
- package/test/mcpl-admin-module.test.ts +41 -0
- package/test/mcpl-agent-overlay.test.ts +51 -3
- package/test/mcpl-child-env.test.ts +64 -0
- package/test/nudge-command.test.ts +47 -0
- package/test/recipe-cache-keepalive.test.ts +59 -0
- package/test/recipe-compression-fallback.test.ts +19 -0
- package/test/recipe-hybrid-prose-routing.test.ts +12 -0
- package/test/recipe-instructions.test.ts +176 -0
- package/test/recipe-kv-unified.test.ts +87 -0
- package/test/recipe-mcp-source.test.ts +54 -0
- package/test/recipe-openai-compatible.test.ts +54 -0
- package/test/recipe-path-resolution.test.ts +19 -8
- package/test/recipe-provider.test.ts +14 -0
- package/test/recipe-save-unresolved.test.ts +244 -0
- package/test/recipe-source-only.test.ts +38 -0
- package/test/release-changelog.test.ts +202 -0
- package/test/subagent-prose-routing.test.ts +109 -0
- package/test/workspace-mounts.test.ts +68 -0
- package/web/src/App.tsx +1 -0
- 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,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. */
|
|
@@ -102,13 +124,53 @@ export interface RecipeStrategy {
|
|
|
102
124
|
identityReminder?: string;
|
|
103
125
|
}
|
|
104
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;
|
|
151
|
+
}
|
|
152
|
+
|
|
105
153
|
export interface RecipeAgent {
|
|
106
154
|
name?: string;
|
|
107
155
|
model?: string;
|
|
108
156
|
/** IANA zone used when rendering wall-clock times to the agent. */
|
|
109
157
|
timezone?: string;
|
|
110
|
-
/** Provider transport. Omitted preserves the historical Anthropic default.
|
|
111
|
-
|
|
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;
|
|
112
174
|
/** Message formatter. 'native' (default) = structured user/assistant turns.
|
|
113
175
|
* 'anthropic-xml' = classic prefill format ("participant: text" runs, XML
|
|
114
176
|
* tools) — for migrating prefill-era bots (chapterx borgs) with their exact
|
|
@@ -133,6 +195,28 @@ export interface RecipeAgent {
|
|
|
133
195
|
* Not forwarded on bedrock — that transport only has the default 5m
|
|
134
196
|
* cache and rejects the ttl field. */
|
|
135
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
|
+
};
|
|
136
220
|
/**
|
|
137
221
|
* Explicit prompt-caching override. Unset means provider-appropriate
|
|
138
222
|
* default: on for everything except bedrock models that predate caching
|
|
@@ -149,9 +233,12 @@ export interface RecipeAgent {
|
|
|
149
233
|
/**
|
|
150
234
|
* Prose delivery mode (agent-framework docs/explicit-prose-routing.md).
|
|
151
235
|
* 'explicit' = model prefixes plain text with `>>destination`; unprefixed
|
|
152
|
-
* prose bounces to a clipboard instead of auto-routing.
|
|
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'.
|
|
153
240
|
*/
|
|
154
|
-
proseRouting?: 'locus' | 'explicit';
|
|
241
|
+
proseRouting?: 'locus' | 'explicit' | 'hybrid' | 'disabled';
|
|
155
242
|
/**
|
|
156
243
|
* Extra Anthropic beta flags sent as the `anthropic-beta` header on every
|
|
157
244
|
* request (e.g. `["context-1m-2025-08-07"]` for the 1M context window on
|
|
@@ -191,6 +278,15 @@ export interface RecipeAgent {
|
|
|
191
278
|
codex?: {
|
|
192
279
|
fastMode?: boolean;
|
|
193
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
|
+
};
|
|
194
290
|
/**
|
|
195
291
|
* Content-refusal handling. When `autoRewind` is on, a `stop_reason: refusal`
|
|
196
292
|
* turn triggers an automatic rewind of the triggering turn + retry (keeping
|
|
@@ -267,13 +363,21 @@ export interface RecipeMcpServer {
|
|
|
267
363
|
|
|
268
364
|
/**
|
|
269
365
|
* How to obtain and install an MCP server at deploy time. Consumed by
|
|
270
|
-
* build tooling like connectome-cook.
|
|
271
|
-
*
|
|
272
|
-
* 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.
|
|
273
369
|
*/
|
|
274
370
|
export interface RecipeMcpServerSource {
|
|
275
|
-
/** Git URL to clone from. */
|
|
276
|
-
url
|
|
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;
|
|
277
381
|
/**
|
|
278
382
|
* Git ref: branch, tag, or commit SHA. Default: "main".
|
|
279
383
|
* If the value starts with "refs/" (e.g. "refs/pull/3/head"), it's
|
|
@@ -388,8 +492,9 @@ export interface RecipeCredentialFileField {
|
|
|
388
492
|
|
|
389
493
|
/**
|
|
390
494
|
* Subset of MountConfig exposed to recipes.
|
|
391
|
-
* Intentionally omits watchDebounceMs
|
|
392
|
-
*
|
|
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.
|
|
393
498
|
*/
|
|
394
499
|
export interface RecipeWorkspaceMount {
|
|
395
500
|
name: string;
|
|
@@ -397,6 +502,8 @@ export interface RecipeWorkspaceMount {
|
|
|
397
502
|
mode?: 'read-write' | 'read-only';
|
|
398
503
|
watch?: 'always' | 'on-agent-action' | 'never';
|
|
399
504
|
ignore?: string[];
|
|
505
|
+
/** Maximum file size in bytes (defaults to the framework's 5 MiB limit). */
|
|
506
|
+
maxFileSize?: number;
|
|
400
507
|
/**
|
|
401
508
|
* Request inference when files in this mount change. Pair with
|
|
402
509
|
* `watch: 'always'` so chokidar actually observes the mount.
|
|
@@ -441,6 +548,52 @@ export interface RecipeModules {
|
|
|
441
548
|
};
|
|
442
549
|
wake?: boolean | import('@animalabs/agent-framework').GateConfig;
|
|
443
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
|
+
};
|
|
444
597
|
/**
|
|
445
598
|
* Surface agent composition activity (typing indicators) to one or more
|
|
446
599
|
* MCPL channels while inference is active. Opt-in per recipe; channel IDs
|
|
@@ -699,6 +852,41 @@ export interface RecipeCodeExecution {
|
|
|
699
852
|
idleReclaimMs?: number;
|
|
700
853
|
}
|
|
701
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
|
+
|
|
702
890
|
export interface Recipe {
|
|
703
891
|
name: string;
|
|
704
892
|
description?: string;
|
|
@@ -711,6 +899,8 @@ export interface Recipe {
|
|
|
711
899
|
sessionNaming?: { examples?: string[] };
|
|
712
900
|
/** Client-side programmatic tool calling (code_execution tool). */
|
|
713
901
|
codeExecution?: RecipeCodeExecution;
|
|
902
|
+
/** Per-channel conversation routing — fork-per-channel from this agent. */
|
|
903
|
+
conversations?: RecipeConversations;
|
|
714
904
|
}
|
|
715
905
|
|
|
716
906
|
// ---------------------------------------------------------------------------
|
|
@@ -812,6 +1002,27 @@ export function substituteEnvVars(value: unknown, source: string): unknown {
|
|
|
812
1002
|
*/
|
|
813
1003
|
type RecipeSourceBase = { kind: 'file'; dir: string } | { kind: 'url'; base: string };
|
|
814
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
|
+
|
|
815
1026
|
/**
|
|
816
1027
|
* Load a recipe from a URL or local file path.
|
|
817
1028
|
* If the systemPrompt value is an HTTP(S) URL, fetches the text.
|
|
@@ -821,6 +1032,15 @@ type RecipeSourceBase = { kind: 'file'; dir: string } | { kind: 'url'; base: str
|
|
|
821
1032
|
* parent recipe's directory (or URL base) so sibling recipes are portable.
|
|
822
1033
|
*/
|
|
823
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> {
|
|
824
1044
|
let raw: unknown;
|
|
825
1045
|
let sourceBase: RecipeSourceBase;
|
|
826
1046
|
|
|
@@ -836,11 +1056,54 @@ export async function loadRecipe(source: string): Promise<Recipe> {
|
|
|
836
1056
|
sourceBase = { kind: 'file', dir: dirname(path) };
|
|
837
1057
|
}
|
|
838
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
|
+
|
|
839
1063
|
raw = substituteEnvVars(raw, source);
|
|
840
1064
|
const recipe = validateRecipe(raw);
|
|
841
1065
|
resolveChildRecipePaths(recipe, sourceBase);
|
|
842
1066
|
resolveExtensionPaths(recipe, sourceBase);
|
|
843
|
-
|
|
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
|
+
}
|
|
844
1107
|
}
|
|
845
1108
|
|
|
846
1109
|
/**
|
|
@@ -902,6 +1165,87 @@ async function resolveSystemPrompt(recipe: Recipe): Promise<Recipe> {
|
|
|
902
1165
|
return recipe;
|
|
903
1166
|
}
|
|
904
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
|
+
|
|
905
1249
|
/**
|
|
906
1250
|
* Validate raw JSON and fill defaults.
|
|
907
1251
|
*/
|
|
@@ -917,8 +1261,13 @@ export function validateRecipe(raw: unknown): Recipe {
|
|
|
917
1261
|
}
|
|
918
1262
|
|
|
919
1263
|
const agent = obj.agent as Record<string, unknown>;
|
|
920
|
-
|
|
921
|
-
|
|
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');
|
|
922
1271
|
}
|
|
923
1272
|
|
|
924
1273
|
if (agent.provider !== undefined &&
|
|
@@ -926,13 +1275,25 @@ export function validateRecipe(raw: unknown): Recipe {
|
|
|
926
1275
|
agent.provider !== 'openai-responses' &&
|
|
927
1276
|
agent.provider !== 'openai-codex' &&
|
|
928
1277
|
agent.provider !== 'openrouter' &&
|
|
929
|
-
agent.provider !== 'bedrock'
|
|
1278
|
+
agent.provider !== 'bedrock' &&
|
|
1279
|
+
agent.provider !== 'openai-compatible' &&
|
|
1280
|
+
agent.provider !== 'mock') {
|
|
930
1281
|
throw new Error(
|
|
931
|
-
`Recipe agent.provider must be 'anthropic', 'openai-responses', 'openai-codex', 'openrouter', or '
|
|
1282
|
+
`Recipe agent.provider must be 'anthropic', 'openai-responses', 'openai-codex', 'openrouter', 'bedrock', 'openai-compatible', or 'mock', ` +
|
|
932
1283
|
`got ${JSON.stringify(agent.provider)}.`,
|
|
933
1284
|
);
|
|
934
1285
|
}
|
|
935
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
|
+
|
|
936
1297
|
if (agent.timezone !== undefined) {
|
|
937
1298
|
if (typeof agent.timezone !== 'string' || !agent.timezone.trim()) {
|
|
938
1299
|
throw new Error('Recipe agent.timezone must be a non-empty IANA time zone string.');
|
|
@@ -979,6 +1340,20 @@ export function validateRecipe(raw: unknown): Recipe {
|
|
|
979
1340
|
}
|
|
980
1341
|
}
|
|
981
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
|
+
|
|
982
1357
|
if (agent.maxStreamTokens !== undefined && (typeof agent.maxStreamTokens !== 'number' || agent.maxStreamTokens <= 0)) {
|
|
983
1358
|
throw new Error('Recipe agent.maxStreamTokens must be a positive number.');
|
|
984
1359
|
}
|
|
@@ -990,6 +1365,63 @@ export function validateRecipe(raw: unknown): Recipe {
|
|
|
990
1365
|
}
|
|
991
1366
|
agent.cacheTtl ??= '1h';
|
|
992
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
|
+
|
|
993
1425
|
if (agent.promptCaching !== undefined && typeof agent.promptCaching !== 'boolean') {
|
|
994
1426
|
throw new Error(
|
|
995
1427
|
`Recipe agent.promptCaching must be a boolean, got ${JSON.stringify(agent.promptCaching)}.`,
|
|
@@ -1083,6 +1515,21 @@ export function validateRecipe(raw: unknown): Recipe {
|
|
|
1083
1515
|
`recipe's "extensions" block (none is declared).`,
|
|
1084
1516
|
);
|
|
1085
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);
|
|
1086
1533
|
if (
|
|
1087
1534
|
strategy.compressionRefusalCurveFallbacks !== undefined
|
|
1088
1535
|
&& (
|
|
@@ -1103,6 +1550,26 @@ export function validateRecipe(raw: unknown): Recipe {
|
|
|
1103
1550
|
) {
|
|
1104
1551
|
throw new Error('Recipe agent.strategy.compressionContextBudgetTokens must be a positive safe integer.');
|
|
1105
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
|
+
}
|
|
1106
1573
|
}
|
|
1107
1574
|
|
|
1108
1575
|
const refusalHandling = agent.refusalHandling as Record<string, unknown> | undefined;
|
|
@@ -1130,8 +1597,16 @@ export function validateRecipe(raw: unknown): Recipe {
|
|
|
1130
1597
|
throw new Error(`mcpServers.${id}.source must be an object`);
|
|
1131
1598
|
}
|
|
1132
1599
|
const src = server.source as Record<string, unknown>;
|
|
1133
|
-
|
|
1134
|
-
|
|
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
|
+
);
|
|
1135
1610
|
}
|
|
1136
1611
|
if (src.ref !== undefined && typeof src.ref !== 'string') {
|
|
1137
1612
|
throw new Error(`mcpServers.${id}.source.ref must be a string`);
|
|
@@ -1269,6 +1744,122 @@ export function validateRecipe(raw: unknown): Recipe {
|
|
|
1269
1744
|
}
|
|
1270
1745
|
}
|
|
1271
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
|
+
|
|
1272
1863
|
// Validate retrieval provider reasoning when configured.
|
|
1273
1864
|
const retrieval = mods.retrieval;
|
|
1274
1865
|
if (retrieval !== undefined && typeof retrieval !== 'boolean') {
|
|
@@ -1400,6 +1991,62 @@ export function validateRecipe(raw: unknown): Recipe {
|
|
|
1400
1991
|
}
|
|
1401
1992
|
}
|
|
1402
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
|
+
|
|
1403
2050
|
return obj as unknown as Recipe;
|
|
1404
2051
|
}
|
|
1405
2052
|
|
|
@@ -1411,20 +2058,80 @@ function savedRecipePath(dataDir: string): string {
|
|
|
1411
2058
|
return resolve(dataDir, '.recipe.json');
|
|
1412
2059
|
}
|
|
1413
2060
|
|
|
1414
|
-
|
|
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 {
|
|
1415
2085
|
mkdirSync(dataDir, { recursive: true });
|
|
1416
|
-
|
|
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);
|
|
1417
2091
|
}
|
|
1418
2092
|
|
|
1419
|
-
|
|
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> {
|
|
1420
2110
|
const path = savedRecipePath(dataDir);
|
|
1421
2111
|
if (!existsSync(path)) return null;
|
|
2112
|
+
let raw: unknown;
|
|
1422
2113
|
try {
|
|
1423
|
-
|
|
1424
|
-
return validateRecipe(raw);
|
|
2114
|
+
raw = JSON.parse(readFileSync(path, 'utf-8'));
|
|
1425
2115
|
} catch {
|
|
1426
2116
|
return null;
|
|
1427
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);
|
|
1428
2135
|
}
|
|
1429
2136
|
|
|
1430
2137
|
export function clearSavedRecipe(dataDir: string): void {
|