@plurnk/plurnk-service 0.2.0 → 0.4.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 (133) hide show
  1. package/SPEC.md +98 -112
  2. package/dist/core/ChannelWrite.d.ts +10 -0
  3. package/dist/core/ChannelWrite.d.ts.map +1 -1
  4. package/dist/core/ChannelWrite.js.map +1 -1
  5. package/dist/core/Engine.d.ts +10 -2
  6. package/dist/core/Engine.d.ts.map +1 -1
  7. package/dist/core/Engine.js +410 -113
  8. package/dist/core/Engine.js.map +1 -1
  9. package/dist/core/ProviderRegistry.d.ts +1 -1
  10. package/dist/core/ProviderRegistry.d.ts.map +1 -1
  11. package/dist/core/ProviderRegistry.js.map +1 -1
  12. package/dist/core/line-marker.d.ts +23 -0
  13. package/dist/core/line-marker.d.ts.map +1 -0
  14. package/dist/core/line-marker.js +321 -0
  15. package/dist/core/line-marker.js.map +1 -0
  16. package/dist/core/matcher.d.ts +14 -0
  17. package/dist/core/matcher.d.ts.map +1 -0
  18. package/dist/core/matcher.js +195 -0
  19. package/dist/core/matcher.js.map +1 -0
  20. package/dist/core/mimetype-binary.d.ts +6 -0
  21. package/dist/core/mimetype-binary.d.ts.map +1 -0
  22. package/dist/core/mimetype-binary.js +82 -0
  23. package/dist/core/mimetype-binary.js.map +1 -0
  24. package/dist/core/packet-wire.d.ts.map +1 -1
  25. package/dist/core/packet-wire.js +174 -33
  26. package/dist/core/packet-wire.js.map +1 -1
  27. package/dist/core/path-mimetype.d.ts +3 -0
  28. package/dist/core/path-mimetype.d.ts.map +1 -0
  29. package/dist/core/path-mimetype.js +47 -0
  30. package/dist/core/path-mimetype.js.map +1 -0
  31. package/dist/core/scheme-types.d.ts +5 -0
  32. package/dist/core/scheme-types.d.ts.map +1 -1
  33. package/dist/core/scheme-types.js.map +1 -1
  34. package/dist/providers/Mock.d.ts +2 -2
  35. package/dist/providers/Mock.d.ts.map +1 -1
  36. package/dist/providers/Mock.js.map +1 -1
  37. package/dist/schemes/Exec.d.ts +30 -1
  38. package/dist/schemes/Exec.d.ts.map +1 -1
  39. package/dist/schemes/Exec.js +229 -6
  40. package/dist/schemes/Exec.js.map +1 -1
  41. package/dist/schemes/File.d.ts +8 -4
  42. package/dist/schemes/File.d.ts.map +1 -1
  43. package/dist/schemes/File.js +184 -68
  44. package/dist/schemes/File.js.map +1 -1
  45. package/dist/schemes/Known.js +1 -1
  46. package/dist/schemes/Known.js.map +1 -1
  47. package/dist/schemes/Log.d.ts +9 -1
  48. package/dist/schemes/Log.d.ts.map +1 -1
  49. package/dist/schemes/Log.js +120 -19
  50. package/dist/schemes/Log.js.map +1 -1
  51. package/dist/schemes/Plurnk.d.ts +15 -1
  52. package/dist/schemes/Plurnk.d.ts.map +1 -1
  53. package/dist/schemes/Plurnk.js +50 -8
  54. package/dist/schemes/Plurnk.js.map +1 -1
  55. package/dist/schemes/Skill.js +1 -1
  56. package/dist/schemes/Skill.js.map +1 -1
  57. package/dist/schemes/Unknown.js +1 -1
  58. package/dist/schemes/Unknown.js.map +1 -1
  59. package/dist/schemes/_entry-crud.d.ts +5 -3
  60. package/dist/schemes/_entry-crud.d.ts.map +1 -1
  61. package/dist/schemes/_entry-crud.js +1 -0
  62. package/dist/schemes/_entry-crud.js.map +1 -1
  63. package/dist/schemes/_entry-find.d.ts.map +1 -1
  64. package/dist/schemes/_entry-find.js +64 -15
  65. package/dist/schemes/_entry-find.js.map +1 -1
  66. package/dist/schemes/_entry-ops.d.ts +3 -0
  67. package/dist/schemes/_entry-ops.d.ts.map +1 -1
  68. package/dist/schemes/_entry-ops.js +269 -72
  69. package/dist/schemes/_entry-ops.js.map +1 -1
  70. package/dist/schemes/_entry-send.d.ts.map +1 -1
  71. package/dist/schemes/_entry-send.js +14 -7
  72. package/dist/schemes/_entry-send.js.map +1 -1
  73. package/dist/server/ClientConnection.d.ts +3 -2
  74. package/dist/server/ClientConnection.d.ts.map +1 -1
  75. package/dist/server/ClientConnection.js +10 -2
  76. package/dist/server/ClientConnection.js.map +1 -1
  77. package/dist/server/Daemon.d.ts +40 -1
  78. package/dist/server/Daemon.d.ts.map +1 -1
  79. package/dist/server/Daemon.js +319 -1
  80. package/dist/server/Daemon.js.map +1 -1
  81. package/dist/server/MethodRegistry.d.ts +29 -0
  82. package/dist/server/MethodRegistry.d.ts.map +1 -1
  83. package/dist/server/MethodRegistry.js.map +1 -1
  84. package/dist/server/dsl.d.ts +2 -2
  85. package/dist/server/dsl.d.ts.map +1 -1
  86. package/dist/server/dsl.js +10 -10
  87. package/dist/server/dsl.js.map +1 -1
  88. package/dist/server/envelope.d.ts +2 -1
  89. package/dist/server/envelope.d.ts.map +1 -1
  90. package/dist/server/envelope.js +11 -8
  91. package/dist/server/envelope.js.map +1 -1
  92. package/dist/server/logEntry.d.ts +9 -9
  93. package/dist/server/logEntry.d.ts.map +1 -1
  94. package/dist/server/logEntry.js +10 -10
  95. package/dist/server/logEntry.js.map +1 -1
  96. package/dist/server/methods/_dispatchAsClient.d.ts.map +1 -1
  97. package/dist/server/methods/_dispatchAsClient.js +7 -2
  98. package/dist/server/methods/_dispatchAsClient.js.map +1 -1
  99. package/dist/server/methods/entry_read.js +5 -5
  100. package/dist/server/methods/entry_read.js.map +1 -1
  101. package/dist/server/methods/loop_cancel.d.ts +3 -0
  102. package/dist/server/methods/loop_cancel.d.ts.map +1 -0
  103. package/dist/server/methods/loop_cancel.js +27 -0
  104. package/dist/server/methods/loop_cancel.js.map +1 -0
  105. package/dist/server/methods/loop_run.d.ts.map +1 -1
  106. package/dist/server/methods/loop_run.js +53 -49
  107. package/dist/server/methods/loop_run.js.map +1 -1
  108. package/dist/server/methods/op_edit.js +3 -3
  109. package/dist/server/methods/op_edit.js.map +1 -1
  110. package/dist/server/methods/op_hide.js +3 -3
  111. package/dist/server/methods/op_hide.js.map +1 -1
  112. package/dist/server/methods/op_read.js +3 -3
  113. package/dist/server/methods/op_read.js.map +1 -1
  114. package/dist/server/methods/op_show.js +3 -3
  115. package/dist/server/methods/op_show.js.map +1 -1
  116. package/migrations/001_schema.sql +324 -0
  117. package/package.json +19 -12
  118. package/migrations/001_sessions.sql +0 -11
  119. package/migrations/002_runs.sql +0 -16
  120. package/migrations/003_loops.sql +0 -12
  121. package/migrations/004_turns.sql +0 -18
  122. package/migrations/005_entries.sql +0 -44
  123. package/migrations/006_log_entries.sql +0 -51
  124. package/migrations/007_visibility.sql +0 -10
  125. package/migrations/008_schemes_providers.sql +0 -25
  126. package/migrations/009_cost_rollups.sql +0 -44
  127. package/migrations/010_subscriptions.sql +0 -36
  128. package/migrations/011_turn_call_metadata.sql +0 -7
  129. package/migrations/012_proposal_lifecycle.sql +0 -22
  130. package/migrations/013_log_entries_lifecycle_columns.sql +0 -22
  131. package/migrations/014_loop_flags.sql +0 -9
  132. package/migrations/015_sessions_project_root.sql +0 -6
  133. package/migrations/016_persona_columns.sql +0 -11
@@ -1,6 +1,9 @@
1
1
  import { PlurnkParser } from "@plurnk/plurnk-grammar";
2
2
  import { Mimetypes, emptyRegistry } from "@plurnk/plurnk-mimetypes";
3
+ import { writeEntry } from "../schemes/_entry-crud.js";
3
4
  import { DEFAULT_LOOP_FLAGS } from "./scheme-types.js";
5
+ import { sliceLinesRaw } from "./line-marker.js";
6
+ import { isBinaryMimetype } from "./mimetype-binary.js";
4
7
  // Plain JS module shared with bin/digest.js so wire projection and
5
8
  // digest projection are structurally one function. tsconfig.build.json
6
9
  // has allowJs:true so this gets copied through to dist/.
@@ -67,17 +70,57 @@ const readPositiveInt = (envVar, fallback) => {
67
70
  return fallback;
68
71
  return n;
69
72
  };
70
- // Per-op fingerprint: op verb + target URI. Body deliberately excluded so the
71
- // model writing varied content to the same target still trips. Path kind is
72
- // included as a discriminator (url vs local). Rummy parallel: scheme +
73
- // sorted attributes joined by '='.
73
+ // Per-op fingerprint: op verb + target URI, plus an op-specific discriminator
74
+ // where the activity isn't fully captured by target alone:
75
+ // - EDIT/COPY/MOVE: body excluded re-writing the same target with varied
76
+ // content IS cycling (the model is producing different versions of the
77
+ // same artifact instead of progressing).
78
+ // - FIND/READ/SHOW/HIDE: body IS the search/selection pattern; varied
79
+ // matchers on the same target ARE different activities (the model is
80
+ // exploring different queries, not repeating one).
74
81
  const fingerprintOp = (stmt) => {
75
- const path = stmt.path;
76
- if (path === null)
82
+ const path = stmt.target;
83
+ const matcherDiscriminator = () => {
84
+ // For matcher-bearing ops, the body's `raw` (matcher source) plus
85
+ // any lineMarker forms the activity discriminator.
86
+ const parts = [];
87
+ const body = stmt.body;
88
+ if (body !== null && typeof body === "object" && typeof body.raw === "string") {
89
+ parts.push(`body:${body.raw.slice(0, 64)}`);
90
+ }
91
+ const lm = stmt.lineMarker;
92
+ if (lm !== null && lm !== undefined)
93
+ parts.push(`L:${lm.first},${lm.last ?? ""}`);
94
+ return parts.length > 0 ? `|${parts.join("|")}` : "";
95
+ };
96
+ if (path === null) {
97
+ // Path-less ops need an activity-defining discriminator other
98
+ // than `target`. Picked per op so the cycle detector reflects
99
+ // intent rather than syntax:
100
+ // - EXEC: the command body IS the activity. Without a body
101
+ // digest, varied shell commands (find / ls / wc) collapse to
102
+ // one fingerprint and the detector mislabels exploration
103
+ // as a loop.
104
+ // - SEND: the status code (signal) IS the activity. Different
105
+ // SEND[X] are different intentions; same SEND[X] with
106
+ // different message bodies is the same termination signal.
107
+ if (stmt.op === "EXEC") {
108
+ const body = typeof stmt.body === "string" ? stmt.body : "";
109
+ return `EXEC|(no-path)${body.length > 0 ? `|body:${body.slice(0, 64)}` : ""}`;
110
+ }
111
+ if (stmt.op === "SEND") {
112
+ const signal = typeof stmt.signal === "number" ? stmt.signal : "";
113
+ return `SEND|(no-path)|signal:${signal}`;
114
+ }
77
115
  return `${stmt.op}|(no-path)`;
78
- if (path.kind === "url")
79
- return `${stmt.op}|${path.scheme}://${path.pathname}`;
80
- return `${stmt.op}|local:${path.raw}`;
116
+ }
117
+ const base = path.kind === "url"
118
+ ? `${stmt.op}|${path.scheme}://${path.pathname}`
119
+ : `${stmt.op}|local:${path.raw}`;
120
+ if (stmt.op === "FIND" || stmt.op === "READ" || stmt.op === "SHOW" || stmt.op === "HIDE") {
121
+ return `${base}${matcherDiscriminator()}`;
122
+ }
123
+ return base;
81
124
  };
82
125
  // Per-turn fingerprint: sorted set of per-op fingerprints, joined. Order
83
126
  // within a turn doesn't matter — we want the SET of activities.
@@ -135,9 +178,19 @@ export default class Engine {
135
178
  // event emitter — no priority, no veto chain at this layer; filter
136
179
  // chains come later if a real consumer needs them.
137
180
  #proposalPendingListeners = [];
138
- constructor({ db, schemes, mimetypes }) {
181
+ // Per-loop AbortController for cancellation propagation into scheme
182
+ // ctx.signal. runLoop creates one at entry, cleans up at end. Engine
183
+ // cancellation paths (strikes, max_turns, external) abort it.
184
+ // Streaming schemes (exec) chain their per-spawn controllers off
185
+ // ctx.signal so cancelled loops tear down their background spawns.
186
+ #loopAborts = new Map();
187
+ #streamEventNotify;
188
+ #wakeRunNotify;
189
+ constructor({ db, schemes, mimetypes, streamEventNotify, wakeRunNotify }) {
139
190
  this.#db = db;
140
191
  this.#schemes = schemes;
192
+ this.#streamEventNotify = streamEventNotify;
193
+ this.#wakeRunNotify = wakeRunNotify;
141
194
  // Default to empty discovery — standalone Engine construction (in
142
195
  // tests) gets no handlers, and content flows through the framework's
143
196
  // raw-content fitContent fallback. Daemon-managed Engine receives a
@@ -164,7 +217,29 @@ export default class Engine {
164
217
  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, }) {
165
218
  const turnIds = [];
166
219
  const suddenDeathThreshold = maxTurns - maxStrikes;
167
- const cleanup = () => {
220
+ // Per-loop AbortController for scheme-side cancellation propagation.
221
+ // Chained from the caller's `signal` so an external abort cascades.
222
+ const loopAbort = new AbortController();
223
+ if (signal !== undefined) {
224
+ if (signal.aborted)
225
+ loopAbort.abort(signal.reason);
226
+ else
227
+ signal.addEventListener("abort", () => loopAbort.abort(signal.reason), { once: true });
228
+ }
229
+ this.#loopAborts.set(loopId, loopAbort);
230
+ // Cleanup splits by termination kind:
231
+ // - "graceful" (loop emitted SEND[2xx]): in-flight streaming-scheme
232
+ // spawns are ALLOWED to outlive the loop. They complete naturally,
233
+ // write final channel state, and wake-on-completion (E.4) opens a
234
+ // fresh loop in the same run if the model needs to react.
235
+ // - "forceful" (max_turns, strike_threshold, external cancel,
236
+ // non-2xx loop status): fire the loop-level abort so spawns
237
+ // tear down immediately.
238
+ const cleanup = (kind, reason) => {
239
+ if (kind === "forceful" && !loopAbort.signal.aborted) {
240
+ loopAbort.abort(reason ?? "loop_forceful_termination");
241
+ }
242
+ this.#loopAborts.delete(loopId);
168
243
  this.#strikeState.delete(loopId);
169
244
  this.#telemetryBuffer.delete(loopId);
170
245
  };
@@ -174,12 +249,15 @@ export default class Engine {
174
249
  if (row === undefined)
175
250
  throw new Error(`Engine.runLoop: loop ${loopId} not found`);
176
251
  if (row.status !== 102) {
177
- cleanup();
252
+ // Status 2xx = graceful (model said done); 4xx/5xx = forceful
253
+ // (external cancel or upstream failure). The threshold splits
254
+ // at 400 to match HTTP success/error semantics.
255
+ cleanup(row.status < 400 ? "graceful" : "forceful", `loop_terminal_${row.status}`);
178
256
  return { turnIds, finalStatus: row.status, hitMaxTurns: false, reason: "external" };
179
257
  }
180
258
  if (turnIds.length >= maxTurns) {
181
259
  await this.#db.engine_loop_cancel.run({ loop_id: loopId });
182
- cleanup();
260
+ cleanup("forceful", "max_turns");
183
261
  return { turnIds, finalStatus: 499, hitMaxTurns: true, reason: "max_turns" };
184
262
  }
185
263
  const turn = await this.runTurn({
@@ -199,7 +277,7 @@ export default class Engine {
199
277
  kind: "cycle",
200
278
  period: cycle.period,
201
279
  cycles: cycle.cycles,
202
- message: `repeating pattern detected: ${cycle.cycles}× period-${cycle.period}; vary your approach`,
280
+ message: "Loop detected",
203
281
  });
204
282
  }
205
283
  this.#strikeState.set(loopId, state);
@@ -214,15 +292,17 @@ export default class Engine {
214
292
  const struck = noOps || recordedFailed || state.turnErrors > 0;
215
293
  if (struck) {
216
294
  state.streak++;
295
+ const reason = noOps ? "no_ops" : recordedFailed ? "recorded_failure" : "rail";
217
296
  this.#pushTelemetry(loopId, {
218
297
  kind: "strike",
219
298
  streak: state.streak,
220
299
  maxStrikes,
221
- reason: noOps ? "no_ops" : recordedFailed ? "recorded_failure" : "rail",
300
+ reason,
301
+ message: `strike ${state.streak}/${maxStrikes} (${reason}); ${maxStrikes - state.streak} before abandonment`,
222
302
  });
223
303
  if (state.streak >= maxStrikes) {
224
304
  await this.#db.engine_loop_cancel.run({ loop_id: loopId });
225
- cleanup();
305
+ cleanup("forceful", "strike_threshold");
226
306
  return { turnIds, finalStatus: 499, hitMaxTurns: false, reason: "strike_threshold" };
227
307
  }
228
308
  }
@@ -238,19 +318,70 @@ export default class Engine {
238
318
  if (turnIds.length >= suddenDeathThreshold && turnIds.length < maxTurns) {
239
319
  this.#pushTelemetry(loopId, {
240
320
  kind: "sudden_death",
241
- message: `approaching max turns: ${turnIds.length} of ${maxTurns}; emit SEND[200] to complete`,
321
+ message: `${turnIds.length} of ${maxTurns}`,
242
322
  remaining: maxTurns - turnIds.length,
243
323
  });
244
324
  }
245
325
  }
246
326
  }
247
327
  async runTurn({ provider, messages, persona = "", sessionId, runId, loopId, origin = "model", signal, onDispatch, turnNumber = 1, maxTurns = 50, }) {
248
- // Build the spec'd packet (Packet.json) request half BEFORE the
249
- // provider call. The wire payload is a projection OF this packet;
250
- // the stored packet is the same object completed with the
251
- // assistant section after the response arrives.
328
+ // === Turn-as-container model ===
329
+ //
330
+ // Turn rows are created at runTurn OPEN (status=102, placeholder
331
+ // packet) so things can be written into the turn before the model
332
+ // is called: the user prompt on turn 1; later, system signals or
333
+ // injected telemetry events on any turn. The turn is CLOSED at
334
+ // the end with the final packet + status + usage stats.
335
+ //
336
+ // sequence is "ordinal of stuff in this turn." Pre-model
337
+ // writes consume low indices; model ops continue from there.
338
+ const seqRow = await this.#db.engine_next_turn_sequence.get({ loop_id: loopId });
339
+ const seq = seqRow.next;
340
+ const openRow = await this.#db.engine_open_turn.get({
341
+ loop_id: loopId, sequence: seq,
342
+ });
343
+ if (openRow === undefined)
344
+ throw new Error("Engine.runTurn: turn open returned no row");
345
+ const turnId = openRow.id;
346
+ // Pre-model writes. Each turn opens with a system-origin EDIT
347
+ // against `plurnk://prompt/<loop_id>/<seq>` IF there's a prompt
348
+ // for THIS turn the model hasn't seen yet:
349
+ // - Turn 1: loop.prompt is the initial user prompt.
350
+ // - Turn N>1: only if Engine.inject (or wake-on-completion via
351
+ // daemon.inject) wrote a prompt entry for this turn slot
352
+ // between turn N-1 and N. Inject writes directly to entries;
353
+ // we DON'T re-foist here for N>1.
354
+ // The log records the EDIT for forensics. Model ops dispatch
355
+ // from sequence=2 onward on prompt-foisted turns; 1 onward
356
+ // otherwise.
357
+ let nextActionIndex = 1;
358
+ if (seq === 1) {
359
+ const promptRow = await this.#db.engine_get_loop_prompt.get({ loop_id: loopId });
360
+ if (promptRow !== undefined && typeof promptRow.prompt === "string" && promptRow.prompt.length > 0) {
361
+ const promptPath = {
362
+ kind: "url", raw: `plurnk://prompt/${loopId}/${seq}`,
363
+ scheme: "plurnk", username: null, password: null,
364
+ hostname: null, port: null,
365
+ pathname: `prompt/${loopId}/${seq}`, params: {}, fragment: null,
366
+ };
367
+ const promptStmt = {
368
+ op: "EDIT", suffix: "", signal: null,
369
+ target: promptPath, lineMarker: null,
370
+ body: promptRow.prompt, position: { line: 1, column: 1 },
371
+ };
372
+ await this.dispatch({
373
+ statement: promptStmt, sessionId, runId, loopId, turnId,
374
+ sequence: nextActionIndex, origin: "system",
375
+ });
376
+ nextActionIndex++;
377
+ }
378
+ }
379
+ // Build the spec'd packet (Packet.json) request half. #buildLog
380
+ // queries log_entries scoped to the run — the prompt entry just
381
+ // written (if turn 1) is part of that query result.
252
382
  const requestPacket = await this.#buildRequestPacket({
253
- initialMessages: messages, persona, runId, loopId, turnNumber, maxTurns, provider,
383
+ initialMessages: messages, persona, runId, loopId,
384
+ turnNumber, currentTurnSeq: seq, maxTurns, provider,
254
385
  });
255
386
  const modelMessages = this.#packetToWireMessages(requestPacket);
256
387
  const response = await provider.generate({ messages: modelMessages, signal });
@@ -258,7 +389,15 @@ export default class Engine {
258
389
  // parsed ops) → packet.assistant per Packet.json §assistant;
259
390
  // call-metadata (usage, finishReason, model) → Turn columns per
260
391
  // Turn.json. Mixing the two on packet.assistant was the wrong layer.
261
- const { packetAssistant, callMetadata } = this.#splitResponse(response);
392
+ const { packetAssistant, callMetadata, parseErrors } = this.#splitResponse(response);
393
+ // Surface parse errors to the model's NEXT packet so it can self-
394
+ // correct. Without this, malformed emissions (e.g. a READ matcher
395
+ // body starting with `//` being interpreted as xpath) silently
396
+ // drop, the model sees zero ops dispatched, strike-rail fires,
397
+ // model has no feedback on WHY its emission didn't take effect.
398
+ for (const message of parseErrors ?? []) {
399
+ this.#pushTelemetry(loopId, { kind: "parse_error", message });
400
+ }
262
401
  const opsCount = packetAssistant.ops.length;
263
402
  const sendOp = packetAssistant.ops.findLast((op) => op.op === "SEND" && typeof op.signal === "number");
264
403
  // Rail #41 (revised): the per-turn requirement is "emit at least one
@@ -267,16 +406,11 @@ export default class Engine {
267
406
  const turnStatus = sendOp !== undefined
268
407
  ? sendOp.signal
269
408
  : opsCount === 0 ? TURN_STATUS_NO_OPS : TURN_STATUS_IMPLICIT_CONTINUE;
270
- const seqRow = await this.#db.engine_next_turn_sequence.get({ loop_id: loopId });
271
- const seq = seqRow.next;
272
- // Complete the spec'd packet by adding the response section.
273
- // requestPacket already has system + user matching what was sent
274
- // to the LLM (one source of truth across wire payload and storage).
409
+ // Close the turn with the final packet, status, and usage stats.
275
410
  const packet = this.#completePacket(requestPacket, packetAssistant, response.assistantRaw, provider);
276
411
  const { usage, finishReason, model } = callMetadata;
277
- const turnRow = await this.#db.engine_insert_turn.get({
278
- loop_id: loopId,
279
- sequence: seq,
412
+ await this.#db.engine_close_turn.run({
413
+ id: turnId,
280
414
  status: turnStatus,
281
415
  packet: JSON.stringify(packet),
282
416
  usage_prompt: usage.prompt,
@@ -286,13 +420,14 @@ export default class Engine {
286
420
  finish_reason: finishReason,
287
421
  model,
288
422
  });
289
- if (turnRow === undefined)
290
- throw new Error("Engine.runTurn: turn insert returned no row");
291
- const turnId = turnRow.id;
423
+ // Dispatch model ops starting at nextActionIndex (continues the
424
+ // turn's running counter after any pre-model writes).
292
425
  const statuses = [];
293
- for (const [actionIndex, statement] of packetAssistant.ops.entries()) {
426
+ for (const [i, statement] of packetAssistant.ops.entries()) {
294
427
  const result = await this.dispatch({
295
- statement, sessionId, runId, loopId, turnId, actionIndex, origin, onDispatch,
428
+ statement, sessionId, runId, loopId, turnId,
429
+ sequence: nextActionIndex + i,
430
+ origin, onDispatch,
296
431
  });
297
432
  statuses.push(result.status);
298
433
  }
@@ -323,6 +458,7 @@ export default class Engine {
323
458
  const preParsedOps = assistant.ops;
324
459
  const ops = [];
325
460
  const textFragments = [];
461
+ const parseErrors = [];
326
462
  if (preParsedOps !== undefined) {
327
463
  ops.push(...preParsedOps);
328
464
  }
@@ -336,6 +472,11 @@ export default class Engine {
336
472
  if (trimmed.length > 0)
337
473
  textFragments.push(trimmed);
338
474
  }
475
+ else if (item.kind === "error") {
476
+ const err = item.error;
477
+ const msg = (err && typeof err.message === "string") ? err.message : "parse error";
478
+ parseErrors.push(msg);
479
+ }
339
480
  }
340
481
  }
341
482
  const wireReasoning = assistant.reasoning ?? "";
@@ -345,6 +486,7 @@ export default class Engine {
345
486
  return {
346
487
  packetAssistant: { content: assistant.content, ops, reasoning },
347
488
  callMetadata: { usage: assistant.usage, finishReason: assistant.finishReason, model: assistant.model },
489
+ parseErrors,
348
490
  };
349
491
  }
350
492
  // Assemble the request half of the spec'd packet (Packet.json §system
@@ -354,18 +496,26 @@ export default class Engine {
354
496
  // Per Packet.json: user.prompt is "Copy of loop.prompt — never null on
355
497
  // a continuation turn"; the turn-N-of-M continuation marker rides on
356
498
  // user.system_requirements (per-turn rules), NOT a mutated prompt.
357
- async #buildRequestPacket({ initialMessages, persona: defaultPersona, runId, loopId, turnNumber, maxTurns, provider, }) {
499
+ async #buildRequestPacket({ initialMessages, persona: defaultPersona, runId, loopId, turnNumber, currentTurnSeq, maxTurns, provider, }) {
358
500
  const byRole = (role) => initialMessages.filter((m) => m.role === role).map((m) => m.content).join("\n\n");
359
501
  const system_definition = byRole("system");
360
- const prompt = byRole("user");
502
+ // user.prompt sources from the loop's most recent prompt entry first
503
+ // (plurnk://prompt/<loop_id>/<N> for the highest N written to date).
504
+ // This is what inject + the turn-1 foist write into. Falls back to
505
+ // the runLoop caller's messages.user for tests that bypass the
506
+ // foist mechanism entirely.
507
+ const latestPromptRow = await this.#db.drain_get_latest_prompt_body_for_loop.get({ pattern: `prompt/${loopId}/%` });
508
+ const prompt = (latestPromptRow !== undefined && typeof latestPromptRow.content === "string" && latestPromptRow.content.length > 0)
509
+ ? latestPromptRow.content
510
+ : byRole("user");
361
511
  // Resolve persona cascade: loops.persona > runs.persona >
362
512
  // sessions.persona > caller-supplied default. SQL coalesces in one
363
513
  // query; null result means no DB override exists, use the default.
364
514
  const row = await this.#db.engine_resolve_persona.get({ loop_id: loopId });
365
515
  const persona = (row?.persona !== undefined && row?.persona !== null) ? row.persona : defaultPersona;
366
- const index = await this.#buildIndex(runId);
367
- const log = await this.#buildLog(loopId);
368
- const telemetryErrors = await this.#buildTelemetryErrors(loopId);
516
+ const index = await this.#buildIndex(runId, loopId);
517
+ const log = await this.#buildLog(runId);
518
+ const telemetryErrors = await this.#buildTelemetryErrors(loopId, currentTurnSeq);
369
519
  // Rummy AgentLoop.js #buildContinuationPrompt: literally
370
520
  // `Turn ${turn}/${maxTurns}`. That's the whole string. The model
371
521
  // can read the action log to see what it already did; it does
@@ -455,16 +605,16 @@ export default class Engine {
455
605
  // 1. Previous-turn action-bound failures (status_rx >= 400 on log_entries).
456
606
  // 2. Engine-buffered actionless failures (no_send, parse, watchdog, rails).
457
607
  // Buffer drains on read — each error appears in exactly one packet.
458
- async #buildTelemetryErrors(loopId) {
459
- const rows = await this.#db.engine_render_telemetry_errors.all({ loop_id: loopId });
608
+ async #buildTelemetryErrors(loopId, currentTurnSeq) {
609
+ const rows = await this.#db.engine_render_telemetry_errors.all({ loop_id: loopId, current_turn_seq: currentTurnSeq });
460
610
  const actionFailures = rows.map((r) => {
461
- const target = r.target_scheme !== null
462
- ? `${r.target_scheme}://${r.target_pathname ?? ""}`
463
- : (r.target_pathname ?? null);
611
+ const target = r.scheme !== null
612
+ ? `${r.scheme}://${r.pathname ?? ""}`
613
+ : (r.pathname ?? null);
464
614
  const parsedRx = r.mimetype_rx === "application/json" ? JSON.parse(r.rx) : r.rx;
465
615
  return {
466
616
  kind: "action_failure",
467
- coordinate: `${r.loop_seq}/${r.turn_seq}/${r.action_index}`,
617
+ coordinate: `${r.loop_seq}/${r.turn_seq}/${r.sequence}`,
468
618
  op: r.op,
469
619
  target,
470
620
  status: r.status_rx,
@@ -478,60 +628,64 @@ export default class Engine {
478
628
  // SPEC §15 packet.system.log — chronological action-entries for the loop.
479
629
  // Snapshot is taken at packet build (pre-dispatch this turn), so it
480
630
  // reflects "what has happened before this turn." Each row carries a
481
- // log://<loop_seq>/<turn_seq>/<action_index> coordinate the model can READ.
482
- async #buildLog(loopId) {
483
- const rows = await this.#db.engine_render_log.all({ loop_id: loopId });
484
- const entries = rows.map((r) => ({
485
- coordinate: `${r.loop_seq}/${r.turn_seq}/${r.action_index}`,
631
+ // log://<loop_seq>/<turn_seq>/<sequence> coordinate the model can READ.
632
+ async #buildLog(runId) {
633
+ // SPEC §0.6: runs own log entries — log is the run's history,
634
+ // not the loop's. Span all loops in the run so the model sees
635
+ // earlier loops' work as conversational memory.
636
+ //
637
+ // User prompts are first-class log entries: runTurn writes a
638
+ // client-origin SEND[200] row at sequence=0 of each new
639
+ // turn-1. Prompts thus surface naturally in this query — no
640
+ // synthetic / shim layer.
641
+ const rows = await this.#db.engine_render_log.all({ run_id: runId });
642
+ return rows.map((r) => ({
643
+ coordinate: `${r.loop_seq}/${r.turn_seq}/${r.sequence}`,
486
644
  origin: r.origin,
487
645
  op: r.op,
488
646
  suffix: r.suffix,
489
647
  signal: r.signal === null ? null : JSON.parse(r.signal),
490
648
  target: {
491
- scheme: r.target_scheme,
492
- username: r.target_username, password: r.target_password,
493
- hostname: r.target_hostname, port: r.target_port,
494
- pathname: r.target_pathname,
495
- params: r.target_params === null ? null : JSON.parse(r.target_params),
496
- fragment: r.target_fragment,
649
+ scheme: r.scheme,
650
+ username: r.username, password: r.password,
651
+ hostname: r.hostname, port: r.port,
652
+ pathname: r.pathname,
653
+ params: r.params === null ? null : JSON.parse(r.params),
654
+ fragment: r.fragment,
497
655
  },
498
656
  status: r.status_rx,
499
657
  rx: r.mimetype_rx === "application/json" ? JSON.parse(r.rx) : r.rx,
500
658
  mimetype_rx: r.mimetype_rx,
659
+ tx: r.mimetype_tx === "application/json" ? JSON.parse(r.tx) : r.tx,
660
+ mimetype_tx: r.mimetype_tx,
501
661
  }));
502
- // Synthetic prompt entry — SHIM per AGENTS.md §Open. Prepends a
503
- // "PROMPT" entry sourced from loops.prompt so `# Plurnk System Log`
504
- // is never empty on turn 1. NOT URI-addressable yet (no
505
- // corresponding row in log_entries / no scheme handler); real fix
506
- // logs the prompt as a first-class entry the model can READ.
507
- const loopRow = await this.#db.engine_get_loop_prompt.get({ loop_id: loopId });
508
- if (loopRow !== undefined && typeof loopRow.prompt === "string" && loopRow.prompt.length > 0) {
509
- entries.unshift({
510
- coordinate: `${loopRow.sequence}/0/0`,
511
- origin: "client",
512
- op: "PROMPT",
513
- suffix: "",
514
- signal: null,
515
- target: {
516
- scheme: null, username: null, password: null,
517
- hostname: null, port: null, pathname: null,
518
- params: null, fragment: null,
519
- },
520
- status: 200,
521
- rx: loopRow.prompt,
522
- mimetype_rx: "text/plain",
523
- });
524
- }
525
- return entries;
526
662
  }
527
- async #buildIndex(runId) {
663
+ async #buildIndex(runId, currentLoopId) {
528
664
  const rows = await this.#db.engine_render_index.all({ run_id: runId });
529
665
  const tagsStmt = this.#db.engine_entry_tags;
666
+ // Foist the CURRENT loop's prompt entries out of the index render —
667
+ // their bodies are materialized in packet.user.prompt instead. With
668
+ // multi-turn injection, a loop can have multiple prompt entries at
669
+ // plurnk://prompt/<loop_id>/<N>; all of them get foisted, leaving
670
+ // only previous loops' prompts (still addressable for HIDE).
671
+ const foistedPrefix = `prompt/${currentLoopId}/`;
672
+ // Backward compat: legacy single-slot path. Tests / older runs may
673
+ // still have a `prompt/<loop_id>` entry (no trailing /N); foist it too.
674
+ const foistedExact = `prompt/${currentLoopId}`;
530
675
  const entries = new Map();
531
676
  for (const row of rows) {
677
+ if (row.scheme === "plurnk"
678
+ && (row.pathname === foistedExact || row.pathname.startsWith(foistedPrefix)))
679
+ continue;
532
680
  let entry = entries.get(row.entry_id);
533
681
  if (entry === undefined) {
534
682
  const tagRows = await tagsStmt.all({ entry_id: row.entry_id });
683
+ // defaultChannel pulled from the row's scheme manifest. The
684
+ // wire renderer uses it to omit `#channel` on fences whose
685
+ // channel is the scheme's default — that absence is the
686
+ // addressing of the default channel.
687
+ const handler = this.#schemes.get(row.scheme ?? "");
688
+ const manifest = handler?.constructor?.manifest;
535
689
  entry = {
536
690
  id: row.entry_id,
537
691
  version: row.version,
@@ -545,6 +699,7 @@ export default class Engine {
545
699
  pathname: row.pathname,
546
700
  params: row.params === null ? null : JSON.parse(row.params),
547
701
  channels: {},
702
+ defaultChannel: manifest?.defaultChannel ?? "",
548
703
  attributes: JSON.parse(row.attributes),
549
704
  tags: tagRows.map((r) => r.tag),
550
705
  };
@@ -565,15 +720,20 @@ export default class Engine {
565
720
  return [...entries.values()];
566
721
  }
567
722
  async dispatch(context) {
568
- const { statement, sessionId, runId, loopId, turnId, actionIndex, origin, onDispatch } = context;
723
+ const { statement, sessionId, runId, loopId, turnId, sequence, origin, onDispatch } = context;
569
724
  const schemeCtx = {
570
725
  db: this.#db,
571
726
  sessionId, runId, loopId, turnId,
572
727
  writer: origin,
573
- signal: undefined,
728
+ signal: this.#loopAborts.get(loopId)?.signal,
729
+ streamEventNotify: this.#streamEventNotify,
730
+ wakeRunNotify: this.#wakeRunNotify,
731
+ mimetypes: this.#mimetypes,
574
732
  };
575
733
  let result;
576
- const denial = this.#checkWritable(statement, origin);
734
+ let denial = this.#checkWritable(statement, origin);
735
+ if (denial === null)
736
+ denial = await this.#checkFlagsGate(statement, loopId);
577
737
  if (denial !== null) {
578
738
  result = denial;
579
739
  }
@@ -584,7 +744,7 @@ export default class Engine {
584
744
  // skips it. Logging failures (#writeLog throws) are NOT caught —
585
745
  // those are system failures.
586
746
  try {
587
- if (statement.op === "SEND" && statement.path === null) {
747
+ if (statement.op === "SEND" && statement.target === null) {
588
748
  result = await this.#handleSendBroadcast(statement, loopId);
589
749
  }
590
750
  else if (statement.op === "COPY") {
@@ -593,8 +753,15 @@ export default class Engine {
593
753
  else if (statement.op === "MOVE") {
594
754
  result = await this.#handleMove(statement, schemeCtx);
595
755
  }
756
+ else if (statement.op === "EXEC") {
757
+ // EXEC's target slot is `cwd`, not a scheme address.
758
+ // Per plurnk.md the op routes unconditionally to the
759
+ // exec scheme; the scheme handler reads runtime
760
+ // (signal), cwd (target), and command (body).
761
+ result = await this.#run("exec", statement, schemeCtx);
762
+ }
596
763
  else {
597
- result = await this.#run(this.#schemeNameOf(statement.path), statement, schemeCtx);
764
+ result = await this.#run(this.#schemeNameOf(statement.target), statement, schemeCtx);
598
765
  }
599
766
  }
600
767
  catch (err) {
@@ -604,7 +771,7 @@ export default class Engine {
604
771
  };
605
772
  }
606
773
  }
607
- const logEntryId = await this.#writeLog({ statement, result, runId, loopId, turnId, actionIndex, origin });
774
+ const logEntryId = await this.#writeLog({ statement, result, runId, loopId, turnId, sequence, origin });
608
775
  onDispatch?.(logEntryId);
609
776
  // Proposal lifecycle (task #42, AGENTS.md §Phase E). When a scheme
610
777
  // returns status 202, the entry is written as state='proposed';
@@ -623,7 +790,7 @@ export default class Engine {
623
790
  // Notify external listeners (Daemon broadcasts loop/proposal;
624
791
  // YOLO listener auto-resolves) BEFORE awaiting — they may
625
792
  // resolve synchronously inside their handlers.
626
- const target = this.#extractTarget(statement.path);
793
+ const target = this.#extractTarget(statement.target);
627
794
  const flags = await this.#loadLoopFlags(loopId);
628
795
  const event = {
629
796
  logEntryId, sessionId, runId, loopId, turnId,
@@ -645,26 +812,40 @@ export default class Engine {
645
812
  // 4xx/5xx or throws, the resolution is downgraded to a reject
646
813
  // with the failure outcome — engine treats it like a client
647
814
  // rejection.
648
- const effective = await this.#runApplyResolution(statement, result, resolution);
815
+ const effective = await this.#runApplyResolution(statement, result, resolution, { sessionId, runId, loopId, turnId });
649
816
  const post = await this.#applyResolution(logEntryId, effective);
650
817
  return post;
651
818
  }
652
819
  return result;
653
820
  }
654
- async #runApplyResolution(statement, originalResult, resolution) {
821
+ async #runApplyResolution(statement, originalResult, resolution, ids) {
822
+ const { sessionId, runId, loopId, turnId } = ids;
655
823
  if (resolution.decision !== "accept")
656
824
  return resolution;
657
- const schemeName = this.#schemeNameOf(statement.path);
825
+ // EXEC routes to the exec scheme regardless of target (cwd, not
826
+ // a scheme address). All other ops resolve their handler from
827
+ // statement.target's scheme.
828
+ const schemeName = statement.op === "EXEC" ? "exec" : this.#schemeNameOf(statement.target);
658
829
  if (schemeName === null)
659
830
  return resolution;
660
831
  const handler = this.#schemes.get(schemeName);
661
832
  if (handler === undefined || typeof handler.applyResolution !== "function")
662
833
  return resolution;
663
834
  try {
835
+ // Build a ctx for the scheme's applyResolution. The proposal
836
+ // was raised inside a specific (session, run, loop, turn);
837
+ // the scheme uses ctx to write the entry that makes the
838
+ // operation's artifact visible in the next packet's index.
839
+ const applyCtx = {
840
+ db: this.#db, sessionId, runId, loopId, turnId,
841
+ writer: "model", signal: this.#loopAborts.get(loopId)?.signal,
842
+ streamEventNotify: this.#streamEventNotify,
843
+ wakeRunNotify: this.#wakeRunNotify,
844
+ };
664
845
  const applyResult = await handler.applyResolution({
665
846
  attrs: (originalResult.attrs ?? {}),
666
847
  body: resolution.body,
667
- });
848
+ }, applyCtx);
668
849
  if (applyResult.status >= 400) {
669
850
  return {
670
851
  decision: "reject",
@@ -672,6 +853,14 @@ export default class Engine {
672
853
  body: applyResult.body,
673
854
  };
674
855
  }
856
+ // Propagate applyResolution.outcome onto the accepted resolution
857
+ // so the log entry's outcome column reflects operational metadata
858
+ // (e.g. exec's "exit_N"). Without this, only failures get an
859
+ // outcome on the durable record, and "ran cleanly but with a
860
+ // notable detail" has nowhere to land.
861
+ if (applyResult.outcome !== undefined && resolution.outcome === undefined) {
862
+ return { ...resolution, outcome: applyResult.outcome };
863
+ }
675
864
  return resolution;
676
865
  }
677
866
  catch (err) {
@@ -702,6 +891,52 @@ export default class Engine {
702
891
  pendingProposalIds() {
703
892
  return [...this.#pendingProposals.keys()];
704
893
  }
894
+ // Used by wake-on-completion (daemon side): "is there any loop in this
895
+ // run still accepting turns?" If yes, skip the wake — the active loop
896
+ // will pick up the channel transition at its next turn boundary. If no,
897
+ // the daemon opens a fresh loop with the wake prompt.
898
+ async hasActiveLoopForRun(runId) {
899
+ const row = await this.#db.engine_count_active_loops_for_run.get({ run_id: runId });
900
+ return (row?.n ?? 0) > 0;
901
+ }
902
+ // Inject a prompt into the run's currently-executing loop. Writes a
903
+ // plurnk://prompt/<loop_id>/<next-turn> entry whose body becomes
904
+ // packet.user.prompt at the next turn boundary. Last-wins: if two
905
+ // injects target the same next-turn slot, the second overwrites the
906
+ // first.
907
+ //
908
+ // Returns null when no loop in the run is currently active (status=102).
909
+ // The daemon-side inject path then enqueues a fresh loop with this
910
+ // prompt; engine doesn't open loops itself.
911
+ //
912
+ // Rummy parallel: AgentLoop.inject(). The "active drain → write
913
+ // prompt entry, return immediately" branch.
914
+ async inject(runId, prompt) {
915
+ const loopRow = await this.#db.drain_current_loop_for_run.get({ run_id: runId });
916
+ if (loopRow === undefined)
917
+ return null;
918
+ const loopId = loopRow.id;
919
+ const turnRow = await this.#db.drain_next_turn_seq_for_loop.get({ loop_id: loopId });
920
+ const turnSeq = turnRow?.next ?? 1;
921
+ const sessionRow = await this.#db.drain_get_run_session.get({ run_id: runId });
922
+ if (sessionRow === undefined)
923
+ throw new Error(`Engine.inject: run ${runId} not found`);
924
+ const pathname = `prompt/${loopId}/${turnSeq}`;
925
+ const ctx = {
926
+ db: this.#db, sessionId: sessionRow.session_id, runId, loopId,
927
+ turnId: 0, // no turn open at inject time; entries don't pin turnId
928
+ writer: "system",
929
+ signal: this.#loopAborts.get(loopId)?.signal,
930
+ streamEventNotify: this.#streamEventNotify,
931
+ wakeRunNotify: this.#wakeRunNotify,
932
+ };
933
+ const entry = {
934
+ channels: { body: { content: prompt, mimetype: "text/markdown" } },
935
+ tags: [],
936
+ };
937
+ await writeEntry(pathname, entry, ctx, "plurnk");
938
+ return { loopId, turnSeq };
939
+ }
705
940
  // Subscribe to proposal-pending events. Daemon registers a listener
706
941
  // that broadcasts the loop/proposal WS notification; YOLO listener
707
942
  // (Phase E.3) registers one that auto-resolves. Listeners fire BEFORE
@@ -760,7 +995,13 @@ export default class Engine {
760
995
  : decision === "reject" ? "rejected"
761
996
  : "loop_aborted";
762
997
  const outcome = resolution.outcome ?? defaultOutcome;
763
- const rx = JSON.stringify({ status, outcome, body: resolution.body ?? null });
998
+ // rx is the model-facing operation result. ONLY status — outcome
999
+ // is operational (security/admin) and stays on the log_entries
1000
+ // column for forensics; body was an input echo with no value to
1001
+ // the model; target/path lives in log_entries metadata and
1002
+ // surfaces via the log section's `target` field uniformly.
1003
+ // Per AGENTS.md "Operational hygiene on what the model sees."
1004
+ const rx = JSON.stringify({ status });
764
1005
  await this.#db.engine_resolve_log_entry.run({
765
1006
  id: logEntryId, state, outcome, status_rx: status, rx,
766
1007
  });
@@ -776,15 +1017,20 @@ export default class Engine {
776
1017
  #checkWritable(statement, origin) {
777
1018
  if (!MUTATING_OPS.has(statement.op))
778
1019
  return null;
779
- if (statement.op === "SEND" && statement.path === null)
1020
+ if (statement.op === "SEND" && statement.target === null)
780
1021
  return null;
1022
+ // EXEC's target slot is `cwd`, not a scheme address. The op's
1023
+ // authority always belongs to the exec scheme regardless of cwd.
1024
+ if (statement.op === "EXEC") {
1025
+ return this.#denyIfDisallowed("exec", origin);
1026
+ }
781
1027
  if (statement.op === "COPY" || statement.op === "MOVE") {
782
1028
  const dstScheme = this.#schemeNameOf(statement.body);
783
1029
  const dstDenial = this.#denyIfDisallowed(dstScheme, origin);
784
1030
  if (dstDenial !== null)
785
1031
  return dstDenial;
786
1032
  if (statement.op === "MOVE") {
787
- const srcScheme = this.#schemeNameOf(statement.path);
1033
+ const srcScheme = this.#schemeNameOf(statement.target);
788
1034
  if (srcScheme !== dstScheme) {
789
1035
  const srcDenial = this.#denyIfDisallowed(srcScheme, origin);
790
1036
  if (srcDenial !== null)
@@ -793,7 +1039,7 @@ export default class Engine {
793
1039
  }
794
1040
  return null;
795
1041
  }
796
- const target = this.#schemeNameOf(statement.path);
1042
+ const target = this.#schemeNameOf(statement.target);
797
1043
  return this.#denyIfDisallowed(target, origin);
798
1044
  }
799
1045
  #denyIfDisallowed(schemeName, origin) {
@@ -809,10 +1055,37 @@ export default class Engine {
809
1055
  return null;
810
1056
  return { status: 403, error: `writer '${origin}' is not in writableBy for scheme '${schemeName}'` };
811
1057
  }
1058
+ // Per-loop flag gating. Schemes self-declare their flag affinity in
1059
+ // their manifest (proposes / excludedInAsk / requiresWeb /
1060
+ // requiresInteraction); SchemeRegistry.resolveForLoop returns the
1061
+ // active set under the loop's persisted flags. Anything outside the
1062
+ // set returns 403 — action-entry-as-outcome carries the rejection.
1063
+ async #checkFlagsGate(statement, loopId) {
1064
+ // Broadcast SEND has no scheme to gate.
1065
+ if (statement.op === "SEND" && statement.target === null)
1066
+ return null;
1067
+ const flags = await this.#loadLoopFlags(loopId);
1068
+ // Fast path: default flags gate nothing. (yolo never gates.)
1069
+ if (!flags.noProposals && !flags.noWeb && !flags.noInteraction && flags.mode === "act")
1070
+ return null;
1071
+ const active = this.#schemes.resolveForLoop(flags);
1072
+ const check = (target) => {
1073
+ const scheme = this.#schemeNameOf(target);
1074
+ if (scheme === null)
1075
+ return null;
1076
+ if (active.has(scheme))
1077
+ return null;
1078
+ return { status: 403, error: `scheme '${scheme}' is inactive under current loop flags` };
1079
+ };
1080
+ if (statement.op === "COPY" || statement.op === "MOVE") {
1081
+ return check(statement.target) ?? check(statement.body);
1082
+ }
1083
+ return check(statement.target);
1084
+ }
812
1085
  async #handleCopy(statement, ctx) {
813
1086
  if (statement.op !== "COPY")
814
1087
  throw new Error("unreachable");
815
- const srcPath = statement.path;
1088
+ const srcPath = statement.target;
816
1089
  const dstPath = statement.body;
817
1090
  if (srcPath === null)
818
1091
  return { status: 400, error: "COPY requires source path" };
@@ -823,7 +1096,7 @@ export default class Engine {
823
1096
  async #handleMove(statement, ctx) {
824
1097
  if (statement.op !== "MOVE")
825
1098
  throw new Error("unreachable");
826
- const srcPath = statement.path;
1099
+ const srcPath = statement.target;
827
1100
  const dstPath = statement.body;
828
1101
  if (srcPath === null)
829
1102
  return { status: 400, error: "MOVE requires source path" };
@@ -881,11 +1154,30 @@ export default class Engine {
881
1154
  return { status: 415, error: `mimetype mismatch on channel '${channelName}': ${channelData.mimetype} vs ${expectedMimetype}` };
882
1155
  }
883
1156
  }
1157
+ // `<L>` source range slicing per AGENTS.md "Resolved ambiguities" §4
1158
+ // (symmetric with READ `<L>` — source range, no line-number prefix).
1159
+ // Applied to every channel of the source entry. Binary channels return
1160
+ // 415 since line semantics don't apply.
1161
+ const lineMarker = statement.lineMarker ?? null;
1162
+ let channels = entry.channels;
1163
+ if (lineMarker !== null) {
1164
+ const sliced = {};
1165
+ for (const [channelName, channelData] of Object.entries(entry.channels)) {
1166
+ if (isBinaryMimetype(channelData.mimetype)) {
1167
+ return { status: 415, error: `cannot slice <L> on binary channel '${channelName}' (${channelData.mimetype})` };
1168
+ }
1169
+ const r = sliceLinesRaw(channelData.content ?? "", lineMarker);
1170
+ if (r.status !== 200)
1171
+ return { status: r.status, error: r.error };
1172
+ sliced[channelName] = { ...channelData, content: r.text ?? "" };
1173
+ }
1174
+ channels = sliced;
1175
+ }
884
1176
  // Tag resolution: signal = replace; absent/empty = carry from source
885
1177
  const tags = (Array.isArray(statement.signal) && statement.signal.length > 0)
886
1178
  ? statement.signal
887
1179
  : entry.tags;
888
- const writeResult = await dstHandler.writeEntry(dstPathname, { channels: entry.channels, tags }, ctx);
1180
+ const writeResult = await dstHandler.writeEntry(dstPathname, { channels, tags }, ctx);
889
1181
  return { status: writeResult.status, entryId: writeResult.entryId, created: writeResult.created };
890
1182
  }
891
1183
  async #handleSendBroadcast(statement, loopId) {
@@ -921,8 +1213,8 @@ export default class Engine {
921
1213
  return path.scheme;
922
1214
  return "file"; // local (bare) → file
923
1215
  }
924
- async #writeLog({ statement, result, runId, loopId, turnId, actionIndex, origin, }) {
925
- const target = this.#extractTarget(statement.path);
1216
+ async #writeLog({ statement, result, runId, loopId, turnId, sequence, origin, }) {
1217
+ const target = this.#extractTarget(statement.target);
926
1218
  const lineMarkerJson = "lineMarker" in statement && statement.lineMarker !== null
927
1219
  ? JSON.stringify(statement.lineMarker)
928
1220
  : null;
@@ -940,19 +1232,19 @@ export default class Engine {
940
1232
  run_id: runId,
941
1233
  loop_id: loopId,
942
1234
  turn_id: turnId,
943
- action_index: actionIndex,
1235
+ sequence: sequence,
944
1236
  origin,
945
1237
  op: statement.op,
946
1238
  suffix: statement.suffix,
947
1239
  signal: this.#signalToJson(statement.signal),
948
- target_scheme: target.scheme,
949
- target_username: target.username,
950
- target_password: target.password,
951
- target_hostname: target.hostname,
952
- target_port: target.port,
953
- target_pathname: target.pathname,
954
- target_params: target.params,
955
- target_fragment: target.fragment,
1240
+ scheme: target.scheme,
1241
+ username: target.username,
1242
+ password: target.password,
1243
+ hostname: target.hostname,
1244
+ port: target.port,
1245
+ pathname: target.pathname,
1246
+ params: target.params,
1247
+ fragment: target.fragment,
956
1248
  lineMarker: lineMarkerJson,
957
1249
  tx: JSON.stringify(statement),
958
1250
  mimetype_tx: "application/json",
@@ -967,13 +1259,18 @@ export default class Engine {
967
1259
  throw new Error("Engine.#writeLog: INSERT ... RETURNING produced no row");
968
1260
  return row.id;
969
1261
  }
1262
+ // Normalize a parsed path for storage. The `file` scheme is a routing
1263
+ // internal — never stored, never rendered to the model. Both bare paths
1264
+ // and `file://...` inputs collapse to scheme=null at this boundary, so
1265
+ // entries.scheme / log_entries.scheme never carry the string "file".
970
1266
  #extractTarget(path) {
971
1267
  if (path === null)
972
1268
  return { scheme: null, username: null, password: null, hostname: null, port: null, pathname: null, params: null, fragment: null };
973
1269
  if (path.kind === "local")
974
1270
  return { scheme: null, username: null, password: null, hostname: null, port: null, pathname: path.raw, params: null, fragment: null };
1271
+ const scheme = path.scheme === "file" ? null : path.scheme;
975
1272
  return {
976
- scheme: path.scheme, username: path.username, password: path.password,
1273
+ scheme, username: path.username, password: path.password,
977
1274
  hostname: path.hostname, port: path.port, pathname: path.pathname,
978
1275
  params: JSON.stringify(path.params), fragment: path.fragment,
979
1276
  };