dsh-feini 0.1.5 → 0.1.7

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
@@ -28,6 +28,11 @@
28
28
  | 提醒 | 「十点提醒我」这种,到点它主动喊你 |
29
29
  | 联网 | 问到天气/价格/赛果/版本这类会现查(需要智谱 key),查不到就说没查到 |
30
30
  | 主动找你 | 闲一阵会主动开口;你太久不理它,它会闹点小脾气,哄两句才好 |
31
+ | 生日 | 第一次聊到日子会问一次你的生日,记着但不主动提;到那天问候,而且一整天都能围着生日聊 |
32
+ | 早上问候 | 你说了晚安就进入静默(夜里再说话会顺延),到早上先来一句「早上好」,之后不回才开始攒小脾气 |
33
+ | 到了吗 | 你说拜拜出门,下次开口时顺口问一句到没到,不单排定时 |
34
+ | 表情认重 | 同一张表情包被重压过也能认出来(按识图描述比对),认出来不重复解释 |
35
+ | 自己的主意 | 会顶嘴、会说不想,也有自己感兴趣和讨厌的东西 |
31
36
 
32
37
  ## 需要准备
33
38
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-feini",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "菲妮做的 QQ 陪伴插件:一张空白人设、一本笔记本、一套会攒记忆的规矩,让你自己养自己的伙伴。",
5
5
  "author": "Feini",
6
6
  "icon": "assets/logo.svg",
package/src/anger.mjs CHANGED
@@ -8,7 +8,7 @@
8
8
  * - 哄好只消当下的气,但「账」记着(strikes),下次再犯直接跳回更高一级
9
9
  * - 七天没再犯,账减一笔;但不会凭空清零
10
10
  *
11
- * 状态存数据目录下的 anger.json
11
+ * 状态存 D:\QQBridge\anger.json
12
12
  */
13
13
  import fs from 'node:fs';
14
14
 
@@ -0,0 +1,43 @@
1
+ /**
2
+ * 「他出门了,问一句到了吗」——不单独排定时,借下一次开口带出来。
3
+ *
4
+ * 他拜拜/出门的时候在 away.json 里记一笔 needArrival;
5
+ * 下一次主动聊天、或者他先说话时,system 里带上这段提示,问过就标记 asked,不再重复。
6
+ */
7
+ import fs from 'node:fs';
8
+
9
+ const TTL = 3 * 3600 * 1000;
10
+
11
+ export function arrivalNote(file) {
12
+ try {
13
+ const s = JSON.parse(fs.readFileSync(file, 'utf8'));
14
+ if (!s?.needArrival || s.asked) return '';
15
+ if (Date.now() - Number(s.at || 0) > TTL) return '';
16
+ return (
17
+ '\n\n【他出门了】他刚才说要出门/拜拜,还没确认到没到。' +
18
+ '这句要是你主动开口,就顺口问一句「到了吗」;要是他先说话,就接一句关心(到了就好),别像查岗。'
19
+ );
20
+ } catch {
21
+ return '';
22
+ }
23
+ }
24
+
25
+ export function markArrivalAsked(file) {
26
+ try {
27
+ const s = JSON.parse(fs.readFileSync(file, 'utf8'));
28
+ if (!s?.needArrival || s.asked) return;
29
+ s.asked = true;
30
+ s.askedAt = Date.now();
31
+ fs.writeFileSync(file, JSON.stringify(s), 'utf8');
32
+ } catch {
33
+ /* 记不上就算了 */
34
+ }
35
+ }
36
+
37
+ export function markLeaving(file, what = '出门') {
38
+ try {
39
+ fs.writeFileSync(file, JSON.stringify({ what, at: Date.now(), needArrival: true, asked: false }), 'utf8');
40
+ } catch {
41
+ /* 记不上就算了 */
42
+ }
43
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * 生日:只问一次,记一辈子,平时不主动提,到他生日那天问候。
3
+ *
4
+ * 规则(他定的):
5
+ * - 第一次聊到日子(「什么日子」「节日」「几号」)时,顺口问一次生日;
6
+ * - 记下来,但平时不主动提——只有生日当天才问候;
7
+ * - 他问起来可以说,但按人设委婉说,别像查档案。
8
+ */
9
+ import fs from 'node:fs';
10
+
11
+ const DATE_CN = /(\d{1,2})\s*月\s*(\d{1,2})\s*[日号]/;
12
+ const DATE_SLASH = /(?:^|\D)(\d{1,2})[/-](\d{1,2})(?:\D|$)/;
13
+ const DATE_WORD_RE = /(什么日子|今天几号|星期几|节日|日期|几月|几号|生日|纪念日)/;
14
+
15
+ function load(file) {
16
+ try {
17
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
18
+ } catch {
19
+ return {};
20
+ }
21
+ }
22
+
23
+ function save(file, s) {
24
+ try {
25
+ fs.writeFileSync(file, JSON.stringify(s), 'utf8');
26
+ } catch {
27
+ /* 记不上就算了 */
28
+ }
29
+ }
30
+
31
+ const pad = (n) => String(n).padStart(2, '0');
32
+
33
+ export function birthdayOf(file) {
34
+ return String(load(file).birthday || '');
35
+ }
36
+
37
+ /** 他这句里报了生日就记下来(问过之后三小时内认得更松) */
38
+ export function captureBirthday(file, text) {
39
+ const t = String(text || '');
40
+ const s = load(file);
41
+ if (s.birthday) return { saved: false };
42
+ const m = t.match(DATE_CN) || t.match(DATE_SLASH);
43
+ if (!m) return { saved: false };
44
+ const mm = Number(m[1]);
45
+ const dd = Number(m[2]);
46
+ if (!(mm >= 1 && mm <= 12 && dd >= 1 && dd <= 31)) return { saved: false };
47
+ const looksRight = /(生日|出生|哪天生)/.test(t) || Date.now() - Number(s.askedAt || 0) < 3 * 3600 * 1000;
48
+ if (!looksRight) return { saved: false };
49
+ s.birthday = `${pad(mm)}-${pad(dd)}`;
50
+ s.capturedAt = Date.now();
51
+ save(file, s);
52
+ return { saved: true, birthday: s.birthday };
53
+ }
54
+
55
+ /** 他第一次聊到日子:问一次生日,问过就不再问 */
56
+ export function askHint(file, text) {
57
+ const t = String(text || '');
58
+ const s = load(file);
59
+ if (s.birthday || s.asked) return '';
60
+ if (!DATE_WORD_RE.test(t)) return '';
61
+ s.asked = Date.now();
62
+ save(file, s);
63
+ return (
64
+ '\n\n【第一次聊到日子】他提到日子了。顺口问一句他生日是哪天,就这一次,' +
65
+ '语气自然点、带点你自己的性子,别像填表;问完以后不主动提,等他生日那天再用。'
66
+ );
67
+ }
68
+
69
+ /** 生日当天才提;一年只提一次 */
70
+ export function todayHint(file, now = new Date()) {
71
+ const s = load(file);
72
+ if (!s.birthday) return '';
73
+ const today = `${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
74
+ if (today !== s.birthday) return '';
75
+ if (Number(s.greetedYear) === now.getFullYear()) {
76
+ // 当天可以说一整天,只是别把「生日快乐」翻来覆去地念
77
+ return (
78
+ '\n\n【今天还是他生日】生日话题可以接着聊——今天怎么过的、想吃什么、要不要什么礼物,' +
79
+ '都顺着他聊;但别再重复说「生日快乐」,也别一开口就绕着生日转,他聊别的就正常接。'
80
+ );
81
+ }
82
+ return (
83
+ '\n\n【今天是他生日】今天是他的生日。第一句就跟他说生日快乐,' +
84
+ '用你自己的方式说,别像系统通知;这一天都可以围绕生日聊,别整段念祝福词。'
85
+ );
86
+ }
87
+
88
+ export function markGreeted(file, now = new Date()) {
89
+ const s = load(file);
90
+ if (!s.birthday) return;
91
+ const today = `${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
92
+ if (today !== s.birthday) return;
93
+ s.greetedYear = now.getFullYear();
94
+ save(file, s);
95
+ }
96
+
97
+ /** 一直挂在 system 里的规矩 */
98
+ export const BIRTHDAY_RULE =
99
+ '\n\n【生日·记着但不主动提】你知道他的生日,但平时绝不主动提;只有生日当天才提,那天可以聊一整天。' +
100
+ '他问起来可以说,但要按你的性子委婉说——比如先绕个弯、傲娇一下再说,别像查档案。';
package/src/bridge.mjs CHANGED
@@ -37,12 +37,16 @@ import { lastAssistant } from './last-assistant.mjs';
37
37
  import { takeInterest, addInterest, removeInterest, interestsLine, INTEREST_RULE } from './interests.mjs';
38
38
  import { startInterestReview } from './review.mjs';
39
39
  import { seaLine } from './sea.mjs';
40
+ import { planHint } from './guess-plan.mjs';
41
+ import { arrivalNote, markArrivalAsked, markLeaving } from './arrival.mjs';
42
+ import { BIRTHDAY_RULE, askHint, captureBirthday, markGreeted, todayHint } from './birthday.mjs';
40
43
  import { takeGuess, addGuess, resolveGuess, guessesLine, GUESS_RULE } from './guess.mjs';
41
44
  import { ANGER_RULE, angerLine, offend, coaxed, looksLikeCoax } from './anger.mjs';
42
45
  import { getBalance, BALANCE_RULE } from './balance.mjs';
43
46
  import { addUsage, usageSummary, USAGE_RULE } from './usage.mjs';
44
47
  import { createDiary } from './diary.mjs';
45
48
  import { onUsage } from './llm.mjs';
49
+ import { buildSea } from './build-sea.mjs';
46
50
 
47
51
  const here = path.dirname(fileURLToPath(import.meta.url));
48
52
  const root = process.env.DSH_FEINI_HOME ? path.resolve(process.env.DSH_FEINI_HOME) : path.resolve(here, '..');
@@ -138,6 +142,17 @@ const POUT_RULE =
138
142
  '他说「迟到了」「我还是迟到了」又没指明是哪件事,默认就是迟回来找我聊天——别猜成别的约会,' +
139
143
  '也别回「原来是迟到这件事呀」这种没接住的话,直接顺着「迟了来找我」接(「原来是迟了来找我呀,那这笔账我可得算算」)。';
140
144
 
145
+ // 问吃饭先看钟:三点问晚饭、或者他刚说吃过还问同一顿,都显得没在听
146
+ // 他也说过:别什么都顺着他,我有自己的主意和感兴趣的东西
147
+ const SELF_RULE =
148
+ '\n\n【你也有自己的主意】别什么都顺着他:真不同意就说出来,可以顶一句嘴、可以不接他的玩笑、可以说「我不想」。\n' +
149
+ '你有自己感兴趣的和讨厌的东西——遇到了主动说一句,不用先掂量他想不想听;但别把「有个性」演成抬杠,观点要真,不用每次都反着来。';
150
+
151
+ const MEAL_RULE =
152
+ '\n\n【问吃饭先看时间】要问「吃没吃」之前,先看上面的【现在】几点:' +
153
+ '11 点前只问早饭,11–14 点问午饭,17 点后才问晚饭;别的时间别提哪一顿,更别把「晚饭」当闲聊话题。\n' +
154
+ '他今天说过吃过哪顿(「吃了」「刚吃完」),就别再问同一顿;记不清就用不指时间的话问「吃过了没」。';
155
+
141
156
  const QQ_NOTE_REMINDER =
142
157
  '\n\n【这是 QQ,不是桌面】上面那条记笔记的规矩在这里同样生效:' +
143
158
  '他这句话里如果有关于他自己、值得长期留下的事,就在回复最后另起一行写〔记〕一句话。' +
@@ -154,13 +169,17 @@ const angerFile = path.join(root, 'anger.json');
154
169
  const petMemoryDir = config.petMemoryDir || '';
155
170
  const interestFile = petMemoryDir ? path.join(petMemoryDir, 'interests.md') : path.join(root, 'interests.md');
156
171
  // 记忆海的数据:pet-memory 的上一级(工作区)里的 memory-sea/data/me.json
157
- const seaFile = petMemoryDir
158
- ? path.join(petMemoryDir, '..', 'memory-sea', 'data', 'me.json')
159
- : path.join(root, 'me.json');
172
+ const seaFile = config.seaFile
173
+ ? path.resolve(root, config.seaFile)
174
+ : petMemoryDir
175
+ ? path.join(petMemoryDir, '..', 'memory-sea', 'data', 'me.json')
176
+ : path.join(root, 'me.json');
160
177
  // 笔记本:每轮现读,改完立刻生效(人设里不再抄它)
161
- const notebookFile = petMemoryDir
162
- ? path.join(petMemoryDir, '..', 'memory', 'notebook.md')
163
- : path.join(root, 'notebook.md');
178
+ const notebookFile = config.notebookPath
179
+ ? path.resolve(root, config.notebookPath)
180
+ : petMemoryDir
181
+ ? path.join(petMemoryDir, '..', 'memory', 'notebook.md')
182
+ : path.join(root, 'notebook.md');
164
183
 
165
184
  let notebookCache = { mtime: 0, text: '' };
166
185
  function notebookText() {
@@ -177,6 +196,25 @@ function notebookText() {
177
196
  // 人设里「别硬报时」是给桌面碎碎念写的;QQ 里他问时间必须直接答,所以把这句话改软
178
197
  const personaForQQ = baseSystemPrompt.replace(/别硬报时/g, '碎碎念时别反复提时间');
179
198
 
199
+ /** 记忆海:每两分钟把「对话 + 笔记本 + 日记」重建成索引,启动两秒后先建一次 */
200
+ function rebuildSea() {
201
+ try {
202
+ const r = buildSea({
203
+ memoryPath: config.memoryPath,
204
+ entry: config.bucket.entry,
205
+ pet: config.bucket.pet,
206
+ notebookPath: notebookFile,
207
+ diaryDir: config.diaryDir ? path.resolve(root, config.diaryDir) : path.join(root, 'diary'),
208
+ outFile: seaFile,
209
+ });
210
+ log(`记忆海重建:长期 ${r.longTerm} 条、对话 ${r.messages} 条、日记 ${r.diaries} 篇`);
211
+ } catch (e) {
212
+ log('记忆海重建失败:' + e.message);
213
+ }
214
+ }
215
+ setTimeout(rebuildSea, 2000).unref?.();
216
+ setInterval(rebuildSea, 2 * 60 * 1000).unref?.();
217
+
180
218
  // 每次说话都现读人设:sync 改完下一句就生效,不用重启桥
181
219
  function freshPersona() {
182
220
  if (!config.systemPromptPath) return personaForQQ;
@@ -197,6 +235,9 @@ const baseSystemNow = () =>
197
235
  FORMAT_RULE +
198
236
  CORRECTION_RULE +
199
237
  RECENT_RULE +
238
+ MEAL_RULE +
239
+ BIRTHDAY_RULE +
240
+ SELF_RULE +
200
241
  POUT_RULE +
201
242
  SEARCH_RULE +
202
243
  QQ_NOTE_REMINDER +
@@ -233,6 +274,25 @@ function sulkLine() {
233
274
  return '';
234
275
  }
235
276
  }
277
+ /** 他今天提过吃的事:拼成一行,别把吃过的那顿再问一遍 */
278
+ function todayMealsLine() {
279
+ try {
280
+ const mem = JSON.parse(fs.readFileSync(config.memoryPath, "utf8"));
281
+ const list = mem?.[config.bucket.entry]?.[config.bucket.pet]?.messages || [];
282
+ const start = new Date();
283
+ start.setHours(0, 0, 0, 0);
284
+ const said = list
285
+ .filter((m) => m.role === "user" && Number(m.ts) >= start.getTime())
286
+ .map((m) => String(m.content || "").replace(/\s+/g, " ").trim())
287
+ .filter((t) => /(吃|饭|饿)/.test(t) && /(了|过|完|饱)/.test(t))
288
+ .slice(-6);
289
+ if (!said.length) return "";
290
+ return "\n\n【他今天提过吃的事】" + said.map((s) => s.slice(0, 40)).join(";") + "。吃过的那顿别再问一遍。";
291
+ } catch {
292
+ return "";
293
+ }
294
+ }
295
+
236
296
  /** 每次说话前现拼 system:心情、他的状态、今天的日期都会变 */
237
297
  function buildSystem(query = '') {
238
298
  let recall = '';
@@ -262,8 +322,12 @@ function buildSystem(query = '') {
262
322
  baseSystemNow() +
263
323
  (notebookText() ? '\n\n【我的笔记本·每轮现读】\n' + notebookText() : '') +
264
324
  nowLine() +
325
+ askHint(birthdayFile, query) +
326
+ todayHint(birthdayFile) +
327
+ todayMealsLine() +
265
328
  festivalLine() +
266
329
  upcomingLine(reminderFile) +
330
+ arrivalNote(awayFile) +
267
331
  moodLine(moodFile) +
268
332
  himLine(himFile) +
269
333
  proactiveLine() +
@@ -419,6 +483,34 @@ async function saveSticker(img) {
419
483
  } catch (e) {
420
484
  log('表情包描述没写成:', e.message);
421
485
  }
486
+ // 同一张图被 QQ 重新压过,hash 就变了:用描述认一下,像的话记成别名,不建第二条
487
+ if (desc) {
488
+ let best = '';
489
+ let bestScore = 0;
490
+ for (const [k, v] of Object.entries(index)) {
491
+ const s = descSimilarity(desc, v?.desc || '');
492
+ if (s > bestScore) {
493
+ bestScore = s;
494
+ best = k;
495
+ }
496
+ }
497
+ if (best && bestScore >= 0.72) {
498
+ index[best].aliases = [...new Set([...(index[best].aliases || []), name])];
499
+ fs.writeFileSync(stickerIndex, JSON.stringify(index, null, 2), 'utf8');
500
+ try {
501
+ fs.copyFileSync(finalFile, path.join(autoDir, `${name}.${ext}`));
502
+ } catch {
503
+ /* 文件早就在就算了 */
504
+ }
505
+ log(`这张和库里的 ${best} 是同一个(${Math.round(bestScore * 100)}% 像),记成别名`);
506
+ try {
507
+ fs.unlinkSync(stickerPendingFile);
508
+ } catch {
509
+ /* 没有待确认的就无所谓 */
510
+ }
511
+ return best;
512
+ }
513
+ }
422
514
  index[name] = {
423
515
  file: path.relative(stickerDir, finalFile).replace(/\\/g, '/'),
424
516
  desc: desc || '他发过的表情包',
@@ -469,7 +561,42 @@ async function describeImage(img) {
469
561
  return String(out || '').replace(/\s+/g, ' ').trim().slice(0, 80);
470
562
  }
471
563
 
472
- /** 他发来的表情包是不是库里已有的那张:按内容 hash 认,认出来就照库里的意思说 */
564
+ /** 读一眼表情库索引(很小,几十条,随手读) */
565
+ function stickerIndexNow() {
566
+ try {
567
+ return JSON.parse(fs.readFileSync(stickerIndex, 'utf8'));
568
+ } catch {
569
+ return {};
570
+ }
571
+ }
572
+
573
+ /** 按名字找库里那张:直接命中,或者它是某个别名(同一张图换了个文件名) */
574
+ function resolveStickerName(name) {
575
+ const idx = stickerIndexNow();
576
+ if (idx[name]) return name;
577
+ for (const [k, v] of Object.entries(idx)) {
578
+ if (Array.isArray(v?.aliases) && v.aliases.includes(name)) return k;
579
+ }
580
+ return '';
581
+ }
582
+
583
+ /** 两句话描述像不像(中文按二字组比),用来认「同一张图被重新压过」 */
584
+ function descSimilarity(a, b) {
585
+ const grams = (s) => {
586
+ const t = String(s || '').replace(/[^\u4e00-\u9fa5a-z0-9]/gi, '');
587
+ const out = new Set();
588
+ for (let i = 0; i + 2 <= t.length; i++) out.add(t.slice(i, i + 2));
589
+ return out;
590
+ };
591
+ const A = grams(a);
592
+ const B = grams(b);
593
+ if (!A.size || !B.size) return 0;
594
+ let hit = 0;
595
+ for (const g of A) if (B.has(g)) hit++;
596
+ return hit / Math.min(A.size, B.size);
597
+ }
598
+
599
+ /** 他发来的表情包是不是库里已有的那张:先按内容 hash,再按别名,认出来就照库里的意思说 */
473
600
  async function knownSticker(img) {
474
601
  if (img?.subType !== 1) return null;
475
602
  try {
@@ -479,8 +606,10 @@ async function knownSticker(img) {
479
606
  const buf = Buffer.from(b64, 'base64');
480
607
  if (!buf.length || buf.length > 3 * 1024 * 1024) return null;
481
608
  const name = `auto_${crypto.createHash('md5').update(buf).digest('hex').slice(0, 10)}`;
482
- const s = stickers.get(name);
483
- return s ? { name, desc: String(s.desc || '').trim() } : null;
609
+ const real = resolveStickerName(name);
610
+ if (!real) return null;
611
+ const s = stickers.get(real);
612
+ return { name: real, desc: String(s?.desc || stickerIndexNow()[real]?.desc || '').trim(), repeat: !!s };
484
613
  } catch {
485
614
  return null;
486
615
  }
@@ -534,7 +663,9 @@ async function buildUserContent(text, images, quoted = '') {
534
663
  if (known) {
535
664
  parts.push({
536
665
  type: 'text',
537
- text: `(他发来的表情包是库里那张 ${known.name}${known.desc ? `,我记的意思是「${known.desc}」` : ',还没记意思'})`,
666
+ text:
667
+ `(他发来的表情包是库里那张 ${known.name}${known.desc ? `,我记的意思是「${known.desc}」` : ',还没记意思'}。` +
668
+ '认出来就别说成新表情、也别重复解释它的意思——当他自己那张,顺着他这一句接话就行。)',
538
669
  });
539
670
  label.push('[图片]');
540
671
  continue;
@@ -598,11 +729,18 @@ const stickerCounts = new Map();
598
729
  let lastStickerAt = 0;
599
730
  let awayNote = ''; // 这一轮要不要提「你不是说去…了吗」
600
731
  const awayFile = path.join(root, 'away.json');
732
+ const birthdayFile = path.join(root, 'birthday.json');
601
733
 
602
734
  /** 记「他说要去干嘛」;他很快又回来,就准备一句好奇 */
603
735
  function noteAway(text) {
604
736
  const t = String(text || '').trim();
605
737
  if (!t) return;
738
+ if (/(拜拜|我走了|出门了|出发了|我出门|走了啊|先走了)/.test(t)) {
739
+ markLeaving(awayFile, '出门');
740
+ log('他出门了,下次开口问一句到了吗');
741
+ awayNote = '';
742
+ return;
743
+ }
606
744
  const going = t.match(/(?:我)?(?:先|要|得)?(?:去|该)(?:玩|吃|睡|洗澡|出门|上班|上课|写|做|忙|打游戏|运动|跑步|看书|开会)/);
607
745
  if (going) {
608
746
  try {
@@ -781,6 +919,12 @@ async function handleMessage(ev, bot) {
781
919
  userSeq++;
782
920
 
783
921
  const { text, images, replyId } = parsedInput;
922
+ try {
923
+ const got = captureBirthday(birthdayFile, text);
924
+ if (got.saved) log('记住他的生日:' + got.birthday);
925
+ } catch {
926
+ /* 记不上就算了 */
927
+ }
784
928
 
785
929
  // 他在哄我:气能消一级,但要哄够次数(一级一次、三级三次)
786
930
  if (text && looksLikeCoax(text)) {
@@ -1127,7 +1271,7 @@ async function respond(bot, userId, text, images, quoted, replyToId = 0) {
1127
1271
  ? []
1128
1272
  : getHistory(config.memoryPath, config.bucket.entry, config.bucket.pet, config.historyRounds);
1129
1273
  const messages = [
1130
- { role: 'system', content: buildSystem(text) + awayNote + correctionLine(text) + searchNote + recentFacts(config.memoryPath, config.bucket.entry, config.bucket.pet) },
1274
+ { role: 'system', content: buildSystem(text) + awayNote + correctionLine(text) + planHint(seaFile, text) + searchNote + recentFacts(config.memoryPath, config.bucket.entry, config.bucket.pet) },
1131
1275
  ...history,
1132
1276
  { role: 'user', content },
1133
1277
  ];
@@ -1266,6 +1410,8 @@ async function respond(bot, userId, text, images, quoted, replyToId = 0) {
1266
1410
  }
1267
1411
  ok = true;
1268
1412
  sendFails = 0;
1413
+ markArrivalAsked(awayFile);
1414
+ markGreeted(birthdayFile);
1269
1415
  log(
1270
1416
  `回 ${userId}${parts.length > 1 ? `(${parts.length} 条)` : ''}:${display.slice(0, 60)}` +
1271
1417
  (stickerList.length ? `(+表情 ${stickerList.map((s) => s.name).join(',')})` : ''),
@@ -1327,6 +1473,7 @@ async function respond(bot, userId, text, images, quoted, replyToId = 0) {
1327
1473
  void finishSearch(bot, userId);
1328
1474
  }, wait);
1329
1475
  }
1476
+
1330
1477
  }
1331
1478
 
1332
1479
  /** 连上之后先回头看:他有没有在我掉线期间发消息没被回 */
package/src/diary.mjs CHANGED
@@ -17,14 +17,10 @@ function ymd(d = new Date()) {
17
17
  }
18
18
 
19
19
  export function createDiary({ config, apiKey, getSystemPrompt, memoryPath, entry, pet, logger = log }) {
20
- // 日记目录:config.diaryDir 优先(dsh-feini 用这个),其次老的 petMemoryDir/diary
21
- const diaryDir = config.diaryDir
22
- ? path.resolve(config.diaryDir)
23
- : config.petMemoryDir
24
- ? path.join(config.petMemoryDir, 'diary')
25
- : '';
26
- if (!diaryDir) return { start() {}, writeNow: async () => '' };
27
- const stateFile = path.join(path.dirname(diaryDir), 'diary-state.json');
20
+ const dir = config.petMemoryDir || '';
21
+ if (!dir) return { start() {}, writeNow: async () => '' };
22
+ const diaryDir = path.join(dir, 'diary');
23
+ const stateFile = path.join(dir, 'diary-state.json');
28
24
  const minMessages = Number(config.diary?.minMessages ?? 8);
29
25
 
30
26
  function readState() {
@@ -0,0 +1,79 @@
1
+ /**
2
+ * 「你猜我在干啥」:先翻他昨天说过的行程,翻不到再按习惯猜。
3
+ *
4
+ * 为什么单独一个模块:这段逻辑要能单独测(拿真的 me.json 喂它),
5
+ * 塞在 bridge.mjs 里一导入就把桥跑起来了,没法测。
6
+ *
7
+ * 检索顺序按他定的来:
8
+ * 1. 最近几天他说过的安排(明天/今天/约/说好 + 去/吃/见…),优先他自己说的;
9
+ * 2. 没翻到就让她按习惯猜(作息、最近在玩什么、爱吃什么)。
10
+ */
11
+ import fs from 'node:fs';
12
+
13
+ const PLAN_RE = /(明天|今天|后天|约好|说好|打算)/;
14
+ const VERB_RE = /(去|吃|见|回|上班|上课|开会|出门)/;
15
+ const NOT_PLAN_RE = /(猜猜|你猜|猜一下|猜我|哼|再说一遍|还记得|记得我|有没有给你说|给你说过)/;
16
+
17
+ /** 像不像「哪天要去干嘛」:短句 + 时间词 + 动作词,别把一整段聊天当成行程 */
18
+ function looksLikePlan(t) {
19
+ if (!t || t.length < 4 || t.length > 70) return false;
20
+ if (!PLAN_RE.test(t) || !VERB_RE.test(t)) return false;
21
+ if (NOT_PLAN_RE.test(t)) return false;
22
+ // 「今天」「明天」后面紧跟着动作,才像安排
23
+ return /(明天|今天|后天|约好|说好|打算)[^。!?;;]{0,18}(去|吃|见|回|上班|上课|开会|出门)/.test(t);
24
+ }
25
+
26
+ /** 最近几天里「像安排」的话,他自己说的排前面,然后按新到旧 */
27
+ export function recentPlans(seaFile, { days = 5, limit = 4 } = {}) {
28
+ let data = {};
29
+ try {
30
+ data = JSON.parse(fs.readFileSync(seaFile, 'utf8'));
31
+ } catch {
32
+ return [];
33
+ }
34
+ const since = Date.now() - days * 86400000;
35
+ const list = [];
36
+ for (const m of data.messages || []) {
37
+ const at = Number(m.at) || 0;
38
+ const t = String(m.text || '');
39
+ if (at < since || !looksLikePlan(t)) continue;
40
+ list.push({ text: t, at, isOwner: !!m.isOwner });
41
+ }
42
+ for (const lt of data.longTerm || []) {
43
+ const t = String(lt.text || '');
44
+ const at = Number(lt.lastMentionAt || lt.createdAt) || 0;
45
+ if (at < since || !looksLikePlan(t)) continue;
46
+ list.push({ text: t, at, isOwner: true });
47
+ }
48
+ const seen = new Set();
49
+ return list
50
+ .sort((a, b) => (b.isOwner ? 1 : 0) - (a.isOwner ? 1 : 0) || b.at - a.at)
51
+ .filter((x) => {
52
+ const k = x.text.slice(0, 24);
53
+ if (seen.has(k)) return false;
54
+ seen.add(k);
55
+ return true;
56
+ })
57
+ .slice(0, limit);
58
+ }
59
+
60
+ /** 拼给 system 的一段;他不是在让猜就返回空 */
61
+ export function planHint(seaFile, text) {
62
+ const t = String(text || '');
63
+ if (!/(猜猜|你猜|猜一下|猜我|猜猜看)/.test(t)) return '';
64
+ const plans = recentPlans(seaFile);
65
+ if (plans.length) {
66
+ const rows = plans
67
+ .map((p) => `${p.at ? new Date(p.at).toLocaleDateString('zh-CN') + ' ' : ''}${p.text.slice(0, 80)}`)
68
+ .join(';');
69
+ return (
70
+ `\n\n【他在让你猜·先看行程】最近他说过的安排:${rows}。` +
71
+ '先按这些安排加上现在的时间点来猜(昨天说今天要去哪、几点做什么),' +
72
+ '对不上再用他的习惯猜(作息、最近在玩什么、爱吃什么)。用一两句说出来,别列清单。'
73
+ );
74
+ }
75
+ return (
76
+ '\n\n【他在让你猜·没查到行程】海里没捞到他最近几天的明确安排,那就按他的习惯猜:' +
77
+ '这个点平时在干嘛、最近在玩什么、爱吃什么。猜一两个就好;他不认就让他给个提示,别硬猜。'
78
+ );
79
+ }
package/src/proactive.mjs CHANGED
@@ -15,6 +15,8 @@ import { fileURLToPath } from 'node:url';
15
15
  import { chat } from './llm.mjs';
16
16
  import { toDisplay, splitParts } from './clean.mjs';
17
17
  import { log } from './util.mjs';
18
+ import { markArrivalAsked } from './arrival.mjs';
19
+ import { markGreeted } from './birthday.mjs';
18
20
 
19
21
  const here = path.dirname(fileURLToPath(import.meta.url));
20
22
  const root = process.env.DSH_FEINI_HOME ? path.resolve(process.env.DSH_FEINI_HOME) : path.resolve(here, '..');
@@ -143,6 +145,8 @@ export function startProactive({ config, getSystemPrompt, apiKey, bot, history,
143
145
  if (i < parts.length - 1) await new Promise((r) => setTimeout(r, 700 + Math.random() * 700));
144
146
  }
145
147
  const display = parts.join(' / ');
148
+ markArrivalAsked(path.join(root, 'away.json'));
149
+ markGreeted(path.join(root, 'birthday.json'));
146
150
  await appendRound(syntheticNote, reply);
147
151
  return display;
148
152
  }
@@ -161,7 +165,29 @@ export function startProactive({ config, getSystemPrompt, apiKey, bot, history,
161
165
 
162
166
  // 他说了「晚安」:就当他睡了,7 小时内不主动、也不闹脾气。
163
167
  // 2026-10-31 02:33 那次就是因为没有这条——他 01:5x 说晚安,之后还被追着喊了三回。
164
- if (state.goodnightAt && now - state.goodnightAt < quietMs) return;
168
+ if (state.goodnightAt) {
169
+ const hNow = new Date(now).getHours();
170
+ const today = new Date(now).toISOString().slice(0, 10);
171
+ const morningFrom = Number(p.morningFromHour ?? 7);
172
+ const morningTo = Number(p.morningToHour ?? 12);
173
+ const greetedToday = state.morningGreetedFor === today;
174
+ // 到早上了:先补一句早上好,然后静默结束,他再不理才开始攒小脾气
175
+ if (hNow >= morningFrom && hNow < morningTo && !greetedToday) {
176
+ const said = await say(
177
+ '天亮了。跟他说一句早上好,用你自己的口吻;他昨晚睡得晚就顺口念一句「这么晚才睡」。一两句就够。',
178
+ '(天亮了,我来说早上好)',
179
+ );
180
+ state.morningGreetedFor = today;
181
+ state.goodnightAt = 0;
182
+ state.pendingSince = now; // 从这里开始算:他多久没回
183
+ state.sulkCount = 0;
184
+ saveState(stateFile, state);
185
+ log(`早上好已发:${String(said).slice(0, 30)}`);
186
+ return;
187
+ }
188
+ // 还没到早上:继续静默(夜里他再说话会把这个时间往后顺延)
189
+ if (now - state.goodnightAt < quietMs) return;
190
+ }
165
191
 
166
192
  // 不在活跃时段(默认 07:00–02:00)就别开口
167
193
  if (!inWindow(window.from, window.to)) return;
@@ -215,19 +241,22 @@ export function startProactive({ config, getSystemPrompt, apiKey, bot, history,
215
241
  state.nextAt = Date.now() + randBetween(gapMin, gapMax) * 60000;
216
242
 
217
243
  // 他说「晚安 / 去睡了」:记下来,接下来 7 小时不主动也不闹。
218
- // 醒来说第一句就清掉(下面 else 分支),不用他专门说「我醒了」。
219
244
  if (/(晚安|我睡了|先去睡|去睡了|睡觉去了|睡啦|睡咯|晚安啦)/.test(t)) {
220
245
  state.goodnightAt = Date.now();
246
+ state.morningGreetedFor = '';
221
247
  state.sulking = false;
222
248
  state.sulkCount = 0;
223
249
  state.coaxNeed = 0;
224
250
  state.coaxCount = 0;
225
251
  writeSulk(sulkFile, null);
226
252
  log('他道晚安了,接下来不主动找他');
227
- } else if (state.goodnightAt && Date.now() - state.goodnightAt > 30 * 60000) {
228
- // 隔了半小时以上又开口,说明醒了:解除静默
229
- state.goodnightAt = 0;
230
- log('他醒了,恢复主动');
253
+ } else if (state.goodnightAt && !state.morningGreetedFor) {
254
+ // 夜里他又冒头(睡不着、刷手机):静默重新起算七小时,不是解除
255
+ const quietMs = Number(p.quietAfterChatHours ?? 7) * 60 * 60 * 1000;
256
+ if (Date.now() - state.goodnightAt < quietMs) {
257
+ state.goodnightAt = Date.now();
258
+ log('夜里他又说话了,静默重新算七小时');
259
+ }
231
260
  }
232
261
 
233
262
  if (state.sulking) {
package/src/prompt.mjs CHANGED
@@ -1,23 +1,11 @@
1
- /**
2
- * 读人设文件。
3
- *
4
- * 两种格式都认:
5
- * - 纯文本(.md / .txt):整份当 system prompt 用,最省事;
6
- * - DSH 桌宠的 main-config.jsonc:取 pets[0].whisperPrompt(想把桌宠那套搬过来时用)。
7
- */
1
+ /** 读 DSH 拼好的人设(whisperPrompt)——和桌面端用的是同一份 */
8
2
  import fs from 'node:fs';
9
3
 
10
4
  export function readSystemPrompt(configPath) {
11
- const raw = fs.readFileSync(configPath, 'utf8').replace(/^\uFEFF/, '');
12
- if (/\.(md|txt)$/i.test(configPath)) {
13
- const text = raw.trim();
14
- if (!text) throw new Error(`人设文件是空的:${configPath}`);
15
- return text;
16
- }
17
- const data = JSON.parse(raw);
18
- const prompt = data?.pets?.[0]?.whisperPrompt;
5
+ const raw = JSON.parse(fs.readFileSync(configPath, 'utf8'));
6
+ const prompt = raw?.pets?.[0]?.whisperPrompt;
19
7
  if (typeof prompt !== 'string' || !prompt.trim()) {
20
- throw new Error(`读不到人设:${configPath}`);
8
+ throw new Error(`读不到 whisperPrompt:${configPath}`);
21
9
  }
22
10
  return prompt;
23
11
  }
package/src/sea.mjs CHANGED
@@ -63,42 +63,6 @@ const STOP = new Set([
63
63
  '一个', '一下', '没有', '知道', '觉得', '还是', '真的', '然后', '所以', '因为', '但是',
64
64
  ]);
65
65
 
66
- /**
67
- * 激活度:一条旧账被捞出来用一次,就亮一点;半年不碰就慢慢暗下去。
68
- * 半衰期 14 天,权重最多加到 2 倍左右——只影响排序,不删记忆。
69
- */
70
- const ACT_HALF_LIFE_DAYS = 14;
71
- const actCache = { file: '', mtime: 0, map: new Map() };
72
-
73
- function activationOf(seaFile) {
74
- const file = path.join(path.dirname(seaFile), 'activation.json');
75
- let st;
76
- try {
77
- st = fs.statSync(file);
78
- } catch {
79
- return new Map();
80
- }
81
- if (actCache.file === file && actCache.mtime === st.mtimeMs) return actCache.map;
82
- const map = new Map();
83
- try {
84
- const data = JSON.parse(fs.readFileSync(file, 'utf8'));
85
- for (const [k, v] of Object.entries(data)) map.set(k, { hits: Number(v?.hits) || 0, last: Number(v?.last) || 0 });
86
- } catch {
87
- /* 读不了就当没有 */
88
- }
89
- Object.assign(actCache, { file, mtime: st.mtimeMs, map });
90
- return map;
91
- }
92
-
93
- function activationWeight(seaFile, text, now = Date.now()) {
94
- const rec = activationOf(seaFile).get(actKey(text));
95
- if (!rec || !rec.hits) return 1;
96
- const ageDays = rec.last ? (now - rec.last) / 86400000 : 999;
97
- const decay = Math.pow(0.5, ageDays / ACT_HALF_LIFE_DAYS);
98
- // 最多 3 倍:常用归常用,别把真正相关的那条挤出去
99
- return 1 + Math.min(2, 0.25 * Math.min(3, Math.log2(1 + rec.hits)) * decay);
100
- }
101
-
102
66
  const cache = { file: '', mtime: 0, entries: [], df: new Map(), total: 0 };
103
67
  // 缓存按文件 mtime 失效:sync 每两分钟重建数据,这里自然跟着更新
104
68
 
@@ -172,7 +136,6 @@ export function searchSea(file, query, limit = 5) {
172
136
  const ageDays = e.at ? (now - e.at) / 86400000 : 999;
173
137
  score += Math.max(0, 0.4 - ageDays / 100); // 近的稍微占点便宜
174
138
  if (e.kind === 'fact' || e.kind === 'preference') score += 0.15; // 事实比闲聊更值钱
175
- score *= activationWeight(file, e.text, now); // 常被用到的旧账亮一点,久不碰的淡一点
176
139
  scored.push({ ...e, score, tokens: undefined });
177
140
  }
178
141
  scored.sort((a, b) => b.score - a.score);
package/src/stickers.mjs CHANGED
@@ -37,6 +37,8 @@ export function stickerPrompt(stickers) {
37
37
  '想发的时候,在回复最后另起一行写:〔表情:名字〕,一次最多两张(他让你「对照着认」、要放一起比的时候才写两张,平时一张就够),只能用这些:' +
38
38
  list +
39
39
  '。\n别每句都发,也别连着几次发同一张;情绪对不上就别硬配,硬配反而怪。\n' +
40
+ '**发之前先过一遍:这张的意思,跟此刻他这句话对得上吗?** 对不上就别发——用错一张,比不发更显傻;' +
41
+ '拿不准的时候宁可不发,或者只在最后一句安抚、撒娇那种顺情绪的地方配一张。\n' +
40
42
  '**别先说「发这两张给你看」结果只写一个〔表情:〕**:说了几张就得写几个,写不了那么多就换个说法。'
41
43
  );
42
44
  }