@oh-my-pi/pi-coding-agent 14.5.14 → 14.6.1

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 (74) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/package.json +7 -7
  3. package/src/autoresearch/command-resume.md +5 -8
  4. package/src/autoresearch/git.ts +41 -51
  5. package/src/autoresearch/helpers.ts +43 -359
  6. package/src/autoresearch/index.ts +281 -273
  7. package/src/autoresearch/prompt-setup.md +43 -0
  8. package/src/autoresearch/prompt.md +52 -193
  9. package/src/autoresearch/resume-message.md +2 -8
  10. package/src/autoresearch/state.ts +59 -166
  11. package/src/autoresearch/storage.ts +687 -0
  12. package/src/autoresearch/tools/init-experiment.ts +201 -290
  13. package/src/autoresearch/tools/log-experiment.ts +304 -517
  14. package/src/autoresearch/tools/run-experiment.ts +117 -296
  15. package/src/autoresearch/tools/update-notes.ts +116 -0
  16. package/src/autoresearch/types.ts +16 -66
  17. package/src/cli/list-models.ts +66 -0
  18. package/src/config/settings-schema.ts +1 -1
  19. package/src/config/settings.ts +20 -1
  20. package/src/cursor.ts +1 -1
  21. package/src/edit/index.ts +9 -31
  22. package/src/edit/line-hash.ts +70 -43
  23. package/src/edit/modes/hashline.lark +26 -0
  24. package/src/edit/modes/hashline.ts +898 -1099
  25. package/src/edit/modes/patch.ts +0 -7
  26. package/src/edit/modes/replace.ts +0 -4
  27. package/src/edit/renderer.ts +22 -20
  28. package/src/edit/streaming.ts +8 -28
  29. package/src/eval/eval.lark +24 -30
  30. package/src/eval/js/context-manager.ts +5 -162
  31. package/src/eval/js/prelude.txt +0 -12
  32. package/src/eval/parse.ts +129 -129
  33. package/src/eval/py/prelude.py +1 -219
  34. package/src/export/html/template.generated.ts +1 -1
  35. package/src/export/html/template.js +2 -2
  36. package/src/internal-urls/docs-index.generated.ts +2 -2
  37. package/src/main.ts +18 -3
  38. package/src/modes/components/session-observer-overlay.ts +5 -2
  39. package/src/modes/components/status-line/segments.ts +1 -1
  40. package/src/modes/components/status-line.ts +3 -5
  41. package/src/modes/components/tree-selector.ts +4 -5
  42. package/src/modes/components/welcome.ts +11 -1
  43. package/src/modes/controllers/command-controller.ts +2 -6
  44. package/src/modes/controllers/event-controller.ts +7 -5
  45. package/src/modes/controllers/extension-ui-controller.ts +3 -15
  46. package/src/modes/controllers/input-controller.ts +0 -1
  47. package/src/modes/controllers/selector-controller.ts +1 -1
  48. package/src/modes/interactive-mode.ts +5 -7
  49. package/src/prompts/system/system-prompt.md +14 -38
  50. package/src/prompts/tools/ast-edit.md +8 -8
  51. package/src/prompts/tools/ast-grep.md +10 -10
  52. package/src/prompts/tools/eval.md +13 -31
  53. package/src/prompts/tools/find.md +2 -1
  54. package/src/prompts/tools/hashline.md +66 -57
  55. package/src/prompts/tools/search.md +2 -2
  56. package/src/session/agent-session.ts +1 -1
  57. package/src/session/session-manager.ts +17 -13
  58. package/src/tools/ast-edit.ts +141 -44
  59. package/src/tools/ast-grep.ts +112 -36
  60. package/src/tools/eval.ts +2 -53
  61. package/src/tools/find.ts +16 -15
  62. package/src/tools/gh-renderer.ts +184 -59
  63. package/src/tools/path-utils.ts +36 -196
  64. package/src/tools/search.ts +56 -35
  65. package/src/utils/edit-mode.ts +2 -11
  66. package/src/utils/file-display-mode.ts +1 -1
  67. package/src/utils/git.ts +59 -24
  68. package/src/utils/session-color.ts +0 -12
  69. package/src/utils/title-generator.ts +22 -38
  70. package/src/autoresearch/apply-contract-to-state.ts +0 -24
  71. package/src/autoresearch/contract.ts +0 -288
  72. package/src/edit/modes/atom.lark +0 -29
  73. package/src/edit/modes/atom.ts +0 -1773
  74. package/src/prompts/tools/atom.md +0 -150
@@ -22,7 +22,7 @@ import {
22
22
  hasGlobPathChars,
23
23
  normalizePathLikeInput,
24
24
  parseSearchPath,
25
- resolveMultiSearchPath,
25
+ resolveExplicitSearchPaths,
26
26
  resolveToCwd,
27
27
  } from "./path-utils";
28
28
  import {
@@ -37,9 +37,10 @@ import { toolResult } from "./tool-result";
37
37
 
38
38
  const searchSchema = Type.Object({
39
39
  pattern: Type.String({ description: "regex pattern", examples: ["function\\s+\\w+", "TODO"] }),
40
- path: Type.String({
41
- description: "file, directory, glob, comma-separated paths, or internal URL to search",
42
- examples: ["src/", "src/foo.ts", "src/**/*.ts"],
40
+ paths: Type.Array(Type.String({ description: "file, directory, glob, or internal URL to search" }), {
41
+ minItems: 1,
42
+ description: "files, directories, globs, or internal URLs to search",
43
+ examples: [["src/"], ["src/foo.ts"], ["src/**/*.ts"], ["src/", "packages/"]],
43
44
  }),
44
45
  i: Type.Optional(Type.Boolean({ description: "case-insensitive search", default: false })),
45
46
  gitignore: Type.Optional(Type.Boolean({ description: "respect gitignore", default: true })),
@@ -48,7 +49,7 @@ const searchSchema = Type.Object({
48
49
 
49
50
  export type SearchToolInput = Static<typeof searchSchema>;
50
51
 
51
- const DEFAULT_MATCH_LIMIT = 20;
52
+ const DEFAULT_MATCH_LIMIT = 500;
52
53
 
53
54
  export interface SearchToolDetails {
54
55
  truncation?: TruncationResult;
@@ -93,7 +94,7 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
93
94
  _onUpdate?: AgentToolUpdateCallback<SearchToolDetails>,
94
95
  _toolContext?: AgentToolContext,
95
96
  ): Promise<AgentToolResult<SearchToolDetails>> {
96
- const { pattern, path: searchDir, i, gitignore, skip } = params;
97
+ const { pattern, paths, i, gitignore, skip } = params;
97
98
 
98
99
  return untilAborted(signal, async () => {
99
100
  const normalizedPattern = pattern.trim();
@@ -117,13 +118,19 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
117
118
  let searchPath: string;
118
119
  let scopePath: string;
119
120
  let exactFilePaths: string[] | undefined;
121
+ let multiTargets: Array<{ basePath: string; glob?: string }> | undefined;
120
122
  let globFilter: string | undefined;
121
- const rawPath = normalizePathLikeInput(searchDir);
122
- if (rawPath.length === 0) {
123
- throw new ToolError("`path` must be a non-empty path or glob");
123
+ const rawPaths = paths.map(normalizePathLikeInput);
124
+ if (rawPaths.some(rawPath => rawPath.length === 0)) {
125
+ throw new ToolError("`paths` must contain non-empty paths or globs");
124
126
  }
125
127
  const internalRouter = this.session.internalRouter;
126
- if (internalRouter?.canHandle(rawPath)) {
128
+ const resolvedPathInputs: string[] = [];
129
+ for (const rawPath of rawPaths) {
130
+ if (!internalRouter?.canHandle(rawPath)) {
131
+ resolvedPathInputs.push(rawPath);
132
+ continue;
133
+ }
127
134
  if (hasGlobPathChars(rawPath)) {
128
135
  throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPath}`);
129
136
  }
@@ -131,28 +138,30 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
131
138
  if (!resource.sourcePath) {
132
139
  throw new ToolError(`Cannot search internal URL without a backing file: ${rawPath}`);
133
140
  }
134
- searchPath = resource.sourcePath;
141
+ resolvedPathInputs.push(resource.sourcePath);
142
+ }
143
+ if (resolvedPathInputs.length === 1) {
144
+ const parsedPath = parseSearchPath(resolvedPathInputs[0] ?? ".");
145
+ searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd);
146
+ globFilter = parsedPath.glob;
135
147
  scopePath = formatScopePath(searchPath);
136
148
  } else {
137
- const multiSearchPath = await resolveMultiSearchPath(rawPath, this.session.cwd, globFilter);
138
- if (multiSearchPath) {
139
- searchPath = multiSearchPath.basePath;
140
- globFilter = multiSearchPath.exactFilePaths ? undefined : multiSearchPath.glob;
141
- exactFilePaths = multiSearchPath.exactFilePaths;
142
- scopePath = multiSearchPath.scopePath;
143
- } else {
144
- const parsedPath = parseSearchPath(rawPath);
145
- searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd);
146
- globFilter = parsedPath.glob;
147
- scopePath = formatScopePath(searchPath);
149
+ const multiSearchPath = await resolveExplicitSearchPaths(resolvedPathInputs, this.session.cwd, globFilter);
150
+ if (!multiSearchPath) {
151
+ throw new ToolError("`paths` must contain at least one path or glob");
148
152
  }
153
+ searchPath = multiSearchPath.basePath;
154
+ exactFilePaths = multiSearchPath.exactFilePaths;
155
+ multiTargets = multiSearchPath.targets;
156
+ globFilter = exactFilePaths || multiTargets ? undefined : multiSearchPath.glob;
157
+ scopePath = multiSearchPath.scopePath;
149
158
  }
150
159
  let isDirectory: boolean;
151
160
  try {
152
161
  const stat = await Bun.file(searchPath).stat();
153
162
  isDirectory = stat.isDirectory();
154
163
  } catch {
155
- const hint = scopePath.includes(",") ? ` (comma-separated paths must each exist relative to cwd)` : "";
164
+ const hint = rawPaths.length > 1 ? " (`paths` entries must each exist relative to cwd)" : "";
156
165
  throw new ToolError(`Path not found: ${scopePath}${hint}`);
157
166
  }
158
167
 
@@ -163,19 +172,26 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
163
172
  // Run grep
164
173
  let result: GrepResult;
165
174
  try {
166
- if (exactFilePaths) {
175
+ if (exactFilePaths || multiTargets) {
167
176
  const matches: GrepMatch[] = [];
168
177
  let limitReached = false;
169
- for (const exactFilePath of exactFilePaths) {
170
- const fileResult = await grep(
178
+ let totalMatches = 0;
179
+ let filesSearched = 0;
180
+ const targets = exactFilePaths
181
+ ? exactFilePaths.map(filePath => ({ basePath: filePath, glob: undefined as string | undefined }))
182
+ : (multiTargets ?? []);
183
+ for (const target of targets) {
184
+ const targetResult = await grep(
171
185
  {
172
186
  pattern: normalizedPattern,
173
- path: exactFilePath,
187
+ path: target.basePath,
188
+ glob: target.glob,
174
189
  ignoreCase,
175
190
  multiline: effectiveMultiline,
176
191
  hidden: true,
177
192
  gitignore: useGitignore,
178
193
  cache: false,
194
+ maxCount: exactFilePaths ? undefined : internalLimit,
179
195
  contextBefore: normalizedContextBefore,
180
196
  contextAfter: normalizedContextAfter,
181
197
  maxColumns: DEFAULT_MAX_COLUMN,
@@ -183,16 +199,21 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
183
199
  },
184
200
  undefined,
185
201
  );
186
- limitReached = limitReached || Boolean(fileResult.limitReached);
187
- const relativeFilePath = path.relative(searchPath, exactFilePath).replace(/\\/g, "/");
188
- matches.push(...fileResult.matches.map(match => ({ ...match, path: relativeFilePath })));
202
+ limitReached = limitReached || Boolean(targetResult.limitReached);
203
+ totalMatches += targetResult.totalMatches;
204
+ filesSearched += targetResult.filesSearched;
205
+ for (const match of targetResult.matches) {
206
+ const absolute = path.resolve(target.basePath, match.path);
207
+ const rebased = path.relative(searchPath, absolute).replace(/\\/g, "/");
208
+ matches.push({ ...match, path: rebased });
209
+ }
189
210
  }
190
211
  const offsetMatches = matches.slice(normalizedSkip);
191
212
  result = {
192
213
  matches: offsetMatches,
193
- totalMatches: offsetMatches.length,
214
+ totalMatches: exactFilePaths ? offsetMatches.length : totalMatches,
194
215
  filesWithMatches: new Set(offsetMatches.map(match => match.path)).size,
195
- filesSearched: exactFilePaths.length,
216
+ filesSearched: exactFilePaths ? exactFilePaths.length : filesSearched,
196
217
  limitReached,
197
218
  };
198
219
  } else {
@@ -261,7 +282,7 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
261
282
  : result.matches.slice(0, effectiveLimit);
262
283
  const matchLimitReached = result.matches.length > effectiveLimit;
263
284
  const nextSkip = normalizedSkip + selectedMatches.length;
264
- const limitMessage = `Result limit reached; narrow path or use skip=${nextSkip}.`;
285
+ const limitMessage = `Result limit reached; narrow paths or use skip=${nextSkip}.`;
265
286
  const { record: recordFile, list: fileList } = createFileRecorder();
266
287
  const fileMatchCounts = new Map<string, number>();
267
288
  if (selectedMatches.length === 0) {
@@ -381,7 +402,7 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
381
402
 
382
403
  interface SearchRenderArgs {
383
404
  pattern: string;
384
- path?: string;
405
+ paths?: string[];
385
406
  i?: boolean;
386
407
  gitignore?: boolean;
387
408
  skip?: number;
@@ -393,7 +414,7 @@ export const searchToolRenderer = {
393
414
  inline: true,
394
415
  renderCall(args: SearchRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
395
416
  const meta: string[] = [];
396
- if (args.path) meta.push(`in ${args.path}`);
417
+ if (args.paths?.length) meta.push(`in ${args.paths.join(", ")}`);
397
418
  if (args.i) meta.push("case:insensitive");
398
419
  if (args.gitignore === false) meta.push("gitignore:false");
399
420
  if (args.skip !== undefined && args.skip > 0) meta.push(`skip:${args.skip}`);
@@ -1,12 +1,11 @@
1
- import { $env, $flag } from "@oh-my-pi/pi-utils";
1
+ import { $env } from "@oh-my-pi/pi-utils";
2
2
 
3
- export type EditMode = "replace" | "patch" | "hashline" | "vim" | "apply_patch" | "atom";
3
+ export type EditMode = "replace" | "patch" | "hashline" | "vim" | "apply_patch";
4
4
 
5
5
  export const DEFAULT_EDIT_MODE: EditMode = "hashline";
6
6
 
7
7
  const EDIT_MODE_IDS = {
8
8
  apply_patch: "apply_patch",
9
- atom: "atom",
10
9
  hashline: "hashline",
11
10
  patch: "patch",
12
11
  replace: "replace",
@@ -38,14 +37,6 @@ export function resolveEditMode(session: EditModeSessionLike): EditMode {
38
37
  const envMode = normalizeEditMode($env.PI_EDIT_VARIANT);
39
38
  if (envMode) return envMode;
40
39
 
41
- if (!$flag("PI_STRICT_EDIT_MODE")) {
42
- if (activeModel?.includes("spark")) return "apply_patch";
43
- if (activeModel?.includes("nano")) return "replace";
44
- if (activeModel?.includes("mini")) return "replace";
45
- if (activeModel?.includes("haiku")) return "replace";
46
- if (activeModel?.includes("flash")) return "replace";
47
- }
48
-
49
40
  const settingsMode = normalizeEditMode(String(session.settings.get("edit.mode") ?? ""));
50
41
  return settingsMode ?? DEFAULT_EDIT_MODE;
51
42
  }
@@ -29,7 +29,7 @@ export function resolveFileDisplayMode(session: FileDisplayModeSession, options?
29
29
  const { settings } = session;
30
30
  const hasEditTool = session.hasEditTool ?? true;
31
31
  const editMode = resolveEditMode(session);
32
- const usesHashLineAnchors = editMode === "hashline" || editMode === "atom";
32
+ const usesHashLineAnchors = editMode === "hashline";
33
33
  const raw = options?.raw === true;
34
34
  const hashLines = !raw && hasEditTool && usesHashLineAnchors && settings.get("readHashLines") !== false;
35
35
  return {
package/src/utils/git.ts CHANGED
@@ -368,50 +368,68 @@ async function writeTempPatch(content: string): Promise<string> {
368
368
 
369
369
  type EntryType = "directory" | "file";
370
370
 
371
- function isOptionalGitMetadataUnavailable(err: unknown): boolean {
372
- return isEnoent(err) || hasFsCode(err, "ENFILE") || hasFsCode(err, "EMFILE");
371
+ function shouldRetry(err: unknown, n: number) {
372
+ if (isEnoent(err) || hasFsCode(err, "ENFILE") || hasFsCode(err, "EMFILE")) return false;
373
+ if (hasFsCode(err, "EINTR")) return n < EINTR_MAX_RETRIES;
374
+ if (n > EINTR_MAX_RETRIES) throw err;
375
+ throw err;
376
+ }
377
+
378
+ /**
379
+ * Bounded retry for synchronous I/O against `EINTR`. POSIX permits short syscalls
380
+ * to be interrupted by signals; when that happens libc traditionally retries.
381
+ * Node's sync wrappers surface the raw `EINTR` so we replicate the retry locally.
382
+ * Any other error (and persistent EINTR after `EINTR_MAX_RETRIES`) is rethrown
383
+ * for the caller's normal "optional metadata" classifier to handle.
384
+ */
385
+ const EINTR_MAX_RETRIES = 3;
386
+ function retryOnEintrSync<T>(op: () => T): T | null {
387
+ for (let attempt = 0; attempt <= EINTR_MAX_RETRIES; attempt += 1) {
388
+ try {
389
+ return op();
390
+ } catch (err) {
391
+ if (shouldRetry(err, attempt)) continue;
392
+ return null;
393
+ }
394
+ }
395
+ throw new Error("retryOnEintrSync: exhausted without resolution");
396
+ }
397
+ async function retryOnEintr<T>(op: () => Promise<T>): Promise<T | null> {
398
+ for (let attempt = 0; attempt <= EINTR_MAX_RETRIES; attempt += 1) {
399
+ try {
400
+ return await op();
401
+ } catch (err) {
402
+ if (shouldRetry(err, attempt)) continue;
403
+ return null;
404
+ }
405
+ }
406
+ throw new Error("retryOnEintr: exhausted without resolution");
373
407
  }
374
408
 
375
409
  function getEntryTypeSync(gitEntryPath: string): EntryType | null {
376
- try {
410
+ return retryOnEintrSync(() => {
377
411
  const stat = fs.statSync(gitEntryPath);
378
412
  if (stat.isDirectory()) return "directory";
379
413
  if (stat.isFile()) return "file";
380
414
  return null;
381
- } catch (err) {
382
- if (isOptionalGitMetadataUnavailable(err)) return null;
383
- throw err;
384
- }
415
+ });
385
416
  }
386
417
 
387
418
  async function getEntryType(gitEntryPath: string): Promise<EntryType | null> {
388
- try {
419
+ return retryOnEintr(async () => {
389
420
  const stat = await fs.promises.stat(gitEntryPath);
390
421
  if (stat.isDirectory()) return "directory";
391
422
  if (stat.isFile()) return "file";
392
423
  return null;
393
- } catch (err) {
394
- if (isOptionalGitMetadataUnavailable(err)) return null;
395
- throw err;
396
- }
424
+ });
397
425
  }
398
426
 
399
427
  function readOptionalTextSync(filePath: string): string | null {
400
- try {
401
- return fs.readFileSync(filePath, "utf8");
402
- } catch (err) {
403
- if (isOptionalGitMetadataUnavailable(err)) return null;
404
- throw err;
405
- }
428
+ return retryOnEintrSync(() => fs.readFileSync(filePath, "utf8"));
406
429
  }
407
430
 
408
431
  async function readOptionalText(filePath: string): Promise<string | null> {
409
- try {
410
- return await Bun.file(filePath).text();
411
- } catch (err) {
412
- if (isOptionalGitMetadataUnavailable(err)) return null;
413
- throw err;
414
- }
432
+ return retryOnEintr(async () => await Bun.file(filePath).text());
415
433
  }
416
434
 
417
435
  function parseGitDirPointer(content: string): string | null {
@@ -1258,6 +1276,23 @@ export async function restore(cwd: string, options: RestoreOptions = {}): Promis
1258
1276
  await runEffect(cwd, args, { signal: options.signal });
1259
1277
  }
1260
1278
 
1279
+ /**
1280
+ * Run `git reset` with options. Default is a soft reset (no flag); pass `hard: true` for a destructive reset.
1281
+ *
1282
+ * NOTE: stage.reset() handles the per-file unstaging case. This helper exists for tree-wide resets.
1283
+ */
1284
+ export async function reset(
1285
+ cwd: string,
1286
+ options: { hard?: boolean; mixed?: boolean; soft?: boolean; target?: string; signal?: AbortSignal } = {},
1287
+ ): Promise<void> {
1288
+ const args = ["reset"];
1289
+ if (options.hard) args.push("--hard");
1290
+ else if (options.mixed) args.push("--mixed");
1291
+ else if (options.soft) args.push("--soft");
1292
+ if (options.target) args.push(options.target);
1293
+ await runEffect(cwd, args, { signal: options.signal });
1294
+ }
1295
+
1261
1296
  export async function clean(
1262
1297
  cwd: string,
1263
1298
  options: { ignoredOnly?: boolean; paths?: readonly string[]; signal?: AbortSignal } = {},
@@ -33,18 +33,6 @@ export function getSessionAccentHex(name: string): string {
33
33
  return hslToHex(nameToHue(name), 0.9, 0.72);
34
34
  }
35
35
 
36
- /**
37
- * Auto-generated titles should not drive the session accent.
38
- * Legacy sessions with unknown title source keep the old behavior.
39
- */
40
- export function getSessionAccentHexForTitle(
41
- name: string | undefined,
42
- titleSource: "auto" | "user" | undefined,
43
- ): string | undefined {
44
- if (!name || titleSource === "auto") return undefined;
45
- return getSessionAccentHex(name);
46
- }
47
-
48
36
  /**
49
37
  * Convert a hex accent color to an ANSI-16m foreground escape sequence.
50
38
  * Returns `undefined` if `hex` is nullish or Bun.color conversion fails.
@@ -2,15 +2,13 @@
2
2
  * Generate session titles using a smol, fast model.
3
3
  */
4
4
  import * as path from "node:path";
5
- import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
6
- import type { Api, Model } from "@oh-my-pi/pi-ai";
7
- import { completeSimple } from "@oh-my-pi/pi-ai";
5
+
6
+ import { type Api, completeSimple, type Model } from "@oh-my-pi/pi-ai";
8
7
  import { logger, prompt } from "@oh-my-pi/pi-utils";
9
8
  import type { ModelRegistry } from "../config/model-registry";
10
9
  import { resolveRoleSelection } from "../config/model-resolver";
11
10
  import type { Settings } from "../config/settings";
12
11
  import titleSystemPrompt from "../prompts/system/title-system.md" with { type: "text" };
13
- import { toReasoningEffort } from "../thinking";
14
12
 
15
13
  const TITLE_SYSTEM_PROMPT = prompt.render(titleSystemPrompt);
16
14
 
@@ -19,22 +17,14 @@ const TERMINAL_TITLE_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f]/g;
19
17
 
20
18
  const MAX_INPUT_CHARS = 2000;
21
19
 
22
- function getTitleModel(
23
- registry: ModelRegistry,
24
- settings: Settings,
25
- currentModel?: Model<Api>,
26
- ): { model: Model<Api>; thinkingLevel?: ThinkingLevel } | undefined {
20
+ function getTitleModel(registry: ModelRegistry, settings: Settings, currentModel?: Model<Api>): Model<Api> | undefined {
27
21
  const availableModels = registry.getAvailable();
28
22
  if (availableModels.length === 0) return undefined;
29
23
 
30
- const titleModel = resolveRoleSelection(["commit", "smol"], settings, availableModels, registry);
31
- if (titleModel) {
32
- return { model: titleModel.model, thinkingLevel: titleModel.thinkingLevel };
33
- }
24
+ const titleModel = resolveRoleSelection(["commit", "smol"], settings, availableModels, registry)?.model;
25
+ if (titleModel) return titleModel;
34
26
 
35
- if (currentModel) {
36
- return { model: currentModel };
37
- }
27
+ if (currentModel) return currentModel;
38
28
 
39
29
  return undefined;
40
30
  }
@@ -44,7 +34,7 @@ function getTitleModel(
44
34
  *
45
35
  * @param firstMessage The first user message
46
36
  * @param registry Model registry
47
- * @param settings Settings used to resolve the smol role, including per-role thinking
37
+ * @param settings Settings used to resolve the smol role
48
38
  * @param sessionId Optional session id for sticky API key selection
49
39
  */
50
40
  export async function generateSessionTitle(
@@ -54,8 +44,8 @@ export async function generateSessionTitle(
54
44
  sessionId?: string,
55
45
  currentModel?: Model<Api>,
56
46
  ): Promise<string | null> {
57
- const candidate = getTitleModel(registry, settings, currentModel);
58
- if (!candidate) {
47
+ const model = getTitleModel(registry, settings, currentModel);
48
+ if (!model) {
59
49
  logger.debug("title-generator: no title model found");
60
50
  return null;
61
51
  }
@@ -67,17 +57,20 @@ export async function generateSessionTitle(
67
57
  ${truncatedMessage}
68
58
  </user-message>`;
69
59
 
70
- const apiKey = await registry.getApiKey(candidate.model, sessionId);
60
+ const apiKey = await registry.getApiKey(model, sessionId);
71
61
  if (!apiKey) {
72
62
  logger.debug("title-generator: no API key for smol model", {
73
- provider: candidate.model.provider,
74
- id: candidate.model.id,
63
+ provider: model.provider,
64
+ id: model.id,
75
65
  });
76
66
  return null;
77
67
  }
78
68
 
69
+ // Title generation is a 3-6 word task; force reasoning off so reasoning models
70
+ // don't burn the entire output budget on internal thinking and return an empty
71
+ // string. With reasoning disabled, 30 tokens of output is plenty.
79
72
  const request = {
80
- model: `${candidate.model.provider}/${candidate.model.id}`,
73
+ model: `${model.provider}/${model.id}`,
81
74
  systemPrompt: TITLE_SYSTEM_PROMPT,
82
75
  userMessage,
83
76
  maxTokens: 30,
@@ -86,7 +79,7 @@ ${truncatedMessage}
86
79
 
87
80
  try {
88
81
  const response = await completeSimple(
89
- candidate.model,
82
+ model,
90
83
  {
91
84
  systemPrompt: request.systemPrompt,
92
85
  messages: [{ role: "user", content: request.userMessage, timestamp: Date.now() }],
@@ -94,7 +87,7 @@ ${truncatedMessage}
94
87
  {
95
88
  apiKey,
96
89
  maxTokens: 30,
97
- reasoning: toReasoningEffort(candidate.thinkingLevel),
90
+ disableReasoning: true,
98
91
  },
99
92
  );
100
93
 
@@ -153,13 +146,8 @@ function getFallbackTerminalTitle(cwd: string | undefined): string | undefined {
153
146
  return sanitizeTerminalTitlePart(baseName);
154
147
  }
155
148
 
156
- export function formatSessionTerminalTitle(
157
- sessionName: string | undefined,
158
- cwd?: string,
159
- titleSource?: "auto" | "user" | undefined,
160
- ): string {
161
- const label =
162
- sanitizeTerminalTitlePart(titleSource === "auto" ? undefined : sessionName) ?? getFallbackTerminalTitle(cwd);
149
+ export function formatSessionTerminalTitle(sessionName: string | undefined, cwd?: string): string {
150
+ const label = sanitizeTerminalTitlePart(sessionName) ?? getFallbackTerminalTitle(cwd);
163
151
  return label ? `${DEFAULT_TERMINAL_TITLE}: ${label}` : DEFAULT_TERMINAL_TITLE;
164
152
  }
165
153
 
@@ -170,12 +158,8 @@ export function setTerminalTitle(title: string): void {
170
158
  process.stdout.write(`\x1b]0;${sanitizeTerminalTitlePart(title) ?? DEFAULT_TERMINAL_TITLE}\x07`);
171
159
  }
172
160
 
173
- export function setSessionTerminalTitle(
174
- sessionName: string | undefined,
175
- cwd?: string,
176
- titleSource?: "auto" | "user" | undefined,
177
- ): void {
178
- setTerminalTitle(formatSessionTerminalTitle(sessionName, cwd, titleSource));
161
+ export function setSessionTerminalTitle(sessionName: string | undefined, cwd?: string): void {
162
+ setTerminalTitle(formatSessionTerminalTitle(sessionName, cwd));
179
163
  }
180
164
 
181
165
  /**
@@ -1,24 +0,0 @@
1
- import { inferMetricUnitFromName } from "./helpers";
2
- import type { AutoresearchContract, ExperimentState } from "./types";
3
-
4
- /**
5
- * Updates session fields from a validated `autoresearch.md` parse (same fields as `init_experiment`).
6
- * Does not touch `name`, `currentSegment`, `results`, `bestMetric`, `confidence`, or `maxExperiments`.
7
- */
8
- export function applyAutoresearchContractToExperimentState(
9
- contract: AutoresearchContract,
10
- state: ExperimentState,
11
- ): void {
12
- const benchmarkContract = contract.benchmark;
13
- state.metricName = benchmarkContract.primaryMetric ?? state.metricName;
14
- state.metricUnit = benchmarkContract.metricUnit;
15
- state.bestDirection = benchmarkContract.direction ?? "lower";
16
- state.secondaryMetrics = benchmarkContract.secondaryMetrics.map(name => ({
17
- name,
18
- unit: inferMetricUnitFromName(name),
19
- }));
20
- state.benchmarkCommand = benchmarkContract.command?.trim() ?? state.benchmarkCommand;
21
- state.scopePaths = [...contract.scopePaths];
22
- state.offLimits = [...contract.offLimits];
23
- state.constraints = [...contract.constraints];
24
- }