tinker-agent 1.0.65 → 1.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 (208) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +6 -0
  4. package/README.md +90 -9
  5. package/bin/tinker.js +39 -0
  6. package/node_modules/ansi-regex/index.d.ts +33 -0
  7. package/node_modules/ansi-regex/index.js +14 -0
  8. package/node_modules/ansi-regex/license +9 -0
  9. package/node_modules/ansi-regex/package.json +61 -0
  10. package/node_modules/ansi-regex/readme.md +66 -0
  11. package/node_modules/ansi-styles/index.d.ts +236 -0
  12. package/node_modules/ansi-styles/index.js +223 -0
  13. package/node_modules/ansi-styles/license +9 -0
  14. package/node_modules/ansi-styles/package.json +54 -0
  15. package/node_modules/ansi-styles/readme.md +173 -0
  16. package/node_modules/chalk/license +9 -0
  17. package/node_modules/chalk/package.json +83 -0
  18. package/node_modules/chalk/readme.md +297 -0
  19. package/node_modules/chalk/source/index.d.ts +325 -0
  20. package/node_modules/chalk/source/index.js +225 -0
  21. package/node_modules/chalk/source/utilities.js +33 -0
  22. package/node_modules/chalk/source/vendor/ansi-styles/index.d.ts +236 -0
  23. package/node_modules/chalk/source/vendor/ansi-styles/index.js +223 -0
  24. package/node_modules/chalk/source/vendor/supports-color/browser.d.ts +1 -0
  25. package/node_modules/chalk/source/vendor/supports-color/browser.js +34 -0
  26. package/node_modules/chalk/source/vendor/supports-color/index.d.ts +55 -0
  27. package/node_modules/chalk/source/vendor/supports-color/index.js +190 -0
  28. package/node_modules/character-entities/index.d.ts +6 -0
  29. package/node_modules/character-entities/index.js +2132 -0
  30. package/node_modules/character-entities/license +22 -0
  31. package/node_modules/character-entities/package.json +78 -0
  32. package/node_modules/character-entities/readme.md +152 -0
  33. package/node_modules/decode-named-character-reference/index.d.ts +13 -0
  34. package/node_modules/decode-named-character-reference/index.d.ts.map +1 -0
  35. package/node_modules/decode-named-character-reference/index.dom.d.ts +6 -0
  36. package/node_modules/decode-named-character-reference/index.dom.d.ts.map +1 -0
  37. package/node_modules/decode-named-character-reference/index.dom.js +32 -0
  38. package/node_modules/decode-named-character-reference/index.js +19 -0
  39. package/node_modules/decode-named-character-reference/license +22 -0
  40. package/node_modules/decode-named-character-reference/package.json +90 -0
  41. package/node_modules/decode-named-character-reference/readme.md +136 -0
  42. package/node_modules/get-east-asian-width/index.d.ts +60 -0
  43. package/node_modules/get-east-asian-width/index.js +30 -0
  44. package/node_modules/get-east-asian-width/license +9 -0
  45. package/node_modules/get-east-asian-width/lookup-data.js +21 -0
  46. package/node_modules/get-east-asian-width/lookup.js +138 -0
  47. package/node_modules/get-east-asian-width/package.json +71 -0
  48. package/node_modules/get-east-asian-width/readme.md +65 -0
  49. package/node_modules/get-east-asian-width/utilities.js +24 -0
  50. package/node_modules/has-flag/index.d.ts +39 -0
  51. package/node_modules/has-flag/index.js +8 -0
  52. package/node_modules/has-flag/license +9 -0
  53. package/node_modules/has-flag/package.json +49 -0
  54. package/node_modules/has-flag/readme.md +74 -0
  55. package/node_modules/is-fullwidth-code-point/index.d.ts +17 -0
  56. package/node_modules/is-fullwidth-code-point/index.js +12 -0
  57. package/node_modules/is-fullwidth-code-point/license +9 -0
  58. package/node_modules/is-fullwidth-code-point/package.json +53 -0
  59. package/node_modules/is-fullwidth-code-point/readme.md +31 -0
  60. package/node_modules/markdansi/LICENSE +21 -0
  61. package/node_modules/markdansi/README.md +167 -0
  62. package/node_modules/markdansi/dist/ast.d.ts +99 -0
  63. package/node_modules/markdansi/dist/ast.js +1 -0
  64. package/node_modules/markdansi/dist/cli.d.ts +17 -0
  65. package/node_modules/markdansi/dist/cli.js +208 -0
  66. package/node_modules/markdansi/dist/hyperlink.d.ts +9 -0
  67. package/node_modules/markdansi/dist/hyperlink.js +26 -0
  68. package/node_modules/markdansi/dist/index.d.ts +10 -0
  69. package/node_modules/markdansi/dist/index.js +16 -0
  70. package/node_modules/markdansi/dist/parser.d.ts +2 -0
  71. package/node_modules/markdansi/dist/parser.js +205 -0
  72. package/node_modules/markdansi/dist/render.d.ts +9 -0
  73. package/node_modules/markdansi/dist/render.js +680 -0
  74. package/node_modules/markdansi/dist/stream.d.ts +39 -0
  75. package/node_modules/markdansi/dist/stream.js +192 -0
  76. package/node_modules/markdansi/dist/theme.d.ts +18 -0
  77. package/node_modules/markdansi/dist/theme.js +105 -0
  78. package/node_modules/markdansi/dist/types.d.ts +58 -0
  79. package/node_modules/markdansi/dist/types.js +1 -0
  80. package/node_modules/markdansi/dist/wrap.d.ts +10 -0
  81. package/node_modules/markdansi/dist/wrap.js +73 -0
  82. package/node_modules/markdansi/docs/spec.md +110 -0
  83. package/node_modules/markdansi/package.json +78 -0
  84. package/node_modules/markdansi/tsconfig.json +18 -0
  85. package/node_modules/marked/LICENSE +44 -0
  86. package/node_modules/marked/README.md +115 -0
  87. package/node_modules/marked/bin/main.js +284 -0
  88. package/node_modules/marked/bin/marked.js +16 -0
  89. package/node_modules/marked/lib/marked.d.ts +760 -0
  90. package/node_modules/marked/lib/marked.esm.js +77 -0
  91. package/node_modules/marked/lib/marked.esm.js.map +7 -0
  92. package/node_modules/marked/lib/marked.umd.js +79 -0
  93. package/node_modules/marked/lib/marked.umd.js.map +7 -0
  94. package/node_modules/marked/man/marked.1 +113 -0
  95. package/node_modules/marked/man/marked.1.md +93 -0
  96. package/node_modules/marked/package.json +103 -0
  97. package/node_modules/slice-ansi/index.d.ts +19 -0
  98. package/node_modules/slice-ansi/index.js +317 -0
  99. package/node_modules/slice-ansi/license +10 -0
  100. package/node_modules/slice-ansi/package.json +59 -0
  101. package/node_modules/slice-ansi/readme.md +55 -0
  102. package/node_modules/slice-ansi/tokenize-ansi.js +752 -0
  103. package/node_modules/string-width/index.d.ts +39 -0
  104. package/node_modules/string-width/index.js +203 -0
  105. package/node_modules/string-width/license +9 -0
  106. package/node_modules/string-width/package.json +65 -0
  107. package/node_modules/string-width/readme.md +66 -0
  108. package/node_modules/strip-ansi/index.d.ts +15 -0
  109. package/node_modules/strip-ansi/index.js +19 -0
  110. package/node_modules/strip-ansi/license +9 -0
  111. package/node_modules/strip-ansi/package.json +59 -0
  112. package/node_modules/strip-ansi/readme.md +37 -0
  113. package/node_modules/supports-color/browser.d.ts +1 -0
  114. package/node_modules/supports-color/browser.js +35 -0
  115. package/node_modules/supports-color/index.d.ts +55 -0
  116. package/node_modules/supports-color/index.js +202 -0
  117. package/node_modules/supports-color/license +9 -0
  118. package/node_modules/supports-color/package.json +64 -0
  119. package/node_modules/supports-color/readme.md +75 -0
  120. package/node_modules/supports-hyperlinks/browser.js +10 -0
  121. package/node_modules/supports-hyperlinks/index.d.ts +25 -0
  122. package/node_modules/supports-hyperlinks/index.js +163 -0
  123. package/node_modules/supports-hyperlinks/license +10 -0
  124. package/node_modules/supports-hyperlinks/package.json +47 -0
  125. package/node_modules/supports-hyperlinks/readme.md +45 -0
  126. package/package.json +41 -10
  127. package/patches/markdansi@0.3.2.patch +22 -2
  128. package/src/agent/context-builder.ts +24 -10
  129. package/src/agent/context-meter.ts +89 -5
  130. package/src/agent/loop.ts +107 -10
  131. package/src/agent/runtime-session.ts +1437 -82
  132. package/src/agent/session-ledger.ts +169 -29
  133. package/src/agent/types.ts +12 -1
  134. package/src/agent/user-prompt-projection.ts +62 -0
  135. package/src/cli/config.ts +57 -14
  136. package/src/cli/model-profiles.ts +124 -0
  137. package/src/cli/run-runner.ts +4 -1
  138. package/src/cli/tui-runner.tsx +68 -30
  139. package/src/context/compiled-context-hash.ts +1 -1
  140. package/src/context/compiled-context-validator.ts +92 -15
  141. package/src/context/context-automation-policy.ts +121 -0
  142. package/src/context/context-manager.ts +390 -11
  143. package/src/context/context-policy.ts +8 -0
  144. package/src/context/context-protocol-validator.ts +11 -1
  145. package/src/context/context-revision-compiler.ts +190 -27
  146. package/src/context/context-revision.ts +105 -19
  147. package/src/context/context-surface.ts +329 -0
  148. package/src/context/prefix-retirement-planner.ts +570 -0
  149. package/src/context/protocol-frame.ts +30 -3
  150. package/src/context/recall-retirement-contract.ts +27 -0
  151. package/src/context/swap-planner.ts +34 -10
  152. package/src/events/observation-text-log.ts +3 -3
  153. package/src/events/stdout-event-printer.ts +39 -3
  154. package/src/events/types.ts +172 -35
  155. package/src/ids/runtime-id.ts +6 -0
  156. package/src/ids/uuid-v7.ts +7 -0
  157. package/src/image/image-asset-store.ts +319 -0
  158. package/src/image/image-input-policy.ts +14 -0
  159. package/src/image/image-probe.ts +233 -0
  160. package/src/image/image-types.ts +262 -0
  161. package/src/instructions/project-instructions.ts +4 -1
  162. package/src/mcp/mcp-manager.ts +83 -19
  163. package/src/model/fake-model-client.ts +9 -1
  164. package/src/model/input-token-estimator.ts +25 -0
  165. package/src/model/model-client.ts +60 -0
  166. package/src/model/model-request-preflight.ts +1 -0
  167. package/src/model/moonshot-input-token-estimator.ts +107 -0
  168. package/src/model/openai-chat-mapping.ts +77 -1
  169. package/src/model/openai-chat-model-client.ts +389 -59
  170. package/src/model/openai-chat-stream.ts +272 -0
  171. package/src/model/prompt-prefix-hash.ts +2 -4
  172. package/src/model/token-estimator.ts +4 -0
  173. package/src/observation/observation-builder.ts +29 -0
  174. package/src/session/resume-projection.ts +94 -34
  175. package/src/session/session-catalog.ts +4 -4
  176. package/src/session/session-errors.ts +1 -1
  177. package/src/session/session-history-reader.ts +151 -4
  178. package/src/session/session-last-response-reader.ts +189 -0
  179. package/src/session/session-schema.ts +349 -35
  180. package/src/session/session-store.ts +3266 -255
  181. package/src/session/sqlite-session-ledger.ts +9 -4
  182. package/src/skills/skill-catalog.ts +182 -0
  183. package/src/skills/skill-context.ts +364 -0
  184. package/src/skills/skill-loader.ts +779 -0
  185. package/src/skills/skill-tool.ts +297 -0
  186. package/src/tools/edit.ts +51 -64
  187. package/src/tools/ensure-parent-directory.ts +6 -0
  188. package/src/tools/read.ts +2 -2
  189. package/src/tools/recall.ts +43 -40
  190. package/src/tools/registry.ts +24 -3
  191. package/src/tools/ripgrep.ts +3 -2
  192. package/src/tools/types.ts +43 -4
  193. package/src/tools/write.ts +20 -31
  194. package/src/tui/app.tsx +333 -96
  195. package/src/tui/clipboard.ts +5 -0
  196. package/src/tui/components/mcp-panel.tsx +28 -0
  197. package/src/tui/components/prompt-input.tsx +790 -257
  198. package/src/tui/components/skills-panel.tsx +31 -0
  199. package/src/tui/components/timeline.tsx +30 -1
  200. package/src/tui/event-store.ts +85 -2
  201. package/src/tui/file-mention.ts +234 -0
  202. package/src/tui/line-editor.ts +1 -0
  203. package/src/tui/project-slash-commands.ts +229 -0
  204. package/src/tui/prompt-draft.ts +510 -0
  205. package/src/tui/prompt-history.ts +294 -22
  206. package/src/tui/slash-commands.ts +46 -2
  207. package/src/tui/tui-session-controller.ts +110 -48
  208. package/src/tui/workspace-file-search.ts +94 -0
@@ -0,0 +1,297 @@
1
+ import { lstat, readdir, realpath, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import {
4
+ SKILL_RESOURCE_MAX_DEPTH,
5
+ SKILL_RESOURCE_MAX_ENTRIES,
6
+ SkillError,
7
+ type LoadedSkill,
8
+ type SkillCatalogSnapshot,
9
+ } from "./skill-loader";
10
+ import { createSkillToolDefinition } from "./skill-catalog";
11
+ import type { SkillActivationCoordinator } from "./skill-context";
12
+ import { defineToolExecutor, type ToolExecutor } from "../tools/types";
13
+ import { cancellationError, throwIfTurnCancelled } from "../agent/turn-cancellation";
14
+
15
+ export function createSkillToolExecutor(input: {
16
+ catalog: SkillCatalogSnapshot;
17
+ coordinator: SkillActivationCoordinator;
18
+ }): ToolExecutor {
19
+ return defineToolExecutor("skill", {
20
+ definition: createSkillToolDefinition(input.catalog),
21
+ async execute(args, _call, context) {
22
+ throwIfTurnCancelled(context.signal);
23
+ let name: string;
24
+ try {
25
+ name = parseSkillArgs(args);
26
+ } catch (error) {
27
+ return failed(
28
+ "",
29
+ error instanceof SkillError ? error.code : "SKILL_FIELD_INVALID",
30
+ errorMessage(error),
31
+ );
32
+ }
33
+ const skill = input.catalog.skills.get(name);
34
+ if (skill === undefined) {
35
+ return failed(name, "SKILL_NOT_FOUND", `Unknown Agent Skill: ${name}.`);
36
+ }
37
+ const status = input.coordinator.status(name);
38
+ if (status.status === "active") {
39
+ return {
40
+ ok: true,
41
+ status: "already_active",
42
+ name,
43
+ scope: status.skill.scope,
44
+ sha256: status.skill.sha256,
45
+ };
46
+ }
47
+ if (status.status === "loaded") {
48
+ return {
49
+ ok: true,
50
+ status: "already_loaded",
51
+ name,
52
+ scope: status.skill.scope,
53
+ lifecycle: status.lifecycle,
54
+ sha256: status.skill.sha256,
55
+ };
56
+ }
57
+
58
+ try {
59
+ input.coordinator.reserve(skill);
60
+ const resources = await listSkillResources(skill, context.signal);
61
+ throwIfTurnCancelled(context.signal);
62
+ return {
63
+ ok: true,
64
+ status: "loaded",
65
+ name,
66
+ scope: skill.scope,
67
+ directory: skill.directory,
68
+ skillFilePath: skill.skillFilePath,
69
+ content: skill.content,
70
+ byteLength: skill.byteLength,
71
+ sha256: skill.sha256,
72
+ resources: resources.paths,
73
+ resourcesTruncated: resources.truncated,
74
+ };
75
+ } catch (error) {
76
+ input.coordinator.release(name);
77
+ if (context.signal.aborted) {
78
+ throw cancellationError(context.signal, error);
79
+ }
80
+ const code =
81
+ error instanceof SkillError ? error.code : "SKILL_RESOURCE_INVALID";
82
+ return failed(name, code, errorMessage(error));
83
+ }
84
+ },
85
+ });
86
+ }
87
+
88
+ function parseSkillArgs(args: unknown): string {
89
+ if (
90
+ typeof args !== "object" ||
91
+ args === null ||
92
+ Array.isArray(args) ||
93
+ Object.keys(args).length !== 1 ||
94
+ typeof (args as Record<string, unknown>).name !== "string" ||
95
+ (args as Record<string, string>).name.trim() === ""
96
+ ) {
97
+ throw new SkillError(
98
+ "SKILL_FIELD_INVALID",
99
+ "Skill arguments must be exactly { name: <available-skill-name> }.",
100
+ );
101
+ }
102
+ return (args as { name: string }).name;
103
+ }
104
+
105
+ async function listSkillResources(
106
+ skill: LoadedSkill,
107
+ signal: AbortSignal,
108
+ ): Promise<{
109
+ paths: readonly string[];
110
+ truncated: boolean;
111
+ }> {
112
+ const files: string[] = [];
113
+ let depthTruncated = false;
114
+ for (const rootName of ["assets", "references", "scripts"] as const) {
115
+ throwIfTurnCancelled(signal);
116
+ if (files.length > SKILL_RESOURCE_MAX_ENTRIES) {
117
+ break;
118
+ }
119
+ const logicalRoot = path.join(skill.directory, rootName);
120
+ let rootStats;
121
+ try {
122
+ rootStats = await lstat(logicalRoot);
123
+ } catch (error) {
124
+ if (isErrno(error, "ENOENT")) {
125
+ continue;
126
+ }
127
+ throw resourceError(skill, rootName, error);
128
+ }
129
+ const canonicalRoot = await resolveResourcePath(skill, logicalRoot, rootName);
130
+ const targetStats = await stat(canonicalRoot);
131
+ if (!targetStats.isDirectory()) {
132
+ if (rootStats.isSymbolicLink()) {
133
+ throw resourceError(
134
+ skill,
135
+ rootName,
136
+ "resource root symlink is not a directory",
137
+ );
138
+ }
139
+ continue;
140
+ }
141
+ await walkResources({
142
+ skill,
143
+ logicalDirectory: logicalRoot,
144
+ logicalRelative: rootName,
145
+ depth: 1,
146
+ canonicalAncestors: new Set([canonicalRoot]),
147
+ files,
148
+ signal,
149
+ onDepthTruncated: () => {
150
+ depthTruncated = true;
151
+ },
152
+ });
153
+ }
154
+ files.sort(compareText);
155
+ const truncated = depthTruncated || files.length > SKILL_RESOURCE_MAX_ENTRIES;
156
+ return {
157
+ paths: Object.freeze(files.slice(0, SKILL_RESOURCE_MAX_ENTRIES)),
158
+ truncated,
159
+ };
160
+ }
161
+
162
+ async function walkResources(input: {
163
+ skill: LoadedSkill;
164
+ logicalDirectory: string;
165
+ logicalRelative: string;
166
+ depth: number;
167
+ canonicalAncestors: ReadonlySet<string>;
168
+ files: string[];
169
+ signal: AbortSignal;
170
+ onDepthTruncated(): void;
171
+ }): Promise<void> {
172
+ throwIfTurnCancelled(input.signal);
173
+ let entries;
174
+ try {
175
+ entries = await readdir(input.logicalDirectory, { withFileTypes: true });
176
+ } catch (error) {
177
+ throw resourceError(input.skill, input.logicalRelative, error);
178
+ }
179
+ entries.sort((left, right) => compareText(left.name, right.name));
180
+ for (const entry of entries) {
181
+ throwIfTurnCancelled(input.signal);
182
+ if (input.files.length > SKILL_RESOURCE_MAX_ENTRIES) {
183
+ return;
184
+ }
185
+ const logicalPath = path.join(input.logicalDirectory, entry.name);
186
+ const relativePath = `${input.logicalRelative}/${entry.name}`;
187
+ const canonicalPath = await resolveResourcePath(
188
+ input.skill,
189
+ logicalPath,
190
+ relativePath,
191
+ );
192
+ const targetStats = await stat(canonicalPath);
193
+ if (targetStats.isFile()) {
194
+ input.files.push(relativePath.split(path.sep).join("/"));
195
+ continue;
196
+ }
197
+ if (!targetStats.isDirectory()) {
198
+ throw resourceError(
199
+ input.skill,
200
+ relativePath,
201
+ "resource must resolve to a regular file or directory",
202
+ );
203
+ }
204
+ if (input.depth >= SKILL_RESOURCE_MAX_DEPTH) {
205
+ input.onDepthTruncated();
206
+ continue;
207
+ }
208
+ if (input.canonicalAncestors.has(canonicalPath)) {
209
+ throw resourceError(input.skill, relativePath, "resource symlink cycle detected");
210
+ }
211
+ await walkResources({
212
+ ...input,
213
+ logicalDirectory: logicalPath,
214
+ logicalRelative: relativePath,
215
+ depth: input.depth + 1,
216
+ canonicalAncestors: new Set([...input.canonicalAncestors, canonicalPath]),
217
+ });
218
+ if (input.files.length > SKILL_RESOURCE_MAX_ENTRIES) {
219
+ return;
220
+ }
221
+ }
222
+ }
223
+
224
+ async function resolveResourcePath(
225
+ skill: LoadedSkill,
226
+ logicalPath: string,
227
+ relativePath: string,
228
+ ): Promise<string> {
229
+ try {
230
+ const canonical = await realpath(logicalPath);
231
+ if (!isWithin(skill.directory, canonical)) {
232
+ throw resourceError(
233
+ skill,
234
+ relativePath,
235
+ "resource resolves outside the skill directory",
236
+ );
237
+ }
238
+ return canonical;
239
+ } catch (error) {
240
+ if (error instanceof SkillError) {
241
+ throw error;
242
+ }
243
+ throw resourceError(skill, relativePath, error);
244
+ }
245
+ }
246
+
247
+ function failed(
248
+ name: string,
249
+ errorCode: string,
250
+ error: string,
251
+ ): {
252
+ ok: false;
253
+ status: "failed";
254
+ name: string;
255
+ errorCode: string;
256
+ error: string;
257
+ } {
258
+ return { ok: false, status: "failed", name, errorCode, error };
259
+ }
260
+
261
+ function resourceError(
262
+ skill: LoadedSkill,
263
+ relativePath: string,
264
+ cause: unknown,
265
+ ): SkillError {
266
+ return new SkillError(
267
+ "SKILL_RESOURCE_INVALID",
268
+ `Agent Skill ${skill.scope}/${skill.name} resource ${relativePath} is invalid: ${errorMessage(cause)}.`,
269
+ { cause },
270
+ );
271
+ }
272
+
273
+ function isWithin(root: string, candidate: string): boolean {
274
+ const relative = path.relative(root, candidate);
275
+ return (
276
+ relative !== ".." &&
277
+ !relative.startsWith(`..${path.sep}`) &&
278
+ !path.isAbsolute(relative)
279
+ );
280
+ }
281
+
282
+ function isErrno(error: unknown, code: string): boolean {
283
+ return (
284
+ typeof error === "object" &&
285
+ error !== null &&
286
+ "code" in error &&
287
+ error.code === code
288
+ );
289
+ }
290
+
291
+ function compareText(left: string, right: string): number {
292
+ return left < right ? -1 : left > right ? 1 : 0;
293
+ }
294
+
295
+ function errorMessage(error: unknown): string {
296
+ return error instanceof Error ? error.message : String(error);
297
+ }
package/src/tools/edit.ts CHANGED
@@ -1,14 +1,14 @@
1
- import path from "node:path";
2
1
  import { Buffer } from "node:buffer";
3
2
  import { readFile, stat, writeFile } from "node:fs/promises";
4
3
  import { throwIfTurnCancelled } from "../agent/turn-cancellation";
5
4
  import { computeFilePatch } from "./file-diff";
6
- import { sha256Text } from "./hash";
5
+ import { ensureParentDirectory } from "./ensure-parent-directory";
6
+ import { sha256Bytes, sha256Text } from "./hash";
7
7
  import { resolveWorkspacePath } from "./path-safety";
8
8
  import { defineToolExecutor } from "./types";
9
9
  import type {
10
10
  EditFileRawResult,
11
- ReadSnapshotStore,
11
+ FileSnapshotStore,
12
12
  ToolExecutionContext,
13
13
  ToolExecutor,
14
14
  } from "./types";
@@ -22,7 +22,7 @@ type EditArgs = {
22
22
 
23
23
  export type EditToolOptions = {
24
24
  workspaceRoot: string;
25
- snapshots: ReadSnapshotStore;
25
+ snapshots: FileSnapshotStore;
26
26
  };
27
27
 
28
28
  export function createEditToolExecutor(options: EditToolOptions): ToolExecutor {
@@ -30,7 +30,7 @@ export function createEditToolExecutor(options: EditToolOptions): ToolExecutor {
30
30
  definition: {
31
31
  name: "Edit",
32
32
  description:
33
- "Replace an exact string in a workspace file. The file must be read first; a successful paginated Read is sufficient.",
33
+ "Replace an exact string in an existing file. Exact replacement requires a current version established by a successful Read, Write, or Edit; when none exists, call Read first, and a successful paginated Read is sufficient. Set old_string='' to create a file or write to an empty file; missing parent directories are created when creating a file.",
34
34
  parameters: {
35
35
  type: "object",
36
36
  additionalProperties: false,
@@ -41,7 +41,8 @@ export function createEditToolExecutor(options: EditToolOptions): ToolExecutor {
41
41
  },
42
42
  old_string: {
43
43
  type: "string",
44
- description: "Exact text to replace.",
44
+ description:
45
+ "Exact text to replace. Use an empty string only to create a file or write to an empty file.",
45
46
  },
46
47
  new_string: {
47
48
  type: "string",
@@ -84,17 +85,6 @@ export function createEditToolExecutor(options: EditToolOptions): ToolExecutor {
84
85
  };
85
86
  }
86
87
 
87
- const parentPath = path.dirname(absolutePath);
88
- const parentCheck = await directoryExists(parentPath);
89
- if (!parentCheck.ok) {
90
- return {
91
- ok: false,
92
- filePath: input.file_path,
93
- absolutePath,
94
- error: parentCheck.error,
95
- };
96
- }
97
-
98
88
  const target = await targetFileState(absolutePath);
99
89
  if (!target.ok) {
100
90
  return {
@@ -126,7 +116,7 @@ export function createEditToolExecutor(options: EditToolOptions): ToolExecutor {
126
116
  }
127
117
 
128
118
  const snapshot = options.snapshots.get(absolutePath);
129
- if (snapshot === undefined || snapshot.source === "write") {
119
+ if (snapshot === undefined) {
130
120
  return {
131
121
  ok: false,
132
122
  filePath: input.file_path,
@@ -136,15 +126,14 @@ export function createEditToolExecutor(options: EditToolOptions): ToolExecutor {
136
126
  };
137
127
  }
138
128
 
139
- if (target.mtimeMs > snapshot.mtimeMs) {
129
+ if (target.sha256 !== snapshot.sha256) {
140
130
  return {
141
131
  ok: false,
142
132
  filePath: input.file_path,
143
133
  absolutePath,
144
- currentMtimeMs: target.mtimeMs,
145
- lastObservedMtimeMs: snapshot.mtimeMs,
146
- error:
147
- "File changed after it was last read or edited. Read it again before Edit.",
134
+ currentSha256: target.sha256,
135
+ lastObservedSha256: snapshot.sha256,
136
+ error: "File changed after it was last observed. Read it again before Edit.",
148
137
  };
149
138
  }
150
139
 
@@ -181,7 +170,7 @@ export function createEditToolExecutor(options: EditToolOptions): ToolExecutor {
181
170
  replacementCount: input.replace_all ? replacementCount : 1,
182
171
  replaceAll: input.replace_all,
183
172
  created: false,
184
- expectedMtimeMs: target.mtimeMs,
173
+ expectedSha256: target.sha256,
185
174
  readRequiredOnChange: true,
186
175
  snapshots: options.snapshots,
187
176
  signal: context.signal,
@@ -229,7 +218,7 @@ async function writeEmptyTarget(input: {
229
218
  absolutePath: string;
230
219
  target: TargetFileState;
231
220
  newContent: string;
232
- snapshots: ReadSnapshotStore;
221
+ snapshots: FileSnapshotStore;
233
222
  signal: AbortSignal;
234
223
  }): Promise<EditFileRawResult> {
235
224
  if (input.target.exists && input.target.content.length > 0) {
@@ -250,7 +239,7 @@ async function writeEmptyTarget(input: {
250
239
  replacementCount: 0,
251
240
  replaceAll: false,
252
241
  created: !input.target.exists,
253
- expectedMtimeMs: input.target.exists ? input.target.mtimeMs : undefined,
242
+ expectedSha256: input.target.exists ? input.target.sha256 : undefined,
254
243
  readRequiredOnChange: false,
255
244
  snapshots: input.snapshots,
256
245
  signal: input.signal,
@@ -266,15 +255,15 @@ async function writeEditedContent(input: {
266
255
  replacementCount: number;
267
256
  replaceAll: boolean;
268
257
  created: boolean;
269
- expectedMtimeMs?: number;
258
+ expectedSha256?: string;
270
259
  readRequiredOnChange: boolean;
271
- snapshots: ReadSnapshotStore;
260
+ snapshots: FileSnapshotStore;
272
261
  signal: AbortSignal;
273
262
  }): Promise<EditFileRawResult> {
274
263
  throwIfTurnCancelled(input.signal);
275
264
 
276
- if (input.expectedMtimeMs !== undefined) {
277
- const currentState = await fileMtime(input.absolutePath);
265
+ if (input.expectedSha256 !== undefined) {
266
+ const currentState = await targetFileState(input.absolutePath);
278
267
  throwIfTurnCancelled(input.signal);
279
268
 
280
269
  if (!currentState.ok) {
@@ -286,13 +275,22 @@ async function writeEditedContent(input: {
286
275
  };
287
276
  }
288
277
 
289
- if (currentState.mtimeMs > input.expectedMtimeMs) {
278
+ if (!currentState.exists) {
279
+ return {
280
+ ok: false,
281
+ filePath: input.filePath,
282
+ absolutePath: input.absolutePath,
283
+ error: "File changed while Edit was being prepared: File does not exist.",
284
+ };
285
+ }
286
+
287
+ if (currentState.sha256 !== input.expectedSha256) {
290
288
  return {
291
289
  ok: false,
292
290
  filePath: input.filePath,
293
291
  absolutePath: input.absolutePath,
294
- currentMtimeMs: currentState.mtimeMs,
295
- lastObservedMtimeMs: input.expectedMtimeMs,
292
+ currentSha256: currentState.sha256,
293
+ lastObservedSha256: input.expectedSha256,
296
294
  error: input.readRequiredOnChange
297
295
  ? "File changed while Edit was being prepared. Read it again before Edit."
298
296
  : "File changed while Edit was being prepared. Retry Edit with the current file state.",
@@ -300,6 +298,20 @@ async function writeEditedContent(input: {
300
298
  }
301
299
  }
302
300
 
301
+ if (input.created) {
302
+ try {
303
+ await ensureParentDirectory(input.absolutePath);
304
+ } catch (error) {
305
+ return {
306
+ ok: false,
307
+ filePath: input.filePath,
308
+ absolutePath: input.absolutePath,
309
+ error: `Failed to create parent directory: ${errorMessage(error)}`,
310
+ };
311
+ }
312
+ throwIfTurnCancelled(input.signal);
313
+ }
314
+
303
315
  await writeFile(input.absolutePath, input.newContent, "utf8");
304
316
  const newSha256 = sha256Text(input.newContent);
305
317
  const writtenInfo = await stat(input.absolutePath);
@@ -330,33 +342,6 @@ async function writeEditedContent(input: {
330
342
  };
331
343
  }
332
344
 
333
- async function directoryExists(
334
- directoryPath: string,
335
- ): Promise<{ ok: true } | { ok: false; error: string }> {
336
- try {
337
- const info = await stat(directoryPath);
338
- return info.isDirectory()
339
- ? { ok: true }
340
- : { ok: false, error: "Parent path is not a directory." };
341
- } catch {
342
- return { ok: false, error: "Parent directory does not exist." };
343
- }
344
- }
345
-
346
- async function fileMtime(
347
- filePath: string,
348
- ): Promise<{ ok: true; mtimeMs: number } | { ok: false; error: string }> {
349
- try {
350
- const info = await stat(filePath);
351
- if (!info.isFile()) {
352
- return { ok: false, error: "Path is not a file." };
353
- }
354
- return { ok: true, mtimeMs: info.mtimeMs };
355
- } catch (error) {
356
- return { ok: false, error: errorMessage(error) };
357
- }
358
- }
359
-
360
345
  type TargetFileState =
361
346
  | { ok: true; exists: false }
362
347
  | {
@@ -376,7 +361,7 @@ async function targetFileState(
376
361
  return { ok: false, error: "Path is not a file." };
377
362
  }
378
363
 
379
- const content = await readFile(absolutePath, "utf8");
364
+ const bytes = await readFile(absolutePath);
380
365
  const currentInfo = await stat(absolutePath);
381
366
  if (currentInfo.mtimeMs > info.mtimeMs) {
382
367
  return { ok: false, error: "File changed while it was being read." };
@@ -385,8 +370,8 @@ async function targetFileState(
385
370
  return {
386
371
  ok: true,
387
372
  exists: true,
388
- content,
389
- sha256: sha256Text(content),
373
+ content: bytes.toString("utf8"),
374
+ sha256: sha256Bytes(bytes),
390
375
  mtimeMs: currentInfo.mtimeMs,
391
376
  };
392
377
  } catch (error) {
@@ -415,11 +400,13 @@ function isRecord(value: unknown): value is Record<string, unknown> {
415
400
  }
416
401
 
417
402
  function isNotFound(error: unknown): boolean {
403
+ // ENOTDIR means a parent component is a file, so the target file cannot exist.
404
+ // Treat it as missing here so each Edit mode can apply its documented behavior.
418
405
  return (
419
406
  typeof error === "object" &&
420
407
  error !== null &&
421
408
  "code" in error &&
422
- error.code === "ENOENT"
409
+ (error.code === "ENOENT" || error.code === "ENOTDIR")
423
410
  );
424
411
  }
425
412
 
@@ -0,0 +1,6 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ export async function ensureParentDirectory(filePath: string): Promise<void> {
5
+ await mkdir(path.dirname(filePath), { recursive: true });
6
+ }
package/src/tools/read.ts CHANGED
@@ -5,8 +5,8 @@ import { sha256Bytes } from "./hash";
5
5
  import { resolveWorkspacePath } from "./path-safety";
6
6
  import { defineToolExecutor } from "./types";
7
7
  import type {
8
+ FileSnapshotStore,
8
9
  ReadFileRawResult,
9
- ReadSnapshotStore,
10
10
  ToolExecutionContext,
11
11
  ToolExecutor,
12
12
  } from "./types";
@@ -19,7 +19,7 @@ type ReadArgs = {
19
19
 
20
20
  export type ReadToolOptions = {
21
21
  workspaceRoot: string;
22
- snapshots: ReadSnapshotStore;
22
+ snapshots: FileSnapshotStore;
23
23
  maxContentBytes?: number;
24
24
  };
25
25
 
@@ -18,9 +18,51 @@ import {
18
18
  type RecallRawResult,
19
19
  type RecallToolErrorCode,
20
20
  type ToolExecutionContext,
21
+ type ToolDefinition,
21
22
  type ToolExecutor,
22
23
  } from "./types";
23
24
 
25
+ export const RECALL_TOOL_DEFINITION: ToolDefinition = Object.freeze({
26
+ name: "Recall",
27
+ description:
28
+ "Search or retrieve immutable model-visible history from the current session. Search matches literal substrings: use a short distinctive anchor such as a path, symbol, project, command fragment, or error text, not a whole natural-language question. Results are historical snapshots and may differ from the current workspace. Use search to find a source, get to retrieve exact content, and Read/Grep for current files.",
29
+ parameters: {
30
+ type: "object",
31
+ additionalProperties: false,
32
+ properties: {
33
+ mode: { type: "string", enum: ["search", "get"] },
34
+ query: { type: "string", maxLength: 1024 },
35
+ roles: {
36
+ type: "array",
37
+ minItems: 1,
38
+ uniqueItems: true,
39
+ items: { type: "string", enum: ["user", "assistant", "tool"] },
40
+ },
41
+ tool_names: {
42
+ type: "array",
43
+ minItems: 1,
44
+ maxItems: 16,
45
+ uniqueItems: true,
46
+ items: { type: "string", minLength: 1 },
47
+ },
48
+ turn_from: { type: "integer", minimum: 1 },
49
+ turn_to: { type: "integer", minimum: 1 },
50
+ limit: { type: "integer", minimum: 1, maximum: 20, default: 10 },
51
+ offset: { type: "integer", minimum: 0, default: 0 },
52
+ snapshot_through_ordinal: { type: "integer", minimum: 1 },
53
+ source: { type: "string" },
54
+ byte_offset: { type: "integer", minimum: 0, default: 0 },
55
+ byte_limit: {
56
+ type: "integer",
57
+ minimum: 256,
58
+ maximum: 20_000,
59
+ default: 12_000,
60
+ },
61
+ },
62
+ required: ["mode"],
63
+ },
64
+ });
65
+
24
66
  type RecallSearchArgs = {
25
67
  mode: "search";
26
68
  query: string;
@@ -55,46 +97,7 @@ export function createRecallToolExecutor(options: {
55
97
  historyReader: SessionHistoryReader;
56
98
  }): ToolExecutor {
57
99
  return defineToolExecutor("recall", {
58
- definition: {
59
- name: "Recall",
60
- description:
61
- "Search or retrieve immutable model-visible history from the current session. Results are historical snapshots and may differ from the current workspace. Use search to find a source, get to retrieve exact content, and Read/Grep for current files.",
62
- parameters: {
63
- type: "object",
64
- additionalProperties: false,
65
- properties: {
66
- mode: { type: "string", enum: ["search", "get"] },
67
- query: { type: "string", maxLength: 1024 },
68
- roles: {
69
- type: "array",
70
- minItems: 1,
71
- uniqueItems: true,
72
- items: { type: "string", enum: ["user", "assistant", "tool"] },
73
- },
74
- tool_names: {
75
- type: "array",
76
- minItems: 1,
77
- maxItems: 16,
78
- uniqueItems: true,
79
- items: { type: "string", minLength: 1 },
80
- },
81
- turn_from: { type: "integer", minimum: 1 },
82
- turn_to: { type: "integer", minimum: 1 },
83
- limit: { type: "integer", minimum: 1, maximum: 20, default: 10 },
84
- offset: { type: "integer", minimum: 0, default: 0 },
85
- snapshot_through_ordinal: { type: "integer", minimum: 1 },
86
- source: { type: "string" },
87
- byte_offset: { type: "integer", minimum: 0, default: 0 },
88
- byte_limit: {
89
- type: "integer",
90
- minimum: 256,
91
- maximum: 20_000,
92
- default: 12_000,
93
- },
94
- },
95
- required: ["mode"],
96
- },
97
- },
100
+ definition: RECALL_TOOL_DEFINITION,
98
101
  async execute(
99
102
  args,
100
103
  _call,