@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/index.ts CHANGED
@@ -16,12 +16,17 @@
16
16
  */
17
17
 
18
18
  import {
19
+ AnthropicXmlFormatter,
20
+ BedrockAdapter,
19
21
  Membrane,
20
22
  NativeFormatter,
21
23
  OpenAIResponsesAPIAdapter,
22
24
  OpenAIResponsesFormatter,
25
+ OpenRouterAdapter,
23
26
  } from '@animalabs/membrane';
24
27
  import { LoggingAnthropicAdapter } from './logging-adapter.js';
28
+ import { LoggingBedrockAdapter } from './logging-bedrock-adapter.js';
29
+ import { CodexSubscriptionAdapter } from './codex-subscription-adapter.js';
25
30
  import { CallLedger } from './call-ledger.js';
26
31
  import { SettingsModule } from './modules/settings-module.js';
27
32
  import { AgentFramework, WorkspaceModule, resolveTimeZone, type Module, type MountConfig } from '@animalabs/agent-framework';
@@ -41,6 +46,7 @@ import { ChannelModeModule } from './modules/channel-mode-module.js';
41
46
  import { WebUiModule } from './modules/web-ui-module.js';
42
47
  import { ObserversModule } from './modules/observers-module.js';
43
48
  import { McplAdminModule } from './modules/mcpl-admin-module.js';
49
+ import { TtsRelayModule } from './modules/tts-relay-module.js';
44
50
  import { loadMcplServers, applyAgentOverlay, DEFAULT_CONFIG_PATH, DEFAULT_AGENT_OVERLAY_PATH } from './mcpl-config.js';
45
51
  import { SessionManager } from './session-manager.js';
46
52
  import { resolveAgentName } from './agent-name.js';
@@ -57,6 +63,7 @@ import {
57
63
  import { createBranchState, resetBranchState, handleExport, type BranchState } from './commands.js';
58
64
  import { buildFrameworkAgentConfig } from './framework-agent-config.js';
59
65
  import { buildFrameworkStrategy } from './framework-strategy.js';
66
+ import { loadExtensions } from './extensions.js';
60
67
 
61
68
  export type { AppContext };
62
69
 
@@ -69,6 +76,8 @@ const config = {
69
76
  // precedence over the API key so requests never carry both auth schemes.
70
77
  authToken: process.env.ANTHROPIC_AUTH_TOKEN,
71
78
  openaiApiKey: process.env.OPENAI_API_KEY,
79
+ openrouterApiKey: process.env.OPENROUTER_API_KEY,
80
+ codexBinary: process.env.CODEX_BINARY,
72
81
  model: process.env.MODEL,
73
82
  dataDir: process.env.DATA_DIR || './data',
74
83
  };
@@ -91,6 +100,7 @@ interface AppContext {
91
100
  agentName: string;
92
101
  branchState: BranchState;
93
102
  userMessageCount: number;
103
+ codexAdapter?: CodexSubscriptionAdapter;
94
104
 
95
105
  /** Stop current framework, switch to a different session, start new framework. */
96
106
  switchSession(id: string): Promise<void>;
@@ -143,10 +153,16 @@ async function createFramework(
143
153
  settingsModule: SettingsModule,
144
154
  callLedger: CallLedger | null,
145
155
  ): Promise<AgentFramework> {
146
- const model = config.model || recipe.agent.model || 'claude-opus-4-6';
156
+ const model = config.model || recipe.agent.model ||
157
+ (recipe.agent.provider === 'openai-codex' ? 'gpt-5.4' : 'claude-opus-4-6');
147
158
  const modules = recipe.modules ?? {};
148
159
  const timeZone = resolveTimeZone(recipe.agent.timezone);
149
160
 
161
+ // Load recipe extensions first — custom strategies must be registered
162
+ // before buildFrameworkStrategy runs, and custom modules join the module
163
+ // list below. The loader is a dumb local import; see src/extensions.ts.
164
+ const extensionRegistry = await loadExtensions(recipe);
165
+
150
166
  // -- Build module list --
151
167
  // SettingsModule is constructed in main() (before the adapter, so the
152
168
  // adapter can read its state for cross-cutting concerns like reasoning).
@@ -342,6 +358,24 @@ async function createFramework(
342
358
  moduleInstances.push(new ObserversModule({ path: observersPath }));
343
359
  }
344
360
 
361
+ // TTS relay tap — opt-in per recipe. Pure trace-bus consumer: mirrors the
362
+ // agent's streaming generation to a melodeus-tts-relay /bot endpoint and
363
+ // trims posted messages on voice interruptions. See modules.ttsRelay in
364
+ // recipe.ts for the config contract (url/token validated at recipe load).
365
+ if (modules.ttsRelay) {
366
+ moduleInstances.push(new TtsRelayModule(modules.ttsRelay));
367
+ }
368
+
369
+ // Extension-registered modules. Instantiated last so built-in modules keep
370
+ // their historical positions; each factory gets the minimal runtime context
371
+ // plus its declaring extension's config blob.
372
+ const extensionModules: Module[] = [];
373
+ for (const entry of extensionRegistry.modules) {
374
+ const instance = entry.factory({ timeZone, storePath, model, config: entry.config });
375
+ extensionModules.push(instance);
376
+ moduleInstances.push(instance);
377
+ }
378
+
345
379
  // -- Build MCP server list --
346
380
  //
347
381
  // Recipes are opt-in: a file entry from mcpl-servers.json is loaded only
@@ -401,14 +435,14 @@ async function createFramework(
401
435
  // No server augmentation needed — gate is wired via FrameworkConfig.gate
402
436
 
403
437
  // -- Build strategy --
404
- const strategy = buildFrameworkStrategy(recipe, model, timeZone);
438
+ const strategy = buildFrameworkStrategy(recipe, model, timeZone, extensionRegistry);
405
439
  const agentConfig = buildFrameworkAgentConfig(recipe, agentName, model, strategy);
406
440
 
407
441
  // -- Create framework --
408
442
  const framework = await AgentFramework.create({
409
443
  storePath,
410
444
  membrane,
411
- agents: [agentConfig],
445
+ agents: [agentConfig],
412
446
  modules: moduleInstances,
413
447
  mcplServers: finalServers,
414
448
  gate: gateOptions,
@@ -467,6 +501,13 @@ async function createFramework(
467
501
  channelModeModule.setFramework(framework);
468
502
  }
469
503
 
504
+ // Extension modules get the same duck-typed post-creation hook the
505
+ // built-ins use: if the instance exposes setFramework, call it.
506
+ for (const instance of extensionModules) {
507
+ const hooked = instance as unknown as { setFramework?: (f: AgentFramework) => void };
508
+ hooked.setFramework?.(framework);
509
+ }
510
+
470
511
  if (mcplAdminModule) {
471
512
  mcplAdminModule.setFramework(framework);
472
513
  }
@@ -733,10 +774,18 @@ async function main() {
733
774
  const recipe = await resolveRecipe();
734
775
  const provider = recipe.agent.provider ?? 'anthropic';
735
776
 
777
+ if (provider === 'openrouter' && !config.openrouterApiKey) {
778
+ console.error('Missing OPENROUTER_API_KEY for recipe provider "openrouter".');
779
+ process.exit(1);
780
+ }
736
781
  if (provider === 'openai-responses' && !config.openaiApiKey) {
737
782
  console.error('Missing OPENAI_API_KEY for recipe provider "openai-responses".');
738
783
  process.exit(1);
739
784
  }
785
+ if (provider === 'bedrock' && !(process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY)) {
786
+ console.error('Missing AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY for recipe provider "bedrock".');
787
+ process.exit(1);
788
+ }
740
789
  if (provider === 'anthropic' && !config.apiKey && !config.authToken) {
741
790
  console.error('Missing ANTHROPIC_API_KEY (or ANTHROPIC_AUTH_TOKEN). Set one in .env or environment.');
742
791
  process.exit(1);
@@ -761,22 +810,54 @@ async function main() {
761
810
  defaultTtl: recipe.agent.cacheTtl ?? '5m',
762
811
  })
763
812
  : null;
764
- // OAuth (subscription) auth wins over API-key auth when both are present.
765
- // Subscription tokens (sk-ant-oat…) additionally require the oauth beta
766
- // header on every request.
813
+ // The Codex subscription adapter owns ChatGPT login/refresh independently
814
+ // of the API-key transports below.
815
+ const codexAdapter = provider === 'openai-codex'
816
+ ? new CodexSubscriptionAdapter({
817
+ codexBinary: config.codexBinary,
818
+ fastMode: recipe.agent.codex?.fastMode ?? false,
819
+ })
820
+ : undefined;
821
+ const openrouterAdapter = provider === 'openrouter'
822
+ ? new OpenRouterAdapter({
823
+ apiKey: config.openrouterApiKey!,
824
+ xTitle: recipe.agent.name ?? recipe.name,
825
+ })
826
+ : undefined;
827
+ // Bedrock: legacy Claude models (3.5 Sonnet 0620/1022, Opus 3) that have
828
+ // left the Anthropic API but survive on AWS. The adapter reads AWS_* env
829
+ // vars (AWS_REGION defaults us-west-2) and maps standard Claude model IDs
830
+ // to Bedrock IDs (explicit map + `anthropic.<id>-v1:0` fallback). Uses the
831
+ // Anthropic-native message shape, so NativeFormatter applies unchanged.
832
+ // No CallLedger: prompt caching is rejected outright by legacy Bedrock
833
+ // models (tested 2026-07-21). Wrapped for llm-calls.jsonl visibility.
834
+ const bedrockAdapter = provider === 'bedrock'
835
+ ? new LoggingBedrockAdapter({}, llmLogPath)
836
+ : undefined;
767
837
  const adapter = provider === 'openai-responses'
768
838
  ? new OpenAIResponsesAPIAdapter({
769
839
  apiKey: config.openaiApiKey!,
770
840
  baseURL: process.env.OPENAI_BASE_URL || undefined,
771
841
  })
772
- : new LoggingAnthropicAdapter(
842
+ : bedrockAdapter ?? openrouterAdapter ?? codexAdapter ?? new LoggingAnthropicAdapter(
773
843
  {
844
+ // Anthropic OAuth wins over API-key auth when both are present.
845
+ // Subscription tokens additionally require this beta header.
846
+ // Recipe-declared betas (agent.anthropicBetas, e.g. context-1m)
847
+ // are merged into the same anthropic-beta header either way.
774
848
  ...(config.authToken
775
849
  ? {
776
850
  authToken: config.authToken,
777
- defaultHeaders: { 'anthropic-beta': 'oauth-2025-04-20' },
851
+ defaultHeaders: {
852
+ 'anthropic-beta': ['oauth-2025-04-20', ...(recipe.agent.anthropicBetas ?? [])].join(','),
853
+ },
778
854
  }
779
- : { apiKey: config.apiKey! }),
855
+ : {
856
+ apiKey: config.apiKey!,
857
+ ...(recipe.agent.anthropicBetas?.length
858
+ ? { defaultHeaders: { 'anthropic-beta': recipe.agent.anthropicBetas.join(',') } }
859
+ : {}),
860
+ }),
780
861
  baseURL: process.env.ANTHROPIC_BASE_URL || undefined,
781
862
  },
782
863
  llmLogPath,
@@ -815,9 +896,15 @@ async function main() {
815
896
  const agentName = resolved.name;
816
897
 
817
898
  const membrane = new Membrane(adapter, {
818
- formatter: provider === 'openai-responses'
899
+ formatter: provider === 'openai-responses' || provider === 'openai-codex'
819
900
  ? new OpenAIResponsesFormatter()
820
- : new NativeFormatter(),
901
+ : recipe.agent.formatter === 'anthropic-xml'
902
+ ? new AnthropicXmlFormatter()
903
+ : new NativeFormatter(),
904
+ // Bedrock legacy Claude models 400 on any cache_control block
905
+ // ("your request did not allow prompt caching") — suppress the
906
+ // historical promptCaching=true default on that transport.
907
+ ...(provider === 'bedrock' ? { defaultPromptCaching: false } : {}),
821
908
  // Anchor the assistant role for internal callers that don't set
822
909
  // request.assistantParticipant themselves (autobio compression,
823
910
  // executeMerge). Mismatch here flips stored assistant turns to
@@ -837,6 +924,7 @@ async function main() {
837
924
  agentName,
838
925
  branchState: createBranchState(),
839
926
  userMessageCount: 0,
927
+ codexAdapter,
840
928
 
841
929
  async switchSession(id: string) {
842
930
  handleExport(this);
@@ -884,14 +972,18 @@ async function main() {
884
972
  setupMcplStderrLog(app, storePath);
885
973
  getWebUiModule(framework)?.setApp(app);
886
974
 
887
- if (headless) {
888
- const { runHeadless } = await import('./headless.js');
889
- await runHeadless(app, process.argv.slice(2));
890
- } else if (noTui) {
891
- await runPiped(app);
892
- } else {
893
- const { runTui } = await import('./tui.js');
894
- await runTui(app);
975
+ try {
976
+ if (headless) {
977
+ const { runHeadless } = await import('./headless.js');
978
+ await runHeadless(app, process.argv.slice(2));
979
+ } else if (noTui) {
980
+ await runPiped(app);
981
+ } else {
982
+ const { runTui } = await import('./tui.js');
983
+ await runTui(app);
984
+ }
985
+ } finally {
986
+ codexAdapter?.dispose();
895
987
  }
896
988
  }
897
989
 
@@ -0,0 +1,145 @@
1
+ // BedrockAdapter wrapper that appends each LLM call's request summary, stop
2
+ // reason, and any error to the same `llm-calls.<iso>.jsonl` file the
3
+ // Anthropic path uses. Purpose-built during the Aria bring-up (2026-07-21)
4
+ // when a 0-token assistant turn proved undiagnosable: the bedrock transport
5
+ // had no wire visibility at all.
6
+ //
7
+ // Kept deliberately lighter than LoggingAnthropicAdapter: request summaries
8
+ // always include tool NAMES (were tools attached at all? — the question that
9
+ // motivated this file), message/system sizes, and the stop sequences; the
10
+ // full raw request is retained only on errors. Responses log stop_reason,
11
+ // usage, and content-block shape (type + size per block) on every call.
12
+ //
13
+ // Each line: { type: 'call'|'error', kind: 'complete'|'stream', timestamp,
14
+ // durationMs, requestSummary, response?, rawRequest?, error? }
15
+
16
+ import { BedrockAdapter } from '@animalabs/membrane';
17
+ import type {
18
+ ProviderRequest,
19
+ ProviderResponse,
20
+ ProviderRequestOptions,
21
+ StreamCallbacks,
22
+ } from '@animalabs/membrane';
23
+ import { appendFileSync } from 'node:fs';
24
+
25
+ type BedrockConfig = ConstructorParameters<typeof BedrockAdapter>[0];
26
+
27
+ function summarizeRequest(request: ProviderRequest): Record<string, unknown> {
28
+ const msgs = (request.messages ?? []) as Array<{ role?: string; content?: unknown }>;
29
+ const last = msgs[msgs.length - 1];
30
+ const lastPreview = typeof last?.content === 'string'
31
+ ? last.content.slice(0, 200)
32
+ : Array.isArray(last?.content)
33
+ ? (last.content as Array<{ type?: string; text?: string }>)
34
+ .map((b) => (b.type === 'text' ? (b.text ?? '').slice(0, 120) : `[${b.type}]`))
35
+ .join(' | ').slice(0, 300)
36
+ : undefined;
37
+ return {
38
+ model: request.model,
39
+ maxTokens: request.maxTokens,
40
+ messageCount: msgs.length,
41
+ systemChars: typeof request.system === 'string'
42
+ ? request.system.length
43
+ : Array.isArray(request.system)
44
+ ? JSON.stringify(request.system).length
45
+ : 0,
46
+ toolNames: (request.tools as Array<{ name?: string }> | undefined)?.map((t) => t.name) ?? null,
47
+ toolCount: (request.tools as unknown[] | undefined)?.length ?? 0,
48
+ stopSequences: request.stopSequences ?? null,
49
+ lastMessageRole: last?.role,
50
+ lastMessagePreview: lastPreview,
51
+ };
52
+ }
53
+
54
+ function summarizeResponse(response: ProviderResponse): Record<string, unknown> {
55
+ const content = (response.content ?? []) as Array<{ type?: string; text?: string; name?: string }>;
56
+ return {
57
+ stopReason: response.stopReason ?? (response.raw as { stop_reason?: string } | undefined)?.stop_reason ?? null,
58
+ usage: response.usage ?? null,
59
+ blocks: content.map((b) => ({
60
+ type: b.type,
61
+ ...(b.type === 'text' ? { chars: (b.text ?? '').length } : {}),
62
+ ...(b.type === 'tool_use' ? { name: b.name } : {}),
63
+ })),
64
+ };
65
+ }
66
+
67
+ export class LoggingBedrockAdapter extends BedrockAdapter {
68
+ private readonly logPath: string;
69
+
70
+ constructor(config: BedrockConfig, logPath: string) {
71
+ super(config);
72
+ this.logPath = logPath;
73
+ }
74
+
75
+ private log(record: Record<string, unknown>): void {
76
+ try {
77
+ appendFileSync(this.logPath, JSON.stringify(record) + '\n');
78
+ } catch {
79
+ // Logging must never break inference.
80
+ }
81
+ }
82
+
83
+ override async complete(
84
+ request: ProviderRequest,
85
+ options?: ProviderRequestOptions,
86
+ ): Promise<ProviderResponse> {
87
+ const started = Date.now();
88
+ let rawRequest: unknown;
89
+ const opts: ProviderRequestOptions = {
90
+ ...options,
91
+ onRequest: (req) => { rawRequest = req; options?.onRequest?.(req); },
92
+ };
93
+ try {
94
+ const response = await super.complete(request, opts);
95
+ this.log({
96
+ type: 'call', kind: 'complete', timestamp: new Date(started).toISOString(),
97
+ durationMs: Date.now() - started,
98
+ requestSummary: summarizeRequest(request),
99
+ response: summarizeResponse(response),
100
+ });
101
+ return response;
102
+ } catch (error) {
103
+ this.log({
104
+ type: 'error', kind: 'complete', timestamp: new Date(started).toISOString(),
105
+ durationMs: Date.now() - started,
106
+ requestSummary: summarizeRequest(request),
107
+ rawRequest,
108
+ error: error instanceof Error ? { message: error.message, name: error.name } : String(error),
109
+ });
110
+ throw error;
111
+ }
112
+ }
113
+
114
+ override async stream(
115
+ request: ProviderRequest,
116
+ callbacks: StreamCallbacks,
117
+ options?: ProviderRequestOptions,
118
+ ): Promise<ProviderResponse> {
119
+ const started = Date.now();
120
+ let rawRequest: unknown;
121
+ const opts: ProviderRequestOptions = {
122
+ ...options,
123
+ onRequest: (req) => { rawRequest = req; options?.onRequest?.(req); },
124
+ };
125
+ try {
126
+ const response = await super.stream(request, callbacks, opts);
127
+ this.log({
128
+ type: 'call', kind: 'stream', timestamp: new Date(started).toISOString(),
129
+ durationMs: Date.now() - started,
130
+ requestSummary: summarizeRequest(request),
131
+ response: summarizeResponse(response),
132
+ });
133
+ return response;
134
+ } catch (error) {
135
+ this.log({
136
+ type: 'error', kind: 'stream', timestamp: new Date(started).toISOString(),
137
+ durationMs: Date.now() - started,
138
+ requestSummary: summarizeRequest(request),
139
+ rawRequest,
140
+ error: error instanceof Error ? { message: error.message, name: error.name } : String(error),
141
+ });
142
+ throw error;
143
+ }
144
+ }
145
+ }
@@ -200,11 +200,13 @@ export class ChannelModeModule implements Module {
200
200
  }
201
201
 
202
202
  // 3. Pin subscription-gc off so a chatty channel doesn't auto-close
203
- // itself back out of debounced mode.
203
+ // itself back out of debounced mode. The pin layer sits above
204
+ // agent-level overrides, so agent_settings reset/update can't undo it
205
+ // and any pre-existing override survives the mode round-trip.
204
206
  steps.push(
205
- await this.callToolStep('gc-off', `${this.gcModuleName}--set_channel_idle_limit`, {
207
+ await this.callToolStep('gc-pin', `${this.gcModuleName}--pin_channel_idle_limit`, {
206
208
  channelId,
207
- limit: 'off',
209
+ pinned: true,
208
210
  }),
209
211
  );
210
212
 
@@ -225,11 +227,12 @@ export class ChannelModeModule implements Module {
225
227
  // 2. Close ambient traffic through the generic host lifecycle.
226
228
  steps.push(await this.callToolStep('close', 'channel_close', { channelId, serverId: this.serverId }));
227
229
 
228
- // 3. Restore the default auto-close limit.
230
+ // 3. Release the pin; the channel's agent override (if any) or the
231
+ // default limit applies again.
229
232
  steps.push(
230
- await this.callToolStep('gc-default', `${this.gcModuleName}--set_channel_idle_limit`, {
233
+ await this.callToolStep('gc-unpin', `${this.gcModuleName}--pin_channel_idle_limit`, {
231
234
  channelId,
232
- limit: 'default',
235
+ pinned: false,
233
236
  }),
234
237
  );
235
238