@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.
- package/.env.example +20 -0
- package/.github/workflows/publish.yml +65 -0
- package/ARCHITECTURE.md +355 -0
- package/CHANGELOG.md +30 -0
- package/HEADLESS-FLEET-PLAN.md +330 -0
- package/README.md +189 -0
- package/UNIFIED-TREE-PLAN.md +242 -0
- package/bun.lock +288 -0
- package/docs/AGENT-MEMORY-GUIDE.md +214 -0
- package/docs/AGENT-ONBOARDING.md +541 -0
- package/docs/ATTENTION-AND-GATING.md +120 -0
- package/docs/DEPLOYMENTS.md +130 -0
- package/docs/DEV-ENVIRONMENT.md +215 -0
- package/docs/LIBRARY-PIPELINE.md +286 -0
- package/docs/LOCUS-ROUTING-DESIGN.md +115 -0
- package/docs/claudeai-evacuation.md +228 -0
- package/docs/debug-context-api.md +208 -0
- package/docs/webui-deployment.md +219 -0
- package/package.json +33 -0
- package/recipes/SETUP.md +308 -0
- package/recipes/TRIUMVIRATE-SETUP.md +467 -0
- package/recipes/claude-export-revive.json +19 -0
- package/recipes/clerk.json +157 -0
- package/recipes/knowledge-miner.json +174 -0
- package/recipes/knowledge-reviewer.json +76 -0
- package/recipes/mcpl-editor-test.json +38 -0
- package/recipes/prompts/transplant-addendum.md +17 -0
- package/recipes/triumvirate.json +63 -0
- package/recipes/webui-fleet-test.json +27 -0
- package/recipes/webui-test.json +16 -0
- package/recipes/zulip-miner.json +87 -0
- package/scripts/connectome-doctor +373 -0
- package/scripts/evacuator.ts +956 -0
- package/scripts/import-claudeai-export.ts +747 -0
- package/scripts/lib/line-reader.ts +53 -0
- package/scripts/test-historical-thinking.ts +148 -0
- package/scripts/warmup-session.ts +336 -0
- package/src/agent-name.ts +58 -0
- package/src/commands.ts +1074 -0
- package/src/headless.ts +528 -0
- package/src/index.ts +867 -0
- package/src/logging-adapter.ts +208 -0
- package/src/mcpl-config.ts +199 -0
- package/src/modules/activity-module.ts +270 -0
- package/src/modules/channel-mode-module.ts +260 -0
- package/src/modules/fleet-module.ts +1705 -0
- package/src/modules/fleet-types.ts +225 -0
- package/src/modules/lessons-module.ts +465 -0
- package/src/modules/mcpl-admin-module.ts +345 -0
- package/src/modules/retrieval-module.ts +327 -0
- package/src/modules/settings-module.ts +143 -0
- package/src/modules/subagent-module.ts +2074 -0
- package/src/modules/subscription-gc-module.ts +322 -0
- package/src/modules/time-module.ts +85 -0
- package/src/modules/tui-module.ts +76 -0
- package/src/modules/web-ui-curve-page.ts +249 -0
- package/src/modules/web-ui-module.ts +2595 -0
- package/src/recipe.ts +1003 -0
- package/src/session-manager.ts +278 -0
- package/src/state/agent-tree-reducer.ts +431 -0
- package/src/state/fleet-tree-aggregator.ts +195 -0
- package/src/strategies/frontdesk-strategy.ts +364 -0
- package/src/synesthete.ts +68 -0
- package/src/tui.ts +2200 -0
- package/src/types/bun-ffi.d.ts +6 -0
- package/src/web/protocol.ts +648 -0
- package/test/agent-name.test.ts +62 -0
- package/test/agent-tree-fleet-launch.test.ts +64 -0
- package/test/agent-tree-reducer-parity.test.ts +194 -0
- package/test/agent-tree-reducer.test.ts +443 -0
- package/test/agent-tree-rollup.test.ts +109 -0
- package/test/autobio-progress-snapshot.test.ts +40 -0
- package/test/claudeai-export-importer.test.ts +386 -0
- package/test/evacuator.test.ts +332 -0
- package/test/fleet-commands.test.ts +244 -0
- package/test/fleet-no-subfleets.test.ts +147 -0
- package/test/fleet-orchestration.test.ts +353 -0
- package/test/fleet-recipe.test.ts +124 -0
- package/test/fleet-route.test.ts +44 -0
- package/test/fleet-smoke.test.ts +479 -0
- package/test/fleet-subscribe-union-e2e.test.ts +123 -0
- package/test/fleet-subscribe-union.test.ts +85 -0
- package/test/fleet-tree-aggregator-e2e.test.ts +110 -0
- package/test/fleet-tree-aggregator.test.ts +215 -0
- package/test/frontdesk-strategy.test.ts +326 -0
- package/test/headless-describe.test.ts +182 -0
- package/test/headless-smoke.test.ts +190 -0
- package/test/logging-adapter.test.ts +87 -0
- package/test/mcpl-admin-module.test.ts +213 -0
- package/test/mcpl-agent-overlay.test.ts +126 -0
- package/test/mock-headless-child.ts +133 -0
- package/test/recipe-cache-ttl.test.ts +37 -0
- package/test/recipe-env.test.ts +157 -0
- package/test/recipe-path-resolution.test.ts +170 -0
- package/test/subagent-async-timeout.test.ts +381 -0
- package/test/subagent-fork-materialise.test.ts +241 -0
- package/test/subagent-peek-scoping.test.ts +180 -0
- package/test/subagent-peek-zombie.test.ts +148 -0
- package/test/subagent-reaper-in-flight.test.ts +229 -0
- package/test/subscription-gc-module.test.ts +136 -0
- package/test/warmup-handoff.test.ts +150 -0
- package/test/web-ui-bind.test.ts +51 -0
- package/test/web-ui-module.test.ts +246 -0
- package/test/web-ui-protocol.test.ts +0 -0
- package/tsconfig.json +14 -0
- package/web/bun.lock +357 -0
- package/web/index.html +12 -0
- package/web/package.json +24 -0
- package/web/src/App.tsx +1931 -0
- package/web/src/Context.tsx +158 -0
- package/web/src/ContextDocument.tsx +150 -0
- package/web/src/Files.tsx +271 -0
- package/web/src/Lessons.tsx +164 -0
- package/web/src/Mcpl.tsx +310 -0
- package/web/src/Stream.tsx +119 -0
- package/web/src/TreeSidebar.tsx +283 -0
- package/web/src/Usage.tsx +182 -0
- package/web/src/main.tsx +7 -0
- package/web/src/styles.css +26 -0
- package/web/src/tree.ts +268 -0
- package/web/src/wire.ts +120 -0
- package/web/tsconfig.json +21 -0
- 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
|
+
});
|