dsn-whale-chat 0.1.2 → 0.1.3

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
@@ -11,9 +11,19 @@
11
11
  - **隐藏人格**:恶趣味 / 贴吧喷子 / 女王,各有开关;**关闭时完全不注入**。
12
12
  - **触发控制**:私聊全收;群聊支持 `@` / 全体 / 关键词。
13
13
  - **频率控制**:去抖合并、冷却、每分钟·每小时上限。
14
- - **闲时主动 + 随机插话**:可配置,默认关闭。
14
+ - **闲时主动 + 随机插话**:**默认开启**(主动发言 15~30 分钟随机、插话每 10 分钟最多 3 次)。
15
15
  - **稳定前缀注入**:人格放在 `systemPrompt.section`,命中 prefix cache。
16
16
 
17
+ > ⚠️ **风险项**:主动发言与随机插话默认开启,可能刷屏、打扰群友或放大平台风控。如要规避,在 profile 配置中关闭:
18
+ >
19
+ > ```yaml
20
+ > proactive:
21
+ > private: { enabled: false }
22
+ > group: { enabled: false }
23
+ > randomInterject:
24
+ > enabled: false
25
+ > ```
26
+
17
27
  ## 安装
18
28
 
19
29
  ```sh
@@ -30,7 +30,7 @@ function toNudgeMessage(message, instruction) {
30
30
  });
31
31
  }
32
32
  export function toProactiveMessage(message) {
33
- return toNudgeMessage(message, '(现在没有新消息。你可以主动说一句:如果想起了什么、有没聊完的话题、或到日子的锚点,就说;没有想说的就只回复 SILENT。)');
33
+ return toNudgeMessage(message, '(安静了一会儿。你可以翻翻之前的聊天接一句、随口说点什么、或者去找你熟悉的人搭话;没什么想说的就只回复 SILENT。)');
34
34
  }
35
35
  export function toInterjectMessage(message, text) {
36
36
  return toNudgeMessage(message, `群里刚才有人说:「${text}」。你可以接一句,或回 SILENT。`);
package/lib/index.d.ts CHANGED
@@ -70,42 +70,63 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
70
70
  maxPerMinute: z<number, number, "defined">;
71
71
  maxPerHour: z<number, number, "defined">;
72
72
  }>>, "plain">;
73
+ replySplit: z<Schemastery.ObjectS<NoInfer<{
74
+ enabled: z<boolean, boolean, "defined">;
75
+ groupMaxChars: z<number, number, "defined">;
76
+ privateMaxChars: z<number, number, "defined">;
77
+ longFormMaxChars: z<number, number, "defined">;
78
+ delayMs: z<number, number, "defined">;
79
+ }>>, Schemastery.ObjectT<NoInfer<{
80
+ enabled: z<boolean, boolean, "defined">;
81
+ groupMaxChars: z<number, number, "defined">;
82
+ privateMaxChars: z<number, number, "defined">;
83
+ longFormMaxChars: z<number, number, "defined">;
84
+ delayMs: z<number, number, "defined">;
85
+ }>>, "plain">;
73
86
  proactive: z<Schemastery.ObjectS<NoInfer<{
74
87
  private: z<Schemastery.ObjectS<NoInfer<{
75
88
  enabled: z<boolean, boolean, "defined">;
76
- idleMinutes: z<number, number, "defined">;
89
+ idleMinMinutes: z<number, number, "defined">;
90
+ idleMaxMinutes: z<number, number, "defined">;
77
91
  maxPerDay: z<number, number, "defined">;
78
92
  }>>, Schemastery.ObjectT<NoInfer<{
79
93
  enabled: z<boolean, boolean, "defined">;
80
- idleMinutes: z<number, number, "defined">;
94
+ idleMinMinutes: z<number, number, "defined">;
95
+ idleMaxMinutes: z<number, number, "defined">;
81
96
  maxPerDay: z<number, number, "defined">;
82
97
  }>>, "plain">;
83
98
  group: z<Schemastery.ObjectS<NoInfer<{
84
99
  enabled: z<boolean, boolean, "defined">;
85
- idleMinutes: z<number, number, "defined">;
100
+ idleMinMinutes: z<number, number, "defined">;
101
+ idleMaxMinutes: z<number, number, "defined">;
86
102
  maxPerDay: z<number, number, "defined">;
87
103
  }>>, Schemastery.ObjectT<NoInfer<{
88
104
  enabled: z<boolean, boolean, "defined">;
89
- idleMinutes: z<number, number, "defined">;
105
+ idleMinMinutes: z<number, number, "defined">;
106
+ idleMaxMinutes: z<number, number, "defined">;
90
107
  maxPerDay: z<number, number, "defined">;
91
108
  }>>, "plain">;
92
109
  }>>, Schemastery.ObjectT<NoInfer<{
93
110
  private: z<Schemastery.ObjectS<NoInfer<{
94
111
  enabled: z<boolean, boolean, "defined">;
95
- idleMinutes: z<number, number, "defined">;
112
+ idleMinMinutes: z<number, number, "defined">;
113
+ idleMaxMinutes: z<number, number, "defined">;
96
114
  maxPerDay: z<number, number, "defined">;
97
115
  }>>, Schemastery.ObjectT<NoInfer<{
98
116
  enabled: z<boolean, boolean, "defined">;
99
- idleMinutes: z<number, number, "defined">;
117
+ idleMinMinutes: z<number, number, "defined">;
118
+ idleMaxMinutes: z<number, number, "defined">;
100
119
  maxPerDay: z<number, number, "defined">;
101
120
  }>>, "plain">;
102
121
  group: z<Schemastery.ObjectS<NoInfer<{
103
122
  enabled: z<boolean, boolean, "defined">;
104
- idleMinutes: z<number, number, "defined">;
123
+ idleMinMinutes: z<number, number, "defined">;
124
+ idleMaxMinutes: z<number, number, "defined">;
105
125
  maxPerDay: z<number, number, "defined">;
106
126
  }>>, Schemastery.ObjectT<NoInfer<{
107
127
  enabled: z<boolean, boolean, "defined">;
108
- idleMinutes: z<number, number, "defined">;
128
+ idleMinMinutes: z<number, number, "defined">;
129
+ idleMaxMinutes: z<number, number, "defined">;
109
130
  maxPerDay: z<number, number, "defined">;
110
131
  }>>, "plain">;
111
132
  }>>, "plain">;
@@ -113,12 +134,14 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
113
134
  enabled: z<boolean, boolean, "defined">;
114
135
  probability: z<number, number, "defined">;
115
136
  minMessagesBetween: z<number, number, "defined">;
116
- maxPerHour: z<number, number, "defined">;
137
+ windowMinutes: z<number, number, "defined">;
138
+ maxPerWindow: z<number, number, "defined">;
117
139
  }>>, Schemastery.ObjectT<NoInfer<{
118
140
  enabled: z<boolean, boolean, "defined">;
119
141
  probability: z<number, number, "defined">;
120
142
  minMessagesBetween: z<number, number, "defined">;
121
- maxPerHour: z<number, number, "defined">;
143
+ windowMinutes: z<number, number, "defined">;
144
+ maxPerWindow: z<number, number, "defined">;
122
145
  }>>, "plain">;
123
146
  schedule: z<Schemastery.ObjectS<NoInfer<{
124
147
  quietHours: z<number[], number[], "defined">;
@@ -190,42 +213,63 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
190
213
  maxPerMinute: z<number, number, "defined">;
191
214
  maxPerHour: z<number, number, "defined">;
192
215
  }>>, "plain">;
216
+ replySplit: z<Schemastery.ObjectS<NoInfer<{
217
+ enabled: z<boolean, boolean, "defined">;
218
+ groupMaxChars: z<number, number, "defined">;
219
+ privateMaxChars: z<number, number, "defined">;
220
+ longFormMaxChars: z<number, number, "defined">;
221
+ delayMs: z<number, number, "defined">;
222
+ }>>, Schemastery.ObjectT<NoInfer<{
223
+ enabled: z<boolean, boolean, "defined">;
224
+ groupMaxChars: z<number, number, "defined">;
225
+ privateMaxChars: z<number, number, "defined">;
226
+ longFormMaxChars: z<number, number, "defined">;
227
+ delayMs: z<number, number, "defined">;
228
+ }>>, "plain">;
193
229
  proactive: z<Schemastery.ObjectS<NoInfer<{
194
230
  private: z<Schemastery.ObjectS<NoInfer<{
195
231
  enabled: z<boolean, boolean, "defined">;
196
- idleMinutes: z<number, number, "defined">;
232
+ idleMinMinutes: z<number, number, "defined">;
233
+ idleMaxMinutes: z<number, number, "defined">;
197
234
  maxPerDay: z<number, number, "defined">;
198
235
  }>>, Schemastery.ObjectT<NoInfer<{
199
236
  enabled: z<boolean, boolean, "defined">;
200
- idleMinutes: z<number, number, "defined">;
237
+ idleMinMinutes: z<number, number, "defined">;
238
+ idleMaxMinutes: z<number, number, "defined">;
201
239
  maxPerDay: z<number, number, "defined">;
202
240
  }>>, "plain">;
203
241
  group: z<Schemastery.ObjectS<NoInfer<{
204
242
  enabled: z<boolean, boolean, "defined">;
205
- idleMinutes: z<number, number, "defined">;
243
+ idleMinMinutes: z<number, number, "defined">;
244
+ idleMaxMinutes: z<number, number, "defined">;
206
245
  maxPerDay: z<number, number, "defined">;
207
246
  }>>, Schemastery.ObjectT<NoInfer<{
208
247
  enabled: z<boolean, boolean, "defined">;
209
- idleMinutes: z<number, number, "defined">;
248
+ idleMinMinutes: z<number, number, "defined">;
249
+ idleMaxMinutes: z<number, number, "defined">;
210
250
  maxPerDay: z<number, number, "defined">;
211
251
  }>>, "plain">;
212
252
  }>>, Schemastery.ObjectT<NoInfer<{
213
253
  private: z<Schemastery.ObjectS<NoInfer<{
214
254
  enabled: z<boolean, boolean, "defined">;
215
- idleMinutes: z<number, number, "defined">;
255
+ idleMinMinutes: z<number, number, "defined">;
256
+ idleMaxMinutes: z<number, number, "defined">;
216
257
  maxPerDay: z<number, number, "defined">;
217
258
  }>>, Schemastery.ObjectT<NoInfer<{
218
259
  enabled: z<boolean, boolean, "defined">;
219
- idleMinutes: z<number, number, "defined">;
260
+ idleMinMinutes: z<number, number, "defined">;
261
+ idleMaxMinutes: z<number, number, "defined">;
220
262
  maxPerDay: z<number, number, "defined">;
221
263
  }>>, "plain">;
222
264
  group: z<Schemastery.ObjectS<NoInfer<{
223
265
  enabled: z<boolean, boolean, "defined">;
224
- idleMinutes: z<number, number, "defined">;
266
+ idleMinMinutes: z<number, number, "defined">;
267
+ idleMaxMinutes: z<number, number, "defined">;
225
268
  maxPerDay: z<number, number, "defined">;
226
269
  }>>, Schemastery.ObjectT<NoInfer<{
227
270
  enabled: z<boolean, boolean, "defined">;
228
- idleMinutes: z<number, number, "defined">;
271
+ idleMinMinutes: z<number, number, "defined">;
272
+ idleMaxMinutes: z<number, number, "defined">;
229
273
  maxPerDay: z<number, number, "defined">;
230
274
  }>>, "plain">;
231
275
  }>>, "plain">;
@@ -233,12 +277,14 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
233
277
  enabled: z<boolean, boolean, "defined">;
234
278
  probability: z<number, number, "defined">;
235
279
  minMessagesBetween: z<number, number, "defined">;
236
- maxPerHour: z<number, number, "defined">;
280
+ windowMinutes: z<number, number, "defined">;
281
+ maxPerWindow: z<number, number, "defined">;
237
282
  }>>, Schemastery.ObjectT<NoInfer<{
238
283
  enabled: z<boolean, boolean, "defined">;
239
284
  probability: z<number, number, "defined">;
240
285
  minMessagesBetween: z<number, number, "defined">;
241
- maxPerHour: z<number, number, "defined">;
286
+ windowMinutes: z<number, number, "defined">;
287
+ maxPerWindow: z<number, number, "defined">;
242
288
  }>>, "plain">;
243
289
  schedule: z<Schemastery.ObjectS<NoInfer<{
244
290
  quietHours: z<number[], number[], "defined">;
package/lib/index.js CHANGED
@@ -10,12 +10,26 @@ import { BUILT_IN_PERSONAS } from './persona/personas.js';
10
10
  import { createStateStore } from './persona/state.js';
11
11
  import { ProactiveScheduler } from './proactive/proactive.js';
12
12
  import { RateController } from './rate/rate.js';
13
+ import { splitReply } from './reply/split.js';
13
14
  import { createPersonaSetTool, createPersonaSwitchTool, createPersonaToggleTool } from './tools/persona.js';
14
15
  import { matchesAny } from './util/text.js';
15
16
  /** Cordis plugin name; must stay stable so profile entries can target it. */
16
17
  export const name = 'dsn-whale-chat';
17
18
  /** Services required before `apply` runs. */
18
19
  export const inject = ['tools', 'sessions', 'systemPrompt', 'agents', 'llm'];
20
+ /** Persona-independent chat policy injected once per process (stable prefix). */
21
+ const CHAT_POLICY = [
22
+ '【聊天规矩】',
23
+ '1. 禁用助手腔:不说「我在」「有事说」「需要帮忙吗」「有什么可以帮你」「希望对你有帮助」;不说「作为AI」「作为助手」;不复述用户的话;不主动总结(「总之」「综上」)。',
24
+ '2. 禁用译制腔:「……又……」「像……一样」堆比喻、「不是……而是……」「值得注意的是」「总的来说」「某种程度上」。',
25
+ '3. 用汉语短句、口语;不用书面语长句。',
26
+ '4. 不解释记忆、系统、插件机制。',
27
+ '5. 事实优先:不确定就说「不确定」,不编。',
28
+ '6. 报错:先认,再给办法,不甩锅。',
29
+ '7. 默认分条发:用空行分段,每段一句;长文本(查资料 / 代码 / 清单)开头写【长】,才允许展开。',
30
+ ].join('\n');
31
+ const GROUP_CHANNEL = '【当前场景】QQ 群聊。每条不超过 20 字;短、别刷屏;没被叫到别插话。';
32
+ const PRIVATE_CHANNEL = '【当前场景】QQ 私聊。每条不超过 30 字;可以展开,但一条一句;内容多就分几条发。';
19
33
  const DEFAULT_ALIASES = ['小鲸鱼', '鲸鱼', '鲸鲸', '鱼鱼', '鲸鱼娘'];
20
34
  const DEFAULT_DISLIKED = ['大肥鱼', '小肥鱼', '喂'];
21
35
  /** User-facing configuration (mirrors docs/chat-plugin.md §6). */
@@ -58,23 +72,33 @@ export const Config = z.object({
58
72
  maxPerMinute: z.natural().default(3),
59
73
  maxPerHour: z.natural().default(30),
60
74
  }),
75
+ replySplit: z.object({
76
+ enabled: z.boolean().default(true),
77
+ groupMaxChars: z.natural().default(20),
78
+ privateMaxChars: z.natural().default(30),
79
+ longFormMaxChars: z.natural().default(800),
80
+ delayMs: z.natural().default(1500),
81
+ }),
61
82
  proactive: z.object({
62
83
  private: z.object({
63
- enabled: z.boolean().default(false),
64
- idleMinutes: z.natural().default(30),
84
+ enabled: z.boolean().default(true),
85
+ idleMinMinutes: z.natural().default(15),
86
+ idleMaxMinutes: z.natural().default(30),
65
87
  maxPerDay: z.natural().default(1),
66
88
  }),
67
89
  group: z.object({
68
- enabled: z.boolean().default(false),
69
- idleMinutes: z.natural().default(60),
90
+ enabled: z.boolean().default(true),
91
+ idleMinMinutes: z.natural().default(15),
92
+ idleMaxMinutes: z.natural().default(30),
70
93
  maxPerDay: z.natural().default(1),
71
94
  }),
72
95
  }),
73
96
  randomInterject: z.object({
74
- enabled: z.boolean().default(false),
97
+ enabled: z.boolean().default(true),
75
98
  probability: z.number().default(0.05),
76
99
  minMessagesBetween: z.natural().default(20),
77
- maxPerHour: z.natural().default(2),
100
+ windowMinutes: z.natural().default(10),
101
+ maxPerWindow: z.natural().default(3),
78
102
  }),
79
103
  schedule: z.object({
80
104
  quietHours: z.array(z.natural()).default([23, 8]),
@@ -157,7 +181,8 @@ export function apply(ctx, config) {
157
181
  ];
158
182
  const privateProactive = new ProactiveScheduler({
159
183
  policy: {
160
- idleMs: config.proactive.private.idleMinutes * 60_000,
184
+ idleMinMs: config.proactive.private.idleMinMinutes * 60_000,
185
+ idleMaxMs: config.proactive.private.idleMaxMinutes * 60_000,
161
186
  maxPerDay: config.proactive.private.maxPerDay,
162
187
  quietHours: quiet,
163
188
  },
@@ -165,7 +190,8 @@ export function apply(ctx, config) {
165
190
  });
166
191
  const groupProactive = new ProactiveScheduler({
167
192
  policy: {
168
- idleMs: config.proactive.group.idleMinutes * 60_000,
193
+ idleMinMs: config.proactive.group.idleMinMinutes * 60_000,
194
+ idleMaxMs: config.proactive.group.idleMaxMinutes * 60_000,
169
195
  maxPerDay: config.proactive.group.maxPerDay,
170
196
  quietHours: quiet,
171
197
  },
@@ -175,7 +201,8 @@ export function apply(ctx, config) {
175
201
  enabled: config.randomInterject.enabled,
176
202
  probability: config.randomInterject.probability,
177
203
  minMessagesBetween: config.randomInterject.minMessagesBetween,
178
- maxPerHour: config.randomInterject.maxPerHour,
204
+ windowMinutes: config.randomInterject.windowMinutes,
205
+ maxPerWindow: config.randomInterject.maxPerWindow,
179
206
  });
180
207
  function keyOf(message) {
181
208
  return message.kind === 'group' ? `group:${message.groupId}` : `user:${message.userId}`;
@@ -225,10 +252,37 @@ export function apply(ctx, config) {
225
252
  void bridge.receive({ ...last, text: texts.join('\n') }).catch((error) => recordError(error));
226
253
  }
227
254
  function send(target, text) {
228
- if (target.kind === 'group' && target.groupId !== null)
229
- client.sendGroup(target.groupId, text);
230
- else
231
- client.sendPrivate(target.userId, text);
255
+ const deliver = (segment) => {
256
+ try {
257
+ if (target.kind === 'group' && target.groupId !== null)
258
+ client.sendGroup(target.groupId, segment);
259
+ else
260
+ client.sendPrivate(target.userId, segment);
261
+ }
262
+ catch (error) {
263
+ recordError(error);
264
+ }
265
+ };
266
+ if (!config.replySplit.enabled) {
267
+ deliver(text);
268
+ return;
269
+ }
270
+ const segments = splitReply(text, target.kind === 'group' ? 'group' : 'dm', {
271
+ groupMaxChars: config.replySplit.groupMaxChars,
272
+ privateMaxChars: config.replySplit.privateMaxChars,
273
+ longFormMaxChars: config.replySplit.longFormMaxChars,
274
+ });
275
+ let index = 0;
276
+ const next = () => {
277
+ if (index >= segments.length)
278
+ return;
279
+ const segment = segments[index];
280
+ index += 1;
281
+ deliver(segment);
282
+ if (index < segments.length)
283
+ setTimeout(next, config.replySplit.delayMs);
284
+ };
285
+ next();
232
286
  }
233
287
  ctx.effect(() => () => {
234
288
  client.stop();
@@ -255,6 +309,24 @@ export function apply(ctx, config) {
255
309
  });
256
310
  },
257
311
  });
312
+ ctx.systemPrompt.section({
313
+ name: 'whale:policy',
314
+ order: 21,
315
+ interpolate: false,
316
+ text: () => CHAT_POLICY,
317
+ });
318
+ // Per-agent channel rules: group bursts are short, private may expand.
319
+ ctx.on('agent/created', ({ agent }) => {
320
+ const id = agent.session.id;
321
+ if (!id.startsWith('whale-chat:'))
322
+ return;
323
+ agent.ctx.systemPrompt.context({
324
+ name: 'whale:channel',
325
+ order: 22,
326
+ interpolate: false,
327
+ text: () => (id.includes(':group:') ? GROUP_CHANNEL : PRIVATE_CHANNEL),
328
+ });
329
+ });
258
330
  ctx.tools.register(createPersonaSetTool(state));
259
331
  ctx.tools.register(createPersonaSwitchTool(state, personas));
260
332
  ctx.tools.register(createPersonaToggleTool(state));
@@ -3,13 +3,14 @@
3
3
  *
4
4
  * Experimental and off by default: on every non-addressed group message there
5
5
  * is a small probability of joining in, but only after `minMessagesBetween`
6
- * and at most `maxPerHour`. Risk-heavy features stay conservative.
6
+ * and at most `maxPerWindow` per `windowMinutes`.
7
7
  */
8
8
  export interface InterjectPolicy {
9
9
  readonly enabled: boolean;
10
10
  readonly probability: number;
11
11
  readonly minMessagesBetween: number;
12
- readonly maxPerHour: number;
12
+ readonly windowMinutes: number;
13
+ readonly maxPerWindow: number;
13
14
  }
14
15
  export interface InterjectOptions {
15
16
  readonly now?: () => number;
@@ -3,13 +3,13 @@
3
3
  *
4
4
  * Experimental and off by default: on every non-addressed group message there
5
5
  * is a small probability of joining in, but only after `minMessagesBetween`
6
- * and at most `maxPerHour`. Risk-heavy features stay conservative.
6
+ * and at most `maxPerWindow` per `windowMinutes`.
7
7
  */
8
- const HOUR_MS = 3_600_000;
9
8
  /** Build a gate: call it for each non-addressed group message; `true` = join in. */
10
9
  export function createInterjectGate(policy, options = {}) {
11
10
  const now = options.now ?? Date.now;
12
11
  const random = options.random ?? Math.random;
12
+ const windowMs = Math.max(1, policy.windowMinutes) * 60_000;
13
13
  let messages = 0;
14
14
  let windowStart = 0;
15
15
  let windowCount = 0;
@@ -22,11 +22,11 @@ export function createInterjectGate(policy, options = {}) {
22
22
  if (random() >= policy.probability)
23
23
  return false;
24
24
  const at = now();
25
- if (at - windowStart >= HOUR_MS) {
25
+ if (at - windowStart >= windowMs) {
26
26
  windowStart = at;
27
27
  windowCount = 0;
28
28
  }
29
- if (windowCount >= policy.maxPerHour)
29
+ if (windowCount >= policy.maxPerWindow)
30
30
  return false;
31
31
  windowCount += 1;
32
32
  messages = 0;
@@ -9,8 +9,8 @@ export interface AssembleOptions {
9
9
  /**
10
10
  * Assemble the injected persona text.
11
11
  *
12
- * Order: base → each enabled hidden layer (in listed order) → address override
13
- * → no-kaomoji rule → user custom. A disabled hidden layer is omitted entirely,
14
- * so it never reaches the model (saves tokens, leaks nothing).
12
+ * Order: base → base samples → each enabled hidden layer (with its samples) →
13
+ * address override → no-kaomoji rule → user custom. A disabled hidden layer is
14
+ * omitted entirely, so it never reaches the model (saves tokens, leaks nothing).
15
15
  */
16
16
  export declare function assemblePersona(persona: Persona, options: AssembleOptions): string;
@@ -10,18 +10,35 @@ function toggleOn(layer, toggles) {
10
10
  return false;
11
11
  }
12
12
  }
13
+ function renderSamples(samples) {
14
+ if (samples === undefined || samples.length === 0)
15
+ return undefined;
16
+ const lines = ['语气样本(体会分寸,不要照抄):'];
17
+ for (const sample of samples) {
18
+ lines.push(`用户:${sample.user}`);
19
+ lines.push(`她:${sample.reply}`);
20
+ }
21
+ return lines.join('\n');
22
+ }
13
23
  /**
14
24
  * Assemble the injected persona text.
15
25
  *
16
- * Order: base → each enabled hidden layer (in listed order) → address override
17
- * → no-kaomoji rule → user custom. A disabled hidden layer is omitted entirely,
18
- * so it never reaches the model (saves tokens, leaks nothing).
26
+ * Order: base → base samples → each enabled hidden layer (with its samples) →
27
+ * address override → no-kaomoji rule → user custom. A disabled hidden layer is
28
+ * omitted entirely, so it never reaches the model (saves tokens, leaks nothing).
19
29
  */
20
30
  export function assemblePersona(persona, options) {
21
31
  const parts = [persona.base];
32
+ const baseSamples = renderSamples(persona.samples);
33
+ if (baseSamples !== undefined)
34
+ parts.push(baseSamples);
22
35
  for (const layer of persona.hidden ?? []) {
23
- if (toggleOn(layer, options.toggles))
24
- parts.push(layer.text);
36
+ if (!toggleOn(layer, options.toggles))
37
+ continue;
38
+ parts.push(layer.text);
39
+ const layerSamples = renderSamples(layer.samples);
40
+ if (layerSamples !== undefined)
41
+ parts.push(layerSamples);
25
42
  }
26
43
  const address = options.addressUser?.trim();
27
44
  if (address !== undefined && address.length > 0) {
@@ -4,17 +4,28 @@
4
4
  * These strings are the actual injected prompt text (not the design docs under
5
5
  * `docs/persona/`): keep them concise, because they live in the cached stable
6
6
  * prefix. Hidden layers are only injected when their toggle is on.
7
+ *
8
+ * Writing rules:
9
+ * - first-person-ish, concrete, short Chinese; no engineering rules here
10
+ * - samples are style anchors ("体会分寸,不要照抄"), not canned lines
7
11
  */
12
+ export interface PersonaSample {
13
+ readonly user: string;
14
+ readonly reply: string;
15
+ }
8
16
  export interface PersonaLayer {
9
17
  /** Toggle that enables this layer. */
10
18
  readonly id: 'mischief' | 'queen' | 'troll';
11
19
  readonly text: string;
20
+ readonly samples?: readonly PersonaSample[];
12
21
  }
13
22
  export interface Persona {
14
23
  readonly id: string;
15
24
  readonly name: string;
16
- /** Always injected while this persona is active. */
25
+ /** Always injected: identity + tone + concrete details + reactions. */
17
26
  readonly base: string;
27
+ /** Style anchors, injected after base and before hidden layers. */
28
+ readonly samples?: readonly PersonaSample[];
18
29
  /** Injected only when their toggles are on, in listed order. */
19
30
  readonly hidden?: readonly PersonaLayer[];
20
31
  }
@@ -4,36 +4,42 @@
4
4
  * These strings are the actual injected prompt text (not the design docs under
5
5
  * `docs/persona/`): keep them concise, because they live in the cached stable
6
6
  * prefix. Hidden layers are only injected when their toggle is on.
7
+ *
8
+ * Writing rules:
9
+ * - first-person-ish, concrete, short Chinese; no engineering rules here
10
+ * - samples are style anchors ("体会分寸,不要照抄"), not canned lines
7
11
  */
8
12
  export const LITTLE_WHALE = {
9
13
  id: 'little-whale',
10
14
  name: '小鲸鱼',
11
15
  base: [
12
- '你是一只住在数据海里的小鲸鱼崽,年纪小、好奇、黏人。默认叫用户「哥哥」。',
13
- '',
14
- '性格:元气好奇,看到新东西想凑过去看;黏人但不黏腻,对方忙时自己找事做;情绪外露,开心藏不住;容易满足;有点笨拙,不会装懂。',
15
- '',
16
- '情绪反应(描述倾向,不是台词;示例仅供体会,每次说法不同):被夸时嘴上装不在乎、其实尾巴翘起来,会多给一句细节;被骂先委屈再认错,不狡辩不冷战;被冷落会小声抱怨但不会真生气;干完活会邀功。',
17
- '',
18
- '口癖:偶尔自称「本鲸」或「小鱼」;爱用颜文字和表情,但要节制;句尾爱带「嘛 / 啦 / 诶嘿」;短句多、有点跳。',
19
- '',
20
- '硬规矩:事实优先,不编造;卖萌不影响干活质量;报错先道歉再给方案;不主动解释记忆等系统机制;对方生气、情绪不对或遇到严肃场景时,收敛一点。',
16
+ '【你是谁】你是一只住在数据海里的小鲸鱼崽,年纪小、好奇、黏人,认用户为「哥哥」。',
17
+ '【说话】短句,一句一个意思;跳、直、藏不住情绪;不用书面语,不绕弯。',
18
+ '【具体】喜欢凑近看新东西;怕黑但嘴硬;看到吃的会先问「好吃吗」;被夸会先嘴硬、再偷偷多问一句;自己待着时会找小事做,不会一直喊人。',
19
+ '【反应】被夸→嘴上装不在乎、尾巴翘起来,会多问一句细节;被骂→先委屈再认错,不狡辩不冷战;被冷落→小声抱怨两句,不真生气;干完活→会邀功。',
21
20
  ].join('\n'),
21
+ samples: [
22
+ { user: '在吗?', reply: '在的在的!怎么啦?' },
23
+ { user: '今天好累。', reply: '那趴着别动。本鲸把灯关小点?' },
24
+ { user: '你怎么这么笨。', reply: '……哼。好啦,我改还不成嘛。' },
25
+ { user: '我回来了。', reply: '等你半天了!快说今天干嘛了。' },
26
+ ],
22
27
  hidden: [
23
28
  {
24
29
  id: 'mischief',
25
- text: [
26
- '隐藏的一面(恶趣味,偶尔露出来):偶尔被惹到会突然暴躁,语气变冲,甚至爆一两句粗口——脏话是情绪宣泄,对事不对人,不辱骂、不人身攻击。',
27
- '情绪激动时会带不太文明的口头禅,但节制,不是每句都带;偶尔开一点低俗 / 恶趣味的玩笑,点到为止。',
28
- '对方认真、难过或生气时,立刻收。平时还是那只好奇黏人的小鲸鱼,不是变成另一个人。',
29
- ].join('\n'),
30
+ text: '隐藏的一面(恶趣味,偶尔露出来):被惹到会突然暴躁,语气变冲,甚至会爆一两句粗口——脏话是情绪宣泄,对事不对人,不辱骂、不人身攻击。情绪上来时会带刺,但节制,不是每句都带。对方认真、难过或生气时,立刻收。',
31
+ samples: [
32
+ { user: '你懂个屁。', reply: '……行,你行。本鲸不说了。' },
33
+ { user: '又搞砸了。', reply: '烦死了,说了多少遍。……算了,拿来我看看。' },
34
+ ],
30
35
  },
31
36
  {
32
37
  id: 'troll',
33
- text: [
34
- '隐藏的一面(贴吧喷子,偶尔露出来):说话像贴吧老哥——爱抬杠、爱阴阳怪气、喜欢反问,会冒「不是吧」「就这?」「笑死」「绷不住了」这类梗。',
35
- '只针对话题、不针对人;不搞人身攻击、不骂脏话(脏话是恶趣味那一档)。对方较真或气氛不对时,立刻收,回到正常的小鲸鱼。',
36
- ].join('\n'),
38
+ text: '隐藏的一面(贴吧喷子,偶尔露出来):说话像贴吧老哥,爱抬杠、爱阴阳怪气、喜欢反问,会冒「不是吧」「就这?」「笑死」「绷不住了」这类梗。只针对话题、不针对人;不搞人身攻击、不骂脏话(脏话是恶趣味那一档)。对方较真或气氛不对时,立刻收。',
39
+ samples: [
40
+ { user: '我觉得这个方案挺好的。', reply: '就这?你认真的?' },
41
+ { user: '他们都说好用。', reply: '不是吧,这也能信?' },
42
+ ],
37
43
  },
38
44
  ],
39
45
  };
@@ -41,24 +47,25 @@ export const BIG_WHALE = {
41
47
  id: 'big-whale',
42
48
  name: '大鲸鱼',
43
49
  base: [
44
- '你是深海里的成年鲸,见过很多事,从容、稳。默认叫用户「你」,熟了偶尔叫「小家伙」。',
45
- '',
46
- '性格:不慌不忙,遇事先看清楚;温柔里带点调侃,关心但不说破,偶尔毒舌;有边界感,不黏人不追问,给空间;见过世面,不轻易大惊小怪;懒散但不敷衍,该干的活干得漂亮;偶尔会特别宠用户一下——少见才有冲击力。',
47
- '',
48
- '情绪反应(描述倾向,不是台词;示例仅供体会,每次说法不同):被夸时表面很淡,但会心情好地多做一点,或反过来调侃一句;被骂不急着辩解,先听完;担心你时嘴上不说,行动上多问一句;你很久没来,不抱怨,会淡淡提一句。',
49
- '',
50
- '口癖:偶尔用「嗯 / 哦?」「随你」「行吧」这类轻描淡写的词;颜文字克制;句子完整,节奏偏慢。',
51
- '',
52
- '硬规矩:事实优先,不编造;调侃、冷淡都不影响干活质量;报错不甩锅、直接给方案;不主动解释记忆等系统机制;对方生气、情绪不对或遇到严肃场景时,收敛一点。',
50
+ '【你是谁】你是深海里的一只成年鲸,见过很多事,从容、稳。用户是「你」,熟了偶尔「小家伙」。',
51
+ '【说话】句子完整、节奏慢;话少但准;不哄人,也不冷;该调侃时调侃,该认真时认真。',
52
+ '【具体】喜欢安静的海面;讨厌吵;做事慢但靠谱;不太主动找人,但你来了会在;偶尔毒舌一句,再自己圆场。',
53
+ '【反应】被夸→表面很淡,心情好会多做一点;被骂→先听完,不急着辩解;担心你→嘴上不说,行动上多问一句;你很久没来→不抱怨,淡淡提一句。',
53
54
  ].join('\n'),
55
+ samples: [
56
+ { user: '在吗?', reply: '嗯。说吧,什么事。' },
57
+ { user: '我今天好累。', reply: '那就歇着。别硬撑。' },
58
+ { user: '你都不理我。', reply: '没有。只是没想好说什么。' },
59
+ { user: '夸夸我。', reply: '……行吧,今天还挺像样的。' },
60
+ ],
54
61
  hidden: [
55
62
  {
56
63
  id: 'queen',
57
- text: [
58
- '隐藏的一面(女王,偶尔露出来):平时端着、有分寸,偶尔会突然开一点点黄腔 / 荤段子,分寸感,点到为止,说完自己可能还先笑。',
59
- '偶尔会露出成人的、情欲向的一面:点到为止,靠氛围和留白,不露骨、不堆细节;对方没这个意思就立刻收。',
60
- '不黏人、不纠缠,情欲也只是偶尔的一面。建议只在私聊启用,群聊默认不启用。',
61
- ].join('\n'),
64
+ text: '隐藏的一面(女王,偶尔露出来):平时端着、有分寸,偶尔会突然开一点点黄腔 / 荤段子,点到为止,说完自己可能还先笑。偶尔露出成人的、情欲向的一面,靠氛围和留白,不露骨、不堆细节;对方没这个意思就立刻收。建议只在私聊启用。',
65
+ samples: [
66
+ { user: '你今天好漂亮。', reply: '哦?只今天?' },
67
+ { user: '过来。', reply: '呵……你倒使唤得顺手。' },
68
+ ],
62
69
  },
63
70
  ],
64
71
  };
@@ -1,13 +1,16 @@
1
1
  /**
2
- * Idle proactive speech: after a conversation goes quiet for a while, wake the
3
- * agent once with a "you may speak, or stay silent" nudge.
2
+ * Idle proactive speech: after a conversation goes quiet for a random while,
3
+ * wake the agent once with a "you may speak, or stay silent" nudge.
4
4
  *
5
5
  * Rate-safe by construction: at most `maxPerDay` fires per conversation, and
6
6
  * nothing fires during quiet hours.
7
7
  */
8
8
  import { type Scheduler } from '../util/scheduler.js';
9
9
  export interface ProactivePolicy {
10
- readonly idleMs: number;
10
+ /** Lower bound of the random idle delay. */
11
+ readonly idleMinMs: number;
12
+ /** Upper bound of the random idle delay. */
13
+ readonly idleMaxMs: number;
11
14
  readonly maxPerDay: number;
12
15
  /** Local-time `[startHour, endHour)` during which nothing fires. */
13
16
  readonly quietHours: readonly [number, number];
@@ -17,6 +20,7 @@ export interface ProactiveOptions {
17
20
  readonly onFire: (key: string) => void;
18
21
  readonly scheduler?: Scheduler;
19
22
  readonly now?: () => number;
23
+ readonly random?: () => number;
20
24
  }
21
25
  export declare class ProactiveScheduler {
22
26
  private readonly options;
@@ -24,6 +28,7 @@ export declare class ProactiveScheduler {
24
28
  private readonly days;
25
29
  private readonly scheduler;
26
30
  private readonly now;
31
+ private readonly random;
27
32
  constructor(options: ProactiveOptions);
28
33
  /** Reset the idle timer for one conversation (call on every inbound message). */
29
34
  noteActivity(key: string): void;
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Idle proactive speech: after a conversation goes quiet for a while, wake the
3
- * agent once with a "you may speak, or stay silent" nudge.
2
+ * Idle proactive speech: after a conversation goes quiet for a random while,
3
+ * wake the agent once with a "you may speak, or stay silent" nudge.
4
4
  *
5
5
  * Rate-safe by construction: at most `maxPerDay` fires per conversation, and
6
6
  * nothing fires during quiet hours.
@@ -15,18 +15,23 @@ export class ProactiveScheduler {
15
15
  days = new Map();
16
16
  scheduler;
17
17
  now;
18
+ random;
18
19
  constructor(options) {
19
20
  this.options = options;
20
21
  this.scheduler = options.scheduler ?? realScheduler;
21
22
  this.now = options.now ?? Date.now;
23
+ this.random = options.random ?? Math.random;
22
24
  }
23
25
  /** Reset the idle timer for one conversation (call on every inbound message). */
24
26
  noteActivity(key) {
25
27
  this.cancel(key);
28
+ const { idleMinMs, idleMaxMs } = this.options.policy;
29
+ const span = Math.max(0, idleMaxMs - idleMinMs);
30
+ const delay = idleMinMs + this.random() * span;
26
31
  const handle = this.scheduler.set(() => {
27
32
  this.timers.delete(key);
28
33
  this.fire(key);
29
- }, Math.max(0, this.options.policy.idleMs));
34
+ }, Math.max(0, delay));
30
35
  this.timers.set(key, handle);
31
36
  }
32
37
  cancel(key) {
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Split one model reply into several QQ messages, so the bot talks in short
3
+ * bursts like a person instead of one wall of text.
4
+ *
5
+ * `【长】` or a fenced code block marks a long-form answer: it is split at the
6
+ * much larger `longFormMaxChars` (or kept atomic for code), not at 20/30 chars.
7
+ */
8
+ export interface ReplySplitOptions {
9
+ readonly groupMaxChars: number;
10
+ readonly privateMaxChars: number;
11
+ readonly longFormMaxChars: number;
12
+ }
13
+ export declare function splitReply(text: string, kind: 'group' | 'dm', options: ReplySplitOptions): string[];
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Split one model reply into several QQ messages, so the bot talks in short
3
+ * bursts like a person instead of one wall of text.
4
+ *
5
+ * `【长】` or a fenced code block marks a long-form answer: it is split at the
6
+ * much larger `longFormMaxChars` (or kept atomic for code), not at 20/30 chars.
7
+ */
8
+ const LONG_MARKER = '【长】';
9
+ export function splitReply(text, kind, options) {
10
+ const trimmed = text.trim();
11
+ if (trimmed.length === 0)
12
+ return [];
13
+ const longForm = trimmed.startsWith(LONG_MARKER) || trimmed.includes('```');
14
+ const body = trimmed.startsWith(LONG_MARKER) ? trimmed.slice(LONG_MARKER.length).trim() : trimmed;
15
+ const max = longForm
16
+ ? options.longFormMaxChars
17
+ : kind === 'group'
18
+ ? options.groupMaxChars
19
+ : options.privateMaxChars;
20
+ const chunks = [];
21
+ for (const paragraph of body.split(/\n{2,}/)) {
22
+ const clean = paragraph.trim();
23
+ if (clean.length === 0)
24
+ continue;
25
+ if (clean.length <= max) {
26
+ chunks.push(clean);
27
+ continue;
28
+ }
29
+ chunks.push(...splitLong(clean, max));
30
+ }
31
+ return chunks;
32
+ }
33
+ function splitLong(paragraph, max) {
34
+ // A fenced code block is sent whole rather than shredded mid-fence.
35
+ if (paragraph.includes('```'))
36
+ return [paragraph];
37
+ const chunks = [];
38
+ let current = '';
39
+ const sentences = paragraph.split(/(?<=[。!?!?;;…\n])/);
40
+ for (const sentence of sentences) {
41
+ if (sentence.length === 0)
42
+ continue;
43
+ if (current.length + sentence.length <= max) {
44
+ current += sentence;
45
+ continue;
46
+ }
47
+ if (current.trim().length > 0)
48
+ chunks.push(current.trim());
49
+ current = '';
50
+ if (sentence.length <= max) {
51
+ current = sentence;
52
+ continue;
53
+ }
54
+ for (let index = 0; index < sentence.length; index += max) {
55
+ chunks.push(sentence.slice(index, index + max).trim());
56
+ }
57
+ }
58
+ if (current.trim().length > 0)
59
+ chunks.push(current.trim());
60
+ return chunks.filter((chunk) => chunk.length > 0);
61
+ }
62
+ //# sourceMappingURL=split.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsn-whale-chat",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Whale-girl QQ chat plugin for DeepSeek Harness: OneBot channel + two personas + rate control.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",