@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.
- package/CHANGELOG.md +52 -0
- package/package.json +7 -7
- package/src/capability/rule.ts +0 -4
- package/src/cli/agents-cli.ts +1 -1
- package/src/cli/args.ts +7 -12
- package/src/commands/launch.ts +3 -2
- package/src/config/model-resolver.ts +106 -33
- package/src/config/settings-schema.ts +14 -2
- package/src/config/settings.ts +1 -17
- package/src/discovery/helpers.ts +10 -17
- package/src/export/html/template.generated.ts +1 -1
- package/src/export/html/template.js +37 -15
- package/src/extensibility/extensions/loader.ts +1 -2
- package/src/extensibility/extensions/types.ts +2 -1
- package/src/main.ts +20 -13
- package/src/modes/components/agent-dashboard.ts +12 -13
- package/src/modes/components/model-selector.ts +157 -59
- package/src/modes/components/read-tool-group.ts +36 -2
- package/src/modes/components/settings-defs.ts +11 -8
- package/src/modes/components/settings-selector.ts +1 -1
- package/src/modes/components/thinking-selector.ts +3 -15
- package/src/modes/controllers/selector-controller.ts +21 -7
- package/src/modes/rpc/rpc-client.ts +2 -2
- package/src/modes/rpc/rpc-types.ts +2 -2
- package/src/modes/theme/theme.ts +2 -1
- package/src/patch/hashline.ts +26 -3
- package/src/patch/index.ts +14 -16
- package/src/prompts/tools/read.md +2 -2
- package/src/sdk.ts +21 -29
- package/src/session/agent-session.ts +44 -37
- package/src/task/executor.ts +10 -8
- package/src/task/types.ts +1 -2
- package/src/tools/read.ts +88 -264
- package/src/utils/frontmatter.ts +25 -4
- 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 {
|
|
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
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
async function
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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:
|
|
385
|
-
fileType:
|
|
285
|
+
pattern: `**/${normalized}`,
|
|
286
|
+
path: cwd,
|
|
287
|
+
// No fileType filter: matches both files and directories
|
|
386
288
|
hidden: true,
|
|
387
289
|
}),
|
|
388
290
|
);
|
|
389
|
-
|
|
291
|
+
matches = result.matches.map(m => m.path);
|
|
390
292
|
} catch (error) {
|
|
391
293
|
if (error instanceof Error && error.name === "AbortError") {
|
|
392
|
-
if (
|
|
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 (
|
|
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
|
-
|
|
489
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
725
|
-
const
|
|
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 =
|
|
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 (
|
|
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 =
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
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 : "";
|
package/src/utils/frontmatter.ts
CHANGED
|
@@ -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> =
|
|
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:
|
|
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,
|
|
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
|
|
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);
|