@skillstate/opencode 2.2.2 → 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.
package/dist/plugin.d.ts CHANGED
@@ -1,95 +1,87 @@
1
- import { mergePatch, resolveHostStateForCwd } from '@skillstate/core';
2
- import type { OpenCodeMessage, SkillStatePlugin } from './plugin-types.js';
3
- export * from './plugin-types.js';
4
1
  /**
5
- * Resolve the per-project state file for a session working directory
6
- * (`cwd` of the current opencode session) — the core single source of
7
- * truth (`resolveHostStateForCwd`): `<cwd>/.skillstate/skillstate.json`,
8
- * or the global bucket `<home>/.skillstate/global/skillstate.json` when
9
- * cwd equals home. A non-empty `agentId` scopes the file under
10
- * `<bucket>/agents/<agentId>/skillstate.json`. Pure path arithmetic via
11
- * `path.resolve`, no filesystem access.
12
- */
13
- export { resolveHostStateForCwd as resolveStatePathForCwd };
14
- export { mergePatch };
15
- /** Options for {@link createSkillStatePlugin}. */
16
- export interface SkillStatePluginOptions {
17
- /** Non-system messages kept in the prompt (default 3). */
18
- maxHistoryMessages?: number;
19
- }
20
- /**
21
- * Read the state file. Missing or corrupt files yield `{}` (best-effort).
22
- * The on-disk envelope is `{ version: 1, state }` (migrations-compatible);
23
- * a bare object is tolerated and treated as the state itself. Thin fs
24
- * adapter over the core hook-runtime {@link readStateEnvelope}.
25
- */
26
- export declare function readSkillState(statePath: string): Record<string, unknown>;
27
- /**
28
- * Persist the state file (best-effort: read-only environments are ignored).
29
- * Creates the parent directory when missing (the per-project resolver may
30
- * target a fresh `<cwd>/.skillstate/agents/<id>/`). Writes the
31
- * `{ version: 1, state }` envelope so `migrate()`/runtime resume read the
32
- * same file — via the core hook-runtime {@link saveStateEnvelope} — under
33
- * the cross-process sync lock {@link lockStateWrite} (2-3 parallel agent
34
- * processes never interleave state writes).
35
- */
36
- export declare function saveSkillState(statePath: string, state: Record<string, unknown>): void;
37
- /**
38
- * Atomic READ-MERGE-WRITE of one `state_patch` (paper ⊕: null deletes):
39
- * the whole critical section runs inside {@link lockStateWrite}, so two
40
- * concurrent writers apply BOTH patches instead of racing between the
41
- * read and the write. Best-effort: lock contention or unwritable state
42
- * files are swallowed — the tool flow never breaks.
43
- */
44
- export declare function mergeSkillState(statePath: string, patch: Record<string, unknown>): Record<string, unknown>;
45
- /**
46
- * Extract the `state_patch` object from an LLM response's fenced ```json
47
- * block; `null` when there is no block, it is malformed, or it carries no
48
- * object-shaped `state_patch`. Thin adapter over the core hook-runtime
49
- * {@link findFencedPatch} (the invalid/truncated outcomes collapse to
50
- * `null`, preserving the legacy boolean contract).
51
- */
52
- export declare function extractPatch(response: string): Record<string, unknown> | null;
53
- /**
54
- * Agent id for an opencode hook call.
55
- *
56
- * MAIN SESSION → `''` (the ROOT state file `<cwd>/.skillstate/skillstate.json`
57
- * — the SAME file the skillstate MCP tools and the CLI address, so the
58
- * injected state and `state.patch` can never disagree). A session
59
- * registered as a SUB-AGENT via the host event bus
60
- * (`session.created`/`updated` carry `info.parentID`) resolves to
61
- * `<parentPrefix>-<sessionPrefix>` — an isolated copy under `agents/` that
62
- * never last-writer-wins the main state; the main agent folds it back with
63
- * `agent.merge`. No session id at all → `''` (root: a single context).
64
- */
65
- export declare function pluginAgentId(input: {
66
- sessionID?: unknown;
67
- }, messages?: OpenCodeMessage[]): string;
68
- /**
69
- * Widen an agent id for a registered sub-agent session:
70
- * `<parentPrefix>-<sessionPrefix>`. Plain sessions resolve to `''` (the
71
- * main/root scope — NOT their own agents/ copy).
72
- */
73
- export declare function scopedAgentId(agentId: string): string;
74
- /**
75
- * Record a session→parent edge from the host event stream. `sessionId`
76
- * with a non-empty `parentID` registers that session as a sub-agent of
77
- * `parentID`; an empty `parentID` (the main session being updated after
78
- * the fact) clears a stale registration. Exposed for tests.
2
+ * `@skillstate/opencode` — the OpenCode **v2** plugin.
3
+ *
4
+ * ── What this replaces ───────────────────────────────────────────────────
5
+ *
6
+ * The v1 integration rewrote the conversation on every model request. It
7
+ * kept the system messages and the last three non-system messages, dropped
8
+ * everything else from `output.messages`, and appended a synthetic
9
+ * `role: "user"` message containing the raw state JSON. The reported
10
+ * failure was that the agent stopped doing the user's task and started
11
+ * emitting state JSON instead.
12
+ *
13
+ * Both halves of that were destructive, and neither was a model quirk:
14
+ *
15
+ * 1. The injected message landed LAST, so for the model it was the current
16
+ * instruction — it displaced the user's actual request.
17
+ * 2. `slice(-3)` deleted the task statement, the tool results and the
18
+ * errors the agent had just been handed. It was reasoning about work it
19
+ * could no longer see.
20
+ *
21
+ * The MCP server made it worse: `spec.get` returned a procedural spec whose
22
+ * default was `INTERCODE_CTF_SPEC`, whose instructions read "You are an
23
+ * autonomous CTF agent ... hidden flag somewhere on its filesystem". A
24
+ * model told to look for a flag looks for a flag. (Fixed: the default is now
25
+ * the neutral `GENERIC_PROCEDURE_SPEC`, and its instructions describe the
26
+ * storage format instead of prescribing a way of working.)
27
+ *
28
+ * ── The v2 design ────────────────────────────────────────────────────────
29
+ *
30
+ * Three rules, each enforced by a test:
31
+ *
32
+ * - **Never mutate `event.messages`.** The plugin contributes one additive
33
+ * fragment to `event.system` and leaves the transcript alone. See
34
+ * `tests/opencode/context-integrity.test.ts`.
35
+ * - **Never inject behavioural instructions.** The system fragment
36
+ * describes what the notes are and when to use them; it contains no
37
+ * "you must", no "always", and no output format. See
38
+ * `system-hint.ts`.
39
+ * - **Inert until used.** A project with no state file gets no system
40
+ * fragment at all and behaves exactly like vanilla OpenCode. No files are
41
+ * created by loading the plugin.
42
+ *
43
+ * ── Native tools AND the MCP server, on purpose ──────────────────────────
44
+ *
45
+ * This package does not replace `@skillstate/mcp`; it sits beside it.
46
+ *
47
+ * - The native tools ({@link registerTools}) are the fast path inside
48
+ * opencode: a typed schema, structured output, no JSON-RPC round-trip and
49
+ * no untyped text result.
50
+ * - The MCP server is the portable path. It is what every other
51
+ * MCP-capable host reads, and the only way to reach this state from a
52
+ * client that is not opencode.
53
+ *
54
+ * Both address the same `<project>/.skillstate/skillstate.json`, so they
55
+ * cannot disagree about what is saved. `skillstate init` registers both.
56
+ *
57
+ * The reason v1 needed the MCP server is gone: an opencode v1 plugin could
58
+ * not contribute first-class tools at all.
59
+ *
60
+ * Load it from `opencode.json(c)`:
61
+ *
62
+ * ```json
63
+ * { "plugins": ["@skillstate/opencode"] }
64
+ * ```
79
65
  */
80
- export declare function registerSessionParent(sessionId: unknown, parentId: unknown): void;
81
- /** Test-only: forget every registered session→parent edge. */
82
- export declare function resetSessionParents(): void;
66
+ import { Plugin } from '@opencode/plugin';
67
+ /** Stable plugin id — scopes plugin storage and identifies it in `/api/plugin`. */
68
+ export declare const PLUGIN_ID = "skillstate";
83
69
  /**
84
- * Build the OpenCode plugin function with the same behavior for every host
85
- * entry point (thin generated loaders, direct imports).
86
- *
87
- * State resolution is ALWAYS per-project: the state file path is computed
88
- * from the session cwd on EVERY hook call via
89
- * `resolveStatePathForCwd(process.cwd(), os.homedir(), agentId)` — each
90
- * project gets its own `<cwd>/.skillstate/`, each session (sub-agent) its
91
- * isolated `agents/<session>/` copy, and a session launched from `$HOME`
92
- * uses the global bucket.
70
+ * The plugin definition.
71
+ *
72
+ * `setup` wires three things and returns a cleanup function:
73
+ *
74
+ * - a {@link SessionRegistry}, fed by the server event stream, so a
75
+ * sub-agent session is recognised and given its own state file;
76
+ * - a {@link ProjectStateStore} rooted at the plugin's own project
77
+ * location, so two checkouts served by one OpenCode server never share
78
+ * state;
79
+ * - native tools plus a single additive `context` hook.
80
+ *
81
+ * The event subscription is the only resource the plugin owns, so the
82
+ * returned cleanup aborts it. Hook and tool registrations are disposed by
83
+ * OpenCode when the plugin unloads.
93
84
  */
94
- export declare function createSkillStatePlugin(options?: SkillStatePluginOptions): SkillStatePlugin;
85
+ export declare const SkillStatePlugin: Plugin.Plugin;
86
+ export default SkillStatePlugin;
95
87
  //# sourceMappingURL=plugin.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAmCA,OAAO,EAGL,UAAU,EAGV,sBAAsB,EAEvB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,KAAK,EACV,eAAe,EAEf,gBAAgB,EACjB,MAAM,mBAAmB,CAAC;AAE3B,cAAc,mBAAmB,CAAC;AAElC;;;;;;;;GAQG;AACH,OAAO,EAAE,sBAAsB,IAAI,sBAAsB,EAAE,CAAC;AAE5D,OAAO,EAAE,UAAU,EAAE,CAAC;AAEtB,kDAAkD;AAClD,MAAM,WAAW,uBAAuB;IACtC,0DAA0D;IAC1D,kBAAkB,CAAC,EAAE,MAAM,CAAC;CAC7B;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAEzE;AAED;;;;;;;;GAQG;AACH,wBAAgB,cAAc,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAWtF;AAED;;;;;;GAMG;AACH,wBAAgB,eAAe,CAC7B,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC7B,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAYzB;AAED;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAG7E;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,aAAa,CAC3B,KAAK,EAAE;IAAE,SAAS,CAAC,EAAE,OAAO,CAAA;CAAE,EAC9B,QAAQ,CAAC,EAAE,eAAe,EAAE,GAC3B,MAAM,CAeR;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAGrD;AAED;;;;;GAKG;AACH,wBAAgB,qBAAqB,CAAC,SAAS,EAAE,OAAO,EAAE,QAAQ,EAAE,OAAO,GAAG,IAAI,CAUjF;AAED,8DAA8D;AAC9D,wBAAgB,mBAAmB,IAAI,IAAI,CAE1C;AAgBD;;;;;;;;;;GAUG;AACH,wBAAgB,sBAAsB,CAAC,OAAO,GAAE,uBAA4B,GAAG,gBAAgB,CAwG9F"}
1
+ {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgEG;AAEH,OAAO,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAC;AAO1C,mFAAmF;AACnF,eAAO,MAAM,SAAS,eAAe,CAAC;AAEtC;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,gBAAgB,eAyD3B,CAAC;eAEY,gBAAgB"}
package/dist/plugin.js CHANGED
@@ -1,279 +1,147 @@
1
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 in `@skillstate/core`
6
- * (`resolveHostStateForCwd`, re-exported here) and the hook logic
7
- * (envelope read/write, ⊕ merge, patch extraction) in the core
8
- * hook-runtime — this module only adapts it to the OpenCode hooks.
2
+ * `@skillstate/opencode` — the OpenCode **v2** plugin.
9
3
  *
10
- * Hooks (opencode 1.17 contract, verified on host):
11
- * - `experimental.chat.messages.transform` — entries are `{ info, parts }`
12
- * envelopes (role on `info.role`); the pipeline keeps the ORIGINAL array
13
- * reference, so trimming mutates in place; the state is injected as a
14
- * synthetic `{ info, parts }` element. Real O(1) prompt footprint.
15
- * - `experimental.session.compacting` — pushes the state into
16
- * `output.context` so the compaction summary preserves it.
17
- * - `tool.execute.after` — the tool response is `output.output`; a fenced
18
- * ```json `state_patch` block is merged (paper ⊕: null deletes) and saved.
4
+ * ── What this replaces ───────────────────────────────────────────────────
19
5
  *
20
- * AGENT-SCOPED STATE: the opencode hook inputs carry the session id
21
- * (`input.sessionID`; message envelopes carry `info.sessionID`). The MAIN
22
- * session resolves to the ROOT state file
23
- * `<cwd>/.skillstate/skillstate.json` — the same file the skillstate MCP
24
- * tools and the CLI address, so the injected state and `state.patch` can
25
- * never disagree. SUB-AGENT sessions (registered from the host event bus —
26
- * `session.created`/`session.updated` carry `info.parentID`) resolve to
27
- * isolated `agents/<parentPrefix>-<sessionPrefix>/` copies and never
28
- * last-writer-win the main state; the main agent folds them back with
29
- * `agent.merge`. Writes go through the core cross-process sync lock
30
- * (`lockStateWrite`) so a state file is never interleaved between
31
- * processes.
6
+ * The v1 integration rewrote the conversation on every model request. It
7
+ * kept the system messages and the last three non-system messages, dropped
8
+ * everything else from `output.messages`, and appended a synthetic
9
+ * `role: "user"` message containing the raw state JSON. The reported
10
+ * failure was that the agent stopped doing the user's task and started
11
+ * emitting state JSON instead.
12
+ *
13
+ * Both halves of that were destructive, and neither was a model quirk:
14
+ *
15
+ * 1. The injected message landed LAST, so for the model it was the current
16
+ * instruction — it displaced the user's actual request.
17
+ * 2. `slice(-3)` deleted the task statement, the tool results and the
18
+ * errors the agent had just been handed. It was reasoning about work it
19
+ * could no longer see.
20
+ *
21
+ * The MCP server made it worse: `spec.get` returned a procedural spec whose
22
+ * default was `INTERCODE_CTF_SPEC`, whose instructions read "You are an
23
+ * autonomous CTF agent ... hidden flag somewhere on its filesystem". A
24
+ * model told to look for a flag looks for a flag. (Fixed: the default is now
25
+ * the neutral `GENERIC_PROCEDURE_SPEC`, and its instructions describe the
26
+ * storage format instead of prescribing a way of working.)
27
+ *
28
+ * ── The v2 design ────────────────────────────────────────────────────────
29
+ *
30
+ * Three rules, each enforced by a test:
31
+ *
32
+ * - **Never mutate `event.messages`.** The plugin contributes one additive
33
+ * fragment to `event.system` and leaves the transcript alone. See
34
+ * `tests/opencode/context-integrity.test.ts`.
35
+ * - **Never inject behavioural instructions.** The system fragment
36
+ * describes what the notes are and when to use them; it contains no
37
+ * "you must", no "always", and no output format. See
38
+ * `system-hint.ts`.
39
+ * - **Inert until used.** A project with no state file gets no system
40
+ * fragment at all and behaves exactly like vanilla OpenCode. No files are
41
+ * created by loading the plugin.
42
+ *
43
+ * ── Native tools AND the MCP server, on purpose ──────────────────────────
44
+ *
45
+ * This package does not replace `@skillstate/mcp`; it sits beside it.
46
+ *
47
+ * - The native tools ({@link registerTools}) are the fast path inside
48
+ * opencode: a typed schema, structured output, no JSON-RPC round-trip and
49
+ * no untyped text result.
50
+ * - The MCP server is the portable path. It is what every other
51
+ * MCP-capable host reads, and the only way to reach this state from a
52
+ * client that is not opencode.
53
+ *
54
+ * Both address the same `<project>/.skillstate/skillstate.json`, so they
55
+ * cannot disagree about what is saved. `skillstate init` registers both.
56
+ *
57
+ * The reason v1 needed the MCP server is gone: an opencode v1 plugin could
58
+ * not contribute first-class tools at all.
59
+ *
60
+ * Load it from `opencode.json(c)`:
61
+ *
62
+ * ```json
63
+ * { "plugins": ["@skillstate/opencode"] }
64
+ * ```
32
65
  */
33
- import * as fs from 'node:fs';
34
- import * as os from 'node:os';
66
+ import { Plugin } from '@opencode/plugin';
35
67
  import * as path from 'node:path';
36
- import { findFencedPatch, lockStateWrite, mergePatch, readStateEnvelope, resolveAgentIdFromSession, resolveHostStateForCwd, saveStateEnvelope, } from '@skillstate/core';
37
- export * from './plugin-types.js';
38
- /**
39
- * Resolve the per-project state file for a session working directory
40
- * (`cwd` of the current opencode session) — the core single source of
41
- * truth (`resolveHostStateForCwd`): `<cwd>/.skillstate/skillstate.json`,
42
- * or the global bucket `<home>/.skillstate/global/skillstate.json` when
43
- * cwd equals home. A non-empty `agentId` scopes the file under
44
- * `<bucket>/agents/<agentId>/skillstate.json`. Pure path arithmetic via
45
- * `path.resolve`, no filesystem access.
46
- */
47
- export { resolveHostStateForCwd as resolveStatePathForCwd };
48
- export { mergePatch };
49
- /**
50
- * Read the state file. Missing or corrupt files yield `{}` (best-effort).
51
- * The on-disk envelope is `{ version: 1, state }` (migrations-compatible);
52
- * a bare object is tolerated and treated as the state itself. Thin fs
53
- * adapter over the core hook-runtime {@link readStateEnvelope}.
54
- */
55
- export function readSkillState(statePath) {
56
- return readStateEnvelope(statePath, (p) => fs.readFileSync(p, 'utf-8'));
57
- }
58
- /**
59
- * Persist the state file (best-effort: read-only environments are ignored).
60
- * Creates the parent directory when missing (the per-project resolver may
61
- * target a fresh `<cwd>/.skillstate/agents/<id>/`). Writes the
62
- * `{ version: 1, state }` envelope so `migrate()`/runtime resume read the
63
- * same file — via the core hook-runtime {@link saveStateEnvelope} — under
64
- * the cross-process sync lock {@link lockStateWrite} (2-3 parallel agent
65
- * processes never interleave state writes).
66
- */
67
- export function saveSkillState(statePath, state) {
68
- try {
69
- fs.mkdirSync(path.dirname(statePath), { recursive: true });
70
- lockStateWrite(statePath, fs, () => saveStateEnvelope(statePath, state, (p, data) => fs.writeFileSync(p, data)));
71
- }
72
- catch {
73
- // Best-effort: read-only environments or permission issues.
74
- }
75
- }
68
+ import { SessionRegistry, stateScopeFor } from './session-registry.js';
69
+ import { ProjectStateStore } from './state-store.js';
70
+ import { buildStateHint } from './system-hint.js';
71
+ import { registerTools } from './tools.js';
72
+ /** Stable plugin id — scopes plugin storage and identifies it in `/api/plugin`. */
73
+ export const PLUGIN_ID = 'skillstate';
76
74
  /**
77
- * Atomic READ-MERGE-WRITE of one `state_patch` (paper ⊕: null deletes):
78
- * the whole critical section runs inside {@link lockStateWrite}, so two
79
- * concurrent writers apply BOTH patches instead of racing between the
80
- * read and the write. Best-effort: lock contention or unwritable state
81
- * files are swallowed — the tool flow never breaks.
82
- */
83
- export function mergeSkillState(statePath, patch) {
84
- try {
85
- fs.mkdirSync(path.dirname(statePath), { recursive: true });
86
- let merged = {};
87
- lockStateWrite(statePath, fs, () => {
88
- merged = mergePatch(readSkillState(statePath), patch);
89
- saveStateEnvelope(statePath, merged, (p, data) => fs.writeFileSync(p, data));
90
- });
91
- return merged;
92
- }
93
- catch {
94
- return readSkillState(statePath);
95
- }
96
- }
97
- /**
98
- * Extract the `state_patch` object from an LLM response's fenced ```json
99
- * block; `null` when there is no block, it is malformed, or it carries no
100
- * object-shaped `state_patch`. Thin adapter over the core hook-runtime
101
- * {@link findFencedPatch} (the invalid/truncated outcomes collapse to
102
- * `null`, preserving the legacy boolean contract).
103
- */
104
- export function extractPatch(response) {
105
- const result = findFencedPatch(response);
106
- return 'patch' in result ? result.patch : null;
107
- }
108
- /**
109
- * Agent id for an opencode hook call.
75
+ * The plugin definition.
110
76
  *
111
- * MAIN SESSION → `''` (the ROOT state file `<cwd>/.skillstate/skillstate.json`
112
- * — the SAME file the skillstate MCP tools and the CLI address, so the
113
- * injected state and `state.patch` can never disagree). A session
114
- * registered as a SUB-AGENT via the host event bus
115
- * (`session.created`/`updated` carry `info.parentID`) resolves to
116
- * `<parentPrefix>-<sessionPrefix>` — an isolated copy under `agents/` that
117
- * never last-writer-wins the main state; the main agent folds it back with
118
- * `agent.merge`. No session id at all → `''` (root: a single context).
119
- */
120
- export function pluginAgentId(input, messages) {
121
- const direct = resolveAgentIdFromSession(input?.sessionID);
122
- const sessionPrefix = direct.length > 0
123
- ? direct
124
- : resolveAgentIdFromSession((messages ?? []).find((m) => typeof m.info?.sessionID === 'string' &&
125
- m.info.sessionID.length > 0 &&
126
- m.info.sessionID !== 'skillstate')?.info.sessionID);
127
- if (sessionPrefix.length === 0)
128
- return '';
129
- return scopedAgentId(sessionPrefix);
130
- }
131
- /**
132
- * Widen an agent id for a registered sub-agent session:
133
- * `<parentPrefix>-<sessionPrefix>`. Plain sessions resolve to `''` (the
134
- * main/root scope — NOT their own agents/ copy).
135
- */
136
- export function scopedAgentId(agentId) {
137
- const parent = SUB_AGENT_PARENTS.get(agentId);
138
- return parent === undefined ? '' : `${parent}-${agentId}`;
139
- }
140
- /**
141
- * Record a session→parent edge from the host event stream. `sessionId`
142
- * with a non-empty `parentID` registers that session as a sub-agent of
143
- * `parentID`; an empty `parentID` (the main session being updated after
144
- * the fact) clears a stale registration. Exposed for tests.
145
- */
146
- export function registerSessionParent(sessionId, parentId) {
147
- if (typeof sessionId !== 'string' || sessionId.length === 0)
148
- return;
149
- const session = resolveAgentIdFromSession(sessionId);
150
- if (session.length === 0)
151
- return;
152
- const parent = resolveAgentIdFromSession(parentId);
153
- if (parent.length === 0 || parent === session) {
154
- SUB_AGENT_PARENTS.delete(session);
155
- return;
156
- }
157
- SUB_AGENT_PARENTS.set(session, parent);
158
- }
159
- /** Test-only: forget every registered session→parent edge. */
160
- export function resetSessionParents() {
161
- SUB_AGENT_PARENTS.clear();
162
- }
163
- /** Synthetic message ids for the injected state carrier. */
164
- const STATE_MESSAGE_ID = 'skillstate-state-inject';
165
- /**
166
- * The session ids known to be SUB-AGENT sessions, keyed by session id →
167
- * parent session id. Populated from the `event` hook
168
- * (`session.created`/`session.updated` carry `info.parentID`); consulted
169
- * when resolving an agent id so a sub-agent's state lands in the SAME
170
- * agents/<parent>/<session-8>/ scope as its hook-session (the task tool
171
- * spawns sessions whose ids never appear as sub-agent prefixes — without
172
- * this map a sub-agent would silently write the MAIN state).
173
- */
174
- const SUB_AGENT_PARENTS = new Map();
175
- /**
176
- * Build the OpenCode plugin function with the same behavior for every host
177
- * entry point (thin generated loaders, direct imports).
77
+ * `setup` wires three things and returns a cleanup function:
178
78
  *
179
- * State resolution is ALWAYS per-project: the state file path is computed
180
- * from the session cwd on EVERY hook call via
181
- * `resolveStatePathForCwd(process.cwd(), os.homedir(), agentId)` — each
182
- * project gets its own `<cwd>/.skillstate/`, each session (sub-agent) its
183
- * isolated `agents/<session>/` copy, and a session launched from `$HOME`
184
- * uses the global bucket.
79
+ * - a {@link SessionRegistry}, fed by the server event stream, so a
80
+ * sub-agent session is recognised and given its own state file;
81
+ * - a {@link ProjectStateStore} rooted at the plugin's own project
82
+ * location, so two checkouts served by one OpenCode server never share
83
+ * state;
84
+ * - native tools plus a single additive `context` hook.
85
+ *
86
+ * The event subscription is the only resource the plugin owns, so the
87
+ * returned cleanup aborts it. Hook and tool registrations are disposed by
88
+ * OpenCode when the plugin unloads.
185
89
  */
186
- export function createSkillStatePlugin(options = {}) {
187
- const resolvePath = (agentId) => resolveHostStateForCwd(process.cwd(), os.homedir(), agentId);
188
- const maxHistory = options.maxHistoryMessages ?? 3;
189
- return async () => {
190
- return {
191
- // ── Session registry ──────────────────────────────────────────────
192
- // The host event bus carries full Session objects on
193
- // session.created/updated — including `parentID`. Registering here
194
- // is what makes sub-agent scoping work: a Task sub-agent's session
195
- // (parentID set) resolves to agents/<parent>-<session>/ BEFORE its
196
- // first hook fires, so it never touches the parent's state file.
197
- event: async ({ event }) => {
198
- const payload = event;
199
- if (payload === null ||
200
- typeof payload !== 'object' ||
201
- payload['type'] !== 'session.created' && payload['type'] !== 'session.updated') {
202
- return;
203
- }
204
- const info = payload['properties']?.['info'];
205
- if (info === null || typeof info !== 'object')
206
- return;
207
- const record = info;
208
- registerSessionParent(record['id'], record['parentID']);
209
- },
210
- // ── O(1) history trimming ──────────────────────────────────────────
211
- // Filters messages BEFORE each LLM call: keeps all system messages
212
- // plus the last `maxHistory` non-system messages, then injects a
213
- // synthetic state element. Old messages are DROPPED from the prompt,
214
- // not just hidden.
215
- 'experimental.chat.messages.transform': async (input, output) => {
216
- const agentId = pluginAgentId(input, output.messages);
217
- const state = readSkillState(resolvePath(agentId));
218
- const messages = output.messages;
219
- const systemMessages = messages.filter((m) => m.info.role === 'system');
220
- const trimmed = messages
221
- .filter((m) => m.info.role !== 'system')
222
- .slice(-maxHistory);
223
- // Synthetic state carrier — a `{ info, parts }` envelope whose text
224
- // part carries the current state JSON.
225
- const stateMessage = {
226
- info: {
227
- id: STATE_MESSAGE_ID,
228
- sessionID: 'skillstate',
229
- role: 'user',
230
- time: { created: 0 },
231
- agent: 'skillstate',
232
- model: { providerID: 'skillstate', modelID: 'skillstate' },
233
- },
234
- parts: [
235
- {
236
- id: `${STATE_MESSAGE_ID}-text`,
237
- sessionID: 'skillstate',
238
- messageID: STATE_MESSAGE_ID,
239
- type: 'text',
240
- synthetic: true,
241
- text: `Current skill state (JSON): ${JSON.stringify(state)}`,
242
- },
243
- ],
244
- };
245
- // The pipeline holds the original array reference — mutate in place
246
- // (reassigning `output.messages` would not reach the LLM call).
247
- const kept = [...systemMessages, ...trimmed, stateMessage];
248
- messages.length = 0;
249
- messages.push(...kept);
250
- },
251
- // ── Compaction context injection ───────────────────────────────────
252
- // Before compaction, inject the current state into the context so the
253
- // compaction summary preserves state even after history is compressed.
254
- 'experimental.session.compacting': async (input, output) => {
255
- const agentId = pluginAgentId(input);
256
- const state = readSkillState(resolvePath(agentId));
257
- if (!Array.isArray(output.context)) {
258
- output.context = [];
259
- }
260
- output.context.push(`Skillstate: ${JSON.stringify(state)}`);
261
- },
262
- // ── State persistence from LLM responses ───────────────────────────
263
- // After tool execution, extract state_patch from the tool response
264
- // (output.output), and atomically merge it into the session-scoped
265
- // state (read + merge + write all inside the cross-process lock).
266
- 'tool.execute.after': async (input, output) => {
267
- const response = output.output ?? '';
268
- if (typeof response !== 'string')
269
- return;
270
- const agentId = pluginAgentId(input);
271
- const patch = extractPatch(response);
272
- if (patch) {
273
- mergeSkillState(resolvePath(agentId), patch);
90
+ export const SkillStatePlugin = Plugin.define({
91
+ id: PLUGIN_ID,
92
+ async setup(ctx) {
93
+ const sessions = new SessionRegistry();
94
+ const scopeFor = (sessionID) => stateScopeFor(sessions, sessionID);
95
+ // `ctx.location.project.canonical` is the canonical checkout, stable
96
+ // across worktrees and symlinks. The v1 plugin used `process.cwd()`,
97
+ // which in v2 is the server's cwd, not the session's project.
98
+ const store = new ProjectStateStore({
99
+ directory: ctx.location.project.canonical,
100
+ });
101
+ await ctx.tool.transform((editor) => {
102
+ registerTools(editor, { store, sessions, scopeFor });
103
+ });
104
+ // ── Session tree ────────────────────────────────────────────────────
105
+ // Sub-agent sessions are created by OpenCode itself, so the parent edge
106
+ // arrives on the event stream. Until one is seen a session is treated as
107
+ // a root session, which is the correct default for single-session use.
108
+ const controller = new AbortController();
109
+ void (async () => {
110
+ try {
111
+ for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
112
+ sessions.ingestEvent(event);
274
113
  }
275
- },
114
+ }
115
+ catch {
116
+ // The stream ends when the plugin unloads or the server goes away.
117
+ // Session scoping degrades to "everyone shares the project file",
118
+ // which is safe; it must never surface as an unhandled rejection.
119
+ }
120
+ })();
121
+ // ── System fragment ─────────────────────────────────────────────────
122
+ // Registered on the agent loop only. `compaction`, `generate` and
123
+ // `title` are separate hooks in v2 and are deliberately left alone:
124
+ // after a compaction the next agent-loop request re-adds the fragment,
125
+ // so state survives without this plugin ever touching the transcript or
126
+ // the summariser's input.
127
+ await ctx.session.hook('context', (event) => {
128
+ const scope = scopeFor(event.sessionID);
129
+ if (!store.exists(scope))
130
+ return;
131
+ const state = store.read(scope);
132
+ const hint = buildStateHint({
133
+ state,
134
+ statePath: path.relative(store.projectDirectory, store.pathFor(scope)),
135
+ scope,
136
+ });
137
+ if (hint.length === 0)
138
+ return;
139
+ event.system.push({ type: 'text', text: hint });
140
+ });
141
+ return () => {
142
+ controller.abort();
276
143
  };
277
- };
278
- }
144
+ },
145
+ });
146
+ export default SkillStatePlugin;
279
147
  //# sourceMappingURL=plugin.js.map