@plurnk/plurnk-service 1.1.1 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.defaults +7 -5
- package/README.md +17 -14
- package/SPEC.md +90 -143
- 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/edited-span.js +1 -1
- package/dist/content/edited-span.js.map +1 -1
- 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 +6 -6
- package/dist/content/matcher.js.map +1 -1
- package/dist/content/mimetype-binary.js +2 -2
- 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 +150 -35
- package/dist/core/Engine.js.map +1 -1
- package/dist/core/Engine.sql +19 -10
- 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 +22 -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 +44 -25
- 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 +2 -0
- package/dist/digest/Digest.d.ts.map +1 -1
- package/dist/digest/Digest.js +88 -11
- 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 +42 -12
- package/dist/schemes/Exec.js.map +1 -1
- package/dist/schemes/File.d.ts.map +1 -1
- package/dist/schemes/File.js +12 -3
- 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 +7 -7
- 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,9 +347,9 @@ 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
|
-
- **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
|
|
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
|
|
|
356
354
|
`SEND[410](path[#fragment])` also deletes the target entry/channel — an implemented side-effect, NOT taught to the model and with no live/demo surface. The model-facing delete idiom is KILL (§move).
|
|
357
355
|
|
|
@@ -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.
|
|
@@ -558,8 +560,9 @@ AST: `{ op: "EDIT", target, body: string | null, signal: tags | null, lineMarker
|
|
|
558
560
|
- Writes body; `body: null` clears. {§edit-null-clears}- Returns `{ status: 201, entryId }` for new entries; `{ status: 200, entryId }` for content updates. {§edit-status-201-200}
|
|
559
561
|
- A write that changes nothing — identical content and no new tag — returns `{ status: 304, entryId }`, mirroring OPEN/FOLD's no-op (§open-fold). {§edit-noop-304}
|
|
560
562
|
- Tags from `signal[]` apply additively via `entry_tags` (scheme may vary). {§edit-tags-additive}
|
|
563
|
+
- **A markerless EDIT is CREATE-ONLY — there is no easy-clobber path on an existing entry.** {§edit-marker-required-on-existing} A `<L>` marker scopes an EDIT to a range; without one, the body becomes the entry's WHOLE content — legitimate and required for a fresh entry (nothing exists to scope into), but on an EXISTING entry a missing marker is refused **400**, never a silent full replace. A deliberate full rewrite states that intent explicitly: `<1,-1>` resolves through the ordinary marker math to the same whole-content replacement, so the capability isn't lost, only the omission. (#571 — run126: a misplaced `<356>` landed inside the body rather than the marker slot, so the EDIT parsed markerless and silently replaced a 1,693-line file with 2; the model's own subsequent reads told it the entry now had 2 lines, twice, and it never connected the fact to its own edit before reporting false success.) This closes the omission's blast radius without capping legitimate content — the same rewrite is always available, it can just never happen by accident.
|
|
561
564
|
|
|
562
|
-
A `file:///` member EDIT diverges from this immediate-write contract: it diffs against the entry snapshot (the body channel, never a fresh disk read) and **proposes** (202) a disk write that lands via a compare-and-swap on accept. See §membership-edit-write-cas and the proposal lifecycle §proposal.
|
|
565
|
+
A `file:///` member EDIT diverges from this immediate-write contract: it diffs against the entry snapshot (the body channel, never a fresh disk read) and **proposes** (202) a disk write that lands via a compare-and-swap on accept. See §membership-edit-write-cas and the proposal lifecycle §proposal. The marker-required rule above applies identically here — an existing file is never markerlessly replaced.
|
|
563
566
|
|
|
564
567
|
### §read READ
|
|
565
568
|
|
|
@@ -577,9 +580,9 @@ OPEN/FOLD operate on the **log** (`log:///`) — the model's context-curation su
|
|
|
577
580
|
|
|
578
581
|
### §jsonplurnk The Log's wire format
|
|
579
582
|
|
|
580
|
-
The `## Log` section renders as a fenced `jsonplurnk` block — a JSON array of entry objects, otherwise-valid JSON with **exactly one** deviation: each `body` value, when present, is a raw HEREDOC (`<<:::TAG … :::TAG`, TAG = the entry's target/log URI), rendered verbatim (numbered for text, tree-navigable verbatim), never a JSON-escaped string. The carve-out is localized to `body`, so the strip-parser is trivial — after `"body":`, `<<:::TAG` opens and `:::TAG` at column 0 closes; replacing that block with an escaped string recovers strict JSON (the plurnkdown linter's transform). Each entry's fold state is a self-documenting `display` field (grammar-ratified #437 — `display:none` mirrors CSS, `folded`/`open` echo the FOLD/OPEN ops): `"display":"none"` (no body), `"folded"` (a body exists but is hidden, still weighable), `"open"` (body shown, heredoc present). The block
|
|
583
|
+
The `## Log` section renders as a fenced `jsonplurnk` block — a JSON array of entry objects, otherwise-valid JSON with **exactly one** deviation: each `body` value, when present, is a raw HEREDOC (`<<:::TAG … :::TAG`, TAG = the entry's target/log URI), rendered verbatim (numbered for text, tree-navigable verbatim), never a JSON-escaped string. The carve-out is localized to `body`, so the strip-parser is trivial — after `"body":`, `<<:::TAG` opens and `:::TAG` at column 0 closes; replacing that block with an escaped string recovers strict JSON (the plurnkdown linter's transform). Each entry's fold state is a self-documenting `display` field (grammar-ratified #437 — `display:none` mirrors CSS, `folded`/`open` echo the FOLD/OPEN ops): `"display":"none"` (no body), `"folded"` (a body exists but is hidden, still weighable), `"open"` (body shown, heredoc present). The block is data only — no prose leads the fence. The numbers' semantics: `tokens` is the ruler-weight of the row's body in this packet — the room it takes (what OPEN adds, what FOLD saves); a FIND's `itemsTokenTotal` is the ruler-weight of the matched entries themselves (the room READing them takes) — curation weights, not dollars. The invariants bind regardless of shape (§packet): addressability (`path`/`target`/`#channel`/numbered bodies), weighability (per-item `tokens`), honesty (every 4xx/5xx row, bodyless-0-token items, the explicit fold state). This is the Log's realization of the packet-wide plurnkdown house style — the whole outbound packet is one coherent document, log data stays JSON housed in a fence. {§jsonplurnk}
|
|
581
584
|
|
|
582
|
-
The opening fence length is **dynamic**: one backtick longer than the longest backtick run in any body (floor 3). A body can carry arbitrary content — a READ of a doc whose own text opens a column-0 triple-backtick fence — which a fixed opener would let close the block early; a dynamic opener can never be closed by its own body content (CommonMark closes a fence only on a line of at least its own length), independent of the `N
|
|
585
|
+
The opening fence length is **dynamic**: one backtick longer than the longest backtick run in any body (floor 3). A body can carry arbitrary content — a READ of a doc whose own text opens a column-0 triple-backtick fence — which a fixed opener would let close the block early; a dynamic opener can never be closed by its own body content (CommonMark closes a fence only on a line of at least its own length), independent of the `N:` numbering that incidentally keeps text bodies off column 0. {§jsonplurnk-dynamic-fence}
|
|
583
586
|
|
|
584
587
|
### §model-entry The model's own emission, mirrored back
|
|
585
588
|
|
|
@@ -593,6 +596,8 @@ The worker's **first** model row is exceptional: a born-OPEN turn-0 **exemplar**
|
|
|
593
596
|
|
|
594
597
|
**OPEN and FOLD are meta-operations — render directives, not actions.** They change how the world *displays*, never what it *is* (scrolling, not editing). A **successful** OPEN/FOLD **is recorded in the log** but **suppressed from the packet render** (#382): the row exists for forensics — a curation act with NO trace is how a weak model folding its own task frame stayed invisible until a database dig — while the render still costs it nothing, so FOLD stays genuinely free (the original rowless design's concern, met by hide-not-drop). The emission also survives verbatim in the `model` mirror. A **failed** OPEN/FOLD (bad target, bad range) renders normally with its status — errors are signals. The idle-turn gate reads the *emitted statements*, so a pure-curation turn is work, never idleness. {§fold-open-meta-operations}
|
|
595
598
|
|
|
599
|
+
**A successful KILL of a log item is suppressed from the render too — same principle, different mechanism.** KILL is a real deletion (not a meta-op), but once it has executed against a `log://` target its tombstone is *spent*: the killed row is gone, and a receipt saying "I deleted it" is no forward-actionable context — it is exactly the OPEN/FOLD case one step later. So a **successful** `KILL` whose target is a **log item** is recorded in the DB (forensics) and suppressed from the packet, while its emission survives in the `model` mirror. This closes a self-growing curation trap: a floor model that KILLs log rows to reclaim budget otherwise accumulates one bodyless-but-~130-char tombstone per kill, and per-row KILL can never shrink the log (each clear leaves a new receipt) — run61 reached ~2000 tombstones, 95% of the packet. The suppression is **scoped to log targets**: a `KILL` of a `worker://` note, an `sh://` stream, or any stored artifact is a **world mutation**, not log housekeeping, and stays visible. A **failed** KILL (bad target, no match ≠ error but a malformed coordinate is) renders like any error. {§kill-log-receipt-suppressed}
|
|
600
|
+
|
|
596
601
|
### §copy COPY (engine-orchestrated)
|
|
597
602
|
|
|
598
603
|
AST: `{ op: "COPY", target (source), body (destination), signal: tags | null, lineMarker? }`.
|
|
@@ -620,11 +625,11 @@ Log history preserved — `log_entries` stores path tuple as text, not FK to `en
|
|
|
620
625
|
|
|
621
626
|
AST: `{ op: "FIND", target (scope), body: MatcherBody | null (predicate), signal: tags | null, lineMarker? }`.
|
|
622
627
|
|
|
623
|
-
- 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}
|
|
624
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}
|
|
625
630
|
- `signal` is a tag filter; entries match if they have ALL listed tags. {§find-tag-filter-and-semantics}
|
|
626
631
|
- Workspace + scheme scoped — no cross-workspace/cross-scheme leakage. {§find-scoped-isolation}
|
|
627
|
-
- 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}
|
|
628
633
|
|
|
629
634
|
### §send SEND
|
|
630
635
|
|
|
@@ -651,7 +656,7 @@ The engine's failure terminals — **500** (strike threshold) and **508** (cycle
|
|
|
651
656
|
|
|
652
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."*
|
|
653
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).
|
|
654
|
-
- **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}
|
|
655
660
|
|
|
656
661
|
### §exec EXEC
|
|
657
662
|
|
|
@@ -679,7 +684,7 @@ A `read` runtime (observes external state, e.g. search) or `pure` runtime (no ob
|
|
|
679
684
|
|
|
680
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}
|
|
681
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.
|
|
682
|
-
- **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.
|
|
683
688
|
|
|
684
689
|
### §proposal The proposal lifecycle
|
|
685
690
|
|
|
@@ -687,7 +692,7 @@ A side-effecting op does not execute on dispatch — it **proposes**. The scheme
|
|
|
687
692
|
|
|
688
693
|
**Resolution arrives four ways, one surface to the model:**
|
|
689
694
|
- **`loop.resolve`** (§methods) — a client's accept / reject / cancel.
|
|
690
|
-
- **
|
|
695
|
+
- **Loop auto** (§proposal-ownership) — an in-tree listener resolves `accept` in-process, same tick, no wire roundtrip.
|
|
691
696
|
- **noProposals** — an in-tree listener resolves `reject` (outcome `no_review_channel`).
|
|
692
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.
|
|
693
698
|
|
|
@@ -758,13 +763,13 @@ SQLite (`node:sqlite`) with WAL mode and STRICT tables. Hand-written DDL; CI-ali
|
|
|
758
763
|
|
|
759
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.
|
|
760
765
|
|
|
761
|
-
- One `.sql` file per cohesive concern under `migrations
|
|
762
|
-
- DDL lives in
|
|
763
|
-
-
|
|
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.
|
|
764
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.
|
|
765
770
|
- DDL = storage truth; JSON Schemas = wire truth. Tested-aligned, allowed to differ where ergonomics demand.
|
|
766
|
-
- **Schema-version stamp.** {§db-schema-version-stamp} Every plurnk DB carries `PRAGMA user_version` (current: `
|
|
767
|
-
- **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.
|
|
768
773
|
|
|
769
774
|
### §sql-ts-boundary SQL/TS responsibility boundary
|
|
770
775
|
|
|
@@ -790,7 +795,7 @@ When SQL becomes onerous for a specific case, retreat for that case and document
|
|
|
790
795
|
|
|
791
796
|
## §plugin-discovery Plugin Discovery
|
|
792
797
|
|
|
793
|
-
|
|
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.
|
|
794
799
|
|
|
795
800
|
Scoped-package scan with manifest field:
|
|
796
801
|
|
|
@@ -851,7 +856,7 @@ Plugin discovery (§plugin-discovery) registers whatever's in `node_modules/@plu
|
|
|
851
856
|
- Backpressure caps — none (§stream-constraints).
|
|
852
857
|
- Stream cancel — `SEND[499]` (§stream-control).
|
|
853
858
|
- Delete — `KILL` (entry-KILL, the canonical delete, §move); `SEND[410]` also deletes as a side-effect (§send-dispatch).
|
|
854
|
-
- Per-loop flags — `loops.flags` JSON column; `
|
|
859
|
+
- Per-loop flags — `loops.flags` JSON column; `auto`, `noProposals`, `noWeb`, `noInteraction`, and `mode`.
|
|
855
860
|
- Default-channel wire rendering — §channel-selection.
|
|
856
861
|
|
|
857
862
|
---
|
|
@@ -868,7 +873,7 @@ Model selection: separate alias cascade in `ProviderRegistry` (§provider-instan
|
|
|
868
873
|
|-----|---------|---------|
|
|
869
874
|
| `PLURNK_SERVICE_DB_PATH` | `~/.plurnk/plurnk.db` | SQLite file path. |
|
|
870
875
|
| `PLURNK_HOST` | `127.0.0.1` | Bind address for the listener. Local-only by default. |
|
|
871
|
-
| `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. |
|
|
872
877
|
| `PLURNK_SERVICE_MAX_TURNS` | `-1` | Operator turn **ceiling** — `-1` = no cap; a positive value caps a per-call `loop.run({maxTurns})`. |
|
|
873
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). |
|
|
874
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} |
|
|
@@ -909,26 +914,11 @@ External plugins declare their own env vars in their own `.env.defaults`, assemb
|
|
|
909
914
|
|
|
910
915
|
---
|
|
911
916
|
|
|
912
|
-
## §rpc
|
|
913
|
-
|
|
914
|
-
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.
|
|
915
|
-
|
|
916
|
-
### §transport Transport
|
|
917
|
-
|
|
918
|
-
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.
|
|
919
|
-
|
|
920
|
-
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
|
|
921
918
|
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
JSON-RPC 2.0. Two message kinds:
|
|
927
|
-
|
|
928
|
-
- **Request:** `{ "jsonrpc": "2.0", "id": …, "method": …, "params": … }`. Server replies with matching `id`.
|
|
929
|
-
- **Notification:** `{ "jsonrpc": "2.0", "method": …, "params": … }`. No `id`; server-initiated; no reply.
|
|
930
|
-
|
|
931
|
-
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.
|
|
932
922
|
|
|
933
923
|
### §method-registration Method registration
|
|
934
924
|
|
|
@@ -1013,7 +1003,7 @@ registry.registerMethod("loop.run", {
|
|
|
1013
1003
|
|
|
1014
1004
|
| Method | Params | Result | Notes |
|
|
1015
1005
|
|-------------------|-------------------------------------|------------------------|-------|
|
|
1016
|
-
| `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} |
|
|
1017
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} |
|
|
1018
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. |
|
|
1019
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} |
|
|
@@ -1057,15 +1047,13 @@ All `op.*` return `{ status, ...op-specific }`. All `requiresInit: true`. None `
|
|
|
1057
1047
|
|
|
1058
1048
|
Future: `subscription.list`, `subscription.cancel` (the latter is `op.send({status: 499, recipient})` today).
|
|
1059
1049
|
|
|
1060
|
-
### §notifications
|
|
1061
|
-
|
|
1062
|
-
Server-initiated events on the same WebSocket.
|
|
1050
|
+
### §notifications Events
|
|
1063
1051
|
|
|
1064
1052
|
| Notification | Params | When fired |
|
|
1065
1053
|
|--------------------|-------------------------------------|------------|
|
|
1066
1054
|
| `log/entry` | `{ entry: LogEntry }` | Every `log_entries` write. {§notifications-log-entry-notify} |
|
|
1067
1055
|
| `loop/terminated` | `{ loopId, finalStatus, hitMaxTurns }` | Loop reaches terminal status. |
|
|
1068
|
-
| `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). |
|
|
1069
1057
|
| `workspace/created` | `{ id, name, projectRoot }` | Any client creates a workspace. |
|
|
1070
1058
|
| `stream/event` | `{ entryId, channel, state, contentLength }` | Channel content grows or state transitions. {§notifications-stream-event-on-channel-change} |
|
|
1071
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} |
|
|
@@ -1073,68 +1061,16 @@ Server-initiated events on the same WebSocket.
|
|
|
1073
1061
|
|
|
1074
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.
|
|
1075
1063
|
|
|
1076
|
-
### §connection-lifecycle
|
|
1064
|
+
### §connection-lifecycle Client context
|
|
1077
1065
|
|
|
1078
|
-
|
|
1079
|
-
[client] [daemon]
|
|
1080
|
-
| |
|
|
1081
|
-
|-- ws.connect ----------------------------------->|
|
|
1082
|
-
|<------- on('open') --------------------------- |
|
|
1083
|
-
| |
|
|
1084
|
-
|-- discover() ---------------------------------->|
|
|
1085
|
-
|<------- { methods, notifications, capabilities }|
|
|
1086
|
-
| |
|
|
1087
|
-
|-- workspace.attach(id=42) ------------------------>|
|
|
1088
|
-
|<------- { id: 42, name: "demo-workspace" } |
|
|
1089
|
-
| (daemon opens a client loop in workspace 42) |
|
|
1090
|
-
| |
|
|
1091
|
-
|-- loop.run(prompt="...") ----------------------->|
|
|
1092
|
-
|<-- notification: log/entry { ... } |
|
|
1093
|
-
|<-- notification: log/entry { ... } |
|
|
1094
|
-
|<-- notification: loop/terminated { ... } |
|
|
1095
|
-
|<------- { loopId, turnIds, finalStatus: 200 } |
|
|
1096
|
-
| |
|
|
1097
|
-
|-- op.dispatch(op=...) -------------------------->|
|
|
1098
|
-
|<-- notification: log/entry { ... } |
|
|
1099
|
-
|<------- { status: 201 } |
|
|
1100
|
-
| |
|
|
1101
|
-
|-- ws.close ------------------------------------->|
|
|
1102
|
-
| (daemon closes the client loop; workspace keeps)|
|
|
1103
|
-
```
|
|
1104
|
-
|
|
1105
|
-
**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.
|
|
1106
|
-
|
|
1107
|
-
`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.
|
|
1108
|
-
|
|
1109
|
-
### §errors Errors
|
|
1110
|
-
|
|
1111
|
-
Standard JSON-RPC codes:
|
|
1112
|
-
|
|
1113
|
-
| Code | Meaning |
|
|
1114
|
-
|--------|-------------------------------|
|
|
1115
|
-
| -32700 | Parse error (malformed JSON) |
|
|
1116
|
-
| -32600 | Invalid request |
|
|
1117
|
-
| -32601 | Method not found |
|
|
1118
|
-
| -32602 | Invalid params |
|
|
1119
|
-
| -32603 | Internal error |
|
|
1120
|
-
|
|
1121
|
-
Plurnk-specific (`-32000` to `-32099`):
|
|
1122
|
-
|
|
1123
|
-
| Code | Meaning |
|
|
1124
|
-
|--------|----------------------------------------------------|
|
|
1125
|
-
| -32000 | Not initialized (requires workspace attach) |
|
|
1126
|
-
| -32001 | Workspace not found |
|
|
1127
|
-
| -32002 | Loop not found |
|
|
1128
|
-
| -32003 | Entry not found (engine 404) |
|
|
1129
|
-
| -32004 | Provider unavailable |
|
|
1130
|
-
| -32005 | Scheme unavailable |
|
|
1131
|
-
| -32006 | Mimetype unavailable |
|
|
1132
|
-
| -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.
|
|
1133
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.
|
|
1134
1069
|
|
|
1135
1070
|
### §versioning Versioning
|
|
1136
1071
|
|
|
1137
|
-
|
|
1072
|
+
The module seam follows the platform major. External protocol compatibility is
|
|
1073
|
+
owned by the client-interface module that publishes it.
|
|
1138
1074
|
|
|
1139
1075
|
---
|
|
1140
1076
|
|
|
@@ -1169,8 +1105,8 @@ Each entry: question, answer, rationale, migration path.
|
|
|
1169
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}
|
|
1170
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}
|
|
1171
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.
|
|
1172
|
-
- **Derivation is
|
|
1173
|
-
- **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}
|
|
1174
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}
|
|
1175
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}
|
|
1176
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}
|
|
@@ -1192,13 +1128,17 @@ Each entry: question, answer, rationale, migration path.
|
|
|
1192
1128
|
|
|
1193
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?
|
|
1194
1130
|
|
|
1195
|
-
**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).
|
|
1196
1132
|
|
|
1197
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.
|
|
1198
1134
|
|
|
1199
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.
|
|
1200
1136
|
|
|
1201
|
-
**
|
|
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}
|
|
1202
1142
|
|
|
1203
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}
|
|
1204
1144
|
|
|
@@ -1215,9 +1155,13 @@ Each entry: question, answer, rationale, migration path.
|
|
|
1215
1155
|
|
|
1216
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.
|
|
1217
1157
|
|
|
1218
|
-
**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.
|
|
1161
|
+
|
|
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.
|
|
1219
1163
|
|
|
1220
|
-
**
|
|
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.
|
|
1221
1165
|
|
|
1222
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.
|
|
1223
1167
|
|
|
@@ -1227,7 +1171,7 @@ Each entry: question, answer, rationale, migration path.
|
|
|
1227
1171
|
|
|
1228
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.
|
|
1229
1173
|
|
|
1230
|
-
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.
|
|
1231
1175
|
|
|
1232
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`.
|
|
1233
1177
|
|
|
@@ -1246,7 +1190,9 @@ The CAS is the **hard backstop**, at the moment of writing, on every accept path
|
|
|
1246
1190
|
- **The hard overflow is a RECOVERY TURN first** {§grinder-hard-413-recovery} — (Q4, owner ruling: recoverable strike, NO margin). The overflow lives in foldable HISTORY the model owns, and the grinder never touches history (§grinder-layer1-rollback) — so the first hard overflow, when the packet is over the POLICY ceiling but still within PHYSICS (the provider's real window minus the decode reserve), is sent ANYWAY, once: a minted `budget_overflow` steer states the fact, the remedy (KILL/FOLD history; the budget table lists the heaviest), and the consequence — and the turn counts a strike (`budgetStruck`). The model curates → the next fitting turn clears the grant (a later overflow earns a fresh recovery; chronic overflow still strikes out via the rail). A recovery turn that CONCLUDES is a legitimate 200 — finishing is a way to stop overflowing. **No 95% margin**: the pointer fires at 100% of budget — a margin masks the signal, and the decode is separately reserved.
|
|
1247
1191
|
- **Hard stop.** A physically-unsendable packet (over the provider's real window too — physics doesn't negotiate), or a SECOND consecutive hard overflow after the recovery turn (the model was told and declined), abandons the loop at **413 Content Too Large** (`engine_loop_set_status`). Its sibling engine-imposed terminals are HTTP-precise too: `maxTurns` → 429, a strike-out → 500 (508 when cycle-driven) — no longer the old catch-all 499. No further passes. {§grinder-hard-413-abort}
|
|
1248
1192
|
|
|
1249
|
-
- **Output truncation states its cause** {§tokenomics-output-truncated} — a `finish=length` turn was guillotined at the decode pool (`REASONING + COMPLETION`, one undifferentiated `max_tokens` — §tokenomics-window-partition), and its parse errors are truncation ARTIFACTS: without the cause stated, the model reads "unclosed block" and fixes syntax forever instead of emitting less (run29: unclamped cloud reasoning + a 9-edit emission vs a 12288 pool; the binding cap was plurnk's own — `completion 12281`). One terse `output_truncated` row (413) leads the artifact rows it explains, naming the cap and distinguishing its two shapes so the model reads the right cause: content cut **mid-op** (
|
|
1193
|
+
- **Output truncation states its cause** {§tokenomics-output-truncated} — a `finish=length` turn was guillotined at the decode pool (`REASONING + COMPLETION`, one undifferentiated `max_tokens` — §tokenomics-window-partition), and its parse errors are truncation ARTIFACTS: without the cause stated, the model reads "unclosed block" and fixes syntax forever instead of emitting less (run29: unclamped cloud reasoning + a 9-edit emission vs a 12288 pool; the binding cap was plurnk's own — `completion 12281`). One terse `output_truncated` row (413) leads the artifact rows it explains, naming the cap and distinguishing its two shapes so the model reads the right cause: content cut **mid-op** (the severed frame — see {§broken-packet-no-dispatch}) vs the pool consumed with **nothing emitted** (reasoning ran away — the parse `must begin with PLAN` is an artifact of the empty emission, not a malformed turn). The row states the fact, not a remedy.
|
|
1194
|
+
|
|
1195
|
+
- **A broken packet dispatches nothing.** {§broken-packet-no-dispatch} A `finish=length` emission that failed to parse (empty, or cut mid-op) is a **broken packet** — structurally incomplete, its parsed "ops" a severed frame, not the model's intent. Core does NOT pass that garbage along: **nothing dispatches**, and the turn is a no-ops **422** strike (so a repeated runaway can't spin as a `102` continue). It still RECORDS through the ordinary error channel — the folded `model` mirror, the `output_truncated` 413, and the parse-error rows — so the model sees why and re-emits next turn (no same-turn re-generate; the existing rail carries recovery). run42: an unclosed `<<PLAN` swallowed a 57,276-char runaway; core had been materializing it as a bogus status-200 `PLAN` op (29k, grinder-exempt) — a budget bomb that gated out the recovery. This is the STRUCTURAL failure (a severed frame), categorically distinct from a **flubbed op** — a well-framed turn (`finish=stop`) whose single op carries a local error still dispatches its valid ops and steers on the bad one (the recovery rail). The gate is `finish=length` + a failed parse: `finish=length` is the provider's definitive "I guillotined this" signal, so the two together mean precisely "truncation severed the frame" — the complete signal, not a heuristic. A deliberate syntax error is not truncation and is correctly left to the recovery rail. The parse rows stay — the record never hides — and the partition's bluntness for unclamped cloud reasoning (reasoning-tokens and emission-tokens sharing one pool) is a known limit: sizing REASONING to the model class is the operator lane; a per-API split decode budget is a providers-contract candidate, owner-gated (run52 made it concrete — a cloud reasoner spent the entire 65536 pool on reasoning, emitting zero content, three times).
|
|
1250
1196
|
|
|
1251
1197
|
- **Fetch-fits-free — the pressure law** {§tokenomics-fetch-fits-free} — under budget pressure, a retrieval larger than the headline's Tokens Free arrives ALREADY FOLDED: the result lands in the next build, the build overflows, and the grinder's one rule folds the newest boundary — which is exactly that result. The model never sees an oversized fetch open, however many times it re-fetches (the read→grind→re-read spiral: run24 on grok, the jumbo fixture on gemma — five turns of narrowing asks, three grind-strikes, 500). The engine's numbers are honest and the grinder is doctrine-correct; the model's ONLY working lever is ordering: FOLD history first (the Heaviest-items table is the target list), then fetch within the room made. The 413 error row states this law verbatim — the signal fires exactly when the lesson applies. Corollary: each turn accrues ~a couple hundred tokens of unavoidable meta (the mirror row, PLAN, budget growth), so a Tokens Free below that accretion is a dead state the NEXT build inherits — conclude or fold before it reaches zero, not at zero.
|
|
1252
1198
|
|
|
@@ -1288,22 +1234,20 @@ The CAS is the **hard backstop**, at the moment of writing, on every accept path
|
|
|
1288
1234
|
|
|
1289
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.
|
|
1290
1236
|
|
|
1291
|
-
### §
|
|
1237
|
+
### §proposal-ownership Loop auto and client YOLO
|
|
1292
1238
|
|
|
1293
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?
|
|
1294
1240
|
|
|
1295
|
-
**Decision — two distinct
|
|
1241
|
+
**Decision — two distinct mechanisms with distinct names.** Authority lives at one of two layers:
|
|
1296
1242
|
|
|
1297
|
-
- **
|
|
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.
|
|
1298
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.
|
|
1299
1245
|
|
|
1300
|
-
**The notification carries
|
|
1301
|
-
|
|
1302
|
-
**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}
|
|
1303
1247
|
|
|
1304
|
-
**
|
|
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}
|
|
1305
1249
|
|
|
1306
|
-
**
|
|
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.
|
|
1307
1251
|
|
|
1308
1252
|
---
|
|
1309
1253
|
|
|
@@ -1334,7 +1278,7 @@ The wire projection (`PacketWire.renderSlot`) groups sections by slot into the s
|
|
|
1334
1278
|
|
|
1335
1279
|
**Sealed reasoning rides the mirror row.** {§sealed-reasoning-carrier} A provider's ENCRYPTED reasoning — the OpenAI Responses reasoning-item LIST (`[{id, subtype, encrypted: [{data, format}]}]` — a turn can carry N distinct-id items; normalized by the provider tier: id + subtype from the wire, never synthesized) — lands VERBATIM in the model mirror row's `attrs.reasoning` — per turn, on the same `log/entry` broadcast and `readLog` read the client seam already consumes; agui projects one correlated `REASONING_ENCRYPTED_VALUE` span per item (#482). A cross-lane conformance test drives core's real write through agui's real Translator, so a future non-meeting seam is a red pre-push gate, not a shipped-broken main. The blobs are never decoded, never synthesized, and never rendered into a packet — the packet renderer reads no foreign attrs keys, so the model never pays tokens for ciphertext it cannot read. Readable text keeps riding `assistant.reasoning`; the two never mix.
|
|
1336
1280
|
|
|
1337
|
-
**The arrival law.** {§arrival-law} Every body lands as an entry and appears in the packet as a weighted meta line; how much body rides OPEN on arrival is set by INVITATION. **Self-invited** content — what this worker addressed by its own op (READ/FIND/OPEN results, its own exec streams) — rides open within free headroom; over, it arrives folded (the grinder + budget_overflow contract). **Pushed** content — text another actor put in front of this worker (the user prompt, a child's deliverable, a sibling irc) — rides the PREVIEW: whole when under the bound, the head + the cut statement + the pull address when over. The bound is `PLURNK_SERVICE_ARRIVAL_PREVIEW_LINES` (default 16) lines AND 80×N chars — the char cap guards single-line bombs. The trust line is authorship: a worker's own ask is its curation, weighed in advance; another actor's push is unratified by the recipient at any size (run111: a child's legally-terminal 19,363-token deliverable landed whole in its parent, was relayed into a 40k child prompt, and cascaded the pipeline — #499). A child whose loop ends non-2xx already lands FOLDED (the termination-delta insert); the preview bounds the ratified-giant case. New lanes inherit the law at the arrival boundary — a future scheme or module cannot reopen the hole by adding a feature. The preview is enforced at RENDER for every pushed lane — the prompt foist's line-slice bounds lines, and the render's char cap cuts what a line-slice cannot (a single-line char-bomb prompt renders as its 80×N-char head + the cut statement + its address).
|
|
1281
|
+
**The arrival law.** {§arrival-law} Every body lands as an entry and appears in the packet as a weighted meta line; how much body rides OPEN on arrival is set by INVITATION. **Self-invited** content — what this worker addressed by its own op (READ/FIND/OPEN results, its own exec streams) — rides open within free headroom; over, it arrives folded (the grinder + budget_overflow contract). **Pushed** content — text another actor put in front of this worker (the user prompt, a child's deliverable, a sibling irc) — rides the PREVIEW: whole when under the bound, the head + the cut statement + the pull address when over. The bound is `PLURNK_SERVICE_ARRIVAL_PREVIEW_LINES` (default 16) lines AND 80×N chars — the char cap guards single-line bombs. The trust line is authorship: a worker's own ask is its curation, weighed in advance; another actor's push is unratified by the recipient at any size (run111: a child's legally-terminal 19,363-token deliverable landed whole in its parent, was relayed into a 40k child prompt, and cascaded the pipeline — #499). A child whose loop ends non-2xx already lands FOLDED (the termination-delta insert); the preview bounds the ratified-giant case. New lanes inherit the law at the arrival boundary — a future scheme or module cannot reopen the hole by adding a feature. The preview is enforced at RENDER for every pushed lane — the prompt foist's line-slice bounds lines, and the render's char cap cuts what a line-slice cannot (a single-line char-bomb prompt renders as its 80×N-char head + the cut statement + its address). **A model's own COMPOSED op bodies ride the same preview** (#566): a `PLAN`/`SEND`/`WORK`/`FORK`'s text or an `EXEC` command renders preview-bounded (the same 16-line, 80×N-char bound), so no text the model composes can land as an unbounded OPEN log row. **CONTENT ops are exempt and render full** — `READ`/`FIND` (retrieved bytes; capping a `READ` would break its `<start,end>` slice contract) and `EDIT`/`COPY`/`MOVE` spans (the resulting file content the model inspects to confirm its edit). The line is compose-vs-inspect: a plan/message/task is composed text the model restates and never needs to re-read; a file span is bytes it reads back. `EDIT` is grinder-foldable (only `error`+`PLAN` are grinder-exempt), so even a large span reclaims — it was never the permanent-bomb shape. run42 is why: an unclosed `<<PLAN` swallowed a 57,276-char runaway emission into ONE 29k open `PLAN` row the grinder is forbidden to fold (§grinder-layer1-rollback exempts `PLAN` so the model always sees its plan), which pushed the packet past the provider's physical window and gated out the hard-413 recovery — a permanent, un-reclaimable budget bomb. The verbatim emission survives in the folded `model` mirror, so the render bound loses nothing. {§arrival-law-authored-bodies}
|
|
1338
1282
|
|
|
1339
1283
|
**Prompt as a first-class entry.** Each loop's prompt is written on loop start as a plurnk-origin `EDIT` against `prompt:///<loop>/<N>` (indexable, body channel, text/markdown); injected mid-loop prompts land the same way at their turn's slot. The **User Prompts** section closes the status clump (user slot, just above the recap — {§packet-cache-monotone}) as a PATHS-ONLY list (the errors shape — `* prompt:///<loop>/<N>`, no bodies): every prompt the loop holds stays listed and READable by address, so prior prompts are never silently lost and recovering them is never a curation imposition. {§prompt-auto-read} **The frame is SELF-ONLY and owner-keyed** (#527, closing #382 with zero exceptions): the entry's owner_id is the worker, the address carries only the loop coordinate — no worker identity ever rides a pathname or a packet. Concurrent workers' identical coordinates (every worker's first loop is sequence 1) are DISTINCT rows on the owner column, so a WORK-spawned sister's turn-1 foist can never clobber the parent's task. The scheme takes no authority slot: packets are per-worker and every cross-worker prompt flow (parent→child inject, the drain's orphan promotion) is engine-mediated, so a worker only ever addresses its own frames; every writer and query builds through one boundary (`plurnk-uri.promptPathname`/`promptLoopPrefix` + the query's owner param). The model never constructs the address — it READs what the section lists. {§prompt-self-only} **A loop CONTAINS its prompts — plural, ordinal-keyed** (owner ruling): a loop is created by its originating prompt (frame 1) and holds EVERY prompt that arrives before it concludes — each lands at the loop's next ordinal (`prompt:///<loop>/<N>`, the Nth prompt of the loop), so rapid arrivals are frames N and N+1, both kept, nothing superseded, and the key structurally cannot collide regardless of turn timing. A new prompt raises a NEW loop only when no active loop exists to contain it. Delivery is the next turn boundary's auto-READ of every not-yet-delivered frame (oldest first — the frame's own foisted READ in the loop is the delivery record); a frame still undelivered when the loop concludes is the orphan the drain promotes into a fresh loop, so no instruction is ever silently lost. {§prompt-loop-containment} The prompt's CONTENT reaches the model through a foisted auto-`READ` of its own entry — `<1,16>` (the §arrival-law preview bound, `PLURNK_SERVICE_ARRIVAL_PREVIEW_LINES`), or `<1,-1>` when the prompt runs under it (the whole-read form doubles as teaching) — dispatched on the first turn for the loop prompt and on the landing turn for each injected prompt, so an arriving prompt always opens in the log like any retrieval. The foisted `EDIT`'s **log row is folded by default** (`expanded=0`): the body arrives via the auto-READ, so the log keeps the write for forensics while collapsing the duplicate, re-OPENable like any fold (§open-fold). {§prompt-fold} **A model `FOLD` targeting the CURRENT loop's foisted preview READ is ILLEGAL** (#382 — a weak model in a housekeeping turn folded its own task auto-READ and lost the plot): the fold is refused (*"Illegal attempt to FOLD the task preview. Use KILL if you want it removed."*), so the frame can't be accidentally hidden — while `KILL` still deletes it deliberately, preserving the model's curation of a prompt it genuinely wants gone. The refusal binds exactly one row (prompt target, origin plurnk, op `READ`, this loop): the born-folded `EDIT` foist, the model's own deeper prompt `READ`s, and prior loops' previews are ordinary curatable rows — a fold there is legal curation, and an OPENed frame body can always be folded back. A glob sweep silently spares the preview and folds the rest. {§prompt-fold-illegal}
|
|
1340
1284
|
|
|
@@ -1375,7 +1319,8 @@ The `log` section is the durable audit; the `errors` section surfaces both — t
|
|
|
1375
1319
|
| notice `kind` | Source | Position |
|
|
1376
1320
|
|---|---|---|
|
|
1377
1321
|
| `grammar_unenforced` | (provider, forwarded) GBNF-filter divergence — the model's bytes diverged from the transported grammar | content-offset into the model's emission |
|
|
1378
|
-
| `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 |
|
|
1379
1324
|
|
|
1380
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}
|
|
1381
1326
|
|
|
@@ -1383,6 +1328,8 @@ Strike accounting, cycle detection, sudden-death thresholds, and no-ops bookkeep
|
|
|
1383
1328
|
|
|
1384
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}
|
|
1385
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
|
+
|
|
1386
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}
|
|
1387
1334
|
|
|
1388
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}
|
|
@@ -1395,7 +1342,7 @@ The tools capability sheet renders **titleless**, directly under the `definition
|
|
|
1395
1342
|
|
|
1396
1343
|
### §schemes user.schemes — the scheme directory
|
|
1397
1344
|
|
|
1398
|
-
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}
|
|
1399
1346
|
|
|
1400
1347
|
### §inject system.inject — the operator injection
|
|
1401
1348
|
|
|
@@ -1421,9 +1368,9 @@ Rendered at the END of the user packet under `## Recap` {§requirements-requirem
|
|
|
1421
1368
|
|
|
1422
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.
|
|
1423
1370
|
|
|
1424
|
-
### §matcher-dispatch Matcher dispatch (service-owned, over
|
|
1371
|
+
### §matcher-dispatch Matcher dispatch (service-owned, over plugin primitives)
|
|
1425
1372
|
|
|
1426
|
-
`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):
|
|
1427
1374
|
|
|
1428
1375
|
| Result | HTTP status |
|
|
1429
1376
|
|---|---|
|
|
@@ -1438,9 +1385,9 @@ Glob anchoring (`TODO*` starts-with, `*TODO*` contains, `*.log` ends-with, `[Tt]
|
|
|
1438
1385
|
|
|
1439
1386
|
### §matcher-result Matcher result shape — READ returns matching LINES, uniformly
|
|
1440
1387
|
|
|
1441
|
-
The contract is the grammar's: **plurnk.md §"`<Line> / <Result>`" — "FIND returns rows of results, READ returns lines of content"**, and READ "prefixes every line with line
|
|
1388
|
+
The contract is the grammar's: **plurnk.md §"`<Line> / <Result>`" — "FIND returns rows of results, READ returns lines of content"**, and READ "prefixes every line with its line number, `N:` — no separator whitespace" (#564 owner policy: a bare `N:` prefix; the old hard-tab separator leaked into edit bodies and corrupted indentation). The number is one source-line coordinate, not part of the source. This section documents the service's implementation of that.
|
|
1442
1389
|
|
|
1443
|
-
**A matcher selects locations; it never extracts a value.** Every dialect identifies *where* in the source it matches; READ returns the **source line(s)** at those locations, faithfully — one shape for every dialect: `<line
|
|
1390
|
+
**A matcher selects locations; it never extracts a value.** Every dialect identifies *where* in the source it matches; READ returns the **source line(s)** at those locations, faithfully — one shape for every dialect: `<line>:<line-content>`, prefixed with the single source-line number per plurnk.md (shifted back to source coordinates inside an `<L>` slice), never double-numbered. Empty → 204; mimetype `text/markdown` regardless of source. The model reads the line and adapts whatever it needs out of it — READ never pre-chews a match down to a bare value. {§matcher-result-read-returns-lines}
|
|
1444
1391
|
|
|
1445
1392
|
| Dialect | Selects | Natural use |
|
|
1446
1393
|
|---|---|---|
|
|
@@ -1451,9 +1398,9 @@ The contract is the grammar's: **plurnk.md §"`<Line> / <Result>`" — "FIND ret
|
|
|
1451
1398
|
| `~`semantic `~q` | the line span of each ranked chunk (a relation, resolved by FIND) | the section about X |
|
|
1452
1399
|
| `@`graph `@<sym` | the line span of each matched symbol occurrence (a relation, resolved by FIND) | where X is referenced |
|
|
1453
1400
|
|
|
1454
|
-
**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}
|
|
1455
1402
|
|
|
1456
|
-
> **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.
|
|
1457
1404
|
|
|
1458
1405
|
### §slice-semantics `<L>` semantics by source mimetype
|
|
1459
1406
|
|
|
@@ -1523,14 +1470,14 @@ Same rule applies across Known, Unknown, Skill, Plurnk, File. Effective mimetype
|
|
|
1523
1470
|
|
|
1524
1471
|
`packet-wire` log render branches on `isLineNavigableMimetype`:
|
|
1525
1472
|
|
|
1526
|
-
- **Line-navigable** (text/markdown, text/plain, csv, source code, yaml, toml) → `N
|
|
1527
|
-
- **Tree-navigable** (application/json, application/xml, text/html, +json/+xml suffixes) → verbatim body (no `N
|
|
1473
|
+
- **Line-navigable** (text/markdown, text/plain, csv, source code, yaml, toml) → `N:` line-number prefix per line {§render-rule-line-navigable-prefix}
|
|
1474
|
+
- **Tree-navigable** (application/json, application/xml, text/html, +json/+xml suffixes) → verbatim body (no `N:` — outer line numbers would collide with structural navigation like jsonpath/xpath) {§render-rule-tree-navigable-verbatim}
|
|
1528
1475
|
|
|
1529
1476
|
A log row renders its **result body** for the content-returning ops — `READ@200` (the content it pulled) and `FIND@200` (the catalog rows / matched entries it returned) — under the query's fence, mimetype-driven per the rules above; every other op re-emits its statement. FIND included: the model must see what a find *returned*, not just its echoed query, and the turn-0 foisted `FIND(scheme:///**)` reaches the packet through this branch — without it the catalog preview is invisible. {§render-rule-find-renders-result}
|
|
1530
1477
|
|
|
1531
1478
|
An `EDIT` log row renders its **resulting span** — the edited area as it looks now (`rx.span`), under the target's fence — not the input statement: the log reads "and here's X now," so the model sees its edit's effect. The object still carries op + target; the model's own EDITs and the system delta-EDITs (§env-delta) render identically; an emptied span → the object's meta alone (no body). With no span stored, the row falls back to re-emitting the statement (the heredoc the model wrote). {§edit-result-render}
|
|
1532
1479
|
|
|
1533
|
-
The `N
|
|
1480
|
+
The `N:` prefix is presentation/reference per plurnk.md ("not part of the source"); stripped before any matcher operation on the log entry.
|
|
1534
1481
|
|
|
1535
1482
|
### §markdown-primitive Mimetype primitive: text/markdown
|
|
1536
1483
|
|
|
@@ -1549,8 +1496,8 @@ Carried from the contract walk; durable.
|
|
|
1549
1496
|
- **Dialect/mimetype mismatch** → 415 (xpath on text/plain → 415; jsonpath on JSON-shapeless mimetypes → 204 because outline is empty, not 415).
|
|
1550
1497
|
- **Binary entries** → 415 across the board for READ/EDIT/OPEN/FOLD.
|
|
1551
1498
|
- **EDIT `<L>` on non-existent entry** → body becomes content; `<L>` is positional-only on existing content.
|
|
1552
|
-
- **COPY/MOVE `<L>`** → slices the SOURCE range into the destination (every channel), symmetric with READ `<L>` but WITHOUT the `N
|
|
1553
|
-
- **READ rx** prefixes each line with `N
|
|
1499
|
+
- **COPY/MOVE `<L>`** → slices the SOURCE range into the destination (every channel), symmetric with READ `<L>` but WITHOUT the `N:` prefix (`sliceLinesRaw`); an out-of-range marker → 416. MOVE `<L>` copies the slice, then deletes the whole source (relocation of a fragment). Binary channels can't be sliced (the binary→415 rule above). {§copy-l-source-range}
|
|
1500
|
+
- **READ rx** prefixes each line with `N:` per §render-rule. `sliceLinesRaw` (used by COPY) returns the lines without prefix.
|
|
1554
1501
|
- **FIND body matcher** applies to entry content (all dialects), per-candidate via the in-tree `Matcher.matchAgainstContent` (§matcher-dispatch; status 200 = content hit → entry selected). Scope + tags select candidates in SQL; the path-glob is the (target).
|
|
1555
1502
|
- **OPEN/FOLD** operate on the **log** (`log:///`), not entries (§open-fold) — FOLD collapses a log row to its path, OPEN restores its body. Aimed at an entry scheme they return 501.
|
|
1556
1503
|
- **SEND[410]** deletes as a side-effect (not the model idiom; §move): with `#fragment`, that channel only; without, the whole entry. **SEND[499]** is owned by the streaming scheme that holds the subscription.
|