@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/commands.ts CHANGED
@@ -14,6 +14,7 @@
14
14
  * /clear — Clear conversation display
15
15
  * /mcp list|add|remove|env — Manage MCPL server config
16
16
  * /budget [N] — Show/set stream token budget (e.g. /budget 1m)
17
+ * /fast [on|off|status] — Toggle Codex subscription Fast mode
17
18
  * /session — Session management (list, new, switch, rename, delete)
18
19
  * /recipe — Show current recipe info
19
20
  * /newtopic — Reset head window (auto-summarize or with user context)
@@ -37,6 +38,10 @@ interface AppContext {
37
38
  sessionManager: import('./session-manager.js').SessionManager;
38
39
  recipe: Recipe;
39
40
  branchState: BranchState;
41
+ codexAdapter?: {
42
+ isFastMode(): boolean;
43
+ setFastMode(enabled: boolean): void;
44
+ };
40
45
  switchSession(id: string): Promise<void>;
41
46
  }
42
47
 
@@ -146,12 +151,15 @@ export function handleCommand(command: string, app: AppContext): CommandResult {
146
151
  { text: ' /restore <name> Restore to checkpoint', style: 'system' },
147
152
  { text: ' /branches List Chronicle branches', style: 'system' },
148
153
  { text: ' /checkout <name> Switch to branch', style: 'system' },
149
- { text: ' /history Show state transitions', style: 'system' },
154
+ { text: ' /history [n] Show state transitions (last n)', style: 'system' },
155
+ { text: ' /find <text> Search messages for text', style: 'system' },
156
+ { text: ' /branchto <msgId> Branch from a specific message', style: 'system' },
150
157
  { text: ' /mcp list List MCPL servers', style: 'system' },
151
158
  { text: ' /mcp add <id> <cmd> Add/overwrite server', style: 'system' },
152
159
  { text: ' /mcp remove <id> Remove a server', style: 'system' },
153
160
  { text: ' /mcp env <id> K=V ... Set env vars on server', style: 'system' },
154
161
  { text: ' /budget [tokens] Show/set stream token budget', style: 'system' },
162
+ { text: ' /fast [on|off|status] Toggle Codex subscription Fast mode', style: 'system' },
155
163
  { text: ' /session Show current session', style: 'system' },
156
164
  { text: ' /session list List all sessions', style: 'system' },
157
165
  { text: ' /session new [name] Create new session', style: 'system' },
@@ -211,6 +219,9 @@ export function handleCommand(command: string, app: AppContext): CommandResult {
211
219
  case 'budget':
212
220
  return handleBudget(framework, args[0]);
213
221
 
222
+ case 'fast':
223
+ return handleFast(app, args[0]);
224
+
214
225
  case 'session':
215
226
  return handleSession(app, args);
216
227
 
@@ -233,6 +244,42 @@ export function handleCommand(command: string, app: AppContext): CommandResult {
233
244
  }
234
245
  }
235
246
 
247
+ function handleFast(app: AppContext, mode?: string): CommandResult {
248
+ const adapter = app.codexAdapter;
249
+ if (!adapter) {
250
+ return {
251
+ lines: [{
252
+ text: '/fast is available only when agent.provider is "openai-codex".',
253
+ style: 'system',
254
+ }],
255
+ };
256
+ }
257
+
258
+ const normalized = mode?.toLowerCase();
259
+ if (normalized === undefined || normalized === 'status') {
260
+ return {
261
+ lines: [{
262
+ text: `Codex Fast mode request is ${adapter.isFastMode() ? 'ON' : 'OFF'}.`,
263
+ style: 'system',
264
+ }],
265
+ };
266
+ }
267
+ if (normalized !== 'on' && normalized !== 'off') {
268
+ return { lines: [{ text: 'Usage: /fast [on|off|status]', style: 'system' }] };
269
+ }
270
+
271
+ const enabled = normalized === 'on';
272
+ adapter.setFastMode(enabled);
273
+ return {
274
+ lines: [{
275
+ text: enabled
276
+ ? 'Codex Fast mode requested (higher subscription credit consumption when applied).'
277
+ : 'Codex Fast mode request disabled.',
278
+ style: 'system',
279
+ }],
280
+ };
281
+ }
282
+
236
283
  // ---------------------------------------------------------------------------
237
284
  // /session subcommands
238
285
  // ---------------------------------------------------------------------------
@@ -702,7 +749,13 @@ function handleCheckpoint(app: AppContext, name?: string): CommandResult {
702
749
  if (!cm) return { lines: [{ text: 'No agent context manager.', style: 'system' }] };
703
750
 
704
751
  const branch = cm.currentBranch();
705
- app.branchState.checkpoints.set(name, { branchName: branch.name });
752
+ // A checkpoint is a *position*, not just a branch: record the id of the
753
+ // last message so /restore can branch back to this exact point even after
754
+ // the branch head has moved on. branchName alone would restore to the
755
+ // branch HEAD — i.e. not roll anything back.
756
+ const { messages } = cm.queryMessages({});
757
+ const messageId = messages.length > 0 ? messages[messages.length - 1]!.id : undefined;
758
+ app.branchState.checkpoints.set(name, { branchName: branch.name, messageId });
706
759
 
707
760
  return { lines: [{ text: `Checkpoint "${name}" saved at branch ${branch.name} (head: ${branch.head}).`, style: 'system' }] };
708
761
  }
@@ -729,12 +782,50 @@ function handleRestore(app: AppContext, name?: string): CommandResult {
729
782
  const cm = getAgentCM(app.framework);
730
783
  if (!cm) return { lines: [{ text: 'No agent context manager.', style: 'system' }] };
731
784
 
732
- const target = point.branchName;
785
+ // Restore to the recorded *position*. If the checkpoint captured a message
786
+ // id, branch at that message (same machinery as /undo) — switching to the
787
+ // stored branch name alone would land on its head, which by now may
788
+ // include everything the user wanted to roll back. Checkpoints from before
789
+ // the messageId field (or taken on an empty branch) fall back to the head.
790
+ let target = point.branchName;
791
+ let exact = false;
792
+ if (point.messageId) {
793
+ // A checkpoint is a POSITION, so position equality is the whole
794
+ // "already there" test. Comparing branch names too would kill the guard
795
+ // after the first restore (you'd be on restore-{name}-{ts}, never the
796
+ // original branch again) and every repeat /restore would mint another
797
+ // branch pointing at the same message.
798
+ const { messages } = cm.queryMessages({});
799
+ const atCheckpoint = messages.length > 0
800
+ && messages[messages.length - 1]!.id === point.messageId;
801
+ if (atCheckpoint) {
802
+ return { lines: [{ text: `Already at checkpoint "${name}".`, style: 'system' }] };
803
+ }
804
+ try {
805
+ // branchAt returns the new branch's NAME (the only thing switchBranch accepts).
806
+ target = cm.branchAt(point.messageId, `restore-${name}-${Date.now()}`);
807
+ exact = true;
808
+ // The record follows the position: keep the newest branch that holds
809
+ // the checkpoint message as the fallback for future restores.
810
+ app.branchState.checkpoints.set(name, { branchName: target, messageId: point.messageId });
811
+ } catch {
812
+ // branchAt resolves ids against the CURRENT branch's view of the log;
813
+ // after e.g. /undo the checkpoint message may not be reachable from
814
+ // here. Degrade to the checkpoint's branch head (the pre-messageId
815
+ // behavior) rather than failing the restore outright.
816
+ target = point.branchName;
817
+ }
818
+ }
733
819
 
734
820
  // switchBranch is async — strategy reinit needs to complete before next op.
735
821
  const asyncWork = Promise.resolve(cm.switchBranch(target))
736
822
  .then(() => ({
737
- lines: [{ text: `Restored to checkpoint "${name}" (branch: ${target}).`, style: 'system' as const }],
823
+ lines: [
824
+ { text: `Restored to checkpoint "${name}" (branch: ${target}).`, style: 'system' as const },
825
+ ...(point.messageId && !exact
826
+ ? [{ text: ' (note: exact checkpoint position unreachable from the current branch — restored to the branch head instead)', style: 'system' as const }]
827
+ : []),
828
+ ],
738
829
  branchChanged: true,
739
830
  }))
740
831
  .catch(err => ({
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Recipe extensions — the loading seam for deployment-specific code.
3
+ *
4
+ * A recipe may declare an `extensions` block mapping a name to a local
5
+ * TypeScript/JavaScript module that registers custom context strategies
6
+ * and/or framework modules:
7
+ *
8
+ * "extensions": {
9
+ * "zk-strategy": {
10
+ * "kind": "strategy",
11
+ * "path": "./extensions/zk-strategy/index.ts",
12
+ * "config": { "floodWindowMs": 250 }
13
+ * }
14
+ * }
15
+ *
16
+ * The entry module must export a `register` function (named or default):
17
+ *
18
+ * export function register(api: ExtensionApi, config: Record<string, unknown>) {
19
+ * api.registerStrategy('zk', (ctx) => new ZkStrategy({ ...ctx.config }));
20
+ * api.registerModule((ctx) => new ZkFeederModule(config));
21
+ * }
22
+ *
23
+ * Design constraint: this loader is deliberately dumb. It resolves nothing,
24
+ * fetches nothing, and negotiates no versions — it imports a local path and
25
+ * calls `register`. The invariant "this path exists and is compatible with
26
+ * the host's dependency tree" is established at install time by build
27
+ * tooling (connectome-cook), not here. Extensions run in-process and share
28
+ * the host's node_modules, so `extends AutobiographicalStrategy` resolves
29
+ * against the exact @animalabs/* versions the host ships.
30
+ *
31
+ * `kind` declares the extension's primary purpose. It gates recipe
32
+ * validation (a non-built-in `agent.strategy.type` is only accepted when at
33
+ * least one `kind: "strategy"` extension is declared) and informs build
34
+ * tooling; it does NOT restrict what `register` may call — a strategy
35
+ * extension may also register a companion module.
36
+ */
37
+
38
+ import { isAbsolute } from 'node:path';
39
+ import { pathToFileURL } from 'node:url';
40
+ import type { ContextStrategy, Module } from '@animalabs/agent-framework';
41
+ import type { Recipe, RecipeExtension, RecipeStrategy } from './recipe.js';
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Factory contexts
45
+ // ---------------------------------------------------------------------------
46
+
47
+ export interface StrategyFactoryContext {
48
+ /** The recipe's `agent.strategy` block verbatim — including keys the
49
+ * built-in schema doesn't know about, so custom strategies can define
50
+ * their own knobs without upstream schema changes. */
51
+ config: RecipeStrategy & Record<string, unknown>;
52
+ /** Resolved agent model id (recipe/env fallback chain already applied). */
53
+ model: string;
54
+ /** Resolved IANA time zone for the session. */
55
+ timeZone: string;
56
+ }
57
+
58
+ export type StrategyFactory = (ctx: StrategyFactoryContext) => ContextStrategy;
59
+
60
+ export interface ModuleFactoryContext {
61
+ timeZone: string;
62
+ /** Session store path (Chronicle data dir) — same value createFramework uses. */
63
+ storePath: string;
64
+ /** Resolved agent model id. */
65
+ model: string;
66
+ /** The declaring extension entry's `config` blob, verbatim. */
67
+ config: Record<string, unknown>;
68
+ }
69
+
70
+ export type ModuleFactory = (ctx: ModuleFactoryContext) => Module;
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Registry
74
+ // ---------------------------------------------------------------------------
75
+
76
+ export interface ExtensionApi {
77
+ /** Register a context-strategy factory under a `agent.strategy.type` name.
78
+ * Built-in names and duplicate registrations are rejected. */
79
+ registerStrategy(type: string, factory: StrategyFactory): void;
80
+ /** Register a framework-module factory. Instantiated after the built-in
81
+ * module list is assembled; if the instance has a `setFramework` method
82
+ * it is called after framework creation (same duck-typed contract the
83
+ * built-in modules use). */
84
+ registerModule(factory: ModuleFactory): void;
85
+ }
86
+
87
+ export interface RegisteredModule {
88
+ /** Name of the extension entry that registered this factory. */
89
+ extensionName: string;
90
+ factory: ModuleFactory;
91
+ /** The extension entry's `config`, passed to the factory context. */
92
+ config: Record<string, unknown>;
93
+ }
94
+
95
+ export interface ExtensionRegistry {
96
+ strategies: Map<string, StrategyFactory>;
97
+ modules: RegisteredModule[];
98
+ }
99
+
100
+ export const BUILTIN_STRATEGY_TYPES = ['autobiographical', 'passthrough', 'frontdesk'] as const;
101
+
102
+ export function isBuiltinStrategyType(type: string): boolean {
103
+ return (BUILTIN_STRATEGY_TYPES as readonly string[]).includes(type);
104
+ }
105
+
106
+ /** An empty registry — used when a recipe declares no extensions. */
107
+ export function emptyExtensionRegistry(): ExtensionRegistry {
108
+ return { strategies: new Map(), modules: [] };
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // Loader
113
+ // ---------------------------------------------------------------------------
114
+
115
+ /**
116
+ * Import every extension declared by the recipe and collect registrations.
117
+ *
118
+ * Paths must be absolute by the time this runs — `loadRecipe` resolves
119
+ * relative extension paths against the recipe file's directory. Errors are
120
+ * wrapped with the extension name + path so a broken deployment names the
121
+ * component instead of surfacing a bare import stack.
122
+ */
123
+ export async function loadExtensions(recipe: Recipe): Promise<ExtensionRegistry> {
124
+ const registry = emptyExtensionRegistry();
125
+ for (const [name, ext] of Object.entries(recipe.extensions ?? {})) {
126
+ await loadOneExtension(name, ext, registry);
127
+ }
128
+ return registry;
129
+ }
130
+
131
+ async function loadOneExtension(
132
+ name: string,
133
+ ext: RecipeExtension,
134
+ registry: ExtensionRegistry,
135
+ ): Promise<void> {
136
+ if (!isAbsolute(ext.path)) {
137
+ throw new Error(
138
+ `extension "${name}": path "${ext.path}" is not absolute. ` +
139
+ `Relative paths are resolved by loadRecipe against the recipe file's directory; ` +
140
+ `URL-loaded recipes must use absolute extension paths.`,
141
+ );
142
+ }
143
+
144
+ let mod: Record<string, unknown>;
145
+ try {
146
+ mod = await import(pathToFileURL(ext.path).href) as Record<string, unknown>;
147
+ } catch (err) {
148
+ throw new Error(
149
+ `extension "${name}": failed to import ${ext.path}: ` +
150
+ `${err instanceof Error ? err.message : String(err)}`,
151
+ { cause: err },
152
+ );
153
+ }
154
+
155
+ const register = (mod.register ?? mod.default) as unknown;
156
+ if (typeof register !== 'function') {
157
+ throw new Error(
158
+ `extension "${name}" (${ext.path}) must export a "register" function ` +
159
+ `(named export or default export); got ${typeof register}.`,
160
+ );
161
+ }
162
+
163
+ const config = ext.config ?? {};
164
+ const api: ExtensionApi = {
165
+ registerStrategy(type, factory) {
166
+ if (typeof type !== 'string' || !type) {
167
+ throw new Error(`extension "${name}": registerStrategy requires a non-empty type name`);
168
+ }
169
+ if (isBuiltinStrategyType(type)) {
170
+ throw new Error(
171
+ `extension "${name}": strategy type "${type}" collides with a built-in strategy`,
172
+ );
173
+ }
174
+ if (registry.strategies.has(type)) {
175
+ throw new Error(
176
+ `extension "${name}": strategy type "${type}" is already registered by another extension`,
177
+ );
178
+ }
179
+ if (typeof factory !== 'function') {
180
+ throw new Error(`extension "${name}": registerStrategy("${type}") requires a factory function`);
181
+ }
182
+ registry.strategies.set(type, factory);
183
+ },
184
+ registerModule(factory) {
185
+ if (typeof factory !== 'function') {
186
+ throw new Error(`extension "${name}": registerModule requires a factory function`);
187
+ }
188
+ registry.modules.push({ extensionName: name, factory, config });
189
+ },
190
+ };
191
+
192
+ try {
193
+ await Promise.resolve((register as (api: ExtensionApi, config: Record<string, unknown>) => unknown)(api, config));
194
+ } catch (err) {
195
+ throw new Error(
196
+ `extension "${name}" (${ext.path}): register() threw: ` +
197
+ `${err instanceof Error ? err.message : String(err)}`,
198
+ { cause: err },
199
+ );
200
+ }
201
+ }
@@ -24,20 +24,28 @@ export function buildFrameworkAgentConfig(
24
24
  maxStreamTokens: recipe.agent.maxStreamTokens ?? 150000,
25
25
  contextBudgetTokens: recipe.agent.contextBudgetTokens,
26
26
  ...(recipe.agent.cacheTtl && { cacheTtl: recipe.agent.cacheTtl }),
27
- ...(recipe.agent.provider === 'openai-responses' && {
27
+ // Bedrock legacy Claude models reject cache_control outright
28
+ // ("your request did not allow prompt caching") — suppress markers.
29
+ ...(recipe.agent.provider === 'bedrock' && { promptCaching: false }),
30
+ // Prefill scaffold (anthropic-xml formatter), e.g. chapterx CLI-sim's
31
+ // '<cmd>cat untitled.txt</cmd>' — part of migrating prefill-era bots.
32
+ ...(recipe.agent.prefillUserMessage && { prefillUserMessage: recipe.agent.prefillUserMessage }),
33
+ ...((recipe.agent.provider === 'openai-responses' || recipe.agent.provider === 'openai-codex') && {
28
34
  providerParams: {
29
35
  reasoning: {
30
36
  effort: recipe.agent.responses?.reasoningEffort ?? 'high',
31
37
  context: recipe.agent.responses?.reasoningContext ?? 'all_turns',
32
38
  },
33
- ...(recipe.agent.responses?.serviceTier ? {
34
- service_tier: recipe.agent.responses.serviceTier,
35
- } : {}),
36
- ...(recipe.agent.responses?.compactThreshold ? {
37
- context_management: [{
38
- type: 'compaction',
39
- compact_threshold: recipe.agent.responses.compactThreshold,
40
- }],
39
+ ...(recipe.agent.provider === 'openai-responses' ? {
40
+ ...(recipe.agent.responses?.serviceTier ? {
41
+ service_tier: recipe.agent.responses.serviceTier,
42
+ } : {}),
43
+ ...(recipe.agent.responses?.compactThreshold ? {
44
+ context_management: [{
45
+ type: 'compaction',
46
+ compact_threshold: recipe.agent.responses.compactThreshold,
47
+ }],
48
+ } : {}),
41
49
  } : {}),
42
50
  },
43
51
  }),
@@ -47,5 +55,8 @@ export function buildFrameworkAgentConfig(
47
55
  ...(recipe.agent.sameRoundThinkTextPolicy !== undefined
48
56
  ? { sameRoundThinkTextPolicy: recipe.agent.sameRoundThinkTextPolicy }
49
57
  : {}),
58
+ ...(recipe.agent.proseRouting !== undefined
59
+ ? { proseRouting: recipe.agent.proseRouting }
60
+ : {}),
50
61
  };
51
62
  }
@@ -5,6 +5,7 @@ import {
5
5
  } from '@animalabs/agent-framework';
6
6
  import type { Recipe, RecipeStrategy } from './recipe.js';
7
7
  import { FrontdeskStrategy } from './strategies/frontdesk-strategy.js';
8
+ import { isBuiltinStrategyType, type ExtensionRegistry } from './extensions.js';
8
9
 
9
10
  const PASSTHROUGH_KEYS: ReadonlyArray<keyof RecipeStrategy> = [
10
11
  'enforceBudget',
@@ -39,13 +40,36 @@ export function buildFrameworkStrategy(
39
40
  recipe: Recipe,
40
41
  model: string,
41
42
  timeZone: string,
43
+ extensions?: ExtensionRegistry,
42
44
  ): ContextStrategy {
43
45
  const strategyConfig = recipe.agent.strategy;
44
46
  const strategyType = strategyConfig?.type ?? 'autobiographical';
47
+
48
+ // Non-built-in types resolve through the extension registry. Validation
49
+ // already required a strategy-kind extension to be declared; this catches
50
+ // the declared-but-didn't-register case with a precise error.
51
+ if (!isBuiltinStrategyType(strategyType)) {
52
+ const factory = extensions?.strategies.get(strategyType);
53
+ if (!factory) {
54
+ const known = extensions ? Array.from(extensions.strategies.keys()) : [];
55
+ throw new Error(
56
+ `strategy type "${strategyType}" is not built-in and no loaded extension registered it. ` +
57
+ `Registered custom types: ${known.length ? known.join(', ') : '(none)'}.`,
58
+ );
59
+ }
60
+ return factory({
61
+ config: (strategyConfig ?? {}) as RecipeStrategy & Record<string, unknown>,
62
+ model,
63
+ timeZone,
64
+ });
65
+ }
45
66
  const autobiographicalOpts: Record<string, unknown> = {
46
67
  headWindowTokens: strategyConfig?.headWindowTokens ?? 4000,
47
68
  recentWindowTokens: strategyConfig?.recentWindowTokens ?? 30000,
48
69
  compressionModel: strategyConfig?.compressionModel ?? model,
70
+ ...(strategyConfig?.compressionMaxTokens !== undefined
71
+ ? { compressionMaxTokens: strategyConfig.compressionMaxTokens }
72
+ : {}),
49
73
  autoTickOnNewMessage: true,
50
74
  maxMessageTokens: strategyConfig?.maxMessageTokens ?? 10000,
51
75
  ...(strategyType === 'frontdesk' ? { timeZone } : {}),