opencode-readseek 0.8.24 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -17
- package/index.ts +370 -271
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
<img alt="OpenCode running opencode-readseek" src="screenshot.png" width="800">
|
|
4
4
|
|
|
5
5
|
`opencode-readseek` adds anchored file operations, structural search, symbol
|
|
6
|
-
navigation, and
|
|
6
|
+
navigation, and document tools to OpenCode.
|
|
7
7
|
|
|
8
8
|
## Install
|
|
9
9
|
|
|
@@ -20,20 +20,18 @@ OpenCode installs the package and its platform-specific
|
|
|
20
20
|
|
|
21
21
|
## Tools
|
|
22
22
|
|
|
23
|
-
| Tool
|
|
24
|
-
|
|
|
25
|
-
| `readseek_read`
|
|
26
|
-
| `readseek_edit`
|
|
27
|
-
| `readseek_write`
|
|
28
|
-
| `readseek_grep`
|
|
29
|
-
| `
|
|
30
|
-
| `readseek_search`
|
|
31
|
-
| `readseek_def`
|
|
32
|
-
| `readseek_refs`
|
|
33
|
-
| `
|
|
34
|
-
| `
|
|
35
|
-
| `readseek_check` | Report parser errors and missing syntax |
|
|
36
|
-
| `readseek_view` | View or narrow a structural PDF index |
|
|
23
|
+
| Tool | Purpose |
|
|
24
|
+
| ------------------ | ----------------------------------------------------------------------- |
|
|
25
|
+
| `readseek_read` | Read text with `LINE:HASH` anchors; read standalone images |
|
|
26
|
+
| `readseek_edit` | Apply hash-verified edits to an existing text file |
|
|
27
|
+
| `readseek_write` | Create or replace a complete text file |
|
|
28
|
+
| `readseek_grep` | Search text or regex and return anchors |
|
|
29
|
+
| `readseek_digest` | Native digest envelope: metadata, content, map, diagnostics, identity |
|
|
30
|
+
| `readseek_search` | Search AST patterns |
|
|
31
|
+
| `readseek_def` | Find symbol definitions |
|
|
32
|
+
| `readseek_refs` | Find identifier usages, optionally scoped to a cursor binding |
|
|
33
|
+
| `readseek_rename` | Apply a binding-aware rename; set `apply: false` for a dry run |
|
|
34
|
+
| `readseek_view` | View or narrow a structural document index |
|
|
37
35
|
|
|
38
36
|
The plugin requests read, grep, external-directory, and edit permissions as
|
|
39
37
|
needed. Mutations invalidate remembered anchors; compaction preserves fresh
|
|
@@ -44,6 +42,9 @@ OpenCode is instructed to prefer ReadSeek over the built-in `read`, `edit`,
|
|
|
44
42
|
`write`, `grep`, and `apply_patch` tools when ReadSeek can do the job. Built-ins
|
|
45
43
|
remain available as fallbacks.
|
|
46
44
|
|
|
45
|
+
Documents such as PDFs are owned by `readseek_view`. Calling `readseek_read` on
|
|
46
|
+
a document returns a cache receipt and directs the caller to `readseek_view`.
|
|
47
|
+
|
|
47
48
|
## Configuration
|
|
48
49
|
|
|
49
50
|
Pass options with OpenCode's plugin tuple syntax:
|
|
@@ -58,9 +59,11 @@ Pass options with OpenCode's plugin tuple syntax:
|
|
|
58
59
|
|
|
59
60
|
- `"auto"` (default): expose `none`, `all`, `ocr`, `caption`, and `objects`.
|
|
60
61
|
- `"on"`: expose local analysis modes and omit `none`.
|
|
61
|
-
- `"off"`: skip image
|
|
62
|
+
- `"off"`: skip image reads.
|
|
62
63
|
|
|
63
|
-
Omitting `image` from a read also skips
|
|
64
|
+
Omitting `image` from a read also skips image files. Document asset analysis
|
|
65
|
+
uses `readseek_view` with optional `visionMode` (`all` | `ocr` | `caption` |
|
|
66
|
+
`objects`) and `visionLevel` (`low` | `medium` | `high`).
|
|
64
67
|
|
|
65
68
|
## Development
|
|
66
69
|
|
package/index.ts
CHANGED
|
@@ -18,10 +18,10 @@ const TOOL_POLICY = [
|
|
|
18
18
|
"ReadSeek tool policy:",
|
|
19
19
|
"- Prefer readseek_* tools over built-ins when they can do the job.",
|
|
20
20
|
"- Read with readseek_read first; readseek_edit needs fresh LINE:HASH anchors.",
|
|
21
|
-
"- Use readseek_grep for text/regex, readseek_search for AST patterns, and
|
|
21
|
+
"- Use readseek_grep for text/regex, readseek_search for AST patterns, and readseek_digest/def/refs for symbol navigation.",
|
|
22
22
|
"- Use readseek_edit for existing files, readseek_write for whole-file creation or replacement, and readseek_rename for symbol renames.",
|
|
23
23
|
"- Do not use built-in read, edit, write, grep, or apply_patch when a ReadSeek tool can do the job.",
|
|
24
|
-
"- Run
|
|
24
|
+
"- Run readseek_digest with select=diagnostics after source edits for a quick syntax check.",
|
|
25
25
|
].join("\n");
|
|
26
26
|
const PREFERRED_TOOL_DESCRIPTIONS: Record<string, string> = {
|
|
27
27
|
readseek_read: "Preferred file reader; returns LINE:HASH anchors for durable references.",
|
|
@@ -30,12 +30,10 @@ const PREFERRED_TOOL_DESCRIPTIONS: Record<string, string> = {
|
|
|
30
30
|
readseek_rename: "Use to rename a symbol with binding-aware, verified edits.",
|
|
31
31
|
readseek_grep: "Preferred text/regex search; returns edit-ready LINE:HASH anchors.",
|
|
32
32
|
readseek_search: "Use for AST or structural code search with ast-grep-style patterns.",
|
|
33
|
-
|
|
33
|
+
readseek_digest: "Use to digest native facets (metadata, content, map, diagnostics, identity) and return the full envelope.",
|
|
34
34
|
readseek_def: "Use for go-to-definition or declaration lookup by name.",
|
|
35
35
|
readseek_refs: "Use to find identifier-name matches or binding-specific references before rename or delete.",
|
|
36
|
-
|
|
37
|
-
readseek_view: "Use to inspect PDF structure or selected content.",
|
|
38
|
-
readseek_check: "Use for a quick parser syntax check after source edits; unsupported languages are not validated.",
|
|
36
|
+
readseek_view: "Use to digest document structure or selected content.",
|
|
39
37
|
};
|
|
40
38
|
const FALLBACK_TOOL_DESCRIPTIONS: Record<string, string> = {
|
|
41
39
|
read: "Fallback only. Prefer readseek_read so edits can use LINE:HASH anchors.",
|
|
@@ -56,7 +54,7 @@ type RenamePlan = {
|
|
|
56
54
|
files: Set<string>;
|
|
57
55
|
};
|
|
58
56
|
|
|
59
|
-
type PresentationKind = "read" | "view" | "
|
|
57
|
+
type PresentationKind = "read" | "view" | "digest" | "search" | "grep" | "def" | "refs" | "rename" | "edit" | "write";
|
|
60
58
|
|
|
61
59
|
type Presentation = {
|
|
62
60
|
title: string;
|
|
@@ -70,7 +68,11 @@ type RenderedToolResult = {
|
|
|
70
68
|
|
|
71
69
|
type ImagePolicy = "on" | "auto" | "off";
|
|
72
70
|
type ImageMode = "none" | "all" | "ocr" | "caption" | "objects";
|
|
71
|
+
type VisionAnalysisMode = Exclude<ImageMode, "none">;
|
|
72
|
+
type VisionLevel = "low" | "medium" | "high";
|
|
73
73
|
type DocumentNodeKind = "artifact" | "footer" | "header" | "heading" | "marginal_label" | "page" | "page_number" | "paragraph" | "section" | "structural_section";
|
|
74
|
+
const VISION_ANALYSIS_MODES: [VisionAnalysisMode, ...VisionAnalysisMode[]] = ["all", "ocr", "caption", "objects"];
|
|
75
|
+
const VISION_LEVELS: [VisionLevel, ...VisionLevel[]] = ["low", "medium", "high"];
|
|
74
76
|
|
|
75
77
|
const DOCUMENT_NODE_KINDS: [DocumentNodeKind, ...DocumentNodeKind[]] = [
|
|
76
78
|
"artifact",
|
|
@@ -95,11 +97,65 @@ function resolveImagePolicy(options: PluginOptions | undefined): ImagePolicy {
|
|
|
95
97
|
function isVisualFile(value: unknown): boolean {
|
|
96
98
|
const output = record(value);
|
|
97
99
|
const type = output.type;
|
|
98
|
-
return typeof output.width === "number"
|
|
100
|
+
return typeof output.width === "number"
|
|
101
|
+
|| (typeof type === "string" && type.startsWith("image/"));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function isVisualBoundsError(error: unknown): boolean {
|
|
105
|
+
if (!(error instanceof Error)) return false;
|
|
106
|
+
const message = error.message.replace(/^error:\s*/, "");
|
|
107
|
+
return message.startsWith("--end")
|
|
108
|
+
&& /(?:does|do) not apply to images/.test(message);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function isDocumentBoundsError(error: unknown): boolean {
|
|
112
|
+
if (!(error instanceof Error)) return false;
|
|
113
|
+
const message = error.message.replace(/^error:\s*/, "");
|
|
114
|
+
return /(?:does|do) not apply to (?:documents|PDFs)/.test(message)
|
|
115
|
+
|| /document content belongs to view/.test(message)
|
|
116
|
+
|| /source facets do not apply to documents/.test(message);
|
|
99
117
|
}
|
|
100
118
|
|
|
101
119
|
function isPdfFile(value: unknown): boolean {
|
|
102
|
-
|
|
120
|
+
const output = record(value);
|
|
121
|
+
return output.format === "pdf" || output.type === "application/pdf";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function documentReceipt(filePath: string, envelope: unknown): Record<string, unknown> {
|
|
125
|
+
const recordEnvelope = record(envelope);
|
|
126
|
+
return {
|
|
127
|
+
file: filePath,
|
|
128
|
+
use: "readseek_view",
|
|
129
|
+
reason: "document content belongs to readseek_view",
|
|
130
|
+
metadata: digestMetadata(envelope),
|
|
131
|
+
...(recordEnvelope.cache !== undefined ? { cache: recordEnvelope.cache } : {}),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function digestDocumentReceipt(context: ToolContext, filePath: string): Promise<Record<string, unknown>> {
|
|
136
|
+
const cacheDir = await ensureReadSeekCache(context);
|
|
137
|
+
const envelope = await runReadSeek(context, [
|
|
138
|
+
"--readseek-dir",
|
|
139
|
+
cacheDir,
|
|
140
|
+
...digestArgs(filePath, "metadata"),
|
|
141
|
+
]);
|
|
142
|
+
return documentReceipt(filePath, envelope);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function digestArgs(filePath: string, select: string, extra: string[] = []): string[] {
|
|
146
|
+
return ["digest", filePath, "--select", select, ...extra];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function requireDigestFacet(envelope: unknown, facet: "content" | "map" | "diagnostics" | "identity"): unknown {
|
|
150
|
+
const value = record(envelope)[facet];
|
|
151
|
+
if (value === undefined || value === null || typeof value !== "object") {
|
|
152
|
+
throw new Error(`digest returned no ${facet} facet`);
|
|
153
|
+
}
|
|
154
|
+
return value;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function digestMetadata(envelope: unknown): Record<string, unknown> {
|
|
158
|
+
return record(record(envelope).metadata);
|
|
103
159
|
}
|
|
104
160
|
|
|
105
161
|
class SessionAnchors {
|
|
@@ -304,6 +360,37 @@ function optionalFlag(args: string[], enabled: boolean | undefined, flag: string
|
|
|
304
360
|
if (enabled) args.push(flag);
|
|
305
361
|
}
|
|
306
362
|
|
|
363
|
+
function atLocation(line: number, column?: number): string {
|
|
364
|
+
return column === undefined ? `line:${line}` : `line:${line}:${column}`;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function withAt(args: string[], line: number, column?: number): void {
|
|
368
|
+
args.push("--at", atLocation(line, column));
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function isLikelyImagePath(filePath: string): boolean {
|
|
372
|
+
return /\.(avif|bmp|gif|heic|heif|ico|jpe?g|png|svg|tiff?|webp)$/i.test(filePath);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function isLikelyDocumentPath(filePath: string): boolean {
|
|
376
|
+
return /\.pdf$/i.test(filePath);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function readRangeArgs(filePath: string, start: number, end: number): string[] {
|
|
380
|
+
const extra: string[] = [];
|
|
381
|
+
if (start > 1) withAt(extra, start);
|
|
382
|
+
extra.push("--end", String(end));
|
|
383
|
+
return digestArgs(filePath, "content", extra);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async function runDigestContent(
|
|
387
|
+
context: ToolContext,
|
|
388
|
+
args: string[],
|
|
389
|
+
options: { cancelable?: boolean } = {},
|
|
390
|
+
): Promise<unknown> {
|
|
391
|
+
return requireDigestFacet(await runReadSeek(context, args, options), "content");
|
|
392
|
+
}
|
|
393
|
+
|
|
307
394
|
function readSeekBinaryPath(): string {
|
|
308
395
|
const platform = `${process.platform}-${process.arch}`;
|
|
309
396
|
const platformPackage = READSEEK_PLATFORM_PACKAGES[platform];
|
|
@@ -331,7 +418,7 @@ async function readBoundedText(
|
|
|
331
418
|
totalBytes += value.byteLength;
|
|
332
419
|
if (totalBytes > MAX_OUTPUT_BYTES) {
|
|
333
420
|
onOverflow();
|
|
334
|
-
|
|
421
|
+
await reader.cancel().catch(() => undefined);
|
|
335
422
|
throw new Error(`readseek ${streamName} exceeded the ${MAX_OUTPUT_BYTES}-byte output limit`);
|
|
336
423
|
}
|
|
337
424
|
chunks.push(value);
|
|
@@ -352,13 +439,14 @@ async function readBoundedText(
|
|
|
352
439
|
async function runReadSeekRaw(
|
|
353
440
|
context: ToolContext,
|
|
354
441
|
args: string[],
|
|
355
|
-
options: { cancelable?: boolean } = {},
|
|
442
|
+
options: { cancelable?: boolean; stdin?: string } = {},
|
|
356
443
|
): Promise<string> {
|
|
357
444
|
context.abort.throwIfAborted();
|
|
358
445
|
const cancelable = options.cancelable !== false;
|
|
359
446
|
const spawnOptions = {
|
|
360
447
|
cwd: context.directory,
|
|
361
448
|
stderr: "pipe" as const,
|
|
449
|
+
stdin: options.stdin === undefined ? undefined : Buffer.from(options.stdin),
|
|
362
450
|
stdout: "pipe" as const,
|
|
363
451
|
};
|
|
364
452
|
const child = Bun.spawn(
|
|
@@ -390,7 +478,7 @@ async function runReadSeekRaw(
|
|
|
390
478
|
async function runReadSeek(
|
|
391
479
|
context: ToolContext,
|
|
392
480
|
args: string[],
|
|
393
|
-
options: { cancelable?: boolean } = {},
|
|
481
|
+
options: { cancelable?: boolean; stdin?: string } = {},
|
|
394
482
|
): Promise<unknown> {
|
|
395
483
|
const stdout = await runReadSeekRaw(context, args, options);
|
|
396
484
|
try {
|
|
@@ -407,7 +495,7 @@ async function postMutationPreview(
|
|
|
407
495
|
changed: boolean,
|
|
408
496
|
): Promise<unknown> {
|
|
409
497
|
try {
|
|
410
|
-
return await
|
|
498
|
+
return await runDigestContent(context, args, { cancelable: false });
|
|
411
499
|
} catch (error) {
|
|
412
500
|
const previewError = error instanceof Error ? error.message : String(error);
|
|
413
501
|
return {
|
|
@@ -471,6 +559,11 @@ type AnchorEdit =
|
|
|
471
559
|
| { replace_lines: { start_anchor: string; end_anchor: string; new_text: string } }
|
|
472
560
|
| { insert_after: { anchor: string; new_text: string } };
|
|
473
561
|
|
|
562
|
+
type EditItem =
|
|
563
|
+
| AnchorEdit
|
|
564
|
+
| { replace: { old_text: string; new_text: string; all?: boolean } }
|
|
565
|
+
| { replace_symbol: { symbol: string; new_body: string } };
|
|
566
|
+
|
|
474
567
|
type TextSnapshot = {
|
|
475
568
|
exists: boolean;
|
|
476
569
|
content: string;
|
|
@@ -496,25 +589,28 @@ async function withFileMutationQueue<T>(filePath: string, operation: () => Promi
|
|
|
496
589
|
}
|
|
497
590
|
}
|
|
498
591
|
|
|
499
|
-
function
|
|
592
|
+
function validateEdits(value: unknown): asserts value is EditItem[] {
|
|
500
593
|
if (!Array.isArray(value) || value.length === 0) throw new Error("edits must contain at least one edit");
|
|
501
|
-
const
|
|
594
|
+
const requiredFields = {
|
|
502
595
|
set_line: ["anchor", "new_text"],
|
|
503
596
|
replace_lines: ["end_anchor", "new_text", "start_anchor"],
|
|
504
597
|
insert_after: ["anchor", "new_text"],
|
|
598
|
+
replace: ["new_text", "old_text"],
|
|
599
|
+
replace_symbol: ["new_body", "symbol"],
|
|
505
600
|
} as const;
|
|
506
601
|
for (const [index, item] of value.entries()) {
|
|
507
602
|
if (!item || typeof item !== "object") throw new Error(`edits[${index}] must be an edit object`);
|
|
508
603
|
const keys = Object.keys(item);
|
|
509
|
-
const variant = keys[0] as keyof typeof
|
|
510
|
-
if (keys.length !== 1 || !Object.hasOwn(
|
|
511
|
-
throw new Error(`edits[${index}] must contain exactly one of: set_line, replace_lines, insert_after`);
|
|
604
|
+
const variant = keys[0] as keyof typeof requiredFields;
|
|
605
|
+
if (keys.length !== 1 || !Object.hasOwn(requiredFields, variant)) {
|
|
606
|
+
throw new Error(`edits[${index}] must contain exactly one of: set_line, replace_lines, insert_after, replace, replace_symbol`);
|
|
512
607
|
}
|
|
513
608
|
const payload = (item as Record<string, unknown>)[variant];
|
|
514
609
|
if (!payload || typeof payload !== "object") throw new Error(`edits[${index}].${variant} must be an object`);
|
|
515
|
-
const expectedFields =
|
|
610
|
+
const expectedFields: readonly string[] = requiredFields[variant];
|
|
611
|
+
const allowedFields = variant === "replace" ? [...expectedFields, "all"] : expectedFields;
|
|
516
612
|
const actualFields = Object.keys(payload).sort();
|
|
517
|
-
if (actualFields.
|
|
613
|
+
if (actualFields.some((field) => !allowedFields.includes(field)) || expectedFields.some((field) => !actualFields.includes(field))) {
|
|
518
614
|
throw new Error(`edits[${index}].${variant} contains invalid fields`);
|
|
519
615
|
}
|
|
520
616
|
const values = payload as Record<string, unknown>;
|
|
@@ -523,6 +619,9 @@ function validateAnchorEdits(value: unknown): asserts value is AnchorEdit[] {
|
|
|
523
619
|
throw new Error(`edits[${index}].${variant}.${field} must be a string`);
|
|
524
620
|
}
|
|
525
621
|
}
|
|
622
|
+
if (variant === "replace" && values.all !== undefined && typeof values.all !== "boolean") {
|
|
623
|
+
throw new Error(`edits[${index}].replace.all must be a boolean`);
|
|
624
|
+
}
|
|
526
625
|
for (const field of expectedFields.filter((field) => field.endsWith("anchor"))) {
|
|
527
626
|
parseAnchor(values[field] as string);
|
|
528
627
|
}
|
|
@@ -537,125 +636,16 @@ function parseAnchor(anchor: string): { line: number; hash: string } {
|
|
|
537
636
|
return { line, hash: match[2].toLowerCase() };
|
|
538
637
|
}
|
|
539
638
|
|
|
540
|
-
function
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
async function verifyAnchors(context: ToolContext, filePath: string, edits: AnchorEdit[]): Promise<void> {
|
|
547
|
-
const refs = new Map<number, string>();
|
|
548
|
-
for (const edit of edits) {
|
|
549
|
-
for (const ref of anchorRefs(edit)) {
|
|
550
|
-
const previous = refs.get(ref.line);
|
|
551
|
-
if (previous !== undefined && previous !== ref.hash) throw new Error(`conflicting hashes for line ${ref.line}`);
|
|
552
|
-
refs.set(ref.line, ref.hash);
|
|
553
|
-
}
|
|
554
|
-
}
|
|
555
|
-
for (const [line, expected] of refs) {
|
|
556
|
-
const output = record(await runReadSeek(context, ["read", `${filePath}:${line}`, "--end", String(line)]));
|
|
557
|
-
const hashline = items(output.hashlines).map(record)[0];
|
|
558
|
-
const actual = hashline?.hash;
|
|
559
|
-
if (typeof actual !== "string" || actual !== expected) {
|
|
560
|
-
throw new Error(`stale anchor ${line}:${expected}; current hash is ${typeof actual === "string" ? actual : "unavailable"}`);
|
|
561
|
-
}
|
|
562
|
-
}
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
type EditableLine = {
|
|
566
|
-
text: string;
|
|
567
|
-
ending: "\r\n" | "\n" | "\r" | "";
|
|
568
|
-
};
|
|
569
|
-
|
|
570
|
-
function parseEditableLines(content: string): EditableLine[] {
|
|
571
|
-
const lines: EditableLine[] = [];
|
|
572
|
-
let start = 0;
|
|
573
|
-
for (let index = 0; index < content.length; index++) {
|
|
574
|
-
const character = content[index];
|
|
575
|
-
if (character !== "\r" && character !== "\n") continue;
|
|
576
|
-
const ending = character === "\r" && content[index + 1] === "\n" ? "\r\n" : character;
|
|
577
|
-
lines.push({ text: content.slice(start, index), ending });
|
|
578
|
-
if (ending === "\r\n") index++;
|
|
579
|
-
start = index + 1;
|
|
639
|
+
function firstChangedLine(before: string, after: string): number {
|
|
640
|
+
const beforeLines = normalizeToLf(before).split("\n");
|
|
641
|
+
const afterLines = normalizeToLf(after).split("\n");
|
|
642
|
+
const count = Math.max(beforeLines.length, afterLines.length);
|
|
643
|
+
for (let index = 0; index < count; index++) {
|
|
644
|
+
if (beforeLines[index] !== afterLines[index]) return index + 1;
|
|
580
645
|
}
|
|
581
|
-
|
|
582
|
-
return lines;
|
|
646
|
+
return 1;
|
|
583
647
|
}
|
|
584
648
|
|
|
585
|
-
function detectLineEnding(lines: EditableLine[]): "\r\n" | "\n" | "\r" {
|
|
586
|
-
const counts = new Map<"\r\n" | "\n" | "\r", number>([
|
|
587
|
-
["\r\n", 0],
|
|
588
|
-
["\n", 0],
|
|
589
|
-
["\r", 0],
|
|
590
|
-
]);
|
|
591
|
-
for (const line of lines) {
|
|
592
|
-
if (line.ending !== "") counts.set(line.ending, (counts.get(line.ending) ?? 0) + 1);
|
|
593
|
-
}
|
|
594
|
-
let selected: "\r\n" | "\n" | "\r" = "\n";
|
|
595
|
-
for (const ending of ["\r\n", "\r"] as const) {
|
|
596
|
-
if ((counts.get(ending) ?? 0) > (counts.get(selected) ?? 0)) selected = ending;
|
|
597
|
-
}
|
|
598
|
-
return selected;
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
function normalizeToLf(content: string): string {
|
|
602
|
-
return content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
function replacementLines(text: string): string[] {
|
|
606
|
-
if (text === "") return [];
|
|
607
|
-
return normalizeToLf(text).replace(/\n$/, "").split("\n");
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
function applyAnchorEdits(content: string, edits: AnchorEdit[]): string {
|
|
611
|
-
const bom = content.startsWith("\uFEFF") ? "\uFEFF" : "";
|
|
612
|
-
const lines = parseEditableLines(bom ? content.slice(1) : content);
|
|
613
|
-
const newline = detectLineEnding(lines);
|
|
614
|
-
const planned = edits.map((edit) => {
|
|
615
|
-
if ("set_line" in edit) {
|
|
616
|
-
const { line } = parseAnchor(edit.set_line.anchor);
|
|
617
|
-
return { start: line - 1, deleteCount: 1, text: edit.set_line.new_text };
|
|
618
|
-
}
|
|
619
|
-
if ("insert_after" in edit) {
|
|
620
|
-
const { line } = parseAnchor(edit.insert_after.anchor);
|
|
621
|
-
return { start: line, deleteCount: 0, text: edit.insert_after.new_text };
|
|
622
|
-
}
|
|
623
|
-
const start = parseAnchor(edit.replace_lines.start_anchor).line;
|
|
624
|
-
const end = parseAnchor(edit.replace_lines.end_anchor).line;
|
|
625
|
-
if (end < start) throw new Error("replace_lines end anchor precedes start anchor");
|
|
626
|
-
return { start: start - 1, deleteCount: end - start + 1, text: edit.replace_lines.new_text };
|
|
627
|
-
});
|
|
628
|
-
const ascending = [...planned].sort((left, right) => left.start - right.start || left.deleteCount - right.deleteCount);
|
|
629
|
-
for (let index = 1; index < ascending.length; index++) {
|
|
630
|
-
const previous = ascending[index - 1];
|
|
631
|
-
const current = ascending[index];
|
|
632
|
-
const previousEnd = previous.start + Math.max(previous.deleteCount, 1);
|
|
633
|
-
if (current.start < previousEnd) throw new Error("anchored edits overlap or target the same location");
|
|
634
|
-
}
|
|
635
|
-
planned.sort((left, right) => right.start - left.start);
|
|
636
|
-
for (const edit of planned) {
|
|
637
|
-
if (edit.start < 0 || edit.start > lines.length || edit.start + edit.deleteCount > lines.length) {
|
|
638
|
-
throw new Error("anchor line is outside the file");
|
|
639
|
-
}
|
|
640
|
-
const removed = lines.slice(edit.start, edit.start + edit.deleteCount);
|
|
641
|
-
const replacement = replacementLines(edit.text).map((text): EditableLine => ({ text, ending: newline }));
|
|
642
|
-
if (edit.deleteCount === 0 && edit.start === lines.length && replacement.length > 0) {
|
|
643
|
-
const previous = lines.at(-1);
|
|
644
|
-
const terminalEnding = previous?.ending ?? "";
|
|
645
|
-
if (previous?.ending === "") previous.ending = newline;
|
|
646
|
-
replacement[replacement.length - 1].ending = terminalEnding;
|
|
647
|
-
} else if (edit.deleteCount > 0) {
|
|
648
|
-
const terminalEnding = removed.at(-1)?.ending ?? "";
|
|
649
|
-
if (replacement.length > 0) {
|
|
650
|
-
replacement[replacement.length - 1].ending = terminalEnding;
|
|
651
|
-
} else if (edit.start + edit.deleteCount === lines.length && edit.start > 0) {
|
|
652
|
-
lines[edit.start - 1].ending = terminalEnding;
|
|
653
|
-
}
|
|
654
|
-
}
|
|
655
|
-
lines.splice(edit.start, edit.deleteCount, ...replacement);
|
|
656
|
-
}
|
|
657
|
-
return bom + lines.map((line) => line.text + line.ending).join("");
|
|
658
|
-
}
|
|
659
649
|
|
|
660
650
|
async function readTextSnapshot(filePath: string): Promise<TextSnapshot> {
|
|
661
651
|
try {
|
|
@@ -729,11 +719,19 @@ function renameFiles(value: unknown): string[] {
|
|
|
729
719
|
return files;
|
|
730
720
|
}
|
|
731
721
|
|
|
722
|
+
function normalizeToLf(content: string): string {
|
|
723
|
+
return content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
724
|
+
}
|
|
725
|
+
|
|
732
726
|
function diffLines(content: string): string[] {
|
|
733
727
|
if (content === "") return [];
|
|
734
|
-
const lines =
|
|
728
|
+
const lines = content.split("\n");
|
|
735
729
|
if (lines.at(-1) === "") lines.pop();
|
|
736
|
-
return lines;
|
|
730
|
+
return lines.map((line) => line.replace(/\r/g, "\\r"));
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function hasFinalNewline(content: string): boolean {
|
|
734
|
+
return content === "" || /[\r\n]$/.test(content);
|
|
737
735
|
}
|
|
738
736
|
|
|
739
737
|
function diffRange(start: number, count: number): string {
|
|
@@ -796,23 +794,7 @@ function imageAttachment(image: Record<string, unknown>, filename: string): Tool
|
|
|
796
794
|
|
|
797
795
|
function formatReadResult(value: unknown): RenderedToolResult {
|
|
798
796
|
const output = record(value);
|
|
799
|
-
if (output.
|
|
800
|
-
const attachments: ToolAttachment[] = [];
|
|
801
|
-
const images = items(output.images).map((value, index) => {
|
|
802
|
-
const image = record(value);
|
|
803
|
-
const page = typeof image.page === "number" ? image.page : "unknown";
|
|
804
|
-
const attachment = imageAttachment(image, `pdf-page-${page}-image-${index + 1}`);
|
|
805
|
-
if (attachment) attachments.push(attachment);
|
|
806
|
-
const sanitized = { ...image };
|
|
807
|
-
delete sanitized.data;
|
|
808
|
-
delete sanitized.encoding;
|
|
809
|
-
return sanitized;
|
|
810
|
-
});
|
|
811
|
-
return {
|
|
812
|
-
output: boundDocumentOutput(render({ ...output, images })),
|
|
813
|
-
...(attachments.length > 0 ? { attachments } : {}),
|
|
814
|
-
};
|
|
815
|
-
}
|
|
797
|
+
if (output.use === "readseek_view") return { output: render(value) };
|
|
816
798
|
|
|
817
799
|
const attachment = imageAttachment(output, "image");
|
|
818
800
|
if (!attachment) return { output: render(value) };
|
|
@@ -846,6 +828,8 @@ function simpleDiff(filePath: string, before: string, after: string): string {
|
|
|
846
828
|
if (before === after) return "";
|
|
847
829
|
const oldLines = diffLines(before);
|
|
848
830
|
const newLines = diffLines(after);
|
|
831
|
+
const oldHasFinalNewline = hasFinalNewline(before);
|
|
832
|
+
const newHasFinalNewline = hasFinalNewline(after);
|
|
849
833
|
let prefix = 0;
|
|
850
834
|
while (prefix < oldLines.length && prefix < newLines.length && oldLines[prefix] === newLines[prefix]) prefix++;
|
|
851
835
|
let suffix = 0;
|
|
@@ -874,13 +858,13 @@ function simpleDiff(filePath: string, before: string, after: string): string {
|
|
|
874
858
|
const fullDiff = [...header];
|
|
875
859
|
let fullDiffBytes = Buffer.byteLength(header.join("\n"));
|
|
876
860
|
let complete = true;
|
|
877
|
-
const ranges: Array<[string[], number, number, string]> = [
|
|
878
|
-
[oldLines, oldStart, prefix, " "],
|
|
879
|
-
[oldLines, prefix, oldChangeEnd, "-"],
|
|
880
|
-
[newLines, prefix, newChangeEnd, "+"],
|
|
881
|
-
[newLines, newChangeEnd, newEnd, " "],
|
|
861
|
+
const ranges: Array<[string[], number, number, string, boolean]> = [
|
|
862
|
+
[oldLines, oldStart, prefix, " ", oldHasFinalNewline],
|
|
863
|
+
[oldLines, prefix, oldChangeEnd, "-", oldHasFinalNewline],
|
|
864
|
+
[newLines, prefix, newChangeEnd, "+", newHasFinalNewline],
|
|
865
|
+
[newLines, newChangeEnd, newEnd, " ", newHasFinalNewline],
|
|
882
866
|
];
|
|
883
|
-
outer: for (const [lines, start, end, marker] of ranges) {
|
|
867
|
+
outer: for (const [lines, start, end, marker, hasNewline] of ranges) {
|
|
884
868
|
for (let index = start; index < end; index++) {
|
|
885
869
|
const lineBytes = 2 + Buffer.byteLength(lines[index]);
|
|
886
870
|
if (fullDiffBytes + lineBytes > MAX_PERMISSION_DIFF_BYTES) {
|
|
@@ -889,13 +873,26 @@ function simpleDiff(filePath: string, before: string, after: string): string {
|
|
|
889
873
|
}
|
|
890
874
|
fullDiff.push(`${marker}${lines[index]}`);
|
|
891
875
|
fullDiffBytes += lineBytes;
|
|
876
|
+
if (index === lines.length - 1 && !hasNewline) {
|
|
877
|
+
const eofMarker = "\";
|
|
878
|
+
if (fullDiffBytes + Buffer.byteLength(eofMarker) + 1 > MAX_PERMISSION_DIFF_BYTES) {
|
|
879
|
+
complete = false;
|
|
880
|
+
break outer;
|
|
881
|
+
}
|
|
882
|
+
fullDiff.push(eofMarker);
|
|
883
|
+
fullDiffBytes += Buffer.byteLength(eofMarker) + 1;
|
|
884
|
+
}
|
|
892
885
|
}
|
|
893
886
|
}
|
|
894
887
|
if (complete) return fullDiff.join("\n");
|
|
895
888
|
|
|
889
|
+
const newlineSummary = oldHasFinalNewline === newHasFinalNewline
|
|
890
|
+
? []
|
|
891
|
+
: [` final newline: ${oldHasFinalNewline ? "present" : "missing"} -> ${newHasFinalNewline ? "present" : "missing"}`];
|
|
896
892
|
return [
|
|
897
893
|
...header,
|
|
898
894
|
` diff truncated: ${oldChangeEnd - prefix} removed, ${newChangeEnd - prefix} added lines`,
|
|
895
|
+
...newlineSummary,
|
|
899
896
|
...sampleDiffLines(oldLines, prefix, oldChangeEnd, "-"),
|
|
900
897
|
...sampleDiffLines(newLines, prefix, newChangeEnd, "+"),
|
|
901
898
|
].join("\n");
|
|
@@ -923,8 +920,8 @@ function initialTitle(kind: PresentationKind, args: any): string {
|
|
|
923
920
|
return `Read ${args.path}`;
|
|
924
921
|
case "view":
|
|
925
922
|
return `View ${args.path}`;
|
|
926
|
-
case "
|
|
927
|
-
return `
|
|
923
|
+
case "digest":
|
|
924
|
+
return `Digest ${args.path}`;
|
|
928
925
|
case "search":
|
|
929
926
|
return `Search ${args.pattern}`;
|
|
930
927
|
case "grep":
|
|
@@ -933,16 +930,12 @@ function initialTitle(kind: PresentationKind, args: any): string {
|
|
|
933
930
|
return `Find definition ${args.name}`;
|
|
934
931
|
case "refs":
|
|
935
932
|
return `Find references to ${args.name}`;
|
|
936
|
-
case "hover":
|
|
937
|
-
return `Identify ${args.path}:${args.line}`;
|
|
938
933
|
case "rename":
|
|
939
934
|
return `Rename ${args.path}:${args.line} to ${args.to}`;
|
|
940
935
|
case "edit":
|
|
941
936
|
return `Edit ${args.path}`;
|
|
942
937
|
case "write":
|
|
943
938
|
return `Write ${args.path}`;
|
|
944
|
-
case "check":
|
|
945
|
-
return `Check ${args.path}`;
|
|
946
939
|
}
|
|
947
940
|
}
|
|
948
941
|
|
|
@@ -967,9 +960,37 @@ function resultPresentation(kind: PresentationKind, args: any, value: unknown):
|
|
|
967
960
|
}
|
|
968
961
|
case "view":
|
|
969
962
|
return { title: `Viewed ${args.path}`, metadata: {} };
|
|
970
|
-
case "
|
|
971
|
-
const
|
|
972
|
-
|
|
963
|
+
case "digest": {
|
|
964
|
+
const map = record(output.map);
|
|
965
|
+
const diagnostics = record(output.diagnostics);
|
|
966
|
+
const identity = record(output.identity);
|
|
967
|
+
const content = record(output.content);
|
|
968
|
+
const metadata: Record<string, number> = {};
|
|
969
|
+
const summary: string[] = [];
|
|
970
|
+
if (output.map !== undefined) {
|
|
971
|
+
const symbols = items(map.symbols).length;
|
|
972
|
+
metadata.symbols = symbols;
|
|
973
|
+
summary.push(`${symbols} symbols`);
|
|
974
|
+
}
|
|
975
|
+
if (output.diagnostics !== undefined) {
|
|
976
|
+
const errors = typeof diagnostics.error_count === "number" ? diagnostics.error_count : 0;
|
|
977
|
+
const missing = typeof diagnostics.missing_count === "number" ? diagnostics.missing_count : 0;
|
|
978
|
+
metadata.error_count = errors;
|
|
979
|
+
metadata.missing_count = missing;
|
|
980
|
+
summary.push(`${errors} errors, ${missing} missing`);
|
|
981
|
+
}
|
|
982
|
+
if (output.identity !== undefined) {
|
|
983
|
+
if (typeof identity.line === "number") metadata.line = identity.line;
|
|
984
|
+
if (typeof identity.column === "number") metadata.column = identity.column;
|
|
985
|
+
const identifier = record(identity.identifier).text;
|
|
986
|
+
if (typeof identifier === "string") summary.push(identifier);
|
|
987
|
+
}
|
|
988
|
+
if (typeof content.start_line === "number") metadata.start_line = content.start_line;
|
|
989
|
+
if (typeof content.end_line === "number") metadata.end_line = content.end_line;
|
|
990
|
+
return {
|
|
991
|
+
title: summary.length > 0 ? `Digested ${args.path} (${summary.join(", ")})` : `Digested ${args.path}`,
|
|
992
|
+
metadata,
|
|
993
|
+
};
|
|
973
994
|
}
|
|
974
995
|
case "search": {
|
|
975
996
|
const results = items(output.results);
|
|
@@ -989,15 +1010,6 @@ function resultPresentation(kind: PresentationKind, args: any, value: unknown):
|
|
|
989
1010
|
const references = items(output.references).length;
|
|
990
1011
|
return { title: `Found ${references} references`, metadata: { references } };
|
|
991
1012
|
}
|
|
992
|
-
case "hover": {
|
|
993
|
-
const identifier = record(output.identifier).text;
|
|
994
|
-
const line = output.line;
|
|
995
|
-
const column = output.column;
|
|
996
|
-
const metadata: Record<string, number> = {};
|
|
997
|
-
if (typeof line === "number") metadata.line = line;
|
|
998
|
-
if (typeof column === "number") metadata.column = column;
|
|
999
|
-
return { title: typeof identifier === "string" ? `Identified ${identifier}` : initialTitle(kind, args), metadata };
|
|
1000
|
-
}
|
|
1001
1013
|
case "rename": {
|
|
1002
1014
|
const oldName = output.old_name;
|
|
1003
1015
|
const newName = output.new_name;
|
|
@@ -1019,14 +1031,6 @@ function resultPresentation(kind: PresentationKind, args: any, value: unknown):
|
|
|
1019
1031
|
const preview = typeof output.preview_error === "string" ? " (preview unavailable)" : "";
|
|
1020
1032
|
return { title: `${kind === "edit" ? "Edited" : "Wrote"} ${args.path}${preview}`, metadata: {} };
|
|
1021
1033
|
}
|
|
1022
|
-
case "check": {
|
|
1023
|
-
const errors = typeof output.error_count === "number" ? output.error_count : 0;
|
|
1024
|
-
const missing = typeof output.missing_count === "number" ? output.missing_count : 0;
|
|
1025
|
-
return {
|
|
1026
|
-
title: `Checked ${args.path} (${errors} errors, ${missing} missing)`,
|
|
1027
|
-
metadata: { error_count: errors, missing_count: missing },
|
|
1028
|
-
};
|
|
1029
|
-
}
|
|
1030
1034
|
}
|
|
1031
1035
|
return { title: initialTitle(kind, args), metadata: {} };
|
|
1032
1036
|
}
|
|
@@ -1079,19 +1083,18 @@ export const ReadSeekPlugin: Plugin = async (_input, options) => {
|
|
|
1079
1083
|
tool: {
|
|
1080
1084
|
readseek_read: readseekTool(
|
|
1081
1085
|
imagePolicy === "off"
|
|
1082
|
-
? "Read text with LINE:HASH anchors. Image
|
|
1083
|
-
: `Read text with LINE:HASH anchors. For an image
|
|
1086
|
+
? "Read text with LINE:HASH anchors. Image reads are disabled. For documents, use readseek_view."
|
|
1087
|
+
: `Read text with LINE:HASH anchors. For an image, select image (${imageModes.join(", ")}); omit it to skip image files. For documents such as PDFs, use readseek_view.`,
|
|
1084
1088
|
{
|
|
1085
1089
|
path: tool.schema.string().describe("Path relative to the project directory"),
|
|
1086
1090
|
offset: tool.schema.number().int().positive().optional().describe("One-based starting line"),
|
|
1087
1091
|
limit: tool.schema.number().int().positive().optional().describe(`Maximum number of lines; defaults to ${DEFAULT_READ_LIMIT}`),
|
|
1088
|
-
page: tool.schema.number().int().positive().optional().describe("One-based PDF page; defaults to 1"),
|
|
1089
1092
|
language: tool.schema.string().optional().describe("Language override when auto-detection is ambiguous"),
|
|
1090
1093
|
...(imagePolicy === "off"
|
|
1091
1094
|
? {}
|
|
1092
1095
|
: {
|
|
1093
1096
|
image: tool.schema.enum(imageModes as [ImageMode, ...ImageMode[]]).optional()
|
|
1094
|
-
.describe("
|
|
1097
|
+
.describe("Standalone image processing mode")
|
|
1095
1098
|
}),
|
|
1096
1099
|
},
|
|
1097
1100
|
"read",
|
|
@@ -1099,47 +1102,119 @@ export const ReadSeekPlugin: Plugin = async (_input, options) => {
|
|
|
1099
1102
|
const filePath = resolvePath(context.directory, input.path as string);
|
|
1100
1103
|
await authorizeRead(context, filePath);
|
|
1101
1104
|
const image = input.image as ImageMode | undefined;
|
|
1102
|
-
if (imagePolicy === "off" && image !== undefined) throw new Error("image
|
|
1105
|
+
if (imagePolicy === "off" && image !== undefined) throw new Error("image reads are disabled");
|
|
1103
1106
|
if (imagePolicy === "on" && image === "none") throw new Error('image="none" requires imageMode="auto"');
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
+
|
|
1108
|
+
if (isLikelyDocumentPath(filePath)) {
|
|
1109
|
+
if (image !== undefined) throw new Error("image does not apply to documents; use readseek_view");
|
|
1110
|
+
if (input.offset !== undefined || input.limit !== undefined) {
|
|
1111
|
+
throw new Error("offset and limit do not apply to documents; use readseek_view");
|
|
1112
|
+
}
|
|
1113
|
+
if (input.language !== undefined) {
|
|
1114
|
+
throw new Error("language does not apply to documents; use readseek_view");
|
|
1115
|
+
}
|
|
1116
|
+
return digestDocumentReceipt(context, filePath);
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
// Image paths without an image mode only need metadata (skip without loading content).
|
|
1120
|
+
// Text paths always use one digest --select content spawn.
|
|
1121
|
+
let detectedText = false;
|
|
1122
|
+
if (image === undefined && isLikelyImagePath(filePath)) {
|
|
1123
|
+
const envelope = await runReadSeek(context, digestArgs(filePath, "metadata"));
|
|
1124
|
+
const metadata = digestMetadata(envelope);
|
|
1125
|
+
if (isPdfFile(metadata)) return documentReceipt(filePath, envelope);
|
|
1126
|
+
if (isVisualFile(metadata)) {
|
|
1127
|
+
return { file: filePath, skipped: true, reason: "image mode not selected" };
|
|
1128
|
+
}
|
|
1129
|
+
detectedText = true;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
const extra: string[] = [];
|
|
1133
|
+
const wantsImage = !detectedText
|
|
1134
|
+
&& (image !== undefined || isLikelyImagePath(filePath));
|
|
1135
|
+
if (!wantsImage) {
|
|
1136
|
+
const start = (input.offset as number | undefined) ?? 1;
|
|
1137
|
+
const end = start + ((input.limit as number | undefined) ?? DEFAULT_READ_LIMIT) - 1;
|
|
1138
|
+
if (start > 1) withAt(extra, start);
|
|
1139
|
+
extra.push("--end", String(end));
|
|
1140
|
+
if (input.language) extra.push("--language", input.language as string);
|
|
1141
|
+
} else {
|
|
1142
|
+
if (input.offset !== undefined || input.limit !== undefined) {
|
|
1143
|
+
throw new Error("offset and limit do not apply to image reads");
|
|
1144
|
+
}
|
|
1145
|
+
if (input.language !== undefined) {
|
|
1146
|
+
throw new Error("language does not apply to image reads");
|
|
1147
|
+
}
|
|
1148
|
+
if (image !== undefined) extra.push("--vision-mode", image);
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
let envelope: unknown;
|
|
1152
|
+
try {
|
|
1153
|
+
envelope = await runReadSeek(context, digestArgs(filePath, "content", extra));
|
|
1154
|
+
} catch (error) {
|
|
1155
|
+
// A visual or document file with an uncommon extension rejects text bounds before
|
|
1156
|
+
// an envelope can be returned. Probe metadata only on this cold path.
|
|
1157
|
+
if (image !== undefined) throw error;
|
|
1158
|
+
if (isDocumentBoundsError(error)) {
|
|
1159
|
+
return digestDocumentReceipt(context, filePath);
|
|
1160
|
+
}
|
|
1161
|
+
if (isVisualBoundsError(error)) {
|
|
1162
|
+
const metadataEnvelope = await runReadSeek(context, digestArgs(filePath, "metadata"));
|
|
1163
|
+
if (isPdfFile(digestMetadata(metadataEnvelope))) {
|
|
1164
|
+
return documentReceipt(filePath, metadataEnvelope);
|
|
1165
|
+
}
|
|
1166
|
+
if (isVisualFile(digestMetadata(metadataEnvelope))) {
|
|
1167
|
+
return { file: filePath, skipped: true, reason: "image mode not selected" };
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
throw error;
|
|
1171
|
+
}
|
|
1172
|
+
const metadata = digestMetadata(envelope);
|
|
1173
|
+
if (isPdfFile(metadata) || record(envelope).cache !== undefined) {
|
|
1174
|
+
return documentReceipt(filePath, envelope);
|
|
1175
|
+
}
|
|
1176
|
+
const visual = isVisualFile(metadata);
|
|
1177
|
+
|
|
1107
1178
|
if (visual && image === undefined) {
|
|
1108
1179
|
return { file: filePath, skipped: true, reason: "image mode not selected" };
|
|
1109
1180
|
}
|
|
1110
1181
|
if (visual && (input.offset !== undefined || input.limit !== undefined)) {
|
|
1111
|
-
throw new Error("offset and limit do not apply to image
|
|
1182
|
+
throw new Error("offset and limit do not apply to image reads");
|
|
1112
1183
|
}
|
|
1113
1184
|
if (visual && input.language !== undefined) {
|
|
1114
|
-
throw new Error("language does not apply to image
|
|
1185
|
+
throw new Error("language does not apply to image reads");
|
|
1115
1186
|
}
|
|
1116
|
-
if (!pdf && input.page !== undefined) throw new Error("page applies to PDF reads only");
|
|
1117
1187
|
|
|
1118
|
-
|
|
1119
|
-
if (!visual) {
|
|
1120
|
-
args.push("--end", String((input.offset ?? 1) + ((input.limit as number | undefined) ?? DEFAULT_READ_LIMIT) - 1));
|
|
1121
|
-
}
|
|
1122
|
-
if (input.language) args.push("--language", input.language as string);
|
|
1123
|
-
if (image !== undefined) args.push("--vision-mode", image);
|
|
1124
|
-
if (pdf) args.push("--page", String(input.page ?? 1));
|
|
1125
|
-
return runReadSeek(context, args);
|
|
1188
|
+
return requireDigestFacet(envelope, "content");
|
|
1126
1189
|
},
|
|
1127
1190
|
formatReadResult,
|
|
1128
1191
|
),
|
|
1129
1192
|
readseek_view: readseekTool(
|
|
1130
|
-
"
|
|
1193
|
+
"Digest a document's structure or selected content. Start with the overview, then narrow by page, node, kind, or depth instead of reading the whole document.",
|
|
1131
1194
|
{
|
|
1132
|
-
path: tool.schema.string().describe("
|
|
1195
|
+
path: tool.schema.string().describe("Document path relative to the project directory"),
|
|
1133
1196
|
node: tool.schema.string().min(1).optional().describe("Node ID to use as the view root"),
|
|
1134
1197
|
page: tool.schema.number().int().positive().optional().describe("One-based source page"),
|
|
1135
1198
|
kind: tool.schema.enum(DOCUMENT_NODE_KINDS).optional().describe("Node kind filter"),
|
|
1136
1199
|
depth: tool.schema.number().int().nonnegative().optional().describe("Maximum depth below selected roots"),
|
|
1137
1200
|
outline: tool.schema.boolean().optional().describe("Return outline nodes only"),
|
|
1201
|
+
visionMode: tool.schema.enum(VISION_ANALYSIS_MODES).optional()
|
|
1202
|
+
.describe("Analyze selected assets: all, ocr, caption, or objects"),
|
|
1203
|
+
visionLevel: tool.schema.enum(VISION_LEVELS).optional()
|
|
1204
|
+
.describe("Vision inference level: low (default), medium, or high"),
|
|
1138
1205
|
},
|
|
1139
1206
|
"view",
|
|
1140
1207
|
async (input, context) => {
|
|
1141
1208
|
const filePath = resolvePath(context.directory, input.path as string);
|
|
1142
1209
|
await authorizeRead(context, filePath);
|
|
1210
|
+
const visionMode = input.visionMode as VisionAnalysisMode | undefined;
|
|
1211
|
+
const visionLevel = input.visionLevel as VisionLevel | undefined;
|
|
1212
|
+
if (visionLevel !== undefined && visionMode === undefined) {
|
|
1213
|
+
throw new Error("visionLevel requires visionMode");
|
|
1214
|
+
}
|
|
1215
|
+
if (input.outline && (visionMode !== undefined || visionLevel !== undefined)) {
|
|
1216
|
+
throw new Error("vision options cannot be combined with outline");
|
|
1217
|
+
}
|
|
1143
1218
|
const cacheDir = await ensureReadSeekCache(context);
|
|
1144
1219
|
const args = ["--readseek-dir", cacheDir, "view", filePath];
|
|
1145
1220
|
if (input.node) args.push("--node", input.node as string);
|
|
@@ -1147,22 +1222,46 @@ export const ReadSeekPlugin: Plugin = async (_input, options) => {
|
|
|
1147
1222
|
if (input.kind) args.push("--kind", input.kind as string);
|
|
1148
1223
|
if (input.depth !== undefined) args.push("--depth", String(input.depth));
|
|
1149
1224
|
optionalFlag(args, input.outline as boolean | undefined, "--outline");
|
|
1225
|
+
if (visionMode !== undefined) args.push("--vision-mode", visionMode);
|
|
1226
|
+
if (visionLevel !== undefined) args.push("--vision-level", visionLevel);
|
|
1150
1227
|
return runReadSeekRaw(context, args);
|
|
1151
1228
|
},
|
|
1152
1229
|
(result) => ({ output: boundDocumentOutput(String(result)) }),
|
|
1153
1230
|
),
|
|
1154
|
-
|
|
1155
|
-
"
|
|
1231
|
+
readseek_digest: readseekTool(
|
|
1232
|
+
"Digest native facets for a path and return the full envelope unchanged. Facets: metadata, content, map, diagnostics, identity (comma-separated). Metadata is always present; other selected facets appear when available.",
|
|
1156
1233
|
{
|
|
1157
1234
|
path: tool.schema.string().describe("Path relative to the project directory"),
|
|
1235
|
+
select: tool.schema.string().optional()
|
|
1236
|
+
.describe("Comma-separated facets: metadata, content, map, diagnostics, identity; defaults to content"),
|
|
1237
|
+
at: tool.schema.string().optional()
|
|
1238
|
+
.describe("Location: line:N[:COLUMN], hash:HASH[:COLUMN], or symbol:NAME"),
|
|
1239
|
+
end: tool.schema.number().int().positive().optional().describe("Last line to include; content facet only"),
|
|
1240
|
+
limit: tool.schema.number().int().positive().optional().describe("Maximum number of lines; content facet only"),
|
|
1158
1241
|
language: tool.schema.string().optional().describe("Language override when auto-detection is ambiguous"),
|
|
1242
|
+
visionMode: tool.schema.enum(["none", ...VISION_ANALYSIS_MODES] as [ImageMode, ...ImageMode[]]).optional()
|
|
1243
|
+
.describe("Standalone image mode for the content facet: none, all, ocr, caption, or objects"),
|
|
1244
|
+
visionLevel: tool.schema.enum(VISION_LEVELS).optional()
|
|
1245
|
+
.describe("Vision inference level: low (default), medium, or high; content facet only"),
|
|
1159
1246
|
},
|
|
1160
|
-
"
|
|
1247
|
+
"digest",
|
|
1161
1248
|
async (input, context) => {
|
|
1162
1249
|
const filePath = resolvePath(context.directory, input.path as string);
|
|
1163
1250
|
await authorizeRead(context, filePath);
|
|
1164
|
-
const
|
|
1251
|
+
const select = ((input.select as string | undefined) ?? "content").trim() || "content";
|
|
1252
|
+
const visionMode = input.visionMode as ImageMode | undefined;
|
|
1253
|
+
const visionLevel = input.visionLevel as VisionLevel | undefined;
|
|
1254
|
+
if (visionLevel !== undefined && visionMode === undefined) {
|
|
1255
|
+
throw new Error("visionLevel requires visionMode");
|
|
1256
|
+
}
|
|
1257
|
+
const cacheDir = await ensureReadSeekCache(context);
|
|
1258
|
+
const args = ["--readseek-dir", cacheDir, ...digestArgs(filePath, select)];
|
|
1259
|
+
if (input.at) args.push("--at", input.at as string);
|
|
1260
|
+
if (input.end !== undefined) args.push("--end", String(input.end));
|
|
1261
|
+
if (input.limit !== undefined) args.push("--limit", String(input.limit));
|
|
1165
1262
|
if (input.language) args.push("--language", input.language as string);
|
|
1263
|
+
if (visionMode !== undefined) args.push("--vision-mode", visionMode);
|
|
1264
|
+
if (visionLevel !== undefined) args.push("--vision-level", visionLevel);
|
|
1166
1265
|
return runReadSeek(context, args);
|
|
1167
1266
|
},
|
|
1168
1267
|
),
|
|
@@ -1235,7 +1334,7 @@ export const ReadSeekPlugin: Plugin = async (_input, options) => {
|
|
|
1235
1334
|
}
|
|
1236
1335
|
const linesByNumber = new Map<number, unknown>();
|
|
1237
1336
|
for (const [start, end] of mergedRanges) {
|
|
1238
|
-
const output = record(await
|
|
1337
|
+
const output = record(await runDigestContent(context, readRangeArgs(filePath, start, end)));
|
|
1239
1338
|
for (const hashline of items(output.hashlines)) {
|
|
1240
1339
|
const line = record(hashline).line;
|
|
1241
1340
|
if (typeof line === "number" && !linesByNumber.has(line)) linesByNumber.set(line, hashline);
|
|
@@ -1312,34 +1411,18 @@ export const ReadSeekPlugin: Plugin = async (_input, options) => {
|
|
|
1312
1411
|
throw new Error("line and column require scope");
|
|
1313
1412
|
}
|
|
1314
1413
|
const target = resolvePath(context.directory, (input.path as string | undefined) ?? ".");
|
|
1315
|
-
const args = ["refs", target
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1414
|
+
const args = ["refs", target];
|
|
1415
|
+
if (input.scope) {
|
|
1416
|
+
withAt(args, input.line as number, input.column as number | undefined);
|
|
1417
|
+
} else {
|
|
1418
|
+
args.push(input.name as string);
|
|
1419
|
+
}
|
|
1319
1420
|
if (input.language) args.push("--language", input.language as string);
|
|
1320
1421
|
withSearchFlags(args, input);
|
|
1321
1422
|
await authorizeSearch(context, target, input.name as string);
|
|
1322
1423
|
return runReadSeek(context, args);
|
|
1323
1424
|
},
|
|
1324
1425
|
),
|
|
1325
|
-
readseek_hover: readseekTool(
|
|
1326
|
-
"Inspect the symbol at a source location. This identifies the token and enclosing symbol; use it before definition lookup or binding-specific references.",
|
|
1327
|
-
{
|
|
1328
|
-
path: tool.schema.string().describe("Path relative to the project directory"),
|
|
1329
|
-
line: tool.schema.number().int().positive().describe("One-based cursor line"),
|
|
1330
|
-
column: tool.schema.number().int().positive().optional().describe("One-based cursor byte column for disambiguation"),
|
|
1331
|
-
language: tool.schema.string().optional().describe("Language override when auto-detection is ambiguous"),
|
|
1332
|
-
},
|
|
1333
|
-
"hover",
|
|
1334
|
-
async (input, context) => {
|
|
1335
|
-
const filePath = resolvePath(context.directory, input.path as string);
|
|
1336
|
-
await authorizeRead(context, filePath);
|
|
1337
|
-
const args = ["identify", `${filePath}:${input.line}`];
|
|
1338
|
-
if (input.column !== undefined) args.push("--column", String(input.column));
|
|
1339
|
-
if (input.language) args.push("--language", input.language as string);
|
|
1340
|
-
return runReadSeek(context, args);
|
|
1341
|
-
},
|
|
1342
|
-
),
|
|
1343
1426
|
readseek_rename: readseekTool(
|
|
1344
1427
|
"Rename a symbol at a source location. Resolvable same-named bindings are preserved; verified edits apply by default. Set `apply: false` to preview the plan before changing files.",
|
|
1345
1428
|
{
|
|
@@ -1355,14 +1438,16 @@ export const ReadSeekPlugin: Plugin = async (_input, options) => {
|
|
|
1355
1438
|
const filePath = resolvePath(context.directory, input.path as string);
|
|
1356
1439
|
await authorizeRead(context, filePath);
|
|
1357
1440
|
if (input.workspace) {
|
|
1358
|
-
const identifyArgs =
|
|
1359
|
-
|
|
1360
|
-
const
|
|
1441
|
+
const identifyArgs = digestArgs(filePath, "identity");
|
|
1442
|
+
withAt(identifyArgs, input.line as number, input.column as number | undefined);
|
|
1443
|
+
const envelope = await runReadSeek(context, identifyArgs);
|
|
1444
|
+
const name = identifiedName(requireDigestFacet(envelope, "identity"));
|
|
1361
1445
|
if (!name) throw new Error("readseek could not identify a binding at the rename cursor");
|
|
1362
1446
|
await authorizeSearch(context, context.directory, name);
|
|
1363
1447
|
}
|
|
1364
|
-
const args = ["rename", filePath
|
|
1365
|
-
|
|
1448
|
+
const args = ["rename", filePath];
|
|
1449
|
+
withAt(args, input.line as number, input.column as number | undefined);
|
|
1450
|
+
args.push("--to", input.to as string);
|
|
1366
1451
|
if (input.workspace) args.push("--workspace", context.directory);
|
|
1367
1452
|
const plan = await runReadSeek(context, args);
|
|
1368
1453
|
if (input.apply === false) return plan;
|
|
@@ -1376,7 +1461,7 @@ export const ReadSeekPlugin: Plugin = async (_input, options) => {
|
|
|
1376
1461
|
},
|
|
1377
1462
|
),
|
|
1378
1463
|
readseek_edit: readseekTool(
|
|
1379
|
-
"Edit an existing text file with fresh LINE:HASH anchors
|
|
1464
|
+
"Edit an existing text file with fresh LINE:HASH anchors, exact text, or a mapped symbol. The native engine rejects stale or overlapping edits before writing.",
|
|
1380
1465
|
{
|
|
1381
1466
|
path: tool.schema.string().describe("Path relative to the project directory"),
|
|
1382
1467
|
edits: tool.schema.array(tool.schema.union([
|
|
@@ -1399,31 +1484,60 @@ export const ReadSeekPlugin: Plugin = async (_input, options) => {
|
|
|
1399
1484
|
new_text: tool.schema.string().describe("Text to insert after the anchored line"),
|
|
1400
1485
|
}).strict(),
|
|
1401
1486
|
}).strict(),
|
|
1402
|
-
|
|
1487
|
+
tool.schema.object({
|
|
1488
|
+
replace: tool.schema.object({
|
|
1489
|
+
old_text: tool.schema.string().describe("Exact text to find"),
|
|
1490
|
+
new_text: tool.schema.string().describe("Replacement text"),
|
|
1491
|
+
all: tool.schema.boolean().optional().describe("Replace every exact match"),
|
|
1492
|
+
}).strict(),
|
|
1493
|
+
}).strict(),
|
|
1494
|
+
tool.schema.object({
|
|
1495
|
+
replace_symbol: tool.schema.object({
|
|
1496
|
+
symbol: tool.schema.string().describe("Mapped symbol name"),
|
|
1497
|
+
new_body: tool.schema.string().describe("Complete replacement body for the symbol"),
|
|
1498
|
+
}).strict(),
|
|
1499
|
+
}).strict(),
|
|
1500
|
+
])).min(1).describe("Use set_line, replace_lines, insert_after, replace, or replace_symbol"),
|
|
1501
|
+
language: tool.schema.string().optional().describe("Language override for symbol replacement"),
|
|
1403
1502
|
},
|
|
1404
1503
|
"edit",
|
|
1405
1504
|
async (input, context) => {
|
|
1406
1505
|
const filePath = resolvePath(context.directory, input.path as string);
|
|
1407
|
-
|
|
1506
|
+
validateEdits(input.edits);
|
|
1408
1507
|
return withFileMutationQueue(filePath, async () => {
|
|
1409
1508
|
context.abort.throwIfAborted();
|
|
1410
1509
|
await authorizeRead(context, filePath);
|
|
1411
1510
|
await rejectSymlinkMutation(filePath);
|
|
1412
|
-
const edits = input.edits as
|
|
1413
|
-
const
|
|
1414
|
-
|
|
1415
|
-
|
|
1511
|
+
const edits = input.edits as EditItem[];
|
|
1512
|
+
const request = JSON.stringify({ edits });
|
|
1513
|
+
const baseArgs = ["edit", filePath];
|
|
1514
|
+
if (input.language) baseArgs.push("--language", input.language as string);
|
|
1515
|
+
const plan = record(await runReadSeek(context, baseArgs, { stdin: request }));
|
|
1516
|
+
if (
|
|
1517
|
+
typeof plan.before_content !== "string" ||
|
|
1518
|
+
typeof plan.content !== "string" ||
|
|
1519
|
+
typeof plan.plan_hash !== "string"
|
|
1520
|
+
) {
|
|
1521
|
+
throw new Error("readseek edit plan is missing before_content, content, or plan_hash");
|
|
1522
|
+
}
|
|
1523
|
+
const before = plan.before_content;
|
|
1524
|
+
const after = plan.content;
|
|
1525
|
+
const previewStart = Math.max(1, firstChangedLine(before, after) - 3);
|
|
1526
|
+
const previewArgs = readRangeArgs(filePath, previewStart, previewStart + EDIT_RESULT_READ_LIMIT - 1);
|
|
1527
|
+
if (before === after) {
|
|
1528
|
+
return postMutationPreview(context, previewArgs, filePath, false);
|
|
1529
|
+
}
|
|
1416
1530
|
await authorizeEdit(context, [filePath], simpleDiff(filePath, before, after), false);
|
|
1417
1531
|
context.abort.throwIfAborted();
|
|
1418
|
-
|
|
1419
|
-
const firstEditedLine = Math.min(...edits.flatMap((edit) => anchorRefs(edit).map((ref) => ref.line)));
|
|
1420
|
-
const previewStart = Math.max(1, firstEditedLine - 3);
|
|
1421
|
-
return postMutationPreview(
|
|
1532
|
+
const applied = record(await runReadSeek(
|
|
1422
1533
|
context,
|
|
1423
|
-
["
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
)
|
|
1534
|
+
[...baseArgs, "--apply", "--plan-hash", plan.plan_hash],
|
|
1535
|
+
{ cancelable: false, stdin: request },
|
|
1536
|
+
));
|
|
1537
|
+
if (applied.applied !== true) {
|
|
1538
|
+
throw new Error("readseek did not confirm that the edit was applied");
|
|
1539
|
+
}
|
|
1540
|
+
return postMutationPreview(context, previewArgs, filePath, true);
|
|
1427
1541
|
});
|
|
1428
1542
|
},
|
|
1429
1543
|
),
|
|
@@ -1448,28 +1562,13 @@ export const ReadSeekPlugin: Plugin = async (_input, options) => {
|
|
|
1448
1562
|
await writeText(filePath, before, content);
|
|
1449
1563
|
return postMutationPreview(
|
|
1450
1564
|
context,
|
|
1451
|
-
|
|
1565
|
+
digestArgs(filePath, "content", ["--end", String(DEFAULT_READ_LIMIT)]),
|
|
1452
1566
|
filePath,
|
|
1453
1567
|
!before.exists || before.content !== content,
|
|
1454
1568
|
);
|
|
1455
1569
|
});
|
|
1456
1570
|
},
|
|
1457
1571
|
),
|
|
1458
|
-
readseek_check: readseekTool(
|
|
1459
|
-
"Check a source file for parser errors or missing syntax after edits. Validation requires a supported tree-sitter language; files without a supported parser are not checked. This is not a compiler, type checker, test runner, or linter.",
|
|
1460
|
-
{
|
|
1461
|
-
path: tool.schema.string().describe("Path relative to the project directory"),
|
|
1462
|
-
language: tool.schema.string().optional().describe("Language override when auto-detection is ambiguous"),
|
|
1463
|
-
},
|
|
1464
|
-
"check",
|
|
1465
|
-
async (input, context) => {
|
|
1466
|
-
const filePath = resolvePath(context.directory, input.path as string);
|
|
1467
|
-
await authorizeRead(context, filePath);
|
|
1468
|
-
const args = ["check", filePath];
|
|
1469
|
-
if (input.language) args.push("--language", input.language as string);
|
|
1470
|
-
return runReadSeek(context, args);
|
|
1471
|
-
},
|
|
1472
|
-
),
|
|
1473
1572
|
},
|
|
1474
1573
|
"experimental.chat.system.transform": async (_input, output) => {
|
|
1475
1574
|
output.system.push(TOOL_POLICY);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-readseek",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "OpenCode plugin for anchored file operations and structural code navigation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.ts",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"node": ">=20.0.0"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@jarkkojs/readseek": "^0.
|
|
34
|
+
"@jarkkojs/readseek": "^0.9.0",
|
|
35
35
|
"@opencode-ai/plugin": "^1.17.20"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|