@plurnk/plurnk-service 0.44.0 → 0.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/.env.example +41 -24
  2. package/README.md +37 -18
  3. package/SPEC.md +34 -10
  4. package/dist/core/ChannelWrite.d.ts +4 -0
  5. package/dist/core/ChannelWrite.d.ts.map +1 -1
  6. package/dist/core/ChannelWrite.js +9 -0
  7. package/dist/core/ChannelWrite.js.map +1 -1
  8. package/dist/core/ChannelWrite.sql +69 -0
  9. package/dist/core/Engine.d.ts.map +1 -1
  10. package/dist/core/Engine.js +146 -103
  11. package/dist/core/Engine.js.map +1 -1
  12. package/dist/core/Engine.sql +307 -0
  13. package/dist/core/ExecutorRegistry.d.ts +4 -2
  14. package/dist/core/ExecutorRegistry.d.ts.map +1 -1
  15. package/dist/core/ExecutorRegistry.js +20 -5
  16. package/dist/core/ExecutorRegistry.js.map +1 -1
  17. package/dist/core/SchemeRegistry.d.ts +3 -0
  18. package/dist/core/SchemeRegistry.d.ts.map +1 -1
  19. package/dist/core/SchemeRegistry.js +37 -0
  20. package/dist/core/SchemeRegistry.js.map +1 -1
  21. package/dist/core/fork.d.ts.map +1 -1
  22. package/dist/core/fork.js +8 -1
  23. package/dist/core/fork.js.map +1 -1
  24. package/dist/core/fork.sql +50 -0
  25. package/dist/core/packet-wire.d.ts +1 -0
  26. package/dist/core/packet-wire.d.ts.map +1 -1
  27. package/dist/core/packet-wire.js +11 -0
  28. package/dist/core/packet-wire.js.map +1 -1
  29. package/dist/core/plugin-attribution.d.ts +5 -0
  30. package/dist/core/plugin-attribution.d.ts.map +1 -0
  31. package/dist/core/plugin-attribution.js +39 -0
  32. package/dist/core/plugin-attribution.js.map +1 -0
  33. package/dist/core/run-ops.sql +16 -0
  34. package/dist/core/scheme-types.d.ts +1 -0
  35. package/dist/core/scheme-types.d.ts.map +1 -1
  36. package/dist/core/session-settings.d.ts +1 -0
  37. package/dist/core/session-settings.d.ts.map +1 -1
  38. package/dist/core/session-settings.js +2 -1
  39. package/dist/core/session-settings.js.map +1 -1
  40. package/dist/schemes/Exec.d.ts +2 -1
  41. package/dist/schemes/Exec.d.ts.map +1 -1
  42. package/dist/schemes/Exec.js +39 -22
  43. package/dist/schemes/Exec.js.map +1 -1
  44. package/dist/schemes/File.d.ts.map +1 -1
  45. package/dist/schemes/File.js +27 -3
  46. package/dist/schemes/File.js.map +1 -1
  47. package/dist/schemes/Log.sql +37 -0
  48. package/dist/schemes/_entry-crud.sql +88 -0
  49. package/dist/schemes/_entry-find.sql +31 -0
  50. package/dist/schemes/_entry-graph.sql +60 -0
  51. package/dist/schemes/_entry-manifest.d.ts.map +1 -1
  52. package/dist/schemes/_entry-manifest.js +11 -3
  53. package/dist/schemes/_entry-manifest.js.map +1 -1
  54. package/dist/schemes/_entry-ops.sql +20 -0
  55. package/dist/schemes/_entry-semantic.d.ts.map +1 -1
  56. package/dist/schemes/_entry-semantic.js +14 -7
  57. package/dist/schemes/_entry-semantic.js.map +1 -1
  58. package/dist/schemes/_entry-semantic.sql +80 -0
  59. package/dist/schemes/exec-abort.d.ts +11 -0
  60. package/dist/schemes/exec-abort.d.ts.map +1 -0
  61. package/dist/schemes/exec-abort.js +23 -0
  62. package/dist/schemes/exec-abort.js.map +1 -0
  63. package/dist/schemes/exec-env.js +1 -1
  64. package/dist/schemes/exec-env.js.map +1 -1
  65. package/dist/server/Daemon.d.ts.map +1 -1
  66. package/dist/server/Daemon.js +162 -76
  67. package/dist/server/Daemon.js.map +1 -1
  68. package/dist/server/clientTurn.sql +10 -0
  69. package/dist/server/drain.sql +92 -0
  70. package/dist/server/dsl.d.ts.map +1 -1
  71. package/dist/server/dsl.js +11 -4
  72. package/dist/server/dsl.js.map +1 -1
  73. package/dist/server/envelope.sql +75 -0
  74. package/dist/server/logEntry.sql +10 -0
  75. package/dist/server/methods/_dispatchAsClient.d.ts.map +1 -1
  76. package/dist/server/methods/_dispatchAsClient.js +11 -6
  77. package/dist/server/methods/_dispatchAsClient.js.map +1 -1
  78. package/dist/server/methods/entry_read.sql +21 -0
  79. package/dist/server/methods/log_read.sql +11 -0
  80. package/dist/server/methods/loop_run.d.ts.map +1 -1
  81. package/dist/server/methods/loop_run.js +31 -11
  82. package/dist/server/methods/loop_run.js.map +1 -1
  83. package/dist/server/methods/loop_run.sql +9 -0
  84. package/dist/server/methods/session_create.d.ts.map +1 -1
  85. package/dist/server/methods/session_create.js +10 -3
  86. package/dist/server/methods/session_create.js.map +1 -1
  87. package/dist/service.d.ts +6 -0
  88. package/dist/service.d.ts.map +1 -0
  89. package/dist/service.js +221 -0
  90. package/dist/service.js.map +1 -0
  91. package/migrations/0000-00-00.01_schema.sql +2 -2
  92. package/package.json +27 -13
  93. package/bin/plurnk-service.ts +0 -176
  94. package/dist/core/ProviderRegistry.d.ts +0 -42
  95. package/dist/core/ProviderRegistry.d.ts.map +0 -1
  96. package/dist/core/ProviderRegistry.js +0 -72
  97. package/dist/core/ProviderRegistry.js.map +0 -1
  98. package/dist/core/line-marker.d.ts +0 -23
  99. package/dist/core/line-marker.d.ts.map +0 -1
  100. package/dist/core/line-marker.js +0 -321
  101. package/dist/core/line-marker.js.map +0 -1
  102. package/dist/core/matcher.d.ts +0 -12
  103. package/dist/core/matcher.d.ts.map +0 -1
  104. package/dist/core/matcher.js +0 -72
  105. package/dist/core/matcher.js.map +0 -1
  106. package/dist/core/mimetype-binary.d.ts +0 -6
  107. package/dist/core/mimetype-binary.d.ts.map +0 -1
  108. package/dist/core/mimetype-binary.js +0 -82
  109. package/dist/core/mimetype-binary.js.map +0 -1
  110. package/dist/core/path-mimetype.d.ts +0 -3
  111. package/dist/core/path-mimetype.d.ts.map +0 -1
  112. package/dist/core/path-mimetype.js +0 -47
  113. package/dist/core/path-mimetype.js.map +0 -1
  114. package/dist/core/plugin-trust.d.ts +0 -4
  115. package/dist/core/plugin-trust.d.ts.map +0 -1
  116. package/dist/core/plugin-trust.js +0 -23
  117. package/dist/core/plugin-trust.js.map +0 -1
  118. package/dist/providers/Mock.d.ts +0 -43
  119. package/dist/providers/Mock.d.ts.map +0 -1
  120. package/dist/providers/Mock.js +0 -36
  121. package/dist/providers/Mock.js.map +0 -1
  122. package/dist/server/methods/op_hide.d.ts +0 -5
  123. package/dist/server/methods/op_hide.d.ts.map +0 -1
  124. package/dist/server/methods/op_hide.js +0 -24
  125. package/dist/server/methods/op_hide.js.map +0 -1
  126. package/dist/server/methods/op_show.d.ts +0 -5
  127. package/dist/server/methods/op_show.d.ts.map +0 -1
  128. package/dist/server/methods/op_show.js +0 -24
  129. package/dist/server/methods/op_show.js.map +0 -1
  130. package/dist/server/methods/session_set_persona.d.ts +0 -5
  131. package/dist/server/methods/session_set_persona.d.ts.map +0 -1
  132. package/dist/server/methods/session_set_persona.js +0 -29
  133. package/dist/server/methods/session_set_persona.js.map +0 -1
@@ -71,6 +71,7 @@ const readManifestItems = () => {
71
71
  return null;
72
72
  return normalizeManifestItems(Number.parseInt(raw, 10));
73
73
  };
74
+ import { ProviderError } from "@plurnk/plurnk-providers";
74
75
  // Resolution timeout — proposed entries auto-cancel if nothing arrives
75
76
  // within this window. SPEC.md §engine-rails (proposal lifecycle) + §methods (loop.resolve).
76
77
  const PROPOSAL_TIMEOUT_DEFAULT_MS = 300000;
@@ -365,13 +366,12 @@ class Engine {
365
366
  }
366
367
  this.#loopAborts.set(loopId, loopAbort);
367
368
  // Cleanup splits by termination kind:
368
- // - "graceful" (loop emitted SEND[2xx]): in-flight streaming-scheme
369
- // spawns are ALLOWED to outlive the loop. They complete naturally,
370
- // write final channel state, and wake-on-completion (E.4) opens a
371
- // fresh loop in the same run if the model needs to react.
372
- // - "forceful" (max_turns, strike_threshold, external cancel,
373
- // non-2xx loop status): fire the loop-level abort so spawns
374
- // tear down immediately.
369
+ // - "graceful" (SEND[202] Accepted): in-flight streaming-scheme spawns
370
+ // are ALLOWED to outlive the loop they complete naturally, write final
371
+ // channel state, and wake-on-completion (E.4) opens a fresh loop. 202 is
372
+ // the only terminal that means "keep my async work."
373
+ // - "forceful" (SEND[200] done, max_turns, strike, cancel, budget, 4xx/5xx):
374
+ // fire the loop-level abort so leftover spawns tear down. "Done" reaps.
375
375
  const cleanup = (kind, reason) => {
376
376
  if (kind === "forceful" && !loopAbort.signal.aborted) {
377
377
  loopAbort.abort(reason ?? "loop_forceful_termination");
@@ -386,10 +386,10 @@ class Engine {
386
386
  if (row === undefined)
387
387
  throw new Error(`Engine.runLoop: loop ${loopId} not found`);
388
388
  if (row.status !== 102) {
389
- // Status 2xx = graceful (model said done); 4xx/5xx = forceful
390
- // (external cancel or upstream failure). The threshold splits
391
- // at 400 to match HTTP success/error semantics.
392
- cleanup(row.status < 400 ? "graceful" : "forceful", `loop_terminal_${row.status}`);
389
+ // Only 202 (Accepted) lets spawns outlive it IS the async wake
390
+ // contract (E.4). Every other terminal, 200 included, reaps: "done"
391
+ // must not leak running execs. Trust the code's declared intent.
392
+ cleanup(row.status === 202 ? "graceful" : "forceful", `loop_terminal_${row.status}`);
393
393
  return { turnIds, finalStatus: row.status, hitMaxTurns: false, reason: "external" };
394
394
  }
395
395
  if (maxTurns >= 0 && turnIds.length >= maxTurns) {
@@ -577,6 +577,7 @@ class Engine {
577
577
  wakeRunNotify: this.#wakeRunNotify,
578
578
  tokenize: this.#tokenize,
579
579
  mimetypes: this.#mimetypes,
580
+ defaultChannelFor: (s) => this.#schemes.defaultChannelFor(s),
580
581
  pushTelemetry: (event) => this.#pushTelemetry(sessionId, loopId, event),
581
582
  };
582
583
  // SPEC §membership D4/D5 — git-ls-files workspace membership, resolved at
@@ -599,7 +600,7 @@ class Engine {
599
600
  // it, not a 404; same plurnk-origin foist as the operator docs.
600
601
  if (seq === 1) {
601
602
  // #231 — a session's client-chosen manifestItems REPLACES the env default outright.
602
- const { manifestItems: sessionMI } = await SessionSettings.read(this.#db, sessionId);
603
+ const { manifestItems: sessionMI, autoReadAgents } = await SessionSettings.read(this.#db, sessionId);
603
604
  const manifestItems = sessionMI !== null ? normalizeManifestItems(sessionMI) : readManifestItems();
604
605
  if (manifestItems !== null) {
605
606
  const manifestRead = {
@@ -618,6 +619,30 @@ class Engine {
618
619
  });
619
620
  nextActionIndex++;
620
621
  }
622
+ // #250 — auto-READ the project's AGENTS.md scratchpad into THIS first model turn
623
+ // when the session opted in AND it's a member. The client picks it (gitignored by
624
+ // convention → not a git member); the engine READs it here so its body is part of
625
+ // turn-1's log — a normal file:/// member READ (the model sees only the READ; it
626
+ // stays read-write, so the model edits the scratchpad back as it evolves).
627
+ if (autoReadAgents === true) {
628
+ const agentsMember = await this.#db.crud_get_member_sig.get({ session_id: sessionId, scheme: null, pathname: "/AGENTS.md" });
629
+ if (agentsMember !== undefined) {
630
+ const agentsRead = {
631
+ op: "READ", suffix: "", signal: null, lineMarker: null,
632
+ target: {
633
+ kind: "url", raw: "file:///AGENTS.md", scheme: "file",
634
+ username: null, password: null, hostname: null, port: null,
635
+ pathname: "/AGENTS.md", params: {}, fragment: null,
636
+ },
637
+ body: null, position: { line: 1, column: 1 },
638
+ };
639
+ await this.dispatch({
640
+ statement: agentsRead, sessionId, runId, loopId, turnId,
641
+ sequence: nextActionIndex, origin: "plurnk", onDispatch,
642
+ });
643
+ nextActionIndex++;
644
+ }
645
+ }
621
646
  }
622
647
  // §env-delta — pre-seed environment deltas (changes since this run last
623
648
  // reconciled) as system EDIT rows, before the packet composes; advance
@@ -631,14 +656,14 @@ class Engine {
631
656
  // queries log_entries scoped to the run — the prompt entry just
632
657
  // written (if turn 1) is part of that query result.
633
658
  let requestPacket = await this.#buildRequestPacket({
634
- initialMessages: messages, requirements, runId, loopId,
659
+ initialMessages: messages, requirements, sessionId, runId, loopId,
635
660
  currentTurnSeq: seq, provider, gitStatus,
636
661
  });
637
662
  // SPEC §grinder — budget grinder, pre-LLM: reclaim window on actual overflow.
638
663
  const enforced = await this.#enforceBudget({
639
664
  packet: requestPacket, provider, runId, loopId, turnId, sessionId, turnNumber,
640
665
  rebuild: (telemetryErrors) => this.#buildRequestPacket({
641
- initialMessages: messages, requirements, runId, loopId,
666
+ initialMessages: messages, requirements, sessionId, runId, loopId,
642
667
  currentTurnSeq: seq, provider, telemetryErrors, gitStatus,
643
668
  }),
644
669
  });
@@ -661,7 +686,33 @@ class Engine {
661
686
  // decode at the free window so a runaway can't reach the context wall.
662
687
  const genCeiling = _a.computeCeiling(provider.contextSize, this.#budgetCeiling); // provider.contextSize, the immutable identity, read by the budget — §provider-surface-identity
663
688
  const maxTokens = genCeiling === null ? undefined : Math.max(1, genCeiling - requestPacket.tokens);
664
- const response = await provider.generate({ messages: modelMessages, runId: String(runId), signal, grammar: await this.#grammarConstraint(), maxTokens }); // §provider-surface-generate §provider-guarantees-single-call §provider-guarantees-signal-wired
689
+ let response;
690
+ // #249 — plugin attribution tags onto the per-turn generate() wire. Value is the
691
+ // active-plugin set (placeholder); real per-turn grounding is deferred.
692
+ const attributions = [...new Set([...this.#schemes.attributions(), ...(this.#executors?.attributions() ?? [])])].toSorted();
693
+ try {
694
+ response = await provider.generate({ messages: modelMessages, runId: String(runId), signal, grammar: await this.#grammarConstraint(), maxTokens, attributions: attributions.length > 0 ? attributions : undefined }); // §provider-surface-generate §provider-guarantees-single-call §provider-guarantees-signal-wired §attribution-plurnk-namespace-reserved
695
+ }
696
+ catch (err) {
697
+ // Every provider error surfaces as telemetry (the client/model sees the cause). #256:
698
+ // grammar_unenforced is the one the MODEL can recover from — the backend didn't
699
+ // constrain the GBNF, so this turn was rejected but a conforming emission next turn is
700
+ // accepted: fall through as an empty no-op turn so the strike rail retries. Every other
701
+ // kind (rate_limit, network_failure, unauthorized, …) is terminal — telemetry'd, then
702
+ // propagated to end the loop (rather than only the opaque loop.run rejection).
703
+ if (err instanceof ProviderError) {
704
+ this.#pushTelemetry(sessionId, loopId, { source: "provider", kind: err.kind, message: err.message });
705
+ if (err.kind !== "grammar_unenforced")
706
+ throw err;
707
+ response = {
708
+ assistant: { content: "", reasoning: null, usage: { prompt: requestPacket.tokens, completion: 0, reasoning: 0, cached: 0, total: requestPacket.tokens }, finishReason: null, model: provider.model },
709
+ assistantRaw: null,
710
+ };
711
+ }
712
+ else {
713
+ throw err;
714
+ }
715
+ }
665
716
  // Engine splits wire-level response: emission (content, reasoning,
666
717
  // parsed ops) → packet.assistant per Packet.json §assistant;
667
718
  // call-metadata (usage, finishReason, model) → Turn columns per
@@ -691,10 +742,10 @@ class Engine {
691
742
  });
692
743
  }
693
744
  const opsCount = packetAssistant.ops.length;
694
- // Informational SEND[103] broadcasts (#free-text-capture) are log rows, not
695
- // actions: excluded from the real-op count so a prose-only turn still strikes
745
+ // PLAN (reasoning) and informational SEND[103] are no-ops, not actions: both are
746
+ // excluded from the real-op count so a PLAN-only or prose-only turn still strikes
696
747
  // as no-ops, and the terminal scan ignores 1xx so they never set turnStatus.
697
- const realOpsCount = packetAssistant.ops.filter((op) => !(op.op === "SEND" && op.signal === 103 && op.target === null)).length;
748
+ const realOpsCount = packetAssistant.ops.filter((op) => op.op !== "PLAN" && !(op.op === "SEND" && op.signal === 103 && op.target === null)).length;
698
749
  const sendOp = packetAssistant.ops.findLast((op) => op.op === "SEND" && typeof op.signal === "number" && op.signal >= 200);
699
750
  // Rail #41 (revised): the per-turn requirement is "emit at least one
700
751
  // op," not "emit a terminal SEND." SEND is purely a signal verb; many
@@ -728,7 +779,14 @@ class Engine {
728
779
  // its emission was truncated.
729
780
  // #232 — a session's maxCommands is a tighten-only ceiling: min() the env ceiling.
730
781
  const maxCommands = Math.min(readMaxCommands(), (await SessionSettings.read(this.#db, sessionId)).maxCommands ?? Number.POSITIVE_INFINITY);
731
- const opsToDispatch = packetAssistant.ops.slice(0, maxCommands);
782
+ // PLAN (reasoning) and a terminal SEND (signal ≥ 200, the conclusion) are not
783
+ // actions — they always dispatch and never count against the cap. maxCommands
784
+ // bounds real actions only; maxCommands:0 still admits a plan and a conclusion
785
+ // (the PLAN/SEND ops, zero actions), which is its only coherent meaning.
786
+ let realCommands = 0;
787
+ const opsToDispatch = packetAssistant.ops.filter((op) => op.op === "PLAN"
788
+ || (op.op === "SEND" && typeof op.signal === "number" && op.signal >= 200)
789
+ || realCommands++ < maxCommands);
732
790
  const droppedCount = opsCount - opsToDispatch.length;
733
791
  const statuses = [];
734
792
  for (const [i, statement] of opsToDispatch.entries()) {
@@ -772,46 +830,26 @@ class Engine {
772
830
  const { assistant } = response;
773
831
  const preParsedOps = assistant.ops;
774
832
  const ops = [];
775
- // #plan-reasoning: PLAN bodies are the model's in-band reasoning hoisted
776
- // out of the op stream into the reasoning field, never dispatched as a log op.
777
- // Free text becomes informational SEND[103] log ops (#free-text-capture) below.
778
- const planFragments = [];
779
- const hoistPlan = (op) => {
780
- if (op.op !== "PLAN")
781
- return false;
782
- const raw = (op.body ?? "").trim();
783
- if (raw.length > 0)
784
- planFragments.push(raw);
785
- return true;
786
- };
833
+ // PLAN is an ordinary op — emitted by the model, dispatched, and passed to the
834
+ // client as a log entry. No special hoisting into the reasoning field (that
835
+ // legacy paradigm is abandoned). Interstitial free text is DROPPED — the prior
836
+ // #free-text-capture synthesis of SEND[103] log ops was retired as tech debt
837
+ // (grammar 0.70 forbids free text between ops, so a prose-only turn strikes 422).
787
838
  // Full PlurnkParseError context (line/column/source) is preserved
788
839
  // here so runTurn can build TelemetryEvent envelopes per the
789
840
  // grammar 0.17.0 protocol — model needs position info to locate
790
841
  // its own offending content on the next turn.
791
842
  const parseErrors = [];
792
843
  if (preParsedOps !== undefined) {
793
- for (const op of preParsedOps)
794
- if (!hoistPlan(op))
795
- ops.push(op);
844
+ ops.push(...preParsedOps);
796
845
  }
797
846
  else {
798
847
  const parsed = PlurnkParser.parse(assistant.content);
799
848
  for (const item of parsed.items) {
800
849
  if (item.kind === "statement") {
801
- if (!hoistPlan(item.statement))
802
- ops.push(item.statement);
803
- }
804
- else if (item.kind === "text") {
805
- // #free-text-capture: interstitial prose → an informational
806
- // SEND[103] broadcast log op, interleaved in emission order.
807
- const trimmed = item.text.trim();
808
- if (trimmed.length > 0)
809
- ops.push({
810
- op: "SEND", suffix: "", signal: 103, target: null,
811
- lineMarker: null, body: { raw: trimmed, json: null },
812
- position: item.position ?? { line: 1, column: 1 },
813
- });
850
+ ops.push(item.statement);
814
851
  }
852
+ // Free text (kind "text") is dropped — #free-text-capture retired (above).
815
853
  else if (item.kind === "error") {
816
854
  const err = item.error;
817
855
  if (err instanceof PlurnkParseError) {
@@ -834,10 +872,7 @@ class Engine {
834
872
  parseErrors.push({ message: tail.reason, line: tail.from.line, column: tail.from.column, source: "grammar" });
835
873
  }
836
874
  }
837
- const wireReasoning = assistant.reasoning ?? "";
838
- const planReasoning = planFragments.join("\n\n");
839
- const reasoningParts = [wireReasoning, planReasoning].filter((s) => s.length > 0);
840
- const reasoning = reasoningParts.length > 0 ? reasoningParts.join("\n\n") : null;
875
+ const reasoning = assistant.reasoning ?? null;
841
876
  return {
842
877
  packetAssistant: { content: assistant.content, ops, reasoning },
843
878
  callMetadata: { usage: assistant.usage, finishReason: assistant.finishReason, model: assistant.model },
@@ -848,11 +883,12 @@ class Engine {
848
883
  // and §user) BEFORE the provider call. The same packet object is then
849
884
  // completed with assistant + assistantRaw after the model responds, so
850
885
  // the stored packet and the wire payload share one source of truth.
851
- async #buildRequestPacket({ initialMessages, requirements, runId, loopId, currentTurnSeq, provider, gitStatus, telemetryErrors: presetTelemetry, }) {
886
+ async #buildRequestPacket({ initialMessages, requirements, sessionId, runId, loopId, currentTurnSeq, provider, gitStatus, telemetryErrors: presetTelemetry, }) {
852
887
  const byRole = (role) => initialMessages.filter((m) => m.role === role).map((m) => m.content).join("\n\n");
853
- // plurnk.md (grammar/dialects onlygrammar 0.49+ is scheme-agnostic). The
854
- // scheme catalogue is now its OWN section (`schemes`, below tools), so the
855
- // definition is just the operator's system prompt tools sits right below it.
888
+ // plurnk.md (grammar/dialects) ONLYthe definition is the hot-path grammar.
889
+ // The scheme catalogue is its own `schemes` section below tools (§schemes-directory),
890
+ // NOT appended here: grammar 0.49+ is scheme-agnostic, so the service advertises
891
+ // the scheme set at packet-time (grammar#239 item 7) via SchemeRegistry.teach().
856
892
  const system_definition = byRole("system");
857
893
  // the prompt section sources from the loop's most recent prompt entry first
858
894
  // (plurnk:///prompt/<loop_id>/<N> for the highest N written to date).
@@ -869,11 +905,11 @@ class Engine {
869
905
  // requirements). Read Paths.defaultRequirements (PLURNK_REQUIREMENTS env →
870
906
  // requirements.md) fresh each build so edits take effect; a non-empty param wins.
871
907
  const baseRequirements = requirements.length > 0 ? requirements : await readFile(Paths.defaultRequirements, "utf8");
872
- // The op syntax leads the requirements; when PLURNK_PLAN=1 the plan directive joins the
873
- // HARD requirements list (dynamically added/removed with the flag) rather than softly
874
- // appearing in the optional # Plurnk System Tools sheet. §requirements-plan-gated
875
- const planDirective = process.env.PLURNK_PLAN === "1" ? "YOU MUST begin every response with <<PLAN:...:PLAN\n" : "";
876
- const requirementsText = `Syntax: <<OPsuffix[signal]?(target)?<Line/Result>?:body?:OPsuffix\n\n${planDirective}${baseRequirements}`;
908
+ // The op syntax leads the requirements. PLAN is mandated unconditionally by
909
+ // plurnk.md §Imperatives (grammar 0.70 requires every turn to lead with PLAN),
910
+ // so the service injects no separate plan directive here — the former PLURNK_PLAN
911
+ // gating is retired (PLURNK_PLAN is no longer a flag).
912
+ const requirementsText = `Syntax: <<OPsuffix[signal]?(target)?<Line/Result>?:body?:OPsuffix\n\n${baseRequirements}`;
877
913
  const log = await this.#buildLog(runId);
878
914
  const telemetryErrors = presetTelemetry ?? await this.#buildTelemetryErrors(loopId, currentTurnSeq);
879
915
  const countTokens = (t) => provider.countTokens(t); // §provider-surface-counttokens
@@ -887,10 +923,13 @@ class Engine {
887
923
  // omitted, section lines still shown). §tokenomics-render-weight-budget
888
924
  const ceiling = _a.computeCeiling(provider.contextSize, this.#budgetCeiling);
889
925
  const budgetReadout = this.#renderBudget(PacketWire.measureLogBudget(log, countTokens), ceiling);
926
+ // Per-scheme tally (§packet) so the model sees which schemes hold content without
927
+ // probing e.g. FIND(known://**) every turn. "" when empty → the section is omitted.
928
+ const catalogSummary = await this.#db.engine_scheme_catalog_summary.all({ session_id: sessionId });
890
929
  // The default packet: an ordered list of sections, each addressable state
891
930
  // (§packet-construction). `slot` is the prompt-cache boundary; the STATIC
892
- // sections (definition, tools, schemes) lead the system slot so they form the
893
- // cached prefix, with the dynamic log after. In the user slot, requirements renders
931
+ // sections (definition, tools) lead the system slot so they form the cached
932
+ // prefix, with the dynamic log after. In the user slot, requirements renders
894
933
  // last (the contract closest to the assistant turn); budget/errors/git are
895
934
  // peer sections (unbundled). The budget section carries its {{tokensFree}}
896
935
  // placeholders here; they resolve below once the assembled total is known.
@@ -903,6 +942,7 @@ class Engine {
903
942
  { name: "budget", slot: "user", header: "Plurnk System Budget", content: budgetReadout, tokens: 0 },
904
943
  { name: "errors", slot: "user", header: "Plurnk System Errors", content: PacketWire.renderErrors(telemetryErrors), tokens: 0 },
905
944
  { name: "git", slot: "user", header: "Plurnk System Git Status", content: PacketWire.renderGit(gitStatus), tokens: 0 },
945
+ { name: "catalog", slot: "user", header: "Plurnk System Catalog", content: PacketWire.renderCatalog(catalogSummary), tokens: 0 },
906
946
  { name: "requirements", slot: "user", header: "Plurnk System Requirements", content: requirementsText, tokens: 0 },
907
947
  ];
908
948
  // Plugin packet control (§packet-construction): trusted schemes rewrite the
@@ -961,9 +1001,7 @@ class Engine {
961
1001
  // The # Plurnk System Tools capability sheet (SPEC §tools). A hook: each enabled
962
1002
  // capability contributes one line, rendered above Requirements so the model sees what
963
1003
  // it can do before the rules. Each available executor tag contributes its self-documenting
964
- // example (plurnk-execs#7), retiring the blind EXEC. The plan directive is NOT a tool — it
965
- // is a hard requirement gated by PLURNK_PLAN (§requirements-plan-gated), so it joins the
966
- // rules list, not this optional sheet.
1004
+ // example (plurnk-execs#7), retiring the blind EXEC.
967
1005
  // The capability sheet — the live tool surface (wired executor tags). §tools-capability-sheet
968
1006
  #collectTools() {
969
1007
  const tools = [];
@@ -1269,13 +1307,13 @@ class Engine {
1269
1307
  const logEntryId = await this.#writeLog({ statement, result, runId, loopId, turnId, sequence, origin });
1270
1308
  onDispatch?.(logEntryId);
1271
1309
  // Proposal lifecycle (SPEC.md §engine-rails + §methods loop.resolve; §proposal-202-pauses). When a
1272
- // scheme returns status 202, the entry is written as state='proposed';
1273
- // dispatch then PAUSES on a per-entry waiter until resolution
1274
- // arrives via Engine.resolveProposal (from the loop/resolve RPC,
1275
- // YOLO listener, or timeout). The post-resolution status replaces
1276
- // 202 in the result the caller sees, so runTurn never branches on
1277
- // a pending state.
1278
- if (result.status === 202) {
1310
+ // side-effecting op returns status 202 (a broadcast SEND[202] park is model
1311
+ // speech, not a proposal #isProposal, #255), the entry is written
1312
+ // state='proposed'; dispatch then PAUSES on a per-entry waiter until
1313
+ // resolution arrives via Engine.resolveProposal (from the loop/resolve RPC,
1314
+ // YOLO listener, or timeout). The post-resolution status replaces 202 in the
1315
+ // result the caller sees, so runTurn never branches on a pending state.
1316
+ if (_a.#isProposal(statement, result)) {
1279
1317
  // Effect-gated auto-run (read/pure runtimes, plurnk-service#182):
1280
1318
  // no human gate, no loop/proposal notification. Accept + apply
1281
1319
  // in-process; the model sees the outcome directly, never a review.
@@ -1720,11 +1758,12 @@ class Engine {
1720
1758
  return { status: 400, error: "KILL target must be a URL path with a scheme" };
1721
1759
  if (schemeName === "log")
1722
1760
  return { status: 405, error: "log:/// is append-only; KILL must bounce" };
1723
- if (schemeName === "exec") {
1724
- const execHandler = this.#schemes.get("exec");
1725
- if (execHandler === undefined || typeof execHandler.kill !== "function")
1726
- return { status: 501 };
1727
- return await execHandler.kill(pathnameFromPath(path), ctx);
1761
+ // Process-KILL: any scheme whose handler exposes kill() aborts a live stream — the
1762
+ // exec handler, registered as "exec" + under every runtime tag (sh/node), so a tag-
1763
+ // addressed stream (sh:///l/t/s) routes here, not to deleteEntry. §exec
1764
+ const killable = this.#schemes.get(schemeName);
1765
+ if (killable !== undefined && typeof killable.kill === "function") {
1766
+ return await killable.kill(pathnameFromPath(path), statement.signal, ctx);
1728
1767
  }
1729
1768
  if (schemeName === "run") {
1730
1769
  // terminate — abort any run by address; whoever holds it may end it.
@@ -1749,12 +1788,9 @@ class Engine {
1749
1788
  const delResult = await handler.deleteEntry(pathnameFromPath(path), ctx);
1750
1789
  return { status: delResult.status };
1751
1790
  }
1752
- // PLAN — the model's in-band reasoning op (plurnk-grammar 0.30.0, the 11th op).
1753
- // Production HOISTS PLAN bodies into the turn's reasoning field in #parseAssistant
1754
- // (#plan-reasoning) before dispatch runs, so PLAN normally never reaches here. This
1755
- // handler is the op's direct-dispatch semantics (exercised by the dispatch unit
1756
- // test): a pure no-op (PLAN ∉ MUTATING_OPS) whose body would serialize into the log
1757
- // row's tx, no runtime effect.
1791
+ // PLAN — the model's reasoning op (the 11th op). An ordinary op: dispatched like any
1792
+ // other, logged, and broadcast to the client as a log entry — but a pure no-op for
1793
+ // state (PLAN ∉ MUTATING_OPS); its body serializes into the log row's tx, no effect.
1758
1794
  #handlePlan(statement) {
1759
1795
  if (statement.op !== "PLAN")
1760
1796
  throw new Error("unreachable");
@@ -1844,8 +1880,9 @@ class Engine {
1844
1880
  const status = statement.signal;
1845
1881
  if (status === null)
1846
1882
  return { status: 400 };
1847
- if (status === 200 || status === 499) {
1848
- // the loop's terminal message its deliverable rides the termination delta.
1883
+ if (status === 200 || status === 202 || status === 499) {
1884
+ // The broadcast terminals (200 done, 202 parked-async, 499 cancelled) advance
1885
+ // the loop; each carries its body as the loop's terminal message — the deliverable.
1849
1886
  const body = statement.body;
1850
1887
  const message = body === null ? null : typeof body === "string" ? body : body.raw;
1851
1888
  await this.#db.engine_loop_set_status.run({ status, loop_id: loopId, message });
@@ -1882,30 +1919,36 @@ class Engine {
1882
1919
  return path.scheme === "https" ? "http" : path.scheme;
1883
1920
  return "file"; // local (bare) → file
1884
1921
  }
1922
+ // A status-202 result is a reviewable PROPOSAL (a side-effecting op — EDIT/EXEC/
1923
+ // directed write — paused for client resolution) UNLESS it is a broadcast SEND.
1924
+ // A broadcast SEND[202] is the model PARKING the loop (a terminal disposition,
1925
+ // plurnk.md), never a side-effect — #255: gating the propose/await path on the
1926
+ // bare 202 surfaced model speech as a loop/proposal and froze clients. The 202
1927
+ // is overloaded (proposal-pause vs parked-terminal); the op disambiguates it.
1928
+ static #isProposal(statement, result) {
1929
+ return result.status === 202 && !(statement.op === "SEND" && statement.target === null);
1930
+ }
1885
1931
  async #writeLog({ statement, result, runId, loopId, turnId, sequence, origin, }) {
1886
1932
  const target = this.#extractTarget(statement.target);
1887
1933
  const lineMarkerJson = "lineMarker" in statement && statement.lineMarker !== null
1888
1934
  ? JSON.stringify(statement.lineMarker)
1889
1935
  : null;
1890
- // Status 202 from a scheme means the action is proposed written to
1891
- // the log in state='proposed' until the proposal lifecycle resolves
1892
- // it. attrs holds the scheme-supplied payload (file diff, exec
1893
- // command, etc.) that the client renders for review and the scheme
1894
- // consumes on accept. All other statuses are terminal state =
1895
- // 'resolved' for the common case.
1896
- const isProposed = result.status === 202;
1936
+ // A proposal (status 202 from a side-effecting op) is written to the log in
1937
+ // state='proposed' until the proposal lifecycle resolves it; attrs holds the
1938
+ // scheme-supplied payload (file diff, exec command, etc.) the client renders
1939
+ // for review and the scheme consumes on accept. A broadcast SEND[202] is a
1940
+ // parked-terminal, NOT a proposal (#isProposal / #255) state='resolved'.
1941
+ const isProposed = _a.#isProposal(statement, result);
1897
1942
  let attrsObj = (result.attrs !== undefined && result.attrs !== null)
1898
1943
  ? { ...result.attrs }
1899
1944
  : {};
1900
- // EXEC pathname is executor-domain + coordinate: the stream entry
1901
- // lives at exec:///<runtime>/<loop_seq>/<turn_seq>/<sequence> (e.g.
1902
- // exec:///sh/1/1/2). The runtime leads domain-aware, the executor
1903
- // as authority and the coordinate that follows is already unique
1904
- // per statement, so no slug is injected. The log row's target points
1905
- // at this same address; its log:/// coordinate shares the trailing
1906
- // <loop>/<turn>/<seq>, so the model correlates op to stream output.
1907
- // Runtime comes from statement.signal (EXEC's runtime slot) so it's
1908
- // resolvable for failed execs too; empty/absent = the default shell.
1945
+ // EXEC stream entry addresses by RUNTIME TAG as authority (§exec): it lives at
1946
+ // <runtime>:///<loop_seq>/<turn_seq>/<sequence> (e.g. sh:///1/1/2) — the runtime tag
1947
+ // is the scheme, the coordinate already unique per statement. The log row's target
1948
+ // points at this same address; its log:/// coordinate shares the trailing
1949
+ // <loop>/<turn>/<seq>, so the model correlates op to stream output. Runtime comes
1950
+ // from statement.signal (EXEC's runtime slot) so it's resolvable for failed execs
1951
+ // too; empty/absent = the default shell.
1909
1952
  if (statement.op === "EXEC") {
1910
1953
  const seqs = await this.#db.engine_loop_turn_seqs.get({
1911
1954
  loop_id: loopId, turn_id: turnId,
@@ -1913,8 +1956,8 @@ class Engine {
1913
1956
  if (seqs === undefined)
1914
1957
  throw new Error(`Engine.#writeLog: loop_turn_seqs returned no row for loop=${loopId} turn=${turnId}`);
1915
1958
  const runtime = (typeof statement.signal === "string" && statement.signal.length > 0) ? statement.signal : "sh";
1916
- const coordPathname = `/${runtime}/${seqs.loop_seq}/${seqs.turn_seq}/${sequence}`;
1917
- target.scheme = "exec";
1959
+ const coordPathname = `/${seqs.loop_seq}/${seqs.turn_seq}/${sequence}`;
1960
+ target.scheme = runtime;
1918
1961
  target.pathname = coordPathname;
1919
1962
  attrsObj.pathname = coordPathname;
1920
1963
  // Mutate the in-memory result.attrs too: the dispatch path