@oh-my-pi/pi-coding-agent 13.19.0 → 14.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (202) hide show
  1. package/CHANGELOG.md +266 -1
  2. package/package.json +86 -20
  3. package/scripts/format-prompts.ts +2 -2
  4. package/src/autoresearch/apply-contract-to-state.ts +24 -0
  5. package/src/autoresearch/contract.ts +0 -44
  6. package/src/autoresearch/dashboard.ts +1 -2
  7. package/src/autoresearch/git.ts +91 -0
  8. package/src/autoresearch/helpers.ts +49 -0
  9. package/src/autoresearch/index.ts +28 -187
  10. package/src/autoresearch/prompt.md +26 -9
  11. package/src/autoresearch/state.ts +0 -6
  12. package/src/autoresearch/tools/init-experiment.ts +202 -117
  13. package/src/autoresearch/tools/log-experiment.ts +83 -125
  14. package/src/autoresearch/tools/run-experiment.ts +48 -10
  15. package/src/autoresearch/types.ts +2 -2
  16. package/src/capability/index.ts +4 -2
  17. package/src/cli/file-processor.ts +3 -3
  18. package/src/cli/grep-cli.ts +8 -8
  19. package/src/cli/grievances-cli.ts +78 -0
  20. package/src/cli/read-cli.ts +67 -0
  21. package/src/cli/setup-cli.ts +4 -4
  22. package/src/cli/update-cli.ts +3 -3
  23. package/src/cli.ts +2 -0
  24. package/src/commands/grep.ts +6 -1
  25. package/src/commands/grievances.ts +20 -0
  26. package/src/commands/read.ts +33 -0
  27. package/src/commit/agentic/agent.ts +5 -5
  28. package/src/commit/agentic/index.ts +3 -4
  29. package/src/commit/agentic/tools/analyze-file.ts +3 -3
  30. package/src/commit/agentic/validation.ts +1 -1
  31. package/src/commit/analysis/conventional.ts +4 -4
  32. package/src/commit/analysis/summary.ts +3 -3
  33. package/src/commit/changelog/generate.ts +4 -4
  34. package/src/commit/map-reduce/map-phase.ts +4 -4
  35. package/src/commit/map-reduce/reduce-phase.ts +4 -4
  36. package/src/commit/pipeline.ts +3 -4
  37. package/src/config/prompt-templates.ts +44 -226
  38. package/src/config/resolve-config-value.ts +4 -2
  39. package/src/config/settings-schema.ts +54 -2
  40. package/src/config/settings.ts +25 -26
  41. package/src/dap/client.ts +674 -0
  42. package/src/dap/config.ts +150 -0
  43. package/src/dap/defaults.json +211 -0
  44. package/src/dap/index.ts +4 -0
  45. package/src/dap/session.ts +1255 -0
  46. package/src/dap/types.ts +600 -0
  47. package/src/debug/log-viewer.ts +3 -2
  48. package/src/discovery/builtin.ts +1 -2
  49. package/src/discovery/codex.ts +2 -2
  50. package/src/discovery/github.ts +2 -1
  51. package/src/discovery/helpers.ts +2 -2
  52. package/src/discovery/opencode.ts +2 -2
  53. package/src/edit/diff.ts +818 -0
  54. package/src/edit/index.ts +309 -0
  55. package/src/edit/line-hash.ts +67 -0
  56. package/src/edit/modes/chunk.ts +454 -0
  57. package/src/{patch → edit/modes}/hashline.ts +741 -361
  58. package/src/{patch/applicator.ts → edit/modes/patch.ts} +420 -117
  59. package/src/{patch/fuzzy.ts → edit/modes/replace.ts} +519 -197
  60. package/src/{patch → edit}/normalize.ts +97 -76
  61. package/src/{patch/shared.ts → edit/renderer.ts} +181 -108
  62. package/src/exec/bash-executor.ts +4 -2
  63. package/src/exec/idle-timeout-watchdog.ts +126 -0
  64. package/src/exec/non-interactive-env.ts +5 -0
  65. package/src/extensibility/custom-commands/bundled/ci-green/index.ts +2 -2
  66. package/src/extensibility/custom-commands/bundled/review/index.ts +2 -2
  67. package/src/extensibility/custom-commands/loader.ts +1 -2
  68. package/src/extensibility/custom-tools/loader.ts +34 -11
  69. package/src/extensibility/extensions/loader.ts +9 -4
  70. package/src/extensibility/extensions/runner.ts +24 -1
  71. package/src/extensibility/extensions/types.ts +1 -1
  72. package/src/extensibility/hooks/loader.ts +5 -6
  73. package/src/extensibility/hooks/types.ts +1 -1
  74. package/src/extensibility/plugins/doctor.ts +2 -1
  75. package/src/extensibility/slash-commands.ts +3 -7
  76. package/src/index.ts +2 -1
  77. package/src/internal-urls/docs-index.generated.ts +11 -11
  78. package/src/ipy/executor.ts +58 -17
  79. package/src/ipy/gateway-coordinator.ts +6 -4
  80. package/src/ipy/kernel.ts +45 -22
  81. package/src/ipy/runtime.ts +2 -2
  82. package/src/lsp/client.ts +7 -4
  83. package/src/lsp/clients/lsp-linter-client.ts +4 -4
  84. package/src/lsp/config.ts +2 -2
  85. package/src/lsp/defaults.json +688 -154
  86. package/src/lsp/index.ts +234 -45
  87. package/src/lsp/lspmux.ts +2 -2
  88. package/src/lsp/startup-events.ts +13 -0
  89. package/src/lsp/types.ts +12 -1
  90. package/src/lsp/utils.ts +8 -1
  91. package/src/main.ts +102 -46
  92. package/src/memories/index.ts +4 -5
  93. package/src/modes/acp/acp-agent.ts +563 -163
  94. package/src/modes/acp/acp-event-mapper.ts +9 -1
  95. package/src/modes/acp/acp-mode.ts +4 -2
  96. package/src/modes/components/agent-dashboard.ts +3 -4
  97. package/src/modes/components/diff.ts +6 -7
  98. package/src/modes/components/read-tool-group.ts +6 -12
  99. package/src/modes/components/settings-defs.ts +5 -0
  100. package/src/modes/components/tool-execution.ts +1 -1
  101. package/src/modes/components/welcome.ts +1 -1
  102. package/src/modes/controllers/btw-controller.ts +2 -2
  103. package/src/modes/controllers/command-controller.ts +3 -2
  104. package/src/modes/controllers/input-controller.ts +12 -8
  105. package/src/modes/index.ts +20 -2
  106. package/src/modes/interactive-mode.ts +94 -37
  107. package/src/modes/rpc/host-tools.ts +186 -0
  108. package/src/modes/rpc/rpc-client.ts +178 -13
  109. package/src/modes/rpc/rpc-mode.ts +73 -3
  110. package/src/modes/rpc/rpc-types.ts +53 -1
  111. package/src/modes/theme/theme.ts +80 -8
  112. package/src/modes/types.ts +2 -2
  113. package/src/prompts/system/system-prompt.md +2 -1
  114. package/src/prompts/tools/chunk-edit.md +219 -0
  115. package/src/prompts/tools/debug.md +43 -0
  116. package/src/prompts/tools/grep.md +3 -0
  117. package/src/prompts/tools/lsp.md +5 -5
  118. package/src/prompts/tools/read-chunk.md +17 -0
  119. package/src/prompts/tools/read.md +19 -5
  120. package/src/sdk.ts +190 -154
  121. package/src/secrets/obfuscator.ts +1 -1
  122. package/src/session/agent-session.ts +306 -256
  123. package/src/session/agent-storage.ts +12 -12
  124. package/src/session/compaction/branch-summarization.ts +3 -3
  125. package/src/session/compaction/compaction.ts +5 -6
  126. package/src/session/compaction/utils.ts +3 -3
  127. package/src/session/history-storage.ts +62 -19
  128. package/src/session/messages.ts +3 -3
  129. package/src/session/session-dump-format.ts +203 -0
  130. package/src/session/session-storage.ts +4 -2
  131. package/src/session/streaming-output.ts +1 -1
  132. package/src/session/tool-choice-queue.ts +213 -0
  133. package/src/slash-commands/builtin-registry.ts +56 -8
  134. package/src/ssh/connection-manager.ts +2 -2
  135. package/src/ssh/sshfs-mount.ts +5 -5
  136. package/src/stt/downloader.ts +4 -4
  137. package/src/stt/recorder.ts +4 -4
  138. package/src/stt/transcriber.ts +2 -2
  139. package/src/system-prompt.ts +21 -13
  140. package/src/task/agents.ts +5 -6
  141. package/src/task/commands.ts +2 -5
  142. package/src/task/executor.ts +4 -4
  143. package/src/task/index.ts +3 -4
  144. package/src/task/template.ts +2 -2
  145. package/src/task/worktree.ts +4 -4
  146. package/src/tools/ask.ts +2 -3
  147. package/src/tools/ast-edit.ts +7 -7
  148. package/src/tools/ast-grep.ts +7 -7
  149. package/src/tools/auto-generated-guard.ts +36 -41
  150. package/src/tools/await-tool.ts +2 -2
  151. package/src/tools/bash.ts +5 -23
  152. package/src/tools/browser.ts +4 -5
  153. package/src/tools/calculator.ts +2 -3
  154. package/src/tools/cancel-job.ts +2 -2
  155. package/src/tools/checkpoint.ts +3 -3
  156. package/src/tools/debug.ts +1007 -0
  157. package/src/tools/exit-plan-mode.ts +2 -3
  158. package/src/tools/fetch.ts +67 -3
  159. package/src/tools/find.ts +4 -5
  160. package/src/tools/fs-cache-invalidation.ts +5 -0
  161. package/src/tools/gemini-image.ts +13 -5
  162. package/src/tools/gh.ts +10 -11
  163. package/src/tools/grep.ts +57 -9
  164. package/src/tools/index.ts +44 -22
  165. package/src/tools/inspect-image.ts +4 -4
  166. package/src/tools/output-meta.ts +1 -1
  167. package/src/tools/python.ts +19 -6
  168. package/src/tools/read.ts +198 -67
  169. package/src/tools/render-mermaid.ts +2 -3
  170. package/src/tools/render-utils.ts +20 -6
  171. package/src/tools/renderers.ts +3 -1
  172. package/src/tools/report-tool-issue.ts +80 -0
  173. package/src/tools/resolve.ts +70 -39
  174. package/src/tools/search-tool-bm25.ts +2 -2
  175. package/src/tools/ssh.ts +2 -2
  176. package/src/tools/todo-write.ts +2 -2
  177. package/src/tools/tool-timeouts.ts +1 -0
  178. package/src/tools/write.ts +5 -6
  179. package/src/tui/tree-list.ts +3 -1
  180. package/src/utils/clipboard.ts +80 -0
  181. package/src/utils/commit-message-generator.ts +2 -3
  182. package/src/utils/edit-mode.ts +49 -0
  183. package/src/utils/file-display-mode.ts +6 -5
  184. package/src/utils/file-mentions.ts +8 -7
  185. package/src/utils/git.ts +4 -4
  186. package/src/utils/image-loading.ts +98 -0
  187. package/src/utils/title-generator.ts +2 -3
  188. package/src/utils/tools-manager.ts +6 -6
  189. package/src/web/scrapers/choosealicense.ts +1 -1
  190. package/src/web/search/index.ts +3 -3
  191. package/src/autoresearch/command-initialize.md +0 -34
  192. package/src/patch/diff.ts +0 -433
  193. package/src/patch/index.ts +0 -888
  194. package/src/patch/parser.ts +0 -532
  195. package/src/patch/types.ts +0 -292
  196. package/src/prompts/agents/oracle.md +0 -77
  197. package/src/tools/pending-action.ts +0 -49
  198. package/src/utils/child-process.ts +0 -88
  199. package/src/utils/frontmatter.ts +0 -117
  200. package/src/utils/image-input.ts +0 -274
  201. package/src/utils/mime.ts +0 -53
  202. package/src/utils/prompt-format.ts +0 -170
@@ -0,0 +1,98 @@
1
+ import * as fs from "node:fs/promises";
2
+ import type { ImageContent } from "@oh-my-pi/pi-ai";
3
+ import { formatBytes, readImageMetadata, SUPPORTED_IMAGE_MIME_TYPES } from "@oh-my-pi/pi-utils";
4
+ import { resolveReadPath } from "../tools/path-utils";
5
+ import { convertToPng } from "./image-convert";
6
+ import { formatDimensionNote, resizeImage } from "./image-resize";
7
+
8
+ export const MAX_IMAGE_INPUT_BYTES = 20 * 1024 * 1024;
9
+ export const SUPPORTED_INPUT_IMAGE_MIME_TYPES = SUPPORTED_IMAGE_MIME_TYPES;
10
+
11
+ export interface LoadImageInputOptions {
12
+ path: string;
13
+ cwd: string;
14
+ autoResize: boolean;
15
+ maxBytes?: number;
16
+ resolvedPath?: string;
17
+ detectedMimeType?: string;
18
+ }
19
+
20
+ export interface LoadedImageInput {
21
+ resolvedPath: string;
22
+ mimeType: string;
23
+ data: string;
24
+ textNote: string;
25
+ dimensionNote?: string;
26
+ bytes: number;
27
+ }
28
+
29
+ export class ImageInputTooLargeError extends Error {
30
+ readonly bytes: number;
31
+ readonly maxBytes: number;
32
+
33
+ constructor(bytes: number, maxBytes: number) {
34
+ super(`Image file too large: ${formatBytes(bytes)} exceeds ${formatBytes(maxBytes)} limit.`);
35
+ this.name = "ImageInputTooLargeError";
36
+ this.bytes = bytes;
37
+ this.maxBytes = maxBytes;
38
+ }
39
+ }
40
+
41
+ export async function ensureSupportedImageInput(image: ImageContent): Promise<ImageContent | null> {
42
+ if (SUPPORTED_INPUT_IMAGE_MIME_TYPES.has(image.mimeType)) {
43
+ return image;
44
+ }
45
+ const converted = await convertToPng(image.data, image.mimeType);
46
+ return converted ? { type: "image", data: converted.data, mimeType: converted.mimeType } : null;
47
+ }
48
+
49
+ export async function loadImageInput(options: LoadImageInputOptions): Promise<LoadedImageInput | null> {
50
+ const maxBytes = options.maxBytes ?? MAX_IMAGE_INPUT_BYTES;
51
+ const resolvedPath = options.resolvedPath ?? resolveReadPath(options.path, options.cwd);
52
+ const metadata = options.detectedMimeType
53
+ ? { mimeType: options.detectedMimeType }
54
+ : await readImageMetadata(resolvedPath);
55
+ const mimeType = metadata?.mimeType;
56
+ if (!mimeType) return null;
57
+
58
+ const stat = await Bun.file(resolvedPath).stat();
59
+ if (stat.size > maxBytes) {
60
+ throw new ImageInputTooLargeError(stat.size, maxBytes);
61
+ }
62
+
63
+ const inputBuffer = await fs.readFile(resolvedPath);
64
+ if (inputBuffer.byteLength > maxBytes) {
65
+ throw new ImageInputTooLargeError(inputBuffer.byteLength, maxBytes);
66
+ }
67
+
68
+ let outputData = Buffer.from(inputBuffer).toBase64();
69
+ let outputMimeType = mimeType;
70
+ let outputBytes = inputBuffer.byteLength;
71
+ let dimensionNote: string | undefined;
72
+
73
+ if (options.autoResize) {
74
+ try {
75
+ const resized = await resizeImage({ type: "image", data: outputData, mimeType });
76
+ outputData = resized.data;
77
+ outputMimeType = resized.mimeType;
78
+ outputBytes = resized.buffer.byteLength;
79
+ dimensionNote = formatDimensionNote(resized);
80
+ } catch {
81
+ // keep original image when resize fails
82
+ }
83
+ }
84
+
85
+ let textNote = `Read image file [${outputMimeType}]`;
86
+ if (dimensionNote) {
87
+ textNote += `\n${dimensionNote}`;
88
+ }
89
+
90
+ return {
91
+ resolvedPath,
92
+ mimeType: outputMimeType,
93
+ data: outputData,
94
+ textNote,
95
+ dimensionNote,
96
+ bytes: outputBytes,
97
+ };
98
+ }
@@ -5,15 +5,14 @@ import * as path from "node:path";
5
5
  import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
6
6
  import type { Api, Model } from "@oh-my-pi/pi-ai";
7
7
  import { completeSimple } from "@oh-my-pi/pi-ai";
8
- import { logger } from "@oh-my-pi/pi-utils";
8
+ import { logger, prompt } from "@oh-my-pi/pi-utils";
9
9
  import type { ModelRegistry } from "../config/model-registry";
10
10
  import { resolveRoleSelection } from "../config/model-resolver";
11
- import { renderPromptTemplate } from "../config/prompt-templates";
12
11
  import type { Settings } from "../config/settings";
13
12
  import titleSystemPrompt from "../prompts/system/title-system.md" with { type: "text" };
14
13
  import { toReasoningEffort } from "../thinking";
15
14
 
16
- const TITLE_SYSTEM_PROMPT = renderPromptTemplate(titleSystemPrompt);
15
+ const TITLE_SYSTEM_PROMPT = prompt.render(titleSystemPrompt);
17
16
 
18
17
  const DEFAULT_TERMINAL_TITLE = "π";
19
18
  const TERMINAL_TITLE_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f]/g;
@@ -1,7 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
- import { APP_NAME, getToolsDir, logger, ptree, TempDir } from "@oh-my-pi/pi-utils";
4
+ import { $which, APP_NAME, getToolsDir, logger, ptree, TempDir } from "@oh-my-pi/pi-utils";
5
5
 
6
6
  const TOOLS_DIR = getToolsDir();
7
7
  const TOOL_DOWNLOAD_TIMEOUT_MS = 120_000;
@@ -96,7 +96,7 @@ export function getToolPath(tool: ToolName): string | null {
96
96
  // Check Python tools first
97
97
  const pythonConfig = PYTHON_TOOLS[tool];
98
98
  if (pythonConfig) {
99
- return Bun.which(pythonConfig.binaryName);
99
+ return $which(pythonConfig.binaryName);
100
100
  }
101
101
 
102
102
  const config = TOOLS[tool];
@@ -109,7 +109,7 @@ export function getToolPath(tool: ToolName): string | null {
109
109
  }
110
110
 
111
111
  // Check system PATH
112
- return Bun.which(config.binaryName);
112
+ return $which(config.binaryName);
113
113
  }
114
114
 
115
115
  // Fetch latest release version from GitHub
@@ -249,7 +249,7 @@ async function downloadTool(tool: ToolName, signal?: AbortSignal): Promise<strin
249
249
  // Install a Python package via uv (preferred) or pip
250
250
  async function installPythonPackage(pkg: string, signal?: AbortSignal): Promise<boolean> {
251
251
  // Try uv first (faster, better isolation)
252
- const uv = Bun.which("uv");
252
+ const uv = $which("uv");
253
253
  if (uv) {
254
254
  const result = await ptree.exec(["uv", "tool", "install", pkg], {
255
255
  signal,
@@ -261,7 +261,7 @@ async function installPythonPackage(pkg: string, signal?: AbortSignal): Promise<
261
261
  }
262
262
 
263
263
  // Fall back to pip
264
- const pip = Bun.which("pip3") || Bun.which("pip");
264
+ const pip = $which("pip3") || $which("pip");
265
265
  if (pip) {
266
266
  const result = await ptree.exec(["pip", "install", "--user", pkg], {
267
267
  signal,
@@ -316,7 +316,7 @@ export async function ensureTool(tool: ToolName, silentOrOptions?: EnsureToolOpt
316
316
  const success = await installPythonPackage(pythonConfig.package, signal);
317
317
  if (success) {
318
318
  // Re-check for the command after installation
319
- const path = Bun.which(pythonConfig.binaryName);
319
+ const path = $which(pythonConfig.binaryName);
320
320
  if (path) {
321
321
  if (!silent) {
322
322
  logger.debug(`${pythonConfig.name} installed successfully`);
@@ -1,4 +1,4 @@
1
- import { parseFrontmatter } from "../../utils/frontmatter";
1
+ import { parseFrontmatter } from "@oh-my-pi/pi-utils";
2
2
  import type { RenderResult, SpecialHandler } from "./types";
3
3
  import { buildResult, loadPage } from "./types";
4
4
  import { asString } from "./utils";
@@ -7,8 +7,8 @@
7
7
  */
8
8
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
9
9
  import { StringEnum } from "@oh-my-pi/pi-ai";
10
+ import { prompt } from "@oh-my-pi/pi-utils";
10
11
  import { Type } from "@sinclair/typebox";
11
- import { renderPromptTemplate } from "../../config/prompt-templates";
12
12
  import type { CustomTool, CustomToolContext, RenderResultOptions } from "../../extensibility/custom-tools/types";
13
13
  import type { Theme } from "../../modes/theme/theme";
14
14
  import webSearchSystemPrompt from "../../prompts/system/web-search.md" with { type: "text" };
@@ -213,7 +213,7 @@ export class SearchTool implements AgentTool<typeof webSearchSchema, SearchRende
213
213
  readonly strict = true;
214
214
 
215
215
  constructor(_session: ToolSession) {
216
- this.description = renderPromptTemplate(webSearchDescription);
216
+ this.description = prompt.render(webSearchDescription);
217
217
  }
218
218
 
219
219
  async execute(
@@ -231,7 +231,7 @@ export class SearchTool implements AgentTool<typeof webSearchSchema, SearchRende
231
231
  export const webSearchCustomTool: CustomTool<typeof webSearchSchema, SearchRenderDetails> = {
232
232
  name: "web_search",
233
233
  label: "Web Search",
234
- description: renderPromptTemplate(webSearchDescription),
234
+ description: prompt.render(webSearchDescription),
235
235
  parameters: webSearchSchema,
236
236
 
237
237
  async execute(
@@ -1,34 +0,0 @@
1
- Set up autoresearch for this intent:
2
-
3
- {{intent}}
4
-
5
- {{branch_status_line}}
6
-
7
- Collected setup:
8
-
9
- - benchmark command: `{{benchmark_command}}`
10
- - primary metric: `{{metric_name}}`
11
- - metric unit: `{{metric_unit}}`
12
- - direction: `{{direction}}`
13
- - tradeoff metrics:
14
- {{{secondary_metrics_block}}}
15
- - files in scope:
16
- {{{scope_paths_block}}}
17
- - off limits:
18
- {{{off_limits_block}}}
19
- - constraints:
20
- {{{constraints_block}}}
21
-
22
- Explain briefly what autoresearch will do in this repository, then initialize the workspace.
23
-
24
- Your first actions:
25
- - write `autoresearch.md`
26
- - record the collected benchmark command, primary metric, metric unit, direction, tradeoff metrics, scope, off-limits list, and constraints in `autoresearch.md`
27
- - add a short preflight section in `autoresearch.md` covering prerequisites, one-time setup, and the comparability invariant that must stay fixed across runs
28
- - explicitly mark the ground-truth evaluator, fixed datasets, and other measurement-critical files as off-limits or hard constraints when they define the benchmark contract
29
- - write or update `autoresearch.program.md` when you learn durable heuristics, failure patterns, or repo-specific strategy that future resume turns should inherit
30
- - define the benchmark entrypoint in `autoresearch.sh`
31
- - optionally add `autoresearch.checks.sh` if correctness or quality needs a hard gate
32
- - run `init_experiment` with the exact collected benchmark command, metric definition, scope paths, off-limits list, and constraints
33
- - run and log the baseline
34
- - keep iterating until interrupted or until the configured iteration cap is reached
package/src/patch/diff.ts DELETED
@@ -1,433 +0,0 @@
1
- /**
2
- * Diff generation and replace-mode utilities for the edit tool.
3
- *
4
- * Provides diff string generation and the replace-mode edit logic
5
- * used when not in patch mode.
6
- */
7
- import * as Diff from "diff";
8
- import { resolveToCwd } from "../tools/path-utils";
9
- import { previewPatch } from "./applicator";
10
- import { DEFAULT_FUZZY_THRESHOLD, findMatch } from "./fuzzy";
11
- import type { HashlineEdit } from "./hashline";
12
- import { applyHashlineEdits } from "./hashline";
13
- import { adjustIndentation, normalizeToLF, stripBom } from "./normalize";
14
- import type { DiffError, DiffResult, PatchInput } from "./types";
15
- import { EditMatchError } from "./types";
16
-
17
- // ═══════════════════════════════════════════════════════════════════════════
18
- // Diff String Generation
19
- // ═══════════════════════════════════════════════════════════════════════════
20
-
21
- function countContentLines(content: string): number {
22
- const lines = content.split("\n");
23
- if (lines.length > 1 && lines[lines.length - 1] === "") {
24
- lines.pop();
25
- }
26
- return Math.max(1, lines.length);
27
- }
28
-
29
- function formatNumberedDiffLine(prefix: "+" | "-" | " ", lineNum: number, width: number, content: string): string {
30
- const padded = String(lineNum).padStart(width, " ");
31
- return `${prefix}${padded}|${content}`;
32
- }
33
-
34
- /**
35
- * Generate a unified diff string with line numbers and context.
36
- * Returns both the diff string and the first changed line number (in the new file).
37
- */
38
- export function generateDiffString(oldContent: string, newContent: string, contextLines = 4): DiffResult {
39
- const parts = Diff.diffLines(oldContent, newContent);
40
- const output: string[] = [];
41
-
42
- const maxLineNum = Math.max(countContentLines(oldContent), countContentLines(newContent));
43
- const lineNumWidth = String(maxLineNum).length;
44
-
45
- let oldLineNum = 1;
46
- let newLineNum = 1;
47
- let lastWasChange = false;
48
- let firstChangedLine: number | undefined;
49
-
50
- for (let i = 0; i < parts.length; i++) {
51
- const part = parts[i];
52
- const raw = part.value.split("\n");
53
- if (raw[raw.length - 1] === "") {
54
- raw.pop();
55
- }
56
-
57
- if (part.added || part.removed) {
58
- // Capture the first changed line (in the new file)
59
- if (firstChangedLine === undefined) {
60
- firstChangedLine = newLineNum;
61
- }
62
-
63
- // Show the change
64
- for (const line of raw) {
65
- if (part.added) {
66
- output.push(formatNumberedDiffLine("+", newLineNum, lineNumWidth, line));
67
- newLineNum++;
68
- } else {
69
- output.push(formatNumberedDiffLine("-", oldLineNum, lineNumWidth, line));
70
- oldLineNum++;
71
- }
72
- }
73
- lastWasChange = true;
74
- } else {
75
- // Context lines - only show a few before/after changes
76
- const nextPartIsChange = i < parts.length - 1 && (parts[i + 1].added || parts[i + 1].removed);
77
-
78
- if (lastWasChange || nextPartIsChange) {
79
- let linesToShow = raw;
80
- let skipStart = 0;
81
- let skipEnd = 0;
82
-
83
- if (!lastWasChange) {
84
- // Show only last N lines as leading context
85
- skipStart = Math.max(0, raw.length - contextLines);
86
- linesToShow = raw.slice(skipStart);
87
- }
88
-
89
- if (!nextPartIsChange && linesToShow.length > contextLines) {
90
- // Show only first N lines as trailing context
91
- skipEnd = linesToShow.length - contextLines;
92
- linesToShow = linesToShow.slice(0, contextLines);
93
- }
94
-
95
- // Add ellipsis if we skipped lines at start
96
- if (skipStart > 0) {
97
- output.push(formatNumberedDiffLine(" ", oldLineNum, lineNumWidth, "..."));
98
- oldLineNum += skipStart;
99
- newLineNum += skipStart;
100
- }
101
-
102
- for (const line of linesToShow) {
103
- output.push(formatNumberedDiffLine(" ", oldLineNum, lineNumWidth, line));
104
- oldLineNum++;
105
- newLineNum++;
106
- }
107
-
108
- // Add ellipsis if we skipped lines at end
109
- if (skipEnd > 0) {
110
- output.push(formatNumberedDiffLine(" ", oldLineNum, lineNumWidth, "..."));
111
- oldLineNum += skipEnd;
112
- newLineNum += skipEnd;
113
- }
114
- } else {
115
- // Skip these context lines entirely
116
- oldLineNum += raw.length;
117
- newLineNum += raw.length;
118
- }
119
-
120
- lastWasChange = false;
121
- }
122
- }
123
-
124
- return { diff: output.join("\n"), firstChangedLine };
125
- }
126
-
127
- // ═══════════════════════════════════════════════════════════════════════════
128
- // Replace Mode Logic
129
- // ═══════════════════════════════════════════════════════════════════════════
130
-
131
- export interface ReplaceOptions {
132
- /** Allow fuzzy matching */
133
- fuzzy: boolean;
134
- /** Replace all occurrences */
135
- all: boolean;
136
- /** Similarity threshold for fuzzy matching */
137
- threshold?: number;
138
- }
139
-
140
- export interface ReplaceResult {
141
- /** The new content after replacements */
142
- content: string;
143
- /** Number of replacements made */
144
- count: number;
145
- }
146
-
147
- /**
148
- * Generate a unified diff string without file headers.
149
- * Returns both the diff string and the first changed line number (in the new file).
150
- */
151
- export function generateUnifiedDiffString(oldContent: string, newContent: string, contextLines = 3): DiffResult {
152
- const patch = Diff.structuredPatch("", "", oldContent, newContent, "", "", { context: contextLines });
153
- const output: string[] = [];
154
- let firstChangedLine: number | undefined;
155
- const maxLineNum = Math.max(countContentLines(oldContent), countContentLines(newContent));
156
- const lineNumWidth = String(maxLineNum).length;
157
- for (const hunk of patch.hunks) {
158
- output.push(`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`);
159
- let oldLine = hunk.oldStart;
160
- let newLine = hunk.newStart;
161
- for (const line of hunk.lines) {
162
- if (line.startsWith("-")) {
163
- if (firstChangedLine === undefined) firstChangedLine = newLine;
164
- output.push(formatNumberedDiffLine("-", oldLine, lineNumWidth, line.slice(1)));
165
- oldLine++;
166
- continue;
167
- }
168
- if (line.startsWith("+")) {
169
- if (firstChangedLine === undefined) firstChangedLine = newLine;
170
- output.push(formatNumberedDiffLine("+", newLine, lineNumWidth, line.slice(1)));
171
- newLine++;
172
- continue;
173
- }
174
- if (line.startsWith(" ")) {
175
- output.push(formatNumberedDiffLine(" ", oldLine, lineNumWidth, line.slice(1)));
176
- oldLine++;
177
- newLine++;
178
- continue;
179
- }
180
- output.push(line);
181
- }
182
- }
183
-
184
- return { diff: output.join("\n"), firstChangedLine };
185
- }
186
-
187
- /**
188
- * Find and replace text in content using fuzzy matching.
189
- */
190
- export function replaceText(content: string, oldText: string, newText: string, options: ReplaceOptions): ReplaceResult {
191
- if (oldText.length === 0) {
192
- throw new Error("oldText must not be empty.");
193
- }
194
- const threshold = options.threshold ?? DEFAULT_FUZZY_THRESHOLD;
195
- let normalizedContent = normalizeToLF(content);
196
- const normalizedOldText = normalizeToLF(oldText);
197
- const normalizedNewText = normalizeToLF(newText);
198
- let count = 0;
199
-
200
- if (options.all) {
201
- // Check for exact matches first
202
- const exactCount = normalizedContent.split(normalizedOldText).length - 1;
203
- if (exactCount > 0) {
204
- return {
205
- content: normalizedContent.split(normalizedOldText).join(normalizedNewText),
206
- count: exactCount,
207
- };
208
- }
209
-
210
- // No exact matches - try fuzzy matching iteratively
211
- while (true) {
212
- const matchOutcome = findMatch(normalizedContent, normalizedOldText, {
213
- allowFuzzy: options.fuzzy,
214
- threshold,
215
- });
216
-
217
- const shouldUseClosest =
218
- options.fuzzy &&
219
- matchOutcome.closest &&
220
- matchOutcome.closest.confidence >= threshold &&
221
- (matchOutcome.fuzzyMatches === undefined || matchOutcome.fuzzyMatches <= 1);
222
- const match = matchOutcome.match || (shouldUseClosest ? matchOutcome.closest : undefined);
223
- if (!match) {
224
- break;
225
- }
226
-
227
- const adjustedNewText = adjustIndentation(normalizedOldText, match.actualText, normalizedNewText);
228
- if (adjustedNewText === match.actualText) {
229
- break;
230
- }
231
- normalizedContent =
232
- normalizedContent.substring(0, match.startIndex) +
233
- adjustedNewText +
234
- normalizedContent.substring(match.startIndex + match.actualText.length);
235
- count++;
236
- }
237
-
238
- return { content: normalizedContent, count };
239
- }
240
-
241
- // Single replacement mode
242
- const matchOutcome = findMatch(normalizedContent, normalizedOldText, {
243
- allowFuzzy: options.fuzzy,
244
- threshold,
245
- });
246
-
247
- if (matchOutcome.occurrences && matchOutcome.occurrences > 1) {
248
- const previews = matchOutcome.occurrencePreviews?.join("\n\n") ?? "";
249
- const moreMsg = matchOutcome.occurrences > 5 ? ` (showing first 5 of ${matchOutcome.occurrences})` : "";
250
- throw new Error(
251
- `Found ${matchOutcome.occurrences} occurrences${moreMsg}:\n\n${previews}\n\n` +
252
- `Add more context lines to disambiguate.`,
253
- );
254
- }
255
-
256
- if (!matchOutcome.match) {
257
- return { content: normalizedContent, count: 0 };
258
- }
259
-
260
- const match = matchOutcome.match;
261
- const adjustedNewText = adjustIndentation(normalizedOldText, match.actualText, normalizedNewText);
262
- normalizedContent =
263
- normalizedContent.substring(0, match.startIndex) +
264
- adjustedNewText +
265
- normalizedContent.substring(match.startIndex + match.actualText.length);
266
-
267
- return { content: normalizedContent, count: 1 };
268
- }
269
-
270
- // ═══════════════════════════════════════════════════════════════════════════
271
- // Preview/Diff Computation
272
- // ═══════════════════════════════════════════════════════════════════════════
273
-
274
- /**
275
- * Compute the diff for an edit operation without applying it.
276
- * Used for preview rendering in the TUI before the tool executes.
277
- */
278
- export async function computeEditDiff(
279
- path: string,
280
- oldText: string,
281
- newText: string,
282
- cwd: string,
283
- fuzzy = true,
284
- all = false,
285
- threshold?: number,
286
- ): Promise<DiffResult | DiffError> {
287
- if (oldText.length === 0) {
288
- return { error: "oldText must not be empty." };
289
- }
290
- const absolutePath = resolveToCwd(path, cwd);
291
-
292
- try {
293
- const file = Bun.file(absolutePath);
294
- try {
295
- if (!(await file.exists())) {
296
- return { error: `File not found: ${path}` };
297
- }
298
- } catch {
299
- return { error: `File not found: ${path}` };
300
- }
301
-
302
- let rawContent: string;
303
- try {
304
- rawContent = await file.text();
305
- } catch (error) {
306
- const message = error instanceof Error ? error.message : String(error);
307
- return { error: message || `Unable to read ${path}` };
308
- }
309
-
310
- const { text: content } = stripBom(rawContent);
311
- const normalizedContent = normalizeToLF(content);
312
- const normalizedOldText = normalizeToLF(oldText);
313
- const normalizedNewText = normalizeToLF(newText);
314
-
315
- const result = replaceText(normalizedContent, normalizedOldText, normalizedNewText, {
316
- fuzzy,
317
- all,
318
- threshold,
319
- });
320
-
321
- if (result.count === 0) {
322
- // Get closest match for error message
323
- const matchOutcome = findMatch(normalizedContent, normalizedOldText, {
324
- allowFuzzy: fuzzy,
325
- threshold: threshold ?? DEFAULT_FUZZY_THRESHOLD,
326
- });
327
-
328
- if (matchOutcome.occurrences && matchOutcome.occurrences > 1) {
329
- const previews = matchOutcome.occurrencePreviews?.join("\n\n") ?? "";
330
- const moreMsg = matchOutcome.occurrences > 5 ? ` (showing first 5 of ${matchOutcome.occurrences})` : "";
331
- return {
332
- error: `Found ${matchOutcome.occurrences} occurrences in ${path}${moreMsg}:\n\n${previews}\n\nAdd more context lines to disambiguate.`,
333
- };
334
- }
335
-
336
- return {
337
- error: EditMatchError.formatMessage(path, normalizedOldText, matchOutcome.closest, {
338
- allowFuzzy: fuzzy,
339
- threshold: threshold ?? DEFAULT_FUZZY_THRESHOLD,
340
- fuzzyMatches: matchOutcome.fuzzyMatches,
341
- }),
342
- };
343
- }
344
-
345
- if (normalizedContent === result.content) {
346
- return {
347
- error: `No changes would be made to ${path}. The replacement produces identical content.`,
348
- };
349
- }
350
-
351
- return generateDiffString(normalizedContent, result.content);
352
- } catch (err) {
353
- return { error: err instanceof Error ? err.message : String(err) };
354
- }
355
- }
356
-
357
- /**
358
- * Compute the diff for a patch operation without applying it.
359
- * Used for preview rendering in the TUI before patch-mode edits execute.
360
- */
361
- export async function computePatchDiff(
362
- input: PatchInput,
363
- cwd: string,
364
- options?: { fuzzyThreshold?: number; allowFuzzy?: boolean },
365
- ): Promise<DiffResult | DiffError> {
366
- try {
367
- const result = await previewPatch(input, {
368
- cwd,
369
- fuzzyThreshold: options?.fuzzyThreshold,
370
- allowFuzzy: options?.allowFuzzy,
371
- });
372
- const oldContent = result.change.oldContent ?? "";
373
- const newContent = result.change.newContent ?? "";
374
- const normalizedOld = normalizeToLF(stripBom(oldContent).text);
375
- const normalizedNew = normalizeToLF(stripBom(newContent).text);
376
- if (!normalizedOld && !normalizedNew) {
377
- return { diff: "", firstChangedLine: undefined };
378
- }
379
- return generateUnifiedDiffString(normalizedOld, normalizedNew);
380
- } catch (err) {
381
- return { error: err instanceof Error ? err.message : String(err) };
382
- }
383
- }
384
- /**
385
- * Compute the diff for a hashline operation without applying it.
386
- * Used for preview rendering in the TUI before hashline-mode edits execute.
387
- */
388
- export async function computeHashlineDiff(
389
- input: { path: string; edits: HashlineEdit[]; move?: string },
390
- cwd: string,
391
- ): Promise<DiffResult | DiffError> {
392
- const { path, edits, move } = input;
393
- const absolutePath = resolveToCwd(path, cwd);
394
- const movePath = move ? resolveToCwd(move, cwd) : undefined;
395
- const isMoveOnly = Boolean(movePath) && movePath !== absolutePath && edits.length === 0;
396
-
397
- try {
398
- const file = Bun.file(absolutePath);
399
- try {
400
- if (!(await file.exists())) {
401
- return { error: `File not found: ${path}` };
402
- }
403
- } catch {
404
- return { error: `File not found: ${path}` };
405
- }
406
-
407
- if (movePath === absolutePath) {
408
- return { error: "move path is the same as source path" };
409
- }
410
- if (isMoveOnly) {
411
- return { diff: "", firstChangedLine: undefined };
412
- }
413
-
414
- let rawContent: string;
415
- try {
416
- rawContent = await file.text();
417
- } catch (error) {
418
- const message = error instanceof Error ? error.message : String(error);
419
- return { error: message || `Unable to read ${path}` };
420
- }
421
-
422
- const { text: content } = stripBom(rawContent);
423
- const normalizedContent = normalizeToLF(content);
424
- const result = applyHashlineEdits(normalizedContent, edits);
425
- if (normalizedContent === result.lines && !move) {
426
- return { error: `No changes would be made to ${path}. The edits produce identical content.` };
427
- }
428
-
429
- return generateDiffString(normalizedContent, result.lines);
430
- } catch (err) {
431
- return { error: err instanceof Error ? err.message : String(err) };
432
- }
433
- }