opencode-stm 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +128 -2
- package/config.example.yaml +20 -0
- package/dist/src/config.d.ts +35 -0
- package/dist/src/config.js +295 -0
- package/dist/src/config.js.map +1 -0
- package/dist/src/index.d.ts +14 -0
- package/dist/src/index.js +211 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/injection.d.ts +63 -0
- package/dist/src/injection.js +103 -0
- package/dist/src/injection.js.map +1 -0
- package/dist/src/scheduler.d.ts +25 -0
- package/dist/src/scheduler.js +182 -0
- package/dist/src/scheduler.js.map +1 -0
- package/dist/src/sdk.d.ts +50 -0
- package/dist/src/sdk.js +101 -0
- package/dist/src/sdk.js.map +1 -0
- package/dist/src/state.d.ts +70 -0
- package/dist/src/state.js +278 -0
- package/dist/src/state.js.map +1 -0
- package/dist/src/tools.d.ts +33 -0
- package/dist/src/tools.js +596 -0
- package/dist/src/tools.js.map +1 -0
- package/dist/src/transcript.d.ts +87 -0
- package/dist/src/transcript.js +376 -0
- package/dist/src/transcript.js.map +1 -0
- package/package.json +39 -4
|
@@ -0,0 +1,596 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { tool } from "@opencode-ai/plugin";
|
|
3
|
+
import { initialMeta, hashTitle } from "./state.js";
|
|
4
|
+
import { page, readSession, writeTitle } from "./sdk.js";
|
|
5
|
+
import { extractVisibleText } from "./transcript.js";
|
|
6
|
+
import { firstObservation, isDefaultTitle, lockExternal, lockManually, resolveReview, unlock } from "./scheduler.js";
|
|
7
|
+
export const TITLE_MAX_CODE_POINTS = 120;
|
|
8
|
+
const TITLE_DISPLAY_MAX = 200;
|
|
9
|
+
const CONTROL_CHARACTERS = /[\p{Cc}\u2028\u2029]/u;
|
|
10
|
+
export function validateTitle(raw) {
|
|
11
|
+
const value = raw.trim();
|
|
12
|
+
if (value.length === 0)
|
|
13
|
+
return { ok: false, reason: "title-empty" };
|
|
14
|
+
if ([...value].length > TITLE_MAX_CODE_POINTS)
|
|
15
|
+
return { ok: false, reason: "title-too-long" };
|
|
16
|
+
if (CONTROL_CHARACTERS.test(value))
|
|
17
|
+
return { ok: false, reason: "title-control-characters" };
|
|
18
|
+
return { ok: true, value };
|
|
19
|
+
}
|
|
20
|
+
function status(value) {
|
|
21
|
+
return JSON.stringify({ plugin: "opencode-stm", ...value });
|
|
22
|
+
}
|
|
23
|
+
function titleForStatus(title) {
|
|
24
|
+
const points = [...title];
|
|
25
|
+
if (points.length <= TITLE_DISPLAY_MAX)
|
|
26
|
+
return { value: title, truncated: false };
|
|
27
|
+
return { value: `${points.slice(0, TITLE_DISPLAY_MAX).join("")}…`, truncated: true };
|
|
28
|
+
}
|
|
29
|
+
async function requireRootCaller(services, context) {
|
|
30
|
+
const root = await services.roots.isRoot(context.sessionID, context.abort);
|
|
31
|
+
if (!root.ok)
|
|
32
|
+
return { ok: false, status: "error" };
|
|
33
|
+
if (!root.value)
|
|
34
|
+
return { ok: false, status: "unsupported-child-session" };
|
|
35
|
+
return { ok: true };
|
|
36
|
+
}
|
|
37
|
+
async function askOrAbort(context, permission, target, metadata) {
|
|
38
|
+
if (context.abort.aborted)
|
|
39
|
+
return { ok: false, status: "aborted" };
|
|
40
|
+
try {
|
|
41
|
+
await context.ask({ permission, patterns: [target], always: [], metadata });
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return { ok: false, status: context.abort.aborted ? "aborted" : "denied" };
|
|
45
|
+
}
|
|
46
|
+
if (context.abort.aborted)
|
|
47
|
+
return { ok: false, status: "aborted" };
|
|
48
|
+
return { ok: true };
|
|
49
|
+
}
|
|
50
|
+
export async function establishBaseline(services, sessionID, signal, knownTitle) {
|
|
51
|
+
const loaded = await services.state.load(sessionID);
|
|
52
|
+
if (!loaded.ok)
|
|
53
|
+
return { ok: false, reason: loaded.reason };
|
|
54
|
+
if (loaded.value !== null)
|
|
55
|
+
return { ok: true, meta: loaded.value };
|
|
56
|
+
let title = knownTitle;
|
|
57
|
+
if (title === undefined) {
|
|
58
|
+
const read = await readSession(services.client, sessionID, signal);
|
|
59
|
+
if (!read.ok)
|
|
60
|
+
return { ok: false, reason: read.reason };
|
|
61
|
+
title = read.value.title;
|
|
62
|
+
}
|
|
63
|
+
const meta = firstObservation(isDefaultTitle(title), hashTitle(title));
|
|
64
|
+
// Transition atomically against the latest persisted metadata: a concurrent
|
|
65
|
+
// lock (or any other write) that landed since the initial load must win
|
|
66
|
+
// instead of being overwritten by this baseline.
|
|
67
|
+
const updated = await services.state.update(sessionID, (current) => {
|
|
68
|
+
const next = current ?? meta;
|
|
69
|
+
return { next, result: next };
|
|
70
|
+
});
|
|
71
|
+
if (!updated.ok)
|
|
72
|
+
return { ok: false, reason: updated.reason };
|
|
73
|
+
return { ok: true, meta: updated.value };
|
|
74
|
+
}
|
|
75
|
+
export function createCurrentTools(services) {
|
|
76
|
+
const titleRead = tool({
|
|
77
|
+
description: "Read the current session title, lock state and pending title review ID.",
|
|
78
|
+
args: {},
|
|
79
|
+
async execute(_args, context) {
|
|
80
|
+
if (context.abort.aborted)
|
|
81
|
+
return status({ status: "aborted" });
|
|
82
|
+
const caller = await requireRootCaller(services, context);
|
|
83
|
+
if (!caller.ok)
|
|
84
|
+
return status({ status: caller.status });
|
|
85
|
+
const read = await readSession(services.client, context.sessionID, context.abort);
|
|
86
|
+
if (!read.ok)
|
|
87
|
+
return status({ status: "error" });
|
|
88
|
+
const baseline = await establishBaseline(services, context.sessionID, context.abort, read.value.title);
|
|
89
|
+
if (!baseline.ok)
|
|
90
|
+
return status({ status: "state-unavailable" });
|
|
91
|
+
const shown = titleForStatus(read.value.title);
|
|
92
|
+
return status({
|
|
93
|
+
status: "ok",
|
|
94
|
+
sessionID: context.sessionID,
|
|
95
|
+
title: shown.value,
|
|
96
|
+
titleTruncated: shown.truncated,
|
|
97
|
+
locked: baseline.meta.locked,
|
|
98
|
+
lockReason: baseline.meta.lockReason,
|
|
99
|
+
reviewID: baseline.meta.review?.id ?? null,
|
|
100
|
+
});
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
const titleReview = tool({
|
|
104
|
+
description: "Resolve the current title review, or apply an explicit keep/rename decision. Keep requires no title; rename requires a new title.",
|
|
105
|
+
args: {
|
|
106
|
+
decision: tool.schema.enum(["keep", "rename"]),
|
|
107
|
+
expectedTitle: tool.schema.string(),
|
|
108
|
+
title: tool.schema.optional(tool.schema.string()),
|
|
109
|
+
reviewID: tool.schema.optional(tool.schema.string()),
|
|
110
|
+
},
|
|
111
|
+
async execute(args, context) {
|
|
112
|
+
if (context.abort.aborted)
|
|
113
|
+
return status({ status: "aborted" });
|
|
114
|
+
const caller = await requireRootCaller(services, context);
|
|
115
|
+
if (!caller.ok)
|
|
116
|
+
return status({ status: caller.status });
|
|
117
|
+
let newTitle = null;
|
|
118
|
+
if (args.decision === "rename") {
|
|
119
|
+
if (args.title === undefined)
|
|
120
|
+
return status({ status: "invalid", reason: "title-required" });
|
|
121
|
+
const valid = validateTitle(args.title);
|
|
122
|
+
if (!valid.ok)
|
|
123
|
+
return status({ status: "invalid", reason: valid.reason });
|
|
124
|
+
newTitle = valid.value;
|
|
125
|
+
}
|
|
126
|
+
else if (args.title !== undefined) {
|
|
127
|
+
return status({ status: "invalid", reason: "title-not-allowed" });
|
|
128
|
+
}
|
|
129
|
+
const loaded = await services.state.load(context.sessionID);
|
|
130
|
+
if (!loaded.ok)
|
|
131
|
+
return status({ status: "state-unavailable" });
|
|
132
|
+
const read = await readSession(services.client, context.sessionID, context.abort);
|
|
133
|
+
if (!read.ok)
|
|
134
|
+
return status({ status: "error" });
|
|
135
|
+
const currentTitle = read.value.title;
|
|
136
|
+
let meta = loaded.value;
|
|
137
|
+
if (meta === null) {
|
|
138
|
+
const baseline = await establishBaseline(services, context.sessionID, context.abort, currentTitle);
|
|
139
|
+
if (!baseline.ok)
|
|
140
|
+
return status({ status: "state-unavailable" });
|
|
141
|
+
meta = baseline.meta;
|
|
142
|
+
}
|
|
143
|
+
if (args.reviewID !== undefined && (meta.review === null || meta.review.id !== args.reviewID)) {
|
|
144
|
+
return status({ status: "conflict", reason: "stale-review" });
|
|
145
|
+
}
|
|
146
|
+
if (currentTitle !== args.expectedTitle) {
|
|
147
|
+
return status({ status: "conflict", reason: "expected-title-mismatch" });
|
|
148
|
+
}
|
|
149
|
+
if (meta.titleDigest !== null && hashTitle(currentTitle) !== meta.titleDigest) {
|
|
150
|
+
// The comparison above used the pre-read snapshot: a concurrent unlock
|
|
151
|
+
// (or any other transition) may have established the current digest
|
|
152
|
+
// while that read was pending. Re-evaluate the mismatch under the same
|
|
153
|
+
// guard as the mutating tools, against the latest persisted state and a
|
|
154
|
+
// fresh title read: only an unattributed difference that is still
|
|
155
|
+
// present on the latest state locks the session; a verified explicit
|
|
156
|
+
// unlock is preserved and an already-locked state is never overwritten.
|
|
157
|
+
const external = await services.guard.run(context.sessionID, async () => {
|
|
158
|
+
if (context.abort.aborted)
|
|
159
|
+
return { ok: false, status: "aborted" };
|
|
160
|
+
const read = await readSession(services.client, context.sessionID, context.abort);
|
|
161
|
+
if (context.abort.aborted)
|
|
162
|
+
return { ok: false, status: "aborted" };
|
|
163
|
+
if (!read.ok)
|
|
164
|
+
return { ok: false, status: "error" };
|
|
165
|
+
const saved = await services.state.update(context.sessionID, (current) => {
|
|
166
|
+
const base = current ?? meta;
|
|
167
|
+
if (base.locked)
|
|
168
|
+
return { next: base, result: true };
|
|
169
|
+
if (base.titleDigest === null || hashTitle(read.value.title) === base.titleDigest) {
|
|
170
|
+
return { next: base, result: false };
|
|
171
|
+
}
|
|
172
|
+
return { next: lockExternal(base), result: true };
|
|
173
|
+
});
|
|
174
|
+
if (!saved.ok)
|
|
175
|
+
return { ok: false, status: "uncertain" };
|
|
176
|
+
return { ok: true, locked: saved.value };
|
|
177
|
+
});
|
|
178
|
+
if (!external.ok) {
|
|
179
|
+
if (external.status === "aborted")
|
|
180
|
+
return status({ status: "aborted" });
|
|
181
|
+
if (external.status === "error")
|
|
182
|
+
return status({ status: "error" });
|
|
183
|
+
return status({ status: "uncertain", reason: "state-save-failed" });
|
|
184
|
+
}
|
|
185
|
+
if (external.locked)
|
|
186
|
+
return status({ status: "locked", reason: "external-title-change" });
|
|
187
|
+
}
|
|
188
|
+
if (args.decision === "keep") {
|
|
189
|
+
const approved = await askOrAbort(context, "stm_title_review", context.sessionID, { decision: "keep" });
|
|
190
|
+
if (!approved.ok)
|
|
191
|
+
return status({ status: approved.status });
|
|
192
|
+
// Serialise the confirmation and resolution with rename's guarded
|
|
193
|
+
// read-check-mutate (never nested): the title may have changed while the
|
|
194
|
+
// ask was pending, and a review resolved, cleared or replaced during the
|
|
195
|
+
// ask must not be resolved by this stale call.
|
|
196
|
+
const outcome = await services.guard.run(context.sessionID, async () => {
|
|
197
|
+
const confirm = await readSession(services.client, context.sessionID, context.abort);
|
|
198
|
+
if (context.abort.aborted)
|
|
199
|
+
return { ok: false, status: "aborted" };
|
|
200
|
+
if (!confirm.ok)
|
|
201
|
+
return { ok: false, status: "error" };
|
|
202
|
+
if (confirm.value.title !== args.expectedTitle) {
|
|
203
|
+
return { ok: false, status: "conflict", reason: "expected-title-mismatch" };
|
|
204
|
+
}
|
|
205
|
+
let resolvedReviewID = null;
|
|
206
|
+
const saved = await services.state.update(context.sessionID, (current) => {
|
|
207
|
+
const base = current ?? meta;
|
|
208
|
+
// Revalidate the caller-provided review ID against the latest
|
|
209
|
+
// state: resolving, clearing or replacing the review while the ask
|
|
210
|
+
// was pending conflicts instead of resolving a review the caller
|
|
211
|
+
// never saw.
|
|
212
|
+
if (args.reviewID !== undefined && (base.review?.id ?? null) !== args.reviewID) {
|
|
213
|
+
return { next: base, result: false };
|
|
214
|
+
}
|
|
215
|
+
resolvedReviewID = base.review?.id ?? null;
|
|
216
|
+
// Resolve against the latest metadata so a lock that landed
|
|
217
|
+
// during the ask is preserved rather than erased.
|
|
218
|
+
return { next: resolveReview(base, hashTitle(confirm.value.title)), result: true };
|
|
219
|
+
});
|
|
220
|
+
if (!saved.ok)
|
|
221
|
+
return { ok: false, status: "uncertain", reason: "state-save-failed" };
|
|
222
|
+
if (saved.value === false)
|
|
223
|
+
return { ok: false, status: "conflict", reason: "stale-review" };
|
|
224
|
+
return { ok: true, reviewID: resolvedReviewID };
|
|
225
|
+
});
|
|
226
|
+
if (!outcome.ok) {
|
|
227
|
+
if (outcome.status === "conflict") {
|
|
228
|
+
return status({ status: "conflict", reason: outcome.reason ?? "expected-title-mismatch" });
|
|
229
|
+
}
|
|
230
|
+
if (outcome.status === "uncertain")
|
|
231
|
+
return status({ status: "uncertain", reason: outcome.reason });
|
|
232
|
+
return status({ status: outcome.status === "aborted" ? "aborted" : "error" });
|
|
233
|
+
}
|
|
234
|
+
return status({ status: "ok", decision: "keep", reviewID: outcome.reviewID });
|
|
235
|
+
}
|
|
236
|
+
if (meta.locked)
|
|
237
|
+
return status({ status: "conflict", reason: "locked" });
|
|
238
|
+
if (newTitle === null)
|
|
239
|
+
return status({ status: "error" });
|
|
240
|
+
const approved = await askOrAbort(context, "stm_title_review", context.sessionID, {
|
|
241
|
+
currentTitle,
|
|
242
|
+
newTitle,
|
|
243
|
+
});
|
|
244
|
+
if (!approved.ok)
|
|
245
|
+
return status({ status: approved.status });
|
|
246
|
+
const outcome = await services.guard.run(context.sessionID, async () => {
|
|
247
|
+
const before = await readSession(services.client, context.sessionID, context.abort);
|
|
248
|
+
if (context.abort.aborted)
|
|
249
|
+
return { outcome: "aborted" };
|
|
250
|
+
if (!before.ok)
|
|
251
|
+
return { outcome: "error" };
|
|
252
|
+
if (before.value.title !== args.expectedTitle)
|
|
253
|
+
return { outcome: "conflict" };
|
|
254
|
+
// A lock mutation (or another guarded transition) may have completed
|
|
255
|
+
// while the permission ask was pending: never write over a lock.
|
|
256
|
+
const latest = await services.state.load(context.sessionID);
|
|
257
|
+
if (context.abort.aborted)
|
|
258
|
+
return { outcome: "aborted" };
|
|
259
|
+
if (!latest.ok)
|
|
260
|
+
return { outcome: "state-unavailable" };
|
|
261
|
+
const effective = latest.value ?? meta;
|
|
262
|
+
if (effective.locked)
|
|
263
|
+
return { outcome: "conflict", reason: "locked" };
|
|
264
|
+
// Revalidate the caller-provided review ID against the latest state: a
|
|
265
|
+
// review resolved or replaced while the ask was pending must conflict
|
|
266
|
+
// instead of writing a title for a review the caller never saw.
|
|
267
|
+
if (args.reviewID !== undefined && (effective.review?.id ?? null) !== args.reviewID) {
|
|
268
|
+
return { outcome: "conflict", reason: "stale-review" };
|
|
269
|
+
}
|
|
270
|
+
const written = await writeTitle(services.client, context.sessionID, newTitle, context.abort);
|
|
271
|
+
// The write may have landed even though the caller aborted: report the
|
|
272
|
+
// partial result and stop. No read-back, no state change, no retry.
|
|
273
|
+
if (context.abort.aborted)
|
|
274
|
+
return { outcome: "uncertain", reason: "aborted-during-write" };
|
|
275
|
+
if (!written.ok)
|
|
276
|
+
return { outcome: "error" };
|
|
277
|
+
const after = await readSession(services.client, context.sessionID, context.abort);
|
|
278
|
+
if (context.abort.aborted)
|
|
279
|
+
return { outcome: "uncertain", reason: "aborted-during-write" };
|
|
280
|
+
if (!after.ok || after.value.title !== newTitle)
|
|
281
|
+
return { outcome: "uncertain" };
|
|
282
|
+
return { outcome: "renamed", title: after.value.title };
|
|
283
|
+
});
|
|
284
|
+
if (outcome.outcome !== "renamed") {
|
|
285
|
+
if (outcome.outcome === "conflict") {
|
|
286
|
+
return status({ status: "conflict", reason: "reason" in outcome ? outcome.reason : "expected-title-mismatch" });
|
|
287
|
+
}
|
|
288
|
+
return "reason" in outcome
|
|
289
|
+
? status({ status: outcome.outcome, reason: outcome.reason })
|
|
290
|
+
: status({ status: outcome.outcome });
|
|
291
|
+
}
|
|
292
|
+
const saved = await services.state.update(context.sessionID, (current) => ({
|
|
293
|
+
next: resolveReview(current ?? meta, hashTitle(outcome.title)),
|
|
294
|
+
result: true,
|
|
295
|
+
}));
|
|
296
|
+
if (!saved.ok)
|
|
297
|
+
return status({ status: "uncertain", reason: "state-save-failed" });
|
|
298
|
+
return status({ status: "ok", decision: "rename", title: outcome.title });
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
const titleLock = tool({
|
|
302
|
+
description: "Lock (pause) or unlock periodic title reviews for the current session.",
|
|
303
|
+
args: { locked: tool.schema.boolean(), expectedTitle: tool.schema.string() },
|
|
304
|
+
async execute(args, context) {
|
|
305
|
+
if (context.abort.aborted)
|
|
306
|
+
return status({ status: "aborted" });
|
|
307
|
+
const caller = await requireRootCaller(services, context);
|
|
308
|
+
if (!caller.ok)
|
|
309
|
+
return status({ status: caller.status });
|
|
310
|
+
const loaded = await services.state.load(context.sessionID);
|
|
311
|
+
if (!loaded.ok)
|
|
312
|
+
return status({ status: "state-unavailable" });
|
|
313
|
+
const approved = await askOrAbort(context, "stm_title_lock", context.sessionID, {});
|
|
314
|
+
if (!approved.ok)
|
|
315
|
+
return status({ status: approved.status });
|
|
316
|
+
// Serialise the whole read-check-mutate sequence with rename's guarded
|
|
317
|
+
// read-check-write. The title must be read inside the guard: a rename that
|
|
318
|
+
// held the guard may have changed it while this call was queued, and the
|
|
319
|
+
// unlock digest must derive from the title actually confirmed here.
|
|
320
|
+
const outcome = await services.guard.run(context.sessionID, async () => {
|
|
321
|
+
if (context.abort.aborted)
|
|
322
|
+
return { ok: false, status: "aborted" };
|
|
323
|
+
const read = await readSession(services.client, context.sessionID, context.abort);
|
|
324
|
+
if (context.abort.aborted)
|
|
325
|
+
return { ok: false, status: "aborted" };
|
|
326
|
+
if (!read.ok)
|
|
327
|
+
return { ok: false, status: "error" };
|
|
328
|
+
if (read.value.title !== args.expectedTitle)
|
|
329
|
+
return { ok: false, status: "conflict" };
|
|
330
|
+
const confirmedTitle = read.value.title;
|
|
331
|
+
const result = await services.state.update(context.sessionID, (current) => {
|
|
332
|
+
const base = current ?? loaded.value ?? initialMeta();
|
|
333
|
+
const next = args.locked ? lockManually(base) : unlock(base, hashTitle(confirmedTitle));
|
|
334
|
+
return { next, result: next.locked };
|
|
335
|
+
});
|
|
336
|
+
if (!result.ok)
|
|
337
|
+
return { ok: false, status: "uncertain" };
|
|
338
|
+
return { ok: true, locked: result.value };
|
|
339
|
+
});
|
|
340
|
+
if (!outcome.ok) {
|
|
341
|
+
if (outcome.status === "aborted")
|
|
342
|
+
return status({ status: "aborted" });
|
|
343
|
+
if (outcome.status === "error")
|
|
344
|
+
return status({ status: "error" });
|
|
345
|
+
if (outcome.status === "conflict") {
|
|
346
|
+
return status({ status: "conflict", reason: "expected-title-mismatch" });
|
|
347
|
+
}
|
|
348
|
+
return status({ status: "uncertain", reason: "state-save-failed" });
|
|
349
|
+
}
|
|
350
|
+
return status({ status: "ok", locked: outcome.locked });
|
|
351
|
+
},
|
|
352
|
+
});
|
|
353
|
+
return { stm_title_read: titleRead, stm_title_review: titleReview, stm_title_lock: titleLock };
|
|
354
|
+
}
|
|
355
|
+
function isBlocked(services, caller, target) {
|
|
356
|
+
return services.blocked.get(caller)?.has(target) ?? false;
|
|
357
|
+
}
|
|
358
|
+
function blockTarget(services, caller, target) {
|
|
359
|
+
const targets = services.blocked.get(caller) ?? new Set();
|
|
360
|
+
targets.add(target);
|
|
361
|
+
services.blocked.set(caller, targets);
|
|
362
|
+
}
|
|
363
|
+
export function createHistoricalTools(services) {
|
|
364
|
+
const tools = {};
|
|
365
|
+
if (services.config.historical.readTitles) {
|
|
366
|
+
tools.stm_history_title_read = tool({
|
|
367
|
+
description: "Opt-in read of one other session's title and root/child metadata. Does not traverse.",
|
|
368
|
+
args: { sessionID: tool.schema.string() },
|
|
369
|
+
async execute(args, context) {
|
|
370
|
+
if (context.abort.aborted)
|
|
371
|
+
return status({ status: "aborted" });
|
|
372
|
+
if (args.sessionID === context.sessionID)
|
|
373
|
+
return status({ status: "invalid", reason: "self-target" });
|
|
374
|
+
const caller = await requireRootCaller(services, context);
|
|
375
|
+
if (!caller.ok)
|
|
376
|
+
return status({ status: caller.status });
|
|
377
|
+
const approved = await askOrAbort(context, "stm_history_title_read", args.sessionID, {});
|
|
378
|
+
if (!approved.ok)
|
|
379
|
+
return status({ status: approved.status });
|
|
380
|
+
const read = await readSession(services.client, args.sessionID, context.abort);
|
|
381
|
+
if (!read.ok)
|
|
382
|
+
return status({ status: "error" });
|
|
383
|
+
const shown = titleForStatus(read.value.title);
|
|
384
|
+
return status({
|
|
385
|
+
status: "ok",
|
|
386
|
+
sessionID: read.value.id,
|
|
387
|
+
title: shown.value,
|
|
388
|
+
titleTruncated: shown.truncated,
|
|
389
|
+
root: read.value.parentID === undefined,
|
|
390
|
+
});
|
|
391
|
+
},
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
if (services.config.historical.renameTitles) {
|
|
395
|
+
tools.stm_history_title_rename = tool({
|
|
396
|
+
description: "Opt-in explicit rename of one other root session. Reads back for verification and leaves the target's automatic review locked.",
|
|
397
|
+
args: { sessionID: tool.schema.string(), expectedTitle: tool.schema.string(), title: tool.schema.string() },
|
|
398
|
+
async execute(args, context) {
|
|
399
|
+
if (context.abort.aborted)
|
|
400
|
+
return status({ status: "aborted" });
|
|
401
|
+
if (args.sessionID === context.sessionID)
|
|
402
|
+
return status({ status: "invalid", reason: "self-target" });
|
|
403
|
+
const caller = await requireRootCaller(services, context);
|
|
404
|
+
if (!caller.ok)
|
|
405
|
+
return status({ status: caller.status });
|
|
406
|
+
if (isBlocked(services, context.sessionID, args.sessionID)) {
|
|
407
|
+
return status({ status: "conflict", reason: "sensitive-blocked" });
|
|
408
|
+
}
|
|
409
|
+
const valid = validateTitle(args.title);
|
|
410
|
+
if (!valid.ok)
|
|
411
|
+
return status({ status: "invalid", reason: valid.reason });
|
|
412
|
+
const approved = await askOrAbort(context, "stm_history_title_rename", args.sessionID, {
|
|
413
|
+
expectedTitle: args.expectedTitle,
|
|
414
|
+
newTitle: valid.value,
|
|
415
|
+
});
|
|
416
|
+
if (!approved.ok)
|
|
417
|
+
return status({ status: approved.status });
|
|
418
|
+
// A sensitive review end may have completed while the ask was pending:
|
|
419
|
+
// revalidate before any target read or write.
|
|
420
|
+
if (isBlocked(services, context.sessionID, args.sessionID)) {
|
|
421
|
+
return status({ status: "conflict", reason: "sensitive-blocked" });
|
|
422
|
+
}
|
|
423
|
+
const outcome = await services.guard.run(args.sessionID, async () => {
|
|
424
|
+
const before = await readSession(services.client, args.sessionID, context.abort);
|
|
425
|
+
if (context.abort.aborted)
|
|
426
|
+
return { outcome: "aborted" };
|
|
427
|
+
if (!before.ok)
|
|
428
|
+
return { outcome: "error" };
|
|
429
|
+
// The sensitive stop may have landed while queued on the guard or during
|
|
430
|
+
// the read: never write after it, even though the fast path passed.
|
|
431
|
+
if (isBlocked(services, context.sessionID, args.sessionID)) {
|
|
432
|
+
return { outcome: "conflict", reason: "sensitive-blocked" };
|
|
433
|
+
}
|
|
434
|
+
if (before.value.parentID !== undefined)
|
|
435
|
+
return { outcome: "conflict", reason: "child-target" };
|
|
436
|
+
if (before.value.title !== args.expectedTitle) {
|
|
437
|
+
return { outcome: "conflict", reason: "expected-title-mismatch" };
|
|
438
|
+
}
|
|
439
|
+
const written = await writeTitle(services.client, args.sessionID, valid.value, context.abort);
|
|
440
|
+
// The write may have landed even though the caller aborted: report the
|
|
441
|
+
// partial result and stop. No read-back, no state change, no retry.
|
|
442
|
+
if (context.abort.aborted)
|
|
443
|
+
return { outcome: "uncertain", reason: "aborted-during-write" };
|
|
444
|
+
if (!written.ok)
|
|
445
|
+
return { outcome: "error" };
|
|
446
|
+
const after = await readSession(services.client, args.sessionID, context.abort);
|
|
447
|
+
if (context.abort.aborted)
|
|
448
|
+
return { outcome: "uncertain", reason: "aborted-during-write" };
|
|
449
|
+
if (!after.ok || after.value.title !== valid.value)
|
|
450
|
+
return { outcome: "uncertain" };
|
|
451
|
+
return { outcome: "renamed", title: after.value.title };
|
|
452
|
+
});
|
|
453
|
+
if (outcome.outcome !== "renamed") {
|
|
454
|
+
return "reason" in outcome
|
|
455
|
+
? status({ status: outcome.outcome, reason: outcome.reason })
|
|
456
|
+
: status({ status: outcome.outcome });
|
|
457
|
+
}
|
|
458
|
+
// Lock the target's automatic review against the latest persisted state so a
|
|
459
|
+
// concurrent metadata change is preserved rather than overwritten.
|
|
460
|
+
const saved = await services.state.update(args.sessionID, (current) => {
|
|
461
|
+
const base = current ?? initialMeta();
|
|
462
|
+
return {
|
|
463
|
+
next: { ...base, locked: true, lockReason: "historical", titleDigest: hashTitle(outcome.title) },
|
|
464
|
+
result: true,
|
|
465
|
+
};
|
|
466
|
+
});
|
|
467
|
+
if (!saved.ok)
|
|
468
|
+
return status({ status: "uncertain", reason: "state-save-failed" });
|
|
469
|
+
return status({ status: "ok", sessionID: args.sessionID, title: outcome.title });
|
|
470
|
+
},
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
if (services.config.historical.readTranscript) {
|
|
474
|
+
tools.stm_history_transcript = tool({
|
|
475
|
+
description: "Opt-in bounded historical text chunk queued for the next outgoing context. Returns counts and cursors only, never text.",
|
|
476
|
+
args: { sessionID: tool.schema.string(), cursor: tool.schema.optional(tool.schema.string()) },
|
|
477
|
+
async execute(args, context) {
|
|
478
|
+
if (context.abort.aborted)
|
|
479
|
+
return status({ status: "aborted" });
|
|
480
|
+
if (args.sessionID === context.sessionID)
|
|
481
|
+
return status({ status: "invalid", reason: "self-target" });
|
|
482
|
+
const caller = await requireRootCaller(services, context);
|
|
483
|
+
if (!caller.ok)
|
|
484
|
+
return status({ status: caller.status });
|
|
485
|
+
if (isBlocked(services, context.sessionID, args.sessionID)) {
|
|
486
|
+
return status({ status: "conflict", reason: "sensitive-blocked" });
|
|
487
|
+
}
|
|
488
|
+
const approved = await askOrAbort(context, "stm_history_transcript", args.sessionID, {});
|
|
489
|
+
if (!approved.ok)
|
|
490
|
+
return status({ status: approved.status });
|
|
491
|
+
// A sensitive review end may have completed while the ask was pending:
|
|
492
|
+
// revalidate before reading the target or fetching any page.
|
|
493
|
+
if (isBlocked(services, context.sessionID, args.sessionID)) {
|
|
494
|
+
return status({ status: "conflict", reason: "sensitive-blocked" });
|
|
495
|
+
}
|
|
496
|
+
const target = await readSession(services.client, args.sessionID, context.abort);
|
|
497
|
+
if (!target.ok)
|
|
498
|
+
return status({ status: "error" });
|
|
499
|
+
// The sensitive stop may have landed during the target read.
|
|
500
|
+
if (isBlocked(services, context.sessionID, args.sessionID)) {
|
|
501
|
+
return status({ status: "conflict", reason: "sensitive-blocked" });
|
|
502
|
+
}
|
|
503
|
+
if (target.value.parentID !== undefined)
|
|
504
|
+
return status({ status: "conflict", reason: "child-target" });
|
|
505
|
+
const begun = services.queue.begin({
|
|
506
|
+
callerSessionID: context.sessionID,
|
|
507
|
+
targetSessionID: args.sessionID,
|
|
508
|
+
requestMessageID: context.messageID,
|
|
509
|
+
signal: context.abort,
|
|
510
|
+
...(args.cursor === undefined ? {} : { cursor: args.cursor }),
|
|
511
|
+
}, services.now());
|
|
512
|
+
if (!begun.ok) {
|
|
513
|
+
return begun.reason === "queue: aborted"
|
|
514
|
+
? status({ status: "aborted" })
|
|
515
|
+
: status({ status: "conflict", reason: begun.reason });
|
|
516
|
+
}
|
|
517
|
+
const fetched = await page(services.client, args.sessionID, begun.fetchCursor, context.abort);
|
|
518
|
+
if (context.abort.aborted) {
|
|
519
|
+
// The abort listener has already dropped any pending chunk; release again
|
|
520
|
+
// is idempotent. Do not queue text from an aborted call. The token scopes
|
|
521
|
+
// the release to this fetch generation.
|
|
522
|
+
services.queue.release(context.sessionID, begun.token);
|
|
523
|
+
return status({ status: "aborted" });
|
|
524
|
+
}
|
|
525
|
+
if (!fetched.ok)
|
|
526
|
+
return status({ status: "error" });
|
|
527
|
+
if (context.abort.aborted) {
|
|
528
|
+
services.queue.release(context.sessionID, begun.token);
|
|
529
|
+
return status({ status: "aborted" });
|
|
530
|
+
}
|
|
531
|
+
// A sensitive review end may have completed during the fetch: discard the
|
|
532
|
+
// fetched text instead of queueing it, scoped to this fetch generation.
|
|
533
|
+
if (isBlocked(services, context.sessionID, args.sessionID)) {
|
|
534
|
+
services.queue.release(context.sessionID, begun.token);
|
|
535
|
+
return status({ status: "conflict", reason: "sensitive-blocked" });
|
|
536
|
+
}
|
|
537
|
+
const supplied = services.queue.supply(context.sessionID, { entries: extractVisibleText(fetched.value.items), nextCursor: fetched.value.nextCursor }, services.now(), begun.token);
|
|
538
|
+
if (!supplied.ok) {
|
|
539
|
+
return supplied.reason === "queue: aborted"
|
|
540
|
+
? status({ status: "aborted" })
|
|
541
|
+
: status({ status: "conflict", reason: supplied.reason });
|
|
542
|
+
}
|
|
543
|
+
if (context.abort.aborted) {
|
|
544
|
+
// An abort that landed before the handoff is a real in-flight
|
|
545
|
+
// cancellation: the listener (or this release) discards the chunk.
|
|
546
|
+
services.queue.release(context.sessionID, begun.token);
|
|
547
|
+
return status({ status: "aborted" });
|
|
548
|
+
}
|
|
549
|
+
if (supplied.ack.continuation) {
|
|
550
|
+
// Commit the queued chunk before returning: OpenCode 1.18.35 aborts the
|
|
551
|
+
// per-tool signal after every successful return, and the handoff detaches
|
|
552
|
+
// that listener so the next transform can still take the chunk.
|
|
553
|
+
const handed = services.queue.handoff(context.sessionID, begun.token);
|
|
554
|
+
if (!handed.ok)
|
|
555
|
+
return status({ status: "conflict", reason: handed.reason });
|
|
556
|
+
}
|
|
557
|
+
return status({
|
|
558
|
+
status: "ok",
|
|
559
|
+
sessionID: args.sessionID,
|
|
560
|
+
messages: supplied.ack.messages,
|
|
561
|
+
bytes: supplied.ack.bytes,
|
|
562
|
+
cursor: supplied.ack.cursor,
|
|
563
|
+
more: supplied.ack.more,
|
|
564
|
+
complete: supplied.ack.complete,
|
|
565
|
+
continuation: supplied.ack.continuation,
|
|
566
|
+
});
|
|
567
|
+
},
|
|
568
|
+
});
|
|
569
|
+
tools.stm_history_review_end = tool({
|
|
570
|
+
description: "Discard queued transcript content and end this caller's review of a target. A sensitive outcome blocks further transcript reads and renames of that target.",
|
|
571
|
+
args: { sessionID: tool.schema.string(), outcome: tool.schema.enum(["reviewed", "sensitive", "cancelled"]) },
|
|
572
|
+
async execute(args, context) {
|
|
573
|
+
if (context.abort.aborted)
|
|
574
|
+
return status({ status: "aborted" });
|
|
575
|
+
if (args.sessionID === context.sessionID)
|
|
576
|
+
return status({ status: "invalid", reason: "self-target" });
|
|
577
|
+
const caller = await requireRootCaller(services, context);
|
|
578
|
+
if (!caller.ok)
|
|
579
|
+
return status({ status: caller.status });
|
|
580
|
+
const approved = await askOrAbort(context, "stm_history_review_end", args.sessionID, {
|
|
581
|
+
outcome: args.outcome,
|
|
582
|
+
});
|
|
583
|
+
if (!approved.ok)
|
|
584
|
+
return status({ status: approved.status });
|
|
585
|
+
const ended = services.queue.endReview(context.sessionID, args.sessionID);
|
|
586
|
+
if (!ended.ok)
|
|
587
|
+
return status({ status: "conflict", reason: ended.reason });
|
|
588
|
+
if (args.outcome === "sensitive")
|
|
589
|
+
blockTarget(services, context.sessionID, args.sessionID);
|
|
590
|
+
return status({ status: "ok", outcome: args.outcome });
|
|
591
|
+
},
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
return tools;
|
|
595
|
+
}
|
|
596
|
+
//# sourceMappingURL=tools.js.map
|