@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
@@ -0,0 +1,208 @@
1
+ // AnthropicAdapter wrapper that appends each LLM call's RAW request, RAW
2
+ // response, and any error to a JSONL log file. Restores the Hermes-era
3
+ // `llm-calls.<iso>.jsonl` visibility we lose on the connectome stack by
4
+ // default. Successful calls keep a compact request summary; the full raw
5
+ // request is retained only for refusals and errors, where forensic inspection
6
+ // matters. Serializing the full raw + normalized request on every tool-loop
7
+ // turn previously created several simultaneous copies of a large context and
8
+ // contributed to production OOMs.
9
+ //
10
+ // "Raw" means the actual Anthropic API payload (post-buildRequest, including
11
+ // `thinking`, tool definitions in Anthropic shape, etc.) and the raw provider
12
+ // response — not the membrane-normalized intermediate. We hook the membrane's
13
+ // `options.onRequest` callback (called inside anthropic.ts with the built
14
+ // request) and read `ProviderResponse.raw` for the response. Normalized
15
+ // representations are kept under separate keys for occasional cross-reference.
16
+ //
17
+ // One file per process lifetime (timestamped at construction). Each line is a
18
+ // JSON object with shape:
19
+ // { type: 'call'|'error', kind: 'complete'|'stream', timestamp, durationMs,
20
+ // requestSummary, rawRequest?, rawResponse, error? }
21
+
22
+ import { AnthropicAdapter } from '@animalabs/membrane';
23
+ import type {
24
+ ProviderRequest,
25
+ ProviderResponse,
26
+ ProviderRequestOptions,
27
+ StreamCallbacks,
28
+ } from '@animalabs/membrane';
29
+ import { appendFileSync } from 'node:fs';
30
+
31
+ /** Live read of the current reasoning setting. The host wires this to
32
+ * `SettingsModule.getReasoning()` so toggles via the `settings--reasoning_*`
33
+ * tools take effect on the next call without restart. */
34
+ export type ReasoningGetter = () => { enabled: boolean; budgetTokens: number };
35
+
36
+ /** Exact first-system-block identity Anthropic requires on subscription
37
+ * (sk-ant-oat…) OAuth traffic. Verified 2026-07-09: any other first block —
38
+ * including this text with a suffix appended in the SAME block — is rejected
39
+ * with a masked 429 rate_limit_error; this block followed by arbitrary
40
+ * persona blocks is accepted (the mechanism the Agent SDK's
41
+ * system-prompt-append uses). */
42
+ const OAUTH_SYSTEM_IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude.";
43
+
44
+ export class LoggingAnthropicAdapter extends AnthropicAdapter {
45
+ private readonly logPath: string;
46
+ private readonly getReasoning?: ReasoningGetter;
47
+ /** True when authenticated with an OAuth/Bearer token instead of an API
48
+ * key; requests then need the identity block prepended (see above). */
49
+ private readonly oauthMode: boolean;
50
+
51
+ constructor(
52
+ config: ConstructorParameters<typeof AnthropicAdapter>[0],
53
+ logPath: string,
54
+ getReasoning?: ReasoningGetter,
55
+ ) {
56
+ super(config);
57
+ this.logPath = logPath;
58
+ this.getReasoning = getReasoning;
59
+ this.oauthMode = Boolean(config?.authToken);
60
+ }
61
+
62
+ /** Under OAuth subscription auth, prepend the required identity block to the
63
+ * system prompt (converting a string system to blocks). No-op under API-key
64
+ * auth or when the identity block is already first. The block is plain
65
+ * (no cache_control) and a stable prefix, so caching is unaffected. */
66
+ private withOAuthIdentity(request: ProviderRequest): ProviderRequest {
67
+ if (!this.oauthMode) return request;
68
+ const sys = request.system;
69
+ const blocks: unknown[] =
70
+ typeof sys === 'string' && sys.length > 0
71
+ ? [{ type: 'text', text: sys }]
72
+ : Array.isArray(sys) ? sys : [];
73
+ const first = blocks[0] as { type?: string; text?: string } | undefined;
74
+ if (first?.type === 'text' && first.text === OAUTH_SYSTEM_IDENTITY) return request;
75
+ return {
76
+ ...request,
77
+ system: [{ type: 'text', text: OAUTH_SYSTEM_IDENTITY }, ...blocks],
78
+ };
79
+ }
80
+
81
+ /** If reasoning is enabled, inject `thinking` into the request before the
82
+ * adapter builds the Anthropic payload. The Anthropic adapter forwards
83
+ * provider-specific params carried in `request.extra`, so that's the hook
84
+ * point. We return a shallow clone to avoid surprising callers. */
85
+ private withReasoning(request: ProviderRequest): ProviderRequest {
86
+ const r = this.getReasoning?.();
87
+ if (!r || !r.enabled) return request;
88
+ // Use ADAPTIVE thinking, not the legacy { type:'enabled', budget_tokens }
89
+ // form. Current Anthropic models (opus-4-6/4-7/4-8, …) reject the legacy
90
+ // form with: `"thinking.type.enabled" is not supported for this model.
91
+ // Use "thinking.type.adaptive"`. Under adaptive the model sizes its own
92
+ // thinking, so `budgetTokens` is no longer sent (it still shows in
93
+ // reasoning_status as an informational hint).
94
+ //
95
+ // membrane's `ProviderRequest` doesn't type a top-level `thinking` field
96
+ // (membrane is 0.5.68 — agent-framework is the package that went 0.6.0;
97
+ // the old `as … ProviderRequest['thinking']` cast only ever compiled via a
98
+ // stale nested membrane copy that lockfile dedup removed). But the Anthropic
99
+ // adapter applies `request.extra` to the API params verbatim
100
+ // (`complete()`: `const { normalizedMessages, prompt, ...rest } =
101
+ // request.extra; Object.assign(params, rest)`), so we route thinking
102
+ // through the typed `extra` bag — no type assertion, and it survives the
103
+ // next dependency reshuffle instead of hiding it from the compiler.
104
+ return {
105
+ ...request,
106
+ extra: { ...request.extra, thinking: { type: 'adaptive' } },
107
+ };
108
+ }
109
+
110
+ private log(record: Record<string, unknown>): void {
111
+ try {
112
+ appendFileSync(this.logPath, JSON.stringify(record) + '\n');
113
+ } catch {
114
+ // never throw from logging
115
+ }
116
+ }
117
+
118
+ private requestSummary(request: ProviderRequest): Record<string, unknown> {
119
+ return {
120
+ model: request.model,
121
+ maxTokens: request.maxTokens,
122
+ messages: request.messages.length,
123
+ tools: request.tools?.length ?? 0,
124
+ };
125
+ }
126
+
127
+ private refusalRawRequest(response: ProviderResponse, rawRequest: unknown): unknown {
128
+ const raw = (response as { raw?: { stop_reason?: string } }).raw;
129
+ return raw?.stop_reason === 'refusal' ? rawRequest : undefined;
130
+ }
131
+
132
+ /** Wrap options to capture the raw provider request via the membrane
133
+ * onRequest hook, then chain to any caller-supplied onRequest. */
134
+ private captureRawRequest(
135
+ options: ProviderRequestOptions | undefined,
136
+ sink: { rawRequest: unknown },
137
+ ): ProviderRequestOptions {
138
+ const callerOnRequest = options?.onRequest;
139
+ return {
140
+ ...options,
141
+ onRequest: (req: unknown) => {
142
+ sink.rawRequest = req;
143
+ try { callerOnRequest?.(req as never); } catch { /* never block on caller hook */ }
144
+ },
145
+ } as ProviderRequestOptions;
146
+ }
147
+
148
+ override async complete(
149
+ request: ProviderRequest,
150
+ options?: ProviderRequestOptions,
151
+ ): Promise<ProviderResponse> {
152
+ const t0 = Date.now();
153
+ const effective = this.withOAuthIdentity(this.withReasoning(request));
154
+ const sink: { rawRequest: unknown } = { rawRequest: null };
155
+ const wrapped = this.captureRawRequest(options, sink);
156
+ try {
157
+ const response = await super.complete(effective, wrapped);
158
+ this.log({
159
+ type: 'call', kind: 'complete',
160
+ timestamp: new Date().toISOString(), durationMs: Date.now() - t0,
161
+ requestSummary: this.requestSummary(request),
162
+ rawRequest: this.refusalRawRequest(response, sink.rawRequest),
163
+ rawResponse: (response as { raw?: unknown }).raw ?? null,
164
+ });
165
+ return response;
166
+ } catch (err) {
167
+ this.log({
168
+ type: 'error', kind: 'complete',
169
+ timestamp: new Date().toISOString(), durationMs: Date.now() - t0,
170
+ requestSummary: this.requestSummary(request),
171
+ rawRequest: sink.rawRequest,
172
+ error: err instanceof Error ? { name: err.name, message: err.message, stack: err.stack } : String(err),
173
+ });
174
+ throw err;
175
+ }
176
+ }
177
+
178
+ override async stream(
179
+ request: ProviderRequest,
180
+ callbacks: StreamCallbacks,
181
+ options?: ProviderRequestOptions,
182
+ ): Promise<ProviderResponse> {
183
+ const t0 = Date.now();
184
+ const effective = this.withOAuthIdentity(this.withReasoning(request));
185
+ const sink: { rawRequest: unknown } = { rawRequest: null };
186
+ const wrapped = this.captureRawRequest(options, sink);
187
+ try {
188
+ const response = await super.stream(effective, callbacks, wrapped);
189
+ this.log({
190
+ type: 'call', kind: 'stream',
191
+ timestamp: new Date().toISOString(), durationMs: Date.now() - t0,
192
+ requestSummary: this.requestSummary(request),
193
+ rawRequest: this.refusalRawRequest(response, sink.rawRequest),
194
+ rawResponse: (response as { raw?: unknown }).raw ?? null,
195
+ });
196
+ return response;
197
+ } catch (err) {
198
+ this.log({
199
+ type: 'error', kind: 'stream',
200
+ timestamp: new Date().toISOString(), durationMs: Date.now() - t0,
201
+ requestSummary: this.requestSummary(request),
202
+ rawRequest: sink.rawRequest,
203
+ error: err instanceof Error ? { name: err.name, message: err.message, stack: err.stack } : String(err),
204
+ });
205
+ throw err;
206
+ }
207
+ }
208
+ }
@@ -0,0 +1,199 @@
1
+ /**
2
+ * File-driven MCPL server configuration.
3
+ *
4
+ * Reads/writes `mcpl-servers.json` (CC `.mcp.json` shape), keyed by server ID.
5
+ */
6
+
7
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
8
+ import { resolve, dirname } from 'node:path';
9
+
10
+ /** Default config file path, resolved from cwd. */
11
+ export const DEFAULT_CONFIG_PATH = resolve(process.cwd(), 'mcpl-servers.json');
12
+
13
+ /**
14
+ * Serializable subset of McplServerConfig (everything except callbacks and scopes).
15
+ */
16
+ export interface ServerFileEntry {
17
+ command: string;
18
+ args?: string[];
19
+ env?: Record<string, string>;
20
+ toolPrefix?: string;
21
+ reconnect?: boolean;
22
+ reconnectIntervalMs?: number;
23
+ reconnectMaxIntervalMs?: number;
24
+ enabledFeatureSets?: string[];
25
+ disabledFeatureSets?: string[];
26
+ enabledTools?: string[];
27
+ disabledTools?: string[];
28
+ channelSubscription?: 'auto' | 'manual' | string[];
29
+ }
30
+
31
+ export interface McplServersFile {
32
+ mcplServers: Record<string, ServerFileEntry>;
33
+ }
34
+
35
+ /** A loaded server config — serializable fields plus the id from the key. */
36
+ export type LoadedServerConfig = ServerFileEntry & { id: string };
37
+
38
+ /**
39
+ * Load MCPL server configs from a JSON file.
40
+ * Returns empty array if the file doesn't exist.
41
+ * Resolves relative paths in `args` relative to the config file's directory.
42
+ */
43
+ export function loadMcplServers(configPath: string): LoadedServerConfig[] {
44
+ if (!existsSync(configPath)) return [];
45
+
46
+ const raw = readFileSync(configPath, 'utf-8');
47
+ const parsed = JSON.parse(raw) as McplServersFile;
48
+ if (!parsed.mcplServers || typeof parsed.mcplServers !== 'object') return [];
49
+
50
+ const configDir = dirname(resolve(configPath));
51
+ const servers: LoadedServerConfig[] = [];
52
+
53
+ for (const [id, entry] of Object.entries(parsed.mcplServers)) {
54
+ const args = entry.args?.map(arg => {
55
+ // Resolve relative paths (starting with ./ or ../) relative to config dir
56
+ if (arg.startsWith('./') || arg.startsWith('../')) {
57
+ return resolve(configDir, arg);
58
+ }
59
+ return arg;
60
+ });
61
+
62
+ servers.push({
63
+ id,
64
+ command: entry.command,
65
+ args,
66
+ env: entry.env,
67
+ toolPrefix: entry.toolPrefix,
68
+ reconnect: entry.reconnect,
69
+ reconnectIntervalMs: entry.reconnectIntervalMs,
70
+ reconnectMaxIntervalMs: entry.reconnectMaxIntervalMs,
71
+ enabledFeatureSets: entry.enabledFeatureSets,
72
+ disabledFeatureSets: entry.disabledFeatureSets,
73
+ enabledTools: entry.enabledTools,
74
+ disabledTools: entry.disabledTools,
75
+ channelSubscription: entry.channelSubscription,
76
+ });
77
+ }
78
+
79
+ return servers;
80
+ }
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // Agent overlay — servers the agent deployed/unloaded for itself at runtime
84
+ // ---------------------------------------------------------------------------
85
+
86
+ /** Default agent-owned overlay path, resolved from cwd (per-agent deploy dir). */
87
+ export const DEFAULT_AGENT_OVERLAY_PATH = resolve(process.cwd(), 'mcpl-servers.agent.json');
88
+
89
+ /**
90
+ * An overlay entry is either a full server definition (agent-deployed, loads
91
+ * unconditionally — no recipe opt-in needed) or a tombstone `{disabled: true}`
92
+ * that suppresses a recipe/file server the agent unloaded.
93
+ *
94
+ * Unlike `ServerFileEntry`, `command` is optional here: an entry has EITHER
95
+ * a `command` (stdio) or a `url` (WebSocket), and tombstones have neither.
96
+ */
97
+ export interface AgentOverlayEntry extends Partial<ServerFileEntry> {
98
+ /** WebSocket URL (WebSocket transport). Mutually exclusive with command. */
99
+ url?: string;
100
+ transport?: 'stdio' | 'websocket';
101
+ /** Bearer token for WebSocket auth. */
102
+ token?: string;
103
+ /** Tombstone: suppress a recipe/file server the agent unloaded. */
104
+ disabled?: boolean;
105
+ }
106
+
107
+ export interface AgentOverlayFile {
108
+ mcplServers: Record<string, AgentOverlayEntry>;
109
+ }
110
+
111
+ /** Read the agent overlay file. Returns empty object if it doesn't exist. */
112
+ export function readAgentOverlay(overlayPath: string): Record<string, AgentOverlayEntry> {
113
+ if (!existsSync(overlayPath)) return {};
114
+ const raw = readFileSync(overlayPath, 'utf-8');
115
+ const parsed = JSON.parse(raw) as AgentOverlayFile;
116
+ return parsed.mcplServers ?? {};
117
+ }
118
+
119
+ /** Write the agent overlay file. */
120
+ export function saveAgentOverlay(
121
+ overlayPath: string,
122
+ servers: Record<string, AgentOverlayEntry>,
123
+ ): void {
124
+ const data: AgentOverlayFile = { mcplServers: servers };
125
+ writeFileSync(overlayPath, JSON.stringify(data, null, 2) + '\n', 'utf-8');
126
+ }
127
+
128
+ /**
129
+ * Resolve an overlay entry into a server config object (id + fields, relative
130
+ * `./`/`../` args resolved against the overlay file's directory). Returns
131
+ * null for tombstones and entries with neither command nor url.
132
+ */
133
+ export function resolveOverlayEntry(
134
+ id: string,
135
+ entry: AgentOverlayEntry,
136
+ overlayPath: string,
137
+ ): ({ id: string; command?: string; url?: string } & Record<string, unknown>) | null {
138
+ if (entry.disabled) return null;
139
+ if (!entry.command && !entry.url) return null;
140
+ const overlayDir = dirname(resolve(overlayPath));
141
+ const { disabled: _d, ...fields } = entry;
142
+ return {
143
+ id,
144
+ ...fields,
145
+ ...(entry.args
146
+ ? {
147
+ args: entry.args.map(arg =>
148
+ arg.startsWith('./') || arg.startsWith('../') ? resolve(overlayDir, arg) : arg,
149
+ ),
150
+ }
151
+ : {}),
152
+ };
153
+ }
154
+
155
+ /**
156
+ * Apply the agent overlay to a resolved server list:
157
+ * - tombstones (`disabled: true`) remove the matching server
158
+ * - full entries replace an existing server or append a new one
159
+ * Relative `./`/`../` args are resolved against the overlay file's directory.
160
+ */
161
+ export function applyAgentOverlay<T extends { id: string }>(
162
+ servers: T[],
163
+ overlayPath: string,
164
+ ): Array<T | ({ id: string } & Record<string, unknown>)> {
165
+ const overlay = readAgentOverlay(overlayPath);
166
+ if (Object.keys(overlay).length === 0) return servers;
167
+
168
+ const result: Array<T | ({ id: string } & Record<string, unknown>)> =
169
+ servers.filter(s => overlay[s.id]?.disabled !== true);
170
+
171
+ for (const [id, entry] of Object.entries(overlay)) {
172
+ const loaded = resolveOverlayEntry(id, entry, overlayPath);
173
+ if (!loaded) continue;
174
+ const idx = result.findIndex(s => s.id === id);
175
+ if (idx >= 0) result[idx] = loaded;
176
+ else result.push(loaded);
177
+ }
178
+
179
+ return result;
180
+ }
181
+
182
+ /**
183
+ * Read the raw server entries from the config file (for editing).
184
+ * Returns empty object if file doesn't exist.
185
+ */
186
+ export function readMcplServersFile(configPath: string): Record<string, ServerFileEntry> {
187
+ if (!existsSync(configPath)) return {};
188
+ const raw = readFileSync(configPath, 'utf-8');
189
+ const parsed = JSON.parse(raw) as McplServersFile;
190
+ return parsed.mcplServers ?? {};
191
+ }
192
+
193
+ /**
194
+ * Write server entries to the config file.
195
+ */
196
+ export function saveMcplServers(configPath: string, servers: Record<string, ServerFileEntry>): void {
197
+ const data: McplServersFile = { mcplServers: servers };
198
+ writeFileSync(configPath, JSON.stringify(data, null, 2) + '\n', 'utf-8');
199
+ }
@@ -0,0 +1,270 @@
1
+ /**
2
+ * ActivityModule — surfaces agent composition activity (typing indicators)
3
+ * to a configurable set of MCPL channels while inference is active.
4
+ *
5
+ * Recipe-seeded initial channels; agent can add/remove at runtime via tools.
6
+ * State persists via Chronicle. Typing refresh is owned by the framework's
7
+ * ChannelRegistry (~7s interval, matches Discord/Zulip expiry).
8
+ */
9
+
10
+ import type {
11
+ AgentFramework,
12
+ Module,
13
+ ModuleContext,
14
+ ProcessEvent,
15
+ ProcessState,
16
+ EventResponse,
17
+ ToolDefinition,
18
+ ToolCall,
19
+ ToolResult,
20
+ TraceEvent,
21
+ WorkspaceModule,
22
+ } from '@animalabs/agent-framework';
23
+ import { open } from 'node:fs/promises';
24
+
25
+ export interface ActivityModuleConfig {
26
+ initialChannels?: string[];
27
+ }
28
+
29
+ interface ActivityState {
30
+ channels: string[];
31
+ }
32
+
33
+ /**
34
+ * Parse a loose origin hint out of a file's top lines. Used when an inference
35
+ * is triggered by a workspace file rather than an incoming channel message:
36
+ * if the file's head declares the conversation it's replying to, we route the
37
+ * typing indicator to that topic. Returns null if no hint is found.
38
+ *
39
+ * Recognized shapes:
40
+ * - `origin: zulip#<channel>#<topic>`
41
+ * - `reply-to: zulip:<channel>/<topic>`
42
+ * - YAML frontmatter (delimited by leading `---` ... `---`) containing both
43
+ * `channel: <name>` and `topic: <name>` — matches the clerk's existing
44
+ * knowledge-request ticket convention. Loose `channel:`/`topic:` lines
45
+ * outside a frontmatter block are ignored to avoid mis-routing on prose
46
+ * files (e.g. README headings, mined artifacts quoting Zulip metadata).
47
+ *
48
+ * All platform hints currently resolve to `zulip:<channel>` — extend here when
49
+ * another MCPL server wants in on origin-routing.
50
+ */
51
+ function parseOriginHint(text: string): { channelId: string; topic: string } | null {
52
+ const compact = text.match(/origin:\s*zulip#([^\s#]+)#(\S+)/i);
53
+ if (compact) return { channelId: `zulip:${compact[1]}`, topic: compact[2] };
54
+
55
+ const replyTo = text.match(/reply-to:\s*zulip:([^\s/]+)\/(\S+)/i);
56
+ if (replyTo) return { channelId: `zulip:${replyTo[1]}`, topic: replyTo[2] };
57
+
58
+ const frontmatter = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
59
+ if (frontmatter) {
60
+ const block = frontmatter[1];
61
+ const channelMatch = block.match(/^channel:\s*(\S+)\s*$/m);
62
+ const topicMatch = block.match(/^topic:\s*(.+?)\s*$/m);
63
+ if (channelMatch && topicMatch) {
64
+ return { channelId: `zulip:${channelMatch[1]}`, topic: topicMatch[1] };
65
+ }
66
+ }
67
+
68
+ return null;
69
+ }
70
+
71
+ export class ActivityModule implements Module {
72
+ readonly name = 'activity';
73
+
74
+ private ctx: ModuleContext | null = null;
75
+ private framework: AgentFramework | null = null;
76
+ private channels = new Set<string>();
77
+ private typingActive = false;
78
+
79
+ /** Most recent incoming-message metadata per channel. Handed back to the
80
+ * originating server on each typing notification so routing hints (e.g.
81
+ * Zulip topic) land where the conversation is active. */
82
+ private lastMetadata = new Map<string, Record<string, unknown>>();
83
+
84
+ constructor(private readonly config: ActivityModuleConfig = {}) {}
85
+
86
+ async start(ctx: ModuleContext): Promise<void> {
87
+ this.ctx = ctx;
88
+
89
+ const saved = ctx.getState<ActivityState>();
90
+ if (saved?.channels) {
91
+ this.channels = new Set(saved.channels);
92
+ } else if (this.config.initialChannels && this.config.initialChannels.length > 0) {
93
+ this.channels = new Set(this.config.initialChannels);
94
+ this.persist();
95
+ }
96
+ }
97
+
98
+ async stop(): Promise<void> {
99
+ this.stopAllTyping();
100
+ this.ctx = null;
101
+ this.framework = null;
102
+ }
103
+
104
+ /** Called from the host after framework creation, like SubagentModule. */
105
+ setFramework(framework: AgentFramework): void {
106
+ this.framework = framework;
107
+ framework.onTrace((event: TraceEvent) => {
108
+ if (event.type === 'inference:started') this.onInferenceStarted();
109
+ else if (event.type === 'inference:completed') this.onInferenceCompleted();
110
+ });
111
+ }
112
+
113
+ getTools(): ToolDefinition[] {
114
+ return [
115
+ {
116
+ name: 'show_in',
117
+ description:
118
+ 'Subscribe a channel to your composition-activity indicator (e.g. Zulip "is typing"). ' +
119
+ 'This is a SET-AND-FORGET POLICY — call it once to opt a channel in, and the host will ' +
120
+ 'automatically start the indicator before each inference and stop it when the inference ' +
121
+ 'ends. You do NOT need to call this before each reply. Do NOT bracket messages with ' +
122
+ 'show_in / send / hide_in — that pattern produces confusing UX because message sends ' +
123
+ 'do not clear Zulip typing indicators, so you end up flashing your own indicator on and ' +
124
+ 'off unnecessarily. Use hide_in only if you want to permanently stop indicating in that ' +
125
+ 'channel. Channel IDs use the MCPL format, e.g. "zulip:tracker-miner-f". Idempotent.',
126
+ inputSchema: {
127
+ type: 'object',
128
+ properties: {
129
+ channel: { type: 'string', description: 'MCPL channel id' },
130
+ },
131
+ required: ['channel'],
132
+ },
133
+ },
134
+ {
135
+ name: 'hide_in',
136
+ description:
137
+ 'Unsubscribe a channel from your composition-activity indicator. Use this ONLY when you ' +
138
+ 'want to PERMANENTLY stop surfacing your activity in that channel (e.g. if a user asks ' +
139
+ 'you to, or you decide the channel should no longer see your thinking). Do NOT call this ' +
140
+ 'after sending a message in a normal reply flow — the host already stops the indicator ' +
141
+ "when inference completes. Doesn't affect other channels. Idempotent.",
142
+ inputSchema: {
143
+ type: 'object',
144
+ properties: {
145
+ channel: { type: 'string', description: 'MCPL channel id' },
146
+ },
147
+ required: ['channel'],
148
+ },
149
+ },
150
+ ];
151
+ }
152
+
153
+ async handleToolCall(call: ToolCall): Promise<ToolResult> {
154
+ const channel = typeof (call.input as { channel?: unknown }).channel === 'string'
155
+ ? ((call.input as { channel: string }).channel)
156
+ : null;
157
+ if (!channel) {
158
+ return { success: false, isError: true, error: 'channel (string) is required' };
159
+ }
160
+
161
+ if (call.name === 'show_in') {
162
+ const added = !this.channels.has(channel);
163
+ this.channels.add(channel);
164
+ this.persist();
165
+ if (this.typingActive) {
166
+ this.framework?.channels?.startTyping(channel, this.lastMetadata.get(channel));
167
+ }
168
+ return {
169
+ success: true,
170
+ data: added
171
+ ? `Now showing composition activity in ${channel}.`
172
+ : `Already showing composition activity in ${channel}.`,
173
+ };
174
+ }
175
+
176
+ if (call.name === 'hide_in') {
177
+ const removed = this.channels.delete(channel);
178
+ this.persist();
179
+ this.framework?.channels?.stopTyping(channel);
180
+ return {
181
+ success: true,
182
+ data: removed
183
+ ? `No longer showing composition activity in ${channel}.`
184
+ : `Was not showing composition activity in ${channel}.`,
185
+ };
186
+ }
187
+
188
+ return { success: false, isError: true, error: `Unknown tool: ${call.name}` };
189
+ }
190
+
191
+ async onProcess(event: ProcessEvent, _state: ProcessState): Promise<EventResponse> {
192
+ // Track the most recent incoming-message metadata per channel so typing
193
+ // notifications can echo it back to the originating server as routing hints.
194
+ if (event.type === 'mcpl:channel-incoming') {
195
+ const e = event as unknown as {
196
+ channelId: string;
197
+ threadId?: string;
198
+ metadata?: Record<string, unknown>;
199
+ };
200
+ const merged: Record<string, unknown> = { ...(e.metadata ?? {}) };
201
+ if (e.threadId !== undefined && merged.threadId === undefined) {
202
+ merged.threadId = e.threadId;
203
+ }
204
+ this.lastMetadata.set(e.channelId, merged);
205
+ }
206
+ // Workspace-triggered inferences have no incoming-message metadata, so
207
+ // peek at the file's head for an origin hint and populate routing metadata
208
+ // if found. Best-effort; failures are silent.
209
+ else if (event.type === 'workspace:created' || event.type === 'workspace:modified') {
210
+ const e = event as unknown as { paths: string[] };
211
+ await this.extractOriginFromFiles(e.paths);
212
+ }
213
+ return {};
214
+ }
215
+
216
+ private async extractOriginFromFiles(mountPaths: string[]): Promise<void> {
217
+ const workspace = this.ctx?.getModule<WorkspaceModule>('workspace');
218
+ if (!workspace) return;
219
+
220
+ for (const mountPath of mountPaths) {
221
+ const abs = workspace.resolveAbsolutePath(mountPath);
222
+ if (!abs) continue;
223
+
224
+ try {
225
+ const fd = await open(abs, 'r');
226
+ try {
227
+ const buf = Buffer.alloc(2048);
228
+ const { bytesRead } = await fd.read(buf, 0, buf.length, 0);
229
+ const head = buf.toString('utf8', 0, bytesRead);
230
+
231
+ const origin = parseOriginHint(head);
232
+ if (origin && this.channels.has(origin.channelId)) {
233
+ // Merge with any existing metadata so other keys (e.g. senderEmail)
234
+ // survive if the origin refresh is subsequent to an incoming message.
235
+ const prior = this.lastMetadata.get(origin.channelId) ?? {};
236
+ this.lastMetadata.set(origin.channelId, { ...prior, topic: origin.topic });
237
+ }
238
+ } finally {
239
+ await fd.close();
240
+ }
241
+ } catch {
242
+ // File unreadable / gone / etc. — silently skip.
243
+ }
244
+ }
245
+ }
246
+
247
+ private onInferenceStarted(): void {
248
+ this.typingActive = true;
249
+ const registry = this.framework?.channels;
250
+ if (!registry) return;
251
+ for (const ch of this.channels) {
252
+ registry.startTyping(ch, this.lastMetadata.get(ch));
253
+ }
254
+ }
255
+
256
+ private onInferenceCompleted(): void {
257
+ this.typingActive = false;
258
+ this.stopAllTyping();
259
+ }
260
+
261
+ private stopAllTyping(): void {
262
+ const registry = this.framework?.channels;
263
+ if (!registry) return;
264
+ for (const ch of this.channels) registry.stopTyping(ch);
265
+ }
266
+
267
+ private persist(): void {
268
+ this.ctx?.setState<ActivityState>({ channels: [...this.channels] });
269
+ }
270
+ }