@aexol/spectral 0.7.1 → 0.7.5

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 (219) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/dist/agent/agents.js +1 -1
  3. package/dist/agent/index.js +199 -184
  4. package/dist/commands/serve.js +0 -3
  5. package/dist/designer/data/systems/renault/DESIGN.md +1 -1
  6. package/dist/designer/philosophies.js +668 -0
  7. package/dist/mcp/sampling-handler.js +1 -1
  8. package/dist/memory/commands/status.js +1 -1
  9. package/dist/memory/compaction.js +2 -2
  10. package/dist/memory/config.js +1 -1
  11. package/dist/memory/debug-log.js +1 -1
  12. package/dist/memory/hooks/compaction-hook.js +29 -0
  13. package/dist/memory/index.js +2 -0
  14. package/dist/memory/observer.js +2 -2
  15. package/dist/memory/project-observations-store.js +14 -0
  16. package/dist/memory/tokens.js +1 -1
  17. package/dist/memory/tools/read-project-observations.js +82 -0
  18. package/dist/memory/tools/recall-observation.js +2 -2
  19. package/dist/pi/agent-core/agent-loop.js +501 -0
  20. package/dist/pi/agent-core/agent.js +401 -0
  21. package/dist/pi/agent-core/harness/agent-harness.js +899 -0
  22. package/dist/pi/agent-core/harness/compaction/branch-summarization.js +173 -0
  23. package/dist/pi/agent-core/harness/compaction/compaction.js +532 -0
  24. package/dist/pi/agent-core/harness/compaction/utils.js +130 -0
  25. package/dist/pi/agent-core/harness/env/nodejs.js +485 -0
  26. package/dist/pi/agent-core/harness/messages.js +101 -0
  27. package/dist/pi/agent-core/harness/prompt-templates.js +229 -0
  28. package/dist/pi/agent-core/harness/session/jsonl-repo.js +100 -0
  29. package/dist/pi/agent-core/harness/session/jsonl-storage.js +230 -0
  30. package/dist/pi/agent-core/harness/session/memory-repo.js +41 -0
  31. package/dist/pi/agent-core/harness/session/memory-storage.js +113 -0
  32. package/dist/pi/agent-core/harness/session/repo-utils.js +38 -0
  33. package/dist/pi/agent-core/harness/session/session.js +196 -0
  34. package/dist/pi/agent-core/harness/session/uuid.js +49 -0
  35. package/dist/pi/agent-core/harness/skills.js +310 -0
  36. package/dist/pi/agent-core/harness/system-prompt.js +29 -0
  37. package/dist/pi/agent-core/harness/types.js +93 -0
  38. package/dist/pi/agent-core/harness/utils/shell-output.js +125 -0
  39. package/dist/pi/agent-core/harness/utils/truncate.js +289 -0
  40. package/dist/pi/agent-core/index.js +24 -0
  41. package/dist/pi/agent-core/node.js +2 -0
  42. package/dist/pi/agent-core/proxy.js +277 -0
  43. package/dist/pi/agent-core/types.js +1 -0
  44. package/dist/pi/ai/api-registry.js +43 -0
  45. package/dist/pi/ai/cli.js +120 -0
  46. package/dist/pi/ai/env-api-keys.js +169 -0
  47. package/dist/pi/ai/image-models.generated.js +441 -0
  48. package/dist/pi/ai/image-models.js +22 -0
  49. package/dist/pi/ai/images-api-registry.js +21 -0
  50. package/dist/pi/ai/images.js +13 -0
  51. package/dist/pi/ai/index.js +18 -0
  52. package/dist/pi/ai/models.generated.js +16220 -0
  53. package/dist/pi/ai/models.js +70 -0
  54. package/dist/pi/ai/oauth.js +1 -0
  55. package/dist/pi/ai/providers/anthropic.js +945 -0
  56. package/dist/pi/ai/providers/faux.js +367 -0
  57. package/dist/pi/ai/providers/github-copilot-headers.js +28 -0
  58. package/dist/pi/ai/providers/openai-completions.js +945 -0
  59. package/dist/pi/ai/providers/openai-prompt-cache.js +9 -0
  60. package/dist/pi/ai/providers/register-builtins.js +97 -0
  61. package/dist/pi/ai/providers/simple-options.js +40 -0
  62. package/dist/pi/ai/providers/transform-messages.js +183 -0
  63. package/dist/pi/ai/session-resources.js +21 -0
  64. package/dist/pi/ai/stream.js +26 -0
  65. package/dist/pi/ai/types.js +1 -0
  66. package/dist/pi/ai/utils/diagnostics.js +24 -0
  67. package/dist/pi/ai/utils/event-stream.js +80 -0
  68. package/dist/pi/ai/utils/hash.js +13 -0
  69. package/dist/pi/ai/utils/headers.js +7 -0
  70. package/dist/pi/ai/utils/json-parse.js +112 -0
  71. package/dist/pi/ai/utils/node-http-proxy.js +96 -0
  72. package/dist/pi/ai/utils/oauth/anthropic.js +334 -0
  73. package/dist/pi/ai/utils/oauth/device-code.js +54 -0
  74. package/dist/pi/ai/utils/oauth/github-copilot.js +270 -0
  75. package/dist/pi/ai/utils/oauth/index.js +121 -0
  76. package/dist/pi/ai/utils/oauth/oauth-page.js +104 -0
  77. package/dist/pi/ai/utils/oauth/openai-codex.js +384 -0
  78. package/dist/pi/ai/utils/oauth/pkce.js +30 -0
  79. package/dist/pi/ai/utils/oauth/types.js +1 -0
  80. package/dist/pi/ai/utils/overflow.js +150 -0
  81. package/dist/pi/ai/utils/sanitize-unicode.js +25 -0
  82. package/dist/pi/ai/utils/typebox-helpers.js +20 -0
  83. package/dist/pi/ai/utils/validation.js +280 -0
  84. package/dist/pi/coding-agent/bun/cli.js +7 -0
  85. package/dist/pi/coding-agent/bun/restore-sandbox-env.js +31 -0
  86. package/dist/pi/coding-agent/cli/args.js +340 -0
  87. package/dist/pi/coding-agent/cli/file-processor.js +82 -0
  88. package/dist/pi/coding-agent/cli/initial-message.js +21 -0
  89. package/dist/pi/coding-agent/cli.js +17 -0
  90. package/dist/pi/coding-agent/config.js +414 -0
  91. package/dist/pi/coding-agent/core/agent-session-runtime.js +299 -0
  92. package/dist/pi/coding-agent/core/agent-session-services.js +117 -0
  93. package/dist/pi/coding-agent/core/agent-session.js +2498 -0
  94. package/dist/pi/coding-agent/core/auth-guidance.js +20 -0
  95. package/dist/pi/coding-agent/core/auth-storage.js +441 -0
  96. package/dist/pi/coding-agent/core/bash-executor.js +110 -0
  97. package/dist/pi/coding-agent/core/compaction/branch-summarization.js +242 -0
  98. package/dist/pi/coding-agent/core/compaction/compaction.js +624 -0
  99. package/dist/pi/coding-agent/core/compaction/index.js +6 -0
  100. package/dist/pi/coding-agent/core/compaction/utils.js +152 -0
  101. package/dist/pi/coding-agent/core/defaults.js +1 -0
  102. package/dist/pi/coding-agent/core/diagnostics.js +1 -0
  103. package/dist/pi/coding-agent/core/event-bus.js +24 -0
  104. package/dist/pi/coding-agent/core/exec.js +74 -0
  105. package/dist/pi/coding-agent/core/export-html/ansi-to-html.js +248 -0
  106. package/dist/pi/coding-agent/core/export-html/index.js +225 -0
  107. package/dist/pi/coding-agent/core/export-html/tool-renderer.js +107 -0
  108. package/dist/pi/coding-agent/core/extensions/index.js +8 -0
  109. package/dist/pi/coding-agent/core/extensions/loader.js +485 -0
  110. package/dist/pi/coding-agent/core/extensions/runner.js +824 -0
  111. package/dist/pi/coding-agent/core/extensions/types.js +44 -0
  112. package/dist/pi/coding-agent/core/extensions/wrapper.js +21 -0
  113. package/dist/pi/coding-agent/core/footer-data-provider.js +309 -0
  114. package/dist/pi/coding-agent/core/http-dispatcher.js +47 -0
  115. package/dist/pi/coding-agent/core/index.js +11 -0
  116. package/dist/pi/coding-agent/core/keybindings.js +294 -0
  117. package/dist/pi/coding-agent/core/messages.js +122 -0
  118. package/dist/pi/coding-agent/core/model-registry.js +728 -0
  119. package/dist/pi/coding-agent/core/model-resolver.js +494 -0
  120. package/dist/pi/coding-agent/core/output-guard.js +58 -0
  121. package/dist/pi/coding-agent/core/package-manager.js +2020 -0
  122. package/dist/pi/coding-agent/core/prompt-templates.js +237 -0
  123. package/dist/pi/coding-agent/core/provider-display-names.js +32 -0
  124. package/dist/pi/coding-agent/core/resolve-config-value.js +125 -0
  125. package/dist/pi/coding-agent/core/resource-loader.js +733 -0
  126. package/dist/pi/coding-agent/core/sdk.js +282 -0
  127. package/dist/pi/coding-agent/core/session-cwd.js +37 -0
  128. package/dist/pi/coding-agent/core/session-manager.js +1146 -0
  129. package/dist/pi/coding-agent/core/settings-manager.js +794 -0
  130. package/dist/pi/coding-agent/core/skills.js +386 -0
  131. package/dist/pi/coding-agent/core/slash-commands.js +24 -0
  132. package/dist/pi/coding-agent/core/source-info.js +18 -0
  133. package/dist/pi/coding-agent/core/system-prompt.js +122 -0
  134. package/dist/pi/coding-agent/core/telemetry.js +8 -0
  135. package/dist/pi/coding-agent/core/timings.js +30 -0
  136. package/dist/pi/coding-agent/core/tools/bash.js +341 -0
  137. package/dist/pi/coding-agent/core/tools/edit-diff.js +344 -0
  138. package/dist/pi/coding-agent/core/tools/edit.js +324 -0
  139. package/dist/pi/coding-agent/core/tools/file-mutation-queue.js +36 -0
  140. package/dist/pi/coding-agent/core/tools/find.js +297 -0
  141. package/dist/pi/coding-agent/core/tools/grep.js +303 -0
  142. package/dist/pi/coding-agent/core/tools/index.js +111 -0
  143. package/dist/pi/coding-agent/core/tools/ls.js +168 -0
  144. package/dist/pi/coding-agent/core/tools/output-accumulator.js +183 -0
  145. package/dist/pi/coding-agent/core/tools/path-utils.js +61 -0
  146. package/dist/pi/coding-agent/core/tools/read.js +288 -0
  147. package/dist/pi/coding-agent/core/tools/render-utils.js +48 -0
  148. package/dist/pi/coding-agent/core/tools/tool-definition-wrapper.js +33 -0
  149. package/dist/pi/coding-agent/core/tools/truncate.js +214 -0
  150. package/dist/pi/coding-agent/core/tools/write.js +212 -0
  151. package/dist/pi/coding-agent/index.js +41 -0
  152. package/dist/pi/coding-agent/main.js +5 -0
  153. package/dist/pi/coding-agent/migrations.js +280 -0
  154. package/dist/pi/coding-agent/modes/index.js +7 -0
  155. package/dist/pi/coding-agent/modes/interactive/components/diff.js +132 -0
  156. package/dist/pi/coding-agent/modes/interactive/components/keybinding-hints.js +35 -0
  157. package/dist/pi/coding-agent/modes/interactive/components/visual-truncate.js +32 -0
  158. package/dist/pi/coding-agent/modes/interactive/interactive-mode.js +3 -0
  159. package/dist/pi/coding-agent/modes/interactive/theme/theme.js +1023 -0
  160. package/dist/pi/coding-agent/modes/print-mode.js +130 -0
  161. package/dist/pi/coding-agent/modes/rpc/jsonl.js +48 -0
  162. package/dist/pi/coding-agent/modes/rpc/rpc-client.js +409 -0
  163. package/dist/pi/coding-agent/modes/rpc/rpc-mode.js +600 -0
  164. package/dist/pi/coding-agent/modes/rpc/rpc-types.js +7 -0
  165. package/dist/pi/coding-agent/utils/ansi.js +51 -0
  166. package/dist/pi/coding-agent/utils/changelog.js +86 -0
  167. package/dist/pi/coding-agent/utils/child-process.js +87 -0
  168. package/dist/pi/coding-agent/utils/clipboard-image.js +244 -0
  169. package/dist/pi/coding-agent/utils/clipboard-native.js +13 -0
  170. package/dist/pi/coding-agent/utils/clipboard.js +116 -0
  171. package/dist/pi/coding-agent/utils/exif-orientation.js +157 -0
  172. package/dist/pi/coding-agent/utils/frontmatter.js +25 -0
  173. package/dist/pi/coding-agent/utils/fs-watch.js +24 -0
  174. package/dist/pi/coding-agent/utils/git.js +162 -0
  175. package/dist/pi/coding-agent/utils/html.js +39 -0
  176. package/dist/pi/coding-agent/utils/image-convert.js +38 -0
  177. package/dist/pi/coding-agent/utils/image-resize.js +136 -0
  178. package/dist/pi/coding-agent/utils/mime.js +68 -0
  179. package/dist/pi/coding-agent/utils/paths.js +91 -0
  180. package/dist/pi/coding-agent/utils/photon.js +120 -0
  181. package/dist/pi/coding-agent/utils/pi-user-agent.js +4 -0
  182. package/dist/pi/coding-agent/utils/shell.js +194 -0
  183. package/dist/pi/coding-agent/utils/sleep.js +16 -0
  184. package/dist/pi/coding-agent/utils/syntax-highlight.js +117 -0
  185. package/dist/pi/coding-agent/utils/tools-manager.js +327 -0
  186. package/dist/pi/coding-agent/utils/version-check.js +81 -0
  187. package/dist/pi/coding-agent/utils/windows-self-update.js +76 -0
  188. package/dist/pi/tui/autocomplete.js +631 -0
  189. package/dist/pi/tui/components/box.js +103 -0
  190. package/dist/pi/tui/components/cancellable-loader.js +34 -0
  191. package/dist/pi/tui/components/editor.js +1915 -0
  192. package/dist/pi/tui/components/image.js +88 -0
  193. package/dist/pi/tui/components/input.js +425 -0
  194. package/dist/pi/tui/components/loader.js +68 -0
  195. package/dist/pi/tui/components/markdown.js +633 -0
  196. package/dist/pi/tui/components/select-list.js +158 -0
  197. package/dist/pi/tui/components/settings-list.js +184 -0
  198. package/dist/pi/tui/components/spacer.js +22 -0
  199. package/dist/pi/tui/components/text.js +88 -0
  200. package/dist/pi/tui/components/truncated-text.js +50 -0
  201. package/dist/pi/tui/editor-component.js +1 -0
  202. package/dist/pi/tui/fuzzy.js +109 -0
  203. package/dist/pi/tui/index.js +31 -0
  204. package/dist/pi/tui/keybindings.js +173 -0
  205. package/dist/pi/tui/keys.js +1172 -0
  206. package/dist/pi/tui/kill-ring.js +43 -0
  207. package/dist/pi/tui/stdin-buffer.js +360 -0
  208. package/dist/pi/tui/terminal-image.js +335 -0
  209. package/dist/pi/tui/terminal.js +324 -0
  210. package/dist/pi/tui/tui.js +1076 -0
  211. package/dist/pi/tui/undo-stack.js +24 -0
  212. package/dist/pi/tui/utils.js +1016 -0
  213. package/dist/relay/dispatcher.js +30 -0
  214. package/dist/server/handlers/queue.js +52 -0
  215. package/dist/server/pi-bridge.js +9 -1
  216. package/dist/server/session-stream.js +76 -111
  217. package/dist/server/storage.js +154 -2
  218. package/dist/server/title-generator.js +14 -153
  219. package/package.json +24 -6
@@ -0,0 +1,341 @@
1
+ import { existsSync } from "node:fs";
2
+ import { Container, Text, truncateToWidth } from "../../../tui/index.js";
3
+ import { spawn } from "child_process";
4
+ import { Type } from "typebox";
5
+ import { keyHint } from "../../modes/interactive/components/keybinding-hints.js";
6
+ import { truncateToVisualLines } from "../../modes/interactive/components/visual-truncate.js";
7
+ import { theme } from "../../modes/interactive/theme/theme.js";
8
+ import { waitForChildProcess } from "../../utils/child-process.js";
9
+ import { getShellConfig, getShellEnv, killProcessTree, trackDetachedChildPid, untrackDetachedChildPid, } from "../../utils/shell.js";
10
+ import { OutputAccumulator } from "./output-accumulator.js";
11
+ import { getTextOutput, invalidArgText, str } from "./render-utils.js";
12
+ import { wrapToolDefinition } from "./tool-definition-wrapper.js";
13
+ import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize } from "./truncate.js";
14
+ const bashSchema = Type.Object({
15
+ command: Type.String({ description: "Bash command to execute" }),
16
+ timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (optional, no default timeout)" })),
17
+ });
18
+ /**
19
+ * Create bash operations using pi's built-in local shell execution backend.
20
+ *
21
+ * This is useful for extensions that intercept user_bash and still want pi's
22
+ * standard local shell behavior while wrapping or rewriting commands.
23
+ */
24
+ export function createLocalBashOperations(options) {
25
+ return {
26
+ exec: (command, cwd, { onData, signal, timeout, env }) => {
27
+ return new Promise((resolve, reject) => {
28
+ const { shell, args } = getShellConfig(options?.shellPath);
29
+ if (!existsSync(cwd)) {
30
+ reject(new Error(`Working directory does not exist: ${cwd}\nCannot execute bash commands.`));
31
+ return;
32
+ }
33
+ const child = spawn(shell, [...args, command], {
34
+ cwd,
35
+ detached: process.platform !== "win32",
36
+ env: env ?? getShellEnv(),
37
+ stdio: ["ignore", "pipe", "pipe"],
38
+ windowsHide: true,
39
+ });
40
+ if (child.pid)
41
+ trackDetachedChildPid(child.pid);
42
+ let timedOut = false;
43
+ let timeoutHandle;
44
+ // Set timeout if provided.
45
+ if (timeout !== undefined && timeout > 0) {
46
+ timeoutHandle = setTimeout(() => {
47
+ timedOut = true;
48
+ if (child.pid)
49
+ killProcessTree(child.pid);
50
+ }, timeout * 1000);
51
+ }
52
+ // Stream stdout and stderr.
53
+ child.stdout?.on("data", onData);
54
+ child.stderr?.on("data", onData);
55
+ // Handle abort signal by killing the entire process tree.
56
+ const onAbort = () => {
57
+ if (child.pid)
58
+ killProcessTree(child.pid);
59
+ };
60
+ if (signal) {
61
+ if (signal.aborted)
62
+ onAbort();
63
+ else
64
+ signal.addEventListener("abort", onAbort, { once: true });
65
+ }
66
+ // Handle shell spawn errors and wait for the process to terminate without hanging
67
+ // on inherited stdio handles held by detached descendants.
68
+ waitForChildProcess(child)
69
+ .then((code) => {
70
+ if (child.pid)
71
+ untrackDetachedChildPid(child.pid);
72
+ if (timeoutHandle)
73
+ clearTimeout(timeoutHandle);
74
+ if (signal)
75
+ signal.removeEventListener("abort", onAbort);
76
+ if (signal?.aborted) {
77
+ reject(new Error("aborted"));
78
+ return;
79
+ }
80
+ if (timedOut) {
81
+ reject(new Error(`timeout:${timeout}`));
82
+ return;
83
+ }
84
+ resolve({ exitCode: code });
85
+ })
86
+ .catch((err) => {
87
+ if (child.pid)
88
+ untrackDetachedChildPid(child.pid);
89
+ if (timeoutHandle)
90
+ clearTimeout(timeoutHandle);
91
+ if (signal)
92
+ signal.removeEventListener("abort", onAbort);
93
+ reject(err);
94
+ });
95
+ });
96
+ },
97
+ };
98
+ }
99
+ function resolveSpawnContext(command, cwd, spawnHook) {
100
+ const baseContext = { command, cwd, env: { ...getShellEnv() } };
101
+ return spawnHook ? spawnHook(baseContext) : baseContext;
102
+ }
103
+ const BASH_PREVIEW_LINES = 5;
104
+ const BASH_UPDATE_THROTTLE_MS = 100;
105
+ class BashResultRenderComponent extends Container {
106
+ state = {
107
+ cachedWidth: undefined,
108
+ cachedLines: undefined,
109
+ cachedSkipped: undefined,
110
+ };
111
+ }
112
+ function formatDuration(ms) {
113
+ return `${(ms / 1000).toFixed(1)}s`;
114
+ }
115
+ function formatBashCall(args) {
116
+ const command = str(args?.command);
117
+ const timeout = args?.timeout;
118
+ const timeoutSuffix = timeout ? theme.fg("muted", ` (timeout ${timeout}s)`) : "";
119
+ const commandDisplay = command === null ? invalidArgText(theme) : command ? command : theme.fg("toolOutput", "...");
120
+ return theme.fg("toolTitle", theme.bold(`$ ${commandDisplay}`)) + timeoutSuffix;
121
+ }
122
+ function rebuildBashResultRenderComponent(component, result, options, showImages, startedAt, endedAt) {
123
+ const state = component.state;
124
+ component.clear();
125
+ let output = getTextOutput(result, showImages).trim();
126
+ const truncation = result.details?.truncation;
127
+ const fullOutputPath = result.details?.fullOutputPath;
128
+ if (!options.isPartial && truncation?.truncated && fullOutputPath && output.endsWith("]")) {
129
+ const footerStart = output.lastIndexOf("\n\n[");
130
+ if (footerStart !== -1 && output.slice(footerStart).includes(fullOutputPath)) {
131
+ output = output.slice(0, footerStart).trimEnd();
132
+ }
133
+ }
134
+ if (output) {
135
+ const styledOutput = output
136
+ .split("\n")
137
+ .map((line) => theme.fg("toolOutput", line))
138
+ .join("\n");
139
+ if (options.expanded) {
140
+ component.addChild(new Text(`\n${styledOutput}`, 0, 0));
141
+ }
142
+ else {
143
+ component.addChild({
144
+ render: (width) => {
145
+ if (state.cachedLines === undefined || state.cachedWidth !== width) {
146
+ const preview = truncateToVisualLines(styledOutput, BASH_PREVIEW_LINES, width);
147
+ state.cachedLines = preview.visualLines;
148
+ state.cachedSkipped = preview.skippedCount;
149
+ state.cachedWidth = width;
150
+ }
151
+ if (state.cachedSkipped && state.cachedSkipped > 0) {
152
+ const hint = theme.fg("muted", `... (${state.cachedSkipped} earlier lines,`) +
153
+ ` ${keyHint("app.tools.expand", "to expand")})`;
154
+ return ["", truncateToWidth(hint, width, "..."), ...(state.cachedLines ?? [])];
155
+ }
156
+ return ["", ...(state.cachedLines ?? [])];
157
+ },
158
+ invalidate: () => {
159
+ state.cachedWidth = undefined;
160
+ state.cachedLines = undefined;
161
+ state.cachedSkipped = undefined;
162
+ },
163
+ });
164
+ }
165
+ }
166
+ if (truncation?.truncated || fullOutputPath) {
167
+ const warnings = [];
168
+ if (fullOutputPath) {
169
+ warnings.push(`Full output: ${fullOutputPath}`);
170
+ }
171
+ if (truncation?.truncated) {
172
+ if (truncation.truncatedBy === "lines") {
173
+ warnings.push(`Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines`);
174
+ }
175
+ else {
176
+ warnings.push(`Truncated: ${truncation.outputLines} lines shown (${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit)`);
177
+ }
178
+ }
179
+ component.addChild(new Text(`\n${theme.fg("warning", `[${warnings.join(". ")}]`)}`, 0, 0));
180
+ }
181
+ if (startedAt !== undefined) {
182
+ const label = options.isPartial ? "Elapsed" : "Took";
183
+ const endTime = endedAt ?? Date.now();
184
+ component.addChild(new Text(`\n${theme.fg("muted", `${label} ${formatDuration(endTime - startedAt)}`)}`, 0, 0));
185
+ }
186
+ }
187
+ export function createBashToolDefinition(cwd, options) {
188
+ const ops = options?.operations ?? createLocalBashOperations({ shellPath: options?.shellPath });
189
+ const commandPrefix = options?.commandPrefix;
190
+ const spawnHook = options?.spawnHook;
191
+ return {
192
+ name: "bash",
193
+ label: "bash",
194
+ description: `Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.`,
195
+ promptSnippet: "Execute bash commands (ls, grep, find, etc.)",
196
+ parameters: bashSchema,
197
+ async execute(_toolCallId, { command, timeout }, signal, onUpdate, _ctx) {
198
+ const resolvedCommand = commandPrefix ? `${commandPrefix}\n${command}` : command;
199
+ const spawnContext = resolveSpawnContext(resolvedCommand, cwd, spawnHook);
200
+ const output = new OutputAccumulator({ tempFilePrefix: "pi-bash" });
201
+ let updateTimer;
202
+ let updateDirty = false;
203
+ let lastUpdateAt = 0;
204
+ const emitOutputUpdate = () => {
205
+ if (!onUpdate || !updateDirty)
206
+ return;
207
+ updateDirty = false;
208
+ lastUpdateAt = Date.now();
209
+ const snapshot = output.snapshot({ persistIfTruncated: true });
210
+ onUpdate({
211
+ content: [{ type: "text", text: snapshot.content || "" }],
212
+ details: {
213
+ truncation: snapshot.truncation.truncated ? snapshot.truncation : undefined,
214
+ fullOutputPath: snapshot.fullOutputPath,
215
+ },
216
+ });
217
+ };
218
+ const clearUpdateTimer = () => {
219
+ if (updateTimer) {
220
+ clearTimeout(updateTimer);
221
+ updateTimer = undefined;
222
+ }
223
+ };
224
+ const scheduleOutputUpdate = () => {
225
+ if (!onUpdate)
226
+ return;
227
+ updateDirty = true;
228
+ const delay = BASH_UPDATE_THROTTLE_MS - (Date.now() - lastUpdateAt);
229
+ if (delay <= 0) {
230
+ clearUpdateTimer();
231
+ emitOutputUpdate();
232
+ return;
233
+ }
234
+ updateTimer ??= setTimeout(() => {
235
+ updateTimer = undefined;
236
+ emitOutputUpdate();
237
+ }, delay);
238
+ };
239
+ if (onUpdate) {
240
+ onUpdate({ content: [], details: undefined });
241
+ }
242
+ const handleData = (data) => {
243
+ output.append(data);
244
+ scheduleOutputUpdate();
245
+ };
246
+ const finishOutput = async () => {
247
+ output.finish();
248
+ clearUpdateTimer();
249
+ emitOutputUpdate();
250
+ const snapshot = output.snapshot({ persistIfTruncated: true });
251
+ await output.closeTempFile();
252
+ return snapshot;
253
+ };
254
+ const formatOutput = (snapshot, emptyText = "(no output)") => {
255
+ const truncation = snapshot.truncation;
256
+ let text = snapshot.content || emptyText;
257
+ let details;
258
+ if (truncation.truncated) {
259
+ details = { truncation, fullOutputPath: snapshot.fullOutputPath };
260
+ const startLine = truncation.totalLines - truncation.outputLines + 1;
261
+ const endLine = truncation.totalLines;
262
+ if (truncation.lastLinePartial) {
263
+ const lastLineSize = formatSize(output.getLastLineBytes());
264
+ text += `\n\n[Showing last ${formatSize(truncation.outputBytes)} of line ${endLine} (line is ${lastLineSize}). Full output: ${snapshot.fullOutputPath}]`;
265
+ }
266
+ else if (truncation.truncatedBy === "lines") {
267
+ text += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}. Full output: ${snapshot.fullOutputPath}]`;
268
+ }
269
+ else {
270
+ text += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Full output: ${snapshot.fullOutputPath}]`;
271
+ }
272
+ }
273
+ return { text, details };
274
+ };
275
+ const appendStatus = (text, status) => `${text ? `${text}\n\n` : ""}${status}`;
276
+ try {
277
+ let exitCode;
278
+ try {
279
+ const result = await ops.exec(spawnContext.command, spawnContext.cwd, {
280
+ onData: handleData,
281
+ signal,
282
+ timeout,
283
+ env: spawnContext.env,
284
+ });
285
+ exitCode = result.exitCode;
286
+ }
287
+ catch (err) {
288
+ const snapshot = await finishOutput();
289
+ const { text } = formatOutput(snapshot, "");
290
+ if (err instanceof Error && err.message === "aborted") {
291
+ throw new Error(appendStatus(text, "Command aborted"));
292
+ }
293
+ if (err instanceof Error && err.message.startsWith("timeout:")) {
294
+ const timeoutSecs = err.message.split(":")[1];
295
+ throw new Error(appendStatus(text, `Command timed out after ${timeoutSecs} seconds`));
296
+ }
297
+ throw err;
298
+ }
299
+ const snapshot = await finishOutput();
300
+ const { text: outputText, details } = formatOutput(snapshot);
301
+ if (exitCode !== 0 && exitCode !== null) {
302
+ throw new Error(appendStatus(outputText, `Command exited with code ${exitCode}`));
303
+ }
304
+ return { content: [{ type: "text", text: outputText }], details };
305
+ }
306
+ finally {
307
+ clearUpdateTimer();
308
+ }
309
+ },
310
+ renderCall(args, _theme, context) {
311
+ const state = context.state;
312
+ if (context.executionStarted && state.startedAt === undefined) {
313
+ state.startedAt = Date.now();
314
+ state.endedAt = undefined;
315
+ }
316
+ const text = context.lastComponent ?? new Text("", 0, 0);
317
+ text.setText(formatBashCall(args));
318
+ return text;
319
+ },
320
+ renderResult(result, options, _theme, context) {
321
+ const state = context.state;
322
+ if (state.startedAt !== undefined && options.isPartial && !state.interval) {
323
+ state.interval = setInterval(() => context.invalidate(), 1000);
324
+ }
325
+ if (!options.isPartial || context.isError) {
326
+ state.endedAt ??= Date.now();
327
+ if (state.interval) {
328
+ clearInterval(state.interval);
329
+ state.interval = undefined;
330
+ }
331
+ }
332
+ const component = context.lastComponent ?? new BashResultRenderComponent();
333
+ rebuildBashResultRenderComponent(component, result, options, context.showImages, state.startedAt, state.endedAt);
334
+ component.invalidate();
335
+ return component;
336
+ },
337
+ };
338
+ }
339
+ export function createBashTool(cwd, options) {
340
+ return wrapToolDefinition(createBashToolDefinition(cwd, options));
341
+ }
@@ -0,0 +1,344 @@
1
+ /**
2
+ * Shared diff computation utilities for the edit tool.
3
+ * Used by both edit.ts (for execution) and tool-execution.ts (for preview rendering).
4
+ */
5
+ import * as Diff from "diff";
6
+ import { constants } from "fs";
7
+ import { access, readFile } from "fs/promises";
8
+ import { resolveToCwd } from "./path-utils.js";
9
+ export function detectLineEnding(content) {
10
+ const crlfIdx = content.indexOf("\r\n");
11
+ const lfIdx = content.indexOf("\n");
12
+ if (lfIdx === -1)
13
+ return "\n";
14
+ if (crlfIdx === -1)
15
+ return "\n";
16
+ return crlfIdx < lfIdx ? "\r\n" : "\n";
17
+ }
18
+ export function normalizeToLF(text) {
19
+ return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
20
+ }
21
+ export function restoreLineEndings(text, ending) {
22
+ return ending === "\r\n" ? text.replace(/\n/g, "\r\n") : text;
23
+ }
24
+ /**
25
+ * Normalize text for fuzzy matching. Applies progressive transformations:
26
+ * - Strip trailing whitespace from each line
27
+ * - Normalize smart quotes to ASCII equivalents
28
+ * - Normalize Unicode dashes/hyphens to ASCII hyphen
29
+ * - Normalize special Unicode spaces to regular space
30
+ */
31
+ export function normalizeForFuzzyMatch(text) {
32
+ return (text
33
+ .normalize("NFKC")
34
+ // Strip trailing whitespace per line
35
+ .split("\n")
36
+ .map((line) => line.trimEnd())
37
+ .join("\n")
38
+ // Smart single quotes → '
39
+ .replace(/[\u2018\u2019\u201A\u201B]/g, "'")
40
+ // Smart double quotes → "
41
+ .replace(/[\u201C\u201D\u201E\u201F]/g, '"')
42
+ // Various dashes/hyphens → -
43
+ // U+2010 hyphen, U+2011 non-breaking hyphen, U+2012 figure dash,
44
+ // U+2013 en-dash, U+2014 em-dash, U+2015 horizontal bar, U+2212 minus
45
+ .replace(/[\u2010\u2011\u2012\u2013\u2014\u2015\u2212]/g, "-")
46
+ // Special spaces → regular space
47
+ // U+00A0 NBSP, U+2002-U+200A various spaces, U+202F narrow NBSP,
48
+ // U+205F medium math space, U+3000 ideographic space
49
+ .replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/g, " "));
50
+ }
51
+ /**
52
+ * Find oldText in content, trying exact match first, then fuzzy match.
53
+ * When fuzzy matching is used, the returned contentForReplacement is the
54
+ * fuzzy-normalized version of the content (trailing whitespace stripped,
55
+ * Unicode quotes/dashes normalized to ASCII).
56
+ */
57
+ export function fuzzyFindText(content, oldText) {
58
+ // Try exact match first
59
+ const exactIndex = content.indexOf(oldText);
60
+ if (exactIndex !== -1) {
61
+ return {
62
+ found: true,
63
+ index: exactIndex,
64
+ matchLength: oldText.length,
65
+ usedFuzzyMatch: false,
66
+ contentForReplacement: content,
67
+ };
68
+ }
69
+ // Try fuzzy match - work entirely in normalized space
70
+ const fuzzyContent = normalizeForFuzzyMatch(content);
71
+ const fuzzyOldText = normalizeForFuzzyMatch(oldText);
72
+ const fuzzyIndex = fuzzyContent.indexOf(fuzzyOldText);
73
+ if (fuzzyIndex === -1) {
74
+ return {
75
+ found: false,
76
+ index: -1,
77
+ matchLength: 0,
78
+ usedFuzzyMatch: false,
79
+ contentForReplacement: content,
80
+ };
81
+ }
82
+ // When fuzzy matching, we work in the normalized space for replacement.
83
+ // This means the output will have normalized whitespace/quotes/dashes,
84
+ // which is acceptable since we're fixing minor formatting differences anyway.
85
+ return {
86
+ found: true,
87
+ index: fuzzyIndex,
88
+ matchLength: fuzzyOldText.length,
89
+ usedFuzzyMatch: true,
90
+ contentForReplacement: fuzzyContent,
91
+ };
92
+ }
93
+ /** Strip UTF-8 BOM if present, return both the BOM (if any) and the text without it */
94
+ export function stripBom(content) {
95
+ return content.startsWith("\uFEFF") ? { bom: "\uFEFF", text: content.slice(1) } : { bom: "", text: content };
96
+ }
97
+ function countOccurrences(content, oldText) {
98
+ const fuzzyContent = normalizeForFuzzyMatch(content);
99
+ const fuzzyOldText = normalizeForFuzzyMatch(oldText);
100
+ return fuzzyContent.split(fuzzyOldText).length - 1;
101
+ }
102
+ function getNotFoundError(path, editIndex, totalEdits) {
103
+ if (totalEdits === 1) {
104
+ return new Error(`Could not find the exact text in ${path}. The old text must match exactly including all whitespace and newlines.`);
105
+ }
106
+ return new Error(`Could not find edits[${editIndex}] in ${path}. The oldText must match exactly including all whitespace and newlines.`);
107
+ }
108
+ function getDuplicateError(path, editIndex, totalEdits, occurrences) {
109
+ if (totalEdits === 1) {
110
+ return new Error(`Found ${occurrences} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique.`);
111
+ }
112
+ return new Error(`Found ${occurrences} occurrences of edits[${editIndex}] in ${path}. Each oldText must be unique. Please provide more context to make it unique.`);
113
+ }
114
+ function getEmptyOldTextError(path, editIndex, totalEdits) {
115
+ if (totalEdits === 1) {
116
+ return new Error(`oldText must not be empty in ${path}.`);
117
+ }
118
+ return new Error(`edits[${editIndex}].oldText must not be empty in ${path}.`);
119
+ }
120
+ function getNoChangeError(path, totalEdits) {
121
+ if (totalEdits === 1) {
122
+ return new Error(`No changes made to ${path}. The replacement produced identical content. This might indicate an issue with special characters or the text not existing as expected.`);
123
+ }
124
+ return new Error(`No changes made to ${path}. The replacements produced identical content.`);
125
+ }
126
+ /**
127
+ * Apply one or more exact-text replacements to LF-normalized content.
128
+ *
129
+ * All edits are matched against the same original content. Replacements are
130
+ * then applied in reverse order so offsets remain stable. If any edit needs
131
+ * fuzzy matching, the operation runs in fuzzy-normalized content space to
132
+ * preserve current single-edit behavior.
133
+ */
134
+ export function applyEditsToNormalizedContent(normalizedContent, edits, path) {
135
+ const normalizedEdits = edits.map((edit) => ({
136
+ oldText: normalizeToLF(edit.oldText),
137
+ newText: normalizeToLF(edit.newText),
138
+ }));
139
+ for (let i = 0; i < normalizedEdits.length; i++) {
140
+ if (normalizedEdits[i].oldText.length === 0) {
141
+ throw getEmptyOldTextError(path, i, normalizedEdits.length);
142
+ }
143
+ }
144
+ const initialMatches = normalizedEdits.map((edit) => fuzzyFindText(normalizedContent, edit.oldText));
145
+ const baseContent = initialMatches.some((match) => match.usedFuzzyMatch)
146
+ ? normalizeForFuzzyMatch(normalizedContent)
147
+ : normalizedContent;
148
+ const matchedEdits = [];
149
+ for (let i = 0; i < normalizedEdits.length; i++) {
150
+ const edit = normalizedEdits[i];
151
+ const matchResult = fuzzyFindText(baseContent, edit.oldText);
152
+ if (!matchResult.found) {
153
+ throw getNotFoundError(path, i, normalizedEdits.length);
154
+ }
155
+ const occurrences = countOccurrences(baseContent, edit.oldText);
156
+ if (occurrences > 1) {
157
+ throw getDuplicateError(path, i, normalizedEdits.length, occurrences);
158
+ }
159
+ matchedEdits.push({
160
+ editIndex: i,
161
+ matchIndex: matchResult.index,
162
+ matchLength: matchResult.matchLength,
163
+ newText: edit.newText,
164
+ });
165
+ }
166
+ matchedEdits.sort((a, b) => a.matchIndex - b.matchIndex);
167
+ for (let i = 1; i < matchedEdits.length; i++) {
168
+ const previous = matchedEdits[i - 1];
169
+ const current = matchedEdits[i];
170
+ if (previous.matchIndex + previous.matchLength > current.matchIndex) {
171
+ throw new Error(`edits[${previous.editIndex}] and edits[${current.editIndex}] overlap in ${path}. Merge them into one edit or target disjoint regions.`);
172
+ }
173
+ }
174
+ let newContent = baseContent;
175
+ for (let i = matchedEdits.length - 1; i >= 0; i--) {
176
+ const edit = matchedEdits[i];
177
+ newContent =
178
+ newContent.substring(0, edit.matchIndex) +
179
+ edit.newText +
180
+ newContent.substring(edit.matchIndex + edit.matchLength);
181
+ }
182
+ if (baseContent === newContent) {
183
+ throw getNoChangeError(path, normalizedEdits.length);
184
+ }
185
+ return { baseContent, newContent };
186
+ }
187
+ /** Generate a standard unified patch. */
188
+ export function generateUnifiedPatch(path, oldContent, newContent, contextLines = 4) {
189
+ return Diff.createTwoFilesPatch(path, path, oldContent, newContent, undefined, undefined, {
190
+ context: contextLines,
191
+ headerOptions: Diff.FILE_HEADERS_ONLY,
192
+ });
193
+ }
194
+ /**
195
+ * Generate a display-oriented diff string with line numbers and context.
196
+ * Returns both the diff string and the first changed line number (in the new file).
197
+ */
198
+ export function generateDiffString(oldContent, newContent, contextLines = 4) {
199
+ const parts = Diff.diffLines(oldContent, newContent);
200
+ const output = [];
201
+ const oldLines = oldContent.split("\n");
202
+ const newLines = newContent.split("\n");
203
+ const maxLineNum = Math.max(oldLines.length, newLines.length);
204
+ const lineNumWidth = String(maxLineNum).length;
205
+ let oldLineNum = 1;
206
+ let newLineNum = 1;
207
+ let lastWasChange = false;
208
+ let firstChangedLine;
209
+ for (let i = 0; i < parts.length; i++) {
210
+ const part = parts[i];
211
+ const raw = part.value.split("\n");
212
+ if (raw[raw.length - 1] === "") {
213
+ raw.pop();
214
+ }
215
+ if (part.added || part.removed) {
216
+ // Capture the first changed line (in the new file)
217
+ if (firstChangedLine === undefined) {
218
+ firstChangedLine = newLineNum;
219
+ }
220
+ // Show the change
221
+ for (const line of raw) {
222
+ if (part.added) {
223
+ const lineNum = String(newLineNum).padStart(lineNumWidth, " ");
224
+ output.push(`+${lineNum} ${line}`);
225
+ newLineNum++;
226
+ }
227
+ else {
228
+ // removed
229
+ const lineNum = String(oldLineNum).padStart(lineNumWidth, " ");
230
+ output.push(`-${lineNum} ${line}`);
231
+ oldLineNum++;
232
+ }
233
+ }
234
+ lastWasChange = true;
235
+ }
236
+ else {
237
+ // Context lines - only show a few before/after changes
238
+ const nextPartIsChange = i < parts.length - 1 && (parts[i + 1].added || parts[i + 1].removed);
239
+ const hasLeadingChange = lastWasChange;
240
+ const hasTrailingChange = nextPartIsChange;
241
+ if (hasLeadingChange && hasTrailingChange) {
242
+ if (raw.length <= contextLines * 2) {
243
+ for (const line of raw) {
244
+ const lineNum = String(oldLineNum).padStart(lineNumWidth, " ");
245
+ output.push(` ${lineNum} ${line}`);
246
+ oldLineNum++;
247
+ newLineNum++;
248
+ }
249
+ }
250
+ else {
251
+ const leadingLines = raw.slice(0, contextLines);
252
+ const trailingLines = raw.slice(raw.length - contextLines);
253
+ const skippedLines = raw.length - leadingLines.length - trailingLines.length;
254
+ for (const line of leadingLines) {
255
+ const lineNum = String(oldLineNum).padStart(lineNumWidth, " ");
256
+ output.push(` ${lineNum} ${line}`);
257
+ oldLineNum++;
258
+ newLineNum++;
259
+ }
260
+ output.push(` ${"".padStart(lineNumWidth, " ")} ...`);
261
+ oldLineNum += skippedLines;
262
+ newLineNum += skippedLines;
263
+ for (const line of trailingLines) {
264
+ const lineNum = String(oldLineNum).padStart(lineNumWidth, " ");
265
+ output.push(` ${lineNum} ${line}`);
266
+ oldLineNum++;
267
+ newLineNum++;
268
+ }
269
+ }
270
+ }
271
+ else if (hasLeadingChange) {
272
+ const shownLines = raw.slice(0, contextLines);
273
+ const skippedLines = raw.length - shownLines.length;
274
+ for (const line of shownLines) {
275
+ const lineNum = String(oldLineNum).padStart(lineNumWidth, " ");
276
+ output.push(` ${lineNum} ${line}`);
277
+ oldLineNum++;
278
+ newLineNum++;
279
+ }
280
+ if (skippedLines > 0) {
281
+ output.push(` ${"".padStart(lineNumWidth, " ")} ...`);
282
+ oldLineNum += skippedLines;
283
+ newLineNum += skippedLines;
284
+ }
285
+ }
286
+ else if (hasTrailingChange) {
287
+ const skippedLines = Math.max(0, raw.length - contextLines);
288
+ if (skippedLines > 0) {
289
+ output.push(` ${"".padStart(lineNumWidth, " ")} ...`);
290
+ oldLineNum += skippedLines;
291
+ newLineNum += skippedLines;
292
+ }
293
+ for (const line of raw.slice(skippedLines)) {
294
+ const lineNum = String(oldLineNum).padStart(lineNumWidth, " ");
295
+ output.push(` ${lineNum} ${line}`);
296
+ oldLineNum++;
297
+ newLineNum++;
298
+ }
299
+ }
300
+ else {
301
+ // Skip these context lines entirely
302
+ oldLineNum += raw.length;
303
+ newLineNum += raw.length;
304
+ }
305
+ lastWasChange = false;
306
+ }
307
+ }
308
+ return { diff: output.join("\n"), firstChangedLine };
309
+ }
310
+ /**
311
+ * Compute the diff for one or more edit operations without applying them.
312
+ * Used for preview rendering in the TUI before the tool executes.
313
+ */
314
+ export async function computeEditsDiff(path, edits, cwd) {
315
+ const absolutePath = resolveToCwd(path, cwd);
316
+ try {
317
+ // Check if file exists and is readable
318
+ try {
319
+ await access(absolutePath, constants.R_OK);
320
+ }
321
+ catch (error) {
322
+ const errorMessage = error instanceof Error && "code" in error ? `Error code: ${error.code}` : String(error);
323
+ return { error: `Could not edit file: ${path}. ${errorMessage}.` };
324
+ }
325
+ // Read the file
326
+ const rawContent = await readFile(absolutePath, "utf-8");
327
+ // Strip BOM before matching (LLM won't include invisible BOM in oldText)
328
+ const { text: content } = stripBom(rawContent);
329
+ const normalizedContent = normalizeToLF(content);
330
+ const { baseContent, newContent } = applyEditsToNormalizedContent(normalizedContent, edits, path);
331
+ // Generate the diff
332
+ return generateDiffString(baseContent, newContent);
333
+ }
334
+ catch (err) {
335
+ return { error: err instanceof Error ? err.message : String(err) };
336
+ }
337
+ }
338
+ /**
339
+ * Compute the diff for a single edit operation without applying it.
340
+ * Kept as a convenience wrapper for single-edit callers.
341
+ */
342
+ export async function computeEditDiff(path, oldText, newText, cwd) {
343
+ return computeEditsDiff(path, [{ oldText, newText }], cwd);
344
+ }