@animalabs/agent-framework 0.26.2 → 0.27.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 (276) hide show
  1. package/README.md +56 -6
  2. package/dist/src/agent.d.ts +263 -22
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +531 -54
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/api/server.d.ts.map +1 -1
  7. package/dist/src/api/server.js +1 -2
  8. package/dist/src/api/server.js.map +1 -1
  9. package/dist/src/auto-continue.d.ts +33 -0
  10. package/dist/src/auto-continue.d.ts.map +1 -0
  11. package/dist/src/auto-continue.js +35 -0
  12. package/dist/src/auto-continue.js.map +1 -0
  13. package/dist/src/code-execution/isolate/scripts.d.ts +11 -0
  14. package/dist/src/code-execution/isolate/scripts.d.ts.map +1 -1
  15. package/dist/src/code-execution/isolate/scripts.js +5 -1
  16. package/dist/src/code-execution/isolate/scripts.js.map +1 -1
  17. package/dist/src/framework.d.ts +759 -129
  18. package/dist/src/framework.d.ts.map +1 -1
  19. package/dist/src/framework.js +3482 -928
  20. package/dist/src/framework.js.map +1 -1
  21. package/dist/src/gate/event-gate.d.ts +309 -46
  22. package/dist/src/gate/event-gate.d.ts.map +1 -1
  23. package/dist/src/gate/event-gate.js +2117 -395
  24. package/dist/src/gate/event-gate.js.map +1 -1
  25. package/dist/src/gate/index.d.ts +2 -2
  26. package/dist/src/gate/index.d.ts.map +1 -1
  27. package/dist/src/gate/index.js +1 -1
  28. package/dist/src/gate/index.js.map +1 -1
  29. package/dist/src/gate/recipe-reconcile.d.ts +103 -0
  30. package/dist/src/gate/recipe-reconcile.d.ts.map +1 -0
  31. package/dist/src/gate/recipe-reconcile.js +325 -0
  32. package/dist/src/gate/recipe-reconcile.js.map +1 -0
  33. package/dist/src/gate/types.d.ts +68 -8
  34. package/dist/src/gate/types.d.ts.map +1 -1
  35. package/dist/src/index.d.ts +6 -4
  36. package/dist/src/index.d.ts.map +1 -1
  37. package/dist/src/index.js +5 -2
  38. package/dist/src/index.js.map +1 -1
  39. package/dist/src/mcpl/channel-registry.d.ts +433 -113
  40. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  41. package/dist/src/mcpl/channel-registry.js +1152 -434
  42. package/dist/src/mcpl/channel-registry.js.map +1 -1
  43. package/dist/src/mcpl/conversation-router.d.ts +16 -8
  44. package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
  45. package/dist/src/mcpl/conversation-router.js +29 -15
  46. package/dist/src/mcpl/conversation-router.js.map +1 -1
  47. package/dist/src/mcpl/convert-block.d.ts.map +1 -1
  48. package/dist/src/mcpl/convert-block.js +14 -2
  49. package/dist/src/mcpl/convert-block.js.map +1 -1
  50. package/dist/src/mcpl/hook-orchestrator.d.ts +7 -1
  51. package/dist/src/mcpl/hook-orchestrator.d.ts.map +1 -1
  52. package/dist/src/mcpl/hook-orchestrator.js +32 -7
  53. package/dist/src/mcpl/hook-orchestrator.js.map +1 -1
  54. package/dist/src/mcpl/index.d.ts +5 -1
  55. package/dist/src/mcpl/index.d.ts.map +1 -1
  56. package/dist/src/mcpl/index.js +4 -0
  57. package/dist/src/mcpl/index.js.map +1 -1
  58. package/dist/src/mcpl/inference-router.d.ts +14 -2
  59. package/dist/src/mcpl/inference-router.d.ts.map +1 -1
  60. package/dist/src/mcpl/inference-router.js +73 -28
  61. package/dist/src/mcpl/inference-router.js.map +1 -1
  62. package/dist/src/mcpl/prose-grammar.d.ts +4 -0
  63. package/dist/src/mcpl/prose-grammar.d.ts.map +1 -1
  64. package/dist/src/mcpl/prose-grammar.js +6 -0
  65. package/dist/src/mcpl/prose-grammar.js.map +1 -1
  66. package/dist/src/mcpl/protocol-family.d.ts.map +1 -1
  67. package/dist/src/mcpl/protocol-family.js +3 -0
  68. package/dist/src/mcpl/protocol-family.js.map +1 -1
  69. package/dist/src/mcpl/push-coalescer.d.ts +20 -2
  70. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  71. package/dist/src/mcpl/push-coalescer.js +33 -11
  72. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  73. package/dist/src/mcpl/reference-fetcher.d.ts +63 -42
  74. package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -1
  75. package/dist/src/mcpl/reference-fetcher.js +177 -145
  76. package/dist/src/mcpl/reference-fetcher.js.map +1 -1
  77. package/dist/src/mcpl/references.d.ts +380 -46
  78. package/dist/src/mcpl/references.d.ts.map +1 -1
  79. package/dist/src/mcpl/references.js +736 -77
  80. package/dist/src/mcpl/references.js.map +1 -1
  81. package/dist/src/mcpl/server-connection.d.ts +82 -3
  82. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  83. package/dist/src/mcpl/server-connection.js +132 -7
  84. package/dist/src/mcpl/server-connection.js.map +1 -1
  85. package/dist/src/mcpl/tool-lifecycle.d.ts +11 -0
  86. package/dist/src/mcpl/tool-lifecycle.d.ts.map +1 -1
  87. package/dist/src/mcpl/tool-lifecycle.js +21 -0
  88. package/dist/src/mcpl/tool-lifecycle.js.map +1 -1
  89. package/dist/src/mcpl/tool-presence.d.ts +259 -0
  90. package/dist/src/mcpl/tool-presence.d.ts.map +1 -0
  91. package/dist/src/mcpl/tool-presence.js +628 -0
  92. package/dist/src/mcpl/tool-presence.js.map +1 -0
  93. package/dist/src/mcpl/tool-result-normalize.d.ts +9 -3
  94. package/dist/src/mcpl/tool-result-normalize.d.ts.map +1 -1
  95. package/dist/src/mcpl/tool-result-normalize.js +14 -6
  96. package/dist/src/mcpl/tool-result-normalize.js.map +1 -1
  97. package/dist/src/mcpl/tool-signature.d.ts +39 -0
  98. package/dist/src/mcpl/tool-signature.d.ts.map +1 -0
  99. package/dist/src/mcpl/tool-signature.js +292 -0
  100. package/dist/src/mcpl/tool-signature.js.map +1 -0
  101. package/dist/src/mcpl/toolset-host.d.ts +200 -0
  102. package/dist/src/mcpl/toolset-host.d.ts.map +1 -0
  103. package/dist/src/mcpl/toolset-host.js +621 -0
  104. package/dist/src/mcpl/toolset-host.js.map +1 -0
  105. package/dist/src/mcpl/toolsets-module.d.ts +80 -0
  106. package/dist/src/mcpl/toolsets-module.d.ts.map +1 -0
  107. package/dist/src/mcpl/toolsets-module.js +431 -0
  108. package/dist/src/mcpl/toolsets-module.js.map +1 -0
  109. package/dist/src/mcpl/transport.d.ts +2 -2
  110. package/dist/src/mcpl/transport.d.ts.map +1 -1
  111. package/dist/src/mcpl/transport.js +3 -2
  112. package/dist/src/mcpl/transport.js.map +1 -1
  113. package/dist/src/mcpl/types.d.ts +21 -1
  114. package/dist/src/mcpl/types.d.ts.map +1 -1
  115. package/dist/src/mcpl/types.js.map +1 -1
  116. package/dist/src/mcpl/visible-content.d.ts +7 -9
  117. package/dist/src/mcpl/visible-content.d.ts.map +1 -1
  118. package/dist/src/mcpl/visible-content.js +21 -9
  119. package/dist/src/mcpl/visible-content.js.map +1 -1
  120. package/dist/src/module-registry.d.ts +23 -8
  121. package/dist/src/module-registry.d.ts.map +1 -1
  122. package/dist/src/module-registry.js +68 -15
  123. package/dist/src/module-registry.js.map +1 -1
  124. package/dist/src/modules/discord/index.js +1 -1
  125. package/dist/src/modules/discord/index.js.map +1 -1
  126. package/dist/src/modules/history/index.d.ts +2 -1
  127. package/dist/src/modules/history/index.d.ts.map +1 -1
  128. package/dist/src/modules/history/index.js +51 -17
  129. package/dist/src/modules/history/index.js.map +1 -1
  130. package/dist/src/modules/history/semantic.d.ts +108 -6
  131. package/dist/src/modules/history/semantic.d.ts.map +1 -1
  132. package/dist/src/modules/history/semantic.js +273 -57
  133. package/dist/src/modules/history/semantic.js.map +1 -1
  134. package/dist/src/modules/workspace/direct-write.d.ts +181 -0
  135. package/dist/src/modules/workspace/direct-write.d.ts.map +1 -0
  136. package/dist/src/modules/workspace/direct-write.js +384 -0
  137. package/dist/src/modules/workspace/direct-write.js.map +1 -0
  138. package/dist/src/modules/workspace/edit-text.d.ts +26 -12
  139. package/dist/src/modules/workspace/edit-text.d.ts.map +1 -1
  140. package/dist/src/modules/workspace/edit-text.js +58 -28
  141. package/dist/src/modules/workspace/edit-text.js.map +1 -1
  142. package/dist/src/modules/workspace/effects.d.ts +10 -0
  143. package/dist/src/modules/workspace/effects.d.ts.map +1 -1
  144. package/dist/src/modules/workspace/effects.js +1 -1
  145. package/dist/src/modules/workspace/effects.js.map +1 -1
  146. package/dist/src/modules/workspace/index.d.ts +246 -27
  147. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  148. package/dist/src/modules/workspace/index.js +1216 -236
  149. package/dist/src/modules/workspace/index.js.map +1 -1
  150. package/dist/src/modules/workspace/read-page.d.ts +41 -0
  151. package/dist/src/modules/workspace/read-page.d.ts.map +1 -0
  152. package/dist/src/modules/workspace/read-page.js +213 -0
  153. package/dist/src/modules/workspace/read-page.js.map +1 -0
  154. package/dist/src/modules/workspace/result-text.d.ts +67 -0
  155. package/dist/src/modules/workspace/result-text.d.ts.map +1 -0
  156. package/dist/src/modules/workspace/result-text.js +94 -0
  157. package/dist/src/modules/workspace/result-text.js.map +1 -0
  158. package/dist/src/modules/workspace/text.d.ts +29 -12
  159. package/dist/src/modules/workspace/text.d.ts.map +1 -1
  160. package/dist/src/modules/workspace/text.js +35 -33
  161. package/dist/src/modules/workspace/text.js.map +1 -1
  162. package/dist/src/modules/workspace/types.d.ts +41 -17
  163. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  164. package/dist/src/modules/workspace/types.js.map +1 -1
  165. package/dist/src/sent-rows.d.ts +59 -0
  166. package/dist/src/sent-rows.d.ts.map +1 -0
  167. package/dist/src/sent-rows.js +145 -0
  168. package/dist/src/sent-rows.js.map +1 -0
  169. package/dist/src/silent-heartbeat.d.ts +17 -10
  170. package/dist/src/silent-heartbeat.d.ts.map +1 -1
  171. package/dist/src/silent-heartbeat.js.map +1 -1
  172. package/dist/src/stable-hash.d.ts +3 -0
  173. package/dist/src/stable-hash.d.ts.map +1 -0
  174. package/dist/src/stable-hash.js +15 -0
  175. package/dist/src/stable-hash.js.map +1 -0
  176. package/dist/src/stored-injections.d.ts +99 -0
  177. package/dist/src/stored-injections.d.ts.map +1 -0
  178. package/dist/src/stored-injections.js +106 -0
  179. package/dist/src/stored-injections.js.map +1 -0
  180. package/dist/src/text-as-stored.d.ts +4 -3
  181. package/dist/src/text-as-stored.d.ts.map +1 -1
  182. package/dist/src/text-as-stored.js +4 -3
  183. package/dist/src/text-as-stored.js.map +1 -1
  184. package/dist/src/thinking-drops.d.ts +81 -0
  185. package/dist/src/thinking-drops.d.ts.map +1 -0
  186. package/dist/src/thinking-drops.js +103 -0
  187. package/dist/src/thinking-drops.js.map +1 -0
  188. package/dist/src/tool-image-ledger.d.ts +9 -8
  189. package/dist/src/tool-image-ledger.d.ts.map +1 -1
  190. package/dist/src/tool-image-ledger.js +9 -8
  191. package/dist/src/tool-image-ledger.js.map +1 -1
  192. package/dist/src/tool-presentation.d.ts.map +1 -1
  193. package/dist/src/tool-presentation.js +3 -1
  194. package/dist/src/tool-presentation.js.map +1 -1
  195. package/dist/src/tool-result-guard.d.ts +4 -2
  196. package/dist/src/tool-result-guard.d.ts.map +1 -1
  197. package/dist/src/tool-result-guard.js +21 -6
  198. package/dist/src/tool-result-guard.js.map +1 -1
  199. package/dist/src/tool-result-history.d.ts +176 -24
  200. package/dist/src/tool-result-history.d.ts.map +1 -1
  201. package/dist/src/tool-result-history.js +411 -91
  202. package/dist/src/tool-result-history.js.map +1 -1
  203. package/dist/src/tune-out/coordinator.d.ts +17 -9
  204. package/dist/src/tune-out/coordinator.d.ts.map +1 -1
  205. package/dist/src/tune-out/coordinator.js +84 -37
  206. package/dist/src/tune-out/coordinator.js.map +1 -1
  207. package/dist/src/types/agent.d.ts +186 -7
  208. package/dist/src/types/agent.d.ts.map +1 -1
  209. package/dist/src/types/events.d.ts +15 -1
  210. package/dist/src/types/events.d.ts.map +1 -1
  211. package/dist/src/types/framework.d.ts +24 -3
  212. package/dist/src/types/framework.d.ts.map +1 -1
  213. package/dist/src/types/module.d.ts +90 -7
  214. package/dist/src/types/module.d.ts.map +1 -1
  215. package/dist/src/types/trace.d.ts +57 -2
  216. package/dist/src/types/trace.d.ts.map +1 -1
  217. package/package.json +4 -3
  218. package/src/agent.ts +593 -60
  219. package/src/api/server.ts +1 -2
  220. package/src/auto-continue.ts +42 -0
  221. package/src/code-execution/isolate/scripts.ts +11 -3
  222. package/src/framework.ts +3601 -967
  223. package/src/gate/event-gate.ts +2315 -389
  224. package/src/gate/index.ts +2 -1
  225. package/src/gate/recipe-reconcile.ts +355 -0
  226. package/src/gate/types.ts +70 -8
  227. package/src/index.ts +7 -4
  228. package/src/mcpl/channel-registry.ts +1220 -467
  229. package/src/mcpl/conversation-router.ts +34 -16
  230. package/src/mcpl/convert-block.ts +14 -1
  231. package/src/mcpl/hook-orchestrator.ts +33 -6
  232. package/src/mcpl/index.ts +6 -1
  233. package/src/mcpl/inference-router.ts +79 -34
  234. package/src/mcpl/prose-grammar.ts +7 -0
  235. package/src/mcpl/protocol-family.ts +2 -0
  236. package/src/mcpl/push-coalescer.ts +51 -13
  237. package/src/mcpl/reference-fetcher.ts +201 -162
  238. package/src/mcpl/references.ts +851 -77
  239. package/src/mcpl/server-connection.ts +145 -7
  240. package/src/mcpl/tool-lifecycle.ts +21 -0
  241. package/src/mcpl/tool-presence.ts +650 -0
  242. package/src/mcpl/tool-result-normalize.ts +18 -6
  243. package/src/mcpl/tool-signature.ts +285 -0
  244. package/src/mcpl/toolset-host.ts +637 -0
  245. package/src/mcpl/toolsets-module.ts +451 -0
  246. package/src/mcpl/transport.ts +3 -2
  247. package/src/mcpl/types.ts +22 -1
  248. package/src/mcpl/visible-content.ts +22 -9
  249. package/src/module-registry.ts +77 -24
  250. package/src/modules/discord/index.ts +1 -1
  251. package/src/modules/history/index.ts +48 -18
  252. package/src/modules/history/semantic.ts +308 -54
  253. package/src/modules/workspace/direct-write.ts +425 -0
  254. package/src/modules/workspace/edit-text.ts +82 -35
  255. package/src/modules/workspace/effects.ts +1 -1
  256. package/src/modules/workspace/index.ts +1274 -241
  257. package/src/modules/workspace/read-page.ts +222 -0
  258. package/src/modules/workspace/result-text.ts +119 -0
  259. package/src/modules/workspace/text.ts +58 -32
  260. package/src/modules/workspace/types.ts +41 -17
  261. package/src/sent-rows.ts +162 -0
  262. package/src/silent-heartbeat.ts +17 -10
  263. package/src/stable-hash.ts +14 -0
  264. package/src/stored-injections.ts +131 -0
  265. package/src/text-as-stored.ts +4 -3
  266. package/src/thinking-drops.ts +141 -0
  267. package/src/tool-image-ledger.ts +9 -8
  268. package/src/tool-presentation.ts +3 -1
  269. package/src/tool-result-guard.ts +19 -6
  270. package/src/tool-result-history.ts +471 -90
  271. package/src/tune-out/coordinator.ts +80 -37
  272. package/src/types/agent.ts +189 -7
  273. package/src/types/events.ts +15 -1
  274. package/src/types/framework.ts +21 -3
  275. package/src/types/module.ts +86 -7
  276. package/src/types/trace.ts +58 -2
@@ -0,0 +1,425 @@
1
+ /**
2
+ * A direct mount's writes (MountConfig.backend): disk is the only copy, so a
3
+ * write lands there whole when its call answers, or doesn't happen and leaves
4
+ * disk as it was. Ported from anima-research/hands-mcp's staged write
5
+ * (src/shared.ts), behind the mount's boundary as the workspace's other
6
+ * effects hold it (effects.ts):
7
+ *
8
+ * - The file's directory is located inside the mount (checkDir), and where
9
+ * the file lands is judged as a push's write judges it (refusedLanding):
10
+ * inside the mount, in no nested mount's directory, and covered by the
11
+ * mount's include. A link at the file is refused on a mount that doesn't
12
+ * follow links; on one that does, the write goes to the file it leads to
13
+ * when the call stages it, judged the same way, and the link stays.
14
+ * - The new bytes are staged beside the file, in `.workspace-staged-<id>`,
15
+ * at the file's mode, special bits included as far as the kernel lets this
16
+ * process set them (a setgid bit only for a member of the file's group),
17
+ * and renamed over it, so a reader beside the agent never sees half a file
18
+ * and a process killed mid-write leaves the old file whole. A file this
19
+ * process may not write is refused, as a push's write, which opens it for
20
+ * writing, is refused: the rename needs only the directory's permission.
21
+ * A process that dies between staging and its rename leaves the staged
22
+ * file, under that name, which `ls` and `git status` show; so does a
23
+ * refused write whose removal of its staged file fails or can't tell the
24
+ * file is its own, or whose directory another writer moved after the
25
+ * staging, since the removal goes by path (discard) and the path then
26
+ * leads elsewhere. Nothing sweeps it, and the answer doesn't name it.
27
+ * The removal checks the staged name holds that file and unlinks it with
28
+ * no turn of the event loop between, so only a file another writer puts
29
+ * there in that instant can be removed in its place, or one put there
30
+ * that a stat can't tell from it: on the freed inode number, with the
31
+ * same size, mode and owner, in the same tick of a coarse clock.
32
+ * - The rename gives a hard-linked path a file of its own, and the other
33
+ * links keep the old text: `otherLinks` counts them for the answer.
34
+ * - Just before the rename, an edit's file is checked unchanged since its
35
+ * read, by its bytes, read again, and its identity (checkUnchanged),
36
+ * synchronously, so the check and the renames after it run with no turn
37
+ * of the event loop between. Only a change landing in that instant isn't
38
+ * seen, which for edit_many runs from the check through its last rename:
39
+ * no rename can be made conditional.
40
+ * - No sync. The store-backed kind syncs its pushes for its agreement
41
+ * record (reconcile.ts), and a direct mount keeps none, so a power loss
42
+ * or a kernel crash can lose a direct write after its answer said it
43
+ * landed, as it can a hands-mcp write. A process dying can't: the rename is
44
+ * whole by then.
45
+ *
46
+ * Node has no openat or renameat, so the stage and the rename go through the
47
+ * checked directory's path: a directory replaced by another writer on disk
48
+ * between the directory's check and the staging, or after its last check
49
+ * (placedStill), can redirect them, as effects.ts says of a create. For
50
+ * edit_many that last check is each file's, before the other files' checks.
51
+ */
52
+
53
+ import { randomUUID } from 'node:crypto';
54
+ import { accessSync, closeSync, constants as fsConstants, lstatSync, openSync, readSync, renameSync, unlinkSync } from 'node:fs';
55
+ import type { BigIntStats } from 'node:fs';
56
+ import { lstat, open, realpath } from 'node:fs/promises';
57
+ import { relative, sep } from 'node:path';
58
+ import { refusedLanding } from './effects.js';
59
+ import {
60
+ type CheckedDir,
61
+ type MountView,
62
+ NONBLOCK,
63
+ NOT_FOLLOWED,
64
+ OUTSIDE_PARENT,
65
+ checkDir,
66
+ checkRoot,
67
+ errno,
68
+ nameOf,
69
+ parentOf,
70
+ } from './observe.js';
71
+
72
+ /** An open that follows no link at the path, where the platform has one. */
73
+ const NOFOLLOW = typeof fsConstants.O_NOFOLLOW === 'number' ? fsConstants.O_NOFOLLOW : 0;
74
+
75
+ /** The name a staged file starts with, beside the file it replaces. */
76
+ export const STAGED_PREFIX = '.workspace-staged-';
77
+
78
+ /**
79
+ * Why a direct write didn't happen when its mount's root was replaced under
80
+ * it: a direct mount keeps no root to sync to (MountConfig.backend), so its
81
+ * next call works beneath whatever is there then.
82
+ */
83
+ export const ROOT_MOVED = 'the mount root changed while this call was being applied: a new call works beneath whatever directory the mount\'s path leads to then';
84
+
85
+ /**
86
+ * A file's identity as a write must find it unchanged: device, inode, size,
87
+ * mode, owner, group, and modification and change times, to the nanosecond.
88
+ * The mode, owner and group are fields of their own, since a chmod or chown
89
+ * in the same tick of a coarse clock as the file's last change leaves its
90
+ * change time as it was (checkUnchanged).
91
+ */
92
+ export interface FileIdentity {
93
+ dev: bigint;
94
+ ino: bigint;
95
+ size: bigint;
96
+ mode: bigint;
97
+ uid: bigint;
98
+ gid: bigint;
99
+ mtimeNs: bigint;
100
+ ctimeNs: bigint;
101
+ }
102
+
103
+ export const identityOf = (info: BigIntStats): FileIdentity => ({
104
+ dev: info.dev,
105
+ ino: info.ino,
106
+ size: info.size,
107
+ mode: info.mode,
108
+ uid: info.uid,
109
+ gid: info.gid,
110
+ mtimeNs: info.mtimeNs,
111
+ ctimeNs: info.ctimeNs,
112
+ });
113
+
114
+ export const sameIdentity = (a: FileIdentity, b: FileIdentity): boolean =>
115
+ a.dev === b.dev && a.ino === b.ino && a.size === b.size && a.mode === b.mode && a.uid === b.uid && a.gid === b.gid
116
+ && a.mtimeNs === b.mtimeNs && a.ctimeNs === b.ctimeNs;
117
+
118
+ /** Another writer replaced, changed or removed the file between its read and its rename; nothing was written. */
119
+ export class FileChanged extends Error {}
120
+
121
+ /** Why a direct write didn't happen, in words for its answer; disk is as it was. */
122
+ export class WriteRefused extends Error {}
123
+
124
+ /** A file's new bytes staged beside it, ready to be renamed over it (stageContained). */
125
+ export interface Staged {
126
+ /** Where the rename lands: the file's entry in its checked directory, a followed link's file's included. */
127
+ path: string;
128
+ /** The staged file, beside it. */
129
+ temp: string;
130
+ /**
131
+ * Which file the staged file is, so the rename moves, and a refusal
132
+ * removes, that one and no other put at its name: its whole identity,
133
+ * since a file made where it was unlinked can take its inode number.
134
+ */
135
+ tempIdentity: FileIdentity;
136
+ /** The mount's root and the file's directory, as checked when it was staged. */
137
+ root: CheckedDir;
138
+ parent: CheckedDir;
139
+ /**
140
+ * The other hard links the replaced file had, counted just before the
141
+ * rename and kept once it succeeds: the rename gives this path a file of
142
+ * its own, and they keep the old text. 0 until then, and for a new file.
143
+ */
144
+ otherLinks: number;
145
+ }
146
+
147
+ /**
148
+ * Test seams. `afterStage` is called once a call's files are staged, before
149
+ * they're checked unchanged (another writer acting then); `beforeRename`
150
+ * just before each rename, inside its refusal, so throwing stands for the
151
+ * rename failing. Production sets neither.
152
+ */
153
+ export const directWriteTestSeam: {
154
+ afterStage?: (paths: string[]) => void | Promise<void>;
155
+ beforeRename?: (path: string) => void;
156
+ } = {};
157
+
158
+ /** The mount-relative path of a real path inside the mount's root, `/`-separated. */
159
+ function relativeTo(rootReal: string, real: string): string {
160
+ return relative(rootReal, real).split(sep).join('/');
161
+ }
162
+
163
+ /**
164
+ * The checked directory a mount-relative file lies in, beneath a checked
165
+ * root, or why not. With `makeDirectory`, missing directories are made, as
166
+ * a push's write makes them; without it, a missing one refuses the write,
167
+ * since an edit replaces a file that exists.
168
+ */
169
+ async function directoryOf(view: MountView, rootReal: string, rel: string, makeDirectory: boolean): Promise<CheckedDir> {
170
+ const checked = await checkDir(view, rootReal, parentOf(rel), { create: makeDirectory });
171
+ if (checked.kind === 'checked') return checked.dir;
172
+ if (checked.kind === 'absent') throw new FileChanged(rel);
173
+ throw new WriteRefused(checked.outside ? OUTSIDE_PARENT : checked.reason);
174
+ }
175
+
176
+ /**
177
+ * Stage `bytes` beside the mount-relative file `rel`, to be renamed over it:
178
+ * the first half of a direct write. The file keeps its mode, special bits
179
+ * included, as far as the kernel lets this process set them; a new file
180
+ * takes `0666` less the umask, as a shell's would. Its owner and group are
181
+ * this process's and the ones its directory gives a new file, and an ACL or
182
+ * extended attribute on the file isn't carried over, as with any write that
183
+ * renames over a file. Anything there that isn't a regular file is refused,
184
+ * and a refusal removes the staged file it made, except where the header
185
+ * says one is left. The staged file is written and moded through its own
186
+ * handle, never through its name again.
187
+ */
188
+ export async function stageContained(
189
+ view: MountView,
190
+ rootReal: string,
191
+ rel: string,
192
+ bytes: Buffer,
193
+ { makeDirectory = false }: { makeDirectory?: boolean } = {},
194
+ ): Promise<Staged> {
195
+ const root = await checkRoot(view, rootReal);
196
+ if ('reason' in root) throw new WriteRefused(root.reason);
197
+ let parent = await directoryOf(view, rootReal, rel, makeDirectory);
198
+ let path = parent.at(nameOf(rel));
199
+ let info = await lstat(path, { bigint: true }).catch((err: unknown) => {
200
+ if (errno(err) === 'ENOENT') return null;
201
+ throw new WriteRefused(`cannot stat the file (${errno(err) ?? String(err)})`);
202
+ });
203
+ if (info?.isSymbolicLink()) {
204
+ if (!view.followSymlinks) throw new WriteRefused(NOT_FOLLOWED);
205
+ // A followed link stands for the file it leads to, as a read takes it:
206
+ // the write goes there, judged where it lands, and the link stays.
207
+ const real = await realpath(path).catch((err: unknown) => {
208
+ throw new WriteRefused(errno(err) === 'ENOENT' ? "a symlink whose target doesn't exist" : `cannot resolve the symlink (${errno(err) ?? String(err)})`);
209
+ });
210
+ const leaves = await refusedLanding(view, rootReal, rel, real);
211
+ if (leaves !== null) throw new WriteRefused(leaves);
212
+ const realRel = relativeTo(rootReal, real);
213
+ parent = await directoryOf(view, rootReal, realRel, false);
214
+ path = parent.at(nameOf(realRel));
215
+ info = await lstat(path, { bigint: true }).catch((err: unknown) => {
216
+ // Gone since realpath found it: another writer's change.
217
+ if (errno(err) === 'ENOENT') throw new FileChanged(rel);
218
+ throw new WriteRefused(`cannot stat the file the symlink leads to (${errno(err) ?? String(err)})`);
219
+ });
220
+ if (info.isSymbolicLink()) throw new FileChanged(rel);
221
+ } else {
222
+ // A file, or none yet, reached through a link among its directories
223
+ // lands where the mount may not act: in a nested mount's directory, or
224
+ // on a file its own include leaves out (refusedLanding).
225
+ const leaves = await refusedLanding(view, rootReal, rel, path);
226
+ if (leaves !== null) throw new WriteRefused(leaves);
227
+ }
228
+ if (info?.isDirectory()) throw new WriteRefused('a directory');
229
+ if (info && !info.isFile()) throw new WriteRefused('not a regular file');
230
+ // A fixed-length name, so a file whose own name is near the 255-byte limit can still be replaced.
231
+ const temp = parent.at(`${STAGED_PREFIX}${randomUUID()}`);
232
+ let made = false;
233
+ try {
234
+ // A new file is made at 0666 and the kernel takes the umask off as it
235
+ // makes it. A file being replaced gives the staged file its own mode,
236
+ // which only a chmod can set past the umask, and only after the bytes
237
+ // are written, since a write clears a set-user or set-group bit; until
238
+ // then the staged file is the owner's alone, so no other user can read
239
+ // a private file's new text while it's staged.
240
+ const handle = await open(temp, 'wx', info ? 0o600 : 0o666);
241
+ made = true;
242
+ let staged: Staged;
243
+ try {
244
+ await handle.writeFile(bytes);
245
+ if (info) await handle.chmod(Number(info.mode & 0o7777n));
246
+ const own = await handle.stat({ bigint: true });
247
+ staged = { path, temp, tempIdentity: identityOf(own), root: root.dir, parent, otherLinks: 0 };
248
+ } catch (err) {
249
+ // Removed while its handle is still open, so its device and inode
250
+ // name it and no other file: one made at its name since can't have
251
+ // taken its inode number. One that can't be told is left.
252
+ const mine = await handle.stat({ bigint: true }).catch(() => null);
253
+ if (mine) removeIf(temp, (there) => there.dev === mine.dev && there.ino === mine.ino);
254
+ await handle.close().catch(() => undefined);
255
+ throw err;
256
+ }
257
+ try {
258
+ await handle.close();
259
+ } catch (err) {
260
+ // Closed, or not, with its bytes in doubt: removed by its whole identity, as a refusal's.
261
+ await discard(staged);
262
+ throw err;
263
+ }
264
+ return staged;
265
+ } catch (err) {
266
+ // A staged file that couldn't be made names the directory: a push's
267
+ // write in place needs only the file, and this one needs both.
268
+ throw new WriteRefused(made
269
+ ? `cannot write the staged file beside it (${errno(err) ?? String(err)})`
270
+ : `cannot make the staged file in its directory (${errno(err) ?? String(err)}): a direct write needs to write the file's directory as well as the file`);
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Throw unless the root and the file's directory are still the ones the
276
+ * staging checked, with a push's words for the directory (effects.ts
277
+ * moved()).
278
+ */
279
+ export async function placedStill(staged: Staged): Promise<void> {
280
+ if (!(await staged.root.stillHere())) throw new WriteRefused(ROOT_MOVED);
281
+ if (!(await staged.parent.stillHere())) throw new WriteRefused('the directory changed since it was checked');
282
+ }
283
+
284
+ /**
285
+ * Throw unless the file is still what its read found: FileChanged when it
286
+ * changed or is gone, nothing written. `expected` is null for a write, which
287
+ * read nothing and replaces whatever is there. Either way a link swapped in
288
+ * at the file the rename replaces (on a mount that follows links, the file
289
+ * the link at the agent's path led to when the call staged it) is refused
290
+ * rather than replaced, and so is a file this process may not write. It reads the entry the
291
+ * rename replaces, and then the file's bytes, synchronously, as renameInto
292
+ * runs, so a check and the renames after it run with no turn of the event
293
+ * loop between.
294
+ *
295
+ * The identity, taken once the bytes are read, catches a file replaced or
296
+ * re-moded under the call, which equal bytes wouldn't show. The bytes are
297
+ * compared with the ones the edit read: a kernel that stamps files from a
298
+ * coarse clock, as Linux before 6.13 does, leaves a file's identity as it
299
+ * was after a second write of the same size within one tick of its last
300
+ * change (on Debian's Linux 6.12.113 with ext4, 1,632 to 1,752 of 2,000
301
+ * such rewrites kept every stamp, in three runs), and the bytes show that
302
+ * change on every clock. The bytes are read through an open that follows
303
+ * no link and doesn't wait on a pipe put at the path since its lstat, and
304
+ * at most one byte past what the edit read, so the check never blocks and
305
+ * reads no more than that.
306
+ */
307
+ export function checkUnchanged(staged: Staged, expected: { identity: FileIdentity; bytes: Buffer } | null): void {
308
+ let now: BigIntStats;
309
+ try {
310
+ now = lstatSync(staged.path, { bigint: true });
311
+ } catch (err) {
312
+ if (errno(err) !== 'ENOENT') throw new WriteRefused(`cannot check that it hadn't changed (${errno(err) ?? String(err)})`);
313
+ if (expected !== null) throw new FileChanged(staged.path);
314
+ return;
315
+ }
316
+ if (now.isSymbolicLink()) throw new WriteRefused('it became a symlink while this call was being applied');
317
+ if (now.isDirectory()) throw new WriteRefused('a directory');
318
+ if (!now.isFile()) throw new WriteRefused('not a regular file');
319
+ // As a push's write, which opens the file for writing, would be refused:
320
+ // the rename needs only the directory's permission, and would replace a
321
+ // file its owner made read-only.
322
+ try {
323
+ accessSync(staged.path, fsConstants.W_OK);
324
+ } catch (err) {
325
+ const code = errno(err);
326
+ // Removed since the lstat: a write makes the file anew; an edit's read is gone.
327
+ if (code === 'ENOENT') {
328
+ if (expected === null) return;
329
+ throw new FileChanged(staged.path);
330
+ }
331
+ if (code === 'EACCES' || code === 'EPERM') {
332
+ throw new WriteRefused(`a file the host's process may not write (${code}): its mode or owner keeps that process out, and calling again is refused until the file's owner, or whoever runs the host, changes that`);
333
+ }
334
+ throw new WriteRefused(`cannot check that the host's process may write it (${code ?? String(err)})`);
335
+ }
336
+ if (expected === null) return;
337
+ let bytes: Buffer;
338
+ let after: BigIntStats;
339
+ try {
340
+ // No link followed, and no wait on a pipe, if either was put at the path since the lstat.
341
+ const fd = openSync(staged.path, fsConstants.O_RDONLY | NOFOLLOW | NONBLOCK);
342
+ try {
343
+ // One byte past what the edit read, enough to see the file differs:
344
+ // a file another writer made larger, or a pipe a writer keeps
345
+ // filling, put at the path since, costs no more than that.
346
+ bytes = readBounded(fd, expected.bytes.length + 1);
347
+ } finally {
348
+ closeSync(fd);
349
+ }
350
+ // And the entry is still the file the edit read, after its bytes were read.
351
+ after = lstatSync(staged.path, { bigint: true });
352
+ } catch (err) {
353
+ // ELOOP: a link put at the path since the lstat; EAGAIN: a pipe, with a
354
+ // writer and nothing in it yet. Either is another writer's change.
355
+ if (errno(err) === 'ENOENT' || errno(err) === 'ELOOP' || errno(err) === 'EAGAIN') throw new FileChanged(staged.path);
356
+ throw new WriteRefused(`cannot check that it hadn't changed (${errno(err) ?? String(err)})`);
357
+ }
358
+ if (!sameIdentity(identityOf(after), expected.identity) || !bytes.equals(expected.bytes)) throw new FileChanged(staged.path);
359
+ }
360
+
361
+ /** Up to `limit` bytes from the start of `fd`, fewer at its end. */
362
+ function readBounded(fd: number, limit: number): Buffer {
363
+ const buffer = Buffer.alloc(limit);
364
+ let filled = 0;
365
+ while (filled < limit) {
366
+ // From where the last read ended (a pipe has no positions to read at).
367
+ const got = readSync(fd, buffer, filled, limit - filled, null);
368
+ if (got === 0) break;
369
+ filled += got;
370
+ }
371
+ return buffer.subarray(0, filled);
372
+ }
373
+
374
+ /**
375
+ * Rename the staged file over the file, synchronously (checkUnchanged says
376
+ * why), counting the other hard links it splits the path from just before.
377
+ * A failed rename leaves the file as it was; the caller removes the staged
378
+ * file (discard).
379
+ */
380
+ export function renameInto(staged: Staged): void {
381
+ let otherLinks: number;
382
+ try {
383
+ directWriteTestSeam.beforeRename?.(staged.path);
384
+ // The staged file is still the one this call wrote, not another put at its name.
385
+ const temp = lstatSync(staged.temp, { bigint: true });
386
+ if (!temp.isFile() || !sameIdentity(identityOf(temp), staged.tempIdentity)) {
387
+ throw new WriteRefused('another writer replaced the staged copy of the new text before it could be renamed over the file, which is as it was');
388
+ }
389
+ otherLinks = otherLinksAt(staged.path);
390
+ renameSync(staged.temp, staged.path);
391
+ } catch (err) {
392
+ if (err instanceof WriteRefused) throw err;
393
+ throw new WriteRefused(`cannot rename the staged file over it (${errno(err) ?? (err instanceof Error ? err.message : String(err))})`);
394
+ }
395
+ // Set once the rename has happened, so a count never stands for a split that didn't.
396
+ staged.otherLinks = otherLinks;
397
+ }
398
+
399
+ /** The other hard links of what the entry at `path` holds: 0 for none there, and when lstat fails, since the count only informs the answer. */
400
+ function otherLinksAt(path: string): number {
401
+ try {
402
+ return lstatSync(path).nlink - 1;
403
+ } catch {
404
+ return 0;
405
+ }
406
+ }
407
+
408
+ /**
409
+ * Remove a staged file that won't be renamed, best effort: only the file
410
+ * this call staged, by its whole identity, since whatever another writer
411
+ * put at its name since is theirs, as renameInto leaves it too. Checked and
412
+ * removed synchronously, as the rename is (checkUnchanged says why).
413
+ */
414
+ export async function discard(staged: Staged): Promise<void> {
415
+ removeIf(staged.temp, (there) => there.isFile() && sameIdentity(identityOf(there), staged.tempIdentity));
416
+ }
417
+
418
+ /** Unlink what's at `path` if `ours` says it's the file meant; best effort, with no turn of the event loop between the check and the unlink. */
419
+ function removeIf(path: string, ours: (there: BigIntStats) => boolean): void {
420
+ try {
421
+ if (ours(lstatSync(path, { bigint: true }))) unlinkSync(path);
422
+ } catch {
423
+ // Gone already, or not ours to know: left, as the header says.
424
+ }
425
+ }
@@ -11,8 +11,10 @@
11
11
  * and EditMany tools' answers.
12
12
  */
13
13
 
14
+ import type { ToolResult } from '../../types/events.js';
14
15
  import { type Change, type FilePlan, type Leaves, type MatchProblem, SHOWN_MATCHES } from './edit-plan.js';
15
- import { MAX_LINE_CHARS, jsonLength, numberedLine, safeCut } from './text.js';
16
+ import { answerText, fieldLine, sectionLead, sectionText, shownValue, textAnswer } from './result-text.js';
17
+ import { MAX_LINE_CHARS, numberedLine, safeCut } from './text.js';
16
18
 
17
19
  /** The tool a call came through, which words its refusals and receipt. */
18
20
  export type EditTool = 'edit' | 'edit_many';
@@ -190,8 +192,13 @@ function label(problem: EditProblem): string {
190
192
  */
191
193
  const FIRST_LINE_MAX = 400;
192
194
 
193
- /** What an answer is: a refusal, whose problems the agent answers and sends again, or the answer to edits stored, which mustn't be sent again. */
194
- export type AnswerKind = 'refusal' | 'stored';
195
+ /**
196
+ * What an answer is: a refusal, whose problems the agent answers and sends
197
+ * again; the answer to edits stored, which mustn't be sent again; or, on a
198
+ * direct mount, the answer to an edit_many call written partway, whose parts
199
+ * are each file's outcome.
200
+ */
201
+ export type AnswerKind = 'refusal' | 'stored' | 'written';
195
202
 
196
203
  /** A line cut to FIRST_LINE_MAX, as a part's first line is, and as a thrown message is in an answer's head. */
197
204
  export function cutLine(line: string): string {
@@ -205,18 +212,25 @@ export function cutLine(line: string): string {
205
212
  * shown and send the call again to see the rest: once those are answered,
206
213
  * their room can show the rest, details included. For edits stored, the
207
214
  * problems are the files disk refused, and a last line that leaves some out
208
- * points to ls.
215
+ * points to ls. For edits written partway, they're each file's outcome, and
216
+ * one that leaves some out points to read.
209
217
  */
210
218
  function tailText(kind: AnswerKind, maxChars: number, cut: number, problems: number, notes: number, anyShown: boolean): string {
211
219
  const items = [
212
220
  ...(cut ? [`the details of ${cut} of these`] : []),
213
- ...(problems ? [kind === 'refusal' ? `${problems} more ${plural(problems, 'problem')}` : `${problems} more ${plural(problems, 'file')} disk refused`] : []),
221
+ ...(problems
222
+ ? [kind === 'refusal'
223
+ ? `${problems} more ${plural(problems, 'problem')}`
224
+ : kind === 'stored' ? `${problems} more ${plural(problems, 'file')} disk refused` : `the outcomes of ${problems} more ${plural(problems, 'file')}`]
225
+ : []),
214
226
  ...(notes ? [`${notes} ${plural(notes, 'note')} on the files' copies on disk`] : []),
215
227
  ];
216
228
  if (!items.length) return '';
217
229
  const next = kind === 'stored'
218
230
  ? problems ? " ls shows each path's state." : ''
219
- : problems && anyShown ? ' Answer the problems shown and send the call again to see the rest.' : '';
231
+ : kind === 'written'
232
+ ? problems ? ' Reading a file shows it as disk has it now.' : ''
233
+ : problems && anyShown ? ' Answer the problems shown and send the call again to see the rest.' : '';
220
234
  const list = items.length < 2 ? items[0] : `${items.slice(0, -1).join(', ')} and ${items.at(-1)}`;
221
235
  return `[To keep this answer within ${maxChars.toLocaleString('en-US')} characters, it leaves out ${list}.${next}]`;
222
236
  }
@@ -271,9 +285,10 @@ export function fitted(head: string, problems: string[], notes: string[], maxCha
271
285
  * counts its problems and names each, in edit order, labelled with its edits.
272
286
  * `notes` follow the problems: which copy of a file the edits looked in, and
273
287
  * a conflict with disk, which the agent needs whether or not the call stored
274
- * anything.
288
+ * anything. On a direct mount, whose edits go to disk with no store between
289
+ * (`verb` 'wrote'), edit_many says it wrote none.
275
290
  */
276
- export function refusalText(tool: EditTool, problems: EditProblem[], notes: string[], maxChars: number): string {
291
+ export function refusalText(tool: EditTool, problems: EditProblem[], notes: string[], maxChars: number, verb: 'stored' | 'wrote' = 'stored'): string {
277
292
  const sorted = [...problems].sort((a, b) => firstEdit(a) - firstEdit(b));
278
293
  if (tool === 'edit') {
279
294
  const [problem] = sorted;
@@ -281,7 +296,7 @@ export function refusalText(tool: EditTool, problems: EditProblem[], notes: stri
281
296
  const whole = problem!.kind === 'file' && problem!.whole ? text : `The edit did nothing: ${text}`;
282
297
  return fitted('', [whole], notes, maxChars, 'refusal');
283
298
  }
284
- const head = `The edit_many call stored none of its edits: ${sorted.length} ${plural(sorted.length, 'problem')}.`;
299
+ const head = `The edit_many call ${verb} none of its edits: ${sorted.length} ${plural(sorted.length, 'problem')}.`;
285
300
  return fitted(head, sorted.map((problem) => `${label(problem)}${problemText(problem, tool)}`), notes, maxChars, 'refusal');
286
301
  }
287
302
 
@@ -350,7 +365,7 @@ function excerptLine(line: string, n: number, columns: number[]): string {
350
365
 
351
366
  /**
352
367
  * Each edit's first change in a file, as numbered regions merged where they
353
- * meet, while the budget lasts, counted as the answer's JSON holds them; past
368
+ * meet, while the budget lasts, counted as the answer's text holds them; past
354
369
  * it, the regions are named by their lines.
355
370
  */
356
371
  export function shownRegions(plan: FilePlan, budget: { left: number }, maxChars: number): string[] {
@@ -396,7 +411,7 @@ export function shownRegions(plan: FilePlan, budget: { left: number }, maxChars:
396
411
  // region of millions of lines, which is named by its lines, isn't built
397
412
  // whole first. The line before it ("…" between regions, or the line end)
398
413
  // counts too, and each line feed between its lines.
399
- let cost = out.length ? jsonLength('\n…\n') : jsonLength('\n');
414
+ let cost = out.length ? '\n…\n'.length : '\n'.length;
400
415
  const lines: string[] = [];
401
416
  let fits = !omitted.length;
402
417
  for (let lineStart = region.start, n = region.from; fits && lineStart < region.end; n++) {
@@ -407,7 +422,7 @@ export function shownRegions(plan: FilePlan, budget: { left: number }, maxChars:
407
422
  // changes start on it, a stretch from shortly before each.
408
423
  const columns = text.length > MAX_LINE_CHARS ? firsts.filter((first) => first.line === n).map((first) => first.at - lineStart) : [];
409
424
  const numberedText = columns.length ? excerptLine(text, n, columns) : numberedLine(text, n);
410
- cost += jsonLength(numberedText) + (lines.length ? jsonLength('\n') : 0);
425
+ cost += numberedText.length + (lines.length ? 1 : 0);
411
426
  if (cost > budget.left) fits = false;
412
427
  else lines.push(numberedText);
413
428
  lineStart = lineEnd + 1;
@@ -507,24 +522,56 @@ function receiptReserve(tool: EditTool, files: StoredFile[], maxChars: number):
507
522
  const listed = Math.min(SHOWN_MATCHES, plan.edits.length);
508
523
  const range = `${last}–${last}`;
509
524
  const note = regionsLeftOut(maxChars, Array.from({ length: listed }, () => range), plan.edits.length - listed, false);
510
- regions = Math.max(regions, jsonLength('\n') + jsonLength(note));
525
+ regions = Math.max(regions, 1 + note.length);
511
526
  }
512
527
  if (tool === 'edit') return regions;
513
528
  const most = files.length;
514
- return regions + jsonLength('\n\n') + jsonLength(filesLeftOut(maxChars, most, { notListed: most, unshown: most, notes: most, edits: most })!);
529
+ return regions + 2 + filesLeftOut(maxChars, most, { notListed: most, unshown: most, notes: most, edits: most })!.length;
530
+ }
531
+
532
+ /** A file edit_many lists, as its receipt's fields hold it. */
533
+ interface ListedFile {
534
+ path: string;
535
+ size: number;
536
+ replacements: number;
537
+ modeKept?: string;
538
+ edits?: number[];
539
+ note?: string;
540
+ }
541
+
542
+ /** edit_many's line for a file it lists: its path, size and replacements, and then the edits, the mode kept and the notes it has room for. */
543
+ function listedFileLine(file: ListedFile): string {
544
+ return `${shownValue(file.path)}: size ${file.size}, replacements ${file.replacements}`
545
+ + (file.edits !== undefined ? `, edits ${file.edits.join(', ')}` : '')
546
+ + (file.modeKept !== undefined ? `; modeKept: ${shownValue(file.modeKept)}` : '')
547
+ + (file.note !== undefined ? `. ${shownValue(file.note)}` : '');
548
+ }
549
+
550
+ /** edit_many's receipt's text before its changed regions: its counts, and the files it lists. */
551
+ function manyHeadText(data: { edits: number; replacements: number; files: ListedFile[]; filesNotListed?: number }): string {
552
+ const header = [fieldLine('edits', data.edits), fieldLine('replacements', data.replacements)];
553
+ if (data.filesNotListed !== undefined) header.push(fieldLine('filesNotListed', data.filesNotListed));
554
+ return answerText(header, [sectionText('files', data.files.map(listedFileLine).join('\n'))]);
515
555
  }
516
556
 
517
557
  /**
518
558
  * The receipt of edits stored: each file's size, replacements and notes, and
519
559
  * each edit's first changed region, numbered as read numbers lines, held to
520
- * `maxChars` as the answer's JSON holds it. Its counts and notes come first;
521
- * the regions take what is left, and past it a file's regions are named by
522
- * their lines. edit_many lists its files while they fit, a file whose notes
523
- * don't fit without them, or without its edits' numbers too, and counts the
524
- * rest. edit's receipt, of one file, keeps its counts and notes and shows no
525
- * region where those alone pass `maxChars`.
560
+ * `maxChars` as the answer's text holds it (result-text.ts). Its counts and
561
+ * notes come first; the regions take what is left, and past it a file's
562
+ * regions are named by their lines. edit_many lists its files while they
563
+ * fit, a file whose notes don't fit without them, or without its edits'
564
+ * numbers too, and counts the rest. edit's receipt, of one file, keeps its
565
+ * counts and notes and shows no region where those leave no room for it. The
566
+ * line that names what a receipt leaves out comes after what it keeps, and
567
+ * where its counts and notes leave too little room for that line too, as
568
+ * they can with long notes or a limit far under the 1,000 a residence or an
569
+ * agent can set, the receipt passes `maxChars` by it, and then reaches its
570
+ * caller as any result past its limit does: a model's spills, and a script's
571
+ * call answers ResultTooLarge. Otherwise the model reads the text, and a
572
+ * script gets the receipt's fields beside it.
526
573
  */
527
- export function editReceipt(tool: EditTool, files: StoredFile[], editCount: number, maxChars: number): Record<string, unknown> {
574
+ export function editReceipt(tool: EditTool, files: StoredFile[], editCount: number, maxChars: number): ToolResult {
528
575
  const reserve = receiptReserve(tool, files, maxChars);
529
576
  const notesOf = ({ plan, notes }: StoredFile): string[] => {
530
577
  const lineEnds = lineEndNote(plan);
@@ -544,28 +591,28 @@ export function editReceipt(tool: EditTool, files: StoredFile[], editCount: numb
544
591
  if (tool === 'edit') {
545
592
  const file = files[0]!;
546
593
  const notes = notesOf(file);
547
- const data: Record<string, unknown> = {
594
+ const head = {
548
595
  path: file.path,
549
596
  size: file.plan.bytes.length,
550
597
  replacements: file.plan.replacements,
551
598
  ...(notes.length ? { note: notes.join(' ') } : {}),
552
599
  ...(file.modeKept ? { modeKept: file.modeKept } : {}),
553
- changed: '',
554
600
  };
555
- const budget = { left: maxChars - JSON.stringify(data).length - reserve };
556
- data.changed = shownRegions(file.plan, budget, maxChars).join('\n');
557
- return data;
601
+ const header = Object.entries(head).map(([key, value]) => fieldLine(key, value));
602
+ const budget = { left: maxChars - answerText(header).length - sectionLead('changed') - reserve };
603
+ const changed = shownRegions(file.plan, budget, maxChars).join('\n');
604
+ return textAnswer(answerText(header, [sectionText('changed', changed)]), { ...head, changed });
558
605
  }
559
- const listed: Array<Record<string, unknown>> = [];
560
- const data: Record<string, unknown> = {
606
+ const listed: ListedFile[] = [];
607
+ const data: { edits: number; replacements: number; files: ListedFile[]; filesNotListed?: number; changed: string } = {
561
608
  edits: editCount,
562
609
  replacements: files.reduce((sum, file) => sum + file.plan.replacements, 0),
563
610
  files: listed,
564
611
  changed: '',
565
612
  };
566
- const fits = (entry: Record<string, unknown>) => {
613
+ const fits = (entry: ListedFile) => {
567
614
  listed.push(entry);
568
- const fit = JSON.stringify(data).length <= maxChars - reserve;
615
+ const fit = manyHeadText(data).length + sectionLead('changed') <= maxChars - reserve;
569
616
  listed.pop();
570
617
  return fit;
571
618
  };
@@ -574,7 +621,7 @@ export function editReceipt(tool: EditTool, files: StoredFile[], editCount: numb
574
621
  let notListed = 0;
575
622
  for (const [i, file] of files.entries()) {
576
623
  const notes = notesOf(file);
577
- const bare = {
624
+ const bare: ListedFile = {
578
625
  path: file.path,
579
626
  size: file.plan.bytes.length,
580
627
  replacements: file.plan.replacements,
@@ -597,22 +644,22 @@ export function editReceipt(tool: EditTool, files: StoredFile[], editCount: numb
597
644
  if (notes.length && !('note' in entry)) notesLeftOut++;
598
645
  if (!('edits' in entry)) editsLeftOut++;
599
646
  }
600
- const budget = { left: maxChars - JSON.stringify(data).length - reserve };
647
+ const budget = { left: maxChars - manyHeadText(data).length - sectionLead('changed') - reserve };
601
648
  const sections: string[] = [];
602
649
  let unshown = 0;
603
650
  for (const file of files.slice(0, listed.length)) {
604
- const heading = jsonLength(`${file.path}\n`) + (sections.length ? jsonLength('\n\n') : 0);
651
+ const heading = shownValue(file.path).length + 1 + (sections.length ? 2 : 0);
605
652
  if (heading >= budget.left) {
606
653
  unshown++;
607
654
  continue;
608
655
  }
609
656
  budget.left -= heading;
610
- sections.push(`${file.path}\n${shownRegions(file.plan, budget, maxChars).join('\n')}`);
657
+ sections.push(`${shownValue(file.path)}\n${shownRegions(file.plan, budget, maxChars).join('\n')}`);
611
658
  }
612
659
  const last = filesLeftOut(maxChars, files.length, { notListed, unshown, notes: notesLeftOut, edits: editsLeftOut });
613
660
  if (last !== null) sections.push(last);
614
661
  data.changed = sections.join('\n\n');
615
- return data;
662
+ return textAnswer(answerText([manyHeadText(data)], [sectionText('changed', data.changed)]), data);
616
663
  }
617
664
 
618
665
  const capitalized = (value: string) => value.charAt(0).toUpperCase() + value.slice(1);