@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
|
@@ -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
|
+
}
|