@oh-my-pi/pi-coding-agent 13.8.0 → 13.9.2

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 (35) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/package.json +7 -7
  3. package/src/capability/rule.ts +0 -4
  4. package/src/cli/agents-cli.ts +1 -1
  5. package/src/cli/args.ts +7 -12
  6. package/src/commands/launch.ts +3 -2
  7. package/src/config/model-resolver.ts +106 -33
  8. package/src/config/settings-schema.ts +14 -2
  9. package/src/config/settings.ts +1 -17
  10. package/src/discovery/helpers.ts +10 -17
  11. package/src/export/html/template.generated.ts +1 -1
  12. package/src/export/html/template.js +37 -15
  13. package/src/extensibility/extensions/loader.ts +1 -2
  14. package/src/extensibility/extensions/types.ts +2 -1
  15. package/src/main.ts +20 -13
  16. package/src/modes/components/agent-dashboard.ts +12 -13
  17. package/src/modes/components/model-selector.ts +157 -59
  18. package/src/modes/components/read-tool-group.ts +36 -2
  19. package/src/modes/components/settings-defs.ts +11 -8
  20. package/src/modes/components/settings-selector.ts +1 -1
  21. package/src/modes/components/thinking-selector.ts +3 -15
  22. package/src/modes/controllers/selector-controller.ts +21 -7
  23. package/src/modes/rpc/rpc-client.ts +2 -2
  24. package/src/modes/rpc/rpc-types.ts +2 -2
  25. package/src/modes/theme/theme.ts +2 -1
  26. package/src/patch/hashline.ts +26 -3
  27. package/src/patch/index.ts +14 -16
  28. package/src/prompts/tools/read.md +2 -2
  29. package/src/sdk.ts +21 -29
  30. package/src/session/agent-session.ts +44 -37
  31. package/src/task/executor.ts +10 -8
  32. package/src/task/types.ts +1 -2
  33. package/src/tools/read.ts +88 -264
  34. package/src/utils/frontmatter.ts +25 -4
  35. package/src/web/scrapers/choosealicense.ts +1 -1
package/src/tools/read.ts CHANGED
@@ -2,7 +2,7 @@ import * as fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
4
4
  import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai";
5
- import { FileType, glob } from "@oh-my-pi/pi-natives";
5
+ import { glob } from "@oh-my-pi/pi-natives";
6
6
  import type { Component } from "@oh-my-pi/pi-tui";
7
7
  import { Text } from "@oh-my-pi/pi-tui";
8
8
  import { getRemoteDir, ptree, untilAborted } from "@oh-my-pi/pi-utils";
@@ -28,7 +28,7 @@ import { detectSupportedImageMimeTypeFromFile } from "../utils/mime";
28
28
  import { ensureTool } from "../utils/tools-manager";
29
29
  import { applyListLimit } from "./list-limit";
30
30
  import { formatFullOutputReference, formatStyledTruncationWarning, type OutputMeta } from "./output-meta";
31
- import { resolveReadPath, resolveToCwd } from "./path-utils";
31
+ import { resolveReadPath } from "./path-utils";
32
32
  import { formatAge, formatBytes, shortenPath, wrapBrackets } from "./render-utils";
33
33
  import { ToolAbortError, ToolError, throwIfAborted } from "./tool-errors";
34
34
  import { toolResult } from "./tool-result";
@@ -254,251 +254,55 @@ async function streamLinesFromFile(
254
254
 
255
255
  // Maximum image file size (20MB) - larger images will be rejected to prevent OOM during serialization
256
256
  const MAX_IMAGE_SIZE = 20 * 1024 * 1024;
257
- const MAX_FUZZY_RESULTS = 5;
258
- const MAX_FUZZY_CANDIDATES = 20000;
259
- const MIN_BASE_SIMILARITY = 0.5;
260
- const MIN_FULL_SIMILARITY = 0.6;
261
257
  const GLOB_TIMEOUT_MS = 5000;
262
258
 
263
- function normalizePathForMatch(value: string): string {
264
- return value
265
- .replace(/\\/g, "/")
266
- .replace(/^\.\/+/, "")
267
- .replace(/\/+$/, "")
268
- .toLowerCase();
269
- }
270
-
271
259
  function isNotFoundError(error: unknown): boolean {
272
260
  if (!error || typeof error !== "object") return false;
273
261
  const code = (error as { code?: string }).code;
274
262
  return code === "ENOENT" || code === "ENOTDIR";
275
263
  }
276
264
 
277
- function isPathWithin(basePath: string, targetPath: string): boolean {
278
- const relativePath = path.relative(basePath, targetPath);
279
- return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath));
280
- }
281
-
282
- async function findExistingDirectory(startDir: string, signal?: AbortSignal): Promise<string | null> {
283
- let current = startDir;
284
- const root = path.parse(startDir).root;
285
-
286
- while (true) {
287
- throwIfAborted(signal);
288
- try {
289
- const stat = await Bun.file(current).stat();
290
- if (stat.isDirectory()) {
291
- return current;
292
- }
293
- } catch {
294
- // Keep walking up.
295
- }
296
-
297
- if (current === root) {
298
- break;
299
- }
300
- current = path.dirname(current);
301
- }
302
-
303
- return null;
304
- }
305
-
306
- function formatScopeLabel(searchRoot: string, cwd: string): string {
307
- const relative = path.relative(cwd, searchRoot).replace(/\\/g, "/");
308
- if (relative === "" || relative === ".") {
309
- return ".";
310
- }
311
- if (!relative.startsWith("..") && !path.isAbsolute(relative)) {
312
- return relative;
313
- }
314
- return searchRoot;
315
- }
316
-
317
- function buildDisplayPath(searchRoot: string, cwd: string, relativePath: string): string {
318
- const scopeLabel = formatScopeLabel(searchRoot, cwd);
319
- const normalized = relativePath.replace(/\\/g, "/");
320
- if (scopeLabel === ".") {
321
- return normalized;
322
- }
323
- if (scopeLabel.startsWith("..") || path.isAbsolute(scopeLabel)) {
324
- return path.join(searchRoot, normalized).replace(/\\/g, "/");
325
- }
326
- return `${scopeLabel}/${normalized}`;
327
- }
328
-
329
- function levenshteinDistance(a: string, b: string): number {
330
- if (a === b) return 0;
331
- const aLen = a.length;
332
- const bLen = b.length;
333
- if (aLen === 0) return bLen;
334
- if (bLen === 0) return aLen;
335
-
336
- let prev = new Array<number>(bLen + 1);
337
- let curr = new Array<number>(bLen + 1);
338
- for (let j = 0; j <= bLen; j++) {
339
- prev[j] = j;
340
- }
341
-
342
- for (let i = 1; i <= aLen; i++) {
343
- curr[0] = i;
344
- const aCode = a.charCodeAt(i - 1);
345
- for (let j = 1; j <= bLen; j++) {
346
- const cost = aCode === b.charCodeAt(j - 1) ? 0 : 1;
347
- const deletion = prev[j] + 1;
348
- const insertion = curr[j - 1] + 1;
349
- const substitution = prev[j - 1] + cost;
350
- curr[j] = Math.min(deletion, insertion, substitution);
351
- }
352
- const tmp = prev;
353
- prev = curr;
354
- curr = tmp;
355
- }
356
-
357
- return prev[bLen];
358
- }
359
-
360
- function similarityScore(a: string, b: string): number {
361
- if (a.length === 0 && b.length === 0) {
362
- return 1;
363
- }
364
- const maxLen = Math.max(a.length, b.length);
365
- if (maxLen === 0) {
366
- return 1;
367
- }
368
- const distance = levenshteinDistance(a, b);
369
- return 1 - distance / maxLen;
370
- }
371
-
372
- async function listCandidateFiles(
373
- searchRoot: string,
265
+ /**
266
+ * Attempt to resolve a non-existent path by finding a unique suffix match within the workspace.
267
+ * Uses a glob suffix pattern so the native engine handles matching directly.
268
+ * Returns null when 0 or >1 candidates match (ambiguous = no auto-resolution).
269
+ */
270
+ async function findUniqueSuffixMatch(
271
+ rawPath: string,
272
+ cwd: string,
374
273
  signal?: AbortSignal,
375
- _notify?: (message: string) => void,
376
- ): Promise<{ files: string[]; truncated: boolean; error?: string }> {
377
- let files: string[];
274
+ ): Promise<{ absolutePath: string; displayPath: string } | null> {
275
+ const normalized = rawPath.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
276
+ if (!normalized) return null;
277
+
378
278
  const timeoutSignal = AbortSignal.timeout(GLOB_TIMEOUT_MS);
379
279
  const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
280
+
281
+ let matches: string[];
380
282
  try {
381
283
  const result = await untilAborted(combinedSignal, () =>
382
284
  glob({
383
- pattern: "**/*",
384
- path: searchRoot,
385
- fileType: FileType.File,
285
+ pattern: `**/${normalized}`,
286
+ path: cwd,
287
+ // No fileType filter: matches both files and directories
386
288
  hidden: true,
387
289
  }),
388
290
  );
389
- files = result.matches.map(match => match.path);
291
+ matches = result.matches.map(m => m.path);
390
292
  } catch (error) {
391
293
  if (error instanceof Error && error.name === "AbortError") {
392
- if (timeoutSignal.aborted && !signal?.aborted) {
393
- const timeoutSeconds = Math.max(1, Math.round(GLOB_TIMEOUT_MS / 1000));
394
- return { files: [], truncated: false, error: `find timed out after ${timeoutSeconds}s` };
395
- }
294
+ if (!signal?.aborted) return null; // timeout — give up silently
396
295
  throw new ToolAbortError();
397
296
  }
398
- const message = error instanceof Error ? error.message : String(error);
399
- return { files: [], truncated: false, error: message };
400
- }
401
-
402
- const normalizedFiles = files.filter(line => line.length > 0);
403
- const truncated = normalizedFiles.length > MAX_FUZZY_CANDIDATES;
404
- const limited = truncated ? normalizedFiles.slice(0, MAX_FUZZY_CANDIDATES) : normalizedFiles;
405
-
406
- return { files: limited, truncated };
407
- }
408
-
409
- async function findReadPathSuggestions(
410
- rawPath: string,
411
- cwd: string,
412
- signal?: AbortSignal,
413
- notify?: (message: string) => void,
414
- ): Promise<{ suggestions: string[]; scopeLabel?: string; truncated?: boolean; error?: string } | null> {
415
- const resolvedPath = resolveToCwd(rawPath, cwd);
416
- const searchRoot = await findExistingDirectory(path.dirname(resolvedPath), signal);
417
- if (!searchRoot) {
418
297
  return null;
419
298
  }
420
299
 
421
- if (!isPathWithin(cwd, resolvedPath)) {
422
- const root = path.parse(searchRoot).root;
423
- if (searchRoot === root) {
424
- return null;
425
- }
426
- }
427
-
428
- const { files, truncated, error } = await listCandidateFiles(searchRoot, signal, notify);
429
- const scopeLabel = formatScopeLabel(searchRoot, cwd);
430
-
431
- if (error && files.length === 0) {
432
- return { suggestions: [], scopeLabel, truncated, error };
433
- }
434
-
435
- if (files.length === 0) {
436
- return null;
437
- }
438
-
439
- const queryPath = (() => {
440
- if (path.isAbsolute(rawPath)) {
441
- const relative = path.relative(cwd, resolvedPath).replace(/\\/g, "/");
442
- if (relative && !relative.startsWith("..") && !path.isAbsolute(relative)) {
443
- return normalizePathForMatch(relative);
444
- }
445
- }
446
- return normalizePathForMatch(rawPath);
447
- })();
448
- const baseQuery = path.posix.basename(queryPath);
449
-
450
- const matches: Array<{ path: string; score: number; baseScore: number; fullScore: number }> = [];
451
- const seen = new Set<string>();
452
-
453
- for (const file of files) {
454
- throwIfAborted(signal);
455
- const cleaned = file.replace(/\r$/, "").trim();
456
- if (!cleaned) continue;
457
-
458
- const relativePath = cleaned;
459
-
460
- if (!relativePath || relativePath.startsWith("..")) {
461
- continue;
462
- }
463
-
464
- const displayPath = buildDisplayPath(searchRoot, cwd, relativePath);
465
- if (seen.has(displayPath)) {
466
- continue;
467
- }
468
- seen.add(displayPath);
469
-
470
- const normalizedDisplay = normalizePathForMatch(displayPath);
471
- const baseCandidate = path.posix.basename(normalizedDisplay);
472
-
473
- const fullScore = similarityScore(queryPath, normalizedDisplay);
474
- const baseScore = baseQuery ? similarityScore(baseQuery, baseCandidate) : 0;
475
-
476
- if (baseQuery) {
477
- if (baseScore < MIN_BASE_SIMILARITY && fullScore < MIN_FULL_SIMILARITY) {
478
- continue;
479
- }
480
- } else if (fullScore < MIN_FULL_SIMILARITY) {
481
- continue;
482
- }
483
-
484
- const score = baseQuery ? baseScore * 0.75 + fullScore * 0.25 : fullScore;
485
- matches.push({ path: displayPath, score, baseScore, fullScore });
486
- }
300
+ if (matches.length !== 1) return null;
487
301
 
488
- if (matches.length === 0) {
489
- return { suggestions: [], scopeLabel, truncated };
490
- }
491
-
492
- matches.sort((a, b) => {
493
- if (b.score !== a.score) return b.score - a.score;
494
- if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore;
495
- return a.path.localeCompare(b.path);
496
- });
497
-
498
- const listLimit = applyListLimit(matches, { limit: MAX_FUZZY_RESULTS });
499
- const suggestions = listLimit.items.map(match => match.path);
500
-
501
- return { suggestions, scopeLabel, truncated };
302
+ return {
303
+ absolutePath: path.resolve(cwd, matches[0]),
304
+ displayPath: matches[0],
305
+ };
502
306
  }
503
307
 
504
308
  async function convertWithMarkitdown(
@@ -540,6 +344,7 @@ export interface ReadToolDetails {
540
344
  truncation?: TruncationResult;
541
345
  isDirectory?: boolean;
542
346
  resolvedPath?: string;
347
+ suffixResolution?: { from: string; to: string };
543
348
  meta?: OutputMeta;
544
349
  }
545
350
 
@@ -560,11 +365,17 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
560
365
  readonly strict = true;
561
366
 
562
367
  readonly #autoResizeImages: boolean;
368
+ readonly #defaultLimit: number;
563
369
 
564
370
  constructor(private readonly session: ToolSession) {
565
371
  const displayMode = resolveFileDisplayMode(session);
566
372
  this.#autoResizeImages = session.settings.get("images.autoResize");
373
+ this.#defaultLimit = Math.max(
374
+ 1,
375
+ Math.min(session.settings.get("read.defaultLimit") ?? DEFAULT_MAX_LINES, DEFAULT_MAX_LINES),
376
+ );
567
377
  this.description = renderPromptTemplate(readDescription, {
378
+ DEFAULT_LIMIT: String(this.#defaultLimit),
568
379
  DEFAULT_MAX_LINES: String(DEFAULT_MAX_LINES),
569
380
  IS_HASHLINE_MODE: displayMode.hashLines,
570
381
  IS_LINE_NUMBER_MODE: !displayMode.hashLines && displayMode.lineNumbers,
@@ -576,7 +387,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
576
387
  params: ReadParams,
577
388
  signal?: AbortSignal,
578
389
  _onUpdate?: AgentToolUpdateCallback<ReadToolDetails>,
579
- toolContext?: AgentToolContext,
390
+ _toolContext?: AgentToolContext,
580
391
  ): Promise<AgentToolResult<ReadToolDetails>> {
581
392
  const { path: readPath, offset, limit } = params;
582
393
 
@@ -588,7 +399,8 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
588
399
  return this.#handleInternalUrl(readPath, offset, limit);
589
400
  }
590
401
 
591
- const absolutePath = resolveReadPath(readPath, this.session.cwd);
402
+ let absolutePath = resolveReadPath(readPath, this.session.cwd);
403
+ let suffixResolution: { from: string; to: string } | undefined;
592
404
 
593
405
  let isDirectory = false;
594
406
  let fileSize = 0;
@@ -598,34 +410,37 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
598
410
  isDirectory = stat.isDirectory();
599
411
  } catch (error) {
600
412
  if (isNotFoundError(error)) {
601
- let message = `File not found: ${readPath}`;
602
-
603
- // Skip fuzzy matching for remote mounts (sshfs) to avoid hangs
413
+ // Attempt unique suffix resolution before falling back to fuzzy suggestions
604
414
  if (!isRemoteMountPath(absolutePath)) {
605
- const suggestions = await findReadPathSuggestions(readPath, this.session.cwd, signal, message =>
606
- toolContext?.ui?.notify(message, "info"),
607
- );
608
-
609
- if (suggestions?.suggestions.length) {
610
- const scopeLabel = suggestions.scopeLabel ? ` in ${suggestions.scopeLabel}` : "";
611
- message += `\n\nClosest matches${scopeLabel}:\n${suggestions.suggestions.map(match => `- ${match}`).join("\n")}`;
612
- if (suggestions.truncated) {
613
- message += `\n[Search truncated to first ${MAX_FUZZY_CANDIDATES} paths. Refine the path if the match isn't listed.]`;
415
+ const suffixMatch = await findUniqueSuffixMatch(readPath, this.session.cwd, signal);
416
+ if (suffixMatch) {
417
+ try {
418
+ const retryStat = await Bun.file(suffixMatch.absolutePath).stat();
419
+ absolutePath = suffixMatch.absolutePath;
420
+ fileSize = retryStat.size;
421
+ isDirectory = retryStat.isDirectory();
422
+ suffixResolution = { from: readPath, to: suffixMatch.displayPath };
423
+ } catch {
424
+ // Suffix match candidate no longer stats — fall through to error path
614
425
  }
615
- } else if (suggestions?.error) {
616
- message += `\n\nFuzzy match failed: ${suggestions.error}`;
617
- } else if (suggestions?.scopeLabel) {
618
- message += `\n\nNo similar paths found in ${suggestions.scopeLabel}.`;
619
426
  }
620
427
  }
621
428
 
622
- throw new ToolError(message);
429
+ if (!suffixResolution) {
430
+ throw new ToolError(`Path '${readPath}' not found`);
431
+ }
432
+ } else {
433
+ throw error;
623
434
  }
624
- throw error;
625
435
  }
626
436
 
627
437
  if (isDirectory) {
628
- return this.#readDirectory(absolutePath, limit, signal);
438
+ const dirResult = await this.#readDirectory(absolutePath, limit, signal);
439
+ if (suffixResolution) {
440
+ dirResult.details ??= {};
441
+ dirResult.details.suffixResolution = suffixResolution;
442
+ }
443
+ return dirResult;
629
444
  }
630
445
 
631
446
  const mimeType = await detectSupportedImageMimeTypeFromFile(absolutePath);
@@ -721,8 +536,9 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
721
536
  const startLine = offset ? Math.max(0, offset - 1) : 0;
722
537
  const startLineDisplay = startLine + 1; // For display (1-indexed)
723
538
 
724
- const maxLinesToCollect = limit !== undefined ? Math.min(limit, DEFAULT_MAX_LINES) : DEFAULT_MAX_LINES;
725
- const selectedLineLimit = limit ?? null;
539
+ const effectiveLimit = limit ?? this.#defaultLimit;
540
+ const maxLinesToCollect = Math.min(effectiveLimit, DEFAULT_MAX_LINES);
541
+ const selectedLineLimit = effectiveLimit;
726
542
  const streamResult = await streamLinesFromFile(
727
543
  absolutePath,
728
544
  startLine,
@@ -747,13 +563,13 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
747
563
  totalFileLines === 0
748
564
  ? "The file is empty."
749
565
  : `Use offset=1 to read from the start, or offset=${totalFileLines} to read the last line.`;
750
- return toolResult<ReadToolDetails>()
566
+ return toolResult<ReadToolDetails>({ resolvedPath: absolutePath, suffixResolution })
751
567
  .text(`Offset ${offset} is beyond end of file (${totalFileLines} lines total). ${suggestion}`)
752
568
  .done();
753
569
  }
754
570
 
755
571
  const selectedContent = collectedLines.join("\n");
756
- const userLimitedLines = limit !== undefined ? collectedLines.length : undefined;
572
+ const userLimitedLines = collectedLines.length;
757
573
 
758
574
  const totalSelectedLines = totalFileLines - startLine;
759
575
  const totalSelectedBytes = collectedBytes;
@@ -812,7 +628,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
812
628
  result: truncation,
813
629
  options: { direction: "head", startLine: startLineDisplay, totalFileLines },
814
630
  };
815
- } else if (userLimitedLines !== undefined && startLine + userLimitedLines < totalFileLines) {
631
+ } else if (startLine + userLimitedLines < totalFileLines) {
816
632
  const remaining = totalFileLines - (startLine + userLimitedLines);
817
633
  const nextOffset = startLine + userLimitedLines + 1;
818
634
 
@@ -830,6 +646,17 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
830
646
  content = [{ type: "text", text: outputText }];
831
647
  }
832
648
 
649
+ if (suffixResolution) {
650
+ details.suffixResolution = suffixResolution;
651
+ // Inline resolution notice into first text block so the model sees the actual path
652
+ const notice = `[Path '${suffixResolution.from}' not found; resolved to '${suffixResolution.to}' via suffix match]`;
653
+ const firstText = content.find((c): c is TextContent => c.type === "text");
654
+ if (firstText) {
655
+ firstText.text = `${notice}\n${firstText.text}`;
656
+ } else {
657
+ content = [{ type: "text", text: notice }, ...content];
658
+ }
659
+ }
833
660
  const resultBuilder = toolResult(details).content(content);
834
661
  if (sourcePath) {
835
662
  resultBuilder.sourcePath(sourcePath);
@@ -869,13 +696,10 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
869
696
 
870
697
  // Resolve the internal URL
871
698
  const resource = await internalRouter.resolve(url);
699
+ const details: ReadToolDetails = { resolvedPath: resource.sourcePath };
872
700
 
873
701
  // If extraction was used, return directly (no pagination)
874
702
  if (hasExtraction) {
875
- const details: ReadToolDetails = {};
876
- if (resource.sourcePath) {
877
- details.resolvedPath = resource.sourcePath;
878
- }
879
703
  return toolResult(details).text(resource.content).sourceInternal(url).done();
880
704
  }
881
705
 
@@ -891,7 +715,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
891
715
  allLines.length === 0
892
716
  ? "The resource is empty."
893
717
  : `Use offset=1 to read from the start, or offset=${allLines.length} to read the last line.`;
894
- return toolResult<ReadToolDetails>()
718
+ return toolResult<ReadToolDetails>(details)
895
719
  .text(`Offset ${offset} is beyond end of resource (${allLines.length} lines total). ${suggestion}`)
896
720
  .done();
897
721
  }
@@ -916,7 +740,6 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
916
740
  };
917
741
 
918
742
  let outputText: string;
919
- let details: ReadToolDetails = {};
920
743
  let truncationInfo:
921
744
  | { result: TruncationResult; options: { direction: "head"; startLine?: number; totalFileLines?: number } }
922
745
  | undefined;
@@ -938,14 +761,14 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
938
761
  firstLineBytes,
939
762
  )}, exceeds ${formatBytes(DEFAULT_MAX_BYTES)} limit. Unable to display a valid UTF-8 snippet.]`;
940
763
  }
941
- details = { truncation };
764
+ details.truncation = truncation;
942
765
  truncationInfo = {
943
766
  result: truncation,
944
767
  options: { direction: "head", startLine: startLineDisplay, totalFileLines: totalLines },
945
768
  };
946
769
  } else if (truncation.truncated) {
947
770
  outputText = formatText(truncation.content, startLineDisplay);
948
- details = { truncation };
771
+ details.truncation = truncation;
949
772
  truncationInfo = {
950
773
  result: truncation,
951
774
  options: { direction: "head", startLine: startLineDisplay, totalFileLines: totalLines },
@@ -956,14 +779,9 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
956
779
 
957
780
  outputText = formatText(truncation.content, startLineDisplay);
958
781
  outputText += `\n\n[${remaining} more lines in resource. Use offset=${nextOffset} to continue]`;
959
- details = {};
782
+ details.truncation = truncation;
960
783
  } else {
961
784
  outputText = formatText(truncation.content, startLineDisplay);
962
- details = {};
963
- }
964
-
965
- if (resource.sourcePath) {
966
- details.resolvedPath = resource.sourcePath;
967
785
  }
968
786
 
969
787
  const resultBuilder = toolResult(details).text(outputText).sourceInternal(url);
@@ -1105,8 +923,11 @@ export const readToolRenderer = {
1105
923
  }
1106
924
 
1107
925
  if (imageContent) {
926
+ const suffix = details?.suffixResolution;
927
+ const displayPath = suffix ? shortenPath(suffix.to) : filePath || rawPath || "image";
928
+ const correction = suffix ? ` ${uiTheme.fg("dim", `(corrected from ${shortenPath(suffix.from)})`)}` : "";
1108
929
  const header = renderStatusLine(
1109
- { icon: "success", title: "Read", description: filePath || rawPath || "image" },
930
+ { icon: suffix ? "warning" : "success", title: "Read", description: `${displayPath}${correction}` },
1110
931
  uiTheme,
1111
932
  );
1112
933
  const detailLines = contentText ? contentText.split("\n").map(line => uiTheme.fg("toolOutput", line)) : [];
@@ -1132,7 +953,10 @@ export const readToolRenderer = {
1132
953
  };
1133
954
  }
1134
955
 
1135
- let title = filePath ? `Read ${filePath}` : "Read";
956
+ const suffix = details?.suffixResolution;
957
+ const displayPath = suffix ? shortenPath(suffix.to) : filePath;
958
+ const correction = suffix ? ` ${uiTheme.fg("dim", `(corrected from ${shortenPath(suffix.from)})`)}` : "";
959
+ let title = displayPath ? `Read ${displayPath}${correction}` : "Read";
1136
960
  if (args?.offset !== undefined || args?.limit !== undefined) {
1137
961
  const startLine = args.offset ?? 1;
1138
962
  const endLine = args.limit !== undefined ? startLine + args.limit - 1 : "";
@@ -5,6 +5,27 @@ function stripHtmlComments(content: string): string {
5
5
  return content.replace(/<!--[\s\S]*?-->/g, "");
6
6
  }
7
7
 
8
+ /** Convert kebab-case to camelCase (e.g. "thinking-level" -> "thinkingLevel") */
9
+ function kebabToCamel(key: string): string {
10
+ return key.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
11
+ }
12
+
13
+ /** Recursively normalize object keys from kebab-case to camelCase */
14
+ function normalizeKeys<T>(obj: T): T {
15
+ if (obj === null || typeof obj !== "object") {
16
+ return obj;
17
+ }
18
+ if (Array.isArray(obj)) {
19
+ return obj.map(normalizeKeys) as T;
20
+ }
21
+ const result: Record<string, unknown> = {};
22
+ for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
23
+ const normalizedKey = kebabToCamel(key);
24
+ result[normalizedKey] = normalizeKeys(value);
25
+ }
26
+ return result as T;
27
+ }
28
+
8
29
  export class FrontmatterError extends Error {
9
30
  constructor(
10
31
  error: Error,
@@ -52,7 +73,7 @@ export function parseFrontmatter(
52
73
  ): { frontmatter: Record<string, unknown>; body: string } {
53
74
  const { location, source, fallback, normalize = true, level = "warn" } = options ?? {};
54
75
  const loc = location ?? source;
55
- const frontmatter: Record<string, unknown> = Object.assign({}, fallback);
76
+ const frontmatter: Record<string, unknown> = { ...fallback };
56
77
 
57
78
  const normalized = normalize ? stripHtmlComments(content.replace(/\r\n/g, "\n").replace(/\r/g, "\n")) : content;
58
79
  if (!normalized.startsWith("---")) {
@@ -70,7 +91,7 @@ export function parseFrontmatter(
70
91
  try {
71
92
  // Replace tabs with spaces for YAML compatibility, use failsafe mode for robustness
72
93
  const loaded = YAML.parse(metadata.replaceAll("\t", " ")) as Record<string, unknown> | null;
73
- return { frontmatter: Object.assign(frontmatter, loaded), body: body };
94
+ return { frontmatter: normalizeKeys({ ...frontmatter, ...loaded }), body };
74
95
  } catch (error) {
75
96
  const err = new FrontmatterError(
76
97
  error instanceof Error ? error : new Error(`YAML: ${error}`),
@@ -85,12 +106,12 @@ export function parseFrontmatter(
85
106
 
86
107
  // Simple YAML parsing - just key: value pairs
87
108
  for (const line of metadata.split("\n")) {
88
- const match = line.match(/^(\w+):\s*(.*)$/);
109
+ const match = line.match(/^([\w-]+):\s*(.*)$/);
89
110
  if (match) {
90
111
  frontmatter[match[1]] = match[2].trim();
91
112
  }
92
113
  }
93
114
 
94
- return { frontmatter, body: body };
115
+ return { frontmatter: normalizeKeys(frontmatter) as Record<string, unknown>, body };
95
116
  }
96
117
  }
@@ -67,7 +67,7 @@ export const handleChooseALicense: SpecialHandler = async (
67
67
  const { frontmatter, body } = parseFrontmatter(result.content, { source: rawUrl });
68
68
 
69
69
  const title = asString(frontmatter.title) ?? formatLabel(licenseSlug);
70
- const spdxId = asString(frontmatter["spdx-id"]) ?? "Unknown";
70
+ const spdxId = asString(frontmatter.spdxId) ?? "Unknown";
71
71
  const description = asString(frontmatter.description);
72
72
  const permissions = normalizeList(frontmatter.permissions);
73
73
  const conditions = normalizeList(frontmatter.conditions);