@skillstate/opencode 2.2.1 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Project state persistence for the OpenCode v2 plugin.
3
+ *
4
+ * ONE store, THREE guarantees:
5
+ *
6
+ * 1. **Per-project addressing.** The state file is derived from the plugin
7
+ * instance's location (`ctx.location.project.canonical`), never from
8
+ * `process.cwd()`. The v1 plugin called `process.cwd()` on every hook;
9
+ * in v2 a single OpenCode server serves many projects, so the process
10
+ * cwd is simply the wrong answer. Two checkouts no longer share state.
11
+ *
12
+ * 2. **Per-session isolation.** A root session owns the shared project
13
+ * file; a sub-agent session owns `agents/<scope>/skillstate.json` (see
14
+ * `stateScopeFor`). Parallel sub-agents therefore never last-writer-win
15
+ * over the main session's notes.
16
+ *
17
+ * 3. **Never lose a write.** Every mutation runs inside the core
18
+ * cross-process lock ({@link withStateLock}) and lands via
19
+ * {@link atomicWriteFile} (temp sibling → fsync → rename), so a crash
20
+ * mid-write can never leave a truncated or interleaved state file. Reads
21
+ * never throw: a missing or corrupt file reads as `{}`.
22
+ *
23
+ * The store is deliberately schema-free. It does not validate against a
24
+ * procedural spec: OpenCode sessions are free-form work, and forcing a
25
+ * fixed `goal`/`progress`/`next_steps` shape is what made the v1 prompt
26
+ * rewrite the user's actual task into a state machine.
27
+ */
28
+ import * as fs from 'node:fs';
29
+ import * as os from 'node:os';
30
+ import * as path from 'node:path';
31
+ import { atomicWriteFile, isPlainObject, mergePatch, migrate, resolveHostStateForCwd, withStateLock, } from '@skillstate/core';
32
+ /** Top-level diff between two state documents. Pure. */
33
+ export function diffDocuments(before, after) {
34
+ const added = [];
35
+ const updated = [];
36
+ const deleted = [];
37
+ for (const key of Object.keys(after)) {
38
+ if (!(key in before)) {
39
+ added.push(key);
40
+ }
41
+ else if (JSON.stringify(before[key]) !== JSON.stringify(after[key])) {
42
+ updated.push(key);
43
+ }
44
+ }
45
+ for (const key of Object.keys(before)) {
46
+ if (!(key in after))
47
+ deleted.push(key);
48
+ }
49
+ return { added, updated, deleted };
50
+ }
51
+ /**
52
+ * The state file for one session scope inside one project.
53
+ *
54
+ * `scope === ''` is the shared project file; any other scope is a
55
+ * sub-agent's isolated copy. Delegates to the core resolver (shared with
56
+ * the MCP server, the CLI and the other host adapters) so every host agrees
57
+ * on where a project's state lives.
58
+ */
59
+ export function statePathFor(directory, scope, home = os.homedir()) {
60
+ return resolveHostStateForCwd(directory, home, scope);
61
+ }
62
+ /**
63
+ * Reads and merges the per-project state documents for a plugin instance.
64
+ *
65
+ * Stateless between calls apart from the filesystem, so it is safe to keep
66
+ * one instance per plugin and to construct it in tests without any global
67
+ * setup.
68
+ */
69
+ export class ProjectStateStore {
70
+ directory;
71
+ home;
72
+ constructor(options) {
73
+ this.directory = path.resolve(options.directory);
74
+ this.home = options.home ?? os.homedir();
75
+ }
76
+ /** The project directory this store addresses. */
77
+ get projectDirectory() {
78
+ return this.directory;
79
+ }
80
+ /** The absolute state file path for a scope (`''` = shared). */
81
+ pathFor(scope) {
82
+ return statePathFor(this.directory, scope, this.home);
83
+ }
84
+ /**
85
+ * Whether a state file already exists for this scope. The plugin uses
86
+ * this to decide whether to inject the system hint at all — an untouched
87
+ * project must behave exactly like vanilla OpenCode.
88
+ */
89
+ exists(scope) {
90
+ try {
91
+ return fs.statSync(this.pathFor(scope)).isFile();
92
+ }
93
+ catch {
94
+ return false;
95
+ }
96
+ }
97
+ /**
98
+ * Read a scope's state. A missing, unreadable or corrupt file reads as
99
+ * `{}` — a best-effort read that never breaks the agent loop.
100
+ */
101
+ read(scope) {
102
+ const filePath = this.pathFor(scope);
103
+ try {
104
+ const raw = fs.readFileSync(filePath, 'utf-8');
105
+ return migrate(JSON.parse(raw)).state;
106
+ }
107
+ catch {
108
+ return {};
109
+ }
110
+ }
111
+ /**
112
+ * Apply a patch to a scope's state and persist it.
113
+ *
114
+ * The read, the merge and the write all happen inside the cross-process
115
+ * lock, so two sub-agents writing disjoint keys both survive — neither
116
+ * can clobber the other's write by reading a stale snapshot.
117
+ *
118
+ * Returns the merged document and the top-level change sets. A `null`
119
+ * value in the patch deletes that key (paper ⊕).
120
+ */
121
+ async patch(scope, patch) {
122
+ const filePath = this.pathFor(scope);
123
+ const result = await withStateLock(filePath, async () => {
124
+ const before = this.read(scope);
125
+ const after = mergePatch(before, patch);
126
+ await atomicWriteFile(filePath, `${JSON.stringify({ version: 1, state: after }, null, 2)}\n`);
127
+ return { state: after, changes: diffDocuments(before, after) };
128
+ });
129
+ return result;
130
+ }
131
+ /**
132
+ * Fold a sub-agent's document into this scope's document.
133
+ *
134
+ * `keep` decides what happens when both sides set the same scalar:
135
+ * `'existing'` (default) keeps the receiving document's value, `'source'`
136
+ * takes the sub-agent's. Keys only present in the sub-agent are always
137
+ * taken, and `null` in either document is a deletion.
138
+ *
139
+ * The source document is left on disk — the merge is not destructive, so
140
+ * a sub-agent's notes remain inspectable after the fold.
141
+ */
142
+ async merge(scope, sourceScope, keep = 'existing') {
143
+ if (sourceScope === scope) {
144
+ throw new Error('skillstate_merge: a sub-agent cannot merge its own state');
145
+ }
146
+ const source = this.read(sourceScope);
147
+ if (Object.keys(source).length === 0) {
148
+ throw new Error(`skillstate_merge: no saved state for sub-agent "${sourceScope}" — nothing to merge`);
149
+ }
150
+ const filePath = this.pathFor(scope);
151
+ return withStateLock(filePath, async () => {
152
+ const before = this.read(scope);
153
+ const after = mergeDocuments(before, source, keep);
154
+ await atomicWriteFile(filePath, `${JSON.stringify({ version: 1, state: after }, null, 2)}\n`);
155
+ return { state: after, changes: diffDocuments(before, after) };
156
+ });
157
+ }
158
+ }
159
+ /**
160
+ * Fold `source` into `target` under a conflict policy. Pure.
161
+ *
162
+ * Nested plain objects recurse. A `null` on either side is a deletion and
163
+ * wins outright — an explicit "remove this" is not a scalar conflict. Every
164
+ * other scalar conflict follows `keep`.
165
+ */
166
+ export function mergeDocuments(target, source, keep) {
167
+ const result = { ...target };
168
+ for (const [key, value] of Object.entries(source)) {
169
+ if (value === null) {
170
+ delete result[key];
171
+ continue;
172
+ }
173
+ if (!(key in result)) {
174
+ result[key] = value;
175
+ continue;
176
+ }
177
+ if (isPlainObject(value) && isPlainObject(result[key])) {
178
+ result[key] = mergeDocuments(result[key], value, keep);
179
+ continue;
180
+ }
181
+ if (keep === 'source')
182
+ result[key] = value;
183
+ }
184
+ return result;
185
+ }
186
+ //# sourceMappingURL=state-store.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"state-store.js","sourceRoot":"","sources":["../src/state-store.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The system-prompt fragment the plugin contributes to each model request.
3
+ *
4
+ * ── Why this module is so small and so careful ───────────────────────────
5
+ *
6
+ * The v1 integration rewrote the conversation on every turn: it truncated
7
+ * history to the last three messages and appended a synthetic `role: "user"`
8
+ * message whose body was the raw state JSON. The reported symptom was that
9
+ * the agent stopped doing what it was asked and started emitting state
10
+ * JSON instead. The cause was structural, not a model quirk:
11
+ *
12
+ * - The last `role: "user"` message is what the model treats as the current
13
+ * instruction, so the state blob displaced the user's actual request.
14
+ * - Truncating history deleted the task statement, the tool results, and
15
+ * the errors the agent had just been given — it could not reason about
16
+ * work it could no longer see.
17
+ *
18
+ * The fix is a rule, enforced by `tests/opencode/context-integrity.test.ts`:
19
+ * **this plugin never mutates `event.messages`.** It contributes one
20
+ * additive, bounded fragment to `event.system`, states what the tools are
21
+ * for, and explicitly leaves the task alone.
22
+ *
23
+ * The wording below is a product requirement, not prose taste. It must
24
+ * describe the tools without instructing the model to change how it works.
25
+ * Imperative framing ("you must", "always", "respond with a JSON block")
26
+ * is what turns a persistence aid into a prompt override.
27
+ */
28
+ /** Largest state JSON rendered into the hint before falling back to a key list. */
29
+ export declare const MAX_INLINE_STATE_CHARS = 4000;
30
+ /** The tool names the hint advertises — kept in sync with `tools.ts`. */
31
+ export declare const ADVERTISED_TOOLS: readonly ['skillstate_read', 'skillstate_update', 'skillstate_merge'];
32
+ /**
33
+ * Render a state document for inclusion in the system prompt.
34
+ *
35
+ * Small documents are inlined verbatim (the model sees what is already
36
+ * saved without a tool round-trip). Documents past
37
+ * {@link MAX_INLINE_STATE_CHARS} are summarized as a key list plus a byte
38
+ * count and a pointer to `skillstate_read` — an unbounded state file must
39
+ * not be able to grow the system prompt without limit.
40
+ */
41
+ export declare function renderStateForHint(state: Record<string, unknown>): string;
42
+ /** Options for {@link buildStateHint}. */
43
+ export interface StateHintOptions {
44
+ /** The state document this session has saved. */
45
+ state: Record<string, unknown>;
46
+ /** Project-relative state path, for the model's reference. */
47
+ statePath: string;
48
+ /**
49
+ * This session's scope, or `''` when it is a root session. A sub-agent
50
+ * hint names the merge step so the main session can fold its notes back.
51
+ */
52
+ scope?: string;
53
+ }
54
+ /**
55
+ * Build the system-prompt fragment.
56
+ *
57
+ * Returns `''` for an empty document so an untouched project contributes
58
+ * nothing at all — the plugin is inert until the agent actually saves
59
+ * something, and an inert plugin is indistinguishable from no plugin.
60
+ */
61
+ export declare function buildStateHint(options: StateHintOptions): string;
62
+ //# sourceMappingURL=system-hint.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"system-hint.d.ts","sourceRoot":"","sources":["../src/system-hint.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,mFAAmF;AACnF,eAAO,MAAM,sBAAsB,OAAO,CAAC;AAE3C,yEAAyE;AACzE,eAAO,MAAM,gBAAgB,YAC3B,iBAAiB,EACjB,mBAAmB,EACnB,kBAAkB,CACV,CAAC;AAEX;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAezE;AAED,0CAA0C;AAC1C,MAAM,WAAW,gBAAgB;IAC/B,iDAAiD;IACjD,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC/B,8DAA8D;IAC9D,SAAS,EAAE,MAAM,CAAC;IAClB;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,gBAAgB,GAAG,MAAM,CAwBhE"}
@@ -0,0 +1,87 @@
1
+ /**
2
+ * The system-prompt fragment the plugin contributes to each model request.
3
+ *
4
+ * ── Why this module is so small and so careful ───────────────────────────
5
+ *
6
+ * The v1 integration rewrote the conversation on every turn: it truncated
7
+ * history to the last three messages and appended a synthetic `role: "user"`
8
+ * message whose body was the raw state JSON. The reported symptom was that
9
+ * the agent stopped doing what it was asked and started emitting state
10
+ * JSON instead. The cause was structural, not a model quirk:
11
+ *
12
+ * - The last `role: "user"` message is what the model treats as the current
13
+ * instruction, so the state blob displaced the user's actual request.
14
+ * - Truncating history deleted the task statement, the tool results, and
15
+ * the errors the agent had just been given — it could not reason about
16
+ * work it could no longer see.
17
+ *
18
+ * The fix is a rule, enforced by `tests/opencode/context-integrity.test.ts`:
19
+ * **this plugin never mutates `event.messages`.** It contributes one
20
+ * additive, bounded fragment to `event.system`, states what the tools are
21
+ * for, and explicitly leaves the task alone.
22
+ *
23
+ * The wording below is a product requirement, not prose taste. It must
24
+ * describe the tools without instructing the model to change how it works.
25
+ * Imperative framing ("you must", "always", "respond with a JSON block")
26
+ * is what turns a persistence aid into a prompt override.
27
+ */
28
+ /** Largest state JSON rendered into the hint before falling back to a key list. */
29
+ export const MAX_INLINE_STATE_CHARS = 4000;
30
+ /** The tool names the hint advertises — kept in sync with `tools.ts`. */
31
+ export const ADVERTISED_TOOLS = [
32
+ 'skillstate_read',
33
+ 'skillstate_update',
34
+ 'skillstate_merge',
35
+ ];
36
+ /**
37
+ * Render a state document for inclusion in the system prompt.
38
+ *
39
+ * Small documents are inlined verbatim (the model sees what is already
40
+ * saved without a tool round-trip). Documents past
41
+ * {@link MAX_INLINE_STATE_CHARS} are summarized as a key list plus a byte
42
+ * count and a pointer to `skillstate_read` — an unbounded state file must
43
+ * not be able to grow the system prompt without limit.
44
+ */
45
+ export function renderStateForHint(state) {
46
+ const json = JSON.stringify(state, null, 2);
47
+ if (json.length <= MAX_INLINE_STATE_CHARS)
48
+ return json;
49
+ const keys = Object.keys(state).sort();
50
+ const bytes = Buffer.byteLength(json, 'utf-8');
51
+ return `${JSON.stringify({
52
+ _truncated: true,
53
+ bytes,
54
+ keys,
55
+ hint: 'Saved state is large — call skillstate_read to load it.',
56
+ }, null, 2)}`;
57
+ }
58
+ /**
59
+ * Build the system-prompt fragment.
60
+ *
61
+ * Returns `''` for an empty document so an untouched project contributes
62
+ * nothing at all — the plugin is inert until the agent actually saves
63
+ * something, and an inert plugin is indistinguishable from no plugin.
64
+ */
65
+ export function buildStateHint(options) {
66
+ const { state, statePath } = options;
67
+ const scope = options.scope ?? '';
68
+ if (Object.keys(state).length === 0)
69
+ return '';
70
+ // `skillstate_merge` is only meaningful to a sub-agent, which is the one
71
+ // session that cannot call it — so it is hidden from every other scope.
72
+ const tools = ADVERTISED_TOOLS.filter((name) => name !== 'skillstate_merge' || scope !== '');
73
+ const toolLine = `\nTools: ${tools.map((name) => `\`${name}\``).join(', ')}.`;
74
+ const mergeLine = scope === ''
75
+ ? ''
76
+ : '\nThis is a sub-agent session. When you finish, the main session folds your notes back with `skillstate_merge`; write them as if someone else will read them.';
77
+ return [
78
+ '<skillstate-project-notes>',
79
+ `Notes for this project are saved at \`${statePath}\` and survive a context reset or compaction.`,
80
+ '',
81
+ renderStateForHint(state),
82
+ `${toolLine}${mergeLine}`,
83
+ 'Use them only to carry facts across turns — plans already made, decisions already taken, file paths, and what is left to do. The notes are a side channel, not the task: keep doing what the user asked, and skip these tools entirely when the work needs no cross-turn memory.',
84
+ '</skillstate-project-notes>',
85
+ ].join('\n');
86
+ }
87
+ //# sourceMappingURL=system-hint.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"system-hint.js","sourceRoot":"","sources":["../src/system-hint.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,mFAAmF;AACnF,MAAM,CAAC,MAAM,sBAAsB,GAAG,IAAI,CAAC;AAE3C,yEAAyE;AACzE,MAAM,CAAC,MAAM,gBAAgB,GAAG;IAC9B,iBAAiB;IACjB,mBAAmB;IACnB,kBAAkB;CACV,CAAC;AAEX;;;;;;;;GAQG;AACH,MAAM,UAAU,kBAAkB,CAAC,KAA8B;IAC/D,MAAM,IAAI,GAAG,IAAI,CAAC,SAAS,CAAC,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC;IAC5C,IAAI,IAAI,CAAC,MAAM,IAAI,sBAAsB;QAAE,OAAO,IAAI,CAAC;IACvD,MAAM,IAAI,GAAG,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,IAAI,EAAE,CAAC;IACvC,MAAM,KAAK,GAAG,MAAM,CAAC,UAAU,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC;IAC/C,OAAO,GAAG,IAAI,CAAC,SAAS,CACtB;QACE,UAAU,EAAE,IAAI;QAChB,KAAK;QACL,IAAI;QACJ,IAAI,EAAE,yDAAyD;KAChE,EACD,IAAI,EACJ,CAAC,CACF,EAAE,CAAC;AACN,CAAC;AAeD;;;;;;GAMG;AACH,MAAM,UAAU,cAAc,CAAC,OAAyB;IACtD,MAAM,EAAE,KAAK,EAAE,SAAS,EAAE,GAAG,OAAO,CAAC;IACrC,MAAM,KAAK,GAAG,OAAO,CAAC,KAAK,IAAI,EAAE,CAAC;IAClC,IAAI,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,EAAE,CAAC;IAE/C,yEAAyE;IACzE,wEAAwE;IACxE,MAAM,KAAK,GAAG,gBAAgB,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,KAAK,kBAAkB,IAAI,KAAK,KAAK,EAAE,CAAC,CAAC;IAC7F,MAAM,QAAQ,GAAG,YAAY,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,KAAK,IAAI,IAAI,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC;IAE9E,MAAM,SAAS,GACb,KAAK,KAAK,EAAE;QACV,CAAC,CAAC,EAAE;QACJ,CAAC,CAAC,+JAA+J,CAAC;IAEtK,OAAO;QACL,4BAA4B;QAC5B,yCAAyC,SAAS,+CAA+C;QACjG,EAAE;QACF,kBAAkB,CAAC,KAAK,CAAC;QACzB,GAAG,QAAQ,GAAG,SAAS,EAAE;QACzB,kRAAkR;QAClR,6BAA6B;KAC9B,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AACf,CAAC"}
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Native OpenCode tools for skillstate.
3
+ *
4
+ * These are the NATIVE tools: the fast path when the host is opencode v2.
5
+ * They carry a real JSON Schema and structured output, where the MCP server
6
+ * (`@skillstate/mcp`, still shipped and still registered) can only offer a
7
+ * JSON-RPC round-trip and untyped text. Both read the same state file, so
8
+ * the choice between them is about speed and typing on one side and reach
9
+ * on the other -- never about which one is correct.
10
+ *
11
+ * The MCP server was once the ONLY option: an opencode v1 plugin could not
12
+ * contribute first-class tools at all.
13
+ *
14
+ * Three tools, deliberately:
15
+ *
16
+ * - `skillstate_read` - what this session has already saved.
17
+ * - `skillstate_update` - merge a patch (the only write path).
18
+ * - `skillstate_merge` - fold sub-agent notes back into the root session.
19
+ *
20
+ * A fourth "delete everything" tool is intentionally absent: `null` in a
21
+ * patch already deletes a key, and a tool whose only job is to erase state
22
+ * is a foot-gun with no upside.
23
+ *
24
+ * ── Result shape ──────────────────────────────────────────────────────────
25
+ *
26
+ * Every tool returns a DISCRIMINATED result: `{ ok: true, ... }` or
27
+ * `{ ok: false, error }`. This is not a stylistic choice. A tool that
28
+ * declares an `output` schema must return a value matching it, so a failure
29
+ * path that returned only text was rejected by the host with "tool did not
30
+ * return its declared output" — a rejected tool call is worse for the agent
31
+ * than a failed one, because it loses the reason. A validation failure is a
32
+ * normal outcome here, and it is modelled as one.
33
+ *
34
+ * SCOPING is automatic. The current session's scope comes from
35
+ * `ToolContext.sessionID` plus the {@link SessionRegistry}, so the model
36
+ * never passes a session id and can never write another agent's file by
37
+ * guessing one.
38
+ */
39
+ import type { ToolEditor } from '@opencode/plugin/promise/tool';
40
+ import type { StatePatch } from '@skillstate/core';
41
+ import type { SessionRegistry } from './session-registry.js';
42
+ import { stateScopeFor } from './session-registry.js';
43
+ import type { ProjectStateStore, StateChanges } from './state-store.js';
44
+ /**
45
+ * Largest serialized patch a single `skillstate_update` accepts. The state
46
+ * file is a side channel for a human to read; a model that tries to dump a
47
+ * whole file into it should be told, not silently accommodated.
48
+ */
49
+ export declare const MAX_PATCH_BYTES: number;
50
+ /** A tool call that succeeded. */
51
+ export interface ToolOk<T> {
52
+ readonly ok: true;
53
+ readonly value: T;
54
+ }
55
+ /** A tool call that was refused. `error` is written for the model to read. */
56
+ export interface ToolError {
57
+ readonly ok: false;
58
+ readonly error: string;
59
+ }
60
+ /** Every tool result is one of these two. */
61
+ export type ToolResult<T> = ToolOk<T> | ToolError;
62
+ /** Payload of a successful `skillstate_read`. */
63
+ export interface ReadValue {
64
+ readonly state: Record<string, unknown>;
65
+ readonly path: string;
66
+ readonly scope: string;
67
+ readonly empty: boolean;
68
+ }
69
+ /** Payload of a successful `skillstate_update`. */
70
+ export interface UpdateValue {
71
+ readonly state: Record<string, unknown>;
72
+ readonly changes: StateChanges;
73
+ readonly path: string;
74
+ readonly scope: string;
75
+ }
76
+ /** Payload of a successful `skillstate_merge`. */
77
+ export interface MergeValue {
78
+ readonly state: Record<string, unknown>;
79
+ readonly changes: StateChanges;
80
+ readonly merged: readonly string[];
81
+ readonly skipped: readonly string[];
82
+ }
83
+ /** Everything the tool definitions need, bound to one plugin instance. */
84
+ export interface ToolDeps {
85
+ readonly store: ProjectStateStore;
86
+ readonly sessions: SessionRegistry;
87
+ /** Resolve a session id to its state scope; `''` is the root session. */
88
+ scopeFor: (sessionID: string) => string;
89
+ }
90
+ /**
91
+ * Validate and normalize an incoming patch.
92
+ *
93
+ * Returns either the patch or a human-readable reason it was rejected. The
94
+ * checks protect the file; they do not dictate the model's schema. A state
95
+ * note is free-form by design, so the only hard rules are "must be an
96
+ * object", "must be JSON-serializable", and "must fit".
97
+ */
98
+ export declare function normalizePatch(raw: unknown): {
99
+ ok: true;
100
+ patch: StatePatch;
101
+ } | {
102
+ ok: false;
103
+ reason: string;
104
+ };
105
+ /**
106
+ * Register the skillstate tools on an editor.
107
+ *
108
+ * The callback is synchronous, side-effect-free and replayable: OpenCode
109
+ * re-runs it on every registry rebuild, so it must not do I/O at
110
+ * registration time. All filesystem work happens inside `execute`.
111
+ */
112
+ export declare function registerTools(editor: ToolEditor, deps: ToolDeps): void;
113
+ export { stateScopeFor };
114
+ //# sourceMappingURL=tools.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tools.d.ts","sourceRoot":"","sources":["../src/tools.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AAEH,OAAO,KAAK,EAAe,UAAU,EAAE,MAAM,+BAA+B,CAAC;AAC7E,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AACnD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAC7D,OAAO,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AACtD,OAAO,KAAK,EAAE,iBAAiB,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAExE;;;;GAIG;AACH,eAAO,MAAM,eAAe,QAAY,CAAC;AAEzC,kCAAkC;AAClC,MAAM,WAAW,MAAM,CAAC,CAAC;IACvB,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAClB,QAAQ,CAAC,KAAK,EAAE,CAAC,CAAC;CACnB;AAED,8EAA8E;AAC9E,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IACnB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;CACxB;AAED,6CAA6C;AAC7C,MAAM,MAAM,UAAU,CAAC,CAAC,IAAI,MAAM,CAAC,CAAC,CAAC,GAAG,SAAS,CAAC;AAElD,iDAAiD;AACjD,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACxC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;CACzB;AAED,mDAAmD;AACnD,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACxC,QAAQ,CAAC,OAAO,EAAE,YAAY,CAAC;IAC/B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;CACxB;AAED,kDAAkD;AAClD,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACxC,QAAQ,CAAC,OAAO,EAAE,YAAY,CAAC;IAC/B,QAAQ,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC;IACnC,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;CACrC;AAED,0EAA0E;AAC1E,MAAM,WAAW,QAAQ;IACvB,QAAQ,CAAC,KAAK,EAAE,iBAAiB,CAAC;IAClC,QAAQ,CAAC,QAAQ,EAAE,eAAe,CAAC;IACnC,yEAAyE;IACzE,QAAQ,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,MAAM,CAAC;CACzC;AA0JD;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAC5B,GAAG,EAAE,OAAO,GACX;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,UAAU,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAoCjE;AAwBD;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,UAAU,EAAE,IAAI,EAAE,QAAQ,GAAG,IAAI,CAkItE;AAOD,OAAO,EAAE,aAAa,EAAE,CAAC"}