@oh-my-pi/pi-coding-agent 1.337.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 (224) hide show
  1. package/CHANGELOG.md +1228 -0
  2. package/README.md +1041 -0
  3. package/docs/compaction.md +403 -0
  4. package/docs/custom-tools.md +541 -0
  5. package/docs/extension-loading.md +1004 -0
  6. package/docs/hooks.md +867 -0
  7. package/docs/rpc.md +1040 -0
  8. package/docs/sdk.md +994 -0
  9. package/docs/session-tree-plan.md +441 -0
  10. package/docs/session.md +240 -0
  11. package/docs/skills.md +290 -0
  12. package/docs/theme.md +637 -0
  13. package/docs/tree.md +197 -0
  14. package/docs/tui.md +341 -0
  15. package/examples/README.md +21 -0
  16. package/examples/custom-tools/README.md +124 -0
  17. package/examples/custom-tools/hello/index.ts +20 -0
  18. package/examples/custom-tools/question/index.ts +84 -0
  19. package/examples/custom-tools/subagent/README.md +172 -0
  20. package/examples/custom-tools/subagent/agents/planner.md +37 -0
  21. package/examples/custom-tools/subagent/agents/reviewer.md +35 -0
  22. package/examples/custom-tools/subagent/agents/scout.md +50 -0
  23. package/examples/custom-tools/subagent/agents/worker.md +24 -0
  24. package/examples/custom-tools/subagent/agents.ts +156 -0
  25. package/examples/custom-tools/subagent/commands/implement-and-review.md +10 -0
  26. package/examples/custom-tools/subagent/commands/implement.md +10 -0
  27. package/examples/custom-tools/subagent/commands/scout-and-plan.md +9 -0
  28. package/examples/custom-tools/subagent/index.ts +1002 -0
  29. package/examples/custom-tools/todo/index.ts +212 -0
  30. package/examples/hooks/README.md +56 -0
  31. package/examples/hooks/auto-commit-on-exit.ts +49 -0
  32. package/examples/hooks/confirm-destructive.ts +59 -0
  33. package/examples/hooks/custom-compaction.ts +116 -0
  34. package/examples/hooks/dirty-repo-guard.ts +52 -0
  35. package/examples/hooks/file-trigger.ts +41 -0
  36. package/examples/hooks/git-checkpoint.ts +53 -0
  37. package/examples/hooks/handoff.ts +150 -0
  38. package/examples/hooks/permission-gate.ts +34 -0
  39. package/examples/hooks/protected-paths.ts +30 -0
  40. package/examples/hooks/qna.ts +119 -0
  41. package/examples/hooks/snake.ts +343 -0
  42. package/examples/hooks/status-line.ts +40 -0
  43. package/examples/sdk/01-minimal.ts +22 -0
  44. package/examples/sdk/02-custom-model.ts +49 -0
  45. package/examples/sdk/03-custom-prompt.ts +44 -0
  46. package/examples/sdk/04-skills.ts +44 -0
  47. package/examples/sdk/05-tools.ts +90 -0
  48. package/examples/sdk/06-hooks.ts +61 -0
  49. package/examples/sdk/07-context-files.ts +36 -0
  50. package/examples/sdk/08-slash-commands.ts +42 -0
  51. package/examples/sdk/09-api-keys-and-oauth.ts +55 -0
  52. package/examples/sdk/10-settings.ts +38 -0
  53. package/examples/sdk/11-sessions.ts +48 -0
  54. package/examples/sdk/12-full-control.ts +95 -0
  55. package/examples/sdk/README.md +154 -0
  56. package/package.json +81 -0
  57. package/src/cli/args.ts +246 -0
  58. package/src/cli/file-processor.ts +72 -0
  59. package/src/cli/list-models.ts +104 -0
  60. package/src/cli/plugin-cli.ts +650 -0
  61. package/src/cli/session-picker.ts +41 -0
  62. package/src/cli.ts +10 -0
  63. package/src/commands/init.md +20 -0
  64. package/src/config.ts +159 -0
  65. package/src/core/agent-session.ts +1900 -0
  66. package/src/core/auth-storage.ts +236 -0
  67. package/src/core/bash-executor.ts +196 -0
  68. package/src/core/compaction/branch-summarization.ts +343 -0
  69. package/src/core/compaction/compaction.ts +742 -0
  70. package/src/core/compaction/index.ts +7 -0
  71. package/src/core/compaction/utils.ts +154 -0
  72. package/src/core/custom-tools/index.ts +21 -0
  73. package/src/core/custom-tools/loader.ts +248 -0
  74. package/src/core/custom-tools/types.ts +169 -0
  75. package/src/core/custom-tools/wrapper.ts +28 -0
  76. package/src/core/exec.ts +129 -0
  77. package/src/core/export-html/index.ts +211 -0
  78. package/src/core/export-html/template.css +781 -0
  79. package/src/core/export-html/template.html +54 -0
  80. package/src/core/export-html/template.js +1185 -0
  81. package/src/core/export-html/vendor/highlight.min.js +1213 -0
  82. package/src/core/export-html/vendor/marked.min.js +6 -0
  83. package/src/core/hooks/index.ts +16 -0
  84. package/src/core/hooks/loader.ts +312 -0
  85. package/src/core/hooks/runner.ts +434 -0
  86. package/src/core/hooks/tool-wrapper.ts +99 -0
  87. package/src/core/hooks/types.ts +773 -0
  88. package/src/core/index.ts +52 -0
  89. package/src/core/mcp/client.ts +158 -0
  90. package/src/core/mcp/config.ts +154 -0
  91. package/src/core/mcp/index.ts +45 -0
  92. package/src/core/mcp/loader.ts +68 -0
  93. package/src/core/mcp/manager.ts +181 -0
  94. package/src/core/mcp/tool-bridge.ts +148 -0
  95. package/src/core/mcp/transports/http.ts +316 -0
  96. package/src/core/mcp/transports/index.ts +6 -0
  97. package/src/core/mcp/transports/stdio.ts +252 -0
  98. package/src/core/mcp/types.ts +220 -0
  99. package/src/core/messages.ts +189 -0
  100. package/src/core/model-registry.ts +317 -0
  101. package/src/core/model-resolver.ts +393 -0
  102. package/src/core/plugins/doctor.ts +59 -0
  103. package/src/core/plugins/index.ts +38 -0
  104. package/src/core/plugins/installer.ts +189 -0
  105. package/src/core/plugins/loader.ts +338 -0
  106. package/src/core/plugins/manager.ts +672 -0
  107. package/src/core/plugins/parser.ts +105 -0
  108. package/src/core/plugins/paths.ts +32 -0
  109. package/src/core/plugins/types.ts +190 -0
  110. package/src/core/sdk.ts +760 -0
  111. package/src/core/session-manager.ts +1128 -0
  112. package/src/core/settings-manager.ts +443 -0
  113. package/src/core/skills.ts +437 -0
  114. package/src/core/slash-commands.ts +248 -0
  115. package/src/core/system-prompt.ts +439 -0
  116. package/src/core/timings.ts +25 -0
  117. package/src/core/tools/ask.ts +211 -0
  118. package/src/core/tools/bash-interceptor.ts +120 -0
  119. package/src/core/tools/bash.ts +250 -0
  120. package/src/core/tools/context.ts +32 -0
  121. package/src/core/tools/edit-diff.ts +475 -0
  122. package/src/core/tools/edit.ts +208 -0
  123. package/src/core/tools/exa/company.ts +59 -0
  124. package/src/core/tools/exa/index.ts +64 -0
  125. package/src/core/tools/exa/linkedin.ts +59 -0
  126. package/src/core/tools/exa/logger.ts +56 -0
  127. package/src/core/tools/exa/mcp-client.ts +368 -0
  128. package/src/core/tools/exa/render.ts +196 -0
  129. package/src/core/tools/exa/researcher.ts +90 -0
  130. package/src/core/tools/exa/search.ts +337 -0
  131. package/src/core/tools/exa/types.ts +168 -0
  132. package/src/core/tools/exa/websets.ts +248 -0
  133. package/src/core/tools/find.ts +261 -0
  134. package/src/core/tools/grep.ts +555 -0
  135. package/src/core/tools/index.ts +202 -0
  136. package/src/core/tools/ls.ts +140 -0
  137. package/src/core/tools/lsp/client.ts +605 -0
  138. package/src/core/tools/lsp/config.ts +147 -0
  139. package/src/core/tools/lsp/edits.ts +101 -0
  140. package/src/core/tools/lsp/index.ts +804 -0
  141. package/src/core/tools/lsp/render.ts +447 -0
  142. package/src/core/tools/lsp/rust-analyzer.ts +145 -0
  143. package/src/core/tools/lsp/types.ts +463 -0
  144. package/src/core/tools/lsp/utils.ts +486 -0
  145. package/src/core/tools/notebook.ts +229 -0
  146. package/src/core/tools/path-utils.ts +61 -0
  147. package/src/core/tools/read.ts +240 -0
  148. package/src/core/tools/renderers.ts +540 -0
  149. package/src/core/tools/task/agents.ts +153 -0
  150. package/src/core/tools/task/artifacts.ts +114 -0
  151. package/src/core/tools/task/bundled-agents/browser.md +71 -0
  152. package/src/core/tools/task/bundled-agents/explore.md +82 -0
  153. package/src/core/tools/task/bundled-agents/plan.md +54 -0
  154. package/src/core/tools/task/bundled-agents/reviewer.md +59 -0
  155. package/src/core/tools/task/bundled-agents/task.md +53 -0
  156. package/src/core/tools/task/bundled-commands/architect-plan.md +10 -0
  157. package/src/core/tools/task/bundled-commands/implement-with-critic.md +11 -0
  158. package/src/core/tools/task/bundled-commands/implement.md +11 -0
  159. package/src/core/tools/task/commands.ts +213 -0
  160. package/src/core/tools/task/discovery.ts +208 -0
  161. package/src/core/tools/task/executor.ts +367 -0
  162. package/src/core/tools/task/index.ts +388 -0
  163. package/src/core/tools/task/model-resolver.ts +115 -0
  164. package/src/core/tools/task/parallel.ts +38 -0
  165. package/src/core/tools/task/render.ts +232 -0
  166. package/src/core/tools/task/types.ts +99 -0
  167. package/src/core/tools/truncate.ts +265 -0
  168. package/src/core/tools/web-fetch.ts +2370 -0
  169. package/src/core/tools/web-search/auth.ts +193 -0
  170. package/src/core/tools/web-search/index.ts +537 -0
  171. package/src/core/tools/web-search/providers/anthropic.ts +198 -0
  172. package/src/core/tools/web-search/providers/exa.ts +302 -0
  173. package/src/core/tools/web-search/providers/perplexity.ts +195 -0
  174. package/src/core/tools/web-search/render.ts +182 -0
  175. package/src/core/tools/web-search/types.ts +180 -0
  176. package/src/core/tools/write.ts +99 -0
  177. package/src/index.ts +176 -0
  178. package/src/main.ts +464 -0
  179. package/src/migrations.ts +135 -0
  180. package/src/modes/index.ts +43 -0
  181. package/src/modes/interactive/components/armin.ts +382 -0
  182. package/src/modes/interactive/components/assistant-message.ts +86 -0
  183. package/src/modes/interactive/components/bash-execution.ts +196 -0
  184. package/src/modes/interactive/components/bordered-loader.ts +41 -0
  185. package/src/modes/interactive/components/branch-summary-message.ts +42 -0
  186. package/src/modes/interactive/components/compaction-summary-message.ts +45 -0
  187. package/src/modes/interactive/components/custom-editor.ts +122 -0
  188. package/src/modes/interactive/components/diff.ts +147 -0
  189. package/src/modes/interactive/components/dynamic-border.ts +25 -0
  190. package/src/modes/interactive/components/footer.ts +381 -0
  191. package/src/modes/interactive/components/hook-editor.ts +117 -0
  192. package/src/modes/interactive/components/hook-input.ts +64 -0
  193. package/src/modes/interactive/components/hook-message.ts +96 -0
  194. package/src/modes/interactive/components/hook-selector.ts +91 -0
  195. package/src/modes/interactive/components/model-selector.ts +247 -0
  196. package/src/modes/interactive/components/oauth-selector.ts +120 -0
  197. package/src/modes/interactive/components/plugin-settings.ts +479 -0
  198. package/src/modes/interactive/components/queue-mode-selector.ts +56 -0
  199. package/src/modes/interactive/components/session-selector.ts +204 -0
  200. package/src/modes/interactive/components/settings-selector.ts +453 -0
  201. package/src/modes/interactive/components/show-images-selector.ts +45 -0
  202. package/src/modes/interactive/components/theme-selector.ts +62 -0
  203. package/src/modes/interactive/components/thinking-selector.ts +64 -0
  204. package/src/modes/interactive/components/tool-execution.ts +675 -0
  205. package/src/modes/interactive/components/tree-selector.ts +866 -0
  206. package/src/modes/interactive/components/user-message-selector.ts +159 -0
  207. package/src/modes/interactive/components/user-message.ts +18 -0
  208. package/src/modes/interactive/components/visual-truncate.ts +50 -0
  209. package/src/modes/interactive/components/welcome.ts +183 -0
  210. package/src/modes/interactive/interactive-mode.ts +2516 -0
  211. package/src/modes/interactive/theme/dark.json +101 -0
  212. package/src/modes/interactive/theme/light.json +98 -0
  213. package/src/modes/interactive/theme/theme-schema.json +308 -0
  214. package/src/modes/interactive/theme/theme.ts +998 -0
  215. package/src/modes/print-mode.ts +128 -0
  216. package/src/modes/rpc/rpc-client.ts +527 -0
  217. package/src/modes/rpc/rpc-mode.ts +483 -0
  218. package/src/modes/rpc/rpc-types.ts +203 -0
  219. package/src/utils/changelog.ts +99 -0
  220. package/src/utils/clipboard.ts +265 -0
  221. package/src/utils/fuzzy.ts +108 -0
  222. package/src/utils/mime.ts +30 -0
  223. package/src/utils/shell.ts +276 -0
  224. package/src/utils/tools-manager.ts +274 -0
@@ -0,0 +1,317 @@
1
+ /**
2
+ * Model registry - manages built-in and custom models, provides API key resolution.
3
+ */
4
+
5
+ import {
6
+ type Api,
7
+ getGitHubCopilotBaseUrl,
8
+ getModels,
9
+ getProviders,
10
+ type KnownProvider,
11
+ type Model,
12
+ normalizeDomain,
13
+ } from "@oh-my-pi/pi-ai";
14
+ import { type Static, Type } from "@sinclair/typebox";
15
+ import AjvModule from "ajv";
16
+ import { existsSync, readFileSync } from "fs";
17
+ import type { AuthStorage } from "./auth-storage.js";
18
+
19
+ const Ajv = (AjvModule as any).default || AjvModule;
20
+
21
+ // Schema for OpenAI compatibility settings
22
+ const OpenAICompatSchema = Type.Object({
23
+ supportsStore: Type.Optional(Type.Boolean()),
24
+ supportsDeveloperRole: Type.Optional(Type.Boolean()),
25
+ supportsReasoningEffort: Type.Optional(Type.Boolean()),
26
+ maxTokensField: Type.Optional(Type.Union([Type.Literal("max_completion_tokens"), Type.Literal("max_tokens")])),
27
+ });
28
+
29
+ // Schema for custom model definition
30
+ const ModelDefinitionSchema = Type.Object({
31
+ id: Type.String({ minLength: 1 }),
32
+ name: Type.String({ minLength: 1 }),
33
+ api: Type.Optional(
34
+ Type.Union([
35
+ Type.Literal("openai-completions"),
36
+ Type.Literal("openai-responses"),
37
+ Type.Literal("anthropic-messages"),
38
+ Type.Literal("google-generative-ai"),
39
+ ]),
40
+ ),
41
+ reasoning: Type.Boolean(),
42
+ input: Type.Array(Type.Union([Type.Literal("text"), Type.Literal("image")])),
43
+ cost: Type.Object({
44
+ input: Type.Number(),
45
+ output: Type.Number(),
46
+ cacheRead: Type.Number(),
47
+ cacheWrite: Type.Number(),
48
+ }),
49
+ contextWindow: Type.Number(),
50
+ maxTokens: Type.Number(),
51
+ headers: Type.Optional(Type.Record(Type.String(), Type.String())),
52
+ compat: Type.Optional(OpenAICompatSchema),
53
+ });
54
+
55
+ const ProviderConfigSchema = Type.Object({
56
+ baseUrl: Type.String({ minLength: 1 }),
57
+ apiKey: Type.String({ minLength: 1 }),
58
+ api: Type.Optional(
59
+ Type.Union([
60
+ Type.Literal("openai-completions"),
61
+ Type.Literal("openai-responses"),
62
+ Type.Literal("anthropic-messages"),
63
+ Type.Literal("google-generative-ai"),
64
+ ]),
65
+ ),
66
+ headers: Type.Optional(Type.Record(Type.String(), Type.String())),
67
+ authHeader: Type.Optional(Type.Boolean()),
68
+ models: Type.Array(ModelDefinitionSchema),
69
+ });
70
+
71
+ const ModelsConfigSchema = Type.Object({
72
+ providers: Type.Record(Type.String(), ProviderConfigSchema),
73
+ });
74
+
75
+ type ModelsConfig = Static<typeof ModelsConfigSchema>;
76
+
77
+ /**
78
+ * Resolve an API key config value to an actual key.
79
+ * Checks environment variable first, then treats as literal.
80
+ */
81
+ function resolveApiKeyConfig(keyConfig: string): string | undefined {
82
+ const envValue = process.env[keyConfig];
83
+ if (envValue) return envValue;
84
+ return keyConfig;
85
+ }
86
+
87
+ /**
88
+ * Model registry - loads and manages models, resolves API keys via AuthStorage.
89
+ */
90
+ export class ModelRegistry {
91
+ private models: Model<Api>[] = [];
92
+ private customProviderApiKeys: Map<string, string> = new Map();
93
+ private loadError: string | undefined = undefined;
94
+
95
+ constructor(
96
+ readonly authStorage: AuthStorage,
97
+ private modelsJsonPath: string | undefined = undefined,
98
+ ) {
99
+ // Set up fallback resolver for custom provider API keys
100
+ this.authStorage.setFallbackResolver((provider) => {
101
+ const keyConfig = this.customProviderApiKeys.get(provider);
102
+ if (keyConfig) {
103
+ return resolveApiKeyConfig(keyConfig);
104
+ }
105
+ return undefined;
106
+ });
107
+
108
+ // Load models
109
+ this.loadModels();
110
+ }
111
+
112
+ /**
113
+ * Reload models from disk (built-in + custom from models.json).
114
+ */
115
+ refresh(): void {
116
+ this.customProviderApiKeys.clear();
117
+ this.loadError = undefined;
118
+ this.loadModels();
119
+ }
120
+
121
+ /**
122
+ * Get any error from loading models.json (undefined if no error).
123
+ */
124
+ getError(): string | undefined {
125
+ return this.loadError;
126
+ }
127
+
128
+ private loadModels(): void {
129
+ // Load built-in models
130
+ const builtInModels: Model<Api>[] = [];
131
+ for (const provider of getProviders()) {
132
+ const providerModels = getModels(provider as KnownProvider);
133
+ builtInModels.push(...(providerModels as Model<Api>[]));
134
+ }
135
+
136
+ // Load custom models from models.json (if path provided)
137
+ let customModels: Model<Api>[] = [];
138
+ if (this.modelsJsonPath) {
139
+ const result = this.loadCustomModels(this.modelsJsonPath);
140
+ if (result.error) {
141
+ this.loadError = result.error;
142
+ // Keep built-in models even if custom models failed to load
143
+ } else {
144
+ customModels = result.models;
145
+ }
146
+ }
147
+
148
+ const combined = [...builtInModels, ...customModels];
149
+
150
+ // Update github-copilot base URL based on OAuth credentials
151
+ const copilotCred = this.authStorage.get("github-copilot");
152
+ if (copilotCred?.type === "oauth") {
153
+ const domain = copilotCred.enterpriseUrl
154
+ ? (normalizeDomain(copilotCred.enterpriseUrl) ?? undefined)
155
+ : undefined;
156
+ const baseUrl = getGitHubCopilotBaseUrl(copilotCred.access, domain);
157
+ this.models = combined.map((m) => (m.provider === "github-copilot" ? { ...m, baseUrl } : m));
158
+ } else {
159
+ this.models = combined;
160
+ }
161
+ }
162
+
163
+ private loadCustomModels(modelsJsonPath: string): { models: Model<Api>[]; error: string | undefined } {
164
+ if (!existsSync(modelsJsonPath)) {
165
+ return { models: [], error: undefined };
166
+ }
167
+
168
+ try {
169
+ const content = readFileSync(modelsJsonPath, "utf-8");
170
+ const config: ModelsConfig = JSON.parse(content);
171
+
172
+ // Validate schema
173
+ const ajv = new Ajv();
174
+ const validate = ajv.compile(ModelsConfigSchema);
175
+ if (!validate(config)) {
176
+ const errors =
177
+ validate.errors?.map((e: any) => ` - ${e.instancePath || "root"}: ${e.message}`).join("\n") ||
178
+ "Unknown schema error";
179
+ return {
180
+ models: [],
181
+ error: `Invalid models.json schema:\n${errors}\n\nFile: ${modelsJsonPath}`,
182
+ };
183
+ }
184
+
185
+ // Additional validation
186
+ this.validateConfig(config);
187
+
188
+ // Parse models
189
+ return { models: this.parseModels(config), error: undefined };
190
+ } catch (error) {
191
+ if (error instanceof SyntaxError) {
192
+ return {
193
+ models: [],
194
+ error: `Failed to parse models.json: ${error.message}\n\nFile: ${modelsJsonPath}`,
195
+ };
196
+ }
197
+ return {
198
+ models: [],
199
+ error: `Failed to load models.json: ${
200
+ error instanceof Error ? error.message : error
201
+ }\n\nFile: ${modelsJsonPath}`,
202
+ };
203
+ }
204
+ }
205
+
206
+ private validateConfig(config: ModelsConfig): void {
207
+ for (const [providerName, providerConfig] of Object.entries(config.providers)) {
208
+ const hasProviderApi = !!providerConfig.api;
209
+
210
+ for (const modelDef of providerConfig.models) {
211
+ const hasModelApi = !!modelDef.api;
212
+
213
+ if (!hasProviderApi && !hasModelApi) {
214
+ throw new Error(
215
+ `Provider ${providerName}, model ${modelDef.id}: no "api" specified. Set at provider or model level.`,
216
+ );
217
+ }
218
+
219
+ if (!modelDef.id) throw new Error(`Provider ${providerName}: model missing "id"`);
220
+ if (!modelDef.name) throw new Error(`Provider ${providerName}: model missing "name"`);
221
+ if (modelDef.contextWindow <= 0)
222
+ throw new Error(`Provider ${providerName}, model ${modelDef.id}: invalid contextWindow`);
223
+ if (modelDef.maxTokens <= 0)
224
+ throw new Error(`Provider ${providerName}, model ${modelDef.id}: invalid maxTokens`);
225
+ }
226
+ }
227
+ }
228
+
229
+ private parseModels(config: ModelsConfig): Model<Api>[] {
230
+ const models: Model<Api>[] = [];
231
+
232
+ for (const [providerName, providerConfig] of Object.entries(config.providers)) {
233
+ // Store API key config for fallback resolver
234
+ this.customProviderApiKeys.set(providerName, providerConfig.apiKey);
235
+
236
+ for (const modelDef of providerConfig.models) {
237
+ const api = modelDef.api || providerConfig.api;
238
+ if (!api) continue;
239
+
240
+ // Merge headers: provider headers are base, model headers override
241
+ let headers =
242
+ providerConfig.headers || modelDef.headers
243
+ ? { ...providerConfig.headers, ...modelDef.headers }
244
+ : undefined;
245
+
246
+ // If authHeader is true, add Authorization header with resolved API key
247
+ if (providerConfig.authHeader) {
248
+ const resolvedKey = resolveApiKeyConfig(providerConfig.apiKey);
249
+ if (resolvedKey) {
250
+ headers = { ...headers, Authorization: `Bearer ${resolvedKey}` };
251
+ }
252
+ }
253
+
254
+ models.push({
255
+ id: modelDef.id,
256
+ name: modelDef.name,
257
+ api: api as Api,
258
+ provider: providerName,
259
+ baseUrl: providerConfig.baseUrl,
260
+ reasoning: modelDef.reasoning,
261
+ input: modelDef.input as ("text" | "image")[],
262
+ cost: modelDef.cost,
263
+ contextWindow: modelDef.contextWindow,
264
+ maxTokens: modelDef.maxTokens,
265
+ headers,
266
+ compat: modelDef.compat,
267
+ } as Model<Api>);
268
+ }
269
+ }
270
+
271
+ return models;
272
+ }
273
+
274
+ /**
275
+ * Get all models (built-in + custom).
276
+ * If models.json had errors, returns only built-in models.
277
+ */
278
+ getAll(): Model<Api>[] {
279
+ return this.models;
280
+ }
281
+
282
+ /**
283
+ * Get only models that have valid API keys available.
284
+ */
285
+ async getAvailable(): Promise<Model<Api>[]> {
286
+ const available: Model<Api>[] = [];
287
+ for (const model of this.models) {
288
+ const apiKey = await this.authStorage.getApiKey(model.provider);
289
+ if (apiKey) {
290
+ available.push(model);
291
+ }
292
+ }
293
+ return available;
294
+ }
295
+
296
+ /**
297
+ * Find a model by provider and ID.
298
+ */
299
+ find(provider: string, modelId: string): Model<Api> | undefined {
300
+ return this.models.find((m) => m.provider === provider && m.id === modelId) ?? undefined;
301
+ }
302
+
303
+ /**
304
+ * Get API key for a model.
305
+ */
306
+ async getApiKey(model: Model<Api>): Promise<string | undefined> {
307
+ return this.authStorage.getApiKey(model.provider);
308
+ }
309
+
310
+ /**
311
+ * Check if a model is using OAuth credentials (subscription).
312
+ */
313
+ isUsingOAuth(model: Model<Api>): boolean {
314
+ const cred = this.authStorage.get(model.provider);
315
+ return cred?.type === "oauth";
316
+ }
317
+ }
@@ -0,0 +1,393 @@
1
+ /**
2
+ * Model resolution, scoping, and initial selection
3
+ */
4
+
5
+ import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
6
+ import { type Api, type KnownProvider, type Model, modelsAreEqual } from "@oh-my-pi/pi-ai";
7
+ import chalk from "chalk";
8
+ import { minimatch } from "minimatch";
9
+ import { isValidThinkingLevel } from "../cli/args.js";
10
+ import type { ModelRegistry } from "./model-registry.js";
11
+
12
+ /** Default model IDs for each known provider */
13
+ export const defaultModelPerProvider: Record<KnownProvider, string> = {
14
+ anthropic: "claude-sonnet-4-5",
15
+ openai: "gpt-5.1-codex",
16
+ google: "gemini-2.5-pro",
17
+ "google-gemini-cli": "gemini-2.5-pro",
18
+ "google-antigravity": "gemini-3-pro-high",
19
+ "github-copilot": "gpt-4o",
20
+ openrouter: "openai/gpt-5.1-codex",
21
+ xai: "grok-4-fast-non-reasoning",
22
+ groq: "openai/gpt-oss-120b",
23
+ cerebras: "zai-glm-4.6",
24
+ zai: "glm-4.6",
25
+ mistral: "devstral-medium-latest",
26
+ };
27
+
28
+ export interface ScopedModel {
29
+ model: Model<Api>;
30
+ thinkingLevel: ThinkingLevel;
31
+ }
32
+
33
+ /**
34
+ * Helper to check if a model ID looks like an alias (no date suffix)
35
+ * Dates are typically in format: -20241022 or -20250929
36
+ */
37
+ function isAlias(id: string): boolean {
38
+ // Check if ID ends with -latest
39
+ if (id.endsWith("-latest")) return true;
40
+
41
+ // Check if ID ends with a date pattern (-YYYYMMDD)
42
+ const datePattern = /-\d{8}$/;
43
+ return !datePattern.test(id);
44
+ }
45
+
46
+ /**
47
+ * Try to match a pattern to a model from the available models list.
48
+ * Returns the matched model or undefined if no match found.
49
+ */
50
+ function tryMatchModel(modelPattern: string, availableModels: Model<Api>[]): Model<Api> | undefined {
51
+ // Check for provider/modelId format (provider is everything before the first /)
52
+ const slashIndex = modelPattern.indexOf("/");
53
+ if (slashIndex !== -1) {
54
+ const provider = modelPattern.substring(0, slashIndex);
55
+ const modelId = modelPattern.substring(slashIndex + 1);
56
+ const providerMatch = availableModels.find(
57
+ (m) => m.provider.toLowerCase() === provider.toLowerCase() && m.id.toLowerCase() === modelId.toLowerCase(),
58
+ );
59
+ if (providerMatch) {
60
+ return providerMatch;
61
+ }
62
+ // No exact provider/model match - fall through to other matching
63
+ }
64
+
65
+ // Check for exact ID match (case-insensitive)
66
+ const exactMatch = availableModels.find((m) => m.id.toLowerCase() === modelPattern.toLowerCase());
67
+ if (exactMatch) {
68
+ return exactMatch;
69
+ }
70
+
71
+ // No exact match - fall back to partial matching
72
+ const matches = availableModels.filter(
73
+ (m) =>
74
+ m.id.toLowerCase().includes(modelPattern.toLowerCase()) ||
75
+ m.name?.toLowerCase().includes(modelPattern.toLowerCase()),
76
+ );
77
+
78
+ if (matches.length === 0) {
79
+ return undefined;
80
+ }
81
+
82
+ // Separate into aliases and dated versions
83
+ const aliases = matches.filter((m) => isAlias(m.id));
84
+ const datedVersions = matches.filter((m) => !isAlias(m.id));
85
+
86
+ if (aliases.length > 0) {
87
+ // Prefer alias - if multiple aliases, pick the one that sorts highest
88
+ aliases.sort((a, b) => b.id.localeCompare(a.id));
89
+ return aliases[0];
90
+ } else {
91
+ // No alias found, pick latest dated version
92
+ datedVersions.sort((a, b) => b.id.localeCompare(a.id));
93
+ return datedVersions[0];
94
+ }
95
+ }
96
+
97
+ export interface ParsedModelResult {
98
+ model: Model<Api> | undefined;
99
+ thinkingLevel: ThinkingLevel;
100
+ warning: string | undefined;
101
+ }
102
+
103
+ /**
104
+ * Parse a pattern to extract model and thinking level.
105
+ * Handles models with colons in their IDs (e.g., OpenRouter's :exacto suffix).
106
+ *
107
+ * Algorithm:
108
+ * 1. Try to match full pattern as a model
109
+ * 2. If found, return it with "off" thinking level
110
+ * 3. If not found and has colons, split on last colon:
111
+ * - If suffix is valid thinking level, use it and recurse on prefix
112
+ * - If suffix is invalid, warn and recurse on prefix with "off"
113
+ *
114
+ * @internal Exported for testing
115
+ */
116
+ export function parseModelPattern(pattern: string, availableModels: Model<Api>[]): ParsedModelResult {
117
+ // Try exact match first
118
+ const exactMatch = tryMatchModel(pattern, availableModels);
119
+ if (exactMatch) {
120
+ return { model: exactMatch, thinkingLevel: "off", warning: undefined };
121
+ }
122
+
123
+ // No match - try splitting on last colon if present
124
+ const lastColonIndex = pattern.lastIndexOf(":");
125
+ if (lastColonIndex === -1) {
126
+ // No colons, pattern simply doesn't match any model
127
+ return { model: undefined, thinkingLevel: "off", warning: undefined };
128
+ }
129
+
130
+ const prefix = pattern.substring(0, lastColonIndex);
131
+ const suffix = pattern.substring(lastColonIndex + 1);
132
+
133
+ if (isValidThinkingLevel(suffix)) {
134
+ // Valid thinking level - recurse on prefix and use this level
135
+ const result = parseModelPattern(prefix, availableModels);
136
+ if (result.model) {
137
+ // Only use this thinking level if no warning from inner recursion
138
+ // (if there was an invalid suffix deeper, we already have "off")
139
+ return {
140
+ model: result.model,
141
+ thinkingLevel: result.warning ? "off" : suffix,
142
+ warning: result.warning,
143
+ };
144
+ }
145
+ return result;
146
+ } else {
147
+ // Invalid suffix - recurse on prefix with "off" and warn
148
+ const result = parseModelPattern(prefix, availableModels);
149
+ if (result.model) {
150
+ return {
151
+ model: result.model,
152
+ thinkingLevel: "off",
153
+ warning: `Invalid thinking level "${suffix}" in pattern "${pattern}". Using "off" instead.`,
154
+ };
155
+ }
156
+ return result;
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Resolve model patterns to actual Model objects with optional thinking levels
162
+ * Format: "pattern:level" where :level is optional
163
+ * For each pattern, finds all matching models and picks the best version:
164
+ * 1. Prefer alias (e.g., claude-sonnet-4-5) over dated versions (claude-sonnet-4-5-20250929)
165
+ * 2. If no alias, pick the latest dated version
166
+ *
167
+ * Supports models with colons in their IDs (e.g., OpenRouter's model:exacto).
168
+ * The algorithm tries to match the full pattern first, then progressively
169
+ * strips colon-suffixes to find a match.
170
+ */
171
+ export async function resolveModelScope(patterns: string[], modelRegistry: ModelRegistry): Promise<ScopedModel[]> {
172
+ const availableModels = await modelRegistry.getAvailable();
173
+ const scopedModels: ScopedModel[] = [];
174
+
175
+ for (const pattern of patterns) {
176
+ // Check if pattern contains glob characters
177
+ if (pattern.includes("*") || pattern.includes("?") || pattern.includes("[")) {
178
+ // Extract optional thinking level suffix (e.g., "provider/*:high")
179
+ const colonIdx = pattern.lastIndexOf(":");
180
+ let globPattern = pattern;
181
+ let thinkingLevel: ThinkingLevel = "off";
182
+
183
+ if (colonIdx !== -1) {
184
+ const suffix = pattern.substring(colonIdx + 1);
185
+ if (isValidThinkingLevel(suffix)) {
186
+ thinkingLevel = suffix;
187
+ globPattern = pattern.substring(0, colonIdx);
188
+ }
189
+ }
190
+
191
+ // Match against "provider/modelId" format OR just model ID
192
+ // This allows "*sonnet*" to match without requiring "anthropic/*sonnet*"
193
+ const matchingModels = availableModels.filter((m) => {
194
+ const fullId = `${m.provider}/${m.id}`;
195
+ return minimatch(fullId, globPattern, { nocase: true }) || minimatch(m.id, globPattern, { nocase: true });
196
+ });
197
+
198
+ if (matchingModels.length === 0) {
199
+ console.warn(chalk.yellow(`Warning: No models match pattern "${pattern}"`));
200
+ continue;
201
+ }
202
+
203
+ for (const model of matchingModels) {
204
+ if (!scopedModels.find((sm) => modelsAreEqual(sm.model, model))) {
205
+ scopedModels.push({ model, thinkingLevel });
206
+ }
207
+ }
208
+ continue;
209
+ }
210
+
211
+ const { model, thinkingLevel, warning } = parseModelPattern(pattern, availableModels);
212
+
213
+ if (warning) {
214
+ console.warn(chalk.yellow(`Warning: ${warning}`));
215
+ }
216
+
217
+ if (!model) {
218
+ console.warn(chalk.yellow(`Warning: No models match pattern "${pattern}"`));
219
+ continue;
220
+ }
221
+
222
+ // Avoid duplicates
223
+ if (!scopedModels.find((sm) => modelsAreEqual(sm.model, model))) {
224
+ scopedModels.push({ model, thinkingLevel });
225
+ }
226
+ }
227
+
228
+ return scopedModels;
229
+ }
230
+
231
+ export interface InitialModelResult {
232
+ model: Model<Api> | undefined;
233
+ thinkingLevel: ThinkingLevel;
234
+ fallbackMessage: string | undefined;
235
+ }
236
+
237
+ /**
238
+ * Find the initial model to use based on priority:
239
+ * 1. CLI args (provider + model)
240
+ * 2. First model from scoped models (if not continuing/resuming)
241
+ * 3. Restored from session (if continuing/resuming)
242
+ * 4. Saved default from settings
243
+ * 5. First available model with valid API key
244
+ */
245
+ export async function findInitialModel(options: {
246
+ cliProvider?: string;
247
+ cliModel?: string;
248
+ scopedModels: ScopedModel[];
249
+ isContinuing: boolean;
250
+ defaultProvider?: string;
251
+ defaultModelId?: string;
252
+ defaultThinkingLevel?: ThinkingLevel;
253
+ modelRegistry: ModelRegistry;
254
+ }): Promise<InitialModelResult> {
255
+ const {
256
+ cliProvider,
257
+ cliModel,
258
+ scopedModels,
259
+ isContinuing,
260
+ defaultProvider,
261
+ defaultModelId,
262
+ defaultThinkingLevel,
263
+ modelRegistry,
264
+ } = options;
265
+
266
+ let model: Model<Api> | undefined;
267
+ let thinkingLevel: ThinkingLevel = "off";
268
+
269
+ // 1. CLI args take priority
270
+ if (cliProvider && cliModel) {
271
+ const found = modelRegistry.find(cliProvider, cliModel);
272
+ if (!found) {
273
+ console.error(chalk.red(`Model ${cliProvider}/${cliModel} not found`));
274
+ process.exit(1);
275
+ }
276
+ return { model: found, thinkingLevel: "off", fallbackMessage: undefined };
277
+ }
278
+
279
+ // 2. Use first model from scoped models (skip if continuing/resuming)
280
+ if (scopedModels.length > 0 && !isContinuing) {
281
+ return {
282
+ model: scopedModels[0].model,
283
+ thinkingLevel: scopedModels[0].thinkingLevel,
284
+ fallbackMessage: undefined,
285
+ };
286
+ }
287
+
288
+ // 3. Try saved default from settings
289
+ if (defaultProvider && defaultModelId) {
290
+ const found = modelRegistry.find(defaultProvider, defaultModelId);
291
+ if (found) {
292
+ model = found;
293
+ if (defaultThinkingLevel) {
294
+ thinkingLevel = defaultThinkingLevel;
295
+ }
296
+ return { model, thinkingLevel, fallbackMessage: undefined };
297
+ }
298
+ }
299
+
300
+ // 4. Try first available model with valid API key
301
+ const availableModels = await modelRegistry.getAvailable();
302
+
303
+ if (availableModels.length > 0) {
304
+ // Try to find a default model from known providers
305
+ for (const provider of Object.keys(defaultModelPerProvider) as KnownProvider[]) {
306
+ const defaultId = defaultModelPerProvider[provider];
307
+ const match = availableModels.find((m) => m.provider === provider && m.id === defaultId);
308
+ if (match) {
309
+ return { model: match, thinkingLevel: "off", fallbackMessage: undefined };
310
+ }
311
+ }
312
+
313
+ // If no default found, use first available
314
+ return { model: availableModels[0], thinkingLevel: "off", fallbackMessage: undefined };
315
+ }
316
+
317
+ // 5. No model found
318
+ return { model: undefined, thinkingLevel: "off", fallbackMessage: undefined };
319
+ }
320
+
321
+ /**
322
+ * Restore model from session, with fallback to available models
323
+ */
324
+ export async function restoreModelFromSession(
325
+ savedProvider: string,
326
+ savedModelId: string,
327
+ currentModel: Model<Api> | undefined,
328
+ shouldPrintMessages: boolean,
329
+ modelRegistry: ModelRegistry,
330
+ ): Promise<{ model: Model<Api> | undefined; fallbackMessage: string | undefined }> {
331
+ const restoredModel = modelRegistry.find(savedProvider, savedModelId);
332
+
333
+ // Check if restored model exists and has a valid API key
334
+ const hasApiKey = restoredModel ? !!(await modelRegistry.getApiKey(restoredModel)) : false;
335
+
336
+ if (restoredModel && hasApiKey) {
337
+ if (shouldPrintMessages) {
338
+ console.log(chalk.dim(`Restored model: ${savedProvider}/${savedModelId}`));
339
+ }
340
+ return { model: restoredModel, fallbackMessage: undefined };
341
+ }
342
+
343
+ // Model not found or no API key - fall back
344
+ const reason = !restoredModel ? "model no longer exists" : "no API key available";
345
+
346
+ if (shouldPrintMessages) {
347
+ console.error(chalk.yellow(`Warning: Could not restore model ${savedProvider}/${savedModelId} (${reason}).`));
348
+ }
349
+
350
+ // If we already have a model, use it as fallback
351
+ if (currentModel) {
352
+ if (shouldPrintMessages) {
353
+ console.log(chalk.dim(`Falling back to: ${currentModel.provider}/${currentModel.id}`));
354
+ }
355
+ return {
356
+ model: currentModel,
357
+ fallbackMessage: `Could not restore model ${savedProvider}/${savedModelId} (${reason}). Using ${currentModel.provider}/${currentModel.id}.`,
358
+ };
359
+ }
360
+
361
+ // Try to find any available model
362
+ const availableModels = await modelRegistry.getAvailable();
363
+
364
+ if (availableModels.length > 0) {
365
+ // Try to find a default model from known providers
366
+ let fallbackModel: Model<Api> | undefined;
367
+ for (const provider of Object.keys(defaultModelPerProvider) as KnownProvider[]) {
368
+ const defaultId = defaultModelPerProvider[provider];
369
+ const match = availableModels.find((m) => m.provider === provider && m.id === defaultId);
370
+ if (match) {
371
+ fallbackModel = match;
372
+ break;
373
+ }
374
+ }
375
+
376
+ // If no default found, use first available
377
+ if (!fallbackModel) {
378
+ fallbackModel = availableModels[0];
379
+ }
380
+
381
+ if (shouldPrintMessages) {
382
+ console.log(chalk.dim(`Falling back to: ${fallbackModel.provider}/${fallbackModel.id}`));
383
+ }
384
+
385
+ return {
386
+ model: fallbackModel,
387
+ fallbackMessage: `Could not restore model ${savedProvider}/${savedModelId} (${reason}). Using ${fallbackModel.provider}/${fallbackModel.id}.`,
388
+ };
389
+ }
390
+
391
+ // No models available
392
+ return { model: undefined, fallbackMessage: undefined };
393
+ }