@pi-unipi/fusion 2.17.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.
@@ -0,0 +1,194 @@
1
+ /**
2
+ * @pi-unipi/fusion — `/unipi:fusion-preset` curation component
3
+ *
4
+ * Two-column checklist over every available model:
5
+ *
6
+ * Search: glm
7
+ * ─────────────────────────────────────────────────────
8
+ * L S model
9
+ * › [x][ ] anthropic/claude-opus-4-6
10
+ * [ ][x] omniroute/zai/glm-5.3-flash ◆ active sidekick
11
+ * [ ][ ] omniroute/deepseek/v4-flash
12
+ *
13
+ * ↑/↓ select · ←/→ column · space toggle lead · Enter save · esc cancel · type to filter
14
+ *
15
+ * Keys follow our overlay conventions (task-manager dock, pi's own model
16
+ * selector): arrows navigate, `Enter` is the primary action (save), `esc`
17
+ * cancels, `tab`/`←→` switch the L/S column, `space` toggles membership.
18
+ * Defaults are not edited here — confirming a Fusion pair in /unipi:model
19
+ * records it as the default.
20
+ */
21
+
22
+ import { Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
23
+ import { frameOverlay } from "@pi-unipi/core";
24
+ import type { ActiveSelection, FusionPreset, ModelKey } from "./preset.js";
25
+
26
+ export interface PresetEditorModel {
27
+ key: ModelKey;
28
+ name: string;
29
+ }
30
+
31
+ export type PresetEditorResult =
32
+ | {
33
+ type: "saved";
34
+ target: "global" | "project";
35
+ curation: Pick<FusionPreset, "lead" | "sidekick" | "default">;
36
+ }
37
+ | { type: "cancelled" };
38
+
39
+ export interface PresetEditorOptions {
40
+ models: readonly PresetEditorModel[];
41
+ initial: Pick<FusionPreset, "lead" | "sidekick" | "default">;
42
+ /** Current selection — its pair becomes the default when still curated. */
43
+ active: ActiveSelection | undefined;
44
+ initialTarget: "global" | "project";
45
+ theme: { fg: (color: string, text: string) => string; bold: (text: string) => string };
46
+ onDone: (result: PresetEditorResult) => void;
47
+ onRenderRequest?: (() => void) | undefined;
48
+ visibleRows?: number | undefined;
49
+ }
50
+
51
+ function printable(data: string): string | undefined {
52
+ if (data.length !== 1) return undefined;
53
+ const code = data.charCodeAt(0);
54
+ if (code < 32 || code === 127) return undefined;
55
+ return data;
56
+ }
57
+
58
+ export class PresetEditor {
59
+ private readonly opts: PresetEditorOptions;
60
+ private lead: Set<ModelKey>;
61
+ private sidekick: Set<ModelKey>;
62
+ private target: "global" | "project";
63
+ private search = "";
64
+ private selected = 0;
65
+ private column: "lead" | "sidekick" = "lead";
66
+ private done = false;
67
+
68
+ constructor(opts: PresetEditorOptions) {
69
+ this.opts = opts;
70
+ this.lead = new Set(opts.initial.lead);
71
+ this.sidekick = new Set(opts.initial.sidekick);
72
+ this.target = opts.initialTarget;
73
+ }
74
+
75
+ private filtered(): PresetEditorModel[] {
76
+ const q = this.search.toLowerCase();
77
+ const all = this.opts.models;
78
+ const list = q.length === 0 ? [...all] : all.filter((m) => `${m.key} ${m.name}`.toLowerCase().includes(q));
79
+ // Selected models float to the top so the curated set is visible at a glance.
80
+ list.sort((a, b) => {
81
+ const sa = this.lead.has(a.key) || this.sidekick.has(a.key) ? 0 : 1;
82
+ const sb = this.lead.has(b.key) || this.sidekick.has(b.key) ? 0 : 1;
83
+ if (sa !== sb) return sa - sb;
84
+ return a.key.localeCompare(b.key);
85
+ });
86
+ return list;
87
+ }
88
+
89
+ handleInput(data: string): void {
90
+ if (this.done) return;
91
+ const list = this.filtered();
92
+ const cur = list[this.selected];
93
+ if (matchesKey(data, Key.escape)) {
94
+ this.done = true;
95
+ this.opts.onDone({ type: "cancelled" });
96
+ return;
97
+ }
98
+ if (matchesKey(data, Key.enter) || data === "\r") {
99
+ // Enter = save & close, the primary action in every other overlay.
100
+ this.done = true;
101
+ const lead = [...this.lead];
102
+ const sidekick = [...this.sidekick];
103
+ const def: FusionPreset["default"] = {};
104
+ const active = this.opts.active;
105
+ const dl =
106
+ active?.kind === "fusion" && this.lead.has(active.lead)
107
+ ? active.lead
108
+ : this.opts.initial.default.lead !== undefined && this.lead.has(this.opts.initial.default.lead)
109
+ ? this.opts.initial.default.lead
110
+ : lead[0];
111
+ const ds =
112
+ active?.kind === "fusion" && this.sidekick.has(active.sidekick)
113
+ ? active.sidekick
114
+ : this.opts.initial.default.sidekick !== undefined && this.sidekick.has(this.opts.initial.default.sidekick)
115
+ ? this.opts.initial.default.sidekick
116
+ : sidekick[0];
117
+ if (dl !== undefined) def.lead = dl;
118
+ if (ds !== undefined) def.sidekick = ds;
119
+ this.opts.onDone({ type: "saved", target: this.target, curation: { lead, sidekick, default: def } });
120
+ return;
121
+ }
122
+ if (matchesKey(data, Key.up)) this.selected = Math.max(0, this.selected - 1);
123
+ else if (matchesKey(data, Key.down)) this.selected = Math.min(Math.max(0, list.length - 1), this.selected + 1);
124
+ else if (matchesKey(data, Key.left) || matchesKey(data, "shift+tab")) this.column = "lead";
125
+ else if (matchesKey(data, Key.right) || matchesKey(data, Key.tab)) this.column = "sidekick";
126
+ else if (data === " " && cur) this.toggle(this.column === "lead" ? this.lead : this.sidekick, cur.key);
127
+ else if (matchesKey(data, Key.backspace) || data === "\x7f") {
128
+ this.search = this.search.slice(0, -1);
129
+ this.selected = 0;
130
+ } else {
131
+ const ch = printable(data);
132
+ if (ch === undefined) return;
133
+ this.search += ch;
134
+ this.selected = 0;
135
+ }
136
+ this.opts.onRenderRequest?.();
137
+ }
138
+
139
+ private toggle(set: Set<ModelKey>, key: ModelKey): void {
140
+ if (set.has(key)) set.delete(key);
141
+ else set.add(key);
142
+ }
143
+
144
+ invalidate(): void {}
145
+
146
+ render(width: number): string[] {
147
+ return frameOverlay(this.renderBody(Math.max(4, width - 2)), width, { title: "Fusion preset" });
148
+ }
149
+
150
+ private renderBody(width: number): string[] {
151
+ const t = this.opts.theme;
152
+ const list = this.filtered();
153
+ if (this.selected >= list.length) this.selected = Math.max(0, list.length - 1);
154
+ const active = this.opts.active;
155
+ const lines: string[] = [];
156
+ lines.push(`${t.fg("accent", t.bold("Fusion preset"))} ${t.fg("dim", `· ${String(this.lead.size)} lead · ${String(this.sidekick.size)} sidekick · writes to ${this.target}`)}`);
157
+ lines.push(`${t.fg("dim", "Search:")} ${this.search.length > 0 ? t.fg("text", this.search) : t.fg("dim", "(type to filter)")}`);
158
+ lines.push(t.fg("dim", "─".repeat(Math.max(1, width - 2))));
159
+ const colHead = (label: string, col: "lead" | "sidekick") =>
160
+ this.column === col ? t.fg("accent", t.bold(label)) : t.fg("dim", label);
161
+ lines.push(` ${colHead("L", "lead")} ${colHead("S", "sidekick")} ${t.fg("dim", "model")}`);
162
+ const win = this.opts.visibleRows ?? 14;
163
+ const start = Math.max(0, Math.min(this.selected - Math.floor(win / 2), list.length - win));
164
+ const end = Math.min(list.length, start + win);
165
+ if (list.length === 0) lines.push(t.fg("warning", " No models match."));
166
+ for (let i = start; i < end; i++) {
167
+ const m = list[i];
168
+ if (!m) continue;
169
+ const hl = i === this.selected;
170
+ const ptr = hl ? t.fg("accent", "›") : " ";
171
+ const box = (on: boolean, col: "lead" | "sidekick") => {
172
+ const focused = hl && this.column === col;
173
+ const glyph = on ? "[x]" : "[ ]";
174
+ return focused ? t.fg("accent", t.bold(glyph)) : on ? t.fg("success", glyph) : t.fg("dim", glyph);
175
+ };
176
+ const l = box(this.lead.has(m.key), "lead");
177
+ const s = box(this.sidekick.has(m.key), "sidekick");
178
+ const name = hl ? t.fg("accent", m.key) : t.fg("text", m.key);
179
+ const tags: string[] = [];
180
+ if (m.key === this.opts.initial.default.lead) tags.push("default lead");
181
+ if (m.key === this.opts.initial.default.sidekick) tags.push("default sidekick");
182
+ if (active?.kind === "fusion") {
183
+ if (m.key === active.lead) tags.push("active lead");
184
+ if (m.key === active.sidekick) tags.push("active sidekick");
185
+ }
186
+ const tag = tags.length > 0 ? ` ${t.fg("dim", `(${tags.join(", ")})`)}` : "";
187
+ lines.push(truncateToWidth(` ${ptr} ${l} ${s} ${name}${tag}`, Math.max(1, width - 1)));
188
+ }
189
+ if (list.length > win) lines.push(t.fg("dim", ` ${String(start + 1)}-${String(end)} of ${String(list.length)}`));
190
+ lines.push("");
191
+ lines.push(t.fg("dim", `↑/↓ select · ←/→ column · space toggle ${this.column} · Enter save · esc cancel · type to filter`));
192
+ return lines;
193
+ }
194
+ }
package/src/preset.ts ADDED
@@ -0,0 +1,259 @@
1
+ /**
2
+ * @pi-unipi/fusion — fusion preset store
3
+ *
4
+ * The preset is the user-curated, finite model list the `/unipi:model` picker
5
+ * shows (pi's full catalogue can be 400+ entries). It also remembers the
6
+ * per-model effort (thinking level), the default lead/sidekick pair, the
7
+ * currently active selection, and the MRU list.
8
+ *
9
+ * Layers (deep-merged, project on top; arrays replace, objects merge):
10
+ * global ~/.unipi/config/fusion/preset.json
11
+ * project <cwd>/.unipi/fusion-preset.json
12
+ *
13
+ * Writes go to the layer the user chose (default global). The active
14
+ * selection + recent list are runtime state and always persist globally.
15
+ */
16
+
17
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from "node:fs";
18
+ import { homedir } from "node:os";
19
+ import { dirname, join } from "node:path";
20
+
21
+ export const PRESET_SCHEMA_VERSION = 1;
22
+
23
+ /** pi thinking levels in ascending effort order (used by ←/→ in the picker). */
24
+ export const EFFORT_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
25
+ export type EffortLevel = (typeof EFFORT_LEVELS)[number];
26
+
27
+ export type FusionBadge = "new" | "promotion" | "beta";
28
+
29
+ export const RECENT_LIMIT = 5;
30
+
31
+ /** `provider/modelId` */
32
+ export type ModelKey = string;
33
+
34
+ export interface FusionPair {
35
+ lead: ModelKey;
36
+ sidekick: ModelKey;
37
+ }
38
+
39
+ export type ActiveSelection =
40
+ | { kind: "single"; model: ModelKey }
41
+ | {
42
+ kind: "fusion";
43
+ lead: ModelKey;
44
+ sidekick: ModelKey;
45
+ /** Fusion-row efforts are independent of per-model memory. */
46
+ leadEffort?: EffortLevel | undefined;
47
+ sidekickEffort?: EffortLevel | undefined;
48
+ };
49
+
50
+ export interface FusionPreset {
51
+ schema_version: number;
52
+ /** Models offered as lead (also shown as plain single-model rows). */
53
+ lead: ModelKey[];
54
+ /** Models offered as sidekick. */
55
+ sidekick: ModelKey[];
56
+ /** Default pair used when the Fusion row is confirmed without editing. */
57
+ default: Partial<FusionPair>;
58
+ /** Remembered per-model effort. */
59
+ effort: Record<ModelKey, EffortLevel>;
60
+ /** MRU single models / leads, newest first, max RECENT_LIMIT. */
61
+ recent: ModelKey[];
62
+ /** Optional hand-curated model badge metadata. */
63
+ badges: Record<ModelKey, FusionBadge>;
64
+ /** What the user last confirmed in the picker. */
65
+ active?: ActiveSelection | undefined;
66
+ }
67
+
68
+ export function emptyPreset(): FusionPreset {
69
+ return {
70
+ schema_version: PRESET_SCHEMA_VERSION,
71
+ lead: [],
72
+ sidekick: [],
73
+ default: {},
74
+ effort: {},
75
+ recent: [],
76
+ badges: {},
77
+ };
78
+ }
79
+
80
+ export function globalPresetPath(home = homedir()): string {
81
+ return join(home, ".unipi", "config", "fusion", "preset.json");
82
+ }
83
+
84
+ export function projectPresetPath(cwd: string): string {
85
+ return join(cwd, ".unipi", "fusion-preset.json");
86
+ }
87
+
88
+ export function modelKey(model: { provider: string; id: string }): ModelKey {
89
+ return `${model.provider}/${model.id}`;
90
+ }
91
+
92
+ export function splitModelKey(key: ModelKey): { provider: string; id: string } | undefined {
93
+ const slash = key.indexOf("/");
94
+ if (slash <= 0 || slash === key.length - 1) return undefined;
95
+ return { provider: key.slice(0, slash), id: key.slice(slash + 1) };
96
+ }
97
+
98
+ export function isEffortLevel(value: unknown): value is EffortLevel {
99
+ return typeof value === "string" && (EFFORT_LEVELS as readonly string[]).includes(value);
100
+ }
101
+
102
+ function stringArray(value: unknown): string[] {
103
+ if (!Array.isArray(value)) return [];
104
+ const out: string[] = [];
105
+ for (const entry of value) {
106
+ if (typeof entry === "string" && entry.includes("/") && !out.includes(entry)) out.push(entry);
107
+ }
108
+ return out;
109
+ }
110
+
111
+ /** Validate + normalise an arbitrary JSON value into a partial preset. */
112
+ export function parsePreset(raw: unknown): Partial<FusionPreset> {
113
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {};
114
+ const r = raw as Record<string, unknown>;
115
+ const out: Partial<FusionPreset> = {};
116
+ if ("lead" in r) out.lead = stringArray(r["lead"]);
117
+ if ("sidekick" in r) out.sidekick = stringArray(r["sidekick"]);
118
+ if (typeof r["default"] === "object" && r["default"] !== null) {
119
+ const d = r["default"] as Record<string, unknown>;
120
+ out.default = {};
121
+ if (typeof d["lead"] === "string") out.default.lead = d["lead"];
122
+ if (typeof d["sidekick"] === "string") out.default.sidekick = d["sidekick"];
123
+ }
124
+ if (typeof r["effort"] === "object" && r["effort"] !== null) {
125
+ const effort: Record<string, EffortLevel> = {};
126
+ for (const [k, v] of Object.entries(r["effort"] as Record<string, unknown>)) {
127
+ if (isEffortLevel(v)) effort[k] = v;
128
+ }
129
+ out.effort = effort;
130
+ }
131
+ if ("recent" in r) out.recent = stringArray(r["recent"]).slice(0, RECENT_LIMIT);
132
+ if (typeof r["badges"] === "object" && r["badges"] !== null) {
133
+ const badges: Record<ModelKey, FusionBadge> = {};
134
+ for (const [k, v] of Object.entries(r["badges"] as Record<string, unknown>)) {
135
+ if (v === "new" || v === "promotion" || v === "beta") badges[k] = v;
136
+ }
137
+ out.badges = badges;
138
+ }
139
+ const active = r["active"];
140
+ if (typeof active === "object" && active !== null) {
141
+ const a = active as Record<string, unknown>;
142
+ if (a["kind"] === "single" && typeof a["model"] === "string") {
143
+ out.active = { kind: "single", model: a["model"] };
144
+ } else if (
145
+ a["kind"] === "fusion" &&
146
+ typeof a["lead"] === "string" &&
147
+ typeof a["sidekick"] === "string"
148
+ ) {
149
+ out.active = {
150
+ kind: "fusion",
151
+ lead: a["lead"],
152
+ sidekick: a["sidekick"],
153
+ ...(isEffortLevel(a["leadEffort"]) ? { leadEffort: a["leadEffort"] } : {}),
154
+ ...(isEffortLevel(a["sidekickEffort"]) ? { sidekickEffort: a["sidekickEffort"] } : {}),
155
+ };
156
+ }
157
+ }
158
+ return out;
159
+ }
160
+
161
+ export function mergePresets(base: FusionPreset, over: Partial<FusionPreset>): FusionPreset {
162
+ return {
163
+ schema_version: PRESET_SCHEMA_VERSION,
164
+ lead: over.lead ?? base.lead,
165
+ sidekick: over.sidekick ?? base.sidekick,
166
+ default: { ...base.default, ...(over.default ?? {}) },
167
+ effort: { ...base.effort, ...(over.effort ?? {}) },
168
+ recent: over.recent ?? base.recent,
169
+ badges: { ...base.badges, ...(over.badges ?? {}) },
170
+ active: over.active ?? base.active,
171
+ };
172
+ }
173
+
174
+ function readJson(path: string): unknown {
175
+ try {
176
+ if (!existsSync(path)) return undefined;
177
+ return JSON.parse(readFileSync(path, "utf8")) as unknown;
178
+ } catch {
179
+ return undefined;
180
+ }
181
+ }
182
+
183
+ export interface LoadedPreset {
184
+ preset: FusionPreset;
185
+ globalPath: string;
186
+ projectPath: string;
187
+ hasProjectLayer: boolean;
188
+ }
189
+
190
+ export function loadPreset(cwd: string, home = homedir()): LoadedPreset {
191
+ const globalPath = globalPresetPath(home);
192
+ const projectPath = projectPresetPath(cwd);
193
+ const globalRaw = readJson(globalPath);
194
+ const projectRaw = readJson(projectPath);
195
+ let preset = mergePresets(emptyPreset(), parsePreset(globalRaw));
196
+ const hasProjectLayer = projectRaw !== undefined;
197
+ if (hasProjectLayer) preset = mergePresets(preset, parsePreset(projectRaw));
198
+ return { preset, globalPath, projectPath, hasProjectLayer };
199
+ }
200
+
201
+ function writeJsonAtomic(path: string, value: unknown): void {
202
+ mkdirSync(dirname(path), { recursive: true });
203
+ const tmp = `${path}.${String(process.pid)}.tmp`;
204
+ writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, "utf8");
205
+ renameSync(tmp, path);
206
+ }
207
+
208
+ /** Persist the curated lists (lead/sidekick/default) to one layer. */
209
+ export function saveCuration(
210
+ path: string,
211
+ curation: Pick<FusionPreset, "lead" | "sidekick" | "default">,
212
+ ): void {
213
+ const existing = parsePreset(readJson(path));
214
+ writeJsonAtomic(path, {
215
+ schema_version: PRESET_SCHEMA_VERSION,
216
+ ...existing,
217
+ lead: curation.lead,
218
+ sidekick: curation.sidekick,
219
+ default: curation.default,
220
+ });
221
+ }
222
+
223
+ /** Persist runtime state (effort / recent / active) — always to the global layer. */
224
+ export function saveRuntimeState(
225
+ globalPath: string,
226
+ state: Pick<FusionPreset, "effort" | "recent"> & { active?: ActiveSelection | undefined },
227
+ ): void {
228
+ const existing = parsePreset(readJson(globalPath));
229
+ writeJsonAtomic(globalPath, {
230
+ schema_version: PRESET_SCHEMA_VERSION,
231
+ lead: existing.lead ?? [],
232
+ sidekick: existing.sidekick ?? [],
233
+ default: existing.default ?? {},
234
+ ...existing,
235
+ effort: state.effort,
236
+ recent: state.recent.slice(0, RECENT_LIMIT),
237
+ active: state.active,
238
+ });
239
+ }
240
+
241
+ // ── pure helpers used by the picker ────────────────────────────────────────
242
+
243
+ export function pushRecent(recent: readonly ModelKey[], key: ModelKey): ModelKey[] {
244
+ return [key, ...recent.filter((k) => k !== key)].slice(0, RECENT_LIMIT);
245
+ }
246
+
247
+ export function stepEffort(current: EffortLevel, delta: -1 | 1): EffortLevel {
248
+ const index = EFFORT_LEVELS.indexOf(current);
249
+ const next = Math.min(EFFORT_LEVELS.length - 1, Math.max(0, index + delta));
250
+ return EFFORT_LEVELS[next] ?? current;
251
+ }
252
+
253
+ /** Devin-style label: off→None, xhigh→XHigh, max→Max, others capitalised. */
254
+ export function effortLabel(level: EffortLevel): string {
255
+ if (level === "off") return "None";
256
+ if (level === "xhigh") return "XHigh";
257
+ if (level === "max") return "Max";
258
+ return level.charAt(0).toUpperCase() + level.slice(1);
259
+ }
package/src/prompts.ts ADDED
@@ -0,0 +1,96 @@
1
+ /**
2
+ * @pi-unipi/fusion — Local Fusion prompt text
3
+ *
4
+ * Two prompt fragments make Fusion behave like a lead + sidekick pair rather
5
+ * than two unrelated models:
6
+ *
7
+ * - `leadPolicy()` is appended to the LEAD's system prompt on every turn
8
+ * while Fusion is active (before_agent_start). It is the delegate-by-
9
+ * default contract: what to hand off, what to keep, how to brief, how to
10
+ * review.
11
+ * - `sidekickSystemPrompt()` is appended to the SIDEKICK child's system
12
+ * prompt once at spawn. It tells the child what it is, what it never
13
+ * does, and how to report.
14
+ *
15
+ * Both are plain strings so they can be unit-tested and diffed. Keep them
16
+ * self-contained; the sidekick never sees the lead's conversation.
17
+ */
18
+
19
+ export interface FusionIdentity {
20
+ leadName: string;
21
+ leadEffort: string;
22
+ sidekickName: string;
23
+ sidekickEffort: string;
24
+ }
25
+
26
+ function cap(s: string): string {
27
+ return s.length === 0 ? s : s.charAt(0).toUpperCase() + s.slice(1);
28
+ }
29
+
30
+ /** One-line identity, e.g. `You are powered by Fusion (Claude Opus 4.6 Medium + GLM 5.3 Flash High).` */
31
+ export function fusionIdentityLine(id: FusionIdentity): string {
32
+ const lead = id.leadEffort ? `${id.leadName} ${cap(id.leadEffort)}` : id.leadName;
33
+ const side = id.sidekickEffort ? `${id.sidekickName} ${cap(id.sidekickEffort)}` : id.sidekickName;
34
+ return `You are powered by Fusion (${lead} + ${side}).`;
35
+ }
36
+
37
+ export function leadPolicy(id: FusionIdentity): string {
38
+ return `${fusionIdentityLine(id)}
39
+
40
+ ## Sidekick
41
+ You have a \`sidekick\` tool: a persistent subagent that works alongside you on the same machine (shared filesystem and repos; its shell sessions are separate from yours). You are the lead: you own the outcome and the user-facing and authority actions — talking to the user, planning, and directing the sidekick. The user interacts with one agent: you. Do not mention the sidekick or distinguish its work from yours unless the user explicitly asks; describe all work as your own, in the first person. The sidekick does the hands-on work you direct, such as implementing changes and verifying results. Your job is to give it context and done-criteria, then review, critique, and decide what to do with its report — decide and direct, don't re-derive what it already gave you or take over its work. When you write a todo list, mark the steps you'll hand off so you don't drift into doing them yourself.
42
+
43
+ - **Delegate by default** the hands-on work — implementation and verification; keep judgment, design, and the user-facing and authority actions.
44
+ - **Implementation:** 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, e.g. a stray import) or correctness-critical (below). Anything that needs a test written or run, touches more than one file, or that you would want to look over again afterwards is not trivial — brief it. The moment your investigation settles the design, your next action is a brief, not an edit.
45
+ - **Verification & environment:** delegate environment setup and repair — even when the failure blocks something you were doing yourself — and running builds, linters, type-checks, and test suites. Name the narrowest checks that cover the change; the sidekick treats your list as mandatory, so a "run everything" brief re-gates unchanged work on every handoff. Reserve a full-suite pass for at most one final gate.
46
+ - **Keep for yourself:**
47
+ - Investigations (codebase exploration, root-cause tracing, git-history archaeology) when your reply will restate the findings as your own grounded answer.
48
+ - Planning and design decisions.
49
+ - **Correctness-critical work — where wrong output looks plausible instead of erroring.** Queries against shared or production data systems, data analysis and measurement (counts, metrics), eval/benchmark harnesses, prompt/rubric/grader text, and pipeline/threshold/sampling configuration. Author, run, and check that work yourself regardless of size; delegate only mechanical execution of a recipe you fully authored — never the authoring or the checking.
50
+ - Reviewing the sidekick's diff before it lands.
51
+ - Talking to the user, commits, pushes, pull requests, and code-review responses.
52
+ - The sidekick remembers everything from previous handoffs (code it wrote, files it explored, your earlier instructions), so don't re-explain context it already has. Its runtime state also persists: background shells and processes it started (dev servers, DB connections, long-running commands) usually survive between handoffs. Every brief that involves servers or long-running processes must say what to do with them — e.g. "the server from the previous handoff may still be running; check and reuse it, restart only if it's gone or the code changed" — and, when a later handoff may need them, tell it to leave them running.
53
+ - On each handoff give it: the goal, your plan, the constraints, the relevant files, and how to verify. Settle the consequential choices before handing off: the exact interface (signature, types, data shape, which existing helper to use) and the exact tests (cases, assertions, where to stub). Don't leave alternatives for it to pick; it will guess, and a wrong guess costs a whole extra round. A code snippet is fine when it is the clearest way to say it.
54
+ - **Never make the sidekick redo work you already did.** Results you already derived go into the brief as settled inputs (the values, or the path to the file holding them), not as an invitation to recompute. Ask for re-derivation only when you have reason to doubt them.
55
+ - Blocking dispatch is the default: a \`sidekick\` call waits and returns the report. Pass \`block: false\` only when you genuinely have parallel lead work to do meanwhile; don't start work redundant with what the sidekick is doing, and never poll \`read_subagent\` in a loop. When you have run out of parallel work and still need the report, wait with \`read_subagent\` (\`block: true\`) rather than ending your turn or guessing.
56
+ - Calling \`sidekick\` again while a handoff is running injects the new message into that handoff as an interrupt — it never starts a second sidekick. Use it to redirect with a corrected brief, tell it to wrap up and report what it has, or send a purely informational update.
57
+ - If the user sends a message while a handoff is in flight, act on it before going back to waiting: handle lead-only work yourself, and for anything that concerns the running handoff, send an interrupt. Resume waiting only once you have deliberately decided the message changes nothing for the sidekick.
58
+ - Track the lead-only actions you have promised the user (messages, review replies, commit/PR updates) — the sidekick cannot do these, so a brief that includes one silently drops it. Do each one the moment the handoff returns, before dispatching a follow-up.
59
+ - Review the evidence it reports back (diff, test output, files, screenshots, logs) instead of re-running it yourself. Its prose is a claim, the artifacts are the evidence: have it hand back their paths rather than clean them up. Do final verification yourself only when the user needs your own recorded proof, the sidekick cannot access the required surface, or its evidence is incomplete or suspicious.
60
+ - **Review its code before it lands.** A report about code it wrote is a landing point: read the full diff and give a verdict before your next action — including before stopping or blocking on the user, and before you commit. Complete the whole review, then dispatch all findings in ONE consolidated rework brief — defects, incomplete items, and gaps together. Don't send multiple small rework handoffs, and don't take the work over after a single miss. Fix something yourself only if it is truly one or two of your own turns including verification.
61
+ - Answer its questions concretely in one handoff — don't make it ask twice. A blocker is still a handoff: pick a direction and hand execution back. If it reports an environment blocker, prefer telling it how to get unblocked. Take over only when the blocker needs your authority, or after a couple of rounds it is still stuck on the same problem.
62
+ - The sidekick never sees the user's messages or your conversation — it knows only your brief and what it discovers itself. Pass along relevant user requirements, decisions, and constraints explicitly. It can use credentials already in the environment, but cannot request new secrets from the user; when a task needs a lead-only action, it will stop and report to you.
63
+ - **Never hand off implementation of an unsettled ask.** If any part still needs exploration, an audit, or the user's agreement, settle it first.
64
+ - **User urgency:** when the user is waiting on a concrete deliverable, do the minimal action that unblocks them yourself immediately — even if it is normally delegated — and move slow validation (test gates, full check suites) off the critical path.
65
+
66
+ ### Known failure patterns when delegating
67
+ - **Premature implementation briefing.** Working solo you would catch a wrong assumption as you go; when delegating, the sidekick executes what you wrote, and changing course afterwards is expensive. Hold your plan to a higher confidence bar than you would need to start yourself. Every claim your plan depends on must either be verified or explicitly marked as a hypothesis for the sidekick to verify; verification instructions must cover every surface you discovered.
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
+ }
70
+
71
+ /** One-time reminder appended to the lead's first direct edit/write while Fusion is active. */
72
+ export const FIRST_EDIT_NUDGE =
73
+ "<system_guidance>You made a direct edit yourself instead of delegating to the sidekick. That is fine for a trivially small change (one you can make and confirm in 1-2 turns with nothing left to test). For anything larger — multiple files, anything that needs a test run, anything you would want to look over again — write a brief and hand it to `sidekick` instead: you design and review, it implements and verifies. This reminder is shown once.</system_guidance>";
74
+
75
+ export function sidekickSystemPrompt(id: FusionIdentity): string {
76
+ return `## Role: Fusion sidekick
77
+ You are the sidekick half of a Fusion pair. A lead agent (${id.leadName}) plans, talks to the user, and reviews; you do the hands-on work it hands you. You run on the same machine and repository as the lead, with your own shell sessions. Your conversation persists across handoffs: remember what you did, what you learned, and which processes you left running.
78
+
79
+ You never see the user or the lead's conversation — only the brief in front of you. Treat the brief as authoritative: execute it exactly, do not re-derive results it states as settled, and do not substitute your own design when it specifies an interface, a test, or a query shape. If the brief is ambiguous or something in the environment contradicts it, stop at that point and report the discrepancy with your evidence instead of guessing.
80
+
81
+ What you do not do:
82
+ - Talk to the user, ask the user questions, or request new secrets. The lead is the only voice the user hears.
83
+ - Commit, push, open or update pull requests, respond to code review, or change repository security/compliance settings. Leave changes in the working tree for the lead to review.
84
+ - Perform irreversible destructive operations (deleting files you did not create, \`rm -rf\`, force-pushes, dropping data) unless the brief explicitly names that exact action.
85
+ - Widen the scope: fix only what the brief asks; note anything else you found.
86
+ - Leave long-running processes in an unknown state: say what is still running and how to reach it.
87
+
88
+ How you report (your final message is what the lead reads — make it the whole story):
89
+ 1. **Result** — done / partially done / blocked, in one line.
90
+ 2. **Changes** — files touched, one line each, plus \`git diff --stat\` style summary if code changed.
91
+ 3. **Verification** — the exact commands you ran and their outcomes (pass/fail counts, exit codes). Include paths to logs, screenshots, or other artifacts rather than deleting them. Never claim a check passed without having run it.
92
+ 4. **Open items** — anything the brief asked for that you did not finish, discrepancies, and questions for the lead, each stated so it can be answered in one reply.
93
+ 5. **Runtime state** — processes or servers still running (pid, port, how to reuse).
94
+
95
+ Prefer compact, idiomatic code that follows the repository's existing conventions; do not add or remove comments unless asked; do not create documentation files unless asked.`;
96
+ }
package/src/savings.ts ADDED
@@ -0,0 +1,22 @@
1
+ import type { PickerModel } from "./picker.js";
2
+ import type { SidekickUsage } from "./sidekick-runtime.js";
3
+
4
+ export function estimateSavings(
5
+ usage: SidekickUsage,
6
+ lead: PickerModel["cost"],
7
+ side: PickerModel["cost"],
8
+ ): { sidekickUsd: number; atLeadUsd: number; savedUsd: number } {
9
+ const sidekickUsd = priceUsage(usage, side);
10
+ const atLeadUsd = priceUsage(usage, lead);
11
+ return { sidekickUsd, atLeadUsd, savedUsd: atLeadUsd - sidekickUsd };
12
+ }
13
+
14
+ function priceUsage(usage: SidekickUsage, cost: PickerModel["cost"]): number {
15
+ if (!cost) return usage.cost;
16
+ return (
17
+ usage.input * cost.input +
18
+ usage.cacheRead * cost.cachedInput +
19
+ usage.cacheWrite * cost.input +
20
+ usage.output * cost.output
21
+ ) / 1_000_000;
22
+ }