@pi-unipi/fusion 2.17.0 → 2.18.0
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/README.md +3 -0
- package/package.json +3 -3
- package/src/index.ts +78 -20
- package/src/nudge.ts +7 -0
- package/src/picker.ts +25 -13
- package/src/preset.ts +18 -0
- package/src/prompts.ts +19 -3
- package/src/sidekick-runtime.ts +83 -7
- package/src/tools.ts +67 -14
- package/src/transcript.ts +108 -0
package/README.md
CHANGED
|
@@ -117,11 +117,14 @@ savings above `$0.005`.
|
|
|
117
117
|
"default": {"lead": "provider/lead", "sidekick": "provider/sidekick"},
|
|
118
118
|
"effort": {"provider/sidekick": "high"},
|
|
119
119
|
"badges": {"provider/sidekick": "new"},
|
|
120
|
+
"prices": {"provider/sidekick": {"input": 0.2, "cachedInput": 0.02, "output": 1.2}},
|
|
120
121
|
"recent": ["provider/lead"],
|
|
121
122
|
"active": {"kind": "fusion", "lead": "provider/lead", "sidekick": "provider/sidekick"}
|
|
122
123
|
}
|
|
123
124
|
```
|
|
124
125
|
|
|
126
|
+
`prices` is an optional manual override for models whose provider reports no pricing.
|
|
127
|
+
|
|
125
128
|
## Status
|
|
126
129
|
|
|
127
130
|
- [x] Preset store, project layering, curation UI, autocomplete boost
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-unipi/fusion",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.18.0",
|
|
4
4
|
"description": "Devin-style model picker, fusion presets (lead + sidekick), and Local Fusion runtime for UniPi",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -32,8 +32,8 @@
|
|
|
32
32
|
"access": "public"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@pi-unipi/core": "2.
|
|
36
|
-
"@pi-unipi/subagents": "2.
|
|
35
|
+
"@pi-unipi/core": "2.18.0",
|
|
36
|
+
"@pi-unipi/subagents": "2.18.0"
|
|
37
37
|
},
|
|
38
38
|
"peerDependencies": {
|
|
39
39
|
"@earendil-works/pi-ai": "^0.84.0",
|
package/src/index.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { homedir } from "node:os";
|
|
|
21
21
|
import { join } from "node:path";
|
|
22
22
|
import {
|
|
23
23
|
effortLabel,
|
|
24
|
+
globalPresetPath,
|
|
24
25
|
isEffortLevel,
|
|
25
26
|
loadPreset,
|
|
26
27
|
modelKey,
|
|
@@ -36,7 +37,8 @@ import { ModelPicker, type PickerModel, type PickerResult } from "./picker.js";
|
|
|
36
37
|
import { PresetEditor, type PresetEditorResult } from "./preset-editor.js";
|
|
37
38
|
import { SidekickRuntime } from "./sidekick-runtime.js";
|
|
38
39
|
import { estimateSavings } from "./savings.js";
|
|
39
|
-
import {
|
|
40
|
+
import { EDIT_NUDGE, bashNudge, leadPolicy, sidekickSystemPrompt, type FusionIdentity } from "./prompts.js";
|
|
41
|
+
import { isTrivialShell, BASH_NUDGE_EVERY } from "./nudge.js";
|
|
40
42
|
import { registerFusionTools } from "./tools.js";
|
|
41
43
|
|
|
42
44
|
export const MODEL_COMMAND = `${UNIPI_PREFIX}model`;
|
|
@@ -60,20 +62,18 @@ function findModel(reg: Registry | undefined, key: string): Model<Api> | undefin
|
|
|
60
62
|
return modelBykey.get(key);
|
|
61
63
|
}
|
|
62
64
|
|
|
63
|
-
function costOf(m: Model<Api> | undefined): PickerModel["cost"] {
|
|
64
|
-
const cost = m?.cost;
|
|
65
|
-
return cost &&
|
|
66
|
-
? { input: cost.input, cachedInput: cost.cacheRead ?? 0, output: cost.output }
|
|
67
|
-
: undefined;
|
|
65
|
+
function costOf(m: Model<Api> | undefined, override?: PickerModel["cost"]): PickerModel["cost"] {
|
|
66
|
+
const cost = override ?? (m?.cost && typeof m.cost.input === "number" ? { input: m.cost.input, cachedInput: m.cost.cacheRead ?? 0, output: m.cost.output } : undefined);
|
|
67
|
+
return cost && (cost.input > 0 || cost.cachedInput > 0 || cost.output > 0) ? cost : undefined;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
function toPickerModel(m: Model<Api>, badge?: FusionPreset["badges"][string]): PickerModel {
|
|
70
|
+
function toPickerModel(m: Model<Api>, badge?: FusionPreset["badges"][string], override?: PickerModel["cost"]): PickerModel {
|
|
71
71
|
return {
|
|
72
72
|
key: modelKey(m),
|
|
73
73
|
name: m.name || m.id,
|
|
74
74
|
provider: m.provider,
|
|
75
75
|
badge,
|
|
76
|
-
cost: costOf(m),
|
|
76
|
+
cost: costOf(m, override),
|
|
77
77
|
reasoning: Boolean(m.reasoning),
|
|
78
78
|
};
|
|
79
79
|
}
|
|
@@ -114,7 +114,10 @@ export default function fusionExtension(pi: ExtensionAPI): void {
|
|
|
114
114
|
|
|
115
115
|
let active: ActiveSelection | undefined;
|
|
116
116
|
let runtime: SidekickRuntime | undefined;
|
|
117
|
-
let
|
|
117
|
+
let lastCtx: ExtensionContext | undefined;
|
|
118
|
+
let leadToolCalls = 0;
|
|
119
|
+
let editNudgedThisTurn = false;
|
|
120
|
+
let bashStreak = 0;
|
|
118
121
|
|
|
119
122
|
function identity(ctx: ExtensionContext): FusionIdentity {
|
|
120
123
|
const reg = registryOf(ctx);
|
|
@@ -130,12 +133,14 @@ export default function fusionExtension(pi: ExtensionAPI): void {
|
|
|
130
133
|
function statusSavings(ctx: ExtensionContext): number | undefined {
|
|
131
134
|
if (active?.kind !== "fusion" || runtime === undefined) return undefined;
|
|
132
135
|
const reg = registryOf(ctx);
|
|
136
|
+
const preset = loadPreset(ctx.cwd ?? process.cwd()).preset;
|
|
133
137
|
const lead = findModel(reg, active.lead);
|
|
134
138
|
const side = findModel(reg, active.sidekick);
|
|
135
|
-
return estimateSavings(runtime.usage, costOf(lead), costOf(side)).savedUsd;
|
|
139
|
+
return estimateSavings(runtime.usage, costOf(lead, preset.prices[active.lead]), costOf(side, preset.prices[active.sidekick])).savedUsd;
|
|
136
140
|
}
|
|
137
141
|
|
|
138
142
|
function publishStatus(ctx: ExtensionContext): void {
|
|
143
|
+
lastCtx = ctx;
|
|
139
144
|
const reg = registryOf(ctx);
|
|
140
145
|
const names = (k: string) => findModel(reg, k)?.name || splitModelKey(k)?.id || k;
|
|
141
146
|
if (active?.kind === "fusion") {
|
|
@@ -147,12 +152,19 @@ export default function fusionExtension(pi: ExtensionAPI): void {
|
|
|
147
152
|
sidekickName: names(active.sidekick),
|
|
148
153
|
sidekickEffort: active.sidekickEffort ?? "",
|
|
149
154
|
savedUsd: statusSavings(ctx),
|
|
155
|
+
busy: runtime?.isBusy() ?? false,
|
|
156
|
+
leadToolCalls,
|
|
157
|
+
sidekickToolCalls: runtime?.totalToolCalls() ?? 0,
|
|
150
158
|
});
|
|
151
159
|
} else {
|
|
152
160
|
setSharedFusionStatus(undefined);
|
|
153
161
|
}
|
|
154
162
|
}
|
|
155
163
|
|
|
164
|
+
function publishStatusLater(): void {
|
|
165
|
+
if (lastCtx) publishStatus(lastCtx);
|
|
166
|
+
}
|
|
167
|
+
|
|
156
168
|
function leadSessionId(ctx: ExtensionContext): string {
|
|
157
169
|
const manager = ctx.sessionManager as { getSessionId?: () => string | undefined } | undefined;
|
|
158
170
|
return manager?.getSessionId?.() ?? "default";
|
|
@@ -167,6 +179,7 @@ export default function fusionExtension(pi: ExtensionAPI): void {
|
|
|
167
179
|
thinking: active.sidekickEffort ?? "medium",
|
|
168
180
|
sessionFile: sidekickSessionPath(leadSessionId(ctx)),
|
|
169
181
|
systemPrompt: sidekickSystemPrompt(identity(ctx)),
|
|
182
|
+
onProgress: () => publishStatusLater(),
|
|
170
183
|
});
|
|
171
184
|
}
|
|
172
185
|
return runtime;
|
|
@@ -175,28 +188,57 @@ export default function fusionExtension(pi: ExtensionAPI): void {
|
|
|
175
188
|
function stopRuntime(): void {
|
|
176
189
|
runtime?.kill();
|
|
177
190
|
runtime = undefined;
|
|
191
|
+
leadToolCalls = 0;
|
|
178
192
|
}
|
|
179
193
|
|
|
180
194
|
function savingsStats(ctx: ExtensionContext): string {
|
|
181
195
|
if (active?.kind !== "fusion" || runtime === undefined) return "Fusion is not active — pick a Fusion pair with /unipi:model.";
|
|
182
196
|
const reg = registryOf(ctx);
|
|
183
|
-
const
|
|
184
|
-
|
|
197
|
+
const preset = loadPreset(ctx.cwd ?? process.cwd()).preset;
|
|
198
|
+
const leadCost = costOf(findModel(reg, active.lead), preset.prices[active.lead]);
|
|
199
|
+
const sidekickCost = costOf(findModel(reg, active.sidekick), preset.prices[active.sidekick]);
|
|
200
|
+
const savings = estimateSavings(runtime.usage, leadCost, sidekickCost);
|
|
201
|
+
const pricing = leadCost === undefined && sidekickCost === undefined
|
|
202
|
+
? '\nPricing unavailable from provider — set "prices" in ~/.unipi/config/fusion/preset.json to estimate savings.'
|
|
203
|
+
: "";
|
|
204
|
+
return `Sidekick tokens: in ${String(runtime.usage.input)} · out ${String(runtime.usage.output)} · cached ${String(runtime.usage.cacheRead)} · cache write ${String(runtime.usage.cacheWrite)}\nSidekick cost: $${savings.sidekickUsd.toFixed(2)} · at lead prices: $${savings.atLeadUsd.toFixed(2)} · saved: $${savings.savedUsd.toFixed(2)}\nHandoffs: ${String(runtime.reports.size)} · runtime alive: ${String(runtime.isAlive())} · busy: ${String(runtime.isBusy())}${pricing}`;
|
|
185
205
|
}
|
|
186
206
|
|
|
187
207
|
registerFusionTools(pi, {
|
|
188
208
|
getRuntime,
|
|
189
209
|
onReport: (ctx) => publishStatus(ctx),
|
|
210
|
+
onHandoffStart: (ctx) => publishStatus(ctx),
|
|
190
211
|
});
|
|
191
212
|
pi.registerCommand("unipi:fusion-stats", {
|
|
192
213
|
description: "Estimated Fusion savings (sidekick tokens priced at lead rates)",
|
|
193
214
|
handler: async (_args, ctx) => ctx.ui.notify(savingsStats(ctx), "info"),
|
|
194
215
|
});
|
|
195
216
|
pi.on("before_agent_start", (event, ctx) => active?.kind === "fusion" ? { systemPrompt: `${event.systemPrompt}\n\n${leadPolicy(identity(ctx))}` } : undefined);
|
|
217
|
+
pi.on("turn_start", () => {
|
|
218
|
+
editNudgedThisTurn = false;
|
|
219
|
+
});
|
|
196
220
|
pi.on("tool_result", (event) => {
|
|
197
|
-
if (active?.kind !== "fusion"
|
|
198
|
-
|
|
199
|
-
|
|
221
|
+
if (active?.kind !== "fusion") return;
|
|
222
|
+
const toolName: string = event.toolName;
|
|
223
|
+
if (toolName === "sidekick" || toolName === "read_subagent") {
|
|
224
|
+
bashStreak = 0;
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
leadToolCalls += 1;
|
|
228
|
+
publishStatusLater();
|
|
229
|
+
if (toolName === "edit" || toolName === "write") {
|
|
230
|
+
if (editNudgedThisTurn) return;
|
|
231
|
+
editNudgedThisTurn = true;
|
|
232
|
+
return { content: [...event.content, { type: "text" as const, text: EDIT_NUDGE }] };
|
|
233
|
+
}
|
|
234
|
+
if (toolName !== "bash") return;
|
|
235
|
+
const command = typeof event.input.command === "string" ? event.input.command : "";
|
|
236
|
+
if (isTrivialShell(command)) return;
|
|
237
|
+
bashStreak += 1;
|
|
238
|
+
if (bashStreak < BASH_NUDGE_EVERY) return;
|
|
239
|
+
const content = [...event.content, { type: "text" as const, text: bashNudge(bashStreak) }];
|
|
240
|
+
bashStreak = 0;
|
|
241
|
+
return { content };
|
|
200
242
|
});
|
|
201
243
|
|
|
202
244
|
async function applyResult(ctx: ExtensionContext, result: PickerResult, preset: FusionPreset, loaded: { globalPath: string; projectPath: string; hasProjectLayer: boolean }): Promise<void> {
|
|
@@ -267,7 +309,7 @@ export default function fusionExtension(pi: ExtensionAPI): void {
|
|
|
267
309
|
const cwd = ctx.cwd ?? process.cwd();
|
|
268
310
|
const loaded = loadPreset(cwd);
|
|
269
311
|
const preset = loaded.preset;
|
|
270
|
-
const models = reg.getAvailable().map((m) => toPickerModel(m, preset.badges[modelKey(m)]));
|
|
312
|
+
const models = reg.getAvailable().map((m) => toPickerModel(m, preset.badges[modelKey(m)], preset.prices[modelKey(m)]));
|
|
271
313
|
if (models.length === 0) {
|
|
272
314
|
ctx.ui.notify("No models available. Use /login to add a provider.", "warning");
|
|
273
315
|
return;
|
|
@@ -347,13 +389,27 @@ export default function fusionExtension(pi: ExtensionAPI): void {
|
|
|
347
389
|
},
|
|
348
390
|
});
|
|
349
391
|
|
|
350
|
-
pi.on("session_start", (_e, ctx) => {
|
|
392
|
+
pi.on("session_start", async (_e, ctx) => {
|
|
351
393
|
stopRuntime();
|
|
352
|
-
|
|
394
|
+
editNudgedThisTurn = false;
|
|
395
|
+
bashStreak = 0;
|
|
353
396
|
modelBykey.clear();
|
|
354
397
|
active = loadPreset(ctx.cwd ?? process.cwd()).preset.active;
|
|
355
|
-
|
|
356
|
-
|
|
398
|
+
if (active?.kind === "fusion" && (!ctx.model || modelKey(ctx.model) !== active.lead)) {
|
|
399
|
+
const leadKey = active.lead;
|
|
400
|
+
const lead = findModel(registryOf(ctx), leadKey);
|
|
401
|
+
const restored = lead !== undefined && await pi.setModel(lead);
|
|
402
|
+
if (restored) {
|
|
403
|
+
try {
|
|
404
|
+
pi.setThinkingLevel(active.leadEffort ?? "medium");
|
|
405
|
+
} catch {
|
|
406
|
+
/* provider may not support thinking */
|
|
407
|
+
}
|
|
408
|
+
} else {
|
|
409
|
+
active = undefined;
|
|
410
|
+
if (ctx.hasUI) ctx.ui.notify(`Fusion lead ${leadKey} unavailable — Fusion off`, "warning");
|
|
411
|
+
}
|
|
412
|
+
}
|
|
357
413
|
publishStatus(ctx);
|
|
358
414
|
if (ctx.hasUI) ctx.ui.addAutocompleteProvider(createModelBoostProvider);
|
|
359
415
|
});
|
|
@@ -369,6 +425,8 @@ export default function fusionExtension(pi: ExtensionAPI): void {
|
|
|
369
425
|
if (active?.kind === "fusion" && modelKey(event.model) !== active.lead) {
|
|
370
426
|
stopRuntime();
|
|
371
427
|
active = { kind: "single", model: modelKey(event.model) };
|
|
428
|
+
const loaded = loadPreset(ctx.cwd ?? process.cwd());
|
|
429
|
+
saveRuntimeState(globalPresetPath(), { effort: loaded.preset.effort, recent: loaded.preset.recent, active });
|
|
372
430
|
publishStatus(ctx);
|
|
373
431
|
}
|
|
374
432
|
});
|
package/src/nudge.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export const BASH_NUDGE_EVERY = 4;
|
|
2
|
+
|
|
3
|
+
const TRIVIAL = /^\s*(?:cd\s+\S+\s*(?:&&|;)\s*)?(?:git\s+(?:status|log|diff|branch|show|remote|rev-parse)|ls|pwd|cat|head|tail|wc|echo|which|type|rg|grep|find|stat|file|du|df|env|printenv|date|whoami|tmux\s+capture-pane|npm\s+(?:view|whoami|ls))\b[^|;&]*$/;
|
|
4
|
+
|
|
5
|
+
export function isTrivialShell(command: string): boolean {
|
|
6
|
+
return TRIVIAL.test(command.trim());
|
|
7
|
+
}
|
package/src/picker.ts
CHANGED
|
@@ -128,6 +128,10 @@ function money(perMillion: number): string {
|
|
|
128
128
|
return `$${rounded.replace(/\.0+$/u, "").replace(/(\.\d)0$/u, "$1")} / 1M`;
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
function hasPricing(cost: PickerModel["cost"]): cost is NonNullable<PickerModel["cost"]> {
|
|
132
|
+
return cost !== undefined && (cost.input > 0 || cost.cachedInput > 0 || cost.output > 0);
|
|
133
|
+
}
|
|
134
|
+
|
|
131
135
|
function pad(text: string, width: number): string {
|
|
132
136
|
const w = visibleWidth(text);
|
|
133
137
|
return w >= width ? text : text + " ".repeat(width - w);
|
|
@@ -161,7 +165,7 @@ export class ModelPicker {
|
|
|
161
165
|
this.onRenderRequest = options.onRenderRequest;
|
|
162
166
|
this.visibleRows = options.visibleRows ?? DEFAULT_VISIBLE_ROWS;
|
|
163
167
|
this.modelsByKey = new Map(options.state.models.map((m) => [m.key, m]));
|
|
164
|
-
const prices = options.state.models.map((m) => (m.cost ? blendedPrice(m.cost) : undefined)).filter((p): p is number => p !== undefined);
|
|
168
|
+
const prices = options.state.models.map((m) => (hasPricing(m.cost) ? blendedPrice(m.cost) : undefined)).filter((p): p is number => p !== undefined && p > 0);
|
|
165
169
|
this.priceRange = { min: prices.length > 0 ? Math.min(...prices) : 0, max: prices.length > 0 ? Math.max(...prices) : 0 };
|
|
166
170
|
this.effort = { ...options.state.effort };
|
|
167
171
|
const active = options.state.active;
|
|
@@ -477,24 +481,27 @@ export class ModelPicker {
|
|
|
477
481
|
const primaryKey = row.kind === "fusion" ? this.lead : row.key;
|
|
478
482
|
const primary = primaryKey === undefined ? undefined : this.modelsByKey.get(primaryKey);
|
|
479
483
|
const side = row.kind === "fusion" && this.sidekick !== undefined ? this.modelsByKey.get(this.sidekick) : undefined;
|
|
484
|
+
const primaryCost = primary?.cost;
|
|
485
|
+
const sideCost = side?.cost;
|
|
480
486
|
const cols: Array<[string, string]> = [];
|
|
481
|
-
if (
|
|
482
|
-
cols.push(["Input", money(
|
|
483
|
-
cols.push(["Cached input", money(
|
|
484
|
-
cols.push(["Output", money(
|
|
487
|
+
if (hasPricing(primaryCost)) {
|
|
488
|
+
cols.push(["Input", money(primaryCost.input)]);
|
|
489
|
+
cols.push(["Cached input", money(primaryCost.cachedInput)]);
|
|
490
|
+
cols.push(["Output", money(primaryCost.output)]);
|
|
485
491
|
} else {
|
|
486
492
|
cols.push(["Input", "—"], ["Cached input", "—"], ["Output", "—"]);
|
|
487
493
|
}
|
|
488
494
|
if (row.kind === "fusion") {
|
|
489
|
-
if (
|
|
490
|
-
cols.push(["Sidekick input", money(
|
|
491
|
-
cols.push(["Sidekick cached input", money(
|
|
492
|
-
cols.push(["Sidekick output", money(
|
|
495
|
+
if (hasPricing(sideCost)) {
|
|
496
|
+
cols.push(["Sidekick input", money(sideCost.input)]);
|
|
497
|
+
cols.push(["Sidekick cached input", money(sideCost.cachedInput)]);
|
|
498
|
+
cols.push(["Sidekick output", money(sideCost.output)]);
|
|
493
499
|
} else {
|
|
494
500
|
cols.push(["Sidekick input", "—"], ["Sidekick cached input", "—"], ["Sidekick output", "—"]);
|
|
495
501
|
}
|
|
496
502
|
}
|
|
497
|
-
const
|
|
503
|
+
const need = Math.max(...cols.map(([h, v]) => Math.max(visibleWidth(h), visibleWidth(v)))) + 3;
|
|
504
|
+
const colWidth = Math.max(10, Math.min(need, Math.floor((width - 4) / cols.length)));
|
|
498
505
|
const head = cols.map(([h]) => pad(t.fg("dim", h), colWidth)).join("");
|
|
499
506
|
const vals = cols.map(([, v]) => pad(t.fg("text", v), colWidth)).join("");
|
|
500
507
|
const desc =
|
|
@@ -506,7 +513,11 @@ export class ModelPicker {
|
|
|
506
513
|
const badges = this.state.models.some((m) => m.badge !== undefined)
|
|
507
514
|
? `${t.fg("success", "✱")} ${t.fg("dim", "New")} ${t.fg("accent", "✱")} ${t.fg("dim", "Promotion")} ${t.fg("warning", "✱")} ${t.fg("dim", "Beta")} ${t.fg("dim", "·")}`
|
|
508
515
|
: "";
|
|
509
|
-
const
|
|
516
|
+
const noPricing = row.kind === "fusion"
|
|
517
|
+
? !hasPricing(primaryCost) || !hasPricing(sideCost)
|
|
518
|
+
: !hasPricing(primaryCost);
|
|
519
|
+
const pricing = noPricing ? t.fg("dim", " · no pricing data from provider") : "";
|
|
520
|
+
const description = `${badges}${badges.length > 0 ? " " : ""}${desc}${pricing}`;
|
|
510
521
|
return [truncateToWidth(` ${head}`, width - 1), truncateToWidth(` ${vals}`, width - 1), truncateToWidth(` ${description}`, width - 1)];
|
|
511
522
|
}
|
|
512
523
|
|
|
@@ -561,8 +572,9 @@ export class ModelPicker {
|
|
|
561
572
|
const sliderCells = Math.min(48, Math.max(1, width - 6));
|
|
562
573
|
const sliderKey = row?.kind === "fusion" ? this.lead : row?.key;
|
|
563
574
|
const sliderModel = sliderKey === undefined ? undefined : this.modelsByKey.get(sliderKey);
|
|
564
|
-
const
|
|
565
|
-
const
|
|
575
|
+
const sliderCost = sliderModel?.cost;
|
|
576
|
+
const sliderPrice = hasPricing(sliderCost) ? blendedPrice(sliderCost) : undefined;
|
|
577
|
+
const marker = this.priceRange.max <= 0 || sliderPrice === undefined ? undefined : sliderPosition(sliderPrice, this.priceRange.min, this.priceRange.max, sliderCells);
|
|
566
578
|
lines.push(truncateToWidth(` ${renderSlider(sliderCells, marker)}`, width - 1));
|
|
567
579
|
lines.push(...this.renderPricePanel(row, width));
|
|
568
580
|
lines.push("");
|
package/src/preset.ts
CHANGED
|
@@ -61,6 +61,8 @@ export interface FusionPreset {
|
|
|
61
61
|
recent: ModelKey[];
|
|
62
62
|
/** Optional hand-curated model badge metadata. */
|
|
63
63
|
badges: Record<ModelKey, FusionBadge>;
|
|
64
|
+
/** Manual pricing overrides for providers that report no pricing. */
|
|
65
|
+
prices: Record<ModelKey, { input: number; cachedInput: number; output: number }>;
|
|
64
66
|
/** What the user last confirmed in the picker. */
|
|
65
67
|
active?: ActiveSelection | undefined;
|
|
66
68
|
}
|
|
@@ -74,6 +76,7 @@ export function emptyPreset(): FusionPreset {
|
|
|
74
76
|
effort: {},
|
|
75
77
|
recent: [],
|
|
76
78
|
badges: {},
|
|
79
|
+
prices: {},
|
|
77
80
|
};
|
|
78
81
|
}
|
|
79
82
|
|
|
@@ -136,6 +139,20 @@ export function parsePreset(raw: unknown): Partial<FusionPreset> {
|
|
|
136
139
|
}
|
|
137
140
|
out.badges = badges;
|
|
138
141
|
}
|
|
142
|
+
if (typeof r["prices"] === "object" && r["prices"] !== null) {
|
|
143
|
+
const prices: FusionPreset["prices"] = {};
|
|
144
|
+
for (const [k, v] of Object.entries(r["prices"] as Record<string, unknown>)) {
|
|
145
|
+
if (typeof v !== "object" || v === null) continue;
|
|
146
|
+
const price = v as Record<string, unknown>;
|
|
147
|
+
const input = price["input"];
|
|
148
|
+
const cachedInput = price["cachedInput"];
|
|
149
|
+
const output = price["output"];
|
|
150
|
+
if ([input, cachedInput, output].every((n) => typeof n === "number" && Number.isFinite(n) && n >= 0)) {
|
|
151
|
+
prices[k] = { input: input as number, cachedInput: cachedInput as number, output: output as number };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
out.prices = prices;
|
|
155
|
+
}
|
|
139
156
|
const active = r["active"];
|
|
140
157
|
if (typeof active === "object" && active !== null) {
|
|
141
158
|
const a = active as Record<string, unknown>;
|
|
@@ -167,6 +184,7 @@ export function mergePresets(base: FusionPreset, over: Partial<FusionPreset>): F
|
|
|
167
184
|
effort: { ...base.effort, ...(over.effort ?? {}) },
|
|
168
185
|
recent: over.recent ?? base.recent,
|
|
169
186
|
badges: { ...base.badges, ...(over.badges ?? {}) },
|
|
187
|
+
prices: { ...base.prices, ...(over.prices ?? {}) },
|
|
170
188
|
active: over.active ?? base.active,
|
|
171
189
|
};
|
|
172
190
|
}
|
package/src/prompts.ts
CHANGED
|
@@ -68,9 +68,25 @@ You have a \`sidekick\` tool: a persistent subagent that works alongside you on
|
|
|
68
68
|
- **Promoting a delegated hypothesis to a confirmed conclusion.** When a report ranks candidate causes, the ranking is not a verdict. Present a cause as the root cause only if evidence shows its code path actually executes in the reported scenario; otherwise present it as the leading hypothesis and name the check that would settle it.`;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
/**
|
|
72
|
-
|
|
73
|
-
|
|
71
|
+
/**
|
|
72
|
+
* Recurring reminder appended to a direct edit/write by the lead while Fusion
|
|
73
|
+
* is active (at most once per agent turn). Mirrors Devin's harness, which
|
|
74
|
+
* re-issues this guidance on every direct implementation action rather than once.
|
|
75
|
+
*/
|
|
76
|
+
export const EDIT_NUDGE =
|
|
77
|
+
"<system_guidance>You made a direct edit yourself instead of delegating to the sidekick. This is a reminder that implementation and verification are to be delegated by default. ONLY implement a step yourself if it is trivially small (you can make the edit AND confirm it in 1-2 of your own turns, with nothing left to test afterwards) or correctness-critical (queries against shared data systems, eval/grading text, pipeline or threshold configuration — you author and check those regardless of size). For anything else, write a brief and hand it to `sidekick`: you design and review, it implements and verifies.</system_guidance>";
|
|
78
|
+
|
|
79
|
+
/** Kept for compatibility with earlier imports; the nudge is no longer one-time. */
|
|
80
|
+
export const FIRST_EDIT_NUDGE = EDIT_NUDGE;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Appended after the lead has run several consecutive non-trivial shell
|
|
84
|
+
* commands itself without a handoff. Builds, tests, installs, environment
|
|
85
|
+
* repair and multi-step shell work are the sidekick's job by default.
|
|
86
|
+
*/
|
|
87
|
+
export function bashNudge(count: number): string {
|
|
88
|
+
return `<system_guidance>You have run ${String(count)} non-trivial shell commands yourself since the last handoff. Builds, test runs, installs, environment setup or repair, and any multi-step shell work are to be delegated to the \`sidekick\` by default; it runs on the same machine and remembers earlier handoffs, so a short brief with the goal, the exact commands or checks you want, and the done-criteria is enough. Keep running commands yourself only when a single read-only command answers a question you need right now, or when the user is waiting on an urgent deliverable.</system_guidance>`;
|
|
89
|
+
}
|
|
74
90
|
|
|
75
91
|
export function sidekickSystemPrompt(id: FusionIdentity): string {
|
|
76
92
|
return `## Role: Fusion sidekick
|
package/src/sidekick-runtime.ts
CHANGED
|
@@ -14,6 +14,7 @@ export interface SidekickSpawnConfig {
|
|
|
14
14
|
systemPrompt: string;
|
|
15
15
|
spawn?: typeof defaultSpawn;
|
|
16
16
|
command?: { command: string; args: string[] };
|
|
17
|
+
onProgress?: () => void;
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
export interface SidekickUsage {
|
|
@@ -24,11 +25,20 @@ export interface SidekickUsage {
|
|
|
24
25
|
cost: number;
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
export type SidekickEvent =
|
|
29
|
+
| { kind: "text"; text: string; open: boolean }
|
|
30
|
+
| { kind: "tool"; toolCallId: string; name: string; args: Record<string, unknown> | undefined; output: string; isError: boolean; done: boolean; startedAt: number; endedAt?: number };
|
|
31
|
+
|
|
32
|
+
export const MAX_EVENTS = 300;
|
|
33
|
+
export const MAX_TOOL_OUTPUT = 4000;
|
|
34
|
+
|
|
27
35
|
export interface HandoffProgress {
|
|
28
36
|
toolCalls: number;
|
|
29
37
|
recentTools: string[];
|
|
30
38
|
textTail: string;
|
|
31
39
|
startedAt: number;
|
|
40
|
+
events: SidekickEvent[];
|
|
41
|
+
droppedEvents: number;
|
|
32
42
|
}
|
|
33
43
|
|
|
34
44
|
export interface HandoffReport {
|
|
@@ -38,6 +48,7 @@ export interface HandoffReport {
|
|
|
38
48
|
usage: SidekickUsage;
|
|
39
49
|
toolCalls: number;
|
|
40
50
|
durationMs: number;
|
|
51
|
+
events: SidekickEvent[];
|
|
41
52
|
error?: string;
|
|
42
53
|
}
|
|
43
54
|
|
|
@@ -79,6 +90,47 @@ export class SidekickRuntime {
|
|
|
79
90
|
return this.pending !== undefined;
|
|
80
91
|
}
|
|
81
92
|
|
|
93
|
+
totalToolCalls(): number {
|
|
94
|
+
let total = this.pending?.progress.toolCalls ?? 0;
|
|
95
|
+
for (const report of this.reports.values()) total += report.toolCalls;
|
|
96
|
+
return total;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private notifyProgress(): void {
|
|
100
|
+
try {
|
|
101
|
+
this.cfg.onProgress?.();
|
|
102
|
+
} catch {
|
|
103
|
+
// Progress updates must not affect the handoff.
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private appendEvent(event: SidekickEvent): void {
|
|
108
|
+
const progress = this.pending?.progress;
|
|
109
|
+
if (!progress) return;
|
|
110
|
+
progress.events.push(event);
|
|
111
|
+
if (progress.events.length > MAX_EVENTS) {
|
|
112
|
+
progress.events.shift();
|
|
113
|
+
progress.droppedEvents += 1;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private closeOpenText(): void {
|
|
118
|
+
const events = this.pending?.progress.events;
|
|
119
|
+
const last = events?.at(-1);
|
|
120
|
+
if (last?.kind === "text" && last.open) last.open = false;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private toolOutput(result: unknown): string {
|
|
124
|
+
if (typeof result === "object" && result !== null && Array.isArray((result as { content?: unknown }).content)) {
|
|
125
|
+
return ((result as { content: unknown[] }).content)
|
|
126
|
+
.map((part) => typeof part === "object" && part !== null && typeof (part as { text?: unknown }).text === "string" ? (part as { text: string }).text : typeof part === "string" ? part : "")
|
|
127
|
+
.filter(Boolean)
|
|
128
|
+
.join("\n")
|
|
129
|
+
.slice(-MAX_TOOL_OUTPUT);
|
|
130
|
+
}
|
|
131
|
+
return String(result ?? "").slice(-MAX_TOOL_OUTPUT);
|
|
132
|
+
}
|
|
133
|
+
|
|
82
134
|
private send(value: Record<string, unknown>): void {
|
|
83
135
|
if (!this.child?.stdin?.writable) throw new Error("Sidekick process is not writable");
|
|
84
136
|
this.child.stdin.write(`${JSON.stringify(value)}\n`);
|
|
@@ -169,19 +221,39 @@ export class SidekickRuntime {
|
|
|
169
221
|
}
|
|
170
222
|
if (this.pending === undefined) return;
|
|
171
223
|
if (message.type === "tool_execution_start") {
|
|
224
|
+
this.closeOpenText();
|
|
172
225
|
this.pending.progress.toolCalls += 1;
|
|
173
|
-
const args = message.args
|
|
174
|
-
const
|
|
226
|
+
const args = message.args !== undefined && typeof message.args === "object" && message.args !== null ? message.args as Record<string, unknown> : undefined;
|
|
227
|
+
const argsText = args === undefined ? "" : JSON.stringify(args).replace(/\s+/gu, " ");
|
|
228
|
+
const summary = `${String(message.toolName ?? "tool")}(${argsText})`.slice(0, 40);
|
|
175
229
|
this.pending.progress.recentTools = [...this.pending.progress.recentTools, summary].slice(-6);
|
|
230
|
+
this.appendEvent({ kind: "tool", toolCallId: String(message.toolCallId ?? ""), name: String(message.toolName ?? "tool"), args, output: "", isError: false, done: false, startedAt: Date.now() });
|
|
231
|
+
this.notifyProgress();
|
|
232
|
+
} else if (message.type === "tool_execution_end") {
|
|
233
|
+
const toolCallId = String(message.toolCallId ?? "");
|
|
234
|
+
const event = [...this.pending.progress.events].reverse().find((entry): entry is Extract<SidekickEvent, { kind: "tool" }> => entry.kind === "tool" && entry.toolCallId === toolCallId);
|
|
235
|
+
if (event) {
|
|
236
|
+
event.done = true;
|
|
237
|
+
event.endedAt = Date.now();
|
|
238
|
+
event.isError = message.isError === true;
|
|
239
|
+
event.output = this.toolOutput(message.result);
|
|
240
|
+
}
|
|
241
|
+
this.notifyProgress();
|
|
176
242
|
} else if (message.type === "message_update") {
|
|
177
|
-
const
|
|
178
|
-
if (
|
|
179
|
-
const delta = typeof
|
|
243
|
+
const streamEvent = (message.assistantMessageEvent ?? message) as Record<string, unknown>;
|
|
244
|
+
if (streamEvent.type === "text_delta") {
|
|
245
|
+
const delta = typeof streamEvent.delta === "string" ? streamEvent.delta : typeof streamEvent.text === "string" ? streamEvent.text : "";
|
|
180
246
|
this.pending.progress.textTail = `${this.pending.progress.textTail}${delta}`.slice(-400);
|
|
247
|
+
const last = this.pending.progress.events.at(-1);
|
|
248
|
+
if (last?.kind === "text" && last.open) last.text += delta;
|
|
249
|
+
else this.appendEvent({ kind: "text", text: delta, open: true });
|
|
250
|
+
this.notifyProgress();
|
|
181
251
|
}
|
|
182
252
|
} else if (message.type === "message_end") {
|
|
183
253
|
const msg = message.message as Record<string, unknown> | undefined;
|
|
184
254
|
if (msg?.role === "assistant") {
|
|
255
|
+
this.closeOpenText();
|
|
256
|
+
this.notifyProgress();
|
|
185
257
|
if (msg.stopReason === "error" && typeof msg.errorMessage === "string") this.pendingError = msg.errorMessage;
|
|
186
258
|
const usage = msg.usage as Record<string, unknown> | undefined;
|
|
187
259
|
if (usage) {
|
|
@@ -231,11 +303,13 @@ export class SidekickRuntime {
|
|
|
231
303
|
usage: { ...current.usage },
|
|
232
304
|
toolCalls: current.progress.toolCalls,
|
|
233
305
|
durationMs: Date.now() - current.startedAt,
|
|
306
|
+
events: current.progress.events.map((event) => ({ ...event })),
|
|
234
307
|
...(error === undefined ? {} : { error }),
|
|
235
308
|
};
|
|
236
309
|
this.reports.set(report.id, report);
|
|
237
310
|
if (this.latestHandoff?.id === report.id) this.latestHandoff.report = report;
|
|
238
311
|
current.resolve(report);
|
|
312
|
+
this.notifyProgress();
|
|
239
313
|
this.abortRequested = false;
|
|
240
314
|
this.pendingError = undefined;
|
|
241
315
|
}
|
|
@@ -259,7 +333,7 @@ export class SidekickRuntime {
|
|
|
259
333
|
id,
|
|
260
334
|
startedAt,
|
|
261
335
|
usage: emptyUsage(),
|
|
262
|
-
progress: { toolCalls: 0, recentTools: [], textTail: "", startedAt },
|
|
336
|
+
progress: { toolCalls: 0, recentTools: [], textTail: "", startedAt, events: [], droppedEvents: 0 },
|
|
263
337
|
resolve,
|
|
264
338
|
reject,
|
|
265
339
|
};
|
|
@@ -273,7 +347,9 @@ export class SidekickRuntime {
|
|
|
273
347
|
}
|
|
274
348
|
|
|
275
349
|
progress(id?: string): HandoffProgress | undefined {
|
|
276
|
-
if (this.pending !== undefined && (id === undefined || id === this.pending.id))
|
|
350
|
+
if (this.pending !== undefined && (id === undefined || id === this.pending.id)) {
|
|
351
|
+
return { ...this.pending.progress, recentTools: [...this.pending.progress.recentTools], events: this.pending.progress.events.map((event) => ({ ...event })) };
|
|
352
|
+
}
|
|
277
353
|
return undefined;
|
|
278
354
|
}
|
|
279
355
|
|
package/src/tools.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type
|
|
1
|
+
import { Markdown, Text, type Component } from "@earendil-works/pi-tui";
|
|
2
|
+
import { getMarkdownTheme, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { Type } from "typebox";
|
|
4
|
-
import type { SidekickRuntime, HandoffReport } from "./sidekick-runtime.js";
|
|
4
|
+
import type { SidekickRuntime, HandoffProgress, HandoffReport } from "./sidekick-runtime.js";
|
|
5
|
+
import { frameSidekick, renderSidekickTranscript, type ThemeLike as TranscriptTheme } from "./transcript.js";
|
|
5
6
|
|
|
6
7
|
const SidekickParams = Type.Object({
|
|
7
8
|
message: Type.String({ description: "A concrete implementation or verification brief for the sidekick" }),
|
|
@@ -16,12 +17,17 @@ const ReadSubagentParams = Type.Object({
|
|
|
16
17
|
export interface FusionToolDeps {
|
|
17
18
|
getRuntime: (ctx: ExtensionContext) => SidekickRuntime | undefined;
|
|
18
19
|
onReport?: (ctx: ExtensionContext, report: HandoffReport) => void;
|
|
20
|
+
onHandoffStart?: (ctx: ExtensionContext) => void;
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
function duration(ms: number): string {
|
|
22
24
|
return `${(ms / 1000).toFixed(1)}s`;
|
|
23
25
|
}
|
|
24
26
|
|
|
27
|
+
function firstLine(value: string): string {
|
|
28
|
+
return value.split("\n", 1)[0] ?? "";
|
|
29
|
+
}
|
|
30
|
+
|
|
25
31
|
function progressText(runtime: SidekickRuntime, id: string): string {
|
|
26
32
|
const progress = runtime.progress(id);
|
|
27
33
|
if (!progress) return "No active handoff progress.";
|
|
@@ -33,7 +39,9 @@ function progressText(runtime: SidekickRuntime, id: string): string {
|
|
|
33
39
|
|
|
34
40
|
function progressKey(runtime: SidekickRuntime, id: string): string {
|
|
35
41
|
const progress = runtime.progress(id);
|
|
36
|
-
|
|
42
|
+
if (!progress) return "";
|
|
43
|
+
const last = progress.events.at(-1);
|
|
44
|
+
return `${String(progress.toolCalls)}|${progress.recentTools.join("|")}|${progress.textTail}|${String(progress.events.length)}|${last?.kind === "tool" ? `${String(last.output.length)}|${String(last.done)}` : last?.kind === "text" ? `${String(last.text.length)}|${String(last.open)}` : ""}`;
|
|
37
45
|
}
|
|
38
46
|
|
|
39
47
|
function reportText(report: HandoffReport): string {
|
|
@@ -70,7 +78,7 @@ async function waitForReport(
|
|
|
70
78
|
const key = progressKey(runtime, id);
|
|
71
79
|
if (key !== lastProgressKey) {
|
|
72
80
|
lastProgressKey = key;
|
|
73
|
-
onUpdate?.({ content: [{ type: "text", text: progress }] });
|
|
81
|
+
onUpdate?.({ content: [{ type: "text", text: progress }], details: { progress: runtime.progress(id) } });
|
|
74
82
|
}
|
|
75
83
|
}
|
|
76
84
|
}
|
|
@@ -90,15 +98,52 @@ type ThemeLike = {
|
|
|
90
98
|
bold: (text: string) => string;
|
|
91
99
|
};
|
|
92
100
|
|
|
93
|
-
function
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
101
|
+
function transcriptTheme(theme: ThemeLike): TranscriptTheme {
|
|
102
|
+
return { fg: (color, text) => theme.fg(color, text), bold: (text) => theme.bold(text) };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function markdownText(markdown: string): Markdown {
|
|
106
|
+
return new Markdown(markdown, 0, 0, getMarkdownTheme());
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function contentText(content: unknown): string {
|
|
110
|
+
if (!Array.isArray(content)) return String(content ?? "");
|
|
111
|
+
return content.map((part) => typeof part === "object" && part !== null && typeof (part as { text?: unknown }).text === "string" ? (part as { text: string }).text : "").filter(Boolean).join("\n");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function renderToolTranscript(result: { content?: unknown; details?: unknown; isError?: boolean }, options: { expanded?: boolean }, theme: ThemeLike, label: "sidekick" | "read_subagent"): Component {
|
|
115
|
+
const details = result.details as (HandoffReport & { progress?: HandoffProgress }) | { progress?: HandoffProgress } | undefined;
|
|
116
|
+
const progress = details?.progress;
|
|
117
|
+
const report = progress ? undefined : details as HandoffReport | undefined;
|
|
118
|
+
const events = progress?.events ?? report?.events;
|
|
119
|
+
const partial = progress !== undefined;
|
|
120
|
+
const status = partial ? "working" : report?.status === "completed" && !result.isError ? "completed" : "error";
|
|
121
|
+
if (!events) return frameSidekick(theme, status, new Text(contentText(result.content), 0, 0));
|
|
122
|
+
const header = partial
|
|
123
|
+
? `${theme.fg("accent", theme.bold(`◆ ${label} working`))} ${theme.fg("dim", `· ${String(progress.toolCalls)} tool calls · ${duration(Date.now() - progress.startedAt)}`)}`
|
|
124
|
+
: `${theme.fg(report?.status === "completed" ? "success" : "error", "◆")} ${theme.fg("accent", theme.bold(`${label} ${report?.status ?? "done"}`))} ${theme.fg("dim", report ? `· ${report.id} · ${String(report.toolCalls)} tool calls · ${duration(report.durationMs)} · in ${String(report.usage.input)} / out ${String(report.usage.output)} tokens` : "")}`;
|
|
125
|
+
return frameSidekick(theme, status, renderSidekickTranscript(transcriptTheme(theme), {
|
|
126
|
+
events,
|
|
127
|
+
droppedEvents: progress?.droppedEvents,
|
|
128
|
+
header,
|
|
129
|
+
expanded: options.expanded === true,
|
|
130
|
+
isPartial: partial,
|
|
131
|
+
report,
|
|
132
|
+
renderText: markdownText,
|
|
133
|
+
}));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function renderCompletionCard(theme: ThemeLike, report: HandoffReport | undefined): Component {
|
|
137
|
+
const status = report?.status === "completed" ? "completed" : "error";
|
|
138
|
+
if (!report) return frameSidekick(theme, "error", new Text(`${theme.fg("accent", "◆")} ${theme.fg("accent", theme.bold("sidekick done"))}`, 0, 0));
|
|
139
|
+
return frameSidekick(theme, status, renderSidekickTranscript(transcriptTheme(theme), {
|
|
140
|
+
events: report.events ?? [],
|
|
141
|
+
header: `${theme.fg(report.status === "completed" ? "success" : "error", "◆")} ${theme.fg("accent", theme.bold(`sidekick ${report.status}`))} ${theme.fg("dim", `· ${report.id} · ${String(report.toolCalls)} tool calls · ${duration(report.durationMs)}`)}`,
|
|
142
|
+
expanded: false,
|
|
143
|
+
isPartial: false,
|
|
144
|
+
report,
|
|
145
|
+
renderText: markdownText,
|
|
146
|
+
}));
|
|
102
147
|
}
|
|
103
148
|
|
|
104
149
|
export function registerFusionTools(pi: ExtensionAPI, deps: FusionToolDeps): void {
|
|
@@ -109,10 +154,15 @@ export function registerFusionTools(pi: ExtensionAPI, deps: FusionToolDeps): voi
|
|
|
109
154
|
label: "Sidekick",
|
|
110
155
|
description: "Hand off work to your persistent sidekick subagent (one per session; context and shells persist across handoffs; runs on the same machine). block:true (default) waits and returns the report. block:false returns immediately and the report arrives later as a <subagent_completion_notification>. Calling again while a handoff is running injects the message as an interrupt rather than starting a second sidekick.",
|
|
111
156
|
parameters: SidekickParams,
|
|
157
|
+
renderShell: "self",
|
|
158
|
+
renderCall: (args, theme) => frameSidekick(theme as unknown as ThemeLike, "working", new Text(`${theme.fg("toolTitle", theme.bold("◆ sidekick"))} ${theme.fg("dim", firstLine(String(args.message)).slice(0, 100))}`, 0, 0)),
|
|
159
|
+
renderResult: (result, options, theme) => renderToolTranscript(result, options, theme as unknown as ThemeLike, "sidekick"),
|
|
112
160
|
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
113
161
|
const runtime = deps.getRuntime(ctx);
|
|
114
162
|
if (!runtime) return result("Fusion is not active — pick a Fusion pair with /unipi:model.", undefined, true);
|
|
163
|
+
const wasBusy = runtime.isBusy();
|
|
115
164
|
const handoff = runtime.handoff(params.message);
|
|
165
|
+
if (!wasBusy) deps.onHandoffStart?.(ctx);
|
|
116
166
|
if (params.block === false) {
|
|
117
167
|
void handoff.done.then((report) => {
|
|
118
168
|
deps.onReport?.(ctx, report);
|
|
@@ -136,6 +186,9 @@ export function registerFusionTools(pi: ExtensionAPI, deps: FusionToolDeps): voi
|
|
|
136
186
|
label: "Read Sidekick",
|
|
137
187
|
description: "Read a sidekick handoff report by agent_id (omit for the latest). block:true waits for completion (default timeout 2700s when omitted); block:false returns the current progress snapshot immediately.",
|
|
138
188
|
parameters: ReadSubagentParams,
|
|
189
|
+
renderShell: "self",
|
|
190
|
+
renderCall: (args, theme) => frameSidekick(theme as unknown as ThemeLike, "working", new Text(`${theme.fg("toolTitle", theme.bold("◆ read_subagent"))} ${theme.fg("dim", args.agent_id ?? "latest")}`, 0, 0)),
|
|
191
|
+
renderResult: (result, options, theme) => renderToolTranscript(result, options, theme as unknown as ThemeLike, "read_subagent"),
|
|
139
192
|
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
140
193
|
const runtime = deps.getRuntime(ctx);
|
|
141
194
|
if (!runtime) return result("Fusion is not active — pick a Fusion pair with /unipi:model.", undefined, true);
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { Box, Container, Text, type Component } from "@earendil-works/pi-tui";
|
|
2
|
+
import type { SidekickEvent } from "./sidekick-runtime.js";
|
|
3
|
+
|
|
4
|
+
export interface ThemeLike {
|
|
5
|
+
fg: (color: string, text: string) => string;
|
|
6
|
+
bold: (text: string) => string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class RailComponent implements Component {
|
|
10
|
+
constructor(private readonly inner: Component, private readonly rail: string) {}
|
|
11
|
+
|
|
12
|
+
render(width: number): string[] {
|
|
13
|
+
return this.inner.render(Math.max(1, width - 2)).map((line) => `${this.rail} ${line}`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
invalidate(): void {
|
|
17
|
+
this.inner.invalidate?.();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
handleInput(data: string): void {
|
|
21
|
+
this.inner.handleInput?.(data);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function frameSidekick(theme: ThemeLike & { bg: (color: string, text: string) => string }, status: "working" | "completed" | "error", content: Component): Component {
|
|
26
|
+
const railColor = status === "working" ? "accent" : status === "completed" ? "success" : "error";
|
|
27
|
+
const rail = theme.fg(railColor, "▍");
|
|
28
|
+
const boxed = new Box(1, 0, (text) => theme.bg("customMessageBg", text));
|
|
29
|
+
boxed.addChild(new RailComponent(content, rail));
|
|
30
|
+
return boxed;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface TranscriptOptions {
|
|
34
|
+
events: readonly SidekickEvent[];
|
|
35
|
+
droppedEvents?: number;
|
|
36
|
+
header: string;
|
|
37
|
+
expanded: boolean;
|
|
38
|
+
isPartial: boolean;
|
|
39
|
+
report?: { text: string };
|
|
40
|
+
renderText: (markdown: string) => Component;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function firstLine(value: string): string {
|
|
44
|
+
return value.split("\n", 1)[0] ?? "";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function truncate(value: string, max: number): string {
|
|
48
|
+
return value.length > max ? `${value.slice(0, Math.max(0, max - 1))}…` : value;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function primaryArg(name: string, args: Record<string, unknown> | undefined): string {
|
|
52
|
+
if (!args) return "";
|
|
53
|
+
const value = name === "bash"
|
|
54
|
+
? args.command
|
|
55
|
+
: name === "read" || name === "edit" || name === "write"
|
|
56
|
+
? args.path ?? args.file_path ?? args.filePath
|
|
57
|
+
: name === "sidekick"
|
|
58
|
+
? args.message
|
|
59
|
+
: Object.values(args).find((entry) => typeof entry === "string");
|
|
60
|
+
return typeof value === "string" ? truncate(firstLine(value), 100) : "";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function toolComponent(theme: ThemeLike, event: Extract<SidekickEvent, { kind: "tool" }>, expanded: boolean): Text {
|
|
64
|
+
const title = `${event.isError ? theme.fg("error", "✗ ") : ""}${theme.fg("toolTitle", theme.bold(event.name))}`;
|
|
65
|
+
const argument = primaryArg(event.name, event.args);
|
|
66
|
+
const lines = [`${title}${argument.length > 0 ? ` ${theme.fg("accent", argument)}` : ""}`];
|
|
67
|
+
if (!event.done) {
|
|
68
|
+
lines[0] += theme.fg("warning", " ⋯ running");
|
|
69
|
+
} else if (event.output.length > 0) {
|
|
70
|
+
const output = event.output.split("\n");
|
|
71
|
+
const visible = expanded ? output.slice(-40) : output.slice(-3);
|
|
72
|
+
lines.push(...visible.map((line) => theme.fg("toolOutput", truncate(line, 160))));
|
|
73
|
+
}
|
|
74
|
+
return new Text(lines.join("\n"), 0, 0);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function renderSidekickTranscript(theme: ThemeLike, opts: TranscriptOptions): Component {
|
|
78
|
+
const box = new Container();
|
|
79
|
+
box.addChild(new Text(opts.header, 0, 0));
|
|
80
|
+
|
|
81
|
+
if (!opts.isPartial && !opts.expanded) {
|
|
82
|
+
if (opts.report?.text) box.addChild(opts.renderText(opts.report.text));
|
|
83
|
+
box.addChild(new Text(theme.fg("dim", `${String(opts.events.length)} steps · expand to see the transcript`), 0, 0));
|
|
84
|
+
return box;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const dropped = opts.droppedEvents ?? 0;
|
|
88
|
+
const start = opts.expanded ? 0 : Math.max(0, opts.events.length - 8);
|
|
89
|
+
if (opts.expanded && dropped > 0) {
|
|
90
|
+
box.addChild(new Text(theme.fg("dim", `… ${String(dropped)} earliest steps dropped`), 0, 0));
|
|
91
|
+
} else if (start > 0) {
|
|
92
|
+
box.addChild(new Text(theme.fg("dim", `… ${String(start + dropped)} earlier steps`), 0, 0));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
for (const event of opts.events.slice(start)) {
|
|
96
|
+
if (event.kind === "tool") box.addChild(toolComponent(theme, event, opts.expanded));
|
|
97
|
+
else if (event.text.trim()) box.addChild(opts.renderText(event.text.trim()));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (!opts.isPartial && opts.expanded && opts.report?.text) {
|
|
101
|
+
// The final assistant message usually IS the report; don't print it twice.
|
|
102
|
+
const last = opts.events.at(-1);
|
|
103
|
+
if (last?.kind === "text" && last.text.trim() === opts.report.text.trim()) return box;
|
|
104
|
+
box.addChild(new Text(theme.fg("dim", "── report ──"), 0, 0));
|
|
105
|
+
box.addChild(opts.renderText(opts.report.text));
|
|
106
|
+
}
|
|
107
|
+
return box;
|
|
108
|
+
}
|