@plurnk/plurnk-service 1.2.0 → 1.3.1
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.defaults +7 -5
- package/README.md +16 -13
- package/SPEC.md +72 -130
- package/dist/build-info.d.ts +11 -0
- package/dist/build-info.d.ts.map +1 -0
- package/dist/build-info.js +45 -0
- package/dist/build-info.js.map +1 -0
- package/dist/build-info.json +1 -0
- package/dist/content/index.js +1 -1
- package/dist/content/index.js.map +1 -1
- package/dist/content/line-marker.js +1 -1
- package/dist/content/line-marker.js.map +1 -1
- package/dist/content/matcher.js +4 -4
- package/dist/content/matcher.js.map +1 -1
- package/dist/content/mimetype-binary.js +1 -1
- package/dist/content/mimetype-binary.js.map +1 -1
- package/dist/content/path-mimetype.js +1 -1
- package/dist/content/path-mimetype.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 +6 -19
- package/dist/core/ChannelWrite.js.map +1 -1
- package/dist/core/ChannelWrite.sql +2 -7
- package/dist/core/Dispatcher.d.ts.map +1 -1
- package/dist/core/Dispatcher.js +92 -23
- package/dist/core/Dispatcher.js.map +1 -1
- package/dist/core/Engine.d.ts +9 -0
- package/dist/core/Engine.d.ts.map +1 -1
- package/dist/core/Engine.js +126 -30
- package/dist/core/Engine.js.map +1 -1
- package/dist/core/Engine.sql +8 -6
- package/dist/core/PacketBuilder.js +1 -1
- package/dist/core/PacketBuilder.js.map +1 -1
- package/dist/core/PluginLoader.d.ts +1 -0
- package/dist/core/PluginLoader.d.ts.map +1 -1
- package/dist/core/PluginLoader.js +30 -11
- package/dist/core/PluginLoader.js.map +1 -1
- package/dist/core/ProposalLifecycle.d.ts.map +1 -1
- package/dist/core/ProposalLifecycle.js +11 -6
- package/dist/core/ProposalLifecycle.js.map +1 -1
- package/dist/core/ProviderInstantiate.d.ts +1 -0
- package/dist/core/ProviderInstantiate.d.ts.map +1 -1
- package/dist/core/ProviderInstantiate.js +8 -0
- package/dist/core/ProviderInstantiate.js.map +1 -1
- package/dist/core/SchemeRegistry.d.ts +3 -1
- package/dist/core/SchemeRegistry.d.ts.map +1 -1
- package/dist/core/SchemeRegistry.js +18 -11
- package/dist/core/SchemeRegistry.js.map +1 -1
- package/dist/core/caps/DbEntryCaps.d.ts +6 -5
- package/dist/core/caps/DbEntryCaps.d.ts.map +1 -1
- package/dist/core/caps/DbEntryCaps.js +36 -14
- package/dist/core/caps/DbEntryCaps.js.map +1 -1
- package/dist/core/caps/DbProjectionCaps.d.ts +11 -0
- package/dist/core/caps/DbProjectionCaps.d.ts.map +1 -0
- package/dist/core/caps/DbProjectionCaps.js +19 -0
- package/dist/core/caps/DbProjectionCaps.js.map +1 -0
- package/dist/core/caps/DbSubscriptionCaps.d.ts +3 -1
- package/dist/core/caps/DbSubscriptionCaps.d.ts.map +1 -1
- package/dist/core/caps/DbSubscriptionCaps.js +12 -3
- package/dist/core/caps/DbSubscriptionCaps.js.map +1 -1
- package/dist/core/caps/SchemeCtxImpl.d.ts +18 -4
- package/dist/core/caps/SchemeCtxImpl.d.ts.map +1 -1
- package/dist/core/caps/SchemeCtxImpl.js +26 -12
- package/dist/core/caps/SchemeCtxImpl.js.map +1 -1
- package/dist/core/fork.sql +4 -4
- package/dist/core/git-membership.d.ts.map +1 -1
- package/dist/core/git-membership.js +23 -0
- package/dist/core/git-membership.js.map +1 -1
- package/dist/core/packet-wire.d.ts.map +1 -1
- package/dist/core/packet-wire.js +3 -2
- package/dist/core/packet-wire.js.map +1 -1
- package/dist/core/path-decode.d.ts +1 -0
- package/dist/core/path-decode.d.ts.map +1 -1
- package/dist/core/path-decode.js +1 -0
- package/dist/core/path-decode.js.map +1 -1
- package/dist/core/plurnk-uri.d.ts.map +1 -1
- package/dist/core/plurnk-uri.js +5 -3
- package/dist/core/plurnk-uri.js.map +1 -1
- package/dist/core/resolveForLoop.js +1 -1
- package/dist/core/resolveForLoop.js.map +1 -1
- package/dist/core/results.js +2 -2
- package/dist/core/results.js.map +1 -1
- package/dist/core/scheme-types.d.ts.map +1 -1
- package/dist/core/scheme-types.js +1 -1
- package/dist/core/scheme-types.js.map +1 -1
- package/dist/core/search-gate.js +2 -2
- package/dist/core/search-gate.js.map +1 -1
- package/dist/digest/Digest.d.ts.map +1 -1
- package/dist/digest/Digest.js +79 -8
- package/dist/digest/Digest.js.map +1 -1
- package/dist/digest/digest.sql +1 -1
- package/dist/schemes/Exec.d.ts +2 -4
- package/dist/schemes/Exec.d.ts.map +1 -1
- package/dist/schemes/Exec.js +41 -11
- package/dist/schemes/Exec.js.map +1 -1
- package/dist/schemes/File.d.ts.map +1 -1
- package/dist/schemes/File.js +1 -0
- package/dist/schemes/File.js.map +1 -1
- package/dist/schemes/Log.d.ts.map +1 -1
- package/dist/schemes/Log.js +20 -0
- package/dist/schemes/Log.js.map +1 -1
- package/dist/schemes/Prompt.d.ts +6 -7
- package/dist/schemes/Prompt.d.ts.map +1 -1
- package/dist/schemes/Prompt.js +4 -5
- package/dist/schemes/Prompt.js.map +1 -1
- package/dist/schemes/Skill.d.ts +7 -9
- package/dist/schemes/Skill.d.ts.map +1 -1
- package/dist/schemes/Skill.js +5 -7
- package/dist/schemes/Skill.js.map +1 -1
- package/dist/schemes/Worker.d.ts.map +1 -1
- package/dist/schemes/Worker.js +1 -0
- package/dist/schemes/Worker.js.map +1 -1
- package/dist/schemes/_entry-chunk.d.ts +4 -1
- package/dist/schemes/_entry-chunk.d.ts.map +1 -1
- package/dist/schemes/_entry-chunk.js +76 -22
- package/dist/schemes/_entry-chunk.js.map +1 -1
- package/dist/schemes/_entry-crud.d.ts.map +1 -1
- package/dist/schemes/_entry-crud.js +0 -10
- package/dist/schemes/_entry-crud.js.map +1 -1
- package/dist/schemes/_entry-find.d.ts.map +1 -1
- package/dist/schemes/_entry-find.js +9 -8
- package/dist/schemes/_entry-find.js.map +1 -1
- package/dist/schemes/_entry-find.sql +1 -1
- package/dist/schemes/_entry-graph.d.ts +1 -1
- package/dist/schemes/_entry-graph.d.ts.map +1 -1
- package/dist/schemes/_entry-graph.js +13 -12
- package/dist/schemes/_entry-graph.js.map +1 -1
- package/dist/schemes/_entry-graph.sql +49 -13
- package/dist/schemes/_entry-manifest.d.ts +5 -2
- package/dist/schemes/_entry-manifest.d.ts.map +1 -1
- package/dist/schemes/_entry-manifest.js +135 -123
- package/dist/schemes/_entry-manifest.js.map +1 -1
- package/dist/schemes/_entry-ops.d.ts.map +1 -1
- package/dist/schemes/_entry-ops.js +0 -4
- package/dist/schemes/_entry-ops.js.map +1 -1
- package/dist/schemes/_entry-semantic.d.ts +12 -5
- package/dist/schemes/_entry-semantic.d.ts.map +1 -1
- package/dist/schemes/_entry-semantic.js +57 -70
- package/dist/schemes/_entry-semantic.js.map +1 -1
- package/dist/schemes/_entry-semantic.sql +22 -53
- package/dist/server/Daemon.d.ts +4 -3
- package/dist/server/Daemon.d.ts.map +1 -1
- package/dist/server/Daemon.js +109 -44
- package/dist/server/Daemon.js.map +1 -1
- package/dist/server/auto.d.ts +6 -0
- package/dist/server/auto.d.ts.map +1 -0
- package/dist/server/{yolo.js → auto.js} +15 -16
- package/dist/server/auto.js.map +1 -0
- package/dist/server/client-input.d.ts +1 -1
- package/dist/server/client-input.d.ts.map +1 -1
- package/dist/server/client-input.js +11 -4
- package/dist/server/client-input.js.map +1 -1
- package/dist/server/drain.sql +6 -3
- package/dist/server/loopDocs.d.ts.map +1 -1
- package/dist/server/loopDocs.js +2 -2
- package/dist/server/loopDocs.js.map +1 -1
- package/dist/server/noProposals.js +5 -5
- package/dist/server/noProposals.js.map +1 -1
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +18 -5
- package/dist/service.js.map +1 -1
- package/migrations/0000-00-00.01_schema.sql +55 -48
- package/migrations/0004_loop-provider.sql +3 -0
- package/migrations/0005_subscription-published-channel.sql +3 -0
- package/package.json +27 -22
- package/dist/server/yolo.d.ts +0 -6
- package/dist/server/yolo.d.ts.map +0 -1
- package/dist/server/yolo.js.map +0 -1
package/SPEC.md
CHANGED
|
@@ -2,8 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
Canonical contracts plurnk-service exposes, architecture it implements, promises it makes to the constellation (`plurnk-grammar`, `plurnk-providers`, `plurnk-schemes`, `plurnk-mimetypes`, `plurnk-execs`, the user-facing `plurnk` CLI). `AGENTS.md` covers process; this file covers contract.
|
|
4
4
|
|
|
5
|
-
The `§` sigil marks one thing: a stable terse tag. A section is a tag (`§discovery`); a promise under it is a child tag (`§discovery-discover`) whose prefix names its section. Headings, prose cross-refs, and promise anchors all use this one namespace — no digits, so renumbering is a non-event. Promise anchors `{§<tag>}` mark individual assertions; tests cite them in their names (`test("[§<tag>] …", …)`). `test/intg/spec-anchors.test.ts` fails on orphan citations and reports gaps. Anchors are drift-grounding, not a forcing function.
|
|
6
|
-
|
|
7
5
|
---
|
|
8
6
|
|
|
9
7
|
## §glossary Glossary
|
|
@@ -63,8 +61,8 @@ Independent axes on entries and channels. Confusion across them is a recurring s
|
|
|
63
61
|
| **cycle** | A repeated turn fingerprint across consecutive turns. Detected silently; model never sees the trigger. Strike accumulates internally. |
|
|
64
62
|
| **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
63
|
| **mode** | `"ask" \| "act"`. Per-loop. Ask = read-only: the dispatch gate refuses every side-effecting op (a filesystem write — EDIT/COPY-dest/MOVE/KILL on the `file` scheme — or an EXEC host runtime, §exec-excluded-in-ask); reads of the workspace stay open. `act` = full surface. The ancient contract: ask never changes the world. {§mode-ask-read-only} |
|
|
66
|
-
| **flag** | Per-loop
|
|
67
|
-
| **proposal** | A deferred side-effecting action
|
|
64
|
+
| **flag** | Per-loop value shaping authority or toolset: `auto` (resolve proposals inside the loop), `noWeb`, `noInteraction`, `noProposals`, `mode`. |
|
|
65
|
+
| **proposal** | A deferred side-effecting action. State machine: `proposed → resolved` (accept), `→ failed` (reject), or `→ cancelled` (cancel). With `flags.auto=true`, authority remains inside the loop and resolution is immediate. |
|
|
68
66
|
| **resolution** | Client's accept / reject / cancel of a proposal via the `loop.resolve` RPC (§methods). |
|
|
69
67
|
|
|
70
68
|
### §packet-terms Packet terms
|
|
@@ -102,8 +100,8 @@ Dependency direction (from root to leaf):
|
|
|
102
100
|
- `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
101
|
- `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
102
|
- `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,
|
|
106
|
-
- **`plurnk`** (client) — terminal UI consuming the
|
|
103
|
+
- **`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, and exposes an in-process seam to client-interface modules. Packet assembly is service-owned (§packet).
|
|
104
|
+
- **`plurnk`** (client) — terminal UI consuming the AG-UI+ client surface. Renders daemon events and contains no engine logic.
|
|
107
105
|
|
|
108
106
|
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.
|
|
109
107
|
|
|
@@ -187,7 +185,7 @@ The worker:// scheme makes §machine-processes addressable: a `worker://` target
|
|
|
187
185
|
- **Spawn** — `WORK(worker://<name>):task` creates a new worker sister (empty log) and starts it with `task` on its first loop. WORK/FORK are the worker-creation verbs (grammar 0.74.55): EDIT is file/entry only, so EDIT on the bare worker entity is a **400** steering to WORK/FORK — the entity is not an entry. A name is **frozen per worker** but **reclaimable across time** (§machine-processes-worker-origin): a name held only by a *terminated* sister is free to reuse — a fresh spawn takes a new row and `worker_resolve_by_name` resolves the newest, the corpse keeping its name in permanent history. A name a *live* sister still holds is a conflict — **409 `worker '<name>' is already running`**, legible at the spawn gate, never a raw store-level uniqueness error. {§worker-scheme-spawn}
|
|
188
186
|
- **irc** — `SEND(worker://<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 worker in the workspace is 404. {§worker-scheme-irc}
|
|
189
187
|
- **Fork** — `FORK(worker://<name>):task` branches the current worker into a **named** sister: its log is deep-copied (§machine-processes-fork-copies-the-log), which continues with `task`; the world is shared, never copied (§machine-processes-fork-shares-the-world). A fork ALSO inherits the worker-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*. WORK and FORK are distinct verbs — WORK spawns a fresh worker, FORK branches the log — and each names the new worker explicitly, so the model addresses it (`KILL`/`SEND`/`READ`) by that name. The legacy auto-name `<parent>-fork-<N>` remains only the internal fallback when Fork is invoked without a name. Inherited loops are copied as **terminal history** (a non-terminal status is clamped): a fork's own work is a fresh loop, so an inherited mid-flight loop never makes the branch look forever-live to the §send-premature-terminate gate. {§worker-scheme-fork} {§worker-scheme-fork-scratch}
|
|
190
|
-
- **Delegation inherits authority.** The live loop a spawn, fork, or irc-raised fresh loop starts with carries the **delegating loop's flags** —
|
|
188
|
+
- **Delegation inherits authority.** The live loop a spawn, fork, or irc-raised fresh loop starts with carries the **delegating loop's flags** — an auto parent delegates auto workers. Flags are a property of the delegation, not of the client connection: a child loop that fell back to defaults would propose every side-effecting op into a resolver-less void (nobody attends a headless worker's review queue; each attempt burns the full proposal timeout — the four-sweep fan-out wedge, where three workers stalled 300s-per-EXEC while the parent slept and the harness watched only the parent). An irc that *resumes* a parked loop leaves that loop's own flags untouched — inheritance applies only where a fresh loop is born. {§worker-delegation-inherits-flags}
|
|
191
189
|
- **A wake re-queue is not a terminal.** A conclusion-wake resumes a 202-blocked loop by re-queueing it (202 → 100); when that lands while the loop's OWN live drain is between turns, the drain **re-claims and continues** (atomic 100 → 102; the injected prompt is already the next turn) — it never reports the re-queue outward. Treating 100 as an externally-imposed terminal broadcast a QUEUED loop as `loop/terminated {finalStatus: 100}` while the DB healed to 200 behind it — a client-facing lie the delegation topology hit on ~30% of runs. {§worker-lifecycle-wake-requeue-not-terminal}
|
|
192
190
|
|
|
193
191
|
All three ride one engine seam — the daemon's inject (active→fold, idle→enqueue+drain) — so the handler creates/branches the worker and hands off; the daemon owns provider + system prompt. FORK/WORK carry the seed task in the body and are their own ops (grammar 0.74.55), dispatched to worker control — never the entry-copy path.
|
|
@@ -201,7 +199,7 @@ Beyond the three creation ops:
|
|
|
201
199
|
|
|
202
200
|
### §run-lifecycle Run lifecycle: the drain, the reap, the passive wake
|
|
203
201
|
|
|
204
|
-
- **A `READ` on a running child is a blocking join, not a poll.** {§join-blocking-collect}
|
|
202
|
+
- **A `READ` on a running child is a blocking join, not a poll.** {§join-blocking-collect} A path-absent `READ(worker://<running-child>)` returns **425** (Too Early) and records a live obligation on the loop. The turn's `SEND[202]` waits for that obligation instead of asking the model to poll. When the child reaches any terminal status, the same loop resumes with the result in its log. Children are bounded by their own turn and strike limits, terminal failure also wakes the parent, and the owed-wake path covers completion before the parent parks. Any `SEND` clears the per-turn arm; `SEND[200]` with a live child remains a premature-termination error. A `<seconds>` timeout-poll is the explicit polling alternative.
|
|
205
203
|
|
|
206
204
|
A worker is a **log plus a cancellation scope** — one `AbortController` per worker, reused while live and replaced only once aborted, so a cancel ends the worker as a unit and a later `loop.run` is never born cancelled. A worker's queued loops are advanced by a **drain**: a single per-worker worker that claims loops atomically (status 100→102) and runs each under the worker's scope. A loop may spawn **streams** (execs) that outlive it; each is a row in the subscription registry (§subscriptions) — the durable record of what the worker holds open. Cancellation and conclusion are defined against these structures, never wall-clock timing.
|
|
207
205
|
|
|
@@ -276,7 +274,7 @@ First path segment = provider plugin; rest = provider's own model id.
|
|
|
276
274
|
|
|
277
275
|
### §mock-provider Mock provider (sibling fixture)
|
|
278
276
|
|
|
279
|
-
`Mock` (exported from `@plurnk/plurnk-providers`) — intg fixture + reference implementation. `{ contextWindow, responses }` constructor; `generate` shifts from the queue. `MockResponse.assistant.ops?: PlurnkStatement[]` is a pre-parsed escape hatch the engine consumes directly when present; production providers don't expose this — and being a
|
|
277
|
+
`Mock` (exported from `@plurnk/plurnk-providers`) — intg fixture + reference implementation. `{ contextWindow, responses }` constructor; `generate` shifts from the queue. `MockResponse.assistant.ops?: PlurnkStatement[]` is a pre-parsed escape hatch the engine consumes directly when present; production providers don't expose this — and being a plugin export, this contract has no service-side `§`-ref. {§mock-provider-mock-fixture}
|
|
280
278
|
|
|
281
279
|
---
|
|
282
280
|
|
|
@@ -286,10 +284,10 @@ Author-facing contract: [plurnk-schemes#1](https://github.com/plurnk/plurnk-sche
|
|
|
286
284
|
|
|
287
285
|
### §scheme-address Address resolution (RFC 3986)
|
|
288
286
|
|
|
289
|
-
Every op targets a URI; the entry key is `(workspace, owner, scheme, pathname)` ({§entry-identity-no-null}). The URI parses per RFC 3986 (`scheme://[authority]/path`)
|
|
287
|
+
Every op targets a URI; the entry key is `(workspace, owner, scheme, pathname)` ({§entry-identity-no-null}). The URI parses per RFC 3986 (`scheme://[authority]/path`). Handler routing and resource identity are separate:
|
|
290
288
|
|
|
291
289
|
- A **registered** scheme is a plurnk namespace: its authority is a leading path segment, folded into the pathname (`Dispatcher.#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}
|
|
292
|
-
- A **
|
|
290
|
+
- A **network resource** keeps its addressed protocol while folding the host into the storage pathname: `https://example.com/page` → `(https, /example.com/page)`. `https` may route through the registered `http` handler, just as `ws` routes through `wss`; that implementation alias never aliases identities. Absolute network URLs are single resources even when their path ends `/` — folder/glob expansion belongs to entry namespaces, never an HTTP origin.
|
|
293
291
|
- The **`file` class is the workspace filesystem** — a mount namespace with its own resolution and naming law, specified below.
|
|
294
292
|
|
|
295
293
|
**The workspace is a mount namespace; `project_root` is the model's `/`.** {§fs-namespace} Chroot semantics: host paths do not exist inside the jail, and no engine surface ever folds a host-absolute spelling onto a member (the convenience fold was run59's biography — #545). The root is **fixed immutably at workspace creation** (headless is forever); the namespace's mount table changes only through the declared membership overlay (§membership), never by re-rooting. At `project_root = /` the jail is the whole filesystem and every rule below degenerates to identity — the design's proof case, and the common benchmark topology.
|
|
@@ -349,7 +347,7 @@ Directed SEND (non-null path) routes to scheme's `send`. Status = intent:
|
|
|
349
347
|
- `SEND[200](path)` — write body into resource (WS message, exec stdin).
|
|
350
348
|
- `SEND[499](path)` — cancel active subscription (§stream).
|
|
351
349
|
|
|
352
|
-
- **Log speaks the universal query contract** {§log-uniform-query} — `FIND(log://…)` works like every scheme's FIND (the jumbo model ran the canon-documented `FIND(log:///**):#…#` six times into a bare 501 — log was the one scheme outside the universal paradigm, by accident not decision). Candidates are the worker's rows scoped by the coordinate hierarchy ({§log-coordinate-hierarchy}); each is projected EXACTLY as READ shows it (the rx unwrap — FIND must match what the model can retrieve); the shared `Matcher.matchCandidates` ({§find-source-agnostic}) applies any content dialect; results are catalog-shaped items keyed `log:///loop/turn/seq/OP` with `{mimetype, tokens, lines}`, and the fan-out retargets per-row READs so `READ(log://…):matcher` composes identically to entries (#286). A tag signal filters candidates by the model's own region tags ({§log-region-tagging}); log rows carry no symbol channels (`@graph` 501, an honest absence). `~semantic` is an HONEST 501 until
|
|
350
|
+
- **Log speaks the universal query contract** {§log-uniform-query} — `FIND(log://…)` works like every scheme's FIND (the jumbo model ran the canon-documented `FIND(log:///**):#…#` six times into a bare 501 — log was the one scheme outside the universal paradigm, by accident not decision). Candidates are the worker's rows scoped by the coordinate hierarchy ({§log-coordinate-hierarchy}); each is projected EXACTLY as READ shows it (the rx unwrap — FIND must match what the model can retrieve); the shared `Matcher.matchCandidates` ({§find-source-agnostic}) applies any content dialect; results are catalog-shaped items keyed `log:///loop/turn/seq/OP` with `{mimetype, tokens, lines}`, and the fan-out retargets per-row READs so `READ(log://…):matcher` composes identically to entries (#286). A tag signal filters candidates by the model's own region tags ({§log-region-tagging}); log rows carry no symbol channels (`@graph` 501, an honest absence). `~semantic` is an HONEST 501 until log rows receive the same exhaustive vector coverage as entries; storage stays the event stream (the Q3/Option-B decision: uniform QUERY contract, honest distinct storage).
|
|
353
351
|
- **The content matcher is source-agnostic** {§find-source-agnostic} — `Matcher.matchCandidates(body, candidates, mimetypes)` applies a content matcher (regex/jsonpath/xpath/glob) to candidates from ANY source, keyed by the caller's own identity (a pathname for entries, a `loop/turn/seq` coordinate for log). The matcher never cares what table the content came from, so FIND/READ with every content dialect works uniformly across schemes BY CONSTRUCTION — `EntryFind` and `Log.find` run the ONE shared primitive rather than re-implementing per scheme. This is the query-layer half of the log-uniformity decision (Q3, Option B): log stays its own event stream, but its rows are candidates the shared matcher covers like any entry's content.
|
|
354
352
|
- **Line-oriented matching is the tent pole; the selection signal is additive** {§matcher-selection-signal} — a pattern match returns the SOURCE LINE containing it, with its line number (`42:I bought Alice some flowers`, never `1:Alice`): the line is the universal coordinate the whole op-algebra composes on (FIND spans → READ delivers → EDIT mutates), and extraction dialects admit NO exception (owner ruling — a bare value has no coordinate and silently breaks the composition). The degenerate case — a MINIFIED single-line document, where "the line" is the whole file — is answered with SIGNAL on the FIND row, not a semantics change: a matcher READ's fan-out WRITES its internal FIND as a selection-summary row (sequence-first, exactly as if the model had FINDed then READ), whose rx carries one result item per HIT with `matchSpan` and `matchPath` (the hit's canonical dialect coordinate, e.g. `$['users'][0]['name']`, when the dialect provides one). Deliveries stay span-DEDUPED (#286 — N hits on one source line deliver that line once, no identical-row noise); the summary row is what tells the model its query hit N times and WHERE (run30: two hits indistinguishable from failure; 17 retries, 508). The teaching half is grammar's canon (grammar#56).
|
|
355
353
|
|
|
@@ -401,7 +399,7 @@ Engine → scheme guarantees:
|
|
|
401
399
|
|
|
402
400
|
Author-facing contract: [plurnk-mimetypes](https://github.com/plurnk/plurnk-mimetypes). Below: firing semantics + consumption surface.
|
|
403
401
|
|
|
404
|
-
**Firing semantics.** Render-time consumers. Engine invokes during packet assembly; handlers read current channel content (possibly mid-stream), produce structural view, result lands in the manifest catalog. Schemes do NOT call mimetype handlers at write — writes are verbatim. Two content lifecycles sit outside that rule and are not exceptions to it: (1) **write-time
|
|
402
|
+
**Firing semantics.** Render-time consumers. Engine invokes during packet assembly; handlers read current channel content (possibly mid-stream), produce structural view, result lands in the manifest catalog. Schemes do NOT call mimetype handlers at write — writes are verbatim. Two content lifecycles sit outside that rule and are not exceptions to it: (1) **write-time lexical discovery** — a handler-free, content-addressed raw-source FTS artifact attaches synchronously so a cold workspace's first semantic query can discover candidates. It is not a deep projection and never participates in vector ranking or graph traversal. Processed FTS, vectors, and graph rows live once on the complete derivation artifact. (2) **fetch-time materialization** — the web-fetch tool (the exec sink) projects the guarded HTTP `text/html` response through the handler's readable projection and stores that as the decisive `body` (`text/markdown` — what READ serves, FIND matches, and every weight reports), archiving the source page under `html` (xpath + fidelity). If and only if the byte response has no readable projection, the sink requests the guarded browser fallback and projects that DOM instead. An empty fallback rejects; raw HTML never becomes the decisive model/embed body. This sets the body's identity, not a re-derived view; an AUTHORED/workspace html file is written verbatim (its attributes are data). {§mimetype-schemes-do-not-invoke-handlers}
|
|
405
403
|
|
|
406
404
|
### §mimetype-manifest Manifest
|
|
407
405
|
|
|
@@ -413,7 +411,7 @@ Author contract owned by plurnk-mimetypes. plurnk-service consumes ONE entry poi
|
|
|
413
411
|
|
|
414
412
|
- `Mimetypes.process(input)` — the projection entry point; returns the structural projections (`deepJson` / `deepXml` / `symbols` / `references`) + extent (`totalLines`). {§mimetype-methods-process-entry-point}
|
|
415
413
|
|
|
416
|
-
**The
|
|
414
|
+
**The plugin projects; the service queries.** `Mimetypes.query()` exists in the author contract, but plurnk-service does NOT consume it. The service owns **all** dialect matching — glob, regex, jsonpath, xpath, `@graph`, `~semantic` — resolved in-tree over those projections plus its own indexes (`symbol_defs`/`symbol_refs`, FTS5, vectors). mimetypes is mimetype-*literate* (content→structure); the service is dialect-*literate* (structure→matches). The pattern-matching DSL is plurnk's defining surface — the service's authority, never a plugin's.
|
|
417
415
|
|
|
418
416
|
Cross-cutting promises service relies on:
|
|
419
417
|
|
|
@@ -467,7 +465,7 @@ if (isBody) await EntryGraph.populateFrom(db, workspaceId, r.entry_id, result.sy
|
|
|
467
465
|
|
|
468
466
|
`hint` short-circuits detection. The service consumes `totalLines` (extent), `symbols`/`references` (the `@graph` index), and `deepJson`/`deepXml` (matcher dispatch); never a rendered preview — content reaches the model on READ. Because this pass runs every assembly over every entry, any content change — by any writer — is reflected in the next packet's index. The `@graph` index is NOT engine *ranking* (the anti-pattern): it's a complete, unranked index the model queries via `FIND @<sym`, the manifest paradigm applied to structure, uniform across schemes (`file:///` is the primary case).
|
|
469
467
|
|
|
470
|
-
The body channel's embedding vectors derive in the same pass (`EntrySemantic.deriveEmbeddings`): content is tiled into token-budgeted chunks, then embedded in **one data-parallel batch** (`mimetypes.embedBatch`) rather than a per-chunk loop
|
|
468
|
+
The body channel's embedding vectors derive in the same pass (`EntrySemantic.deriveEmbeddings`): content is tiled into token-budgeted chunks, then embedded in **one data-parallel batch** (`mimetypes.embedBatch`) rather than a per-chunk loop. A workspace warm first materializes its active repository members from disk, then derives their deep channels; overlapping warm requests coalesce, and a request that arrives during a pass forces one final rescan. The pump computes the changed-entry worklist up front so the corpus total is known. It publishes `embed_progress` with an explicit `preparing`, `indexing`, `complete`, or `failed` phase; the latest state is queryable by clients that attach after startup began.
|
|
471
469
|
|
|
472
470
|
**Conformance.** Mimetype-specific behavioral tests live in each handler's own surface. plurnk-service intg covers integration: the engine routes through `Mimetypes.process` with the right hint and the catalog reflects `totalLines`; tests use auto-discovery (production handler set); a custom-handler test injects a stub `BaseHandler` via `loader + discovery`.
|
|
473
471
|
|
|
@@ -485,6 +483,10 @@ No stored `preview` channel — channel content is pulled on READ, never preview
|
|
|
485
483
|
|
|
486
484
|
Schemes MAY declare multiple channels (`exec`: stdout/stderr/stdin; `http`: body/header; SSE: per-event-type). Each goes in `manifest.channels` with mimetype pinned; rendered independently.
|
|
487
485
|
|
|
486
|
+
For a multi-channel streaming READ, persistence and publication are distinct: the scheme may acquire and persist auxiliary channels, but a fragmentless target publishes only the manifest's `defaultChannel`. An explicit fragment publishes that channel. Thus an ordinary HTTP READ presents the sanitized `body`; response metadata and archival DOM remain addressable implementation/diagnostic surfaces rather than ambient model context.
|
|
487
|
+
|
|
488
|
+
A published default channel renders under the entry's ordinary fragmentless address. The channel name remains internal bookkeeping, just as it is for synchronous entry READs. Only explicitly selected non-default channels render a fragment.
|
|
489
|
+
|
|
488
490
|
### §no-visibility Entries carry no visibility
|
|
489
491
|
|
|
490
492
|
Every entry is uniformly listed in the catalog (`FIND(scheme:///**)`, §packet) and READable — entries have no per-worker open/folded state. Context curation is the model's, on the **log** (via OPEN/FOLD, §open-fold), never on entries.
|
|
@@ -623,11 +625,11 @@ Log history preserved — `log_entries` stores path tuple as text, not FK to `en
|
|
|
623
625
|
|
|
624
626
|
AST: `{ op: "FIND", target (scope), body: MatcherBody | null (predicate), signal: tags | null, lineMarker? }`.
|
|
625
627
|
|
|
626
|
-
- Filters entries within scope.
|
|
628
|
+
- Filters entries within scope. A **bare** path is the exact entry; an explicit **glob** expands to a scope; `#regex#` filters by pathname. A trailing slash is a folder scope only for a scheme whose manifest declares `folderScopes: true`; otherwise it is ordinary resource syntax and READ dispatches it directly. This is an explicit plugin contract, never inferred from URL punctuation: new schemes cannot accidentally turn a root resource into unbounded fan-out. For declared folder schemes the same target contract governs FIND and READ — bare = the entry, folder/glob = a scope (#286). {§find-scope-prefix-filter}
|
|
627
629
|
- `body` matcher operates on entry content (glob/regex/jsonpath/xpath), per grammar plurnk.md §"Body matcher dispatch"; the path-glob lives in the (target), not the body. {§find-glob-filter-on-content}
|
|
628
630
|
- `signal` is a tag filter; entries match if they have ALL listed tags. {§find-tag-filter-and-semantics}
|
|
629
631
|
- Workspace + scheme scoped — no cross-workspace/cross-scheme leakage. {§find-scoped-isolation}
|
|
630
|
-
- Returns `FindResult { status, content, mimetype, results: MatchItem[], matches, pathnames }`. The matcher sets the unit (#286). A **body-less** FIND is the **catalog**: one item per *entry* — `{ path, seconds?, tags?, channels: { <uri>: { mimetype, tokens, lines } } }` (the addressable path, per-channel `{mimetype, tokens, lines}` keyed by URI — default channel → the bare path, non-default → `path#channel` — plus `tags` and a live `seconds` stream age), the manifest's per-scheme slice. A **matcher** FIND resolves to one item per *match*: the entry's catalog row plus the `matchSpan` `{lineStart, lineEnd}` it hit. **A file with N matches yields N items** — the same row repeated, one span each; there is no `matchLines` array. The unit is uniform across every dialect — glob/regex/jsonpath/xpath select line spans, `~`semantic the ranked chunk's span, `@`graph the matched symbol's span — all `(file, span)`, all real content lines (the old "the extent of ~semantic/@graph is not a content line" carve-out was false: a chunk span and a symbol span are line ranges). Order is match order (rank for `~`semantic, source order otherwise); a miss contributes nothing; identical spans dedup. `content` is the items as a JSON array (`application/json`). {§find-result-catalog-rows} **Over the render budget, FIND returns a count, not contents** (#418, `PLURNK_SERVICE_FIND_MAX_MATCHES`): a repo-scale `FIND(**)` over a 19k-entry workspace can't enumerate — materializing every match overflows the window, and a clean grind must not be a crash-and-recover. When the match set exceeds the budget the result carries `overflow: N` and its `content` states the fact (`"N entries match, exceeding the render budget — not enumerated"`), `text/markdown` not the JSON array; the
|
|
632
|
+
- Returns `FindResult { status, content, mimetype, results: MatchItem[], matches, pathnames }`. The matcher sets the unit (#286). A **body-less** FIND is the **catalog**: one item per *entry* — `{ path, seconds?, tags?, channels: { <uri>: { mimetype, tokens, lines } } }` (the addressable path, per-channel `{mimetype, tokens, lines}` keyed by URI — default channel → the bare path, non-default → `path#channel` — plus `tags` and a live `seconds` stream age), the manifest's per-scheme slice. A **matcher** FIND resolves to one item per *match*: the entry's catalog row plus the `matchSpan` `{lineStart, lineEnd}` it hit. **A file with N matches yields N items** — the same row repeated, one span each; there is no `matchLines` array. The unit is uniform across every dialect — glob/regex/jsonpath/xpath select line spans, `~`semantic the ranked chunk's span, `@`graph the matched symbol's span — all `(file, span)`, all real content lines (the old "the extent of ~semantic/@graph is not a content line" carve-out was false: a chunk span and a symbol span are line ranges). Order is match order (rank for `~`semantic, source order otherwise); a miss contributes nothing; identical spans dedup. `content` is the items as a JSON array (`application/json`). {§find-result-catalog-rows} **Over the render budget, FIND returns a count, not contents** (#418, `PLURNK_SERVICE_FIND_MAX_MATCHES`): a repo-scale `FIND(**)` over a 19k-entry workspace can't enumerate — materializing every match overflows the window, and a clean grind must not be a crash-and-recover. When the match set exceeds the budget the result carries `overflow: N` and its `content` states the fact (`"N entries match, exceeding the render budget — not enumerated"`), `text/markdown` not the JSON array; its enumerated `results`, `matches`, and `pathnames` arrays are EMPTY so no caller can perform hidden work from content the model was denied, while `overflow` and `itemsTokenTotal` report the full count and aggregate weight. INDEPENDENT of window size — even a 256k window should not render a whole repo's catalog into one turn. `0`/unset = no gate (small workspaces enumerate as before). {§find-count-not-contents}
|
|
631
633
|
|
|
632
634
|
### §send SEND
|
|
633
635
|
|
|
@@ -654,7 +656,7 @@ The engine's failure terminals — **500** (strike threshold) and **508** (cycle
|
|
|
654
656
|
|
|
655
657
|
- **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 your work is done, conclude with 200. If you're waiting on a child or stream you spawned, SEND[202] to block on it — a 202 with nothing to wait on simply concludes."*
|
|
656
658
|
- **Premature terminate — the pending set** {§send-premature-terminate} — `SEND[200]` terminates, gated by ONE rule: *nothing pending may be silently discarded*. pending = open streams/spawns (§subscriptions) ∪ **live child workers** (a child is live by its **latest loop** — the same definition §child-orientation renders, so the gate and the section the model reads never disagree) ∪ **this turn's retrievals** (READ/FIND/OPEN rows, whose results fold back next packet — a [200] over them discards answers the model asked for) ∪ **children terminated during this turn's generation** (`terminated_at` past the current turn's timestamp — no longer *live*, but their §worker-scheme collect deltas are queued for the NEXT build and unseen; the 1ms fan-out race where workers outrun the parent's own emission {§send-undelivered-child-term}). The set is judged **at the terminal's own dispatch, post-batch**: the emission's earlier ops have executed, so a same-turn `KILL + [200]` repairs in ONE turn, and a same-turn `WORK + [200]` is caught (the spawn is live by the time the SEND lands). A refusal is **409** with one steer naming the pending kinds — *"KILL what you no longer need; SEND[102] (or [102]<seconds>) to receive the rest; then conclude"* — the row records the attempt faithfully (never rewritten, never erased) and the loop stays a continue. The strike is **kind-specific** (owner ruling): a refusal holding open streams or live children strikes (§grinder-strike-coupling — discarding live work stays serious); a **retrievals-only** refusal teaches WITHOUT striking — atomic-turn-pretrained models pair fetch-and-answer by habit, each refusal is individually correct, and maxTurns bounds the walk; striking executed visibly-converging behavior (the jumbo/admins specimens: three correct refusals → 500 mid-adaptation). Its steer (*"Last turn both performed retrieval operations and attempted to terminate. Retrieval operations force an additional turn to receive results for review and reaction. To conclude, only use PLAN and SEND[200] operations."* — owner wording, #384/run48) carries three lessons the specimen requiems taught: it narrates the refusal as HISTORY (weak models read log rows in the third person and never take "you"-steers as self-addressed — run48's model called our 409 "strange… Anyway" and proceeded), it states the mechanism as law (retrievals FORCE an additional turn — physics, not blame), and it prescribes the concluding emission's legal SHAPE (PLAN + SEND[200] only), leaving no room for the groundedness-trained justify-READ that re-armed the gate four times around a correct answer. **Failures gate the terminal identically** {§send-200-failed-ops} — a turn that carried **failed operations** (the model's own op results at status ≥ 400, or this emission's parse errors) cannot conclude `200`: those errors land in the log **next** packet, so a same-turn `[200]` concludes blind past a failure the model never weighed — refused **409** (*"weigh them, then conclude"*), the row faithful, the loop a continue; next turn, the failures in-log and seen, `[200]` stands (the gate judges only the current turn, so it never re-arms on its own refusal). `SEND[499]` abandons regardless — discard by stated intent, the one legitimate discard; declaring failure IS weighing it. Waiting is its own signal — `SEND[202]<T,P>` (§wait-obligation-matrix): a **bounded** block on the loop's live obligations, `loops.status = 202` internally, woken by any arrival (stream/child conclusion, sibling irc, operator inject) or its `<T>` deadline. A `202` with **no** obligation resolves like `200` — a wait on nothing is already satisfied — and `<-1>+∅` (an unbounded wait on nothing) self-resolves rather than hang the agent. A child that **terminated during this turn's generation** is not ∅: its deliverable is on the doorstep (queued for the next build), so the wait CONTINUES like a retrieval (R semantics — parking would hang, the wake edges having already fired into an unparked worker; §send-undelivered-child-term). The groundless park is therefore never *refused*, it is *complete*: an idle worker with nothing in flight **concludes** (§worker-lifecycle-idle-is-concluded), reawakened later by a new prompt as a new loop. The park deadline is engine-memory consumed by the daemon's drain park-exit (a daemon restart drops pending deadlines; arrivals still wake).
|
|
657
|
-
- **SEND[300] is an operator question — a PROPOSAL, the stop-the-world system file edits and MCP auths ride** (owner ruling, #346). Enablement cascades: `PLURNK_QUESTIONS=0` is a servicewide ceiling; otherwise the client affirmatively requests per workspace (`settings.questions: true` at workspace.create), which ALSO injects the questions.md teaching — capability and teaching gate as one. Enabled: the `;`-delimited body parses leniently (first segment the question, the rest choices; zero choices = an open question — never malformed), and the ask raises a proposal: dispatch stops the world, `loop/proposal` carries `{question, choices}` in attrs, and the client's `loop.resolve {decision:"accept", body}` delivers the ANSWER — written into the ask's own model-facing rx (`{"status":200,"body":…}`), read next packet. Reject/timeout resolve through the standard §proposal semantics; the turn records a continue either way (never a 300 terminal), and the loop simply proceeds.
|
|
659
|
+
- **SEND[300] is an operator question — a PROPOSAL, the stop-the-world system file edits and MCP auths ride** (owner ruling, #346). Enablement cascades: `PLURNK_QUESTIONS=0` is a servicewide ceiling; otherwise the client affirmatively requests per workspace (`settings.questions: true` at workspace.create), which ALSO injects the questions.md teaching — capability and teaching gate as one. Enabled: the `;`-delimited body parses leniently (first segment the question, the rest choices; zero choices = an open question — never malformed), and the ask raises a proposal: dispatch stops the world, `loop/proposal` carries `{question, choices}` in attrs, and the client's `loop.resolve {decision:"accept", body}` delivers the ANSWER — written into the ask's own model-facing rx (`{"status":200,"body":…}`), read next packet. Reject/timeout resolve through the standard §proposal semantics; the turn records a continue either way (never a 300 terminal), and the loop simply proceeds. Loop auto never auto-answers a question — it exists precisely to stop the world for a human, and the workspace opted in. Disabled: refused 409 with a self-decide steer, never a park into the void. {§send-300-choices}
|
|
658
660
|
|
|
659
661
|
### §exec EXEC
|
|
660
662
|
|
|
@@ -682,7 +684,7 @@ A `read` runtime (observes external state, e.g. search) or `pure` runtime (no ob
|
|
|
682
684
|
|
|
683
685
|
**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}
|
|
684
686
|
- **The turn-hold exception** {§exec-hold-until-concluded} — for runtimes in `PLURNK_SERVICE_EXEC_HOLD` (a decision-table env, shipped listing the search family), an in-flight stream **pauses the cycle**: the next packet does not assemble until the stream concludes, so the model never burns a turn asking "are we there yet" about a result the engine controls end-to-end (one final JSON digest, seconds-bounded — the owner's ruling: this is the one special case where the stream is known well enough not to fall back on the standard cycle). Bounded by `PLURNK_SERVICE_EXEC_HOLD_MS` and **fail-open**: at the cap the standard cycle resumes untouched (waits, wakes, polls). Zero grammar or teaching surface — the model emits `EXEC + SEND[102]` as ever; the wake-shaped world simply arrives one packet sooner. Lives at the post-EXEC breath seam in `runLoop`, upstream of `PLURNK_SERVICE_EXEC_WAIT_MS`. A bare entry holds ALL of a runtime's spawns; a `<runtime>:<effect>` suffix (`github:read`) holds only that effect-class — an MCP server is one runtime whose tools split (a `read` `get_issue` is instant; a `host` `run_migration` is a slow mutation), so an operator opts the known-fast read-class in without parking on the mutation (#485). Conservative stays default: an arbitrary third-party server's latency never parks the engine unless a suffix opts a class in.
|
|
685
|
-
- **The entry() sink** {§exec-entry-sink} — an executor may *request* entry materialization (execs SPEC §2.6: every sink is a consumer-implemented callback; the executor owns zero substrate). The service implements it in exec dispatch: `entry(path, content: string | null, {tags, mimetype?})` upserts the entry (writeEntry; tags **UNIONED** across writes — a re-seen URL keeps its history of query slugs), then narrates ONE `EDIT` row in the reserved `plurnk` worker's log — the fs-fiction pattern, `source` = the calling worker, `tokens` = the content's count, `attrs` carrying the tags — which the env-delta ambience (§env-delta) folds into every worker's next packet as a one-liner. **The executor owns no fetcher** (Web Search ruling #5): a `content: null` is a *declaration* — the service
|
|
687
|
+
- **The entry() sink** {§exec-entry-sink} — an executor may *request* entry materialization (execs SPEC §2.6: every sink is a consumer-implemented callback; the executor owns zero substrate). The service implements it in exec dispatch: `entry(path, content: string | null, {tags, mimetype?})` upserts the entry (writeEntry; tags **UNIONED** across writes — a re-seen URL keeps its history of query slugs), then narrates ONE `EDIT` row in the reserved `plurnk` worker's log — the fs-fiction pattern, `source` = the calling worker, `tokens` = the content's count, `attrs` carrying the tags plus `kind:"entry_materialized"` — which the env-delta ambience (§env-delta) folds into every worker's next packet as a one-liner. The kind lets live clients collapse machine acquisition into compact progress while complete replayable provenance remains in the log. **The executor owns no fetcher** (Web Search ruling #5): a `content: null` is a *declaration* — the service acquires the page through schemes-http's guarded primitive, accepts a useful MIME-rendered byte response, and lazily tries guarded browser rendering only when that projection is empty. A failed acquisition or empty final projection rejects the sink and produces no HTTP entry, but does not invalidate a search runtime's upstream discovery row (#596). A non-null `content` is the materialize-given-body path (the caller already holds the bytes and states their mimetype). **No page body ever rides a packet**; the announcement is the folded row's meta (path + tokens + tags), and the model READs/~queries what it chooses. Parallel `entry()` calls serialize on a per-spawn chain; a rejected call leaves the chain healthy. The narration context (one plurnk-worker turn) is lazy per spawn, not per entry.
|
|
686
688
|
|
|
687
689
|
### §proposal The proposal lifecycle
|
|
688
690
|
|
|
@@ -690,7 +692,7 @@ A side-effecting op does not execute on dispatch — it **proposes**. The scheme
|
|
|
690
692
|
|
|
691
693
|
**Resolution arrives four ways, one surface to the model:**
|
|
692
694
|
- **`loop.resolve`** (§methods) — a client's accept / reject / cancel.
|
|
693
|
-
- **
|
|
695
|
+
- **Loop auto** (§proposal-ownership) — an in-tree listener resolves `accept` in-process, same tick, no wire roundtrip.
|
|
694
696
|
- **noProposals** — an in-tree listener resolves `reject` (outcome `no_review_channel`).
|
|
695
697
|
- **Timeout is OPT-IN; the shipped default is a world that WAITS** {§proposal-timeout-cancels} — `PLURNK_SERVICE_PROPOSAL_TIMEOUT_MS` empty (shipped) means a pending proposal — a file edit awaiting review, an MCP auth, a [300] question — waits indefinitely for its human: absence is not an answer, and a synthetic cancel deciding it was is unacceptable (owner ruling, the AG-UI migration's first surfaced decision). An operator whose lane needs a bound sets milliseconds; then elapsing synthesizes `cancel` (outcome `timeout`), server-side, needing no client.
|
|
696
698
|
|
|
@@ -761,13 +763,13 @@ SQLite (`node:sqlite`) with WAL mode and STRICT tables. Hand-written DDL; CI-ali
|
|
|
761
763
|
|
|
762
764
|
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.
|
|
763
765
|
|
|
764
|
-
- One `.sql` file per cohesive concern under `migrations
|
|
765
|
-
- DDL lives in
|
|
766
|
-
-
|
|
766
|
+
- One `.sql` file per cohesive concern under `migrations/`. File names are descriptive organization only; `-- MIGRATE: <positive-integer>` versions define the authoritative order.
|
|
767
|
+
- Structural DDL lives in versioned `MIGRATE` blocks. SqlRite applies every version above `PRAGMA user_version` once, in ascending order, transactionally; a failed body and its version bump roll back together. Applied migrations are immutable history.
|
|
768
|
+
- `INIT` is not a schema-evolution mechanism. It is reserved for genuinely idempotent posture or seeds that must run on every open. PLURNK currently needs none.
|
|
767
769
|
- **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.
|
|
768
770
|
- DDL = storage truth; JSON Schemas = wire truth. Tested-aligned, allowed to differ where ergonomics demand.
|
|
769
|
-
- **Schema-version stamp.** {§db-schema-version-stamp} Every plurnk DB carries `PRAGMA user_version` (current: `
|
|
770
|
-
- **Identity components are never NULL.** {§entry-identity-no-null} The entries identity tuple — (workspace, owner, scheme, pathname) — admits no NULL component, because NULLs are distinct under SQL UNIQUE and a nullable component voids the identity index entirely: the #526 disease, re-run on the scheme axis as run59/#545 (the
|
|
771
|
+
- **Schema-version stamp.** {§db-schema-version-stamp} Every plurnk DB carries SqlRite's `PRAGMA user_version` (current: `5`). Versioned `MIGRATE` blocks are the sole schema-evolution history; the latest migration version is therefore also the cross-repo drift stamp. Any change to the schema's *shape* — tables, columns, identity keys — adds the next migration in the same commit. External consumers (bench's digest) read the stamp with zero table dependency and fail hard on mismatch — "schema v5 required, found v4" — instead of rotting silently against a moved schema (the pre-rename specimen class: "no such table"). `INIT` is reserved for genuinely repeatable database posture or seeds, never schema creation or a manual `user_version` assignment.
|
|
772
|
+
- **Identity components are never NULL.** {§entry-identity-no-null} The entries identity tuple — (workspace, owner, scheme, pathname) — admits no NULL component, because NULLs are distinct under SQL UNIQUE and a nullable component voids the identity index entirely: the #526 disease, re-run on the scheme axis as run59/#545 (the per-turn membership upsert never conflicted — one phantom row per member per turn, 74k rows over 530 identities, null-keyed lookups landing on arbitrary rows, the sig-gate misfiring, EDIT anchors resolving against stale bytes). File members persist under the reserved **`file`** scheme (`storedScheme: "file"`; they still render as bare paths); `entries.scheme` is `NOT NULL`; a manifest declaring `storedScheme: null` is refused at dispatch.
|
|
771
773
|
|
|
772
774
|
### §sql-ts-boundary SQL/TS responsibility boundary
|
|
773
775
|
|
|
@@ -793,7 +795,7 @@ When SQL becomes onerous for a specific case, retreat for that case and document
|
|
|
793
795
|
|
|
794
796
|
## §plugin-discovery Plugin Discovery
|
|
795
797
|
|
|
796
|
-
|
|
798
|
+
{§plugin-built-against} Plugin compatibility comes from its family-head dependency range; `plurnk.builtAgainst` records exact provenance. The loader rejects an incompatible range before import. Missing metadata warns and loads as a legacy artifact.
|
|
797
799
|
|
|
798
800
|
Scoped-package scan with manifest field:
|
|
799
801
|
|
|
@@ -854,7 +856,7 @@ Plugin discovery (§plugin-discovery) registers whatever's in `node_modules/@plu
|
|
|
854
856
|
- Backpressure caps — none (§stream-constraints).
|
|
855
857
|
- Stream cancel — `SEND[499]` (§stream-control).
|
|
856
858
|
- Delete — `KILL` (entry-KILL, the canonical delete, §move); `SEND[410]` also deletes as a side-effect (§send-dispatch).
|
|
857
|
-
- Per-loop flags — `loops.flags` JSON column; `
|
|
859
|
+
- Per-loop flags — `loops.flags` JSON column; `auto`, `noProposals`, `noWeb`, `noInteraction`, and `mode`.
|
|
858
860
|
- Default-channel wire rendering — §channel-selection.
|
|
859
861
|
|
|
860
862
|
---
|
|
@@ -871,7 +873,7 @@ Model selection: separate alias cascade in `ProviderRegistry` (§provider-instan
|
|
|
871
873
|
|-----|---------|---------|
|
|
872
874
|
| `PLURNK_SERVICE_DB_PATH` | `~/.plurnk/plurnk.db` | SQLite file path. |
|
|
873
875
|
| `PLURNK_HOST` | `127.0.0.1` | Bind address for the listener. Local-only by default. |
|
|
874
|
-
| `PLURNK_PORT` | `3044` | TCP port for THE client surface — the AG-UI+ listener (the plurnk-agui
|
|
876
|
+
| `PLURNK_PORT` | `3044` | TCP port for THE client surface — the AG-UI+ listener (the plurnk-agui plugin module binds it at boot). Production is single-listener. |
|
|
875
877
|
| `PLURNK_SERVICE_MAX_TURNS` | `-1` | Operator turn **ceiling** — `-1` = no cap; a positive value caps a per-call `loop.run({maxTurns})`. |
|
|
876
878
|
| `PLURNK_SERVICE_MAX_COMMANDS` | `-1` | Per-emission op ceiling; `-1` = no cap (default) — every generated op dispatches. A positive value caps dispatched actions: overflow ops drop silently with one `max_commands_exceeded` telemetry entry on the next packet. Tightened per workspace via `settings.maxCommands` (min wins). |
|
|
877
879
|
| `PLURNK_SERVICE_LOOP_TIMEOUT` | `86400000` | ms wall-clock budget for a single `loop.run`: expiry aborts the loop signal mid-flight (a stuck `generate` included) and the loop terminates `504 loop_timeout` — a legible engine terminal, kin to the exec `<T>` reap's 504 (§exec-timeout). {§operator-config-loop-timeout} |
|
|
@@ -912,26 +914,11 @@ External plugins declare their own env vars in their own `.env.defaults`, assemb
|
|
|
912
914
|
|
|
913
915
|
---
|
|
914
916
|
|
|
915
|
-
## §rpc
|
|
916
|
-
|
|
917
|
-
plurnk-service runs as a daemon. Clients (TUI/CLI/neovim/web/Telegram/etc.) drive it via self-describing RPC. This section is the wire — implementing a new client should require reading only §rpc.
|
|
918
|
-
|
|
919
|
-
### §transport Transport
|
|
920
|
-
|
|
921
|
-
The client surface is AG-UI+ on `PLURNK_HOST:PLURNK_PORT` (default `127.0.0.1:3044`), bound by the plurnk-agui daughter module at boot — the module owns that protocol (its SPEC lives in plurnk-agui). Production is **single-listener**: the daemon opens no transport of its own; daughter modules open theirs through the seam.
|
|
922
|
-
|
|
923
|
-
The daemon speaks **no wire protocol of its own**: the intg harness dispatches into the seam directly (a JSON-RPC-shaped in-process mimic, `test/intg/_seam.ts`), certifying the same surface a module consumes.
|
|
917
|
+
## §rpc Module seam
|
|
924
918
|
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
JSON-RPC 2.0. Two message kinds:
|
|
930
|
-
|
|
931
|
-
- **Request:** `{ "jsonrpc": "2.0", "id": …, "method": …, "params": … }`. Server replies with matching `id`.
|
|
932
|
-
- **Notification:** `{ "jsonrpc": "2.0", "method": …, "params": … }`. No `id`; server-initiated; no reply.
|
|
933
|
-
|
|
934
|
-
Success response: `{ "jsonrpc": "2.0", "id": …, "result": … }`. Failure: `{ "jsonrpc": "2.0", "id": …, "error": { "code": …, "message": …, "data": … } }`.
|
|
919
|
+
Core exposes operations and events to in-process client-interface modules. It
|
|
920
|
+
does not own an external wire protocol or listener. `plurnk-agui` owns the
|
|
921
|
+
default AG-UI+ HTTP/SSE interface and its public contract.
|
|
935
922
|
|
|
936
923
|
### §method-registration Method registration
|
|
937
924
|
|
|
@@ -1016,7 +1003,7 @@ registry.registerMethod("loop.run", {
|
|
|
1016
1003
|
|
|
1017
1004
|
| Method | Params | Result | Notes |
|
|
1018
1005
|
|-------------------|-------------------------------------|------------------------|-------|
|
|
1019
|
-
| `loop.run` | `prompt: string`, `maxTurns?: number`, `alias?: string`, `model?: string`, `flags?: LoopFlags` | `{ loopId, action, finalStatus: 100 }` | Model-driven loop. **Accepts and returns immediately** (`finalStatus: 100`; `action` = `enqueued_new_loop` \| `injected_next_turn`) — it never blocks on the loop, which may block on a `SEND[202]` wait for its own spawned work (§worker-lifecycle-wake-liveness). The loop's outcome — `finalStatus`, `turnIds`, `hitMaxTurns`, `usage` — arrives on the **`loop/terminated`** event. **Per-loop model selection** (#414): optional `model` (client-resolved `<provider>/<model>`, wins) or `alias` (a declared `PLURNK_MODEL_<alias>`) overrides the boot-time `PLURNK_MODEL` for
|
|
1006
|
+
| `loop.run` | `prompt: string`, `maxTurns?: number`, `alias?: string`, `model?: string`, `flags?: LoopFlags` | `{ loopId, action, finalStatus: 100 }` | Model-driven loop. **Accepts and returns immediately** (`finalStatus: 100`; `action` = `enqueued_new_loop` \| `injected_next_turn`) — it never blocks on the loop, which may block on a `SEND[202]` wait for its own spawned work (§worker-lifecycle-wake-liveness). The loop's outcome — `finalStatus`, `turnIds`, `hitMaxTurns`, `usage` — arrives on the **`loop/terminated`** event. **Per-loop model selection** (#414/#598): optional `model` (client-resolved `<provider>/<model>`, wins) or `alias` (a declared `PLURNK_MODEL_<alias>`) overrides the boot-time `PLURNK_MODEL` for a newly created loop. The fully resolved `{ alias, provider, model, baseUrl? }` is persisted on that loop and is immutable through every turn, queue claim, async wake, and daemon restart; no resume path may substitute the boot default. A prompt folded into an active or parked loop must resolve to the same selection; a conflicting selection fails loudly and requires the caller to conclude or cancel before opening a loop on another model. Provider instances are cached, an unresolvable spec fails before enqueue, and neither selector runs the boot default. Mid-loop switching is not an implicit side effect of prompt injection; a future such capability requires its own explicit atomic operation. Optional `flags` carries per-loop flags (`{auto?: boolean}`; see §engine-rails). Streams `log/entry` and `loop/proposal` during. `longRunning: false`. {§methods-loop-run} {§methods-loop-run-model} |
|
|
1020
1007
|
| `proposal.list` | — | `{ proposals: [{logEntryId, workerId, loopId, turnId, op, suffix, target, body, attrs, flags, at}] }` | Every pending (`state='proposed'`) stop-the-world proposal in the attached workspace — the indefinite-wait ruling's mandatory companion: `loop/proposal` is a notification, so a client reconnecting during a stopped world (possibly days old) DISCOVERS it here and answers via the ordinary `loop.resolve`. `attrs` carries `{question, choices}` for [300] asks. {§proposal-list} |
|
|
1021
1008
|
| `loop.resolve` | `logEntryId: number`, `decision: "accept" \| "reject" \| "cancel"`, `body?: string`, `outcome?: string` | `{ status, logEntryId }` | Resolve a pending proposal (status=202 log entry). Engine.dispatch unpauses on resolution. |
|
|
1022
1009
|
| `loop.cancel` | `reason?: string` | `{ cancelled, workerId, reason }` | Abort the attached worker's active drain. `{cancelled: true}` if a drain was running, `{false}` if idle. Cancelled loops close at 499; queued-but-unclaimed loops stay enqueued. Default reason `user_cancelled`. **The cancel is provenanced, never silent** (#380, owner ruling): the loop's terminal ROW is written (499 — a dead loop must never read as live 102, the #311 rule applied to the cancel path) with `terminated_by='cancel'` and the reason as its abandonment message, and the `loop/terminated` broadcast carries the same `message` — an external cancel is allowed (the client owns its loops, even 202-waiting ones with live obligations), but who/why is always on the record. A PARKED (202) loop has no drain to observe the abort, so the cancel terminalizes it directly — same row, same broadcast — even as `cancelled` answers `false` (no in-flight drain existed). {§methods-loop-cancel} |
|
|
@@ -1060,15 +1047,13 @@ All `op.*` return `{ status, ...op-specific }`. All `requiresInit: true`. None `
|
|
|
1060
1047
|
|
|
1061
1048
|
Future: `subscription.list`, `subscription.cancel` (the latter is `op.send({status: 499, recipient})` today).
|
|
1062
1049
|
|
|
1063
|
-
### §notifications
|
|
1064
|
-
|
|
1065
|
-
Server-initiated events on the same WebSocket.
|
|
1050
|
+
### §notifications Events
|
|
1066
1051
|
|
|
1067
1052
|
| Notification | Params | When fired |
|
|
1068
1053
|
|--------------------|-------------------------------------|------------|
|
|
1069
1054
|
| `log/entry` | `{ entry: LogEntry }` | Every `log_entries` write. {§notifications-log-entry-notify} |
|
|
1070
1055
|
| `loop/terminated` | `{ loopId, finalStatus, hitMaxTurns }` | Loop reaches terminal status. |
|
|
1071
|
-
| `loop/proposal` | `{ logEntryId, workspaceId, workerId, loopId, turnId, op, target, body, attrs, flags }` | Dispatch pauses on status=202. Carries `flags` so
|
|
1056
|
+
| `loop/proposal` | `{ logEntryId, workspaceId, workerId, loopId, turnId, op, target, body, attrs, flags }` | Dispatch pauses on status=202. Carries `flags` so a client can suppress review UI when loop-owned policy will resolve it. Client responds with `loop.resolve` when authority crosses the boundary (or `PLURNK_SERVICE_PROPOSAL_TIMEOUT_MS` fires). |
|
|
1072
1057
|
| `workspace/created` | `{ id, name, projectRoot }` | Any client creates a workspace. |
|
|
1073
1058
|
| `stream/event` | `{ entryId, channel, state, contentLength }` | Channel content grows or state transitions. {§notifications-stream-event-on-channel-change} |
|
|
1074
1059
|
| `stream/concluded` | `{ entryId, target, subscriptionId, scheme, closeStatus, summary, wakeAction, wakeLoopId? }` | A streaming subscription closed (subprocess finished / errored / cancelled). `wakeAction` says how the conclusion reached the worker: `resumed-loop` (a slept `202` loop resumed in place, §worker-lifecycle-wake-liveness), `no-op-active-loop` (folded into a live loop's next turn), `skipped-aborted`/`skipped-cancelled`/`skipped-no-provider`, or `no-loop` (nothing to resume). `summary` rides the notification for client display; it is no longer fed to the model as a prompt. {§notifications-stream-concluded} |
|
|
@@ -1076,68 +1061,16 @@ Server-initiated events on the same WebSocket.
|
|
|
1076
1061
|
|
|
1077
1062
|
`stream/event` carries metadata only, never content. Clients fetch via `entry.read({target})`. **Every notification envelope carries its `workspaceId`** (and `workerId` where the emitter has it) so a multi-workspace client — one connection, many workspaces — can route it ({§notifications-envelope-carries-workspaceid}); the broadcast stays workspace-scoped too.
|
|
1078
1063
|
|
|
1079
|
-
### §connection-lifecycle
|
|
1064
|
+
### §connection-lifecycle Client context
|
|
1080
1065
|
|
|
1081
|
-
|
|
1082
|
-
[client] [daemon]
|
|
1083
|
-
| |
|
|
1084
|
-
|-- ws.connect ----------------------------------->|
|
|
1085
|
-
|<------- on('open') --------------------------- |
|
|
1086
|
-
| |
|
|
1087
|
-
|-- discover() ---------------------------------->|
|
|
1088
|
-
|<------- { methods, notifications, capabilities }|
|
|
1089
|
-
| |
|
|
1090
|
-
|-- workspace.attach(id=42) ------------------------>|
|
|
1091
|
-
|<------- { id: 42, name: "demo-workspace" } |
|
|
1092
|
-
| (daemon opens a client loop in workspace 42) |
|
|
1093
|
-
| |
|
|
1094
|
-
|-- loop.run(prompt="...") ----------------------->|
|
|
1095
|
-
|<-- notification: log/entry { ... } |
|
|
1096
|
-
|<-- notification: log/entry { ... } |
|
|
1097
|
-
|<-- notification: loop/terminated { ... } |
|
|
1098
|
-
|<------- { loopId, turnIds, finalStatus: 200 } |
|
|
1099
|
-
| |
|
|
1100
|
-
|-- op.dispatch(op=...) -------------------------->|
|
|
1101
|
-
|<-- notification: log/entry { ... } |
|
|
1102
|
-
|<------- { status: 201 } |
|
|
1103
|
-
| |
|
|
1104
|
-
|-- ws.close ------------------------------------->|
|
|
1105
|
-
| (daemon closes the client loop; workspace keeps)|
|
|
1106
|
-
```
|
|
1107
|
-
|
|
1108
|
-
**The client's run.** A client connection is an actor (§machine-processes); its `op.*` write to its **own worker** — `origin = "client"`, one loop per connection — and `log.read` reads that worker. Disconnect closes the loop's status; rows persist. Multiple connections each get their own client worker.
|
|
1109
|
-
|
|
1110
|
-
`loop.run` and `inject` target the **model's worker** — a separate worker holding the conversation, `origin = "model"`. Both workers share the workspace's one filesystem (§machine-processes); the packet renders only the model's worker, so the client's ops are structurally absent from it — no origin filter (§actor-boundary-isolation). The model worker (`Envelope.ensureModelWorker`) and the connection's client worker are distinct, each lazily allocated on first use — the §machine-processes conflation is corrected.
|
|
1111
|
-
|
|
1112
|
-
### §errors Errors
|
|
1113
|
-
|
|
1114
|
-
Standard JSON-RPC codes:
|
|
1115
|
-
|
|
1116
|
-
| Code | Meaning |
|
|
1117
|
-
|--------|-------------------------------|
|
|
1118
|
-
| -32700 | Parse error (malformed JSON) |
|
|
1119
|
-
| -32600 | Invalid request |
|
|
1120
|
-
| -32601 | Method not found |
|
|
1121
|
-
| -32602 | Invalid params |
|
|
1122
|
-
| -32603 | Internal error |
|
|
1123
|
-
|
|
1124
|
-
Plurnk-specific (`-32000` to `-32099`):
|
|
1125
|
-
|
|
1126
|
-
| Code | Meaning |
|
|
1127
|
-
|--------|----------------------------------------------------|
|
|
1128
|
-
| -32000 | Not initialized (requires workspace attach) |
|
|
1129
|
-
| -32001 | Workspace not found |
|
|
1130
|
-
| -32002 | Loop not found |
|
|
1131
|
-
| -32003 | Entry not found (engine 404) |
|
|
1132
|
-
| -32004 | Provider unavailable |
|
|
1133
|
-
| -32005 | Scheme unavailable |
|
|
1134
|
-
| -32006 | Mimetype unavailable |
|
|
1135
|
-
| -32007 | Timeout |
|
|
1066
|
+
**The client's run.** A module client context is an actor (§machine-processes); its `op.*` write to its **own worker** — `origin = "client"`, one loop per context — and `log.read` reads that worker. Closing the context closes the loop's status; rows persist. Multiple contexts each get their own client worker.
|
|
1136
1067
|
|
|
1068
|
+
`loop.run` and `inject` target the **model's worker** — a separate worker holding the conversation, `origin = "model"`. Both workers share the workspace's one filesystem (§machine-processes); the packet renders only the model's worker, so the client's ops are structurally absent from it — no origin filter (§actor-boundary-isolation). The model worker (`Envelope.ensureModelWorker`) and the client context's worker are distinct, each lazily allocated on first use.
|
|
1137
1069
|
|
|
1138
1070
|
### §versioning Versioning
|
|
1139
1071
|
|
|
1140
|
-
|
|
1072
|
+
The module seam follows the platform major. External protocol compatibility is
|
|
1073
|
+
owned by the client-interface module that publishes it.
|
|
1141
1074
|
|
|
1142
1075
|
---
|
|
1143
1076
|
|
|
@@ -1172,8 +1105,8 @@ Each entry: question, answer, rationale, migration path.
|
|
|
1172
1105
|
- **Heaviest entries.** A second table lists the five heaviest log entries by their **body weight — the same number the row's own `tokens` shows** (what a FOLD saves; #466: the budget and the log never disagree about one row), each by its `log:///<coord>/<op>` handle — the FOLD targets behind the turn weight. A row ranks only when a FOLD would actually reclaim it: a bodyless row has nothing to fold, an already-folded row is already reclaimed (its `tokens` price an OPEN, not a FOLD), and the current loop's foisted preview is refused outright (§prompt-fold-illegal) — the list never advertises a lever the law refuses or the state has already pulled. The handle carries the turn, so the two tables interlock. {§tokenomics-largest-entries}
|
|
1173
1106
|
- **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}
|
|
1174
1107
|
- **The window is a partition, never a fraction.** the window and both reserves are **provider-owned** (#507, owner-ruled): `provider.contextWindow` (probed/declared in the provider tier), `provider.reasoningReserve` and `provider.completionReserve` (ingested or `PLURNK_PROVIDERS_*`-pinned there, percent or absolute). A null window or null reserves is genuinely-unknown — nobody chose an envelope — and is treated as **NO-CAP** — the prompt is unbounded, the budget and ceiling resolve to null and the gauge omits its headline — rather than let numbers nobody chose silently stand in for unknown physics; a probe blip must degrade to no-cap, never crash the loop {§tokenomics-window-unpollable-deliberate}) splits **without remainder**: `promptBudget = min(operatorCap, provider.contextWindow) − reasoningReserve − completionReserve − PLURNK_SERVICE_SAFETY` is what the service may send — the operator's `PLURNK_PROVIDERS_CONTEXT_WINDOW` pin (alias-scoped else bare) is CORE's log-budget cap (#528): construction strips it so the provider reports its NATURAL window, reserves derive from that nature (a tighter log never throttles how much the model may think or say), and the cap tightens the PROMPT alone; a pin over an UNPOLLABLE window (#419) declares the window itself — no separate nature exists, reserves derive from the declaration — `max_tokens = reasoningReserve + completionReserve` is the generation envelope passed on every `generate({maxTokens})` when the reserves are known, and `SAFETY` — core's ONE remaining partition knob, the ruler's own packing margin — covers chat-template overhead no content counter sees. The retired `PLURNK_SERVICE_{CONTEXT_WINDOW,REASONING,COMPLETION}` names fail hard at boot naming their provider-tier successors. The prompt ceiling is **derived, never set**: a settable ceiling lets policy contradict physics, and a fractional one budgets the prompt against the window while *forgetting the response lives there too* (at 0.9 × 49152, any emission past ~4.9k overflowed gemma with a perfectly honest ruler). Reserves exceeding the window (`promptBudget ≤ 0`) are a configuration contradiction and fail hard — post-migration its one cause is pinned absolute reserves over a detected window; percent reserves derive and cannot contradict. When native reasoning is on, llama-server ignores per-request numeric budgets — the serving box's `--reasoning-budget` launch flag must equal the alias's resolved reasoning reserve, and the service warns at boot when it cannot verify that coupling. The LOCAL example partition (turboderp suffix, 78848 − 4096 − 8192 − 1024) yields exactly 65536 prompt tokens; the bare cloud defaults yield a large decode envelope the backend self-clamps. {§tokenomics-window-partition} **The partition is PER ALIAS** (#352): each knob resolves `PLURNK_SERVICE_<KNOB>_<alias>` over the bare fallback (providers' `scopeEnvToAlias`), scoped by the alias that produced the turn's provider (the service's own provider→alias side-table, falling back to the active boot alias). BARE defaults are cloud-generous — the generation envelope is a large `max_tokens` the backend self-clamps to its true output limit (cloud APIs accept and silently clamp; none expose a max-output field, so generous-and-clamp is the honest posture, and a cloud reasoner burning ~16k on thought finishes its DSL instead of dying at `finish=length`). LOCAL (llama-server) aliases MUST keep a measured envelope — `n_predict` is honored to the context wall, so an unbounded decode wall-runs (providers#10, the cap doctrine's origin); a local alias whose partition contradicts its real window fails HARD at build time, naming the per-alias knobs to set.
|
|
1175
|
-
- **Derivation is
|
|
1176
|
-
- **Binary truth beats the label; no entry dominates the corpus.** A tracked member whose HEAD bytes contain NUL is materialized as a binary marker (empty body, `application/octet-stream`, READ-415) **regardless of what extension-based detection claims** — the markdown default for unmapped extensions once shipped a 3.3MB `.wasm` blob into the semantic corpus as prose, three copies, ~10M tokens (#320)
|
|
1108
|
+
- **Derivation is eager and exhaustive.** Workspace creation and membership changes immediately start one coalesced warm. The first model turn joins that warm; later turns fully derive intervening changes before dispatch. No model operation observes partial graph or vector coverage. A semantic query ranks every eligible vector in scope, so lexical overlap never gates recall. With no embedder, the already-derived readable-content FTS is the explicit keyword fallback. Progress telemetry makes the wait visible; latency is never hidden by returning incomplete semantics. {§derivation-exhaustive}
|
|
1109
|
+
- **Binary truth beats the label; no entry dominates the corpus.** A tracked member whose HEAD bytes contain NUL is materialized as a binary marker (empty body, `application/octet-stream`, READ-415) **regardless of what extension-based detection claims** — the markdown default for unmapped extensions once shipped a 3.3MB `.wasm` blob into the semantic corpus as prose, three copies, ~10M tokens (#320). Every eligible text is tiled losslessly to the embedder window and every tile is embedded before its derivation attaches; `semantic_rank` max-pools the best chunk per entry. {§membership-binary-sniff}
|
|
1177
1110
|
- **A turn is never blank; the provider never adjudicates.** A completed exchange ALWAYS returns from the provider — the model's bytes flow in `assistant` no matter what, with any grammar-conformance verdict riding `response.telemetry` as an **observation** (providers 0.32; the provider transports and observes — the engine's own parse is the judge). Every emission takes the one pipeline: complete statements dispatch, malformed text mints position-carrying parse-error rows the model reads next turn, nothing-parsed is the ordinary no-ops 422, and the record keeps the emission verbatim with its real usage billed. A `ProviderError` reaching the engine means NO completed exchange exists (auth, network beyond retries, rate limit) — an infrastructure failure, and the loop dies 500 carrying the cause, first occurrence: tolerating an infrastructure violation is how a bug accretes concentric layers of policy instead of a fix (the retired empty-turn fallback laundered provider adjudications into model-behavior 422s, and forensics chased the wrong suspect for days). {§turn-never-blank}
|
|
1178
1111
|
- **One model-agnostic ruler; exact only at the materialization gate.** The daemon runs many workers on DIFFERENT models in ONE workspace concurrently (per-loop model selection, #414), and token accounting is workspace-wide (the catalog lists every workspace entry). A single per-model "true" count is therefore a fiction — there is no one model to be true to — and exact tokenizers exist only for models with a bundled `tokenizer.json` (most frontier models degrade to the chars ruler anyway). So the ENTIRE model-facing perspective — catalog per-entry weights, `tokensFree`, ceiling, per-turn weights, write-time stamps — uses ONE model-INDEPENDENT ruler: `rulerCount = ceil(chars/2)` (mimetypes' no-exact heuristic). One number per content, identical no matter which model reads it; zero per-model state; trivially concurrent-safe. It is a conservative UPPER bound, so the model's whole ledger is coherent AND safe: comparing ruler-weight to the real-token ceiling (window − reserves, no calibration ratio) is itself the conservative bias — the model curates against less room than it has and never overflows for typical content, at the cost of some window under-utilization (accepted). The provider's EXACT count is used at exactly ONE point: the packet-materialization fit-gate (`exactPacketTokens` — provider.countTokens of the assembled packet, once per turn), which guards the pathological tail where the ruler under-counts token-dense content. The prior per-alias usage-calibration ratio and the (content_hash, tokenizer_id) per-tokenizer cache were RETIRED (owner ruling 2026-07-13): they delivered per-model exactness the real mixed-model topology mostly can't provide, at the cost of workspace-wide concurrent recount passes — latent-bug surface that reads as model weakness on benchmarks. A provider failure that still escapes lands as a loop-terminal **500 carrying the cause** on both the row and the `loop/terminated` broadcast. {§tokenomics-agnostic-ruler}
|
|
1179
1112
|
- **Curation pressure gates on occupancy.** The budget section's Turns/Heaviest tables — a standing FOLD-target list — render only at **50%+ occupancy** (assembled total / ceiling); below that the headline's numbers stand alone. A high-headroom model reads the tables as a todo and burns turns on token hygiene at 3–25% occupancy (#308, the bench grok run); a null ceiling (an unbounded window, §tokenomics-window-unpollable-deliberate) can't calibrate occupancy, so the headline is omitted and the tables ship un-truncated. The requirements footer carries only the overflow-RECOVERY must, never ambient optimize-your-context pressure. {§tokenomics-pressure-gates-on-occupancy}
|
|
@@ -1195,13 +1128,17 @@ Each entry: question, answer, rationale, migration path.
|
|
|
1195
1128
|
|
|
1196
1129
|
**Question.** How does plurnk represent the project a workspace works on? Where does file membership come from? Does writing an entry imply writing to disk?
|
|
1197
1130
|
|
|
1198
|
-
**The boundary is the client's.** The client owns the model's filesystem access in both directions: reads are membership-gated (a file is invisible to the model unless it is a member), and writes are proposals the client accepts or rejects (
|
|
1131
|
+
**The boundary is the client's.** The client owns the model's filesystem access in both directions: reads are membership-gated (a file is invisible to the model unless it is a member), and writes are proposals the client accepts or rejects (client `--yolo` auto-accepts). Writing an entry never implies writing to disk — entries are canonical in the store; disk only moves when the client accepts a side-effecting proposal, and only where `project_root` is set (null = headless, client owns materialization).
|
|
1199
1132
|
|
|
1200
1133
|
**Tier — workspace is the world; permissions are the workspace's.** Membership, the overlay, and the git flags are **workspace-tier** (`workspace_constraints.workspace_id`, service/workspace config) — never per-worker. Every worker in a workspace shares one world (§machine-processes: one filesystem, one overlay); a worker is a *log* — a perspective over that world — owning no membership of its own. A declaration reshapes the one world for every worker, never per-connection. `workers.origin` is attribution (whose perspective), not a permission.
|
|
1201
1134
|
|
|
1202
1135
|
**Workspace identity.** No `projects` table; `workspaces.project_root TEXT` (nullable = headless) anchors the workspace. `entries.scope ∈ {'workspace','worker'}` (agent-scope retired). Workspace = workspace; no users/auth/multi-tenant.
|
|
1203
1136
|
|
|
1204
|
-
**
|
|
1137
|
+
**Web search and retrieval are one first-class composition.** {§web-search-retrieval} A search runtime enumerates a configured maximum of candidate URLs and hands each to the engine as a `content: null` `entry()` request (§exec-entry-sink): the guarded `WebFetcher` sink fetches candidates in parallel, off the write-serialization chain, and materializes successful bodies as ordinary HTTP entries. SearXNG owns membership and rank; Plurnk does not rerank or classify sources. Failed materializations are mechanically omitted from the model-facing result directory, while survivors retain upstream order. The compact directory carries `title/url/snippet/publishedDate/materialized`; it locates readable resources and is not a substitute for their contents. Without an entry sink the executor cannot test materialization and omits the verdict.
|
|
1138
|
+
|
|
1139
|
+
Search prefetch and direct HTTP READ materialize the same resource contract: protocol+authority+path is the absolute identity; the sanitized readable projection is the fragmentless default; faithful DOM and response metadata remain explicit auxiliary channels. A normal `READ(https://host/path)` therefore publishes only the sanitized body under that exact URL—never raw HTML, response headers, or a channel-selection lesson. FIND, matcher READ, and embeddings consume the same stored readable projection and never re-fetch each match. Because the search family is in `PLURNK_SERVICE_EXEC_HOLD`, the cycle holds until acquisition concludes (§exec-hold-until-concluded), so the next packet contains final materialization verdicts and folded ambient rows for every acquired page.
|
|
1140
|
+
|
|
1141
|
+
Coverage protects the composition at distinct seams: HTTP unit tests pin fragmentless-body publication and explicit auxiliary selection; integration tests pin search→materialize→FIND/matcher READ and persistence/publication separation; deterministic model demos require a model-originated fragmentless HTTPS READ whose returned body contains facts absent from search snippets; a live positive-control demo requires a model-originated HTTPS READ and substantive answer from a real sanitized page. Live discovery demos remain diagnostic and may expose model judgment failures without weakening these assertions. **The search gates** (#406, owner ruling) are rail-family accounting — in-memory per-loop state cleaned at the same seam as strikes, restart-drop accepted (a post-restart duplicate re-fetches; the TTL makes it cheap): an IDENTICAL duplicate (same runtime + command in one loop) **strikes and serves** — status 409 (the strike rail counts the turn failure) carrying the prior ranked digest re-read live from the original exec entry, no re-fetch, no provenance prose; the per-turn CAP (`PLURNK_SERVICE_SEARCH_MAX_PER_TURN`) is flood control — 429 with a legible steer, nothing served. {§search-gate}
|
|
1205
1142
|
|
|
1206
1143
|
**git is the substrate.** {§membership-git-membership} git-tracked files (`git ls-files` semantics) 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. **Every core git read is in-process by default** {§git-portable-default}: isomorphic-git (exact-pinned), an explicit-`dir` library read — no subprocess, no ambient `GIT_*`, no global/system config — so the read is hermetic **by construction** and identical on a sandboxed or git-less host; linked worktrees and submodule boundaries (gitlinks filtered) behave as native. The untracked scan is a pruning ignore-walk, **differential-gated**: it must reproduce native `ls-files --others --exclude-standard` across a gitignore edge-case corpus (negations, anchors, nested `.gitignore` precedence, `.git/info/exclude`, dir-vs-glob patterns, embedded-repo boundaries) or it does not ship. `PLURNK_SERVICE_GIT_NATIVE=1` routes membership + status to the system git binary {§git-native-flag} — in-process membership measures ~8× native (~130ms at 20k files) and the status read ~55× (statusMatrix hashes the workdir), so a large-repo host with git installed buys the hot path back. **That native arm scrubs ambient `GIT_*`** (#401): a process launched from a git hook inherits `GIT_DIR` — absolute in a worktree checkout — which retargets every child git at the enclosing repo regardless of cwd; project git state binds to the workspace's `project_root`, never to whoever spawned the daemon. One boundary (`git-env.hermeticGitEnv`), threaded through the native arm and every shell-git test fixture. {§membership-git-hermetic}
|
|
1207
1144
|
|
|
@@ -1218,9 +1155,13 @@ Each entry: question, answer, rationale, migration path.
|
|
|
1218
1155
|
|
|
1219
1156
|
**File ops act on the entry, not the disk; the two reconcile only at gates.** A `file:///` member is a row whose body channel holds the *materialized snapshot* of its disk content. READ returns that channel; EDIT diffs against it — neither reaches the filesystem directly. Entry and disk reconcile at exactly two gates: the **pre-turn materialize** (disk → entry, below) and the **accept-time write-back** (entry → disk, §proposal). Between the gates the entry is the truth the model curates against, and `synced_sig` — the member's last-synced disk stat (`mtime:size`) — is the version token both gates compare on.
|
|
1220
1157
|
|
|
1221
|
-
**The pump dedups then parallelizes.** {§derivation-dedup-parallel} The
|
|
1158
|
+
**The pump dedups then parallelizes.** {§derivation-dedup-parallel} The derivation identity hashes content, mimetype, reader behavior, embedding configuration, and path-dependent search exclusion. Entries attach that immutable artifact only after it is complete; identical included inputs therefore share one processed FTS row, one symbol graph, and one vector set without copying. Distinct artifacts run with bounded producer concurrency (`PLURNK_SERVICE_DERIVE_CONCURRENCY`). Unset uses a host-relative square-root fan-out: enough independent entries to feed the separate all-core embedding pool without materializing one enormous syntax graph and chunk plan per core. A positive integer is an exact operator budget and `-1` explicitly claims every core. Token-count and embedding batches keep only a pool-sized window of promises alive, so legitimate hundred-thousand-line assets do not turn parallelism into unbounded heap growth. Exact token counts remain the losslessness authority; bounded scheduling does not replace them with an estimate. Every pending entry attaches a terminal classified artifact, identical at concurrency 1 and N. Multi-entry warming reports throttled aggregate planning and embedding milestones as well as corpus completion, so a large tail entry remains visibly active without streaming a pathname ledger to the client.
|
|
1159
|
+
|
|
1160
|
+
Every completed artifact records one terminal disposition: `vector`, `lexical` (only no embedder or an operator size ceiling), `excluded` (the configured search-exclusion table), `nonsemantic` (empty/binary/no embedding content), or `failed` (an entry-local reader/derivation failure). Cancellation alone remains `building` and retries. A failed specimen therefore cannot brick workspace readiness, and the digest reports every non-vector pathname with its disposition and reason.
|
|
1222
1161
|
|
|
1223
|
-
**Identical content embeds once.** {§semantic-embed-dedup}
|
|
1162
|
+
**Identical content embeds once.** {§semantic-embed-dedup} The metaproject's repeated `tokenizer.json` bodies attach one content-addressed derivation artifact. Nothing copies vectors between entries: graph, processed FTS, and chunk vectors exist once and every pathname joins through the artifact hash. Model/configuration changes produce a different identity, so incompatible vector spaces never share.
|
|
1163
|
+
|
|
1164
|
+
**Embedding has an optional size posture.** {§semantic-max-embed-size} `PLURNK_SERVICE_MAX_EMBED_SIZE` is an operator-set maximum UTF-8 byte size eligible for vectors; `0` is unlimited and is the shipped default. The measured value is the handler's curated readable projection when one exists, otherwise the raw body—the source actually sent to the embedder. Exhaustive embedding therefore remains the normal posture. When a nonzero ceiling rejects an oversized projection, the entry remains directly readable with full graph and lexical indexing; only vectors are absent. The setting is folded into the deep derivation signature, so changing it honestly re-derives affected entries. Client telemetry reports compact aggregate progress; the digest records every non-vector pathname, terminal disposition, and reason for forensic inspection.
|
|
1224
1165
|
|
|
1225
1166
|
**Sync is idempotent and change-gated.** {§membership-change-gated-sync} Per turn, membership materializes every member's disk content into its entry — but the *work* is gated on a cheap per-member change-detect: a member unchanged on disk since its last sync is not re-read, re-tokenized, or rewritten. **Coverage is exhaustive — every member is detected every turn — but work is proportional to change**, so a ninety-repo forest costs detection, not a full re-read. Invariant: after a pass every member's entry equals its disk content; a no-change pass is a no-op.
|
|
1226
1167
|
|
|
@@ -1230,7 +1171,7 @@ Each entry: question, answer, rationale, migration path.
|
|
|
1230
1171
|
|
|
1231
1172
|
The version travels *with the proposal*, never re-read from the entry at accept: a sibling worker in the same workspace may reconcile while this proposal sits paused, advancing the entry's `synced_sig` to the drifted disk — comparing against the *current* entry sig would wave that clobber through, so the comparison is always against the sig the proposal was computed at. A proposal that assumed an **absent** path (a create) conflicts only if a file has since appeared; a member with **no recorded snapshot** (an un-materialized entry, null `synced_sig`) has no baseline to guard and writes through — the two are told apart by the proposal's `existed` flag, not by a null sig alone. On a clean landing the entry refreshes to the written content and `synced_sig` is **restamped** to it, so the next reconcile recognizes the model's own write (not an external divergence) and a second same-turn edit bases on the landed bytes, not a stale sig. This is the write-side twin of the read-side change-gate (§membership-change-gated-sync): one `synced_sig`, gating both the re-read and the write.
|
|
1232
1173
|
|
|
1233
|
-
The CAS is the **hard backstop**, at the moment of writing, on every accept path. It composes with — and is distinct from — the
|
|
1174
|
+
The CAS is the **hard backstop**, at the moment of writing, on every accept path. It composes with — and is distinct from — the loop-auto `staleClobberRisk` guard (§proposal-ownership-auto-stale-clobber): that guard refuses to resolve an edit whose target already diverged earlier this turn (the read→propose window, auto path only); the CAS refuses to write against a snapshot disk has left (the propose→write window, every path). Together they bracket the full read→write span.
|
|
1234
1175
|
|
|
1235
1176
|
**Permission flags.** {§membership-git-flags} `PLURNK_SERVICE_GIT_ALLOWED` is the hard ceiling: `=0` denies all git membership service-wide, un-re-enableable — the sandbox/benchmark lockout. `PLURNK_SERVICE_GIT_AUTO` is the default declaration: `=1` (default) declares an implicit `repo` at `project_root` (no-op if it isn't a git tree); `=0` declares nothing — service/clients `repo`-declare explicitly. `ALLOWED` gates `AUTO`.
|
|
1236
1177
|
|
|
@@ -1293,22 +1234,20 @@ The CAS is the **hard backstop**, at the moment of writing, on every accept path
|
|
|
1293
1234
|
|
|
1294
1235
|
**Migration path.** Changes what EDIT rows *show* (input → output); the op surface and EDIT's behaviour are unchanged. Tests asserting the input-heredoc render move to the resulting-span render.
|
|
1295
1236
|
|
|
1296
|
-
### §
|
|
1237
|
+
### §proposal-ownership Loop auto and client YOLO
|
|
1297
1238
|
|
|
1298
1239
|
**Question.** A side-effecting op proposes (§exec) — dispatch pauses at 202 awaiting a client accept/reject (§engine-rails, §methods). But two unrelated needs want to skip the human gate: a service running *headless* (a benchmark, a CI job, a fixture — there may be no client at all), and a *human* who wants "stop asking me" ergonomics in an interactive workspace. One flag, or two mechanisms?
|
|
1299
1240
|
|
|
1300
|
-
**Decision — two distinct
|
|
1241
|
+
**Decision — two distinct mechanisms with distinct names.** Authority lives at one of two layers:
|
|
1301
1242
|
|
|
1302
|
-
- **
|
|
1243
|
+
- **Loop auto** — a per-loop flag, `loops.flags.auto=true`, set via `loop.run({flags:{auto:true}})` or the client `--auto` sugar. The in-tree `auto` listener resolves proposals **in-process** without any `loop.resolve` crossing the wire. No client need be connected. {§proposal-ownership-loop-auto} Its uses are non-interactive automation, benchmarks, CI, fixtures, and deliberately unattended dogfood.
|
|
1303
1244
|
- **Client-side YOLO** — the *client's* own setting (`--yolo` / `PLURNK_YOLO`). The daemon emits the `loop/proposal` notification exactly as always; the client immediately answers `loop.resolve({decision:"accept"})`. The wire roundtrip still happens and the daemon stays **unaware** the acceptance was automatic — indistinguishable from a fast human. Its use is the interactive "stop bothering me" workspace.
|
|
1304
1245
|
|
|
1305
|
-
**The notification carries
|
|
1306
|
-
|
|
1307
|
-
**Server-YOLO is not blind — it refuses a stale clobber.** Auto-accept is not accept-everything: when an EDIT's target diverged on disk *this turn* — a `source=file` env-delta landed in the worker's log since the model's prior turn — the model's EDIT is based on a stale read, and accepting it would silently overwrite the ambient change. The engine flags such a proposal `staleClobberRisk`, and the server-YOLO listener **rejects** it (the reject's outcome is forensics-only, never in the model's rx) rather than accepting; the model sees an ordinary reject and can re-READ the current content and retry. The guard is the engine's, on the auto-accept path it owns — it brackets the read→propose window (server-YOLO only), while the write-back compare-and-swap (§membership-edit-write-cas) brackets the propose→write window on every accept path. {§dual-yolo-stale-clobber-reject}
|
|
1246
|
+
**The notification carries authority.** `loop/proposal` carries `flags` (§notifications), including `auto`, so a connected client suppresses review UI for a proposal the loop will resolve itself. {§proposal-ownership-notification}
|
|
1308
1247
|
|
|
1309
|
-
**
|
|
1248
|
+
**Auto is not blind — it refuses a stale clobber.** When an EDIT's target diverged on disk this turn, accepting it would overwrite an ambient change. The engine flags the proposal `staleClobberRisk`, and the auto listener rejects it; the model can re-READ and retry. This brackets the read→propose window for loop auto, while the compare-and-swap (§membership-edit-write-cas) brackets propose→write for every accept path. {§proposal-ownership-auto-stale-clobber}
|
|
1310
1249
|
|
|
1311
|
-
**
|
|
1250
|
+
**Why two.** `auto` answers whether proposal authority remains inside the loop. Client `yolo` answers how a client responds after authority crosses the boundary. One is execution policy; the other is review ergonomics.
|
|
1312
1251
|
|
|
1313
1252
|
---
|
|
1314
1253
|
|
|
@@ -1380,7 +1319,8 @@ The `log` section is the durable audit; the `errors` section surfaces both — t
|
|
|
1380
1319
|
| notice `kind` | Source | Position |
|
|
1381
1320
|
|---|---|---|
|
|
1382
1321
|
| `grammar_unenforced` | (provider, forwarded) GBNF-filter divergence — the model's bytes diverged from the transported grammar | content-offset into the model's emission |
|
|
1383
|
-
| `embed_progress` |
|
|
1322
|
+
| `embed_progress` | repository materialization/indexing lifecycle (§mimetype-surface); structured phase, count, and percent; `level: info` except terminal failure | none |
|
|
1323
|
+
| `search_progress` | aggregate search-page acquisition lifecycle; structured phase, counts, and percent; never candidate URLs or per-result notices | none |
|
|
1384
1324
|
|
|
1385
1325
|
**Severity on the wire (`level`, required — grammar 0.74.29+).** Every `TelemetryEvent` carries `level: "error" | "warn" | "info"`, set by the **producer** at the emit site — severity is meaning the producer owns, not something the client re-derives by pattern-matching the open `kind` vocabulary. Service mappings: every error log row is `error` (an error is an error); a forwarded `grammar_unenforced` carries the producer's own level (defaulted to `warn` only when the producer predates the field); `embed_progress` is `info`, a progress note that never reaches the errors section. Clients color straight off `level`. {§telemetry-event-level}
|
|
1386
1326
|
|
|
@@ -1388,6 +1328,8 @@ Strike accounting, cycle detection, sudden-death thresholds, and no-ops bookkeep
|
|
|
1388
1328
|
|
|
1389
1329
|
**Client surface.** Engine NOTICES broadcast live via the `telemetry/event` WS notification — same envelope as the model's drained copy (`{ source, kind, level, message?, position?, …kind-specific }` per the grammar's `TelemetryEvent` schema), the moment they land, scoped to the loop's workspace (a `grammar_unenforced` snippet in a debug panel, a workspace 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}
|
|
1390
1330
|
|
|
1331
|
+
**Forensic fidelity and cardinality.** The digest's machine-readable JSON preserves every log row. Targets reconstruct the model-visible address, including hostname, port, and fragment; an authority-bearing URL must never degrade from `https://host/path` to `https:///path`, and folded network-entry storage paths render back to their authority form. Its human Markdown waterfall groups identical per-turn op outcomes and typed `entry_materialized` narrations, reporting the exact count and sequence span (`×N (seq A–B)`). Grouping keys include the complete target, so distinct authorities or channels never collapse. Thus amplification is conspicuous without making the diagnostic artifact itself pathological; packet files remain byte-identical records of what the model saw.
|
|
1332
|
+
|
|
1391
1333
|
**Turn-lifecycle liveness.** The provider `generate()` call is the one long, opaque window in a turn — submit → first committed op is provider latency plus a full first-turn generation (tens of seconds on a local model); a static client screen there is indistinguishable from a hang. The engine brackets `generate()` with two `telemetry/event` NOTICES (`source: "engine:turn"`, `level: "info"`): `turn_awaiting_model` the instant it calls the provider, `turn_generated` when the call resolves and op-parsing begins — a legible reasoning→working heartbeat, NOT model token-content (that stays out of the Log, a paradigm break). Both are suppressed on an aborted loop and broadcast to the workspace like any notice (§telemetry-telemetry-event-notify). Optional intra-generation ticks (a moving counter during the long wait) are a later provider-contract enhancement (an `onProgress` on `generate()`, the `embedBatch` shape); the two-beat bracket needs no provider change. {§turn-lifecycle}
|
|
1392
1334
|
|
|
1393
1335
|
**Content-offset position.** An emission-level error carries a `position: { type: "content-offset", line, column }` into the model's own emission — a parse-error LOG ROW (op='error', §model-entry) and a content-offset NOTICE (e.g. a provider's `grammar_unenforced`) both report the line, not the bytes. The model resolves it against its own emission: the `model` mirror row (§model-entry, always folded) holds the line-numbered emission, and the model `READ`s the folded row at the cited lines — surgical, budget-bounded, no auto-opening. No snippet is embedded — that would duplicate an emission the model can already introspect. {§telemetry-content-offset-pointer}
|
|
@@ -1400,7 +1342,7 @@ The tools capability sheet renders **titleless**, directly under the `definition
|
|
|
1400
1342
|
|
|
1401
1343
|
### §schemes user.schemes — the scheme directory
|
|
1402
1344
|
|
|
1403
|
-
A `## Schemes` section renders in the system slot **after the definition (plurnk.md — grammar + imperatives) and the tools sheet** — a terse directory of the scheme families available this workspace, so the model knows what URI schemes exist before it acts. Each scheme that ships a `manifest.example` contributes ONE bare op — its canonical usage (no scheme prefix; the example self-documents) — into a `plurnk` fence (§tools shares the shape, #441). The doc is NOT linked inline (#270) — it is materialized at `plurnk://docs/<scheme>.md` and discovered via the turn-1 `FIND(plurnk://docs/**)` foist, keeping the raw packet free of doc links. The in-tree core schemes author their depth in `docs/<name>.md` (loaded at boot, shipped with the package);
|
|
1345
|
+
A `## Schemes` section renders in the system slot **after the definition (plurnk.md — grammar + imperatives) and the tools sheet** — a terse directory of the scheme families available this workspace, so the model knows what URI schemes exist before it acts. Each scheme that ships a `manifest.example` contributes ONE bare op — its canonical usage (no scheme prefix; the example self-documents) — into a `plurnk` fence (§tools shares the shape, #441). The doc is NOT linked inline (#270) — it is materialized at `plurnk://docs/<scheme>.md` and discovered via the turn-1 `FIND(plurnk://docs/**)` foist, keeping the raw packet free of doc links. The in-tree core schemes author their depth in `docs/<name>.md` (loaded at boot, shipped with the package); plugin schemes ship `manifest.documentation`. The verbose semantics live in that pull doc (materialized like any entry, READ on demand), not the hot path — terse pushes, depth pulls, the examples fenced like the tools sheet (§tools). A scheme with no example (provisional) is omitted; `PLURNK_SERVICE_DOCS_EXCLUDE` drops a named scheme's line + doc. {§schemes-directory}
|
|
1404
1346
|
|
|
1405
1347
|
### §inject system.inject — the operator injection
|
|
1406
1348
|
|
|
@@ -1426,9 +1368,9 @@ Rendered at the END of the user packet under `## Recap` {§requirements-requirem
|
|
|
1426
1368
|
|
|
1427
1369
|
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.
|
|
1428
1370
|
|
|
1429
|
-
### §matcher-dispatch Matcher dispatch (service-owned, over
|
|
1371
|
+
### §matcher-dispatch Matcher dispatch (service-owned, over plugin primitives)
|
|
1430
1372
|
|
|
1431
|
-
`Matcher.matchAgainstContent` (in-tree, `src/content/matcher.ts`) is the **service's own** dialect dispatch — `Mimetypes.query` is NOT consumed (§mimetype-methods). It handles the **content dialects** and switches on each, calling the
|
|
1373
|
+
`Matcher.matchAgainstContent` (in-tree, `src/content/matcher.ts`) is the **service's own** dialect dispatch — `Mimetypes.query` is NOT consumed (§mimetype-methods). It handles the **content dialects** and switches on each, calling the plugin'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), returning `QueryMatch[]` rendered as `<source-line>:<line>`. `~semantic` and `@graph` are **relation dialects, not content matchers** — FIND resolves them upstream to `(file, span)` items (`~`semantic via `rankSemantic`, `@`graph via `EntryGraph`), so they never reach `matchAgainstContent` (a fail-hard invariant guards the impossible routing). Status mapping (content dialects):
|
|
1432
1374
|
|
|
1433
1375
|
| Result | HTTP status |
|
|
1434
1376
|
|---|---|
|
|
@@ -1456,9 +1398,9 @@ The contract is the grammar's: **plurnk.md §"`<Line> / <Result>`" — "FIND ret
|
|
|
1456
1398
|
| `~`semantic `~q` | the line span of each ranked chunk (a relation, resolved by FIND) | the section about X |
|
|
1457
1399
|
| `@`graph `@<sym` | the line span of each matched symbol occurrence (a relation, resolved by FIND) | where X is referenced |
|
|
1458
1400
|
|
|
1459
|
-
**READ honors FIND.** A READ that resolves to more than the single exact entry — a glob/folder scope, OR any matcher — fans out: the engine runs the scheme's FIND, then writes **one log row per MATCH** (not per file), each delivering that match's content — READ is the content retrieval over FIND's survey (§find-result-catalog-rows). A file with N matches → N rows. It costs **one command** (the model emitted one READ) yet writes N rows, each its own concrete `(file, span)` — individually foldable/killable/re-READable. A matcher row carries the source LINES at the match's span, delivered via a **raw line-slice** so a structural mimetype's item-index `<L>` never mis-slices a span that is, by construction, source lines; a body-less folder/glob row carries the whole entry. A **bare entry, body-less** is the single direct read. Zero matches writes a single `204` row (never silence). {§read-multi-file-fanout}
|
|
1401
|
+
**READ honors FIND.** A READ that resolves to more than the single exact entry — a glob/folder scope, OR any matcher — fans out: the engine runs the scheme's FIND, then writes **one log row per MATCH** (not per file), each delivering that match's content — READ is the content retrieval over FIND's survey (§find-result-catalog-rows). A file with N matches → N rows. It costs **one command** (the model emitted one READ) yet writes N rows, each its own concrete `(file, span)` — individually foldable/killable/re-READable. A matcher row carries the source LINES at the match's span, delivered via a **raw line-slice** so a structural mimetype's item-index `<L>` never mis-slices a span that is, by construction, source lines; a body-less folder/glob row carries the whole entry. A **bare entry, body-less** is the single direct read. Zero matches writes a single `204` row (never silence). If FIND is count-only under §find-count-not-contents, READ writes that bounded FIND summary followed by one **413** READ refusal naming the count and asking for a narrower target or matcher; it delivers ZERO hidden matches. Fan-out checks cancellation between deliveries, so an operator abort bounds additional work rather than waiting for the entire selected set. {§read-multi-file-fanout}
|
|
1460
1402
|
|
|
1461
|
-
> **Source-line provenance (shipped, every dialect).** Each hit carries a source-line span: regex/glob over raw content; jsonpath/xpath over the parsed `deepJson`/`deepXml` projection (the mimetypes
|
|
1403
|
+
> **Source-line provenance (shipped, every dialect).** Each hit carries a source-line span: regex/glob over raw content; jsonpath/xpath over the parsed `deepJson`/`deepXml` projection (the mimetypes plugin reports each hit's line span); `~`semantic the ranked chunk's span; `@`graph the symbol occurrence's span. So the per-match `(file, span)` item is well-defined for every dialect, and READ returns the line uniformly.
|
|
1462
1404
|
|
|
1463
1405
|
### §slice-semantics `<L>` semantics by source mimetype
|
|
1464
1406
|
|