@plurnk/plurnk-service 0.21.0 → 0.23.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/README.md +6 -6
- package/SPEC.md +347 -291
- package/dist/Paths.d.ts +4 -0
- package/dist/Paths.d.ts.map +1 -1
- package/dist/Paths.js +20 -0
- package/dist/Paths.js.map +1 -1
- package/dist/content/edited-span.js +2 -2
- package/dist/content/edited-span.js.map +1 -1
- package/dist/content/matcher.js +1 -1
- package/dist/content/matcher.js.map +1 -1
- package/dist/content/mimetype-binary.js +1 -1
- package/dist/content/mimetype-binary.js.map +1 -1
- package/dist/core/ChannelWrite.js +2 -2
- package/dist/core/ChannelWrite.js.map +1 -1
- package/dist/core/Engine.d.ts.map +1 -1
- package/dist/core/Engine.js +72 -46
- package/dist/core/Engine.js.map +1 -1
- package/dist/core/ExecutorRegistry.js +1 -1
- package/dist/core/ExecutorRegistry.js.map +1 -1
- package/dist/core/PluginLoader.js +1 -1
- package/dist/core/PluginLoader.js.map +1 -1
- package/dist/core/SchemeRegistry.js +1 -1
- package/dist/core/SchemeRegistry.js.map +1 -1
- package/dist/core/caps/DbChannelCaps.js +1 -1
- package/dist/core/caps/DbChannelCaps.js.map +1 -1
- package/dist/core/caps/DbEntryCaps.js +1 -1
- package/dist/core/caps/DbEntryCaps.js.map +1 -1
- package/dist/core/caps/SchemeCtxImpl.js +1 -1
- package/dist/core/caps/SchemeCtxImpl.js.map +1 -1
- package/dist/core/fork.d.ts +5 -0
- package/dist/core/fork.d.ts.map +1 -0
- package/dist/core/fork.js +47 -0
- package/dist/core/fork.js.map +1 -0
- package/dist/core/git-membership.js +4 -4
- package/dist/core/git-membership.js.map +1 -1
- package/dist/core/git-state.js +1 -1
- package/dist/core/git-state.js.map +1 -1
- package/dist/core/packet-wire.d.ts.map +1 -1
- package/dist/core/packet-wire.js +18 -13
- package/dist/core/packet-wire.js.map +1 -1
- package/dist/schemes/File.js +6 -6
- package/dist/schemes/File.js.map +1 -1
- package/dist/schemes/_entry-crud.js +2 -2
- package/dist/schemes/_entry-crud.js.map +1 -1
- package/dist/schemes/_entry-find.js +1 -1
- package/dist/schemes/_entry-find.js.map +1 -1
- package/dist/schemes/_entry-graph.js +1 -1
- package/dist/schemes/_entry-graph.js.map +1 -1
- package/dist/schemes/_entry-manifest.js +1 -1
- package/dist/schemes/_entry-manifest.js.map +1 -1
- package/dist/schemes/_entry-ops.js +6 -6
- package/dist/schemes/_entry-ops.js.map +1 -1
- package/dist/schemes/_entry-semantic.js +1 -1
- package/dist/schemes/_entry-semantic.js.map +1 -1
- package/dist/schemes/_entry-send.js +3 -3
- package/dist/schemes/_entry-send.js.map +1 -1
- package/dist/server/ClientConnection.js +3 -3
- package/dist/server/ClientConnection.js.map +1 -1
- package/dist/server/Daemon.d.ts +2 -2
- package/dist/server/Daemon.js +4 -4
- package/dist/server/Daemon.js.map +1 -1
- package/dist/server/MethodRegistry.js +1 -1
- package/dist/server/MethodRegistry.js.map +1 -1
- package/dist/server/envelope.d.ts +1 -0
- package/dist/server/envelope.d.ts.map +1 -1
- package/dist/server/envelope.js +19 -6
- package/dist/server/envelope.js.map +1 -1
- package/dist/server/methods/_dispatchAsPlurnk.d.ts +7 -0
- package/dist/server/methods/_dispatchAsPlurnk.d.ts.map +1 -0
- package/dist/server/methods/_dispatchAsPlurnk.js +23 -0
- package/dist/server/methods/_dispatchAsPlurnk.js.map +1 -0
- package/dist/server/methods/entry_read.js +1 -1
- package/dist/server/methods/entry_read.js.map +1 -1
- package/dist/server/methods/log_read.js +1 -1
- package/dist/server/methods/log_read.js.map +1 -1
- package/dist/server/methods/loop_cancel.js +1 -1
- package/dist/server/methods/loop_cancel.js.map +1 -1
- package/dist/server/methods/loop_resolve.js +1 -1
- package/dist/server/methods/loop_resolve.js.map +1 -1
- package/dist/server/methods/loop_run.d.ts.map +1 -1
- package/dist/server/methods/loop_run.js +31 -4
- package/dist/server/methods/loop_run.js.map +1 -1
- package/dist/server/methods/session_constraints.js +2 -2
- package/dist/server/methods/session_constraints.js.map +1 -1
- package/dist/server/yolo.js +1 -1
- package/dist/server/yolo.js.map +1 -1
- package/migrations/0000-00-00.01_schema.sql +7 -7
- package/package.json +11 -11
- package/requirements.md +2 -1
package/SPEC.md
CHANGED
|
@@ -2,15 +2,15 @@
|
|
|
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
|
-
|
|
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
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
9
|
-
## §
|
|
9
|
+
## §glossary Glossary
|
|
10
10
|
|
|
11
11
|
Canonical meanings. When a doc, comment, test name, or commit message uses one of these words, it means exactly what's written here. Drift is a bug.
|
|
12
12
|
|
|
13
|
-
### §
|
|
13
|
+
### §lifecycle-terms Lifecycle terms
|
|
14
14
|
|
|
15
15
|
| Term | Meaning |
|
|
16
16
|
|---|---|
|
|
@@ -24,37 +24,37 @@ Canonical meanings. When a doc, comment, test name, or commit message uses one o
|
|
|
24
24
|
| **action** | One executed op. Action and op are the same thing in different states (op = parsed; action = executed). The execution produces a log_entries row at `log://<L>/<T>/<S>/<op>`. |
|
|
25
25
|
| **dispatch** | The engine routing a statement to its scheme's op handler. |
|
|
26
26
|
|
|
27
|
-
### §
|
|
27
|
+
### §storage-terms Storage terms
|
|
28
28
|
|
|
29
29
|
| Term | Meaning |
|
|
30
30
|
|---|---|
|
|
31
31
|
| **entry** | The unit of canonical state. Identity: `(scope, scheme, pathname)`. Holds one or more `channels` of content plus `tags` and `attributes`. |
|
|
32
32
|
| **channel** | A named content buffer on an entry. Examples: `body`, `stdout`, `stderr`, `headers`, `symbols`. Each channel has `content`, `mimetype`, `tokens`, `state`. |
|
|
33
33
|
| **scope** | `"agent"` or `"session"`. Determines who reads. Agent-scope entries visible to every run; session-scope entries to that session's runs. |
|
|
34
|
-
| **scheme** | A URI prefix + handler. `known`, `unknown`, `file`, `https`, `exec`. The scheme handler interprets paths under its prefix and implements the op surface. Consumption surface §
|
|
35
|
-
| **mimetype** | A channel's content type. Drives the handler that produces the structural projections (`symbols`, `deepJson`, `deepXml`). Consumption surface §
|
|
36
|
-
| **provider** | An LLM transport. Implements `generate({messages, signal})` against a wire protocol. Consumption surface §
|
|
34
|
+
| **scheme** | A URI prefix + handler. `known`, `unknown`, `file`, `https`, `exec`. The scheme handler interprets paths under its prefix and implements the op surface. Consumption surface §scheme-surface; author contract: [plurnk-schemes](https://github.com/plurnk/plurnk-schemes). |
|
|
35
|
+
| **mimetype** | A channel's content type. Drives the handler that produces the structural projections (`symbols`, `deepJson`, `deepXml`). Consumption surface §mimetype-surface; author contract: [plurnk-mimetypes](https://github.com/plurnk/plurnk-mimetypes). |
|
|
36
|
+
| **provider** | An LLM transport. Implements `generate({messages, signal})` against a wire protocol. Consumption surface §provider; author contract: [plurnk-providers](https://github.com/plurnk/plurnk-providers). |
|
|
37
37
|
|
|
38
|
-
### §
|
|
38
|
+
### §state-terms State / status
|
|
39
39
|
|
|
40
40
|
Independent axes on entries and channels. Confusion across them is a recurring source of bugs.
|
|
41
41
|
|
|
42
42
|
| Term | Type | Meaning |
|
|
43
43
|
|---|---|---|
|
|
44
|
-
| **status** | HTTP int | Outcome of an operation. Carried on `log_entries.status_rx`, returned from op handlers. Per the catalogue (§
|
|
44
|
+
| **status** | HTTP int | Outcome of an operation. Carried on `log_entries.status_rx`, returned from op handlers. Per the catalogue (§send-dispatch). |
|
|
45
45
|
| **channel state** | `static \| active \| closed \| errored` | Streaming lifecycle of a channel's content. Metadata, not gating — engine renders content regardless of state. |
|
|
46
46
|
| **entry state** | `proposed \| resolved \| cancelled` | Proposal lifecycle. `proposed` = pending client accept; `resolved` = side effect happened; `cancelled` = client rejected. Distinct from channel state. |
|
|
47
47
|
| **outcome** | `string \| null` | Short reason for `failed`/`cancelled` (`"permission:403"`, `"aborted"`, `"not_found"`). Opaque to most callers. |
|
|
48
48
|
|
|
49
|
-
### §
|
|
49
|
+
### §authority-terms Writer / authority
|
|
50
50
|
|
|
51
51
|
| Term | Meaning |
|
|
52
52
|
|---|---|
|
|
53
53
|
| **writer** | The identity authoring a write. One of `model \| client \| plurnk \| plugin`. Carried on `ctx.writer` for schemes; engine enforces `manifest.writableBy`. |
|
|
54
54
|
| **origin** | Synonym for writer in log_entries (`log_entries.origin`). Historical naming; treat as equivalent. |
|
|
55
|
-
| **writable_by** | The set of writers a scheme accepts. Subset of `{model, client, plurnk, plugin}`. Engine rejects writes outside the set with 403; the rejection is logged as the action-entry (§
|
|
55
|
+
| **writable_by** | The set of writers a scheme accepts. Subset of `{model, client, plurnk, plugin}`. Engine rejects writes outside the set with 403; the rejection is logged as the action-entry (§subscriptions action-entry-as-outcome). |
|
|
56
56
|
|
|
57
|
-
### §
|
|
57
|
+
### §engine-rails Engine rails
|
|
58
58
|
|
|
59
59
|
| Term | Meaning |
|
|
60
60
|
|---|---|
|
|
@@ -64,10 +64,10 @@ Independent axes on entries and channels. Confusion across them is a recurring s
|
|
|
64
64
|
| **sudden death** | The last `MAX_STRIKES` turns of a loop's `MAX_LOOP_TURNS` window emit soft 429 warnings so the model can wrap up cleanly. `soft=true`: no strike, no streak increment. |
|
|
65
65
|
| **mode** | `"ask" \| "act"`. Per-loop. Ask = read-only (no side-effecting ops); act = full surface. |
|
|
66
66
|
| **flag** | Per-loop boolean shaping the active toolset: `yolo` (auto-accept proposals), `noWeb`, `noInteraction`, `noProposals`. |
|
|
67
|
-
| **proposal** | A deferred side-effecting action awaiting client accept/reject. State machine: `proposed → resolved` or `proposed → cancelled`. `yolo` short-circuits to immediate. |
|
|
67
|
+
| **proposal** | A deferred side-effecting action awaiting client accept/reject (full lifecycle §proposal). State machine: `proposed → resolved` or `proposed → cancelled`. `yolo` short-circuits to immediate. |
|
|
68
68
|
| **resolution** | Client's accept/reject of a proposal via `op.resolve` RPC. |
|
|
69
69
|
|
|
70
|
-
### §
|
|
70
|
+
### §packet-terms Packet terms
|
|
71
71
|
|
|
72
72
|
| Term | Meaning |
|
|
73
73
|
|---|---|
|
|
@@ -75,7 +75,7 @@ Independent axes on entries and channels. Confusion across them is a recurring s
|
|
|
75
75
|
| **log** | `packet.system.log`. Chronological list of `log_entries` in scope this turn. |
|
|
76
76
|
| **render** | The act of computing the packet from current DB state at turn boundaries. Mimetype handlers fire at render time. |
|
|
77
77
|
|
|
78
|
-
### §
|
|
78
|
+
### §test-taxonomy Test taxonomy
|
|
79
79
|
|
|
80
80
|
| Tier | Location | LLM | Substrate |
|
|
81
81
|
|---|---|---|---|
|
|
@@ -86,9 +86,11 @@ Independent axes on entries and channels. Confusion across them is a recurring s
|
|
|
86
86
|
|
|
87
87
|
---
|
|
88
88
|
|
|
89
|
-
## §
|
|
89
|
+
## §arch Architecture
|
|
90
90
|
|
|
91
|
-
|
|
91
|
+
The ecosystem and the in-process shape (§ecosystem–§in-process), then the two invariants the rest of the spec rests on: isolation by run (§actor-boundary) and the session/run/fork ownership model (§machine-processes).
|
|
92
|
+
|
|
93
|
+
### §ecosystem Ecosystem
|
|
92
94
|
|
|
93
95
|
The plurnk project is a modular monorepo-of-repos in the `@plurnk/*` npm namespace. Each repo has one published package and one agent who owns it; cross-repo coordination happens through issues, not shared code. This service sits in the middle of that ecosystem and is its **runtime substrate** — the daemon other repos plug into.
|
|
94
96
|
|
|
@@ -107,48 +109,90 @@ The grammar is the contract. The frameworks consume the contract and add author-
|
|
|
107
109
|
|
|
108
110
|
**This service's central role:** sole consumer of every author-facing framework contract (one set of integrations across the ecosystem), sole producer of the engine's runtime behavior (one canonical implementation of dispatch, log, packet wire), and sole orchestrator of cross-scheme operations (COPY/MOVE flow through engine-mediated `readEntry` / `writeEntry` / `deleteEntry`, never scheme-to-scheme). Most cross-repo coordination flows through us — we file the consumer-need issues at upstream repos, adopt their decisions, document the surface in SPEC.
|
|
109
111
|
|
|
110
|
-
### §
|
|
112
|
+
### §in-process In-process architecture
|
|
111
113
|
|
|
112
114
|
Engine library + admin CLI + daemon. Four plug points:
|
|
113
115
|
|
|
114
|
-
- **Providers** (§
|
|
115
|
-
- **Schemes** (§
|
|
116
|
-
- **Mimetypes** (§
|
|
117
|
-
- **Executors** (§
|
|
116
|
+
- **Providers** (§provider) — LLM transports. Engine sends a turn's messages, receives raw content + usage; engine parses the content into `PlurnkStatement[]`.
|
|
117
|
+
- **Schemes** (§scheme) — addressable resources. Every op targets a URI; scheme handler interprets paths under its prefix and owns its storage substrate.
|
|
118
|
+
- **Mimetypes** (§mimetype) — content interpretation. Render-time handlers consume channel content; framework owns the dispatch.
|
|
119
|
+
- **Executors** (§exec / §bundled-set) — EXEC runtime dispatch. Subprocess shells, search backends, future tool runtimes.
|
|
118
120
|
|
|
119
|
-
The engine dispatches ops, persists state to SQLite, orchestrates cross-scheme COPY/MOVE (§
|
|
121
|
+
The engine dispatches ops, persists state to SQLite, orchestrates cross-scheme COPY/MOVE (§copy/§move), writes the log. Substantive behavior lives in the four plug points.
|
|
120
122
|
|
|
121
123
|
The grammar (`@plurnk/plurnk-grammar`) owns parser + AST contract. Schemes receive parsed statement fragments via dispatch.
|
|
122
124
|
|
|
123
125
|
Server posture: this package is the runtime. User-facing CLI lives in `plurnk` and consumes the library API (`src/index.ts` + `PATHS`).
|
|
124
126
|
|
|
127
|
+
### §actor-boundary The actor boundary: isolation by run, two doors, self-hosting
|
|
128
|
+
|
|
129
|
+
**Question.** A session holds many runs — model, client, plurnk (§lifecycle-terms, §authority-terms) — over one shared manifest. What keeps one run's activity out of another's conversation; what are the *only* ways a run's work reaches another; and does the engine's own work obey the boundary or get a privileged back channel?
|
|
130
|
+
|
|
131
|
+
**Decision — isolation by run; the model is not privileged.** A packet renders exactly one run's log — the assembling run's — against the session's shared manifest (§packet). A run cannot see another's log: isolation is *structural*, a consequence of "a run owns its log entries" (§lifecycle-terms) and "one packet, one run," never an `origin` filter at render time. `origin` (§authority-terms) is **attribution** — the delta's provenance (§env-delta) — never read to hide a row. {§actor-boundary-isolation} {§actor-boundary-origin-not-filter}
|
|
132
|
+
|
|
133
|
+
**Two doors, and only two.** A run's work reaches another run by exactly two channels, and a private log is reachable no other way:
|
|
134
|
+
- the **environment door** — a write to a *shared entry* surfaces to every run sharing it as a folded, attributed delta (§env-delta). *State.*
|
|
135
|
+
- the **voice door** — an **inject** delivers a turn into a *specific* run's log; `btw` is the user's mid-loop inject. *Message.*
|
|
136
|
+
|
|
137
|
+
{§actor-boundary-two-doors}
|
|
138
|
+
|
|
139
|
+
**Wild west — no mutual exclusion.** Runs share the manifest without locks. Coordination is cooperative (tags + the shared workspace convention) and softly fenced (the §membership `read-only` overlay, a session policy, bounds every run's writable surface uniformly — §machine-processes); a conflict *surfaces* as a delta rather than being prevented. Inform, never override. {§actor-boundary-no-mutex}
|
|
140
|
+
|
|
141
|
+
**Passive wake.** An idle run wakes on exactly two events — a prompt injected into it (voice; user or system) or a stream-status transition it subscribes to (§channel-state). A delta never wakes a run; it queues and drains at the next turn one of those produces (§env-delta). {§actor-boundary-passive-wake}
|
|
142
|
+
|
|
143
|
+
**Self-hosting — the runtime is an actor, not a back channel.** Runtime-initiated work (fs reconciliation §membership, git auto-add) is an **ephemeral `plurnk` run** firing ordinary ops, seen by other runs through the environment door like any actor's — not a privileged engine pathway. The engine keeps only the irreducible kernel runs stand on (spawn, dispatch, packet assembly, the budget rails §grinder, the fs-watch); everything expressible as ops on session entries is a run doing ops, through the same `op.*` surface (§methods) the service offers clients. Dogfooding is the architecture, not a test mode. {§actor-boundary-self-hosting}
|
|
144
|
+
|
|
145
|
+
**Migration path.** Largely realized: `Engine.dispatch` is origin-agnostic; client ops run in a per-connection client loop (`_dispatchAsClient`); plurnk EDITs already carry `origin=plurnk`. The keystone is **built** — `dispatchAsPlurnk` spawns the session's reserved `plurnk` run and fires ops through dispatch, mirroring `_dispatchAsClient`. What remains is *repatriation* — the inline plurnk dispatches still bolted into the model's loop (the §env-delta materialization, the manifest build, git auto-add) move onto it.
|
|
146
|
+
|
|
147
|
+
**The keystone's first use: operator reference docs.** `PLURNK_MD_<ALIAS>=<path>` (§operator-config) materializes `<path>` as a `plurnk://<ALIAS>.md` entry — a `dispatchAsPlurnk` EDIT in the plurnk run, **not** the model's — and the model's turn-0 foists a READ of it. The model reads the doc inline while the materializing EDIT stays out of its log: idiomatic context injection, an ordinary entry + READ rather than a bespoke packet section. The same `PLURNK_MD_*` convention cascades to clients. {§actor-boundary-doc-injection}
|
|
148
|
+
|
|
149
|
+
### §machine-processes The machine and its processes: session, run, fork
|
|
150
|
+
|
|
151
|
+
**Question.** §actor-boundary isolates runs and lets the runtime self-host, but it stands on an ownership model it never states: what does a *session* own versus a *run*; what is shared versus private; and what does a fork carry? Unstated, the downstream questions — which run `log.read` reads, what a fork copies, where a per-client view of the workspace would live — grow subtle, then metastasize. Drawn once, they vanish.
|
|
152
|
+
|
|
153
|
+
**Decision — the session is the world; a run is a log on it.** A **session** is the world: one shared filesystem — the `session`-scoped entries, surfaced as `plurnk://manifest.json` (§packet) — under one membership overlay (§membership). Exactly one filesystem and one overlay per session; neither is per-run. A **run** is a process whose entire private memory is its **log** (§lifecycle-terms) — its loops, turns, and rows, each row carrying its own content, attribution (`origin`/`source`, §env-delta), and fold-state (`indexed`). A run owns **no entries** and **no membership**; even its visibility is not a possession but a bit on its own rows. It is a *history over the shared world, not a world*.
|
|
154
|
+
|
|
155
|
+
**One filesystem.** The entries are the session's: `entries.session_id`, never a run. A write by any run is a write to the one filesystem every run reads; there is no per-run entry set. {§machine-processes-one-filesystem}
|
|
156
|
+
|
|
157
|
+
**One overlay.** Membership — `git ls-files ∪ add − ignore` with read-only (§membership) — is the session's: `session_constraints.session_id`, never a run. It is workspace *curation*, and the workspace *is* the session; two runs are two conversations about one curated workspace and see the same one. Divergent membership is a different session, never a per-run overlay. {§machine-processes-one-overlay}
|
|
158
|
+
|
|
159
|
+
**A run is its log — and nothing beside.** The run-private state is the log and only the log. *What I am looking at* (OPEN/FOLD) is `log_entries.indexed`, a bit on the run's own rows, toggled by ordinary `log://` ops — not a second store, and never membership (§open-fold). *What I last saw* needs no shadow either: a run learns its world moved through log entries (§env-delta) — a sibling's write broadcast into its log, an out-of-band disk change detected against the entry's own content and broadcast the same way — never through a per-run snapshot the run cannot see. The log is the whole of a run's memory. {§machine-processes-run-is-its-log}
|
|
160
|
+
|
|
161
|
+
**Fork — copy the log, share the world.** A fork is a new run in the *same* session (`runs.parent_run_id`, §lifecycle-terms). It copies the **log** — the rows, their fold-state riding along — so the branch inherits everything the parent observed (§env-delta makes a run's timeline self-contained for exactly this) and diverges freely after. {§machine-processes-fork-copies-the-log} It shares the **world** — the one filesystem, the one overlay — live and uncopied, because the run never owned it. {§machine-processes-fork-shares-the-world}
|
|
162
|
+
|
|
163
|
+
**A session cannot be forked.** There is nothing to branch — a session *is* the shared ground. `runs` carries `parent_run_id`; `sessions` carries no parent. Parallel histories over one workspace are forks of its runs; a divergent workspace is a new session. {§machine-processes-no-fork-session}
|
|
164
|
+
|
|
165
|
+
**Rationale.** The model falls out of one correction: *a run is a history over a shared world, not a world.* Entries are the world (session); the log is the history (run); forking a history need not copy the world, and a run accumulates nothing the log does not already hold. The overlay's session home is forced the same way — it is the world's curation, and the world is shared; per-run it fragments the one manifest, forks the membership read-gate (the §membership security line), and duplicates what FOLD already does at the right level. Every "which run / what's copied / where's the per-client view" answers itself once the world/log line is drawn.
|
|
166
|
+
|
|
167
|
+
**Migration path.** Mostly stating what the schema already carries: `runs.parent_run_id` and the parentless `sessions` exist (§lifecycle-terms); `session_constraints` is session-level (§membership); §env-delta already makes a run's timeline self-contained, so a fork's log copy suffices. Additive: `run.fork` over the wire (the engine fork is built). Two repatriations: §actor-boundary's "read-only overlay scopes a run's writable surface" becomes a *session* policy bounding every run uniformly; and the §env-delta environment door sheds its per-run watermark — a run's only memory is its log, so drift is the broadcast (run-caused) and the build-time disk-vs-entry diff (ambient), both landing as log entries, never a per-run snapshot.
|
|
168
|
+
|
|
125
169
|
---
|
|
126
170
|
|
|
127
|
-
## §
|
|
171
|
+
## §provider Provider Contract
|
|
128
172
|
|
|
129
173
|
Author-facing contract: [plurnk-providers#1](https://github.com/plurnk/plurnk-providers/issues/1). Below: consumption surface + engine→provider guarantees.
|
|
130
174
|
|
|
131
|
-
### §
|
|
175
|
+
### §provider-surface Consumption surface
|
|
132
176
|
|
|
133
177
|
Three entry points:
|
|
134
178
|
|
|
135
|
-
- `provider.generate({messages, signal})` — once per turn; returns `{ assistant: { content, reasoning, usage, finishReason, model }, assistantRaw }`. **Engine parses `assistant.content`** into `PlurnkStatement[]` via `@plurnk/plurnk-grammar`. {§
|
|
136
|
-
- `provider.countTokens(text)` — synchronous, called at write-time (§
|
|
137
|
-
- `provider.costFor(usage)` — once per completed turn; pico-USD. Engine writes to `turns.usage_cost_pico`; triggers cascade to `runs.cost_pico` / `sessions.cost_pico`. {§
|
|
179
|
+
- `provider.generate({messages, signal})` — once per turn; returns `{ assistant: { content, reasoning, usage, finishReason, model }, assistantRaw }`. **Engine parses `assistant.content`** into `PlurnkStatement[]` via `@plurnk/plurnk-grammar`. {§provider-surface-generate}
|
|
180
|
+
- `provider.countTokens(text)` — synchronous, called at write-time (§tokenomics) and render-time. Non-negative integer. {§provider-surface-counttokens}
|
|
181
|
+
- `provider.costFor(usage)` — once per completed turn; pico-USD. Engine writes to `turns.usage_cost_pico`; triggers cascade to `runs.cost_pico` / `sessions.cost_pico`. {§provider-surface-costfor}
|
|
138
182
|
|
|
139
|
-
Plus immutable identity: `provider.contextSize` (token total, or `null` → "no budget info"), read by the budget {§
|
|
183
|
+
Plus immutable identity: `provider.contextSize` (token total, or `null` → "no budget info"), read by the budget {§provider-surface-identity}; and `provider.model` — the instance identity the deferred model-switch recompute compares (§tokenomics), exposed but not yet consumed here.
|
|
140
184
|
|
|
141
|
-
### §
|
|
185
|
+
### §provider-guarantees Engine → provider guarantees
|
|
142
186
|
|
|
143
187
|
- `messages` is a complete prompt (`system_definition`, `persona`, `index`, `log`, `prompt`, `telemetry`, `system_requirements` pre-assembled). Provider does not reorder.
|
|
144
|
-
- `signal` is wired to the run's AbortController. {§
|
|
145
|
-
- `generate` is single-call per turn. No parallel calls on the same instance. {§
|
|
146
|
-
- `assistantRaw` is opaque to the engine (forensics-only). {§
|
|
188
|
+
- `signal` is wired to the run's AbortController. {§provider-guarantees-signal-wired}
|
|
189
|
+
- `generate` is single-call per turn. No parallel calls on the same instance. {§provider-guarantees-single-call}
|
|
190
|
+
- `assistantRaw` is opaque to the engine (forensics-only). {§provider-guarantees-assistantraw-opaque}
|
|
147
191
|
- `countTokens` is cheap by contract; engine calls frequently.
|
|
148
192
|
|
|
149
|
-
### §
|
|
193
|
+
### §provider-instantiation Provider instantiation
|
|
150
194
|
|
|
151
|
-
Model alias parsing (`parseAliasesFromEnv` / `resolveActiveAlias`) lives in [`@plurnk/plurnk-providers`](https://github.com/plurnk/plurnk-providers). {§
|
|
195
|
+
Model alias parsing (`parseAliasesFromEnv` / `resolveActiveAlias`) lives in [`@plurnk/plurnk-providers`](https://github.com/plurnk/plurnk-providers). {§provider-instantiation-alias-resolution} Dynamic provider instantiation (`instantiateProvider` / `loadActiveProvider`) lives in `src/core/ProviderInstantiate.ts` here — `import()` resolves package specifiers relative to the calling module, so the dynamic-import path stays in the consumer where the `@plurnk/plurnk-providers-<vendor>` packages are installed.
|
|
152
196
|
|
|
153
197
|
```
|
|
154
198
|
PLURNK_MODEL_gemma=openai/macher.gguf
|
|
@@ -158,29 +202,29 @@ PLURNK_MODEL=gemma
|
|
|
158
202
|
|
|
159
203
|
First path segment = provider plugin; rest = provider's own model id.
|
|
160
204
|
|
|
161
|
-
### §
|
|
205
|
+
### §mock-provider Mock provider (sibling fixture)
|
|
162
206
|
|
|
163
|
-
`Mock` (exported from `@plurnk/plurnk-providers`) — intg fixture + reference implementation. `{ contextSize, 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. {§
|
|
207
|
+
`Mock` (exported from `@plurnk/plurnk-providers`) — intg fixture + reference implementation. `{ contextSize, 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. {§mock-provider-mock-fixture}
|
|
164
208
|
|
|
165
209
|
---
|
|
166
210
|
|
|
167
|
-
## §
|
|
211
|
+
## §scheme Scheme Contract
|
|
168
212
|
|
|
169
213
|
Author-facing contract: [plurnk-schemes#1](https://github.com/plurnk/plurnk-schemes/issues/1). Below: what plurnk-service exposes to schemes and orchestrates over them.
|
|
170
214
|
|
|
171
|
-
### §
|
|
215
|
+
### §scheme-manifest Manifest
|
|
172
216
|
|
|
173
|
-
Per author contract. Each scheme declares a `static manifest: SchemeManifest` with `name`, `channels`, `defaultChannel`, `category`, `scope`, `writableBy`, `volatile`, `modelVisible`, optional `flags`. {§
|
|
217
|
+
Per author contract. Each scheme declares a `static manifest: SchemeManifest` with `name`, `channels`, `defaultChannel`, `category`, `scope`, `writableBy`, `volatile`, `modelVisible`, optional `flags`. {§scheme-manifest-manifest} Identity match enforced at plugin load: `manifest.name` must equal `package.json#plurnk.name`.
|
|
174
218
|
|
|
175
|
-
### §
|
|
219
|
+
### §crud CRUD primitives
|
|
176
220
|
|
|
177
|
-
Per author contract (`readEntry` / `writeEntry` / `deleteEntry`). Engine drives cross-scheme COPY/MOVE/SEND[410] through these — the orchestration and its 404/409/415 semantics are anchored under §
|
|
221
|
+
Per author contract (`readEntry` / `writeEntry` / `deleteEntry`). Engine drives cross-scheme COPY/MOVE/SEND[410] through these — the orchestration and its 404/409/415 semantics are anchored under §copy/§move. Each method is one SQL transaction; engine owns the outer transaction for orchestrations.
|
|
178
222
|
|
|
179
|
-
### §
|
|
223
|
+
### §op-methods Op methods
|
|
180
224
|
|
|
181
|
-
Per author contract (`edit`/`read`/`open`/`fold`/`find`/`send`/`exec?`). Engine dispatches by `PlurnkStatement.op`. {§
|
|
225
|
+
Per author contract (`edit`/`read`/`open`/`fold`/`find`/`send`/`exec?`). Engine dispatches by `PlurnkStatement.op`. {§op-methods-op-dispatch} COPY and MOVE are NOT scheme methods — engine orchestrates over CRUD primitives (§copy/§move).
|
|
182
226
|
|
|
183
|
-
### §
|
|
227
|
+
### §orchestration Cross-scheme orchestration
|
|
184
228
|
|
|
185
229
|
```
|
|
186
230
|
copy(source_path, dest_path, signal_tags, ctx):
|
|
@@ -198,22 +242,22 @@ move(source_path, dest_path, signal_tags, ctx):
|
|
|
198
242
|
src_scheme.deleteEntry(source_pathname, ctx)
|
|
199
243
|
```
|
|
200
244
|
|
|
201
|
-
Same- and cross-scheme operations share the orchestrator. Same-scheme COPY is not a special case. Orchestration behavior — 404/409/415, `move` = `copy` + `deleteEntry` — is anchored under §
|
|
245
|
+
Same- and cross-scheme operations share the orchestrator. Same-scheme COPY is not a special case. Orchestration behavior — 404/409/415, `move` = `copy` + `deleteEntry` — is anchored under §copy/§move.
|
|
202
246
|
|
|
203
|
-
### §
|
|
247
|
+
### §send-dispatch SEND dispatch (status-code-as-verb)
|
|
204
248
|
|
|
205
249
|
Directed SEND (non-null path) routes to scheme's `send`. Status = intent:
|
|
206
250
|
|
|
207
251
|
- `SEND[200](path)` — write body into resource (WS message, exec stdin).
|
|
208
|
-
- `SEND[499](path)` — cancel active subscription (§
|
|
252
|
+
- `SEND[499](path)` — cancel active subscription (§stream).
|
|
209
253
|
|
|
210
|
-
`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 (the MOVE→`/dev/null` idiom is retired, §
|
|
254
|
+
`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 (the MOVE→`/dev/null` idiom is retired, §move).
|
|
211
255
|
|
|
212
|
-
Other status codes return 501 from entry-bearing schemes by default. {§
|
|
256
|
+
Other status codes return 501 from entry-bearing schemes by default. {§send-dispatch-entry-schemes-501-on-non-410}
|
|
213
257
|
|
|
214
|
-
Null-path SEND is broadcast (§
|
|
258
|
+
Null-path SEND is broadcast (§send), engine-handled.
|
|
215
259
|
|
|
216
|
-
### §
|
|
260
|
+
### §scheme-surface Consumption surface
|
|
217
261
|
|
|
218
262
|
Per-call context (`src/core/scheme-types.ts`):
|
|
219
263
|
|
|
@@ -238,29 +282,29 @@ Engine → scheme guarantees:
|
|
|
238
282
|
|
|
239
283
|
- `ctx` is fresh per call. No mutation across calls.
|
|
240
284
|
- `ctx.writer` reflects the actual writer at this dispatch.
|
|
241
|
-
- `manifest.writableBy` checked BEFORE invocation; engine returns 403 directly on exclusion. {§
|
|
242
|
-
- `ctx.signal` is wired to the run's AbortController (§
|
|
243
|
-
- Scheme exceptions become the action-entry's outcome (status 500); summary surfaces in next turn's `packet.user.telemetry.errors[]` (§
|
|
285
|
+
- `manifest.writableBy` checked BEFORE invocation; engine returns 403 directly on exclusion. {§scheme-surface-writableby-403}
|
|
286
|
+
- `ctx.signal` is wired to the run's AbortController (§provider-guarantees-signal-wired).
|
|
287
|
+
- Scheme exceptions become the action-entry's outcome (status 500); summary surfaces in next turn's `packet.user.telemetry.errors[]` (§telemetry). {§scheme-surface-exception-500}
|
|
244
288
|
|
|
245
|
-
**Tokenization participation.** Schemes route writes through the shared `_entry-crud.ts` write helper (in plurnk-service today; migrates to plurnk-schemes). Helper populates `entry_channels.tokens` at write time via `ctx.provider.countTokens` (§
|
|
289
|
+
**Tokenization participation.** Schemes route writes through the shared `_entry-crud.ts` write helper (in plurnk-service today; migrates to plurnk-schemes). Helper populates `entry_channels.tokens` at write time via `ctx.provider.countTokens` (§tokenomics-tokens-stored-at-write). Raw DB writes bypass tokenization — out of API scope.
|
|
246
290
|
|
|
247
291
|
---
|
|
248
292
|
|
|
249
|
-
## §
|
|
293
|
+
## §mimetype Mimetype Contract
|
|
250
294
|
|
|
251
295
|
Author-facing contract: [plurnk-mimetypes](https://github.com/plurnk/plurnk-mimetypes). Below: firing semantics + consumption surface.
|
|
252
296
|
|
|
253
|
-
**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 time. {§
|
|
297
|
+
**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 time. {§mimetype-schemes-do-not-invoke-handlers}
|
|
254
298
|
|
|
255
|
-
### §
|
|
299
|
+
### §mimetype-manifest Manifest
|
|
256
300
|
|
|
257
|
-
Per author contract. Manifest declares `kind: "mimetype"`; handler class declares `mimetype` (matches manifest name) and `glyph` (single emoji). Collisions fail-hard at boot per §
|
|
301
|
+
Per author contract. Manifest declares `kind: "mimetype"`; handler class declares `mimetype` (matches manifest name) and `glyph` (single emoji). Collisions fail-hard at boot per §plugin-discovery.
|
|
258
302
|
|
|
259
|
-
### §
|
|
303
|
+
### §mimetype-methods Methods
|
|
260
304
|
|
|
261
305
|
Author contract owned by plurnk-mimetypes. plurnk-service consumes ONE entry point:
|
|
262
306
|
|
|
263
|
-
- `Mimetypes.process(input)` — the projection entry point; returns the structural projections (`deepJson` / `deepXml` / `symbols` / `references`) + extent (`totalLines`). {§
|
|
307
|
+
- `Mimetypes.process(input)` — the projection entry point; returns the structural projections (`deepJson` / `deepXml` / `symbols` / `references`) + extent (`totalLines`). {§mimetype-methods-process-entry-point}
|
|
264
308
|
|
|
265
309
|
**The daughter 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 daughter's.
|
|
266
310
|
|
|
@@ -271,17 +315,17 @@ Cross-cutting promises service relies on:
|
|
|
271
315
|
- Validation errors propagate (fail-hard).
|
|
272
316
|
- Degraded projection (a `grammarMissing` marker) rather than throw when a grammar is absent.
|
|
273
317
|
|
|
274
|
-
### §
|
|
318
|
+
### §handler-bounds What handlers do NOT do
|
|
275
319
|
|
|
276
|
-
- **Tokenization** — provider-bound (§
|
|
320
|
+
- **Tokenization** — provider-bound (§provider).
|
|
277
321
|
- **Storage** — pure functions over content strings.
|
|
278
|
-
- **Streaming** — handlers see whatever content is current; subscription registry lives between schemes and §
|
|
322
|
+
- **Streaming** — handlers see whatever content is current; subscription registry lives between schemes and §stream.
|
|
279
323
|
|
|
280
|
-
### §
|
|
324
|
+
### §handler-bundling Bundled vs sibling handlers
|
|
281
325
|
|
|
282
326
|
No mimetype handlers ship in-tree. Framework + every handler are siblings.
|
|
283
327
|
|
|
284
|
-
### §
|
|
328
|
+
### §mimetype-surface Consumption surface
|
|
285
329
|
|
|
286
330
|
plurnk-service is mimetype-illiterate. Engine hands channel content + mimetype label to `Mimetypes.process({content, hint})`; the manifest build uses `result.totalLines` for each channel's `lines`. Content reaches the model on READ, not as a rendered preview.
|
|
287
331
|
|
|
@@ -306,7 +350,7 @@ new Mimetypes({
|
|
|
306
350
|
|
|
307
351
|
Fallback heuristic is a boot-before-provider-resolved tripwire.
|
|
308
352
|
|
|
309
|
-
**Manifest build.** `EntryManifest.buildManifestBody` is the engine-side packet-assembly pass (the §
|
|
353
|
+
**Manifest build.** `EntryManifest.buildManifestBody` is the engine-side packet-assembly pass (the §mimetype firing point) that walks **every** entry. It calls `process({ content, hint })` per channel for the catalog's `lines` (`totalLines`) and, for the body channel, pulls `symbols`+`references` from the *same* call to (re)build the `@graph` symbol index (`symbol_defs`/`symbol_refs`) via `EntryGraph.populateFrom` — one parse, two projections:
|
|
310
354
|
|
|
311
355
|
```ts
|
|
312
356
|
const result = await mimetypes.process({ content: r.content, hint: r.mimetype }, { channels: ["symbols", "references"] });
|
|
@@ -320,39 +364,39 @@ if (isBody) await EntryGraph.populateFrom(db, sessionId, r.entry_id, result.symb
|
|
|
320
364
|
|
|
321
365
|
---
|
|
322
366
|
|
|
323
|
-
## §
|
|
367
|
+
## §channels Channel Topology
|
|
324
368
|
|
|
325
|
-
Every entry has named channels. **Channels are append-only content stores** keyed by `(entry_id, name)`. Schemes write content; the engine reads at turn boundaries; mimetype handlers interpret. {§
|
|
369
|
+
Every entry has named channels. **Channels are append-only content stores** keyed by `(entry_id, name)`. Schemes write content; the engine reads at turn boundaries; mimetype handlers interpret. {§channels-channels-append-only}
|
|
326
370
|
|
|
327
|
-
### §
|
|
371
|
+
### §per-entry-channels Per-entry channels
|
|
328
372
|
|
|
329
|
-
EDIT writes one channel per call — the channel resolved from the path's fragment (or the scheme's `defaultChannel` when no fragment). {§
|
|
373
|
+
EDIT writes one channel per call — the channel resolved from the path's fragment (or the scheme's `defaultChannel` when no fragment). {§per-entry-channels-edit-writes-only-body}
|
|
330
374
|
|
|
331
375
|
No stored `preview` channel — channel content is pulled on READ, never previewed.
|
|
332
376
|
|
|
333
377
|
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.
|
|
334
378
|
|
|
335
|
-
### §
|
|
379
|
+
### §no-visibility Entries carry no visibility
|
|
336
380
|
|
|
337
|
-
Every entry is uniformly listed in `plurnk://manifest.json` (§
|
|
381
|
+
Every entry is uniformly listed in `plurnk://manifest.json` (§packet) and READable — entries have no per-run open/folded state. Context curation is the model's, on the **log** (via OPEN/FOLD, §open-fold), never on entries.
|
|
338
382
|
|
|
339
|
-
### §
|
|
383
|
+
### §channel-mimetype Mimetype is a (scheme, channel) property — never a default
|
|
340
384
|
|
|
341
|
-
Mimetype is declared by scheme manifest (§
|
|
385
|
+
Mimetype is declared by scheme manifest (§scheme-manifest) or supplied per-call for dynamic schemes. Writing a channel without a declared mimetype throws. No default mimetype anywhere.
|
|
342
386
|
|
|
343
|
-
- Cross-mimetype COPY/MOVE → 415, never coerces (§
|
|
387
|
+
- Cross-mimetype COPY/MOVE → 415, never coerces (§copy). {§channel-mimetype-cross-mimetype-415}
|
|
344
388
|
|
|
345
|
-
### §
|
|
389
|
+
### §channel-selection Channel selection in the DSL
|
|
346
390
|
|
|
347
391
|
DSL targets a specific channel via the URL fragment (`#name`).
|
|
348
392
|
|
|
349
393
|
Rules:
|
|
350
394
|
|
|
351
|
-
1. Fragment-less paths target the scheme's `defaultChannel`. {§
|
|
352
|
-
2. Paths with a fragment target the named channel. {§
|
|
353
|
-
3. Unknown channel name → 400. {§
|
|
395
|
+
1. Fragment-less paths target the scheme's `defaultChannel`. {§channel-selection-fragmentless-targets-default-channel}
|
|
396
|
+
2. Paths with a fragment target the named channel. {§channel-selection-fragment-selects-named-channel}
|
|
397
|
+
3. Unknown channel name → 400. {§channel-selection-unknown-channel-400}
|
|
354
398
|
4. Schemes without `defaultChannel` reject fragment-less EDIT/READ.
|
|
355
|
-
5. Non-default channel EDIT requires entry to exist (404 if absent); default-channel EDIT creates. {§
|
|
399
|
+
5. Non-default channel EDIT requires entry to exist (404 if absent); default-channel EDIT creates. {§channel-selection-fragment-on-nonexistent-404}
|
|
356
400
|
| URI | Channel |
|
|
357
401
|
|---|---|
|
|
358
402
|
| `known://france/capital` | body (default) |
|
|
@@ -378,150 +422,172 @@ RPC params carry fragments inline via the `target` string (`{ target: "known://x
|
|
|
378
422
|
<<log://1/1/0:...:log://1/1/0 — atomic log row
|
|
379
423
|
```
|
|
380
424
|
|
|
381
|
-
### §
|
|
425
|
+
### §channel-state Channel state — metadata, not gating
|
|
382
426
|
|
|
383
|
-
Each channel has `state ∈ {static, active, closed, errored}`. Metadata only, not an engine gate. {§
|
|
427
|
+
Each channel has `state ∈ {static, active, closed, errored}`. Metadata only, not an engine gate. {§channel-state-state-is-metadata}
|
|
384
428
|
|
|
385
429
|
- `static` — content final, not being written. Entry schemes after EDIT.
|
|
386
430
|
- `active` — scheme is writing (chunks arriving). Streaming schemes during accumulation.
|
|
387
431
|
- `closed` — stream ended cleanly. Content final.
|
|
388
432
|
- `errored` — stream ended in error. Content may be partial; reads return what accumulated.
|
|
389
433
|
|
|
390
|
-
Schemes own transitions; UPDATE `entry_channels.state` as connection lifecycle progresses. {§
|
|
434
|
+
Schemes own transitions; UPDATE `entry_channels.state` as connection lifecycle progresses. {§channel-state-schemes-own-state-transitions} State does not gate reads — schemes return accumulated `content` regardless (§channel-state-state-is-metadata).
|
|
391
435
|
|
|
392
436
|
Model uses state to anticipate growth between turns. Clients use state for UI (spinner / red border / etc.).
|
|
393
437
|
|
|
394
438
|
---
|
|
395
439
|
|
|
396
|
-
## §
|
|
440
|
+
## §op Op Surface
|
|
397
441
|
|
|
398
|
-
Per-op semantics. AST shapes from `@plurnk/plurnk-grammar`'s `PlurnkStatement`. Engine dispatches by `op`; scheme implements per author contract (§
|
|
442
|
+
Per-op semantics. AST shapes from `@plurnk/plurnk-grammar`'s `PlurnkStatement`. Engine dispatches by `op`; scheme implements per author contract (§scheme).
|
|
399
443
|
|
|
400
|
-
### §
|
|
444
|
+
### §edit EDIT
|
|
401
445
|
|
|
402
446
|
AST: `{ op: "EDIT", target, body: string | null, signal: tags | null, lineMarker? }`.
|
|
403
447
|
|
|
404
|
-
- Resolves target channel from fragment (§
|
|
405
|
-
- Writes body; `body: null` clears. {§
|
|
406
|
-
- A write that changes nothing — identical content and no new tag — returns `{ status: 304, entryId }`, mirroring OPEN/FOLD's no-op (§
|
|
407
|
-
- Tags from `signal[]` apply additively via `entry_tags` (scheme may vary). {§
|
|
448
|
+
- Resolves target channel from fragment (§channel-selection); unknown channel → 400; undeclared in manifest → engine crash (§channel-mimetype).
|
|
449
|
+
- 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}
|
|
450
|
+
- 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}
|
|
451
|
+
- Tags from `signal[]` apply additively via `entry_tags` (scheme may vary). {§edit-tags-additive}
|
|
408
452
|
|
|
409
|
-
### §
|
|
453
|
+
### §read READ
|
|
410
454
|
|
|
411
455
|
AST: `{ op: "READ", target, body: MatcherBody | null, signal: tags | null, lineMarker? }`.
|
|
412
456
|
|
|
413
|
-
- Returns channel content + mimetype {§
|
|
414
|
-
- `lineMarker` slices per §
|
|
415
|
-
- `body` matcher dispatches through `Mimetypes.query` per §
|
|
457
|
+
- Returns channel content + mimetype {§read-read-content}, or 404 {§read-read-404}.
|
|
458
|
+
- `lineMarker` slices per §slice-semantics.
|
|
459
|
+
- `body` matcher dispatches through `Mimetypes.query` per §matcher-dispatch (all four dialects wired).
|
|
416
460
|
|
|
417
|
-
### §
|
|
461
|
+
### §open-fold OPEN / FOLD
|
|
418
462
|
|
|
419
463
|
AST: `{ op: "OPEN"|"FOLD", target, body: MatcherBody | null, signal: tags | null, lineMarker? }`.
|
|
420
464
|
|
|
421
|
-
OPEN/FOLD operate on the **log** (`log://`) — the model's context-curation surface (§
|
|
465
|
+
OPEN/FOLD operate on the **log** (`log://`) — the model's context-curation surface (§packet). FOLD collapses a log row to its path; OPEN restores its body. Non-destructive: rows and bodies persist, re-OPENable. Entries carry no visibility (§no-visibility), so OPEN/FOLD against an entry scheme returns 501.
|
|
422
466
|
|
|
423
|
-
### §
|
|
467
|
+
### §copy COPY (engine-orchestrated)
|
|
424
468
|
|
|
425
469
|
AST: `{ op: "COPY", target (source), body (destination), signal: tags | null, lineMarker? }`.
|
|
426
470
|
|
|
427
|
-
Engine orchestrates over CRUD primitives (§
|
|
471
|
+
Engine orchestrates over CRUD primitives (§crud, §orchestration):
|
|
428
472
|
|
|
429
|
-
1. `src_scheme.readEntry` → 404 if missing. {§
|
|
430
|
-
2. `dst_scheme.readEntry` → conflict verdict, deferred until the written content is known (step 5): exists with identical content + tags → 304 (no-op, mirrors EDIT §
|
|
473
|
+
1. `src_scheme.readEntry` → 404 if missing. {§copy-missing-source-404}
|
|
474
|
+
2. `dst_scheme.readEntry` → conflict verdict, deferred until the written content is known (step 5): exists with identical content + tags → 304 (no-op, mirrors EDIT §edit) {§copy-noop-304}; exists with different content → 409 (no overwrite) {§copy-conflict-409}; absent → proceed.
|
|
431
475
|
3. Mimetype compat — channels' mimetypes must be accepted by `dst_scheme.manifest.channels`. Mismatch → 415.
|
|
432
|
-
4. Tags: `signal` non-null replaces source tags {§
|
|
476
|
+
4. Tags: `signal` non-null replaces source tags {§copy-signal-replaces-source-tags}; null/empty carries source tags {§copy-no-signal-carries-source-tags}.
|
|
433
477
|
5. `dst_scheme.writeEntry({channels, tags})`.
|
|
434
478
|
|
|
435
|
-
Returns 201 on success. Same- and cross-scheme COPY share the orchestrator. {§
|
|
479
|
+
Returns 201 on success. Same- and cross-scheme COPY share the orchestrator. {§copy-cross-scheme-copy}
|
|
436
480
|
|
|
437
|
-
### §
|
|
481
|
+
### §move MOVE (engine-orchestrated)
|
|
438
482
|
|
|
439
483
|
AST: `{ op: "MOVE", target (source), body: dest | null, signal: tags | null, lineMarker? }`.
|
|
440
484
|
|
|
441
|
-
- **Relocation** (`body` non-null, resolvable dest): COPY (§
|
|
442
|
-
- **MOVE never deletes.** A null body → 400 (a destination is required). {§
|
|
485
|
+
- **Relocation** (`body` non-null, resolvable dest): COPY (§copy) + `src_scheme.deleteEntry` in one transaction. 201 on success. {§move-relocation-deletes-source} Cross-scheme same as same-scheme. {§move-cross-scheme-move} Missing source → 404. {§move-missing-source-404}
|
|
486
|
+
- **MOVE never deletes.** A null body → 400 (a destination is required). {§move-null-body-400} `/dev/null` carries no special meaning — the MOVE→/dev/null delete idiom is retired; KILL is the canonical delete. {§move-dev-null-not-special}
|
|
443
487
|
|
|
444
488
|
Log history preserved — `log_entries` stores path tuple as text, not FK to `entries.id`.
|
|
445
489
|
|
|
446
|
-
### §
|
|
490
|
+
### §find FIND
|
|
447
491
|
|
|
448
492
|
AST: `{ op: "FIND", target (scope), body: MatcherBody | null (predicate), signal: tags | null, lineMarker? }`.
|
|
449
493
|
|
|
450
|
-
- Filters entries within scope (scheme + pathname prefix). {§
|
|
451
|
-
- `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. {§
|
|
452
|
-
- `signal` is a tag filter; entries match if they have ALL listed tags. {§
|
|
453
|
-
- Session + scheme scoped — no cross-session/cross-scheme leakage. {§
|
|
494
|
+
- Filters entries within scope (scheme + pathname prefix). {§find-scope-prefix-filter}
|
|
495
|
+
- `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}
|
|
496
|
+
- `signal` is a tag filter; entries match if they have ALL listed tags. {§find-tag-filter-and-semantics}
|
|
497
|
+
- Session + scheme scoped — no cross-session/cross-scheme leakage. {§find-scoped-isolation}
|
|
454
498
|
- Returns `{ status: 200, results: string }` (newline-separated matching paths, `text/plain`).
|
|
455
499
|
|
|
456
|
-
### §
|
|
500
|
+
### §send SEND
|
|
457
501
|
|
|
458
502
|
AST: `{ op: "SEND", target: ParsedPath | null, body: SendBody | null, signal: number | null }`.
|
|
459
503
|
|
|
460
504
|
- **Broadcast** (path null): terminal status (200/499) updates `loop.status` and ends loop. Other codes return `{status}` with no state change.
|
|
461
|
-
- **Directed** (path non-null): routes to `scheme.send` per §
|
|
505
|
+
- **Directed** (path non-null): routes to `scheme.send` per §send-dispatch.
|
|
462
506
|
|
|
463
|
-
### §
|
|
507
|
+
### §exec EXEC
|
|
464
508
|
|
|
465
509
|
AST: `{ op: "EXEC", target (cwd), body: string | null (command), signal: string | null (runtime tag) }`.
|
|
466
510
|
|
|
467
|
-
Engine routes unconditionally to `exec` scheme (path slot is `cwd`, not a URI). The runtime slot (`signal`) selects an executor, resolved against the boot-time `ExecutorRegistry` — siblings discovered and probed at startup, availability cached, default `sh`. Unknown or unavailable runtime → 501 carrying the probe `detail`. {§
|
|
511
|
+
Engine routes unconditionally to `exec` scheme (path slot is `cwd`, not a URI). The runtime slot (`signal`) selects an executor, resolved against the boot-time `ExecutorRegistry` — siblings discovered and probed at startup, availability cached, default `sh`. Unknown or unavailable runtime → 501 carrying the probe `detail`. {§exec-registry-resolves}
|
|
512
|
+
|
|
513
|
+
**Effect-gating.** Each executor declares an `effect` (`pure` | `read` | `host`); the service maps it to policy (`EffectPolicy`). A `host` runtime (subprocess; file-backed sqlite) mutates the host → **propose** (lifecycle §proposal): the run waits for a human gate, then spawns and writes stdout/stderr to channels of an `exec://<runtime>/<loop>/<turn>/<seq>` entry (the executor is the URI authority; the coordinate that follows matches the op's log-row coordinate, e.g. `exec://sh/1/1/2`), returning `102 Processing` immediately. Channel state transitions (`active` → `closed`/`errored`) drive the model's view at subsequent turn boundaries (§channel-state). {§exec-host-proposes}
|
|
514
|
+
|
|
515
|
+
A `read` runtime (observes external state, e.g. search) or `pure` runtime (no observable effect, e.g. `:memory:` sqlite) is side-effect-free → **auto-run** in-process: no proposal, no human gate, no notification. The run is awaited synchronously and its channel content rides back as the EXEC result body the same turn — not streamed to the entry for a next-turn read. {§exec-readpure-inline}
|
|
468
516
|
|
|
469
|
-
|
|
517
|
+
`SEND[499](exec://<runtime>/<loop>/<turn>/<seq>)` cancels in-flight subprocess via subscription registry's stored AbortController (§stream-control).
|
|
470
518
|
|
|
471
|
-
|
|
519
|
+
### §proposal The proposal lifecycle
|
|
472
520
|
|
|
473
|
-
`
|
|
521
|
+
A side-effecting op does not execute on dispatch — it **proposes**. The scheme returns **202** (an EXEC `host` runtime §exec, an EDIT to a member file §membership); the engine writes the log row `state='proposed'`, registers a waiter keyed by `logEntryId`, and **pauses `dispatch`** awaiting a resolution. The pause is internal to dispatch — the turn has already closed, so §grinder strike accounting sees the *resolved* status, never the 202. On accept the status becomes 200 and the scheme's effect runs. {§proposal-202-pauses}
|
|
522
|
+
|
|
523
|
+
**Resolution arrives four ways, one surface to the model:**
|
|
524
|
+
- **`loop.resolve`** (§methods) — a client's accept / reject / cancel.
|
|
525
|
+
- **Server-YOLO** (§dual-yolo) — an in-tree listener resolves `accept` in-process, same tick, no wire roundtrip.
|
|
526
|
+
- **noProposals** — an in-tree listener resolves `reject` (outcome `no_review_channel`).
|
|
527
|
+
- **Timeout** — `PLURNK_PROPOSAL_TIMEOUT_MS` (§operator-config) elapses with no resolution → the engine synthesizes `cancel` (outcome `timeout`), server-side, needing no client. {§proposal-timeout-cancels}
|
|
528
|
+
|
|
529
|
+
**The decision drives a one-way state transition** on `log_entries.state` (resolution is idempotent — `WHERE state='proposed'`, so a second resolution 404s):
|
|
530
|
+
|
|
531
|
+
| decision | state | `status_rx` | default outcome | effect |
|
|
532
|
+
|---|---|---|---|---|
|
|
533
|
+
| accept | `resolved` | 200 | — | runs the scheme's **`applyResolution`** — the real side effect (disk write, exec spawn). {§proposal-accept-applies} A failing apply (≥400) downgrades to reject (outcome `apply_failed`). |
|
|
534
|
+
| reject | `failed` | 400 | `rejected` | none — the action did not occur. {§proposal-reject-fails} |
|
|
535
|
+
| cancel | `cancelled` | 499 | `loop_aborted` | none — the loop is abandoning. {§proposal-cancel-aborts} |
|
|
536
|
+
|
|
537
|
+
A caller-supplied `outcome` overrides the default, but `outcome` is **forensics-only** — never in the model-facing `rx`. So a YOLO accept, a human reject, and a timeout are indistinguishable to the model: the action **occurred** (200) or it **didn't** (400/499), nothing about how it was administratively resolved (§telemetry).
|
|
538
|
+
|
|
539
|
+
**A proposed row is invisible until it resolves.** A `state='proposed'` / 202 row is withheld from `packet.system.log`; it surfaces only after resolution, carrying its terminal status — the model sees outcomes, never pending proposals. {§proposal-proposed-hidden}
|
|
474
540
|
|
|
475
541
|
---
|
|
476
542
|
|
|
477
|
-
## §
|
|
543
|
+
## §stream Stream Model
|
|
478
544
|
|
|
479
|
-
Streams are static content from the engine's perspective — content arrives over time, channels grow, mimetype handlers render whatever's there at turn boundaries. No engine-level transaction abstraction; schemes own connection lifecycle. {§
|
|
545
|
+
Streams are static content from the engine's perspective — content arrives over time, channels grow, mimetype handlers render whatever's there at turn boundaries. No engine-level transaction abstraction; schemes own connection lifecycle. {§stream-no-engine-transaction-abstraction}
|
|
480
546
|
|
|
481
|
-
### §
|
|
547
|
+
### §subscriptions Subscriptions
|
|
482
548
|
|
|
483
|
-
READ on a streaming scheme is a subscription, not a one-shot. Scheme opens the connection (SSE/WS/subprocess), returns `102 Processing` immediately, stays alive. Engine records `(sessionId, entryId) → schemeName + handle` in a subscription registry so `SEND[499]` cancellation routes to the owning scheme. {§
|
|
549
|
+
READ on a streaming scheme is a subscription, not a one-shot. Scheme opens the connection (SSE/WS/subprocess), returns `102 Processing` immediately, stays alive. Engine records `(sessionId, entryId) → schemeName + handle` in a subscription registry so `SEND[499]` cancellation routes to the owning scheme. {§subscriptions-subscription-registry-routes-cancellation}
|
|
484
550
|
|
|
485
|
-
Subscription registry is plurnk-service runtime state (its own SQLite table). Exists ONLY for cancellation routing. Channel state (§
|
|
551
|
+
Subscription registry is plurnk-service runtime state (its own SQLite table). Exists ONLY for cancellation routing. Channel state (§channel-state) + log entries (§no-chunk-rows) carry lifecycle.
|
|
486
552
|
|
|
487
|
-
### §
|
|
553
|
+
### §chunk-accumulation Chunk accumulation
|
|
488
554
|
|
|
489
|
-
SSE event types, WS message types, exec stdout/stderr each map to a named channel. Channel record (`ChannelContent`): `content`, `mimetype`, `tokens`. Active-connection state lives in the subscription registry, not on the channel. Chunks accumulate into the channel as they arrive — not buffered until close. {§
|
|
555
|
+
SSE event types, WS message types, exec stdout/stderr each map to a named channel. Channel record (`ChannelContent`): `content`, `mimetype`, `tokens`. Active-connection state lives in the subscription registry, not on the channel. Chunks accumulate into the channel as they arrive — not buffered until close. {§chunk-accumulation-chunks-accumulate}
|
|
490
556
|
|
|
491
|
-
### §
|
|
557
|
+
### §no-chunk-rows No per-chunk log rows
|
|
492
558
|
|
|
493
|
-
Channels are the source of truth for chunk content. Log captures lifecycle events only: open (102), graceful close (200), cancel (499), errors (5xx), scheme-significant transitions. {§
|
|
559
|
+
Channels are the source of truth for chunk content. Log captures lifecycle events only: open (102), graceful close (200), cancel (499), errors (5xx), scheme-significant transitions. {§no-chunk-rows-log-captures-lifecycle-only}
|
|
494
560
|
|
|
495
561
|
Model sees lifecycle events in `packet.system.log[]` per turn.
|
|
496
562
|
|
|
497
|
-
### §
|
|
563
|
+
### §deep-slices Deep slices on demand
|
|
498
564
|
|
|
499
565
|
`<<READ(sse://feed/x#data)<N-M>:…:READ` pulls a slice into a log row when the model wants a specific line-range of the stream.
|
|
500
566
|
|
|
501
|
-
### §
|
|
567
|
+
### §stream-control SEND for stream control
|
|
502
568
|
|
|
503
569
|
- **Cancel:** `<<SEND[499](sse://feed/x)::SEND` — scheme tears down via AbortController.
|
|
504
570
|
- **Write:** `<<SEND[200](wss://feed/x):body:SEND` — pipes body into active connection (WS, exec stdin, etc.).
|
|
505
571
|
|
|
506
|
-
### §
|
|
572
|
+
### §stream-constraints Engine constraints
|
|
507
573
|
|
|
508
574
|
ONE engine-level constraint: **100 MiB char-length cap per channel body**. `CHECK (length(content) <= 104857600)` on `entry_channels.content` (migrations/001_schema.sql). Violations → SQLITE_CONSTRAINT; action-entry captures rejection at status 500.
|
|
509
575
|
|
|
510
|
-
All other limits are extrinsic — providers (request size, model context, fetch timeouts), schemes (per-call validation), mimetypes (render budgets). Engine does not throttle, batch, rate-limit, or cap anything else. {§
|
|
576
|
+
All other limits are extrinsic — providers (request size, model context, fetch timeouts), schemes (per-call validation), mimetypes (render budgets). Engine does not throttle, batch, rate-limit, or cap anything else. {§stream-constraints-engine-one-cap}
|
|
511
577
|
|
|
512
|
-
### §
|
|
578
|
+
### §live-updates Live updates for clients (between turns)
|
|
513
579
|
|
|
514
|
-
Daemon emits `stream/event` notifications (§
|
|
580
|
+
Daemon emits `stream/event` notifications (§notifications) when channel content changes; clients use them for live waterfalls without polling. {§live-updates-stream-event-fires-on-chunk}
|
|
515
581
|
|
|
516
582
|
The model is NOT a stream/event consumer — turn-based only; sees whatever's in the channel at the next turn boundary.
|
|
517
583
|
|
|
518
584
|
---
|
|
519
585
|
|
|
520
|
-
## §
|
|
586
|
+
## §storage Storage Model
|
|
521
587
|
|
|
522
588
|
SQLite (`node:sqlite`) with WAL mode and STRICT tables. Hand-written DDL; CI-aligned against grammar schemas.
|
|
523
589
|
|
|
524
|
-
### §
|
|
590
|
+
### §ddl DDL strategy
|
|
525
591
|
|
|
526
592
|
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.
|
|
527
593
|
|
|
@@ -530,7 +596,7 @@ No generator. SQLite-optimal: STRICT (3.37+), `INTEGER PRIMARY KEY` aliasing, ex
|
|
|
530
596
|
- **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.
|
|
531
597
|
- DDL = storage truth; JSON Schemas = wire truth. Tested-aligned, allowed to differ where ergonomics demand.
|
|
532
598
|
|
|
533
|
-
### §
|
|
599
|
+
### §sql-ts-boundary SQL/TS responsibility boundary
|
|
534
600
|
|
|
535
601
|
**Lives in SQL:**
|
|
536
602
|
- Render queries — log assembly + the manifest catalog.
|
|
@@ -541,10 +607,10 @@ No generator. SQLite-optimal: STRICT (3.37+), `INTEGER PRIMARY KEY` aliasing, ex
|
|
|
541
607
|
|
|
542
608
|
**Lives in TS:**
|
|
543
609
|
- Status-bubble rules (`turn.status` → `loop.status` → `run.status` → `session.status`). Engine UPDATEs explicitly; CHECK constraints enforce; triggers fight branching state machines.
|
|
544
|
-
- Tokenization (provider-bound; hot-swap re-tokenizes per §
|
|
610
|
+
- Tokenization (provider-bound; hot-swap re-tokenizes per §tokenomics).
|
|
545
611
|
- Provider dispatch + response normalization.
|
|
546
612
|
- Scheme-handler invocation (connections, subprocesses, fetch).
|
|
547
|
-
- Plugin loading (§
|
|
613
|
+
- Plugin loading (§plugin-discovery).
|
|
548
614
|
- Stream AbortController lifecycle.
|
|
549
615
|
- CLI + daemon.
|
|
550
616
|
|
|
@@ -552,7 +618,7 @@ When SQL becomes onerous for a specific case, retreat for that case and document
|
|
|
552
618
|
|
|
553
619
|
---
|
|
554
620
|
|
|
555
|
-
## §
|
|
621
|
+
## §plugin-discovery Plugin Discovery
|
|
556
622
|
|
|
557
623
|
Scoped-package scan with manifest field:
|
|
558
624
|
|
|
@@ -572,9 +638,9 @@ Env vars configure installed plugins; never declare existence. Filesystem is the
|
|
|
572
638
|
|
|
573
639
|
---
|
|
574
640
|
|
|
575
|
-
## §
|
|
641
|
+
## §bundled-set Bundled Set
|
|
576
642
|
|
|
577
|
-
Plugin discovery (§
|
|
643
|
+
Plugin discovery (§plugin-discovery) registers whatever's in `node_modules/@plurnk/*`.
|
|
578
644
|
|
|
579
645
|
**Providers in-tree (`src/providers/`):** `Mock` (intg-only test fixture + worked example).
|
|
580
646
|
|
|
@@ -588,7 +654,7 @@ Plugin discovery (§9) registers whatever's in `node_modules/@plurnk/*`.
|
|
|
588
654
|
| `Unknown.ts` | `@plurnk/plurnk-schemes-unknown` | Open questions / decomposition. |
|
|
589
655
|
| `Skill.ts` | `@plurnk/plurnk-schemes-skill` | Skill docs; same shape as known. |
|
|
590
656
|
| `Plurnk.ts` | may stay in-tree | `plurnk://prompt/<loop_id>` carries each loop's prompt. Model-origin writes to `plurnk://prompt/*` rejected in-handler. |
|
|
591
|
-
| `Log.ts` | may stay in-tree | Read-only coordinate-addressed (`log://<L>/<T>/<S>`). Renders as JSON meta line in packet log; status ≥ 400 mirrors to `packet.user.telemetry.errors[]` (§
|
|
657
|
+
| `Log.ts` | may stay in-tree | Read-only coordinate-addressed (`log://<L>/<T>/<S>`). Renders as JSON meta line in packet log; status ≥ 400 mirrors to `packet.user.telemetry.errors[]` (§telemetry). |
|
|
592
658
|
| `File.ts` | `@plurnk/plurnk-schemes-file` | Filesystem-backed. **Model is never trained on `file://` and never sees it.** Bare paths are model-facing; `file://` accepted as input, renders bare. |
|
|
593
659
|
| `Exec.ts` | stays in-tree | Dispatches EXEC op to runtime executors registered via [plurnk-execs](https://github.com/plurnk/plurnk-execs). |
|
|
594
660
|
|
|
@@ -596,47 +662,48 @@ Plugin discovery (§9) registers whatever's in `node_modules/@plurnk/*`.
|
|
|
596
662
|
|
|
597
663
|
---
|
|
598
664
|
|
|
599
|
-
## §
|
|
665
|
+
## §grammar Grammar Dependency
|
|
600
666
|
|
|
601
667
|
`@plurnk/plurnk-grammar` is the contract. Authoritative; surface gaps via issue, adopt what lands. Don't redesign from this side.
|
|
602
668
|
|
|
603
|
-
### §
|
|
669
|
+
### §grammar-provides What grammar provides
|
|
604
670
|
|
|
605
671
|
- Parser (`PlurnkParser`, ANTLR4) — DSL text → `PlurnkStatement[]`.
|
|
606
672
|
- AST types — exported TypeScript interfaces.
|
|
607
673
|
- JSON schemas (`schema/*.json`, draft 2020-12) for every wire shape.
|
|
608
674
|
- `plurnk.md` — canonical model-facing DSL description.
|
|
609
675
|
|
|
610
|
-
### §
|
|
676
|
+
### §service-tracks What plurnk-service tracks (NOT in grammar)
|
|
611
677
|
|
|
612
678
|
- Channel state (`active`/`closed`/`errored`) — subscription registry, not on `ChannelContent`.
|
|
613
|
-
- Backpressure caps — none (§
|
|
614
|
-
- Stream cancel — `SEND[499]` (§
|
|
615
|
-
- Delete — MOVE to `/dev/null` (§
|
|
679
|
+
- Backpressure caps — none (§stream-constraints).
|
|
680
|
+
- Stream cancel — `SEND[499]` (§stream-control).
|
|
681
|
+
- Delete — MOVE to `/dev/null` (§move); `SEND[410]` also deletes as a side-effect (§send-dispatch).
|
|
616
682
|
- Per-loop flags — `loops.flags` JSON column; `yolo` end-to-end today, others scheduled.
|
|
617
|
-
- Default-channel wire rendering — §
|
|
683
|
+
- Default-channel wire rendering — §channel-selection.
|
|
618
684
|
|
|
619
685
|
---
|
|
620
686
|
|
|
621
|
-
## §
|
|
687
|
+
## §operator-config Operator Configuration
|
|
622
688
|
|
|
623
689
|
Env-var cascade: `.env.example` < `.env` < `.env.<config>` (via `--config=`) < shell < CLI flags. `bin/plurnk-service.ts` auto-loads `.env.example`; zero-setup boot.
|
|
624
690
|
|
|
625
|
-
Model selection: separate alias cascade in `ProviderRegistry` (§
|
|
691
|
+
Model selection: separate alias cascade in `ProviderRegistry` (§provider-instantiation). `PLURNK_MODEL_<alias>=<provider>/<model-id>` declares; `PLURNK_MODEL=<alias>` selects. Aliases live in `.env`, not `.env.example` (operator-specific).
|
|
626
692
|
|
|
627
693
|
| Var | Default | Status | Purpose |
|
|
628
694
|
|--------------------------------------|--------------------|------------|---------------------------------------------------------------|
|
|
629
695
|
| `PLURNK_DB_PATH` | `./plurnk.db` | enforced | SQLite file path. |
|
|
630
696
|
| `PLURNK_HOST` | `127.0.0.1` | enforced | Bind address for the daemon WebSocket. Local-only by default. |
|
|
631
697
|
| `PLURNK_PORT` | `3044` | enforced | TCP port for the daemon WebSocket. |
|
|
632
|
-
| `PLURNK_MAX_TURNS` | `
|
|
698
|
+
| `PLURNK_MAX_TURNS` | `-1` | enforced | Operator turn **ceiling** — `-1` = no cap; a positive value caps a per-call `loop.run({maxTurns})`. |
|
|
633
699
|
| `PLURNK_MAX_COMMANDS` | `99` | enforced | Per-emission op cap. Overflow ops drop silently; one `max_commands_exceeded` telemetry entry surfaces on the next packet. |
|
|
634
700
|
| `PLURNK_RPC_TIMEOUT` | `30000` | reserved | ms timeout for non-`longRunning` RPC handlers. Not yet enforced. |
|
|
635
701
|
| `PLURNK_LOOP_TIMEOUT` | `86400000` | reserved | ms wall-clock budget for a single `loop.run`. Not yet enforced. |
|
|
636
|
-
| `PLURNK_MAX_STRIKES` | `3` | enforced | Strike threshold + sudden-death lead time (§
|
|
637
|
-
| `PLURNK_MIN_CYCLES` | `3` | enforced | Min repetitions before cycle detection fires (§
|
|
638
|
-
| `PLURNK_MAX_CYCLE_PERIOD` | `4` | enforced | Max period length cycle detection examines (§
|
|
702
|
+
| `PLURNK_MAX_STRIKES` | `3` | enforced | Strike threshold + sudden-death lead time (§engine-rails). |
|
|
703
|
+
| `PLURNK_MIN_CYCLES` | `3` | enforced | Min repetitions before cycle detection fires (§engine-rails). |
|
|
704
|
+
| `PLURNK_MAX_CYCLE_PERIOD` | `4` | enforced | Max period length cycle detection examines (§engine-rails). |
|
|
639
705
|
| `PLURNK_PERSONA` | `persona.md` | enforced | Path to the default persona file. Tail of the persona cascade: loops.persona > runs.persona > sessions.persona > this file. |
|
|
706
|
+
| `PLURNK_MD_<ALIAS>` | (unset) | enforced | Operator reference doc: materializes `<path>` as `plurnk://<ALIAS>.md`, auto-READ into every model run's turn 0 (§actor-boundary). `~` expands to home. |
|
|
640
707
|
| `PLURNK_PROPOSAL_TIMEOUT_MS` | `300000` | enforced | ms wait for a proposed entry (status=202) to be resolved before timing out. |
|
|
641
708
|
| `PLURNK_REASON` | `0` | enforced | Reasoning-token budget. 0 = disabled. Positive = budget in tokens; provider modules translate to wire format. |
|
|
642
709
|
| `PLURNK_FETCH_TIMEOUT` | `600000` | enforced | Service-wide ms ceiling on any outbound request (providers, future http schemes). Module-specific overrides are allowed below the ceiling. |
|
|
@@ -645,6 +712,12 @@ Model selection: separate alias cascade in `ProviderRegistry` (§2.3). `PLURNK_M
|
|
|
645
712
|
|
|
646
713
|
**enforced** = engine reads and acts on the value. **reserved** = shipped in `.env.example` (forward-spec) but no-op until wired.
|
|
647
714
|
|
|
715
|
+
**Two override semantics — ceiling vs default.** Which kind a var is determines what "override" means across the cascade:
|
|
716
|
+
- **Ceiling** (most-restrictive-wins) — an operator-set hard bound nothing downstream may exceed: not a lower-precedence file, not a per-session constraint, not a per-call RPC arg. `PLURNK_GIT_ENABLED` (`=0` flatly denies git service-wide, §membership), `PLURNK_BUDGET_CEILING` (§tokenomics), `PLURNK_MAX_COMMANDS`, `PLURNK_MAX_STRIKES`, `PLURNK_FETCH_TIMEOUT` (module overrides allowed only *below* it), and `PLURNK_MAX_TURNS` (`-1` ships it off; a positive value caps the per-call request). The sandbox/cost guarantee: the operator caps it; no client widens it.
|
|
717
|
+
- **Default** (explicit-wins) — a fallback the most-specific setter replaces freely: `PLURNK_MODEL` (a `loop.run({alias})` overrides it), `PLURNK_PERSONA` / `PLURNK_REQUIREMENTS` (the §persona persona cascade / per-call requirements), and the config-time vars (`HOST` / `PORT` / `DB_PATH`).
|
|
718
|
+
|
|
719
|
+
Enforcement is per-use-site — no central most-restrictive pass; each ceiling is checked where it bites. `PLURNK_MAX_TURNS` ships **off** (`-1` = no cap; the loop ends via SEND, budget, strikes, or cycle detection) and, when an operator sets a positive value, the per-call request is `min()`-capped against it. {§operator-config-max-turns-ceiling}
|
|
720
|
+
|
|
648
721
|
Feature-flag bools use `process.env.X === "1"` exactly — never `=== "true"`.
|
|
649
722
|
|
|
650
723
|
External plugins declare their own env vars in their own `.env.example`; service merges at boot via the cascade.
|
|
@@ -653,17 +726,17 @@ External plugins declare their own env vars in their own `.env.example`; service
|
|
|
653
726
|
|
|
654
727
|
---
|
|
655
728
|
|
|
656
|
-
## §
|
|
729
|
+
## §rpc RPC Surface
|
|
657
730
|
|
|
658
|
-
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 §
|
|
731
|
+
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.
|
|
659
732
|
|
|
660
|
-
### §
|
|
733
|
+
### §transport Transport
|
|
661
734
|
|
|
662
735
|
WebSocket (`ws` npm). One message per `ws.send`. UTF-8 JSON. One full-duplex connection per client. Bind: `PLURNK_HOST:PLURNK_PORT` (default `127.0.0.1:3044`).
|
|
663
736
|
|
|
664
737
|
Out of scope for v0: auth, TLS, multiplexing. Local-loopback + filesystem permissions are the access control.
|
|
665
738
|
|
|
666
|
-
### §
|
|
739
|
+
### §protocol Protocol
|
|
667
740
|
|
|
668
741
|
JSON-RPC 2.0. Two message kinds:
|
|
669
742
|
|
|
@@ -672,7 +745,7 @@ JSON-RPC 2.0. Two message kinds:
|
|
|
672
745
|
|
|
673
746
|
Success response: `{ "jsonrpc": "2.0", "id": …, "result": … }`. Failure: `{ "jsonrpc": "2.0", "id": …, "error": { "code": …, "message": …, "data": … } }`.
|
|
674
747
|
|
|
675
|
-
### §
|
|
748
|
+
### §method-registration Method registration
|
|
676
749
|
|
|
677
750
|
```ts
|
|
678
751
|
registry.register("loop.run", {
|
|
@@ -691,9 +764,9 @@ registry.register("loop.run", {
|
|
|
691
764
|
- `description`: one-liner surfaced by `discover`.
|
|
692
765
|
- `params`: `"type — meaning"` per param; `?` suffix = optional. Self-documenting, not enforced.
|
|
693
766
|
- `requiresInit`: rejects until a session is attached.
|
|
694
|
-
- `longRunning`: exempt from `PLURNK_RPC_TIMEOUT`. {§
|
|
767
|
+
- `longRunning`: exempt from `PLURNK_RPC_TIMEOUT`. {§method-registration-register}
|
|
695
768
|
|
|
696
|
-
### §
|
|
769
|
+
### §discovery Discovery
|
|
697
770
|
|
|
698
771
|
`discover` returns the catalog:
|
|
699
772
|
|
|
@@ -717,60 +790,60 @@ registry.register("loop.run", {
|
|
|
717
790
|
}
|
|
718
791
|
```
|
|
719
792
|
|
|
720
|
-
`capabilities` lists registered plug-ins by `(kind, name)`. Cold clients call `discover` first. No hardcoded method names or capability lists in any client. {§
|
|
793
|
+
`capabilities` lists registered plug-ins by `(kind, name)`. Cold clients call `discover` first. No hardcoded method names or capability lists in any client. {§discovery-discover}
|
|
721
794
|
|
|
722
|
-
### §
|
|
795
|
+
### §methods Core method set
|
|
723
796
|
|
|
724
797
|
**Liveness + introspection**
|
|
725
798
|
|
|
726
799
|
| Method | Params | Result | Notes |
|
|
727
800
|
|------------|--------|--------|-------|
|
|
728
801
|
| `ping` | none | `{}` | No init required. |
|
|
729
|
-
| `discover` | none | catalog (§
|
|
802
|
+
| `discover` | none | catalog (§discovery) | No init required. |
|
|
730
803
|
|
|
731
804
|
**Sessions**
|
|
732
805
|
|
|
733
806
|
| Method | Params | Result | Notes |
|
|
734
807
|
|------------------------|---------------------|-------------------|-------|
|
|
735
|
-
| `session.create` | `name?: string`, `projectRoot?: string`, `persona?: string` | `{ id, name, runId, runName, projectRoot, persona }` | Creates new session + its first run; auto-name if unprovided. Returns the auto-created run's identity so clients skip the pending-dance ({§
|
|
808
|
+
| `session.create` | `name?: string`, `projectRoot?: string`, `persona?: string` | `{ id, name, runId, runName, projectRoot, persona }` | Creates new session + its first run; auto-name if unprovided. Returns the auto-created run's identity so clients skip the pending-dance ({§methods-session-create}). Optional `projectRoot` pins the workspace (null/omitted = headless). Optional `persona` sets the session-level persona override. |
|
|
736
809
|
| `session.list` | none | `{ sessions: Session[] }` | Lists all sessions. |
|
|
737
|
-
| `session.attach` | `id: number`, `runId?: number`, `runName?: string`, `persona?: string` | `{ id, name, runId, runName }` | Binds this connection to an existing session. Optional `runId` resumes that specific run (must belong to the session). Optional `runName` reuses-or-creates by name within the session. Both omitted → new auto-named run. Optional `persona` sets run-level persona only when a NEW run is created. {§
|
|
810
|
+
| `session.attach` | `id: number`, `runId?: number`, `runName?: string`, `persona?: string` | `{ id, name, runId, runName }` | Binds this connection to an existing session. Optional `runId` resumes that specific run (must belong to the session). Optional `runName` reuses-or-creates by name within the session. Both omitted → new auto-named run. Optional `persona` sets run-level persona only when a NEW run is created. {§methods-session-attach} |
|
|
738
811
|
| `session.runs` | `id?: number` | `{ runs: Run[] }` | Lists runs in a session (defaults to attached session); most-recent first. |
|
|
739
812
|
| `session.set_root` | `projectRoot: string \| null` | `{ projectRoot }` | Update the workspace pointer on the attached session. Null reverts to headless. |
|
|
740
813
|
| `session.set_persona` | `persona: string \| null` | `{ persona }` | Update the session-level persona. Null clears the override (falls through to PLURNK_PERSONA file). |
|
|
741
|
-
| `session.constrain` | `effect: "add" \| "ignore" \| "read-only"`, `glob: string` | `{ effect, glob }` | Add a workspace membership constraint (§
|
|
814
|
+
| `session.constrain` | `effect: "add" \| "ignore" \| "read-only"`, `glob: string` | `{ effect, glob }` | Add a workspace membership constraint (§membership overlay): `add` admits files git misses, `ignore` drops tracked matches, `read-only` admits for read but refuses edits. Immediate. |
|
|
742
815
|
| `session.unconstrain` | `effect: "add" \| "ignore" \| "read-only"`, `glob: string` | `{ effect, glob }` | Remove a membership constraint — the inverse of `session.constrain`. Immediate. |
|
|
743
816
|
| `session.constraints` | none | `{ constraints }` | List the attached session's membership constraints. |
|
|
744
817
|
|
|
745
|
-
**Re-binding.** `session.create` and `session.attach` may be called on a connection that already has a session attached — the connection switches in place, releasing the prior client loop (closed at 200). No reconnect needed to change session or run. {§
|
|
818
|
+
**Re-binding.** `session.create` and `session.attach` may be called on a connection that already has a session attached — the connection switches in place, releasing the prior client loop (closed at 200). No reconnect needed to change session or run. {§methods-rebind}
|
|
746
819
|
|
|
747
|
-
**Auto-envelope.** Clients calling a `requiresInit: true` method without first attaching get auto-created session → run → client loop. Records persist normally; auto-created ≠ auto-deleted. Cleanup is a future `session.delete` / `session.archive` endpoint. {§
|
|
820
|
+
**Auto-envelope.** Clients calling a `requiresInit: true` method without first attaching get auto-created session → run → client loop. Records persist normally; auto-created ≠ auto-deleted. Cleanup is a future `session.delete` / `session.archive` endpoint. {§methods-auto-envelope}
|
|
748
821
|
|
|
749
|
-
**Reserved run names.** `plurnk` is reserved for the runtime actor (§
|
|
822
|
+
**Reserved run names.** `plurnk` is reserved for the runtime actor (§authority-terms). `session.attach` rejects it — case-insensitively, *before* the lookup-or-create — so a client can neither forge a `plurnk` run nor resume the runtime's, closing impersonation of `origin=plurnk`. The auto-namer never emits a reserved name. {§methods-run-name-reserved}
|
|
750
823
|
|
|
751
824
|
**Loops (model-driven)**
|
|
752
825
|
|
|
753
826
|
| Method | Params | Result | Notes |
|
|
754
827
|
|-------------------|-------------------------------------|------------------------|-------|
|
|
755
|
-
| `loop.run` | `prompt: string`, `maxTurns?: number`, `alias?: string`, `flags?: LoopFlags`, `persona?: string` | `{ loopId, turnIds, finalStatus, hitMaxTurns, reason }` | Model-driven loop. Optional `alias` overrides the boot-time `PLURNK_MODEL`. Optional `flags` carries per-loop flags (currently `{yolo?: boolean}`; more arrive as wired — see §
|
|
828
|
+
| `loop.run` | `prompt: string`, `maxTurns?: number`, `alias?: string`, `flags?: LoopFlags`, `persona?: string` | `{ loopId, turnIds, finalStatus, hitMaxTurns, reason }` | Model-driven loop. Optional `alias` overrides the boot-time `PLURNK_MODEL`. Optional `flags` carries per-loop flags (currently `{yolo?: boolean}`; more arrive as wired — see §engine-rails). Optional `persona` sets the loop-level persona (highest precedence in the cascade). Streams `log/entry` and `loop/proposal` notifications during. `longRunning: true`. {§methods-loop-run} |
|
|
756
829
|
| `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. |
|
|
757
|
-
| `loop.cancel` | `reason?: string` | `{ cancelled, runId, reason }` | Abort the attached run'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`. {§
|
|
830
|
+
| `loop.cancel` | `reason?: string` | `{ cancelled, runId, reason }` | Abort the attached run'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`. {§methods-loop-cancel} |
|
|
758
831
|
| `providers.list` | none | `{ aliases: ProviderAlias[] }` | Lists configured `PLURNK_MODEL_<alias>` entries with `{alias, provider, model, active}`. Clients use to populate model-selection UI. |
|
|
759
832
|
|
|
760
833
|
**Reads**
|
|
761
834
|
|
|
762
835
|
| Method | Params | Result | Notes |
|
|
763
836
|
|---------------|-------------------------------------|------------------------|-------|
|
|
764
|
-
| `entry.read` | `target: string` | `{ status, entry }` | Read the full entry shape (channels + tags + metadata) at the given URI. {§
|
|
765
|
-
| `log.read` | `loopId?: number`, … | `{ entries: LogEntry[] }` | Read recent log entries from the attached session, optionally filtered by loop. {§
|
|
837
|
+
| `entry.read` | `target: string` | `{ status, entry }` | Read the full entry shape (channels + tags + metadata) at the given URI. {§methods-entry-read} |
|
|
838
|
+
| `log.read` | `loopId?: number`, … | `{ entries: LogEntry[] }` | Read recent log entries from the attached session, optionally filtered by loop. {§methods-log-read} |
|
|
766
839
|
|
|
767
|
-
**Log coordinate.** Every `LogEntry` — from `log.read` and the `log/entry` notification alike — carries `loop_seq`/`turn_seq`, the loop+turn ordinals, beside the `loop_id`/`turn_id` DB keys, so a client renders the logical coordinate (e.g. `01/02/03`) without resolving ids. {§
|
|
840
|
+
**Log coordinate.** Every `LogEntry` — from `log.read` and the `log/entry` notification alike — carries `loop_seq`/`turn_seq`, the loop+turn ordinals, beside the `loop_id`/`turn_id` DB keys, so a client renders the logical coordinate (e.g. `01/02/03`) without resolving ids. {§methods-log-coordinate}
|
|
768
841
|
|
|
769
842
|
**DSL operations (client-driven, mirror grammar)**
|
|
770
843
|
|
|
771
|
-
Per the **Speak in DSL, not plumbing** rule (AGENTS.md): `op.*` methods construct DSL statements internally and dispatch through `Engine.dispatch`. {§
|
|
844
|
+
Per the **Speak in DSL, not plumbing** rule (AGENTS.md): `op.*` methods construct DSL statements internally and dispatch through `Engine.dispatch`. {§methods-op-mirror} Param shapes are ergonomic (semantic names, not HEREDOC slots); semantics are the DSL's.
|
|
772
845
|
|
|
773
|
-
Each `op.*` call creates a turn in the connection's client loop (§
|
|
846
|
+
Each `op.*` call creates a turn in the connection's client loop (§connection-lifecycle), dispatches, fires `log/entry`, returns the dispatch result.
|
|
774
847
|
|
|
775
848
|
Naming: `target` = URI the op acts on; `scope` for FIND; `source`/`destination` for COPY/MOVE; `recipient` for SEND (or null = broadcast); `cwd` for EXEC. `path` is reserved for *identity* — never an RPC operand.
|
|
776
849
|
|
|
@@ -792,23 +865,23 @@ All `op.*` return `{ status, ...op-specific }`. All `requiresInit: true`. None `
|
|
|
792
865
|
|
|
793
866
|
Future: `subscription.list`, `subscription.cancel` (the latter is `op.send({status: 499, recipient})` today).
|
|
794
867
|
|
|
795
|
-
### §
|
|
868
|
+
### §notifications Notifications
|
|
796
869
|
|
|
797
870
|
Server-initiated events on the same WebSocket.
|
|
798
871
|
|
|
799
872
|
| Notification | Params | When fired |
|
|
800
873
|
|--------------------|-------------------------------------|------------|
|
|
801
|
-
| `log/entry` | `{ entry: LogEntry }` | Every `log_entries` write. {§
|
|
874
|
+
| `log/entry` | `{ entry: LogEntry }` | Every `log_entries` write. {§notifications-log-entry-notify} |
|
|
802
875
|
| `loop/terminated` | `{ loopId, finalStatus, hitMaxTurns }` | Loop reaches terminal status. |
|
|
803
876
|
| `loop/proposal` | `{ logEntryId, sessionId, runId, loopId, turnId, op, target, body, attrs, flags }` | Dispatch pauses on status=202. Carries `flags` so server-YOLO clients can suppress review UI. Client responds with `loop.resolve` (or `PLURNK_PROPOSAL_TIMEOUT_MS` fires). |
|
|
804
877
|
| `session/created` | `{ id, name, projectRoot, persona }` | Any client creates a session. |
|
|
805
|
-
| `stream/event` | `{ entryId, channel, state, contentLength }` | Channel content grows or state transitions. {§
|
|
806
|
-
| `stream/concluded` | `{ entryId, target, subscriptionId, scheme, closeStatus, summary, wakeAction, wakeLoopId? }` | A streaming subscription closed (subprocess finished / errored / cancelled). `wakeAction` says whether the daemon opened a fresh loop to surface the conclusion to the model. {§
|
|
807
|
-
| `telemetry/event` | `{ loopId, event: TelemetryEvent }` | A TelemetryEvent (parse error, engine-rail strike/cycle/sudden-death, scheme/provider failure) was buffered — the same envelope the model sees on the next packet, delivered live for client surfacing. {§
|
|
878
|
+
| `stream/event` | `{ entryId, channel, state, contentLength }` | Channel content grows or state transitions. {§notifications-stream-event-on-channel-change} |
|
|
879
|
+
| `stream/concluded` | `{ entryId, target, subscriptionId, scheme, closeStatus, summary, wakeAction, wakeLoopId? }` | A streaming subscription closed (subprocess finished / errored / cancelled). `wakeAction` says whether the daemon opened a fresh loop to surface the conclusion to the model. {§notifications-stream-concluded} |
|
|
880
|
+
| `telemetry/event` | `{ loopId, event: TelemetryEvent }` | A TelemetryEvent (parse error, engine-rail strike/cycle/sudden-death, scheme/provider failure) was buffered — the same envelope the model sees on the next packet, delivered live for client surfacing. {§notifications-telemetry-event} |
|
|
808
881
|
|
|
809
|
-
`stream/event` carries metadata only, never content. Clients fetch via `entry.read({target})`. **Every notification envelope carries its `sessionId`** (and `runId` where the emitter has it) so a multi-session client — one connection, many sessions — can route it ({§
|
|
882
|
+
`stream/event` carries metadata only, never content. Clients fetch via `entry.read({target})`. **Every notification envelope carries its `sessionId`** (and `runId` where the emitter has it) so a multi-session client — one connection, many sessions — can route it ({§notifications-envelope-carries-sessionid}); the broadcast stays session-scoped too.
|
|
810
883
|
|
|
811
|
-
### §
|
|
884
|
+
### §connection-lifecycle Connection lifecycle
|
|
812
885
|
|
|
813
886
|
```
|
|
814
887
|
[client] [daemon]
|
|
@@ -837,11 +910,11 @@ Server-initiated events on the same WebSocket.
|
|
|
837
910
|
| (daemon closes the client loop; session keeps)|
|
|
838
911
|
```
|
|
839
912
|
|
|
840
|
-
**The client's run.** A client connection is an actor (§
|
|
913
|
+
**The client's run.** A client connection is an actor (§machine-processes); its `op.*` write to its **own run** — `origin = "client"`, one loop per connection — and `log.read` reads that run. Disconnect closes the loop's status; rows persist. Multiple connections each get their own client run.
|
|
841
914
|
|
|
842
|
-
`loop.run` and `inject` target the **model's run** — a separate run holding the conversation, `origin = "model"`. Both runs share the session's one filesystem (§
|
|
915
|
+
`loop.run` and `inject` target the **model's run** — a separate run holding the conversation, `origin = "model"`. Both runs share the session's one filesystem (§machine-processes); the packet renders only the model's run, so the client's ops are structurally absent from it — no origin filter (§actor-boundary-isolation). *Migration:* the daemon today opens both loops in the connection's one run (the conflation §machine-processes corrects); the build gives the client and the model their separate runs.
|
|
843
916
|
|
|
844
|
-
### §
|
|
917
|
+
### §errors Errors
|
|
845
918
|
|
|
846
919
|
Standard JSON-RPC codes:
|
|
847
920
|
|
|
@@ -866,19 +939,19 @@ Plurnk-specific (`-32000` to `-32099`):
|
|
|
866
939
|
| -32006 | Mimetype unavailable |
|
|
867
940
|
| -32007 | Timeout |
|
|
868
941
|
|
|
869
|
-
Error responses MAY include `data: {…}` with structured context (404'd path, timed-out method, etc.). {§
|
|
942
|
+
Error responses MAY include `data: {…}` with structured context (404'd path, timed-out method, etc.). {§errors-error-codes}
|
|
870
943
|
|
|
871
|
-
### §
|
|
944
|
+
### §versioning Versioning
|
|
872
945
|
|
|
873
946
|
Pre-stabilization. Clients track HEAD. No semver until the interface is worth committing to.
|
|
874
947
|
|
|
875
948
|
---
|
|
876
949
|
|
|
877
|
-
## §
|
|
950
|
+
## §decisions Architectural decisions
|
|
878
951
|
|
|
879
952
|
Each entry: question, answer, rationale, migration path.
|
|
880
953
|
|
|
881
|
-
### §
|
|
954
|
+
### §packet-assembly Packet assembly: engine-direct, not filter-chain
|
|
882
955
|
|
|
883
956
|
**Question.** Rummy uses priority-ordered filter chains for packet assembly. Plurnk assembles directly in `Engine.#buildRequestPacket` (`#buildLog` + the materialized manifest catalog).
|
|
884
957
|
|
|
@@ -888,7 +961,7 @@ Each entry: question, answer, rationale, migration path.
|
|
|
888
961
|
|
|
889
962
|
**Migration path.** If a plugin needs to inject a packet section, grow a single `packet.augment` hook called after `#buildRequestPacket`; plugins return system/user augmentation objects merged into the packet. Additive — engine-direct base stays.
|
|
890
963
|
|
|
891
|
-
### §
|
|
964
|
+
### §tokenomics Tokenomics: real provider tokens, render-weight budget, per-scheme balance
|
|
892
965
|
|
|
893
966
|
**Question.** How does plurnk track token costs accurately enough to ground the model's OPEN/FOLD/compose decisions? Accuracy is the whole game — a budget that smells wrong is one the model stops trusting and curating against.
|
|
894
967
|
|
|
@@ -899,12 +972,12 @@ Each entry: question, answer, rationale, migration path.
|
|
|
899
972
|
|
|
900
973
|
**Built.**
|
|
901
974
|
|
|
902
|
-
- **Provider tokens, stored at write.** `provider.countTokens` is the source of truth; `entry_channels.tokens` (via `_entry-crud`) and `log_entries.tokens` (via `Engine.#writeLog`) are populated at write as a write-time snapshot. A `ceil(len/DIVISOR)` fallback (the divisor tripwire) applies only when no provider tokenizer is wired. {§
|
|
903
|
-
- **Render-weight budget.** The budget headline — `ceiling`, `tokenUsage`, `tokensFree` — is measured from the *assembled packet* (placeholders substituted after measuring), so it reflects what the model actually receives. A `SUM` of stored content-depth would mis-price the rendered packet; render-weight is the accurate measure. {§
|
|
904
|
-
- **Per-scheme balance.** A markdown table groups the model's context by scheme — render-weight `tokens` per scheme — anchored `repo, known, unknown, log`, tail sorted by tokens. The model sees at a glance what's eating its window. {§
|
|
905
|
-
- **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. {§
|
|
975
|
+
- **Provider tokens, stored at write.** `provider.countTokens` is the source of truth; `entry_channels.tokens` (via `_entry-crud`) and `log_entries.tokens` (via `Engine.#writeLog`) are populated at write as a write-time snapshot. A `ceil(len/DIVISOR)` fallback (the divisor tripwire) applies only when no provider tokenizer is wired. {§tokenomics-tokens-stored-at-write}
|
|
976
|
+
- **Render-weight budget.** The budget headline — `ceiling`, `tokenUsage`, `tokensFree` — is measured from the *assembled packet* (placeholders substituted after measuring), so it reflects what the model actually receives. A `SUM` of stored content-depth would mis-price the rendered packet; render-weight is the accurate measure. {§tokenomics-render-weight-budget}
|
|
977
|
+
- **Per-scheme balance.** A markdown table groups the model's context by scheme — render-weight `tokens` per scheme — anchored `repo, known, unknown, log`, tail sorted by tokens. The model sees at a glance what's eating its window. {§tokenomics-per-scheme-balance}
|
|
978
|
+
- **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}
|
|
906
979
|
- **Depth re-counted at render.** The manifest re-tokenizes each entry's `tokens` through the live provider at build — never the write-time snapshot — so a model change between loops can't stale the catalog. Every token figure in the packet is render-fresh, manifest and budget alike; nothing trusts a cross-loop cached total.
|
|
907
|
-
- **Over-budget is honest.** When usage exceeds the ceiling, `free` floors at 0 and the percent passes 100 — the readout shows the overshoot rather than a negative free, so the model knows it's over and curates down. {§
|
|
980
|
+
- **Over-budget is honest.** When usage exceeds the ceiling, `free` floors at 0 and the percent passes 100 — the readout shows the overshoot rather than a negative free, so the model knows it's over and curates down. {§tokenomics-over-budget-floor}
|
|
908
981
|
|
|
909
982
|
**Rejected / obviated.**
|
|
910
983
|
|
|
@@ -915,50 +988,50 @@ Each entry: question, answer, rationale, migration path.
|
|
|
915
988
|
|
|
916
989
|
**Migration path.** None on cost — SQLite, JS, and a local tokenizer are negligible against the model's token budget, the only thing worth economizing. The fallback divisor is a correctness tripwire (no provider tokenizer wired), not a performance retreat. Schema unchanged.
|
|
917
990
|
|
|
918
|
-
### §
|
|
991
|
+
### §membership Workspace identity, membership, disk co-location
|
|
919
992
|
|
|
920
993
|
**Question.** How does plurnk represent the project a session works on? Where does file membership come from? Does writing an entry imply writing to disk?
|
|
921
994
|
|
|
922
995
|
**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 (`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).
|
|
923
996
|
|
|
924
|
-
**Built — git-substrate membership.** {§
|
|
997
|
+
**Built — git-substrate membership.** {§membership-git-membership}
|
|
925
998
|
|
|
926
999
|
- **Identity on the session.** No `projects` table; `sessions.project_root TEXT` (nullable = headless). `entries.scope` unchanged (`∈ {'agent','session'}`). Workspace = session; no users/auth/multi-tenant.
|
|
927
1000
|
- **git-ls-files membership.** git present → tracked files (`git ls-files`) are members with no explicit `add` — channel-less markers, disk is truth. git absent → no fs-walk (non-git/headless get no substrate membership).
|
|
928
1001
|
- **EMI eager + exhaustive.** Materializes *every* active git member at prompt-composition, re-reading disk so a divergent member reflects current content. git's `ls-files` is the whole membership bound — the engine adds no relevance pass of its own. Allowing git *is* the curation.
|
|
929
|
-
- **Membership-gated edits.** {§
|
|
1002
|
+
- **Membership-gated edits.** {§membership-edit-membership-gate} EDIT is bounded by membership exactly as READ is. An existing **member** is read from disk before diffing, so its baseline is real content, never empty — silent overwrite is structurally prevented. An existing **non-member** is refused (403) *before* any read or write: the model never reads a file it can't see (no leak into the proposal) and never overwrites one (no wiping a gitignored `.env` it never added). A **new path** stays open — proposal→accept adds it to the manifest. Reaching past membership is `EXEC[sh]`'s job, not the file scheme's.
|
|
930
1003
|
|
|
931
|
-
- **Constraint overlay — `ignore`.** {§
|
|
932
|
-
- **Constraint overlay — `read-only`.** {§
|
|
933
|
-
- **Constraint overlay — `add`.** {§
|
|
934
|
-
- **EMI divergence signal.** {§
|
|
1004
|
+
- **Constraint overlay — `ignore`.** {§membership-constraint-ignore} A `session_constraints` table (effect ∈ {add, ignore, read-only}, glob) is the client's supersede over git. `ignore` drops tracked matches: resolution computes `git ls-files − ignore` (`node:path.matchesGlob`) and **reconciles** — registers the desired, un-registers any git-origin member no longer desired (untracked or newly ignored) — so the entry set *equals* the member set. The lever to exclude a committed-but-sensitive tracked file; `entries.membership_origin` keeps reconciliation off model-created members.
|
|
1005
|
+
- **Constraint overlay — `read-only`.** {§membership-constraint-readonly} A `read-only` glob keeps a matching member readable but refuses `File.edit` — 403'd at the membership check, before any diff. A file admitted for reading without granting writes. (Admitting an *untracked* file as read-only rides on `add`'s scan.)
|
|
1006
|
+
- **Constraint overlay — `add`.** {§membership-constraint-add} `add` globs admit members git misses: a targeted, client-dictated `node:fs` glob scan enumerates untracked matches (files only), registered with 'constraint' origin and reconciled like git members. Enumerated, so the manifest stays exhaustive. git-absent, `add` is the *sole* membership source — the whole mechanism without git.
|
|
1007
|
+
- **EMI divergence signal.** {§membership-emi-divergence-signal} When git membership re-reads a member whose disk content changed out-of-band, the build-time delta detector (§env-delta) surfaces it as a system `EDIT` log row naming the file, `source="file"` — the model sees what changed without diffing the manifest against memory. The model's own accepted edits advance the run's watermark, so they're never mis-attributed as external divergence.
|
|
935
1008
|
|
|
936
1009
|
**Rationale.** No users/tenants. Session is the right scope unit. git+constraints membership keeps the model out of fs-walk territory — and *is* the curation, outsourced: git bounds membership by tracking, the client supersedes by constraint, the model curates its view by READ/FOLD. The engine curates nothing. EMI re-reads every member each turn; the full-repo cost is git's to bound (what it tracks) and the client's to bound (`ignore`), never the engine's to bound by relevance.
|
|
937
1010
|
|
|
938
1011
|
**Migration path.** Tenancy / cross-session shared workspaces require a `workspaces` table joining sessions to workspace id, with constraints lifted off `session_constraints`. Disk co-location semantics unchanged.
|
|
939
1012
|
|
|
940
|
-
### §
|
|
1013
|
+
### §grinder Budget enforcement: the grinder
|
|
941
1014
|
|
|
942
|
-
**Question.** §
|
|
1015
|
+
**Question.** §tokenomics surfaces the budget honestly and the model curates against `tokensFree` — almost always enough. Two states defeat self-regulation, neither the model's doing: a jumbo prompt (the turn-0 environment), and an unexpectedly large read. (A jumbo repo is no longer its own case — with no index nothing auto-renders the repo; it surfaces only as a large `manifest.json` READ, which the model chunks like any big read.) What enforces the ceiling when the signal isn't enough?
|
|
943
1016
|
|
|
944
|
-
**Decision — a pre-LLM grinder, fired only on actual overflow.** In `Engine.runTurn`, after the packet is assembled (`#buildRequestPacket`) and before `provider.generate`, the assembled render-weight (§
|
|
1017
|
+
**Decision — a pre-LLM grinder, fired only on actual overflow.** In `Engine.runTurn`, after the packet is assembled (`#buildRequestPacket`) and before `provider.generate`, the assembled render-weight (§tokenomics) is measured against the ceiling. At or under → the packet ships untouched; the grinder never trims speculatively or "helpfully." {§grinder-overflow-only} On overflow it reverts the prior turn, then hard-stops if that isn't enough:
|
|
945
1018
|
|
|
946
|
-
- **Prior-turn rollback.** The immediately-prior turn's log entries — the latest emissions, the ones that pushed the packet over — are folded (`indexed=0`, the same flag the model's own FOLD uses); the prior turn fit by induction, so reverting it usually lands back under. Folded, not deleted: rows and bodies persist and are re-OPENable, so log *history* is preserved while the render collapses to coordinates. {§
|
|
947
|
-
- **Hard stop.** If the packet still overflows after the prior-turn rollback, the loop abandons at 499 (`engine_loop_cancel`) — the path `maxTurns` and the strike threshold already use. No further passes. {§
|
|
1019
|
+
- **Prior-turn rollback.** The immediately-prior turn's log entries — the latest emissions, the ones that pushed the packet over — are folded (`indexed=0`, the same flag the model's own FOLD uses); the prior turn fit by induction, so reverting it usually lands back under. Folded, not deleted: rows and bodies persist and are re-OPENable, so log *history* is preserved while the render collapses to coordinates. {§grinder-layer1-rollback}
|
|
1020
|
+
- **Hard stop.** If the packet still overflows after the prior-turn rollback, the loop abandons at 499 (`engine_loop_cancel`) — the path `maxTurns` and the strike threshold already use. No further passes. {§grinder-hard-413-abort}
|
|
948
1021
|
|
|
949
|
-
**Strike coupling.** A grinder fire bumps the engine's `turnErrors` — the same internal counter cycle detection feeds — so an overflow counts toward the strike streak that ends a runaway loop at 499. This is the pressure that keeps self-curation the path of least resistance. {§
|
|
1022
|
+
**Strike coupling.** A grinder fire bumps the engine's `turnErrors` — the same internal counter cycle detection feeds — so an overflow counts toward the strike streak that ends a runaway loop at 499. This is the pressure that keeps self-curation the path of least resistance. {§grinder-strike-coupling} **Turn 0/1 is exempt:** the first turn's overflow precedes any model action — it's the environment, not the model — so it never strikes. {§grinder-soft-turn-0-1}
|
|
950
1023
|
|
|
951
|
-
**What the model sees.** A `budget_overflow` telemetry event (§
|
|
1024
|
+
**What the model sees.** A `budget_overflow` telemetry event (§telemetry), in the model's own terms: which of its entries left the window, by scheme. No mechanism vocabulary — no "layer," no "grinder," no "reclaim" — and no advice. The engine reports *what happened to the model's world*; the per-scheme budget table (§tokenomics) is the diagnostic surface, and the model — which can see what changed in its repo, its reads, its turn — diagnoses the cause the engine can't attribute. {§grinder-event-model-terms} Per the gamification policy (§telemetry), the *strike* the overflow triggers stays engine-internal; the model sees the hidden entries, never the accounting.
|
|
952
1025
|
|
|
953
|
-
**Rationale.** The model owns curation (§
|
|
1026
|
+
**Rationale.** The model owns curation (§tokenomics); the grinder is the exceptional backstop. It only *hides* — reversibly — the prior turn's render; nothing is deleted, so the model can OPEN it back and log history stays intact. Rummy's §1316 spec described clearing log *bodies*, but its code instead hid the prior turn whole — because body-clearing is destructive (it deletes the read result) and bespoke. The code was the lesson; plurnk follows it.
|
|
954
1027
|
|
|
955
1028
|
**Migration path.** None on mechanism. Speculative or non-overflow trimming is a different feature, deliberately excluded — the grinder fires only in response to actual overflow.
|
|
956
1029
|
|
|
957
|
-
### §
|
|
1030
|
+
### §env-delta The environment delta: what changed since the model last looked
|
|
958
1031
|
|
|
959
|
-
**Question.** The manifest (§
|
|
1032
|
+
**Question.** The manifest (§packet) is a live directory of what *exists*, re-derived each turn — but it carries *state*, not *events*. When a session entry changes out-of-band between turns — an exec stream grows, a sibling run edits a shared entry, a tracked file diverges on disk (§membership) — the model's prior READ is now stale, and the manifest's new line count is a fact it must *diff against its own memory* to notice. The manifest also cannot say *who* changed it; with more than one actor in a session, provenance is load-bearing. What surfaces change — losslessly, attributably, without curating?
|
|
960
1033
|
|
|
961
|
-
**Decision — a per-run delta of plurnk-authored EDITs.** When a session entry changes out-of-band, the engine records it the way the model records its *own* edits: a `log_entries` row, op=`EDIT`, `origin=plurnk`, carrying the new `source` attribution (the cause). The body is exactly an EDIT's body — **the edited area as it looks now, line-numbered, with a couple lines of context** (the result-rendering all EDITs share, §
|
|
1034
|
+
**Decision — a per-run delta of plurnk-authored EDITs.** When a session entry changes out-of-band, the engine records it the way the model records its *own* edits: a `log_entries` row, op=`EDIT`, `origin=plurnk`, carrying the new `source` attribution (the cause). The body is exactly an EDIT's body — **the edited area as it looks now, line-numbered, with a couple lines of context** (the result-rendering all EDITs share, §edit-result-render). The set is **exhaustive and unranked** — every change, no relevance order — but **not content-free**: showing the edited region of a change that *happened* is a faithful record, not the index regrowing. The engine may inform (the §membership EMI divergence is the precedent), never rank or select what the model retains; the one line it must never cross is *ranking*. Volume is the model's to manage by FOLD (and the grinder's under budget), not the engine's to manage by gutting the payload.
|
|
962
1035
|
|
|
963
1036
|
**Form — a log entry, `origin=plurnk`, the change translated to DSL.** A delta is a `log_entries` row: an **`EDIT`** ("an EDIT happened to X"), `origin=plurnk`, carrying the new **`source`** column — the cause. A log entry, not a transient frame section, because a run's timeline must be **self-contained** (a forked run carries everything it observed). `source` renders as `run="<id>"` / `run="file"` in the meta line, **omitted when the cause is the owning run itself**. It is a third attribution axis, distinct from `run_id` (whose log owns the row) and `origin` (the actor *type*).
|
|
964
1037
|
|
|
@@ -966,67 +1039,42 @@ Each entry: question, answer, rationale, migration path.
|
|
|
966
1039
|
|
|
967
1040
|
**Coalesce per `(entry, source)`.** Net the magnitude, keep the cause: three edits to one entry by one run collapse to a single `+N`; the same entry touched by a run *and* the fs stays two deltas. Provenance is the delta's reason to exist, so it is the one thing coalescing never erases.
|
|
968
1041
|
|
|
969
|
-
**Detection — announced vs ambient.** A run *knows* when it writes, so run-caused changes are **broadcast** (the existing `stream/event`, §
|
|
1042
|
+
**Detection — announced vs ambient.** A run *knows* when it writes, so run-caused changes are **broadcast** (the existing `stream/event`, §notifications — already session-scoped and carrying its `runId`) and **queued** per sibling run, drained into deltas at that run's next turn. Nothing announces an external disk edit, so ambient fs changes are **scooped at pre-turn** by the §membership EMI scan. Attribution falls out of detection: the broadcast carries the run; the scan attributes to the scheme.
|
|
970
1043
|
|
|
971
1044
|
**Rationale.** "The model knows its world moved" becomes a property of *reading reality at build*, not of *remembering to emit* — 100% coverage by construction. The engine records each change faithfully — as the EDIT it was, showing its result — and hands the model the wheel; it never ranks, selects, or folds on the model's behalf. The model FOLDs what it doesn't need; the grinder folds under budget.
|
|
972
1045
|
|
|
973
1046
|
**Migration path.** Additive. The `source` column defaults null — existing rows and the current render are unaffected; materialization and the broadcast/EMI detection layer on without touching the op surface.
|
|
974
1047
|
|
|
975
|
-
### §
|
|
1048
|
+
### §edit-result-render EDIT log rows render their result, not their input
|
|
976
1049
|
|
|
977
|
-
**Question.** An EDIT's log row exists so the model has a record of what it did. Re-emitting the model's *input* statement (the tx heredoc) records the *intent* but not the *outcome* — the model still has to READ the entry back to confirm "did it land, what does it look like now." And a system delta-EDIT (§
|
|
1050
|
+
**Question.** An EDIT's log row exists so the model has a record of what it did. Re-emitting the model's *input* statement (the tx heredoc) records the *intent* but not the *outcome* — the model still has to READ the entry back to confirm "did it land, what does it look like now." And a system delta-EDIT (§env-delta) has no input statement at all. What should an EDIT row's body be?
|
|
978
1051
|
|
|
979
|
-
**Decision — the edited area as it looks now.** An EDIT row renders the **resulting span**: the edited region of the entry *after* the write, line-numbered, with a couple of lines of context above and below. The model sees post-edit state inline — no confirming READ — and the same rendering serves the model's own EDITs and the system delta-EDITs (§
|
|
1052
|
+
**Decision — the edited area as it looks now.** An EDIT row renders the **resulting span**: the edited region of the entry *after* the write, line-numbered, with a couple of lines of context above and below. The model sees post-edit state inline — no confirming READ — and the same rendering serves the model's own EDITs and the system delta-EDITs (§env-delta) identically. The meta line still carries op + target, so "I EDITed X" stays legible; the body says "and here's X now."
|
|
980
1053
|
|
|
981
1054
|
**Scope.** The span is computed at edit time — the write range and the result are both known then — and stored on the EDIT's `rx`; the render reads it. A large span is bounded like any rendered slice, and FOLD collapses it to the coordinate when the model doesn't need it.
|
|
982
1055
|
|
|
983
1056
|
**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.
|
|
984
1057
|
|
|
985
|
-
### §
|
|
986
|
-
|
|
987
|
-
**Question.** A session holds many runs — model, client, plurnk (§0.1, §0.4) — over one shared manifest. What keeps one run's activity out of another's conversation; what are the *only* ways a run's work reaches another; and does the engine's own work obey the boundary or get a privileged back channel?
|
|
988
|
-
|
|
989
|
-
**Decision — isolation by run; the model is not privileged.** A packet renders exactly one run's log — the assembling run's — against the session's shared manifest (§15). A run cannot see another's log: isolation is *structural*, a consequence of "a run owns its log entries" (§0.1) and "one packet, one run," never an `origin` filter at render time. `origin` (§0.4) is **attribution** — the delta's provenance (§14.5) — never read to hide a row. {§14.7-isolation} {§14.7-origin-not-filter}
|
|
990
|
-
|
|
991
|
-
**Two doors, and only two.** A run's work reaches another run by exactly two channels, and a private log is reachable no other way:
|
|
992
|
-
- the **environment door** — a write to a *shared entry* surfaces to every run sharing it as a folded, attributed delta (§14.5). *State.*
|
|
993
|
-
- the **voice door** — an **inject** delivers a turn into a *specific* run's log; `btw` is the user's mid-loop inject. *Message.*
|
|
994
|
-
|
|
995
|
-
{§14.7-two-doors}
|
|
1058
|
+
### §dual-yolo Dual-YOLO: server- and client-side auto-accept
|
|
996
1059
|
|
|
997
|
-
**
|
|
1060
|
+
**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 session. One flag, or two mechanisms?
|
|
998
1061
|
|
|
999
|
-
**
|
|
1062
|
+
**Decision — two distinct, complementary mechanisms.** Auto-accept lives at two layers that never substitute for each other:
|
|
1000
1063
|
|
|
1001
|
-
**
|
|
1064
|
+
- **Server-side YOLO** — a per-loop flag, `loops.flags.yolo=true`, set via `loop.run({flags:{yolo:true}})`. The engine auto-resolves the proposal **in-process** — the in-tree `yolo` listener reads the pending proposal and accepts it without any `loop.resolve` ever crossing the wire. No client need be connected. {§dual-yolo-server-yolo-auto-accept} Its uses are non-interactive: benchmarks, CI runs, internal automation, test fixtures. Client apps deliberately do **not** expose it — it is not end-user ergonomics.
|
|
1065
|
+
- **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" session.
|
|
1002
1066
|
|
|
1003
|
-
**
|
|
1067
|
+
**The notification carries the flag.** `loop/proposal` carries `flags` (§notifications), `yolo` among them, so a client attached to a *server*-YOLO loop can suppress its review UI — those proposals resolve in-process before any human could react, and rendering a doomed review prompt is noise. {§dual-yolo-proposal-carries-flags}
|
|
1004
1068
|
|
|
1005
|
-
|
|
1069
|
+
**Why two.** They answer different questions. Server-side asks *"is a human in the loop at all?"* — and when the answer is no, dispatch must not block on a `loop.resolve` that will never come. Client-side asks *"does this human want to review each one?"* — a presentation choice that leaves the protocol untouched. Collapsing them would either force a client onto every headless run or leak an interactive preference into the engine's dispatch path. They are orthogonal by construction: the engine gate and the human gate, each bypassable on its own terms.
|
|
1006
1070
|
|
|
1007
|
-
**
|
|
1008
|
-
|
|
1009
|
-
**Decision — the session is the world; a run is a log on it.** A **session** is the world: one shared filesystem — the `session`-scoped entries, surfaced as `plurnk://manifest.json` (§15) — under one membership overlay (§14.3). Exactly one filesystem and one overlay per session; neither is per-run. A **run** is a process whose entire private memory is its **log** (§0.1) — its loops, turns, and rows, each row carrying its own content, attribution (`origin`/`source`, §14.5), and fold-state (`indexed`). A run owns **no entries** and **no membership**; even its visibility is not a possession but a bit on its own rows. It is a *history over the shared world, not a world*.
|
|
1010
|
-
|
|
1011
|
-
**One filesystem.** The entries are the session's: `entries.session_id`, never a run. A write by any run is a write to the one filesystem every run reads; there is no per-run entry set. {§14.8-one-filesystem}
|
|
1012
|
-
|
|
1013
|
-
**One overlay.** Membership — `git ls-files ∪ add − ignore` with read-only (§14.3) — is the session's: `session_constraints.session_id`, never a run. It is workspace *curation*, and the workspace *is* the session; two runs are two conversations about one curated workspace and see the same one. Divergent membership is a different session, never a per-run overlay. {§14.8-one-overlay}
|
|
1014
|
-
|
|
1015
|
-
**A run is its log — and nothing beside.** The run-private state is the log and only the log. *What I am looking at* (OPEN/FOLD) is `log_entries.indexed`, a bit on the run's own rows, toggled by ordinary `log://` ops — not a second store, and never membership (§6.3). *What I last saw* needs no shadow either: a run learns its world moved through log entries (§14.5) — a sibling's write broadcast into its log, an out-of-band disk change detected against the entry's own content and broadcast the same way — never through a per-run snapshot the run cannot see. The log is the whole of a run's memory. {§14.8-run-is-its-log}
|
|
1016
|
-
|
|
1017
|
-
**Fork — copy the log, share the world.** A fork is a new run in the *same* session (`runs.parent_run_id`, §0.1), taken at a savepoint. It copies the **log** — the rows, their fold-state riding along — so the branch inherits everything the parent observed (§14.5 makes a run's timeline self-contained for exactly this) and diverges freely after. {§14.8-fork-copies-the-log} It shares the **world** — the one filesystem, the one overlay — live and uncopied, because the run never owned it. {§14.8-fork-shares-the-world}
|
|
1018
|
-
|
|
1019
|
-
**A session cannot be forked.** There is nothing to branch — a session *is* the shared ground. `runs` carries `parent_run_id`; `sessions` carries no parent. Parallel histories over one workspace are forks of its runs; a divergent workspace is a new session. {§14.8-no-fork-session}
|
|
1020
|
-
|
|
1021
|
-
**Rationale.** The model falls out of one correction: *a run is a history over a shared world, not a world.* Entries are the world (session); the log is the history (run); forking a history need not copy the world, and a run accumulates nothing the log does not already hold. The overlay's session home is forced the same way — it is the world's curation, and the world is shared; per-run it fragments the one manifest, forks the membership read-gate (the §14.3 security line), and duplicates what FOLD already does at the right level. Every "which run / what's copied / where's the per-client view" answers itself once the world/log line is drawn.
|
|
1022
|
-
|
|
1023
|
-
**Migration path.** Mostly stating what the schema already carries: `runs.parent_run_id` and the parentless `sessions` exist (§0.1); `session_constraints` is session-level (§14.3); §14.5 already makes a run's timeline self-contained, so a fork's log copy suffices. Additive: the savepoint/branch *operation* and `run.fork` over the wire. Two repatriations: §14.7's "read-only overlay scopes a run's writable surface" becomes a *session* policy bounding every run uniformly; and the §14.5 environment door sheds its per-run watermark — a run's only memory is its log, so drift is the broadcast (run-caused) and the build-time disk-vs-entry diff (ambient), both landing as log entries, never a per-run snapshot.
|
|
1071
|
+
**Migration path.** Built. `loops.flags.yolo` persists and the `yolo` listener (`src/server/yolo.ts`) auto-resolves; `loop/proposal` already carries `flags`. Client-side YOLO is wholly the client's (`@plurnk/plurnk`) concern — the service offers nothing to build for it beyond the `loop.resolve` RPC it already has.
|
|
1024
1072
|
|
|
1025
1073
|
---
|
|
1026
1074
|
|
|
1027
|
-
## §
|
|
1075
|
+
## §packet Packet shape
|
|
1028
1076
|
|
|
1029
|
-
Canonical shape defined by `@plurnk/plurnk-grammar` (`schema/Packet.json`). Engine assembles in `Engine.#buildRequestPacket`; no plugin augmentation (§
|
|
1077
|
+
Canonical shape defined by `@plurnk/plurnk-grammar` (`schema/Packet.json`). Engine assembles in `Engine.#buildRequestPacket`; no plugin augmentation (§packet-assembly). This section is plurnk-service's responsibilities under that contract.
|
|
1030
1078
|
|
|
1031
1079
|
```ts
|
|
1032
1080
|
type Packet = {
|
|
@@ -1035,14 +1083,14 @@ type Packet = {
|
|
|
1035
1083
|
tokens: number;
|
|
1036
1084
|
system_definition: string;
|
|
1037
1085
|
persona: string;
|
|
1038
|
-
index: PacketEntry[]; // visible entries (§
|
|
1039
|
-
log: PacketLogRow[]; // chronological action-entries (§
|
|
1086
|
+
index: PacketEntry[]; // visible entries (§mimetype / §channels)
|
|
1087
|
+
log: PacketLogRow[]; // chronological action-entries (§stream)
|
|
1040
1088
|
};
|
|
1041
1089
|
user: {
|
|
1042
1090
|
tokens: number;
|
|
1043
1091
|
prompt: string;
|
|
1044
|
-
telemetry: { budget: string; errors: object[] }; // §
|
|
1045
|
-
system_requirements: string; // §
|
|
1092
|
+
telemetry: { budget: string; errors: object[] }; // §telemetry
|
|
1093
|
+
system_requirements: string; // §requirements
|
|
1046
1094
|
};
|
|
1047
1095
|
assistant: { tokens: number; content: string; ops: PlurnkStatement[]; reasoning: string | null };
|
|
1048
1096
|
assistantRaw: unknown;
|
|
@@ -1051,9 +1099,9 @@ type Packet = {
|
|
|
1051
1099
|
|
|
1052
1100
|
**Prompt as a first-class entry.** Each loop's prompt is written on loop start as a plurnk-origin `EDIT` against `plurnk://prompt/<loop_id>` (indexable, body channel, text/markdown). At render time the current loop's prompt body materializes into `packet.user.prompt`; the entry itself stays READ/FOLD-able like any other.
|
|
1053
1101
|
|
|
1054
|
-
**The entry catalog.** `plurnk://manifest.json` is a real session entry the model READs to discover what's available — rewritten every turn as a live view of the full entry set. Built in the schemes layer (`_entry-manifest`) and materialized like any entry (the engine only orchestrates the per-turn write — the same pattern as git membership), so it's READable and queryable. Body is `application/json`: a flat, **complete, unranked** array — one item per entry across all schemes, every entry listed in no relevance order, each `{ path, channels: { <name>: { mimetype, tokens, lines } } }`. The model ranks and filters the catalog itself by querying it (task-aware); the catalog never ranks for it — the instant it did, it would be an index again. `tokens` is the provider's write-time count (budget depth), `lines` the content extent from `Mimetypes.process().totalLines`. The engine counts neither. It does not list itself. {§
|
|
1102
|
+
**The entry catalog.** `plurnk://manifest.json` is a real session entry the model READs to discover what's available — rewritten every turn as a live view of the full entry set. Built in the schemes layer (`_entry-manifest`) and materialized like any entry (the engine only orchestrates the per-turn write — the same pattern as git membership), so it's READable and queryable. Body is `application/json`: a flat, **complete, unranked** array — one item per entry across all schemes, every entry listed in no relevance order, each `{ path, channels: { <name>: { mimetype, tokens, lines } } }`. The model ranks and filters the catalog itself by querying it (task-aware); the catalog never ranks for it — the instant it did, it would be an index again. `tokens` is the provider's write-time count (budget depth), `lines` the content extent from `Mimetypes.process().totalLines`. The engine counts neither. It does not list itself. {§packet-manifest-catalog}
|
|
1055
1103
|
|
|
1056
|
-
### §
|
|
1104
|
+
### §telemetry user.telemetry — model-facing runtime telemetry
|
|
1057
1105
|
|
|
1058
1106
|
Slot for telemetry the model MUST react to immediately. Rendered at the bottom of the user section. Errors are transient — appear on the turn AFTER the failure, clear once seen. `packet.system.log[]` is the durable audit; `telemetry.errors[]` is the **alert**.
|
|
1059
1107
|
|
|
@@ -1064,9 +1112,9 @@ Slot for telemetry the model MUST react to immediately. Rendered at the bottom o
|
|
|
1064
1112
|
|
|
1065
1113
|
**Plurnk-service rendering:**
|
|
1066
1114
|
|
|
1067
|
-
- `budget` per §
|
|
1115
|
+
- `budget` per §tokenomics: per-scheme breakdown table with `tokenCeiling`/`tokenUsage`/`tokensFree`.
|
|
1068
1116
|
- `errors[]` from previous turn's dispatch. Required: `kind` discriminator. Additional kind-specific fields are flat on the element — NO nested `detail`. Canonical-JSON serialization sorts keys for prefix-cache friendliness.
|
|
1069
|
-
- Wire: one `* {canonical-JSON}` line per error under `# Plurnk System Errors`, push order. Buffer drains on read. {§
|
|
1117
|
+
- Wire: one `* {canonical-JSON}` line per error under `# Plurnk System Errors`, push order. Buffer drains on read. {§telemetry-drain-on-read}
|
|
1070
1118
|
- **No prose `message` field.** Errors carry structured facts. The `kind` is the alert; the named fields are the data. Guidance, advice, hints, and exhortation MUST NOT appear in telemetry. Letting the model infer what to do from facts (and the log) beats handing it instructions it will second-guess.
|
|
1071
1119
|
- **Gamification policy (rummy precedent, plugins/error/error.js).** The model sees errors that **happened** — its actions failed, its emission didn't parse, its ops were truncated. The model does NOT see the engine's accounting *about* errors: strike streaks, cycle detection, sudden-death thresholds, no-ops bookkeeping. Surfacing internal state creates a gamification surface where the model optimizes for engine metrics (manufacturing a clean turn to reset the strike counter, e.g.) instead of the task. Engine bookkeeping drives abandonment silently; the model just sees its actual failures.
|
|
1072
1120
|
|
|
@@ -1079,29 +1127,37 @@ Slot for telemetry the model MUST react to immediately. Rendered at the bottom o
|
|
|
1079
1127
|
| `max_commands_exceeded` | Single emission exceeded `PLURNK_MAX_COMMANDS` cap; overflow ops dropped without dispatch | `source: "engine:rail"`, `kind`, `emitted`, `dropped` |
|
|
1080
1128
|
| `budget_overflow` | Assembled packet exceeded the budget ceiling; entries moved out of the window to fit | `source: "engine:rail"`, `kind`, `hidden` (per-scheme `[{scheme, count}]` — entries removed from the window) |
|
|
1081
1129
|
|
|
1082
|
-
Strike accounting, cycle detection, sudden-death thresholds, and no-ops bookkeeping are all engine-internal — they drive abandonment silently per the gamification policy above. Action-bound failures (handler returned 4xx/5xx or threw) mirror as `action_failure` kind on the next packet. Full detail queryable via `log://`. {§
|
|
1130
|
+
Strike accounting, cycle detection, sudden-death thresholds, and no-ops bookkeeping are all engine-internal — they drive abandonment silently per the gamification policy above. Action-bound failures (handler returned 4xx/5xx or threw) mirror as `action_failure` kind on the next packet. Full detail queryable via `log://`. {§telemetry-no-error-scheme}
|
|
1083
1131
|
|
|
1084
1132
|
**No `error://` scheme.** Actionless failures route to telemetry, not a queryable scheme namespace.
|
|
1085
1133
|
|
|
1086
|
-
**Client surface: `telemetry/event` notification.** Every event the engine pushes to the loop's telemetry buffer also broadcasts live via the `telemetry/event` WS notification. Same envelope on both sides — `{ source, kind, message?, position?, …kind-specific }` per the grammar's `TelemetryEvent` schema. The model sees the event on the NEXT packet's `telemetry.errors[]` (drains on read); the client sees it the moment it lands. Client uses cases: render parse errors in a debug panel (the `snippet` field is content the model emitted), surface strike/sudden_death as "loop is degrading" toasts, log everything to a session timeline. Scoped to the loop's session. {§
|
|
1134
|
+
**Client surface: `telemetry/event` notification.** Every event the engine pushes to the loop's telemetry buffer also broadcasts live via the `telemetry/event` WS notification. Same envelope on both sides — `{ source, kind, message?, position?, …kind-specific }` per the grammar's `TelemetryEvent` schema. The model sees the event on the NEXT packet's `telemetry.errors[]` (drains on read); the client sees it the moment it lands. Client uses cases: render parse errors in a debug panel (the `snippet` field is content the model emitted), surface strike/sudden_death as "loop is degrading" toasts, log everything to a session timeline. Scoped to the loop's session. {§telemetry-telemetry-event-notify}
|
|
1087
1135
|
|
|
1088
|
-
**Content-offset snippet rendering.** When telemetry carries `position: { type: "content-offset", line, column }`, plurnk-service extracts a ±N-line slice from the model's own prior `assistant.content` and renders it as an `N:\t`-prefixed heredoc under an `error://<line>` fence, immediately following the event meta line. Without the snippet, the model gets "invalid xpath at 1:0" with no way to trace what it wrote at 1:0 — and tends to regenerate the same broken emission. With it, recovery is direct (canonical case: the edit-todo demo where a READ body starting with `//` got xpath-dispatched). The snippet field is stripped from the meta JSON so it appears once, in the body block. {§
|
|
1136
|
+
**Content-offset snippet rendering.** When telemetry carries `position: { type: "content-offset", line, column }`, plurnk-service extracts a ±N-line slice from the model's own prior `assistant.content` and renders it as an `N:\t`-prefixed heredoc under an `error://<line>` fence, immediately following the event meta line. Without the snippet, the model gets "invalid xpath at 1:0" with no way to trace what it wrote at 1:0 — and tends to regenerate the same broken emission. With it, recovery is direct (canonical case: the edit-todo demo where a READ body starting with `//` got xpath-dispatched). The snippet field is stripped from the meta JSON so it appears once, in the body block. {§telemetry-content-offset-snippet}
|
|
1089
1137
|
|
|
1090
|
-
### §
|
|
1138
|
+
### §requirements user.system_requirements — static per-turn rules
|
|
1091
1139
|
|
|
1092
|
-
Rendered at the END of the user packet under `# Plurnk System Requirements` {§
|
|
1140
|
+
Rendered at the END of the user packet under `# Plurnk System Requirements` {§requirements-requirements-render-last} — closest to the assistant turn so the contract the model has to honor is the most recent text it sees. The header is omitted entirely when the requirements string is empty. {§requirements-requirements-omitted-when-empty} Contains rules the grammar block doesn't cover (canonical example: "Conclude the loop with `<<SEND[200]:answer:SEND`").
|
|
1093
1141
|
|
|
1094
1142
|
**Sourcing:** caller supplies the string via `runLoop({ requirements })` / `runTurn({ requirements })`. Plurnk-service exposes `PATHS.defaultRequirements` (resolves `PLURNK_REQUIREMENTS` env → in-package `requirements.md`). No DB cascade — same string every turn.
|
|
1095
1143
|
|
|
1096
1144
|
**Rationale:** the user's prompt is natural language ("Reply with just the number") and routinely conflicts with the grammar's operational contract. Without an explicit requirement block, the model obeys the prompt literally and never reaches for SEND. Requirements are the contract that wins those conflicts.
|
|
1097
1145
|
|
|
1146
|
+
### §persona Persona — the per-entity cascade
|
|
1147
|
+
|
|
1148
|
+
The persona — the character the model wears, rendered into `packet.system` — resolves per turn at packet assembly by a cascade over three nullable columns plus a file default. **`loops.persona` > `runs.persona` > `sessions.persona` > the `PLURNK_PERSONA` file** (`engine_resolve_persona` is `COALESCE(loop, run, session)`, falling to `PATHS.defaultPersona` when all three are null); the most specific level set wins. {§persona-cascade-precedence}
|
|
1149
|
+
|
|
1150
|
+
**Null falls through; empty string overrides.** A null at a level defers to the next; an explicit `""` is a non-null value that wins the COALESCE and **suppresses** the cascade — the model gets no persona section. Setting `""` is how a client deliberately strips an inherited persona for one loop. {§persona-null-falls-through} {§persona-empty-suppresses}
|
|
1151
|
+
|
|
1152
|
+
**Set by RPC, evaluated at build.** `session.create` / `session.set_persona` set the session level, `session.attach` sets a *new* run's level, `loop.run({persona})` sets the loop level (highest precedence). The cascade resolves fresh each turn — not frozen at loop start — so a runtime `session.set_persona` lands on the next turn.
|
|
1153
|
+
|
|
1098
1154
|
---
|
|
1099
1155
|
|
|
1100
|
-
## §
|
|
1156
|
+
## §matcher Matcher and `<L>` slicing
|
|
1101
1157
|
|
|
1102
1158
|
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.
|
|
1103
1159
|
|
|
1104
|
-
### §
|
|
1160
|
+
### §matcher-dispatch Matcher dispatch (delegated to `Mimetypes.query`)
|
|
1105
1161
|
|
|
1106
1162
|
`matchAgainstContent` (exported from `@plurnk/plurnk-schemes`) is an adapter over `Mimetypes.query(input, expression)`. Framework parses leading prefix, resolves per-mimetype handler, returns `QueryMatch[]`. Adapter maps typed errors:
|
|
1107
1163
|
|
|
@@ -1113,11 +1169,11 @@ Body matchers and `<L>` both dispatch on entry mimetype. Body matcher: leading-c
|
|
|
1113
1169
|
| Empty match array | 204 |
|
|
1114
1170
|
| Match array | 200 |
|
|
1115
1171
|
|
|
1116
|
-
203 is HTTP-creative ("Non-Authoritative Information"). On parse failure, returns raw bytes as text primitive with `reason` so the model can fall back to regex/visual parsing or fix source. {§
|
|
1172
|
+
203 is HTTP-creative ("Non-Authoritative Information"). On parse failure, returns raw bytes as text primitive with `reason` so the model can fall back to regex/visual parsing or fix source. {§matcher-dispatch-203-soft-fallback}
|
|
1117
1173
|
|
|
1118
1174
|
Glob anchoring (`TODO*` starts-with, `*TODO*` contains, `*.log` ends-with, `[Tt]odo*` char class) lives in framework's `BaseHandler`.
|
|
1119
1175
|
|
|
1120
|
-
### §
|
|
1176
|
+
### §matcher-result Matcher result shape (uniform across dialects)
|
|
1121
1177
|
|
|
1122
1178
|
Body: one match per line as `<line>:\t<value>` — the same `N:\t` form READ emits, so `<L>` can page the result set. Empty → 204. Mimetype = `text/markdown` regardless of source dialect.
|
|
1123
1179
|
|
|
@@ -1138,7 +1194,7 @@ Body: one match per line as `<line>:\t<value>` — the same `N:\t` form READ emi
|
|
|
1138
1194
|
| jsonpath `$.path` | JSON values (parsed value for JSON-shaped mimetypes; bare-leaves outline for markdown/HTML/source) | get the host field / jump to Installation |
|
|
1139
1195
|
| xpath `//sel` | XML nodes/text/attrs (text/html only) | get the h1 contents |
|
|
1140
1196
|
|
|
1141
|
-
### §
|
|
1197
|
+
### §slice-semantics `<L>` semantics by source mimetype
|
|
1142
1198
|
|
|
1143
1199
|
**General**: sentinels `<0>` (before pos 1) and `<-1>` (after last) are EDIT insertion points; READ/COPY select empty. Other negatives in a single-position marker → 416. In a range, `M = -1` normalizes to "last" so `<1,-1>` is the whole content.
|
|
1144
1200
|
|
|
@@ -1160,16 +1216,16 @@ Body: one match per line as `<line>:\t<value>` — the same `N:\t` form READ emi
|
|
|
1160
1216
|
- `<0>` / `<-1>` → `[]` for READ
|
|
1161
1217
|
- Out-of-range → 416; malformed JSON → 400
|
|
1162
1218
|
|
|
1163
|
-
**Killer composition.** `<<READ(log://N/M/K)<P>::READ` picks the P-th match from a prior matcher result — matcher rx is `application/json`, structural `<L>` selects the P-th element. {§
|
|
1219
|
+
**Killer composition.** `<<READ(log://N/M/K)<P>::READ` picks the P-th match from a prior matcher result — matcher rx is `application/json`, structural `<L>` selects the P-th element. {§slice-semantics-compose-pattern}
|
|
1164
1220
|
|
|
1165
|
-
### §
|
|
1221
|
+
### §json-edit Structural EDIT on JSON
|
|
1166
1222
|
|
|
1167
|
-
When effective mimetype is `application/json`, EDIT dispatches through `applyJsonItemEdit`. {§
|
|
1223
|
+
When effective mimetype is `application/json`, EDIT dispatches through `applyJsonItemEdit`. {§json-edit-structural-json-edit} Body shape rule (parse-then-discriminate):
|
|
1168
1224
|
|
|
1169
1225
|
- Body parses as JSON array → items to splice
|
|
1170
1226
|
- Body parses as non-array JSON → single item to splice
|
|
1171
1227
|
- Empty body → delete the selection
|
|
1172
|
-
- Body fails JSON parse → 400 (path-extension declares intent; honor strictly) {§
|
|
1228
|
+
- Body fails JSON parse → 400 (path-extension declares intent; honor strictly) {§json-edit-json-parse-fail-400}
|
|
1173
1229
|
|
|
1174
1230
|
**Array source marker × body:**
|
|
1175
1231
|
|
|
@@ -1191,37 +1247,37 @@ When effective mimetype is `application/json`, EDIT dispatches through `applyJso
|
|
|
1191
1247
|
|
|
1192
1248
|
**Scalar source**: `<1>` replaces only. Grow markers (`<-1>`, `<0>`) and multi-item bodies → 400 (no implicit promotion scalar→array).
|
|
1193
1249
|
|
|
1194
|
-
### §
|
|
1250
|
+
### §ext-mimetype Path-extension declares mimetype
|
|
1195
1251
|
|
|
1196
|
-
`resolveEntryMimetype` (exported from `@plurnk/plurnk-schemes`): pathname extension → `Mimetypes.detect({ ext })` (with `text/plain` normalized to `text/markdown` per the text-primitive rule §
|
|
1252
|
+
`resolveEntryMimetype` (exported from `@plurnk/plurnk-schemes`): pathname extension → `Mimetypes.detect({ ext })` (with `text/plain` normalized to `text/markdown` per the text-primitive rule §markdown-primitive); falls back to scheme manifest channel default when no extension.
|
|
1197
1253
|
|
|
1198
1254
|
- `known://users.json` → `application/json` (extension wins)
|
|
1199
1255
|
- `known://notes.md` → `text/markdown` (extension; matches default)
|
|
1200
1256
|
- `known://config.yaml` → `application/yaml`
|
|
1201
1257
|
- `known://users` (no suffix) → `text/markdown` (Known manifest default)
|
|
1202
1258
|
|
|
1203
|
-
Same rule applies across Known, Unknown, Skill, Plurnk, File. Effective mimetype is stored in `entry_channels.mimetype` on write and drives `<L>` and matcher dispatch on read. {§
|
|
1259
|
+
Same rule applies across Known, Unknown, Skill, Plurnk, File. Effective mimetype is stored in `entry_channels.mimetype` on write and drives `<L>` and matcher dispatch on read. {§ext-mimetype-extension-mimetype}
|
|
1204
1260
|
|
|
1205
|
-
### §
|
|
1261
|
+
### §render-rule Render rule (mimetype-driven)
|
|
1206
1262
|
|
|
1207
1263
|
`packet-wire` log render branches on `isLineNavigableMimetype`:
|
|
1208
1264
|
|
|
1209
|
-
- **Line-navigable** (text/markdown, text/plain, csv, source code, yaml, toml) → `N:\t` line-number prefix per line {§
|
|
1210
|
-
- **Tree-navigable** (application/json, application/xml, text/html, +json/+xml suffixes) → verbatim body (no `N:\t` — outer line numbers would collide with structural navigation like jsonpath/xpath) {§
|
|
1265
|
+
- **Line-navigable** (text/markdown, text/plain, csv, source code, yaml, toml) → `N:\t` line-number prefix per line {§render-rule-line-navigable-prefix}
|
|
1266
|
+
- **Tree-navigable** (application/json, application/xml, text/html, +json/+xml suffixes) → verbatim body (no `N:\t` — outer line numbers would collide with structural navigation like jsonpath/xpath) {§render-rule-tree-navigable-verbatim}
|
|
1211
1267
|
|
|
1212
1268
|
The `N:\t` prefix is presentation/reference per plurnk.md ("not part of the source"); stripped before any matcher operation on the log entry.
|
|
1213
1269
|
|
|
1214
|
-
### §
|
|
1270
|
+
### §markdown-primitive Mimetype primitive: text/markdown
|
|
1215
1271
|
|
|
1216
1272
|
Auto-derived text mimetypes anywhere in plurnk-service normalize to `text/markdown`:
|
|
1217
1273
|
|
|
1218
|
-
- `<L>` slice on line-navigable source → `text/markdown` {§
|
|
1274
|
+
- `<L>` slice on line-navigable source → `text/markdown` {§markdown-primitive-text-markdown-normalize}
|
|
1219
1275
|
- File scheme extension fallback → `text/markdown`
|
|
1220
1276
|
- `Mimetypes.detect()` returning `text/plain` → normalized via `normalizeAutoTextMimetype`
|
|
1221
1277
|
|
|
1222
1278
|
`text/plain` survives only where a scheme explicitly declares it (exec stdout/stderr — subprocess byte-streams aren't markdown). The model never auto-encounters `text/plain` from defaults.
|
|
1223
1279
|
|
|
1224
|
-
### §
|
|
1280
|
+
### §op-invariants Op-level invariants and resolved ambiguities
|
|
1225
1281
|
|
|
1226
1282
|
Carried from the contract walk; durable.
|
|
1227
1283
|
|
|
@@ -1229,12 +1285,12 @@ Carried from the contract walk; durable.
|
|
|
1229
1285
|
- **Binary entries** → 415 across the board for READ/EDIT/OPEN/FOLD.
|
|
1230
1286
|
- **EDIT `<L>` on non-existent entry** → body becomes content; `<L>` is positional-only on existing content.
|
|
1231
1287
|
- **COPY `<L>`** → source range, symmetric with READ `<L>`.
|
|
1232
|
-
- **READ rx** prefixes each line with `N:\t` per §
|
|
1288
|
+
- **READ rx** prefixes each line with `N:\t` per §render-rule. `sliceLinesRaw` (used by COPY) returns the lines without prefix.
|
|
1233
1289
|
- **FIND body matcher** applies to entry content (all dialects), per-candidate via `Matcher.matchAgainstContent` → `Mimetypes.query` (status 200 = content hit → entry selected). Scope + tags select candidates in SQL; the path-glob is the (target).
|
|
1234
|
-
- **OPEN/FOLD** operate on the **log** (`log://`), not entries (§
|
|
1235
|
-
- **SEND[410]** deletes as a side-effect (not the model idiom; §
|
|
1290
|
+
- **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.
|
|
1291
|
+
- **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.
|
|
1236
1292
|
- **File scheme** reads disk content with mimetype detected via `Mimetypes.detect({ path })` (plumbed through `PlurnkSchemeContext.mimetypes`). Binary mimetypes → 415 on READ and EDIT.
|
|
1237
1293
|
|
|
1238
|
-
### §
|
|
1294
|
+
### §send-status-policy Directed-SEND status code policy
|
|
1239
1295
|
|
|
1240
1296
|
Status codes outside 410/499 on directed SEND return 501 from entry schemes. plurnk.md doesn't prescribe semantics for arbitrary HTTP status codes on directed sends; each scheme decides. 501 is the default; new interpretations land as concrete use cases arise.
|