dominds 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/LICENSE +157 -0
- package/README.md +250 -0
- package/README.zh.md +161 -0
- package/dist/access-control.js +253 -0
- package/dist/cli/create.js +263 -0
- package/dist/cli/read.js +84 -0
- package/dist/cli/tui.js +199 -0
- package/dist/cli/webui.js +169 -0
- package/dist/cli.js +227 -0
- package/dist/dialog-factory.js +53 -0
- package/dist/dialog-global-registry.js +68 -0
- package/dist/dialog-instance-registry.js +78 -0
- package/dist/dialog-run-state.js +198 -0
- package/dist/dialog.js +1024 -0
- package/dist/evt-registry.js +103 -0
- package/dist/index.js +8 -0
- package/dist/llm/client.js +69 -0
- package/dist/llm/defaults.yaml +386 -0
- package/dist/llm/driver.js +3214 -0
- package/dist/llm/gen/anthropic.js +611 -0
- package/dist/llm/gen/codex.js +375 -0
- package/dist/llm/gen/mock.js +326 -0
- package/dist/llm/gen/openai.js +470 -0
- package/dist/llm/gen/registry.js +26 -0
- package/dist/llm/gen.js +2 -0
- package/dist/llm/tools-projection.js +37 -0
- package/dist/log.js +228 -0
- package/dist/mcp/config.js +230 -0
- package/dist/mcp/sdk-client.js +129 -0
- package/dist/mcp/server-runtime.js +57 -0
- package/dist/mcp/stdio-client.js +280 -0
- package/dist/mcp/supervisor.js +979 -0
- package/dist/mcp/tool-names.js +109 -0
- package/dist/minds/builtin/cmdr/persona.md +3 -0
- package/dist/minds/builtin/dijiang/knowledge.md +287 -0
- package/dist/minds/builtin/dijiang/persona.md +7 -0
- package/dist/minds/builtin/fuxi/persona.en.md +59 -0
- package/dist/minds/builtin/fuxi/persona.zh.md +49 -0
- package/dist/minds/builtin/pangu/persona.en.md +78 -0
- package/dist/minds/builtin/pangu/persona.zh.md +71 -0
- package/dist/minds/load.js +617 -0
- package/dist/minds/minds-i18n.js +131 -0
- package/dist/minds/system-prompt.js +281 -0
- package/dist/persistence.js +3128 -0
- package/dist/problems.js +109 -0
- package/dist/server/api-routes.js +1031 -0
- package/dist/server/auth.js +180 -0
- package/dist/server/mime-types.js +32 -0
- package/dist/server/prompts-routes.js +543 -0
- package/dist/server/server-core.js +235 -0
- package/dist/server/setup-routes.js +697 -0
- package/dist/server/static-server.js +132 -0
- package/dist/server/websocket-handler.js +1011 -0
- package/dist/server.js +164 -0
- package/dist/shared/async-fifo-mutex.js +36 -0
- package/dist/shared/diligence.js +20 -0
- package/dist/shared/dotenv.js +144 -0
- package/dist/shared/evt.js +195 -0
- package/dist/shared/i18n/driver-messages.js +267 -0
- package/dist/shared/i18n/text.js +9 -0
- package/dist/shared/i18n/tool-result-messages.js +51 -0
- package/dist/shared/rtws-cli.js +73 -0
- package/dist/shared/runtime-language.js +47 -0
- package/dist/shared/team-mgmt-manual.js +116 -0
- package/dist/shared/types/context-health.js +2 -0
- package/dist/shared/types/dialog.js +11 -0
- package/dist/shared/types/i18n.js +2 -0
- package/dist/shared/types/index.js +26 -0
- package/dist/shared/types/language.js +40 -0
- package/dist/shared/types/problems.js +2 -0
- package/dist/shared/types/prompts.js +2 -0
- package/dist/shared/types/q4h.js +7 -0
- package/dist/shared/types/run-state.js +8 -0
- package/dist/shared/types/setup.js +2 -0
- package/dist/shared/types/storage.js +10 -0
- package/dist/shared/types/tellask.js +8 -0
- package/dist/shared/types/tools-registry.js +2 -0
- package/dist/shared/types/wire.js +12 -0
- package/dist/shared/utils/fmt.js +9 -0
- package/dist/shared/utils/html.js +20 -0
- package/dist/shared/utils/id.js +18 -0
- package/dist/shared/utils/inter-dialog-format.js +101 -0
- package/dist/shared/utils/time.js +13 -0
- package/dist/static/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/dist/static/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/dist/static/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/dist/static/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/dist/static/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/dist/static/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/dist/static/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/dist/static/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/dist/static/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/dist/static/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/dist/static/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/dist/static/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/dist/static/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/dist/static/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/dist/static/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/dist/static/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/dist/static/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/dist/static/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/dist/static/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/dist/static/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/dist/static/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/dist/static/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/dist/static/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/dist/static/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/dist/static/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/dist/static/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/dist/static/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/dist/static/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/dist/static/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/dist/static/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/dist/static/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/dist/static/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/dist/static/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/dist/static/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/dist/static/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/dist/static/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/dist/static/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/dist/static/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/dist/static/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/dist/static/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/dist/static/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/dist/static/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/dist/static/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/dist/static/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/dist/static/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/dist/static/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/dist/static/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/dist/static/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/dist/static/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/dist/static/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/dist/static/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/dist/static/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/dist/static/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/dist/static/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/dist/static/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/dist/static/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/dist/static/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/dist/static/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/dist/static/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/dist/static/assets/_baseUniq-Crfl3d5Y.js +661 -0
- package/dist/static/assets/_baseUniq-Crfl3d5Y.js.map +1 -0
- package/dist/static/assets/arc-CbA_x9GD.js +132 -0
- package/dist/static/assets/arc-CbA_x9GD.js.map +1 -0
- package/dist/static/assets/architectureDiagram-VXUJARFQ-lcFS8ZQJ.js +8685 -0
- package/dist/static/assets/architectureDiagram-VXUJARFQ-lcFS8ZQJ.js.map +1 -0
- package/dist/static/assets/blockDiagram-VD42YOAC-B3Q36qRc.js +3608 -0
- package/dist/static/assets/blockDiagram-VD42YOAC-B3Q36qRc.js.map +1 -0
- package/dist/static/assets/c4Diagram-YG6GDRKO-Mt-aq3VH.js +2482 -0
- package/dist/static/assets/c4Diagram-YG6GDRKO-Mt-aq3VH.js.map +1 -0
- package/dist/static/assets/channel-BVr1Yke-.js +8 -0
- package/dist/static/assets/channel-BVr1Yke-.js.map +1 -0
- package/dist/static/assets/chunk-4BX2VUAB-qCIn5Iic.js +17 -0
- package/dist/static/assets/chunk-4BX2VUAB-qCIn5Iic.js.map +1 -0
- package/dist/static/assets/chunk-55IACEB6-q172NeCV.js +14 -0
- package/dist/static/assets/chunk-55IACEB6-q172NeCV.js.map +1 -0
- package/dist/static/assets/chunk-B4BG7PRW-CMJmtYzq.js +1827 -0
- package/dist/static/assets/chunk-B4BG7PRW-CMJmtYzq.js.map +1 -0
- package/dist/static/assets/chunk-DI55MBZ5-DiuwwZPL.js +1916 -0
- package/dist/static/assets/chunk-DI55MBZ5-DiuwwZPL.js.map +1 -0
- package/dist/static/assets/chunk-FMBD7UC4-06sqZTTn.js +20 -0
- package/dist/static/assets/chunk-FMBD7UC4-06sqZTTn.js.map +1 -0
- package/dist/static/assets/chunk-QN33PNHL-CnpBNkpP.js +25 -0
- package/dist/static/assets/chunk-QN33PNHL-CnpBNkpP.js.map +1 -0
- package/dist/static/assets/chunk-QZHKN3VN-CNgjMR-e.js +18 -0
- package/dist/static/assets/chunk-QZHKN3VN-CNgjMR-e.js.map +1 -0
- package/dist/static/assets/chunk-TZMSLE5B-BxtzW6--.js +109 -0
- package/dist/static/assets/chunk-TZMSLE5B-BxtzW6--.js.map +1 -0
- package/dist/static/assets/classDiagram-2ON5EDUG-29huvmn-.js +23 -0
- package/dist/static/assets/classDiagram-2ON5EDUG-29huvmn-.js.map +1 -0
- package/dist/static/assets/classDiagram-v2-WZHVMYZB-29huvmn-.js +23 -0
- package/dist/static/assets/classDiagram-v2-WZHVMYZB-29huvmn-.js.map +1 -0
- package/dist/static/assets/clone-D2OgLSSn.js +9 -0
- package/dist/static/assets/clone-D2OgLSSn.js.map +1 -0
- package/dist/static/assets/cose-bilkent-S5V4N54A-BNegDCxl.js +4943 -0
- package/dist/static/assets/cose-bilkent-S5V4N54A-BNegDCxl.js.map +1 -0
- package/dist/static/assets/cytoscape.esm-Bm8DJGmZ.js +30240 -0
- package/dist/static/assets/cytoscape.esm-Bm8DJGmZ.js.map +1 -0
- package/dist/static/assets/dagre-6UL2VRFP-f1XrTRSn.js +695 -0
- package/dist/static/assets/dagre-6UL2VRFP-f1XrTRSn.js.map +1 -0
- package/dist/static/assets/defaultLocale-DVr69WTU.js +207 -0
- package/dist/static/assets/defaultLocale-DVr69WTU.js.map +1 -0
- package/dist/static/assets/diagram-PSM6KHXK-8w1WbeDi.js +849 -0
- package/dist/static/assets/diagram-PSM6KHXK-8w1WbeDi.js.map +1 -0
- package/dist/static/assets/diagram-QEK2KX5R-CF4wtMmR.js +303 -0
- package/dist/static/assets/diagram-QEK2KX5R-CF4wtMmR.js.map +1 -0
- package/dist/static/assets/diagram-S2PKOQOG-8p3Avgn2.js +213 -0
- package/dist/static/assets/diagram-S2PKOQOG-8p3Avgn2.js.map +1 -0
- package/dist/static/assets/erDiagram-Q2GNP2WA-BMKLxlM9.js +1159 -0
- package/dist/static/assets/erDiagram-Q2GNP2WA-BMKLxlM9.js.map +1 -0
- package/dist/static/assets/favicon-Cmg5RbCj.svg +8 -0
- package/dist/static/assets/flowDiagram-NV44I4VS-CgEuPNK2.js +2332 -0
- package/dist/static/assets/flowDiagram-NV44I4VS-CgEuPNK2.js.map +1 -0
- package/dist/static/assets/ganttDiagram-JELNMOA3-bJkDCf-9.js +3681 -0
- package/dist/static/assets/ganttDiagram-JELNMOA3-bJkDCf-9.js.map +1 -0
- package/dist/static/assets/gitGraphDiagram-NY62KEGX-4QE9kesp.js +1206 -0
- package/dist/static/assets/gitGraphDiagram-NY62KEGX-4QE9kesp.js.map +1 -0
- package/dist/static/assets/graph-CS0Pmm7c.js +597 -0
- package/dist/static/assets/graph-CS0Pmm7c.js.map +1 -0
- package/dist/static/assets/index-BS6HnGzC.js +112303 -0
- package/dist/static/assets/index-BS6HnGzC.js.map +1 -0
- package/dist/static/assets/index-DaIsSzC_.css +483 -0
- package/dist/static/assets/infoDiagram-WHAUD3N6-ypBcKfUs.js +34 -0
- package/dist/static/assets/infoDiagram-WHAUD3N6-ypBcKfUs.js.map +1 -0
- package/dist/static/assets/init-ZxktEp_H.js +17 -0
- package/dist/static/assets/init-ZxktEp_H.js.map +1 -0
- package/dist/static/assets/journeyDiagram-XKPGCS4Q-QnrxDowJ.js +1255 -0
- package/dist/static/assets/journeyDiagram-XKPGCS4Q-QnrxDowJ.js.map +1 -0
- package/dist/static/assets/kanban-definition-3W4ZIXB7-CfvEc4z5.js +1048 -0
- package/dist/static/assets/kanban-definition-3W4ZIXB7-CfvEc4z5.js.map +1 -0
- package/dist/static/assets/layout-8TGxpm23.js +2218 -0
- package/dist/static/assets/layout-8TGxpm23.js.map +1 -0
- package/dist/static/assets/linear-BATBPQQv.js +341 -0
- package/dist/static/assets/linear-BATBPQQv.js.map +1 -0
- package/dist/static/assets/min-B3oVH3AC.js +42 -0
- package/dist/static/assets/min-B3oVH3AC.js.map +1 -0
- package/dist/static/assets/mindmap-definition-VGOIOE7T-L7VLwwF8.js +1127 -0
- package/dist/static/assets/mindmap-definition-VGOIOE7T-L7VLwwF8.js.map +1 -0
- package/dist/static/assets/ordinal-CxptdPJm.js +77 -0
- package/dist/static/assets/ordinal-CxptdPJm.js.map +1 -0
- package/dist/static/assets/pieDiagram-ADFJNKIX-CFW3zIhM.js +241 -0
- package/dist/static/assets/pieDiagram-ADFJNKIX-CFW3zIhM.js.map +1 -0
- package/dist/static/assets/quadrantDiagram-AYHSOK5B-B7ssen3E.js +1338 -0
- package/dist/static/assets/quadrantDiagram-AYHSOK5B-B7ssen3E.js.map +1 -0
- package/dist/static/assets/requirementDiagram-UZGBJVZJ-D0v5BArv.js +1162 -0
- package/dist/static/assets/requirementDiagram-UZGBJVZJ-D0v5BArv.js.map +1 -0
- package/dist/static/assets/sankeyDiagram-TZEHDZUN-B7slncJe.js +1195 -0
- package/dist/static/assets/sankeyDiagram-TZEHDZUN-B7slncJe.js.map +1 -0
- package/dist/static/assets/sequenceDiagram-WL72ISMW-oXU2lRh_.js +3875 -0
- package/dist/static/assets/sequenceDiagram-WL72ISMW-oXU2lRh_.js.map +1 -0
- package/dist/static/assets/stateDiagram-FKZM4ZOC-CFYsEd0x.js +452 -0
- package/dist/static/assets/stateDiagram-FKZM4ZOC-CFYsEd0x.js.map +1 -0
- package/dist/static/assets/stateDiagram-v2-4FDKWEC3-C0UWaNA7.js +22 -0
- package/dist/static/assets/stateDiagram-v2-4FDKWEC3-C0UWaNA7.js.map +1 -0
- package/dist/static/assets/timeline-definition-IT6M3QCI-C3KODUrh.js +1223 -0
- package/dist/static/assets/timeline-definition-IT6M3QCI-C3KODUrh.js.map +1 -0
- package/dist/static/assets/treemap-KMMF4GRG-DAGDLhj2.js +18753 -0
- package/dist/static/assets/treemap-KMMF4GRG-DAGDLhj2.js.map +1 -0
- package/dist/static/assets/xychartDiagram-PRI3JC2R-C0J9iwTO.js +1888 -0
- package/dist/static/assets/xychartDiagram-PRI3JC2R-C0J9iwTO.js.map +1 -0
- package/dist/static/index.html +71 -0
- package/dist/static/testing/dom-observation-utils.js +425 -0
- package/dist/static/testing/e2e-test-helper.js +3119 -0
- package/dist/team.js +1160 -0
- package/dist/tellask.js +431 -0
- package/dist/tool.js +150 -0
- package/dist/tools/apply-patch.js +542 -0
- package/dist/tools/builtins.js +196 -0
- package/dist/tools/context-health.js +177 -0
- package/dist/tools/ctrl.js +478 -0
- package/dist/tools/diag.js +583 -0
- package/dist/tools/env.js +184 -0
- package/dist/tools/fs.js +818 -0
- package/dist/tools/mcp.js +138 -0
- package/dist/tools/mem.js +349 -0
- package/dist/tools/os.js +751 -0
- package/dist/tools/prompts/team_mgmt.en.md +70 -0
- package/dist/tools/prompts/team_mgmt.zh.md +70 -0
- package/dist/tools/prompts/ws_mod.en.md +86 -0
- package/dist/tools/prompts/ws_mod.zh.md +87 -0
- package/dist/tools/registry-snapshot.js +31 -0
- package/dist/tools/registry.js +121 -0
- package/dist/tools/ripgrep.js +678 -0
- package/dist/tools/team-mgmt.js +3300 -0
- package/dist/tools/txt.js +3178 -0
- package/dist/utils/id.js +72 -0
- package/dist/utils/task-doc.js +236 -0
- package/dist/utils/task-package.js +522 -0
- package/dist/utils/taskdoc-search.js +280 -0
- package/dist/utils/taskdoc.js +400 -0
- package/package.json +69 -0
|
@@ -0,0 +1,3178 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.prepareFileAppendTool = exports.prepareFileBlockReplaceTool = exports.applyFileModificationTool = exports.prepareFileInsertBeforeTool = exports.prepareFileInsertAfterTool = exports.prepareFileRangeEditTool = exports.overwriteEntireFileTool = exports.createNewFileTool = exports.readFileTool = void 0;
|
|
7
|
+
/**
|
|
8
|
+
* Module: tools/txt
|
|
9
|
+
*
|
|
10
|
+
* Text file tooling for reading and modifying workspace files.
|
|
11
|
+
* Provides `read_file`, `overwrite_entire_file`, `prepare_*`, and `apply_file_modification`.
|
|
12
|
+
*/
|
|
13
|
+
const crypto_1 = __importDefault(require("crypto"));
|
|
14
|
+
const fs_1 = __importDefault(require("fs"));
|
|
15
|
+
const promises_1 = __importDefault(require("fs/promises"));
|
|
16
|
+
const path_1 = __importDefault(require("path"));
|
|
17
|
+
const access_control_1 = require("../access-control");
|
|
18
|
+
const tool_result_messages_1 = require("../shared/i18n/tool-result-messages");
|
|
19
|
+
const runtime_language_1 = require("../shared/runtime-language");
|
|
20
|
+
function wrapTxtToolResult(language, messages) {
|
|
21
|
+
const first = messages[0];
|
|
22
|
+
const text = first && 'content' in first && typeof first.content === 'string' ? first.content : '';
|
|
23
|
+
const failed = /^(?:Error:|错误:|❌\s|\*\*Access Denied\*\*|\*\*访问被拒绝\*\*)/m.test(text) ||
|
|
24
|
+
text.includes('Please use the correct format') ||
|
|
25
|
+
text.includes('请使用正确的格式') ||
|
|
26
|
+
text.includes('Invalid format') ||
|
|
27
|
+
text.includes('格式不正确') ||
|
|
28
|
+
text.includes('Path required') ||
|
|
29
|
+
text.includes('需要提供路径') ||
|
|
30
|
+
text.includes('Path must be within workspace') ||
|
|
31
|
+
text.includes('路径必须位于工作区内');
|
|
32
|
+
return {
|
|
33
|
+
status: failed ? 'failed' : 'completed',
|
|
34
|
+
result: text || (failed ? (0, tool_result_messages_1.formatToolError)(language) : (0, tool_result_messages_1.formatToolOk)(language)),
|
|
35
|
+
messages,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function ok(result, messages) {
|
|
39
|
+
return { status: 'completed', result, messages };
|
|
40
|
+
}
|
|
41
|
+
function failed(result, messages) {
|
|
42
|
+
return { status: 'failed', result, messages };
|
|
43
|
+
}
|
|
44
|
+
function ensureInsideWorkspace(rel) {
|
|
45
|
+
const file = path_1.default.resolve(process.cwd(), rel);
|
|
46
|
+
const cwd = path_1.default.resolve(process.cwd());
|
|
47
|
+
if (!file.startsWith(cwd)) {
|
|
48
|
+
throw new Error('Path must be within workspace');
|
|
49
|
+
}
|
|
50
|
+
return file;
|
|
51
|
+
}
|
|
52
|
+
function normalizeFileWriteBody(inputBody) {
|
|
53
|
+
if (inputBody === '' || inputBody.endsWith('\n')) {
|
|
54
|
+
return { normalizedBody: inputBody, addedTrailingNewlineToContent: false };
|
|
55
|
+
}
|
|
56
|
+
return { normalizedBody: `${inputBody}\n`, addedTrailingNewlineToContent: true };
|
|
57
|
+
}
|
|
58
|
+
function detectStrongDiffOrPatchMarkers(text) {
|
|
59
|
+
// Intentionally "strong-feature only" to avoid false positives on common Markdown patterns
|
|
60
|
+
// like list bullets (`- foo`) or front matter delimiters (`---`).
|
|
61
|
+
if (/^diff --git\s/m.test(text))
|
|
62
|
+
return true;
|
|
63
|
+
if (/^\*\*\* Begin Patch\s*$/m.test(text))
|
|
64
|
+
return true;
|
|
65
|
+
if (/^@@.*@@/m.test(text))
|
|
66
|
+
return true;
|
|
67
|
+
const hasHeaderOld = /^---\s+\S+/m.test(text);
|
|
68
|
+
const hasHeaderNew = /^\+\+\+\s+\S+/m.test(text);
|
|
69
|
+
return hasHeaderOld && hasHeaderNew;
|
|
70
|
+
}
|
|
71
|
+
function requireNonEmptyStringArg(args, key) {
|
|
72
|
+
const value = args[key];
|
|
73
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
74
|
+
throw new Error(`Invalid arguments: \`${key}\` must be a non-empty string`);
|
|
75
|
+
}
|
|
76
|
+
return value;
|
|
77
|
+
}
|
|
78
|
+
function optionalStringArg(args, key) {
|
|
79
|
+
const value = args[key];
|
|
80
|
+
if (value === undefined)
|
|
81
|
+
return undefined;
|
|
82
|
+
if (typeof value !== 'string')
|
|
83
|
+
throw new Error(`Invalid arguments: \`${key}\` must be a string`);
|
|
84
|
+
return value;
|
|
85
|
+
}
|
|
86
|
+
function optionalBooleanArg(args, key) {
|
|
87
|
+
const value = args[key];
|
|
88
|
+
if (value === undefined)
|
|
89
|
+
return undefined;
|
|
90
|
+
if (typeof value !== 'boolean')
|
|
91
|
+
throw new Error(`Invalid arguments: \`${key}\` must be a boolean`);
|
|
92
|
+
return value;
|
|
93
|
+
}
|
|
94
|
+
function optionalIntegerArg(args, key) {
|
|
95
|
+
const value = args[key];
|
|
96
|
+
if (value === undefined)
|
|
97
|
+
return undefined;
|
|
98
|
+
if (typeof value !== 'number' || !Number.isInteger(value)) {
|
|
99
|
+
throw new Error(`Invalid arguments: \`${key}\` must be an integer`);
|
|
100
|
+
}
|
|
101
|
+
return value;
|
|
102
|
+
}
|
|
103
|
+
function optionalNonEmptyStringArg(args, key) {
|
|
104
|
+
const value = optionalStringArg(args, key);
|
|
105
|
+
if (value === undefined)
|
|
106
|
+
return undefined;
|
|
107
|
+
if (value.trim() === '')
|
|
108
|
+
return undefined;
|
|
109
|
+
return value;
|
|
110
|
+
}
|
|
111
|
+
function normalizeExistingHunkId(raw) {
|
|
112
|
+
if (raw === undefined)
|
|
113
|
+
return undefined;
|
|
114
|
+
const trimmed = raw.trim();
|
|
115
|
+
const id = trimmed.startsWith('!') ? trimmed.slice(1) : trimmed;
|
|
116
|
+
if (id === '')
|
|
117
|
+
return undefined;
|
|
118
|
+
return id;
|
|
119
|
+
}
|
|
120
|
+
function unwrapTxtToolResult(res) {
|
|
121
|
+
return res.result;
|
|
122
|
+
}
|
|
123
|
+
async function countFileLinesUtf8(absPath) {
|
|
124
|
+
return await new Promise((resolve, reject) => {
|
|
125
|
+
const stream = fs_1.default.createReadStream(absPath, { encoding: 'utf8' });
|
|
126
|
+
let newlineCount = 0;
|
|
127
|
+
let sawAny = false;
|
|
128
|
+
let lastChar = '';
|
|
129
|
+
stream.on('error', (err) => reject(err));
|
|
130
|
+
stream.on('data', (chunk) => {
|
|
131
|
+
const text = typeof chunk === 'string' ? chunk : chunk.toString('utf8');
|
|
132
|
+
if (text.length === 0)
|
|
133
|
+
return;
|
|
134
|
+
sawAny = true;
|
|
135
|
+
for (let i = 0; i < text.length; i++) {
|
|
136
|
+
if (text[i] === '\n')
|
|
137
|
+
newlineCount += 1;
|
|
138
|
+
}
|
|
139
|
+
lastChar = text[text.length - 1] ?? '';
|
|
140
|
+
});
|
|
141
|
+
stream.on('end', () => {
|
|
142
|
+
if (!sawAny)
|
|
143
|
+
return resolve(0);
|
|
144
|
+
if (lastChar === '\n')
|
|
145
|
+
return resolve(newlineCount);
|
|
146
|
+
return resolve(newlineCount + 1);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
function yamlQuote(value) {
|
|
151
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
152
|
+
}
|
|
153
|
+
function yamlBlockScalarLines(valueLines, indent) {
|
|
154
|
+
if (valueLines.length === 0)
|
|
155
|
+
return `''`;
|
|
156
|
+
const content = valueLines.map((l) => `${indent}${l}`).join('\n');
|
|
157
|
+
return `|-\n${content}`;
|
|
158
|
+
}
|
|
159
|
+
function formatYamlCodeBlock(yaml) {
|
|
160
|
+
return `\`\`\`yaml\n${yaml}\n\`\`\``;
|
|
161
|
+
}
|
|
162
|
+
function splitFileTextToLines(fileText) {
|
|
163
|
+
const parts = fileText.split('\n');
|
|
164
|
+
// Remove the terminator token created by trailing '\n' (canonical line semantics).
|
|
165
|
+
if (parts.length > 1 && parts[parts.length - 1] === '') {
|
|
166
|
+
parts.pop();
|
|
167
|
+
}
|
|
168
|
+
// Keep empty-file representation stable: one empty line.
|
|
169
|
+
if (parts.length === 0)
|
|
170
|
+
return [''];
|
|
171
|
+
return parts;
|
|
172
|
+
}
|
|
173
|
+
function isEmptyFileLines(lines) {
|
|
174
|
+
return lines.length === 0 || (lines.length === 1 && lines[0] === '');
|
|
175
|
+
}
|
|
176
|
+
function fileLineCount(lines) {
|
|
177
|
+
return isEmptyFileLines(lines) ? 0 : lines.length;
|
|
178
|
+
}
|
|
179
|
+
function rangeTotalLines(lines) {
|
|
180
|
+
return isEmptyFileLines(lines) ? 1 : lines.length;
|
|
181
|
+
}
|
|
182
|
+
function joinLinesForWrite(lines) {
|
|
183
|
+
if (isEmptyFileLines(lines))
|
|
184
|
+
return '';
|
|
185
|
+
return `${lines.join('\n')}\n`;
|
|
186
|
+
}
|
|
187
|
+
function previewWindow(lines, startIndex0, count) {
|
|
188
|
+
if (count <= 0)
|
|
189
|
+
return [];
|
|
190
|
+
const start = Math.max(0, startIndex0);
|
|
191
|
+
const end = Math.min(lines.length, startIndex0 + count);
|
|
192
|
+
if (start >= end)
|
|
193
|
+
return [];
|
|
194
|
+
return lines.slice(start, end);
|
|
195
|
+
}
|
|
196
|
+
function buildRangePreview(rangeLines) {
|
|
197
|
+
const maxShow = 6;
|
|
198
|
+
if (rangeLines.length <= maxShow)
|
|
199
|
+
return rangeLines;
|
|
200
|
+
const head = rangeLines.slice(0, 3);
|
|
201
|
+
const tail = rangeLines.slice(-3);
|
|
202
|
+
return [...head, '…', ...tail];
|
|
203
|
+
}
|
|
204
|
+
function yamlFlowStringArray(values) {
|
|
205
|
+
if (values.length === 0)
|
|
206
|
+
return '[]';
|
|
207
|
+
return `[${values.map(yamlQuote).join(', ')}]`;
|
|
208
|
+
}
|
|
209
|
+
function sha256HexUtf8(text) {
|
|
210
|
+
return crypto_1.default.createHash('sha256').update(text, 'utf8').digest('hex');
|
|
211
|
+
}
|
|
212
|
+
function countLeadingBlankLines(lines) {
|
|
213
|
+
let count = 0;
|
|
214
|
+
for (const line of lines) {
|
|
215
|
+
if (line === '')
|
|
216
|
+
count += 1;
|
|
217
|
+
else
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
return count;
|
|
221
|
+
}
|
|
222
|
+
function countTrailingBlankLines(lines) {
|
|
223
|
+
let count = 0;
|
|
224
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
225
|
+
const line = lines[i] ?? '';
|
|
226
|
+
if (line === '')
|
|
227
|
+
count += 1;
|
|
228
|
+
else
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
return count;
|
|
232
|
+
}
|
|
233
|
+
function parseOccurrence(value) {
|
|
234
|
+
if (value === 'last')
|
|
235
|
+
return { kind: 'last' };
|
|
236
|
+
if (!/^\d+$/.test(value))
|
|
237
|
+
return undefined;
|
|
238
|
+
const index1 = Number.parseInt(value, 10);
|
|
239
|
+
if (!Number.isFinite(index1) || index1 <= 0)
|
|
240
|
+
return undefined;
|
|
241
|
+
return { kind: 'index', index1 };
|
|
242
|
+
}
|
|
243
|
+
function splitTextToLinesForEditing(fileText) {
|
|
244
|
+
if (fileText === '')
|
|
245
|
+
return [];
|
|
246
|
+
const parts = fileText.split('\n');
|
|
247
|
+
if (parts.length > 0 && parts[parts.length - 1] === '') {
|
|
248
|
+
parts.pop();
|
|
249
|
+
}
|
|
250
|
+
return parts;
|
|
251
|
+
}
|
|
252
|
+
function joinLinesForTextWrite(lines) {
|
|
253
|
+
if (lines.length === 0)
|
|
254
|
+
return '';
|
|
255
|
+
return `${lines.join('\n')}\n`;
|
|
256
|
+
}
|
|
257
|
+
function countLogicalLines(text) {
|
|
258
|
+
if (text === '')
|
|
259
|
+
return 0;
|
|
260
|
+
const parts = text.split('\n');
|
|
261
|
+
if (parts.length > 0 && parts[parts.length - 1] === '') {
|
|
262
|
+
parts.pop();
|
|
263
|
+
}
|
|
264
|
+
return parts.length;
|
|
265
|
+
}
|
|
266
|
+
const PLANNED_MOD_TTL_MS = 60 * 60 * 1000; // ~1 hour
|
|
267
|
+
const plannedModsById = new Map();
|
|
268
|
+
const plannedBlockReplacesById = new Map();
|
|
269
|
+
const fileApplyQueues = new Map();
|
|
270
|
+
const fileApplyRunning = new Set();
|
|
271
|
+
function enqueueFileApply(relPath, item) {
|
|
272
|
+
const q = fileApplyQueues.get(relPath) ?? [];
|
|
273
|
+
q.push(item);
|
|
274
|
+
q.sort((a, b) => a.priority !== b.priority ? a.priority - b.priority : a.tieBreaker.localeCompare(b.tieBreaker));
|
|
275
|
+
fileApplyQueues.set(relPath, q);
|
|
276
|
+
}
|
|
277
|
+
async function drainFileApplyQueue(relPath) {
|
|
278
|
+
if (fileApplyRunning.has(relPath))
|
|
279
|
+
return;
|
|
280
|
+
const q = fileApplyQueues.get(relPath);
|
|
281
|
+
if (!q || q.length === 0)
|
|
282
|
+
return;
|
|
283
|
+
fileApplyRunning.add(relPath);
|
|
284
|
+
try {
|
|
285
|
+
while (true) {
|
|
286
|
+
const next = fileApplyQueues.get(relPath)?.shift();
|
|
287
|
+
if (!next)
|
|
288
|
+
break;
|
|
289
|
+
await next.run();
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
finally {
|
|
293
|
+
fileApplyRunning.delete(relPath);
|
|
294
|
+
const remaining = fileApplyQueues.get(relPath);
|
|
295
|
+
if (!remaining || remaining.length === 0)
|
|
296
|
+
fileApplyQueues.delete(relPath);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
function pruneExpiredPlannedMods(nowMs) {
|
|
300
|
+
for (const [id, mod] of plannedModsById.entries()) {
|
|
301
|
+
if (mod.expiresAtMs <= nowMs)
|
|
302
|
+
plannedModsById.delete(id);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
function pruneExpiredPlannedBlockReplaces(nowMs) {
|
|
306
|
+
for (const [id, mod] of plannedBlockReplacesById.entries()) {
|
|
307
|
+
if (mod.expiresAtMs <= nowMs)
|
|
308
|
+
plannedBlockReplacesById.delete(id);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
function generateHunkId() {
|
|
312
|
+
// Short, URL-safe, command-friendly id
|
|
313
|
+
return crypto_1.default.randomBytes(4).toString('hex');
|
|
314
|
+
}
|
|
315
|
+
function isValidHunkId(id) {
|
|
316
|
+
return /^[a-z0-9_-]{2,32}$/i.test(id);
|
|
317
|
+
}
|
|
318
|
+
function parseLineRangeSpec(rangeSpec, totalLines) {
|
|
319
|
+
const trimmed = rangeSpec.trim();
|
|
320
|
+
if (!trimmed)
|
|
321
|
+
return { ok: false, error: 'Range required' };
|
|
322
|
+
// Shorthand: "N" means "N~N"
|
|
323
|
+
if (/^\d+$/.test(trimmed)) {
|
|
324
|
+
const n = Number.parseInt(trimmed, 10);
|
|
325
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
326
|
+
return { ok: false, error: 'Invalid range' };
|
|
327
|
+
if (n > totalLines)
|
|
328
|
+
return { ok: false, error: 'Range out of bounds' };
|
|
329
|
+
return { ok: true, range: { kind: 'replace', startLine: n, endLine: n } };
|
|
330
|
+
}
|
|
331
|
+
const match = trimmed.match(/^(\d+)?~(\d+)?$/);
|
|
332
|
+
if (!match)
|
|
333
|
+
return { ok: false, error: 'Invalid range' };
|
|
334
|
+
const startStr = match[1];
|
|
335
|
+
const endStr = match[2];
|
|
336
|
+
const start = startStr !== undefined ? Number.parseInt(startStr, 10) : undefined;
|
|
337
|
+
const end = endStr !== undefined ? Number.parseInt(endStr, 10) : undefined;
|
|
338
|
+
if (start !== undefined && (!Number.isFinite(start) || start <= 0)) {
|
|
339
|
+
return { ok: false, error: 'Invalid range' };
|
|
340
|
+
}
|
|
341
|
+
if (end !== undefined && (!Number.isFinite(end) || end <= 0)) {
|
|
342
|
+
return { ok: false, error: 'Invalid range' };
|
|
343
|
+
}
|
|
344
|
+
// "~" = entire file
|
|
345
|
+
if (start === undefined && end === undefined) {
|
|
346
|
+
return { ok: true, range: { kind: 'replace', startLine: 1, endLine: totalLines } };
|
|
347
|
+
}
|
|
348
|
+
// "~N" = 1..N
|
|
349
|
+
if (start === undefined && end !== undefined) {
|
|
350
|
+
if (end > totalLines)
|
|
351
|
+
return { ok: false, error: 'Range out of bounds' };
|
|
352
|
+
return { ok: true, range: { kind: 'replace', startLine: 1, endLine: end } };
|
|
353
|
+
}
|
|
354
|
+
// "N~" = N..end (or append if N is exactly totalLines+1)
|
|
355
|
+
if (start !== undefined && end === undefined) {
|
|
356
|
+
if (start === totalLines + 1) {
|
|
357
|
+
return { ok: true, range: { kind: 'append', startLine: start } };
|
|
358
|
+
}
|
|
359
|
+
if (start > totalLines)
|
|
360
|
+
return { ok: false, error: 'Range out of bounds' };
|
|
361
|
+
return { ok: true, range: { kind: 'replace', startLine: start, endLine: totalLines } };
|
|
362
|
+
}
|
|
363
|
+
// "N~M"
|
|
364
|
+
if (start !== undefined && end !== undefined) {
|
|
365
|
+
if (start > end)
|
|
366
|
+
return { ok: false, error: 'Invalid range' };
|
|
367
|
+
if (end > totalLines)
|
|
368
|
+
return { ok: false, error: 'Range out of bounds' };
|
|
369
|
+
return { ok: true, range: { kind: 'replace', startLine: start, endLine: end } };
|
|
370
|
+
}
|
|
371
|
+
return { ok: false, error: 'Invalid range' };
|
|
372
|
+
}
|
|
373
|
+
function buildUnifiedSingleHunkDiff(relPath, currentLines, startIndex0, deleteCount, newLines) {
|
|
374
|
+
const context = 3;
|
|
375
|
+
const beforeStart0 = Math.max(0, startIndex0 - context);
|
|
376
|
+
const afterEnd0 = Math.min(currentLines.length, startIndex0 + deleteCount + context);
|
|
377
|
+
const contextBefore = currentLines.slice(beforeStart0, startIndex0);
|
|
378
|
+
const oldRemoved = currentLines.slice(startIndex0, startIndex0 + deleteCount);
|
|
379
|
+
const contextAfter = currentLines.slice(startIndex0 + deleteCount, afterEnd0);
|
|
380
|
+
const oldStartLine1 = beforeStart0 + 1;
|
|
381
|
+
const oldCount = contextBefore.length + oldRemoved.length + contextAfter.length;
|
|
382
|
+
const newStartLine1 = oldStartLine1;
|
|
383
|
+
const newCount = contextBefore.length + newLines.length + contextAfter.length;
|
|
384
|
+
const hunkLines = [
|
|
385
|
+
...contextBefore.map((l) => ` ${l}`),
|
|
386
|
+
...oldRemoved.map((l) => `-${l}`),
|
|
387
|
+
...newLines.map((l) => `+${l}`),
|
|
388
|
+
...contextAfter.map((l) => ` ${l}`),
|
|
389
|
+
];
|
|
390
|
+
return [
|
|
391
|
+
`diff --git a/${relPath} b/${relPath}`,
|
|
392
|
+
`--- a/${relPath}`,
|
|
393
|
+
`+++ b/${relPath}`,
|
|
394
|
+
`@@ -${oldStartLine1},${oldCount} +${newStartLine1},${newCount} @@`,
|
|
395
|
+
...hunkLines,
|
|
396
|
+
'',
|
|
397
|
+
].join('\n');
|
|
398
|
+
}
|
|
399
|
+
function computeContextWindow(currentLines, startIndex0, deleteCount) {
|
|
400
|
+
const context = 3;
|
|
401
|
+
const beforeStart0 = Math.max(0, startIndex0 - context);
|
|
402
|
+
const afterEnd0 = Math.min(currentLines.length, startIndex0 + deleteCount + context);
|
|
403
|
+
const contextBefore = currentLines.slice(beforeStart0, startIndex0);
|
|
404
|
+
const contextAfter = currentLines.slice(startIndex0 + deleteCount, afterEnd0);
|
|
405
|
+
return { contextBefore, contextAfter };
|
|
406
|
+
}
|
|
407
|
+
function splitPlannedBodyLines(inputBody) {
|
|
408
|
+
// Treat a single trailing '\n' as a terminator, not an extra blank line.
|
|
409
|
+
// - '' (no body) means "replace with nothing" (deletion).
|
|
410
|
+
// - '\n' means "replace with one empty line".
|
|
411
|
+
if (inputBody === '')
|
|
412
|
+
return [];
|
|
413
|
+
const body = inputBody.endsWith('\n') ? inputBody.slice(0, -1) : inputBody;
|
|
414
|
+
return body.split('\n');
|
|
415
|
+
}
|
|
416
|
+
function matchesAt(currentLines, index0, oldLines) {
|
|
417
|
+
if (index0 < 0)
|
|
418
|
+
return false;
|
|
419
|
+
if (index0 + oldLines.length > currentLines.length)
|
|
420
|
+
return false;
|
|
421
|
+
for (let i = 0; i < oldLines.length; i++) {
|
|
422
|
+
if (currentLines[index0 + i] !== oldLines[i])
|
|
423
|
+
return false;
|
|
424
|
+
}
|
|
425
|
+
return true;
|
|
426
|
+
}
|
|
427
|
+
function findAllMatches(currentLines, oldLines) {
|
|
428
|
+
if (oldLines.length === 0)
|
|
429
|
+
return [];
|
|
430
|
+
const matches = [];
|
|
431
|
+
const maxStart = currentLines.length - oldLines.length;
|
|
432
|
+
for (let i = 0; i <= maxStart; i++) {
|
|
433
|
+
if (matchesAt(currentLines, i, oldLines))
|
|
434
|
+
matches.push(i);
|
|
435
|
+
}
|
|
436
|
+
return matches;
|
|
437
|
+
}
|
|
438
|
+
function filterByContext(currentLines, candidateStarts, contextBefore, contextAfter, oldLinesLen) {
|
|
439
|
+
if (candidateStarts.length <= 1)
|
|
440
|
+
return [...candidateStarts];
|
|
441
|
+
if (contextBefore.length === 0 && contextAfter.length === 0)
|
|
442
|
+
return [...candidateStarts];
|
|
443
|
+
const out = [];
|
|
444
|
+
for (const start0 of candidateStarts) {
|
|
445
|
+
const beforeStart0 = start0 - contextBefore.length;
|
|
446
|
+
const afterStart0 = start0 + oldLinesLen;
|
|
447
|
+
const afterEnd0 = afterStart0 + contextAfter.length;
|
|
448
|
+
if (beforeStart0 < 0)
|
|
449
|
+
continue;
|
|
450
|
+
if (afterEnd0 > currentLines.length)
|
|
451
|
+
continue;
|
|
452
|
+
let ok = true;
|
|
453
|
+
for (let i = 0; i < contextBefore.length; i++) {
|
|
454
|
+
if (currentLines[beforeStart0 + i] !== contextBefore[i]) {
|
|
455
|
+
ok = false;
|
|
456
|
+
break;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
if (!ok)
|
|
460
|
+
continue;
|
|
461
|
+
for (let i = 0; i < contextAfter.length; i++) {
|
|
462
|
+
if (currentLines[afterStart0 + i] !== contextAfter[i]) {
|
|
463
|
+
ok = false;
|
|
464
|
+
break;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
if (ok)
|
|
468
|
+
out.push(start0);
|
|
469
|
+
}
|
|
470
|
+
return out;
|
|
471
|
+
}
|
|
472
|
+
const READ_FILE_CONTENT_CHAR_LIMIT = 100000;
|
|
473
|
+
async function readFileContentBounded(absPath, options) {
|
|
474
|
+
const rangeStart = options.rangeStart ?? 1;
|
|
475
|
+
const rangeEnd = options.rangeEnd ?? Number.POSITIVE_INFINITY;
|
|
476
|
+
const outLines = [];
|
|
477
|
+
let shownLines = 0;
|
|
478
|
+
let totalLines = 0;
|
|
479
|
+
let outputChars = 0;
|
|
480
|
+
let truncatedByMaxLines = false;
|
|
481
|
+
let truncatedByCharLimit = false;
|
|
482
|
+
const stream = fs_1.default.createReadStream(absPath, { encoding: 'utf8' });
|
|
483
|
+
let leftover = '';
|
|
484
|
+
let currentLineNumber = 1;
|
|
485
|
+
const tryAddLine = (line, lineNumber) => {
|
|
486
|
+
if (lineNumber < rangeStart || lineNumber > rangeEnd)
|
|
487
|
+
return;
|
|
488
|
+
if (shownLines >= options.maxLines) {
|
|
489
|
+
truncatedByMaxLines = true;
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
const decoratedLine = options.decorateLinenos
|
|
493
|
+
? `${lineNumber.toString().padStart(4, ' ')}| ${line}`
|
|
494
|
+
: line;
|
|
495
|
+
const extraChars = decoratedLine.length + (outLines.length === 0 ? 0 : 1); // +1 for '\n'
|
|
496
|
+
if (outputChars + extraChars > READ_FILE_CONTENT_CHAR_LIMIT) {
|
|
497
|
+
truncatedByCharLimit = true;
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
outLines.push(decoratedLine);
|
|
501
|
+
outputChars += extraChars;
|
|
502
|
+
shownLines++;
|
|
503
|
+
};
|
|
504
|
+
return await new Promise((resolve, reject) => {
|
|
505
|
+
stream.on('error', (err) => reject(err));
|
|
506
|
+
stream.on('data', (chunk) => {
|
|
507
|
+
const chunkText = typeof chunk === 'string' ? chunk : chunk.toString('utf8');
|
|
508
|
+
const combined = leftover + chunkText;
|
|
509
|
+
const parts = combined.split('\n');
|
|
510
|
+
const nextLeftover = parts.pop();
|
|
511
|
+
leftover = nextLeftover === undefined ? '' : nextLeftover;
|
|
512
|
+
for (const line of parts) {
|
|
513
|
+
tryAddLine(line, currentLineNumber);
|
|
514
|
+
totalLines++;
|
|
515
|
+
currentLineNumber++;
|
|
516
|
+
}
|
|
517
|
+
});
|
|
518
|
+
stream.on('end', () => {
|
|
519
|
+
// Canonical line semantics:
|
|
520
|
+
// - empty file yields 0 lines
|
|
521
|
+
// - trailing '\n' does NOT yield an extra empty "terminator" line
|
|
522
|
+
if (leftover !== '') {
|
|
523
|
+
tryAddLine(leftover, currentLineNumber);
|
|
524
|
+
totalLines++;
|
|
525
|
+
}
|
|
526
|
+
resolve({
|
|
527
|
+
totalLines,
|
|
528
|
+
formattedContent: outLines.join('\n'),
|
|
529
|
+
shownLines,
|
|
530
|
+
truncatedByMaxLines,
|
|
531
|
+
truncatedByCharLimit,
|
|
532
|
+
});
|
|
533
|
+
});
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
exports.readFileTool = {
|
|
537
|
+
type: 'func',
|
|
538
|
+
name: 'read_file',
|
|
539
|
+
description: 'Read a text file (bounded) relative to workspace.',
|
|
540
|
+
descriptionI18n: {
|
|
541
|
+
en: 'Read a text file (bounded) relative to workspace.',
|
|
542
|
+
zh: '读取工作区内的文本文件(有上限/可截断)。',
|
|
543
|
+
},
|
|
544
|
+
parameters: {
|
|
545
|
+
type: 'object',
|
|
546
|
+
additionalProperties: false,
|
|
547
|
+
properties: {
|
|
548
|
+
path: { type: 'string', description: 'Workspace-relative path.' },
|
|
549
|
+
range: {
|
|
550
|
+
type: 'string',
|
|
551
|
+
description: "Optional line range string: '10~50' | '300~' | '~20' | '~' (1-based, inclusive).",
|
|
552
|
+
},
|
|
553
|
+
max_lines: { type: 'integer', description: 'Max lines to show (default: 500).' },
|
|
554
|
+
show_linenos: {
|
|
555
|
+
type: 'boolean',
|
|
556
|
+
description: 'Whether to show line numbers (default: true).',
|
|
557
|
+
},
|
|
558
|
+
},
|
|
559
|
+
required: ['path'],
|
|
560
|
+
},
|
|
561
|
+
argsValidation: 'dominds',
|
|
562
|
+
call: async (dlg, caller, args) => {
|
|
563
|
+
const language = (0, runtime_language_1.getWorkLanguage)();
|
|
564
|
+
let labels;
|
|
565
|
+
if (language === 'zh') {
|
|
566
|
+
labels = {
|
|
567
|
+
formatError: '请使用正确的函数工具参数调用 `read_file`。\n\n' +
|
|
568
|
+
'**期望格式:** `read_file({ path, range, max_lines, show_linenos })`\n\n' +
|
|
569
|
+
'注意:大多数 provider 可省略可选字段;但如果你的 provider 要求函数工具参数字段都“必填”(例如 Codex),可用哨兵值表达“未指定/默认”。\n' +
|
|
570
|
+
'- `range: \"\"` 表示不指定范围\n' +
|
|
571
|
+
'- `max_lines: 0` 表示使用默认值(500)\n\n' +
|
|
572
|
+
'**示例:**\n```text\n{ \"path\": \"src/main.ts\" }\n{ \"path\": \"src/main.ts\", \"range\": \"10~50\" }\n{ \"path\": \"src/main.ts\", \"range\": \"\", \"max_lines\": 0, \"show_linenos\": true }\n```',
|
|
573
|
+
formatErrorWithReason: (msg) => `❌ **错误:** ${msg}\n\n` +
|
|
574
|
+
'请使用正确的函数工具参数调用 `read_file`。\n\n' +
|
|
575
|
+
'**期望格式:** `read_file({ path, range, max_lines, show_linenos })`\n\n' +
|
|
576
|
+
'注意:大多数 provider 可省略可选字段;但如果你的 provider 要求函数工具参数字段都“必填”(例如 Codex),可用哨兵值表达“未指定/默认”。\n' +
|
|
577
|
+
'- `range: \"\"` 表示不指定范围\n' +
|
|
578
|
+
'- `max_lines: 0` 表示使用默认值(500)\n\n' +
|
|
579
|
+
'**示例:**\n```text\n{ \"path\": \"src/main.ts\" }\n{ \"path\": \"src/main.ts\", \"range\": \"10~50\" }\n{ \"path\": \"src/main.ts\", \"range\": \"\", \"max_lines\": 0, \"show_linenos\": true }\n```',
|
|
580
|
+
fileLabel: '文件',
|
|
581
|
+
warningTruncatedByMaxLines: (shown, maxLines) => `⚠️ **警告:** 输出已截断(最多显示 ${maxLines} 行,当前显示 ${shown} 行)\n\n`,
|
|
582
|
+
warningTruncatedByCharLimit: (shown, maxChars) => `⚠️ **警告:** 输出已截断(字符总数上限约 ${maxChars},当前显示 ${shown} 行)\n\n`,
|
|
583
|
+
warningTruncatedByMaxLinesWithRange: (maxLines, rangeLines, used) => `⚠️ **警告:** 输出将被 \`max_lines\`(${maxLines})截断:\`range\` 共 ${rangeLines} 行,仅返回前 ${used} 行。\n\n`,
|
|
584
|
+
hintUseRangeNext: (relPath, start, end) => `💡 **提示:** 可继续调用 \`read_file\` 读取下一段,例如:\`read_file({ \"path\": \"${relPath}\", \"range\": \"${start}~${end}\", \"max_lines\": 0, \"show_linenos\": true })\`\n\n`,
|
|
585
|
+
hintLargeFileStrategy: (relPath) => `💡 **大文件策略:** 建议分多轮分析:每轮读取一段、完成总结并整理“重入包”后,在新一轮调用函数工具 \`clear_mind({ \"reminder_content\": \"<重入包>\" })\`(降低上下文占用,同时保留可扫读、可行动的恢复信息),再继续读取下一段(例如:\`read_file({ \"path\": \"${relPath}\", \"range\": \"1~500\", \"max_lines\": 0, \"show_linenos\": true })\`、\`read_file({ \"path\": \"${relPath}\", \"range\": \"201~400\", \"max_lines\": 0, \"show_linenos\": true })\`)。\n\n`,
|
|
586
|
+
sizeLabel: '大小',
|
|
587
|
+
totalLinesLabel: '总行数',
|
|
588
|
+
emptyFileLabel: '<空文件>',
|
|
589
|
+
failedToRead: (msg) => `❌ **错误**\n\n读取文件失败:${msg}`,
|
|
590
|
+
invalidFormatMultiToolCalls: (toolName) => `INVALID_FORMAT:检测到疑似把多个工具调用文本混入了 \`read_file\` 的输入(例如出现 \`${toolName}\`)。\n\n` +
|
|
591
|
+
'请把不同工具拆分为独立调用(不要把 `@ripgrep_*` 等调用文本拼接到 `path/range` 里)。',
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
else {
|
|
595
|
+
labels = {
|
|
596
|
+
formatError: 'Please call the function tool `read_file` with valid arguments.\n\n' +
|
|
597
|
+
'**Expected:** `read_file({ path, range, max_lines, show_linenos })`\n\n' +
|
|
598
|
+
'Note: most providers can omit optional fields; but if your provider requires “all fields present” (e.g. Codex), use sentinel values for “unset/default”.\n' +
|
|
599
|
+
'- use `range: \"\"` for unset\n' +
|
|
600
|
+
'- use `max_lines: 0` for default (500)\n\n' +
|
|
601
|
+
'**Examples:**\n```text\n{ \"path\": \"src/main.ts\" }\n{ \"path\": \"src/main.ts\", \"range\": \"10~50\" }\n{ \"path\": \"src/main.ts\", \"range\": \"\", \"max_lines\": 0, \"show_linenos\": true }\n```',
|
|
602
|
+
formatErrorWithReason: (msg) => `❌ **Error:** ${msg}\n\n` +
|
|
603
|
+
'Please call the function tool `read_file` with valid arguments.\n\n' +
|
|
604
|
+
'**Expected:** `read_file({ path, range, max_lines, show_linenos })`\n\n' +
|
|
605
|
+
'Note: most providers can omit optional fields; but if your provider requires “all fields present” (e.g. Codex), use sentinel values for “unset/default”.\n' +
|
|
606
|
+
'- use `range: \"\"` for unset\n' +
|
|
607
|
+
'- use `max_lines: 0` for default (500)\n\n' +
|
|
608
|
+
'**Examples:**\n```text\n{ \"path\": \"src/main.ts\" }\n{ \"path\": \"src/main.ts\", \"range\": \"10~50\" }\n{ \"path\": \"src/main.ts\", \"range\": \"\", \"max_lines\": 0, \"show_linenos\": true }\n```',
|
|
609
|
+
fileLabel: 'File',
|
|
610
|
+
warningTruncatedByMaxLines: (shown, maxLines) => `⚠️ **Warning:** Output was truncated (max ${maxLines} lines; showing ${shown})\n\n`,
|
|
611
|
+
warningTruncatedByCharLimit: (shown, maxChars) => `⚠️ **Warning:** Output was truncated (~${maxChars} character cap; showing ${shown} lines)\n\n`,
|
|
612
|
+
warningTruncatedByMaxLinesWithRange: (maxLines, rangeLines, used) => `⚠️ **Warning:** Output will be truncated by \`max_lines\` (${maxLines}): \`range\` has ${rangeLines} lines; returning only the first ${used}.\n\n`,
|
|
613
|
+
hintUseRangeNext: (relPath, start, end) => `💡 **Hint:** Call \`read_file\` again to continue reading, e.g. \`read_file({ \"path\": \"${relPath}\", \"range\": \"${start}~${end}\", \"max_lines\": 0, \"show_linenos\": true })\`\n\n`,
|
|
614
|
+
hintLargeFileStrategy: (relPath) => `💡 **Large file strategy:** Analyze in multiple rounds: each round read a slice, summarize, and prepare a re-entry package; then start a new round and call the function tool \`clear_mind({ \"reminder_content\": \"<re-entry package>\" })\` (less context, while preserving scannable resume info) before reading the next slice (e.g. \`read_file({ \"path\": \"${relPath}\", \"range\": \"1~500\", \"max_lines\": 0, \"show_linenos\": true })\`, then \`read_file({ \"path\": \"${relPath}\", \"range\": \"201~400\", \"max_lines\": 0, \"show_linenos\": true })\`).\n\n`,
|
|
615
|
+
sizeLabel: 'Size',
|
|
616
|
+
totalLinesLabel: 'Total lines',
|
|
617
|
+
emptyFileLabel: '<empty file>',
|
|
618
|
+
failedToRead: (msg) => `❌ **Error**\n\nFailed to read file: ${msg}`,
|
|
619
|
+
invalidFormatMultiToolCalls: (toolName) => `INVALID_FORMAT: Detected what looks like tool-call text mixed into \`read_file\` input (e.g. \`${toolName}\`).\n\n` +
|
|
620
|
+
'Split different tools into separate calls (do not paste `@ripgrep_*` or other tool-call text into `path/range`).',
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
// labels is always set above
|
|
624
|
+
if (!labels) {
|
|
625
|
+
throw new Error('Failed to initialize labels');
|
|
626
|
+
}
|
|
627
|
+
const errorMsg = (zh, en) => (language === 'zh' ? zh : en);
|
|
628
|
+
const pathValue = args['path'];
|
|
629
|
+
if (typeof pathValue !== 'string' || pathValue.trim() === '') {
|
|
630
|
+
return labels.formatError;
|
|
631
|
+
}
|
|
632
|
+
const rel = pathValue.trim();
|
|
633
|
+
const showLinenosValue = args['show_linenos'];
|
|
634
|
+
const showLinenos = showLinenosValue === undefined
|
|
635
|
+
? true
|
|
636
|
+
: typeof showLinenosValue === 'boolean'
|
|
637
|
+
? showLinenosValue
|
|
638
|
+
: null;
|
|
639
|
+
if (showLinenos === null) {
|
|
640
|
+
return labels.formatErrorWithReason(errorMsg('`show_linenos` 必须是 boolean', '`show_linenos` must be a boolean'));
|
|
641
|
+
}
|
|
642
|
+
const maxLinesValue = args['max_lines'];
|
|
643
|
+
const maxLinesSpecified = maxLinesValue !== undefined && maxLinesValue !== 0;
|
|
644
|
+
const maxLines = maxLinesValue === undefined || maxLinesValue === 0
|
|
645
|
+
? 500
|
|
646
|
+
: typeof maxLinesValue === 'number' && Number.isInteger(maxLinesValue) && maxLinesValue > 0
|
|
647
|
+
? maxLinesValue
|
|
648
|
+
: null;
|
|
649
|
+
if (maxLines === null) {
|
|
650
|
+
return labels.formatErrorWithReason(errorMsg('`max_lines` 必须是正整数(或传 0 表示默认值)', '`max_lines` must be a positive integer (or 0 for default)'));
|
|
651
|
+
}
|
|
652
|
+
const rangeValue = args['range'];
|
|
653
|
+
const rangeStr = rangeValue === undefined ? '' : typeof rangeValue === 'string' ? rangeValue.trim() : null;
|
|
654
|
+
if (rangeStr === null) {
|
|
655
|
+
return labels.formatErrorWithReason(errorMsg('`range` 必须是 string(传 \"\" 表示不指定)', '`range` must be a string (use "" for unset)'));
|
|
656
|
+
}
|
|
657
|
+
const rangeSpecified = rangeStr !== '';
|
|
658
|
+
const detectMultiToolCalls = (input) => {
|
|
659
|
+
const trimmed = input.trimEnd();
|
|
660
|
+
const lines = trimmed.split(/\r?\n/);
|
|
661
|
+
if (lines.length <= 1)
|
|
662
|
+
return null;
|
|
663
|
+
const suspicious = lines.slice(1).find((l) => l.trimStart().startsWith('@'));
|
|
664
|
+
if (!suspicious)
|
|
665
|
+
return null;
|
|
666
|
+
return suspicious.trimStart().split(/\s+/)[0] ?? null;
|
|
667
|
+
};
|
|
668
|
+
const suspiciousTool = detectMultiToolCalls(rel) ?? (rangeSpecified ? detectMultiToolCalls(rangeStr) : null);
|
|
669
|
+
if (suspiciousTool) {
|
|
670
|
+
return labels.invalidFormatMultiToolCalls(suspiciousTool);
|
|
671
|
+
}
|
|
672
|
+
const options = { decorateLinenos: showLinenos, maxLines };
|
|
673
|
+
if (rangeSpecified) {
|
|
674
|
+
const match = rangeStr.match(/^(\d+)?~(\d+)?$/);
|
|
675
|
+
if (!match) {
|
|
676
|
+
return labels.formatErrorWithReason(errorMsg('`range` 无效(期望:\"start~end\" / \"start~\" / \"~end\" / \"~\")', 'Invalid `range` (expected "start~end" / "start~" / "~end" / "~")'));
|
|
677
|
+
}
|
|
678
|
+
const [, startStr, endStr] = match;
|
|
679
|
+
if (startStr) {
|
|
680
|
+
const start = Number.parseInt(startStr, 10);
|
|
681
|
+
if (!Number.isFinite(start) || start <= 0) {
|
|
682
|
+
return labels.formatErrorWithReason(errorMsg('`range` 起始行号无效(必须是正整数)', 'Invalid `range` start (must be a positive integer)'));
|
|
683
|
+
}
|
|
684
|
+
options.rangeStart = start;
|
|
685
|
+
}
|
|
686
|
+
if (endStr) {
|
|
687
|
+
const end = Number.parseInt(endStr, 10);
|
|
688
|
+
if (!Number.isFinite(end) || end <= 0) {
|
|
689
|
+
return labels.formatErrorWithReason(errorMsg('`range` 结束行号无效(必须是正整数)', 'Invalid `range` end (must be a positive integer)'));
|
|
690
|
+
}
|
|
691
|
+
options.rangeEnd = end;
|
|
692
|
+
}
|
|
693
|
+
if (options.rangeStart !== undefined &&
|
|
694
|
+
options.rangeEnd !== undefined &&
|
|
695
|
+
options.rangeStart > options.rangeEnd) {
|
|
696
|
+
return labels.formatErrorWithReason(errorMsg('`range` 无效(start 必须 <= end)', 'Invalid `range` (start must be <= end)'));
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
const flags = { maxLinesSpecified, rangeSpecified };
|
|
700
|
+
try {
|
|
701
|
+
// Check member access permissions
|
|
702
|
+
if (!(0, access_control_1.hasReadAccess)(caller, rel)) {
|
|
703
|
+
const content = (0, access_control_1.getAccessDeniedMessage)('read', rel, language);
|
|
704
|
+
return content;
|
|
705
|
+
}
|
|
706
|
+
const file = ensureInsideWorkspace(rel);
|
|
707
|
+
const stat = await promises_1.default.stat(file);
|
|
708
|
+
const contentSummary = await readFileContentBounded(file, options);
|
|
709
|
+
const maxLinesRangeMismatch = contentSummary.truncatedByMaxLines &&
|
|
710
|
+
flags.maxLinesSpecified &&
|
|
711
|
+
flags.rangeSpecified &&
|
|
712
|
+
options.rangeEnd !== undefined
|
|
713
|
+
? (() => {
|
|
714
|
+
const rangeStart = options.rangeStart ?? 1;
|
|
715
|
+
const rangeLines = options.rangeEnd - rangeStart + 1;
|
|
716
|
+
if (rangeLines > options.maxLines) {
|
|
717
|
+
return { maxLines: options.maxLines, rangeLines, used: options.maxLines };
|
|
718
|
+
}
|
|
719
|
+
return null;
|
|
720
|
+
})()
|
|
721
|
+
: null;
|
|
722
|
+
const headerSummary = language === 'zh'
|
|
723
|
+
? `read_file:${rel};size=${stat.size} bytes;total_lines=${contentSummary.totalLines};shown=${contentSummary.shownLines}.`
|
|
724
|
+
: `read_file: ${rel}; size=${stat.size} bytes; total_lines=${contentSummary.totalLines}; shown=${contentSummary.shownLines}.`;
|
|
725
|
+
const yaml = [
|
|
726
|
+
`status: ok`,
|
|
727
|
+
`mode: read_file`,
|
|
728
|
+
`path: ${yamlQuote(rel)}`,
|
|
729
|
+
`size_bytes: ${stat.size}`,
|
|
730
|
+
`total_lines: ${contentSummary.totalLines}`,
|
|
731
|
+
`shown_lines: ${contentSummary.shownLines}`,
|
|
732
|
+
`truncated_by_max_lines: ${contentSummary.truncatedByMaxLines}`,
|
|
733
|
+
`truncated_by_char_limit: ${contentSummary.truncatedByCharLimit}`,
|
|
734
|
+
`summary: ${yamlQuote(headerSummary)}`,
|
|
735
|
+
].join('\n');
|
|
736
|
+
// Create markdown response (human-friendly body after a structured YAML header)
|
|
737
|
+
let markdown = `${formatYamlCodeBlock(yaml)}\n\n`;
|
|
738
|
+
markdown += `📄 **${labels.fileLabel}:** \`${rel}\`\n`;
|
|
739
|
+
if (maxLinesRangeMismatch) {
|
|
740
|
+
markdown += labels.warningTruncatedByMaxLinesWithRange(maxLinesRangeMismatch.maxLines, maxLinesRangeMismatch.rangeLines, maxLinesRangeMismatch.used);
|
|
741
|
+
}
|
|
742
|
+
if (contentSummary.truncatedByCharLimit) {
|
|
743
|
+
markdown += labels.warningTruncatedByCharLimit(contentSummary.shownLines, READ_FILE_CONTENT_CHAR_LIMIT);
|
|
744
|
+
}
|
|
745
|
+
else if (contentSummary.truncatedByMaxLines && !maxLinesRangeMismatch) {
|
|
746
|
+
markdown += labels.warningTruncatedByMaxLines(contentSummary.shownLines, options.maxLines);
|
|
747
|
+
}
|
|
748
|
+
if ((contentSummary.truncatedByCharLimit || contentSummary.truncatedByMaxLines) &&
|
|
749
|
+
!flags.maxLinesSpecified &&
|
|
750
|
+
!flags.rangeSpecified) {
|
|
751
|
+
const start = contentSummary.shownLines + 1;
|
|
752
|
+
const end = start + 199;
|
|
753
|
+
markdown += labels.hintUseRangeNext(rel, start, end);
|
|
754
|
+
}
|
|
755
|
+
if (contentSummary.truncatedByCharLimit) {
|
|
756
|
+
markdown += labels.hintLargeFileStrategy(rel);
|
|
757
|
+
}
|
|
758
|
+
markdown += `**${labels.sizeLabel}:** ${stat.size} bytes\n`;
|
|
759
|
+
markdown += `**${labels.totalLinesLabel}:** ${contentSummary.totalLines}\n`;
|
|
760
|
+
if (contentSummary.totalLines === 0) {
|
|
761
|
+
markdown += `\n${labels.emptyFileLabel}\n`;
|
|
762
|
+
}
|
|
763
|
+
markdown += '\n';
|
|
764
|
+
if (contentSummary.totalLines > 0) {
|
|
765
|
+
// Add file content with code block formatting
|
|
766
|
+
markdown += '```\n';
|
|
767
|
+
markdown += contentSummary.formattedContent;
|
|
768
|
+
if (!contentSummary.formattedContent.endsWith('\n')) {
|
|
769
|
+
markdown += '\n';
|
|
770
|
+
}
|
|
771
|
+
markdown += '```';
|
|
772
|
+
}
|
|
773
|
+
return markdown;
|
|
774
|
+
}
|
|
775
|
+
catch (error) {
|
|
776
|
+
if (error instanceof Error &&
|
|
777
|
+
(error.message === 'Invalid format' || error.message === 'Path required')) {
|
|
778
|
+
const content = labels.formatError;
|
|
779
|
+
return content;
|
|
780
|
+
}
|
|
781
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
782
|
+
const content = labels.failedToRead(msg);
|
|
783
|
+
return content;
|
|
784
|
+
}
|
|
785
|
+
},
|
|
786
|
+
};
|
|
787
|
+
const overwriteEntireFileSchema = {
|
|
788
|
+
type: 'object',
|
|
789
|
+
additionalProperties: false,
|
|
790
|
+
properties: {
|
|
791
|
+
path: {
|
|
792
|
+
type: 'string',
|
|
793
|
+
description: 'Workspace-relative path to an existing file to overwrite.',
|
|
794
|
+
},
|
|
795
|
+
known_old_total_lines: {
|
|
796
|
+
type: 'integer',
|
|
797
|
+
description: 'Expected old total line count of the file (0 for empty). Used as an overwrite guardrail.',
|
|
798
|
+
},
|
|
799
|
+
known_old_total_bytes: {
|
|
800
|
+
type: 'integer',
|
|
801
|
+
description: 'Expected old total bytes of the file as reported by stat().size. Used as an overwrite guardrail.',
|
|
802
|
+
},
|
|
803
|
+
content: {
|
|
804
|
+
type: 'string',
|
|
805
|
+
description: 'The new full file content. If non-empty and missing a trailing newline, Dominds will append one.',
|
|
806
|
+
},
|
|
807
|
+
content_format: {
|
|
808
|
+
type: 'string',
|
|
809
|
+
description: "Optional content format hint. If omitted (or empty string), Dominds refuses to overwrite when content looks like a diff/patch (use prepare/apply instead). Use 'diff' or 'patch' to explicitly allow writing diff/patch text literally.",
|
|
810
|
+
},
|
|
811
|
+
},
|
|
812
|
+
required: ['path', 'known_old_total_lines', 'known_old_total_bytes', 'content'],
|
|
813
|
+
};
|
|
814
|
+
function parseCreateNewFileArgs(args) {
|
|
815
|
+
const pathValue = args['path'];
|
|
816
|
+
if (typeof pathValue !== 'string' || pathValue.trim() === '') {
|
|
817
|
+
throw new Error('Invalid `path` (expected non-empty string)');
|
|
818
|
+
}
|
|
819
|
+
const contentValue = args['content'];
|
|
820
|
+
if (contentValue === undefined) {
|
|
821
|
+
return { path: pathValue, content: '' };
|
|
822
|
+
}
|
|
823
|
+
if (typeof contentValue !== 'string') {
|
|
824
|
+
throw new Error('Invalid `content` (expected string)');
|
|
825
|
+
}
|
|
826
|
+
return { path: pathValue, content: contentValue };
|
|
827
|
+
}
|
|
828
|
+
function parseOverwriteContentFormat(value) {
|
|
829
|
+
if (value === undefined)
|
|
830
|
+
return undefined;
|
|
831
|
+
if (typeof value !== 'string')
|
|
832
|
+
return undefined;
|
|
833
|
+
if (value.trim() === '')
|
|
834
|
+
return undefined;
|
|
835
|
+
switch (value) {
|
|
836
|
+
case 'text':
|
|
837
|
+
case 'markdown':
|
|
838
|
+
case 'json':
|
|
839
|
+
case 'diff':
|
|
840
|
+
case 'patch':
|
|
841
|
+
return value;
|
|
842
|
+
default:
|
|
843
|
+
return undefined;
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
function parseOverwriteEntireFileArgs(args) {
|
|
847
|
+
const pathValue = args['path'];
|
|
848
|
+
if (typeof pathValue !== 'string' || pathValue.trim() === '') {
|
|
849
|
+
throw new Error('Invalid `path` (expected non-empty string)');
|
|
850
|
+
}
|
|
851
|
+
const knownOldTotalLinesValue = args['known_old_total_lines'];
|
|
852
|
+
if (typeof knownOldTotalLinesValue !== 'number' || !Number.isInteger(knownOldTotalLinesValue)) {
|
|
853
|
+
throw new Error('Invalid `known_old_total_lines` (expected integer)');
|
|
854
|
+
}
|
|
855
|
+
if (knownOldTotalLinesValue < 0) {
|
|
856
|
+
throw new Error('Invalid `known_old_total_lines` (must be >= 0)');
|
|
857
|
+
}
|
|
858
|
+
const knownOldTotalBytesValue = args['known_old_total_bytes'];
|
|
859
|
+
if (typeof knownOldTotalBytesValue !== 'number' || !Number.isInteger(knownOldTotalBytesValue)) {
|
|
860
|
+
throw new Error('Invalid `known_old_total_bytes` (expected integer)');
|
|
861
|
+
}
|
|
862
|
+
if (knownOldTotalBytesValue < 0) {
|
|
863
|
+
throw new Error('Invalid `known_old_total_bytes` (must be >= 0)');
|
|
864
|
+
}
|
|
865
|
+
const contentValue = args['content'];
|
|
866
|
+
if (typeof contentValue !== 'string') {
|
|
867
|
+
throw new Error('Invalid `content` (expected string)');
|
|
868
|
+
}
|
|
869
|
+
const rawContentFormat = args['content_format'];
|
|
870
|
+
let contentFormat;
|
|
871
|
+
if (rawContentFormat === undefined) {
|
|
872
|
+
contentFormat = undefined;
|
|
873
|
+
}
|
|
874
|
+
else if (typeof rawContentFormat === 'string') {
|
|
875
|
+
if (rawContentFormat.trim() === '') {
|
|
876
|
+
contentFormat = undefined;
|
|
877
|
+
}
|
|
878
|
+
else {
|
|
879
|
+
contentFormat = parseOverwriteContentFormat(rawContentFormat);
|
|
880
|
+
if (contentFormat === undefined) {
|
|
881
|
+
throw new Error('Invalid `content_format` (expected one of: text, markdown, json, diff, patch)');
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
else {
|
|
886
|
+
throw new Error('Invalid `content_format` (expected string)');
|
|
887
|
+
}
|
|
888
|
+
return {
|
|
889
|
+
path: pathValue,
|
|
890
|
+
// Guardrails are expected to come from `read_file`'s YAML header:
|
|
891
|
+
// - `total_lines` → known_old_total_lines
|
|
892
|
+
// - `size_bytes` → known_old_total_bytes
|
|
893
|
+
knownOldTotalLines: knownOldTotalLinesValue,
|
|
894
|
+
knownOldTotalBytes: knownOldTotalBytesValue,
|
|
895
|
+
content: contentValue,
|
|
896
|
+
contentFormat,
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
exports.createNewFileTool = {
|
|
900
|
+
type: 'func',
|
|
901
|
+
name: 'create_new_file',
|
|
902
|
+
description: 'Create a new file (no prepare/apply). Refuses to overwrite existing files.',
|
|
903
|
+
descriptionI18n: {
|
|
904
|
+
en: 'Create a new file (no prepare/apply). Refuses to overwrite existing files.',
|
|
905
|
+
zh: '创建一个新文件(不走 prepare/apply)。若文件已存在则拒绝覆写。',
|
|
906
|
+
},
|
|
907
|
+
parameters: {
|
|
908
|
+
type: 'object',
|
|
909
|
+
additionalProperties: false,
|
|
910
|
+
properties: {
|
|
911
|
+
path: { type: 'string', description: 'Workspace-relative path to create.' },
|
|
912
|
+
content: {
|
|
913
|
+
type: 'string',
|
|
914
|
+
description: 'Optional initial content. Empty string is allowed. If non-empty and missing a trailing newline, Dominds will append one.',
|
|
915
|
+
},
|
|
916
|
+
},
|
|
917
|
+
required: ['path'],
|
|
918
|
+
},
|
|
919
|
+
argsValidation: 'dominds',
|
|
920
|
+
call: async (_dlg, caller, args) => {
|
|
921
|
+
const language = (0, runtime_language_1.getWorkLanguage)();
|
|
922
|
+
const t = language === 'zh'
|
|
923
|
+
? {
|
|
924
|
+
invalidArgs: (msg) => `参数不正确:${msg}`,
|
|
925
|
+
fileExists: '文件已存在,拒绝创建。',
|
|
926
|
+
notAFile: '路径已存在但不是文件(可能是目录),拒绝创建。',
|
|
927
|
+
nextOverwrite: '下一步:先用 read_file 获取 total_lines/size_bytes,然后再调用 overwrite_entire_file 覆盖写入。',
|
|
928
|
+
ok: '已创建新文件。',
|
|
929
|
+
}
|
|
930
|
+
: {
|
|
931
|
+
invalidArgs: (msg) => `Invalid args: ${msg}`,
|
|
932
|
+
fileExists: 'File already exists; refusing to create.',
|
|
933
|
+
notAFile: 'Path exists but is not a file (e.g. a directory); refusing to create.',
|
|
934
|
+
nextOverwrite: 'Next: call read_file to get total_lines/size_bytes, then use overwrite_entire_file to overwrite.',
|
|
935
|
+
ok: 'Created new file.',
|
|
936
|
+
};
|
|
937
|
+
const parsed = (() => {
|
|
938
|
+
try {
|
|
939
|
+
return parseCreateNewFileArgs(args);
|
|
940
|
+
}
|
|
941
|
+
catch (err) {
|
|
942
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
943
|
+
return { __error: msg };
|
|
944
|
+
}
|
|
945
|
+
})();
|
|
946
|
+
if ('__error' in parsed) {
|
|
947
|
+
return formatYamlCodeBlock([
|
|
948
|
+
`status: error`,
|
|
949
|
+
`mode: create_new_file`,
|
|
950
|
+
`error: INVALID_ARGS`,
|
|
951
|
+
`summary: ${yamlQuote(t.invalidArgs(parsed.__error))}`,
|
|
952
|
+
].join('\n'));
|
|
953
|
+
}
|
|
954
|
+
if (!(0, access_control_1.hasWriteAccess)(caller, parsed.path)) {
|
|
955
|
+
return (0, access_control_1.getAccessDeniedMessage)('write', parsed.path, language);
|
|
956
|
+
}
|
|
957
|
+
let absPath;
|
|
958
|
+
try {
|
|
959
|
+
absPath = ensureInsideWorkspace(parsed.path);
|
|
960
|
+
}
|
|
961
|
+
catch (err) {
|
|
962
|
+
return formatYamlCodeBlock([
|
|
963
|
+
`status: error`,
|
|
964
|
+
`mode: create_new_file`,
|
|
965
|
+
`path: ${yamlQuote(parsed.path)}`,
|
|
966
|
+
`error: INVALID_PATH`,
|
|
967
|
+
`summary: ${yamlQuote(err instanceof Error ? err.message : String(err))}`,
|
|
968
|
+
].join('\n'));
|
|
969
|
+
}
|
|
970
|
+
if (fs_1.default.existsSync(absPath)) {
|
|
971
|
+
let s;
|
|
972
|
+
try {
|
|
973
|
+
s = fs_1.default.statSync(absPath);
|
|
974
|
+
}
|
|
975
|
+
catch (err) {
|
|
976
|
+
return formatYamlCodeBlock([
|
|
977
|
+
`status: error`,
|
|
978
|
+
`mode: create_new_file`,
|
|
979
|
+
`path: ${yamlQuote(parsed.path)}`,
|
|
980
|
+
`error: FAILED`,
|
|
981
|
+
`summary: ${yamlQuote(err instanceof Error ? err.message : String(err))}`,
|
|
982
|
+
].join('\n'));
|
|
983
|
+
}
|
|
984
|
+
if (!s.isFile()) {
|
|
985
|
+
return formatYamlCodeBlock([
|
|
986
|
+
`status: error`,
|
|
987
|
+
`mode: create_new_file`,
|
|
988
|
+
`path: ${yamlQuote(parsed.path)}`,
|
|
989
|
+
`error: NOT_A_FILE`,
|
|
990
|
+
`summary: ${yamlQuote(t.notAFile)}`,
|
|
991
|
+
].join('\n'));
|
|
992
|
+
}
|
|
993
|
+
return formatYamlCodeBlock([
|
|
994
|
+
`status: error`,
|
|
995
|
+
`mode: create_new_file`,
|
|
996
|
+
`path: ${yamlQuote(parsed.path)}`,
|
|
997
|
+
`error: FILE_EXISTS`,
|
|
998
|
+
`summary: ${yamlQuote(t.fileExists)}`,
|
|
999
|
+
`next: ${yamlQuote(t.nextOverwrite)}`,
|
|
1000
|
+
].join('\n'));
|
|
1001
|
+
}
|
|
1002
|
+
const { normalizedBody, addedTrailingNewlineToContent } = normalizeFileWriteBody(parsed.content);
|
|
1003
|
+
try {
|
|
1004
|
+
fs_1.default.mkdirSync(path_1.default.dirname(absPath), { recursive: true });
|
|
1005
|
+
fs_1.default.writeFileSync(absPath, normalizedBody, 'utf8');
|
|
1006
|
+
}
|
|
1007
|
+
catch (err) {
|
|
1008
|
+
return formatYamlCodeBlock([
|
|
1009
|
+
`status: error`,
|
|
1010
|
+
`mode: create_new_file`,
|
|
1011
|
+
`path: ${yamlQuote(parsed.path)}`,
|
|
1012
|
+
`error: FAILED`,
|
|
1013
|
+
`summary: ${yamlQuote(err instanceof Error ? err.message : String(err))}`,
|
|
1014
|
+
].join('\n'));
|
|
1015
|
+
}
|
|
1016
|
+
const newTotalBytes = Buffer.byteLength(normalizedBody, 'utf8');
|
|
1017
|
+
const newTotalLines = splitTextToLinesForEditing(normalizedBody).length;
|
|
1018
|
+
const normalizedNewlineAdded = addedTrailingNewlineToContent && normalizedBody !== '';
|
|
1019
|
+
const okSummary = language === 'zh'
|
|
1020
|
+
? `${t.ok} path=${parsed.path}; new_total_lines=${newTotalLines}; new_total_bytes=${newTotalBytes}.`
|
|
1021
|
+
: `${t.ok} path=${parsed.path}; new_total_lines=${newTotalLines}; new_total_bytes=${newTotalBytes}.`;
|
|
1022
|
+
return formatYamlCodeBlock([
|
|
1023
|
+
`status: ok`,
|
|
1024
|
+
`mode: create_new_file`,
|
|
1025
|
+
`path: ${yamlQuote(parsed.path)}`,
|
|
1026
|
+
`new_total_lines: ${newTotalLines}`,
|
|
1027
|
+
`new_total_bytes: ${newTotalBytes}`,
|
|
1028
|
+
`normalized_trailing_newline_added: ${normalizedNewlineAdded}`,
|
|
1029
|
+
`summary: ${yamlQuote(okSummary)}`,
|
|
1030
|
+
].join('\n'));
|
|
1031
|
+
},
|
|
1032
|
+
};
|
|
1033
|
+
exports.overwriteEntireFileTool = {
|
|
1034
|
+
type: 'func',
|
|
1035
|
+
name: 'overwrite_entire_file',
|
|
1036
|
+
description: 'Overwrite an existing file with new full content (guarded by known_old_total_lines/bytes; refuses diff/patch-like content unless content_format is diff|patch).',
|
|
1037
|
+
descriptionI18n: {
|
|
1038
|
+
en: 'Overwrite an existing file with new full content (guarded by known_old_total_lines/bytes; refuses diff/patch-like content unless content_format is diff|patch).',
|
|
1039
|
+
zh: '整体覆盖写入一个已存在的文件(需要 known_old_total_lines/bytes 对账;若正文疑似 diff/patch 且未显式声明 content_format=diff|patch,则默认拒绝)。',
|
|
1040
|
+
},
|
|
1041
|
+
parameters: overwriteEntireFileSchema,
|
|
1042
|
+
argsValidation: 'dominds',
|
|
1043
|
+
call: async (_dlg, caller, args) => {
|
|
1044
|
+
const language = (0, runtime_language_1.getWorkLanguage)();
|
|
1045
|
+
const t = language === 'zh'
|
|
1046
|
+
? {
|
|
1047
|
+
invalidArgs: (msg) => `参数不正确:${msg}`,
|
|
1048
|
+
fileNotFound: '文件不存在;创建文件请使用 prepare/apply(例如 prepare_file_append create=true)。',
|
|
1049
|
+
notAFile: '路径不是文件。',
|
|
1050
|
+
statsMismatch: '旧文件快照不匹配,拒绝覆盖写入。',
|
|
1051
|
+
nextRefreshStats: '下一步:先 read_file 获取最新 total_lines/size_bytes,再重试。',
|
|
1052
|
+
suspiciousDiff: '检测到疑似 diff/patch 正文,且未显式声明 content_format;为避免把 patch 文本误写进文件,默认拒绝。',
|
|
1053
|
+
nextUsePreviewApply: '下一步:改用 prepare_* → apply_file_modification;或若确实要保存 diff/patch 字面量,请设置 content_format=diff|patch。',
|
|
1054
|
+
ok: '已覆盖写入。',
|
|
1055
|
+
}
|
|
1056
|
+
: {
|
|
1057
|
+
invalidArgs: (msg) => `Invalid args: ${msg}`,
|
|
1058
|
+
fileNotFound: 'File not found; to create a file, use prepare/apply (e.g. prepare_file_append create=true).',
|
|
1059
|
+
notAFile: 'Path is not a file.',
|
|
1060
|
+
statsMismatch: 'known_old_total_lines/bytes mismatch; refusing to overwrite.',
|
|
1061
|
+
nextRefreshStats: 'Next: call read_file to refresh total_lines/size_bytes, then retry.',
|
|
1062
|
+
suspiciousDiff: 'Content looks like a diff/patch, but content_format was not provided; rejected by default to prevent accidental overwrites.',
|
|
1063
|
+
nextUsePreviewApply: "Next: use prepare_* → apply_file_modification; or if you intentionally want to store diff/patch text literally, set content_format='diff'|'patch'.",
|
|
1064
|
+
ok: 'Overwrote file.',
|
|
1065
|
+
};
|
|
1066
|
+
const parsed = (() => {
|
|
1067
|
+
try {
|
|
1068
|
+
return parseOverwriteEntireFileArgs(args);
|
|
1069
|
+
}
|
|
1070
|
+
catch (err) {
|
|
1071
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1072
|
+
return { __error: msg };
|
|
1073
|
+
}
|
|
1074
|
+
})();
|
|
1075
|
+
if ('__error' in parsed) {
|
|
1076
|
+
return formatYamlCodeBlock([
|
|
1077
|
+
`status: error`,
|
|
1078
|
+
`mode: overwrite_entire_file`,
|
|
1079
|
+
`error: INVALID_ARGS`,
|
|
1080
|
+
`summary: ${yamlQuote(t.invalidArgs(parsed.__error))}`,
|
|
1081
|
+
].join('\n'));
|
|
1082
|
+
}
|
|
1083
|
+
if (!(0, access_control_1.hasWriteAccess)(caller, parsed.path)) {
|
|
1084
|
+
return (0, access_control_1.getAccessDeniedMessage)('write', parsed.path, language);
|
|
1085
|
+
}
|
|
1086
|
+
let absPath;
|
|
1087
|
+
try {
|
|
1088
|
+
absPath = ensureInsideWorkspace(parsed.path);
|
|
1089
|
+
}
|
|
1090
|
+
catch (err) {
|
|
1091
|
+
return formatYamlCodeBlock([
|
|
1092
|
+
`status: error`,
|
|
1093
|
+
`mode: overwrite_entire_file`,
|
|
1094
|
+
`path: ${yamlQuote(parsed.path)}`,
|
|
1095
|
+
`error: INVALID_PATH`,
|
|
1096
|
+
`summary: ${yamlQuote(err instanceof Error ? err.message : String(err))}`,
|
|
1097
|
+
].join('\n'));
|
|
1098
|
+
}
|
|
1099
|
+
let s;
|
|
1100
|
+
try {
|
|
1101
|
+
s = fs_1.default.statSync(absPath);
|
|
1102
|
+
}
|
|
1103
|
+
catch (err) {
|
|
1104
|
+
if (typeof err === 'object' &&
|
|
1105
|
+
err !== null &&
|
|
1106
|
+
'code' in err &&
|
|
1107
|
+
err.code === 'ENOENT') {
|
|
1108
|
+
return formatYamlCodeBlock([
|
|
1109
|
+
`status: error`,
|
|
1110
|
+
`mode: overwrite_entire_file`,
|
|
1111
|
+
`path: ${yamlQuote(parsed.path)}`,
|
|
1112
|
+
`error: FILE_NOT_FOUND`,
|
|
1113
|
+
`summary: ${yamlQuote(t.fileNotFound)}`,
|
|
1114
|
+
].join('\n'));
|
|
1115
|
+
}
|
|
1116
|
+
return formatYamlCodeBlock([
|
|
1117
|
+
`status: error`,
|
|
1118
|
+
`mode: overwrite_entire_file`,
|
|
1119
|
+
`path: ${yamlQuote(parsed.path)}`,
|
|
1120
|
+
`error: FAILED`,
|
|
1121
|
+
`summary: ${yamlQuote(err instanceof Error ? err.message : String(err))}`,
|
|
1122
|
+
].join('\n'));
|
|
1123
|
+
}
|
|
1124
|
+
if (!s.isFile()) {
|
|
1125
|
+
return formatYamlCodeBlock([
|
|
1126
|
+
`status: error`,
|
|
1127
|
+
`mode: overwrite_entire_file`,
|
|
1128
|
+
`path: ${yamlQuote(parsed.path)}`,
|
|
1129
|
+
`error: NOT_A_FILE`,
|
|
1130
|
+
`summary: ${yamlQuote(t.notAFile)}`,
|
|
1131
|
+
].join('\n'));
|
|
1132
|
+
}
|
|
1133
|
+
const actualOldTotalBytes = s.size;
|
|
1134
|
+
let actualOldTotalLines;
|
|
1135
|
+
try {
|
|
1136
|
+
actualOldTotalLines = await countFileLinesUtf8(absPath);
|
|
1137
|
+
}
|
|
1138
|
+
catch (err) {
|
|
1139
|
+
return formatYamlCodeBlock([
|
|
1140
|
+
`status: error`,
|
|
1141
|
+
`mode: overwrite_entire_file`,
|
|
1142
|
+
`path: ${yamlQuote(parsed.path)}`,
|
|
1143
|
+
`error: FAILED`,
|
|
1144
|
+
`summary: ${yamlQuote(err instanceof Error ? err.message : String(err))}`,
|
|
1145
|
+
].join('\n'));
|
|
1146
|
+
}
|
|
1147
|
+
if (parsed.knownOldTotalBytes !== actualOldTotalBytes ||
|
|
1148
|
+
parsed.knownOldTotalLines !== actualOldTotalLines) {
|
|
1149
|
+
return formatYamlCodeBlock([
|
|
1150
|
+
`status: error`,
|
|
1151
|
+
`mode: overwrite_entire_file`,
|
|
1152
|
+
`path: ${yamlQuote(parsed.path)}`,
|
|
1153
|
+
`error: STATS_MISMATCH`,
|
|
1154
|
+
`known_old_total_lines: ${parsed.knownOldTotalLines}`,
|
|
1155
|
+
`known_old_total_bytes: ${parsed.knownOldTotalBytes}`,
|
|
1156
|
+
`actual_old_total_lines: ${actualOldTotalLines}`,
|
|
1157
|
+
`actual_old_total_bytes: ${actualOldTotalBytes}`,
|
|
1158
|
+
`summary: ${yamlQuote(t.statsMismatch)}`,
|
|
1159
|
+
`next: ${yamlQuote(t.nextRefreshStats)}`,
|
|
1160
|
+
].join('\n'));
|
|
1161
|
+
}
|
|
1162
|
+
if (parsed.contentFormat !== 'diff' && parsed.contentFormat !== 'patch') {
|
|
1163
|
+
// Only refuse when content_format is omitted (or a non-diff format), and content is strongly diff-like.
|
|
1164
|
+
if (detectStrongDiffOrPatchMarkers(parsed.content)) {
|
|
1165
|
+
return formatYamlCodeBlock([
|
|
1166
|
+
`status: error`,
|
|
1167
|
+
`mode: overwrite_entire_file`,
|
|
1168
|
+
`path: ${yamlQuote(parsed.path)}`,
|
|
1169
|
+
`error: SUSPICIOUS_DIFF`,
|
|
1170
|
+
`content_format: ${yamlQuote(parsed.contentFormat ?? '')}`,
|
|
1171
|
+
`summary: ${yamlQuote(t.suspiciousDiff)}`,
|
|
1172
|
+
`next: ${yamlQuote(t.nextUsePreviewApply)}`,
|
|
1173
|
+
].join('\n'));
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
const { normalizedBody, addedTrailingNewlineToContent } = normalizeFileWriteBody(parsed.content);
|
|
1177
|
+
try {
|
|
1178
|
+
await promises_1.default.writeFile(absPath, normalizedBody, 'utf8');
|
|
1179
|
+
}
|
|
1180
|
+
catch (err) {
|
|
1181
|
+
return formatYamlCodeBlock([
|
|
1182
|
+
`status: error`,
|
|
1183
|
+
`mode: overwrite_entire_file`,
|
|
1184
|
+
`path: ${yamlQuote(parsed.path)}`,
|
|
1185
|
+
`error: FAILED`,
|
|
1186
|
+
`summary: ${yamlQuote(err instanceof Error ? err.message : String(err))}`,
|
|
1187
|
+
].join('\n'));
|
|
1188
|
+
}
|
|
1189
|
+
const newTotalBytes = Buffer.byteLength(normalizedBody, 'utf8');
|
|
1190
|
+
const newTotalLines = splitTextToLinesForEditing(normalizedBody).length;
|
|
1191
|
+
const normalizedNewlineAdded = addedTrailingNewlineToContent && normalizedBody !== '';
|
|
1192
|
+
const okSummary = language === 'zh'
|
|
1193
|
+
? `${t.ok} path=${parsed.path}; new_total_lines=${newTotalLines}; new_total_bytes=${newTotalBytes}.`
|
|
1194
|
+
: `${t.ok} path=${parsed.path}; new_total_lines=${newTotalLines}; new_total_bytes=${newTotalBytes}.`;
|
|
1195
|
+
return formatYamlCodeBlock([
|
|
1196
|
+
`status: ok`,
|
|
1197
|
+
`mode: overwrite_entire_file`,
|
|
1198
|
+
`path: ${yamlQuote(parsed.path)}`,
|
|
1199
|
+
`known_old_total_lines: ${parsed.knownOldTotalLines}`,
|
|
1200
|
+
`known_old_total_bytes: ${parsed.knownOldTotalBytes}`,
|
|
1201
|
+
`new_total_lines: ${newTotalLines}`,
|
|
1202
|
+
`new_total_bytes: ${newTotalBytes}`,
|
|
1203
|
+
`normalized_trailing_newline_added: ${normalizedNewlineAdded}`,
|
|
1204
|
+
`content_format: ${yamlQuote(parsed.contentFormat ?? '')}`,
|
|
1205
|
+
`summary: ${yamlQuote(okSummary)}`,
|
|
1206
|
+
].join('\n'));
|
|
1207
|
+
},
|
|
1208
|
+
};
|
|
1209
|
+
async function runPrepareFileRangeEdit(caller, filePath, rangeSpec, requestedId, inputBody) {
|
|
1210
|
+
const language = (0, runtime_language_1.getWorkLanguage)();
|
|
1211
|
+
const labels = language === 'zh'
|
|
1212
|
+
? {
|
|
1213
|
+
invalidFormat: '错误:参数不正确。\n\n期望:调用函数工具 `prepare_file_range_edit({ path, range, existing_hunk_id, content })`。\n(注意:大多数 provider 可省略可选字段;但如果你的 provider 要求“字段全必填”(例如 Codex),则:`existing_hunk_id: ""` 表示生成新 hunk;`content: ""` 可用于删除范围内内容。)',
|
|
1214
|
+
filePathRequired: '错误:需要提供文件路径。',
|
|
1215
|
+
rangeRequired: '错误:需要提供行号范围(例如 10~20 或 ~)。',
|
|
1216
|
+
fileDoesNotExist: (p) => `错误:文件 \`${p}\` 不存在。`,
|
|
1217
|
+
planned: (id, p) => `✅ 已规划:\`${id}\` → \`${p}\``,
|
|
1218
|
+
next: (id) => `下一步:调用函数工具 \`apply_file_modification\`,参数:{ \"hunk_id\": \"${id}\" }`,
|
|
1219
|
+
invalidHunkId: '错误:hunk id 格式无效(例如 `a1b2c3d4`)。',
|
|
1220
|
+
unknownHunkId: (id) => `错误:hunk id \`${id}\` 不存在(可能已过期/已被应用)。不支持自定义新 id;要生成新 id,请将 \`existing_hunk_id\` 设为空字符串。`,
|
|
1221
|
+
wrongOwner: (id) => `错误:hunk id \`${id}\` 不是由当前成员规划的,不能覆写。`,
|
|
1222
|
+
planFailed: (msg) => `错误:生成修改规划失败:${msg}`,
|
|
1223
|
+
}
|
|
1224
|
+
: {
|
|
1225
|
+
invalidFormat: 'Error: Invalid args.\n\nExpected: call the function tool `prepare_file_range_edit({ path, range, existing_hunk_id, content })`.\n(Note: most providers can omit optional fields; but if your provider requires “all fields present” (e.g. Codex): `existing_hunk_id: ""` means generate a new hunk; `content: ""` can be used to delete the range.)',
|
|
1226
|
+
filePathRequired: 'Error: File path is required.',
|
|
1227
|
+
rangeRequired: 'Error: Line range is required (e.g. 10~20 or ~).',
|
|
1228
|
+
fileDoesNotExist: (p) => `Error: File \`${p}\` does not exist.`,
|
|
1229
|
+
planned: (id, p) => `✅ Planned \`${id}\` for \`${p}\``,
|
|
1230
|
+
next: (id) => `Next: call function tool \`apply_file_modification\` with { \"hunk_id\": \"${id}\" }.`,
|
|
1231
|
+
invalidHunkId: 'Error: invalid hunk id format (e.g. `a1b2c3d4`).',
|
|
1232
|
+
unknownHunkId: (id) => `Error: hunk id \`${id}\` not found (expired or already applied). Custom new ids are not allowed; set \`existing_hunk_id\` to an empty string to generate a new one.`,
|
|
1233
|
+
wrongOwner: (id) => `Error: hunk id \`${id}\` was planned by a different member; cannot overwrite.`,
|
|
1234
|
+
planFailed: (msg) => `Error planning modification: ${msg}`,
|
|
1235
|
+
};
|
|
1236
|
+
if (!filePath) {
|
|
1237
|
+
const content = labels.filePathRequired;
|
|
1238
|
+
return wrapTxtToolResult(language, [{ type: 'environment_msg', role: 'user', content }]);
|
|
1239
|
+
}
|
|
1240
|
+
if (!rangeSpec) {
|
|
1241
|
+
const content = labels.rangeRequired;
|
|
1242
|
+
return wrapTxtToolResult(language, [{ type: 'environment_msg', role: 'user', content }]);
|
|
1243
|
+
}
|
|
1244
|
+
if (requestedId !== undefined && !isValidHunkId(requestedId)) {
|
|
1245
|
+
const content = labels.invalidHunkId;
|
|
1246
|
+
return wrapTxtToolResult(language, [{ type: 'environment_msg', role: 'user', content }]);
|
|
1247
|
+
}
|
|
1248
|
+
if (!(0, access_control_1.hasWriteAccess)(caller, filePath)) {
|
|
1249
|
+
const content = (0, access_control_1.getAccessDeniedMessage)('write', filePath, language);
|
|
1250
|
+
return wrapTxtToolResult(language, [{ type: 'environment_msg', role: 'user', content }]);
|
|
1251
|
+
}
|
|
1252
|
+
try {
|
|
1253
|
+
pruneExpiredPlannedMods(Date.now());
|
|
1254
|
+
pruneExpiredPlannedBlockReplaces(Date.now());
|
|
1255
|
+
const fullPath = ensureInsideWorkspace(filePath);
|
|
1256
|
+
if (requestedId) {
|
|
1257
|
+
const existing = plannedModsById.get(requestedId);
|
|
1258
|
+
if (!existing) {
|
|
1259
|
+
const content = labels.unknownHunkId(requestedId);
|
|
1260
|
+
return wrapTxtToolResult(language, [{ type: 'environment_msg', role: 'user', content }]);
|
|
1261
|
+
}
|
|
1262
|
+
if (existing.plannedBy !== caller.id) {
|
|
1263
|
+
const content = labels.wrongOwner(requestedId);
|
|
1264
|
+
return wrapTxtToolResult(language, [{ type: 'environment_msg', role: 'user', content }]);
|
|
1265
|
+
}
|
|
1266
|
+
if (existing.kind !== 'range') {
|
|
1267
|
+
const content = language === 'zh'
|
|
1268
|
+
? `错误:hunk id \`${requestedId}\` 不是由 prepare_file_range_edit 生成的,不能用该工具覆写。`
|
|
1269
|
+
: `Error: hunk id \`${requestedId}\` was not generated by prepare_file_range_edit; cannot overwrite with this tool.`;
|
|
1270
|
+
return wrapTxtToolResult(language, [{ type: 'environment_msg', role: 'user', content }]);
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
if (!fs_1.default.existsSync(fullPath)) {
|
|
1274
|
+
const content = labels.fileDoesNotExist(filePath);
|
|
1275
|
+
return wrapTxtToolResult(language, [{ type: 'environment_msg', role: 'user', content }]);
|
|
1276
|
+
}
|
|
1277
|
+
const currentContent = fs_1.default.readFileSync(fullPath, 'utf8');
|
|
1278
|
+
const currentLines = splitFileTextToLines(currentContent);
|
|
1279
|
+
const totalLines = rangeTotalLines(currentLines);
|
|
1280
|
+
const parsed = parseLineRangeSpec(rangeSpec, totalLines);
|
|
1281
|
+
if (!parsed.ok) {
|
|
1282
|
+
const content = language === 'zh'
|
|
1283
|
+
? `错误:行号范围无效:${parsed.error}`
|
|
1284
|
+
: `Error: invalid line range: ${parsed.error}`;
|
|
1285
|
+
return wrapTxtToolResult(language, [{ type: 'environment_msg', role: 'user', content }]);
|
|
1286
|
+
}
|
|
1287
|
+
const range = parsed.range;
|
|
1288
|
+
const startIndex0 = range.kind === 'append' ? totalLines : range.startLine - 1;
|
|
1289
|
+
const deleteCount = range.kind === 'append' ? 0 : range.endLine - range.startLine + 1;
|
|
1290
|
+
const newLines = splitPlannedBodyLines(inputBody);
|
|
1291
|
+
const oldLines = currentLines.slice(startIndex0, startIndex0 + deleteCount);
|
|
1292
|
+
const { contextBefore, contextAfter } = computeContextWindow(currentLines, startIndex0, deleteCount);
|
|
1293
|
+
const unifiedDiff = buildUnifiedSingleHunkDiff(filePath, currentLines, startIndex0, deleteCount, newLines);
|
|
1294
|
+
const nowMs = Date.now();
|
|
1295
|
+
const action = range.kind === 'append' ? 'append' : newLines.length === 0 ? 'delete' : 'replace';
|
|
1296
|
+
const hunkId = (() => {
|
|
1297
|
+
if (requestedId)
|
|
1298
|
+
return requestedId;
|
|
1299
|
+
for (let i = 0; i < 10; i += 1) {
|
|
1300
|
+
const id = generateHunkId();
|
|
1301
|
+
if (!plannedModsById.has(id) && !plannedBlockReplacesById.has(id))
|
|
1302
|
+
return id;
|
|
1303
|
+
}
|
|
1304
|
+
throw new Error('Failed to generate a unique hunk id');
|
|
1305
|
+
})();
|
|
1306
|
+
const planned = {
|
|
1307
|
+
kind: 'range',
|
|
1308
|
+
action,
|
|
1309
|
+
hunkId,
|
|
1310
|
+
plannedBy: caller.id,
|
|
1311
|
+
createdAtMs: nowMs,
|
|
1312
|
+
expiresAtMs: nowMs + PLANNED_MOD_TTL_MS,
|
|
1313
|
+
relPath: filePath,
|
|
1314
|
+
absPath: fullPath,
|
|
1315
|
+
range,
|
|
1316
|
+
startIndex0,
|
|
1317
|
+
deleteCount,
|
|
1318
|
+
contextBefore,
|
|
1319
|
+
contextAfter,
|
|
1320
|
+
oldLines,
|
|
1321
|
+
newLines,
|
|
1322
|
+
unifiedDiff,
|
|
1323
|
+
plannedFileDigestSha256: sha256HexUtf8(currentContent),
|
|
1324
|
+
};
|
|
1325
|
+
plannedModsById.set(hunkId, planned);
|
|
1326
|
+
const rangeLabel = range.kind === 'append' ? `${range.startLine}~` : `${range.startLine}~${range.endLine}`;
|
|
1327
|
+
const reviseHint = language === 'zh'
|
|
1328
|
+
? `(可选:用同一工具重新规划并覆写该 hunk:\`prepare_file_range_edit({ \"path\": \"${filePath}\", \"range\": \"${rangeSpec}\", \"existing_hunk_id\": \"${hunkId}\", \"content\": \"...\" })\`。)`
|
|
1329
|
+
: `Optional: revise by re-running the same tool to overwrite this hunk: \`prepare_file_range_edit({ \"path\": \"${filePath}\", \"range\": \"${rangeSpec}\", \"existing_hunk_id\": \"${hunkId}\", \"content\": \"...\" })\`.`;
|
|
1330
|
+
const resolvedStart = range.kind === 'append' ? range.startLine : range.startLine;
|
|
1331
|
+
const resolvedEnd = range.kind === 'append' ? range.startLine + Math.max(0, newLines.length - 1) : range.endLine;
|
|
1332
|
+
const evidenceBefore = previewWindow(currentLines, startIndex0 - 2, 2);
|
|
1333
|
+
const evidenceRange = buildRangePreview(oldLines);
|
|
1334
|
+
const evidenceAfter = previewWindow(currentLines, startIndex0 + deleteCount, 2);
|
|
1335
|
+
const linesOld = deleteCount;
|
|
1336
|
+
const linesNew = newLines.length;
|
|
1337
|
+
const delta = linesNew - linesOld;
|
|
1338
|
+
const summary = language === 'zh'
|
|
1339
|
+
? `Plan:${action} 第 ${resolvedStart}–${resolvedEnd} 行(old=${linesOld}, new=${linesNew}, delta=${delta});匹配=exact;hunk_id=${hunkId}.`
|
|
1340
|
+
: `Plan: ${action} lines ${resolvedStart}–${resolvedEnd} (old=${linesOld}, new=${linesNew}, delta=${delta}); matched exact; hunk_id=${hunkId}.`;
|
|
1341
|
+
const fileEofHasNewline = currentContent === '' || currentContent.endsWith('\n');
|
|
1342
|
+
const normalizedFileEofNewlineAdded = currentContent !== '' && !currentContent.endsWith('\n');
|
|
1343
|
+
const contentEofHasNewline = inputBody === '' || inputBody.endsWith('\n');
|
|
1344
|
+
const normalizedContentEofNewlineAdded = inputBody !== '' && !inputBody.endsWith('\n');
|
|
1345
|
+
const yaml = [
|
|
1346
|
+
`status: ok`,
|
|
1347
|
+
`mode: prepare_file_range_edit`,
|
|
1348
|
+
`path: ${yamlQuote(filePath)}`,
|
|
1349
|
+
`hunk_id: ${yamlQuote(hunkId)}`,
|
|
1350
|
+
`expires_at_ms: ${planned.expiresAtMs}`,
|
|
1351
|
+
`action: ${action}`,
|
|
1352
|
+
`range:`,
|
|
1353
|
+
` input: ${yamlQuote(rangeSpec)}`,
|
|
1354
|
+
` resolved:`,
|
|
1355
|
+
` start: ${resolvedStart}`,
|
|
1356
|
+
` end: ${resolvedEnd}`,
|
|
1357
|
+
`file_line_count: ${fileLineCount(currentLines)}`,
|
|
1358
|
+
`lines:`,
|
|
1359
|
+
` old: ${linesOld}`,
|
|
1360
|
+
` new: ${linesNew}`,
|
|
1361
|
+
` delta: ${delta}`,
|
|
1362
|
+
`match: exact`,
|
|
1363
|
+
`normalized:`,
|
|
1364
|
+
` file_eof_has_newline: ${fileEofHasNewline}`,
|
|
1365
|
+
` content_eof_has_newline: ${contentEofHasNewline}`,
|
|
1366
|
+
` normalized_file_eof_newline_added: ${normalizedFileEofNewlineAdded}`,
|
|
1367
|
+
` normalized_content_eof_newline_added: ${normalizedContentEofNewlineAdded}`,
|
|
1368
|
+
`evidence:`,
|
|
1369
|
+
` before: ${yamlBlockScalarLines(evidenceBefore, ' ')}`,
|
|
1370
|
+
` range: ${yamlBlockScalarLines(evidenceRange, ' ')}`,
|
|
1371
|
+
` after: ${yamlBlockScalarLines(evidenceAfter, ' ')}`,
|
|
1372
|
+
`summary: ${yamlQuote(summary)}`,
|
|
1373
|
+
].join('\n');
|
|
1374
|
+
const content = `${labels.planned(hunkId, filePath)}\n\n` +
|
|
1375
|
+
`${formatYamlCodeBlock(yaml)}\n\n` +
|
|
1376
|
+
`\`\`\`diff\n${unifiedDiff}\`\`\`\n\n` +
|
|
1377
|
+
`${labels.next(hunkId)}\n` +
|
|
1378
|
+
`${reviseHint}\n` +
|
|
1379
|
+
(language === 'zh'
|
|
1380
|
+
? `(Range resolved: \`${rangeLabel}\`)`
|
|
1381
|
+
: `(Range resolved: \`${rangeLabel}\`)`);
|
|
1382
|
+
return ok(content, [{ type: 'environment_msg', role: 'user', content }]);
|
|
1383
|
+
}
|
|
1384
|
+
catch (error) {
|
|
1385
|
+
const content = labels.planFailed(error instanceof Error ? error.message : String(error));
|
|
1386
|
+
return wrapTxtToolResult(language, [{ type: 'environment_msg', role: 'user', content }]);
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
exports.prepareFileRangeEditTool = {
|
|
1390
|
+
type: 'func',
|
|
1391
|
+
name: 'prepare_file_range_edit',
|
|
1392
|
+
description: 'Prepare a single-file edit by line range (does not write).',
|
|
1393
|
+
parameters: {
|
|
1394
|
+
type: 'object',
|
|
1395
|
+
additionalProperties: false,
|
|
1396
|
+
properties: {
|
|
1397
|
+
path: { type: 'string' },
|
|
1398
|
+
range: { type: 'string' },
|
|
1399
|
+
existing_hunk_id: { type: 'string' },
|
|
1400
|
+
content: { type: 'string' },
|
|
1401
|
+
},
|
|
1402
|
+
required: ['path', 'range'],
|
|
1403
|
+
},
|
|
1404
|
+
argsValidation: 'dominds',
|
|
1405
|
+
call: async (_dlg, caller, args) => {
|
|
1406
|
+
const filePath = requireNonEmptyStringArg(args, 'path');
|
|
1407
|
+
const range = requireNonEmptyStringArg(args, 'range');
|
|
1408
|
+
const existingHunkId = normalizeExistingHunkId(optionalNonEmptyStringArg(args, 'existing_hunk_id'));
|
|
1409
|
+
const content = optionalStringArg(args, 'content') ?? '';
|
|
1410
|
+
const requestedId = existingHunkId;
|
|
1411
|
+
if (requestedId !== undefined && !isValidHunkId(requestedId)) {
|
|
1412
|
+
throw new Error("Invalid arguments: `existing_hunk_id` must be a hunk id like 'a1b2c3d4' (letters/digits/_/-)");
|
|
1413
|
+
}
|
|
1414
|
+
const res = await runPrepareFileRangeEdit(caller, filePath, range, requestedId, content);
|
|
1415
|
+
return unwrapTxtToolResult(res);
|
|
1416
|
+
},
|
|
1417
|
+
};
|
|
1418
|
+
async function runPrepareFileAppend(caller, filePath, inputBody, options) {
|
|
1419
|
+
const language = (0, runtime_language_1.getWorkLanguage)();
|
|
1420
|
+
if (!filePath) {
|
|
1421
|
+
const content = formatYamlCodeBlock([
|
|
1422
|
+
`status: error`,
|
|
1423
|
+
`mode: prepare_file_append`,
|
|
1424
|
+
`error: PATH_REQUIRED`,
|
|
1425
|
+
`summary: ${yamlQuote(language === 'zh' ? '需要提供文件路径。' : 'File path is required.')}`,
|
|
1426
|
+
].join('\n'));
|
|
1427
|
+
return failed(content, [{ type: 'environment_msg', role: 'user', content }]);
|
|
1428
|
+
}
|
|
1429
|
+
if (!(0, access_control_1.hasWriteAccess)(caller, filePath)) {
|
|
1430
|
+
const content = (0, access_control_1.getAccessDeniedMessage)('write', filePath, language);
|
|
1431
|
+
return wrapTxtToolResult(language, [{ type: 'environment_msg', role: 'user', content }]);
|
|
1432
|
+
}
|
|
1433
|
+
if (inputBody === '') {
|
|
1434
|
+
const content = formatYamlCodeBlock([
|
|
1435
|
+
`status: error`,
|
|
1436
|
+
`mode: prepare_file_append`,
|
|
1437
|
+
`path: ${yamlQuote(filePath)}`,
|
|
1438
|
+
`error: CONTENT_REQUIRED`,
|
|
1439
|
+
`summary: ${yamlQuote(language === 'zh' ? '正文不能为空(需要提供要追加的内容)。' : 'Content is required.')}`,
|
|
1440
|
+
].join('\n'));
|
|
1441
|
+
return failed(content, [{ type: 'environment_msg', role: 'user', content }]);
|
|
1442
|
+
}
|
|
1443
|
+
const create = options.create;
|
|
1444
|
+
const requestedId = options.requestedId;
|
|
1445
|
+
if (requestedId !== undefined && !isValidHunkId(requestedId)) {
|
|
1446
|
+
const content = formatYamlCodeBlock([
|
|
1447
|
+
`status: error`,
|
|
1448
|
+
`mode: prepare_file_append`,
|
|
1449
|
+
`path: ${yamlQuote(filePath)}`,
|
|
1450
|
+
`error: INVALID_HUNK_ID`,
|
|
1451
|
+
`summary: ${yamlQuote(language === 'zh'
|
|
1452
|
+
? 'hunk id 格式无效(例如 `a1b2c3d4`)。'
|
|
1453
|
+
: 'Invalid hunk id (e.g. `a1b2c3d4`).')}`,
|
|
1454
|
+
].join('\n'));
|
|
1455
|
+
return failed(content, [{ type: 'environment_msg', role: 'user', content }]);
|
|
1456
|
+
}
|
|
1457
|
+
try {
|
|
1458
|
+
pruneExpiredPlannedMods(Date.now());
|
|
1459
|
+
pruneExpiredPlannedBlockReplaces(Date.now());
|
|
1460
|
+
const fullPath = ensureInsideWorkspace(filePath);
|
|
1461
|
+
if (requestedId) {
|
|
1462
|
+
const existing = plannedModsById.get(requestedId);
|
|
1463
|
+
if (!existing) {
|
|
1464
|
+
const content = formatYamlCodeBlock([
|
|
1465
|
+
`status: error`,
|
|
1466
|
+
`mode: prepare_file_append`,
|
|
1467
|
+
`path: ${yamlQuote(filePath)}`,
|
|
1468
|
+
`hunk_id: ${yamlQuote(requestedId)}`,
|
|
1469
|
+
`error: HUNK_NOT_FOUND`,
|
|
1470
|
+
`summary: ${yamlQuote(language === 'zh'
|
|
1471
|
+
? '该 hunk id 不存在(可能已过期/已被应用)。不支持自定义新 id;要生成新 id,请将 `existing_hunk_id` 设为空字符串。'
|
|
1472
|
+
: 'Hunk not found (expired or already applied). Custom new ids are not allowed; set `existing_hunk_id` to an empty string to generate a new one.')}`,
|
|
1473
|
+
].join('\n'));
|
|
1474
|
+
return failed(content, [{ type: 'environment_msg', role: 'user', content }]);
|
|
1475
|
+
}
|
|
1476
|
+
if (existing.plannedBy !== caller.id) {
|
|
1477
|
+
const content = formatYamlCodeBlock([
|
|
1478
|
+
`status: error`,
|
|
1479
|
+
`mode: prepare_file_append`,
|
|
1480
|
+
`path: ${yamlQuote(filePath)}`,
|
|
1481
|
+
`hunk_id: ${yamlQuote(requestedId)}`,
|
|
1482
|
+
`error: WRONG_OWNER`,
|
|
1483
|
+
`summary: ${yamlQuote(language === 'zh'
|
|
1484
|
+
? '该 hunk 不是由当前成员规划的,不能覆写。'
|
|
1485
|
+
: 'This hunk was planned by a different member; cannot overwrite.')}`,
|
|
1486
|
+
].join('\n'));
|
|
1487
|
+
return failed(content, [{ type: 'environment_msg', role: 'user', content }]);
|
|
1488
|
+
}
|
|
1489
|
+
if (existing.kind !== 'append') {
|
|
1490
|
+
const content = formatYamlCodeBlock([
|
|
1491
|
+
`status: error`,
|
|
1492
|
+
`mode: prepare_file_append`,
|
|
1493
|
+
`path: ${yamlQuote(filePath)}`,
|
|
1494
|
+
`hunk_id: ${yamlQuote(requestedId)}`,
|
|
1495
|
+
`error: WRONG_MODE`,
|
|
1496
|
+
`summary: ${yamlQuote(language === 'zh'
|
|
1497
|
+
? '该 hunk id 不是由 prepare_file_append 生成的,不能用该工具覆写。'
|
|
1498
|
+
: 'This hunk was not generated by prepare_file_append; cannot overwrite.')}`,
|
|
1499
|
+
].join('\n'));
|
|
1500
|
+
return failed(content, [{ type: 'environment_msg', role: 'user', content }]);
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
const fileExists = fs_1.default.existsSync(fullPath);
|
|
1504
|
+
if (!fileExists && !create) {
|
|
1505
|
+
const content = formatYamlCodeBlock([
|
|
1506
|
+
`status: error`,
|
|
1507
|
+
`mode: prepare_file_append`,
|
|
1508
|
+
`path: ${yamlQuote(filePath)}`,
|
|
1509
|
+
`error: FILE_NOT_FOUND`,
|
|
1510
|
+
`summary: ${yamlQuote(language === 'zh'
|
|
1511
|
+
? '文件不存在(create=false),无法规划追加。'
|
|
1512
|
+
: 'File does not exist (create=false); cannot plan append.')}`,
|
|
1513
|
+
].join('\n'));
|
|
1514
|
+
return failed(content, [{ type: 'environment_msg', role: 'user', content }]);
|
|
1515
|
+
}
|
|
1516
|
+
const existingContent = fileExists ? fs_1.default.readFileSync(fullPath, 'utf8') : '';
|
|
1517
|
+
const fileEofHasNewline = existingContent === '' || existingContent.endsWith('\n');
|
|
1518
|
+
const normalizedFileEofNewlineAdded = existingContent !== '' && !existingContent.endsWith('\n');
|
|
1519
|
+
const existingNormalized = normalizedFileEofNewlineAdded
|
|
1520
|
+
? `${existingContent}\n`
|
|
1521
|
+
: existingContent;
|
|
1522
|
+
const { normalizedBody, addedTrailingNewlineToContent } = normalizeFileWriteBody(inputBody);
|
|
1523
|
+
const contentEofHasNewline = inputBody.endsWith('\n');
|
|
1524
|
+
const normalizedContentEofNewlineAdded = addedTrailingNewlineToContent;
|
|
1525
|
+
const normalized = {
|
|
1526
|
+
fileEofHasNewline,
|
|
1527
|
+
contentEofHasNewline,
|
|
1528
|
+
normalizedFileEofNewlineAdded,
|
|
1529
|
+
normalizedContentEofNewlineAdded,
|
|
1530
|
+
};
|
|
1531
|
+
const fileLinesBefore = splitTextToLinesForEditing(existingNormalized);
|
|
1532
|
+
const appendLines = splitPlannedBodyLines(normalizedBody);
|
|
1533
|
+
const plannedAfterLines = [...fileLinesBefore, ...appendLines];
|
|
1534
|
+
const unifiedDiff = buildUnifiedSingleHunkDiff(filePath, fileLinesBefore, fileLinesBefore.length, 0, appendLines);
|
|
1535
|
+
const fileLineCountBefore = countLogicalLines(existingContent);
|
|
1536
|
+
const fileLineCountAfter = countLogicalLines(`${existingNormalized}${normalizedBody}`);
|
|
1537
|
+
const appendedLineCount = countLogicalLines(normalizedBody);
|
|
1538
|
+
const fileTrailingBlankLineCount = countTrailingBlankLines(fileLinesBefore);
|
|
1539
|
+
const contentLeadingBlankLineCount = countLeadingBlankLines(appendLines);
|
|
1540
|
+
const styleWarning = fileTrailingBlankLineCount > 0 && contentLeadingBlankLineCount > 0
|
|
1541
|
+
? language === 'zh'
|
|
1542
|
+
? '注意:文件末尾已有空行且追加内容以空行开头,可能产生多余空行。'
|
|
1543
|
+
: 'Warning: file already ends with blank line(s) and appended content starts with blank line(s); you may get extra blank lines.'
|
|
1544
|
+
: '';
|
|
1545
|
+
const evidenceBeforeTail = fileLinesBefore.slice(Math.max(0, fileLinesBefore.length - 2));
|
|
1546
|
+
const evidenceAppendPreview = appendLines.length <= 2 ? appendLines : appendLines.slice(0, 2);
|
|
1547
|
+
const evidenceAfterTail = plannedAfterLines.slice(Math.max(0, plannedAfterLines.length - 2));
|
|
1548
|
+
const nowMs = Date.now();
|
|
1549
|
+
const hunkId = (() => {
|
|
1550
|
+
if (requestedId)
|
|
1551
|
+
return requestedId;
|
|
1552
|
+
for (let i = 0; i < 10; i += 1) {
|
|
1553
|
+
const id = generateHunkId();
|
|
1554
|
+
if (!plannedModsById.has(id) && !plannedBlockReplacesById.has(id))
|
|
1555
|
+
return id;
|
|
1556
|
+
}
|
|
1557
|
+
throw new Error('Failed to generate a unique hunk id');
|
|
1558
|
+
})();
|
|
1559
|
+
const planned = {
|
|
1560
|
+
kind: 'append',
|
|
1561
|
+
hunkId,
|
|
1562
|
+
plannedBy: caller.id,
|
|
1563
|
+
createdAtMs: nowMs,
|
|
1564
|
+
expiresAtMs: nowMs + PLANNED_MOD_TTL_MS,
|
|
1565
|
+
relPath: filePath,
|
|
1566
|
+
absPath: fullPath,
|
|
1567
|
+
allowCreate: create,
|
|
1568
|
+
plannedFileDigestSha256: sha256HexUtf8(existingContent),
|
|
1569
|
+
newLines: appendLines,
|
|
1570
|
+
unifiedDiff,
|
|
1571
|
+
normalized,
|
|
1572
|
+
};
|
|
1573
|
+
plannedModsById.set(hunkId, planned);
|
|
1574
|
+
const summary = language === 'zh'
|
|
1575
|
+
? `Plan-append:+${appendedLineCount} 行;file ${fileLineCountBefore} → ${fileLineCountAfter};hunk_id=${hunkId}.`
|
|
1576
|
+
: `Plan-append: +${appendedLineCount} lines; file ${fileLineCountBefore} → ${fileLineCountAfter}; hunk_id=${hunkId}.`;
|
|
1577
|
+
const yaml = [
|
|
1578
|
+
`status: ok`,
|
|
1579
|
+
`mode: prepare_file_append`,
|
|
1580
|
+
`path: ${yamlQuote(filePath)}`,
|
|
1581
|
+
`hunk_id: ${yamlQuote(hunkId)}`,
|
|
1582
|
+
`expires_at_ms: ${planned.expiresAtMs}`,
|
|
1583
|
+
`action: append`,
|
|
1584
|
+
`create: ${create}`,
|
|
1585
|
+
`file_line_count_before: ${fileLineCountBefore}`,
|
|
1586
|
+
`file_line_count_after: ${fileLineCountAfter}`,
|
|
1587
|
+
`appended_line_count: ${appendedLineCount}`,
|
|
1588
|
+
`normalized:`,
|
|
1589
|
+
` file_eof_has_newline: ${normalized.fileEofHasNewline}`,
|
|
1590
|
+
` content_eof_has_newline: ${normalized.contentEofHasNewline}`,
|
|
1591
|
+
` normalized_file_eof_newline_added: ${normalized.normalizedFileEofNewlineAdded}`,
|
|
1592
|
+
` normalized_content_eof_newline_added: ${normalized.normalizedContentEofNewlineAdded}`,
|
|
1593
|
+
`blankline_style:`,
|
|
1594
|
+
` file_trailing_blank_line_count: ${fileTrailingBlankLineCount}`,
|
|
1595
|
+
` content_leading_blank_line_count: ${contentLeadingBlankLineCount}`,
|
|
1596
|
+
styleWarning ? `style_warning: ${yamlQuote(styleWarning)}` : `style_warning: ''`,
|
|
1597
|
+
`evidence_preview:`,
|
|
1598
|
+
` before_tail: ${yamlFlowStringArray(evidenceBeforeTail)}`,
|
|
1599
|
+
` append_preview: ${yamlFlowStringArray(evidenceAppendPreview)}`,
|
|
1600
|
+
` after_tail: ${yamlFlowStringArray(evidenceAfterTail)}`,
|
|
1601
|
+
`summary: ${yamlQuote(summary)}`,
|
|
1602
|
+
].join('\n');
|
|
1603
|
+
const content = `${formatYamlCodeBlock(yaml)}\n\n` +
|
|
1604
|
+
`\`\`\`diff\n${unifiedDiff}\`\`\`\n\n` +
|
|
1605
|
+
(language === 'zh'
|
|
1606
|
+
? `下一步:调用函数工具 \`apply_file_modification\`,参数:{ \"hunk_id\": \"${hunkId}\" }`
|
|
1607
|
+
: `Next: call function tool \`apply_file_modification\` with { \"hunk_id\": \"${hunkId}\" }.`);
|
|
1608
|
+
return ok(content, [{ type: 'environment_msg', role: 'user', content }]);
|
|
1609
|
+
}
|
|
1610
|
+
catch (error) {
|
|
1611
|
+
const content = formatYamlCodeBlock([
|
|
1612
|
+
`status: error`,
|
|
1613
|
+
`mode: prepare_file_append`,
|
|
1614
|
+
`path: ${yamlQuote(filePath)}`,
|
|
1615
|
+
`error: FAILED`,
|
|
1616
|
+
`summary: ${yamlQuote(error instanceof Error ? error.message : String(error))}`,
|
|
1617
|
+
].join('\n'));
|
|
1618
|
+
return failed(content, [{ type: 'environment_msg', role: 'user', content }]);
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
async function planInsertionCommon(position, caller, options) {
|
|
1622
|
+
const language = (0, runtime_language_1.getWorkLanguage)();
|
|
1623
|
+
const mode = position === 'after' ? 'prepare_file_insert_after' : 'prepare_file_insert_before';
|
|
1624
|
+
const filePath = options.filePath;
|
|
1625
|
+
const anchor = options.anchor;
|
|
1626
|
+
const inputBody = options.inputBody;
|
|
1627
|
+
const occurrence = options.occurrence;
|
|
1628
|
+
const occurrenceSpecified = options.occurrenceSpecified;
|
|
1629
|
+
const match = options.match;
|
|
1630
|
+
const requestedId = options.requestedId;
|
|
1631
|
+
if (!filePath || !anchor) {
|
|
1632
|
+
const content = formatYamlCodeBlock([
|
|
1633
|
+
`status: error`,
|
|
1634
|
+
`mode: ${mode}`,
|
|
1635
|
+
`error: INVALID_FORMAT`,
|
|
1636
|
+
`summary: ${yamlQuote(language === 'zh'
|
|
1637
|
+
? `需要提供 path 与 anchor。请调用函数工具:${mode}({ path, anchor, content, ...options })`
|
|
1638
|
+
: `path and anchor are required. Call the function tool: ${mode}({ path, anchor, content, ...options })`)}`,
|
|
1639
|
+
].join('\n'));
|
|
1640
|
+
return failed(content, [{ type: 'environment_msg', role: 'user', content }]);
|
|
1641
|
+
}
|
|
1642
|
+
if (requestedId !== undefined && !isValidHunkId(requestedId)) {
|
|
1643
|
+
const content = formatYamlCodeBlock([
|
|
1644
|
+
`status: error`,
|
|
1645
|
+
`mode: ${mode}`,
|
|
1646
|
+
`path: ${yamlQuote(filePath)}`,
|
|
1647
|
+
`error: INVALID_HUNK_ID`,
|
|
1648
|
+
`summary: ${yamlQuote(language === 'zh'
|
|
1649
|
+
? 'hunk id 格式无效(例如 `a1b2c3d4`)。'
|
|
1650
|
+
: 'Invalid hunk id (e.g. `a1b2c3d4`).')}`,
|
|
1651
|
+
].join('\n'));
|
|
1652
|
+
return failed(content, [{ type: 'environment_msg', role: 'user', content }]);
|
|
1653
|
+
}
|
|
1654
|
+
if (!(0, access_control_1.hasWriteAccess)(caller, filePath)) {
|
|
1655
|
+
const content = (0, access_control_1.getAccessDeniedMessage)('write', filePath, language);
|
|
1656
|
+
return wrapTxtToolResult(language, [{ type: 'environment_msg', role: 'user', content }]);
|
|
1657
|
+
}
|
|
1658
|
+
if (inputBody === '') {
|
|
1659
|
+
const content = formatYamlCodeBlock([
|
|
1660
|
+
`status: error`,
|
|
1661
|
+
`mode: ${mode}`,
|
|
1662
|
+
`path: ${yamlQuote(filePath)}`,
|
|
1663
|
+
`error: CONTENT_REQUIRED`,
|
|
1664
|
+
`summary: ${yamlQuote(language === 'zh'
|
|
1665
|
+
? '正文不能为空(需要提供要插入的内容)。'
|
|
1666
|
+
: 'Content is required in the body.')}`,
|
|
1667
|
+
].join('\n'));
|
|
1668
|
+
return failed(content, [{ type: 'environment_msg', role: 'user', content }]);
|
|
1669
|
+
}
|
|
1670
|
+
try {
|
|
1671
|
+
pruneExpiredPlannedMods(Date.now());
|
|
1672
|
+
pruneExpiredPlannedBlockReplaces(Date.now());
|
|
1673
|
+
const fullPath = ensureInsideWorkspace(filePath);
|
|
1674
|
+
if (!fs_1.default.existsSync(fullPath)) {
|
|
1675
|
+
const content = formatYamlCodeBlock([
|
|
1676
|
+
`status: error`,
|
|
1677
|
+
`mode: ${mode}`,
|
|
1678
|
+
`path: ${yamlQuote(filePath)}`,
|
|
1679
|
+
`error: FILE_NOT_FOUND`,
|
|
1680
|
+
`summary: ${yamlQuote(language === 'zh' ? '文件不存在,无法规划插入。' : 'File does not exist.')}`,
|
|
1681
|
+
].join('\n'));
|
|
1682
|
+
return failed(content, [{ type: 'environment_msg', role: 'user', content }]);
|
|
1683
|
+
}
|
|
1684
|
+
if (requestedId) {
|
|
1685
|
+
const existing = plannedModsById.get(requestedId);
|
|
1686
|
+
if (!existing) {
|
|
1687
|
+
const content = formatYamlCodeBlock([
|
|
1688
|
+
`status: error`,
|
|
1689
|
+
`mode: ${mode}`,
|
|
1690
|
+
`path: ${yamlQuote(filePath)}`,
|
|
1691
|
+
`hunk_id: ${yamlQuote(requestedId)}`,
|
|
1692
|
+
`error: HUNK_NOT_FOUND`,
|
|
1693
|
+
`summary: ${yamlQuote(language === 'zh'
|
|
1694
|
+
? '该 hunk id 不存在(可能已过期/已被应用)。不支持自定义新 id;要生成新 id,请将 `existing_hunk_id` 设为空字符串。'
|
|
1695
|
+
: 'Hunk not found (expired or already applied). Custom new ids are not allowed; set `existing_hunk_id` to an empty string to generate a new one.')}`,
|
|
1696
|
+
].join('\n'));
|
|
1697
|
+
return failed(content, [{ type: 'environment_msg', role: 'user', content }]);
|
|
1698
|
+
}
|
|
1699
|
+
if (existing.plannedBy !== caller.id) {
|
|
1700
|
+
const content = formatYamlCodeBlock([
|
|
1701
|
+
`status: error`,
|
|
1702
|
+
`mode: ${mode}`,
|
|
1703
|
+
`path: ${yamlQuote(filePath)}`,
|
|
1704
|
+
`hunk_id: ${yamlQuote(requestedId)}`,
|
|
1705
|
+
`error: WRONG_OWNER`,
|
|
1706
|
+
`summary: ${yamlQuote(language === 'zh'
|
|
1707
|
+
? '该 hunk 不是由当前成员规划的,不能覆写。'
|
|
1708
|
+
: 'This hunk was planned by a different member; cannot overwrite.')}`,
|
|
1709
|
+
].join('\n'));
|
|
1710
|
+
return failed(content, [{ type: 'environment_msg', role: 'user', content }]);
|
|
1711
|
+
}
|
|
1712
|
+
if (existing.kind !== 'insert') {
|
|
1713
|
+
const content = formatYamlCodeBlock([
|
|
1714
|
+
`status: error`,
|
|
1715
|
+
`mode: ${mode}`,
|
|
1716
|
+
`path: ${yamlQuote(filePath)}`,
|
|
1717
|
+
`hunk_id: ${yamlQuote(requestedId)}`,
|
|
1718
|
+
`error: WRONG_MODE`,
|
|
1719
|
+
`summary: ${yamlQuote(language === 'zh'
|
|
1720
|
+
? '该 hunk id 不是由 prepare_file_insert_* 生成的,不能用该工具覆写。'
|
|
1721
|
+
: 'This hunk was not generated by prepare_file_insert_*; cannot overwrite.')}`,
|
|
1722
|
+
].join('\n'));
|
|
1723
|
+
return failed(content, [{ type: 'environment_msg', role: 'user', content }]);
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
const existingContent = fs_1.default.readFileSync(fullPath, 'utf8');
|
|
1727
|
+
const lines = splitTextToLinesForEditing(existingContent);
|
|
1728
|
+
const isMatch = (line) => {
|
|
1729
|
+
return match === 'equals' ? line === anchor : line.includes(anchor);
|
|
1730
|
+
};
|
|
1731
|
+
const matchLines = [];
|
|
1732
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
1733
|
+
const line = lines[i] ?? '';
|
|
1734
|
+
if (isMatch(line))
|
|
1735
|
+
matchLines.push(i);
|
|
1736
|
+
}
|
|
1737
|
+
if (!occurrenceSpecified && matchLines.length > 1) {
|
|
1738
|
+
const content = formatYamlCodeBlock([
|
|
1739
|
+
`status: error`,
|
|
1740
|
+
`mode: ${mode}`,
|
|
1741
|
+
`path: ${yamlQuote(filePath)}`,
|
|
1742
|
+
`anchor: ${yamlQuote(anchor)}`,
|
|
1743
|
+
`candidates_count: ${matchLines.length}`,
|
|
1744
|
+
`error: ANCHOR_AMBIGUOUS`,
|
|
1745
|
+
`summary: ${yamlQuote(language === 'zh'
|
|
1746
|
+
? '锚点出现多次且未指定 occurrence;拒绝规划。请指定 occurrence 或改用 prepare_file_range_edit。'
|
|
1747
|
+
: 'Anchor appears multiple times and occurrence is not specified; refusing to plan. Specify occurrence or use prepare_file_range_edit.')}`,
|
|
1748
|
+
].join('\n'));
|
|
1749
|
+
return failed(content, [{ type: 'environment_msg', role: 'user', content }]);
|
|
1750
|
+
}
|
|
1751
|
+
if (matchLines.length === 0) {
|
|
1752
|
+
const content = formatYamlCodeBlock([
|
|
1753
|
+
`status: error`,
|
|
1754
|
+
`mode: ${mode}`,
|
|
1755
|
+
`path: ${yamlQuote(filePath)}`,
|
|
1756
|
+
`anchor: ${yamlQuote(anchor)}`,
|
|
1757
|
+
`error: ANCHOR_NOT_FOUND`,
|
|
1758
|
+
`summary: ${yamlQuote(language === 'zh'
|
|
1759
|
+
? '锚点未找到;请改用 prepare_file_range_edit 或选择更可靠的 anchor。'
|
|
1760
|
+
: 'Anchor not found; use prepare_file_range_edit or choose a different anchor.')}`,
|
|
1761
|
+
].join('\n'));
|
|
1762
|
+
return failed(content, [{ type: 'environment_msg', role: 'user', content }]);
|
|
1763
|
+
}
|
|
1764
|
+
const anchorIndex0 = occurrence.kind === 'last'
|
|
1765
|
+
? matchLines[matchLines.length - 1]
|
|
1766
|
+
: matchLines[occurrence.index1 - 1];
|
|
1767
|
+
if (anchorIndex0 === undefined) {
|
|
1768
|
+
const content = formatYamlCodeBlock([
|
|
1769
|
+
`status: error`,
|
|
1770
|
+
`mode: ${mode}`,
|
|
1771
|
+
`path: ${yamlQuote(filePath)}`,
|
|
1772
|
+
`anchor: ${yamlQuote(anchor)}`,
|
|
1773
|
+
`error: OCCURRENCE_OUT_OF_RANGE`,
|
|
1774
|
+
`candidates_count: ${matchLines.length}`,
|
|
1775
|
+
`summary: ${yamlQuote(language === 'zh' ? 'occurrence 超出范围。' : 'Occurrence out of range.')}`,
|
|
1776
|
+
].join('\n'));
|
|
1777
|
+
return failed(content, [{ type: 'environment_msg', role: 'user', content }]);
|
|
1778
|
+
}
|
|
1779
|
+
const occurrenceResolved = matchLines.length === 1
|
|
1780
|
+
? '1'
|
|
1781
|
+
: occurrence.kind === 'last'
|
|
1782
|
+
? 'last'
|
|
1783
|
+
: String(occurrence.index1);
|
|
1784
|
+
const anchorLineText = lines[anchorIndex0] ?? '';
|
|
1785
|
+
const { normalizedBody, addedTrailingNewlineToContent } = normalizeFileWriteBody(inputBody);
|
|
1786
|
+
const insertLines = splitPlannedBodyLines(normalizedBody);
|
|
1787
|
+
const newLines = position === 'after' ? [anchorLineText, ...insertLines] : [...insertLines, anchorLineText];
|
|
1788
|
+
const startIndex0 = anchorIndex0;
|
|
1789
|
+
const deleteCount = 1;
|
|
1790
|
+
const { contextBefore, contextAfter } = computeContextWindow(lines, startIndex0, deleteCount);
|
|
1791
|
+
const unifiedDiff = buildUnifiedSingleHunkDiff(filePath, lines, startIndex0, deleteCount, newLines);
|
|
1792
|
+
const fileEofHasNewline = existingContent === '' || existingContent.endsWith('\n');
|
|
1793
|
+
const normalizedFileEofNewlineAdded = existingContent !== '' && !existingContent.endsWith('\n');
|
|
1794
|
+
const contentEofHasNewline = inputBody.endsWith('\n');
|
|
1795
|
+
const normalizedContentEofNewlineAdded = addedTrailingNewlineToContent;
|
|
1796
|
+
const insertedLineCount = insertLines.length;
|
|
1797
|
+
const insertedAtLine = position === 'after' ? anchorIndex0 + 2 : anchorIndex0 + 1;
|
|
1798
|
+
const fileBeforeTrailingBlankLineCount = position === 'after'
|
|
1799
|
+
? countTrailingBlankLines([anchorLineText])
|
|
1800
|
+
: countTrailingBlankLines(lines.slice(0, anchorIndex0));
|
|
1801
|
+
const fileAfterLeadingBlankLineCount = position === 'after'
|
|
1802
|
+
? countLeadingBlankLines(lines.slice(anchorIndex0 + 1))
|
|
1803
|
+
: countLeadingBlankLines(lines.slice(anchorIndex0));
|
|
1804
|
+
const contentLeadingBlankLineCount = countLeadingBlankLines(insertLines);
|
|
1805
|
+
const contentTrailingBlankLineCount = countTrailingBlankLines(insertLines);
|
|
1806
|
+
const styleWarning = (fileBeforeTrailingBlankLineCount > 0 && contentLeadingBlankLineCount > 0) ||
|
|
1807
|
+
(contentTrailingBlankLineCount > 0 && fileAfterLeadingBlankLineCount > 0)
|
|
1808
|
+
? language === 'zh'
|
|
1809
|
+
? '注意:插入点两侧与插入内容的空行风格可能叠加,可能产生多余空行。'
|
|
1810
|
+
: 'Warning: blank lines may stack at insertion boundaries; you may get extra blank lines.'
|
|
1811
|
+
: '';
|
|
1812
|
+
const beforePreview = position === 'after'
|
|
1813
|
+
? lines.slice(Math.max(0, anchorIndex0 - 1), anchorIndex0 + 1)
|
|
1814
|
+
: lines.slice(Math.max(0, anchorIndex0 - 2), anchorIndex0);
|
|
1815
|
+
const insertPreview = insertLines.length <= 2 ? insertLines : insertLines.slice(0, 2);
|
|
1816
|
+
const afterPreview = position === 'after'
|
|
1817
|
+
? lines.slice(anchorIndex0 + 1, anchorIndex0 + 3)
|
|
1818
|
+
: lines.slice(anchorIndex0, anchorIndex0 + 2);
|
|
1819
|
+
const nowMs = Date.now();
|
|
1820
|
+
const hunkId = (() => {
|
|
1821
|
+
if (requestedId)
|
|
1822
|
+
return requestedId;
|
|
1823
|
+
for (let i = 0; i < 10; i += 1) {
|
|
1824
|
+
const id = generateHunkId();
|
|
1825
|
+
if (!plannedModsById.has(id) && !plannedBlockReplacesById.has(id))
|
|
1826
|
+
return id;
|
|
1827
|
+
}
|
|
1828
|
+
throw new Error('Failed to generate a unique hunk id');
|
|
1829
|
+
})();
|
|
1830
|
+
const planned = {
|
|
1831
|
+
kind: 'insert',
|
|
1832
|
+
action: 'insert',
|
|
1833
|
+
hunkId,
|
|
1834
|
+
plannedBy: caller.id,
|
|
1835
|
+
createdAtMs: nowMs,
|
|
1836
|
+
expiresAtMs: nowMs + PLANNED_MOD_TTL_MS,
|
|
1837
|
+
relPath: filePath,
|
|
1838
|
+
absPath: fullPath,
|
|
1839
|
+
startIndex0,
|
|
1840
|
+
deleteCount,
|
|
1841
|
+
contextBefore,
|
|
1842
|
+
contextAfter,
|
|
1843
|
+
oldLines: [anchorLineText],
|
|
1844
|
+
newLines,
|
|
1845
|
+
unifiedDiff,
|
|
1846
|
+
insertion: {
|
|
1847
|
+
position,
|
|
1848
|
+
anchor,
|
|
1849
|
+
match,
|
|
1850
|
+
strict: true,
|
|
1851
|
+
occurrenceResolved,
|
|
1852
|
+
anchorLineText,
|
|
1853
|
+
fallback: 'none',
|
|
1854
|
+
},
|
|
1855
|
+
plannedFileDigestSha256: sha256HexUtf8(existingContent),
|
|
1856
|
+
};
|
|
1857
|
+
plannedModsById.set(hunkId, planned);
|
|
1858
|
+
const linesOld = deleteCount;
|
|
1859
|
+
const linesNew = newLines.length;
|
|
1860
|
+
const delta = linesNew - linesOld;
|
|
1861
|
+
const summary = language === 'zh'
|
|
1862
|
+
? `Plan-insert:${position === 'after' ? 'after' : 'before'} "${anchor}"(occurrence=${occurrenceResolved})插入 +${insertedLineCount} 行;delta=${delta};hunk_id=${hunkId}.`
|
|
1863
|
+
: `Plan-insert: insert +${insertedLineCount} lines ${position} "${anchor}" (occurrence=${occurrenceResolved}); delta=${delta}; hunk_id=${hunkId}.`;
|
|
1864
|
+
const yaml = [
|
|
1865
|
+
`status: ok`,
|
|
1866
|
+
`mode: ${mode}`,
|
|
1867
|
+
`path: ${yamlQuote(filePath)}`,
|
|
1868
|
+
`hunk_id: ${yamlQuote(hunkId)}`,
|
|
1869
|
+
`expires_at_ms: ${planned.expiresAtMs}`,
|
|
1870
|
+
`action: insert`,
|
|
1871
|
+
`position: ${yamlQuote(position)}`,
|
|
1872
|
+
`anchor: ${yamlQuote(anchor)}`,
|
|
1873
|
+
`match: ${yamlQuote(match)}`,
|
|
1874
|
+
`candidates_count: ${matchLines.length}`,
|
|
1875
|
+
`occurrence_resolved: ${yamlQuote(occurrenceResolved)}`,
|
|
1876
|
+
`inserted_at_line: ${insertedAtLine}`,
|
|
1877
|
+
`inserted_line_count: ${insertedLineCount}`,
|
|
1878
|
+
`lines:`,
|
|
1879
|
+
` old: ${linesOld}`,
|
|
1880
|
+
` new: ${linesNew}`,
|
|
1881
|
+
` delta: ${delta}`,
|
|
1882
|
+
`normalized:`,
|
|
1883
|
+
` file_eof_has_newline: ${fileEofHasNewline}`,
|
|
1884
|
+
` content_eof_has_newline: ${contentEofHasNewline}`,
|
|
1885
|
+
` normalized_file_eof_newline_added: ${normalizedFileEofNewlineAdded}`,
|
|
1886
|
+
` normalized_content_eof_newline_added: ${normalizedContentEofNewlineAdded}`,
|
|
1887
|
+
`blankline_style:`,
|
|
1888
|
+
` file_before_trailing_blank_line_count: ${fileBeforeTrailingBlankLineCount}`,
|
|
1889
|
+
` file_after_leading_blank_line_count: ${fileAfterLeadingBlankLineCount}`,
|
|
1890
|
+
` content_leading_blank_line_count: ${contentLeadingBlankLineCount}`,
|
|
1891
|
+
` content_trailing_blank_line_count: ${contentTrailingBlankLineCount}`,
|
|
1892
|
+
styleWarning ? `style_warning: ${yamlQuote(styleWarning)}` : `style_warning: ''`,
|
|
1893
|
+
`evidence_preview:`,
|
|
1894
|
+
` before_preview: ${yamlFlowStringArray(beforePreview)}`,
|
|
1895
|
+
` insert_preview: ${yamlFlowStringArray(insertPreview)}`,
|
|
1896
|
+
` after_preview: ${yamlFlowStringArray(afterPreview)}`,
|
|
1897
|
+
`summary: ${yamlQuote(summary)}`,
|
|
1898
|
+
].join('\n');
|
|
1899
|
+
const content = `${formatYamlCodeBlock(yaml)}\n\n` +
|
|
1900
|
+
`\`\`\`diff\n${unifiedDiff}\`\`\`\n\n` +
|
|
1901
|
+
(language === 'zh'
|
|
1902
|
+
? `下一步:调用函数工具 \`apply_file_modification\`,参数:{ \"hunk_id\": \"${hunkId}\" }`
|
|
1903
|
+
: `Next: call function tool \`apply_file_modification\` with { \"hunk_id\": \"${hunkId}\" }.`);
|
|
1904
|
+
return ok(content, [{ type: 'environment_msg', role: 'user', content }]);
|
|
1905
|
+
}
|
|
1906
|
+
catch (error) {
|
|
1907
|
+
const content = formatYamlCodeBlock([
|
|
1908
|
+
`status: error`,
|
|
1909
|
+
`mode: ${mode}`,
|
|
1910
|
+
`path: ${yamlQuote(filePath)}`,
|
|
1911
|
+
`error: FAILED`,
|
|
1912
|
+
`summary: ${yamlQuote(error instanceof Error ? error.message : String(error))}`,
|
|
1913
|
+
].join('\n'));
|
|
1914
|
+
return failed(content, [{ type: 'environment_msg', role: 'user', content }]);
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
exports.prepareFileInsertAfterTool = {
|
|
1918
|
+
type: 'func',
|
|
1919
|
+
name: 'prepare_file_insert_after',
|
|
1920
|
+
description: 'Prepare a file insertion after an anchor line (does not write).',
|
|
1921
|
+
parameters: {
|
|
1922
|
+
type: 'object',
|
|
1923
|
+
additionalProperties: false,
|
|
1924
|
+
properties: {
|
|
1925
|
+
path: { type: 'string' },
|
|
1926
|
+
anchor: { type: 'string' },
|
|
1927
|
+
occurrence: { type: ['integer', 'string'] },
|
|
1928
|
+
match: {
|
|
1929
|
+
type: 'string',
|
|
1930
|
+
description: "Anchor match mode: 'contains' (default) or 'equals'.",
|
|
1931
|
+
},
|
|
1932
|
+
existing_hunk_id: { type: 'string' },
|
|
1933
|
+
content: { type: 'string' },
|
|
1934
|
+
},
|
|
1935
|
+
required: ['path', 'anchor', 'content'],
|
|
1936
|
+
},
|
|
1937
|
+
argsValidation: 'dominds',
|
|
1938
|
+
call: async (_dlg, caller, args) => {
|
|
1939
|
+
const filePath = requireNonEmptyStringArg(args, 'path');
|
|
1940
|
+
const anchor = requireNonEmptyStringArg(args, 'anchor');
|
|
1941
|
+
const existingHunkId = normalizeExistingHunkId(optionalNonEmptyStringArg(args, 'existing_hunk_id'));
|
|
1942
|
+
const content = optionalStringArg(args, 'content') ?? '';
|
|
1943
|
+
let occurrence = { kind: 'index', index1: 1 };
|
|
1944
|
+
let occurrenceSpecified = false;
|
|
1945
|
+
const occurrenceValue = args['occurrence'];
|
|
1946
|
+
if (occurrenceValue !== undefined) {
|
|
1947
|
+
if (typeof occurrenceValue === 'number' && Number.isInteger(occurrenceValue)) {
|
|
1948
|
+
// Codex may require this field to be present even when semantically "unset".
|
|
1949
|
+
// Sentinel 0 means "not specified".
|
|
1950
|
+
if (occurrenceValue >= 1) {
|
|
1951
|
+
occurrence = { kind: 'index', index1: occurrenceValue };
|
|
1952
|
+
occurrenceSpecified = true;
|
|
1953
|
+
}
|
|
1954
|
+
else if (occurrenceValue !== 0) {
|
|
1955
|
+
throw new Error("Invalid arguments: `occurrence` must be a positive integer or 'last'");
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1958
|
+
else if (typeof occurrenceValue === 'string') {
|
|
1959
|
+
const trimmed = occurrenceValue.trim();
|
|
1960
|
+
// Codex may require this field to be present even when semantically "unset".
|
|
1961
|
+
// Sentinel empty string means "not specified".
|
|
1962
|
+
if (trimmed !== '') {
|
|
1963
|
+
const parsed = parseOccurrence(trimmed);
|
|
1964
|
+
if (!parsed) {
|
|
1965
|
+
throw new Error("Invalid arguments: `occurrence` must be a positive integer or 'last'");
|
|
1966
|
+
}
|
|
1967
|
+
occurrence = parsed;
|
|
1968
|
+
occurrenceSpecified = true;
|
|
1969
|
+
}
|
|
1970
|
+
}
|
|
1971
|
+
else {
|
|
1972
|
+
throw new Error("Invalid arguments: `occurrence` must be a positive integer or 'last'");
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
let match = 'contains';
|
|
1976
|
+
const matchArg = optionalNonEmptyStringArg(args, 'match');
|
|
1977
|
+
if (matchArg !== undefined) {
|
|
1978
|
+
if (matchArg !== 'contains' && matchArg !== 'equals') {
|
|
1979
|
+
throw new Error("Invalid arguments: `match` must be one of: 'contains', 'equals'");
|
|
1980
|
+
}
|
|
1981
|
+
match = matchArg;
|
|
1982
|
+
}
|
|
1983
|
+
const requestedId = existingHunkId;
|
|
1984
|
+
if (requestedId !== undefined && !isValidHunkId(requestedId)) {
|
|
1985
|
+
throw new Error("Invalid arguments: `existing_hunk_id` must be a hunk id like 'a1b2c3d4' (letters/digits/_/-)");
|
|
1986
|
+
}
|
|
1987
|
+
const res = await planInsertionCommon('after', caller, {
|
|
1988
|
+
filePath,
|
|
1989
|
+
anchor,
|
|
1990
|
+
occurrence,
|
|
1991
|
+
occurrenceSpecified,
|
|
1992
|
+
match,
|
|
1993
|
+
requestedId,
|
|
1994
|
+
inputBody: content,
|
|
1995
|
+
});
|
|
1996
|
+
return unwrapTxtToolResult(res);
|
|
1997
|
+
},
|
|
1998
|
+
};
|
|
1999
|
+
exports.prepareFileInsertBeforeTool = {
|
|
2000
|
+
type: 'func',
|
|
2001
|
+
name: 'prepare_file_insert_before',
|
|
2002
|
+
description: 'Prepare a file insertion before an anchor line (does not write).',
|
|
2003
|
+
parameters: {
|
|
2004
|
+
type: 'object',
|
|
2005
|
+
additionalProperties: false,
|
|
2006
|
+
properties: {
|
|
2007
|
+
path: { type: 'string' },
|
|
2008
|
+
anchor: { type: 'string' },
|
|
2009
|
+
occurrence: { type: ['integer', 'string'] },
|
|
2010
|
+
match: {
|
|
2011
|
+
type: 'string',
|
|
2012
|
+
description: "Anchor match mode: 'contains' (default) or 'equals'.",
|
|
2013
|
+
},
|
|
2014
|
+
existing_hunk_id: { type: 'string' },
|
|
2015
|
+
content: { type: 'string' },
|
|
2016
|
+
},
|
|
2017
|
+
required: ['path', 'anchor', 'content'],
|
|
2018
|
+
},
|
|
2019
|
+
argsValidation: 'dominds',
|
|
2020
|
+
call: async (_dlg, caller, args) => {
|
|
2021
|
+
const filePath = requireNonEmptyStringArg(args, 'path');
|
|
2022
|
+
const anchor = requireNonEmptyStringArg(args, 'anchor');
|
|
2023
|
+
const existingHunkId = normalizeExistingHunkId(optionalNonEmptyStringArg(args, 'existing_hunk_id'));
|
|
2024
|
+
const content = optionalStringArg(args, 'content') ?? '';
|
|
2025
|
+
let occurrence = { kind: 'index', index1: 1 };
|
|
2026
|
+
let occurrenceSpecified = false;
|
|
2027
|
+
const occurrenceValue = args['occurrence'];
|
|
2028
|
+
if (occurrenceValue !== undefined) {
|
|
2029
|
+
if (typeof occurrenceValue === 'number' && Number.isInteger(occurrenceValue)) {
|
|
2030
|
+
// Codex may require this field to be present even when semantically "unset".
|
|
2031
|
+
// Sentinel 0 means "not specified".
|
|
2032
|
+
if (occurrenceValue >= 1) {
|
|
2033
|
+
occurrence = { kind: 'index', index1: occurrenceValue };
|
|
2034
|
+
occurrenceSpecified = true;
|
|
2035
|
+
}
|
|
2036
|
+
else if (occurrenceValue !== 0) {
|
|
2037
|
+
throw new Error("Invalid arguments: `occurrence` must be a positive integer or 'last'");
|
|
2038
|
+
}
|
|
2039
|
+
}
|
|
2040
|
+
else if (typeof occurrenceValue === 'string') {
|
|
2041
|
+
const trimmed = occurrenceValue.trim();
|
|
2042
|
+
// Codex may require this field to be present even when semantically "unset".
|
|
2043
|
+
// Sentinel empty string means "not specified".
|
|
2044
|
+
if (trimmed !== '') {
|
|
2045
|
+
const parsed = parseOccurrence(trimmed);
|
|
2046
|
+
if (!parsed) {
|
|
2047
|
+
throw new Error("Invalid arguments: `occurrence` must be a positive integer or 'last'");
|
|
2048
|
+
}
|
|
2049
|
+
occurrence = parsed;
|
|
2050
|
+
occurrenceSpecified = true;
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
else {
|
|
2054
|
+
throw new Error("Invalid arguments: `occurrence` must be a positive integer or 'last'");
|
|
2055
|
+
}
|
|
2056
|
+
}
|
|
2057
|
+
let match = 'contains';
|
|
2058
|
+
const matchArg = optionalNonEmptyStringArg(args, 'match');
|
|
2059
|
+
if (matchArg !== undefined) {
|
|
2060
|
+
if (matchArg !== 'contains' && matchArg !== 'equals') {
|
|
2061
|
+
throw new Error("Invalid arguments: `match` must be one of: 'contains', 'equals'");
|
|
2062
|
+
}
|
|
2063
|
+
match = matchArg;
|
|
2064
|
+
}
|
|
2065
|
+
const requestedId = existingHunkId;
|
|
2066
|
+
if (requestedId !== undefined && !isValidHunkId(requestedId)) {
|
|
2067
|
+
throw new Error("Invalid arguments: `existing_hunk_id` must be a hunk id like 'a1b2c3d4' (letters/digits/_/-)");
|
|
2068
|
+
}
|
|
2069
|
+
const res = await planInsertionCommon('before', caller, {
|
|
2070
|
+
filePath,
|
|
2071
|
+
anchor,
|
|
2072
|
+
occurrence,
|
|
2073
|
+
occurrenceSpecified,
|
|
2074
|
+
match,
|
|
2075
|
+
requestedId,
|
|
2076
|
+
inputBody: content,
|
|
2077
|
+
});
|
|
2078
|
+
return unwrapTxtToolResult(res);
|
|
2079
|
+
},
|
|
2080
|
+
};
|
|
2081
|
+
async function runApplyFileModification(caller, id) {
|
|
2082
|
+
const language = (0, runtime_language_1.getWorkLanguage)();
|
|
2083
|
+
const labels = language === 'zh'
|
|
2084
|
+
? {
|
|
2085
|
+
invalidFormat: '错误:参数不正确。请调用函数工具:apply_file_modification({ \"hunk_id\": \"<hunk_id>\" })',
|
|
2086
|
+
hunkIdRequired: '错误:需要提供要应用的 hunk id(例如 `a1b2c3d4`)。',
|
|
2087
|
+
notFound: (id) => `错误:未找到该 hunk:\`${id}\`(可能已过期或已被应用)。`,
|
|
2088
|
+
wrongOwner: '错误:该 hunk 不是由当前成员规划的,不能应用。',
|
|
2089
|
+
mismatch: '错误:文件内容已变化,无法安全应用该 hunk;请重新规划。',
|
|
2090
|
+
ambiguous: '错误:无法唯一定位该 hunk 的目标位置(文件内出现多处匹配);请重新规划(缩小范围或增加上下文)。',
|
|
2091
|
+
applied: (p, id) => `✅ 已应用:\`${id}\` → \`${p}\``,
|
|
2092
|
+
applyFailed: (msg) => `错误:应用失败:${msg}`,
|
|
2093
|
+
}
|
|
2094
|
+
: {
|
|
2095
|
+
invalidFormat: 'Error: Invalid args. Call the function tool: apply_file_modification({ "hunk_id": "<hunk_id>" })',
|
|
2096
|
+
hunkIdRequired: 'Error: hunk id is required (e.g. `a1b2c3d4`).',
|
|
2097
|
+
notFound: (id) => `Error: hunk \`${id}\` not found (expired or already applied).`,
|
|
2098
|
+
wrongOwner: 'Error: this hunk was planned by a different member.',
|
|
2099
|
+
mismatch: 'Error: file content has changed; refusing to apply this hunk safely. Re-plan it.',
|
|
2100
|
+
ambiguous: 'Error: unable to uniquely locate the hunk target (multiple matches). Re-plan with a narrower range or more context.',
|
|
2101
|
+
applied: (p, id) => `✅ Applied \`${id}\` to \`${p}\``,
|
|
2102
|
+
applyFailed: (msg) => `Error applying modification: ${msg}`,
|
|
2103
|
+
};
|
|
2104
|
+
if (!id) {
|
|
2105
|
+
const content = labels.hunkIdRequired;
|
|
2106
|
+
return wrapTxtToolResult(language, [{ type: 'environment_msg', role: 'user', content }]);
|
|
2107
|
+
}
|
|
2108
|
+
if (!isValidHunkId(id)) {
|
|
2109
|
+
const content = labels.hunkIdRequired;
|
|
2110
|
+
return wrapTxtToolResult(language, [{ type: 'environment_msg', role: 'user', content }]);
|
|
2111
|
+
}
|
|
2112
|
+
try {
|
|
2113
|
+
pruneExpiredPlannedMods(Date.now());
|
|
2114
|
+
pruneExpiredPlannedBlockReplaces(Date.now());
|
|
2115
|
+
const plannedFileMod = plannedModsById.get(id);
|
|
2116
|
+
const plannedBlockReplace = plannedBlockReplacesById.get(id);
|
|
2117
|
+
if (plannedFileMod && plannedBlockReplace) {
|
|
2118
|
+
const content = formatYamlCodeBlock([
|
|
2119
|
+
`status: error`,
|
|
2120
|
+
`mode: apply_file_modification`,
|
|
2121
|
+
`hunk_id: ${yamlQuote(id)}`,
|
|
2122
|
+
`error: HUNK_ID_CONFLICT`,
|
|
2123
|
+
`summary: ${yamlQuote(language === 'zh'
|
|
2124
|
+
? 'hunk id 冲突:该 id 同时存在于不同的规划类型中;请重新规划生成新的 hunk id。'
|
|
2125
|
+
: 'Hunk id conflict: this id exists in multiple plan types; re-plan to generate a new hunk id.')}`,
|
|
2126
|
+
].join('\n'));
|
|
2127
|
+
return failed(content, [{ type: 'environment_msg', role: 'user', content }]);
|
|
2128
|
+
}
|
|
2129
|
+
if (!plannedFileMod && !plannedBlockReplace) {
|
|
2130
|
+
const content = formatYamlCodeBlock([
|
|
2131
|
+
`status: error`,
|
|
2132
|
+
`mode: apply_file_modification`,
|
|
2133
|
+
`hunk_id: ${yamlQuote(id)}`,
|
|
2134
|
+
`error: HUNK_NOT_FOUND`,
|
|
2135
|
+
`summary: ${yamlQuote(labels.notFound(id))}`,
|
|
2136
|
+
].join('\n'));
|
|
2137
|
+
return failed(content, [{ type: 'environment_msg', role: 'user', content }]);
|
|
2138
|
+
}
|
|
2139
|
+
if (plannedFileMod) {
|
|
2140
|
+
if (plannedFileMod.plannedBy !== caller.id) {
|
|
2141
|
+
const content = formatYamlCodeBlock([
|
|
2142
|
+
`status: error`,
|
|
2143
|
+
`mode: apply_file_modification`,
|
|
2144
|
+
`hunk_id: ${yamlQuote(id)}`,
|
|
2145
|
+
`error: WRONG_OWNER`,
|
|
2146
|
+
`summary: ${yamlQuote(labels.wrongOwner)}`,
|
|
2147
|
+
].join('\n'));
|
|
2148
|
+
return failed(content, [{ type: 'environment_msg', role: 'user', content }]);
|
|
2149
|
+
}
|
|
2150
|
+
if (!(0, access_control_1.hasWriteAccess)(caller, plannedFileMod.relPath)) {
|
|
2151
|
+
const content = (0, access_control_1.getAccessDeniedMessage)('write', plannedFileMod.relPath, language);
|
|
2152
|
+
return wrapTxtToolResult(language, [{ type: 'environment_msg', role: 'user', content }]);
|
|
2153
|
+
}
|
|
2154
|
+
const absKey = plannedFileMod.absPath;
|
|
2155
|
+
const res = await new Promise((resolve) => {
|
|
2156
|
+
enqueueFileApply(absKey, {
|
|
2157
|
+
priority: plannedFileMod.createdAtMs,
|
|
2158
|
+
tieBreaker: plannedFileMod.hunkId,
|
|
2159
|
+
run: async () => {
|
|
2160
|
+
try {
|
|
2161
|
+
pruneExpiredPlannedMods(Date.now());
|
|
2162
|
+
pruneExpiredPlannedBlockReplaces(Date.now());
|
|
2163
|
+
const p = plannedModsById.get(id);
|
|
2164
|
+
if (!p) {
|
|
2165
|
+
const content = formatYamlCodeBlock([
|
|
2166
|
+
`status: error`,
|
|
2167
|
+
`mode: apply_file_modification`,
|
|
2168
|
+
`hunk_id: ${yamlQuote(id)}`,
|
|
2169
|
+
`error: HUNK_NOT_FOUND`,
|
|
2170
|
+
`summary: ${yamlQuote(labels.notFound(id))}`,
|
|
2171
|
+
].join('\n'));
|
|
2172
|
+
resolve(failed(content, [{ type: 'environment_msg', role: 'user', content }]));
|
|
2173
|
+
return;
|
|
2174
|
+
}
|
|
2175
|
+
if (p.plannedBy !== caller.id) {
|
|
2176
|
+
const content = formatYamlCodeBlock([
|
|
2177
|
+
`status: error`,
|
|
2178
|
+
`mode: apply_file_modification`,
|
|
2179
|
+
`hunk_id: ${yamlQuote(id)}`,
|
|
2180
|
+
`error: WRONG_OWNER`,
|
|
2181
|
+
`summary: ${yamlQuote(labels.wrongOwner)}`,
|
|
2182
|
+
].join('\n'));
|
|
2183
|
+
resolve(failed(content, [{ type: 'environment_msg', role: 'user', content }]));
|
|
2184
|
+
return;
|
|
2185
|
+
}
|
|
2186
|
+
// Read current file (append hunks may allow creation).
|
|
2187
|
+
let fileExists = fs_1.default.existsSync(p.absPath);
|
|
2188
|
+
if (!fileExists && p.kind === 'append' && p.allowCreate) {
|
|
2189
|
+
fs_1.default.mkdirSync(path_1.default.dirname(p.absPath), { recursive: true });
|
|
2190
|
+
fileExists = true;
|
|
2191
|
+
fs_1.default.writeFileSync(p.absPath, '', 'utf8');
|
|
2192
|
+
}
|
|
2193
|
+
if (!fileExists) {
|
|
2194
|
+
const content = formatYamlCodeBlock([
|
|
2195
|
+
`status: error`,
|
|
2196
|
+
`mode: apply_file_modification`,
|
|
2197
|
+
`path: ${yamlQuote(p.relPath)}`,
|
|
2198
|
+
`hunk_id: ${yamlQuote(id)}`,
|
|
2199
|
+
`context_match: rejected`,
|
|
2200
|
+
`error: FILE_NOT_FOUND`,
|
|
2201
|
+
`summary: ${yamlQuote(language === 'zh'
|
|
2202
|
+
? '文件不存在,无法应用;请重新规划。'
|
|
2203
|
+
: 'File not found; cannot apply; re-plan it.')}`,
|
|
2204
|
+
].join('\n'));
|
|
2205
|
+
resolve(failed(content, [{ type: 'environment_msg', role: 'user', content }]));
|
|
2206
|
+
return;
|
|
2207
|
+
}
|
|
2208
|
+
const currentContent = fs_1.default.readFileSync(p.absPath, 'utf8');
|
|
2209
|
+
// Append is always applied at EOF; drift is reported via digest.
|
|
2210
|
+
if (p.kind === 'append' || (p.kind === 'range' && p.action === 'append')) {
|
|
2211
|
+
const currentDigest = sha256HexUtf8(currentContent);
|
|
2212
|
+
const plannedDigest = p.plannedFileDigestSha256;
|
|
2213
|
+
const contextMatch = plannedDigest !== undefined && plannedDigest === currentDigest ? 'exact' : 'fuzz';
|
|
2214
|
+
const fileChangedSincePreview = plannedDigest !== undefined && plannedDigest !== currentDigest;
|
|
2215
|
+
const currentLinesRaw = splitFileTextToLines(currentContent);
|
|
2216
|
+
const baseLines = isEmptyFileLines(currentLinesRaw) ? [] : currentLinesRaw;
|
|
2217
|
+
const nextLines = [...baseLines, ...p.newLines];
|
|
2218
|
+
const nextText = joinLinesForWrite(nextLines);
|
|
2219
|
+
fs_1.default.mkdirSync(path_1.default.dirname(p.absPath), { recursive: true });
|
|
2220
|
+
fs_1.default.writeFileSync(p.absPath, nextText, 'utf8');
|
|
2221
|
+
plannedModsById.delete(id);
|
|
2222
|
+
const fileLineCountBefore = fileLineCount(baseLines);
|
|
2223
|
+
const appendedLineCount = p.newLines.length;
|
|
2224
|
+
const appendStartLine = fileLineCountBefore + 1;
|
|
2225
|
+
const appendEndLine = appendStartLine + Math.max(0, appendedLineCount - 1);
|
|
2226
|
+
const evidenceBeforeTail = baseLines.slice(Math.max(0, baseLines.length - 2));
|
|
2227
|
+
const evidenceAppendPreview = p.newLines.length <= 2 ? p.newLines : p.newLines.slice(0, 2);
|
|
2228
|
+
const evidenceAfterTail = nextLines.slice(Math.max(0, nextLines.length - 2));
|
|
2229
|
+
const summary = language === 'zh'
|
|
2230
|
+
? `Apply:append 第 ${appendStartLine}–${appendEndLine} 行(+${appendedLineCount} 行);匹配=${contextMatch};hunk_id=${id}.`
|
|
2231
|
+
: `Apply: append lines ${appendStartLine}–${appendEndLine} (+${appendedLineCount} lines); matched ${contextMatch}; hunk_id=${id}.`;
|
|
2232
|
+
const yamlLines = [
|
|
2233
|
+
`status: ok`,
|
|
2234
|
+
`mode: apply_file_modification`,
|
|
2235
|
+
`path: ${yamlQuote(p.relPath)}`,
|
|
2236
|
+
`hunk_id: ${yamlQuote(id)}`,
|
|
2237
|
+
`action: append`,
|
|
2238
|
+
`append_range:`,
|
|
2239
|
+
` start: ${appendStartLine}`,
|
|
2240
|
+
` end: ${appendEndLine}`,
|
|
2241
|
+
`lines:`,
|
|
2242
|
+
` old: 0`,
|
|
2243
|
+
` new: ${appendedLineCount}`,
|
|
2244
|
+
` delta: ${appendedLineCount}`,
|
|
2245
|
+
`context_match: ${contextMatch}`,
|
|
2246
|
+
];
|
|
2247
|
+
if (contextMatch === 'fuzz') {
|
|
2248
|
+
yamlLines.push(`file_changed_since_preview: ${fileChangedSincePreview}`, `planned_file_digest_sha256: ${yamlQuote(plannedDigest ?? '')}`, `current_file_digest_sha256: ${yamlQuote(currentDigest)}`);
|
|
2249
|
+
}
|
|
2250
|
+
yamlLines.push(`apply_evidence:`, ` before_tail: ${yamlBlockScalarLines(evidenceBeforeTail, ' ')}`, ` appended_preview: ${yamlBlockScalarLines(evidenceAppendPreview, ' ')}`, ` after_tail: ${yamlBlockScalarLines(evidenceAfterTail, ' ')}`, `summary: ${yamlQuote(summary)}`);
|
|
2251
|
+
const yaml = yamlLines.join('\n');
|
|
2252
|
+
const content = `${labels.applied(p.relPath, id)}\n\n` +
|
|
2253
|
+
`${formatYamlCodeBlock(yaml)}\n\n` +
|
|
2254
|
+
`\`\`\`diff\n${p.unifiedDiff}\`\`\``;
|
|
2255
|
+
resolve(ok(content, [{ type: 'environment_msg', role: 'user', content }]));
|
|
2256
|
+
return;
|
|
2257
|
+
}
|
|
2258
|
+
if (p.kind === 'insert') {
|
|
2259
|
+
const currentLines = splitFileTextToLines(currentContent);
|
|
2260
|
+
let startIndex0 = -1;
|
|
2261
|
+
if (matchesAt(currentLines, p.startIndex0, p.oldLines)) {
|
|
2262
|
+
startIndex0 = p.startIndex0;
|
|
2263
|
+
}
|
|
2264
|
+
else {
|
|
2265
|
+
const all = findAllMatches(currentLines, p.oldLines);
|
|
2266
|
+
if (all.length === 0) {
|
|
2267
|
+
const summary = language === 'zh'
|
|
2268
|
+
? 'Apply rejected:文件内容已变化,无法定位该 hunk 目标位置;请重新 plan。'
|
|
2269
|
+
: 'Apply rejected: file content changed; unable to locate the hunk target; re-plan this hunk.';
|
|
2270
|
+
const yaml = [
|
|
2271
|
+
`status: error`,
|
|
2272
|
+
`mode: apply_file_modification`,
|
|
2273
|
+
`path: ${yamlQuote(p.relPath)}`,
|
|
2274
|
+
`hunk_id: ${yamlQuote(id)}`,
|
|
2275
|
+
`context_match: rejected`,
|
|
2276
|
+
`error: CONTENT_CHANGED`,
|
|
2277
|
+
`summary: ${yamlQuote(summary)}`,
|
|
2278
|
+
].join('\n');
|
|
2279
|
+
const content = formatYamlCodeBlock(yaml);
|
|
2280
|
+
resolve(failed(content, [{ type: 'environment_msg', role: 'user', content }]));
|
|
2281
|
+
return;
|
|
2282
|
+
}
|
|
2283
|
+
if (all.length === 1) {
|
|
2284
|
+
startIndex0 = all[0];
|
|
2285
|
+
}
|
|
2286
|
+
else {
|
|
2287
|
+
const filtered = filterByContext(currentLines, all, p.contextBefore, p.contextAfter, p.oldLines.length);
|
|
2288
|
+
if (filtered.length === 1) {
|
|
2289
|
+
startIndex0 = filtered[0];
|
|
2290
|
+
}
|
|
2291
|
+
else {
|
|
2292
|
+
const summary = language === 'zh'
|
|
2293
|
+
? 'Apply rejected:hunk 目标位置不唯一(多处匹配);请缩小范围或增加上下文后重新 plan。'
|
|
2294
|
+
: 'Apply rejected: ambiguous hunk target (multiple matches); re-plan with a narrower range or more context.';
|
|
2295
|
+
const yaml = [
|
|
2296
|
+
`status: error`,
|
|
2297
|
+
`mode: apply_file_modification`,
|
|
2298
|
+
`path: ${yamlQuote(p.relPath)}`,
|
|
2299
|
+
`hunk_id: ${yamlQuote(id)}`,
|
|
2300
|
+
`context_match: rejected`,
|
|
2301
|
+
`error: AMBIGUOUS_MATCH`,
|
|
2302
|
+
`summary: ${yamlQuote(summary)}`,
|
|
2303
|
+
].join('\n');
|
|
2304
|
+
const content = formatYamlCodeBlock(yaml);
|
|
2305
|
+
resolve(failed(content, [{ type: 'environment_msg', role: 'user', content }]));
|
|
2306
|
+
return;
|
|
2307
|
+
}
|
|
2308
|
+
}
|
|
2309
|
+
}
|
|
2310
|
+
const currentDigest = sha256HexUtf8(currentContent);
|
|
2311
|
+
const plannedDigest = p.plannedFileDigestSha256;
|
|
2312
|
+
const fileChangedSincePreview = plannedDigest !== undefined && plannedDigest !== currentDigest;
|
|
2313
|
+
const nextLines = [...currentLines];
|
|
2314
|
+
nextLines.splice(startIndex0, p.deleteCount, ...p.newLines);
|
|
2315
|
+
const nextText = joinLinesForWrite(nextLines);
|
|
2316
|
+
fs_1.default.writeFileSync(p.absPath, nextText, 'utf8');
|
|
2317
|
+
plannedModsById.delete(id);
|
|
2318
|
+
const contextMatch = startIndex0 === p.startIndex0 ? 'exact' : 'fuzz';
|
|
2319
|
+
const insertedLineCount = Math.max(0, p.newLines.length - 1);
|
|
2320
|
+
const insertedAtLine = p.insertion.position === 'after' ? startIndex0 + 2 : startIndex0 + 1;
|
|
2321
|
+
const insertedStartIndex0 = p.insertion.position === 'after' ? startIndex0 + 1 : startIndex0;
|
|
2322
|
+
const insertedLines = nextLines.slice(insertedStartIndex0, insertedStartIndex0 + insertedLineCount);
|
|
2323
|
+
const evidenceBefore = previewWindow(nextLines, insertedStartIndex0 - 2, 2);
|
|
2324
|
+
const evidenceRange = buildRangePreview(insertedLines);
|
|
2325
|
+
const evidenceAfter = previewWindow(nextLines, insertedStartIndex0 + insertedLineCount, 2);
|
|
2326
|
+
const summary = language === 'zh'
|
|
2327
|
+
? `Apply:insert 第 ${insertedAtLine} 起 +${insertedLineCount} 行;匹配=${contextMatch};hunk_id=${id}.`
|
|
2328
|
+
: `Apply: insert +${insertedLineCount} lines at line ${insertedAtLine}; matched ${contextMatch}; hunk_id=${id}.`;
|
|
2329
|
+
const yamlLines = [
|
|
2330
|
+
`status: ok`,
|
|
2331
|
+
`mode: apply_file_modification`,
|
|
2332
|
+
`path: ${yamlQuote(p.relPath)}`,
|
|
2333
|
+
`hunk_id: ${yamlQuote(id)}`,
|
|
2334
|
+
`action: insert`,
|
|
2335
|
+
`position: ${yamlQuote(p.insertion.position)}`,
|
|
2336
|
+
`anchor: ${yamlQuote(p.insertion.anchor)}`,
|
|
2337
|
+
`inserted_at_line: ${insertedAtLine}`,
|
|
2338
|
+
`inserted_line_count: ${insertedLineCount}`,
|
|
2339
|
+
`context_match: ${contextMatch}`,
|
|
2340
|
+
];
|
|
2341
|
+
if (contextMatch === 'fuzz') {
|
|
2342
|
+
yamlLines.push(`file_changed_since_preview: ${fileChangedSincePreview}`, `planned_file_digest_sha256: ${yamlQuote(plannedDigest ?? '')}`, `current_file_digest_sha256: ${yamlQuote(currentDigest)}`);
|
|
2343
|
+
}
|
|
2344
|
+
yamlLines.push(`apply_evidence:`, ` before: ${yamlBlockScalarLines(evidenceBefore, ' ')}`, ` range: ${yamlBlockScalarLines(evidenceRange, ' ')}`, ` after: ${yamlBlockScalarLines(evidenceAfter, ' ')}`, `summary: ${yamlQuote(summary)}`);
|
|
2345
|
+
const yaml = yamlLines.join('\n');
|
|
2346
|
+
const content = `${labels.applied(p.relPath, id)}\n\n` +
|
|
2347
|
+
`${formatYamlCodeBlock(yaml)}\n\n` +
|
|
2348
|
+
`\`\`\`diff\n${p.unifiedDiff}\`\`\``;
|
|
2349
|
+
resolve(ok(content, [{ type: 'environment_msg', role: 'user', content }]));
|
|
2350
|
+
return;
|
|
2351
|
+
}
|
|
2352
|
+
// Range replace/delete (non-append).
|
|
2353
|
+
const currentLines = splitFileTextToLines(currentContent);
|
|
2354
|
+
let startIndex0 = -1;
|
|
2355
|
+
if (matchesAt(currentLines, p.startIndex0, p.oldLines)) {
|
|
2356
|
+
startIndex0 = p.startIndex0;
|
|
2357
|
+
}
|
|
2358
|
+
else {
|
|
2359
|
+
const all = findAllMatches(currentLines, p.oldLines);
|
|
2360
|
+
if (all.length === 0) {
|
|
2361
|
+
const summary = language === 'zh'
|
|
2362
|
+
? 'Apply rejected:文件内容已变化,无法定位该 hunk 目标位置;请重新 plan。'
|
|
2363
|
+
: 'Apply rejected: file content changed; unable to locate the hunk target; re-plan this hunk.';
|
|
2364
|
+
const yaml = [
|
|
2365
|
+
`status: error`,
|
|
2366
|
+
`mode: apply_file_modification`,
|
|
2367
|
+
`path: ${yamlQuote(p.relPath)}`,
|
|
2368
|
+
`hunk_id: ${yamlQuote(id)}`,
|
|
2369
|
+
`context_match: rejected`,
|
|
2370
|
+
`error: CONTENT_CHANGED`,
|
|
2371
|
+
`summary: ${yamlQuote(summary)}`,
|
|
2372
|
+
].join('\n');
|
|
2373
|
+
const content = formatYamlCodeBlock(yaml);
|
|
2374
|
+
resolve(failed(content, [{ type: 'environment_msg', role: 'user', content }]));
|
|
2375
|
+
return;
|
|
2376
|
+
}
|
|
2377
|
+
if (all.length === 1) {
|
|
2378
|
+
startIndex0 = all[0];
|
|
2379
|
+
}
|
|
2380
|
+
else {
|
|
2381
|
+
const filtered = filterByContext(currentLines, all, p.contextBefore, p.contextAfter, p.oldLines.length);
|
|
2382
|
+
if (filtered.length === 1) {
|
|
2383
|
+
startIndex0 = filtered[0];
|
|
2384
|
+
}
|
|
2385
|
+
else {
|
|
2386
|
+
const summary = language === 'zh'
|
|
2387
|
+
? 'Apply rejected:hunk 目标位置不唯一(多处匹配);请缩小范围或增加上下文后重新 plan。'
|
|
2388
|
+
: 'Apply rejected: ambiguous hunk target (multiple matches); re-plan with a narrower range or more context.';
|
|
2389
|
+
const yaml = [
|
|
2390
|
+
`status: error`,
|
|
2391
|
+
`mode: apply_file_modification`,
|
|
2392
|
+
`path: ${yamlQuote(p.relPath)}`,
|
|
2393
|
+
`hunk_id: ${yamlQuote(id)}`,
|
|
2394
|
+
`context_match: rejected`,
|
|
2395
|
+
`error: AMBIGUOUS_MATCH`,
|
|
2396
|
+
`summary: ${yamlQuote(summary)}`,
|
|
2397
|
+
].join('\n');
|
|
2398
|
+
const content = formatYamlCodeBlock(yaml);
|
|
2399
|
+
resolve(failed(content, [{ type: 'environment_msg', role: 'user', content }]));
|
|
2400
|
+
return;
|
|
2401
|
+
}
|
|
2402
|
+
}
|
|
2403
|
+
}
|
|
2404
|
+
const currentDigest = sha256HexUtf8(currentContent);
|
|
2405
|
+
const plannedDigest = p.plannedFileDigestSha256;
|
|
2406
|
+
const fileChangedSincePreview = plannedDigest !== undefined && plannedDigest !== currentDigest;
|
|
2407
|
+
const nextLines = [...currentLines];
|
|
2408
|
+
nextLines.splice(startIndex0, p.deleteCount, ...p.newLines);
|
|
2409
|
+
const nextText = joinLinesForWrite(nextLines);
|
|
2410
|
+
fs_1.default.writeFileSync(p.absPath, nextText, 'utf8');
|
|
2411
|
+
plannedModsById.delete(id);
|
|
2412
|
+
const contextMatch = startIndex0 === p.startIndex0 ? 'exact' : 'fuzz';
|
|
2413
|
+
const action = p.action;
|
|
2414
|
+
const startLine = startIndex0 + 1;
|
|
2415
|
+
const endLine = action === 'delete'
|
|
2416
|
+
? startLine + p.deleteCount - 1
|
|
2417
|
+
: startLine + Math.max(0, p.newLines.length - 1);
|
|
2418
|
+
const evidenceBefore = previewWindow(nextLines, startIndex0 - 2, 2);
|
|
2419
|
+
const appliedRangeLines = action === 'delete'
|
|
2420
|
+
? []
|
|
2421
|
+
: nextLines.slice(startIndex0, startIndex0 + p.newLines.length);
|
|
2422
|
+
const evidenceRange = buildRangePreview(appliedRangeLines);
|
|
2423
|
+
const afterStartIndex0 = action === 'delete' ? startIndex0 : startIndex0 + p.newLines.length;
|
|
2424
|
+
const evidenceAfter = previewWindow(nextLines, afterStartIndex0, 2);
|
|
2425
|
+
const linesOld = p.deleteCount;
|
|
2426
|
+
const linesNew = p.newLines.length;
|
|
2427
|
+
const delta = linesNew - linesOld;
|
|
2428
|
+
const summary = language === 'zh'
|
|
2429
|
+
? `Apply:${action} 第 ${startLine}–${endLine} 行(old=${linesOld}, new=${linesNew}, delta=${delta});匹配=${contextMatch};hunk_id=${id}.`
|
|
2430
|
+
: `Apply: ${action} lines ${startLine}–${endLine} (old=${linesOld}, new=${linesNew}, delta=${delta}); matched ${contextMatch}; hunk_id=${id}.`;
|
|
2431
|
+
const yamlLines = [
|
|
2432
|
+
`status: ok`,
|
|
2433
|
+
`mode: apply_file_modification`,
|
|
2434
|
+
`path: ${yamlQuote(p.relPath)}`,
|
|
2435
|
+
`hunk_id: ${yamlQuote(id)}`,
|
|
2436
|
+
`action: ${action}`,
|
|
2437
|
+
`range:`,
|
|
2438
|
+
` applied:`,
|
|
2439
|
+
` start: ${startLine}`,
|
|
2440
|
+
` end: ${endLine}`,
|
|
2441
|
+
`lines:`,
|
|
2442
|
+
` old: ${linesOld}`,
|
|
2443
|
+
` new: ${linesNew}`,
|
|
2444
|
+
` delta: ${delta}`,
|
|
2445
|
+
`context_match: ${contextMatch}`,
|
|
2446
|
+
];
|
|
2447
|
+
if (contextMatch === 'fuzz') {
|
|
2448
|
+
yamlLines.push(`file_changed_since_preview: ${fileChangedSincePreview}`, `planned_file_digest_sha256: ${yamlQuote(plannedDigest ?? '')}`, `current_file_digest_sha256: ${yamlQuote(currentDigest)}`);
|
|
2449
|
+
}
|
|
2450
|
+
yamlLines.push(`apply_evidence:`, ` before: ${yamlBlockScalarLines(evidenceBefore, ' ')}`, ` range: ${yamlBlockScalarLines(evidenceRange, ' ')}`, ` after: ${yamlBlockScalarLines(evidenceAfter, ' ')}`, `summary: ${yamlQuote(summary)}`);
|
|
2451
|
+
const yaml = yamlLines.join('\n');
|
|
2452
|
+
const content = `${labels.applied(p.relPath, id)}\n\n` +
|
|
2453
|
+
`${formatYamlCodeBlock(yaml)}\n\n` +
|
|
2454
|
+
`\`\`\`diff\n${p.unifiedDiff}\`\`\``;
|
|
2455
|
+
resolve(ok(content, [{ type: 'environment_msg', role: 'user', content }]));
|
|
2456
|
+
}
|
|
2457
|
+
catch (error) {
|
|
2458
|
+
const content = labels.applyFailed(error instanceof Error ? error.message : String(error));
|
|
2459
|
+
resolve(wrapTxtToolResult(language, [{ type: 'environment_msg', role: 'user', content }]));
|
|
2460
|
+
}
|
|
2461
|
+
},
|
|
2462
|
+
});
|
|
2463
|
+
void drainFileApplyQueue(absKey);
|
|
2464
|
+
});
|
|
2465
|
+
return res;
|
|
2466
|
+
}
|
|
2467
|
+
// plannedBlockReplace must exist here.
|
|
2468
|
+
const planned = plannedBlockReplace;
|
|
2469
|
+
if (!planned) {
|
|
2470
|
+
const content = formatYamlCodeBlock([
|
|
2471
|
+
`status: error`,
|
|
2472
|
+
`mode: apply_file_modification`,
|
|
2473
|
+
`hunk_id: ${yamlQuote(id)}`,
|
|
2474
|
+
`error: HUNK_NOT_FOUND`,
|
|
2475
|
+
`summary: ${yamlQuote(labels.notFound(id))}`,
|
|
2476
|
+
].join('\n'));
|
|
2477
|
+
return failed(content, [{ type: 'environment_msg', role: 'user', content }]);
|
|
2478
|
+
}
|
|
2479
|
+
if (planned.plannedBy !== caller.id) {
|
|
2480
|
+
const content = formatYamlCodeBlock([
|
|
2481
|
+
`status: error`,
|
|
2482
|
+
`mode: apply_file_modification`,
|
|
2483
|
+
`path: ${yamlQuote(planned.relPath)}`,
|
|
2484
|
+
`hunk_id: ${yamlQuote(id)}`,
|
|
2485
|
+
`error: WRONG_OWNER`,
|
|
2486
|
+
`summary: ${yamlQuote(labels.wrongOwner)}`,
|
|
2487
|
+
].join('\n'));
|
|
2488
|
+
return failed(content, [{ type: 'environment_msg', role: 'user', content }]);
|
|
2489
|
+
}
|
|
2490
|
+
if (!(0, access_control_1.hasWriteAccess)(caller, planned.relPath)) {
|
|
2491
|
+
const content = (0, access_control_1.getAccessDeniedMessage)('write', planned.relPath, language);
|
|
2492
|
+
return wrapTxtToolResult(language, [{ type: 'environment_msg', role: 'user', content }]);
|
|
2493
|
+
}
|
|
2494
|
+
const absKey = planned.absPath;
|
|
2495
|
+
const res = await new Promise((resolve) => {
|
|
2496
|
+
enqueueFileApply(absKey, {
|
|
2497
|
+
priority: planned.createdAtMs,
|
|
2498
|
+
tieBreaker: planned.hunkId,
|
|
2499
|
+
run: async () => {
|
|
2500
|
+
try {
|
|
2501
|
+
pruneExpiredPlannedMods(Date.now());
|
|
2502
|
+
pruneExpiredPlannedBlockReplaces(Date.now());
|
|
2503
|
+
const p = plannedBlockReplacesById.get(id);
|
|
2504
|
+
if (!p) {
|
|
2505
|
+
const content = formatYamlCodeBlock([
|
|
2506
|
+
`status: error`,
|
|
2507
|
+
`mode: apply_file_modification`,
|
|
2508
|
+
`hunk_id: ${yamlQuote(id)}`,
|
|
2509
|
+
`error: HUNK_NOT_FOUND`,
|
|
2510
|
+
`summary: ${yamlQuote(labels.notFound(id))}`,
|
|
2511
|
+
].join('\n'));
|
|
2512
|
+
resolve(failed(content, [{ type: 'environment_msg', role: 'user', content }]));
|
|
2513
|
+
return;
|
|
2514
|
+
}
|
|
2515
|
+
if (p.plannedBy !== caller.id) {
|
|
2516
|
+
const content = formatYamlCodeBlock([
|
|
2517
|
+
`status: error`,
|
|
2518
|
+
`mode: apply_file_modification`,
|
|
2519
|
+
`path: ${yamlQuote(p.relPath)}`,
|
|
2520
|
+
`hunk_id: ${yamlQuote(id)}`,
|
|
2521
|
+
`error: WRONG_OWNER`,
|
|
2522
|
+
`summary: ${yamlQuote(labels.wrongOwner)}`,
|
|
2523
|
+
].join('\n'));
|
|
2524
|
+
resolve(failed(content, [{ type: 'environment_msg', role: 'user', content }]));
|
|
2525
|
+
return;
|
|
2526
|
+
}
|
|
2527
|
+
if (!fs_1.default.existsSync(p.absPath)) {
|
|
2528
|
+
const content = formatYamlCodeBlock([
|
|
2529
|
+
`status: error`,
|
|
2530
|
+
`mode: apply_file_modification`,
|
|
2531
|
+
`path: ${yamlQuote(p.relPath)}`,
|
|
2532
|
+
`hunk_id: ${yamlQuote(id)}`,
|
|
2533
|
+
`context_match: rejected`,
|
|
2534
|
+
`error: FILE_NOT_FOUND`,
|
|
2535
|
+
`summary: ${yamlQuote(language === 'zh'
|
|
2536
|
+
? '文件不存在,无法应用;请重新规划。'
|
|
2537
|
+
: 'File not found; cannot apply; re-plan it.')}`,
|
|
2538
|
+
].join('\n'));
|
|
2539
|
+
resolve(failed(content, [{ type: 'environment_msg', role: 'user', content }]));
|
|
2540
|
+
return;
|
|
2541
|
+
}
|
|
2542
|
+
const currentContent = fs_1.default.readFileSync(p.absPath, 'utf8');
|
|
2543
|
+
const currentDigest = sha256HexUtf8(currentContent);
|
|
2544
|
+
const plannedDigest = p.plannedFileDigestSha256;
|
|
2545
|
+
const fileChangedSincePreview = plannedDigest !== undefined && plannedDigest !== currentDigest;
|
|
2546
|
+
const fileEofHasNewline = currentContent === '' || currentContent.endsWith('\n');
|
|
2547
|
+
const normalizedFileEofNewlineAdded = currentContent !== '' && !currentContent.endsWith('\n');
|
|
2548
|
+
const lines = splitTextToLinesForEditing(currentContent);
|
|
2549
|
+
const isMatch = (line, anchor) => {
|
|
2550
|
+
return p.match === 'equals' ? line === anchor : line.includes(anchor);
|
|
2551
|
+
};
|
|
2552
|
+
const startMatches = [];
|
|
2553
|
+
const endMatches = [];
|
|
2554
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
2555
|
+
const line = lines[i] ?? '';
|
|
2556
|
+
if (isMatch(line, p.startAnchor))
|
|
2557
|
+
startMatches.push(i);
|
|
2558
|
+
if (isMatch(line, p.endAnchor))
|
|
2559
|
+
endMatches.push(i);
|
|
2560
|
+
}
|
|
2561
|
+
const pairs = [];
|
|
2562
|
+
for (const start0 of startMatches) {
|
|
2563
|
+
const end0 = endMatches.find((e) => e > start0);
|
|
2564
|
+
if (end0 !== undefined)
|
|
2565
|
+
pairs.push({ start0, end0 });
|
|
2566
|
+
}
|
|
2567
|
+
if (pairs.length === 0) {
|
|
2568
|
+
const summary = language === 'zh'
|
|
2569
|
+
? 'Apply rejected:anchors 未找到或无法配对;请重新 plan。'
|
|
2570
|
+
: 'Apply rejected: anchors not found or not paired; re-plan this hunk.';
|
|
2571
|
+
const yaml = [
|
|
2572
|
+
`status: error`,
|
|
2573
|
+
`mode: apply_file_modification`,
|
|
2574
|
+
`path: ${yamlQuote(p.relPath)}`,
|
|
2575
|
+
`hunk_id: ${yamlQuote(id)}`,
|
|
2576
|
+
`context_match: rejected`,
|
|
2577
|
+
`error: APPLY_REJECTED_ANCHOR_NOT_FOUND`,
|
|
2578
|
+
`summary: ${yamlQuote(summary)}`,
|
|
2579
|
+
].join('\n');
|
|
2580
|
+
const content = formatYamlCodeBlock(yaml);
|
|
2581
|
+
resolve(failed(content, [{ type: 'environment_msg', role: 'user', content }]));
|
|
2582
|
+
return;
|
|
2583
|
+
}
|
|
2584
|
+
if (!p.occurrenceSpecified && p.requireUnique && pairs.length !== 1) {
|
|
2585
|
+
const summary = language === 'zh'
|
|
2586
|
+
? `Apply rejected:anchors 歧义(${pairs.length} 个候选块);请重新 plan 并指定 occurrence。`
|
|
2587
|
+
: `Apply rejected: ambiguous anchors (${pairs.length} candidates); re-plan with occurrence specified.`;
|
|
2588
|
+
const yaml = [
|
|
2589
|
+
`status: error`,
|
|
2590
|
+
`mode: apply_file_modification`,
|
|
2591
|
+
`path: ${yamlQuote(p.relPath)}`,
|
|
2592
|
+
`hunk_id: ${yamlQuote(id)}`,
|
|
2593
|
+
`context_match: rejected`,
|
|
2594
|
+
`error: APPLY_REJECTED_ANCHOR_AMBIGUOUS`,
|
|
2595
|
+
`candidates_count: ${pairs.length}`,
|
|
2596
|
+
`summary: ${yamlQuote(summary)}`,
|
|
2597
|
+
].join('\n');
|
|
2598
|
+
const content = formatYamlCodeBlock(yaml);
|
|
2599
|
+
resolve(failed(content, [{ type: 'environment_msg', role: 'user', content }]));
|
|
2600
|
+
return;
|
|
2601
|
+
}
|
|
2602
|
+
const selected = (() => {
|
|
2603
|
+
if (pairs.length === 1)
|
|
2604
|
+
return pairs[0];
|
|
2605
|
+
if (p.occurrence.kind === 'last')
|
|
2606
|
+
return pairs[pairs.length - 1];
|
|
2607
|
+
return pairs[p.occurrence.index1 - 1];
|
|
2608
|
+
})();
|
|
2609
|
+
if (!selected) {
|
|
2610
|
+
const summary = language === 'zh'
|
|
2611
|
+
? 'Apply rejected:occurrence 超出范围;请重新 plan。'
|
|
2612
|
+
: 'Apply rejected: occurrence out of range; re-plan.';
|
|
2613
|
+
const yaml = [
|
|
2614
|
+
`status: error`,
|
|
2615
|
+
`mode: apply_file_modification`,
|
|
2616
|
+
`path: ${yamlQuote(p.relPath)}`,
|
|
2617
|
+
`hunk_id: ${yamlQuote(id)}`,
|
|
2618
|
+
`context_match: rejected`,
|
|
2619
|
+
`error: APPLY_REJECTED_OCCURRENCE_OUT_OF_RANGE`,
|
|
2620
|
+
`candidates_count: ${pairs.length}`,
|
|
2621
|
+
`summary: ${yamlQuote(summary)}`,
|
|
2622
|
+
].join('\n');
|
|
2623
|
+
const content = formatYamlCodeBlock(yaml);
|
|
2624
|
+
resolve(failed(content, [{ type: 'environment_msg', role: 'user', content }]));
|
|
2625
|
+
return;
|
|
2626
|
+
}
|
|
2627
|
+
const nestedStart = startMatches.some((s) => s > selected.start0 && s < selected.end0);
|
|
2628
|
+
const nestedEnd = endMatches.some((e) => e > selected.start0 && e < selected.end0);
|
|
2629
|
+
if (nestedStart || nestedEnd) {
|
|
2630
|
+
const summary = language === 'zh'
|
|
2631
|
+
? 'Apply rejected:检测到嵌套/歧义锚点;请重新 plan。'
|
|
2632
|
+
: 'Apply rejected: nested/ambiguous anchors detected; re-plan.';
|
|
2633
|
+
const yaml = [
|
|
2634
|
+
`status: error`,
|
|
2635
|
+
`mode: apply_file_modification`,
|
|
2636
|
+
`path: ${yamlQuote(p.relPath)}`,
|
|
2637
|
+
`hunk_id: ${yamlQuote(id)}`,
|
|
2638
|
+
`context_match: rejected`,
|
|
2639
|
+
`error: APPLY_REJECTED_ANCHOR_AMBIGUOUS`,
|
|
2640
|
+
`summary: ${yamlQuote(summary)}`,
|
|
2641
|
+
].join('\n');
|
|
2642
|
+
const content = formatYamlCodeBlock(yaml);
|
|
2643
|
+
resolve(failed(content, [{ type: 'environment_msg', role: 'user', content }]));
|
|
2644
|
+
return;
|
|
2645
|
+
}
|
|
2646
|
+
const replaceStart0 = p.includeAnchors ? selected.start0 + 1 : selected.start0;
|
|
2647
|
+
const replaceDeleteCount = p.includeAnchors
|
|
2648
|
+
? Math.max(0, selected.end0 - selected.start0 - 1)
|
|
2649
|
+
: selected.end0 - selected.start0 + 1;
|
|
2650
|
+
const currentOldLines = lines.slice(replaceStart0, replaceStart0 + replaceDeleteCount);
|
|
2651
|
+
const same = currentOldLines.length === p.oldLines.length &&
|
|
2652
|
+
currentOldLines.every((v, i) => v === p.oldLines[i]);
|
|
2653
|
+
if (!same) {
|
|
2654
|
+
const summary = language === 'zh'
|
|
2655
|
+
? 'Apply rejected:文件内容已变化(目标块内容与规划时不一致);请重新 plan。'
|
|
2656
|
+
: 'Apply rejected: file content changed (target block no longer matches the planned content); re-plan.';
|
|
2657
|
+
const yaml = [
|
|
2658
|
+
`status: error`,
|
|
2659
|
+
`mode: apply_file_modification`,
|
|
2660
|
+
`path: ${yamlQuote(p.relPath)}`,
|
|
2661
|
+
`hunk_id: ${yamlQuote(id)}`,
|
|
2662
|
+
`context_match: rejected`,
|
|
2663
|
+
`error: APPLY_REJECTED_CONTENT_CHANGED`,
|
|
2664
|
+
`summary: ${yamlQuote(summary)}`,
|
|
2665
|
+
].join('\n');
|
|
2666
|
+
const content = formatYamlCodeBlock(yaml);
|
|
2667
|
+
resolve(failed(content, [{ type: 'environment_msg', role: 'user', content }]));
|
|
2668
|
+
return;
|
|
2669
|
+
}
|
|
2670
|
+
const outLines = [...lines];
|
|
2671
|
+
outLines.splice(replaceStart0, replaceDeleteCount, ...p.newLines);
|
|
2672
|
+
const out = joinLinesForTextWrite(outLines);
|
|
2673
|
+
fs_1.default.writeFileSync(p.absPath, out, 'utf8');
|
|
2674
|
+
plannedBlockReplacesById.delete(id);
|
|
2675
|
+
const locationMatch = selected.start0 === p.selectedStart0 &&
|
|
2676
|
+
selected.end0 === p.selectedEnd0 &&
|
|
2677
|
+
replaceStart0 === p.replaceStart0 &&
|
|
2678
|
+
replaceDeleteCount === p.deleteCount;
|
|
2679
|
+
const contextMatch = locationMatch ? 'exact' : 'fuzz';
|
|
2680
|
+
const oldCount = replaceDeleteCount;
|
|
2681
|
+
const newCount = p.newLines.length;
|
|
2682
|
+
const delta = newCount - oldCount;
|
|
2683
|
+
const oldPreview = buildRangePreview(p.oldLines);
|
|
2684
|
+
const newPreview = buildRangePreview(p.newLines);
|
|
2685
|
+
const summary = language === 'zh'
|
|
2686
|
+
? `Apply:block_replace old=${oldCount}, new=${newCount}, delta=${delta};匹配=${contextMatch};hunk_id=${id}.`
|
|
2687
|
+
: `Apply: block_replace old=${oldCount}, new=${newCount}, delta=${delta}; matched ${contextMatch}; hunk_id=${id}.`;
|
|
2688
|
+
const yamlLines = [
|
|
2689
|
+
`status: ok`,
|
|
2690
|
+
`mode: apply_file_modification`,
|
|
2691
|
+
`path: ${yamlQuote(p.relPath)}`,
|
|
2692
|
+
`hunk_id: ${yamlQuote(id)}`,
|
|
2693
|
+
`action: block_replace`,
|
|
2694
|
+
`block_range:`,
|
|
2695
|
+
` start_line: ${selected.start0 + 1}`,
|
|
2696
|
+
` end_line: ${selected.end0 + 1}`,
|
|
2697
|
+
`replace_slice:`,
|
|
2698
|
+
` start_line: ${replaceStart0 + 1}`,
|
|
2699
|
+
` delete_count: ${replaceDeleteCount}`,
|
|
2700
|
+
`lines:`,
|
|
2701
|
+
` old: ${oldCount}`,
|
|
2702
|
+
` new: ${newCount}`,
|
|
2703
|
+
` delta: ${delta}`,
|
|
2704
|
+
`context_match: ${contextMatch}`,
|
|
2705
|
+
];
|
|
2706
|
+
if (contextMatch === 'fuzz') {
|
|
2707
|
+
yamlLines.push(`file_changed_since_preview: ${fileChangedSincePreview}`, `planned_file_digest_sha256: ${yamlQuote(plannedDigest ?? '')}`, `current_file_digest_sha256: ${yamlQuote(currentDigest)}`);
|
|
2708
|
+
}
|
|
2709
|
+
yamlLines.push(`normalized:`, ` file_eof_has_newline: ${fileEofHasNewline}`, ` content_eof_has_newline: ${p.normalized.contentEofHasNewline}`, ` normalized_file_eof_newline_added: ${normalizedFileEofNewlineAdded}`, ` normalized_content_eof_newline_added: ${p.normalized.normalizedContentEofNewlineAdded}`, `apply_evidence:`, ` before_preview: ${yamlFlowStringArray([lines[selected.start0] ?? ''])}`, ` old_preview: ${yamlFlowStringArray(oldPreview)}`, ` new_preview: ${yamlFlowStringArray(newPreview)}`, ` after_preview: ${yamlFlowStringArray([lines[selected.end0] ?? ''])}`, `summary: ${yamlQuote(summary)}`);
|
|
2710
|
+
const yaml = yamlLines.join('\n');
|
|
2711
|
+
const content = `${labels.applied(p.relPath, id)}\n\n` +
|
|
2712
|
+
`${formatYamlCodeBlock(yaml)}\n\n` +
|
|
2713
|
+
`\`\`\`diff\n${p.unifiedDiff}\`\`\``;
|
|
2714
|
+
resolve(ok(content, [{ type: 'environment_msg', role: 'user', content }]));
|
|
2715
|
+
}
|
|
2716
|
+
catch (error) {
|
|
2717
|
+
const content = labels.applyFailed(error instanceof Error ? error.message : String(error));
|
|
2718
|
+
resolve(wrapTxtToolResult(language, [{ type: 'environment_msg', role: 'user', content }]));
|
|
2719
|
+
}
|
|
2720
|
+
},
|
|
2721
|
+
});
|
|
2722
|
+
void drainFileApplyQueue(absKey);
|
|
2723
|
+
});
|
|
2724
|
+
return res;
|
|
2725
|
+
}
|
|
2726
|
+
catch (error) {
|
|
2727
|
+
const content = labels.applyFailed(error instanceof Error ? error.message : String(error));
|
|
2728
|
+
return wrapTxtToolResult(language, [{ type: 'environment_msg', role: 'user', content }]);
|
|
2729
|
+
}
|
|
2730
|
+
}
|
|
2731
|
+
exports.applyFileModificationTool = {
|
|
2732
|
+
type: 'func',
|
|
2733
|
+
name: 'apply_file_modification',
|
|
2734
|
+
description: 'Apply a prepared file modification by hunk id (writes the file).',
|
|
2735
|
+
parameters: {
|
|
2736
|
+
type: 'object',
|
|
2737
|
+
additionalProperties: false,
|
|
2738
|
+
properties: {
|
|
2739
|
+
hunk_id: { type: 'string' },
|
|
2740
|
+
},
|
|
2741
|
+
required: ['hunk_id'],
|
|
2742
|
+
},
|
|
2743
|
+
argsValidation: 'dominds',
|
|
2744
|
+
call: async (_dlg, caller, args) => {
|
|
2745
|
+
const raw = requireNonEmptyStringArg(args, 'hunk_id');
|
|
2746
|
+
const id = normalizeExistingHunkId(raw) ?? '';
|
|
2747
|
+
if (!id)
|
|
2748
|
+
throw new Error('Invalid arguments: `hunk_id` must be a non-empty string');
|
|
2749
|
+
if (!isValidHunkId(id)) {
|
|
2750
|
+
throw new Error("Invalid arguments: `hunk_id` must be a hunk id like 'a1b2c3d4' (letters/digits/_/-)");
|
|
2751
|
+
}
|
|
2752
|
+
const res = await runApplyFileModification(caller, id);
|
|
2753
|
+
return unwrapTxtToolResult(res);
|
|
2754
|
+
},
|
|
2755
|
+
};
|
|
2756
|
+
async function runPrepareBlockReplace(caller, options) {
|
|
2757
|
+
const language = (0, runtime_language_1.getWorkLanguage)();
|
|
2758
|
+
const filePath = options.filePath;
|
|
2759
|
+
const startAnchor = options.startAnchor;
|
|
2760
|
+
const endAnchor = options.endAnchor;
|
|
2761
|
+
const inputBody = options.inputBody;
|
|
2762
|
+
const requestedId = options.requestedId;
|
|
2763
|
+
const occurrence = options.occurrence;
|
|
2764
|
+
const occurrenceSpecified = options.occurrenceSpecified;
|
|
2765
|
+
const includeAnchors = options.includeAnchors;
|
|
2766
|
+
const match = options.match;
|
|
2767
|
+
const requireUnique = options.requireUnique;
|
|
2768
|
+
const strict = options.strict;
|
|
2769
|
+
if (!filePath || !startAnchor || !endAnchor) {
|
|
2770
|
+
const content = formatYamlCodeBlock([
|
|
2771
|
+
`status: error`,
|
|
2772
|
+
`mode: prepare_file_block_replace`,
|
|
2773
|
+
`error: INVALID_FORMAT`,
|
|
2774
|
+
`summary: ${yamlQuote(language === 'zh'
|
|
2775
|
+
? '需要提供 path、start_anchor、end_anchor。'
|
|
2776
|
+
: 'path, start_anchor, and end_anchor are required.')}`,
|
|
2777
|
+
].join('\n'));
|
|
2778
|
+
return failed(content, [{ type: 'environment_msg', role: 'user', content }]);
|
|
2779
|
+
}
|
|
2780
|
+
if (!(0, access_control_1.hasWriteAccess)(caller, filePath)) {
|
|
2781
|
+
const content = (0, access_control_1.getAccessDeniedMessage)('write', filePath, language);
|
|
2782
|
+
return wrapTxtToolResult(language, [{ type: 'environment_msg', role: 'user', content }]);
|
|
2783
|
+
}
|
|
2784
|
+
if (inputBody === '') {
|
|
2785
|
+
const content = formatYamlCodeBlock([
|
|
2786
|
+
`status: error`,
|
|
2787
|
+
`path: ${yamlQuote(filePath)}`,
|
|
2788
|
+
`mode: prepare_file_block_replace`,
|
|
2789
|
+
`error: CONTENT_REQUIRED`,
|
|
2790
|
+
`summary: ${yamlQuote(language === 'zh'
|
|
2791
|
+
? '正文不能为空(需要提供要写入块内的新内容)。'
|
|
2792
|
+
: 'Content is required in the body (new block content).')}`,
|
|
2793
|
+
].join('\n'));
|
|
2794
|
+
return failed(content, [{ type: 'environment_msg', role: 'user', content }]);
|
|
2795
|
+
}
|
|
2796
|
+
try {
|
|
2797
|
+
pruneExpiredPlannedMods(Date.now());
|
|
2798
|
+
pruneExpiredPlannedBlockReplaces(Date.now());
|
|
2799
|
+
const fullPath = ensureInsideWorkspace(filePath);
|
|
2800
|
+
if (requestedId) {
|
|
2801
|
+
const existingBlockReplace = plannedBlockReplacesById.get(requestedId);
|
|
2802
|
+
if (!existingBlockReplace) {
|
|
2803
|
+
const wrongMode = plannedModsById.has(requestedId);
|
|
2804
|
+
const content = formatYamlCodeBlock([
|
|
2805
|
+
`status: error`,
|
|
2806
|
+
`mode: prepare_file_block_replace`,
|
|
2807
|
+
`path: ${yamlQuote(filePath)}`,
|
|
2808
|
+
`hunk_id: ${yamlQuote(requestedId)}`,
|
|
2809
|
+
`error: ${wrongMode ? 'WRONG_MODE' : 'HUNK_NOT_FOUND'}`,
|
|
2810
|
+
`summary: ${yamlQuote(wrongMode
|
|
2811
|
+
? language === 'zh'
|
|
2812
|
+
? '该 hunk id 不是由 prepare_file_block_replace 生成的,不能用该工具覆写。'
|
|
2813
|
+
: 'This hunk was not generated by prepare_file_block_replace; cannot overwrite.'
|
|
2814
|
+
: language === 'zh'
|
|
2815
|
+
? '该 hunk id 不存在(可能已过期/已被应用)。不支持自定义新 id;要生成新 id,请将 existing_hunk_id 设为空字符串。'
|
|
2816
|
+
: 'Hunk not found (expired or already applied). Custom new ids are not allowed; set existing_hunk_id to an empty string to generate a new one.')}`,
|
|
2817
|
+
].join('\n'));
|
|
2818
|
+
return failed(content, [{ type: 'environment_msg', role: 'user', content }]);
|
|
2819
|
+
}
|
|
2820
|
+
if (existingBlockReplace.plannedBy !== caller.id) {
|
|
2821
|
+
const content = formatYamlCodeBlock([
|
|
2822
|
+
`status: error`,
|
|
2823
|
+
`mode: prepare_file_block_replace`,
|
|
2824
|
+
`path: ${yamlQuote(filePath)}`,
|
|
2825
|
+
`hunk_id: ${yamlQuote(requestedId)}`,
|
|
2826
|
+
`error: WRONG_OWNER`,
|
|
2827
|
+
`summary: ${yamlQuote(language === 'zh'
|
|
2828
|
+
? '该 hunk id 不是由当前成员规划的,不能覆写。'
|
|
2829
|
+
: 'This hunk was planned by a different member; cannot overwrite.')}`,
|
|
2830
|
+
].join('\n'));
|
|
2831
|
+
return failed(content, [{ type: 'environment_msg', role: 'user', content }]);
|
|
2832
|
+
}
|
|
2833
|
+
}
|
|
2834
|
+
if (!fs_1.default.existsSync(fullPath)) {
|
|
2835
|
+
const content = formatYamlCodeBlock([
|
|
2836
|
+
`status: error`,
|
|
2837
|
+
`path: ${yamlQuote(filePath)}`,
|
|
2838
|
+
`mode: prepare_file_block_replace`,
|
|
2839
|
+
`error: FILE_NOT_FOUND`,
|
|
2840
|
+
`summary: ${yamlQuote(language === 'zh' ? '文件不存在。' : 'File does not exist.')}`,
|
|
2841
|
+
].join('\n'));
|
|
2842
|
+
return failed(content, [{ type: 'environment_msg', role: 'user', content }]);
|
|
2843
|
+
}
|
|
2844
|
+
const existing = fs_1.default.readFileSync(fullPath, 'utf8');
|
|
2845
|
+
const fileEofHasNewline = existing === '' || existing.endsWith('\n');
|
|
2846
|
+
const normalizedFileEofNewlineAdded = existing !== '' && !existing.endsWith('\n');
|
|
2847
|
+
const lines = splitTextToLinesForEditing(existing);
|
|
2848
|
+
const isMatch = (line, anchor) => {
|
|
2849
|
+
return match === 'equals' ? line === anchor : line.includes(anchor);
|
|
2850
|
+
};
|
|
2851
|
+
const startMatches = [];
|
|
2852
|
+
const endMatches = [];
|
|
2853
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2854
|
+
const line = lines[i] ?? '';
|
|
2855
|
+
if (isMatch(line, startAnchor))
|
|
2856
|
+
startMatches.push(i);
|
|
2857
|
+
if (isMatch(line, endAnchor))
|
|
2858
|
+
endMatches.push(i);
|
|
2859
|
+
}
|
|
2860
|
+
const pairs = [];
|
|
2861
|
+
for (const start0 of startMatches) {
|
|
2862
|
+
const end0 = endMatches.find((e) => e > start0);
|
|
2863
|
+
if (end0 !== undefined)
|
|
2864
|
+
pairs.push({ start0, end0 });
|
|
2865
|
+
}
|
|
2866
|
+
const candidatesCount = pairs.length;
|
|
2867
|
+
if (candidatesCount === 0) {
|
|
2868
|
+
const content = formatYamlCodeBlock([
|
|
2869
|
+
`status: error`,
|
|
2870
|
+
`path: ${yamlQuote(filePath)}`,
|
|
2871
|
+
`mode: prepare_file_block_replace`,
|
|
2872
|
+
`start_anchor: ${yamlQuote(startAnchor)}`,
|
|
2873
|
+
`end_anchor: ${yamlQuote(endAnchor)}`,
|
|
2874
|
+
`candidates_count: 0`,
|
|
2875
|
+
`error: ANCHOR_NOT_FOUND`,
|
|
2876
|
+
`summary: ${yamlQuote(language === 'zh'
|
|
2877
|
+
? '锚点未找到或无法配对。请改用 prepare_file_range_edit(行号范围精确编辑)。'
|
|
2878
|
+
: 'Anchors not found or not paired. Use prepare_file_range_edit (line-range precise edits).')}`,
|
|
2879
|
+
].join('\n'));
|
|
2880
|
+
return failed(content, [{ type: 'environment_msg', role: 'user', content }]);
|
|
2881
|
+
}
|
|
2882
|
+
if (!occurrenceSpecified && requireUnique && candidatesCount !== 1 && strict) {
|
|
2883
|
+
const content = formatYamlCodeBlock([
|
|
2884
|
+
`status: error`,
|
|
2885
|
+
`path: ${yamlQuote(filePath)}`,
|
|
2886
|
+
`mode: prepare_file_block_replace`,
|
|
2887
|
+
`start_anchor: ${yamlQuote(startAnchor)}`,
|
|
2888
|
+
`end_anchor: ${yamlQuote(endAnchor)}`,
|
|
2889
|
+
`candidates_count: ${candidatesCount}`,
|
|
2890
|
+
`error: ANCHOR_AMBIGUOUS`,
|
|
2891
|
+
`summary: ${yamlQuote(language === 'zh'
|
|
2892
|
+
? `锚点歧义:存在 ${candidatesCount} 个候选块。请指定 occurrence=<n|last>,或改用 prepare_file_range_edit(行号范围)。`
|
|
2893
|
+
: `Ambiguous anchors: ${candidatesCount} candidate block(s). Specify occurrence=<n|last>, or use prepare_file_range_edit (line range).`)}`,
|
|
2894
|
+
].join('\n'));
|
|
2895
|
+
return failed(content, [{ type: 'environment_msg', role: 'user', content }]);
|
|
2896
|
+
}
|
|
2897
|
+
const selected = (() => {
|
|
2898
|
+
if (candidatesCount === 1)
|
|
2899
|
+
return pairs[0];
|
|
2900
|
+
if (occurrence.kind === 'last')
|
|
2901
|
+
return pairs[pairs.length - 1];
|
|
2902
|
+
const idx0 = occurrence.index1 - 1;
|
|
2903
|
+
return pairs[idx0];
|
|
2904
|
+
})();
|
|
2905
|
+
if (!selected) {
|
|
2906
|
+
const content = formatYamlCodeBlock([
|
|
2907
|
+
`status: error`,
|
|
2908
|
+
`path: ${yamlQuote(filePath)}`,
|
|
2909
|
+
`mode: prepare_file_block_replace`,
|
|
2910
|
+
`start_anchor: ${yamlQuote(startAnchor)}`,
|
|
2911
|
+
`end_anchor: ${yamlQuote(endAnchor)}`,
|
|
2912
|
+
`candidates_count: ${candidatesCount}`,
|
|
2913
|
+
`error: OCCURRENCE_OUT_OF_RANGE`,
|
|
2914
|
+
`summary: ${yamlQuote(language === 'zh' ? 'occurrence 超出范围。' : 'occurrence is out of range.')}`,
|
|
2915
|
+
].join('\n'));
|
|
2916
|
+
return failed(content, [{ type: 'environment_msg', role: 'user', content }]);
|
|
2917
|
+
}
|
|
2918
|
+
const nestedStart = startMatches.some((s) => s > selected.start0 && s < selected.end0);
|
|
2919
|
+
const nestedEnd = endMatches.some((e) => e > selected.start0 && e < selected.end0);
|
|
2920
|
+
if (nestedStart || nestedEnd) {
|
|
2921
|
+
const content = formatYamlCodeBlock([
|
|
2922
|
+
`status: error`,
|
|
2923
|
+
`path: ${yamlQuote(filePath)}`,
|
|
2924
|
+
`mode: prepare_file_block_replace`,
|
|
2925
|
+
`start_anchor: ${yamlQuote(startAnchor)}`,
|
|
2926
|
+
`end_anchor: ${yamlQuote(endAnchor)}`,
|
|
2927
|
+
`candidates_count: ${candidatesCount}`,
|
|
2928
|
+
`error: ANCHOR_AMBIGUOUS`,
|
|
2929
|
+
`summary: ${yamlQuote(language === 'zh'
|
|
2930
|
+
? '检测到嵌套/歧义锚点,拒绝规划。请先规范 anchors,或改用 prepare_file_range_edit(行号范围)。'
|
|
2931
|
+
: 'Nested/ambiguous anchors detected. Refusing to prepare; normalize anchors or use prepare_file_range_edit (line range).')}`,
|
|
2932
|
+
].join('\n'));
|
|
2933
|
+
return failed(content, [{ type: 'environment_msg', role: 'user', content }]);
|
|
2934
|
+
}
|
|
2935
|
+
const occurrenceResolved = candidatesCount === 1 ? '1' : occurrence.kind === 'last' ? 'last' : String(occurrence.index1);
|
|
2936
|
+
const { normalizedBody, addedTrailingNewlineToContent } = normalizeFileWriteBody(inputBody);
|
|
2937
|
+
const contentEofHasNewline = inputBody.endsWith('\n');
|
|
2938
|
+
const normalizedContentEofNewlineAdded = addedTrailingNewlineToContent;
|
|
2939
|
+
const normalized = {
|
|
2940
|
+
fileEofHasNewline,
|
|
2941
|
+
contentEofHasNewline,
|
|
2942
|
+
normalizedFileEofNewlineAdded,
|
|
2943
|
+
normalizedContentEofNewlineAdded,
|
|
2944
|
+
};
|
|
2945
|
+
const replacementLines = splitPlannedBodyLines(normalizedBody);
|
|
2946
|
+
const replaceStart0 = includeAnchors ? selected.start0 + 1 : selected.start0;
|
|
2947
|
+
const replaceDeleteCount = includeAnchors
|
|
2948
|
+
? Math.max(0, selected.end0 - selected.start0 - 1)
|
|
2949
|
+
: selected.end0 - selected.start0 + 1;
|
|
2950
|
+
const oldLines = lines.slice(replaceStart0, replaceStart0 + replaceDeleteCount);
|
|
2951
|
+
const unifiedDiff = buildUnifiedSingleHunkDiff(filePath, lines, replaceStart0, replaceDeleteCount, replacementLines);
|
|
2952
|
+
const nowMs = Date.now();
|
|
2953
|
+
const hunkId = (() => {
|
|
2954
|
+
if (requestedId)
|
|
2955
|
+
return requestedId;
|
|
2956
|
+
for (let i = 0; i < 10; i += 1) {
|
|
2957
|
+
const id = generateHunkId();
|
|
2958
|
+
if (!plannedModsById.has(id) && !plannedBlockReplacesById.has(id))
|
|
2959
|
+
return id;
|
|
2960
|
+
}
|
|
2961
|
+
throw new Error('Failed to generate a unique hunk id');
|
|
2962
|
+
})();
|
|
2963
|
+
const planned = {
|
|
2964
|
+
hunkId,
|
|
2965
|
+
plannedBy: caller.id,
|
|
2966
|
+
createdAtMs: nowMs,
|
|
2967
|
+
expiresAtMs: nowMs + PLANNED_MOD_TTL_MS,
|
|
2968
|
+
relPath: filePath,
|
|
2969
|
+
absPath: fullPath,
|
|
2970
|
+
startAnchor,
|
|
2971
|
+
endAnchor,
|
|
2972
|
+
match,
|
|
2973
|
+
includeAnchors,
|
|
2974
|
+
requireUnique,
|
|
2975
|
+
strict,
|
|
2976
|
+
occurrence,
|
|
2977
|
+
occurrenceSpecified,
|
|
2978
|
+
selectedStart0: selected.start0,
|
|
2979
|
+
selectedEnd0: selected.end0,
|
|
2980
|
+
replaceStart0,
|
|
2981
|
+
deleteCount: replaceDeleteCount,
|
|
2982
|
+
oldLines,
|
|
2983
|
+
newLines: replacementLines,
|
|
2984
|
+
unifiedDiff,
|
|
2985
|
+
normalized,
|
|
2986
|
+
plannedFileDigestSha256: sha256HexUtf8(existing),
|
|
2987
|
+
};
|
|
2988
|
+
plannedBlockReplacesById.set(hunkId, planned);
|
|
2989
|
+
const oldCount = replaceDeleteCount;
|
|
2990
|
+
const newCount = replacementLines.length;
|
|
2991
|
+
const delta = newCount - oldCount;
|
|
2992
|
+
const oldPreview = buildRangePreview(oldLines);
|
|
2993
|
+
const newPreview = buildRangePreview(replacementLines);
|
|
2994
|
+
const summary = language === 'zh'
|
|
2995
|
+
? `Plan-block-replace:候选=${candidatesCount};block 第 ${selected.start0 + 1}–${selected.end0 + 1} 行;old=${oldCount}, new=${newCount}, delta=${delta};hunk_id=${hunkId}.`
|
|
2996
|
+
: `Plan-block-replace: candidates=${candidatesCount}; block lines ${selected.start0 + 1}–${selected.end0 + 1}; old=${oldCount}, new=${newCount}, delta=${delta}; hunk_id=${hunkId}.`;
|
|
2997
|
+
const yaml = [
|
|
2998
|
+
`status: ok`,
|
|
2999
|
+
`mode: prepare_file_block_replace`,
|
|
3000
|
+
`path: ${yamlQuote(filePath)}`,
|
|
3001
|
+
`action: block_replace`,
|
|
3002
|
+
`start_anchor: ${yamlQuote(startAnchor)}`,
|
|
3003
|
+
`end_anchor: ${yamlQuote(endAnchor)}`,
|
|
3004
|
+
`match: ${yamlQuote(match)}`,
|
|
3005
|
+
`include_anchors: ${includeAnchors}`,
|
|
3006
|
+
`require_unique: ${requireUnique}`,
|
|
3007
|
+
`strict: ${strict}`,
|
|
3008
|
+
`candidates_count: ${candidatesCount}`,
|
|
3009
|
+
`occurrence_resolved: ${yamlQuote(occurrenceResolved)}`,
|
|
3010
|
+
`hunk_id: ${yamlQuote(hunkId)}`,
|
|
3011
|
+
`reused_hunk_id: ${requestedId ? 'true' : 'false'}`,
|
|
3012
|
+
`expires_at_ms: ${planned.expiresAtMs}`,
|
|
3013
|
+
`block_range:`,
|
|
3014
|
+
` start_line: ${selected.start0 + 1}`,
|
|
3015
|
+
` end_line: ${selected.end0 + 1}`,
|
|
3016
|
+
`replace_slice:`,
|
|
3017
|
+
` start_line: ${replaceStart0 + 1}`,
|
|
3018
|
+
` delete_count: ${replaceDeleteCount}`,
|
|
3019
|
+
`lines:`,
|
|
3020
|
+
` old: ${oldCount}`,
|
|
3021
|
+
` new: ${newCount}`,
|
|
3022
|
+
` delta: ${delta}`,
|
|
3023
|
+
`normalized:`,
|
|
3024
|
+
` file_eof_has_newline: ${normalized.fileEofHasNewline}`,
|
|
3025
|
+
` content_eof_has_newline: ${normalized.contentEofHasNewline}`,
|
|
3026
|
+
` normalized_file_eof_newline_added: ${normalized.normalizedFileEofNewlineAdded}`,
|
|
3027
|
+
` normalized_content_eof_newline_added: ${normalized.normalizedContentEofNewlineAdded}`,
|
|
3028
|
+
`evidence_preview:`,
|
|
3029
|
+
` before_preview: ${yamlFlowStringArray([lines[selected.start0] ?? ''])}`,
|
|
3030
|
+
` old_preview: ${yamlFlowStringArray(oldPreview)}`,
|
|
3031
|
+
` new_preview: ${yamlFlowStringArray(newPreview)}`,
|
|
3032
|
+
` after_preview: ${yamlFlowStringArray([lines[selected.end0] ?? ''])}`,
|
|
3033
|
+
`summary: ${yamlQuote(summary)}`,
|
|
3034
|
+
].join('\n');
|
|
3035
|
+
const content = `${formatYamlCodeBlock(yaml)}\n\n` +
|
|
3036
|
+
`\`\`\`diff\n${unifiedDiff}\`\`\`\n\n` +
|
|
3037
|
+
(language === 'zh'
|
|
3038
|
+
? `下一步:调用函数工具 \`apply_file_modification\`,参数:{ \"hunk_id\": \"${hunkId}\" }`
|
|
3039
|
+
: `Next: call function tool \`apply_file_modification\` with { \"hunk_id\": \"${hunkId}\" }.`);
|
|
3040
|
+
return ok(content, [{ type: 'environment_msg', role: 'user', content }]);
|
|
3041
|
+
}
|
|
3042
|
+
catch (error) {
|
|
3043
|
+
const content = formatYamlCodeBlock([
|
|
3044
|
+
`status: error`,
|
|
3045
|
+
`path: ${yamlQuote(filePath)}`,
|
|
3046
|
+
`mode: prepare_file_block_replace`,
|
|
3047
|
+
`error: FAILED`,
|
|
3048
|
+
`summary: ${yamlQuote(error instanceof Error ? error.message : String(error))}`,
|
|
3049
|
+
].join('\n'));
|
|
3050
|
+
return failed(content, [{ type: 'environment_msg', role: 'user', content }]);
|
|
3051
|
+
}
|
|
3052
|
+
}
|
|
3053
|
+
exports.prepareFileBlockReplaceTool = {
|
|
3054
|
+
type: 'func',
|
|
3055
|
+
name: 'prepare_file_block_replace',
|
|
3056
|
+
description: 'Prepare a block replacement between anchors in a file (does not write).',
|
|
3057
|
+
parameters: {
|
|
3058
|
+
type: 'object',
|
|
3059
|
+
additionalProperties: false,
|
|
3060
|
+
properties: {
|
|
3061
|
+
path: { type: 'string' },
|
|
3062
|
+
start_anchor: { type: 'string' },
|
|
3063
|
+
end_anchor: { type: 'string' },
|
|
3064
|
+
occurrence: { type: ['integer', 'string'] },
|
|
3065
|
+
include_anchors: { type: 'boolean' },
|
|
3066
|
+
match: {
|
|
3067
|
+
type: 'string',
|
|
3068
|
+
description: "Anchor match mode: 'contains' (default) or 'equals'.",
|
|
3069
|
+
},
|
|
3070
|
+
require_unique: { type: 'boolean' },
|
|
3071
|
+
strict: { type: 'boolean' },
|
|
3072
|
+
existing_hunk_id: { type: 'string' },
|
|
3073
|
+
content: { type: 'string' },
|
|
3074
|
+
},
|
|
3075
|
+
required: ['path', 'start_anchor', 'end_anchor', 'content'],
|
|
3076
|
+
},
|
|
3077
|
+
argsValidation: 'dominds',
|
|
3078
|
+
call: async (_dlg, caller, args) => {
|
|
3079
|
+
const filePath = requireNonEmptyStringArg(args, 'path');
|
|
3080
|
+
const startAnchor = requireNonEmptyStringArg(args, 'start_anchor');
|
|
3081
|
+
const endAnchor = requireNonEmptyStringArg(args, 'end_anchor');
|
|
3082
|
+
const existingHunkId = normalizeExistingHunkId(optionalNonEmptyStringArg(args, 'existing_hunk_id'));
|
|
3083
|
+
const content = optionalStringArg(args, 'content') ?? '';
|
|
3084
|
+
let occurrence = { kind: 'index', index1: 1 };
|
|
3085
|
+
let occurrenceSpecified = false;
|
|
3086
|
+
const occurrenceValue = args['occurrence'];
|
|
3087
|
+
if (occurrenceValue !== undefined) {
|
|
3088
|
+
if (typeof occurrenceValue === 'number' && Number.isInteger(occurrenceValue)) {
|
|
3089
|
+
// Codex may require this field to be present even when semantically "unset".
|
|
3090
|
+
// Sentinel 0 means "not specified".
|
|
3091
|
+
if (occurrenceValue >= 1) {
|
|
3092
|
+
occurrence = { kind: 'index', index1: occurrenceValue };
|
|
3093
|
+
occurrenceSpecified = true;
|
|
3094
|
+
}
|
|
3095
|
+
else if (occurrenceValue !== 0) {
|
|
3096
|
+
throw new Error("Invalid arguments: `occurrence` must be a positive integer or 'last'");
|
|
3097
|
+
}
|
|
3098
|
+
}
|
|
3099
|
+
else if (typeof occurrenceValue === 'string') {
|
|
3100
|
+
const trimmed = occurrenceValue.trim();
|
|
3101
|
+
// Codex may require this field to be present even when semantically "unset".
|
|
3102
|
+
// Sentinel empty string means "not specified".
|
|
3103
|
+
if (trimmed !== '') {
|
|
3104
|
+
const parsed = parseOccurrence(trimmed);
|
|
3105
|
+
if (!parsed) {
|
|
3106
|
+
throw new Error("Invalid arguments: `occurrence` must be a positive integer or 'last'");
|
|
3107
|
+
}
|
|
3108
|
+
occurrence = parsed;
|
|
3109
|
+
occurrenceSpecified = true;
|
|
3110
|
+
}
|
|
3111
|
+
}
|
|
3112
|
+
else {
|
|
3113
|
+
throw new Error("Invalid arguments: `occurrence` must be a positive integer or 'last'");
|
|
3114
|
+
}
|
|
3115
|
+
}
|
|
3116
|
+
const includeAnchors = optionalBooleanArg(args, 'include_anchors') ?? true;
|
|
3117
|
+
let match = 'contains';
|
|
3118
|
+
const matchArg = optionalNonEmptyStringArg(args, 'match');
|
|
3119
|
+
if (matchArg !== undefined) {
|
|
3120
|
+
if (matchArg !== 'contains' && matchArg !== 'equals') {
|
|
3121
|
+
throw new Error("Invalid arguments: `match` must be one of: 'contains', 'equals'");
|
|
3122
|
+
}
|
|
3123
|
+
match = matchArg;
|
|
3124
|
+
}
|
|
3125
|
+
const requireUnique = optionalBooleanArg(args, 'require_unique') ?? true;
|
|
3126
|
+
const strict = optionalBooleanArg(args, 'strict') ?? true;
|
|
3127
|
+
const requestedId = existingHunkId;
|
|
3128
|
+
if (requestedId !== undefined && !isValidHunkId(requestedId)) {
|
|
3129
|
+
throw new Error("Invalid arguments: `existing_hunk_id` must be a hunk id like 'a1b2c3d4' (letters/digits/_/-)");
|
|
3130
|
+
}
|
|
3131
|
+
const res = await runPrepareBlockReplace(caller, {
|
|
3132
|
+
filePath,
|
|
3133
|
+
startAnchor,
|
|
3134
|
+
endAnchor,
|
|
3135
|
+
requestedId,
|
|
3136
|
+
occurrence,
|
|
3137
|
+
occurrenceSpecified,
|
|
3138
|
+
includeAnchors,
|
|
3139
|
+
match,
|
|
3140
|
+
requireUnique,
|
|
3141
|
+
strict,
|
|
3142
|
+
inputBody: content,
|
|
3143
|
+
});
|
|
3144
|
+
return unwrapTxtToolResult(res);
|
|
3145
|
+
},
|
|
3146
|
+
};
|
|
3147
|
+
exports.prepareFileAppendTool = {
|
|
3148
|
+
type: 'func',
|
|
3149
|
+
name: 'prepare_file_append',
|
|
3150
|
+
description: 'Prepare an append-to-EOF edit (does not write).',
|
|
3151
|
+
parameters: {
|
|
3152
|
+
type: 'object',
|
|
3153
|
+
additionalProperties: false,
|
|
3154
|
+
properties: {
|
|
3155
|
+
path: { type: 'string' },
|
|
3156
|
+
create: { type: 'boolean' },
|
|
3157
|
+
existing_hunk_id: { type: 'string' },
|
|
3158
|
+
content: { type: 'string' },
|
|
3159
|
+
},
|
|
3160
|
+
required: ['path', 'content'],
|
|
3161
|
+
},
|
|
3162
|
+
argsValidation: 'dominds',
|
|
3163
|
+
call: async (_dlg, caller, args) => {
|
|
3164
|
+
const filePath = requireNonEmptyStringArg(args, 'path');
|
|
3165
|
+
const create = optionalBooleanArg(args, 'create');
|
|
3166
|
+
const existingHunkId = normalizeExistingHunkId(optionalNonEmptyStringArg(args, 'existing_hunk_id'));
|
|
3167
|
+
const content = optionalStringArg(args, 'content') ?? '';
|
|
3168
|
+
const requestedId = existingHunkId;
|
|
3169
|
+
if (requestedId !== undefined && !isValidHunkId(requestedId)) {
|
|
3170
|
+
throw new Error("Invalid arguments: `existing_hunk_id` must be a hunk id like 'a1b2c3d4' (letters/digits/_/-)");
|
|
3171
|
+
}
|
|
3172
|
+
const res = await runPrepareFileAppend(caller, filePath, content, {
|
|
3173
|
+
create: create ?? true,
|
|
3174
|
+
requestedId,
|
|
3175
|
+
});
|
|
3176
|
+
return unwrapTxtToolResult(res);
|
|
3177
|
+
},
|
|
3178
|
+
};
|