pi-taskflow 0.0.11 → 0.0.13

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,263 @@
1
+ /**
2
+ * Cross-run memoization: fingerprint resolver + persistent phase-result cache.
3
+ *
4
+ * See docs/rfc-cross-run-memoization.md. The cache lets a phase reuse the result
5
+ * of an identical-input phase from ANY prior run (scope: "cross-run"), for $0.00.
6
+ * Freshness is guarded by:
7
+ * - the existing content-addressed inputHash (declared inputs)
8
+ * - optional `fingerprint` entries folded into the key (git/glob/file/env)
9
+ * - optional TTL
10
+ * - default `run-only` scope (this module is only consulted for cross-run)
11
+ *
12
+ * Zero runtime dependencies: Node built-ins only (fs.globSync requires Node >=22,
13
+ * which the project already targets).
14
+ */
15
+
16
+ import { execFileSync } from "node:child_process";
17
+ import * as crypto from "node:crypto";
18
+ import * as fs from "node:fs";
19
+ import * as path from "node:path";
20
+ import { cacheDir, withLock, writeFileAtomic } from "./store.ts";
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Fingerprint resolution
24
+ // ---------------------------------------------------------------------------
25
+
26
+ /** Per-file byte cap when content-hashing (mirrors store/context limits). */
27
+ const FINGERPRINT_MAX_FILE_BYTES = 10 * 1024 * 1024; // 10 MB
28
+ /** Cap on glob match count folded into a single fingerprint (defensive). */
29
+ const FINGERPRINT_MAX_GLOB_MATCHES = 5000;
30
+
31
+ /**
32
+ * Resolve a single fingerprint entry to a deterministic string. Never throws:
33
+ * missing files / non-git repos / unreadable paths resolve to a stable sentinel
34
+ * so the key stays deterministic (and a later appearance of the resource simply
35
+ * changes the key → cache miss, which is the safe direction).
36
+ */
37
+ function resolveOne(entry: string, cwd: string): string {
38
+ try {
39
+ if (entry === "git:HEAD" || entry.startsWith("git:")) {
40
+ const ref = entry.slice("git:".length) || "HEAD";
41
+ // Reject refs that could be interpreted as git options (e.g. "--exec=...").
42
+ // The ref comes from a taskflow definition; refuse flag-like values so a
43
+ // crafted definition can't smuggle arguments into git.
44
+ if (ref.startsWith("-")) return `git:${ref}=<invalid-ref>`;
45
+ try {
46
+ const sha = execFileSync("git", ["rev-parse", ref], {
47
+ cwd,
48
+ encoding: "utf-8",
49
+ stdio: ["ignore", "pipe", "ignore"],
50
+ }).trim();
51
+ return `git:${ref}=${sha}`;
52
+ } catch {
53
+ return `git:${ref}=<no-git>`;
54
+ }
55
+ }
56
+
57
+ if (entry.startsWith("glob:") || entry.startsWith("glob!:")) {
58
+ const contentMode = entry.startsWith("glob!:");
59
+ const pattern = entry.slice(contentMode ? "glob!:".length : "glob:".length);
60
+ let matches: string[];
61
+ try {
62
+ // fs.globSync (Node >=22) — cwd-relative, returns posix-ish paths.
63
+ matches = (fs.globSync(pattern, { cwd }) as string[]).slice().sort();
64
+ } catch {
65
+ return `${entry}=<glob-error>`;
66
+ }
67
+ if (matches.length > FINGERPRINT_MAX_GLOB_MATCHES) {
68
+ matches = matches.slice(0, FINGERPRINT_MAX_GLOB_MATCHES);
69
+ }
70
+ const parts: string[] = [];
71
+ for (const rel of matches) {
72
+ const abs = path.resolve(cwd, rel);
73
+ try {
74
+ if (contentMode) {
75
+ const st = fs.statSync(abs);
76
+ if (st.isFile() && st.size <= FINGERPRINT_MAX_FILE_BYTES) {
77
+ const buf = fs.readFileSync(abs);
78
+ parts.push(`${rel}:${crypto.createHash("sha256").update(buf).digest("hex").slice(0, 16)}`);
79
+ } else {
80
+ parts.push(`${rel}:<skip>`);
81
+ }
82
+ } else {
83
+ const st = fs.statSync(abs);
84
+ parts.push(`${rel}:${st.size}:${Math.floor(st.mtimeMs)}`);
85
+ }
86
+ } catch {
87
+ parts.push(`${rel}:<stat-error>`);
88
+ }
89
+ }
90
+ const digest = crypto.createHash("sha256").update(parts.join("\u0000")).digest("hex").slice(0, 16);
91
+ return `${entry}=${digest}`;
92
+ }
93
+
94
+ if (entry.startsWith("file:")) {
95
+ const rel = entry.slice("file:".length);
96
+ const abs = path.resolve(cwd, rel);
97
+ try {
98
+ const st = fs.statSync(abs);
99
+ if (!st.isFile() || st.size > FINGERPRINT_MAX_FILE_BYTES) return `file:${rel}=<skip>`;
100
+ const buf = fs.readFileSync(abs);
101
+ return `file:${rel}=${crypto.createHash("sha256").update(buf).digest("hex").slice(0, 16)}`;
102
+ } catch {
103
+ return `file:${rel}=<missing>`;
104
+ }
105
+ }
106
+
107
+ if (entry.startsWith("env:")) {
108
+ const name = entry.slice("env:".length);
109
+ return `env:${name}=${process.env[name] ?? ""}`;
110
+ }
111
+ } catch {
112
+ // Fall through to sentinel below.
113
+ }
114
+ // Unknown prefixes are rejected at validation time; defensively encode.
115
+ return `${entry}=<unknown>`;
116
+ }
117
+
118
+ /**
119
+ * Resolve a phase's `fingerprint` list into a single deterministic string to be
120
+ * folded into the cache key. Returns "" when there are no entries (so the key is
121
+ * unchanged for phases that declare no fingerprint).
122
+ */
123
+ export function resolveFingerprint(entries: string[] | undefined, cwd: string): string {
124
+ if (!entries || entries.length === 0) return "";
125
+ // Preserve author order (it's part of the declared key) but resolve each.
126
+ const resolved = entries.map((e) => resolveOne(e, cwd));
127
+ return crypto.createHash("sha256").update(resolved.join("\u0000")).digest("hex").slice(0, 16);
128
+ }
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // Cross-run cache store
132
+ // ---------------------------------------------------------------------------
133
+
134
+ export interface CacheEntry {
135
+ /** The full cache key (== phase inputHash incl. fingerprint). */
136
+ key: string;
137
+ createdAt: number;
138
+ /** Trimmed phase result surface that downstream phases consume. */
139
+ output?: string;
140
+ json?: unknown;
141
+ model?: string;
142
+ /** Provenance for audit / cleanup. */
143
+ flowName?: string;
144
+ phaseId?: string;
145
+ runId?: string;
146
+ }
147
+
148
+ /** Keep at most this many cache entries; LRU-ish eviction by createdAt. */
149
+ const DEFAULT_MAX_ENTRIES = 1000;
150
+ /** Drop entries older than this regardless of TTL (hard backstop). */
151
+ const DEFAULT_MAX_AGE_MS = 90 * 24 * 60 * 60 * 1000; // 90 days
152
+
153
+ /** A cache key is a 16-hex inputHash; constrain to that to prevent traversal. */
154
+ function isValidKey(key: string): boolean {
155
+ return /^[0-9a-f]{8,64}$/.test(key);
156
+ }
157
+
158
+ function entryPath(dir: string, key: string): string {
159
+ return path.join(dir, `${key}.json`);
160
+ }
161
+
162
+ /**
163
+ * The cross-run cache, scoped to a working directory. Cheap to construct; all IO
164
+ * is lazy and failure-tolerant (a broken cache must never break a run).
165
+ */
166
+ export class CacheStore {
167
+ private dir: string;
168
+
169
+ constructor(cwd: string) {
170
+ this.dir = cacheDir(cwd);
171
+ }
172
+
173
+ /** Look up a fresh entry. Returns null on miss, malformed key, or TTL expiry. */
174
+ get(key: string, ttlMs?: number): CacheEntry | null {
175
+ if (!isValidKey(key)) return null;
176
+ let entry: CacheEntry;
177
+ try {
178
+ const raw = fs.readFileSync(entryPath(this.dir, key), "utf-8");
179
+ entry = JSON.parse(raw) as CacheEntry;
180
+ } catch {
181
+ return null;
182
+ }
183
+ if (typeof entry?.createdAt !== "number") return null;
184
+ const age = Date.now() - entry.createdAt;
185
+ if (age > DEFAULT_MAX_AGE_MS) return null;
186
+ if (ttlMs !== undefined && age > ttlMs) return null;
187
+ return entry;
188
+ }
189
+
190
+ /** Store an entry (best-effort; never throws into the run). */
191
+ put(entry: CacheEntry): void {
192
+ if (!isValidKey(entry.key)) return;
193
+ try {
194
+ fs.mkdirSync(this.dir, { recursive: true });
195
+ const lock = path.join(this.dir, `${entry.key}.json.lock`);
196
+ withLock(lock, () => {
197
+ writeFileAtomic(entryPath(this.dir, entry.key), JSON.stringify(entry, null, 2));
198
+ });
199
+ this.cleanup();
200
+ } catch {
201
+ /* cache write failures are non-fatal */
202
+ }
203
+ }
204
+
205
+ /** Remove all cache entries. Returns the number removed. */
206
+ clear(): number {
207
+ let n = 0;
208
+ try {
209
+ for (const f of fs.readdirSync(this.dir)) {
210
+ if (f.endsWith(".json")) {
211
+ try {
212
+ fs.unlinkSync(path.join(this.dir, f));
213
+ n++;
214
+ } catch {
215
+ /* ignore */
216
+ }
217
+ }
218
+ }
219
+ } catch {
220
+ /* no dir → nothing to clear */
221
+ }
222
+ return n;
223
+ }
224
+
225
+ /** Opportunistic eviction: drop expired/oversized entries. Best-effort. */
226
+ private cleanup(): void {
227
+ let files: string[];
228
+ try {
229
+ files = fs.readdirSync(this.dir).filter((f) => f.endsWith(".json"));
230
+ } catch {
231
+ return;
232
+ }
233
+ const now = Date.now();
234
+ const live: Array<{ file: string; createdAt: number }> = [];
235
+ for (const f of files) {
236
+ const abs = path.join(this.dir, f);
237
+ try {
238
+ const e = JSON.parse(fs.readFileSync(abs, "utf-8")) as CacheEntry;
239
+ if (typeof e?.createdAt !== "number" || now - e.createdAt > DEFAULT_MAX_AGE_MS) {
240
+ fs.unlinkSync(abs);
241
+ continue;
242
+ }
243
+ live.push({ file: abs, createdAt: e.createdAt });
244
+ } catch {
245
+ try {
246
+ fs.unlinkSync(abs);
247
+ } catch {
248
+ /* ignore */
249
+ }
250
+ }
251
+ }
252
+ if (live.length > DEFAULT_MAX_ENTRIES) {
253
+ live.sort((a, b) => a.createdAt - b.createdAt); // oldest first
254
+ for (const victim of live.slice(0, live.length - DEFAULT_MAX_ENTRIES)) {
255
+ try {
256
+ fs.unlinkSync(victim.file);
257
+ } catch {
258
+ /* ignore */
259
+ }
260
+ }
261
+ }
262
+ }
263
+ }
@@ -12,8 +12,17 @@
12
12
 
13
13
  import type { AgentToolResult } from "@earendil-works/pi-agent-core";
14
14
  import { StringEnum } from "@earendil-works/pi-ai";
15
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
15
+ import type { ExtensionAPI, ExtensionContext, ExtensionUIContext } from "@earendil-works/pi-coding-agent";
16
16
  import { Text } from "@earendil-works/pi-tui";
17
+ import {
18
+ RECOMMENDED_DEFAULTS,
19
+ readSettings,
20
+ writeSettings,
21
+ formatRolesReport,
22
+ formatDiffReport,
23
+ formatFlowResult,
24
+ runInteractiveInit,
25
+ } from "./init.ts";
17
26
  import { Type } from "typebox";
18
27
  import { type AgentScope, discoverAgents, readSubagentSettings } from "./agents.ts";
19
28
  import { renderRunResult, summarizeRun } from "./render.ts";
@@ -30,6 +39,7 @@ import {
30
39
  saveFlow,
31
40
  saveRun,
32
41
  } from "./store.ts";
42
+ import { CacheStore } from "./cache.ts";
33
43
 
34
44
  interface TaskflowDetails {
35
45
  state?: RunState;
@@ -50,8 +60,8 @@ const ShorthandStep = Type.Object(
50
60
  );
51
61
 
52
62
  const TaskflowParams = Type.Object({
53
- action: StringEnum(["run", "save", "resume", "list", "agents"] as const, {
54
- description: "What to do: run a flow, save a definition, resume a paused run, list saved flows, or list available agents you can use in phases",
63
+ action: StringEnum(["run", "save", "resume", "list", "agents", "init", "cache-clear"] as const, {
64
+ description: "What to do: run a flow, save a definition, resume a paused run, list saved flows, list available agents, init model role configuration, or clear the cross-run memoization cache",
55
65
  default: "run",
56
66
  }),
57
67
  name: Type.Optional(Type.String({ description: "Name of a saved flow (for run/save without inline define)" })),
@@ -84,6 +94,19 @@ const TaskflowParams = Type.Object({
84
94
  scope: Type.Optional(
85
95
  StringEnum(["user", "project"] as const, { description: "Where to save (action=save)", default: "project" }),
86
96
  ),
97
+ mode: Type.Optional(
98
+ StringEnum(["show", "apply-defaults", "interactive"] as const, {
99
+ description:
100
+ "Init action mode. 'show' is read-only (default); 'apply-defaults' requires force:true; 'interactive' requires a UI session.",
101
+ default: "show",
102
+ }),
103
+ ),
104
+ force: Type.Optional(
105
+ Type.Boolean({
106
+ description:
107
+ "Destructive: overwrites modelRoles in settings.json. Required for mode='apply-defaults'.",
108
+ }),
109
+ ),
87
110
  });
88
111
 
89
112
  function makeRunState(def: Taskflow, args: Record<string, unknown>, cwd: string): RunState {
@@ -167,7 +190,20 @@ async function runFlow(
167
190
  // the heartbeat timer is cleared by the finally block below.
168
191
  const settings = readSubagentSettings();
169
192
  const scope: AgentScope = def.agentScope ?? "user";
170
- const { agents } = discoverAgents(ctx.cwd, scope, settings.agentOverrides);
193
+ const { agents } = discoverAgents(ctx.cwd, scope, settings.agentOverrides, settings.modelRoles);
194
+
195
+ // Hint: if any agent still has unresolved {{role}} references, suggest configuring modelRoles
196
+ const unresolvedRoles = agents
197
+ .filter(a => a.model && /^\{\{\w+\}\}$/.test(a.model))
198
+ .map(a => a.model!.match(/^\{\{(\w+)\}\}$/)![1]);
199
+ if (unresolvedRoles.length > 0) {
200
+ const unique = [...new Set(unresolvedRoles)];
201
+ console.warn(
202
+ `[taskflow] Hint: ${unique.length} model role(s) not configured: ${unique.join(", ")}. ` +
203
+ `Agents will use the default model (slower / less optimal). ` +
204
+ `Run /tf init to auto-generate modelRoles config.`
205
+ );
206
+ }
171
207
 
172
208
  // Pre-flight: warn if any phase references an agent not in the registry
173
209
  const agentNames = new Set(agents.map(a => a.name));
@@ -216,7 +252,20 @@ export default function (pi: ExtensionAPI) {
216
252
  }
217
253
  };
218
254
 
219
- pi.on("session_start", async (_e, ctx) => registerSavedFlowCommands(ctx));
255
+ pi.on("session_start", async (_e, ctx) => {
256
+ registerSavedFlowCommands(ctx);
257
+
258
+ // Hint: prompt to configure model roles if not set
259
+ try {
260
+ const settings = readSubagentSettings();
261
+ if (!settings.modelRoles) {
262
+ console.warn(
263
+ `[taskflow] Model roles not configured — agents will use the default model. ` +
264
+ `Run /tf init to generate a recommended modelRoles config.`
265
+ );
266
+ }
267
+ } catch {}
268
+ });
220
269
 
221
270
  // ---- The LLM-callable tool ----
222
271
  pi.registerTool({
@@ -229,7 +278,7 @@ export default function (pi: ExtensionAPI) {
229
278
  "For simple non-DAG delegations (like the subagent tool) skip the DSL: pass `task` (+optional `agent`) for one task, `tasks:[{task,agent?}]` to run in parallel, or `chain:[{task,agent?}]` to run sequentially (reference the prior step with {previous.output}).",
230
279
  "Use action=save to persist a definition as a reusable /tf:<name> command. action=resume continues a paused run. action=list shows saved flows. Use action=agents to list available agents — do NOT invent agent names; either use an agent from that list or omit the 'agent' field to auto-select the default agent.",
231
280
  "DSL: {name, args?, concurrency?, budget?:{maxUSD,maxTokens}, phases:[{id, type, agent, task, dependsOn?, join?:'all'|'any', when?, retry?:{max,backoffMs,factor}, over?(map), as?(map), branches?(parallel), from?(reduce), use?(flow), with?(flow), output?:'json', final?}]}.",
232
- "Phase types: agent (one subagent), parallel (static branches), map (dynamic fan-out over an array), gate (VERDICT: PASS/BLOCK quality gate), reduce (aggregate from N phases), approval (human-in-the-loop pause), flow (run a saved sub-flow). join:'any' is an OR-join; when is a conditional guard; retry adds backoff; budget caps run cost.",
281
+ "Phase types: agent (one subagent), parallel (static branches), map (dynamic fan-out over an array), gate (VERDICT: PASS/BLOCK quality gate), reduce (aggregate from N phases), approval (human-in-the-loop pause), flow (run a saved sub-flow), loop (re-run a task until 'until' is truthy / converged / maxIterations; body reads {loop.iteration} and {loop.lastOutput}), tournament (spawn N variants of 'task' — or distinct 'branches' — then a judge picks the best / aggregates; mode:'best'|'aggregate'). join:'any' is an OR-join; when is a conditional guard; retry adds backoff; budget caps run cost.",
233
282
  "Interpolation: {args.X}, {steps.ID.output}, {steps.ID.json}, {item} (map), {previous.output}.",
234
283
  ].join(" "),
235
284
  parameters: TaskflowParams,
@@ -243,10 +292,88 @@ export default function (pi: ExtensionAPI) {
243
292
  async execute(_id, params, signal, onUpdate, ctx) {
244
293
  const action = params.action ?? "run";
245
294
 
246
- // agentslist available agents the LLM can use in phase definitions
295
+ // initconfigure model roles
296
+ if (action === "init") {
297
+ let settings: Record<string, unknown>;
298
+ try {
299
+ settings = readSettings();
300
+ } catch (e) {
301
+ return errorResult(
302
+ action,
303
+ `Failed to read settings.json: ${e instanceof Error ? e.message : String(e)}. ` +
304
+ `Fix the file or remove it.`,
305
+ );
306
+ }
307
+ const current = (settings.modelRoles ?? {}) as Record<string, string>;
308
+ const mode = params.mode;
309
+
310
+ // v0.0.13 deprecation bridge: mode omitted → old behavior
311
+ if (mode === undefined) {
312
+ if (Object.keys(current).length === 0) {
313
+ // v0.0.12 compat: auto-write recommended defaults when modelRoles is empty
314
+ console.warn(
315
+ "[taskflow] action=init with no mode is deprecated and will require explicit mode in v0.0.14. " +
316
+ "Use mode='apply-defaults' with force=true.",
317
+ );
318
+ writeSettings({ ...settings, modelRoles: { ...RECOMMENDED_DEFAULTS } });
319
+ const text = formatDiffReport({}, RECOMMENDED_DEFAULTS);
320
+ return { content: [{ type: "text", text }], details: { action } satisfies TaskflowDetails };
321
+ }
322
+ // mode omitted + modelRoles exist → show
323
+ const text = formatRolesReport(current);
324
+ return { content: [{ type: "text", text }], details: { action } satisfies TaskflowDetails };
325
+ }
326
+
327
+ // mode === "show" (read-only, never overwrites)
328
+ if (mode === "show") {
329
+ const text = formatRolesReport(current);
330
+ return { content: [{ type: "text", text }], details: { action } satisfies TaskflowDetails };
331
+ }
332
+
333
+ // mode === "apply-defaults" requires explicit force=true
334
+ if (mode === "apply-defaults") {
335
+ if (!params.force)
336
+ return errorResult(action, "mode=apply-defaults requires force=true to overwrite.");
337
+ const merged: Record<string, string> = { ...RECOMMENDED_DEFAULTS };
338
+ for (const key of Object.keys(current)) {
339
+ if (!(key in merged)) merged[key] = current[key]; // stale-preserved
340
+ }
341
+ writeSettings({ ...settings, modelRoles: merged });
342
+ const text = formatDiffReport(current, merged);
343
+ return { content: [{ type: "text", text }], details: { action } satisfies TaskflowDetails };
344
+ }
345
+
346
+ // mode === "interactive" — requires a UI session
347
+ if (mode === "interactive") {
348
+ if (!ctx.hasUI)
349
+ return errorResult(action, "mode=interactive requires an interactive session.");
350
+ const enabledModels = (settings.enabledModels as string[] | undefined) ?? [];
351
+ const modelList =
352
+ enabledModels.length > 0
353
+ ? enabledModels
354
+ .map((id) => ctx.modelRegistry.find(id.split("/")[0], id.split("/").slice(1).join("/")))
355
+ .filter((m): m is NonNullable<typeof m> => m !== undefined)
356
+ : ctx.modelRegistry.getAvailable();
357
+ const result = await runInteractiveInit({
358
+ hasUI: ctx.hasUI,
359
+ signal: signal ?? new AbortController().signal,
360
+ ui: ctx.ui as ExtensionUIContext,
361
+ modelRegistry: ctx.modelRegistry,
362
+ modelList,
363
+ currentRoles: current,
364
+ });
365
+ const text = formatFlowResult(result);
366
+ return { content: [{ type: "text", text }], details: { action } satisfies TaskflowDetails };
367
+ }
368
+
369
+ return errorResult(action, `Unknown init mode: ${String(mode)}`);
370
+ }
371
+
372
+ // agents — list available agents the LLM can use in phase definitions
247
373
  if (action === "agents") {
248
374
  const scope = params.scope ?? "both";
249
- const { agents } = discoverAgents(ctx.cwd, scope as AgentScope, undefined);
375
+ const settings2 = readSubagentSettings();
376
+ const { agents } = discoverAgents(ctx.cwd, scope as AgentScope, undefined, settings2.modelRoles);
250
377
  const text = agents.length
251
378
  ? agents
252
379
  .map(
@@ -267,6 +394,14 @@ export default function (pi: ExtensionAPI) {
267
394
  return { content: [{ type: "text", text }], details: { action } satisfies TaskflowDetails };
268
395
  }
269
396
 
397
+ if (action === "cache-clear") {
398
+ const removed = new CacheStore(ctx.cwd).clear();
399
+ return {
400
+ content: [{ type: "text", text: `Cleared ${removed} cross-run cache entr${removed === 1 ? "y" : "ies"}.` }],
401
+ details: { action } satisfies TaskflowDetails,
402
+ };
403
+ }
404
+
270
405
  // resume
271
406
  if (action === "resume") {
272
407
  if (!params.runId)
@@ -386,9 +521,9 @@ export default function (pi: ExtensionAPI) {
386
521
 
387
522
  // ---- The /tf user command ----
388
523
  pi.registerCommand("tf", {
389
- description: "Taskflow: list | run <name> | show <name> | runs",
524
+ description: "Taskflow: list | run <name> | show <name> | runs | init",
390
525
  getArgumentCompletions: (prefix) => {
391
- const subs = ["list", "run", "show", "runs", "resume"];
526
+ const subs = ["list", "run", "show", "runs", "resume", "init"];
392
527
  const items = subs.map((s) => ({ value: s, label: s }));
393
528
  const filtered = items.filter((i) => i.value.startsWith(prefix));
394
529
  return filtered.length > 0 ? filtered : null;
@@ -480,6 +615,62 @@ export default function (pi: ExtensionAPI) {
480
615
  return;
481
616
  }
482
617
 
618
+ if (sub === "init") {
619
+ let settings: Record<string, unknown>;
620
+ try {
621
+ settings = readSettings();
622
+ } catch (e) {
623
+ ctx.ui.notify(
624
+ `Failed to read settings.json: ${e instanceof Error ? e.message : String(e)}`,
625
+ "error",
626
+ );
627
+ return;
628
+ }
629
+ const currentRoles = (settings.modelRoles ?? {}) as Record<string, string>;
630
+
631
+ if (!ctx.hasUI) {
632
+ if (Object.keys(currentRoles).length > 0) {
633
+ ctx.ui.notify(
634
+ formatRolesReport(currentRoles),
635
+ "info",
636
+ );
637
+ } else {
638
+ ctx.ui.notify(
639
+ "No modelRoles configured. Run /tf init in an interactive session to select models.",
640
+ "warning",
641
+ );
642
+ }
643
+ return;
644
+ }
645
+
646
+ const enabledModels = (settings.enabledModels as string[] | undefined) ?? [];
647
+ const modelList =
648
+ enabledModels.length > 0
649
+ ? enabledModels
650
+ .map((id) => ctx.modelRegistry.find(id.split("/")[0], id.split("/").slice(1).join("/")))
651
+ .filter((m): m is NonNullable<typeof m> => m !== undefined)
652
+ : ctx.modelRegistry.getAvailable();
653
+ const result = await runInteractiveInit({
654
+ hasUI: ctx.hasUI,
655
+ signal: ctx.signal ?? new AbortController().signal,
656
+ ui: ctx.ui,
657
+ modelRegistry: ctx.modelRegistry,
658
+ modelList,
659
+ currentRoles,
660
+ });
661
+ ctx.ui.notify(
662
+ result.kind === "saved"
663
+ ? `Saved model roles to ${result.savedPath}:\n${Object.entries(result.chosen)
664
+ .map(([k, v]) => ` ${k.padEnd(10)} → ${v}`)
665
+ .join("\n")}`
666
+ : result.kind === "no-change"
667
+ ? "No changes made."
668
+ : "Init cancelled.",
669
+ result.kind === "saved" ? "info" : "info",
670
+ );
671
+ return;
672
+ }
673
+
483
674
  ctx.ui.notify(`Unknown subcommand: ${sub}`, "warning");
484
675
  },
485
676
  });