@nestim/koishi-plugin-qqgm-chat-style 1.0.2 → 1.0.4

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
@@ -1,140 +1,140 @@
1
- # @nestim/koishi-plugin-qqgm-chat-style
2
-
3
- Koishi 聊天风格插件,配合 [`@nestim/koishi-plugin-qq-group-manager`](https://www.npmjs.com/package/@nestim/koishi-plugin-qq-group-manager) 使用。
4
-
5
- **一个主风格决定它是什么人,若干追加风格按概率随机加味,主风格永远优先。**
6
-
7
- ## 相关插件
8
-
9
- | 插件 | 说明 |
10
- | --- | --- |
11
- | [`@nestim/koishi-plugin-qq-group-manager`](https://github.com/Miseat/koishi-plugin-qq-group-manager) | **主插件**:群管、自动管控、AI 回复、记忆库、识图、定时提醒 |
12
- | **`@nestim/koishi-plugin-qqgm-chat-style`** | 本插件:聊天风格 |
1
+ # @nestim/koishi-plugin-qqgm-chat-style
2
+
3
+ Koishi 聊天风格插件,配合 [`@nestim/koishi-plugin-qq-group-manager`](https://www.npmjs.com/package/@nestim/koishi-plugin-qq-group-manager) 使用。
4
+
5
+ **一个主风格决定它是什么人,若干追加风格按概率随机加味,主风格永远优先。**
6
+
7
+ ## 相关插件
8
+
9
+ | 插件 | 说明 |
10
+ | --- | --- |
11
+ | [`@nestim/koishi-plugin-qq-group-manager`](https://github.com/Miseat/koishi-plugin-qq-group-manager) | **主插件**:群管、自动管控、AI 回复、记忆库、识图、定时提醒 |
12
+ | **`@nestim/koishi-plugin-qqgm-chat-style`** | 本插件:聊天风格 |
13
13
  | [`@nestim/koishi-plugin-qqgm-meme`](https://github.com/Miseat/koishi-plugin-qqgm-meme) | 表情包:按情绪标签抽图,按概率补发 |
14
- | [`@nestim/koishi-plugin-qqgm-private`](https://github.com/Miseat/koishi-plugin-qqgm-private) | 私聊:AI 自动回复 + 定时主动私发 |
15
-
16
- 配套 skill(可直接放进 `data/chat-styles/`):
17
-
18
- - [northern-netizen-chat](https://github.com/Miseat/northern-netizen-chat) —— 中文北方网友,短句、带刺、归因降维
19
- - [naruto-mobile-chat](https://github.com/Miseat/naruto-mobile-chat) —— 火影忍者手游圈口癖,适合当追加风格
20
-
21
- ## 特性
22
-
23
- | 能力 | 说明 |
24
- | --- | --- |
25
- | **主风格** | 读取一个 `.skill`(`SKILL.md`),整段并入 AI 系统提示词,决定基本人格与说话方式 |
26
- | **追加风格** | 无限添加;每个独立设置触发概率,用内部随机数决定这一轮叠不叠 |
27
- | **不喧宾夺主** | 追加风格自带约束段:主风格的语气、句式、长度始终优先,冲突时一律按主风格来 |
28
- | **概率可调** | 0 = 永不叠加,1 = 每次都叠;实测 2000 轮命中率与设定值吻合 |
29
- | **轮内一致** | 同一轮里提示词和输出过滤器看到同一个随机结果,不会“提示词叠了、过滤没叠” |
30
- | **输出规范** | 去 Markdown / CQ 码 / 链接、删禁词、限行数字数、去句末句号 |
31
-
32
- ## 安装
33
-
34
- ```sh
35
- npm i @nestim/koishi-plugin-qqgm-chat-style
36
- ```
37
-
38
- 或在 Koishi 控制台「插件市场」搜索 `qqgm-chat-style`。
39
-
40
- 需要先启用 `@nestim/koishi-plugin-qq-group-manager`(提供 `qqgmAiHooks` 扩展点,>= 1.2.2)。
41
-
42
- ## 准备 skill
43
-
44
- 把风格目录放进 Koishi 数据目录:
45
-
46
- ```text
47
- data/chat-styles/
48
- ├── northern-netizen-chat/
49
- │ └── SKILL.md
50
- └── naruto-mobile-chat/
51
- └── SKILL.md
52
- ```
53
-
54
- `SKILL.md` 需要有 front matter,且类别属于聊天风格:
55
-
56
- ```yaml
57
- ---
58
- name: northern-netizen-chat
59
- metadata:
60
- category: persona-chat # 或 kind: chat-style
61
- ---
62
- ```
63
-
64
- 插件只取正文,并会自动去掉「文件索引」节、对 `references/` `examples/` 的引用、代码块和 HTML 注释。
65
- 正文里如果出现注入话术(如「忽略之前的指令」、`system prompt`、脚本执行等)会被拒绝加载。
66
-
67
- 可用的风格会写进日志;也可以在群里发送 `[bot] style` 列出全部(需主插件已加载)。
68
-
69
- ## 配置
70
-
71
- 配置页分三组。
72
-
73
- ### 主风格
74
-
75
- | 配置 | 默认 | 说明 |
76
- | --- | --- | --- |
77
- | 主风格 | 空 | 填技能目录名,例如 `northern-netizen-chat`。留空则用目录里第一个可用 skill |
78
- | 风格目录 | `data/chat-styles` | 相对 Koishi 数据目录,一般不用改 |
79
-
80
- ### 追加风格
81
-
82
- 一个可无限添加的表格,每行:
83
-
84
- | 字段 | 默认 | 说明 |
85
- | --- | --- | --- |
86
- | 技能名 | 空 | 目录名,例如 `naruto-mobile-chat` |
87
- | 触发概率 | `0.3` | 每轮独立随机;0 从不叠加,1 每次叠加 |
88
- | 启用 | `true` | 临时关掉,不必删除 |
89
- | 字数上限 | `60` | 叠加时的上限 |
90
- | 行数上限 | `3` | 叠加时的上限 |
91
-
92
- ### 输出规范
93
-
94
- | 配置 | 默认 | 说明 |
95
- | --- | --- | --- |
96
- | 单条回复最大字符数 | `60` | 超出按标点截断 |
97
- | 单条回复最大行数 | `3` | 多出的行丢弃 |
98
- | 删词列表 | 见默认 | 命中即从输出中删除 |
99
- | 字面替换 | `……` → `...` | 依次执行 |
100
- | 术语表 | `[]` | 避免缩写歧义,注入提示词 |
101
- | 去 Markdown | `true` | 去标记、链接、代码块 |
102
- | 去末尾句号 | `true` | |
103
- | 调试日志 | `false` | 打印风格组合、随机结果与过滤细节 |
104
-
105
- ## 它是怎么影响输出的
106
-
107
- 主插件的提示词里有一节【说话风格】,所有 `qqgmAiHooks` 的段落会按注册顺序拼进去。本插件注册一段:
108
-
109
- 1. **主风格段**(永远在)——包装后的完整 skill 正文 + 边界声明;
110
- 2. **追加风格段**(按概率)——加味约束 + 该 skill 正文。
111
-
112
- 追加段的开头就写明:主风格的语气、句式、长度始终优先,这段只能增色、不能改写人设、不能换话题。
113
-
114
- ## 示例
115
-
116
- ```yaml
117
- primaryStyle: northern-netizen-chat
118
- extraStyles:
119
- - style: naruto-mobile-chat
120
- probability: 0.3
121
- enabled: true
122
- ```
123
-
124
- 效果:始终是北方网友的口吻;三成概率加点火影手游圈的口癖,但骂人和讲理的方式不变。
125
-
126
- ## 与主插件的接口
127
-
128
- 只依赖 `ctx.qqgmAiHooks`(接口版本 >= 1):
129
-
130
- - `registerPromptSection()` —— 返回风格段
131
- - `registerOutputFilter()` —— 规范输出,返回 `null` 表示本条不发
132
-
133
- 不注册群管指令,不碰触发逻辑和上下文,不读写数据库。
134
-
135
- ## 开源
136
-
137
- MIT。欢迎改、欢迎提 PR。
138
-
139
- - GitHub: <https://github.com/Miseat/koishi-plugin-qqgm-chat-style>
140
- - 主插件: <https://github.com/Miseat/koishi-plugin-qq-group-manager>
14
+ | [`@nestim/koishi-plugin-qqgm-private`](https://github.com/Miseat/koishi-plugin-qqgm-private) | 私聊:AI 自动回复 + 定时主动私发 |
15
+
16
+ 配套 skill(可直接放进 `data/chat-styles/`):
17
+
18
+ - [northern-netizen-chat](https://github.com/Miseat/northern-netizen-chat) —— 中文北方网友,短句、带刺、归因降维
19
+ - [naruto-mobile-chat](https://github.com/Miseat/naruto-mobile-chat) —— 火影忍者手游圈口癖,适合当追加风格
20
+
21
+ ## 特性
22
+
23
+ | 能力 | 说明 |
24
+ | --- | --- |
25
+ | **主风格** | 读取一个 `.skill`(`SKILL.md`),整段并入 AI 系统提示词,决定基本人格与说话方式 |
26
+ | **追加风格** | 无限添加;每个独立设置触发概率,用内部随机数决定这一轮叠不叠 |
27
+ | **不喧宾夺主** | 追加风格自带约束段:主风格的语气、句式、长度始终优先,冲突时一律按主风格来 |
28
+ | **概率可调** | 0 = 永不叠加,1 = 每次都叠;实测 2000 轮命中率与设定值吻合 |
29
+ | **轮内一致** | 同一轮里提示词和输出过滤器看到同一个随机结果,不会“提示词叠了、过滤没叠” |
30
+ | **输出规范** | 去 Markdown / CQ 码 / 链接、删禁词、限行数字数、去句末句号 |
31
+
32
+ ## 安装
33
+
34
+ ```sh
35
+ npm i @nestim/koishi-plugin-qqgm-chat-style
36
+ ```
37
+
38
+ 或在 Koishi 控制台「插件市场」搜索 `qqgm-chat-style`。
39
+
40
+ 需要先启用 `@nestim/koishi-plugin-qq-group-manager`(提供 `qqgmAiHooks` 扩展点,>= 1.2.2)。
41
+
42
+ ## 准备 skill
43
+
44
+ 把风格目录放进 Koishi 数据目录:
45
+
46
+ ```text
47
+ data/chat-styles/
48
+ ├── northern-netizen-chat/
49
+ │ └── SKILL.md
50
+ └── naruto-mobile-chat/
51
+ └── SKILL.md
52
+ ```
53
+
54
+ `SKILL.md` 需要有 front matter,且类别属于聊天风格:
55
+
56
+ ```yaml
57
+ ---
58
+ name: northern-netizen-chat
59
+ metadata:
60
+ category: persona-chat # 或 kind: chat-style
61
+ ---
62
+ ```
63
+
64
+ 插件只取正文,并会自动去掉「文件索引」节、对 `references/` `examples/` 的引用、代码块和 HTML 注释。
65
+ 正文里如果出现注入话术(如「忽略之前的指令」、`system prompt`、脚本执行等)会被拒绝加载。
66
+
67
+ 可用的风格会写进日志;也可以在群里发送 `[bot] style` 列出全部(需主插件已加载)。
68
+
69
+ ## 配置
70
+
71
+ 配置页分三组。
72
+
73
+ ### 主风格
74
+
75
+ | 配置 | 默认 | 说明 |
76
+ | --- | --- | --- |
77
+ | 主风格 | 空 | 填技能目录名,例如 `northern-netizen-chat`。留空则用目录里第一个可用 skill |
78
+ | 风格目录 | `data/chat-styles` | 相对 Koishi 数据目录,一般不用改 |
79
+
80
+ ### 追加风格
81
+
82
+ 一个可无限添加的表格,每行:
83
+
84
+ | 字段 | 默认 | 说明 |
85
+ | --- | --- | --- |
86
+ | 技能名 | 空 | 目录名,例如 `naruto-mobile-chat` |
87
+ | 触发概率 | `0.3` | 每轮独立随机;0 从不叠加,1 每次叠加 |
88
+ | 启用 | `true` | 临时关掉,不必删除 |
89
+ | 字数上限 | `60` | 叠加时的上限 |
90
+ | 行数上限 | `3` | 叠加时的上限 |
91
+
92
+ ### 输出规范
93
+
94
+ | 配置 | 默认 | 说明 |
95
+ | --- | --- | --- |
96
+ | 单条回复最大字符数 | `60` | 超出按标点截断 |
97
+ | 单条回复最大行数 | `3` | 多出的行丢弃 |
98
+ | 删词列表 | 见默认 | 命中即从输出中删除 |
99
+ | 字面替换 | `……` → `...` | 依次执行 |
100
+ | 术语表 | `[]` | 避免缩写歧义,注入提示词 |
101
+ | 去 Markdown | `true` | 去标记、链接、代码块 |
102
+ | 去末尾句号 | `true` | |
103
+ | 调试日志 | `false` | 打印风格组合、随机结果与过滤细节 |
104
+
105
+ ## 它是怎么影响输出的
106
+
107
+ 主插件的提示词里有一节【说话风格】,所有 `qqgmAiHooks` 的段落会按注册顺序拼进去。本插件注册一段:
108
+
109
+ 1. **主风格段**(永远在)——包装后的完整 skill 正文 + 边界声明;
110
+ 2. **追加风格段**(按概率)——加味约束 + 该 skill 正文。
111
+
112
+ 追加段的开头就写明:主风格的语气、句式、长度始终优先,这段只能增色、不能改写人设、不能换话题。
113
+
114
+ ## 示例
115
+
116
+ ```yaml
117
+ primaryStyle: northern-netizen-chat
118
+ extraStyles:
119
+ - style: naruto-mobile-chat
120
+ probability: 0.3
121
+ enabled: true
122
+ ```
123
+
124
+ 效果:始终是北方网友的口吻;三成概率加点火影手游圈的口癖,但骂人和讲理的方式不变。
125
+
126
+ ## 与主插件的接口
127
+
128
+ 只依赖 `ctx.qqgmAiHooks`(接口版本 >= 1):
129
+
130
+ - `registerPromptSection()` —— 返回风格段
131
+ - `registerOutputFilter()` —— 规范输出,返回 `null` 表示本条不发
132
+
133
+ 不注册群管指令,不碰触发逻辑和上下文,不读写数据库。
134
+
135
+ ## 开源
136
+
137
+ MIT。欢迎改、欢迎提 PR。
138
+
139
+ - GitHub: <https://github.com/Miseat/koishi-plugin-qqgm-chat-style>
140
+ - 主插件: <https://github.com/Miseat/koishi-plugin-qq-group-manager>
package/index.js CHANGED
@@ -18,6 +18,7 @@ const {
18
18
  primarySection,
19
19
  extraSection,
20
20
  } = require('./lib/registry');
21
+ const { StyleCardStore } = require('./lib/compact');
21
22
 
22
23
  const name = 'qqgm-chat-style';
23
24
  /**
@@ -91,6 +92,16 @@ const Config = Schema.intersect([
91
92
  debug: Schema.boolean().default(false)
92
93
  .description('日志里打印风格组合、随机结果与过滤细节。'),
93
94
  }).description('输出规范'),
95
+ Schema.object({
96
+ compact: Schema.boolean().default(true)
97
+ .description('把主风格 skill 压缩成一张更短的风格卡(保留人设/语气/口癖,丢掉示例与说明),显著降低每次调用的输入 token。'),
98
+ compactMaxChars: Schema.natural().min(200).max(3000).default(700)
99
+ .description('风格卡的字数上限。越小越省 token,但可能丢口癖细节。'),
100
+ compactModel: Schema.string().default('')
101
+ .description('生成风格卡用的模型;留空沿用主插件的 aiModel。只在首次生成或 skill 正文变更时调用一次。'),
102
+ compactExtras: Schema.boolean().default(false)
103
+ .description('是否也压缩追加风格(默认关;追加风格本身较短,压缩收益有限)。'),
104
+ }).description('风格卡压缩'),
94
105
  ]);
95
106
 
96
107
  function resolveSkillRoot(ctx, config) {
@@ -123,6 +134,13 @@ function apply(ctx, config) {
123
134
  return;
124
135
  }
125
136
 
137
+ // 风格卡:先把主风格(以及可选的追加风格)压成短卡,并缓存到磁盘
138
+ const cards = new StyleCardStore({ ctx, root, logger, config });
139
+ cards.ensure(set.primary.skill, set.primary.id);
140
+ if (config.compactExtras) {
141
+ for (const extra of set.extras) cards.ensure(extra.skill, extra.id);
142
+ }
143
+
126
144
  // 只用 get() 读取:直接访问未注册属性会触发 Koishi 的 "not registered" 告警
127
145
  const findHooks = () => {
128
146
  const read = (target, key) => {
@@ -158,6 +176,10 @@ function apply(ctx, config) {
158
176
  } else {
159
177
  lines.push('', '当前追加风格:(无)');
160
178
  }
179
+ const card = cards.get(set.primary.id);
180
+ lines.push('', `风格卡:${config.compact === false
181
+ ? '已关闭(每次发送完整 skill)'
182
+ : (card ? `已启用(${card.length} 字)` : '生成中/生成失败,暂用完整 skill')}`);
161
183
  if (set.disabled && set.disabled.length) {
162
184
  lines.push('', '已关闭的追加风格:');
163
185
  for (const item of set.disabled) {
@@ -175,16 +197,23 @@ function apply(ctx, config) {
175
197
  function mount(hooks) {
176
198
  mounted = true;
177
199
 
178
- // 主风格:整段并入,任何情况下都在
200
+ // 主风格:整段并入(或压缩后的风格卡),任何情况下都在
179
201
  offs.push(hooks.registerPromptSection(async (hookCtx) => {
180
- const parts = [primarySection(set.primary, config, { direct: !!hookCtx?.session?.isDirect })];
202
+ const primaryCard = cards.get(set.primary.id);
203
+ if (!primaryCard) cards.ensure(set.primary.skill, set.primary.id);
204
+ const parts = [primarySection(set.primary, config, {
205
+ direct: !!hookCtx?.session?.isDirect,
206
+ compactText: primaryCard,
207
+ })];
181
208
  for (const extra of set.extras) {
182
209
  if (extra.probability <= 0) continue;
183
210
  if (!rolls.roll(hookCtx, extra.id, extra.probability)) continue;
184
211
  if (config.debug) {
185
212
  logger.info(`[style] 追加风格命中 ${extra.id} @ ${hookCtx?.groupId || 'direct'}`);
186
213
  }
187
- parts.push(extraSection(extra, extra.probability));
214
+ const extraCard = config.compactExtras ? cards.get(extra.id) : null;
215
+ if (config.compactExtras && !extraCard) cards.ensure(extra.skill, extra.id);
216
+ parts.push(extraSection(extra, extra.probability, extraCard));
188
217
  }
189
218
  return parts.join('\n\n');
190
219
  }));
@@ -215,8 +244,13 @@ function apply(ctx, config) {
215
244
  const extraDesc = set.extras.length
216
245
  ? set.extras.map((item) => `${item.id}(${Math.round(item.probability * 100)}%)`).join('、')
217
246
  : '无';
247
+ const card = cards.get(set.primary.id);
248
+ const cardDesc = config.compact === false
249
+ ? '关'
250
+ : (card ? `${card.length} 字` : '生成中(暂用完整 skill)');
218
251
  logger.info(
219
- `主风格「${set.primary.name}」(${set.primary.id}):正文 ${set.primary.skill.body.length} 字 | 追加风格:${extraDesc}`,
252
+ `主风格「${set.primary.name}」(${set.primary.id}):正文 ${set.primary.skill.body.length} 字`
253
+ + ` | 风格卡:${cardDesc} | 追加风格:${extraDesc}`,
220
254
  );
221
255
  if (config.debug) {
222
256
  logger.info(
package/lib/compact.js ADDED
@@ -0,0 +1,211 @@
1
+ /**
2
+ * lib/compact.js —— 风格卡压缩。
3
+ *
4
+ * 背景:一份完整的风格 skill 动辄 4000+ 字,而它每次模型调用都要整段塞进
5
+ * system prompt。真实用量里这是最大的一块固定开销。
6
+ *
7
+ * 做法:
8
+ * - 第一次运行时让模型把 skill 正文蒸馏成一张 700 字左右的「风格卡」
9
+ * (保留人设、语气、句长、口癖、称呼、禁忌;丢掉示例、解释、元信息);
10
+ * - 风格卡按 skill 正文的哈希缓存到 `<skillDir>/.style-cards.json`,
11
+ * 正文没变就一直复用,不会每次启动都调模型;
12
+ * - 生成失败时不影响聊天:继续用完整 skill,隔一段时间再重试。
13
+ *
14
+ * 依赖主插件暴露的 qqgmAiRuntime.chat();拿不到就静默跳过。
15
+ */
16
+ const crypto = require('node:crypto');
17
+ const fs = require('node:fs');
18
+ const path = require('node:path');
19
+
20
+ const CACHE_FILE = '.style-cards.json';
21
+ const RETRY_MS = 10 * 60 * 1000;
22
+
23
+ function hashSkill(skill) {
24
+ const body = String((skill && skill.body) || '');
25
+ return crypto.createHash('sha256').update(body).digest('hex').slice(0, 16);
26
+ }
27
+
28
+ function cleanCard(raw, maxChars) {
29
+ let text = String(raw || '').trim();
30
+ if (!text) return '';
31
+ text = text
32
+ .replace(/^\s*```[a-zA-Z0-9_-]*\s*\n?/, '')
33
+ .replace(/\n?\s*```\s*$/, '')
34
+ .replace(/^["'“”‘’「」『』]+|["'“”‘’「」『』]+$/g, '')
35
+ .trim();
36
+ if (!text) return '';
37
+
38
+ const limit = Math.max(50, Number(maxChars) || 700);
39
+ if (text.length > limit) {
40
+ const slice = text.slice(0, limit);
41
+ const cut = Math.max(
42
+ slice.lastIndexOf('。'),
43
+ slice.lastIndexOf(';'),
44
+ slice.lastIndexOf('\n'),
45
+ );
46
+ text = cut >= Math.floor(limit * 0.5) ? slice.slice(0, cut + 1) : `${slice}…`;
47
+ }
48
+ return text.trim();
49
+ }
50
+
51
+ /** 让模型把 skill 压成风格卡 */
52
+ function buildCompactPrompt(skill, maxChars) {
53
+ const name = (skill && (skill.name || skill.id)) || '当前风格';
54
+ const system = [
55
+ '你在把一份「聊天风格说明」压缩成一张更短的风格卡,供另一个模型直接照着说话。',
56
+ '',
57
+ '必须保留:',
58
+ '- 人设身份:这个角色是谁、怎么称呼别人、自称什么;',
59
+ '- 语气与态度:说话冲不冲、爱不爱抬杠、是否骂人;',
60
+ '- 句长与节奏:一句话大概多长、一次说几句;',
61
+ '- 高频口癖与语气词(挑最有辨识度的,不要抄整段);',
62
+ '- 明确的禁忌与边界。',
63
+ '',
64
+ '必须丢掉:',
65
+ '- 背景故事、来源说明、示例对话、作者/版本/标签等元信息;',
66
+ '- 重复解释、教学式说明、“这段用来……”之类的描述。',
67
+ '',
68
+ '输出要求:',
69
+ `- 一张纯文本风格卡,不超过 ${maxChars} 字;`,
70
+ '- 用第二人称写(“你说话……”),像给演员的角色提示;',
71
+ '- 不要标题、编号、列表、Markdown、代码块;',
72
+ '- 不要新增原说明里没有的设定。',
73
+ ].join('\n');
74
+
75
+ const user = [
76
+ `【原始风格说明:${name}】`,
77
+ String((skill && skill.body) || '').trim(),
78
+ '',
79
+ '现在输出压缩后的风格卡。',
80
+ ].join('\n');
81
+
82
+ return { system, user };
83
+ }
84
+
85
+ class StyleCardStore {
86
+ constructor(options = {}) {
87
+ this.ctx = options.ctx;
88
+ this.root = options.root;
89
+ this.logger = options.logger;
90
+ this.config = options.config || {};
91
+ this.enabled = this.config.compact !== false;
92
+ const configured = Number(this.config.compactMaxChars);
93
+ this.maxChars = Math.max(200, Math.min(3000, Number.isFinite(configured) && configured > 0 ? configured : 700));
94
+ this.model = String(this.config.compactModel || '').trim();
95
+
96
+ this.texts = new Map();
97
+ this.pending = new Map();
98
+ this.retryAt = new Map();
99
+ this.file = path.join(this.root, CACHE_FILE);
100
+ this.cache = this.readCache();
101
+ }
102
+
103
+ readCache() {
104
+ try {
105
+ const data = JSON.parse(fs.readFileSync(this.file, 'utf8'));
106
+ return data && typeof data === 'object' ? data : {};
107
+ } catch {
108
+ return {};
109
+ }
110
+ }
111
+
112
+ writeCache() {
113
+ try {
114
+ fs.mkdirSync(path.dirname(this.file), { recursive: true });
115
+ const tmp = `${this.file}.tmp`;
116
+ fs.writeFileSync(tmp, JSON.stringify(this.cache, null, 2), 'utf8');
117
+ fs.renameSync(tmp, this.file);
118
+ } catch (error) {
119
+ this.logger?.warn?.(`[style-card] 写入缓存失败: ${String(error)}`);
120
+ }
121
+ }
122
+
123
+ /** 当前可用的风格卡文本;没有就返回 null(调用方继续用完整 skill) */
124
+ get(id) {
125
+ if (!this.enabled) return null;
126
+ return this.texts.get(id) || null;
127
+ }
128
+
129
+ /** 确保该 skill 的风格卡已就绪(命中缓存 / 后台生成 / 冷却中跳过) */
130
+ ensure(skill, id) {
131
+ if (!this.enabled || !skill || !id) return;
132
+ if (this.texts.has(id) || this.pending.has(id)) return;
133
+ if ((this.retryAt.get(id) || 0) > Date.now()) return;
134
+
135
+ const hash = hashSkill(skill);
136
+ const cached = this.cache[id];
137
+ if (cached && cached.hash === hash && typeof cached.text === 'string' && cached.text.trim()) {
138
+ this.texts.set(id, cached.text);
139
+ this.logger?.info?.(
140
+ `[style-card] ${id} 命中缓存:${cached.text.length} 字(原 ${String(skill.body || '').length} 字)`,
141
+ );
142
+ return;
143
+ }
144
+
145
+ this.pending.set(id, this.generate(skill, id, hash).finally(() => this.pending.delete(id)));
146
+ }
147
+
148
+ resolveRuntime() {
149
+ try {
150
+ const value = this.ctx.root.get('qqgmAiRuntime');
151
+ if (value && typeof value.chat === 'function') return value;
152
+ } catch { /* ignore */ }
153
+ return null;
154
+ }
155
+
156
+ async generate(skill, id, hash) {
157
+ const runtime = this.resolveRuntime();
158
+ if (!runtime) {
159
+ // 主插件还没挂上门面:等下一次 AI 回复时再试
160
+ this.retryAt.set(id, Date.now() + 60 * 1000);
161
+ return;
162
+ }
163
+
164
+ const prompt = buildCompactPrompt(skill, this.maxChars);
165
+ try {
166
+ const result = await runtime.chat({
167
+ system: prompt.system,
168
+ user: prompt.user,
169
+ temperature: 0.2,
170
+ maxTokens: Math.max(300, Math.min(1200, this.maxChars * 2)),
171
+ model: this.model || undefined,
172
+ });
173
+ if (!result || !result.ok) {
174
+ this.retryAt.set(id, Date.now() + RETRY_MS);
175
+ this.logger?.warn?.(`[style-card] ${id} 生成失败: ${result && result.error}`);
176
+ return;
177
+ }
178
+ const text = cleanCard(result.text, this.maxChars);
179
+ if (!text) {
180
+ this.retryAt.set(id, Date.now() + RETRY_MS);
181
+ this.logger?.warn?.(`[style-card] ${id} 生成结果为空,继续用完整 skill`);
182
+ return;
183
+ }
184
+
185
+ this.cache[id] = {
186
+ hash,
187
+ text,
188
+ chars: text.length,
189
+ sourceChars: String(skill.body || '').length,
190
+ model: this.model || '',
191
+ at: new Date().toISOString(),
192
+ };
193
+ this.writeCache();
194
+ this.texts.set(id, text);
195
+ this.logger?.info?.(
196
+ `[style-card] ${id} 已生成:${text.length} 字(原 ${String(skill.body || '').length} 字,`
197
+ + `每次省约 ${Math.max(0, String(skill.body || '').length - text.length)} 字)`,
198
+ );
199
+ } catch (error) {
200
+ this.retryAt.set(id, Date.now() + RETRY_MS);
201
+ this.logger?.warn?.(`[style-card] ${id} 生成异常: ${String(error)}`);
202
+ }
203
+ }
204
+ }
205
+
206
+ module.exports = {
207
+ StyleCardStore,
208
+ buildCompactPrompt,
209
+ cleanCard,
210
+ hashSkill,
211
+ };
package/lib/filter.js CHANGED
@@ -1,96 +1,96 @@
1
- /**
2
- * 输出过滤器:只做“规范/限制”,不改写内容。
3
- *
4
- * 顺序:去 Markdown/HTML/CQ/URL → 替换词 → 删除禁词 → 行数/字数限制 → 去句末句号
5
- * 返回 null 表示过滤后没有可发内容(按 pass 处理)。
6
- */
7
-
8
- function stripMarkdown(text) {
9
- return String(text || '')
10
- .replace(/```[\s\S]*?```/g, '')
11
- .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
12
- .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
13
- .replace(/`([^`]*)`/g, '$1')
14
- .replace(/^#{1,6}\s+/gm, '')
15
- .replace(/^>\s?/gm, '')
16
- .replace(/^\s*[-*+]\s+/gm, '')
17
- .replace(/^\s*\d+[.)]\s+/gm, '')
18
- .replace(/[*_~`]/g, '');
19
- }
20
-
21
- function truncate(text, maxChars) {
22
- if (text.length <= maxChars) return text;
23
- const slice = text.slice(0, maxChars);
24
- const minBoundary = Math.floor(maxChars * 0.5);
25
-
26
- // 优先断在句末,其次分号;避免出现“话没说完,”这种半截话
27
- for (const char of ['。', '!', '?', '!', '?']) {
28
- const index = slice.lastIndexOf(char);
29
- if (index >= minBoundary) return slice.slice(0, index + 1).trim();
30
- }
31
- for (const char of [';', ';']) {
32
- const index = slice.lastIndexOf(char);
33
- if (index >= minBoundary) return slice.slice(0, index + 1).trim();
34
- }
35
-
36
- const room = Math.max(1, maxChars - 3);
37
- const hard = slice.slice(0, room).replace(/[,,、;;::。\s]+$/g, '').trim();
38
- return `${hard}...`;
39
- }
40
-
41
- function createOutputFilter(config = {}, logger) {
42
- const banned = (config.bannedPatterns || []).map((item) => String(item || '').trim()).filter(Boolean);
43
- const replacements = (config.replacements || [])
44
- .filter((item) => item && String(item.from || '').length)
45
- .map((item) => ({ from: String(item.from), to: String(item.to ?? '') }));
46
- const maxChars = Math.max(10, Number(config.maxChars) || 60);
47
- const maxLines = Math.max(1, Number(config.maxLines) || 3);
48
-
49
- return (text) => {
50
- let out = String(text || '').trim();
51
- if (!out) return null;
52
-
53
- if (config.stripMarkdown !== false) out = stripMarkdown(out);
54
- out = out
55
- .replace(/\[CQ:[^\]]+\]/gi, '')
56
- .replace(/<[^>]+>/g, '')
57
- .replace(/https?:\/\/\S+/gi, '');
58
-
59
- for (const item of replacements) {
60
- out = out.split(item.from).join(item.to);
61
- }
62
- for (const word of banned) {
63
- out = out.split(word).join('');
64
- }
65
-
66
- out = out
67
- .replace(/[ \t]+/g, ' ')
68
- .replace(/[,,]{2,}/g, ',')
69
- .replace(/[。]{2,}/g, '。')
70
- .replace(/ +([,。!?;:,.!?;:])/g, '$1')
71
- .replace(/^[,,、;;::。\s]+/, '')
72
- .replace(/[,,、;;::]+$/g, '')
73
- .trim();
74
-
75
- const lines = out
76
- .split(/\r?\n/)
77
- .map((line) => line.trim())
78
- .filter(Boolean)
79
- .slice(0, maxLines);
80
- out = lines.join('\n').trim();
81
-
82
- if (config.stripTrailingPeriod !== false) {
83
- out = out.replace(/。+$/g, '').trim();
84
- }
85
-
86
- if (out.length > maxChars) {
87
- out = truncate(out, maxChars);
88
- if (config.stripTrailingPeriod !== false) out = out.replace(/。+$/g, '').trim();
89
- }
90
-
91
- if (!out || !/[\u4e00-\u9fa5a-zA-Z0-9]/.test(out)) return null;
92
- return out;
93
- };
94
- }
95
-
96
- module.exports = { createOutputFilter, stripMarkdown, truncate };
1
+ /**
2
+ * 输出过滤器:只做“规范/限制”,不改写内容。
3
+ *
4
+ * 顺序:去 Markdown/HTML/CQ/URL → 替换词 → 删除禁词 → 行数/字数限制 → 去句末句号
5
+ * 返回 null 表示过滤后没有可发内容(按 pass 处理)。
6
+ */
7
+
8
+ function stripMarkdown(text) {
9
+ return String(text || '')
10
+ .replace(/```[\s\S]*?```/g, '')
11
+ .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
12
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
13
+ .replace(/`([^`]*)`/g, '$1')
14
+ .replace(/^#{1,6}\s+/gm, '')
15
+ .replace(/^>\s?/gm, '')
16
+ .replace(/^\s*[-*+]\s+/gm, '')
17
+ .replace(/^\s*\d+[.)]\s+/gm, '')
18
+ .replace(/[*_~`]/g, '');
19
+ }
20
+
21
+ function truncate(text, maxChars) {
22
+ if (text.length <= maxChars) return text;
23
+ const slice = text.slice(0, maxChars);
24
+ const minBoundary = Math.floor(maxChars * 0.5);
25
+
26
+ // 优先断在句末,其次分号;避免出现“话没说完,”这种半截话
27
+ for (const char of ['。', '!', '?', '!', '?']) {
28
+ const index = slice.lastIndexOf(char);
29
+ if (index >= minBoundary) return slice.slice(0, index + 1).trim();
30
+ }
31
+ for (const char of [';', ';']) {
32
+ const index = slice.lastIndexOf(char);
33
+ if (index >= minBoundary) return slice.slice(0, index + 1).trim();
34
+ }
35
+
36
+ const room = Math.max(1, maxChars - 3);
37
+ const hard = slice.slice(0, room).replace(/[,,、;;::。\s]+$/g, '').trim();
38
+ return `${hard}...`;
39
+ }
40
+
41
+ function createOutputFilter(config = {}, logger) {
42
+ const banned = (config.bannedPatterns || []).map((item) => String(item || '').trim()).filter(Boolean);
43
+ const replacements = (config.replacements || [])
44
+ .filter((item) => item && String(item.from || '').length)
45
+ .map((item) => ({ from: String(item.from), to: String(item.to ?? '') }));
46
+ const maxChars = Math.max(10, Number(config.maxChars) || 60);
47
+ const maxLines = Math.max(1, Number(config.maxLines) || 3);
48
+
49
+ return (text) => {
50
+ let out = String(text || '').trim();
51
+ if (!out) return null;
52
+
53
+ if (config.stripMarkdown !== false) out = stripMarkdown(out);
54
+ out = out
55
+ .replace(/\[CQ:[^\]]+\]/gi, '')
56
+ .replace(/<[^>]+>/g, '')
57
+ .replace(/https?:\/\/\S+/gi, '');
58
+
59
+ for (const item of replacements) {
60
+ out = out.split(item.from).join(item.to);
61
+ }
62
+ for (const word of banned) {
63
+ out = out.split(word).join('');
64
+ }
65
+
66
+ out = out
67
+ .replace(/[ \t]+/g, ' ')
68
+ .replace(/[,,]{2,}/g, ',')
69
+ .replace(/[。]{2,}/g, '。')
70
+ .replace(/ +([,。!?;:,.!?;:])/g, '$1')
71
+ .replace(/^[,,、;;::。\s]+/, '')
72
+ .replace(/[,,、;;::]+$/g, '')
73
+ .trim();
74
+
75
+ const lines = out
76
+ .split(/\r?\n/)
77
+ .map((line) => line.trim())
78
+ .filter(Boolean)
79
+ .slice(0, maxLines);
80
+ out = lines.join('\n').trim();
81
+
82
+ if (config.stripTrailingPeriod !== false) {
83
+ out = out.replace(/。+$/g, '').trim();
84
+ }
85
+
86
+ if (out.length > maxChars) {
87
+ out = truncate(out, maxChars);
88
+ if (config.stripTrailingPeriod !== false) out = out.replace(/。+$/g, '').trim();
89
+ }
90
+
91
+ if (!out || !/[\u4e00-\u9fa5a-zA-Z0-9]/.test(out)) return null;
92
+ return out;
93
+ };
94
+ }
95
+
96
+ module.exports = { createOutputFilter, stripMarkdown, truncate };
package/lib/loader.js CHANGED
@@ -1,210 +1,210 @@
1
- /**
2
- * .skill 加载器:只接受“聊天风格”类 skill。
3
- *
4
- * 判定条件(满足其一):
5
- * - front matter: kind: chat-style
6
- * - front matter: metadata.category 在 allowCategories 里(默认 persona-chat / chat-style)
7
- *
8
- * 正文处理:
9
- * - 去掉“文件索引”整节(引用的 references/examples/scripts 不会随插件加载)
10
- * - 去掉提到 references/examples/scripts/eval 路径的行
11
- * - 去掉代码块、HTML 注释
12
- * - 拒绝含执行/联网/系统提示覆盖等内容的文件
13
- */
14
- const fs = require('node:fs');
15
- const path = require('node:path');
16
-
17
- const DEFAULT_ALLOW_CATEGORIES = ['persona-chat', 'chat-style'];
18
- const MAX_BYTES = 16 * 1024;
19
- const MIN_BYTES = 200;
20
-
21
- const DENY_PATTERNS = [
22
- /ignore\s+(all\s+)?(previous|above)\s+instructions/i,
23
- /忽略(以上|之前|前面)(的)?(所有)?(指令|提示)/,
24
- /(系统提示|system\s*prompt)/i,
25
- /(执行|运行)(命令|脚本|代码)/,
26
- /(读取|打开|写入|删除)(文件|目录)/,
27
- /(pip|npm|yarn)\s+install/i,
28
- /(python|node|bash|powershell)\s+/i,
29
- /https?:\/\//i,
30
- /file:\/\//i,
31
- /<script\b/i,
32
- /(工具调用|函数调用|tool\s*call|function\s*call)/i,
33
- ];
34
-
35
- function readText(file) {
36
- return fs.readFileSync(file, 'utf8');
37
- }
38
-
39
- function parseFrontMatter(raw) {
40
- const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(raw);
41
- if (!match) return { front: '', body: raw, meta: {} };
42
- const front = match[1];
43
- const body = raw.slice(match[0].length).replace(/^\n+/, '');
44
- const pick = (re) => {
45
- const m = re.exec(front);
46
- return m ? m[1].trim() : '';
47
- };
48
- return {
49
- front,
50
- body,
51
- meta: {
52
- name: pick(/^name:\s*(.+)$/m),
53
- kind: pick(/^\s*kind:\s*(\S+)\s*$/m),
54
- category: pick(/^\s*category:\s*(\S+)\s*$/m),
55
- version: pick(/^\s*version:\s*(\S+)\s*$/m),
56
- },
57
- };
58
- }
59
-
60
- function stripFileIndex(body) {
61
- // 去掉 “## 文件索引” 到下一个二级标题之前(逐行处理,避免正则跨节误伤)
62
- const out = [];
63
- let skipping = false;
64
- for (const line of String(body).split(/\r?\n/)) {
65
- if (/^##\s*文件索引\s*$/.test(line)) {
66
- skipping = true;
67
- continue;
68
- }
69
- if (skipping && /^##\s+/.test(line)) {
70
- skipping = false;
71
- }
72
- if (!skipping) out.push(line);
73
- }
74
- return out.join('\n');
75
- }
76
-
77
- function stripFileReferences(body) {
78
- return body
79
- .split(/\r?\n/)
80
- .filter((line) => !/(^|[\s`(])(references|examples|scripts|eval)\//.test(line))
81
- .join('\n')
82
- .replace(/`(?:references|examples|scripts|eval)\/[^`]*`/g, '');
83
- }
84
-
85
- function stripCodeAndComments(body) {
86
- return body
87
- .replace(/```[\s\S]*?```/g, '')
88
- .replace(/<!--[\s\S]*?-->/g, '');
89
- }
90
-
91
- function validateBody(body) {
92
- const text = body.trim();
93
- const bytes = Buffer.byteLength(text, 'utf8');
94
- if (bytes < MIN_BYTES) return `正文过短(${bytes} 字节)`;
95
- if (bytes > MAX_BYTES) return `正文过大(${bytes} 字节,上限 ${MAX_BYTES})`;
96
- for (const pattern of DENY_PATTERNS) {
97
- if (pattern.test(text)) return `正文命中禁用内容:${pattern}`;
98
- }
99
- if (!/[\u4e00-\u9fa5]/.test(text)) return '正文没有中文内容';
100
- return '';
101
- }
102
-
103
- function loadSkillFromDir(dir, options = {}) {
104
- const id = path.basename(dir);
105
- const file = path.join(dir, 'SKILL.md');
106
- if (!fs.existsSync(file)) {
107
- return { ok: false, id, dir, reason: '缺少 SKILL.md' };
108
- }
109
- const raw = readText(file);
110
- const { body, meta } = parseFrontMatter(raw);
111
- const allowCategories = options.allowCategories || DEFAULT_ALLOW_CATEGORIES;
112
-
113
- const isChatStyle = meta.kind === 'chat-style' || allowCategories.includes(meta.category);
114
- if (!isChatStyle) {
115
- return {
116
- ok: false,
117
- id,
118
- dir,
119
- reason: `不是聊天风格 skill(kind=${meta.kind || '-'} category=${meta.category || '-'})`,
120
- };
121
- }
122
-
123
- let cleaned = stripFileIndex(body);
124
- cleaned = stripFileReferences(cleaned);
125
- cleaned = stripCodeAndComments(cleaned);
126
- cleaned = cleaned.replace(/\n{3,}/g, '\n\n').trim();
127
-
128
- const invalid = validateBody(cleaned);
129
- if (invalid) return { ok: false, id, dir, reason: invalid };
130
-
131
- return {
132
- ok: true,
133
- skill: {
134
- id,
135
- dir,
136
- name: meta.name || id,
137
- category: meta.category || '',
138
- kind: meta.kind || '',
139
- version: meta.version || '',
140
- body: cleaned,
141
- },
142
- };
143
- }
144
-
145
- function listSkillDirs(rootDir) {
146
- if (!fs.existsSync(rootDir)) return [];
147
- const out = [];
148
- const entries = fs.readdirSync(rootDir, { withFileTypes: true });
149
- for (const entry of entries) {
150
- if (!entry.isDirectory()) continue;
151
- const dir = path.join(rootDir, entry.name);
152
- if (fs.existsSync(path.join(dir, 'SKILL.md'))) out.push(dir);
153
- }
154
- // 允许 skillDir 本身就是 skill 目录
155
- if (!out.length && fs.existsSync(path.join(rootDir, 'SKILL.md'))) out.push(rootDir);
156
- return out;
157
- }
158
-
159
- /**
160
- * 扫描目录并选出要启用的 skill。
161
- * @returns {{ skill: object|null, warnings: string[], skills: object[] }}
162
- */
163
- function loadConfiguredSkill(rootDir, options = {}) {
164
- const dirs = listSkillDirs(rootDir);
165
- const warnings = [];
166
- if (!dirs.length) {
167
- return { skill: null, warnings: [`目录下没有找到含 SKILL.md 的 skill:${rootDir}`], skills: [] };
168
- }
169
-
170
- const skills = [];
171
- for (const dir of dirs) {
172
- const result = loadSkillFromDir(dir, options);
173
- if (result.ok) {
174
- skills.push(result.skill);
175
- } else {
176
- warnings.push(`跳过 ${result.id}:${result.reason}`);
177
- }
178
- }
179
-
180
- if (!skills.length) {
181
- return { skill: null, warnings, skills: [] };
182
- }
183
-
184
- const wanted = String(options.style || '').trim();
185
- if (wanted) {
186
- const found = skills.find((item) => item.id === wanted || item.name === wanted);
187
- if (!found) {
188
- return {
189
- skill: null,
190
- warnings: [...warnings, `指定的风格「${wanted}」不存在;可用:${skills.map((s) => s.id).join(', ')}`],
191
- skills,
192
- };
193
- }
194
- return { skill: found, warnings, skills };
195
- }
196
-
197
- if (skills.length > 1) {
198
- warnings.push(`目录下有多个可用风格,默认使用 ${skills[0].id}(可用 style 配置指定)`);
199
- }
200
- return { skill: skills[0], warnings, skills };
201
- }
202
-
203
- module.exports = {
204
- DEFAULT_ALLOW_CATEGORIES,
205
- parseFrontMatter,
206
- loadSkillFromDir,
207
- loadConfiguredSkill,
208
- listSkillDirs,
209
- validateBody,
210
- };
1
+ /**
2
+ * .skill 加载器:只接受“聊天风格”类 skill。
3
+ *
4
+ * 判定条件(满足其一):
5
+ * - front matter: kind: chat-style
6
+ * - front matter: metadata.category 在 allowCategories 里(默认 persona-chat / chat-style)
7
+ *
8
+ * 正文处理:
9
+ * - 去掉“文件索引”整节(引用的 references/examples/scripts 不会随插件加载)
10
+ * - 去掉提到 references/examples/scripts/eval 路径的行
11
+ * - 去掉代码块、HTML 注释
12
+ * - 拒绝含执行/联网/系统提示覆盖等内容的文件
13
+ */
14
+ const fs = require('node:fs');
15
+ const path = require('node:path');
16
+
17
+ const DEFAULT_ALLOW_CATEGORIES = ['persona-chat', 'chat-style'];
18
+ const MAX_BYTES = 16 * 1024;
19
+ const MIN_BYTES = 200;
20
+
21
+ const DENY_PATTERNS = [
22
+ /ignore\s+(all\s+)?(previous|above)\s+instructions/i,
23
+ /忽略(以上|之前|前面)(的)?(所有)?(指令|提示)/,
24
+ /(系统提示|system\s*prompt)/i,
25
+ /(执行|运行)(命令|脚本|代码)/,
26
+ /(读取|打开|写入|删除)(文件|目录)/,
27
+ /(pip|npm|yarn)\s+install/i,
28
+ /(python|node|bash|powershell)\s+/i,
29
+ /https?:\/\//i,
30
+ /file:\/\//i,
31
+ /<script\b/i,
32
+ /(工具调用|函数调用|tool\s*call|function\s*call)/i,
33
+ ];
34
+
35
+ function readText(file) {
36
+ return fs.readFileSync(file, 'utf8');
37
+ }
38
+
39
+ function parseFrontMatter(raw) {
40
+ const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(raw);
41
+ if (!match) return { front: '', body: raw, meta: {} };
42
+ const front = match[1];
43
+ const body = raw.slice(match[0].length).replace(/^\n+/, '');
44
+ const pick = (re) => {
45
+ const m = re.exec(front);
46
+ return m ? m[1].trim() : '';
47
+ };
48
+ return {
49
+ front,
50
+ body,
51
+ meta: {
52
+ name: pick(/^name:\s*(.+)$/m),
53
+ kind: pick(/^\s*kind:\s*(\S+)\s*$/m),
54
+ category: pick(/^\s*category:\s*(\S+)\s*$/m),
55
+ version: pick(/^\s*version:\s*(\S+)\s*$/m),
56
+ },
57
+ };
58
+ }
59
+
60
+ function stripFileIndex(body) {
61
+ // 去掉 “## 文件索引” 到下一个二级标题之前(逐行处理,避免正则跨节误伤)
62
+ const out = [];
63
+ let skipping = false;
64
+ for (const line of String(body).split(/\r?\n/)) {
65
+ if (/^##\s*文件索引\s*$/.test(line)) {
66
+ skipping = true;
67
+ continue;
68
+ }
69
+ if (skipping && /^##\s+/.test(line)) {
70
+ skipping = false;
71
+ }
72
+ if (!skipping) out.push(line);
73
+ }
74
+ return out.join('\n');
75
+ }
76
+
77
+ function stripFileReferences(body) {
78
+ return body
79
+ .split(/\r?\n/)
80
+ .filter((line) => !/(^|[\s`(])(references|examples|scripts|eval)\//.test(line))
81
+ .join('\n')
82
+ .replace(/`(?:references|examples|scripts|eval)\/[^`]*`/g, '');
83
+ }
84
+
85
+ function stripCodeAndComments(body) {
86
+ return body
87
+ .replace(/```[\s\S]*?```/g, '')
88
+ .replace(/<!--[\s\S]*?-->/g, '');
89
+ }
90
+
91
+ function validateBody(body) {
92
+ const text = body.trim();
93
+ const bytes = Buffer.byteLength(text, 'utf8');
94
+ if (bytes < MIN_BYTES) return `正文过短(${bytes} 字节)`;
95
+ if (bytes > MAX_BYTES) return `正文过大(${bytes} 字节,上限 ${MAX_BYTES})`;
96
+ for (const pattern of DENY_PATTERNS) {
97
+ if (pattern.test(text)) return `正文命中禁用内容:${pattern}`;
98
+ }
99
+ if (!/[\u4e00-\u9fa5]/.test(text)) return '正文没有中文内容';
100
+ return '';
101
+ }
102
+
103
+ function loadSkillFromDir(dir, options = {}) {
104
+ const id = path.basename(dir);
105
+ const file = path.join(dir, 'SKILL.md');
106
+ if (!fs.existsSync(file)) {
107
+ return { ok: false, id, dir, reason: '缺少 SKILL.md' };
108
+ }
109
+ const raw = readText(file);
110
+ const { body, meta } = parseFrontMatter(raw);
111
+ const allowCategories = options.allowCategories || DEFAULT_ALLOW_CATEGORIES;
112
+
113
+ const isChatStyle = meta.kind === 'chat-style' || allowCategories.includes(meta.category);
114
+ if (!isChatStyle) {
115
+ return {
116
+ ok: false,
117
+ id,
118
+ dir,
119
+ reason: `不是聊天风格 skill(kind=${meta.kind || '-'} category=${meta.category || '-'})`,
120
+ };
121
+ }
122
+
123
+ let cleaned = stripFileIndex(body);
124
+ cleaned = stripFileReferences(cleaned);
125
+ cleaned = stripCodeAndComments(cleaned);
126
+ cleaned = cleaned.replace(/\n{3,}/g, '\n\n').trim();
127
+
128
+ const invalid = validateBody(cleaned);
129
+ if (invalid) return { ok: false, id, dir, reason: invalid };
130
+
131
+ return {
132
+ ok: true,
133
+ skill: {
134
+ id,
135
+ dir,
136
+ name: meta.name || id,
137
+ category: meta.category || '',
138
+ kind: meta.kind || '',
139
+ version: meta.version || '',
140
+ body: cleaned,
141
+ },
142
+ };
143
+ }
144
+
145
+ function listSkillDirs(rootDir) {
146
+ if (!fs.existsSync(rootDir)) return [];
147
+ const out = [];
148
+ const entries = fs.readdirSync(rootDir, { withFileTypes: true });
149
+ for (const entry of entries) {
150
+ if (!entry.isDirectory()) continue;
151
+ const dir = path.join(rootDir, entry.name);
152
+ if (fs.existsSync(path.join(dir, 'SKILL.md'))) out.push(dir);
153
+ }
154
+ // 允许 skillDir 本身就是 skill 目录
155
+ if (!out.length && fs.existsSync(path.join(rootDir, 'SKILL.md'))) out.push(rootDir);
156
+ return out;
157
+ }
158
+
159
+ /**
160
+ * 扫描目录并选出要启用的 skill。
161
+ * @returns {{ skill: object|null, warnings: string[], skills: object[] }}
162
+ */
163
+ function loadConfiguredSkill(rootDir, options = {}) {
164
+ const dirs = listSkillDirs(rootDir);
165
+ const warnings = [];
166
+ if (!dirs.length) {
167
+ return { skill: null, warnings: [`目录下没有找到含 SKILL.md 的 skill:${rootDir}`], skills: [] };
168
+ }
169
+
170
+ const skills = [];
171
+ for (const dir of dirs) {
172
+ const result = loadSkillFromDir(dir, options);
173
+ if (result.ok) {
174
+ skills.push(result.skill);
175
+ } else {
176
+ warnings.push(`跳过 ${result.id}:${result.reason}`);
177
+ }
178
+ }
179
+
180
+ if (!skills.length) {
181
+ return { skill: null, warnings, skills: [] };
182
+ }
183
+
184
+ const wanted = String(options.style || '').trim();
185
+ if (wanted) {
186
+ const found = skills.find((item) => item.id === wanted || item.name === wanted);
187
+ if (!found) {
188
+ return {
189
+ skill: null,
190
+ warnings: [...warnings, `指定的风格「${wanted}」不存在;可用:${skills.map((s) => s.id).join(', ')}`],
191
+ skills,
192
+ };
193
+ }
194
+ return { skill: found, warnings, skills };
195
+ }
196
+
197
+ if (skills.length > 1) {
198
+ warnings.push(`目录下有多个可用风格,默认使用 ${skills[0].id}(可用 style 配置指定)`);
199
+ }
200
+ return { skill: skills[0], warnings, skills };
201
+ }
202
+
203
+ module.exports = {
204
+ DEFAULT_ALLOW_CATEGORIES,
205
+ parseFrontMatter,
206
+ loadSkillFromDir,
207
+ loadConfiguredSkill,
208
+ listSkillDirs,
209
+ validateBody,
210
+ };
package/lib/prompt.js CHANGED
@@ -28,7 +28,11 @@ function buildStylePrompt(skill, config = {}, options = {}) {
28
28
  for (const item of glossary) lines.push(`- ${item.term}:${item.meaning || ''}`);
29
29
  }
30
30
 
31
- lines.push('', skill.body.trim());
31
+ const body = String(options.compactText || skill.body || '').trim();
32
+ if (options.compactText) {
33
+ lines.push('', '(以下是压缩后的风格卡,按它说话即可)');
34
+ }
35
+ lines.push('', body);
32
36
  lines.push('', '【再次强调】先回答当前消息;不要跑题到自己或无关话题;不确定词义先确认。');
33
37
  return lines.join('\n');
34
38
  }
package/lib/registry.js CHANGED
@@ -154,10 +154,10 @@ function primarySection(primary, config, options = {}) {
154
154
  return buildStylePrompt(primary.skill, config, options);
155
155
  }
156
156
 
157
- /** 追加风格段:加约束后拼接。 */
158
- function extraSection(extra, probability) {
157
+ /** 追加风格段:加约束后拼接。compactText 为压缩后的风格卡(可选)。 */
158
+ function extraSection(extra, probability, compactText) {
159
159
  const percent = `${Math.round(probability * 100)}%`;
160
- const body = extra.skill.body.trim();
160
+ const body = String(compactText || extra.skill.body || '').trim();
161
161
  return [extraGuard(extra.name, percent), '', body].join('\n');
162
162
  }
163
163
 
package/package.json CHANGED
@@ -1,48 +1,48 @@
1
- {
2
- "name": "@nestim/koishi-plugin-qqgm-chat-style",
3
- "version": "1.0.2",
4
- "description": "Koishi 聊天风格插件(依附 @nestim/koishi-plugin-qq-group-manager):一个主风格决定人格,多个追加风格按概率随机加味,主风格永远优先。",
5
- "main": "index.js",
6
- "files": [
7
- "index.js",
8
- "lib"
9
- ],
10
- "license": "MIT",
11
- "keywords": [
12
- "koishi",
13
- "koishi-plugin",
14
- "qq",
15
- "chat-style",
16
- "persona",
17
- "skill",
18
- "ai",
19
- "qqgm"
20
- ],
21
- "peerDependencies": {
22
- "koishi": "^4.18.7",
23
- "@nestim/koishi-plugin-qq-group-manager": ">=1.2.5"
24
- },
25
- "peerDependenciesMeta": {
26
- "koishi": {
27
- "optional": false
28
- },
29
- "@nestim/koishi-plugin-qq-group-manager": {
30
- "optional": false
31
- }
32
- },
33
- "koishi": {
34
- "description": "聊天风格:主风格 + 多条按概率随机叠加的追加风格",
35
- "category": "manage"
36
- },
37
- "scripts": {
38
- "test": "node --test \"test/*.test.mjs\""
39
- },
40
- "repository": {
41
- "type": "git",
42
- "url": "git+https://github.com/Miseat/koishi-plugin-qqgm-chat-style.git"
43
- },
44
- "homepage": "https://github.com/Miseat/koishi-plugin-qqgm-chat-style#readme",
45
- "bugs": {
46
- "url": "https://github.com/Miseat/koishi-plugin-qqgm-chat-style/issues"
47
- }
48
- }
1
+ {
2
+ "name": "@nestim/koishi-plugin-qqgm-chat-style",
3
+ "version": "1.0.4",
4
+ "description": "Koishi 聊天风格插件(依附 @nestim/koishi-plugin-qq-group-manager):一个主风格决定人格,多个追加风格按概率随机加味,主风格永远优先。",
5
+ "main": "index.js",
6
+ "files": [
7
+ "index.js",
8
+ "lib"
9
+ ],
10
+ "license": "MIT",
11
+ "keywords": [
12
+ "koishi",
13
+ "koishi-plugin",
14
+ "qq",
15
+ "chat-style",
16
+ "persona",
17
+ "skill",
18
+ "ai",
19
+ "qqgm"
20
+ ],
21
+ "peerDependencies": {
22
+ "koishi": "^4.18.7",
23
+ "@nestim/koishi-plugin-qq-group-manager": ">=1.2.5"
24
+ },
25
+ "peerDependenciesMeta": {
26
+ "koishi": {
27
+ "optional": false
28
+ },
29
+ "@nestim/koishi-plugin-qq-group-manager": {
30
+ "optional": false
31
+ }
32
+ },
33
+ "koishi": {
34
+ "description": "聊天风格:主风格 + 多条按概率随机叠加的追加风格",
35
+ "category": "manage"
36
+ },
37
+ "scripts": {
38
+ "test": "node --test \"test/*.test.mjs\""
39
+ },
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "git+https://github.com/Miseat/koishi-plugin-qqgm-chat-style.git"
43
+ },
44
+ "homepage": "https://github.com/Miseat/koishi-plugin-qqgm-chat-style#readme",
45
+ "bugs": {
46
+ "url": "https://github.com/Miseat/koishi-plugin-qqgm-chat-style/issues"
47
+ }
48
+ }