@skillstate/opencode 2.2.2 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +194 -142
  2. package/dist/feedback.d.ts +178 -0
  3. package/dist/feedback.d.ts.map +1 -0
  4. package/dist/feedback.js +235 -0
  5. package/dist/feedback.js.map +1 -0
  6. package/dist/index.d.ts +58 -2
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +48 -3
  9. package/dist/index.js.map +1 -1
  10. package/dist/mode.d.ts +97 -0
  11. package/dist/mode.d.ts.map +1 -0
  12. package/dist/mode.js +111 -0
  13. package/dist/mode.js.map +1 -0
  14. package/dist/opencode-adapter.d.ts +10 -46
  15. package/dist/opencode-adapter.d.ts.map +1 -1
  16. package/dist/opencode-adapter.js +1 -64
  17. package/dist/opencode-adapter.js.map +1 -1
  18. package/dist/paper-mode.d.ts +421 -0
  19. package/dist/paper-mode.d.ts.map +1 -0
  20. package/dist/paper-mode.js +445 -0
  21. package/dist/paper-mode.js.map +1 -0
  22. package/dist/plugin.d.ts +218 -76
  23. package/dist/plugin.d.ts.map +1 -1
  24. package/dist/plugin.js +867 -232
  25. package/dist/plugin.js.map +1 -1
  26. package/dist/response-sink.d.ts +208 -0
  27. package/dist/response-sink.d.ts.map +1 -0
  28. package/dist/response-sink.js +243 -0
  29. package/dist/response-sink.js.map +1 -0
  30. package/dist/runtime.d.ts +203 -0
  31. package/dist/runtime.d.ts.map +1 -0
  32. package/dist/runtime.js +332 -0
  33. package/dist/runtime.js.map +1 -0
  34. package/dist/session-registry.d.ts +148 -0
  35. package/dist/session-registry.d.ts.map +1 -0
  36. package/dist/session-registry.js +236 -0
  37. package/dist/session-registry.js.map +1 -0
  38. package/dist/spec-loader.d.ts +81 -0
  39. package/dist/spec-loader.d.ts.map +1 -0
  40. package/dist/spec-loader.js +163 -0
  41. package/dist/spec-loader.js.map +1 -0
  42. package/dist/state-store.d.ts +126 -0
  43. package/dist/state-store.d.ts.map +1 -0
  44. package/dist/state-store.js +186 -0
  45. package/dist/state-store.js.map +1 -0
  46. package/dist/step-boundary.d.ts +91 -0
  47. package/dist/step-boundary.d.ts.map +1 -0
  48. package/dist/step-boundary.js +109 -0
  49. package/dist/step-boundary.js.map +1 -0
  50. package/dist/system-hint.d.ts +129 -0
  51. package/dist/system-hint.d.ts.map +1 -0
  52. package/dist/system-hint.js +166 -0
  53. package/dist/system-hint.js.map +1 -0
  54. package/dist/tools.d.ts +148 -0
  55. package/dist/tools.d.ts.map +1 -0
  56. package/dist/tools.js +350 -0
  57. package/dist/tools.js.map +1 -0
  58. package/package.json +3 -2
  59. package/dist/plugin-types.d.ts +0 -71
  60. package/dist/plugin-types.d.ts.map +0 -1
  61. package/dist/plugin-types.js +0 -8
  62. package/dist/plugin-types.js.map +0 -1
package/README.md CHANGED
@@ -2,11 +2,10 @@
2
2
 
3
3
  # @skillstate/opencode
4
4
 
5
- **OpenCode platform adapter for the @skillstate/core runtime — the only case with real O(1) prompt economy via history trimming.**
5
+ **OpenCode v2 plugin for the @skillstate/core runtime — three native tools and one additive system fragment.**
6
6
 
7
7
  [![npm version](https://img.shields.io/npm/v/@skillstate/opencode)](https://www.npmjs.com/package/@skillstate/opencode)
8
8
  [![node](https://img.shields.io/node/v/@skillstate/opencode)](https://www.npmjs.com/package/@skillstate/opencode)
9
- [![Tests](https://img.shields.io/badge/tests-873%20passing-brightgreen)](https://github.com/vitkuz573/skillstate)
10
9
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](https://github.com/vitkuz573/skillstate/blob/main/LICENSE)
11
10
 
12
11
  </div>
@@ -14,11 +13,10 @@
14
13
  ---
15
14
 
16
15
  `@skillstate/opencode` integrates the paper-exact runtime
17
- ([`@skillstate/core`](../core)) into **OpenCode**. It emits a `SKILL.md` so the
18
- host discovers the skill, and a plugin that hooks
19
- `experimental.chat.messages.transform` to **trim history before every LLM
20
- call** — dropping old messages and injecting only the state, which is genuine
21
- **O(1)** prompt footprint.
16
+ ([`@skillstate/core`](../core)) into **OpenCode v2**. The integration is the
17
+ npm package itself, loaded from the PROJECT `opencode.json(c)` under the v2
18
+ `plugins` key. It registers three native tools and one `context` hook, and it
19
+ is project-local and inert when the project has no `.skillstate/` state.
22
20
 
23
21
  > **@non-paper** — no adapters exist in arXiv 2608.26263v3. This adapter is an
24
22
  > additive integration, not part of the paper.
@@ -29,160 +27,214 @@ call** — dropping old messages and injecting only the state, which is genuine
29
27
  npm i @skillstate/core @skillstate/opencode
30
28
  ```
31
29
 
32
- Requires Node.js >= 20. TypeScript types are bundled.
30
+ Requires Node.js >= 20 and OpenCode >= 2.0. TypeScript types are bundled, and
31
+ `@opencode/plugin` is a real dependency — the plugin is typed against the
32
+ host's own API rather than against hand-written local declarations.
33
33
 
34
- ## Quick start
34
+ ## Configure OpenCode
35
+
36
+ `skillstate init` writes this for you. By hand:
37
+
38
+ ```jsonc
39
+ // opencode.json
40
+ {
41
+ "$schema": "https://opencode.ai/config.json",
42
+ "plugins": ["@skillstate/opencode"]
43
+ }
44
+ ```
45
+
46
+ That is the whole configuration. The MCP server is deliberately NOT
47
+ registered here — see below.
48
+
49
+ OpenCode v1 is not supported: the config key was `plugin`, and a v1 plugin
50
+ implementation does not run in v2 at all. `skillstate init` migrates a config
51
+ written by an earlier version — our entry is removed from the legacy `plugin`
52
+ array, the key is dropped when nothing of yours is left in it, and an
53
+ `mcp.skillstate` entry from a previous install is removed.
54
+
55
+ ### Why there is no MCP entry here
56
+
57
+ The MCP server is still shipped and still registered for **Claude Code,
58
+ Codex and any other MCP-capable host** — there it is the only way in. In
59
+ OpenCode the plugin already provides native tools, so registering both is not
60
+ free redundancy:
61
+
62
+ native 3 tools 3 357 chars ~839 tokens
63
+ MCP 14 tools 9 814 chars ~2 454 tokens
64
+ extra 6 457 chars ~1 614 tokens on EVERY request
65
+
66
+ `spec.get` pours a further 1 286 characters of prose into context per call.
67
+
68
+ Worse, the two surfaces disagree about what may be written, over one file:
69
+
70
+ native write -> {"added":["decision"],"updated":[],"deleted":[]}
71
+ MCP write -> {"valid":false,"error":"Unknown key: decision"}
72
+
73
+ The native tools are schema-free; the MCP server validates against the
74
+ procedural spec. With both advertised, whether a note is saved depends on
75
+ which one the model happened to pick.
76
+
77
+ ## What the plugin registers
35
78
 
36
79
  ```ts
37
- import { OpenCodeAdapter } from '@skillstate/opencode';
38
- import { INTERCODE_CTF_SPEC } from '@skillstate/core/schemas';
39
-
40
- const adapter = new OpenCodeAdapter();
41
-
42
- // SKILL.md with frontmatter (name/description/version) + an
43
- // execution_context block pointing at the persisted state file:
44
- const skillMd = adapter.generateSkillMd(INTERCODE_CTF_SPEC, './.skillstate.json');
45
-
46
- // Plugin with real O(1) history trimming via experimental.chat.messages.transform.
47
- // The state path is resolved per session from the host cwd inside the plugin —
48
- // no baked path:
49
- const plugin = adapter.generatePluginCode();
50
-
51
- // Default keeps the last 3 non-system messages + state injection.
52
- // Configure history depth:
53
- const plugin2 = adapter.generatePluginCode({
54
- maxHistoryMessages: 5, // keep last 5 non-system messages
55
- });
56
-
57
- // Persist the plugin to disk atomically:
58
- const saved = await adapter.savePluginCode(
59
- './skillstate.plugin.ts',
60
- { maxHistoryMessages: 5 },
61
- );
80
+ import SkillStatePlugin from '@skillstate/opencode';
81
+
82
+ await SkillStatePlugin.setup(ctx);
62
83
  ```
63
84
 
64
- ## Install into OpenCode (host)
85
+ | Registration | Detail |
86
+ | --- | --- |
87
+ | `ctx.tool.transform(...)` | `skillstate_read`, `skillstate_update`, `skillstate_merge` |
88
+ | `ctx.session.hook('context')` | `notes`: pushes ONE bounded fragment onto `event.system`. `paper`: replaces the context with the A.4 prompt |
89
+ | `ctx.event.subscribe(...)` | session parent edges, for sub-agent scoping; in paper mode also the `state_patch` sink |
90
+
91
+ Nothing else. It does not register `compaction`, `generate` or `title` hooks,
92
+ and in the default `notes` mode it never touches `event.messages`.
93
+
94
+ ### Modes
95
+
96
+ `notes` is the default and stays the default. `paper` is the paper's own
97
+ specification and is opt-in:
98
+
99
+ ```jsonc
100
+ // skillstate.json in the project root
101
+ { "mode": "paper" }
102
+ ```
103
+
104
+ ```sh
105
+ SKILLSTATE_MODE=paper opencode # environment wins over the file
106
+ ```
107
+
108
+ `paper` replaces the model-facing context with `Aₜ = (P, Σₜ, Oₜ)` — the
109
+ Appendix A.4 prompt, byte-verbatim — and applies the `state_patch` the model
110
+ emits in response, parsed from the `session.text.ended` event. Prompt size is
111
+ then constant regardless of transcript length.
112
+
113
+ Note that paper mode means the model no longer sees its own transcript: §3.2
114
+ discards the reasoning trace by construction. Anything worth remembering has
115
+ to be in Σₜ. An unrecognised mode value falls back to `notes` and is reported
116
+ rather than silently applied.
65
117
 
66
- Tested end-to-end against OpenCode ≥ 1.17 (`@opencode-ai/plugin` 1.15.x
67
- hook contracts). Four steps:
118
+ ### Tools
68
119
 
69
- **1. Generate the plugin file** (one-off, or wire into a build script):
120
+ Every tool returns a discriminated result, because a tool that declares an
121
+ `output` schema must return a value matching it — a failure path that returned
122
+ only text is rejected by the host with "tool did not return its declared
123
+ output", which loses the reason.
70
124
 
71
125
  ```ts
72
- // scripts/gen-plugin.mjs (run with: node scripts/gen-plugin.mjs)
73
- import { OpenCodeAdapter } from '@skillstate/opencode';
74
- import { writeFileSync } from 'node:fs';
126
+ { ok: true, value: {...} }
127
+ { ok: false, error: "..." } // e.g. a patch over the 64 KiB budget
128
+ ```
129
+
130
+ | Tool | Purpose |
131
+ | --- | --- |
132
+ | `skillstate_read` | what this session has already saved |
133
+ | `skillstate_update` | merge a patch — the only write path (`null` deletes a key) |
134
+ | `skillstate_merge` | fold sub-agent notes back into the root session |
135
+
136
+ Scoping is automatic: the session's own scope comes from
137
+ `ToolContext.sessionID` plus the session registry, so the model never passes a
138
+ session id and can never write another agent's file by guessing one.
139
+
140
+ ## Why the transcript is never rewritten
75
141
 
76
- const adapter = new OpenCodeAdapter();
77
- const code = adapter.generatePluginCode({ maxHistoryMessages: 3 });
78
- writeFileSync('/abs/path/to/skillstate.plugin.ts', code);
142
+ The previous version of this plugin rewrote the conversation on every model
143
+ request:
144
+
145
+ ```ts
146
+ // removed — this is what broke it
147
+ const trimmed = messages.filter((m) => m.info.role !== 'system').slice(-maxHistory);
148
+ messages.length = 0;
149
+ messages.push(...systemMessages, ...trimmed, stateMessage);
79
150
  ```
80
151
 
81
- Put the generated `skillstate.plugin.ts` anywhere stable (absolute path is
82
- safest), e.g. `<project>/.opencode-runtime/skillstate.plugin.ts`. The plugin
83
- must be able to resolve `@skillstate/opencode` at load time (install it in
84
- the project or globally) — hook logic lives in that package.
152
+ Two independent failures in three lines:
85
153
 
86
- **2. Create the initial state file** (`<project>/.skillstate/skillstate.json`),
87
- matching your spec schema, e.g. for `INTERCODE_CTF_SPEC`:
154
+ 1. `slice(-3)` **deleted the task statement, the tool results and the error
155
+ messages** the agent had just been given. It was reasoning about work it
156
+ could no longer see. Users reported that the agent "started talking
157
+ nonsense and would not do my tasks" — it could not, because the task was
158
+ no longer in the prompt.
159
+ 2. The injected message was appended **last**, as `role: "user"`. For a chat
160
+ model the last user message is the current instruction, so a JSON blob of
161
+ state displaced the user's actual request.
88
162
 
89
- ```json
90
- {
91
- "discovered_flags": [],
92
- "tested_hypotheses": [],
93
- "active_files": [],
94
- "working_dir": "/abs/work/dir",
95
- "cmd_summary": "initialized"
96
- }
163
+ The old test suite asserted the bug — `expect(messages).toHaveLength(1 + 3 + 1)`.
164
+ It is replaced by `tests/opencode/context-integrity.test.ts`, which asserts the
165
+ opposite.
166
+
167
+ Four rules, each enforced by a test:
168
+
169
+ | Rule | Enforced by |
170
+ | --- | --- |
171
+ | `notes` mode never mutates `event.messages` | `context-integrity.test.ts` |
172
+ | Never inject behavioural instructions | `system-hint.test.ts` |
173
+ | Inert until a state file exists | `plugin.test.ts` |
174
+ | `paper` mode replaces the context with exactly Aₜ | `paper-mode.test.ts` |
175
+
176
+ Measured on a live OpenCode 2.0.19, one session, five turns:
177
+
178
+ ```
179
+ turn 1: messages= 3 hint=True marker=True override=False
180
+ turn 3: messages= 7 hint=True marker=True override=False
181
+ turn 5: messages=11 hint=True marker=True override=False
97
182
  ```
98
183
 
99
- **3. Register the plugin in `opencode.jsonc`** — add a `file://` entry to the
100
- `plugin` array (local paths are resolved by OpenCode and imported directly;
101
- TypeScript is supported because plugins load under Bun):
184
+ The transcript grows. Under the previous version it was pinned at 3.
102
185
 
103
- ```jsonc
104
- {
105
- "plugin": [
106
- "@ai-sdk/anthropic",
107
- "file:///abs/path/to/skillstate.plugin.ts"
108
- ]
109
- }
186
+ ### The system fragment
187
+
188
+ Advisory, bounded, and deliberately dull. It names the state file, renders the
189
+ current notes (a large document is summarized to a key list plus a pointer to
190
+ `skillstate_read`), lists the tools, and says the notes are a side channel
191
+ rather than the task. It contains no "you must", no "always", and no output
192
+ format — that framing is what turned a persistence aid into a prompt override.
193
+
194
+ The wording is a product requirement, not prose taste, and
195
+ `system-hint.test.ts` fails the build if that framing creeps back.
196
+
197
+ ## Design notes
198
+
199
+ - **Per-project addressing.** State resolves from
200
+ `ctx.location.project.canonical`, never `process.cwd()` — one v2 server
201
+ serves many projects, so the process cwd is the wrong answer.
202
+ - **Sub-agent isolation.** The parent edge comes from the real event stream
203
+ (`session.created` / `session.forked`, `data.parentID`). A session is
204
+ scoped to `<parentPrefix>-<full session id>`; the full id, not a prefix, so
205
+ two siblings sharing an 8-char prefix cannot collapse into one file.
206
+ - **No premature trust.** A session not yet seen on the stream is treated as a
207
+ root session, which is what a single-session user expects.
208
+ - **Durability.** Every mutation runs under the core cross-process lock and
209
+ lands via temp-sibling → fsync → rename. Reads never throw.
210
+ - **Bounded prompt cost.** State above 4 KB is summarized rather than inlined,
211
+ and a patch above 64 KiB is refused with a reason.
212
+
213
+ ## The paper adapter
214
+
215
+ `OpenCodeAdapter` is still exported. It is the paper-exact `PlatformAdapter`
216
+ (A.4 prompt format) used by the benchmark — it is **not** the host
217
+ integration, and nothing in the plugin path calls it. Note that its
218
+ `injectState` still emits the paper's `STATE_PATCH_CONTRACT`, which is
219
+ exactly the instruction pattern the plugin avoids.
220
+
221
+ ## Tests
222
+
223
+ ```bash
224
+ npm test -- --project opencode
110
225
  ```
111
226
 
112
- **4. Install the SKILL.md** — write `adapter.generateSkillMd(spec, statePath)`
113
- to `~/.config/opencode/skills/skillstate/SKILL.md` (global) or
114
- `.opencode/skills/skillstate/SKILL.md` (project). Keep the frontmatter fields
115
- `name` (must match the folder name) and a one-line `description`; the
116
- generated frontmatter may need a manual trim to those two fields.
117
-
118
- Verify with `opencode debug config` (plugin entry shows under `plugin`) and
119
- `opencode debug skill` (your skill is listed). Hook notes for OpenCode ≥ 1.17:
120
- `messages.transform` receives `{ info: Message, parts: Part[] }` entries and
121
- must mutate `output.messages` **in place**; the plugin injects state as a
122
- synthetic `{ info, parts }` message.
123
-
124
- ## API / Exports
125
-
126
- Root path `@skillstate/opencode` exports the adapter and the static plugin:
127
-
128
- - `new OpenCodeAdapter()` — implements `PlatformAdapter` (`name = 'opencode'`).
129
- - `generateSkillMd(spec, statePath?): string` — a `SKILL.md` body with
130
- frontmatter and a state-based process description.
131
- - `generatePluginCode(options?): string` — a thin plugin loader
132
- (`import { createSkillStatePlugin } from '@skillstate/opencode'`;
133
- `options.maxHistoryMessages`, default 3). State resolution is always
134
- per-project and lives inside the static plugin. Hooks:
135
- `experimental.chat.messages.transform` (real history trimming),
136
- `experimental.session.compacting` (inject state into compaction context),
137
- `tool.execute.after` (persist `state_patch` to disk).
138
- - `createSkillStatePlugin({ maxHistoryMessages? })` — the static plugin
139
- factory (single source of truth for the hook logic); state is resolved from
140
- the session cwd on every hook call via
141
- `resolveStatePathForCwd(process.cwd(), os.homedir(), agentId)`. The
142
- `event` hook registers sub-agent sessions from the host bus
143
- (`session.created`/`session.updated` carry `info.parentID`): a sub-agent
144
- session resolves to `agents/<parentPrefix>-<sessionPrefix>/` instead of
145
- overwriting its parent's state file.
146
- - `resolveStatePathForCwd(cwd, home?, agentId?): string` — the per-project
147
- state path resolution (pure path arithmetic, no filesystem access).
148
- - `pluginAgentId(input, messages?)` / `scopedAgentId(agentId)` /
149
- `registerSessionParent(sessionId, parentId)` / `resetSessionParents()` —
150
- the agent-scope plumbing (session id → `agents/` directory name).
151
- - `readSkillState` / `saveSkillState` / `mergePatch` / `extractPatch` — the
152
- plugin's state helpers, shared by the static plugin.
153
- - `savePluginCode(target, options?): Promise<string>` — writes the plugin
154
- atomically and returns the destination. `target` accepts a raw path or a
155
- `{ root, name }` ref confined by `resolveStatePath` (`..` escapes throw).
156
- - `injectState(state, spec): string` / `formatPrompt(state, observation, spec): string`.
157
- - `extractPatch(response): StatePatch | null` / `extractAction(response): string | null`.
158
-
159
- ## Notes
160
-
161
- - **Real O(1).** Unlike Claude Code (append-only hooks) and Codex
162
- (hooks + experimental app-server fork-trim), OpenCode exposes
163
- `experimental.chat.messages.transform`, so the plugin drops old messages
164
- instead of just hiding them — only the last N non-system messages plus an
165
- injected state message reach the LLM.
166
- - The generated plugin is a **thin loader**: it imports
167
- `createSkillStatePlugin` from `@skillstate/opencode` (one source of truth).
168
- Hook logic lives only in `src/plugin.ts` — regenerate rather than editing
169
- generated files. **WHERE STATE LIVES** (each opencode session reads AND
170
- writes the same path within its cwd — no cross-file surprises):
171
- - main session: `<cwd>/.skillstate/skillstate.json`
172
- - sub-agent session (Task sub-agents carry `parentID` on the session):
173
- `<cwd>/.skillstate/agents/<parentPrefix>-<sessionPrefix>/skillstate.json`
174
- - a session started in `$HOME`: the global bucket
175
- `~/.skillstate/global/...` with the same main/sub split.
176
- - Depends on [`@skillstate/core`](../core) for `PromptTransformer`,
177
- `atomicWriteFile`, and `resolveStatePath`.
178
-
179
- ## Related
180
-
181
- - Paper: [arXiv:2608.26263](https://arxiv.org/abs/2608.26263).
182
- - Core runtime: [`@skillstate/core`](../core).
183
- - [`state.md`](../../state.md) — design notes.
184
- - Other adapters: `@skillstate/claude`, `@skillstate/codex`, `@skillstate/mcp`.
227
+ | File | Covers |
228
+ | --- | --- |
229
+ | `context-integrity.test.ts` | the transcript is never rewritten |
230
+ | `system-hint.test.ts` | the fragment never overrides the model |
231
+ | `session-registry.test.ts` | the v2 event shape, the session tree, eviction |
232
+ | `state-store.test.ts` | addressing, atomicity, merge semantics |
233
+ | `tools.test.ts` | schemas, validation, scoping, refusals |
234
+ | `plugin.test.ts` | setup, teardown, per-project addressing |
235
+
236
+ Coverage thresholds are 100% on statements, branches, functions and lines.
185
237
 
186
238
  ## License
187
239
 
188
- [MIT](LICENSE) © 2026 Vitaly Kuzyaev
240
+ MIT
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Corrective feedback for a rejected state patch, in paper mode.
3
+ *
4
+ * ── The hole this closes ─────────────────────────────────────────────────
5
+ *
6
+ * `PaperStateSink` computed a {@link SinkOutcome} for every assistant text
7
+ * block and `plugin.ts` discarded it. All seven rejection reasons were
8
+ * calculated and thrown away, so a model whose `state_patch` failed to parse
9
+ * received a byte-identical next prompt and no indication that anything had
10
+ * gone wrong.
11
+ *
12
+ * The consequences were not subtle. Σₜ stops moving — a model that has been
13
+ * silently failing for ten steps sees the same stale state each time and
14
+ * keeps producing output the integration cannot accept. On a long-horizon
15
+ * task, which is the entire reason the paper exists, that is total failure
16
+ * that looks like the model refusing to work.
17
+ *
18
+ * `SkillStateRuntime` does not have this problem: it owns the loop, so
19
+ * `runtime.ts:404` can re-prompt the model with `withRetryFeedback` and try
20
+ * again inside the same step. A host plugin cannot do that. It cannot invoke
21
+ * a tool on the model's behalf, and the v2 session API has no response hook
22
+ * to re-enter the model from.
23
+ *
24
+ * ── Where the feedback goes, and why there ───────────────────────────────
25
+ *
26
+ * Appendix A.4 gives the model exactly three things: P (instructions), Σₜ
27
+ * (state), and Oₜ (latest observation). A rejected patch is a fact about the
28
+ * environment — the integration refused it and can say why. That is an
29
+ * OBSERVATION, not an instruction, and Oₜ is where observations go.
30
+ *
31
+ * Putting it there rather than in P is not a stylistic choice:
32
+ *
33
+ * - P is the specification. Appending a correction to P would make the
34
+ * prompt shape drift from A.4 and, worse, would inject a *behavioural*
35
+ * instruction into the one surface that is supposed to be the operator's
36
+ * spec. `tests/opencode/system-hint.test.ts` exists precisely to keep
37
+ * behavioural instructions out of the model's view.
38
+ * - Oₜ is already the mechanism by which the host tells the model what
39
+ * happened. Using it keeps the change inside the paper's own shape instead
40
+ * of around it.
41
+ * - The next step's Oₜ is the tool result. Prepending the rejection to it
42
+ * means the model reads, in order: what you did was rejected, and here is
43
+ * the environment's response, and here is the tool output. That is the
44
+ * order the events happened in.
45
+ *
46
+ * ── Why the reason is shown once ─────────────────────────────────────────
47
+ *
48
+ * A correction that repeats forever becomes wallpaper. By step ten the model
49
+ * has seen the same complaint nine times and it carries no more information
50
+ * than a constant line in the prompt would. So a rejection is delivered to
51
+ * exactly the next prompt and then dropped: if the model fails the same way
52
+ * again, that is a fresh rejection with a fresh reason, and a reader counting
53
+ * the prompts can see the failure is ongoing rather than stale.
54
+ *
55
+ * This also means feedback is *not* a retry mechanism. It does not re-prompt,
56
+ * it does not roll back, and it does not count attempts. The host's agent
57
+ * loop remains the executor, and §7's bounded retry cycle stays where the
58
+ * paper put it — inside a runtime that can actually own the loop.
59
+ *
60
+ * @non-paper — a host-integration affordance. It uses the paper's Oₜ slot but
61
+ * is not prescribed by the paper.
62
+ */
63
+ import type { SinkOutcome, SinkRejection } from './response-sink.js';
64
+ /**
65
+ * A corrective note waiting to be shown to the model, in a plain string.
66
+ *
67
+ * Held as a string rather than as a {@link SinkOutcome} because what reaches
68
+ * the prompt is prose the model can act on, not a type the model can read.
69
+ * The structured outcome stays in the sink for diagnostics and tests.
70
+ */
71
+ export type PendingFeedback = string;
72
+ /**
73
+ * §6.4: the synthetic observation a failed step produces.
74
+ *
75
+ * Verbatim in shape from the paper — `Invalid state patch after N attempts:
76
+ * <last error>` — and the reason it exists rather than a retry continuing is
77
+ * that a step which has spent its attempts is a different event from one which
78
+ * is still trying. The model needs to know the step is over and the state was
79
+ * not written, or it has no way to tell a stalled loop from a slow one.
80
+ *
81
+ * Returns `''` when there is nothing to report, because `attempts` below one
82
+ * means no attempt was made and a synthetic observation describing zero
83
+ * attempts would be a sentence about nothing.
84
+ */
85
+ /**
86
+ * The correction text for one rejection.
87
+ *
88
+ * Returns `''` for a reason with no entry rather than throwing or returning
89
+ * `undefined`. `SinkRejection` is a closed union today, so the table is total
90
+ * — but it is a `Record` over a type that grows, and a reason added without a
91
+ * message must degrade to "no correction" instead of throwing. This runs
92
+ * inside the plugin's event loop, where a throw would end the subscription and
93
+ * silently stop session scoping for the rest of the process's life.
94
+ */
95
+ export declare function feedbackFor(rejection: SinkRejection): PendingFeedback;
96
+ /**
97
+ * Holds at most one pending correction per session.
98
+ *
99
+ * Keyed by session, because sub-agents each write their own scope: a
100
+ * correction for one agent's rejected patch must not be shown to another.
101
+ *
102
+ * Bounded by construction — one string per session, overwritten by the next
103
+ * rejection, dropped once delivered. A long-lived server running many
104
+ * sessions accumulates one entry per session that has failed, which is the
105
+ * set of sessions worth reporting on anyway, and never more per session.
106
+ */
107
+ export declare class FeedbackQueue {
108
+ private readonly pending;
109
+ /**
110
+ * Record the outcome of one block.
111
+ *
112
+ * An applied patch CLEARS any pending correction: the model did the right
113
+ * thing, so a stale complaint must not be carried into the next prompt
114
+ * alongside good news. Anything else leaves the previous correction in
115
+ * place, because a second failure before the first was delivered is still
116
+ * a failure, and the newest reason is the most useful one.
117
+ */
118
+ record(sessionID: string, outcome: SinkOutcome): void;
119
+ /**
120
+ * Take the pending correction for a session, if any, and clear it.
121
+ *
122
+ * Consuming on read is what makes the correction show up exactly once. A
123
+ * caller that only ever peeked would turn a one-step correction into a
124
+ * permanent line in the prompt.
125
+ */
126
+ take(sessionID: string): PendingFeedback | undefined;
127
+ /** Peek without consuming. For diagnostics and tests. */
128
+ peek(sessionID: string): PendingFeedback | undefined;
129
+ /**
130
+ * Take the pending correction only if no synthetic observation is owed.
131
+ *
132
+ * §6.4: when every attempt fails, the step's observation is the synthetic
133
+ * `Invalid state patch after N attempts: <last error>` — not the running
134
+ * correction, and not nothing. Delivering both would show the model two
135
+ * complaints, one of which is stale by definition: the correction belongs to
136
+ * the attempt that just ended, while the synthetic observation reports the
137
+ * whole step.
138
+ *
139
+ * The correction is still cleared when it is dropped. Holding it would put
140
+ * the same reason in front of the model a second time, on the next step,
141
+ * where it would be describing a step that is over.
142
+ */
143
+ takeUnlessInvalidated(sessionID: string, attempts: number, lastError: string | undefined): PendingFeedback | undefined;
144
+ /** Forget everything (test isolation, plugin reload). */
145
+ clear(): void;
146
+ /** How many sessions currently hold a pending correction. */
147
+ get size(): number;
148
+ }
149
+ /**
150
+ * Prepend a correction to an observation.
151
+ *
152
+ * The observation is a single line in A.4 (`Latest Observation: …`), so the
153
+ * two are joined on one line rather than separated by a blank line that the
154
+ * template does not have. The correction is marked so the model can tell it
155
+ * apart from environment output — without a marker, a correction would be
156
+ * indistinguishable from a tool result, and the model could reasonably read
157
+ * it as data rather than as a report about its own last turn.
158
+ *
159
+ * A blank observation is left to carry the correction alone rather than
160
+ * becoming `"prefix: "`, which would read as a message addressed to nobody.
161
+ */
162
+ export declare function applyFeedback(observation: string, feedback: PendingFeedback | undefined): string;
163
+ /**
164
+ * Prepend a line from the environment to the observation slot.
165
+ *
166
+ * Shared by the two things the environment has to say to the model — a patch
167
+ * it refused, and an action it is carrying out — because they need the same
168
+ * shape and must not be told apart by accident.
169
+ *
170
+ * The marker is a parameter for exactly that reason. An earlier version reused
171
+ * {@link applyFeedback} for both, and the hard-coded
172
+ * `[state patch rejected]` would have told the model its patch was refused at
173
+ * the very moment the runtime was accepting it and asking for the next step —
174
+ * a message not merely useless but actively false, and the kind of false that
175
+ * makes a model re-derive state it has already recorded.
176
+ */
177
+ export declare function applyObservation(observation: string, marker: string, line: string): string;
178
+ //# sourceMappingURL=feedback.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"feedback.d.ts","sourceRoot":"","sources":["../src/feedback.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6DG;AAGH,OAAO,KAAK,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AAErE;;;;;;GAMG;AACH,MAAM,MAAM,eAAe,GAAG,MAAM,CAAC;AAkCrC;;;;;;;;;;;;GAYG;AACH;;;;;;;;;GASG;AACH,wBAAgB,WAAW,CAAC,SAAS,EAAE,aAAa,GAAG,eAAe,CAErE;AAED;;;;;;;;;;GAUG;AACH,qBAAa,aAAa;IACxB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAsC;IAE9D;;;;;;;;OAQG;IACH,MAAM,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,WAAW,GAAG,IAAI,CAUpD;IAED;;;;;;OAMG;IACH,IAAI,CAAC,SAAS,EAAE,MAAM,GAAG,eAAe,GAAG,SAAS,CAKnD;IAED,yDAAyD;IACzD,IAAI,CAAC,SAAS,EAAE,MAAM,GAAG,eAAe,GAAG,SAAS,CAEnD;IAED;;;;;;;;;;;;;OAaG;IACH,qBAAqB,CAAC,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,SAAS,GAAG,eAAe,GAAG,SAAS,CAQrH;IAED,yDAAyD;IACzD,KAAK,IAAI,IAAI,CAEZ;IAED,6DAA6D;IAC7D,IAAI,IAAI,IAAI,MAAM,CAEjB;CACF;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,aAAa,CAC3B,WAAW,EAAE,MAAM,EACnB,QAAQ,EAAE,eAAe,GAAG,SAAS,GACpC,MAAM,CAGR;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,gBAAgB,CAC9B,WAAW,EAAE,MAAM,EACnB,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,GACX,MAAM,CAGR"}