@skillstate/opencode 2.0.3 → 2.0.5

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 CHANGED
@@ -6,7 +6,7 @@
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-755%20passing-brightgreen)](https://github.com/vitkuz573/skillstate)
9
+ [![Tests](https://img.shields.io/badge/tests-873%20passing-brightgreen)](https://github.com/vitkuz573/skillstate)
10
10
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](https://github.com/vitkuz573/skillstate/blob/main/LICENSE)
11
11
 
12
12
  </div>
@@ -43,19 +43,20 @@ const adapter = new OpenCodeAdapter();
43
43
  // execution_context block pointing at the persisted state file:
44
44
  const skillMd = adapter.generateSkillMd(INTERCODE_CTF_SPEC, './.skillstate.json');
45
45
 
46
- // Plugin with real O(1) history trimming via experimental.chat.messages.transform:
47
- const plugin = adapter.generatePluginCode('./.skillstate.json');
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();
48
50
 
49
51
  // Default keeps the last 3 non-system messages + state injection.
50
52
  // Configure history depth:
51
- const plugin2 = adapter.generatePluginCode('./.skillstate.json', {
53
+ const plugin2 = adapter.generatePluginCode({
52
54
  maxHistoryMessages: 5, // keep last 5 non-system messages
53
55
  });
54
56
 
55
57
  // Persist the plugin to disk atomically:
56
58
  const saved = await adapter.savePluginCode(
57
59
  './skillstate.plugin.ts',
58
- './.skillstate.json',
59
60
  { maxHistoryMessages: 5 },
60
61
  );
61
62
  ```
@@ -73,16 +74,17 @@ import { OpenCodeAdapter } from '@skillstate/opencode';
73
74
  import { writeFileSync } from 'node:fs';
74
75
 
75
76
  const adapter = new OpenCodeAdapter();
76
- const statePath = '/abs/path/to/.skillstate.json';
77
- const code = adapter.generatePluginCode(statePath, { maxHistoryMessages: 3 });
77
+ const code = adapter.generatePluginCode({ maxHistoryMessages: 3 });
78
78
  writeFileSync('/abs/path/to/skillstate.plugin.ts', code);
79
79
  ```
80
80
 
81
81
  Put the generated `skillstate.plugin.ts` anywhere stable (absolute path is
82
- safest), e.g. `<project>/.opencode-runtime/skillstate.plugin.ts`.
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.
83
85
 
84
- **2. Create the initial state file** (`statePath` from step 1), matching your
85
- spec schema, e.g. for `INTERCODE_CTF_SPEC`:
86
+ **2. Create the initial state file** (`<project>/.skillstate/skillstate.json`),
87
+ matching your spec schema, e.g. for `INTERCODE_CTF_SPEC`:
86
88
 
87
89
  ```json
88
90
  {
@@ -121,32 +123,47 @@ synthetic `{ info, parts }` message.
121
123
 
122
124
  ## API / Exports
123
125
 
124
- Root path `@skillstate/opencode` exports one thing: `OpenCodeAdapter`.
126
+ Root path `@skillstate/opencode` exports the adapter and the static plugin:
125
127
 
126
128
  - `new OpenCodeAdapter()` — implements `PlatformAdapter` (`name = 'opencode'`).
127
129
  - `generateSkillMd(spec, statePath?): string` — a `SKILL.md` body with
128
130
  frontmatter and a state-based process description.
129
- - `generatePluginCode(statePath, options?): string` — an OpenCode plugin
130
- (`options.maxHistoryMessages`, default 3). Hooks:
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:
131
135
  `experimental.chat.messages.transform` (real history trimming),
132
136
  `experimental.session.compacting` (inject state into compaction context),
133
137
  `tool.execute.after` (persist `state_patch` to disk).
134
- - `savePluginCode(target, statePath, options?): Promise<string>` — writes the
135
- plugin atomically and returns the destination.
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())` —
142
+ `<cwd>/.skillstate/skillstate.json`, or the global bucket
143
+ `<home>/.skillstate/global/skillstate.json` when the session runs from
144
+ `$HOME`. Re-exported from `@skillstate/opencode/plugin` as well.
145
+ - `resolveStatePathForCwd(cwd, home): string` — the per-project state path
146
+ resolution (pure path arithmetic, no filesystem access).
147
+ - `readSkillState` / `saveSkillState` / `mergePatch` / `extractPatch` — the
148
+ plugin's state helpers, shared by the static plugin.
149
+ - `savePluginCode(target, options?): Promise<string>` — writes the plugin
150
+ atomically and returns the destination. `target` accepts a raw path or a
151
+ `{ root, name }` ref confined by `resolveStatePath` (`..` escapes throw).
136
152
  - `injectState(state, spec): string` / `formatPrompt(state, observation, spec): string`.
137
153
  - `extractPatch(response): StatePatch | null` / `extractAction(response): string | null`.
138
154
 
139
- Both `generatePluginCode`/`savePluginCode` accept a raw path (legacy) or a
140
- `{ root, name }` ref confined by `resolveStatePath` (`..` escapes throw).
141
-
142
155
  ## Notes
143
156
 
144
157
  - **Real O(1).** Unlike Claude Code and Codex, OpenCode exposes
145
158
  `experimental.chat.messages.transform`, so the plugin drops old messages
146
159
  instead of just hiding them — only the last N non-system messages plus an
147
160
  injected state message reach the LLM.
148
- - The generated plugin is a self-contained ESM/TS module; it reads and writes
149
- the state file directly and applies the paper ⊕ null-deletion merge.
161
+ - The generated plugin is a **thin loader**: it imports
162
+ `createSkillStatePlugin` from `@skillstate/opencode` (one source of truth).
163
+ Hook logic lives only in `src/plugin.ts` — regenerate rather than editing
164
+ generated files. State resolves per session:
165
+ `<cwd>/.skillstate/skillstate.json` (global bucket from `~` when the
166
+ session cwd is `$HOME`).
150
167
  - Depends on [`@skillstate/core`](../core) for `PromptTransformer`,
151
168
  `atomicWriteFile`, and `resolveStatePath`.
152
169
 
package/dist/index.d.ts CHANGED
@@ -1,2 +1,3 @@
1
1
  export * from './opencode-adapter.js';
2
+ export * from './plugin.js';
2
3
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,cAAc,uBAAuB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,cAAc,uBAAuB,CAAC;AACtC,cAAc,aAAa,CAAC"}
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
1
  // @skillstate/opencode — OpenCode platform adapter.
2
2
  export * from './opencode-adapter.js';
3
+ export * from './plugin.js';
3
4
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,oDAAoD;AACpD,cAAc,uBAAuB,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,oDAAoD;AACpD,cAAc,uBAAuB,CAAC;AACtC,cAAc,aAAa,CAAC"}
@@ -14,6 +14,11 @@
14
14
  */
15
15
  import type { SkillState, StatePatch, ProceduralSpec, Observation, PlatformAdapter } from '@skillstate/core';
16
16
  import type { StatePathRef } from '@skillstate/core';
17
+ /** Options for {@link OpenCodeAdapter.generatePluginCode}. */
18
+ export interface GeneratePluginOptions {
19
+ /** Non-system messages kept by the plugin (default 3). */
20
+ maxHistoryMessages?: number;
21
+ }
17
22
  /**
18
23
  * OpenCode platform adapter (@non-paper; see module doc).
19
24
  */
@@ -51,37 +56,29 @@ export declare class OpenCodeAdapter implements PlatformAdapter {
51
56
  */
52
57
  generateSkillMd(spec: ProceduralSpec, statePath?: string): string;
53
58
  /**
54
- * Generate an OpenCode plugin with real O(1) prompt economy.
59
+ * Generate an OpenCode plugin file.
55
60
  *
56
- * Hooks:
57
- * - `experimental.chat.messages.transform`: trims history to the last
58
- * `maxHistoryMessages` non-system messages, injects a synthetic state
59
- * message — real O(1) prompt footprint.
60
- * - `experimental.session.compacting`: injects state into the compaction
61
- * context so the summary preserves it.
62
- * - `tool.execute.after`: persists state updates from LLM responses.
63
- */
64
- generatePluginCode(statePath: string, options?: {
65
- maxHistoryMessages?: number;
66
- }): string;
67
- /**
68
- * @non-paper additive overload: accept a `{ root, name }` ref confined
69
- * via `resolveStatePath` — `..` escapes throw instead of embedding an
70
- * unsafe path into the generated plugin.
61
+ * The plugin is ALWAYS a thin loader in per-project resolver mode: the
62
+ * state path is computed from the session cwd on every hook call inside
63
+ * the static plugin (`resolveStatePathForCwd(process.cwd(),
64
+ * os.homedir())`), so each project gets its own
65
+ * `<cwd>/.skillstate/skillstate.json` and a session opened anywhere
66
+ * resolves the same state for the same project.
67
+ *
68
+ * Hooks (see `src/plugin.ts`): `experimental.chat.messages.transform`
69
+ * (real O(1) history trimming, `{ info, parts }` entries, in-place
70
+ * mutation), `experimental.session.compacting` (state into compaction
71
+ * context), `tool.execute.after` (persist `state_patch` from
72
+ * `output.output`).
71
73
  */
72
- generatePluginCode(stateRef: StatePathRef, options?: {
73
- maxHistoryMessages?: number;
74
- }): string;
74
+ generatePluginCode(options?: GeneratePluginOptions): string;
75
75
  /**
76
76
  * @non-paper additive helper: generate the plugin and persist it via
77
- * `atomicWriteFile` (tmp + fsync + rename). Both the destination and the
78
- * embedded state path accept raw strings (legacy behavior) or
79
- * `{ root, name }` refs confined by `resolveStatePath`. Returns the
80
- * absolute destination path.
77
+ * `atomicWriteFile` (tmp + fsync + rename). The destination accepts a raw
78
+ * string or a `{ root, name }` ref confined by `resolveStatePath`.
79
+ * Returns the absolute destination path.
81
80
  */
82
- savePluginCode(target: string | StatePathRef, statePath: string | StatePathRef, options?: {
83
- maxHistoryMessages?: number;
84
- }): Promise<string>;
81
+ savePluginCode(target: string | StatePathRef, options?: GeneratePluginOptions): Promise<string>;
85
82
  /**
86
83
  * Describe the schema fields for inclusion in prompts.
87
84
  */
@@ -1 +1 @@
1
- {"version":3,"file":"opencode-adapter.d.ts","sourceRoot":"","sources":["../src/opencode-adapter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AACH,OAAO,KAAK,EACV,UAAU,EACV,UAAU,EACV,cAAc,EACd,WAAW,EACX,eAAe,EAChB,MAAM,kBAAkB,CAAC;AAM1B,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAErD;;GAEG;AACH,qBAAa,eAAgB,YAAW,eAAe;IACrD,QAAQ,CAAC,IAAI,cAAc;IAE3B,OAAO,CAAC,WAAW,CAAmD;IAEtE;;;;OAIG;IACH,WAAW,CAAC,KAAK,EAAE,UAAU,EAAE,IAAI,EAAE,cAAc,GAAG,MAAM,CAsB3D;IAED;;OAEG;IACH,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,UAAU,GAAG,IAAI,CAEhD;IAED;;OAEG;IACH,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAE7C;IAED;;;;;OAKG;IACH,YAAY,CACV,KAAK,EAAE,UAAU,EACjB,WAAW,EAAE,WAAW,EACxB,IAAI,EAAE,cAAc,GACnB,MAAM,CAER;IAED;;;;;;;OAOG;IACH,eAAe,CAAC,IAAI,EAAE,cAAc,EAAE,SAAS,CAAC,EAAE,MAAM,GAAG,MAAM,CA0ChE;IAED;;;;;;;;;;OAUG;IACH,kBAAkB,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE;QAAE,kBAAkB,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,MAAM,CAAC;IACzF;;;;OAIG;IACH,kBAAkB,CAAC,QAAQ,EAAE,YAAY,EAAE,OAAO,CAAC,EAAE;QAAE,kBAAkB,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,MAAM,CAAC;IAoI9F;;;;;;OAMG;IACG,cAAc,CAClB,MAAM,EAAE,MAAM,GAAG,YAAY,EAC7B,SAAS,EAAE,MAAM,GAAG,YAAY,EAChC,OAAO,CAAC,EAAE;QAAE,kBAAkB,CAAC,EAAE,MAAM,CAAA;KAAE,GACxC,OAAO,CAAC,MAAM,CAAC,CAYjB;IAMD;;OAEG;IACH,OAAO,CAAC,cAAc;CASvB"}
1
+ {"version":3,"file":"opencode-adapter.d.ts","sourceRoot":"","sources":["../src/opencode-adapter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AACH,OAAO,KAAK,EACV,UAAU,EACV,UAAU,EACV,cAAc,EACd,WAAW,EACX,eAAe,EAChB,MAAM,kBAAkB,CAAC;AAM1B,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAErD,8DAA8D;AAC9D,MAAM,WAAW,qBAAqB;IACpC,0DAA0D;IAC1D,kBAAkB,CAAC,EAAE,MAAM,CAAC;CAC7B;AAED;;GAEG;AACH,qBAAa,eAAgB,YAAW,eAAe;IACrD,QAAQ,CAAC,IAAI,cAAc;IAE3B,OAAO,CAAC,WAAW,CAAmD;IAEtE;;;;OAIG;IACH,WAAW,CAAC,KAAK,EAAE,UAAU,EAAE,IAAI,EAAE,cAAc,GAAG,MAAM,CAsB3D;IAED;;OAEG;IACH,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,UAAU,GAAG,IAAI,CAEhD;IAED;;OAEG;IACH,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAE7C;IAED;;;;;OAKG;IACH,YAAY,CACV,KAAK,EAAE,UAAU,EACjB,WAAW,EAAE,WAAW,EACxB,IAAI,EAAE,cAAc,GACnB,MAAM,CAER;IAED;;;;;;;OAOG;IACH,eAAe,CAAC,IAAI,EAAE,cAAc,EAAE,SAAS,CAAC,EAAE,MAAM,GAAG,MAAM,CA0ChE;IAED;;;;;;;;;;;;;;;OAeG;IACH,kBAAkB,CAAC,OAAO,CAAC,EAAE,qBAAqB,GAAG,MAAM,CAa1D;IAED;;;;;OAKG;IACG,cAAc,CAClB,MAAM,EAAE,MAAM,GAAG,YAAY,EAC7B,OAAO,CAAC,EAAE,qBAAqB,GAC9B,OAAO,CAAC,MAAM,CAAC,CAOjB;IAMD;;OAEG;IACH,OAAO,CAAC,cAAc;CASvB"}
@@ -104,147 +104,47 @@ plugin; only the current state and the latest observation matter.
104
104
  - Reasoning is discarded after execution — put anything you need to persist
105
105
  into \`state_patch\`.`;
106
106
  }
107
- generatePluginCode(statePathOrRef, options) {
108
- const statePath = typeof statePathOrRef === 'string'
109
- ? statePathOrRef
110
- : resolveStatePath(statePathOrRef.root, statePathOrRef.name);
111
- const sp = JSON.stringify(statePath);
107
+ /**
108
+ * Generate an OpenCode plugin file.
109
+ *
110
+ * The plugin is ALWAYS a thin loader in per-project resolver mode: the
111
+ * state path is computed from the session cwd on every hook call inside
112
+ * the static plugin (`resolveStatePathForCwd(process.cwd(),
113
+ * os.homedir())`), so each project gets its own
114
+ * `<cwd>/.skillstate/skillstate.json` and a session opened anywhere
115
+ * resolves the same state for the same project.
116
+ *
117
+ * Hooks (see `src/plugin.ts`): `experimental.chat.messages.transform`
118
+ * (real O(1) history trimming, `{ info, parts }` entries, in-place
119
+ * mutation), `experimental.session.compacting` (state into compaction
120
+ * context), `tool.execute.after` (persist `state_patch` from
121
+ * `output.output`).
122
+ */
123
+ generatePluginCode(options) {
112
124
  const maxHistory = options?.maxHistoryMessages ?? 3;
113
- return `// OpenCode plugin generated by skillstate.
114
- // Real O(1) prompt economy via experimental.chat.messages.transform:
115
- // trims history to last ${maxHistory} non-system messages + injected state.
116
-
117
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
118
- import type { Plugin } from "@opencode-ai/plugin";
119
-
120
- const STATE_PATH = ${sp};
121
- const MAX_HISTORY = ${maxHistory};
122
-
123
- function readSkillState(): Record<string, unknown> {
124
- try {
125
- if (existsSync(STATE_PATH)) {
126
- return JSON.parse(readFileSync(STATE_PATH, "utf-8"));
127
- }
128
- } catch {
129
- // Corrupt or unreadable state file — fall back to empty state.
130
- }
131
- return {};
132
- }
133
-
134
- function saveSkillState(state: Record<string, unknown>): void {
135
- try {
136
- writeFileSync(STATE_PATH, JSON.stringify(state, null, 2));
137
- } catch {
138
- // Best-effort: read-only environments or permission issues.
139
- }
140
- }
141
-
142
- function mergePatch(base: Record<string, unknown>, patch: Record<string, unknown>): Record<string, unknown> {
143
- const result = { ...base };
144
- for (const key of Object.keys(patch)) {
145
- if (patch[key] === null) {
146
- delete result[key];
147
- } else if (
148
- typeof patch[key] === "object" && patch[key] !== null &&
149
- !Array.isArray(patch[key]) &&
150
- typeof result[key] === "object" && result[key] !== null &&
151
- !Array.isArray(result[key])
152
- ) {
153
- result[key] = mergePatch(result[key] as Record<string, unknown>, patch[key] as Record<string, unknown>);
154
- } else {
155
- result[key] = patch[key];
156
- }
157
- }
158
- return result;
159
- }
160
-
161
- function extractPatch(response: string): Record<string, unknown> | null {
162
- const match = response.match(/\`\`\`json\\s*\\n?([\\s\\S]*?)\\n?\\s*\`\`\`/);
163
- if (!match) return null;
164
- try {
165
- const parsed = JSON.parse(match[1]);
166
- if (parsed.state_patch && typeof parsed.state_patch === "object" && !Array.isArray(parsed.state_patch)) {
167
- return parsed.state_patch;
168
- }
169
- } catch {
170
- // Malformed JSON — ignore.
171
- }
172
- return null;
173
- }
174
-
175
- export const SkillStatePlugin: Plugin = async ({ project, client, $ }) => {
176
- return {
177
- // ── O(1) history trimming ──────────────────────────────────────────
178
- // Filters messages BEFORE each LLM call: keeps all system messages
179
- // plus the last ${maxHistory} non-system messages, then injects a
180
- // synthetic user message with the current state. This is real O(1):
181
- // old messages are DROPPED from the prompt, not just hidden.
182
- "experimental.chat.messages.transform": async (input, output) => {
183
- const state = readSkillState();
184
- const messages = output.messages ?? [];
185
- const systemMessages = messages.filter((m) => m.role === "system");
186
- const nonSystem = messages.filter((m) => m.role !== "system");
187
- const trimmed = nonSystem.slice(-MAX_HISTORY);
188
-
189
- const stateMessage = {
190
- role: "user",
191
- content: "Current skill state (JSON): " + JSON.stringify(state),
192
- };
193
-
194
- output.messages = [...systemMessages, ...trimmed, stateMessage];
195
- return output;
196
- },
197
-
198
- // ── Compaction context injection ───────────────────────────────────
199
- // Before compaction, inject the current state into the context so the
200
- // compaction summary preserves state even after history is compressed.
201
- "experimental.session.compacting": async (input, output) => {
202
- const state = readSkillState();
203
- const stateContext = "Skillstate: " + JSON.stringify(state);
204
- if (!Array.isArray(output.context)) {
205
- output.context = [];
206
- }
207
- output.context.push(stateContext);
208
- return output;
209
- },
210
-
211
- // ── State persistence from LLM responses ───────────────────────────
212
- // After tool execution, extract state_patch from the assistant's last
213
- // response, merge it, and save to disk.
214
- "tool.execute.after": async (input, output) => {
215
- const response = output?.result ?? "";
216
- if (typeof response === "string") {
217
- const patch = extractPatch(response);
218
- if (patch) {
219
- const current = readSkillState();
220
- const merged = mergePatch(current, patch);
221
- saveSkillState(merged);
222
- }
223
- }
224
- return output;
225
- },
226
- };
227
- };
228
-
229
- export default SkillStatePlugin;
125
+ return `// OpenCode plugin (thin loader) generated by skillstate.
126
+ // Hook logic lives in the static plugin — the single source of truth —
127
+ // shipped in the @skillstate/opencode package. PER-PROJECT state is
128
+ // resolved from the session cwd on every hook call —
129
+ // <cwd>/.skillstate/skillstate.json (global bucket when cwd === home).
130
+ import { createSkillStatePlugin } from '@skillstate/opencode';
131
+
132
+ export default createSkillStatePlugin({
133
+ maxHistoryMessages: ${maxHistory},
134
+ });
230
135
  `;
231
136
  }
232
137
  /**
233
138
  * @non-paper additive helper: generate the plugin and persist it via
234
- * `atomicWriteFile` (tmp + fsync + rename). Both the destination and the
235
- * embedded state path accept raw strings (legacy behavior) or
236
- * `{ root, name }` refs confined by `resolveStatePath`. Returns the
237
- * absolute destination path.
139
+ * `atomicWriteFile` (tmp + fsync + rename). The destination accepts a raw
140
+ * string or a `{ root, name }` ref confined by `resolveStatePath`.
141
+ * Returns the absolute destination path.
238
142
  */
239
- async savePluginCode(target, statePath, options) {
143
+ async savePluginCode(target, options) {
240
144
  const dest = typeof target === 'string'
241
145
  ? target
242
146
  : resolveStatePath(target.root, target.name);
243
- const resolvedState = typeof statePath === 'string'
244
- ? statePath
245
- : resolveStatePath(statePath.root, statePath.name);
246
- const plugin = this.generatePluginCode(resolvedState, options);
247
- await atomicWriteFile(dest, plugin);
147
+ await atomicWriteFile(dest, this.generatePluginCode(options));
248
148
  return dest;
249
149
  }
250
150
  /* ------------------------------------------------------------------ */
@@ -1 +1 @@
1
- {"version":3,"file":"opencode-adapter.js","sourceRoot":"","sources":["../src/opencode-adapter.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"opencode-adapter.js","sourceRoot":"","sources":["../src/opencode-adapter.ts"],"names":[],"mappings":"AAqBA,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AACrD,OAAO,EACL,eAAe,EACf,gBAAgB,GACjB,MAAM,kBAAkB,CAAC;AAS1B;;GAEG;AACH,MAAM,OAAO,eAAe;IACjB,IAAI,GAAG,UAAU,CAAC;IAEnB,WAAW,GAAG,IAAI,iBAAiB,CAAC,EAAE,QAAQ,EAAE,UAAU,EAAE,CAAC,CAAC;IAEtE;;;;OAIG;IACH,WAAW,CAAC,KAAiB,EAAE,IAAoB;QACjD,MAAM,SAAS,GAAG,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC;QACxC,MAAM,UAAU,GAAG,IAAI,CAAC,cAAc,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;QAEpD,OAAO,gBAAgB,IAAI,CAAC,EAAE;gBAClB,IAAI,CAAC,YAAY;EAC/B,UAAU;;EAEV,SAAS;;;;;;;;;;;;;kIAauH,CAAC;IACjI,CAAC;IAED;;OAEG;IACH,YAAY,CAAC,QAAgB;QAC3B,OAAO,IAAI,CAAC,WAAW,CAAC,iBAAiB,CAAC,QAAQ,CAAC,CAAC;IACtD,CAAC;IAED;;OAEG;IACH,aAAa,CAAC,QAAgB;QAC5B,OAAO,IAAI,CAAC,WAAW,CAAC,aAAa,CAAC,QAAQ,CAAC,CAAC;IAClD,CAAC;IAED;;;;;OAKG;IACH,YAAY,CACV,KAAiB,EACjB,WAAwB,EACxB,IAAoB;QAEpB,OAAO,IAAI,CAAC,WAAW,CAAC,iBAAiB,CAAC,IAAI,EAAE,KAAK,EAAE,WAAW,CAAC,CAAC;IACtE,CAAC;IAED;;;;;;;OAOG;IACH,eAAe,CAAC,IAAoB,EAAE,SAAkB;QACtD,MAAM,iBAAiB,GAAG,SAAS,IAAI,oBAAoB,CAAC;QAE5D,OAAO;QACH,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,IAAI,CAAC;eAClB,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,YAAY,CAAC;WACrC,IAAI,CAAC,OAAO;;gBAEP,iBAAiB;;;;IAI7B,IAAI,CAAC,IAAI;;EAEX,IAAI,CAAC,YAAY;;;;yCAIsB,iBAAiB;;;;;;;;;;;;;;;;;;;;;;;wBAuBlC,CAAC;IACvB,CAAC;IAED;;;;;;;;;;;;;;;OAeG;IACH,kBAAkB,CAAC,OAA+B;QAChD,MAAM,UAAU,GAAG,OAAO,EAAE,kBAAkB,IAAI,CAAC,CAAC;QACpD,OAAO;;;;;;;;wBAQa,UAAU;;CAEjC,CAAC;IACA,CAAC;IAED;;;;;OAKG;IACH,KAAK,CAAC,cAAc,CAClB,MAA6B,EAC7B,OAA+B;QAE/B,MAAM,IAAI,GACR,OAAO,MAAM,KAAK,QAAQ;YACxB,CAAC,CAAC,MAAM;YACR,CAAC,CAAC,gBAAgB,CAAC,MAAM,CAAC,IAAI,EAAE,MAAM,CAAC,IAAI,CAAC,CAAC;QACjD,MAAM,eAAe,CAAC,IAAI,EAAE,IAAI,CAAC,kBAAkB,CAAC,OAAO,CAAC,CAAC,CAAC;QAC9D,OAAO,IAAI,CAAC;IACd,CAAC;IAED,wEAAwE;IACxE,yEAAyE;IACzE,wEAAwE;IAExE;;OAEG;IACK,cAAc,CAAC,MAAgC;QACrD,MAAM,MAAM,GAAG,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC;aAClC,GAAG,CACF,CAAC,CAAC,IAAI,EAAE,KAAK,CAAC,EAAE,EAAE,CAChB,KAAK,IAAI,KAAK,KAAK,CAAC,IAAI,MAAM,KAAK,CAAC,WAAW,IAAI,gBAAgB,EAAE,CACxE;aACA,IAAI,CAAC,IAAI,CAAC,CAAC;QACd,OAAO,cAAc,MAAM,EAAE,CAAC;IAChC,CAAC;CACF"}
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Minimal local types for OpenCode plugin hooks — the `@opencode-ai/plugin`
3
+ * shape, verified against opencode 1.17 (PluginInput/Hooks). Declared locally
4
+ * to keep `@skillstate/opencode` zero-dep; the host supplies the real types
5
+ * at runtime.
6
+ */
7
+ /** Message roles seen by `experimental.chat.messages.transform`. */
8
+ export type OpenCodeMessageRole = 'user' | 'assistant' | 'system';
9
+ /**
10
+ * Message envelope: the transform pipeline hands each message as
11
+ * `{ info, parts }` — the role lives on `info.role`.
12
+ */
13
+ export interface OpenCodeMessage {
14
+ info: {
15
+ role: OpenCodeMessageRole | string;
16
+ [key: string]: unknown;
17
+ };
18
+ parts: Array<{
19
+ type: string;
20
+ text?: string;
21
+ [key: string]: unknown;
22
+ }>;
23
+ }
24
+ /** Output payload of `experimental.chat.messages.transform`. */
25
+ export interface MessagesTransformOutput {
26
+ /** Mutated IN PLACE — the pipeline keeps the original array reference. */
27
+ messages: OpenCodeMessage[];
28
+ }
29
+ /** Output payload of `experimental.session.compacting`. */
30
+ export interface SessionCompactingOutput {
31
+ context: string[];
32
+ prompt?: string;
33
+ }
34
+ /** Output payload of `tool.execute.after`. */
35
+ export interface ToolExecuteAfterOutput {
36
+ title?: string;
37
+ /** The tool's string response (state_patch extraction source). */
38
+ output: unknown;
39
+ metadata?: unknown;
40
+ }
41
+ /** OpenCode hooks used by the skillstate plugin. */
42
+ export interface SkillStateHooks {
43
+ 'experimental.chat.messages.transform'?: (input: Record<string, never>, output: MessagesTransformOutput) => Promise<void>;
44
+ 'experimental.session.compacting'?: (input: {
45
+ sessionID: string;
46
+ }, output: SessionCompactingOutput) => Promise<void>;
47
+ 'tool.execute.after'?: (input: {
48
+ tool: string;
49
+ sessionID: string;
50
+ callID: string;
51
+ args: unknown;
52
+ }, output: ToolExecuteAfterOutput) => Promise<void>;
53
+ }
54
+ /**
55
+ * OpenCode plugin: an async factory receiving the host input and returning
56
+ * the hooks object (`Plugin` from `@opencode-ai/plugin`).
57
+ */
58
+ export type SkillStatePlugin = (input: {
59
+ project?: unknown;
60
+ client?: unknown;
61
+ $?: unknown;
62
+ [key: string]: unknown;
63
+ }) => Promise<SkillStateHooks>;
64
+ //# sourceMappingURL=plugin-types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plugin-types.d.ts","sourceRoot":"","sources":["../src/plugin-types.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,oEAAoE;AACpE,MAAM,MAAM,mBAAmB,GAAG,MAAM,GAAG,WAAW,GAAG,QAAQ,CAAC;AAElE;;;GAGG;AACH,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE;QAAE,IAAI,EAAE,mBAAmB,GAAG,MAAM,CAAC;QAAC,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;KAAE,CAAC;IACrE,KAAK,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;KAAE,CAAC,CAAC;CACvE;AAED,gEAAgE;AAChE,MAAM,WAAW,uBAAuB;IACtC,0EAA0E;IAC1E,QAAQ,EAAE,eAAe,EAAE,CAAC;CAC7B;AAED,2DAA2D;AAC3D,MAAM,WAAW,uBAAuB;IACtC,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,8CAA8C;AAC9C,MAAM,WAAW,sBAAsB;IACrC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,kEAAkE;IAClE,MAAM,EAAE,OAAO,CAAC;IAChB,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,oDAAoD;AACpD,MAAM,WAAW,eAAe;IAC9B,sCAAsC,CAAC,EAAE,CACvC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,EAC5B,MAAM,EAAE,uBAAuB,KAC5B,OAAO,CAAC,IAAI,CAAC,CAAC;IACnB,iCAAiC,CAAC,EAAE,CAClC,KAAK,EAAE;QAAE,SAAS,EAAE,MAAM,CAAA;KAAE,EAC5B,MAAM,EAAE,uBAAuB,KAC5B,OAAO,CAAC,IAAI,CAAC,CAAC;IACnB,oBAAoB,CAAC,EAAE,CACrB,KAAK,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,OAAO,CAAA;KAAE,EACzE,MAAM,EAAE,sBAAsB,KAC3B,OAAO,CAAC,IAAI,CAAC,CAAC;CACpB;AAED;;;GAGG;AACH,MAAM,MAAM,gBAAgB,GAAG,CAAC,KAAK,EAAE;IACrC,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,CAAC,CAAC,EAAE,OAAO,CAAC;IACZ,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB,KAAK,OAAO,CAAC,eAAe,CAAC,CAAC"}
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Minimal local types for OpenCode plugin hooks — the `@opencode-ai/plugin`
3
+ * shape, verified against opencode 1.17 (PluginInput/Hooks). Declared locally
4
+ * to keep `@skillstate/opencode` zero-dep; the host supplies the real types
5
+ * at runtime.
6
+ */
7
+ export {};
8
+ //# sourceMappingURL=plugin-types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plugin-types.js","sourceRoot":"","sources":["../src/plugin-types.ts"],"names":[],"mappings":"AAAA;;;;;GAKG"}
@@ -0,0 +1,61 @@
1
+ import type { SkillStatePlugin } from './plugin-types.js';
2
+ export * from './plugin-types.js';
3
+ /** Options for {@link createSkillStatePlugin}. */
4
+ export interface SkillStatePluginOptions {
5
+ /** Non-system messages kept in the prompt (default 3). */
6
+ maxHistoryMessages?: number;
7
+ }
8
+ /**
9
+ * Resolve the per-project state file for a session working directory
10
+ * (`cwd` of the current opencode session):
11
+ *
12
+ * - `cwd === home` — a session launched straight from `$HOME` has no single
13
+ * project, so state goes to the global bucket
14
+ * `<home>/.skillstate/global/skillstate.json`;
15
+ * - any other cwd (including subdirectories of `$HOME`) — the state lives in
16
+ * the project: `<cwd>/.skillstate/skillstate.json`.
17
+ *
18
+ * Pure path arithmetic: both arguments are normalized via `path.resolve`
19
+ * before comparison, and there is NO filesystem access. The same project
20
+ * directory therefore always maps to the same state file no matter where
21
+ * the host was launched from, while different projects never share state.
22
+ * Zero-dep by design (node builtins only) so generated plugins and the MCP
23
+ * server can inline the same semantics. Keep any copy in sync.
24
+ */
25
+ export declare function resolveStatePathForCwd(cwd: string, home: string): string;
26
+ /**
27
+ * Read the state file. Missing or corrupt files yield `{}` (best-effort).
28
+ * The on-disk envelope is `{ version: 1, state }` (migrations-compatible);
29
+ * a bare object is tolerated and treated as the state itself.
30
+ */
31
+ export declare function readSkillState(statePath: string): Record<string, unknown>;
32
+ /**
33
+ * Persist the state file (best-effort: read-only environments are ignored).
34
+ * Creates the parent directory when missing (the per-project resolver may
35
+ * target a fresh `<cwd>/.skillstate/`). Writes the `{ version: 1, state }`
36
+ * envelope so `migrate()`/runtime resume read the same file.
37
+ */
38
+ export declare function saveSkillState(statePath: string, state: Record<string, unknown>): void;
39
+ /**
40
+ * Paper ⊕ merge: `null` deletes a key, nested plain objects merge
41
+ * recursively, everything else replaces.
42
+ */
43
+ export declare function mergePatch(base: Record<string, unknown>, patch: Record<string, unknown>): Record<string, unknown>;
44
+ /**
45
+ * Extract the `state_patch` object from an LLM response's fenced ```json
46
+ * block; `null` when there is no block, it is malformed, or it carries no
47
+ * object-shaped `state_patch`.
48
+ */
49
+ export declare function extractPatch(response: string): Record<string, unknown> | null;
50
+ /**
51
+ * Build the OpenCode plugin function with the same behavior for every host
52
+ * entry point (thin generated loaders, direct imports).
53
+ *
54
+ * State resolution is ALWAYS per-project: the state file path is computed
55
+ * from the session cwd on EVERY hook call via
56
+ * `resolveStatePathForCwd(process.cwd(), os.homedir())` — each project gets
57
+ * its own `<cwd>/.skillstate/skillstate.json`, and a session launched from
58
+ * `$HOME` uses the global bucket.
59
+ */
60
+ export declare function createSkillStatePlugin(options?: SkillStatePluginOptions): SkillStatePlugin;
61
+ //# sourceMappingURL=plugin.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAmBA,OAAO,KAAK,EAGV,gBAAgB,EACjB,MAAM,mBAAmB,CAAC;AAE3B,cAAc,mBAAmB,CAAC;AAElC,kDAAkD;AAClD,MAAM,WAAW,uBAAuB;IACtC,0DAA0D;IAC1D,kBAAkB,CAAC,EAAE,MAAM,CAAC;CAC7B;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAOxE;AAOD;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAqBzE;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAOtF;AAED;;;GAGG;AACH,wBAAgB,UAAU,CACxB,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC7B,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC7B,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAazB;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAY7E;AAKD;;;;;;;;;GASG;AACH,wBAAgB,sBAAsB,CAAC,OAAO,GAAE,uBAA4B,GAAG,gBAAgB,CAgF9F"}
package/dist/plugin.js ADDED
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Static OpenCode plugin — the SINGLE SOURCE OF TRUTH for the skillstate
3
+ * host integration. `OpenCodeAdapter.generatePluginCode` emits a thin loader
4
+ * that imports `createSkillStatePlugin` from this module; the per-project
5
+ * state resolution lives inside the plugin itself.
6
+ *
7
+ * Hooks (opencode 1.17 contract, verified on host):
8
+ * - `experimental.chat.messages.transform` — entries are `{ info, parts }`
9
+ * envelopes (role on `info.role`); the pipeline keeps the ORIGINAL array
10
+ * reference, so trimming mutates in place; the state is injected as a
11
+ * synthetic `{ info, parts }` element. Real O(1) prompt footprint.
12
+ * - `experimental.session.compacting` — pushes the state into
13
+ * `output.context` so the compaction summary preserves it.
14
+ * - `tool.execute.after` — the tool response is `output.output`; a fenced
15
+ * ```json `state_patch` block is merged (paper ⊕: null deletes) and saved.
16
+ */
17
+ import * as fs from 'node:fs';
18
+ import * as os from 'node:os';
19
+ import * as path from 'node:path';
20
+ export * from './plugin-types.js';
21
+ /**
22
+ * Resolve the per-project state file for a session working directory
23
+ * (`cwd` of the current opencode session):
24
+ *
25
+ * - `cwd === home` — a session launched straight from `$HOME` has no single
26
+ * project, so state goes to the global bucket
27
+ * `<home>/.skillstate/global/skillstate.json`;
28
+ * - any other cwd (including subdirectories of `$HOME`) — the state lives in
29
+ * the project: `<cwd>/.skillstate/skillstate.json`.
30
+ *
31
+ * Pure path arithmetic: both arguments are normalized via `path.resolve`
32
+ * before comparison, and there is NO filesystem access. The same project
33
+ * directory therefore always maps to the same state file no matter where
34
+ * the host was launched from, while different projects never share state.
35
+ * Zero-dep by design (node builtins only) so generated plugins and the MCP
36
+ * server can inline the same semantics. Keep any copy in sync.
37
+ */
38
+ export function resolveStatePathForCwd(cwd, home) {
39
+ const resolvedCwd = path.resolve(cwd);
40
+ const resolvedHome = path.resolve(home);
41
+ if (resolvedCwd === resolvedHome) {
42
+ return path.join(resolvedHome, '.skillstate', 'global', 'skillstate.json');
43
+ }
44
+ return path.join(resolvedCwd, '.skillstate', 'skillstate.json');
45
+ }
46
+ /** True for plain (non-null, non-array) objects. */
47
+ function isPlainObject(value) {
48
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
49
+ }
50
+ /**
51
+ * Read the state file. Missing or corrupt files yield `{}` (best-effort).
52
+ * The on-disk envelope is `{ version: 1, state }` (migrations-compatible);
53
+ * a bare object is tolerated and treated as the state itself.
54
+ */
55
+ export function readSkillState(statePath) {
56
+ try {
57
+ if (fs.existsSync(statePath)) {
58
+ const parsed = JSON.parse(fs.readFileSync(statePath, 'utf-8'));
59
+ if (typeof parsed === 'object' &&
60
+ parsed !== null &&
61
+ 'state' in parsed &&
62
+ typeof parsed['state'] === 'object' &&
63
+ parsed['state'] !== null) {
64
+ return parsed['state'];
65
+ }
66
+ if (typeof parsed === 'object' && parsed !== null) {
67
+ return parsed;
68
+ }
69
+ }
70
+ }
71
+ catch {
72
+ // Corrupt or unreadable state file — fall back to empty state.
73
+ }
74
+ return {};
75
+ }
76
+ /**
77
+ * Persist the state file (best-effort: read-only environments are ignored).
78
+ * Creates the parent directory when missing (the per-project resolver may
79
+ * target a fresh `<cwd>/.skillstate/`). Writes the `{ version: 1, state }`
80
+ * envelope so `migrate()`/runtime resume read the same file.
81
+ */
82
+ export function saveSkillState(statePath, state) {
83
+ try {
84
+ fs.mkdirSync(path.dirname(statePath), { recursive: true });
85
+ fs.writeFileSync(statePath, `${JSON.stringify({ version: 1, state }, null, 2)}\n`);
86
+ }
87
+ catch {
88
+ // Best-effort: read-only environments or permission issues.
89
+ }
90
+ }
91
+ /**
92
+ * Paper ⊕ merge: `null` deletes a key, nested plain objects merge
93
+ * recursively, everything else replaces.
94
+ */
95
+ export function mergePatch(base, patch) {
96
+ const result = { ...base };
97
+ for (const key of Object.keys(patch)) {
98
+ const value = patch[key];
99
+ if (value === null) {
100
+ delete result[key];
101
+ }
102
+ else if (isPlainObject(value) && isPlainObject(result[key])) {
103
+ result[key] = mergePatch(result[key], value);
104
+ }
105
+ else {
106
+ result[key] = value;
107
+ }
108
+ }
109
+ return result;
110
+ }
111
+ /**
112
+ * Extract the `state_patch` object from an LLM response's fenced ```json
113
+ * block; `null` when there is no block, it is malformed, or it carries no
114
+ * object-shaped `state_patch`.
115
+ */
116
+ export function extractPatch(response) {
117
+ const match = response.match(/```json\s*\n?([\s\S]*?)\n?\s*```/);
118
+ if (!match)
119
+ return null;
120
+ try {
121
+ const parsed = JSON.parse(match[1]);
122
+ if (isPlainObject(parsed) && isPlainObject(parsed['state_patch'])) {
123
+ return parsed['state_patch'];
124
+ }
125
+ }
126
+ catch {
127
+ // Malformed JSON — ignore.
128
+ }
129
+ return null;
130
+ }
131
+ /** Synthetic message ids for the injected state carrier. */
132
+ const STATE_MESSAGE_ID = 'skillstate-state-inject';
133
+ /**
134
+ * Build the OpenCode plugin function with the same behavior for every host
135
+ * entry point (thin generated loaders, direct imports).
136
+ *
137
+ * State resolution is ALWAYS per-project: the state file path is computed
138
+ * from the session cwd on EVERY hook call via
139
+ * `resolveStatePathForCwd(process.cwd(), os.homedir())` — each project gets
140
+ * its own `<cwd>/.skillstate/skillstate.json`, and a session launched from
141
+ * `$HOME` uses the global bucket.
142
+ */
143
+ export function createSkillStatePlugin(options = {}) {
144
+ const resolvePath = () => resolveStatePathForCwd(process.cwd(), os.homedir());
145
+ const maxHistory = options.maxHistoryMessages ?? 3;
146
+ return async () => {
147
+ return {
148
+ // ── O(1) history trimming ──────────────────────────────────────────
149
+ // Filters messages BEFORE each LLM call: keeps all system messages
150
+ // plus the last `maxHistory` non-system messages, then injects a
151
+ // synthetic state element. Old messages are DROPPED from the prompt,
152
+ // not just hidden.
153
+ 'experimental.chat.messages.transform': async (_input, output) => {
154
+ const state = readSkillState(resolvePath());
155
+ const messages = output.messages;
156
+ const systemMessages = messages.filter((m) => m.info.role === 'system');
157
+ const trimmed = messages
158
+ .filter((m) => m.info.role !== 'system')
159
+ .slice(-maxHistory);
160
+ // Synthetic state carrier — a `{ info, parts }` envelope whose text
161
+ // part carries the current state JSON.
162
+ const stateMessage = {
163
+ info: {
164
+ id: STATE_MESSAGE_ID,
165
+ sessionID: 'skillstate',
166
+ role: 'user',
167
+ time: { created: 0 },
168
+ agent: 'skillstate',
169
+ model: { providerID: 'skillstate', modelID: 'skillstate' },
170
+ },
171
+ parts: [
172
+ {
173
+ id: `${STATE_MESSAGE_ID}-text`,
174
+ sessionID: 'skillstate',
175
+ messageID: STATE_MESSAGE_ID,
176
+ type: 'text',
177
+ synthetic: true,
178
+ text: `Current skill state (JSON): ${JSON.stringify(state)}`,
179
+ },
180
+ ],
181
+ };
182
+ // The pipeline holds the original array reference — mutate in place
183
+ // (reassigning `output.messages` would not reach the LLM call).
184
+ const kept = [...systemMessages, ...trimmed, stateMessage];
185
+ messages.length = 0;
186
+ messages.push(...kept);
187
+ },
188
+ // ── Compaction context injection ───────────────────────────────────
189
+ // Before compaction, inject the current state into the context so the
190
+ // compaction summary preserves state even after history is compressed.
191
+ 'experimental.session.compacting': async (_input, output) => {
192
+ const state = readSkillState(resolvePath());
193
+ if (!Array.isArray(output.context)) {
194
+ output.context = [];
195
+ }
196
+ output.context.push(`Skillstate: ${JSON.stringify(state)}`);
197
+ },
198
+ // ── State persistence from LLM responses ───────────────────────────
199
+ // After tool execution, extract state_patch from the tool response
200
+ // (output.output), merge it, and save to disk.
201
+ 'tool.execute.after': async (_input, output) => {
202
+ const response = output.output ?? '';
203
+ if (typeof response !== 'string')
204
+ return;
205
+ const statePath = resolvePath();
206
+ const patch = extractPatch(response);
207
+ if (patch) {
208
+ saveSkillState(statePath, mergePatch(readSkillState(statePath), patch));
209
+ }
210
+ },
211
+ };
212
+ };
213
+ }
214
+ //# sourceMappingURL=plugin.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plugin.js","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skillstate/opencode",
3
- "version": "2.0.3",
3
+ "version": "2.0.5",
4
4
  "description": "OpenCode platform adapter for the skillstate runtime.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -10,6 +10,10 @@
10
10
  "types": "./dist/index.d.ts",
11
11
  "default": "./dist/index.js"
12
12
  },
13
+ "./plugin": {
14
+ "types": "./dist/plugin.d.ts",
15
+ "default": "./dist/plugin.js"
16
+ },
13
17
  "./package.json": "./package.json"
14
18
  },
15
19
  "files": [