pi-taskflow 0.0.12 → 0.0.14
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 +156 -23
- package/extensions/cache.ts +263 -0
- package/extensions/index.ts +194 -118
- package/extensions/init.ts +607 -0
- package/extensions/render.ts +39 -0
- package/extensions/runtime.ts +418 -17
- package/extensions/schema.ts +179 -1
- package/extensions/store.ts +16 -2
- package/extensions/verify.ts +367 -0
- package/package.json +4 -3
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/tf init` — single source of truth for model-role configuration.
|
|
3
|
+
*
|
|
4
|
+
* Exports:
|
|
5
|
+
* INIT_ROLES, RECOMMENDED_DEFAULTS – role catalog & recommended defaults
|
|
6
|
+
* readSettings, writeSettings – settings.json I/O (atomic writes)
|
|
7
|
+
* formatModelOption, buildRoleOptions – picker UI helpers
|
|
8
|
+
* parseCustomModel – custom model string validator
|
|
9
|
+
* modelExists – registry membership check (guards typos)
|
|
10
|
+
* diffRoles – diff engine for preview screen
|
|
11
|
+
* formatRolesReport, formatDiffReport – read-only report formatters
|
|
12
|
+
* runInteractiveInit – full interactive UX flow
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import * as fs from "node:fs";
|
|
16
|
+
import * as path from "node:path";
|
|
17
|
+
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
18
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
19
|
+
import type { ExtensionContext, ExtensionUIContext } from "@earendil-works/pi-coding-agent";
|
|
20
|
+
import { writeFileAtomic } from "./store.ts";
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Role catalog
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
export interface InitRole {
|
|
27
|
+
role: string;
|
|
28
|
+
description: string;
|
|
29
|
+
defaultModel: string;
|
|
30
|
+
/** Filter the model registry to models usable for this role. */
|
|
31
|
+
filter?: (m: Model<Api>) => boolean;
|
|
32
|
+
/** Sort tiebreaker after recommended-first. */
|
|
33
|
+
sort?: (a: Model<Api>, b: Model<Api>) => number;
|
|
34
|
+
/** Prefer `reasoning: true` models in display order. */
|
|
35
|
+
preferReasoning?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const INIT_ROLES: readonly InitRole[] = [
|
|
39
|
+
{
|
|
40
|
+
role: "fast",
|
|
41
|
+
description:
|
|
42
|
+
"Cheap & quick — high-volume, low-stakes tasks (executor, scout, recover, verifier, doc-writer, test-engineer)",
|
|
43
|
+
defaultModel: "openrouter/deepseek/deepseek-v4-flash",
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
role: "strong",
|
|
47
|
+
description:
|
|
48
|
+
"Balanced — planning, review, moderate complexity (planner, reviewer, executor-code)",
|
|
49
|
+
defaultModel: "openrouter/xiaomi/mimo-v2.5-pro",
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
role: "thinker",
|
|
53
|
+
description:
|
|
54
|
+
"Deep analysis — requirements, ambiguity detection, critique (analyst, critic)",
|
|
55
|
+
defaultModel: "openrouter/deepseek/deepseek-v4-pro",
|
|
56
|
+
preferReasoning: true,
|
|
57
|
+
sort: (a, b) => (a.reasoning === b.reasoning ? 0 : a.reasoning ? -1 : 1),
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
role: "arbiter",
|
|
61
|
+
description:
|
|
62
|
+
"Final judgment — tiebreak, plan quality gates (plan-arbiter, final-arbiter)",
|
|
63
|
+
defaultModel: "openrouter/qwen/qwen3.7-max",
|
|
64
|
+
preferReasoning: true,
|
|
65
|
+
sort: (a, b) => (a.reasoning === b.reasoning ? 0 : a.reasoning ? -1 : 1),
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
role: "vision",
|
|
69
|
+
description:
|
|
70
|
+
"Multimodal — UI work, design reading, Figma analysis (executor-ui, visual-explorer)",
|
|
71
|
+
defaultModel: "minimax/MiniMax-M3",
|
|
72
|
+
filter: (m) => m.input.includes("image"),
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
role: "reasoner",
|
|
76
|
+
description:
|
|
77
|
+
"Cautious reasoning — security, risk review, sensitive changes (risk-reviewer, security-reviewer)",
|
|
78
|
+
defaultModel: "z-ai/glm-5.1",
|
|
79
|
+
preferReasoning: true,
|
|
80
|
+
sort: (a, b) => (a.reasoning === b.reasoning ? 0 : a.reasoning ? -1 : 1),
|
|
81
|
+
},
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
/** Derived from INIT_ROLES — the catalog is the single source of truth. */
|
|
85
|
+
export const RECOMMENDED_DEFAULTS: Readonly<Record<string, string>> = Object.fromEntries(
|
|
86
|
+
INIT_ROLES.map((r) => [r.role, r.defaultModel]),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// Settings path
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
/** Returns the current settings.json path (respects PI_CODING_AGENT_DIR). */
|
|
94
|
+
export function getSettingsPath(): string {
|
|
95
|
+
return path.join(getAgentDir(), "settings.json");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// Settings I/O
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
103
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function readSettings(): Record<string, unknown> {
|
|
107
|
+
const sp = getSettingsPath();
|
|
108
|
+
if (!fs.existsSync(sp)) return {};
|
|
109
|
+
const raw: unknown = JSON.parse(fs.readFileSync(sp, "utf-8"));
|
|
110
|
+
if (!isPlainObject(raw)) return {};
|
|
111
|
+
if ("modelRoles" in raw) {
|
|
112
|
+
if (!isPlainObject(raw.modelRoles)) {
|
|
113
|
+
console.warn("[taskflow] settings.json: modelRoles had unexpected shape, treating as empty.");
|
|
114
|
+
raw.modelRoles = {};
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return raw as Record<string, unknown>;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Write settings safely.
|
|
122
|
+
*
|
|
123
|
+
* Strategy: read current on-disk state, merge our changes on top, then
|
|
124
|
+
* write atomically. This preserves keys written by pi's own SettingsManager
|
|
125
|
+
* flusher between our read and write (last-write-wins, but we don't clobber
|
|
126
|
+
* unrelated keys). Additionally creates a timestamped backup before any
|
|
127
|
+
* write to a non-trivial settings file as a belt-and-suspenders safeguard.
|
|
128
|
+
*/
|
|
129
|
+
export function writeSettings(incoming: Record<string, unknown>): string {
|
|
130
|
+
const sp = getSettingsPath();
|
|
131
|
+
let current: Record<string, unknown> = {};
|
|
132
|
+
|
|
133
|
+
// 1. Read current on-disk state (so we can merge, not replace)
|
|
134
|
+
if (fs.existsSync(sp)) {
|
|
135
|
+
try {
|
|
136
|
+
const raw: unknown = JSON.parse(fs.readFileSync(sp, "utf-8"));
|
|
137
|
+
if (isPlainObject(raw)) current = raw;
|
|
138
|
+
} catch {
|
|
139
|
+
/* proceed with empty fallback */
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// 2. Pre-write backup — only for non-trivial files
|
|
144
|
+
const existingKeys = Object.keys(current);
|
|
145
|
+
if (existingKeys.length > 3) {
|
|
146
|
+
try {
|
|
147
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
148
|
+
fs.copyFileSync(sp, `${sp}.bak-tf-${stamp}`);
|
|
149
|
+
} catch {
|
|
150
|
+
/* backup is best-effort */
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// 3. Merge: disk state + our overrides. This is the key safety property —
|
|
155
|
+
// unrelated keys (packages, subagents, UI prefs, etc.) survive even if
|
|
156
|
+
// pi's SettingsManager flushed between our read and write.
|
|
157
|
+
const merged = { ...current, ...incoming };
|
|
158
|
+
writeFileAtomic(sp, JSON.stringify(merged, null, 2) + "\n");
|
|
159
|
+
return sp;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// Picker helpers (pure, fully testable)
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
/** Build a display label for a model in the picker. */
|
|
167
|
+
export function formatModelOption(m: Model<Api>): string {
|
|
168
|
+
const tags: string[] = [];
|
|
169
|
+
if (m.input.includes("image")) tags.push("image ✓");
|
|
170
|
+
if (m.reasoning) tags.push("reasoning ✓");
|
|
171
|
+
const tagStr = tags.length > 0 ? ` · ${tags.join(" · ")}` : "";
|
|
172
|
+
return `${m.name} (${m.provider}/${m.id})${tagStr}`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Build picker options for a single role. */
|
|
176
|
+
export function buildRoleOptions(
|
|
177
|
+
role: InitRole,
|
|
178
|
+
available: ReadonlyArray<Model<Api>>,
|
|
179
|
+
ctx: { current?: string; recommended?: string },
|
|
180
|
+
): string[] {
|
|
181
|
+
const recommendedId = ctx.recommended;
|
|
182
|
+
const pool = role.filter ? available.filter(role.filter) : [...available];
|
|
183
|
+
if (role.sort) pool.sort(role.sort);
|
|
184
|
+
else if (role.preferReasoning) pool.sort((a, b) => (a.reasoning === b.reasoning ? 0 : a.reasoning ? -1 : 1));
|
|
185
|
+
|
|
186
|
+
const seen = new Set<string>();
|
|
187
|
+
const options: string[] = [];
|
|
188
|
+
for (const m of pool) {
|
|
189
|
+
const key = `${m.provider}/${m.id}`;
|
|
190
|
+
if (seen.has(key)) continue;
|
|
191
|
+
seen.add(key);
|
|
192
|
+
const isCurrent = key === ctx.current;
|
|
193
|
+
const isRecommended = key === recommendedId;
|
|
194
|
+
const suffix = isCurrent
|
|
195
|
+
? " · (current)"
|
|
196
|
+
: isRecommended
|
|
197
|
+
? " · (recommended)"
|
|
198
|
+
: "";
|
|
199
|
+
options.push(`${formatModelOption(m)}${suffix}`);
|
|
200
|
+
}
|
|
201
|
+
options.push("───────────────");
|
|
202
|
+
options.push("Custom (type your own)");
|
|
203
|
+
if (ctx.current !== undefined) options.push("Keep current");
|
|
204
|
+
options.push("Back to action menu");
|
|
205
|
+
return options;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Parse a custom model string like "provider/model-id" or "provider/a/b/c". */
|
|
209
|
+
export function parseCustomModel(input: string): { provider: string; id: string } | null {
|
|
210
|
+
const trimmed = input.trim();
|
|
211
|
+
if (!trimmed) return null;
|
|
212
|
+
const slashIdx = trimmed.indexOf("/");
|
|
213
|
+
if (slashIdx < 0) return null;
|
|
214
|
+
const provider = trimmed.slice(0, slashIdx).trim();
|
|
215
|
+
const id = trimmed.slice(slashIdx + 1).trim();
|
|
216
|
+
if (!provider || !id) return null;
|
|
217
|
+
return { provider, id };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Returns true if `provider/id` exists in the available model registry.
|
|
222
|
+
* Used to warn before persisting a hand-typed model that would never resolve
|
|
223
|
+
* at runtime (e.g. a typo or a copy-pasted example string).
|
|
224
|
+
*/
|
|
225
|
+
export function modelExists(
|
|
226
|
+
provider: string,
|
|
227
|
+
id: string,
|
|
228
|
+
available: ReadonlyArray<Model<Api>>,
|
|
229
|
+
): boolean {
|
|
230
|
+
return available.some((m) => m.provider === provider && m.id === id);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
// Diff engine for preview screen
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
export type RoleDiffStatus = "unchanged" | "changed" | "new" | "stale-preserved";
|
|
238
|
+
|
|
239
|
+
export interface RoleDiffEntry {
|
|
240
|
+
role: string;
|
|
241
|
+
status: RoleDiffStatus;
|
|
242
|
+
before?: string;
|
|
243
|
+
after?: string;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function diffRoles(
|
|
247
|
+
before: Record<string, string>,
|
|
248
|
+
after: Record<string, string>,
|
|
249
|
+
catalog: ReadonlyArray<{ role: string }>,
|
|
250
|
+
): RoleDiffEntry[] {
|
|
251
|
+
const seen = new Set<string>();
|
|
252
|
+
const diffs: RoleDiffEntry[] = [];
|
|
253
|
+
for (const c of catalog) {
|
|
254
|
+
seen.add(c.role);
|
|
255
|
+
const b = before[c.role];
|
|
256
|
+
const a = after[c.role];
|
|
257
|
+
if (b === undefined) {
|
|
258
|
+
diffs.push({ role: c.role, status: "new", after: a });
|
|
259
|
+
} else if (b === a) {
|
|
260
|
+
diffs.push({ role: c.role, status: "unchanged", before: b, after: a });
|
|
261
|
+
} else {
|
|
262
|
+
diffs.push({ role: c.role, status: "changed", before: b, after: a });
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
// Append stale keys from `before` that are not in catalog
|
|
266
|
+
for (const key of Object.keys(before)) {
|
|
267
|
+
if (!seen.has(key)) {
|
|
268
|
+
diffs.push({ role: key, status: "stale-preserved", before: before[key], after: before[key] });
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return diffs;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
// Read-only report formatters
|
|
276
|
+
// ---------------------------------------------------------------------------
|
|
277
|
+
|
|
278
|
+
function formatSettingsPath(sp: string): string {
|
|
279
|
+
const home = process.env.HOME ?? "";
|
|
280
|
+
if (home && sp.startsWith(home)) return `~${sp.slice(home.length)}`;
|
|
281
|
+
return sp;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export function formatRolesReport(current: Record<string, string>): string {
|
|
285
|
+
const sp = formatSettingsPath(getSettingsPath());
|
|
286
|
+
if (Object.keys(current).length === 0) {
|
|
287
|
+
return `No modelRoles configured in ${sp}. Use /tf init interactively to select models.`;
|
|
288
|
+
}
|
|
289
|
+
const lines = [`Model roles configured in ${sp}:`, ""];
|
|
290
|
+
for (const role of INIT_ROLES) {
|
|
291
|
+
const val = current[role.role];
|
|
292
|
+
if (val) lines.push(` ${role.role.padEnd(10)} → ${val} (${role.description})`);
|
|
293
|
+
}
|
|
294
|
+
// Append stale keys
|
|
295
|
+
for (const key of Object.keys(current)) {
|
|
296
|
+
if (!INIT_ROLES.some((r) => r.role === key)) {
|
|
297
|
+
lines.push(` ${key.padEnd(10)} → ${current[key]} (stale — not in current role catalog)`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
lines.push("", "To reconfigure, run /tf init interactively.");
|
|
301
|
+
return lines.join("\n");
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const STATUS_SYMBOL: Record<RoleDiffStatus, string> = {
|
|
305
|
+
unchanged: " ",
|
|
306
|
+
changed: "↔ ",
|
|
307
|
+
new: "+ ",
|
|
308
|
+
"stale-preserved": "⚠ ",
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
export function formatDiffReport(
|
|
312
|
+
before: Record<string, string>,
|
|
313
|
+
after: Record<string, string>,
|
|
314
|
+
): string {
|
|
315
|
+
const diffs = diffRoles(before, after, INIT_ROLES);
|
|
316
|
+
const sp = formatSettingsPath(getSettingsPath());
|
|
317
|
+
const lines = [`Wrote model roles to ${sp}:`, ""];
|
|
318
|
+
for (const d of diffs) {
|
|
319
|
+
const sym = STATUS_SYMBOL[d.status];
|
|
320
|
+
if (d.status === "unchanged") {
|
|
321
|
+
lines.push(` ${sym}${d.role.padEnd(10)} → ${d.after} (unchanged)`);
|
|
322
|
+
} else if (d.status === "changed") {
|
|
323
|
+
lines.push(` ${sym}${d.role.padEnd(10)} → ${d.after} (was: ${d.before})`);
|
|
324
|
+
} else if (d.status === "new") {
|
|
325
|
+
lines.push(` ${sym}${d.role.padEnd(10)} → ${d.after} (new)`);
|
|
326
|
+
} else if (d.status === "stale-preserved") {
|
|
327
|
+
lines.push(` ${sym}${d.role.padEnd(10)} → ${d.before} (stale — preserved but not in catalog)`);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return lines.join("\n");
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export function formatFlowResult(result: InitFlowResult): string {
|
|
334
|
+
if (result.kind === "cancelled") return "Init cancelled.";
|
|
335
|
+
if (result.kind === "no-change") {
|
|
336
|
+
return (
|
|
337
|
+
"No changes.\n" +
|
|
338
|
+
Object.entries(result.chosen)
|
|
339
|
+
.map(([k, v]) => ` ${k.padEnd(10)} → ${v}`)
|
|
340
|
+
.join("\n")
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
// kind === "saved"
|
|
344
|
+
const savedPath = formatSettingsPath(result.savedPath);
|
|
345
|
+
return (
|
|
346
|
+
`Saved model roles to ${savedPath}:\n` +
|
|
347
|
+
Object.entries(result.chosen)
|
|
348
|
+
.map(([k, v]) => ` ${k.padEnd(10)} → ${v}`)
|
|
349
|
+
.join("\n")
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ---------------------------------------------------------------------------
|
|
354
|
+
// Main interactive flow
|
|
355
|
+
// ---------------------------------------------------------------------------
|
|
356
|
+
|
|
357
|
+
export type InitFlowResult =
|
|
358
|
+
| { kind: "saved"; chosen: Record<string, string>; savedPath: string }
|
|
359
|
+
| { kind: "no-change"; chosen: Record<string, string> }
|
|
360
|
+
| { kind: "cancelled" };
|
|
361
|
+
|
|
362
|
+
export async function runInteractiveInit(ctx: {
|
|
363
|
+
hasUI: boolean;
|
|
364
|
+
signal: AbortSignal;
|
|
365
|
+
ui: ExtensionUIContext;
|
|
366
|
+
modelRegistry: ExtensionContext["modelRegistry"];
|
|
367
|
+
modelList: Model<Api>[];
|
|
368
|
+
currentRoles: Record<string, string>;
|
|
369
|
+
}): Promise<InitFlowResult> {
|
|
370
|
+
if (!ctx.hasUI) {
|
|
371
|
+
throw new Error("runInteractiveInit requires an interactive session (hasUI=true).");
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const recommended = RECOMMENDED_DEFAULTS;
|
|
375
|
+
const current = ctx.currentRoles;
|
|
376
|
+
const hasCurrent = Object.keys(current).length > 0;
|
|
377
|
+
|
|
378
|
+
// ---- Action menu ----
|
|
379
|
+
const actionOptions = hasCurrent
|
|
380
|
+
? [
|
|
381
|
+
"Use recommended defaults",
|
|
382
|
+
"Configure each role",
|
|
383
|
+
"Edit one role",
|
|
384
|
+
"Show current roles",
|
|
385
|
+
"Cancel",
|
|
386
|
+
]
|
|
387
|
+
: ["Use recommended defaults", "Configure each role"];
|
|
388
|
+
|
|
389
|
+
const action = await ctx.ui.select(
|
|
390
|
+
"What do you want to do with model roles?",
|
|
391
|
+
actionOptions,
|
|
392
|
+
{ signal: ctx.signal },
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
if (action === undefined) return { kind: "cancelled" };
|
|
396
|
+
|
|
397
|
+
// ---- Use recommended defaults ----
|
|
398
|
+
if (action === "Use recommended defaults") {
|
|
399
|
+
const merged: Record<string, string> = { ...recommended };
|
|
400
|
+
for (const key of Object.keys(current)) {
|
|
401
|
+
if (!(key in merged)) merged[key] = current[key];
|
|
402
|
+
}
|
|
403
|
+
const diff = diffRoles(current, merged, INIT_ROLES);
|
|
404
|
+
const noChange = diff.every((d) => d.status === "unchanged" || d.status === "stale-preserved");
|
|
405
|
+
if (noChange) return { kind: "no-change", chosen: merged };
|
|
406
|
+
const savedPath = writeSettings({ ...readSettings(), modelRoles: merged });
|
|
407
|
+
return { kind: "saved", chosen: merged, savedPath };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ---- Show current roles ----
|
|
411
|
+
if (action === "Show current roles") {
|
|
412
|
+
ctx.ui.notify(formatRolesReport(current), "info");
|
|
413
|
+
return { kind: "cancelled" };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// ---- Cancel ----
|
|
417
|
+
if (action === "Cancel") return { kind: "cancelled" };
|
|
418
|
+
|
|
419
|
+
// ---- Configure each role ----
|
|
420
|
+
if (action === "Configure each role") {
|
|
421
|
+
const chosen = await collectRolePicks(ctx, current, recommended, undefined);
|
|
422
|
+
if (chosen === undefined) return { kind: "cancelled" };
|
|
423
|
+
return finalizeOrPreview(ctx, current, chosen, recommended);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// ---- Edit one role ----
|
|
427
|
+
if (action === "Edit one role") {
|
|
428
|
+
const chosen = await collectSingleRoleEdit(ctx, current, recommended);
|
|
429
|
+
if (chosen === undefined) return { kind: "cancelled" };
|
|
430
|
+
return finalizeOrPreview(ctx, current, chosen, recommended);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
return { kind: "cancelled" };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// ---------------------------------------------------------------------------
|
|
437
|
+
// Internal helpers
|
|
438
|
+
// ---------------------------------------------------------------------------
|
|
439
|
+
|
|
440
|
+
/** Collect picks for all roles. Returns undefined if user escapes to action menu. */
|
|
441
|
+
async function collectRolePicks(
|
|
442
|
+
ctx: { signal: AbortSignal; ui: ExtensionUIContext; modelList: Model<Api>[] },
|
|
443
|
+
current: Record<string, string>,
|
|
444
|
+
recommended: Record<string, string>,
|
|
445
|
+
startAtRole: string | undefined,
|
|
446
|
+
): Promise<Record<string, string> | undefined> {
|
|
447
|
+
const chosen: Record<string, string> = { ...current };
|
|
448
|
+
let startIdx = 0;
|
|
449
|
+
if (startAtRole) {
|
|
450
|
+
const idx = INIT_ROLES.findIndex((r) => r.role === startAtRole);
|
|
451
|
+
if (idx >= 0) startIdx = idx;
|
|
452
|
+
}
|
|
453
|
+
for (let i = startIdx; i < INIT_ROLES.length; i++) {
|
|
454
|
+
const role = INIT_ROLES[i];
|
|
455
|
+
const val = await pickOneRole(ctx, role, current, recommended, chosen);
|
|
456
|
+
if (val === "back") return undefined; // back to action menu
|
|
457
|
+
if (val !== undefined) chosen[role.role] = val;
|
|
458
|
+
// val === undefined → keep existing (selected "Keep current")
|
|
459
|
+
}
|
|
460
|
+
return chosen;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Collect a single-role edit. Returns undefined if user escapes. */
|
|
464
|
+
async function collectSingleRoleEdit(
|
|
465
|
+
ctx: { signal: AbortSignal; ui: ExtensionUIContext; modelList: Model<Api>[] },
|
|
466
|
+
current: Record<string, string>,
|
|
467
|
+
recommended: Record<string, string>,
|
|
468
|
+
): Promise<Record<string, string> | undefined> {
|
|
469
|
+
const chosen: Record<string, string> = { ...current };
|
|
470
|
+
const roleOptions = INIT_ROLES.map((r) => {
|
|
471
|
+
const cur = current[r.role];
|
|
472
|
+
const suffix = cur ? ` (current: ${cur})` : "";
|
|
473
|
+
return `${r.role} — ${r.description}${suffix}`;
|
|
474
|
+
});
|
|
475
|
+
roleOptions.push("───────────────");
|
|
476
|
+
roleOptions.push("Back to action menu");
|
|
477
|
+
const picked = await ctx.ui.select("Which role to edit?", roleOptions, {
|
|
478
|
+
signal: ctx.signal,
|
|
479
|
+
});
|
|
480
|
+
if (picked === undefined || picked === "Back to action menu") return undefined;
|
|
481
|
+
const roleName = picked.split(" — ")[0];
|
|
482
|
+
const role = INIT_ROLES.find((r) => r.role === roleName);
|
|
483
|
+
if (!role) return undefined;
|
|
484
|
+
const val = await pickOneRole(ctx, role, current, recommended, chosen);
|
|
485
|
+
if (val === "back") return undefined;
|
|
486
|
+
if (val !== undefined) chosen[role.role] = val;
|
|
487
|
+
return chosen;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** Pick a model for one role. Returns "back" to signal exit, undefined for "keep current". */
|
|
491
|
+
async function pickOneRole(
|
|
492
|
+
ctx: { signal: AbortSignal; ui: ExtensionUIContext; modelList: Model<Api>[] },
|
|
493
|
+
role: InitRole,
|
|
494
|
+
current: Record<string, string>,
|
|
495
|
+
recommended: Record<string, string>,
|
|
496
|
+
_partialChosen: Record<string, string>,
|
|
497
|
+
): Promise<string | "back" | undefined> {
|
|
498
|
+
const cur = current[role.role];
|
|
499
|
+
const options = buildRoleOptions(role, ctx.modelList, {
|
|
500
|
+
current: cur,
|
|
501
|
+
recommended: recommended[role.role],
|
|
502
|
+
});
|
|
503
|
+
const title =
|
|
504
|
+
`Model for '${role.role}' — ${role.description}` +
|
|
505
|
+
(cur !== undefined ? `\nCurrent: ${cur}` : "");
|
|
506
|
+
const pick = await ctx.ui.select(title, options, { signal: ctx.signal });
|
|
507
|
+
|
|
508
|
+
if (pick === undefined) return "back"; // Esc = back to action menu
|
|
509
|
+
if (pick === "Back to action menu") return "back";
|
|
510
|
+
if (pick === "───────────────") return cur ?? recommended[role.role];
|
|
511
|
+
if (pick === "Custom (type your own)") {
|
|
512
|
+
const custom = await ctx.ui.input(
|
|
513
|
+
`Enter model identifier for '${role.role}'`,
|
|
514
|
+
"provider/model-id",
|
|
515
|
+
{ signal: ctx.signal },
|
|
516
|
+
);
|
|
517
|
+
if (custom === undefined) return cur ?? recommended[role.role];
|
|
518
|
+
const parsed = parseCustomModel(custom);
|
|
519
|
+
if (!parsed) return cur ?? recommended[role.role];
|
|
520
|
+
const full = `${parsed.provider}/${parsed.id}`;
|
|
521
|
+
// Guard: a hand-typed model that isn't in the registry will fail at
|
|
522
|
+
// runtime with "Model not found" and silently break every flow that
|
|
523
|
+
// uses this role. Require explicit confirmation before accepting it.
|
|
524
|
+
if (!modelExists(parsed.provider, parsed.id, ctx.modelList)) {
|
|
525
|
+
const keep = await ctx.ui.confirm(
|
|
526
|
+
`'${full}' is not in the model registry`,
|
|
527
|
+
`This model was not found and may fail at runtime with "Model not found".\n` +
|
|
528
|
+
`Use it anyway?`,
|
|
529
|
+
{ signal: ctx.signal },
|
|
530
|
+
);
|
|
531
|
+
if (!keep) return cur ?? recommended[role.role];
|
|
532
|
+
}
|
|
533
|
+
return full;
|
|
534
|
+
}
|
|
535
|
+
if (pick === "Keep current") return undefined;
|
|
536
|
+
return parseModelFromLabel(pick);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Recover the `provider/id` from a picker label like "Name (provider/id) · tags".
|
|
541
|
+
* Greedy `.*` anchors to the LAST parenthesized group and the `/` requirement
|
|
542
|
+
* ensures we capture the provider/id — not a parenthesized part of the model's
|
|
543
|
+
* display name (e.g. "GPT-4o (2024-08-06) (openai/gpt-4o-2024-08-06)"). Falls
|
|
544
|
+
* back to the raw label when no provider/id group is present.
|
|
545
|
+
*/
|
|
546
|
+
export function parseModelFromLabel(label: string): string {
|
|
547
|
+
const match = label.match(/.*\(([^)]+\/[^)]+)\)/);
|
|
548
|
+
return match ? match[1] : label;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** Check if two role maps are semantically identical. */
|
|
552
|
+
function rolesIdentical(
|
|
553
|
+
a: Record<string, string>,
|
|
554
|
+
b: Record<string, string>,
|
|
555
|
+
): boolean {
|
|
556
|
+
const keysA = Object.keys(a).sort();
|
|
557
|
+
const keysB = Object.keys(b).sort();
|
|
558
|
+
if (keysA.length !== keysB.length) return false;
|
|
559
|
+
return keysA.every((k) => a[k] === b[k]);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/** Run the preview/save flow. Returns the InitFlowResult. */
|
|
563
|
+
async function finalizeOrPreview(
|
|
564
|
+
ctx: { signal: AbortSignal; ui: ExtensionUIContext; modelList: Model<Api>[] },
|
|
565
|
+
current: Record<string, string>,
|
|
566
|
+
chosen: Record<string, string>,
|
|
567
|
+
recommended: Record<string, string>,
|
|
568
|
+
): Promise<InitFlowResult> {
|
|
569
|
+
// Short-circuit: no change
|
|
570
|
+
if (rolesIdentical(current, chosen)) return { kind: "no-change", chosen };
|
|
571
|
+
|
|
572
|
+
// Preview screen
|
|
573
|
+
const diffs = diffRoles(current, chosen, INIT_ROLES);
|
|
574
|
+
const previewLines = ["Review changes:", ""];
|
|
575
|
+
for (const d of diffs) {
|
|
576
|
+
if (d.status === "unchanged") {
|
|
577
|
+
previewLines.push(` ${d.role.padEnd(10)} ${d.after ?? ""} (unchanged)`);
|
|
578
|
+
} else if (d.status === "changed") {
|
|
579
|
+
previewLines.push(` ${d.role.padEnd(10)} ${d.after ?? ""} (changed ← was: ${d.before})`);
|
|
580
|
+
} else if (d.status === "new") {
|
|
581
|
+
previewLines.push(` ${d.role.padEnd(10)} ${d.after ?? ""} (new)`);
|
|
582
|
+
} else if (d.status === "stale-preserved") {
|
|
583
|
+
previewLines.push(` ${d.role.padEnd(10)} ${d.before ?? ""} (stale — preserved)`);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
const previewTitle = previewLines.join("\n");
|
|
587
|
+
const previewAction = await ctx.ui.select(
|
|
588
|
+
previewTitle,
|
|
589
|
+
["Save these changes", "Edit a role", "Cancel"],
|
|
590
|
+
{ signal: ctx.signal },
|
|
591
|
+
);
|
|
592
|
+
|
|
593
|
+
if (previewAction === "Save these changes") {
|
|
594
|
+
const settings = readSettings();
|
|
595
|
+
const merged = { ...settings, modelRoles: chosen };
|
|
596
|
+
const savedPath = writeSettings(merged);
|
|
597
|
+
return { kind: "saved", chosen, savedPath };
|
|
598
|
+
}
|
|
599
|
+
if (previewAction === "Cancel" || previewAction === undefined) {
|
|
600
|
+
return { kind: "cancelled" };
|
|
601
|
+
}
|
|
602
|
+
// "Edit a role" — jump back into per-role loop
|
|
603
|
+
const changedRole = diffs.find((d) => d.status === "changed")?.role ?? INIT_ROLES[0].role;
|
|
604
|
+
const reChosen = await collectRolePicks(ctx, current, recommended, changedRole);
|
|
605
|
+
if (reChosen === undefined) return { kind: "cancelled" };
|
|
606
|
+
return finalizeOrPreview(ctx, current, reChosen, recommended);
|
|
607
|
+
}
|
package/extensions/render.ts
CHANGED
|
@@ -164,6 +164,14 @@ function phaseDetail(phase: Phase, ps: PhaseState | undefined, theme: Theme): st
|
|
|
164
164
|
}
|
|
165
165
|
|
|
166
166
|
// done
|
|
167
|
+
// Cross-run cache hit: show a compact badge with age and the $0 cost.
|
|
168
|
+
if (ps.cacheHit === "cross-run") {
|
|
169
|
+
const ageMs = ps.endedAt ? Date.now() - ps.endedAt : 0;
|
|
170
|
+
let c = theme.fg("success", "✓") + " " + theme.fg("toolOutput", theme.bold("CACHED")) + theme.fg("dim", " cross-run");
|
|
171
|
+
if (ageMs > 1500) c += theme.fg("dim", ` · ${elapsed(ageMs)} ago`);
|
|
172
|
+
if (ps.warnings?.length) c += theme.fg("warning", ` ⚠${ps.warnings.length}`);
|
|
173
|
+
return c;
|
|
174
|
+
}
|
|
167
175
|
if (isFanout) {
|
|
168
176
|
const { done = 0, total = 0, failed = 0 } = ps.subProgress ?? {};
|
|
169
177
|
let s = theme.fg("success", `${total}✓`);
|
|
@@ -201,6 +209,37 @@ function phaseDetail(phase: Phase, ps: PhaseState | undefined, theme: Theme): st
|
|
|
201
209
|
if (ps.warnings?.length) g += theme.fg("warning", ` ⚠${ps.warnings.length}`);
|
|
202
210
|
return g;
|
|
203
211
|
}
|
|
212
|
+
if (ps.loop) {
|
|
213
|
+
const stopLabel =
|
|
214
|
+
ps.loop.stop === "until"
|
|
215
|
+
? theme.fg("success", "done")
|
|
216
|
+
: ps.loop.stop === "converged"
|
|
217
|
+
? theme.fg("toolOutput", "converged")
|
|
218
|
+
: ps.loop.stop === "maxIterations"
|
|
219
|
+
? theme.fg("warning", "max")
|
|
220
|
+
: theme.fg("error", "failed");
|
|
221
|
+
let l = theme.fg("toolTitle", `↻${ps.loop.iterations}`) + " " + stopLabel;
|
|
222
|
+
const cost = costStr(ps.usage, theme);
|
|
223
|
+
if (cost) l += ` ${cost}`;
|
|
224
|
+
if (time) l += ` ${time}`;
|
|
225
|
+
if (ps.warnings?.length) l += theme.fg("warning", ` ⚠${ps.warnings.length}`);
|
|
226
|
+
return l;
|
|
227
|
+
}
|
|
228
|
+
if (ps.tournament) {
|
|
229
|
+
const { variants, winner, mode } = ps.tournament;
|
|
230
|
+
let w =
|
|
231
|
+
theme.fg("toolTitle", `⚑ ${variants}→`) +
|
|
232
|
+
theme.fg("success", mode === "aggregate" ? "aggregate" : `#${winner}`);
|
|
233
|
+
if (ps.tournament.reason) {
|
|
234
|
+
const r = ps.tournament.reason.replace(/\s+/g, " ");
|
|
235
|
+
w += theme.fg("dim", ` ${r.length > 36 ? `${r.slice(0, 36)}…` : r}`);
|
|
236
|
+
}
|
|
237
|
+
const cost = costStr(ps.usage, theme);
|
|
238
|
+
if (cost) w += ` ${cost}`;
|
|
239
|
+
if (time) w += ` ${time}`;
|
|
240
|
+
if (ps.warnings?.length) w += theme.fg("warning", ` ⚠${ps.warnings.length}`);
|
|
241
|
+
return w;
|
|
242
|
+
}
|
|
204
243
|
let s = roleLabel;
|
|
205
244
|
if (cost) s += ` ${cost}`;
|
|
206
245
|
if (ps.attempts && ps.attempts > 1) s += theme.fg("warning", ` ↻${ps.attempts - 1}`);
|