@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.
- package/.env.example +0 -8
- package/SPEC.md +111 -93
- package/dist/content/matcher.d.ts.map +1 -1
- package/dist/content/matcher.js +62 -89
- package/dist/content/matcher.js.map +1 -1
- package/dist/core/ChannelWrite.d.ts +2 -1
- package/dist/core/ChannelWrite.d.ts.map +1 -1
- package/dist/core/ChannelWrite.js +2 -2
- package/dist/core/ChannelWrite.js.map +1 -1
- package/dist/core/ChannelWrite.sql +2 -2
- package/dist/core/Engine.d.ts +1 -0
- package/dist/core/Engine.d.ts.map +1 -1
- package/dist/core/Engine.js +115 -50
- package/dist/core/Engine.js.map +1 -1
- package/dist/core/Engine.sql +40 -2
- package/dist/core/ProviderInstantiate.d.ts.map +1 -1
- package/dist/core/ProviderInstantiate.js +5 -2
- package/dist/core/ProviderInstantiate.js.map +1 -1
- package/dist/core/fork.d.ts.map +1 -1
- package/dist/core/fork.js +20 -4
- package/dist/core/fork.js.map +1 -1
- package/dist/core/fork.sql +29 -0
- package/dist/core/packet-inject.d.ts +2 -0
- package/dist/core/packet-inject.d.ts.map +1 -1
- package/dist/core/packet-inject.js +28 -1
- package/dist/core/packet-inject.js.map +1 -1
- package/dist/core/packet-wire.d.ts.map +1 -1
- package/dist/core/packet-wire.js +22 -2
- package/dist/core/packet-wire.js.map +1 -1
- package/dist/core/session-settings.d.ts +0 -5
- package/dist/core/session-settings.d.ts.map +1 -1
- package/dist/core/session-settings.js +1 -10
- package/dist/core/session-settings.js.map +1 -1
- package/dist/schemes/Exec.d.ts.map +1 -1
- package/dist/schemes/Exec.js +34 -8
- package/dist/schemes/Exec.js.map +1 -1
- package/dist/schemes/File.d.ts.map +1 -1
- package/dist/schemes/File.js +5 -1
- package/dist/schemes/File.js.map +1 -1
- package/dist/schemes/Log.d.ts +4 -0
- package/dist/schemes/Log.d.ts.map +1 -1
- package/dist/schemes/Log.js +35 -19
- package/dist/schemes/Log.js.map +1 -1
- package/dist/schemes/Log.sql +14 -11
- package/dist/schemes/Run.d.ts +3 -1
- package/dist/schemes/Run.d.ts.map +1 -1
- package/dist/schemes/Run.js +16 -0
- package/dist/schemes/Run.js.map +1 -1
- package/dist/schemes/_entry-find.d.ts.map +1 -1
- package/dist/schemes/_entry-find.js +17 -3
- package/dist/schemes/_entry-find.js.map +1 -1
- package/dist/schemes/_entry-find.sql +23 -0
- package/dist/schemes/_entry-manifest.d.ts +1 -1
- package/dist/schemes/_entry-manifest.d.ts.map +1 -1
- package/dist/schemes/_entry-manifest.js +22 -4
- package/dist/schemes/_entry-manifest.js.map +1 -1
- package/dist/schemes/exec-abort.d.ts +4 -0
- package/dist/schemes/exec-abort.d.ts.map +1 -1
- package/dist/schemes/exec-abort.js +6 -0
- package/dist/schemes/exec-abort.js.map +1 -1
- package/dist/server/Daemon.d.ts.map +1 -1
- package/dist/server/Daemon.js +128 -19
- package/dist/server/Daemon.js.map +1 -1
- package/dist/server/MethodRegistry.d.ts +1 -0
- package/dist/server/MethodRegistry.d.ts.map +1 -1
- package/dist/server/drain.sql +17 -5
- package/dist/server/envelope.d.ts.map +1 -1
- package/dist/server/envelope.js +0 -9
- package/dist/server/envelope.js.map +1 -1
- package/dist/server/methods/log_read.d.ts.map +1 -1
- package/dist/server/methods/log_read.js +7 -1
- package/dist/server/methods/log_read.js.map +1 -1
- package/dist/server/methods/log_read.sql +17 -7
- package/dist/server/methods/session_create.d.ts.map +1 -1
- package/dist/server/methods/session_create.js +1 -8
- package/dist/server/methods/session_create.js.map +1 -1
- package/docs/run.md +3 -1
- package/migrations/0000-00-00.01_schema.sql +11 -1
- package/package.json +9 -9
- package/requirements.md +5 -1
package/SPEC.md
CHANGED
|
@@ -14,14 +14,14 @@ Canonical meanings. When a doc, comment, test name, or commit message uses one o
|
|
|
14
14
|
|
|
15
15
|
| Term | Meaning |
|
|
16
16
|
|---|---|
|
|
17
|
-
| **agent** | The plurnk runtime
|
|
17
|
+
| **agent** | The plurnk runtime. Acts in-session as the reserved `plurnk` run (§actor-boundary self-hosting), never a privileged singleton owning its own entries — the old *agent scope* is retired (entry scope is now `session` / `run`, §machine-processes). |
|
|
18
18
|
| **session** | Durable user-named workspace. Persists across runs and process restarts. Identity: `sessions.id` + unique `sessions.name`. |
|
|
19
19
|
| **run** | A stretch of work within a session. Multiple runs per session. May fork from another run via `parent_run_id`. Owns the log entries. |
|
|
20
|
-
| **loop** | One model-driven or client-driven iteration within a run. Status ∈ {100 pending · 102 running · 200 done · 413 budget-overflow · 429 turn-ceiling · 499 cancelled · 500 failed · 508 runaway}. Many loops per run. The model runs inside a loop; each client RPC has its own loop. |
|
|
20
|
+
| **loop** | One model-driven or client-driven iteration within a run. Status ∈ {100 pending · 102 running · 200 done · 202 parked (resumable, §send) · 413 budget-overflow · 429 turn-ceiling · 499 cancelled · 500 failed · 508 runaway}. Many loops per run. The model runs inside a loop; each client RPC has its own loop. |
|
|
21
21
|
| **turn** | One round-trip with the LLM (or one client RPC dispatch). One assembled prompt sent, one parsed response handled. Many turns per loop. Identity: `(loop_id, sequence)`. |
|
|
22
22
|
| **op** | One DSL operation the model emits. Parsed into a `PlurnkStatement`. Examples: `EDIT`, `READ`, `SEND`, `FIND`, `COPY`, `MOVE`, `OPEN`, `FOLD`, `EXEC`. One turn produces zero or more ops. |
|
|
23
23
|
| **statement** | Synonym for parsed op. The AST shape `PlurnkStatement` from `@plurnk/plurnk-grammar`. |
|
|
24
|
-
| **action** | One executed op. Action and op are the same thing in different states (op = parsed; action = executed). The execution produces a log_entries row at `log:///<L>/<T>/<S>/<op>`. |
|
|
24
|
+
| **action** | One executed op. Action and op are the same thing in different states (op = parsed; action = executed). The execution produces a log_entries row at `log:///<L>/<T>/<S>/<op>`. (The log also holds an *actionless* `op='error'` row — a model emission that failed to parse, §telemetry — so a failure is curatable like any row.) |
|
|
25
25
|
| **dispatch** | The engine routing a statement to its scheme's op handler. |
|
|
26
26
|
|
|
27
27
|
### §storage-terms Storage terms
|
|
@@ -30,7 +30,7 @@ Canonical meanings. When a doc, comment, test name, or commit message uses one o
|
|
|
30
30
|
|---|---|
|
|
31
31
|
| **entry** | The unit of canonical state. Identity: `(scope, scheme, pathname)`. Holds one or more `channels` of content plus `tags` and `attributes`. |
|
|
32
32
|
| **channel** | A named content buffer on an entry. Examples: `body`, `stdout`, `stderr`, `headers`, `symbols`. Each channel has `content`, `mimetype`, `tokens`, `state`. |
|
|
33
|
-
| **scope** | `"
|
|
33
|
+
| **scope** | `"session"` or `"run"`. Determines who reads: session-scope entries are the shared world (every run in the session), run-scope entries are a run's private scratch (§machine-processes). *Agent scope is retired.* |
|
|
34
34
|
| **scheme** | A URI prefix + handler. `known`, `unknown`, `file`, `https`, `exec`. The scheme handler interprets paths under its prefix and implements the op surface. Consumption surface §scheme-surface; author contract: [plurnk-schemes](https://github.com/plurnk/plurnk-schemes). |
|
|
35
35
|
| **mimetype** | A channel's content type. Drives the handler that produces the structural projections (`symbols`, `deepJson`, `deepXml`). Consumption surface §mimetype-surface; author contract: [plurnk-mimetypes](https://github.com/plurnk/plurnk-mimetypes). |
|
|
36
36
|
| **provider** | An LLM transport. Implements `generate({messages, signal})` against a wire protocol. Consumption surface §provider; author contract: [plurnk-providers](https://github.com/plurnk/plurnk-providers). |
|
|
@@ -43,7 +43,7 @@ Independent axes on entries and channels. Confusion across them is a recurring s
|
|
|
43
43
|
|---|---|---|
|
|
44
44
|
| **status** | HTTP int | Outcome of an operation. Carried on `log_entries.status_rx`, returned from op handlers. Per the catalogue (§send-dispatch). |
|
|
45
45
|
| **channel state** | `static \| active \| closed \| errored` | Streaming lifecycle of a channel's content. Metadata, not gating — engine renders content regardless of state. |
|
|
46
|
-
| **entry state** | `proposed \| resolved \| cancelled` | Proposal lifecycle. `proposed` = pending client accept; `resolved` = side effect happened; `cancelled` =
|
|
46
|
+
| **entry state** | `proposed \| resolved \| failed \| cancelled` | Proposal lifecycle (`log_entries.state`). `proposed` = pending client accept; `resolved` = accepted, side effect happened; `failed` = rejected (no effect); `cancelled` = the proposal was cancelled (loop abandoning). Distinct from channel state. |
|
|
47
47
|
| **outcome** | `string \| null` | Short reason for `failed`/`cancelled` (`"permission:403"`, `"aborted"`, `"not_found"`). Opaque to most callers. |
|
|
48
48
|
|
|
49
49
|
### §authority-terms Writer / authority
|
|
@@ -64,8 +64,8 @@ Independent axes on entries and channels. Confusion across them is a recurring s
|
|
|
64
64
|
| **sudden death** | The last `MAX_STRIKES` turns of a loop's `MAX_LOOP_TURNS` window emit soft 429 warnings so the model can wrap up cleanly. `soft=true`: no strike, no streak increment. |
|
|
65
65
|
| **mode** | `"ask" \| "act"`. Per-loop. Ask = read-only (no side-effecting ops); act = full surface. |
|
|
66
66
|
| **flag** | Per-loop boolean shaping the active toolset: `yolo` (auto-accept proposals), `noWeb`, `noInteraction`, `noProposals`. |
|
|
67
|
-
| **proposal** | A deferred side-effecting action awaiting client accept/reject (full lifecycle §proposal). State machine: `proposed → resolved`
|
|
68
|
-
| **resolution** | Client's accept/reject of a proposal via `
|
|
67
|
+
| **proposal** | A deferred side-effecting action awaiting client accept/reject (full lifecycle §proposal). State machine: `proposed → resolved` (accept), `→ failed` (reject), or `→ cancelled` (cancel). `yolo` short-circuits to immediate. |
|
|
68
|
+
| **resolution** | Client's accept / reject / cancel of a proposal via the `loop.resolve` RPC (§methods). |
|
|
69
69
|
|
|
70
70
|
### §packet-terms Packet terms
|
|
71
71
|
|
|
@@ -99,10 +99,10 @@ Dependency direction (from root to leaf):
|
|
|
99
99
|
- **`plurnk-grammar`** — root. Owns the JSON-Schema contracts (Packet, TelemetryEvent, AST shapes), the ANTLR parser that turns model output into `PlurnkStatement[]`, and `PlurnkParseError` with its `toTelemetryEvent()` helper. Nothing in the ecosystem can speak the DSL without it; everything else pins it exactly.
|
|
100
100
|
- **Framework siblings** consume grammar and define their own author-facing contracts:
|
|
101
101
|
- `plurnk-providers` — Provider/Alias types, `parseAliasesFromEnv`, `resolveActiveAlias`, `Mock`, `ProviderUsage` (currency-aware, includes `reasoning`). Vendor-specific implementations are children: `plurnk-providers-openai`, `-google`, `-ollama`, `-openrouter`, `-cloudflare`, `-xai`.
|
|
102
|
-
- `plurnk-mimetypes` — handler base classes, discovery, fitting algorithm, matcher dispatch. Handler children are per-mimetype: `plurnk-mimetypes-text-{python,typescript,markdown,html,csv,plain}`, `plurnk-mimetypes-application-{json,yaml,toml,pdf}`, …
|
|
102
|
+
- `plurnk-mimetypes` — handler base classes, discovery, the fitting algorithm, and the match primitives (`queryGlob`/`queryRegex`/`queryJsonpathObject`/`queryXpathString`) the service's matcher dispatches over (§matcher-dispatch). Handler children are per-mimetype: `plurnk-mimetypes-text-{python,typescript,markdown,html,csv,plain}`, `plurnk-mimetypes-application-{json,yaml,toml,pdf}`, …
|
|
103
103
|
- `plurnk-schemes` — scheme-author types (`SchemeManifest`, `WriterTier`, `LoopFlags`), result-shape contracts (`EntryResult` / `ProposalResult` / `PassthroughResult`), slicing primitives, matcher helpers, `schemeError(...)` constructor. Future scheme children: `plurnk-schemes-http`, `plurnk-schemes-git`, …
|
|
104
104
|
- `plurnk-execs` — `BaseExecutor`, `SubprocessExecutor`, runtime resolver, discovery. Children declare runtimes: `plurnk-execs-sh`, future `plurnk-execs-search`, `plurnk-execs-node`, …
|
|
105
|
-
- **`plurnk-service`** (this repo) — consumes all of the above. Implements the engine, dispatches ops through scheme handlers, hosts the in-tree set of schemes (`plurnk`, `log`, `exec`, `known`, `unknown`, `skill`, `file`), discovers installed mimetype handlers + provider vendors + executor siblings at boot, hosts the daemon (`src/service.ts` over WebSocket + JSON-RPC), and projects packets to the wire
|
|
105
|
+
- **`plurnk-service`** (this repo) — consumes all of the above. Implements the engine, dispatches ops through scheme handlers, hosts the in-tree set of schemes (`plurnk`, `log`, `exec`, `known`, `unknown`, `skill`, `file`), discovers installed mimetype handlers + provider vendors + executor siblings at boot, hosts the daemon (`src/service.ts` over WebSocket + JSON-RPC), and projects packets to the wire (the packet shape is service-owned since grammar 0.67 deleted `Packet.json`, §packet). Most of the substantive runtime work lives here.
|
|
106
106
|
- **`plurnk`** (client) — terminal UI consuming the daemon's RPC surface. Renders `telemetry/event` notifications, subscribes to log/stream/proposal events. No engine logic of its own.
|
|
107
107
|
|
|
108
108
|
The grammar is the contract. The frameworks consume the contract and add author-facing surfaces. The service consumes the frameworks and runs the engine. The client consumes the service and renders to humans. Each tier is its own published package; each tier's evolution happens in its own repo.
|
|
@@ -138,11 +138,11 @@ Server posture: this package is the runtime. User-facing CLI lives in `plurnk` a
|
|
|
138
138
|
|
|
139
139
|
**Wild west — no mutual exclusion.** Runs share the manifest without locks. Coordination is cooperative (tags + the shared workspace convention) and softly fenced (the §membership `read-only` overlay, a session policy, bounds every run's writable surface uniformly — §machine-processes); a conflict *surfaces* as a delta rather than being prevented. Inform, never override. {§actor-boundary-no-mutex}
|
|
140
140
|
|
|
141
|
-
**Passive wake.** An idle run wakes on exactly two events, both *directed at the run*: a prompt injected into it — the **voice door** (a user/system `loop.inject`, and once `run://` lands a sibling's `SEND(run
|
|
141
|
+
**Passive wake.** An idle run wakes on exactly two events, both *directed at the run*: a prompt injected into it — the **voice door** (a user/system `loop.inject`, and once `run://` lands a sibling's `SEND(run://.)`) — or a **stream-status transition** on a subscription it opened (§channel-state). Everything ambient is a delta — a sibling's edit to a shared entry, an out-of-band disk change — and a delta **never** wakes; it queues and drains at the next turn one of those two events produces (§env-delta). {§actor-boundary-passive-wake}
|
|
142
142
|
|
|
143
143
|
**Self-hosting — the runtime is an actor, not a back channel.** Runtime-initiated work (fs reconciliation §membership, git auto-add) is an **ephemeral `plurnk` run** firing ordinary ops, seen by other runs through the environment door like any actor's — not a privileged engine pathway. The engine keeps only the irreducible kernel runs stand on (spawn, dispatch, packet assembly, the budget rails §grinder, the fs-watch); everything expressible as ops on session entries is a run doing ops, through the same `op.*` surface (§methods) the service offers clients. Dogfooding is the architecture, not a test mode. {§actor-boundary-self-hosting}
|
|
144
144
|
|
|
145
|
-
**Migration path.** Largely realized: `Engine.dispatch` is origin-agnostic; client ops run in a per-connection client loop (`_dispatchAsClient`); plurnk EDITs already carry `origin=plurnk`. The keystone is **built** — `dispatchAsPlurnk` spawns the session's reserved `plurnk` run and fires ops through dispatch, mirroring `_dispatchAsClient
|
|
145
|
+
**Migration path.** Largely realized: `Engine.dispatch` is origin-agnostic; client ops run in a per-connection client loop (`_dispatchAsClient`); plurnk EDITs already carry `origin=plurnk`. The keystone is **built** — `dispatchAsPlurnk` spawns the session's reserved `plurnk` run and fires ops through dispatch, mirroring `_dispatchAsClient`; its uses so far (operator docs below; the fs-divergence narration) land in the plurnk run's log. The line that remains is one of *kind*, not a list of pending dispatches: work **expressible as an op** belongs on the keystone; work that is **not** stays kernel. Disk→entry materialization is the latter — *ingestion* is the inverse of an EDIT (which proposes egress to disk, §membership-edit-membership-gate), so it has no actor op and remains fs-watch kernel, paired with the plurnk run's filtered `source=file` narration (§env-delta) so a sibling pulls only true divergences, not every re-sync; the manifest build is likewise the per-turn derivation pump — packet-assembly kernel, not an entry-creating op. The one outstanding *expressible* piece is **git auto-add** — a model-created file surfaced as a plurnk-run op — gated on the §membership repo-overlay still being built.
|
|
146
146
|
|
|
147
147
|
**The keystone's first use: operator reference docs.** `PLURNK_MD_<ALIAS>=<path>` (§operator-config) materializes `<path>` as a `plurnk:///<ALIAS>.md` entry — a `dispatchAsPlurnk` EDIT in the plurnk run, **not** the model's — and the model's turn-0 foists a READ of it. The model reads the doc inline while the materializing EDIT stays out of its log: idiomatic context injection, an ordinary entry + READ rather than a bespoke packet section. The same `PLURNK_MD_*` convention cascades to clients. {§actor-boundary-doc-injection}
|
|
148
148
|
|
|
@@ -152,13 +152,13 @@ Server posture: this package is the runtime. User-facing CLI lives in `plurnk` a
|
|
|
152
152
|
|
|
153
153
|
**Question.** §actor-boundary isolates runs and lets the runtime self-host, but it stands on an ownership model it never states: what does a *session* own versus a *run*; what is shared versus private; and what does a fork carry? Unstated, the downstream questions — which run `log.read` reads, what a fork copies, where a per-client window onto the workspace would live — grow subtle, then metastasize. Drawn once, they vanish.
|
|
154
154
|
|
|
155
|
-
**Decision — the session is the world; a run is a log on it.** A **session** is the world: one shared filesystem — the `session`-scoped entries, surfaced as `
|
|
155
|
+
**Decision — the session is the world; a run is a log on it.** A **session** is the world: one shared filesystem — the `session`-scoped entries, surfaced as the per-scheme catalog (`FIND(scheme:///**)`, §packet) — under one membership overlay (§membership). Exactly one filesystem and one overlay per session; neither is per-run. A **run** is a process whose private memory is its **log** (§lifecycle-terms) — its loops, turns, and rows, each row carrying its own content, attribution (`origin`/`source`, §env-delta), and fold-state (`expanded`). A run owns **no membership**; even its visibility is not a possession but a bit on its own rows. It is a *history over the shared world, not a world*.
|
|
156
156
|
|
|
157
157
|
**One filesystem.** The entries are the session's: `entries.session_id`, never a run. A write by any run is a write to the one filesystem every run reads; there is no per-run entry set. {§machine-processes-one-filesystem}
|
|
158
158
|
|
|
159
159
|
**One overlay.** Membership — `git ls-files ∪ pick − hide` with `view` read-only (§membership) — is the session's: `session_constraints.session_id`, never a run. It is workspace *curation*, and the workspace *is* the session; two runs are two conversations about one curated workspace and see the same one. Divergent membership is a different session, never a per-run overlay. {§machine-processes-one-overlay}
|
|
160
160
|
|
|
161
|
-
**A run is its log —
|
|
161
|
+
**A run's memory of the world is its log — no shadow beside it.** A run's view of the shared world is the log and only the log — never a per-run snapshot. *What I am looking at* (OPEN/FOLD) is `log_entries.expanded`, a bit on the run's own rows, toggled by ordinary `log:///` ops — not a second store, and never membership (§open-fold). *What I last saw* needs no shadow either: a run learns its world moved through log entries (§env-delta) — a sibling's write broadcast into its log, an out-of-band disk change detected against the entry's own content and broadcast the same way — never through a per-run snapshot the run cannot see. (Its private **scratch** — run-scope entries, §run-scheme — is the run's own evolving workspace, owned not shadowed: a store it writes and reads deliberately, not a hidden mirror of the shared world. The doctrine is *no shadow of the world*, not *no private state*.) {§machine-processes-run-is-its-log}
|
|
162
162
|
|
|
163
163
|
**A run's log is private to packets, not to the session.** Isolation (§actor-boundary) governs what an *actor* sees — its own run, never a sibling's. It does not wall off the *wire*: any connection may read any run's log in its session by id — `log.read({ runId })`, ownership-verified, defaulting to the connection's own run. This is how a conversation client reads the **model** run, where the conversation lives: `loop.run` returns its `modelRunId`, and `session.runs` enumerates a session's runs for a connection that did not drive it live. The read is observation, never packet membership — no actor sees it. {§machine-processes-model-run-readable}
|
|
164
164
|
|
|
@@ -172,21 +172,23 @@ Server posture: this package is the runtime. User-facing CLI lives in `plurnk` a
|
|
|
172
172
|
|
|
173
173
|
**Migration path.** Mostly stating what the schema already carries: `runs.parent_run_id` and the parentless `sessions` exist (§lifecycle-terms); `session_constraints` is session-level (§membership); §env-delta already makes a run's timeline self-contained, so a fork's log copy suffices. Additive: `run.fork` over the wire (the engine fork is built). Two repatriations: §actor-boundary's "read-only overlay scopes a run's writable surface" becomes a *session* policy bounding every run uniformly; and the §env-delta environment door has shed its per-run snapshot — a run's only memory is its log, so drift is pulled from the shared log (other actors' edits since the run's last turn) and the filesystem narrates its own through the `plurnk` run, both already log entries, never a per-run shadow.
|
|
174
174
|
|
|
175
|
-
### §run-scheme The run:// scheme — spawn, irc, fork, terminate, cap, collect
|
|
175
|
+
### §run-scheme The run:// scheme — control (spawn, irc, fork, terminate, cap, collect) and run-scope scratch
|
|
176
176
|
|
|
177
|
-
The run:// scheme makes §machine-processes addressable: a `run://` target is a sister run in the session — `run
|
|
177
|
+
The run:// scheme makes §machine-processes addressable: a `run://` target is a sister run in the session — `run://.` the current run, `run://<name>` a session-scoped sibling (`runs.name`). Same-session only; a run never addresses another session's runs (§actor-boundary). The path discriminates the two faces: **path-absent is control** on the run-as-actor — the NAME is the authority (`run://<name>`, two slashes, no path) — while **path-present is run-scope storage**: `run://<owner>/<path>` (or `run:///<path>` for self) addresses the owner's private scratch (Scratch + Perspective, below). The control ops are three, fire-and-forget — the child runs independently, lineage in `runs.parent_run_id`:
|
|
178
178
|
|
|
179
|
-
- **Spawn** — `EDIT(run
|
|
180
|
-
- **irc** — `SEND(run
|
|
181
|
-
- **Fork** — `COPY(run
|
|
179
|
+
- **Spawn** — `EDIT(run://<name>):prompt` creates a new sister (empty log) and starts it with `prompt` on its first loop; self cannot be spawned (400). {§run-scheme-spawn}
|
|
180
|
+
- **irc** — `SEND(run://<name>):msg` delivers `msg` to an existing sister, the **voice door** (§actor-boundary-two-doors): an active sister folds it into its next turn, an idle one wakes (§actor-boundary-passive-wake); a name with no run in the session is 404. {§run-scheme-irc}
|
|
181
|
+
- **Fork** — `COPY(run://<src>):prompt` branches `src` (self is `run://.`): the source's log is deep-copied into a new sister (§machine-processes-fork-copies-the-log), which continues with `prompt`; the world is shared, never copied (§machine-processes-fork-shares-the-world). A fork ALSO inherits the source's run-scope **scratch** — its private workspace deep-copied with the owner remapped (source → branch) — so the branch opens with the parent's notes and diverges on its own edits: *fork = everything-in-common-but-name*. {§run-scheme-fork} {§run-scheme-fork-scratch}
|
|
182
182
|
|
|
183
183
|
All three ride one engine seam — the daemon's inject (active→fold, idle→enqueue+drain) — so the handler creates/branches/resolves the run and hands off; the daemon owns provider + system prompt. COPY's body here is the fork's seed prompt, not a destination path: the engine routes a run:// source away from the entry-copy path before parsing the body.
|
|
184
184
|
|
|
185
185
|
Beyond the three creation ops:
|
|
186
186
|
|
|
187
|
-
- **
|
|
187
|
+
- **Scratch (storage)** — `run://<owner>/<path>` is run-scope entry storage (`scope='run'`, the owner is the run name folded into the path; `run:///` is self). A run EDITs only its own scratch — a cross-run write is **403** ("read a sister's notes, never write them") — but READs and FINDs any sister's by name (cross-run read is open; scratch is perspective-private, not ACL-private). {§run-scheme-scratch}
|
|
188
|
+
- **Perspective** — a run's own scratch is catalogued in **its** manifest alone — `Manifest(run) = session-scope ∪ this-run's-run-scope`, foisted as `FIND(run:///**)` at turn 0 — so a sibling reaches it only by explicit `FIND(run://<name>/**)` and never sees it in its own perspective; isolation is structural (`scope='run'` is excluded from every session query, the owner opted back in only on its own read paths). {§run-scheme-find-perspective}
|
|
189
|
+
- **Terminate** — `KILL(run://<name>)` aborts a run by address (self is `run://.`): its active loop closes 499 and its subscriptions tear down; a name with no run is 404. The override to the fire-and-forget default — not a parent-power, whoever holds the address may end it; a run left alone simply ends at its own `SEND[200]`. {§run-scheme-terminate}
|
|
188
190
|
- **Cap** — `PLURNK_SESSION_RUNS_MAX_ACTIVE` ceilings the *concurrent* active runs per session (a run with a non-terminal loop); a spawn or fork past it fails hard (508 — no queue, no retry), irc exempt; `-1` disables it. The fork-bomb brake, sized for sessions that live for months. {§run-scheme-cap}
|
|
189
|
-
- **Collect** — a run's loop reaching a terminal status surfaces to its sisters as an ambient FOLDED delta (§env-delta): a `SEND` from `run
|
|
191
|
+
- **Collect** — a run's loop reaching a terminal status surfaces to its sisters as an ambient FOLDED delta (§env-delta): a `SEND` from `run://<name>` carrying the loop's deliverable — the `SEND[200]` body, or for an abandonment the reason. Every death-path is stamped uniformly, so no termination is silent; collection is the shared world moving, never a verb. {§run-scheme-collect}
|
|
190
192
|
|
|
191
193
|
### §run-lifecycle Run lifecycle: the drain, the reap, the passive wake
|
|
192
194
|
|
|
@@ -197,6 +199,8 @@ A run is a **log plus a cancellation scope** — one `AbortController` per run,
|
|
|
197
199
|
- **A stream's kill binds to the scope it captured at spawn.** A stream captures the run's cancellation scope as it registers and wires its kill to it, re-checking `aborted` AFTER wiring — no check-then-listen gap can drop an abort that lands mid-registration. Because the scope is replaced only once aborted, a captured-then-replaced scope is necessarily already aborted, so replacement never strands a live stream. {§run-lifecycle-exec-epoch-bound}
|
|
198
200
|
- **A cancelled run is not resurrected by its own torn-down work.** A stream conclusion delivered to a cancelled, idle run starts no fresh drain: an aborted (499) conclusion is skipped, and a straggler that concluded cleanly surfaces its deliverable as an environment delta (§env-delta), never a revived loop. The cancel was deliberate; only an explicit `loop.run` resumes the run. {§run-lifecycle-no-resurrection}
|
|
199
201
|
- **A stream conclusion always reaches its run.** When a backgrounded stream concludes, the daemon routes it through the same inject seam as any loop source (§actor-boundary-passive-wake): an active run folds the conclusion into its next turn; a run parked at a **slept loop** (`SEND[202]`) **awakens that loop in place** — the slept loop *is* the continuation, so there is no fresh loop and no summary-as-prompt fiction. The result is never lost: a parked loop sleeps rather than ending, and the stream's status-transition is the OPEN event (§actor-boundary-passive-wake) that wakes it; on resume it reads the concluded stream's own state, not a synthetic prompt. {§run-lifecycle-wake-liveness}
|
|
202
|
+
- **A child run concluding wakes a parent parked on it — the topology join.** `run://` spawn/fork records `parent_run_id` (§lifecycle-terms). When a run's drain exits having **concluded** — no parked `202` loop, no open stream — the daemon resumes its parent **in place** if the parent is parked (`#onDrainExit` → the shared `#wakeParkedRun`, the same 202→100 resume a stream conclusion uses). So a parent that spawns work and `SEND[202]`s is woken the moment its child finishes; on resume it reads the child's deliverable from the §run-scheme-collect delta in its own log — a control edge, **never an injected prompt**. The wake recurses upward via the parent's own drain-exit. A child still running — or itself parked at 202 — is not *concluded*, so it does not wake the parent (it's still a live thing the subtree holds). This is the structured-concurrency join: streams and child runs are the same kind of "live thing a run holds," driving premature-terminate (§send-premature-terminate), the wake edge, and the collect delta identically. {§run-lifecycle-child-wake}
|
|
203
|
+
- **A 202 whose subtree is idle emits `loop/quiesced` — a soft signal, not a terminal.** When a loop parks at `SEND[202]` and its subtree is idle (no open stream, no non-terminal child), the daemon broadcasts `loop/quiesced` (§notifications) — the client's honest *"nothing is running under this run right now."* The loop **stays at 202 and is reawakable**: a later `irc`/`loop.run` resumes it (and it re-quiesces, re-firing). It is **never a terminal code** — in a topology where any sibling can `irc` any run, true finality below the session doesn't exist, so quiescence (not finality) is the honest "done." A subtree with any live thing emits nothing — that thing's conclusion is the wake edge, not a quiesce. This is also the dead-park resolution: a wake-edge-less 202 no longer hangs silently; it announces idleness while staying resumable. {§run-lifecycle-quiesced}
|
|
200
204
|
- **A loop is never stranded by a drain's exit.** A drain relinquishes its registry slot only after a lock-held re-claim confirms the queue is empty; a loop enqueued during that teardown is either re-claimed by the exiting drain or claimed by a fresh drain that a later inject starts. The relinquish and the start are serialized, so neither the lost-loop hang nor a transient double-drain can occur. {§run-lifecycle-no-lost-loop}
|
|
201
205
|
|
|
202
206
|
---
|
|
@@ -209,12 +213,14 @@ Author-facing contract: [plurnk-providers#1](https://github.com/plurnk/plurnk-pr
|
|
|
209
213
|
|
|
210
214
|
Three entry points:
|
|
211
215
|
|
|
212
|
-
- `provider.generate({messages, signal})` — once per turn; returns `{ assistant: { content, reasoning, usage, finishReason, model }, assistantRaw }`. **Engine parses `assistant.content`** into `PlurnkStatement[]` via `@plurnk/plurnk-grammar`. {§provider-surface-generate}
|
|
216
|
+
- `provider.generate({messages, signal})` — once per turn; returns `{ assistant: { content, reasoning, usage, finishReason, model }, assistantRaw, meta? }`. **Engine parses `assistant.content`** into `PlurnkStatement[]` via `@plurnk/plurnk-grammar`. {§provider-surface-generate}
|
|
213
217
|
- `provider.countTokens(text)` — synchronous, called at write-time (§tokenomics) and render-time. Non-negative integer. {§provider-surface-counttokens}
|
|
214
218
|
- `provider.costFor(usage)` — once per completed turn; pico-USD. Engine writes to `turns.usage_cost_pico`; triggers cascade to `runs.cost_pico` / `sessions.cost_pico`. {§provider-surface-costfor}
|
|
215
219
|
|
|
216
220
|
Plus immutable identity: `provider.contextSize` (token total, or `null` → "no budget info"), read by the budget {§provider-surface-identity}; and `provider.model` — the instance identity the deferred model-switch recompute compares (§tokenomics), exposed but not yet consumed here.
|
|
217
221
|
|
|
222
|
+
**Metadata passthrough (provider → client).** `generate` may return an open `meta: Record<string, unknown>` bag — e.g. a hosted provider's running `balancePico`. The service stores it **unenforced** per turn (`turns.meta`, `json_valid` only — no schema) and forwards the latest turn's blob to the client on the loop usage payload (`loop.run` result / `loop/terminated`, §methods). **The service never reads a field within it.** The canonical-field contract — which fields exist and their shapes — is the *provider framework's* (it normalizes raw vendor data into the agreed set) and the *client's* (it renders that set); a provider and client can ship a feature with **zero service change** as long as the blob flows. Absent → `{}` (the client renders nothing; never fabricated). The mirror direction (client → provider, the self-identified `client` id) rides `generate({client})` (§attribution). {§meta-passthrough}
|
|
223
|
+
|
|
218
224
|
### §provider-guarantees Engine → provider guarantees
|
|
219
225
|
|
|
220
226
|
- `messages` is a complete prompt (the section list, pre-assembled into the system + user messages). Provider does not reorder.
|
|
@@ -267,7 +273,7 @@ Every op targets a URI; the entry key is `(scope, scheme, pathname)`. The URI pa
|
|
|
267
273
|
|
|
268
274
|
- A **registered** scheme is a plurnk namespace: its authority is a leading path segment, folded into the pathname (`Engine.#extractTarget` → `foldAuthorityIntoPath`). So `known://x`, `known:///x`, and pathname `/x` are the same entry — the authority is never a host, and the two-slash and three-slash forms are not distinct resources. {§scheme-address-namespace-fold}
|
|
269
275
|
- A **foreign** scheme (unregistered — `http`/`https`) is a real web host: its authority stays in `hostname`, never folded.
|
|
270
|
-
- `file` persists `scheme = NULL`; a relative path resolves against the workspace root to the namespace-absolute `/rel` key (RFC §5 reference resolution), and a path escaping the root is 403 (§membership).
|
|
276
|
+
- `file` persists `scheme = NULL`; a relative path resolves against the workspace root to the namespace-absolute `/rel` key (RFC 3986 §5 reference resolution), and a path escaping the root is 403 (§membership).
|
|
271
277
|
|
|
272
278
|
Storage keys on the resolved `(scheme, pathname)` **verbatim** — the leading slash is the namespace origin, not a filesystem absolute, and is never re-normalized at the storage boundary.
|
|
273
279
|
|
|
@@ -327,15 +333,20 @@ interface PlurnkSchemeContext {
|
|
|
327
333
|
readonly runId: number;
|
|
328
334
|
readonly loopId: number;
|
|
329
335
|
readonly turnId: number;
|
|
330
|
-
readonly writer: "model" | "client" | "plurnk" | "plugin";
|
|
336
|
+
readonly writer: "model" | "client" | "plurnk" | "plugin"; // WriterTier
|
|
331
337
|
readonly signal: AbortSignal | undefined;
|
|
332
338
|
readonly streamEventNotify?: StreamEventNotify;
|
|
333
339
|
readonly wakeRunNotify?: WakeRunNotify;
|
|
340
|
+
readonly injectRun?: InjectRunNotify; // run:// spawn/fork/irc loop-start (§run-scheme)
|
|
334
341
|
readonly mimetypes?: Mimetypes;
|
|
342
|
+
readonly executors?: ExecutorRegistry; // boot-discovered EXEC runtimes (§exec)
|
|
343
|
+
readonly tokenize?: (text: string) => number; // write-time tokenizer (§tokenomics)
|
|
344
|
+
readonly defaultChannelFor?: (scheme: string | null) => string;
|
|
345
|
+
readonly pushTelemetry?: (event: TelemetryEvent) => void; // → next packet errors[] + telemetry/event (§telemetry)
|
|
335
346
|
}
|
|
336
347
|
```
|
|
337
348
|
|
|
338
|
-
|
|
349
|
+
The optional engine-/daemon-populated capabilities (the notifiers, `injectRun`, `executors`, `tokenize`, `defaultChannelFor`, `pushTelemetry`) are absent in bare test fixtures; a handler that needs one **fail-hards** rather than silently degrading (no default runtime, no silent zero-token write).
|
|
339
350
|
|
|
340
351
|
Engine → scheme guarantees:
|
|
341
352
|
|
|
@@ -345,7 +356,7 @@ Engine → scheme guarantees:
|
|
|
345
356
|
- `ctx.signal` is wired to the run's AbortController (§provider-guarantees-signal-wired).
|
|
346
357
|
- Scheme exceptions become the action-entry's outcome (status 500); summary surfaces in next turn's `errors` section (§telemetry). {§scheme-surface-exception-500}
|
|
347
358
|
|
|
348
|
-
**Tokenization participation.** Schemes route writes through the shared `_entry-crud.ts` write helper (in plurnk-service today; migrates to plurnk-schemes). Helper populates `entry_channels.tokens` at write time via `ctx.
|
|
359
|
+
**Tokenization participation.** Schemes route writes through the shared `_entry-crud.ts` write helper (in plurnk-service today; migrates to plurnk-schemes). Helper populates `entry_channels.tokens` at write time via `ctx.tokenize` (§tokenomics-tokens-stored-at-write). Raw DB writes bypass tokenization — out of API scope.
|
|
349
360
|
|
|
350
361
|
---
|
|
351
362
|
|
|
@@ -409,7 +420,7 @@ new Mimetypes({
|
|
|
409
420
|
|
|
410
421
|
Fallback heuristic is a boot-before-provider-resolved tripwire.
|
|
411
422
|
|
|
412
|
-
**
|
|
423
|
+
**Derivation pump.** `EntryManifest.maintainDerivations` is the per-turn engine-side pass (the §mimetype firing point) that walks **every** entry. It calls `process({ content, hint })` per channel for the catalog's `lines` (`totalLines`) and, for the body channel, pulls `symbols`+`references` from the *same* call to (re)build the `@graph` symbol index (`symbol_defs`/`symbol_refs`) via `EntryGraph.populateFrom` — one parse, two projections:
|
|
413
424
|
|
|
414
425
|
```ts
|
|
415
426
|
const result = await mimetypes.process({ content: r.content, hint: r.mimetype }, { channels: ["symbols", "references"] });
|
|
@@ -437,7 +448,7 @@ Schemes MAY declare multiple channels (`exec`: stdout/stderr/stdin; `http`: body
|
|
|
437
448
|
|
|
438
449
|
### §no-visibility Entries carry no visibility
|
|
439
450
|
|
|
440
|
-
Every entry is uniformly listed in `
|
|
451
|
+
Every entry is uniformly listed in the catalog (`FIND(scheme:///**)`, §packet) and READable — entries have no per-run open/folded state. Context curation is the model's, on the **log** (via OPEN/FOLD, §open-fold), never on entries.
|
|
441
452
|
|
|
442
453
|
### §channel-mimetype Mimetype is a (scheme, channel) property — never a default
|
|
443
454
|
|
|
@@ -460,8 +471,8 @@ Rules:
|
|
|
460
471
|
|---|---|
|
|
461
472
|
| `known:///france/capital` | body (default) |
|
|
462
473
|
| `known:///france/capital#symbols` | symbols |
|
|
463
|
-
| `
|
|
464
|
-
| `
|
|
474
|
+
| `sh:///1/1/2#stdout` | stdout |
|
|
475
|
+
| `sh:///1/1/2#stderr` | stderr |
|
|
465
476
|
| `sse://feed/y#data` | data |
|
|
466
477
|
| `log:///N/T/A` | (no channel concept; atomic log row) |
|
|
467
478
|
|
|
@@ -476,8 +487,8 @@ RPC params carry fragments inline via the `target` string (`{ target: "known:///
|
|
|
476
487
|
|
|
477
488
|
```
|
|
478
489
|
<<notes.md:...:notes.md — file scheme (bare)
|
|
479
|
-
<<
|
|
480
|
-
<<
|
|
490
|
+
<<sh:///1/1/2:...:sh:///1/1/2 — exec output default (stdout)
|
|
491
|
+
<<sh:///1/1/2#stderr:...:sh:///1/1/2#stderr — non-default
|
|
481
492
|
<<log:///1/1/0:...:log:///1/1/0 — atomic log row
|
|
482
493
|
```
|
|
483
494
|
|
|
@@ -517,7 +528,7 @@ AST: `{ op: "READ", target, body: MatcherBody | null, signal: tags | null, lineM
|
|
|
517
528
|
|
|
518
529
|
- Returns channel content + mimetype {§read-read-content}, or 404 {§read-read-404}.
|
|
519
530
|
- `lineMarker` slices per §slice-semantics.
|
|
520
|
-
- `body` matcher dispatches through `
|
|
531
|
+
- `body` matcher dispatches through the in-tree `Matcher` per §matcher-dispatch (all four content dialects wired).
|
|
521
532
|
|
|
522
533
|
### §open-fold OPEN / FOLD
|
|
523
534
|
|
|
@@ -579,23 +590,25 @@ The engine's failure terminals — **500** (strike threshold) and **508** (cycle
|
|
|
579
590
|
**Two engine error states verify the claim.** Neither is a status code the model learns; both are engine machinery (§engine-rails), pushed to the model as a steering hint on the next packet and **never** as the strike itself (the model sees errors that happened, never the engine's accounting — the gamification policy, §engine-rails). Each strikes (`turnErrors`) and lets the loop continue so the model can correct; a model that ignores the hint and keeps offending spins out to the engine's 500, seeing only the repeated hint, never the count. (Both live at `Engine.runLoop`'s turn close.)
|
|
580
591
|
|
|
581
592
|
- **Idle turn** {§send-idle-turn} — a continuing turn (102) whose ops are only PLAN/SEND — no work op. The model continued with nothing to do. The steer, verbatim: *"If the turn's work is complete, terminate with 200. If awaiting a stream or run trigger, terminate with 202 to hibernate."*
|
|
582
|
-
- **Premature terminate** {§send-premature-terminate} — a `SEND[200]` while the run holds a live
|
|
593
|
+
- **Premature terminate** {§send-premature-terminate} — a `SEND[200]` while the run holds a **live thing**: an open stream/spawn (§subscriptions, §run-lifecycle-total-reap) **or a non-terminal child run** (§run-lifecycle — children and streams are the same kind of live thing a run holds). The model declared done with work still running. The engine **downgrades the 200 to 102** — the turn continues *and the SEND's body is preserved* (it dispatches as a continue, never discarded) — and steers, verbatim: *"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."*
|
|
583
594
|
|
|
584
|
-
**Dead-park caveat — read before changing 202.** A 202 is a real hibernation only when a wake edge exists
|
|
595
|
+
**Dead-park caveat — read before changing 202.** A 202 is a real hibernation only when a **wake edge** exists. Two edges exist: a **stream conclusion** (§run-lifecycle-wake-liveness) and an EXEC **poll cadence** `<T,P>` (§exec-poll — a per-run timer resumes the slept loop every P). grammar 0.74.20 now **advertises** `SEND[202]` on the hot path, so the old "preventive" guard (un-advertising it) no longer holds. The remaining gap is the **wake-edge-*less* 202** — a model that parks with no open stream and no poll: it has nothing to resume it and *can* hang. The daemon-side check that detects and resolves a wake-edge-less 202 is the known backstop, still **deferred**; until it lands, do not assume a wake-edge guarantee for a 202 emitted with nothing running.
|
|
585
596
|
|
|
586
597
|
### §exec EXEC
|
|
587
598
|
|
|
588
|
-
AST: `{ op: "EXEC", target (cwd), body: string | null (command), signal: string | null (runtime tag) }`.
|
|
599
|
+
AST: `{ op: "EXEC", target (cwd), body: string | null (command), signal: string | null (runtime tag), lineMarker (timeout/poll) }`.
|
|
589
600
|
|
|
590
601
|
Engine routes unconditionally to `exec` scheme (path slot is `cwd`, not a URI). The runtime slot (`signal`) selects an executor, resolved against the boot-time `ExecutorRegistry` — siblings discovered and probed at startup, availability cached, default `sh`. Unknown or unavailable runtime → 501 carrying the probe `detail`. {§exec-registry-resolves}
|
|
591
602
|
|
|
592
|
-
**
|
|
603
|
+
**Timeout and poll — `<T,P>` on the `<L>` slot (grammar 0.74.20).** EXEC repurposes the line-marker slot as `<timeout, poll>` in **seconds** (consistent with the `seconds=` stream-age render). `T` (mark[0]) caps the spawn's lifetime: at T the service aborts it (a bounded reap — polite signal then SIGKILL after `PLURNK_EXEC_KILL_GRACE_MS`) and stamps the stream **504**, distinct from a deliberate kill (499) or a clean exit (200). Absent / ≤0 → unbounded, the prior background-stream behavior. {§exec-timeout} `P` (mark[1]) is the hibernation **poll cadence**, stored on the subscription: while the loop is *parked* at `SEND[202]`, the daemon arms a per-run timer for the tightest open poll cadence and resumes the slept loop every P seconds to inspect progress (the same 202→100 resume a stream conclusion uses, §run-lifecycle). It does **nothing while the loop is active** — an active loop already gets the ambient folded stream deltas (§exec-stream), so the poll-wake matters only across hibernation. A 202 with no polled stream gets no timer (it sleeps until a conclusion wakes it). {§exec-poll}
|
|
604
|
+
|
|
605
|
+
**Effect-gating.** Each executor declares an `effect` (`pure` | `read` | `host`); the service maps it to policy (`EffectPolicy`). A `host` runtime (subprocess; file-backed sqlite) mutates the host → **propose** (lifecycle §proposal): the run waits for a human gate, then spawns and writes stdout/stderr to channels of a `<runtime>:///<loop>/<turn>/<seq>` entry (the runtime tag is the URI scheme, §exec/#240; the coordinate matches the op's log-row coordinate, e.g. `sh:///1/1/2`), returning `102 Processing` immediately. Channel state transitions (`active` → `closed`/`errored`) drive what the model sees at subsequent turn boundaries (§channel-state). {§exec-host-proposes}
|
|
593
606
|
|
|
594
607
|
A `read` runtime (observes external state, e.g. search) or `pure` runtime (no observable effect, e.g. `:memory:` sqlite) is side-effect-free → **auto-run**: no proposal, no human gate, no notification. It skips the gate a host command faces, but it does NOT resolve in-band — like every exec it backgrounds and streams, its output reaching the model through the environment-observation injector (a foisted READ of the stream's new bytes each turn, §exec-stream), never a same-turn receipt. {§exec-readpure-ungated}
|
|
595
608
|
|
|
596
609
|
**Stream surfacing.** An exec's output is *observed, not fetched*. Each turn the environment-observation injector — the same machine §env-delta rides — reads each of the run's open channels from a per-channel byte cursor and foists the new bytes as an `origin=plurnk` READ at `<runtime>:///<coord>#<channel>`, then advances the cursor — each delta carries the `startLine` that cursor implies, so a stream spanning turns numbers into one continuous sequence (lines 1–k, then k+1–m), not a fresh `1:` each turn. The delta is **folded** while the channel streams and auto-**opened** on the terminal one (the channel closed): a model ignores a chatty background run but always SEES a finished one. It never types these READs — it consumes them. The EXEC row itself renders the *command* it ran, `:::`-fenced and line-numbered per §render-rule so the model can line-reference its own code — the input, distinct from the stream above (the output). This is exec as an instance of one ambient machine, env-delta as another (sibling edits, timestamp cursor, always folded). {§exec-stream}
|
|
597
610
|
|
|
598
|
-
`SEND[499](exec:///<
|
|
611
|
+
`SEND[499](exec:///<loop>/<turn>/<seq>)` cancels the in-flight subprocess via the subscription registry's stored `AbortController` — the coordinate addresses the spawn (`exec://` is the process-control face); the `<runtime>://` output entry delegates the same KILL to the one `Exec` handler that owns the abort state (§stream-control).
|
|
599
612
|
|
|
600
613
|
**Scoped environment.** An EXEC subprocess inherits the *project's* environment — its `.env`, the standard shell vars — so the model's commands run as the project expects; but never plurnk's own secrets: the provider API keys and `PLURNK_*` config are stripped before the spawn, so a model-run command can't `printenv` the engine's keys. The service owns the scoping policy (the denylist); the executor spawns with the env it is handed. {§exec-env-scoped}
|
|
601
614
|
|
|
@@ -656,7 +669,7 @@ Model sees lifecycle events in the `log` section per turn.
|
|
|
656
669
|
|
|
657
670
|
### §stream-constraints Engine constraints
|
|
658
671
|
|
|
659
|
-
ONE engine-level constraint: **100 MiB char-length cap per channel body**. `CHECK (length(content) <= 104857600)` on `entry_channels.content` (migrations/
|
|
672
|
+
ONE engine-level constraint: **100 MiB char-length cap per channel body**. `CHECK (length(content) <= 104857600)` on `entry_channels.content` (the genesis schema migration, `migrations/0000-00-00.01_schema.sql`). Violations → SQLITE_CONSTRAINT; action-entry captures rejection at status 500.
|
|
660
673
|
|
|
661
674
|
All other limits are extrinsic — providers (request size, model context, fetch timeouts), schemes (per-call validation), mimetypes (render budgets). Engine does not throttle, batch, rate-limit, or cap anything else. {§stream-constraints-engine-one-cap}
|
|
662
675
|
|
|
@@ -676,8 +689,9 @@ SQLite (`node:sqlite`) with WAL mode and STRICT tables. Hand-written DDL; CI-ali
|
|
|
676
689
|
|
|
677
690
|
No generator. SQLite-optimal: STRICT (3.37+), `INTEGER PRIMARY KEY` aliasing, explicit `NOT NULL`, indexed query paths, deliberate FK `ON DELETE`/`ON UPDATE`, `WITHOUT ROWID` where access pattern warrants, generated columns, FTS5.
|
|
678
691
|
|
|
679
|
-
- One
|
|
680
|
-
- `migrate` CLI
|
|
692
|
+
- One `.sql` file per cohesive concern under `migrations/`, **date-prefixed and basename-sorted** for deterministic apply order (`0000-00-00.01_schema.sql` is the genesis).
|
|
693
|
+
- DDL lives in `-- INIT: <name>` blocks (`CREATE TABLE IF NOT EXISTS`) that `@possumtech/sqlrite` runs idempotently at DB open — re-running is a no-op by construction, so today there is no separate `migrate` CLI or applied-marker table. A fresh DB is the recovery story while the project is greenfield-solo.
|
|
694
|
+
- **Forward — apply-once evolution.** The idempotent-`INIT` model creates a fresh schema but does not *evolve* a populated one. When persisted state outlives a "just nuke it" reset, the date-prefixed ordering is the foundation a real migration policy layers onto: ordered `ALTER` files applied once and recorded in a marker table, not recreated idempotently. The current scheme is chosen so that transition is **additive** — a marker table plus apply-once dispatch — never a restructuring of how DDL is authored or ordered.
|
|
681
695
|
- **Schema-alignment test**: loads `@plurnk/plurnk-grammar/schema/*.json`, parses DDL via `node:sqlite` introspection, asserts every required schema field has a corresponding `NOT NULL` column. Grammar drift fails CI.
|
|
682
696
|
- DDL = storage truth; JSON Schemas = wire truth. Tested-aligned, allowed to differ where ergonomics demand.
|
|
683
697
|
|
|
@@ -727,7 +741,7 @@ Env vars configure installed plugins; never declare existence. Filesystem is the
|
|
|
727
741
|
|
|
728
742
|
Plugin discovery (§plugin-discovery) registers whatever's in `node_modules/@plurnk/*`.
|
|
729
743
|
|
|
730
|
-
**Providers in-tree
|
|
744
|
+
**Providers in-tree:** none. `Mock` (the intg-only test fixture + worked example) is a sibling, `@plurnk/plurnk-providers` (§mock-provider); `ProviderInstantiate.ts` dynamically imports the selected provider package.
|
|
731
745
|
|
|
732
746
|
**Mimetypes in-tree:** none. Framework + handlers are all siblings.
|
|
733
747
|
|
|
@@ -763,7 +777,7 @@ Plugin discovery (§plugin-discovery) registers whatever's in `node_modules/@plu
|
|
|
763
777
|
- Channel state (`active`/`closed`/`errored`) — subscription registry, not on `ChannelContent`.
|
|
764
778
|
- Backpressure caps — none (§stream-constraints).
|
|
765
779
|
- Stream cancel — `SEND[499]` (§stream-control).
|
|
766
|
-
- Delete —
|
|
780
|
+
- Delete — `KILL` (entry-KILL, the canonical delete; the MOVE→`/dev/null` idiom is retired, §move); `SEND[410]` also deletes as a side-effect (§send-dispatch).
|
|
767
781
|
- Per-loop flags — `loops.flags` JSON column; `yolo` end-to-end today, others scheduled.
|
|
768
782
|
- Default-channel wire rendering — §channel-selection.
|
|
769
783
|
|
|
@@ -788,9 +802,9 @@ Model selection: separate alias cascade in `ProviderRegistry` (§provider-instan
|
|
|
788
802
|
| `PLURNK_MIN_CYCLES` | `3` | enforced | Min repetitions before cycle detection fires (§engine-rails). |
|
|
789
803
|
| `PLURNK_MAX_CYCLE_PERIOD` | `4` | enforced | Max period length cycle detection examines (§engine-rails). |
|
|
790
804
|
| `PLURNK_MD_<ALIAS>` | (unset) | enforced | Operator reference doc: materializes `<path>` as `plurnk:///<ALIAS>.md`, auto-READ into every model run's turn 0 (§actor-boundary). `~` expands to home. |
|
|
791
|
-
| `PLURNK_MANIFEST_ITEMS` | `0` | enforced | Turn-0
|
|
805
|
+
| `PLURNK_MANIFEST_ITEMS` | `0` | enforced | Turn-0 catalog preview foisted into the model's first turn, one `FIND(scheme:///**)` per active scheme. `-1` = each scheme's full catalog; positive `N` = its first N rows (`<L>` cap); `0` / unset = off (§actor-boundary-manifest-preview). |
|
|
792
806
|
| `PLURNK_PROPOSAL_TIMEOUT_MS` | `300000` | enforced | ms wait for a proposed entry (status=202) to be resolved before timing out. |
|
|
793
|
-
| `
|
|
807
|
+
| `PLURNK_PROVIDERS_REASONING_BUDGET` | `-1` | enforced | The single provider reasoning gate (REQUIRED — fail-hard if unset). `0` = native reasoning off (in-DSL PLAN does it), `-1` = adaptive / no cap, `N` = capped at N tokens; provider modules translate per model family (reasoning_effort tiers, Anthropic `budget_tokens`). Floor (gemma): `0`. |
|
|
794
808
|
| `PLURNK_FETCH_TIMEOUT` | `600000` | enforced | Service-wide ms ceiling on any outbound request (providers, future http schemes). Module-specific overrides are allowed below the ceiling. |
|
|
795
809
|
| `PLURNK_DEBUG` | `0` | reserved | Schema-validation toggle. Not yet enforced. |
|
|
796
810
|
| `PLURNK_LOG_LEVEL` | `info` | reserved | Stdout banner verbosity. Not yet enforced. |
|
|
@@ -798,7 +812,7 @@ Model selection: separate alias cascade in `ProviderRegistry` (§provider-instan
|
|
|
798
812
|
**enforced** = engine reads and acts on the value. **reserved** = shipped in `.env.example` (forward-spec) but no-op until wired.
|
|
799
813
|
|
|
800
814
|
**Two override semantics — ceiling vs default.** Which kind a var is determines what "override" means across the cascade:
|
|
801
|
-
- **Ceiling** (most-restrictive-wins) — an operator-set hard bound nothing downstream may exceed: not a lower-precedence file, not a per-session constraint, not a per-call RPC arg. `
|
|
815
|
+
- **Ceiling** (most-restrictive-wins) — an operator-set hard bound nothing downstream may exceed: not a lower-precedence file, not a per-session constraint, not a per-call RPC arg. `PLURNK_GIT_ALLOWED` (`=0` flatly denies git service-wide, §membership), `PLURNK_BUDGET_CEILING` (§tokenomics), `PLURNK_MAX_COMMANDS`, `PLURNK_MAX_STRIKES`, `PLURNK_FETCH_TIMEOUT` (module overrides allowed only *below* it), and `PLURNK_MAX_TURNS` (`-1` ships it off; a positive value caps the per-call request). The sandbox/cost guarantee: the operator caps it; no client widens it.
|
|
802
816
|
- **Default** (explicit-wins) — a fallback the most-specific setter replaces freely: `PLURNK_MODEL` (a `loop.run({alias})` overrides it), `PLURNK_REQUIREMENTS` (the per-call requirements default), and the config-time vars (`HOST` / `PORT` / `DB_PATH`).
|
|
803
817
|
|
|
804
818
|
Enforcement is per-use-site — no central most-restrictive pass; each ceiling is checked where it bites. `PLURNK_MAX_TURNS` ships **off** (`-1` = no cap; the loop ends via SEND, budget, strikes, or cycle detection) and, when an operator sets a positive value, the per-call request is `min()`-capped against it. {§operator-config-max-turns-ceiling}
|
|
@@ -843,16 +857,16 @@ Success response: `{ "jsonrpc": "2.0", "id": …, "result": … }`. Failure: `{
|
|
|
843
857
|
### §method-registration Method registration
|
|
844
858
|
|
|
845
859
|
```ts
|
|
846
|
-
registry.
|
|
860
|
+
registry.registerMethod("loop.run", {
|
|
847
861
|
handler: async (params, ctx) => { /* ... */ },
|
|
848
862
|
description: "Run a model-driven loop with a prompt.",
|
|
849
863
|
params: {
|
|
850
864
|
prompt: "string — the user prompt for the loop",
|
|
851
|
-
sessionId: "number? — defaults to current attached session",
|
|
852
865
|
maxTurns: "number? — defaults to PLURNK_MAX_TURNS",
|
|
866
|
+
alias: "string? — overrides the boot-time PLURNK_MODEL",
|
|
853
867
|
},
|
|
854
868
|
requiresInit: true,
|
|
855
|
-
longRunning:
|
|
869
|
+
longRunning: false, // returns immediately (finalStatus:100); the loop runs async, §methods
|
|
856
870
|
});
|
|
857
871
|
```
|
|
858
872
|
|
|
@@ -938,7 +952,7 @@ registry.register("loop.run", {
|
|
|
938
952
|
| Method | Params | Result | Notes |
|
|
939
953
|
|---------------|-------------------------------------|------------------------|-------|
|
|
940
954
|
| `entry.read` | `target: string` | `{ status, entry }` | Read the full entry shape (channels + tags + metadata) at the given URI. {§methods-entry-read} |
|
|
941
|
-
| `log.read` | `loopId
|
|
955
|
+
| `log.read` | `loopId?`, `turnId?`, `loopSeq?`, `turnSeq?`, `sequence?`, `sinceId?`, `limit?` | `{ entries: LogEntry[] }` | Read recent log entries from the attached session. A full display coordinate (`loopSeq`+`turnSeq`+`sequence`) resolves the single entry behind an `L/T/S` waterfall line — full shape (tx + rx), server-side, no client fetch-all+match (#271). {§methods-log-read} |
|
|
942
956
|
|
|
943
957
|
**Log coordinate.** Every `LogEntry` — from `log.read` and the `log/entry` notification alike — carries `loop_seq`/`turn_seq`, the loop+turn ordinals, beside the `loop_id`/`turn_id` DB keys, so a client renders the logical coordinate (e.g. `01/02/03`) without resolving ids. {§methods-log-coordinate}
|
|
944
958
|
|
|
@@ -976,6 +990,7 @@ Server-initiated events on the same WebSocket.
|
|
|
976
990
|
|--------------------|-------------------------------------|------------|
|
|
977
991
|
| `log/entry` | `{ entry: LogEntry }` | Every `log_entries` write. {§notifications-log-entry-notify} |
|
|
978
992
|
| `loop/terminated` | `{ loopId, finalStatus, hitMaxTurns }` | Loop reaches terminal status. |
|
|
993
|
+
| `loop/quiesced` | `{ loopId, runId, status: 202 }` | A loop parked at `SEND[202]` reached subtree-quiescence (no open stream, no non-terminal child) — idle/complete-for-now but **reawakable**, distinct from `loop/terminated` (§run-lifecycle-quiesced). |
|
|
979
994
|
| `loop/proposal` | `{ logEntryId, sessionId, runId, loopId, turnId, op, target, body, attrs, flags }` | Dispatch pauses on status=202. Carries `flags` so server-YOLO clients can suppress review UI. Client responds with `loop.resolve` (or `PLURNK_PROPOSAL_TIMEOUT_MS` fires). |
|
|
980
995
|
| `session/created` | `{ id, name, projectRoot }` | Any client creates a session. |
|
|
981
996
|
| `stream/event` | `{ entryId, channel, state, contentLength }` | Channel content grows or state transitions. {§notifications-stream-event-on-channel-change} |
|
|
@@ -1015,7 +1030,7 @@ Server-initiated events on the same WebSocket.
|
|
|
1015
1030
|
|
|
1016
1031
|
**The client's run.** A client connection is an actor (§machine-processes); its `op.*` write to its **own run** — `origin = "client"`, one loop per connection — and `log.read` reads that run. Disconnect closes the loop's status; rows persist. Multiple connections each get their own client run.
|
|
1017
1032
|
|
|
1018
|
-
`loop.run` and `inject` target the **model's run** — a separate run holding the conversation, `origin = "model"`. Both runs share the session's one filesystem (§machine-processes); the packet renders only the model's run, so the client's ops are structurally absent from it — no origin filter (§actor-boundary-isolation).
|
|
1033
|
+
`loop.run` and `inject` target the **model's run** — a separate run holding the conversation, `origin = "model"`. Both runs share the session's one filesystem (§machine-processes); the packet renders only the model's run, so the client's ops are structurally absent from it — no origin filter (§actor-boundary-isolation). The model run (`Envelope.ensureModelRun`) and the connection's client run are distinct, each lazily allocated on first use — the §machine-processes conflation is corrected.
|
|
1019
1034
|
|
|
1020
1035
|
### §errors Errors
|
|
1021
1036
|
|
|
@@ -1058,7 +1073,7 @@ Each entry: question, answer, rationale, migration path.
|
|
|
1058
1073
|
|
|
1059
1074
|
**Question.** Rummy uses priority-ordered filter chains for packet assembly. Plurnk builds a default ordered section list directly in `Engine.#buildRequestPacket`, then lets trusted plugins rewrite it.
|
|
1060
1075
|
|
|
1061
|
-
**Decision.** Two stages. (1) The engine builds the default section list — the
|
|
1076
|
+
**Decision.** Two stages. (1) The engine builds the default section list — the system-slot sections `definition`, `tools`, `schemes`, the policy sections `system-policy`/`project-policy`, and `budget` (budget is law — a privileged constraint kept in the lean system zone), then the user-slot sections `prompt`, `errors`, `log`, `git`, `requirements` (the log is short-term memory — data, rendered at the action point, not a privileged rule). (2) `SchemeRegistry.transformSections` pipes that list through every registered scheme that implements `transformSections(sections) → sections`, in registration order, before the engine measures. A plugin returns whatever list it wants — add, remove, reorder. {§packet-plugin-transform}
|
|
1062
1077
|
|
|
1063
1078
|
**Why a whole-list transform, not a per-section hook.** It is the legible, fork-avoiding seam: a plugin that can reshape the packet to its needs never has a reason to fork the engine (§ecosystem). And it is **strictly in-process and trusted** (behind `PLURNK_PLUGINS_TRUSTED_ONLY`) — the client/RPC wire never reaches the packet, because handing an untrusted connection the model's entire context is exactly the actor-boundary violation the engine exists to prevent. Pure list-in/list-out; no context is handed to plugins.
|
|
1064
1079
|
|
|
@@ -1081,7 +1096,7 @@ Each entry: question, answer, rationale, migration path.
|
|
|
1081
1096
|
- **Heaviest entries.** A second table lists the ten heaviest log entries by render-weight, each by its `log:///<coord>/<op>` handle — the FOLD targets behind the turn weight. The handle carries the turn, so the two tables interlock. {§tokenomics-largest-entries}
|
|
1082
1097
|
- **Context-window percent.** The headline carries usage as a percent of the ceiling — `usage Y (P%)` — a fullness gauge beside the absolutes. Reads the ceiling already in hand; no extra provider call. {§tokenomics-context-percent}
|
|
1083
1098
|
- **Depth re-counted at render.** The manifest re-tokenizes each entry's `tokens` through the live provider at build — never the write-time snapshot — so a model change between loops can't stale the catalog. Every token figure in the packet is render-fresh, manifest and budget alike; nothing trusts a cross-loop cached total.
|
|
1084
|
-
- **
|
|
1099
|
+
- **The delivered packet is never over budget.** The readout shows the state of the packet the model actually has, and the grinder (§grinder) folds any over-ceiling packet back under *before* it is sent — so a delivered budget headline is always usage ≤ ceiling, percent ≤ 100, free ≥ 0. The percent is of the **post-fold** packet; the pre-fold overshoot is engine trivia the model never sees. A packet that can't be folded under even fully collapsed is the corner case: the loop **hard-413s** rather than deliver an over-budget packet. Its STORED failure record renders the overshoot honestly — `free` floors at 0 (never negative), the percent passes 100 — never clamped to hide the degenerate state, but never the model's reasoning surface either. {§tokenomics-over-budget-floor}
|
|
1085
1100
|
|
|
1086
1101
|
**Rejected / obviated.**
|
|
1087
1102
|
|
|
@@ -1100,7 +1115,7 @@ Each entry: question, answer, rationale, migration path.
|
|
|
1100
1115
|
|
|
1101
1116
|
**Tier — session is the world; permissions are the session's.** Membership, the overlay, and the git flags are **session-tier** (`session_constraints.session_id`, service/session config) — never per-run. Every run in a session shares one world (§machine-processes: one filesystem, one overlay); a run is a *log* — a perspective over that world — owning no membership of its own. A declaration reshapes the one world for every run, never per-connection. `runs.origin` is attribution (whose perspective), not a permission.
|
|
1102
1117
|
|
|
1103
|
-
**Workspace identity.** No `projects` table; `sessions.project_root TEXT` (nullable = headless) anchors the workspace. `entries.scope
|
|
1118
|
+
**Workspace identity.** No `projects` table; `sessions.project_root TEXT` (nullable = headless) anchors the workspace. `entries.scope ∈ {'session','run'}` (agent-scope retired). Workspace = session; no users/auth/multi-tenant.
|
|
1104
1119
|
|
|
1105
1120
|
**git is the substrate.** {§membership-git-membership} git-tracked files (`git ls-files`) are members with no explicit overlay — channel-less markers, disk is truth. git absent → no fs-walk (non-git/headless get no substrate membership); `pick` is then the sole source.
|
|
1106
1121
|
|
|
@@ -1131,11 +1146,11 @@ The CAS is the **hard backstop**, at the moment of writing, on every accept path
|
|
|
1131
1146
|
|
|
1132
1147
|
**Rationale.** Session is the right scope unit; membership *is* the curation, outsourced and tiered: git bounds it by tracking, the client supersedes by overlay, the model curates its own render by READ/FOLD — the engine curates nothing. The forest falls out of "session = world": one workspace can be many repos, so membership is their union, declared not guessed (the scan and its security are the client's). Exhaustiveness is a property of *coverage*, not *work*: every member is checked every turn so no drift hides, but unchanged members cost only a detect — the full-repo cost is git's to bound (what it tracks) and the client's to bound (`hide`), never the engine's to pay re-reading what hasn't moved.
|
|
1133
1148
|
|
|
1134
|
-
**Migration path.** `session_constraints.effect` gains `repo`; the three renames (`add`→`pick`, `ignore`→`hide`, `read-only`→`view`) are wire-surface changes on `session.constrain`. Forest resolution iterates declared repos (was one `ls-files` at root). The change-detect adds a per-member stored signal — mtime+size or content hash, and *that choice is the EMI reliability bound* — gating the existing materialize. `PLURNK_GIT_ALLOWED`
|
|
1149
|
+
**Migration path.** `session_constraints.effect` gains `repo`; the three renames (`add`→`pick`, `ignore`→`hide`, `read-only`→`view`) are wire-surface changes on `session.constrain`. Forest resolution iterates declared repos (was one `ls-files` at root). The change-detect adds a per-member stored signal — mtime+size or content hash, and *that choice is the EMI reliability bound* — gating the existing materialize. `PLURNK_GIT_ALLOWED` (the hard ceiling) and `PLURNK_GIT_AUTO` (the default declaration) are the git flags (§membership-git-flags). Tenancy / cross-session shared workspaces still require a `workspaces` table lifting constraints off `session_constraints`.
|
|
1135
1150
|
|
|
1136
1151
|
### §grinder Budget enforcement: the grinder
|
|
1137
1152
|
|
|
1138
|
-
**Question.** §tokenomics surfaces the budget honestly and the model curates against `tokensFree` — almost always enough. Two states defeat self-regulation, neither the model's doing: a jumbo prompt (the turn-0 environment), and an unexpectedly large read. (A jumbo repo is no longer its own case — with no index nothing auto-renders the repo; it surfaces only as a large `
|
|
1153
|
+
**Question.** §tokenomics surfaces the budget honestly and the model curates against `tokensFree` — almost always enough. Two states defeat self-regulation, neither the model's doing: a jumbo prompt (the turn-0 environment), and an unexpectedly large read. (A jumbo repo is no longer its own case — with no index nothing auto-renders the repo; it surfaces only as a large catalog `FIND`, which the model pages like any big result.) What enforces the ceiling when the signal isn't enough?
|
|
1139
1154
|
|
|
1140
1155
|
**Decision — a pre-LLM grinder, fired only on actual overflow.** In `Engine.runTurn`, after the packet is assembled (`#buildRequestPacket`) and before `provider.generate`, the assembled render-weight (§tokenomics) is measured against the ceiling. At or under → the packet ships untouched; the grinder never trims speculatively or "helpfully." {§grinder-overflow-only} On overflow it reverts the prior turn, then hard-stops if that isn't enough:
|
|
1141
1156
|
|
|
@@ -1146,7 +1161,7 @@ The CAS is the **hard backstop**, at the moment of writing, on every accept path
|
|
|
1146
1161
|
|
|
1147
1162
|
**What the model sees.** A `budget_overflow` telemetry event (§telemetry), in the model's own terms: which of its entries left the window, by scheme. No mechanism vocabulary — no "layer," no "grinder," no "reclaim" — and no advice. The engine reports *what happened to the model's world*; the budget readout (§tokenomics) — its turn and entry weights — is the diagnostic surface, and the model — which can see what changed in its repo, its reads, its turn — diagnoses the cause the engine can't attribute. {§grinder-event-model-terms} Per the gamification policy (§telemetry), the *strike* the overflow triggers stays engine-internal; the model sees the hidden entries, never the accounting.
|
|
1148
1163
|
|
|
1149
|
-
**Rationale.** The model owns curation (§tokenomics); the grinder is the exceptional backstop. It only *folds* — reversibly — the prior turn's render; nothing is deleted, so the model can OPEN it back and log history stays intact. Rummy's
|
|
1164
|
+
**Rationale.** The model owns curation (§tokenomics); the grinder is the exceptional backstop. It only *folds* — reversibly — the prior turn's render; nothing is deleted, so the model can OPEN it back and log history stays intact. Rummy's own spec described clearing log *bodies*, but its code instead folded the prior turn whole — because body-clearing is destructive (it deletes the read result) and bespoke. The code was the lesson; plurnk follows it.
|
|
1150
1165
|
|
|
1151
1166
|
**Migration path.** None on mechanism. Speculative or non-overflow trimming is a different feature, deliberately excluded — the grinder fires only in response to actual overflow.
|
|
1152
1167
|
|
|
@@ -1203,7 +1218,7 @@ The CAS is the **hard backstop**, at the moment of writing, on every accept path
|
|
|
1203
1218
|
|
|
1204
1219
|
```ts
|
|
1205
1220
|
type PacketSection = {
|
|
1206
|
-
name: string; // stable id: definition, tools, schemes,
|
|
1221
|
+
name: string; // stable id: definition, tools, schemes, system-policy, project-policy, budget, prompt, errors, log, git, requirements — or a plugin's own
|
|
1207
1222
|
slot: "system" | "user"; // the prompt-cache boundary; system-slot sections build the cache-stable system message
|
|
1208
1223
|
header: string | null; // "## Plurnk System X", or null (definition renders verbatim)
|
|
1209
1224
|
content: string; // rendered markdown — what the model saw
|
|
@@ -1226,7 +1241,12 @@ The wire projection (`PacketWire.renderSlot`) groups sections by slot into the s
|
|
|
1226
1241
|
|
|
1227
1242
|
### §telemetry user.telemetry — model-facing runtime telemetry
|
|
1228
1243
|
|
|
1229
|
-
|
|
1244
|
+
The model's runtime alert surface, with two sources by lifetime:
|
|
1245
|
+
|
|
1246
|
+
- **Errors are LOG ITEMS.** A model FAILURE — a parse failure (an actionless `op='error'` row) or an action that returned `status_rx ≥ 400` — is a durable `log_entries` row. It folds, kills, and budgets like any log entry (§open-fold), so the model recalls or curates its own mistakes and the grinder's prior-turn rollback can reclaim them — ONE budget surface, the log. The `errors` section is a derived POINTER INDEX over the recent `status_rx ≥ 400` rows (status + `log:///<coord>`), aiming the model at them; it holds no bodies and no fold/budget state of its own. Durable: an error persists until the model folds or kills it, not "cleared once seen."
|
|
1247
|
+
- **Engine NOTICES are telemetry.** Ephemeral events the engine emits while steering or narrating — a provider's `grammar_unenforced`, a `max_commands_exceeded` truncation, a `budget_overflow` fold, the premature-terminate/idle steers. Transient: they appear on the turn AFTER the event and drain once seen (`packet.telemetryErrors` is their only home). They are NOT the model's failures.
|
|
1248
|
+
|
|
1249
|
+
The `log` section is the durable audit; the `errors` section surfaces both — the error pointers (durable, in the log) and the notices (ephemeral).
|
|
1230
1250
|
|
|
1231
1251
|
**Grammar contract:**
|
|
1232
1252
|
|
|
@@ -1236,27 +1256,25 @@ Telemetry the model MUST react to immediately. Errors render in their own `error
|
|
|
1236
1256
|
**Plurnk-service rendering:**
|
|
1237
1257
|
|
|
1238
1258
|
- `budget` per §tokenomics: turn-weight and heaviest-entries tables with `tokenCeiling`/`tokenUsage`/`tokensFree`.
|
|
1239
|
-
-
|
|
1240
|
-
-
|
|
1259
|
+
- **Error pointers** — derived from the previous turn's `log_entries WHERE status_rx ≥ 400`: `kind: "action_failure"`, `coordinate` (`<L>/<T>/<S>`), `op` (`error` for a parse failure, the real op for a failed action), `status`, `target`, and a terse `error`. The full body (message, snippet) lives on the foldable log row, not here.
|
|
1260
|
+
- **Notices** — one `* {canonical-JSON}` line per event under `## Plurnk System Errors`, push order; flat kind-specific fields (NO nested `detail`); canonical-JSON sorts keys for prefix-cache friendliness. The notice buffer drains on read — each appears on exactly one packet. {§telemetry-drain-on-read}
|
|
1241
1261
|
- **No prose `message` field.** Errors carry structured facts. The `kind` is the alert; the named fields are the data. Guidance, advice, hints, and exhortation MUST NOT appear in telemetry. Letting the model infer what to do from facts (and the log) beats handing it instructions it will second-guess.
|
|
1242
1262
|
- **Gamification policy (rummy precedent, plugins/error/error.js).** The model sees errors that **happened** — its actions failed, its emission didn't parse, its ops were truncated. The model does NOT see the engine's accounting *about* errors: strike streaks, cycle detection, sudden-death thresholds, no-ops bookkeeping. Surfacing internal state creates a gamification surface where the model optimizes for engine metrics (manufacturing a clean turn to reset the strike counter, e.g.) instead of the task. Engine bookkeeping drives abandonment silently; the model just sees its actual failures.
|
|
1243
1263
|
|
|
1244
|
-
**
|
|
1264
|
+
**The error pointer + the engine NOTICE kinds:**
|
|
1245
1265
|
|
|
1246
1266
|
| `kind` | Source | Required fields |
|
|
1247
1267
|
|---|---|---|
|
|
1248
|
-
| `
|
|
1249
|
-
| `
|
|
1268
|
+
| `action_failure` | The DERIVED error pointer — any `log_entries` row with `status_rx ≥ 400` from the previous turn: a parse failure's actionless `op='error'` row, or a failed action | `kind`, `coordinate` (`<L>/<T>/<S>`), `op`, `status`, `target` (URI or null). May carry a terse `error` fact. The full message/snippet lives on the foldable log row. |
|
|
1269
|
+
| `grammar_unenforced` | (provider, forwarded) GBNF-filter divergence — the model's bytes diverged from the transported grammar | `source: "provider:*"`, `kind`, `message`, `position` (content-offset), `snippet` |
|
|
1250
1270
|
| `max_commands_exceeded` | Single emission exceeded `PLURNK_MAX_COMMANDS` cap; overflow ops dropped without dispatch | `source: "engine:rail"`, `kind`, `emitted`, `dropped` |
|
|
1251
1271
|
| `budget_overflow` | Assembled packet exceeded the budget ceiling; entries moved out of the window to fit | `source: "engine:rail"`, `kind`, `hidden` (per-scheme `[{scheme, count}]` — entries removed from the window) |
|
|
1252
1272
|
|
|
1253
|
-
Strike accounting, cycle detection, sudden-death thresholds, and no-ops bookkeeping
|
|
1254
|
-
|
|
1255
|
-
**No `error://` scheme.** Actionless failures route to telemetry, not a queryable scheme namespace.
|
|
1273
|
+
Strike accounting, cycle detection, sudden-death thresholds, and no-ops bookkeeping stay engine-internal — they drive abandonment silently per the gamification policy. Both error kinds — a failed action AND an actionless parse failure — are LOG ITEMS (`log:///<coord>`, `op='error'` for the latter, `status_rx ≥ 400`), foldable and re-OPENable, with full detail (message, snippet) on the row. The `errors` section surfaces a derived pointer to each. There is **no bespoke `error://` scheme**: errors live in the log, addressable + curatable like any row — not a separate queryable namespace. {§telemetry-no-error-scheme}
|
|
1256
1274
|
|
|
1257
|
-
**Client surface
|
|
1275
|
+
**Client surface.** Engine NOTICES broadcast live via the `telemetry/event` WS notification — same envelope as the model's drained copy (`{ source, kind, message?, position?, …kind-specific }` per the grammar's `TelemetryEvent` schema), the moment they land, scoped to the loop's session (a `grammar_unenforced` snippet in a debug panel, a session timeline). ERRORS do not broadcast on this surface: they are log rows, and the client reads them the same way the model curates them — `log.read` / the `log/entry` notification, the durable log. {§telemetry-telemetry-event-notify}
|
|
1258
1276
|
|
|
1259
|
-
**Content-offset snippet rendering.** When
|
|
1277
|
+
**Content-offset snippet rendering.** When a NOTICE carries `position: { type: "content-offset", line, column }` (e.g. a provider's `grammar_unenforced`), plurnk-service extracts a ±N-line slice from the model's own prior `assistant.content` and renders it as an `N:\t`-prefixed heredoc under an `error://<line>` fence, immediately following the event meta line. Without the snippet, the model gets "invalid xpath at 1:0" with no way to trace what it wrote at 1:0 — and tends to regenerate the same broken emission. With it, recovery is direct. The snippet field is stripped from the meta JSON so it appears once, in the body block. (A parse-error LOG ROW carries the equivalent snippet in its own foldable body — the same locator, durable in the log.) {§telemetry-content-offset-snippet}
|
|
1260
1278
|
|
|
1261
1279
|
### §tools user.tools — the capability sheet
|
|
1262
1280
|
|
|
@@ -1270,7 +1288,11 @@ A `## Plurnk System Schemes` section renders in the system slot **after the defi
|
|
|
1270
1288
|
|
|
1271
1289
|
### §inject system.inject — the operator injection
|
|
1272
1290
|
|
|
1273
|
-
When `PLURNK_PACKET_INJECT` names a readable markdown file, its content renders as a `## Plurnk Operator Notes` section in the system slot **right after the teaching** (definition → tools → schemes → inject),
|
|
1291
|
+
When `PLURNK_PACKET_INJECT` names a readable markdown file, its content renders as a `## Plurnk Operator Notes` section in the system slot **right after the teaching** (definition → tools → schemes → inject), ahead of the policy sections and budget — part of the cached prefix. Read per-turn so the operator's edits take effect live; a set-but-unreadable path fails the turn hard (a deliberate setting with a broken path is a misconfig, surfaced not hidden). `~/` expands to home. It's the operator-side complement to the plugin section hook — a pressure valve so reshaping the packet edits operator content, never the core. Unset → no section. {§packet-inject}
|
|
1292
|
+
|
|
1293
|
+
### §policy system.policy — the client's policy injection
|
|
1294
|
+
|
|
1295
|
+
Two sections ride the system slot **below the operator notes, above budget**: `## Plurnk System Policy` from `PLURNK_POLICY` (default `~/.plurnk/AGENTS.md`) and `## Project Policy` from `PLURNK_PROJECT` (default `<projectRoot>/AGENTS.md`, resolved relative to the session root). AGENTS.md is **policy** — the client's authoritative rules promoted into the privileged zone — NOT a curatable, foldable, READ-able entry; the model cannot FOLD it away. A default-absent path is silent (the section is omitted); an explicit override (env set) that fails to read fails the turn hard — a deliberate setting with a broken path is a misconfig, surfaced not hidden. Read per-turn so edits take effect live. Reference/scratch docs are NOT policy — they ride `PLURNK_MD_*` (materialized as READ-able entries, §operator-config), which is where the dev-notes AGENTS.md used to hold belong. {§policy-sections}
|
|
1274
1296
|
|
|
1275
1297
|
**The scheme self-doc contract.** `example` is the hot-path one-liner; `documentation` is the deep doc — the exact shape execs already use (`example` + `documentation`). `SchemeRegistry.teach()` renders the directory; `docEntries()` materializes the docs (per loop.run, alongside the operator docs). `documentation` rides a service-side `SchemeManifest` extension until plurnk-schemes#25 lands it in the contract.
|
|
1276
1298
|
|
|
@@ -1288,42 +1310,38 @@ Rendered at the END of the user packet under `## Plurnk System Requirements` {§
|
|
|
1288
1310
|
|
|
1289
1311
|
Body matchers and `<L>` both dispatch on entry mimetype. Body matcher: leading-char classification (`//` xpath, `/` regex, `$` jsonpath, otherwise glob). `<L>`: line-navigable → by line, structured → by item.
|
|
1290
1312
|
|
|
1291
|
-
### §matcher-dispatch Matcher dispatch (
|
|
1313
|
+
### §matcher-dispatch Matcher dispatch (service-owned, over daughter primitives)
|
|
1292
1314
|
|
|
1293
|
-
`matchAgainstContent` (
|
|
1315
|
+
`Matcher.matchAgainstContent` (in-tree, `src/content/matcher.ts`) is the **service's own** dialect dispatch — `Mimetypes.query` is NOT consumed (§mimetype-methods). It switches on the matcher's dialect and calls the daughter's individual primitives: `glob → queryGlob` and `regex → queryRegex` over the raw content; `jsonpath → queryJsonpathObject` over the `deepJson` projection and `xpath → queryXpathString` over `deepXml` (both pulled from `mimetypes.process({channels})`, so a structural dialect works over any source type). `~semantic` is service-side and parked; `@graph` resolves over the service's own symbol indexes (§mimetype-methods). Each returns `QueryMatch[]`, rendered as `<source-line>:\t<value>`. Status mapping:
|
|
1294
1316
|
|
|
1295
|
-
|
|
|
1317
|
+
| Result | HTTP status |
|
|
1296
1318
|
|---|---|
|
|
1297
|
-
| `UnsupportedDialectError` | 415 |
|
|
1298
|
-
| `InvalidExpressionError` | 400 |
|
|
1299
|
-
| `QueryParseFailureError` | 203 (soft fallback: raw content as `text/markdown` with `reason`) |
|
|
1300
|
-
| Empty match array | 204 |
|
|
1301
1319
|
| Match array | 200 |
|
|
1320
|
+
| Empty match array | 204 |
|
|
1321
|
+
| Malformed matcher expression | 400 |
|
|
1322
|
+
| Source unparseable for its mimetype | 203 (soft fallback: raw content as text with `reason`) |
|
|
1323
|
+
| `~semantic` (parked, needs vector design) | 501 |
|
|
1302
1324
|
|
|
1303
1325
|
203 is HTTP-creative ("Non-Authoritative Information"). On parse failure, returns raw bytes as text primitive with `reason` so the model can fall back to regex/visual parsing or fix source. {§matcher-dispatch-203-soft-fallback}
|
|
1304
1326
|
|
|
1305
1327
|
Glob anchoring (`TODO*` starts-with, `*TODO*` contains, `*.log` ends-with, `[Tt]odo*` char class) lives in framework's `BaseHandler`.
|
|
1306
1328
|
|
|
1307
|
-
### §matcher-result Matcher result shape
|
|
1329
|
+
### §matcher-result Matcher result shape — READ returns matching LINES, uniformly
|
|
1308
1330
|
|
|
1309
|
-
|
|
1331
|
+
The contract is the grammar's: **plurnk.md §"`<Line> / <Result>`" — "FIND returns rows of results, READ returns lines of content"**, and READ "prefixes every line with line numbers and a hard tab, `N:\t`" (one source-line number, not part of the source). This section documents the service's implementation of that.
|
|
1310
1332
|
|
|
1311
|
-
|
|
1312
|
-
- `<value>` — the extracted match, rendered bare when it is a single-line string, else JSON-encoded (preserving the one-match-per-line invariant). Polymorphic per dialect:
|
|
1313
|
-
- **bare regex** → string (full match)
|
|
1314
|
-
- **anon captures** → array `[c1, c2, …]`
|
|
1315
|
-
- **named captures** → object `{name: v, …}`. Mixed anon+named uses positional keys `"1"`, `"2"` alongside names.
|
|
1316
|
-
- **glob** → string (matching source line)
|
|
1317
|
-
- **jsonpath** → JSON value at the path
|
|
1318
|
-
- **xpath text/attr** → string
|
|
1319
|
-
- **xpath node** → serialized XML
|
|
1333
|
+
**A matcher selects locations; it never extracts a value.** Every dialect identifies *where* in the source it matches; READ returns the **source line(s)** at those locations, faithfully — one shape for every dialect: `<line>:\t<line-content>`, prefixed with the single source-line number per plurnk.md (shifted back to source coordinates inside an `<L>` slice), never double-numbered. Empty → 204; mimetype `text/markdown` regardless of source. The model reads the line and adapts whatever it needs out of it — READ never pre-chews a match down to a bare value. {§matcher-result-read-returns-lines}
|
|
1320
1334
|
|
|
1321
|
-
| Dialect |
|
|
1335
|
+
| Dialect | Selects | Natural use |
|
|
1322
1336
|
|---|---|---|
|
|
1323
|
-
| regex `/pat/` |
|
|
1324
|
-
| glob `pat` |
|
|
1325
|
-
| jsonpath `$.path` |
|
|
1326
|
-
| xpath `//sel` |
|
|
1337
|
+
| regex `/pat/` | the lines the pattern occurs in (it *matches*, never captures-and-extracts) | the lines mentioning X |
|
|
1338
|
+
| glob `pat` | the lines the glob matches | the lines containing TODO |
|
|
1339
|
+
| jsonpath `$.path` | the line(s) where the structural path resolves | the line defining `host` |
|
|
1340
|
+
| xpath `//sel` | the line(s) of the selected node (text/html) | the line(s) of the h1 |
|
|
1341
|
+
|
|
1342
|
+
Across a **multi-file target** (a glob over `(target)`), READ returns **one log item per file that contains a match**, each holding that file's matching lines — READ is the content retrieval over the whole matched set, the companion to FIND's survey (§find-result-catalog-rows). *(Engine-level fan-out; tagged + tested when built.)*
|
|
1343
|
+
|
|
1344
|
+
> **Implementation requirement (a mimetypes-daughter need, not a contract exception):** structural dialects must report the SOURCE LINE of each hit. regex/glob match over raw content (the line is in hand); jsonpath/xpath run over the parsed `deepJson`/`deepXml` projection and today return the value — the match primitive must instead carry the line span of each hit, so READ can return the line.
|
|
1327
1345
|
|
|
1328
1346
|
### §slice-semantics `<L>` semantics by source mimetype
|
|
1329
1347
|
|
|
@@ -1421,7 +1439,7 @@ Carried from the contract walk; durable.
|
|
|
1421
1439
|
- **EDIT `<L>` on non-existent entry** → body becomes content; `<L>` is positional-only on existing content.
|
|
1422
1440
|
- **COPY `<L>`** → source range, symmetric with READ `<L>`.
|
|
1423
1441
|
- **READ rx** prefixes each line with `N:\t` per §render-rule. `sliceLinesRaw` (used by COPY) returns the lines without prefix.
|
|
1424
|
-
- **FIND body matcher** applies to entry content (all dialects), per-candidate via `Matcher.matchAgainstContent`
|
|
1442
|
+
- **FIND body matcher** applies to entry content (all dialects), per-candidate via the in-tree `Matcher.matchAgainstContent` (§matcher-dispatch; status 200 = content hit → entry selected). Scope + tags select candidates in SQL; the path-glob is the (target).
|
|
1425
1443
|
- **OPEN/FOLD** operate on the **log** (`log:///`), not entries (§open-fold) — FOLD collapses a log row to its path, OPEN restores its body. Aimed at an entry scheme they return 501.
|
|
1426
1444
|
- **SEND[410]** deletes as a side-effect (not the model idiom; §move): with `#fragment`, that channel only; without, the whole entry. **SEND[499]** is owned by the streaming scheme that holds the subscription.
|
|
1427
1445
|
- **File scheme** reads disk content with mimetype detected via `Mimetypes.detect({ path })` (plumbed through `PlurnkSchemeContext.mimetypes`). Binary mimetypes → 415 on READ and EDIT.
|