opencode-superagent 0.0.0-stage → 0.1.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 +333 -2
- package/agent/superagent-router.md +7 -0
- package/agent/superagent-worker.md +10 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.js +115 -0
- package/dist/src/abort.d.ts +31 -0
- package/dist/src/abort.js +176 -0
- package/dist/src/agents.d.ts +51 -0
- package/dist/src/agents.js +83 -0
- package/dist/src/context.d.ts +20 -0
- package/dist/src/context.js +98 -0
- package/dist/src/dirs.d.ts +45 -0
- package/dist/src/dirs.js +129 -0
- package/dist/src/dispatch.d.ts +38 -0
- package/dist/src/dispatch.js +214 -0
- package/dist/src/format.d.ts +3 -0
- package/dist/src/format.js +14 -0
- package/dist/src/guard.d.ts +4 -0
- package/dist/src/guard.js +9 -0
- package/dist/src/prompts.d.ts +1 -0
- package/dist/src/prompts.js +36 -0
- package/dist/src/protocol.d.ts +210 -0
- package/dist/src/protocol.js +88 -0
- package/dist/src/recall.d.ts +58 -0
- package/dist/src/recall.js +309 -0
- package/dist/src/report.d.ts +72 -0
- package/dist/src/report.js +147 -0
- package/dist/src/router.d.ts +35 -0
- package/dist/src/router.js +209 -0
- package/dist/src/session-meta.d.ts +42 -0
- package/dist/src/session-meta.js +91 -0
- package/dist/src/state.d.ts +124 -0
- package/dist/src/state.js +431 -0
- package/dist/src/ticket-tools.d.ts +45 -0
- package/dist/src/ticket-tools.js +148 -0
- package/dist/src/ticket-view.d.ts +62 -0
- package/dist/src/ticket-view.js +135 -0
- package/dist/src/ticket.d.ts +246 -0
- package/dist/src/ticket.js +700 -0
- package/dist/src/tools.d.ts +85 -0
- package/dist/src/tools.js +376 -0
- package/dist/src/watchdog.d.ts +16 -0
- package/dist/src/watchdog.js +77 -0
- package/dist/src/watcher.d.ts +11 -0
- package/dist/src/watcher.js +181 -0
- package/dist/tui.d.ts +3 -0
- package/dist/tui.js +1027 -0
- package/package.json +79 -4
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode/plugin/effect";
|
|
2
|
+
import { Tool } from "@opencode/schema/tool";
|
|
3
|
+
import { Effect, Schema } from "effect";
|
|
4
|
+
export declare const ThreadDetailInput: Schema.Struct<{
|
|
5
|
+
readonly threadID: Schema.String;
|
|
6
|
+
readonly limit: Schema.optionalKey<Schema.Number>;
|
|
7
|
+
readonly offset: Schema.optionalKey<Schema.Number>;
|
|
8
|
+
}>;
|
|
9
|
+
export declare const WorkerDetailInput: Schema.Struct<{
|
|
10
|
+
readonly sessionID: Schema.String;
|
|
11
|
+
readonly messages: Schema.optionalKey<Schema.Number>;
|
|
12
|
+
}>;
|
|
13
|
+
export declare const ReportReadInput: Schema.Struct<{
|
|
14
|
+
readonly sessionID: Schema.String;
|
|
15
|
+
readonly offset: Schema.optionalKey<Schema.Number>;
|
|
16
|
+
readonly limit: Schema.optionalKey<Schema.Number>;
|
|
17
|
+
}>;
|
|
18
|
+
interface ThreadDetailInput {
|
|
19
|
+
readonly threadID: string;
|
|
20
|
+
readonly limit?: number;
|
|
21
|
+
readonly offset?: number;
|
|
22
|
+
}
|
|
23
|
+
interface WorkerDetailInput {
|
|
24
|
+
readonly sessionID: string;
|
|
25
|
+
readonly messages?: number;
|
|
26
|
+
}
|
|
27
|
+
interface ReportReadInput {
|
|
28
|
+
readonly sessionID: string;
|
|
29
|
+
readonly offset?: number;
|
|
30
|
+
readonly limit?: number;
|
|
31
|
+
}
|
|
32
|
+
export declare const runThreadDetail: (ctx: Plugin.Context, input: ThreadDetailInput, caller: string) => Effect.Effect<{
|
|
33
|
+
output: {
|
|
34
|
+
text: string;
|
|
35
|
+
};
|
|
36
|
+
content: string;
|
|
37
|
+
}, Tool.Error, never>;
|
|
38
|
+
export declare const runWorkerDetail: (ctx: Plugin.Context, input: WorkerDetailInput, caller: string) => Effect.Effect<{
|
|
39
|
+
output: {
|
|
40
|
+
text: string;
|
|
41
|
+
};
|
|
42
|
+
content: string;
|
|
43
|
+
}, Tool.Error, never>;
|
|
44
|
+
/** Page through the full report kept at `report:<sessionID>`. */
|
|
45
|
+
export declare const runReportRead: (ctx: Plugin.Context, input: ReportReadInput, caller: string) => Effect.Effect<{
|
|
46
|
+
output: {
|
|
47
|
+
text: string;
|
|
48
|
+
};
|
|
49
|
+
content: string;
|
|
50
|
+
}, Tool.Error, never>;
|
|
51
|
+
/**
|
|
52
|
+
* Router-only recall tools plus capture of triggers delivered straight to
|
|
53
|
+
* workers (they never pass the pending queue, so the prompt hook records
|
|
54
|
+
* them from the admitted text). Both tools reuse the `status` permission
|
|
55
|
+
* action, which the router session already allows.
|
|
56
|
+
*/
|
|
57
|
+
export declare const registerRecall: (ctx: Plugin.Context) => Effect.Effect<void, never, import("effect/Scope").Scope>;
|
|
58
|
+
export {};
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { Tool } from "@opencode/schema/tool";
|
|
2
|
+
import { Effect, Schema } from "effect";
|
|
3
|
+
import { threadDirs } from "./dirs.js";
|
|
4
|
+
import { requireRouter } from "./guard.js";
|
|
5
|
+
import { loadReport, REPORT_DEFAULT_LIMIT, REPORT_MAX_LIMIT, REPORT_TEXT_LIMIT, sliceReport } from "./report.js";
|
|
6
|
+
import { decodeTrigger, saveTrigger, store, toSessionID, TRIGGER_PREFIX, upsertDirectTrigger, } from "./state.js";
|
|
7
|
+
import { specFor, toolPermission } from "./tools.js";
|
|
8
|
+
export const ThreadDetailInput = Schema.Struct({
|
|
9
|
+
threadID: Schema.String,
|
|
10
|
+
limit: Schema.optionalKey(Schema.Number),
|
|
11
|
+
offset: Schema.optionalKey(Schema.Number),
|
|
12
|
+
});
|
|
13
|
+
export const WorkerDetailInput = Schema.Struct({
|
|
14
|
+
sessionID: Schema.String,
|
|
15
|
+
messages: Schema.optionalKey(Schema.Number),
|
|
16
|
+
});
|
|
17
|
+
export const ReportReadInput = Schema.Struct({
|
|
18
|
+
sessionID: Schema.String,
|
|
19
|
+
offset: Schema.optionalKey(Schema.Number),
|
|
20
|
+
limit: Schema.optionalKey(Schema.Number),
|
|
21
|
+
});
|
|
22
|
+
const DetailOutput = Schema.Struct({ text: Schema.String });
|
|
23
|
+
/** Single response budget: compact text, truncated with a note when over. */
|
|
24
|
+
const MAX_TEXT = 6000;
|
|
25
|
+
const THREAD_ITEM_LIMIT = 300;
|
|
26
|
+
/** Per-message text in `worker_detail`; overridable through plugin options. */
|
|
27
|
+
const MESSAGE_ITEM_LIMIT = 1500;
|
|
28
|
+
const MESSAGE_ITEM_MIN = 200;
|
|
29
|
+
const MESSAGE_ITEM_MAX = 4000;
|
|
30
|
+
const DEFAULT_THREAD_LIMIT = 20;
|
|
31
|
+
const MAX_THREAD_TAKE = 200;
|
|
32
|
+
/** KV fallback is bounded: one page read at a time, so a recall never fans out into per-id reads. */
|
|
33
|
+
const KV_SCAN_PAGE = 100;
|
|
34
|
+
const KV_SCAN_MAX_ENTRIES = 2_000;
|
|
35
|
+
/** At most this many older triggers are pulled from KV for one `thread_detail` call. */
|
|
36
|
+
const KV_BACKFILL_MAX = 20;
|
|
37
|
+
const DEFAULT_MESSAGE_COUNT = 5;
|
|
38
|
+
const MAX_MESSAGE_COUNT = 10;
|
|
39
|
+
function truncate(text, limit, hint) {
|
|
40
|
+
if (text.length <= limit)
|
|
41
|
+
return text;
|
|
42
|
+
const suffix = `(${text.length - limit} chars truncated)${hint === undefined ? "" : ` ${hint}`}`;
|
|
43
|
+
return `${text.slice(0, limit)}...${suffix}`;
|
|
44
|
+
}
|
|
45
|
+
/** Plugin option `worker_detail_message_limit`, clamped so one message cannot eat the whole budget. */
|
|
46
|
+
function messageItemLimit(ctx) {
|
|
47
|
+
const raw = ctx.options["worker_detail_message_limit"];
|
|
48
|
+
const value = typeof raw === "number" ? raw : Number(raw);
|
|
49
|
+
if (!Number.isFinite(value))
|
|
50
|
+
return MESSAGE_ITEM_LIMIT;
|
|
51
|
+
return Math.min(MESSAGE_ITEM_MAX, Math.max(MESSAGE_ITEM_MIN, Math.floor(value)));
|
|
52
|
+
}
|
|
53
|
+
function fitBudget(text) {
|
|
54
|
+
if (text.length <= MAX_TEXT)
|
|
55
|
+
return text;
|
|
56
|
+
return `${text.slice(0, MAX_TEXT)}\n...(truncated to ${MAX_TEXT} chars)`;
|
|
57
|
+
}
|
|
58
|
+
function workerLine(sessionID) {
|
|
59
|
+
const worker = store.workers.get(sessionID);
|
|
60
|
+
if (worker === undefined)
|
|
61
|
+
return `- ${sessionID} untracked`;
|
|
62
|
+
const threads = worker.threads.length === 0 ? "-" : worker.threads.join(",");
|
|
63
|
+
const dir = worker.directory ?? "-";
|
|
64
|
+
return `- ${worker.sessionID} ${worker.title ?? "-"} [${threads}] ${worker.state} dir=${dir} ${worker.lastReport ?? "-"}`;
|
|
65
|
+
}
|
|
66
|
+
export const runThreadDetail = Effect.fn("superagent.threadDetail")(function* (ctx, input, caller) {
|
|
67
|
+
yield* requireRouter(caller, "thread_detail");
|
|
68
|
+
const take = Math.min(MAX_THREAD_TAKE, input.limit === undefined ? DEFAULT_THREAD_LIMIT : Math.max(1, Math.floor(input.limit)));
|
|
69
|
+
const offset = input.offset === undefined ? 0 : Math.max(0, Math.floor(input.offset));
|
|
70
|
+
const memory = [...store.triggers.values()]
|
|
71
|
+
.filter((record) => record.threadID === input.threadID)
|
|
72
|
+
.sort((left, right) => left.createdAt - right.createdAt);
|
|
73
|
+
// Memory keeps only the newest 200 triggers. When the requested window
|
|
74
|
+
// starts before that, scan the trigger prefix once (paged, bounded) and keep
|
|
75
|
+
// the older records. This is a prefix scan, not one KV read per missing id,
|
|
76
|
+
// so a stale thread cannot fan out into hundreds of round trips.
|
|
77
|
+
let triggers = memory;
|
|
78
|
+
let kvFilled = 0;
|
|
79
|
+
let kvTruncated = false;
|
|
80
|
+
if (offset + take > memory.length) {
|
|
81
|
+
const seen = new Set(memory.map((record) => record.id));
|
|
82
|
+
const found = [];
|
|
83
|
+
let scanned = 0;
|
|
84
|
+
let after;
|
|
85
|
+
// Scan the whole bounded prefix first, then keep the newest missing
|
|
86
|
+
// records: stopping at the first 20 matches would return key order, not
|
|
87
|
+
// the most recent triggers of the thread.
|
|
88
|
+
while (scanned < KV_SCAN_MAX_ENTRIES) {
|
|
89
|
+
const page = yield* ctx.storage
|
|
90
|
+
.scan({ prefix: TRIGGER_PREFIX, after, limit: KV_SCAN_PAGE })
|
|
91
|
+
.pipe(Effect.catch(() => Effect.succeed({ entries: [], next: undefined })));
|
|
92
|
+
if (page.entries.length === 0)
|
|
93
|
+
break;
|
|
94
|
+
scanned += page.entries.length;
|
|
95
|
+
for (const entry of page.entries) {
|
|
96
|
+
const record = decodeTrigger(entry.value);
|
|
97
|
+
if (record === undefined || record.threadID !== input.threadID || seen.has(record.id))
|
|
98
|
+
continue;
|
|
99
|
+
seen.add(record.id);
|
|
100
|
+
found.push(record);
|
|
101
|
+
}
|
|
102
|
+
after = page.next;
|
|
103
|
+
if (after === undefined)
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
found.sort((left, right) => right.createdAt - left.createdAt);
|
|
107
|
+
const older = found.slice(0, KV_BACKFILL_MAX);
|
|
108
|
+
// Still more pages or more matching records than the cap: say so instead of
|
|
109
|
+
// pretending the list is complete.
|
|
110
|
+
if (after !== undefined || found.length > KV_BACKFILL_MAX)
|
|
111
|
+
kvTruncated = true;
|
|
112
|
+
kvFilled = older.length;
|
|
113
|
+
if (older.length > 0)
|
|
114
|
+
triggers = [...memory, ...older].sort((left, right) => left.createdAt - right.createdAt);
|
|
115
|
+
}
|
|
116
|
+
const total = triggers.length;
|
|
117
|
+
const end = Math.max(0, total - offset);
|
|
118
|
+
const start = Math.max(0, end - take);
|
|
119
|
+
const shown = triggers.slice(start, end);
|
|
120
|
+
const workers = [...store.workers.values()].filter((worker) => worker.threadID === input.threadID || worker.threads.includes(input.threadID));
|
|
121
|
+
const source = kvFilled > 0 ? `memory ${memory.length} + KV ${kvFilled}` : `memory ${memory.length}`;
|
|
122
|
+
const lines = [
|
|
123
|
+
`thread ${input.threadID}: ${total} trigger(s) (${source}), ${workers.length} worker(s)`,
|
|
124
|
+
`directory: ${threadDirs.get(input.threadID) ?? "(no binding, use gateway default and confirm with user)"}`,
|
|
125
|
+
`triggers (offset=${offset}, showing ${shown.length} of ${total}):`,
|
|
126
|
+
];
|
|
127
|
+
for (const record of shown) {
|
|
128
|
+
const at = new Date(record.createdAt).toISOString();
|
|
129
|
+
lines.push(`- [${at}] ${record.author ?? "unknown"} (${record.route ?? "queued"}): ${truncate(record.text, THREAD_ITEM_LIMIT)}`);
|
|
130
|
+
}
|
|
131
|
+
if (shown.length === 0)
|
|
132
|
+
lines.push("(no trigger in this range)");
|
|
133
|
+
if (offset >= total) {
|
|
134
|
+
lines.push(`offset ${offset} 超出已知 ${total} 条;thread_detail 每次最多从 KV 回源 ${KV_BACKFILL_MAX} 条。`);
|
|
135
|
+
}
|
|
136
|
+
if (kvTruncated) {
|
|
137
|
+
lines.push(`KV 回源已截断:本次扫描 ${KV_SCAN_MAX_ENTRIES} 条、最多补 ${KV_BACKFILL_MAX} 条,仍有更早的 trigger 未列出。`);
|
|
138
|
+
}
|
|
139
|
+
lines.push(`workers (${workers.length}):`);
|
|
140
|
+
for (const worker of workers)
|
|
141
|
+
lines.push(workerLine(worker.sessionID).slice(0, THREAD_ITEM_LIMIT));
|
|
142
|
+
const text = fitBudget(lines.join("\n"));
|
|
143
|
+
return {
|
|
144
|
+
output: { text },
|
|
145
|
+
content: text,
|
|
146
|
+
};
|
|
147
|
+
});
|
|
148
|
+
function assistantTexts(messages, count) {
|
|
149
|
+
const collected = [];
|
|
150
|
+
for (let index = messages.length - 1; index >= 0 && collected.length < count; index--) {
|
|
151
|
+
const message = messages[index];
|
|
152
|
+
if (typeof message !== "object" || message === null)
|
|
153
|
+
continue;
|
|
154
|
+
const typed = message;
|
|
155
|
+
if (typed.type !== "assistant" || !Array.isArray(typed.content))
|
|
156
|
+
continue;
|
|
157
|
+
const text = typed.content
|
|
158
|
+
.flatMap((part) => {
|
|
159
|
+
if (typeof part !== "object" || part === null)
|
|
160
|
+
return [];
|
|
161
|
+
const candidate = part;
|
|
162
|
+
return candidate.type === "text" && typeof candidate.text === "string" ? [candidate.text] : [];
|
|
163
|
+
})
|
|
164
|
+
.join("\n")
|
|
165
|
+
.trim();
|
|
166
|
+
if (text !== "")
|
|
167
|
+
collected.push(text);
|
|
168
|
+
}
|
|
169
|
+
return collected.reverse();
|
|
170
|
+
}
|
|
171
|
+
export const runWorkerDetail = Effect.fn("superagent.workerDetail")(function* (ctx, input, caller) {
|
|
172
|
+
yield* requireRouter(caller, "worker_detail");
|
|
173
|
+
const count = input.messages === undefined
|
|
174
|
+
? DEFAULT_MESSAGE_COUNT
|
|
175
|
+
: Math.min(MAX_MESSAGE_COUNT, Math.max(1, Math.floor(input.messages)));
|
|
176
|
+
const worker = store.workers.get(input.sessionID);
|
|
177
|
+
const stored = yield* loadReport(ctx, input.sessionID);
|
|
178
|
+
const messages = yield* ctx.session
|
|
179
|
+
.context({ sessionID: toSessionID(input.sessionID) })
|
|
180
|
+
.pipe(Effect.catch(() => Effect.succeed([])));
|
|
181
|
+
const texts = assistantTexts(messages, count);
|
|
182
|
+
const handle = stored === undefined
|
|
183
|
+
? "none (无全文记录:Worker 还没结束过,或没有 assistant 文本)"
|
|
184
|
+
: `${stored.length} chars${stored.truncated ? ` (仅存前 ${stored.text.length})` : ""},用 report_read 读取全文`;
|
|
185
|
+
const lines = worker === undefined
|
|
186
|
+
? [`worker ${input.sessionID}: not tracked by the router`]
|
|
187
|
+
: [
|
|
188
|
+
`worker ${worker.sessionID} ${worker.title ?? "-"} [${worker.threads.join(",") || "-"}] ${worker.state}`,
|
|
189
|
+
`directory: ${worker.directory ?? "(gateway default)"}`,
|
|
190
|
+
`report: ${worker.lastReport ?? "none"}`,
|
|
191
|
+
`report full: ${handle}`,
|
|
192
|
+
];
|
|
193
|
+
const perMessage = messageItemLimit(ctx);
|
|
194
|
+
const hint = `用 report_read(sessionID: "${input.sessionID}") 读取全文`;
|
|
195
|
+
lines.push(`recent assistant messages (last ${texts.length} of up to ${count}):`);
|
|
196
|
+
texts.forEach((text, index) => {
|
|
197
|
+
lines.push(`${index + 1}. ${truncate(text, perMessage, hint)}`);
|
|
198
|
+
});
|
|
199
|
+
if (texts.length === 0)
|
|
200
|
+
lines.push("(no assistant text found)");
|
|
201
|
+
const text = fitBudget(lines.join("\n"));
|
|
202
|
+
return {
|
|
203
|
+
output: { text },
|
|
204
|
+
content: text,
|
|
205
|
+
};
|
|
206
|
+
});
|
|
207
|
+
/** Page through the full report kept at `report:<sessionID>`. */
|
|
208
|
+
export const runReportRead = Effect.fn("superagent.reportRead")(function* (ctx, input, caller) {
|
|
209
|
+
yield* requireRouter(caller, "report_read");
|
|
210
|
+
const record = yield* loadReport(ctx, input.sessionID);
|
|
211
|
+
if (record === undefined) {
|
|
212
|
+
return yield* new Tool.Error({
|
|
213
|
+
message: `no stored report for ${input.sessionID}: 该 Worker 没有全文报告(可能还没结束过,或没有 assistant 文本);先用 worker_detail 看摘要`,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
const rawOffset = input.offset ?? 0;
|
|
217
|
+
const offset = Number.isFinite(rawOffset) ? Math.max(0, Math.floor(rawOffset)) : 0;
|
|
218
|
+
if (offset >= record.text.length) {
|
|
219
|
+
return yield* new Tool.Error({
|
|
220
|
+
message: `offset ${offset} 超出已存全文长度 ${record.text.length}${record.truncated ? `(原文 ${record.length} 字,已截断存储)` : ""}`,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
const rawLimit = input.limit ?? REPORT_DEFAULT_LIMIT;
|
|
224
|
+
const limit = Number.isFinite(rawLimit)
|
|
225
|
+
? Math.min(REPORT_MAX_LIMIT, Math.max(1, Math.floor(rawLimit)))
|
|
226
|
+
: REPORT_DEFAULT_LIMIT;
|
|
227
|
+
const slice = sliceReport(record, offset, limit);
|
|
228
|
+
const lines = [
|
|
229
|
+
`report ${record.sessionID}: 全文 ${record.length} 字,已存 ${record.text.length} 字${record.truncated ? `(超出 ${REPORT_TEXT_LIMIT} 字上限,已截断保存)` : ""}`,
|
|
230
|
+
`captured ${new Date(record.capturedAt).toISOString()} · 本次 [${slice.offset}, ${slice.end}) · limit=${limit}`,
|
|
231
|
+
"",
|
|
232
|
+
slice.text,
|
|
233
|
+
];
|
|
234
|
+
if (slice.hasMore) {
|
|
235
|
+
lines.push("", `还有后续:report_read(sessionID: "${record.sessionID}", offset: ${slice.nextOffset})`);
|
|
236
|
+
}
|
|
237
|
+
else {
|
|
238
|
+
lines.push("", "已到末尾。");
|
|
239
|
+
}
|
|
240
|
+
const text = lines.join("\n");
|
|
241
|
+
return { output: { text }, content: text };
|
|
242
|
+
});
|
|
243
|
+
const TRIGGER_HEADER = /^\[trigger:(.+?)\] source=(\S+) thread=(\S+) author=(\S+) at=(\S+)(?:\n([\s\S]*))?$/;
|
|
244
|
+
/** Parse the worker-visible trigger format back into a record (see `formatTrigger`). */
|
|
245
|
+
function parseTriggerHeader(text) {
|
|
246
|
+
const match = TRIGGER_HEADER.exec(text);
|
|
247
|
+
if (match === null)
|
|
248
|
+
return undefined;
|
|
249
|
+
const id = match[1];
|
|
250
|
+
const source = match[2];
|
|
251
|
+
const threadID = match[3];
|
|
252
|
+
const author = match[4];
|
|
253
|
+
const at = match[5];
|
|
254
|
+
if (id === undefined || source === undefined || threadID === undefined || author === undefined || at === undefined) {
|
|
255
|
+
return undefined;
|
|
256
|
+
}
|
|
257
|
+
const parsed = Date.parse(at);
|
|
258
|
+
return {
|
|
259
|
+
id,
|
|
260
|
+
source,
|
|
261
|
+
threadID,
|
|
262
|
+
...(author === "unknown" ? {} : { author }),
|
|
263
|
+
text: match[6] ?? "",
|
|
264
|
+
createdAt: Number.isNaN(parsed) ? Date.now() : parsed,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Router-only recall tools plus capture of triggers delivered straight to
|
|
269
|
+
* workers (they never pass the pending queue, so the prompt hook records
|
|
270
|
+
* them from the admitted text). Both tools reuse the `status` permission
|
|
271
|
+
* action, which the router session already allows.
|
|
272
|
+
*/
|
|
273
|
+
export const registerRecall = Effect.fn("superagent.registerRecall")(function* (ctx) {
|
|
274
|
+
yield* ctx.tool.transform((editor) => {
|
|
275
|
+
editor.add({
|
|
276
|
+
name: "thread_detail",
|
|
277
|
+
description: specFor("thread_detail").description,
|
|
278
|
+
input: ThreadDetailInput,
|
|
279
|
+
output: DetailOutput,
|
|
280
|
+
options: { permission: toolPermission(specFor("thread_detail")) },
|
|
281
|
+
execute: (input, context) => runThreadDetail(ctx, input, context.sessionID),
|
|
282
|
+
});
|
|
283
|
+
editor.add({
|
|
284
|
+
name: "worker_detail",
|
|
285
|
+
description: specFor("worker_detail").description,
|
|
286
|
+
input: WorkerDetailInput,
|
|
287
|
+
output: DetailOutput,
|
|
288
|
+
options: { permission: toolPermission(specFor("worker_detail")) },
|
|
289
|
+
execute: (input, context) => runWorkerDetail(ctx, input, context.sessionID),
|
|
290
|
+
});
|
|
291
|
+
editor.add({
|
|
292
|
+
name: "report_read",
|
|
293
|
+
description: specFor("report_read").description,
|
|
294
|
+
input: ReportReadInput,
|
|
295
|
+
output: DetailOutput,
|
|
296
|
+
options: { permission: toolPermission(specFor("report_read")) },
|
|
297
|
+
execute: (input, context) => runReportRead(ctx, input, context.sessionID),
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
yield* ctx.session.hook("prompt", (event) => Effect.gen(function* () {
|
|
301
|
+
if (!store.workers.has(event.sessionID))
|
|
302
|
+
return;
|
|
303
|
+
const parsed = parseTriggerHeader(event.prompt.text);
|
|
304
|
+
if (parsed === undefined)
|
|
305
|
+
return;
|
|
306
|
+
const record = upsertDirectTrigger(parsed, event.sessionID);
|
|
307
|
+
yield* saveTrigger(ctx, record);
|
|
308
|
+
}).pipe(Effect.catch((cause) => Effect.logWarning("superagent failed to record direct trigger", { cause }))));
|
|
309
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode/plugin/effect";
|
|
2
|
+
import type { Schema } from "effect";
|
|
3
|
+
import { Effect } from "effect";
|
|
4
|
+
/**
|
|
5
|
+
* Full worker reports ("spill") kept beside the compact `worker:<id>` record.
|
|
6
|
+
*
|
|
7
|
+
* The worker record keeps only a summary (`lastReport`), because it is copied
|
|
8
|
+
* into every router status line and context. The complete text lives under
|
|
9
|
+
* `report:<sessionID>` so `report_read` can page through it after the fact.
|
|
10
|
+
* Reports are intentionally not removed when the worker record disappears: a
|
|
11
|
+
* supervisor may still need the evidence. Growth is only measured and logged
|
|
12
|
+
* (see `hydrateReports`); automatic trimming is a later, separate policy.
|
|
13
|
+
*/
|
|
14
|
+
export declare const REPORT_PREFIX = "report:";
|
|
15
|
+
/** Hard cap for one stored report. Longer reports are clipped and marked, never silently lost. */
|
|
16
|
+
export declare const REPORT_TEXT_LIMIT = 200000;
|
|
17
|
+
/** How much of a report the worker record keeps for status lines and router injections. */
|
|
18
|
+
export declare const REPORT_SUMMARY_LIMIT = 2000;
|
|
19
|
+
/** `report_read` paging: default page size, hard cap, and the paging floor. */
|
|
20
|
+
export declare const REPORT_DEFAULT_LIMIT = 4000;
|
|
21
|
+
export declare const REPORT_MAX_LIMIT = 20000;
|
|
22
|
+
export declare const REPORT_MIN_LIMIT = 1;
|
|
23
|
+
export interface ReportRecord {
|
|
24
|
+
readonly sessionID: string;
|
|
25
|
+
/** Stored text: the original, or its first `REPORT_TEXT_LIMIT` characters when clipped. */
|
|
26
|
+
readonly text: string;
|
|
27
|
+
/** Length of the original text, before any clipping. */
|
|
28
|
+
readonly length: number;
|
|
29
|
+
readonly capturedAt: number;
|
|
30
|
+
readonly truncated: boolean;
|
|
31
|
+
/** Character offset where the stored text stops, present only when `truncated`. */
|
|
32
|
+
readonly clippedAt?: number;
|
|
33
|
+
}
|
|
34
|
+
export interface ReportSlice {
|
|
35
|
+
readonly text: string;
|
|
36
|
+
readonly offset: number;
|
|
37
|
+
readonly end: number;
|
|
38
|
+
/** Length of the original report. */
|
|
39
|
+
readonly total: number;
|
|
40
|
+
/** Length of the stored text, which is smaller than `total` for clipped reports. */
|
|
41
|
+
readonly stored: number;
|
|
42
|
+
readonly hasMore: boolean;
|
|
43
|
+
readonly nextOffset?: number;
|
|
44
|
+
}
|
|
45
|
+
export interface ReportStats {
|
|
46
|
+
readonly entries: number;
|
|
47
|
+
readonly chars: number;
|
|
48
|
+
readonly truncated: number;
|
|
49
|
+
}
|
|
50
|
+
export declare const reportKey: (sessionID: string) => string;
|
|
51
|
+
/** Capture-time copy of the text, clipped to the hard cap and tagged with its metadata. */
|
|
52
|
+
export declare function buildReport(sessionID: string, text: string, capturedAt?: number): ReportRecord;
|
|
53
|
+
/** The compact form kept in `worker:<id>.lastReport`. */
|
|
54
|
+
export declare function reportSummary(record: ReportRecord): string;
|
|
55
|
+
/** One router-facing line that names the full-text handle. */
|
|
56
|
+
export declare function reportNotice(record: ReportRecord): string;
|
|
57
|
+
/** Page through a stored report; the caller validates offset against `stored`. */
|
|
58
|
+
export declare function sliceReport(record: ReportRecord, offset: number, limit: number): ReportSlice;
|
|
59
|
+
export declare function encodeReport(record: ReportRecord): Schema.Json;
|
|
60
|
+
export declare function decodeReport(value: Schema.Json | undefined): ReportRecord | undefined;
|
|
61
|
+
export declare const saveReport: (ctx: Plugin.Context, record: ReportRecord) => Effect.Effect<void, never, never>;
|
|
62
|
+
export declare const loadReport: (ctx: Plugin.Context, sessionID: string) => Effect.Effect<ReportRecord | undefined, never, never>;
|
|
63
|
+
export declare const reportStats: () => ReportStats;
|
|
64
|
+
/** Fixed thresholds used to warn about unbounded spill growth at startup. */
|
|
65
|
+
export declare const REPORT_ENTRY_WARN = 500;
|
|
66
|
+
export declare const REPORT_CHARS_WARN = 20000000;
|
|
67
|
+
/**
|
|
68
|
+
* Measure stored reports once at startup. Nothing is deleted here: dropping a
|
|
69
|
+
* report is a deliberate retention decision, not a side effect of hydration.
|
|
70
|
+
* Undecodable values are removed because they can never be read back.
|
|
71
|
+
*/
|
|
72
|
+
export declare const hydrateReports: (ctx: Plugin.Context) => Effect.Effect<void, never, never>;
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
/**
|
|
3
|
+
* Full worker reports ("spill") kept beside the compact `worker:<id>` record.
|
|
4
|
+
*
|
|
5
|
+
* The worker record keeps only a summary (`lastReport`), because it is copied
|
|
6
|
+
* into every router status line and context. The complete text lives under
|
|
7
|
+
* `report:<sessionID>` so `report_read` can page through it after the fact.
|
|
8
|
+
* Reports are intentionally not removed when the worker record disappears: a
|
|
9
|
+
* supervisor may still need the evidence. Growth is only measured and logged
|
|
10
|
+
* (see `hydrateReports`); automatic trimming is a later, separate policy.
|
|
11
|
+
*/
|
|
12
|
+
export const REPORT_PREFIX = "report:";
|
|
13
|
+
/** Hard cap for one stored report. Longer reports are clipped and marked, never silently lost. */
|
|
14
|
+
export const REPORT_TEXT_LIMIT = 200_000;
|
|
15
|
+
/** How much of a report the worker record keeps for status lines and router injections. */
|
|
16
|
+
export const REPORT_SUMMARY_LIMIT = 2_000;
|
|
17
|
+
/** `report_read` paging: default page size, hard cap, and the paging floor. */
|
|
18
|
+
export const REPORT_DEFAULT_LIMIT = 4_000;
|
|
19
|
+
export const REPORT_MAX_LIMIT = 20_000;
|
|
20
|
+
export const REPORT_MIN_LIMIT = 1;
|
|
21
|
+
export const reportKey = (sessionID) => `${REPORT_PREFIX}${sessionID}`;
|
|
22
|
+
/** Capture-time copy of the text, clipped to the hard cap and tagged with its metadata. */
|
|
23
|
+
export function buildReport(sessionID, text, capturedAt = Date.now()) {
|
|
24
|
+
if (text.length <= REPORT_TEXT_LIMIT) {
|
|
25
|
+
return { sessionID, text, length: text.length, capturedAt, truncated: false };
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
sessionID,
|
|
29
|
+
text: text.slice(0, REPORT_TEXT_LIMIT),
|
|
30
|
+
length: text.length,
|
|
31
|
+
capturedAt,
|
|
32
|
+
truncated: true,
|
|
33
|
+
clippedAt: REPORT_TEXT_LIMIT,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/** The compact form kept in `worker:<id>.lastReport`. */
|
|
37
|
+
export function reportSummary(record) {
|
|
38
|
+
if (record.text.length <= REPORT_SUMMARY_LIMIT)
|
|
39
|
+
return record.text;
|
|
40
|
+
return `${record.text.slice(0, REPORT_SUMMARY_LIMIT)}…`;
|
|
41
|
+
}
|
|
42
|
+
/** One router-facing line that names the full-text handle. */
|
|
43
|
+
export function reportNotice(record) {
|
|
44
|
+
const clip = record.truncated ? `,超出 ${REPORT_TEXT_LIMIT} 字上限仅存前 ${record.text.length} 字` : "";
|
|
45
|
+
return `全文长度 ${record.length}${clip},可用 report_read 读取(sessionID: ${record.sessionID})`;
|
|
46
|
+
}
|
|
47
|
+
/** Page through a stored report; the caller validates offset against `stored`. */
|
|
48
|
+
export function sliceReport(record, offset, limit) {
|
|
49
|
+
const stored = record.text.length;
|
|
50
|
+
const start = Math.min(Math.max(0, offset), stored);
|
|
51
|
+
const end = Math.min(stored, start + Math.max(REPORT_MIN_LIMIT, limit));
|
|
52
|
+
const hasMore = end < stored;
|
|
53
|
+
return {
|
|
54
|
+
text: record.text.slice(start, end),
|
|
55
|
+
offset: start,
|
|
56
|
+
end,
|
|
57
|
+
total: record.length,
|
|
58
|
+
stored,
|
|
59
|
+
hasMore,
|
|
60
|
+
...(hasMore ? { nextOffset: end } : {}),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
function isRecord(value) {
|
|
64
|
+
return typeof value === "object" && value !== null;
|
|
65
|
+
}
|
|
66
|
+
function optionalNumber(record, key) {
|
|
67
|
+
const value = record[key];
|
|
68
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
69
|
+
}
|
|
70
|
+
export function encodeReport(record) {
|
|
71
|
+
return {
|
|
72
|
+
sessionID: record.sessionID,
|
|
73
|
+
text: record.text,
|
|
74
|
+
length: record.length,
|
|
75
|
+
capturedAt: record.capturedAt,
|
|
76
|
+
truncated: record.truncated,
|
|
77
|
+
...(record.clippedAt === undefined ? {} : { clippedAt: record.clippedAt }),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
export function decodeReport(value) {
|
|
81
|
+
if (!isRecord(value))
|
|
82
|
+
return undefined;
|
|
83
|
+
if (typeof value["sessionID"] !== "string")
|
|
84
|
+
return undefined;
|
|
85
|
+
if (typeof value["text"] !== "string")
|
|
86
|
+
return undefined;
|
|
87
|
+
const length = optionalNumber(value, "length");
|
|
88
|
+
const capturedAt = optionalNumber(value, "capturedAt");
|
|
89
|
+
if (length === undefined || capturedAt === undefined)
|
|
90
|
+
return undefined;
|
|
91
|
+
const clippedAt = optionalNumber(value, "clippedAt");
|
|
92
|
+
return {
|
|
93
|
+
sessionID: value["sessionID"],
|
|
94
|
+
text: value["text"],
|
|
95
|
+
length,
|
|
96
|
+
capturedAt,
|
|
97
|
+
truncated: value["truncated"] === true,
|
|
98
|
+
...(clippedAt === undefined ? {} : { clippedAt }),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
export const saveReport = Effect.fn("superagent.saveReport")(function* (ctx, record) {
|
|
102
|
+
yield* ctx.storage
|
|
103
|
+
.set(reportKey(record.sessionID), encodeReport(record))
|
|
104
|
+
.pipe(Effect.catch((cause) => Effect.logWarning("superagent failed to persist report", { sessionID: record.sessionID, cause })));
|
|
105
|
+
});
|
|
106
|
+
export const loadReport = Effect.fn("superagent.loadReport")(function* (ctx, sessionID) {
|
|
107
|
+
const value = yield* ctx.storage.get(reportKey(sessionID)).pipe(Effect.catch(() => Effect.succeed(undefined)));
|
|
108
|
+
return decodeReport(value);
|
|
109
|
+
});
|
|
110
|
+
let stats = { entries: 0, chars: 0, truncated: 0 };
|
|
111
|
+
export const reportStats = () => stats;
|
|
112
|
+
/** Fixed thresholds used to warn about unbounded spill growth at startup. */
|
|
113
|
+
export const REPORT_ENTRY_WARN = 500;
|
|
114
|
+
export const REPORT_CHARS_WARN = 20_000_000;
|
|
115
|
+
/**
|
|
116
|
+
* Measure stored reports once at startup. Nothing is deleted here: dropping a
|
|
117
|
+
* report is a deliberate retention decision, not a side effect of hydration.
|
|
118
|
+
* Undecodable values are removed because they can never be read back.
|
|
119
|
+
*/
|
|
120
|
+
export const hydrateReports = Effect.fn("superagent.hydrateReports")(function* (ctx) {
|
|
121
|
+
let entries = 0;
|
|
122
|
+
let chars = 0;
|
|
123
|
+
let truncated = 0;
|
|
124
|
+
let after;
|
|
125
|
+
do {
|
|
126
|
+
const page = yield* ctx.storage
|
|
127
|
+
.scan({ prefix: REPORT_PREFIX, after, limit: 100 })
|
|
128
|
+
.pipe(Effect.catch(() => Effect.succeed({ entries: [], next: undefined })));
|
|
129
|
+
for (const entry of page.entries) {
|
|
130
|
+
const record = decodeReport(entry.value);
|
|
131
|
+
if (record === undefined) {
|
|
132
|
+
yield* ctx.storage.remove(entry.key).pipe(Effect.catch(() => Effect.void));
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
entries += 1;
|
|
136
|
+
chars += record.length;
|
|
137
|
+
if (record.truncated)
|
|
138
|
+
truncated += 1;
|
|
139
|
+
}
|
|
140
|
+
after = page.next;
|
|
141
|
+
} while (after !== undefined);
|
|
142
|
+
stats = { entries, chars, truncated };
|
|
143
|
+
yield* Effect.logInfo("superagent report spill measured", stats);
|
|
144
|
+
if (entries > REPORT_ENTRY_WARN || chars > REPORT_CHARS_WARN) {
|
|
145
|
+
yield* Effect.logWarning("superagent report spill is growing; a retention policy (per-thread cap or age) is due", stats);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type Plugin } from "@opencode/plugin/effect";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
import { type TriggerInput } from "./state.js";
|
|
4
|
+
/** KV prefix for crash-safe pending triggers. Owned by the router; `state.ts` stays untouched. */
|
|
5
|
+
export declare const PENDING_PREFIX = "pending:";
|
|
6
|
+
/**
|
|
7
|
+
* Enqueue a trigger in memory and in KV. The trigger gets a stable id when
|
|
8
|
+
* the caller did not supply one, so a crash-restarted process can dedupe and
|
|
9
|
+
* rehydrate it. Same-id enqueues are ignored.
|
|
10
|
+
*/
|
|
11
|
+
export declare const enqueuePending: (ctx: Plugin.Context, input: TriggerInput) => Effect.Effect<void, never, never>;
|
|
12
|
+
/**
|
|
13
|
+
* Reload unflushed triggers left in KV by a crashed process back into the
|
|
14
|
+
* memory queue. Never creates a router session: the queue waits until the
|
|
15
|
+
* next `router` RPC or trigger asks for a flush.
|
|
16
|
+
*/
|
|
17
|
+
export declare const hydratePending: (ctx: Plugin.Context) => Effect.Effect<void, never, never>;
|
|
18
|
+
/**
|
|
19
|
+
* Return the live router session, creating it when missing. A router id
|
|
20
|
+
* whose session no longer exists is forgotten before creating a new one,
|
|
21
|
+
* so a user-deleted router is recovered automatically.
|
|
22
|
+
*/
|
|
23
|
+
export declare const ensureRouter: (ctx: Plugin.Context) => Effect.Effect<string, unknown, never>;
|
|
24
|
+
/**
|
|
25
|
+
* Merge queued triggers into one synthetic prompt for the router. Items
|
|
26
|
+
* leave the queue (memory and KV) only after the injection succeeds; on
|
|
27
|
+
* failure they stay queued for the next attempt.
|
|
28
|
+
*/
|
|
29
|
+
export declare const flushPending: (ctx: Plugin.Context) => Effect.Effect<void, unknown, never>;
|
|
30
|
+
/**
|
|
31
|
+
* Router idle hook: when the router goes idle with nothing pending in the
|
|
32
|
+
* background, flush queued triggers into the same execution. An empty queue
|
|
33
|
+
* returns without admitting input, so the hook never loops on its own.
|
|
34
|
+
*/
|
|
35
|
+
export declare const registerRouterIdle: (ctx: Plugin.Context) => Effect.Effect<void, never, import("effect/Scope").Scope>;
|