@cmach_socket/opencode-qq-bot 0.2.0 → 0.3.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/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
  - 群聊 / 私聊消息 → OpenCode 会话 → 模型回复 → 对应聊天
7
7
  - 每个聊天固定对应一个会话,会话忙碌时新消息先缓存,空闲后合并成一轮再投进去;绑定写入插件存储,重启后自动沿用原会话
8
8
  - 语音自动转写、图片落盘后以文件引用进入会话、引用与合并转发展开成文本块
9
- - 角色设定放在 `SOUL.txt`
9
+ - 角色设定放在 `souls/` 目录,可放多份,按文件名切换,改动后下一轮立即生效
10
10
 
11
11
  ## 环境要求
12
12
 
@@ -54,26 +54,27 @@ bun install
54
54
  插件不会读取项目 `.env`。如果选择用环境变量配置,必须把变量注入 OpenCode 服务进程本身,
55
55
  可参考 `.env.example`;`cp .env.example .env` 不会生效。
56
56
 
57
- | 选项 | 环境变量 | 默认值 | 说明 |
58
- | ----------------------------------- | ------------------------------- | --------------- | ----------------------------------------------- |
59
- | `napcat.url` | `NAPCAT_WS_URL` | 无(必填) | NapCat 正向 WebSocket 地址 |
60
- | `napcat.token` | `NAPCAT_WS_TOKEN` | 无 | NapCat 访问令牌 |
61
- | `groups` | `QQ_TARGET_GROUPS` | `[]` | 参与对话的群号,逗号分隔 |
62
- | `users` | `QQ_TARGET_USERS` | `[]` | 参与对话的私聊 QQ 号,逗号分隔 |
63
- | `soulPath` | `QQ_BOT_SOUL_PATH` | 包内 `SOUL.txt` | 角色设定文件路径 |
64
- | `agent` | `QQ_BOT_AGENT` | OpenCode 默认 | 会话使用的 agent |
65
- | `model` | `QQ_BOT_MODEL` | OpenCode 默认 | 会话使用的模型,格式 `provider/model[/variant]` |
66
- | `sendDelayMs` | `QQ_SEND_DELAY_MS` | `1000` | 超长消息分段发送时每段之间的间隔 |
67
- | `warnLength` | `QQ_WARN_LENGTH` | `20` | 一轮回复总长度超过该值时告警 |
68
- | `reuseSession` | `QQ_REUSE_SESSION` | `false` | 没有保存的绑定时,按会话标记找回上一次的会话 |
69
- | `busyTimeoutMs` | `QQ_BUSY_TIMEOUT_MS` | `300000` | 会话超过该时长未结束视为卡死,强制放行缓存消息 |
70
- | `proactive.enabled` | `QQ_PROACTIVE_ENABLED` | `true` | 是否启用主动说话 |
71
- | `proactive.silenceThresholdSeconds` | `QQ_PROACTIVE_SILENCE_SECONDS` | `10800` | 沉默多久后才开始计算主动说话概率 |
72
- | `proactive.startProbability` | `QQ_PROACTIVE_P_START` | `0.01` | 刚跨过阈值时的概率 |
73
- | `proactive.probabilityPerHour` | `QQ_PROACTIVE_P_SLOPE_PER_HOUR` | `0.05` | 每多沉默一小时概率的增量 |
74
- | `proactive.tickSeconds` | `QQ_PROACTIVE_TICK_SECONDS` | `3600` | 心跳间隔,必须大于 0 |
75
- | `reminder.enabled` | `QQ_REMINDER_ENABLED` | `true` | 模型一轮没调用发送工具时是否提醒 |
76
- | `reminder.max` | `QQ_REMINDER_MAX` | `2` | 连续提醒次数上限 |
57
+ | 选项 | 环境变量 | 默认值 | 说明 |
58
+ | ----------------------------------- | ------------------------------- | ------------- | ----------------------------------------------- |
59
+ | `napcat.url` | `NAPCAT_WS_URL` | 无(必填) | NapCat 正向 WebSocket 地址 |
60
+ | `napcat.token` | `NAPCAT_WS_TOKEN` | 无 | NapCat 访问令牌 |
61
+ | `groups` | `QQ_TARGET_GROUPS` | `[]` | 参与对话的群号,逗号分隔 |
62
+ | `users` | `QQ_TARGET_USERS` | `[]` | 参与对话的私聊 QQ 号,逗号分隔 |
63
+ | `soulPath` | `QQ_BOT_SOUL_PATH` | 包内 `souls/` | 角色设定文件或目录路径 |
64
+ | `soulName` | `QQ_BOT_SOUL_NAME` | `default.txt` | `souls` 目录里的角色设定文件名 |
65
+ | `agent` | `QQ_BOT_AGENT` | OpenCode 默认 | 会话使用的 agent |
66
+ | `model` | `QQ_BOT_MODEL` | OpenCode 默认 | 会话使用的模型,格式 `provider/model[/variant]` |
67
+ | `sendDelayMs` | `QQ_SEND_DELAY_MS` | `1000` | 超长消息分段发送时每段之间的间隔 |
68
+ | `warnLength` | `QQ_WARN_LENGTH` | `20` | 一轮回复总长度超过该值时告警 |
69
+ | `reuseSession` | `QQ_REUSE_SESSION` | `false` | 没有保存的绑定时,按会话标记找回上一次的会话 |
70
+ | `busyTimeoutMs` | `QQ_BUSY_TIMEOUT_MS` | `300000` | 会话超过该时长未结束视为卡死,强制放行缓存消息 |
71
+ | `proactive.enabled` | `QQ_PROACTIVE_ENABLED` | `true` | 是否启用主动说话 |
72
+ | `proactive.silenceThresholdSeconds` | `QQ_PROACTIVE_SILENCE_SECONDS` | `10800` | 沉默多久后才开始计算主动说话概率 |
73
+ | `proactive.startProbability` | `QQ_PROACTIVE_P_START` | `0.01` | 刚跨过阈值时的概率 |
74
+ | `proactive.probabilityPerHour` | `QQ_PROACTIVE_P_SLOPE_PER_HOUR` | `0.05` | 每多沉默一小时概率的增量 |
75
+ | `proactive.tickSeconds` | `QQ_PROACTIVE_TICK_SECONDS` | `3600` | 心跳间隔,必须大于 0 |
76
+ | `reminder.enabled` | `QQ_REMINDER_ENABLED` | `true` | 模型一轮没调用发送工具时是否提醒 |
77
+ | `reminder.max` | `QQ_REMINDER_MAX` | `2` | 连续提醒次数上限 |
77
78
 
78
79
  没配置 `groups` 和 `users` 时插件不会响应任何消息(默认全部关闭)。
79
80
 
@@ -81,6 +82,34 @@ bun install
81
82
  写错的配置项会输出告警并回退到默认值,不会静默失效。
82
83
  取值规则与图片内联限制见 [docs/configuration.md](docs/configuration.md)。
83
84
 
85
+ ### 角色设定
86
+
87
+ 角色设定文件放在 `souls/` 目录,一个目录里可以放多份,用 `soulName` 选生效的那份:
88
+
89
+ ```
90
+ souls/
91
+ default.txt 默认角色
92
+ 白羽桃.txt 另一份角色
93
+ ```
94
+
95
+ ```jsonc
96
+ {
97
+ "plugins": [
98
+ {
99
+ "package": "./",
100
+ "options": {
101
+ "soulName": "白羽桃.txt",
102
+ },
103
+ },
104
+ ],
105
+ }
106
+ ```
107
+
108
+ - `soulName` 填 `souls` 目录里的文件名,默认 `default.txt`。
109
+ - `soulPath` 可以直接给文件路径(相对路径按进程工作目录解析),也可以给目录,目录时同样用 `soulName` 选文件。
110
+ - 生效文件的内容改动会被监听,下一轮就用新设定,不需要重启插件。
111
+ - 改 `soulName` 换角色:写进 `opencode.json(c)` 保存后 OpenCode 会重载插件,随即生效。
112
+
84
113
  ## 工具
85
114
 
86
115
  模型只能操作当前会话绑定的群聊或私聊,工具都带 `qq_` 前缀:
@@ -158,6 +187,7 @@ bun run format # prettier --write .
158
187
  ## 目录
159
188
 
160
189
  ```
190
+ souls/ 角色设定文件(可多份,按文件名切换)
161
191
  src/
162
192
  config.ts 配置合并与校验
163
193
  index.ts 插件入口
@@ -165,8 +195,10 @@ src/
165
195
  logger.ts 日志
166
196
  naplink/client.ts NapLink 连接与 API 门面
167
197
  naplink/render.ts 消息段 → 文本与图片附件
168
- session/prompt.ts 角色设定与系统提示
198
+ session/prompt.ts 固定守则与系统提示
199
+ session/soul.ts 角色设定定位、读取与热更新
169
200
  session/manager.ts 群聊 / 私聊 ↔ 会话映射、消息缓存
201
+ tools/ask-guard.ts 拦截内置 question 工具
170
202
  tools/qq.ts QQ 工具注册
171
203
  docs/
172
204
  configuration.md 配置取值规则与限制
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cmach_socket/opencode-qq-bot",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "OpenCode V2 plugin bridging a QQ group chat (NapCat via NapLink) to an OpenCode session.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -13,7 +13,7 @@
13
13
  },
14
14
  "files": [
15
15
  "src",
16
- "SOUL.txt",
16
+ "souls",
17
17
  "README.md"
18
18
  ],
19
19
  "scripts": {
package/src/config.ts CHANGED
@@ -52,8 +52,10 @@ export interface QQBotConfig {
52
52
  groups: number[]
53
53
  /** 参与 AI 对话的私聊 QQ 号。 */
54
54
  users: number[]
55
- /** 角色设定文件路径,默认使用插件包内的 SOUL.txt。 */
55
+ /** 角色设定文件路径,默认使用插件包内的 souls 目录。 */
56
56
  soulPath?: string
57
+ /** souls 目录里的角色设定文件名,默认 default.txt。 */
58
+ soulName?: string
57
59
  /** QQ 会话使用的 OpenCode agent。 */
58
60
  agent?: string
59
61
  /** QQ 会话使用的模型。 */
@@ -279,6 +281,7 @@ function createConfigSchema(
279
281
  groups,
280
282
  users,
281
283
  soulPath: text("soulPath", defaults.soulPath),
284
+ soulName: text("soulName", defaults.soulName),
282
285
  agent: text("agent", defaults.agent),
283
286
  model,
284
287
  sendDelayMs: nonNegative("sendDelayMs", defaults.sendDelayMs),
@@ -320,6 +323,7 @@ const ENV_VARS = {
320
323
  groups: "QQ_TARGET_GROUPS",
321
324
  users: "QQ_TARGET_USERS",
322
325
  soulPath: "QQ_BOT_SOUL_PATH",
326
+ soulName: "QQ_BOT_SOUL_NAME",
323
327
  agent: "QQ_BOT_AGENT",
324
328
  model: "QQ_BOT_MODEL",
325
329
  sendDelayMs: "QQ_SEND_DELAY_MS",
@@ -340,6 +344,7 @@ const OPTION_KEYS = new Set([
340
344
  "groups",
341
345
  "users",
342
346
  "soulPath",
347
+ "soulName",
343
348
  "agent",
344
349
  "model",
345
350
  "sendDelayMs",
package/src/index.ts CHANGED
@@ -8,15 +8,10 @@ import { QQClient } from "./naplink/client"
8
8
  import { createMediaStore } from "./naplink/media"
9
9
  import { mentionsUser, renderMessage, resolveUserName, shouldProcess } from "./naplink/render"
10
10
  import { SessionManager } from "./session/manager"
11
- import {
12
- buildSystemPrompt,
13
- currentTimeText,
14
- loadSoul,
15
- PROACTIVE_HINT,
16
- resolveSoulPath,
17
- packageDirectory,
18
- } from "./session/prompt"
11
+ import { currentTimeText, PROACTIVE_HINT } from "./session/prompt"
19
12
  import { proactiveTickProbability } from "./session/proactive"
13
+ import { packageDirectory, SoulStore } from "./session/soul"
14
+ import { registerAskGuard } from "./tools/ask-guard"
20
15
  import { registerTools } from "./tools/qq"
21
16
 
22
17
  const RETRY_DELAY_MS = 1000
@@ -53,10 +48,17 @@ export default Plugin.define({
53
48
  }
54
49
  }
55
50
 
56
- const soulPath = resolveSoulPath(config.soulPath, packageDirectory(), process.cwd())
57
- const soul = await loadSoul(soulPath)
58
- if (!soul) logger.warn(`角色设定读取失败:${soulPath ?? "(未找到)"}`)
59
- const systemPrompt = buildSystemPrompt(soul)
51
+ // 角色设定:读取生效文件并监听改动,改完下一轮即生效。
52
+ const soul = new SoulStore(
53
+ {
54
+ soulPath: config.soulPath,
55
+ soulName: config.soulName,
56
+ moduleDir: packageDirectory(),
57
+ cwd: process.cwd(),
58
+ },
59
+ logger,
60
+ )
61
+ await soul.load()
60
62
 
61
63
  const client = new QQClient(config.napcat, logger)
62
64
  const manager = new SessionManager(ctx, config, logger)
@@ -71,13 +73,15 @@ export default Plugin.define({
71
73
  const mediaHandle = mediaCleanup as { unref?: () => void }
72
74
  mediaHandle.unref?.()
73
75
  const toolRegistration = await registerTools({ ctx, config, logger, manager, client })
76
+ // QQ 会话里禁止模型调用内置 question 工具,改用 qq_send_message 询问用户。
77
+ const askGuardRegistration = await registerAskGuard({ ctx, logger, manager })
74
78
  const nameCache = new Map<string, { name: string; expires: number }>()
75
79
 
76
80
  // 系统提示:角色设定、当前时间、本轮是否必须回复。
77
81
  const contextRegistration = await ctx.session.hook("context", (event) => {
78
82
  const target = manager.targetForSession(event.sessionID)
79
83
  if (target === undefined) return
80
- event.system.push({ type: "text", text: systemPrompt })
84
+ event.system.push({ type: "text", text: soul.prompt })
81
85
  event.system.push({ type: "text", text: `当前时间:${currentTimeText()}` })
82
86
  const turn = manager.currentTurn(event.sessionID)
83
87
  const destination = target.kind === "group" ? "群里" : "私聊里"
@@ -232,7 +236,9 @@ export default Plugin.define({
232
236
  clearInterval(mediaCleanup)
233
237
  idleAbort.abort()
234
238
  await manager.flush()
239
+ soul.dispose()
235
240
  await toolRegistration.dispose()
241
+ await askGuardRegistration.dispose()
236
242
  await contextRegistration.dispose()
237
243
  await idleRegistration.dispose()
238
244
  client.dispose()
@@ -8,6 +8,83 @@ import {
8
8
  import type { Logger } from "../logger"
9
9
  import type { NapcatConfig } from "../config"
10
10
 
11
+ const MEDIA_SEGMENT_TYPES = ["image", "video", "record", "audio", "file"] as const
12
+
13
+ type MediaSegmentType = (typeof MEDIA_SEGMENT_TYPES)[number]
14
+
15
+ interface MediaSegmentData {
16
+ file?: string
17
+ file_id?: string
18
+ url?: string
19
+ }
20
+
21
+ interface MediaFileLike {
22
+ file?: unknown
23
+ url?: unknown
24
+ }
25
+
26
+ function isMediaSegmentType(type: string): type is MediaSegmentType {
27
+ return (MEDIA_SEGMENT_TYPES as readonly string[]).includes(type)
28
+ }
29
+
30
+ /** 取第一个可下载的 http(s) 直链,忽略本地路径与 base64。 */
31
+ export function pickRemoteUrl(...values: unknown[]): string | undefined {
32
+ for (const value of values) {
33
+ if (typeof value === "string" && /^https?:\/\//i.test(value)) return value
34
+ }
35
+ return undefined
36
+ }
37
+
38
+ /**
39
+ * 补全单个媒体段的远程直链。
40
+ *
41
+ * NapCat 同时返回本地路径(`file`)和 CDN 直链(`url`),SDK 自带的补全逻辑
42
+ * 会优先选用本地路径,导致插件无法下载。这里只写入 `url`,且优先使用 http(s) 地址。
43
+ */
44
+ export async function hydrateSegment(
45
+ api: OneBotApi,
46
+ segment: MessageSegment,
47
+ logger: Logger,
48
+ ): Promise<void> {
49
+ if (!isMediaSegmentType(segment.type)) return
50
+ const data = segment.data as MediaSegmentData
51
+ // 已有可用的远程直链时不要覆盖。
52
+ if (pickRemoteUrl(data.url)) return
53
+ const fileId = data.file ?? data.file_id
54
+ if (typeof fileId !== "string" || fileId.length === 0) return
55
+ if (/^(file|base64|data):/i.test(fileId)) return
56
+
57
+ try {
58
+ const file = (await api.getFile(fileId)) as MediaFileLike | undefined
59
+ const direct = pickRemoteUrl(file?.url, file?.file)
60
+ if (direct) {
61
+ data.url = direct
62
+ return
63
+ }
64
+ if (segment.type === "image") {
65
+ const image = (await api.getImage(fileId)) as MediaFileLike | undefined
66
+ const imageUrl = pickRemoteUrl(image?.url, image?.file)
67
+ if (imageUrl) data.url = imageUrl
68
+ } else if (segment.type === "record" || segment.type === "audio") {
69
+ const record = (await api.getRecord(fileId, "mp3")) as MediaFileLike | undefined
70
+ const recordUrl = pickRemoteUrl(record?.url, record?.file)
71
+ if (recordUrl) data.url = recordUrl
72
+ }
73
+ } catch (error) {
74
+ logger.debug(`补全媒体直链失败 type=${segment.type} file=${fileId}`, error)
75
+ }
76
+ }
77
+
78
+ /** 补全一条消息里所有媒体段的远程直链。 */
79
+ export async function hydrateMedia(
80
+ api: OneBotApi,
81
+ message: MessageSegment[],
82
+ logger: Logger,
83
+ ): Promise<void> {
84
+ if (!Array.isArray(message)) return
85
+ await Promise.all(message.map((segment) => hydrateSegment(api, segment, logger)))
86
+ }
87
+
11
88
  /** NapCat 的连接与 API 门面,屏蔽 SDK 细节。 */
12
89
  export class QQClient {
13
90
  private readonly link: NapLink
@@ -105,7 +182,7 @@ export class QQClient {
105
182
  /** 补全消息里媒体段的直链,便于下载图片。 */
106
183
  async hydrate(message: MessageSegment[]): Promise<void> {
107
184
  try {
108
- await this.link.hydrateMessage(message)
185
+ await hydrateMedia(this.api, message, this.logger)
109
186
  } catch (error) {
110
187
  this.logger.warn("补全媒体直链失败", error)
111
188
  }
@@ -83,6 +83,11 @@ function truncateCodePoints(text: string, max: number): string {
83
83
  return Array.from(text).slice(0, max).join("")
84
84
  }
85
85
 
86
+ /** 截断过长的地址,避免日志被超长 URL 淹没。 */
87
+ function truncateForLog(value: string, max = 120): string {
88
+ return value.length > max ? `${value.slice(0, max)}…` : value
89
+ }
90
+
86
91
  /** 判断主机名是否指向本机或内网,避免下载请求打内网服务。 */
87
92
  export function isPrivateHost(hostname: string): boolean {
88
93
  let host = hostname.trim().toLowerCase()
@@ -199,7 +204,10 @@ export class MediaStore {
199
204
  return { bytes, mime }
200
205
  }
201
206
  if (url.startsWith("data:")) return parseDataUrl(url)
202
- if (!url.startsWith("http://") && !url.startsWith("https://")) return undefined
207
+ if (!url.startsWith("http://") && !url.startsWith("https://")) {
208
+ this.logger.warn(`不支持的图片地址,跳过下载 url=${truncateForLog(url)}`)
209
+ return undefined
210
+ }
203
211
 
204
212
  let parsed: URL
205
213
  try {
@@ -2,6 +2,7 @@ import type { MessageEvent, MessageSegment, OneBotApi } from "@naplink/naplink"
2
2
  import type { ChatTarget } from "../chat"
3
3
  import { targetKey } from "../chat"
4
4
  import type { Logger } from "../logger"
5
+ import { hydrateSegment } from "./client"
5
6
  import {
6
7
  MAX_IMAGE_BYTES,
7
8
  inlineImageBytes,
@@ -174,8 +175,10 @@ async function renderForwardSegments(
174
175
  break
175
176
  case "image": {
176
177
  parts.push("[图片]")
178
+ if (budget.images <= 0) break
179
+ await hydrateSegment(ctx.api, segment, ctx.logger)
177
180
  const url = segment.data.url || segment.data.file
178
- if (!url || budget.images <= 0) break
181
+ if (!url) break
179
182
  const stored = await ctx.media.storeImage(url)
180
183
  if (!stored || !takeImage(budget, stored)) break
181
184
  images.push(stored)
@@ -363,8 +366,10 @@ export async function renderMessage(
363
366
  break
364
367
  case "image": {
365
368
  textBuffer.push("[图片] ")
369
+ if (budget.images <= 0) break
370
+ await hydrateSegment(ctx.api, segment, ctx.logger)
366
371
  const url = segment.data.url || segment.data.file
367
- if (!url || budget.images <= 0) break
372
+ if (!url) break
368
373
  const stored = await ctx.media.storeImage(url)
369
374
  if (!stored || !takeImage(budget, stored)) break
370
375
  imageIndex += 1
@@ -1,7 +1,3 @@
1
- import { readFile } from "node:fs/promises"
2
- import { existsSync } from "node:fs"
3
- import { dirname, isAbsolute, join, resolve } from "node:path"
4
-
5
1
  /** 发消息用的工具名(命名空间 qq 下的 send_message)。 */
6
2
  export const SEND_MESSAGE_TOOL = "qq_send_message"
7
3
 
@@ -23,41 +19,6 @@ export const PROACTIVE_HINT = `
23
19
  你需要调用 ${SEND_MESSAGE_TOOL} 工具把话题发到当前聊天。
24
20
  `
25
21
 
26
- /** 从模块所在目录向上查找 SOUL.txt,兼容 src/ 与 dist/ 两种布局。 */
27
- export function findSoulFile(startDir: string, maxDepth = 5): string | undefined {
28
- let current = resolve(startDir)
29
- for (let depth = 0; depth <= maxDepth; depth += 1) {
30
- const candidate = join(current, "SOUL.txt")
31
- if (existsSync(candidate)) return candidate
32
- const parent = dirname(current)
33
- if (parent === current) break
34
- current = parent
35
- }
36
- return undefined
37
- }
38
-
39
- /** 定位角色设定文件:显式路径优先,否则用包内默认。 */
40
- export function resolveSoulPath(
41
- explicitPath: string | undefined,
42
- moduleDir: string,
43
- cwd: string,
44
- ): string | undefined {
45
- if (explicitPath) {
46
- return isAbsolute(explicitPath) ? explicitPath : resolve(cwd, explicitPath)
47
- }
48
- return findSoulFile(moduleDir)
49
- }
50
-
51
- /** 读取角色设定,失败时返回空字符串。 */
52
- export async function loadSoul(path: string | undefined): Promise<string> {
53
- if (!path) return ""
54
- try {
55
- return await readFile(path, "utf-8")
56
- } catch {
57
- return ""
58
- }
59
- }
60
-
61
22
  /** 组装完整的系统提示词。 */
62
23
  export function buildSystemPrompt(soul: string): string {
63
24
  const base = soul.trim()
@@ -78,8 +39,3 @@ const TIME_FORMAT = new Intl.DateTimeFormat("sv-SE", {
78
39
  export function currentTimeText(now = new Date()): string {
79
40
  return TIME_FORMAT.format(now)
80
41
  }
81
-
82
- /** 供测试与其它模块定位包内 SOUL.txt。 */
83
- export function packageDirectory(): string {
84
- return import.meta.dirname
85
- }
@@ -0,0 +1,180 @@
1
+ import { readFile } from "node:fs/promises"
2
+ import { existsSync, statSync, watch, type FSWatcher } from "node:fs"
3
+ import { dirname, isAbsolute, join, resolve } from "node:path"
4
+ import type { Logger } from "../logger"
5
+ import { buildSystemPrompt } from "./prompt"
6
+
7
+ /** 角色设定目录名。 */
8
+ export const SOUL_DIR = "souls"
9
+
10
+ /** 默认角色设定文件名。 */
11
+ export const DEFAULT_SOUL_NAME = "default.txt"
12
+
13
+ /** 文件变化后的合并等待时间,避免编辑器连续写盘时重复读取。 */
14
+ const RELOAD_DEBOUNCE_MS = 150
15
+
16
+ export interface SoulOptions {
17
+ /** 显式指定的角色设定文件或目录,优先于 soulName。 */
18
+ soulPath?: string
19
+ /** souls 目录里的文件名。 */
20
+ soulName?: string
21
+ /** 插件所在目录,用于向上查找包内 souls 目录。 */
22
+ moduleDir: string
23
+ /** 相对路径的基准目录。 */
24
+ cwd: string
25
+ }
26
+
27
+ /** 从 startDir 向上逐级查找 souls/<name>。 */
28
+ export function findSoulFile(
29
+ startDir: string,
30
+ name: string = DEFAULT_SOUL_NAME,
31
+ maxDepth = 5,
32
+ ): string | undefined {
33
+ let current = resolve(startDir)
34
+ for (let depth = 0; depth <= maxDepth; depth += 1) {
35
+ const candidate = join(current, SOUL_DIR, name)
36
+ if (existsSync(candidate)) return candidate
37
+ const parent = dirname(current)
38
+ if (parent === current) break
39
+ current = parent
40
+ }
41
+ return undefined
42
+ }
43
+
44
+ /** 找不到文件时给出预期路径,文件补齐后可被监听器捡到。 */
45
+ export function expectedSoulPath(startDir: string, name: string, maxDepth = 5): string {
46
+ let current = resolve(startDir)
47
+ for (let depth = 0; depth <= maxDepth; depth += 1) {
48
+ const directory = join(current, SOUL_DIR)
49
+ if (existsSync(directory)) return join(directory, name)
50
+ const parent = dirname(current)
51
+ if (parent === current) break
52
+ current = parent
53
+ }
54
+ return join(resolve(startDir), SOUL_DIR, name)
55
+ }
56
+
57
+ /** 定位角色设定文件:显式路径优先,其次 souls 目录里的指定文件。 */
58
+ export function resolveSoulPath(options: SoulOptions): string {
59
+ const name = options.soulName?.trim() || DEFAULT_SOUL_NAME
60
+ const explicit = options.soulPath?.trim()
61
+ if (!explicit) {
62
+ return findSoulFile(options.moduleDir, name) ?? expectedSoulPath(options.moduleDir, name)
63
+ }
64
+
65
+ const target = isAbsolute(explicit) ? explicit : resolve(options.cwd, explicit)
66
+ if (existsSync(target) && statSync(target).isDirectory()) return join(target, name)
67
+ return target
68
+ }
69
+
70
+ /** 读取角色设定,失败时返回空字符串。 */
71
+ export async function loadSoul(path: string | undefined): Promise<string> {
72
+ if (!path) return ""
73
+ try {
74
+ return await readFile(path, "utf-8")
75
+ } catch {
76
+ return ""
77
+ }
78
+ }
79
+
80
+ /**
81
+ * 角色设定持有者:读取生效的设定文件,并在文件变化后热更新。
82
+ *
83
+ * 提示词在每轮组装时读取当前内容,所以文件改动会在下一轮立即生效。
84
+ */
85
+ export class SoulStore {
86
+ #path: string | undefined
87
+ #text = ""
88
+ #watcher: FSWatcher | undefined
89
+ #timer: ReturnType<typeof setTimeout> | undefined
90
+ #logger: Logger
91
+
92
+ constructor(
93
+ private readonly options: SoulOptions,
94
+ logger: Logger,
95
+ ) {
96
+ this.#logger = logger
97
+ }
98
+
99
+ /** 生效的角色设定文件路径。 */
100
+ get path(): string | undefined {
101
+ return this.#path
102
+ }
103
+
104
+ /** 当前角色设定文本。 */
105
+ get text(): string {
106
+ return this.#text
107
+ }
108
+
109
+ /** 角色设定与固定守则组装出的系统提示词。 */
110
+ get prompt(): string {
111
+ return buildSystemPrompt(this.#text)
112
+ }
113
+
114
+ /** 解析路径、读取内容并开始监听文件变化。 */
115
+ async load(): Promise<void> {
116
+ this.#path = resolveSoulPath(this.options)
117
+ this.#text = await loadSoul(this.#path)
118
+ if (!this.#text) this.#logger.warn(`角色设定读取失败:${this.#path}`)
119
+ else this.#logger.info(`角色设定已加载:${this.#path}`)
120
+ this.#watch()
121
+ }
122
+
123
+ /** 重新读取角色设定文件,内容有变化时返回 true。 */
124
+ async reload(): Promise<boolean> {
125
+ if (!this.#path) return false
126
+ const text = await loadSoul(this.#path)
127
+ if (text === this.#text) return false
128
+ this.#text = text
129
+ this.#logger.info(`角色设定已更新:${this.#path}`)
130
+ return true
131
+ }
132
+
133
+ dispose(): void {
134
+ if (this.#timer) {
135
+ clearTimeout(this.#timer)
136
+ this.#timer = undefined
137
+ }
138
+ this.#watcher?.close()
139
+ this.#watcher = undefined
140
+ }
141
+
142
+ /** 监听所在目录:编辑器多为替换文件,只盯目录才能捕捉到。 */
143
+ #watch(): void {
144
+ if (!this.#path) return
145
+ const directory = dirname(this.#path)
146
+ if (!existsSync(directory)) return
147
+ try {
148
+ this.#watcher = watch(directory, (_event, filename) => {
149
+ const name = filename ? String(filename) : undefined
150
+ if (name && !this.#matches(name, directory)) return
151
+ this.#scheduleReload()
152
+ })
153
+ } catch (error) {
154
+ this.#logger.warn("角色设定监听失败,改动后需要重载插件", error)
155
+ return
156
+ }
157
+ const handle = this.#watcher as { unref?: () => void }
158
+ handle.unref?.()
159
+ }
160
+
161
+ #matches(filename: string, directory: string): boolean {
162
+ if (!this.#path) return false
163
+ return resolve(directory, filename) === this.#path
164
+ }
165
+
166
+ #scheduleReload(): void {
167
+ if (this.#timer) clearTimeout(this.#timer)
168
+ this.#timer = setTimeout(() => {
169
+ this.#timer = undefined
170
+ void this.reload()
171
+ }, RELOAD_DEBOUNCE_MS)
172
+ const handle = this.#timer as { unref?: () => void }
173
+ handle.unref?.()
174
+ }
175
+ }
176
+
177
+ /** 供测试与其它模块定位插件包目录。 */
178
+ export function packageDirectory(): string {
179
+ return import.meta.dirname
180
+ }
@@ -0,0 +1,32 @@
1
+ import type { Plugin } from "@opencode/plugin"
2
+ import { targetLabel } from "../chat"
3
+ import type { Logger } from "../logger"
4
+ import type { SessionManager } from "../session/manager"
5
+
6
+ /** OpenCode 内置的“向用户提问”工具 id。 */
7
+ export const QUESTION_TOOL = "question"
8
+
9
+ /** 拒绝提问工具时回给模型的说明。 */
10
+ export const ASK_REFUSAL =
11
+ "不允许调用 question 工具:QQ 会话里请直接用 qq_send_message 询问用户(Code Mode 下调用方式为 tools.qq.send_message)。"
12
+
13
+ export interface AskGuardDeps {
14
+ ctx: Plugin.Context
15
+ logger: Logger
16
+ manager: SessionManager
17
+ }
18
+
19
+ /**
20
+ * 在 QQ 会话里拦截内置 question 工具,要求模型改用 qq_send_message 询问用户。
21
+ * 非 QQ 会话不受影响。
22
+ */
23
+ export async function registerAskGuard(deps: AskGuardDeps): Promise<{ dispose(): Promise<void> }> {
24
+ const { ctx, logger, manager } = deps
25
+ return await ctx.tool.hook("execute.before", async (event) => {
26
+ if (event.tool !== QUESTION_TOOL) return
27
+ const target = manager.targetForSession(event.sessionID)
28
+ if (!target) return
29
+ logger.warn(`${targetLabel(target)} 会话里模型尝试调用 ${QUESTION_TOOL} 工具,已拒绝`)
30
+ throw new Error(ASK_REFUSAL)
31
+ })
32
+ }
File without changes