@armadra/agent 0.0.0-stage → 0.2.1
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/CHANGELOG.md +22 -0
- package/LICENSE +21 -0
- package/README.md +426 -2
- package/dist/agent/agent.d.ts +56 -0
- package/dist/agent/agent.js +129 -0
- package/dist/agent/loop.d.ts +51 -0
- package/dist/agent/loop.js +158 -0
- package/dist/agent/queue.d.ts +25 -0
- package/dist/agent/queue.js +55 -0
- package/dist/agent/retry.d.ts +22 -0
- package/dist/agent/retry.js +92 -0
- package/dist/agent/schema.d.ts +27 -0
- package/dist/agent/schema.js +186 -0
- package/dist/agent/session-cache.d.ts +100 -0
- package/dist/agent/session-cache.js +511 -0
- package/dist/agent/session-compaction.d.ts +49 -0
- package/dist/agent/session-compaction.js +251 -0
- package/dist/agent/session-core.d.ts +103 -0
- package/dist/agent/session-core.js +7 -0
- package/dist/agent/session-run.d.ts +55 -0
- package/dist/agent/session-run.js +246 -0
- package/dist/agent/session-state.d.ts +39 -0
- package/dist/agent/session-state.js +118 -0
- package/dist/agent/session-subagent.d.ts +54 -0
- package/dist/agent/session-subagent.js +157 -0
- package/dist/agent/session-sync.d.ts +20 -0
- package/dist/agent/session-sync.js +104 -0
- package/dist/agent/session-tools.d.ts +24 -0
- package/dist/agent/session-tools.js +277 -0
- package/dist/agent/session.d.ts +117 -0
- package/dist/agent/session.js +484 -0
- package/dist/agent/system-prompt.d.ts +49 -0
- package/dist/agent/system-prompt.js +121 -0
- package/dist/agent/tool-runner.d.ts +64 -0
- package/dist/agent/tool-runner.js +309 -0
- package/dist/agent/transform.d.ts +26 -0
- package/dist/agent/transform.js +171 -0
- package/dist/agent/types.d.ts +378 -0
- package/dist/agent/types.js +21 -0
- package/dist/ai/apis/anthropic-messages.d.ts +26 -0
- package/dist/ai/apis/anthropic-messages.js +214 -0
- package/dist/ai/apis/anthropic-request.d.ts +42 -0
- package/dist/ai/apis/anthropic-request.js +282 -0
- package/dist/ai/apis/api.d.ts +28 -0
- package/dist/ai/apis/api.js +102 -0
- package/dist/ai/apis/cache-params.d.ts +48 -0
- package/dist/ai/apis/cache-params.js +163 -0
- package/dist/ai/apis/google-generative-ai.d.ts +32 -0
- package/dist/ai/apis/google-generative-ai.js +249 -0
- package/dist/ai/apis/google-request.d.ts +40 -0
- package/dist/ai/apis/google-request.js +255 -0
- package/dist/ai/apis/openai-compat.d.ts +35 -0
- package/dist/ai/apis/openai-compat.js +142 -0
- package/dist/ai/apis/openai-completions.d.ts +28 -0
- package/dist/ai/apis/openai-completions.js +246 -0
- package/dist/ai/apis/openai-request.d.ts +35 -0
- package/dist/ai/apis/openai-request.js +303 -0
- package/dist/ai/apis/openai-responses-request.d.ts +52 -0
- package/dist/ai/apis/openai-responses-request.js +266 -0
- package/dist/ai/apis/openai-responses.d.ts +28 -0
- package/dist/ai/apis/openai-responses.js +303 -0
- package/dist/ai/apis/shared.d.ts +56 -0
- package/dist/ai/apis/shared.js +179 -0
- package/dist/ai/cache/economics.d.ts +24 -0
- package/dist/ai/cache/economics.js +69 -0
- package/dist/ai/cache/fingerprint.d.ts +17 -0
- package/dist/ai/cache/fingerprint.js +39 -0
- package/dist/ai/cache/miss.d.ts +49 -0
- package/dist/ai/cache/miss.js +96 -0
- package/dist/ai/cache/reporting.d.ts +39 -0
- package/dist/ai/cache/reporting.js +102 -0
- package/dist/ai/cache/types.d.ts +94 -0
- package/dist/ai/cache/types.js +19 -0
- package/dist/ai/cache/warmer.d.ts +94 -0
- package/dist/ai/cache/warmer.js +224 -0
- package/dist/ai/context.d.ts +35 -0
- package/dist/ai/context.js +114 -0
- package/dist/ai/cost.d.ts +21 -0
- package/dist/ai/cost.js +60 -0
- package/dist/ai/event-stream.d.ts +33 -0
- package/dist/ai/event-stream.js +101 -0
- package/dist/ai/fake/fake-provider.d.ts +68 -0
- package/dist/ai/fake/fake-provider.js +278 -0
- package/dist/ai/fake/fake-script.d.ts +69 -0
- package/dist/ai/fake/fake-script.js +142 -0
- package/dist/ai/http.d.ts +54 -0
- package/dist/ai/http.js +212 -0
- package/dist/ai/json-partial.d.ts +17 -0
- package/dist/ai/json-partial.js +243 -0
- package/dist/ai/overflow.d.ts +22 -0
- package/dist/ai/overflow.js +61 -0
- package/dist/ai/providers/auth.d.ts +71 -0
- package/dist/ai/providers/auth.js +202 -0
- package/dist/ai/providers/builtin.d.ts +23 -0
- package/dist/ai/providers/builtin.js +148 -0
- package/dist/ai/providers/catalog-data.d.ts +5 -0
- package/dist/ai/providers/catalog-data.js +19 -0
- package/dist/ai/providers/catalog.d.ts +28 -0
- package/dist/ai/providers/catalog.js +166 -0
- package/dist/ai/providers/registry.d.ts +83 -0
- package/dist/ai/providers/registry.js +305 -0
- package/dist/ai/sse.d.ts +32 -0
- package/dist/ai/sse.js +140 -0
- package/dist/ai/thinking.d.ts +42 -0
- package/dist/ai/thinking.js +101 -0
- package/dist/ai/types.d.ts +428 -0
- package/dist/ai/types.js +19 -0
- package/dist/bundle/ama-sandbox.cjs +324 -0
- package/dist/bundle/ama.cjs +25770 -0
- package/dist/bundle.d.ts +9 -0
- package/dist/bundle.js +15 -0
- package/dist/cli/args.d.ts +82 -0
- package/dist/cli/args.js +381 -0
- package/dist/cli/bootstrap.d.ts +18 -0
- package/dist/cli/bootstrap.js +398 -0
- package/dist/cli/compose-providers.d.ts +37 -0
- package/dist/cli/compose-providers.js +104 -0
- package/dist/cli/compose-session.d.ts +69 -0
- package/dist/cli/compose-session.js +370 -0
- package/dist/cli/compose-store.d.ts +24 -0
- package/dist/cli/compose-store.js +125 -0
- package/dist/cli/compose.d.ts +106 -0
- package/dist/cli/compose.js +206 -0
- package/dist/cli/default-model.d.ts +16 -0
- package/dist/cli/default-model.js +24 -0
- package/dist/cli/deps.d.ts +205 -0
- package/dist/cli/deps.js +12 -0
- package/dist/cli/exit-codes.d.ts +29 -0
- package/dist/cli/exit-codes.js +40 -0
- package/dist/cli/main.d.ts +26 -0
- package/dist/cli/main.js +165 -0
- package/dist/cli/runtime.d.ts +90 -0
- package/dist/cli/runtime.js +8 -0
- package/dist/cli/startup-screen.d.ts +15 -0
- package/dist/cli/startup-screen.js +56 -0
- package/dist/cli/startup-steps.d.ts +35 -0
- package/dist/cli/startup-steps.js +197 -0
- package/dist/cli/subcommands/auth.d.ts +12 -0
- package/dist/cli/subcommands/auth.js +91 -0
- package/dist/cli/subcommands/config.d.ts +44 -0
- package/dist/cli/subcommands/config.js +201 -0
- package/dist/cli/subcommands/context.d.ts +21 -0
- package/dist/cli/subcommands/context.js +41 -0
- package/dist/cli/subcommands/doctor.d.ts +11 -0
- package/dist/cli/subcommands/doctor.js +246 -0
- package/dist/cli/subcommands/models-cache-probe.d.ts +34 -0
- package/dist/cli/subcommands/models-cache-probe.js +190 -0
- package/dist/cli/subcommands/models-discover.d.ts +40 -0
- package/dist/cli/subcommands/models-discover.js +204 -0
- package/dist/cli/subcommands/models.d.ts +40 -0
- package/dist/cli/subcommands/models.js +144 -0
- package/dist/cli/subcommands/sessions.d.ts +11 -0
- package/dist/cli/subcommands/sessions.js +100 -0
- package/dist/codemode/capability.d.ts +37 -0
- package/dist/codemode/capability.js +55 -0
- package/dist/codemode/declarations.d.ts +44 -0
- package/dist/codemode/declarations.js +111 -0
- package/dist/codemode/host-side.d.ts +59 -0
- package/dist/codemode/host-side.js +253 -0
- package/dist/codemode/modes.d.ts +23 -0
- package/dist/codemode/modes.js +40 -0
- package/dist/codemode/protocol.d.ts +91 -0
- package/dist/codemode/protocol.js +114 -0
- package/dist/codemode/sandbox-entry.d.ts +25 -0
- package/dist/codemode/sandbox-entry.js +323 -0
- package/dist/codemode/store.d.ts +26 -0
- package/dist/codemode/store.js +53 -0
- package/dist/codemode/tool.d.ts +105 -0
- package/dist/codemode/tool.js +283 -0
- package/dist/compaction/branch-summary.d.ts +30 -0
- package/dist/compaction/branch-summary.js +79 -0
- package/dist/compaction/breaker.d.ts +32 -0
- package/dist/compaction/breaker.js +65 -0
- package/dist/compaction/cut-point.d.ts +23 -0
- package/dist/compaction/cut-point.js +70 -0
- package/dist/compaction/estimate.d.ts +30 -0
- package/dist/compaction/estimate.js +114 -0
- package/dist/compaction/prune-tier.d.ts +31 -0
- package/dist/compaction/prune-tier.js +83 -0
- package/dist/compaction/serialize.d.ts +20 -0
- package/dist/compaction/serialize.js +115 -0
- package/dist/compaction/summarize-tier.d.ts +111 -0
- package/dist/compaction/summarize-tier.js +305 -0
- package/dist/config/auth-file.d.ts +39 -0
- package/dist/config/auth-file.js +92 -0
- package/dist/config/context-files.d.ts +30 -0
- package/dist/config/context-files.js +77 -0
- package/dist/config/load.d.ts +45 -0
- package/dist/config/load.js +97 -0
- package/dist/config/merge.d.ts +73 -0
- package/dist/config/merge.js +248 -0
- package/dist/config/paths.d.ts +38 -0
- package/dist/config/paths.js +108 -0
- package/dist/config/profile.d.ts +27 -0
- package/dist/config/profile.js +65 -0
- package/dist/config/schema.d.ts +34 -0
- package/dist/config/schema.js +413 -0
- package/dist/config/trust.d.ts +42 -0
- package/dist/config/trust.js +122 -0
- package/dist/config/types.d.ts +172 -0
- package/dist/config/types.js +27 -0
- package/dist/config/write.d.ts +10 -0
- package/dist/config/write.js +25 -0
- package/dist/errors.d.ts +28 -0
- package/dist/errors.js +30 -0
- package/dist/hooks/config.d.ts +49 -0
- package/dist/hooks/config.js +102 -0
- package/dist/hooks/dispatcher.d.ts +41 -0
- package/dist/hooks/dispatcher.js +104 -0
- package/dist/hooks/matcher.d.ts +22 -0
- package/dist/hooks/matcher.js +108 -0
- package/dist/hooks/protocol.d.ts +33 -0
- package/dist/hooks/protocol.js +240 -0
- package/dist/hooks/runner.d.ts +30 -0
- package/dist/hooks/runner.js +143 -0
- package/dist/hooks/types.d.ts +147 -0
- package/dist/hooks/types.js +20 -0
- package/dist/host/api-impl.d.ts +75 -0
- package/dist/host/api-impl.js +173 -0
- package/dist/host/loader.d.ts +31 -0
- package/dist/host/loader.js +147 -0
- package/dist/host/types.d.ts +169 -0
- package/dist/host/types.js +14 -0
- package/dist/host.d.ts +5 -0
- package/dist/host.js +4 -0
- package/dist/index.d.ts +41 -0
- package/dist/index.js +28 -0
- package/dist/modes/commands-core.d.ts +44 -0
- package/dist/modes/commands-core.js +171 -0
- package/dist/modes/interactive/approval-dialog.d.ts +48 -0
- package/dist/modes/interactive/approval-dialog.js +202 -0
- package/dist/modes/interactive/commands.d.ts +41 -0
- package/dist/modes/interactive/commands.js +202 -0
- package/dist/modes/interactive/completion.d.ts +53 -0
- package/dist/modes/interactive/completion.js +157 -0
- package/dist/modes/interactive/interactive-mode.d.ts +46 -0
- package/dist/modes/interactive/interactive-mode.js +487 -0
- package/dist/modes/interactive/key-dispatch.d.ts +29 -0
- package/dist/modes/interactive/key-dispatch.js +107 -0
- package/dist/modes/interactive/line/line-editor.d.ts +50 -0
- package/dist/modes/interactive/line/line-editor.js +232 -0
- package/dist/modes/interactive/line/line-mode.d.ts +20 -0
- package/dist/modes/interactive/line/line-mode.js +216 -0
- package/dist/modes/interactive/line/line-render.d.ts +35 -0
- package/dist/modes/interactive/line/line-render.js +171 -0
- package/dist/modes/interactive/line/paste-state.d.ts +26 -0
- package/dist/modes/interactive/line/paste-state.js +72 -0
- package/dist/modes/interactive/message-view.d.ts +77 -0
- package/dist/modes/interactive/message-view.js +269 -0
- package/dist/modes/interactive/pickers.d.ts +42 -0
- package/dist/modes/interactive/pickers.js +115 -0
- package/dist/modes/interactive/startup-ui.d.ts +28 -0
- package/dist/modes/interactive/startup-ui.js +238 -0
- package/dist/modes/interactive/status-bar.d.ts +48 -0
- package/dist/modes/interactive/status-bar.js +139 -0
- package/dist/modes/interactive/tool-view.d.ts +95 -0
- package/dist/modes/interactive/tool-view.js +344 -0
- package/dist/modes/print/json-event.d.ts +15 -0
- package/dist/modes/print/json-event.js +39 -0
- package/dist/modes/print/print-mode.d.ts +15 -0
- package/dist/modes/print/print-mode.js +96 -0
- package/dist/modes/rpc/commands.d.ts +40 -0
- package/dist/modes/rpc/commands.js +258 -0
- package/dist/modes/rpc/jsonl.d.ts +16 -0
- package/dist/modes/rpc/jsonl.js +55 -0
- package/dist/modes/rpc/rpc-mode.d.ts +22 -0
- package/dist/modes/rpc/rpc-mode.js +153 -0
- package/dist/modes/session-report.d.ts +55 -0
- package/dist/modes/session-report.js +278 -0
- package/dist/modes/shared.d.ts +13 -0
- package/dist/modes/shared.js +52 -0
- package/dist/modes/startup-ui-text.d.ts +16 -0
- package/dist/modes/startup-ui-text.js +103 -0
- package/dist/permissions/broker.d.ts +47 -0
- package/dist/permissions/broker.js +107 -0
- package/dist/permissions/dangerous.d.ts +47 -0
- package/dist/permissions/dangerous.js +370 -0
- package/dist/permissions/pipeline.d.ts +51 -0
- package/dist/permissions/pipeline.js +181 -0
- package/dist/permissions/preview.d.ts +43 -0
- package/dist/permissions/preview.js +463 -0
- package/dist/permissions/rules.d.ts +58 -0
- package/dist/permissions/rules.js +249 -0
- package/dist/permissions/types.d.ts +100 -0
- package/dist/permissions/types.js +18 -0
- package/dist/rpc.d.ts +171 -0
- package/dist/rpc.js +13 -0
- package/dist/sdk.d.ts +98 -0
- package/dist/sdk.js +195 -0
- package/dist/session/manager.d.ts +70 -0
- package/dist/session/manager.js +278 -0
- package/dist/session/migrate.d.ts +19 -0
- package/dist/session/migrate.js +53 -0
- package/dist/session/projection.d.ts +50 -0
- package/dist/session/projection.js +180 -0
- package/dist/session/store.d.ts +47 -0
- package/dist/session/store.js +210 -0
- package/dist/session/tree.d.ts +21 -0
- package/dist/session/tree.js +88 -0
- package/dist/session/types.d.ts +199 -0
- package/dist/session/types.js +18 -0
- package/dist/skills/discover.d.ts +60 -0
- package/dist/skills/discover.js +160 -0
- package/dist/skills/expand.d.ts +35 -0
- package/dist/skills/expand.js +46 -0
- package/dist/skills/frontmatter.d.ts +27 -0
- package/dist/skills/frontmatter.js +157 -0
- package/dist/skills/index-prompt.d.ts +13 -0
- package/dist/skills/index-prompt.js +42 -0
- package/dist/skills/templates.d.ts +52 -0
- package/dist/skills/templates.js +177 -0
- package/dist/tools/bash.d.ts +49 -0
- package/dist/tools/bash.js +222 -0
- package/dist/tools/edit-fuzzy.d.ts +36 -0
- package/dist/tools/edit-fuzzy.js +129 -0
- package/dist/tools/edit.d.ts +38 -0
- package/dist/tools/edit.js +241 -0
- package/dist/tools/file-mutex.d.ts +8 -0
- package/dist/tools/file-mutex.js +29 -0
- package/dist/tools/glob.d.ts +44 -0
- package/dist/tools/glob.js +217 -0
- package/dist/tools/grep.d.ts +39 -0
- package/dist/tools/grep.js +179 -0
- package/dist/tools/ignore.d.ts +40 -0
- package/dist/tools/ignore.js +174 -0
- package/dist/tools/ls.d.ts +21 -0
- package/dist/tools/ls.js +96 -0
- package/dist/tools/output-accumulator.d.ts +48 -0
- package/dist/tools/output-accumulator.js +106 -0
- package/dist/tools/paths.d.ts +20 -0
- package/dist/tools/paths.js +58 -0
- package/dist/tools/presets.d.ts +71 -0
- package/dist/tools/presets.js +128 -0
- package/dist/tools/process-tree.d.ts +35 -0
- package/dist/tools/process-tree.js +125 -0
- package/dist/tools/read.d.ts +28 -0
- package/dist/tools/read.js +181 -0
- package/dist/tools/registry.d.ts +43 -0
- package/dist/tools/registry.js +106 -0
- package/dist/tools/shell.d.ts +38 -0
- package/dist/tools/shell.js +91 -0
- package/dist/tools/task.d.ts +29 -0
- package/dist/tools/task.js +107 -0
- package/dist/tools/todo.d.ts +28 -0
- package/dist/tools/todo.js +107 -0
- package/dist/tools/truncate.d.ts +44 -0
- package/dist/tools/truncate.js +175 -0
- package/dist/tools/types.d.ts +135 -0
- package/dist/tools/types.js +19 -0
- package/dist/tools/write.d.ts +20 -0
- package/dist/tools/write.js +84 -0
- package/dist/tui/ansi.d.ts +29 -0
- package/dist/tui/ansi.js +420 -0
- package/dist/tui/component.d.ts +38 -0
- package/dist/tui/component.js +13 -0
- package/dist/tui/components/box.d.ts +26 -0
- package/dist/tui/components/box.js +65 -0
- package/dist/tui/components/container.d.ts +14 -0
- package/dist/tui/components/container.js +39 -0
- package/dist/tui/components/editor-buffer.d.ts +67 -0
- package/dist/tui/components/editor-buffer.js +300 -0
- package/dist/tui/components/editor-paste.d.ts +29 -0
- package/dist/tui/components/editor-paste.js +64 -0
- package/dist/tui/components/editor.d.ts +114 -0
- package/dist/tui/components/editor.js +530 -0
- package/dist/tui/components/key-value.d.ts +29 -0
- package/dist/tui/components/key-value.js +48 -0
- package/dist/tui/components/loader.d.ts +35 -0
- package/dist/tui/components/loader.js +71 -0
- package/dist/tui/components/markdown.d.ts +90 -0
- package/dist/tui/components/markdown.js +462 -0
- package/dist/tui/components/meter.d.ts +29 -0
- package/dist/tui/components/meter.js +47 -0
- package/dist/tui/components/overlay.d.ts +24 -0
- package/dist/tui/components/overlay.js +52 -0
- package/dist/tui/components/select-list.d.ts +57 -0
- package/dist/tui/components/select-list.js +179 -0
- package/dist/tui/components/spacer.d.ts +11 -0
- package/dist/tui/components/spacer.js +16 -0
- package/dist/tui/components/text.d.ts +30 -0
- package/dist/tui/components/text.js +65 -0
- package/dist/tui/keybindings.d.ts +66 -0
- package/dist/tui/keybindings.js +120 -0
- package/dist/tui/keys.d.ts +38 -0
- package/dist/tui/keys.js +275 -0
- package/dist/tui/stdin-buffer.d.ts +41 -0
- package/dist/tui/stdin-buffer.js +206 -0
- package/dist/tui/terminal.d.ts +79 -0
- package/dist/tui/terminal.js +153 -0
- package/dist/tui/theme.d.ts +31 -0
- package/dist/tui/theme.js +199 -0
- package/dist/tui/tui.d.ts +103 -0
- package/dist/tui/tui.js +363 -0
- package/dist/tui/vt-screen.d.ts +46 -0
- package/dist/tui/vt-screen.js +251 -0
- package/dist/tui.d.ts +30 -0
- package/dist/tui.js +31 -0
- package/dist/version.d.ts +8 -0
- package/dist/version.js +24 -0
- package/docs/codemode.md +102 -0
- package/docs/hooks.md +196 -0
- package/docs/host-api.md +161 -0
- package/docs/providers.md +316 -0
- package/docs/rpc.md +253 -0
- package/docs/session-format.md +119 -0
- package/docs/tui.md +168 -0
- package/package.json +101 -4
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `bash` 工具(设计 §5.2、§4.2)。[B3]
|
|
3
|
+
*
|
|
4
|
+
* - `spawn(shell, args, { detached: !win, stdio: [ignore, pipe, pipe] })`,stdout / stderr 合流;
|
|
5
|
+
* - 超时(缺省 120 s,上限 600 s)与 abort 都走 `killProcessTree`:SIGTERM → 2 s → SIGKILL;
|
|
6
|
+
* - 滚动尾部节流后经 `ctx.onUpdate` 流出;超限(2000 行 / 50 KB)尾截断并落全文;
|
|
7
|
+
* - 结构化结果 `{ output, exit_code, truncated, full_output_path?, wall_time_seconds }`;
|
|
8
|
+
* 信号退出 `128 + signo`;非零退出码 → isError;
|
|
9
|
+
* - 注入 `AMA_*` 环境变量(shell.ts)。shell 退出后不等仍占着管道的后台进程。
|
|
10
|
+
*/
|
|
11
|
+
import { spawn } from "node:child_process";
|
|
12
|
+
import { stat } from "node:fs/promises";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { resolvePath } from "./paths.js";
|
|
15
|
+
import { buildShellArgs, buildToolEnv, resolveShell } from "./shell.js";
|
|
16
|
+
import { exitCodeOf, isGroupAlive, killProcessTree, trackProcessGroup, untrackProcessGroup, } from "./process-tree.js";
|
|
17
|
+
import { OutputAccumulator } from "./output-accumulator.js";
|
|
18
|
+
import { formatSize, resolveOutputDir, safeFileName } from "./truncate.js";
|
|
19
|
+
export const DEFAULT_BASH_TIMEOUT_MS = 120_000;
|
|
20
|
+
export const MAX_BASH_TIMEOUT_MS = 600_000;
|
|
21
|
+
const UPDATE_THROTTLE_MS = 100;
|
|
22
|
+
/** shell 退出后等管道关闭的上限(后台子进程可能一直占着)。 */
|
|
23
|
+
const STDIO_DRAIN_MS = 200;
|
|
24
|
+
function fail(message) {
|
|
25
|
+
return { content: message, isError: true };
|
|
26
|
+
}
|
|
27
|
+
/** 等 shell 退出;之后最多再等 STDIO_DRAIN_MS 让管道读完,再强制关闭。 */
|
|
28
|
+
function waitForExit(child) {
|
|
29
|
+
return new Promise((resolve, reject) => {
|
|
30
|
+
let exit;
|
|
31
|
+
let settled = false;
|
|
32
|
+
const done = () => {
|
|
33
|
+
if (settled || exit === undefined)
|
|
34
|
+
return;
|
|
35
|
+
settled = true;
|
|
36
|
+
resolve(exit);
|
|
37
|
+
};
|
|
38
|
+
child.once("error", (err) => {
|
|
39
|
+
if (!settled) {
|
|
40
|
+
settled = true;
|
|
41
|
+
reject(err);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
child.once("close", (code, signal) => {
|
|
45
|
+
exit ??= { code, signal };
|
|
46
|
+
done();
|
|
47
|
+
});
|
|
48
|
+
child.once("exit", (code, signal) => {
|
|
49
|
+
exit = { code, signal };
|
|
50
|
+
setTimeout(() => {
|
|
51
|
+
child.stdout?.destroy();
|
|
52
|
+
child.stderr?.destroy();
|
|
53
|
+
done();
|
|
54
|
+
}, STDIO_DRAIN_MS).unref();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
export async function executeBash(input, ctx, options = {}) {
|
|
59
|
+
if (typeof input.command !== "string" || input.command.trim() === "") {
|
|
60
|
+
return fail("command must be a non-empty string");
|
|
61
|
+
}
|
|
62
|
+
const timeoutMs = input.timeoutMs ?? options.defaultTimeoutMs ?? DEFAULT_BASH_TIMEOUT_MS;
|
|
63
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > MAX_BASH_TIMEOUT_MS) {
|
|
64
|
+
return fail(`timeoutMs must be between 1 and ${MAX_BASH_TIMEOUT_MS}`);
|
|
65
|
+
}
|
|
66
|
+
const cwd = resolvePath(input.cwd ?? ".", ctx.cwd);
|
|
67
|
+
try {
|
|
68
|
+
if (!(await stat(cwd)).isDirectory())
|
|
69
|
+
return fail(`Working directory is not a directory: ${cwd}`);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return fail(`Working directory does not exist: ${cwd}`);
|
|
73
|
+
}
|
|
74
|
+
if (ctx.signal.aborted)
|
|
75
|
+
return fail("aborted by user");
|
|
76
|
+
const shell = options.shell ? options.shell() : resolveShell();
|
|
77
|
+
const env = buildToolEnv(options.baseEnv ? options.baseEnv() : process.env, {
|
|
78
|
+
sessionId: ctx.sessionId,
|
|
79
|
+
sessionFile: ctx.sessionFile,
|
|
80
|
+
provider: ctx.model?.provider,
|
|
81
|
+
model: ctx.model?.id,
|
|
82
|
+
thinkingLevel: ctx.thinkingLevel,
|
|
83
|
+
depth: ctx.depth,
|
|
84
|
+
});
|
|
85
|
+
const isWindows = (options.processDeps?.platform ?? process.platform) === "win32";
|
|
86
|
+
const started = Date.now();
|
|
87
|
+
const acc = new OutputAccumulator({
|
|
88
|
+
spillPath: () => join(resolveOutputDir(ctx.outputDir), safeFileName(`bash-${ctx.toolCallId}.log`)),
|
|
89
|
+
});
|
|
90
|
+
let child;
|
|
91
|
+
try {
|
|
92
|
+
child = spawn(shell.shell, buildShellArgs(shell, input.command), {
|
|
93
|
+
cwd,
|
|
94
|
+
env,
|
|
95
|
+
detached: !isWindows,
|
|
96
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
97
|
+
windowsHide: true,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
return fail(`Failed to start shell ${shell.shell}: ${err.message}`);
|
|
102
|
+
}
|
|
103
|
+
const pid = child.pid;
|
|
104
|
+
if (pid !== undefined && !isWindows)
|
|
105
|
+
trackProcessGroup(pid);
|
|
106
|
+
let lastUpdate = 0;
|
|
107
|
+
let pendingUpdate;
|
|
108
|
+
const emitUpdate = () => {
|
|
109
|
+
pendingUpdate = undefined;
|
|
110
|
+
lastUpdate = Date.now();
|
|
111
|
+
ctx.onUpdate(acc.tail());
|
|
112
|
+
};
|
|
113
|
+
const onData = (chunk) => {
|
|
114
|
+
acc.append(chunk);
|
|
115
|
+
if (pendingUpdate)
|
|
116
|
+
return;
|
|
117
|
+
const wait = Math.max(0, lastUpdate + UPDATE_THROTTLE_MS - Date.now());
|
|
118
|
+
pendingUpdate = setTimeout(emitUpdate, wait);
|
|
119
|
+
};
|
|
120
|
+
child.stdout?.on("data", onData);
|
|
121
|
+
child.stderr?.on("data", onData);
|
|
122
|
+
let timedOut = false;
|
|
123
|
+
let aborted = false;
|
|
124
|
+
let killing;
|
|
125
|
+
const kill = () => {
|
|
126
|
+
if (pid === undefined)
|
|
127
|
+
return;
|
|
128
|
+
killing ??= killProcessTree(pid, options.processDeps);
|
|
129
|
+
};
|
|
130
|
+
const timer = setTimeout(() => {
|
|
131
|
+
timedOut = true;
|
|
132
|
+
kill();
|
|
133
|
+
}, timeoutMs);
|
|
134
|
+
const onAbort = () => {
|
|
135
|
+
aborted = true;
|
|
136
|
+
kill();
|
|
137
|
+
};
|
|
138
|
+
ctx.signal.addEventListener("abort", onAbort, { once: true });
|
|
139
|
+
let exit;
|
|
140
|
+
try {
|
|
141
|
+
exit = await waitForExit(child);
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
return fail(`Failed to run command with ${shell.shell}: ${err.message}`);
|
|
145
|
+
}
|
|
146
|
+
finally {
|
|
147
|
+
clearTimeout(timer);
|
|
148
|
+
ctx.signal.removeEventListener("abort", onAbort);
|
|
149
|
+
if (pendingUpdate) {
|
|
150
|
+
// 冲掉节流中的最后一次更新,保证 UI 看到的尾部是完整的。
|
|
151
|
+
clearTimeout(pendingUpdate);
|
|
152
|
+
emitUpdate();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const killedWithSigkill = killing ? await killing : false;
|
|
156
|
+
// 后台子进程仍在组里时保持登记,进程退出时一并清理。
|
|
157
|
+
if (pid !== undefined && !isWindows && !isGroupAlive(pid))
|
|
158
|
+
untrackProcessGroup(pid);
|
|
159
|
+
const out = acc.finish();
|
|
160
|
+
const exitCode = exitCodeOf(exit.code, exit.signal);
|
|
161
|
+
const wall = Math.round((Date.now() - started) / 10) / 100;
|
|
162
|
+
const structured = {
|
|
163
|
+
output: out.output,
|
|
164
|
+
exit_code: exitCode,
|
|
165
|
+
truncated: out.truncated,
|
|
166
|
+
...(out.fullOutputPath !== undefined ? { full_output_path: out.fullOutputPath } : {}),
|
|
167
|
+
wall_time_seconds: wall,
|
|
168
|
+
};
|
|
169
|
+
const details = {
|
|
170
|
+
...structured,
|
|
171
|
+
timedOut,
|
|
172
|
+
aborted,
|
|
173
|
+
killedWithSigkill,
|
|
174
|
+
totalLines: out.totalLines,
|
|
175
|
+
totalBytes: out.totalBytes,
|
|
176
|
+
cwd,
|
|
177
|
+
...(input.description !== undefined ? { description: input.description } : {}),
|
|
178
|
+
};
|
|
179
|
+
const parts = [out.output === "" ? "(no output)" : out.output];
|
|
180
|
+
if (out.truncated) {
|
|
181
|
+
const where = out.fullOutputPath ? ` Full output: ${out.fullOutputPath}` : "";
|
|
182
|
+
parts.push(`[Output truncated: showing the last ${out.outputLines} of ${out.totalLines} lines ` +
|
|
183
|
+
`(${formatSize(out.totalBytes)} total).${where}]`);
|
|
184
|
+
}
|
|
185
|
+
if (aborted)
|
|
186
|
+
parts.push("aborted by user");
|
|
187
|
+
else if (timedOut)
|
|
188
|
+
parts.push(`[Command timed out after ${timeoutMs} ms and was killed]`);
|
|
189
|
+
if (exitCode !== 0)
|
|
190
|
+
parts.push(`[exit code: ${exitCode}]`);
|
|
191
|
+
return {
|
|
192
|
+
content: parts.join("\n\n"),
|
|
193
|
+
isError: exitCode !== 0 || timedOut || aborted,
|
|
194
|
+
structured,
|
|
195
|
+
details,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
export function createBashTool(options = {}) {
|
|
199
|
+
return {
|
|
200
|
+
name: "bash",
|
|
201
|
+
label: "Bash",
|
|
202
|
+
description: "Run a shell command. stdout+stderr combined; over 2000 lines or 50 KB keeps the tail " +
|
|
203
|
+
"and saves the full output to a file. " +
|
|
204
|
+
`Timeout default ${DEFAULT_BASH_TIMEOUT_MS} ms, max ${MAX_BASH_TIMEOUT_MS} ms.`,
|
|
205
|
+
parameters: {
|
|
206
|
+
type: "object",
|
|
207
|
+
properties: {
|
|
208
|
+
command: { type: "string" },
|
|
209
|
+
timeoutMs: { type: "integer" },
|
|
210
|
+
cwd: { type: "string", description: "Default: session cwd" },
|
|
211
|
+
description: { type: "string", description: "What this does, briefly" },
|
|
212
|
+
},
|
|
213
|
+
required: ["command"],
|
|
214
|
+
additionalProperties: false,
|
|
215
|
+
},
|
|
216
|
+
permission: "execute",
|
|
217
|
+
executionMode: "sequential",
|
|
218
|
+
annotations: { destructive: true, openWorld: true },
|
|
219
|
+
promptSnippet: "bash: run shell commands (AMA_* env vars describe the session)",
|
|
220
|
+
execute: (input, ctx) => executeBash(input, ctx, options),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 文本归一与模糊匹配(设计 §5.2 edit:先精确再模糊)。[B3]
|
|
3
|
+
*
|
|
4
|
+
* - BOM / 换行:`splitBom`、`detectLineEnding`、`normalizeToLF`、`restoreLineEndings`,write 与
|
|
5
|
+
* edit 共用,保证写回时保留原文件的 BOM 与 CRLF。
|
|
6
|
+
* - 模糊归一 `normalizeForFuzzy`:NFKC、去行尾空白、弯引号 / 各种破折号 / 特殊空格归 ASCII。
|
|
7
|
+
* 归一**不改变行数**,所以模糊匹配在归一空间算出的替换可以按行映射回原文:
|
|
8
|
+
* `applyPreservingLines()` 只重写被替换触及的行,其余行逐字节保留原文。
|
|
9
|
+
*/
|
|
10
|
+
export declare const BOM = "\uFEFF";
|
|
11
|
+
export declare function splitBom(text: string): {
|
|
12
|
+
bom: string;
|
|
13
|
+
text: string;
|
|
14
|
+
};
|
|
15
|
+
/** 以第一处换行为准;无换行视为 LF。 */
|
|
16
|
+
export declare function detectLineEnding(text: string): "\r\n" | "\n";
|
|
17
|
+
export declare function normalizeToLF(text: string): string;
|
|
18
|
+
export declare function restoreLineEndings(text: string, ending: "\r\n" | "\n"): string;
|
|
19
|
+
export declare function normalizeForFuzzy(text: string): string;
|
|
20
|
+
/** 不重叠的全部出现位置。 */
|
|
21
|
+
export declare function findAll(haystack: string, needle: string): number[];
|
|
22
|
+
/** 偏移量所在行(1 起)。 */
|
|
23
|
+
export declare function lineNumberAt(text: string, index: number): number;
|
|
24
|
+
export interface Replacement {
|
|
25
|
+
/** 在基准文本中的起点与长度。 */
|
|
26
|
+
index: number;
|
|
27
|
+
length: number;
|
|
28
|
+
newText: string;
|
|
29
|
+
}
|
|
30
|
+
/** 按起点倒序应用(调用方保证不重叠)。 */
|
|
31
|
+
export declare function applyReplacements(base: string, replacements: readonly Replacement[]): string;
|
|
32
|
+
/**
|
|
33
|
+
* `base` 是 `original` 的模糊归一视图(同行数)。替换在 base 上算出;被触及的行组从 base 改写,
|
|
34
|
+
* 其余行原样拷回 original,避免把整份文件的行尾空白、弯引号一并改掉。
|
|
35
|
+
*/
|
|
36
|
+
export declare function applyPreservingLines(original: string, base: string, replacements: readonly Replacement[]): string;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 文本归一与模糊匹配(设计 §5.2 edit:先精确再模糊)。[B3]
|
|
3
|
+
*
|
|
4
|
+
* - BOM / 换行:`splitBom`、`detectLineEnding`、`normalizeToLF`、`restoreLineEndings`,write 与
|
|
5
|
+
* edit 共用,保证写回时保留原文件的 BOM 与 CRLF。
|
|
6
|
+
* - 模糊归一 `normalizeForFuzzy`:NFKC、去行尾空白、弯引号 / 各种破折号 / 特殊空格归 ASCII。
|
|
7
|
+
* 归一**不改变行数**,所以模糊匹配在归一空间算出的替换可以按行映射回原文:
|
|
8
|
+
* `applyPreservingLines()` 只重写被替换触及的行,其余行逐字节保留原文。
|
|
9
|
+
*/
|
|
10
|
+
export const BOM = "\uFEFF";
|
|
11
|
+
export function splitBom(text) {
|
|
12
|
+
return text.startsWith(BOM) ? { bom: BOM, text: text.slice(1) } : { bom: "", text };
|
|
13
|
+
}
|
|
14
|
+
/** 以第一处换行为准;无换行视为 LF。 */
|
|
15
|
+
export function detectLineEnding(text) {
|
|
16
|
+
const lf = text.indexOf("\n");
|
|
17
|
+
if (lf === -1)
|
|
18
|
+
return "\n";
|
|
19
|
+
return lf > 0 && text[lf - 1] === "\r" ? "\r\n" : "\n";
|
|
20
|
+
}
|
|
21
|
+
export function normalizeToLF(text) {
|
|
22
|
+
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
23
|
+
}
|
|
24
|
+
export function restoreLineEndings(text, ending) {
|
|
25
|
+
return ending === "\r\n" ? text.replace(/\n/g, "\r\n") : text;
|
|
26
|
+
}
|
|
27
|
+
export function normalizeForFuzzy(text) {
|
|
28
|
+
return text
|
|
29
|
+
.normalize("NFKC")
|
|
30
|
+
.split("\n")
|
|
31
|
+
.map((line) => line.trimEnd())
|
|
32
|
+
.join("\n")
|
|
33
|
+
.replace(/[\u2018\u2019\u201A\u201B\u2032]/g, "'")
|
|
34
|
+
.replace(/[\u201C\u201D\u201E\u201F\u2033]/g, '"')
|
|
35
|
+
.replace(/[\u2010\u2011\u2012\u2013\u2014\u2015\u2212]/g, "-")
|
|
36
|
+
.replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/g, " ");
|
|
37
|
+
}
|
|
38
|
+
/** 不重叠的全部出现位置。 */
|
|
39
|
+
export function findAll(haystack, needle) {
|
|
40
|
+
const out = [];
|
|
41
|
+
if (needle === "")
|
|
42
|
+
return out;
|
|
43
|
+
let from = 0;
|
|
44
|
+
for (;;) {
|
|
45
|
+
const idx = haystack.indexOf(needle, from);
|
|
46
|
+
if (idx === -1)
|
|
47
|
+
return out;
|
|
48
|
+
out.push(idx);
|
|
49
|
+
from = idx + needle.length;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** 偏移量所在行(1 起)。 */
|
|
53
|
+
export function lineNumberAt(text, index) {
|
|
54
|
+
let line = 1;
|
|
55
|
+
for (let i = 0; i < index && i < text.length; i++)
|
|
56
|
+
if (text.charCodeAt(i) === 10)
|
|
57
|
+
line++;
|
|
58
|
+
return line;
|
|
59
|
+
}
|
|
60
|
+
/** 按起点倒序应用(调用方保证不重叠)。 */
|
|
61
|
+
export function applyReplacements(base, replacements) {
|
|
62
|
+
const sorted = [...replacements].sort((a, b) => b.index - a.index);
|
|
63
|
+
let out = base;
|
|
64
|
+
for (const r of sorted)
|
|
65
|
+
out = out.slice(0, r.index) + r.newText + out.slice(r.index + r.length);
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
function lineSpans(text) {
|
|
69
|
+
const spans = [];
|
|
70
|
+
let start = 0;
|
|
71
|
+
for (let i = 0; i < text.length; i++) {
|
|
72
|
+
if (text.charCodeAt(i) === 10) {
|
|
73
|
+
spans.push({ start, end: i + 1 });
|
|
74
|
+
start = i + 1;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (start < text.length || text.length === 0)
|
|
78
|
+
spans.push({ start, end: text.length });
|
|
79
|
+
return spans;
|
|
80
|
+
}
|
|
81
|
+
function lineIndexOf(spans, offset) {
|
|
82
|
+
for (let i = 0; i < spans.length; i++) {
|
|
83
|
+
const s = spans[i];
|
|
84
|
+
if (offset >= s.start && offset < s.end)
|
|
85
|
+
return i;
|
|
86
|
+
}
|
|
87
|
+
return spans.length - 1;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* `base` 是 `original` 的模糊归一视图(同行数)。替换在 base 上算出;被触及的行组从 base 改写,
|
|
91
|
+
* 其余行原样拷回 original,避免把整份文件的行尾空白、弯引号一并改掉。
|
|
92
|
+
*/
|
|
93
|
+
export function applyPreservingLines(original, base, replacements) {
|
|
94
|
+
const origSpans = lineSpans(original);
|
|
95
|
+
const baseSpans = lineSpans(base);
|
|
96
|
+
if (origSpans.length !== baseSpans.length)
|
|
97
|
+
return applyReplacements(base, replacements);
|
|
98
|
+
const groups = [];
|
|
99
|
+
for (const r of [...replacements].sort((a, b) => a.index - b.index)) {
|
|
100
|
+
const first = lineIndexOf(baseSpans, r.index);
|
|
101
|
+
const last = lineIndexOf(baseSpans, Math.max(r.index, r.index + r.length - 1));
|
|
102
|
+
const current = groups[groups.length - 1];
|
|
103
|
+
if (current && first <= current.last) {
|
|
104
|
+
current.last = Math.max(current.last, last);
|
|
105
|
+
current.items.push(r);
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
groups.push({ first, last, items: [r] });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
let out = "";
|
|
112
|
+
let nextLine = 0;
|
|
113
|
+
for (const g of groups) {
|
|
114
|
+
for (let i = nextLine; i < g.first; i++) {
|
|
115
|
+
const s = origSpans[i];
|
|
116
|
+
out += original.slice(s.start, s.end);
|
|
117
|
+
}
|
|
118
|
+
const gStart = baseSpans[g.first].start;
|
|
119
|
+
const gEnd = baseSpans[g.last].end;
|
|
120
|
+
const local = g.items.map((r) => ({ ...r, index: r.index - gStart }));
|
|
121
|
+
out += applyReplacements(base.slice(gStart, gEnd), local);
|
|
122
|
+
nextLine = g.last + 1;
|
|
123
|
+
}
|
|
124
|
+
for (let i = nextLine; i < origSpans.length; i++) {
|
|
125
|
+
const s = origSpans[i];
|
|
126
|
+
out += original.slice(s.start, s.end);
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `edit` 工具(设计 §5.2)。[B3]
|
|
3
|
+
*
|
|
4
|
+
* - 每处 `oldText` 都在**原文**上匹配,必须唯一(`replaceAll` 例外)且互不重叠;
|
|
5
|
+
* - 先精确匹配;任一处精确匹配失败则整批转入模糊空间(edit-fuzzy.ts),只改写被触及的行;
|
|
6
|
+
* - 不唯一 → 错误里给出现次数与首两处行号;
|
|
7
|
+
* - 保留 BOM 与 CRLF;要求先 read;`details.diff` 是统一 diff(给 TUI)。
|
|
8
|
+
*/
|
|
9
|
+
import type { ToolContext, ToolDefinition, ToolResult } from "./types.js";
|
|
10
|
+
export interface EditOp {
|
|
11
|
+
oldText: string;
|
|
12
|
+
newText: string;
|
|
13
|
+
}
|
|
14
|
+
export interface EditInput {
|
|
15
|
+
path: string;
|
|
16
|
+
edits: EditOp[];
|
|
17
|
+
replaceAll?: boolean;
|
|
18
|
+
}
|
|
19
|
+
export interface EditDetails {
|
|
20
|
+
path: string;
|
|
21
|
+
diff: string;
|
|
22
|
+
replacements: number;
|
|
23
|
+
fuzzy: boolean;
|
|
24
|
+
firstChangedLine: number;
|
|
25
|
+
}
|
|
26
|
+
export declare class EditError extends Error {
|
|
27
|
+
}
|
|
28
|
+
export interface EditPlan {
|
|
29
|
+
result: string;
|
|
30
|
+
replacements: number;
|
|
31
|
+
fuzzy: boolean;
|
|
32
|
+
}
|
|
33
|
+
/** 在 LF 归一后的内容上计算替换结果;失败抛 EditError。 */
|
|
34
|
+
export declare function planEdits(content: string, edits: readonly EditOp[], replaceAll?: boolean): EditPlan;
|
|
35
|
+
/** 统一 diff(LF 文本,3 行上下文);无差异返回空串。 */
|
|
36
|
+
export declare function unifiedDiff(oldText: string, newText: string, path: string, context?: number): string;
|
|
37
|
+
export declare function executeEdit(input: EditInput, ctx: ToolContext): Promise<ToolResult>;
|
|
38
|
+
export declare function createEditTool(): ToolDefinition<EditInput>;
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `edit` 工具(设计 §5.2)。[B3]
|
|
3
|
+
*
|
|
4
|
+
* - 每处 `oldText` 都在**原文**上匹配,必须唯一(`replaceAll` 例外)且互不重叠;
|
|
5
|
+
* - 先精确匹配;任一处精确匹配失败则整批转入模糊空间(edit-fuzzy.ts),只改写被触及的行;
|
|
6
|
+
* - 不唯一 → 错误里给出现次数与首两处行号;
|
|
7
|
+
* - 保留 BOM 与 CRLF;要求先 read;`details.diff` 是统一 diff(给 TUI)。
|
|
8
|
+
*/
|
|
9
|
+
import { readFile, stat, writeFile } from "node:fs/promises";
|
|
10
|
+
import { displayPath, resolvePath } from "./paths.js";
|
|
11
|
+
import { withFileMutex } from "./file-mutex.js";
|
|
12
|
+
import { applyPreservingLines, applyReplacements, detectLineEnding, findAll, lineNumberAt, normalizeForFuzzy, normalizeToLF, restoreLineEndings, splitBom, } from "./edit-fuzzy.js";
|
|
13
|
+
export class EditError extends Error {
|
|
14
|
+
}
|
|
15
|
+
function label(index, total) {
|
|
16
|
+
return total === 1 ? "oldText" : `edits[${index}].oldText`;
|
|
17
|
+
}
|
|
18
|
+
/** 在 LF 归一后的内容上计算替换结果;失败抛 EditError。 */
|
|
19
|
+
export function planEdits(content, edits, replaceAll = false) {
|
|
20
|
+
if (edits.length === 0)
|
|
21
|
+
throw new EditError("edits must contain at least one item");
|
|
22
|
+
const ops = edits.map((e, i) => {
|
|
23
|
+
if (typeof e.oldText !== "string" || typeof e.newText !== "string") {
|
|
24
|
+
throw new EditError(`edits[${i}] needs string oldText and newText`);
|
|
25
|
+
}
|
|
26
|
+
if (e.oldText === "")
|
|
27
|
+
throw new EditError(`${label(i, edits.length)} must not be empty`);
|
|
28
|
+
return { oldText: normalizeToLF(e.oldText), newText: normalizeToLF(e.newText) };
|
|
29
|
+
});
|
|
30
|
+
const fuzzy = ops.some((op) => !content.includes(op.oldText));
|
|
31
|
+
const base = fuzzy ? normalizeForFuzzy(content) : content;
|
|
32
|
+
const reps = [];
|
|
33
|
+
ops.forEach((op, i) => {
|
|
34
|
+
const needle = fuzzy ? normalizeForFuzzy(op.oldText) : op.oldText;
|
|
35
|
+
const hits = needle === "" ? [] : findAll(base, needle);
|
|
36
|
+
if (hits.length === 0) {
|
|
37
|
+
throw new EditError(`Could not find ${label(i, ops.length)} in the file. It must match the current content ` +
|
|
38
|
+
"exactly, including whitespace and line breaks.");
|
|
39
|
+
}
|
|
40
|
+
if (hits.length > 1 && !replaceAll) {
|
|
41
|
+
const lines = hits.slice(0, 2).map((h) => lineNumberAt(base, h));
|
|
42
|
+
throw new EditError(`${label(i, ops.length)} matches ${hits.length} times (first at lines ${lines.join(" and ")}). ` +
|
|
43
|
+
"Add surrounding context to make it unique, or set replaceAll.");
|
|
44
|
+
}
|
|
45
|
+
for (const index of replaceAll ? hits : hits.slice(0, 1)) {
|
|
46
|
+
reps.push({ index, length: needle.length, newText: op.newText, edit: i });
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
const sorted = [...reps].sort((a, b) => a.index - b.index);
|
|
50
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
51
|
+
const prev = sorted[i - 1];
|
|
52
|
+
const cur = sorted[i];
|
|
53
|
+
if (prev.index + prev.length > cur.index) {
|
|
54
|
+
throw new EditError(`edits[${prev.edit}] and edits[${cur.edit}] overlap; merge them into a single edit`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const result = fuzzy ? applyPreservingLines(content, base, reps) : applyReplacements(base, reps);
|
|
58
|
+
if (result === content)
|
|
59
|
+
throw new EditError("The edits produced no change to the file");
|
|
60
|
+
return { result, replacements: reps.length, fuzzy };
|
|
61
|
+
}
|
|
62
|
+
function diffOps(a, b) {
|
|
63
|
+
let pre = 0;
|
|
64
|
+
while (pre < a.length && pre < b.length && a[pre] === b[pre])
|
|
65
|
+
pre++;
|
|
66
|
+
let suf = 0;
|
|
67
|
+
while (suf < a.length - pre &&
|
|
68
|
+
suf < b.length - pre &&
|
|
69
|
+
a[a.length - 1 - suf] === b[b.length - 1 - suf]) {
|
|
70
|
+
suf++;
|
|
71
|
+
}
|
|
72
|
+
const ops = [];
|
|
73
|
+
for (let i = 0; i < pre; i++)
|
|
74
|
+
ops.push({ kind: " ", text: a[i], a: i, b: i });
|
|
75
|
+
const am = a.slice(pre, a.length - suf);
|
|
76
|
+
const bm = b.slice(pre, b.length - suf);
|
|
77
|
+
if (am.length * bm.length <= 4_000_000) {
|
|
78
|
+
// LCS 动态规划(中段通常很小)。
|
|
79
|
+
const w = bm.length + 1;
|
|
80
|
+
const dp = new Uint32Array((am.length + 1) * w);
|
|
81
|
+
for (let i = am.length - 1; i >= 0; i--) {
|
|
82
|
+
for (let j = bm.length - 1; j >= 0; j--) {
|
|
83
|
+
dp[i * w + j] =
|
|
84
|
+
am[i] === bm[j]
|
|
85
|
+
? (dp[(i + 1) * w + j + 1] ?? 0) + 1
|
|
86
|
+
: Math.max(dp[(i + 1) * w + j] ?? 0, dp[i * w + j + 1] ?? 0);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
let i = 0;
|
|
90
|
+
let j = 0;
|
|
91
|
+
while (i < am.length || j < bm.length) {
|
|
92
|
+
if (i < am.length && j < bm.length && am[i] === bm[j]) {
|
|
93
|
+
ops.push({ kind: " ", text: am[i], a: pre + i, b: pre + j });
|
|
94
|
+
i++;
|
|
95
|
+
j++;
|
|
96
|
+
}
|
|
97
|
+
else if (i < am.length &&
|
|
98
|
+
(j >= bm.length || (dp[(i + 1) * w + j] ?? 0) >= (dp[i * w + j + 1] ?? 0))) {
|
|
99
|
+
ops.push({ kind: "-", text: am[i], a: pre + i, b: pre + j });
|
|
100
|
+
i++;
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
ops.push({ kind: "+", text: bm[j], a: pre + i, b: pre + j });
|
|
104
|
+
j++;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
am.forEach((t, i) => ops.push({ kind: "-", text: t, a: pre + i, b: pre }));
|
|
110
|
+
bm.forEach((t, j) => ops.push({ kind: "+", text: t, a: pre + am.length, b: pre + j }));
|
|
111
|
+
}
|
|
112
|
+
for (let k = 0; k < suf; k++) {
|
|
113
|
+
const ai = a.length - suf + k;
|
|
114
|
+
ops.push({ kind: " ", text: a[ai], a: ai, b: b.length - suf + k });
|
|
115
|
+
}
|
|
116
|
+
return ops;
|
|
117
|
+
}
|
|
118
|
+
function toLines(text) {
|
|
119
|
+
if (text === "")
|
|
120
|
+
return [];
|
|
121
|
+
const lines = text.split("\n");
|
|
122
|
+
if (lines[lines.length - 1] === "")
|
|
123
|
+
lines.pop();
|
|
124
|
+
return lines;
|
|
125
|
+
}
|
|
126
|
+
/** 统一 diff(LF 文本,3 行上下文);无差异返回空串。 */
|
|
127
|
+
export function unifiedDiff(oldText, newText, path, context = 3) {
|
|
128
|
+
const ops = diffOps(toLines(oldText), toLines(newText));
|
|
129
|
+
const changed = ops.map((op, i) => (op.kind === " " ? -1 : i)).filter((i) => i >= 0);
|
|
130
|
+
if (changed.length === 0)
|
|
131
|
+
return "";
|
|
132
|
+
const hunks = [];
|
|
133
|
+
for (const idx of changed) {
|
|
134
|
+
const start = Math.max(0, idx - context);
|
|
135
|
+
const end = Math.min(ops.length, idx + context + 1);
|
|
136
|
+
const last = hunks[hunks.length - 1];
|
|
137
|
+
if (last && start <= last.end)
|
|
138
|
+
last.end = Math.max(last.end, end);
|
|
139
|
+
else
|
|
140
|
+
hunks.push({ start, end });
|
|
141
|
+
}
|
|
142
|
+
const out = [`--- a/${path}`, `+++ b/${path}`];
|
|
143
|
+
for (const h of hunks) {
|
|
144
|
+
const slice = ops.slice(h.start, h.end);
|
|
145
|
+
const first = slice[0];
|
|
146
|
+
const aCount = slice.filter((o) => o.kind !== "+").length;
|
|
147
|
+
const bCount = slice.filter((o) => o.kind !== "-").length;
|
|
148
|
+
const aStart = aCount === 0 ? first.a : first.a + 1;
|
|
149
|
+
const bStart = bCount === 0 ? first.b : first.b + 1;
|
|
150
|
+
out.push(`@@ -${aStart},${aCount} +${bStart},${bCount} @@`);
|
|
151
|
+
for (const o of slice)
|
|
152
|
+
out.push(`${o.kind}${o.text}`);
|
|
153
|
+
}
|
|
154
|
+
return out.join("\n");
|
|
155
|
+
}
|
|
156
|
+
function firstChangedLine(oldText, newText) {
|
|
157
|
+
const a = oldText.split("\n");
|
|
158
|
+
const b = newText.split("\n");
|
|
159
|
+
let i = 0;
|
|
160
|
+
while (i < a.length && i < b.length && a[i] === b[i])
|
|
161
|
+
i++;
|
|
162
|
+
return i + 1;
|
|
163
|
+
}
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// 工具
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
export async function executeEdit(input, ctx) {
|
|
168
|
+
const abs = resolvePath(input.path, ctx.cwd);
|
|
169
|
+
const shown = displayPath(abs, ctx.cwd);
|
|
170
|
+
return withFileMutex(abs, async () => {
|
|
171
|
+
try {
|
|
172
|
+
const info = await stat(abs);
|
|
173
|
+
if (!info.isFile())
|
|
174
|
+
return { content: `${shown} is not a regular file`, isError: true };
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return { content: `File not found: ${shown}`, isError: true };
|
|
178
|
+
}
|
|
179
|
+
if (!ctx.readFiles.has(abs)) {
|
|
180
|
+
return { content: `Read ${shown} with the read tool before editing it.`, isError: true };
|
|
181
|
+
}
|
|
182
|
+
const raw = await readFile(abs, "utf8");
|
|
183
|
+
const { bom, text } = splitBom(raw);
|
|
184
|
+
const ending = detectLineEnding(text);
|
|
185
|
+
const content = normalizeToLF(text);
|
|
186
|
+
let plan;
|
|
187
|
+
try {
|
|
188
|
+
plan = planEdits(content, input.edits ?? [], input.replaceAll === true);
|
|
189
|
+
}
|
|
190
|
+
catch (err) {
|
|
191
|
+
if (err instanceof EditError)
|
|
192
|
+
return { content: `${shown}: ${err.message}`, isError: true };
|
|
193
|
+
throw err;
|
|
194
|
+
}
|
|
195
|
+
await writeFile(abs, bom + restoreLineEndings(plan.result, ending), "utf8");
|
|
196
|
+
const details = {
|
|
197
|
+
path: abs,
|
|
198
|
+
diff: unifiedDiff(content, plan.result, shown),
|
|
199
|
+
replacements: plan.replacements,
|
|
200
|
+
fuzzy: plan.fuzzy,
|
|
201
|
+
firstChangedLine: firstChangedLine(content, plan.result),
|
|
202
|
+
};
|
|
203
|
+
const note = plan.fuzzy ? " (matched after whitespace/quote normalization)" : "";
|
|
204
|
+
const count = `${plan.replacements} replacement${plan.replacements === 1 ? "" : "s"}`;
|
|
205
|
+
return { content: `Edited ${shown}: ${count}${note}`, details };
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
export function createEditTool() {
|
|
209
|
+
return {
|
|
210
|
+
name: "edit",
|
|
211
|
+
label: "Edit",
|
|
212
|
+
description: "Replace text in a file (read it first). Each oldText must be unique in the original " +
|
|
213
|
+
"(unless replaceAll); edits must not overlap.",
|
|
214
|
+
parameters: {
|
|
215
|
+
type: "object",
|
|
216
|
+
properties: {
|
|
217
|
+
path: { type: "string" },
|
|
218
|
+
edits: {
|
|
219
|
+
type: "array",
|
|
220
|
+
items: {
|
|
221
|
+
type: "object",
|
|
222
|
+
properties: {
|
|
223
|
+
oldText: { type: "string", description: "Exact text to replace" },
|
|
224
|
+
newText: { type: "string" },
|
|
225
|
+
},
|
|
226
|
+
required: ["oldText", "newText"],
|
|
227
|
+
additionalProperties: false,
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
replaceAll: { type: "boolean" },
|
|
231
|
+
},
|
|
232
|
+
required: ["path", "edits"],
|
|
233
|
+
additionalProperties: false,
|
|
234
|
+
},
|
|
235
|
+
permission: "write",
|
|
236
|
+
executionMode: "sequential",
|
|
237
|
+
annotations: { destructive: true },
|
|
238
|
+
promptSnippet: "edit: replace text in a file",
|
|
239
|
+
execute: executeEdit,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 按路径串行化读 - 改 - 写(设计 §1.2)。[B3]
|
|
3
|
+
*
|
|
4
|
+
* 同一绝对路径上的 write / edit 排队执行;不同路径互不阻塞。前一个失败不影响后一个。
|
|
5
|
+
*/
|
|
6
|
+
export declare function withFileMutex<T>(path: string, fn: () => Promise<T>): Promise<T>;
|
|
7
|
+
/** 当前持有队列的路径数(测试用)。 */
|
|
8
|
+
export declare function pendingMutexCount(): number;
|