@oh-my-pi/pi-coding-agent 13.10.1 → 13.11.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 (60) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/package.json +7 -7
  3. package/src/commit/agentic/agent.ts +3 -1
  4. package/src/commit/agentic/index.ts +7 -1
  5. package/src/commit/analysis/conventional.ts +5 -1
  6. package/src/commit/analysis/summary.ts +5 -1
  7. package/src/commit/changelog/generate.ts +5 -1
  8. package/src/commit/changelog/index.ts +4 -0
  9. package/src/commit/map-reduce/index.ts +5 -0
  10. package/src/commit/map-reduce/map-phase.ts +17 -2
  11. package/src/commit/map-reduce/reduce-phase.ts +5 -1
  12. package/src/commit/model-selection.ts +38 -26
  13. package/src/commit/pipeline.ts +22 -11
  14. package/src/config/settings-schema.ts +20 -0
  15. package/src/config.ts +10 -3
  16. package/src/discovery/helpers.ts +7 -3
  17. package/src/internal-urls/docs-index.generated.ts +1 -1
  18. package/src/lsp/index.ts +4 -4
  19. package/src/lsp/utils.ts +81 -0
  20. package/src/modes/components/settings-defs.ts +1 -0
  21. package/src/modes/components/todo-reminder.ts +8 -1
  22. package/src/modes/controllers/command-controller.ts +75 -3
  23. package/src/modes/controllers/input-controller.ts +2 -3
  24. package/src/modes/interactive-mode.ts +11 -7
  25. package/src/modes/theme/theme.ts +30 -27
  26. package/src/modes/types.ts +2 -1
  27. package/src/patch/hashline.ts +3 -6
  28. package/src/prompts/system/eager-todo.md +13 -0
  29. package/src/prompts/tools/ast-edit.md +1 -1
  30. package/src/prompts/tools/ast-grep.md +1 -1
  31. package/src/prompts/tools/find.md +1 -0
  32. package/src/prompts/tools/grep.md +1 -0
  33. package/src/prompts/tools/hashline.md +23 -111
  34. package/src/prompts/tools/todo-write.md +11 -1
  35. package/src/sdk.ts +1 -1
  36. package/src/session/agent-session.ts +85 -7
  37. package/src/slash-commands/builtin-registry.ts +10 -2
  38. package/src/task/executor.ts +9 -18
  39. package/src/task/index.ts +8 -4
  40. package/src/task/render.ts +5 -10
  41. package/src/task/template.ts +4 -1
  42. package/src/task/types.ts +2 -0
  43. package/src/tools/ast-edit.ts +26 -7
  44. package/src/tools/ast-grep.ts +26 -9
  45. package/src/tools/fetch.ts +36 -5
  46. package/src/tools/find.ts +13 -64
  47. package/src/tools/grep.ts +27 -10
  48. package/src/tools/output-meta.ts +2 -1
  49. package/src/tools/path-utils.ts +348 -0
  50. package/src/tools/todo-write.ts +27 -4
  51. package/src/utils/commit-message-generator.ts +27 -22
  52. package/src/utils/image-input.ts +1 -1
  53. package/src/utils/image-resize.ts +4 -4
  54. package/src/utils/title-generator.ts +36 -23
  55. package/src/utils/tool-choice.ts +28 -0
  56. package/src/web/parallel.ts +346 -0
  57. package/src/web/scrapers/youtube.ts +29 -0
  58. package/src/web/search/provider.ts +4 -1
  59. package/src/web/search/providers/parallel.ts +63 -0
  60. package/src/web/search/types.ts +1 -0
@@ -4,6 +4,14 @@ import * as path from "node:path";
4
4
 
5
5
  const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
6
6
  const NARROW_NO_BREAK_SPACE = "\u202F";
7
+ const TOP_LEVEL_INTERNAL_URL_PREFIXES = [
8
+ "agent://",
9
+ "artifact://",
10
+ "skill://",
11
+ "rule://",
12
+ "local://",
13
+ "mcp://",
14
+ ] as const;
7
15
 
8
16
  function normalizeUnicodeSpaces(str: string): string {
9
17
  return str.replace(UNICODE_SPACES, " ");
@@ -38,6 +46,15 @@ function fileExists(filePath: string): boolean {
38
46
  }
39
47
  }
40
48
 
49
+ async function pathExists(filePath: string): Promise<boolean> {
50
+ try {
51
+ await fs.promises.access(filePath, fs.constants.F_OK);
52
+ return true;
53
+ } catch {
54
+ return false;
55
+ }
56
+ }
57
+
41
58
  function normalizeAtPrefix(filePath: string): string {
42
59
  if (!filePath.startsWith("@")) return filePath;
43
60
 
@@ -105,6 +122,24 @@ export interface ParsedSearchPath {
105
122
  glob?: string;
106
123
  }
107
124
 
125
+ export interface ParsedFindPattern {
126
+ basePath: string;
127
+ globPattern: string;
128
+ hasGlob: boolean;
129
+ }
130
+
131
+ export interface ResolvedMultiSearchPath {
132
+ basePath: string;
133
+ glob?: string;
134
+ scopePath: string;
135
+ }
136
+
137
+ export interface ResolvedMultiFindPattern {
138
+ basePath: string;
139
+ globPattern: string;
140
+ scopePath: string;
141
+ }
142
+
108
143
  /**
109
144
  * Split a user path into a base path + glob pattern for tools that delegate to
110
145
  * APIs accepting separate `path` and `glob` arguments.
@@ -128,6 +163,44 @@ export function parseSearchPath(filePath: string): ParsedSearchPath {
128
163
  };
129
164
  }
130
165
 
166
+ // Parse a find pattern into a base directory path and a glob pattern.
167
+ // Examples:
168
+ // src/app/**/\*.tsx -> { basePath: "src/app", globPattern: "**/*.tsx", hasGlob: true }
169
+ // src/app/\*.tsx -> { basePath: "src/app", globPattern: "*.tsx", hasGlob: true }
170
+ // \*.ts -> { basePath: ".", globPattern: "**/*.ts", hasGlob: true }
171
+ // **/\*.json -> { basePath: ".", globPattern: "**/*.json", hasGlob: true }
172
+ // /abs/path/**/\*.ts -> { basePath: "/abs/path", globPattern: "**/*.ts", hasGlob: true }
173
+ // src/app -> { basePath: "src/app", globPattern: "**/*", hasGlob: false }
174
+ export function parseFindPattern(pattern: string): ParsedFindPattern {
175
+ const segments = pattern.split("/");
176
+ let firstGlobIndex = -1;
177
+ for (let i = 0; i < segments.length; i++) {
178
+ if (hasGlobPathChars(segments[i])) {
179
+ firstGlobIndex = i;
180
+ break;
181
+ }
182
+ }
183
+
184
+ if (firstGlobIndex === -1) {
185
+ return { basePath: pattern, globPattern: "**/*", hasGlob: false };
186
+ }
187
+
188
+ if (firstGlobIndex === 0) {
189
+ const needsRecursive = !pattern.startsWith("**/");
190
+ return {
191
+ basePath: ".",
192
+ globPattern: needsRecursive ? `**/${pattern}` : pattern,
193
+ hasGlob: true,
194
+ };
195
+ }
196
+
197
+ return {
198
+ basePath: segments.slice(0, firstGlobIndex).join("/"),
199
+ globPattern: segments.slice(firstGlobIndex).join("/"),
200
+ hasGlob: true,
201
+ };
202
+ }
203
+
131
204
  export function combineSearchGlobs(prefixGlob?: string, suffixGlob?: string): string | undefined {
132
205
  if (!prefixGlob) return suffixGlob;
133
206
  if (!suffixGlob) return prefixGlob;
@@ -138,6 +211,281 @@ export function combineSearchGlobs(prefixGlob?: string, suffixGlob?: string): st
138
211
  return `${normalizedPrefix}/${normalizedSuffix}`;
139
212
  }
140
213
 
214
+ type TopLevelSeparator = "comma" | "whitespace";
215
+
216
+ function splitTopLevel(value: string, separator: TopLevelSeparator): string[] {
217
+ const parts: string[] = [];
218
+ let current = "";
219
+ let braceDepth = 0;
220
+ let bracketDepth = 0;
221
+ let parenDepth = 0;
222
+ let quote: '"' | "'" | undefined;
223
+ let escaped = false;
224
+
225
+ const pushCurrent = () => {
226
+ const normalized = current.trim();
227
+ if (normalized.length > 0) {
228
+ parts.push(normalized);
229
+ }
230
+ current = "";
231
+ };
232
+
233
+ for (const char of value) {
234
+ if (escaped) {
235
+ current += char;
236
+ escaped = false;
237
+ continue;
238
+ }
239
+
240
+ if (char === "\\") {
241
+ current += char;
242
+ escaped = true;
243
+ continue;
244
+ }
245
+
246
+ if (quote) {
247
+ current += char;
248
+ if (char === quote) {
249
+ quote = undefined;
250
+ }
251
+ continue;
252
+ }
253
+
254
+ if (char === '"' || char === "'") {
255
+ quote = char;
256
+ current += char;
257
+ continue;
258
+ }
259
+
260
+ if (char === "{") braceDepth += 1;
261
+ else if (char === "}" && braceDepth > 0) braceDepth -= 1;
262
+ else if (char === "[") bracketDepth += 1;
263
+ else if (char === "]" && bracketDepth > 0) bracketDepth -= 1;
264
+ else if (char === "(") parenDepth += 1;
265
+ else if (char === ")" && parenDepth > 0) parenDepth -= 1;
266
+
267
+ const topLevel = braceDepth === 0 && bracketDepth === 0 && parenDepth === 0;
268
+ const isWhitespace = /\s/.test(char);
269
+ if (topLevel && separator === "comma" && char === ",") {
270
+ pushCurrent();
271
+ continue;
272
+ }
273
+ if (topLevel && separator === "whitespace" && isWhitespace) {
274
+ pushCurrent();
275
+ continue;
276
+ }
277
+
278
+ current += char;
279
+ }
280
+
281
+ pushCurrent();
282
+ return parts.length > 1 ? parts : [value.trim()];
283
+ }
284
+
285
+ function normalizePosixPath(filePath: string): string {
286
+ return filePath.replace(/\\/g, "/");
287
+ }
288
+
289
+ function joinRelativeGlob(basePath: string | undefined, globPattern: string): string {
290
+ if (!basePath || basePath === ".") return normalizePosixPath(globPattern).replace(/^\/+/, "");
291
+ const normalizedBase = normalizePosixPath(basePath).replace(/\/+$/, "");
292
+ const normalizedGlob = normalizePosixPath(globPattern).replace(/^\/+/, "");
293
+ return `${normalizedBase}/${normalizedGlob}`;
294
+ }
295
+
296
+ function buildBraceUnion(patterns: string[]): string | undefined {
297
+ const uniquePatterns = [...new Set(patterns.map(pattern => normalizePosixPath(pattern).trim()).filter(Boolean))];
298
+ if (uniquePatterns.length === 0) return undefined;
299
+ if (uniquePatterns.length === 1) return uniquePatterns[0];
300
+ return `{${uniquePatterns.join(",")}}`;
301
+ }
302
+
303
+ function findCommonBasePath(paths: string[]): string {
304
+ if (paths.length === 0) return ".";
305
+ let commonParts = path.resolve(paths[0]).split(path.sep);
306
+ for (const candidatePath of paths.slice(1)) {
307
+ const candidateParts = path.resolve(candidatePath).split(path.sep);
308
+ let sharedCount = 0;
309
+ const maxShared = Math.min(commonParts.length, candidateParts.length);
310
+ while (sharedCount < maxShared && commonParts[sharedCount] === candidateParts[sharedCount]) {
311
+ sharedCount += 1;
312
+ }
313
+ commonParts = commonParts.slice(0, sharedCount);
314
+ }
315
+ if (commonParts.length === 0) {
316
+ return path.parse(path.resolve(paths[0])).root;
317
+ }
318
+ const joined = commonParts.join(path.sep);
319
+ return joined || path.parse(path.resolve(paths[0])).root;
320
+ }
321
+
322
+ function toScopeDisplay(items: string[]): string {
323
+ return items.map(item => normalizePosixPath(item)).join(", ");
324
+ }
325
+
326
+ function looksLikeDelimitedPathToken(token: string): boolean {
327
+ return (
328
+ TOP_LEVEL_INTERNAL_URL_PREFIXES.some(prefix => token.startsWith(prefix)) ||
329
+ token.startsWith(".") ||
330
+ token.startsWith("/") ||
331
+ token.startsWith("~") ||
332
+ token.startsWith("@") ||
333
+ token.includes("/") ||
334
+ token.includes("\\") ||
335
+ hasGlobPathChars(token) ||
336
+ /\.[^./\\]+$/.test(token)
337
+ );
338
+ }
339
+
340
+ async function areDelimitedTokensResolvable(
341
+ tokens: string[],
342
+ cwd: string,
343
+ parseBasePath: (value: string) => string,
344
+ allowBareExistingTokens: boolean,
345
+ ): Promise<boolean> {
346
+ for (const token of tokens) {
347
+ if (TOP_LEVEL_INTERNAL_URL_PREFIXES.some(prefix => token.startsWith(prefix))) {
348
+ return false;
349
+ }
350
+
351
+ if (!allowBareExistingTokens && !looksLikeDelimitedPathToken(token)) {
352
+ // Bare names like "packages" don't look like path tokens syntactically,
353
+ // but may still be valid directory names. Check existence before rejecting.
354
+ const resolvedExactPath = resolveToCwd(token, cwd);
355
+ if (!(await pathExists(resolvedExactPath))) {
356
+ return false;
357
+ }
358
+ continue;
359
+ }
360
+
361
+ const basePath = parseBasePath(token);
362
+ const resolvedBasePath = resolveToCwd(basePath, cwd);
363
+ if (await pathExists(resolvedBasePath)) {
364
+ continue;
365
+ }
366
+
367
+ if (!allowBareExistingTokens) {
368
+ return false;
369
+ }
370
+
371
+ const resolvedExactPath = resolveToCwd(token, cwd);
372
+ if (!(await pathExists(resolvedExactPath))) {
373
+ return false;
374
+ }
375
+ }
376
+
377
+ return true;
378
+ }
379
+
380
+ async function splitDelimitedSearchInput(
381
+ rawInput: string,
382
+ cwd: string,
383
+ parseBasePath: (value: string) => string,
384
+ ): Promise<string[] | undefined> {
385
+ const trimmed = rawInput.trim();
386
+ if (!trimmed) return undefined;
387
+
388
+ const resolvedExactPath = resolveToCwd(trimmed, cwd);
389
+ if (await pathExists(resolvedExactPath)) {
390
+ return undefined;
391
+ }
392
+
393
+ const commaSeparated = splitTopLevel(trimmed, "comma");
394
+ if (commaSeparated.length > 1 && (await areDelimitedTokensResolvable(commaSeparated, cwd, parseBasePath, true))) {
395
+ return [...new Set(commaSeparated)];
396
+ }
397
+
398
+ const whitespaceSeparated = splitTopLevel(trimmed, "whitespace");
399
+ if (
400
+ whitespaceSeparated.length > 1 &&
401
+ (await areDelimitedTokensResolvable(whitespaceSeparated, cwd, parseBasePath, false))
402
+ ) {
403
+ return [...new Set(whitespaceSeparated)];
404
+ }
405
+
406
+ return undefined;
407
+ }
408
+
409
+ export async function resolveMultiSearchPath(
410
+ rawPath: string,
411
+ cwd: string,
412
+ suffixGlob?: string,
413
+ ): Promise<ResolvedMultiSearchPath | undefined> {
414
+ const pathItems = await splitDelimitedSearchInput(rawPath, cwd, value => parseSearchPath(value).basePath);
415
+ if (!pathItems || pathItems.length <= 1) {
416
+ return undefined;
417
+ }
418
+
419
+ const parsedItems = await Promise.all(
420
+ pathItems.map(async item => {
421
+ const parsedPath = parseSearchPath(item);
422
+ const absoluteBasePath = resolveToCwd(parsedPath.basePath, cwd);
423
+ const stat = await fs.promises.stat(absoluteBasePath);
424
+ return { raw: item, parsedPath, absoluteBasePath, stat };
425
+ }),
426
+ );
427
+
428
+ const commonBasePath = findCommonBasePath(parsedItems.map(item => item.absoluteBasePath));
429
+ const combinedPatterns = parsedItems.map(item => {
430
+ const relativeBasePath = normalizePosixPath(path.relative(commonBasePath, item.absoluteBasePath)) || ".";
431
+ if (item.parsedPath.glob) {
432
+ const pathGlob = joinRelativeGlob(relativeBasePath, item.parsedPath.glob);
433
+ return combineSearchGlobs(pathGlob, suffixGlob) ?? pathGlob;
434
+ }
435
+ if (suffixGlob) {
436
+ const pathPrefix = relativeBasePath === "." ? undefined : relativeBasePath;
437
+ return combineSearchGlobs(pathPrefix, suffixGlob) ?? suffixGlob;
438
+ }
439
+ if (item.stat.isDirectory()) {
440
+ return joinRelativeGlob(relativeBasePath, "**/*");
441
+ }
442
+ return relativeBasePath === "." ? path.basename(item.absoluteBasePath) : relativeBasePath;
443
+ });
444
+
445
+ return {
446
+ basePath: commonBasePath,
447
+ glob: buildBraceUnion(combinedPatterns),
448
+ scopePath: toScopeDisplay(pathItems),
449
+ };
450
+ }
451
+
452
+ export async function resolveMultiFindPattern(
453
+ rawPattern: string,
454
+ cwd: string,
455
+ ): Promise<ResolvedMultiFindPattern | undefined> {
456
+ const patternItems = await splitDelimitedSearchInput(rawPattern, cwd, value => parseFindPattern(value).basePath);
457
+ if (!patternItems || patternItems.length <= 1) {
458
+ return undefined;
459
+ }
460
+
461
+ const parsedItems = await Promise.all(
462
+ patternItems.map(async item => {
463
+ const parsedPattern = parseFindPattern(item);
464
+ const absoluteBasePath = resolveToCwd(parsedPattern.basePath, cwd);
465
+ const stat = await fs.promises.stat(absoluteBasePath);
466
+ return { raw: item, parsedPattern, absoluteBasePath, stat };
467
+ }),
468
+ );
469
+
470
+ const commonBasePath = findCommonBasePath(parsedItems.map(item => item.absoluteBasePath));
471
+ const combinedPatterns = parsedItems.map(item => {
472
+ const relativeBasePath = normalizePosixPath(path.relative(commonBasePath, item.absoluteBasePath)) || ".";
473
+ if (item.parsedPattern.hasGlob) {
474
+ return joinRelativeGlob(relativeBasePath, item.parsedPattern.globPattern);
475
+ }
476
+ if (item.stat.isDirectory()) {
477
+ return joinRelativeGlob(relativeBasePath, "**/*");
478
+ }
479
+ return relativeBasePath === "." ? path.basename(item.absoluteBasePath) : relativeBasePath;
480
+ });
481
+
482
+ return {
483
+ basePath: commonBasePath,
484
+ globPattern: buildBraceUnion(combinedPatterns) ?? "**/*",
485
+ scopePath: toScopeDisplay(patternItems),
486
+ };
487
+ }
488
+
141
489
  export function resolveReadPath(filePath: string, cwd: string): string {
142
490
  const resolved = resolveToCwd(filePath, cwd);
143
491
  const shellEscapedVariant = tryShellEscapedPath(resolved);
@@ -24,6 +24,7 @@ export interface TodoItem {
24
24
  content: string;
25
25
  status: TodoStatus;
26
26
  notes?: string;
27
+ details?: string;
27
28
  }
28
29
 
29
30
  export interface TodoPhase {
@@ -49,6 +50,9 @@ const InputTask = Type.Object({
49
50
  content: Type.String({ description: "Task description" }),
50
51
  status: Type.Optional(StatusEnum),
51
52
  notes: Type.Optional(Type.String({ description: "Additional context or notes" })),
53
+ details: Type.Optional(
54
+ Type.String({ description: "Implementation details, file paths, and specifics (shown only when active)" }),
55
+ ),
52
56
  });
53
57
 
54
58
  const InputPhase = Type.Object({
@@ -73,6 +77,7 @@ const todoWriteSchema = Type.Object({
73
77
  phase: Type.String({ description: "Phase ID, e.g. phase-1" }),
74
78
  content: Type.String({ description: "Task description" }),
75
79
  notes: Type.Optional(Type.String({ description: "Additional context or notes" })),
80
+ details: Type.Optional(Type.String({ description: "Implementation details, file paths, and specifics" })),
76
81
  }),
77
82
  Type.Object({
78
83
  op: Type.Literal("update"),
@@ -80,6 +85,7 @@ const todoWriteSchema = Type.Object({
80
85
  status: Type.Optional(StatusEnum),
81
86
  content: Type.Optional(Type.String({ description: "Updated task description" })),
82
87
  notes: Type.Optional(Type.String({ description: "Additional context or notes" })),
88
+ details: Type.Optional(Type.String({ description: "Updated details" })),
83
89
  }),
84
90
  Type.Object({
85
91
  op: Type.Literal("remove_task"),
@@ -118,14 +124,20 @@ function findTask(phases: TodoPhase[], id: string): TodoItem | undefined {
118
124
  }
119
125
 
120
126
  function buildPhaseFromInput(
121
- input: { name: string; tasks?: Array<{ content: string; status?: TodoStatus; notes?: string }> },
127
+ input: { name: string; tasks?: Array<{ content: string; status?: TodoStatus; notes?: string; details?: string }> },
122
128
  phaseId: string,
123
129
  nextTaskId: number,
124
130
  ): { phase: TodoPhase; nextTaskId: number } {
125
131
  const tasks: TodoItem[] = [];
126
132
  let tid = nextTaskId;
127
133
  for (const t of input.tasks ?? []) {
128
- tasks.push({ id: `task-${tid++}`, content: t.content, status: t.status ?? "pending", notes: t.notes });
134
+ tasks.push({
135
+ id: `task-${tid++}`,
136
+ content: t.content,
137
+ status: t.status ?? "pending",
138
+ notes: t.notes,
139
+ details: t.details,
140
+ });
129
141
  }
130
142
  return { phase: { id: phaseId, name: input.name, tasks }, nextTaskId: tid };
131
143
  }
@@ -231,6 +243,7 @@ function applyOps(file: TodoFile, ops: TodoWriteParams["ops"]): { file: TodoFile
231
243
  content: op.content,
232
244
  status: "pending",
233
245
  notes: op.notes,
246
+ details: op.details,
234
247
  });
235
248
  break;
236
249
  }
@@ -244,6 +257,7 @@ function applyOps(file: TodoFile, ops: TodoWriteParams["ops"]): { file: TodoFile
244
257
  if (op.status !== undefined) task.status = op.status;
245
258
  if (op.content !== undefined) task.content = op.content;
246
259
  if (op.notes !== undefined) task.notes = op.notes;
260
+ if (op.details !== undefined) task.details = op.details;
247
261
  break;
248
262
  }
249
263
 
@@ -293,6 +307,11 @@ function formatSummary(phases: TodoPhase[], errors: string[]): string {
293
307
  lines.push(`Remaining items (${remainingTasks.length}):`);
294
308
  for (const task of remainingTasks) {
295
309
  lines.push(` - ${task.id} ${task.content} [${task.status}] (${task.phase})`);
310
+ if (task.status === "in_progress" && task.details) {
311
+ for (const line of task.details.split("\n")) {
312
+ lines.push(` ${line}`);
313
+ }
314
+ }
296
315
  }
297
316
  }
298
317
  lines.push(
@@ -364,8 +383,12 @@ function formatTodoLine(item: TodoItem, uiTheme: Theme, prefix: string): string
364
383
  switch (item.status) {
365
384
  case "completed":
366
385
  return uiTheme.fg("success", `${prefix}${checkbox.checked} ${chalk.strikethrough(item.content)}`);
367
- case "in_progress":
368
- return uiTheme.fg("accent", `${prefix}${checkbox.unchecked} ${item.content}`);
386
+ case "in_progress": {
387
+ const main = uiTheme.fg("accent", `${prefix}${checkbox.unchecked} ${item.content}`);
388
+ if (!item.details) return main;
389
+ const detailLines = item.details.split("\n").map(l => uiTheme.fg("dim", `${prefix} ${l}`));
390
+ return [main, ...detailLines].join("\n");
391
+ }
369
392
  case "abandoned":
370
393
  return uiTheme.fg("error", `${prefix}${checkbox.unchecked} ${chalk.strikethrough(item.content)}`);
371
394
  default:
@@ -2,14 +2,17 @@
2
2
  * Generate commit messages from diffs using a smol, fast model.
3
3
  * Follows the same pattern as title-generator.ts.
4
4
  */
5
+ import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
5
6
  import type { Api, Model } from "@oh-my-pi/pi-ai";
6
7
  import { completeSimple } from "@oh-my-pi/pi-ai";
7
8
  import { logger } from "@oh-my-pi/pi-utils";
8
9
  import type { ModelRegistry } from "../config/model-registry";
9
- import { parseModelString } from "../config/model-resolver";
10
+ import { resolveModelRoleValue } from "../config/model-resolver";
10
11
  import { renderPromptTemplate } from "../config/prompt-templates";
12
+ import type { Settings } from "../config/settings";
11
13
  import MODEL_PRIO from "../priority.json" with { type: "json" };
12
14
  import commitSystemPrompt from "../prompts/system/commit-message-system.md" with { type: "text" };
15
+ import { toReasoningEffort } from "../thinking";
13
16
 
14
17
  const COMMIT_SYSTEM_PROMPT = renderPromptTemplate(commitSystemPrompt);
15
18
  const MAX_DIFF_CHARS = 4000;
@@ -32,24 +35,26 @@ function filterDiffNoise(diff: string): string {
32
35
  return filtered.join("\n");
33
36
  }
34
37
 
35
- function getSmolModelCandidates(registry: ModelRegistry, savedSmolModel?: string): Model<Api>[] {
38
+ function getSmolModelCandidates(
39
+ registry: ModelRegistry,
40
+ settings: Settings,
41
+ ): Array<{ model: Model<Api>; thinkingLevel?: ThinkingLevel }> {
36
42
  const availableModels = registry.getAvailable();
37
43
  if (availableModels.length === 0) return [];
38
44
 
39
- const candidates: Model<Api>[] = [];
40
- const addCandidate = (model?: Model<Api>): void => {
45
+ const candidates: Array<{ model: Model<Api>; thinkingLevel?: ThinkingLevel }> = [];
46
+ const addCandidate = (model?: Model<Api>, thinkingLevel?: ThinkingLevel): void => {
41
47
  if (!model) return;
42
- if (candidates.some(c => c.provider === model.provider && c.id === model.id)) return;
43
- candidates.push(model);
48
+ if (candidates.some(c => c.model.provider === model.provider && c.model.id === model.id)) return;
49
+ candidates.push({ model, thinkingLevel });
44
50
  };
45
51
 
46
- if (savedSmolModel) {
47
- const parsed = parseModelString(savedSmolModel);
48
- if (parsed) {
49
- const match = availableModels.find(m => m.provider === parsed.provider && m.id === parsed.id);
50
- addCandidate(match);
51
- }
52
- }
52
+ const matchPreferences = { usageOrder: settings.getStorage()?.getModelUsageOrder() };
53
+ const configuredSmol = resolveModelRoleValue(settings.getModelRole("smol"), availableModels, {
54
+ settings,
55
+ matchPreferences,
56
+ });
57
+ addCandidate(configuredSmol.model, configuredSmol.thinkingLevel);
53
58
 
54
59
  for (const pattern of MODEL_PRIO.smol) {
55
60
  const needle = pattern.toLowerCase();
@@ -71,10 +76,10 @@ function getSmolModelCandidates(registry: ModelRegistry, savedSmolModel?: string
71
76
  export async function generateCommitMessage(
72
77
  diff: string,
73
78
  registry: ModelRegistry,
74
- savedSmolModel?: string,
79
+ settings: Settings,
75
80
  sessionId?: string,
76
81
  ): Promise<string | null> {
77
- const candidates = getSmolModelCandidates(registry, savedSmolModel);
82
+ const candidates = getSmolModelCandidates(registry, settings);
78
83
  if (candidates.length === 0) {
79
84
  logger.debug("commit-msg-generator: no smol model found");
80
85
  return null;
@@ -89,22 +94,22 @@ export async function generateCommitMessage(
89
94
  }
90
95
  const userMessage = `<diff>\n${truncatedDiff}\n</diff>`;
91
96
 
92
- for (const model of candidates) {
93
- const apiKey = await registry.getApiKey(model, sessionId);
97
+ for (const candidate of candidates) {
98
+ const apiKey = await registry.getApiKey(candidate.model, sessionId);
94
99
  if (!apiKey) continue;
95
100
 
96
101
  try {
97
102
  const response = await completeSimple(
98
- model,
103
+ candidate.model,
99
104
  {
100
105
  systemPrompt: COMMIT_SYSTEM_PROMPT,
101
106
  messages: [{ role: "user", content: userMessage, timestamp: Date.now() }],
102
107
  },
103
- { apiKey, maxTokens: 60 },
108
+ { apiKey, maxTokens: 60, reasoning: toReasoningEffort(candidate.thinkingLevel) },
104
109
  );
105
110
 
106
111
  if (response.stopReason === "error") {
107
- logger.debug("commit-msg-generator: error", { model: model.id, error: response.errorMessage });
112
+ logger.debug("commit-msg-generator: error", { model: candidate.model.id, error: response.errorMessage });
108
113
  continue;
109
114
  }
110
115
 
@@ -118,11 +123,11 @@ export async function generateCommitMessage(
118
123
  // Clean up: remove wrapping quotes, backticks, trailing period
119
124
  msg = msg.replace(/^[`"']|[`"']$/g, "").replace(/\.$/, "");
120
125
 
121
- logger.debug("commit-msg-generator: generated", { model: model.id, msg });
126
+ logger.debug("commit-msg-generator: generated", { model: candidate.model.id, msg });
122
127
  return msg;
123
128
  } catch (err) {
124
129
  logger.debug("commit-msg-generator: error", {
125
- model: model.id,
130
+ model: candidate.model.id,
126
131
  error: err instanceof Error ? err.message : String(err),
127
132
  });
128
133
  }
@@ -231,7 +231,7 @@ export async function loadImageInput(options: LoadImageInputOptions): Promise<Lo
231
231
  throw new ImageInputTooLargeError(inputBuffer.byteLength, maxBytes);
232
232
  }
233
233
 
234
- let outputData = new Uint8Array(inputBuffer).toBase64();
234
+ let outputData = Buffer.from(inputBuffer).toBase64();
235
235
  let outputMimeType = mimeType;
236
236
  let outputBytes = inputBuffer.byteLength;
237
237
  let dimensionNote: string | undefined;
@@ -136,7 +136,7 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
136
136
  height: finalHeight,
137
137
  wasResized: true,
138
138
  get data() {
139
- return best.buffer.toBase64();
139
+ return Buffer.from(best.buffer).toBase64();
140
140
  },
141
141
  };
142
142
  }
@@ -155,7 +155,7 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
155
155
  height: finalHeight,
156
156
  wasResized: true,
157
157
  get data() {
158
- return best.buffer.toBase64();
158
+ return Buffer.from(best.buffer).toBase64();
159
159
  },
160
160
  };
161
161
  }
@@ -183,7 +183,7 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
183
183
  height: finalHeight,
184
184
  wasResized: true,
185
185
  get data() {
186
- return best.buffer.toBase64();
186
+ return Buffer.from(best.buffer).toBase64();
187
187
  },
188
188
  };
189
189
  }
@@ -200,7 +200,7 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
200
200
  height: finalHeight,
201
201
  wasResized: true,
202
202
  get data() {
203
- return best.buffer.toBase64();
203
+ return Buffer.from(best.buffer).toBase64();
204
204
  },
205
205
  };
206
206
  } catch {