@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.
- package/README.md +194 -142
- package/dist/feedback.d.ts +178 -0
- package/dist/feedback.d.ts.map +1 -0
- package/dist/feedback.js +235 -0
- package/dist/feedback.js.map +1 -0
- package/dist/index.d.ts +58 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +48 -3
- package/dist/index.js.map +1 -1
- package/dist/mode.d.ts +97 -0
- package/dist/mode.d.ts.map +1 -0
- package/dist/mode.js +111 -0
- package/dist/mode.js.map +1 -0
- package/dist/opencode-adapter.d.ts +10 -46
- package/dist/opencode-adapter.d.ts.map +1 -1
- package/dist/opencode-adapter.js +1 -64
- package/dist/opencode-adapter.js.map +1 -1
- package/dist/paper-mode.d.ts +421 -0
- package/dist/paper-mode.d.ts.map +1 -0
- package/dist/paper-mode.js +445 -0
- package/dist/paper-mode.js.map +1 -0
- package/dist/plugin.d.ts +218 -76
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +867 -232
- package/dist/plugin.js.map +1 -1
- package/dist/response-sink.d.ts +208 -0
- package/dist/response-sink.d.ts.map +1 -0
- package/dist/response-sink.js +243 -0
- package/dist/response-sink.js.map +1 -0
- package/dist/runtime.d.ts +203 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +332 -0
- package/dist/runtime.js.map +1 -0
- package/dist/session-registry.d.ts +148 -0
- package/dist/session-registry.d.ts.map +1 -0
- package/dist/session-registry.js +236 -0
- package/dist/session-registry.js.map +1 -0
- package/dist/spec-loader.d.ts +81 -0
- package/dist/spec-loader.d.ts.map +1 -0
- package/dist/spec-loader.js +163 -0
- package/dist/spec-loader.js.map +1 -0
- package/dist/state-store.d.ts +126 -0
- package/dist/state-store.d.ts.map +1 -0
- package/dist/state-store.js +186 -0
- package/dist/state-store.js.map +1 -0
- package/dist/step-boundary.d.ts +91 -0
- package/dist/step-boundary.d.ts.map +1 -0
- package/dist/step-boundary.js +109 -0
- package/dist/step-boundary.js.map +1 -0
- package/dist/system-hint.d.ts +129 -0
- package/dist/system-hint.d.ts.map +1 -0
- package/dist/system-hint.js +166 -0
- package/dist/system-hint.js.map +1 -0
- package/dist/tools.d.ts +148 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +350 -0
- package/dist/tools.js.map +1 -0
- package/package.json +3 -2
- package/dist/plugin-types.d.ts +0 -71
- package/dist/plugin-types.d.ts.map +0 -1
- package/dist/plugin-types.js +0 -8
- 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
|
|
5
|
+
**OpenCode v2 plugin for the @skillstate/core runtime — three native tools and one additive system fragment.**
|
|
6
6
|
|
|
7
7
|
[](https://www.npmjs.com/package/@skillstate/opencode)
|
|
8
8
|
[](https://www.npmjs.com/package/@skillstate/opencode)
|
|
9
|
-
[](https://github.com/vitkuz573/skillstate)
|
|
10
9
|
[](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**.
|
|
18
|
-
|
|
19
|
-
`
|
|
20
|
-
|
|
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
|
-
##
|
|
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
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
67
|
-
hook contracts). Four steps:
|
|
118
|
+
### Tools
|
|
68
119
|
|
|
69
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
`
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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"}
|