@animus-labs/cortex 0.2.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.
Files changed (293) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +73 -0
  3. package/dist/budget-guard.d.ts +75 -0
  4. package/dist/budget-guard.d.ts.map +1 -0
  5. package/dist/budget-guard.js +142 -0
  6. package/dist/budget-guard.js.map +1 -0
  7. package/dist/compaction/compaction.d.ts +99 -0
  8. package/dist/compaction/compaction.d.ts.map +1 -0
  9. package/dist/compaction/compaction.js +302 -0
  10. package/dist/compaction/compaction.js.map +1 -0
  11. package/dist/compaction/failsafe.d.ts +57 -0
  12. package/dist/compaction/failsafe.d.ts.map +1 -0
  13. package/dist/compaction/failsafe.js +135 -0
  14. package/dist/compaction/failsafe.js.map +1 -0
  15. package/dist/compaction/index.d.ts +381 -0
  16. package/dist/compaction/index.d.ts.map +1 -0
  17. package/dist/compaction/index.js +979 -0
  18. package/dist/compaction/index.js.map +1 -0
  19. package/dist/compaction/microcompaction.d.ts +219 -0
  20. package/dist/compaction/microcompaction.d.ts.map +1 -0
  21. package/dist/compaction/microcompaction.js +536 -0
  22. package/dist/compaction/microcompaction.js.map +1 -0
  23. package/dist/compaction/observational/buffering.d.ts +225 -0
  24. package/dist/compaction/observational/buffering.d.ts.map +1 -0
  25. package/dist/compaction/observational/buffering.js +354 -0
  26. package/dist/compaction/observational/buffering.js.map +1 -0
  27. package/dist/compaction/observational/constants.d.ts +70 -0
  28. package/dist/compaction/observational/constants.d.ts.map +1 -0
  29. package/dist/compaction/observational/constants.js +507 -0
  30. package/dist/compaction/observational/constants.js.map +1 -0
  31. package/dist/compaction/observational/index.d.ts +219 -0
  32. package/dist/compaction/observational/index.d.ts.map +1 -0
  33. package/dist/compaction/observational/index.js +641 -0
  34. package/dist/compaction/observational/index.js.map +1 -0
  35. package/dist/compaction/observational/observer.d.ts +97 -0
  36. package/dist/compaction/observational/observer.d.ts.map +1 -0
  37. package/dist/compaction/observational/observer.js +424 -0
  38. package/dist/compaction/observational/observer.js.map +1 -0
  39. package/dist/compaction/observational/recall-tool.d.ts +27 -0
  40. package/dist/compaction/observational/recall-tool.d.ts.map +1 -0
  41. package/dist/compaction/observational/recall-tool.js +93 -0
  42. package/dist/compaction/observational/recall-tool.js.map +1 -0
  43. package/dist/compaction/observational/reflector.d.ts +94 -0
  44. package/dist/compaction/observational/reflector.d.ts.map +1 -0
  45. package/dist/compaction/observational/reflector.js +167 -0
  46. package/dist/compaction/observational/reflector.js.map +1 -0
  47. package/dist/compaction/observational/types.d.ts +271 -0
  48. package/dist/compaction/observational/types.d.ts.map +1 -0
  49. package/dist/compaction/observational/types.js +15 -0
  50. package/dist/compaction/observational/types.js.map +1 -0
  51. package/dist/context-manager.d.ts +134 -0
  52. package/dist/context-manager.d.ts.map +1 -0
  53. package/dist/context-manager.js +170 -0
  54. package/dist/context-manager.js.map +1 -0
  55. package/dist/cortex-agent.d.ts +1020 -0
  56. package/dist/cortex-agent.d.ts.map +1 -0
  57. package/dist/cortex-agent.js +3589 -0
  58. package/dist/cortex-agent.js.map +1 -0
  59. package/dist/error-classifier.d.ts +48 -0
  60. package/dist/error-classifier.d.ts.map +1 -0
  61. package/dist/error-classifier.js +152 -0
  62. package/dist/error-classifier.js.map +1 -0
  63. package/dist/event-bridge.d.ts +166 -0
  64. package/dist/event-bridge.d.ts.map +1 -0
  65. package/dist/event-bridge.js +381 -0
  66. package/dist/event-bridge.js.map +1 -0
  67. package/dist/index.d.ts +55 -0
  68. package/dist/index.d.ts.map +1 -0
  69. package/dist/index.js +57 -0
  70. package/dist/index.js.map +1 -0
  71. package/dist/mcp-client.d.ts +119 -0
  72. package/dist/mcp-client.d.ts.map +1 -0
  73. package/dist/mcp-client.js +474 -0
  74. package/dist/mcp-client.js.map +1 -0
  75. package/dist/model-wrapper.d.ts +58 -0
  76. package/dist/model-wrapper.d.ts.map +1 -0
  77. package/dist/model-wrapper.js +86 -0
  78. package/dist/model-wrapper.js.map +1 -0
  79. package/dist/noop-logger.d.ts +4 -0
  80. package/dist/noop-logger.d.ts.map +1 -0
  81. package/dist/noop-logger.js +8 -0
  82. package/dist/noop-logger.js.map +1 -0
  83. package/dist/prompt-diagnostics.d.ts +47 -0
  84. package/dist/prompt-diagnostics.d.ts.map +1 -0
  85. package/dist/prompt-diagnostics.js +230 -0
  86. package/dist/prompt-diagnostics.js.map +1 -0
  87. package/dist/provider-manager.d.ts +224 -0
  88. package/dist/provider-manager.d.ts.map +1 -0
  89. package/dist/provider-manager.js +563 -0
  90. package/dist/provider-manager.js.map +1 -0
  91. package/dist/provider-registry.d.ts +115 -0
  92. package/dist/provider-registry.d.ts.map +1 -0
  93. package/dist/provider-registry.js +305 -0
  94. package/dist/provider-registry.js.map +1 -0
  95. package/dist/schema-converter.d.ts +20 -0
  96. package/dist/schema-converter.d.ts.map +1 -0
  97. package/dist/schema-converter.js +48 -0
  98. package/dist/schema-converter.js.map +1 -0
  99. package/dist/skill-preprocessor.d.ts +46 -0
  100. package/dist/skill-preprocessor.d.ts.map +1 -0
  101. package/dist/skill-preprocessor.js +237 -0
  102. package/dist/skill-preprocessor.js.map +1 -0
  103. package/dist/skill-registry.d.ts +107 -0
  104. package/dist/skill-registry.d.ts.map +1 -0
  105. package/dist/skill-registry.js +330 -0
  106. package/dist/skill-registry.js.map +1 -0
  107. package/dist/skill-tool.d.ts +54 -0
  108. package/dist/skill-tool.d.ts.map +1 -0
  109. package/dist/skill-tool.js +88 -0
  110. package/dist/skill-tool.js.map +1 -0
  111. package/dist/sub-agent-manager.d.ts +90 -0
  112. package/dist/sub-agent-manager.d.ts.map +1 -0
  113. package/dist/sub-agent-manager.js +192 -0
  114. package/dist/sub-agent-manager.js.map +1 -0
  115. package/dist/token-estimator.d.ts +23 -0
  116. package/dist/token-estimator.d.ts.map +1 -0
  117. package/dist/token-estimator.js +27 -0
  118. package/dist/token-estimator.js.map +1 -0
  119. package/dist/tool-contract.d.ts +68 -0
  120. package/dist/tool-contract.d.ts.map +1 -0
  121. package/dist/tool-contract.js +35 -0
  122. package/dist/tool-contract.js.map +1 -0
  123. package/dist/tool-result-persistence.d.ts +89 -0
  124. package/dist/tool-result-persistence.d.ts.map +1 -0
  125. package/dist/tool-result-persistence.js +152 -0
  126. package/dist/tool-result-persistence.js.map +1 -0
  127. package/dist/tools/bash/index.d.ts +71 -0
  128. package/dist/tools/bash/index.d.ts.map +1 -0
  129. package/dist/tools/bash/index.js +485 -0
  130. package/dist/tools/bash/index.js.map +1 -0
  131. package/dist/tools/bash/interactive.d.ts +47 -0
  132. package/dist/tools/bash/interactive.d.ts.map +1 -0
  133. package/dist/tools/bash/interactive.js +262 -0
  134. package/dist/tools/bash/interactive.js.map +1 -0
  135. package/dist/tools/bash/safety.d.ts +149 -0
  136. package/dist/tools/bash/safety.d.ts.map +1 -0
  137. package/dist/tools/bash/safety.js +1116 -0
  138. package/dist/tools/bash/safety.js.map +1 -0
  139. package/dist/tools/edit.d.ts +57 -0
  140. package/dist/tools/edit.d.ts.map +1 -0
  141. package/dist/tools/edit.js +310 -0
  142. package/dist/tools/edit.js.map +1 -0
  143. package/dist/tools/glob.d.ts +34 -0
  144. package/dist/tools/glob.d.ts.map +1 -0
  145. package/dist/tools/glob.js +268 -0
  146. package/dist/tools/glob.js.map +1 -0
  147. package/dist/tools/grep.d.ts +53 -0
  148. package/dist/tools/grep.d.ts.map +1 -0
  149. package/dist/tools/grep.js +673 -0
  150. package/dist/tools/grep.js.map +1 -0
  151. package/dist/tools/index.d.ts +62 -0
  152. package/dist/tools/index.d.ts.map +1 -0
  153. package/dist/tools/index.js +52 -0
  154. package/dist/tools/index.js.map +1 -0
  155. package/dist/tools/read.d.ts +43 -0
  156. package/dist/tools/read.d.ts.map +1 -0
  157. package/dist/tools/read.js +459 -0
  158. package/dist/tools/read.js.map +1 -0
  159. package/dist/tools/runtime.d.ts +62 -0
  160. package/dist/tools/runtime.d.ts.map +1 -0
  161. package/dist/tools/runtime.js +116 -0
  162. package/dist/tools/runtime.js.map +1 -0
  163. package/dist/tools/shared/cwd-tracker.d.ts +32 -0
  164. package/dist/tools/shared/cwd-tracker.d.ts.map +1 -0
  165. package/dist/tools/shared/cwd-tracker.js +44 -0
  166. package/dist/tools/shared/cwd-tracker.js.map +1 -0
  167. package/dist/tools/shared/edit-history.d.ts +55 -0
  168. package/dist/tools/shared/edit-history.d.ts.map +1 -0
  169. package/dist/tools/shared/edit-history.js +72 -0
  170. package/dist/tools/shared/edit-history.js.map +1 -0
  171. package/dist/tools/shared/edit-matcher.d.ts +83 -0
  172. package/dist/tools/shared/edit-matcher.d.ts.map +1 -0
  173. package/dist/tools/shared/edit-matcher.js +359 -0
  174. package/dist/tools/shared/edit-matcher.js.map +1 -0
  175. package/dist/tools/shared/file-mutation-lock.d.ts +22 -0
  176. package/dist/tools/shared/file-mutation-lock.d.ts.map +1 -0
  177. package/dist/tools/shared/file-mutation-lock.js +35 -0
  178. package/dist/tools/shared/file-mutation-lock.js.map +1 -0
  179. package/dist/tools/shared/gitignore.d.ts +17 -0
  180. package/dist/tools/shared/gitignore.d.ts.map +1 -0
  181. package/dist/tools/shared/gitignore.js +59 -0
  182. package/dist/tools/shared/gitignore.js.map +1 -0
  183. package/dist/tools/shared/pdf-extractor.d.ts +96 -0
  184. package/dist/tools/shared/pdf-extractor.d.ts.map +1 -0
  185. package/dist/tools/shared/pdf-extractor.js +196 -0
  186. package/dist/tools/shared/pdf-extractor.js.map +1 -0
  187. package/dist/tools/shared/read-registry.d.ts +66 -0
  188. package/dist/tools/shared/read-registry.d.ts.map +1 -0
  189. package/dist/tools/shared/read-registry.js +65 -0
  190. package/dist/tools/shared/read-registry.js.map +1 -0
  191. package/dist/tools/shared/safe-env.d.ts +18 -0
  192. package/dist/tools/shared/safe-env.d.ts.map +1 -0
  193. package/dist/tools/shared/safe-env.js +70 -0
  194. package/dist/tools/shared/safe-env.js.map +1 -0
  195. package/dist/tools/sub-agent.d.ts +91 -0
  196. package/dist/tools/sub-agent.d.ts.map +1 -0
  197. package/dist/tools/sub-agent.js +89 -0
  198. package/dist/tools/sub-agent.js.map +1 -0
  199. package/dist/tools/task-output.d.ts +38 -0
  200. package/dist/tools/task-output.d.ts.map +1 -0
  201. package/dist/tools/task-output.js +186 -0
  202. package/dist/tools/task-output.js.map +1 -0
  203. package/dist/tools/tool-search/index.d.ts +40 -0
  204. package/dist/tools/tool-search/index.d.ts.map +1 -0
  205. package/dist/tools/tool-search/index.js +110 -0
  206. package/dist/tools/tool-search/index.js.map +1 -0
  207. package/dist/tools/tool-search/registry.d.ts +82 -0
  208. package/dist/tools/tool-search/registry.d.ts.map +1 -0
  209. package/dist/tools/tool-search/registry.js +238 -0
  210. package/dist/tools/tool-search/registry.js.map +1 -0
  211. package/dist/tools/undo-edit.d.ts +51 -0
  212. package/dist/tools/undo-edit.d.ts.map +1 -0
  213. package/dist/tools/undo-edit.js +231 -0
  214. package/dist/tools/undo-edit.js.map +1 -0
  215. package/dist/tools/web-fetch/cache.d.ts +49 -0
  216. package/dist/tools/web-fetch/cache.d.ts.map +1 -0
  217. package/dist/tools/web-fetch/cache.js +89 -0
  218. package/dist/tools/web-fetch/cache.js.map +1 -0
  219. package/dist/tools/web-fetch/index.d.ts +53 -0
  220. package/dist/tools/web-fetch/index.d.ts.map +1 -0
  221. package/dist/tools/web-fetch/index.js +513 -0
  222. package/dist/tools/web-fetch/index.js.map +1 -0
  223. package/dist/tools/write.d.ts +59 -0
  224. package/dist/tools/write.d.ts.map +1 -0
  225. package/dist/tools/write.js +316 -0
  226. package/dist/tools/write.js.map +1 -0
  227. package/dist/types.d.ts +881 -0
  228. package/dist/types.d.ts.map +1 -0
  229. package/dist/types.js +16 -0
  230. package/dist/types.js.map +1 -0
  231. package/dist/working-tags.d.ts +44 -0
  232. package/dist/working-tags.d.ts.map +1 -0
  233. package/dist/working-tags.js +103 -0
  234. package/dist/working-tags.js.map +1 -0
  235. package/package.json +87 -0
  236. package/src/budget-guard.ts +170 -0
  237. package/src/compaction/compaction.ts +386 -0
  238. package/src/compaction/failsafe.ts +185 -0
  239. package/src/compaction/index.ts +1199 -0
  240. package/src/compaction/microcompaction.ts +709 -0
  241. package/src/compaction/observational/buffering.ts +430 -0
  242. package/src/compaction/observational/constants.ts +532 -0
  243. package/src/compaction/observational/index.ts +837 -0
  244. package/src/compaction/observational/observer.ts +510 -0
  245. package/src/compaction/observational/recall-tool.ts +130 -0
  246. package/src/compaction/observational/reflector.ts +221 -0
  247. package/src/compaction/observational/types.ts +343 -0
  248. package/src/context-manager.ts +237 -0
  249. package/src/cortex-agent.ts +4297 -0
  250. package/src/error-classifier.ts +199 -0
  251. package/src/event-bridge.ts +508 -0
  252. package/src/index.ts +292 -0
  253. package/src/mcp-client.ts +582 -0
  254. package/src/model-wrapper.ts +128 -0
  255. package/src/noop-logger.ts +9 -0
  256. package/src/prompt-diagnostics.ts +296 -0
  257. package/src/provider-manager.ts +823 -0
  258. package/src/provider-registry.ts +386 -0
  259. package/src/schema-converter.ts +51 -0
  260. package/src/skill-preprocessor.ts +314 -0
  261. package/src/skill-registry.ts +378 -0
  262. package/src/skill-tool.ts +130 -0
  263. package/src/sub-agent-manager.ts +236 -0
  264. package/src/token-estimator.ts +26 -0
  265. package/src/tool-contract.ts +113 -0
  266. package/src/tool-result-persistence.ts +197 -0
  267. package/src/tools/bash/index.ts +633 -0
  268. package/src/tools/bash/interactive.ts +302 -0
  269. package/src/tools/bash/safety.ts +1297 -0
  270. package/src/tools/edit.ts +422 -0
  271. package/src/tools/glob.ts +330 -0
  272. package/src/tools/grep.ts +819 -0
  273. package/src/tools/index.ts +110 -0
  274. package/src/tools/read.ts +580 -0
  275. package/src/tools/runtime.ts +173 -0
  276. package/src/tools/shared/cwd-tracker.ts +50 -0
  277. package/src/tools/shared/edit-history.ts +96 -0
  278. package/src/tools/shared/edit-matcher.ts +457 -0
  279. package/src/tools/shared/file-mutation-lock.ts +40 -0
  280. package/src/tools/shared/gitignore.ts +61 -0
  281. package/src/tools/shared/pdf-extractor.ts +290 -0
  282. package/src/tools/shared/read-registry.ts +93 -0
  283. package/src/tools/shared/safe-env.ts +82 -0
  284. package/src/tools/sub-agent.ts +171 -0
  285. package/src/tools/task-output.ts +236 -0
  286. package/src/tools/tool-search/index.ts +167 -0
  287. package/src/tools/tool-search/registry.ts +278 -0
  288. package/src/tools/undo-edit.ts +314 -0
  289. package/src/tools/web-fetch/cache.ts +112 -0
  290. package/src/tools/web-fetch/index.ts +604 -0
  291. package/src/tools/write.ts +385 -0
  292. package/src/types.ts +1057 -0
  293. package/src/working-tags.ts +118 -0
@@ -0,0 +1,422 @@
1
+ /**
2
+ * Edit tool: make precise string replacements in existing files.
3
+ *
4
+ * Supports exact string matching with a uniqueness constraint
5
+ * (when replaceAll is false). Enforces read-before-edit via ReadRegistry.
6
+ * Handles line ending normalization for cross-platform compatibility.
7
+ *
8
+ * Reference: docs/cortex/tools/edit.md
9
+ */
10
+
11
+ import * as crypto from 'node:crypto';
12
+ import * as fs from 'node:fs';
13
+ import * as path from 'node:path';
14
+ import { Type, type Static } from 'typebox';
15
+ import type { EditHistory } from './shared/edit-history.js';
16
+ import type { FileMutationLock } from './shared/file-mutation-lock.js';
17
+ import type { ReadRegistry } from './shared/read-registry.js';
18
+ import type { ToolContentDetails } from '../types.js';
19
+ import { computeDiff, type DiffHunk } from './write.js';
20
+ import type { CortexToolRuntime } from './runtime.js';
21
+ import { attachRuntimeAwareTool } from './runtime.js';
22
+ import {
23
+ findMatch,
24
+ findNearestMatch,
25
+ reindentReplacement,
26
+ type MatchResult,
27
+ } from './shared/edit-matcher.js';
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Schema
31
+ // ---------------------------------------------------------------------------
32
+
33
+ export const EditParams = Type.Object({
34
+ file_path: Type.String({ description: 'Absolute path to the file to edit' }),
35
+ old_string: Type.String({ description: 'The exact text to find and replace' }),
36
+ new_string: Type.String({ description: 'The replacement text (must differ from old_string)' }),
37
+ replace_all: Type.Optional(
38
+ Type.Boolean({
39
+ description: 'Replace all occurrences. Default: false (replace first unique match).',
40
+ default: false,
41
+ }),
42
+ ),
43
+ });
44
+
45
+ export type EditParamsType = Static<typeof EditParams>;
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Details type
49
+ // ---------------------------------------------------------------------------
50
+
51
+ export interface EditDetails {
52
+ filePath: string;
53
+ oldString: string;
54
+ newString: string;
55
+ replacementCount: number;
56
+ replaceAll: boolean;
57
+ diff: DiffHunk[];
58
+ originalContent: string;
59
+ /**
60
+ * Which matcher tier resolved the edit. Useful for consumers that want
61
+ * to surface "we applied a fuzzy match" in the UI. Absent when no edit
62
+ * was performed (errors, identical strings, etc.).
63
+ */
64
+ matchTier?: 'exact' | 'line-trimmed' | 'indentation-flexible';
65
+ }
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Config
69
+ // ---------------------------------------------------------------------------
70
+
71
+ export interface EditToolConfig {
72
+ runtime?: CortexToolRuntime | undefined;
73
+ readRegistry?: ReadRegistry | undefined;
74
+ fileMutationLock?: FileMutationLock | undefined;
75
+ /**
76
+ * Undo stack. When provided, every successful edit pushes a
77
+ * pre-mutation snapshot so `UndoEdit` can restore the prior state.
78
+ * Optional — tests and embedded consumers that don't expose undo
79
+ * may omit it; the tool degrades gracefully to current behavior.
80
+ */
81
+ editHistory?: EditHistory | undefined;
82
+ }
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // Helpers
86
+ // ---------------------------------------------------------------------------
87
+
88
+ type AppliedTier = 'exact' | 'line-trimmed' | 'indentation-flexible';
89
+
90
+ interface AppliedReplacement {
91
+ newContent: string;
92
+ replacementCount: number;
93
+ tier: AppliedTier;
94
+ }
95
+
96
+ /**
97
+ * Given a successful match (not `none` and not `ambiguous`), produce the
98
+ * rebuilt file content along with the replacement count and the tier
99
+ * that resolved the edit. Caller is responsible for having already
100
+ * rejected `none`, `ambiguous`, and the tier-1 `count>1 && !replaceAll`
101
+ * case. Returns null when `match` is one of those guarded states, which
102
+ * is a programming error at the call site.
103
+ */
104
+ function applyReplacement(
105
+ match: MatchResult,
106
+ normalizedContent: string,
107
+ normalizedOldString: string,
108
+ normalizedNewString: string,
109
+ replaceAll: boolean,
110
+ ): AppliedReplacement | null {
111
+ if (match.kind === 'exact') {
112
+ if (replaceAll) {
113
+ return {
114
+ newContent: normalizedContent
115
+ .split(normalizedOldString)
116
+ .join(normalizedNewString),
117
+ replacementCount: match.count,
118
+ tier: 'exact',
119
+ };
120
+ }
121
+ return {
122
+ newContent:
123
+ normalizedContent.slice(0, match.startIndex) +
124
+ normalizedNewString +
125
+ normalizedContent.slice(match.startIndex + match.matchedLength),
126
+ replacementCount: 1,
127
+ tier: 'exact',
128
+ };
129
+ }
130
+ if (match.kind === 'line-trimmed') {
131
+ return {
132
+ newContent:
133
+ normalizedContent.slice(0, match.startIndex) +
134
+ normalizedNewString +
135
+ normalizedContent.slice(match.startIndex + match.matchedLength),
136
+ replacementCount: 1,
137
+ tier: 'line-trimmed',
138
+ };
139
+ }
140
+ if (match.kind === 'indentation-flexible') {
141
+ const reindented = reindentReplacement(
142
+ normalizedNewString,
143
+ match.needleIndent,
144
+ match.haystackIndent,
145
+ );
146
+ return {
147
+ newContent:
148
+ normalizedContent.slice(0, match.startIndex) +
149
+ reindented +
150
+ normalizedContent.slice(match.startIndex + match.matchedLength),
151
+ replacementCount: 1,
152
+ tier: 'indentation-flexible',
153
+ };
154
+ }
155
+ return null;
156
+ }
157
+
158
+ // ---------------------------------------------------------------------------
159
+ // Tool factory
160
+ // ---------------------------------------------------------------------------
161
+
162
+ export function createEditTool(config: EditToolConfig): {
163
+ name: string;
164
+ description: string;
165
+ parameters: typeof EditParams;
166
+ execute: (params: EditParamsType) => Promise<ToolContentDetails<EditDetails>>;
167
+ } {
168
+ const readRegistry = config.runtime?.readRegistry ?? config.readRegistry;
169
+ if (!readRegistry) {
170
+ throw new Error('createEditTool requires either runtime or readRegistry');
171
+ }
172
+ const fileMutationLock = config.runtime?.fileMutationLock ?? config.fileMutationLock;
173
+ const editHistory = config.runtime?.editHistory ?? config.editHistory;
174
+
175
+ /** Build a no-op result for early returns. */
176
+ function noChange(
177
+ filePath: string, oldString: string, newString: string,
178
+ replaceAll: boolean, text: string, originalContent = '',
179
+ ): ToolContentDetails<EditDetails> {
180
+ return {
181
+ content: [{ type: 'text', text }],
182
+ details: { filePath, oldString, newString, replacementCount: 0, replaceAll, diff: [], originalContent },
183
+ };
184
+ }
185
+
186
+ const tool = {
187
+ name: 'Edit',
188
+ description:
189
+ 'Make precise string replacements in an existing file. ' +
190
+ 'You MUST Read the file before using this tool. The edit will be rejected if the file has not been read first.',
191
+ parameters: EditParams,
192
+
193
+ async execute(params: EditParamsType): Promise<ToolContentDetails<EditDetails>> {
194
+ const filePath = path.resolve(params.file_path);
195
+ const oldString = params.old_string;
196
+ const newString = params.new_string;
197
+ const replaceAll = params.replace_all ?? false;
198
+
199
+ // Check identical strings (no lock needed)
200
+ if (oldString === newString) {
201
+ return noChange(filePath, oldString, newString, replaceAll,
202
+ 'old_string and new_string are identical. No change needed.');
203
+ }
204
+
205
+ // Check file exists (no lock needed)
206
+ let stat: fs.Stats;
207
+ try {
208
+ stat = await fs.promises.stat(filePath);
209
+ } catch (err: unknown) {
210
+ const code = (err as NodeJS.ErrnoException).code;
211
+ if (code === 'ENOENT') {
212
+ return noChange(filePath, oldString, newString, replaceAll,
213
+ `File does not exist: ${filePath}`);
214
+ }
215
+ if (code === 'EACCES') {
216
+ return noChange(filePath, oldString, newString, replaceAll,
217
+ `Permission denied: ${filePath}`);
218
+ }
219
+ throw err;
220
+ }
221
+
222
+ // Acquire per-file mutation lock (serializes concurrent same-file edits)
223
+ const release = fileMutationLock ? await fileMutationLock.acquire(filePath) : undefined;
224
+ try {
225
+ // Enforce read-before-edit
226
+ if (!readRegistry.hasBeenRead(filePath)) {
227
+ return noChange(filePath, oldString, newString, replaceAll,
228
+ 'You must Read this file before editing it.');
229
+ }
230
+
231
+ // Mtime freshness check: reject if file changed since last Read.
232
+ // Using strict greater-than (not !==) to tolerate Windows/cloud-sync
233
+ // quirks where mtime can go backwards without a real modification.
234
+ // When mtime does indicate a change, fall back to a content-hash
235
+ // comparison (only possible for full reads) so formatter-style
236
+ // touches that don't change bytes still allow the edit.
237
+ const readState = readRegistry.getState(filePath);
238
+ let originalBuffer: Buffer | undefined;
239
+ if (readState) {
240
+ const currentStat = await fs.promises.stat(filePath);
241
+ if (currentStat.mtimeMs > readState.timestamp) {
242
+ let contentUnchanged = false;
243
+ if (readState.contentHash) {
244
+ originalBuffer = await fs.promises.readFile(filePath);
245
+ const currentHash = crypto.createHash('sha256')
246
+ .update(originalBuffer).digest('hex');
247
+ contentUnchanged = currentHash === readState.contentHash;
248
+ }
249
+ if (!contentUnchanged) {
250
+ readRegistry.invalidate(filePath);
251
+ return noChange(filePath, oldString, newString, replaceAll,
252
+ 'File was modified since last Read. Read the file again before editing.');
253
+ }
254
+ }
255
+ }
256
+
257
+ // Read the file content (reusing the buffer if we already loaded it
258
+ // for the content-hash fallback).
259
+ const originalContent = originalBuffer
260
+ ? originalBuffer.toString('utf8')
261
+ : await fs.promises.readFile(filePath, 'utf8');
262
+
263
+ // Normalize line endings for matching: \r\n -> \n
264
+ // We'll do matching on normalized content but track whether the
265
+ // original had \r\n so we can preserve the original style.
266
+ const hadCRLF = originalContent.includes('\r\n');
267
+ const normalizedContent = originalContent.replace(/\r\n/g, '\n');
268
+ const normalizedOldString = oldString.replace(/\r\n/g, '\n');
269
+ const normalizedNewString = newString.replace(/\r\n/g, '\n');
270
+
271
+ // Resolve the match via the tiered cascade (see edit-matcher.ts):
272
+ // tier 1: exact — substring indexOf
273
+ // tier 2: line-trimmed — tolerates trailing whitespace
274
+ // tier 3: indentation-flexible — tolerates leading indent delta
275
+ // replace_all semantics apply only to tier 1; tier 2 and tier 3
276
+ // always resolve to a single replacement (ambiguity there rejects).
277
+ const match = findMatch(normalizedContent, normalizedOldString);
278
+
279
+ if (match.kind === 'none') {
280
+ const hint = findNearestMatch(normalizedContent, normalizedOldString);
281
+ const text = hint
282
+ ? `The specified text was not found in the file.\n\nNearest match in ${path.basename(filePath)}:\n${hint.snippet}`
283
+ : 'The specified text was not found in the file.';
284
+ return noChange(
285
+ filePath, oldString, newString, replaceAll, text, originalContent,
286
+ );
287
+ }
288
+
289
+ if (match.kind === 'ambiguous') {
290
+ const tolerance =
291
+ match.tier === 'line-trimmed'
292
+ ? 'trailing-whitespace tolerance'
293
+ : 'indentation tolerance';
294
+ const lines = match.matchLines.join(', ');
295
+ const suffix = match.count > match.matchLines.length ? ' (first 3 shown)' : '';
296
+ return {
297
+ content: [{
298
+ type: 'text',
299
+ text:
300
+ `Found ${match.count} possible matches on lines ${lines}${suffix} via ${tolerance}. ` +
301
+ 'No exact match exists. Tighten old_string to uniquely identify the edit location.',
302
+ }],
303
+ details: {
304
+ filePath, oldString, newString,
305
+ replacementCount: 0, replaceAll, diff: [], originalContent,
306
+ },
307
+ };
308
+ }
309
+
310
+ if (match.kind === 'exact' && !replaceAll && match.count > 1) {
311
+ const lines = match.matchLines.join(', ');
312
+ const suffix = match.count > match.matchLines.length ? ' (first 3 shown)' : '';
313
+ return {
314
+ content: [{
315
+ type: 'text',
316
+ text:
317
+ `Found ${match.count} exact matches on lines ${lines}${suffix}. ` +
318
+ 'Provide more surrounding context to uniquely identify the edit location, or pass replace_all: true.',
319
+ }],
320
+ details: {
321
+ filePath, oldString, newString,
322
+ replacementCount: 0, replaceAll, diff: [], originalContent,
323
+ },
324
+ };
325
+ }
326
+
327
+ const applied = applyReplacement(
328
+ match, normalizedContent, normalizedOldString, normalizedNewString, replaceAll,
329
+ );
330
+ if (!applied) {
331
+ // Unreachable: above guards cover 'none' and 'ambiguous'. Treat
332
+ // as a programming error rather than silently succeeding.
333
+ throw new Error(`Unexpected match kind: ${match.kind}`);
334
+ }
335
+ const newNormalizedContent = applied.newContent;
336
+ const replacementCount = applied.replacementCount;
337
+ const matchTier = applied.tier;
338
+
339
+ // Restore original line ending style if it was CRLF
340
+ const finalContent = hadCRLF
341
+ ? newNormalizedContent.replace(/\n/g, '\r\n')
342
+ : newNormalizedContent;
343
+
344
+ // Compute diff
345
+ const diff = computeDiff(originalContent, finalContent);
346
+
347
+ // Atomic write: write to temp file, then rename
348
+ const tempPath = path.join(path.dirname(filePath), `.edit-${crypto.randomUUID()}.tmp`);
349
+ try {
350
+ await fs.promises.writeFile(tempPath, finalContent, 'utf8');
351
+ try {
352
+ await fs.promises.rename(tempPath, filePath);
353
+ } catch {
354
+ // Rename may fail on Windows if target is open. Fall back to direct write.
355
+ await fs.promises.writeFile(filePath, finalContent, 'utf8');
356
+ try { await fs.promises.unlink(tempPath); } catch { /* ignore */ }
357
+ }
358
+ } catch (writeErr) {
359
+ try { await fs.promises.unlink(tempPath); } catch { /* ignore */ }
360
+ throw writeErr;
361
+ }
362
+
363
+ // Refresh read state: the agent's own edit is authoritative knowledge
364
+ // of current file contents, so subsequent edits don't require a re-read.
365
+ // We record the new mtime and a content hash of what we just wrote so
366
+ // external modifications still trigger the freshness check above.
367
+ // Also capture an EditHistory snapshot (when enabled) so UndoEdit
368
+ // can restore the prior contents while being able to detect
369
+ // post-edit external modifications.
370
+ try {
371
+ const postStat = await fs.promises.stat(filePath);
372
+ const postHash = crypto.createHash('sha256')
373
+ .update(finalContent, 'utf8').digest('hex');
374
+ readRegistry.markRead(filePath, {
375
+ timestamp: postStat.mtimeMs,
376
+ contentHash: postHash,
377
+ });
378
+ editHistory?.record(filePath, {
379
+ originalContent,
380
+ postMutationMtimeMs: postStat.mtimeMs,
381
+ postMutationContentHash: postHash,
382
+ source: 'Edit',
383
+ });
384
+ } catch {
385
+ readRegistry.invalidate(filePath);
386
+ }
387
+
388
+ const plural = replacementCount === 1 ? 'replacement' : 'replacements';
389
+ const tierSuffix =
390
+ matchTier === 'line-trimmed'
391
+ ? ' (matched after trailing-whitespace tolerance)'
392
+ : matchTier === 'indentation-flexible'
393
+ ? ' (matched after indentation tolerance)'
394
+ : '';
395
+ return {
396
+ content: [{
397
+ type: 'text',
398
+ text: `Made ${replacementCount} ${plural} in ${filePath}${tierSuffix}`,
399
+ }],
400
+ details: {
401
+ filePath, oldString, newString,
402
+ replacementCount, replaceAll, diff, originalContent,
403
+ matchTier,
404
+ },
405
+ };
406
+ } finally {
407
+ release?.();
408
+ }
409
+ },
410
+ };
411
+
412
+ return attachRuntimeAwareTool(tool, {
413
+ toolKind: 'Edit',
414
+ cloneForRuntime: (runtime) => createEditTool({
415
+ ...config,
416
+ runtime,
417
+ readRegistry: runtime.readRegistry,
418
+ fileMutationLock: runtime.fileMutationLock,
419
+ editHistory: runtime.editHistory,
420
+ }),
421
+ });
422
+ }