@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.
Files changed (44) hide show
  1. package/.env.example +7 -0
  2. package/.github/PULL_REQUEST_TEMPLATE.md +26 -0
  3. package/.github/workflows/changelog.yml +32 -0
  4. package/.github/workflows/publish.yml +46 -0
  5. package/CHANGELOG.md +140 -0
  6. package/CONTRIBUTING.md +126 -0
  7. package/HEADLESS-FLEET-PLAN.md +1 -1
  8. package/README.md +52 -5
  9. package/UNIFIED-TREE-PLAN.md +2 -0
  10. package/docs/AGENT-ONBOARDING.md +9 -8
  11. package/docs/DEPLOYMENTS.md +2 -2
  12. package/docs/DEV-ENVIRONMENT.md +28 -26
  13. package/docs/LOCUS-ROUTING-DESIGN.md +8 -2
  14. package/package.json +5 -4
  15. package/scripts/release-changelog.ts +32 -0
  16. package/src/codex-subscription-adapter.ts +938 -0
  17. package/src/commands.ts +95 -4
  18. package/src/extensions.ts +201 -0
  19. package/src/framework-agent-config.ts +20 -9
  20. package/src/framework-strategy.ts +24 -0
  21. package/src/index.ts +111 -19
  22. package/src/logging-bedrock-adapter.ts +145 -0
  23. package/src/modules/channel-mode-module.ts +9 -6
  24. package/src/modules/subscription-gc-module.ts +163 -34
  25. package/src/modules/tts-relay-module.ts +481 -0
  26. package/src/modules/web-ui-module.ts +45 -1
  27. package/src/recipe.ts +196 -10
  28. package/src/tui.ts +527 -137
  29. package/src/web/protocol.ts +35 -0
  30. package/test/codex-subscription-adapter.test.ts +226 -0
  31. package/test/commands-checkpoint-restore.test.ts +118 -0
  32. package/test/fast-command.test.ts +39 -0
  33. package/test/recipe-extensions.test.ts +295 -0
  34. package/test/recipe-provider.test.ts +14 -1
  35. package/test/subscription-gc-module.test.ts +156 -1
  36. package/test/tui-quit-confirm.test.ts +42 -0
  37. package/test/tui-short-agent-name.test.ts +36 -0
  38. package/test/web-ui-observers.test.ts +14 -0
  39. package/test/web-ui-protocol.test.ts +0 -0
  40. package/web/src/App.tsx +235 -20
  41. package/web/src/Branches.tsx +150 -0
  42. package/web/src/Context.tsx +21 -13
  43. package/web/src/Health.tsx +274 -0
  44. 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
- type: 'autobiographical' | 'passthrough' | 'frontdesk';
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
- /** Stateless OpenAI Responses settings. Only used with
119
- * `provider: "openai-responses"`. */
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 the `subscription-gc--set_channel_idle_limit` tool.
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 && agent.provider !== 'anthropic' && agent.provider !== 'openai-responses') {
705
- throw new Error(`Recipe agent.provider must be 'anthropic' or 'openai-responses', got ${JSON.stringify(agent.provider)}.`);
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", or "frontdesk".`,
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>;