@plurnk/plurnk-service 0.54.0 → 0.56.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 (80) hide show
  1. package/.env.example +0 -8
  2. package/SPEC.md +111 -93
  3. package/dist/content/matcher.d.ts.map +1 -1
  4. package/dist/content/matcher.js +62 -89
  5. package/dist/content/matcher.js.map +1 -1
  6. package/dist/core/ChannelWrite.d.ts +2 -1
  7. package/dist/core/ChannelWrite.d.ts.map +1 -1
  8. package/dist/core/ChannelWrite.js +2 -2
  9. package/dist/core/ChannelWrite.js.map +1 -1
  10. package/dist/core/ChannelWrite.sql +2 -2
  11. package/dist/core/Engine.d.ts +1 -0
  12. package/dist/core/Engine.d.ts.map +1 -1
  13. package/dist/core/Engine.js +115 -50
  14. package/dist/core/Engine.js.map +1 -1
  15. package/dist/core/Engine.sql +40 -2
  16. package/dist/core/ProviderInstantiate.d.ts.map +1 -1
  17. package/dist/core/ProviderInstantiate.js +5 -2
  18. package/dist/core/ProviderInstantiate.js.map +1 -1
  19. package/dist/core/fork.d.ts.map +1 -1
  20. package/dist/core/fork.js +20 -4
  21. package/dist/core/fork.js.map +1 -1
  22. package/dist/core/fork.sql +29 -0
  23. package/dist/core/packet-inject.d.ts +2 -0
  24. package/dist/core/packet-inject.d.ts.map +1 -1
  25. package/dist/core/packet-inject.js +28 -1
  26. package/dist/core/packet-inject.js.map +1 -1
  27. package/dist/core/packet-wire.d.ts.map +1 -1
  28. package/dist/core/packet-wire.js +22 -2
  29. package/dist/core/packet-wire.js.map +1 -1
  30. package/dist/core/session-settings.d.ts +0 -5
  31. package/dist/core/session-settings.d.ts.map +1 -1
  32. package/dist/core/session-settings.js +1 -10
  33. package/dist/core/session-settings.js.map +1 -1
  34. package/dist/schemes/Exec.d.ts.map +1 -1
  35. package/dist/schemes/Exec.js +34 -8
  36. package/dist/schemes/Exec.js.map +1 -1
  37. package/dist/schemes/File.d.ts.map +1 -1
  38. package/dist/schemes/File.js +5 -1
  39. package/dist/schemes/File.js.map +1 -1
  40. package/dist/schemes/Log.d.ts +4 -0
  41. package/dist/schemes/Log.d.ts.map +1 -1
  42. package/dist/schemes/Log.js +35 -19
  43. package/dist/schemes/Log.js.map +1 -1
  44. package/dist/schemes/Log.sql +14 -11
  45. package/dist/schemes/Run.d.ts +3 -1
  46. package/dist/schemes/Run.d.ts.map +1 -1
  47. package/dist/schemes/Run.js +16 -0
  48. package/dist/schemes/Run.js.map +1 -1
  49. package/dist/schemes/_entry-find.d.ts.map +1 -1
  50. package/dist/schemes/_entry-find.js +17 -3
  51. package/dist/schemes/_entry-find.js.map +1 -1
  52. package/dist/schemes/_entry-find.sql +23 -0
  53. package/dist/schemes/_entry-manifest.d.ts +1 -1
  54. package/dist/schemes/_entry-manifest.d.ts.map +1 -1
  55. package/dist/schemes/_entry-manifest.js +22 -4
  56. package/dist/schemes/_entry-manifest.js.map +1 -1
  57. package/dist/schemes/exec-abort.d.ts +4 -0
  58. package/dist/schemes/exec-abort.d.ts.map +1 -1
  59. package/dist/schemes/exec-abort.js +6 -0
  60. package/dist/schemes/exec-abort.js.map +1 -1
  61. package/dist/server/Daemon.d.ts.map +1 -1
  62. package/dist/server/Daemon.js +128 -19
  63. package/dist/server/Daemon.js.map +1 -1
  64. package/dist/server/MethodRegistry.d.ts +1 -0
  65. package/dist/server/MethodRegistry.d.ts.map +1 -1
  66. package/dist/server/drain.sql +17 -5
  67. package/dist/server/envelope.d.ts.map +1 -1
  68. package/dist/server/envelope.js +0 -9
  69. package/dist/server/envelope.js.map +1 -1
  70. package/dist/server/methods/log_read.d.ts.map +1 -1
  71. package/dist/server/methods/log_read.js +7 -1
  72. package/dist/server/methods/log_read.js.map +1 -1
  73. package/dist/server/methods/log_read.sql +17 -7
  74. package/dist/server/methods/session_create.d.ts.map +1 -1
  75. package/dist/server/methods/session_create.js +1 -8
  76. package/dist/server/methods/session_create.js.map +1 -1
  77. package/docs/run.md +3 -1
  78. package/migrations/0000-00-00.01_schema.sql +11 -1
  79. package/package.json +9 -9
  80. package/requirements.md +5 -1
@@ -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";
@@ -315,6 +315,9 @@ 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
+ // #252 — the latest turn's opaque provider blob, parsed for the wire. Empty {} when the
319
+ // provider returned no meta. The service forwards it; it never reads a field within.
320
+ meta: JSON.parse(row?.meta ?? "{}"),
318
321
  };
319
322
  }
320
323
  #pushTelemetry(sessionId, loopId, event) {
@@ -357,6 +360,26 @@ class Engine {
357
360
  }
358
361
  return slice.join("\n");
359
362
  }
363
+ // A @plurnk/gbnf divergence position (providers#24) is a CODE-POINT offset into the
364
+ // model's content; the snippet/telemetry surface speaks 1-based line + 0-based column.
365
+ // Convert over code points (not UTF-16 units) so an astral char doesn't skew the line,
366
+ // clamping out-of-range offsets to the content's end.
367
+ #offsetToLineColumn(content, offset) {
368
+ const cps = Array.from(content);
369
+ const clamped = Math.max(0, Math.min(offset, cps.length));
370
+ let line = 1;
371
+ let column = 0;
372
+ for (let i = 0; i < clamped; i++) {
373
+ if (cps[i] === "\n") {
374
+ line++;
375
+ column = 0;
376
+ }
377
+ else {
378
+ column++;
379
+ }
380
+ }
381
+ return { line, column };
382
+ }
360
383
  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
384
  const turnIds = [];
362
385
  const suddenDeathThreshold = maxTurns - maxStrikes;
@@ -611,7 +634,7 @@ class Engine {
611
634
  // (clamped to the scheme's count so FIND's strict <L> never 416s); off by default.
612
635
  if (seq === 1) {
613
636
  // #231 — a session's client-chosen manifestItems REPLACES the env default outright.
614
- const { manifestItems: sessionMI, autoReadAgents } = await SessionSettings.read(this.#db, sessionId);
637
+ const { manifestItems: sessionMI } = await SessionSettings.read(this.#db, sessionId);
615
638
  const manifestItems = sessionMI !== null ? normalizeManifestItems(sessionMI) : readManifestItems();
616
639
  if (manifestItems !== null && runFirstLoop) { // #269 — catalog preview is run-once
617
640
  // engine_scheme_catalog_summary is the scheme source: session-scoped, ordered,
@@ -656,31 +679,19 @@ class Engine {
656
679
  });
657
680
  nextActionIndex++;
658
681
  }
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 },
682
+ // §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
684
+ // manage its private workspace) so it's catalogued in ITS perspective alone; other
685
+ // runs reach it only via explicit FIND(run://<name>/**). A run with no scratch foists nothing.
686
+ const selfRun = await this.#db.run_name_by_id.get({ run_id: runId });
687
+ const scratch = selfRun === undefined ? 0 : (await this.#db.engine_run_scratch_count.get({ session_id: sessionId, owner_prefix: `/${selfRun.name}/*` }))?.entries ?? 0;
688
+ if (scratch > 0) {
689
+ const runFind = {
690
+ op: "FIND", suffix: "", signal: null,
691
+ target: { kind: "url", raw: "run:///**", scheme: "run", username: null, password: null, hostname: "", port: null, pathname: "/**", params: {}, fragment: null },
692
+ body: null, lineMarker: null, position: { line: 1, column: 1 },
679
693
  };
680
- await this.dispatch({
681
- statement: agentsRead, sessionId, runId, loopId, turnId,
682
- sequence: nextActionIndex, origin: "plurnk", onDispatch,
683
- });
694
+ await this.dispatch({ statement: runFind, sessionId, runId, loopId, turnId, sequence: nextActionIndex, origin: "plurnk", onDispatch });
684
695
  nextActionIndex++;
685
696
  }
686
697
  }
@@ -740,17 +751,18 @@ class Engine {
740
751
  await this.#db.engine_close_turn.run({
741
752
  id: turnId, status: 413, packet: JSON.stringify(hardPacket),
742
753
  usage_prompt: 0, usage_completion: 0, usage_cached: 0, usage_cost_pico: 0,
743
- finish_reason: "budget_hard_stop", model: provider.model,
754
+ finish_reason: "budget_hard_stop", model: provider.model, meta: "{}",
744
755
  });
745
756
  return { turnId, status: 413, statuses: [], fingerprint: "", budgetStruck: enforced.struck, budgetHardStop: true, steerStruck: false };
746
757
  }
747
758
  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);
759
+ // No decode cap. Our budget governs the TRANSMISSION packet (the grinder folds
760
+ // the input under the ceiling); the model's decode reasoning + emission — is
761
+ // out of band, owned by the provider's own context window. Deriving a maxTokens
762
+ // from our budget conflated the two and guillotined a reasoning model's
763
+ // out-of-band thinking as the packet filled (`ceiling - packet` near-zero
764
+ // decode → finish=length mid-reasoning no emission strike spiral). The
765
+ // provider enforces its physical wall on its own.
754
766
  let response;
755
767
  // #249 — plugin attribution tags onto the per-turn generate() wire. Value is the
756
768
  // active-plugin set (placeholder); real per-turn grounding is deferred.
@@ -761,7 +773,7 @@ class Engine {
761
773
  // #249 — session-stable frontend id, forwarded as Plurnk-Client by the plurnk provider only.
762
774
  const { client } = await SessionSettings.read(this.#db, sessionId);
763
775
  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
776
+ 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
777
  }
766
778
  catch (err) {
767
779
  // Every provider error surfaces as telemetry (the client/model sees the cause). #256:
@@ -770,6 +782,9 @@ class Engine {
770
782
  // accepted: fall through as an empty no-op turn so the strike rail retries. Every other
771
783
  // kind (rate_limit, network_failure, unauthorized, …) is terminal — telemetry'd, then
772
784
  // propagated to end the loop (rather than only the opaque loop.run rejection).
785
+ // NOTE (providers 0.19.0 / #275): only the CONSTRAINED path still throws grammar_unenforced.
786
+ // In GBNF-filter mode the provider returns the bytes with a grammar_unenforced telemetry
787
+ // event instead — recovered on the success path below (response.telemetry), no empty turn.
773
788
  if (err instanceof ProviderError) {
774
789
  this.#pushTelemetry(sessionId, loopId, { source: "provider", kind: err.kind, message: err.message });
775
790
  if (err.kind !== "grammar_unenforced")
@@ -801,14 +816,28 @@ class Engine {
801
816
  // the snippet, the model sees "invalid xpath at 1:0" but can't
802
817
  // connect that to what IT wrote — and tends to regenerate the
803
818
  // same broken emission. See edit-todo demo for the canonical case.
804
- for (const { message, line, column, source } of parseErrors ?? []) {
819
+ // Parse errors are LOG ITEMS now (§telemetry one budget surface): each failed-to-parse
820
+ // emission records an actionless `error` row below, after the turn's dispatched ops are
821
+ // sequenced (see the parse-error log write past the dispatch loop). The errors section
822
+ // derives a pointer to it from log≥400, uniform with action_failure.
823
+ // providers#24 / #275: non-fatal provider telemetry on a SUCCESSFUL turn. In GBNF-filter
824
+ // mode the provider no longer THROWS grammar_unenforced — it returns the model's bytes
825
+ // (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.
830
+ for (const event of response.telemetry ?? []) {
831
+ const located = typeof event.position === "number"
832
+ ? this.#offsetToLineColumn(packetAssistant.content, event.position)
833
+ : null;
805
834
  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,
835
+ source: event.source,
836
+ kind: event.kind,
837
+ message: event.message ?? "",
838
+ ...(located !== null
839
+ ? { position: { type: "content-offset", line: located.line, column: located.column }, snippet: this.#extractSnippet(packetAssistant.content, located.line, 2) }
840
+ : {}),
812
841
  });
813
842
  }
814
843
  const opsCount = packetAssistant.ops.length;
@@ -822,19 +851,22 @@ class Engine {
822
851
  // §grinder-strike-coupling): the loop continues, the model sees the steering hint not the strike
823
852
  // count, and a non-resolver spins out to the engine's 500.
824
853
  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.
854
+ // Premature terminate: a SEND[200] while the run still holds a live thingan open stream/spawn
855
+ // OR a non-terminal child run (§run-lifecycle: children and streams are the same kind of "live
856
+ // thing a run holds"). The model declared done with work running. Downgrade the 200 to 102 so it
857
+ // dispatches as a continue (its body is preserved, not discarded) and steer; the stream's/child's
858
+ // own conclusion (the wake edge) or a KILL is the exit.
828
859
  if (sendOp?.signal === 200) {
829
860
  const openSubs = await this.#db.find_open_subscriptions_for_run.all({ run_id: runId });
830
861
  const execHandler = this.#schemes.get("exec");
831
- if (openSubs.length > 0 || execHandler?.hasActiveSpawns?.(runId) === true) {
862
+ const liveChild = await this.#db.engine_run_has_live_child.get({ run_id: runId });
863
+ if (openSubs.length > 0 || execHandler?.hasActiveSpawns?.(runId) === true || liveChild !== undefined) {
832
864
  sendOp.signal = TURN_STATUS_IMPLICIT_CONTINUE; // 102 — downgraded, no longer a terminal
833
865
  steerStruck = true;
834
866
  this.#pushTelemetry(sessionId, loopId, {
835
867
  source: "engine:rail",
836
868
  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.",
869
+ 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.",
838
870
  });
839
871
  }
840
872
  }
@@ -867,6 +899,9 @@ class Engine {
867
899
  usage_cost_pico: provider.costFor(usage), // §provider-surface-costfor
868
900
  finish_reason: finishReason,
869
901
  model,
902
+ // #252 — opaque provider→client metadata passthrough (e.g. balancePico the
903
+ // provider normalized). Stored verbatim, unenforced; the service never reads a field.
904
+ meta: JSON.stringify(response.meta ?? {}),
870
905
  });
871
906
  // Dispatch model ops starting at nextActionIndex (continues the
872
907
  // turn's running counter after any pre-model writes).
@@ -910,6 +945,27 @@ class Engine {
910
945
  dropped: droppedCount,
911
946
  });
912
947
  }
948
+ // §telemetry — parse errors as LOG ITEMS: a failed-to-parse emission records an actionless
949
+ // `error` row (status 400, no target, snippet = the foldable body) at the turn's next free
950
+ // sequence (after the dispatched ops). The model folds/kills/recalls it like any log entry,
951
+ // and the errors section derives a pointer (status + coordinate) from log≥400 — one surface.
952
+ let errSeq = nextActionIndex + opsToDispatch.length;
953
+ for (const { message, line, column, source } of parseErrors ?? []) {
954
+ const snippet = this.#extractSnippet(packetAssistant.content, line, 2);
955
+ await this.#db.engine_insert_log_entry.get({
956
+ run_id: runId, loop_id: loopId, turn_id: turnId, sequence: errSeq++,
957
+ origin: "model", source: "grammar", op: "error", suffix: "", signal: null,
958
+ scheme: null, username: null, password: null, hostname: null, port: null,
959
+ pathname: null, params: null, fragment: null, lineMarker: null,
960
+ 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 }),
965
+ mimetype_rx: "application/json",
966
+ status_rx: 400, tokens: 0, state: "resolved", outcome: null, attrs: "{}",
967
+ });
968
+ }
913
969
  // Zero ops is NOT an error to report — the model knows it emitted
914
970
  // nothing. Strike accounting (engine-internal) treats it as a
915
971
  // struck turn; the model just sees an empty packet next turn.
@@ -1032,15 +1088,23 @@ class Engine {
1032
1088
  // peer sections (unbundled). The budget section carries its {{tokensFree}}
1033
1089
  // placeholders here; they resolve below once the assembled total is known.
1034
1090
  const inject = await readPacketInject(); // #240 — operator section, per-turn, fail-hard on a broken path
1091
+ const sessionRoot = (await this.#db.envelope_get_session.get({ id: sessionId }))?.project_root ?? null;
1092
+ const systemPolicy = await readSystemPolicy(); // ~/.plurnk/AGENTS.md (or PLURNK_POLICY)
1093
+ const projectPolicy = await readProjectPolicy(sessionRoot); // <projectRoot>/AGENTS.md (or PLURNK_PROJECT)
1035
1094
  const defaults = [
1036
1095
  { name: "definition", slot: "system", header: null, content: system_definition, tokens: 0 },
1037
1096
  { name: "tools", slot: "system", header: null, content: tools.join("\n"), tokens: 0 }, // titleless — the examples flow on from plurnk.md (definition) directly above
1038
1097
  { name: "schemes", slot: "system", header: "Plurnk System Schemes", content: this.#schemes.teach(), tokens: 0 },
1039
1098
  ...(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 },
1099
+ // 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
+ { name: "system-policy", slot: "system", header: "Plurnk System Policy", content: systemPolicy ?? "", tokens: 0 },
1101
+ { 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.
1103
+ { name: "budget", slot: "system", header: "Plurnk System Budget", content: budgetReadout, tokens: 0 },
1041
1104
  { 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
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.
1107
+ { name: "log", slot: "user", header: "Plurnk System Log", content: PacketWire.renderLog(log, countTokens), tokens: 0 },
1044
1108
  { name: "git", slot: "user", header: "Plurnk System Git Status", content: PacketWire.renderGit(gitStatus), tokens: 0 },
1045
1109
  { name: "requirements", slot: "user", header: "Plurnk System Requirements", content: baseRequirements, tokens: 0 },
1046
1110
  ];
@@ -1894,8 +1958,9 @@ class Engine {
1894
1958
  const schemeName = this.#schemeNameOf(path);
1895
1959
  if (schemeName === null)
1896
1960
  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" };
1961
+ // KILL on log:/// erases the log row(s) — the model's DB-storage curation lever
1962
+ // (plurnk.md:36, :98), routed to Log.kill below via the killable.kill path. The old
1963
+ // "append-only" 405 forbade what the grammar requires; FOLD only collapses the render.
1899
1964
  // Process-KILL: any scheme whose handler exposes kill() aborts a live stream — the
1900
1965
  // exec handler, registered as "exec" + under every runtime tag (sh/node), so a tag-
1901
1966
  // addressed stream (sh:///l/t/s) routes here, not to deleteEntry. §exec