@oh-my-pi/pi-coding-agent 3.15.1 → 3.20.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.
- package/CHANGELOG.md +51 -1
- package/docs/extensions.md +1055 -0
- package/docs/rpc.md +69 -13
- package/docs/session-tree-plan.md +1 -1
- package/examples/extensions/README.md +141 -0
- package/examples/extensions/api-demo.ts +87 -0
- package/examples/extensions/chalk-logger.ts +26 -0
- package/examples/extensions/hello.ts +33 -0
- package/examples/extensions/pirate.ts +44 -0
- package/examples/extensions/plan-mode.ts +551 -0
- package/examples/extensions/subagent/agents/reviewer.md +35 -0
- package/examples/extensions/todo.ts +299 -0
- package/examples/extensions/tools.ts +145 -0
- package/examples/extensions/with-deps/index.ts +36 -0
- package/examples/extensions/with-deps/package-lock.json +31 -0
- package/examples/extensions/with-deps/package.json +16 -0
- package/examples/sdk/02-custom-model.ts +3 -3
- package/examples/sdk/05-tools.ts +7 -3
- package/examples/sdk/06-extensions.ts +81 -0
- package/examples/sdk/06-hooks.ts +14 -13
- package/examples/sdk/08-prompt-templates.ts +42 -0
- package/examples/sdk/08-slash-commands.ts +17 -12
- package/examples/sdk/09-api-keys-and-oauth.ts +2 -2
- package/examples/sdk/12-full-control.ts +6 -6
- package/package.json +11 -7
- package/src/capability/extension-module.ts +34 -0
- package/src/cli/args.ts +22 -7
- package/src/cli/file-processor.ts +38 -67
- package/src/cli/list-models.ts +1 -1
- package/src/config.ts +25 -14
- package/src/core/agent-session.ts +505 -242
- package/src/core/auth-storage.ts +33 -21
- package/src/core/compaction/branch-summarization.ts +4 -4
- package/src/core/compaction/compaction.ts +3 -3
- package/src/core/custom-commands/bundled/wt/index.ts +430 -0
- package/src/core/custom-commands/loader.ts +9 -0
- package/src/core/custom-tools/wrapper.ts +5 -0
- package/src/core/event-bus.ts +59 -0
- package/src/core/export-html/vendor/highlight.min.js +1213 -0
- package/src/core/export-html/vendor/marked.min.js +6 -0
- package/src/core/extensions/index.ts +100 -0
- package/src/core/extensions/loader.ts +501 -0
- package/src/core/extensions/runner.ts +477 -0
- package/src/core/extensions/types.ts +712 -0
- package/src/core/extensions/wrapper.ts +147 -0
- package/src/core/hooks/types.ts +2 -2
- package/src/core/index.ts +10 -21
- package/src/core/keybindings.ts +199 -0
- package/src/core/messages.ts +26 -7
- package/src/core/model-registry.ts +123 -46
- package/src/core/model-resolver.ts +7 -5
- package/src/core/prompt-templates.ts +242 -0
- package/src/core/sdk.ts +378 -295
- package/src/core/session-manager.ts +72 -58
- package/src/core/settings-manager.ts +118 -22
- package/src/core/system-prompt.ts +24 -1
- package/src/core/terminal-notify.ts +37 -0
- package/src/core/tools/context.ts +4 -4
- package/src/core/tools/exa/mcp-client.ts +5 -4
- package/src/core/tools/exa/render.ts +176 -131
- package/src/core/tools/gemini-image.ts +361 -0
- package/src/core/tools/git.ts +216 -0
- package/src/core/tools/index.ts +28 -15
- package/src/core/tools/lsp/config.ts +5 -4
- package/src/core/tools/lsp/index.ts +17 -12
- package/src/core/tools/lsp/render.ts +39 -47
- package/src/core/tools/read.ts +66 -29
- package/src/core/tools/render-utils.ts +268 -0
- package/src/core/tools/renderers.ts +243 -225
- package/src/core/tools/task/discovery.ts +2 -2
- package/src/core/tools/task/executor.ts +66 -58
- package/src/core/tools/task/index.ts +29 -10
- package/src/core/tools/task/model-resolver.ts +8 -13
- package/src/core/tools/task/omp-command.ts +24 -0
- package/src/core/tools/task/render.ts +35 -60
- package/src/core/tools/task/types.ts +3 -0
- package/src/core/tools/web-fetch.ts +29 -28
- package/src/core/tools/web-search/index.ts +6 -5
- package/src/core/tools/web-search/providers/exa.ts +6 -5
- package/src/core/tools/web-search/render.ts +66 -111
- package/src/core/voice-controller.ts +135 -0
- package/src/core/voice-supervisor.ts +1003 -0
- package/src/core/voice.ts +308 -0
- package/src/discovery/builtin.ts +75 -1
- package/src/discovery/claude.ts +47 -1
- package/src/discovery/codex.ts +54 -2
- package/src/discovery/gemini.ts +55 -2
- package/src/discovery/helpers.ts +100 -1
- package/src/discovery/index.ts +2 -0
- package/src/index.ts +14 -9
- package/src/lib/worktree/collapse.ts +179 -0
- package/src/lib/worktree/constants.ts +14 -0
- package/src/lib/worktree/errors.ts +23 -0
- package/src/lib/worktree/git.ts +110 -0
- package/src/lib/worktree/index.ts +23 -0
- package/src/lib/worktree/operations.ts +216 -0
- package/src/lib/worktree/session.ts +114 -0
- package/src/lib/worktree/stats.ts +67 -0
- package/src/main.ts +61 -37
- package/src/migrations.ts +37 -7
- package/src/modes/interactive/components/bash-execution.ts +6 -4
- package/src/modes/interactive/components/custom-editor.ts +55 -0
- package/src/modes/interactive/components/custom-message.ts +95 -0
- package/src/modes/interactive/components/extensions/extension-list.ts +5 -0
- package/src/modes/interactive/components/extensions/inspector-panel.ts +18 -12
- package/src/modes/interactive/components/extensions/state-manager.ts +12 -0
- package/src/modes/interactive/components/extensions/types.ts +1 -0
- package/src/modes/interactive/components/footer.ts +324 -0
- package/src/modes/interactive/components/hook-selector.ts +3 -3
- package/src/modes/interactive/components/model-selector.ts +7 -6
- package/src/modes/interactive/components/oauth-selector.ts +3 -3
- package/src/modes/interactive/components/settings-defs.ts +55 -6
- package/src/modes/interactive/components/status-line.ts +45 -37
- package/src/modes/interactive/components/tool-execution.ts +95 -23
- package/src/modes/interactive/interactive-mode.ts +643 -113
- package/src/modes/interactive/theme/defaults/index.ts +16 -16
- package/src/modes/print-mode.ts +14 -72
- package/src/modes/rpc/rpc-client.ts +23 -9
- package/src/modes/rpc/rpc-mode.ts +137 -125
- package/src/modes/rpc/rpc-types.ts +46 -24
- package/src/prompts/task.md +1 -0
- package/src/prompts/tools/gemini-image.md +4 -0
- package/src/prompts/tools/git.md +9 -0
- package/src/prompts/voice-summary.md +12 -0
- package/src/utils/image-convert.ts +26 -0
- package/src/utils/image-resize.ts +215 -0
- package/src/utils/shell-snapshot.ts +22 -20
|
@@ -35,8 +35,10 @@ const ModelDefinitionSchema = Type.Object({
|
|
|
35
35
|
Type.Union([
|
|
36
36
|
Type.Literal("openai-completions"),
|
|
37
37
|
Type.Literal("openai-responses"),
|
|
38
|
+
Type.Literal("openai-codex-responses"),
|
|
38
39
|
Type.Literal("anthropic-messages"),
|
|
39
40
|
Type.Literal("google-generative-ai"),
|
|
41
|
+
Type.Literal("google-vertex"),
|
|
40
42
|
]),
|
|
41
43
|
),
|
|
42
44
|
reasoning: Type.Boolean(),
|
|
@@ -54,19 +56,21 @@ const ModelDefinitionSchema = Type.Object({
|
|
|
54
56
|
});
|
|
55
57
|
|
|
56
58
|
const ProviderConfigSchema = Type.Object({
|
|
57
|
-
baseUrl: Type.String({ minLength: 1 }),
|
|
58
|
-
apiKey: Type.String({ minLength: 1 }),
|
|
59
|
+
baseUrl: Type.Optional(Type.String({ minLength: 1 })),
|
|
60
|
+
apiKey: Type.Optional(Type.String({ minLength: 1 })),
|
|
59
61
|
api: Type.Optional(
|
|
60
62
|
Type.Union([
|
|
61
63
|
Type.Literal("openai-completions"),
|
|
62
64
|
Type.Literal("openai-responses"),
|
|
65
|
+
Type.Literal("openai-codex-responses"),
|
|
63
66
|
Type.Literal("anthropic-messages"),
|
|
64
67
|
Type.Literal("google-generative-ai"),
|
|
68
|
+
Type.Literal("google-vertex"),
|
|
65
69
|
]),
|
|
66
70
|
),
|
|
67
71
|
headers: Type.Optional(Type.Record(Type.String(), Type.String())),
|
|
68
72
|
authHeader: Type.Optional(Type.Boolean()),
|
|
69
|
-
models: Type.Array(ModelDefinitionSchema),
|
|
73
|
+
models: Type.Optional(Type.Array(ModelDefinitionSchema)),
|
|
70
74
|
});
|
|
71
75
|
|
|
72
76
|
const ModelsConfigSchema = Type.Object({
|
|
@@ -75,6 +79,27 @@ const ModelsConfigSchema = Type.Object({
|
|
|
75
79
|
|
|
76
80
|
type ModelsConfig = Static<typeof ModelsConfigSchema>;
|
|
77
81
|
|
|
82
|
+
/** Provider override config (baseUrl, headers, apiKey) without custom models */
|
|
83
|
+
interface ProviderOverride {
|
|
84
|
+
baseUrl?: string;
|
|
85
|
+
headers?: Record<string, string>;
|
|
86
|
+
apiKey?: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Result of loading custom models from models.json */
|
|
90
|
+
interface CustomModelsResult {
|
|
91
|
+
models: Model<Api>[];
|
|
92
|
+
/** Providers with custom models (full replacement) */
|
|
93
|
+
replacedProviders: Set<string>;
|
|
94
|
+
/** Providers with only baseUrl/headers override (no custom models) */
|
|
95
|
+
overrides: Map<string, ProviderOverride>;
|
|
96
|
+
error: string | undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function emptyCustomModelsResult(error?: string): CustomModelsResult {
|
|
100
|
+
return { models: [], replacedProviders: new Set(), overrides: new Map(), error };
|
|
101
|
+
}
|
|
102
|
+
|
|
78
103
|
/**
|
|
79
104
|
* Resolve an API key config value to an actual key.
|
|
80
105
|
* Checks environment variable first, then treats as literal.
|
|
@@ -111,8 +136,7 @@ export class ModelRegistry {
|
|
|
111
136
|
}
|
|
112
137
|
return undefined;
|
|
113
138
|
});
|
|
114
|
-
|
|
115
|
-
// Load models
|
|
139
|
+
// Load models synchronously in constructor
|
|
116
140
|
this.loadModels();
|
|
117
141
|
}
|
|
118
142
|
|
|
@@ -133,15 +157,10 @@ export class ModelRegistry {
|
|
|
133
157
|
}
|
|
134
158
|
|
|
135
159
|
private loadModels(): void {
|
|
136
|
-
// Load
|
|
137
|
-
const builtInModels: Model<Api>[] = [];
|
|
138
|
-
for (const provider of getProviders()) {
|
|
139
|
-
const providerModels = getModels(provider as KnownProvider);
|
|
140
|
-
builtInModels.push(...(providerModels as Model<Api>[]));
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// Load custom models from models.json (check primary path, then fallbacks)
|
|
160
|
+
// Load custom models from models.json first (to know which providers to skip/override)
|
|
144
161
|
let customModels: Model<Api>[] = [];
|
|
162
|
+
let replacedProviders: Set<string> = new Set();
|
|
163
|
+
let overrides: Map<string, ProviderOverride> = new Map();
|
|
145
164
|
const pathsToCheck = this.modelsJsonPath ? [this.modelsJsonPath, ...this.fallbackPaths] : this.fallbackPaths;
|
|
146
165
|
|
|
147
166
|
if (pathsToCheck.length > 0) {
|
|
@@ -157,11 +176,14 @@ export class ModelRegistry {
|
|
|
157
176
|
// Keep built-in models even if custom models failed to load
|
|
158
177
|
} else {
|
|
159
178
|
customModels = result.models;
|
|
179
|
+
replacedProviders = result.replacedProviders;
|
|
180
|
+
overrides = result.overrides;
|
|
160
181
|
}
|
|
161
182
|
break; // Use first existing file
|
|
162
183
|
}
|
|
163
184
|
}
|
|
164
185
|
|
|
186
|
+
const builtInModels = this.loadBuiltInModels(replacedProviders, overrides);
|
|
165
187
|
const combined = [...builtInModels, ...customModels];
|
|
166
188
|
|
|
167
189
|
// Update github-copilot base URL based on OAuth credentials
|
|
@@ -177,9 +199,27 @@ export class ModelRegistry {
|
|
|
177
199
|
}
|
|
178
200
|
}
|
|
179
201
|
|
|
180
|
-
|
|
202
|
+
/** Load built-in models, skipping replaced providers and applying overrides */
|
|
203
|
+
private loadBuiltInModels(replacedProviders: Set<string>, overrides: Map<string, ProviderOverride>): Model<Api>[] {
|
|
204
|
+
return getProviders()
|
|
205
|
+
.filter((provider) => !replacedProviders.has(provider))
|
|
206
|
+
.flatMap((provider) => {
|
|
207
|
+
const models = getModels(provider as KnownProvider) as Model<Api>[];
|
|
208
|
+
const override = overrides.get(provider);
|
|
209
|
+
if (!override) return models;
|
|
210
|
+
|
|
211
|
+
// Apply baseUrl/headers override to all models of this provider
|
|
212
|
+
return models.map((m) => ({
|
|
213
|
+
...m,
|
|
214
|
+
baseUrl: override.baseUrl ?? m.baseUrl,
|
|
215
|
+
headers: override.headers ? { ...m.headers, ...override.headers } : m.headers,
|
|
216
|
+
}));
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private loadCustomModels(modelsJsonPath: string): CustomModelsResult {
|
|
181
221
|
if (!existsSync(modelsJsonPath)) {
|
|
182
|
-
return
|
|
222
|
+
return emptyCustomModelsResult();
|
|
183
223
|
}
|
|
184
224
|
|
|
185
225
|
try {
|
|
@@ -193,38 +233,68 @@ export class ModelRegistry {
|
|
|
193
233
|
const errors =
|
|
194
234
|
validate.errors?.map((e: any) => ` - ${e.instancePath || "root"}: ${e.message}`).join("\n") ||
|
|
195
235
|
"Unknown schema error";
|
|
196
|
-
return {
|
|
197
|
-
models: [],
|
|
198
|
-
error: `Invalid models.json schema:\n${errors}\n\nFile: ${modelsJsonPath}`,
|
|
199
|
-
};
|
|
236
|
+
return emptyCustomModelsResult(`Invalid models.json schema:\n${errors}\n\nFile: ${modelsJsonPath}`);
|
|
200
237
|
}
|
|
201
238
|
|
|
202
239
|
// Additional validation
|
|
203
240
|
this.validateConfig(config);
|
|
204
241
|
|
|
205
|
-
//
|
|
206
|
-
|
|
242
|
+
// Separate providers into "full replacement" (has models) vs "override-only" (no models)
|
|
243
|
+
const replacedProviders = new Set<string>();
|
|
244
|
+
const overrides = new Map<string, ProviderOverride>();
|
|
245
|
+
|
|
246
|
+
for (const [providerName, providerConfig] of Object.entries(config.providers)) {
|
|
247
|
+
if (providerConfig.models && providerConfig.models.length > 0) {
|
|
248
|
+
// Has custom models -> full replacement
|
|
249
|
+
replacedProviders.add(providerName);
|
|
250
|
+
} else {
|
|
251
|
+
// No models -> just override baseUrl/headers on built-in
|
|
252
|
+
overrides.set(providerName, {
|
|
253
|
+
baseUrl: providerConfig.baseUrl,
|
|
254
|
+
headers: providerConfig.headers,
|
|
255
|
+
apiKey: providerConfig.apiKey,
|
|
256
|
+
});
|
|
257
|
+
// Store API key for fallback resolver
|
|
258
|
+
if (providerConfig.apiKey) {
|
|
259
|
+
this.customProviderApiKeys.set(providerName, providerConfig.apiKey);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return { models: this.parseModels(config), replacedProviders, overrides, error: undefined };
|
|
207
265
|
} catch (error) {
|
|
208
266
|
if (error instanceof SyntaxError) {
|
|
209
|
-
return {
|
|
210
|
-
models: [],
|
|
211
|
-
error: `Failed to parse models.json: ${error.message}\n\nFile: ${modelsJsonPath}`,
|
|
212
|
-
};
|
|
267
|
+
return emptyCustomModelsResult(`Failed to parse models.json: ${error.message}\n\nFile: ${modelsJsonPath}`);
|
|
213
268
|
}
|
|
214
|
-
return
|
|
215
|
-
models:
|
|
216
|
-
|
|
217
|
-
error instanceof Error ? error.message : error
|
|
218
|
-
}\n\nFile: ${modelsJsonPath}`,
|
|
219
|
-
};
|
|
269
|
+
return emptyCustomModelsResult(
|
|
270
|
+
`Failed to load models.json: ${error instanceof Error ? error.message : error}\n\nFile: ${modelsJsonPath}`,
|
|
271
|
+
);
|
|
220
272
|
}
|
|
221
273
|
}
|
|
222
274
|
|
|
223
275
|
private validateConfig(config: ModelsConfig): void {
|
|
224
276
|
for (const [providerName, providerConfig] of Object.entries(config.providers)) {
|
|
225
277
|
const hasProviderApi = !!providerConfig.api;
|
|
278
|
+
const models = providerConfig.models ?? [];
|
|
279
|
+
|
|
280
|
+
if (models.length === 0) {
|
|
281
|
+
// Override-only config: just needs baseUrl (to override built-in)
|
|
282
|
+
if (!providerConfig.baseUrl) {
|
|
283
|
+
throw new Error(
|
|
284
|
+
`Provider ${providerName}: must specify either "baseUrl" (for override) or "models" (for replacement).`,
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
} else {
|
|
288
|
+
// Full replacement: needs baseUrl and apiKey
|
|
289
|
+
if (!providerConfig.baseUrl) {
|
|
290
|
+
throw new Error(`Provider ${providerName}: "baseUrl" is required when defining custom models.`);
|
|
291
|
+
}
|
|
292
|
+
if (!providerConfig.apiKey) {
|
|
293
|
+
throw new Error(`Provider ${providerName}: "apiKey" is required when defining custom models.`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
226
296
|
|
|
227
|
-
for (const modelDef of
|
|
297
|
+
for (const modelDef of models) {
|
|
228
298
|
const hasModelApi = !!modelDef.api;
|
|
229
299
|
|
|
230
300
|
if (!hasProviderApi && !hasModelApi) {
|
|
@@ -247,10 +317,15 @@ export class ModelRegistry {
|
|
|
247
317
|
const models: Model<Api>[] = [];
|
|
248
318
|
|
|
249
319
|
for (const [providerName, providerConfig] of Object.entries(config.providers)) {
|
|
320
|
+
const modelDefs = providerConfig.models ?? [];
|
|
321
|
+
if (modelDefs.length === 0) continue; // Override-only, no custom models
|
|
322
|
+
|
|
250
323
|
// Store API key config for fallback resolver
|
|
251
|
-
|
|
324
|
+
if (providerConfig.apiKey) {
|
|
325
|
+
this.customProviderApiKeys.set(providerName, providerConfig.apiKey);
|
|
326
|
+
}
|
|
252
327
|
|
|
253
|
-
for (const modelDef of
|
|
328
|
+
for (const modelDef of modelDefs) {
|
|
254
329
|
const api = modelDef.api || providerConfig.api;
|
|
255
330
|
if (!api) continue;
|
|
256
331
|
|
|
@@ -261,19 +336,20 @@ export class ModelRegistry {
|
|
|
261
336
|
: undefined;
|
|
262
337
|
|
|
263
338
|
// If authHeader is true, add Authorization header with resolved API key
|
|
264
|
-
if (providerConfig.authHeader) {
|
|
339
|
+
if (providerConfig.authHeader && providerConfig.apiKey) {
|
|
265
340
|
const resolvedKey = resolveApiKeyConfig(providerConfig.apiKey);
|
|
266
341
|
if (resolvedKey) {
|
|
267
342
|
headers = { ...headers, Authorization: `Bearer ${resolvedKey}` };
|
|
268
343
|
}
|
|
269
344
|
}
|
|
270
345
|
|
|
346
|
+
// baseUrl is validated to exist for providers with models
|
|
271
347
|
models.push({
|
|
272
348
|
id: modelDef.id,
|
|
273
349
|
name: modelDef.name,
|
|
274
350
|
api: api as Api,
|
|
275
351
|
provider: providerName,
|
|
276
|
-
baseUrl: providerConfig.baseUrl
|
|
352
|
+
baseUrl: providerConfig.baseUrl!,
|
|
277
353
|
reasoning: modelDef.reasoning,
|
|
278
354
|
input: modelDef.input as ("text" | "image")[],
|
|
279
355
|
cost: modelDef.cost,
|
|
@@ -297,17 +373,11 @@ export class ModelRegistry {
|
|
|
297
373
|
}
|
|
298
374
|
|
|
299
375
|
/**
|
|
300
|
-
* Get only models that have
|
|
376
|
+
* Get only models that have auth configured.
|
|
377
|
+
* This is a fast check that doesn't refresh OAuth tokens.
|
|
301
378
|
*/
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
for (const model of this.models) {
|
|
305
|
-
const apiKey = await this.authStorage.getApiKey(model.provider);
|
|
306
|
-
if (apiKey) {
|
|
307
|
-
available.push(model);
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
return available;
|
|
379
|
+
getAvailable(): Model<Api>[] {
|
|
380
|
+
return this.models.filter((m) => this.authStorage.hasAuth(m.provider));
|
|
311
381
|
}
|
|
312
382
|
|
|
313
383
|
/**
|
|
@@ -324,6 +394,13 @@ export class ModelRegistry {
|
|
|
324
394
|
return this.authStorage.getApiKey(model.provider);
|
|
325
395
|
}
|
|
326
396
|
|
|
397
|
+
/**
|
|
398
|
+
* Get API key for a provider (e.g., "openai").
|
|
399
|
+
*/
|
|
400
|
+
async getApiKeyForProvider(provider: string): Promise<string | undefined> {
|
|
401
|
+
return this.authStorage.getApiKey(provider);
|
|
402
|
+
}
|
|
403
|
+
|
|
327
404
|
/**
|
|
328
405
|
* Check if a model is using OAuth credentials (subscription).
|
|
329
406
|
*/
|
|
@@ -13,9 +13,11 @@ import type { ModelRegistry } from "./model-registry";
|
|
|
13
13
|
export const defaultModelPerProvider: Record<KnownProvider, string> = {
|
|
14
14
|
anthropic: "claude-sonnet-4-5",
|
|
15
15
|
openai: "gpt-5.1-codex",
|
|
16
|
+
"openai-codex": "codex-max",
|
|
16
17
|
google: "gemini-2.5-pro",
|
|
17
18
|
"google-gemini-cli": "gemini-2.5-pro",
|
|
18
19
|
"google-antigravity": "gemini-3-pro-high",
|
|
20
|
+
"google-vertex": "gemini-2.5-pro",
|
|
19
21
|
"github-copilot": "gpt-4o",
|
|
20
22
|
openrouter: "openai/gpt-5.1-codex",
|
|
21
23
|
xai: "grok-4-fast-non-reasoning",
|
|
@@ -192,7 +194,7 @@ export function parseModelPattern(pattern: string, availableModels: Model<Api>[]
|
|
|
192
194
|
* strips colon-suffixes to find a match.
|
|
193
195
|
*/
|
|
194
196
|
export async function resolveModelScope(patterns: string[], modelRegistry: ModelRegistry): Promise<ScopedModel[]> {
|
|
195
|
-
const availableModels =
|
|
197
|
+
const availableModels = modelRegistry.getAvailable();
|
|
196
198
|
const scopedModels: ScopedModel[] = [];
|
|
197
199
|
|
|
198
200
|
for (const pattern of patterns) {
|
|
@@ -321,7 +323,7 @@ export async function findInitialModel(options: {
|
|
|
321
323
|
}
|
|
322
324
|
|
|
323
325
|
// 4. Try first available model with valid API key
|
|
324
|
-
const availableModels =
|
|
326
|
+
const availableModels = modelRegistry.getAvailable();
|
|
325
327
|
|
|
326
328
|
if (availableModels.length > 0) {
|
|
327
329
|
// Try to find a default model from known providers
|
|
@@ -382,7 +384,7 @@ export async function restoreModelFromSession(
|
|
|
382
384
|
}
|
|
383
385
|
|
|
384
386
|
// Try to find any available model
|
|
385
|
-
const availableModels =
|
|
387
|
+
const availableModels = modelRegistry.getAvailable();
|
|
386
388
|
|
|
387
389
|
if (availableModels.length > 0) {
|
|
388
390
|
// Try to find a default model from known providers
|
|
@@ -427,7 +429,7 @@ export async function findSmolModel(
|
|
|
427
429
|
modelRegistry: ModelRegistry,
|
|
428
430
|
savedModel?: string,
|
|
429
431
|
): Promise<Model<Api> | undefined> {
|
|
430
|
-
const availableModels =
|
|
432
|
+
const availableModels = modelRegistry.getAvailable();
|
|
431
433
|
if (availableModels.length === 0) return undefined;
|
|
432
434
|
|
|
433
435
|
// 1. Try saved model from settings
|
|
@@ -466,7 +468,7 @@ export async function findSlowModel(
|
|
|
466
468
|
modelRegistry: ModelRegistry,
|
|
467
469
|
savedModel?: string,
|
|
468
470
|
): Promise<Model<Api> | undefined> {
|
|
469
|
-
const availableModels =
|
|
471
|
+
const availableModels = modelRegistry.getAvailable();
|
|
470
472
|
if (availableModels.length === 0) return undefined;
|
|
471
473
|
|
|
472
474
|
// 1. Try saved model from settings
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { join, resolve } from "node:path";
|
|
2
|
+
import { CONFIG_DIR_NAME, getPromptsDir } from "../config";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Represents a prompt template loaded from a markdown file
|
|
6
|
+
*/
|
|
7
|
+
export interface PromptTemplate {
|
|
8
|
+
name: string;
|
|
9
|
+
description: string;
|
|
10
|
+
content: string;
|
|
11
|
+
source: string; // e.g., "(user)", "(project)", "(project:frontend)"
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Parse YAML frontmatter from markdown content
|
|
16
|
+
* Returns { frontmatter, content } where content has frontmatter stripped
|
|
17
|
+
*/
|
|
18
|
+
function parseFrontmatter(content: string): { frontmatter: Record<string, string>; content: string } {
|
|
19
|
+
const frontmatter: Record<string, string> = {};
|
|
20
|
+
|
|
21
|
+
if (!content.startsWith("---")) {
|
|
22
|
+
return { frontmatter, content };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const endIndex = content.indexOf("\n---", 3);
|
|
26
|
+
if (endIndex === -1) {
|
|
27
|
+
return { frontmatter, content };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const frontmatterBlock = content.slice(4, endIndex);
|
|
31
|
+
const remainingContent = content.slice(endIndex + 4).trim();
|
|
32
|
+
|
|
33
|
+
// Simple YAML parsing - just key: value pairs
|
|
34
|
+
for (const line of frontmatterBlock.split("\n")) {
|
|
35
|
+
const match = line.match(/^(\w+):\s*(.*)$/);
|
|
36
|
+
if (match) {
|
|
37
|
+
frontmatter[match[1]] = match[2].trim();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return { frontmatter, content: remainingContent };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Parse command arguments respecting quoted strings (bash-style)
|
|
46
|
+
* Returns array of arguments
|
|
47
|
+
*/
|
|
48
|
+
export function parseCommandArgs(argsString: string): string[] {
|
|
49
|
+
const args: string[] = [];
|
|
50
|
+
let current = "";
|
|
51
|
+
let inQuote: string | null = null;
|
|
52
|
+
|
|
53
|
+
for (let i = 0; i < argsString.length; i++) {
|
|
54
|
+
const char = argsString[i];
|
|
55
|
+
|
|
56
|
+
if (inQuote) {
|
|
57
|
+
if (char === inQuote) {
|
|
58
|
+
inQuote = null;
|
|
59
|
+
} else {
|
|
60
|
+
current += char;
|
|
61
|
+
}
|
|
62
|
+
} else if (char === '"' || char === "'") {
|
|
63
|
+
inQuote = char;
|
|
64
|
+
} else if (char === " " || char === "\t") {
|
|
65
|
+
if (current) {
|
|
66
|
+
args.push(current);
|
|
67
|
+
current = "";
|
|
68
|
+
}
|
|
69
|
+
} else {
|
|
70
|
+
current += char;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (current) {
|
|
75
|
+
args.push(current);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return args;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Substitute argument placeholders in template content
|
|
83
|
+
* Supports $1, $2, ... for positional args, $@ and $ARGUMENTS for all args
|
|
84
|
+
*
|
|
85
|
+
* Note: Replacement happens on the template string only. Argument values
|
|
86
|
+
* containing patterns like $1, $@, or $ARGUMENTS are NOT recursively substituted.
|
|
87
|
+
*/
|
|
88
|
+
export function substituteArgs(content: string, args: string[]): string {
|
|
89
|
+
let result = content;
|
|
90
|
+
|
|
91
|
+
// Replace $1, $2, etc. with positional args FIRST (before wildcards)
|
|
92
|
+
// This prevents wildcard replacement values containing $<digit> patterns from being re-substituted
|
|
93
|
+
result = result.replace(/\$(\d+)/g, (_, num) => {
|
|
94
|
+
const index = parseInt(num, 10) - 1;
|
|
95
|
+
return args[index] ?? "";
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Pre-compute all args joined (optimization)
|
|
99
|
+
const allArgs = args.join(" ");
|
|
100
|
+
|
|
101
|
+
// Replace $ARGUMENTS with all args joined (new syntax, aligns with Claude, Codex, OpenCode)
|
|
102
|
+
result = result.replace(/\$ARGUMENTS/g, allArgs);
|
|
103
|
+
|
|
104
|
+
// Replace $@ with all args joined (existing syntax)
|
|
105
|
+
result = result.replace(/\$@/g, allArgs);
|
|
106
|
+
|
|
107
|
+
return result;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Recursively scan a directory for .md files (and symlinks to .md files) and load them as prompt templates
|
|
112
|
+
*/
|
|
113
|
+
async function loadTemplatesFromDir(
|
|
114
|
+
dir: string,
|
|
115
|
+
source: "user" | "project",
|
|
116
|
+
subdir: string = "",
|
|
117
|
+
): Promise<PromptTemplate[]> {
|
|
118
|
+
const templates: PromptTemplate[] = [];
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
const stat = await Bun.file(`${dir}/.`).exists();
|
|
122
|
+
if (!stat) return templates;
|
|
123
|
+
} catch {
|
|
124
|
+
return templates;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
const glob = new Bun.Glob("**/*");
|
|
129
|
+
const entries = [];
|
|
130
|
+
for await (const entry of glob.scan({ cwd: dir, absolute: false, onlyFiles: false })) {
|
|
131
|
+
entries.push(entry);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Group by path depth to process directories before deeply nested files
|
|
135
|
+
entries.sort((a, b) => a.split("/").length - b.split("/").length);
|
|
136
|
+
|
|
137
|
+
for (const entry of entries) {
|
|
138
|
+
const fullPath = join(dir, entry);
|
|
139
|
+
const file = Bun.file(fullPath);
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
const stat = await file.exists();
|
|
143
|
+
if (!stat) continue;
|
|
144
|
+
|
|
145
|
+
if (entry.endsWith(".md")) {
|
|
146
|
+
const rawContent = await file.text();
|
|
147
|
+
const { frontmatter, content } = parseFrontmatter(rawContent);
|
|
148
|
+
|
|
149
|
+
const name = entry.split("/").pop()!.slice(0, -3); // Remove .md extension
|
|
150
|
+
|
|
151
|
+
// Build source string based on subdirectory structure
|
|
152
|
+
const entryDir = entry.includes("/") ? entry.split("/").slice(0, -1).join(":") : "";
|
|
153
|
+
const fullSubdir = subdir && entryDir ? `${subdir}:${entryDir}` : entryDir || subdir;
|
|
154
|
+
|
|
155
|
+
let sourceStr: string;
|
|
156
|
+
if (source === "user") {
|
|
157
|
+
sourceStr = fullSubdir ? `(user:${fullSubdir})` : "(user)";
|
|
158
|
+
} else {
|
|
159
|
+
sourceStr = fullSubdir ? `(project:${fullSubdir})` : "(project)";
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Get description from frontmatter or first non-empty line
|
|
163
|
+
let description = frontmatter.description || "";
|
|
164
|
+
if (!description) {
|
|
165
|
+
const firstLine = content.split("\n").find((line) => line.trim());
|
|
166
|
+
if (firstLine) {
|
|
167
|
+
// Truncate if too long
|
|
168
|
+
description = firstLine.slice(0, 60);
|
|
169
|
+
if (firstLine.length > 60) description += "...";
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Append source to description
|
|
174
|
+
description = description ? `${description} ${sourceStr}` : sourceStr;
|
|
175
|
+
|
|
176
|
+
templates.push({
|
|
177
|
+
name,
|
|
178
|
+
description,
|
|
179
|
+
content,
|
|
180
|
+
source: sourceStr,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
} catch (_error) {
|
|
184
|
+
// Silently skip files that can't be read
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
} catch (_error) {
|
|
188
|
+
// Silently skip directories that can't be read
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return templates;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export interface LoadPromptTemplatesOptions {
|
|
195
|
+
/** Working directory for project-local templates. Default: process.cwd() */
|
|
196
|
+
cwd?: string;
|
|
197
|
+
/** Agent config directory for global templates. Default: from getPromptsDir() */
|
|
198
|
+
agentDir?: string;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Load all prompt templates from:
|
|
203
|
+
* 1. Global: agentDir/prompts/
|
|
204
|
+
* 2. Project: cwd/{CONFIG_DIR_NAME}/prompts/
|
|
205
|
+
*/
|
|
206
|
+
export async function loadPromptTemplates(options: LoadPromptTemplatesOptions = {}): Promise<PromptTemplate[]> {
|
|
207
|
+
const resolvedCwd = options.cwd ?? process.cwd();
|
|
208
|
+
const resolvedAgentDir = options.agentDir ?? getPromptsDir();
|
|
209
|
+
|
|
210
|
+
const templates: PromptTemplate[] = [];
|
|
211
|
+
|
|
212
|
+
// 1. Load global templates from agentDir/prompts/
|
|
213
|
+
// Note: if agentDir is provided, it should be the agent dir, not the prompts dir
|
|
214
|
+
const globalPromptsDir = options.agentDir ? join(options.agentDir, "prompts") : resolvedAgentDir;
|
|
215
|
+
templates.push(...(await loadTemplatesFromDir(globalPromptsDir, "user")));
|
|
216
|
+
|
|
217
|
+
// 2. Load project templates from cwd/{CONFIG_DIR_NAME}/prompts/
|
|
218
|
+
const projectPromptsDir = resolve(resolvedCwd, CONFIG_DIR_NAME, "prompts");
|
|
219
|
+
templates.push(...(await loadTemplatesFromDir(projectPromptsDir, "project")));
|
|
220
|
+
|
|
221
|
+
return templates;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Expand a prompt template if it matches a template name.
|
|
226
|
+
* Returns the expanded content or the original text if not a template.
|
|
227
|
+
*/
|
|
228
|
+
export function expandPromptTemplate(text: string, templates: PromptTemplate[]): string {
|
|
229
|
+
if (!text.startsWith("/")) return text;
|
|
230
|
+
|
|
231
|
+
const spaceIndex = text.indexOf(" ");
|
|
232
|
+
const templateName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
|
|
233
|
+
const argsString = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1);
|
|
234
|
+
|
|
235
|
+
const template = templates.find((t) => t.name === templateName);
|
|
236
|
+
if (template) {
|
|
237
|
+
const args = parseCommandArgs(argsString);
|
|
238
|
+
return substituteArgs(template.content, args);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return text;
|
|
242
|
+
}
|