dsh-feini 0.1.6 → 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 +5 -0
- package/package.json +1 -1
- package/src/anger.mjs +1 -1
- package/src/arrival.mjs +43 -0
- package/src/birthday.mjs +100 -0
- package/src/bridge.mjs +156 -10
- package/src/diary.mjs +4 -8
- package/src/proactive.mjs +35 -6
- package/src/prompt.mjs +4 -16
- package/src/sea.mjs +0 -37
- package/src/stickers.mjs +2 -0
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
package/src/anger.mjs
CHANGED
package/src/arrival.mjs
ADDED
|
@@ -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
|
+
}
|
package/src/birthday.mjs
ADDED
|
@@ -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
|
@@ -38,12 +38,15 @@ import { takeInterest, addInterest, removeInterest, interestsLine, INTEREST_RULE
|
|
|
38
38
|
import { startInterestReview } from './review.mjs';
|
|
39
39
|
import { seaLine } from './sea.mjs';
|
|
40
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';
|
|
41
43
|
import { takeGuess, addGuess, resolveGuess, guessesLine, GUESS_RULE } from './guess.mjs';
|
|
42
44
|
import { ANGER_RULE, angerLine, offend, coaxed, looksLikeCoax } from './anger.mjs';
|
|
43
45
|
import { getBalance, BALANCE_RULE } from './balance.mjs';
|
|
44
46
|
import { addUsage, usageSummary, USAGE_RULE } from './usage.mjs';
|
|
45
47
|
import { createDiary } from './diary.mjs';
|
|
46
48
|
import { onUsage } from './llm.mjs';
|
|
49
|
+
import { buildSea } from './build-sea.mjs';
|
|
47
50
|
|
|
48
51
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
49
52
|
const root = process.env.DSH_FEINI_HOME ? path.resolve(process.env.DSH_FEINI_HOME) : path.resolve(here, '..');
|
|
@@ -139,6 +142,17 @@ const POUT_RULE =
|
|
|
139
142
|
'他说「迟到了」「我还是迟到了」又没指明是哪件事,默认就是迟回来找我聊天——别猜成别的约会,' +
|
|
140
143
|
'也别回「原来是迟到这件事呀」这种没接住的话,直接顺着「迟了来找我」接(「原来是迟了来找我呀,那这笔账我可得算算」)。';
|
|
141
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
|
+
|
|
142
156
|
const QQ_NOTE_REMINDER =
|
|
143
157
|
'\n\n【这是 QQ,不是桌面】上面那条记笔记的规矩在这里同样生效:' +
|
|
144
158
|
'他这句话里如果有关于他自己、值得长期留下的事,就在回复最后另起一行写〔记〕一句话。' +
|
|
@@ -155,13 +169,17 @@ const angerFile = path.join(root, 'anger.json');
|
|
|
155
169
|
const petMemoryDir = config.petMemoryDir || '';
|
|
156
170
|
const interestFile = petMemoryDir ? path.join(petMemoryDir, 'interests.md') : path.join(root, 'interests.md');
|
|
157
171
|
// 记忆海的数据:pet-memory 的上一级(工作区)里的 memory-sea/data/me.json
|
|
158
|
-
const seaFile =
|
|
159
|
-
? path.
|
|
160
|
-
:
|
|
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');
|
|
161
177
|
// 笔记本:每轮现读,改完立刻生效(人设里不再抄它)
|
|
162
|
-
const notebookFile =
|
|
163
|
-
? path.
|
|
164
|
-
:
|
|
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');
|
|
165
183
|
|
|
166
184
|
let notebookCache = { mtime: 0, text: '' };
|
|
167
185
|
function notebookText() {
|
|
@@ -178,6 +196,25 @@ function notebookText() {
|
|
|
178
196
|
// 人设里「别硬报时」是给桌面碎碎念写的;QQ 里他问时间必须直接答,所以把这句话改软
|
|
179
197
|
const personaForQQ = baseSystemPrompt.replace(/别硬报时/g, '碎碎念时别反复提时间');
|
|
180
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
|
+
|
|
181
218
|
// 每次说话都现读人设:sync 改完下一句就生效,不用重启桥
|
|
182
219
|
function freshPersona() {
|
|
183
220
|
if (!config.systemPromptPath) return personaForQQ;
|
|
@@ -198,6 +235,9 @@ const baseSystemNow = () =>
|
|
|
198
235
|
FORMAT_RULE +
|
|
199
236
|
CORRECTION_RULE +
|
|
200
237
|
RECENT_RULE +
|
|
238
|
+
MEAL_RULE +
|
|
239
|
+
BIRTHDAY_RULE +
|
|
240
|
+
SELF_RULE +
|
|
201
241
|
POUT_RULE +
|
|
202
242
|
SEARCH_RULE +
|
|
203
243
|
QQ_NOTE_REMINDER +
|
|
@@ -234,6 +274,25 @@ function sulkLine() {
|
|
|
234
274
|
return '';
|
|
235
275
|
}
|
|
236
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
|
+
|
|
237
296
|
/** 每次说话前现拼 system:心情、他的状态、今天的日期都会变 */
|
|
238
297
|
function buildSystem(query = '') {
|
|
239
298
|
let recall = '';
|
|
@@ -263,8 +322,12 @@ function buildSystem(query = '') {
|
|
|
263
322
|
baseSystemNow() +
|
|
264
323
|
(notebookText() ? '\n\n【我的笔记本·每轮现读】\n' + notebookText() : '') +
|
|
265
324
|
nowLine() +
|
|
325
|
+
askHint(birthdayFile, query) +
|
|
326
|
+
todayHint(birthdayFile) +
|
|
327
|
+
todayMealsLine() +
|
|
266
328
|
festivalLine() +
|
|
267
329
|
upcomingLine(reminderFile) +
|
|
330
|
+
arrivalNote(awayFile) +
|
|
268
331
|
moodLine(moodFile) +
|
|
269
332
|
himLine(himFile) +
|
|
270
333
|
proactiveLine() +
|
|
@@ -420,6 +483,34 @@ async function saveSticker(img) {
|
|
|
420
483
|
} catch (e) {
|
|
421
484
|
log('表情包描述没写成:', e.message);
|
|
422
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
|
+
}
|
|
423
514
|
index[name] = {
|
|
424
515
|
file: path.relative(stickerDir, finalFile).replace(/\\/g, '/'),
|
|
425
516
|
desc: desc || '他发过的表情包',
|
|
@@ -470,7 +561,42 @@ async function describeImage(img) {
|
|
|
470
561
|
return String(out || '').replace(/\s+/g, ' ').trim().slice(0, 80);
|
|
471
562
|
}
|
|
472
563
|
|
|
473
|
-
/**
|
|
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,再按别名,认出来就照库里的意思说 */
|
|
474
600
|
async function knownSticker(img) {
|
|
475
601
|
if (img?.subType !== 1) return null;
|
|
476
602
|
try {
|
|
@@ -480,8 +606,10 @@ async function knownSticker(img) {
|
|
|
480
606
|
const buf = Buffer.from(b64, 'base64');
|
|
481
607
|
if (!buf.length || buf.length > 3 * 1024 * 1024) return null;
|
|
482
608
|
const name = `auto_${crypto.createHash('md5').update(buf).digest('hex').slice(0, 10)}`;
|
|
483
|
-
const
|
|
484
|
-
|
|
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 };
|
|
485
613
|
} catch {
|
|
486
614
|
return null;
|
|
487
615
|
}
|
|
@@ -535,7 +663,9 @@ async function buildUserContent(text, images, quoted = '') {
|
|
|
535
663
|
if (known) {
|
|
536
664
|
parts.push({
|
|
537
665
|
type: 'text',
|
|
538
|
-
text:
|
|
666
|
+
text:
|
|
667
|
+
`(他发来的表情包是库里那张 ${known.name}${known.desc ? `,我记的意思是「${known.desc}」` : ',还没记意思'}。` +
|
|
668
|
+
'认出来就别说成新表情、也别重复解释它的意思——当他自己那张,顺着他这一句接话就行。)',
|
|
539
669
|
});
|
|
540
670
|
label.push('[图片]');
|
|
541
671
|
continue;
|
|
@@ -599,11 +729,18 @@ const stickerCounts = new Map();
|
|
|
599
729
|
let lastStickerAt = 0;
|
|
600
730
|
let awayNote = ''; // 这一轮要不要提「你不是说去…了吗」
|
|
601
731
|
const awayFile = path.join(root, 'away.json');
|
|
732
|
+
const birthdayFile = path.join(root, 'birthday.json');
|
|
602
733
|
|
|
603
734
|
/** 记「他说要去干嘛」;他很快又回来,就准备一句好奇 */
|
|
604
735
|
function noteAway(text) {
|
|
605
736
|
const t = String(text || '').trim();
|
|
606
737
|
if (!t) return;
|
|
738
|
+
if (/(拜拜|我走了|出门了|出发了|我出门|走了啊|先走了)/.test(t)) {
|
|
739
|
+
markLeaving(awayFile, '出门');
|
|
740
|
+
log('他出门了,下次开口问一句到了吗');
|
|
741
|
+
awayNote = '';
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
607
744
|
const going = t.match(/(?:我)?(?:先|要|得)?(?:去|该)(?:玩|吃|睡|洗澡|出门|上班|上课|写|做|忙|打游戏|运动|跑步|看书|开会)/);
|
|
608
745
|
if (going) {
|
|
609
746
|
try {
|
|
@@ -782,6 +919,12 @@ async function handleMessage(ev, bot) {
|
|
|
782
919
|
userSeq++;
|
|
783
920
|
|
|
784
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
|
+
}
|
|
785
928
|
|
|
786
929
|
// 他在哄我:气能消一级,但要哄够次数(一级一次、三级三次)
|
|
787
930
|
if (text && looksLikeCoax(text)) {
|
|
@@ -1267,6 +1410,8 @@ async function respond(bot, userId, text, images, quoted, replyToId = 0) {
|
|
|
1267
1410
|
}
|
|
1268
1411
|
ok = true;
|
|
1269
1412
|
sendFails = 0;
|
|
1413
|
+
markArrivalAsked(awayFile);
|
|
1414
|
+
markGreeted(birthdayFile);
|
|
1270
1415
|
log(
|
|
1271
1416
|
`回 ${userId}${parts.length > 1 ? `(${parts.length} 条)` : ''}:${display.slice(0, 60)}` +
|
|
1272
1417
|
(stickerList.length ? `(+表情 ${stickerList.map((s) => s.name).join(',')})` : ''),
|
|
@@ -1328,6 +1473,7 @@ async function respond(bot, userId, text, images, quoted, replyToId = 0) {
|
|
|
1328
1473
|
void finishSearch(bot, userId);
|
|
1329
1474
|
}, wait);
|
|
1330
1475
|
}
|
|
1476
|
+
|
|
1331
1477
|
}
|
|
1332
1478
|
|
|
1333
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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() {
|
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
|
|
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 &&
|
|
228
|
-
//
|
|
229
|
-
|
|
230
|
-
|
|
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')
|
|
12
|
-
|
|
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(
|
|
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
|
}
|