@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/README.md +76 -89
- package/dist/index.d.ts +28 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +24 -3
- package/dist/index.js.map +1 -1
- package/dist/opencode-adapter.d.ts +9 -46
- package/dist/opencode-adapter.d.ts.map +1 -1
- package/dist/opencode-adapter.js +1 -64
- package/dist/opencode-adapter.js.map +1 -1
- package/dist/plugin.d.ts +82 -90
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +135 -267
- package/dist/plugin.js.map +1 -1
- package/dist/session-registry.d.ts +148 -0
- package/dist/session-registry.d.ts.map +1 -0
- package/dist/session-registry.js +236 -0
- package/dist/session-registry.js.map +1 -0
- package/dist/state-store.d.ts +126 -0
- package/dist/state-store.d.ts.map +1 -0
- package/dist/state-store.js +186 -0
- package/dist/state-store.js.map +1 -0
- package/dist/system-hint.d.ts +62 -0
- package/dist/system-hint.d.ts.map +1 -0
- package/dist/system-hint.js +87 -0
- package/dist/system-hint.js.map +1 -0
- package/dist/tools.d.ts +114 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +334 -0
- package/dist/tools.js.map +1 -0
- package/package.json +3 -2
- package/dist/plugin-types.d.ts +0 -71
- package/dist/plugin-types.d.ts.map +0 -1
- package/dist/plugin-types.js +0 -8
- package/dist/plugin-types.js.map +0 -1
package/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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* `
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
*
|
|
39
|
-
* the
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
81
|
-
/**
|
|
82
|
-
export declare
|
|
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
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
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
|
|
85
|
+
export declare const SkillStatePlugin: Plugin.Plugin;
|
|
86
|
+
export default SkillStatePlugin;
|
|
95
87
|
//# sourceMappingURL=plugin.d.ts.map
|
package/dist/plugin.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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
|
|
34
|
-
import * as os from 'node:os';
|
|
66
|
+
import { Plugin } from '@opencode/plugin';
|
|
35
67
|
import * as path from 'node:path';
|
|
36
|
-
import {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
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
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
const
|
|
208
|
-
|
|
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
|