@skillstate/opencode 2.2.2 → 3.0.1
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 +194 -142
- package/dist/feedback.d.ts +178 -0
- package/dist/feedback.d.ts.map +1 -0
- package/dist/feedback.js +235 -0
- package/dist/feedback.js.map +1 -0
- package/dist/index.d.ts +58 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +48 -3
- package/dist/index.js.map +1 -1
- package/dist/mode.d.ts +97 -0
- package/dist/mode.d.ts.map +1 -0
- package/dist/mode.js +111 -0
- package/dist/mode.js.map +1 -0
- package/dist/opencode-adapter.d.ts +10 -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/paper-mode.d.ts +421 -0
- package/dist/paper-mode.d.ts.map +1 -0
- package/dist/paper-mode.js +445 -0
- package/dist/paper-mode.js.map +1 -0
- package/dist/plugin.d.ts +218 -76
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +867 -232
- package/dist/plugin.js.map +1 -1
- package/dist/response-sink.d.ts +208 -0
- package/dist/response-sink.d.ts.map +1 -0
- package/dist/response-sink.js +243 -0
- package/dist/response-sink.js.map +1 -0
- package/dist/runtime.d.ts +203 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +332 -0
- package/dist/runtime.js.map +1 -0
- 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/spec-loader.d.ts +81 -0
- package/dist/spec-loader.d.ts.map +1 -0
- package/dist/spec-loader.js +163 -0
- package/dist/spec-loader.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/step-boundary.d.ts +91 -0
- package/dist/step-boundary.d.ts.map +1 -0
- package/dist/step-boundary.js +109 -0
- package/dist/step-boundary.js.map +1 -0
- package/dist/system-hint.d.ts +129 -0
- package/dist/system-hint.d.ts.map +1 -0
- package/dist/system-hint.js +166 -0
- package/dist/system-hint.js.map +1 -0
- package/dist/tools.d.ts +148 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +350 -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
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sub-agent session registry for the OpenCode v2 plugin.
|
|
3
|
+
*
|
|
4
|
+
* OpenCode spawns a CHILD SESSION for every sub-agent (the `task` tool and
|
|
5
|
+
* every custom agent). Those child sessions are first-class: they run the
|
|
6
|
+
* agent loop, call tools, and would otherwise all write to the SAME project
|
|
7
|
+
* state file — the last writer wins and the main agent's notes are silently
|
|
8
|
+
* clobbered mid-task.
|
|
9
|
+
*
|
|
10
|
+
* The v1 plugin inferred this from an in-process `Map` fed by an `event`
|
|
11
|
+
* hook. v2 exposes a real event stream (`ctx.event.subscribe()`), so the
|
|
12
|
+
* registry is an explicit, testable value object instead of module-level
|
|
13
|
+
* mutable state shared by tests and a single plugin instance.
|
|
14
|
+
*
|
|
15
|
+
* PARENT EDGES come from the server's own event stream. In OpenCode v2 the
|
|
16
|
+
* relevant payloads are:
|
|
17
|
+
*
|
|
18
|
+
* ```ts
|
|
19
|
+
* { type: "session.created", data: { sessionID: string, parentID?: string } }
|
|
20
|
+
* { type: "session.forked", data: { sessionID: string, parentID: string } }
|
|
21
|
+
* { type: "session.deleted", data: { sessionID: string } }
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* There is no `session.updated` event in v2, and the parent lives on
|
|
25
|
+
* `data.parentID` — not on a `properties.info` envelope as it did in v1.
|
|
26
|
+
* A session with a non-empty `parentID` is a sub-agent; a session with an
|
|
27
|
+
* absent or empty `parentID` is a root session. The registry never guesses
|
|
28
|
+
* from the id shape — a session that has not been observed on the stream
|
|
29
|
+
* yet is treated as a ROOT session (the safe default: it gets the shared
|
|
30
|
+
* project file, which is what a plain single-session user expects).
|
|
31
|
+
*
|
|
32
|
+
* LIFECYCLE: entries are refreshed on every event and evicted after
|
|
33
|
+
* {@link DEFAULT_SESSION_TTL_MS} of silence, so a long-lived OpenCode
|
|
34
|
+
* process does not accumulate one entry per session it has ever seen.
|
|
35
|
+
*/
|
|
36
|
+
/** How long a session stays registered after its last event. */
|
|
37
|
+
export declare const DEFAULT_SESSION_TTL_MS: number;
|
|
38
|
+
/** A session as observed on the OpenCode event stream. */
|
|
39
|
+
export interface SessionRecord {
|
|
40
|
+
/** The host session id, verbatim. */
|
|
41
|
+
readonly id: string;
|
|
42
|
+
/** The parent session id, or `null` for a root session. */
|
|
43
|
+
readonly parentID: string | null;
|
|
44
|
+
/** Event receive time (epoch ms) — drives TTL eviction. */
|
|
45
|
+
readonly seenAt: number;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Options for {@link SessionRegistry}. Tests inject a clock and a TTL so
|
|
49
|
+
* eviction is deterministic.
|
|
50
|
+
*/
|
|
51
|
+
export interface SessionRegistryOptions {
|
|
52
|
+
/** Clock in epoch milliseconds. Defaults to `Date.now`. */
|
|
53
|
+
now?: () => number;
|
|
54
|
+
/** Idle time after which a session is evicted. Defaults to 6h. */
|
|
55
|
+
ttlMs?: number;
|
|
56
|
+
/** Cap on retained sessions; the oldest are evicted first. */
|
|
57
|
+
maxSessions?: number;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Tracks the session→parent forest reported by the OpenCode event stream.
|
|
61
|
+
*
|
|
62
|
+
* Every method is pure with respect to the plugin's other state: the
|
|
63
|
+
* registry only ever answers questions about the shape of the session tree.
|
|
64
|
+
*/
|
|
65
|
+
export declare class SessionRegistry {
|
|
66
|
+
private readonly sessions;
|
|
67
|
+
private readonly now;
|
|
68
|
+
private readonly ttlMs;
|
|
69
|
+
private readonly maxSessions;
|
|
70
|
+
constructor(options?: SessionRegistryOptions);
|
|
71
|
+
/**
|
|
72
|
+
* Fold one server event into the registry.
|
|
73
|
+
*
|
|
74
|
+
* `session.created` and `session.forked` register (or re-parent) a
|
|
75
|
+
* session; `session.deleted` forgets it. Any other event, and any
|
|
76
|
+
* malformed payload, is ignored — the stream carries far more than
|
|
77
|
+
* sessions and one odd record must never break the subscription loop.
|
|
78
|
+
*
|
|
79
|
+
* Returns the {@link SessionRecord} it wrote, or `null` when nothing was
|
|
80
|
+
* registered (including a deletion).
|
|
81
|
+
*/
|
|
82
|
+
ingestEvent(event: unknown): SessionRecord | null;
|
|
83
|
+
/** The recorded session, or `undefined` when it was never observed. */
|
|
84
|
+
get(sessionID: string): SessionRecord | undefined;
|
|
85
|
+
/**
|
|
86
|
+
* Whether `sessionID` is a sub-agent session (a session with a parent).
|
|
87
|
+
*
|
|
88
|
+
* An UNOBSERVED session answers `false`: the plugin then treats it as a
|
|
89
|
+
* root session, which is the behaviour a single-session user expects and
|
|
90
|
+
* never creates a surprise `agents/` directory.
|
|
91
|
+
*/
|
|
92
|
+
isSubAgent(sessionID: string): boolean;
|
|
93
|
+
/**
|
|
94
|
+
* The topmost ancestor of `sessionID` (itself when it is a root
|
|
95
|
+
* session). Follows parent edges with a visited set so a cycle — which a
|
|
96
|
+
* buggy or hostile event stream could produce — terminates instead of
|
|
97
|
+
* hanging the agent loop.
|
|
98
|
+
*/
|
|
99
|
+
rootOf(sessionID: string): string;
|
|
100
|
+
/**
|
|
101
|
+
* Every session below `sessionID` in the tree - i.e. every session whose
|
|
102
|
+
* parent chain passes through `sessionID`, excluding `sessionID` itself.
|
|
103
|
+
*
|
|
104
|
+
* "Root ancestor" would be the wrong definition here: asked about a
|
|
105
|
+
* sub-agent it would also return that sub-agent, because the sub-agent's
|
|
106
|
+
* root is further up. Walking up to the queried session instead answers
|
|
107
|
+
* both cases uniformly, and a root session still gets every sub-agent.
|
|
108
|
+
*
|
|
109
|
+
* Sorted by id for a stable, testable result.
|
|
110
|
+
*/
|
|
111
|
+
descendantsOf(sessionID: string): string[];
|
|
112
|
+
/**
|
|
113
|
+
* Whether `candidate` sits somewhere below `ancestor`. Cycle-safe: a
|
|
114
|
+
* parent chain that loops back on itself terminates instead of hanging
|
|
115
|
+
* the agent loop.
|
|
116
|
+
*/
|
|
117
|
+
private isDescendantOf;
|
|
118
|
+
/** Every registered session, oldest-observed first. */
|
|
119
|
+
list(): readonly SessionRecord[];
|
|
120
|
+
/** How many sessions are currently registered. */
|
|
121
|
+
get size(): number;
|
|
122
|
+
/** Forget every session (test isolation, and a clean reload). */
|
|
123
|
+
clear(): void;
|
|
124
|
+
/**
|
|
125
|
+
* Drop sessions idle for longer than the TTL, then enforce the size cap
|
|
126
|
+
* by dropping the least-recently-seen entries. Called after every
|
|
127
|
+
* ingest; also exposed for tests.
|
|
128
|
+
*/
|
|
129
|
+
evict(): void;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* The on-disk scope name for a session: a stable, filesystem-safe id that
|
|
133
|
+
* keeps SUB-AGENT sessions in their own directory and root sessions on the
|
|
134
|
+
* shared project file.
|
|
135
|
+
*
|
|
136
|
+
* Returns `''` for a root session (the shared project state) and a
|
|
137
|
+
* sanitized `<parent>-<session>` id for a sub-agent.
|
|
138
|
+
*
|
|
139
|
+
* The child part is the FULL sanitized session id, not a short prefix. The
|
|
140
|
+
* v1 code truncated to 8 characters, which silently collapses two sibling
|
|
141
|
+
* sub-agents into one state file whenever their ids share a prefix — they
|
|
142
|
+
* overwrite each other's notes with no error. `sanitizeAgentId` caps the
|
|
143
|
+
* result at 64 characters, so the directory name stays bounded. The parent
|
|
144
|
+
* part is truncated to 8 characters because it only has to disambiguate
|
|
145
|
+
* which root a sub-agent belongs to.
|
|
146
|
+
*/
|
|
147
|
+
export declare function stateScopeFor(registry: SessionRegistry, sessionID: string): string;
|
|
148
|
+
//# sourceMappingURL=session-registry.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session-registry.d.ts","sourceRoot":"","sources":["../src/session-registry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AAIH,gEAAgE;AAChE,eAAO,MAAM,sBAAsB,QAAqB,CAAC;AAEzD,0DAA0D;AAC1D,MAAM,WAAW,aAAa;IAC5B,qCAAqC;IACrC,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,2DAA2D;IAC3D,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,2DAA2D;IAC3D,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED;;;GAGG;AACH,MAAM,WAAW,sBAAsB;IACrC,2DAA2D;IAC3D,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IACnB,kEAAkE;IAClE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,8DAA8D;IAC9D,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAkCD;;;;;GAKG;AACH,qBAAa,eAAe;IAC1B,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAoC;IAC7D,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAe;IACnC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAS;IAC/B,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAS;IAErC,YAAY,OAAO,GAAE,sBAA2B,EAI/C;IAED;;;;;;;;;;OAUG;IACH,WAAW,CAAC,KAAK,EAAE,OAAO,GAAG,aAAa,GAAG,IAAI,CAehD;IAED,uEAAuE;IACvE,GAAG,CAAC,SAAS,EAAE,MAAM,GAAG,aAAa,GAAG,SAAS,CAEhD;IAED;;;;;;OAMG;IACH,UAAU,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAErC;IAED;;;;;OAKG;IACH,MAAM,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAUhC;IAED;;;;;;;;;;OAUG;IACH,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,EAAE,CAQzC;IAED;;;;OAIG;IACH,OAAO,CAAC,cAAc;IActB,uDAAuD;IACvD,IAAI,IAAI,SAAS,aAAa,EAAE,CAE/B;IAED,kDAAkD;IAClD,IAAI,IAAI,IAAI,MAAM,CAEjB;IAED,iEAAiE;IACjE,KAAK,IAAI,IAAI,CAEZ;IAED;;;;OAIG;IACH,KAAK,IAAI,IAAI,CAYZ;CACF;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,aAAa,CAC3B,QAAQ,EAAE,eAAe,EACzB,SAAS,EAAE,MAAM,GAChB,MAAM,CAQR"}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sub-agent session registry for the OpenCode v2 plugin.
|
|
3
|
+
*
|
|
4
|
+
* OpenCode spawns a CHILD SESSION for every sub-agent (the `task` tool and
|
|
5
|
+
* every custom agent). Those child sessions are first-class: they run the
|
|
6
|
+
* agent loop, call tools, and would otherwise all write to the SAME project
|
|
7
|
+
* state file — the last writer wins and the main agent's notes are silently
|
|
8
|
+
* clobbered mid-task.
|
|
9
|
+
*
|
|
10
|
+
* The v1 plugin inferred this from an in-process `Map` fed by an `event`
|
|
11
|
+
* hook. v2 exposes a real event stream (`ctx.event.subscribe()`), so the
|
|
12
|
+
* registry is an explicit, testable value object instead of module-level
|
|
13
|
+
* mutable state shared by tests and a single plugin instance.
|
|
14
|
+
*
|
|
15
|
+
* PARENT EDGES come from the server's own event stream. In OpenCode v2 the
|
|
16
|
+
* relevant payloads are:
|
|
17
|
+
*
|
|
18
|
+
* ```ts
|
|
19
|
+
* { type: "session.created", data: { sessionID: string, parentID?: string } }
|
|
20
|
+
* { type: "session.forked", data: { sessionID: string, parentID: string } }
|
|
21
|
+
* { type: "session.deleted", data: { sessionID: string } }
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* There is no `session.updated` event in v2, and the parent lives on
|
|
25
|
+
* `data.parentID` — not on a `properties.info` envelope as it did in v1.
|
|
26
|
+
* A session with a non-empty `parentID` is a sub-agent; a session with an
|
|
27
|
+
* absent or empty `parentID` is a root session. The registry never guesses
|
|
28
|
+
* from the id shape — a session that has not been observed on the stream
|
|
29
|
+
* yet is treated as a ROOT session (the safe default: it gets the shared
|
|
30
|
+
* project file, which is what a plain single-session user expects).
|
|
31
|
+
*
|
|
32
|
+
* LIFECYCLE: entries are refreshed on every event and evicted after
|
|
33
|
+
* {@link DEFAULT_SESSION_TTL_MS} of silence, so a long-lived OpenCode
|
|
34
|
+
* process does not accumulate one entry per session it has ever seen.
|
|
35
|
+
*/
|
|
36
|
+
import { sanitizeAgentId } from '@skillstate/core';
|
|
37
|
+
/** How long a session stays registered after its last event. */
|
|
38
|
+
export const DEFAULT_SESSION_TTL_MS = 6 * 60 * 60 * 1000;
|
|
39
|
+
const DEFAULT_MAX_SESSIONS = 512;
|
|
40
|
+
function readSessionEvent(event) {
|
|
41
|
+
if (typeof event !== 'object' || event === null)
|
|
42
|
+
return null;
|
|
43
|
+
const type = event.type;
|
|
44
|
+
if (typeof type !== 'string')
|
|
45
|
+
return null;
|
|
46
|
+
if (type !== 'session.created' && type !== 'session.forked' && type !== 'session.deleted') {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
const data = event.data;
|
|
50
|
+
if (typeof data !== 'object' || data === null)
|
|
51
|
+
return null;
|
|
52
|
+
const record = data;
|
|
53
|
+
if (typeof record.sessionID !== 'string' || record.sessionID.length === 0)
|
|
54
|
+
return null;
|
|
55
|
+
if (type === 'session.deleted')
|
|
56
|
+
return { kind: 'delete', id: record.sessionID };
|
|
57
|
+
const parent = typeof record.parentID === 'string' && record.parentID.length > 0 ? record.parentID : null;
|
|
58
|
+
return { kind: 'upsert', id: record.sessionID, parentID: parent };
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Tracks the session→parent forest reported by the OpenCode event stream.
|
|
62
|
+
*
|
|
63
|
+
* Every method is pure with respect to the plugin's other state: the
|
|
64
|
+
* registry only ever answers questions about the shape of the session tree.
|
|
65
|
+
*/
|
|
66
|
+
export class SessionRegistry {
|
|
67
|
+
sessions = new Map();
|
|
68
|
+
now;
|
|
69
|
+
ttlMs;
|
|
70
|
+
maxSessions;
|
|
71
|
+
constructor(options = {}) {
|
|
72
|
+
this.now = options.now ?? Date.now;
|
|
73
|
+
this.ttlMs = options.ttlMs ?? DEFAULT_SESSION_TTL_MS;
|
|
74
|
+
this.maxSessions = options.maxSessions ?? DEFAULT_MAX_SESSIONS;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Fold one server event into the registry.
|
|
78
|
+
*
|
|
79
|
+
* `session.created` and `session.forked` register (or re-parent) a
|
|
80
|
+
* session; `session.deleted` forgets it. Any other event, and any
|
|
81
|
+
* malformed payload, is ignored — the stream carries far more than
|
|
82
|
+
* sessions and one odd record must never break the subscription loop.
|
|
83
|
+
*
|
|
84
|
+
* Returns the {@link SessionRecord} it wrote, or `null` when nothing was
|
|
85
|
+
* registered (including a deletion).
|
|
86
|
+
*/
|
|
87
|
+
ingestEvent(event) {
|
|
88
|
+
const parsed = readSessionEvent(event);
|
|
89
|
+
if (parsed === null)
|
|
90
|
+
return null;
|
|
91
|
+
if (parsed.kind === 'delete') {
|
|
92
|
+
this.sessions.delete(parsed.id);
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
const record = {
|
|
96
|
+
id: parsed.id,
|
|
97
|
+
parentID: parsed.parentID,
|
|
98
|
+
seenAt: this.now(),
|
|
99
|
+
};
|
|
100
|
+
this.sessions.set(record.id, record);
|
|
101
|
+
this.evict();
|
|
102
|
+
return record;
|
|
103
|
+
}
|
|
104
|
+
/** The recorded session, or `undefined` when it was never observed. */
|
|
105
|
+
get(sessionID) {
|
|
106
|
+
return this.sessions.get(sessionID);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Whether `sessionID` is a sub-agent session (a session with a parent).
|
|
110
|
+
*
|
|
111
|
+
* An UNOBSERVED session answers `false`: the plugin then treats it as a
|
|
112
|
+
* root session, which is the behaviour a single-session user expects and
|
|
113
|
+
* never creates a surprise `agents/` directory.
|
|
114
|
+
*/
|
|
115
|
+
isSubAgent(sessionID) {
|
|
116
|
+
return this.sessions.get(sessionID)?.parentID != null;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* The topmost ancestor of `sessionID` (itself when it is a root
|
|
120
|
+
* session). Follows parent edges with a visited set so a cycle — which a
|
|
121
|
+
* buggy or hostile event stream could produce — terminates instead of
|
|
122
|
+
* hanging the agent loop.
|
|
123
|
+
*/
|
|
124
|
+
rootOf(sessionID) {
|
|
125
|
+
let current = sessionID;
|
|
126
|
+
const visited = new Set([sessionID]);
|
|
127
|
+
for (;;) {
|
|
128
|
+
const record = this.sessions.get(current);
|
|
129
|
+
const parent = record?.parentID;
|
|
130
|
+
if (parent === undefined || parent === null || visited.has(parent))
|
|
131
|
+
return current;
|
|
132
|
+
visited.add(parent);
|
|
133
|
+
current = parent;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Every session below `sessionID` in the tree - i.e. every session whose
|
|
138
|
+
* parent chain passes through `sessionID`, excluding `sessionID` itself.
|
|
139
|
+
*
|
|
140
|
+
* "Root ancestor" would be the wrong definition here: asked about a
|
|
141
|
+
* sub-agent it would also return that sub-agent, because the sub-agent's
|
|
142
|
+
* root is further up. Walking up to the queried session instead answers
|
|
143
|
+
* both cases uniformly, and a root session still gets every sub-agent.
|
|
144
|
+
*
|
|
145
|
+
* Sorted by id for a stable, testable result.
|
|
146
|
+
*/
|
|
147
|
+
descendantsOf(sessionID) {
|
|
148
|
+
const result = [];
|
|
149
|
+
for (const record of this.sessions.values()) {
|
|
150
|
+
if (record.id !== sessionID && this.isDescendantOf(record.id, sessionID)) {
|
|
151
|
+
result.push(record.id);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return result.sort();
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Whether `candidate` sits somewhere below `ancestor`. Cycle-safe: a
|
|
158
|
+
* parent chain that loops back on itself terminates instead of hanging
|
|
159
|
+
* the agent loop.
|
|
160
|
+
*/
|
|
161
|
+
isDescendantOf(candidate, ancestor) {
|
|
162
|
+
let current = candidate;
|
|
163
|
+
const visited = new Set([candidate]);
|
|
164
|
+
for (;;) {
|
|
165
|
+
const record = this.sessions.get(current);
|
|
166
|
+
const parent = record?.parentID ?? null;
|
|
167
|
+
if (parent === null)
|
|
168
|
+
return false;
|
|
169
|
+
if (parent === ancestor)
|
|
170
|
+
return true;
|
|
171
|
+
if (visited.has(parent))
|
|
172
|
+
return false;
|
|
173
|
+
visited.add(parent);
|
|
174
|
+
current = parent;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/** Every registered session, oldest-observed first. */
|
|
178
|
+
list() {
|
|
179
|
+
return [...this.sessions.values()].sort((a, b) => a.seenAt - b.seenAt || a.id.localeCompare(b.id));
|
|
180
|
+
}
|
|
181
|
+
/** How many sessions are currently registered. */
|
|
182
|
+
get size() {
|
|
183
|
+
return this.sessions.size;
|
|
184
|
+
}
|
|
185
|
+
/** Forget every session (test isolation, and a clean reload). */
|
|
186
|
+
clear() {
|
|
187
|
+
this.sessions.clear();
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Drop sessions idle for longer than the TTL, then enforce the size cap
|
|
191
|
+
* by dropping the least-recently-seen entries. Called after every
|
|
192
|
+
* ingest; also exposed for tests.
|
|
193
|
+
*/
|
|
194
|
+
evict() {
|
|
195
|
+
const cutoff = this.now() - this.ttlMs;
|
|
196
|
+
for (const [id, record] of this.sessions) {
|
|
197
|
+
if (record.seenAt < cutoff)
|
|
198
|
+
this.sessions.delete(id);
|
|
199
|
+
}
|
|
200
|
+
if (this.sessions.size <= this.maxSessions)
|
|
201
|
+
return;
|
|
202
|
+
const ordered = [...this.sessions.entries()].sort((a, b) => a[1].seenAt - b[1].seenAt || a[0].localeCompare(b[0]));
|
|
203
|
+
for (const [id] of ordered.slice(0, this.sessions.size - this.maxSessions)) {
|
|
204
|
+
this.sessions.delete(id);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* The on-disk scope name for a session: a stable, filesystem-safe id that
|
|
210
|
+
* keeps SUB-AGENT sessions in their own directory and root sessions on the
|
|
211
|
+
* shared project file.
|
|
212
|
+
*
|
|
213
|
+
* Returns `''` for a root session (the shared project state) and a
|
|
214
|
+
* sanitized `<parent>-<session>` id for a sub-agent.
|
|
215
|
+
*
|
|
216
|
+
* The child part is the FULL sanitized session id, not a short prefix. The
|
|
217
|
+
* v1 code truncated to 8 characters, which silently collapses two sibling
|
|
218
|
+
* sub-agents into one state file whenever their ids share a prefix — they
|
|
219
|
+
* overwrite each other's notes with no error. `sanitizeAgentId` caps the
|
|
220
|
+
* result at 64 characters, so the directory name stays bounded. The parent
|
|
221
|
+
* part is truncated to 8 characters because it only has to disambiguate
|
|
222
|
+
* which root a sub-agent belongs to.
|
|
223
|
+
*/
|
|
224
|
+
export function stateScopeFor(registry, sessionID) {
|
|
225
|
+
const record = registry.get(sessionID);
|
|
226
|
+
if (record === undefined || record.parentID === null)
|
|
227
|
+
return '';
|
|
228
|
+
const child = sanitizeAgentId(sessionID);
|
|
229
|
+
const parentPrefix = sanitizeAgentId(record.parentID).slice(0, 8);
|
|
230
|
+
if (child.length === 0)
|
|
231
|
+
return parentPrefix;
|
|
232
|
+
if (parentPrefix.length === 0)
|
|
233
|
+
return child;
|
|
234
|
+
return `${parentPrefix}-${child}`;
|
|
235
|
+
}
|
|
236
|
+
//# sourceMappingURL=session-registry.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session-registry.js","sourceRoot":"","sources":["../src/session-registry.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolving P — the procedural specification the paper's prompt is built on.
|
|
3
|
+
*
|
|
4
|
+
* ── Why the plugin needs a spec at all ────────────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* A.4 is `Format(P, Σₜ, Oₜ)`, and P is the first argument. Paper mode
|
|
7
|
+
* therefore cannot assemble a paper-conformant prompt without one, even
|
|
8
|
+
* though the store ({@link ProjectStateStore}) is deliberately schema-free:
|
|
9
|
+
* notes mode never shows the model a schema, so it never needs P.
|
|
10
|
+
*
|
|
11
|
+
* ── Resolution order ─────────────────────────────────────────────────────
|
|
12
|
+
*
|
|
13
|
+
* 1. `<project>/skill-spec.json` — the file `skillstate init` writes and the
|
|
14
|
+
* one the CLI's `--spec` already points at (`config.ts` default
|
|
15
|
+
* `specPath: './skill-spec.json'`). A project that has customised its
|
|
16
|
+
* procedure gets that procedure.
|
|
17
|
+
* 2. {@link GENERIC_PROCEDURE_SPEC} — the built-in, domain-neutral default.
|
|
18
|
+
*
|
|
19
|
+
* ── A malformed spec file must not reach the model ────────────────────────
|
|
20
|
+
*
|
|
21
|
+
* A half-written or hand-mangled `skill-spec.json` is exactly the kind of
|
|
22
|
+
* input that produced the v1 failure, where the spec's own instructions
|
|
23
|
+
* ("You are an autonomous CTF agent … find the flag") overrode the user. So
|
|
24
|
+
* the file is VALIDATED before it is trusted, field by field, and anything
|
|
25
|
+
* that does not typecheck falls back to the built-in spec with the reason
|
|
26
|
+
* recorded. There is no code path that feeds an unvalidated P to the model.
|
|
27
|
+
*
|
|
28
|
+
* Reads are cached per directory for the lifetime of the resolver: the
|
|
29
|
+
* `context` hook runs on every model request, and re-reading and re-validating
|
|
30
|
+
* a file that cannot change without the user editing it is pure overhead on
|
|
31
|
+
* the agent loop's hot path. {@link SpecResolver.invalidate} drops the cache
|
|
32
|
+
* for callers that do edit it.
|
|
33
|
+
*/
|
|
34
|
+
import type { ProceduralSpec } from '@skillstate/core';
|
|
35
|
+
/** The file a project keeps its procedural spec in. */
|
|
36
|
+
export declare const SPEC_FILE_NAME = "skill-spec.json";
|
|
37
|
+
/** Where the resolved spec came from. */
|
|
38
|
+
export type SpecSource = 'builtin' | 'file';
|
|
39
|
+
/** The outcome of {@link SpecResolver.resolve}. */
|
|
40
|
+
export interface SpecResolution {
|
|
41
|
+
/** The spec to build the prompt from. Always valid. */
|
|
42
|
+
readonly spec: ProceduralSpec;
|
|
43
|
+
/** `builtin` when the file was absent or rejected. */
|
|
44
|
+
readonly source: SpecSource;
|
|
45
|
+
/** Absolute path of the file that was read, when one existed. */
|
|
46
|
+
readonly path?: string;
|
|
47
|
+
/**
|
|
48
|
+
* Why a file was rejected, verbatim. Present only alongside
|
|
49
|
+
* `source: 'builtin'` when a file existed and did not validate.
|
|
50
|
+
*/
|
|
51
|
+
readonly rejected?: string;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Validate a parsed spec document. Returns the spec, or a sentence naming
|
|
55
|
+
* the first field that failed. Pure.
|
|
56
|
+
*
|
|
57
|
+
* The checks are deliberately structural only — they reject a document that
|
|
58
|
+
* could not be rendered or that would misdescribe Σₜ, and nothing subtler.
|
|
59
|
+
* Semantic review of an instructions string is not something a type check can
|
|
60
|
+
* do, and pretending otherwise would be worse than not checking.
|
|
61
|
+
*/
|
|
62
|
+
export declare function parseSpec(value: unknown): ProceduralSpec | string;
|
|
63
|
+
/**
|
|
64
|
+
* Per-project spec resolution with a cache.
|
|
65
|
+
*
|
|
66
|
+
* One instance per plugin setup. The cache key is the resolved project
|
|
67
|
+
* directory, so a single OpenCode server serving several checkouts gets a
|
|
68
|
+
* different spec per checkout without the caller managing that.
|
|
69
|
+
*/
|
|
70
|
+
export declare class SpecResolver {
|
|
71
|
+
private readonly cache;
|
|
72
|
+
/**
|
|
73
|
+
* The spec for `directory`, reading and validating the file at most once
|
|
74
|
+
* per {@link invalidate}.
|
|
75
|
+
*/
|
|
76
|
+
resolve(directory: string): SpecResolution;
|
|
77
|
+
/** Drop the cached spec for `directory` (or for every directory). */
|
|
78
|
+
invalidate(directory?: string): void;
|
|
79
|
+
private load;
|
|
80
|
+
}
|
|
81
|
+
//# sourceMappingURL=spec-loader.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"spec-loader.d.ts","sourceRoot":"","sources":["../src/spec-loader.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAKH,OAAO,KAAK,EAAE,cAAc,EAA4B,MAAM,kBAAkB,CAAC;AAEjF,uDAAuD;AACvD,eAAO,MAAM,cAAc,oBAAoB,CAAC;AAEhD,yCAAyC;AACzC,MAAM,MAAM,UAAU,GAAG,SAAS,GAAG,MAAM,CAAC;AAE5C,mDAAmD;AACnD,MAAM,WAAW,cAAc;IAC7B,uDAAuD;IACvD,QAAQ,CAAC,IAAI,EAAE,cAAc,CAAC;IAC9B,sDAAsD;IACtD,QAAQ,CAAC,MAAM,EAAE,UAAU,CAAC;IAC5B,iEAAiE;IACjE,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB;;;OAGG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;CAC5B;AA+BD;;;;;;;;GAQG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,OAAO,GAAG,cAAc,GAAG,MAAM,CAiBjE;AAED;;;;;;GAMG;AACH,qBAAa,YAAY;IACvB,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAqC;IAE3D;;;OAGG;IACH,OAAO,CAAC,SAAS,EAAE,MAAM,GAAG,cAAc,CAOzC;IAED,qEAAqE;IACrE,UAAU,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAGnC;IAED,OAAO,CAAC,IAAI;CA8Bb"}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolving P — the procedural specification the paper's prompt is built on.
|
|
3
|
+
*
|
|
4
|
+
* ── Why the plugin needs a spec at all ────────────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* A.4 is `Format(P, Σₜ, Oₜ)`, and P is the first argument. Paper mode
|
|
7
|
+
* therefore cannot assemble a paper-conformant prompt without one, even
|
|
8
|
+
* though the store ({@link ProjectStateStore}) is deliberately schema-free:
|
|
9
|
+
* notes mode never shows the model a schema, so it never needs P.
|
|
10
|
+
*
|
|
11
|
+
* ── Resolution order ─────────────────────────────────────────────────────
|
|
12
|
+
*
|
|
13
|
+
* 1. `<project>/skill-spec.json` — the file `skillstate init` writes and the
|
|
14
|
+
* one the CLI's `--spec` already points at (`config.ts` default
|
|
15
|
+
* `specPath: './skill-spec.json'`). A project that has customised its
|
|
16
|
+
* procedure gets that procedure.
|
|
17
|
+
* 2. {@link GENERIC_PROCEDURE_SPEC} — the built-in, domain-neutral default.
|
|
18
|
+
*
|
|
19
|
+
* ── A malformed spec file must not reach the model ────────────────────────
|
|
20
|
+
*
|
|
21
|
+
* A half-written or hand-mangled `skill-spec.json` is exactly the kind of
|
|
22
|
+
* input that produced the v1 failure, where the spec's own instructions
|
|
23
|
+
* ("You are an autonomous CTF agent … find the flag") overrode the user. So
|
|
24
|
+
* the file is VALIDATED before it is trusted, field by field, and anything
|
|
25
|
+
* that does not typecheck falls back to the built-in spec with the reason
|
|
26
|
+
* recorded. There is no code path that feeds an unvalidated P to the model.
|
|
27
|
+
*
|
|
28
|
+
* Reads are cached per directory for the lifetime of the resolver: the
|
|
29
|
+
* `context` hook runs on every model request, and re-reading and re-validating
|
|
30
|
+
* a file that cannot change without the user editing it is pure overhead on
|
|
31
|
+
* the agent loop's hot path. {@link SpecResolver.invalidate} drops the cache
|
|
32
|
+
* for callers that do edit it.
|
|
33
|
+
*/
|
|
34
|
+
import * as fs from 'node:fs';
|
|
35
|
+
import * as path from 'node:path';
|
|
36
|
+
import { GENERIC_PROCEDURE_SPEC, isPlainObject } from '@skillstate/core';
|
|
37
|
+
/** The file a project keeps its procedural spec in. */
|
|
38
|
+
export const SPEC_FILE_NAME = 'skill-spec.json';
|
|
39
|
+
const SCHEMA_TYPES = new Set([
|
|
40
|
+
'string',
|
|
41
|
+
'number',
|
|
42
|
+
'boolean',
|
|
43
|
+
'array',
|
|
44
|
+
'object',
|
|
45
|
+
]);
|
|
46
|
+
function validateSchema(value, at) {
|
|
47
|
+
if (!isPlainObject(value))
|
|
48
|
+
return `${at} is not an object`;
|
|
49
|
+
const schema = {};
|
|
50
|
+
for (const [key, raw] of Object.entries(value)) {
|
|
51
|
+
if (!isPlainObject(raw))
|
|
52
|
+
return `${at}.${key} is not an object`;
|
|
53
|
+
const type = raw['type'];
|
|
54
|
+
if (typeof type !== 'string' || !SCHEMA_TYPES.has(type)) {
|
|
55
|
+
return `${at}.${key}.type must be one of ${[...SCHEMA_TYPES].join(', ')}`;
|
|
56
|
+
}
|
|
57
|
+
if (!('default' in raw))
|
|
58
|
+
return `${at}.${key}.default is required`;
|
|
59
|
+
const description = raw['description'];
|
|
60
|
+
if (description !== undefined && typeof description !== 'string') {
|
|
61
|
+
return `${at}.${key}.description must be a string`;
|
|
62
|
+
}
|
|
63
|
+
const field = { type: type, default: raw['default'] };
|
|
64
|
+
if (typeof description === 'string')
|
|
65
|
+
field.description = description;
|
|
66
|
+
schema[key] = field;
|
|
67
|
+
}
|
|
68
|
+
return schema;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Validate a parsed spec document. Returns the spec, or a sentence naming
|
|
72
|
+
* the first field that failed. Pure.
|
|
73
|
+
*
|
|
74
|
+
* The checks are deliberately structural only — they reject a document that
|
|
75
|
+
* could not be rendered or that would misdescribe Σₜ, and nothing subtler.
|
|
76
|
+
* Semantic review of an instructions string is not something a type check can
|
|
77
|
+
* do, and pretending otherwise would be worse than not checking.
|
|
78
|
+
*/
|
|
79
|
+
export function parseSpec(value) {
|
|
80
|
+
if (!isPlainObject(value))
|
|
81
|
+
return 'spec is not an object';
|
|
82
|
+
const id = value['id'];
|
|
83
|
+
if (typeof id !== 'string' || id.trim() === '')
|
|
84
|
+
return 'id must be a non-empty string';
|
|
85
|
+
const name = value['name'];
|
|
86
|
+
if (typeof name !== 'string' || name.trim() === '')
|
|
87
|
+
return 'name must be a non-empty string';
|
|
88
|
+
const version = value['version'];
|
|
89
|
+
if (typeof version !== 'string' || version.trim() === '') {
|
|
90
|
+
return 'version must be a non-empty string';
|
|
91
|
+
}
|
|
92
|
+
const instructions = value['instructions'];
|
|
93
|
+
if (typeof instructions !== 'string' || instructions.trim() === '') {
|
|
94
|
+
return 'instructions must be a non-empty string';
|
|
95
|
+
}
|
|
96
|
+
const schema = validateSchema(value['schema'], 'schema');
|
|
97
|
+
if (typeof schema === 'string')
|
|
98
|
+
return schema;
|
|
99
|
+
return { id, name, version, instructions, schema };
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Per-project spec resolution with a cache.
|
|
103
|
+
*
|
|
104
|
+
* One instance per plugin setup. The cache key is the resolved project
|
|
105
|
+
* directory, so a single OpenCode server serving several checkouts gets a
|
|
106
|
+
* different spec per checkout without the caller managing that.
|
|
107
|
+
*/
|
|
108
|
+
export class SpecResolver {
|
|
109
|
+
cache = new Map();
|
|
110
|
+
/**
|
|
111
|
+
* The spec for `directory`, reading and validating the file at most once
|
|
112
|
+
* per {@link invalidate}.
|
|
113
|
+
*/
|
|
114
|
+
resolve(directory) {
|
|
115
|
+
const key = path.resolve(directory);
|
|
116
|
+
const cached = this.cache.get(key);
|
|
117
|
+
if (cached !== undefined)
|
|
118
|
+
return cached;
|
|
119
|
+
const resolution = this.load(key);
|
|
120
|
+
this.cache.set(key, resolution);
|
|
121
|
+
return resolution;
|
|
122
|
+
}
|
|
123
|
+
/** Drop the cached spec for `directory` (or for every directory). */
|
|
124
|
+
invalidate(directory) {
|
|
125
|
+
if (directory === undefined)
|
|
126
|
+
this.cache.clear();
|
|
127
|
+
else
|
|
128
|
+
this.cache.delete(path.resolve(directory));
|
|
129
|
+
}
|
|
130
|
+
load(directory) {
|
|
131
|
+
const file = path.join(directory, SPEC_FILE_NAME);
|
|
132
|
+
let raw;
|
|
133
|
+
try {
|
|
134
|
+
raw = fs.readFileSync(file, 'utf-8');
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return { spec: GENERIC_PROCEDURE_SPEC, source: 'builtin' };
|
|
138
|
+
}
|
|
139
|
+
let parsed;
|
|
140
|
+
try {
|
|
141
|
+
parsed = JSON.parse(raw);
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
return {
|
|
145
|
+
spec: GENERIC_PROCEDURE_SPEC,
|
|
146
|
+
source: 'builtin',
|
|
147
|
+
path: file,
|
|
148
|
+
rejected: `invalid JSON: ${String(error)}`,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
const validated = parseSpec(parsed);
|
|
152
|
+
if (typeof validated === 'string') {
|
|
153
|
+
return {
|
|
154
|
+
spec: GENERIC_PROCEDURE_SPEC,
|
|
155
|
+
source: 'builtin',
|
|
156
|
+
path: file,
|
|
157
|
+
rejected: validated,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
return { spec: validated, source: 'file', path: file };
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
//# sourceMappingURL=spec-loader.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"spec-loader.js","sourceRoot":"","sources":["../src/spec-loader.ts"],"names":[],"mappings":"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"}
|