@yycholla/pi-dynamic-workflows 3.3.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.
Files changed (179) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +325 -0
  3. package/assets/readme/hero.png +0 -0
  4. package/assets/readme/hero.svg +70 -0
  5. package/assets/readme/package-cover.png +0 -0
  6. package/assets/readme/package-cover.svg +66 -0
  7. package/assets/readme/workflow.png +0 -0
  8. package/assets/readme/workflow.svg +77 -0
  9. package/dist/accept-workflow-guidance.d.ts +14 -0
  10. package/dist/accept-workflow-guidance.js +53 -0
  11. package/dist/adversarial-review.d.ts +30 -0
  12. package/dist/adversarial-review.js +107 -0
  13. package/dist/agent-history.d.ts +20 -0
  14. package/dist/agent-history.js +122 -0
  15. package/dist/agent-registry.d.ts +83 -0
  16. package/dist/agent-registry.js +190 -0
  17. package/dist/agent.d.ts +364 -0
  18. package/dist/agent.js +714 -0
  19. package/dist/builtin-commands.d.ts +19 -0
  20. package/dist/builtin-commands.js +251 -0
  21. package/dist/builtin-workflows.d.ts +45 -0
  22. package/dist/builtin-workflows.js +121 -0
  23. package/dist/code-review.d.ts +26 -0
  24. package/dist/code-review.js +181 -0
  25. package/dist/config.d.ts +37 -0
  26. package/dist/config.js +44 -0
  27. package/dist/deep-research.d.ts +30 -0
  28. package/dist/deep-research.js +124 -0
  29. package/dist/display.d.ts +134 -0
  30. package/dist/display.js +248 -0
  31. package/dist/effort-command.d.ts +28 -0
  32. package/dist/effort-command.js +68 -0
  33. package/dist/enums.d.ts +69 -0
  34. package/dist/enums.js +78 -0
  35. package/dist/errors.d.ts +113 -0
  36. package/dist/errors.js +140 -0
  37. package/dist/extension-reload.d.ts +37 -0
  38. package/dist/extension-reload.js +78 -0
  39. package/dist/fs-persistence.d.ts +63 -0
  40. package/dist/fs-persistence.js +102 -0
  41. package/dist/index.d.ts +57 -0
  42. package/dist/index.js +35 -0
  43. package/dist/logger.d.ts +21 -0
  44. package/dist/logger.js +66 -0
  45. package/dist/model-routing.d.ts +30 -0
  46. package/dist/model-routing.js +50 -0
  47. package/dist/model-spec.d.ts +29 -0
  48. package/dist/model-spec.js +252 -0
  49. package/dist/model-tier-config.d.ts +133 -0
  50. package/dist/model-tier-config.js +249 -0
  51. package/dist/run-persistence.d.ts +180 -0
  52. package/dist/run-persistence.js +294 -0
  53. package/dist/saved-commands.d.ts +28 -0
  54. package/dist/saved-commands.js +100 -0
  55. package/dist/shared-store.d.ts +98 -0
  56. package/dist/shared-store.js +212 -0
  57. package/dist/structured-output.d.ts +19 -0
  58. package/dist/structured-output.js +30 -0
  59. package/dist/task-panel.d.ts +61 -0
  60. package/dist/task-panel.js +422 -0
  61. package/dist/usage-limit-scheduler.d.ts +145 -0
  62. package/dist/usage-limit-scheduler.js +368 -0
  63. package/dist/web-tools.d.ts +20 -0
  64. package/dist/web-tools.js +120 -0
  65. package/dist/workflow-authoring-coverage.d.ts +70 -0
  66. package/dist/workflow-authoring-coverage.js +421 -0
  67. package/dist/workflow-authoring-reference.d.ts +20 -0
  68. package/dist/workflow-authoring-reference.js +156 -0
  69. package/dist/workflow-capability-contract.d.ts +131 -0
  70. package/dist/workflow-capability-contract.js +604 -0
  71. package/dist/workflow-commands.d.ts +18 -0
  72. package/dist/workflow-commands.js +260 -0
  73. package/dist/workflow-comprehension.d.ts +133 -0
  74. package/dist/workflow-comprehension.js +1321 -0
  75. package/dist/workflow-context-measurement.d.ts +72 -0
  76. package/dist/workflow-context-measurement.js +213 -0
  77. package/dist/workflow-control-tool.d.ts +30 -0
  78. package/dist/workflow-control-tool.js +176 -0
  79. package/dist/workflow-delivery-choice.d.ts +20 -0
  80. package/dist/workflow-delivery-choice.js +48 -0
  81. package/dist/workflow-editor.d.ts +93 -0
  82. package/dist/workflow-editor.js +363 -0
  83. package/dist/workflow-manager.d.ts +492 -0
  84. package/dist/workflow-manager.js +1124 -0
  85. package/dist/workflow-paths.d.ts +22 -0
  86. package/dist/workflow-paths.js +46 -0
  87. package/dist/workflow-release-gate.d.ts +39 -0
  88. package/dist/workflow-release-gate.js +309 -0
  89. package/dist/workflow-saved.d.ts +38 -0
  90. package/dist/workflow-saved.js +126 -0
  91. package/dist/workflow-settings.d.ts +70 -0
  92. package/dist/workflow-settings.js +131 -0
  93. package/dist/workflow-tool.d.ts +71 -0
  94. package/dist/workflow-tool.js +367 -0
  95. package/dist/workflow-ui.d.ts +182 -0
  96. package/dist/workflow-ui.js +1587 -0
  97. package/dist/workflow.d.ts +333 -0
  98. package/dist/workflow.js +1151 -0
  99. package/dist/workflows-models-command.d.ts +31 -0
  100. package/dist/workflows-models-command.js +156 -0
  101. package/dist/worktree.d.ts +25 -0
  102. package/dist/worktree.js +61 -0
  103. package/extensions/workflow.ts +151 -0
  104. package/package.json +104 -0
  105. package/skills/workflow-authoring/SKILL.md +30 -0
  106. package/skills/workflow-authoring/examples/adversarial-verification.js +63 -0
  107. package/skills/workflow-authoring/examples/bounded-semantic-retry.js +56 -0
  108. package/skills/workflow-authoring/examples/classify-and-act.js +67 -0
  109. package/skills/workflow-authoring/examples/fan-out-and-synthesize.js +47 -0
  110. package/skills/workflow-authoring/examples/generate-and-filter.js +68 -0
  111. package/skills/workflow-authoring/examples/loop-until-done.js +68 -0
  112. package/skills/workflow-authoring/examples/phased-budgets.js +75 -0
  113. package/skills/workflow-authoring/examples/saved-nested-workflows.js +44 -0
  114. package/skills/workflow-authoring/examples/structured-output.js +37 -0
  115. package/skills/workflow-authoring/examples/tournament.js +81 -0
  116. package/skills/workflow-authoring/examples/validated-gate.js +63 -0
  117. package/skills/workflow-authoring/references/capabilities.md +41 -0
  118. package/skills/workflow-authoring/references/capability-details.md +357 -0
  119. package/skills/workflow-authoring/references/common-helpers.md +4 -0
  120. package/skills/workflow-authoring/references/debugging.md +27 -0
  121. package/skills/workflow-authoring/references/focused-recipes.md +11 -0
  122. package/skills/workflow-authoring/references/helpers.md +9 -0
  123. package/skills/workflow-authoring/references/lifecycle.md +33 -0
  124. package/skills/workflow-authoring/references/pattern-selection.md +15 -0
  125. package/skills/workflow-authoring/references/quality-helpers.md +8 -0
  126. package/skills/workflow-authoring/references/registry-ownership.md +15 -0
  127. package/skills/workflow-authoring/references/retry-helper.md +5 -0
  128. package/skills/workflow-authoring/references/review.md +43 -0
  129. package/skills/workflow-authoring/references/runtime.md +27 -0
  130. package/skills/workflow-authoring/references/specialized-helpers.md +19 -0
  131. package/skills/workflow-authoring/references/versions.md +13 -0
  132. package/skills/workflow-patterns/SKILL.md +51 -0
  133. package/src/accept-workflow-guidance.ts +71 -0
  134. package/src/adversarial-review.ts +120 -0
  135. package/src/agent-history.ts +157 -0
  136. package/src/agent-registry.ts +221 -0
  137. package/src/agent.ts +929 -0
  138. package/src/builtin-commands.ts +286 -0
  139. package/src/builtin-workflows.ts +155 -0
  140. package/src/code-review.ts +183 -0
  141. package/src/config.ts +55 -0
  142. package/src/deep-research.ts +135 -0
  143. package/src/display.ts +367 -0
  144. package/src/effort-command.ts +87 -0
  145. package/src/enums.ts +77 -0
  146. package/src/errors.ts +199 -0
  147. package/src/extension-reload.ts +100 -0
  148. package/src/fs-persistence.ts +124 -0
  149. package/src/index.ts +176 -0
  150. package/src/logger.ts +88 -0
  151. package/src/model-routing.ts +73 -0
  152. package/src/model-spec.ts +309 -0
  153. package/src/model-tier-config.ts +296 -0
  154. package/src/run-persistence.ts +484 -0
  155. package/src/saved-commands.ts +115 -0
  156. package/src/shared-store.ts +228 -0
  157. package/src/structured-output.ts +47 -0
  158. package/src/task-panel.ts +490 -0
  159. package/src/usage-limit-scheduler.ts +432 -0
  160. package/src/web-tools.ts +124 -0
  161. package/src/workflow-authoring-coverage.ts +486 -0
  162. package/src/workflow-authoring-reference.ts +186 -0
  163. package/src/workflow-capability-contract.ts +806 -0
  164. package/src/workflow-commands.ts +287 -0
  165. package/src/workflow-comprehension.ts +1673 -0
  166. package/src/workflow-context-measurement.ts +262 -0
  167. package/src/workflow-control-tool.ts +238 -0
  168. package/src/workflow-delivery-choice.ts +69 -0
  169. package/src/workflow-editor.ts +444 -0
  170. package/src/workflow-manager.ts +1405 -0
  171. package/src/workflow-paths.ts +63 -0
  172. package/src/workflow-release-gate.ts +529 -0
  173. package/src/workflow-saved.ts +180 -0
  174. package/src/workflow-settings.ts +194 -0
  175. package/src/workflow-tool.ts +464 -0
  176. package/src/workflow-ui.ts +1789 -0
  177. package/src/workflow.ts +1615 -0
  178. package/src/workflows-models-command.ts +211 -0
  179. package/src/worktree.ts +76 -0
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Per-stage model routing for workflows.
3
+ * Allows different phases to use different models.
4
+ */
5
+
6
+ export interface ModelRoute {
7
+ /** Phase name pattern (regex or exact match). */
8
+ phasePattern: string;
9
+ /** Model to use for this phase. */
10
+ model: string;
11
+ /** Whether to use regex matching. */
12
+ useRegex?: boolean;
13
+ }
14
+
15
+ export interface ModelRoutingConfig {
16
+ /** Default model for all phases. */
17
+ defaultModel?: string;
18
+ /** Per-phase model overrides. */
19
+ routes: ModelRoute[];
20
+ }
21
+
22
+ /**
23
+ * Resolve which model to use for a given phase.
24
+ */
25
+ export function resolveModelForPhase(phase: string | undefined, config: ModelRoutingConfig): string | undefined {
26
+ if (!phase || !config.routes.length) {
27
+ return config.defaultModel;
28
+ }
29
+
30
+ for (const route of config.routes) {
31
+ if (route.useRegex) {
32
+ try {
33
+ const regex = new RegExp(route.phasePattern, "i");
34
+ if (regex.test(phase)) {
35
+ return route.model;
36
+ }
37
+ } catch {
38
+ // Invalid regex, skip
39
+ }
40
+ } else if (phase === route.phasePattern) {
41
+ // Exact, case-sensitive match — phase titles are author-controlled literals,
42
+ // so fuzzy substring matching only caused mis-routes (e.g. "analyze" matching
43
+ // "analyze-deep" or vice-versa). Use the regex branch for fuzzy needs.
44
+ return route.model;
45
+ }
46
+ }
47
+
48
+ return config.defaultModel;
49
+ }
50
+
51
+ /**
52
+ * Parse model routing from workflow meta: per-phase models from meta.phases[].model
53
+ * and a top-level default from meta.model (used when no phase route matches).
54
+ */
55
+ export function parseModelRoutingFromMeta(
56
+ phases?: Array<{ title: string; model?: string }>,
57
+ defaultModel?: string,
58
+ ): ModelRoutingConfig {
59
+ const routes: ModelRoute[] = [];
60
+
61
+ if (phases) {
62
+ for (const phase of phases) {
63
+ if (phase.model) {
64
+ routes.push({
65
+ phasePattern: phase.title,
66
+ model: phase.model,
67
+ });
68
+ }
69
+ }
70
+ }
71
+
72
+ return { defaultModel, routes };
73
+ }
@@ -0,0 +1,309 @@
1
+ import type { Api, Model } from "@earendil-works/pi-ai";
2
+ import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
3
+
4
+ export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
5
+
6
+ export type ModelThinkingLevel = (typeof THINKING_LEVELS)[number];
7
+
8
+ export interface ResolvedModelSpec {
9
+ requestedSpec: string;
10
+ model?: Model<Api>;
11
+ thinkingLevel?: ModelThinkingLevel;
12
+ resolvedSpec?: string;
13
+ warning?: string;
14
+ error?: string;
15
+ }
16
+
17
+ interface ParseModelPatternOptions {
18
+ allowInvalidThinkingLevelFallback?: boolean;
19
+ }
20
+
21
+ interface ParsedModelPattern {
22
+ model?: Model<Api>;
23
+ thinkingLevel?: ModelThinkingLevel;
24
+ warning?: string;
25
+ }
26
+
27
+ const DEFAULT_MODEL_PER_PROVIDER: Record<string, string> = {
28
+ "amazon-bedrock": "us.anthropic.claude-opus-4-6-v1",
29
+ anthropic: "claude-opus-4-8",
30
+ openai: "gpt-5.4",
31
+ "azure-openai-responses": "gpt-5.4",
32
+ "openai-codex": "gpt-5.5",
33
+ deepseek: "deepseek-v4-pro",
34
+ google: "gemini-3.1-pro-preview",
35
+ "google-vertex": "gemini-3.1-pro-preview",
36
+ "github-copilot": "gpt-5.4",
37
+ openrouter: "moonshotai/kimi-k2.6",
38
+ "vercel-ai-gateway": "zai/glm-5.1",
39
+ zai: "glm-5.1",
40
+ mistral: "devstral-medium-latest",
41
+ minimax: "MiniMax-M2.7",
42
+ "minimax-cn": "MiniMax-M2.7",
43
+ moonshotai: "kimi-k2.6",
44
+ "moonshotai-cn": "kimi-k2.6",
45
+ huggingface: "moonshotai/Kimi-K2.6",
46
+ fireworks: "accounts/fireworks/models/kimi-k2p6",
47
+ together: "moonshotai/Kimi-K2.6",
48
+ opencode: "kimi-k2.6",
49
+ "opencode-go": "kimi-k2.6",
50
+ "kimi-coding": "kimi-for-coding",
51
+ "cloudflare-workers-ai": "@cf/moonshotai/kimi-k2.6",
52
+ "cloudflare-ai-gateway": "workers-ai/@cf/moonshotai/kimi-k2.6",
53
+ xiaomi: "mimo-v2.5-pro",
54
+ "xiaomi-token-plan-cn": "mimo-v2.5-pro",
55
+ "xiaomi-token-plan-ams": "mimo-v2.5-pro",
56
+ "xiaomi-token-plan-sgp": "mimo-v2.5-pro",
57
+ };
58
+
59
+ export function isThinkingLevel(value: string): value is ModelThinkingLevel {
60
+ return (THINKING_LEVELS as readonly string[]).includes(value);
61
+ }
62
+
63
+ export function formatModelSpecWithThinking(modelSpec: string, thinkingLevel: ModelThinkingLevel | undefined): string {
64
+ return thinkingLevel ? `${modelSpec}:${thinkingLevel}` : modelSpec;
65
+ }
66
+
67
+ export function canonicalModelSpec(model: Model<Api>): string {
68
+ return `${model.provider}/${model.id}`;
69
+ }
70
+
71
+ /**
72
+ * Split a stored tier spec for display/editing. Exact known model specs win, so
73
+ * model ids that legitimately contain colons are not mistaken for thinking.
74
+ */
75
+ export function splitModelSpecThinking(
76
+ spec: string | undefined,
77
+ knownModelSpecs?: readonly string[],
78
+ ): { modelSpec: string; thinkingLevel?: ModelThinkingLevel } {
79
+ const trimmed = spec?.trim() ?? "";
80
+ if (!trimmed) return { modelSpec: "", thinkingLevel: undefined };
81
+
82
+ const known = knownModelSpecs ? new Set(knownModelSpecs) : undefined;
83
+ if (known?.has(trimmed)) return { modelSpec: trimmed, thinkingLevel: undefined };
84
+
85
+ const lastColon = trimmed.lastIndexOf(":");
86
+ if (lastColon === -1) return { modelSpec: trimmed, thinkingLevel: undefined };
87
+
88
+ const prefix = trimmed.slice(0, lastColon);
89
+ const suffix = trimmed.slice(lastColon + 1);
90
+ if (!prefix || !isThinkingLevel(suffix)) return { modelSpec: trimmed, thinkingLevel: undefined };
91
+ if (known && !known.has(prefix)) return { modelSpec: trimmed, thinkingLevel: undefined };
92
+ return { modelSpec: prefix, thinkingLevel: suffix };
93
+ }
94
+
95
+ function isAlias(id: string): boolean {
96
+ if (id.endsWith("-latest")) return true;
97
+ return !/-\d{8}$/.test(id);
98
+ }
99
+
100
+ function findExactModelReferenceMatch(modelReference: string, availableModels: Model<Api>[]): Model<Api> | undefined {
101
+ const trimmedReference = modelReference.trim();
102
+ if (!trimmedReference) return undefined;
103
+ const normalizedReference = trimmedReference.toLowerCase();
104
+
105
+ const canonicalMatches = availableModels.filter(
106
+ (model) => canonicalModelSpec(model).toLowerCase() === normalizedReference,
107
+ );
108
+ if (canonicalMatches.length === 1) return canonicalMatches[0];
109
+ if (canonicalMatches.length > 1) return undefined;
110
+
111
+ const slashIndex = trimmedReference.indexOf("/");
112
+ if (slashIndex !== -1) {
113
+ const provider = trimmedReference.slice(0, slashIndex).trim();
114
+ const modelId = trimmedReference.slice(slashIndex + 1).trim();
115
+ if (provider && modelId) {
116
+ const providerMatches = availableModels.filter(
117
+ (model) =>
118
+ model.provider.toLowerCase() === provider.toLowerCase() && model.id.toLowerCase() === modelId.toLowerCase(),
119
+ );
120
+ if (providerMatches.length === 1) return providerMatches[0];
121
+ if (providerMatches.length > 1) return undefined;
122
+ }
123
+ }
124
+
125
+ const idMatches = availableModels.filter((model) => model.id.toLowerCase() === normalizedReference);
126
+ return idMatches.length === 1 ? idMatches[0] : undefined;
127
+ }
128
+
129
+ function tryMatchModel(modelPattern: string, availableModels: Model<Api>[]): Model<Api> | undefined {
130
+ const exactMatch = findExactModelReferenceMatch(modelPattern, availableModels);
131
+ if (exactMatch) return exactMatch;
132
+
133
+ const normalizedPattern = modelPattern.toLowerCase();
134
+ const matches = availableModels.filter(
135
+ (model) =>
136
+ model.id.toLowerCase().includes(normalizedPattern) || model.name?.toLowerCase().includes(normalizedPattern),
137
+ );
138
+ if (matches.length === 0) return undefined;
139
+
140
+ const aliases = matches.filter((model) => isAlias(model.id));
141
+ if (aliases.length > 0) {
142
+ aliases.sort((a, b) => b.id.localeCompare(a.id));
143
+ return aliases[0];
144
+ }
145
+
146
+ const datedVersions = matches.filter((model) => !isAlias(model.id));
147
+ datedVersions.sort((a, b) => b.id.localeCompare(a.id));
148
+ return datedVersions[0];
149
+ }
150
+
151
+ function parseModelPattern(
152
+ pattern: string,
153
+ availableModels: Model<Api>[],
154
+ options?: ParseModelPatternOptions,
155
+ ): ParsedModelPattern {
156
+ const exactMatch = tryMatchModel(pattern, availableModels);
157
+ if (exactMatch) return { model: exactMatch };
158
+
159
+ const lastColonIndex = pattern.lastIndexOf(":");
160
+ if (lastColonIndex === -1) return {};
161
+
162
+ const prefix = pattern.slice(0, lastColonIndex);
163
+ const suffix = pattern.slice(lastColonIndex + 1);
164
+ if (isThinkingLevel(suffix)) {
165
+ const result = parseModelPattern(prefix, availableModels, options);
166
+ if (!result.model) return result;
167
+ return {
168
+ model: result.model,
169
+ thinkingLevel: result.warning ? undefined : suffix,
170
+ warning: result.warning,
171
+ };
172
+ }
173
+
174
+ if (options?.allowInvalidThinkingLevelFallback === false) return {};
175
+
176
+ const result = parseModelPattern(prefix, availableModels, options);
177
+ if (!result.model) return result;
178
+ return {
179
+ model: result.model,
180
+ warning: `Invalid thinking level "${suffix}" in pattern "${pattern}". Using default instead.`,
181
+ };
182
+ }
183
+
184
+ function buildFallbackModel(provider: string, modelId: string, availableModels: Model<Api>[]): Model<Api> | undefined {
185
+ const providerModels = availableModels.filter((model) => model.provider === provider);
186
+ if (providerModels.length === 0) return undefined;
187
+ const defaultId = DEFAULT_MODEL_PER_PROVIDER[provider];
188
+ const baseModel = defaultId
189
+ ? (providerModels.find((model) => model.id === defaultId) ?? providerModels[0])
190
+ : providerModels[0];
191
+ return { ...baseModel, id: modelId, name: modelId };
192
+ }
193
+
194
+ /**
195
+ * Resolve a workflow model-tier/agent model string with the same user-facing
196
+ * grammar as Pi CLI `--model`: `provider/modelId[:thinking]`, bare model ids,
197
+ * fuzzy patterns, and exact colon-containing model ids.
198
+ */
199
+ export function resolveModelSpecWithThinking(
200
+ spec: string,
201
+ modelRegistry: Pick<ModelRegistry, "getAll">,
202
+ ): ResolvedModelSpec {
203
+ const requestedSpec = spec.trim();
204
+ if (!requestedSpec) return { requestedSpec, error: "No model spec provided." };
205
+
206
+ const availableModels = modelRegistry.getAll();
207
+ if (availableModels.length === 0) {
208
+ return {
209
+ requestedSpec,
210
+ error: "No models available. Check your installation or add models to models.json.",
211
+ };
212
+ }
213
+
214
+ const providerMap = new Map<string, string>();
215
+ for (const model of availableModels) {
216
+ providerMap.set(model.provider.toLowerCase(), model.provider);
217
+ }
218
+
219
+ let provider: string | undefined;
220
+ let pattern = requestedSpec;
221
+ let inferredProvider = false;
222
+ const slashIndex = requestedSpec.indexOf("/");
223
+ if (slashIndex !== -1) {
224
+ const maybeProvider = requestedSpec.slice(0, slashIndex);
225
+ const canonicalProvider = providerMap.get(maybeProvider.toLowerCase());
226
+ if (canonicalProvider) {
227
+ provider = canonicalProvider;
228
+ pattern = requestedSpec.slice(slashIndex + 1);
229
+ inferredProvider = true;
230
+ }
231
+ }
232
+
233
+ if (!provider) {
234
+ const exact = findExactModelReferenceMatch(requestedSpec, availableModels);
235
+ if (exact) {
236
+ return { requestedSpec, model: exact, resolvedSpec: canonicalModelSpec(exact) };
237
+ }
238
+ }
239
+
240
+ const candidates = provider ? availableModels.filter((model) => model.provider === provider) : availableModels;
241
+ const { model, thinkingLevel, warning } = parseModelPattern(pattern, candidates, {
242
+ allowInvalidThinkingLevelFallback: false,
243
+ });
244
+ if (model) {
245
+ return {
246
+ requestedSpec,
247
+ model,
248
+ thinkingLevel,
249
+ warning,
250
+ resolvedSpec: formatModelSpecWithThinking(canonicalModelSpec(model), thinkingLevel),
251
+ };
252
+ }
253
+
254
+ if (inferredProvider) {
255
+ const exact = findExactModelReferenceMatch(requestedSpec, availableModels);
256
+ if (exact) {
257
+ return { requestedSpec, model: exact, resolvedSpec: canonicalModelSpec(exact) };
258
+ }
259
+
260
+ const fallback = parseModelPattern(requestedSpec, availableModels, {
261
+ allowInvalidThinkingLevelFallback: false,
262
+ });
263
+ if (fallback.model) {
264
+ return {
265
+ requestedSpec,
266
+ model: fallback.model,
267
+ thinkingLevel: fallback.thinkingLevel,
268
+ warning: fallback.warning,
269
+ resolvedSpec: formatModelSpecWithThinking(canonicalModelSpec(fallback.model), fallback.thinkingLevel),
270
+ };
271
+ }
272
+ }
273
+
274
+ if (provider) {
275
+ let fallbackPattern = pattern;
276
+ let fallbackThinking: ModelThinkingLevel | undefined;
277
+ const lastColon = pattern.lastIndexOf(":");
278
+ if (lastColon !== -1) {
279
+ const suffix = pattern.slice(lastColon + 1);
280
+ if (isThinkingLevel(suffix)) {
281
+ fallbackPattern = pattern.slice(0, lastColon);
282
+ fallbackThinking = suffix;
283
+ }
284
+ }
285
+
286
+ const fallbackModel = buildFallbackModel(provider, fallbackPattern, availableModels);
287
+ if (fallbackModel) {
288
+ const modelWithReasoning =
289
+ fallbackThinking && fallbackThinking !== "off" ? { ...fallbackModel, reasoning: true } : fallbackModel;
290
+ const fallbackWarning = warning
291
+ ? `${warning} Model "${fallbackPattern}" not found for provider "${provider}". Using custom model id.`
292
+ : `Model "${fallbackPattern}" not found for provider "${provider}". Using custom model id.`;
293
+ return {
294
+ requestedSpec,
295
+ model: modelWithReasoning,
296
+ thinkingLevel: fallbackThinking,
297
+ warning: fallbackWarning,
298
+ resolvedSpec: formatModelSpecWithThinking(canonicalModelSpec(modelWithReasoning), fallbackThinking),
299
+ };
300
+ }
301
+ }
302
+
303
+ const display = provider ? `${provider}/${pattern}` : requestedSpec;
304
+ return {
305
+ requestedSpec,
306
+ warning,
307
+ error: `Model "${display}" not found. Use /workflows-models to choose an available model.`,
308
+ };
309
+ }
@@ -0,0 +1,296 @@
1
+ /**
2
+ * Model tier configuration for workflow subagent model routing.
3
+ *
4
+ * A tier is a named slot (small/medium/big) holding exactly ONE model spec
5
+ * string (e.g. "openai/gpt-4.1-mini" or "openai-codex/gpt-5.5:xhigh").
6
+ * When an agent() call specifies opts.tier, that single model is resolved with
7
+ * Pi CLI-style parsing and used as the subagent's model/thinking level (unless
8
+ * an explicit opts.model is given, which always wins — see agent.ts).
9
+ *
10
+ * This augments the phase-pattern routing in model-routing.ts: phase routing
11
+ * maps workflow phases → models via the script's meta; tiers give scripts a
12
+ * coarse, user-configurable small/medium/big knob that is independent of any
13
+ * concrete provider/model id.
14
+ */
15
+
16
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
17
+ import { homedir } from "node:os";
18
+ import { dirname, join } from "node:path";
19
+ import { listAvailableModels } from "./agent.js";
20
+ import { MODEL_TIERS_FILE } from "./config.js";
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Types
24
+ // ---------------------------------------------------------------------------
25
+
26
+ /**
27
+ * Model tier configuration. Maps tier names (e.g. "small", "medium", "big")
28
+ * to a single model spec string (e.g. "gpt-4.1-mini", "openai/gpt-4.1-mini",
29
+ * or "openai-codex/gpt-5.5:xhigh").
30
+ */
31
+ export interface ModelTierConfig {
32
+ tiers: Record<string, string>;
33
+ }
34
+
35
+ /**
36
+ * The minimal projection of a model that tier ranking needs. Deliberately NOT
37
+ * the SDK's full `Model` type: tier logic depends only on these three fields,
38
+ * so it stays decoupled from the SDK (no `@earendil-works/pi-ai` import here)
39
+ * and is trivially unit-testable with plain objects. `agent.ts`'s
40
+ * `listAvailableModels()` produces these from the live registry.
41
+ */
42
+ export interface RankableModel {
43
+ /** Canonical "provider/id" spec string. */
44
+ spec: string;
45
+ /** Per-token output price, if the registry reports one. Missing or 0 = unknown. */
46
+ costOutput?: number;
47
+ /** Context window size, if the registry reports one. */
48
+ contextWindow?: number;
49
+ }
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Configuration path
53
+ // ---------------------------------------------------------------------------
54
+
55
+ /** Path to the model tiers JSON config file (~/.pi/workflows/model-tiers.json). */
56
+ export function getModelTierConfigPath(): string {
57
+ return join(homedir(), MODEL_TIERS_FILE);
58
+ }
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Capability signal
62
+ // ---------------------------------------------------------------------------
63
+
64
+ /**
65
+ * Substrings that identify small/cheap models (case-insensitive), used only as
66
+ * a fallback capability hint when price signals are absent or tied.
67
+ */
68
+ export const SMALL_MODEL_HINTS = ["mini", "flash", "haiku", "nano", "small"] as const;
69
+
70
+ /**
71
+ * Substrings that identify large/capable models (case-insensitive), used only
72
+ * as a fallback capability hint when price signals are absent or tied.
73
+ */
74
+ export const BIG_MODEL_HINTS = ["opus", "pro", "ultra", "large", "plus"] as const;
75
+
76
+ /**
77
+ * Fallback capability hint from a model's name: -1 for a small/cheap name, +1
78
+ * for a large/capable name, 0 otherwise. If a name matches both sets, the small
79
+ * hint wins (we never want a "mini"-labelled model to outrank a neutral or
80
+ * clearly-large one). This is only a FALLBACK: `rankByCapability` prefers the
81
+ * registry's price signal, which is robust to new vendor names (e.g. "fable",
82
+ * "mimo") that match no hint and would otherwise all score 0.
83
+ */
84
+ export function hintScore(spec: string): number {
85
+ const lower = spec.toLowerCase();
86
+ if (SMALL_MODEL_HINTS.some((hint) => lower.includes(hint))) return -1;
87
+ if (BIG_MODEL_HINTS.some((hint) => lower.includes(hint))) return 1;
88
+ return 0;
89
+ }
90
+
91
+ /**
92
+ * Rank models from least → most capable.
93
+ *
94
+ * PRIMARY signal is output price (higher price ≈ more capable): within a single
95
+ * registry, price tracks the vendor's capability tier far more robustly than
96
+ * model-name substrings, and it works for models whose names match no hint.
97
+ *
98
+ * Models with an UNKNOWN price (missing or 0 — common for self-hosted
99
+ * `models.json` entries) are NOT treated as "cheapest = weakest". Instead they
100
+ * are projected onto the known price range via their substring hint: a
101
+ * big-hint name lands at the top of the range, a small-hint name at the bottom,
102
+ * a neutral name at the middle. When NO model has a known price at all, this
103
+ * degrades to pure hint ordering (the previous behavior).
104
+ *
105
+ * The comparison is a single total order (projected cost → hint → contextWindow
106
+ * → stable registry index), so the sort is transitive and stable.
107
+ */
108
+ export function rankByCapability(models: readonly RankableModel[]): RankableModel[] {
109
+ const knownCosts = models
110
+ .map((m) => m.costOutput)
111
+ .filter((c): c is number => typeof c === "number" && c > 0)
112
+ .sort((a, b) => a - b);
113
+ const hasPriceSignal = knownCosts.length > 0;
114
+ const min = knownCosts[0];
115
+ const max = knownCosts[knownCosts.length - 1];
116
+ const median = knownCosts[Math.floor(knownCosts.length / 2)];
117
+
118
+ // Project every model onto the price axis. Undefined only when there is no
119
+ // price signal anywhere (all models unpriced) — then the sort falls through
120
+ // to the hint comparison below.
121
+ const costKey = (m: RankableModel): number | undefined => {
122
+ if (typeof m.costOutput === "number" && m.costOutput > 0) return m.costOutput;
123
+ if (!hasPriceSignal) return undefined;
124
+ const hint = hintScore(m.spec);
125
+ return hint > 0 ? max : hint < 0 ? min : median;
126
+ };
127
+
128
+ return models
129
+ .map((m, index) => ({ m, index, cost: costKey(m), hint: hintScore(m.spec), ctx: m.contextWindow ?? 0 }))
130
+ .sort((a, b) => {
131
+ if (a.cost !== undefined && b.cost !== undefined && a.cost !== b.cost) return a.cost - b.cost;
132
+ if (a.hint !== b.hint) return a.hint - b.hint;
133
+ if (a.ctx !== b.ctx) return a.ctx - b.ctx;
134
+ return a.index - b.index;
135
+ })
136
+ .map((entry) => entry.m);
137
+ }
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // Defaults
141
+ // ---------------------------------------------------------------------------
142
+
143
+ /**
144
+ * Build a default tier config. When the available model registry is known,
145
+ * spread it across tiers so small/medium/big routing is meaningful out of the
146
+ * box. When the registry is empty or unavailable, fall back to the current Pi
147
+ * model so fresh installs still get usable tier values.
148
+ *
149
+ * Models are first ranked least → most capable via `rankByCapability` (price
150
+ * first, name-substring hint as fallback). Tiers are then assigned from this
151
+ * single ranked pool with exclusion — each model is used for at most one tier —
152
+ * so distinct tiers never collapse onto the same model and a weaker model can
153
+ * never outrank a stronger one (no inversion):
154
+ *
155
+ * - big = the most capable model (last in the ranking)
156
+ * - small = the least capable model (first in the ranking)
157
+ * - medium = the middle-ranked model
158
+ *
159
+ * When fewer than 3 distinct models are available, this degrades gracefully by
160
+ * reusing the *strongest* available model for the higher tier(s):
161
+ *
162
+ * - 2 models: small = weaker, medium = big = stronger
163
+ * - 1 / 0 models: small = medium = big = that model (or the current model /
164
+ * "" fallback)
165
+ *
166
+ * `availableModels` is injectable for testing and for callers that already
167
+ * fetched the registry. When omitted, this reads from the live registry.
168
+ */
169
+ export function buildDefaultTierConfig(
170
+ currentModelSpec?: string,
171
+ availableModels?: readonly RankableModel[],
172
+ ): ModelTierConfig {
173
+ const models = availableModels ?? listAvailableModels();
174
+ const ranked = rankByCapability(models).map((m) => m.spec);
175
+
176
+ if (ranked.length >= 3) {
177
+ const small = ranked[0];
178
+ const big = ranked[ranked.length - 1];
179
+ const medium = ranked[Math.floor(ranked.length / 2)];
180
+ return { tiers: { small, medium, big } };
181
+ }
182
+ if (ranked.length === 2) {
183
+ const [weaker, stronger] = ranked;
184
+ return { tiers: { small: weaker, medium: stronger, big: stronger } };
185
+ }
186
+ const fallback = ranked[0] ?? currentModelSpec ?? "";
187
+ return {
188
+ tiers: {
189
+ small: fallback,
190
+ medium: fallback,
191
+ big: fallback,
192
+ },
193
+ };
194
+ }
195
+
196
+ /**
197
+ * One-time notice shown when an agent requests `opts.tier` but no
198
+ * model-tiers.json is configured — in that state tiers silently fall back to
199
+ * the session model (see `resolveAgentModelSpec` in agent.ts), which is easy to
200
+ * miss. This surfaces the fallback and the mapping the user *would* get by
201
+ * configuring, using the same `buildDefaultTierConfig` ranking so the hint is
202
+ * actionable. Pure/string-only so the caller owns how it's emitted.
203
+ */
204
+ export function formatTierFallbackNotice(
205
+ mainModel: string | undefined,
206
+ availableModels: readonly RankableModel[],
207
+ ): string {
208
+ const fallback = mainModel ?? "the session default model";
209
+ const suggested = buildDefaultTierConfig(mainModel, availableModels);
210
+ const mapping = sortedTierNames(suggested)
211
+ .map((tier) => `${tier}=${suggested.tiers[tier] || "?"}`)
212
+ .join(" ");
213
+ return (
214
+ `[workflow] An agent requested opts.tier but no model-tiers.json is configured, so tiers currently ` +
215
+ `fall back to ${fallback}. Run /workflows-models to configure them` +
216
+ (mapping ? `. Suggested mapping from your available models: ${mapping}` : ".")
217
+ );
218
+ }
219
+
220
+ // ---------------------------------------------------------------------------
221
+ // Load / Save
222
+ // ---------------------------------------------------------------------------
223
+
224
+ /**
225
+ * True iff `value` is a usable tiers map: a plain (non-array) object with at
226
+ * least one entry, every key and value a non-empty string. Anything else
227
+ * (an array, `{}`, or a tier mapped to `""`) is treated as absent rather than
228
+ * a truthy-but-broken config — resolveTierModel would silently resolve such
229
+ * entries to `undefined`/`""` while the caller's "no model-tiers.json
230
+ * configured" warning only fires on an exactly-null config.
231
+ */
232
+ function isValidTiersMap(value: unknown): value is Record<string, string> {
233
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
234
+ const entries = Object.entries(value as Record<string, unknown>);
235
+ if (entries.length === 0) return false;
236
+ return entries.every(([key, val]) => key.trim().length > 0 && typeof val === "string" && val.trim().length > 0);
237
+ }
238
+
239
+ /**
240
+ * Load the model tier config from disk. Returns null if the file does not
241
+ * exist or is unparseable (callers fall back to a default).
242
+ */
243
+ export function loadModelTierConfig(configPath?: string): ModelTierConfig | null {
244
+ const path = configPath ?? getModelTierConfigPath();
245
+ if (!existsSync(path)) return null;
246
+ try {
247
+ const raw = readFileSync(path, "utf-8");
248
+ const parsed = JSON.parse(raw);
249
+ if (!parsed || typeof parsed !== "object") return null;
250
+ if (!isValidTiersMap(parsed.tiers)) return null;
251
+ return parsed as ModelTierConfig;
252
+ } catch {
253
+ return null;
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Save a model tier config to disk. Creates parent directories if needed.
259
+ *
260
+ * Refuses a degenerate `tiers` (e.g. all-empty-string, from buildDefaultTierConfig
261
+ * with an empty model registry) using the same isValidTiersMap check the loader
262
+ * uses — otherwise the write side could produce exactly the shape loadModelTierConfig
263
+ * now rejects on the next read, silently discarding the "saved" config.
264
+ */
265
+ export function saveModelTierConfig(config: ModelTierConfig, configPath?: string): void {
266
+ if (!isValidTiersMap(config?.tiers)) {
267
+ throw new Error(
268
+ "Refusing to save a degenerate model tier config: tiers must be a non-empty map of tier name to a non-empty model spec string.",
269
+ );
270
+ }
271
+ const path = configPath ?? getModelTierConfigPath();
272
+ const dir = dirname(path);
273
+ if (!existsSync(dir)) {
274
+ mkdirSync(dir, { recursive: true });
275
+ }
276
+ writeFileSync(path, JSON.stringify(config, null, 2), "utf-8");
277
+ }
278
+
279
+ // ---------------------------------------------------------------------------
280
+ // Resolve / helpers
281
+ // ---------------------------------------------------------------------------
282
+
283
+ /**
284
+ * Resolve a tier name to its configured model spec, or undefined if the tier
285
+ * is not configured.
286
+ */
287
+ export function resolveTierModel(tier: string, config: ModelTierConfig): string | undefined {
288
+ return config.tiers[tier];
289
+ }
290
+
291
+ /** Return all tier names sorted: small < medium < big, then alphabetically. */
292
+ export function sortedTierNames(config: ModelTierConfig): string[] {
293
+ const names = Object.keys(config.tiers);
294
+ const rank: Record<string, number> = { small: 0, medium: 1, big: 2 };
295
+ return names.sort((a, b) => (rank[a] ?? 99) - (rank[b] ?? 99) || a.localeCompare(b));
296
+ }