@animalabs/connectome-host 0.3.9 → 0.3.10

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.
@@ -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
+ }
@@ -16,7 +16,8 @@
16
16
  * fan-out across many VMs are the reverse-proxy's job; an optional Basic-Auth
17
17
  * check is available as defense-in-depth.
18
18
  *
19
- * See WEBUI-PLAN.md and src/web/protocol.ts for the wire shape.
19
+ * See src/web/protocol.ts for the wire shape and docs/webui-deployment.md
20
+ * for the deployment story.
20
21
  */
21
22
 
22
23
  import { CURVE_PAGE_HTML } from './web-ui-curve-page.js';
@@ -57,6 +58,7 @@ import {
57
58
  type TokenUsage,
58
59
  type CallLedgerSnapshot,
59
60
  type McplListMessage,
61
+ type BranchesListMessage,
60
62
  type LessonsListMessage,
61
63
  } from '../web/protocol.js';
62
64
  import {
@@ -1547,6 +1549,9 @@ export class WebUiModule implements Module {
1547
1549
  if (client.auth !== 'observer') return false;
1548
1550
  if (type === 'ping') return true;
1549
1551
  if (type === 'request-history') return client.scopes?.has('messages') ?? false;
1552
+ // Branch listing is conversation-shape metadata (names, fork points) —
1553
+ // same sensitivity tier as the message window, so same scope.
1554
+ if (type === 'request-branches') return client.scopes?.has('messages') ?? false;
1550
1555
  return false; // observers are read-only: no user-message/command/mcpl/fleet
1551
1556
  }
1552
1557
 
@@ -1698,6 +1703,11 @@ export class WebUiModule implements Module {
1698
1703
  return;
1699
1704
  }
1700
1705
 
1706
+ case 'request-branches': {
1707
+ this.sendBranchesList(client);
1708
+ return;
1709
+ }
1710
+
1701
1711
  case 'mcpl-add': {
1702
1712
  try {
1703
1713
  const servers = readMcplServersFile(DEFAULT_CONFIG_PATH);
@@ -1983,6 +1993,40 @@ export class WebUiModule implements Module {
1983
1993
  this.send(client, out);
1984
1994
  }
1985
1995
 
1996
+ /** Build a BranchesListMessage from the agent's context manager. Lineage
1997
+ * (parentId + branchPoint) comes straight from Chronicle's branch records;
1998
+ * the SPA folds it into a tree. */
1999
+ private sendBranchesList(client: ClientState): void {
2000
+ if (!sharedServer?.app) return;
2001
+ const cm = sharedServer.app.framework.getAllAgents()[0]?.getContextManager();
2002
+ if (!cm) {
2003
+ this.send(client, { type: 'error', message: 'no agent context manager' });
2004
+ return;
2005
+ }
2006
+ try {
2007
+ const branches = cm.listBranches();
2008
+ const current = cm.currentBranch();
2009
+ const out: BranchesListMessage = {
2010
+ type: 'branches-list',
2011
+ branches: branches.map((b) => ({
2012
+ id: b.id,
2013
+ name: b.name,
2014
+ head: b.head,
2015
+ ...(b.parentId !== undefined ? { parentId: b.parentId } : {}),
2016
+ ...(b.branchPoint !== undefined ? { branchPoint: b.branchPoint } : {}),
2017
+ created: b.created instanceof Date ? b.created.getTime() : Number(b.created),
2018
+ })),
2019
+ currentId: current.id,
2020
+ };
2021
+ this.send(client, out);
2022
+ } catch (err) {
2023
+ this.send(client, {
2024
+ type: 'error',
2025
+ message: `branch listing failed: ${err instanceof Error ? err.message : String(err)}`,
2026
+ });
2027
+ }
2028
+ }
2029
+
1986
2030
  /** Build a LessonsListMessage from the bound LessonsModule, if present. */
1987
2031
  private sendLessonsList(client: ClientState): void {
1988
2032
  if (!sharedServer?.app) return;
package/src/recipe.ts CHANGED
@@ -19,7 +19,11 @@ 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;
@@ -85,7 +89,15 @@ export interface RecipeAgent {
85
89
  /** IANA zone used when rendering wall-clock times to the agent. */
86
90
  timezone?: string;
87
91
  /** Provider transport. Omitted preserves the historical Anthropic default. */
88
- provider?: 'anthropic' | 'openai-responses';
92
+ provider?: 'anthropic' | 'openai-responses' | 'openai-codex' | 'openrouter' | 'bedrock';
93
+ /** Message formatter. 'native' (default) = structured user/assistant turns.
94
+ * 'anthropic-xml' = classic prefill format ("participant: text" runs, XML
95
+ * tools) — for migrating prefill-era bots (chapterx borgs) with their exact
96
+ * prompting structure intact. */
97
+ formatter?: 'native' | 'anthropic-xml';
98
+ /** Prefill scaffold user message appended after the conversation (e.g.
99
+ * chapterx CLI-sim's "<cmd>cat untitled.txt</cmd>"). Prefill formatter only. */
100
+ prefillUserMessage?: string;
89
101
  systemPrompt: string;
90
102
  maxTokens?: number;
91
103
  /**
@@ -105,6 +117,13 @@ export interface RecipeAgent {
105
117
  * Omitted preserves the compatibility carry-forward in Agent Framework.
106
118
  */
107
119
  sameRoundThinkTextPolicy?: 'public' | 'private';
120
+ /**
121
+ * Extra Anthropic beta flags sent as the `anthropic-beta` header on every
122
+ * request (e.g. `["context-1m-2025-08-07"]` for the 1M context window on
123
+ * models where it is opt-in, like claude-sonnet-4-5). Merged with any
124
+ * transport-required betas (OAuth). Anthropic provider only.
125
+ */
126
+ anthropicBetas?: string[];
108
127
  strategy?: RecipeStrategy;
109
128
  /**
110
129
  * Native extended thinking. When `enabled: true`, the agent's API requests
@@ -115,13 +134,20 @@ export interface RecipeAgent {
115
134
  enabled: boolean;
116
135
  budgetTokens?: number;
117
136
  };
118
- /** Stateless OpenAI Responses settings. Only used with
119
- * `provider: "openai-responses"`. */
137
+ /** OpenAI Responses settings. Reasoning applies to both OpenAI providers;
138
+ * compaction and serviceTier are API-key transport settings. */
120
139
  responses?: {
121
140
  reasoningEffort?: 'none' | 'low' | 'medium' | 'high' | 'xhigh' | 'max';
122
141
  reasoningContext?: 'current_turn' | 'all_turns';
123
- serviceTier?: string;
124
142
  compactThreshold?: number;
143
+ /** OpenAI Responses API processing tier. `priority` is the API-key
144
+ * equivalent of Codex fast mode. */
145
+ serviceTier?: 'auto' | 'default' | 'flex' | 'priority';
146
+ };
147
+ /** ChatGPT-subscription Codex settings. Only used with
148
+ * `provider: "openai-codex"`. Runtime `/fast` overrides fastMode. */
149
+ codex?: {
150
+ fastMode?: boolean;
125
151
  };
126
152
  /**
127
153
  * Content-refusal handling. When `autoRewind` is on, a `stop_reason: refusal`
@@ -503,6 +529,31 @@ export interface RecipeFleetChild {
503
529
  autoRestart?: boolean;
504
530
  }
505
531
 
532
+ /**
533
+ * A deployment-specific code extension: a local TS/JS module that registers
534
+ * custom context strategies and/or framework modules at boot. See
535
+ * src/extensions.ts for the loader and the `register(api, config)` contract.
536
+ */
537
+ export interface RecipeExtension {
538
+ /** Primary purpose. Gates validation (a non-built-in `agent.strategy.type`
539
+ * requires at least one `kind: "strategy"` extension) and informs build
540
+ * tooling; does not restrict what `register` may call. */
541
+ kind: 'strategy' | 'module';
542
+ /** Path to the extension entry module. Relative paths resolve against the
543
+ * recipe file's directory at load time; URL-loaded recipes must use
544
+ * absolute paths (the host never imports code over the network). */
545
+ path: string;
546
+ /** Opaque blob passed verbatim to the extension's `register` function and
547
+ * to module factory contexts. */
548
+ config?: Record<string, unknown>;
549
+ /** Build-tooling acquisition metadata (git url/ref/install pattern) —
550
+ * consumed by connectome-cook at build time, ignored at runtime. */
551
+ source?: Record<string, unknown>;
552
+ /** Build-only provenance: `source` demoted by build tooling into the
553
+ * shipped recipe. Ignored at runtime. */
554
+ sourceMeta?: Record<string, unknown>;
555
+ }
556
+
506
557
  export interface Recipe {
507
558
  name: string;
508
559
  description?: string;
@@ -510,6 +561,8 @@ export interface Recipe {
510
561
  agent: RecipeAgent;
511
562
  mcpServers?: Record<string, RecipeMcpServer>;
512
563
  modules?: RecipeModules;
564
+ /** Deployment-specific code extensions, keyed by a human-readable name. */
565
+ extensions?: Record<string, RecipeExtension>;
513
566
  sessionNaming?: { examples?: string[] };
514
567
  }
515
568
 
@@ -639,6 +692,7 @@ export async function loadRecipe(source: string): Promise<Recipe> {
639
692
  raw = substituteEnvVars(raw, source);
640
693
  const recipe = validateRecipe(raw);
641
694
  resolveChildRecipePaths(recipe, sourceBase);
695
+ resolveExtensionPaths(recipe, sourceBase);
642
696
  return resolveSystemPrompt(recipe);
643
697
  }
644
698
 
@@ -663,6 +717,25 @@ function resolveChildRecipePaths(recipe: Recipe, base: RecipeSourceBase): void {
663
717
  }
664
718
  }
665
719
 
720
+ /**
721
+ * Resolve relative `extensions[].path` values against the recipe file's
722
+ * directory. URL-loaded recipes cannot carry relative extension paths — the
723
+ * host never fetches code over the network, so there is nothing local to
724
+ * resolve them against.
725
+ */
726
+ function resolveExtensionPaths(recipe: Recipe, base: RecipeSourceBase): void {
727
+ for (const [name, ext] of Object.entries(recipe.extensions ?? {})) {
728
+ if (isAbsolute(ext.path)) continue;
729
+ if (base.kind === 'url') {
730
+ throw new Error(
731
+ `extensions.${name}.path "${ext.path}" is relative, but the recipe was loaded from a URL. ` +
732
+ `Extension code is never fetched remotely — use an absolute path to a local install.`,
733
+ );
734
+ }
735
+ ext.path = resolve(base.dir, ext.path);
736
+ }
737
+ }
738
+
666
739
  /**
667
740
  * If systemPrompt is an HTTP(S) URL, fetch its contents as plain text.
668
741
  * Only treated as a URL if it looks like a single URL (no spaces/newlines).
@@ -701,8 +774,16 @@ export function validateRecipe(raw: unknown): Recipe {
701
774
  throw new Error('Recipe agent must have a "systemPrompt" string');
702
775
  }
703
776
 
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)}.`);
777
+ if (agent.provider !== undefined &&
778
+ agent.provider !== 'anthropic' &&
779
+ agent.provider !== 'openai-responses' &&
780
+ agent.provider !== 'openai-codex' &&
781
+ agent.provider !== 'openrouter' &&
782
+ agent.provider !== 'bedrock') {
783
+ throw new Error(
784
+ `Recipe agent.provider must be 'anthropic', 'openai-responses', 'openai-codex', 'openrouter', or 'bedrock', ` +
785
+ `got ${JSON.stringify(agent.provider)}.`,
786
+ );
706
787
  }
707
788
 
708
789
  if (agent.timezone !== undefined) {
@@ -735,6 +816,20 @@ export function validateRecipe(raw: unknown): Recipe {
735
816
  (typeof responses.compactThreshold !== 'number' || responses.compactThreshold <= 0)) {
736
817
  throw new Error('Recipe agent.responses.compactThreshold must be a positive number.');
737
818
  }
819
+ const serviceTiers = ['auto', 'default', 'flex', 'priority'];
820
+ if (responses.serviceTier !== undefined && !serviceTiers.includes(String(responses.serviceTier))) {
821
+ throw new Error(`Invalid agent.responses.serviceTier ${JSON.stringify(responses.serviceTier)}.`);
822
+ }
823
+ }
824
+
825
+ if (agent.codex !== undefined) {
826
+ if (!agent.codex || typeof agent.codex !== 'object' || Array.isArray(agent.codex)) {
827
+ throw new Error('Recipe agent.codex must be an object.');
828
+ }
829
+ const codex = agent.codex as Record<string, unknown>;
830
+ if (codex.fastMode !== undefined && typeof codex.fastMode !== 'boolean') {
831
+ throw new Error('Recipe agent.codex.fastMode must be a boolean.');
832
+ }
738
833
  }
739
834
 
740
835
  if (agent.maxStreamTokens !== undefined && (typeof agent.maxStreamTokens !== 'number' || agent.maxStreamTokens <= 0)) {
@@ -782,6 +877,37 @@ export function validateRecipe(raw: unknown): Recipe {
782
877
  }
783
878
  }
784
879
 
880
+ // Validate the extensions block if present (before the strategy check,
881
+ // which consults it to admit custom strategy types).
882
+ let hasStrategyExtension = false;
883
+ if (obj.extensions !== undefined) {
884
+ if (!obj.extensions || typeof obj.extensions !== 'object' || Array.isArray(obj.extensions)) {
885
+ throw new Error('Recipe "extensions" must be an object mapping names to extension entries.');
886
+ }
887
+ for (const [name, entry] of Object.entries(obj.extensions as Record<string, unknown>)) {
888
+ if (!name) throw new Error('Recipe extensions keys must be non-empty names.');
889
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
890
+ throw new Error(`extensions.${name} must be an object.`);
891
+ }
892
+ const ext = entry as Record<string, unknown>;
893
+ if (ext.kind !== 'strategy' && ext.kind !== 'module') {
894
+ throw new Error(`extensions.${name}.kind must be "strategy" or "module", got ${JSON.stringify(ext.kind)}.`);
895
+ }
896
+ if (typeof ext.path !== 'string' || !ext.path) {
897
+ throw new Error(`extensions.${name}.path must be a non-empty string.`);
898
+ }
899
+ if (ext.config !== undefined && (!ext.config || typeof ext.config !== 'object' || Array.isArray(ext.config))) {
900
+ throw new Error(`extensions.${name}.config must be an object.`);
901
+ }
902
+ for (const metaKey of ['source', 'sourceMeta'] as const) {
903
+ if (ext[metaKey] !== undefined && (!ext[metaKey] || typeof ext[metaKey] !== 'object' || Array.isArray(ext[metaKey]))) {
904
+ throw new Error(`extensions.${name}.${metaKey} must be an object.`);
905
+ }
906
+ }
907
+ if (ext.kind === 'strategy') hasStrategyExtension = true;
908
+ }
909
+ }
910
+
785
911
  // Validate strategy type if present
786
912
  if (agent.strategy) {
787
913
  const strategy = agent.strategy as Record<string, unknown>;
@@ -789,10 +915,13 @@ export function validateRecipe(raw: unknown): Recipe {
789
915
  strategy.type &&
790
916
  strategy.type !== 'autobiographical' &&
791
917
  strategy.type !== 'passthrough' &&
792
- strategy.type !== 'frontdesk'
918
+ strategy.type !== 'frontdesk' &&
919
+ !hasStrategyExtension
793
920
  ) {
794
921
  throw new Error(
795
- `Invalid strategy type "${strategy.type}". Must be "autobiographical", "passthrough", or "frontdesk".`,
922
+ `Invalid strategy type "${strategy.type}". Must be "autobiographical", "passthrough", ` +
923
+ `or "frontdesk" — or a custom type registered by a "kind": "strategy" entry in the ` +
924
+ `recipe's "extensions" block (none is declared).`,
796
925
  );
797
926
  }
798
927
  if (