@animalabs/connectome-host 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +20 -0
- package/.github/workflows/publish.yml +65 -0
- package/ARCHITECTURE.md +355 -0
- package/CHANGELOG.md +30 -0
- package/HEADLESS-FLEET-PLAN.md +330 -0
- package/README.md +189 -0
- package/UNIFIED-TREE-PLAN.md +242 -0
- package/bun.lock +288 -0
- package/docs/AGENT-MEMORY-GUIDE.md +214 -0
- package/docs/AGENT-ONBOARDING.md +541 -0
- package/docs/ATTENTION-AND-GATING.md +120 -0
- package/docs/DEPLOYMENTS.md +130 -0
- package/docs/DEV-ENVIRONMENT.md +215 -0
- package/docs/LIBRARY-PIPELINE.md +286 -0
- package/docs/LOCUS-ROUTING-DESIGN.md +115 -0
- package/docs/claudeai-evacuation.md +228 -0
- package/docs/debug-context-api.md +208 -0
- package/docs/webui-deployment.md +219 -0
- package/package.json +33 -0
- package/recipes/SETUP.md +308 -0
- package/recipes/TRIUMVIRATE-SETUP.md +467 -0
- package/recipes/claude-export-revive.json +19 -0
- package/recipes/clerk.json +157 -0
- package/recipes/knowledge-miner.json +174 -0
- package/recipes/knowledge-reviewer.json +76 -0
- package/recipes/mcpl-editor-test.json +38 -0
- package/recipes/prompts/transplant-addendum.md +17 -0
- package/recipes/triumvirate.json +63 -0
- package/recipes/webui-fleet-test.json +27 -0
- package/recipes/webui-test.json +16 -0
- package/recipes/zulip-miner.json +87 -0
- package/scripts/connectome-doctor +373 -0
- package/scripts/evacuator.ts +956 -0
- package/scripts/import-claudeai-export.ts +747 -0
- package/scripts/lib/line-reader.ts +53 -0
- package/scripts/test-historical-thinking.ts +148 -0
- package/scripts/warmup-session.ts +336 -0
- package/src/agent-name.ts +58 -0
- package/src/commands.ts +1074 -0
- package/src/headless.ts +528 -0
- package/src/index.ts +867 -0
- package/src/logging-adapter.ts +208 -0
- package/src/mcpl-config.ts +199 -0
- package/src/modules/activity-module.ts +270 -0
- package/src/modules/channel-mode-module.ts +260 -0
- package/src/modules/fleet-module.ts +1705 -0
- package/src/modules/fleet-types.ts +225 -0
- package/src/modules/lessons-module.ts +465 -0
- package/src/modules/mcpl-admin-module.ts +345 -0
- package/src/modules/retrieval-module.ts +327 -0
- package/src/modules/settings-module.ts +143 -0
- package/src/modules/subagent-module.ts +2074 -0
- package/src/modules/subscription-gc-module.ts +322 -0
- package/src/modules/time-module.ts +85 -0
- package/src/modules/tui-module.ts +76 -0
- package/src/modules/web-ui-curve-page.ts +249 -0
- package/src/modules/web-ui-module.ts +2595 -0
- package/src/recipe.ts +1003 -0
- package/src/session-manager.ts +278 -0
- package/src/state/agent-tree-reducer.ts +431 -0
- package/src/state/fleet-tree-aggregator.ts +195 -0
- package/src/strategies/frontdesk-strategy.ts +364 -0
- package/src/synesthete.ts +68 -0
- package/src/tui.ts +2200 -0
- package/src/types/bun-ffi.d.ts +6 -0
- package/src/web/protocol.ts +648 -0
- package/test/agent-name.test.ts +62 -0
- package/test/agent-tree-fleet-launch.test.ts +64 -0
- package/test/agent-tree-reducer-parity.test.ts +194 -0
- package/test/agent-tree-reducer.test.ts +443 -0
- package/test/agent-tree-rollup.test.ts +109 -0
- package/test/autobio-progress-snapshot.test.ts +40 -0
- package/test/claudeai-export-importer.test.ts +386 -0
- package/test/evacuator.test.ts +332 -0
- package/test/fleet-commands.test.ts +244 -0
- package/test/fleet-no-subfleets.test.ts +147 -0
- package/test/fleet-orchestration.test.ts +353 -0
- package/test/fleet-recipe.test.ts +124 -0
- package/test/fleet-route.test.ts +44 -0
- package/test/fleet-smoke.test.ts +479 -0
- package/test/fleet-subscribe-union-e2e.test.ts +123 -0
- package/test/fleet-subscribe-union.test.ts +85 -0
- package/test/fleet-tree-aggregator-e2e.test.ts +110 -0
- package/test/fleet-tree-aggregator.test.ts +215 -0
- package/test/frontdesk-strategy.test.ts +326 -0
- package/test/headless-describe.test.ts +182 -0
- package/test/headless-smoke.test.ts +190 -0
- package/test/logging-adapter.test.ts +87 -0
- package/test/mcpl-admin-module.test.ts +213 -0
- package/test/mcpl-agent-overlay.test.ts +126 -0
- package/test/mock-headless-child.ts +133 -0
- package/test/recipe-cache-ttl.test.ts +37 -0
- package/test/recipe-env.test.ts +157 -0
- package/test/recipe-path-resolution.test.ts +170 -0
- package/test/subagent-async-timeout.test.ts +381 -0
- package/test/subagent-fork-materialise.test.ts +241 -0
- package/test/subagent-peek-scoping.test.ts +180 -0
- package/test/subagent-peek-zombie.test.ts +148 -0
- package/test/subagent-reaper-in-flight.test.ts +229 -0
- package/test/subscription-gc-module.test.ts +136 -0
- package/test/warmup-handoff.test.ts +150 -0
- package/test/web-ui-bind.test.ts +51 -0
- package/test/web-ui-module.test.ts +246 -0
- package/test/web-ui-protocol.test.ts +0 -0
- package/tsconfig.json +14 -0
- package/web/bun.lock +357 -0
- package/web/index.html +12 -0
- package/web/package.json +24 -0
- package/web/src/App.tsx +1931 -0
- package/web/src/Context.tsx +158 -0
- package/web/src/ContextDocument.tsx +150 -0
- package/web/src/Files.tsx +271 -0
- package/web/src/Lessons.tsx +164 -0
- package/web/src/Mcpl.tsx +310 -0
- package/web/src/Stream.tsx +119 -0
- package/web/src/TreeSidebar.tsx +283 -0
- package/web/src/Usage.tsx +182 -0
- package/web/src/main.tsx +7 -0
- package/web/src/styles.css +26 -0
- package/web/src/tree.ts +268 -0
- package/web/src/wire.ts +120 -0
- package/web/tsconfig.json +21 -0
- package/web/vite.config.ts +32 -0
package/src/recipe.ts
ADDED
|
@@ -0,0 +1,1003 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recipe system — config-driven agent bootstrapping.
|
|
3
|
+
*
|
|
4
|
+
* A recipe defines everything domain-specific about an agent session:
|
|
5
|
+
* system prompt, MCP servers, which modules to enable, and naming hints.
|
|
6
|
+
*
|
|
7
|
+
* Recipes can be loaded from:
|
|
8
|
+
* - HTTP(S) URLs
|
|
9
|
+
* - Local file paths
|
|
10
|
+
* - Saved state from a previous run (data/.recipe.json)
|
|
11
|
+
* - Built-in default (generic assistant)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from 'node:fs';
|
|
15
|
+
import { dirname, isAbsolute, resolve } from 'node:path';
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Types
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
export interface RecipeStrategy {
|
|
22
|
+
type: 'autobiographical' | 'passthrough' | 'frontdesk';
|
|
23
|
+
// Core window/compression sizing
|
|
24
|
+
headWindowTokens?: number;
|
|
25
|
+
recentWindowTokens?: number;
|
|
26
|
+
compressionModel?: string;
|
|
27
|
+
maxMessageTokens?: number;
|
|
28
|
+
overBudgetGraceRatio?: number;
|
|
29
|
+
// Compression/merge tuning passed through to the underlying
|
|
30
|
+
// autobiographical strategy (and frontdesk, which extends it).
|
|
31
|
+
// Declared here so a typo in a recipe (e.g. `l1BudgetTokes`) fails
|
|
32
|
+
// at recipe validation rather than silently defaulting at strategy
|
|
33
|
+
// construction. Keep in sync with `AutobiographicalConfig` in
|
|
34
|
+
// @animalabs/context-manager; this interface is the source of truth
|
|
35
|
+
// for what the recipe loader accepts under `agent.strategy`.
|
|
36
|
+
enforceBudget?: boolean;
|
|
37
|
+
maxSpeculativeL1s?: number;
|
|
38
|
+
positionedRecallPairs?: boolean;
|
|
39
|
+
recallHeaderTemplate?: string;
|
|
40
|
+
targetChunkTokens?: number;
|
|
41
|
+
mergeThreshold?: number;
|
|
42
|
+
summaryTargetTokens?: number;
|
|
43
|
+
l1BudgetTokens?: number;
|
|
44
|
+
l2BudgetTokens?: number;
|
|
45
|
+
l3BudgetTokens?: number;
|
|
46
|
+
toolResultMaxLastN?: number;
|
|
47
|
+
toolUseInputMaxTokens?: number;
|
|
48
|
+
// Adaptive resolution (document-based gradual compression). For the
|
|
49
|
+
// 'autobiographical' strategy type this defaults ON (see index.ts); set
|
|
50
|
+
// `adaptiveResolution: false` to opt back to the count-based hierarchical
|
|
51
|
+
// renderer. The remaining knobs only apply when adaptiveResolution is true.
|
|
52
|
+
adaptiveResolution?: boolean;
|
|
53
|
+
/** kv-stable: trust region P on per-turn perturbation (tokens re-read).
|
|
54
|
+
* See context-manager adaptive-resolution-design.md §13. */
|
|
55
|
+
kvStableReachTokens?: number;
|
|
56
|
+
/** kv-stable: quality-gap override threshold (§13.4). Default 0.35. */
|
|
57
|
+
kvStableQualityGapRatio?: number;
|
|
58
|
+
compressionSlackRatio?: number;
|
|
59
|
+
foldingStrategy?: 'flat-profile' | 'oldest-first' | 'kv-stable';
|
|
60
|
+
speculativeProduction?: boolean;
|
|
61
|
+
/** L1 production holdback: keep the newest N closed chunks out of the
|
|
62
|
+
* speculative compression queue (default 1); demand still overrides. */
|
|
63
|
+
l1HoldbackChunks?: number;
|
|
64
|
+
// Self-voice / compression framing. When the agent's name differs from
|
|
65
|
+
// 'Claude' (the strategy default for summaryParticipant), these MUST be set
|
|
66
|
+
// (especially summaryParticipant: <agent.name>) — otherwise self-recollections
|
|
67
|
+
// are stored under a stranger's participant and surface in the compiled prompt
|
|
68
|
+
// as another voice speaking in first person about the agent.
|
|
69
|
+
summaryParticipant?: string;
|
|
70
|
+
summarySystemPrompt?: string;
|
|
71
|
+
summaryUserPrompt?: string;
|
|
72
|
+
summaryContextLabel?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface RecipeAgent {
|
|
76
|
+
name?: string;
|
|
77
|
+
model?: string;
|
|
78
|
+
systemPrompt: string;
|
|
79
|
+
maxTokens?: number;
|
|
80
|
+
/**
|
|
81
|
+
* Per-agent stream token budget — the accumulated-input ceiling at which the
|
|
82
|
+
* framework breaks the stream and lets the strategy compress. Mirrors the
|
|
83
|
+
* runtime `/budget` command. When unset, the framework default (150k) applies.
|
|
84
|
+
*/
|
|
85
|
+
maxStreamTokens?: number;
|
|
86
|
+
/** Per-agent context compile budget (input tokens). When unset, the
|
|
87
|
+
* ContextManager default (100k) applies. Raise for large-context models. */
|
|
88
|
+
contextBudgetTokens?: number;
|
|
89
|
+
/** Prompt-cache TTL ('5m' | '1h') forwarded to the provider. Set '1h' for
|
|
90
|
+
* agents whose reply cadence is slower than 5 minutes — avoids re-writing
|
|
91
|
+
* the full context to cache after every gap. Unset: provider default (5m). */
|
|
92
|
+
cacheTtl?: '5m' | '1h';
|
|
93
|
+
strategy?: RecipeStrategy;
|
|
94
|
+
/**
|
|
95
|
+
* Native extended thinking. When `enabled: true`, the agent's API requests
|
|
96
|
+
* include Anthropic's thinking config and responses carry signed `thinking`
|
|
97
|
+
* blocks. Required for matching the cognitive mode of claude.ai web Sonnet.
|
|
98
|
+
*/
|
|
99
|
+
thinking?: {
|
|
100
|
+
enabled: boolean;
|
|
101
|
+
budgetTokens?: number;
|
|
102
|
+
};
|
|
103
|
+
/**
|
|
104
|
+
* Content-refusal handling. When `autoRewind` is on, a `stop_reason: refusal`
|
|
105
|
+
* turn triggers an automatic rewind of the triggering turn + retry (keeping
|
|
106
|
+
* the agent on its own model). See agent-framework AgentConfig.refusalHandling.
|
|
107
|
+
*/
|
|
108
|
+
refusalHandling?: {
|
|
109
|
+
autoRewind?: boolean;
|
|
110
|
+
maxRewinds?: number;
|
|
111
|
+
announceHumanTurns?: boolean;
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface RecipeMcpServer {
|
|
116
|
+
/** Command to spawn (stdio transport). Mutually exclusive with url. */
|
|
117
|
+
command?: string;
|
|
118
|
+
args?: string[];
|
|
119
|
+
env?: Record<string, string>;
|
|
120
|
+
/** WebSocket URL (WebSocket transport). Mutually exclusive with command. */
|
|
121
|
+
url?: string;
|
|
122
|
+
transport?: 'stdio' | 'websocket';
|
|
123
|
+
/** Bearer token for WebSocket auth (appended as ?token= query param). */
|
|
124
|
+
token?: string;
|
|
125
|
+
toolPrefix?: string;
|
|
126
|
+
enabledFeatureSets?: string[];
|
|
127
|
+
disabledFeatureSets?: string[];
|
|
128
|
+
/**
|
|
129
|
+
* Tool allow-list (bare tool names as the server exports them, no toolPrefix).
|
|
130
|
+
* `*` is a substring wildcard (`read_*`, `*_file`, `*`).
|
|
131
|
+
* If set, only matching tools are exposed to the model AND callable at dispatch.
|
|
132
|
+
*/
|
|
133
|
+
enabledTools?: string[];
|
|
134
|
+
/**
|
|
135
|
+
* Tool deny-list (same syntax as enabledTools). Wins over enabledTools on overlap.
|
|
136
|
+
* Denied tools are hidden from the model and rejected at dispatch — both, so a
|
|
137
|
+
* model that imitates a prior call from message history can't sneak the call through.
|
|
138
|
+
*/
|
|
139
|
+
disabledTools?: string[];
|
|
140
|
+
reconnect?: boolean;
|
|
141
|
+
/** Base reconnect interval; the framework doubles it per consecutive
|
|
142
|
+
* failure (with jitter) up to reconnectMaxIntervalMs. */
|
|
143
|
+
reconnectIntervalMs?: number;
|
|
144
|
+
/** Ceiling for the exponential reconnect backoff. Default: 5 minutes. */
|
|
145
|
+
reconnectMaxIntervalMs?: number;
|
|
146
|
+
/**
|
|
147
|
+
* Channel auto-open policy. 'auto' (default) opens everything the server
|
|
148
|
+
* registers; 'manual' opens nothing (agent calls channel_open as needed);
|
|
149
|
+
* a string[] is an allow-list of channel ids.
|
|
150
|
+
*/
|
|
151
|
+
channelSubscription?: 'auto' | 'manual' | string[];
|
|
152
|
+
/**
|
|
153
|
+
* Optional install metadata for build/deploy tooling (e.g. connectome-cook).
|
|
154
|
+
* Ignored by the recipe loader at runtime — agents don't need this; only
|
|
155
|
+
* tools that produce deployable artifacts (Docker images, etc.) do.
|
|
156
|
+
*/
|
|
157
|
+
source?: RecipeMcpServerSource;
|
|
158
|
+
/**
|
|
159
|
+
* Auxiliary credential / config files this MCP server needs at runtime
|
|
160
|
+
* (e.g. `.zuliprc` for the Zulip adapter, `~/.netrc` for HTTP-auth APIs,
|
|
161
|
+
* a `gcloud.json` service-account key for Google APIs). Build tooling
|
|
162
|
+
* collects values from the operator (env vars or interactive prompts),
|
|
163
|
+
* writes the file, and bind-mounts it into the container. The recipe
|
|
164
|
+
* loader itself does NOT touch these — the runtime reads the file via
|
|
165
|
+
* whatever path the MCP server expects (typically declared in `env`).
|
|
166
|
+
*
|
|
167
|
+
* Like `source`, this is build-tooling metadata. Ignored at runtime.
|
|
168
|
+
*/
|
|
169
|
+
credentialFiles?: RecipeCredentialFile[];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* How to obtain and install an MCP server at deploy time. Consumed by
|
|
174
|
+
* build tooling like connectome-cook. All fields optional-at-the-schema-
|
|
175
|
+
* layer except `url`; tools may require more depending on the install
|
|
176
|
+
* pattern they're generating.
|
|
177
|
+
*/
|
|
178
|
+
export interface RecipeMcpServerSource {
|
|
179
|
+
/** Git URL to clone from. */
|
|
180
|
+
url: string;
|
|
181
|
+
/**
|
|
182
|
+
* Git ref: branch, tag, or commit SHA. Default: "main".
|
|
183
|
+
* If the value starts with "refs/" (e.g. "refs/pull/3/head"), it's
|
|
184
|
+
* treated as an explicit refspec — tools fetch it then check out
|
|
185
|
+
* FETCH_HEAD. Useful for tracking PR heads on GitHub/GitLab.
|
|
186
|
+
*/
|
|
187
|
+
ref?: string;
|
|
188
|
+
/**
|
|
189
|
+
* How to install inside the cloned dir at build time.
|
|
190
|
+
* "npm" runs `npm install && npm run build`; implies node runtime.
|
|
191
|
+
* "pip-editable" creates a venv at `.venv/` and runs `pip install -e .`;
|
|
192
|
+
* implies python3 runtime.
|
|
193
|
+
* { run, runtime } runs an arbitrary shell command from the cloned dir;
|
|
194
|
+
* `runtime` tells the tool which base packages to install
|
|
195
|
+
* in the image (node / python3 / custom / bun = no defaults).
|
|
196
|
+
* Omit entirely for sources that need no build step (e.g. the tool is
|
|
197
|
+
* fetched at runtime by npx/uvx — the recipe's `command` is enough).
|
|
198
|
+
*/
|
|
199
|
+
install?:
|
|
200
|
+
| 'npm'
|
|
201
|
+
| 'pip-editable'
|
|
202
|
+
| { run: string; runtime: 'node' | 'python3' | 'custom' | 'bun' };
|
|
203
|
+
/**
|
|
204
|
+
* Build-arg name for an auth token if the URL is private.
|
|
205
|
+
* Tools consuming `source` should mount this via BuildKit secrets
|
|
206
|
+
* (so the token doesn't leak into image layers). The same env var
|
|
207
|
+
* name is typically already needed at runtime by the operator's
|
|
208
|
+
* `.env`, so this field doesn't introduce new secret surface.
|
|
209
|
+
*/
|
|
210
|
+
authSecret?: string;
|
|
211
|
+
/**
|
|
212
|
+
* True if the URL's TLS cert isn't in the container's trust store
|
|
213
|
+
* (e.g. self-signed internal CAs on private GitLab/Gitea). Tools
|
|
214
|
+
* should add `-c http.sslVerify=false` to the git clone of this source.
|
|
215
|
+
* Prefer installing a proper CA bundle in the image over using this flag.
|
|
216
|
+
*/
|
|
217
|
+
sslBypass?: boolean;
|
|
218
|
+
/**
|
|
219
|
+
* Override the in-container path of the installed source. Default:
|
|
220
|
+
* `/<basename-of-url-without-.git-suffix>` (e.g. the URL
|
|
221
|
+
* `https://github.com/x/zulip_mcp.git` places the source at `/zulip_mcp`).
|
|
222
|
+
*/
|
|
223
|
+
inContainer?: { path: string };
|
|
224
|
+
/**
|
|
225
|
+
* Extra apt packages this source needs in the runtime image (e.g.
|
|
226
|
+
* `ffmpeg`/`curl` for a tool that shells out to them). Build tooling
|
|
227
|
+
* appends these to the runtime stage's apt install; connectome-host
|
|
228
|
+
* ignores the field at runtime (it doesn't build images).
|
|
229
|
+
*/
|
|
230
|
+
systemPackages?: string[];
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* One auxiliary credential or config file the MCP server reads at runtime.
|
|
235
|
+
* Build tooling (cook, etc.) prompts the operator for the field values,
|
|
236
|
+
* serializes them in the declared format, and writes the file at the
|
|
237
|
+
* declared path so the bind-mounted file appears in the container.
|
|
238
|
+
*
|
|
239
|
+
* Example for Zulip's `.zuliprc`:
|
|
240
|
+
* ```json
|
|
241
|
+
* {
|
|
242
|
+
* "path": "./.zuliprc",
|
|
243
|
+
* "format": "ini",
|
|
244
|
+
* "section": "api",
|
|
245
|
+
* "mode": "0600",
|
|
246
|
+
* "fields": [
|
|
247
|
+
* { "name": "email", "envOverride": "ZULIP_EMAIL", "placeholder": "bot@example.zulipchat.com" },
|
|
248
|
+
* { "name": "key", "envOverride": "ZULIP_KEY", "placeholder": "abc123...", "secret": true },
|
|
249
|
+
* { "name": "site", "envOverride": "ZULIP_SITE", "placeholder": "https://example.zulipchat.com" }
|
|
250
|
+
* ]
|
|
251
|
+
* }
|
|
252
|
+
* ```
|
|
253
|
+
*/
|
|
254
|
+
export interface RecipeCredentialFile {
|
|
255
|
+
/** Where the runtime expects the file. In a Docker context this also
|
|
256
|
+
* determines the bind-mount target; the host-side path is `<outDir>` +
|
|
257
|
+
* basename(path). Relative paths resolve against the conductor's CWD
|
|
258
|
+
* (typically `/app`); absolute paths land verbatim. */
|
|
259
|
+
path: string;
|
|
260
|
+
/** Serialization format. `ini` writes `key=value` lines under an
|
|
261
|
+
* optional `[section]` header; `json` writes `{ "field": "value", ... }`;
|
|
262
|
+
* `env` writes `KEY=value` (no quoting). */
|
|
263
|
+
format: 'ini' | 'json' | 'env';
|
|
264
|
+
/** Optional INI section header (`[<section>]`) preceding the fields.
|
|
265
|
+
* Ignored by `json` / `env` formats. */
|
|
266
|
+
section?: string;
|
|
267
|
+
/** Filesystem mode as an octal string. Default `0600` (typical for
|
|
268
|
+
* credentials). Build tooling sets this on the host file before bind-
|
|
269
|
+
* mounting; the container inherits the mode through the mount. */
|
|
270
|
+
mode?: string;
|
|
271
|
+
/** Fields the operator must supply. */
|
|
272
|
+
fields: RecipeCredentialFileField[];
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export interface RecipeCredentialFileField {
|
|
276
|
+
/** Field name as written in the file (e.g. `email`, `key`, `site`).
|
|
277
|
+
* For `ini` / `env` formats this is the literal key. For `json` it
|
|
278
|
+
* becomes the JSON property name. */
|
|
279
|
+
name: string;
|
|
280
|
+
/** Optional env var name that, when set in `process.env` (or supplied
|
|
281
|
+
* via cook's `--env-file`), substitutes for prompting. Lets the
|
|
282
|
+
* operator pre-set values in CI / scripted contexts. */
|
|
283
|
+
envOverride?: string;
|
|
284
|
+
/** Operator-facing description of what to enter (one short line). */
|
|
285
|
+
description?: string;
|
|
286
|
+
/** Placeholder shown next to the prompt (e.g. `bot@example.zulipchat.com`). */
|
|
287
|
+
placeholder?: string;
|
|
288
|
+
/** When true, build tooling masks the input (no echo while typing).
|
|
289
|
+
* Default `false`. */
|
|
290
|
+
secret?: boolean;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Subset of MountConfig exposed to recipes.
|
|
295
|
+
* Intentionally omits watchDebounceMs, followSymlinks, and maxFileSize —
|
|
296
|
+
* these are implementation details best left to framework defaults.
|
|
297
|
+
*/
|
|
298
|
+
export interface RecipeWorkspaceMount {
|
|
299
|
+
name: string;
|
|
300
|
+
path: string;
|
|
301
|
+
mode?: 'read-write' | 'read-only';
|
|
302
|
+
watch?: 'always' | 'on-agent-action' | 'never';
|
|
303
|
+
ignore?: string[];
|
|
304
|
+
/**
|
|
305
|
+
* Request inference when files in this mount change. Pair with
|
|
306
|
+
* `watch: 'always'` so chokidar actually observes the mount.
|
|
307
|
+
* - `true` — any of created | modified | deleted
|
|
308
|
+
* - array — only the listed ops
|
|
309
|
+
*/
|
|
310
|
+
wakeOnChange?: boolean | Array<'created' | 'modified' | 'deleted'>;
|
|
311
|
+
/**
|
|
312
|
+
* Persist every write/edit/delete to disk immediately. Required for mounts
|
|
313
|
+
* shared across agents as a communication channel (tickets, reports, etc.)
|
|
314
|
+
* so other agents' chokidar watchers actually see the change.
|
|
315
|
+
*/
|
|
316
|
+
autoMaterialize?: boolean;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export interface RecipeModules {
|
|
320
|
+
subagents?: boolean | { defaultModel?: string; defaultMaxTokens?: number };
|
|
321
|
+
lessons?: boolean;
|
|
322
|
+
retrieval?: boolean | { model?: string; maxInjected?: number };
|
|
323
|
+
wake?: boolean | import('@animalabs/agent-framework').GateConfig;
|
|
324
|
+
workspace?: boolean | { mounts: RecipeWorkspaceMount[]; configMount?: boolean };
|
|
325
|
+
/**
|
|
326
|
+
* Surface agent composition activity (typing indicators) to one or more
|
|
327
|
+
* MCPL channels while inference is active. Opt-in per recipe; channel IDs
|
|
328
|
+
* use the MCPL format (e.g. `zulip:tracker-miner-f`). The agent can also
|
|
329
|
+
* adjust the set at runtime via the `activity:show_in` / `activity:hide_in`
|
|
330
|
+
* tools.
|
|
331
|
+
*/
|
|
332
|
+
activity?: boolean | { channels?: string[] };
|
|
333
|
+
/**
|
|
334
|
+
* MCPL self-administration. Off by default. When enabled, the agent gets
|
|
335
|
+
* `mcpl_list` / `mcpl_deploy` / `mcpl_restart` / `mcpl_unload` tools to
|
|
336
|
+
* hot-manage its own MCPL servers without a host restart. Deployments
|
|
337
|
+
* persist to `mcpl-servers.agent.json` (the agent overlay), merged over the
|
|
338
|
+
* recipe/file server list at startup.
|
|
339
|
+
*
|
|
340
|
+
* SECURITY: `mcpl_deploy` spawns arbitrary commands as the host user —
|
|
341
|
+
* enabling this module means trusting the agent with code execution.
|
|
342
|
+
*/
|
|
343
|
+
mcplAdmin?: boolean;
|
|
344
|
+
/**
|
|
345
|
+
* Cross-process child fleet. When true (shorthand), FleetModule is attached
|
|
346
|
+
* with no pre-configured children. When an object, declares children the
|
|
347
|
+
* conductor supervises (auto-started by default on framework start) and
|
|
348
|
+
* optionally an allowlist of recipe paths the conductor may spawn at will.
|
|
349
|
+
* Recipes outside the allowlist require user approval.
|
|
350
|
+
*
|
|
351
|
+
* Recipe path resolution: relative paths in `children[].recipe` are resolved
|
|
352
|
+
* at load time against the directory of the parent recipe file (or URL
|
|
353
|
+
* base), NOT the process CWD. This lets a recipe bundle live anywhere on
|
|
354
|
+
* disk and reference its siblings portably. Absolute paths and http(s)
|
|
355
|
+
* URLs pass through unchanged.
|
|
356
|
+
*
|
|
357
|
+
* Note: this differs from runtime paths (workspace mounts, `dataDir`),
|
|
358
|
+
* which stay CWD-relative because they describe where the running process
|
|
359
|
+
* puts its state.
|
|
360
|
+
*/
|
|
361
|
+
fleet?: boolean | RecipeFleet;
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Web admin UI. Off by default. The default bind is 0.0.0.0:7340 (connectome
|
|
365
|
+
* deployments are remote, not local), which hard-requires Basic-Auth: a
|
|
366
|
+
* recipe that enables webui MUST supply `basicAuth`, or the host refuses to
|
|
367
|
+
* start. Credentials should be `${VAR}`-substituted from .env, never literal.
|
|
368
|
+
*
|
|
369
|
+
* For remote admin, front this with a reverse proxy (Caddy/nginx) for TLS;
|
|
370
|
+
* Basic-Auth is still required at the app layer. Set `host: '127.0.0.1'` to
|
|
371
|
+
* bind loopback-only for local development, which skips the auth requirement.
|
|
372
|
+
*/
|
|
373
|
+
webui?: boolean | RecipeWebUi;
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Auto-unsubscribe noisy ambient channels. On by default. Per subscribed
|
|
377
|
+
* channel, the host counts ambient (non-mention, non-DM) characters since the
|
|
378
|
+
* agent's last activation; when a channel crosses its limit before the next
|
|
379
|
+
* activation, it's auto-unsubscribed (delivery stops, and the surface starts
|
|
380
|
+
* tracking what's then missed). Counters reset on every activation — the
|
|
381
|
+
* agent sees all subscribed channels in context, compressed if large — and
|
|
382
|
+
* persist across restarts. The agent can override the per-channel limit at
|
|
383
|
+
* runtime via the `subscription-gc--set_channel_idle_limit` tool.
|
|
384
|
+
*
|
|
385
|
+
* `false` disables entirely. `defaultLimitChars` sets the global default
|
|
386
|
+
* (20000 if omitted). `serverId`/`toolPrefix` target the MCPL surface that
|
|
387
|
+
* owns subscriptions (defaults: `discord` / `mcpl--discord`).
|
|
388
|
+
*/
|
|
389
|
+
subscriptionGc?:
|
|
390
|
+
| boolean
|
|
391
|
+
| { defaultLimitChars?: number; serverId?: string; toolPrefix?: string };
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* One-step channel attention modes (the `set_channel_mode` tool): flip a
|
|
395
|
+
* channel between mentions-only and every-message-debounced. Debounced mode
|
|
396
|
+
* bundles subscribe + a per-channel ambient debounce gate rule + pinning
|
|
397
|
+
* subscription-gc off; mentions mode reverses all three. On by default when
|
|
398
|
+
* the gate (`wake`) is enabled — it only ADDS a tool, inert until called.
|
|
399
|
+
* `false` disables. `serverId`/`toolPrefix` target the MCPL surface that owns
|
|
400
|
+
* subscriptions (defaults: `discord` / `mcpl--discord`); `defaultDebounceMs`
|
|
401
|
+
* sets the debounced-mode quiet window (180000 if omitted).
|
|
402
|
+
*/
|
|
403
|
+
channelMode?:
|
|
404
|
+
| boolean
|
|
405
|
+
| { serverId?: string; toolPrefix?: string; gcModuleName?: string; defaultDebounceMs?: number };
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
export interface RecipeWebUi {
|
|
409
|
+
port?: number;
|
|
410
|
+
host?: string;
|
|
411
|
+
/**
|
|
412
|
+
* Basic-Auth credentials. Required whenever the bind host is non-loopback
|
|
413
|
+
* (which is the default). `${VAR}`-substitutable from .env.
|
|
414
|
+
*/
|
|
415
|
+
basicAuth?: { username: string; password: string };
|
|
416
|
+
/**
|
|
417
|
+
* Override the default Origin allowlist for the WebSocket upgrade. Default
|
|
418
|
+
* is `http(s)://127.0.0.1:<port>` and `http(s)://localhost:<port>`. Add
|
|
419
|
+
* your reverse-proxy origin (e.g. `https://admin.example.com`) here when
|
|
420
|
+
* fronted by Caddy/nginx. Pass `[]` to disable the Origin check entirely
|
|
421
|
+
* — only sensible when something else upstream is enforcing it.
|
|
422
|
+
*/
|
|
423
|
+
allowedOrigins?: string[];
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export interface RecipeFleet {
|
|
427
|
+
/** Children to manage. autoStart children launch when the framework starts. */
|
|
428
|
+
children?: RecipeFleetChild[];
|
|
429
|
+
/**
|
|
430
|
+
* Allowlist for `fleet--launch`. If omitted, the list is implicitly the
|
|
431
|
+
* set of recipe paths named in `children`. A launch call targeting a
|
|
432
|
+
* recipe outside the allowlist fails with an error the agent should
|
|
433
|
+
* relay to the user (who can then approve and re-issue).
|
|
434
|
+
*
|
|
435
|
+
* Pattern syntax (intentionally minimal to keep matcher and intent aligned):
|
|
436
|
+
* - Literal exact match: `"recipes/miner.json"`
|
|
437
|
+
* - Single `"*"`: allow everything
|
|
438
|
+
* - Trailing `"*"` only (prefix match): `"recipes/*"` matches any path
|
|
439
|
+
* under `recipes/`. A mid-string `*` is rejected at validation time
|
|
440
|
+
* — it would look like a glob but behave only as a literal.
|
|
441
|
+
*/
|
|
442
|
+
allowedRecipes?: string[];
|
|
443
|
+
/** Default subscription sent to each child at handshake if they don't specify their own. */
|
|
444
|
+
defaultSubscription?: string[];
|
|
445
|
+
/**
|
|
446
|
+
* Spawn-readiness timeouts forwarded to FleetModule. Bump these when a
|
|
447
|
+
* child's startup work (heavy MCP servers, large state load) won't fit
|
|
448
|
+
* inside the FleetModule defaults (15s / 10s / 10s / 5s respectively).
|
|
449
|
+
*/
|
|
450
|
+
socketWaitTimeoutMs?: number;
|
|
451
|
+
readyTimeoutMs?: number;
|
|
452
|
+
gracefulShutdownMs?: number;
|
|
453
|
+
sigtermEscalationMs?: number;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export interface RecipeFleetChild {
|
|
457
|
+
name: string;
|
|
458
|
+
/**
|
|
459
|
+
* Recipe path or http(s) URL. Relative paths are resolved at `loadRecipe`
|
|
460
|
+
* time against the directory of the parent recipe file (or URL base), so
|
|
461
|
+
* a sibling recipe is referenced by its filename regardless of where the
|
|
462
|
+
* parent is launched from. Absolute paths and URLs pass through
|
|
463
|
+
* unchanged.
|
|
464
|
+
*/
|
|
465
|
+
recipe: string;
|
|
466
|
+
/** Data dir override; default `./data/<name>`. */
|
|
467
|
+
dataDir?: string;
|
|
468
|
+
/** Env overrides on top of parent env. */
|
|
469
|
+
env?: Record<string, string>;
|
|
470
|
+
/**
|
|
471
|
+
* Event subscription at handshake (supports glob like `tool:*`).
|
|
472
|
+
* Relevant synthetic events the parent may care about:
|
|
473
|
+
* - `lifecycle` (includes `ready` / `idle` / `exiting`) — required for fleet--await.
|
|
474
|
+
* - `inference:speech` — per-final-inference speech text, required for fleet--relay.
|
|
475
|
+
* - `inference:completed` / `inference:failed` / `tool:completed` / `tool:failed` — useful for status tracking.
|
|
476
|
+
*/
|
|
477
|
+
subscription?: string[];
|
|
478
|
+
/** Launch this child on framework start. Default: true. */
|
|
479
|
+
autoStart?: boolean;
|
|
480
|
+
/** Respawn on crash (Phase 5 honours this; schema accepts it now). */
|
|
481
|
+
autoRestart?: boolean;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
export interface Recipe {
|
|
485
|
+
name: string;
|
|
486
|
+
description?: string;
|
|
487
|
+
version?: string;
|
|
488
|
+
agent: RecipeAgent;
|
|
489
|
+
mcpServers?: Record<string, RecipeMcpServer>;
|
|
490
|
+
modules?: RecipeModules;
|
|
491
|
+
sessionNaming?: { examples?: string[] };
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// ---------------------------------------------------------------------------
|
|
495
|
+
// Default recipe
|
|
496
|
+
// ---------------------------------------------------------------------------
|
|
497
|
+
|
|
498
|
+
export const DEFAULT_RECIPE: Recipe = {
|
|
499
|
+
name: 'Agent',
|
|
500
|
+
description: 'General-purpose assistant with tool access',
|
|
501
|
+
agent: {
|
|
502
|
+
name: 'agent',
|
|
503
|
+
systemPrompt: [
|
|
504
|
+
'You are a helpful assistant. You have access to tools provided by connected MCP servers.',
|
|
505
|
+
'Use them to help the user with their tasks.',
|
|
506
|
+
'',
|
|
507
|
+
'You can fork subagents for parallel work, create persistent notes, and write files to `products/` as outputs of your work.',
|
|
508
|
+
].join('\n'),
|
|
509
|
+
},
|
|
510
|
+
modules: {
|
|
511
|
+
subagents: true,
|
|
512
|
+
lessons: true,
|
|
513
|
+
retrieval: true,
|
|
514
|
+
wake: true,
|
|
515
|
+
workspace: true,
|
|
516
|
+
},
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
// ---------------------------------------------------------------------------
|
|
520
|
+
// Environment variable substitution
|
|
521
|
+
// ---------------------------------------------------------------------------
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Walk a parsed-JSON value tree and substitute `${VAR_NAME}` patterns in
|
|
525
|
+
* string values with `process.env.VAR_NAME`. Applied at recipe-load time,
|
|
526
|
+
* before schema validation, so secrets can live in `.env` (gitignored) while
|
|
527
|
+
* the recipe itself stays commit-safe.
|
|
528
|
+
*
|
|
529
|
+
* Patterns:
|
|
530
|
+
* - `${FOO}` — required. Throws if FOO is unset (empty string OK).
|
|
531
|
+
* - `${FOO:-default}` — optional. Uses FOO when set and non-empty,
|
|
532
|
+
* otherwise the literal default text. Default may be empty
|
|
533
|
+
* (`${FOO:-}`) to mean "use FOO or just empty string, never throw".
|
|
534
|
+
* - VAR name: `[A-Za-z_][A-Za-z0-9_]*`.
|
|
535
|
+
* - Default text: any chars except `}`. No nested `${...}` parsing
|
|
536
|
+
* and no escape syntax — keep recipes JSON-friendly.
|
|
537
|
+
* - Multiple occurrences in a single string are all substituted.
|
|
538
|
+
* - Non-string values (numbers, booleans, nulls) pass through unchanged.
|
|
539
|
+
* - Arrays and objects are walked recursively.
|
|
540
|
+
*
|
|
541
|
+
* No escape syntax yet — a literal `${...}` in recipe JSON is not a supported
|
|
542
|
+
* case. If that becomes needed, add `$$` → `$` unwrapping as a pre-pass.
|
|
543
|
+
*/
|
|
544
|
+
export function substituteEnvVars(value: unknown, source: string): unknown {
|
|
545
|
+
if (typeof value === 'string') {
|
|
546
|
+
return value.replace(
|
|
547
|
+
/\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g,
|
|
548
|
+
(_match, name: string, defaultValue: string | undefined) => {
|
|
549
|
+
const v = process.env[name];
|
|
550
|
+
if (defaultValue !== undefined) {
|
|
551
|
+
// ${VAR:-default} — use VAR if set + non-empty, else default.
|
|
552
|
+
return v !== undefined && v !== '' ? v : defaultValue;
|
|
553
|
+
}
|
|
554
|
+
// ${VAR} — required; empty string is allowed if explicitly set.
|
|
555
|
+
if (v === undefined) {
|
|
556
|
+
throw new Error(
|
|
557
|
+
`Recipe "${source}" references environment variable \${${name}} which is not set. ` +
|
|
558
|
+
`Add it to your .env file, supply a default with \${${name}:-...}, ` +
|
|
559
|
+
`or delete the section of the recipe that uses it ` +
|
|
560
|
+
`(e.g. remove the mcpServers entry for a source you don't have).`,
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
return v;
|
|
564
|
+
},
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
if (Array.isArray(value)) {
|
|
568
|
+
return value.map((v) => substituteEnvVars(v, source));
|
|
569
|
+
}
|
|
570
|
+
if (value !== null && typeof value === 'object') {
|
|
571
|
+
const out: Record<string, unknown> = {};
|
|
572
|
+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
573
|
+
out[k] = substituteEnvVars(v, source);
|
|
574
|
+
}
|
|
575
|
+
return out;
|
|
576
|
+
}
|
|
577
|
+
return value;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// ---------------------------------------------------------------------------
|
|
581
|
+
// Loading
|
|
582
|
+
// ---------------------------------------------------------------------------
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Base for resolving recipe-relative paths (currently: `children[].recipe`).
|
|
586
|
+
* `file` sources use the recipe file's directory; `url` sources use the URL
|
|
587
|
+
* base so a child like `"child.json"` on an `https://example.com/parent.json`
|
|
588
|
+
* load resolves to `https://example.com/child.json`.
|
|
589
|
+
*/
|
|
590
|
+
type RecipeSourceBase = { kind: 'file'; dir: string } | { kind: 'url'; base: string };
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Load a recipe from a URL or local file path.
|
|
594
|
+
* If the systemPrompt value is an HTTP(S) URL, fetches the text.
|
|
595
|
+
* Recipe string values containing `${VAR}` patterns are substituted against
|
|
596
|
+
* `process.env` before validation — see substituteEnvVars().
|
|
597
|
+
* Relative `modules.fleet.children[].recipe` paths are resolved against the
|
|
598
|
+
* parent recipe's directory (or URL base) so sibling recipes are portable.
|
|
599
|
+
*/
|
|
600
|
+
export async function loadRecipe(source: string): Promise<Recipe> {
|
|
601
|
+
let raw: unknown;
|
|
602
|
+
let sourceBase: RecipeSourceBase;
|
|
603
|
+
|
|
604
|
+
if (source.startsWith('http://') || source.startsWith('https://')) {
|
|
605
|
+
const res = await fetch(source);
|
|
606
|
+
if (!res.ok) throw new Error(`Failed to fetch recipe from ${source}: ${res.status} ${res.statusText}`);
|
|
607
|
+
raw = await res.json();
|
|
608
|
+
sourceBase = { kind: 'url', base: source };
|
|
609
|
+
} else {
|
|
610
|
+
const path = resolve(source);
|
|
611
|
+
if (!existsSync(path)) throw new Error(`Recipe file not found: ${path}`);
|
|
612
|
+
raw = JSON.parse(readFileSync(path, 'utf-8'));
|
|
613
|
+
sourceBase = { kind: 'file', dir: dirname(path) };
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
raw = substituteEnvVars(raw, source);
|
|
617
|
+
const recipe = validateRecipe(raw);
|
|
618
|
+
resolveChildRecipePaths(recipe, sourceBase);
|
|
619
|
+
return resolveSystemPrompt(recipe);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Resolve a single `children[].recipe` value against the parent recipe's
|
|
624
|
+
* source base. Returns unchanged if absolute or http(s) URL.
|
|
625
|
+
*/
|
|
626
|
+
export function resolveRecipeRelative(child: string, base: RecipeSourceBase): string {
|
|
627
|
+
if (child.startsWith('http://') || child.startsWith('https://')) return child;
|
|
628
|
+
if (isAbsolute(child)) return child;
|
|
629
|
+
if (base.kind === 'file') return resolve(base.dir, child);
|
|
630
|
+
// URL base: resolve the child against the parent URL.
|
|
631
|
+
return new URL(child, base.base).href;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function resolveChildRecipePaths(recipe: Recipe, base: RecipeSourceBase): void {
|
|
635
|
+
const fleet = recipe.modules?.fleet;
|
|
636
|
+
if (!fleet || typeof fleet !== 'object') return;
|
|
637
|
+
if (!fleet.children) return;
|
|
638
|
+
for (const child of fleet.children) {
|
|
639
|
+
child.recipe = resolveRecipeRelative(child.recipe, base);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* If systemPrompt is an HTTP(S) URL, fetch its contents as plain text.
|
|
645
|
+
* Only treated as a URL if it looks like a single URL (no spaces/newlines).
|
|
646
|
+
*/
|
|
647
|
+
async function resolveSystemPrompt(recipe: Recipe): Promise<Recipe> {
|
|
648
|
+
const prompt = recipe.agent.systemPrompt;
|
|
649
|
+
const isUrl = (prompt.startsWith('http://') || prompt.startsWith('https://'))
|
|
650
|
+
&& !prompt.includes(' ') && !prompt.includes('\n');
|
|
651
|
+
if (isUrl) {
|
|
652
|
+
const res = await fetch(prompt);
|
|
653
|
+
if (!res.ok) throw new Error(`Failed to fetch system prompt from ${prompt}: ${res.status} ${res.statusText}`);
|
|
654
|
+
return {
|
|
655
|
+
...recipe,
|
|
656
|
+
agent: { ...recipe.agent, systemPrompt: await res.text() },
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
return recipe;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* Validate raw JSON and fill defaults.
|
|
664
|
+
*/
|
|
665
|
+
export function validateRecipe(raw: unknown): Recipe {
|
|
666
|
+
if (!raw || typeof raw !== 'object') throw new Error('Recipe must be a JSON object');
|
|
667
|
+
const obj = raw as Record<string, unknown>;
|
|
668
|
+
|
|
669
|
+
if (typeof obj.name !== 'string' || !obj.name) {
|
|
670
|
+
throw new Error('Recipe must have a "name" string');
|
|
671
|
+
}
|
|
672
|
+
if (!obj.agent || typeof obj.agent !== 'object') {
|
|
673
|
+
throw new Error('Recipe must have an "agent" object');
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
const agent = obj.agent as Record<string, unknown>;
|
|
677
|
+
if (typeof agent.systemPrompt !== 'string' || !agent.systemPrompt) {
|
|
678
|
+
throw new Error('Recipe agent must have a "systemPrompt" string');
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
if (agent.maxStreamTokens !== undefined && (typeof agent.maxStreamTokens !== 'number' || agent.maxStreamTokens <= 0)) {
|
|
682
|
+
throw new Error('Recipe agent.maxStreamTokens must be a positive number.');
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// Recipes are runtime JSON; a typo'd TTL ("1hr", "60m") would otherwise
|
|
686
|
+
// surface as a 400 from Anthropic at the agent's first inference.
|
|
687
|
+
if (agent.cacheTtl !== undefined && agent.cacheTtl !== '5m' && agent.cacheTtl !== '1h') {
|
|
688
|
+
throw new Error(`Recipe agent.cacheTtl must be '5m' or '1h', got ${JSON.stringify(agent.cacheTtl)}.`);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// Validate agent.thinking if present. Catches typos and constraint
|
|
692
|
+
// violations (notably max_tokens > budget_tokens) at recipe-load time
|
|
693
|
+
// rather than as a 400 from Anthropic at first inference.
|
|
694
|
+
if (agent.thinking !== undefined) {
|
|
695
|
+
if (!agent.thinking || typeof agent.thinking !== 'object' || Array.isArray(agent.thinking)) {
|
|
696
|
+
throw new Error('Recipe agent.thinking must be an object with "enabled" and optional "budgetTokens".');
|
|
697
|
+
}
|
|
698
|
+
const thinking = agent.thinking as Record<string, unknown>;
|
|
699
|
+
if (typeof thinking.enabled !== 'boolean') {
|
|
700
|
+
throw new Error('Recipe agent.thinking.enabled must be a boolean.');
|
|
701
|
+
}
|
|
702
|
+
if (thinking.budgetTokens !== undefined && (typeof thinking.budgetTokens !== 'number' || thinking.budgetTokens <= 0)) {
|
|
703
|
+
throw new Error('Recipe agent.thinking.budgetTokens must be a positive number.');
|
|
704
|
+
}
|
|
705
|
+
if (thinking.enabled === true && typeof thinking.budgetTokens === 'number' && typeof agent.maxTokens === 'number') {
|
|
706
|
+
if (thinking.budgetTokens >= agent.maxTokens) {
|
|
707
|
+
throw new Error(
|
|
708
|
+
`Recipe agent.thinking.budgetTokens (${thinking.budgetTokens}) must be less than agent.maxTokens (${agent.maxTokens}) ` +
|
|
709
|
+
`(Anthropic requires max_tokens > thinking.budget_tokens).`,
|
|
710
|
+
);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// Validate strategy type if present
|
|
716
|
+
if (agent.strategy) {
|
|
717
|
+
const strategy = agent.strategy as Record<string, unknown>;
|
|
718
|
+
if (
|
|
719
|
+
strategy.type &&
|
|
720
|
+
strategy.type !== 'autobiographical' &&
|
|
721
|
+
strategy.type !== 'passthrough' &&
|
|
722
|
+
strategy.type !== 'frontdesk'
|
|
723
|
+
) {
|
|
724
|
+
throw new Error(
|
|
725
|
+
`Invalid strategy type "${strategy.type}". Must be "autobiographical", "passthrough", or "frontdesk".`,
|
|
726
|
+
);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// Validate mcpServers entries if present
|
|
731
|
+
if (obj.mcpServers && typeof obj.mcpServers === 'object') {
|
|
732
|
+
for (const [id, entry] of Object.entries(obj.mcpServers as Record<string, unknown>)) {
|
|
733
|
+
if (!entry || typeof entry !== 'object') {
|
|
734
|
+
throw new Error(`mcpServers.${id} must be an object`);
|
|
735
|
+
}
|
|
736
|
+
const server = entry as Record<string, unknown>;
|
|
737
|
+
const hasCommand = typeof server.command === 'string' && server.command;
|
|
738
|
+
const hasUrl = typeof server.url === 'string' && server.url;
|
|
739
|
+
if (!hasCommand && !hasUrl) {
|
|
740
|
+
throw new Error(`mcpServers.${id} must have a "command" string (stdio) or "url" string (websocket)`);
|
|
741
|
+
}
|
|
742
|
+
if (server.args !== undefined && !Array.isArray(server.args)) {
|
|
743
|
+
throw new Error(`mcpServers.${id}.args must be an array`);
|
|
744
|
+
}
|
|
745
|
+
if (server.source !== undefined) {
|
|
746
|
+
if (typeof server.source !== 'object' || server.source === null) {
|
|
747
|
+
throw new Error(`mcpServers.${id}.source must be an object`);
|
|
748
|
+
}
|
|
749
|
+
const src = server.source as Record<string, unknown>;
|
|
750
|
+
if (typeof src.url !== 'string' || !src.url) {
|
|
751
|
+
throw new Error(`mcpServers.${id}.source.url must be a non-empty string`);
|
|
752
|
+
}
|
|
753
|
+
if (src.ref !== undefined && typeof src.ref !== 'string') {
|
|
754
|
+
throw new Error(`mcpServers.${id}.source.ref must be a string`);
|
|
755
|
+
}
|
|
756
|
+
if (src.install !== undefined) {
|
|
757
|
+
const install = src.install;
|
|
758
|
+
const isShorthand = install === 'npm' || install === 'pip-editable';
|
|
759
|
+
const isCustom =
|
|
760
|
+
typeof install === 'object' && install !== null
|
|
761
|
+
&& typeof (install as Record<string, unknown>).run === 'string'
|
|
762
|
+
&& ['node', 'python3', 'custom', 'bun'].includes(
|
|
763
|
+
(install as Record<string, unknown>).runtime as string,
|
|
764
|
+
);
|
|
765
|
+
if (!isShorthand && !isCustom) {
|
|
766
|
+
throw new Error(
|
|
767
|
+
`mcpServers.${id}.source.install must be 'npm', 'pip-editable', ` +
|
|
768
|
+
`or { run: string, runtime: 'node' | 'python3' | 'custom' | 'bun' }`,
|
|
769
|
+
);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
if (src.authSecret !== undefined && typeof src.authSecret !== 'string') {
|
|
773
|
+
throw new Error(`mcpServers.${id}.source.authSecret must be a string`);
|
|
774
|
+
}
|
|
775
|
+
if (src.sslBypass !== undefined && typeof src.sslBypass !== 'boolean') {
|
|
776
|
+
throw new Error(`mcpServers.${id}.source.sslBypass must be a boolean`);
|
|
777
|
+
}
|
|
778
|
+
if (src.systemPackages !== undefined) {
|
|
779
|
+
// The regex bounds these to Debian package names — build tooling
|
|
780
|
+
// pastes them into an `apt-get install` line, so an unbounded string
|
|
781
|
+
// is a command-injection vector. Kept in sync with cook's vendored
|
|
782
|
+
// copy (connectome-cook src/vendor/recipe.ts).
|
|
783
|
+
if (!Array.isArray(src.systemPackages)
|
|
784
|
+
|| !(src.systemPackages as unknown[]).every(
|
|
785
|
+
(p) => typeof p === 'string' && /^[a-z0-9][a-z0-9+.\-]+$/.test(p),
|
|
786
|
+
)) {
|
|
787
|
+
throw new Error(`mcpServers.${id}.source.systemPackages must be an array of Debian package names`);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
if (src.inContainer !== undefined) {
|
|
791
|
+
if (typeof src.inContainer !== 'object' || src.inContainer === null) {
|
|
792
|
+
throw new Error(`mcpServers.${id}.source.inContainer must be an object`);
|
|
793
|
+
}
|
|
794
|
+
if (typeof (src.inContainer as Record<string, unknown>).path !== 'string') {
|
|
795
|
+
throw new Error(`mcpServers.${id}.source.inContainer.path must be a string`);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
for (const field of ['enabledTools', 'disabledTools'] as const) {
|
|
800
|
+
if (server[field] === undefined) continue;
|
|
801
|
+
if (!Array.isArray(server[field]) || !(server[field] as unknown[]).every((p) => typeof p === 'string' && p)) {
|
|
802
|
+
throw new Error(`mcpServers.${id}.${field} must be an array of non-empty strings`);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
if (server.credentialFiles !== undefined) {
|
|
806
|
+
if (!Array.isArray(server.credentialFiles)) {
|
|
807
|
+
throw new Error(`mcpServers.${id}.credentialFiles must be an array`);
|
|
808
|
+
}
|
|
809
|
+
const seenPaths = new Set<string>();
|
|
810
|
+
for (let i = 0; i < server.credentialFiles.length; i++) {
|
|
811
|
+
const cf = server.credentialFiles[i] as Record<string, unknown>;
|
|
812
|
+
if (!cf || typeof cf !== 'object') {
|
|
813
|
+
throw new Error(`mcpServers.${id}.credentialFiles[${i}] must be an object`);
|
|
814
|
+
}
|
|
815
|
+
if (typeof cf.path !== 'string' || !cf.path) {
|
|
816
|
+
throw new Error(`mcpServers.${id}.credentialFiles[${i}].path must be a non-empty string`);
|
|
817
|
+
}
|
|
818
|
+
if (seenPaths.has(cf.path)) {
|
|
819
|
+
throw new Error(`mcpServers.${id}.credentialFiles[${i}].path "${cf.path}" is duplicated within the same server`);
|
|
820
|
+
}
|
|
821
|
+
seenPaths.add(cf.path);
|
|
822
|
+
if (cf.format !== 'ini' && cf.format !== 'json' && cf.format !== 'env') {
|
|
823
|
+
throw new Error(`mcpServers.${id}.credentialFiles[${i}].format must be 'ini', 'json', or 'env'`);
|
|
824
|
+
}
|
|
825
|
+
if (cf.section !== undefined && typeof cf.section !== 'string') {
|
|
826
|
+
throw new Error(`mcpServers.${id}.credentialFiles[${i}].section must be a string`);
|
|
827
|
+
}
|
|
828
|
+
if (cf.mode !== undefined && (typeof cf.mode !== 'string' || !/^0?[0-7]{3,4}$/.test(cf.mode))) {
|
|
829
|
+
throw new Error(`mcpServers.${id}.credentialFiles[${i}].mode must be an octal string like "0600"`);
|
|
830
|
+
}
|
|
831
|
+
if (!Array.isArray(cf.fields) || cf.fields.length === 0) {
|
|
832
|
+
throw new Error(`mcpServers.${id}.credentialFiles[${i}].fields must be a non-empty array`);
|
|
833
|
+
}
|
|
834
|
+
const seenFieldNames = new Set<string>();
|
|
835
|
+
for (let j = 0; j < cf.fields.length; j++) {
|
|
836
|
+
const f = cf.fields[j] as Record<string, unknown>;
|
|
837
|
+
if (!f || typeof f !== 'object') {
|
|
838
|
+
throw new Error(`mcpServers.${id}.credentialFiles[${i}].fields[${j}] must be an object`);
|
|
839
|
+
}
|
|
840
|
+
if (typeof f.name !== 'string' || !f.name) {
|
|
841
|
+
throw new Error(`mcpServers.${id}.credentialFiles[${i}].fields[${j}].name must be a non-empty string`);
|
|
842
|
+
}
|
|
843
|
+
if (seenFieldNames.has(f.name)) {
|
|
844
|
+
throw new Error(`mcpServers.${id}.credentialFiles[${i}].fields[${j}].name "${f.name}" is duplicated`);
|
|
845
|
+
}
|
|
846
|
+
seenFieldNames.add(f.name);
|
|
847
|
+
if (f.envOverride !== undefined && (typeof f.envOverride !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(f.envOverride))) {
|
|
848
|
+
throw new Error(`mcpServers.${id}.credentialFiles[${i}].fields[${j}].envOverride must be a valid env var name`);
|
|
849
|
+
}
|
|
850
|
+
for (const optStr of ['description', 'placeholder'] as const) {
|
|
851
|
+
if (f[optStr] !== undefined && typeof f[optStr] !== 'string') {
|
|
852
|
+
throw new Error(`mcpServers.${id}.credentialFiles[${i}].fields[${j}].${optStr} must be a string`);
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
if (f.secret !== undefined && typeof f.secret !== 'boolean') {
|
|
856
|
+
throw new Error(`mcpServers.${id}.credentialFiles[${i}].fields[${j}].secret must be a boolean`);
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// Validate workspace mounts if present
|
|
865
|
+
if (obj.modules && typeof obj.modules === 'object') {
|
|
866
|
+
const mods = obj.modules as Record<string, unknown>;
|
|
867
|
+
if (mods.workspace && typeof mods.workspace === 'object') {
|
|
868
|
+
const ws = mods.workspace as Record<string, unknown>;
|
|
869
|
+
if (!Array.isArray(ws.mounts) || ws.mounts.length === 0) {
|
|
870
|
+
throw new Error('workspace.mounts must be a non-empty array');
|
|
871
|
+
}
|
|
872
|
+
for (let i = 0; i < ws.mounts.length; i++) {
|
|
873
|
+
const m = ws.mounts[i] as Record<string, unknown>;
|
|
874
|
+
if (!m || typeof m !== 'object') {
|
|
875
|
+
throw new Error(`workspace.mounts[${i}] must be an object`);
|
|
876
|
+
}
|
|
877
|
+
if (typeof m.name !== 'string' || !m.name) {
|
|
878
|
+
throw new Error(`workspace.mounts[${i}].name must be a non-empty string`);
|
|
879
|
+
}
|
|
880
|
+
if (typeof m.path !== 'string' || !m.path) {
|
|
881
|
+
throw new Error(`workspace.mounts[${i}].path must be a non-empty string`);
|
|
882
|
+
}
|
|
883
|
+
if (m.mode !== undefined && m.mode !== 'read-write' && m.mode !== 'read-only') {
|
|
884
|
+
throw new Error(`workspace.mounts[${i}].mode must be "read-write" or "read-only"`);
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// Validate fleet if present (object form only — boolean is trivially valid).
|
|
890
|
+
if (mods.fleet && typeof mods.fleet === 'object') {
|
|
891
|
+
const fleet = mods.fleet as Record<string, unknown>;
|
|
892
|
+
if (fleet.children !== undefined) {
|
|
893
|
+
if (!Array.isArray(fleet.children)) {
|
|
894
|
+
throw new Error('fleet.children must be an array');
|
|
895
|
+
}
|
|
896
|
+
const seenNames = new Set<string>();
|
|
897
|
+
for (let i = 0; i < fleet.children.length; i++) {
|
|
898
|
+
const c = fleet.children[i] as Record<string, unknown>;
|
|
899
|
+
if (!c || typeof c !== 'object') {
|
|
900
|
+
throw new Error(`fleet.children[${i}] must be an object`);
|
|
901
|
+
}
|
|
902
|
+
if (typeof c.name !== 'string' || !c.name) {
|
|
903
|
+
throw new Error(`fleet.children[${i}].name must be a non-empty string`);
|
|
904
|
+
}
|
|
905
|
+
if (seenNames.has(c.name)) {
|
|
906
|
+
throw new Error(`fleet.children[${i}].name "${c.name}" is duplicated`);
|
|
907
|
+
}
|
|
908
|
+
seenNames.add(c.name);
|
|
909
|
+
if (typeof c.recipe !== 'string' || !c.recipe) {
|
|
910
|
+
throw new Error(`fleet.children[${i}].recipe must be a non-empty string`);
|
|
911
|
+
}
|
|
912
|
+
if (c.subscription !== undefined && !Array.isArray(c.subscription)) {
|
|
913
|
+
throw new Error(`fleet.children[${i}].subscription must be an array of strings`);
|
|
914
|
+
}
|
|
915
|
+
if (c.autoStart !== undefined && typeof c.autoStart !== 'boolean') {
|
|
916
|
+
throw new Error(`fleet.children[${i}].autoStart must be a boolean`);
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
if (fleet.allowedRecipes !== undefined) {
|
|
921
|
+
if (!Array.isArray(fleet.allowedRecipes) || !fleet.allowedRecipes.every((r) => typeof r === 'string')) {
|
|
922
|
+
throw new Error('fleet.allowedRecipes must be an array of strings');
|
|
923
|
+
}
|
|
924
|
+
// Prefix-match-only: reject mid-string `*` so the pattern can't look
|
|
925
|
+
// like a glob while silently behaving as a literal.
|
|
926
|
+
for (const pattern of fleet.allowedRecipes as string[]) {
|
|
927
|
+
if (pattern === '*' || !pattern.includes('*')) continue;
|
|
928
|
+
if (pattern.indexOf('*') !== pattern.length - 1) {
|
|
929
|
+
throw new Error(
|
|
930
|
+
`fleet.allowedRecipes entry "${pattern}" has a mid-string "*". ` +
|
|
931
|
+
`Only trailing "*" (prefix match) or a bare "*" (allow all) are supported.`,
|
|
932
|
+
);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
if (fleet.defaultSubscription !== undefined) {
|
|
937
|
+
if (!Array.isArray(fleet.defaultSubscription) || !fleet.defaultSubscription.every((s) => typeof s === 'string')) {
|
|
938
|
+
throw new Error('fleet.defaultSubscription must be an array of strings');
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
for (const k of ['socketWaitTimeoutMs', 'readyTimeoutMs', 'gracefulShutdownMs', 'sigtermEscalationMs'] as const) {
|
|
942
|
+
if (fleet[k] !== undefined && (typeof fleet[k] !== 'number' || (fleet[k] as number) < 0)) {
|
|
943
|
+
throw new Error(`fleet.${k} must be a non-negative number`);
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
return obj as unknown as Recipe;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// ---------------------------------------------------------------------------
|
|
953
|
+
// Persistence
|
|
954
|
+
// ---------------------------------------------------------------------------
|
|
955
|
+
|
|
956
|
+
function savedRecipePath(dataDir: string): string {
|
|
957
|
+
return resolve(dataDir, '.recipe.json');
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
export function saveRecipe(dataDir: string, recipe: Recipe): void {
|
|
961
|
+
mkdirSync(dataDir, { recursive: true });
|
|
962
|
+
writeFileSync(savedRecipePath(dataDir), JSON.stringify(recipe, null, 2) + '\n', 'utf-8');
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
export function loadSavedRecipe(dataDir: string): Recipe | null {
|
|
966
|
+
const path = savedRecipePath(dataDir);
|
|
967
|
+
if (!existsSync(path)) return null;
|
|
968
|
+
try {
|
|
969
|
+
const raw = JSON.parse(readFileSync(path, 'utf-8'));
|
|
970
|
+
return validateRecipe(raw);
|
|
971
|
+
} catch {
|
|
972
|
+
return null;
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
export function clearSavedRecipe(dataDir: string): void {
|
|
977
|
+
const path = savedRecipePath(dataDir);
|
|
978
|
+
if (existsSync(path)) {
|
|
979
|
+
unlinkSync(path);
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
// ---------------------------------------------------------------------------
|
|
984
|
+
// CLI helpers
|
|
985
|
+
// ---------------------------------------------------------------------------
|
|
986
|
+
|
|
987
|
+
/**
|
|
988
|
+
* Parse argv to find a recipe source. Returns null if none provided.
|
|
989
|
+
* Skips known flags (--no-tui, --no-recipe).
|
|
990
|
+
*/
|
|
991
|
+
export function parseRecipeArg(argv: string[]): { source: string | null; noRecipe: boolean } {
|
|
992
|
+
const noRecipe = argv.includes('--no-recipe');
|
|
993
|
+
let source: string | null = null;
|
|
994
|
+
|
|
995
|
+
for (const arg of argv.slice(2)) {
|
|
996
|
+
if (arg.startsWith('--')) continue;
|
|
997
|
+
// First positional arg is the recipe source
|
|
998
|
+
source = arg;
|
|
999
|
+
break;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
return { source, noRecipe };
|
|
1003
|
+
}
|