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,28 @@
|
|
|
1
|
+
import { en } from "./en.ts"
|
|
2
|
+
import type { Messages } from "./types.ts"
|
|
3
|
+
import { zh } from "./zh.ts"
|
|
4
|
+
|
|
5
|
+
export type { Messages }
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 输入:locale 字符串(可为空)。
|
|
9
|
+
* 输出:对应语言的消息表。
|
|
10
|
+
* 预期:`zh*` 用中文,其余一律英文;空值回退到系统 locale。
|
|
11
|
+
*/
|
|
12
|
+
export function resolveMessages(locale: string | undefined): Messages {
|
|
13
|
+
const value = (locale ?? detectLocale()).toLowerCase()
|
|
14
|
+
return value.startsWith("zh") ? zh : en
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 输入:无。
|
|
19
|
+
* 输出:系统 locale。
|
|
20
|
+
* 预期:运行时拿不到时回退 `en`,保证始终有文案。
|
|
21
|
+
*/
|
|
22
|
+
export function detectLocale(): string {
|
|
23
|
+
try {
|
|
24
|
+
return Intl.DateTimeFormat().resolvedOptions().locale
|
|
25
|
+
} catch {
|
|
26
|
+
return "en"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 界面文案。
|
|
3
|
+
*
|
|
4
|
+
* 源码里只引用键名,所有用户可见文字都来自语言文件。
|
|
5
|
+
*/
|
|
6
|
+
export type Messages = {
|
|
7
|
+
readonly command: {
|
|
8
|
+
readonly title: string
|
|
9
|
+
readonly description: string
|
|
10
|
+
readonly group: string
|
|
11
|
+
}
|
|
12
|
+
readonly menu: {
|
|
13
|
+
readonly title: string
|
|
14
|
+
readonly enabled: string
|
|
15
|
+
readonly language: string
|
|
16
|
+
readonly notifications: string
|
|
17
|
+
readonly goal: string
|
|
18
|
+
readonly scheduling: string
|
|
19
|
+
readonly sidebar: string
|
|
20
|
+
readonly on: string
|
|
21
|
+
readonly off: string
|
|
22
|
+
readonly auto: string
|
|
23
|
+
readonly chinese: string
|
|
24
|
+
readonly english: string
|
|
25
|
+
readonly showCache: string
|
|
26
|
+
readonly showTools: string
|
|
27
|
+
readonly showSubagents: string
|
|
28
|
+
readonly collapsed: string
|
|
29
|
+
}
|
|
30
|
+
readonly panel: {
|
|
31
|
+
readonly title: string
|
|
32
|
+
readonly goal: string
|
|
33
|
+
readonly noGoal: string
|
|
34
|
+
readonly loops: string
|
|
35
|
+
readonly jobs: string
|
|
36
|
+
readonly cache: string
|
|
37
|
+
readonly tools: string
|
|
38
|
+
readonly subagents: string
|
|
39
|
+
readonly status: Readonly<Record<string, string>>
|
|
40
|
+
}
|
|
41
|
+
readonly sidebar: {
|
|
42
|
+
readonly title: string
|
|
43
|
+
readonly on: string
|
|
44
|
+
readonly off: string
|
|
45
|
+
}
|
|
46
|
+
readonly notification: {
|
|
47
|
+
readonly title: string
|
|
48
|
+
readonly completed: string
|
|
49
|
+
readonly question: string
|
|
50
|
+
readonly permission: string
|
|
51
|
+
readonly user: string
|
|
52
|
+
readonly assistant: string
|
|
53
|
+
}
|
|
54
|
+
readonly tool: {
|
|
55
|
+
readonly pingResult: (enabled: boolean, lang: string) => string
|
|
56
|
+
}
|
|
57
|
+
}
|
package/src/i18n/zh.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { Messages } from "./types.ts"
|
|
2
|
+
|
|
3
|
+
/** 中文文案。 */
|
|
4
|
+
export const zh: Messages = {
|
|
5
|
+
command: {
|
|
6
|
+
title: "工具包设置",
|
|
7
|
+
description: "打开 my-opencode-toolkit 设置",
|
|
8
|
+
group: "插件",
|
|
9
|
+
},
|
|
10
|
+
menu: {
|
|
11
|
+
title: "my-opencode-toolkit",
|
|
12
|
+
enabled: "启用",
|
|
13
|
+
language: "语言",
|
|
14
|
+
notifications: "通知",
|
|
15
|
+
goal: "目标",
|
|
16
|
+
scheduling: "定时任务",
|
|
17
|
+
sidebar: "侧栏",
|
|
18
|
+
on: "开",
|
|
19
|
+
off: "关",
|
|
20
|
+
auto: "自动",
|
|
21
|
+
chinese: "中文",
|
|
22
|
+
english: "English",
|
|
23
|
+
showCache: "缓存",
|
|
24
|
+
showTools: "工具",
|
|
25
|
+
showSubagents: "子代理",
|
|
26
|
+
collapsed: "折叠",
|
|
27
|
+
},
|
|
28
|
+
panel: {
|
|
29
|
+
title: "工具包",
|
|
30
|
+
goal: "目标",
|
|
31
|
+
noGoal: "无",
|
|
32
|
+
loops: "循环",
|
|
33
|
+
jobs: "定时",
|
|
34
|
+
cache: "缓存",
|
|
35
|
+
tools: "工具",
|
|
36
|
+
subagents: "子代理",
|
|
37
|
+
status: {
|
|
38
|
+
active: "进行中",
|
|
39
|
+
paused: "已暂停",
|
|
40
|
+
blocked: "已阻塞",
|
|
41
|
+
budget_limited: "预算用尽",
|
|
42
|
+
complete: "已完成",
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
sidebar: {
|
|
46
|
+
title: "工具包",
|
|
47
|
+
on: "已启用",
|
|
48
|
+
off: "已禁用",
|
|
49
|
+
},
|
|
50
|
+
notification: {
|
|
51
|
+
title: "OpenCode",
|
|
52
|
+
completed: "任务完成",
|
|
53
|
+
question: "代理正在提问",
|
|
54
|
+
permission: "需要权限确认",
|
|
55
|
+
user: "用户",
|
|
56
|
+
assistant: "助手",
|
|
57
|
+
},
|
|
58
|
+
tool: {
|
|
59
|
+
pingResult: (enabled, lang) => `my-opencode-toolkit:启用=${enabled ? "是" : "否"},语言=${lang}`,
|
|
60
|
+
},
|
|
61
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { Plugin } from "@opencode/plugin"
|
|
2
|
+
|
|
3
|
+
import { hideGoalTools } from "./goal/filter.ts"
|
|
4
|
+
import { registerGoal } from "./goal/index.ts"
|
|
5
|
+
import { createGoalController } from "./goal/controller.ts"
|
|
6
|
+
import { createSessionRoles } from "./goal/session-role.ts"
|
|
7
|
+
import { createNotifier } from "./notification/index.ts"
|
|
8
|
+
import { hideSchedulingTools } from "./scheduling/filter.ts"
|
|
9
|
+
import { registerScheduling } from "./scheduling/index.ts"
|
|
10
|
+
import { dispatchEvent, type EventHandler } from "./shared/events.ts"
|
|
11
|
+
import { asRecord } from "./shared/json.ts"
|
|
12
|
+
import { ToolkitRpc } from "./shared/rpc.ts"
|
|
13
|
+
import { loadSettings, saveSettings } from "./shared/settings-store.ts"
|
|
14
|
+
import { normalizeSettings } from "./shared/settings.ts"
|
|
15
|
+
import { buildStats, createStatsTracker, hitRatePercent, trackEvent } from "./stats.ts"
|
|
16
|
+
import { registerPingTool } from "./tools/ping.ts"
|
|
17
|
+
|
|
18
|
+
export default Plugin.define({
|
|
19
|
+
id: "my-opencode-toolkit",
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 输入:OpenCode 注入的插件上下文。
|
|
23
|
+
* 输出:设置/面板 RPC、`toolkit_ping`、Goal、定时任务、通知,以及卸载回调。
|
|
24
|
+
* 预期:首次启动把 options 归一化后写入存储;之后以存储里的设置为准。
|
|
25
|
+
*/
|
|
26
|
+
async setup(ctx) {
|
|
27
|
+
await loadSettings(ctx.storage, ctx.options)
|
|
28
|
+
|
|
29
|
+
const roles = createSessionRoles(ctx)
|
|
30
|
+
const goalController = createGoalController(ctx.storage)
|
|
31
|
+
const tracker = createStatsTracker()
|
|
32
|
+
|
|
33
|
+
const tool = await registerPingTool(ctx)
|
|
34
|
+
const goal = await registerGoal(ctx, ctx.storage, roles, goalController)
|
|
35
|
+
const scheduling = await registerScheduling(ctx, ctx.storage, roles, async (sessionID) => {
|
|
36
|
+
const current = await goalController.getGoal(sessionID)
|
|
37
|
+
return current?.status === "active"
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
const rpc = await ctx.rpc.register(ToolkitRpc, {
|
|
41
|
+
settingsGet: async () => loadSettings(ctx.storage, ctx.options),
|
|
42
|
+
settingsSet: async (input) => {
|
|
43
|
+
// RPC 定义是 PortableDefinition,运行时才知道 input 形状;这里再归一化一次兜底。
|
|
44
|
+
await saveSettings(ctx.storage, normalizeSettings(input))
|
|
45
|
+
return loadSettings(ctx.storage, ctx.options)
|
|
46
|
+
},
|
|
47
|
+
panelGet: async (input) => {
|
|
48
|
+
const record = asRecord(input)
|
|
49
|
+
const requested = record?.["sessionID"]
|
|
50
|
+
const sessionID = typeof requested === "string" ? requested : ""
|
|
51
|
+
|
|
52
|
+
// 统计不落盘:每个会话第一次打开面板时,从 OpenCode 已存消息重建一次。
|
|
53
|
+
if (sessionID.length > 0 && !tracker.isSeeded(sessionID)) {
|
|
54
|
+
tracker.seed(sessionID, await buildStats(ctx, sessionID))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const current = await goalController.getGoal(sessionID)
|
|
58
|
+
const stats = tracker.get(sessionID)
|
|
59
|
+
const tools = Object.entries(stats.tools)
|
|
60
|
+
.map(([name, count]) => ({ name, count }))
|
|
61
|
+
.sort((left, right) => right.count - left.count)
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
goal:
|
|
65
|
+
current === null
|
|
66
|
+
? null
|
|
67
|
+
: {
|
|
68
|
+
objective: current.objective,
|
|
69
|
+
status: current.status,
|
|
70
|
+
tokensUsed: current.tokensUsed,
|
|
71
|
+
timeUsedSeconds: current.timeUsedSeconds,
|
|
72
|
+
...(current.tokenBudget === undefined ? {} : { tokenBudget: current.tokenBudget }),
|
|
73
|
+
},
|
|
74
|
+
loops: scheduling.scheduler.listLoops(sessionID).map((loop) => ({
|
|
75
|
+
id: loop.id,
|
|
76
|
+
mode: loop.mode,
|
|
77
|
+
prompt: loop.prompt,
|
|
78
|
+
...(loop.nextFireAt === undefined ? {} : { nextFireAt: loop.nextFireAt }),
|
|
79
|
+
})),
|
|
80
|
+
jobs: scheduling.jobs.listJobs(sessionID).map((job) => ({
|
|
81
|
+
id: job.id,
|
|
82
|
+
cron: job.cron,
|
|
83
|
+
prompt: job.prompt,
|
|
84
|
+
...(job.nextRunAt === undefined ? {} : { nextRunAt: job.nextRunAt }),
|
|
85
|
+
})),
|
|
86
|
+
stats: { ...stats, hitRate: hitRatePercent(stats.input, stats.cached), tools },
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
// 功能关闭或子代理会话时,请求发给模型前就把对应工具从工具表移除。
|
|
92
|
+
const hook = await ctx.session.hook("context", async (event) => {
|
|
93
|
+
const settings = await loadSettings(ctx.storage)
|
|
94
|
+
if (!settings.enabled) {
|
|
95
|
+
hideGoalTools(event.tools)
|
|
96
|
+
hideSchedulingTools(event.tools)
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
const subagent = await roles.isSubagent(event.sessionID)
|
|
100
|
+
if (subagent || !settings.goal.enabled) hideGoalTools(event.tools)
|
|
101
|
+
if (subagent || !settings.scheduling.enabled) hideSchedulingTools(event.tools)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
const notifier = createNotifier({ ctx, store: ctx.storage, roles })
|
|
105
|
+
|
|
106
|
+
const toolBefore = await ctx.tool.hook("execute.before", async (input) => {
|
|
107
|
+
await notifier.handleToolBefore({ tool: input.tool, sessionID: input.sessionID })
|
|
108
|
+
})
|
|
109
|
+
const toolAfter = await ctx.tool.hook("execute.after", (input) => {
|
|
110
|
+
tracker.recordTool(input.sessionID, input.tool)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
const handlers: EventHandler[] = [
|
|
114
|
+
(event) => {
|
|
115
|
+
trackEvent(tracker, event)
|
|
116
|
+
return Promise.resolve()
|
|
117
|
+
},
|
|
118
|
+
(event) => notifier.handle(event),
|
|
119
|
+
(event) => goal.handle(event),
|
|
120
|
+
(event) => scheduling.handle(event),
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
const events = new AbortController()
|
|
124
|
+
void (async () => {
|
|
125
|
+
try {
|
|
126
|
+
for await (const event of ctx.event.subscribe({ signal: events.signal })) {
|
|
127
|
+
await dispatchEvent(handlers, event)
|
|
128
|
+
}
|
|
129
|
+
} catch {
|
|
130
|
+
// aborted 或连接断开:卸载时正常路径
|
|
131
|
+
}
|
|
132
|
+
})()
|
|
133
|
+
|
|
134
|
+
// 工具注册后刷新一次工具表,让当前会话的下一次请求就能看到新工具。
|
|
135
|
+
await ctx.tool.reload()
|
|
136
|
+
|
|
137
|
+
return async () => {
|
|
138
|
+
events.abort()
|
|
139
|
+
notifier.dispose()
|
|
140
|
+
tracker.clear()
|
|
141
|
+
await toolAfter.dispose()
|
|
142
|
+
await toolBefore.dispose()
|
|
143
|
+
await hook.dispose()
|
|
144
|
+
await scheduling.dispose()
|
|
145
|
+
await goal.dispose()
|
|
146
|
+
await tool.dispose()
|
|
147
|
+
await rpc.dispose()
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
})
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** 输入:文本;输出:PowerShell 单引号字符串里可安全使用的文本。 */
|
|
2
|
+
export function escapePowerShellSingleQuotedText(input: string): string {
|
|
3
|
+
return input.replaceAll("'", "''")
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/** 输入:文本;输出:AppleScript 双引号字符串里可安全使用的文本。 */
|
|
7
|
+
export function escapeAppleScriptText(input: string): string {
|
|
8
|
+
return input.replaceAll("\\", "\\\\").replaceAll('"', '\\"')
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** 输入:文本;输出:XML 转义后的文本。 */
|
|
12
|
+
export function escapeXmlText(input: string): string {
|
|
13
|
+
return input
|
|
14
|
+
.replaceAll("&", "&")
|
|
15
|
+
.replaceAll("<", "<")
|
|
16
|
+
.replaceAll(">", ">")
|
|
17
|
+
.replaceAll('"', """)
|
|
18
|
+
.replaceAll("'", "'")
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 输入:标题 + 正文。
|
|
23
|
+
* 输出:一行 PowerShell 脚本,弹出 Windows toast。
|
|
24
|
+
* 预期:先 XML 转义(给 toast 模板),再单引号转义(给 -Command 字符串)。
|
|
25
|
+
*/
|
|
26
|
+
export function buildWindowsToastScript(title: string, message: string): string {
|
|
27
|
+
const psTitle = escapePowerShellSingleQuotedText(escapeXmlText(title))
|
|
28
|
+
const psMessage = escapePowerShellSingleQuotedText(escapeXmlText(message))
|
|
29
|
+
|
|
30
|
+
return `
|
|
31
|
+
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
|
|
32
|
+
$Template = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)
|
|
33
|
+
$RawXml = [xml] $Template.GetXml()
|
|
34
|
+
($RawXml.toast.visual.binding.text | Where-Object {$_.id -eq '1'}).AppendChild($RawXml.CreateTextNode('${psTitle}')) | Out-Null
|
|
35
|
+
($RawXml.toast.visual.binding.text | Where-Object {$_.id -eq '2'}).AppendChild($RawXml.CreateTextNode('${psMessage}')) | Out-Null
|
|
36
|
+
$SerializedXml = New-Object Windows.Data.Xml.Dom.XmlDocument
|
|
37
|
+
$SerializedXml.LoadXml($RawXml.OuterXml)
|
|
38
|
+
$Toast = [Windows.UI.Notifications.ToastNotification]::new($SerializedXml)
|
|
39
|
+
$Notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('OpenCode')
|
|
40
|
+
$Notifier.Show($Toast)
|
|
41
|
+
`.trim().replaceAll("\n", "; ")
|
|
42
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode/plugin"
|
|
2
|
+
|
|
3
|
+
import type { SessionRoles } from "../goal/session-role.ts"
|
|
4
|
+
import { resolveMessages, type Messages } from "../i18n/index.ts"
|
|
5
|
+
import { readEventSessionID } from "../shared/events.ts"
|
|
6
|
+
import type { JsonStore } from "../shared/json.ts"
|
|
7
|
+
import { loadSettings } from "../shared/settings-store.ts"
|
|
8
|
+
import type { Settings } from "../shared/settings.ts"
|
|
9
|
+
import { createSender, type NotificationSender } from "./send.ts"
|
|
10
|
+
|
|
11
|
+
/** 通知类型。 */
|
|
12
|
+
type NotificationKind = "completed" | "question" | "permission"
|
|
13
|
+
|
|
14
|
+
/** 同一会话完成通知的去重窗口(idle 与 execution.succeeded 会同时到达)。 */
|
|
15
|
+
const COMPLETE_DEDUPE_MS = 1000
|
|
16
|
+
|
|
17
|
+
/** 预览文本上限。 */
|
|
18
|
+
const PREVIEW_MAX = 200
|
|
19
|
+
|
|
20
|
+
/** 通知控制器。 */
|
|
21
|
+
export type Notifier = {
|
|
22
|
+
/** 输入:会话事件;输出:无。预期:完成/权限事件按设置发系统通知。 */
|
|
23
|
+
handle(event: { readonly type: string; readonly data: unknown }): Promise<void>
|
|
24
|
+
/** 输入:工具名 + 会话;输出:无。预期:`question` 工具触发提问通知。 */
|
|
25
|
+
handleToolBefore(input: { readonly tool: string; readonly sessionID: string }): Promise<void>
|
|
26
|
+
/** 输入:无;输出:无。预期:清理去重缓存。 */
|
|
27
|
+
dispose(): void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 输入:文本;输出:空白折叠成单行。 */
|
|
31
|
+
function collapse(text: string): string {
|
|
32
|
+
return text
|
|
33
|
+
.split(/\r?\n/)
|
|
34
|
+
.map((line) => line.trim())
|
|
35
|
+
.filter(Boolean)
|
|
36
|
+
.join(" ")
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 输入:文本 + 上限;输出:截断后的文本。 */
|
|
40
|
+
function truncate(text: string, max: number): string {
|
|
41
|
+
return text.length > max ? `${text.slice(0, max)}...` : text
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 输入:插件上下文、存储、会话角色、可选发送函数与平台。
|
|
46
|
+
* 输出:通知控制器。
|
|
47
|
+
* 预期:发送失败静默;子代理在 onlyMainSession 下不通知;完成通知带消息预览。
|
|
48
|
+
*/
|
|
49
|
+
export function createNotifier(args: {
|
|
50
|
+
readonly ctx: Plugin.Context
|
|
51
|
+
readonly store: JsonStore
|
|
52
|
+
readonly roles: SessionRoles
|
|
53
|
+
readonly send?: NotificationSender
|
|
54
|
+
readonly platform?: NodeJS.Platform
|
|
55
|
+
readonly now?: () => number
|
|
56
|
+
}): Notifier {
|
|
57
|
+
const { ctx, store, roles } = args
|
|
58
|
+
const send = args.send ?? createSender(args.platform ?? process.platform)
|
|
59
|
+
const clock = args.now ?? Date.now
|
|
60
|
+
const lastCompleteAt = new Map<string, number>()
|
|
61
|
+
|
|
62
|
+
/** 输入:设置里的语言;输出:`resolveMessages` 需要的 locale。 */
|
|
63
|
+
const localeOf = (settings: Settings): string | undefined =>
|
|
64
|
+
settings.lang === "auto" ? undefined : settings.lang
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 输入:会话 ID + 文案。
|
|
68
|
+
* 输出:`User: ...` / `Assistant: ...` 两行(可能为空)。
|
|
69
|
+
* 预期:取最后一条用户文本与最后一条助手文本的最后一行;读取失败返回空。
|
|
70
|
+
*/
|
|
71
|
+
const buildPreview = async (sessionID: string, messages: Messages): Promise<string[]> => {
|
|
72
|
+
const history = await ctx.session.context({ sessionID }).catch(() => undefined)
|
|
73
|
+
if (history === undefined) return []
|
|
74
|
+
|
|
75
|
+
let userText = ""
|
|
76
|
+
let assistantText = ""
|
|
77
|
+
for (let index = history.length - 1; index >= 0; index -= 1) {
|
|
78
|
+
const message = history[index]
|
|
79
|
+
if (message === undefined) continue
|
|
80
|
+
if (userText === "" && message.type === "user") userText = collapse(message.text)
|
|
81
|
+
if (assistantText === "" && message.type === "assistant") {
|
|
82
|
+
const text = message.content
|
|
83
|
+
.filter((part) => part.type === "text")
|
|
84
|
+
.map((part) => part.text)
|
|
85
|
+
.join("\n")
|
|
86
|
+
const lines = collapse(text)
|
|
87
|
+
assistantText = lines
|
|
88
|
+
}
|
|
89
|
+
if (userText !== "" && assistantText !== "") break
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return [
|
|
93
|
+
userText === "" ? "" : `${messages.notification.user}: ${truncate(userText, PREVIEW_MAX)}`,
|
|
94
|
+
assistantText === "" ? "" : `${messages.notification.assistant}: ${truncate(assistantText, PREVIEW_MAX)}`,
|
|
95
|
+
].filter(Boolean)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** 输入:会话 + 设置 + 通知类型;输出:无。预期:标题带会话名,发送失败静默。 */
|
|
99
|
+
const notify = async (sessionID: string, settings: Settings, kind: NotificationKind): Promise<void> => {
|
|
100
|
+
const messages = resolveMessages(localeOf(settings))
|
|
101
|
+
const session = await ctx.session.get({ sessionID }).catch(() => undefined)
|
|
102
|
+
const name = session?.title?.trim() ?? ""
|
|
103
|
+
const title = name.length > 0 ? `${messages.notification.title} · ${name}` : messages.notification.title
|
|
104
|
+
const detail = kind === "completed" ? await buildPreview(sessionID, messages) : []
|
|
105
|
+
const body = [messages.notification[kind], ...detail].join("\n")
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
await send(title, body)
|
|
109
|
+
} catch {
|
|
110
|
+
// 通知是尽力而为,失败不影响会话
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 输入:会话 ID + 设置;输出:是否应通知。预期:onlyMainSession 时跳过子代理。 */
|
|
115
|
+
const allowed = async (sessionID: string, settings: Settings): Promise<boolean> => {
|
|
116
|
+
if (!settings.notification.onlyMainSession) return true
|
|
117
|
+
return !(await roles.isSubagent(sessionID))
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
async handle(event) {
|
|
122
|
+
const sessionID = readEventSessionID(event)
|
|
123
|
+
if (sessionID === undefined) return
|
|
124
|
+
|
|
125
|
+
const settings = await loadSettings(store)
|
|
126
|
+
if (!settings.enabled || !settings.notification.enabled) return
|
|
127
|
+
|
|
128
|
+
if (event.type === "session.deleted") {
|
|
129
|
+
lastCompleteAt.delete(sessionID)
|
|
130
|
+
return
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (!(await allowed(sessionID, settings))) return
|
|
134
|
+
|
|
135
|
+
// idle 与 execution.succeeded 是同一轮结束的两个信号,合并成一次通知。
|
|
136
|
+
if (event.type === "session.idle" || event.type === "session.execution.succeeded") {
|
|
137
|
+
if (!settings.notification.onComplete) return
|
|
138
|
+
const at = clock()
|
|
139
|
+
const last = lastCompleteAt.get(sessionID)
|
|
140
|
+
if (last !== undefined && at - last < COMPLETE_DEDUPE_MS) return
|
|
141
|
+
lastCompleteAt.set(sessionID, at)
|
|
142
|
+
await notify(sessionID, settings, "completed")
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (event.type === "permission.asked") {
|
|
147
|
+
if (!settings.notification.onPermission) return
|
|
148
|
+
await notify(sessionID, settings, "permission")
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
async handleToolBefore(input) {
|
|
153
|
+
if (input.tool.toLowerCase() !== "question") return
|
|
154
|
+
|
|
155
|
+
const settings = await loadSettings(store)
|
|
156
|
+
if (!settings.enabled || !settings.notification.enabled || !settings.notification.onQuestion) return
|
|
157
|
+
if (!(await allowed(input.sessionID, settings))) return
|
|
158
|
+
await notify(input.sessionID, settings, "question")
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
dispose() {
|
|
162
|
+
lastCompleteAt.clear()
|
|
163
|
+
},
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { execFile } from "node:child_process"
|
|
2
|
+
import { promisify } from "node:util"
|
|
3
|
+
|
|
4
|
+
import { buildWindowsToastScript, escapeAppleScriptText } from "./format.ts"
|
|
5
|
+
|
|
6
|
+
const execFileAsync = promisify(execFile)
|
|
7
|
+
|
|
8
|
+
const POWERSHELL = "powershell.exe"
|
|
9
|
+
const OSASCRIPT = "osascript"
|
|
10
|
+
const NOTIFY_SEND = "notify-send"
|
|
11
|
+
|
|
12
|
+
/** Windows toast 对长度敏感,统一截断。 */
|
|
13
|
+
const TITLE_MAX = 100
|
|
14
|
+
const MESSAGE_MAX = 800
|
|
15
|
+
|
|
16
|
+
/** 发送函数。 */
|
|
17
|
+
export type NotificationSender = (title: string, message: string) => Promise<void>
|
|
18
|
+
|
|
19
|
+
/** 输入:文本 + 上限;输出:截断后的文本。 */
|
|
20
|
+
function truncate(text: string, max: number): string {
|
|
21
|
+
return text.length > max ? `${text.slice(0, max)}...` : text
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 输入:命令名 + 该命令能快速成功退出的探测参数。
|
|
26
|
+
* 输出:可用时返回命令名,否则 `null`。
|
|
27
|
+
* 预期:结果缓存;探测超时 3 秒,不阻塞通知链路。
|
|
28
|
+
* 探测参数必须按命令区分:把 PowerShell 的 `-Command` 传给 osascript/notify-send 会必然失败,
|
|
29
|
+
* 导致这两个平台的命令被误判为不存在。
|
|
30
|
+
*/
|
|
31
|
+
function createCommandFinder(commandName: string, probeArgs: readonly string[]): () => Promise<string | null> {
|
|
32
|
+
let cached: string | null | undefined
|
|
33
|
+
let pending: Promise<string | null> | undefined
|
|
34
|
+
|
|
35
|
+
return async () => {
|
|
36
|
+
if (cached !== undefined) return cached
|
|
37
|
+
if (pending !== undefined) return pending
|
|
38
|
+
|
|
39
|
+
pending = (async () => {
|
|
40
|
+
try {
|
|
41
|
+
await execFileAsync(commandName, [...probeArgs], {
|
|
42
|
+
timeout: 3_000,
|
|
43
|
+
windowsHide: true,
|
|
44
|
+
})
|
|
45
|
+
cached = commandName
|
|
46
|
+
} catch {
|
|
47
|
+
cached = null
|
|
48
|
+
}
|
|
49
|
+
return cached
|
|
50
|
+
})()
|
|
51
|
+
return pending
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const findPowerShell = createCommandFinder(POWERSHELL, ["-NoProfile", "-Command", "exit 0"])
|
|
56
|
+
const findOsascript = createCommandFinder(OSASCRIPT, ["-e", "return"])
|
|
57
|
+
const findNotifySend = createCommandFinder(NOTIFY_SEND, ["--help"])
|
|
58
|
+
|
|
59
|
+
/** 输入:命令 + 参数;输出:无。预期:静默执行,失败由调用方忽略。 */
|
|
60
|
+
async function runQuiet(command: string, args: readonly string[]): Promise<void> {
|
|
61
|
+
await execFileAsync(command, [...args], { timeout: 10_000, windowsHide: true })
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 输入:标题 + 正文;输出:无。预期:没有 powershell 时静默跳过。 */
|
|
65
|
+
async function sendWindows(title: string, message: string): Promise<void> {
|
|
66
|
+
const path = await findPowerShell()
|
|
67
|
+
if (path === null) return
|
|
68
|
+
const script = buildWindowsToastScript(truncate(title, TITLE_MAX), truncate(message, MESSAGE_MAX))
|
|
69
|
+
await runQuiet(path, ["-NoProfile", "-NonInteractive", "-Command", script])
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 输入:标题 + 正文;输出:无。预期:没有 osascript 时静默跳过。 */
|
|
73
|
+
async function sendMacos(title: string, message: string): Promise<void> {
|
|
74
|
+
const path = await findOsascript()
|
|
75
|
+
if (path === null) return
|
|
76
|
+
const safeTitle = escapeAppleScriptText(truncate(title, TITLE_MAX))
|
|
77
|
+
const safeMessage = escapeAppleScriptText(truncate(message, MESSAGE_MAX))
|
|
78
|
+
await runQuiet(path, ["-e", `display notification "${safeMessage}" with title "${safeTitle}"`])
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 输入:标题 + 正文;输出:无。预期:没有 notify-send 时静默跳过。 */
|
|
82
|
+
async function sendLinux(title: string, message: string): Promise<void> {
|
|
83
|
+
const path = await findNotifySend()
|
|
84
|
+
if (path === null) return
|
|
85
|
+
await runQuiet(path, [truncate(title, TITLE_MAX), truncate(message, MESSAGE_MAX)])
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 输入:Node 平台字符串。
|
|
90
|
+
* 输出:该平台的发送函数。
|
|
91
|
+
* 预期:不支持的平台返回空实现,绝不抛错。
|
|
92
|
+
*/
|
|
93
|
+
export function createSender(platform: NodeJS.Platform): NotificationSender {
|
|
94
|
+
switch (platform) {
|
|
95
|
+
case "win32":
|
|
96
|
+
return sendWindows
|
|
97
|
+
case "darwin":
|
|
98
|
+
return sendMacos
|
|
99
|
+
case "linux":
|
|
100
|
+
return sendLinux
|
|
101
|
+
default:
|
|
102
|
+
return () => Promise.resolve()
|
|
103
|
+
}
|
|
104
|
+
}
|