pi-readseek 0.3.22 → 0.3.24
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/package.json +1 -1
- package/src/coerce-obvious-int.ts +21 -10
- package/src/edit-output.ts +24 -6
- package/src/edit.ts +8 -14
- package/src/grep-output.ts +18 -8
- package/src/grep.ts +45 -21
- package/src/hashline.ts +8 -4
- package/src/map-cache.ts +7 -4
- package/src/read.ts +29 -17
- package/src/readseek-client.ts +23 -1
- package/src/refs.ts +9 -26
- package/src/register-tool.ts +47 -0
- package/src/sg.ts +9 -22
- package/src/tui-render-utils.ts +23 -6
- package/src/write.ts +92 -115
package/package.json
CHANGED
|
@@ -1,30 +1,41 @@
|
|
|
1
1
|
const BASE10_INT_RE = /^-?\d+$/;
|
|
2
|
+
const MIN_SAFE_INTEGER = BigInt(Number.MIN_SAFE_INTEGER);
|
|
3
|
+
const MAX_SAFE_INTEGER = BigInt(Number.MAX_SAFE_INTEGER);
|
|
2
4
|
|
|
3
5
|
type CoercedIntResult =
|
|
4
6
|
| { ok: true; value: number | undefined }
|
|
5
7
|
| { ok: false; message: string };
|
|
6
8
|
|
|
9
|
+
function formatReceived(value: unknown): string {
|
|
10
|
+
if (typeof value === "number" || typeof value === "bigint") return String(value);
|
|
11
|
+
return JSON.stringify(value) ?? String(value);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function invalidInteger(value: unknown, name: string): CoercedIntResult {
|
|
15
|
+
return {
|
|
16
|
+
ok: false,
|
|
17
|
+
message: `Invalid ${name}: expected a safe base-10 integer, received ${formatReceived(value)}.`,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
7
21
|
export function coerceObviousBase10Int(value: unknown, name: string): CoercedIntResult {
|
|
8
22
|
if (value === undefined) {
|
|
9
23
|
return { ok: true, value: undefined };
|
|
10
24
|
}
|
|
11
25
|
|
|
12
26
|
if (typeof value === "number") {
|
|
13
|
-
if (Number.
|
|
27
|
+
if (Number.isSafeInteger(value)) {
|
|
14
28
|
return { ok: true, value };
|
|
15
29
|
}
|
|
16
|
-
return
|
|
17
|
-
ok: false,
|
|
18
|
-
message: `Invalid ${name}: expected a base-10 integer, received ${value}.`,
|
|
19
|
-
};
|
|
30
|
+
return invalidInteger(value, name);
|
|
20
31
|
}
|
|
21
32
|
|
|
22
33
|
if (typeof value === "string" && BASE10_INT_RE.test(value)) {
|
|
23
|
-
|
|
34
|
+
const parsed = BigInt(value);
|
|
35
|
+
if (parsed >= MIN_SAFE_INTEGER && parsed <= MAX_SAFE_INTEGER) {
|
|
36
|
+
return { ok: true, value: Number(parsed) };
|
|
37
|
+
}
|
|
24
38
|
}
|
|
25
39
|
|
|
26
|
-
return
|
|
27
|
-
ok: false,
|
|
28
|
-
message: `Invalid ${name}: expected a base-10 integer, received ${JSON.stringify(value)}.`,
|
|
29
|
-
};
|
|
40
|
+
return invalidInteger(value, name);
|
|
30
41
|
}
|
package/src/edit-output.ts
CHANGED
|
@@ -19,6 +19,19 @@ export interface EditOutputResult {
|
|
|
19
19
|
patch: string;
|
|
20
20
|
readseekValue: ReturnType<typeof buildReadseekEditResult>;
|
|
21
21
|
}
|
|
22
|
+
|
|
23
|
+
const EDIT_OPERATION_NAMES = ["set_line", "replace_lines", "insert_after", "replace"] as const;
|
|
24
|
+
|
|
25
|
+
type EditOperationName = (typeof EDIT_OPERATION_NAMES)[number];
|
|
26
|
+
|
|
27
|
+
type EditOperationWithNewText = {
|
|
28
|
+
new_text: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function hasNewText(value: unknown): value is EditOperationWithNewText {
|
|
32
|
+
return typeof value === "object" && value !== null && typeof (value as { new_text?: unknown }).new_text === "string";
|
|
33
|
+
}
|
|
34
|
+
|
|
22
35
|
function getVisibleDiffStats(diff: string): { added: number; removed: number } {
|
|
23
36
|
const stats = parseDiffStats(diff);
|
|
24
37
|
if (stats.added > 0 || stats.removed > 0) return stats;
|
|
@@ -49,10 +62,12 @@ function extractNewTextValues(edits: unknown[] | undefined): string[] {
|
|
|
49
62
|
const values: string[] = [];
|
|
50
63
|
for (const edit of edits ?? []) {
|
|
51
64
|
if (!edit || typeof edit !== "object") continue;
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
65
|
+
|
|
66
|
+
const operations = edit as Partial<Record<EditOperationName, unknown>>;
|
|
67
|
+
for (const operationName of EDIT_OPERATION_NAMES) {
|
|
68
|
+
const operation = operations[operationName];
|
|
69
|
+
if (hasNewText(operation)) values.push(operation.new_text);
|
|
70
|
+
}
|
|
56
71
|
}
|
|
57
72
|
return values;
|
|
58
73
|
}
|
|
@@ -62,10 +77,13 @@ function formatWhitespaceOnlyWarning(semanticSummary: SemanticSummary | undefine
|
|
|
62
77
|
return "⚠ Edit classified as whitespace-only — if you intended a behavior change, re-read to verify.";
|
|
63
78
|
}
|
|
64
79
|
function formatSemanticSuffix(semanticSummary: SemanticSummary | undefined): string {
|
|
65
|
-
|
|
80
|
+
if (!semanticSummary) return "";
|
|
81
|
+
|
|
82
|
+
const movedBlocks = semanticSummary.movedBlocks ?? 0;
|
|
66
83
|
if (movedBlocks <= 0) return "";
|
|
84
|
+
|
|
67
85
|
const blockWord = movedBlocks === 1 ? "block" : "blocks";
|
|
68
|
-
return ` [semantic: ${semanticSummary
|
|
86
|
+
return ` [semantic: ${semanticSummary.classification}, ${movedBlocks} ${blockWord} moved]`;
|
|
69
87
|
}
|
|
70
88
|
function formatReplaceHint(edits: unknown[] | undefined, noopEdits: unknown[]): string | undefined {
|
|
71
89
|
if ((noopEdits ?? []).length > 0) return undefined;
|
package/src/edit.ts
CHANGED
|
@@ -20,9 +20,10 @@ import { resolveSyntaxValidateMode, type SyntaxValidateOptions } from "./syntax-
|
|
|
20
20
|
import { replaceSymbol } from "./replace-symbol.js";
|
|
21
21
|
import { buildEditPreviewKey, buildPendingEditPreviewData, resolvePendingDiffPreview, type PendingDiffPreviewResult } from "./pending-diff-preview.js";
|
|
22
22
|
import { buildDiffData, type DiffBlockRange } from "./diff-data.js";
|
|
23
|
-
import { clampLineToWidth, clampLinesToWidth, linkToolPath, resolveRenderResultContext, summaryLine } from "./tui-render-utils.js";
|
|
23
|
+
import { clampLineToWidth, clampLinesToWidth, linkToolPath, renderPendingResult, resolveRenderResultContext, summaryLine } from "./tui-render-utils.js";
|
|
24
24
|
import { DiffPreviewComponent } from "./tui-diff-component.js";
|
|
25
25
|
import type { FreshAnchorsPredicate } from "./tool-types.js";
|
|
26
|
+
import { registerReadseekTool } from "./register-tool.js";
|
|
26
27
|
|
|
27
28
|
import { resolveEditDiffDisplay } from "./readseek-settings.js";
|
|
28
29
|
|
|
@@ -566,22 +567,17 @@ export async function executeEdit(opts: ExecuteEditOptions): Promise<any> {
|
|
|
566
567
|
|
|
567
568
|
|
|
568
569
|
export function registerEditTool(pi: ExtensionAPI, options: EditToolOptions = {}) {
|
|
569
|
-
const
|
|
570
|
-
|
|
571
|
-
enabled: true,
|
|
572
|
-
policy: "mutating" as const,
|
|
573
|
-
readOnly: false,
|
|
570
|
+
const tool = registerReadseekTool(pi, {
|
|
571
|
+
policy: "mutating",
|
|
574
572
|
pythonName: "edit",
|
|
575
|
-
defaultExposure: "not-safe-by-default"
|
|
576
|
-
}
|
|
577
|
-
const tool = {
|
|
573
|
+
defaultExposure: "not-safe-by-default",
|
|
574
|
+
}, {
|
|
578
575
|
name: "edit",
|
|
579
576
|
label: "Edit",
|
|
580
577
|
description: EDIT_PROMPT_METADATA.description,
|
|
581
578
|
promptSnippet: EDIT_PROMPT_METADATA.promptSnippet,
|
|
582
579
|
promptGuidelines: EDIT_PROMPT_METADATA.promptGuidelines,
|
|
583
580
|
parameters: hashlineEditSchema,
|
|
584
|
-
ptc: toolConfig,
|
|
585
581
|
renderShell: "default" as const,
|
|
586
582
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
587
583
|
return executeEdit({
|
|
@@ -645,7 +641,7 @@ export function registerEditTool(pi: ExtensionAPI, options: EditToolOptions = {}
|
|
|
645
641
|
const { isPartial, isError, expanded: baseExpanded, width, context } = resolveRenderResultContext(options, rest);
|
|
646
642
|
|
|
647
643
|
if (isPartial) {
|
|
648
|
-
return
|
|
644
|
+
return renderPendingResult("pending edit", width);
|
|
649
645
|
}
|
|
650
646
|
|
|
651
647
|
// Extract data from result
|
|
@@ -698,8 +694,6 @@ export function registerEditTool(pi: ExtensionAPI, options: EditToolOptions = {}
|
|
|
698
694
|
}
|
|
699
695
|
return new Text(clampLinesToWidth(text.split("\n"), width).join("\n"), 0, 0);
|
|
700
696
|
},
|
|
701
|
-
}
|
|
702
|
-
|
|
703
|
-
pi.registerTool(tool);
|
|
697
|
+
});
|
|
704
698
|
return tool;
|
|
705
699
|
}
|
package/src/grep-output.ts
CHANGED
|
@@ -47,6 +47,14 @@ export interface GrepOutputGroup {
|
|
|
47
47
|
};
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
type ScopedGrepOutputGroup = GrepOutputGroup & {
|
|
51
|
+
scope: NonNullable<GrepOutputGroup["scope"]>;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
function hasScope(group: GrepOutputGroup): group is ScopedGrepOutputGroup {
|
|
55
|
+
return group.scope !== undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
50
58
|
export interface BuildGrepOutputInput {
|
|
51
59
|
summary: boolean;
|
|
52
60
|
totalMatches: number;
|
|
@@ -100,13 +108,13 @@ function buildScopeMetadata(groups: GrepOutputGroup[], warnings: GrepScopeWarnin
|
|
|
100
108
|
return {
|
|
101
109
|
mode: "symbol" as const,
|
|
102
110
|
groups: groups
|
|
103
|
-
.filter(
|
|
111
|
+
.filter(hasScope)
|
|
104
112
|
.map((group) => ({
|
|
105
113
|
path: group.absolutePath,
|
|
106
114
|
displayPath: group.displayPath,
|
|
107
|
-
symbol: group.scope
|
|
115
|
+
symbol: group.scope.symbol,
|
|
108
116
|
matchCount: group.matchCount,
|
|
109
|
-
matchLines: [...group.scope
|
|
117
|
+
matchLines: [...group.scope.matchLines],
|
|
110
118
|
lineAnchors: group.entries.flatMap((entry) => (entry.kind === "separator" ? [] : [entry.line.anchor])),
|
|
111
119
|
})),
|
|
112
120
|
warnings: [...warnings],
|
|
@@ -132,14 +140,16 @@ export function buildGrepOutput(input: BuildGrepOutputInput): GrepOutputResult {
|
|
|
132
140
|
}
|
|
133
141
|
text = blocks.join("\n");
|
|
134
142
|
}
|
|
135
|
-
|
|
136
|
-
|
|
143
|
+
const passthroughLines = input.passthroughLines ?? [];
|
|
144
|
+
if (passthroughLines.length > 0) {
|
|
145
|
+
text += `\n\n${passthroughLines.join("\n")}`;
|
|
137
146
|
}
|
|
138
147
|
if (input.limit !== undefined && input.totalMatches === input.limit) {
|
|
139
148
|
text += `\n\n[Results truncated at ${input.limit} matches — refine pattern or increase limit]`;
|
|
140
149
|
}
|
|
141
|
-
|
|
142
|
-
|
|
150
|
+
const scopeWarnings = input.scopeWarnings ?? [];
|
|
151
|
+
if (!input.summary && input.scopeMode === "symbol" && scopeWarnings.length > 0) {
|
|
152
|
+
text = `${scopeWarnings.map((warning) => warning.message).join("\n\n")}\n\n${text}`;
|
|
143
153
|
}
|
|
144
154
|
const budget = resolveGrepOutputBudget();
|
|
145
155
|
const truncated = truncateHead(text, {
|
|
@@ -161,7 +171,7 @@ export function buildGrepOutput(input: BuildGrepOutputInput): GrepOutputResult {
|
|
|
161
171
|
})),
|
|
162
172
|
};
|
|
163
173
|
if (!input.summary && input.scopeMode === "symbol") {
|
|
164
|
-
readseekValue.scopes = buildScopeMetadata(input.groups,
|
|
174
|
+
readseekValue.scopes = buildScopeMetadata(input.groups, scopeWarnings);
|
|
165
175
|
}
|
|
166
176
|
|
|
167
177
|
return {
|
package/src/grep.ts
CHANGED
|
@@ -17,8 +17,9 @@ import { throwIfAborted } from "./runtime.js";
|
|
|
17
17
|
import { Text } from "@earendil-works/pi-tui";
|
|
18
18
|
import { formatGrepCallText, formatGrepResultText } from "./grep-render-helpers.js";
|
|
19
19
|
import { coerceObviousBase10Int } from "./coerce-obvious-int.js";
|
|
20
|
-
import { clampLineToWidth, clampLinesToWidth, linkToolPath, renderToolLabel, resolveRenderResultContext, summaryLine } from "./tui-render-utils.js";
|
|
20
|
+
import { clampLineToWidth, clampLinesToWidth, linkToolPath, renderErrorResult, renderPendingResult, renderToolLabel, resolveRenderResultContext, summaryLine } from "./tui-render-utils.js";
|
|
21
21
|
import type { FileAnchoredCallback } from "./tool-types.js";
|
|
22
|
+
import { registerReadseekTool } from "./register-tool.js";
|
|
22
23
|
|
|
23
24
|
const GREP_PROMPT_METADATA = defineToolPromptMetadata({
|
|
24
25
|
promptUrl: new URL("../prompts/grep.md", import.meta.url),
|
|
@@ -313,6 +314,16 @@ export async function executeGrep(opts: ExecuteGrepOptions): Promise<any> {
|
|
|
313
314
|
let candidateUnparsedCount = 0;
|
|
314
315
|
const candidateLinePattern = /^.+(?::|-)\d+(?::|-)\s/;
|
|
315
316
|
|
|
317
|
+
const addSummaryMatch = (displayPath: string, absolutePath: string) => {
|
|
318
|
+
let group = groupsByPath.get(displayPath);
|
|
319
|
+
if (!group) {
|
|
320
|
+
group = { displayPath, absolutePath, matchCount: 0, entries: [] };
|
|
321
|
+
groupsByPath.set(displayPath, group);
|
|
322
|
+
}
|
|
323
|
+
group.matchCount++;
|
|
324
|
+
totalMatches++;
|
|
325
|
+
};
|
|
326
|
+
|
|
316
327
|
const addEntry = (displayPath: string, absolutePath: string, kind: "match" | "context", line: ReadseekLine) => {
|
|
317
328
|
let group = groupsByPath.get(displayPath);
|
|
318
329
|
if (!group) {
|
|
@@ -341,6 +352,12 @@ export async function executeGrep(opts: ExecuteGrepOptions): Promise<any> {
|
|
|
341
352
|
}
|
|
342
353
|
parsedCount++;
|
|
343
354
|
const absolute = toAbsolutePath(parsed.displayPath);
|
|
355
|
+
if (p.summary) {
|
|
356
|
+
if (parsed.kind === "match") {
|
|
357
|
+
addSummaryMatch(parsed.displayPath, absolute);
|
|
358
|
+
}
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
344
361
|
const fileLines = await getFileLines(absolute);
|
|
345
362
|
if (fileLines === undefined) continue;
|
|
346
363
|
// Bare-CR remapping: rg treats the entire bare-CR file as line 1, and the
|
|
@@ -379,6 +396,25 @@ export async function executeGrep(opts: ExecuteGrepOptions): Promise<any> {
|
|
|
379
396
|
});
|
|
380
397
|
}
|
|
381
398
|
|
|
399
|
+
if (p.summary && parsedCount === 0 && candidateUnparsedCount > 0) {
|
|
400
|
+
const passthroughDetails =
|
|
401
|
+
typeof result.details === "object" && result.details !== null
|
|
402
|
+
? (result.details as Record<string, unknown>)
|
|
403
|
+
: {};
|
|
404
|
+
return {
|
|
405
|
+
...result,
|
|
406
|
+
details: {
|
|
407
|
+
...passthroughDetails,
|
|
408
|
+
readseekValue: {
|
|
409
|
+
tool: "grep",
|
|
410
|
+
summary: true,
|
|
411
|
+
totalMatches: 0,
|
|
412
|
+
records: [],
|
|
413
|
+
},
|
|
414
|
+
},
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
|
|
382
418
|
if (parsedCount === 0 && candidateUnparsedCount > 0) {
|
|
383
419
|
const warning =
|
|
384
420
|
"[hashline grep passthrough] Unparsed grep format; returned original output.";
|
|
@@ -405,7 +441,7 @@ export async function executeGrep(opts: ExecuteGrepOptions): Promise<any> {
|
|
|
405
441
|
};
|
|
406
442
|
}
|
|
407
443
|
|
|
408
|
-
const summary = p.summary;
|
|
444
|
+
const summary = !!p.summary;
|
|
409
445
|
const effectiveLimit = typeof p.limit === "number" ? p.limit : 100;
|
|
410
446
|
const groups = [...groupsByPath.values()];
|
|
411
447
|
for (const group of groups) {
|
|
@@ -473,21 +509,15 @@ export async function executeGrep(opts: ExecuteGrepOptions): Promise<any> {
|
|
|
473
509
|
}
|
|
474
510
|
|
|
475
511
|
export function registerGrepTool(pi: ExtensionAPI, options: GrepToolOptions = {}) {
|
|
476
|
-
const
|
|
477
|
-
|
|
478
|
-
enabled: true,
|
|
479
|
-
policy: "read-only" as const,
|
|
480
|
-
readOnly: true,
|
|
512
|
+
const tool = registerReadseekTool(pi, {
|
|
513
|
+
policy: "read-only",
|
|
481
514
|
pythonName: "grep",
|
|
482
|
-
defaultExposure: "safe-by-default"
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
const tool = {
|
|
515
|
+
defaultExposure: "safe-by-default",
|
|
516
|
+
}, {
|
|
486
517
|
name: "grep",
|
|
487
518
|
label: "grep",
|
|
488
519
|
description: GREP_PROMPT_METADATA.description,
|
|
489
520
|
parameters: grepSchema,
|
|
490
|
-
ptc: toolConfig,
|
|
491
521
|
promptSnippet: GREP_PROMPT_METADATA.promptSnippet,
|
|
492
522
|
promptGuidelines: options.searchGuideline
|
|
493
523
|
? [GREP_PROMPT_METADATA.promptGuidelines[0], options.searchGuideline]
|
|
@@ -523,16 +553,12 @@ export function registerGrepTool(pi: ExtensionAPI, options: GrepToolOptions = {}
|
|
|
523
553
|
renderResult(result: any, options: ToolRenderResultOptions, theme: any, ...rest: any[]) {
|
|
524
554
|
const { isPartial, isError, expanded, cwd, width } = resolveRenderResultContext(options, rest);
|
|
525
555
|
|
|
526
|
-
if (isPartial) return
|
|
556
|
+
if (isPartial) return renderPendingResult("pending search", width);
|
|
527
557
|
|
|
528
558
|
const content = result.content?.[0];
|
|
529
559
|
const textContent = content?.type === "text" ? content.text : "";
|
|
530
560
|
|
|
531
|
-
if (isError || result.isError) {
|
|
532
|
-
const firstLine = textContent.split("\n")[0] || "Error";
|
|
533
|
-
const body = expanded && textContent ? textContent : firstLine;
|
|
534
|
-
return new Text(clampLinesToWidth(summaryLine(body).split("\n"), width).join("\n"), 0, 0);
|
|
535
|
-
}
|
|
561
|
+
if (isError || result.isError) return renderErrorResult(textContent, { expanded, width });
|
|
536
562
|
|
|
537
563
|
const readseekValue = (result.details as any)?.readseekValue as {
|
|
538
564
|
tool: "grep";
|
|
@@ -572,8 +598,6 @@ export function registerGrepTool(pi: ExtensionAPI, options: GrepToolOptions = {}
|
|
|
572
598
|
}
|
|
573
599
|
return new Text(clampLinesToWidth(text.split("\n"), width).join("\n"), 0, 0);
|
|
574
600
|
},
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
pi.registerTool(tool);
|
|
601
|
+
});
|
|
578
602
|
return tool;
|
|
579
603
|
}
|
package/src/hashline.ts
CHANGED
|
@@ -71,13 +71,16 @@ interface HashlineGlobalState {
|
|
|
71
71
|
|
|
72
72
|
const HASHLINE_STATE_KEY = Symbol.for("pi-readseek.hashlineState.v1");
|
|
73
73
|
|
|
74
|
+
type HashlineGlobalSlots = typeof globalThis & Record<symbol, HashlineGlobalState | undefined>;
|
|
75
|
+
|
|
74
76
|
function getHashlineState(): HashlineGlobalState {
|
|
75
|
-
const globalObject = globalThis as
|
|
76
|
-
globalObject[HASHLINE_STATE_KEY]
|
|
77
|
+
const globalObject = globalThis as HashlineGlobalSlots;
|
|
78
|
+
const state = globalObject[HASHLINE_STATE_KEY] ?? {
|
|
77
79
|
h32Fn: null,
|
|
78
80
|
initPromise: null,
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
+
};
|
|
82
|
+
globalObject[HASHLINE_STATE_KEY] = state;
|
|
83
|
+
return state;
|
|
81
84
|
}
|
|
82
85
|
|
|
83
86
|
export async function ensureHashInit(): Promise<void> {
|
|
@@ -123,6 +126,7 @@ export function parseLineRef(ref: string): { line: number; hash: string; content
|
|
|
123
126
|
const match = normalized.match(new RegExp(`^(\\d+):([0-9a-fA-F]{${HASH_LEN}})$`));
|
|
124
127
|
if (!match) throw new Error(`Invalid line reference "${ref}". Expected "LINE:HASH" (e.g. "5:abc").`);
|
|
125
128
|
const line = Number.parseInt(match[1], 10);
|
|
129
|
+
if (!Number.isSafeInteger(line)) throw new Error(`Line number must be a safe integer, got ${match[1]} in "${ref}".`);
|
|
126
130
|
if (line < 1) throw new Error(`Line number must be >= 1, got ${line} in "${ref}".`);
|
|
127
131
|
return { line, hash: match[2], content: contentAfterPipe };
|
|
128
132
|
}
|
package/src/map-cache.ts
CHANGED
|
@@ -17,14 +17,17 @@ interface MapCacheGlobalState {
|
|
|
17
17
|
|
|
18
18
|
const MAP_CACHE_STATE_KEY = Symbol.for("pi-readseek.mapCacheState.v1");
|
|
19
19
|
|
|
20
|
+
type MapCacheGlobalSlots = typeof globalThis & Record<symbol, MapCacheGlobalState | undefined>;
|
|
21
|
+
|
|
20
22
|
function getMapCacheState(): MapCacheGlobalState {
|
|
21
|
-
const globalObject = globalThis as
|
|
22
|
-
globalObject[MAP_CACHE_STATE_KEY]
|
|
23
|
+
const globalObject = globalThis as MapCacheGlobalSlots;
|
|
24
|
+
const state = globalObject[MAP_CACHE_STATE_KEY] ?? {
|
|
23
25
|
cache: new Map<string, CacheEntry>(),
|
|
24
26
|
inflight: new Map<string, Promise<FileMap | null>>(),
|
|
25
27
|
maxSize: MAP_CACHE_MAX_SIZE,
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
+
};
|
|
29
|
+
globalObject[MAP_CACHE_STATE_KEY] = state;
|
|
30
|
+
return state;
|
|
28
31
|
}
|
|
29
32
|
|
|
30
33
|
function rememberInMemory(absPath: string, entry: CacheEntry): void {
|
package/src/read.ts
CHANGED
|
@@ -26,8 +26,9 @@ import { coerceObviousBase10Int } from "./coerce-obvious-int.js";
|
|
|
26
26
|
import { readseekRead } from "./readseek-client.js";
|
|
27
27
|
import { Text } from "@earendil-works/pi-tui";
|
|
28
28
|
import { formatReadCallText, formatReadResultText } from "./read-render-helpers.js";
|
|
29
|
-
import { clampLineToWidth, clampLinesToWidth, linkToolPath, renderToolLabel, resolveRenderResultContext, summaryLine, wrapReadHashlinesForWidth } from "./tui-render-utils.js";
|
|
29
|
+
import { clampLineToWidth, clampLinesToWidth, linkToolPath, renderPendingResult, renderToolLabel, resolveRenderResultContext, summaryLine, wrapReadHashlinesForWidth } from "./tui-render-utils.js";
|
|
30
30
|
import type { FileAnchoredCallback } from "./tool-types.js";
|
|
31
|
+
import { registerReadseekTool } from "./register-tool.js";
|
|
31
32
|
|
|
32
33
|
const READ_PROMPT_METADATA = defineToolPromptMetadata({
|
|
33
34
|
promptUrl: new URL("../prompts/read.md", import.meta.url),
|
|
@@ -40,7 +41,7 @@ interface ReadParams {
|
|
|
40
41
|
limit?: number | string;
|
|
41
42
|
symbol?: string;
|
|
42
43
|
map?: boolean;
|
|
43
|
-
bundle?:
|
|
44
|
+
bundle?: string;
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
interface ReadToolOptions {
|
|
@@ -56,6 +57,15 @@ export interface ExecuteReadOptions {
|
|
|
56
57
|
onSuccessfulRead?: FileAnchoredCallback;
|
|
57
58
|
}
|
|
58
59
|
|
|
60
|
+
function hasReadAnchors(result: AgentToolResult<any>): boolean {
|
|
61
|
+
const details = (result as { details?: unknown }).details;
|
|
62
|
+
if (!details || typeof details !== "object") return false;
|
|
63
|
+
const readseekValue = (details as { readseekValue?: unknown }).readseekValue;
|
|
64
|
+
if (!readseekValue || typeof readseekValue !== "object") return false;
|
|
65
|
+
const lines = (readseekValue as { lines?: unknown }).lines;
|
|
66
|
+
return Array.isArray(lines) && lines.length > 0;
|
|
67
|
+
}
|
|
68
|
+
|
|
59
69
|
export async function executeRead(opts: ExecuteReadOptions): Promise<AgentToolResult<any>> {
|
|
60
70
|
const { toolCallId, params, signal, onUpdate, cwd, onSuccessfulRead } = opts;
|
|
61
71
|
await ensureHashInit();
|
|
@@ -76,10 +86,16 @@ export async function executeRead(opts: ExecuteReadOptions): Promise<AgentToolRe
|
|
|
76
86
|
const message = `Invalid offset: expected a positive integer, received ${offset.value}.`;
|
|
77
87
|
return buildToolErrorResult("read", "invalid-offset", message, { path: rawParams.path });
|
|
78
88
|
}
|
|
89
|
+
const rawBundle = typeof rawParams.bundle === "string" ? rawParams.bundle.trim() : undefined;
|
|
90
|
+
const requestedMapViaBundle =
|
|
91
|
+
rawBundle === "map" ||
|
|
92
|
+
(rawBundle === "local" && rawParams.symbol === undefined && rawParams.map !== false);
|
|
79
93
|
const p = {
|
|
80
94
|
...rawParams,
|
|
81
95
|
offset: offset.value,
|
|
82
96
|
limit: limit.value,
|
|
97
|
+
map: rawParams.map === true || requestedMapViaBundle,
|
|
98
|
+
bundle: requestedMapViaBundle ? undefined : rawBundle,
|
|
83
99
|
};
|
|
84
100
|
if (rawParams.symbol !== undefined) {
|
|
85
101
|
const trimmedSymbol = typeof rawParams.symbol === "string" ? rawParams.symbol.trim() : "";
|
|
@@ -93,13 +109,17 @@ export async function executeRead(opts: ExecuteReadOptions): Promise<AgentToolRe
|
|
|
93
109
|
const absolutePath = resolveToCwd(rawPath, cwd);
|
|
94
110
|
const succeed = <T extends AgentToolResult<any>>(result: T): T => {
|
|
95
111
|
const isError = (result as { isError?: boolean }).isError;
|
|
96
|
-
if (!isError) {
|
|
112
|
+
if (!isError && hasReadAnchors(result)) {
|
|
97
113
|
onSuccessfulRead?.(absolutePath);
|
|
98
114
|
}
|
|
99
115
|
return result;
|
|
100
116
|
};
|
|
101
117
|
|
|
102
118
|
throwIfAborted(signal);
|
|
119
|
+
if (p.bundle !== undefined && p.bundle !== "local") {
|
|
120
|
+
const message = `Invalid bundle: expected "local", received ${JSON.stringify(p.bundle)}.`;
|
|
121
|
+
return buildToolErrorResult("read", "invalid-params-combo", message, { path: rawParams.path });
|
|
122
|
+
}
|
|
103
123
|
if (p.symbol && (p.offset !== undefined || p.limit !== undefined)) {
|
|
104
124
|
const message = "Cannot combine symbol with offset/limit. Use one or the other.";
|
|
105
125
|
return buildToolErrorResult("read", "invalid-params-combo", message, { path: rawParams.path });
|
|
@@ -404,16 +424,11 @@ function splitReadseekLines(text: string): string[] {
|
|
|
404
424
|
}
|
|
405
425
|
|
|
406
426
|
export function registerReadTool(pi: ExtensionAPI, options: ReadToolOptions = {}) {
|
|
407
|
-
const
|
|
408
|
-
|
|
409
|
-
enabled: true,
|
|
410
|
-
policy: "read-only" as const,
|
|
411
|
-
readOnly: true,
|
|
427
|
+
const tool = registerReadseekTool(pi, {
|
|
428
|
+
policy: "read-only",
|
|
412
429
|
pythonName: "read",
|
|
413
|
-
defaultExposure: "safe-by-default"
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
const tool = {
|
|
430
|
+
defaultExposure: "safe-by-default",
|
|
431
|
+
}, {
|
|
417
432
|
name: "read",
|
|
418
433
|
label: "Read",
|
|
419
434
|
description: READ_PROMPT_METADATA.description,
|
|
@@ -441,7 +456,6 @@ export function registerReadTool(pi: ExtensionAPI, options: ReadToolOptions = {}
|
|
|
441
456
|
}),
|
|
442
457
|
),
|
|
443
458
|
}),
|
|
444
|
-
ptc: toolConfig,
|
|
445
459
|
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
446
460
|
return executeRead({
|
|
447
461
|
toolCallId,
|
|
@@ -470,7 +484,7 @@ export function registerReadTool(pi: ExtensionAPI, options: ReadToolOptions = {}
|
|
|
470
484
|
},
|
|
471
485
|
renderResult(result: any, options: ToolRenderResultOptions, theme: any, ...rest: any[]) {
|
|
472
486
|
const { isPartial, isError, expanded, width } = resolveRenderResultContext(options, rest);
|
|
473
|
-
if (isPartial) return
|
|
487
|
+
if (isPartial) return renderPendingResult("pending read", width);
|
|
474
488
|
|
|
475
489
|
const content = result.content?.[0];
|
|
476
490
|
const textContent = content?.type === "text" ? content.text : "";
|
|
@@ -497,8 +511,6 @@ export function registerReadTool(pi: ExtensionAPI, options: ReadToolOptions = {}
|
|
|
497
511
|
if (expanded && textContent) text += "\n" + wrapReadHashlinesForWidth(textContent, width);
|
|
498
512
|
return new Text(clampLinesToWidth(text.split("\n"), width).join("\n"), 0, 0);
|
|
499
513
|
},
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
pi.registerTool(tool);
|
|
514
|
+
});
|
|
503
515
|
return tool;
|
|
504
516
|
}
|
package/src/readseek-client.ts
CHANGED
|
@@ -203,6 +203,28 @@ export function isReadseekAvailable(): boolean {
|
|
|
203
203
|
}
|
|
204
204
|
}
|
|
205
205
|
|
|
206
|
+
export interface ReadseekFailure {
|
|
207
|
+
code: "readseek-not-installed" | "readseek-execution-error";
|
|
208
|
+
message: string;
|
|
209
|
+
hint?: string;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Classify an error thrown while invoking readseek into the shared failure
|
|
214
|
+
* taxonomy: a missing binary or package (`readseek-not-installed`, with an
|
|
215
|
+
* install hint) versus any other execution error.
|
|
216
|
+
*/
|
|
217
|
+
export function classifyReadseekFailure(err: unknown): ReadseekFailure {
|
|
218
|
+
const message = String((err as { message?: unknown } | null)?.message || err);
|
|
219
|
+
const missing =
|
|
220
|
+
(err as { code?: unknown } | null)?.code === "ENOENT" ||
|
|
221
|
+
/Cannot find package|Cannot find module|no such file/i.test(message);
|
|
222
|
+
if (missing) {
|
|
223
|
+
return { code: "readseek-not-installed", message, hint: "Run npm install to install @jarkkojs/readseek." };
|
|
224
|
+
}
|
|
225
|
+
return { code: "readseek-execution-error", message };
|
|
226
|
+
}
|
|
227
|
+
|
|
206
228
|
function directoryExists(dirPath: string): boolean {
|
|
207
229
|
try {
|
|
208
230
|
return statSync(dirPath).isDirectory();
|
|
@@ -332,7 +354,7 @@ async function runReadseek(args: string[], options: RunReadseekOptions = {}): Pr
|
|
|
332
354
|
}
|
|
333
355
|
|
|
334
356
|
function requireNumber(value: unknown, field: string): number {
|
|
335
|
-
if (typeof value !== "number" || !Number.
|
|
357
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value)) throw new Error(`invalid readseek ${field}: expected safe integer`);
|
|
336
358
|
return value;
|
|
337
359
|
}
|
|
338
360
|
|
package/src/refs.ts
CHANGED
|
@@ -6,9 +6,10 @@ import { stat as fsStat } from "node:fs/promises";
|
|
|
6
6
|
import { defineToolPromptMetadata } from "./tool-prompt-metadata.js";
|
|
7
7
|
import { buildReadseekLineWithHash, buildToolErrorResult } from "./readseek-value.js";
|
|
8
8
|
import { resolveToCwd } from "./path-utils.js";
|
|
9
|
-
import {
|
|
9
|
+
import { classifyReadseekFailure, readseekRefs, type ReadseekReference } from "./readseek-client.js";
|
|
10
10
|
import { buildRefsOutput, type RefsOutputFile, type RefsOutputLine } from "./refs-output.js";
|
|
11
11
|
import type { FileAnchoredCallback } from "./tool-types.js";
|
|
12
|
+
import { registerReadseekTool } from "./register-tool.js";
|
|
12
13
|
|
|
13
14
|
import { clampLineToWidth, renderAnchoredFilesResult, renderToolLabel } from "./tui-render-utils.js";
|
|
14
15
|
|
|
@@ -29,10 +30,6 @@ const REFS_PROMPT_METADATA = defineToolPromptMetadata({
|
|
|
29
30
|
promptSnippet: "Find references to an identifier with readseek and return edit-ready anchors",
|
|
30
31
|
});
|
|
31
32
|
|
|
32
|
-
export function isRefsAvailable(): boolean {
|
|
33
|
-
return isReadseekAvailable();
|
|
34
|
-
}
|
|
35
|
-
|
|
36
33
|
interface RefsToolOptions {
|
|
37
34
|
onFileAnchored?: FileAnchoredCallback;
|
|
38
35
|
}
|
|
@@ -127,28 +124,17 @@ export async function executeRefs(opts: ExecuteRefsOptions): Promise<any> {
|
|
|
127
124
|
details: { readseekValue: builtOutput.readseekValue },
|
|
128
125
|
};
|
|
129
126
|
} catch (err: any) {
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
return buildToolErrorResult(
|
|
133
|
-
"refs",
|
|
134
|
-
missingReadseek ? "readseek-not-installed" : "readseek-execution-error",
|
|
135
|
-
message,
|
|
136
|
-
missingReadseek ? { hint: "Run npm install to install @jarkkojs/readseek." } : {},
|
|
137
|
-
);
|
|
127
|
+
const failure = classifyReadseekFailure(err);
|
|
128
|
+
return buildToolErrorResult("refs", failure.code, failure.message, failure.hint ? { hint: failure.hint } : {});
|
|
138
129
|
}
|
|
139
130
|
}
|
|
140
131
|
|
|
141
132
|
export function registerRefsTool(pi: ExtensionAPI, options: RefsToolOptions = {}) {
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
enabled: true,
|
|
145
|
-
policy: "read-only" as const,
|
|
146
|
-
readOnly: true,
|
|
133
|
+
const tool = registerReadseekTool(pi, {
|
|
134
|
+
policy: "read-only",
|
|
147
135
|
pythonName: "refs",
|
|
148
|
-
defaultExposure: "opt-in"
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
const tool = {
|
|
136
|
+
defaultExposure: "opt-in",
|
|
137
|
+
}, {
|
|
152
138
|
name: "refs",
|
|
153
139
|
label: "References",
|
|
154
140
|
description: REFS_PROMPT_METADATA.description,
|
|
@@ -165,7 +151,6 @@ export function registerRefsTool(pi: ExtensionAPI, options: RefsToolOptions = {}
|
|
|
165
151
|
others: Type.Optional(Type.Boolean({ description: "In a Git repository, search untracked files" })),
|
|
166
152
|
ignored: Type.Optional(Type.Boolean({ description: "With others=true, include ignored untracked files" })),
|
|
167
153
|
}),
|
|
168
|
-
ptc: toolConfig,
|
|
169
154
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
170
155
|
return executeRefs({ params, signal, cwd: ctx.cwd, onFileAnchored: options.onFileAnchored });
|
|
171
156
|
},
|
|
@@ -186,8 +171,6 @@ export function registerRefsTool(pi: ExtensionAPI, options: RefsToolOptions = {}
|
|
|
186
171
|
unitPlural: "references",
|
|
187
172
|
});
|
|
188
173
|
},
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
pi.registerTool(tool);
|
|
174
|
+
});
|
|
192
175
|
return tool;
|
|
193
176
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
export type ReadseekToolPolicy = "read-only" | "mutating";
|
|
4
|
+
|
|
5
|
+
export type ReadseekToolExposure = "safe-by-default" | "opt-in" | "not-safe-by-default";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Per-tool fields of the pi tool config (`ptc`) that genuinely differ between
|
|
9
|
+
* readseek tools. The remaining fields are constant or derived.
|
|
10
|
+
*/
|
|
11
|
+
export interface ReadseekToolConfig {
|
|
12
|
+
policy: ReadseekToolPolicy;
|
|
13
|
+
pythonName: string;
|
|
14
|
+
defaultExposure: ReadseekToolExposure;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ReadseekToolPtc extends ReadseekToolConfig {
|
|
18
|
+
callable: true;
|
|
19
|
+
enabled: true;
|
|
20
|
+
readOnly: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type ToolSpec = Parameters<ExtensionAPI["registerTool"]>[0];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Attach the standard `ptc` envelope to a tool definition and register it.
|
|
27
|
+
*
|
|
28
|
+
* `callable` and `enabled` are always true and `readOnly` is derived from
|
|
29
|
+
* `policy`, so callers supply only the fields that vary between tools.
|
|
30
|
+
*/
|
|
31
|
+
export function registerReadseekTool<T extends ToolSpec>(
|
|
32
|
+
pi: ExtensionAPI,
|
|
33
|
+
config: ReadseekToolConfig,
|
|
34
|
+
tool: T,
|
|
35
|
+
): T & { ptc: ReadseekToolPtc } {
|
|
36
|
+
const ptc: ReadseekToolPtc = {
|
|
37
|
+
callable: true,
|
|
38
|
+
enabled: true,
|
|
39
|
+
policy: config.policy,
|
|
40
|
+
readOnly: config.policy === "read-only",
|
|
41
|
+
pythonName: config.pythonName,
|
|
42
|
+
defaultExposure: config.defaultExposure,
|
|
43
|
+
};
|
|
44
|
+
const registered = { ...tool, ptc };
|
|
45
|
+
pi.registerTool(registered);
|
|
46
|
+
return registered;
|
|
47
|
+
}
|
package/src/sg.ts
CHANGED
|
@@ -6,9 +6,10 @@ import { stat as fsStat } from "node:fs/promises";
|
|
|
6
6
|
import { defineToolPromptMetadata } from "./tool-prompt-metadata.js";
|
|
7
7
|
import { buildReadseekLineWithHash, buildToolErrorResult, type ReadseekLine } from "./readseek-value.js";
|
|
8
8
|
import { resolveToCwd } from "./path-utils.js";
|
|
9
|
-
import { isReadseekAvailable, readseekSearch, type ReadseekHashline, type ReadseekSearchFileOutput } from "./readseek-client.js";
|
|
9
|
+
import { classifyReadseekFailure, isReadseekAvailable, readseekSearch, type ReadseekHashline, type ReadseekSearchFileOutput } from "./readseek-client.js";
|
|
10
10
|
import { buildSgOutput } from "./sg-output.js";
|
|
11
11
|
import type { FileAnchoredCallback } from "./tool-types.js";
|
|
12
|
+
import { registerReadseekTool } from "./register-tool.js";
|
|
12
13
|
|
|
13
14
|
import { clampLineToWidth, renderAnchoredFilesResult, renderToolLabel } from "./tui-render-utils.js";
|
|
14
15
|
|
|
@@ -193,28 +194,17 @@ export async function executeSg(opts: ExecuteSgOptions): Promise<any> {
|
|
|
193
194
|
},
|
|
194
195
|
};
|
|
195
196
|
} catch (err: any) {
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
return buildToolErrorResult(
|
|
199
|
-
"search",
|
|
200
|
-
missingReadseek ? "readseek-not-installed" : "readseek-execution-error",
|
|
201
|
-
message,
|
|
202
|
-
missingReadseek ? { hint: "Run npm install to install @jarkkojs/readseek." } : {},
|
|
203
|
-
);
|
|
197
|
+
const failure = classifyReadseekFailure(err);
|
|
198
|
+
return buildToolErrorResult("search", failure.code, failure.message, failure.hint ? { hint: failure.hint } : {});
|
|
204
199
|
}
|
|
205
200
|
}
|
|
206
201
|
|
|
207
202
|
export function registerSgTool(pi: ExtensionAPI, options: SgToolOptions = {}) {
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
enabled: true,
|
|
211
|
-
policy: "read-only" as const,
|
|
212
|
-
readOnly: true,
|
|
203
|
+
const tool = registerReadseekTool(pi, {
|
|
204
|
+
policy: "read-only",
|
|
213
205
|
pythonName: "search",
|
|
214
|
-
defaultExposure: "opt-in"
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
const tool = {
|
|
206
|
+
defaultExposure: "opt-in",
|
|
207
|
+
}, {
|
|
218
208
|
name: "search",
|
|
219
209
|
label: "Structural Search",
|
|
220
210
|
description: SG_PROMPT_METADATA.description,
|
|
@@ -228,7 +218,6 @@ export function registerSgTool(pi: ExtensionAPI, options: SgToolOptions = {}) {
|
|
|
228
218
|
others: Type.Optional(Type.Boolean({ description: "In a Git repository, search untracked files" })),
|
|
229
219
|
ignored: Type.Optional(Type.Boolean({ description: "With others=true, include ignored untracked files" })),
|
|
230
220
|
}),
|
|
231
|
-
ptc: toolConfig,
|
|
232
221
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
233
222
|
return executeSg({ params, signal, cwd: ctx.cwd, onFileAnchored: options.onFileAnchored });
|
|
234
223
|
},
|
|
@@ -249,8 +238,6 @@ export function registerSgTool(pi: ExtensionAPI, options: SgToolOptions = {}) {
|
|
|
249
238
|
unitPlural: "matches",
|
|
250
239
|
});
|
|
251
240
|
},
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
pi.registerTool(tool);
|
|
241
|
+
});
|
|
255
242
|
return tool;
|
|
256
243
|
}
|
package/src/tui-render-utils.ts
CHANGED
|
@@ -156,6 +156,27 @@ export function wrapReadHashlinesForWidth(text: string, width: number | undefine
|
|
|
156
156
|
return output.join("\n");
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Render the `isPartial` placeholder line shared by every tool's `renderResult`.
|
|
161
|
+
*/
|
|
162
|
+
export function renderPendingResult(pendingLabel: string, width: number | undefined): Text {
|
|
163
|
+
return new Text(clampLinesToWidth([summaryLine(pendingLabel)], width).join("\n"), 0, 0);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Render a tool result error: the first line of `textContent`, or its full body
|
|
168
|
+
* when expanded, prefixed and clamped to width. Tools pass their own `fallback`
|
|
169
|
+
* for the empty-content case.
|
|
170
|
+
*/
|
|
171
|
+
export function renderErrorResult(
|
|
172
|
+
textContent: string,
|
|
173
|
+
options: { expanded: boolean; width: number | undefined; fallback?: string },
|
|
174
|
+
): Text {
|
|
175
|
+
const firstLine = textContent.split("\n")[0] || (options.fallback ?? "Error");
|
|
176
|
+
const body = options.expanded && textContent ? textContent : firstLine;
|
|
177
|
+
return new Text(clampLinesToWidth(summaryLine(body).split("\n"), options.width).join("\n"), 0, 0);
|
|
178
|
+
}
|
|
179
|
+
|
|
159
180
|
export interface AnchoredFilesLabels {
|
|
160
181
|
pendingLabel: string;
|
|
161
182
|
emptyLabel: string;
|
|
@@ -178,15 +199,11 @@ export function renderAnchoredFilesResult(
|
|
|
178
199
|
): Text {
|
|
179
200
|
const { isPartial, isError, expanded, cwd, width } = resolveRenderResultContext(options, rest);
|
|
180
201
|
|
|
181
|
-
if (isPartial) return
|
|
202
|
+
if (isPartial) return renderPendingResult(labels.pendingLabel, width);
|
|
182
203
|
|
|
183
204
|
const content = result.content?.[0];
|
|
184
205
|
const textContent = content?.type === "text" ? content.text : "";
|
|
185
|
-
if (isError || result.isError) {
|
|
186
|
-
const firstLine = textContent.split("\n")[0] || "Error";
|
|
187
|
-
const body = expanded && textContent ? textContent : firstLine;
|
|
188
|
-
return new Text(clampLinesToWidth(summaryLine(body).split("\n"), width).join("\n"), 0, 0);
|
|
189
|
-
}
|
|
206
|
+
if (isError || result.isError) return renderErrorResult(textContent, { expanded, width });
|
|
190
207
|
|
|
191
208
|
const readseekValue = (result.details as any)?.readseekValue as
|
|
192
209
|
| { files: Array<{ path: string; lines: any[] }> }
|
package/src/write.ts
CHANGED
|
@@ -14,9 +14,10 @@ import { defineToolPromptMetadata } from "./tool-prompt-metadata.js";
|
|
|
14
14
|
import { buildPendingWritePreviewData, buildWritePreviewKey, resolvePendingDiffPreview, type PendingDiffPreviewResult } from "./pending-diff-preview.js";
|
|
15
15
|
import { generateCompactOrFullDiff, normalizeToLF, hasBareCarriageReturn } from "./edit-diff.js";
|
|
16
16
|
import { buildDiffData, type DiffData } from "./diff-data.js";
|
|
17
|
-
import { clampLineToWidth, clampLinesToWidth, isRendererExpanded, linkToolPath, renderToolLabel, summaryLine } from "./tui-render-utils.js";
|
|
17
|
+
import { clampLineToWidth, clampLinesToWidth, isRendererExpanded, linkToolPath, renderErrorResult, renderToolLabel, summaryLine } from "./tui-render-utils.js";
|
|
18
18
|
import { DiffPreviewComponent } from "./tui-diff-component.js";
|
|
19
19
|
import type { FileAnchoredCallback } from "./tool-types.js";
|
|
20
|
+
import { registerReadseekTool } from "./register-tool.js";
|
|
20
21
|
|
|
21
22
|
const WRITE_PENDING_PREVIEW_STATE_KEY = "hashline-write-pending-preview";
|
|
22
23
|
|
|
@@ -169,6 +170,30 @@ function mapFsWriteError(err: any, path: string): MappedFsError {
|
|
|
169
170
|
};
|
|
170
171
|
}
|
|
171
172
|
|
|
173
|
+
function buildWriteFsErrorResult(err: any, absolutePath: string) {
|
|
174
|
+
const mapped = mapFsWriteError(err, absolutePath);
|
|
175
|
+
return {
|
|
176
|
+
content: [{ type: "text" as const, text: mapped.message }],
|
|
177
|
+
isError: true,
|
|
178
|
+
details: {
|
|
179
|
+
readseekValue: {
|
|
180
|
+
tool: "write" as const,
|
|
181
|
+
path: absolutePath,
|
|
182
|
+
lines: [] as ReadseekLine[],
|
|
183
|
+
warnings: [] as ReadseekWarning[],
|
|
184
|
+
ok: false,
|
|
185
|
+
error: buildReadseekError(
|
|
186
|
+
mapped.code,
|
|
187
|
+
mapped.message,
|
|
188
|
+
undefined,
|
|
189
|
+
mapped.includeMeta ? { fsCode: err?.code, fsMessage: err?.message } : undefined,
|
|
190
|
+
),
|
|
191
|
+
},
|
|
192
|
+
warnings: [] as string[],
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
172
197
|
export async function executeWrite(opts: {
|
|
173
198
|
path: string;
|
|
174
199
|
content: string;
|
|
@@ -301,21 +326,16 @@ export async function executeWrite(opts: {
|
|
|
301
326
|
}
|
|
302
327
|
|
|
303
328
|
export function registerWriteTool(pi: ExtensionAPI, options: WriteToolOptions = {}) {
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
enabled: true,
|
|
307
|
-
policy: "mutating" as const,
|
|
308
|
-
readOnly: false,
|
|
329
|
+
const tool = registerReadseekTool(pi, {
|
|
330
|
+
policy: "mutating",
|
|
309
331
|
pythonName: "write",
|
|
310
|
-
defaultExposure: "not-safe-by-default"
|
|
311
|
-
}
|
|
312
|
-
const tool = {
|
|
332
|
+
defaultExposure: "not-safe-by-default",
|
|
333
|
+
}, {
|
|
313
334
|
name: "write",
|
|
314
335
|
label: "write",
|
|
315
336
|
description: WRITE_PROMPT_METADATA.description,
|
|
316
337
|
promptSnippet: WRITE_PROMPT_METADATA.promptSnippet,
|
|
317
338
|
promptGuidelines: WRITE_PROMPT_METADATA.promptGuidelines,
|
|
318
|
-
ptc: toolConfig,
|
|
319
339
|
parameters: Type.Object({
|
|
320
340
|
path: Type.String({ description: "File path" }),
|
|
321
341
|
content: Type.String({ description: "File content" }),
|
|
@@ -325,112 +345,72 @@ export function registerWriteTool(pi: ExtensionAPI, options: WriteToolOptions =
|
|
|
325
345
|
const cwd = ctx?.cwd ?? process.cwd();
|
|
326
346
|
const absolutePath = resolveToCwd(params.path, cwd);
|
|
327
347
|
try {
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
path: absolutePath,
|
|
333
|
-
content: params.content,
|
|
334
|
-
map: params.map,
|
|
335
|
-
cwd,
|
|
336
|
-
});
|
|
337
|
-
} catch (err: any) {
|
|
338
|
-
const mapped = mapFsWriteError(err, absolutePath);
|
|
339
|
-
return {
|
|
340
|
-
content: [{ type: "text" as const, text: mapped.message }],
|
|
341
|
-
isError: true,
|
|
342
|
-
details: {
|
|
343
|
-
readseekValue: {
|
|
344
|
-
tool: "write" as const,
|
|
348
|
+
return await withFileMutationQueue(absolutePath, async () => {
|
|
349
|
+
let result: WriteResult;
|
|
350
|
+
try {
|
|
351
|
+
result = await executeWrite({
|
|
345
352
|
path: absolutePath,
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
mapped.includeMeta ? { fsCode: err?.code, fsMessage: err?.message } : undefined,
|
|
354
|
-
),
|
|
355
|
-
},
|
|
356
|
-
warnings: [] as string[],
|
|
357
|
-
},
|
|
358
|
-
};
|
|
359
|
-
}
|
|
353
|
+
content: params.content,
|
|
354
|
+
map: params.map,
|
|
355
|
+
cwd,
|
|
356
|
+
});
|
|
357
|
+
} catch (err: any) {
|
|
358
|
+
return buildWriteFsErrorResult(err, absolutePath);
|
|
359
|
+
}
|
|
360
360
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
361
|
+
if (result.readseekValue.lines.length > 0) {
|
|
362
|
+
options.onFileAnchored?.(absolutePath);
|
|
363
|
+
}
|
|
364
364
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
365
|
+
// Lift binary-content signal into a fatal readseekValue.error envelope so
|
|
366
|
+
// downstream consumers get the same taxonomy shape as every other tool.
|
|
367
|
+
// The existing ReadseekWarning entry is preserved on readseekValue.warnings for
|
|
368
|
+
// backward compatibility (see AC 12 — warnings namespace alignment).
|
|
369
|
+
const binaryWarning = result.readseekValue.warnings.find((w) => w.code === "binary-content");
|
|
370
|
+
if (binaryWarning) {
|
|
371
|
+
return {
|
|
372
|
+
content: [{ type: "text" as const, text: result.text }],
|
|
373
|
+
isError: true,
|
|
374
|
+
details: {
|
|
375
|
+
readseekValue: {
|
|
376
|
+
...result.readseekValue,
|
|
377
|
+
ok: false,
|
|
378
|
+
error: buildReadseekError("binary-content", binaryWarning.message),
|
|
379
|
+
},
|
|
380
|
+
warnings: result.warnings,
|
|
381
|
+
},
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
384
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
385
|
+
const bareCrWarning = result.readseekValue.warnings.find((w) => w.code === "bare-cr");
|
|
386
|
+
if (bareCrWarning) {
|
|
387
|
+
return {
|
|
388
|
+
content: [{ type: "text" as const, text: result.text }],
|
|
389
|
+
isError: true,
|
|
390
|
+
details: {
|
|
391
|
+
readseekValue: {
|
|
392
|
+
...result.readseekValue,
|
|
393
|
+
ok: false,
|
|
394
|
+
error: buildReadseekError("bare-cr", bareCrWarning.message),
|
|
395
|
+
},
|
|
396
|
+
warnings: result.warnings,
|
|
397
|
+
},
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
400
|
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
},
|
|
410
|
-
};
|
|
411
|
-
});
|
|
412
|
-
} catch (err: any) {
|
|
413
|
-
const mapped = mapFsWriteError(err, absolutePath);
|
|
414
|
-
return {
|
|
415
|
-
content: [{ type: "text" as const, text: mapped.message }],
|
|
416
|
-
isError: true,
|
|
417
|
-
details: {
|
|
418
|
-
readseekValue: {
|
|
419
|
-
tool: "write" as const,
|
|
420
|
-
path: absolutePath,
|
|
421
|
-
lines: [] as ReadseekLine[],
|
|
422
|
-
warnings: [] as ReadseekWarning[],
|
|
423
|
-
ok: false,
|
|
424
|
-
error: buildReadseekError(
|
|
425
|
-
mapped.code,
|
|
426
|
-
mapped.message,
|
|
427
|
-
undefined,
|
|
428
|
-
mapped.includeMeta ? { fsCode: err?.code, fsMessage: err?.message } : undefined,
|
|
429
|
-
),
|
|
401
|
+
return {
|
|
402
|
+
content: [{ type: "text" as const, text: result.text }],
|
|
403
|
+
details: {
|
|
404
|
+
...(result.diff !== undefined ? { diff: result.diff } : {}),
|
|
405
|
+
...(result.diffData !== undefined ? { diffData: result.diffData } : {}),
|
|
406
|
+
...(result.writeState ? { writeState: result.writeState } : {}),
|
|
407
|
+
readseekValue: result.readseekValue,
|
|
408
|
+
warnings: result.warnings,
|
|
430
409
|
},
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
410
|
+
};
|
|
411
|
+
});
|
|
412
|
+
} catch (err: any) {
|
|
413
|
+
return buildWriteFsErrorResult(err, absolutePath);
|
|
434
414
|
}
|
|
435
415
|
},
|
|
436
416
|
renderCall(args: any, theme: any, context: any = {}) {
|
|
@@ -478,9 +458,7 @@ export function registerWriteTool(pi: ExtensionAPI, options: WriteToolOptions =
|
|
|
478
458
|
const details = result.details ?? {};
|
|
479
459
|
const output = result.content?.[0]?.type === "text" ? result.content[0].text : "";
|
|
480
460
|
if (result.isError || details.readseekValue?.ok === false) {
|
|
481
|
-
|
|
482
|
-
const body = expanded && output ? output : firstLine;
|
|
483
|
-
return new Text(clampLinesToWidth(summaryLine(body).split("\n"), width).join("\n"), 0, 0);
|
|
461
|
+
return renderErrorResult(output, { expanded, width, fallback: "write failed" });
|
|
484
462
|
}
|
|
485
463
|
const diffData = details.diffData;
|
|
486
464
|
const state = details.writeState === "overwritten" ? "overwritten" : "created";
|
|
@@ -510,7 +488,6 @@ export function registerWriteTool(pi: ExtensionAPI, options: WriteToolOptions =
|
|
|
510
488
|
}
|
|
511
489
|
return new Text(clampLinesToWidth(text.split("\n"), width).join("\n"), 0, 0);
|
|
512
490
|
},
|
|
513
|
-
}
|
|
514
|
-
pi.registerTool(tool);
|
|
491
|
+
});
|
|
515
492
|
return tool;
|
|
516
493
|
}
|