my-opencode-toolkit 0.0.0-stage → 0.2.2
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 +112 -2
- package/index.ts +3 -0
- package/package.json +65 -4
- package/src/goal/command.ts +84 -0
- package/src/goal/continuation.ts +211 -0
- package/src/goal/controller.ts +107 -0
- package/src/goal/error.ts +94 -0
- package/src/goal/filter.ts +21 -0
- package/src/goal/index.ts +43 -0
- package/src/goal/parse-command.ts +31 -0
- package/src/goal/prompts.ts +84 -0
- package/src/goal/session-role.ts +37 -0
- package/src/goal/store.ts +91 -0
- package/src/goal/tools.ts +131 -0
- package/src/goal/types.ts +75 -0
- package/src/i18n/en.ts +61 -0
- package/src/i18n/index.ts +28 -0
- package/src/i18n/types.ts +57 -0
- package/src/i18n/zh.ts +61 -0
- package/src/index.ts +150 -0
- package/src/notification/format.ts +42 -0
- package/src/notification/index.ts +165 -0
- package/src/notification/send.ts +104 -0
- package/src/scheduling/command.ts +181 -0
- package/src/scheduling/continuation.ts +164 -0
- package/src/scheduling/cron.ts +309 -0
- package/src/scheduling/filter.ts +25 -0
- package/src/scheduling/index.ts +68 -0
- package/src/scheduling/loops.ts +235 -0
- package/src/scheduling/store.ts +228 -0
- package/src/scheduling/tools.ts +183 -0
- package/src/scheduling/types.ts +54 -0
- package/src/shared/events.ts +44 -0
- package/src/shared/format.ts +16 -0
- package/src/shared/json.ts +44 -0
- package/src/shared/keys.ts +26 -0
- package/src/shared/messages.ts +17 -0
- package/src/shared/rpc.ts +214 -0
- package/src/shared/scan.ts +21 -0
- package/src/shared/settings-store.ts +28 -0
- package/src/shared/settings.ts +133 -0
- package/src/shared/types.ts +4 -0
- package/src/stats.ts +215 -0
- package/src/tools/ping.ts +40 -0
- package/src/tui/index.tsx +274 -0
- package/tui.ts +3 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode/plugin"
|
|
2
|
+
import { Schema } from "effect"
|
|
3
|
+
|
|
4
|
+
import type { SessionRoles } from "../goal/session-role.ts"
|
|
5
|
+
import { loadSettings } from "../shared/settings-store.ts"
|
|
6
|
+
import { cronToHuman } from "./cron.ts"
|
|
7
|
+
import type { LoopScheduler } from "./loops.ts"
|
|
8
|
+
import type { SchedulingStore } from "./store.ts"
|
|
9
|
+
import { DEFAULT_MAX_AGE_DAYS } from "./types.ts"
|
|
10
|
+
|
|
11
|
+
const DESCRIPTION_CREATE =
|
|
12
|
+
'Schedule a recurring or one-shot prompt in this session using a 5-field cron expression ' +
|
|
13
|
+
'(local time: "M H DoM Mon DoW"). The prompt is auto-injected when the session is idle at the ' +
|
|
14
|
+
`scheduled time (no catch-up for missed fires). Recurring tasks auto-expire after ${String(DEFAULT_MAX_AGE_DAYS)} ` +
|
|
15
|
+
"days. Use cron_delete to cancel sooner."
|
|
16
|
+
|
|
17
|
+
const DESCRIPTION_LIST = "List all scheduled jobs for this session with their ids, schedules, and prompts."
|
|
18
|
+
|
|
19
|
+
const DESCRIPTION_DELETE = "Cancel a scheduled job by its 8-character id (returned by cron_create)."
|
|
20
|
+
|
|
21
|
+
const DESCRIPTION_WAKEUP =
|
|
22
|
+
"Schedule when to resume work in /loop dynamic mode. Only works when there is an active dynamic " +
|
|
23
|
+
"loop in this session with the exact same prompt. Pass the loop prompt back verbatim to continue; " +
|
|
24
|
+
"omit this call to end the loop. delaySeconds is clamped to 60-3600. Set stop: true to end the " +
|
|
25
|
+
"loop immediately."
|
|
26
|
+
|
|
27
|
+
const CreateInput = Schema.Struct({
|
|
28
|
+
cron: Schema.String,
|
|
29
|
+
prompt: Schema.String,
|
|
30
|
+
recurring: Schema.optional(Schema.Boolean),
|
|
31
|
+
durable: Schema.optional(Schema.Boolean),
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
const EmptyInput = Schema.Struct({})
|
|
35
|
+
|
|
36
|
+
const DeleteInput = Schema.Struct({ id: Schema.String })
|
|
37
|
+
|
|
38
|
+
const WakeupInput = Schema.Struct({
|
|
39
|
+
delaySeconds: Schema.Number,
|
|
40
|
+
prompt: Schema.String,
|
|
41
|
+
reason: Schema.String,
|
|
42
|
+
stop: Schema.optional(Schema.Boolean),
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
const EmptyOutput = Schema.Struct({})
|
|
46
|
+
|
|
47
|
+
/** 输入:文本 + 上限;输出:超长时截断并加省略号。 */
|
|
48
|
+
function truncate(text: string, max: number): string {
|
|
49
|
+
return text.length > max ? `${text.slice(0, max - 3)}...` : text
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 输入:插件上下文、任务存储、循环调度器、会话角色。
|
|
54
|
+
* 输出:注册 cron_create / cron_list / cron_delete / schedule_wakeup。
|
|
55
|
+
* 预期:关闭或子代理会话返回错误文本;不抛异常。
|
|
56
|
+
*/
|
|
57
|
+
export async function registerSchedulingTools(
|
|
58
|
+
ctx: Plugin.Context,
|
|
59
|
+
store: SchedulingStore,
|
|
60
|
+
scheduler: LoopScheduler,
|
|
61
|
+
roles: SessionRoles,
|
|
62
|
+
): Promise<{ dispose(): Promise<void> }> {
|
|
63
|
+
/** 输入:会话 ID;输出:不可用原因,可用时为 null。 */
|
|
64
|
+
const unavailable = async (sessionID: string): Promise<string | null> => {
|
|
65
|
+
const settings = await loadSettings(ctx.storage)
|
|
66
|
+
if (!settings.enabled || !settings.scheduling.enabled) {
|
|
67
|
+
return "Error: the scheduling feature is disabled in settings."
|
|
68
|
+
}
|
|
69
|
+
if (await roles.isSubagent(sessionID)) return "Error: scheduling tools are not available to subagents."
|
|
70
|
+
return null
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return ctx.tool.transform((tools) => {
|
|
74
|
+
tools.add({
|
|
75
|
+
name: "cron_create",
|
|
76
|
+
options: { codemode: false },
|
|
77
|
+
description: DESCRIPTION_CREATE,
|
|
78
|
+
input: CreateInput,
|
|
79
|
+
output: EmptyOutput,
|
|
80
|
+
execute: async (input, toolCtx) => {
|
|
81
|
+
const sessionID = toolCtx.sessionID
|
|
82
|
+
const blocked = await unavailable(sessionID)
|
|
83
|
+
if (blocked !== null) return { output: {}, content: blocked }
|
|
84
|
+
|
|
85
|
+
const prompt = input.prompt.trim()
|
|
86
|
+
if (prompt.length === 0) return { output: {}, content: "Error: prompt cannot be empty." }
|
|
87
|
+
|
|
88
|
+
const result = await store.addJob({
|
|
89
|
+
sessionID,
|
|
90
|
+
cron: input.cron,
|
|
91
|
+
prompt,
|
|
92
|
+
recurring: input.recurring ?? true,
|
|
93
|
+
durable: input.durable ?? false,
|
|
94
|
+
})
|
|
95
|
+
if (!result.ok) return { output: {}, content: `Error: ${result.error}` }
|
|
96
|
+
|
|
97
|
+
const job = result.job
|
|
98
|
+
const where =
|
|
99
|
+
job.durable === true
|
|
100
|
+
? "Persisted in plugin storage and survives restarts"
|
|
101
|
+
: "Session-only (dies when the plugin restarts)"
|
|
102
|
+
const content =
|
|
103
|
+
job.recurring === true
|
|
104
|
+
? `Scheduled recurring job ${job.id} (${cronToHuman(job.cron)}). ${where}. Auto-expires after ${String(DEFAULT_MAX_AGE_DAYS)} days. Use cron_delete to cancel sooner.`
|
|
105
|
+
: `Scheduled one-shot task ${job.id} (${cronToHuman(job.cron)}). ${where}. It will fire once then auto-delete.`
|
|
106
|
+
return { output: {}, content }
|
|
107
|
+
},
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
tools.add({
|
|
111
|
+
name: "cron_list",
|
|
112
|
+
options: { codemode: false },
|
|
113
|
+
description: DESCRIPTION_LIST,
|
|
114
|
+
input: EmptyInput,
|
|
115
|
+
output: EmptyOutput,
|
|
116
|
+
execute: async (_input, toolCtx) => {
|
|
117
|
+
const sessionID = toolCtx.sessionID
|
|
118
|
+
const blocked = await unavailable(sessionID)
|
|
119
|
+
if (blocked !== null) return { output: {}, content: blocked }
|
|
120
|
+
|
|
121
|
+
const jobs = store.listJobs(sessionID)
|
|
122
|
+
if (jobs.length === 0) return { output: {}, content: "No scheduled jobs." }
|
|
123
|
+
const lines = jobs.map((job) => {
|
|
124
|
+
const kind = job.recurring === true ? "recurring" : "one-shot"
|
|
125
|
+
const scope = job.durable === false ? " [session-only]" : ""
|
|
126
|
+
return `${job.id} — ${cronToHuman(job.cron)} (${kind})${scope}: ${truncate(job.prompt, 80)}`
|
|
127
|
+
})
|
|
128
|
+
return { output: {}, content: lines.join("\n") }
|
|
129
|
+
},
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
tools.add({
|
|
133
|
+
name: "cron_delete",
|
|
134
|
+
options: { codemode: false },
|
|
135
|
+
description: DESCRIPTION_DELETE,
|
|
136
|
+
input: DeleteInput,
|
|
137
|
+
output: EmptyOutput,
|
|
138
|
+
execute: async (input, toolCtx) => {
|
|
139
|
+
const sessionID = toolCtx.sessionID
|
|
140
|
+
const blocked = await unavailable(sessionID)
|
|
141
|
+
if (blocked !== null) return { output: {}, content: blocked }
|
|
142
|
+
|
|
143
|
+
const job = store.getJob(input.id)
|
|
144
|
+
if (job === undefined) return { output: {}, content: `Error: No scheduled job with id '${input.id}'` }
|
|
145
|
+
if (job.sessionID !== sessionID) {
|
|
146
|
+
return { output: {}, content: `Error: Cannot delete cron job '${input.id}': owned by another session` }
|
|
147
|
+
}
|
|
148
|
+
await store.removeJobs([input.id])
|
|
149
|
+
return { output: {}, content: `Cancelled job ${input.id}.` }
|
|
150
|
+
},
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
tools.add({
|
|
154
|
+
name: "schedule_wakeup",
|
|
155
|
+
options: { codemode: false },
|
|
156
|
+
description: DESCRIPTION_WAKEUP,
|
|
157
|
+
input: WakeupInput,
|
|
158
|
+
output: EmptyOutput,
|
|
159
|
+
execute: async (input, toolCtx) => {
|
|
160
|
+
const sessionID = toolCtx.sessionID
|
|
161
|
+
const blocked = await unavailable(sessionID)
|
|
162
|
+
if (blocked !== null) return { output: {}, content: blocked }
|
|
163
|
+
|
|
164
|
+
if (input.stop === true) {
|
|
165
|
+
const stopped = scheduler.stopLoopByPrompt(sessionID, input.prompt)
|
|
166
|
+
return {
|
|
167
|
+
output: {},
|
|
168
|
+
content: stopped
|
|
169
|
+
? `Loop stopped: ${input.prompt}`
|
|
170
|
+
: `Error: no active dynamic loop for prompt '${input.prompt}'`,
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const result = scheduler.scheduleWakeup(sessionID, input.prompt, input.delaySeconds, input.reason)
|
|
175
|
+
if (!result.ok) return { output: {}, content: `Error: ${result.error}` }
|
|
176
|
+
return {
|
|
177
|
+
output: {},
|
|
178
|
+
content: `Wake-up scheduled for ${new Date(result.fireAt).toLocaleString()} (loop ${result.id}).`,
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/** 任务 id 长度(`randomUUID().slice(0, 8)`,8 个 hex 字符)。 */
|
|
2
|
+
export const JOB_ID_LENGTH = 8
|
|
3
|
+
|
|
4
|
+
/** 每会话定时任务上限。 */
|
|
5
|
+
export const MAX_JOBS = 50
|
|
6
|
+
|
|
7
|
+
/** recurring 任务自动过期天数。 */
|
|
8
|
+
export const DEFAULT_MAX_AGE_DAYS = 7
|
|
9
|
+
|
|
10
|
+
/** 定时任务(cron)。 */
|
|
11
|
+
export type ScheduledJob = {
|
|
12
|
+
readonly id: string
|
|
13
|
+
readonly sessionID: string
|
|
14
|
+
/** 5 字段 cron(本地时区)。 */
|
|
15
|
+
readonly cron: string
|
|
16
|
+
/** 触发时注入的 prompt。 */
|
|
17
|
+
readonly prompt: string
|
|
18
|
+
readonly createdAt: number
|
|
19
|
+
lastFiredAt?: number
|
|
20
|
+
/** true = 重复触发;缺省 = one-shot(火一次自删)。 */
|
|
21
|
+
readonly recurring?: boolean
|
|
22
|
+
/** 豁免过期(系统内部任务预留)。 */
|
|
23
|
+
readonly permanent?: boolean
|
|
24
|
+
/** false = 仅内存;缺省 = 写存储。 */
|
|
25
|
+
readonly durable?: boolean
|
|
26
|
+
/** 运行时字段:下一次 fire 时刻,不写存储。 */
|
|
27
|
+
nextRunAt?: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 固定间隔循环。 */
|
|
31
|
+
export type Loop = {
|
|
32
|
+
readonly id: string
|
|
33
|
+
readonly sessionID: string
|
|
34
|
+
readonly prompt: string
|
|
35
|
+
readonly mode: "fixed" | "dynamic"
|
|
36
|
+
/** fixed 模式间隔秒数。 */
|
|
37
|
+
readonly intervalSeconds?: number
|
|
38
|
+
readonly createdAt: number
|
|
39
|
+
readonly expiresAt: number
|
|
40
|
+
/** 已触发次数(运行时)。 */
|
|
41
|
+
fires: number
|
|
42
|
+
/** 下一次 fire 时刻(运行时,供 TUI 倒计时)。 */
|
|
43
|
+
nextFireAt?: number
|
|
44
|
+
/** dynamic 模式:`schedule_wakeup` 设定的待唤醒信息。 */
|
|
45
|
+
pendingWakeup?: Wakeup
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** `schedule_wakeup` 的一次唤醒。 */
|
|
49
|
+
export type Wakeup = {
|
|
50
|
+
readonly fireAt: number
|
|
51
|
+
readonly delaySeconds: number
|
|
52
|
+
readonly prompt: string
|
|
53
|
+
readonly reason: string
|
|
54
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { asRecord } from "./json.ts"
|
|
2
|
+
|
|
3
|
+
/** 插件事件的最小形状。 */
|
|
4
|
+
export type PluginEvent = {
|
|
5
|
+
readonly type: string
|
|
6
|
+
readonly data: unknown
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 输入:插件事件。
|
|
11
|
+
* 输出:`data.sessionID`,没有则 `undefined`。
|
|
12
|
+
* 预期:兼容 `sessionID` 字段缺失或类型错误;不抛异常。
|
|
13
|
+
*/
|
|
14
|
+
export function readEventSessionID(event: PluginEvent): string | undefined {
|
|
15
|
+
const data = asRecord(event.data)
|
|
16
|
+
const value = data?.["sessionID"]
|
|
17
|
+
return typeof value === "string" && value.length > 0 ? value : undefined
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 输入:插件事件。
|
|
22
|
+
* 输出:`data` 的对象视图,非对象时为 `undefined`。
|
|
23
|
+
*/
|
|
24
|
+
export function readEventData(event: PluginEvent): Record<string, unknown> | undefined {
|
|
25
|
+
return asRecord(event.data)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 事件处理函数。 */
|
|
29
|
+
export type EventHandler = (event: PluginEvent) => Promise<void>
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 输入:处理函数列表 + 事件。
|
|
33
|
+
* 输出:无。
|
|
34
|
+
* 预期:按顺序调用;单个 handler 抛错被吞掉,不影响其他 handler 与后续事件。
|
|
35
|
+
*/
|
|
36
|
+
export async function dispatchEvent(handlers: readonly EventHandler[], event: PluginEvent): Promise<void> {
|
|
37
|
+
for (const handler of handlers) {
|
|
38
|
+
try {
|
|
39
|
+
await handler(event)
|
|
40
|
+
} catch {
|
|
41
|
+
// 单个 handler 失败不能影响其他 handler 与后续事件
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 输入:token 数。
|
|
3
|
+
* 输出:紧凑文本(`999` / `12.3k` / `1.2m`)。
|
|
4
|
+
* 预期:保留一位小数,末尾 `.0` 去掉。
|
|
5
|
+
*/
|
|
6
|
+
export function formatTokens(value: number): string {
|
|
7
|
+
const abs = Math.abs(value)
|
|
8
|
+
if (abs < 1000) return String(Math.trunc(value))
|
|
9
|
+
if (abs < 1_000_000) return trim(`${(value / 1000).toFixed(1)}k`)
|
|
10
|
+
return trim(`${(value / 1_000_000).toFixed(1)}m`)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** 输入:`12.0k`;输出:`12k`。预期:只去掉小数部分全零的尾巴。 */
|
|
14
|
+
function trim(text: string): string {
|
|
15
|
+
return text.replace(/\.0([km])$/, "$1")
|
|
16
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 插件存储能接受的 JSON 值。
|
|
3
|
+
*
|
|
4
|
+
* 预期:只包含 JSON 原生类型,保证 `ctx.storage.set` 能直接落盘。
|
|
5
|
+
*/
|
|
6
|
+
export type JsonValue =
|
|
7
|
+
| string
|
|
8
|
+
| number
|
|
9
|
+
| boolean
|
|
10
|
+
| null
|
|
11
|
+
| JsonValue[]
|
|
12
|
+
| { [key: string]: JsonValue }
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 插件存储的最小接口。
|
|
16
|
+
*
|
|
17
|
+
* 输入:key 与 JSON 值。
|
|
18
|
+
* 输出:读取返回 `unknown`(调用方负责校验),写入无返回。
|
|
19
|
+
* 预期:与 `ctx.storage` 结构兼容,测试里可用内存实现替换。
|
|
20
|
+
*/
|
|
21
|
+
export type JsonStore = {
|
|
22
|
+
get(key: string): Promise<unknown>
|
|
23
|
+
set(key: string, value: JsonValue): Promise<void>
|
|
24
|
+
remove(key: string): Promise<void>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 带分页扫描能力的存储(`ctx.storage` 满足)。 */
|
|
28
|
+
export type ScannableStore = JsonStore & {
|
|
29
|
+
scan(options: {
|
|
30
|
+
prefix: string
|
|
31
|
+
limit?: number
|
|
32
|
+
after?: string
|
|
33
|
+
}): Promise<{ entries: readonly { key: string; value: unknown }[]; next?: string }>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 输入:任意值。
|
|
38
|
+
* 输出:普通对象则返回自身,否则 `undefined`。
|
|
39
|
+
* 预期:用于把存储里读出的 `unknown` 收窄成可按 key 访问的记录;数组不算记录。
|
|
40
|
+
*/
|
|
41
|
+
export function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
42
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined
|
|
43
|
+
return value as Record<string, unknown>
|
|
44
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** 全局设置的存储 key。 */
|
|
2
|
+
export const SETTINGS_KEY = "settings"
|
|
3
|
+
|
|
4
|
+
/** Goal 状态的 key 前缀,后面拼会话 ID。 */
|
|
5
|
+
export const GOAL_PREFIX = "goal:"
|
|
6
|
+
|
|
7
|
+
/** 定时任务的 key 前缀,后面拼会话 ID。 */
|
|
8
|
+
export const SCHEDULE_PREFIX = "schedule:"
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 输入:会话 ID。
|
|
12
|
+
* 输出:该会话的 goal 存储 key。
|
|
13
|
+
* 预期:不同会话互不覆盖。
|
|
14
|
+
*/
|
|
15
|
+
export function goalKey(sessionID: string): string {
|
|
16
|
+
return `${GOAL_PREFIX}${sessionID}`
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 输入:会话 ID。
|
|
21
|
+
* 输出:该会话的定时任务存储 key。
|
|
22
|
+
* 预期:不同会话互不覆盖。
|
|
23
|
+
*/
|
|
24
|
+
export function scheduleKey(sessionID: string): string {
|
|
25
|
+
return `${SCHEDULE_PREFIX}${sessionID}`
|
|
26
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode/plugin"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 输入:插件上下文 + 会话 ID + 注入标记。
|
|
5
|
+
* 输出:末条消息是否是真实用户输入(不是本插件注入的)。
|
|
6
|
+
* 预期:读不到消息时返回 false——宁可尝试注入,也不因为读取失败而永久死锁。
|
|
7
|
+
*/
|
|
8
|
+
export async function lastMessageIsUserInput(
|
|
9
|
+
ctx: Plugin.Context,
|
|
10
|
+
sessionID: string,
|
|
11
|
+
marker: string,
|
|
12
|
+
): Promise<boolean> {
|
|
13
|
+
const messages = await ctx.session.context({ sessionID }).catch(() => undefined)
|
|
14
|
+
const last = messages?.at(-1)
|
|
15
|
+
if (last?.type !== "user") return false
|
|
16
|
+
return last.metadata?.[marker] !== true
|
|
17
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { Rpc } from "@opencode/plugin/rpc"
|
|
2
|
+
|
|
3
|
+
import type { Settings } from "./settings.ts"
|
|
4
|
+
|
|
5
|
+
/** 设置读取的输入:当前不需要参数,保留空对象以便后续扩展。 */
|
|
6
|
+
const EmptyInputJson = {
|
|
7
|
+
type: "object",
|
|
8
|
+
properties: {},
|
|
9
|
+
additionalProperties: false,
|
|
10
|
+
} as const
|
|
11
|
+
|
|
12
|
+
/** 面板查询输入。 */
|
|
13
|
+
const PanelInputJson = {
|
|
14
|
+
type: "object",
|
|
15
|
+
properties: { sessionID: { type: "string" } },
|
|
16
|
+
required: ["sessionID"],
|
|
17
|
+
additionalProperties: false,
|
|
18
|
+
} as const
|
|
19
|
+
|
|
20
|
+
/** 设置对象的 JSON Schema,服务端与 TUI 共用。 */
|
|
21
|
+
const SettingsJson = {
|
|
22
|
+
type: "object",
|
|
23
|
+
properties: {
|
|
24
|
+
lang: { type: "string", enum: ["auto", "zh", "en"] },
|
|
25
|
+
enabled: { type: "boolean" },
|
|
26
|
+
notification: {
|
|
27
|
+
type: "object",
|
|
28
|
+
properties: {
|
|
29
|
+
enabled: { type: "boolean" },
|
|
30
|
+
onComplete: { type: "boolean" },
|
|
31
|
+
onQuestion: { type: "boolean" },
|
|
32
|
+
onPermission: { type: "boolean" },
|
|
33
|
+
onlyMainSession: { type: "boolean" },
|
|
34
|
+
},
|
|
35
|
+
required: ["enabled", "onComplete", "onQuestion", "onPermission", "onlyMainSession"],
|
|
36
|
+
additionalProperties: false,
|
|
37
|
+
},
|
|
38
|
+
goal: {
|
|
39
|
+
type: "object",
|
|
40
|
+
properties: { enabled: { type: "boolean" } },
|
|
41
|
+
required: ["enabled"],
|
|
42
|
+
additionalProperties: false,
|
|
43
|
+
},
|
|
44
|
+
scheduling: {
|
|
45
|
+
type: "object",
|
|
46
|
+
properties: { enabled: { type: "boolean" } },
|
|
47
|
+
required: ["enabled"],
|
|
48
|
+
additionalProperties: false,
|
|
49
|
+
},
|
|
50
|
+
sidebar: {
|
|
51
|
+
type: "object",
|
|
52
|
+
properties: {
|
|
53
|
+
enabled: { type: "boolean" },
|
|
54
|
+
showCache: { type: "boolean" },
|
|
55
|
+
showTools: { type: "boolean" },
|
|
56
|
+
showSubagents: { type: "boolean" },
|
|
57
|
+
collapsed: { type: "boolean" },
|
|
58
|
+
},
|
|
59
|
+
required: ["enabled", "showCache", "showTools", "showSubagents", "collapsed"],
|
|
60
|
+
additionalProperties: false,
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
required: ["lang", "enabled", "notification", "goal", "scheduling", "sidebar"],
|
|
64
|
+
additionalProperties: false,
|
|
65
|
+
} as const
|
|
66
|
+
|
|
67
|
+
/** 面板里的目标摘要。 */
|
|
68
|
+
const PanelGoalJson = {
|
|
69
|
+
type: "object",
|
|
70
|
+
properties: {
|
|
71
|
+
objective: { type: "string" },
|
|
72
|
+
status: { type: "string", enum: ["active", "paused", "blocked", "budget_limited", "complete"] },
|
|
73
|
+
tokensUsed: { type: "number" },
|
|
74
|
+
tokenBudget: { type: "number" },
|
|
75
|
+
timeUsedSeconds: { type: "number" },
|
|
76
|
+
},
|
|
77
|
+
required: ["objective", "status", "tokensUsed", "timeUsedSeconds"],
|
|
78
|
+
additionalProperties: false,
|
|
79
|
+
} as const
|
|
80
|
+
|
|
81
|
+
/** 面板里的循环摘要。 */
|
|
82
|
+
const PanelLoopJson = {
|
|
83
|
+
type: "object",
|
|
84
|
+
properties: {
|
|
85
|
+
id: { type: "string" },
|
|
86
|
+
mode: { type: "string", enum: ["fixed", "dynamic"] },
|
|
87
|
+
prompt: { type: "string" },
|
|
88
|
+
nextFireAt: { type: "number" },
|
|
89
|
+
},
|
|
90
|
+
required: ["id", "mode", "prompt"],
|
|
91
|
+
additionalProperties: false,
|
|
92
|
+
} as const
|
|
93
|
+
|
|
94
|
+
/** 面板里的定时任务摘要。 */
|
|
95
|
+
const PanelJobJson = {
|
|
96
|
+
type: "object",
|
|
97
|
+
properties: {
|
|
98
|
+
id: { type: "string" },
|
|
99
|
+
cron: { type: "string" },
|
|
100
|
+
prompt: { type: "string" },
|
|
101
|
+
nextRunAt: { type: "number" },
|
|
102
|
+
},
|
|
103
|
+
required: ["id", "cron", "prompt"],
|
|
104
|
+
additionalProperties: false,
|
|
105
|
+
} as const
|
|
106
|
+
|
|
107
|
+
/** 面板里的统计。 */
|
|
108
|
+
const PanelStatsJson = {
|
|
109
|
+
type: "object",
|
|
110
|
+
properties: {
|
|
111
|
+
steps: { type: "number" },
|
|
112
|
+
input: { type: "number" },
|
|
113
|
+
output: { type: "number" },
|
|
114
|
+
cached: { type: "number" },
|
|
115
|
+
hitRate: { type: "number" },
|
|
116
|
+
subagents: { type: "number" },
|
|
117
|
+
tools: {
|
|
118
|
+
type: "array",
|
|
119
|
+
items: {
|
|
120
|
+
type: "object",
|
|
121
|
+
properties: { name: { type: "string" }, count: { type: "number" } },
|
|
122
|
+
required: ["name", "count"],
|
|
123
|
+
additionalProperties: false,
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
required: ["steps", "input", "output", "cached", "hitRate", "subagents", "tools"],
|
|
128
|
+
additionalProperties: false,
|
|
129
|
+
} as const
|
|
130
|
+
|
|
131
|
+
const PanelOutputJson = {
|
|
132
|
+
type: "object",
|
|
133
|
+
properties: {
|
|
134
|
+
goal: { anyOf: [PanelGoalJson, { type: "null" }] },
|
|
135
|
+
loops: { type: "array", items: PanelLoopJson },
|
|
136
|
+
jobs: { type: "array", items: PanelJobJson },
|
|
137
|
+
stats: PanelStatsJson,
|
|
138
|
+
},
|
|
139
|
+
required: ["goal", "loops", "jobs", "stats"],
|
|
140
|
+
additionalProperties: false,
|
|
141
|
+
} as const
|
|
142
|
+
|
|
143
|
+
/** 服务端与 TUI 共用的 RPC 定义。 */
|
|
144
|
+
export const ToolkitRpc: Rpc.PortableDefinition = Rpc.define({
|
|
145
|
+
id: "my-opencode-toolkit",
|
|
146
|
+
methods: {
|
|
147
|
+
settingsGet: { input: EmptyInputJson, output: SettingsJson },
|
|
148
|
+
settingsSet: { input: SettingsJson, output: SettingsJson },
|
|
149
|
+
panelGet: { input: PanelInputJson, output: PanelOutputJson },
|
|
150
|
+
},
|
|
151
|
+
events: {},
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
/** 面板目标摘要。 */
|
|
155
|
+
export type PanelGoal = {
|
|
156
|
+
readonly objective: string
|
|
157
|
+
readonly status: string
|
|
158
|
+
readonly tokensUsed: number
|
|
159
|
+
readonly tokenBudget?: number
|
|
160
|
+
readonly timeUsedSeconds: number
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** 面板循环摘要。 */
|
|
164
|
+
export type PanelLoop = {
|
|
165
|
+
readonly id: string
|
|
166
|
+
readonly mode: "fixed" | "dynamic"
|
|
167
|
+
readonly prompt: string
|
|
168
|
+
readonly nextFireAt?: number
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** 面板任务摘要。 */
|
|
172
|
+
export type PanelJob = {
|
|
173
|
+
readonly id: string
|
|
174
|
+
readonly cron: string
|
|
175
|
+
readonly prompt: string
|
|
176
|
+
readonly nextRunAt?: number
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** 面板统计。 */
|
|
180
|
+
export type PanelStats = {
|
|
181
|
+
readonly steps: number
|
|
182
|
+
readonly input: number
|
|
183
|
+
readonly output: number
|
|
184
|
+
readonly cached: number
|
|
185
|
+
readonly hitRate: number
|
|
186
|
+
readonly subagents: number
|
|
187
|
+
readonly tools: readonly { readonly name: string; readonly count: number }[]
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** 面板数据。 */
|
|
191
|
+
export type PanelData = {
|
|
192
|
+
readonly goal: PanelGoal | null
|
|
193
|
+
readonly loops: readonly PanelLoop[]
|
|
194
|
+
readonly jobs: readonly PanelJob[]
|
|
195
|
+
readonly stats: PanelStats
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** TUI 侧对 ToolkitRpc 的类型化视图。 */
|
|
199
|
+
export type ToolkitRpcClient = {
|
|
200
|
+
settingsGet(input: Record<string, never>): Promise<Settings>
|
|
201
|
+
settingsSet(input: Settings): Promise<Settings>
|
|
202
|
+
panelGet(input: { readonly sessionID: string }): Promise<PanelData>
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* 输入:带 `rpc` 方法的 TUI client。
|
|
207
|
+
* 输出:类型化 RPC 客户端。
|
|
208
|
+
* 预期:只做类型断言,不改变运行时行为。
|
|
209
|
+
*/
|
|
210
|
+
export function toolkitRpc(client: {
|
|
211
|
+
rpc(definition: Rpc.PortableDefinition): unknown
|
|
212
|
+
}): ToolkitRpcClient {
|
|
213
|
+
return client.rpc(ToolkitRpc) as ToolkitRpcClient
|
|
214
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ScannableStore } from "./json.ts"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 输入:具备 scan 能力的存储 + 前缀 + 每条目处理函数 + 每页条数。
|
|
5
|
+
* 输出:无。
|
|
6
|
+
* 预期:按 next 游标分页遍历全部匹配条目,逐条串行处理,避免一次读进内存。
|
|
7
|
+
*/
|
|
8
|
+
export async function forEachScannedEntry(
|
|
9
|
+
store: ScannableStore,
|
|
10
|
+
prefix: string,
|
|
11
|
+
visit: (entry: { key: string; value: unknown }) => Promise<void>,
|
|
12
|
+
limit = 100,
|
|
13
|
+
): Promise<void> {
|
|
14
|
+
let after: string | undefined
|
|
15
|
+
for (;;) {
|
|
16
|
+
const page = await store.scan({ prefix, limit, ...(after === undefined ? {} : { after }) })
|
|
17
|
+
for (const entry of page.entries) await visit(entry)
|
|
18
|
+
if (page.next === undefined) return
|
|
19
|
+
after = page.next
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { JsonStore } from "./json.ts"
|
|
2
|
+
import { SETTINGS_KEY } from "./keys.ts"
|
|
3
|
+
import { normalizeSettings, type Settings } from "./settings.ts"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 输入:存储 + 插件 options(可缺省)。
|
|
7
|
+
* 输出:当前设置。
|
|
8
|
+
* 预期:首次运行时把 options 归一化成默认值写盘;之后以存储里的值为准,
|
|
9
|
+
* 存储损坏或字段缺失时按默认值补齐。
|
|
10
|
+
*/
|
|
11
|
+
export async function loadSettings(store: JsonStore, options?: unknown): Promise<Settings> {
|
|
12
|
+
const stored = await store.get(SETTINGS_KEY)
|
|
13
|
+
if (stored === undefined) {
|
|
14
|
+
const initial = normalizeSettings(options)
|
|
15
|
+
await store.set(SETTINGS_KEY, initial)
|
|
16
|
+
return initial
|
|
17
|
+
}
|
|
18
|
+
return normalizeSettings(stored)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 输入:存储 + 完整设置。
|
|
23
|
+
* 输出:无。
|
|
24
|
+
* 预期:先归一化再写盘,保证存储里不会出现未知字段或错误类型。
|
|
25
|
+
*/
|
|
26
|
+
export async function saveSettings(store: JsonStore, settings: Settings): Promise<void> {
|
|
27
|
+
await store.set(SETTINGS_KEY, normalizeSettings(settings))
|
|
28
|
+
}
|