@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 +37 -20
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/opencode-adapter.d.ts +23 -26
- package/dist/opencode-adapter.d.ts.map +1 -1
- package/dist/opencode-adapter.js +32 -132
- package/dist/opencode-adapter.js.map +1 -1
- package/dist/plugin-types.d.ts +64 -0
- package/dist/plugin-types.d.ts.map +1 -0
- package/dist/plugin-types.js +8 -0
- package/dist/plugin-types.js.map +1 -0
- package/dist/plugin.d.ts +61 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +214 -0
- package/dist/plugin.js.map +1 -0
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
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
10
|
[](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
|
-
|
|
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(
|
|
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
|
|
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** (`
|
|
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
|
|
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(
|
|
130
|
-
(`
|
|
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
|
-
- `
|
|
135
|
-
|
|
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
|
|
149
|
-
|
|
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
package/dist/index.d.ts.map
CHANGED
|
@@ -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
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
|
|
59
|
+
* Generate an OpenCode plugin file.
|
|
55
60
|
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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(
|
|
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).
|
|
78
|
-
*
|
|
79
|
-
*
|
|
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,
|
|
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
|
|
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"}
|
package/dist/opencode-adapter.js
CHANGED
|
@@ -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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
//
|
|
115
|
-
//
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
import
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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).
|
|
235
|
-
*
|
|
236
|
-
*
|
|
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,
|
|
143
|
+
async savePluginCode(target, options) {
|
|
240
144
|
const dest = typeof target === 'string'
|
|
241
145
|
? target
|
|
242
146
|
: resolveStatePath(target.root, target.name);
|
|
243
|
-
|
|
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":"AAqBA,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AACrD,OAAO,EACL,eAAe,EACf,gBAAgB,GACjB,MAAM,kBAAkB,CAAC;
|
|
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"}
|
package/dist/plugin.d.ts
ADDED
|
@@ -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
|
+
"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": [
|