opencode-superagent 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 +336 -2
- package/agent/superagent-router.md +7 -0
- package/agent/superagent-worker.md +10 -0
- package/dist/server.d.ts +237 -0
- package/dist/server.js +148 -0
- package/dist/src/abort.d.ts +31 -0
- package/dist/src/abort.js +171 -0
- package/dist/src/agents.d.ts +51 -0
- package/dist/src/agents.js +83 -0
- package/dist/src/context.d.ts +27 -0
- package/dist/src/context.js +114 -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 +296 -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 +37 -0
- package/dist/src/protocol.d.ts +219 -0
- package/dist/src/protocol.js +95 -0
- package/dist/src/recall.d.ts +58 -0
- package/dist/src/recall.js +309 -0
- package/dist/src/report.d.ts +78 -0
- package/dist/src/report.js +176 -0
- package/dist/src/router.d.ts +34 -0
- package/dist/src/router.js +234 -0
- package/dist/src/session-meta.d.ts +42 -0
- package/dist/src/session-meta.js +91 -0
- package/dist/src/sleep.d.ts +35 -0
- package/dist/src/sleep.js +58 -0
- package/dist/src/state.d.ts +126 -0
- package/dist/src/state.js +480 -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 +253 -0
- package/dist/src/ticket.js +717 -0
- package/dist/src/tools.d.ts +85 -0
- package/dist/src/tools.js +392 -0
- package/dist/src/watchdog.d.ts +23 -0
- package/dist/src/watchdog.js +94 -0
- package/dist/src/watcher.d.ts +17 -0
- package/dist/src/watcher.js +165 -0
- package/dist/tui.d.ts +3 -0
- package/dist/tui.js +1057 -0
- package/package.json +80 -4
|
@@ -0,0 +1,78 @@
|
|
|
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
|
+
/**
|
|
58
|
+
* Last non-empty assistant text in a session-context snapshot. Shared by the
|
|
59
|
+
* live watcher and startup hydration so a crash-recovered worker gets the
|
|
60
|
+
* same report extraction as one that ended while the plugin was running.
|
|
61
|
+
*/
|
|
62
|
+
export declare function lastAssistantText(messages: ReadonlyArray<unknown>): string | undefined;
|
|
63
|
+
/** Page through a stored report; the caller validates offset against `stored`. */
|
|
64
|
+
export declare function sliceReport(record: ReportRecord, offset: number, limit: number): ReportSlice;
|
|
65
|
+
export declare function encodeReport(record: ReportRecord): Schema.Json;
|
|
66
|
+
export declare function decodeReport(value: Schema.Json | undefined): ReportRecord | undefined;
|
|
67
|
+
export declare const saveReport: (ctx: Plugin.Context, record: ReportRecord) => Effect.Effect<void, never, never>;
|
|
68
|
+
export declare const loadReport: (ctx: Plugin.Context, sessionID: string) => Effect.Effect<ReportRecord | undefined, never, never>;
|
|
69
|
+
export declare const reportStats: () => ReportStats;
|
|
70
|
+
/** Fixed thresholds used to warn about unbounded spill growth at startup. */
|
|
71
|
+
export declare const REPORT_ENTRY_WARN = 500;
|
|
72
|
+
export declare const REPORT_CHARS_WARN = 20000000;
|
|
73
|
+
/**
|
|
74
|
+
* Measure stored reports once at startup. Nothing is deleted here: dropping a
|
|
75
|
+
* report is a deliberate retention decision, not a side effect of hydration.
|
|
76
|
+
* Undecodable values are removed because they can never be read back.
|
|
77
|
+
*/
|
|
78
|
+
export declare const hydrateReports: (ctx: Plugin.Context) => Effect.Effect<void, never, never>;
|
|
@@ -0,0 +1,176 @@
|
|
|
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
|
+
/**
|
|
48
|
+
* Last non-empty assistant text in a session-context snapshot. Shared by the
|
|
49
|
+
* live watcher and startup hydration so a crash-recovered worker gets the
|
|
50
|
+
* same report extraction as one that ended while the plugin was running.
|
|
51
|
+
*/
|
|
52
|
+
export function lastAssistantText(messages) {
|
|
53
|
+
for (let index = messages.length - 1; index >= 0; index--) {
|
|
54
|
+
const message = messages[index];
|
|
55
|
+
if (typeof message !== "object" || message === null)
|
|
56
|
+
continue;
|
|
57
|
+
const typed = message;
|
|
58
|
+
if (typed.type !== "assistant")
|
|
59
|
+
continue;
|
|
60
|
+
if (!Array.isArray(typed.content))
|
|
61
|
+
continue;
|
|
62
|
+
const text = typed.content
|
|
63
|
+
.flatMap((part) => {
|
|
64
|
+
if (typeof part !== "object" || part === null)
|
|
65
|
+
return [];
|
|
66
|
+
const candidate = part;
|
|
67
|
+
return candidate.type === "text" && typeof candidate.text === "string" ? [candidate.text] : [];
|
|
68
|
+
})
|
|
69
|
+
.join("\n")
|
|
70
|
+
.trim();
|
|
71
|
+
if (text !== "")
|
|
72
|
+
return text;
|
|
73
|
+
}
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
/** Page through a stored report; the caller validates offset against `stored`. */
|
|
77
|
+
export function sliceReport(record, offset, limit) {
|
|
78
|
+
const stored = record.text.length;
|
|
79
|
+
const start = Math.min(Math.max(0, offset), stored);
|
|
80
|
+
const end = Math.min(stored, start + Math.max(REPORT_MIN_LIMIT, limit));
|
|
81
|
+
const hasMore = end < stored;
|
|
82
|
+
return {
|
|
83
|
+
text: record.text.slice(start, end),
|
|
84
|
+
offset: start,
|
|
85
|
+
end,
|
|
86
|
+
total: record.length,
|
|
87
|
+
stored,
|
|
88
|
+
hasMore,
|
|
89
|
+
...(hasMore ? { nextOffset: end } : {}),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
function isRecord(value) {
|
|
93
|
+
return typeof value === "object" && value !== null;
|
|
94
|
+
}
|
|
95
|
+
function optionalNumber(record, key) {
|
|
96
|
+
const value = record[key];
|
|
97
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
98
|
+
}
|
|
99
|
+
export function encodeReport(record) {
|
|
100
|
+
return {
|
|
101
|
+
sessionID: record.sessionID,
|
|
102
|
+
text: record.text,
|
|
103
|
+
length: record.length,
|
|
104
|
+
capturedAt: record.capturedAt,
|
|
105
|
+
truncated: record.truncated,
|
|
106
|
+
...(record.clippedAt === undefined ? {} : { clippedAt: record.clippedAt }),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
export function decodeReport(value) {
|
|
110
|
+
if (!isRecord(value))
|
|
111
|
+
return undefined;
|
|
112
|
+
if (typeof value["sessionID"] !== "string")
|
|
113
|
+
return undefined;
|
|
114
|
+
if (typeof value["text"] !== "string")
|
|
115
|
+
return undefined;
|
|
116
|
+
const length = optionalNumber(value, "length");
|
|
117
|
+
const capturedAt = optionalNumber(value, "capturedAt");
|
|
118
|
+
if (length === undefined || capturedAt === undefined)
|
|
119
|
+
return undefined;
|
|
120
|
+
const clippedAt = optionalNumber(value, "clippedAt");
|
|
121
|
+
return {
|
|
122
|
+
sessionID: value["sessionID"],
|
|
123
|
+
text: value["text"],
|
|
124
|
+
length,
|
|
125
|
+
capturedAt,
|
|
126
|
+
truncated: value["truncated"] === true,
|
|
127
|
+
...(clippedAt === undefined ? {} : { clippedAt }),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
export const saveReport = Effect.fn("superagent.saveReport")(function* (ctx, record) {
|
|
131
|
+
yield* ctx.storage
|
|
132
|
+
.set(reportKey(record.sessionID), encodeReport(record))
|
|
133
|
+
.pipe(Effect.catch((cause) => Effect.logWarning("superagent failed to persist report", { sessionID: record.sessionID, cause })));
|
|
134
|
+
});
|
|
135
|
+
export const loadReport = Effect.fn("superagent.loadReport")(function* (ctx, sessionID) {
|
|
136
|
+
const value = yield* ctx.storage.get(reportKey(sessionID)).pipe(Effect.catch(() => Effect.succeed(undefined)));
|
|
137
|
+
return decodeReport(value);
|
|
138
|
+
});
|
|
139
|
+
let stats = { entries: 0, chars: 0, truncated: 0 };
|
|
140
|
+
export const reportStats = () => stats;
|
|
141
|
+
/** Fixed thresholds used to warn about unbounded spill growth at startup. */
|
|
142
|
+
export const REPORT_ENTRY_WARN = 500;
|
|
143
|
+
export const REPORT_CHARS_WARN = 20_000_000;
|
|
144
|
+
/**
|
|
145
|
+
* Measure stored reports once at startup. Nothing is deleted here: dropping a
|
|
146
|
+
* report is a deliberate retention decision, not a side effect of hydration.
|
|
147
|
+
* Undecodable values are removed because they can never be read back.
|
|
148
|
+
*/
|
|
149
|
+
export const hydrateReports = Effect.fn("superagent.hydrateReports")(function* (ctx) {
|
|
150
|
+
let entries = 0;
|
|
151
|
+
let chars = 0;
|
|
152
|
+
let truncated = 0;
|
|
153
|
+
let after;
|
|
154
|
+
do {
|
|
155
|
+
const page = yield* ctx.storage
|
|
156
|
+
.scan({ prefix: REPORT_PREFIX, after, limit: 100 })
|
|
157
|
+
.pipe(Effect.catch(() => Effect.succeed({ entries: [], next: undefined })));
|
|
158
|
+
for (const entry of page.entries) {
|
|
159
|
+
const record = decodeReport(entry.value);
|
|
160
|
+
if (record === undefined) {
|
|
161
|
+
yield* ctx.storage.remove(entry.key).pipe(Effect.catch(() => Effect.void));
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
entries += 1;
|
|
165
|
+
chars += record.length;
|
|
166
|
+
if (record.truncated)
|
|
167
|
+
truncated += 1;
|
|
168
|
+
}
|
|
169
|
+
after = page.next;
|
|
170
|
+
} while (after !== undefined);
|
|
171
|
+
stats = { entries, chars, truncated };
|
|
172
|
+
yield* Effect.logInfo("superagent report spill measured", stats);
|
|
173
|
+
if (entries > REPORT_ENTRY_WARN || chars > REPORT_CHARS_WARN) {
|
|
174
|
+
yield* Effect.logWarning("superagent report spill is growing; a retention policy (per-thread cap or age) is due", stats);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
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
|
+
* Serialised entry point for every flush caller (idle hook, RPC, tests).
|
|
26
|
+
* Concurrent calls wait on `flushLock`, so one batch is delivered once.
|
|
27
|
+
*/
|
|
28
|
+
export declare const flushPending: (ctx: Plugin.Context) => Effect.Effect<void, unknown, never>;
|
|
29
|
+
/**
|
|
30
|
+
* Router idle hook: when the router goes idle with nothing pending in the
|
|
31
|
+
* background, flush queued triggers into the same execution. An empty queue
|
|
32
|
+
* returns without admitting input, so the hook never loops on its own.
|
|
33
|
+
*/
|
|
34
|
+
export declare const registerRouterIdle: (ctx: Plugin.Context) => Effect.Effect<void, never, import("effect/Scope").Scope>;
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { Agent } from "@opencode/plugin/effect";
|
|
2
|
+
import { Effect, Option, Semaphore } from "effect";
|
|
3
|
+
import { ROUTER_AGENT } from "./agents.js";
|
|
4
|
+
import { threadDirHint } from "./dirs.js";
|
|
5
|
+
import { formatTrigger } from "./format.js";
|
|
6
|
+
import { ROUTER_TITLE, superagentMetadata } from "./session-meta.js";
|
|
7
|
+
import { ROUTER_KEY, store, toSessionID } from "./state.js";
|
|
8
|
+
import { noteRouterTrigger } from "./ticket.js";
|
|
9
|
+
import { ROUTER_PERMISSIONS } from "./tools.js";
|
|
10
|
+
const MAX_FLUSH = 20;
|
|
11
|
+
/**
|
|
12
|
+
* Serialise flushes. A second caller waits for the in-flight flush to finish,
|
|
13
|
+
* then observes the drained queue and only flushes events enqueued meanwhile,
|
|
14
|
+
* so the same batch is never injected twice. Effect releases the permit on
|
|
15
|
+
* success, failure and interruption.
|
|
16
|
+
*/
|
|
17
|
+
const flushLock = Semaphore.makeUnsafe(1);
|
|
18
|
+
/** KV prefix for crash-safe pending triggers. Owned by the router; `state.ts` stays untouched. */
|
|
19
|
+
export const PENDING_PREFIX = "pending:";
|
|
20
|
+
const pendingKey = (id) => `${PENDING_PREFIX}${id}`;
|
|
21
|
+
/** Stable queue identity: explicit id, else source/thread/creation time. */
|
|
22
|
+
function pendingID(trigger, arrivedAt) {
|
|
23
|
+
return trigger.id ?? `${trigger.source}:${trigger.threadID}:${trigger.createdAt ?? arrivedAt}`;
|
|
24
|
+
}
|
|
25
|
+
function keyOf(item) {
|
|
26
|
+
return pendingKey(pendingID(item.trigger, item.arrivedAt));
|
|
27
|
+
}
|
|
28
|
+
function isRecord(value) {
|
|
29
|
+
return typeof value === "object" && value !== null;
|
|
30
|
+
}
|
|
31
|
+
function optionalString(record, key) {
|
|
32
|
+
const value = record[key];
|
|
33
|
+
return typeof value === "string" ? value : undefined;
|
|
34
|
+
}
|
|
35
|
+
function optionalNumber(record, key) {
|
|
36
|
+
const value = record[key];
|
|
37
|
+
return typeof value === "number" ? value : undefined;
|
|
38
|
+
}
|
|
39
|
+
function decodePending(value) {
|
|
40
|
+
if (!isRecord(value))
|
|
41
|
+
return undefined;
|
|
42
|
+
const raw = value["trigger"];
|
|
43
|
+
if (!isRecord(raw))
|
|
44
|
+
return undefined;
|
|
45
|
+
if (typeof raw["source"] !== "string" || typeof raw["threadID"] !== "string")
|
|
46
|
+
return undefined;
|
|
47
|
+
if (typeof raw["text"] !== "string")
|
|
48
|
+
return undefined;
|
|
49
|
+
const arrivedAt = optionalNumber(value, "arrivedAt");
|
|
50
|
+
if (arrivedAt === undefined)
|
|
51
|
+
return undefined;
|
|
52
|
+
return {
|
|
53
|
+
trigger: {
|
|
54
|
+
id: optionalString(raw, "id"),
|
|
55
|
+
source: raw["source"],
|
|
56
|
+
threadID: raw["threadID"],
|
|
57
|
+
author: optionalString(raw, "author"),
|
|
58
|
+
text: raw["text"],
|
|
59
|
+
title: optionalString(raw, "title"),
|
|
60
|
+
createdAt: optionalNumber(raw, "createdAt"),
|
|
61
|
+
},
|
|
62
|
+
arrivedAt,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function encodePending(item) {
|
|
66
|
+
return {
|
|
67
|
+
trigger: {
|
|
68
|
+
...(item.trigger.id === undefined ? {} : { id: item.trigger.id }),
|
|
69
|
+
source: item.trigger.source,
|
|
70
|
+
threadID: item.trigger.threadID,
|
|
71
|
+
...(item.trigger.author === undefined ? {} : { author: item.trigger.author }),
|
|
72
|
+
text: item.trigger.text,
|
|
73
|
+
...(item.trigger.title === undefined ? {} : { title: item.trigger.title }),
|
|
74
|
+
...(item.trigger.createdAt === undefined ? {} : { createdAt: item.trigger.createdAt }),
|
|
75
|
+
},
|
|
76
|
+
arrivedAt: item.arrivedAt,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Enqueue a trigger in memory and in KV. The trigger gets a stable id when
|
|
81
|
+
* the caller did not supply one, so a crash-restarted process can dedupe and
|
|
82
|
+
* rehydrate it. Same-id enqueues are ignored.
|
|
83
|
+
*/
|
|
84
|
+
export const enqueuePending = Effect.fn("superagent.enqueuePending")(function* (ctx, input) {
|
|
85
|
+
const arrivedAt = Date.now();
|
|
86
|
+
const id = pendingID(input, arrivedAt);
|
|
87
|
+
if (store.pending.some((item) => pendingID(item.trigger, item.arrivedAt) === id))
|
|
88
|
+
return;
|
|
89
|
+
const item = {
|
|
90
|
+
trigger: input.id === undefined ? { ...input, id } : { ...input },
|
|
91
|
+
arrivedAt,
|
|
92
|
+
};
|
|
93
|
+
store.pending.push(item);
|
|
94
|
+
yield* ctx.storage
|
|
95
|
+
.set(keyOf(item), encodePending(item))
|
|
96
|
+
.pipe(Effect.catch((cause) => Effect.logWarning("superagent failed to persist pending trigger", { cause })));
|
|
97
|
+
});
|
|
98
|
+
/**
|
|
99
|
+
* Reload unflushed triggers left in KV by a crashed process back into the
|
|
100
|
+
* memory queue. Never creates a router session: the queue waits until the
|
|
101
|
+
* next `router` RPC or trigger asks for a flush.
|
|
102
|
+
*/
|
|
103
|
+
export const hydratePending = Effect.fn("superagent.hydratePending")(function* (ctx) {
|
|
104
|
+
let after;
|
|
105
|
+
do {
|
|
106
|
+
const page = yield* ctx.storage
|
|
107
|
+
.scan({ prefix: PENDING_PREFIX, after, limit: 100 })
|
|
108
|
+
.pipe(Effect.catch(() => Effect.succeed({ entries: [], next: undefined })));
|
|
109
|
+
for (const entry of page.entries) {
|
|
110
|
+
const item = decodePending(entry.value);
|
|
111
|
+
if (item === undefined) {
|
|
112
|
+
yield* ctx.storage.remove(entry.key).pipe(Effect.catch(() => Effect.void));
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const id = pendingID(item.trigger, item.arrivedAt);
|
|
116
|
+
if (store.pending.some((existing) => pendingID(existing.trigger, existing.arrivedAt) === id))
|
|
117
|
+
continue;
|
|
118
|
+
store.pending.push(item.trigger.id === undefined ? { trigger: { ...item.trigger, id }, arrivedAt: item.arrivedAt } : item);
|
|
119
|
+
}
|
|
120
|
+
after = page.next;
|
|
121
|
+
} while (after !== undefined);
|
|
122
|
+
});
|
|
123
|
+
/**
|
|
124
|
+
* Return the live router session, creating it when missing. A router id
|
|
125
|
+
* whose session no longer exists is forgotten before creating a new one,
|
|
126
|
+
* so a user-deleted router is recovered automatically.
|
|
127
|
+
*/
|
|
128
|
+
export const ensureRouter = Effect.fn("superagent.ensureRouter")(function* (ctx) {
|
|
129
|
+
const sessions = ctx.session;
|
|
130
|
+
const storage = ctx.storage;
|
|
131
|
+
if (store.routerID !== undefined) {
|
|
132
|
+
const cached = yield* sessions.get({ sessionID: toSessionID(store.routerID) }).pipe(Effect.option);
|
|
133
|
+
if (Option.isSome(cached))
|
|
134
|
+
return store.routerID;
|
|
135
|
+
store.routerID = undefined;
|
|
136
|
+
store.routerBusy = false;
|
|
137
|
+
yield* storage.remove(ROUTER_KEY).pipe(Effect.catch(() => Effect.void));
|
|
138
|
+
}
|
|
139
|
+
const stored = yield* storage.get(ROUTER_KEY).pipe(Effect.catch(() => Effect.succeed(undefined)));
|
|
140
|
+
if (typeof stored === "string") {
|
|
141
|
+
const info = yield* sessions.get({ sessionID: toSessionID(stored) }).pipe(Effect.option);
|
|
142
|
+
if (Option.isSome(info)) {
|
|
143
|
+
store.routerID = info.value.id;
|
|
144
|
+
return store.routerID;
|
|
145
|
+
}
|
|
146
|
+
yield* storage.remove(ROUTER_KEY).pipe(Effect.catch(() => Effect.void));
|
|
147
|
+
}
|
|
148
|
+
const agents = ctx.agent;
|
|
149
|
+
const routerAgent = yield* agents.get({ agentID: Agent.ID.make(ROUTER_AGENT) }).pipe(Effect.map((result) => result.data.id), Effect.catch((cause) => Effect.logWarning("superagent router agent missing, creating session without it", { cause }).pipe(Effect.as(undefined))));
|
|
150
|
+
const created = yield* sessions.create({
|
|
151
|
+
title: ROUTER_TITLE,
|
|
152
|
+
...(routerAgent === undefined ? {} : { agent: routerAgent }),
|
|
153
|
+
metadata: superagentMetadata("router"),
|
|
154
|
+
// Derived from the tool catalog: a tool whose permission action is not
|
|
155
|
+
// allowed here is dropped from the Router's toolset by `Tool.snapshot`,
|
|
156
|
+
// so adding a tool without its action would hide it silently.
|
|
157
|
+
permissions: ROUTER_PERMISSIONS,
|
|
158
|
+
});
|
|
159
|
+
store.routerID = created.id;
|
|
160
|
+
store.routerBusy = false;
|
|
161
|
+
yield* storage
|
|
162
|
+
.set(ROUTER_KEY, created.id)
|
|
163
|
+
.pipe(Effect.catch((cause) => Effect.logWarning("superagent failed to persist router", { cause })));
|
|
164
|
+
return created.id;
|
|
165
|
+
});
|
|
166
|
+
/**
|
|
167
|
+
* Merge queued triggers into one synthetic prompt for the router. Items
|
|
168
|
+
* leave the queue (memory and KV) only after the injection succeeds; on
|
|
169
|
+
* failure they stay queued for the next attempt.
|
|
170
|
+
*/
|
|
171
|
+
const flushPendingUnlocked = Effect.fn("superagent.flushPendingUnlocked")(function* (ctx) {
|
|
172
|
+
if (store.pending.length === 0)
|
|
173
|
+
return;
|
|
174
|
+
const routerID = yield* ensureRouter(ctx);
|
|
175
|
+
const batch = store.pending.slice(0, MAX_FLUSH);
|
|
176
|
+
// Identity snapshot of this delivery. Removal below matches these ids
|
|
177
|
+
// instead of the queue prefix, so events enqueued while the injection is
|
|
178
|
+
// in flight survive.
|
|
179
|
+
const batchIDs = new Set(batch.map((item) => pendingID(item.trigger, item.arrivedAt)));
|
|
180
|
+
const text = [
|
|
181
|
+
`${batch.length} new event(s) for the router:`,
|
|
182
|
+
...batch.map((item) => {
|
|
183
|
+
const base = formatTrigger(item.trigger);
|
|
184
|
+
const hint = threadDirHint(item.trigger.threadID);
|
|
185
|
+
return hint === undefined ? base : `${base}\n${hint}`;
|
|
186
|
+
}),
|
|
187
|
+
].join("\n\n");
|
|
188
|
+
const delivered = yield* ctx.session
|
|
189
|
+
.synthetic({ sessionID: toSessionID(routerID), text, description: "superagent triggers", delivery: "queue" })
|
|
190
|
+
.pipe(Effect.option);
|
|
191
|
+
if (Option.isNone(delivered)) {
|
|
192
|
+
yield* Effect.logWarning("superagent failed to flush pending triggers, keeping them queued", {
|
|
193
|
+
count: batch.length,
|
|
194
|
+
});
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
// Remove by delivered id, never by position: the queue may have grown at
|
|
198
|
+
// the tail (or been trimmed elsewhere) while `synthetic` was awaited.
|
|
199
|
+
for (let index = store.pending.length - 1; index >= 0; index -= 1) {
|
|
200
|
+
const item = store.pending[index];
|
|
201
|
+
if (item !== undefined && batchIDs.has(pendingID(item.trigger, item.arrivedAt))) {
|
|
202
|
+
store.pending.splice(index, 1);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
// Remember what the Router was just told: `ticket_create` copies this
|
|
206
|
+
// trigger into the ticket's authorization (id + quote).
|
|
207
|
+
const newest = batch[batch.length - 1];
|
|
208
|
+
if (newest !== undefined)
|
|
209
|
+
noteRouterTrigger(newest.trigger);
|
|
210
|
+
for (const item of batch) {
|
|
211
|
+
yield* ctx.storage.remove(keyOf(item)).pipe(Effect.catch(() => Effect.void));
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
/**
|
|
215
|
+
* Serialised entry point for every flush caller (idle hook, RPC, tests).
|
|
216
|
+
* Concurrent calls wait on `flushLock`, so one batch is delivered once.
|
|
217
|
+
*/
|
|
218
|
+
export const flushPending = Effect.fn("superagent.flushPending")(function* (ctx) {
|
|
219
|
+
yield* flushLock.withPermit(flushPendingUnlocked(ctx));
|
|
220
|
+
});
|
|
221
|
+
/**
|
|
222
|
+
* Router idle hook: when the router goes idle with nothing pending in the
|
|
223
|
+
* background, flush queued triggers into the same execution. An empty queue
|
|
224
|
+
* returns without admitting input, so the hook never loops on its own.
|
|
225
|
+
*/
|
|
226
|
+
export const registerRouterIdle = Effect.fn("superagent.registerRouterIdle")(function* (ctx) {
|
|
227
|
+
yield* ctx.session.hook("experimental.idle", (event) => Effect.gen(function* () {
|
|
228
|
+
if (store.routerID === undefined || event.sessionID !== store.routerID)
|
|
229
|
+
return;
|
|
230
|
+
if (event.background.length > 0)
|
|
231
|
+
return;
|
|
232
|
+
yield* flushPending(ctx);
|
|
233
|
+
}).pipe(Effect.catch((cause) => Effect.logWarning("superagent router idle flush failed", { cause }))));
|
|
234
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode/plugin/effect";
|
|
2
|
+
import { Effect, type Schema } from "effect";
|
|
3
|
+
/**
|
|
4
|
+
* Session identity for the Super-agent prototype.
|
|
5
|
+
*
|
|
6
|
+
* A session's role lives in its durable `metadata.superagent` record, not in
|
|
7
|
+
* its title: the title is user-editable and auto-generated titles are not a
|
|
8
|
+
* contract. Every session this plugin creates writes the metadata at create
|
|
9
|
+
* time, and `hydrateSessionMeta` backfills sessions created before that.
|
|
10
|
+
*/
|
|
11
|
+
export type SuperagentRole = "router" | "worker";
|
|
12
|
+
export declare const ROUTER_TITLE = "\uD83E\uDDED router-main";
|
|
13
|
+
/** The role recorded in a session's metadata, when the metadata is a Super-agent one. */
|
|
14
|
+
export declare function superagentRole(metadata: unknown): SuperagentRole | undefined;
|
|
15
|
+
export declare function isSuperagentSession(session: {
|
|
16
|
+
readonly metadata?: unknown;
|
|
17
|
+
}): boolean;
|
|
18
|
+
export declare function isRouterSession(session: {
|
|
19
|
+
readonly metadata?: unknown;
|
|
20
|
+
}): boolean;
|
|
21
|
+
export declare function isWorkerSession(session: {
|
|
22
|
+
readonly metadata?: unknown;
|
|
23
|
+
}): boolean;
|
|
24
|
+
/** The `superagent` metadata entry written at create time. */
|
|
25
|
+
export declare function superagentMetadata(role: SuperagentRole, threads?: ReadonlyArray<string>): Record<string, Schema.Json>;
|
|
26
|
+
/**
|
|
27
|
+
* Worker session title. It is convenience only: the session stays
|
|
28
|
+
* identifiable from metadata when a user renames it.
|
|
29
|
+
*/
|
|
30
|
+
export declare function workerTitle(input: {
|
|
31
|
+
readonly task: string;
|
|
32
|
+
readonly title?: string;
|
|
33
|
+
readonly threadID?: string;
|
|
34
|
+
}): string;
|
|
35
|
+
/**
|
|
36
|
+
* Backfill `metadata.superagent` on sessions this plugin already owns (the
|
|
37
|
+
* router id plus every recorded worker) so role detection also covers
|
|
38
|
+
* sessions created before the metadata existed. Idempotent: a session whose
|
|
39
|
+
* role already matches is left untouched, and existing metadata is merged
|
|
40
|
+
* rather than replaced.
|
|
41
|
+
*/
|
|
42
|
+
export declare const hydrateSessionMeta: (ctx: Plugin.Context) => Effect.Effect<void, never, never>;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { Effect, Option } from "effect";
|
|
2
|
+
import { store, toSessionID } from "./state.js";
|
|
3
|
+
const WORKER_TITLE_PREFIX = "🔧 worker-";
|
|
4
|
+
export const ROUTER_TITLE = "🧭 router-main";
|
|
5
|
+
const TITLE_SLUG_MAX = 40;
|
|
6
|
+
const THREAD_LABEL_MAX = 12;
|
|
7
|
+
function isRecord(value) {
|
|
8
|
+
return typeof value === "object" && value !== null;
|
|
9
|
+
}
|
|
10
|
+
/** The role recorded in a session's metadata, when the metadata is a Super-agent one. */
|
|
11
|
+
export function superagentRole(metadata) {
|
|
12
|
+
if (!isRecord(metadata))
|
|
13
|
+
return undefined;
|
|
14
|
+
const superagent = metadata["superagent"];
|
|
15
|
+
if (!isRecord(superagent))
|
|
16
|
+
return undefined;
|
|
17
|
+
const role = superagent["role"];
|
|
18
|
+
return role === "router" || role === "worker" ? role : undefined;
|
|
19
|
+
}
|
|
20
|
+
export function isSuperagentSession(session) {
|
|
21
|
+
return superagentRole(session.metadata) !== undefined;
|
|
22
|
+
}
|
|
23
|
+
export function isRouterSession(session) {
|
|
24
|
+
return superagentRole(session.metadata) === "router";
|
|
25
|
+
}
|
|
26
|
+
export function isWorkerSession(session) {
|
|
27
|
+
return superagentRole(session.metadata) === "worker";
|
|
28
|
+
}
|
|
29
|
+
/** The `superagent` metadata entry written at create time. */
|
|
30
|
+
export function superagentMetadata(role, threads) {
|
|
31
|
+
return {
|
|
32
|
+
superagent: role === "worker" ? { role, threads: threads === undefined ? [] : [...threads] } : { role },
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Short label for a thread id: the last `:`-separated segment, so
|
|
37
|
+
* `telegram:123456789` becomes `123456789` and long ids keep their tail.
|
|
38
|
+
*/
|
|
39
|
+
function threadLabel(threadID) {
|
|
40
|
+
const segment = (threadID.split(":").pop() ?? threadID).trim();
|
|
41
|
+
if (segment === "")
|
|
42
|
+
return "thread";
|
|
43
|
+
return segment.length > THREAD_LABEL_MAX ? segment.slice(-THREAD_LABEL_MAX) : segment;
|
|
44
|
+
}
|
|
45
|
+
function titleSlug(text) {
|
|
46
|
+
const clean = text.replace(/\s+/g, " ").trim();
|
|
47
|
+
if (clean === "")
|
|
48
|
+
return "task";
|
|
49
|
+
return clean.length > TITLE_SLUG_MAX ? `${clean.slice(0, TITLE_SLUG_MAX - 1).trimEnd()}…` : clean;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Worker session title. It is convenience only: the session stays
|
|
53
|
+
* identifiable from metadata when a user renames it.
|
|
54
|
+
*/
|
|
55
|
+
export function workerTitle(input) {
|
|
56
|
+
const subject = titleSlug(input.title ?? input.task.split("\n")[0] ?? "");
|
|
57
|
+
const label = input.threadID === undefined ? subject : `${threadLabel(input.threadID)}-${subject}`;
|
|
58
|
+
return `${WORKER_TITLE_PREFIX}${label}`;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Backfill `metadata.superagent` on sessions this plugin already owns (the
|
|
62
|
+
* router id plus every recorded worker) so role detection also covers
|
|
63
|
+
* sessions created before the metadata existed. Idempotent: a session whose
|
|
64
|
+
* role already matches is left untouched, and existing metadata is merged
|
|
65
|
+
* rather than replaced.
|
|
66
|
+
*/
|
|
67
|
+
export const hydrateSessionMeta = Effect.fn("superagent.hydrateSessionMeta")(function* (ctx) {
|
|
68
|
+
const targets = [];
|
|
69
|
+
if (store.routerID !== undefined)
|
|
70
|
+
targets.push({ sessionID: store.routerID, role: "router" });
|
|
71
|
+
for (const worker of store.workers.values())
|
|
72
|
+
targets.push({ sessionID: worker.sessionID, role: "worker" });
|
|
73
|
+
for (const target of targets) {
|
|
74
|
+
const current = yield* ctx.session.get({ sessionID: toSessionID(target.sessionID) }).pipe(Effect.option);
|
|
75
|
+
if (Option.isNone(current))
|
|
76
|
+
continue;
|
|
77
|
+
const metadata = current.value.metadata;
|
|
78
|
+
if (superagentRole(metadata) === target.role)
|
|
79
|
+
continue;
|
|
80
|
+
const threads = target.role === "worker" ? store.workers.get(target.sessionID)?.threads : undefined;
|
|
81
|
+
yield* ctx.session
|
|
82
|
+
.update({
|
|
83
|
+
sessionID: toSessionID(target.sessionID),
|
|
84
|
+
metadata: { ...(metadata ?? {}), ...superagentMetadata(target.role, threads) },
|
|
85
|
+
})
|
|
86
|
+
.pipe(Effect.catch((cause) => Effect.logWarning("superagent failed to backfill session metadata", {
|
|
87
|
+
sessionID: target.sessionID,
|
|
88
|
+
cause,
|
|
89
|
+
})));
|
|
90
|
+
}
|
|
91
|
+
});
|