@animalabs/connectome-host 0.3.1

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 (123) hide show
  1. package/.env.example +20 -0
  2. package/.github/workflows/publish.yml +65 -0
  3. package/ARCHITECTURE.md +355 -0
  4. package/CHANGELOG.md +30 -0
  5. package/HEADLESS-FLEET-PLAN.md +330 -0
  6. package/README.md +189 -0
  7. package/UNIFIED-TREE-PLAN.md +242 -0
  8. package/bun.lock +288 -0
  9. package/docs/AGENT-MEMORY-GUIDE.md +214 -0
  10. package/docs/AGENT-ONBOARDING.md +541 -0
  11. package/docs/ATTENTION-AND-GATING.md +120 -0
  12. package/docs/DEPLOYMENTS.md +130 -0
  13. package/docs/DEV-ENVIRONMENT.md +215 -0
  14. package/docs/LIBRARY-PIPELINE.md +286 -0
  15. package/docs/LOCUS-ROUTING-DESIGN.md +115 -0
  16. package/docs/claudeai-evacuation.md +228 -0
  17. package/docs/debug-context-api.md +208 -0
  18. package/docs/webui-deployment.md +219 -0
  19. package/package.json +33 -0
  20. package/recipes/SETUP.md +308 -0
  21. package/recipes/TRIUMVIRATE-SETUP.md +467 -0
  22. package/recipes/claude-export-revive.json +19 -0
  23. package/recipes/clerk.json +157 -0
  24. package/recipes/knowledge-miner.json +174 -0
  25. package/recipes/knowledge-reviewer.json +76 -0
  26. package/recipes/mcpl-editor-test.json +38 -0
  27. package/recipes/prompts/transplant-addendum.md +17 -0
  28. package/recipes/triumvirate.json +63 -0
  29. package/recipes/webui-fleet-test.json +27 -0
  30. package/recipes/webui-test.json +16 -0
  31. package/recipes/zulip-miner.json +87 -0
  32. package/scripts/connectome-doctor +373 -0
  33. package/scripts/evacuator.ts +956 -0
  34. package/scripts/import-claudeai-export.ts +747 -0
  35. package/scripts/lib/line-reader.ts +53 -0
  36. package/scripts/test-historical-thinking.ts +148 -0
  37. package/scripts/warmup-session.ts +336 -0
  38. package/src/agent-name.ts +58 -0
  39. package/src/commands.ts +1074 -0
  40. package/src/headless.ts +528 -0
  41. package/src/index.ts +867 -0
  42. package/src/logging-adapter.ts +208 -0
  43. package/src/mcpl-config.ts +199 -0
  44. package/src/modules/activity-module.ts +270 -0
  45. package/src/modules/channel-mode-module.ts +260 -0
  46. package/src/modules/fleet-module.ts +1705 -0
  47. package/src/modules/fleet-types.ts +225 -0
  48. package/src/modules/lessons-module.ts +465 -0
  49. package/src/modules/mcpl-admin-module.ts +345 -0
  50. package/src/modules/retrieval-module.ts +327 -0
  51. package/src/modules/settings-module.ts +143 -0
  52. package/src/modules/subagent-module.ts +2074 -0
  53. package/src/modules/subscription-gc-module.ts +322 -0
  54. package/src/modules/time-module.ts +85 -0
  55. package/src/modules/tui-module.ts +76 -0
  56. package/src/modules/web-ui-curve-page.ts +249 -0
  57. package/src/modules/web-ui-module.ts +2595 -0
  58. package/src/recipe.ts +1003 -0
  59. package/src/session-manager.ts +278 -0
  60. package/src/state/agent-tree-reducer.ts +431 -0
  61. package/src/state/fleet-tree-aggregator.ts +195 -0
  62. package/src/strategies/frontdesk-strategy.ts +364 -0
  63. package/src/synesthete.ts +68 -0
  64. package/src/tui.ts +2200 -0
  65. package/src/types/bun-ffi.d.ts +6 -0
  66. package/src/web/protocol.ts +648 -0
  67. package/test/agent-name.test.ts +62 -0
  68. package/test/agent-tree-fleet-launch.test.ts +64 -0
  69. package/test/agent-tree-reducer-parity.test.ts +194 -0
  70. package/test/agent-tree-reducer.test.ts +443 -0
  71. package/test/agent-tree-rollup.test.ts +109 -0
  72. package/test/autobio-progress-snapshot.test.ts +40 -0
  73. package/test/claudeai-export-importer.test.ts +386 -0
  74. package/test/evacuator.test.ts +332 -0
  75. package/test/fleet-commands.test.ts +244 -0
  76. package/test/fleet-no-subfleets.test.ts +147 -0
  77. package/test/fleet-orchestration.test.ts +353 -0
  78. package/test/fleet-recipe.test.ts +124 -0
  79. package/test/fleet-route.test.ts +44 -0
  80. package/test/fleet-smoke.test.ts +479 -0
  81. package/test/fleet-subscribe-union-e2e.test.ts +123 -0
  82. package/test/fleet-subscribe-union.test.ts +85 -0
  83. package/test/fleet-tree-aggregator-e2e.test.ts +110 -0
  84. package/test/fleet-tree-aggregator.test.ts +215 -0
  85. package/test/frontdesk-strategy.test.ts +326 -0
  86. package/test/headless-describe.test.ts +182 -0
  87. package/test/headless-smoke.test.ts +190 -0
  88. package/test/logging-adapter.test.ts +87 -0
  89. package/test/mcpl-admin-module.test.ts +213 -0
  90. package/test/mcpl-agent-overlay.test.ts +126 -0
  91. package/test/mock-headless-child.ts +133 -0
  92. package/test/recipe-cache-ttl.test.ts +37 -0
  93. package/test/recipe-env.test.ts +157 -0
  94. package/test/recipe-path-resolution.test.ts +170 -0
  95. package/test/subagent-async-timeout.test.ts +381 -0
  96. package/test/subagent-fork-materialise.test.ts +241 -0
  97. package/test/subagent-peek-scoping.test.ts +180 -0
  98. package/test/subagent-peek-zombie.test.ts +148 -0
  99. package/test/subagent-reaper-in-flight.test.ts +229 -0
  100. package/test/subscription-gc-module.test.ts +136 -0
  101. package/test/warmup-handoff.test.ts +150 -0
  102. package/test/web-ui-bind.test.ts +51 -0
  103. package/test/web-ui-module.test.ts +246 -0
  104. package/test/web-ui-protocol.test.ts +0 -0
  105. package/tsconfig.json +14 -0
  106. package/web/bun.lock +357 -0
  107. package/web/index.html +12 -0
  108. package/web/package.json +24 -0
  109. package/web/src/App.tsx +1931 -0
  110. package/web/src/Context.tsx +158 -0
  111. package/web/src/ContextDocument.tsx +150 -0
  112. package/web/src/Files.tsx +271 -0
  113. package/web/src/Lessons.tsx +164 -0
  114. package/web/src/Mcpl.tsx +310 -0
  115. package/web/src/Stream.tsx +119 -0
  116. package/web/src/TreeSidebar.tsx +283 -0
  117. package/web/src/Usage.tsx +182 -0
  118. package/web/src/main.tsx +7 -0
  119. package/web/src/styles.css +26 -0
  120. package/web/src/tree.ts +268 -0
  121. package/web/src/wire.ts +120 -0
  122. package/web/tsconfig.json +21 -0
  123. package/web/vite.config.ts +32 -0
package/src/index.ts ADDED
@@ -0,0 +1,867 @@
1
+ /**
2
+ * connectome-host — General-purpose agent TUI host with recipe-based configuration.
3
+ *
4
+ * Usage:
5
+ * bun src/index.ts # Start with saved/default recipe
6
+ * bun src/index.ts <recipe-url-or-path> # Load recipe from URL or file
7
+ * bun src/index.ts --no-recipe # Start fresh with default recipe
8
+ * bun src/index.ts --no-tui # Readline mode (works in pipes/CI)
9
+ * bun src/index.ts --headless # Daemon mode: JSONL over Unix socket at $DATA_DIR/ipc.sock
10
+ * bun src/index.ts --headless --exit-when-idle # One-shot: exit when agents go idle after first inference
11
+ *
12
+ * Environment variables:
13
+ * ANTHROPIC_API_KEY - Required
14
+ * MODEL - Override model (default: from recipe or claude-opus-4-6)
15
+ * DATA_DIR - Data directory for sessions (default: ./data)
16
+ */
17
+
18
+ import { Membrane, NativeFormatter } from '@animalabs/membrane';
19
+ import { LoggingAnthropicAdapter } from './logging-adapter.js';
20
+ import { SettingsModule } from './modules/settings-module.js';
21
+ import { AgentFramework, AutobiographicalStrategy, PassthroughStrategy, WorkspaceModule, type Module, type MountConfig } from '@animalabs/agent-framework';
22
+ import { resolve, join, basename } from 'node:path';
23
+ import { appendFile, mkdir, stat, rename } from 'node:fs/promises';
24
+ import { readFileSync, existsSync } from 'node:fs';
25
+ import { FrontdeskStrategy } from './strategies/frontdesk-strategy.js';
26
+ import { SubagentModule } from './modules/subagent-module.js';
27
+ import { LessonsModule } from './modules/lessons-module.js';
28
+ import { RetrievalModule } from './modules/retrieval-module.js';
29
+ import type { RecipeWorkspaceMount, RecipeStrategy } from './recipe.js';
30
+ import { TuiModule } from './modules/tui-module.js';
31
+ import { TimeModule } from './modules/time-module.js';
32
+ import { FleetModule, type FleetModuleConfig } from './modules/fleet-module.js';
33
+ import { ActivityModule } from './modules/activity-module.js';
34
+ import { SubscriptionGcModule } from './modules/subscription-gc-module.js';
35
+ import { ChannelModeModule } from './modules/channel-mode-module.js';
36
+ import { WebUiModule } from './modules/web-ui-module.js';
37
+ import { McplAdminModule } from './modules/mcpl-admin-module.js';
38
+ import { loadMcplServers, applyAgentOverlay, DEFAULT_CONFIG_PATH, DEFAULT_AGENT_OVERLAY_PATH } from './mcpl-config.js';
39
+ import { SessionManager } from './session-manager.js';
40
+ import { resolveAgentName } from './agent-name.js';
41
+ import { generateSessionName } from './synesthete.js';
42
+ import {
43
+ type Recipe,
44
+ DEFAULT_RECIPE,
45
+ loadRecipe,
46
+ saveRecipe,
47
+ loadSavedRecipe,
48
+ clearSavedRecipe,
49
+ parseRecipeArg,
50
+ } from './recipe.js';
51
+ import { createBranchState, resetBranchState, handleExport, type BranchState } from './commands.js';
52
+
53
+ export type { AppContext };
54
+
55
+ const headless = process.argv.includes('--headless');
56
+ const noTui = !headless && (process.argv.includes('--no-tui') || !process.stdin.isTTY);
57
+
58
+ const config = {
59
+ apiKey: process.env.ANTHROPIC_API_KEY,
60
+ // OAuth/Bearer token (e.g. a Claude subscription token). When set, it takes
61
+ // precedence over the API key so requests never carry both auth schemes.
62
+ authToken: process.env.ANTHROPIC_AUTH_TOKEN,
63
+ model: process.env.MODEL,
64
+ dataDir: process.env.DATA_DIR || './data',
65
+ };
66
+
67
+ if (!config.apiKey && !config.authToken) {
68
+ console.error('Missing ANTHROPIC_API_KEY (or ANTHROPIC_AUTH_TOKEN). Set one in .env or environment.');
69
+ process.exit(1);
70
+ }
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // AppContext — mutable container for session switching
74
+ // ---------------------------------------------------------------------------
75
+
76
+ interface AppContext {
77
+ framework: AgentFramework;
78
+ membrane: Membrane;
79
+ sessionManager: SessionManager;
80
+ recipe: Recipe;
81
+ /**
82
+ * Resolved at startup from recipe + active session's import sidecar +
83
+ * default. Used downstream by createFramework, setupSynesthete, etc. so
84
+ * the priority chain isn't recomputed (and potentially drifted) at each
85
+ * call site. Stays stable across `switchSession` — see note in main().
86
+ */
87
+ agentName: string;
88
+ branchState: BranchState;
89
+ userMessageCount: number;
90
+
91
+ /** Stop current framework, switch to a different session, start new framework. */
92
+ switchSession(id: string): Promise<void>;
93
+ }
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Recipe resolution
97
+ // ---------------------------------------------------------------------------
98
+
99
+ async function resolveRecipe(): Promise<Recipe> {
100
+ const { source, noRecipe } = parseRecipeArg(process.argv);
101
+
102
+ if (noRecipe) {
103
+ clearSavedRecipe(config.dataDir);
104
+ console.log('Starting with default recipe.');
105
+ return DEFAULT_RECIPE;
106
+ }
107
+
108
+ if (source) {
109
+ try {
110
+ const recipe = await loadRecipe(source);
111
+ saveRecipe(config.dataDir, recipe);
112
+ console.log(`Loaded recipe: ${recipe.name}${recipe.description ? ` — ${recipe.description}` : ''}`);
113
+ return recipe;
114
+ } catch (err) {
115
+ console.error(`Failed to load recipe from ${source}:`, err instanceof Error ? err.message : err);
116
+ process.exit(1);
117
+ }
118
+ }
119
+
120
+ // Try saved recipe
121
+ const saved = loadSavedRecipe(config.dataDir);
122
+ if (saved) {
123
+ console.log(`Resuming recipe: ${saved.name}`);
124
+ return saved;
125
+ }
126
+
127
+ return DEFAULT_RECIPE;
128
+ }
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // Framework factory
132
+ // ---------------------------------------------------------------------------
133
+
134
+ async function createFramework(
135
+ membrane: Membrane,
136
+ storePath: string,
137
+ recipe: Recipe,
138
+ agentName: string,
139
+ settingsModule: SettingsModule,
140
+ ): Promise<AgentFramework> {
141
+ const model = config.model || recipe.agent.model || 'claude-opus-4-6';
142
+ const modules = recipe.modules ?? {};
143
+
144
+ // -- Build module list --
145
+ // SettingsModule is constructed in main() (before the adapter, so the
146
+ // adapter can read its state for cross-cutting concerns like reasoning).
147
+ const moduleInstances: Module[] = [new TuiModule(), new TimeModule(), settingsModule];
148
+
149
+ // Subagents
150
+ let subagentModule: SubagentModule | null = null;
151
+ if (modules.subagents !== false) {
152
+ const subagentConfig = typeof modules.subagents === 'object' ? modules.subagents : {};
153
+ subagentModule = new SubagentModule({
154
+ parentAgentName: agentName,
155
+ defaultModel: subagentConfig.defaultModel || model,
156
+ defaultMaxTokens: subagentConfig.defaultMaxTokens,
157
+ });
158
+ moduleInstances.push(subagentModule);
159
+ }
160
+
161
+ // Lessons
162
+ let lessonsModule: LessonsModule | null = null;
163
+ if (modules.lessons !== false) {
164
+ const globalLessonsPath = resolve(join(storePath, '..', '..', 'lessons.json'));
165
+ lessonsModule = new LessonsModule({ globalPath: globalLessonsPath });
166
+ moduleInstances.push(lessonsModule);
167
+ }
168
+
169
+ // Fleet (cross-process child orchestration). Opt-in via recipe.
170
+ if (modules.fleet === true) {
171
+ moduleInstances.push(new FleetModule());
172
+ } else if (modules.fleet && typeof modules.fleet === 'object') {
173
+ const fleetCfg = modules.fleet;
174
+ const fleetModuleConfig: FleetModuleConfig = {};
175
+ if (fleetCfg.children) {
176
+ fleetModuleConfig.autoStart = fleetCfg.children.map((c) => {
177
+ const entry: NonNullable<FleetModuleConfig['autoStart']>[number] = {
178
+ name: c.name,
179
+ recipe: c.recipe,
180
+ autoStart: c.autoStart !== false, // default true
181
+ };
182
+ if (c.dataDir !== undefined) entry.dataDir = c.dataDir;
183
+ if (c.env !== undefined) entry.env = c.env;
184
+ if (c.subscription !== undefined) entry.subscription = c.subscription;
185
+ if (c.autoRestart !== undefined) entry.autoRestart = c.autoRestart;
186
+ return entry;
187
+ });
188
+ }
189
+ if (fleetCfg.allowedRecipes !== undefined) fleetModuleConfig.allowedRecipes = fleetCfg.allowedRecipes;
190
+ if (fleetCfg.defaultSubscription !== undefined) fleetModuleConfig.defaultSubscription = fleetCfg.defaultSubscription;
191
+ if (fleetCfg.socketWaitTimeoutMs !== undefined) fleetModuleConfig.socketWaitTimeoutMs = fleetCfg.socketWaitTimeoutMs;
192
+ if (fleetCfg.readyTimeoutMs !== undefined) fleetModuleConfig.readyTimeoutMs = fleetCfg.readyTimeoutMs;
193
+ if (fleetCfg.gracefulShutdownMs !== undefined) fleetModuleConfig.gracefulShutdownMs = fleetCfg.gracefulShutdownMs;
194
+ if (fleetCfg.sigtermEscalationMs !== undefined) fleetModuleConfig.sigtermEscalationMs = fleetCfg.sigtermEscalationMs;
195
+ moduleInstances.push(new FleetModule(fleetModuleConfig));
196
+ }
197
+
198
+ // Retrieval (requires lessons)
199
+ if (modules.retrieval !== false && lessonsModule) {
200
+ const retrievalConfig = typeof modules.retrieval === 'object' ? modules.retrieval : {};
201
+ moduleInstances.push(new RetrievalModule({
202
+ membrane,
203
+ retrievalModel: retrievalConfig.model,
204
+ maxInjectedLessons: retrievalConfig.maxInjected,
205
+ }));
206
+ }
207
+
208
+ // Gate config — core AF EventGate feature.
209
+ // Path is per-session: {storePath}/config/gate.json
210
+ let gateOptions: import('@animalabs/agent-framework').GateOptions | undefined;
211
+ if (modules.wake !== false) {
212
+ gateOptions = { configPath: join(storePath, 'config', 'gate.json') };
213
+ if (typeof modules.wake === 'object' && 'policies' in modules.wake) {
214
+ gateOptions.config = modules.wake as import('@animalabs/agent-framework').GateConfig;
215
+ }
216
+ // Privileged-users file for the `sleep` tool — users who may wake the agent
217
+ // through a sleep window. Stable across sessions (install-dir relative);
218
+ // override with SLEEP_PRIVILEGED_FILE. Edit the file to change the list.
219
+ gateOptions.privilegedUsersPath =
220
+ process.env.SLEEP_PRIVILEGED_FILE || resolve('./sleep-privileged.json');
221
+ }
222
+
223
+ // Workspace (replaces FilesModule + LocalFilesModule)
224
+ // Note: workspace: false disables ALL filesystem access (both read and write).
225
+ // Previously LocalFilesModule was always-on; this is an intentional change —
226
+ // recipes that need read-only access should keep workspace enabled (the default).
227
+ let workspaceModule: WorkspaceModule | null = null;
228
+ if (modules.workspace !== false) {
229
+ let mounts: MountConfig[];
230
+ if (typeof modules.workspace === 'object' && modules.workspace.mounts) {
231
+ // Only pass fields the recipe explicitly provides; let WorkspaceModule default the rest.
232
+ // We override watch to 'never' since FKM doesn't need chokidar filesystem watchers.
233
+ mounts = modules.workspace.mounts.map((m: RecipeWorkspaceMount) => {
234
+ const mount: MountConfig = {
235
+ name: m.name,
236
+ path: resolve(m.path),
237
+ mode: m.mode ?? 'read-write',
238
+ watch: m.watch ?? 'never', // FKM: no chokidar watchers by default
239
+ };
240
+ if (m.ignore) mount.ignore = m.ignore;
241
+ if (m.wakeOnChange !== undefined) mount.wakeOnChange = m.wakeOnChange;
242
+ if (m.autoMaterialize !== undefined) mount.autoMaterialize = m.autoMaterialize;
243
+ return mount;
244
+ });
245
+ } else {
246
+ // Default: read-only input mount + read-write products mount
247
+ mounts = [
248
+ { name: 'input', path: resolve('./input'), mode: 'read-only', watch: 'never' },
249
+ { name: 'products', path: resolve('./output'), mode: 'read-write', watch: 'never' },
250
+ ];
251
+ }
252
+
253
+ // Config mount: version-controls gate.json (and future config files) via Chronicle.
254
+ // Opt-in via recipe: workspace.configMount = true
255
+ const wantConfigMount = typeof modules.workspace === 'object' && modules.workspace.configMount;
256
+ if (wantConfigMount) {
257
+ mounts.push({
258
+ name: '_config',
259
+ path: resolve(join(storePath, 'config')),
260
+ mode: 'read-write',
261
+ watch: 'always',
262
+ });
263
+ }
264
+
265
+ workspaceModule = new WorkspaceModule({ mounts });
266
+ moduleInstances.push(workspaceModule);
267
+ }
268
+
269
+ // Activity (typing indicators) — opt-in per recipe
270
+ let activityModule: ActivityModule | null = null;
271
+ if (modules.activity !== undefined && modules.activity !== false) {
272
+ const activityConfig = typeof modules.activity === 'object' ? modules.activity : {};
273
+ activityModule = new ActivityModule({ initialChannels: activityConfig.channels });
274
+ moduleInstances.push(activityModule);
275
+ }
276
+
277
+ // Auto-unsubscribe noisy ambient channels — ON by default (opt out with
278
+ // `modules.subscriptionGc: false`).
279
+ if (modules.subscriptionGc !== false) {
280
+ const gcConfig =
281
+ typeof modules.subscriptionGc === 'object' ? modules.subscriptionGc : {};
282
+ moduleInstances.push(
283
+ new SubscriptionGcModule({
284
+ defaultLimitChars: gcConfig.defaultLimitChars,
285
+ serverId: gcConfig.serverId,
286
+ toolPrefix: gcConfig.toolPrefix,
287
+ }),
288
+ );
289
+ }
290
+
291
+ // Channel attention modes (`set_channel_mode`). Needs the gate to add/remove
292
+ // the per-channel debounce policy, so only when `wake` is enabled. On by
293
+ // default there (opt out with `modules.channelMode: false`); it only adds a
294
+ // tool, inert until called.
295
+ let channelModeModule: ChannelModeModule | null = null;
296
+ if (gateOptions && modules.channelMode !== false) {
297
+ const cmConfig =
298
+ typeof modules.channelMode === 'object' ? modules.channelMode : {};
299
+ channelModeModule = new ChannelModeModule({
300
+ serverId: cmConfig.serverId,
301
+ toolPrefix: cmConfig.toolPrefix,
302
+ gcModuleName: cmConfig.gcModuleName,
303
+ defaultDebounceMs: cmConfig.defaultDebounceMs,
304
+ });
305
+ moduleInstances.push(channelModeModule);
306
+ }
307
+
308
+ // MCPL self-administration — opt-in per recipe (grants the agent the
309
+ // ability to spawn arbitrary commands via mcpl_deploy; see recipe.ts).
310
+ let mcplAdminModule: McplAdminModule | null = null;
311
+ if (modules.mcplAdmin === true) {
312
+ mcplAdminModule = new McplAdminModule();
313
+ moduleInstances.push(mcplAdminModule);
314
+ }
315
+
316
+ // Web admin UI — opt-in per recipe
317
+ let webUiModule: WebUiModule | null = null;
318
+ if (modules.webui !== undefined && modules.webui !== false) {
319
+ const webuiConfig = typeof modules.webui === 'object' ? modules.webui : {};
320
+ webUiModule = new WebUiModule({
321
+ port: webuiConfig.port,
322
+ host: webuiConfig.host,
323
+ basicAuth: webuiConfig.basicAuth,
324
+ allowedOrigins: webuiConfig.allowedOrigins,
325
+ });
326
+ moduleInstances.push(webUiModule);
327
+ }
328
+
329
+ // -- Build MCP server list --
330
+ //
331
+ // Recipes are opt-in: a file entry from mcpl-servers.json is loaded only
332
+ // when the recipe references its id under `mcpServers`. Credentials and
333
+ // the spawn command come from the file; the recipe entry can override
334
+ // policy fields (channelSubscription, toolPrefix, feature-set toggles,
335
+ // reconnect). Recipes can also define new servers the file doesn't have
336
+ // by supplying `command` or `url` themselves.
337
+ //
338
+ // The previous behavior loaded every file server for every recipe, which
339
+ // silently flooded focused recipes (conductor, reviewer) with traffic
340
+ // from channels the agent never asked to listen to.
341
+ const recipeServers = recipe.mcpServers ?? {};
342
+ const fileServers = loadMcplServers(DEFAULT_CONFIG_PATH);
343
+ const fileServersById = new Map(fileServers.map(s => [s.id, s]));
344
+
345
+ // A server entry has EITHER a `command` (stdio) or a `url` (WebSocket); the
346
+ // framework's McplServerConfig now carries both as optional, so this local
347
+ // type must too. Previously the url-only branch forced `command: undefined!`,
348
+ // which then reached `spawn(undefined, …)` and crashed a network-MCPL recipe.
349
+ const allServers: Array<{ id: string; command?: string; url?: string; [k: string]: unknown }> = [];
350
+ for (const [id, recipeEntry] of Object.entries(recipeServers)) {
351
+ const fileEntry = fileServersById.get(id);
352
+ if (fileEntry) {
353
+ const merged: Record<string, unknown> = { ...fileEntry };
354
+ if (recipeEntry.channelSubscription !== undefined) merged.channelSubscription = recipeEntry.channelSubscription;
355
+ if (recipeEntry.toolPrefix !== undefined) merged.toolPrefix = recipeEntry.toolPrefix;
356
+ if (recipeEntry.enabledFeatureSets !== undefined) merged.enabledFeatureSets = recipeEntry.enabledFeatureSets;
357
+ if (recipeEntry.disabledFeatureSets !== undefined) merged.disabledFeatureSets = recipeEntry.disabledFeatureSets;
358
+ if (recipeEntry.enabledTools !== undefined) merged.enabledTools = recipeEntry.enabledTools;
359
+ if (recipeEntry.disabledTools !== undefined) merged.disabledTools = recipeEntry.disabledTools;
360
+ if (recipeEntry.reconnect !== undefined) merged.reconnect = recipeEntry.reconnect;
361
+ if (recipeEntry.reconnectIntervalMs !== undefined) merged.reconnectIntervalMs = recipeEntry.reconnectIntervalMs;
362
+ if (recipeEntry.reconnectMaxIntervalMs !== undefined) merged.reconnectMaxIntervalMs = recipeEntry.reconnectMaxIntervalMs;
363
+ // Let a recipe override/adopt WebSocket transport for a file-defined server.
364
+ if (recipeEntry.url !== undefined) merged.url = recipeEntry.url;
365
+ if (recipeEntry.transport !== undefined) merged.transport = recipeEntry.transport;
366
+ if (recipeEntry.token !== undefined) merged.token = recipeEntry.token;
367
+ allServers.push(merged as { id: string; command?: string; url?: string; [k: string]: unknown });
368
+ } else if (recipeEntry.command || recipeEntry.url) {
369
+ // Recipe-defined server (not in the file config). Spread ALL recipe fields
370
+ // (command OR url/transport/token, plus policy) verbatim — no fake command.
371
+ allServers.push({ id, ...recipeEntry } as { id: string; command?: string; url?: string; [k: string]: unknown });
372
+ }
373
+ }
374
+
375
+ // Apply the agent overlay (mcpl-servers.agent.json): servers the agent
376
+ // deployed for itself load unconditionally (no recipe opt-in), and
377
+ // tombstones suppress recipe/file servers the agent unloaded.
378
+ const finalServers = applyAgentOverlay(allServers, DEFAULT_AGENT_OVERLAY_PATH);
379
+
380
+ // No server augmentation needed — gate is wired via FrameworkConfig.gate
381
+
382
+ // -- Build strategy --
383
+ //
384
+ // Build the options object with typed property access — no
385
+ // `Record<string, unknown>` cast on `strategyConfig`. Every field we
386
+ // forward is declared on `RecipeStrategy` (see recipe.ts); a typo in a
387
+ // recipe (e.g. `l1BudgetTokes`) now fails at recipe validation rather
388
+ // than silently being a no-op at strategy construction. AutobiographicalStrategy
389
+ // and FrontdeskStrategy share this option bag today; if strategy-specific
390
+ // fields are ever added, this should split into per-strategy types.
391
+ const strategyConfig = recipe.agent.strategy;
392
+ const strategyType = strategyConfig?.type ?? 'autobiographical';
393
+ const autobiographicalOpts: Record<string, unknown> = {
394
+ headWindowTokens: strategyConfig?.headWindowTokens ?? 4000,
395
+ recentWindowTokens: strategyConfig?.recentWindowTokens ?? 30000,
396
+ compressionModel: strategyConfig?.compressionModel ?? model,
397
+ autoTickOnNewMessage: true,
398
+ maxMessageTokens: strategyConfig?.maxMessageTokens ?? 10000,
399
+ };
400
+ // Forward optional tuning fields when set. The key list is typed
401
+ // against `RecipeStrategy`, so an unknown field name is a compile
402
+ // error here rather than a silent no-op at runtime.
403
+ const passthroughKeys: ReadonlyArray<keyof RecipeStrategy> = [
404
+ 'enforceBudget',
405
+ 'maxSpeculativeL1s',
406
+ 'positionedRecallPairs',
407
+ 'recallHeaderTemplate',
408
+ 'targetChunkTokens',
409
+ 'mergeThreshold',
410
+ 'summaryTargetTokens',
411
+ 'l1BudgetTokens',
412
+ 'l2BudgetTokens',
413
+ 'l3BudgetTokens',
414
+ 'toolResultMaxLastN',
415
+ 'toolUseInputMaxTokens',
416
+ 'adaptiveResolution',
417
+ 'kvStableReachTokens',
418
+ 'kvStableQualityGapRatio',
419
+ 'compressionSlackRatio',
420
+ 'overBudgetGraceRatio',
421
+ 'foldingStrategy',
422
+ 'speculativeProduction',
423
+ 'l1HoldbackChunks',
424
+ 'summaryParticipant',
425
+ 'summarySystemPrompt',
426
+ 'summaryUserPrompt',
427
+ 'summaryContextLabel',
428
+ ];
429
+ for (const key of passthroughKeys) {
430
+ const v = strategyConfig?.[key];
431
+ if (v !== undefined) autobiographicalOpts[key] = v;
432
+ }
433
+ // Adaptive resolution (document-based gradual compression) is the intended
434
+ // default for autobiographical agents. Frontdesk keeps the hierarchical
435
+ // renderer (its salience-biased L1 selection); it can still opt in via the
436
+ // recipe. A recipe may set `adaptiveResolution: false` to opt back out.
437
+ if (strategyType === 'autobiographical' && autobiographicalOpts.adaptiveResolution === undefined) {
438
+ autobiographicalOpts.adaptiveResolution = true;
439
+ }
440
+ const strategy = strategyType === 'passthrough'
441
+ ? new PassthroughStrategy()
442
+ : strategyType === 'frontdesk'
443
+ ? new FrontdeskStrategy(autobiographicalOpts)
444
+ : new AutobiographicalStrategy(autobiographicalOpts);
445
+
446
+ // -- Create framework --
447
+ const framework = await AgentFramework.create({
448
+ storePath,
449
+ membrane,
450
+ agents: [
451
+ {
452
+ name: agentName,
453
+ model,
454
+ systemPrompt: recipe.agent.systemPrompt,
455
+ maxTokens: recipe.agent.maxTokens ?? 16384,
456
+ maxStreamTokens: recipe.agent.maxStreamTokens ?? 150000,
457
+ contextBudgetTokens: recipe.agent.contextBudgetTokens,
458
+ ...(recipe.agent.cacheTtl && { cacheTtl: recipe.agent.cacheTtl }),
459
+ strategy,
460
+ ...(recipe.agent.thinking && { thinking: recipe.agent.thinking }),
461
+ ...(recipe.agent.refusalHandling && { refusalHandling: recipe.agent.refusalHandling }),
462
+ },
463
+ ],
464
+ modules: moduleInstances,
465
+ mcplServers: finalServers,
466
+ gate: gateOptions,
467
+ });
468
+
469
+ // Wire post-creation hooks
470
+ if (subagentModule) {
471
+ subagentModule.setFramework(framework);
472
+ }
473
+
474
+ if (activityModule) {
475
+ activityModule.setFramework(framework);
476
+ }
477
+
478
+ if (channelModeModule) {
479
+ channelModeModule.setFramework(framework);
480
+ }
481
+
482
+ if (mcplAdminModule) {
483
+ mcplAdminModule.setFramework(framework);
484
+ }
485
+
486
+ if (workspaceModule) {
487
+ workspaceModule.initStore(framework.getStore());
488
+ }
489
+
490
+ // Stash the WebUiModule reference on the framework so main() can call
491
+ // setApp() once the AppContext is built. Using a symbol-keyed property to
492
+ // avoid polluting the public framework API for the sake of one module.
493
+ if (webUiModule) {
494
+ (framework as unknown as Record<symbol, WebUiModule>)[webUiModuleSymbol] = webUiModule;
495
+ }
496
+
497
+ return framework;
498
+ }
499
+
500
+ const webUiModuleSymbol = Symbol.for('connectome-host:web-ui-module');
501
+
502
+ function getWebUiModule(framework: AgentFramework): WebUiModule | null {
503
+ const sym = (framework as unknown as Record<symbol, WebUiModule | undefined>)[webUiModuleSymbol];
504
+ return sym ?? null;
505
+ }
506
+
507
+ // ---------------------------------------------------------------------------
508
+ // Synesthete auto-naming hook
509
+ // ---------------------------------------------------------------------------
510
+
511
+ function setupSynesthete(app: AppContext): void {
512
+ const agentName = app.agentName;
513
+ const namingExamples = app.recipe.sessionNaming?.examples;
514
+
515
+ app.framework.onTrace((event) => {
516
+ if (event.type !== 'message:added') return;
517
+ const e = event as unknown as { source: string };
518
+ if (e.source !== 'external-message') return;
519
+
520
+ app.userMessageCount++;
521
+ if (app.userMessageCount !== 3) return;
522
+
523
+ const session = app.sessionManager.getActiveSession();
524
+ if (!session || session.manuallyNamed) return;
525
+
526
+ const agent = app.framework.getAgent(agentName);
527
+ const cm = agent?.getContextManager();
528
+ if (!cm) return;
529
+
530
+ const { messages } = cm.queryMessages({});
531
+ const summary = messages
532
+ .filter(m => m.content.some((b: { type: string }) => b.type === 'text'))
533
+ .slice(0, 6)
534
+ .map(m => {
535
+ const text = m.content
536
+ .filter((b: { type: string }): b is { type: 'text'; text: string } => b.type === 'text')
537
+ .map((b: { text: string }) => b.text)
538
+ .join(' ');
539
+ return `${m.participant}: ${text.slice(0, 200)}`;
540
+ })
541
+ .join('\n');
542
+
543
+ generateSessionName(app.membrane, summary, namingExamples).then(name => {
544
+ if (name) {
545
+ app.sessionManager.renameSession(session.id, name, false);
546
+ }
547
+ });
548
+ });
549
+ }
550
+
551
+ // ---------------------------------------------------------------------------
552
+ // MCPL subprocess stderr log — receipts for "why did that MCPL server break"
553
+ // ---------------------------------------------------------------------------
554
+
555
+ const MCPL_STDERR_LOG_MAX_BYTES = 10 * 1024 * 1024; // 10 MB; rolls to .1 on overflow.
556
+
557
+ /** Render an mcpl:server-* connection-lifecycle trace as a log line, or null
558
+ * for trace types this sink doesn't record. Lifecycle lines are prefixed
559
+ * `[host]` to stand apart from the server's own stderr output. */
560
+ function formatMcplLifecycleLine(event: { type: string } & Record<string, unknown>): string | null {
561
+ switch (event.type) {
562
+ case 'mcpl:server-stderr':
563
+ return String(event.line);
564
+ case 'mcpl:server-connect-failed':
565
+ return `[host] connect failed (attempt ${event.attempt}, ${event.willRetry ? 'will retry' : 'NO RETRY — server unavailable until restart'}): ${event.error}`;
566
+ case 'mcpl:server-reconnected':
567
+ return `[host] reconnected after ${event.attempts} attempt(s)`;
568
+ case 'mcpl:server-closed':
569
+ return `[host] connection closed (code=${event.code}, signal=${event.signal}${event.willReconnect ? ', reconnect scheduled' : ''})`;
570
+ case 'mcpl:server-error':
571
+ return `[host] connection error: ${event.error}`;
572
+ default:
573
+ return null;
574
+ }
575
+ }
576
+
577
+ function setupMcplStderrLog(app: AppContext, storePath: string): void {
578
+ const dir = join(storePath, 'mcpl-stderr');
579
+ // Best-effort directory creation — if it fails, per-write attempts will too,
580
+ // and we'll swallow those quietly. We don't want logging to be load-bearing.
581
+ void mkdir(dir, { recursive: true }).catch(() => {});
582
+
583
+ app.framework.onTrace((event) => {
584
+ const e = event as unknown as { type: string; serverId?: string; timestamp: number } & Record<string, unknown>;
585
+ if (typeof e.serverId !== 'string') return;
586
+ const line = formatMcplLifecycleLine(e);
587
+ if (line === null) return;
588
+ const iso = new Date(e.timestamp).toISOString();
589
+ // basename guards against a misconfigured serverId like "../foo" escaping dir.
590
+ const path = join(dir, `${basename(e.serverId)}.log`);
591
+ const entry = `${iso} ${line}\n`;
592
+ void rotateIfNeeded(path, entry.length)
593
+ .then(() => appendFile(path, entry))
594
+ .catch(() => {
595
+ // If logging itself fails, don't cascade.
596
+ });
597
+ });
598
+ }
599
+
600
+ async function rotateIfNeeded(path: string, incomingBytes: number): Promise<void> {
601
+ try {
602
+ const s = await stat(path);
603
+ if (s.size + incomingBytes > MCPL_STDERR_LOG_MAX_BYTES) {
604
+ await rename(path, `${path}.1`);
605
+ }
606
+ } catch {
607
+ // No existing file (or stat failed) — nothing to rotate.
608
+ }
609
+ }
610
+
611
+ // ---------------------------------------------------------------------------
612
+ // Piped/headless mode (--no-tui or non-TTY stdin)
613
+ // ---------------------------------------------------------------------------
614
+
615
+ async function runPiped(app: AppContext) {
616
+ const { createInterface } = await import('node:readline');
617
+ const { handleCommand } = await import('./commands.js');
618
+
619
+ let inferenceResolve: (() => void) | null = null;
620
+
621
+ app.framework.onTrace((event) => {
622
+ const e = event as unknown as Record<string, unknown>;
623
+ switch (event.type) {
624
+ case 'inference:started':
625
+ process.stdout.write('\n');
626
+ break;
627
+ case 'inference:tokens': {
628
+ const content = e.content as string;
629
+ if (content) process.stdout.write(content);
630
+ break;
631
+ }
632
+ case 'inference:completed':
633
+ process.stdout.write('\n');
634
+ inferenceResolve?.();
635
+ inferenceResolve = null;
636
+ break;
637
+ case 'inference:failed':
638
+ console.error(`\nError: ${e.error}`);
639
+ inferenceResolve?.();
640
+ inferenceResolve = null;
641
+ break;
642
+ case 'inference:tool_calls_yielded': {
643
+ const calls = e.calls as Array<{ name: string }>;
644
+ console.log(`\n[tools] ${calls.map(c => c.name).join(', ')}`);
645
+ break;
646
+ }
647
+ case 'tool:started': {
648
+ const toolInput = e.input ? JSON.stringify(e.input) : '';
649
+ const truncated = toolInput.length > 120 ? toolInput.slice(0, 120) + '...' : toolInput;
650
+ console.log(`[tool] ${e.tool}${truncated ? ' ' + truncated : ''}`);
651
+ break;
652
+ }
653
+ }
654
+ });
655
+
656
+ function waitForInference(): Promise<void> {
657
+ return new Promise(resolve => {
658
+ inferenceResolve = resolve;
659
+ setTimeout(() => {
660
+ if (inferenceResolve === resolve) { inferenceResolve = null; resolve(); }
661
+ }, 120_000);
662
+ });
663
+ }
664
+
665
+ async function processLine(line: string): Promise<boolean> {
666
+ const trimmed = line.trim();
667
+ if (!trimmed) return false;
668
+ if (trimmed.startsWith('/')) {
669
+ const result = handleCommand(trimmed, app);
670
+ if (result.quit) return true;
671
+ for (const l of result.lines) console.log(l.text);
672
+ if (result.branchChanged) {
673
+ const ws = app.framework.getModule('workspace');
674
+ if (ws && 'materializeMount' in ws) {
675
+ await (ws as any).materializeMount('_config');
676
+ }
677
+ }
678
+ if (result.switchToSessionId) {
679
+ await app.switchSession(result.switchToSessionId);
680
+ console.log('Session switched.');
681
+ }
682
+ } else {
683
+ app.framework.pushEvent({
684
+ type: 'external-message', source: 'cli',
685
+ content: trimmed, metadata: {}, triggerInference: true,
686
+ });
687
+ await waitForInference();
688
+ }
689
+ return false;
690
+ }
691
+
692
+ // Piped: read all then process
693
+ if (!process.stdin.isTTY) {
694
+ const lines: string[] = [];
695
+ const rl = createInterface({ input: process.stdin });
696
+ for await (const line of rl) lines.push(line);
697
+ console.log(`Processing ${lines.length} commands...`);
698
+ for (const line of lines) {
699
+ console.log(`> ${line}`);
700
+ if (await processLine(line)) break;
701
+ }
702
+ console.log('Done.');
703
+ await app.framework.stop();
704
+ return;
705
+ }
706
+
707
+ // Interactive TTY readline (fallback if --no-tui is explicit on a TTY)
708
+ const rl = createInterface({ input: process.stdin, output: process.stdout, prompt: '> ' });
709
+ console.log('connectome-host (readline mode). Type /help for commands.');
710
+ rl.prompt();
711
+ rl.on('line', async (line: string) => {
712
+ if (await processLine(line)) { rl.close(); return; }
713
+ rl.prompt();
714
+ });
715
+ await new Promise<void>(r => rl.on('close', r));
716
+ console.log('\nShutting down...');
717
+ await app.framework.stop();
718
+ }
719
+
720
+ // ---------------------------------------------------------------------------
721
+ // Utilities
722
+ // ---------------------------------------------------------------------------
723
+
724
+ /** Count non-empty lines in a file. Returns 0 if the file doesn't exist. */
725
+ function countLines(path: string): number {
726
+ if (!existsSync(path)) return 0;
727
+ try {
728
+ const buf = readFileSync(path, 'utf8');
729
+ if (buf.length === 0) return 0;
730
+ let n = 0;
731
+ for (let i = 0; i < buf.length; i++) if (buf.charCodeAt(i) === 10) n++;
732
+ // If the last byte isn't a newline, there's a partial trailing line that counts too.
733
+ if (buf.charCodeAt(buf.length - 1) !== 10) n++;
734
+ return n;
735
+ } catch {
736
+ return 0;
737
+ }
738
+ }
739
+
740
+ // ---------------------------------------------------------------------------
741
+ // Main
742
+ // ---------------------------------------------------------------------------
743
+
744
+ async function main() {
745
+ const recipe = await resolveRecipe();
746
+
747
+ // SettingsModule constructed early so the adapter can read its state for
748
+ // cross-cutting concerns (currently: reasoning). It's wired into the
749
+ // framework's module list inside createFramework().
750
+ const settingsModule = new SettingsModule();
751
+
752
+ // Append each LLM request/response/error to a JSONL log per process lifetime
753
+ // (matches the Hermes-era `llm-calls.<iso>.jsonl` visibility). The adapter
754
+ // also reads SettingsModule.getReasoning() per call to inject `thinking`
755
+ // when the agent has toggled reasoning on.
756
+ const llmLogPath = join(
757
+ config.dataDir,
758
+ `llm-calls.${new Date().toISOString().replace(/[:.]/g, '-')}.jsonl`,
759
+ );
760
+ // OAuth (subscription) auth wins over API-key auth when both are present.
761
+ // Subscription tokens (sk-ant-oat…) additionally require the oauth beta
762
+ // header on every request.
763
+ const adapter = new LoggingAnthropicAdapter(
764
+ {
765
+ ...(config.authToken
766
+ ? {
767
+ authToken: config.authToken,
768
+ defaultHeaders: { 'anthropic-beta': 'oauth-2025-04-20' },
769
+ }
770
+ : { apiKey: config.apiKey! }),
771
+ baseURL: process.env.ANTHROPIC_BASE_URL || undefined,
772
+ },
773
+ llmLogPath,
774
+ () => settingsModule.getReasoning(),
775
+ );
776
+
777
+ // Session management — resolved before Membrane construction so the
778
+ // active session's import-source sidecar can contribute to agent-name
779
+ // resolution. Without that, a custom recipe that omits agent.name
780
+ // combined with a claudeai-imported session falls back to 'agent' on
781
+ // the live side while the importer + warmup default to 'Claude',
782
+ // re-creating the namespace fork this branch exists to close.
783
+ const sessionManager = new SessionManager(config.dataDir);
784
+ sessionManager.migrateIfNeeded();
785
+
786
+ let activeSession = sessionManager.getActiveSession();
787
+ if (!activeSession) {
788
+ activeSession = sessionManager.createSession();
789
+ }
790
+
791
+ const resolved = resolveAgentName({
792
+ explicit: recipe.agent.name,
793
+ sidecar: sessionManager.getImportSource(activeSession.id)?.agentName,
794
+ default: 'agent',
795
+ });
796
+ if (resolved.mismatch) {
797
+ console.warn(
798
+ `[recipe vs sidecar] agent name disagreement: recipe says ` +
799
+ `"${resolved.mismatch.explicit}", session ${activeSession.id}'s ` +
800
+ `import sidecar says "${resolved.mismatch.sidecar}". Using the ` +
801
+ `recipe value; warmup output under "${resolved.mismatch.sidecar}" ` +
802
+ `will be orphaned at agents/${resolved.mismatch.sidecar}/...`,
803
+ );
804
+ }
805
+ const agentName = resolved.name;
806
+
807
+ const membrane = new Membrane(adapter, {
808
+ formatter: new NativeFormatter(),
809
+ // Anchor the assistant role for internal callers that don't set
810
+ // request.assistantParticipant themselves (autobio compression,
811
+ // executeMerge). Mismatch here flips stored assistant turns to
812
+ // role: 'user' and the API rejects tool_use blocks riding along.
813
+ assistantParticipant: agentName,
814
+ });
815
+
816
+ const storePath = sessionManager.getStorePath(activeSession.id);
817
+ const framework = await createFramework(membrane, storePath, recipe, agentName, settingsModule);
818
+
819
+ // Build app context
820
+ const app: AppContext = {
821
+ framework,
822
+ membrane,
823
+ sessionManager,
824
+ recipe,
825
+ agentName,
826
+ branchState: createBranchState(),
827
+ userMessageCount: 0,
828
+
829
+ async switchSession(id: string) {
830
+ handleExport(this);
831
+ await this.framework.stop();
832
+ sessionManager.setActiveSession(id);
833
+ const newStorePath = sessionManager.getStorePath(id);
834
+ // Note: agentName stays as resolved at startup. A per-session
835
+ // re-resolution would matter only if recipe.agent.name is absent
836
+ // AND the user switches between imports that used different
837
+ // --agent values; not the canonical flow.
838
+ this.framework = await createFramework(membrane, newStorePath, recipe, this.agentName, settingsModule);
839
+ this.framework.start();
840
+ this.userMessageCount = 0;
841
+ resetBranchState(this.branchState);
842
+ setupSynesthete(this);
843
+ setupMcplStderrLog(this, newStorePath);
844
+ getWebUiModule(this.framework)?.setApp(this);
845
+ },
846
+ };
847
+
848
+ framework.start();
849
+ setupSynesthete(app);
850
+ setupMcplStderrLog(app, storePath);
851
+ getWebUiModule(framework)?.setApp(app);
852
+
853
+ if (headless) {
854
+ const { runHeadless } = await import('./headless.js');
855
+ await runHeadless(app, process.argv.slice(2));
856
+ } else if (noTui) {
857
+ await runPiped(app);
858
+ } else {
859
+ const { runTui } = await import('./tui.js');
860
+ await runTui(app);
861
+ }
862
+ }
863
+
864
+ main().catch((err) => {
865
+ console.error('Fatal error:', err);
866
+ process.exit(1);
867
+ });