march-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/march.mjs +13 -0
- package/package.json +36 -0
- package/src/agent/command-exec-tool.mjs +91 -0
- package/src/agent/context-stats-tool.mjs +57 -0
- package/src/agent/editing/diff-apply.mjs +28 -0
- package/src/agent/editing/diff-format.mjs +57 -0
- package/src/agent/file-edit-tool.mjs +276 -0
- package/src/agent/find-tool.mjs +112 -0
- package/src/agent/model-payload-dumper.mjs +201 -0
- package/src/agent/pi-session/pi-session-sidecar-failure.mjs +10 -0
- package/src/agent/provider/payload-messages.mjs +138 -0
- package/src/agent/read-file-tool.mjs +112 -0
- package/src/agent/runner/fast-model.mjs +36 -0
- package/src/agent/runner/runner-cleanup.mjs +12 -0
- package/src/agent/runner/runner-init.mjs +15 -0
- package/src/agent/runner/runner-session-state.mjs +40 -0
- package/src/agent/runner.mjs +266 -0
- package/src/agent/runtime/runner-runtime-host.mjs +73 -0
- package/src/agent/runtime/runtime-factory.mjs +42 -0
- package/src/agent/runtime/runtime-host.mjs +34 -0
- package/src/agent/session/session-auto-name.mjs +41 -0
- package/src/agent/session/session-binding.mjs +12 -0
- package/src/agent/session/session-options.mjs +46 -0
- package/src/agent/tool-names.mjs +1 -0
- package/src/agent/tool-result.mjs +3 -0
- package/src/agent/tools.mjs +54 -0
- package/src/agent/turn/turn-events.mjs +64 -0
- package/src/agent/turn/turn-runner.mjs +103 -0
- package/src/auth/login-command.mjs +90 -0
- package/src/auth/storage.mjs +33 -0
- package/src/cli/args.mjs +71 -0
- package/src/cli/commands/copy-command.mjs +73 -0
- package/src/cli/commands/export-command.mjs +206 -0
- package/src/cli/commands/extensions-command.mjs +53 -0
- package/src/cli/commands/help-command.mjs +7 -0
- package/src/cli/commands/model-command.mjs +110 -0
- package/src/cli/commands/paste-image-command.mjs +43 -0
- package/src/cli/commands/provider-command.mjs +55 -0
- package/src/cli/commands/status-command.mjs +157 -0
- package/src/cli/commands/thinking-command.mjs +80 -0
- package/src/cli/fallback-ui.mjs +156 -0
- package/src/cli/input/attachment-tokens.mjs +20 -0
- package/src/cli/input/autocomplete.mjs +106 -0
- package/src/cli/input/external-editor.mjs +39 -0
- package/src/cli/input/history-store.mjs +35 -0
- package/src/cli/input/image-clipboard.mjs +55 -0
- package/src/cli/input/keybinding-dispatch.mjs +76 -0
- package/src/cli/input/keybindings.mjs +96 -0
- package/src/cli/input/mode-state.mjs +43 -0
- package/src/cli/input/prompt-templates.mjs +84 -0
- package/src/cli/input/select-with-keyboard.mjs +67 -0
- package/src/cli/permissions.mjs +103 -0
- package/src/cli/repl-commands.mjs +86 -0
- package/src/cli/repl-loop.mjs +157 -0
- package/src/cli/selector-list.mjs +21 -0
- package/src/cli/session/pi-session-switch-command.mjs +41 -0
- package/src/cli/session/session-command.mjs +23 -0
- package/src/cli/session/session-list-command.mjs +68 -0
- package/src/cli/session/session-name-command.mjs +26 -0
- package/src/cli/session/session-source-command.mjs +89 -0
- package/src/cli/session/session-switch-command.mjs +1 -0
- package/src/cli/shell/shell-command.mjs +55 -0
- package/src/cli/shell/shell-drawer-controls.mjs +33 -0
- package/src/cli/shell/shell-drawer.mjs +192 -0
- package/src/cli/shell/shell-split-layout.mjs +70 -0
- package/src/cli/slash-commands.mjs +176 -0
- package/src/cli/startup/startup-banner.mjs +17 -0
- package/src/cli/startup/startup-session.mjs +51 -0
- package/src/cli/status-line-updater.mjs +74 -0
- package/src/cli/tool-output.mjs +9 -0
- package/src/cli/tui/editor/external-editor-runner.mjs +24 -0
- package/src/cli/tui/input/mouse-selection-controller.mjs +89 -0
- package/src/cli/tui/input/mouse-tracking.mjs +20 -0
- package/src/cli/tui/layout/main-pane-layout.mjs +38 -0
- package/src/cli/tui/layout/safe-render-boundary.mjs +46 -0
- package/src/cli/tui/markdown-renderer.mjs +279 -0
- package/src/cli/tui/output/scroll-state.mjs +79 -0
- package/src/cli/tui/output/tool-card-renderer.mjs +59 -0
- package/src/cli/tui/output-buffer.mjs +297 -0
- package/src/cli/tui/permission-request-ui.mjs +18 -0
- package/src/cli/tui/recall-rendering.mjs +25 -0
- package/src/cli/tui/select/editor-select-list.mjs +111 -0
- package/src/cli/tui/selection-screen.mjs +212 -0
- package/src/cli/tui/status/retry-status.mjs +72 -0
- package/src/cli/tui/status/spinner-status.mjs +42 -0
- package/src/cli/tui/status/status-bar.mjs +88 -0
- package/src/cli/tui/syntax/highlighting.mjs +277 -0
- package/src/cli/tui/syntax/languages.mjs +91 -0
- package/src/cli/tui/syntax/tree-sitter/bash.highlights.scm +261 -0
- package/src/cli/tui/syntax/tree-sitter/c.highlights.scm +341 -0
- package/src/cli/tui/syntax/tree-sitter/cpp.highlights.scm +268 -0
- package/src/cli/tui/syntax/tree-sitter/csharp.highlights.scm +577 -0
- package/src/cli/tui/syntax/tree-sitter/css.highlights.scm +109 -0
- package/src/cli/tui/syntax/tree-sitter/diff.highlights.scm +49 -0
- package/src/cli/tui/syntax/tree-sitter/go.highlights.scm +254 -0
- package/src/cli/tui/syntax/tree-sitter/html.highlights.scm +13 -0
- package/src/cli/tui/syntax/tree-sitter/java.highlights.scm +330 -0
- package/src/cli/tui/syntax/tree-sitter/json.highlights.scm +38 -0
- package/src/cli/tui/syntax/tree-sitter/php.highlights.scm +203 -0
- package/src/cli/tui/syntax/tree-sitter/python.highlights.scm +137 -0
- package/src/cli/tui/syntax/tree-sitter/ruby.highlights.scm +309 -0
- package/src/cli/tui/syntax/tree-sitter/rust.highlights.scm +531 -0
- package/src/cli/tui/syntax/tree-sitter/toml.highlights.scm +39 -0
- package/src/cli/tui/syntax/tree-sitter/tree-sitter-bash.wasm +0 -0
- package/src/cli/tui/syntax/tree-sitter/tree-sitter-c-sharp.wasm +0 -0
- package/src/cli/tui/syntax/tree-sitter/tree-sitter-c.wasm +0 -0
- package/src/cli/tui/syntax/tree-sitter/tree-sitter-cpp.wasm +0 -0
- package/src/cli/tui/syntax/tree-sitter/tree-sitter-css.wasm +0 -0
- package/src/cli/tui/syntax/tree-sitter/tree-sitter-diff.wasm +0 -0
- package/src/cli/tui/syntax/tree-sitter/tree-sitter-go.wasm +0 -0
- package/src/cli/tui/syntax/tree-sitter/tree-sitter-html.wasm +0 -0
- package/src/cli/tui/syntax/tree-sitter/tree-sitter-java.wasm +0 -0
- package/src/cli/tui/syntax/tree-sitter/tree-sitter-json.wasm +0 -0
- package/src/cli/tui/syntax/tree-sitter/tree-sitter-php.wasm +0 -0
- package/src/cli/tui/syntax/tree-sitter/tree-sitter-python.wasm +0 -0
- package/src/cli/tui/syntax/tree-sitter/tree-sitter-ruby.wasm +0 -0
- package/src/cli/tui/syntax/tree-sitter/tree-sitter-rust.wasm +0 -0
- package/src/cli/tui/syntax/tree-sitter/tree-sitter-toml.wasm +0 -0
- package/src/cli/tui/syntax/tree-sitter/tree-sitter-tsx.wasm +0 -0
- package/src/cli/tui/syntax/tree-sitter/tree-sitter-typescript.wasm +0 -0
- package/src/cli/tui/syntax/tree-sitter/tree-sitter-yaml.wasm +0 -0
- package/src/cli/tui/syntax/tree-sitter/tsx.highlights.scm +35 -0
- package/src/cli/tui/syntax/tree-sitter/typescript.highlights.scm +35 -0
- package/src/cli/tui/syntax/tree-sitter/yaml.highlights.scm +99 -0
- package/src/cli/tui/tool-rendering.mjs +194 -0
- package/src/cli/tui/tui-diff-rendering.mjs +157 -0
- package/src/cli/tui/tui-handlers.mjs +110 -0
- package/src/cli/tui/tui-input-controller.mjs +61 -0
- package/src/cli/tui/ui-theme.mjs +148 -0
- package/src/cli/ui.mjs +299 -0
- package/src/config/config-json.mjs +73 -0
- package/src/config/dotenv.mjs +20 -0
- package/src/config/features.mjs +75 -0
- package/src/config/loader.mjs +109 -0
- package/src/config/settings-command.mjs +97 -0
- package/src/context/diagnostics.mjs +70 -0
- package/src/context/engine.mjs +148 -0
- package/src/context/injections.mjs +26 -0
- package/src/context/project-context.mjs +20 -0
- package/src/context/session-status.mjs +15 -0
- package/src/context/shell-layers.mjs +23 -0
- package/src/context/system-core/base.md +60 -0
- package/src/context/system-core/prompts/deepseek-v4-pro.md +3 -0
- package/src/context/system-core/prompts/default.md +3 -0
- package/src/context/system-core.mjs +35 -0
- package/src/debug/model-context-dumper.mjs +52 -0
- package/src/extensions/discovery.mjs +40 -0
- package/src/extensions/lifecycle-adapter.mjs +210 -0
- package/src/extensions/lifecycle-manifest.mjs +69 -0
- package/src/image-gen/index.mjs +7 -0
- package/src/image-gen/provider.mjs +231 -0
- package/src/image-gen/tool.mjs +84 -0
- package/src/lsp/client.mjs +204 -0
- package/src/lsp/diagnostic-store.mjs +39 -0
- package/src/lsp/servers.mjs +212 -0
- package/src/lsp/service.mjs +65 -0
- package/src/main.mjs +294 -0
- package/src/mcp/client.mjs +195 -0
- package/src/mcp/config.mjs +130 -0
- package/src/mcp/index.mjs +48 -0
- package/src/mcp/tools.mjs +98 -0
- package/src/memory/database.mjs +219 -0
- package/src/memory/glossary.mjs +124 -0
- package/src/memory/graph/graph-cascades.mjs +109 -0
- package/src/memory/graph/graph-diagnostics.mjs +73 -0
- package/src/memory/graph/graph-path-removal.mjs +50 -0
- package/src/memory/graph/graph-path-utils.mjs +17 -0
- package/src/memory/graph/graph-primitives.mjs +103 -0
- package/src/memory/graph/graph-read.mjs +159 -0
- package/src/memory/graph.mjs +282 -0
- package/src/memory/markdown/markdown-delete.mjs +23 -0
- package/src/memory/markdown/markdown-format.mjs +128 -0
- package/src/memory/markdown/markdown-recall.mjs +28 -0
- package/src/memory/markdown/ripgrep.mjs +16 -0
- package/src/memory/markdown/sqlite-index.mjs +87 -0
- package/src/memory/markdown-store.mjs +286 -0
- package/src/memory/markdown-tools.mjs +103 -0
- package/src/memory/search.mjs +142 -0
- package/src/memory/snapshot.mjs +86 -0
- package/src/memory/system-views.mjs +120 -0
- package/src/memory/tools.mjs +282 -0
- package/src/notification/desktop-notifier.mjs +85 -0
- package/src/platform/open-file.mjs +28 -0
- package/src/provider/config-command.mjs +129 -0
- package/src/provider/presets.mjs +72 -0
- package/src/session/attachment-display.mjs +16 -0
- package/src/session/attachment-references.mjs +65 -0
- package/src/session/attachments.mjs +140 -0
- package/src/session/persist.mjs +1 -0
- package/src/session/pi-manager.mjs +34 -0
- package/src/session/session-utils.mjs +16 -0
- package/src/session/sidecar-sync.mjs +19 -0
- package/src/session/sidecar.mjs +68 -0
- package/src/session/transcript.mjs +83 -0
- package/src/session/tree.mjs +42 -0
- package/src/shell/cli-runtime.mjs +11 -0
- package/src/shell/hints.mjs +12 -0
- package/src/shell/node-pty-adapter.mjs +81 -0
- package/src/shell/runtime-state.mjs +126 -0
- package/src/shell/runtime.mjs +244 -0
- package/src/shell/screen-buffer.mjs +136 -0
- package/src/shell/tool-read.mjs +74 -0
- package/src/shell/tools.mjs +299 -0
- package/src/supergrok/actions/image-generate.mjs +60 -0
- package/src/supergrok/actions/search.mjs +78 -0
- package/src/supergrok/auth.mjs +36 -0
- package/src/supergrok/constants.mjs +18 -0
- package/src/supergrok/oauth-provider.mjs +278 -0
- package/src/supergrok/provider.mjs +36 -0
- package/src/supergrok/response.mjs +76 -0
- package/src/supergrok/tool.mjs +61 -0
- package/src/text/ansi.mjs +3 -0
- package/src/web/config-command.mjs +43 -0
- package/src/web/fetch.mjs +78 -0
- package/src/web/presets.mjs +16 -0
- package/src/web/search.mjs +83 -0
- package/src/web/tools.mjs +107 -0
package/bin/march.mjs
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const emitWarning = process.emitWarning;
|
|
4
|
+
process.emitWarning = function filteredWarning(warning, ...args) {
|
|
5
|
+
const message = typeof warning === "string" ? warning : warning?.message;
|
|
6
|
+
const type = typeof warning === "string" ? args[0] : warning?.name;
|
|
7
|
+
if (type === "ExperimentalWarning" && String(message).includes("SQLite")) return;
|
|
8
|
+
return emitWarning.call(this, warning, ...args);
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const { run } = await import("../src/main.mjs");
|
|
12
|
+
const code = await run(process.argv.slice(2));
|
|
13
|
+
process.exitCode = code;
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "march-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "March CLI — terminal-native coding agent with context reconstruction",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/main.mjs",
|
|
7
|
+
"bin": {
|
|
8
|
+
"march": "bin/march.mjs"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin/",
|
|
12
|
+
"src/"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"dev": "cd .. && node march-cli/bin/march.mjs",
|
|
16
|
+
"test": "node test/smoke.test.mjs",
|
|
17
|
+
"test:real": "npm run test:shell-runtime-real && npm run test:shell-tui-real && npm run test:tui-key-real",
|
|
18
|
+
"test:shell-runtime-real": "node test/shell-real-runtime.acceptance.mjs",
|
|
19
|
+
"test:shell-tui-real": "node test/shell-tui-real.acceptance.mjs",
|
|
20
|
+
"test:tui-key-real": "node test/tui-key-real.acceptance.mjs",
|
|
21
|
+
"context": "cd .. && node march-cli/bin/march.mjs --dump-context"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@earendil-works/pi-ai": "^0.74.0",
|
|
25
|
+
"@earendil-works/pi-coding-agent": "^0.74.0",
|
|
26
|
+
"@earendil-works/pi-tui": "^0.74.0",
|
|
27
|
+
"@xterm/headless": "^5.5.0",
|
|
28
|
+
"marked": "^18.0.3",
|
|
29
|
+
"node-pty": "^1.1.0",
|
|
30
|
+
"typebox": "^1.0.58",
|
|
31
|
+
"web-tree-sitter": "^0.26.8"
|
|
32
|
+
},
|
|
33
|
+
"optionalDependencies": {
|
|
34
|
+
"@vscode/ripgrep": "^1.18.0"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { Type } from "typebox";
|
|
5
|
+
import { toolText } from "./tool-result.mjs";
|
|
6
|
+
import { stripAnsi } from "../text/ansi.mjs";
|
|
7
|
+
|
|
8
|
+
const OUTPUT_LIMIT = 64 * 1024;
|
|
9
|
+
|
|
10
|
+
export function createCommandExecTool({ cwd }) {
|
|
11
|
+
return defineTool({
|
|
12
|
+
name: "command_exec",
|
|
13
|
+
label: "Command Exec",
|
|
14
|
+
description: "Run a one-shot command in the project directory. Use terminal_* for interactive or long-running processes.",
|
|
15
|
+
parameters: Type.Object({
|
|
16
|
+
command: Type.String({ description: "Command to execute" }),
|
|
17
|
+
shell: Type.Optional(Type.String({ description: "auto (default), bash, or powershell" })),
|
|
18
|
+
timeout: Type.Optional(Type.Number({ description: "Timeout in seconds; default 60" })),
|
|
19
|
+
}),
|
|
20
|
+
execute: async (_toolCallId, params) => executeCommand({ cwd, ...params }),
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function executeCommand({ cwd, command, shell = "auto", timeout = 60, spawnSyncImpl = spawnSync }) {
|
|
25
|
+
let resolved;
|
|
26
|
+
try {
|
|
27
|
+
resolved = resolveCommandShell(shell);
|
|
28
|
+
} catch (err) {
|
|
29
|
+
return toolText(`Error: ${err.message}`, { error: true });
|
|
30
|
+
}
|
|
31
|
+
const result = spawnSyncImpl(resolved.bin, [...resolved.args, String(command ?? "")], {
|
|
32
|
+
cwd,
|
|
33
|
+
encoding: "utf8",
|
|
34
|
+
timeout: Math.max(1, Number(timeout) || 60) * 1000,
|
|
35
|
+
windowsHide: true,
|
|
36
|
+
maxBuffer: OUTPUT_LIMIT,
|
|
37
|
+
});
|
|
38
|
+
if (result.error) {
|
|
39
|
+
const isTimeout = result.error.code === "ETIMEDOUT" || result.signal === "SIGTERM";
|
|
40
|
+
const detail = isTimeout ? ` (timed out after ${timeout}s)` : "";
|
|
41
|
+
return toolText(`Error: ${result.error.message}${detail}`, { error: true });
|
|
42
|
+
}
|
|
43
|
+
const stdout = stripAnsi(result.stdout ?? "");
|
|
44
|
+
const stderr = stripAnsi(result.stderr ?? "");
|
|
45
|
+
const output = formatCommandOutput({ stdout, stderr, status: result.status });
|
|
46
|
+
return toolText(output, {
|
|
47
|
+
status: result.status,
|
|
48
|
+
stdout,
|
|
49
|
+
stderr,
|
|
50
|
+
shell: resolved.name,
|
|
51
|
+
error: result.status !== 0,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function resolveCommandShell(shell = "auto", platform = process.platform) {
|
|
56
|
+
const normalized = String(shell ?? "auto").trim().toLowerCase();
|
|
57
|
+
if (normalized === "powershell" || (normalized === "auto" && platform === "win32")) {
|
|
58
|
+
return { name: "powershell", bin: findPowerShell() ?? "powershell.exe", args: ["-NoProfile", "-Command"] };
|
|
59
|
+
}
|
|
60
|
+
if (normalized === "bash" || normalized === "auto") {
|
|
61
|
+
return { name: "bash", bin: "bash", args: ["-lc"] };
|
|
62
|
+
}
|
|
63
|
+
throw new Error(`unsupported shell: ${shell}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function findPowerShell() {
|
|
67
|
+
for (const name of ["pwsh.exe", "powershell.exe"]) {
|
|
68
|
+
try {
|
|
69
|
+
const result = spawnSync("where", [name], { encoding: "utf-8", timeout: 5000, windowsHide: true });
|
|
70
|
+
if (result.status === 0 && result.stdout) {
|
|
71
|
+
const first = result.stdout.trim().split(/\r?\n/)[0];
|
|
72
|
+
if (first && existsSync(first)) return first;
|
|
73
|
+
}
|
|
74
|
+
} catch {}
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function formatCommandOutput({ stdout, stderr, status }) {
|
|
80
|
+
const parts = [];
|
|
81
|
+
if (stdout) parts.push(stdout.trimEnd());
|
|
82
|
+
if (stderr) parts.push(stderr.trimEnd());
|
|
83
|
+
if (status && status !== 0) parts.push(`exit ${status}`);
|
|
84
|
+
const output = parts.filter(Boolean).join("\n");
|
|
85
|
+
return output ? truncateOutput(output) : "(no output)";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function truncateOutput(text) {
|
|
89
|
+
if (text.length <= OUTPUT_LIMIT) return text;
|
|
90
|
+
return `${text.slice(-OUTPUT_LIMIT)}\n... (truncated to last ${OUTPUT_LIMIT} chars)`;
|
|
91
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
import { toolText } from "./tool-result.mjs";
|
|
4
|
+
|
|
5
|
+
export function createContextStatsTool({ engine }) {
|
|
6
|
+
return defineTool({
|
|
7
|
+
name: "context_stats",
|
|
8
|
+
label: "Context Stats",
|
|
9
|
+
description: "Show size statistics for the current March context layers without returning the full prompt text.",
|
|
10
|
+
parameters: Type.Object({}),
|
|
11
|
+
execute: async () => toolText(formatContextStats(engine), buildContextStats(engine)),
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function buildContextStats(engine) {
|
|
16
|
+
const layers = engine.buildContextLayers("");
|
|
17
|
+
const contextText = layers.map((layer) => layer.text).join("\n\n");
|
|
18
|
+
const layerStats = layers.map((layer) => ({
|
|
19
|
+
name: layer.name,
|
|
20
|
+
chars: layer.text.length,
|
|
21
|
+
estimatedTokens: estimateTokens(layer.text.length),
|
|
22
|
+
}));
|
|
23
|
+
return {
|
|
24
|
+
totalChars: contextText.length,
|
|
25
|
+
estimatedTokens: estimateTokens(contextText.length),
|
|
26
|
+
layers: layerStats,
|
|
27
|
+
runtime: {
|
|
28
|
+
turns: engine.turns?.length ?? 0,
|
|
29
|
+
toolDefs: engine.toolDefs?.length ?? 0,
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function formatContextStats(engine) {
|
|
35
|
+
const stats = buildContextStats(engine);
|
|
36
|
+
const lines = [
|
|
37
|
+
"Context stats:",
|
|
38
|
+
`total_chars: ${stats.totalChars}`,
|
|
39
|
+
`estimated_tokens: ${stats.estimatedTokens}`,
|
|
40
|
+
"",
|
|
41
|
+
"Layers:",
|
|
42
|
+
];
|
|
43
|
+
for (const layer of stats.layers) {
|
|
44
|
+
lines.push(`- ${layer.name}: ${layer.chars} chars, ~${layer.estimatedTokens} tokens`);
|
|
45
|
+
}
|
|
46
|
+
lines.push(
|
|
47
|
+
"",
|
|
48
|
+
"Runtime:",
|
|
49
|
+
`- turns: ${stats.runtime.turns}`,
|
|
50
|
+
`- tool_defs: ${stats.runtime.toolDefs}`,
|
|
51
|
+
);
|
|
52
|
+
return lines.join("\n");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function estimateTokens(chars) {
|
|
56
|
+
return Math.ceil(chars / 4);
|
|
57
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Apply unified-diff-like patches to in-memory text content.
|
|
3
|
+
* Used by file-edit-tool for dry-run validation and safe application.
|
|
4
|
+
*/
|
|
5
|
+
export function applyReplaceTextPatch(text, oldText, newText) {
|
|
6
|
+
const idx = text.indexOf(oldText);
|
|
7
|
+
if (idx === -1) {
|
|
8
|
+
return { ok: false, error: `Text not found in content` };
|
|
9
|
+
}
|
|
10
|
+
const before = text.slice(0, idx);
|
|
11
|
+
const after = text.slice(idx + oldText.length);
|
|
12
|
+
return { ok: true, result: before + newText + after };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function applyReplaceRangePatch(text, startLine, endLine, newText) {
|
|
16
|
+
const lines = text.split("\n");
|
|
17
|
+
if (startLine < 1 || startLine > lines.length) {
|
|
18
|
+
return { ok: false, error: `startLine ${startLine} out of range (1-${lines.length})` };
|
|
19
|
+
}
|
|
20
|
+
if (endLine < startLine || endLine > lines.length) {
|
|
21
|
+
return { ok: false, error: `endLine ${endLine} out of range (${startLine}-${lines.length})` };
|
|
22
|
+
}
|
|
23
|
+
const zeroBasedStart = startLine - 1;
|
|
24
|
+
const before = lines.slice(0, zeroBasedStart);
|
|
25
|
+
const after = lines.slice(endLine);
|
|
26
|
+
const newLines = newText === "" ? [] : newText.split("\n");
|
|
27
|
+
return { ok: true, result: [...before, ...newLines, ...after].join("\n") };
|
|
28
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export function formatDiff(oldText, newText, { startLine = 1 } = {}) {
|
|
2
|
+
if (oldText === "") return newText === "" ? [] : newText.split("\n").map((text, i) => ({ type: "add", text, lineNum: startLine + i }));
|
|
3
|
+
if (newText === "") return oldText.split("\n").map((text, i) => ({ type: "del", text, lineNum: startLine + i }));
|
|
4
|
+
const oldLines = oldText.split("\n");
|
|
5
|
+
const newLines = newText.split("\n");
|
|
6
|
+
|
|
7
|
+
let prefix = 0;
|
|
8
|
+
while (prefix < oldLines.length && prefix < newLines.length && oldLines[prefix] === newLines[prefix]) prefix++;
|
|
9
|
+
|
|
10
|
+
let suffix = 0;
|
|
11
|
+
while (
|
|
12
|
+
suffix < oldLines.length - prefix &&
|
|
13
|
+
suffix < newLines.length - prefix &&
|
|
14
|
+
oldLines[oldLines.length - 1 - suffix] === newLines[newLines.length - 1 - suffix]
|
|
15
|
+
) {
|
|
16
|
+
suffix++;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const ctx = 3;
|
|
20
|
+
const result = [];
|
|
21
|
+
const ctxStart = Math.max(0, prefix - ctx);
|
|
22
|
+
for (let i = ctxStart; i < prefix; i++) result.push({ type: "ctx", text: oldLines[i], lineNum: startLine + i });
|
|
23
|
+
|
|
24
|
+
const oldEnd = oldLines.length - suffix;
|
|
25
|
+
for (let i = prefix; i < oldEnd; i++) result.push({ type: "del", text: oldLines[i], lineNum: startLine + i });
|
|
26
|
+
|
|
27
|
+
const newEnd = newLines.length - suffix;
|
|
28
|
+
for (let i = prefix; i < newEnd; i++) result.push({ type: "add", text: newLines[i], lineNum: startLine + i });
|
|
29
|
+
|
|
30
|
+
const postStart = oldLines.length - suffix;
|
|
31
|
+
const postEnd = Math.min(oldLines.length, postStart + ctx);
|
|
32
|
+
for (let i = postStart; i < postEnd; i++) result.push({ type: "ctx", text: oldLines[i], lineNum: startLine + i });
|
|
33
|
+
return result;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function countDiffChanges(diffLines) {
|
|
37
|
+
let adds = 0, dels = 0;
|
|
38
|
+
for (const line of diffLines) {
|
|
39
|
+
if (line.type === "add") adds++;
|
|
40
|
+
if (line.type === "del") dels++;
|
|
41
|
+
}
|
|
42
|
+
return { adds, dels };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function formatAppliedDiff(edits) {
|
|
46
|
+
const lines = ["[diff]"];
|
|
47
|
+
for (const edit of edits) {
|
|
48
|
+
const oldLineCount = edit.oldText.split("\n").length;
|
|
49
|
+
lines.push(`@@ lines ${edit.startLine}-${edit.startLine + oldLineCount - 1} @@`);
|
|
50
|
+
for (const line of formatDiff(edit.oldText, edit.newText, { startLine: edit.startLine })) {
|
|
51
|
+
if (line.type === "ctx") lines.push(` ${line.lineNum}: ${line.text}`);
|
|
52
|
+
if (line.type === "del") lines.push(`-${line.lineNum}: ${line.text}`);
|
|
53
|
+
if (line.type === "add") lines.push(`+${line.lineNum}: ${line.text}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return lines.join("\n");
|
|
57
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { Type } from "typebox";
|
|
5
|
+
import { toolText } from "./tool-result.mjs";
|
|
6
|
+
import { applyReplaceTextPatch, applyReplaceRangePatch } from "./editing/diff-apply.mjs";
|
|
7
|
+
import { formatAppliedDiff, formatDiff } from "./editing/diff-format.mjs";
|
|
8
|
+
import { buildDiagnosticsForPath } from "../context/diagnostics.mjs";
|
|
9
|
+
|
|
10
|
+
export { formatDiff } from "./editing/diff-format.mjs";
|
|
11
|
+
|
|
12
|
+
const PATCH_MODE = "patch";
|
|
13
|
+
const WRITE_MODE = "write";
|
|
14
|
+
const OVERWRITE_MODE = "overwrite";
|
|
15
|
+
|
|
16
|
+
const replaceTextEditSchema = Type.Object({
|
|
17
|
+
type: Type.Literal("replace_text"),
|
|
18
|
+
oldText: Type.String({ description: "Exact text to replace. Must match exactly once in the original file." }),
|
|
19
|
+
newText: Type.String({ description: "Replacement text" }),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const replaceRangeEditSchema = Type.Object({
|
|
23
|
+
type: Type.Literal("replace_range"),
|
|
24
|
+
startLine: Type.Number({ description: "1-based inclusive start line" }),
|
|
25
|
+
endLine: Type.Number({ description: "1-based inclusive end line. This line is deleted too; keep it out of the range or include it in newText if it should remain." }),
|
|
26
|
+
newText: Type.String({ description: "Replacement text. Omit a trailing newline unless intentionally adding a blank line." }),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export function createEditFileTool({ engine, ui, lspService = null }) {
|
|
30
|
+
return defineTool({
|
|
31
|
+
name: "edit_file",
|
|
32
|
+
label: "Edit File",
|
|
33
|
+
description:
|
|
34
|
+
"Single file write tool. Use mode=patch with edits[] for targeted edits. " +
|
|
35
|
+
"Use mode=write with content for new files. Use mode=overwrite with content for full-file replacement.",
|
|
36
|
+
parameters: Type.Object({
|
|
37
|
+
path: Type.String({ description: "Absolute or relative path" }),
|
|
38
|
+
mode: Type.Optional(Type.Union([
|
|
39
|
+
Type.Literal(PATCH_MODE),
|
|
40
|
+
Type.Literal(WRITE_MODE),
|
|
41
|
+
Type.Literal(OVERWRITE_MODE),
|
|
42
|
+
], { description: "patch (default), write, or overwrite" })),
|
|
43
|
+
edits: Type.Optional(Type.Array(Type.Union([replaceTextEditSchema, replaceRangeEditSchema]), {
|
|
44
|
+
description: "Patch edits. replace_text uses exact text; replace_range uses 1-based inclusive line numbers and deletes the endLine content too.",
|
|
45
|
+
})),
|
|
46
|
+
content: Type.Optional(Type.String({ description: "Full file content for mode=write or mode=overwrite" })),
|
|
47
|
+
}),
|
|
48
|
+
execute: async (_toolCallId, params) => executeEditFile({ params, engine, ui, lspService }),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function executeEditFile({ params, engine, ui, lspService = null }) {
|
|
53
|
+
const absPath = engine.resolvePath(params.path);
|
|
54
|
+
const mode = params.mode ?? PATCH_MODE;
|
|
55
|
+
|
|
56
|
+
if (mode === WRITE_MODE || mode === OVERWRITE_MODE) {
|
|
57
|
+
return await writeFullFile({ absPath, path: params.path, content: params.content, mode, engine, ui, lspService });
|
|
58
|
+
}
|
|
59
|
+
if (mode !== PATCH_MODE) return toolText(`Error: unsupported edit_file mode: ${mode}`, { error: true });
|
|
60
|
+
return await patchFile({ absPath, path: params.path, edits: params.edits, engine, ui, lspService });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function writeFullFile({ absPath, path, content, mode, engine, ui, lspService }) {
|
|
64
|
+
if (typeof content !== "string") {
|
|
65
|
+
return toolText(`Error: content is required for mode=${mode}`, { error: true });
|
|
66
|
+
}
|
|
67
|
+
if (mode === WRITE_MODE && existsSync(absPath)) {
|
|
68
|
+
return toolText(`Error: ${absPath} already exists. Use mode=overwrite to replace it.`, { error: true });
|
|
69
|
+
}
|
|
70
|
+
const oldText = mode === OVERWRITE_MODE && existsSync(absPath) ? readFileSync(absPath, "utf8") : "";
|
|
71
|
+
const diffLines = formatDiff(oldText, content, { startLine: 1 });
|
|
72
|
+
try {
|
|
73
|
+
mkdirSync(dirname(absPath), { recursive: true });
|
|
74
|
+
writeFileSync(absPath, content, "utf8");
|
|
75
|
+
lspService?.touchFile?.(absPath);
|
|
76
|
+
ui.editDiff(absPath, diffLines);
|
|
77
|
+
|
|
78
|
+
return await toolTextWithDiagnostics(`${mode === WRITE_MODE ? "Wrote" : "Overwrote"} ${path}\n\n${formatAppliedDiff([{ oldText, newText: content, startLine: 1 }])}`, { path: absPath }, { lspService, path: absPath });
|
|
79
|
+
} catch (err) {
|
|
80
|
+
return toolText(`Error writing ${absPath}: ${err.message}`, { error: true });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function patchFile({ absPath, path, edits, engine, ui, lspService }) {
|
|
85
|
+
if (!existsSync(absPath)) {
|
|
86
|
+
return toolText(`Error: ${absPath} does not exist. Use mode=write to create it.`, { error: true });
|
|
87
|
+
}
|
|
88
|
+
if (!Array.isArray(edits) || edits.length === 0) {
|
|
89
|
+
return toolText("Error: mode=patch requires at least one edit", { error: true });
|
|
90
|
+
}
|
|
91
|
+
if (edits.length > 50) {
|
|
92
|
+
return toolText("Error: maximum 50 edits per call", { error: true });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const content = readFileSync(absPath, "utf8");
|
|
96
|
+
const prepared = preparePatchEdits(content, edits, absPath);
|
|
97
|
+
if (prepared.error) return toolText(prepared.error, { error: true });
|
|
98
|
+
|
|
99
|
+
const newContent = applyPreparedEdits(content, prepared.edits);
|
|
100
|
+
try {
|
|
101
|
+
mkdirSync(dirname(absPath), { recursive: true });
|
|
102
|
+
writeFileSync(absPath, newContent, "utf8");
|
|
103
|
+
lspService?.touchFile?.(absPath);
|
|
104
|
+
ui.editDiff(absPath, prepared.edits.flatMap((edit) => formatDiff(edit.oldText, edit.newText, { startLine: edit.startLine })));
|
|
105
|
+
return await toolTextWithDiagnostics(`Edited ${absPath}\n\n${formatAppliedDiff(prepared.edits)}`, { path: absPath, edits: prepared.edits.length }, { lspService, path: absPath });
|
|
106
|
+
} catch (err) {
|
|
107
|
+
return toolText(`Error writing ${absPath}: ${err.message}`, { error: true });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function toolTextWithDiagnostics(text, details, { lspService, path, timeoutMs = 3000, intervalMs = 150 } = {}) {
|
|
112
|
+
const diagnostics = await waitForDiagnosticsForPath({ lspService, path, timeoutMs, intervalMs });
|
|
113
|
+
return toolText(diagnostics ? `${text}\n\n${diagnostics}` : text, details);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function waitForDiagnosticsForPath({ lspService, path, timeoutMs, intervalMs }) {
|
|
117
|
+
if (!lspService?.snapshot || !path) return "";
|
|
118
|
+
const deadline = Date.now() + timeoutMs;
|
|
119
|
+
for (;;) {
|
|
120
|
+
const diagnostics = buildDiagnosticsForPath({ snapshot: lspService.snapshot(), path });
|
|
121
|
+
if (diagnostics) return diagnostics;
|
|
122
|
+
const remaining = deadline - Date.now();
|
|
123
|
+
if (remaining <= 0) return "";
|
|
124
|
+
await sleep(Math.min(intervalMs, remaining));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function sleep(ms) {
|
|
129
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function preparePatchEdits(content, edits, path = "file") {
|
|
133
|
+
const prepared = [];
|
|
134
|
+
for (const edit of edits) {
|
|
135
|
+
const next = prepareOneEdit(content, edit, path);
|
|
136
|
+
if (next.error) return next;
|
|
137
|
+
prepared.push(next.edit);
|
|
138
|
+
}
|
|
139
|
+
prepared.sort((a, b) => a.start - b.start || a.end - b.end);
|
|
140
|
+
for (let i = 1; i < prepared.length; i++) {
|
|
141
|
+
if (prepared[i].start < prepared[i - 1].end) {
|
|
142
|
+
return { error: `Error: edits overlap in ${path}` };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return { edits: prepared };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function prepareOneEdit(content, edit, path) {
|
|
149
|
+
if (edit?.type === "replace_text") return prepareTextEdit(content, edit, path);
|
|
150
|
+
if (edit?.type === "replace_range") return prepareRangeEdit(content, edit, path);
|
|
151
|
+
return { error: `Error: unsupported edit type: ${edit?.type}` };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function prepareTextEdit(content, edit, path) {
|
|
155
|
+
if (!edit.oldText) return { error: "Error: replace_text oldText must be non-empty" };
|
|
156
|
+
const first = content.indexOf(edit.oldText);
|
|
157
|
+
if (first < 0) return { error: formatMissingOldTextError(content, edit.oldText, path) };
|
|
158
|
+
const second = content.indexOf(edit.oldText, first + edit.oldText.length);
|
|
159
|
+
if (second >= 0) return { error: `Error: oldText is not unique in ${path}. Use replace_range or include more context.` };
|
|
160
|
+
return {
|
|
161
|
+
edit: {
|
|
162
|
+
start: first,
|
|
163
|
+
end: first + edit.oldText.length,
|
|
164
|
+
oldText: edit.oldText,
|
|
165
|
+
newText: edit.newText,
|
|
166
|
+
startLine: lineNumberForOffset(content, first),
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function formatMissingOldTextError(content, oldText, path) {
|
|
172
|
+
const candidate = findClosestTextCandidate(content, oldText);
|
|
173
|
+
const lines = [`Error: oldText not found in ${path}. File may have changed.`];
|
|
174
|
+
if (!candidate) return lines.join("\n");
|
|
175
|
+
lines.push(
|
|
176
|
+
"",
|
|
177
|
+
"Closest candidate:",
|
|
178
|
+
`lines ${candidate.startLine}-${candidate.endLine}, similarity ${candidate.score.toFixed(2)}`,
|
|
179
|
+
"---",
|
|
180
|
+
candidate.snippet,
|
|
181
|
+
"---",
|
|
182
|
+
`Use replace_range with startLine=${candidate.startLine} endLine=${candidate.endLine} if this is intended.`,
|
|
183
|
+
);
|
|
184
|
+
return lines.join("\n");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function findClosestTextCandidate(content, oldText) {
|
|
188
|
+
const lines = content.split("\n");
|
|
189
|
+
const oldLineCount = Math.max(1, oldText.split("\n").length);
|
|
190
|
+
const windowSizes = [...new Set([oldLineCount - 1, oldLineCount, oldLineCount + 1])]
|
|
191
|
+
.filter((size) => size > 0 && size <= lines.length);
|
|
192
|
+
let best = null;
|
|
193
|
+
for (const size of windowSizes) {
|
|
194
|
+
for (let start = 0; start <= lines.length - size; start++) {
|
|
195
|
+
const snippet = lines.slice(start, start + size).join("\n");
|
|
196
|
+
const score = textSimilarity(oldText, snippet);
|
|
197
|
+
if (!best || score > best.score) {
|
|
198
|
+
best = { startLine: start + 1, endLine: start + size, score, snippet: truncateSnippet(snippet) };
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return best?.score >= 0.2 ? best : null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function textSimilarity(a, b) {
|
|
206
|
+
const aTokens = tokenizeForSimilarity(a);
|
|
207
|
+
const bTokens = tokenizeForSimilarity(b);
|
|
208
|
+
if (aTokens.length === 0 || bTokens.length === 0) return 0;
|
|
209
|
+
const counts = new Map();
|
|
210
|
+
for (const token of aTokens) counts.set(token, (counts.get(token) ?? 0) + 1);
|
|
211
|
+
let common = 0;
|
|
212
|
+
for (const token of bTokens) {
|
|
213
|
+
const count = counts.get(token) ?? 0;
|
|
214
|
+
if (count <= 0) continue;
|
|
215
|
+
common++;
|
|
216
|
+
counts.set(token, count - 1);
|
|
217
|
+
}
|
|
218
|
+
return (2 * common) / (aTokens.length + bTokens.length);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function tokenizeForSimilarity(text) {
|
|
222
|
+
const tokens = String(text).toLowerCase().match(/[a-z0-9_]+/g);
|
|
223
|
+
if (tokens?.length) return tokens;
|
|
224
|
+
return String(text).replace(/\s+/g, "").split("").filter(Boolean);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function truncateSnippet(text) {
|
|
228
|
+
const limit = 1200;
|
|
229
|
+
if (text.length <= limit) return text;
|
|
230
|
+
return `${text.slice(0, limit)}\n...(snippet truncated)`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function prepareRangeEdit(content, edit, path) {
|
|
234
|
+
const lines = content.split("\n");
|
|
235
|
+
const startLine = Math.trunc(edit.startLine);
|
|
236
|
+
const endLine = Math.trunc(edit.endLine);
|
|
237
|
+
if (startLine < 1 || endLine > lines.length || startLine > endLine) {
|
|
238
|
+
return { error: `Error: line range ${startLine}-${endLine} out of bounds (file has ${lines.length} lines)` };
|
|
239
|
+
}
|
|
240
|
+
const oldText = lines.slice(startLine - 1, endLine).join("\n");
|
|
241
|
+
const newText = normalizeRangeNewText(edit.newText, endLine, lines.length);
|
|
242
|
+
return {
|
|
243
|
+
edit: {
|
|
244
|
+
start: offsetForLine(lines, startLine),
|
|
245
|
+
end: offsetForLine(lines, startLine) + oldText.length,
|
|
246
|
+
oldText,
|
|
247
|
+
newText,
|
|
248
|
+
startLine,
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function normalizeRangeNewText(newText, endLine, lineCount) {
|
|
254
|
+
if (endLine < lineCount && /\r?\n$/.test(newText)) return newText.replace(/\r?\n$/, "");
|
|
255
|
+
return newText;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function lineNumberForOffset(content, offset) {
|
|
259
|
+
let line = 1;
|
|
260
|
+
for (let i = 0; i < offset; i++) if (content[i] === "\n") line++;
|
|
261
|
+
return line;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function offsetForLine(lines, lineNumber) {
|
|
265
|
+
let offset = 0;
|
|
266
|
+
for (let i = 0; i < lineNumber - 1; i++) offset += lines[i].length + 1;
|
|
267
|
+
return offset;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function applyPreparedEdits(content, edits) {
|
|
271
|
+
let next = content;
|
|
272
|
+
for (const edit of [...edits].sort((a, b) => b.start - a.start)) {
|
|
273
|
+
next = next.slice(0, edit.start) + edit.newText + next.slice(edit.end);
|
|
274
|
+
}
|
|
275
|
+
return next;
|
|
276
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
3
|
+
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { Type } from "typebox";
|
|
5
|
+
import { toolText } from "./tool-result.mjs";
|
|
6
|
+
|
|
7
|
+
const DEFAULT_LIMIT = 1000;
|
|
8
|
+
const DEFAULT_IGNORES = new Set([".git", "node_modules"]);
|
|
9
|
+
|
|
10
|
+
export function createFindTool({ cwd }) {
|
|
11
|
+
return defineTool({
|
|
12
|
+
name: "find",
|
|
13
|
+
label: "Find Files",
|
|
14
|
+
description: "Find files by glob pattern. Pattern is matched relative to the search directory. Basename-only patterns like '*.mjs' search recursively, so find('*.mjs', path:'src') and find('src/**/*.mjs') both work.",
|
|
15
|
+
parameters: Type.Object({
|
|
16
|
+
pattern: Type.String({ description: "Glob pattern to match files, e.g. '*.mjs', '**/*.json', or 'src/**/*.test.mjs'" }),
|
|
17
|
+
path: Type.Optional(Type.String({ description: "Directory to search in (default: current directory)" })),
|
|
18
|
+
limit: Type.Optional(Type.Number({ description: "Maximum number of results (default 1000)" })),
|
|
19
|
+
}),
|
|
20
|
+
execute: async (_toolCallId, params) => executeFind({ cwd, ...params }),
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function executeFind({ cwd, pattern, path = ".", limit = DEFAULT_LIMIT }) {
|
|
25
|
+
const searchRoot = resolveSearchRoot(cwd, path);
|
|
26
|
+
const trimmedPattern = String(pattern ?? "").trim().replaceAll("\\", "/");
|
|
27
|
+
if (!trimmedPattern) return toolText("Error: pattern is required", { error: true });
|
|
28
|
+
const effectivePattern = normalizePattern(trimmedPattern);
|
|
29
|
+
|
|
30
|
+
const max = Math.max(1, Number(limit) || DEFAULT_LIMIT);
|
|
31
|
+
let files;
|
|
32
|
+
try {
|
|
33
|
+
files = listFiles(searchRoot);
|
|
34
|
+
} catch (err) {
|
|
35
|
+
return toolText(`Error: ${err.message}`, { error: true });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const matches = [];
|
|
39
|
+
for (const file of files) {
|
|
40
|
+
const rel = toPosix(relative(searchRoot, file));
|
|
41
|
+
if (!matchesGlob(effectivePattern, rel)) continue;
|
|
42
|
+
matches.push(rel);
|
|
43
|
+
if (matches.length >= max) break;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (matches.length === 0) return toolText("No files found matching pattern", { pattern: trimmedPattern, effectivePattern, path: searchRoot, count: 0 });
|
|
47
|
+
const limitHint = matches.length >= max ? `\n\n[Results truncated to ${max}. Increase limit or refine pattern.]` : "";
|
|
48
|
+
return toolText(`${matches.join("\n")}${limitHint}`, {
|
|
49
|
+
pattern: trimmedPattern,
|
|
50
|
+
effectivePattern: effectivePattern === trimmedPattern ? undefined : effectivePattern,
|
|
51
|
+
path: searchRoot,
|
|
52
|
+
count: matches.length,
|
|
53
|
+
resultLimitReached: matches.length >= max ? max : undefined,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function normalizePattern(pattern) {
|
|
58
|
+
if (pattern.includes("/") || pattern.includes("**")) return pattern;
|
|
59
|
+
return `**/${pattern}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function resolveSearchRoot(cwd, path) {
|
|
63
|
+
const raw = String(path || ".");
|
|
64
|
+
return isAbsolute(raw) ? raw : resolve(cwd, raw);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function listFiles(root) {
|
|
68
|
+
const out = [];
|
|
69
|
+
walk(root, out);
|
|
70
|
+
return out.sort((a, b) => toPosix(a).localeCompare(toPosix(b)));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function walk(dir, out) {
|
|
74
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
75
|
+
if (entry.isDirectory() && DEFAULT_IGNORES.has(entry.name)) continue;
|
|
76
|
+
const path = resolve(dir, entry.name);
|
|
77
|
+
if (entry.isDirectory()) walk(path, out);
|
|
78
|
+
else if (entry.isFile()) out.push(path);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function matchesGlob(pattern, candidate) {
|
|
83
|
+
return matchSegments(splitGlob(pattern), splitGlob(candidate));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function matchSegments(patternSegments, candidateSegments) {
|
|
87
|
+
if (patternSegments.length === 0) return candidateSegments.length === 0;
|
|
88
|
+
const [head, ...tail] = patternSegments;
|
|
89
|
+
if (head === "**") {
|
|
90
|
+
if (matchSegments(tail, candidateSegments)) return true;
|
|
91
|
+
return candidateSegments.length > 0 && matchSegments(patternSegments, candidateSegments.slice(1));
|
|
92
|
+
}
|
|
93
|
+
if (candidateSegments.length === 0) return false;
|
|
94
|
+
return matchSegment(head, candidateSegments[0]) && matchSegments(tail, candidateSegments.slice(1));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function matchSegment(pattern, candidate) {
|
|
98
|
+
const regex = new RegExp(`^${escapeRegex(pattern).replaceAll("\\*", "[^/]*").replaceAll("\\?", "[^/]")}$`);
|
|
99
|
+
return regex.test(candidate);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function splitGlob(value) {
|
|
103
|
+
return String(value).split("/").filter(Boolean);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function toPosix(value) {
|
|
107
|
+
return String(value).replaceAll("\\", "/");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function escapeRegex(value) {
|
|
111
|
+
return String(value).replace(/[|\\{}()[\]^$+*?.]/g, "\\$&");
|
|
112
|
+
}
|