@plurnk/plurnk-service 0.56.0 → 0.58.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 (87) hide show
  1. package/.env.example +9 -6
  2. package/SPEC.md +40 -24
  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 +22 -14
  6. package/dist/content/matcher.js.map +1 -1
  7. package/dist/core/ChannelWrite.d.ts +6 -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 +8 -0
  13. package/dist/core/Engine.d.ts.map +1 -1
  14. package/dist/core/Engine.js +263 -81
  15. package/dist/core/Engine.js.map +1 -1
  16. package/dist/core/Engine.sql +10 -4
  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/git-membership.d.ts.map +1 -1
  21. package/dist/core/git-membership.js +40 -4
  22. package/dist/core/git-membership.js.map +1 -1
  23. package/dist/core/packet-wire.d.ts.map +1 -1
  24. package/dist/core/packet-wire.js +28 -37
  25. package/dist/core/packet-wire.js.map +1 -1
  26. package/dist/core/session-settings.d.ts +1 -1
  27. package/dist/core/session-settings.d.ts.map +1 -1
  28. package/dist/core/session-settings.js +4 -4
  29. package/dist/core/session-settings.js.map +1 -1
  30. package/dist/schemes/Exec.d.ts.map +1 -1
  31. package/dist/schemes/Exec.js +11 -3
  32. package/dist/schemes/Exec.js.map +1 -1
  33. package/dist/schemes/ExecOutputScheme.d.ts.map +1 -1
  34. package/dist/schemes/ExecOutputScheme.js +2 -1
  35. package/dist/schemes/ExecOutputScheme.js.map +1 -1
  36. package/dist/schemes/File.d.ts.map +1 -1
  37. package/dist/schemes/File.js +4 -5
  38. package/dist/schemes/File.js.map +1 -1
  39. package/dist/schemes/Run.d.ts +5 -1
  40. package/dist/schemes/Run.d.ts.map +1 -1
  41. package/dist/schemes/Run.js +36 -12
  42. package/dist/schemes/Run.js.map +1 -1
  43. package/dist/schemes/_entry-find.d.ts +16 -1
  44. package/dist/schemes/_entry-find.d.ts.map +1 -1
  45. package/dist/schemes/_entry-find.js +67 -47
  46. package/dist/schemes/_entry-find.js.map +1 -1
  47. package/dist/schemes/_entry-graph.d.ts +6 -1
  48. package/dist/schemes/_entry-graph.d.ts.map +1 -1
  49. package/dist/schemes/_entry-graph.js +35 -22
  50. package/dist/schemes/_entry-graph.js.map +1 -1
  51. package/dist/schemes/_entry-graph.sql +9 -7
  52. package/dist/schemes/_entry-manifest.d.ts.map +1 -1
  53. package/dist/schemes/_entry-manifest.js +16 -3
  54. package/dist/schemes/_entry-manifest.js.map +1 -1
  55. package/dist/schemes/_entry-ops.d.ts +5 -0
  56. package/dist/schemes/_entry-ops.d.ts.map +1 -1
  57. package/dist/schemes/_entry-ops.js +14 -0
  58. package/dist/schemes/_entry-ops.js.map +1 -1
  59. package/dist/schemes/_entry-semantic.d.ts +1 -1
  60. package/dist/schemes/_entry-semantic.d.ts.map +1 -1
  61. package/dist/schemes/_entry-semantic.js +15 -12
  62. package/dist/schemes/_entry-semantic.js.map +1 -1
  63. package/dist/server/Daemon.d.ts.map +1 -1
  64. package/dist/server/Daemon.js +7 -2
  65. package/dist/server/Daemon.js.map +1 -1
  66. package/dist/server/MethodRegistry.d.ts +1 -0
  67. package/dist/server/MethodRegistry.d.ts.map +1 -1
  68. package/dist/server/dsl.d.ts +9 -1
  69. package/dist/server/dsl.d.ts.map +1 -1
  70. package/dist/server/dsl.js +33 -4
  71. package/dist/server/dsl.js.map +1 -1
  72. package/dist/server/logEntry.d.ts.map +1 -1
  73. package/dist/server/logEntry.js +3 -1
  74. package/dist/server/logEntry.js.map +1 -1
  75. package/dist/server/methods/op_look.d.ts +5 -0
  76. package/dist/server/methods/op_look.d.ts.map +1 -0
  77. package/dist/server/methods/op_look.js +30 -0
  78. package/dist/server/methods/op_look.js.map +1 -0
  79. package/dist/server/methods/op_parse.d.ts.map +1 -1
  80. package/dist/server/methods/op_parse.js +7 -2
  81. package/dist/server/methods/op_parse.js.map +1 -1
  82. package/dist/server/methods/session_constraints.js +1 -1
  83. package/dist/server/methods/session_constraints.js.map +1 -1
  84. package/dist/server/methods/session_create.js +7 -7
  85. package/dist/server/methods/session_create.js.map +1 -1
  86. package/migrations/0000-00-00.01_schema.sql +11 -1
  87. package/package.json +5 -5
@@ -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,8 @@ 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,
318
320
  // #252 — the latest turn's opaque provider blob, parsed for the wire. Empty {} when the
319
321
  // provider returned no meta. The service forwards it; it never reads a field within.
320
322
  meta: JSON.parse(row?.meta ?? "{}"),
@@ -339,27 +341,6 @@ class Engine {
339
341
  this.#telemetryBuffer.delete(loopId);
340
342
  return buf;
341
343
  }
342
- // Pull a ±windowLines context block around `targetLine` (1-based) from
343
- // `content`, formatted with N:\t prefixes — same shape the model
344
- // already knows from READ output and numbered Index entries. Used to
345
- // give parse_error telemetry concrete locality: the model sees what
346
- // it wrote on the offending line, not just an abstract error message.
347
- //
348
- // Lenient: targetLine ≤ 0 clamps to 1; targetLine beyond content
349
- // returns whatever overlap exists; empty content returns "".
350
- #extractSnippet(content, targetLine, windowLines) {
351
- if (content.length === 0)
352
- return "";
353
- const lines = content.split("\n");
354
- const target = Math.max(1, targetLine);
355
- const start = Math.max(1, target - windowLines);
356
- const end = Math.min(lines.length, target + windowLines);
357
- const slice = [];
358
- for (let i = start; i <= end; i++) {
359
- slice.push(`${i}:\t${lines[i - 1] ?? ""}`);
360
- }
361
- return slice.join("\n");
362
- }
363
344
  // A @plurnk/gbnf divergence position (providers#24) is a CODE-POINT offset into the
364
345
  // model's content; the snippet/telemetry surface speaks 1-based line + 0-based column.
365
346
  // Convert over code points (not UTF-16 units) so an astral char doesn't skew the line,
@@ -548,7 +529,14 @@ class Engine {
548
529
  // from sequence=2 onward on prompt-foisted turns; 1 onward
549
530
  // otherwise.
550
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 = [];
551
537
  if (seq === 1) {
538
+ if (runFirstLoop)
539
+ nextActionIndex = 2; // reserve sequence 1 for the turn-0 echo
552
540
  // Operator doc READs (PLURNK_MD_<ALIAS>, §actor-boundary-doc-injection). The docs were materialized
553
541
  // as plurnk:///<entry> entries by the plurnk run (loop_run, via the
554
542
  // §actor-boundary keystone); foist a READ of each into THIS turn-0 so the model
@@ -627,16 +615,16 @@ class Engine {
627
615
  const fsDivergences = await GitMembership.indexGitMembership(systemCtx);
628
616
  await this.#logFsFictions(sessionId, fsDivergences);
629
617
  await EntryManifest.maintainDerivations(systemCtx);
630
- // Turn-0 catalog preview (PLURNK_MANIFEST_ITEMS, §actor-boundary-manifest-preview):
618
+ // Turn-0 catalog preview (PLURNK_FILES_ITEMS, §actor-boundary-manifest-preview):
631
619
  // one FIND(scheme:///**) per scheme that holds entries, foisted into the run's first
632
620
  // model turn so it opens with its catalog (the per-scheme arrays that replaced the
633
621
  // single manifest.json). -1 → each scheme's whole catalog; N → its first N rows
634
622
  // (clamped to the scheme's count so FIND's strict <L> never 416s); off by default.
635
623
  if (seq === 1) {
636
- // #231 — a session's client-chosen manifestItems REPLACES the env default outright.
637
- const { manifestItems: sessionMI } = await SessionSettings.read(this.#db, sessionId);
638
- const manifestItems = sessionMI !== null ? normalizeManifestItems(sessionMI) : readManifestItems();
639
- 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
640
628
  // engine_scheme_catalog_summary is the scheme source: session-scoped, ordered,
641
629
  // one row per scheme that has entries (scheme=null → file). log:// is absent —
642
630
  // it lives in log_entries, not the catalog (present-mode, the # Log section).
@@ -656,9 +644,12 @@ class Engine {
656
644
  // documenting surface. The prompt is shown in # Prompt, so the plurnk catalog
657
645
  // the model orients on IS the docs; doc links are no longer rendered inline (#270).
658
646
  const isPlurnk = schemeName === "plurnk";
659
- // entries===0 (an always-foisted empty known/unknown) uncapped: nothing to
660
- // clamp, and Math.min(items, 0)=0 would emit a degenerate <1,0> marker.
661
- 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;
662
653
  const catalogFind = {
663
654
  op: "FIND", suffix: "", signal: null,
664
655
  target: {
@@ -678,9 +669,12 @@ class Engine {
678
669
  sequence: nextActionIndex, origin: "plurnk", onDispatch,
679
670
  });
680
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`);
681
675
  }
682
676
  // §run-scheme — Manifest(run) = session-scope ∪ THIS run's run-scope. Foist the
683
- // building run's OWN scratch (run:///**, uncapped — a run needs the full view to
677
+ // building run's OWN scratch (run://self/**, uncapped — a run needs the full view to
684
678
  // manage its private workspace) so it's catalogued in ITS perspective alone; other
685
679
  // runs reach it only via explicit FIND(run://<name>/**). A run with no scratch foists nothing.
686
680
  const selfRun = await this.#db.run_name_by_id.get({ run_id: runId });
@@ -688,11 +682,12 @@ class Engine {
688
682
  if (scratch > 0) {
689
683
  const runFind = {
690
684
  op: "FIND", suffix: "", signal: null,
691
- target: { kind: "url", raw: "run:///**", scheme: "run", username: null, password: null, hostname: "", port: null, pathname: "/**", params: {}, fragment: null },
685
+ target: { kind: "url", raw: "run://self/**", scheme: "run", username: null, password: null, hostname: "self", port: null, pathname: "/**", params: {}, fragment: null },
692
686
  body: null, lineMarker: null, position: { line: 1, column: 1 },
693
687
  };
694
688
  await this.dispatch({ statement: runFind, sessionId, runId, loopId, turnId, sequence: nextActionIndex, origin: "plurnk", onDispatch });
695
689
  nextActionIndex++;
690
+ turnZeroMoves.push("<<FIND(run://self/**)::FIND"); // §model-entry — the run-scope survey, into the turn-0 echo
696
691
  }
697
692
  }
698
693
  // #260 — foist a turn-0 READ of each client-passed @file path so its content sits in front
@@ -716,12 +711,24 @@ class Engine {
716
711
  });
717
712
  nextActionIndex++;
718
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
+ }
719
722
  }
720
723
  // §environment-observation — pre-seed the run's ambient observations (what changed since
721
724
  // it last looked) as foisted rows before the packet composes; advance the action index
722
725
  // past them so model ops continue after. Two instances of one machine: env-delta (sibling
723
726
  // edits · timestamp cursor · always folded) and exec streams (channel bytes · byte cursor ·
724
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);
725
732
  nextActionIndex += await this.#materializeEnvironmentDeltas({ sessionId, runId, loopId, turnId, fromSequence: nextActionIndex });
726
733
  nextActionIndex += await this.#materializeStreamDeltas({ runId, loopId, turnId, fromSequence: nextActionIndex });
727
734
  // SPEC §telemetry — git working-tree state for the telemetry section, read once
@@ -751,6 +758,7 @@ class Engine {
751
758
  await this.#db.engine_close_turn.run({
752
759
  id: turnId, status: 413, packet: JSON.stringify(hardPacket),
753
760
  usage_prompt: 0, usage_completion: 0, usage_cached: 0, usage_cost_pico: 0,
761
+ usage_context_size: provider.contextSize, // #274 — the model's window, even on a hard-413 turn
754
762
  finish_reason: "budget_hard_stop", model: provider.model, meta: "{}",
755
763
  });
756
764
  return { turnId, status: 413, statuses: [], fingerprint: "", budgetStruck: enforced.struck, budgetHardStop: true, steerStruck: false };
@@ -786,7 +794,7 @@ class Engine {
786
794
  // In GBNF-filter mode the provider returns the bytes with a grammar_unenforced telemetry
787
795
  // event instead — recovered on the success path below (response.telemetry), no empty turn.
788
796
  if (err instanceof ProviderError) {
789
- 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" });
790
798
  if (err.kind !== "grammar_unenforced")
791
799
  throw err;
792
800
  response = {
@@ -823,20 +831,23 @@ class Engine {
823
831
  // providers#24 / #275: non-fatal provider telemetry on a SUCCESSFUL turn. In GBNF-filter
824
832
  // mode the provider no longer THROWS grammar_unenforced — it returns the model's bytes
825
833
  // (here, packetAssistant.content) and attaches the conflict as a telemetry event carrying
826
- // the divergence code-point position. Mirror the parse_error path: forward each event, and
827
- // when it locates a position, render the model its own emission around that line so it can
828
- // self-correct the exact recovery affordance parse errors already get, instead of the
829
- // empty-turn cascade the old throw produced.
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;
830
838
  for (const event of response.telemetry ?? []) {
831
839
  const located = typeof event.position === "number"
832
840
  ? this.#offsetToLineColumn(packetAssistant.content, event.position)
833
841
  : null;
842
+ if (located !== null)
843
+ hadContentOffsetNotice = true;
834
844
  this.#pushTelemetry(sessionId, loopId, {
835
845
  source: event.source,
836
846
  kind: event.kind,
837
847
  message: event.message ?? "",
848
+ level: event.level ?? "warn", // forward the producer's severity; default for a producer predating the field
838
849
  ...(located !== null
839
- ? { position: { type: "content-offset", line: located.line, column: located.column }, snippet: this.#extractSnippet(packetAssistant.content, located.line, 2) }
850
+ ? { position: { type: "content-offset", line: located.line, column: located.column } }
840
851
  : {}),
841
852
  });
842
853
  }
@@ -867,6 +878,7 @@ class Engine {
867
878
  source: "engine:rail",
868
879
  kind: "premature_terminate",
869
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",
870
882
  });
871
883
  }
872
884
  }
@@ -884,6 +896,7 @@ class Engine {
884
896
  source: "engine:rail",
885
897
  kind: "idle_turn",
886
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",
887
900
  });
888
901
  }
889
902
  // Close the turn with the final packet, status, and usage stats.
@@ -897,6 +910,7 @@ class Engine {
897
910
  usage_completion: usage.completion,
898
911
  usage_cached: usage.cached,
899
912
  usage_cost_pico: provider.costFor(usage), // §provider-surface-costfor
913
+ usage_context_size: provider.contextSize, // #274 — the running model's window (gauge denominator)
900
914
  finish_reason: finishReason,
901
915
  model,
902
916
  // #252 — opaque provider→client metadata passthrough (e.g. balancePico the
@@ -925,13 +939,18 @@ class Engine {
925
939
  || realCommands++ < maxCommands);
926
940
  const droppedCount = opsCount - opsToDispatch.length;
927
941
  const statuses = [];
928
- 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) {
929
947
  const result = await this.dispatch({
930
948
  statement, sessionId, runId, loopId, turnId,
931
- sequence: nextActionIndex + i,
949
+ sequence: rowSeq,
932
950
  origin, onDispatch,
933
951
  });
934
952
  statuses.push(result.status);
953
+ rowSeq += result.rowsWritten ?? 1;
935
954
  }
936
955
  // max_commands_exceeded IS model-facing: dropped ops are things
937
956
  // the model emitted that didn't run — it needs to know. Engine
@@ -943,29 +962,39 @@ class Engine {
943
962
  kind: "max_commands_exceeded",
944
963
  emitted: opsCount,
945
964
  dropped: droppedCount,
965
+ level: "error",
946
966
  });
947
967
  }
948
968
  // §telemetry — parse errors as LOG ITEMS: a failed-to-parse emission records an actionless
949
969
  // `error` row (status 400, no target, snippet = the foldable body) at the turn's next free
950
970
  // sequence (after the dispatched ops). The model folds/kills/recalls it like any log entry,
951
971
  // and the errors section derives a pointer (status + coordinate) from log≥400 — one surface.
952
- let errSeq = nextActionIndex + opsToDispatch.length;
972
+ let errSeq = rowSeq; // after every dispatched row, including a multi-file READ's fan-out
953
973
  for (const { message, line, column, source } of parseErrors ?? []) {
954
- const snippet = this.#extractSnippet(packetAssistant.content, line, 2);
955
974
  await this.#db.engine_insert_log_entry.get({
956
975
  run_id: runId, loop_id: loopId, turn_id: turnId, sequence: errSeq++,
957
976
  origin: "model", source: "grammar", op: "error", suffix: "", signal: null,
958
977
  scheme: null, username: null, password: null, hostname: null, port: null,
959
978
  pathname: null, params: null, fragment: null, lineMarker: null,
960
979
  tx: "", mimetype_tx: "text/plain",
961
- // `message`/`snippet` render as the foldable body (not a pointer `error` field), so the
962
- // derived errors-section pointer stays minimal (status + coordinate), the bloated parser
963
- // message lives in the log row, reclaimable on FOLD.
964
- rx: JSON.stringify({ message, position: { type: "content-offset", line, column }, snippet, parserSource: source }),
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 }),
965
984
  mimetype_rx: "application/json",
966
985
  status_rx: 400, tokens: 0, state: "resolved", outcome: null, attrs: "{}",
967
986
  });
968
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
+ }
969
998
  // Zero ops is NOT an error to report — the model knows it emitted
970
999
  // nothing. Strike accounting (engine-internal) treats it as a
971
1000
  // struck turn; the model just sees an empty packet next turn.
@@ -1080,13 +1109,13 @@ class Engine {
1080
1109
  // omitted, section lines still shown). §tokenomics-render-weight-budget
1081
1110
  const ceiling = _a.computeCeiling(provider.contextSize, this.#budgetCeiling);
1082
1111
  const budgetReadout = this.#renderBudget(PacketWire.measureLogBudget(log, countTokens), ceiling);
1083
- // The default packet: an ordered list of sections, each addressable state
1084
- // (§packet-construction). `slot` is the prompt-cache boundary; the STATIC
1085
- // sections (definition, tools) lead the system slot so they form the cached
1086
- // prefix, with the dynamic log after. In the user slot, requirements renders
1087
- // last (the contract closest to the assistant turn); budget/errors/git are
1088
- // peer sections (unbundled). The budget section carries its {{tokensFree}}
1089
- // placeholders here; they resolve below once the assembled total is known.
1112
+ // The default packet: an ordered list of addressable sections (§packet-construction).
1113
+ // `slot` is a TRUST boundary (and the prompt-cache boundary): system holds only
1114
+ // framework-authored, non-injectable sections the static head (definition/tools/
1115
+ // schemes/policy) forms the cached prefix, then the volatile-but-trusted tail of
1116
+ // errors/git/budget; user holds injectable content (the log, the operator prompt) plus
1117
+ // the requirements footer. The budget section carries its {{tokensFree}} placeholders
1118
+ // here; they resolve below once the assembled total is known.
1090
1119
  const inject = await readPacketInject(); // #240 — operator section, per-turn, fail-hard on a broken path
1091
1120
  const sessionRoot = (await this.#db.envelope_get_session.get({ id: sessionId }))?.project_root ?? null;
1092
1121
  const systemPolicy = await readSystemPolicy(); // ~/.plurnk/AGENTS.md (or PLURNK_POLICY)
@@ -1099,13 +1128,19 @@ class Engine {
1099
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.
1100
1129
  { name: "system-policy", slot: "system", header: "Plurnk System Policy", content: systemPolicy ?? "", tokens: 0 },
1101
1130
  { name: "project-policy", slot: "system", header: "Project Policy", content: projectPolicy ?? "", tokens: 0 },
1102
- // 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.
1131
+ // The packet split is a TRUST boundary: system carries only framework-authored, non-injectable
1132
+ // sections; anything that could carry attacker-reachable text (a READ result, exec output, the
1133
+ // model's own mirrored bytes) stays in user. errors + git are framework status — the errors
1134
+ // section is uri+status POINTERS (the error item + body live in the log), git is counts — so
1135
+ // neither is an injection surface; both sit at the bottom of system, just above budget-the-law.
1136
+ { name: "errors", slot: "system", header: "Plurnk System Errors", content: PacketWire.renderErrors(telemetryErrors), tokens: 0 },
1137
+ { name: "git", slot: "system", header: "Plurnk System Git Status", content: PacketWire.renderGit(gitStatus), tokens: 0 },
1138
+ // budget is the very last system line — LAW (a hard ceiling the model must obey), the final word before the model acts.
1103
1139
  { name: "budget", slot: "system", header: "Plurnk System Budget", content: budgetReadout, tokens: 0 },
1104
1140
  { name: "prompt", slot: "user", header: "Plurnk System User Prompt", content: prompt, tokens: 0 },
1105
- { name: "errors", slot: "user", header: "Plurnk System Errors", content: PacketWire.renderErrors(telemetryErrors), tokens: 0 },
1106
- // 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.
1141
+ // log in the user slot: injectable content (READ results, exec output, the model's own mirror) — data, never rules — kept at the action point so the model consults its history.
1107
1142
  { name: "log", slot: "user", header: "Plurnk System Log", content: PacketWire.renderLog(log, countTokens), tokens: 0 },
1108
- { name: "git", slot: "user", header: "Plurnk System Git Status", content: PacketWire.renderGit(gitStatus), tokens: 0 },
1143
+ // requirements renders LAST — the user-slot footer, the syntax contract closest to the model's turn (a recency carve-out for weak models).
1109
1144
  { name: "requirements", slot: "user", header: "Plurnk System Requirements", content: baseRequirements, tokens: 0 },
1110
1145
  ];
1111
1146
  // Plugin packet control (§packet-construction): trusted schemes rewrite the
@@ -1251,6 +1286,7 @@ class Engine {
1251
1286
  source: "engine:rail",
1252
1287
  kind: "budget_overflow",
1253
1288
  folded: [...folded.entries()].map(([scheme, count]) => ({ scheme, count })),
1289
+ level: "warn",
1254
1290
  });
1255
1291
  }
1256
1292
  // Wire projection lives in ./packet-wire.ts so Engine and
@@ -1291,6 +1327,7 @@ class Engine {
1291
1327
  const parsedRx = r.mimetype_rx === "application/json" ? JSON.parse(r.rx) : r.rx;
1292
1328
  return {
1293
1329
  kind: "action_failure",
1330
+ level: "error", // the derived error pointer is always an error — clients color off level (#276)
1294
1331
  coordinate: `${r.loop_seq}/${r.turn_seq}/${r.sequence}`,
1295
1332
  op: r.op,
1296
1333
  target,
@@ -1378,6 +1415,18 @@ class Engine {
1378
1415
  }
1379
1416
  return written;
1380
1417
  }
1418
+ // §exec-poll — EXEC `<0>` is turn-scoped: abort the run's open turn-scoped streams via their
1419
+ // owning scheme (the same registry-routed abort the total reap uses). Called at each pre-turn
1420
+ // before the turn's own spawns, so every open turn-scoped sub here is from a prior turn — it
1421
+ // never survives into the subsequent turn. Fire-and-forget: the spawn finalizes async and its
1422
+ // terminal output surfaces born-OPEN through the stream-delta path (§exec-stream).
1423
+ async #reapTurnScopedStreams(runId) {
1424
+ const open = await this.#db.find_open_turn_scoped_subscriptions_for_run.all({ run_id: runId });
1425
+ for (const { id, scheme } of open) {
1426
+ const handler = this.#schemes.get(scheme);
1427
+ handler?.abortSubscription?.(id);
1428
+ }
1429
+ }
1381
1430
  // §environment-observation — exec streams as an instance of the ambient-observe machine:
1382
1431
  // each turn, emit each owned channel's unshown byte-delta as a foisted READ@200 row. Folded
1383
1432
  // while the channel streams; the terminal delta (channel closed) auto-OPENs. The cursor is the
@@ -1446,19 +1495,7 @@ class Engine {
1446
1495
  }
1447
1496
  async dispatch(context) {
1448
1497
  const { statement, sessionId, runId, loopId, turnId, sequence, origin, onDispatch } = context;
1449
- const schemeCtx = {
1450
- db: this.#db,
1451
- sessionId, runId, loopId, turnId,
1452
- writer: origin,
1453
- signal: this.#loopAborts.get(loopId)?.signal,
1454
- streamEventNotify: this.#streamEventNotify,
1455
- wakeRunNotify: this.#wakeRunNotify,
1456
- injectRun: this.#injectRun,
1457
- mimetypes: this.#mimetypes,
1458
- tokenize: this.#tokenize,
1459
- pushTelemetry: (event) => this.#pushTelemetry(sessionId, loopId, event),
1460
- executors: this.#executors,
1461
- };
1498
+ const schemeCtx = this.#buildSchemeCtx({ sessionId, runId, loopId, turnId, origin });
1462
1499
  let result;
1463
1500
  let denial = this.#checkWritable(statement, origin);
1464
1501
  if (denial === null)
@@ -1466,6 +1503,12 @@ class Engine {
1466
1503
  if (denial !== null) {
1467
1504
  result = denial;
1468
1505
  }
1506
+ else if (_a.#readFansOut(statement)) {
1507
+ // READ honors FIND: a glob/folder scope or a matcher fans out to one log row per MATCH
1508
+ // (its own writeLogs), returning early. A READ never proposes, so it bypasses the
1509
+ // single-row path below. A bare entry, body-less, falls through to the direct read. #286
1510
+ return await this.#handleReadFanout(statement, schemeCtx, { runId, loopId, turnId, sequence, origin, onDispatch });
1511
+ }
1469
1512
  else {
1470
1513
  // SPEC §scheme-surface + plurnk-schemes#1: action-entry-as-outcome. Scheme-handler
1471
1514
  // exceptions become the action-entry's outcome (status 500), not a
@@ -1574,6 +1617,38 @@ class Engine {
1574
1617
  }
1575
1618
  return result;
1576
1619
  }
1620
+ // op.look (#283) — resolve a READ and return its content WITHOUT writing a
1621
+ // log_entries row: the client's off-run inspection primitive (LOOK → READ,
1622
+ // invisible to the model). READ never mutates and never proposes, so this is
1623
+ // dispatch's resolve path minus #writeLog. Runs on the client loop, so the
1624
+ // human's inspection is never constrained by a model loop's flags. {§op-look}
1625
+ async look(context) {
1626
+ const { statement, sessionId, runId, loopId, origin = "client" } = context;
1627
+ if (statement.op !== "READ")
1628
+ throw new Error(`look resolves READ only; got ${statement.op}`);
1629
+ // turnId is a write-time FK only — a look writes no row, so 0 (no turn) is inert.
1630
+ const schemeCtx = this.#buildSchemeCtx({ sessionId, runId, loopId, turnId: 0, origin });
1631
+ const denial = await this.#checkFlagsGate(statement, loopId);
1632
+ if (denial !== null)
1633
+ return denial;
1634
+ return this.#run(this.#schemeNameOf(statement.target), statement, schemeCtx);
1635
+ }
1636
+ #buildSchemeCtx(ids) {
1637
+ const { sessionId, runId, loopId, turnId, origin } = ids;
1638
+ return {
1639
+ db: this.#db,
1640
+ sessionId, runId, loopId, turnId,
1641
+ writer: origin,
1642
+ signal: this.#loopAborts.get(loopId)?.signal,
1643
+ streamEventNotify: this.#streamEventNotify,
1644
+ wakeRunNotify: this.#wakeRunNotify,
1645
+ injectRun: this.#injectRun,
1646
+ mimetypes: this.#mimetypes,
1647
+ tokenize: this.#tokenize,
1648
+ pushTelemetry: (event) => this.#pushTelemetry(sessionId, loopId, event),
1649
+ executors: this.#executors,
1650
+ };
1651
+ }
1577
1652
  // On accept, run the scheme's applyResolution — File writes disk, Exec spawns. §proposal-accept-applies
1578
1653
  async #runApplyResolution(statement, originalResult, resolution, ids) {
1579
1654
  const { sessionId, runId, loopId, turnId } = ids;
@@ -1864,17 +1939,19 @@ class Engine {
1864
1939
  #isRunFork(statement) {
1865
1940
  return statement.op === "COPY" && this.#schemeNameOf(statement.target) === "run";
1866
1941
  }
1867
- // COPY(run:///<src>):prompt — fork: deep-copy the source run's log into a new
1868
- // run (Fork), then start it with the prompt (ctx.injectRun). Source "."/"" =
1869
- // self (ctx.runId); a name resolves within the session (404 if absent).
1942
+ // COPY(run://<src>):prompt — fork: deep-copy the source run's log into a new
1943
+ // run (Fork), then start it with the prompt (ctx.injectRun). Source `run://self` =
1944
+ // self (ctx.runId); a name resolves within the session (404 if absent). §run-scheme,
1870
1945
  // §machine-processes-fork-copies-the-log
1871
1946
  async #handleRunFork(statement, ctx) {
1872
1947
  const target = statement.target;
1873
1948
  if (target === null)
1874
1949
  return { status: 400, error: "run:// fork requires a source run" };
1875
1950
  const name = target.kind === "url" ? (target.hostname ?? "") : ""; // §run-scheme — run is the AUTHORITY (run://<name>), not the path
1951
+ if (name === "")
1952
+ return { status: 400, error: "run:// fork requires a source run name or 'self' (run://self)" };
1876
1953
  let srcRunId = ctx.runId;
1877
- if (name !== "" && name !== ".") {
1954
+ if (name !== "self") {
1878
1955
  const row = await this.#db.run_resolve_by_name.get({ session_id: ctx.sessionId, name });
1879
1956
  if (row === undefined)
1880
1957
  return { status: 404, error: `run://${name} not found in this session` };
@@ -1969,12 +2046,22 @@ class Engine {
1969
2046
  return await killable.kill(pathnameFromPath(path), statement.signal, ctx);
1970
2047
  }
1971
2048
  if (schemeName === "run") {
2049
+ // Entry-path present → KILL a run-scope scratch ENTRY (delete it), self-only —
2050
+ // NOT run cancellation. The authority (hostname) names the owner, the pathname the
2051
+ // entry; only the path-ABSENT form (run://<name>) terminates the run-as-actor. §run-scheme
2052
+ const entryPath = path.kind === "url" ? (path.pathname ?? "") : "";
2053
+ if (entryPath !== "" && entryPath !== "/") {
2054
+ const runHandler = this.#schemes.get("run");
2055
+ return await runHandler.deleteEntry(statement, ctx);
2056
+ }
1972
2057
  // terminate — abort any run by address; whoever holds it may end it.
1973
- // `.`/"" = self. cancelRun (→ Daemon.cancelDrain) aborts the run's signal
2058
+ // `run://self` = self. cancelRun (→ Daemon.cancelDrain) aborts the run's signal
1974
2059
  // (its loop closes 499); an idle run is a no-op-200, a missing run 404.
1975
2060
  const name = path.kind === "url" ? (path.hostname ?? "") : ""; // §run-scheme — run is the AUTHORITY
2061
+ if (name === "")
2062
+ return { status: 400, error: "run:// kill requires a run name or 'self' (run://<name>)" };
1976
2063
  let runId = ctx.runId;
1977
- if (name !== "" && name !== ".") {
2064
+ if (name !== "self") {
1978
2065
  const row = await this.#db.run_resolve_by_name.get({ session_id: ctx.sessionId, name });
1979
2066
  if (row === undefined)
1980
2067
  return { status: 404, error: `run://${name} not found in this session` };
@@ -1991,6 +2078,101 @@ class Engine {
1991
2078
  const delResult = await handler.deleteEntry(pathnameFromPath(path), ctx);
1992
2079
  return { status: delResult.status };
1993
2080
  }
2081
+ // Multi-file READ fan-out (SPEC §matcher-result — "the companion to FIND's survey"). A glob
2082
+ // READ target resolves to MANY files; READ returns one log row per file that matches, each
2083
+ // holding that file's matching lines. The matched SET is exactly FIND's survey (which files
2084
+ // + where — matchLines), so we reuse the scheme's own find, then READ each matched file. One
2085
+ // model command, N log rows — each row addresses its concrete file, so it folds/kills/re-READs
2086
+ // on its own. The running sequence counter in runTurn advances by rowsWritten.
2087
+ // A READ fans out (honors FIND) when it resolves to more than the single exact entry: a glob
2088
+ // or folder scope, OR a matcher (which selects per-match within whatever the target resolved).
2089
+ // A bare entry, body-less, is the one direct read. #286
2090
+ static #readFansOut(statement) {
2091
+ if (statement.op !== "READ")
2092
+ return false;
2093
+ if ("body" in statement && statement.body !== null)
2094
+ return true; // a matcher → per-match fan-out
2095
+ const t = statement.target;
2096
+ const p = t === null ? "" : (t.kind === "url" ? t.pathname : t.raw);
2097
+ return p.includes("*") || p.endsWith("/"); // glob/folder scope → fan out its contents
2098
+ }
2099
+ // Clone the READ onto one concrete match — the FIND already matched, so the per-match READ
2100
+ // delivers content at the span: strip the body (no re-match) and set <L> to the span. A null
2101
+ // span (body-less folder/glob fan-out) reads the whole entry. #286
2102
+ static #retargetRead(statement, pathname, span) {
2103
+ const t = statement.target;
2104
+ const target = t !== null && t.kind === "url"
2105
+ ? { ...t, pathname, raw: `${t.scheme}://${pathname}` }
2106
+ : { ...t, raw: pathname };
2107
+ const lineMarker = span !== null ? { marks: [span.lineStart, span.lineEnd] } : null;
2108
+ return { ...statement, target: target, lineMarker, body: null };
2109
+ }
2110
+ async #handleReadFanout(statement, ctx, ids) {
2111
+ const { runId, loopId, turnId, sequence, origin, onDispatch } = ids;
2112
+ const schemeName = this.#schemeNameOf(statement.target);
2113
+ const found = await this.#run(schemeName, { ...statement, op: "FIND" }, ctx);
2114
+ const matches = found.matches ?? [];
2115
+ // Find-less scheme, a matcher/scope error, or zero matches → a single row carrying the
2116
+ // status, exactly like a non-fanned READ. The model sees the empty/failed result, not silence.
2117
+ if (found.status !== 200 || matches.length === 0) {
2118
+ const result = { status: found.status === 200 ? 204 : found.status };
2119
+ const id = await this.#writeLog({ statement, result, runId, loopId, turnId, sequence, origin });
2120
+ onDispatch?.(id);
2121
+ return { ...result, rowsWritten: 1 };
2122
+ }
2123
+ // One READ row per MATCH — the span's source lines (or the whole entry for a body-less
2124
+ // folder/glob). The match span is SOURCE LINES, so deliver via a raw line-slice — NOT the
2125
+ // scheme's <L> (which is item-index for application/json, structural for xml). Read each
2126
+ // distinct entry's content once, then line-slice per match. #286
2127
+ const wholeByPath = new Map();
2128
+ const fannedStatuses = [];
2129
+ let written = 0;
2130
+ for (const m of matches) {
2131
+ let whole = wholeByPath.get(m.pathname);
2132
+ if (whole === undefined) {
2133
+ whole = await this.#run(schemeName, _a.#retargetRead(statement, m.pathname, null), ctx);
2134
+ wholeByPath.set(m.pathname, whole);
2135
+ }
2136
+ const result = _a.#sliceMatch(whole, m.span);
2137
+ const id = await this.#writeLog({ statement: _a.#retargetRead(statement, m.pathname, m.span), result, runId, loopId, turnId, sequence: sequence + written, origin });
2138
+ onDispatch?.(id);
2139
+ fannedStatuses.push(result.status);
2140
+ written++;
2141
+ }
2142
+ return { status: 200, rowsWritten: written, fannedStatuses };
2143
+ }
2144
+ // Deliver one match: the whole entry (body-less, span null) or the source lines at the span —
2145
+ // a RAW line-slice, so a structural mimetype (json item-index / xml) doesn't mis-slice a span
2146
+ // that is, by construction, source line numbers (#286).
2147
+ static #sliceMatch(whole, span) {
2148
+ if (whole.status !== 200 || span === null)
2149
+ return whole;
2150
+ const sliced = LineMarkerOps.sliceLines(typeof whole.content === "string" ? whole.content : "", { marks: [span.lineStart, span.lineEnd] });
2151
+ if (sliced.status !== 200)
2152
+ return { status: sliced.status, error: sliced.error };
2153
+ return { status: 200, content: sliced.text ?? "", mimetype: "text/markdown", startLine: sliced.startLine ?? span.lineStart };
2154
+ }
2155
+ // §model-entry — mirror a verbatim model emission back as an actionless `model` log row, so
2156
+ // the model can finally SEE its own prior output (and reason through its own syntax errors).
2157
+ // Born FOLDED by default (budget-neutral until OPENed); the turn-0 exemplar passes folded:false
2158
+ // (born open — the one worked example the model orients on, thinning the grammar). text/vnd.plurnk.
2159
+ async #writeModelEntry({ verbatim, runId, loopId, turnId, sequence, folded, origin = "model" }) {
2160
+ const row = await this.#db.engine_insert_log_entry.get({
2161
+ run_id: runId, loop_id: loopId, turn_id: turnId, sequence,
2162
+ origin, source: null, op: "model", suffix: "", signal: null,
2163
+ scheme: null, username: null, password: null, hostname: null, port: null,
2164
+ pathname: null, params: null, fragment: null, lineMarker: null,
2165
+ tx: "", mimetype_tx: "text/vnd.plurnk",
2166
+ rx: JSON.stringify({ content: verbatim, mimetype: "text/vnd.plurnk" }),
2167
+ mimetype_rx: "application/json",
2168
+ status_rx: 200, tokens: this.#tokenize(verbatim), state: "resolved", outcome: null, attrs: "{}",
2169
+ });
2170
+ if (row === undefined)
2171
+ throw new Error("Engine.#writeModelEntry: insert returned no row");
2172
+ if (folded)
2173
+ await this.#db.engine_fold_log_entry.run({ id: row.id });
2174
+ return row.id;
2175
+ }
1994
2176
  // PLAN — the model's reasoning op (the 11th op). An ordinary op: dispatched like any
1995
2177
  // other, logged, and broadcast to the client as a log entry — but a pure no-op for
1996
2178
  // state (PLAN ∉ MUTATING_OPS); its body serializes into the log row's tx, no effect.
@@ -2224,7 +2406,7 @@ class Engine {
2224
2406
  // it into the canonical pathname so known://x ≡ known:///x ≡ /x and the log keys identically to
2225
2407
  // the entry (/prompt/<loop>, /docs/x.md). A foreign web host (http://, unregistered) is NOT a
2226
2408
  // namespace: keep it in hostname. run:// is the one registered EXCEPTION — its authority IS the
2227
- // run selector (§run-scheme), and run:/// (self) must stay distinct from run://name, so Run.ts
2409
+ // run selector (§run-scheme), and run://self must stay distinct from run://name, so Run.ts
2228
2410
  // folds the owner into the storage path itself, never here.
2229
2411
  const foldNs = scheme !== null && scheme !== "run" && this.#schemes.has(scheme);
2230
2412
  return {