@plurnk/plurnk-service 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (242) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +54 -0
  3. package/SPEC.md +1045 -0
  4. package/bin/plurnk-service.js +111 -0
  5. package/dist/core/ChannelWrite.d.ts +40 -0
  6. package/dist/core/ChannelWrite.d.ts.map +1 -0
  7. package/dist/core/ChannelWrite.js +49 -0
  8. package/dist/core/ChannelWrite.js.map +1 -0
  9. package/dist/core/Db.d.ts +14 -0
  10. package/dist/core/Db.d.ts.map +1 -0
  11. package/dist/core/Db.js +7 -0
  12. package/dist/core/Db.js.map +1 -0
  13. package/dist/core/Engine.d.ts +107 -0
  14. package/dist/core/Engine.d.ts.map +1 -0
  15. package/dist/core/Engine.js +937 -0
  16. package/dist/core/Engine.js.map +1 -0
  17. package/dist/core/EnvFlags.d.ts +10 -0
  18. package/dist/core/EnvFlags.d.ts.map +1 -0
  19. package/dist/core/EnvFlags.js +82 -0
  20. package/dist/core/EnvFlags.js.map +1 -0
  21. package/dist/core/Migrator.d.ts +19 -0
  22. package/dist/core/Migrator.d.ts.map +1 -0
  23. package/dist/core/Migrator.js +55 -0
  24. package/dist/core/Migrator.js.map +1 -0
  25. package/dist/core/PluginLoader.d.ts +14 -0
  26. package/dist/core/PluginLoader.d.ts.map +1 -0
  27. package/dist/core/PluginLoader.js +100 -0
  28. package/dist/core/PluginLoader.js.map +1 -0
  29. package/dist/core/ProviderRegistry.d.ts +42 -0
  30. package/dist/core/ProviderRegistry.d.ts.map +1 -0
  31. package/dist/core/ProviderRegistry.js +72 -0
  32. package/dist/core/ProviderRegistry.js.map +1 -0
  33. package/dist/core/SchemeRegistry.d.ts +13 -0
  34. package/dist/core/SchemeRegistry.d.ts.map +1 -0
  35. package/dist/core/SchemeRegistry.js +50 -0
  36. package/dist/core/SchemeRegistry.js.map +1 -0
  37. package/dist/core/scheme-types.d.ts +37 -0
  38. package/dist/core/scheme-types.d.ts.map +1 -0
  39. package/dist/core/scheme-types.js +9 -0
  40. package/dist/core/scheme-types.js.map +1 -0
  41. package/dist/index.d.ts +22 -0
  42. package/dist/index.d.ts.map +1 -0
  43. package/dist/index.js +51 -0
  44. package/dist/index.js.map +1 -0
  45. package/dist/providers/Mock.d.ts +43 -0
  46. package/dist/providers/Mock.d.ts.map +1 -0
  47. package/dist/providers/Mock.js +36 -0
  48. package/dist/providers/Mock.js.map +1 -0
  49. package/dist/schemes/Exec.d.ts +5 -0
  50. package/dist/schemes/Exec.d.ts.map +1 -0
  51. package/dist/schemes/Exec.js +20 -0
  52. package/dist/schemes/Exec.js.map +1 -0
  53. package/dist/schemes/File.d.ts +35 -0
  54. package/dist/schemes/File.d.ts.map +1 -0
  55. package/dist/schemes/File.js +149 -0
  56. package/dist/schemes/File.js.map +1 -0
  57. package/dist/schemes/Known.d.ts +19 -0
  58. package/dist/schemes/Known.d.ts.map +1 -0
  59. package/dist/schemes/Known.js +44 -0
  60. package/dist/schemes/Known.js.map +1 -0
  61. package/dist/schemes/Log.d.ts +13 -0
  62. package/dist/schemes/Log.d.ts.map +1 -0
  63. package/dist/schemes/Log.js +38 -0
  64. package/dist/schemes/Log.js.map +1 -0
  65. package/dist/schemes/Plurnk.d.ts +5 -0
  66. package/dist/schemes/Plurnk.d.ts.map +1 -0
  67. package/dist/schemes/Plurnk.js +15 -0
  68. package/dist/schemes/Plurnk.js.map +1 -0
  69. package/dist/schemes/Skill.d.ts +19 -0
  70. package/dist/schemes/Skill.d.ts.map +1 -0
  71. package/dist/schemes/Skill.js +46 -0
  72. package/dist/schemes/Skill.js.map +1 -0
  73. package/dist/schemes/Unknown.d.ts +19 -0
  74. package/dist/schemes/Unknown.d.ts.map +1 -0
  75. package/dist/schemes/Unknown.js +44 -0
  76. package/dist/schemes/Unknown.js.map +1 -0
  77. package/dist/schemes/_entry-crud.d.ts +24 -0
  78. package/dist/schemes/_entry-crud.d.ts.map +1 -0
  79. package/dist/schemes/_entry-crud.js +56 -0
  80. package/dist/schemes/_entry-crud.js.map +1 -0
  81. package/dist/schemes/_entry-find.d.ts +10 -0
  82. package/dist/schemes/_entry-find.d.ts.map +1 -0
  83. package/dist/schemes/_entry-find.js +41 -0
  84. package/dist/schemes/_entry-find.js.map +1 -0
  85. package/dist/schemes/_entry-ops.d.ts +21 -0
  86. package/dist/schemes/_entry-ops.d.ts.map +1 -0
  87. package/dist/schemes/_entry-ops.js +146 -0
  88. package/dist/schemes/_entry-ops.js.map +1 -0
  89. package/dist/schemes/_entry-send.d.ts +8 -0
  90. package/dist/schemes/_entry-send.d.ts.map +1 -0
  91. package/dist/schemes/_entry-send.js +56 -0
  92. package/dist/schemes/_entry-send.js.map +1 -0
  93. package/dist/server/ClientConnection.d.ts +22 -0
  94. package/dist/server/ClientConnection.d.ts.map +1 -0
  95. package/dist/server/ClientConnection.js +120 -0
  96. package/dist/server/ClientConnection.js.map +1 -0
  97. package/dist/server/Daemon.d.ts +43 -0
  98. package/dist/server/Daemon.d.ts.map +1 -0
  99. package/dist/server/Daemon.js +252 -0
  100. package/dist/server/Daemon.js.map +1 -0
  101. package/dist/server/MethodRegistry.d.ts +56 -0
  102. package/dist/server/MethodRegistry.d.ts.map +1 -0
  103. package/dist/server/MethodRegistry.js +55 -0
  104. package/dist/server/MethodRegistry.js.map +1 -0
  105. package/dist/server/clientTurn.d.ts +3 -0
  106. package/dist/server/clientTurn.d.ts.map +1 -0
  107. package/dist/server/clientTurn.js +22 -0
  108. package/dist/server/clientTurn.js.map +1 -0
  109. package/dist/server/dsl.d.ts +47 -0
  110. package/dist/server/dsl.d.ts.map +1 -0
  111. package/dist/server/dsl.js +117 -0
  112. package/dist/server/dsl.js.map +1 -0
  113. package/dist/server/envelope.d.ts +44 -0
  114. package/dist/server/envelope.d.ts.map +1 -0
  115. package/dist/server/envelope.js +113 -0
  116. package/dist/server/envelope.js.map +1 -0
  117. package/dist/server/logEntry.d.ts +30 -0
  118. package/dist/server/logEntry.d.ts.map +1 -0
  119. package/dist/server/logEntry.js +43 -0
  120. package/dist/server/logEntry.js.map +1 -0
  121. package/dist/server/methods/_dispatchAsClient.d.ts +8 -0
  122. package/dist/server/methods/_dispatchAsClient.d.ts.map +1 -0
  123. package/dist/server/methods/_dispatchAsClient.js +31 -0
  124. package/dist/server/methods/_dispatchAsClient.js.map +1 -0
  125. package/dist/server/methods/discover.d.ts +3 -0
  126. package/dist/server/methods/discover.d.ts.map +1 -0
  127. package/dist/server/methods/discover.js +7 -0
  128. package/dist/server/methods/discover.js.map +1 -0
  129. package/dist/server/methods/entry_read.d.ts +3 -0
  130. package/dist/server/methods/entry_read.d.ts.map +1 -0
  131. package/dist/server/methods/entry_read.js +56 -0
  132. package/dist/server/methods/entry_read.js.map +1 -0
  133. package/dist/server/methods/log_read.d.ts +3 -0
  134. package/dist/server/methods/log_read.d.ts.map +1 -0
  135. package/dist/server/methods/log_read.js +39 -0
  136. package/dist/server/methods/log_read.js.map +1 -0
  137. package/dist/server/methods/loop_resolve.d.ts +3 -0
  138. package/dist/server/methods/loop_resolve.d.ts.map +1 -0
  139. package/dist/server/methods/loop_resolve.js +45 -0
  140. package/dist/server/methods/loop_resolve.js.map +1 -0
  141. package/dist/server/methods/loop_run.d.ts +3 -0
  142. package/dist/server/methods/loop_run.d.ts.map +1 -0
  143. package/dist/server/methods/loop_run.js +112 -0
  144. package/dist/server/methods/loop_run.js.map +1 -0
  145. package/dist/server/methods/op_copy.d.ts +3 -0
  146. package/dist/server/methods/op_copy.d.ts.map +1 -0
  147. package/dist/server/methods/op_copy.js +24 -0
  148. package/dist/server/methods/op_copy.js.map +1 -0
  149. package/dist/server/methods/op_dispatch.d.ts +3 -0
  150. package/dist/server/methods/op_dispatch.d.ts.map +1 -0
  151. package/dist/server/methods/op_dispatch.js +17 -0
  152. package/dist/server/methods/op_dispatch.js.map +1 -0
  153. package/dist/server/methods/op_edit.d.ts +3 -0
  154. package/dist/server/methods/op_edit.d.ts.map +1 -0
  155. package/dist/server/methods/op_edit.js +22 -0
  156. package/dist/server/methods/op_edit.js.map +1 -0
  157. package/dist/server/methods/op_exec.d.ts +3 -0
  158. package/dist/server/methods/op_exec.d.ts.map +1 -0
  159. package/dist/server/methods/op_exec.js +19 -0
  160. package/dist/server/methods/op_exec.js.map +1 -0
  161. package/dist/server/methods/op_find.d.ts +3 -0
  162. package/dist/server/methods/op_find.d.ts.map +1 -0
  163. package/dist/server/methods/op_find.js +22 -0
  164. package/dist/server/methods/op_find.js.map +1 -0
  165. package/dist/server/methods/op_hide.d.ts +3 -0
  166. package/dist/server/methods/op_hide.d.ts.map +1 -0
  167. package/dist/server/methods/op_hide.js +22 -0
  168. package/dist/server/methods/op_hide.js.map +1 -0
  169. package/dist/server/methods/op_move.d.ts +3 -0
  170. package/dist/server/methods/op_move.d.ts.map +1 -0
  171. package/dist/server/methods/op_move.js +22 -0
  172. package/dist/server/methods/op_move.js.map +1 -0
  173. package/dist/server/methods/op_parse.d.ts +3 -0
  174. package/dist/server/methods/op_parse.d.ts.map +1 -0
  175. package/dist/server/methods/op_parse.js +23 -0
  176. package/dist/server/methods/op_parse.js.map +1 -0
  177. package/dist/server/methods/op_read.d.ts +3 -0
  178. package/dist/server/methods/op_read.d.ts.map +1 -0
  179. package/dist/server/methods/op_read.js +22 -0
  180. package/dist/server/methods/op_read.js.map +1 -0
  181. package/dist/server/methods/op_send.d.ts +3 -0
  182. package/dist/server/methods/op_send.d.ts.map +1 -0
  183. package/dist/server/methods/op_send.js +21 -0
  184. package/dist/server/methods/op_send.js.map +1 -0
  185. package/dist/server/methods/op_show.d.ts +3 -0
  186. package/dist/server/methods/op_show.d.ts.map +1 -0
  187. package/dist/server/methods/op_show.js +22 -0
  188. package/dist/server/methods/op_show.js.map +1 -0
  189. package/dist/server/methods/ping.d.ts +3 -0
  190. package/dist/server/methods/ping.d.ts.map +1 -0
  191. package/dist/server/methods/ping.js +7 -0
  192. package/dist/server/methods/ping.js.map +1 -0
  193. package/dist/server/methods/providers_list.d.ts +3 -0
  194. package/dist/server/methods/providers_list.d.ts.map +1 -0
  195. package/dist/server/methods/providers_list.js +19 -0
  196. package/dist/server/methods/providers_list.js.map +1 -0
  197. package/dist/server/methods/session_attach.d.ts +3 -0
  198. package/dist/server/methods/session_attach.d.ts.map +1 -0
  199. package/dist/server/methods/session_attach.js +42 -0
  200. package/dist/server/methods/session_attach.js.map +1 -0
  201. package/dist/server/methods/session_create.d.ts +3 -0
  202. package/dist/server/methods/session_create.d.ts.map +1 -0
  203. package/dist/server/methods/session_create.js +53 -0
  204. package/dist/server/methods/session_create.js.map +1 -0
  205. package/dist/server/methods/session_list.d.ts +3 -0
  206. package/dist/server/methods/session_list.d.ts.map +1 -0
  207. package/dist/server/methods/session_list.js +8 -0
  208. package/dist/server/methods/session_list.js.map +1 -0
  209. package/dist/server/methods/session_runs.d.ts +3 -0
  210. package/dist/server/methods/session_runs.d.ts.map +1 -0
  211. package/dist/server/methods/session_runs.js +18 -0
  212. package/dist/server/methods/session_runs.js.map +1 -0
  213. package/dist/server/methods/session_set_persona.d.ts +3 -0
  214. package/dist/server/methods/session_set_persona.d.ts.map +1 -0
  215. package/dist/server/methods/session_set_persona.js +27 -0
  216. package/dist/server/methods/session_set_persona.js.map +1 -0
  217. package/dist/server/methods/session_set_root.d.ts +3 -0
  218. package/dist/server/methods/session_set_root.d.ts.map +1 -0
  219. package/dist/server/methods/session_set_root.js +33 -0
  220. package/dist/server/methods/session_set_root.js.map +1 -0
  221. package/dist/server/yolo.d.ts +4 -0
  222. package/dist/server/yolo.d.ts.map +1 -0
  223. package/dist/server/yolo.js +48 -0
  224. package/dist/server/yolo.js.map +1 -0
  225. package/migrations/001_sessions.sql +11 -0
  226. package/migrations/002_runs.sql +16 -0
  227. package/migrations/003_loops.sql +12 -0
  228. package/migrations/004_turns.sql +18 -0
  229. package/migrations/005_entries.sql +44 -0
  230. package/migrations/006_log_entries.sql +51 -0
  231. package/migrations/007_visibility.sql +10 -0
  232. package/migrations/008_schemes_providers.sql +25 -0
  233. package/migrations/009_cost_rollups.sql +44 -0
  234. package/migrations/010_subscriptions.sql +36 -0
  235. package/migrations/011_turn_call_metadata.sql +7 -0
  236. package/migrations/012_proposal_lifecycle.sql +22 -0
  237. package/migrations/013_log_entries_lifecycle_columns.sql +22 -0
  238. package/migrations/014_loop_flags.sql +9 -0
  239. package/migrations/015_sessions_project_root.sql +6 -0
  240. package/migrations/016_persona_columns.sql +11 -0
  241. package/package.json +66 -0
  242. package/persona.md +1 -0
@@ -0,0 +1,937 @@
1
+ import { PlurnkParser } from "@plurnk/plurnk-grammar";
2
+ import { Mimetypes, emptyRegistry } from "@plurnk/plurnk-mimetypes";
3
+ import { DEFAULT_LOOP_FLAGS } from "./scheme-types.js";
4
+ // @ts-expect-error -- plain JS module shared with bin/digest.js so wire
5
+ // projection and digest projection are structurally one function.
6
+ import { packetToWireMessages } from "./packet-wire.js";
7
+ // SCHEMES.md §8: writer must be in target scheme's manifest.writableBy.
8
+ // SHOW/HIDE/READ/FIND are not gated — they touch visibility metadata or read.
9
+ const MUTATING_OPS = new Set(["EDIT", "SEND", "COPY", "MOVE", "EXEC"]);
10
+ const DEFAULT_PREVIEW_BUDGET = 256;
11
+ const DEFAULT_MAX_STRIKES = 3;
12
+ const readBudget = () => {
13
+ const raw = process.env.PLURNK_ENTRY_SIZE_DEFAULT_TOKENS;
14
+ if (raw === undefined || raw.length === 0)
15
+ return DEFAULT_PREVIEW_BUDGET;
16
+ const n = Number.parseInt(raw, 10);
17
+ if (!Number.isFinite(n) || n <= 0)
18
+ return DEFAULT_PREVIEW_BUDGET;
19
+ return n;
20
+ };
21
+ const readMaxStrikes = () => {
22
+ const raw = process.env.PLURNK_MAX_STRIKES;
23
+ if (raw === undefined || raw.length === 0)
24
+ return DEFAULT_MAX_STRIKES;
25
+ const n = Number.parseInt(raw, 10);
26
+ if (!Number.isFinite(n) || n < 0)
27
+ return DEFAULT_MAX_STRIKES;
28
+ return n;
29
+ };
30
+ // Resolution timeout — proposed entries auto-cancel if nothing arrives
31
+ // within this window. Per AGENTS.md §Phase E.2.
32
+ const PROPOSAL_TIMEOUT_DEFAULT_MS = 300000;
33
+ const readProposalTimeoutMs = () => {
34
+ const raw = process.env.PLURNK_PROPOSAL_TIMEOUT_MS;
35
+ if (raw === undefined || raw.length === 0)
36
+ return PROPOSAL_TIMEOUT_DEFAULT_MS;
37
+ const n = Number(raw);
38
+ if (!Number.isFinite(n) || n <= 0)
39
+ return PROPOSAL_TIMEOUT_DEFAULT_MS;
40
+ return n;
41
+ };
42
+ const pathnameFromPath = (path) => {
43
+ if (path.kind === "url")
44
+ return path.pathname;
45
+ return path.raw;
46
+ };
47
+ // Default turn.status when ops were emitted but no SEND. Model is implicitly
48
+ // continuing; loop.status stays 102 either way (only SEND broadcast advances
49
+ // loop terminal). No strike, no telemetry.
50
+ const TURN_STATUS_IMPLICIT_CONTINUE = 102;
51
+ // Status assigned to a turn that emitted NO ops at all. Strike-worthy; the
52
+ // action routes through telemetry.errors[] (§15.1).
53
+ const TURN_STATUS_NO_OPS = 422;
54
+ // Rail #38: action-entry statuses that DON'T accumulate strikes. Model adapted
55
+ // to a finding (not_found, op_not_supported); no penalty. Rummy parallel:
56
+ // SOFT_FAILURE_OUTCOMES = {"not_found", "unparsed"}.
57
+ const SOFT_FAILURE_STATUSES = new Set([404, 501]);
58
+ const DEFAULT_MIN_CYCLES = 3;
59
+ const DEFAULT_MAX_CYCLE_PERIOD = 4;
60
+ const readPositiveInt = (envVar, fallback) => {
61
+ const raw = process.env[envVar];
62
+ if (raw === undefined || raw.length === 0)
63
+ return fallback;
64
+ const n = Number.parseInt(raw, 10);
65
+ if (!Number.isFinite(n) || n < 1)
66
+ return fallback;
67
+ return n;
68
+ };
69
+ // Per-op fingerprint: op verb + target URI. Body deliberately excluded so the
70
+ // model writing varied content to the same target still trips. Path kind is
71
+ // included as a discriminator (url vs local). Rummy parallel: scheme +
72
+ // sorted attributes joined by '='.
73
+ const fingerprintOp = (stmt) => {
74
+ const path = stmt.path;
75
+ if (path === null)
76
+ return `${stmt.op}|(no-path)`;
77
+ if (path.kind === "url")
78
+ return `${stmt.op}|${path.scheme}://${path.pathname}`;
79
+ return `${stmt.op}|local:${path.raw}`;
80
+ };
81
+ // Per-turn fingerprint: sorted set of per-op fingerprints, joined. Order
82
+ // within a turn doesn't matter — we want the SET of activities.
83
+ export const fingerprintTurn = (ops) => {
84
+ return ops.map(fingerprintOp).toSorted().join(",");
85
+ };
86
+ // Rail #39 cycle detector. For each candidate period k in [1, maxCyclePeriod],
87
+ // check whether the last k*minCycles entries form minCycles repetitions of the
88
+ // same length-k pattern. O(maxCyclePeriod × minCycles × max k) ≈ tiny. Rummy
89
+ // parallel: src/plugins/error/error.js detectCycle.
90
+ export const detectCycle = (history, minCycles, maxCyclePeriod) => {
91
+ for (let k = 1; k <= maxCyclePeriod; k++) {
92
+ const needed = k * minCycles;
93
+ if (history.length < needed)
94
+ continue;
95
+ const tail = history.slice(-needed);
96
+ const cycle = tail.slice(0, k);
97
+ let match = true;
98
+ outer: for (let rep = 0; rep < minCycles; rep++) {
99
+ for (let j = 0; j < k; j++) {
100
+ if (tail[rep * k + j] !== cycle[j]) {
101
+ match = false;
102
+ break outer;
103
+ }
104
+ }
105
+ }
106
+ if (match)
107
+ return { detected: true, period: k, cycles: minCycles };
108
+ }
109
+ return { detected: false };
110
+ };
111
+ export default class Engine {
112
+ #db;
113
+ #schemes;
114
+ #mimetypes;
115
+ #previewBudget;
116
+ // Per-loop transient buffer of actionless failures pending surface in the
117
+ // NEXT packet's user.telemetry.errors[]. Drained by #buildTelemetryErrors.
118
+ // Map<loopId, TelemetryError[]>. SPEC §15.1.
119
+ #telemetryBuffer = new Map();
120
+ // Rail #38 strike state per loop. `streak` = consecutive struck turns;
121
+ // resets on a clean turn. `turnErrors` is bumped externally by per-turn
122
+ // rails (cycle detection #39, etc.) — read and reset at end of each turn.
123
+ // `history` holds per-turn fingerprints for rail #39 cycle detection.
124
+ #strikeState = new Map();
125
+ // Proposal lifecycle (task #42): pending dispatch pauses waiting for
126
+ // resolution. Engine.runTurn awaits the promise when a scheme returns
127
+ // status 202; Engine.resolveProposal feeds the resolution back in. Map
128
+ // is per-log-entry-id; entries clear on resolution. See AGENTS.md
129
+ // §Phase E for the broader lifecycle plan.
130
+ #pendingProposals = new Map();
131
+ // External observers of proposal lifecycle events. Daemon subscribes
132
+ // here to push `loop/proposal` notifications when an entry enters
133
+ // pending state. YOLO listener (Phase E.3) subscribes here too. Lean
134
+ // event emitter — no priority, no veto chain at this layer; filter
135
+ // chains come later if a real consumer needs them.
136
+ #proposalPendingListeners = [];
137
+ constructor({ db, schemes, mimetypes }) {
138
+ this.#db = db;
139
+ this.#schemes = schemes;
140
+ // Default to empty discovery — standalone Engine construction (in
141
+ // tests) gets no handlers, and content flows through the framework's
142
+ // raw-content fitContent fallback. Daemon-managed Engine receives a
143
+ // production-configured Mimetypes via the constructor arg.
144
+ this.#mimetypes = mimetypes ?? new Mimetypes({
145
+ discovery: { registry: emptyRegistry(), handlers: new Map() },
146
+ });
147
+ this.#previewBudget = readBudget();
148
+ }
149
+ #pushTelemetry(loopId, error) {
150
+ const existing = this.#telemetryBuffer.get(loopId);
151
+ if (existing === undefined)
152
+ this.#telemetryBuffer.set(loopId, [error]);
153
+ else
154
+ existing.push(error);
155
+ }
156
+ #drainTelemetry(loopId) {
157
+ const buf = this.#telemetryBuffer.get(loopId);
158
+ if (buf === undefined)
159
+ return [];
160
+ this.#telemetryBuffer.delete(loopId);
161
+ return buf;
162
+ }
163
+ async runLoop({ provider, messages, persona = "", sessionId, runId, loopId, maxTurns = 50, maxStrikes = readMaxStrikes(), minCycles = readPositiveInt("PLURNK_MIN_CYCLES", DEFAULT_MIN_CYCLES), maxCyclePeriod = readPositiveInt("PLURNK_MAX_CYCLE_PERIOD", DEFAULT_MAX_CYCLE_PERIOD), origin = "model", signal, onDispatch, }) {
164
+ const turnIds = [];
165
+ const suddenDeathThreshold = maxTurns - maxStrikes;
166
+ const cleanup = () => {
167
+ this.#strikeState.delete(loopId);
168
+ this.#telemetryBuffer.delete(loopId);
169
+ };
170
+ while (true) {
171
+ signal?.throwIfAborted();
172
+ const row = await this.#db.engine_loop_status.get({ loop_id: loopId });
173
+ if (row === undefined)
174
+ throw new Error(`Engine.runLoop: loop ${loopId} not found`);
175
+ if (row.status !== 102) {
176
+ cleanup();
177
+ return { turnIds, finalStatus: row.status, hitMaxTurns: false, reason: "external" };
178
+ }
179
+ if (turnIds.length >= maxTurns) {
180
+ await this.#db.engine_loop_cancel.run({ loop_id: loopId });
181
+ cleanup();
182
+ return { turnIds, finalStatus: 499, hitMaxTurns: true, reason: "max_turns" };
183
+ }
184
+ const turn = await this.runTurn({
185
+ provider, messages, persona, sessionId, runId, loopId, origin, signal, onDispatch,
186
+ turnNumber: turnIds.length + 1, maxTurns,
187
+ });
188
+ turnIds.push(turn.turnId);
189
+ // Rail #39: cycle detection. Push this turn's fingerprint to
190
+ // history, scan for repetition patterns. Detection bumps
191
+ // turnErrors so the strike system handles abandonment naturally.
192
+ const state = this.#strikeState.get(loopId) ?? { streak: 0, turnErrors: 0, history: [] };
193
+ state.history.push(turn.fingerprint);
194
+ const cycle = detectCycle(state.history, minCycles, maxCyclePeriod);
195
+ if (cycle.detected) {
196
+ state.turnErrors++;
197
+ this.#pushTelemetry(loopId, {
198
+ kind: "cycle",
199
+ period: cycle.period,
200
+ cycles: cycle.cycles,
201
+ message: `repeating pattern detected: ${cycle.cycles}× period-${cycle.period}; vary your approach`,
202
+ });
203
+ }
204
+ this.#strikeState.set(loopId, state);
205
+ // Rail #38: strike accounting. Three sources strike a turn:
206
+ // 1. recordedFailed — any action-entry at hard failure status
207
+ // (>= 400 and not in SOFT_FAILURE_STATUSES).
208
+ // 2. noOps — turn.status === TURN_STATUS_NO_OPS (per #41).
209
+ // 3. turnErrors — externally bumped by per-turn rails (#39 cycle).
210
+ // Struck → streak++; clean → streak = 0. Threshold → abandon.
211
+ const recordedFailed = turn.statuses.some((s) => s >= 400 && !SOFT_FAILURE_STATUSES.has(s));
212
+ const noOps = turn.status === TURN_STATUS_NO_OPS;
213
+ const struck = noOps || recordedFailed || state.turnErrors > 0;
214
+ if (struck) {
215
+ state.streak++;
216
+ this.#pushTelemetry(loopId, {
217
+ kind: "strike",
218
+ streak: state.streak,
219
+ maxStrikes,
220
+ reason: noOps ? "no_ops" : recordedFailed ? "recorded_failure" : "rail",
221
+ });
222
+ if (state.streak >= maxStrikes) {
223
+ await this.#db.engine_loop_cancel.run({ loop_id: loopId });
224
+ cleanup();
225
+ return { turnIds, finalStatus: 499, hitMaxTurns: false, reason: "strike_threshold" };
226
+ }
227
+ }
228
+ else {
229
+ state.streak = 0;
230
+ }
231
+ state.turnErrors = 0;
232
+ this.#strikeState.set(loopId, state);
233
+ // Rail #40: sudden-death soft warning. When the loop enters the
234
+ // last maxStrikes-sized window before maxTurns, push a warning
235
+ // each turn so the model can wrap up before the hard cancel.
236
+ // Soft: no strike, no loop-status change. SPEC §15.1.
237
+ if (turnIds.length >= suddenDeathThreshold && turnIds.length < maxTurns) {
238
+ this.#pushTelemetry(loopId, {
239
+ kind: "sudden_death",
240
+ message: `approaching max turns: ${turnIds.length} of ${maxTurns}; emit SEND[200] to complete`,
241
+ remaining: maxTurns - turnIds.length,
242
+ });
243
+ }
244
+ }
245
+ }
246
+ async runTurn({ provider, messages, persona = "", sessionId, runId, loopId, origin = "model", signal, onDispatch, turnNumber = 1, maxTurns = 50, }) {
247
+ // Build the spec'd packet (Packet.json) request half BEFORE the
248
+ // provider call. The wire payload is a projection OF this packet;
249
+ // the stored packet is the same object completed with the
250
+ // assistant section after the response arrives.
251
+ const requestPacket = await this.#buildRequestPacket({
252
+ initialMessages: messages, persona, runId, loopId, turnNumber, maxTurns, provider,
253
+ });
254
+ const modelMessages = this.#packetToWireMessages(requestPacket);
255
+ const response = await provider.generate({ messages: modelMessages, signal });
256
+ // Engine splits wire-level response: emission (content, reasoning,
257
+ // parsed ops) → packet.assistant per Packet.json §assistant;
258
+ // call-metadata (usage, finishReason, model) → Turn columns per
259
+ // Turn.json. Mixing the two on packet.assistant was the wrong layer.
260
+ const { packetAssistant, callMetadata } = this.#splitResponse(response);
261
+ const opsCount = packetAssistant.ops.length;
262
+ const sendOp = packetAssistant.ops.findLast((op) => op.op === "SEND" && typeof op.signal === "number");
263
+ // Rail #41 (revised): the per-turn requirement is "emit at least one
264
+ // op," not "emit a terminal SEND." SEND is purely a signal verb; many
265
+ // turns may pass without one. An empty op list is the only strike.
266
+ const turnStatus = sendOp !== undefined
267
+ ? sendOp.signal
268
+ : opsCount === 0 ? TURN_STATUS_NO_OPS : TURN_STATUS_IMPLICIT_CONTINUE;
269
+ const seqRow = await this.#db.engine_next_turn_sequence.get({ loop_id: loopId });
270
+ const seq = seqRow.next;
271
+ // Complete the spec'd packet by adding the response section.
272
+ // requestPacket already has system + user matching what was sent
273
+ // to the LLM (one source of truth across wire payload and storage).
274
+ const packet = this.#completePacket(requestPacket, packetAssistant, response.assistantRaw, provider);
275
+ const { usage, finishReason, model } = callMetadata;
276
+ const turnRow = await this.#db.engine_insert_turn.get({
277
+ loop_id: loopId,
278
+ sequence: seq,
279
+ status: turnStatus,
280
+ packet: JSON.stringify(packet),
281
+ usage_prompt: usage.prompt,
282
+ usage_completion: usage.completion,
283
+ usage_cached: usage.cached,
284
+ usage_cost_pico: provider.costFor(usage),
285
+ finish_reason: finishReason,
286
+ model,
287
+ });
288
+ if (turnRow === undefined)
289
+ throw new Error("Engine.runTurn: turn insert returned no row");
290
+ const turnId = turnRow.id;
291
+ const statuses = [];
292
+ for (const [actionIndex, statement] of packetAssistant.ops.entries()) {
293
+ const result = await this.dispatch({
294
+ statement, sessionId, runId, loopId, turnId, actionIndex, origin, onDispatch,
295
+ });
296
+ statuses.push(result.status);
297
+ }
298
+ if (opsCount === 0) {
299
+ // Rail #41 (revised): per-turn requirement is "emit at least one
300
+ // op." Zero ops = actionless failure. SEND specifically is not
301
+ // required — any of the 9 grammar ops satisfies. Pushed AFTER
302
+ // #buildPacket so this turn's drain doesn't consume it.
303
+ this.#pushTelemetry(loopId, {
304
+ kind: "no_ops",
305
+ message: "turn ended without emitting any op; emit at least one operation per turn",
306
+ });
307
+ }
308
+ return { turnId, status: turnStatus, statuses, fingerprint: fingerprintTurn(packetAssistant.ops) };
309
+ }
310
+ // Split the wire-level ProviderResponse into the two destinations:
311
+ // packet.assistant gets the model's emission (content, ops, reasoning);
312
+ // Turn columns get the call-metadata (usage, finishReason, model).
313
+ // PROVIDERS.md §3.3 text-fragment scraping policy lives here — engine
314
+ // owns the parse and the scraping rule, providers stay grammar-unaware.
315
+ //
316
+ // Test-fixture escape hatch: the Mock provider may pre-supply `ops` on
317
+ // its assistant payload to skip the parse roundtrip. The wire Provider
318
+ // contract has no `ops` field; only Mock exposes one. Real providers
319
+ // always take the parse path because their `assistant.ops` is undefined.
320
+ #splitResponse(response) {
321
+ const { assistant } = response;
322
+ const preParsedOps = assistant.ops;
323
+ const ops = [];
324
+ const textFragments = [];
325
+ if (preParsedOps !== undefined) {
326
+ ops.push(...preParsedOps);
327
+ }
328
+ else {
329
+ const parsed = PlurnkParser.parse(assistant.content);
330
+ for (const item of parsed.items) {
331
+ if (item.kind === "statement")
332
+ ops.push(item.statement);
333
+ else if (item.kind === "text") {
334
+ const trimmed = item.text.trim();
335
+ if (trimmed.length > 0)
336
+ textFragments.push(trimmed);
337
+ }
338
+ }
339
+ }
340
+ const wireReasoning = assistant.reasoning ?? "";
341
+ const scrapedReasoning = textFragments.join("\n");
342
+ const reasoningParts = [wireReasoning, scrapedReasoning].filter((s) => s.length > 0);
343
+ const reasoning = reasoningParts.length > 0 ? reasoningParts.join("\n\n") : null;
344
+ return {
345
+ packetAssistant: { content: assistant.content, ops, reasoning },
346
+ callMetadata: { usage: assistant.usage, finishReason: assistant.finishReason, model: assistant.model },
347
+ };
348
+ }
349
+ // Assemble the request half of the spec'd packet (Packet.json §system
350
+ // and §user) BEFORE the provider call. The same packet object is then
351
+ // completed with assistant + assistantRaw after the model responds, so
352
+ // the stored packet and the wire payload share one source of truth.
353
+ // Per Packet.json: user.prompt is "Copy of loop.prompt — never null on
354
+ // a continuation turn"; the turn-N-of-M continuation marker rides on
355
+ // user.system_requirements (per-turn rules), NOT a mutated prompt.
356
+ async #buildRequestPacket({ initialMessages, persona: defaultPersona, runId, loopId, turnNumber, maxTurns, provider, }) {
357
+ const byRole = (role) => initialMessages.filter((m) => m.role === role).map((m) => m.content).join("\n\n");
358
+ const system_definition = byRole("system");
359
+ const prompt = byRole("user");
360
+ // Resolve persona cascade: loops.persona > runs.persona >
361
+ // sessions.persona > caller-supplied default. SQL coalesces in one
362
+ // query; null result means no DB override exists, use the default.
363
+ const row = await this.#db.engine_resolve_persona.get({ loop_id: loopId });
364
+ const persona = (row?.persona !== undefined && row?.persona !== null) ? row.persona : defaultPersona;
365
+ const index = await this.#buildIndex(runId);
366
+ const log = await this.#buildLog(loopId);
367
+ const telemetryErrors = await this.#buildTelemetryErrors(loopId);
368
+ // Rummy AgentLoop.js #buildContinuationPrompt: literally
369
+ // `Turn ${turn}/${maxTurns}`. That's the whole string. The model
370
+ // can read the action log to see what it already did; it does
371
+ // not need editorial instructions from us about not repeating.
372
+ const system_requirements = turnNumber > 1
373
+ ? `Turn ${turnNumber}/${maxTurns}`
374
+ : "";
375
+ // Per-section render-cost subtotals via provider's tokenizer.
376
+ // Engine approximates each section by tokenizing its serialized
377
+ // form — wire-payload tokens may differ slightly because chat-
378
+ // template scaffolding adds bytes, but the subtotal tracks "what
379
+ // the model has to process" closely enough for budget diagnostics.
380
+ const systemTokens = provider.countTokens(system_definition) +
381
+ provider.countTokens(persona) +
382
+ provider.countTokens(JSON.stringify(index)) +
383
+ provider.countTokens(JSON.stringify(log));
384
+ const userTokens = provider.countTokens(prompt) +
385
+ provider.countTokens(system_requirements) +
386
+ provider.countTokens(JSON.stringify(telemetryErrors));
387
+ return {
388
+ system: {
389
+ tokens: systemTokens,
390
+ system_definition,
391
+ persona,
392
+ index,
393
+ log,
394
+ },
395
+ user: {
396
+ tokens: userTokens,
397
+ prompt,
398
+ telemetry: { budget: "", errors: telemetryErrors },
399
+ system_requirements,
400
+ },
401
+ };
402
+ }
403
+ // Wire projection lives in ./packet-wire.js (plain JS) so Engine and
404
+ // bin/digest.js import the exact same function — structurally one
405
+ // implementation, no drift between wire and digest possible.
406
+ // Format: markdown (user pick over rummy's XML alternative, 2026-05-22).
407
+ #packetToWireMessages(packet) {
408
+ return packetToWireMessages(packet);
409
+ }
410
+ // Complete the packet by adding the model's response. After this the
411
+ // packet matches Packet.json fully and is ready for storage.
412
+ #completePacket(requestPacket, assistant, assistantRaw, provider) {
413
+ const assistantTokens = provider.countTokens(assistant.content);
414
+ return {
415
+ tokens: requestPacket.system.tokens + requestPacket.user.tokens + assistantTokens,
416
+ system: requestPacket.system,
417
+ user: requestPacket.user,
418
+ assistant,
419
+ assistantRaw,
420
+ };
421
+ }
422
+ // Render-time mimetype invocation (SPEC §4 {§4-handlers-fire-render-time},
423
+ // §5.1 {§5.1-preview-is-handler-output}). For each (run, entry, channel)
424
+ // with indexed=1, pass the channel's current content through
425
+ // mimetype.preview(content, budget). State is included verbatim — engine
426
+ // does NOT branch on it (§5.6 {§5.6-engine-does-not-branch-on-state}).
427
+ // SPEC §15.1: model-facing alert surface.
428
+ // Two sources, merged on each packet build:
429
+ // 1. Previous-turn action-bound failures (status_rx >= 400 on log_entries).
430
+ // 2. Engine-buffered actionless failures (no_send, parse, watchdog, rails).
431
+ // Buffer drains on read — each error appears in exactly one packet.
432
+ async #buildTelemetryErrors(loopId) {
433
+ const rows = await this.#db.engine_render_telemetry_errors.all({ loop_id: loopId });
434
+ const actionFailures = rows.map((r) => {
435
+ const target = r.target_scheme !== null
436
+ ? `${r.target_scheme}://${r.target_pathname ?? ""}`
437
+ : (r.target_pathname ?? null);
438
+ const parsedRx = r.mimetype_rx === "application/json" ? JSON.parse(r.rx) : r.rx;
439
+ return {
440
+ kind: "action_failure",
441
+ coordinate: `${r.loop_seq}/${r.turn_seq}/${r.action_index}`,
442
+ op: r.op,
443
+ target,
444
+ status: r.status_rx,
445
+ message: typeof parsedRx === "object" && parsedRx !== null && "error" in parsedRx
446
+ ? parsedRx.error
447
+ : typeof parsedRx === "string" ? parsedRx : "",
448
+ };
449
+ });
450
+ return [...this.#drainTelemetry(loopId), ...actionFailures];
451
+ }
452
+ // SPEC §15 packet.system.log — chronological action-entries for the loop.
453
+ // Snapshot is taken at packet build (pre-dispatch this turn), so it
454
+ // reflects "what has happened before this turn." Each row carries a
455
+ // log://<loop_seq>/<turn_seq>/<action_index> coordinate the model can READ.
456
+ async #buildLog(loopId) {
457
+ const rows = await this.#db.engine_render_log.all({ loop_id: loopId });
458
+ return rows.map((r) => ({
459
+ coordinate: `${r.loop_seq}/${r.turn_seq}/${r.action_index}`,
460
+ origin: r.origin,
461
+ op: r.op,
462
+ suffix: r.suffix,
463
+ signal: r.signal === null ? null : JSON.parse(r.signal),
464
+ target: {
465
+ scheme: r.target_scheme,
466
+ username: r.target_username, password: r.target_password,
467
+ hostname: r.target_hostname, port: r.target_port,
468
+ pathname: r.target_pathname,
469
+ params: r.target_params === null ? null : JSON.parse(r.target_params),
470
+ fragment: r.target_fragment,
471
+ },
472
+ status: r.status_rx,
473
+ rx: r.mimetype_rx === "application/json" ? JSON.parse(r.rx) : r.rx,
474
+ mimetype_rx: r.mimetype_rx,
475
+ }));
476
+ }
477
+ async #buildIndex(runId) {
478
+ const rows = await this.#db.engine_render_index.all({ run_id: runId });
479
+ const tagsStmt = this.#db.engine_entry_tags;
480
+ const entries = new Map();
481
+ for (const row of rows) {
482
+ let entry = entries.get(row.entry_id);
483
+ if (entry === undefined) {
484
+ const tagRows = await tagsStmt.all({ entry_id: row.entry_id });
485
+ entry = {
486
+ id: row.entry_id,
487
+ version: row.version,
488
+ scope: row.scope,
489
+ session_id: row.session_id,
490
+ scheme: row.scheme,
491
+ username: row.username,
492
+ password: row.password,
493
+ hostname: row.hostname,
494
+ port: row.port,
495
+ pathname: row.pathname,
496
+ params: row.params === null ? null : JSON.parse(row.params),
497
+ channels: {},
498
+ attributes: JSON.parse(row.attributes),
499
+ tags: tagRows.map((r) => r.tag),
500
+ };
501
+ entries.set(row.entry_id, entry);
502
+ }
503
+ // Mimetypes.process owns the full preview pipeline: detect (or
504
+ // honor the hint), resolve handler, validate, extract → symbols,
505
+ // budget-truncate via the framework's fit/fitContent. Passing
506
+ // `hint: row.mimetype` short-circuits detection — service already
507
+ // knows what each channel is.
508
+ const result = await this.#mimetypes.process({ content: row.content, hint: row.mimetype }, { budget: this.#previewBudget });
509
+ entry.channels[row.channel] = {
510
+ content: result.preview,
511
+ mimetype: row.mimetype,
512
+ tokens: row.tokens,
513
+ };
514
+ }
515
+ return [...entries.values()];
516
+ }
517
+ async dispatch(context) {
518
+ const { statement, sessionId, runId, loopId, turnId, actionIndex, origin, onDispatch } = context;
519
+ const schemeCtx = {
520
+ db: this.#db,
521
+ sessionId, runId, loopId, turnId,
522
+ writer: origin,
523
+ signal: undefined,
524
+ };
525
+ let result;
526
+ const denial = this.#checkWritable(statement, origin);
527
+ if (denial !== null) {
528
+ result = denial;
529
+ }
530
+ else {
531
+ // SCHEMES.md §7.1 / §8: action-entry-as-outcome. Scheme-handler
532
+ // exceptions become the action-entry's outcome (status 500), not a
533
+ // thrown bubble. The log_entry is the durable record; engine never
534
+ // skips it. Logging failures (#writeLog throws) are NOT caught —
535
+ // those are system failures.
536
+ try {
537
+ if (statement.op === "SEND" && statement.path === null) {
538
+ result = await this.#handleSendBroadcast(statement, loopId);
539
+ }
540
+ else if (statement.op === "COPY") {
541
+ result = await this.#handleCopy(statement, schemeCtx);
542
+ }
543
+ else if (statement.op === "MOVE") {
544
+ result = await this.#handleMove(statement, schemeCtx);
545
+ }
546
+ else {
547
+ result = await this.#run(this.#schemeNameOf(statement.path), statement, schemeCtx);
548
+ }
549
+ }
550
+ catch (err) {
551
+ result = {
552
+ status: 500,
553
+ error: err instanceof Error ? err.message : String(err),
554
+ };
555
+ }
556
+ }
557
+ const logEntryId = await this.#writeLog({ statement, result, runId, loopId, turnId, actionIndex, origin });
558
+ onDispatch?.(logEntryId);
559
+ // Proposal lifecycle (task #42, AGENTS.md §Phase E). When a scheme
560
+ // returns status 202, the entry is written as state='proposed';
561
+ // dispatch then PAUSES on a per-entry waiter until resolution
562
+ // arrives via Engine.resolveProposal (from the loop/resolve RPC,
563
+ // YOLO listener, or timeout — Phase E.2/E.3 work). The post-
564
+ // resolution status replaces 202 in the result the caller sees,
565
+ // so runTurn never branches on a pending state.
566
+ if (result.status === 202) {
567
+ // Register the resolution waiter SYNCHRONOUSLY before any await
568
+ // yields. A same-tick resolveProposal() (e.g. from a test that
569
+ // awaits the onDispatch callback and immediately resolves) must
570
+ // find the waiter registered — adding an await between insert
571
+ // and waiter-registration would open a race window.
572
+ const resolutionPromise = this.#awaitResolution(logEntryId);
573
+ // Notify external listeners (Daemon broadcasts loop/proposal;
574
+ // YOLO listener auto-resolves) BEFORE awaiting — they may
575
+ // resolve synchronously inside their handlers.
576
+ const target = this.#extractTarget(statement.path);
577
+ const flags = await this.#loadLoopFlags(loopId);
578
+ const event = {
579
+ logEntryId, sessionId, runId, loopId, turnId,
580
+ op: statement.op,
581
+ target: { scheme: target.scheme, pathname: target.pathname },
582
+ body: typeof result.body === "string" ? result.body : "",
583
+ attrs: (result.attrs ?? {}),
584
+ flags,
585
+ };
586
+ for (const listener of this.#proposalPendingListeners) {
587
+ try {
588
+ listener(event);
589
+ }
590
+ catch (_) { /* listener errors don't break dispatch */ }
591
+ }
592
+ const resolution = await resolutionPromise;
593
+ // Run the scheme's applyResolution hook on accept (writes the
594
+ // file, spawns the process, etc.). If applyResolution returns a
595
+ // 4xx/5xx or throws, the resolution is downgraded to a reject
596
+ // with the failure outcome — engine treats it like a client
597
+ // rejection.
598
+ const effective = await this.#runApplyResolution(statement, result, resolution);
599
+ const post = await this.#applyResolution(logEntryId, effective);
600
+ return post;
601
+ }
602
+ return result;
603
+ }
604
+ async #runApplyResolution(statement, originalResult, resolution) {
605
+ if (resolution.decision !== "accept")
606
+ return resolution;
607
+ const schemeName = this.#schemeNameOf(statement.path);
608
+ if (schemeName === null)
609
+ return resolution;
610
+ const handler = this.#schemes.get(schemeName);
611
+ if (handler === undefined || typeof handler.applyResolution !== "function")
612
+ return resolution;
613
+ try {
614
+ const applyResult = await handler.applyResolution({
615
+ attrs: (originalResult.attrs ?? {}),
616
+ body: resolution.body,
617
+ });
618
+ if (applyResult.status >= 400) {
619
+ return {
620
+ decision: "reject",
621
+ outcome: applyResult.outcome ?? "apply_failed",
622
+ body: applyResult.body,
623
+ };
624
+ }
625
+ return resolution;
626
+ }
627
+ catch (err) {
628
+ return {
629
+ decision: "reject",
630
+ outcome: "apply_threw",
631
+ body: err instanceof Error ? err.message : String(err),
632
+ };
633
+ }
634
+ }
635
+ // Engine.resolveProposal: external API to feed a resolution into a
636
+ // pending proposal. Called by the loop/resolve RPC handler (Phase E.2),
637
+ // the in-tree YOLO listener (Phase E.3), or the timeout watcher. Throws
638
+ // when the logEntryId has no pending waiter — duplicate resolutions, IDs
639
+ // for non-proposed entries, or entries already-resolved are caller
640
+ // errors.
641
+ resolveProposal(logEntryId, resolution) {
642
+ const waiter = this.#pendingProposals.get(logEntryId);
643
+ if (waiter === undefined) {
644
+ throw new Error(`Engine.resolveProposal: no pending proposal for log_entry ${logEntryId}`);
645
+ }
646
+ clearTimeout(waiter.timeoutHandle);
647
+ this.#pendingProposals.delete(logEntryId);
648
+ waiter.resolve(resolution);
649
+ }
650
+ // Snapshot of pending proposals (for diagnostic / RPC listings). Returns
651
+ // the log entry IDs currently awaiting resolution.
652
+ pendingProposalIds() {
653
+ return [...this.#pendingProposals.keys()];
654
+ }
655
+ // Subscribe to proposal-pending events. Daemon registers a listener
656
+ // that broadcasts the loop/proposal WS notification; YOLO listener
657
+ // (Phase E.3) registers one that auto-resolves. Listeners fire BEFORE
658
+ // dispatch awaits resolution, so synchronous (or fast-async) handlers
659
+ // can resolve inline.
660
+ onProposalPending(listener) {
661
+ this.#proposalPendingListeners.push(listener);
662
+ }
663
+ // Loads loops.flags (json column) and merges over DEFAULT_LOOP_FLAGS so
664
+ // missing keys read as their documented defaults. Single read site —
665
+ // ProposalPendingEvent.flags is constructed from this, and listeners
666
+ // (Daemon broadcast, YOLO auto-accept) share the result.
667
+ async #loadLoopFlags(loopId) {
668
+ const row = await this.#db.engine_get_loop_flags.get({ loop_id: loopId });
669
+ if (row === undefined)
670
+ return DEFAULT_LOOP_FLAGS;
671
+ try {
672
+ const parsed = JSON.parse(row.flags);
673
+ return { ...DEFAULT_LOOP_FLAGS, ...parsed };
674
+ }
675
+ catch {
676
+ return DEFAULT_LOOP_FLAGS;
677
+ }
678
+ }
679
+ #awaitResolution(logEntryId) {
680
+ const timeoutMs = readProposalTimeoutMs();
681
+ return new Promise((resolve) => {
682
+ const timeoutHandle = setTimeout(() => {
683
+ // Timeout: synthesize a cancel resolution and feed it back
684
+ // through the same path as any other resolution. State
685
+ // transitions to cancelled with outcome='timeout'.
686
+ if (this.#pendingProposals.has(logEntryId)) {
687
+ this.#pendingProposals.delete(logEntryId);
688
+ resolve({ decision: "cancel", outcome: "timeout" });
689
+ }
690
+ }, timeoutMs);
691
+ this.#pendingProposals.set(logEntryId, { resolve, timeoutHandle });
692
+ });
693
+ }
694
+ async #applyResolution(logEntryId, resolution) {
695
+ // Map decision → terminal state + HTTP-aligned status:
696
+ // accept → state='resolved', status=200
697
+ // reject → state='failed', status=400, outcome='rejected' (default)
698
+ // cancel → state='cancelled',status=499, outcome='loop_aborted' (default)
699
+ // resolution.outcome wins over the default when supplied; this is how
700
+ // veto filters (Phase E.2 proposal.accepting) can specify a more
701
+ // precise outcome string like 'policy_veto' or 'timeout'.
702
+ const decision = resolution.decision;
703
+ const state = decision === "accept" ? "resolved"
704
+ : decision === "reject" ? "failed"
705
+ : "cancelled";
706
+ const status = decision === "accept" ? 200
707
+ : decision === "reject" ? 400
708
+ : 499;
709
+ const defaultOutcome = decision === "accept" ? null
710
+ : decision === "reject" ? "rejected"
711
+ : "loop_aborted";
712
+ const outcome = resolution.outcome ?? defaultOutcome;
713
+ const rx = JSON.stringify({ status, outcome, body: resolution.body ?? null });
714
+ await this.#db.engine_resolve_log_entry.run({
715
+ id: logEntryId, state, outcome, status_rx: status, rx,
716
+ });
717
+ return { status, outcome, body: resolution.body };
718
+ }
719
+ // SCHEMES.md §8 {§8-writable-by-enforcement}: engine rejects writes whose
720
+ // origin is outside the target scheme's manifest.writableBy.
721
+ // - Read-side ops (READ, FIND, SHOW, HIDE) are not gated.
722
+ // - SEND broadcast (path=null) has no target scheme; not gated.
723
+ // - COPY: dst scheme writableBy applies.
724
+ // - MOVE: both src (delete) and dst (write) schemes' writableBy apply.
725
+ // - Schemes without a manifest are not gated (legacy / future allowance).
726
+ #checkWritable(statement, origin) {
727
+ if (!MUTATING_OPS.has(statement.op))
728
+ return null;
729
+ if (statement.op === "SEND" && statement.path === null)
730
+ return null;
731
+ if (statement.op === "COPY" || statement.op === "MOVE") {
732
+ const dstScheme = this.#schemeNameOf(statement.body);
733
+ const dstDenial = this.#denyIfDisallowed(dstScheme, origin);
734
+ if (dstDenial !== null)
735
+ return dstDenial;
736
+ if (statement.op === "MOVE") {
737
+ const srcScheme = this.#schemeNameOf(statement.path);
738
+ if (srcScheme !== dstScheme) {
739
+ const srcDenial = this.#denyIfDisallowed(srcScheme, origin);
740
+ if (srcDenial !== null)
741
+ return srcDenial;
742
+ }
743
+ }
744
+ return null;
745
+ }
746
+ const target = this.#schemeNameOf(statement.path);
747
+ return this.#denyIfDisallowed(target, origin);
748
+ }
749
+ #denyIfDisallowed(schemeName, origin) {
750
+ if (schemeName === null)
751
+ return null;
752
+ const handler = this.#schemes.get(schemeName);
753
+ if (handler === undefined)
754
+ return null;
755
+ const manifest = handler.constructor.manifest;
756
+ if (manifest === undefined)
757
+ return null;
758
+ if (manifest.writableBy.includes(origin))
759
+ return null;
760
+ return { status: 403, error: `writer '${origin}' is not in writableBy for scheme '${schemeName}'` };
761
+ }
762
+ async #handleCopy(statement, ctx) {
763
+ if (statement.op !== "COPY")
764
+ throw new Error("unreachable");
765
+ const srcPath = statement.path;
766
+ const dstPath = statement.body;
767
+ if (srcPath === null)
768
+ return { status: 400, error: "COPY requires source path" };
769
+ if (dstPath === null)
770
+ return { status: 400, error: "COPY requires destination path (in body slot)" };
771
+ return await this.#copyOrchestration({ statement, srcPath, dstPath, ctx });
772
+ }
773
+ async #handleMove(statement, ctx) {
774
+ if (statement.op !== "MOVE")
775
+ throw new Error("unreachable");
776
+ const srcPath = statement.path;
777
+ const dstPath = statement.body;
778
+ if (srcPath === null)
779
+ return { status: 400, error: "MOVE requires source path" };
780
+ const srcSchemeName = this.#schemeNameOf(srcPath);
781
+ if (srcSchemeName === null)
782
+ return { status: 400, error: "MOVE source must be a URL path with a scheme" };
783
+ const srcHandler = this.#schemes.get(srcSchemeName);
784
+ if (srcHandler === undefined || typeof srcHandler.deleteEntry !== "function")
785
+ return { status: 501 };
786
+ // Null-body MOVE = delete the source entry (per SPEC §6.5)
787
+ if (dstPath === null) {
788
+ const srcPathname = pathnameFromPath(srcPath);
789
+ const delResult = await srcHandler.deleteEntry(srcPathname, ctx);
790
+ return { status: delResult.status };
791
+ }
792
+ // Relocation: COPY then DELETE source
793
+ const copyResult = await this.#copyOrchestration({ statement, srcPath, dstPath, ctx });
794
+ if (copyResult.status >= 400)
795
+ return copyResult;
796
+ const srcPathname = pathnameFromPath(srcPath);
797
+ const delResult = await srcHandler.deleteEntry(srcPathname, ctx);
798
+ if (delResult.status >= 400)
799
+ return { status: delResult.status };
800
+ return copyResult;
801
+ }
802
+ async #copyOrchestration({ statement, srcPath, dstPath, ctx }) {
803
+ const srcSchemeName = this.#schemeNameOf(srcPath);
804
+ const dstSchemeName = this.#schemeNameOf(dstPath);
805
+ if (srcSchemeName === null || dstSchemeName === null)
806
+ return { status: 400, error: "COPY/MOVE require URL paths with schemes" };
807
+ const srcHandler = this.#schemes.get(srcSchemeName);
808
+ const dstHandler = this.#schemes.get(dstSchemeName);
809
+ if (srcHandler === undefined || dstHandler === undefined)
810
+ return { status: 501 };
811
+ if (typeof srcHandler.readEntry !== "function" || typeof dstHandler.writeEntry !== "function")
812
+ return { status: 501 };
813
+ const srcPathname = pathnameFromPath(srcPath);
814
+ const dstPathname = pathnameFromPath(dstPath);
815
+ const srcResult = await srcHandler.readEntry(srcPathname, ctx);
816
+ if (srcResult.status !== 200 || srcResult.entry === null)
817
+ return { status: 404, error: `COPY/MOVE source not found: ${srcSchemeName}://${srcPathname}` };
818
+ const entry = srcResult.entry;
819
+ // Conflict check on destination
820
+ if (typeof dstHandler.readEntry === "function") {
821
+ const dstExists = await dstHandler.readEntry(dstPathname, ctx);
822
+ if (dstExists.status === 200)
823
+ return { status: 409, error: `COPY/MOVE destination exists: ${dstSchemeName}://${dstPathname}` };
824
+ }
825
+ // Mimetype compatibility check against the destination scheme's manifest
826
+ const dstManifest = dstHandler.constructor.manifest;
827
+ const dstChannels = dstManifest?.channels ?? {};
828
+ for (const [channelName, channelData] of Object.entries(entry.channels)) {
829
+ const expectedMimetype = dstChannels[channelName];
830
+ if (expectedMimetype !== undefined && expectedMimetype !== channelData.mimetype) {
831
+ return { status: 415, error: `mimetype mismatch on channel '${channelName}': ${channelData.mimetype} vs ${expectedMimetype}` };
832
+ }
833
+ }
834
+ // Tag resolution: signal = replace; absent/empty = carry from source
835
+ const tags = (Array.isArray(statement.signal) && statement.signal.length > 0)
836
+ ? statement.signal
837
+ : entry.tags;
838
+ const writeResult = await dstHandler.writeEntry(dstPathname, { channels: entry.channels, tags }, ctx);
839
+ return { status: writeResult.status, entryId: writeResult.entryId, created: writeResult.created };
840
+ }
841
+ async #handleSendBroadcast(statement, loopId) {
842
+ if (statement.op !== "SEND")
843
+ throw new Error("unreachable");
844
+ const status = statement.signal;
845
+ if (status === null)
846
+ return { status: 400 };
847
+ if (status === 200 || status === 499) {
848
+ await this.#db.engine_loop_set_status.run({ status, loop_id: loopId });
849
+ }
850
+ return { status };
851
+ }
852
+ async #run(schemeName, statement, ctx) {
853
+ if (schemeName === null)
854
+ return { status: 400 };
855
+ const handler = this.#schemes.get(schemeName);
856
+ if (handler === undefined)
857
+ return { status: 501 };
858
+ const methodName = statement.op.toLowerCase();
859
+ const method = handler[methodName];
860
+ if (typeof method !== "function")
861
+ return { status: 501 };
862
+ return method.call(handler, statement, ctx);
863
+ }
864
+ // Bare paths default to the file scheme per plurnk.md (grammar sysprompt):
865
+ // "Bare paths (no scheme) default to local relative project file paths."
866
+ // file:// remains an optional explicit form for absolute paths.
867
+ #schemeNameOf(path) {
868
+ if (path === null)
869
+ return null;
870
+ if (path.kind === "url")
871
+ return path.scheme;
872
+ return "file"; // local (bare) → file
873
+ }
874
+ async #writeLog({ statement, result, runId, loopId, turnId, actionIndex, origin, }) {
875
+ const target = this.#extractTarget(statement.path);
876
+ const lineMarkerJson = "lineMarker" in statement && statement.lineMarker !== null
877
+ ? JSON.stringify(statement.lineMarker)
878
+ : null;
879
+ // Status 202 from a scheme means the action is proposed — written to
880
+ // the log in state='proposed' until the proposal lifecycle resolves
881
+ // it. attrs holds the scheme-supplied payload (file diff, exec
882
+ // command, etc.) that the client renders for review and the scheme
883
+ // consumes on accept. All other statuses are terminal — state =
884
+ // 'resolved' for the common case.
885
+ const isProposed = result.status === 202;
886
+ const attrs = (result.attrs !== undefined && result.attrs !== null)
887
+ ? JSON.stringify(result.attrs)
888
+ : "{}";
889
+ const row = await this.#db.engine_insert_log_entry.get({
890
+ run_id: runId,
891
+ loop_id: loopId,
892
+ turn_id: turnId,
893
+ action_index: actionIndex,
894
+ origin,
895
+ op: statement.op,
896
+ suffix: statement.suffix,
897
+ signal: this.#signalToJson(statement.signal),
898
+ target_scheme: target.scheme,
899
+ target_username: target.username,
900
+ target_password: target.password,
901
+ target_hostname: target.hostname,
902
+ target_port: target.port,
903
+ target_pathname: target.pathname,
904
+ target_params: target.params,
905
+ target_fragment: target.fragment,
906
+ lineMarker: lineMarkerJson,
907
+ tx: JSON.stringify(statement),
908
+ mimetype_tx: "application/json",
909
+ rx: JSON.stringify(result),
910
+ mimetype_rx: "application/json",
911
+ status_rx: result.status,
912
+ state: isProposed ? "proposed" : "resolved",
913
+ outcome: null,
914
+ attrs,
915
+ });
916
+ if (row === undefined)
917
+ throw new Error("Engine.#writeLog: INSERT ... RETURNING produced no row");
918
+ return row.id;
919
+ }
920
+ #extractTarget(path) {
921
+ if (path === null)
922
+ return { scheme: null, username: null, password: null, hostname: null, port: null, pathname: null, params: null, fragment: null };
923
+ if (path.kind === "local")
924
+ return { scheme: null, username: null, password: null, hostname: null, port: null, pathname: path.raw, params: null, fragment: null };
925
+ return {
926
+ scheme: path.scheme, username: path.username, password: path.password,
927
+ hostname: path.hostname, port: path.port, pathname: path.pathname,
928
+ params: JSON.stringify(path.params), fragment: path.fragment,
929
+ };
930
+ }
931
+ #signalToJson(signal) {
932
+ if (signal === null || signal === undefined)
933
+ return null;
934
+ return JSON.stringify(signal);
935
+ }
936
+ }
937
+ //# sourceMappingURL=Engine.js.map