@plurnk/plurnk-service 1.0.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 (343) hide show
  1. package/.env.defaults +208 -0
  2. package/INSTALL.md +69 -0
  3. package/LICENSE +21 -0
  4. package/README.md +46 -0
  5. package/SPEC.md +1511 -0
  6. package/dist/Paths.d.ts +13 -0
  7. package/dist/Paths.d.ts.map +1 -0
  8. package/dist/Paths.js +60 -0
  9. package/dist/Paths.js.map +1 -0
  10. package/dist/content/edited-span.d.ts +2 -0
  11. package/dist/content/edited-span.d.ts.map +1 -0
  12. package/dist/content/edited-span.js +33 -0
  13. package/dist/content/edited-span.js.map +1 -0
  14. package/dist/content/index.d.ts +10 -0
  15. package/dist/content/index.d.ts.map +1 -0
  16. package/dist/content/index.js +11 -0
  17. package/dist/content/index.js.map +1 -0
  18. package/dist/content/line-marker.d.ts +15 -0
  19. package/dist/content/line-marker.d.ts.map +1 -0
  20. package/dist/content/line-marker.js +21 -0
  21. package/dist/content/line-marker.js.map +1 -0
  22. package/dist/content/matcher.d.ts +44 -0
  23. package/dist/content/matcher.d.ts.map +1 -0
  24. package/dist/content/matcher.js +126 -0
  25. package/dist/content/matcher.js.map +1 -0
  26. package/dist/content/mimetype-binary.d.ts +8 -0
  27. package/dist/content/mimetype-binary.d.ts.map +1 -0
  28. package/dist/content/mimetype-binary.js +17 -0
  29. package/dist/content/mimetype-binary.js.map +1 -0
  30. package/dist/content/path-mimetype.d.ts +5 -0
  31. package/dist/content/path-mimetype.d.ts.map +1 -0
  32. package/dist/content/path-mimetype.js +15 -0
  33. package/dist/content/path-mimetype.js.map +1 -0
  34. package/dist/content/read-resolve.d.ts +20 -0
  35. package/dist/content/read-resolve.d.ts.map +1 -0
  36. package/dist/content/read-resolve.js +68 -0
  37. package/dist/content/read-resolve.js.map +1 -0
  38. package/dist/core/ChannelWrite.d.ts +100 -0
  39. package/dist/core/ChannelWrite.d.ts.map +1 -0
  40. package/dist/core/ChannelWrite.js +112 -0
  41. package/dist/core/ChannelWrite.js.map +1 -0
  42. package/dist/core/ChannelWrite.sql +84 -0
  43. package/dist/core/Db.d.ts +14 -0
  44. package/dist/core/Db.d.ts.map +1 -0
  45. package/dist/core/Db.js +7 -0
  46. package/dist/core/Db.js.map +1 -0
  47. package/dist/core/Dispatcher.d.ts +67 -0
  48. package/dist/core/Dispatcher.d.ts.map +1 -0
  49. package/dist/core/Dispatcher.js +1021 -0
  50. package/dist/core/Dispatcher.js.map +1 -0
  51. package/dist/core/Engine.d.ts +113 -0
  52. package/dist/core/Engine.d.ts.map +1 -0
  53. package/dist/core/Engine.js +1410 -0
  54. package/dist/core/Engine.js.map +1 -0
  55. package/dist/core/Engine.sql +538 -0
  56. package/dist/core/EnvFlags.d.ts +13 -0
  57. package/dist/core/EnvFlags.d.ts.map +1 -0
  58. package/dist/core/EnvFlags.js +84 -0
  59. package/dist/core/EnvFlags.js.map +1 -0
  60. package/dist/core/ExecutorRegistry.d.ts +45 -0
  61. package/dist/core/ExecutorRegistry.d.ts.map +1 -0
  62. package/dist/core/ExecutorRegistry.js +128 -0
  63. package/dist/core/ExecutorRegistry.js.map +1 -0
  64. package/dist/core/PacketBuilder.d.ts +67 -0
  65. package/dist/core/PacketBuilder.d.ts.map +1 -0
  66. package/dist/core/PacketBuilder.js +524 -0
  67. package/dist/core/PacketBuilder.js.map +1 -0
  68. package/dist/core/PluginLoader.d.ts +17 -0
  69. package/dist/core/PluginLoader.d.ts.map +1 -0
  70. package/dist/core/PluginLoader.js +97 -0
  71. package/dist/core/PluginLoader.js.map +1 -0
  72. package/dist/core/ProposalLifecycle.d.ts +59 -0
  73. package/dist/core/ProposalLifecycle.d.ts.map +1 -0
  74. package/dist/core/ProposalLifecycle.js +215 -0
  75. package/dist/core/ProposalLifecycle.js.map +1 -0
  76. package/dist/core/ProviderInstantiate.d.ts +9 -0
  77. package/dist/core/ProviderInstantiate.d.ts.map +1 -0
  78. package/dist/core/ProviderInstantiate.js +156 -0
  79. package/dist/core/ProviderInstantiate.js.map +1 -0
  80. package/dist/core/SchemeRegistry.d.ts +26 -0
  81. package/dist/core/SchemeRegistry.d.ts.map +1 -0
  82. package/dist/core/SchemeRegistry.js +205 -0
  83. package/dist/core/SchemeRegistry.js.map +1 -0
  84. package/dist/core/StrikeRail.d.ts +28 -0
  85. package/dist/core/StrikeRail.d.ts.map +1 -0
  86. package/dist/core/StrikeRail.js +165 -0
  87. package/dist/core/StrikeRail.js.map +1 -0
  88. package/dist/core/TelemetryChannel.d.ts +37 -0
  89. package/dist/core/TelemetryChannel.d.ts.map +1 -0
  90. package/dist/core/TelemetryChannel.js +77 -0
  91. package/dist/core/TelemetryChannel.js.map +1 -0
  92. package/dist/core/TokenGauge.d.ts +16 -0
  93. package/dist/core/TokenGauge.d.ts.map +1 -0
  94. package/dist/core/TokenGauge.js +58 -0
  95. package/dist/core/TokenGauge.js.map +1 -0
  96. package/dist/core/caps/CapsResolve.d.ts +5 -0
  97. package/dist/core/caps/CapsResolve.d.ts.map +1 -0
  98. package/dist/core/caps/CapsResolve.js +13 -0
  99. package/dist/core/caps/CapsResolve.js.map +1 -0
  100. package/dist/core/caps/DbChannelCaps.d.ts +16 -0
  101. package/dist/core/caps/DbChannelCaps.d.ts.map +1 -0
  102. package/dist/core/caps/DbChannelCaps.js +41 -0
  103. package/dist/core/caps/DbChannelCaps.js.map +1 -0
  104. package/dist/core/caps/DbEntryCaps.d.ts +19 -0
  105. package/dist/core/caps/DbEntryCaps.d.ts.map +1 -0
  106. package/dist/core/caps/DbEntryCaps.js +28 -0
  107. package/dist/core/caps/DbEntryCaps.js.map +1 -0
  108. package/dist/core/caps/DbNotifyCaps.d.ts +8 -0
  109. package/dist/core/caps/DbNotifyCaps.d.ts.map +1 -0
  110. package/dist/core/caps/DbNotifyCaps.js +35 -0
  111. package/dist/core/caps/DbNotifyCaps.js.map +1 -0
  112. package/dist/core/caps/DbSubscriptionCaps.d.ts +10 -0
  113. package/dist/core/caps/DbSubscriptionCaps.d.ts.map +1 -0
  114. package/dist/core/caps/DbSubscriptionCaps.js +86 -0
  115. package/dist/core/caps/DbSubscriptionCaps.js.map +1 -0
  116. package/dist/core/caps/DbTagCaps.d.ts +17 -0
  117. package/dist/core/caps/DbTagCaps.d.ts.map +1 -0
  118. package/dist/core/caps/DbTagCaps.js +37 -0
  119. package/dist/core/caps/DbTagCaps.js.map +1 -0
  120. package/dist/core/caps/SchemeCtxImpl.d.ts +18 -0
  121. package/dist/core/caps/SchemeCtxImpl.d.ts.map +1 -0
  122. package/dist/core/caps/SchemeCtxImpl.js +42 -0
  123. package/dist/core/caps/SchemeCtxImpl.js.map +1 -0
  124. package/dist/core/env-defaults.d.ts +19 -0
  125. package/dist/core/env-defaults.d.ts.map +1 -0
  126. package/dist/core/env-defaults.js +89 -0
  127. package/dist/core/env-defaults.js.map +1 -0
  128. package/dist/core/fork.d.ts +6 -0
  129. package/dist/core/fork.d.ts.map +1 -0
  130. package/dist/core/fork.js +84 -0
  131. package/dist/core/fork.js.map +1 -0
  132. package/dist/core/fork.sql +85 -0
  133. package/dist/core/git-membership.d.ts +23 -0
  134. package/dist/core/git-membership.d.ts.map +1 -0
  135. package/dist/core/git-membership.js +366 -0
  136. package/dist/core/git-membership.js.map +1 -0
  137. package/dist/core/git-state.d.ts +15 -0
  138. package/dist/core/git-state.d.ts.map +1 -0
  139. package/dist/core/git-state.js +67 -0
  140. package/dist/core/git-state.js.map +1 -0
  141. package/dist/core/packet-inject.d.ts +5 -0
  142. package/dist/core/packet-inject.d.ts.map +1 -0
  143. package/dist/core/packet-inject.js +48 -0
  144. package/dist/core/packet-inject.js.map +1 -0
  145. package/dist/core/packet-wire.d.ts +46 -0
  146. package/dist/core/packet-wire.d.ts.map +1 -0
  147. package/dist/core/packet-wire.js +453 -0
  148. package/dist/core/packet-wire.js.map +1 -0
  149. package/dist/core/path-decode.d.ts +2 -0
  150. package/dist/core/path-decode.d.ts.map +1 -0
  151. package/dist/core/path-decode.js +8 -0
  152. package/dist/core/path-decode.js.map +1 -0
  153. package/dist/core/plugin-attribution.d.ts +5 -0
  154. package/dist/core/plugin-attribution.d.ts.map +1 -0
  155. package/dist/core/plugin-attribution.js +39 -0
  156. package/dist/core/plugin-attribution.js.map +1 -0
  157. package/dist/core/plurnk-uri.d.ts +7 -0
  158. package/dist/core/plurnk-uri.d.ts.map +1 -0
  159. package/dist/core/plurnk-uri.js +52 -0
  160. package/dist/core/plurnk-uri.js.map +1 -0
  161. package/dist/core/resolveForLoop.d.ts +5 -0
  162. package/dist/core/resolveForLoop.d.ts.map +1 -0
  163. package/dist/core/resolveForLoop.js +12 -0
  164. package/dist/core/resolveForLoop.js.map +1 -0
  165. package/dist/core/results.d.ts +12 -0
  166. package/dist/core/results.d.ts.map +1 -0
  167. package/dist/core/results.js +27 -0
  168. package/dist/core/results.js.map +1 -0
  169. package/dist/core/run-cap.d.ts +8 -0
  170. package/dist/core/run-cap.d.ts.map +1 -0
  171. package/dist/core/run-cap.js +20 -0
  172. package/dist/core/run-cap.js.map +1 -0
  173. package/dist/core/run-ops.sql +43 -0
  174. package/dist/core/scheme-types.d.ts +45 -0
  175. package/dist/core/scheme-types.d.ts.map +1 -0
  176. package/dist/core/scheme-types.js +6 -0
  177. package/dist/core/scheme-types.js.map +1 -0
  178. package/dist/core/session-settings.d.ts +23 -0
  179. package/dist/core/session-settings.d.ts.map +1 -0
  180. package/dist/core/session-settings.js +55 -0
  181. package/dist/core/session-settings.js.map +1 -0
  182. package/dist/core/teaching.d.ts +3 -0
  183. package/dist/core/teaching.d.ts.map +1 -0
  184. package/dist/core/teaching.js +13 -0
  185. package/dist/core/teaching.js.map +1 -0
  186. package/dist/core/types.d.ts +3 -0
  187. package/dist/core/types.d.ts.map +1 -0
  188. package/dist/core/types.js +2 -0
  189. package/dist/core/types.js.map +1 -0
  190. package/dist/digest/Digest.d.ts +20 -0
  191. package/dist/digest/Digest.d.ts.map +1 -0
  192. package/dist/digest/Digest.js +451 -0
  193. package/dist/digest/Digest.js.map +1 -0
  194. package/dist/digest/Digest.sql +1 -0
  195. package/dist/digest/digest.sql +55 -0
  196. package/dist/index.d.ts +18 -0
  197. package/dist/index.d.ts.map +1 -0
  198. package/dist/index.js +28 -0
  199. package/dist/index.js.map +1 -0
  200. package/dist/schemes/EffectPolicy.d.ts +6 -0
  201. package/dist/schemes/EffectPolicy.d.ts.map +1 -0
  202. package/dist/schemes/EffectPolicy.js +18 -0
  203. package/dist/schemes/EffectPolicy.js.map +1 -0
  204. package/dist/schemes/Exec.d.ts +39 -0
  205. package/dist/schemes/Exec.d.ts.map +1 -0
  206. package/dist/schemes/Exec.js +510 -0
  207. package/dist/schemes/Exec.js.map +1 -0
  208. package/dist/schemes/ExecOutputScheme.d.ts +20 -0
  209. package/dist/schemes/ExecOutputScheme.d.ts.map +1 -0
  210. package/dist/schemes/ExecOutputScheme.js +39 -0
  211. package/dist/schemes/ExecOutputScheme.js.map +1 -0
  212. package/dist/schemes/File.d.ts +42 -0
  213. package/dist/schemes/File.d.ts.map +1 -0
  214. package/dist/schemes/File.js +356 -0
  215. package/dist/schemes/File.js.map +1 -0
  216. package/dist/schemes/Known.d.ts +17 -0
  217. package/dist/schemes/Known.d.ts.map +1 -0
  218. package/dist/schemes/Known.js +40 -0
  219. package/dist/schemes/Known.js.map +1 -0
  220. package/dist/schemes/Log.d.ts +22 -0
  221. package/dist/schemes/Log.d.ts.map +1 -0
  222. package/dist/schemes/Log.js +269 -0
  223. package/dist/schemes/Log.js.map +1 -0
  224. package/dist/schemes/Log.sql +59 -0
  225. package/dist/schemes/Plurnk.d.ts +17 -0
  226. package/dist/schemes/Plurnk.d.ts.map +1 -0
  227. package/dist/schemes/Plurnk.js +46 -0
  228. package/dist/schemes/Plurnk.js.map +1 -0
  229. package/dist/schemes/Run.d.ts +25 -0
  230. package/dist/schemes/Run.d.ts.map +1 -0
  231. package/dist/schemes/Run.js +172 -0
  232. package/dist/schemes/Run.js.map +1 -0
  233. package/dist/schemes/Skill.d.ts +17 -0
  234. package/dist/schemes/Skill.d.ts.map +1 -0
  235. package/dist/schemes/Skill.js +40 -0
  236. package/dist/schemes/Skill.js.map +1 -0
  237. package/dist/schemes/Unknown.d.ts +17 -0
  238. package/dist/schemes/Unknown.d.ts.map +1 -0
  239. package/dist/schemes/Unknown.js +39 -0
  240. package/dist/schemes/Unknown.js.map +1 -0
  241. package/dist/schemes/_entry-chunk.d.ts +11 -0
  242. package/dist/schemes/_entry-chunk.d.ts.map +1 -0
  243. package/dist/schemes/_entry-chunk.js +100 -0
  244. package/dist/schemes/_entry-chunk.js.map +1 -0
  245. package/dist/schemes/_entry-crud.d.ts +31 -0
  246. package/dist/schemes/_entry-crud.d.ts.map +1 -0
  247. package/dist/schemes/_entry-crud.js +77 -0
  248. package/dist/schemes/_entry-crud.js.map +1 -0
  249. package/dist/schemes/_entry-crud.sql +101 -0
  250. package/dist/schemes/_entry-find.d.ts +32 -0
  251. package/dist/schemes/_entry-find.d.ts.map +1 -0
  252. package/dist/schemes/_entry-find.js +226 -0
  253. package/dist/schemes/_entry-find.js.map +1 -0
  254. package/dist/schemes/_entry-find.sql +54 -0
  255. package/dist/schemes/_entry-graph.d.ts +16 -0
  256. package/dist/schemes/_entry-graph.d.ts.map +1 -0
  257. package/dist/schemes/_entry-graph.js +94 -0
  258. package/dist/schemes/_entry-graph.js.map +1 -0
  259. package/dist/schemes/_entry-graph.sql +52 -0
  260. package/dist/schemes/_entry-manifest.d.ts +25 -0
  261. package/dist/schemes/_entry-manifest.d.ts.map +1 -0
  262. package/dist/schemes/_entry-manifest.js +266 -0
  263. package/dist/schemes/_entry-manifest.js.map +1 -0
  264. package/dist/schemes/_entry-ops.d.ts +32 -0
  265. package/dist/schemes/_entry-ops.d.ts.map +1 -0
  266. package/dist/schemes/_entry-ops.js +216 -0
  267. package/dist/schemes/_entry-ops.js.map +1 -0
  268. package/dist/schemes/_entry-ops.sql +31 -0
  269. package/dist/schemes/_entry-semantic.d.ts +38 -0
  270. package/dist/schemes/_entry-semantic.d.ts.map +1 -0
  271. package/dist/schemes/_entry-semantic.js +202 -0
  272. package/dist/schemes/_entry-semantic.js.map +1 -0
  273. package/dist/schemes/_entry-semantic.sql +95 -0
  274. package/dist/schemes/_entry-send.d.ts +11 -0
  275. package/dist/schemes/_entry-send.d.ts.map +1 -0
  276. package/dist/schemes/_entry-send.js +66 -0
  277. package/dist/schemes/_entry-send.js.map +1 -0
  278. package/dist/schemes/cosine.d.ts +4 -0
  279. package/dist/schemes/cosine.d.ts.map +1 -0
  280. package/dist/schemes/cosine.js +9 -0
  281. package/dist/schemes/cosine.js.map +1 -0
  282. package/dist/schemes/exec-abort.d.ts +15 -0
  283. package/dist/schemes/exec-abort.d.ts.map +1 -0
  284. package/dist/schemes/exec-abort.js +29 -0
  285. package/dist/schemes/exec-abort.js.map +1 -0
  286. package/dist/schemes/exec-env.d.ts +5 -0
  287. package/dist/schemes/exec-env.d.ts.map +1 -0
  288. package/dist/schemes/exec-env.js +34 -0
  289. package/dist/schemes/exec-env.js.map +1 -0
  290. package/dist/server/Daemon.d.ts +244 -0
  291. package/dist/server/Daemon.d.ts.map +1 -0
  292. package/dist/server/Daemon.js +1119 -0
  293. package/dist/server/Daemon.js.map +1 -0
  294. package/dist/server/client-input.d.ts +11 -0
  295. package/dist/server/client-input.d.ts.map +1 -0
  296. package/dist/server/client-input.js +145 -0
  297. package/dist/server/client-input.js.map +1 -0
  298. package/dist/server/clientTurn.d.ts +6 -0
  299. package/dist/server/clientTurn.d.ts.map +1 -0
  300. package/dist/server/clientTurn.js +23 -0
  301. package/dist/server/clientTurn.js.map +1 -0
  302. package/dist/server/clientTurn.sql +10 -0
  303. package/dist/server/dispatch-as-plurnk.d.ts +7 -0
  304. package/dist/server/dispatch-as-plurnk.d.ts.map +1 -0
  305. package/dist/server/dispatch-as-plurnk.js +23 -0
  306. package/dist/server/dispatch-as-plurnk.js.map +1 -0
  307. package/dist/server/drain.sql +124 -0
  308. package/dist/server/envelope.d.ts +53 -0
  309. package/dist/server/envelope.d.ts.map +1 -0
  310. package/dist/server/envelope.js +172 -0
  311. package/dist/server/envelope.js.map +1 -0
  312. package/dist/server/envelope.sql +76 -0
  313. package/dist/server/logEntry.d.ts +35 -0
  314. package/dist/server/logEntry.d.ts.map +1 -0
  315. package/dist/server/logEntry.js +49 -0
  316. package/dist/server/logEntry.js.map +1 -0
  317. package/dist/server/logEntry.sql +10 -0
  318. package/dist/server/loopDocs.d.ts +6 -0
  319. package/dist/server/loopDocs.d.ts.map +1 -0
  320. package/dist/server/loopDocs.js +22 -0
  321. package/dist/server/loopDocs.js.map +1 -0
  322. package/dist/server/noProposals.d.ts +6 -0
  323. package/dist/server/noProposals.d.ts.map +1 -0
  324. package/dist/server/noProposals.js +37 -0
  325. package/dist/server/noProposals.js.map +1 -0
  326. package/dist/server/seam-entry-read.sql +21 -0
  327. package/dist/server/seam-log-read.sql +21 -0
  328. package/dist/server/seam-loop.sql +9 -0
  329. package/dist/server/seam-proposal-list.sql +13 -0
  330. package/dist/server/version-info.d.ts +14 -0
  331. package/dist/server/version-info.d.ts.map +1 -0
  332. package/dist/server/version-info.js +69 -0
  333. package/dist/server/version-info.js.map +1 -0
  334. package/dist/server/yolo.d.ts +6 -0
  335. package/dist/server/yolo.d.ts.map +1 -0
  336. package/dist/server/yolo.js +64 -0
  337. package/dist/server/yolo.js.map +1 -0
  338. package/dist/service.d.ts +6 -0
  339. package/dist/service.d.ts.map +1 -0
  340. package/dist/service.js +309 -0
  341. package/dist/service.js.map +1 -0
  342. package/migrations/0000-00-00.01_schema.sql +500 -0
  343. package/package.json +86 -0
@@ -0,0 +1,1410 @@
1
+ import { PlurnkParser, PlurnkParseError } from "@plurnk/plurnk-grammar";
2
+ import { Mimetypes, emptyRegistry } from "@plurnk/plurnk-mimetypes";
3
+ import EntryCrud from "../schemes/_entry-crud.js";
4
+ import EntryManifest from "../schemes/_entry-manifest.js";
5
+ import { markTerminal } from "../schemes/Run.js";
6
+ import TokenGauge from "./TokenGauge.js";
7
+ import GitMembership from "./git-membership.js";
8
+ import GitState from "./git-state.js";
9
+ import SessionSettings from "./session-settings.js";
10
+ import { editedSpan } from "../content/index.js";
11
+ import { promptPathname } from "./plurnk-uri.js";
12
+ import { readFile } from "node:fs/promises";
13
+ import { fileURLToPath } from "node:url";
14
+ import { setTimeout as delay } from "node:timers/promises";
15
+ // Shared module imported by both Engine and bin/digest.ts, so wire
16
+ // projection and digest projection are structurally one function — no
17
+ // drift between wire and digest possible.
18
+ // Format: markdown (user pick over rummy's XML alternative, 2026-05-22).
19
+ import PacketWire from "./packet-wire.js";
20
+ // The engine's collaborators — each owns one machine; Engine owns the loop/turn
21
+ // lifecycle and wires them together as the public facade.
22
+ import TelemetryChannel from "./TelemetryChannel.js";
23
+ import StrikeRail from "./StrikeRail.js";
24
+ import PacketBuilder from "./PacketBuilder.js";
25
+ import ProposalLifecycle from "./ProposalLifecycle.js";
26
+ import Dispatcher from "./Dispatcher.js";
27
+ const DEFAULT_MAX_STRIKES = 3;
28
+ const DEFAULT_MAX_COMMANDS = 99;
29
+ // The foisted prompt EDIT/READ target — run-qualified storage (§prompt-auto-read, #382 fault-1)
30
+ // rendered in the plurnk:// authority form (hostname carries the namespace, plurnk-uri folds it
31
+ // back into the storage key on dispatch).
32
+ const promptTarget = (runId, loopSeq, turnSeq) => {
33
+ const storage = promptPathname(runId, loopSeq, turnSeq);
34
+ return {
35
+ kind: "url", raw: `plurnk://${storage.slice(1)}`,
36
+ scheme: "plurnk", username: null, password: null,
37
+ hostname: "prompt", port: null,
38
+ pathname: storage.slice("/prompt".length), params: {}, fragment: null,
39
+ };
40
+ };
41
+ const readMaxStrikes = () => {
42
+ const raw = process.env.PLURNK_SERVICE_MAX_STRIKES;
43
+ if (raw === undefined || raw.length === 0)
44
+ return DEFAULT_MAX_STRIKES;
45
+ const n = Number.parseInt(raw, 10);
46
+ if (!Number.isFinite(n) || n < 0)
47
+ return DEFAULT_MAX_STRIKES;
48
+ return n;
49
+ };
50
+ const readMaxCommands = () => {
51
+ const raw = process.env.PLURNK_SERVICE_MAX_COMMANDS;
52
+ if (raw === undefined || raw.length === 0)
53
+ return DEFAULT_MAX_COMMANDS;
54
+ const n = Number.parseInt(raw, 10);
55
+ if (!Number.isFinite(n) || n < 1)
56
+ return DEFAULT_MAX_COMMANDS;
57
+ return n;
58
+ };
59
+ // PLURNK_SERVICE_FILES_ITEMS — the turn-0 manifest preview. null = off (no foist);
60
+ // -1 = the full manifest; positive N = the first N items. 0 / unset = off.
61
+ const normalizeFilesItems = (n) => (!Number.isFinite(n) || n === 0 ? null : n < 0 ? -1 : n);
62
+ const readFilesItems = () => {
63
+ const raw = process.env.PLURNK_SERVICE_FILES_ITEMS;
64
+ if (raw === undefined || raw.length === 0)
65
+ return null;
66
+ return normalizeFilesItems(Number.parseInt(raw, 10));
67
+ };
68
+ import { ProviderError, scopeEnvToAlias, resolveActiveAlias } from "@plurnk/plurnk-providers";
69
+ import ProviderInstantiate from "./ProviderInstantiate.js";
70
+ // Default turn.status when ops were emitted but no SEND. Model is implicitly
71
+ // continuing; loop.status stays 102 either way (only SEND broadcast advances
72
+ // loop terminal). No strike, no telemetry.
73
+ const TURN_STATUS_IMPLICIT_CONTINUE = 102;
74
+ // Status assigned to a turn that emitted NO ops at all. Strike-worthy; the
75
+ // action routes through telemetry.errors[] (§telemetry, §telemetry-no-error-scheme — never an error:// scheme).
76
+ const TURN_STATUS_NO_OPS = 422;
77
+ const DEFAULT_MIN_CYCLES = 3;
78
+ const DEFAULT_MAX_CYCLE_PERIOD = 4;
79
+ const readPositiveInt = (envVar, fallback) => {
80
+ const raw = process.env[envVar];
81
+ if (raw === undefined || raw.length === 0)
82
+ return fallback;
83
+ const n = Number.parseInt(raw, 10);
84
+ if (!Number.isFinite(n) || n < 1)
85
+ return fallback;
86
+ return n;
87
+ };
88
+ // §operator-config-loop-timeout — the loop's wall-clock budget (PLURNK_SERVICE_LOOP_TIMEOUT).
89
+ const DEFAULT_LOOP_TIMEOUT_MS = 86400000;
90
+ const readLoopTimeoutMs = () => readPositiveInt("PLURNK_SERVICE_LOOP_TIMEOUT", DEFAULT_LOOP_TIMEOUT_MS);
91
+ // The wall's abort reason — runLoop branches a mid-turn teardown to the 504 terminal on it.
92
+ const LOOP_TIMEOUT_REASON = "loop_timeout";
93
+ export default class Engine {
94
+ static fingerprintTurn(ops) {
95
+ return StrikeRail.fingerprintTurn(ops);
96
+ }
97
+ static detectCycle(history, minCycles, maxCyclePeriod) {
98
+ return StrikeRail.detectCycle(history, minCycles, maxCyclePeriod);
99
+ }
100
+ #db;
101
+ #schemes;
102
+ #mimetypes;
103
+ // Write-time tokenizer (SPEC §tokenomics). Synchronous per the provider
104
+ // contract (§provider-surface). Populated from the active provider's countTokens via
105
+ // the Daemon; a divisor tripwire stands in only for bare/standalone
106
+ // construction before a provider is wired (same boot affordance as
107
+ // Mimetypes, §mimetype-surface). Real counts come from provider.countTokens.
108
+ #tokenize;
109
+ // Boot-discovered runtime executors. Daemon builds + sets via
110
+ // setExecutors at start(); undefined until then (and in bare tests).
111
+ #executors;
112
+ // §send-premature-terminate/[102]<T> — park deadlines by loopId, written at dispatch (the
113
+ // marker's seconds; -1 = indefinite), consumed by the daemon's drain park-exit to schedule
114
+ // the deadline wake. In-memory: a daemon restart drops pending deadlines (documented).
115
+ parkDeadlines = new Map();
116
+ // §join-blocking-collect (#354) — loops whose turn issued a READ(run://running-child): the
117
+ // blocking join. The dispatcher arms this on the READ; the turn's bare SEND[102] parks on it
118
+ // (indefinite) instead of continuing, and any SEND clears it. Twin of parkDeadlines.
119
+ joinTargets = new Set();
120
+ // The collaborators. Engine constructs them (they share its deps via
121
+ // thunks where the value is late-injected — executors, loop signals)
122
+ // and fronts their public surface.
123
+ #telemetry;
124
+ #strikes;
125
+ // §grinder-hard-413-recovery — loops granted their ONE over-ceiling recovery turn. Cleared on a
126
+ // fitting turn (the model curated; a LATER overflow earns a fresh recovery) and at loop cleanup.
127
+ #hardOverflowRecovery = new Set();
128
+ #packets;
129
+ #proposals;
130
+ #dispatcher;
131
+ // Per-loop AbortController for cancellation propagation into scheme
132
+ // ctx.signal. runLoop creates one at entry, cleans up at end. Engine
133
+ // cancellation paths (strikes, max_turns, external) abort it.
134
+ // Streaming schemes (exec) chain their per-spawn controllers off
135
+ // ctx.signal so cancelled loops tear down their background spawns.
136
+ #loopAborts = new Map();
137
+ // §tokenomics-ceiling-calibrates-to-usage (#311) — the loop's observed real/measured token
138
+ // ratio (provider usage.prompt ÷ our measured packet tokens), monotone max so the ceiling
139
+ // only tightens. A chars/4 heuristic ruler undercounts escaped-JSON logs ~1.5×; calibrating
140
+ // against the provider's own count makes a real context overflow unreachable past turn 1.
141
+ #tokenRatios = new Map();
142
+ // §send-premature-terminate — loops owed one idle-grace turn after a retrieval-only 409
143
+ // (the steer's own advice is to wait; in-memory, fail-open on restart).
144
+ #retrievalRefusalGrace = new Set();
145
+ // §derivation-off-hot-path — the background derivation chain: the per-turn pump and the
146
+ // session warm ride it instead of the turn (a 2-CPU container CPU-embedding a 335-entry
147
+ // ingest starved every loop for ~28min, #316). Serialized (never two pumps interleaved),
148
+ // drained at daemon stop (never racing db close), failures logged (never swallowed).
149
+ // A turn never waits on an embedding; a ~query warms its own candidate slice inline
150
+ // (§semantic-cold-query-full-fidelity), so cold sessions still get full-fidelity search.
151
+ #derivationChain = Promise.resolve();
152
+ #queueDerivation(job) {
153
+ const run = this.#derivationChain.then(job).catch((err) => {
154
+ process.stderr.write(`plurnk-engine: background derivation failed: ${err instanceof Error ? err.message : String(err)}\n`);
155
+ });
156
+ this.#derivationChain = run;
157
+ return run;
158
+ }
159
+ // Awaited by Daemon.stop before the db closes.
160
+ async drainDerivations() {
161
+ await this.#derivationChain;
162
+ }
163
+ #streamEventNotify;
164
+ #wakeRunNotify;
165
+ // Cached plurnk GBNF — read once on the first constrained generate (#189).
166
+ #gbnfCache = new Map(); // variant name -> GBNF text (per-alias selection, #353)
167
+ constructor({ db, schemes, mimetypes, streamEventNotify, wakeRunNotify, injectRun, cancelRun, telemetryEventNotify, tokenize }) {
168
+ this.#db = db;
169
+ this.#schemes = schemes;
170
+ this.#streamEventNotify = streamEventNotify;
171
+ this.#wakeRunNotify = wakeRunNotify;
172
+ // Default to empty discovery — standalone Engine construction (in
173
+ // tests) gets no handlers, and content flows through the framework's
174
+ // raw-content fitContent fallback. Daemon-managed Engine receives a
175
+ // production-configured Mimetypes via the constructor arg.
176
+ this.#mimetypes = mimetypes ?? new Mimetypes({
177
+ discovery: { registry: emptyRegistry(), handlers: new Map() },
178
+ });
179
+ // Tripwire default matches the Mimetypes boot affordance (SPEC §mimetype-surface):
180
+ // the divisor stands in only until the provider-backed tokenizer is
181
+ // wired by the Daemon. Real counts come from provider.countTokens.
182
+ this.#tokenize = tokenize ?? ((text) => Math.ceil(text.length / 4));
183
+ const executors = () => this.#executors;
184
+ const loopSignal = (loopId) => this.#loopAborts.get(loopId)?.signal;
185
+ this.#telemetry = new TelemetryChannel({ db, notify: telemetryEventNotify });
186
+ this.#strikes = new StrikeRail();
187
+ this.#packets = new PacketBuilder({ db, schemes, telemetry: this.#telemetry, executors });
188
+ this.#proposals = new ProposalLifecycle({
189
+ db, schemes, telemetry: this.#telemetry,
190
+ streamEventNotify, wakeRunNotify,
191
+ tokenize: this.#tokenize, mimetypes: this.#mimetypes, executors, loopSignal,
192
+ });
193
+ this.#dispatcher = new Dispatcher({
194
+ db, schemes, mimetypes: this.#mimetypes,
195
+ tokenize: this.#tokenize,
196
+ telemetry: this.#telemetry, proposals: this.#proposals,
197
+ executors, loopSignal,
198
+ streamEventNotify, wakeRunNotify, injectRun, cancelRun,
199
+ parkDeadlines: this.parkDeadlines,
200
+ joinTargets: this.joinTargets,
201
+ });
202
+ }
203
+ // Late injection: the executor registry is async-built at daemon start()
204
+ // (discover + probe), after Engine construction.
205
+ setExecutors(executors) {
206
+ this.#executors = executors;
207
+ }
208
+ // Runtime hotload (#289) — register an executor TAG live, after boot (the /mcp route: an MCP
209
+ // server connected at runtime becomes EXEC[<server>]). Registers on BOTH registries the boot path
210
+ // wires: the ExecutorRegistry (dispatch resolves the tag; the tools sheet, rebuilt per packet, then
211
+ // offers it to the model) and the SchemeRegistry face (the tag's READ/FIND/KILL scheme), sharing the
212
+ // same reserved/cross-family arbitration boot uses. Fail-hard if the registry isn't wired yet — a
213
+ // hotload before daemon start() is a caller bug, not a silent no-op.
214
+ hotloadRuntime(tag, entry) {
215
+ if (this.#executors === undefined)
216
+ throw new Error("hotloadRuntime: executor registry not wired yet (call after daemon start)");
217
+ // Scheme face FIRST — it is the arbitration gate (reserved / cross-family collision, #240) and
218
+ // throws before we mutate the executor registry, so a rejected tag leaves neither registry
219
+ // half-written. A brand-new tag registers on both; a reserved/claimed tag throws here untouched.
220
+ this.#schemes.registerRuntimeScheme(tag, entry.executor);
221
+ this.#executors.register(tag, entry);
222
+ }
223
+ // Grammar-constrained sampling (#189): when PLURNK_PROVIDERS_GBNF is enabled
224
+ // (the only knob — default-on in .env.defaults), hand the provider the plurnk
225
+ // GBNF (the full shipped multi-op root, read once + cached). The provider
226
+ // attaches it iff the backend supports it and silently drops it otherwise —
227
+ // capability is providers' concern, not ours. Pure plumbing grammar→provider.
228
+ async #grammarConstraint(provider) {
229
+ // PLURNK_PROVIDERS_GBNF SELECTS the GBNF variant to constrain sampling to (#225):
230
+ // a bare name (`plurnk-strict.gbnf` | `plurnk.gbnf`) is a variant shipped by
231
+ // @plurnk/plurnk-grammar; an absolute/relative path is a BYO grammar. Empty or "0"
232
+ // disables — unconstrained generation.
233
+ //
234
+ // PER ALIAS (#353): resolved PLURNK_PROVIDERS_GBNF_<alias> over the bare fallback (providers'
235
+ // scopeEnvToAlias), scoped by the alias that built this provider. GBNF only helps backends
236
+ // that constrain sampling (llama-server, response_format backends); a cloud model that IGNORES the grammar
237
+ // gets a filter-mode divergence event every turn for nothing. So the bare default is OFF
238
+ // and the GBNF-capable aliases opt IN via a PLURNK_PROVIDERS_GBNF_<alias> suffix.
239
+ const alias = ProviderInstantiate.aliasOf(provider) ?? resolveActiveAlias(process.env)?.alias ?? "";
240
+ const variant = scopeEnvToAlias(process.env, alias, ["PLURNK_PROVIDERS_GBNF"]).PLURNK_PROVIDERS_GBNF;
241
+ if (variant === undefined || variant === "" || variant === "0")
242
+ return undefined;
243
+ const hit = this.#gbnfCache.get(variant);
244
+ if (hit !== undefined)
245
+ return hit;
246
+ const path = variant.startsWith("/") || variant.startsWith(".")
247
+ ? variant
248
+ : fileURLToPath(import.meta.resolve(`@plurnk/plurnk-grammar/${variant}`));
249
+ const text = await readFile(path, "utf8");
250
+ this.#gbnfCache.set(variant, text);
251
+ return text;
252
+ }
253
+ // Per-loop usage totals (#197): SUM the loop's turns (usage is stored per
254
+ // turn, §tokenomics). Surfaced on loop.run + loop/terminated so clients render real
255
+ // token/cost numbers. costPico is the stored pico-dollar unit.
256
+ // #345 — the client-facing budget denominator, ONE meaning on every surface: the prompt
257
+ // budget the packet actually lives under (effective window minus the partition reserves),
258
+ // the same number loop-usage stores per turn. providers.list advertised the raw KV and the
259
+ // client's gauge rendered a window the model can never fill.
260
+ promptBudgetFor(provider) {
261
+ return this.#packets.promptBudgetFor(provider);
262
+ }
263
+ async loopUsage(loopId) {
264
+ const row = await this.#db.engine_loop_usage.get({ loop_id: loopId });
265
+ return {
266
+ promptTokens: row?.prompt ?? 0,
267
+ completionTokens: row?.completion ?? 0,
268
+ costPico: row?.cost_pico ?? 0,
269
+ // #263 — the last turn's prompt tokens = current window occupancy (gauge numerator), NOT the
270
+ // summed promptTokens above, which overcounts a context that grows across turns.
271
+ contextTokens: row?.context ?? 0,
272
+ // #274 — the last turn's model window (denominator); null when the provider reports none.
273
+ contextSize: row?.context_size ?? null,
274
+ // #252 — the latest turn's opaque provider blob, parsed for the wire. Empty {} when the
275
+ // provider returned no meta. The service forwards it; it never reads a field within.
276
+ meta: JSON.parse(row?.meta ?? "{}"),
277
+ };
278
+ }
279
+ // A @plurnk/gbnf divergence position (providers#24) is a CODE-POINT offset into the
280
+ // model's content; the snippet/telemetry surface speaks 1-based line + 0-based column.
281
+ // Convert over code points (not UTF-16 units) so an astral char doesn't skew the line,
282
+ // clamping out-of-range offsets to the content's end.
283
+ #offsetToLineColumn(content, offset) {
284
+ const cps = Array.from(content);
285
+ const clamped = Math.max(0, Math.min(offset, cps.length));
286
+ let line = 1;
287
+ let column = 0;
288
+ for (let i = 0; i < clamped; i++) {
289
+ if (cps[i] === "\n") {
290
+ line++;
291
+ column = 0;
292
+ }
293
+ else {
294
+ column++;
295
+ }
296
+ }
297
+ return { line, column };
298
+ }
299
+ async runLoop({ provider, messages, requirements = "", sessionId, runId, loopId, maxTurns = 50, maxStrikes = readMaxStrikes(), minCycles = readPositiveInt("PLURNK_SERVICE_MIN_CYCLES", DEFAULT_MIN_CYCLES), maxCyclePeriod = readPositiveInt("PLURNK_SERVICE_MAX_CYCLE_PERIOD", DEFAULT_MAX_CYCLE_PERIOD), origin = "model", signal, onDispatch, }) {
300
+ const turnIds = [];
301
+ const suddenDeathThreshold = maxTurns - maxStrikes;
302
+ // Per-loop AbortController for scheme-side cancellation propagation.
303
+ // Chained from the caller's `signal` so an external abort cascades.
304
+ const loopAbort = new AbortController();
305
+ if (signal !== undefined) {
306
+ if (signal.aborted)
307
+ loopAbort.abort(signal.reason);
308
+ else
309
+ signal.addEventListener("abort", () => loopAbort.abort(signal.reason), { once: true });
310
+ }
311
+ this.#loopAborts.set(loopId, loopAbort);
312
+ // §operator-config-loop-timeout — the wall-clock budget. Expiry aborts the loop signal, so a
313
+ // mid-flight provider call (generate rides this signal) and in-flight spawns tear down; the
314
+ // loop terminates 504 (kin to the exec <T> reap's 504, §exec-timeout) — a legible engine
315
+ // terminal, never an outside kill. unref'd: the wall never holds the process open.
316
+ const wall = setTimeout(() => loopAbort.abort(LOOP_TIMEOUT_REASON), readLoopTimeoutMs());
317
+ wall.unref();
318
+ const timedOut = () => loopAbort.signal.aborted && loopAbort.signal.reason === LOOP_TIMEOUT_REASON;
319
+ const ruleTimeout = async () => {
320
+ await this.#db.engine_loop_set_status.run({ loop_id: loopId, status: 504, message: "loop_timeout" });
321
+ cleanup("forceful", "loop_timeout");
322
+ return { turnIds, finalStatus: 504, hitMaxTurns: false, reason: "loop_timeout" };
323
+ };
324
+ // Cleanup splits by termination kind:
325
+ // - "graceful" (SEND[202] Accepted): in-flight streaming-scheme spawns
326
+ // are ALLOWED to outlive the loop — they complete naturally, write final
327
+ // channel state, and wake-on-completion (E.4) opens a fresh loop. 202 is
328
+ // the only terminal that means "keep my async work."
329
+ // - "forceful" (SEND[200] done, max_turns, strike, cancel, budget, 4xx/5xx):
330
+ // fire the loop-level abort so leftover spawns tear down. "Done" reaps.
331
+ const cleanup = (kind, reason) => {
332
+ clearTimeout(wall);
333
+ if (kind === "forceful" && !loopAbort.signal.aborted) {
334
+ loopAbort.abort(reason ?? "loop_forceful_termination");
335
+ }
336
+ this.#loopAborts.delete(loopId);
337
+ this.#strikes.delete(loopId);
338
+ this.#hardOverflowRecovery.delete(loopId);
339
+ this.#telemetry.delete(loopId);
340
+ this.#tokenRatios.delete(loopId);
341
+ };
342
+ while (true) {
343
+ // The wall fired between turns — rule 504 before anything else reads the loop.
344
+ if (timedOut())
345
+ return await ruleTimeout();
346
+ signal?.throwIfAborted();
347
+ const row = await this.#db.engine_loop_status.get({ loop_id: loopId });
348
+ if (row === undefined)
349
+ throw new Error(`Engine.runLoop: loop ${loopId} not found`);
350
+ if (row.status === 100) {
351
+ // NOT a terminal — a wake re-queued this loop while its own live drain was
352
+ // between turns (a child concluded in the gap between our 202 write and this
353
+ // check, §run-lifecycle-wake-requeue-not-terminal). The wake's intent is KEEP
354
+ // RUNNING: re-claim atomically and continue — the injected prompt is already
355
+ // this loop's next turn. Returning it as "external" broadcast a QUEUED loop
356
+ // as loop/terminated {finalStatus: 100} — the delegation-flags flake.
357
+ await this.#db.engine_reclaim_queued_loop.run({ loop_id: loopId });
358
+ continue; // claimed (or a racer flipped it first — the re-read decides)
359
+ }
360
+ if (row.status !== 102) {
361
+ // Only 202 (Accepted) lets spawns outlive — it IS the async wake
362
+ // contract (E.4). Every other terminal, 200 included, reaps: "done"
363
+ // must not leak running execs. Trust the code's declared intent.
364
+ cleanup(row.status === 202 ? "graceful" : "forceful", `loop_terminal_${row.status}`);
365
+ return { turnIds, finalStatus: row.status, hitMaxTurns: false, reason: "external" };
366
+ }
367
+ if (maxTurns >= 0 && turnIds.length >= maxTurns) {
368
+ // §loop-terminals — the turn ceiling is exhausted: 429 Too Many Requests
369
+ // (kin to the soft sudden-death 429 warnings that precede it).
370
+ await this.#db.engine_loop_set_status.run({ loop_id: loopId, status: 429, message: "max_turns" });
371
+ cleanup("forceful", "max_turns");
372
+ return { turnIds, finalStatus: 429, hitMaxTurns: true, reason: "max_turns" };
373
+ }
374
+ // PLURNK_SERVICE_EXEC_WAIT_MS — a post-EXEC breath: if a spawn from the prior turn
375
+ // is still in flight, give it a tunable beat to land in THIS turn's packet
376
+ // before we assemble it. A fixed grace beat, never a wait-for-completion;
377
+ // 0/unset = off. Abortable with the loop signal.
378
+ const execHandler = this.#schemes.get("exec");
379
+ // §exec-hold-until-concluded — the turn-hold exception (owner ruling): for runtimes in
380
+ // the operator's HOLD set (the search family — streams we know and control: one final
381
+ // JSON digest, seconds-bounded), the cycle PAUSES here until the stream concludes, so
382
+ // the model never gets a turn it can only waste asking "are we there yet". Bounded by
383
+ // PLURNK_SERVICE_EXEC_HOLD_MS and FAIL-OPEN: at the cap the standard cycle resumes
384
+ // (the stream stays live; parks/wakes/polls all still apply). Zero grammar or teaching
385
+ // change — the model emits EXEC + SEND[102] as ever; the next packet simply contains
386
+ // the finished digest, open and final.
387
+ const holdSet = new Set((process.env.PLURNK_SERVICE_EXEC_HOLD ?? "").split(",").map((x) => x.trim()).filter((x) => x.length > 0));
388
+ const holdCapMs = Number(process.env.PLURNK_SERVICE_EXEC_HOLD_MS ?? "300000");
389
+ if (holdSet.size > 0 && holdCapMs > 0 && execHandler?.hasActiveHoldSpawns !== undefined) {
390
+ const holdStart = Date.now();
391
+ while (execHandler.hasActiveHoldSpawns(runId, holdSet) && Date.now() - holdStart < holdCapMs) {
392
+ await delay(150, undefined, { signal });
393
+ }
394
+ }
395
+ const execWaitMs = Number(process.env.PLURNK_SERVICE_EXEC_WAIT_MS ?? "0");
396
+ if (execWaitMs > 0) {
397
+ if (execHandler?.hasActiveSpawns?.(runId) === true)
398
+ await delay(execWaitMs, undefined, { signal });
399
+ }
400
+ let turn;
401
+ try {
402
+ turn = await this.runTurn({
403
+ provider, messages, requirements, sessionId, runId, loopId, origin, signal, onDispatch,
404
+ turnNumber: turnIds.length + 1, maxTurns,
405
+ });
406
+ }
407
+ catch (err) {
408
+ // The wall fired mid-turn — the abort tore the turn down (generate rides the loop
409
+ // signal); rule the legible 504, never a generic drain error.
410
+ if (timedOut())
411
+ return await ruleTimeout();
412
+ throw err;
413
+ }
414
+ turnIds.push(turn.turnId);
415
+ // SPEC §grinder: budget hard-stop — packet won't fit even collapsed → abandon.
416
+ if (turn.budgetHardStop) {
417
+ // §loop-terminals — the packet won't fit even collapsed: 413 Content Too Large.
418
+ await this.#db.engine_loop_set_status.run({ loop_id: loopId, status: 413, message: "budget_overflow" });
419
+ cleanup("forceful", "budget_overflow");
420
+ return { turnIds, finalStatus: 413, hitMaxTurns: false, reason: "budget_overflow" };
421
+ }
422
+ // Rails #38/#39 — per-turn strike accounting (cycle detection, the
423
+ // grinder/steer coupling, hard-failure statuses). StrikeRail owns the
424
+ // bookkeeping; runLoop owns abandonment.
425
+ const verdict = this.#strikes.assess(loopId, {
426
+ fingerprint: turn.fingerprint,
427
+ statuses: turn.statuses,
428
+ noOps: turn.status === TURN_STATUS_NO_OPS,
429
+ budgetStruck: turn.budgetStruck,
430
+ steerStruck: turn.steerStruck,
431
+ minCycles, maxCyclePeriod, maxStrikes,
432
+ });
433
+ if (verdict.thresholdCrossed) {
434
+ // §loop-terminals — a cycle-driven strike is the model spinning in place
435
+ // (508 Loop Detected); a failure/no-op strike is the model failing (500
436
+ // Internal Server Error). The straw that crossed the threshold picks it.
437
+ const status = verdict.cycleDetected ? 508 : 500;
438
+ await this.#db.engine_loop_set_status.run({ loop_id: loopId, status, message: "strike_threshold" });
439
+ cleanup("forceful", "strike_threshold");
440
+ return { turnIds, finalStatus: status, hitMaxTurns: false, reason: "strike_threshold" };
441
+ }
442
+ // Sudden-death threshold is engine-internal — abandonment
443
+ // happens at maxTurns regardless. Per gamification policy:
444
+ // we don't warn the model that it's nearing our limit.
445
+ if (turnIds.length >= suddenDeathThreshold && turnIds.length < maxTurns) {
446
+ // Threshold tripped; engine bookkeeping only.
447
+ }
448
+ }
449
+ }
450
+ async runTurn({ provider, messages, requirements = "", sessionId, runId, loopId, origin = "model", signal, onDispatch, turnNumber = 1, maxTurns = 50, }) {
451
+ // === Turn-as-container model ===
452
+ //
453
+ // Turn rows are created at runTurn OPEN (status=102, placeholder
454
+ // packet) so things can be written into the turn before the model
455
+ // is called: the user prompt on turn 1; later, system signals or
456
+ // injected telemetry events on any turn. The turn is CLOSED at
457
+ // the end with the final packet + status + usage stats.
458
+ //
459
+ // sequence is "ordinal of stuff in this turn." Pre-model
460
+ // writes consume low indices; model ops continue from there.
461
+ const seqRow = await this.#db.engine_next_turn_sequence.get({ loop_id: loopId });
462
+ const seq = seqRow.next;
463
+ // #269 — loops.sequence is the loop's ordinal WITHIN the run. Turn-0 foists that belong to the
464
+ // RUN (manifest preview, AGENTS, operator docs) gate on the run's FIRST loop, not every loop's
465
+ // first turn; per-loop foists (the prompt, @file) still fire each loop. Read once, turn-1 only.
466
+ const loopRow = seq === 1
467
+ ? await this.#db.engine_get_loop_prompt.get({ loop_id: loopId })
468
+ : undefined;
469
+ const runFirstLoop = (loopRow?.sequence ?? 0) === 1;
470
+ const openRow = await this.#db.engine_open_turn.get({
471
+ loop_id: loopId, sequence: seq,
472
+ });
473
+ if (openRow === undefined)
474
+ throw new Error("Engine.runTurn: turn open returned no row");
475
+ const turnId = openRow.id;
476
+ // Pre-model writes. Each turn opens with a system-origin EDIT
477
+ // against `plurnk://prompt/<run>/<loop>/<seq>` IF there's a prompt
478
+ // for THIS turn the model hasn't seen yet:
479
+ // - Turn 1: loop.prompt is the initial user prompt.
480
+ // - Turn N>1: only if Engine.inject (or wake-on-completion via
481
+ // daemon.inject) wrote a prompt entry for this turn slot
482
+ // between turn N-1 and N. Inject writes directly to entries;
483
+ // we DON'T re-foist here for N>1.
484
+ // The log records the EDIT for forensics. Model ops dispatch
485
+ // from sequence=2 onward on prompt-foisted turns; 1 onward
486
+ // otherwise.
487
+ let nextActionIndex = 1;
488
+ // §model-entry — the run's first turn opens with the model's own turn-0, mirrored OPEN: a
489
+ // worked turn PLAN → the environment FINDs the foist ACTUALLY dispatches → SEND[102]. Built
490
+ // from the real ops below (not a static print — we lean into the genuine echo paradigm) and
491
+ // written at sequence 1, so it reads first as the emission with the foisted results following.
492
+ const turnZeroMoves = [];
493
+ if (seq === 1) {
494
+ if (runFirstLoop)
495
+ nextActionIndex = 2; // reserve sequence 1 for the turn-0 echo
496
+ // Operator doc READs (PLURNK_SERVICE_MD_<ALIAS>, §actor-boundary-doc-injection). The docs were materialized
497
+ // as plurnk:///<entry> entries by the plurnk run (loop_run, via the
498
+ // §actor-boundary keystone); foist a READ of each into THIS turn-0 so the model
499
+ // reads them inline. It sees only the READ — the materializing EDIT
500
+ // lives in the plurnk run's log, never the model's.
501
+ // #231 — env docs (PLURNK_SERVICE_MD_*) UNION the session's client docs; foist a READ of
502
+ // each materialized plurnk:///<alias>.md (loop_run materialized the same set).
503
+ const { mdDocs } = await SessionSettings.read(this.#db, sessionId);
504
+ // #269 — operator docs are run-once; foist them only on the run's first loop.
505
+ for (const doc of runFirstLoop ? await SessionSettings.resolveDocs(mdDocs) : []) {
506
+ const docTarget = {
507
+ kind: "url", raw: `plurnk:///${doc.entryName}`, scheme: "plurnk",
508
+ username: null, password: null, hostname: null, port: null,
509
+ pathname: `/${doc.entryName}`, params: {}, fragment: null,
510
+ };
511
+ const docRead = {
512
+ op: "READ", suffix: "", signal: null, target: docTarget,
513
+ lineMarker: null, body: null, position: { line: 1, column: 1 },
514
+ };
515
+ await this.dispatch({
516
+ statement: docRead, sessionId, runId, loopId, turnId,
517
+ sequence: nextActionIndex, origin: "plurnk", onDispatch,
518
+ });
519
+ nextActionIndex++;
520
+ }
521
+ const promptRow = loopRow; // #269 — already read above (per-loop; fires every loop's turn 1)
522
+ if (promptRow !== undefined && typeof promptRow.prompt === "string" && promptRow.prompt.length > 0) {
523
+ const promptLoopSeq = promptRow.sequence; // the loop's PER-RUN sequence — model-facing, matching log coordinates (owner: the db id read as prompt/2/1)
524
+ const promptPath = promptTarget(runId, promptLoopSeq, seq);
525
+ const promptStmt = {
526
+ op: "EDIT", suffix: "", signal: null,
527
+ target: promptPath, lineMarker: null,
528
+ body: promptRow.prompt, position: { line: 1, column: 1 },
529
+ };
530
+ let promptLogId;
531
+ await this.dispatch({
532
+ statement: promptStmt, sessionId, runId, loopId, turnId,
533
+ sequence: nextActionIndex, origin: "plurnk",
534
+ onDispatch: (id) => { promptLogId = id; onDispatch?.(id); },
535
+ });
536
+ // §prompt-fold: the prompt EDIT's row is folded — the body reaches the model
537
+ // through the auto-READ below; the EDIT stays forensic, re-OPENable.
538
+ if (promptLogId !== undefined)
539
+ await this.#db.engine_fold_log_entry.run({ id: promptLogId });
540
+ nextActionIndex++;
541
+ // §prompt-auto-read (owner): the prompt's body reaches the model as a foisted
542
+ // READ of its own entry — first 12 lines (<1,12>), or the whole prompt (<1,-1>)
543
+ // when it runs fewer than 12 (whole-read form doubles as teaching). Prior prompts
544
+ // stay listed by path in the system packet's User Prompts section — reachable,
545
+ // never silently lost.
546
+ const promptLineCount = promptRow.prompt.split("\n").length;
547
+ const promptRead = {
548
+ op: "READ", suffix: "", signal: null, target: promptPath,
549
+ lineMarker: { marks: promptLineCount >= 12 ? [1, 12] : [1, -1] },
550
+ body: null, position: { line: 1, column: 1 },
551
+ };
552
+ await this.dispatch({
553
+ statement: promptRead, sessionId, runId, loopId, turnId,
554
+ sequence: nextActionIndex, origin: "plurnk", onDispatch,
555
+ });
556
+ nextActionIndex++;
557
+ }
558
+ }
559
+ // §prompt-auto-read, the mid-loop half (owner): an inject writes a prompt entry for
560
+ // THIS turn's slot between turns — foist the same auto-READ so an injected prompt
561
+ // arrives exactly like the first one (first 12 lines, or whole when fewer).
562
+ if (seq > 1) {
563
+ const loopSeqRow = await this.#db.engine_loop_sequence.get({ loop_id: loopId });
564
+ const loopSeq = loopSeqRow?.sequence ?? loopId;
565
+ const injected = await this.#db.drain_get_all_prompt_bodies_for_loop.all({ pattern: promptPathname(runId, loopSeq, seq) });
566
+ const injectedRow = injected.find((r) => typeof r.content === "string" && r.content.length > 0);
567
+ if (injectedRow !== undefined) {
568
+ const lineCount = injectedRow.content.split("\n").length;
569
+ const injTarget = promptTarget(runId, loopSeq, seq);
570
+ const injRead = {
571
+ op: "READ", suffix: "", signal: null, target: injTarget,
572
+ lineMarker: { marks: lineCount >= 12 ? [1, 12] : [1, -1] },
573
+ body: null, position: { line: 1, column: 1 },
574
+ };
575
+ await this.dispatch({
576
+ statement: injRead, sessionId, runId, loopId, turnId,
577
+ sequence: nextActionIndex, origin: "plurnk", onDispatch,
578
+ });
579
+ nextActionIndex++;
580
+ }
581
+ }
582
+ // The per-turn derivation pump (_entry-manifest.maintainDerivations) — refreshes
583
+ // every entry's deep channels (symbols/refs/embeddings/FTS, deep_hash-gated) so the
584
+ // catalog and FIND read current data. NOT an action: no log entry, no sequence slot,
585
+ // not dispatched. There is no plurnk:///manifest.json entry — the catalog is served
586
+ // on demand by FIND(scheme:///**), foisted into the run's first turn below.
587
+ // #312 — the turn's token gauge: the ACTIVE provider's tokenizer identity + exact counter
588
+ // (mimetypes seam; provider upper bound surfaced as tokenizer_unavailable when inexact).
589
+ // Threaded per turn — never engine state — so concurrent loops on different providers
590
+ // each read their own honest numbers.
591
+ const gauge = await TokenGauge.resolve(this.#mimetypes, provider, (event) => this.#telemetry.push(sessionId, loopId, event));
592
+ const systemCtx = {
593
+ db: this.#db, sessionId, runId, loopId, turnId,
594
+ writer: "plurnk",
595
+ signal: this.#loopAborts.get(loopId)?.signal,
596
+ streamEventNotify: this.#streamEventNotify,
597
+ wakeRunNotify: this.#wakeRunNotify,
598
+ tokenize: this.#tokenize,
599
+ gauge,
600
+ mimetypes: this.#mimetypes,
601
+ defaultChannelFor: (s) => this.#schemes.defaultChannelFor(s),
602
+ pushTelemetry: (event) => this.#telemetry.push(sessionId, loopId, event),
603
+ };
604
+ // SPEC §membership D4/D5 — git-ls-files workspace membership, resolved at
605
+ // prompt-composition (EMI is eager + exhaustive — git is the only bound). When the
606
+ // session's project_root is a git working tree, tracked files are
607
+ // members without a client `add`; active members are materialized
608
+ // (disk → body channel) so they appear in the catalog. No-ops
609
+ // on headless / non-git sessions. Runs BEFORE the derivation pump so
610
+ // this turn's packet reflects them.
611
+ const fsDivergences = await GitMembership.indexGitMembership(systemCtx);
612
+ await this.#logFsFictions(sessionId, fsDivergences);
613
+ this.#queueDerivation(() => EntryManifest.maintainDerivations(systemCtx)); // §derivation-off-hot-path — the turn proceeds; ~queries warm their own slice
614
+ // Turn-0 catalog preview (PLURNK_SERVICE_FILES_ITEMS, §actor-boundary-catalog-preview):
615
+ // one FIND(scheme:///**) per scheme that holds entries, foisted into the run's first
616
+ // model turn so it opens with its catalog (the per-scheme arrays that replaced the
617
+ // single manifest.json). -1 → each scheme's whole catalog; N → its first N rows
618
+ // (clamped to the scheme's count so FIND's strict <L> never 416s); off by default.
619
+ if (seq === 1) {
620
+ // #231 — a session's client-chosen filesItems REPLACES the env default outright.
621
+ const { filesItems: sessionMI } = await SessionSettings.read(this.#db, sessionId);
622
+ const filesItems = sessionMI !== null ? normalizeFilesItems(sessionMI) : readFilesItems();
623
+ if (filesItems !== null && runFirstLoop) { // #269 — catalog preview is run-once
624
+ // engine_scheme_catalog_summary is the scheme source: session-scoped, ordered,
625
+ // one row per scheme that has entries (scheme=null → file). log:// is absent —
626
+ // it lives in log_entries, not the catalog (present-mode, the # Log section).
627
+ const catalogSchemes = await this.#db.engine_scheme_catalog_summary.all({ session_id: sessionId });
628
+ // known:/// + unknown:/// + file ALWAYS foist, even at zero entries — else the
629
+ // model burns a turn running the FIND itself, assuming the catalog is merely
630
+ // being withheld. An empty FIND(**) is orienting, not noise (owner): it tells
631
+ // the model NOT to look there. Other schemes keep the with-entries default.
632
+ const foistSchemes = [...catalogSchemes];
633
+ for (const always of ["known", "unknown", null]) {
634
+ if (!foistSchemes.some((c) => c.scheme === always))
635
+ foistSchemes.push({ scheme: always, entries: 0 });
636
+ }
637
+ for (const { scheme, entries } of foistSchemes) {
638
+ const schemeName = scheme ?? "file";
639
+ // plurnk → its docs subtree (FIND(plurnk://docs/**), uncapped) — the self-
640
+ // documenting surface. The prompt is shown in # Prompt, so the plurnk catalog
641
+ // the model orients on IS the docs; doc links are no longer rendered inline (#270).
642
+ const isPlurnk = schemeName === "plurnk";
643
+ const isFile = schemeName === "file";
644
+ // Only the FILE list is cappable (PLURNK_SERVICE_FILES_ITEMS first-N): the tracked-file
645
+ // tree is external and arbitrarily large. Every other scheme — known/unknown
646
+ // (memory), run (scratch), plurnk (docs) — foists FULL, never truncated: a partial
647
+ // view of the model's own memory reads as withheld. file at -1, or any non-file
648
+ // scheme → no cap. (file in this loop always has entries>0, so no degenerate <1,0>.)
649
+ const cap = isFile && filesItems > 0 ? Math.min(filesItems, entries) : null;
650
+ // The file survey foists as the BARE relative glob — the path shape plurnk.md
651
+ // teaches (`src/**`, `**/notes.md`; bare = project-relative) — so the turn-0
652
+ // exemplar and the log rows the model reads never train a leading-slash or
653
+ // file:/// habit the rest of the teaching contradicts.
654
+ const catalogFind = {
655
+ op: "FIND", suffix: "", signal: null,
656
+ target: isFile ? { kind: "local", raw: "**" } : {
657
+ kind: "url",
658
+ raw: isPlurnk ? "plurnk://docs/**" : `${schemeName}:///**`,
659
+ scheme: schemeName,
660
+ username: null, password: null, hostname: null, port: null,
661
+ pathname: isPlurnk ? "/docs/**" : "/**",
662
+ params: {}, fragment: null,
663
+ },
664
+ body: null,
665
+ lineMarker: cap === null ? null : { marks: [1, cap] },
666
+ position: { line: 1, column: 1 },
667
+ };
668
+ await this.dispatch({
669
+ statement: catalogFind, sessionId, runId, loopId, turnId,
670
+ sequence: nextActionIndex, origin: "plurnk", onDispatch,
671
+ });
672
+ nextActionIndex++;
673
+ // §model-entry — the same FIND, rendered back to DSL for the turn-0 echo (the model's
674
+ // own survey, mirrored OPEN). The <L> cap rides as `<1,N>`, exactly as the model would type it.
675
+ turnZeroMoves.push(`<<FIND(${isPlurnk ? "plurnk://docs/**" : isFile ? "**" : `${schemeName}:///**`})${cap === null ? "" : `<1,${cap}>`}::FIND`);
676
+ }
677
+ // §run-scheme — Manifest(run) = session-scope ∪ THIS run's run-scope. Foist the
678
+ // building run's OWN scratch (run://self/**, uncapped — a run needs the full view to
679
+ // manage its private workspace) so it's catalogued in ITS perspective alone; other
680
+ // runs reach it only via explicit FIND(run://<name>/**). A run with no scratch foists nothing.
681
+ const selfRun = await this.#db.run_name_by_id.get({ run_id: runId });
682
+ const scratch = selfRun === undefined ? 0 : (await this.#db.engine_run_scratch_count.get({ session_id: sessionId, owner_prefix: `/${selfRun.name}/*` }))?.entries ?? 0;
683
+ if (scratch > 0) {
684
+ const runFind = {
685
+ op: "FIND", suffix: "", signal: null,
686
+ target: { kind: "url", raw: "run://self/**", scheme: "run", username: null, password: null, hostname: "self", port: null, pathname: "/**", params: {}, fragment: null },
687
+ body: null, lineMarker: null, position: { line: 1, column: 1 },
688
+ };
689
+ await this.dispatch({ statement: runFind, sessionId, runId, loopId, turnId, sequence: nextActionIndex, origin: "plurnk", onDispatch });
690
+ nextActionIndex++;
691
+ turnZeroMoves.push("<<FIND(run://self/**)::FIND"); // §model-entry — the run-scope survey, into the turn-0 echo
692
+ }
693
+ }
694
+ // #260 — foist a turn-0 READ of each client-passed @file path so its content sits in front
695
+ // of the model. Daemon owns the workspace → a normal file:/// member READ; a missing or
696
+ // non-member path surfaces its READ outcome (4xx) in the log, visible to the model.
697
+ const openPathsRow = await this.#db.engine_get_loop_open_paths.get({ loop_id: loopId });
698
+ for (const raw of JSON.parse(openPathsRow?.open_paths ?? "[]")) {
699
+ const pathname = raw.startsWith("/") ? raw : `/${raw}`;
700
+ const fileRead = {
701
+ op: "READ", suffix: "", signal: null, lineMarker: null,
702
+ target: {
703
+ kind: "url", raw: `file://${pathname}`, scheme: "file",
704
+ username: null, password: null, hostname: null, port: null,
705
+ pathname, params: {}, fragment: null,
706
+ },
707
+ body: null, position: { line: 1, column: 1 },
708
+ };
709
+ await this.dispatch({
710
+ statement: fileRead, sessionId, runId, loopId, turnId,
711
+ sequence: nextActionIndex, origin: "plurnk", onDispatch,
712
+ });
713
+ nextActionIndex++;
714
+ }
715
+ // §model-entry — mirror the model's turn-0 OPEN at sequence 1: PLAN → the FINDs actually
716
+ // foisted above (real, their results already in the log) → SEND[102]. Dynamic — it reflects
717
+ // the true survey, never a frozen print — and OPEN: the worked example the model orients on,
718
+ // so the grammar can stay thin. Subsequent turns mirror the model's real output, folded.
719
+ if (runFirstLoop) {
720
+ const emission = ["<<PLAN:Initialize:PLAN", ...turnZeroMoves, "<<SEND[102]:Initialized:SEND"].join("\n");
721
+ await this.#dispatcher.writeModelEntry({ verbatim: emission, runId, loopId, turnId, sequence: 1, folded: false, origin: "plurnk" });
722
+ }
723
+ }
724
+ // §env-delta — pre-seed the run's ambient observations (what changed since
725
+ // it last looked) as foisted rows before the packet composes; advance the action index
726
+ // past them so model ops continue after. Two instances of one machine: env-delta (sibling
727
+ // edits · timestamp cursor · always folded) and exec streams (channel bytes · byte cursor ·
728
+ // terminal delta opens). §env-delta §exec-stream
729
+ // §exec-poll — EXEC `<0>` is turn-scoped: reap the run's open turn-scoped streams (necessarily
730
+ // from a prior turn — this runs before the turn's own spawns) so a `<0>` never survives into
731
+ // the subsequent turn. The terminal output then surfaces born-OPEN via the stream-delta path.
732
+ await this.#reapTurnScopedStreams(runId);
733
+ nextActionIndex += await this.#materializeEnvironmentDeltas({ sessionId, runId, loopId, turnId, fromSequence: nextActionIndex });
734
+ nextActionIndex += await this.#materializeStreamDeltas({ runId, loopId, turnId, fromSequence: nextActionIndex });
735
+ // SPEC §telemetry — git working-tree state for the telemetry section, read once
736
+ // (a service-side `git status` shell-out) and threaded into the budget
737
+ // rebuild too so it isn't re-shelled on overflow.
738
+ const gitStatus = await GitState.status(this.#db, sessionId, this.#loopAborts.get(loopId)?.signal);
739
+ // Build the spec'd packet (Packet.json) request half. The log build
740
+ // queries log_entries scoped to the run — the prompt entry just
741
+ // written (if turn 1) is part of that query result.
742
+ const tokenRatio = this.#tokenRatios.get(loopId) ?? 1;
743
+ let requestPacket = await this.#packets.buildRequestPacket({
744
+ initialMessages: messages, requirements, sessionId, runId, loopId,
745
+ currentTurnSeq: seq, provider, gitStatus, tokenRatio,
746
+ });
747
+ // SPEC §grinder — budget grinder, pre-LLM: reclaim window on actual overflow.
748
+ const enforced = await this.#packets.enforceBudget({
749
+ packet: requestPacket, provider, runId, loopId, turnId, tokenRatio,
750
+ // The overflow error row is minted at the turn's running sequence (nextActionIndex), pre-generate;
751
+ // runTurn advances the counter past it below so the post-generate dispatch rows never collide.
752
+ mintSequence: nextActionIndex,
753
+ // No preset telemetry — the rebuild RE-DERIVES the errors section from log≥400 so the
754
+ // overflow row just minted surfaces THIS turn (§grinder-overflow-error-row). Safe: the
755
+ // ephemeral buffer is empty pre-generate (events drain on the next turn's build).
756
+ rebuild: () => this.#packets.buildRequestPacket({
757
+ initialMessages: messages, requirements, sessionId, runId, loopId,
758
+ currentTurnSeq: seq, provider, gitStatus, tokenRatio,
759
+ }),
760
+ });
761
+ if (enforced.struck)
762
+ nextActionIndex += 1; // the budget-overflow error row consumed a sequence
763
+ requestPacket = enforced.packet;
764
+ if (!enforced.fit) {
765
+ // §grinder-hard-413-recovery (Q4, owner ruling: recoverable strike, NO margin) — the
766
+ // overflow lives in foldable HISTORY the model owns, and the grinder won't touch
767
+ // history (§grinder-layer1-rollback doctrine). So the first hard overflow is a
768
+ // RECOVERY TURN, not death: the packet is over the POLICY ceiling but usually well
769
+ // within PHYSICS (the jumbo pin: ceiling 13k, real window 49k) — send it once, with a
770
+ // minted steer naming the remedy, and a strike (budgetStruck). The model curates →
771
+ // next turn fits → the grant clears. It declines → the second consecutive hard
772
+ // overflow terminates 413 — death only after the model was told. Physically
773
+ // unsendable (over the provider's real window too) → 413 immediately; physics
774
+ // doesn't negotiate. The pointer stays at 100% of budget — a margin would mask it.
775
+ const physicallySendable = provider.contextSize === null
776
+ ? true
777
+ : requestPacket.tokens * Math.max(tokenRatio, 1) <= provider.contextSize - this.#packets.decodeBudget(provider);
778
+ if (physicallySendable && !this.#hardOverflowRecovery.has(loopId)) {
779
+ this.#hardOverflowRecovery.add(loopId);
780
+ await this.#db.engine_insert_log_entry.get({
781
+ run_id: runId, loop_id: loopId, turn_id: turnId, sequence: nextActionIndex++,
782
+ origin: "model", source: "engine", op: "error", suffix: "", signal: null,
783
+ scheme: null, username: null, password: null, hostname: null, port: null,
784
+ pathname: null, params: null, fragment: null, lineMarker: null,
785
+ tx: "", mimetype_tx: "text/plain",
786
+ rx: JSON.stringify({ status: 413, kind: "budget_overflow", message: "the packet exceeds the budget even after the newest turn folded — this is your ONE recovery turn: KILL or FOLD history items now (the budget table lists the heaviest) to reclaim room; a second consecutive overflow terminates the loop" }),
787
+ mimetype_rx: "application/json", status_rx: 413, tokens: 0, state: "failed", outcome: "budget_overflow",
788
+ attrs: "{}",
789
+ });
790
+ // Rebuild so the recovery-steer row just minted renders in THIS packet's log +
791
+ // errors sections (the same re-derive contract the soft grind uses).
792
+ nextActionIndex += 1;
793
+ requestPacket = await this.#packets.buildRequestPacket({
794
+ initialMessages: messages, requirements, sessionId, runId, loopId,
795
+ currentTurnSeq: seq, provider, gitStatus, tokenRatio,
796
+ });
797
+ }
798
+ else {
799
+ // Hard 413: physically unsendable, or the model already declined its recovery turn.
800
+ // Skip the LLM, close the turn, and let runLoop abandon.
801
+ const hardPacket = this.#packets.completePacket(requestPacket, { content: "", ops: [], reasoning: null }, null, provider);
802
+ await this.#db.engine_close_turn.run({
803
+ id: turnId, status: 413, packet: JSON.stringify(hardPacket),
804
+ usage_prompt: 0, usage_completion: 0, usage_reasoning: 0, usage_cached: 0, usage_cost_pico: 0,
805
+ usage_context_size: this.#packets.promptBudgetFor(provider), // #274 — the PROMPT BUDGET (window − reserves), even on a hard-413 turn: the gauge denominator the packet actually lives under
806
+ finish_reason: "budget_hard_stop", model: provider.model, meta: "{}",
807
+ });
808
+ return { turnId, status: 413, statuses: [], fingerprint: "", budgetStruck: enforced.struck, budgetHardStop: true, steerStruck: false };
809
+ }
810
+ }
811
+ else {
812
+ // A fitting turn clears the recovery grant — the model curated; a later overflow
813
+ // earns a fresh recovery turn (chronic overflow still strikes out via the rail).
814
+ this.#hardOverflowRecovery.delete(loopId);
815
+ }
816
+ const modelMessages = PacketWire.packetToWireMessages(requestPacket);
817
+ // No decode cap. Our budget governs the TRANSMISSION packet (the grinder folds
818
+ // the input under the ceiling); the model's decode — reasoning + emission — is
819
+ // out of band, owned by the provider's own context window. Deriving a maxTokens
820
+ // from our budget conflated the two and guillotined a reasoning model's
821
+ // out-of-band thinking as the packet filled (`ceiling - packet` → near-zero
822
+ // decode → finish=length mid-reasoning → no emission → strike spiral). The
823
+ // provider enforces its physical wall on its own.
824
+ let response;
825
+ // #249 — plugin attribution tags onto the per-turn generate() wire. Value is the
826
+ // active-plugin set (placeholder); real per-turn grounding is deferred.
827
+ const attributions = [...new Set([...this.#schemes.attributions(), ...(this.#executors?.attributions() ?? [])])].toSorted();
828
+ // #249 — tag the loop (the activity) with its active plugins' attribution tags, write-once.
829
+ if (attributions.length > 0)
830
+ await this.#db.engine_tag_loop_attributions.run({ loop_id: loopId, attributions: JSON.stringify(attributions) });
831
+ // #249 — session-stable frontend id, forwarded as Plurnk-Client by the plurnk provider only.
832
+ const { client } = await SessionSettings.read(this.#db, sessionId);
833
+ try {
834
+ // §turn-lifecycle (#301) — the provider call is the long, opaque window (submit → first
835
+ // committed op is provider latency + a full first-turn generation, ~70s local): a static
836
+ // screen there is indistinguishable from a hang. Bracket generate() with two telemetry beats
837
+ // so a client can show "awaiting model" the instant the turn starts and flip to "parsing" when
838
+ // ops are about to land. Base telemetry/event channel (the embed_progress precedent, §tokenomics
839
+ // clients already render it unconditionally); the abort guard keeps a cancelled loop silent.
840
+ if (!signal?.aborted)
841
+ this.#telemetry.push(sessionId, loopId, { source: "engine:turn", kind: "turn_awaiting_model", level: "info", message: "awaiting model response" });
842
+ // generate rides the LOOP signal (already chained from the caller's), so a loop-level
843
+ // abort — the §operator-config-loop-timeout wall — cancels a stuck provider call, not
844
+ // just the schemes. Bare runTurn (no runLoop) has no loop entry → the caller's signal.
845
+ response = await provider.generate({ messages: modelMessages, runId: String(runId), signal: this.#loopAborts.get(loopId)?.signal ?? signal, grammar: await this.#grammarConstraint(provider), maxTokens: this.#packets.decodeBudget(provider), strikes: this.#strikes.streak(loopId), attributions: attributions.length > 0 ? attributions : undefined, client: client ?? undefined }); // strikes: first-party routing signal, 0 sent explicitly (#313) // §provider-surface-generate §provider-guarantees-single-call §provider-guarantees-signal-wired §attribution-plurnk-namespace-reserved §client-telemetry
846
+ if (!signal?.aborted)
847
+ this.#telemetry.push(sessionId, loopId, { source: "engine:turn", kind: "turn_generated", level: "info", message: "parsing model response" });
848
+ }
849
+ catch (err) {
850
+ // §turn-never-blank — a ProviderError is an INFRASTRUCTURE failure (auth, network
851
+ // beyond retries, rate limit): no completed exchange exists, so no turn exists —
852
+ // telemetry the cause and DIE legibly (the drain writes the loop terminal 500 with
853
+ // the message). Grammar conformance never arrives here: providers 0.32 retired the
854
+ // constrained-path throw — a completed exchange ALWAYS returns, bytes in assistant,
855
+ // the conformance verdict riding response.telemetry as an OBSERVATION (the engine's
856
+ // ANTLR parse is the judge; the provider transports and observes, never adjudicates).
857
+ // The old fallback fabricated an empty emission here and laundered a provider
858
+ // adjudication into a model-behavior 422 — a state the system otherwise forbids,
859
+ // a record that lied, and days of forensics pointed at the wrong suspect.
860
+ if (err instanceof ProviderError) {
861
+ this.#telemetry.push(sessionId, loopId, { source: "provider", kind: err.kind, message: err.message, level: "error" });
862
+ }
863
+ throw err;
864
+ }
865
+ // Engine splits wire-level response: emission (content, reasoning,
866
+ // parsed ops) → packet.assistant per Packet.json assistant section;
867
+ // call-metadata (usage, finishReason, model) → Turn columns per
868
+ // Turn.json. Mixing the two on packet.assistant was the wrong layer.
869
+ const { packetAssistant, callMetadata, parseErrors } = this.#splitResponse(response); // raw assistant content is opaque — split, never interpreted — §provider-guarantees-assistantraw-opaque
870
+ // §tokenomics-ceiling-calibrates-to-usage — learn the loop's real/measured ratio from the
871
+ // provider's OWN prompt count (ground truth for the whole wire request, template overhead
872
+ // included). Monotone max within the loop. An EXACT ruler floors at 1 (never expands);
873
+ // a certified upper-bound ruler calibrates to observed truth in BOTH directions — the
874
+ // worst-observed packing wins, and expansion toward ground truth cannot overshoot the
875
+ // window (owner-ruled; run24: the unconditional floor halved gbuild's effective budget).
876
+ if (callMetadata.usage.prompt > 0 && requestPacket.tokens > 0) {
877
+ const observed = callMetadata.usage.prompt / requestPacket.tokens;
878
+ const prior = this.#tokenRatios.get(loopId) ?? (gauge.exact ? 1 : observed);
879
+ if (observed >= prior)
880
+ this.#tokenRatios.set(loopId, observed);
881
+ }
882
+ // Surface parse errors to the model's NEXT packet so it can self-
883
+ // correct. Without this, malformed emissions (e.g. a READ matcher
884
+ // body starting with `//` being interpreted as xpath) silently
885
+ // drop, the model sees zero ops dispatched, strike-rail fires,
886
+ // model has no feedback on WHY its emission didn't take effect.
887
+ //
888
+ // Envelope per @plurnk/plurnk-grammar 0.17.0 TelemetryEvent:
889
+ // { source, kind, message, position: { type: "content-offset", line, column } }
890
+ // Plus a `snippet` field (additionalProperties) carrying ±N lines
891
+ // of the assistant's own content around the error line. Without
892
+ // the snippet, the model sees "invalid xpath at 1:0" but can't
893
+ // connect that to what IT wrote — and tends to regenerate the
894
+ // same broken emission. See edit-todo demo for the canonical case.
895
+ // Parse errors are LOG ITEMS now (§telemetry — one budget surface): each failed-to-parse
896
+ // emission records an actionless `error` row below, after the turn's dispatched ops are
897
+ // sequenced (see the parse-error log write past the dispatch loop). The errors section
898
+ // derives a pointer to it from log≥400, uniform with action_failure.
899
+ // providers#24 / #275: non-fatal provider telemetry on a SUCCESSFUL turn. In GBNF-filter
900
+ // mode the provider no longer THROWS grammar_unenforced — it returns the model's bytes
901
+ // (here, packetAssistant.content) and attaches the conflict as a telemetry event carrying
902
+ // the divergence code-point position. Forward each event with a content-offset `line:col`;
903
+ // the model resolves it against its own emission — READ the folded `model` mirror row at the
904
+ // cited lines (§model-entry) — not an embedded snippet that would duplicate the emission.
905
+ for (const event of response.telemetry ?? []) {
906
+ const located = typeof event.position === "number"
907
+ ? this.#offsetToLineColumn(packetAssistant.content, event.position)
908
+ : null;
909
+ this.#telemetry.push(sessionId, loopId, {
910
+ source: event.source,
911
+ kind: event.kind,
912
+ message: event.message ?? "",
913
+ level: event.level ?? "warn", // forward the producer's severity; default for a producer predating the field
914
+ ...(located !== null
915
+ ? { position: { type: "content-offset", line: located.line, column: located.column } }
916
+ : {}),
917
+ });
918
+ }
919
+ const opsCount = packetAssistant.ops.length;
920
+ // PLAN (reasoning) and informational SEND[103] are no-ops, not actions: both are
921
+ // excluded from the real-op count so a PLAN-only or prose-only turn still strikes
922
+ // as no-ops, and the terminal scan ignores 1xx so they never set turnStatus.
923
+ const realOpsCount = packetAssistant.ops.filter((op) => op.op !== "PLAN" && !(op.op === "SEND" && op.signal === 103 && op.target === null)).length;
924
+ const sendOp = packetAssistant.ops.findLast((op) => op.op === "SEND" && typeof op.signal === "number" && op.signal >= 200);
925
+ // §send the terminal contract — two engine error states verify a terminal claim against run
926
+ // state, never trusting the model's code. Both strike via turn.steerStruck (turnErrors,
927
+ // §grinder-strike-coupling): the loop continues, the model sees the steering hint not the strike
928
+ // count, and a non-resolver spins out to the engine's 500.
929
+ let steerStruck = false;
930
+ // Engine errors raised this turn, minted as op='error' log rows after dispatch (they share the
931
+ // post-dispatch sequence counter). §telemetry-uniform-error-channel
932
+ const pendingEngineErrors = [];
933
+ // Terminal adjudication moved to the DISPATCHER (§send-premature-terminate, the unified
934
+ // pending set): the terminal SEND is judged AT ITS OWN DISPATCH — after the emission's
935
+ // earlier ops executed — so a same-turn KILL+[200] repairs in one turn and a same-turn
936
+ // WORK+[200] is caught. A refused terminal (409) strikes via the dispatch-loop check below.
937
+ // Rail #41 (revised): the per-turn requirement is "emit at least one op," not "emit a terminal
938
+ // SEND." SEND is purely a signal verb; many turns pass without one. An empty op list strikes.
939
+ // Provisional here — a terminal REFUSED at dispatch (the pending-set 409, only knowable
940
+ // post-dispatch) demotes the turn back to a continue below: the SEND's signal stays on the
941
+ // row (the un-erased record), but the loop never went terminal, so the turn didn't either.
942
+ let turnStatus = sendOp !== undefined
943
+ ? sendOp.signal
944
+ : realOpsCount === 0 ? TURN_STATUS_NO_OPS : TURN_STATUS_IMPLICIT_CONTINUE;
945
+ // Idle turn: an implicit-continue (102) that did no WORK — its ops are only PLAN/SEND, no mid op.
946
+ // The model continued with nothing to do. (Skipped when premature already steered this turn.)
947
+ const midOpsCount = packetAssistant.ops.filter((op) => op.op !== "PLAN" && op.op !== "SEND").length;
948
+ if (!steerStruck && turnStatus === TURN_STATUS_IMPLICIT_CONTINUE && midOpsCount === 0) {
949
+ // One grace turn after a retrieval-only 409 (admins specimen): the refusal steer says
950
+ // "continuing in order to receive results" — a model that obediently waits one bare
951
+ // [102] turn is following OUR advice, and the idle rail was executing it for that.
952
+ // The grace is exactly one turn; a second consecutive idle strikes as ever.
953
+ if (this.#retrievalRefusalGrace.delete(loopId)) {
954
+ // graced — the wait the steer asked for
955
+ }
956
+ else {
957
+ steerStruck = true;
958
+ pendingEngineErrors.push("idle_turn");
959
+ }
960
+ }
961
+ else {
962
+ this.#retrievalRefusalGrace.delete(loopId); // a working turn consumes any pending grace
963
+ }
964
+ // Close the turn with the final packet, status, and usage stats.
965
+ const packet = this.#packets.completePacket(requestPacket, packetAssistant, response.assistantRaw, provider);
966
+ const { usage, finishReason, model } = callMetadata;
967
+ await this.#db.engine_close_turn.run({
968
+ id: turnId,
969
+ status: turnStatus,
970
+ packet: JSON.stringify(packet),
971
+ usage_prompt: usage.prompt,
972
+ usage_completion: usage.completion,
973
+ usage_reasoning: usage.reasoning,
974
+ usage_cached: usage.cached,
975
+ usage_cost_pico: provider.costFor(usage), // §provider-surface-costfor
976
+ usage_context_size: this.#packets.promptBudgetFor(provider), // #274 — the PROMPT BUDGET (window − reserves): the raw n_ctx overstated usable room by the reserve total
977
+ finish_reason: finishReason,
978
+ model,
979
+ // #252 — opaque provider→client metadata passthrough (e.g. balancePico the
980
+ // provider normalized). Stored verbatim, unenforced; the service never reads a field.
981
+ meta: JSON.stringify(response.meta ?? {}),
982
+ });
983
+ // Dispatch model ops starting at nextActionIndex (continues the
984
+ // turn's running counter after any pre-model writes).
985
+ //
986
+ // Max-commands cap: a single emission with more than `maxCommands`
987
+ // ops is the runaway-loop fingerprint observed in pathological cases
988
+ // (html-attrs demo: 635 ops in one turn). Cap dispatches at the
989
+ // configured limit; overflow ops are dropped without per-op log
990
+ // entries (avoids bloating forensics with hundreds of identical refusals)
991
+ // and the model gets a single telemetry signal next packet so it knows
992
+ // its emission was truncated.
993
+ // #232 — a session's maxCommands is a tighten-only ceiling: min() the env ceiling.
994
+ const maxCommands = Math.min(readMaxCommands(), (await SessionSettings.read(this.#db, sessionId)).maxCommands ?? Number.POSITIVE_INFINITY);
995
+ // PLAN (reasoning) and a terminal SEND (signal ≥ 200, the conclusion) are not
996
+ // actions — they always dispatch and never count against the cap. maxCommands
997
+ // bounds real actions only; maxCommands:0 still admits a plan and a conclusion
998
+ // (the PLAN/SEND ops, zero actions), which is its only coherent meaning.
999
+ let realCommands = 0;
1000
+ const opsToDispatch = packetAssistant.ops.filter((op) => op.op === "PLAN"
1001
+ || (op.op === "SEND" && typeof op.signal === "number" && op.signal >= 200)
1002
+ || realCommands++ < maxCommands);
1003
+ const droppedCount = opsCount - opsToDispatch.length;
1004
+ const statuses = [];
1005
+ // Running counter — a multi-file READ writes N rows from one statement (rowsWritten),
1006
+ // so the next op's sequence picks up after them. Collapses to nextActionIndex+i when
1007
+ // every op writes one row (the common case).
1008
+ let rowSeq = nextActionIndex;
1009
+ for (const statement of opsToDispatch) {
1010
+ const result = await this.dispatch({
1011
+ statement, sessionId, runId, loopId, turnId,
1012
+ sequence: rowSeq,
1013
+ origin, onDispatch, gauge,
1014
+ // §send-200-failed-ops — parse errors mint as rows AFTER this loop; the terminal
1015
+ // gate needs them NOW, so the count rides the dispatch context.
1016
+ turnParseErrors: parseErrors?.length ?? 0,
1017
+ });
1018
+ statuses.push(result.status);
1019
+ // A refused terminal (the pending-set 409) demotes the turn to a continue: the loop
1020
+ // never went terminal, so the turn didn't either (the close persisted the provisional
1021
+ // status BEFORE dispatch — run20's T3 stored 200 with a 409-refused SEND). Whether it
1022
+ // ALSO strikes is kind-specific (owner ruling): a retrievals-only refusal teaches
1023
+ // without striking — atomic-turn-pretrained models pair fetch-and-answer by habit,
1024
+ // the refusal is correct each time, and maxTurns bounds the walk; striking executed
1025
+ // converging behavior (jumbo/admins specimens: 3 correct refusals → 500 mid-adapt).
1026
+ // Streams/children refusals keep the strike — discarding live work stays serious.
1027
+ if (statement === sendOp && result.status === 409) {
1028
+ if (result.attrs?.retrievalOnly !== true)
1029
+ steerStruck = true;
1030
+ else
1031
+ this.#retrievalRefusalGrace.add(loopId); // the steer says "continuing to receive" — the NEXT turn's obedient wait must not idle-strike
1032
+ turnStatus = TURN_STATUS_IMPLICIT_CONTINUE;
1033
+ await this.#db.engine_demote_turn_status.run({ id: turnId, status: turnStatus });
1034
+ }
1035
+ // A [300] question resolves through the proposal system (#346) — whatever the
1036
+ // resolution (answer/reject/timeout), the LOOP continues to the turn where the model
1037
+ // reads it; the turn record is a continue, never a 300 terminal.
1038
+ if (statement === sendOp && sendOp.signal === 300 && result.status !== 409) {
1039
+ turnStatus = TURN_STATUS_IMPLICIT_CONTINUE;
1040
+ await this.#db.engine_demote_turn_status.run({ id: turnId, status: turnStatus });
1041
+ }
1042
+ rowSeq += result.rowsWritten ?? 1;
1043
+ }
1044
+ // §telemetry-uniform-error-channel — every engine + parse failure mints as an op='error'
1045
+ // log row at the turn's next free sequence (after every dispatched row, incl. a multi-file
1046
+ // READ's fan-out). One channel: the errors section derives a LogCoordinate pointer from log≥400.
1047
+ let errSeq = rowSeq;
1048
+ // max_commands_exceeded IS model-facing: dropped ops the model emitted that didn't run.
1049
+ if (droppedCount > 0)
1050
+ pendingEngineErrors.push("max_commands_exceeded");
1051
+ for (const kind of pendingEngineErrors)
1052
+ await this.#telemetry.mintEngineError(kind, { runId, loopId, turnId, sequence: errSeq++ });
1053
+ // §log-row-self-explains (Q2, owner-clarified) — a model-op failure is the MODEL'S OWN op
1054
+ // result: the op row carries its failure message on its meta line (packet-wire), and the
1055
+ // errors section points at the row. No separate minted item (the retired action_failure
1056
+ // mint dressed op results as source:"engine" faults — the jumbo model chased a phantom
1057
+ // "engine run 400 error" off a message-less item). Genuine engine-internal faults CRASH
1058
+ // (fail-hard, §turn-never-blank) and never mint model-facing rows.
1059
+ // §tokenomics-output-truncated (#351) — packet honesty at the completion cap: a
1060
+ // finish=length turn was GUILLOTINED mid-emission, and its parse errors are truncation
1061
+ // ARTIFACTS. This one MINTS (actionless — no op row exists to self-explain): the cause
1062
+ // must lead the artifact rows it explains, or the model fixes syntax forever.
1063
+ if (finishReason === "length" && (parseErrors?.length ?? 0) > 0) {
1064
+ await this.#db.engine_insert_log_entry.get({
1065
+ run_id: runId, loop_id: loopId, turn_id: turnId, sequence: errSeq++,
1066
+ origin: "model", source: "engine", op: "error", suffix: "", signal: null,
1067
+ scheme: null, username: null, password: null, hostname: null, port: null,
1068
+ pathname: null, params: null, fragment: null, lineMarker: null,
1069
+ tx: "", mimetype_tx: "text/plain",
1070
+ rx: JSON.stringify({ status: 413, kind: "output_truncated", message: `output truncated at the completion cap (${this.#packets.decodeBudget(provider)} tokens) mid-emission — the parse errors below are truncation artifacts; emit fewer ops per turn and continue` }),
1071
+ mimetype_rx: "application/json", status_rx: 413, tokens: 0, state: "failed", outcome: "output_truncated",
1072
+ attrs: "{}",
1073
+ });
1074
+ }
1075
+ // Parse errors carry the parser message + a content-offset line:col (a ContentOffset position),
1076
+ // resolved against the model's folded mirror row (§model-entry) — origin 'model', not engine.
1077
+ for (const { message, line, column, source } of parseErrors ?? []) {
1078
+ await this.#db.engine_insert_log_entry.get({
1079
+ run_id: runId, loop_id: loopId, turn_id: turnId, sequence: errSeq++,
1080
+ origin: "model", source: "grammar", op: "error", suffix: "", signal: null,
1081
+ scheme: null, username: null, password: null, hostname: null, port: null,
1082
+ pathname: null, params: null, fragment: null, lineMarker: null,
1083
+ tx: "", mimetype_tx: "text/plain",
1084
+ // The error carries the parser message + a content-offset `line:col`; the model READs
1085
+ // its own folded mirror row (§model-entry) at the cited lines, so no snippet is
1086
+ // embedded. The derived errors-section pointer stays minimal (status + coordinate).
1087
+ rx: JSON.stringify({ message, position: { type: "content-offset", line, column }, parserSource: source }),
1088
+ mimetype_rx: "application/json",
1089
+ status_rx: 400, tokens: 0, state: "resolved", outcome: null, attrs: "{}",
1090
+ });
1091
+ }
1092
+ // §model-entry — mirror this turn's verbatim emission back as a `model` row, so the NEXT
1093
+ // packet shows the model exactly what it last produced. ALWAYS born FOLDED — the old
1094
+ // born-OPEN-on-error auto-trigger was conditional helpfulness that bred its own hazards
1095
+ // (a 24k-char ramble mirrored open re-injects itself into the next packet: cost,
1096
+ // contamination, pressure feedback). An error's line:col resolves the same way anything
1097
+ // else does: the model that cares READs the folded row at the lines it wants — and can
1098
+ // introspect any prior emission of its own the same way. Empty emissions (a struck/
1099
+ // silent turn) write nothing — no prior output to mirror.
1100
+ if (packetAssistant.content.trim().length > 0) {
1101
+ await this.#dispatcher.writeModelEntry({ verbatim: packetAssistant.content, runId, loopId, turnId, sequence: errSeq++, folded: true });
1102
+ }
1103
+ // Zero ops is NOT an error to report — the model knows it emitted
1104
+ // nothing. Strike accounting (engine-internal) treats it as a
1105
+ // struck turn; the model just sees an empty packet next turn.
1106
+ // Per SPEC §telemetry gamification policy.
1107
+ return { turnId, status: turnStatus, statuses, fingerprint: StrikeRail.fingerprintTurn(packetAssistant.ops), budgetStruck: enforced.struck, budgetHardStop: false, steerStruck };
1108
+ }
1109
+ // Split the wire-level ProviderResponse into the two destinations:
1110
+ // packet.assistant gets the model's emission (content, ops, reasoning);
1111
+ // Turn columns get the call-metadata (usage, finishReason, model).
1112
+ // SPEC §provider-surface / plurnk-providers#1: text-fragment scraping policy lives
1113
+ // here — engine owns the parse and the scraping rule, providers stay
1114
+ // grammar-unaware.
1115
+ //
1116
+ // Test-fixture escape hatch: the Mock provider may pre-supply `ops` on
1117
+ // its assistant payload to skip the parse roundtrip. The wire Provider
1118
+ // contract has no `ops` field; only Mock exposes one. Real providers
1119
+ // always take the parse path because their `assistant.ops` is undefined.
1120
+ #splitResponse(response) {
1121
+ const { assistant } = response;
1122
+ const preParsedOps = assistant.ops;
1123
+ const ops = [];
1124
+ // PLAN is an ordinary op — emitted by the model, dispatched, and passed to the
1125
+ // client as a log entry. No special hoisting into the reasoning field (that
1126
+ // legacy paradigm is abandoned). Interstitial free text is DROPPED — the prior
1127
+ // #free-text-capture synthesis of SEND[103] log ops was retired as tech debt
1128
+ // (grammar 0.70 forbids free text between ops, so a prose-only turn strikes 422).
1129
+ // Full PlurnkParseError context (line/column/source) is preserved
1130
+ // here so runTurn can build TelemetryEvent envelopes per the
1131
+ // grammar 0.17.0 protocol — model needs position info to locate
1132
+ // its own offending content on the next turn.
1133
+ const parseErrors = [];
1134
+ if (preParsedOps !== undefined) {
1135
+ ops.push(...preParsedOps);
1136
+ }
1137
+ else {
1138
+ const parsed = PlurnkParser.parse(assistant.content);
1139
+ for (const item of parsed.items) {
1140
+ if (item.kind === "statement") {
1141
+ ops.push(item.statement);
1142
+ }
1143
+ // Free text (kind "text") is dropped — #free-text-capture retired (above).
1144
+ else if (item.kind === "error") {
1145
+ const err = item.error;
1146
+ if (err instanceof PlurnkParseError) {
1147
+ parseErrors.push({ message: err.message, line: err.line, column: err.column, source: err.source });
1148
+ }
1149
+ else {
1150
+ const msg = err?.message ?? "parse error";
1151
+ parseErrors.push({ message: msg, line: 0, column: 0, source: "parser" });
1152
+ }
1153
+ }
1154
+ }
1155
+ // The grammar also reports an `unparsedTail` when input ends
1156
+ // mid-statement (a body opened but never closed): its `reason`
1157
+ // names the op AND the fix ("…never closed — add `:READ`"), where
1158
+ // the item-level error only says "expected close tag" for a tag the
1159
+ // model thinks it already wrote. Surface it — phenomenal messages
1160
+ // the model can self-correct from are the whole point of the DSL.
1161
+ const tail = parsed.unparsedTail;
1162
+ if (tail !== undefined) {
1163
+ parseErrors.push({ message: tail.reason, line: tail.from.line, column: tail.from.column, source: "grammar" });
1164
+ }
1165
+ }
1166
+ const reasoning = assistant.reasoning ?? null;
1167
+ return {
1168
+ packetAssistant: { content: assistant.content, ops, reasoning },
1169
+ callMetadata: { usage: assistant.usage, finishReason: assistant.finishReason, model: assistant.model },
1170
+ parseErrors,
1171
+ };
1172
+ }
1173
+ // #note12 — the daughter-provided reference docs (schemes' + execs' `documentation`),
1174
+ // materialized at plurnk:///docs/<name>.md by loop_run (like operator docs).
1175
+ docEntries(sessionId) {
1176
+ return this.#packets.docEntries(sessionId);
1177
+ }
1178
+ // §env-delta (§actor-boundary-no-mutex: runs share without locks; a conflict surfaces as a delta, never prevented) — at pre-turn build, surface what changed in the shared world since this
1179
+ // run last looked. No per-run snapshot (§machine-processes "a run is its log"): every
1180
+ // edit is already a span-carrying log row, so PULL other actors' EDITs on shared
1181
+ // entries since this run's prior turn — real cross-run edits and the plurnk run's
1182
+ // fs-sync fictions — and materialize each as a FOLDED delta reusing the row's span +
1183
+ // cause. Returns the count so the caller advances nextActionIndex past the deltas.
1184
+ async #materializeEnvironmentDeltas(args) {
1185
+ const { sessionId, runId, loopId, turnId, fromSequence } = args;
1186
+ const boundary = await this.#db.engine_run_prior_turn_time.get({ run_id: runId, turn_id: turnId });
1187
+ const since = boundary?.since ?? null;
1188
+ if (since === null)
1189
+ return 0; // first turn — nothing prior; the model reads current state fresh
1190
+ const rows = await this.#db.engine_pull_env_deltas.all({ session_id: sessionId, run_id: runId, since });
1191
+ let written = 0;
1192
+ for (const r of rows) {
1193
+ // source: the originating run (a real cross-run edit) or 'file' (an fs fiction);
1194
+ // rx reuses the originating row's result span — the edit as it looked then.
1195
+ await this.#db.engine_insert_env_delta.run({
1196
+ run_id: runId, loop_id: loopId, turn_id: turnId, sequence: fromSequence + written,
1197
+ source: r.source ?? String(r.run_id), scheme: r.scheme, pathname: r.pathname, rx: r.rx,
1198
+ });
1199
+ written++;
1200
+ }
1201
+ // §run-scheme — loop-terminations: a sibling's loop reaching terminal surfaces the
1202
+ // same way an entry-change does, carrying its deliverable (the SEND body) or the
1203
+ // abandonment reason. Folded, attributed to the terminated run.
1204
+ const terms = await this.#db.engine_pull_loop_terminations.all({ session_id: sessionId, run_id: runId, since });
1205
+ for (const t of terms) {
1206
+ await this.#db.engine_insert_loop_termination_delta.run({
1207
+ run_id: runId, loop_id: loopId, turn_id: turnId, sequence: fromSequence + written,
1208
+ source: String(t.run_id), pathname: `/${t.run_name}`,
1209
+ rx: markTerminal(t.terminated_by, t.terminal_message) ?? `loop "${t.prompt}" ended (${t.status})`,
1210
+ status: t.status,
1211
+ });
1212
+ written++;
1213
+ }
1214
+ return written;
1215
+ }
1216
+ // §exec-poll — EXEC `<0>` is turn-scoped: abort the run's open turn-scoped streams via their
1217
+ // owning scheme (the same registry-routed abort the total reap uses). Called at each pre-turn
1218
+ // before the turn's own spawns, so every open turn-scoped sub here is from a prior turn — it
1219
+ // never survives into the subsequent turn. Fire-and-forget: the spawn finalizes async and its
1220
+ // terminal output surfaces born-OPEN through the stream-delta path (§exec-stream).
1221
+ async #reapTurnScopedStreams(runId) {
1222
+ const open = await this.#db.find_open_turn_scoped_subscriptions_for_run.all({ run_id: runId });
1223
+ for (const { id, scheme } of open) {
1224
+ const handler = this.#schemes.get(scheme);
1225
+ handler?.abortSubscription?.(id);
1226
+ }
1227
+ }
1228
+ // §env-delta — exec streams as an instance of the ambient-observe machine:
1229
+ // each turn, emit each owned channel's unshown byte-delta as a foisted READ@200 row. Folded
1230
+ // while the channel streams; the terminal delta (channel closed) auto-OPENs. The cursor is the
1231
+ // streamEnd recorded on the channel's prior delta — no exec-specific surfacing, just the
1232
+ // env-observe loop with a byte cursor where env-delta uses a timestamp. §exec-stream
1233
+ async #materializeStreamDeltas(args) {
1234
+ const { runId, loopId, turnId, fromSequence } = args;
1235
+ const channels = await this.#db.engine_run_stream_channels.all({ run_id: runId });
1236
+ let written = 0;
1237
+ for (const ch of channels) {
1238
+ const prior = await this.#db.engine_stream_cursor.get({
1239
+ run_id: runId, scheme: ch.runtime, pathname: ch.coord, fragment: ch.channel,
1240
+ });
1241
+ const priorAttrs = prior !== undefined ? JSON.parse(prior.attrs) : {};
1242
+ const cursor = priorAttrs.streamEnd ?? 0;
1243
+ const closed = ch.state === "closed" || ch.state === "errored";
1244
+ if (ch.content.length <= cursor) {
1245
+ // The cursor-terminal race (owner's dogfood find): a channel written in one final
1246
+ // burst gets fully shown FOLDED while still active; the close then has zero new
1247
+ // bytes and the auto-OPEN terminal delta never fired — the model was never shown
1248
+ // the conclusion of a stream whose result it already holds folded. Emit the
1249
+ // terminal marker ONCE: open, terse, carrying the close status; the content is a
1250
+ // pointer to the already-delivered bytes, never a re-send (§tokenomics-fetch-fits-free).
1251
+ if (closed && priorAttrs.terminal !== true && cursor > 0) {
1252
+ await this.#db.engine_insert_stream_delta.run({
1253
+ run_id: runId, loop_id: loopId, turn_id: turnId, sequence: fromSequence + written,
1254
+ scheme: ch.runtime, pathname: ch.coord, fragment: ch.channel,
1255
+ rx: JSON.stringify({ status: ch.close_status ?? 200, content: `[ stream closed (${ch.close_status ?? 200}) — full output already delivered above; READ ${ch.runtime}://${ch.coord}#${ch.channel} to revisit ]`, mimetype: "text/stream" }),
1256
+ attrs: JSON.stringify({ streamEnd: ch.content.length, terminal: true }),
1257
+ expanded: 1,
1258
+ });
1259
+ written++;
1260
+ }
1261
+ continue;
1262
+ }
1263
+ // startLine continues the line count across turns: a multi-turn stream's deltas number
1264
+ // into one sequence (lines N..M, then M+1..), not N independent "1:" restarts. §exec-stream
1265
+ const startLine = (ch.content.slice(0, cursor).match(/\n/g)?.length ?? 0) + 1;
1266
+ await this.#db.engine_insert_stream_delta.run({
1267
+ run_id: runId, loop_id: loopId, turn_id: turnId, sequence: fromSequence + written,
1268
+ scheme: ch.runtime, pathname: ch.coord, fragment: ch.channel,
1269
+ rx: JSON.stringify({ status: 200, content: ch.content.slice(cursor), mimetype: "text/stream", startLine }),
1270
+ attrs: JSON.stringify({ streamEnd: ch.content.length, terminal: closed }),
1271
+ expanded: closed ? 1 : 0, // §exec-stream — terminal delta auto-OPENs; ongoing folds
1272
+ });
1273
+ written++;
1274
+ }
1275
+ return written;
1276
+ }
1277
+ // §env-delta — the filesystem as an actor. Ambient disk divergences detected at
1278
+ // pre-turn (git membership re-read) are logged as the plurnk run's source=file EDIT
1279
+ // "fictions": no op happened, but EDIT is the only grammar the model has for "your
1280
+ // world changed," so the fiction keeps its perspective aligned with what its tooling
1281
+ // would show. The fiction lives in the plurnk run's log; every other run pulls it
1282
+ // through the one delta path, exactly like a sibling's real edit.
1283
+ // §membership-emi-divergence-signal — disk divergences logged as the plurnk run's source=file EDIT fictions
1284
+ async #logFsFictions(sessionId, divergences) {
1285
+ if (divergences.length === 0)
1286
+ return;
1287
+ const run = await this.#db.envelope_get_run_by_name.get({ session_id: sessionId, name: "plurnk" })
1288
+ ?? await this.#db.envelope_insert_run.get({ session_id: sessionId, name: "plurnk", origin: "plurnk" });
1289
+ if (run === undefined)
1290
+ throw new Error("logFsFictions: plurnk run resolution returned no row");
1291
+ const loop = await this.#db.envelope_insert_client_loop.get({ run_id: run.id });
1292
+ if (loop === undefined)
1293
+ throw new Error("logFsFictions: loop insert returned no row");
1294
+ const seq = await this.#db.client_turn_next_sequence.get({ loop_id: loop.id });
1295
+ const turn = await this.#db.client_turn_insert.get({ loop_id: loop.id, sequence: seq?.next ?? 1, packet: "{}" });
1296
+ if (turn === undefined)
1297
+ throw new Error("logFsFictions: turn insert returned no row");
1298
+ let sequence = 1;
1299
+ for (const d of divergences) {
1300
+ const span = editedSpan(d.before, d.after);
1301
+ await this.#db.engine_insert_log_entry.get({
1302
+ run_id: run.id, loop_id: loop.id, turn_id: turn.id, sequence: sequence++,
1303
+ origin: "plurnk", source: "file", op: "EDIT", suffix: "", signal: null,
1304
+ scheme: d.scheme, username: null, password: null, hostname: null, port: null,
1305
+ pathname: d.pathname, params: null, fragment: null, lineMarker: null,
1306
+ tx: "", mimetype_tx: "text/plain",
1307
+ rx: JSON.stringify({ status: 200, entryId: d.entryId, channel: d.channel, span }), mimetype_rx: "application/json",
1308
+ status_rx: 200, tokens: 0, state: "resolved", outcome: null, attrs: "{}",
1309
+ });
1310
+ }
1311
+ }
1312
+ async dispatch(context) {
1313
+ return this.#dispatcher.dispatch(context);
1314
+ }
1315
+ // op.look (#283) — resolve a READ and return its content WITHOUT writing a
1316
+ // log_entries row: the client's off-run inspection primitive. {§op-look}
1317
+ async look(context) {
1318
+ return this.#dispatcher.look(context);
1319
+ }
1320
+ // External API to feed a resolution into a pending proposal — the loop/resolve
1321
+ // RPC handler, the in-tree YOLO listener, or the timeout watcher.
1322
+ // Shutdown lane: settle every pending proposal with a cancel so a stopped world can never
1323
+ // deadlock the stop (§proposal-cancel-aborts; the #344 wedge class).
1324
+ cancelAllProposals(outcome) {
1325
+ this.#proposals.cancelAll(outcome);
1326
+ }
1327
+ resolveProposal(logEntryId, resolution) {
1328
+ this.#proposals.resolve(logEntryId, resolution);
1329
+ }
1330
+ // Snapshot of pending proposals (for diagnostic / RPC listings).
1331
+ pendingProposalIds() {
1332
+ return this.#proposals.pendingIds();
1333
+ }
1334
+ // Subscribe to proposal-pending events. Daemon registers a listener
1335
+ // that broadcasts the loop/proposal WS notification; YOLO listener
1336
+ // registers one that auto-resolves.
1337
+ onProposalPending(listener) {
1338
+ this.#proposals.onPending(listener);
1339
+ }
1340
+ // Used by wake-on-completion (daemon side): "is there any loop in this
1341
+ // run still accepting turns?" If yes, skip the wake — the active loop
1342
+ // will pick up the channel transition at its next turn boundary. If no,
1343
+ // the daemon opens a fresh loop with the wake prompt.
1344
+ async hasActiveLoopForRun(runId) {
1345
+ const row = await this.#db.engine_count_active_loops_for_run.get({ run_id: runId });
1346
+ return (row?.n ?? 0) > 0;
1347
+ }
1348
+ // #290 — run the derivation pump (deep channels: symbols/refs/FTS +
1349
+ // embeddings, deep_hash-gated) at SESSION-SCOPE, off the per-turn path, so a freshly-created
1350
+ // session's corpus warms DURING the client's startup window instead of freezing the first
1351
+ // loop.run. session.create fires this and returns immediately; embed_progress live-fans-out as it
1352
+ // runs. Idempotent + deep_hash-gated, so turn 1's pump finds the work done (or harmlessly re-runs);
1353
+ // a no-embedder build derives the cheap symbols/refs/FTS channels and skips the embed pass. Has no
1354
+ // loop yet — telemetry fans out live only (loopId 0), never buffered to a loop that never drains.
1355
+ async warmSessionDerivations(sessionId) {
1356
+ const ctx = {
1357
+ db: this.#db, sessionId, runId: 0, loopId: 0, turnId: 0,
1358
+ writer: "plurnk",
1359
+ signal: undefined,
1360
+ streamEventNotify: this.#streamEventNotify,
1361
+ wakeRunNotify: this.#wakeRunNotify,
1362
+ tokenize: this.#tokenize,
1363
+ mimetypes: this.#mimetypes,
1364
+ defaultChannelFor: (s) => this.#schemes.defaultChannelFor(s),
1365
+ pushTelemetry: (event) => this.#telemetry.notify(sessionId, 0, event),
1366
+ };
1367
+ await this.#queueDerivation(() => EntryManifest.maintainDerivations(ctx)); // §derivation-off-hot-path
1368
+ }
1369
+ // Inject a prompt into the run's currently-executing loop. Writes a
1370
+ // plurnk://prompt/<run>/<loop>/<next-turn> entry whose body becomes the
1371
+ // prompt section at the next turn boundary. Last-wins: if two
1372
+ // injects target the same next-turn slot, the second overwrites the
1373
+ // first.
1374
+ //
1375
+ // Returns null when no loop in the run is currently active (status=102).
1376
+ // The daemon-side inject path then enqueues a fresh loop with this
1377
+ // prompt; engine doesn't open loops itself.
1378
+ //
1379
+ // Rummy parallel: AgentLoop.inject(). The "active drain → write
1380
+ // prompt entry, return immediately" branch.
1381
+ async inject(runId, prompt) {
1382
+ const loopRow = await this.#db.drain_current_loop_for_run.get({ run_id: runId });
1383
+ if (loopRow === undefined)
1384
+ return null;
1385
+ const loopId = loopRow.id;
1386
+ const turnRow = await this.#db.drain_next_turn_seq_for_loop.get({ loop_id: loopId });
1387
+ const turnSeq = turnRow?.next ?? 1;
1388
+ const sessionRow = await this.#db.drain_get_run_session.get({ run_id: runId });
1389
+ if (sessionRow === undefined)
1390
+ throw new Error(`Engine.inject: run ${runId} not found`);
1391
+ const pathname = promptPathname(runId, loopRow.sequence, turnSeq); // canonical storage form, run-qualified loop-SEQ coordinates matching the turn-1 foist
1392
+ const ctx = {
1393
+ db: this.#db, sessionId: sessionRow.session_id, runId, loopId,
1394
+ turnId: 0, // no turn open at inject time; entries don't pin turnId
1395
+ writer: "plurnk",
1396
+ signal: this.#loopAborts.get(loopId)?.signal,
1397
+ streamEventNotify: this.#streamEventNotify,
1398
+ wakeRunNotify: this.#wakeRunNotify,
1399
+ tokenize: this.#tokenize,
1400
+ pushTelemetry: (event) => this.#telemetry.push(sessionRow.session_id, loopId, event),
1401
+ };
1402
+ const entry = {
1403
+ channels: { body: { content: prompt, mimetype: "text/markdown" } },
1404
+ tags: [],
1405
+ };
1406
+ await EntryCrud.writeEntry(pathname, entry, ctx, "plurnk");
1407
+ return { loopId, turnSeq };
1408
+ }
1409
+ }
1410
+ //# sourceMappingURL=Engine.js.map