@plurnk/plurnk-service 0.55.0 → 0.57.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 (117) hide show
  1. package/.env.example +9 -14
  2. package/SPEC.md +133 -99
  3. package/dist/content/matcher.d.ts +4 -0
  4. package/dist/content/matcher.d.ts.map +1 -1
  5. package/dist/content/matcher.js +73 -92
  6. package/dist/content/matcher.js.map +1 -1
  7. package/dist/core/ChannelWrite.d.ts +7 -1
  8. package/dist/core/ChannelWrite.d.ts.map +1 -1
  9. package/dist/core/ChannelWrite.js +8 -2
  10. package/dist/core/ChannelWrite.js.map +1 -1
  11. package/dist/core/ChannelWrite.sql +12 -2
  12. package/dist/core/Engine.d.ts +9 -0
  13. package/dist/core/Engine.d.ts.map +1 -1
  14. package/dist/core/Engine.js +345 -104
  15. package/dist/core/Engine.js.map +1 -1
  16. package/dist/core/Engine.sql +44 -2
  17. package/dist/core/SchemeRegistry.d.ts.map +1 -1
  18. package/dist/core/SchemeRegistry.js +6 -1
  19. package/dist/core/SchemeRegistry.js.map +1 -1
  20. package/dist/core/fork.d.ts.map +1 -1
  21. package/dist/core/fork.js +20 -4
  22. package/dist/core/fork.js.map +1 -1
  23. package/dist/core/fork.sql +29 -0
  24. package/dist/core/git-membership.d.ts.map +1 -1
  25. package/dist/core/git-membership.js +40 -4
  26. package/dist/core/git-membership.js.map +1 -1
  27. package/dist/core/packet-inject.d.ts +2 -0
  28. package/dist/core/packet-inject.d.ts.map +1 -1
  29. package/dist/core/packet-inject.js +28 -1
  30. package/dist/core/packet-inject.js.map +1 -1
  31. package/dist/core/packet-wire.d.ts.map +1 -1
  32. package/dist/core/packet-wire.js +32 -28
  33. package/dist/core/packet-wire.js.map +1 -1
  34. package/dist/core/session-settings.d.ts +1 -6
  35. package/dist/core/session-settings.d.ts.map +1 -1
  36. package/dist/core/session-settings.js +4 -13
  37. package/dist/core/session-settings.js.map +1 -1
  38. package/dist/schemes/Exec.d.ts.map +1 -1
  39. package/dist/schemes/Exec.js +41 -7
  40. package/dist/schemes/Exec.js.map +1 -1
  41. package/dist/schemes/ExecOutputScheme.d.ts.map +1 -1
  42. package/dist/schemes/ExecOutputScheme.js +2 -1
  43. package/dist/schemes/ExecOutputScheme.js.map +1 -1
  44. package/dist/schemes/File.d.ts.map +1 -1
  45. package/dist/schemes/File.js +9 -6
  46. package/dist/schemes/File.js.map +1 -1
  47. package/dist/schemes/Log.d.ts +4 -0
  48. package/dist/schemes/Log.d.ts.map +1 -1
  49. package/dist/schemes/Log.js +35 -19
  50. package/dist/schemes/Log.js.map +1 -1
  51. package/dist/schemes/Log.sql +14 -11
  52. package/dist/schemes/Run.d.ts +7 -1
  53. package/dist/schemes/Run.d.ts.map +1 -1
  54. package/dist/schemes/Run.js +49 -9
  55. package/dist/schemes/Run.js.map +1 -1
  56. package/dist/schemes/_entry-find.d.ts +16 -1
  57. package/dist/schemes/_entry-find.d.ts.map +1 -1
  58. package/dist/schemes/_entry-find.js +84 -50
  59. package/dist/schemes/_entry-find.js.map +1 -1
  60. package/dist/schemes/_entry-find.sql +23 -0
  61. package/dist/schemes/_entry-graph.d.ts +6 -1
  62. package/dist/schemes/_entry-graph.d.ts.map +1 -1
  63. package/dist/schemes/_entry-graph.js +35 -22
  64. package/dist/schemes/_entry-graph.js.map +1 -1
  65. package/dist/schemes/_entry-graph.sql +9 -7
  66. package/dist/schemes/_entry-manifest.d.ts +1 -1
  67. package/dist/schemes/_entry-manifest.d.ts.map +1 -1
  68. package/dist/schemes/_entry-manifest.js +38 -7
  69. package/dist/schemes/_entry-manifest.js.map +1 -1
  70. package/dist/schemes/_entry-ops.d.ts +5 -0
  71. package/dist/schemes/_entry-ops.d.ts.map +1 -1
  72. package/dist/schemes/_entry-ops.js +14 -0
  73. package/dist/schemes/_entry-ops.js.map +1 -1
  74. package/dist/schemes/_entry-semantic.d.ts +1 -1
  75. package/dist/schemes/_entry-semantic.d.ts.map +1 -1
  76. package/dist/schemes/_entry-semantic.js +15 -12
  77. package/dist/schemes/_entry-semantic.js.map +1 -1
  78. package/dist/schemes/exec-abort.d.ts +4 -0
  79. package/dist/schemes/exec-abort.d.ts.map +1 -1
  80. package/dist/schemes/exec-abort.js +6 -0
  81. package/dist/schemes/exec-abort.js.map +1 -1
  82. package/dist/server/Daemon.d.ts.map +1 -1
  83. package/dist/server/Daemon.js +133 -19
  84. package/dist/server/Daemon.js.map +1 -1
  85. package/dist/server/MethodRegistry.d.ts +2 -0
  86. package/dist/server/MethodRegistry.d.ts.map +1 -1
  87. package/dist/server/drain.sql +17 -5
  88. package/dist/server/dsl.d.ts +9 -1
  89. package/dist/server/dsl.d.ts.map +1 -1
  90. package/dist/server/dsl.js +31 -4
  91. package/dist/server/dsl.js.map +1 -1
  92. package/dist/server/envelope.d.ts.map +1 -1
  93. package/dist/server/envelope.js +0 -9
  94. package/dist/server/envelope.js.map +1 -1
  95. package/dist/server/logEntry.d.ts.map +1 -1
  96. package/dist/server/logEntry.js +3 -1
  97. package/dist/server/logEntry.js.map +1 -1
  98. package/dist/server/methods/log_read.d.ts.map +1 -1
  99. package/dist/server/methods/log_read.js +7 -1
  100. package/dist/server/methods/log_read.js.map +1 -1
  101. package/dist/server/methods/log_read.sql +17 -7
  102. package/dist/server/methods/op_look.d.ts +5 -0
  103. package/dist/server/methods/op_look.d.ts.map +1 -0
  104. package/dist/server/methods/op_look.js +30 -0
  105. package/dist/server/methods/op_look.js.map +1 -0
  106. package/dist/server/methods/op_parse.d.ts.map +1 -1
  107. package/dist/server/methods/op_parse.js +7 -2
  108. package/dist/server/methods/op_parse.js.map +1 -1
  109. package/dist/server/methods/session_constraints.js +1 -1
  110. package/dist/server/methods/session_constraints.js.map +1 -1
  111. package/dist/server/methods/session_create.d.ts.map +1 -1
  112. package/dist/server/methods/session_create.js +7 -14
  113. package/dist/server/methods/session_create.js.map +1 -1
  114. package/docs/run.md +3 -1
  115. package/migrations/0000-00-00.01_schema.sql +21 -1
  116. package/package.json +10 -10
  117. package/requirements.md +4 -0
@@ -9,7 +9,7 @@ import GitState from "./git-state.js";
9
9
  import Fork from "./fork.js";
10
10
  import RunCap from "./run-cap.js";
11
11
  import { teachingLine, docsExcludeSet } from "./teaching.js";
12
- import { readPacketInject } from "./packet-inject.js";
12
+ import { readPacketInject, readSystemPolicy, readProjectPolicy } from "./packet-inject.js";
13
13
  import SessionSettings from "./session-settings.js";
14
14
  import { decodePathParens } from "./path-decode.js";
15
15
  import { DEFAULT_LOOP_FLAGS } from "./scheme-types.js";
@@ -64,14 +64,14 @@ const readMaxCommands = () => {
64
64
  return DEFAULT_MAX_COMMANDS;
65
65
  return n;
66
66
  };
67
- // PLURNK_MANIFEST_ITEMS — the turn-0 manifest preview. null = off (no foist);
67
+ // PLURNK_FILES_ITEMS — the turn-0 manifest preview. null = off (no foist);
68
68
  // -1 = the full manifest; positive N = the first N items. 0 / unset = off.
69
- const normalizeManifestItems = (n) => (!Number.isFinite(n) || n === 0 ? null : n < 0 ? -1 : n);
70
- const readManifestItems = () => {
71
- const raw = process.env.PLURNK_MANIFEST_ITEMS;
69
+ const normalizeFilesItems = (n) => (!Number.isFinite(n) || n === 0 ? null : n < 0 ? -1 : n);
70
+ const readFilesItems = () => {
71
+ const raw = process.env.PLURNK_FILES_ITEMS;
72
72
  if (raw === undefined || raw.length === 0)
73
73
  return null;
74
- return normalizeManifestItems(Number.parseInt(raw, 10));
74
+ return normalizeFilesItems(Number.parseInt(raw, 10));
75
75
  };
76
76
  import { ProviderError } from "@plurnk/plurnk-providers";
77
77
  // Resolution timeout — proposed entries auto-cancel if nothing arrives
@@ -315,6 +315,11 @@ class Engine {
315
315
  // #263 — the last turn's prompt tokens = current window occupancy (gauge numerator), NOT the
316
316
  // summed promptTokens above, which overcounts a context that grows across turns.
317
317
  contextTokens: row?.context ?? 0,
318
+ // #274 — the last turn's model window (denominator); null when the provider reports none.
319
+ contextSize: row?.context_size ?? null,
320
+ // #252 — the latest turn's opaque provider blob, parsed for the wire. Empty {} when the
321
+ // provider returned no meta. The service forwards it; it never reads a field within.
322
+ meta: JSON.parse(row?.meta ?? "{}"),
318
323
  };
319
324
  }
320
325
  #pushTelemetry(sessionId, loopId, event) {
@@ -336,26 +341,25 @@ class Engine {
336
341
  this.#telemetryBuffer.delete(loopId);
337
342
  return buf;
338
343
  }
339
- // Pull a ±windowLines context block around `targetLine` (1-based) from
340
- // `content`, formatted with N:\t prefixes same shape the model
341
- // already knows from READ output and numbered Index entries. Used to
342
- // give parse_error telemetry concrete locality: the model sees what
343
- // it wrote on the offending line, not just an abstract error message.
344
- //
345
- // Lenient: targetLine 0 clamps to 1; targetLine beyond content
346
- // returns whatever overlap exists; empty content returns "".
347
- #extractSnippet(content, targetLine, windowLines) {
348
- if (content.length === 0)
349
- return "";
350
- const lines = content.split("\n");
351
- const target = Math.max(1, targetLine);
352
- const start = Math.max(1, target - windowLines);
353
- const end = Math.min(lines.length, target + windowLines);
354
- const slice = [];
355
- for (let i = start; i <= end; i++) {
356
- slice.push(`${i}:\t${lines[i - 1] ?? ""}`);
344
+ // A @plurnk/gbnf divergence position (providers#24) is a CODE-POINT offset into the
345
+ // model's content; the snippet/telemetry surface speaks 1-based line + 0-based column.
346
+ // Convert over code points (not UTF-16 units) so an astral char doesn't skew the line,
347
+ // clamping out-of-range offsets to the content's end.
348
+ #offsetToLineColumn(content, offset) {
349
+ const cps = Array.from(content);
350
+ const clamped = Math.max(0, Math.min(offset, cps.length));
351
+ let line = 1;
352
+ let column = 0;
353
+ for (let i = 0; i < clamped; i++) {
354
+ if (cps[i] === "\n") {
355
+ line++;
356
+ column = 0;
357
+ }
358
+ else {
359
+ column++;
360
+ }
357
361
  }
358
- return slice.join("\n");
362
+ return { line, column };
359
363
  }
360
364
  async runLoop({ provider, messages, requirements = "", 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, }) {
361
365
  const turnIds = [];
@@ -525,7 +529,14 @@ class Engine {
525
529
  // from sequence=2 onward on prompt-foisted turns; 1 onward
526
530
  // otherwise.
527
531
  let nextActionIndex = 1;
532
+ // §model-entry — the run's first turn opens with the model's own turn-0, mirrored OPEN: a
533
+ // worked turn PLAN → the environment FINDs the foist ACTUALLY dispatches → SEND[102]. Built
534
+ // from the real ops below (not a static print — we lean into the genuine echo paradigm) and
535
+ // written at sequence 1, so it reads first as the emission with the foisted results following.
536
+ const turnZeroMoves = [];
528
537
  if (seq === 1) {
538
+ if (runFirstLoop)
539
+ nextActionIndex = 2; // reserve sequence 1 for the turn-0 echo
529
540
  // Operator doc READs (PLURNK_MD_<ALIAS>, §actor-boundary-doc-injection). The docs were materialized
530
541
  // as plurnk:///<entry> entries by the plurnk run (loop_run, via the
531
542
  // §actor-boundary keystone); foist a READ of each into THIS turn-0 so the model
@@ -604,16 +615,16 @@ class Engine {
604
615
  const fsDivergences = await GitMembership.indexGitMembership(systemCtx);
605
616
  await this.#logFsFictions(sessionId, fsDivergences);
606
617
  await EntryManifest.maintainDerivations(systemCtx);
607
- // Turn-0 catalog preview (PLURNK_MANIFEST_ITEMS, §actor-boundary-manifest-preview):
618
+ // Turn-0 catalog preview (PLURNK_FILES_ITEMS, §actor-boundary-manifest-preview):
608
619
  // one FIND(scheme:///**) per scheme that holds entries, foisted into the run's first
609
620
  // model turn so it opens with its catalog (the per-scheme arrays that replaced the
610
621
  // single manifest.json). -1 → each scheme's whole catalog; N → its first N rows
611
622
  // (clamped to the scheme's count so FIND's strict <L> never 416s); off by default.
612
623
  if (seq === 1) {
613
- // #231 — a session's client-chosen manifestItems REPLACES the env default outright.
614
- const { manifestItems: sessionMI, autoReadAgents } = await SessionSettings.read(this.#db, sessionId);
615
- const manifestItems = sessionMI !== null ? normalizeManifestItems(sessionMI) : readManifestItems();
616
- if (manifestItems !== null && runFirstLoop) { // #269 — catalog preview is run-once
624
+ // #231 — a session's client-chosen filesItems REPLACES the env default outright.
625
+ const { filesItems: sessionMI } = await SessionSettings.read(this.#db, sessionId);
626
+ const filesItems = sessionMI !== null ? normalizeFilesItems(sessionMI) : readFilesItems();
627
+ if (filesItems !== null && runFirstLoop) { // #269 — catalog preview is run-once
617
628
  // engine_scheme_catalog_summary is the scheme source: session-scoped, ordered,
618
629
  // one row per scheme that has entries (scheme=null → file). log:// is absent —
619
630
  // it lives in log_entries, not the catalog (present-mode, the # Log section).
@@ -633,9 +644,12 @@ class Engine {
633
644
  // documenting surface. The prompt is shown in # Prompt, so the plurnk catalog
634
645
  // the model orients on IS the docs; doc links are no longer rendered inline (#270).
635
646
  const isPlurnk = schemeName === "plurnk";
636
- // entries===0 (an always-foisted empty known/unknown) uncapped: nothing to
637
- // clamp, and Math.min(items, 0)=0 would emit a degenerate <1,0> marker.
638
- const cap = isPlurnk || manifestItems < 0 || entries === 0 ? null : Math.min(manifestItems, entries);
647
+ // Only the FILE list is cappable (PLURNK_FILES_ITEMS first-N): the tracked-file
648
+ // tree is external and arbitrarily large. Every other scheme known/unknown
649
+ // (memory), run (scratch), plurnk (docs) foists FULL, never truncated: a partial
650
+ // view of the model's own memory reads as withheld. file at -1, or any non-file
651
+ // scheme → no cap. (file in this loop always has entries>0, so no degenerate <1,0>.)
652
+ const cap = schemeName === "file" && filesItems > 0 ? Math.min(filesItems, entries) : null;
639
653
  const catalogFind = {
640
654
  op: "FIND", suffix: "", signal: null,
641
655
  target: {
@@ -655,33 +669,25 @@ class Engine {
655
669
  sequence: nextActionIndex, origin: "plurnk", onDispatch,
656
670
  });
657
671
  nextActionIndex++;
672
+ // §model-entry — the same FIND, rendered back to DSL for the turn-0 echo (the model's
673
+ // own survey, mirrored OPEN). The <L> cap rides as `<1,N>`, exactly as the model would type it.
674
+ turnZeroMoves.push(`<<FIND(${isPlurnk ? "plurnk://docs/**" : `${schemeName}:///**`})${cap === null ? "" : `<1,${cap}>`}::FIND`);
658
675
  }
659
- }
660
- // #268 auto-READ the configured AGENTS file(s) into THIS first model turn. Env-driven
661
- // (PLURNK_AGENTS_AUTO / PLURNK_AGENTS_FILES), overridable per-session by autoReadAgents; the
662
- // matching files are auto-PICKed into membership at session setup (envelope). The model sees
663
- // only the READ a normal file:/// member READ, read-write so it edits the scratchpad back.
664
- const { auto: agentsAuto, files: agentsFiles } = SessionSettings.resolveAgentsAutoload(autoReadAgents);
665
- if (agentsAuto && runFirstLoop) { // #269 — run-once, the run's first loop
666
- for (const file of agentsFiles) {
667
- const pathname = file.startsWith("/") ? file : `/${file}`;
668
- const member = await this.#db.crud_get_member_sig.get({ session_id: sessionId, scheme: null, pathname });
669
- if (member === undefined)
670
- continue; // absent / non-git session → not a member → skip
671
- const agentsRead = {
672
- op: "READ", suffix: "", signal: null, lineMarker: null,
673
- target: {
674
- kind: "url", raw: `file://${pathname}`, scheme: "file",
675
- username: null, password: null, hostname: null, port: null,
676
- pathname, params: {}, fragment: null,
677
- },
678
- body: null, position: { line: 1, column: 1 },
676
+ // §run-scheme — Manifest(run) = session-scope ∪ THIS run's run-scope. Foist the
677
+ // building run's OWN scratch (run://self/**, uncapped a run needs the full view to
678
+ // manage its private workspace) so it's catalogued in ITS perspective alone; other
679
+ // runs reach it only via explicit FIND(run://<name>/**). A run with no scratch foists nothing.
680
+ const selfRun = await this.#db.run_name_by_id.get({ run_id: runId });
681
+ const scratch = selfRun === undefined ? 0 : (await this.#db.engine_run_scratch_count.get({ session_id: sessionId, owner_prefix: `/${selfRun.name}/*` }))?.entries ?? 0;
682
+ if (scratch > 0) {
683
+ const runFind = {
684
+ op: "FIND", suffix: "", signal: null,
685
+ target: { kind: "url", raw: "run://self/**", scheme: "run", username: null, password: null, hostname: "self", port: null, pathname: "/**", params: {}, fragment: null },
686
+ body: null, lineMarker: null, position: { line: 1, column: 1 },
679
687
  };
680
- await this.dispatch({
681
- statement: agentsRead, sessionId, runId, loopId, turnId,
682
- sequence: nextActionIndex, origin: "plurnk", onDispatch,
683
- });
688
+ await this.dispatch({ statement: runFind, sessionId, runId, loopId, turnId, sequence: nextActionIndex, origin: "plurnk", onDispatch });
684
689
  nextActionIndex++;
690
+ turnZeroMoves.push("<<FIND(run://self/**)::FIND"); // §model-entry — the run-scope survey, into the turn-0 echo
685
691
  }
686
692
  }
687
693
  // #260 — foist a turn-0 READ of each client-passed @file path so its content sits in front
@@ -705,12 +711,24 @@ class Engine {
705
711
  });
706
712
  nextActionIndex++;
707
713
  }
714
+ // §model-entry — mirror the model's turn-0 OPEN at sequence 1: PLAN → the FINDs actually
715
+ // foisted above (real, their results already in the log) → SEND[102]. Dynamic — it reflects
716
+ // the true survey, never a frozen print — and OPEN: the worked example the model orients on,
717
+ // so the grammar can stay thin. Subsequent turns mirror the model's real output, folded.
718
+ if (runFirstLoop) {
719
+ const emission = ["<<PLAN:Initialize:PLAN", ...turnZeroMoves, "<<SEND[102]:Initialized:SEND"].join("\n");
720
+ await this.#writeModelEntry({ verbatim: emission, runId, loopId, turnId, sequence: 1, folded: false, origin: "plurnk" });
721
+ }
708
722
  }
709
723
  // §environment-observation — pre-seed the run's ambient observations (what changed since
710
724
  // it last looked) as foisted rows before the packet composes; advance the action index
711
725
  // past them so model ops continue after. Two instances of one machine: env-delta (sibling
712
726
  // edits · timestamp cursor · always folded) and exec streams (channel bytes · byte cursor ·
713
727
  // terminal delta opens). §env-delta §exec-stream
728
+ // §exec-poll — EXEC `<0>` is turn-scoped: reap the run's open turn-scoped streams (necessarily
729
+ // from a prior turn — this runs before the turn's own spawns) so a `<0>` never survives into
730
+ // the subsequent turn. The terminal output then surfaces born-OPEN via the stream-delta path.
731
+ await this.#reapTurnScopedStreams(runId);
714
732
  nextActionIndex += await this.#materializeEnvironmentDeltas({ sessionId, runId, loopId, turnId, fromSequence: nextActionIndex });
715
733
  nextActionIndex += await this.#materializeStreamDeltas({ runId, loopId, turnId, fromSequence: nextActionIndex });
716
734
  // SPEC §telemetry — git working-tree state for the telemetry section, read once
@@ -740,17 +758,19 @@ class Engine {
740
758
  await this.#db.engine_close_turn.run({
741
759
  id: turnId, status: 413, packet: JSON.stringify(hardPacket),
742
760
  usage_prompt: 0, usage_completion: 0, usage_cached: 0, usage_cost_pico: 0,
743
- finish_reason: "budget_hard_stop", model: provider.model,
761
+ usage_context_size: provider.contextSize, // #274 — the model's window, even on a hard-413 turn
762
+ finish_reason: "budget_hard_stop", model: provider.model, meta: "{}",
744
763
  });
745
764
  return { turnId, status: 413, statuses: [], fingerprint: "", budgetStruck: enforced.struck, budgetHardStop: true, steerStruck: false };
746
765
  }
747
766
  const modelMessages = this.#packetToWireMessages(requestPacket);
748
- // maxTokens = remaining context window (loop policy, plurnk-providers#10).
749
- // The 0.28.0 EOS-forcing root terminates the turn at the status SEND, but a
750
- // grammar can't bound degeneration *inside* a statement body this caps the
751
- // decode at the free window so a runaway can't reach the context wall.
752
- const genCeiling = _a.computeCeiling(provider.contextSize, this.#budgetCeiling); // provider.contextSize, the immutable identity, read by the budget — §provider-surface-identity
753
- const maxTokens = genCeiling === null ? undefined : Math.max(1, genCeiling - requestPacket.tokens);
767
+ // No decode cap. Our budget governs the TRANSMISSION packet (the grinder folds
768
+ // the input under the ceiling); the model's decode reasoning + emission — is
769
+ // out of band, owned by the provider's own context window. Deriving a maxTokens
770
+ // from our budget conflated the two and guillotined a reasoning model's
771
+ // out-of-band thinking as the packet filled (`ceiling - packet` near-zero
772
+ // decode → finish=length mid-reasoning no emission strike spiral). The
773
+ // provider enforces its physical wall on its own.
754
774
  let response;
755
775
  // #249 — plugin attribution tags onto the per-turn generate() wire. Value is the
756
776
  // active-plugin set (placeholder); real per-turn grounding is deferred.
@@ -761,7 +781,7 @@ class Engine {
761
781
  // #249 — session-stable frontend id, forwarded as Plurnk-Client by the plurnk provider only.
762
782
  const { client } = await SessionSettings.read(this.#db, sessionId);
763
783
  try {
764
- response = await provider.generate({ messages: modelMessages, runId: String(runId), signal, grammar: await this.#grammarConstraint(), maxTokens, attributions: attributions.length > 0 ? attributions : undefined, client: client ?? undefined }); // §provider-surface-generate §provider-guarantees-single-call §provider-guarantees-signal-wired §attribution-plurnk-namespace-reserved §client-telemetry
784
+ response = await provider.generate({ messages: modelMessages, runId: String(runId), signal, grammar: await this.#grammarConstraint(), attributions: attributions.length > 0 ? attributions : undefined, client: client ?? undefined }); // §provider-surface-generate §provider-guarantees-single-call §provider-guarantees-signal-wired §attribution-plurnk-namespace-reserved §client-telemetry
765
785
  }
766
786
  catch (err) {
767
787
  // Every provider error surfaces as telemetry (the client/model sees the cause). #256:
@@ -770,8 +790,11 @@ class Engine {
770
790
  // accepted: fall through as an empty no-op turn so the strike rail retries. Every other
771
791
  // kind (rate_limit, network_failure, unauthorized, …) is terminal — telemetry'd, then
772
792
  // propagated to end the loop (rather than only the opaque loop.run rejection).
793
+ // NOTE (providers 0.19.0 / #275): only the CONSTRAINED path still throws grammar_unenforced.
794
+ // In GBNF-filter mode the provider returns the bytes with a grammar_unenforced telemetry
795
+ // event instead — recovered on the success path below (response.telemetry), no empty turn.
773
796
  if (err instanceof ProviderError) {
774
- this.#pushTelemetry(sessionId, loopId, { source: "provider", kind: err.kind, message: err.message });
797
+ this.#pushTelemetry(sessionId, loopId, { source: "provider", kind: err.kind, message: err.message, level: "error" });
775
798
  if (err.kind !== "grammar_unenforced")
776
799
  throw err;
777
800
  response = {
@@ -801,14 +824,31 @@ class Engine {
801
824
  // the snippet, the model sees "invalid xpath at 1:0" but can't
802
825
  // connect that to what IT wrote — and tends to regenerate the
803
826
  // same broken emission. See edit-todo demo for the canonical case.
804
- for (const { message, line, column, source } of parseErrors ?? []) {
827
+ // Parse errors are LOG ITEMS now (§telemetry one budget surface): each failed-to-parse
828
+ // emission records an actionless `error` row below, after the turn's dispatched ops are
829
+ // sequenced (see the parse-error log write past the dispatch loop). The errors section
830
+ // derives a pointer to it from log≥400, uniform with action_failure.
831
+ // providers#24 / #275: non-fatal provider telemetry on a SUCCESSFUL turn. In GBNF-filter
832
+ // mode the provider no longer THROWS grammar_unenforced — it returns the model's bytes
833
+ // (here, packetAssistant.content) and attaches the conflict as a telemetry event carrying
834
+ // the divergence code-point position. Forward each event with a content-offset `line:col`;
835
+ // the model resolves it against its own emission — the born-OPEN `model` row (§model-entry),
836
+ // not an embedded snippet that would duplicate the emission.
837
+ let hadContentOffsetNotice = false;
838
+ for (const event of response.telemetry ?? []) {
839
+ const located = typeof event.position === "number"
840
+ ? this.#offsetToLineColumn(packetAssistant.content, event.position)
841
+ : null;
842
+ if (located !== null)
843
+ hadContentOffsetNotice = true;
805
844
  this.#pushTelemetry(sessionId, loopId, {
806
- source: "grammar",
807
- kind: "parse_error",
808
- message,
809
- position: { type: "content-offset", line, column },
810
- snippet: this.#extractSnippet(packetAssistant.content, line, 2),
811
- parserSource: source,
845
+ source: event.source,
846
+ kind: event.kind,
847
+ message: event.message ?? "",
848
+ level: event.level ?? "warn", // forward the producer's severity; default for a producer predating the field
849
+ ...(located !== null
850
+ ? { position: { type: "content-offset", line: located.line, column: located.column } }
851
+ : {}),
812
852
  });
813
853
  }
814
854
  const opsCount = packetAssistant.ops.length;
@@ -822,19 +862,23 @@ class Engine {
822
862
  // §grinder-strike-coupling): the loop continues, the model sees the steering hint not the strike
823
863
  // count, and a non-resolver spins out to the engine's 500.
824
864
  let steerStruck = false;
825
- // Premature terminate: a SEND[200] while the run still holds a live stream/spawnthe model
826
- // declared done with work running. Downgrade the 200 to 102 so it dispatches as a continue (its
827
- // body is preserved, not discarded) and steer; the stream's own conclusion or a KILL is the exit.
865
+ // Premature terminate: a SEND[200] while the run still holds a live thingan open stream/spawn
866
+ // OR a non-terminal child run (§run-lifecycle: children and streams are the same kind of "live
867
+ // thing a run holds"). The model declared done with work running. Downgrade the 200 to 102 so it
868
+ // dispatches as a continue (its body is preserved, not discarded) and steer; the stream's/child's
869
+ // own conclusion (the wake edge) or a KILL is the exit.
828
870
  if (sendOp?.signal === 200) {
829
871
  const openSubs = await this.#db.find_open_subscriptions_for_run.all({ run_id: runId });
830
872
  const execHandler = this.#schemes.get("exec");
831
- if (openSubs.length > 0 || execHandler?.hasActiveSpawns?.(runId) === true) {
873
+ const liveChild = await this.#db.engine_run_has_live_child.get({ run_id: runId });
874
+ if (openSubs.length > 0 || execHandler?.hasActiveSpawns?.(runId) === true || liveChild !== undefined) {
832
875
  sendOp.signal = TURN_STATUS_IMPLICIT_CONTINUE; // 102 — downgraded, no longer a terminal
833
876
  steerStruck = true;
834
877
  this.#pushTelemetry(sessionId, loopId, {
835
878
  source: "engine:rail",
836
879
  kind: "premature_terminate",
837
- message: "Attempted termination with active streams. Terminate with 202 to hibernate until stream completion, KILL(path) with 200 again to clean up, or 499 to fail.",
880
+ message: "Attempted termination with active streams or child runs. Terminate with 202 to hibernate until they complete, KILL(path) with 200 again to clean up, or 499 to fail.",
881
+ level: "warn",
838
882
  });
839
883
  }
840
884
  }
@@ -852,6 +896,7 @@ class Engine {
852
896
  source: "engine:rail",
853
897
  kind: "idle_turn",
854
898
  message: "If the turn's work is complete, terminate with 200. If awaiting a stream or run trigger, terminate with 202 to hibernate.",
899
+ level: "warn",
855
900
  });
856
901
  }
857
902
  // Close the turn with the final packet, status, and usage stats.
@@ -865,8 +910,12 @@ class Engine {
865
910
  usage_completion: usage.completion,
866
911
  usage_cached: usage.cached,
867
912
  usage_cost_pico: provider.costFor(usage), // §provider-surface-costfor
913
+ usage_context_size: provider.contextSize, // #274 — the running model's window (gauge denominator)
868
914
  finish_reason: finishReason,
869
915
  model,
916
+ // #252 — opaque provider→client metadata passthrough (e.g. balancePico the
917
+ // provider normalized). Stored verbatim, unenforced; the service never reads a field.
918
+ meta: JSON.stringify(response.meta ?? {}),
870
919
  });
871
920
  // Dispatch model ops starting at nextActionIndex (continues the
872
921
  // turn's running counter after any pre-model writes).
@@ -890,13 +939,18 @@ class Engine {
890
939
  || realCommands++ < maxCommands);
891
940
  const droppedCount = opsCount - opsToDispatch.length;
892
941
  const statuses = [];
893
- for (const [i, statement] of opsToDispatch.entries()) {
942
+ // Running counter — a multi-file READ writes N rows from one statement (rowsWritten),
943
+ // so the next op's sequence picks up after them. Collapses to nextActionIndex+i when
944
+ // every op writes one row (the common case).
945
+ let rowSeq = nextActionIndex;
946
+ for (const statement of opsToDispatch) {
894
947
  const result = await this.dispatch({
895
948
  statement, sessionId, runId, loopId, turnId,
896
- sequence: nextActionIndex + i,
949
+ sequence: rowSeq,
897
950
  origin, onDispatch,
898
951
  });
899
952
  statuses.push(result.status);
953
+ rowSeq += result.rowsWritten ?? 1;
900
954
  }
901
955
  // max_commands_exceeded IS model-facing: dropped ops are things
902
956
  // the model emitted that didn't run — it needs to know. Engine
@@ -908,8 +962,39 @@ class Engine {
908
962
  kind: "max_commands_exceeded",
909
963
  emitted: opsCount,
910
964
  dropped: droppedCount,
965
+ level: "error",
911
966
  });
912
967
  }
968
+ // §telemetry — parse errors as LOG ITEMS: a failed-to-parse emission records an actionless
969
+ // `error` row (status 400, no target, snippet = the foldable body) at the turn's next free
970
+ // sequence (after the dispatched ops). The model folds/kills/recalls it like any log entry,
971
+ // and the errors section derives a pointer (status + coordinate) from log≥400 — one surface.
972
+ let errSeq = rowSeq; // after every dispatched row, including a multi-file READ's fan-out
973
+ for (const { message, line, column, source } of parseErrors ?? []) {
974
+ await this.#db.engine_insert_log_entry.get({
975
+ run_id: runId, loop_id: loopId, turn_id: turnId, sequence: errSeq++,
976
+ origin: "model", source: "grammar", op: "error", suffix: "", signal: null,
977
+ scheme: null, username: null, password: null, hostname: null, port: null,
978
+ pathname: null, params: null, fragment: null, lineMarker: null,
979
+ tx: "", mimetype_tx: "text/plain",
980
+ // The error carries the parser message + a content-offset `line:col`; the model resolves
981
+ // it against its own born-OPEN emission (the `model` row, §model-entry), so no snippet is
982
+ // embedded. The derived errors-section pointer stays minimal (status + coordinate).
983
+ rx: JSON.stringify({ message, position: { type: "content-offset", line, column }, parserSource: source }),
984
+ mimetype_rx: "application/json",
985
+ status_rx: 400, tokens: 0, state: "resolved", outcome: null, attrs: "{}",
986
+ });
987
+ }
988
+ // §model-entry — mirror this turn's verbatim emission back as a `model` row, so the NEXT
989
+ // packet shows the model exactly what it last produced. Born OPEN when the turn carried an
990
+ // emission-level error the model must resolve against its own bytes — a parse error or a
991
+ // content-offset NOTICE (grammar_unenforced) — both report a line the model reads off this
992
+ // row. Folded otherwise (budget-neutral until the model OPENs it). Empty emissions (a
993
+ // struck/silent turn) write nothing — no prior output to mirror.
994
+ if (packetAssistant.content.trim().length > 0) {
995
+ const hadEmissionError = (parseErrors?.length ?? 0) > 0 || hadContentOffsetNotice;
996
+ await this.#writeModelEntry({ verbatim: packetAssistant.content, runId, loopId, turnId, sequence: errSeq++, folded: !hadEmissionError });
997
+ }
913
998
  // Zero ops is NOT an error to report — the model knows it emitted
914
999
  // nothing. Strike accounting (engine-internal) treats it as a
915
1000
  // struck turn; the model just sees an empty packet next turn.
@@ -1032,15 +1117,23 @@ class Engine {
1032
1117
  // peer sections (unbundled). The budget section carries its {{tokensFree}}
1033
1118
  // placeholders here; they resolve below once the assembled total is known.
1034
1119
  const inject = await readPacketInject(); // #240 — operator section, per-turn, fail-hard on a broken path
1120
+ const sessionRoot = (await this.#db.envelope_get_session.get({ id: sessionId }))?.project_root ?? null;
1121
+ const systemPolicy = await readSystemPolicy(); // ~/.plurnk/AGENTS.md (or PLURNK_POLICY)
1122
+ const projectPolicy = await readProjectPolicy(sessionRoot); // <projectRoot>/AGENTS.md (or PLURNK_PROJECT)
1035
1123
  const defaults = [
1036
1124
  { name: "definition", slot: "system", header: null, content: system_definition, tokens: 0 },
1037
1125
  { name: "tools", slot: "system", header: null, content: tools.join("\n"), tokens: 0 }, // titleless — the examples flow on from plurnk.md (definition) directly above
1038
1126
  { name: "schemes", slot: "system", header: "Plurnk System Schemes", content: this.#schemes.teach(), tokens: 0 },
1039
1127
  ...(inject !== null ? [{ name: "inject", slot: "system", header: "Plurnk Operator Notes", content: inject, tokens: 0 }] : []),
1040
- { name: "log", slot: "system", header: "Plurnk System Log", content: PacketWire.renderLog(log, countTokens), tokens: 0 },
1128
+ // policy: the client's privileged rules ~/.plurnk/AGENTS.md (system) then <root>/AGENTS.md (project) — below grammar/tools/schemes, above budget-the-law. AGENTS is POLICY here, never a curatable READable entry. Empty content ⇒ section omitted.
1129
+ { name: "system-policy", slot: "system", header: "Plurnk System Policy", content: systemPolicy ?? "", tokens: 0 },
1130
+ { name: "project-policy", slot: "system", header: "Project Policy", content: projectPolicy ?? "", tokens: 0 },
1131
+ // budget at the BOTTOM of the SYSTEM slot — budget is LAW (the ceiling is a hard constraint the model must obey), so it belongs in the lean privileged zone, not as user-slot status.
1132
+ { name: "budget", slot: "system", header: "Plurnk System Budget", content: budgetReadout, tokens: 0 },
1041
1133
  { name: "prompt", slot: "user", header: "Plurnk System User Prompt", content: prompt, tokens: 0 },
1042
- { name: "budget", slot: "user", header: "Plurnk System Budget", content: budgetReadout, tokens: 0 },
1043
1134
  { name: "errors", slot: "user", header: "Plurnk System Errors", content: PacketWire.renderErrors(telemetryErrors), tokens: 0 },
1135
+ // log in the USER slot, low: short-term memory (data, not rules) at the action point, so the model consults its history instead of skimming it. git follows it as workspace status — not law, so it stays in user, never system.
1136
+ { name: "log", slot: "user", header: "Plurnk System Log", content: PacketWire.renderLog(log, countTokens), tokens: 0 },
1044
1137
  { name: "git", slot: "user", header: "Plurnk System Git Status", content: PacketWire.renderGit(gitStatus), tokens: 0 },
1045
1138
  { name: "requirements", slot: "user", header: "Plurnk System Requirements", content: baseRequirements, tokens: 0 },
1046
1139
  ];
@@ -1187,6 +1280,7 @@ class Engine {
1187
1280
  source: "engine:rail",
1188
1281
  kind: "budget_overflow",
1189
1282
  folded: [...folded.entries()].map(([scheme, count]) => ({ scheme, count })),
1283
+ level: "warn",
1190
1284
  });
1191
1285
  }
1192
1286
  // Wire projection lives in ./packet-wire.ts so Engine and
@@ -1227,6 +1321,7 @@ class Engine {
1227
1321
  const parsedRx = r.mimetype_rx === "application/json" ? JSON.parse(r.rx) : r.rx;
1228
1322
  return {
1229
1323
  kind: "action_failure",
1324
+ level: "error", // the derived error pointer is always an error — clients color off level (#276)
1230
1325
  coordinate: `${r.loop_seq}/${r.turn_seq}/${r.sequence}`,
1231
1326
  op: r.op,
1232
1327
  target,
@@ -1314,6 +1409,18 @@ class Engine {
1314
1409
  }
1315
1410
  return written;
1316
1411
  }
1412
+ // §exec-poll — EXEC `<0>` is turn-scoped: abort the run's open turn-scoped streams via their
1413
+ // owning scheme (the same registry-routed abort the total reap uses). Called at each pre-turn
1414
+ // before the turn's own spawns, so every open turn-scoped sub here is from a prior turn — it
1415
+ // never survives into the subsequent turn. Fire-and-forget: the spawn finalizes async and its
1416
+ // terminal output surfaces born-OPEN through the stream-delta path (§exec-stream).
1417
+ async #reapTurnScopedStreams(runId) {
1418
+ const open = await this.#db.find_open_turn_scoped_subscriptions_for_run.all({ run_id: runId });
1419
+ for (const { id, scheme } of open) {
1420
+ const handler = this.#schemes.get(scheme);
1421
+ handler?.abortSubscription?.(id);
1422
+ }
1423
+ }
1317
1424
  // §environment-observation — exec streams as an instance of the ambient-observe machine:
1318
1425
  // each turn, emit each owned channel's unshown byte-delta as a foisted READ@200 row. Folded
1319
1426
  // while the channel streams; the terminal delta (channel closed) auto-OPENs. The cursor is the
@@ -1382,19 +1489,7 @@ class Engine {
1382
1489
  }
1383
1490
  async dispatch(context) {
1384
1491
  const { statement, sessionId, runId, loopId, turnId, sequence, origin, onDispatch } = context;
1385
- const schemeCtx = {
1386
- db: this.#db,
1387
- sessionId, runId, loopId, turnId,
1388
- writer: origin,
1389
- signal: this.#loopAborts.get(loopId)?.signal,
1390
- streamEventNotify: this.#streamEventNotify,
1391
- wakeRunNotify: this.#wakeRunNotify,
1392
- injectRun: this.#injectRun,
1393
- mimetypes: this.#mimetypes,
1394
- tokenize: this.#tokenize,
1395
- pushTelemetry: (event) => this.#pushTelemetry(sessionId, loopId, event),
1396
- executors: this.#executors,
1397
- };
1492
+ const schemeCtx = this.#buildSchemeCtx({ sessionId, runId, loopId, turnId, origin });
1398
1493
  let result;
1399
1494
  let denial = this.#checkWritable(statement, origin);
1400
1495
  if (denial === null)
@@ -1402,6 +1497,12 @@ class Engine {
1402
1497
  if (denial !== null) {
1403
1498
  result = denial;
1404
1499
  }
1500
+ else if (_a.#readFansOut(statement)) {
1501
+ // READ honors FIND: a glob/folder scope or a matcher fans out to one log row per MATCH
1502
+ // (its own writeLogs), returning early. A READ never proposes, so it bypasses the
1503
+ // single-row path below. A bare entry, body-less, falls through to the direct read. #286
1504
+ return await this.#handleReadFanout(statement, schemeCtx, { runId, loopId, turnId, sequence, origin, onDispatch });
1505
+ }
1405
1506
  else {
1406
1507
  // SPEC §scheme-surface + plurnk-schemes#1: action-entry-as-outcome. Scheme-handler
1407
1508
  // exceptions become the action-entry's outcome (status 500), not a
@@ -1510,6 +1611,38 @@ class Engine {
1510
1611
  }
1511
1612
  return result;
1512
1613
  }
1614
+ // op.look (#283) — resolve a READ and return its content WITHOUT writing a
1615
+ // log_entries row: the client's off-run inspection primitive (LOOK → READ,
1616
+ // invisible to the model). READ never mutates and never proposes, so this is
1617
+ // dispatch's resolve path minus #writeLog. Runs on the client loop, so the
1618
+ // human's inspection is never constrained by a model loop's flags. {§op-look}
1619
+ async look(context) {
1620
+ const { statement, sessionId, runId, loopId, origin = "client" } = context;
1621
+ if (statement.op !== "READ")
1622
+ throw new Error(`look resolves READ only; got ${statement.op}`);
1623
+ // turnId is a write-time FK only — a look writes no row, so 0 (no turn) is inert.
1624
+ const schemeCtx = this.#buildSchemeCtx({ sessionId, runId, loopId, turnId: 0, origin });
1625
+ const denial = await this.#checkFlagsGate(statement, loopId);
1626
+ if (denial !== null)
1627
+ return denial;
1628
+ return this.#run(this.#schemeNameOf(statement.target), statement, schemeCtx);
1629
+ }
1630
+ #buildSchemeCtx(ids) {
1631
+ const { sessionId, runId, loopId, turnId, origin } = ids;
1632
+ return {
1633
+ db: this.#db,
1634
+ sessionId, runId, loopId, turnId,
1635
+ writer: origin,
1636
+ signal: this.#loopAborts.get(loopId)?.signal,
1637
+ streamEventNotify: this.#streamEventNotify,
1638
+ wakeRunNotify: this.#wakeRunNotify,
1639
+ injectRun: this.#injectRun,
1640
+ mimetypes: this.#mimetypes,
1641
+ tokenize: this.#tokenize,
1642
+ pushTelemetry: (event) => this.#pushTelemetry(sessionId, loopId, event),
1643
+ executors: this.#executors,
1644
+ };
1645
+ }
1513
1646
  // On accept, run the scheme's applyResolution — File writes disk, Exec spawns. §proposal-accept-applies
1514
1647
  async #runApplyResolution(statement, originalResult, resolution, ids) {
1515
1648
  const { sessionId, runId, loopId, turnId } = ids;
@@ -1800,17 +1933,19 @@ class Engine {
1800
1933
  #isRunFork(statement) {
1801
1934
  return statement.op === "COPY" && this.#schemeNameOf(statement.target) === "run";
1802
1935
  }
1803
- // COPY(run:///<src>):prompt — fork: deep-copy the source run's log into a new
1804
- // run (Fork), then start it with the prompt (ctx.injectRun). Source "."/"" =
1805
- // self (ctx.runId); a name resolves within the session (404 if absent).
1936
+ // COPY(run://<src>):prompt — fork: deep-copy the source run's log into a new
1937
+ // run (Fork), then start it with the prompt (ctx.injectRun). Source `run://self` =
1938
+ // self (ctx.runId); a name resolves within the session (404 if absent). §run-scheme,
1806
1939
  // §machine-processes-fork-copies-the-log
1807
1940
  async #handleRunFork(statement, ctx) {
1808
1941
  const target = statement.target;
1809
1942
  if (target === null)
1810
1943
  return { status: 400, error: "run:// fork requires a source run" };
1811
1944
  const name = target.kind === "url" ? (target.hostname ?? "") : ""; // §run-scheme — run is the AUTHORITY (run://<name>), not the path
1945
+ if (name === "")
1946
+ return { status: 400, error: "run:// fork requires a source run name or 'self' (run://self)" };
1812
1947
  let srcRunId = ctx.runId;
1813
- if (name !== "" && name !== ".") {
1948
+ if (name !== "self") {
1814
1949
  const row = await this.#db.run_resolve_by_name.get({ session_id: ctx.sessionId, name });
1815
1950
  if (row === undefined)
1816
1951
  return { status: 404, error: `run://${name} not found in this session` };
@@ -1894,8 +2029,9 @@ class Engine {
1894
2029
  const schemeName = this.#schemeNameOf(path);
1895
2030
  if (schemeName === null)
1896
2031
  return { status: 400, error: "KILL target must be a URL path with a scheme" };
1897
- if (schemeName === "log")
1898
- return { status: 405, error: "log:/// is append-only; KILL must bounce" };
2032
+ // KILL on log:/// erases the log row(s) — the model's DB-storage curation lever
2033
+ // (plurnk.md:36, :98), routed to Log.kill below via the killable.kill path. The old
2034
+ // "append-only" 405 forbade what the grammar requires; FOLD only collapses the render.
1899
2035
  // Process-KILL: any scheme whose handler exposes kill() aborts a live stream — the
1900
2036
  // exec handler, registered as "exec" + under every runtime tag (sh/node), so a tag-
1901
2037
  // addressed stream (sh:///l/t/s) routes here, not to deleteEntry. §exec
@@ -1904,12 +2040,22 @@ class Engine {
1904
2040
  return await killable.kill(pathnameFromPath(path), statement.signal, ctx);
1905
2041
  }
1906
2042
  if (schemeName === "run") {
2043
+ // Entry-path present → KILL a run-scope scratch ENTRY (delete it), self-only —
2044
+ // NOT run cancellation. The authority (hostname) names the owner, the pathname the
2045
+ // entry; only the path-ABSENT form (run://<name>) terminates the run-as-actor. §run-scheme
2046
+ const entryPath = path.kind === "url" ? (path.pathname ?? "") : "";
2047
+ if (entryPath !== "" && entryPath !== "/") {
2048
+ const runHandler = this.#schemes.get("run");
2049
+ return await runHandler.deleteEntry(statement, ctx);
2050
+ }
1907
2051
  // terminate — abort any run by address; whoever holds it may end it.
1908
- // `.`/"" = self. cancelRun (→ Daemon.cancelDrain) aborts the run's signal
2052
+ // `run://self` = self. cancelRun (→ Daemon.cancelDrain) aborts the run's signal
1909
2053
  // (its loop closes 499); an idle run is a no-op-200, a missing run 404.
1910
2054
  const name = path.kind === "url" ? (path.hostname ?? "") : ""; // §run-scheme — run is the AUTHORITY
2055
+ if (name === "")
2056
+ return { status: 400, error: "run:// kill requires a run name or 'self' (run://<name>)" };
1911
2057
  let runId = ctx.runId;
1912
- if (name !== "" && name !== ".") {
2058
+ if (name !== "self") {
1913
2059
  const row = await this.#db.run_resolve_by_name.get({ session_id: ctx.sessionId, name });
1914
2060
  if (row === undefined)
1915
2061
  return { status: 404, error: `run://${name} not found in this session` };
@@ -1926,6 +2072,101 @@ class Engine {
1926
2072
  const delResult = await handler.deleteEntry(pathnameFromPath(path), ctx);
1927
2073
  return { status: delResult.status };
1928
2074
  }
2075
+ // Multi-file READ fan-out (SPEC §matcher-result — "the companion to FIND's survey"). A glob
2076
+ // READ target resolves to MANY files; READ returns one log row per file that matches, each
2077
+ // holding that file's matching lines. The matched SET is exactly FIND's survey (which files
2078
+ // + where — matchLines), so we reuse the scheme's own find, then READ each matched file. One
2079
+ // model command, N log rows — each row addresses its concrete file, so it folds/kills/re-READs
2080
+ // on its own. The running sequence counter in runTurn advances by rowsWritten.
2081
+ // A READ fans out (honors FIND) when it resolves to more than the single exact entry: a glob
2082
+ // or folder scope, OR a matcher (which selects per-match within whatever the target resolved).
2083
+ // A bare entry, body-less, is the one direct read. #286
2084
+ static #readFansOut(statement) {
2085
+ if (statement.op !== "READ")
2086
+ return false;
2087
+ if ("body" in statement && statement.body !== null)
2088
+ return true; // a matcher → per-match fan-out
2089
+ const t = statement.target;
2090
+ const p = t === null ? "" : (t.kind === "url" ? t.pathname : t.raw);
2091
+ return p.includes("*") || p.endsWith("/"); // glob/folder scope → fan out its contents
2092
+ }
2093
+ // Clone the READ onto one concrete match — the FIND already matched, so the per-match READ
2094
+ // delivers content at the span: strip the body (no re-match) and set <L> to the span. A null
2095
+ // span (body-less folder/glob fan-out) reads the whole entry. #286
2096
+ static #retargetRead(statement, pathname, span) {
2097
+ const t = statement.target;
2098
+ const target = t !== null && t.kind === "url"
2099
+ ? { ...t, pathname, raw: `${t.scheme}://${pathname}` }
2100
+ : { ...t, raw: pathname };
2101
+ const lineMarker = span !== null ? { marks: [span.lineStart, span.lineEnd] } : null;
2102
+ return { ...statement, target: target, lineMarker, body: null };
2103
+ }
2104
+ async #handleReadFanout(statement, ctx, ids) {
2105
+ const { runId, loopId, turnId, sequence, origin, onDispatch } = ids;
2106
+ const schemeName = this.#schemeNameOf(statement.target);
2107
+ const found = await this.#run(schemeName, { ...statement, op: "FIND" }, ctx);
2108
+ const matches = found.matches ?? [];
2109
+ // Find-less scheme, a matcher/scope error, or zero matches → a single row carrying the
2110
+ // status, exactly like a non-fanned READ. The model sees the empty/failed result, not silence.
2111
+ if (found.status !== 200 || matches.length === 0) {
2112
+ const result = { status: found.status === 200 ? 204 : found.status };
2113
+ const id = await this.#writeLog({ statement, result, runId, loopId, turnId, sequence, origin });
2114
+ onDispatch?.(id);
2115
+ return { ...result, rowsWritten: 1 };
2116
+ }
2117
+ // One READ row per MATCH — the span's source lines (or the whole entry for a body-less
2118
+ // folder/glob). The match span is SOURCE LINES, so deliver via a raw line-slice — NOT the
2119
+ // scheme's <L> (which is item-index for application/json, structural for xml). Read each
2120
+ // distinct entry's content once, then line-slice per match. #286
2121
+ const wholeByPath = new Map();
2122
+ const fannedStatuses = [];
2123
+ let written = 0;
2124
+ for (const m of matches) {
2125
+ let whole = wholeByPath.get(m.pathname);
2126
+ if (whole === undefined) {
2127
+ whole = await this.#run(schemeName, _a.#retargetRead(statement, m.pathname, null), ctx);
2128
+ wholeByPath.set(m.pathname, whole);
2129
+ }
2130
+ const result = _a.#sliceMatch(whole, m.span);
2131
+ const id = await this.#writeLog({ statement: _a.#retargetRead(statement, m.pathname, m.span), result, runId, loopId, turnId, sequence: sequence + written, origin });
2132
+ onDispatch?.(id);
2133
+ fannedStatuses.push(result.status);
2134
+ written++;
2135
+ }
2136
+ return { status: 200, rowsWritten: written, fannedStatuses };
2137
+ }
2138
+ // Deliver one match: the whole entry (body-less, span null) or the source lines at the span —
2139
+ // a RAW line-slice, so a structural mimetype (json item-index / xml) doesn't mis-slice a span
2140
+ // that is, by construction, source line numbers (#286).
2141
+ static #sliceMatch(whole, span) {
2142
+ if (whole.status !== 200 || span === null)
2143
+ return whole;
2144
+ const sliced = LineMarkerOps.sliceLines(typeof whole.content === "string" ? whole.content : "", { marks: [span.lineStart, span.lineEnd] });
2145
+ if (sliced.status !== 200)
2146
+ return { status: sliced.status, error: sliced.error };
2147
+ return { status: 200, content: sliced.text ?? "", mimetype: "text/markdown", startLine: sliced.startLine ?? span.lineStart };
2148
+ }
2149
+ // §model-entry — mirror a verbatim model emission back as an actionless `model` log row, so
2150
+ // the model can finally SEE its own prior output (and reason through its own syntax errors).
2151
+ // Born FOLDED by default (budget-neutral until OPENed); the turn-0 exemplar passes folded:false
2152
+ // (born open — the one worked example the model orients on, thinning the grammar). text/vnd.plurnk.
2153
+ async #writeModelEntry({ verbatim, runId, loopId, turnId, sequence, folded, origin = "model" }) {
2154
+ const row = await this.#db.engine_insert_log_entry.get({
2155
+ run_id: runId, loop_id: loopId, turn_id: turnId, sequence,
2156
+ origin, source: null, op: "model", suffix: "", signal: null,
2157
+ scheme: null, username: null, password: null, hostname: null, port: null,
2158
+ pathname: null, params: null, fragment: null, lineMarker: null,
2159
+ tx: "", mimetype_tx: "text/vnd.plurnk",
2160
+ rx: JSON.stringify({ content: verbatim, mimetype: "text/vnd.plurnk" }),
2161
+ mimetype_rx: "application/json",
2162
+ status_rx: 200, tokens: this.#tokenize(verbatim), state: "resolved", outcome: null, attrs: "{}",
2163
+ });
2164
+ if (row === undefined)
2165
+ throw new Error("Engine.#writeModelEntry: insert returned no row");
2166
+ if (folded)
2167
+ await this.#db.engine_fold_log_entry.run({ id: row.id });
2168
+ return row.id;
2169
+ }
1929
2170
  // PLAN — the model's reasoning op (the 11th op). An ordinary op: dispatched like any
1930
2171
  // other, logged, and broadcast to the client as a log entry — but a pure no-op for
1931
2172
  // state (PLAN ∉ MUTATING_OPS); its body serializes into the log row's tx, no effect.
@@ -2159,7 +2400,7 @@ class Engine {
2159
2400
  // it into the canonical pathname so known://x ≡ known:///x ≡ /x and the log keys identically to
2160
2401
  // the entry (/prompt/<loop>, /docs/x.md). A foreign web host (http://, unregistered) is NOT a
2161
2402
  // namespace: keep it in hostname. run:// is the one registered EXCEPTION — its authority IS the
2162
- // run selector (§run-scheme), and run:/// (self) must stay distinct from run://name, so Run.ts
2403
+ // run selector (§run-scheme), and run://self must stay distinct from run://name, so Run.ts
2163
2404
  // folds the owner into the storage path itself, never here.
2164
2405
  const foldNs = scheme !== null && scheme !== "run" && this.#schemes.has(scheme);
2165
2406
  return {