@plurnk/plurnk-service 0.3.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.
- package/README.md +13 -44
- package/SPEC.md +559 -447
- package/bin/plurnk-service.js +2 -1
- package/dist/core/ChannelWrite.d.ts +15 -0
- package/dist/core/ChannelWrite.d.ts.map +1 -1
- package/dist/core/ChannelWrite.js.map +1 -1
- package/dist/core/Engine.d.ts +15 -4
- package/dist/core/Engine.d.ts.map +1 -1
- package/dist/core/Engine.js +417 -114
- package/dist/core/Engine.js.map +1 -1
- package/dist/core/PluginLoader.js +6 -6
- package/dist/core/PluginLoader.js.map +1 -1
- package/dist/core/ProviderInstantiate.d.ts +4 -0
- package/dist/core/ProviderInstantiate.d.ts.map +1 -0
- package/dist/core/ProviderInstantiate.js +41 -0
- package/dist/core/ProviderInstantiate.js.map +1 -0
- package/dist/core/ProviderRegistry.d.ts.map +1 -1
- package/dist/core/ProviderRegistry.js +1 -1
- package/dist/core/ProviderRegistry.js.map +1 -1
- package/dist/core/SchemeRegistry.d.ts +1 -1
- package/dist/core/SchemeRegistry.d.ts.map +1 -1
- package/dist/core/SchemeRegistry.js +4 -20
- package/dist/core/SchemeRegistry.js.map +1 -1
- package/dist/core/line-marker.d.ts +23 -0
- package/dist/core/line-marker.d.ts.map +1 -0
- package/dist/core/line-marker.js +321 -0
- package/dist/core/line-marker.js.map +1 -0
- package/dist/core/matcher.d.ts +12 -0
- package/dist/core/matcher.d.ts.map +1 -0
- package/dist/core/matcher.js +72 -0
- package/dist/core/matcher.js.map +1 -0
- package/dist/core/mimetype-binary.d.ts +6 -0
- package/dist/core/mimetype-binary.d.ts.map +1 -0
- package/dist/core/mimetype-binary.js +82 -0
- package/dist/core/mimetype-binary.js.map +1 -0
- package/dist/core/packet-wire.d.ts.map +1 -1
- package/dist/core/packet-wire.js +206 -30
- package/dist/core/packet-wire.js.map +1 -1
- package/dist/core/path-mimetype.d.ts +3 -0
- package/dist/core/path-mimetype.d.ts.map +1 -0
- package/dist/core/path-mimetype.js +47 -0
- package/dist/core/path-mimetype.js.map +1 -0
- package/dist/core/scheme-types.d.ts +8 -26
- package/dist/core/scheme-types.d.ts.map +1 -1
- package/dist/core/scheme-types.js +5 -8
- package/dist/core/scheme-types.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -1
- package/dist/index.js.map +1 -1
- package/dist/schemes/Exec.d.ts +30 -1
- package/dist/schemes/Exec.d.ts.map +1 -1
- package/dist/schemes/Exec.js +217 -6
- package/dist/schemes/Exec.js.map +1 -1
- package/dist/schemes/File.d.ts +5 -0
- package/dist/schemes/File.d.ts.map +1 -1
- package/dist/schemes/File.js +113 -23
- package/dist/schemes/File.js.map +1 -1
- package/dist/schemes/Log.d.ts +3 -0
- package/dist/schemes/Log.d.ts.map +1 -1
- package/dist/schemes/Log.js +88 -13
- package/dist/schemes/Log.js.map +1 -1
- package/dist/schemes/Plurnk.js +3 -3
- package/dist/schemes/Plurnk.js.map +1 -1
- package/dist/schemes/_entry-crud.d.ts +2 -0
- package/dist/schemes/_entry-crud.d.ts.map +1 -1
- package/dist/schemes/_entry-crud.js +1 -0
- package/dist/schemes/_entry-crud.js.map +1 -1
- package/dist/schemes/_entry-find.d.ts.map +1 -1
- package/dist/schemes/_entry-find.js +64 -15
- package/dist/schemes/_entry-find.js.map +1 -1
- package/dist/schemes/_entry-ops.d.ts +3 -0
- package/dist/schemes/_entry-ops.d.ts.map +1 -1
- package/dist/schemes/_entry-ops.js +262 -54
- package/dist/schemes/_entry-ops.js.map +1 -1
- package/dist/schemes/_entry-send.d.ts.map +1 -1
- package/dist/schemes/_entry-send.js +14 -7
- package/dist/schemes/_entry-send.js.map +1 -1
- package/dist/server/ClientConnection.d.ts +3 -2
- package/dist/server/ClientConnection.d.ts.map +1 -1
- package/dist/server/ClientConnection.js +4 -1
- package/dist/server/ClientConnection.js.map +1 -1
- package/dist/server/Daemon.d.ts +51 -1
- package/dist/server/Daemon.d.ts.map +1 -1
- package/dist/server/Daemon.js +337 -1
- package/dist/server/Daemon.js.map +1 -1
- package/dist/server/MethodRegistry.d.ts +31 -2
- package/dist/server/MethodRegistry.d.ts.map +1 -1
- package/dist/server/MethodRegistry.js.map +1 -1
- package/dist/server/dsl.d.ts +2 -2
- package/dist/server/dsl.d.ts.map +1 -1
- package/dist/server/dsl.js +10 -10
- package/dist/server/dsl.js.map +1 -1
- package/dist/server/methods/entry_read.js +5 -5
- package/dist/server/methods/entry_read.js.map +1 -1
- package/dist/server/methods/loop_cancel.d.ts +3 -0
- package/dist/server/methods/loop_cancel.d.ts.map +1 -0
- package/dist/server/methods/loop_cancel.js +27 -0
- package/dist/server/methods/loop_cancel.js.map +1 -0
- package/dist/server/methods/loop_resolve.js +2 -2
- package/dist/server/methods/loop_resolve.js.map +1 -1
- package/dist/server/methods/loop_run.d.ts.map +1 -1
- package/dist/server/methods/loop_run.js +48 -48
- package/dist/server/methods/loop_run.js.map +1 -1
- package/dist/server/methods/op_edit.js +3 -3
- package/dist/server/methods/op_edit.js.map +1 -1
- package/dist/server/methods/op_hide.js +3 -3
- package/dist/server/methods/op_hide.js.map +1 -1
- package/dist/server/methods/op_read.js +3 -3
- package/dist/server/methods/op_read.js.map +1 -1
- package/dist/server/methods/op_show.js +3 -3
- package/dist/server/methods/op_show.js.map +1 -1
- package/dist/server/methods/providers_list.js +1 -1
- package/dist/server/methods/providers_list.js.map +1 -1
- package/migrations/001_schema.sql +1 -1
- package/package.json +19 -14
package/SPEC.md
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
# plurnk-service — Specification
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Canonical contracts plurnk-service exposes, architecture it implements, promises it makes to the constellation (`plurnk-grammar`, `plurnk-providers`, `plurnk-schemes`, `plurnk-mimetypes`, `plurnk-execs`, the user-facing `plurnk` CLI). `AGENTS.md` covers process; this file covers contract.
|
|
4
4
|
|
|
5
|
-
Section numbers are stable.
|
|
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.
|
|
37
|
-
| **mimetype** | A channel's content type. Drives the render-time handler that produces `preview`/`symbols`.
|
|
38
|
-
| **provider** | An LLM transport. Implements `generate({messages, signal})` against a wire protocol.
|
|
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
|
|
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. |
|
|
@@ -84,7 +82,7 @@ Three independent axes on entries and channels. Confusion across them is a recur
|
|
|
84
82
|
| Tier | Location | LLM | Substrate |
|
|
85
83
|
|---|---|---|---|
|
|
86
84
|
| **unit** | `src/**/*.test.ts` | No | Isolated logic, mocked boundaries |
|
|
87
|
-
| **intg** | `test/intg/` | No (mock provider) | Real
|
|
85
|
+
| **intg** | `test/intg/` | No (mock provider) | Real file-backed SqlRite (per-test DB under `test/intg/.tmp/`), real engine |
|
|
88
86
|
| **live** | `test/live/` | Real | Wire-level assertions |
|
|
89
87
|
| **demo** | `test/demo/` | Real | Holistic outcome assertions |
|
|
90
88
|
|
|
@@ -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
|
-
|
|
93
|
+
Engine library + admin CLI + daemon. Four plug points:
|
|
96
94
|
|
|
97
|
-
- **Providers** (§2) — LLM transports.
|
|
98
|
-
- **Schemes** (§3) — addressable resources. Every
|
|
99
|
-
- **Mimetypes** (§4) — content interpretation.
|
|
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
|
|
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
|
|
102
|
+
The grammar (`@plurnk/plurnk-grammar`) owns parser + AST contract. Schemes receive parsed statement fragments via dispatch.
|
|
104
103
|
|
|
105
|
-
|
|
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
|
-
|
|
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
|
|
112
|
+
### §2.1 Consumption surface
|
|
114
113
|
|
|
115
|
-
|
|
114
|
+
Three entry points:
|
|
116
115
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
120
|
+
Plus immutable identity: `provider.contextSize` (token total, or `null` → "no budget info") and `provider.model`.
|
|
125
121
|
|
|
126
|
-
### §2.2
|
|
122
|
+
### §2.2 Engine → provider guarantees
|
|
127
123
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
142
|
-
```
|
|
130
|
+
### §2.3 Provider instantiation
|
|
143
131
|
|
|
144
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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
|
|
142
|
+
### §2.4 In-tree Mock provider
|
|
163
143
|
|
|
164
|
-
|
|
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
|
-
|
|
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
|
|
180
|
-
|
|
181
|
-
```json
|
|
182
|
-
{
|
|
183
|
-
"name": "@plurnk/plurnk-schemes-<name>",
|
|
184
|
-
"plurnk": { "kind": "scheme", "name": "<name>" }
|
|
185
|
-
}
|
|
186
|
-
```
|
|
187
|
-
|
|
188
|
-
The class declares its full manifest as a static field (per `SchemeManifest` in `src/core/scheme-types.ts`):
|
|
189
|
-
|
|
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
|
-
```
|
|
206
|
-
|
|
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).
|
|
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`.
|
|
212
155
|
|
|
213
|
-
### §3.2 CRUD
|
|
156
|
+
### §3.2 CRUD primitives
|
|
214
157
|
|
|
215
|
-
|
|
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
|
-
```
|
|
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.
|
|
227
159
|
|
|
228
|
-
|
|
160
|
+
### §3.3 Op methods
|
|
229
161
|
|
|
230
|
-
|
|
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
|
|
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
|
-
|
|
186
|
+
Directed SEND (non-null path) routes to scheme's `send`. Status = intent:
|
|
279
187
|
|
|
280
|
-
- `SEND[200](path)` — write
|
|
281
|
-
- `SEND[410](path)` — delete the resource
|
|
282
|
-
- `SEND[
|
|
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).
|
|
283
192
|
|
|
284
|
-
Other status codes
|
|
193
|
+
Other status codes return 501 from entry-bearing schemes by default. {§3.5-entry-schemes-501-on-non-410}
|
|
285
194
|
|
|
286
|
-
|
|
195
|
+
Null-path SEND is broadcast (§6.7), engine-handled.
|
|
287
196
|
|
|
288
|
-
|
|
197
|
+
### §3.6 Consumption surface
|
|
198
|
+
|
|
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
|
-
|
|
232
|
+
Author-facing contract: [plurnk-mimetypes](https://github.com/plurnk/plurnk-mimetypes). Below: firing semantics + consumption surface.
|
|
295
233
|
|
|
296
|
-
**Firing semantics.**
|
|
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
|
-
|
|
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
|
-
|
|
240
|
+
### §4.2 Methods
|
|
308
241
|
|
|
309
|
-
|
|
242
|
+
Author contract owned by plurnk-mimetypes. plurnk-service consumes through two entry points:
|
|
310
243
|
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
247
|
+
Cross-cutting promises service relies on:
|
|
320
248
|
|
|
321
|
-
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
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
|
-
|
|
289
|
+
**Render pipeline.** `Engine.#buildIndex`:
|
|
336
290
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
335
|
+
DSL targets a specific channel via the URL fragment (`#name`).
|
|
375
336
|
|
|
376
337
|
Rules:
|
|
377
338
|
|
|
378
|
-
1.
|
|
379
|
-
2.
|
|
380
|
-
3.
|
|
381
|
-
4.
|
|
382
|
-
5.
|
|
383
|
-
6.
|
|
384
|
-
|
|
385
|
-
Examples:
|
|
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}
|
|
386
345
|
|
|
387
|
-
| URI |
|
|
346
|
+
| URI | Channel |
|
|
388
347
|
|---|---|
|
|
389
|
-
| `known://france/capital` | body
|
|
390
|
-
| `known://france/capital#preview` | preview
|
|
391
|
-
| `exec://run/abc#stdout` | stdout
|
|
392
|
-
| `exec://run/abc#stderr` | stderr
|
|
393
|
-
| `sse://feed/y#data` |
|
|
394
|
-
| `log://N/T/A` |
|
|
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) |
|
|
395
354
|
|
|
396
|
-
|
|
355
|
+
Op implications:
|
|
397
356
|
|
|
398
|
-
-
|
|
399
|
-
-
|
|
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).
|
|
357
|
+
- EDIT to undeclared channel → 400; read-only channel → 405.
|
|
358
|
+
- COPY/MOVE with fragment is per-channel; design deferred until needed.
|
|
402
359
|
|
|
403
|
-
|
|
360
|
+
RPC params carry fragments inline via the `target` string (`{ target: "known://x#stderr" }`).
|
|
404
361
|
|
|
405
|
-
**Wire
|
|
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}
|
|
406
363
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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
|
-
|
|
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
|
|
419
|
-
- `active` —
|
|
420
|
-
- `closed` —
|
|
421
|
-
- `errored` —
|
|
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
|
|
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
|
-
|
|
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
|
|
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",
|
|
392
|
+
AST: `{ op: "EDIT", target, body: string | null, signal: tags | null, lineMarker? }`.
|
|
436
393
|
|
|
437
|
-
|
|
438
|
-
-
|
|
439
|
-
-
|
|
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
|
-
-
|
|
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",
|
|
402
|
+
AST: `{ op: "READ", target, body: MatcherBody | null, signal: tags | null, lineMarker? }`.
|
|
448
403
|
|
|
449
|
-
|
|
450
|
-
-
|
|
451
|
-
- `
|
|
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",
|
|
410
|
+
AST: `{ op: "SHOW"|"HIDE", target, body: MatcherBody | null, signal: tags | null, lineMarker? }`.
|
|
457
411
|
|
|
458
|
-
|
|
459
|
-
-
|
|
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",
|
|
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
|
|
470
|
-
2. `dst_scheme.readEntry
|
|
471
|
-
3. Mimetype
|
|
472
|
-
4.
|
|
473
|
-
5. `dst_scheme.writeEntry(
|
|
474
|
-
6. Dest
|
|
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
|
|
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",
|
|
483
|
-
|
|
484
|
-
Two modes:
|
|
432
|
+
AST: `{ op: "MOVE", target (source), body: dest | null, signal: tags | null, lineMarker? }`.
|
|
485
433
|
|
|
486
|
-
**Relocation** (`body` non-null):
|
|
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}
|
|
487
436
|
|
|
488
|
-
|
|
489
|
-
|
|
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",
|
|
441
|
+
AST: `{ op: "FIND", target (scope), body: MatcherBody | null (predicate), signal: tags | null, lineMarker? }`.
|
|
495
442
|
|
|
496
|
-
|
|
497
|
-
-
|
|
498
|
-
-
|
|
499
|
-
-
|
|
500
|
-
-
|
|
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
|
-
AST: `{ op: "SEND",
|
|
506
|
-
|
|
507
|
-
Two modes:
|
|
451
|
+
AST: `{ op: "SEND", target: ParsedPath | null, body: SendBody | null, signal: number | null }`.
|
|
508
452
|
|
|
509
|
-
**Broadcast** (
|
|
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",
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
560
|
-
- **Write:** `<<SEND[200](wss://feed/x):
|
|
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
|
-
|
|
565
|
-
|
|
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}
|
|
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.
|
|
567
506
|
|
|
568
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
521
|
+
### §8.1 DDL strategy
|
|
583
522
|
|
|
584
|
-
No generator.
|
|
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
|
-
-
|
|
587
|
-
-
|
|
588
|
-
-
|
|
589
|
-
-
|
|
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
|
|
595
|
-
-
|
|
596
|
-
- Cross-scope path collision
|
|
597
|
-
- Cost rollups (denormalized
|
|
598
|
-
- Sequence number issuance (1-based per
|
|
599
|
-
- Entry-vs-log integrity
|
|
600
|
-
|
|
601
|
-
**Lives
|
|
602
|
-
-
|
|
603
|
-
- Tokenization (
|
|
604
|
-
- Provider dispatch
|
|
605
|
-
- Scheme-handler invocation (
|
|
606
|
-
- Plugin loading (
|
|
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
|
-
-
|
|
546
|
+
- CLI + daemon.
|
|
609
547
|
|
|
610
|
-
When SQL becomes onerous for a specific case, retreat for that case and document
|
|
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
|
-
|
|
554
|
+
Scoped-package scan with manifest field:
|
|
617
555
|
|
|
618
|
-
1. Each
|
|
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.
|
|
624
|
-
3. Load order: deterministic
|
|
625
|
-
4. Collision on `(kind, name)` is fail-hard
|
|
626
|
-
5.
|
|
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
|
|
566
|
+
Env vars configure installed plugins; never declare existence. Filesystem is the source of truth.
|
|
629
567
|
|
|
630
|
-
|
|
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
|
-
|
|
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
|
-
|
|
578
|
+
**Mimetypes in-tree:** none. Framework + handlers are all siblings.
|
|
642
579
|
|
|
643
|
-
**
|
|
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
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
665
|
-
- AST types
|
|
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
|
-
-
|
|
605
|
+
- `plurnk.md` — canonical model-facing DSL description.
|
|
668
606
|
|
|
669
|
-
### §11.2 What plurnk-service tracks
|
|
607
|
+
### §11.2 What plurnk-service tracks (NOT in grammar)
|
|
670
608
|
|
|
671
|
-
- Channel
|
|
672
|
-
- Render budget per channel — `PLURNK_ENTRY_SIZE_DEFAULT_TOKENS`
|
|
673
|
-
- Backpressure caps — none
|
|
674
|
-
- Stream cancel
|
|
675
|
-
- Delete
|
|
676
|
-
- Per-loop
|
|
677
|
-
-
|
|
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
|
-
|
|
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
|
|
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` |
|
|
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 (
|
|
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** =
|
|
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
|
|
649
|
+
External plugins declare their own env vars in their own `.env.example`; service merges at boot via the cascade.
|
|
712
650
|
|
|
713
|
-
|
|
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
|
-
|
|
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
|
|
726
|
-
|
|
727
|
-
Configuration via §12:
|
|
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`).
|
|
728
662
|
|
|
729
|
-
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
764
|
-
|
|
765
|
-
- `
|
|
766
|
-
- `
|
|
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
|
-
|
|
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
|
|
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 | `{}` |
|
|
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
|
-
|
|
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
|
|
|
@@ -830,51 +752,48 @@ If a client issues a method requiring init (`requiresInit: true`) without first
|
|
|
830
752
|
|
|
831
753
|
| Method | Params | Result | Notes |
|
|
832
754
|
|---------------|-------------------------------------|------------------------|-------|
|
|
833
|
-
| `entry.read` | `
|
|
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
|
|
758
|
+
**DSL operations (client-driven, mirror grammar)**
|
|
837
759
|
|
|
838
|
-
Per the **Speak in DSL, not plumbing** rule (AGENTS.md
|
|
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)
|
|
762
|
+
Each `op.*` call creates a turn in the connection's client loop (§13.7), dispatches, fires `log/entry`, returns the dispatch result.
|
|
763
|
+
|
|
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.
|
|
841
765
|
|
|
842
766
|
| Method | Params | Notes |
|
|
843
767
|
|---------------|---------------------------------------------------------|-------|
|
|
844
768
|
| `op.find` | `scope: string`, `matcher?: string`, `tags?: string[]`, `lineRange?: LineMarker` | Mirrors `<<FIND>>`. |
|
|
845
|
-
| `op.read` | `
|
|
846
|
-
| `op.edit` | `
|
|
769
|
+
| `op.read` | `target: string`, `matcher?: string`, `lineRange?: LineMarker`, `tags?: string[]` | Mirrors `<<READ>>`. |
|
|
770
|
+
| `op.edit` | `target: string`, `content?: string`, `tags?: string[]`, `lineRange?: LineMarker` | Mirrors `<<EDIT>>`. |
|
|
847
771
|
| `op.copy` | `source: string`, `destination: string`, `tags?: string[]`, `lineRange?: LineMarker` | Mirrors `<<COPY>>`. |
|
|
848
772
|
| `op.move` | `source: string`, `destination?: string`, `tags?: string[]`, `lineRange?: LineMarker` | Mirrors `<<MOVE>>`. Missing `destination` = delete (null-body MOVE). |
|
|
849
|
-
| `op.show` | `
|
|
850
|
-
| `op.hide` | `
|
|
773
|
+
| `op.show` | `target: string`, `matcher?: string`, `tags?: string[]`, `lineRange?: LineMarker` | Mirrors `<<SHOW>>`. |
|
|
774
|
+
| `op.hide` | `target: string`, `matcher?: string`, `tags?: string[]`, `lineRange?: LineMarker` | Mirrors `<<HIDE>>`. |
|
|
851
775
|
| `op.send` | `status: number`, `recipient?: string`, `body?: string` | Mirrors `<<SEND>>`. |
|
|
852
776
|
| `op.exec` | `cwd?: string`, `runtime?: string`, `command?: string` | Mirrors `<<EXEC>>`. |
|
|
853
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). |
|
|
854
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[] }`. |
|
|
855
779
|
|
|
856
|
-
All `op.*`
|
|
857
|
-
|
|
858
|
-
Future-reserved (post-v0):
|
|
780
|
+
All `op.*` return `{ status, ...op-specific }`. All `requiresInit: true`. None `longRunning`.
|
|
859
781
|
|
|
860
|
-
|
|
861
|
-
- `subscription.cancel` — analog of `SEND[499]` over RPC, though `op.send({status: 499, recipient: path})` does the same thing today.
|
|
782
|
+
Future: `subscription.list`, `subscription.cancel` (the latter is `op.send({status: 499, recipient})` today).
|
|
862
783
|
|
|
863
784
|
### §13.6 Notifications
|
|
864
785
|
|
|
865
|
-
Server-initiated events
|
|
786
|
+
Server-initiated events on the same WebSocket.
|
|
866
787
|
|
|
867
788
|
| Notification | Params | When fired |
|
|
868
789
|
|--------------------|-------------------------------------|------------|
|
|
869
|
-
| `log/entry` | `{ entry: LogEntry }` | Every
|
|
870
|
-
| `loop/terminated` | `{ loopId, finalStatus, hitMaxTurns }` |
|
|
871
|
-
| `loop/proposal` | `{ logEntryId, sessionId, runId, loopId, turnId, op, target, body, attrs, flags }` |
|
|
872
|
-
| `session/created` | `{ id, name, projectRoot, persona }` |
|
|
873
|
-
| `stream/event` | `{ entryId, channel, state, contentLength }` |
|
|
874
|
-
|
|
875
|
-
The `stream/event` payload deliberately carries metadata, NOT content. Clients that want the new content call `entry.read({path})` 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} |
|
|
876
795
|
|
|
877
|
-
|
|
796
|
+
`stream/event` carries metadata only, never content. Clients fetch via `entry.read({target})`. Notifications are session-scoped.
|
|
878
797
|
|
|
879
798
|
### §13.7 Connection lifecycle
|
|
880
799
|
|
|
@@ -905,13 +824,13 @@ Notifications are scoped to the connection's attached session — a client attac
|
|
|
905
824
|
| (daemon closes the client loop; session keeps)|
|
|
906
825
|
```
|
|
907
826
|
|
|
908
|
-
|
|
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.
|
|
909
828
|
|
|
910
|
-
`loop.run` creates a separate
|
|
829
|
+
`loop.run` creates a separate loop with `origin = "model"` for the model-driven turn sequence. Coexists with client loop in same run.
|
|
911
830
|
|
|
912
831
|
### §13.8 Errors
|
|
913
832
|
|
|
914
|
-
Standard JSON-RPC
|
|
833
|
+
Standard JSON-RPC codes:
|
|
915
834
|
|
|
916
835
|
| Code | Meaning |
|
|
917
836
|
|--------|-------------------------------|
|
|
@@ -921,60 +840,83 @@ Standard JSON-RPC error codes:
|
|
|
921
840
|
| -32602 | Invalid params |
|
|
922
841
|
| -32603 | Internal error |
|
|
923
842
|
|
|
924
|
-
Plurnk-specific
|
|
843
|
+
Plurnk-specific (`-32000` to `-32099`):
|
|
925
844
|
|
|
926
845
|
| Code | Meaning |
|
|
927
846
|
|--------|----------------------------------------------------|
|
|
928
|
-
| -32000 | Not initialized (
|
|
847
|
+
| -32000 | Not initialized (requires session attach) |
|
|
929
848
|
| -32001 | Session not found |
|
|
930
849
|
| -32002 | Loop not found |
|
|
931
|
-
| -32003 | Entry not found (
|
|
850
|
+
| -32003 | Entry not found (engine 404) |
|
|
932
851
|
| -32004 | Provider unavailable |
|
|
933
852
|
| -32005 | Scheme unavailable |
|
|
934
853
|
| -32006 | Mimetype unavailable |
|
|
935
854
|
| -32007 | Timeout |
|
|
936
855
|
|
|
937
|
-
Error responses MAY include `data: {
|
|
856
|
+
Error responses MAY include `data: {…}` with structured context (404'd path, timed-out method, etc.).
|
|
938
857
|
|
|
939
858
|
### §13.9 Versioning
|
|
940
859
|
|
|
941
|
-
Pre-
|
|
860
|
+
Pre-stabilization. Clients track HEAD. No semver until the interface is worth committing to.
|
|
942
861
|
|
|
943
862
|
---
|
|
944
863
|
|
|
945
864
|
## §14 Architectural decisions
|
|
946
865
|
|
|
947
|
-
Each entry:
|
|
866
|
+
Each entry: question, answer, rationale, migration path.
|
|
948
867
|
|
|
949
868
|
### §14.1 Packet assembly: engine-direct, not filter-chain
|
|
950
869
|
|
|
951
|
-
**Question.** Rummy
|
|
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:
|
|
952
885
|
|
|
953
|
-
|
|
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.
|
|
954
892
|
|
|
955
|
-
**Rationale.**
|
|
956
|
-
- Plurnk's bundled extension set is small (3 entry-bearing schemes, 2 mimetypes, 1 provider). No current plugin wants to inject a packet section.
|
|
957
|
-
- 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.
|
|
958
|
-
- 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.
|
|
959
|
-
- 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.
|
|
960
894
|
|
|
961
|
-
**Migration path
|
|
895
|
+
**Migration path.** If `countTokens` cost becomes prohibitive, retreat to chars/DIVISOR per provider, async-refined to real count when budget permits. Schema unchanged.
|
|
962
896
|
|
|
963
|
-
### §14.
|
|
897
|
+
### §14.3 Workspace identity, membership, disk co-location
|
|
964
898
|
|
|
965
|
-
**Question.**
|
|
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?
|
|
966
900
|
|
|
967
|
-
**Decision.**
|
|
901
|
+
**Decision lattice.** {§14.3-workspace-phase-f}
|
|
968
902
|
|
|
969
|
-
**
|
|
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.
|
|
970
910
|
|
|
971
|
-
**
|
|
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.
|
|
972
914
|
|
|
973
915
|
---
|
|
974
916
|
|
|
975
917
|
## §15 Packet shape
|
|
976
918
|
|
|
977
|
-
|
|
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.
|
|
978
920
|
|
|
979
921
|
```ts
|
|
980
922
|
type Packet = {
|
|
@@ -990,33 +932,203 @@ type Packet = {
|
|
|
990
932
|
tokens: number;
|
|
991
933
|
prompt: string;
|
|
992
934
|
telemetry: { budget: string; errors: object[] }; // §15.1
|
|
993
|
-
system_requirements: string;
|
|
935
|
+
system_requirements: string; // §15.2
|
|
994
936
|
};
|
|
995
937
|
assistant: { tokens: number; content: string; ops: PlurnkStatement[]; reasoning: string | null };
|
|
996
938
|
assistantRaw: unknown;
|
|
997
939
|
};
|
|
998
940
|
```
|
|
999
941
|
|
|
1000
|
-
**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
|
|
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}
|
|
1001
943
|
|
|
1002
944
|
### §15.1 user.telemetry — model-facing runtime telemetry
|
|
1003
945
|
|
|
1004
|
-
|
|
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**.
|
|
947
|
+
|
|
948
|
+
**Grammar contract:**
|
|
949
|
+
|
|
950
|
+
- `budget: string` — text/markdown. Empty when nothing to surface.
|
|
951
|
+
- `errors: object[]` — shape open at v0.
|
|
952
|
+
|
|
953
|
+
**Plurnk-service rendering:**
|
|
954
|
+
|
|
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.
|
|
959
|
+
|
|
960
|
+
**Kinds emitted by plurnk-service:**
|
|
961
|
+
|
|
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}
|
|
1005
1117
|
|
|
1006
|
-
|
|
1118
|
+
### §16.9 Op-level invariants and resolved ambiguities
|
|
1007
1119
|
|
|
1008
|
-
|
|
1009
|
-
- `errors: object[]` — element shape intentionally open at v0. Consumers populate as actionless-failure rendering needs solidify. Empty array when no errors to surface.
|
|
1120
|
+
Carried from the contract walk; durable.
|
|
1010
1121
|
|
|
1011
|
-
**
|
|
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.
|
|
1012
1131
|
|
|
1013
|
-
|
|
1014
|
-
- `errors[]` carries one object per actionless failure from the previous turn. Service's working element shape (subject to tightening as needs solidify):
|
|
1015
|
-
```
|
|
1016
|
-
{ kind: "parse" | "dispatch_crash" | "no_send" | "watchdog" | "budget_overflow" | "rail",
|
|
1017
|
-
message: string,
|
|
1018
|
-
detail?: unknown }
|
|
1019
|
-
```
|
|
1020
|
-
- Action-bound failures (handler returned 4xx/5xx or threw) are mirrored as a one-line summary object into `telemetry.errors[]` 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
|
|
1021
1133
|
|
|
1022
|
-
|
|
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.
|