@animalabs/connectome-host 0.3.9 → 0.4.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 +7 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +26 -0
- package/.github/workflows/changelog.yml +32 -0
- package/.github/workflows/publish.yml +46 -0
- package/CHANGELOG.md +140 -0
- package/CONTRIBUTING.md +126 -0
- package/HEADLESS-FLEET-PLAN.md +1 -1
- package/README.md +52 -5
- package/UNIFIED-TREE-PLAN.md +2 -0
- package/docs/AGENT-ONBOARDING.md +9 -8
- package/docs/DEPLOYMENTS.md +2 -2
- package/docs/DEV-ENVIRONMENT.md +28 -26
- package/docs/LOCUS-ROUTING-DESIGN.md +8 -2
- package/package.json +5 -4
- package/scripts/release-changelog.ts +32 -0
- package/src/codex-subscription-adapter.ts +938 -0
- package/src/commands.ts +95 -4
- package/src/extensions.ts +201 -0
- package/src/framework-agent-config.ts +20 -9
- package/src/framework-strategy.ts +24 -0
- package/src/index.ts +111 -19
- package/src/logging-bedrock-adapter.ts +145 -0
- package/src/modules/channel-mode-module.ts +9 -6
- package/src/modules/subscription-gc-module.ts +163 -34
- package/src/modules/tts-relay-module.ts +481 -0
- package/src/modules/web-ui-module.ts +45 -1
- package/src/recipe.ts +196 -10
- package/src/tui.ts +527 -137
- package/src/web/protocol.ts +35 -0
- package/test/codex-subscription-adapter.test.ts +226 -0
- package/test/commands-checkpoint-restore.test.ts +118 -0
- package/test/fast-command.test.ts +39 -0
- package/test/recipe-extensions.test.ts +295 -0
- package/test/recipe-provider.test.ts +14 -1
- package/test/subscription-gc-module.test.ts +156 -1
- package/test/tui-quit-confirm.test.ts +42 -0
- package/test/tui-short-agent-name.test.ts +36 -0
- package/test/web-ui-observers.test.ts +14 -0
- package/test/web-ui-protocol.test.ts +0 -0
- package/web/src/App.tsx +235 -20
- package/web/src/Branches.tsx +150 -0
- package/web/src/Context.tsx +21 -13
- package/web/src/Health.tsx +274 -0
- package/web/src/Lessons.tsx +2 -1
package/src/recipe.ts
CHANGED
|
@@ -19,11 +19,19 @@ import { dirname, isAbsolute, resolve } from 'node:path';
|
|
|
19
19
|
// ---------------------------------------------------------------------------
|
|
20
20
|
|
|
21
21
|
export interface RecipeStrategy {
|
|
22
|
-
|
|
22
|
+
/** Built-in strategy name, or a custom type registered by a
|
|
23
|
+
* `kind: "strategy"` extension (see `Recipe.extensions`). The
|
|
24
|
+
* `string & {}` arm keeps literal autocompletion while admitting
|
|
25
|
+
* extension-registered names. */
|
|
26
|
+
type: 'autobiographical' | 'passthrough' | 'frontdesk' | (string & {});
|
|
23
27
|
// Core window/compression sizing
|
|
24
28
|
headWindowTokens?: number;
|
|
25
29
|
recentWindowTokens?: number;
|
|
26
30
|
compressionModel?: string;
|
|
31
|
+
/** Cap compression `max_tokens` at the compression model's OUTPUT ceiling.
|
|
32
|
+
* Required for pre-4.x models (Claude 3 Opus caps at 4096) — without it the
|
|
33
|
+
* summarizer's 16k floor is rejected and the agent never folds. */
|
|
34
|
+
compressionMaxTokens?: number;
|
|
27
35
|
maxMessageTokens?: number;
|
|
28
36
|
overBudgetGraceRatio?: number;
|
|
29
37
|
// Compression/merge tuning passed through to the underlying
|
|
@@ -85,7 +93,15 @@ export interface RecipeAgent {
|
|
|
85
93
|
/** IANA zone used when rendering wall-clock times to the agent. */
|
|
86
94
|
timezone?: string;
|
|
87
95
|
/** Provider transport. Omitted preserves the historical Anthropic default. */
|
|
88
|
-
provider?: 'anthropic' | 'openai-responses';
|
|
96
|
+
provider?: 'anthropic' | 'openai-responses' | 'openai-codex' | 'openrouter' | 'bedrock';
|
|
97
|
+
/** Message formatter. 'native' (default) = structured user/assistant turns.
|
|
98
|
+
* 'anthropic-xml' = classic prefill format ("participant: text" runs, XML
|
|
99
|
+
* tools) — for migrating prefill-era bots (chapterx borgs) with their exact
|
|
100
|
+
* prompting structure intact. */
|
|
101
|
+
formatter?: 'native' | 'anthropic-xml';
|
|
102
|
+
/** Prefill scaffold user message appended after the conversation (e.g.
|
|
103
|
+
* chapterx CLI-sim's "<cmd>cat untitled.txt</cmd>"). Prefill formatter only. */
|
|
104
|
+
prefillUserMessage?: string;
|
|
89
105
|
systemPrompt: string;
|
|
90
106
|
maxTokens?: number;
|
|
91
107
|
/**
|
|
@@ -105,6 +121,19 @@ export interface RecipeAgent {
|
|
|
105
121
|
* Omitted preserves the compatibility carry-forward in Agent Framework.
|
|
106
122
|
*/
|
|
107
123
|
sameRoundThinkTextPolicy?: 'public' | 'private';
|
|
124
|
+
/**
|
|
125
|
+
* Prose delivery mode (agent-framework docs/explicit-prose-routing.md).
|
|
126
|
+
* 'explicit' = model prefixes plain text with `>>destination`; unprefixed
|
|
127
|
+
* prose bounces to a clipboard instead of auto-routing. Default 'locus'.
|
|
128
|
+
*/
|
|
129
|
+
proseRouting?: 'locus' | 'explicit';
|
|
130
|
+
/**
|
|
131
|
+
* Extra Anthropic beta flags sent as the `anthropic-beta` header on every
|
|
132
|
+
* request (e.g. `["context-1m-2025-08-07"]` for the 1M context window on
|
|
133
|
+
* models where it is opt-in, like claude-sonnet-4-5). Merged with any
|
|
134
|
+
* transport-required betas (OAuth). Anthropic provider only.
|
|
135
|
+
*/
|
|
136
|
+
anthropicBetas?: string[];
|
|
108
137
|
strategy?: RecipeStrategy;
|
|
109
138
|
/**
|
|
110
139
|
* Native extended thinking. When `enabled: true`, the agent's API requests
|
|
@@ -115,13 +144,20 @@ export interface RecipeAgent {
|
|
|
115
144
|
enabled: boolean;
|
|
116
145
|
budgetTokens?: number;
|
|
117
146
|
};
|
|
118
|
-
/**
|
|
119
|
-
*
|
|
147
|
+
/** OpenAI Responses settings. Reasoning applies to both OpenAI providers;
|
|
148
|
+
* compaction and serviceTier are API-key transport settings. */
|
|
120
149
|
responses?: {
|
|
121
150
|
reasoningEffort?: 'none' | 'low' | 'medium' | 'high' | 'xhigh' | 'max';
|
|
122
151
|
reasoningContext?: 'current_turn' | 'all_turns';
|
|
123
|
-
serviceTier?: string;
|
|
124
152
|
compactThreshold?: number;
|
|
153
|
+
/** OpenAI Responses API processing tier. `priority` is the API-key
|
|
154
|
+
* equivalent of Codex fast mode. */
|
|
155
|
+
serviceTier?: 'auto' | 'default' | 'flex' | 'priority';
|
|
156
|
+
};
|
|
157
|
+
/** ChatGPT-subscription Codex settings. Only used with
|
|
158
|
+
* `provider: "openai-codex"`. Runtime `/fast` overrides fastMode. */
|
|
159
|
+
codex?: {
|
|
160
|
+
fastMode?: boolean;
|
|
125
161
|
};
|
|
126
162
|
/**
|
|
127
163
|
* Content-refusal handling. When `autoRewind` is on, a `stop_reason: refusal`
|
|
@@ -394,6 +430,18 @@ export interface RecipeModules {
|
|
|
394
430
|
*/
|
|
395
431
|
webui?: boolean | RecipeWebUi;
|
|
396
432
|
|
|
433
|
+
/**
|
|
434
|
+
* Live TTS streaming tap (melodeus-tts-relay). Off by default. When set,
|
|
435
|
+
* the host mirrors the agent's streaming generation — chunks, block
|
|
436
|
+
* boundaries, activation start/end, tagged with the turn's channel — to the
|
|
437
|
+
* relay's `/bot` WebSocket, where voice clients (Melodeus / iOS) speak it.
|
|
438
|
+
* Also handles the relay's `interruption` events: the just-posted Discord
|
|
439
|
+
* message is edited down to the words actually voiced, and a note lands in
|
|
440
|
+
* the agent's context. Pure trace-bus tap — no effect on the agent loop.
|
|
441
|
+
* `token` should be `${VAR}`-substituted from .env, never literal.
|
|
442
|
+
*/
|
|
443
|
+
ttsRelay?: RecipeTtsRelay;
|
|
444
|
+
|
|
397
445
|
/**
|
|
398
446
|
* Auto-unsubscribe noisy ambient channels. On by default. Per subscribed
|
|
399
447
|
* channel, the host counts ambient (non-mention, non-DM) characters since the
|
|
@@ -402,7 +450,7 @@ export interface RecipeModules {
|
|
|
402
450
|
* tracking what's then missed). Counters reset on every activation — the
|
|
403
451
|
* agent sees all subscribed channels in context, compressed if large — and
|
|
404
452
|
* persist across restarts. The agent can override the per-channel limit at
|
|
405
|
-
* runtime via
|
|
453
|
+
* runtime via `agent_settings` (field `channel_idle_limits`).
|
|
406
454
|
*
|
|
407
455
|
* `false` disables entirely. `defaultLimitChars` sets the global default
|
|
408
456
|
* (20000 if omitted). `serverId`/`toolPrefix` target the MCPL surface that
|
|
@@ -445,6 +493,25 @@ export interface RecipeWebUi {
|
|
|
445
493
|
allowedOrigins?: string[];
|
|
446
494
|
}
|
|
447
495
|
|
|
496
|
+
export interface RecipeTtsRelay {
|
|
497
|
+
/** Relay bot endpoint, e.g. "ws://localhost:8800/bot". */
|
|
498
|
+
url: string;
|
|
499
|
+
/** Shared secret for the relay's /bot auth (its BOT_TOKENS). */
|
|
500
|
+
token: string;
|
|
501
|
+
/** Relay-side bot identifier. Defaults to the agent's name. */
|
|
502
|
+
botId?: string;
|
|
503
|
+
/** Discord user id stamped on stream events (client voice-config key).
|
|
504
|
+
* Defaults to botId. */
|
|
505
|
+
userId?: string;
|
|
506
|
+
/** Display name stamped on stream events. Defaults to the agent's name. */
|
|
507
|
+
username?: string;
|
|
508
|
+
/** MCPL server id owning `edit_message` for interruption trims. Default 'discord'. */
|
|
509
|
+
editServerId?: string;
|
|
510
|
+
reconnectIntervalMs?: number;
|
|
511
|
+
/** Drop a context note when an interruption trims a posted message. Default true. */
|
|
512
|
+
notifyOnInterruption?: boolean;
|
|
513
|
+
}
|
|
514
|
+
|
|
448
515
|
export interface RecipeFleet {
|
|
449
516
|
/** Children to manage. autoStart children launch when the framework starts. */
|
|
450
517
|
children?: RecipeFleetChild[];
|
|
@@ -503,6 +570,31 @@ export interface RecipeFleetChild {
|
|
|
503
570
|
autoRestart?: boolean;
|
|
504
571
|
}
|
|
505
572
|
|
|
573
|
+
/**
|
|
574
|
+
* A deployment-specific code extension: a local TS/JS module that registers
|
|
575
|
+
* custom context strategies and/or framework modules at boot. See
|
|
576
|
+
* src/extensions.ts for the loader and the `register(api, config)` contract.
|
|
577
|
+
*/
|
|
578
|
+
export interface RecipeExtension {
|
|
579
|
+
/** Primary purpose. Gates validation (a non-built-in `agent.strategy.type`
|
|
580
|
+
* requires at least one `kind: "strategy"` extension) and informs build
|
|
581
|
+
* tooling; does not restrict what `register` may call. */
|
|
582
|
+
kind: 'strategy' | 'module';
|
|
583
|
+
/** Path to the extension entry module. Relative paths resolve against the
|
|
584
|
+
* recipe file's directory at load time; URL-loaded recipes must use
|
|
585
|
+
* absolute paths (the host never imports code over the network). */
|
|
586
|
+
path: string;
|
|
587
|
+
/** Opaque blob passed verbatim to the extension's `register` function and
|
|
588
|
+
* to module factory contexts. */
|
|
589
|
+
config?: Record<string, unknown>;
|
|
590
|
+
/** Build-tooling acquisition metadata (git url/ref/install pattern) —
|
|
591
|
+
* consumed by connectome-cook at build time, ignored at runtime. */
|
|
592
|
+
source?: Record<string, unknown>;
|
|
593
|
+
/** Build-only provenance: `source` demoted by build tooling into the
|
|
594
|
+
* shipped recipe. Ignored at runtime. */
|
|
595
|
+
sourceMeta?: Record<string, unknown>;
|
|
596
|
+
}
|
|
597
|
+
|
|
506
598
|
export interface Recipe {
|
|
507
599
|
name: string;
|
|
508
600
|
description?: string;
|
|
@@ -510,6 +602,8 @@ export interface Recipe {
|
|
|
510
602
|
agent: RecipeAgent;
|
|
511
603
|
mcpServers?: Record<string, RecipeMcpServer>;
|
|
512
604
|
modules?: RecipeModules;
|
|
605
|
+
/** Deployment-specific code extensions, keyed by a human-readable name. */
|
|
606
|
+
extensions?: Record<string, RecipeExtension>;
|
|
513
607
|
sessionNaming?: { examples?: string[] };
|
|
514
608
|
}
|
|
515
609
|
|
|
@@ -639,6 +733,7 @@ export async function loadRecipe(source: string): Promise<Recipe> {
|
|
|
639
733
|
raw = substituteEnvVars(raw, source);
|
|
640
734
|
const recipe = validateRecipe(raw);
|
|
641
735
|
resolveChildRecipePaths(recipe, sourceBase);
|
|
736
|
+
resolveExtensionPaths(recipe, sourceBase);
|
|
642
737
|
return resolveSystemPrompt(recipe);
|
|
643
738
|
}
|
|
644
739
|
|
|
@@ -663,6 +758,25 @@ function resolveChildRecipePaths(recipe: Recipe, base: RecipeSourceBase): void {
|
|
|
663
758
|
}
|
|
664
759
|
}
|
|
665
760
|
|
|
761
|
+
/**
|
|
762
|
+
* Resolve relative `extensions[].path` values against the recipe file's
|
|
763
|
+
* directory. URL-loaded recipes cannot carry relative extension paths — the
|
|
764
|
+
* host never fetches code over the network, so there is nothing local to
|
|
765
|
+
* resolve them against.
|
|
766
|
+
*/
|
|
767
|
+
function resolveExtensionPaths(recipe: Recipe, base: RecipeSourceBase): void {
|
|
768
|
+
for (const [name, ext] of Object.entries(recipe.extensions ?? {})) {
|
|
769
|
+
if (isAbsolute(ext.path)) continue;
|
|
770
|
+
if (base.kind === 'url') {
|
|
771
|
+
throw new Error(
|
|
772
|
+
`extensions.${name}.path "${ext.path}" is relative, but the recipe was loaded from a URL. ` +
|
|
773
|
+
`Extension code is never fetched remotely — use an absolute path to a local install.`,
|
|
774
|
+
);
|
|
775
|
+
}
|
|
776
|
+
ext.path = resolve(base.dir, ext.path);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
666
780
|
/**
|
|
667
781
|
* If systemPrompt is an HTTP(S) URL, fetch its contents as plain text.
|
|
668
782
|
* Only treated as a URL if it looks like a single URL (no spaces/newlines).
|
|
@@ -701,8 +815,16 @@ export function validateRecipe(raw: unknown): Recipe {
|
|
|
701
815
|
throw new Error('Recipe agent must have a "systemPrompt" string');
|
|
702
816
|
}
|
|
703
817
|
|
|
704
|
-
if (agent.provider !== undefined &&
|
|
705
|
-
|
|
818
|
+
if (agent.provider !== undefined &&
|
|
819
|
+
agent.provider !== 'anthropic' &&
|
|
820
|
+
agent.provider !== 'openai-responses' &&
|
|
821
|
+
agent.provider !== 'openai-codex' &&
|
|
822
|
+
agent.provider !== 'openrouter' &&
|
|
823
|
+
agent.provider !== 'bedrock') {
|
|
824
|
+
throw new Error(
|
|
825
|
+
`Recipe agent.provider must be 'anthropic', 'openai-responses', 'openai-codex', 'openrouter', or 'bedrock', ` +
|
|
826
|
+
`got ${JSON.stringify(agent.provider)}.`,
|
|
827
|
+
);
|
|
706
828
|
}
|
|
707
829
|
|
|
708
830
|
if (agent.timezone !== undefined) {
|
|
@@ -735,6 +857,20 @@ export function validateRecipe(raw: unknown): Recipe {
|
|
|
735
857
|
(typeof responses.compactThreshold !== 'number' || responses.compactThreshold <= 0)) {
|
|
736
858
|
throw new Error('Recipe agent.responses.compactThreshold must be a positive number.');
|
|
737
859
|
}
|
|
860
|
+
const serviceTiers = ['auto', 'default', 'flex', 'priority'];
|
|
861
|
+
if (responses.serviceTier !== undefined && !serviceTiers.includes(String(responses.serviceTier))) {
|
|
862
|
+
throw new Error(`Invalid agent.responses.serviceTier ${JSON.stringify(responses.serviceTier)}.`);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
if (agent.codex !== undefined) {
|
|
867
|
+
if (!agent.codex || typeof agent.codex !== 'object' || Array.isArray(agent.codex)) {
|
|
868
|
+
throw new Error('Recipe agent.codex must be an object.');
|
|
869
|
+
}
|
|
870
|
+
const codex = agent.codex as Record<string, unknown>;
|
|
871
|
+
if (codex.fastMode !== undefined && typeof codex.fastMode !== 'boolean') {
|
|
872
|
+
throw new Error('Recipe agent.codex.fastMode must be a boolean.');
|
|
873
|
+
}
|
|
738
874
|
}
|
|
739
875
|
|
|
740
876
|
if (agent.maxStreamTokens !== undefined && (typeof agent.maxStreamTokens !== 'number' || agent.maxStreamTokens <= 0)) {
|
|
@@ -782,6 +918,37 @@ export function validateRecipe(raw: unknown): Recipe {
|
|
|
782
918
|
}
|
|
783
919
|
}
|
|
784
920
|
|
|
921
|
+
// Validate the extensions block if present (before the strategy check,
|
|
922
|
+
// which consults it to admit custom strategy types).
|
|
923
|
+
let hasStrategyExtension = false;
|
|
924
|
+
if (obj.extensions !== undefined) {
|
|
925
|
+
if (!obj.extensions || typeof obj.extensions !== 'object' || Array.isArray(obj.extensions)) {
|
|
926
|
+
throw new Error('Recipe "extensions" must be an object mapping names to extension entries.');
|
|
927
|
+
}
|
|
928
|
+
for (const [name, entry] of Object.entries(obj.extensions as Record<string, unknown>)) {
|
|
929
|
+
if (!name) throw new Error('Recipe extensions keys must be non-empty names.');
|
|
930
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
931
|
+
throw new Error(`extensions.${name} must be an object.`);
|
|
932
|
+
}
|
|
933
|
+
const ext = entry as Record<string, unknown>;
|
|
934
|
+
if (ext.kind !== 'strategy' && ext.kind !== 'module') {
|
|
935
|
+
throw new Error(`extensions.${name}.kind must be "strategy" or "module", got ${JSON.stringify(ext.kind)}.`);
|
|
936
|
+
}
|
|
937
|
+
if (typeof ext.path !== 'string' || !ext.path) {
|
|
938
|
+
throw new Error(`extensions.${name}.path must be a non-empty string.`);
|
|
939
|
+
}
|
|
940
|
+
if (ext.config !== undefined && (!ext.config || typeof ext.config !== 'object' || Array.isArray(ext.config))) {
|
|
941
|
+
throw new Error(`extensions.${name}.config must be an object.`);
|
|
942
|
+
}
|
|
943
|
+
for (const metaKey of ['source', 'sourceMeta'] as const) {
|
|
944
|
+
if (ext[metaKey] !== undefined && (!ext[metaKey] || typeof ext[metaKey] !== 'object' || Array.isArray(ext[metaKey]))) {
|
|
945
|
+
throw new Error(`extensions.${name}.${metaKey} must be an object.`);
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
if (ext.kind === 'strategy') hasStrategyExtension = true;
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
|
|
785
952
|
// Validate strategy type if present
|
|
786
953
|
if (agent.strategy) {
|
|
787
954
|
const strategy = agent.strategy as Record<string, unknown>;
|
|
@@ -789,10 +956,13 @@ export function validateRecipe(raw: unknown): Recipe {
|
|
|
789
956
|
strategy.type &&
|
|
790
957
|
strategy.type !== 'autobiographical' &&
|
|
791
958
|
strategy.type !== 'passthrough' &&
|
|
792
|
-
strategy.type !== 'frontdesk'
|
|
959
|
+
strategy.type !== 'frontdesk' &&
|
|
960
|
+
!hasStrategyExtension
|
|
793
961
|
) {
|
|
794
962
|
throw new Error(
|
|
795
|
-
`Invalid strategy type "${strategy.type}". Must be "autobiographical", "passthrough",
|
|
963
|
+
`Invalid strategy type "${strategy.type}". Must be "autobiographical", "passthrough", ` +
|
|
964
|
+
`or "frontdesk" — or a custom type registered by a "kind": "strategy" entry in the ` +
|
|
965
|
+
`recipe's "extensions" block (none is declared).`,
|
|
796
966
|
);
|
|
797
967
|
}
|
|
798
968
|
if (
|
|
@@ -981,6 +1151,22 @@ export function validateRecipe(raw: unknown): Recipe {
|
|
|
981
1151
|
}
|
|
982
1152
|
}
|
|
983
1153
|
|
|
1154
|
+
// Validate ttsRelay if present — url + token are load-bearing, and a
|
|
1155
|
+
// recipe that names the module but can't reach a relay should fail at
|
|
1156
|
+
// load, not silently stream nowhere.
|
|
1157
|
+
if (mods.ttsRelay !== undefined && mods.ttsRelay !== false) {
|
|
1158
|
+
if (typeof mods.ttsRelay !== 'object' || mods.ttsRelay === null) {
|
|
1159
|
+
throw new Error('modules.ttsRelay must be an object ({ url, token, ... })');
|
|
1160
|
+
}
|
|
1161
|
+
const tr = mods.ttsRelay as Record<string, unknown>;
|
|
1162
|
+
if (typeof tr.url !== 'string' || !/^wss?:\/\//.test(tr.url)) {
|
|
1163
|
+
throw new Error('modules.ttsRelay.url must be a ws:// or wss:// URL');
|
|
1164
|
+
}
|
|
1165
|
+
if (typeof tr.token !== 'string' || !tr.token) {
|
|
1166
|
+
throw new Error('modules.ttsRelay.token must be a non-empty string (use ${VAR} substitution)');
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
|
|
984
1170
|
// Validate fleet if present (object form only — boolean is trivially valid).
|
|
985
1171
|
if (mods.fleet && typeof mods.fleet === 'object') {
|
|
986
1172
|
const fleet = mods.fleet as Record<string, unknown>;
|