@animalabs/connectome-host 0.7.4 → 0.8.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 +12 -5
- package/.github/PULL_REQUEST_TEMPLATE.md +3 -2
- package/.github/workflows/changelog.yml +9 -4
- package/.github/workflows/ci.yml +5 -3
- package/.github/workflows/publish.yml +12 -6
- package/CHANGELOG.md +320 -0
- package/CONTRIBUTING.md +47 -19
- package/README.md +27 -0
- package/bun.lock +26 -32
- package/changelog.d/README.md +28 -0
- package/package.json +6 -6
- package/recipes/SETUP.md +11 -5
- package/recipes/TRIUMVIRATE-SETUP.md +68 -14
- package/recipes/knowledge-miner.json +0 -30
- package/recipes/mock-test.json +19 -0
- package/recipes/triumvirate.json +6 -1
- package/scripts/release-changelog.ts +210 -21
- package/src/cache-keepalive-log.ts +41 -0
- package/src/commands.ts +221 -32
- package/src/framework-agent-config.ts +3 -0
- package/src/framework-strategy.ts +42 -0
- package/src/gate-telemetry.ts +134 -0
- package/src/headless.ts +10 -0
- package/src/index.ts +194 -55
- package/src/mcpl-config.ts +99 -1
- package/src/modules/identity-module.ts +310 -2
- package/src/modules/instructions-module.ts +265 -0
- package/src/modules/mcpl-admin-module.ts +58 -11
- package/src/modules/subagent-module.ts +18 -0
- package/src/modules/web-ui-module.ts +32 -4
- package/src/recipe.ts +821 -25
- package/src/web/panel-data.ts +44 -1
- package/src/workspace-mounts.ts +73 -0
- package/test/audit-module-optins.test.ts +10 -3
- package/test/cache-keepalive-log.test.ts +83 -0
- package/test/commands-qa-family.test.ts +239 -0
- package/test/conversations-recipe.test.ts +142 -0
- package/test/count-tokens-model.test.ts +31 -0
- package/test/framework-fkm-composition.test.ts +35 -3
- package/test/framework-strategy-defaults.test.ts +60 -0
- package/test/gate-telemetry-adapter.test.ts +84 -0
- package/test/gate-telemetry.test.ts +124 -0
- package/test/identity-and-surfaces.test.ts +212 -1
- package/test/instructions-module.test.ts +258 -0
- package/test/mcpl-admin-module.test.ts +41 -0
- package/test/mcpl-agent-overlay.test.ts +51 -3
- package/test/mcpl-child-env.test.ts +64 -0
- package/test/nudge-command.test.ts +47 -0
- package/test/recipe-cache-keepalive.test.ts +59 -0
- package/test/recipe-compression-fallback.test.ts +19 -0
- package/test/recipe-hybrid-prose-routing.test.ts +12 -0
- package/test/recipe-instructions.test.ts +176 -0
- package/test/recipe-kv-unified.test.ts +87 -0
- package/test/recipe-mcp-source.test.ts +54 -0
- package/test/recipe-openai-compatible.test.ts +54 -0
- package/test/recipe-path-resolution.test.ts +19 -8
- package/test/recipe-provider.test.ts +14 -0
- package/test/recipe-save-unresolved.test.ts +244 -0
- package/test/recipe-source-only.test.ts +38 -0
- package/test/release-changelog.test.ts +202 -0
- package/test/subagent-prose-routing.test.ts +109 -0
- package/test/subconscious-recipe.test.ts +86 -0
- package/test/tool-wrapper-prose-guard-recipe.test.ts +37 -0
- package/test/web-ui-module.test.ts +41 -0
- package/test/workspace-mounts.test.ts +68 -0
- package/web/src/App.tsx +10 -0
- package/web/src/Health.tsx +61 -1
package/src/commands.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Commands:
|
|
5
5
|
* /undo — Revert to state before last agent turn
|
|
6
6
|
* /redo — Re-apply last undone action
|
|
7
|
+
* /nudge [agent] — Run inference on current context (no new events)
|
|
7
8
|
* /checkpoint N — Save current state as named checkpoint
|
|
8
9
|
* /restore N — Branch from checkpoint, switch to it
|
|
9
10
|
* /branches — List all Chronicle branches
|
|
@@ -35,6 +36,10 @@ import { type FleetModule, formatChildRow } from './modules/fleet-module.js';
|
|
|
35
36
|
/** Imported lazily to avoid circular deps — index.ts re-exports the type. */
|
|
36
37
|
interface AppContext {
|
|
37
38
|
framework: AgentFramework;
|
|
39
|
+
/** Resolved main-agent name (see index.ts resolveAgentName). Optional
|
|
40
|
+
* because some callers (tui/webui refs) don't thread it; /puppet falls
|
|
41
|
+
* back to the first registered agent, same as getAgentCM. */
|
|
42
|
+
agentName?: string;
|
|
38
43
|
sessionManager: import('./session-manager.js').SessionManager;
|
|
39
44
|
recipe: Recipe;
|
|
40
45
|
branchState: BranchState;
|
|
@@ -116,6 +121,35 @@ function getAgentCM(framework: AgentFramework, agentName?: string): ContextManag
|
|
|
116
121
|
return all[0]?.getContextManager() ?? null;
|
|
117
122
|
}
|
|
118
123
|
|
|
124
|
+
/**
|
|
125
|
+
* Refuse head-moving commands while the main agent's turn is in flight.
|
|
126
|
+
*
|
|
127
|
+
* Moving the Chronicle head (undo/redo/checkout/restore/branchto/newtopic)
|
|
128
|
+
* is not atomic with respect to a streaming generation: the in-flight reply
|
|
129
|
+
* commits onto whatever branch is current WHEN IT COMPLETES, so a head move
|
|
130
|
+
* mid-stream detaches the reply from its request — it lands on the new
|
|
131
|
+
* branch attached to the wrong parent (orphaned node), including when the
|
|
132
|
+
* move is issued from a second client on the same session. The supported
|
|
133
|
+
* sequence is: stop the generation, then move the head.
|
|
134
|
+
*
|
|
135
|
+
* Returns null when the command may proceed. Agents without a state field
|
|
136
|
+
* (stubs, minimal harnesses) are treated as idle.
|
|
137
|
+
*/
|
|
138
|
+
function inFlightGuard(app: AppContext, cmd: string): CommandResult | null {
|
|
139
|
+
const framework = app.framework;
|
|
140
|
+
const agent = (app.agentName ? framework.getAgent(app.agentName) : undefined)
|
|
141
|
+
?? framework.getAllAgents()[0];
|
|
142
|
+
const status = (agent as { state?: { status?: string } } | undefined)?.state?.status;
|
|
143
|
+
if (status === undefined || status === 'idle') return null;
|
|
144
|
+
return {
|
|
145
|
+
lines: [
|
|
146
|
+
{ text: `/${cmd} refused: a turn is in flight (${(agent as { name?: string }).name ?? 'agent'}: ${status}).`, style: 'system' },
|
|
147
|
+
{ text: ' Moving the head mid-generation would attach the streaming reply to the wrong', style: 'system' },
|
|
148
|
+
{ text: ' branch/request. Stop the generation first, then retry.', style: 'system' },
|
|
149
|
+
],
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
119
153
|
export function handleCommand(command: string, app: AppContext): CommandResult {
|
|
120
154
|
const parts = command.slice(1).split(/\s+/);
|
|
121
155
|
const cmd = parts[0]!;
|
|
@@ -142,14 +176,16 @@ export function handleCommand(command: string, app: AppContext): CommandResult {
|
|
|
142
176
|
{ text: '--- Commands ---', style: 'system' },
|
|
143
177
|
{ text: ' /quit, /q Exit the app', style: 'system' },
|
|
144
178
|
{ text: ' /status Show agent status', style: 'system' },
|
|
145
|
-
{ text: ' /clear Clear
|
|
179
|
+
{ text: ' /clear Clear this client\'s display (history/context are kept)', style: 'system' },
|
|
146
180
|
{ text: ' /lessons Show lesson library', style: 'system' },
|
|
147
181
|
{ text: ' /export Export lessons to ./output/ (JSON + markdown)', style: 'system' },
|
|
148
182
|
{ text: ' /undo Revert last agent turn', style: 'system' },
|
|
149
183
|
{ text: ' /redo Re-apply undone action', style: 'system' },
|
|
150
|
-
{ text: ' /
|
|
151
|
-
{ text: ' /
|
|
152
|
-
{ text: ' /
|
|
184
|
+
{ text: ' /nudge [agent] Run inference on current context (no new events)', style: 'system' },
|
|
185
|
+
{ text: ' /puppet <tool> [json] Admin: execute a tool AS the agent, store the pair', style: 'system' },
|
|
186
|
+
{ text: ' /checkpoint [name] Save current state (no name: list checkpoints)', style: 'system' },
|
|
187
|
+
{ text: ' /restore [name] Restore to checkpoint (no name: list checkpoints)', style: 'system' },
|
|
188
|
+
{ text: ' /branches List Chronicle branches and checkpoints', style: 'system' },
|
|
153
189
|
{ text: ' /checkout <name> Switch to branch', style: 'system' },
|
|
154
190
|
{ text: ' /history [n] Show state transitions (last n)', style: 'system' },
|
|
155
191
|
{ text: ' /find <text> Search messages for text', style: 'system' },
|
|
@@ -163,9 +199,9 @@ export function handleCommand(command: string, app: AppContext): CommandResult {
|
|
|
163
199
|
{ text: ' /session Show current session', style: 'system' },
|
|
164
200
|
{ text: ' /session list List all sessions', style: 'system' },
|
|
165
201
|
{ text: ' /session new [name] Create new session', style: 'system' },
|
|
166
|
-
{ text: ' /session switch <name> Switch to session', style: 'system' },
|
|
202
|
+
{ text: ' /session switch <name or id> Switch to session', style: 'system' },
|
|
167
203
|
{ text: ' /session rename <name> Rename current session', style: 'system' },
|
|
168
|
-
{ text: ' /session delete <name> Delete a session', style: 'system' },
|
|
204
|
+
{ text: ' /session delete <name or id> --confirm Delete a session (irreversible)', style: 'system' },
|
|
169
205
|
{ text: ' /recipe Show current recipe info', style: 'system' },
|
|
170
206
|
{ text: ' /newtopic [context] Reset head window (auto-summarize if empty)', style: 'system' },
|
|
171
207
|
{ text: ' /usage Show session token usage and costs', style: 'system' },
|
|
@@ -175,7 +211,12 @@ export function handleCommand(command: string, app: AppContext): CommandResult {
|
|
|
175
211
|
};
|
|
176
212
|
|
|
177
213
|
case 'clear':
|
|
178
|
-
|
|
214
|
+
// Display-clearing is client-side: the TUI wipes its scrollback and
|
|
215
|
+
// the SPA wipes its transcript view before this handler is ever
|
|
216
|
+
// reached. This line is only seen by surfaces with no display to
|
|
217
|
+
// clear (headless), where it honestly reports that nothing else —
|
|
218
|
+
// Chronicle, context — was touched.
|
|
219
|
+
return { lines: [{ text: '(display cleared on clients; history and context are kept)', style: 'system' }] };
|
|
179
220
|
|
|
180
221
|
case 'status':
|
|
181
222
|
return handleStatus(framework);
|
|
@@ -187,28 +228,38 @@ export function handleCommand(command: string, app: AppContext): CommandResult {
|
|
|
187
228
|
return handleExport(app);
|
|
188
229
|
|
|
189
230
|
case 'undo':
|
|
190
|
-
return handleUndo(app);
|
|
231
|
+
return inFlightGuard(app, cmd) ?? handleUndo(app);
|
|
232
|
+
|
|
233
|
+
case 'nudge':
|
|
234
|
+
return handleNudge(app, args[0]);
|
|
235
|
+
|
|
236
|
+
case 'puppet':
|
|
237
|
+
return handlePuppet(app, args);
|
|
191
238
|
|
|
192
239
|
case 'redo':
|
|
193
|
-
return handleRedo(app);
|
|
240
|
+
return inFlightGuard(app, cmd) ?? handleRedo(app);
|
|
194
241
|
|
|
242
|
+
// Name-taking commands join the REST of the line, not just the first
|
|
243
|
+
// token: names may contain spaces (/checkpoint my test point), and
|
|
244
|
+
// /session rename already accepts multi-word names — parsing them
|
|
245
|
+
// differently made multi-word names silently truncate here.
|
|
195
246
|
case 'checkpoint':
|
|
196
|
-
return handleCheckpoint(app, args
|
|
247
|
+
return handleCheckpoint(app, args.join(' ') || undefined);
|
|
197
248
|
|
|
198
249
|
case 'restore':
|
|
199
|
-
return handleRestore(app, args
|
|
250
|
+
return inFlightGuard(app, cmd) ?? handleRestore(app, args.join(' ') || undefined);
|
|
200
251
|
|
|
201
252
|
case 'branches':
|
|
202
|
-
return handleBranches(
|
|
253
|
+
return handleBranches(app);
|
|
203
254
|
|
|
204
255
|
case 'checkout':
|
|
205
|
-
return handleCheckout(framework, args
|
|
256
|
+
return inFlightGuard(app, cmd) ?? handleCheckout(framework, args.join(' ') || undefined);
|
|
206
257
|
|
|
207
258
|
case 'history':
|
|
208
259
|
return handleHistory(framework, args[0]);
|
|
209
260
|
|
|
210
261
|
case 'branchto':
|
|
211
|
-
return handleBranchTo(app, args[0]);
|
|
262
|
+
return inFlightGuard(app, cmd) ?? handleBranchTo(app, args[0]);
|
|
212
263
|
|
|
213
264
|
case 'find':
|
|
214
265
|
return handleFind(framework, args.join(' '));
|
|
@@ -229,7 +280,7 @@ export function handleCommand(command: string, app: AppContext): CommandResult {
|
|
|
229
280
|
return handleRecipe(app);
|
|
230
281
|
|
|
231
282
|
case 'newtopic':
|
|
232
|
-
return handleNewTopic(app, args);
|
|
283
|
+
return inFlightGuard(app, cmd) ?? handleNewTopic(app, args);
|
|
233
284
|
|
|
234
285
|
case 'usage':
|
|
235
286
|
return handleUsage(app);
|
|
@@ -297,12 +348,14 @@ function handleSession(app: AppContext, args: string[]): CommandResult {
|
|
|
297
348
|
return handleSessionNew(app, args.slice(1).join(' ') || undefined);
|
|
298
349
|
case 'switch':
|
|
299
350
|
case 'sw':
|
|
300
|
-
|
|
351
|
+
// Rest-of-line, matching rename: session names may contain spaces, and
|
|
352
|
+
// a session renamed to a multi-word name must stay reachable by name.
|
|
353
|
+
return handleSessionSwitch(app, args.slice(1).join(' ') || undefined);
|
|
301
354
|
case 'rename':
|
|
302
355
|
return handleSessionRename(app, args.slice(1).join(' ') || undefined);
|
|
303
356
|
case 'delete':
|
|
304
357
|
case 'rm':
|
|
305
|
-
return handleSessionDelete(app, args
|
|
358
|
+
return handleSessionDelete(app, args.slice(1));
|
|
306
359
|
default:
|
|
307
360
|
return { lines: [{ text: `Unknown /session subcommand: ${sub}. Try /session list.`, style: 'system' }] };
|
|
308
361
|
}
|
|
@@ -398,9 +451,17 @@ function handleSessionRename(app: AppContext, name?: string): CommandResult {
|
|
|
398
451
|
return { lines: [{ text: `Session renamed to "${name}".`, style: 'system' }] };
|
|
399
452
|
}
|
|
400
453
|
|
|
401
|
-
function handleSessionDelete(app: AppContext,
|
|
454
|
+
function handleSessionDelete(app: AppContext, args: string[]): CommandResult {
|
|
455
|
+
// Deletion is irreversible, so it takes an explicit second step: the bare
|
|
456
|
+
// command shows exactly what matched (name + id + message count) and asks
|
|
457
|
+
// for --confirm. This also defuses the truncated-name amplifier: a typo'd
|
|
458
|
+
// or partial name can match a DIFFERENT session, and without the echo the
|
|
459
|
+
// wrong one died silently.
|
|
460
|
+
const confirmed = args[args.length - 1] === '--confirm';
|
|
461
|
+
const nameOrId = (confirmed ? args.slice(0, -1) : args).join(' ') || undefined;
|
|
462
|
+
|
|
402
463
|
if (!nameOrId) {
|
|
403
|
-
return { lines: [{ text: 'Usage: /session delete <name or id>', style: 'system' }] };
|
|
464
|
+
return { lines: [{ text: 'Usage: /session delete <name or id> [--confirm]', style: 'system' }] };
|
|
404
465
|
}
|
|
405
466
|
|
|
406
467
|
const session = app.sessionManager.findSession(nameOrId);
|
|
@@ -408,6 +469,16 @@ function handleSessionDelete(app: AppContext, nameOrId?: string): CommandResult
|
|
|
408
469
|
return { lines: [{ text: `Session "${nameOrId}" not found.`, style: 'system' }] };
|
|
409
470
|
}
|
|
410
471
|
|
|
472
|
+
if (!confirmed) {
|
|
473
|
+
const msgs = session.messageCount !== undefined ? `, ${session.messageCount} msgs` : '';
|
|
474
|
+
return {
|
|
475
|
+
lines: [
|
|
476
|
+
{ text: `Will delete session "${session.name}" [${session.id}]${msgs} — irreversible.`, style: 'system' },
|
|
477
|
+
{ text: `To proceed: /session delete ${session.id} --confirm`, style: 'system' },
|
|
478
|
+
],
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
411
482
|
try {
|
|
412
483
|
app.sessionManager.deleteSession(session.id);
|
|
413
484
|
return { lines: [{ text: `Deleted session "${session.name}" [${session.id}].`, style: 'system' }] };
|
|
@@ -513,14 +584,16 @@ function handleBudget(framework: AgentFramework, arg?: string): CommandResult {
|
|
|
513
584
|
}
|
|
514
585
|
|
|
515
586
|
if (!arg) {
|
|
516
|
-
// Show current budgets
|
|
587
|
+
// Show current budgets. fmtTokens is exact below 1000 — flooring to "0k"
|
|
588
|
+
// hid real small values and made the display contradict the validator
|
|
589
|
+
// (which rejects 0 but accepts 50).
|
|
517
590
|
const lines: Line[] = [{ text: '--- Stream Token Budgets ---', style: 'system' }];
|
|
518
591
|
for (const agent of agents) {
|
|
519
592
|
const budget = agent.maxStreamTokens;
|
|
520
593
|
const last = agent.lastStreamInputTokens;
|
|
521
594
|
const pct = budget > 0 ? ((last / budget) * 100).toFixed(0) : '—';
|
|
522
595
|
lines.push({
|
|
523
|
-
text: ` ${agent.name}: ${(budget
|
|
596
|
+
text: ` ${agent.name}: ${fmtTokens(budget)} (last: ${fmtTokens(last)}, ${pct}%)`,
|
|
524
597
|
style: 'system',
|
|
525
598
|
});
|
|
526
599
|
}
|
|
@@ -546,12 +619,8 @@ function handleBudget(framework: AgentFramework, arg?: string): CommandResult {
|
|
|
546
619
|
agent.maxStreamTokens = tokens;
|
|
547
620
|
}
|
|
548
621
|
|
|
549
|
-
const display = tokens >= 1_000_000
|
|
550
|
-
? `${(tokens / 1_000_000).toFixed(1)}m`
|
|
551
|
-
: `${(tokens / 1_000).toFixed(0)}k`;
|
|
552
|
-
|
|
553
622
|
return {
|
|
554
|
-
lines: [{ text: `Stream budget set to ${
|
|
623
|
+
lines: [{ text: `Stream budget set to ${fmtTokens(tokens)} tokens for all agents.`, style: 'system' }],
|
|
555
624
|
};
|
|
556
625
|
}
|
|
557
626
|
|
|
@@ -651,6 +720,89 @@ export function handleExport(app: AppContext): CommandResult {
|
|
|
651
720
|
};
|
|
652
721
|
}
|
|
653
722
|
|
|
723
|
+
/**
|
|
724
|
+
* /nudge [agent] — admin-level: queue an inference turn on the agent's
|
|
725
|
+
* CURRENT context without adding any message or event (framework
|
|
726
|
+
* `nudgeAgent`). The zero-pollution complement to /undo: rewind, then nudge,
|
|
727
|
+
* and the agent takes another swing at exactly what it already sees.
|
|
728
|
+
*/
|
|
729
|
+
/**
|
|
730
|
+
* /puppet <toolName> [json-input] — admin: execute one tool AS the main
|
|
731
|
+
* agent and store the tool_use + tool_result pair in its window, exactly as
|
|
732
|
+
* a model-initiated call (Framework.puppetToolCall). The call runs for real.
|
|
733
|
+
* Refused unless the agent is idle and the tool is on its surface. Does not
|
|
734
|
+
* wake the agent. Born from the princess exemplar surgery (2026-08-23):
|
|
735
|
+
* one first-person pair restores a capacity the model can't find on its own
|
|
736
|
+
* — older models especially. Disclosure to the resident is the operator's
|
|
737
|
+
* call; the precedent was disclosed first.
|
|
738
|
+
*/
|
|
739
|
+
function handlePuppet(app: AppContext, args: string[]): CommandResult {
|
|
740
|
+
const toolName = args[0];
|
|
741
|
+
if (!toolName) {
|
|
742
|
+
return {
|
|
743
|
+
lines: [
|
|
744
|
+
{ text: 'Usage: /puppet <toolName> [json-input]', style: 'system' },
|
|
745
|
+
{ text: ' Executes the tool AS the agent (for real) and stores the', style: 'system' },
|
|
746
|
+
{ text: ' tool_use + tool_result pair in its window. Requires idle.', style: 'system' },
|
|
747
|
+
],
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
const rawInput = args.slice(1).join(' ').trim();
|
|
751
|
+
let input: Record<string, unknown> = {};
|
|
752
|
+
if (rawInput) {
|
|
753
|
+
try {
|
|
754
|
+
const parsed = JSON.parse(rawInput);
|
|
755
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
756
|
+
return { lines: [{ text: 'puppet: input must be a JSON object', style: 'system' }] };
|
|
757
|
+
}
|
|
758
|
+
input = parsed;
|
|
759
|
+
} catch (e) {
|
|
760
|
+
return { lines: [{ text: `puppet: bad JSON input: ${e instanceof Error ? e.message : e}`, style: 'system' }] };
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
const agentName = app.agentName ?? app.framework.getAllAgents()[0]?.name;
|
|
764
|
+
if (!agentName) {
|
|
765
|
+
return { lines: [{ text: 'puppet: no registered agent', style: 'system' }] };
|
|
766
|
+
}
|
|
767
|
+
const asyncWork = (async (): Promise<CommandResult> => {
|
|
768
|
+
try {
|
|
769
|
+
const { toolUseId, result } = await app.framework.puppetToolCall(agentName, toolName, input);
|
|
770
|
+
const preview = result.isError
|
|
771
|
+
? `ERROR: ${result.error ?? 'unknown'}`
|
|
772
|
+
: String(typeof result.data === 'string' ? result.data : JSON.stringify(result.data) ?? '').slice(0, 300);
|
|
773
|
+
return {
|
|
774
|
+
lines: [
|
|
775
|
+
{ text: `puppet ${agentName}: ${toolName} → ${result.isError ? 'error' : 'ok'} (${toolUseId})`, style: 'system' },
|
|
776
|
+
{ text: ` stored tool_use + tool_result in ${agentName}'s window (no wake).`, style: 'system' },
|
|
777
|
+
{ text: ` result: ${preview.replace(/\n/g, ' ')}`, style: 'system' },
|
|
778
|
+
],
|
|
779
|
+
};
|
|
780
|
+
} catch (e) {
|
|
781
|
+
return { lines: [{ text: `puppet failed: ${e instanceof Error ? e.message : e}`, style: 'system' }] };
|
|
782
|
+
}
|
|
783
|
+
})();
|
|
784
|
+
return {
|
|
785
|
+
lines: [{ text: `puppet: executing ${toolName} as ${agentName}...`, style: 'system' }],
|
|
786
|
+
asyncWork,
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
function handleNudge(app: AppContext, agentName?: string): CommandResult {
|
|
791
|
+
const r = app.framework.nudgeAgent(agentName, 'host-console');
|
|
792
|
+
if (!r.ok) {
|
|
793
|
+
return { lines: [{ text: `Nudge failed: ${r.error}`, style: 'system' }] };
|
|
794
|
+
}
|
|
795
|
+
const when = r.agentStatus === 'idle'
|
|
796
|
+
? 'running now'
|
|
797
|
+
: `queued — runs when current turn settles (agent is ${r.agentStatus})`;
|
|
798
|
+
return {
|
|
799
|
+
lines: [{
|
|
800
|
+
text: `Nudged ${r.agentName}: inference on current context, no new events (${when}).`,
|
|
801
|
+
style: 'system',
|
|
802
|
+
}],
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
|
|
654
806
|
function handleUndo(app: AppContext): CommandResult {
|
|
655
807
|
const { framework, branchState: bs } = app;
|
|
656
808
|
const cm = getAgentCM(framework);
|
|
@@ -742,7 +894,17 @@ function handleRedo(app: AppContext): CommandResult {
|
|
|
742
894
|
|
|
743
895
|
function handleCheckpoint(app: AppContext, name?: string): CommandResult {
|
|
744
896
|
if (!name) {
|
|
745
|
-
|
|
897
|
+
// Bare /checkpoint lists what exists — same affordance as bare /restore —
|
|
898
|
+
// so the lifecycle is discoverable from either end.
|
|
899
|
+
const names = [...app.branchState.checkpoints.keys()];
|
|
900
|
+
return {
|
|
901
|
+
lines: [
|
|
902
|
+
{ text: 'Usage: /checkpoint <name>', style: 'system' },
|
|
903
|
+
...(names.length > 0
|
|
904
|
+
? [{ text: `Saved checkpoints: ${names.join(', ')}`, style: 'system' as const }]
|
|
905
|
+
: []),
|
|
906
|
+
],
|
|
907
|
+
};
|
|
746
908
|
}
|
|
747
909
|
|
|
748
910
|
const cm = getAgentCM(app.framework);
|
|
@@ -838,8 +1000,8 @@ function handleRestore(app: AppContext, name?: string): CommandResult {
|
|
|
838
1000
|
};
|
|
839
1001
|
}
|
|
840
1002
|
|
|
841
|
-
function handleBranches(
|
|
842
|
-
const cm = getAgentCM(framework);
|
|
1003
|
+
function handleBranches(app: AppContext): CommandResult {
|
|
1004
|
+
const cm = getAgentCM(app.framework);
|
|
843
1005
|
if (!cm) return { lines: [{ text: 'No agent context manager.', style: 'system' }] };
|
|
844
1006
|
|
|
845
1007
|
const branches = cm.listBranches();
|
|
@@ -854,6 +1016,19 @@ function handleBranches(framework: AgentFramework): CommandResult {
|
|
|
854
1016
|
});
|
|
855
1017
|
}
|
|
856
1018
|
|
|
1019
|
+
// Checkpoints are positions (branch + message), not branches — but they're
|
|
1020
|
+
// part of the same mental model, and being invisible here made the whole
|
|
1021
|
+
// checkpoint lifecycle run blind: created → not listed anywhere → restored
|
|
1022
|
+
// on faith. Session-scoped, in-memory (cleared on session switch/restart).
|
|
1023
|
+
const cps = [...app.branchState.checkpoints.entries()];
|
|
1024
|
+
if (cps.length > 0) {
|
|
1025
|
+
lines.push({ text: `--- Checkpoints (${cps.length}, this session) ---`, style: 'system' });
|
|
1026
|
+
for (const [name, point] of cps) {
|
|
1027
|
+
const at = point.messageId ? ` @ [${point.messageId}]` : '';
|
|
1028
|
+
lines.push({ text: ` ${name} → ${point.branchName}${at}`, style: 'system' });
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
|
|
857
1032
|
return { lines };
|
|
858
1033
|
}
|
|
859
1034
|
|
|
@@ -1041,13 +1216,27 @@ function handleMcpAdd(args: string[]): CommandResult {
|
|
|
1041
1216
|
|
|
1042
1217
|
const [id, command, ...cmdArgs] = args;
|
|
1043
1218
|
const servers = readMcplServersFile(DEFAULT_CONFIG_PATH);
|
|
1044
|
-
const
|
|
1045
|
-
|
|
1219
|
+
const prev = servers[id!];
|
|
1220
|
+
// Overwrite replaces the command line (command + args, which travel as one
|
|
1221
|
+
// unit) but PRESERVES env and other settings: env is edited via /mcp env
|
|
1222
|
+
// and the panel editor, whose contract is "cleared only by explicit empty
|
|
1223
|
+
// save" — a command update silently dropping tokens/settings broke working
|
|
1224
|
+
// servers in a way that only surfaced at next start.
|
|
1225
|
+
servers[id!] = {
|
|
1226
|
+
...prev,
|
|
1227
|
+
command: command!,
|
|
1228
|
+
...(cmdArgs.length > 0 ? { args: cmdArgs } : {}),
|
|
1229
|
+
};
|
|
1230
|
+
if (cmdArgs.length === 0) delete servers[id!]!.args;
|
|
1046
1231
|
saveMcplServers(DEFAULT_CONFIG_PATH, servers);
|
|
1047
1232
|
|
|
1233
|
+
const keptEnv = prev?.env ? Object.keys(prev.env) : [];
|
|
1048
1234
|
return {
|
|
1049
1235
|
lines: [
|
|
1050
|
-
{ text: `${
|
|
1236
|
+
{ text: `${prev ? 'Updated' : 'Added'} server "${id}". Restart to apply.`, style: 'system' },
|
|
1237
|
+
...(keptEnv.length > 0
|
|
1238
|
+
? [{ text: ` (kept env: ${keptEnv.join(', ')})`, style: 'system' as const }]
|
|
1239
|
+
: []),
|
|
1051
1240
|
],
|
|
1052
1241
|
};
|
|
1053
1242
|
}
|
|
@@ -113,5 +113,8 @@ export function buildFrameworkAgentConfig(
|
|
|
113
113
|
...(recipe.agent.proseRouting !== undefined
|
|
114
114
|
? { proseRouting: recipe.agent.proseRouting }
|
|
115
115
|
: {}),
|
|
116
|
+
...(recipe.agent.toolWrapperProseGuard !== undefined
|
|
117
|
+
? { toolWrapperProseGuard: recipe.agent.toolWrapperProseGuard }
|
|
118
|
+
: {}),
|
|
116
119
|
};
|
|
117
120
|
}
|
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
AutobiographicalStrategy,
|
|
3
3
|
PassthroughStrategy,
|
|
4
4
|
type ContextStrategy,
|
|
5
|
+
type ConversationRouterConfig,
|
|
5
6
|
} from '@animalabs/agent-framework';
|
|
6
7
|
import type { Recipe, RecipeStrategy } from './recipe.js';
|
|
7
8
|
import { FrontdeskStrategy } from './strategies/frontdesk-strategy.js';
|
|
@@ -12,11 +13,22 @@ const PASSTHROUGH_KEYS: ReadonlyArray<keyof RecipeStrategy> = [
|
|
|
12
13
|
'maxSpeculativeL1s',
|
|
13
14
|
'compressionRefusalCurveFallbacks',
|
|
14
15
|
'compressionContextBudgetTokens',
|
|
16
|
+
'compressionSourceOnly',
|
|
17
|
+
'compressionSourceOnlyFallback',
|
|
18
|
+
'compressionMergeSourceOnly',
|
|
19
|
+
'compressionMergeSourceOnlyFallback',
|
|
20
|
+
'compressionSplitFallback',
|
|
21
|
+
'compressionSplitPlaceholder',
|
|
22
|
+
'compressionSplitMaxCallsPerChunk',
|
|
23
|
+
'compressionSplitMaxCallsPer10Min',
|
|
24
|
+
'compressionRecallBudgetTokens',
|
|
15
25
|
'positionedRecallPairs',
|
|
16
26
|
'recallHeaderTemplate',
|
|
17
27
|
'targetChunkTokens',
|
|
18
28
|
'mergeThreshold',
|
|
29
|
+
'mergeMaxSourceSpanMessages',
|
|
19
30
|
'summaryTargetTokens',
|
|
31
|
+
'productionBudgetTokens',
|
|
20
32
|
'l1BudgetTokens',
|
|
21
33
|
'l2BudgetTokens',
|
|
22
34
|
'l3BudgetTokens',
|
|
@@ -28,6 +40,7 @@ const PASSTHROUGH_KEYS: ReadonlyArray<keyof RecipeStrategy> = [
|
|
|
28
40
|
'compressionSlackRatio',
|
|
29
41
|
'overBudgetGraceRatio',
|
|
30
42
|
'foldingStrategy',
|
|
43
|
+
'kvUnified',
|
|
31
44
|
'speculativeProduction',
|
|
32
45
|
'l1HoldbackChunks',
|
|
33
46
|
'summaryParticipant',
|
|
@@ -124,3 +137,32 @@ export function buildFrameworkStrategy(
|
|
|
124
137
|
? new FrontdeskStrategy(autobiographicalOpts)
|
|
125
138
|
: new AutobiographicalStrategy(autobiographicalOpts);
|
|
126
139
|
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Map a recipe's `conversations` block to the framework's
|
|
143
|
+
* ConversationRouterConfig. The host supplies the two fields a recipe
|
|
144
|
+
* cannot: `templateAgent` is the recipe's own (sole) agent, and
|
|
145
|
+
* `strategyFactory` builds a FRESH instance of the recipe's configured
|
|
146
|
+
* strategy per fork — strategy instances are stateful and must never be
|
|
147
|
+
* shared between ContextManagers (without a factory the framework would
|
|
148
|
+
* silently give forks passthrough, i.e. no compression).
|
|
149
|
+
*/
|
|
150
|
+
export function buildConversationsConfig(
|
|
151
|
+
recipe: Recipe,
|
|
152
|
+
agentName: string,
|
|
153
|
+
model: string,
|
|
154
|
+
timeZone: string,
|
|
155
|
+
extensions?: ExtensionRegistry,
|
|
156
|
+
): ConversationRouterConfig | undefined {
|
|
157
|
+
const conv = recipe.conversations;
|
|
158
|
+
if (!conv) return undefined;
|
|
159
|
+
return {
|
|
160
|
+
templateAgent: agentName,
|
|
161
|
+
...(conv.bind !== undefined ? { bind: conv.bind } : {}),
|
|
162
|
+
...(conv.trigger !== undefined ? { trigger: conv.trigger } : {}),
|
|
163
|
+
...(conv.idleTtlMs !== undefined ? { idleTtlMs: conv.idleTtlMs } : {}),
|
|
164
|
+
...(conv.closurePrompt !== undefined ? { closurePrompt: conv.closurePrompt } : {}),
|
|
165
|
+
...(conv.agentPrefix !== undefined ? { agentPrefix: conv.agentPrefix } : {}),
|
|
166
|
+
strategyFactory: () => buildFrameworkStrategy(recipe, model, timeZone, extensions),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Household-gateway telemetry headers (x-gate-* stamps).
|
|
3
|
+
*
|
|
4
|
+
* The data boundary these headers exist under: a household inference gateway
|
|
5
|
+
* records them into its ledger and STRIPS them before the vendor — the vendor
|
|
6
|
+
* must never see them. That boundary is only real if the host refuses to
|
|
7
|
+
* attach the stamps anywhere else, so attachment is double-gated:
|
|
8
|
+
*
|
|
9
|
+
* 1. `GATE_TELEMETRY=1` — the operator's explicit declaration that the
|
|
10
|
+
* configured base URL is such a gateway. Absent/false ⇒ never attach.
|
|
11
|
+
* 2. `ANTHROPIC_BASE_URL` actually set — the flag alone must not stamp
|
|
12
|
+
* traffic that would go to the vendor's default endpoint.
|
|
13
|
+
*
|
|
14
|
+
* Fail-closed on both (review finding on the first wiring: the stamp was
|
|
15
|
+
* attached unconditionally, so with no base URL configured the value went
|
|
16
|
+
* straight to the vendor).
|
|
17
|
+
*
|
|
18
|
+
* Two stamps ride the same hook:
|
|
19
|
+
*
|
|
20
|
+
* x-gate-debt-chunks compression debt at request build (every lane — the
|
|
21
|
+
* aux lane is where the debt series is most telling)
|
|
22
|
+
* x-gate-origin WHY the turn fired: heartbeat | event | mail |
|
|
23
|
+
* operator | <raw reason> — stream lane ONLY
|
|
24
|
+
* x-gate-channel where (adapter-namespaced id) — stream lane ONLY
|
|
25
|
+
* x-gate-counterparty who woke the agent (namespaced id) — stream lane ONLY
|
|
26
|
+
*
|
|
27
|
+
* The origin trio describes the agent's turn; a compression call running in
|
|
28
|
+
* the background is not the turn, so on the 'complete' lane those three are
|
|
29
|
+
* withheld (an older membrane that passes no lane gets them on every call —
|
|
30
|
+
* documented, and the ledger's `streamed` flag lets a reader tell the lanes
|
|
31
|
+
* apart regardless). Values are ids and short class words: never content,
|
|
32
|
+
* never display names.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/** Truthy env-flag parse: unset/''/'0'/'false' (any case) are off. */
|
|
36
|
+
function envFlag(value: string | undefined): boolean {
|
|
37
|
+
return value !== undefined && value !== '' && value !== '0' && value.toLowerCase() !== 'false';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** What the framework knows about the turn in progress (agent-framework InferenceRequest). */
|
|
41
|
+
export interface TurnTrigger {
|
|
42
|
+
reason: string;
|
|
43
|
+
source: string;
|
|
44
|
+
/** Routing locus of the turn (direct channel wakes set it). */
|
|
45
|
+
channelId?: string;
|
|
46
|
+
/** Telemetry-only channel of a gate-batched wake (agent-framework ≥0.14:
|
|
47
|
+
* InferenceRequest.wakeChannelId) — never a locus. */
|
|
48
|
+
wakeChannelId?: string;
|
|
49
|
+
counterparty?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface DynamicHeadersContext {
|
|
53
|
+
lane?: 'stream' | 'complete';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Collapse the framework's free-form reason/source pair into the ledger's
|
|
58
|
+
* origin classes. The raw reason survives when no class fits, clipped and
|
|
59
|
+
* sanitized (ids and words only), so a new event kind shows up as itself
|
|
60
|
+
* instead of vanishing into 'event'.
|
|
61
|
+
*/
|
|
62
|
+
export function originClass(trigger: TurnTrigger): string {
|
|
63
|
+
const r = trigger.reason.toLowerCase();
|
|
64
|
+
const s = trigger.source.toLowerCase();
|
|
65
|
+
if (r.includes('heartbeat') || s.includes('heartbeat')) return 'heartbeat';
|
|
66
|
+
if (r.includes('mail') || s.includes('mail')) return 'mail';
|
|
67
|
+
// channel/push events, directly or batched through the framework's EventGate
|
|
68
|
+
if (r === 'mcpl:channel-incoming' || r === 'mcpl:push-event' || r.startsWith('discord') || r.startsWith('gate:') || s === 'gate') return 'event';
|
|
69
|
+
// a person typing at the host itself: headless IPC, CLI, TUI, web UI, API
|
|
70
|
+
if (r === 'external-message' || ['headless', 'cli', 'tui', 'webui', 'api'].includes(s)) return 'operator';
|
|
71
|
+
if (r.includes('admin') || r.includes('nudge') || r.includes('unstick') || r.includes('operator')) return 'operator';
|
|
72
|
+
return r.replace(/[^a-z0-9:_-]/g, '').slice(0, 40) || 'event';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Header-safe attribute. HTTP header values are ByteStrings: a single
|
|
77
|
+
* non-ASCII code point (an emoji in a channel name, say) makes Fetch throw
|
|
78
|
+
* and would turn telemetry into a failed model request. So the rule is
|
|
79
|
+
* fail-closed on the WHOLE value — visible ASCII (0x20..0x7e) only, clipped
|
|
80
|
+
* to 120 — never character-stripping, which would mint a different id and
|
|
81
|
+
* collide provenance. An unsendable id is simply not sent (null → dropped).
|
|
82
|
+
*/
|
|
83
|
+
function attr(v: string | undefined): string | null {
|
|
84
|
+
if (typeof v !== 'string') return null;
|
|
85
|
+
const t = v.trim();
|
|
86
|
+
if (!t) return null;
|
|
87
|
+
for (let i = 0; i < t.length; i++) {
|
|
88
|
+
const code = t.charCodeAt(i);
|
|
89
|
+
if (code < 0x20 || code > 0x7e) return null;
|
|
90
|
+
}
|
|
91
|
+
return t.slice(0, 120);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Which agent's turn may be stamped onto a request, given that ONE provider
|
|
96
|
+
* adapter — and therefore one header hook — serves every agent in the
|
|
97
|
+
* process (primary, subconscious, forks, ephemerals) and the hook cannot
|
|
98
|
+
* tell whose request it is decorating. Rule: stamp the primary's trigger
|
|
99
|
+
* only while the primary is the ONLY agent with a turn in flight; any other
|
|
100
|
+
* agent mid-turn → withhold (null), never guess. Debt is per agent, not per
|
|
101
|
+
* request, so it may always be read from the primary.
|
|
102
|
+
*/
|
|
103
|
+
export function stampedTrigger(view: {
|
|
104
|
+
agents: string[];
|
|
105
|
+
primary?: string | null;
|
|
106
|
+
triggerOf: (agent: string) => TurnTrigger | null | undefined;
|
|
107
|
+
}): TurnTrigger | null {
|
|
108
|
+
const primary = view.primary ?? (view.agents.length === 1 ? view.agents[0] : undefined);
|
|
109
|
+
if (!primary) return null;
|
|
110
|
+
for (const a of view.agents) {
|
|
111
|
+
if (a !== primary && view.triggerOf(a)) return null;
|
|
112
|
+
}
|
|
113
|
+
const t = view.triggerOf(primary);
|
|
114
|
+
return t ?? null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function gateTelemetryHeaders(
|
|
118
|
+
env: Record<string, string | undefined>,
|
|
119
|
+
pendingDebtChunks: () => number | null,
|
|
120
|
+
activeTrigger: () => TurnTrigger | null = () => null,
|
|
121
|
+
): ((ctx?: DynamicHeadersContext) => Record<string, string | number | null>) | undefined {
|
|
122
|
+
if (!envFlag(env.GATE_TELEMETRY)) return undefined;
|
|
123
|
+
if (!env.ANTHROPIC_BASE_URL) return undefined;
|
|
124
|
+
return (ctx?: DynamicHeadersContext) => {
|
|
125
|
+
const out: Record<string, string | number | null> = { 'x-gate-debt-chunks': pendingDebtChunks() };
|
|
126
|
+
if (ctx?.lane === 'complete') return out;
|
|
127
|
+
const t = activeTrigger();
|
|
128
|
+
if (!t) return out;
|
|
129
|
+
out['x-gate-origin'] = originClass(t);
|
|
130
|
+
out['x-gate-channel'] = attr(t.channelId ?? t.wakeChannelId);
|
|
131
|
+
out['x-gate-counterparty'] = attr(t.counterparty);
|
|
132
|
+
return out;
|
|
133
|
+
};
|
|
134
|
+
}
|
package/src/headless.ts
CHANGED
|
@@ -205,6 +205,16 @@ export async function runHeadless(app: AppContext, argv: string[] = []): Promise
|
|
|
205
205
|
for (const line of result.lines) {
|
|
206
206
|
emit({ type: 'command-output', text: line.text, style: line.style ?? null });
|
|
207
207
|
}
|
|
208
|
+
// Commands with async follow-up (fleet kill/restart, puppet) put
|
|
209
|
+
// their real outcome in asyncWork; without this await the IPC
|
|
210
|
+
// caller only ever saw the "...starting" line and the result was
|
|
211
|
+
// silently dropped.
|
|
212
|
+
if (result.asyncWork) {
|
|
213
|
+
const followUp = await result.asyncWork;
|
|
214
|
+
for (const line of followUp.lines) {
|
|
215
|
+
emit({ type: 'command-output', text: line.text, style: line.style ?? null });
|
|
216
|
+
}
|
|
217
|
+
}
|
|
208
218
|
if (result.switchToSessionId) {
|
|
209
219
|
await app.switchSession(result.switchToSessionId);
|
|
210
220
|
emit({ type: 'command-output', text: 'Session switched.', style: 'system' });
|