@plurnk/plurnk-service 1.0.4 → 1.0.6

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 (215) hide show
  1. package/.env.defaults +55 -31
  2. package/INSTALL.md +3 -3
  3. package/README.md +4 -4
  4. package/SPEC.md +205 -193
  5. package/dist/Paths.js +1 -1
  6. package/dist/Paths.js.map +1 -1
  7. package/dist/core/ChannelWrite.d.ts +18 -18
  8. package/dist/core/ChannelWrite.d.ts.map +1 -1
  9. package/dist/core/ChannelWrite.js +17 -17
  10. package/dist/core/ChannelWrite.js.map +1 -1
  11. package/dist/core/ChannelWrite.sql +11 -11
  12. package/dist/core/Dispatcher.d.ts +11 -16
  13. package/dist/core/Dispatcher.d.ts.map +1 -1
  14. package/dist/core/Dispatcher.js +122 -125
  15. package/dist/core/Dispatcher.js.map +1 -1
  16. package/dist/core/Engine.d.ts +20 -23
  17. package/dist/core/Engine.d.ts.map +1 -1
  18. package/dist/core/Engine.js +213 -210
  19. package/dist/core/Engine.js.map +1 -1
  20. package/dist/core/Engine.sql +108 -130
  21. package/dist/core/PacketBuilder.d.ts +10 -11
  22. package/dist/core/PacketBuilder.d.ts.map +1 -1
  23. package/dist/core/PacketBuilder.js +204 -128
  24. package/dist/core/PacketBuilder.js.map +1 -1
  25. package/dist/core/ProposalLifecycle.d.ts +8 -8
  26. package/dist/core/ProposalLifecycle.d.ts.map +1 -1
  27. package/dist/core/ProposalLifecycle.js +10 -10
  28. package/dist/core/ProposalLifecycle.js.map +1 -1
  29. package/dist/core/ProviderInstantiate.d.ts +1 -0
  30. package/dist/core/ProviderInstantiate.d.ts.map +1 -1
  31. package/dist/core/ProviderInstantiate.js +9 -3
  32. package/dist/core/ProviderInstantiate.js.map +1 -1
  33. package/dist/core/SchemeRegistry.d.ts +4 -1
  34. package/dist/core/SchemeRegistry.d.ts.map +1 -1
  35. package/dist/core/SchemeRegistry.js +18 -9
  36. package/dist/core/SchemeRegistry.js.map +1 -1
  37. package/dist/core/TelemetryChannel.d.ts +5 -5
  38. package/dist/core/TelemetryChannel.d.ts.map +1 -1
  39. package/dist/core/TelemetryChannel.js +9 -9
  40. package/dist/core/TelemetryChannel.js.map +1 -1
  41. package/dist/core/caps/CapsResolve.js +2 -2
  42. package/dist/core/caps/CapsResolve.js.map +1 -1
  43. package/dist/core/caps/DbNotifyCaps.js +2 -2
  44. package/dist/core/caps/DbNotifyCaps.js.map +1 -1
  45. package/dist/core/caps/DbSubscriptionCaps.js +7 -7
  46. package/dist/core/caps/DbSubscriptionCaps.js.map +1 -1
  47. package/dist/core/caps/SchemeCtxImpl.d.ts +2 -2
  48. package/dist/core/caps/SchemeCtxImpl.d.ts.map +1 -1
  49. package/dist/core/caps/SchemeCtxImpl.js +4 -4
  50. package/dist/core/caps/SchemeCtxImpl.js.map +1 -1
  51. package/dist/core/content-hash.d.ts +2 -0
  52. package/dist/core/content-hash.d.ts.map +1 -0
  53. package/dist/core/content-hash.js +6 -0
  54. package/dist/core/content-hash.js.map +1 -0
  55. package/dist/core/fork.d.ts +1 -1
  56. package/dist/core/fork.d.ts.map +1 -1
  57. package/dist/core/fork.js +31 -26
  58. package/dist/core/fork.js.map +1 -1
  59. package/dist/core/fork.sql +41 -32
  60. package/dist/core/git-env.d.ts.map +1 -1
  61. package/dist/core/git-env.js +23 -7
  62. package/dist/core/git-env.js.map +1 -1
  63. package/dist/core/git-iso.d.ts +9 -0
  64. package/dist/core/git-iso.d.ts.map +1 -0
  65. package/dist/core/git-iso.js +227 -0
  66. package/dist/core/git-iso.js.map +1 -0
  67. package/dist/core/git-membership.d.ts +2 -2
  68. package/dist/core/git-membership.d.ts.map +1 -1
  69. package/dist/core/git-membership.js +70 -50
  70. package/dist/core/git-membership.js.map +1 -1
  71. package/dist/core/git-state.d.ts +1 -1
  72. package/dist/core/git-state.d.ts.map +1 -1
  73. package/dist/core/git-state.js +21 -11
  74. package/dist/core/git-state.js.map +1 -1
  75. package/dist/core/packet-wire.d.ts.map +1 -1
  76. package/dist/core/packet-wire.js +81 -22
  77. package/dist/core/packet-wire.js.map +1 -1
  78. package/dist/core/plurnk-uri.d.ts +2 -2
  79. package/dist/core/plurnk-uri.d.ts.map +1 -1
  80. package/dist/core/plurnk-uri.js +19 -12
  81. package/dist/core/plurnk-uri.js.map +1 -1
  82. package/dist/core/run-ops.sql +26 -26
  83. package/dist/core/scheme-types.d.ts +6 -11
  84. package/dist/core/scheme-types.d.ts.map +1 -1
  85. package/dist/core/search-gate.js +0 -0
  86. package/dist/core/search-gate.js.map +1 -1
  87. package/dist/core/token-ruler.d.ts +2 -0
  88. package/dist/core/token-ruler.d.ts.map +1 -0
  89. package/dist/core/token-ruler.js +18 -0
  90. package/dist/core/token-ruler.js.map +1 -0
  91. package/dist/core/worker-cap.d.ts +8 -0
  92. package/dist/core/worker-cap.d.ts.map +1 -0
  93. package/dist/core/worker-cap.js +20 -0
  94. package/dist/core/worker-cap.js.map +1 -0
  95. package/dist/core/{session-settings.d.ts → workspace-settings.d.ts} +5 -5
  96. package/dist/core/workspace-settings.d.ts.map +1 -0
  97. package/dist/core/{session-settings.js → workspace-settings.js} +13 -13
  98. package/dist/core/workspace-settings.js.map +1 -0
  99. package/dist/digest/Digest.d.ts +3 -3
  100. package/dist/digest/Digest.d.ts.map +1 -1
  101. package/dist/digest/Digest.js +79 -73
  102. package/dist/digest/Digest.js.map +1 -1
  103. package/dist/digest/digest.sql +18 -18
  104. package/dist/schemes/Exec.d.ts +9 -2
  105. package/dist/schemes/Exec.d.ts.map +1 -1
  106. package/dist/schemes/Exec.js +121 -68
  107. package/dist/schemes/Exec.js.map +1 -1
  108. package/dist/schemes/ExecOutputScheme.js +3 -3
  109. package/dist/schemes/ExecOutputScheme.js.map +1 -1
  110. package/dist/schemes/File.js +23 -23
  111. package/dist/schemes/File.js.map +1 -1
  112. package/dist/schemes/Known.js +5 -5
  113. package/dist/schemes/Known.js.map +1 -1
  114. package/dist/schemes/Log.d.ts.map +1 -1
  115. package/dist/schemes/Log.js +63 -14
  116. package/dist/schemes/Log.js.map +1 -1
  117. package/dist/schemes/Log.sql +49 -8
  118. package/dist/schemes/Plurnk.js +5 -5
  119. package/dist/schemes/Plurnk.js.map +1 -1
  120. package/dist/schemes/Skill.js +5 -5
  121. package/dist/schemes/Skill.js.map +1 -1
  122. package/dist/schemes/Unknown.js +5 -5
  123. package/dist/schemes/Unknown.js.map +1 -1
  124. package/dist/schemes/{Run.d.ts → Worker.d.ts} +2 -2
  125. package/dist/schemes/Worker.d.ts.map +1 -0
  126. package/dist/schemes/{Run.js → Worker.js} +67 -67
  127. package/dist/schemes/Worker.js.map +1 -0
  128. package/dist/schemes/_entry-crud.js +11 -11
  129. package/dist/schemes/_entry-crud.js.map +1 -1
  130. package/dist/schemes/_entry-crud.sql +29 -29
  131. package/dist/schemes/_entry-find.d.ts +2 -1
  132. package/dist/schemes/_entry-find.d.ts.map +1 -1
  133. package/dist/schemes/_entry-find.js +31 -19
  134. package/dist/schemes/_entry-find.js.map +1 -1
  135. package/dist/schemes/_entry-find.sql +9 -9
  136. package/dist/schemes/_entry-graph.d.ts +2 -2
  137. package/dist/schemes/_entry-graph.d.ts.map +1 -1
  138. package/dist/schemes/_entry-graph.js +18 -18
  139. package/dist/schemes/_entry-graph.js.map +1 -1
  140. package/dist/schemes/_entry-graph.sql +8 -8
  141. package/dist/schemes/_entry-manifest.d.ts +1 -1
  142. package/dist/schemes/_entry-manifest.d.ts.map +1 -1
  143. package/dist/schemes/_entry-manifest.js +74 -50
  144. package/dist/schemes/_entry-manifest.js.map +1 -1
  145. package/dist/schemes/_entry-ops.d.ts +4 -4
  146. package/dist/schemes/_entry-ops.d.ts.map +1 -1
  147. package/dist/schemes/_entry-ops.js +25 -25
  148. package/dist/schemes/_entry-ops.js.map +1 -1
  149. package/dist/schemes/_entry-ops.sql +9 -9
  150. package/dist/schemes/_entry-semantic.d.ts +2 -1
  151. package/dist/schemes/_entry-semantic.d.ts.map +1 -1
  152. package/dist/schemes/_entry-semantic.js +23 -4
  153. package/dist/schemes/_entry-semantic.js.map +1 -1
  154. package/dist/schemes/_entry-semantic.sql +18 -4
  155. package/dist/schemes/_entry-send.d.ts +1 -1
  156. package/dist/schemes/_entry-send.d.ts.map +1 -1
  157. package/dist/schemes/_entry-send.js +6 -6
  158. package/dist/schemes/_entry-send.js.map +1 -1
  159. package/dist/schemes/exec-abort.js +1 -1
  160. package/dist/schemes/exec-abort.js.map +1 -1
  161. package/dist/server/Daemon.d.ts +50 -48
  162. package/dist/server/Daemon.d.ts.map +1 -1
  163. package/dist/server/Daemon.js +307 -297
  164. package/dist/server/Daemon.js.map +1 -1
  165. package/dist/server/client-input.js +20 -20
  166. package/dist/server/client-input.js.map +1 -1
  167. package/dist/server/dispatch-as-plurnk.d.ts +1 -1
  168. package/dist/server/dispatch-as-plurnk.d.ts.map +1 -1
  169. package/dist/server/dispatch-as-plurnk.js +7 -7
  170. package/dist/server/dispatch-as-plurnk.js.map +1 -1
  171. package/dist/server/drain.sql +27 -27
  172. package/dist/server/envelope.d.ts +20 -20
  173. package/dist/server/envelope.d.ts.map +1 -1
  174. package/dist/server/envelope.js +86 -86
  175. package/dist/server/envelope.js.map +1 -1
  176. package/dist/server/envelope.sql +39 -39
  177. package/dist/server/logEntry.d.ts +1 -1
  178. package/dist/server/logEntry.d.ts.map +1 -1
  179. package/dist/server/logEntry.js +1 -1
  180. package/dist/server/logEntry.js.map +1 -1
  181. package/dist/server/loop-model.d.ts +3 -0
  182. package/dist/server/loop-model.d.ts.map +1 -0
  183. package/dist/server/loop-model.js +26 -0
  184. package/dist/server/loop-model.js.map +1 -0
  185. package/dist/server/loopDocs.d.ts +1 -1
  186. package/dist/server/loopDocs.d.ts.map +1 -1
  187. package/dist/server/loopDocs.js +6 -6
  188. package/dist/server/loopDocs.js.map +1 -1
  189. package/dist/server/seam-entry-read.sql +2 -2
  190. package/dist/server/seam-log-read.sql +3 -3
  191. package/dist/server/seam-loop.sql +3 -3
  192. package/dist/server/seam-proposal-list.sql +4 -4
  193. package/dist/server/yolo.js +2 -2
  194. package/dist/server/yolo.js.map +1 -1
  195. package/dist/service.d.ts.map +1 -1
  196. package/dist/service.js +21 -6
  197. package/dist/service.js.map +1 -1
  198. package/migrations/0000-00-00.01_schema.sql +84 -85
  199. package/package.json +17 -14
  200. package/dist/core/TokenGauge.d.ts +0 -16
  201. package/dist/core/TokenGauge.d.ts.map +0 -1
  202. package/dist/core/TokenGauge.js +0 -58
  203. package/dist/core/TokenGauge.js.map +0 -1
  204. package/dist/core/run-cap.d.ts +0 -8
  205. package/dist/core/run-cap.d.ts.map +0 -1
  206. package/dist/core/run-cap.js +0 -20
  207. package/dist/core/run-cap.js.map +0 -1
  208. package/dist/core/search-prefetch.d.ts +0 -17
  209. package/dist/core/search-prefetch.d.ts.map +0 -1
  210. package/dist/core/search-prefetch.js +0 -164
  211. package/dist/core/search-prefetch.js.map +0 -1
  212. package/dist/core/session-settings.d.ts.map +0 -1
  213. package/dist/core/session-settings.js.map +0 -1
  214. package/dist/schemes/Run.d.ts.map +0 -1
  215. package/dist/schemes/Run.js.map +0 -1
@@ -1,8 +1,9 @@
1
1
  var _a;
2
2
  import { renderAddress, promptLoopPrefix } from "./plurnk-uri.js";
3
- import { teachingLine, docsExcludeSet } from "./teaching.js";
3
+ import { rulerCount } from "./token-ruler.js";
4
+ import { docsExcludeSet } from "./teaching.js";
4
5
  import { Policy } from "@plurnk/plurnk-execs";
5
- import SessionSettings from "./session-settings.js";
6
+ import WorkspaceSettings from "./workspace-settings.js";
6
7
  import { DEFAULT_LOOP_FLAGS } from "./scheme-types.js";
7
8
  import { readPacketInject, readSystemPolicy, readProjectPolicy } from "./packet-inject.js";
8
9
  import { readFile } from "node:fs/promises";
@@ -19,6 +20,7 @@ import ProviderInstantiate from "./ProviderInstantiate.js";
19
20
  const TOKENS_FREE_PLACEHOLDER = "{{tokensFree}}";
20
21
  const TOKEN_USAGE_PLACEHOLDER = "{{tokenUsage}}";
21
22
  const TOKEN_PERCENT_PLACEHOLDER = "{{tokenPercent}}";
23
+ const SYSTEM_CTX_PLACEHOLDER = "{{systemCtx}}"; // #440 — treemap's non-turn overhead = total − Σturns, known only post-assembly
22
24
  // §tokenomics-window-partition — the four partition numbers. REQUIRED (fail-hard, the
23
25
  // providers-env convention): the ceiling is DERIVED from these, never set directly
24
26
  // (PLURNK_BUDGET_CEILING is retired — a settable ceiling let policy contradict physics).
@@ -58,14 +60,21 @@ class PacketBuilder {
58
60
  // scopeEnvToAlias resolves PLURNK_SERVICE_*_<alias> over the bare fallback with providers' own
59
61
  // battle-tested suffix parser. Cached per alias; the boot-global case falls back to the active
60
62
  // alias when a provider carries no side-table entry (a test Mock).
61
- static #KNOBS = ["PLURNK_SERVICE_CTX", "PLURNK_SERVICE_REASONING", "PLURNK_SERVICE_ASSISTANT", "PLURNK_SERVICE_SAFETY"];
63
+ static #KNOBS = ["PLURNK_SERVICE_CONTEXT_WINDOW", "PLURNK_SERVICE_REASONING", "PLURNK_SERVICE_COMPLETION", "PLURNK_SERVICE_SAFETY"];
62
64
  #partitions = new Map();
63
65
  #resolvePartition(alias) {
66
+ // #472 hard shed (OpenAI lexicon ruling) — the retired llama.cpp-speak names fail LOUD at the
67
+ // read boundary; a stale operator .env must never silently lose its ceiling to a rename.
68
+ for (const k of Object.keys(process.env)) {
69
+ const m = /^PLURNK_SERVICE_(CTX|ASSISTANT)(_.*)?$/.exec(k);
70
+ if (m !== null)
71
+ throw new Error(`${k} is retired (#472): the knob is PLURNK_SERVICE_${m[1] === "CTX" ? "CONTEXT_WINDOW" : "COMPLETION"}${m[2] ?? ""}.`);
72
+ }
64
73
  const view = scopeEnvToAlias(process.env, alias, _a.#KNOBS);
65
74
  return {
66
- ctx: readPartitionIntFrom(view, "PLURNK_SERVICE_CTX", 1),
75
+ contextWindow: readPartitionIntFrom(view, "PLURNK_SERVICE_CONTEXT_WINDOW", 1),
67
76
  reasoning: readPartitionIntFrom(view, "PLURNK_SERVICE_REASONING", 0),
68
- assistant: readPartitionIntFrom(view, "PLURNK_SERVICE_ASSISTANT", 0),
77
+ completion: readPartitionIntFrom(view, "PLURNK_SERVICE_COMPLETION", 0),
69
78
  safety: readPartitionIntFrom(view, "PLURNK_SERVICE_SAFETY", 0),
70
79
  };
71
80
  }
@@ -88,20 +97,20 @@ class PacketBuilder {
88
97
  }
89
98
  return false;
90
99
  }
100
+ // #421 — §tokenomics-window-unpollable-deliberate: an unpollable window (provider.contextWindow null
101
+ // after env/probe/catalog all miss) with NO per-alias knob is genuinely-unknown — nobody chose an
102
+ // envelope. The budget/ceiling short-circuit that window to NO-CAP (#isUnboundedWindow) rather than
103
+ // substitute a stand-in the operator never chose; the bare partition still feeds maxTokensFor's
104
+ // generation reserves, which the backend clamps. A per-alias CONTEXT_WINDOW knob makes the window DELIBERATE
105
+ // and it flows bounded, normally.
106
+ #isUnboundedWindow(provider) {
107
+ if (provider.contextWindow !== null)
108
+ return false;
109
+ const alias = ProviderInstantiate.aliasOf(provider) ?? resolveActiveAlias(process.env)?.alias ?? "";
110
+ return !_a.#hasAliasKnob(alias);
111
+ }
91
112
  #partitionFor(provider) {
92
113
  const alias = ProviderInstantiate.aliasOf(provider) ?? resolveActiveAlias(process.env)?.alias ?? "";
93
- // #377 — §tokenomics-window-unpollable-deliberate: "CTX stands in for unknown physics" is
94
- // only honest when the CTX is the operator's DELIBERATE policy for THIS alias. An unpollable
95
- // window (provider.contextSize null — the provider's machine signal after env/probe/catalog
96
- // all miss) riding BARE partition numbers is nobody's policy: numbers tuned for some other
97
- // model silently applied, surfacing later as truncation/overflow telemetry (#352's failure
98
- // mode recurring for every unpollable model). Fail loud at first use, naming both remedies.
99
- if (provider.contextSize === null && !_a.#hasAliasKnob(alias)) {
100
- // BEFORE the cache lookup — the constructor pre-primes the boot alias's partition, and a
101
- // cache hit must never bypass this refusal (the check keys on the PROVIDER's null window,
102
- // which the cached partition knows nothing about).
103
- throw new Error(`window unpollable for alias '${alias || "<active>"}' and its partition rides bare numbers — nobody chose this envelope for this model. Either set PLURNK_PROVIDERS_CONTEXT_SIZE_${alias || "<alias>"} (so the window is known) or set the per-alias partition (PLURNK_SERVICE_{CTX,REASONING,ASSISTANT,SAFETY}_${alias || "<alias>"}) so the numbers are deliberate.`);
104
- }
105
114
  const hit = this.#partitions.get(alias);
106
115
  if (hit !== undefined)
107
116
  return hit;
@@ -109,15 +118,15 @@ class PacketBuilder {
109
118
  this.#partitions.set(alias, part);
110
119
  return part;
111
120
  }
112
- // The generation envelope — REASONING + ASSISTANT, one undifferentiated pool, passed on
121
+ // The generation envelope — REASONING + COMPLETION, one undifferentiated pool, passed on
113
122
  // every generate({maxTokens}): no decode is unbounded (§tokenomics-window-partition). Per
114
123
  // alias (#352): gemma's measured envelope; a cloud alias's generous default the backend clamps.
115
- decodeBudget(provider) {
116
- const { reasoning, assistant } = this.#partitionFor(provider);
117
- return reasoning + assistant;
124
+ maxTokensFor(provider) {
125
+ const { reasoning, completion } = this.#partitionFor(provider);
126
+ return reasoning + completion;
118
127
  }
119
- // §tokenomics-window-partition ÷ §tokenomics-ceiling-calibrates-to-usage — the prompt ceiling
120
- // is DERIVED, never set: effectiveWindow = min(CTX, provider window; CTX alone when the
128
+ // §tokenomics-window-partition — the prompt ceiling
129
+ // is DERIVED, never set: effectiveWindow = min(CONTEXT_WINDOW, provider window; CONTEXT_WINDOW alone when the
121
130
  // provider reports none) minus the reserves, divided by the loop's observed real/measured
122
131
  // token ratio (usage.prompt is ground truth; a heuristic ruler shipped a 65k-real packet into
123
132
  // a 49k window, #311). A fractional ceiling also budgeted the prompt against the window and
@@ -128,31 +137,34 @@ class PacketBuilder {
128
137
  // space (usage.prompt, the numerator, is real; the calibration ratio maps measured→real and
129
138
  // has no business here). The raw n_ctx overstates usable room by the reserve total.
130
139
  promptBudgetFor(provider) {
131
- const { ctx, reasoning, assistant, safety } = this.#partitionFor(provider);
132
- const effectiveWindow = provider.contextSize === null ? ctx : Math.min(ctx, provider.contextSize);
133
- return Math.max(0, effectiveWindow - reasoning - assistant - safety);
140
+ if (this.#isUnboundedWindow(provider))
141
+ return null; // #421 — no cap: an unknown window has no denominator
142
+ const { contextWindow, reasoning, completion, safety } = this.#partitionFor(provider);
143
+ const effectiveWindow = provider.contextWindow === null ? contextWindow : Math.min(contextWindow, provider.contextWindow);
144
+ return Math.max(0, effectiveWindow - reasoning - completion - safety);
134
145
  }
135
- // tokenRatio is real/measured, calibrated by Engine per loop (§tokenomics-ceiling-calibrates-to-usage).
136
- // BELOW 1 is legitimate: a certified upper-bound ruler (no exact tokenizer)
137
- // overmeasures, and the ceiling expands to observed truth — Engine applies the floor for
138
- // exact rulers; this method trusts its input (run24: the floor-at-1 halved gbuild's window).
139
- ceilingFor(provider, tokenRatio = 1) {
140
- if (tokenRatio <= 0)
141
- throw new Error(`ceilingFor: tokenRatio must be > 0, got ${tokenRatio}`);
142
- const { ctx, reasoning, assistant, safety } = this.#partitionFor(provider);
143
- const effectiveWindow = provider.contextSize === null ? ctx : Math.min(ctx, provider.contextSize);
144
- const promptBudget = effectiveWindow - reasoning - assistant - safety;
146
+ // §tokenomics-agnostic-ruler — the ceiling is the real window partition (window − reserves),
147
+ // NO calibration ratio: the model-facing measure is the chars/2 ruler (an over-count for
148
+ // typical text), so comparing ruler-weight to the real-token ceiling is itself the conservative
149
+ // bias — the model curates against less room than it has and never overflows for typical
150
+ // content; the exact provider count guards the pathological tail at the materialization gate.
151
+ ceilingFor(provider) {
152
+ if (this.#isUnboundedWindow(provider))
153
+ return null; // #421 — no cap: the gauge headline is omitted
154
+ const { contextWindow, reasoning, completion, safety } = this.#partitionFor(provider);
155
+ const effectiveWindow = provider.contextWindow === null ? contextWindow : Math.min(contextWindow, provider.contextWindow);
156
+ const promptBudget = effectiveWindow - reasoning - completion - safety;
145
157
  if (promptBudget <= 0) {
146
158
  const alias = ProviderInstantiate.aliasOf(provider) ?? resolveActiveAlias(process.env)?.alias ?? "";
147
- throw new Error(`window partition contradiction for alias '${alias}': effective window ${effectiveWindow} <= reserves ${reasoning}+${assistant}+${safety}. A local (llama-server) alias needs its OWN measured envelope — set PLURNK_SERVICE_{CTX,REASONING,ASSISTANT,SAFETY}_${alias || "<alias>"} (the bare defaults are cloud-generous; #352).`);
159
+ throw new Error(`window partition contradiction for alias '${alias}': effective window ${effectiveWindow} <= reserves ${reasoning}+${completion}+${safety}. A local (llama-server) alias needs its OWN measured envelope — set PLURNK_SERVICE_{CONTEXT_WINDOW,REASONING,COMPLETION,SAFETY}_${alias || "<alias>"} (the bare defaults are cloud-generous; #352).`);
148
160
  }
149
- return Math.floor(promptBudget / tokenRatio);
161
+ return promptBudget;
150
162
  }
151
163
  // Assemble the request half of the spec'd packet (Packet.json system
152
164
  // and §user) BEFORE the provider call. The same packet object is then
153
165
  // completed with assistant + assistantRaw after the model responds, so
154
166
  // the stored packet and the wire payload share one source of truth.
155
- async buildRequestPacket({ initialMessages, requirements, sessionId, runId, loopId, currentTurnSeq, provider, gitStatus, tokenRatio = 1, telemetryErrors: presetTelemetry, }) {
167
+ async buildRequestPacket({ initialMessages, requirements, workspaceId, workerId, loopId, currentTurnSeq, provider, gitStatus, telemetryErrors: presetTelemetry, }) {
156
168
  const byRole = (role) => initialMessages.filter((m) => m.role === role).map((m) => m.content).join("\n\n");
157
169
  // plurnk.md (grammar/dialects) ONLY — the definition is the hot-path grammar.
158
170
  // The scheme catalogue is its own `schemes` section below tools (§schemes-directory),
@@ -165,7 +177,7 @@ class PacketBuilder {
165
177
  // the runLoop caller's messages.user for tests that bypass the
166
178
  // foist mechanism entirely.
167
179
  const loopSeqRow = await this.#db.engine_loop_sequence.get({ loop_id: loopId });
168
- const promptRows = (await this.#db.drain_get_all_prompt_bodies_for_loop.all({ pattern: `${promptLoopPrefix(runId, loopSeqRow?.sequence ?? loopId)}%` }))
180
+ const promptRows = (await this.#db.drain_get_all_prompt_bodies_for_loop.all({ pattern: `${promptLoopPrefix(workerId, loopSeqRow?.sequence ?? loopId)}%` }))
169
181
  .filter((r) => typeof r.content === "string" && r.content.length > 0);
170
182
  // §prompt-auto-read (owner): the section is a PATHS list (the errors shape — no bodies);
171
183
  // each prompt's content reaches the model through its foisted auto-READ in the log, and
@@ -184,14 +196,14 @@ class PacketBuilder {
184
196
  // leads requirements.md, so a third copy here was pure duplication in the model's packet. PLAN
185
197
  // is mandated unconditionally by plurnk.md §Imperatives (grammar 0.70 requires every turn to
186
198
  // lead with PLAN), so the service injects no separate plan directive either.
187
- const log = await this.#buildLog(runId);
199
+ const log = await this.#buildLog(workerId);
188
200
  const telemetryErrors = presetTelemetry ?? await this.buildTelemetryErrors(loopId, currentTurnSeq);
189
- const countTokens = (t) => provider.countTokens(t); // §provider-surface-counttokens
190
- // #367 — the capability sheet must reflect the LOOP MODE, not just session-enablement: an
201
+ const countTokens = rulerCount; // §tokenomics-agnostic-ruler — the ONE model-facing ruler (chars/2), not the provider
202
+ // #367 — the capability sheet must reflect the LOOP MODE, not just workspace-enablement: an
191
203
  // ask-mode loop advertises only what its dispatch gate (resolveForLoop) will accept, so the
192
204
  // model is never taught a tag it'll then be 403'd on (the taught→emitted→rejected→508 spiral).
193
205
  const activeSchemes = this.#schemes.resolveForLoop(await this.#loadLoopFlags(loopId));
194
- const tools = this.#collectTools(await this.#sessionEnabled(sessionId), await SessionSettings.questionsEnabled(this.#db, sessionId), activeSchemes);
206
+ const tools = this.#collectTools(await this.#workspaceEnabled(workspaceId), await WorkspaceSettings.questionsEnabled(this.#db, workspaceId), activeSchemes);
195
207
  // Budget readout (SPEC.md §tokenomics). Two-pass: render the budget from
196
208
  // the structured log's subtotals with a {{tokensFree}} placeholder, build
197
209
  // the section list, measure the assembled total, resolve free, substitute.
@@ -199,8 +211,9 @@ class PacketBuilder {
199
211
  // a serialized approximation. ceiling is the provider's window ×
200
212
  // PLURNK_BUDGET_CEILING (null when no window is reported → headline
201
213
  // omitted, section lines still shown). §tokenomics-render-weight-budget
202
- const ceiling = this.ceilingFor(provider, tokenRatio);
203
- const budgetReadout = this.#renderBudget(PacketWire.measureLogBudget(log, countTokens), ceiling);
214
+ const ceiling = this.ceilingFor(provider);
215
+ const logBudget = PacketWire.measureLogBudget(log, countTokens);
216
+ const budgetReadout = this.#renderBudget(logBudget, ceiling);
204
217
  // The default packet: an ordered list of addressable sections (§packet-assembly).
205
218
  // `slot` is a TRUST boundary (and the prompt-cache boundary): system holds only
206
219
  // framework-authored, non-injectable sections — the static head (definition/tools/
@@ -209,24 +222,24 @@ class PacketBuilder {
209
222
  // the requirements footer. The budget section carries its {{tokensFree}} placeholders
210
223
  // here; they resolve below once the assembled total is known.
211
224
  const inject = await readPacketInject(); // #240 — operator section, per-turn, fail-hard on a broken path
212
- const sessionRoot = (await this.#db.envelope_get_session.get({ id: sessionId }))?.project_root ?? null;
225
+ const workspaceRoot = (await this.#db.envelope_get_workspace.get({ id: workspaceId }))?.project_root ?? null;
213
226
  const systemPolicy = await readSystemPolicy(); // ~/.plurnk/AGENTS.md (or PLURNK_SERVICE_POLICY)
214
- const projectPolicy = await readProjectPolicy(sessionRoot); // <projectRoot>/AGENTS.md (or PLURNK_SERVICE_PROJECT)
227
+ const projectPolicy = await readProjectPolicy(workspaceRoot); // <projectRoot>/AGENTS.md (or PLURNK_SERVICE_PROJECT)
215
228
  // Child-orientation (§child-orientation): the live things THIS run holds — open streams +
216
- // unconcluded child runs — surfaced every turn as terse `* <status> <path>` pointers (same shape
229
+ // unconcluded child workers — surfaced every turn as terse `* <status> <path>` pointers (same shape
217
230
  // as errors) just above the errors section. Orienting STATE so the model never loses track of
218
231
  // what it's holding (the premature-terminate trap), never advice on what to do. Empty → omitted.
219
- const childStreams = (await this.#db.engine_child_streams_open.all({ run_id: runId }))
232
+ const childStreams = (await this.#db.engine_child_streams_open.all({ worker_id: workerId }))
220
233
  .map((s) => ({ status: "active", path: renderAddress(s.scheme, s.pathname) }));
221
- const childRuns = (await this.#db.engine_child_runs_live.all({ run_id: runId }))
222
- .map((r) => ({ status: r.status, path: `run://${r.name}` }));
234
+ const childWorkers = (await this.#db.engine_child_workers_live.all({ worker_id: workerId }))
235
+ .map((r) => ({ status: r.status, path: `worker://${r.name}` }));
223
236
  const defaults = [
224
237
  { name: "definition", slot: "system", header: null, content: system_definition, tokens: 0 },
225
- { name: "tools", slot: "system", header: null, content: tools.join("\n"), tokens: 0 }, // titleless — the examples flow on from plurnk.md (definition) directly above
226
- { name: "schemes", slot: "system", header: "Plurnk Service Schemes", content: this.#schemes.teach(), tokens: 0 },
227
- ...(inject !== null ? [{ name: "inject", slot: "system", header: "Plurnk Operator Notes", content: inject, tokens: 0 }] : []),
238
+ { name: "tools", slot: "system", header: null, content: tools, tokens: 0 }, // titleless — the fenced op catalog flows on from plurnk.md (definition) directly above
239
+ { name: "schemes", slot: "system", header: "Schemes", content: this.#schemes.teach(), tokens: 0 },
240
+ ...(inject !== null ? [{ name: "inject", slot: "system", header: "Operator Notes", content: inject, tokens: 0 }] : []),
228
241
  // 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.
229
- { name: "system-policy", slot: "system", header: "Plurnk Service Policy", content: systemPolicy ?? "", tokens: 0 },
242
+ { name: "system-policy", slot: "system", header: "Policy", content: systemPolicy ?? "", tokens: 0 },
230
243
  { name: "project-policy", slot: "system", header: "Project Policy", content: projectPolicy ?? "", tokens: 0 },
231
244
  // The packet split is a TRUST boundary: system carries only framework-authored, non-injectable
232
245
  // sections; anything that could carry attacker-reachable text (a READ result, exec output, the
@@ -235,19 +248,19 @@ class PacketBuilder {
235
248
  // neither is an injection surface; both sit at the bottom of system, just above budget-the-law.
236
249
  // child-orientation: what THIS run holds live — streams then runs — just above errors. Terse
237
250
  // pointers (the path is the actionable address the model READs/OPENs/KILLs), never advice. §child-orientation
238
- { name: "child-streams", slot: "system", header: "Plurnk Service Child Streams", content: PacketWire.renderChildPointers(childStreams), tokens: 0 },
239
- { name: "child-runs", slot: "system", header: "Plurnk Service Active Child Worker Runs", content: PacketWire.renderChildPointers(childRuns), tokens: 0 },
240
- { name: "errors", slot: "system", header: "Plurnk Service Errors", content: PacketWire.renderErrors(telemetryErrors), tokens: 0 },
241
- { name: "git", slot: "system", header: "Plurnk Service Git Status", content: PacketWire.renderGit(gitStatus), tokens: 0 },
251
+ { name: "child-streams", slot: "system", header: "Child Streams", content: PacketWire.renderChildPointers(childStreams), tokens: 0 },
252
+ { name: "child-workers", slot: "system", header: "Active Child Workers", content: PacketWire.renderChildPointers(childWorkers), tokens: 0 },
253
+ { name: "errors", slot: "system", header: "Errors", content: PacketWire.renderErrors(telemetryErrors), tokens: 0 },
254
+ { name: "git", slot: "system", header: "Git Status", content: PacketWire.renderGit(gitStatus), tokens: 0 },
242
255
  // budget — LAW (a hard ceiling the model must obey).
243
- { name: "budget", slot: "system", header: "Plurnk Service Budget", content: budgetReadout, tokens: 0 },
256
+ { name: "budget", slot: "system", header: "Budget", content: budgetReadout, tokens: 0 },
244
257
  // §prompt-auto-read (owner): the prompts section is the system slot's very bottom —
245
258
  // a paths-only list (the errors shape); bodies arrive via the foisted auto-READ.
246
- { name: "prompt", slot: "system", header: "Plurnk Service User Prompts", content: prompt, tokens: 0 },
259
+ { name: "prompt", slot: "system", header: "User Prompts", content: prompt, tokens: 0 },
247
260
  // 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.
248
- { name: "log", slot: "user", header: "Plurnk Service Log", content: PacketWire.renderLog(log, countTokens), tokens: 0 },
261
+ { name: "log", slot: "user", header: "Log", content: PacketWire.renderLog(log, countTokens), tokens: 0 },
249
262
  // requirements renders LAST — the user-slot footer, the syntax contract closest to the model's turn (a recency carve-out for weak models).
250
- { name: "requirements", slot: "user", header: "Plurnk Service Requirements", content: baseRequirements, tokens: 0 },
263
+ { name: "requirements", slot: "user", header: "Recap", content: baseRequirements, tokens: 0 },
251
264
  ];
252
265
  // Plugin packet control (§packet-assembly): trusted schemes rewrite the
253
266
  // default list — add, remove, reorder — in-process, before measurement.
@@ -257,14 +270,18 @@ class PacketBuilder {
257
270
  let total = countTokens(PacketWire.renderSlot(sections, "system")) + countTokens(PacketWire.renderSlot(sections, "user"));
258
271
  {
259
272
  const budgetSec = sections.find((s) => s.name === "budget"); // a plugin may have removed it
260
- if (budgetSec) {
273
+ // A null ceiling (#421 — unbounded window) has no headline to calibrate: no truncation, no
274
+ // percent/free substitution. #renderBudget already omitted the headline, so nothing to do.
275
+ if (budgetSec && ceiling !== null) {
261
276
  // Curation pressure gates on OCCUPANCY (§tokenomics-pressure-gates-on-occupancy, #308):
262
277
  // the Turns/Heaviest tables are a standing FOLD-target list, and a high-headroom model
263
278
  // reads them as a todo — burning turns on token hygiene at 3% of a 64k window. Under
264
279
  // half-full, the headline's numbers stand alone (truncate at the first blank line) and
265
280
  // the total RE-measures — the substituted figures must reconcile with what ships.
266
281
  // A null ceiling can't calibrate, so the full readout stays.
267
- if ((total / ceiling) * 100 < 50) {
282
+ // A mermaid budget (#440) self-scales to pressure, so it is never truncated — the calm
283
+ // low-usage view IS the point; only the tabular readout collapses under half-full.
284
+ if (!budgetSec.content.includes("```mermaid") && (total / ceiling) * 100 < 50) {
268
285
  const cut = budgetSec.content.indexOf("\n\n");
269
286
  if (cut !== -1) {
270
287
  budgetSec.content = budgetSec.content.slice(0, cut);
@@ -273,12 +290,17 @@ class PacketBuilder {
273
290
  }
274
291
  const tokensFree = Math.max(0, ceiling - total); // free floors at 0 on overshoot — §tokenomics-over-budget-floor
275
292
  const percent = (total / ceiling) * 100; // usage as % of the ceiling — §tokenomics-context-percent
293
+ const sumTurns = logBudget.byTurn.reduce((s, t) => s + t.tokens, 0); // #440 — treemap non-turn box = total − Σturns
294
+ const systemCtx = Math.max(0, total - sumTurns);
295
+ // replaceAll: a mermaid budget recurs free/used across the headline + treemap + pie, so a
296
+ // single .replace would leave the diagrams carrying literal {{…}} placeholders.
276
297
  budgetSec.content = budgetSec.content
277
- .replace(TOKEN_USAGE_PLACEHOLDER, String(total))
298
+ .replaceAll(TOKEN_USAGE_PLACEHOLDER, String(total))
278
299
  // Any nonzero usage under 1% is "<1" — Math.round alone claimed "1%" from 0.51%,
279
300
  // overstating a near-empty window.
280
- .replace(TOKEN_PERCENT_PLACEHOLDER, total > 0 && percent < 1 ? "<1" : String(Math.round(percent)))
281
- .replace(TOKENS_FREE_PLACEHOLDER, String(tokensFree));
301
+ .replaceAll(TOKEN_PERCENT_PLACEHOLDER, total > 0 && percent < 1 ? "<1" : String(Math.round(percent)))
302
+ .replaceAll(TOKENS_FREE_PLACEHOLDER, String(tokensFree))
303
+ .replaceAll(SYSTEM_CTX_PLACEHOLDER, String(systemCtx));
282
304
  }
283
305
  }
284
306
  // Pass 2: per-section render-weight + the assembled packet total (post
@@ -294,7 +316,23 @@ class PacketBuilder {
294
316
  // placeholder here — buildRequestPacket substitutes it after measuring the packet.
295
317
  #renderBudget(log, ceiling) {
296
318
  const lines = [];
297
- lines.push(`Token Ceiling ${ceiling} · Token Usage ${TOKEN_USAGE_PLACEHOLDER} (${TOKEN_PERCENT_PLACEHOLDER}%) · Tokens Free ${TOKENS_FREE_PLACEHOLDER}`);
319
+ // #421 — no ceiling (unbounded window): omit the headline entirely; the section lines below
320
+ // stay so the model keeps its FOLD-target surface, just with no percent it can't compute.
321
+ if (ceiling !== null)
322
+ lines.push(`Token Ceiling ${ceiling} · Token Usage ${TOKEN_USAGE_PLACEHOLDER} (${TOKEN_PERCENT_PLACEHOLDER}%) · Tokens Free ${TOKENS_FREE_PLACEHOLDER}`);
323
+ // #440 {§budget-mermaid} — the enriched visual Budget (default on). With a ceiling to scale
324
+ // against, the treemap REPLACES the Turns table (per-turn composition) + a pie gauge; the
325
+ // heaviest-items list stays a table (#450). Self-scaled to pressure (calm→urgent), never <50%-truncated.
326
+ // Set PLURNK_SERVICE_BUDGET_MERMAID=off to A/B against the tabular baseline (#440's before/after).
327
+ if (process.env.PLURNK_SERVICE_BUDGET_MERMAID !== "off" && ceiling !== null && log.entries > 0) {
328
+ if (lines.length > 0)
329
+ lines.push("");
330
+ lines.push(_a.#renderBudgetMermaid(log, ceiling));
331
+ // #450 — the heaviest items stay a plain ranked list (a ranking isn't a composition, no
332
+ // treemap; two mermaid diagrams are enough visual examples) — the same table as the tabular budget.
333
+ lines.push(..._a.#heaviestItemsLines(log.largest));
334
+ return lines.join("\n");
335
+ }
298
336
  if (log.entries > 0) {
299
337
  if (lines.length > 0)
300
338
  lines.push("");
@@ -306,23 +344,54 @@ class PacketBuilder {
306
344
  for (const t of log.byTurn)
307
345
  lines.push(`| ${t.turn} | ${t.tokens} |`);
308
346
  }
309
- // The heaviest individual log items — the FOLD targets behind the weight
310
- // (§tokenomics {§tokenomics-largest-entries}). "items", not "entries": the readout
311
- // lists log:/// rows (log items), distinct from catalog entries (plurnk.md: "EDIT
312
- // is only for entries. Do not attempt to edit log items.").
313
- if (log.largest.length > 0) {
314
- lines.push("", "Heaviest items (FOLD targets — folding reclaims their tokens):", "| item | tokens |", "|---|--:|");
315
- for (const e of log.largest)
316
- lines.push(`| ${e.path} | ${e.tokens} |`);
317
- }
347
+ lines.push(..._a.#heaviestItemsLines(log.largest));
318
348
  }
319
349
  return lines.join("\n");
320
350
  }
321
- // #328 — the per-session client execs policy narrows what the packet ADVERTISES, matching what
322
- // dispatch refuses: a session-disabled tag is absent from the capability sheet and the doc set,
351
+ // The heaviest individual log items — the FOLD targets behind the weight, a ranked LIST in both the
352
+ // mermaid and tabular budgets (#450: a ranking isn't a composition, so it's never a chart). "items",
353
+ // not "entries": log:/// rows, distinct from catalog entries (plurnk.md: "EDIT is only for entries").
354
+ // {§tokenomics-largest-entries}
355
+ static #heaviestItemsLines(largest) {
356
+ if (largest.length === 0)
357
+ return [];
358
+ return ["", "Heaviest items (FOLD targets — folding reclaims their tokens):", "| item | tokens |", "|---|--:|",
359
+ ...largest.map((e) => `| ${e.path} | ${e.tokens} |`)];
360
+ }
361
+ // #440 {§budget-mermaid} — the Budget as two budget-scaled mermaid diagrams (validated to render on
362
+ // GitHub; syntax: plurnk-plurnkdown/demo/budget-mermaid.md). Both scaled to the CEILING, so salience
363
+ // tracks pressure: `free` dominates at low usage (calm), turn boxes fill as it climbs (urgent).
364
+ // free/used/system+context are placeholders — the post-assembly total resolves them. (#450 cut the xychart.)
365
+ static #renderBudgetMermaid(log, ceiling) {
366
+ // Turn composition → treemap: turn boxes + system+context + free compose the whole ceiling —
367
+ // the per-turn FOLD surface (which turns are heavy, labeled `turn L/T`) the headline can't give.
368
+ const treemap = [
369
+ "```mermaid",
370
+ "treemap-beta",
371
+ `"Budget — ceiling ${ceiling}"`,
372
+ ` "free": ${TOKENS_FREE_PLACEHOLDER}`,
373
+ ` "system + context": ${SYSTEM_CTX_PLACEHOLDER}`,
374
+ ...log.byTurn.map((t) => ` "turn ${t.turn}": ${t.tokens}`),
375
+ "```",
376
+ ].join("\n");
377
+ // Gauge → pie: used vs free, budget-scaled (used + free = ceiling); also a visual exemplar for
378
+ // the model's own user-facing SENDs. (#450 cut the heaviest-items xychart — its bare-coordinate
379
+ // labels a floor model can't decode, and the treemap already surfaces per-turn heaviness.)
380
+ const pie = [
381
+ "```mermaid",
382
+ "pie showData",
383
+ ` title Budget — used vs free (ceiling ${ceiling})`,
384
+ ` "used" : ${TOKEN_USAGE_PLACEHOLDER}`,
385
+ ` "free" : ${TOKENS_FREE_PLACEHOLDER}`,
386
+ "```",
387
+ ].join("\n");
388
+ return [treemap, pie].join("\n\n");
389
+ }
390
+ // #328 — the per-workspace client execs policy narrows what the packet ADVERTISES, matching what
391
+ // dispatch refuses: a workspace-disabled tag is absent from the capability sheet and the doc set,
323
392
  // never taught-then-refused. No policy (execs unset) → everything boot-registered shows.
324
- async #sessionEnabled(sessionId) {
325
- const { execs } = await SessionSettings.read(this.#db, sessionId);
393
+ async #workspaceEnabled(workspaceId) {
394
+ const { execs } = await WorkspaceSettings.read(this.#db, workspaceId);
326
395
  if (execs === null)
327
396
  return () => true;
328
397
  return (tag) => Policy.isEnabled(tag, execs);
@@ -344,58 +413,55 @@ class PacketBuilder {
344
413
  return DEFAULT_LOOP_FLAGS;
345
414
  }
346
415
  }
347
- #collectTools(sessionEnabled, questionsOn = false, activeSchemes) {
348
- const tools = [];
416
+ #collectTools(workspaceEnabled, questionsOn = false, activeSchemes) {
417
+ // §PACKET Tools (#441) — the capability sheet's OP examples ride a `plurnk` fence, matching the
418
+ // Schemes catalog (one packet, one shape for op-example sheets). Prose notices (EXEC-disabled)
419
+ // stay prose beside the fence — a prose line isn't an op for the op-fence gate to validate.
420
+ const ops = [];
421
+ const notices = [];
349
422
  // §send-300-choices — the one-liner rides ONLY where questions are enabled (allowed +
350
423
  // client-requested); the fuller questions.md doc injects through docEntries the same way.
351
424
  if (questionsOn)
352
- tools.push(teachingLine("<<SEND[300]:Deploy where?;staging;production:SEND"));
353
- // Each available runtime tag contributes its self-documenting example —
354
- // the example carries syntax + purpose, so there's no prose line. Tags
355
- // with no example (sh/node, covered by the core prompt) contribute
356
- // nothing; available-only, so the model never sees an unusable tag. `* `
357
- // bullets + bare op forms match the packet's list/op rendering (no `- `,
358
- // no backticks — see packet-wire.ts).
425
+ ops.push("<<SEND[300]:Deploy where?;staging;production:SEND");
359
426
  const executors = this.#executors();
360
427
  if (executors !== undefined) {
361
428
  const excluded = docsExcludeSet();
362
429
  const runtimes = executors.availableRuntimes();
363
- // execs#24 (operator design, via the client) + the #367 filter CORRECTED: the per-tag
364
- // runtime schemes are READ faces (reading sh:// history is legitimate in ask mode), so
365
- // keying the sheet on activeSchemes.has(tag) filtered NOTHING — the untested corner of
366
- // be8a77c. The sheet's lines are EXEC-usage examples, so the key is the 'exec' scheme
367
- // (the op face, excludedInAsk). When it's inactive, say so POSITIVELY: plurnk.md still
368
- // teaches EXEC as language, and silent absence measurably invites confabulated runtimes
369
- // (the client's 5-probe: 500×3). Core speaks the line — only core knows the gate closed.
430
+ // execs#24 / #367: the sheet's lines are EXEC-usage examples, keyed on the 'exec' scheme
431
+ // (the op face, excludedInAsk). When inactive, say so POSITIVELY (a prose notice): plurnk.md
432
+ // still teaches EXEC as language, and silent absence measurably invites confabulated runtimes.
370
433
  const execActive = activeSchemes === undefined || activeSchemes.has("exec");
371
434
  if (runtimes.length > 0 && !execActive) {
372
- tools.push(teachingLine("EXEC operations are disabled for this loop — do not run commands; answer or advise directly"));
373
- return tools;
435
+ notices.push("EXEC operations are disabled for this loop — do not run commands; answer or advise directly");
374
436
  }
375
- for (const tag of runtimes) {
376
- if (excluded.has(tag))
377
- continue; // #240 — PLURNK_SERVICE_DOCS_EXCLUDE drops the oneliner + the doc
378
- if (!sessionEnabled(tag))
379
- continue; // #328 — session-disabled tags aren't advertised
380
- const entry = executors.entry(tag);
381
- // #240 — identical treatment with the scheme directory: the example IS the oneliner,
382
- // the fuller doc (materialized at plurnk://docs/<tag>.md) rides an inline link whose
383
- // token cost lives on that manifest entry. No example → no line (like a provisional scheme).
384
- if (entry?.example)
385
- tools.push(teachingLine(entry.example));
437
+ else {
438
+ for (const tag of runtimes) {
439
+ if (excluded.has(tag))
440
+ continue; // #240 — PLURNK_SERVICE_DOCS_EXCLUDE drops the oneliner + the doc
441
+ if (!workspaceEnabled(tag))
442
+ continue; // #328 — workspace-disabled tags aren't advertised
443
+ const entry = executors.entry(tag);
444
+ // #240 — the example IS the oneliner (a bare op, fenced below); the fuller doc
445
+ // materializes at plurnk://docs/<tag>.md. No example → no line.
446
+ if (entry?.example)
447
+ ops.push(entry.example);
448
+ }
386
449
  }
387
450
  }
388
- return tools;
451
+ const parts = [...notices];
452
+ if (ops.length > 0)
453
+ parts.push(`\`\`\`plurnk\n${ops.join("\n")}\n\`\`\``);
454
+ return parts.join("\n\n");
389
455
  }
390
456
  // #note12 — the daughter-provided reference docs (schemes' + execs' `documentation`),
391
457
  // materialized at plurnk:///docs/<name>.md by loop_run (like operator docs) so the
392
458
  // catalogue's doc-links READ and the manifest carries each doc's token cost.
393
- async docEntries(sessionId) {
459
+ async docEntries(workspaceId) {
394
460
  const out = this.#schemes.docs(); // scheme docs already drop PLURNK_SERVICE_DOCS_EXCLUDE names
395
461
  // §send-300-choices — the conditional teaching: questions.md (from the docs corpus)
396
- // materializes ONLY for enabled sessions — the same conditional-doc mechanism as the EXEC
397
- // plugin docs below. An un-enabled session is never taught the op it can't use.
398
- if (await SessionSettings.questionsEnabled(this.#db, sessionId)) {
462
+ // materializes ONLY for enabled workspaces — the same conditional-doc mechanism as the EXEC
463
+ // plugin docs below. An un-enabled workspace is never taught the op it can't use.
464
+ if (await WorkspaceSettings.questionsEnabled(this.#db, workspaceId)) {
399
465
  try {
400
466
  const q = await readFile(resolvePath(Paths.schemeDocs, "questions.md"), "utf8");
401
467
  if (q.length > 0)
@@ -406,11 +472,11 @@ class PacketBuilder {
406
472
  const executors = this.#executors();
407
473
  if (executors !== undefined) {
408
474
  const excluded = docsExcludeSet();
409
- const sessionEnabled = await this.#sessionEnabled(sessionId); // #328 — no doc for a disabled tag
475
+ const workspaceEnabled = await this.#workspaceEnabled(workspaceId); // #328 — no doc for a disabled tag
410
476
  for (const tag of executors.availableRuntimes()) {
411
477
  if (excluded.has(tag))
412
478
  continue; // #240 — exec docs honor the same exclude
413
- if (!sessionEnabled(tag))
479
+ if (!workspaceEnabled(tag))
414
480
  continue;
415
481
  const doc = executors.entry(tag)?.documentation;
416
482
  if (doc !== undefined && doc.length > 0)
@@ -425,10 +491,12 @@ class PacketBuilder {
425
491
  // strike, rebuild, re-measure. Folds (never deletes). The strike it raises and
426
492
  // the hard-stop it can signal are returned to runLoop, which owns abandonment.
427
493
  // §grinder-overflow-only — fires only on actual overflow, never speculatively
428
- async enforceBudget({ packet, provider, runId, loopId, turnId, mintSequence, tokenRatio = 1, rebuild }) {
429
- const ceiling = this.ceilingFor(provider, tokenRatio);
494
+ async enforceBudget({ packet, provider, workerId, loopId, turnId, mintSequence, rebuild }) {
495
+ const ceiling = this.ceilingFor(provider);
430
496
  const measure = (p) => p.tokens;
431
- if (measure(packet) <= ceiling)
497
+ // #421 — a null ceiling is an unbounded window: always fit, never fold or strike (the backend
498
+ // clamps; this mirrors Engine's physicallySendable, which treats a null contextWindow as sendable).
499
+ if (ceiling === null || measure(packet) <= ceiling)
432
500
  return { packet, fit: true, struck: false };
433
501
  // ONE rule, every turn — turn 1 and turn 101 alike (§grinder-layer1-rollback): fold the
434
502
  // NEWEST turn boundary's still-open rows (the prior turn's emissions + this turn's
@@ -442,15 +510,23 @@ class PacketBuilder {
442
510
  // turn late. The row is grinder-exempt, so it stacks into a visible recurrence trail. It
443
511
  // sits at the turn's reserved running sequence (mintSequence) so it never collides with the
444
512
  // post-generate dispatch rows. §telemetry-uniform-error-channel, §grinder-overflow-error-row
445
- await this.#telemetry.mintEngineError("budget_overflow", { runId, loopId, turnId, sequence: mintSequence });
513
+ await this.#telemetry.mintEngineError("budget_overflow", { workerId, loopId, turnId, sequence: mintSequence });
446
514
  await this.#db.engine_grinder_fold_newest_turn.run({ loop_id: loopId, turn_id: turnId });
447
515
  const current = await rebuild();
448
516
  return { packet: current, fit: measure(current) <= ceiling, struck: true };
449
517
  }
518
+ // §tokenomics-agnostic-ruler — the EXACT materialization measure: the provider's own token
519
+ // count of the assembled packet, the ONE place per-model exactness is used (once per turn, at
520
+ // the fit-gate). The model-facing render-weight (packet.tokens) stays the ruler; this is the
521
+ // physics check that the real bytes fit the real window.
522
+ exactPacketTokens(packet, provider) {
523
+ return provider.countTokens(PacketWire.renderSlot(packet.sections, "system"))
524
+ + provider.countTokens(PacketWire.renderSlot(packet.sections, "user"));
525
+ }
450
526
  // Complete the packet by adding the model's response. After this the
451
527
  // packet matches Packet.json fully and is ready for storage.
452
528
  completePacket(requestPacket, assistant, assistantRaw, provider) {
453
- const assistantTokens = provider.countTokens(assistant.content);
529
+ const assistantTokens = rulerCount(assistant.content); // §tokenomics-agnostic-ruler — render-weight in ruler units (usage_* keep the provider's real count)
454
530
  return {
455
531
  tokens: requestPacket.tokens + assistantTokens,
456
532
  sections: requestPacket.sections,
@@ -484,16 +560,16 @@ class PacketBuilder {
484
560
  // Snapshot is taken at packet build (pre-dispatch this turn), so it
485
561
  // reflects "what has happened before this turn." Each row carries a
486
562
  // log:///<loop_seq>/<turn_seq>/<sequence> coordinate the model can READ.
487
- async #buildLog(runId) {
488
- // SPEC §packet-terms: runs own log entries — log is the run's history,
489
- // not the loop's. Span all loops in the run so the model sees
563
+ async #buildLog(workerId) {
564
+ // SPEC §packet-terms: runs own log entries — log is the worker's history,
565
+ // not the loop's. Span all loops in the worker so the model sees
490
566
  // earlier loops' work as conversational memory.
491
567
  //
492
568
  // User prompts are first-class log entries: runTurn writes a
493
569
  // client-origin SEND[200] row at sequence=0 of each new
494
570
  // turn-1. Prompts thus surface naturally in this query — no
495
571
  // synthetic / shim layer.
496
- const rows = await this.#db.engine_render_log.all({ run_id: runId });
572
+ const rows = await this.#db.engine_render_log.all({ worker_id: workerId });
497
573
  return rows.map((r) => ({
498
574
  coordinate: `${r.loop_seq}/${r.turn_seq}/${r.sequence}`,
499
575
  origin: r.origin,