my-opencode-toolkit 0.0.0-stage → 0.2.1
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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 1cyberlangke1
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,113 @@
|
|
|
1
|
-
#
|
|
1
|
+
# my-opencode-toolkit
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
OpenCode 插件:系统通知、持久目标、定时任务与循环、TUI 状态面板。
|
|
4
|
+
|
|
5
|
+
## 功能
|
|
6
|
+
|
|
7
|
+
### 系统通知
|
|
8
|
+
|
|
9
|
+
- 会话空闲(任务完成)、权限请求(`permission.asked`)、代理提问(`question` 工具)三种通知
|
|
10
|
+
- 平台:Windows toast(PowerShell)、macOS(osascript)、Linux(notify-send)
|
|
11
|
+
- 标题带会话名,正文按设置语言本地化,长度截断避免 toast 溢出
|
|
12
|
+
|
|
13
|
+
### 持久目标(Goal)
|
|
14
|
+
|
|
15
|
+
- 工具:`create_goal`、`get_goal`、`update_goal`
|
|
16
|
+
- 命令:`/goal <objective>`、`/goal pause|resume|clear|edit`、裸 `/goal` 查看
|
|
17
|
+
- 目标 active 时会话空闲自动注入续作提示;同一原因连续 3 次阻塞才标记 `blocked`
|
|
18
|
+
- 记账:每步的输入、输出、缓存读写都计入;达到 token 预算自动转 `budget_limited`
|
|
19
|
+
- 用户打断自动暂停;余额不足暂停并锁死,需显式 `/goal resume`
|
|
20
|
+
|
|
21
|
+
### 定时任务与循环
|
|
22
|
+
|
|
23
|
+
- 工具:`cron_create`、`cron_list`、`cron_delete`、`schedule_wakeup`
|
|
24
|
+
- 命令:
|
|
25
|
+
- `/cron <expr> <prompt>` 创建重复任务
|
|
26
|
+
- `/cron once <expr> <prompt>` 一次性任务
|
|
27
|
+
- `/cron list`、`/cron delete <id>`
|
|
28
|
+
- `/loop [interval] [prompt]` 启动循环,`/loop stop` 停止
|
|
29
|
+
- 5 字段 cron(本地时区),支持 `*`、步进、范围、列表;每会话上限 50;重复任务 7 天过期
|
|
30
|
+
- durable 任务存插件存储,重启恢复并重新武装 timer;非 durable 只留内存
|
|
31
|
+
- 循环:固定间隔或动态(模型用 `schedule_wakeup` 决定下次,60–3600 秒,20 分钟兜底)
|
|
32
|
+
- 裸 `/loop` 使用内置维护提示词;项目根目录有非空 `loop.md` 时优先用它
|
|
33
|
+
|
|
34
|
+
### TUI 面板
|
|
35
|
+
|
|
36
|
+
- 侧栏显示目标、缓存命中率、工具使用 Top3、子代理数量、循环与定时任务数量
|
|
37
|
+
- 统计不单独存一份:每个会话第一次打开面板时,从 OpenCode 已存消息(`session.context`)重建一次,
|
|
38
|
+
之后用事件增量维护;插件重载或进程重启后再重建
|
|
39
|
+
- 设置项可控制显示内容与折叠
|
|
40
|
+
- `/my-opencode-toolkit` 打开设置菜单:启用、语言、通知、Goal、定时任务、侧栏
|
|
41
|
+
|
|
42
|
+
### 子代理隔离
|
|
43
|
+
|
|
44
|
+
子代理会话里不会出现 goal / scheduling 工具,也不会收到任何续作或定时注入。
|
|
45
|
+
实现方式是在 `context` 钩子里从工具表删除,并在注入前检查 `session.parentID`。
|
|
46
|
+
|
|
47
|
+
## 安装
|
|
48
|
+
|
|
49
|
+
在 `~/.config/opencode/opencode.json` 的 `plugins` 数组里加包名:
|
|
50
|
+
|
|
51
|
+
```jsonc
|
|
52
|
+
{
|
|
53
|
+
"plugins": ["my-opencode-toolkit"]
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
本地开发可以直接指向仓库目录:
|
|
58
|
+
|
|
59
|
+
```jsonc
|
|
60
|
+
{
|
|
61
|
+
"plugins": ["D:\\Git_Repository\\my-opencode-toolkit"]
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
重启 OpenCode 后生效。
|
|
66
|
+
|
|
67
|
+
要求 Node 20+;Windows 通知走 PowerShell toast,macOS 走 `osascript`,Linux 走 `notify-send`。
|
|
68
|
+
|
|
69
|
+
## 设置
|
|
70
|
+
|
|
71
|
+
设置存在插件存储里,首次启动用默认值;通过 `/my-opencode-toolkit` 菜单修改,立即生效。
|
|
72
|
+
|
|
73
|
+
| 字段 | 默认 | 说明 |
|
|
74
|
+
|---|---|---|
|
|
75
|
+
| `lang` | `auto` | `auto` / `zh` / `en` |
|
|
76
|
+
| `enabled` | `true` | 插件总开关 |
|
|
77
|
+
| `notification.enabled` | `true` | 通知总开关 |
|
|
78
|
+
| `notification.onComplete` | `true` | 任务完成通知 |
|
|
79
|
+
| `notification.onQuestion` | `true` | 提问通知 |
|
|
80
|
+
| `notification.onPermission` | `true` | 权限通知 |
|
|
81
|
+
| `notification.onlyMainSession` | `true` | 跳过子代理会话 |
|
|
82
|
+
| `goal.enabled` | `true` | Goal 功能 |
|
|
83
|
+
| `scheduling.enabled` | `true` | 定时任务与循环 |
|
|
84
|
+
| `sidebar.enabled` | `true` | 侧栏面板 |
|
|
85
|
+
| `sidebar.showCache` | `true` | 显示缓存命中 |
|
|
86
|
+
| `sidebar.showTools` | `true` | 显示工具统计 |
|
|
87
|
+
| `sidebar.showSubagents` | `true` | 显示子代理数量 |
|
|
88
|
+
| `sidebar.collapsed` | `false` | 折叠侧栏 |
|
|
89
|
+
|
|
90
|
+
## 优先级
|
|
91
|
+
|
|
92
|
+
会话空闲时:Goal active > 定时任务 / 循环。Goal 进行中,定时任务到点只重排不注入。
|
|
93
|
+
|
|
94
|
+
## 已知限制
|
|
95
|
+
|
|
96
|
+
- 侧栏统计是进程内数据:重启后第一次打开面板会从已存消息重建(重建瞬间完成的一步可能漏算)。
|
|
97
|
+
- 通知是尽力而为:系统关闭通知、专注助手拦截或缺少对应命令时静默跳过,不影响会话。
|
|
98
|
+
- 续作与定时注入只在主会话生效;子代理会话既看不到相关工具,也不会被注入。
|
|
99
|
+
- 定时任务按本地时区解析 5 字段 cron,不支持秒级字段。
|
|
100
|
+
|
|
101
|
+
## 开发
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
pnpm install
|
|
105
|
+
pnpm check # typecheck + lint + 单测 + TUI 渲染测试
|
|
106
|
+
pnpm test:e2e # 隔离环境启动真实 opencode,假 provider 驱动工具调用
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
要求 Node 20+(OpenCode v2 要求),测试用 vitest + bun。
|
|
110
|
+
|
|
111
|
+
## License
|
|
112
|
+
|
|
113
|
+
MIT,见 [LICENSE](LICENSE)。
|
package/index.ts
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,67 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "my-opencode-toolkit",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "OpenCode 插件:系统通知、持久目标、定时任务与 TUI 面板",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "1cyberlangke1",
|
|
8
|
+
"homepage": "https://github.com/1cyberlangke1/my-opencode-toolkit#readme",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/1cyberlangke1/my-opencode-toolkit.git"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/1cyberlangke1/my-opencode-toolkit/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"opencode",
|
|
18
|
+
"plugin",
|
|
19
|
+
"notification",
|
|
20
|
+
"goal",
|
|
21
|
+
"cron",
|
|
22
|
+
"tui"
|
|
23
|
+
],
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=20"
|
|
26
|
+
},
|
|
27
|
+
"exports": {
|
|
28
|
+
".": "./index.ts",
|
|
29
|
+
"./tui": "./tui.ts"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"src",
|
|
33
|
+
"index.ts",
|
|
34
|
+
"tui.ts"
|
|
35
|
+
],
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"effect": "4.0.1"
|
|
38
|
+
},
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"@opencode/plugin": "^2.0.32",
|
|
41
|
+
"@opentui/core": ">=0.5.17",
|
|
42
|
+
"@opentui/solid": ">=0.5.17",
|
|
43
|
+
"solid-js": ">=1.9.0"
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"typecheck": "tsc --noEmit",
|
|
47
|
+
"lint": "eslint .",
|
|
48
|
+
"check": "pnpm typecheck && pnpm lint && pnpm test && pnpm test:tui",
|
|
49
|
+
"test": "vitest run",
|
|
50
|
+
"test:tui": "cd tui-test && bun test",
|
|
51
|
+
"test:e2e": "bun scripts/e2e-load.mjs",
|
|
52
|
+
"test:watch": "vitest"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@eslint/js": "^10.0.1",
|
|
56
|
+
"@opencode/plugin": "^2.1.2",
|
|
57
|
+
"@opencode/theme": "2.1.2",
|
|
58
|
+
"@opentui/core": "0.6.6",
|
|
59
|
+
"@opentui/solid": "0.6.6",
|
|
60
|
+
"@types/node": "^26.6.5",
|
|
61
|
+
"eslint": "^10.13.0",
|
|
62
|
+
"solid-js": "1.9.15",
|
|
63
|
+
"typescript": "^6.0.3",
|
|
64
|
+
"typescript-eslint": "^8.71.2",
|
|
65
|
+
"vitest": "^5.0.5"
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode/plugin"
|
|
2
|
+
|
|
3
|
+
import { loadSettings } from "../shared/settings-store.ts"
|
|
4
|
+
import type { GoalContinuation } from "./continuation.ts"
|
|
5
|
+
import type { GoalController } from "./controller.ts"
|
|
6
|
+
import { parseGoalCommand } from "./parse-command.ts"
|
|
7
|
+
|
|
8
|
+
const DESCRIPTION = "Set, inspect, pause, resume or clear the persistent goal for this session"
|
|
9
|
+
|
|
10
|
+
const USAGE = "Usage: /goal [<objective>|clear|edit|pause|resume]"
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 输入:控制器 + 续作控制器 + 会话 ID + 原始参数。
|
|
14
|
+
* 输出:给模型看的英文反馈。
|
|
15
|
+
* 预期:set 会清掉旧目标与锁死标志;pause/resume 只对已有目标生效。
|
|
16
|
+
*/
|
|
17
|
+
async function applyGoalCommand(
|
|
18
|
+
controller: GoalController,
|
|
19
|
+
continuation: GoalContinuation,
|
|
20
|
+
sessionID: string,
|
|
21
|
+
rawArgs: string,
|
|
22
|
+
): Promise<string> {
|
|
23
|
+
const parsed = parseGoalCommand(rawArgs)
|
|
24
|
+
switch (parsed.kind) {
|
|
25
|
+
case "set": {
|
|
26
|
+
await controller.setGoal(sessionID, parsed.objective)
|
|
27
|
+
// 新目标 = 全新开始:清掉旧会话残留的打断/超长/余额锁死标志。
|
|
28
|
+
continuation.resetRunState(sessionID)
|
|
29
|
+
return `Goal set: ${parsed.objective}`
|
|
30
|
+
}
|
|
31
|
+
case "pause": {
|
|
32
|
+
const goal = await controller.pauseGoal(sessionID)
|
|
33
|
+
return goal !== null ? `Goal paused. Current goal: ${goal.objective}` : "No active goal"
|
|
34
|
+
}
|
|
35
|
+
case "resume": {
|
|
36
|
+
const goal = await controller.resumeGoal(sessionID)
|
|
37
|
+
continuation.resetRunState(sessionID)
|
|
38
|
+
return goal !== null ? `Goal resumed. Current goal: ${goal.objective}` : "No active goal"
|
|
39
|
+
}
|
|
40
|
+
case "clear": {
|
|
41
|
+
const existed = await controller.clearGoal(sessionID)
|
|
42
|
+
return existed ? "Goal cleared" : "No active goal"
|
|
43
|
+
}
|
|
44
|
+
case "edit": {
|
|
45
|
+
const goal = await controller.getGoal(sessionID)
|
|
46
|
+
if (goal === null) return USAGE
|
|
47
|
+
return `Current goal: ${goal.objective}. Edit with /goal <new objective>.\n${USAGE}`
|
|
48
|
+
}
|
|
49
|
+
case "show": {
|
|
50
|
+
const goal = await controller.getGoal(sessionID)
|
|
51
|
+
if (goal === null) return "No active goal"
|
|
52
|
+
return `Goal (${goal.status}): ${goal.objective}`
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 输入:插件上下文 + 控制器 + 续作控制器。
|
|
59
|
+
* 输出:注册 `/goal` 命令,返回注销句柄。
|
|
60
|
+
* 预期:命令本身不进对话流;执行后把反馈作为普通 prompt 发给当前会话。
|
|
61
|
+
*/
|
|
62
|
+
export async function registerGoalCommand(
|
|
63
|
+
ctx: Plugin.Context,
|
|
64
|
+
controller: GoalController,
|
|
65
|
+
continuation: GoalContinuation,
|
|
66
|
+
): Promise<{ dispose(): Promise<void> }> {
|
|
67
|
+
const registration = await ctx.command.transform((editor) => {
|
|
68
|
+
editor.add({
|
|
69
|
+
name: "goal",
|
|
70
|
+
description: DESCRIPTION,
|
|
71
|
+
execute: async (input) => {
|
|
72
|
+
const settings = await loadSettings(ctx.storage)
|
|
73
|
+
const text =
|
|
74
|
+
settings.enabled && settings.goal.enabled
|
|
75
|
+
? await applyGoalCommand(controller, continuation, input.sessionID, input.prompt.text)
|
|
76
|
+
: "Error: the goal feature is disabled in settings."
|
|
77
|
+
// /goal 只关心文本参数;附件不回传,避免把命令附件重复塞进会话。
|
|
78
|
+
await ctx.session.prompt({ sessionID: input.sessionID, text, delivery: input.delivery })
|
|
79
|
+
},
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
await ctx.command.reload()
|
|
83
|
+
return registration
|
|
84
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode/plugin"
|
|
2
|
+
|
|
3
|
+
import { readEventData } from "../shared/events.ts"
|
|
4
|
+
import type { PluginEvent } from "../shared/events.ts"
|
|
5
|
+
import { asRecord, type JsonStore } from "../shared/json.ts"
|
|
6
|
+
import { lastMessageIsUserInput } from "../shared/messages.ts"
|
|
7
|
+
import { loadSettings } from "../shared/settings-store.ts"
|
|
8
|
+
import type { GoalController } from "./controller.ts"
|
|
9
|
+
import { classifyGoalFailure } from "./error.ts"
|
|
10
|
+
import { buildContinuationPrompt } from "./prompts.ts"
|
|
11
|
+
import type { SessionRoles } from "./session-role.ts"
|
|
12
|
+
|
|
13
|
+
/** 注入消息的 metadata 标记,用于识别“这条 user 消息是我们发的”。 */
|
|
14
|
+
const CONTINUATION_MARKER = "goalContinuation"
|
|
15
|
+
|
|
16
|
+
/** 会话运行期锁死标志。 */
|
|
17
|
+
type RunState = {
|
|
18
|
+
/** 用户打断过,暂停后需显式 resume。 */
|
|
19
|
+
wasCancelled: boolean
|
|
20
|
+
/** 上下文超长,等下一次模型活动自动解除。 */
|
|
21
|
+
tokenLimitDetected: boolean
|
|
22
|
+
/** 余额不足,只有显式 resume 才解除。 */
|
|
23
|
+
insufficientFundsDetected: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 续作控制器。 */
|
|
27
|
+
export type GoalContinuation = {
|
|
28
|
+
/** 输入:会话事件;输出:无。预期:按事件类型记账、续作或暂停。 */
|
|
29
|
+
handle(event: PluginEvent): Promise<void>
|
|
30
|
+
/** 输入:会话 ID;输出:无。预期:清除打断/超长/余额锁死与待确认注入。 */
|
|
31
|
+
resetRunState(sessionID: string): void
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 输入:插件上下文、存储、目标控制器、可选时钟。
|
|
36
|
+
* 输出:续作控制器。
|
|
37
|
+
* 预期:只在 goal 处于 active 且未被锁死时注入续作;一次 idle 最多注入一条。
|
|
38
|
+
*/
|
|
39
|
+
export function createGoalContinuation(args: {
|
|
40
|
+
readonly ctx: Plugin.Context
|
|
41
|
+
readonly store: JsonStore
|
|
42
|
+
readonly controller: GoalController
|
|
43
|
+
readonly roles: SessionRoles
|
|
44
|
+
readonly now?: () => number
|
|
45
|
+
}): GoalContinuation {
|
|
46
|
+
const { ctx, store, controller, roles } = args
|
|
47
|
+
const now = args.now ?? Date.now
|
|
48
|
+
const runStates = new Map<string, RunState>()
|
|
49
|
+
const pending = new Set<string>()
|
|
50
|
+
const inFlight = new Set<string>()
|
|
51
|
+
const lastAccountAt = new Map<string, number>()
|
|
52
|
+
|
|
53
|
+
/** 输入:会话 ID;输出:可变的运行状态。预期:不存在时创建默认值。 */
|
|
54
|
+
const stateOf = (sessionID: string): RunState => {
|
|
55
|
+
const existing = runStates.get(sessionID)
|
|
56
|
+
if (existing !== undefined) return existing
|
|
57
|
+
const created: RunState = { wasCancelled: false, tokenLimitDetected: false, insufficientFundsDetected: false }
|
|
58
|
+
runStates.set(sessionID, created)
|
|
59
|
+
return created
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 输入:会话 ID;输出:设置是否允许续作。预期:总开关或 goal 开关关闭时不续作。 */
|
|
63
|
+
const enabled = async (): Promise<boolean> => {
|
|
64
|
+
const settings = await loadSettings(store)
|
|
65
|
+
return settings.enabled && settings.goal.enabled
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 输入:会话 ID + token 增量;输出:无。预期:只对 active 目标记账。 */
|
|
69
|
+
const account = async (sessionID: string, tokens: number): Promise<void> => {
|
|
70
|
+
const at = now()
|
|
71
|
+
const last = lastAccountAt.get(sessionID)
|
|
72
|
+
const elapsedSeconds = last === undefined ? 0 : Math.max(0, Math.floor((at - last) / 1000))
|
|
73
|
+
lastAccountAt.set(sessionID, at)
|
|
74
|
+
if (tokens === 0 && elapsedSeconds === 0) return
|
|
75
|
+
await controller.accountUsage(sessionID, { tokens, elapsedSeconds })
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 输入:会话 ID + 文本;输出:无。预期:注入一条带标记的续作提示;并发或失败时静默跳过。 */
|
|
79
|
+
const inject = async (sessionID: string, text: string): Promise<void> => {
|
|
80
|
+
if (inFlight.has(sessionID)) return
|
|
81
|
+
inFlight.add(sessionID)
|
|
82
|
+
try {
|
|
83
|
+
await ctx.session.prompt({ sessionID, text, delivery: "steer", metadata: { [CONTINUATION_MARKER]: true } })
|
|
84
|
+
pending.add(sessionID)
|
|
85
|
+
} catch {
|
|
86
|
+
// 注入失败:下一轮 idle 再试,不阻塞其他会话
|
|
87
|
+
} finally {
|
|
88
|
+
inFlight.delete(sessionID)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 输入:会话 ID。
|
|
94
|
+
* 输出:末条消息是否是真实用户输入。
|
|
95
|
+
* 预期:委托共享 helper,读不到消息时返回 false。
|
|
96
|
+
*/
|
|
97
|
+
const userInputPending = (sessionID: string): Promise<boolean> =>
|
|
98
|
+
lastMessageIsUserInput(ctx, sessionID, CONTINUATION_MARKER)
|
|
99
|
+
|
|
100
|
+
/** 输入:会话 ID;输出:无。预期:active 且未锁死、且不是子代理时注入续作。 */
|
|
101
|
+
const handleIdle = async (sessionID: string): Promise<void> => {
|
|
102
|
+
if (!(await enabled())) return
|
|
103
|
+
// 子代理不续作:子代理的生命周期由主会话的工具调用驱动。
|
|
104
|
+
if (await roles.isSubagent(sessionID)) return
|
|
105
|
+
|
|
106
|
+
const state = stateOf(sessionID)
|
|
107
|
+
if (state.wasCancelled || state.tokenLimitDetected || state.insufficientFundsDetected) return
|
|
108
|
+
|
|
109
|
+
// 上一条续作注入后没有产生任何 step:说明模型没有接手,先跳过一次避免死循环。
|
|
110
|
+
if (pending.has(sessionID)) {
|
|
111
|
+
pending.delete(sessionID)
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const goal = await controller.getGoal(sessionID)
|
|
116
|
+
if (goal?.status !== "active") return
|
|
117
|
+
|
|
118
|
+
// 末条是真实用户输入(上一条续作还没被回复)时跳过,避免叠加。
|
|
119
|
+
if (await userInputPending(sessionID)) return
|
|
120
|
+
|
|
121
|
+
await inject(sessionID, buildContinuationPrompt(goal))
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** 输入:会话 ID + 错误载荷;输出:无。预期:余额不足/超长锁死,其余只记录。 */
|
|
125
|
+
const handleFailure = async (sessionID: string, error: unknown): Promise<void> => {
|
|
126
|
+
if (!(await enabled())) return
|
|
127
|
+
|
|
128
|
+
const state = stateOf(sessionID)
|
|
129
|
+
const kind = classifyGoalFailure(error)
|
|
130
|
+
if (kind === "insufficient_funds") {
|
|
131
|
+
state.insufficientFundsDetected = true
|
|
132
|
+
await controller.pauseGoal(sessionID)
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
if (kind === "token_limit") {
|
|
136
|
+
state.tokenLimitDetected = true
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** 输入:会话 ID + 打断原因;输出:无。预期:用户打断时暂停目标。 */
|
|
141
|
+
const handleInterrupted = async (sessionID: string, reason: string): Promise<void> => {
|
|
142
|
+
if (reason !== "user") return
|
|
143
|
+
if (!(await enabled())) return
|
|
144
|
+
const state = stateOf(sessionID)
|
|
145
|
+
state.wasCancelled = true
|
|
146
|
+
await controller.pauseGoal(sessionID)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** 输入:任意值;输出:本步消耗的 token 总数。预期:输入、输出、缓存读写都计入。 */
|
|
150
|
+
const readTokens = (raw: unknown): number => {
|
|
151
|
+
const record = asRecord(raw)
|
|
152
|
+
if (record === undefined) return 0
|
|
153
|
+
const cache = asRecord(record["cache"]) ?? {}
|
|
154
|
+
const parts = [record["input"], record["output"], cache["read"], cache["write"]]
|
|
155
|
+
let total = 0
|
|
156
|
+
for (const part of parts) {
|
|
157
|
+
if (typeof part === "number" && Number.isFinite(part) && part > 0) total += part
|
|
158
|
+
}
|
|
159
|
+
return total
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
async handle(event) {
|
|
164
|
+
const data = readEventData(event)
|
|
165
|
+
if (data === undefined) return
|
|
166
|
+
const sessionID = data["sessionID"]
|
|
167
|
+
if (typeof sessionID !== "string" || sessionID.length === 0) return
|
|
168
|
+
|
|
169
|
+
switch (event.type) {
|
|
170
|
+
case "session.step.ended": {
|
|
171
|
+
// 模型活动:解除打断/超长锁死,并记账。
|
|
172
|
+
const state = stateOf(sessionID)
|
|
173
|
+
state.wasCancelled = false
|
|
174
|
+
state.tokenLimitDetected = false
|
|
175
|
+
pending.delete(sessionID)
|
|
176
|
+
if (!(await enabled())) return
|
|
177
|
+
await account(sessionID, readTokens(data["tokens"]))
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
case "session.idle":
|
|
181
|
+
await handleIdle(sessionID)
|
|
182
|
+
return
|
|
183
|
+
case "session.execution.failed":
|
|
184
|
+
await handleFailure(sessionID, data["error"])
|
|
185
|
+
return
|
|
186
|
+
case "session.execution.interrupted":
|
|
187
|
+
await handleInterrupted(sessionID, typeof data["reason"] === "string" ? data["reason"] : "")
|
|
188
|
+
return
|
|
189
|
+
case "session.deleted":
|
|
190
|
+
runStates.delete(sessionID)
|
|
191
|
+
pending.delete(sessionID)
|
|
192
|
+
inFlight.delete(sessionID)
|
|
193
|
+
lastAccountAt.delete(sessionID)
|
|
194
|
+
roles.forget(sessionID)
|
|
195
|
+
// 会话删了就把目标一起清掉,避免插件存储里留下永远读不到的 key。
|
|
196
|
+
await controller.clearGoal(sessionID)
|
|
197
|
+
return
|
|
198
|
+
default:
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
|
|
203
|
+
resetRunState(sessionID) {
|
|
204
|
+
const state = stateOf(sessionID)
|
|
205
|
+
state.wasCancelled = false
|
|
206
|
+
state.tokenLimitDetected = false
|
|
207
|
+
state.insufficientFundsDetected = false
|
|
208
|
+
pending.delete(sessionID)
|
|
209
|
+
},
|
|
210
|
+
}
|
|
211
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { JsonStore } from "../shared/json.ts"
|
|
2
|
+
import { clearGoal, createGoal, nowSeconds, readGoal, setGoalStatus, writeGoal } from "./store.ts"
|
|
3
|
+
import type { Goal } from "./types.ts"
|
|
4
|
+
|
|
5
|
+
/** 同一阻塞原因连续出现多少次才判定为 blocked。 */
|
|
6
|
+
export const BLOCKED_STREAK_REQUIRED = 3
|
|
7
|
+
|
|
8
|
+
/** 一次记账的增量。 */
|
|
9
|
+
export type GoalUsage = {
|
|
10
|
+
readonly tokens: number
|
|
11
|
+
readonly elapsedSeconds: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** 目标控制器:所有方法异步,读写都经过插件存储。 */
|
|
15
|
+
export type GoalController = {
|
|
16
|
+
setGoal(sessionID: string, objective: string, tokenBudget?: number): Promise<Goal>
|
|
17
|
+
getGoal(sessionID: string): Promise<Goal | null>
|
|
18
|
+
pauseGoal(sessionID: string): Promise<Goal | null>
|
|
19
|
+
resumeGoal(sessionID: string): Promise<Goal | null>
|
|
20
|
+
clearGoal(sessionID: string): Promise<boolean>
|
|
21
|
+
markComplete(sessionID: string): Promise<Goal | null>
|
|
22
|
+
markBlocked(sessionID: string, reason: string): Promise<Goal | null>
|
|
23
|
+
accountUsage(sessionID: string, usage: GoalUsage): Promise<Goal | null>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 输入:插件存储。
|
|
28
|
+
* 输出:目标控制器。
|
|
29
|
+
* 预期:所有状态转换都落盘;不合法转换返回 `null` 或原目标,不抛异常。
|
|
30
|
+
*/
|
|
31
|
+
export function createGoalController(store: JsonStore): GoalController {
|
|
32
|
+
return {
|
|
33
|
+
async setGoal(sessionID, objective, tokenBudget) {
|
|
34
|
+
await clearGoal(store, sessionID)
|
|
35
|
+
const goal = await createGoal(store, sessionID, objective)
|
|
36
|
+
if (tokenBudget === undefined) return goal
|
|
37
|
+
const withBudget: Goal = { ...goal, tokenBudget }
|
|
38
|
+
await writeGoal(store, withBudget)
|
|
39
|
+
return withBudget
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
getGoal(sessionID) {
|
|
43
|
+
return readGoal(store, sessionID)
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
async pauseGoal(sessionID) {
|
|
47
|
+
// 只允许 active → paused:已 complete/blocked/budget_limited 的目标不覆写。
|
|
48
|
+
const existing = await readGoal(store, sessionID)
|
|
49
|
+
if (existing?.status !== "active") return null
|
|
50
|
+
return setGoalStatus(store, sessionID, "paused")
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
async resumeGoal(sessionID) {
|
|
54
|
+
const existing = await readGoal(store, sessionID)
|
|
55
|
+
if (existing === null) return null
|
|
56
|
+
if (existing.status === "active") return existing
|
|
57
|
+
return setGoalStatus(store, sessionID, "active")
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
clearGoal(sessionID) {
|
|
61
|
+
return clearGoal(store, sessionID)
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
markComplete(sessionID) {
|
|
65
|
+
return setGoalStatus(store, sessionID, "complete")
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
async markBlocked(sessionID, reason) {
|
|
69
|
+
const existing = await readGoal(store, sessionID)
|
|
70
|
+
if (existing === null) return null
|
|
71
|
+
|
|
72
|
+
const sameReason = existing.lastBlockedReason === reason
|
|
73
|
+
const streak = sameReason ? (existing.blockedStreak ?? 1) + 1 : 1
|
|
74
|
+
const status = streak >= BLOCKED_STREAK_REQUIRED ? "blocked" : existing.status
|
|
75
|
+
const updated: Goal = {
|
|
76
|
+
...existing,
|
|
77
|
+
status,
|
|
78
|
+
lastBlockedReason: reason,
|
|
79
|
+
blockedStreak: streak,
|
|
80
|
+
updatedAt: nowSeconds(),
|
|
81
|
+
}
|
|
82
|
+
await writeGoal(store, updated)
|
|
83
|
+
return updated
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
async accountUsage(sessionID, usage) {
|
|
87
|
+
const goal = await readGoal(store, sessionID)
|
|
88
|
+
if (goal?.status !== "active") return goal
|
|
89
|
+
|
|
90
|
+
const tokenDelta = Math.max(0, usage.tokens)
|
|
91
|
+
const timeDelta = Math.max(0, usage.elapsedSeconds)
|
|
92
|
+
if (tokenDelta === 0 && timeDelta === 0) return goal
|
|
93
|
+
|
|
94
|
+
const nextTokens = goal.tokensUsed + tokenDelta
|
|
95
|
+
const budgetReached = goal.tokenBudget !== undefined && nextTokens >= goal.tokenBudget
|
|
96
|
+
const updated: Goal = {
|
|
97
|
+
...goal,
|
|
98
|
+
tokensUsed: nextTokens,
|
|
99
|
+
timeUsedSeconds: goal.timeUsedSeconds + timeDelta,
|
|
100
|
+
updatedAt: nowSeconds(),
|
|
101
|
+
...(budgetReached ? { status: "budget_limited" as const } : {}),
|
|
102
|
+
}
|
|
103
|
+
await writeGoal(store, updated)
|
|
104
|
+
return updated
|
|
105
|
+
},
|
|
106
|
+
}
|
|
107
|
+
}
|