@plurnk/plurnk-service 0.1.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/LICENSE +21 -0
- package/README.md +54 -0
- package/SPEC.md +1045 -0
- package/bin/plurnk-service.js +111 -0
- package/dist/core/ChannelWrite.d.ts +40 -0
- package/dist/core/ChannelWrite.d.ts.map +1 -0
- package/dist/core/ChannelWrite.js +49 -0
- package/dist/core/ChannelWrite.js.map +1 -0
- package/dist/core/Db.d.ts +14 -0
- package/dist/core/Db.d.ts.map +1 -0
- package/dist/core/Db.js +7 -0
- package/dist/core/Db.js.map +1 -0
- package/dist/core/Engine.d.ts +107 -0
- package/dist/core/Engine.d.ts.map +1 -0
- package/dist/core/Engine.js +937 -0
- package/dist/core/Engine.js.map +1 -0
- package/dist/core/EnvFlags.d.ts +10 -0
- package/dist/core/EnvFlags.d.ts.map +1 -0
- package/dist/core/EnvFlags.js +82 -0
- package/dist/core/EnvFlags.js.map +1 -0
- package/dist/core/Migrator.d.ts +19 -0
- package/dist/core/Migrator.d.ts.map +1 -0
- package/dist/core/Migrator.js +55 -0
- package/dist/core/Migrator.js.map +1 -0
- package/dist/core/PluginLoader.d.ts +14 -0
- package/dist/core/PluginLoader.d.ts.map +1 -0
- package/dist/core/PluginLoader.js +100 -0
- package/dist/core/PluginLoader.js.map +1 -0
- package/dist/core/ProviderRegistry.d.ts +42 -0
- package/dist/core/ProviderRegistry.d.ts.map +1 -0
- package/dist/core/ProviderRegistry.js +72 -0
- package/dist/core/ProviderRegistry.js.map +1 -0
- package/dist/core/SchemeRegistry.d.ts +13 -0
- package/dist/core/SchemeRegistry.d.ts.map +1 -0
- package/dist/core/SchemeRegistry.js +50 -0
- package/dist/core/SchemeRegistry.js.map +1 -0
- package/dist/core/scheme-types.d.ts +37 -0
- package/dist/core/scheme-types.d.ts.map +1 -0
- package/dist/core/scheme-types.js +9 -0
- package/dist/core/scheme-types.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +51 -0
- package/dist/index.js.map +1 -0
- package/dist/providers/Mock.d.ts +43 -0
- package/dist/providers/Mock.d.ts.map +1 -0
- package/dist/providers/Mock.js +36 -0
- package/dist/providers/Mock.js.map +1 -0
- package/dist/schemes/Exec.d.ts +5 -0
- package/dist/schemes/Exec.d.ts.map +1 -0
- package/dist/schemes/Exec.js +20 -0
- package/dist/schemes/Exec.js.map +1 -0
- package/dist/schemes/File.d.ts +35 -0
- package/dist/schemes/File.d.ts.map +1 -0
- package/dist/schemes/File.js +149 -0
- package/dist/schemes/File.js.map +1 -0
- package/dist/schemes/Known.d.ts +19 -0
- package/dist/schemes/Known.d.ts.map +1 -0
- package/dist/schemes/Known.js +44 -0
- package/dist/schemes/Known.js.map +1 -0
- package/dist/schemes/Log.d.ts +13 -0
- package/dist/schemes/Log.d.ts.map +1 -0
- package/dist/schemes/Log.js +38 -0
- package/dist/schemes/Log.js.map +1 -0
- package/dist/schemes/Plurnk.d.ts +5 -0
- package/dist/schemes/Plurnk.d.ts.map +1 -0
- package/dist/schemes/Plurnk.js +15 -0
- package/dist/schemes/Plurnk.js.map +1 -0
- package/dist/schemes/Skill.d.ts +19 -0
- package/dist/schemes/Skill.d.ts.map +1 -0
- package/dist/schemes/Skill.js +46 -0
- package/dist/schemes/Skill.js.map +1 -0
- package/dist/schemes/Unknown.d.ts +19 -0
- package/dist/schemes/Unknown.d.ts.map +1 -0
- package/dist/schemes/Unknown.js +44 -0
- package/dist/schemes/Unknown.js.map +1 -0
- package/dist/schemes/_entry-crud.d.ts +24 -0
- package/dist/schemes/_entry-crud.d.ts.map +1 -0
- package/dist/schemes/_entry-crud.js +56 -0
- package/dist/schemes/_entry-crud.js.map +1 -0
- package/dist/schemes/_entry-find.d.ts +10 -0
- package/dist/schemes/_entry-find.d.ts.map +1 -0
- package/dist/schemes/_entry-find.js +41 -0
- package/dist/schemes/_entry-find.js.map +1 -0
- package/dist/schemes/_entry-ops.d.ts +21 -0
- package/dist/schemes/_entry-ops.d.ts.map +1 -0
- package/dist/schemes/_entry-ops.js +146 -0
- package/dist/schemes/_entry-ops.js.map +1 -0
- package/dist/schemes/_entry-send.d.ts +8 -0
- package/dist/schemes/_entry-send.d.ts.map +1 -0
- package/dist/schemes/_entry-send.js +56 -0
- package/dist/schemes/_entry-send.js.map +1 -0
- package/dist/server/ClientConnection.d.ts +22 -0
- package/dist/server/ClientConnection.d.ts.map +1 -0
- package/dist/server/ClientConnection.js +120 -0
- package/dist/server/ClientConnection.js.map +1 -0
- package/dist/server/Daemon.d.ts +43 -0
- package/dist/server/Daemon.d.ts.map +1 -0
- package/dist/server/Daemon.js +252 -0
- package/dist/server/Daemon.js.map +1 -0
- package/dist/server/MethodRegistry.d.ts +56 -0
- package/dist/server/MethodRegistry.d.ts.map +1 -0
- package/dist/server/MethodRegistry.js +55 -0
- package/dist/server/MethodRegistry.js.map +1 -0
- package/dist/server/clientTurn.d.ts +3 -0
- package/dist/server/clientTurn.d.ts.map +1 -0
- package/dist/server/clientTurn.js +22 -0
- package/dist/server/clientTurn.js.map +1 -0
- package/dist/server/dsl.d.ts +47 -0
- package/dist/server/dsl.d.ts.map +1 -0
- package/dist/server/dsl.js +117 -0
- package/dist/server/dsl.js.map +1 -0
- package/dist/server/envelope.d.ts +44 -0
- package/dist/server/envelope.d.ts.map +1 -0
- package/dist/server/envelope.js +113 -0
- package/dist/server/envelope.js.map +1 -0
- package/dist/server/logEntry.d.ts +30 -0
- package/dist/server/logEntry.d.ts.map +1 -0
- package/dist/server/logEntry.js +43 -0
- package/dist/server/logEntry.js.map +1 -0
- package/dist/server/methods/_dispatchAsClient.d.ts +8 -0
- package/dist/server/methods/_dispatchAsClient.d.ts.map +1 -0
- package/dist/server/methods/_dispatchAsClient.js +31 -0
- package/dist/server/methods/_dispatchAsClient.js.map +1 -0
- package/dist/server/methods/discover.d.ts +3 -0
- package/dist/server/methods/discover.d.ts.map +1 -0
- package/dist/server/methods/discover.js +7 -0
- package/dist/server/methods/discover.js.map +1 -0
- package/dist/server/methods/entry_read.d.ts +3 -0
- package/dist/server/methods/entry_read.d.ts.map +1 -0
- package/dist/server/methods/entry_read.js +56 -0
- package/dist/server/methods/entry_read.js.map +1 -0
- package/dist/server/methods/log_read.d.ts +3 -0
- package/dist/server/methods/log_read.d.ts.map +1 -0
- package/dist/server/methods/log_read.js +39 -0
- package/dist/server/methods/log_read.js.map +1 -0
- package/dist/server/methods/loop_resolve.d.ts +3 -0
- package/dist/server/methods/loop_resolve.d.ts.map +1 -0
- package/dist/server/methods/loop_resolve.js +45 -0
- package/dist/server/methods/loop_resolve.js.map +1 -0
- package/dist/server/methods/loop_run.d.ts +3 -0
- package/dist/server/methods/loop_run.d.ts.map +1 -0
- package/dist/server/methods/loop_run.js +112 -0
- package/dist/server/methods/loop_run.js.map +1 -0
- package/dist/server/methods/op_copy.d.ts +3 -0
- package/dist/server/methods/op_copy.d.ts.map +1 -0
- package/dist/server/methods/op_copy.js +24 -0
- package/dist/server/methods/op_copy.js.map +1 -0
- package/dist/server/methods/op_dispatch.d.ts +3 -0
- package/dist/server/methods/op_dispatch.d.ts.map +1 -0
- package/dist/server/methods/op_dispatch.js +17 -0
- package/dist/server/methods/op_dispatch.js.map +1 -0
- package/dist/server/methods/op_edit.d.ts +3 -0
- package/dist/server/methods/op_edit.d.ts.map +1 -0
- package/dist/server/methods/op_edit.js +22 -0
- package/dist/server/methods/op_edit.js.map +1 -0
- package/dist/server/methods/op_exec.d.ts +3 -0
- package/dist/server/methods/op_exec.d.ts.map +1 -0
- package/dist/server/methods/op_exec.js +19 -0
- package/dist/server/methods/op_exec.js.map +1 -0
- package/dist/server/methods/op_find.d.ts +3 -0
- package/dist/server/methods/op_find.d.ts.map +1 -0
- package/dist/server/methods/op_find.js +22 -0
- package/dist/server/methods/op_find.js.map +1 -0
- package/dist/server/methods/op_hide.d.ts +3 -0
- package/dist/server/methods/op_hide.d.ts.map +1 -0
- package/dist/server/methods/op_hide.js +22 -0
- package/dist/server/methods/op_hide.js.map +1 -0
- package/dist/server/methods/op_move.d.ts +3 -0
- package/dist/server/methods/op_move.d.ts.map +1 -0
- package/dist/server/methods/op_move.js +22 -0
- package/dist/server/methods/op_move.js.map +1 -0
- package/dist/server/methods/op_parse.d.ts +3 -0
- package/dist/server/methods/op_parse.d.ts.map +1 -0
- package/dist/server/methods/op_parse.js +23 -0
- package/dist/server/methods/op_parse.js.map +1 -0
- package/dist/server/methods/op_read.d.ts +3 -0
- package/dist/server/methods/op_read.d.ts.map +1 -0
- package/dist/server/methods/op_read.js +22 -0
- package/dist/server/methods/op_read.js.map +1 -0
- package/dist/server/methods/op_send.d.ts +3 -0
- package/dist/server/methods/op_send.d.ts.map +1 -0
- package/dist/server/methods/op_send.js +21 -0
- package/dist/server/methods/op_send.js.map +1 -0
- package/dist/server/methods/op_show.d.ts +3 -0
- package/dist/server/methods/op_show.d.ts.map +1 -0
- package/dist/server/methods/op_show.js +22 -0
- package/dist/server/methods/op_show.js.map +1 -0
- package/dist/server/methods/ping.d.ts +3 -0
- package/dist/server/methods/ping.d.ts.map +1 -0
- package/dist/server/methods/ping.js +7 -0
- package/dist/server/methods/ping.js.map +1 -0
- package/dist/server/methods/providers_list.d.ts +3 -0
- package/dist/server/methods/providers_list.d.ts.map +1 -0
- package/dist/server/methods/providers_list.js +19 -0
- package/dist/server/methods/providers_list.js.map +1 -0
- package/dist/server/methods/session_attach.d.ts +3 -0
- package/dist/server/methods/session_attach.d.ts.map +1 -0
- package/dist/server/methods/session_attach.js +42 -0
- package/dist/server/methods/session_attach.js.map +1 -0
- package/dist/server/methods/session_create.d.ts +3 -0
- package/dist/server/methods/session_create.d.ts.map +1 -0
- package/dist/server/methods/session_create.js +53 -0
- package/dist/server/methods/session_create.js.map +1 -0
- package/dist/server/methods/session_list.d.ts +3 -0
- package/dist/server/methods/session_list.d.ts.map +1 -0
- package/dist/server/methods/session_list.js +8 -0
- package/dist/server/methods/session_list.js.map +1 -0
- package/dist/server/methods/session_runs.d.ts +3 -0
- package/dist/server/methods/session_runs.d.ts.map +1 -0
- package/dist/server/methods/session_runs.js +18 -0
- package/dist/server/methods/session_runs.js.map +1 -0
- package/dist/server/methods/session_set_persona.d.ts +3 -0
- package/dist/server/methods/session_set_persona.d.ts.map +1 -0
- package/dist/server/methods/session_set_persona.js +27 -0
- package/dist/server/methods/session_set_persona.js.map +1 -0
- package/dist/server/methods/session_set_root.d.ts +3 -0
- package/dist/server/methods/session_set_root.d.ts.map +1 -0
- package/dist/server/methods/session_set_root.js +33 -0
- package/dist/server/methods/session_set_root.js.map +1 -0
- package/dist/server/yolo.d.ts +4 -0
- package/dist/server/yolo.d.ts.map +1 -0
- package/dist/server/yolo.js +48 -0
- package/dist/server/yolo.js.map +1 -0
- package/migrations/001_sessions.sql +11 -0
- package/migrations/002_runs.sql +16 -0
- package/migrations/003_loops.sql +12 -0
- package/migrations/004_turns.sql +18 -0
- package/migrations/005_entries.sql +44 -0
- package/migrations/006_log_entries.sql +51 -0
- package/migrations/007_visibility.sql +10 -0
- package/migrations/008_schemes_providers.sql +25 -0
- package/migrations/009_cost_rollups.sql +44 -0
- package/migrations/010_subscriptions.sql +36 -0
- package/migrations/011_turn_call_metadata.sql +7 -0
- package/migrations/012_proposal_lifecycle.sql +22 -0
- package/migrations/013_log_entries_lifecycle_columns.sql +22 -0
- package/migrations/014_loop_flags.sql +9 -0
- package/migrations/015_sessions_project_root.sql +6 -0
- package/migrations/016_persona_columns.sql +11 -0
- package/package.json +66 -0
- package/persona.md +1 -0
package/SPEC.md
ADDED
|
@@ -0,0 +1,1045 @@
|
|
|
1
|
+
# plurnk-service — Specification
|
|
2
|
+
|
|
3
|
+
Single source of truth for what plurnk-service IS — the contracts it exposes, the architecture it implements, the promises it makes to the rest of the constellation (`plurnk-grammar`, `plurnk-providers-*`, `plurnk-schemes-*`, `plurnk-mimetypes-*`, the user-facing `plurnk` CLI). `AGENTS.md` covers how we work on it; this file covers what we're working on.
|
|
4
|
+
|
|
5
|
+
Section numbers are stable. Future anchor-to-test wiring binds individual promises here to integration / live / demo tests, giving semi-deterministic specification-testing alignment.
|
|
6
|
+
|
|
7
|
+
Floor scope is green (capstone intg test exercises every non-EXEC DSL op end-to-end). Post-floor work landed in #123: PlurnkSchemeContext (#33), the engine rails (#38–#41), packet shape (§15), single-cap constraint (§7.8), ProviderRegistry (#58), and first sibling-package extraction (#47). This document evolves with each phase; the next refinements will come from Phase C (mimetype integration story) and Phase F (exec + streams).
|
|
8
|
+
|
|
9
|
+
**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.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## §0 Glossary
|
|
14
|
+
|
|
15
|
+
Canonical meanings. When a doc, comment, test name, or commit message uses one of these words, it means exactly what's written here. Drift is a bug.
|
|
16
|
+
|
|
17
|
+
### §0.1 Lifecycle terms
|
|
18
|
+
|
|
19
|
+
| Term | Meaning |
|
|
20
|
+
|---|---|
|
|
21
|
+
| **agent** | The plurnk runtime singleton. Owns agent-scoped state (default scheme registry, agent-wide entries). One per process. |
|
|
22
|
+
| **session** | Durable user-named workspace. Persists across runs and process restarts. Identity: `sessions.id` + unique `sessions.name`. |
|
|
23
|
+
| **run** | A stretch of work within a session. Multiple runs per session. May fork from another run via `parent_run_id`. Owns visibility state and log entries. |
|
|
24
|
+
| **loop** | One model-driven or client-driven iteration within a run. Status ∈ {102, 200, 499}. Many loops per run. The model runs inside a loop; each client RPC has its own loop. |
|
|
25
|
+
| **turn** | One round-trip with the LLM (or one client RPC dispatch). One assembled prompt sent, one parsed response handled. Many turns per loop. Identity: `(loop_id, sequence)`. |
|
|
26
|
+
| **op** | One DSL operation the model emits. Parsed into a `PlurnkStatement`. Examples: `EDIT`, `READ`, `SEND`, `FIND`, `COPY`, `MOVE`, `SHOW`, `HIDE`, `EXEC`. One turn produces zero or more ops. |
|
|
27
|
+
| **statement** | Synonym for parsed op. The AST shape `PlurnkStatement` from `@plurnk/plurnk-grammar`. |
|
|
28
|
+
| **action** | One executed op. Action and op are the same thing in different states (op = parsed; action = executed). The execution produces a log_entries row at `log://<L>/<T>/<S>/<op>`. |
|
|
29
|
+
| **dispatch** | The engine routing a statement to its scheme's op handler. |
|
|
30
|
+
|
|
31
|
+
### §0.2 Storage terms
|
|
32
|
+
|
|
33
|
+
| Term | Meaning |
|
|
34
|
+
|---|---|
|
|
35
|
+
| **entry** | The unit of canonical state. Identity: `(scope, scheme, pathname)`. Holds one or more `channels` of content plus `tags` and `attributes`. |
|
|
36
|
+
| **channel** | A named content buffer on an entry. Examples: `body`, `preview`, `stdout`, `stderr`, `headers`, `symbols`. Each channel has `content`, `mimetype`, `tokens`, `state`. |
|
|
37
|
+
| **scope** | `"agent"` or `"session"`. Determines who reads. Agent-scope entries visible to every run; session-scope entries to that session's runs. |
|
|
38
|
+
| **scheme** | A URI prefix + handler. `known`, `unknown`, `file`, `https`, `exec`. The scheme handler interprets paths under its prefix and implements the op surface. See SCHEMES.md. |
|
|
39
|
+
| **mimetype** | A channel's content type. Drives the render-time handler that produces `preview`/`symbols`. See MIMETYPES.md. |
|
|
40
|
+
| **provider** | An LLM transport. Implements `generate({messages, signal})` against a wire protocol. See PROVIDERS.md. |
|
|
41
|
+
|
|
42
|
+
### §0.3 State / visibility / status
|
|
43
|
+
|
|
44
|
+
Three independent axes on entries and channels. Confusion across them is a recurring source of bugs.
|
|
45
|
+
|
|
46
|
+
| Term | Type | Meaning |
|
|
47
|
+
|---|---|---|
|
|
48
|
+
| **status** | HTTP int | Outcome of an operation. Carried on `log_entries.status_rx`, returned from op handlers. Per the catalogue (§3.5). |
|
|
49
|
+
| **visibility** | `0 \| 1` | Per-`(run, entry, channel)` bit. `1 = indexed` (appears in `packet.system.index`), `0 = hidden` (not rendered, recallable via explicit READ). |
|
|
50
|
+
| **channel state** | `static \| active \| closed \| errored` | Streaming lifecycle of a channel's content. Metadata, not gating — engine renders content regardless of state. |
|
|
51
|
+
| **entry state** | `proposed \| resolved \| cancelled` | Proposal lifecycle. `proposed` = pending client accept; `resolved` = side effect happened; `cancelled` = client rejected. Distinct from channel state. |
|
|
52
|
+
| **outcome** | `string \| null` | Short reason for `failed`/`cancelled` (`"permission:403"`, `"aborted"`, `"not_found"`). Opaque to most callers. |
|
|
53
|
+
|
|
54
|
+
### §0.4 Writer / authority
|
|
55
|
+
|
|
56
|
+
| Term | Meaning |
|
|
57
|
+
|---|---|
|
|
58
|
+
| **writer** | The identity authoring a write. One of `model \| client \| system \| plugin`. Carried on `ctx.writer` for schemes; engine enforces `manifest.writableBy`. |
|
|
59
|
+
| **origin** | Synonym for writer in log_entries (`log_entries.origin`). Historical naming; treat as equivalent. |
|
|
60
|
+
| **writable_by** | The set of writers a scheme accepts. Subset of `{model, client, system, plugin}`. Engine rejects writes outside the set with 403; the rejection is logged as the action-entry (§7.1 action-entry-as-outcome). |
|
|
61
|
+
|
|
62
|
+
### §0.5 Engine rails
|
|
63
|
+
|
|
64
|
+
| Term | Meaning |
|
|
65
|
+
|---|---|
|
|
66
|
+
| **verdict** | End-of-turn ruling from the verdict filter chain. Returns `{continue: boolean, status: number, reason: string}`. Decides whether the loop terminates or another turn fires. |
|
|
67
|
+
| **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. |
|
|
68
|
+
| **cycle** | A repeated turn fingerprint across consecutive turns. Detected silently; model never sees the trigger. Strike accumulates internally. |
|
|
69
|
+
| **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. |
|
|
70
|
+
| **mode** | `"ask" \| "act"`. Per-loop. Ask = read-only (no side-effecting ops); act = full surface. |
|
|
71
|
+
| **flag** | Per-loop boolean shaping the active toolset: `yolo` (auto-accept proposals), `noWeb`, `noInteraction`, `noProposals`. |
|
|
72
|
+
| **proposal** | A deferred side-effecting action awaiting client accept/reject. State machine: `proposed → resolved` or `proposed → cancelled`. `yolo` short-circuits to immediate. |
|
|
73
|
+
| **resolution** | Client's accept/reject of a proposal via `op.resolve` RPC. |
|
|
74
|
+
|
|
75
|
+
### §0.6 Packet terms
|
|
76
|
+
|
|
77
|
+
| Term | Meaning |
|
|
78
|
+
|---|---|
|
|
79
|
+
| **packet** | The turn's full exchange shape: `{system, user, assistant, assistantRaw}`. Persisted on `turns.packet`. |
|
|
80
|
+
| **index** | `packet.system.index`. Entry list visible to the model this turn. Built from `visibility` lattice + mimetype.preview. |
|
|
81
|
+
| **log** | `packet.system.log`. Chronological list of `log_entries` in scope this turn. |
|
|
82
|
+
| **render** | The act of computing the packet from current DB state at turn boundaries. Mimetype handlers fire at render time. |
|
|
83
|
+
|
|
84
|
+
### §0.7 Test taxonomy
|
|
85
|
+
|
|
86
|
+
| Tier | Location | LLM | Substrate |
|
|
87
|
+
|---|---|---|---|
|
|
88
|
+
| **unit** | `src/**/*.test.ts` | No | Isolated logic, mocked boundaries |
|
|
89
|
+
| **intg** | `test/intg/` | No (mock provider) | Real in-memory SqlRite, real engine |
|
|
90
|
+
| **live** | `test/live/` | Real | Wire-level assertions |
|
|
91
|
+
| **demo** | `test/demo/` | Real | Holistic outcome assertions |
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## §1 Architecture
|
|
96
|
+
|
|
97
|
+
`plurnk-service` is an engine library plus an admin CLI. The engine orchestrates a model's interaction with a workspace through three plug points:
|
|
98
|
+
|
|
99
|
+
- **Providers** (§2) — LLM transports. The engine asks a provider for the next assistant turn; the provider returns parsed plurnk ops.
|
|
100
|
+
- **Schemes** (§3) — addressable resources. Every plurnk op targets a URI; the URI's scheme picks the handler. Schemes own their storage substrate (SQL entries for `known`/`unknown`/`skill`; filesystem for `file`; subprocess for `exec`; remote endpoints for future `http`/`ws`/`sse`/etc.).
|
|
101
|
+
- **Mimetypes** (§4) — content interpretation. Schemes produce content with a declared mimetype; mimetype handlers validate, summarize, and render that content for the model's index view.
|
|
102
|
+
|
|
103
|
+
The engine itself is small. It dispatches ops, persists state to SQLite, orchestrates cross-scheme operations (COPY/MOVE — see §6.4/§6.5), and writes the log. All substantive behavior lives in the three plug points.
|
|
104
|
+
|
|
105
|
+
The grammar — `@plurnk/plurnk-grammar` — is the parser and AST contract. Engine receives parsed `PlurnkStatement[]` from providers; engine does not re-parse. Schemes receive statement fragments via dispatch.
|
|
106
|
+
|
|
107
|
+
Client/server posture: this package is the server / core / agent runtime. The user-facing CLI lives in a separate `plurnk` repo and consumes plurnk-service through its public library API (`src/index.ts` + `PATHS` helper).
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## §2 Provider Contract
|
|
112
|
+
|
|
113
|
+
A provider transports model interactions. It exposes one required method (generate) and one mandatory token-counting method. Every `@plurnk/plurnk-providers-*` package implements this contract.
|
|
114
|
+
|
|
115
|
+
### §2.1 Manifest
|
|
116
|
+
|
|
117
|
+
Each provider package declares itself in its `package.json`:
|
|
118
|
+
|
|
119
|
+
```json
|
|
120
|
+
{
|
|
121
|
+
"name": "@plurnk/plurnk-providers-<name>",
|
|
122
|
+
"plurnk": { "kind": "provider", "name": "<name>" }
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
`name` is the lookup key. Collisions on `(kind, name)` are fail-hard at boot per §9.
|
|
127
|
+
|
|
128
|
+
### §2.2 generate(args) — required
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
type ChatMessage = { role: "system" | "user" | "assistant"; content: string };
|
|
132
|
+
|
|
133
|
+
type ProviderResponse = {
|
|
134
|
+
assistant: {
|
|
135
|
+
tokens: number; // completion-token count for this turn
|
|
136
|
+
content: string; // raw text from the model
|
|
137
|
+
ops: PlurnkStatement[]; // parsed plurnk ops; see grammar AST
|
|
138
|
+
reasoning: string | null; // thinking/reasoning payload if the model emitted one
|
|
139
|
+
};
|
|
140
|
+
assistantRaw: unknown; // provider's raw wire response for forensic capture
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
generate({ messages, signal? }: { messages: ChatMessage[]; signal?: AbortSignal }): Promise<ProviderResponse>;
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Promises:
|
|
147
|
+
- 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`.
|
|
148
|
+
- `assistant.tokens` is the completion token count for THIS turn — used for cost accounting and packet token tracking.
|
|
149
|
+
- `assistantRaw` preserves the wire response verbatim for log forensics. Schema-free; provider's discretion.
|
|
150
|
+
- If `signal` aborts, the provider MUST tear down its connection and reject the promise. AbortController lifecycle is the provider's responsibility.
|
|
151
|
+
|
|
152
|
+
### §2.3 countTokens(text) — required
|
|
153
|
+
|
|
154
|
+
```ts
|
|
155
|
+
countTokens(text: string): Promise<number>;
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Promises:
|
|
159
|
+
- Returns the token count for `text` according to the active model's tokenizer.
|
|
160
|
+
- The engine treats this as authoritative — there is no fallback estimator at the engine layer.
|
|
161
|
+
- Provider implementations MAY cache by content hash; the engine ALSO caches by `(provider_id, content_hash)` in a `token_counts` table.
|
|
162
|
+
- Mock provider returns a deterministic length-based estimate (e.g., `len/4`) sufficient for integration tests.
|
|
163
|
+
|
|
164
|
+
### §2.4 Cancellation
|
|
165
|
+
|
|
166
|
+
Every provider call accepts an optional `AbortSignal`. When aborted:
|
|
167
|
+
- The provider's outbound connection MUST tear down.
|
|
168
|
+
- The promise MUST reject (not resolve with partial content).
|
|
169
|
+
- Any provider-internal streams MUST close cleanly.
|
|
170
|
+
|
|
171
|
+
---
|
|
172
|
+
|
|
173
|
+
## §3 Scheme Contract
|
|
174
|
+
|
|
175
|
+
A scheme is an addressable resource handler. Every URI in plurnk has a scheme prefix (`known://`, `file://`, `https://`, etc.); the scheme handler interprets paths under that prefix.
|
|
176
|
+
|
|
177
|
+
Every `@plurnk/plurnk-schemes-*` package implements this contract.
|
|
178
|
+
|
|
179
|
+
### §3.1 Manifest
|
|
180
|
+
|
|
181
|
+
Each scheme package declares itself in its `package.json`:
|
|
182
|
+
|
|
183
|
+
```json
|
|
184
|
+
{
|
|
185
|
+
"name": "@plurnk/plurnk-schemes-<name>",
|
|
186
|
+
"plurnk": { "kind": "scheme", "name": "<name>" }
|
|
187
|
+
}
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
The class declares its full manifest as a static field (per `SchemeManifest` in `src/core/scheme-types.ts`):
|
|
191
|
+
|
|
192
|
+
```ts
|
|
193
|
+
class Known {
|
|
194
|
+
static manifest: SchemeManifest = {
|
|
195
|
+
name: "known",
|
|
196
|
+
channels: { body: "text/markdown", preview: "text/markdown" },
|
|
197
|
+
defaultChannel: "body",
|
|
198
|
+
category: "data",
|
|
199
|
+
scope: "session",
|
|
200
|
+
writableBy: ["model", "client"],
|
|
201
|
+
volatile: false,
|
|
202
|
+
modelVisible: true,
|
|
203
|
+
flags: { /* optional flag affinity per LoopFlags */ },
|
|
204
|
+
};
|
|
205
|
+
// ...
|
|
206
|
+
}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
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).
|
|
210
|
+
|
|
211
|
+
`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.
|
|
212
|
+
|
|
213
|
+
Identity-match is enforced at plugin load: `manifest.name` must equal the `plurnk.name` in `package.json` (PluginLoader.assertIdentityMatch).
|
|
214
|
+
|
|
215
|
+
### §3.2 CRUD Primitives (uniform across schemes)
|
|
216
|
+
|
|
217
|
+
The canonical surface a scheme exposes for engine orchestration. Every entry-bearing scheme MUST implement these three methods. The engine uses them to drive cross-scheme operations (§6.4 COPY, §6.5 MOVE) and the SEND[410] delete pattern (§6.8).
|
|
218
|
+
|
|
219
|
+
```ts
|
|
220
|
+
type EntryData = {
|
|
221
|
+
channels: Record<string, { content: string; mimetype: string }>;
|
|
222
|
+
tags: string[];
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
readEntry(pathname: string, ctx: PlurnkSchemeContext): Promise<ReadEntryResult>;
|
|
226
|
+
writeEntry(pathname: string, entry: EntryData, ctx: PlurnkSchemeContext): Promise<WriteEntryResult>;
|
|
227
|
+
deleteEntry(pathname: string, ctx: PlurnkSchemeContext): Promise<DeleteEntryResult>;
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
Where `PlurnkSchemeContext` is the per-call helper bundling `{ db, sessionId, runId, loopId, turnId, writer, signal }` (per `scheme-types.ts`). v0 ctx exposes `db` directly; the namespaced surface described in SCHEMES.md §4 (entries / channels / visibility / tags / subscriptions / proposals / crossScheme / notify) lands in v1 when third-party plugin schemes are an actual concern.
|
|
231
|
+
|
|
232
|
+
Promises:
|
|
233
|
+
- `readEntry` returns the full entry shape at `pathname` or `{ status: 404, entry: null }`.
|
|
234
|
+
- `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.
|
|
235
|
+
- `deleteEntry` removes the entry. Returns 200 on success, 404 if absent.
|
|
236
|
+
- 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).
|
|
237
|
+
- Atomicity: each method is a single SQL transaction. Engine orchestration (COPY = read + write) is responsible for its own outer transaction.
|
|
238
|
+
|
|
239
|
+
### §3.3 Op Methods (layered over CRUD)
|
|
240
|
+
|
|
241
|
+
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`.
|
|
242
|
+
|
|
243
|
+
```ts
|
|
244
|
+
edit(statement: EditStatement, ctx: PlurnkSchemeContext): Promise<EditResult>;
|
|
245
|
+
read(statement: ReadStatement, ctx: PlurnkSchemeContext): Promise<ReadResult>;
|
|
246
|
+
show(statement: ShowStatement | HideStatement, ctx): Promise<ShowHideResult>;
|
|
247
|
+
hide(statement: ShowStatement | HideStatement, ctx): Promise<ShowHideResult>;
|
|
248
|
+
find(statement: FindStatement, ctx: PlurnkSchemeContext): Promise<FindResult>;
|
|
249
|
+
send(statement: SendStatement, ctx: PlurnkSchemeContext): Promise<SendResult>;
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
COPY and MOVE are NOT scheme methods. They are engine orchestrations over CRUD primitives (§6.4, §6.5).
|
|
253
|
+
|
|
254
|
+
### §3.4 Cross-scheme orchestration
|
|
255
|
+
|
|
256
|
+
The engine — not any individual scheme — handles cross-scheme COPY and MOVE:
|
|
257
|
+
|
|
258
|
+
```
|
|
259
|
+
copy(source_path, dest_path, signal_tags, ctx):
|
|
260
|
+
src_scheme = scheme_for(source_path)
|
|
261
|
+
dst_scheme = scheme_for(dest_path)
|
|
262
|
+
entry = src_scheme.readEntry(source_pathname, ctx)
|
|
263
|
+
if entry == null: return 404
|
|
264
|
+
if dst_scheme.readEntry(dest_pathname, ctx) succeeds: return 409
|
|
265
|
+
if not mimetype_compatible(entry, dst_scheme): return 415
|
|
266
|
+
tags = signal_tags ?? entry.tags
|
|
267
|
+
dst_scheme.writeEntry(dest_pathname, { channels: entry.channels, tags }, ctx)
|
|
268
|
+
|
|
269
|
+
move(source_path, dest_path, signal_tags, ctx):
|
|
270
|
+
copy(source_path, dest_path, signal_tags, ctx)
|
|
271
|
+
src_scheme.deleteEntry(source_pathname, ctx)
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
Same- and cross-scheme operations follow the identical orchestration. Same-scheme COPY (e.g., `known://a` → `known://b`) is NOT a special case — it is COPY where src_scheme and dst_scheme happen to be the same handler. The engine does not optimize this case; uniformity beats local efficiency at this layer.
|
|
275
|
+
|
|
276
|
+
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.
|
|
277
|
+
|
|
278
|
+
### §3.5 SEND dispatch (status-code-as-verb)
|
|
279
|
+
|
|
280
|
+
When the engine dispatches a `SEND` op with a non-null path (recipient-directed SEND), the target scheme's `send` method receives the statement. The scheme interprets the status code as an intent:
|
|
281
|
+
|
|
282
|
+
- `SEND[200](path)` — write the body into the resource at `path` (e.g., WebSocket message, exec stdin).
|
|
283
|
+
- `SEND[410](path)` — delete the resource at `path`. Scheme calls its own `delete` primitive. {§3.5-410-deletes-resource}
|
|
284
|
+
- `SEND[499](path)` — cancel any active subscription bound to `path` (§7).
|
|
285
|
+
|
|
286
|
+
Other status codes are scheme-specific. The engine does not interpret SEND status codes for directed (non-broadcast) sends. Entry-bearing schemes return 501 for status codes they don't interpret. {§3.5-entry-schemes-501-on-non-410}
|
|
287
|
+
|
|
288
|
+
For SEND[410], channel-level deletion via `#fragment` is deferred — schemes return 400 on fragment-targeted 410. {§3.5-410-fragment-400}
|
|
289
|
+
|
|
290
|
+
`SEND` with null path is broadcast (§6.7) — engine-handled, not scheme-handled.
|
|
291
|
+
|
|
292
|
+
---
|
|
293
|
+
|
|
294
|
+
## §4 Mimetype Contract
|
|
295
|
+
|
|
296
|
+
A mimetype handler interprets channel content for validation, structural extraction, and preview rendering. Every `@plurnk/plurnk-mimetypes-*` package implements this contract.
|
|
297
|
+
|
|
298
|
+
**Firing semantics.** Mimetype handlers are **render-time** consumers. The engine invokes them when assembling the turn packet at turn boundaries — they read the current channel content (whatever's there, possibly mid-stream), produce a structural view, and the result lands in the model's context. {§4-handlers-fire-render-time} Schemes do NOT call mimetype handlers at write time; schemes just append to channel content. Render is the only consumer of "interpreted" content. {§4-schemes-do-not-invoke-handlers} Memoization (handler result cached by content hash) is an implementation concern; the contract is render-time.
|
|
299
|
+
|
|
300
|
+
### §4.1 Manifest
|
|
301
|
+
|
|
302
|
+
```json
|
|
303
|
+
{
|
|
304
|
+
"name": "@plurnk/plurnk-mimetypes-<name>",
|
|
305
|
+
"plurnk": { "kind": "mimetype", "name": "<mimetype-id>" }
|
|
306
|
+
}
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
`<mimetype-id>` follows IANA conventions (`text/plain`, `application/json`, `text/vnd.<vendor>`, etc.). Collisions fail-hard at boot per §9.
|
|
310
|
+
|
|
311
|
+
The handler class declares two required identifiers as instance properties:
|
|
312
|
+
|
|
313
|
+
```ts
|
|
314
|
+
class TextMarkdown implements MimetypeHandler {
|
|
315
|
+
readonly mimetype = "text/markdown";
|
|
316
|
+
readonly glyph = "📝";
|
|
317
|
+
// ... methods (§4.2)
|
|
318
|
+
}
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
`mimetype` is the IANA-shaped identifier — must match the manifest `name`. `glyph` is a single emoji representing this mimetype, used by clients rendering log waterfalls, channel tiles, and any structural index. Every handler MUST declare both. The engine treats an absent `glyph` as a contract violation at registration time, parallel to absent `mimetype`.
|
|
322
|
+
|
|
323
|
+
### §4.2 Methods
|
|
324
|
+
|
|
325
|
+
```ts
|
|
326
|
+
validate(content: string): void;
|
|
327
|
+
symbols(content: string): string;
|
|
328
|
+
preview(content: string, budget: number): string;
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
Promises:
|
|
332
|
+
- `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.
|
|
333
|
+
- `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).
|
|
334
|
+
- `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.
|
|
335
|
+
- All three methods MUST be deterministic for caching to work — same content in, same output out.
|
|
336
|
+
|
|
337
|
+
### §4.3 What mimetype handlers do NOT do
|
|
338
|
+
|
|
339
|
+
- **Tokenization.** Token counting is provider-bound (§2.3). Mimetype handlers describe structure; tokenizers count tokens.
|
|
340
|
+
- **Storage.** Mimetype handlers are pure functions over content strings. They neither read nor write the DB.
|
|
341
|
+
- **Streaming.** Mimetype handlers operate on whatever content is current when invoked. The streaming relationship (§7) is between schemes and the subscription registry; mimetype handlers don't see it.
|
|
342
|
+
|
|
343
|
+
### §4.4 Bundled vs sibling handlers
|
|
344
|
+
|
|
345
|
+
Only `text/plain` ships in `plurnk-service`'s `src/mimetypes/` — the universal fallback that every deployment needs and that has no external dependency. Every other mimetype handler ships as a sibling `@plurnk/plurnk-mimetypes-*` package and registers via Daemon's plugin discovery scan (§9).
|
|
346
|
+
|
|
347
|
+
The first such extraction is `@plurnk/plurnk-mimetypes-text-markdown`, separate repo since #47. Validates the "bundle minimally" pattern. `application/json` and the DSL mimetype (`text/vnd.plurnk` — grammar #6 closed in favor of this id) follow the same pattern when they have consumers.
|
|
348
|
+
|
|
349
|
+
Locked glyph assignments for the standard set:
|
|
350
|
+
|
|
351
|
+
| Mimetype | Glyph | Where |
|
|
352
|
+
|-----------------------------------|-------|------------------------------------|
|
|
353
|
+
| `text/plain` | 📄 | bundled (`src/mimetypes/`) |
|
|
354
|
+
| `text/markdown` | 📝 | sibling (`@plurnk/plurnk-mimetypes-text-markdown`) |
|
|
355
|
+
| `application/json` *(deferred)* | 🗂 | future sibling |
|
|
356
|
+
| `text/vnd.plurnk` *(deferred)* | 📜 | future sibling |
|
|
357
|
+
|
|
358
|
+
Deferred handlers reserve their glyphs now so external packages don't collide.
|
|
359
|
+
|
|
360
|
+
---
|
|
361
|
+
|
|
362
|
+
## §5 Channel Topology
|
|
363
|
+
|
|
364
|
+
Every entry has named channels. **Channels are append-only content stores** keyed by `(entry_id, name)`. Schemes write content; the engine reads at turn boundaries; mimetype handlers interpret. {§5-channels-append-only}
|
|
365
|
+
|
|
366
|
+
### §5.1 Per-entry channels
|
|
367
|
+
|
|
368
|
+
EDIT writes a minimum of two channels per entry, in one transaction: {§5.1-edit-writes-body-plus-preview}
|
|
369
|
+
|
|
370
|
+
- **`body`** — the raw content.
|
|
371
|
+
- **`preview`** — the render-time structural summary. The stored content here is the result of `mimetype.preview(body, budget)` for the body's mimetype. When the mimetype handler is unsophisticated (text/plain head-truncation), the preview is short. As handlers mature (markdown heading outline, JSON key tree, DSL op summary), the preview becomes structural. {§5.1-preview-is-handler-output}
|
|
372
|
+
|
|
373
|
+
Whether preview is stored (write-time memoization) or computed fresh on each render is an implementation choice behind the contract — both behave the same to callers. v0 implementation stores it (avoids per-render compute); future implementation may switch to compute-on-render with content-hash cache.
|
|
374
|
+
|
|
375
|
+
Schemes MAY declare additional channels (exec will declare `stdout`/`stderr`; file may declare an `outline` channel; SSE may declare per-event-type channels). Each additional channel goes in the scheme's `channels` manifest (§3.1) and has its mimetype pinned there.
|
|
376
|
+
|
|
377
|
+
### §5.2 Visibility lattice
|
|
378
|
+
|
|
379
|
+
Visibility is per-`(run, entry, channel)` — a bit per cell in the `visibility` table. EDIT-creating-new sets `indexed=1` for every channel of the new entry in the current run. SHOW flips all channels of the target entry to `indexed=1`; HIDE flips all to `indexed=0`. {§5.2-fragmentless-show-hide-flips-all} Channel-specific SHOW/HIDE via fragment exists for the entry-bearing schemes; see §5.5.
|
|
380
|
+
|
|
381
|
+
The engine's render-time index (`packet.system.index`) includes only `indexed=1` channels for the current run. {§5.2-render-filters-by-indexed} Each included channel is passed through its mimetype handler's `preview(content, budget)` per §4 / §5.1, with the result landing in the entry's `channels[name].content` field in the packet.
|
|
382
|
+
|
|
383
|
+
### §5.3 Mimetype is a (scheme, channel) property — never a default
|
|
384
|
+
|
|
385
|
+
The mimetype of a channel is declared by the scheme's manifest (§3.1) or — for dynamic schemes — supplied per-call. If the engine attempts to write a channel without a declared mimetype, it throws. There is no default mimetype anywhere in the system. This is a reinforcement of the no-fallbacks rule at the channel layer.
|
|
386
|
+
|
|
387
|
+
Implications:
|
|
388
|
+
- Cross-mimetype COPY/MOVE crashes (`415 Unsupported Media Type`) — never coerces. See §6.4.
|
|
389
|
+
|
|
390
|
+
### §5.4 Orientation hint
|
|
391
|
+
|
|
392
|
+
The grammar's `SchemeRegistration.channel_orientations` declares each scheme's channels as `head` or `tail` — "which end of the content matters for preview." Mimetype handlers consult this hint when truncating. Channels not listed default to `head`.
|
|
393
|
+
|
|
394
|
+
### §5.5 Channel selection in the DSL
|
|
395
|
+
|
|
396
|
+
The DSL targets a specific channel of an entry via the URL **fragment** — the `#name` segment of the path. Per the grammar's `UrlPath` shape (0.3.0+), every parsed path has a nullable `fragment` field; this section defines its semantic for the engine.
|
|
397
|
+
|
|
398
|
+
Rules:
|
|
399
|
+
|
|
400
|
+
1. **Fragment-less paths target the scheme's `defaultChannel`.** For `known` / `unknown` / `skill` that's `body`. The path `known://philosophy/meaning` writes/reads the body channel of that entry; equivalent to `known://philosophy/meaning#body` made explicit. {§5.5-fragmentless-targets-default-channel}
|
|
401
|
+
2. **Paths with a fragment target the named channel.** `known://philosophy/meaning#preview` targets the preview channel. {§5.5-fragment-selects-named-channel}
|
|
402
|
+
3. **Unknown channel name → 400 (bad request).** The channel name must appear in the scheme's `channels` manifest (§3.1). Engine never writes to an undeclared channel. {§5.5-unknown-channel-400}
|
|
403
|
+
4. **Schemes without `defaultChannel` reject fragment-less EDIT/READ.** Streaming schemes like `sse://feed/x` may require an explicit fragment (`#data`, `#error`, etc.) and have no default.
|
|
404
|
+
5. **Non-default channel EDIT requires entry to exist.** Writing to a fragment-targeted channel of a nonexistent entry returns 404. Default-channel EDIT creates the entry if absent (existing semantics). {§5.5-fragment-on-nonexistent-404}
|
|
405
|
+
6. **Fragment-targeted SHOW/HIDE flips only the named channel.** Fragment-less SHOW/HIDE flips all channels per §5.2. {§5.5-fragment-targeted-show-hide}
|
|
406
|
+
|
|
407
|
+
Examples:
|
|
408
|
+
|
|
409
|
+
| URI | Scheme's interpretation |
|
|
410
|
+
|---|---|
|
|
411
|
+
| `known://france/capital` | body channel of the entry (text/markdown) |
|
|
412
|
+
| `known://france/capital#preview` | preview channel (text/markdown structural summary) |
|
|
413
|
+
| `exec://run/abc#stdout` | stdout channel of the exec invocation |
|
|
414
|
+
| `exec://run/abc#stderr` | stderr channel |
|
|
415
|
+
| `sse://feed/y#data` | named channel for the SSE "data" event type (§7) |
|
|
416
|
+
| `log://N/T/A` | log entry at coordinates; no channel concept (log entries are atomic rows) |
|
|
417
|
+
|
|
418
|
+
Implications for operations:
|
|
419
|
+
|
|
420
|
+
- **EDIT** writes the body to the resolved channel. If the channel doesn't exist in the manifest → 400. If the scheme doesn't support EDIT to that channel (e.g., exec stdout is read-only) → 405.
|
|
421
|
+
- **READ** returns content + mimetype from the resolved channel.
|
|
422
|
+
- **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).
|
|
423
|
+
- **COPY / MOVE** with a fragment is a per-channel operation; deferred design pass needed before specifying (out of scope for v0).
|
|
424
|
+
|
|
425
|
+
The clean-shape RPC params (§13.5) carry the fragment naturally inside the `path` string: `{ path: "known://x#preview" }` works as expected. No new RPC parameter needed; the URL surface handles it.
|
|
426
|
+
|
|
427
|
+
### §5.6 Channel state — metadata, not gating
|
|
428
|
+
|
|
429
|
+
Per the grammar's `ChannelContent` schema, each channel has a `state ∈ {static, active, closed, errored}`. This is **information about the channel's current writing status**, not a gate on engine behavior. {§5.6-state-is-metadata}
|
|
430
|
+
|
|
431
|
+
- `static` — content is final; not actively being written. Entry-bearing schemes (known/unknown/skill) stay here always after EDIT.
|
|
432
|
+
- `active` — a scheme is currently writing to this channel (chunks arriving). Streaming schemes use this during their connection's accumulating phase.
|
|
433
|
+
- `closed` — a scheme finished writing cleanly. The channel content is final but came from a stream that has now ended.
|
|
434
|
+
- `errored` — a scheme was writing but ended in error. Content may be partial; subsequent reads still return what was accumulated.
|
|
435
|
+
|
|
436
|
+
Schemes own state transitions. They UPDATE `entry_channels.state` as their connection lifecycle progresses. {§5.6-schemes-own-state-transitions} The engine does NOT branch on state during rendering — it reads `content` and `state`, includes both in the rendered tile, lets the model and clients see the truth. {§5.6-engine-does-not-branch-on-state}
|
|
437
|
+
|
|
438
|
+
The model uses state for context: "this channel says `active` — content may grow before my next turn." Clients use state for UI: render an active channel with a spinner, errored with red, etc.
|
|
439
|
+
|
|
440
|
+
---
|
|
441
|
+
|
|
442
|
+
## §6 Op Surface
|
|
443
|
+
|
|
444
|
+
Per-op semantics for `FIND | READ | EDIT | COPY | MOVE | SHOW | HIDE | SEND | EXEC`. Each subsection includes the AST shape (per `@plurnk/plurnk-grammar`'s `PlurnkStatement` schema), the engine's dispatch behavior, and the promises returned to callers.
|
|
445
|
+
|
|
446
|
+
### §6.1 EDIT
|
|
447
|
+
|
|
448
|
+
AST: `{ op: "EDIT", path: ParsedPath, body: string | null, signal: tags | null, lineMarker?: LineMarker }`.
|
|
449
|
+
|
|
450
|
+
Engine dispatches to `scheme.edit(statement, ctx)`. Scheme:
|
|
451
|
+
- Resolves the target channel from the path's fragment (§5.5). Fragment absent → scheme's `manifest.defaultChannel`. Unknown channel → 400. Channel manifest-undeclared → engine crashes per §5.3.
|
|
452
|
+
- Writes the body to the resolved channel.
|
|
453
|
+
- For entry-bearing schemes (known/unknown/skill), also writes the `preview` channel in the same transaction when writing `body`. Storage is verbatim body at write time; structural rendering happens at packet build time via `mimetype.preview(content, budget)` (§5.1).
|
|
454
|
+
- Indexes the written channels in the current run (visibility = 1).
|
|
455
|
+
- Returns `{ status: 201, entryId }` for new entries; `{ status: 200, entryId }` for updates.
|
|
456
|
+
- `body: null` clears the content (writes empty string).
|
|
457
|
+
- Tags from `signal[]` are applied via `entry_tags` (additive on update for the v0 entry schemes; final policy may vary by scheme).
|
|
458
|
+
|
|
459
|
+
### §6.2 READ
|
|
460
|
+
|
|
461
|
+
AST: `{ op: "READ", path: ParsedPath, body: MatcherBody | null, signal: tags | null, lineMarker?: LineMarker }`.
|
|
462
|
+
|
|
463
|
+
Engine dispatches to `scheme.read(statement, ctx)`. Scheme:
|
|
464
|
+
- Returns the body channel content + mimetype for `path`, or `{ status: 404 }`.
|
|
465
|
+
- `lineMarker` selects a line range (e.g., `<10-20>` for lines 10-20).
|
|
466
|
+
- `body` (matcher) is for streaming-scheme deep reads (§7) — not v0 surface for entry schemes.
|
|
467
|
+
|
|
468
|
+
### §6.3 SHOW / HIDE
|
|
469
|
+
|
|
470
|
+
AST: `{ op: "SHOW"|"HIDE", path: ParsedPath, body: MatcherBody | null, signal: tags | null, lineMarker?: LineMarker }`.
|
|
471
|
+
|
|
472
|
+
Engine dispatches to `scheme.show(statement, ctx)` / `scheme.hide(statement, ctx)`. Scheme:
|
|
473
|
+
- Flips `visibility.indexed` for every channel of the targeted entry to 1 (SHOW) or 0 (HIDE).
|
|
474
|
+
- Returns 200 on transition, 304 on no-op, 404 if entry not found.
|
|
475
|
+
- No channel-specific selectors in v0; SHOW/HIDE always affects all channels of the entry.
|
|
476
|
+
|
|
477
|
+
### §6.4 COPY (engine-orchestrated)
|
|
478
|
+
|
|
479
|
+
AST: `{ op: "COPY", path: ParsedPath (source), body: ParsedPath (destination), signal: tags | null, lineMarker?: LineMarker }`.
|
|
480
|
+
|
|
481
|
+
Engine orchestrates over CRUD primitives (§3.2, §3.4):
|
|
482
|
+
|
|
483
|
+
1. `src_scheme.readEntry(source_pathname, ctx)` → entry or 404. Missing source returns 404. {§6.4-missing-source-404}
|
|
484
|
+
2. `dst_scheme.readEntry(dest_pathname, ctx)` to check conflict — if exists, return **409 Conflict** (no overwrite). Per fail-hard discipline. {§6.4-conflict-409}
|
|
485
|
+
3. Mimetype compatibility check — channels of `entry` MUST have mimetypes accepted by `dst_scheme`'s `manifest.channels`. Mismatch returns **415 Unsupported Media Type**.
|
|
486
|
+
4. Tag resolution: if `signal` is non-null, dest tags = signal_tags (REPLACE). {§6.4-signal-replaces-source-tags} If signal is null/empty, dest tags = source tags (CARRY). {§6.4-no-signal-carries-source-tags}
|
|
487
|
+
5. `dst_scheme.writeEntry(dest_pathname, { channels: entry.channels, tags }, ctx)`.
|
|
488
|
+
6. Dest visibility indexed=1 in current run (parity with EDIT-creating-new).
|
|
489
|
+
|
|
490
|
+
Returns `{ status: 201 }` on success.
|
|
491
|
+
|
|
492
|
+
Same- and cross-scheme COPY both go through this orchestrator. {§6.4-cross-scheme-copy}
|
|
493
|
+
|
|
494
|
+
### §6.5 MOVE (engine-orchestrated)
|
|
495
|
+
|
|
496
|
+
AST: `{ op: "MOVE", path: ParsedPath (source), body: ParsedPath | null (destination), signal: tags | null, lineMarker?: LineMarker }`.
|
|
497
|
+
|
|
498
|
+
Two modes:
|
|
499
|
+
|
|
500
|
+
**Relocation** (`body` non-null): engine runs §6.4 COPY then `src_scheme.deleteEntry(source_pathname, ctx)`. One transaction. Returns 201 on success. Source is removed. {§6.5-relocation-deletes-source} Cross-scheme relocation works the same as same-scheme. {§6.5-cross-scheme-move}
|
|
501
|
+
|
|
502
|
+
**Deletion** (`body` is null): engine runs `src_scheme.deleteEntry(source_pathname, ctx)` directly. The null-body MOVE expresses "relocate to nowhere" = delete. {§6.5-null-body-deletes} Returns 200 on success, 404 if source absent. {§6.5-missing-source-404}
|
|
503
|
+
|
|
504
|
+
Log history is preserved through MOVE because `log_entries.target_*` columns store the path tuple as text, not FK to `entries.id`.
|
|
505
|
+
|
|
506
|
+
### §6.6 FIND
|
|
507
|
+
|
|
508
|
+
AST: `{ op: "FIND", path: ParsedPath (scope), body: MatcherBody | null (predicate), signal: tags | null (tag filter), lineMarker?: LineMarker }`.
|
|
509
|
+
|
|
510
|
+
Engine dispatches to `scheme.find(statement, ctx)`. Scheme:
|
|
511
|
+
- Filters entries within the path's scope (scheme + pathname prefix). {§6.6-scope-prefix-filter}
|
|
512
|
+
- Applies `body` matcher if present. v0 supports `glob` dialect over pathname. {§6.6-glob-filter-on-pathname} Other dialects (regex over content, xpath, jsonpath) return **501 Not Implemented** until the relevant infrastructure exists. {§6.6-non-glob-dialects-501}
|
|
513
|
+
- Applies `signal` as a tag filter: only entries with ALL listed tags pass. {§6.6-tag-filter-and-semantics}
|
|
514
|
+
- Results are session- and scheme-scoped — no cross-session or cross-scheme leakage. {§6.6-scoped-isolation}
|
|
515
|
+
- Returns `{ status: 200, results: string }` where `results` is `text/plain` with newline-separated matching paths.
|
|
516
|
+
|
|
517
|
+
### §6.7 SEND
|
|
518
|
+
|
|
519
|
+
AST: `{ op: "SEND", path: ParsedPath | null, body: SendBody | null, signal: number | null }`.
|
|
520
|
+
|
|
521
|
+
Two modes:
|
|
522
|
+
|
|
523
|
+
**Broadcast** (`path` is null): terminal status (200/499) updates `loop.status` and ends the loop. Other status codes return `{ status }` without state change. The model's universal "talk to the orchestrator" surface.
|
|
524
|
+
|
|
525
|
+
**Directed** (`path` is non-null): engine dispatches `scheme.send(statement, ctx)`. Scheme interprets `signal` as an intent per §3.5:
|
|
526
|
+
- 200 → write `body` into the resource (stream-write, exec stdin, etc.)
|
|
527
|
+
- 410 → delete the resource at `path` (scheme calls its own `delete` primitive)
|
|
528
|
+
- 499 → cancel active subscription (§7)
|
|
529
|
+
- Other → scheme-specific
|
|
530
|
+
|
|
531
|
+
### §6.8 EXEC
|
|
532
|
+
|
|
533
|
+
AST: `{ op: "EXEC", path: ParsedPath (cwd), body: string | null (command), signal: string | null (runtime tag) }`.
|
|
534
|
+
|
|
535
|
+
Deferred. The `exec` scheme is in §10's bundled set but lacks a working handler; calls return 501. Sandboxing design and process-lifecycle semantics are the substance to figure out, drawing on rummy's exec plugin as prior art.
|
|
536
|
+
|
|
537
|
+
---
|
|
538
|
+
|
|
539
|
+
## §7 Stream Model
|
|
540
|
+
|
|
541
|
+
The model can't poll and can't wait for streams to complete. It must stay passively informed of ongoing streams between turns. Plurnk-service treats streams as **the same as static content from the engine's perspective** — content arrives over time, channels grow, mimetype handlers render whatever's there at turn boundaries. There is no engine-level "transaction" abstraction; schemes own their connection lifecycle entirely. {§7-no-engine-transaction-abstraction}
|
|
542
|
+
|
|
543
|
+
### §7.1 Subscriptions
|
|
544
|
+
|
|
545
|
+
READ on a streaming scheme is a subscription, not a one-shot. The scheme handler opens the connection (SSE, WS, exec subprocess, etc.), returns a `102 Processing` log row immediately, and stays alive. The engine records `(sessionId, entryId) → schemeName + scheme-handle` in a **subscription registry** so cancellation (SEND[499]) can be routed to the scheme owning the active connection. {§7.1-subscription-registry-routes-cancellation}
|
|
546
|
+
|
|
547
|
+
The subscription registry is plurnk-service runtime state (its own SQLite table). Not in the grammar's schema. **It exists ONLY for cancellation routing** — not for lifecycle tracking, not for state coordination. Channel state (§5.6) and log entries (§7.3) carry the lifecycle information.
|
|
548
|
+
|
|
549
|
+
### §7.2 Chunk accumulation
|
|
550
|
+
|
|
551
|
+
SSE event types, WS message types, exec stdout/stderr — each maps to a named channel on the entry. Channel record (per grammar's `ChannelContent`): `content`, `mimetype`, `tokens`. Whether a connection is currently feeding the channel is tracked in the subscription registry, not on the channel itself.
|
|
552
|
+
|
|
553
|
+
### §7.3 No per-chunk log rows
|
|
554
|
+
|
|
555
|
+
Channels are the single source of truth for chunk content. Log captures **lifecycle events** only: open (`102`), graceful close (`200`), explicit cancel (`499`), errors (`5xx`), scheme-significant transitions. {§7.3-log-captures-lifecycle-only}
|
|
556
|
+
|
|
557
|
+
The model sees lifecycle events in `packet.system.log[]` per turn (§7.4 / §7.8). This is how the model learns "the stream opened" / "the stream closed cleanly" / "an error happened" — through log rows, not through engine-level state inspection.
|
|
558
|
+
|
|
559
|
+
### §7.4 Index tile rendering
|
|
560
|
+
|
|
561
|
+
Per-channel previews use `SchemeRegistration.channel_orientations` (grammar 0.3.0). The scheme declares each channel as `"head"` (front-anchored — render from beginning) or `"tail"` (append-temporal — render most recent). Channels not listed default to `"head"`. The renderer (plurnk-service code) decides token budget via `PLURNK_ENTRY_SIZE_DEFAULT_TOKENS` (§12).
|
|
562
|
+
|
|
563
|
+
### §7.5 Deep slices on demand
|
|
564
|
+
|
|
565
|
+
`<<READ(sse://feed/x#data)<N-M>:…:READ` pulls a specific slice of the channel's content into a log row when the model wants depth beyond the preview.
|
|
566
|
+
|
|
567
|
+
### §7.6 HIDE is pure archive
|
|
568
|
+
|
|
569
|
+
`<<HIDE(sse://feed/x)::HIDE` demotes the entry from the index. The connection persists silently; channels keep updating in the background; the model can `SHOW` it back later. HIDE never tears down a subscription.
|
|
570
|
+
|
|
571
|
+
### §7.7 SEND for stream control
|
|
572
|
+
|
|
573
|
+
- **Cancel:** `<<SEND[499](sse://feed/x)::SEND` — scheme interprets 499 as "tear down." Subscription registry transitions to closed; the AbortController fires; channel stops accumulating.
|
|
574
|
+
- **Write:** `<<SEND[200](wss://feed/x):message body:SEND` — pipes body into an active WS connection, exec stdin, etc.
|
|
575
|
+
|
|
576
|
+
### §7.8 Engine constraints
|
|
577
|
+
|
|
578
|
+
The engine imposes ONE constraint: **100 MiB char-length cap per channel content body.** Enforced at the storage layer via `CHECK (length(content) <= 104857600)` on `entry_channels.content` (migrations/005_entries.sql). Writes exceeding this fail at the SQL boundary with a SQLITE_CONSTRAINT; the action-entry captures the rejection at status 500.
|
|
579
|
+
|
|
580
|
+
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}
|
|
581
|
+
|
|
582
|
+
This reflects an intentional v0 stance: pre-MVP, every operator-configurable cap is a barrier between the user and the system actually running. The user-facing CLI/TUI fiddles best when nothing fires unexpectedly. When real production pressure arrives, additional caps land as opt-in operator config — not as defaults.
|
|
583
|
+
|
|
584
|
+
### §7.9 Live updates for clients (between turns)
|
|
585
|
+
|
|
586
|
+
While the model only sees turn-boundary rendering (coherent context per turn), connected RPC clients (TUI, neovim, web) want to see channel content grow in real time. The daemon emits `stream/event` notifications (§13.6) when channel content changes. Clients use these to render live waterfalls and refresh entry views without polling. {§7.9-stream-event-fires-on-chunk}
|
|
587
|
+
|
|
588
|
+
The model is NOT a stream/event consumer. The model is a turn-based consumer; whatever's in the channel at the next turn boundary is what gets rendered into its packet.
|
|
589
|
+
|
|
590
|
+
---
|
|
591
|
+
|
|
592
|
+
## §8 Storage Model
|
|
593
|
+
|
|
594
|
+
SQLite (`node:sqlite`) with WAL mode and STRICT tables. Hand-written DDL, validated against grammar schemas via a CI test.
|
|
595
|
+
|
|
596
|
+
### §8.1 DDL strategy — hand-written, STRICT, indexed
|
|
597
|
+
|
|
598
|
+
No generator. The value-add of JSON-Schema-to-DDL generation tops out at ~30% (table skeletons with NOT NULL columns and naive types); the remaining 70% — FK semantics, indexes, triggers, generated columns, FTS5, `WITHOUT ROWID`, defaults, decomposition decisions — is hand-written regardless. The generator's only real win is drift detection, which a CI alignment test handles at a fraction of the complexity.
|
|
599
|
+
|
|
600
|
+
- All `migrations/*.sql` files are authored deliberately. SQLite-optimal: STRICT tables (3.37+), `INTEGER PRIMARY KEY` aliasing where appropriate, explicit `NOT NULL`, indexed query paths, FKs with deliberate `ON DELETE`/`ON UPDATE` semantics, `WITHOUT ROWID` where access pattern warrants, generated columns for materialized derivations, FTS5 virtual tables where text search is load-bearing.
|
|
601
|
+
- One migration file per cohesive concern. Numbered for deterministic apply order.
|
|
602
|
+
- `migrate` CLI subcommand applies in numeric order. Idempotent — runner skips migrations whose marker is in the `applied_migrations` meta table.
|
|
603
|
+
- **Schema-alignment test** loads `@plurnk/plurnk-grammar/schema/*.json`, parses DDL via `node:sqlite` introspection, and asserts every required schema field has a corresponding `NOT NULL` column. Grammar drift fails CI; hand-written DDL catches up before the alignment goes green again.
|
|
604
|
+
- **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.
|
|
605
|
+
|
|
606
|
+
### §8.2 SQL/TS responsibility boundary
|
|
607
|
+
|
|
608
|
+
**Lives-in-SQL:**
|
|
609
|
+
- The visibility / index / archive lattice (CTEs per run computing the projected entry list).
|
|
610
|
+
- Cross-scope path collision detection (CHECK or trigger producing the `409 Conflict` response).
|
|
611
|
+
- Cost rollups (denormalized integer pico-units; updated atomically on turn close via triggers).
|
|
612
|
+
- Sequence number issuance (1-based per the grammar spec).
|
|
613
|
+
- Entry-vs-log integrity (channels carry latest state; log carries every interaction).
|
|
614
|
+
|
|
615
|
+
**Lives-in-TS:**
|
|
616
|
+
- All status-bubble rules (`turn.status` → `loop.status` → `run.status` → `session.status`). Engine UPDATEs status explicitly when it observes terminal turn semantics. CHECK constraints enforce contract; trigger doesn't have to compute it. Triggers were tried and explicitly removed — they fight branching state machines.
|
|
617
|
+
- Tokenization (active model's tokenizer; provider-bound; hot-swap requires re-tokenize on switch).
|
|
618
|
+
- Provider dispatch and response normalization to `{ assistant, assistantRaw }`.
|
|
619
|
+
- Scheme-handler invocation (open connections, run subprocesses, fetch URLs).
|
|
620
|
+
- Plugin loading (boot-time scan per §9).
|
|
621
|
+
- Stream AbortController lifecycle.
|
|
622
|
+
- The CLI surface and the long-running daemon.
|
|
623
|
+
|
|
624
|
+
When SQL becomes onerous for a specific case, retreat for that case and document the retreat.
|
|
625
|
+
|
|
626
|
+
---
|
|
627
|
+
|
|
628
|
+
## §9 Plugin Discovery
|
|
629
|
+
|
|
630
|
+
External `@plurnk/*` npm packages register at boot via the **scoped-package scan with manifest field** pattern:
|
|
631
|
+
|
|
632
|
+
1. Each external package declares its kind in its `package.json`:
|
|
633
|
+
```json
|
|
634
|
+
{ "name": "@plurnk/plurnk-providers-openrouter",
|
|
635
|
+
"plurnk": { "kind": "provider", "name": "openrouter" } }
|
|
636
|
+
```
|
|
637
|
+
2. On boot, `plurnk-service` reads every `node_modules/@plurnk/*/package.json`, filters for those with a `plurnk` field, dynamic-imports the matches.
|
|
638
|
+
3. Load order: deterministic, alphabetical by name.
|
|
639
|
+
4. Collision on `(kind, name)` is fail-hard at boot.
|
|
640
|
+
5. The user's flow: `npm i @plurnk/plurnk-<kind>-<name> && plurnk start`. Zero config.
|
|
641
|
+
|
|
642
|
+
Env vars are reserved for *configuring* installed plugins (API keys, endpoints, throttles), never for *declaring their existence*. The filesystem is the source of truth for what's installed.
|
|
643
|
+
|
|
644
|
+
Versioning: a `plurnkContractVersion` field on each plugin's `plurnk` manifest declares which version of this SPEC the plugin targets. Engine refuses incompatible plugins at boot. (Wired up post-v1.0 of this document.)
|
|
645
|
+
|
|
646
|
+
---
|
|
647
|
+
|
|
648
|
+
## §10 Bundled Set
|
|
649
|
+
|
|
650
|
+
These ship in `plurnk-service` directly, not as separate `@plurnk/*` packages:
|
|
651
|
+
|
|
652
|
+
**Providers:**
|
|
653
|
+
- `mock` — fake provider used exclusively in `intg` for deterministic engine tests. Also serves as a minimal worked example for authors of external `@plurnk/plurnk-providers-*` packages.
|
|
654
|
+
|
|
655
|
+
**Mimetypes** (in `src/mimetypes/`):
|
|
656
|
+
- `text/plain` — universal fallback. Identity validate; head-truncated preview. Stays bundled because every deployment needs it and it has no external dependency.
|
|
657
|
+
|
|
658
|
+
**Schemes** (in `src/schemes/`):
|
|
659
|
+
- `plurnk` — meta-scheme for scheme registration ops (manifest only; ops not implemented yet).
|
|
660
|
+
- `log` — coordinate-addressed run/turn/action log reads.
|
|
661
|
+
- `known` — primary narrative entries.
|
|
662
|
+
- `unknown` — decomposition / open questions.
|
|
663
|
+
- `skill` — sibling of known/unknown; semantics provisional.
|
|
664
|
+
- `exec` — manifest only; subprocess invocation deferred to Phase F.
|
|
665
|
+
- `file` — currently in-tree but flagged for extraction to `@plurnk/plurnk-schemes-file` once exec/streams phase shapes the file scheme's full surface.
|
|
666
|
+
|
|
667
|
+
**Sibling repos** (live `@plurnk/*` packages we own):
|
|
668
|
+
- **Mimetypes:** `@plurnk/plurnk-mimetypes-text-markdown` — first extraction (#47). Validates the "bundle minimally" pattern.
|
|
669
|
+
- **Providers:** `@plurnk/plurnk-providers-openai` — talks to any OpenAI-compatible endpoint (OpenAI proper, llama-server, Ollama OpenAI-compat, etc.).
|
|
670
|
+
- **Client:** `@plurnk/plurnk` — user-facing CLI/TUI (independent agent ownership since #57).
|
|
671
|
+
|
|
672
|
+
**Future external** (separate repos, separate npm packages, optional install when written):
|
|
673
|
+
- **Providers:** `openrouter`, `xai`, `google`, `ollama` (native API), `anthropic`, `cf` (Cloudflare). Phase D.
|
|
674
|
+
- **Schemes:** `http(s)`, `ws(s)`, `sse`, `graphql`, `openapi`, `grpc`, `mailto`, `mcp`, `sftp`, `rest`, `search`. Streaming schemes land in Phase F.
|
|
675
|
+
- **Mimetypes:** `application/json`, `text/vnd.plurnk`, anything beyond. Phase C.
|
|
676
|
+
|
|
677
|
+
Plugin discovery (§9) finds every installed `@plurnk/*` package at boot and registers what they declare. The bundled set is the floor; siblings are what's installed; the system runs against whatever is present in `node_modules`.
|
|
678
|
+
|
|
679
|
+
---
|
|
680
|
+
|
|
681
|
+
## §11 Grammar Dependency
|
|
682
|
+
|
|
683
|
+
`@plurnk/plurnk-grammar@0.5.0` is the contract. Consumed via `github:plurnk/plurnk-grammar` HEAD during pre-1.0 iteration. Treat the grammar as authoritative; surface gaps to the user, don't redesign from this side.
|
|
684
|
+
|
|
685
|
+
### §11.1 What grammar provides
|
|
686
|
+
|
|
687
|
+
- Parser (`PlurnkParser`, ANTLR4-generated) — DSL text → `PlurnkStatement[]`.
|
|
688
|
+
- AST types (`@plurnk/plurnk-grammar`'s exported TypeScript interfaces).
|
|
689
|
+
- JSON schemas (`schema/*.json`, draft 2020-12) for every wire shape.
|
|
690
|
+
- The grammar's `plurnk.md` is the canonical model-facing description of the DSL.
|
|
691
|
+
|
|
692
|
+
### §11.2 What plurnk-service tracks in its own runtime state (NOT in the grammar)
|
|
693
|
+
|
|
694
|
+
- Channel lifecycle (`active` / `closed` / `errored`) — subscription registry, not on `ChannelContent`. Was rejected as a contract field with good reason: streams are not a distinct paradigm at the contract layer; they're a runtime relationship between an entry and an open connection.
|
|
695
|
+
- Render budget per channel (token count) — `PLURNK_ENTRY_SIZE_DEFAULT_TOKENS` operator config (§12).
|
|
696
|
+
- Backpressure caps — none in v0 (§7.8). Providers/schemes/mimetypes own their own throttling.
|
|
697
|
+
- Stream cancel verb — no dedicated cancel op or signal in the grammar. SEND[499] to the URI is the pattern (§7.7).
|
|
698
|
+
- Delete verb — no dedicated DELETE op. SEND[410] to the URI is the pattern (§3.5, §6.5).
|
|
699
|
+
|
|
700
|
+
### §11.3 Grammar contract changes (resolved)
|
|
701
|
+
|
|
702
|
+
All grammar issues filed against the upstream contract have closed and shipped. Service tracks the resolution:
|
|
703
|
+
|
|
704
|
+
- **#6 `text/vnd.plurnk`** — closed; DSL mimetype identifier confirmed.
|
|
705
|
+
- **#7 public `parsePath` helper** — closed; landed in grammar 0.3.2.
|
|
706
|
+
- **#8 SEND broadcast vs directed clarification** — closed; grammar plurnk.md docs tightened.
|
|
707
|
+
- **#9 packet contract + parse-error surface** — closed; grammar 0.4.0 renamed `packet.user.turn` → `packet.user.telemetry` and shipped the `{budget, errors}` shape. Service aligned in #123.
|
|
708
|
+
- **#10 Run.name + dual-handle** — closed; grammar 0.5.0 added required `Run.name`. Service aligned in #123.
|
|
709
|
+
|
|
710
|
+
When future gaps surface, they get filed as grammar issues (not redesigned in plurnk-service), and the relevant SPEC.md section notes the pending request.
|
|
711
|
+
|
|
712
|
+
---
|
|
713
|
+
|
|
714
|
+
## §12 Operator Configuration
|
|
715
|
+
|
|
716
|
+
Every plurnk-service deployment configures via env vars. Cascade: `.env.example` (shipped defaults) < `.env` (project) < `.env.<config>` (via `--config=`) < shell < CLI flags. `bin/plurnk-service.js` auto-loads `.env.example` so the daemon starts on `./bin/plurnk-service.js` with no setup required.
|
|
717
|
+
|
|
718
|
+
Model selection uses a separate alias cascade managed by `ProviderRegistry` (`src/core/ProviderRegistry.ts`): `PLURNK_MODEL_<alias>=<provider>/<model-id>` declares an alias; `PLURNK_MODEL=<alias>` selects which is active. The first path segment of the value names the provider plugin (`@plurnk/plurnk-providers-<provider>`); the rest is the provider's own model identifier (may contain `/` for tri-level providers like openrouter's `publisher/model`). Aliases live in `.env`, not `.env.example`, since they're operator-specific. Rummy parallel: `RUMMY_MODEL_<alias>` cascade.
|
|
719
|
+
|
|
720
|
+
| Var | Default | Status | Purpose |
|
|
721
|
+
|--------------------------------------|--------------------|------------|---------------------------------------------------------------|
|
|
722
|
+
| `PLURNK_DB_PATH` | `./plurnk.db` | enforced | SQLite file path. |
|
|
723
|
+
| `PLURNK_HOST` | `127.0.0.1` | enforced | Bind address for the daemon WebSocket. Local-only by default. |
|
|
724
|
+
| `PLURNK_PORT` | `3044` | enforced | TCP port for the daemon WebSocket. |
|
|
725
|
+
| `PLURNK_MAX_TURNS` | `999` | enforced | Per-loop turn cap (overridable per `loop.run` call). |
|
|
726
|
+
| `PLURNK_MAX_COMMANDS` | `99` | reserved | Per-turn op cap. Not yet enforced; Phase B scope. |
|
|
727
|
+
| `PLURNK_RPC_TIMEOUT` | `30000` | reserved | ms timeout for non-`longRunning` RPC handlers. Not yet enforced. |
|
|
728
|
+
| `PLURNK_LOOP_TIMEOUT` | `86400000` | reserved | ms wall-clock budget for a single `loop.run`. Not yet enforced. |
|
|
729
|
+
| `PLURNK_MAX_STRIKES` | `3` | enforced | Strike threshold + sudden-death lead time (§0.5). |
|
|
730
|
+
| `PLURNK_MIN_CYCLES` | `3` | enforced | Min repetitions before cycle detection fires (§0.5). |
|
|
731
|
+
| `PLURNK_MAX_CYCLE_PERIOD` | `4` | enforced | Max period length cycle detection examines (§0.5). |
|
|
732
|
+
| `PLURNK_WORKSPACE_ROOT` | `""` | enforced | Filesystem root for `file://` scheme reads. Empty = `file://` returns 400. |
|
|
733
|
+
| `PLURNK_REASON` | `0` | enforced | Reasoning-token budget. 0 = disabled. Positive = budget in tokens; provider modules translate to wire format (PROVIDERS.md §3.8). |
|
|
734
|
+
| `PLURNK_ENTRY_SIZE_DEFAULT_TOKENS` | `256` | enforced | Per-channel preview budget for index tiles (characters; §14.2). |
|
|
735
|
+
| `PLURNK_DEBUG` | `0` | reserved | Schema-validation toggle. Not yet enforced. |
|
|
736
|
+
| `PLURNK_LOG_LEVEL` | `info` | reserved | Stdout banner verbosity. Not yet enforced. |
|
|
737
|
+
|
|
738
|
+
**enforced** = the engine actually reads and acts on this value. **reserved** = shipped in `.env.example` (rummy parity, forward-spec) but not yet read anywhere in code. Reserved vars are still parsed as CLI flags by `bin/plurnk-service.js` (auto-derived from `.env.example`); their values currently no-op.
|
|
739
|
+
|
|
740
|
+
Feature-flag bools use `process.env.X === "1"` exactly — never `=== "true"`.
|
|
741
|
+
|
|
742
|
+
External provider/scheme/mimetype plugins declare their own env vars in their own `.env.example` files; plurnk-service merges them at boot via the cascading-env convention.
|
|
743
|
+
|
|
744
|
+
The admin CLI (`bin/plurnk-service.js`) auto-derives flags from `.env.example`. Every `PLURNK_*` env var becomes a `--<kebab-cased-name>` flag (prefix stripped, lowercased, underscores → dashes). A comment immediately above the env line (no blank line between) becomes the flag's `-h` description. Vars without a comment are still accepted as flags but hidden from `-h`. Non-`PLURNK_*` vars in `.env.example` are bugs — vendor-specific config belongs to the vendor's package, not to plurnk-service's namespace.
|
|
745
|
+
|
|
746
|
+
---
|
|
747
|
+
|
|
748
|
+
## §13 RPC Surface
|
|
749
|
+
|
|
750
|
+
`plurnk-service` runs as a daemon — a long-lived process owning the engine, DB, providers, schemes, and mimetypes. Clients (TUI, CLI, neovim plugin, Telegram bot, web app, anything) connect over a network protocol and drive the agent through a self-describing RPC contract.
|
|
751
|
+
|
|
752
|
+
This section defines the wire. Implementing a new client should require reading only §13 — no source diving, no protocol guessing.
|
|
753
|
+
|
|
754
|
+
### §13.1 Transport
|
|
755
|
+
|
|
756
|
+
WebSocket via the `ws` npm package. One message per `ws.send`. UTF-8 JSON payloads. Single full-duplex connection per client.
|
|
757
|
+
|
|
758
|
+
Configuration via §12:
|
|
759
|
+
|
|
760
|
+
- `PLURNK_PORT` (default `3044`) — TCP port to bind.
|
|
761
|
+
- `PLURNK_HOST` (default `127.0.0.1`) — bind address. Local-only by default; explicit operator action to expose beyond loopback.
|
|
762
|
+
|
|
763
|
+
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.
|
|
764
|
+
|
|
765
|
+
### §13.2 Protocol
|
|
766
|
+
|
|
767
|
+
JSON-RPC 2.0 (https://www.jsonrpc.org/specification). Two message kinds:
|
|
768
|
+
|
|
769
|
+
- **Request:** `{ "jsonrpc": "2.0", "id": "...", "method": "...", "params": {...} }`. Server replies with a matching `id`.
|
|
770
|
+
- **Notification:** `{ "jsonrpc": "2.0", "method": "...", "params": {...} }`. No `id`; no reply expected. Server-initiated for live events.
|
|
771
|
+
|
|
772
|
+
Server responses are `{ "jsonrpc": "2.0", "id": "...", "result": {...} }` on success or `{ "jsonrpc": "2.0", "id": "...", "error": { "code": ..., "message": "...", "data": {...} } }` on failure.
|
|
773
|
+
|
|
774
|
+
Wire envelope matches rummy's; clients written against rummy translate with cosmetic renames only.
|
|
775
|
+
|
|
776
|
+
### §13.3 Method registration
|
|
777
|
+
|
|
778
|
+
Every RPC method is registered on the daemon with metadata:
|
|
779
|
+
|
|
780
|
+
```ts
|
|
781
|
+
registry.register("loop.run", {
|
|
782
|
+
handler: async (params, ctx) => { /* ... */ },
|
|
783
|
+
description: "Run a model-driven loop with a prompt; streams log/entry notifications.",
|
|
784
|
+
params: {
|
|
785
|
+
prompt: "string — the user prompt for the loop",
|
|
786
|
+
sessionId: "number? — defaults to current attached session",
|
|
787
|
+
maxTurns: "number? — defaults to PLURNK_MAX_TURNS",
|
|
788
|
+
},
|
|
789
|
+
requiresInit: true,
|
|
790
|
+
longRunning: true,
|
|
791
|
+
});
|
|
792
|
+
```
|
|
793
|
+
|
|
794
|
+
Metadata fields:
|
|
795
|
+
|
|
796
|
+
- `handler(params, ctx) -> Promise<result>` — the implementation.
|
|
797
|
+
- `description: string` — one-line human description; surfaced by `discover`.
|
|
798
|
+
- `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.
|
|
799
|
+
- `requiresInit: boolean` — if true, server rejects this method until a session is attached.
|
|
800
|
+
- `longRunning: boolean` — exempt from the RPC timeout (`PLURNK_RPC_TIMEOUT`). Use for methods that intentionally take many seconds (running a loop).
|
|
801
|
+
|
|
802
|
+
### §13.4 Discovery
|
|
803
|
+
|
|
804
|
+
The `discover` method returns the entire method + notification catalog:
|
|
805
|
+
|
|
806
|
+
```json
|
|
807
|
+
{
|
|
808
|
+
"methods": {
|
|
809
|
+
"ping": { "description": "Liveness check.", "params": {} },
|
|
810
|
+
"loop.run": { "description": "Run a model-driven loop.", "params": {...} },
|
|
811
|
+
"...": "..."
|
|
812
|
+
},
|
|
813
|
+
"notifications": {
|
|
814
|
+
"log/entry": { "description": "A new log_entries row was written.", "params": {...} },
|
|
815
|
+
"loop/terminated": { "description": "A loop reached a terminal status.", "params": {...} },
|
|
816
|
+
"...": "..."
|
|
817
|
+
},
|
|
818
|
+
"capabilities": {
|
|
819
|
+
"providers": [...],
|
|
820
|
+
"schemes": [...],
|
|
821
|
+
"mimetypes": [...]
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
```
|
|
825
|
+
|
|
826
|
+
`capabilities` lists the registered plug-ins by `(kind, name)` so clients can show "what's available" without polling separate endpoints. A client connecting cold calls `discover` first, then renders its UI accordingly. No hardcoding of method names or capability lists in any client.
|
|
827
|
+
|
|
828
|
+
### §13.5 Core method set
|
|
829
|
+
|
|
830
|
+
The minimum v0 surface. Methods are grouped by concern.
|
|
831
|
+
|
|
832
|
+
**Liveness + introspection**
|
|
833
|
+
|
|
834
|
+
| Method | Params | Result | Notes |
|
|
835
|
+
|------------|--------|--------|-------|
|
|
836
|
+
| `ping` | none | `{}` | Liveness. No init required. |
|
|
837
|
+
| `discover` | none | catalog (§13.4) | No init required. |
|
|
838
|
+
|
|
839
|
+
**Sessions**
|
|
840
|
+
|
|
841
|
+
| Method | Params | Result | Notes |
|
|
842
|
+
|-------------------|---------------------|-------------------|-------|
|
|
843
|
+
| `session.create` | `name?: string` | `{ id, name }` | Creates new session; auto-name from timestamp if unprovided. |
|
|
844
|
+
| `session.list` | none | `{ sessions: Session[] }` | Lists all sessions. |
|
|
845
|
+
| `session.attach` | `id: number`, `runId?: number`, `runName?: string` | `{ id, name, runId, runName }` | Binds this connection to an existing session. Optional `runId` resumes that specific run (must belong to the session). Optional `runName` reuses-or-creates by name within the session. Both omitted → new auto-named run. |
|
|
846
|
+
| `session.runs` | `id?: number` | `{ runs: Run[] }` | Lists runs in a session (defaults to attached session); most-recent first. |
|
|
847
|
+
|
|
848
|
+
If a client issues a method requiring init (`requiresInit: true`) without first calling `session.attach` or `session.create`, the daemon auto-creates the envelope on demand: session → run → client loop, all persisted normally. Auto-creation is a convenience for one-off invocations (Telegram quick-queries, neovim ad-hoc dispatches, `plurnk "prompt"` CLI shots); the records carry through the same way explicitly-created ones do. **Auto-created ≠ auto-deleted.** Records persist for the log's forensic value. If a client wants active cleanup, that's a future `session.delete` / `session.archive` endpoint, opt-in.
|
|
849
|
+
|
|
850
|
+
**Loops (model-driven)**
|
|
851
|
+
|
|
852
|
+
| Method | Params | Result | Notes |
|
|
853
|
+
|---------------|-------------------------------------|------------------------|-------|
|
|
854
|
+
| `loop.run` | `prompt`, `maxTurns?`, `alias?` | `{ loopId, turnIds, finalStatus, hitMaxTurns, reason }` | Model-driven loop. Optional `alias` overrides the daemon's boot-time `PLURNK_MODEL` for this call (resolves via `ProviderRegistry`); unknown alias → error. Streams `log/entry` notifications during. `longRunning: true`. |
|
|
855
|
+
| `providers.list` | none | `{ aliases: ProviderAlias[] }` | Lists configured `PLURNK_MODEL_<alias>` entries with `{alias, provider, model, active}`. Clients use to populate model-selection UI. |
|
|
856
|
+
|
|
857
|
+
**DSL operations (client-driven, mirror the grammar)**
|
|
858
|
+
|
|
859
|
+
Per the **Speak in DSL, not plumbing** rule (AGENTS.md Standing Rules): RPC methods for client-driven ops construct DSL statements internally and dispatch through the same `Engine.dispatch` path the model uses. Param shapes are ergonomic (semantic names, not HEREDOC slot positions); the semantics ARE the DSL's.
|
|
860
|
+
|
|
861
|
+
Each `op.*` call creates a turn in the connection's client loop (§13.7) with the constructed statement as a single action, dispatches it, fires a `log/entry` notification to attached clients of the session, returns the dispatch result.
|
|
862
|
+
|
|
863
|
+
| Method | Params | Notes |
|
|
864
|
+
|---------------|---------------------------------------------------------|-------|
|
|
865
|
+
| `op.find` | `scope: string`, `matcher?: string`, `tags?: string[]`, `lineRange?: LineMarker` | Mirrors `<<FIND>>`. |
|
|
866
|
+
| `op.read` | `path: string`, `matcher?: string`, `lineRange?: LineMarker`, `tags?: string[]` | Mirrors `<<READ>>`. |
|
|
867
|
+
| `op.edit` | `path: string`, `content?: string`, `tags?: string[]`, `lineRange?: LineMarker` | Mirrors `<<EDIT>>`. |
|
|
868
|
+
| `op.copy` | `source: string`, `destination: string`, `tags?: string[]`, `lineRange?: LineMarker` | Mirrors `<<COPY>>`. |
|
|
869
|
+
| `op.move` | `source: string`, `destination?: string`, `tags?: string[]`, `lineRange?: LineMarker` | Mirrors `<<MOVE>>`. Missing `destination` = delete (null-body MOVE). |
|
|
870
|
+
| `op.show` | `path: string`, `matcher?: string`, `tags?: string[]`, `lineRange?: LineMarker` | Mirrors `<<SHOW>>`. |
|
|
871
|
+
| `op.hide` | `path: string`, `matcher?: string`, `tags?: string[]`, `lineRange?: LineMarker` | Mirrors `<<HIDE>>`. |
|
|
872
|
+
| `op.send` | `status: number`, `recipient?: string`, `body?: string` | Mirrors `<<SEND>>`. |
|
|
873
|
+
| `op.exec` | `cwd?: string`, `runtime?: string`, `command?: string` | Mirrors `<<EXEC>>`. |
|
|
874
|
+
| `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). |
|
|
875
|
+
| `op.parse` | `text: string` | Convenience: daemon parses raw DSL text via the grammar, dispatches each statement as actions of one turn, returns `{ results: DispatchResult[] }`. |
|
|
876
|
+
|
|
877
|
+
All `op.*` methods return `{ status: number, ...op-specific-extras }`. They are `requiresInit: true`. They are NOT `longRunning` (one dispatch per call; result returns when the engine returns).
|
|
878
|
+
|
|
879
|
+
Future-reserved (post-v0):
|
|
880
|
+
|
|
881
|
+
- `subscription.list` — list active streaming subscriptions (§7).
|
|
882
|
+
- `subscription.cancel` — analog of `SEND[499]` over RPC, though `op.send({status: 499, recipient: path})` does the same thing today.
|
|
883
|
+
|
|
884
|
+
### §13.6 Notifications
|
|
885
|
+
|
|
886
|
+
Server-initiated events streamed to the client over the same WebSocket. Critical for live waterfall rendering.
|
|
887
|
+
|
|
888
|
+
| Notification | Params | When fired |
|
|
889
|
+
|--------------------|-------------------------------------|------------|
|
|
890
|
+
| `log/entry` | `{ entry: LogEntry }` | Every time a `log_entries` row is written. |
|
|
891
|
+
| `loop/terminated` | `{ loopId, finalStatus, hitMaxTurns }` | When a loop reaches a terminal status. |
|
|
892
|
+
| `session/created` | `{ session: Session }` | When a session is created (any client's action; gives multi-client awareness). |
|
|
893
|
+
| `stream/event` | `{ entryId, channel, state, contentLength }` | When a channel's content grows or its state transitions. For clients rendering live; the model only sees state at turn boundaries. {§13.6-stream-event-on-channel-change} |
|
|
894
|
+
|
|
895
|
+
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).
|
|
896
|
+
|
|
897
|
+
Notifications are scoped to the connection's attached session — a client attached to session A does NOT receive `log/entry` notifications for actions on session B. (Cross-session observation is a future feature; v0 keeps the scope tight.)
|
|
898
|
+
|
|
899
|
+
### §13.7 Connection lifecycle
|
|
900
|
+
|
|
901
|
+
```
|
|
902
|
+
[client] [daemon]
|
|
903
|
+
| |
|
|
904
|
+
|-- ws.connect ----------------------------------->|
|
|
905
|
+
|<------- on('open') --------------------------- |
|
|
906
|
+
| |
|
|
907
|
+
|-- discover() ---------------------------------->|
|
|
908
|
+
|<------- { methods, notifications, capabilities }|
|
|
909
|
+
| |
|
|
910
|
+
|-- session.attach(id=42) ------------------------>|
|
|
911
|
+
|<------- { id: 42, name: "demo-session" } |
|
|
912
|
+
| (daemon opens a client loop in session 42) |
|
|
913
|
+
| |
|
|
914
|
+
|-- loop.run(prompt="...") ----------------------->|
|
|
915
|
+
|<-- notification: log/entry { ... } |
|
|
916
|
+
|<-- notification: log/entry { ... } |
|
|
917
|
+
|<-- notification: loop/terminated { ... } |
|
|
918
|
+
|<------- { loopId, turnIds, finalStatus: 200 } |
|
|
919
|
+
| |
|
|
920
|
+
|-- op.dispatch(op=...) -------------------------->|
|
|
921
|
+
|<-- notification: log/entry { ... } |
|
|
922
|
+
|<------- { status: 201 } |
|
|
923
|
+
| |
|
|
924
|
+
|-- ws.close ------------------------------------->|
|
|
925
|
+
| (daemon closes the client loop; session keeps)|
|
|
926
|
+
```
|
|
927
|
+
|
|
928
|
+
The **client loop** is the envelope for client-origin actions. When a session is attached (explicit or auto-created), the daemon opens a loop in the session's current run (auto-creating the run too if absent) with `origin = "client"`. Every `op.dispatch` call creates a turn in that loop. On disconnect, the loop's `status` transitions to a closed terminal but the rows persist. Multiple connections to the same session each get their own client loop; they coexist.
|
|
929
|
+
|
|
930
|
+
`loop.run` creates a separate, normal loop (origin = "model") for the model-driven turn sequence. It coexists with the client loop in the same run.
|
|
931
|
+
|
|
932
|
+
### §13.8 Errors
|
|
933
|
+
|
|
934
|
+
Standard JSON-RPC error codes:
|
|
935
|
+
|
|
936
|
+
| Code | Meaning |
|
|
937
|
+
|--------|-------------------------------|
|
|
938
|
+
| -32700 | Parse error (malformed JSON) |
|
|
939
|
+
| -32600 | Invalid request |
|
|
940
|
+
| -32601 | Method not found |
|
|
941
|
+
| -32602 | Invalid params |
|
|
942
|
+
| -32603 | Internal error |
|
|
943
|
+
|
|
944
|
+
Plurnk-specific extensions in the `-32000` to `-32099` range:
|
|
945
|
+
|
|
946
|
+
| Code | Meaning |
|
|
947
|
+
|--------|----------------------------------------------------|
|
|
948
|
+
| -32000 | Not initialized (method requires session attach) |
|
|
949
|
+
| -32001 | Session not found |
|
|
950
|
+
| -32002 | Loop not found |
|
|
951
|
+
| -32003 | Entry not found (404 from the engine layer) |
|
|
952
|
+
| -32004 | Provider unavailable |
|
|
953
|
+
| -32005 | Scheme unavailable |
|
|
954
|
+
| -32006 | Mimetype unavailable |
|
|
955
|
+
| -32007 | Timeout |
|
|
956
|
+
|
|
957
|
+
Error responses MAY include `data: { ... }` with structured context (the path that was 404'd, the method that timed out, etc.) for client error rendering.
|
|
958
|
+
|
|
959
|
+
### §13.9 Versioning
|
|
960
|
+
|
|
961
|
+
`plurnk-service` exposes a `protocolVersion` field in `discover`'s response (semver). Major version mismatches are a contract break — clients SHOULD refuse to operate on a major mismatch. Minor/patch increments are backward-compatible.
|
|
962
|
+
|
|
963
|
+
Current: `protocolVersion: "0.1.0"`. The floor is green and the daemon has been exercised end-to-end via the `plurnk` TUI client (which now graduates to its own agent). Promotes to `1.0.0` when an independent external client (neovim plugin or Telegram bot) lands AND the mimetype/channel/transaction work below has settled.
|
|
964
|
+
|
|
965
|
+
---
|
|
966
|
+
|
|
967
|
+
## §14 Architectural decisions
|
|
968
|
+
|
|
969
|
+
Each entry: the question, the answer, the rationale, the migration path if revisited.
|
|
970
|
+
|
|
971
|
+
### §14.1 Packet assembly: engine-direct, not filter-chain
|
|
972
|
+
|
|
973
|
+
**Question.** Rummy assembles `<index>`, `<log>`, `<turn>`, `<system_commands>`, `<system_requirements>` via priority-ordered filter chains (`assembly.system` + `assembly.user`); plugins each filter for their data and append their section. Plurnk assembles `packet.system.index` directly in `Engine.#buildIndex` by querying visibility + entries + entry_channels and routing each channel's content through its mimetype handler; `packet.system.log` similarly via `Engine.#buildLog`. Both queries are engine-direct.
|
|
974
|
+
|
|
975
|
+
**Decision.** v0 stays engine-direct. The engine reads the DB and constructs the packet. Plugin-driven assembly is out of v0 scope. {§14.1-engine-direct-assembly}
|
|
976
|
+
|
|
977
|
+
**Rationale.**
|
|
978
|
+
- Plurnk's bundled extension set is small (3 entry-bearing schemes, 2 mimetypes, 1 provider). No current plugin wants to inject a packet section.
|
|
979
|
+
- 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.
|
|
980
|
+
- 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.
|
|
981
|
+
- 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.
|
|
982
|
+
|
|
983
|
+
**Migration path if revisited.** If a future plugin needs to inject a packet section (e.g., a `<turn>` metadata table per rummy SPEC §packet_structure), the engine grows a single `packet.augment` filter hook called after `#buildIndex` returns. Plugins subscribe with a priority; each returns a `system` and/or `user` augmentation object that gets merged into the packet shape. This is additive — the engine-direct base case stays; plugins augment.
|
|
984
|
+
|
|
985
|
+
### §14.2 Budget unit: character count for v0
|
|
986
|
+
|
|
987
|
+
**Question.** `PLURNK_ENTRY_SIZE_DEFAULT_TOKENS` is named for tokens; current implementation in `Engine.#previewBudget` treats it as a character-count cap passed to `MimetypeHandler.preview(content, budget)`. The MIMETYPES.md contract documents budget as a number, semantic-agnostic. The env var name and the implementation disagree.
|
|
988
|
+
|
|
989
|
+
**Decision.** v0 treats budget as character count. The env var keeps its `_TOKENS` suffix as forward-naming for the eventual switch but currently means characters. {§14.2-budget-is-characters-v0}
|
|
990
|
+
|
|
991
|
+
**Rationale.**
|
|
992
|
+
- Token counts require a per-provider tokenizer. PROVIDERS.md §11 marks `countTokens` as out of v0 contract; no provider currently exposes it.
|
|
993
|
+
- Character count is a tokenizer-independent first approximation. Wrong by a factor of ~3-4× for English (1 token ≈ 3-4 chars), but consistent and zero-config.
|
|
994
|
+
- Switching the unit later requires: (a) `countTokens` lands on the provider contract, (b) engine caches token counts per `(provider_id, content_hash)`, (c) mimetype.preview gets a tokenizer reference or returns content for engine to tokenize-and-truncate. Out of v0 scope.
|
|
995
|
+
|
|
996
|
+
**Migration path if revisited.** When provider `countTokens` lands and the engine has per-channel token accounting, `PLURNK_ENTRY_SIZE_DEFAULT_TOKENS` becomes literal tokens. Mimetype handlers either receive a tokenizer reference in `preview(content, budget, countTokens?)` or the engine post-processes character-bounded `preview` output through tokenizer + truncate. The MIMETYPES.md contract revision is non-breaking (signature stays `(content, budget) → string`); the semantic of `budget` changes from char-count to token-count.
|
|
997
|
+
|
|
998
|
+
---
|
|
999
|
+
|
|
1000
|
+
## §15 Packet shape
|
|
1001
|
+
|
|
1002
|
+
The canonical packet shape is defined by `@plurnk/plurnk-grammar` (`schema/Packet.json`, ≥0.4.0). Engine assembles in `Engine.#buildPacket`; plugins do not augment in v0 (§14.1). This section describes plurnk-service's responsibilities under that contract — see grammar for the authoritative schema.
|
|
1003
|
+
|
|
1004
|
+
```ts
|
|
1005
|
+
type Packet = {
|
|
1006
|
+
tokens: number;
|
|
1007
|
+
system: {
|
|
1008
|
+
tokens: number;
|
|
1009
|
+
system_definition: string;
|
|
1010
|
+
persona: string;
|
|
1011
|
+
index: PacketEntry[]; // visible entries (§4 / §5)
|
|
1012
|
+
log: PacketLogRow[]; // chronological action-entries (§7)
|
|
1013
|
+
};
|
|
1014
|
+
user: {
|
|
1015
|
+
tokens: number;
|
|
1016
|
+
prompt: string;
|
|
1017
|
+
telemetry: { budget: string; errors: object[] }; // §15.1
|
|
1018
|
+
system_requirements: string;
|
|
1019
|
+
};
|
|
1020
|
+
assistant: { tokens: number; content: string; ops: PlurnkStatement[]; reasoning: string | null };
|
|
1021
|
+
assistantRaw: unknown;
|
|
1022
|
+
};
|
|
1023
|
+
```
|
|
1024
|
+
|
|
1025
|
+
### §15.1 user.telemetry — model-facing runtime telemetry
|
|
1026
|
+
|
|
1027
|
+
The slot for telemetry the model MUST react to right now: budget pressure and last-turn failures that didn't produce an action-entry. Rendered prominently at the bottom of the user section so the model cannot ignore it. Errors here are transient — they appear on the turn AFTER the failure and clear once the model has seen them. The action-entries (`packet.system.log[]`) are the durable audit; `telemetry.errors[]` is the **alert**.
|
|
1028
|
+
|
|
1029
|
+
**Grammar contract (authoritative, plurnk-grammar 0.4.0):**
|
|
1030
|
+
|
|
1031
|
+
- `budget: string` — text/markdown. Renderer-provided summary of remaining context / cost / etc. Empty string when nothing to surface.
|
|
1032
|
+
- `errors: object[]` — element shape intentionally open at v0. Consumers populate as actionless-failure rendering needs solidify. Empty array when no errors to surface.
|
|
1033
|
+
|
|
1034
|
+
**Plurnk-service rendering (v0):**
|
|
1035
|
+
|
|
1036
|
+
- `budget` is rendered as a short markdown line. Unit follows §14.2 (character count); the exact rendering is engine-internal and may evolve without a schema change.
|
|
1037
|
+
- `errors[]` carries one object per actionless failure from the previous turn. Service's working element shape (subject to tightening as needs solidify):
|
|
1038
|
+
```
|
|
1039
|
+
{ kind: "parse" | "dispatch_crash" | "no_send" | "watchdog" | "budget_overflow" | "rail",
|
|
1040
|
+
message: string,
|
|
1041
|
+
detail?: unknown }
|
|
1042
|
+
```
|
|
1043
|
+
- 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}
|
|
1044
|
+
|
|
1045
|
+
**No `error://` scheme.** Actionless failures route to telemetry, not to a queryable scheme namespace.
|