mioku-plugin-agent 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +58 -0
- package/commands/index.ts +603 -0
- package/configs/base.ts +11 -0
- package/configs/context-window.ts +15 -0
- package/configs/settings.ts +39 -0
- package/core/activity.ts +236 -0
- package/core/attachment.ts +50 -0
- package/core/chat-config.ts +24 -0
- package/core/compaction.ts +97 -0
- package/core/download.ts +340 -0
- package/core/emotion.ts +47 -0
- package/core/loop.ts +597 -0
- package/core/media.ts +170 -0
- package/core/prompt.ts +214 -0
- package/core/risk.ts +61 -0
- package/core/send.ts +131 -0
- package/core/session.ts +58 -0
- package/core/title.ts +52 -0
- package/core/units.ts +208 -0
- package/db.ts +464 -0
- package/handlers/message.ts +13 -0
- package/index.ts +212 -0
- package/package.json +28 -0
- package/tools/approval.ts +119 -0
- package/tools/bash.ts +280 -0
- package/tools/deliver.ts +75 -0
- package/tools/fs.ts +296 -0
- package/tools/index.ts +194 -0
- package/tools/perm.ts +87 -0
- package/tools/todo.ts +136 -0
- package/tools/view-image.ts +85 -0
- package/tools/web.ts +133 -0
- package/tsconfig.json +7 -0
- package/types.ts +111 -0
- package/utils/config.ts +31 -0
- package/utils/json.ts +5 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mioku Lab
|
|
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
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# mioku-plugin-agent
|
|
2
|
+
|
|
3
|
+
适用于私聊的 Mioku agent 助手插件。与 chat 插件可同时启用
|
|
4
|
+
|
|
5
|
+
## 特性
|
|
6
|
+
|
|
7
|
+
- **独立 agent 循环**:基于 AI 服务实例(主模型角色绑定),工具循环、流式输出
|
|
8
|
+
- **跨平台**:bash 工具在 macOS/Linux 用 bash(缺失时回退 sh)、Windows 用 cmd.exe;工作区目录自动创建
|
|
9
|
+
- **五种权限模式**:
|
|
10
|
+
- `read-only` 只读:不能写文件,bash 每条都要审批
|
|
11
|
+
- `workspace-write` 工作区内写入:写入限制在工作区,bash 每条都要审批
|
|
12
|
+
- `auto` 自动:文件与命令直接执行,但**每条命令先由工作模型审查**,危险操作(删除用户文件、清库、`git push`、sudo 等)转用户审批;执行通知与审批请求都即时推送
|
|
13
|
+
- `full` 完全访问:不审批,命令与写入/编辑汇总成一条转发记录
|
|
14
|
+
- `yolo` 静默:权限最高且**不推送任何中间通知**,用户只收到最终结果
|
|
15
|
+
- **个人工作区**:每个 QQ 号独立工作区,默认 `data/agent/workspace/<qq号>`
|
|
16
|
+
- **聊天内审批**:非 full 权限档下 bash 命令需 `.agent approve` 批准(不带 id 时处理该用户最近一次请求);每条命令都必须带 `purpose`,审批与执行告知都会带上用途
|
|
17
|
+
- **操作合并转发**(仅 `full`):命令、写入、编辑不再逐条推送,而是攒到本轮结束、在最终回复**之前**合并成一条合并转发消息(卡片来源「Agent 执行记录」,外显小字是操作条数与用户请求摘要,总摘要是各类操作计数与失败数;首节点为简介,其余节点按时间顺序记录每条命令/文件改动,含用途、耗时与失败输出)。适配器不支持转发时自动降级为普通文本消息。`read`/`glob`/`grep`/联网/看图等只读操作不记录。`auto` 不合并,仍逐条即时推送
|
|
18
|
+
- **工具面**:read / write / edit / glob / grep / bash / view_image / send_file / send_image / web_search (SearXNG) / web_fetch / todo_write
|
|
19
|
+
- **附件自动下载**:用户发来的图片/文件/音视频统一按文件自动落到 `download/<今天日期>/`,**保留原始文件名与后缀**(`file_name` → segment 的 `file` → `path` → URL 文件名,缺后缀才用 content-type 补);user 消息里带 `message_id`、`name` 和 `[file://路径]`,图片额外作为图片内容附加给模型。QQ 文件消息只有 `file_id` 时按平台 API(`get_file` / `get_group_file_url` / `get_private_file_url`)换取下载地址与原始文件名
|
|
20
|
+
- **消息引用**:模型在回复首行写 `[reply:message_id]` 即可引用(回复)指定聊天消息,标记会被移除并只作用于本轮第一条消息
|
|
21
|
+
- **运行中插话**:Agent 正在跑时用户继续发消息,不再排队等下一轮,而是并入**当前请求**的下一次迭代(DSH 式 steering),模型在同一个回复里就能看到并调整;日志里 `steer queued` 表示已入队、`steer merged into running turn` 表示已并入本轮请求
|
|
22
|
+
- **看图**:`view_image` 查看本地图片;多模态主模型直接把图片附加进对话,非多模态时交给视觉模型转成描述
|
|
23
|
+
- **任务清单**:`todo_write` 整表替换(创建/改状态),沿用 DSH 语义;每次变更都会把最新清单推送给用户
|
|
24
|
+
- **人设 / 说话风格 / 情绪共用**:直接读取 chat 插件 `personalization` 的 persona、`replyStyle.baseStyle`(长期稳定语气)与 emotion 定义,写进 system 提示词;chat 未安装时不下发人设与风格
|
|
25
|
+
- **情绪处理**:默认使用 chat 配置的默认情绪,只有模型主动写 `[emotion:name]` 才切换(不做自动情绪分析),情绪随会话持久化
|
|
26
|
+
- **媒体处理**:多模态主模型直接把图片作为图片内容放进 user 消息;非多模态时用视觉模型转成文字描述
|
|
27
|
+
- **运行日志**:每轮固定输出 turn start / turn done(轮次、工具数、耗时);`debug` 打开后额外输出完整 system prompt、原始回复、推理与工具调用明细
|
|
28
|
+
- **缓存友好**:单条 system 提示词按「变化频率」排序(人设/风格/规则/工具 → 环境/情绪/目标/计划),时间只精确到小时且情绪不再自动刷新;user 消息只保留正文、`message_id` 与媒体清单
|
|
29
|
+
- **自动上下文压缩**:超过阈值时用工作模型把较早对话压缩为摘要
|
|
30
|
+
- **数据收集**:运行记录、工具调用明细落盘 `data/agent/agent.db`;token 用量通过 AI 服务 usage 上报(source=agent)
|
|
31
|
+
- **TODO**:skills 加载、MCP 协议、长期记忆/知识库(vector + BM25 + rerank 混合检索,规划为独立 service)
|
|
32
|
+
|
|
33
|
+
## 触发范围
|
|
34
|
+
|
|
35
|
+
默认仅 bot 主人私聊可用;可在配置中允许管理员或指定用户。
|
|
36
|
+
|
|
37
|
+
## 命令
|
|
38
|
+
|
|
39
|
+
| 命令 | 说明 | 权限 |
|
|
40
|
+
|------------------------------------------------------------------|----------------------------------------------------------------------|--------|
|
|
41
|
+
| `.agent approve` / `.agent deny` | 批准/拒绝最近一次命令执行审批(可带 id 指定某条) | master |
|
|
42
|
+
| `.agent new` | 当前会话转入后台(异步生成标题),开启全新会话 | master |
|
|
43
|
+
| `.agent resume` / `.agent resume <序号>` | 列出后台会话 / 恢复对应会话(上下文、目标、计划随会话恢复) | master |
|
|
44
|
+
| `.agent archive` / `.agent archive list` / `.agent archive <序号>` | 归档当前会话并开新会话 / 查看归档 / 归档列表中的会话 | master |
|
|
45
|
+
| `.agent permission [级别]` | 查看/切换权限模式(read-only / workspace-write / auto / full / yolo),写入配置立即生效 | master |
|
|
46
|
+
| `.agent goal [目标]` | 查看/设置/清除会话目标(注入系统提示词) | master |
|
|
47
|
+
| `.agent plan [每行一项]` | 查看/设置/清除会话计划;agent 运行中用 todo_write 工具自动维护 | master |
|
|
48
|
+
| `.agent compact` | 立即把较早上下文压缩为摘要 | master |
|
|
49
|
+
| `.agent reset` | 清空当前会话上下文 | master |
|
|
50
|
+
| `.agent stop` | 停止当前正在运行的会话:中止模型请求、拒绝待审批命令、丢弃排队消息 | master |
|
|
51
|
+
| `.agent clear` / `.agent clear confirm` | 清除该用户全部保存会话(含归档)及其消息/运行记录,需二次确认(60 秒内有效) | master |
|
|
52
|
+
| `.agent status` | 会话、权限、模型/情绪、token 速度/用量/上下文占用(读自 AI 服务 usage 记录)、目标/计划与待审批项 | master |
|
|
53
|
+
|
|
54
|
+
Token 数据来自 AI 服务的 usage 记录(`AIService.getUsageRecords`,按 session_id 查询),输出速度按最近一次请求与全会话平均分别显示,上下文占用按最近一次请求的输入+输出 token 与模型窗口求占比。
|
|
55
|
+
|
|
56
|
+
## 依赖服务
|
|
57
|
+
|
|
58
|
+
`ai` / `config` / `screenshot`(Markdown 截图关闭时可不装)。
|
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
import type { AIUsageRecordSummary, MessageEvent } from "mioku";
|
|
2
|
+
import type { AgentHost } from "../types";
|
|
3
|
+
import type { SessionPlanItem } from "../db";
|
|
4
|
+
import { maybeCompact } from "../core/compaction";
|
|
5
|
+
import { generateSessionTitle } from "../core/title";
|
|
6
|
+
import { stopAgentTurn } from "../core/loop";
|
|
7
|
+
import { PERMISSION_LEVELS } from "../tools/perm";
|
|
8
|
+
|
|
9
|
+
const CLEAR_CONFIRM_TTL_MS = 60_000;
|
|
10
|
+
|
|
11
|
+
const resumeListCache = new Map<
|
|
12
|
+
number,
|
|
13
|
+
{ generations: number[]; at: number }
|
|
14
|
+
>();
|
|
15
|
+
const pendingClear = new Map<number, number>();
|
|
16
|
+
|
|
17
|
+
async function reply(event: MessageEvent, text: string): Promise<void> {
|
|
18
|
+
await event.reply(text, true);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function requireUser(
|
|
22
|
+
host: AgentHost,
|
|
23
|
+
event: MessageEvent,
|
|
24
|
+
): Promise<number> {
|
|
25
|
+
const userId = Number(event.user_id || event.sender?.user_id || 0);
|
|
26
|
+
if (!userId) {
|
|
27
|
+
await reply(event, "agent 命令需要在 QQ 私聊中使用");
|
|
28
|
+
}
|
|
29
|
+
return userId;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function formatTime(ts: number): string {
|
|
33
|
+
const date = new Date(ts);
|
|
34
|
+
const pad = (value: number) => String(value).padStart(2, "0");
|
|
35
|
+
return `${pad(date.getMonth() + 1)}/${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function sessionLabel(session: {
|
|
39
|
+
title: string;
|
|
40
|
+
sessionId: string;
|
|
41
|
+
updatedAt: number;
|
|
42
|
+
messageCount?: number;
|
|
43
|
+
}): string {
|
|
44
|
+
const title = session.title || "(未命名)";
|
|
45
|
+
const count =
|
|
46
|
+
session.messageCount !== undefined ? `,${session.messageCount} 条` : "";
|
|
47
|
+
return `${title}(${formatTime(session.updatedAt)}${count})`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function backgroundTitle(
|
|
51
|
+
host: AgentHost,
|
|
52
|
+
sessionId: string,
|
|
53
|
+
): Promise<void> {
|
|
54
|
+
const title = await generateSessionTitle(host, sessionId);
|
|
55
|
+
host.db.setSessionMeta(sessionId, { title });
|
|
56
|
+
host.logger.info(`[agent] session ${sessionId} titled: ${title}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function cachedResumable(host: AgentHost, userId: number): number[] {
|
|
60
|
+
const cached = resumeListCache.get(userId);
|
|
61
|
+
if (cached && Date.now() - cached.at < 5 * 60_000) return cached.generations;
|
|
62
|
+
const current = host.sessions.sessionId(userId);
|
|
63
|
+
const generations = host.sessions
|
|
64
|
+
.resumableSessions(userId, current)
|
|
65
|
+
.map((session) => session.generation);
|
|
66
|
+
resumeListCache.set(userId, { generations, at: Date.now() });
|
|
67
|
+
return generations;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function invalidateResumeCache(userId: number): void {
|
|
71
|
+
resumeListCache.delete(userId);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function parsePlanItems(text: string): SessionPlanItem[] {
|
|
75
|
+
return text
|
|
76
|
+
.split("\n")
|
|
77
|
+
.map((line) => line.replace(/^\s*(?:\d+[.、)]|[-*])\s*/, "").trim())
|
|
78
|
+
.filter(Boolean)
|
|
79
|
+
.map((content) => ({ content, status: "pending" as const }));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function renderPlan(items: SessionPlanItem[]): string {
|
|
83
|
+
const marker: Record<string, string> = {
|
|
84
|
+
pending: "[ ]",
|
|
85
|
+
in_progress: "[~]",
|
|
86
|
+
completed: "[x]",
|
|
87
|
+
};
|
|
88
|
+
return items
|
|
89
|
+
.map(
|
|
90
|
+
(item, index) =>
|
|
91
|
+
`${index + 1}. ${marker[item.status] ?? "[ ]"} ${item.content}`,
|
|
92
|
+
)
|
|
93
|
+
.join("\n");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function buildUsageLines(host: AgentHost, sessionId: string): string[] {
|
|
97
|
+
const records: AIUsageRecordSummary[] =
|
|
98
|
+
host.aiService.getUsageRecords?.({ sessionId, limit: 200 }) ?? [];
|
|
99
|
+
if (records.length === 0) {
|
|
100
|
+
return ["- Token:本会话暂无请求记录"];
|
|
101
|
+
}
|
|
102
|
+
const okRecords = records.filter((record) => record.success);
|
|
103
|
+
if (okRecords.length === 0) {
|
|
104
|
+
return ["- Token:本会话暂无成功请求"];
|
|
105
|
+
}
|
|
106
|
+
const totalInput = okRecords.reduce(
|
|
107
|
+
(sum, record) => sum + record.inputTokens,
|
|
108
|
+
0,
|
|
109
|
+
);
|
|
110
|
+
const totalOutput = okRecords.reduce(
|
|
111
|
+
(sum, record) => sum + record.outputTokens,
|
|
112
|
+
0,
|
|
113
|
+
);
|
|
114
|
+
const totalDuration = okRecords.reduce(
|
|
115
|
+
(sum, record) => sum + record.durationMs,
|
|
116
|
+
0,
|
|
117
|
+
);
|
|
118
|
+
const last = okRecords[0];
|
|
119
|
+
const lastSpeed =
|
|
120
|
+
last.durationMs > 0 ? (last.outputTokens / last.durationMs) * 1000 : 0;
|
|
121
|
+
const avgSpeed = totalDuration > 0 ? (totalOutput / totalDuration) * 1000 : 0;
|
|
122
|
+
|
|
123
|
+
const lines = [
|
|
124
|
+
`- Token 速度:上次 ~${lastSpeed.toFixed(1)} tok/s / 平均 ~${avgSpeed.toFixed(1)} tok/s(${okRecords.length} 次请求)`,
|
|
125
|
+
`- 会话 Token:累计 ~${(totalInput + totalOutput).toLocaleString()}(输入 ~${totalInput.toLocaleString()} / 输出 ~${totalOutput.toLocaleString()})`,
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
const resolved = host.resolveModel();
|
|
129
|
+
if (resolved && resolved.contextWindow > 0) {
|
|
130
|
+
const contextTokens = last.inputTokens + last.outputTokens;
|
|
131
|
+
const percent = ((contextTokens / resolved.contextWindow) * 100).toFixed(1);
|
|
132
|
+
lines.push(
|
|
133
|
+
`- 上下文占用:~${contextTokens.toLocaleString()} / ${resolved.contextWindow.toLocaleString()} tokens(${percent}%,按最近一次请求)`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
return lines;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function registerCommands(host: AgentHost): void {
|
|
140
|
+
const { ctx } = host;
|
|
141
|
+
|
|
142
|
+
ctx.command({
|
|
143
|
+
name: "agent-approve",
|
|
144
|
+
match: /^\.?agent\s+(approve|deny)(?:\s+([A-Za-z0-9_]+))?$/i,
|
|
145
|
+
prefixes: false,
|
|
146
|
+
permission: "master",
|
|
147
|
+
description: "批准或拒绝 agent 的命令执行审批",
|
|
148
|
+
usage: ".agent approve / .agent deny",
|
|
149
|
+
handler: async ({ event, match }) => {
|
|
150
|
+
const userId = await requireUser(host, event);
|
|
151
|
+
if (!userId) return;
|
|
152
|
+
const action = String(match?.[1] ?? "").toLowerCase();
|
|
153
|
+
const id = String(match?.[2] ?? "").trim();
|
|
154
|
+
const approved = action === "approve";
|
|
155
|
+
|
|
156
|
+
if (!id) {
|
|
157
|
+
const approval = host.approvals.resolveLatest(userId, approved);
|
|
158
|
+
await reply(
|
|
159
|
+
event,
|
|
160
|
+
approval
|
|
161
|
+
? `${approved ? "已批准" : "已拒绝"}`
|
|
162
|
+
: "当前没有待审批的请求。",
|
|
163
|
+
);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const resolved = host.approvals.resolve(id, approved);
|
|
168
|
+
await reply(
|
|
169
|
+
event,
|
|
170
|
+
resolved ? (approved ? "已批准" : "已拒绝") : "没有找到对应的审批请求",
|
|
171
|
+
);
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
ctx.command({
|
|
176
|
+
name: "agent-new",
|
|
177
|
+
match: /^\.?agent\s+new$/i,
|
|
178
|
+
prefixes: false,
|
|
179
|
+
permission: "master",
|
|
180
|
+
description: "已开启新会话",
|
|
181
|
+
handler: async ({ event }) => {
|
|
182
|
+
const userId = await requireUser(host, event);
|
|
183
|
+
if (!userId) return;
|
|
184
|
+
const oldSession = host.sessions.get(userId);
|
|
185
|
+
const session = host.sessions.newSession(userId);
|
|
186
|
+
invalidateResumeCache(userId);
|
|
187
|
+
void backgroundTitle(host, oldSession.sessionId);
|
|
188
|
+
await reply(event, `已开启新会话 ${session.sessionId}`);
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
ctx.command({
|
|
193
|
+
name: "agent-resume",
|
|
194
|
+
match: /^\.?agent\s+resume(?:\s+(\d+))?$/i,
|
|
195
|
+
prefixes: false,
|
|
196
|
+
permission: "master",
|
|
197
|
+
description: "列出/恢复后台会",
|
|
198
|
+
usage: ".agent resume / .agent resume 1",
|
|
199
|
+
handler: async ({ event, match }) => {
|
|
200
|
+
const userId = await requireUser(host, event);
|
|
201
|
+
if (!userId) return;
|
|
202
|
+
const index = match?.[1] ? Number(match[1]) : undefined;
|
|
203
|
+
|
|
204
|
+
if (index === undefined) {
|
|
205
|
+
const current = host.sessions.get(userId);
|
|
206
|
+
const sessions = host.sessions.resumableSessions(
|
|
207
|
+
userId,
|
|
208
|
+
current.sessionId,
|
|
209
|
+
);
|
|
210
|
+
if (sessions.length === 0) {
|
|
211
|
+
await reply(
|
|
212
|
+
event,
|
|
213
|
+
"没有可恢复的后台会话,用 .agent new 开启新会话吧",
|
|
214
|
+
);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
resumeListCache.set(userId, {
|
|
218
|
+
generations: sessions.map((session) => session.generation),
|
|
219
|
+
at: Date.now(),
|
|
220
|
+
});
|
|
221
|
+
const lines = sessions.map((session, position) => {
|
|
222
|
+
const marker = session.generation === current.generation ? " *" : "";
|
|
223
|
+
return `${position + 1}. ${marker}${sessionLabel({
|
|
224
|
+
title: session.title,
|
|
225
|
+
sessionId: session.sessionId,
|
|
226
|
+
updatedAt: session.updatedAt,
|
|
227
|
+
messageCount: host.db.countMessages(session.sessionId),
|
|
228
|
+
})}`;
|
|
229
|
+
});
|
|
230
|
+
await reply(
|
|
231
|
+
event,
|
|
232
|
+
["后台会话:", ...lines, "", "用 .agent resume <序号> 恢复"].join(
|
|
233
|
+
"\n",
|
|
234
|
+
),
|
|
235
|
+
);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const generations = cachedResumable(host, userId);
|
|
240
|
+
const generation = generations[index - 1];
|
|
241
|
+
if (generation === undefined) {
|
|
242
|
+
await reply(event, "序号不存在,先运行 .agent resume 查看列表");
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
const target = host.sessions.resume(userId, generation);
|
|
246
|
+
if (!target) {
|
|
247
|
+
invalidateResumeCache(userId);
|
|
248
|
+
await reply(event, "该会话不存在,可能已被归档或删除");
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
await reply(
|
|
252
|
+
event,
|
|
253
|
+
`已切换到会话 ${target.sessionId}\n标题:${target.title || "(未命名)"}\n上下文与目标已随会话恢复`,
|
|
254
|
+
);
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
ctx.command({
|
|
259
|
+
name: "agent-archive",
|
|
260
|
+
match: /^\.?agent\s+archive(?:\s+(list|\d+))?$/i,
|
|
261
|
+
prefixes: false,
|
|
262
|
+
permission: "master",
|
|
263
|
+
description: "归档会话",
|
|
264
|
+
usage: ".agent archive / .agent archive list / .agent archive 1",
|
|
265
|
+
handler: async ({ event, match }) => {
|
|
266
|
+
const userId = await requireUser(host, event);
|
|
267
|
+
if (!userId) return;
|
|
268
|
+
const arg = match?.[1]?.toLowerCase();
|
|
269
|
+
|
|
270
|
+
if (arg === "list") {
|
|
271
|
+
const archived = host.db.listSessions(userId, { archived: true });
|
|
272
|
+
if (archived.length === 0) {
|
|
273
|
+
await reply(event, "还没有归档的会话。");
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
const lines = archived.map(
|
|
277
|
+
(session, position) =>
|
|
278
|
+
`${position + 1}. g${session.generation} ${sessionLabel({
|
|
279
|
+
title: session.title,
|
|
280
|
+
sessionId: session.sessionId,
|
|
281
|
+
updatedAt: session.updatedAt,
|
|
282
|
+
messageCount: host.db.countMessages(session.sessionId),
|
|
283
|
+
})}`,
|
|
284
|
+
);
|
|
285
|
+
await reply(event, ["归档会话:", ...lines].join("\n"));
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (arg && /^\d+$/.test(arg)) {
|
|
290
|
+
const current = host.sessions.get(userId);
|
|
291
|
+
const sessions = host.sessions.resumableSessions(
|
|
292
|
+
userId,
|
|
293
|
+
current.sessionId,
|
|
294
|
+
);
|
|
295
|
+
const target = sessions[Number(arg) - 1];
|
|
296
|
+
if (!target) {
|
|
297
|
+
await reply(event, "序号不存在,先运行 .agent resume 查看列表");
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
host.db.setSessionMeta(target.sessionId, { archived: true });
|
|
301
|
+
void backgroundTitle(host, target.sessionId);
|
|
302
|
+
invalidateResumeCache(userId);
|
|
303
|
+
await reply(event, `已归档:${target.title || target.sessionId}`);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const current = host.sessions.get(userId);
|
|
308
|
+
host.db.setSessionMeta(current.sessionId, { archived: true });
|
|
309
|
+
void backgroundTitle(host, current.sessionId);
|
|
310
|
+
const session = host.sessions.newSession(userId);
|
|
311
|
+
invalidateResumeCache(userId);
|
|
312
|
+
await reply(event, `当前会话已归档,新会话:${session.sessionId}`);
|
|
313
|
+
},
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
ctx.command({
|
|
317
|
+
name: "agent-permission",
|
|
318
|
+
match: /^\.?agent\s+permission(?:\s+(\S+))?$/i,
|
|
319
|
+
prefixes: false,
|
|
320
|
+
permission: "master",
|
|
321
|
+
description: "查看或修改 agent 权限级别",
|
|
322
|
+
usage: ".agent permission [级别]",
|
|
323
|
+
handler: async ({ event, match }) => {
|
|
324
|
+
await requireUser(host, event);
|
|
325
|
+
const level = match?.[1]?.toLowerCase();
|
|
326
|
+
if (!level) {
|
|
327
|
+
const base = host.getBase();
|
|
328
|
+
const writeScope =
|
|
329
|
+
base.permissionLevel === "read-only"
|
|
330
|
+
? "禁止"
|
|
331
|
+
: base.permissionLevel === "workspace-write"
|
|
332
|
+
? "仅工作区内"
|
|
333
|
+
: "任意路径";
|
|
334
|
+
const bashPolicy =
|
|
335
|
+
base.permissionLevel === "read-only" ||
|
|
336
|
+
base.permissionLevel === "workspace-write"
|
|
337
|
+
? "每条都要用户审批(.agent approve)"
|
|
338
|
+
: base.permissionLevel === "auto"
|
|
339
|
+
? "自动执行,危险命令由工作模型判定后转用户审批"
|
|
340
|
+
: base.permissionLevel === "yolo"
|
|
341
|
+
? "自动执行,不通知用户"
|
|
342
|
+
: "自动执行,命令与用途会通知用户";
|
|
343
|
+
await reply(
|
|
344
|
+
event,
|
|
345
|
+
[
|
|
346
|
+
`当前权限级别:${base.permissionLevel}`,
|
|
347
|
+
`- 文件写入:${writeScope}`,
|
|
348
|
+
`- bash:${bashPolicy}`,
|
|
349
|
+
"",
|
|
350
|
+
`修改:.agent permission <${PERMISSION_LEVELS.join("|")}>`,
|
|
351
|
+
].join("\n"),
|
|
352
|
+
);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
if (!(PERMISSION_LEVELS as readonly string[]).includes(level)) {
|
|
356
|
+
await reply(
|
|
357
|
+
event,
|
|
358
|
+
`未知级别 "${level}",可选:${PERMISSION_LEVELS.join(" / ")}`,
|
|
359
|
+
);
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
await host.updateBase({
|
|
363
|
+
permissionLevel: level as (typeof PERMISSION_LEVELS)[number],
|
|
364
|
+
});
|
|
365
|
+
await reply(event, `权限级别已切换为 ${level}`);
|
|
366
|
+
},
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
ctx.command({
|
|
370
|
+
name: "agent-goal",
|
|
371
|
+
match: /^\.?agent\s+goal(?:\s+([\s\S]+))?$/i,
|
|
372
|
+
prefixes: false,
|
|
373
|
+
permission: "master",
|
|
374
|
+
description: "查看/设置/清除当前会话目标",
|
|
375
|
+
usage: ".agent goal / .agent goal <目标> / .agent goal clear",
|
|
376
|
+
handler: async ({ event, match }) => {
|
|
377
|
+
const userId = await requireUser(host, event);
|
|
378
|
+
if (!userId) return;
|
|
379
|
+
const session = host.sessions.get(userId);
|
|
380
|
+
const text = match?.[1]?.trim();
|
|
381
|
+
if (!text) {
|
|
382
|
+
await reply(
|
|
383
|
+
event,
|
|
384
|
+
session.goal
|
|
385
|
+
? `当前会话目标:\n${session.goal}`
|
|
386
|
+
: "当前会话没有目标,用 .agent goal <目标> 设置。",
|
|
387
|
+
);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
if (text.toLowerCase() === "clear") {
|
|
391
|
+
host.db.setSessionMeta(session.sessionId, { goal: "" });
|
|
392
|
+
await reply(event, "会话目标已清除。");
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
host.db.setSessionMeta(session.sessionId, { goal: text });
|
|
396
|
+
await reply(event, `会话目标已设置:\n${text}`);
|
|
397
|
+
},
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
ctx.command({
|
|
401
|
+
name: "agent-plan",
|
|
402
|
+
match: /^\.?agent\s+plan(?:\s+([\s\S]+))?$/i,
|
|
403
|
+
prefixes: false,
|
|
404
|
+
permission: "master",
|
|
405
|
+
description: "查看/设置/清除会话计划",
|
|
406
|
+
usage: ".agent plan / .agent plan <每行一项> / .agent plan clear",
|
|
407
|
+
handler: async ({ event, match }) => {
|
|
408
|
+
const userId = await requireUser(host, event);
|
|
409
|
+
if (!userId) return;
|
|
410
|
+
const session = host.sessions.get(userId);
|
|
411
|
+
const text = match?.[1]?.trim();
|
|
412
|
+
if (!text) {
|
|
413
|
+
if (session.plan.length === 0) {
|
|
414
|
+
await reply(
|
|
415
|
+
event,
|
|
416
|
+
"当前会话没有计划,用 .agent plan <每行一项> 设置。",
|
|
417
|
+
);
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
await reply(event, ["会话计划:", renderPlan(session.plan)].join("\n"));
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
if (text.toLowerCase() === "clear") {
|
|
424
|
+
host.db.setSessionMeta(session.sessionId, { plan: [] });
|
|
425
|
+
await reply(event, "会话计划已清除。");
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
const items = parsePlanItems(text);
|
|
429
|
+
host.db.setSessionMeta(session.sessionId, { plan: items });
|
|
430
|
+
await reply(event, ["会话计划已设置:", renderPlan(items)].join("\n"));
|
|
431
|
+
},
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
ctx.command({
|
|
435
|
+
name: "agent-reset",
|
|
436
|
+
match: /^\.?agent\s+reset$/i,
|
|
437
|
+
prefixes: false,
|
|
438
|
+
permission: "master",
|
|
439
|
+
description: "清空当前 agent 会话的上下文",
|
|
440
|
+
handler: async ({ event }) => {
|
|
441
|
+
const userId = await requireUser(host, event);
|
|
442
|
+
if (!userId) return;
|
|
443
|
+
const sessionId = host.sessions.sessionId(userId);
|
|
444
|
+
host.sessions.reset(userId);
|
|
445
|
+
await reply(event, `当前会话 ${sessionId} 的上下文已清空`);
|
|
446
|
+
},
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
ctx.command({
|
|
450
|
+
name: "agent-clear",
|
|
451
|
+
match: /^\.?agent\s+clear(?:\s+(confirm|yes|确认))?$/i,
|
|
452
|
+
prefixes: false,
|
|
453
|
+
permission: "master",
|
|
454
|
+
description: "清除该用户的全部保存会话",
|
|
455
|
+
usage: ".agent clear / .agent clear confirm",
|
|
456
|
+
handler: async ({ event, match }) => {
|
|
457
|
+
const userId = await requireUser(host, event);
|
|
458
|
+
if (!userId) return;
|
|
459
|
+
const confirmed = String(match?.[1] ?? "").toLowerCase();
|
|
460
|
+
|
|
461
|
+
if (!confirmed) {
|
|
462
|
+
const sessions = host.db.listSessions(userId);
|
|
463
|
+
if (sessions.length === 0) {
|
|
464
|
+
await reply(event, "你还没有任何保存的 agent 会话。");
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
const messages = sessions.reduce(
|
|
468
|
+
(total, session) => total + host.db.countMessages(session.sessionId),
|
|
469
|
+
0,
|
|
470
|
+
);
|
|
471
|
+
pendingClear.set(userId, Date.now() + CLEAR_CONFIRM_TTL_MS);
|
|
472
|
+
await reply(
|
|
473
|
+
event,
|
|
474
|
+
[
|
|
475
|
+
`将删除你的全部 ${sessions.length} 个会话(含归档)、${messages} 条消息,以及运行记录与工具调用明细。`,
|
|
476
|
+
"工作区文件不会被删除。",
|
|
477
|
+
`确认请回复 .agent clear confirm(${CLEAR_CONFIRM_TTL_MS / 1000} 秒内有效)。`,
|
|
478
|
+
].join("\n"),
|
|
479
|
+
);
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const expiresAt = pendingClear.get(userId);
|
|
484
|
+
pendingClear.delete(userId);
|
|
485
|
+
if (!expiresAt || Date.now() > expiresAt) {
|
|
486
|
+
await reply(
|
|
487
|
+
event,
|
|
488
|
+
"没有待确认的清除操作,或已超时。请先发送 .agent clear",
|
|
489
|
+
);
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
invalidateResumeCache(userId);
|
|
494
|
+
const removed = host.db.clearUserSessions(userId);
|
|
495
|
+
await reply(
|
|
496
|
+
event,
|
|
497
|
+
[
|
|
498
|
+
"已清除:",
|
|
499
|
+
`- 会话 ${removed.sessions} 个`,
|
|
500
|
+
`- 消息 ${removed.messages} 条`,
|
|
501
|
+
`- 运行记录 ${removed.runs} 条 / 工具调用 ${removed.toolCalls} 条`,
|
|
502
|
+
].join("\n"),
|
|
503
|
+
);
|
|
504
|
+
},
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
ctx.command({
|
|
508
|
+
name: "agent-stop",
|
|
509
|
+
match: /^\.?agent\s+stop$/i,
|
|
510
|
+
prefixes: false,
|
|
511
|
+
permission: "master",
|
|
512
|
+
description: "停止当前正在运行的会话",
|
|
513
|
+
usage: ".agent stop",
|
|
514
|
+
handler: async ({ event }) => {
|
|
515
|
+
const userId = await requireUser(host, event);
|
|
516
|
+
if (!userId) return;
|
|
517
|
+
const result = stopAgentTurn(host, userId);
|
|
518
|
+
const lines = [
|
|
519
|
+
result.running ? "已请求停止当前会话。" : "当前没有正在运行的会话。",
|
|
520
|
+
];
|
|
521
|
+
if (result.approvals > 0)
|
|
522
|
+
lines.push(`已拒绝 ${result.approvals} 条待审批命令。`);
|
|
523
|
+
if (result.dropped > 0)
|
|
524
|
+
lines.push(`已丢弃 ${result.dropped} 条排队中的消息。`);
|
|
525
|
+
await reply(event, lines.join("\n"));
|
|
526
|
+
},
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
ctx.command({
|
|
530
|
+
name: "agent-compact",
|
|
531
|
+
match: /^\.?agent\s+compact$/i,
|
|
532
|
+
prefixes: false,
|
|
533
|
+
permission: "master",
|
|
534
|
+
description: "立即压缩当前会话的较早上下文为摘要",
|
|
535
|
+
handler: async ({ event }) => {
|
|
536
|
+
const userId = await requireUser(host, event);
|
|
537
|
+
if (!userId) return;
|
|
538
|
+
await reply(event, "正在压缩会话上下文…");
|
|
539
|
+
const result = await maybeCompact(host, userId, { force: true });
|
|
540
|
+
await reply(
|
|
541
|
+
event,
|
|
542
|
+
result.compacted
|
|
543
|
+
? `压缩完成:${result.reason},释放约 ${result.freedTokens} tokens。`
|
|
544
|
+
: `未压缩:${result.reason}`,
|
|
545
|
+
);
|
|
546
|
+
},
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
ctx.command({
|
|
550
|
+
name: "agent-status",
|
|
551
|
+
match: /^\.?agent\s+status$/i,
|
|
552
|
+
prefixes: false,
|
|
553
|
+
permission: "master",
|
|
554
|
+
description: "查看 agent 会话、权限、token 用量与运行状态",
|
|
555
|
+
handler: async ({ event }) => {
|
|
556
|
+
const userId = await requireUser(host, event);
|
|
557
|
+
if (!userId) return;
|
|
558
|
+
const base = host.getBase();
|
|
559
|
+
const resolved = host.resolveModel();
|
|
560
|
+
const session = host.sessions.get(userId);
|
|
561
|
+
const shared = await host.getChatShared();
|
|
562
|
+
const stats = host.db.getRunStats(userId);
|
|
563
|
+
const pending = host.approvals.listByUser(userId);
|
|
564
|
+
const history = host.sessions.history(session);
|
|
565
|
+
const messageCount = host.db.countMessages(session.sessionId);
|
|
566
|
+
const emotion = shared.emotion
|
|
567
|
+
? host.emotions.getCurrent(session.emotion, shared.emotion)
|
|
568
|
+
: null;
|
|
569
|
+
|
|
570
|
+
const lines = [
|
|
571
|
+
`会话:${session.sessionId}${session.title ? `「${session.title}」` : ""}`,
|
|
572
|
+
`- 消息:${messageCount} 条(摘要后 ${history.length} 条在上下文${session.summary ? ",含摘要" : ""})`,
|
|
573
|
+
`- 权限级别:${base.permissionLevel}`,
|
|
574
|
+
`- 工作区:${host.workspaceRoot(userId)}`,
|
|
575
|
+
`- 模型:${resolved ? resolved.model : "(未配置)"}${
|
|
576
|
+
resolved?.isMultimodal ? "(多模态)" : ""
|
|
577
|
+
}`,
|
|
578
|
+
`- 情绪:${emotion ?? "无"}`,
|
|
579
|
+
];
|
|
580
|
+
lines.push(...buildUsageLines(host, session.sessionId));
|
|
581
|
+
lines.push(
|
|
582
|
+
`- 历史运行:${stats.runs} 次 / 工具调用 ${stats.toolCalls} 次`,
|
|
583
|
+
`- 目标:${session.goal ? session.goal.slice(0, 80) : "无"}`,
|
|
584
|
+
`- 计划:${
|
|
585
|
+
session.plan.length > 0
|
|
586
|
+
? `${session.plan.filter((item) => item.status === "completed").length}/${session.plan.length} 完成`
|
|
587
|
+
: "无"
|
|
588
|
+
}`,
|
|
589
|
+
`- 待审批:${
|
|
590
|
+
pending.length > 0
|
|
591
|
+
? pending
|
|
592
|
+
.map(
|
|
593
|
+
(item) =>
|
|
594
|
+
`${item.command.slice(0, 40)}(用途:${item.purpose.slice(0, 40)})`,
|
|
595
|
+
)
|
|
596
|
+
.join("; ")
|
|
597
|
+
: "无"
|
|
598
|
+
}`,
|
|
599
|
+
);
|
|
600
|
+
await reply(event, lines.join("\n"));
|
|
601
|
+
},
|
|
602
|
+
});
|
|
603
|
+
}
|