@plurnk/plurnk-service 0.4.0 → 0.5.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.
Files changed (71) hide show
  1. package/README.md +13 -44
  2. package/SPEC.md +548 -445
  3. package/bin/plurnk-service.js +2 -1
  4. package/dist/core/ChannelWrite.d.ts +5 -0
  5. package/dist/core/ChannelWrite.d.ts.map +1 -1
  6. package/dist/core/ChannelWrite.js.map +1 -1
  7. package/dist/core/Engine.d.ts +8 -5
  8. package/dist/core/Engine.d.ts.map +1 -1
  9. package/dist/core/Engine.js +169 -73
  10. package/dist/core/Engine.js.map +1 -1
  11. package/dist/core/PluginLoader.js +6 -6
  12. package/dist/core/PluginLoader.js.map +1 -1
  13. package/dist/core/ProviderInstantiate.d.ts +4 -0
  14. package/dist/core/ProviderInstantiate.d.ts.map +1 -0
  15. package/dist/core/ProviderInstantiate.js +41 -0
  16. package/dist/core/ProviderInstantiate.js.map +1 -0
  17. package/dist/core/ProviderRegistry.d.ts.map +1 -1
  18. package/dist/core/ProviderRegistry.js +1 -1
  19. package/dist/core/ProviderRegistry.js.map +1 -1
  20. package/dist/core/SchemeRegistry.d.ts +1 -1
  21. package/dist/core/SchemeRegistry.d.ts.map +1 -1
  22. package/dist/core/SchemeRegistry.js +4 -20
  23. package/dist/core/SchemeRegistry.js.map +1 -1
  24. package/dist/core/line-marker.js +3 -3
  25. package/dist/core/line-marker.js.map +1 -1
  26. package/dist/core/matcher.d.ts +4 -6
  27. package/dist/core/matcher.d.ts.map +1 -1
  28. package/dist/core/matcher.js +61 -184
  29. package/dist/core/matcher.js.map +1 -1
  30. package/dist/core/mimetype-binary.js +1 -1
  31. package/dist/core/mimetype-binary.js.map +1 -1
  32. package/dist/core/packet-wire.d.ts.map +1 -1
  33. package/dist/core/packet-wire.js +130 -30
  34. package/dist/core/packet-wire.js.map +1 -1
  35. package/dist/core/scheme-types.d.ts +3 -26
  36. package/dist/core/scheme-types.d.ts.map +1 -1
  37. package/dist/core/scheme-types.js +5 -8
  38. package/dist/core/scheme-types.js.map +1 -1
  39. package/dist/index.d.ts +3 -2
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +14 -1
  42. package/dist/index.js.map +1 -1
  43. package/dist/schemes/Exec.d.ts.map +1 -1
  44. package/dist/schemes/Exec.js +12 -24
  45. package/dist/schemes/Exec.js.map +1 -1
  46. package/dist/schemes/File.d.ts +1 -0
  47. package/dist/schemes/File.d.ts.map +1 -1
  48. package/dist/schemes/File.js +11 -5
  49. package/dist/schemes/File.js.map +1 -1
  50. package/dist/schemes/Log.d.ts +1 -0
  51. package/dist/schemes/Log.d.ts.map +1 -1
  52. package/dist/schemes/Log.js +10 -4
  53. package/dist/schemes/Log.js.map +1 -1
  54. package/dist/schemes/_entry-ops.d.ts +1 -1
  55. package/dist/schemes/_entry-ops.d.ts.map +1 -1
  56. package/dist/schemes/_entry-ops.js +15 -20
  57. package/dist/schemes/_entry-ops.js.map +1 -1
  58. package/dist/server/Daemon.d.ts +11 -0
  59. package/dist/server/Daemon.d.ts.map +1 -1
  60. package/dist/server/Daemon.js +18 -0
  61. package/dist/server/Daemon.js.map +1 -1
  62. package/dist/server/MethodRegistry.d.ts +2 -2
  63. package/dist/server/MethodRegistry.d.ts.map +1 -1
  64. package/dist/server/methods/loop_resolve.js +2 -2
  65. package/dist/server/methods/loop_resolve.js.map +1 -1
  66. package/dist/server/methods/loop_run.d.ts.map +1 -1
  67. package/dist/server/methods/loop_run.js +2 -1
  68. package/dist/server/methods/loop_run.js.map +1 -1
  69. package/dist/server/methods/providers_list.js +1 -1
  70. package/dist/server/methods/providers_list.js.map +1 -1
  71. package/package.json +16 -12
package/SPEC.md CHANGED
@@ -1,10 +1,8 @@
1
1
  # plurnk-service — Specification
2
2
 
3
- Single source of truth for what plurnk-service IS — the contracts it exposes, the architecture it implements, the promises it makes to the rest of the constellation (`plurnk-grammar`, `plurnk-providers-*`, `plurnk-schemes-*`, `plurnk-mimetypes-*`, the user-facing `plurnk` CLI). `AGENTS.md` covers how we work on it; this file covers what we're working on.
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
- Section numbers are stable. Anchor-to-test wiring binds individual promises here to integration / live / demo tests, giving semi-deterministic specification-testing alignment.
6
-
7
- **Promise anchors.** Individual contract assertions in this document carry a trailing `{§<id>}` marker. Tests reference these anchors in their names (`test("[§<id>] description", ...)`). An alignment test (`test/intg/spec-anchors.test.ts`) fails if a test cites a nonexistent anchor (orphan — typo or stale reference) and surfaces gaps (anchored promises with no test) informationally. The anchors are **grounding against drift**, not a forcing function on development — write the spec, write the test, jot the link, move on. Coverage grows organically.
5
+ Section numbers are stable. Promise anchors `{§<id>}` mark individual assertions; tests cite them in their names (`test("[§<id>] …", …)`). `test/intg/spec-anchors.test.ts` fails on orphan citations and reports gaps. Anchors are drift-grounding, not a forcing function.
8
6
 
9
7
  ---
10
8
 
@@ -33,9 +31,9 @@ Canonical meanings. When a doc, comment, test name, or commit message uses one o
33
31
  | **entry** | The unit of canonical state. Identity: `(scope, scheme, pathname)`. Holds one or more `channels` of content plus `tags` and `attributes`. |
34
32
  | **channel** | A named content buffer on an entry. Examples: `body`, `preview`, `stdout`, `stderr`, `headers`, `symbols`. Each channel has `content`, `mimetype`, `tokens`, `state`. |
35
33
  | **scope** | `"agent"` or `"session"`. Determines who reads. Agent-scope entries visible to every run; session-scope entries to that session's runs. |
36
- | **scheme** | A URI prefix + handler. `known`, `unknown`, `file`, `https`, `exec`. The scheme handler interprets paths under its prefix and implements the op surface. See SCHEMES.md. |
37
- | **mimetype** | A channel's content type. Drives the render-time handler that produces `preview`/`symbols`. See MIMETYPES.md. |
38
- | **provider** | An LLM transport. Implements `generate({messages, signal})` against a wire protocol. See PROVIDERS.md. |
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 §3.6; author contract: [plurnk-schemes](https://github.com/plurnk/plurnk-schemes). |
35
+ | **mimetype** | A channel's content type. Drives the render-time handler that produces `preview`/`symbols`. Consumption surface §4.5; 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 §2; author contract: [plurnk-providers](https://github.com/plurnk/plurnk-providers). |
39
37
 
40
38
  ### §0.3 State / visibility / status
41
39
 
@@ -61,7 +59,7 @@ Three independent axes on entries and channels. Confusion across them is a recur
61
59
 
62
60
  | Term | Meaning |
63
61
  |---|---|
64
- | **verdict** | End-of-turn ruling from the verdict filter chain. Returns `{continue: boolean, status: number, reason: string}`. Decides whether the loop terminates or another turn fires. |
62
+ | **verdict** | End-of-turn ruling computed directly in `Engine.runLoop` from strike/cycle/sudden-death rail state. Decides whether the loop terminates or another turn fires. No filter chain — rails are inline. |
65
63
  | **strike** | A turn whose verdict counts toward `MAX_STRIKES`. Fires when `turnErrors > 0` or cycle detection trips. The streak counter resets on clean turn; reaches `MAX_STRIKES` → loop abandons at 499. |
66
64
  | **cycle** | A repeated turn fingerprint across consecutive turns. Detected silently; model never sees the trigger. Strike accumulates internally. |
67
65
  | **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. |
@@ -92,167 +90,79 @@ Three independent axes on entries and channels. Confusion across them is a recur
92
90
 
93
91
  ## §1 Architecture
94
92
 
95
- `plurnk-service` is an engine library plus an admin CLI. The engine orchestrates a model's interaction with a workspace through three plug points:
93
+ Engine library + admin CLI + daemon. Four plug points:
96
94
 
97
- - **Providers** (§2) — LLM transports. The engine asks a provider for the next assistant turn; the provider returns parsed plurnk ops.
98
- - **Schemes** (§3) — addressable resources. Every plurnk op targets a URI; the URI's scheme picks the handler. Schemes own their storage substrate (SQL entries for `known`/`unknown`/`skill`; filesystem for `file`; subprocess for `exec`; remote endpoints for future `http`/`ws`/`sse`/etc.).
99
- - **Mimetypes** (§4) — content interpretation. Schemes produce content with a declared mimetype; mimetype handlers validate, summarize, and render that content for the model's index view.
95
+ - **Providers** (§2) — LLM transports. Engine sends a turn's messages, receives raw content + usage; engine parses the content into `PlurnkStatement[]`.
96
+ - **Schemes** (§3) — addressable resources. Every op targets a URI; scheme handler interprets paths under its prefix and owns its storage substrate.
97
+ - **Mimetypes** (§4) — content interpretation. Render-time handlers consume channel content; framework owns the dispatch.
98
+ - **Executors** (§6.8 / §10) — EXEC runtime dispatch. Subprocess shells, search backends, future tool runtimes.
100
99
 
101
- The engine itself is small. It dispatches ops, persists state to SQLite, orchestrates cross-scheme operations (COPY/MOVE — see §6.4/§6.5), and writes the log. All substantive behavior lives in the three plug points.
100
+ The engine dispatches ops, persists state to SQLite, orchestrates cross-scheme COPY/MOVE (§6.4/§6.5), writes the log. Substantive behavior lives in the four plug points.
102
101
 
103
- The grammar `@plurnk/plurnk-grammar` is the parser and AST contract. Engine receives parsed `PlurnkStatement[]` from providers; engine does not re-parse. Schemes receive statement fragments via dispatch.
102
+ The grammar (`@plurnk/plurnk-grammar`) owns parser + AST contract. Schemes receive parsed statement fragments via dispatch.
104
103
 
105
- Client/server posture: this package is the server / core / agent runtime. The user-facing CLI lives in a separate `plurnk` repo and consumes plurnk-service through its public library API (`src/index.ts` + `PATHS` helper).
104
+ Server posture: this package is the runtime. User-facing CLI lives in `plurnk` and consumes the library API (`src/index.ts` + `PATHS`).
106
105
 
107
106
  ---
108
107
 
109
108
  ## §2 Provider Contract
110
109
 
111
- A provider transports model interactions. It exposes one required method (generate) and one mandatory token-counting method. Every `@plurnk/plurnk-providers-*` package implements this contract.
110
+ Author-facing contract: [plurnk-providers#1](https://github.com/plurnk/plurnk-providers/issues/1). Below: consumption surface + engine→provider guarantees.
112
111
 
113
- ### §2.1 Manifest
112
+ ### §2.1 Consumption surface
114
113
 
115
- Each provider package declares itself in its `package.json`:
114
+ Three entry points:
116
115
 
117
- ```json
118
- {
119
- "name": "@plurnk/plurnk-providers-<name>",
120
- "plurnk": { "kind": "provider", "name": "<name>" }
121
- }
122
- ```
116
+ - `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`.
117
+ - `provider.countTokens(text)` — synchronous, called at write-time (§14.2) and render-time. Non-negative integer.
118
+ - `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`.
123
119
 
124
- `name` is the lookup key. Collisions on `(kind, name)` are fail-hard at boot per §9.
120
+ Plus immutable identity: `provider.contextSize` (token total, or `null` "no budget info") and `provider.model`.
125
121
 
126
- ### §2.2 generate(args) required
122
+ ### §2.2 Engine provider guarantees
127
123
 
128
- ```ts
129
- type ChatMessage = { role: "system" | "user" | "assistant"; content: string };
130
-
131
- type ProviderResponse = {
132
- assistant: {
133
- tokens: number; // completion-token count for this turn
134
- content: string; // raw text from the model
135
- ops: PlurnkStatement[]; // parsed plurnk ops; see grammar AST
136
- reasoning: string | null; // thinking/reasoning payload if the model emitted one
137
- };
138
- assistantRaw: unknown; // provider's raw wire response for forensic capture
139
- };
124
+ - `messages` is a complete prompt (`system_definition`, `persona`, `index`, `log`, `prompt`, `telemetry`, `system_requirements` pre-assembled). Provider does not reorder.
125
+ - `signal` is wired to the run's AbortController.
126
+ - `generate` is single-call per turn. No parallel calls on the same instance.
127
+ - `assistantRaw` is opaque to the engine (forensics-only).
128
+ - `countTokens` is cheap by contract; engine calls frequently.
140
129
 
141
- generate({ messages, signal? }: { messages: ChatMessage[]; signal?: AbortSignal }): Promise<ProviderResponse>;
142
- ```
130
+ ### §2.3 Provider instantiation
143
131
 
144
- Promises:
145
- - The provider returns parsed `PlurnkStatement[]` in `assistant.ops`. The engine does NOT re-parse. Provider is responsible for invoking `@plurnk/plurnk-grammar`'s parser against `assistant.content`.
146
- - `assistant.tokens` is the completion token count for THIS turn — used for cost accounting and packet token tracking.
147
- - `assistantRaw` preserves the wire response verbatim for log forensics. Schema-free; provider's discretion.
148
- - If `signal` aborts, the provider MUST tear down its connection and reject the promise. AbortController lifecycle is the provider's responsibility.
132
+ Model alias parsing (`parseAliasesFromEnv` / `resolveActiveAlias`) lives in [`@plurnk/plurnk-providers`](https://github.com/plurnk/plurnk-providers). 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.
149
133
 
150
- ### §2.3 countTokens(text) — required
151
-
152
- ```ts
153
- countTokens(text: string): Promise<number>;
134
+ ```
135
+ PLURNK_MODEL_gemma=openai/macher.gguf
136
+ PLURNK_MODEL_opus=openrouter/anthropic/claude-opus-latest
137
+ PLURNK_MODEL=gemma
154
138
  ```
155
139
 
156
- Promises:
157
- - Returns the token count for `text` according to the active model's tokenizer.
158
- - The engine treats this as authoritative — there is no fallback estimator at the engine layer.
159
- - Provider implementations MAY cache by content hash; the engine ALSO caches by `(provider_id, content_hash)` in a `token_counts` table.
160
- - Mock provider returns a deterministic length-based estimate (e.g., `len/4`) sufficient for integration tests.
140
+ First path segment = provider plugin; rest = provider's own model id.
161
141
 
162
- ### §2.4 Cancellation
142
+ ### §2.4 In-tree Mock provider
163
143
 
164
- Every provider call accepts an optional `AbortSignal`. When aborted:
165
- - The provider's outbound connection MUST tear down.
166
- - The promise MUST reject (not resolve with partial content).
167
- - Any provider-internal streams MUST close cleanly.
144
+ `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.
168
145
 
169
146
  ---
170
147
 
171
148
  ## §3 Scheme Contract
172
149
 
173
- A scheme is an addressable resource handler. Every URI in plurnk has a scheme prefix (`known://`, `file://`, `https://`, etc.); the scheme handler interprets paths under that prefix.
174
-
175
- Every `@plurnk/plurnk-schemes-*` package implements this contract.
150
+ 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.
176
151
 
177
152
  ### §3.1 Manifest
178
153
 
179
- Each scheme package declares itself in its `package.json`:
154
+ Per author contract. Each scheme declares a `static manifest: SchemeManifest` with `name`, `channels`, `defaultChannel`, `category`, `scope`, `writableBy`, `volatile`, `modelVisible`, optional `flags`. Identity match enforced at plugin load: `manifest.name` must equal `package.json#plurnk.name`.
180
155
 
181
- ```json
182
- {
183
- "name": "@plurnk/plurnk-schemes-<name>",
184
- "plurnk": { "kind": "scheme", "name": "<name>" }
185
- }
186
- ```
156
+ ### §3.2 CRUD primitives
187
157
 
188
- The class declares its full manifest as a static field (per `SchemeManifest` in `src/core/scheme-types.ts`):
158
+ Per author contract (`readEntry` / `writeEntry` / `deleteEntry`). Engine drives cross-scheme COPY/MOVE/SEND[410] through these. Each method is one SQL transaction; engine owns the outer transaction for orchestrations.
189
159
 
190
- ```ts
191
- class Known {
192
- static manifest: SchemeManifest = {
193
- name: "known",
194
- channels: { body: "text/markdown", preview: "text/markdown" },
195
- defaultChannel: "body",
196
- category: "data",
197
- scope: "session",
198
- writableBy: ["model", "client"],
199
- volatile: false,
200
- modelVisible: true,
201
- flags: { /* optional flag affinity per LoopFlags */ },
202
- };
203
- // ...
204
- }
205
- ```
160
+ ### §3.3 Op methods
206
161
 
207
- Each entry in `manifest.channels` names a channel and pins its mimetype. The engine consults this manifest before writing channels. Schemes whose mimetypes are content-dynamic (file, eventually exec) declare an empty `channels: {}` and supply mimetype per-call instead; the engine accepts either path but never accepts an unset mimetype (see §5).
208
-
209
- `manifest.defaultChannel` is REQUIRED for any scheme that accepts EDIT or READ on fragment-less paths. It names which channel of the entry is targeted when the path has no `#fragment`. See §5.5 for the channel-selection semantic.
210
-
211
- Identity-match is enforced at plugin load: `manifest.name` must equal the `plurnk.name` in `package.json` (PluginLoader.assertIdentityMatch).
212
-
213
- ### §3.2 CRUD Primitives (uniform across schemes)
214
-
215
- The canonical surface a scheme exposes for engine orchestration. Every entry-bearing scheme MUST implement these three methods. The engine uses them to drive cross-scheme operations (§6.4 COPY, §6.5 MOVE) and the SEND[410] delete pattern (§6.8).
216
-
217
- ```ts
218
- type EntryData = {
219
- channels: Record<string, { content: string; mimetype: string }>;
220
- tags: string[];
221
- };
222
-
223
- readEntry(pathname: string, ctx: PlurnkSchemeContext): Promise<ReadEntryResult>;
224
- writeEntry(pathname: string, entry: EntryData, ctx: PlurnkSchemeContext): Promise<WriteEntryResult>;
225
- deleteEntry(pathname: string, ctx: PlurnkSchemeContext): Promise<DeleteEntryResult>;
226
- ```
227
-
228
- Where `PlurnkSchemeContext` is the per-call helper bundling `{ db, sessionId, runId, loopId, turnId, writer, signal }` (per `scheme-types.ts`). v0 ctx exposes `db` directly; the namespaced surface described in SCHEMES.md §4 (entries / channels / visibility / tags / subscriptions / proposals / crossScheme / notify) lands in v1 when third-party plugin schemes are an actual concern.
229
-
230
- Promises:
231
- - `readEntry` returns the full entry shape at `pathname` or `{ status: 404, entry: null }`.
232
- - `writeEntry` accepts a full entry shape and persists it. Returns `{ status: 201, created: true }` for new entries; `{ status: 200, created: false }` for replaces — UNLESS the scheme's policy forbids overwrites, in which case it returns `{ status: 409, created: false }`. See §6.4 for the COPY/MOVE conflict policy.
233
- - `deleteEntry` removes the entry. Returns 200 on success, 404 if absent.
234
- - Validation: `writeEntry` MUST verify channel mimetypes against the scheme's manifest (§3.1) and crash on mismatch. No defaults, no coercion (see §5 Channel Topology).
235
- - Atomicity: each method is a single SQL transaction. Engine orchestration (COPY = read + write) is responsible for its own outer transaction.
236
-
237
- ### §3.3 Op Methods (layered over CRUD)
238
-
239
- The DSL-facing methods the engine dispatches based on parsed `PlurnkStatement.op`. Signature: `op(statement, ctx)` where `statement` is the parsed AST node and `ctx` is the per-call `PlurnkSchemeContext`.
240
-
241
- ```ts
242
- edit(statement: EditStatement, ctx: PlurnkSchemeContext): Promise<EditResult>;
243
- read(statement: ReadStatement, ctx: PlurnkSchemeContext): Promise<ReadResult>;
244
- show(statement: ShowStatement | HideStatement, ctx): Promise<ShowHideResult>;
245
- hide(statement: ShowStatement | HideStatement, ctx): Promise<ShowHideResult>;
246
- find(statement: FindStatement, ctx: PlurnkSchemeContext): Promise<FindResult>;
247
- send(statement: SendStatement, ctx: PlurnkSchemeContext): Promise<SendResult>;
248
- ```
249
-
250
- COPY and MOVE are NOT scheme methods. They are engine orchestrations over CRUD primitives (§6.4, §6.5).
162
+ Per author contract (`edit`/`read`/`show`/`hide`/`find`/`send`/`exec?`). Engine dispatches by `PlurnkStatement.op`. COPY and MOVE are NOT scheme methods engine orchestrates over CRUD primitives.
251
163
 
252
164
  ### §3.4 Cross-scheme orchestration
253
165
 
254
- The engine — not any individual scheme — handles cross-scheme COPY and MOVE:
255
-
256
166
  ```
257
167
  copy(source_path, dest_path, signal_tags, ctx):
258
168
  src_scheme = scheme_for(source_path)
@@ -269,78 +179,130 @@ move(source_path, dest_path, signal_tags, ctx):
269
179
  src_scheme.deleteEntry(source_pathname, ctx)
270
180
  ```
271
181
 
272
- Same- and cross-scheme operations follow the identical orchestration. Same-scheme COPY (e.g., `known://a` → `known://b`) is NOT a special case — it is COPY where src_scheme and dst_scheme happen to be the same handler. The engine does not optimize this case; uniformity beats local efficiency at this layer.
273
-
274
- For v0 (known/unknown/skill, all sharing the `entries` table), CRUD primitives are thin wrappers over entry-table operations. For future schemes (file, exec, http), the same orchestrator drives the same primitives over different storage substrates.
182
+ Same- and cross-scheme operations share the orchestrator. Same-scheme COPY is not a special case.
275
183
 
276
184
  ### §3.5 SEND dispatch (status-code-as-verb)
277
185
 
278
- When the engine dispatches a `SEND` op with a non-null path (recipient-directed SEND), the target scheme's `send` method receives the statement. The scheme interprets the status code as an intent:
186
+ Directed SEND (non-null path) routes to scheme's `send`. Status = intent:
187
+
188
+ - `SEND[200](path)` — write body into resource (WS message, exec stdin).
189
+ - `SEND[410](path)` — delete the resource. {§3.5-410-deletes-resource}
190
+ - `SEND[410](path#fragment)` — delete the named channel only. {§3.5-410-fragment-channel-delete}
191
+ - `SEND[499](path)` — cancel active subscription (§7).
279
192
 
280
- - `SEND[200](path)` write the body into the resource at `path` (e.g., WebSocket message, exec stdin).
281
- - `SEND[410](path)` — delete the resource at `path`. Scheme calls its own `delete` primitive. {§3.5-410-deletes-resource}
282
- - `SEND[499](path)` — cancel any active subscription bound to `path` (§7).
193
+ Other status codes return 501 from entry-bearing schemes by default. {§3.5-entry-schemes-501-on-non-410}
283
194
 
284
- Other status codes are scheme-specific. The engine does not interpret SEND status codes for directed (non-broadcast) sends. Entry-bearing schemes return 501 for status codes they don't interpret. {§3.5-entry-schemes-501-on-non-410}
195
+ Null-path SEND is broadcast (§6.7), engine-handled.
285
196
 
286
- For SEND[410], channel-level deletion via `#fragment` is deferred — schemes return 400 on fragment-targeted 410. {§3.5-410-fragment-400}
197
+ ### §3.6 Consumption surface
287
198
 
288
- `SEND` with null path is broadcast (§6.7) — engine-handled, not scheme-handled.
199
+ Per-call context (`src/core/scheme-types.ts`):
200
+
201
+ ```ts
202
+ interface PlurnkSchemeContext {
203
+ readonly db: Db;
204
+ readonly sessionId: number;
205
+ readonly runId: number;
206
+ readonly loopId: number;
207
+ readonly turnId: number;
208
+ readonly writer: "model" | "client" | "system" | "plugin";
209
+ readonly signal: AbortSignal | undefined;
210
+ readonly streamEventNotify?: StreamEventNotify;
211
+ readonly wakeRunNotify?: WakeRunNotify;
212
+ readonly mimetypes?: Mimetypes;
213
+ }
214
+ ```
215
+
216
+ Notifier fields populated by the Daemon; absent in test fixtures.
217
+
218
+ Engine → scheme guarantees:
219
+
220
+ - `ctx` is fresh per call. No mutation across calls.
221
+ - `ctx.writer` reflects the actual writer at this dispatch.
222
+ - `manifest.writableBy` checked BEFORE invocation; engine returns 403 directly on exclusion.
223
+ - `ctx.signal` is wired to the run's AbortController.
224
+ - Scheme exceptions become the action-entry's outcome (status 500); summary surfaces in next turn's `packet.user.telemetry.errors[]` (§15.1).
225
+
226
+ **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`. Raw DB writes bypass tokenization — out of API scope.
289
227
 
290
228
  ---
291
229
 
292
230
  ## §4 Mimetype Contract
293
231
 
294
- A mimetype handler interprets channel content for validation, structural extraction, and preview rendering. Every `@plurnk/plurnk-mimetypes-*` package implements this contract.
232
+ Author-facing contract: [plurnk-mimetypes](https://github.com/plurnk/plurnk-mimetypes). Below: firing semantics + consumption surface.
295
233
 
296
- **Firing semantics.** Mimetype handlers are **render-time** consumers. The engine invokes them when assembling the turn packet at turn boundaries — they read the current channel content (whatever's there, possibly mid-stream), produce a structural view, and the result lands in the model's context. {§4-handlers-fire-render-time} Schemes do NOT call mimetype handlers at write time; schemes just append to channel content. Render is the only consumer of "interpreted" content. {§4-schemes-do-not-invoke-handlers} Memoization (handler result cached by content hash) is an implementation concern; the contract is render-time.
234
+ **Firing semantics.** Render-time consumers. Engine invokes during packet assembly; handlers read current channel content (possibly mid-stream), produce structural view, result lands in model's context. {§4-handlers-fire-render-time} Schemes do NOT call mimetype handlers at write time. {§4-schemes-do-not-invoke-handlers}
297
235
 
298
236
  ### §4.1 Manifest
299
237
 
300
- ```json
301
- {
302
- "name": "@plurnk/plurnk-mimetypes-<name>",
303
- "plurnk": { "kind": "mimetype", "name": "<mimetype-id>" }
304
- }
305
- ```
238
+ Per author contract. Manifest declares `kind: "mimetype"`; handler class declares `mimetype` (matches manifest name) and `glyph` (single emoji). Collisions fail-hard at boot per §9.
306
239
 
307
- `<mimetype-id>` follows IANA conventions (`text/plain`, `application/json`, `text/vnd.<vendor>`, etc.). Collisions fail-hard at boot per §9.
240
+ ### §4.2 Methods
308
241
 
309
- The handler class declares two required identifiers as instance properties:
242
+ Author contract owned by plurnk-mimetypes. plurnk-service consumes through two entry points:
310
243
 
311
- ```ts
312
- class TextMarkdown implements MimetypeHandler {
313
- readonly mimetype = "text/markdown";
314
- readonly glyph = "📝";
315
- // ... methods (§4.2)
316
- }
317
- ```
244
+ - `Mimetypes.process(input, options)` — packet-assembly; returns `{ mimetype, preview, ok }`. {§4.2-process-entry-point}
245
+ - `Mimetypes.query(input, expression)` body-matcher dispatch (§16.1); returns `QueryMatch[]`.
318
246
 
319
- `mimetype` is the IANA-shaped identifier must match the manifest `name`. `glyph` is a single emoji representing this mimetype, used by clients rendering log waterfalls, channel tiles, and any structural index. Every handler MUST declare both. The engine treats an absent `glyph` as a contract violation at registration time, parallel to absent `mimetype`.
247
+ Cross-cutting promises service relies on:
320
248
 
321
- ### §4.2 Methods
249
+ - Render-time only. Schemes do not invoke.
250
+ - Deterministic for a given (content, budget, mimetype) tuple.
251
+ - Validation errors propagate (fail-hard).
252
+ - Empty preview rather than throw when handler can't produce one.
253
+
254
+ ### §4.3 What handlers do NOT do
255
+
256
+ - **Tokenization** — provider-bound (§2).
257
+ - **Storage** — pure functions over content strings.
258
+ - **Streaming** — handlers see whatever content is current; subscription registry lives between schemes and §7.
259
+
260
+ ### §4.4 Bundled vs sibling handlers
261
+
262
+ No mimetype handlers ship in-tree. Framework + every handler are siblings.
263
+
264
+ ### §4.5 Consumption surface
265
+
266
+ plurnk-service is mimetype-illiterate. Engine hands channel content + mimetype label + budget to `Mimetypes.process({content, hint}, {budget})`; uses `result.preview` as the rendered output in `packet.system.index[].channels[name].content`.
267
+
268
+ **Required dependencies** (hard deps in `package.json`):
269
+
270
+ | Package | Mimetype | Why required |
271
+ |---|---|---|
272
+ | `@plurnk/plurnk-mimetypes-text-markdown` | `text/markdown` | LLM emission default; configured as `defaultMimetype` on the `Mimetypes` orchestrator. |
273
+ | `@plurnk/plurnk-mimetypes-text-plain` | `text/plain` | Canonical EXEC stdout/stderr channel mimetype. |
274
+ | `@plurnk/plurnk-mimetypes-application-json` | `application/json`, `application/jsonc` | Service emits json for `log_entries` rx/tx, telemetry, packet serialization. |
275
+
276
+ Everything else is opt-in; framework's `discover()` picks up installed packages automatically.
277
+
278
+ **Tokenize injection.** Daemon constructs `Mimetypes` with a `tokenize` lambda capturing the active provider's `countTokens`:
322
279
 
323
280
  ```ts
324
- validate(content: string): void;
325
- symbols(content: string): string;
326
- preview(content: string, budget: number): string;
281
+ new Mimetypes({
282
+ tokenize: async (text) => this.#provider?.countTokens(text) ?? Math.ceil(text.length / 4),
283
+ defaultMimetype: "text/markdown",
284
+ });
327
285
  ```
328
286
 
329
- Promises:
330
- - `validate` is a render-time guard. If it throws, that's a handler bug — engine crashes loudly per fail-hard. Schemes that want write-time validation handle that themselves; it's not plurnk-service's contract.
331
- - `symbols` returns a structural index of the content. For text/markdown, this is the heading outline. For application/json, the top-level key tree. For text/plain, an empty string (no structure).
332
- - `preview` returns a budget-bounded structural summary. `budget` is a character-count hint (not strictly enforced; handler does its best). For text/plain, head-truncation. For text/markdown, heading outline. For application/json, depth-limited key tree.
333
- - All three methods MUST be deterministic for caching to work — same content in, same output out.
287
+ Fallback heuristic is a boot-before-provider-resolved tripwire.
334
288
 
335
- ### §4.3 What mimetype handlers do NOT do
289
+ **Render pipeline.** `Engine.#buildIndex`:
336
290
 
337
- - **Tokenization.** Token counting is provider-bound (§2.3). Mimetype handlers describe structure; tokenizers count tokens.
338
- - **Storage.** Mimetype handlers are pure functions over content strings. They neither read nor write the DB.
339
- - **Streaming.** Mimetype handlers operate on whatever content is current when invoked. The streaming relationship (§7) is between schemes and the subscription registry; mimetype handlers don't see it.
291
+ ```ts
292
+ const result = await this.#mimetypes.process(
293
+ { content: row.content, hint: row.mimetype },
294
+ { budget: this.#previewBudget },
295
+ );
296
+ entry.channels[row.channel] = {
297
+ content: result.preview,
298
+ mimetype: row.mimetype,
299
+ tokens: row.tokens,
300
+ };
301
+ ```
340
302
 
341
- ### §4.4 Bundled vs sibling handlers
303
+ `hint` short-circuits detection. plurnk-mimetypes 0.6.0+ dropped the fitContent raw-content fallback — handlers without symbols return empty preview. Per-mimetype handlers (text-plain) return content under budget directly.
342
304
 
343
- No mimetype handlers ship in-tree. Every handler is a sibling `@plurnk/plurnk-mimetypes-*` package; the framework (`@plurnk/plurnk-mimetypes`) is also a sibling and is the surface plurnk-service consumes via `Mimetypes.process()`. Handlers register at boot via plugin discovery (§9). The in-tree set is empty by design the framework is responsible for the universal fallback, not us.
305
+ **Conformance.** Mimetype-specific behavioral tests live in each handler's own surface. plurnk-service intg covers integration: engine routes through `Mimetypes.process` with right hint/budget; `result.preview` lands in the right packet slot; tests use auto-discovery (production handler set); custom-handler test injects a stub `BaseHandler` via `loader + discovery`.
344
306
 
345
307
  ---
346
308
 
@@ -352,337 +314,313 @@ Every entry has named channels. **Channels are append-only content stores** keye
352
314
 
353
315
  EDIT writes one channel per call — the channel resolved from the path's fragment (or the scheme's `defaultChannel` when no fragment). {§5.1-edit-writes-only-body}
354
316
 
355
- There is no stored `preview` channel. Preview is a **render-time output**, computed by passing the channel's content through `Mimetypes.process()` at packet-build time. The result lands in `packet.system.index[].channels[name].content`. {§5.1-preview-is-handler-output} The mimetype framework owns the preview pipeline; plurnk-service consumes the returned preview and never inspects the framework's internals.
317
+ No stored `preview` channel. Preview is render-time output via `Mimetypes.process()` at packet-build; lands in `packet.system.index[].channels[name].content`. {§5.1-preview-is-handler-output}
356
318
 
357
- Schemes MAY declare multiple channels (exec will declare `stdout`/`stderr`/`stdin`; http will declare `body`/`header`; SSE may declare per-event-type channels). Each channel goes in the scheme's `channels` manifest (§3.1) and has its mimetype pinned there. Each channel is rendered independently at packet-build time.
319
+ 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.
358
320
 
359
321
  ### §5.2 Visibility lattice
360
322
 
361
- Visibility is per-`(run, entry, channel)` — a bit per cell in the `visibility` table. EDIT-creating-new sets `indexed=1` for every channel of the new entry in the current run. SHOW flips all channels of the target entry to `indexed=1`; HIDE flips all to `indexed=0`. {§5.2-fragmentless-show-hide-flips-all} Channel-specific SHOW/HIDE via fragment exists for the entry-bearing schemes; see §5.5.
323
+ Per-`(run, entry, channel)` bit in the `visibility` table. EDIT-creating-new sets `indexed=1` for every channel of the new entry in the current run. SHOW flips all channels of target entry to 1; HIDE flips to 0. {§5.2-fragmentless-show-hide-flips-all} Channel-specific SHOW/HIDE via fragment per §5.5.
362
324
 
363
- The engine's render-time index (`packet.system.index`) includes only `indexed=1` channels for the current run. {§5.2-render-filters-by-indexed} Each included channel is passed through its mimetype handler's `preview(content, budget)` per §4 / §5.1, with the result landing in the entry's `channels[name].content` field in the packet.
325
+ Render-time index includes only `indexed=1` channels for the current run. {§5.2-render-filters-by-indexed}
364
326
 
365
327
  ### §5.3 Mimetype is a (scheme, channel) property — never a default
366
328
 
367
- The mimetype of a channel is declared by the scheme's manifest (§3.1) or — for dynamic schemes — supplied per-call. If the engine attempts to write a channel without a declared mimetype, it throws. There is no default mimetype anywhere in the system. This is a reinforcement of the no-fallbacks rule at the channel layer.
329
+ Mimetype is declared by scheme manifest (§3.1) or supplied per-call for dynamic schemes. Writing a channel without a declared mimetype throws. No default mimetype anywhere.
368
330
 
369
- Implications:
370
- - Cross-mimetype COPY/MOVE crashes (`415 Unsupported Media Type`) — never coerces. See §6.4.
331
+ - Cross-mimetype COPY/MOVE → 415, never coerces (§6.4).
371
332
 
372
333
  ### §5.5 Channel selection in the DSL
373
334
 
374
- The DSL targets a specific channel of an entry via the URL **fragment** — the `#name` segment of the path. Per the grammar's `UrlPath` shape (0.3.0+), every parsed path has a nullable `fragment` field; this section defines its semantic for the engine.
335
+ DSL targets a specific channel via the URL fragment (`#name`).
375
336
 
376
337
  Rules:
377
338
 
378
- 1. **Fragment-less paths target the scheme's `defaultChannel`.** For `known` / `unknown` / `skill` that's `body`. The path `known://philosophy/meaning` writes/reads the body channel of that entry; equivalent to `known://philosophy/meaning#body` made explicit. {§5.5-fragmentless-targets-default-channel}
379
- 2. **Paths with a fragment target the named channel.** `known://philosophy/meaning#preview` targets the preview channel. {§5.5-fragment-selects-named-channel}
380
- 3. **Unknown channel name → 400 (bad request).** The channel name must appear in the scheme's `channels` manifest (§3.1). Engine never writes to an undeclared channel. {§5.5-unknown-channel-400}
381
- 4. **Schemes without `defaultChannel` reject fragment-less EDIT/READ.** Streaming schemes like `sse://feed/x` may require an explicit fragment (`#data`, `#error`, etc.) and have no default.
382
- 5. **Non-default channel EDIT requires entry to exist.** Writing to a fragment-targeted channel of a nonexistent entry returns 404. Default-channel EDIT creates the entry if absent (existing semantics). {§5.5-fragment-on-nonexistent-404}
383
- 6. **Fragment-targeted SHOW/HIDE flips only the named channel.** Fragment-less SHOW/HIDE flips all channels per §5.2. {§5.5-fragment-targeted-show-hide}
339
+ 1. Fragment-less paths target the scheme's `defaultChannel`. {§5.5-fragmentless-targets-default-channel}
340
+ 2. Paths with a fragment target the named channel. {§5.5-fragment-selects-named-channel}
341
+ 3. Unknown channel name → 400. {§5.5-unknown-channel-400}
342
+ 4. Schemes without `defaultChannel` reject fragment-less EDIT/READ.
343
+ 5. Non-default channel EDIT requires entry to exist (404 if absent); default-channel EDIT creates. {§5.5-fragment-on-nonexistent-404}
344
+ 6. Fragment-targeted SHOW/HIDE flips only the named channel; fragment-less flips all per §5.2. {§5.5-fragment-targeted-show-hide}
384
345
 
385
- Examples:
386
-
387
- | URI | Scheme's interpretation |
346
+ | URI | Channel |
388
347
  |---|---|
389
- | `known://france/capital` | body channel of the entry (text/markdown) |
390
- | `known://france/capital#preview` | preview channel (text/markdown structural summary) |
391
- | `exec://run/abc#stdout` | stdout channel of the exec invocation |
392
- | `exec://run/abc#stderr` | stderr channel |
393
- | `sse://feed/y#data` | named channel for the SSE "data" event type (§7) |
394
- | `log://N/T/A` | log entry at coordinates; no channel concept (log entries are atomic rows) |
395
-
396
- Implications for operations:
348
+ | `known://france/capital` | body (default) |
349
+ | `known://france/capital#preview` | preview |
350
+ | `exec://run/abc#stdout` | stdout |
351
+ | `exec://run/abc#stderr` | stderr |
352
+ | `sse://feed/y#data` | data |
353
+ | `log://N/T/A` | (no channel concept; atomic log row) |
397
354
 
398
- - **EDIT** writes the body to the resolved channel. If the channel doesn't exist in the manifest → 400. If the scheme doesn't support EDIT to that channel (e.g., exec stdout is read-only) → 405.
399
- - **READ** returns content + mimetype from the resolved channel.
400
- - **SHOW / HIDE** flip visibility for the resolved channel only — channel-specific visibility is achievable via fragments. Fragment-less SHOW/HIDE flips ALL channels of the entry per §5.2 (existing behavior).
401
- - **COPY / MOVE** with a fragment is a per-channel operation; deferred design pass needed before specifying (out of scope for v0).
355
+ Op implications:
402
356
 
403
- The clean-shape RPC params (§13.5) carry the fragment naturally inside the `target` string: `{ target: "known://x#stderr" }` works as expected. No new RPC parameter needed; the URL surface handles it.
357
+ - EDIT to undeclared channel 400; read-only channel 405.
358
+ - COPY/MOVE with fragment is per-channel; design deferred until needed.
404
359
 
405
- **Wire-rendering inverse: default channel is path-only.** When the engine projects entries to the model's view, the heredoc fence omits `#channel` whenever the channel name matches the scheme's `defaultChannel`. Single-channel entries (the channel IS the default) render path-only; multi-channel entries render the default channel path-only and only non-default channels carry `#name` in the fence. {§5.5-wire-omits-suffix-on-default-channel} Examples:
360
+ RPC params carry fragments inline via the `target` string (`{ target: "known://x#stderr" }`).
406
361
 
407
- - `<<notes.md:...:notes.md` file scheme renders bare (see §10 file note)
408
- - `<<exec://run:...:exec://run` — exec's default (`stdout`)
409
- - `<<exec://run#stderr:...:exec://run#stderr` — explicit non-default
410
- - `<<log://1/1/0:...:log://1/1/0` — log responses (single-payload; no channel concept)
362
+ **Wire rendering: default channel is path-only.** Heredoc fence omits `#channel` when channel matches `defaultChannel`. Single-channel entries render path-only; multi-channel entries render the default path-only and only non-default carries `#name`. {§5.5-wire-omits-suffix-on-default-channel}
411
363
 
412
- The absence of `#channel` IS the addressing of the default. This matches what models naturally emit and removes `#body` / `#rx` clutter from every packet.
364
+ ```
365
+ <<notes.md:...:notes.md — file scheme (bare)
366
+ <<exec://run:...:exec://run — exec default (stdout)
367
+ <<exec://run#stderr:...:exec://run#stderr — non-default
368
+ <<log://1/1/0:...:log://1/1/0 — atomic log row
369
+ ```
413
370
 
414
371
  ### §5.6 Channel state — metadata, not gating
415
372
 
416
- Per the grammar's `ChannelContent` schema, each channel has a `state ∈ {static, active, closed, errored}`. This is **information about the channel's current writing status**, not a gate on engine behavior. {§5.6-state-is-metadata}
373
+ Each channel has `state ∈ {static, active, closed, errored}`. Metadata only, not an engine gate. {§5.6-state-is-metadata}
417
374
 
418
- - `static` — content is final; not actively being written. Entry-bearing schemes (known/unknown/skill) stay here always after EDIT.
419
- - `active` — a scheme is currently writing to this channel (chunks arriving). Streaming schemes use this during their connection's accumulating phase.
420
- - `closed` — a scheme finished writing cleanly. The channel content is final but came from a stream that has now ended.
421
- - `errored` — a scheme was writing but ended in error. Content may be partial; subsequent reads still return what was accumulated.
375
+ - `static` — content final, not being written. Entry schemes after EDIT.
376
+ - `active` — scheme is writing (chunks arriving). Streaming schemes during accumulation.
377
+ - `closed` — stream ended cleanly. Content final.
378
+ - `errored` — stream ended in error. Content may be partial; reads return what accumulated.
422
379
 
423
- Schemes own state transitions. They UPDATE `entry_channels.state` as their connection lifecycle progresses. {§5.6-schemes-own-state-transitions} The engine does NOT branch on state during rendering — it reads `content` and `state`, includes both in the rendered tile, lets the model and clients see the truth. {§5.6-engine-does-not-branch-on-state}
380
+ Schemes own transitions; UPDATE `entry_channels.state` as connection lifecycle progresses. {§5.6-schemes-own-state-transitions} Engine does NOT branch on state during rendering — reads `content` and `state`, includes both. {§5.6-engine-does-not-branch-on-state}
424
381
 
425
- The model uses state for context: "this channel says `active` — content may grow before my next turn." Clients use state for UI: render an active channel with a spinner, errored with red, etc.
382
+ Model uses state to anticipate growth between turns. Clients use state for UI (spinner / red border / etc.).
426
383
 
427
384
  ---
428
385
 
429
386
  ## §6 Op Surface
430
387
 
431
- Per-op semantics for `FIND | READ | EDIT | COPY | MOVE | SHOW | HIDE | SEND | EXEC`. Each subsection includes the AST shape (per `@plurnk/plurnk-grammar`'s `PlurnkStatement` schema), the engine's dispatch behavior, and the promises returned to callers.
388
+ Per-op semantics. AST shapes from `@plurnk/plurnk-grammar`'s `PlurnkStatement`. Engine dispatches by `op`; scheme implements per author contract (§3).
432
389
 
433
390
  ### §6.1 EDIT
434
391
 
435
- AST: `{ op: "EDIT", target: ParsedPath, body: string | null, signal: tags | null, lineMarker?: LineMarker }`.
392
+ AST: `{ op: "EDIT", target, body: string | null, signal: tags | null, lineMarker? }`.
436
393
 
437
- Engine dispatches to `scheme.edit(statement, ctx)`. Scheme:
438
- - Resolves the target channel from the path's fragment (§5.5). Fragment absent → scheme's `manifest.defaultChannel`. Unknown channel → 400. Channel manifest-undeclared → engine crashes per §5.3.
439
- - Writes the body to the resolved channel.
440
- - Indexes the written channel in the current run (visibility = 1).
394
+ - Resolves target channel from fragment (§5.5); unknown channel → 400; undeclared in manifest → engine crash (§5.3).
395
+ - Writes body; `body: null` clears.
396
+ - Sets `indexed=1` for written channel in current run.
441
397
  - Returns `{ status: 201, entryId }` for new entries; `{ status: 200, entryId }` for updates.
442
- - `body: null` clears the content (writes empty string).
443
- - Tags from `signal[]` are applied via `entry_tags` (additive on update for the v0 entry schemes; final policy may vary by scheme).
398
+ - Tags from `signal[]` apply additively via `entry_tags` (scheme may vary).
444
399
 
445
400
  ### §6.2 READ
446
401
 
447
- AST: `{ op: "READ", target: ParsedPath, body: MatcherBody | null, signal: tags | null, lineMarker?: LineMarker }`.
402
+ AST: `{ op: "READ", target, body: MatcherBody | null, signal: tags | null, lineMarker? }`.
448
403
 
449
- Engine dispatches to `scheme.read(statement, ctx)`. Scheme:
450
- - Returns the body channel content + mimetype for `path`, or `{ status: 404 }`.
451
- - `lineMarker` selects a line range (e.g., `<10-20>` for lines 10-20).
452
- - `body` (matcher) is for streaming-scheme deep reads (§7) — not v0 surface for entry schemes.
404
+ - Returns channel content + mimetype, or 404.
405
+ - `lineMarker` slices per §16.3.
406
+ - `body` matcher dispatches through `Mimetypes.query` per §16.1 (all four dialects wired).
453
407
 
454
408
  ### §6.3 SHOW / HIDE
455
409
 
456
- AST: `{ op: "SHOW"|"HIDE", target: ParsedPath, body: MatcherBody | null, signal: tags | null, lineMarker?: LineMarker }`.
410
+ AST: `{ op: "SHOW"|"HIDE", target, body: MatcherBody | null, signal: tags | null, lineMarker? }`.
457
411
 
458
- Engine dispatches to `scheme.show(statement, ctx)` / `scheme.hide(statement, ctx)`. Scheme:
459
- - Flips `visibility.indexed` for every channel of the targeted entry to 1 (SHOW) or 0 (HIDE).
460
- - Returns 200 on transition, 304 on no-op, 404 if entry not found.
461
- - No channel-specific selectors in v0; SHOW/HIDE always affects all channels of the entry.
412
+ - Flips `visibility.indexed` for the targeted channel(s) per §5.5 rules.
413
+ - Returns 200 on transition, 304 on no-op, 404 if entry absent.
462
414
 
463
415
  ### §6.4 COPY (engine-orchestrated)
464
416
 
465
- AST: `{ op: "COPY", target: ParsedPath (source), body: ParsedPath (destination), signal: tags | null, lineMarker?: LineMarker }`.
417
+ AST: `{ op: "COPY", target (source), body (destination), signal: tags | null, lineMarker? }`.
466
418
 
467
419
  Engine orchestrates over CRUD primitives (§3.2, §3.4):
468
420
 
469
- 1. `src_scheme.readEntry(source_pathname, ctx)` → entry or 404. Missing source returns 404. {§6.4-missing-source-404}
470
- 2. `dst_scheme.readEntry(dest_pathname, ctx)` to check conflict — if exists, return **409 Conflict** (no overwrite). Per fail-hard discipline. {§6.4-conflict-409}
471
- 3. Mimetype compatibility check — channels of `entry` MUST have mimetypes accepted by `dst_scheme`'s `manifest.channels`. Mismatch returns **415 Unsupported Media Type**.
472
- 4. Tag resolution: if `signal` is non-null, dest tags = signal_tags (REPLACE). {§6.4-signal-replaces-source-tags} If signal is null/empty, dest tags = source tags (CARRY). {§6.4-no-signal-carries-source-tags}
473
- 5. `dst_scheme.writeEntry(dest_pathname, { channels: entry.channels, tags }, ctx)`.
474
- 6. Dest visibility indexed=1 in current run (parity with EDIT-creating-new).
475
-
476
- Returns `{ status: 201 }` on success.
421
+ 1. `src_scheme.readEntry` → 404 if missing. {§6.4-missing-source-404}
422
+ 2. `dst_scheme.readEntry` if exists, 409 (no overwrite). {§6.4-conflict-409}
423
+ 3. Mimetype compat — channels' mimetypes must be accepted by `dst_scheme.manifest.channels`. Mismatch 415.
424
+ 4. Tags: `signal` non-null replaces source tags {§6.4-signal-replaces-source-tags}; null/empty carries source tags {§6.4-no-signal-carries-source-tags}.
425
+ 5. `dst_scheme.writeEntry({channels, tags})`.
426
+ 6. Dest `indexed=1` in current run.
477
427
 
478
- Same- and cross-scheme COPY both go through this orchestrator. {§6.4-cross-scheme-copy}
428
+ Returns 201 on success. Same- and cross-scheme COPY share the orchestrator. {§6.4-cross-scheme-copy}
479
429
 
480
430
  ### §6.5 MOVE (engine-orchestrated)
481
431
 
482
- AST: `{ op: "MOVE", target: ParsedPath (source), body: ParsedPath | null (destination), signal: tags | null, lineMarker?: LineMarker }`.
483
-
484
- Two modes:
485
-
486
- **Relocation** (`body` non-null): engine runs §6.4 COPY then `src_scheme.deleteEntry(source_pathname, ctx)`. One transaction. Returns 201 on success. Source is removed. {§6.5-relocation-deletes-source} Cross-scheme relocation works the same as same-scheme. {§6.5-cross-scheme-move}
432
+ AST: `{ op: "MOVE", target (source), body: dest | null, signal: tags | null, lineMarker? }`.
487
433
 
488
- **Deletion** (`body` is null): engine runs `src_scheme.deleteEntry(source_pathname, ctx)` directly. The null-body MOVE expresses "relocate to nowhere" = delete. {§6.5-null-body-deletes} Returns 200 on success, 404 if source absent. {§6.5-missing-source-404}
434
+ - **Relocation** (`body` non-null): COPY (§6.4) + `src_scheme.deleteEntry` in one transaction. 201 on success. {§6.5-relocation-deletes-source} Cross-scheme same as same-scheme. {§6.5-cross-scheme-move}
435
+ - **Deletion** (`body: null`): `src_scheme.deleteEntry` directly. {§6.5-null-body-deletes} 200 on success, 404 if absent. {§6.5-missing-source-404}
489
436
 
490
- Log history is preserved through MOVE because `log_entries`' URI-bit columns (`scheme`, `pathname`, etc.) store the path tuple as text, not FK to `entries.id`.
437
+ Log history preserved `log_entries` stores path tuple as text, not FK to `entries.id`.
491
438
 
492
439
  ### §6.6 FIND
493
440
 
494
- AST: `{ op: "FIND", target: ParsedPath (scope), body: MatcherBody | null (predicate), signal: tags | null (tag filter), lineMarker?: LineMarker }`.
441
+ AST: `{ op: "FIND", target (scope), body: MatcherBody | null (predicate), signal: tags | null, lineMarker? }`.
495
442
 
496
- Engine dispatches to `scheme.find(statement, ctx)`. Scheme:
497
- - Filters entries within the path's scope (scheme + pathname prefix). {§6.6-scope-prefix-filter}
498
- - Applies `body` matcher if present. v0 supports `glob` dialect over pathname. {§6.6-glob-filter-on-pathname} Other dialects (regex over content, xpath, jsonpath) return **501 Not Implemented** until the relevant infrastructure exists. {§6.6-non-glob-dialects-501}
499
- - Applies `signal` as a tag filter: only entries with ALL listed tags pass. {§6.6-tag-filter-and-semantics}
500
- - Results are session- and scheme-scoped no cross-session or cross-scheme leakage. {§6.6-scoped-isolation}
501
- - Returns `{ status: 200, results: string }` where `results` is `text/plain` with newline-separated matching paths.
443
+ - Filters entries within scope (scheme + pathname prefix). {§6.6-scope-prefix-filter}
444
+ - `body` matcher operates on pathname (glob). {§6.6-glob-filter-on-pathname} Content matchers (xpath/jsonpath/regex over body) live on READ.
445
+ - `signal` is a tag filter; entries match if they have ALL listed tags. {§6.6-tag-filter-and-semantics}
446
+ - Session + scheme scoped no cross-session/cross-scheme leakage. {§6.6-scoped-isolation}
447
+ - Returns `{ status: 200, results: string }` (newline-separated matching paths, `text/plain`).
502
448
 
503
449
  ### §6.7 SEND
504
450
 
505
451
  AST: `{ op: "SEND", target: ParsedPath | null, body: SendBody | null, signal: number | null }`.
506
452
 
507
- Two modes:
508
-
509
- **Broadcast** (`path` is null): terminal status (200/499) updates `loop.status` and ends the loop. Other status codes return `{ status }` without state change. The model's universal "talk to the orchestrator" surface.
510
-
511
- **Directed** (`path` is non-null): engine dispatches `scheme.send(statement, ctx)`. Scheme interprets `signal` as an intent per §3.5:
512
- - 200 → write `body` into the resource (stream-write, exec stdin, etc.)
513
- - 410 → delete the resource at `path` (scheme calls its own `delete` primitive)
514
- - 499 → cancel active subscription (§7)
515
- - Other → scheme-specific
453
+ - **Broadcast** (path null): terminal status (200/499) updates `loop.status` and ends loop. Other codes return `{status}` with no state change.
454
+ - **Directed** (path non-null): routes to `scheme.send` per §3.5.
516
455
 
517
456
  ### §6.8 EXEC
518
457
 
519
- AST: `{ op: "EXEC", target: ParsedPath (cwd), body: string | null (command), signal: string | null (runtime tag) }`.
458
+ AST: `{ op: "EXEC", target (cwd), body: string | null (command), signal: string | null (runtime tag) }`.
459
+
460
+ Engine routes unconditionally to `exec` scheme (path slot is `cwd`, not a URI). Scheme spawns subprocess, writes stdout/stderr to channels of an `exec://r-<hash>` entry, returns `102 Processing` log row immediately. Channel state transitions (`active` → `closed`/`errored`) drive the model's view at subsequent turn boundaries (§5.6). Runtime slot (`signal`) selects executor (`sh` default, `node`, `python`); unknown runtime → 501.
520
461
 
521
- Deferred. The `exec` scheme is in §10's bundled set but lacks a working handler; calls return 501. Sandboxing design and process-lifecycle semantics are the substance to figure out, drawing on rummy's exec plugin as prior art.
462
+ `SEND[499](exec://r-<hash>)` cancels in-flight subprocess via subscription registry's stored AbortController (§7.7).
522
463
 
523
464
  ---
524
465
 
525
466
  ## §7 Stream Model
526
467
 
527
- The model can't poll and can't wait for streams to complete. It must stay passively informed of ongoing streams between turns. Plurnk-service treats streams as **the same as static content from the engine's perspective** — content arrives over time, channels grow, mimetype handlers render whatever's there at turn boundaries. There is no engine-level "transaction" abstraction; schemes own their connection lifecycle entirely. {§7-no-engine-transaction-abstraction}
468
+ 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. {§7-no-engine-transaction-abstraction}
528
469
 
529
470
  ### §7.1 Subscriptions
530
471
 
531
- READ on a streaming scheme is a subscription, not a one-shot. The scheme handler opens the connection (SSE, WS, exec subprocess, etc.), returns a `102 Processing` log row immediately, and stays alive. The engine records `(sessionId, entryId) → schemeName + scheme-handle` in a **subscription registry** so cancellation (SEND[499]) can be routed to the scheme owning the active connection. {§7.1-subscription-registry-routes-cancellation}
472
+ 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. {§7.1-subscription-registry-routes-cancellation}
532
473
 
533
- The subscription registry is plurnk-service runtime state (its own SQLite table). Not in the grammar's schema. **It exists ONLY for cancellation routing** — not for lifecycle tracking, not for state coordination. Channel state (§5.6) and log entries (§7.3) carry the lifecycle information.
474
+ Subscription registry is plurnk-service runtime state (its own SQLite table). Exists ONLY for cancellation routing. Channel state (§5.6) + log entries (§7.3) carry lifecycle.
534
475
 
535
476
  ### §7.2 Chunk accumulation
536
477
 
537
- SSE event types, WS message types, exec stdout/stderr each maps to a named channel on the entry. Channel record (per grammar's `ChannelContent`): `content`, `mimetype`, `tokens`. Whether a connection is currently feeding the channel is tracked in the subscription registry, not on the channel itself.
478
+ 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.
538
479
 
539
480
  ### §7.3 No per-chunk log rows
540
481
 
541
- Channels are the single source of truth for chunk content. Log captures **lifecycle events** only: open (`102`), graceful close (`200`), explicit cancel (`499`), errors (`5xx`), scheme-significant transitions. {§7.3-log-captures-lifecycle-only}
482
+ 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. {§7.3-log-captures-lifecycle-only}
542
483
 
543
- The model sees lifecycle events in `packet.system.log[]` per turn (§7.4 / §7.8). This is how the model learns "the stream opened" / "the stream closed cleanly" / "an error happened" — through log rows, not through engine-level state inspection.
484
+ Model sees lifecycle events in `packet.system.log[]` per turn.
544
485
 
545
486
  ### §7.4 Index tile rendering
546
487
 
547
- Per-channel previews use `SchemeRegistration.channel_orientations` (grammar 0.3.0). The scheme declares each channel as `"head"` (front-anchored — render from beginning) or `"tail"` (append-temporal — render most recent). Channels not listed default to `"head"`. The renderer (plurnk-service code) decides token budget via `PLURNK_ENTRY_SIZE_DEFAULT_TOKENS` (§12).
488
+ Per-channel previews use `SchemeRegistration.channel_orientations` (grammar): `"head"` (front-anchored) or `"tail"` (most recent); default `"head"`. Renderer passes `PLURNK_ENTRY_SIZE_DEFAULT_TOKENS` budget (§12) to `Mimetypes.process()`; handler fits to budget per §14.2.
548
489
 
549
490
  ### §7.5 Deep slices on demand
550
491
 
551
- `<<READ(sse://feed/x#data)<N-M>:…:READ` pulls a specific slice of the channel's content into a log row when the model wants depth beyond the preview.
492
+ `<<READ(sse://feed/x#data)<N-M>:…:READ` pulls a slice into a log row when the model wants depth beyond the preview.
552
493
 
553
494
  ### §7.6 HIDE is pure archive
554
495
 
555
- `<<HIDE(sse://feed/x)::HIDE` demotes the entry from the index. The connection persists silently; channels keep updating in the background; the model can `SHOW` it back later. HIDE never tears down a subscription.
496
+ `<<HIDE(sse://feed/x)::HIDE` demotes from index. Connection persists; channels keep updating. HIDE never tears down a subscription.
556
497
 
557
498
  ### §7.7 SEND for stream control
558
499
 
559
- - **Cancel:** `<<SEND[499](sse://feed/x)::SEND` — scheme interprets 499 as "tear down." Subscription registry transitions to closed; the AbortController fires; channel stops accumulating.
560
- - **Write:** `<<SEND[200](wss://feed/x):message body:SEND` — pipes body into an active WS connection, exec stdin, etc.
500
+ - **Cancel:** `<<SEND[499](sse://feed/x)::SEND` — scheme tears down via AbortController.
501
+ - **Write:** `<<SEND[200](wss://feed/x):body:SEND` — pipes body into active connection (WS, exec stdin, etc.).
561
502
 
562
503
  ### §7.8 Engine constraints
563
504
 
564
- The engine imposes ONE constraint: **100 MiB char-length cap per channel content body.** Enforced at the storage layer via `CHECK (length(content) <= 104857600)` on `entry_channels.content` (migrations/005_entries.sql). Writes exceeding this fail at the SQL boundary with a SQLITE_CONSTRAINT; the action-entry captures the rejection at status 500.
505
+ 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.
565
506
 
566
- All other limits are **extrinsic**owned by providers (request size, model context, fetch timeouts), schemes (per-call validation, scheme-specific size policies), and mimetypes (render budgets per `preview(content, budget)`). The engine does not throttle, batch, rate-limit, or cap anything else. {§7.8-engine-one-cap}
567
-
568
- This reflects an intentional v0 stance: pre-MVP, every operator-configurable cap is a barrier between the user and the system actually running. The user-facing CLI/TUI fiddles best when nothing fires unexpectedly. When real production pressure arrives, additional caps land as opt-in operator config — not as defaults.
507
+ 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. {§7.8-engine-one-cap}
569
508
 
570
509
  ### §7.9 Live updates for clients (between turns)
571
510
 
572
- While the model only sees turn-boundary rendering (coherent context per turn), connected RPC clients (TUI, neovim, web) want to see channel content grow in real time. The daemon emits `stream/event` notifications (§13.6) when channel content changes. Clients use these to render live waterfalls and refresh entry views without polling. {§7.9-stream-event-fires-on-chunk}
511
+ Daemon emits `stream/event` notifications (§13.6) when channel content changes; clients use them for live waterfalls without polling. {§7.9-stream-event-fires-on-chunk}
573
512
 
574
- The model is NOT a stream/event consumer. The model is a turn-based consumer; whatever's in the channel at the next turn boundary is what gets rendered into its packet.
513
+ The model is NOT a stream/event consumer turn-based only; sees whatever's in the channel at the next turn boundary.
575
514
 
576
515
  ---
577
516
 
578
517
  ## §8 Storage Model
579
518
 
580
- SQLite (`node:sqlite`) with WAL mode and STRICT tables. Hand-written DDL, validated against grammar schemas via a CI test.
519
+ SQLite (`node:sqlite`) with WAL mode and STRICT tables. Hand-written DDL; CI-aligned against grammar schemas.
581
520
 
582
- ### §8.1 DDL strategy — hand-written, STRICT, indexed
521
+ ### §8.1 DDL strategy
583
522
 
584
- No generator. The value-add of JSON-Schema-to-DDL generation tops out at ~30% (table skeletons with NOT NULL columns and naive types); the remaining 70% — FK semantics, indexes, triggers, generated columns, FTS5, `WITHOUT ROWID`, defaults, decomposition decisions is hand-written regardless. The generator's only real win is drift detection, which a CI alignment test handles at a fraction of the complexity.
523
+ 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.
585
524
 
586
- - All `migrations/*.sql` files are authored deliberately. SQLite-optimal: STRICT tables (3.37+), `INTEGER PRIMARY KEY` aliasing where appropriate, explicit `NOT NULL`, indexed query paths, FKs with deliberate `ON DELETE`/`ON UPDATE` semantics, `WITHOUT ROWID` where access pattern warrants, generated columns for materialized derivations, FTS5 virtual tables where text search is load-bearing.
587
- - One migration file per cohesive concern. Numbered for deterministic apply order.
588
- - `migrate` CLI subcommand applies in numeric order. Idempotent runner skips migrations whose marker is in the `applied_migrations` meta table.
589
- - **Schema-alignment test** loads `@plurnk/plurnk-grammar/schema/*.json`, parses DDL via `node:sqlite` introspection, and asserts every required schema field has a corresponding `NOT NULL` column. Grammar drift fails CI; hand-written DDL catches up before the alignment goes green again.
590
- - **Storage shape ≠ wire shape.** DDL is source of truth for storage; JSON schemas are source of truth for wire format. Tested-aligned, but allowed to differ where SQLite ergonomics demand it.
525
+ - One migration file per cohesive concern. Numbered, deterministic apply order.
526
+ - `migrate` CLI: idempotent; skips applied markers in `applied_migrations`.
527
+ - **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.
528
+ - DDL = storage truth; JSON Schemas = wire truth. Tested-aligned, allowed to differ where ergonomics demand.
591
529
 
592
530
  ### §8.2 SQL/TS responsibility boundary
593
531
 
594
- **Lives-in-SQL:**
595
- - The visibility / index / archive lattice (CTEs per run computing the projected entry list).
596
- - Cross-scope path collision detection (CHECK or trigger producing the `409 Conflict` response).
597
- - Cost rollups (denormalized integer pico-units; updated atomically on turn close via triggers).
598
- - Sequence number issuance (1-based per the grammar spec).
599
- - Entry-vs-log integrity (channels carry latest state; log carries every interaction).
600
-
601
- **Lives-in-TS:**
602
- - All status-bubble rules (`turn.status` → `loop.status` → `run.status` → `session.status`). Engine UPDATEs status explicitly when it observes terminal turn semantics. CHECK constraints enforce contract; trigger doesn't have to compute it. Triggers were tried and explicitly removed — they fight branching state machines.
603
- - Tokenization (active model's tokenizer; provider-bound; hot-swap requires re-tokenize on switch).
604
- - Provider dispatch and response normalization to `{ assistant, assistantRaw }`.
605
- - Scheme-handler invocation (open connections, run subprocesses, fetch URLs).
606
- - Plugin loading (boot-time scan per §9).
532
+ **Lives in SQL:**
533
+ - Visibility / index / archive lattice (CTEs).
534
+ - Cross-scope path collision (CHECK/trigger 409).
535
+ - Cost rollups (denormalized pico-units; atomic on turn close).
536
+ - Sequence number issuance (1-based per grammar).
537
+ - Entry-vs-log integrity.
538
+
539
+ **Lives in TS:**
540
+ - Status-bubble rules (`turn.status` → `loop.status` → `run.status` → `session.status`). Engine UPDATEs explicitly; CHECK constraints enforce; triggers fight branching state machines.
541
+ - Tokenization (provider-bound; hot-swap re-tokenizes per §14.2).
542
+ - Provider dispatch + response normalization.
543
+ - Scheme-handler invocation (connections, subprocesses, fetch).
544
+ - Plugin loading (§9).
607
545
  - Stream AbortController lifecycle.
608
- - The CLI surface and the long-running daemon.
546
+ - CLI + daemon.
609
547
 
610
- When SQL becomes onerous for a specific case, retreat for that case and document the retreat.
548
+ When SQL becomes onerous for a specific case, retreat for that case and document why.
611
549
 
612
550
  ---
613
551
 
614
552
  ## §9 Plugin Discovery
615
553
 
616
- External `@plurnk/*` npm packages register at boot via the **scoped-package scan with manifest field** pattern:
554
+ Scoped-package scan with manifest field:
617
555
 
618
- 1. Each external package declares its kind in its `package.json`:
556
+ 1. Each package declares its kind:
619
557
  ```json
620
558
  { "name": "@plurnk/plurnk-providers-openrouter",
621
559
  "plurnk": { "kind": "provider", "name": "openrouter" } }
622
560
  ```
623
- 2. On boot, `plurnk-service` reads every `node_modules/@plurnk/*/package.json`, filters for those with a `plurnk` field, dynamic-imports the matches.
624
- 3. Load order: deterministic, alphabetical by name.
625
- 4. Collision on `(kind, name)` is fail-hard at boot.
626
- 5. The user's flow: `npm i @plurnk/plurnk-<kind>-<name> && plurnk start`. Zero config.
561
+ 2. Boot scans `node_modules/@plurnk/*/package.json`, filters by `plurnk` field, dynamic-imports matches.
562
+ 3. Load order: deterministic alphabetical.
563
+ 4. Collision on `(kind, name)` is fail-hard.
564
+ 5. Operator flow: `npm i @plurnk/plurnk-<kind>-<name> && plurnk start`. Zero config.
627
565
 
628
- Env vars are reserved for *configuring* installed plugins (API keys, endpoints, throttles), never for *declaring their existence*. The filesystem is the source of truth for what's installed.
566
+ Env vars configure installed plugins; never declare existence. Filesystem is the source of truth.
629
567
 
630
- Versioning: a `plurnkContractVersion` field on each plugin's `plurnk` manifest declares which version of this SPEC the plugin targets. Engine refuses incompatible plugins at boot. (Wired up post-v1.0 of this document.)
568
+ `plurnkContractVersion` on each manifest declares SPEC target version; engine refuses incompatible plugins. (Wired post-v1.0.)
631
569
 
632
570
  ---
633
571
 
634
572
  ## §10 Bundled Set
635
573
 
636
- What ships in-tree vs. in sibling `@plurnk/*` packages. Plugin discovery (§9) finds every installed `@plurnk/*` package at boot and registers what they declare; the system runs against whatever is present in `node_modules`.
574
+ Plugin discovery (§9) registers whatever's in `node_modules/@plurnk/*`.
637
575
 
638
- **Providers in-tree (`src/providers/`):**
639
- - `Mock` — fake provider used exclusively in `intg` for deterministic engine tests. Also serves as a minimal worked example for authors of external provider packages.
576
+ **Providers in-tree (`src/providers/`):** `Mock` (intg-only test fixture + worked example).
640
577
 
641
- All real providers are siblings, registered via plugin discovery.
578
+ **Mimetypes in-tree:** none. Framework + handlers are all siblings.
642
579
 
643
- **Mimetypes in-tree:** none. The framework (`@plurnk/plurnk-mimetypes`) and every handler are siblings.
580
+ **Schemes in-tree (`src/schemes/`)** transitional; each extracts to a sibling under [plurnk-schemes](https://github.com/plurnk/plurnk-schemes) as the framework matures:
644
581
 
645
- **Schemes in-tree (`src/schemes/`):**
646
- - `plurnk` — indexable scheme for internal model-interactions. Current use: `plurnk://prompt/<loop_id>` carries each loop's prompt as a body-channel entry written on loop start. Manifest-level writability is open (any origin); model-origin writes to `plurnk://prompt/*` are rejected in-handler (engine + client own those paths).
647
- - `log` coordinate-addressed log entries. Read returns a summary of the action at `log://<loop_seq>/<turn_seq>/<sequence>`; SHOW/HIDE toggle the row's `indexed` flag (log entries are not entries-table entries, but participate in the model's curation surface via URI dispatch). Each entry renders in the packet as a single JSON meta line — no fence, no body — with `path` = the log entry's own URI (`log://<L>/<T>/<S>/<op>`) and `target` = the URI the action acted on. Errors mirror to `packet.user.telemetry.errors[]` per §15.1; status ≥ 400 in the meta signals "look in telemetry."
648
- - `known` primary narrative entries.
649
- - `unknown` decomposition / open questions.
650
- - `skill` sibling of known/unknown; semantics provisional.
651
- - `exec` manifest only; subprocess invocation not yet implemented.
652
- - `file` in-tree; extraction to a sibling repo is planned once the workspace + exec/streams work shapes the scheme's full surface. **The model is never trained on `file://` and never sees it in any rendered output.** Bare paths (`README.md`, `/abs/path.txt`) are the model-facing form; `file://` is accepted as an explicit input for absolute root paths but renders bare in the index, log, and every other model-facing surface.
582
+ | In-tree | Future sibling | Notes |
583
+ |---|---|---|
584
+ | `Known.ts` | `@plurnk/plurnk-schemes-known` | Primary narrative entries; session-scoped. |
585
+ | `Unknown.ts` | `@plurnk/plurnk-schemes-unknown` | Open questions / decomposition. |
586
+ | `Skill.ts` | `@plurnk/plurnk-schemes-skill` | Skill docs; same shape as known. |
587
+ | `Plurnk.ts` | may stay in-tree | `plurnk://prompt/<loop_id>` carries each loop's prompt. Model-origin writes to `plurnk://prompt/*` rejected in-handler. |
588
+ | `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[]` (§15.1). |
589
+ | `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. |
590
+ | `Exec.ts` | stays in-tree | Dispatches EXEC op to runtime executors registered via [plurnk-execs](https://github.com/plurnk/plurnk-execs). |
653
591
 
654
- In-tree schemes will move to siblings as their surfaces stabilize.
592
+ **Executors in-tree:** none. Framework + every runtime are siblings. `Exec.ts` dispatches by the EXEC op's `runtime` slot (`sh` default, `node`, `python`) to the matching sibling. Today's registry is hardcoded; plugin discovery migration tracked in [plurnk-execs#1](https://github.com/plurnk/plurnk-execs/issues/1).
655
593
 
656
594
  ---
657
595
 
658
596
  ## §11 Grammar Dependency
659
597
 
660
- `@plurnk/plurnk-grammar` is the contract. Treat it as authoritative; surface gaps to the user, don't redesign from this side. When a gap surfaces, file an issue upstream and adopt whatever lands.
598
+ `@plurnk/plurnk-grammar` is the contract. Authoritative; surface gaps via issue, adopt what lands. Don't redesign from this side.
661
599
 
662
600
  ### §11.1 What grammar provides
663
601
 
664
- - Parser (`PlurnkParser`, ANTLR4-generated) — DSL text → `PlurnkStatement[]`.
665
- - AST types (`@plurnk/plurnk-grammar`'s exported TypeScript interfaces).
602
+ - Parser (`PlurnkParser`, ANTLR4) — DSL text → `PlurnkStatement[]`.
603
+ - AST types exported TypeScript interfaces.
666
604
  - JSON schemas (`schema/*.json`, draft 2020-12) for every wire shape.
667
- - The grammar's `plurnk.md` is the canonical model-facing description of the DSL.
605
+ - `plurnk.md` canonical model-facing DSL description.
668
606
 
669
- ### §11.2 What plurnk-service tracks in its own runtime state (NOT in the grammar)
607
+ ### §11.2 What plurnk-service tracks (NOT in grammar)
670
608
 
671
- - Channel lifecycle (`active` / `closed` / `errored`) — subscription registry, not on `ChannelContent`. Streams are not a distinct paradigm at the contract layer; they're a runtime relationship between an entry and an open connection.
672
- - Render budget per channel — `PLURNK_ENTRY_SIZE_DEFAULT_TOKENS` operator config (§12); character-count today (§14.2).
673
- - Backpressure caps — none in v0 (§7.8). Providers/schemes/mimetypes own their own throttling.
674
- - Stream cancel verb no dedicated cancel op or signal in the grammar. SEND[499] to the URI is the pattern (§7.7).
675
- - Delete verb no dedicated DELETE op. SEND[410] to the URI is the pattern (§3.5, §6.5).
676
- - Per-loop flag plumbing — `loops.flags` JSON column. Engine plumbs the grammar's `LoopFlags` shape (yolo, mode, noWeb, noInteraction, noProposals) per-loop; today `yolo` is end-to-end, others are scheduled.
677
- - Wire-rendering conventions on top of grammar fences see §5.5 default-channel addressing.
609
+ - Channel state (`active`/`closed`/`errored`) — subscription registry, not on `ChannelContent`.
610
+ - Render budget per channel — `PLURNK_ENTRY_SIZE_DEFAULT_TOKENS` (§12); tokenization per §14.2.
611
+ - Backpressure caps — none (§7.8).
612
+ - Stream cancel — `SEND[499]` (§7.7).
613
+ - Delete — `SEND[410]` (§3.5, §6.5).
614
+ - Per-loop flags — `loops.flags` JSON column; `yolo` end-to-end today, others scheduled.
615
+ - Default-channel wire rendering — §5.5.
678
616
 
679
617
  ---
680
618
 
681
619
  ## §12 Operator Configuration
682
620
 
683
- Every plurnk-service deployment configures via env vars. Cascade: `.env.example` (shipped defaults) < `.env` (project) < `.env.<config>` (via `--config=`) < shell < CLI flags. `bin/plurnk-service.js` auto-loads `.env.example` so the daemon starts on `./bin/plurnk-service.js` with no setup required.
621
+ Env-var cascade: `.env.example` < `.env` < `.env.<config>` (via `--config=`) < shell < CLI flags. `bin/plurnk-service.js` auto-loads `.env.example`; zero-setup boot.
684
622
 
685
- Model selection uses a separate alias cascade managed by `ProviderRegistry` (`src/core/ProviderRegistry.ts`): `PLURNK_MODEL_<alias>=<provider>/<model-id>` declares an alias; `PLURNK_MODEL=<alias>` selects which is active. The first path segment of the value names the provider plugin (`@plurnk/plurnk-providers-<provider>`); the rest is the provider's own model identifier (may contain `/` for tri-level providers like openrouter's `publisher/model`). Aliases live in `.env`, not `.env.example`, since they're operator-specific. Rummy parallel: `RUMMY_MODEL_<alias>` cascade.
623
+ Model selection: separate alias cascade in `ProviderRegistry` (§2.3). `PLURNK_MODEL_<alias>=<provider>/<model-id>` declares; `PLURNK_MODEL=<alias>` selects. Aliases live in `.env`, not `.env.example` (operator-specific).
686
624
 
687
625
  | Var | Default | Status | Purpose |
688
626
  |--------------------------------------|--------------------|------------|---------------------------------------------------------------|
@@ -690,7 +628,7 @@ Model selection uses a separate alias cascade managed by `ProviderRegistry` (`sr
690
628
  | `PLURNK_HOST` | `127.0.0.1` | enforced | Bind address for the daemon WebSocket. Local-only by default. |
691
629
  | `PLURNK_PORT` | `3044` | enforced | TCP port for the daemon WebSocket. |
692
630
  | `PLURNK_MAX_TURNS` | `999` | enforced | Per-loop turn cap (overridable per `loop.run` call). |
693
- | `PLURNK_MAX_COMMANDS` | `99` | reserved | Per-turn op cap. Not yet enforced; Phase B scope. |
631
+ | `PLURNK_MAX_COMMANDS` | `99` | enforced | Per-emission op cap. Overflow ops drop silently; one `max_commands_exceeded` telemetry entry surfaces on the next packet. |
694
632
  | `PLURNK_RPC_TIMEOUT` | `30000` | reserved | ms timeout for non-`longRunning` RPC handlers. Not yet enforced. |
695
633
  | `PLURNK_LOOP_TIMEOUT` | `86400000` | reserved | ms wall-clock budget for a single `loop.run`. Not yet enforced. |
696
634
  | `PLURNK_MAX_STRIKES` | `3` | enforced | Strike threshold + sudden-death lead time (§0.5). |
@@ -700,56 +638,45 @@ Model selection uses a separate alias cascade managed by `ProviderRegistry` (`sr
700
638
  | `PLURNK_PROPOSAL_TIMEOUT_MS` | `300000` | enforced | ms wait for a proposed entry (status=202) to be resolved before timing out. |
701
639
  | `PLURNK_REASON` | `0` | enforced | Reasoning-token budget. 0 = disabled. Positive = budget in tokens; provider modules translate to wire format. |
702
640
  | `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. |
703
- | `PLURNK_ENTRY_SIZE_DEFAULT_TOKENS` | `256` | enforced | Per-channel preview budget for index tiles (characters; §14.2). |
641
+ | `PLURNK_ENTRY_SIZE_DEFAULT_TOKENS` | `256` | enforced | Per-channel preview budget for index tiles (tokens; see §14.2 for the tokenization contract). |
704
642
  | `PLURNK_DEBUG` | `0` | reserved | Schema-validation toggle. Not yet enforced. |
705
643
  | `PLURNK_LOG_LEVEL` | `info` | reserved | Stdout banner verbosity. Not yet enforced. |
706
644
 
707
- **enforced** = the engine actually reads and acts on this value. **reserved** = shipped in `.env.example` (rummy parity, forward-spec) but not yet read anywhere in code. Reserved vars are still parsed as CLI flags by `bin/plurnk-service.js` (auto-derived from `.env.example`); their values currently no-op.
645
+ **enforced** = engine reads and acts on the value. **reserved** = shipped in `.env.example` (forward-spec) but no-op until wired.
708
646
 
709
647
  Feature-flag bools use `process.env.X === "1"` exactly — never `=== "true"`.
710
648
 
711
- External provider/scheme/mimetype plugins declare their own env vars in their own `.env.example` files; plurnk-service merges them at boot via the cascading-env convention.
649
+ External plugins declare their own env vars in their own `.env.example`; service merges at boot via the cascade.
712
650
 
713
- The admin CLI (`bin/plurnk-service.js`) auto-derives flags from `.env.example`. Every `PLURNK_*` env var becomes a `--<kebab-cased-name>` flag (prefix stripped, lowercased, underscores → dashes). A comment immediately above the env line (no blank line between) becomes the flag's `-h` description. Vars without a comment are still accepted as flags but hidden from `-h`. Non-`PLURNK_*` vars in `.env.example` are bugs — vendor-specific config belongs to the vendor's package, not to plurnk-service's namespace.
651
+ **Admin CLI flag derivation.** `bin/plurnk-service.js` auto-derives flags from `.env.example`: every `PLURNK_*` becomes `--<kebab-cased-name>` (prefix stripped, lowercased, underscores → dashes). Comment immediately above (no blank line) becomes `-h` description. Non-`PLURNK_*` vars in `.env.example` are bugs — vendor config belongs in the vendor's package namespace.
714
652
 
715
653
  ---
716
654
 
717
655
  ## §13 RPC Surface
718
656
 
719
- `plurnk-service` runs as a daemon — a long-lived process owning the engine, DB, providers, schemes, and mimetypes. Clients (TUI, CLI, neovim plugin, Telegram bot, web app, anything) connect over a network protocol and drive the agent through a self-describing RPC contract.
720
-
721
- This section defines the wire. Implementing a new client should require reading only §13 — no source diving, no protocol guessing.
657
+ 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 §13.
722
658
 
723
659
  ### §13.1 Transport
724
660
 
725
- WebSocket via the `ws` npm package. One message per `ws.send`. UTF-8 JSON payloads. Single full-duplex connection per client.
661
+ 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`).
726
662
 
727
- Configuration via §12:
728
-
729
- - `PLURNK_PORT` (default `3044`) — TCP port to bind.
730
- - `PLURNK_HOST` (default `127.0.0.1`) — bind address. Local-only by default; explicit operator action to expose beyond loopback.
731
-
732
- Out of scope for v0: authentication, TLS, multiplexing. Local-loopback + filesystem permissions are the access control. Network exposure gets its own design pass with auth.
663
+ Out of scope for v0: auth, TLS, multiplexing. Local-loopback + filesystem permissions are the access control.
733
664
 
734
665
  ### §13.2 Protocol
735
666
 
736
- JSON-RPC 2.0 (https://www.jsonrpc.org/specification). Two message kinds:
737
-
738
- - **Request:** `{ "jsonrpc": "2.0", "id": "...", "method": "...", "params": {...} }`. Server replies with a matching `id`.
739
- - **Notification:** `{ "jsonrpc": "2.0", "method": "...", "params": {...} }`. No `id`; no reply expected. Server-initiated for live events.
667
+ JSON-RPC 2.0. Two message kinds:
740
668
 
741
- Server responses are `{ "jsonrpc": "2.0", "id": "...", "result": {...} }` on success or `{ "jsonrpc": "2.0", "id": "...", "error": { "code": ..., "message": "...", "data": {...} } }` on failure.
669
+ - **Request:** `{ "jsonrpc": "2.0", "id": …, "method": …, "params": }`. Server replies with matching `id`.
670
+ - **Notification:** `{ "jsonrpc": "2.0", "method": …, "params": … }`. No `id`; server-initiated; no reply.
742
671
 
743
- Wire envelope matches rummy's; clients written against rummy translate with cosmetic renames only.
672
+ Success response: `{ "jsonrpc": "2.0", "id": …, "result": }`. Failure: `{ "jsonrpc": "2.0", "id": …, "error": { "code": …, "message": …, "data": … } }`.
744
673
 
745
674
  ### §13.3 Method registration
746
675
 
747
- Every RPC method is registered on the daemon with metadata:
748
-
749
676
  ```ts
750
677
  registry.register("loop.run", {
751
678
  handler: async (params, ctx) => { /* ... */ },
752
- description: "Run a model-driven loop with a prompt; streams log/entry notifications.",
679
+ description: "Run a model-driven loop with a prompt.",
753
680
  params: {
754
681
  prompt: "string — the user prompt for the loop",
755
682
  sessionId: "number? — defaults to current attached session",
@@ -760,17 +687,14 @@ registry.register("loop.run", {
760
687
  });
761
688
  ```
762
689
 
763
- Metadata fields:
764
-
765
- - `handler(params, ctx) -> Promise<result>` the implementation.
766
- - `description: string` one-line human description; surfaced by `discover`.
767
- - `params: Record<string, string>` — per-param descriptions; surfaced by `discover`. The string format is `"type — meaning"` with `?` suffix for optional types. Self-documenting, not enforced.
768
- - `requiresInit: boolean` — if true, server rejects this method until a session is attached.
769
- - `longRunning: boolean` — exempt from the RPC timeout (`PLURNK_RPC_TIMEOUT`). Use for methods that intentionally take many seconds (running a loop).
690
+ - `description`: one-liner surfaced by `discover`.
691
+ - `params`: `"type — meaning"` per param; `?` suffix = optional. Self-documenting, not enforced.
692
+ - `requiresInit`: rejects until a session is attached.
693
+ - `longRunning`: exempt from `PLURNK_RPC_TIMEOUT`.
770
694
 
771
695
  ### §13.4 Discovery
772
696
 
773
- The `discover` method returns the entire method + notification catalog:
697
+ `discover` returns the catalog:
774
698
 
775
699
  ```json
776
700
  {
@@ -792,17 +716,15 @@ The `discover` method returns the entire method + notification catalog:
792
716
  }
793
717
  ```
794
718
 
795
- `capabilities` lists the registered plug-ins by `(kind, name)` so clients can show "what's available" without polling separate endpoints. A client connecting cold calls `discover` first, then renders its UI accordingly. No hardcoding of method names or capability lists in any client.
719
+ `capabilities` lists registered plug-ins by `(kind, name)`. Cold clients call `discover` first. No hardcoded method names or capability lists in any client.
796
720
 
797
721
  ### §13.5 Core method set
798
722
 
799
- The minimum v0 surface. Methods are grouped by concern.
800
-
801
723
  **Liveness + introspection**
802
724
 
803
725
  | Method | Params | Result | Notes |
804
726
  |------------|--------|--------|-------|
805
- | `ping` | none | `{}` | Liveness. No init required. |
727
+ | `ping` | none | `{}` | No init required. |
806
728
  | `discover` | none | catalog (§13.4) | No init required. |
807
729
 
808
730
  **Sessions**
@@ -816,7 +738,7 @@ The minimum v0 surface. Methods are grouped by concern.
816
738
  | `session.set_root` | `projectRoot: string \| null` | `{ projectRoot }` | Update the workspace pointer on the attached session. Null reverts to headless. |
817
739
  | `session.set_persona` | `persona: string \| null` | `{ persona }` | Update the session-level persona. Null clears the override (falls through to PLURNK_PERSONA file). |
818
740
 
819
- If a client issues a method requiring init (`requiresInit: true`) without first calling `session.attach` or `session.create`, the daemon auto-creates the envelope on demand: session → run → client loop, all persisted normally. Auto-creation is a convenience for one-off invocations (Telegram quick-queries, neovim ad-hoc dispatches, `plurnk "prompt"` CLI shots); the records carry through the same way explicitly-created ones do. **Auto-created ≠ auto-deleted.** Records persist for the log's forensic value. If a client wants active cleanup, that's a future `session.delete` / `session.archive` endpoint, opt-in.
741
+ **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
742
 
821
743
  **Loops (model-driven)**
822
744
 
@@ -833,13 +755,13 @@ If a client issues a method requiring init (`requiresInit: true`) without first
833
755
  | `entry.read` | `target: string` | `{ status, entry }` | Read the full entry shape (channels + tags + metadata) at the given URI. |
834
756
  | `log.read` | `loopId?: number`, … | `{ entries: LogEntry[] }` | Read recent log entries from the attached session, optionally filtered by loop. |
835
757
 
836
- **DSL operations (client-driven, mirror the grammar)**
758
+ **DSL operations (client-driven, mirror grammar)**
837
759
 
838
- Per the **Speak in DSL, not plumbing** rule (AGENTS.md Standing Rules): RPC methods for client-driven ops construct DSL statements internally and dispatch through the same `Engine.dispatch` path the model uses. Param shapes are ergonomic (semantic names, not HEREDOC slot positions); the semantics ARE the DSL's.
760
+ Per the **Speak in DSL, not plumbing** rule (AGENTS.md): `op.*` methods construct DSL statements internally and dispatch through `Engine.dispatch`. Param shapes are ergonomic (semantic names, not HEREDOC slots); semantics are the DSL's.
839
761
 
840
- Each `op.*` call creates a turn in the connection's client loop (§13.7) with the constructed statement as a single action, dispatches it, fires a `log/entry` notification to attached clients of the session, returns the dispatch result.
762
+ Each `op.*` call creates a turn in the connection's client loop (§13.7), dispatches, fires `log/entry`, returns the dispatch result.
841
763
 
842
- Naming follows the uniform principle: `target` is the URI the op acts on (the operand); `scope` for FIND (the search root); `source`/`destination` for COPY/MOVE; `recipient` for SEND (or null = broadcast); `cwd` for EXEC. `path` is never an RPC operand here — that word is reserved for *identity* throughout plurnk (the URI of *this thing*).
764
+ 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.
843
765
 
844
766
  | Method | Params | Notes |
845
767
  |---------------|---------------------------------------------------------|-------|
@@ -855,28 +777,23 @@ Naming follows the uniform principle: `target` is the URI the op acts on (the op
855
777
  | `op.dispatch` | `statement: PlurnkStatement` | Low-level path for clients that have a parsed AST already (e.g. the TUI when the user types raw HEREDOC at the prompt). |
856
778
  | `op.parse` | `text: string` | Convenience: daemon parses raw DSL text via the grammar, dispatches each statement as actions of one turn, returns `{ results: DispatchResult[] }`. |
857
779
 
858
- All `op.*` methods return `{ status: number, ...op-specific-extras }`. They are `requiresInit: true`. They are NOT `longRunning` (one dispatch per call; result returns when the engine returns).
859
-
860
- Future-reserved (post-v0):
780
+ All `op.*` return `{ status, ...op-specific }`. All `requiresInit: true`. None `longRunning`.
861
781
 
862
- - `subscription.list` list active streaming subscriptions (§7).
863
- - `subscription.cancel` — analog of `SEND[499]` over RPC, though `op.send({status: 499, recipient})` does the same thing today.
782
+ Future: `subscription.list`, `subscription.cancel` (the latter is `op.send({status: 499, recipient})` today).
864
783
 
865
784
  ### §13.6 Notifications
866
785
 
867
- Server-initiated events streamed to the client over the same WebSocket. Critical for live waterfall rendering.
786
+ Server-initiated events on the same WebSocket.
868
787
 
869
788
  | Notification | Params | When fired |
870
789
  |--------------------|-------------------------------------|------------|
871
- | `log/entry` | `{ entry: LogEntry }` | Every time a `log_entries` row is written. |
872
- | `loop/terminated` | `{ loopId, finalStatus, hitMaxTurns }` | When a loop reaches a terminal status. |
873
- | `loop/proposal` | `{ logEntryId, sessionId, runId, loopId, turnId, op, target, body, attrs, flags }` | Fired when dispatch pauses on a status=202 proposed entry. Carries the proposal payload + the loop's `flags` so a client connected to a server-YOLO loop can suppress its review UI. Client responds with `loop.resolve` (or ignores, in which case the daemon's `PLURNK_PROPOSAL_TIMEOUT_MS` fires). |
874
- | `session/created` | `{ id, name, projectRoot, persona }` | When a session is created (any client's action; gives multi-client awareness). |
875
- | `stream/event` | `{ entryId, channel, state, contentLength }` | When a channel's content grows or its state transitions. For clients rendering live; the model only sees state at turn boundaries. {§13.6-stream-event-on-channel-change} |
876
-
877
- The `stream/event` payload deliberately carries metadata, NOT content. Clients that want the new content call `entry.read({target})` to fetch — this avoids large notification payloads and gives the client agency over whether to refresh. Sample-driven (notifications include `contentLength` so clients can dedupe / batch).
790
+ | `log/entry` | `{ entry: LogEntry }` | Every `log_entries` write. |
791
+ | `loop/terminated` | `{ loopId, finalStatus, hitMaxTurns }` | Loop reaches terminal status. |
792
+ | `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). |
793
+ | `session/created` | `{ id, name, projectRoot, persona }` | Any client creates a session. |
794
+ | `stream/event` | `{ entryId, channel, state, contentLength }` | Channel content grows or state transitions. {§13.6-stream-event-on-channel-change} |
878
795
 
879
- Notifications are scoped to the connection's attached session a client attached to session A does NOT receive `log/entry` notifications for actions on session B. (Cross-session observation is a future feature; v0 keeps the scope tight.)
796
+ `stream/event` carries metadata only, never content. Clients fetch via `entry.read({target})`. Notifications are session-scoped.
880
797
 
881
798
  ### §13.7 Connection lifecycle
882
799
 
@@ -907,13 +824,13 @@ Notifications are scoped to the connection's attached session — a client attac
907
824
  | (daemon closes the client loop; session keeps)|
908
825
  ```
909
826
 
910
- The **client loop** is the envelope for client-origin actions. When a session is attached (explicit or auto-created), the daemon opens a loop in the session's current run (auto-creating the run too if absent) with `origin = "client"`. Every `op.dispatch` call creates a turn in that loop. On disconnect, the loop's `status` transitions to a closed terminal but the rows persist. Multiple connections to the same session each get their own client loop; they coexist.
827
+ **Client loop.** Envelope for client-origin actions. Attached session daemon opens loop in current run (auto-creating run if absent) with `origin = "client"`. Each `op.*` creates a turn in that loop. Disconnect closes the loop's status; rows persist. Multiple connections each get their own client loop.
911
828
 
912
- `loop.run` creates a separate, normal loop (origin = "model") for the model-driven turn sequence. It coexists with the client loop in the same run.
829
+ `loop.run` creates a separate loop with `origin = "model"` for the model-driven turn sequence. Coexists with client loop in same run.
913
830
 
914
831
  ### §13.8 Errors
915
832
 
916
- Standard JSON-RPC error codes:
833
+ Standard JSON-RPC codes:
917
834
 
918
835
  | Code | Meaning |
919
836
  |--------|-------------------------------|
@@ -923,60 +840,83 @@ Standard JSON-RPC error codes:
923
840
  | -32602 | Invalid params |
924
841
  | -32603 | Internal error |
925
842
 
926
- Plurnk-specific extensions in the `-32000` to `-32099` range:
843
+ Plurnk-specific (`-32000` to `-32099`):
927
844
 
928
845
  | Code | Meaning |
929
846
  |--------|----------------------------------------------------|
930
- | -32000 | Not initialized (method requires session attach) |
847
+ | -32000 | Not initialized (requires session attach) |
931
848
  | -32001 | Session not found |
932
849
  | -32002 | Loop not found |
933
- | -32003 | Entry not found (404 from the engine layer) |
850
+ | -32003 | Entry not found (engine 404) |
934
851
  | -32004 | Provider unavailable |
935
852
  | -32005 | Scheme unavailable |
936
853
  | -32006 | Mimetype unavailable |
937
854
  | -32007 | Timeout |
938
855
 
939
- Error responses MAY include `data: { ... }` with structured context (the path that was 404'd, the method that timed out, etc.) for client error rendering.
856
+ Error responses MAY include `data: {}` with structured context (404'd path, timed-out method, etc.).
940
857
 
941
858
  ### §13.9 Versioning
942
859
 
943
- Pre-interface-stabilization. The RPC surface evolves freely; clients track HEAD. No semver promises until we have an actual interface worth committing to.
860
+ Pre-stabilization. Clients track HEAD. No semver until the interface is worth committing to.
944
861
 
945
862
  ---
946
863
 
947
864
  ## §14 Architectural decisions
948
865
 
949
- Each entry: the question, the answer, the rationale, the migration path if revisited.
866
+ Each entry: question, answer, rationale, migration path.
950
867
 
951
868
  ### §14.1 Packet assembly: engine-direct, not filter-chain
952
869
 
953
- **Question.** Rummy assembles `<index>`, `<log>`, `<turn>`, `<system_commands>`, `<system_requirements>` via priority-ordered filter chains (`assembly.system` + `assembly.user`); plugins each filter for their data and append their section. Plurnk assembles `packet.system.index` directly in `Engine.#buildIndex` by querying visibility + entries + entry_channels and routing each channel's content through its mimetype handler; `packet.system.log` similarly via `Engine.#buildLog`. Both queries are engine-direct.
870
+ **Question.** Rummy uses priority-ordered filter chains for packet assembly. Plurnk assembles directly in `Engine.#buildIndex` / `#buildLog`.
871
+
872
+ **Decision.** Engine-direct. Plugin-driven assembly is out of scope. {§14.1-engine-direct-assembly}
873
+
874
+ **Rationale.** Channel + mimetype split already extends rendering; visibility lattice owns inclusion. Filter chain would add indirection nothing exercises. Schemes-as-URI-handlers + mimetypes-as-renderers earn extensibility through different shapes than rummy's tag-per-plugin pattern.
875
+
876
+ **Migration path.** If a plugin needs to inject a packet section, grow a single `packet.augment` hook called after `#buildIndex`; plugins return system/user augmentation objects merged into the packet. Additive — engine-direct base stays.
877
+
878
+ ### §14.2 Tokenomics: real provider tokens, stored at write, summed at render
879
+
880
+ **Question.** How does plurnk track token costs accurately enough for model SHOW/HIDE/compose decisions, without re-tokenizing every turn?
881
+
882
+ **Decision.** Provider tokenizers authoritative; counts computed at write time and stored; render-time accounting is SQL `SUM` plus tokenization of a small set of wrapper strings. {§14.2-tokenomics-v0}
883
+
884
+ Lattice:
954
885
 
955
- **Decision.** v0 stays engine-direct. The engine reads the DB and constructs the packet. Plugin-driven assembly is out of v0 scope. {§14.1-engine-direct-assembly}
886
+ - **`provider.countTokens` is the source of truth.** No chars/DIVISOR approximation. Per-provider tokenizer (§2) is the foundation.
887
+ - **Stored at write time.** `entry_channels.tokens` populated by `_entry-crud.ts` on INSERT/UPDATE. `log_entries.tokens` populated by `Engine.#writeLog`. Write helper IS the contract sister modules see.
888
+ - **Render-time SUM.** `Engine.#buildIndex` adds per-scheme `SUM(tokens)`. Only wrapper strings re-tokenize each render — bounded.
889
+ - **Hot model switch is a feature.** Session model change walks `entry_channels` + `log_entries` and recomputes against new tokenizer. One-time cost at switch boundary.
890
+ - **Budget table self-reference via placeholder substitution.** Render with `{{tokenUsage}}`/`{{tokensFree}}`, measure, substitute. ±1–2 token drift accepted.
891
+ - **Telemetry shape.** Per-scheme breakdown table in `packet.user.telemetry.budget` with `tokenCeiling`/`tokenUsage`/`tokensFree` headline; markdown table groups by scheme with indexed-count, archived-count, tokens.
956
892
 
957
- **Rationale.**
958
- - Plurnk's bundled extension set is small (3 entry-bearing schemes, 2 mimetypes, 1 provider). No current plugin wants to inject a packet section.
959
- - The channel + mimetype split already gives substantial extensibility: scheme registers channels, mimetype owns rendering. Visibility lattice owns *which* channels appear. A filter chain on top of that would be paying for indirection nothing currently exercises.
960
- - Rummy's pattern earns its keep through 25+ plugins each owning a tag. Plurnk's pattern earns its keep through schemes-as-URI-handlers + mimetypes-as-renderers. Different shapes; different consequences.
961
- - The engine-direct path is testable end-to-end against real visibility/render-time behavior. The filter-chain path requires testing the composition of plugins, which is a separate axis of complexity.
893
+ **Rationale.** Rummy used chars/DIVISOR + compute-at-SELECT because its sync-only SQL function couldn't call provider tokenizers. plurnk's async `countTokens` is potentially expensive; pay once at write, never re-pay at render. Approximation can't ground SHOW/HIDE decisions — the model curating its own context only works if numbers are accurate.
962
894
 
963
- **Migration path if revisited.** If a future plugin needs to inject a packet section (e.g., a `<turn>` metadata table per rummy SPEC §packet_structure), the engine grows a single `packet.augment` filter hook called after `#buildIndex` returns. Plugins subscribe with a priority; each returns a `system` and/or `user` augmentation object that gets merged into the packet shape. This is additive — the engine-direct base case stays; plugins augment.
895
+ **Migration path.** If `countTokens` cost becomes prohibitive, retreat to chars/DIVISOR per provider, async-refined to real count when budget permits. Schema unchanged.
964
896
 
965
- ### §14.2 Budget unit: character count
897
+ ### §14.3 Workspace identity, membership, disk co-location
966
898
 
967
- **Question.** `PLURNK_ENTRY_SIZE_DEFAULT_TOKENS` is named for tokens; the engine passes it as a character-count budget to mimetype processing. Env var name and implementation disagree.
899
+ **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?
968
900
 
969
- **Decision.** Budget is character count. The env var name is a forward-naming placeholder for an eventual token unit; today it means characters. {§14.2-budget-is-characters-v0}
901
+ **Decision lattice.** {§14.3-workspace-phase-f}
970
902
 
971
- **Rationale.** Mimetype handlers operate on strings. They have no tokenizer reference and shouldn't — tokenization is provider-bound (§2.3). Character-count is the tokenizer-independent budget that fits the handler's domain. Engine *could* post-tokenize the resulting preview for accounting (the provider's `countTokens` is available), but the budget passed *into* the handler is characters by design.
903
+ - **D1. Workspace identity lives on the session.** No `projects` table. `sessions.project_root TEXT` (nullable = headless). Constraints scoped via `session_constraints`. plurnk has no users/auth/multi-tenant; workspace = session.
904
+ - **D2. No new `entries.scope` value.** Stays `∈ {'agent', 'session'}`.
905
+ - **D3. Disk co-location optional.** `sessions.project_root` set → disk side-effects on accept; null → canonical entries land, no disk write (client owns materialization).
906
+ - **D4. Membership without git: no fs-walk.** git present → ls-files + constraint overlay. git absent → `effect='add'` constraints only.
907
+ - **D5. EMI is eager + relevance-bounded.** Fires at prompt-composition time; checks only entries the current run will include (`visibility.indexed=1`). Divergent → re-read disk + emit synthetic log entry.
908
+ - **D6. Edit-on-untouched-file: disk-read-first.** EDIT targeting a member with no existing entry reads disk first to populate canonical baseline, then diffs. Prevents silent overwrite.
909
+ - **D7. Constraint pattern matching via `node:path.matchesGlob`.** No dep. Helper wrapper for swap-out optionality.
972
910
 
973
- **Migration path if revisited.** Either rename the env var to reflect characters, or change the budget semantic to tokens by handing handlers a tokenizer reference (`preview(content, budget, countTokens?)`). The latter conflates concerns and isn't appealing.
911
+ **Rationale.** No users/tenants. Session is the right scope unit. git+constraints membership keeps model out of fs-walk territory. EMI's eager-relevance-bounded firing prevents stale previews without per-turn full-repo cost.
912
+
913
+ **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.
974
914
 
975
915
  ---
976
916
 
977
917
  ## §15 Packet shape
978
918
 
979
- The canonical packet shape is defined by `@plurnk/plurnk-grammar` (`schema/Packet.json`). Engine assembles in `Engine.#buildRequestPacket`; plugins do not augment in v0 (§14.1). This section describes plurnk-service's responsibilities under that contract — see grammar for the authoritative schema.
919
+ Canonical shape defined by `@plurnk/plurnk-grammar` (`schema/Packet.json`). Engine assembles in `Engine.#buildRequestPacket`; no plugin augmentation (§14.1). This section is plurnk-service's responsibilities under that contract.
980
920
 
981
921
  ```ts
982
922
  type Packet = {
@@ -992,40 +932,203 @@ type Packet = {
992
932
  tokens: number;
993
933
  prompt: string;
994
934
  telemetry: { budget: string; errors: object[] }; // §15.1
995
- system_requirements: string;
935
+ system_requirements: string; // §15.2
996
936
  };
997
937
  assistant: { tokens: number; content: string; ops: PlurnkStatement[]; reasoning: string | null };
998
938
  assistantRaw: unknown;
999
939
  };
1000
940
  ```
1001
941
 
1002
- **Prompt as a first-class entry.** Each loop's prompt is written on loop start as a system-origin `EDIT` against `plurnk://prompt/<loop_id>` (indexable entry, body channel, text/markdown). The corresponding log row renders as `* {"op":"EDIT","origin":"system","path":"log://<L>/1/1/EDIT","status":201,"target":"plurnk://prompt/<L>"}` — `path` is the log row's own URI, `target` is the plurnk entry that got written. At render time, the *current* loop's `plurnk://prompt/<loop_id>` entry is foisted out of `packet.system.index` and materialized in `packet.user.prompt` — one section, one mechanism, no duplicate content rendering. Previous loops' prompt entries remain in the index and are addressable for the model to READ or HIDE. {§15-current-prompt-foist}
942
+ **Prompt as a first-class entry.** Each loop's prompt is written on loop start as a system-origin `EDIT` against `plurnk://prompt/<loop_id>` (indexable, body channel, text/markdown). At render time the current loop's entry is foisted out of `packet.system.index` into `packet.user.prompt` — no duplicate rendering. Previous loops' prompts remain in the index, addressable for READ/HIDE. {§15-current-prompt-foist}
1003
943
 
1004
944
  ### §15.1 user.telemetry — model-facing runtime telemetry
1005
945
 
1006
- The slot for telemetry the model MUST react to right now: budget pressure and last-turn failures that didn't produce an action-entry. Rendered prominently at the bottom of the user section so the model cannot ignore it. Errors here are transient — they appear on the turn AFTER the failure and clear once the model has seen them. The action-entries (`packet.system.log[]`) are the durable audit; `telemetry.errors[]` is the **alert**.
946
+ 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**.
1007
947
 
1008
- **Grammar contract (authoritative, plurnk-grammar 0.4.0):**
948
+ **Grammar contract:**
1009
949
 
1010
- - `budget: string` — text/markdown. Renderer-provided summary of remaining context / cost / etc. Empty string when nothing to surface.
1011
- - `errors: object[]` — element shape intentionally open at v0. Consumers populate as actionless-failure rendering needs solidify. Empty array when no errors to surface.
950
+ - `budget: string` — text/markdown. Empty when nothing to surface.
951
+ - `errors: object[]` — shape open at v0.
1012
952
 
1013
- **Plurnk-service rendering (v0):**
953
+ **Plurnk-service rendering:**
1014
954
 
1015
- - `budget` is rendered as a short markdown line. Unit follows §14.2 (character count); the exact rendering is engine-internal and may evolve without a schema change.
1016
- - `errors[]` carries telemetry from the previous turn's dispatch + rail evaluation. Each element has a required `kind` discriminator and a required `message` string (human-readable, model-actionable). Additional kind-specific fields are flat on the element — there is NO nested `detail` envelope; canonical-JSON serialization sorts keys for prefix-cache friendliness.
1017
- - Wire format: one `* {canonical-JSON}` line per error under `# Plurnk System Errors`, in push order. Buffer drains on read — each error appears in exactly one packet. {§15.1-drain-on-read}
955
+ - `budget` per §14.2: per-scheme breakdown table with `tokenCeiling`/`tokenUsage`/`tokensFree`.
956
+ - `errors[]` from previous turn's dispatch + rail eval. Required: `kind` discriminator. Additional kind-specific fields are flat on the element — NO nested `detail`. Canonical-JSON serialization sorts keys for prefix-cache friendliness.
957
+ - Wire: one `* {canonical-JSON}` line per error under `# Plurnk System Errors`, push order. Buffer drains on read. {§15.1-drain-on-read}
958
+ - **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.
1018
959
 
1019
- **Kinds emitted by plurnk-service v0:**
960
+ **Kinds emitted by plurnk-service:**
1020
961
 
1021
- | `kind` | Source | Required fields | Additional fields |
1022
- |---|---|---|---|
1023
- | `action_failure` | Log entry with `status_rx ≥ 400` from previous turn | `kind`, `message`, `coordinate` (`<L>/<T>/<S>`), `op`, `status` | `target` (URI or null) |
1024
- | `no_ops` | Rail #41 — turn ended with zero ops | `kind`, `message` | — |
1025
- | `strike` | Rail #38 — strike streak bumped (no_ops / recorded_failure / rail) | `kind`, `message`, `streak`, `maxStrikes`, `reason` | — |
1026
- | `cycle` | Rail #39 — repeating turn fingerprint pattern detected | `kind`, `message`, `period`, `cycles` | — |
1027
- | `sudden_death` | Rail #40 — entering the last `maxStrikes`-sized window before `maxTurns` | `kind`, `message`, `remaining` | — |
962
+ | `kind` | Source | Required fields |
963
+ |---|---|---|
964
+ | `action_failure` | Log entry with `status_rx ≥ 400` from previous turn | `kind`, `coordinate` (`<L>/<T>/<S>`), `op`, `status`, `target` (URI or null). May carry scheme-emitted `error` (a terse fact, not guidance). |
965
+ | `no_ops` | Rail #41 — turn ended with zero ops | `kind` |
966
+ | `strike` | Rail #38 — strike streak bumped (no_ops / recorded_failure / rail) | `kind`, `streak`, `maxStrikes`, `reason` |
967
+ | `cycle` | Rail #39 — repeating turn fingerprint pattern detected | `kind`, `period`, `cycles` |
968
+ | `sudden_death` | Rail #40 — entering the last `maxStrikes`-sized window before `maxTurns` | `kind`, `remaining` |
969
+ | `max_commands_exceeded` | Single emission exceeded `PLURNK_MAX_COMMANDS` cap; overflow ops dropped without dispatch | `kind`, `emitted`, `cap`, `dropped` |
970
+
971
+ Action-bound failures (handler returned 4xx/5xx or threw) mirror as `action_failure` kind on the next packet. Full detail queryable via `log://`. {§15.1-no-error-scheme}
972
+
973
+ **No `error://` scheme.** Actionless failures route to telemetry, not a queryable scheme namespace.
974
+
975
+ **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/cycle/sudden_death as "loop is degrading" toasts, log everything to a session timeline. Scoped to the loop's session. {§15.1-telemetry-event-notify}
976
+
977
+ **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. {§15.1-content-offset-snippet}
978
+
979
+ ### §15.2 user.system_requirements — static per-turn rules
980
+
981
+ Rendered at the END of the user packet under `# Plurnk System Requirements` — closest to the assistant turn so the contract the model has to honor is the most recent text it sees. Contains rules the grammar block doesn't cover (canonical example: "Conclude the loop with `<<SEND[200]:answer:SEND`").
982
+
983
+ **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.
984
+
985
+ **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.
986
+
987
+ ---
988
+
989
+ ## §16 Matcher and `<L>` slicing
990
+
991
+ 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.
992
+
993
+ ### §16.1 Matcher dispatch (delegated to `Mimetypes.query`)
994
+
995
+ `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:
996
+
997
+ | Framework error | HTTP status |
998
+ |---|---|
999
+ | `UnsupportedDialectError` | 415 |
1000
+ | `InvalidExpressionError` | 400 |
1001
+ | `QueryParseFailureError` | 203 (soft fallback: raw content as `text/markdown` with `reason`) |
1002
+ | Empty match array | 204 |
1003
+ | Match array | 200 |
1004
+
1005
+ 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. {§16.1-203-soft-fallback}
1006
+
1007
+ Glob anchoring (`TODO*` starts-with, `*TODO*` contains, `*.log` ends-with, `[Tt]odo*` char class) lives in framework's `BaseHandler`.
1008
+
1009
+ ### §16.2 Matcher result shape (uniform across dialects)
1010
+
1011
+ Body: JSON array of `{line, matched, matching?}`. Empty → 204. Mimetype = `application/json` regardless of source dialect. Render verbatim (no `N:\t` — tree-navigable per §16.6).
1012
+
1013
+ - `line` — 1-indexed source line.
1014
+ - `matched` — extracted value, polymorphic per dialect:
1015
+ - **bare regex** → string (full match)
1016
+ - **anon captures** → array `[c1, c2, …]`
1017
+ - **named captures** → object `{name: v, …}`. Mixed anon+named uses positional keys `"1"`, `"2"` alongside names.
1018
+ - **glob** → string (matching source line)
1019
+ - **jsonpath** → JSON value at the path
1020
+ - **xpath text/attr** → string
1021
+ - **xpath node** → serialized XML
1022
+ - `matching` — per-instance discriminator. Present for jsonpath wildcards (bracket form like `$['users'][0]['name']`) and xpath multi-match (`(//user)[1]`). Omitted otherwise.
1023
+
1024
+ | Dialect | Extracts | Natural use |
1025
+ |---|---|---|
1026
+ | regex `/pat/` | substring (or captures) | extract the value after X: |
1027
+ | glob `pat` | whole matching lines | show lines containing TODO |
1028
+ | jsonpath `$.path` | JSON values (parsed value for JSON-shaped mimetypes; bare-leaves outline for markdown/HTML/source) | get the host field / jump to Installation |
1029
+ | xpath `//sel` | XML nodes/text/attrs (text/html only) | get the h1 contents |
1030
+
1031
+ ### §16.3 `<L>` semantics by source mimetype
1032
+
1033
+ **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.
1034
+
1035
+ **Line-navigable** (text/markdown, source code, csv, yaml/toml): indexes by line via `sliceLines`. Output mimetype = `text/markdown`.
1036
+
1037
+ **JSON**: indexes by item via `sliceJsonItems`. Every JSON value becomes a list of top-level items:
1038
+
1039
+ | Source | Items |
1040
+ |---|---|
1041
+ | Array `[a, b, c]` | array elements |
1042
+ | Object `{k1: v1, k2: v2}` | key-value pairs as single-key wrappers (insertion order) |
1043
+ | Scalar | the scalar itself (length-1 list) |
1044
+
1045
+ `<L>` indexes 1-based. READ result always a JSON array. Output mimetype = `application/json` (preserves structure for compose).
1046
+
1047
+ - `<N>` → `[items[N-1]]`
1048
+ - `<N,M>` → `items.slice(N-1, M)`
1049
+ - `<1,-1>` → whole top-level
1050
+ - `<0>` / `<-1>` → `[]` for READ
1051
+ - Out-of-range → 416; malformed JSON → 400
1052
+
1053
+ **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. {§16.3-compose-pattern}
1054
+
1055
+ ### §16.4 Structural EDIT on JSON
1056
+
1057
+ When effective mimetype is `application/json`, EDIT dispatches through `applyJsonItemEdit`. Body shape rule (parse-then-discriminate):
1058
+
1059
+ - Body parses as JSON array → items to splice
1060
+ - Body parses as non-array JSON → single item to splice
1061
+ - Empty body → delete the selection
1062
+ - Body fails JSON parse → 400 (path-extension declares intent; honor strictly)
1063
+
1064
+ **Array source marker × body:**
1065
+
1066
+ | Marker | Body | Effect |
1067
+ |---|---|---|
1068
+ | `<-1>` | `"d"` | append one |
1069
+ | `<-1>` | `["x","y"]` | append multiple |
1070
+ | `<-1>` | `[[1,2]]` | append inner array as one element (wrap-workaround) |
1071
+ | `<0>` | `"x"` | prepend |
1072
+ | `<N>` | `"X"` | replace position N |
1073
+ | `<N>` | `["X","Y"]` | replace position N, expanding |
1074
+ | `<N,M>` | `"X"` | range collapses to single item |
1075
+ | `<N,M>` | `[...]` | range replaced with array items |
1076
+ | `<N>` | (empty) | delete position N |
1077
+ | `<1,-1>` | (empty) | clear to `[]` |
1078
+ | `<-1>` / `<0>` | (empty) | no-op |
1079
+
1080
+ **Object source** (items are kv-pairs): body items must be objects (multi-key body inserts multiple kv-pairs). Array body → 400.
1081
+
1082
+ **Scalar source**: `<1>` replaces only. Grow markers (`<-1>`, `<0>`) and multi-item bodies → 400 (no implicit promotion scalar→array).
1083
+
1084
+ ### §16.5 Path-extension declares mimetype
1085
+
1086
+ `resolveEntryMimetype` (exported from `@plurnk/plurnk-schemes`): pathname extension → `Mimetypes.detect({ ext })` (with `text/plain` normalized to `text/markdown` per the text-primitive rule §16.7); falls back to scheme manifest channel default when no extension.
1087
+
1088
+ - `known://users.json` → `application/json` (extension wins)
1089
+ - `known://notes.md` → `text/markdown` (extension; matches default)
1090
+ - `known://config.yaml` → `application/yaml`
1091
+ - `known://users` (no suffix) → `text/markdown` (Known manifest default)
1092
+
1093
+ 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.
1094
+
1095
+ ### §16.6 Render rule (mimetype-driven)
1096
+
1097
+ `packet-wire` log render branches on `isLineNavigableMimetype`:
1098
+
1099
+ - **Line-navigable** (text/markdown, text/plain, csv, source code, yaml, toml) → `N:\t` line-number prefix per line
1100
+ - **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)
1101
+
1102
+ The `N:\t` prefix is presentation/reference per plurnk.md ("not part of the source"); stripped before any matcher operation on the log entry.
1103
+
1104
+ ### §16.7 Mimetype primitive: text/markdown
1105
+
1106
+ Auto-derived text mimetypes anywhere in plurnk-service normalize to `text/markdown`:
1107
+
1108
+ - `<L>` slice on line-navigable source → `text/markdown`
1109
+ - File scheme extension fallback → `text/markdown`
1110
+ - `Mimetypes.detect()` returning `text/plain` → normalized via `normalizeAutoTextMimetype`
1111
+
1112
+ `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.
1113
+
1114
+ ### §16.8 EDIT response surfaces unified diff
1115
+
1116
+ Every EDIT@200/201 carries `diff` (unified diff format) on the result. Both `_entry-ops.editSessionEntry` and `File.edit` produce it. `packet-wire` log render emits the diff under the entry's fence so the model sees what changed without a follow-up READ — wrong-marker mistakes (e.g., `<1>` when `<-1>` was meant) become visible on the next turn. Omitted on no-op edits (content unchanged). {§16.8-edit-diff}
1117
+
1118
+ ### §16.9 Op-level invariants and resolved ambiguities
1119
+
1120
+ Carried from the contract walk; durable.
1121
+
1122
+ - **Dialect/mimetype mismatch** → 415 (xpath on text/plain → 415; jsonpath on JSON-shapeless mimetypes → 204 because outline is empty, not 415).
1123
+ - **Binary entries** → 415 across the board for READ/EDIT/SHOW/HIDE.
1124
+ - **EDIT `<L>` on non-existent entry** → body becomes content; `<L>` is positional-only on existing content.
1125
+ - **COPY `<L>`** → source range, symmetric with READ `<L>`.
1126
+ - **READ rx** prefixes each line with `N:\t` per §16.6. `sliceLinesRaw` (used by COPY) returns the lines without prefix.
1127
+ - **FIND body matcher** applies to pathname. xpath/jsonpath content matchers dispatch through `Mimetypes.query`.
1128
+ - **SHOW/HIDE** has a single-entry path (exact pathname, no slots) and a multi-entry path (any of body/signal/`<L>` present). Multi-entry path treats target as pathname glob scope, applies body matcher to pathnames (regex/glob), filters by `signal` tags, paginates with `<L>`, and flips visibility on the resulting set.
1129
+ - **SEND[410]** with `#fragment` deletes that channel only; without fragment, deletes the whole entry. **SEND[499]** is owned by the streaming scheme that holds the subscription.
1130
+ - **File scheme** reads disk content with mimetype detected via `Mimetypes.detect({ path })` (plumbed through `PlurnkSchemeContext.mimetypes`). Binary mimetypes → 415 on READ and EDIT.
1028
1131
 
1029
- Action-bound failures (handler returned 4xx/5xx or threw) are mirrored as the `action_failure` kind on the next packet — same forced-confrontation pattern. Full detail stays queryable via `log://`. {§15.1-no-error-scheme}
1132
+ ### §16.10 Directed-SEND status code policy
1030
1133
 
1031
- **No `error://` scheme.** Actionless failures route to telemetry, not to a queryable scheme namespace.
1134
+ 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.