@oh-my-pi/pi-coding-agent 14.4.1 → 14.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/package.json +7 -7
  3. package/src/cli.ts +0 -1
  4. package/src/config/prompt-templates.ts +0 -30
  5. package/src/config/settings-schema.ts +68 -36
  6. package/src/config/settings.ts +1 -1
  7. package/src/edit/index.ts +1 -53
  8. package/src/edit/line-hash.ts +0 -53
  9. package/src/edit/modes/atom.ts +82 -47
  10. package/src/edit/modes/hashline.ts +6 -8
  11. package/src/edit/renderer.ts +6 -8
  12. package/src/edit/streaming.ts +90 -114
  13. package/src/export/html/template.generated.ts +1 -1
  14. package/src/export/html/template.js +10 -15
  15. package/src/internal-urls/docs-index.generated.ts +1 -2
  16. package/src/modes/components/session-observer-overlay.ts +635 -295
  17. package/src/modes/components/settings-defs.ts +1 -5
  18. package/src/modes/components/tool-execution.ts +2 -5
  19. package/src/modes/controllers/btw-controller.ts +17 -105
  20. package/src/modes/controllers/command-controller.ts +16 -5
  21. package/src/modes/controllers/selector-controller.ts +32 -19
  22. package/src/modes/controllers/todo-command-controller.ts +537 -0
  23. package/src/modes/interactive-mode.ts +45 -10
  24. package/src/modes/types.ts +3 -0
  25. package/src/modes/utils/ui-helpers.ts +17 -0
  26. package/src/prompts/system/irc-incoming.md +8 -0
  27. package/src/prompts/system/subagent-system-prompt.md +8 -0
  28. package/src/prompts/tools/ast-grep.md +1 -1
  29. package/src/prompts/tools/atom.md +37 -26
  30. package/src/prompts/tools/bash.md +2 -2
  31. package/src/prompts/tools/grep.md +2 -5
  32. package/src/prompts/tools/irc.md +49 -0
  33. package/src/prompts/tools/job.md +11 -0
  34. package/src/prompts/tools/read.md +12 -13
  35. package/src/prompts/tools/task.md +1 -1
  36. package/src/prompts/tools/todo-write.md +14 -5
  37. package/src/registry/agent-registry.ts +139 -0
  38. package/src/sdk.ts +35 -0
  39. package/src/session/agent-session.ts +226 -6
  40. package/src/session/session-manager.ts +13 -0
  41. package/src/session/session-storage.ts +4 -0
  42. package/src/session/streaming-output.ts +1 -1
  43. package/src/slash-commands/builtin-registry.ts +32 -0
  44. package/src/task/executor.ts +14 -0
  45. package/src/tools/bash.ts +1 -1
  46. package/src/tools/fetch.ts +18 -6
  47. package/src/tools/fs-cache-invalidation.ts +0 -5
  48. package/src/tools/grep.ts +4 -124
  49. package/src/tools/index.ts +12 -6
  50. package/src/tools/irc.ts +258 -0
  51. package/src/tools/job.ts +489 -0
  52. package/src/tools/match-line-format.ts +7 -6
  53. package/src/tools/output-meta.ts +1 -1
  54. package/src/tools/read.ts +36 -126
  55. package/src/tools/renderers.ts +2 -0
  56. package/src/tools/todo-write.ts +243 -12
  57. package/src/utils/edit-mode.ts +1 -2
  58. package/src/utils/file-display-mode.ts +0 -3
  59. package/src/web/search/index.ts +2 -2
  60. package/src/web/search/provider.ts +3 -0
  61. package/src/web/search/providers/searxng.ts +238 -0
  62. package/src/web/search/types.ts +3 -1
  63. package/src/cli/read-cli.ts +0 -67
  64. package/src/commands/read.ts +0 -33
  65. package/src/edit/modes/chunk.ts +0 -832
  66. package/src/prompts/tools/cancel-job.md +0 -5
  67. package/src/prompts/tools/chunk-edit.md +0 -158
  68. package/src/prompts/tools/poll.md +0 -5
  69. package/src/prompts/tools/read-chunk.md +0 -73
  70. package/src/tools/cancel-job.ts +0 -95
  71. package/src/tools/poll-tool.ts +0 -173
@@ -1,832 +0,0 @@
1
- import * as fs from "node:fs/promises";
2
- import * as nodePath from "node:path";
3
- import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
4
- import { StringEnum } from "@oh-my-pi/pi-ai";
5
- import {
6
- ChunkAnchorStyle,
7
- ChunkEditOp,
8
- type ChunkInfo,
9
- ChunkReadStatus,
10
- type ChunkReadTarget,
11
- ChunkRegion,
12
- ChunkState,
13
- type EditOperation as NativeEditOperation,
14
- } from "@oh-my-pi/pi-natives";
15
- import { $envpos } from "@oh-my-pi/pi-utils";
16
- import { type Static, Type } from "@sinclair/typebox";
17
- import type { BunFile } from "bun";
18
- import { LRUCache } from "lru-cache";
19
- import type { Settings } from "../../config/settings";
20
- import type { WritethroughCallback, WritethroughDeferredHandle } from "../../lsp";
21
- import { getLanguageFromPath } from "../../modes/theme/theme";
22
- import type { ToolSession } from "../../tools";
23
- import { assertEditableFileContent } from "../../tools/auto-generated-guard";
24
- import { invalidateFsScanAfterWrite } from "../../tools/fs-cache-invalidation";
25
- import { outputMeta } from "../../tools/output-meta";
26
- import { enforcePlanModeWrite, resolvePlanPath } from "../../tools/plan-mode-guard";
27
- import { generateUnifiedDiffString } from "../diff";
28
- import { HASHLINE_BIGRAMS } from "../line-hash";
29
- import { detectLineEnding, normalizeToLF, restoreLineEndings, stripBom } from "../normalize";
30
- import type { EditToolDetails, LspBatchRequest } from "../renderer";
31
-
32
- export type { ChunkReadTarget };
33
-
34
- export type ChunkEditOperation =
35
- | { op: "put"; sel?: string; content: string }
36
- | { op: "delete"; sel?: string }
37
- | { op: "before"; sel?: string; content: string }
38
- | { op: "after"; sel?: string; content: string }
39
- | { op: "prepend"; sel?: string; content: string }
40
- | { op: "append"; sel?: string; content: string };
41
-
42
- type ChunkEditResult = {
43
- diffSourceBefore: string;
44
- diffSourceAfter: string;
45
- responseText: string;
46
- changed: boolean;
47
- parseValid: boolean;
48
- touchedPaths: string[];
49
- warnings: string[];
50
- };
51
-
52
- export type ParsedChunkReadPath = {
53
- filePath: string;
54
- selector?: string;
55
- };
56
-
57
- type ChunkCacheEntry = {
58
- mtimeMs: number;
59
- size: number;
60
- source: string;
61
- state: ChunkState;
62
- };
63
-
64
- const validAnchorStyles: Record<string, ChunkAnchorStyle> = {
65
- full: ChunkAnchorStyle.Full,
66
- kind: ChunkAnchorStyle.Kind,
67
- bare: ChunkAnchorStyle.Bare,
68
- };
69
-
70
- export function resolveChunkAutoIndent(rawValue = Bun.env.PI_CHUNK_AUTOINDENT): boolean {
71
- if (!rawValue) return true;
72
- const normalized = rawValue.trim().toLowerCase();
73
- switch (normalized) {
74
- case "1":
75
- case "true":
76
- case "yes":
77
- case "on":
78
- return true;
79
- case "0":
80
- case "false":
81
- case "no":
82
- case "off":
83
- return false;
84
- default:
85
- throw new Error(`Invalid PI_CHUNK_AUTOINDENT: ${rawValue}`);
86
- }
87
- }
88
-
89
- function getChunkRenderIndentOptions(): {
90
- normalizeIndent: boolean;
91
- tabReplacement: string;
92
- } {
93
- return resolveChunkAutoIndent()
94
- ? { normalizeIndent: true, tabReplacement: " " }
95
- : { normalizeIndent: false, tabReplacement: "\t" };
96
- }
97
-
98
- export function resolveAnchorStyle(settings?: Settings): ChunkAnchorStyle {
99
- const envStyle = Bun.env.PI_ANCHOR_STYLE;
100
- return (
101
- (envStyle && validAnchorStyles[envStyle]) ||
102
- (settings?.get("read.anchorstyle") as ChunkAnchorStyle | undefined) ||
103
- ChunkAnchorStyle.Full
104
- );
105
- }
106
-
107
- const chunkStateCache = new LRUCache<string, ChunkCacheEntry>({
108
- max: $envpos("PI_CHUNK_CACHE_MAX_ENTRIES", 200),
109
- });
110
-
111
- export function invalidateChunkCache(filePath: string): void {
112
- chunkStateCache.delete(filePath);
113
- }
114
-
115
- type ChunkSourceContext = {
116
- resolvedPath: string;
117
- sourceFile: BunFile;
118
- sourceExists: boolean;
119
- rawContent: string;
120
- chunkLanguage: string | undefined;
121
- };
122
-
123
- type ChunkSourceIntent = "read" | "write";
124
-
125
- function normalizeLanguage(language: string | undefined): string {
126
- return language?.trim().toLowerCase() || "";
127
- }
128
-
129
- function normalizeChunkSource(text: string): string {
130
- return normalizeToLF(stripBom(text).text);
131
- }
132
-
133
- function displayPathForFile(filePath: string, cwd: string): string {
134
- const relative = nodePath.relative(cwd, filePath).replace(/\\/g, "/");
135
- return relative && !relative.startsWith("..") ? relative : filePath.replace(/\\/g, "/");
136
- }
137
-
138
- function fileLanguageTag(filePath: string, language?: string): string | undefined {
139
- const normalizedLanguage = normalizeLanguage(language);
140
- if (normalizedLanguage.length > 0) return normalizedLanguage;
141
- const ext = nodePath.extname(filePath).replace(/^\./, "").toLowerCase();
142
- return ext.length > 0 ? ext : undefined;
143
- }
144
-
145
- async function resolveChunkSourceContext(
146
- session: ToolSession,
147
- path: string,
148
- options?: { intent?: ChunkSourceIntent },
149
- ): Promise<ChunkSourceContext> {
150
- const resolvedPath = resolvePlanPath(session, path);
151
- const sourceFile = Bun.file(resolvedPath);
152
- const sourceExists = await sourceFile.exists();
153
- if ((options?.intent ?? "write") === "write") {
154
- enforcePlanModeWrite(session, path, { op: sourceExists ? "update" : "create" });
155
- }
156
-
157
- let rawContent = "";
158
- if (sourceExists) {
159
- rawContent = await sourceFile.text();
160
- assertEditableFileContent(rawContent, path);
161
- }
162
-
163
- return {
164
- resolvedPath,
165
- sourceFile,
166
- sourceExists,
167
- rawContent,
168
- chunkLanguage: getLanguageFromPath(resolvedPath),
169
- };
170
- }
171
-
172
- /**
173
- * Preview-safe loader: read raw source without plan-mode enforcement or
174
- * editable-file guards. Used by streaming diff previews that must not throw
175
- * side-effecting errors while args are still being streamed.
176
- */
177
- export async function loadChunkSource(params: {
178
- cwd: string;
179
- path: string;
180
- }): Promise<{ resolvedPath: string; rawContent: string; language: string | undefined; exists: boolean }> {
181
- const resolvedPath = nodePath.isAbsolute(params.path) ? params.path : nodePath.resolve(params.cwd, params.path);
182
- const sourceFile = Bun.file(resolvedPath);
183
- const exists = await sourceFile.exists();
184
- const rawContent = exists ? await sourceFile.text() : "";
185
- return { resolvedPath, rawContent, language: getLanguageFromPath(resolvedPath), exists };
186
- }
187
-
188
- /**
189
- * Compute a unified diff preview for a chunk edit without applying it.
190
- * Used for streaming previews while args are still arriving. Returns
191
- * `{ error }` on any failure so callers can decide whether to surface it.
192
- */
193
- export async function computeChunkDiff(
194
- input: { path: string; edits: ChunkToolEdit[] },
195
- cwd: string,
196
- options?: { anchorStyle?: ChunkAnchorStyle; signal?: AbortSignal },
197
- ): Promise<{ diff: string; firstChangedLine: number | undefined } | { error: string }> {
198
- try {
199
- options?.signal?.throwIfAborted?.();
200
- const { filePath } = parseChunkEditPath(input.path);
201
- if (!filePath) return { error: "chunk edit path is empty" };
202
- const { resolvedPath, rawContent, language } = await loadChunkSource({ cwd, path: filePath });
203
- options?.signal?.throwIfAborted?.();
204
- const { operations } = normalizeChunkEditOperations(input.edits);
205
- const result = applyChunkEdits({
206
- source: rawContent,
207
- language,
208
- cwd,
209
- filePath: resolvedPath,
210
- operations,
211
- anchorStyle: options?.anchorStyle,
212
- });
213
- options?.signal?.throwIfAborted?.();
214
- if (!result.changed) {
215
- return { diff: "", firstChangedLine: undefined };
216
- }
217
- return generateUnifiedDiffString(result.diffSourceBefore, result.diffSourceAfter);
218
- } catch (err) {
219
- return { error: err instanceof Error ? err.message : String(err) };
220
- }
221
- }
222
-
223
- function normalizeChunkRegionSyntax(text: string): string {
224
- return text.replaceAll("@body", "~").replaceAll("@head", "^");
225
- }
226
-
227
- function buildChunkEditResult(result: {
228
- diffBefore: string;
229
- diffAfter: string;
230
- responseText: string;
231
- changed: boolean;
232
- parseValid: boolean;
233
- touchedPaths: string[];
234
- warnings: string[];
235
- }): ChunkEditResult {
236
- return {
237
- diffSourceBefore: result.diffBefore,
238
- diffSourceAfter: result.diffAfter,
239
- responseText: result.responseText,
240
- changed: result.changed,
241
- parseValid: result.parseValid,
242
- touchedPaths: result.touchedPaths,
243
- warnings: result.warnings.map(normalizeChunkRegionSyntax),
244
- };
245
- }
246
-
247
- function chunkReadPathSeparatorIndex(readPath: string): number {
248
- if (/^[a-zA-Z]:[/\\]/.test(readPath)) {
249
- return readPath.indexOf(":", 2);
250
- }
251
- const urlMatch = readPath.match(/^([a-z][a-z0-9+.-]*):\/\//i);
252
- if (urlMatch) {
253
- const scheme = urlMatch[1].toLowerCase();
254
- const urlPrefixEnd = urlMatch[0].length;
255
- if (scheme === "local") {
256
- const index = readPath.lastIndexOf(":");
257
- return index >= urlPrefixEnd ? index : -1;
258
- }
259
-
260
- const pathStart = readPath.indexOf("/", urlPrefixEnd);
261
- if (pathStart === -1) return -1;
262
- const index = readPath.lastIndexOf(":");
263
- return index >= pathStart ? index : -1;
264
- }
265
- return readPath.indexOf(":");
266
- }
267
-
268
- export function parseChunkSelector(selector: string | undefined): { selector?: string } {
269
- if (!selector || selector.length === 0) {
270
- return {};
271
- }
272
- return { selector };
273
- }
274
-
275
- /** Split a combined `file:selector` path into file path and chunk selector. */
276
- export function parseChunkEditPath(editPath: string | undefined): { filePath: string; selector?: string } {
277
- if (!editPath) return { filePath: "" };
278
- const colonIndex = chunkReadPathSeparatorIndex(editPath);
279
- if (colonIndex === -1) {
280
- return { filePath: editPath };
281
- }
282
- const sel = editPath.slice(colonIndex + 1) || undefined;
283
- return { filePath: editPath.slice(0, colonIndex), selector: sel };
284
- }
285
-
286
- export function parseChunkReadPath(readPath: string): ParsedChunkReadPath {
287
- const colonIndex = chunkReadPathSeparatorIndex(readPath);
288
- if (colonIndex === -1) {
289
- return { filePath: readPath };
290
- }
291
- const parsedSelector = parseChunkSelector(readPath.slice(colonIndex + 1) || undefined);
292
- return {
293
- filePath: readPath.slice(0, colonIndex),
294
- selector: parsedSelector.selector,
295
- };
296
- }
297
-
298
- export function isChunkReadablePath(readPath: string): boolean {
299
- return parseChunkReadPath(readPath).selector !== undefined;
300
- }
301
-
302
- export async function loadChunkStateForFile(filePath: string, language: string | undefined): Promise<ChunkCacheEntry> {
303
- const file = Bun.file(filePath);
304
- const stat = await file.stat();
305
- const cached = chunkStateCache.get(filePath);
306
- if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
307
- return cached;
308
- }
309
-
310
- const source = normalizeChunkSource(await file.text());
311
- const state = ChunkState.parse(source, normalizeLanguage(language));
312
- const entry = { mtimeMs: stat.mtimeMs, size: stat.size, source, state };
313
- chunkStateCache.set(filePath, entry);
314
- return entry;
315
- }
316
-
317
- export async function formatChunkedRead(params: {
318
- filePath: string;
319
- readPath: string;
320
- cwd: string;
321
- language?: string;
322
- omitChecksum?: boolean;
323
- anchorStyle?: ChunkAnchorStyle;
324
- absoluteLineRange?: { startLine: number; endLine?: number };
325
- }): Promise<{ text: string; resolvedPath?: string; chunk?: ChunkReadTarget }> {
326
- const { filePath, readPath, cwd, language, omitChecksum = false, anchorStyle, absoluteLineRange } = params;
327
- const normalizedLanguage = normalizeLanguage(language);
328
- const { state } = await loadChunkStateForFile(filePath, normalizedLanguage);
329
- const displayPath = displayPathForFile(filePath, cwd);
330
- const renderIndentOptions = getChunkRenderIndentOptions();
331
- const result = state.renderRead({
332
- readPath,
333
- displayPath,
334
- languageTag: fileLanguageTag(filePath, normalizedLanguage),
335
- omitChecksum,
336
- anchorStyle,
337
- absoluteLineRange: absoluteLineRange
338
- ? { startLine: absoluteLineRange.startLine, endLine: absoluteLineRange.endLine ?? absoluteLineRange.startLine }
339
- : undefined,
340
- tabReplacement: renderIndentOptions.tabReplacement,
341
- normalizeIndent: renderIndentOptions.normalizeIndent,
342
- });
343
- return { text: result.text, resolvedPath: filePath, chunk: result.chunk };
344
- }
345
-
346
- export type ChunkedGrepMatch = {
347
- displayPath: string;
348
- fileLineCount: number;
349
- chunkPath?: string;
350
- chunkChecksum?: string;
351
- lineNumber: number;
352
- line: string;
353
- };
354
-
355
- export async function describeChunkedGrepMatch(params: {
356
- filePath: string;
357
- lineNumber: number;
358
- line: string;
359
- cwd: string;
360
- language?: string;
361
- }): Promise<ChunkedGrepMatch> {
362
- const { filePath, lineNumber, line, cwd, language } = params;
363
- const { state } = await loadChunkStateForFile(filePath, language);
364
- const chunkPath = state.lineToContainingChunkPath(lineNumber) || undefined;
365
- const chunkInfo = chunkPath ? state.chunk(chunkPath) : null;
366
- return {
367
- displayPath: displayPathForFile(filePath, cwd),
368
- fileLineCount: state.lineCount,
369
- chunkPath,
370
- chunkChecksum: chunkInfo?.checksum,
371
- lineNumber,
372
- line,
373
- };
374
- }
375
-
376
- const CHUNK_CHECKSUM_BIGRAMS = new Set<string>(HASHLINE_BIGRAMS);
377
- type NativeChunkRegion = "head" | "body";
378
-
379
- function isChunkChecksumToken(value: string): boolean {
380
- if (value.length !== 4) return false;
381
- const lower = value.toLowerCase();
382
- return CHUNK_CHECKSUM_BIGRAMS.has(lower.slice(0, 2)) && CHUNK_CHECKSUM_BIGRAMS.has(lower.slice(2, 4));
383
- }
384
-
385
- function parseChunkEditSelector(selector: string | undefined): {
386
- selector?: string;
387
- crc?: string;
388
- region?: NativeChunkRegion;
389
- } {
390
- if (!selector) {
391
- return {};
392
- }
393
-
394
- let trimmed = selector.trim();
395
- if (trimmed.length === 0) {
396
- return {};
397
- }
398
-
399
- let region: NativeChunkRegion | undefined;
400
- const suffix = trimmed.at(-1);
401
- if (suffix === "~" || suffix === "^") {
402
- region = suffix === "~" ? "body" : "head";
403
- trimmed = trimmed.slice(0, -1).trimEnd();
404
- }
405
-
406
- let selectorPart = trimmed;
407
- let crc: string | undefined;
408
- const hashIndex = selectorPart.lastIndexOf("#");
409
- if (hashIndex >= 0) {
410
- const suffix = selectorPart.slice(hashIndex + 1).trim();
411
- if (isChunkChecksumToken(suffix)) {
412
- crc = suffix.toLowerCase();
413
- selectorPart = selectorPart.slice(0, hashIndex).trimEnd();
414
- }
415
- } else if (isChunkChecksumToken(selectorPart)) {
416
- crc = selectorPart.toLowerCase();
417
- selectorPart = "";
418
- }
419
-
420
- return { selector: selectorPart || undefined, crc, region };
421
- }
422
-
423
- type NativeChunkRegionEncoding = "named" | "symbolic";
424
-
425
- function toNativeEditRegion(
426
- region: NativeChunkRegion | undefined,
427
- encoding: NativeChunkRegionEncoding,
428
- ): NativeEditOperation["region"] | undefined {
429
- if (!region) {
430
- return undefined;
431
- }
432
- if (encoding === "symbolic") {
433
- return region === "body" ? ChunkRegion.Body : ChunkRegion.Head;
434
- }
435
- return region as unknown as NativeEditOperation["region"] | undefined;
436
- }
437
-
438
- function toNativeEditOperation(
439
- operation: ChunkEditOperation,
440
- defaultRegion: NativeChunkRegion | undefined,
441
- encoding: NativeChunkRegionEncoding,
442
- ): NativeEditOperation {
443
- const { selector, crc, region } = parseChunkEditSelector(operation.sel);
444
- const nativeRegion = toNativeEditRegion(operation.sel === undefined ? (region ?? defaultRegion) : region, encoding);
445
- switch (operation.op) {
446
- case "put":
447
- return {
448
- op: ChunkEditOp.Put,
449
- sel: selector,
450
- crc,
451
- region: nativeRegion,
452
- content: operation.content,
453
- };
454
- case "before":
455
- return { op: ChunkEditOp.Before, sel: selector, crc, region: nativeRegion, content: operation.content };
456
- case "after":
457
- return { op: ChunkEditOp.After, sel: selector, crc, region: nativeRegion, content: operation.content };
458
- case "prepend":
459
- return { op: ChunkEditOp.Prepend, sel: selector, crc, region: nativeRegion, content: operation.content };
460
- case "append":
461
- return { op: ChunkEditOp.Append, sel: selector, crc, region: nativeRegion, content: operation.content };
462
- case "delete":
463
- return { op: ChunkEditOp.Delete, sel: selector, crc, region: nativeRegion };
464
- default: {
465
- const exhaustive: never = operation;
466
- return exhaustive;
467
- }
468
- }
469
- }
470
-
471
- function buildNativeChunkEditRequest(
472
- params: { defaultSelector?: string; defaultCrc?: string; operations: ChunkEditOperation[] },
473
- encoding: NativeChunkRegionEncoding,
474
- ): Pick<Parameters<ChunkState["applyEdits"]>[0], "operations" | "defaultSelector" | "defaultCrc"> {
475
- const parsedDefaultSelector = parseChunkEditSelector(params.defaultSelector);
476
- const operations = params.operations.map(operation =>
477
- toNativeEditOperation(operation, parsedDefaultSelector.region, encoding),
478
- );
479
- return {
480
- operations,
481
- defaultSelector: parsedDefaultSelector.selector,
482
- defaultCrc: params.defaultCrc ?? parsedDefaultSelector.crc,
483
- };
484
- }
485
-
486
- function isChunkRegionEncodingError(error: unknown): error is Error {
487
- return (
488
- error instanceof Error &&
489
- /value `"(body|head|~|\^)"` does not match any variant of enum `ChunkRegion`/.test(error.message)
490
- );
491
- }
492
-
493
- export function applyChunkEdits(params: {
494
- source: string;
495
- language?: string;
496
- cwd: string;
497
- filePath: string;
498
- operations: ChunkEditOperation[];
499
- defaultSelector?: string;
500
- defaultCrc?: string;
501
- anchorStyle?: ChunkAnchorStyle;
502
- }): ChunkEditResult {
503
- const normalizedSource = normalizeChunkSource(params.source);
504
- const applyNativeEdits = (encoding: NativeChunkRegionEncoding): ChunkEditResult => {
505
- const request = buildNativeChunkEditRequest(params, encoding);
506
- const state = ChunkState.parse(normalizedSource, normalizeLanguage(params.language));
507
- return buildChunkEditResult(
508
- state.applyEdits({
509
- operations: request.operations,
510
- normalizeIndent: resolveChunkAutoIndent(),
511
- defaultSelector: request.defaultSelector,
512
- defaultCrc: request.defaultCrc,
513
- anchorStyle: params.anchorStyle,
514
- cwd: params.cwd,
515
- filePath: params.filePath,
516
- }),
517
- );
518
- };
519
-
520
- try {
521
- return applyNativeEdits("named");
522
- } catch (error) {
523
- if (isChunkRegionEncodingError(error)) {
524
- try {
525
- return applyNativeEdits("symbolic");
526
- } catch (fallbackError) {
527
- if (fallbackError instanceof Error) {
528
- throw new Error(normalizeChunkRegionSyntax(fallbackError.message));
529
- }
530
- throw fallbackError;
531
- }
532
- }
533
- if (error instanceof Error) {
534
- throw new Error(normalizeChunkRegionSyntax(error.message));
535
- }
536
- throw error;
537
- }
538
- }
539
-
540
- export async function getChunkInfoForFile(
541
- filePath: string,
542
- language: string | undefined,
543
- chunkPath: string,
544
- ): Promise<ChunkInfo | undefined> {
545
- const { state } = await loadChunkStateForFile(filePath, language);
546
- return state.chunk(chunkPath) ?? undefined;
547
- }
548
-
549
- export function missingChunkReadTarget(selector: string): ChunkReadTarget {
550
- return { status: ChunkReadStatus.NotFound, selector };
551
- }
552
-
553
- export const chunkToolEditSchema = Type.Object(
554
- {
555
- path: Type.Optional(
556
- Type.String({
557
- description: "File path with chunk selector. Examples: 'src/app.ts:fn_foo#thth~', 'src/app.ts:class_Bar'.",
558
- }),
559
- ),
560
- write: Type.Optional(
561
- Type.Union([Type.String(), Type.Null()], {
562
- description:
563
- "Write complete new content to the targeted region. Null is rejected; use delete: true for deletion.",
564
- }),
565
- ),
566
- delete: Type.Optional(
567
- Type.Boolean({
568
- description: "Explicitly delete the targeted chunk. Must be true; include the current chunk ID.",
569
- }),
570
- ),
571
- insert: Type.Optional(
572
- Type.Object(
573
- {
574
- loc: StringEnum(["append", "prepend"] as const),
575
- body: Type.String({ description: "Content to insert." }),
576
- },
577
- { description: "Insert content relative to the chunk." },
578
- ),
579
- ),
580
- },
581
- { additionalProperties: false },
582
- );
583
- export const chunkEditParamsSchema = Type.Object(
584
- {
585
- path: Type.Optional(Type.String({ description: "Default file path used when an edit omits its own `path`" })),
586
- edits: Type.Array(chunkToolEditSchema, {
587
- description: "Chunk edits",
588
- minItems: 1,
589
- }),
590
- },
591
- { additionalProperties: false },
592
- );
593
-
594
- export type ChunkToolEdit = Static<typeof chunkToolEditSchema>;
595
- export type ChunkParams = Static<typeof chunkEditParamsSchema>;
596
-
597
- export interface ExecuteChunkSingleOptions {
598
- session: ToolSession;
599
- path: string;
600
- edits: ChunkToolEdit[];
601
- signal?: AbortSignal;
602
- batchRequest?: LspBatchRequest;
603
- writethrough: WritethroughCallback;
604
- beginDeferredDiagnosticsForPath: (path: string) => WritethroughDeferredHandle;
605
- }
606
-
607
- /** Auto-correct indentation for content targeting a body region (`~`) when autoIndent is on.
608
- * Handles two patterns:
609
- * 1. Tab-based over-indentation: models include the function's base \t indent.
610
- * 2. Space-based indentation: models use literal spaces instead of \t.
611
- * Returns the corrected content and any warnings. */
612
- function autoCorrectBodyIndent(content: string, index: number): { content: string; warnings: string[] } {
613
- const warnings: string[] = [];
614
- if (!content || !resolveChunkAutoIndent()) return { content, warnings };
615
- const lines = content.split("\n");
616
- const nonEmpty = lines.filter(l => l.length > 0);
617
- if (nonEmpty.length <= 1) return { content, warnings };
618
-
619
- // 1. Tab-based over-indentation: strip common leading tabs.
620
- const minTabs = Math.min(...nonEmpty.map(l => l.match(/^\t*/)?.[0].length ?? 0));
621
- if (minTabs >= 1) {
622
- const fixed = lines.map(l => (l.length === 0 ? l : l.slice(minTabs))).join("\n");
623
- warnings.push(
624
- `Edit ${index + 1}: auto-corrected body indentation \u2014 stripped ${minTabs} leading tab(s). When writing to \`~\`, write at column 0; the tool adds the function's base indent.`,
625
- );
626
- return { content: fixed, warnings };
627
- }
628
-
629
- // 2. Space-based indentation: strip common leading spaces and convert to tabs.
630
- const spaceIndents = nonEmpty.map(l => l.match(/^ */)?.[0].length ?? 0);
631
- const minSpaces = Math.min(...spaceIndents);
632
- if (minSpaces >= 2) {
633
- const indentDiffs = spaceIndents.map(s => s - minSpaces).filter(d => d > 0);
634
- const indentUnit = indentDiffs.length > 0 ? Math.min(...indentDiffs) : 4;
635
- const unit = indentUnit >= 2 && indentUnit <= 8 ? indentUnit : 4;
636
- const fixed = lines
637
- .map(line => {
638
- if (line.length === 0) return line;
639
- const stripped = line.slice(minSpaces);
640
- const leadingSpaces = stripped.match(/^ */)?.[0].length ?? 0;
641
- const tabs = Math.floor(leadingSpaces / unit);
642
- const rem = leadingSpaces % unit;
643
- return "\t".repeat(tabs) + " ".repeat(rem) + stripped.slice(leadingSpaces);
644
- })
645
- .join("\n");
646
- warnings.push(
647
- `Edit ${index + 1}: auto-converted space indentation to tabs \u2014 stripped ${minSpaces} common leading spaces and converted ${unit}-space indent to tabs. When auto-indent is on, use \\t for indentation.`,
648
- );
649
- return { content: fixed, warnings };
650
- }
651
-
652
- return { content, warnings };
653
- }
654
-
655
- function chunkEditOperationFields(edit: ChunkToolEdit): string[] {
656
- const fields: string[] = [];
657
- if (edit.write !== undefined) fields.push("write");
658
- if (edit.insert != null) fields.push("insert");
659
- if (edit.delete === true) fields.push("delete");
660
- return fields;
661
- }
662
-
663
- function assertSingleChunkOperation(edit: ChunkToolEdit, index: number): string {
664
- const fields = chunkEditOperationFields(edit);
665
- if (fields.length === 0) {
666
- throw new Error(
667
- `Edit ${index + 1}: no operation specified. Use write:"..." to replace, insert:{loc,body} to insert, or delete:true to delete. Use the open tool to inspect chunks.`,
668
- );
669
- }
670
- if (fields.length > 1) {
671
- throw new Error(
672
- `Edit ${index + 1}: multiple operation fields set (${fields.join(", ")}). Each chunk edit entry must have exactly one operation.`,
673
- );
674
- }
675
- return fields[0];
676
- }
677
-
678
- function normalizeChunkEditOperations(edits: ChunkToolEdit[]): {
679
- operations: ChunkEditOperation[];
680
- warnings: string[];
681
- } {
682
- const warnings: string[] = [];
683
- const operations = edits.map((edit, index): ChunkEditOperation => {
684
- const { selector } = parseChunkEditPath(edit.path);
685
- const operation = assertSingleChunkOperation(edit, index);
686
- if (operation === "write") {
687
- if (edit.write === null) {
688
- throw new Error(
689
- `Edit ${index + 1}: write:null no longer deletes chunks. Use delete:true to delete, or open the chunk to inspect its content without modifying the file.`,
690
- );
691
- }
692
- if (typeof edit.write !== "string") {
693
- throw new Error(`Edit ${index + 1}: write must be a string.`);
694
- }
695
- if (edit.write.length === 0) {
696
- throw new Error(
697
- `Edit ${index + 1}: write:"" is a destructive empty replacement. Use delete:true to delete the chunk, or open the chunk to inspect its content without modifying the file.`,
698
- );
699
- }
700
- let writeContent = edit.write;
701
- if (selector?.endsWith("~")) {
702
- const corrected = autoCorrectBodyIndent(writeContent, index);
703
- writeContent = corrected.content;
704
- warnings.push(...corrected.warnings);
705
- }
706
- return { op: "put", sel: selector, content: writeContent };
707
- }
708
- if (operation === "insert") {
709
- if (edit.insert == null || typeof edit.insert.body !== "string" || edit.insert.body.length === 0) {
710
- throw new Error(`Edit ${index + 1}: insert.body must be a non-empty string.`);
711
- }
712
- const op = edit.insert.loc === "prepend" ? "before" : "after";
713
- let insertContent = edit.insert.body;
714
- if (selector?.endsWith("~")) {
715
- const corrected = autoCorrectBodyIndent(insertContent, index);
716
- insertContent = corrected.content;
717
- warnings.push(...corrected.warnings);
718
- }
719
- return { op, sel: selector, content: insertContent };
720
- }
721
- if (operation !== "delete") {
722
- throw new Error(`Edit ${index + 1}: unsupported chunk edit operation "${operation}".`);
723
- }
724
- return { op: "delete", sel: selector };
725
- });
726
- return { operations, warnings };
727
- }
728
-
729
- async function writeChunkResult(params: {
730
- result: ChunkEditResult;
731
- resolvedPath: string;
732
- sourceFile: BunFile;
733
- sourceText: string;
734
- sourceExists: boolean;
735
- signal?: AbortSignal;
736
- batchRequest?: LspBatchRequest;
737
- writethrough: WritethroughCallback;
738
- beginDeferredDiagnosticsForPath: (path: string) => WritethroughDeferredHandle;
739
- }): Promise<AgentToolResult<EditToolDetails, typeof chunkEditParamsSchema>> {
740
- const {
741
- result,
742
- resolvedPath,
743
- sourceFile,
744
- sourceText,
745
- sourceExists,
746
- signal,
747
- batchRequest,
748
- writethrough,
749
- beginDeferredDiagnosticsForPath,
750
- } = params;
751
-
752
- const { bom, text } = stripBom(sourceText);
753
- const originalEnding = detectLineEnding(text);
754
- const finalContent = bom + restoreLineEndings(result.diffSourceAfter, originalEnding);
755
- const diagnostics = await writethrough(resolvedPath, finalContent, signal, sourceFile, batchRequest, dst =>
756
- dst === resolvedPath ? beginDeferredDiagnosticsForPath(resolvedPath) : undefined,
757
- );
758
- invalidateFsScanAfterWrite(resolvedPath);
759
-
760
- const diffResult = generateUnifiedDiffString(result.diffSourceBefore, result.diffSourceAfter);
761
- const warningsBlock = result.warnings.length > 0 ? `\n\nWarnings:\n${result.warnings.join("\n")}` : "";
762
- const meta = outputMeta()
763
- .diagnostics(diagnostics?.summary ?? "", diagnostics?.messages ?? [])
764
- .get();
765
-
766
- return {
767
- content: [{ type: "text", text: `${result.responseText}${warningsBlock}` }],
768
- details: {
769
- diff: diffResult.diff,
770
- firstChangedLine: diffResult.firstChangedLine,
771
- diagnostics,
772
- op: sourceExists ? "update" : "create",
773
- meta,
774
- },
775
- };
776
- }
777
-
778
- export async function executeChunkSingle(
779
- options: ExecuteChunkSingleOptions,
780
- ): Promise<AgentToolResult<EditToolDetails, typeof chunkEditParamsSchema>> {
781
- const { session, path, edits, signal, batchRequest, writethrough, beginDeferredDiagnosticsForPath } = options;
782
- const { resolvedPath, sourceFile, sourceExists, rawContent, chunkLanguage } = await resolveChunkSourceContext(
783
- session,
784
- path,
785
- { intent: "write" },
786
- );
787
- const parentDir = nodePath.dirname(resolvedPath);
788
- if (parentDir && parentDir !== ".") {
789
- await fs.mkdir(parentDir, { recursive: true });
790
- }
791
- const { operations: normalizedOperations, warnings: normWarnings } = normalizeChunkEditOperations(edits);
792
-
793
- if (!sourceExists && normalizedOperations.some(op => op.sel)) {
794
- throw new Error(
795
- `File does not exist: ${path}. Cannot resolve chunk selectors on a non-existent file. Use the write tool to create a new file, or check the path for typos.`,
796
- );
797
- }
798
-
799
- const chunkResult = applyChunkEdits({
800
- source: rawContent,
801
- language: chunkLanguage,
802
- cwd: session.cwd,
803
- filePath: resolvedPath,
804
- operations: normalizedOperations,
805
- anchorStyle: resolveAnchorStyle(session.settings),
806
- });
807
- chunkResult.warnings.push(...normWarnings);
808
-
809
- if (!chunkResult.changed) {
810
- const warningsBlock = chunkResult.warnings.length > 0 ? `\n\nWarnings:\n${chunkResult.warnings.join("\n")}` : "";
811
- return {
812
- content: [{ type: "text", text: `[No changes needed — content already matches.]${warningsBlock}` }],
813
- details: {
814
- diff: "",
815
- op: sourceExists ? "update" : "create",
816
- meta: outputMeta().get(),
817
- },
818
- };
819
- }
820
-
821
- return writeChunkResult({
822
- result: chunkResult,
823
- resolvedPath,
824
- sourceFile,
825
- sourceText: rawContent,
826
- sourceExists,
827
- signal,
828
- batchRequest,
829
- writethrough,
830
- beginDeferredDiagnosticsForPath,
831
- });
832
- }