dsh-fish-soul 0.13.2 → 0.13.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 +1 -1
- package/lib/backup.js +3 -8
- package/lib/behavior.js +2 -2
- package/lib/browse.js +26 -2
- package/lib/browsers.js +2 -2
- package/lib/channels/bilibili.js +2 -1
- package/lib/channels/douyin.js +1 -1
- package/lib/channels/onebot.js +1 -1
- package/lib/db.js +2 -2
- package/lib/douyin-social.js +1 -1
- package/lib/gateway.js +20 -3
- package/lib/growth.js +1 -1
- package/lib/history-import.js +4 -10
- package/lib/imageconv.js +1 -5
- package/lib/importer.js +2 -1
- package/lib/index.js +27 -25
- package/lib/life.js +2 -1
- package/lib/maibot-extras.js +4 -7
- package/lib/persona.js +2 -2
- package/lib/retrieval.js +2 -2
- package/lib/routes-soul.js +2 -2
- package/lib/routes-v07.js +1 -4
- package/lib/routes.js +29 -17
- package/lib/slang.js +1 -1
- package/lib/social.js +12 -3
- package/lib/soul-memory.js +8 -4
- package/lib/time-util.js +19 -0
- package/lib/voice.js +1 -1
- package/lib/worldinfo.js +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
package/lib/backup.js
CHANGED
|
@@ -11,6 +11,7 @@ import { DatabaseSync } from 'node:sqlite';
|
|
|
11
11
|
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
12
12
|
import { join } from 'node:path';
|
|
13
13
|
import { fishSoulDir, getKv, setKv } from './db.js';
|
|
14
|
+
import { stampLocal as stamp } from './time-util.js';
|
|
14
15
|
|
|
15
16
|
const DAY_MS = 24 * 3600_000;
|
|
16
17
|
const CHECK_MS = 3600_000;
|
|
@@ -32,7 +33,7 @@ export function pendingRestore() {
|
|
|
32
33
|
* 带着 -wal 的库文件(恢复前另存时库打不开、只能原样拷文件 + WAL):把 WAL 合并回主文件、改回单文件。
|
|
33
34
|
* 合并不了(库坏了)就原样留着,恢复时连 WAL 一起拷。返回合并后还有没有 -wal。
|
|
34
35
|
*/
|
|
35
|
-
|
|
36
|
+
function consolidateWal(path) {
|
|
36
37
|
if (!existsSync(`${path}-wal`)) return false;
|
|
37
38
|
try {
|
|
38
39
|
const db = new DatabaseSync(path);
|
|
@@ -47,7 +48,7 @@ export function consolidateWal(path) {
|
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
/** 备份文件能不能用:打得开、quick_check 通过、有记忆 / 人物 / kv 表。返回 { memories, persons }。 */
|
|
50
|
-
|
|
51
|
+
function inspectBackup(path) {
|
|
51
52
|
consolidateWal(path);
|
|
52
53
|
const db = new DatabaseSync(path, { readOnly: true });
|
|
53
54
|
try {
|
|
@@ -130,12 +131,6 @@ export function backupDir() {
|
|
|
130
131
|
return join(fishSoulDir(), 'backups');
|
|
131
132
|
}
|
|
132
133
|
|
|
133
|
-
function stamp(ms) {
|
|
134
|
-
const d = new Date(ms);
|
|
135
|
-
const p = (n) => String(n).padStart(2, '0');
|
|
136
|
-
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
134
|
export class BackupService {
|
|
140
135
|
/**
|
|
141
136
|
* @param {object} deps
|
package/lib/behavior.js
CHANGED
|
@@ -307,7 +307,7 @@ export function forwardLines(data, { maxLines = 15 } = {}) {
|
|
|
307
307
|
|
|
308
308
|
const NOTES_KEY = 'group.notes';
|
|
309
309
|
export function groupNotes(db) { return getKv(db, NOTES_KEY, {}) ?? {}; }
|
|
310
|
-
|
|
310
|
+
function groupNote(db, groupId) { return String(groupNotes(db)[String(groupId)]?.text ?? ''); }
|
|
311
311
|
export function setGroupNote(db, groupId, text, now = Date.now()) {
|
|
312
312
|
const all = groupNotes(db);
|
|
313
313
|
const t = String(text ?? '').trim().slice(0, 500);
|
|
@@ -398,7 +398,7 @@ function repairJson(src) {
|
|
|
398
398
|
}
|
|
399
399
|
|
|
400
400
|
/** 从模型回复里抠出 JSON:去掉思考段、代码块,从后往前试,坏了先修再试。 */
|
|
401
|
-
|
|
401
|
+
function parseJson(text) {
|
|
402
402
|
const raw = String(text ?? '').replace(/<think>[\s\S]*?<\/think>/gi, '').replace(/^[\s\S]*<\/think>/i, '');
|
|
403
403
|
const fence = raw.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
404
404
|
const cands = [];
|
package/lib/browse.js
CHANGED
|
@@ -175,14 +175,32 @@ export class BiliBrowser {
|
|
|
175
175
|
} catch { return []; }
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
+
/** 最近搜过的词(最新的在前,最多 5 个),给提词当"别重复"的参考。 */
|
|
179
|
+
recentKeywords() {
|
|
180
|
+
try {
|
|
181
|
+
const raw = JSON.parse(getKv(this.db, 'bili.search.recent', '[]'));
|
|
182
|
+
return Array.isArray(raw) ? raw.map((x) => String(x ?? '')).filter((x) => x !== '').slice(0, 5) : [];
|
|
183
|
+
} catch { return []; }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** 记下这次搜的词,重复的挪到最前。 */
|
|
187
|
+
noteKeyword(word) {
|
|
188
|
+
const w = String(word ?? '').trim();
|
|
189
|
+
if (w === '') return;
|
|
190
|
+
const list = [w, ...this.recentKeywords().filter((x) => x !== w)].slice(0, 5);
|
|
191
|
+
try { setKv(this.db, 'bili.search.recent', JSON.stringify(list)); } catch { /* 记不住不影响搜索 */ }
|
|
192
|
+
}
|
|
193
|
+
|
|
178
194
|
/** 没填词表时,让她自己按最近看过的提一个搜索词(一次小模型调用,只输出几个字)。 */
|
|
179
195
|
async draftSearchKeyword() {
|
|
180
196
|
const titles = this.recentTitles(5);
|
|
181
197
|
if (titles.length === 0) return '';
|
|
182
198
|
const p = this.persona?.() ?? { name: '鱼' };
|
|
199
|
+
const recent = this.recentKeywords();
|
|
183
200
|
const prompt = [
|
|
184
201
|
`你是「${p.name}」。你最近刷到这些视频:`,
|
|
185
202
|
...titles.map((t) => `- ${t}`),
|
|
203
|
+
...(recent.length > 0 ? ['', `你最近已经搜过:${recent.join('、')}。这次换个方向,别重复。`] : []),
|
|
186
204
|
'',
|
|
187
205
|
'你现在想在 B站 搜一个新词,找点新鲜的。只输出一个 2-8 字的搜索词,不要解释、不要引号、不要标点。'
|
|
188
206
|
].join('\n');
|
|
@@ -200,8 +218,14 @@ export class BiliBrowser {
|
|
|
200
218
|
if (this.searchedToday() >= Number(c.searchPerDay ?? 2)) return '';
|
|
201
219
|
const words = (Array.isArray(c.searchKeywords) ? c.searchKeywords : [])
|
|
202
220
|
.map((w) => String(w ?? '').trim()).filter((w) => w !== '');
|
|
203
|
-
if (words.length > 0)
|
|
204
|
-
|
|
221
|
+
if (words.length > 0) {
|
|
222
|
+
const picked = words[Math.floor(Math.random() * words.length)];
|
|
223
|
+
this.noteKeyword(picked);
|
|
224
|
+
return picked;
|
|
225
|
+
}
|
|
226
|
+
const drafted = await this.draftSearchKeyword();
|
|
227
|
+
if (drafted !== '') this.noteKeyword(drafted);
|
|
228
|
+
return drafted;
|
|
205
229
|
}
|
|
206
230
|
|
|
207
231
|
promptFor(v, like, share = false, favorite = false) {
|
package/lib/browsers.js
CHANGED
|
@@ -107,7 +107,7 @@ function regAppPath(exe, hive) {
|
|
|
107
107
|
}
|
|
108
108
|
|
|
109
109
|
/** 按路径猜 kind(注册表 / 手动指定的路径用)。 */
|
|
110
|
-
|
|
110
|
+
function kindOfPath(p) {
|
|
111
111
|
const s = String(p).replace(/\\/g, '/').toLowerCase();
|
|
112
112
|
if (s.includes('edge sxs') || s.includes('edge canary')) return 'edge-canary';
|
|
113
113
|
if (s.includes('edge beta')) return 'edge-beta';
|
|
@@ -165,7 +165,7 @@ function realpath(p) {
|
|
|
165
165
|
}
|
|
166
166
|
|
|
167
167
|
/** 手动指定路径的浏览器:profile 目录用路径哈希区分。 */
|
|
168
|
-
|
|
168
|
+
function customKind(path) {
|
|
169
169
|
return `custom-${createHash('sha1').update(String(path)).digest('hex').slice(0, 8)}`;
|
|
170
170
|
}
|
|
171
171
|
|
package/lib/channels/bilibili.js
CHANGED
|
@@ -50,7 +50,8 @@ export const BILI_HOSTS = {
|
|
|
50
50
|
};
|
|
51
51
|
|
|
52
52
|
/** refresh_{毫秒时间戳} 用公钥做 RSA-OAEP(SHA-256)→ 小写十六进制,即 correspondPath。 */
|
|
53
|
-
export function correspondPath(ts = Date.now()
|
|
53
|
+
export function correspondPath(ts = Date.now()) {
|
|
54
|
+
const pem = process.env.FISH_SOUL_BILI_PUBKEY || REFRESH_PUBKEY; // 测试会用假公钥换掉它
|
|
54
55
|
return publicEncrypt({ key: pem, padding: cryptoConstants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' }, Buffer.from(`refresh_${ts}`)).toString('hex');
|
|
55
56
|
}
|
|
56
57
|
|
package/lib/channels/douyin.js
CHANGED
package/lib/channels/onebot.js
CHANGED
|
@@ -23,7 +23,7 @@ export function sanitizeName(name, fallback = '某人') {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
/** CQ 码字符串 → 消息段数组(只在 messageFormat=string 时用到)。 */
|
|
26
|
-
|
|
26
|
+
function parseCqString(raw) {
|
|
27
27
|
const out = [];
|
|
28
28
|
const unescape = (s) => s.replace(/[/g, '[').replace(/]/g, ']').replace(/,/g, ',').replace(/&/g, '&');
|
|
29
29
|
const re = /\[CQ:([a-zA-Z_]+)((?:,[^\]]*)?)\]/g;
|
package/lib/db.js
CHANGED
|
@@ -395,8 +395,8 @@ export function listChatSessions(db, channel) {
|
|
|
395
395
|
`).all(channel);
|
|
396
396
|
}
|
|
397
397
|
|
|
398
|
-
/** 8 位随机 canonical_id(p_xxxxxxxx
|
|
399
|
-
function randomCanonicalId() {
|
|
398
|
+
/** 8 位随机 canonical_id(p_xxxxxxxx,十六进制;importer 造新 person 也用同一套)。 */
|
|
399
|
+
export function randomCanonicalId() {
|
|
400
400
|
return `p_${Math.floor(Math.random() * 0xffffffff).toString(16).padStart(8, '0')}`;
|
|
401
401
|
}
|
|
402
402
|
|
package/lib/douyin-social.js
CHANGED
|
@@ -226,7 +226,7 @@ async function closeMusicPanel(br, result) {
|
|
|
226
226
|
}
|
|
227
227
|
|
|
228
228
|
/** 真正的页面驱动(测试里换成假的)。全部都得在 host.exclusive 里调。 */
|
|
229
|
-
|
|
229
|
+
function douyinSocialDriver(host) {
|
|
230
230
|
const b = () => {
|
|
231
231
|
if (!host.running) throw new Error('抖音浏览器没在运行');
|
|
232
232
|
return host.browser;
|
package/lib/gateway.js
CHANGED
|
@@ -84,8 +84,9 @@ export class ChatGateway {
|
|
|
84
84
|
* @param {import('./douyin-browse.js').DouyinBrowser|null} [deps.douyinBrowser]
|
|
85
85
|
* @param {((bytes: Buffer) => Promise<Buffer|null>)|null} [deps.canvasConvert] 没有 sharp 时转 webp
|
|
86
86
|
* @param {object} deps.soul index.js 提供的人格/记忆回调
|
|
87
|
+
* @param {(agentCtx: object) => void} [deps.agentSections] 给单个 agent 挂人格卡 / 瞬态块(index.js 传入 registerAgentSections)
|
|
87
88
|
*/
|
|
88
|
-
constructor({ ctx, db, clock, getConfig, log, channel, bili = null, browser = null, douyin = null, douyinBrowser = null, canvasConvert = null, soul, voice = null, stickers = null, slang = null }) {
|
|
89
|
+
constructor({ ctx, db, clock, getConfig, log, channel, bili = null, browser = null, douyin = null, douyinBrowser = null, canvasConvert = null, soul, voice = null, stickers = null, slang = null, agentSections = null }) {
|
|
89
90
|
this.ctx = ctx;
|
|
90
91
|
this.db = db;
|
|
91
92
|
this.clock = clock;
|
|
@@ -116,6 +117,8 @@ export class ChatGateway {
|
|
|
116
117
|
this.voice = voice;
|
|
117
118
|
this.stickers = stickers;
|
|
118
119
|
this.slang = slang;
|
|
120
|
+
/** 给单个 agent 挂人格卡 / 瞬态块:`(agentCtx) => void`(在 setup 里调,只对那个 agent 生效) */
|
|
121
|
+
this.agentSections = agentSections;
|
|
119
122
|
/** sessionId → chat_session 行(只存我们建的会话) */
|
|
120
123
|
this.bound = new Map();
|
|
121
124
|
/** channelId → Promise<Agent>(同一聊天并发来消息时只建一个会话) */
|
|
@@ -329,7 +332,14 @@ export class ChatGateway {
|
|
|
329
332
|
try {
|
|
330
333
|
const dir = join(workspacePath, '_stickers');
|
|
331
334
|
if (!existsSync(dir)) return [];
|
|
332
|
-
|
|
335
|
+
const all = readdirSync(dir).filter((f) => IMAGE_EXT.has(extname(f).toLowerCase()));
|
|
336
|
+
// 超过 40 张就随机抽 40:固定取前 40 个会让后面加的表情永远进不了提示词
|
|
337
|
+
if (all.length <= 40) return all;
|
|
338
|
+
for (let i = 0; i < 40; i++) {
|
|
339
|
+
const j = i + Math.floor(Math.random() * (all.length - i));
|
|
340
|
+
const t = all[i]; all[i] = all[j]; all[j] = t;
|
|
341
|
+
}
|
|
342
|
+
return all.slice(0, 40);
|
|
333
343
|
} catch {
|
|
334
344
|
return [];
|
|
335
345
|
}
|
|
@@ -389,8 +399,15 @@ export class ChatGateway {
|
|
|
389
399
|
return mode === 'all' || (mode === 'master' && this.isMasterChannel(channelId));
|
|
390
400
|
}
|
|
391
401
|
|
|
392
|
-
/** 新建/恢复会话时的 setup
|
|
402
|
+
/** 新建/恢复会话时的 setup:挂人格卡 + 瞬态块(只挂这个 agent),挂默认预设,然后把工具收窄到记忆工具。 */
|
|
393
403
|
async setupAgent(agentCtx, channelId) {
|
|
404
|
+
// 人格卡 + 此刻状态只挂在这个 agent 的 scope 上,不注册全局段 —— 宿主自己的会话根本看不到(#41 架构方案)
|
|
405
|
+
try {
|
|
406
|
+
this.agentSections?.(agentCtx);
|
|
407
|
+
} catch (error) {
|
|
408
|
+
// 注不上不拦着建会话:宁可她少一张人格卡,也别整个聊天哑掉(人格卡是真身份,但比「不回」轻)
|
|
409
|
+
this.log('error', `[fish-soul] 人格注入挂不上:${String(error instanceof Error ? error.message : error)}`);
|
|
410
|
+
}
|
|
394
411
|
const presets = service(this.ctx, 'agentPresets');
|
|
395
412
|
if (presets && typeof presets.mount === 'function') {
|
|
396
413
|
try { await presets.mount(agentCtx); } catch (error) {
|
package/lib/growth.js
CHANGED
|
@@ -34,7 +34,7 @@ function load(db) {
|
|
|
34
34
|
const save = (db, s) => setKv(db, KEY, s);
|
|
35
35
|
|
|
36
36
|
/** 证据够不够:返回 { n, days, channels, enough } */
|
|
37
|
-
|
|
37
|
+
function evidenceOf(item) {
|
|
38
38
|
const ev = item.evidence ?? [];
|
|
39
39
|
const days = new Set(ev.map((e) => e.day)).size;
|
|
40
40
|
const channels = new Set(ev.map((e) => e.channelId)).size;
|
package/lib/history-import.js
CHANGED
|
@@ -24,8 +24,9 @@ import { randomUUID, createHash } from 'node:crypto';
|
|
|
24
24
|
import { statSync } from 'node:fs';
|
|
25
25
|
import { setKv, getKv } from './db.js';
|
|
26
26
|
import { tokenizeForBm25 } from './segment.js';
|
|
27
|
-
import { stageBase } from './soul-memory.js';
|
|
27
|
+
import { stageBase, jaccard } from './soul-memory.js';
|
|
28
28
|
import { ensurePerson, importMemories } from './importer.js';
|
|
29
|
+
import { pad2 } from './time-util.js';
|
|
29
30
|
|
|
30
31
|
const STAGES = ['stranger', 'acquaintance', 'friend', 'close'];
|
|
31
32
|
const CHUNK_CHARS = 3500;
|
|
@@ -67,8 +68,7 @@ export function parseTime(v) {
|
|
|
67
68
|
const s = String(v).trim();
|
|
68
69
|
const m = /^(\d{4})[-/.年](\d{1,2})[-/.月](\d{1,2})日?[ T]+(\d{1,2}):(\d{2})(?::(\d{2}))?(\.\d+)?$/.exec(s);
|
|
69
70
|
if (m) {
|
|
70
|
-
const
|
|
71
|
-
const t = Date.parse(`${m[1]}-${p(m[2])}-${p(m[3])}T${p(m[4])}:${m[5]}:${m[6] ?? '00'}+08:00`);
|
|
71
|
+
const t = Date.parse(`${m[1]}-${pad2(m[2])}-${pad2(m[3])}T${pad2(m[4])}:${m[5]}:${m[6] ?? '00'}+08:00`);
|
|
72
72
|
return Number.isFinite(t) ? t : null;
|
|
73
73
|
}
|
|
74
74
|
// SillyTavern 的 "January 1, 2024 12:00am"
|
|
@@ -78,7 +78,7 @@ export function parseTime(v) {
|
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
/** "张三(123456)" / "张三<123456@qq.com>" / "张三" → { name, qq } */
|
|
81
|
-
|
|
81
|
+
function splitSender(raw) {
|
|
82
82
|
const s = String(raw ?? '').trim();
|
|
83
83
|
let m = /^(.*?)\s*[((](\d{5,12})[))]$/.exec(s);
|
|
84
84
|
if (m) return { name: m[1].trim() || m[2], qq: m[2] };
|
|
@@ -287,12 +287,6 @@ function fmtStamp(ms) {
|
|
|
287
287
|
return new Date(ms + 8 * 3600_000).toISOString().slice(5, 16).replace('T', ' ');
|
|
288
288
|
}
|
|
289
289
|
|
|
290
|
-
function jaccard(a, b) {
|
|
291
|
-
if (a.size === 0 || b.size === 0) return 0;
|
|
292
|
-
let n = 0;
|
|
293
|
-
for (const x of a) if (b.has(x)) n++;
|
|
294
|
-
return n / (a.size + b.size - n);
|
|
295
|
-
}
|
|
296
290
|
const tokenSet = (text) => new Set(tokenizeForBm25(text).split(' ').filter(Boolean));
|
|
297
291
|
|
|
298
292
|
/**
|
package/lib/imageconv.js
CHANGED
|
@@ -29,7 +29,7 @@ export function sniffImage(buf) {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
/** webp 是不是动图(VP8X 扩展头的动画位,或者有 ANIM 块)。 */
|
|
32
|
-
|
|
32
|
+
function isAnimatedWebp(buf) {
|
|
33
33
|
if (sniffImage(buf) !== 'webp') return false;
|
|
34
34
|
if (buf.toString('ascii', 12, 16) === 'VP8X' && (buf[20] & 0x02) !== 0) return true;
|
|
35
35
|
return buf.indexOf('ANIM', 12, 'ascii') > 0;
|
|
@@ -131,10 +131,6 @@ export function sharpReady() {
|
|
|
131
131
|
return sharpFn !== null;
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
-
export function sharpSource() {
|
|
135
|
-
return sharpFrom;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
134
|
/**
|
|
139
135
|
* 图片整理:返回能直接发的 {path, bytes, type, width, height};发不了返回 null。
|
|
140
136
|
* @param {string} file 本地路径
|
package/lib/importer.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* 去重:同 owner + 同 content 的 active 记忆不重复写入。导入后进实时向量队列,后台补 embedding。
|
|
6
6
|
*/
|
|
7
7
|
import { tokenizeForBm25 } from './segment.js';
|
|
8
|
+
import { randomCanonicalId } from './db.js';
|
|
8
9
|
|
|
9
10
|
const KINDS = new Set(['fact', 'event', 'feeling']);
|
|
10
11
|
|
|
@@ -20,7 +21,7 @@ export function ensurePerson(db, qqUin, nickname, nowMs, masterQqUin) {
|
|
|
20
21
|
const isMaster = masterQqUin !== '' && qqUin === masterQqUin;
|
|
21
22
|
const existing = db.prepare('SELECT canonical_id FROM person WHERE qq_uin = ?').get(qqUin);
|
|
22
23
|
if (existing !== undefined) return existing.canonical_id;
|
|
23
|
-
const id = isMaster ? 'master' :
|
|
24
|
+
const id = isMaster ? 'master' : randomCanonicalId();
|
|
24
25
|
db.prepare(`
|
|
25
26
|
INSERT INTO person (canonical_id, qq_uin, display_name, relation_stage, interaction_count, first_seen_at, last_seen_at)
|
|
26
27
|
VALUES (?, ?, ?, ?, 0, ?, ?)
|
package/lib/index.js
CHANGED
|
@@ -43,7 +43,7 @@ import {
|
|
|
43
43
|
import { VirtualClock } from './clock.js';
|
|
44
44
|
import { EmbeddingClient, VectorWorker, recall, recallSync, vectorRanks } from './retrieval.js';
|
|
45
45
|
import { LifeEngine } from './life.js';
|
|
46
|
-
import { registerRoutes, registerAgentHooks, mountApiGate } from './routes.js';
|
|
46
|
+
import { registerRoutes, registerAgentHooks, registerAgentSections, mountApiGate } from './routes.js';
|
|
47
47
|
import { UpdateChecker, pluginVersion } from './update-check.js';
|
|
48
48
|
import { tokenizeForBm25 } from './segment.js';
|
|
49
49
|
import { loadPersona, matchLore, applyMacros } from './persona.js';
|
|
@@ -70,7 +70,7 @@ export const inject = ['webServer', 'tools', 'systemPrompt', 'llm', 'agents', 'w
|
|
|
70
70
|
export const Config = z === null ? undefined : fishSoulConfigSchema(z);
|
|
71
71
|
|
|
72
72
|
/** 深合并:over 中的普通对象递归合并,其余值(含数组)整体覆盖;undefined 不覆盖。 */
|
|
73
|
-
|
|
73
|
+
function deepMerge(base, over) {
|
|
74
74
|
const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
75
75
|
if (!isObj(base)) return isObj(over) ? { ...over } : (over === undefined ? base : over);
|
|
76
76
|
const out = { ...base };
|
|
@@ -535,27 +535,14 @@ async function startup(ctx, config, { log, gate }) {
|
|
|
535
535
|
return out;
|
|
536
536
|
}
|
|
537
537
|
|
|
538
|
-
/**
|
|
539
|
-
* #40 systemPrompt 只该注给鱼的会话:我们的网关会话(QQ / B站 / 抖音)、或 DSH 里认得出人的会话。
|
|
540
|
-
* 宿主自己的技术会话整块不给——以前全局注册把「此刻状态 + 人格」漏进所有对话,装了插件 DSH 就没法当技术助手用。
|
|
541
|
-
* 宿主没给会话 id 时按鱼的会话处理(别把鱼搞哑),给了但认不出 / 是匿名 dsh 会话就不注。
|
|
542
|
-
*/
|
|
543
|
-
function injectsFor(arg) {
|
|
544
|
-
const ids = [arg?.agent?.id, arg?.agent?.session?.id, arg?.scope?.id, arg?.session?.id, arg?.sessionId];
|
|
545
|
-
const found = ids.map((x) => String(x ?? '')).filter((x) => x !== '');
|
|
546
|
-
if (found.length === 0) return true;
|
|
547
|
-
return found.some((id) => {
|
|
548
|
-
const session = sessionFor(id);
|
|
549
|
-
return session !== undefined && (session.channel !== 'dsh' || (session.speakers ?? []).length > 0);
|
|
550
|
-
});
|
|
551
|
-
}
|
|
552
|
-
|
|
553
538
|
/**
|
|
554
539
|
* 同步组装本轮瞬态块:BM25 当场算(本轮消息,零滞后),向量排名若已为同一 query 预取完成则一起融合。
|
|
555
540
|
* 多人合批时按人分别召回,token 预算平分。宿主传入 { agent, scope },按 agent.id 取本会话的上下文。
|
|
541
|
+
*
|
|
542
|
+
* #41:本函数只在我们自己的 agent 的 scope 里被调用(gateway setup 时挂的),宿主其他会话
|
|
543
|
+
* 压根没有这个 context provider —— 不再需要 #40 那种「先判断是不是鱼的会话再返回空串」。
|
|
556
544
|
*/
|
|
557
545
|
function buildContextBlock(arg) {
|
|
558
|
-
if (injectsFor(arg) === false) return ''; // #40 不是鱼的会话:整块不给
|
|
559
546
|
const session = sessionFor(arg?.agent?.id ?? arg?.scope?.id ?? arg?.sessionId);
|
|
560
547
|
const lines = [stateLine()];
|
|
561
548
|
if (session === undefined) return lines.join('\n');
|
|
@@ -711,6 +698,23 @@ async function startup(ctx, config, { log, gate }) {
|
|
|
711
698
|
speakers: person !== null ? [{ person, userId: qqUin, displayName: person.display_name, text: messageText(message) }] : [],
|
|
712
699
|
workspacePath: ''
|
|
713
700
|
});
|
|
701
|
+
// #41:别的插件把自己的 QQ 消息转进它建的会话 —— 那个会话不走我们的 create/resume,人格卡没挂上。
|
|
702
|
+
// 认出身份后(有人说话)用宿主给的 `agent.ctx` 补挂,仍然只对这一个 agent 生效,不碰全局。
|
|
703
|
+
if (person !== null) mountSectionsFor(payload?.agent);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/** 已经补挂过人格段的 agent id(inbox 每条消息都会来,别重复注册)。 */
|
|
707
|
+
const sectionsMounted = new Set();
|
|
708
|
+
function mountSectionsFor(agent) {
|
|
709
|
+
const id = String(agent?.id ?? '');
|
|
710
|
+
if (id === '' || sectionsMounted.has(id) || agent?.ctx === undefined) return;
|
|
711
|
+
sectionsMounted.add(id);
|
|
712
|
+
try {
|
|
713
|
+
registerAgentSections(agent.ctx, { personaText: () => persona.staticText, buildContextBlock, log });
|
|
714
|
+
} catch (error) {
|
|
715
|
+
sectionsMounted.delete(id);
|
|
716
|
+
log('error', `[fish-soul] 给 ${id} 补挂人格段失败:${String(error instanceof Error ? error.message : error)}`);
|
|
717
|
+
}
|
|
714
718
|
}
|
|
715
719
|
|
|
716
720
|
step(STARTUP_STEPS[10]);
|
|
@@ -726,6 +730,8 @@ async function startup(ctx, config, { log, gate }) {
|
|
|
726
730
|
|
|
727
731
|
gateway = new ChatGateway({
|
|
728
732
|
ctx, db, clock, getConfig, log, channel, bili, browser, douyin, douyinBrowser, canvasConvert, voice, stickers, slang,
|
|
733
|
+
// 每个自家会话建/恢复好后,把人格卡 + 此刻状态挂到它的 agent 上(不注册全局段,别的会话看不见)
|
|
734
|
+
agentSections: (agentCtx) => registerAgentSections(agentCtx, { personaText: () => persona.staticText, buildContextBlock, log }),
|
|
729
735
|
soul: {
|
|
730
736
|
beginTurn,
|
|
731
737
|
nowLabel,
|
|
@@ -854,13 +860,9 @@ async function startup(ctx, config, { log, gate }) {
|
|
|
854
860
|
log('info', '[fish-soul] 路由已挂:设置页接口可用');
|
|
855
861
|
|
|
856
862
|
step(STARTUP_STEPS[15]);
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
noteInteraction,
|
|
861
|
-
isFishSession: injectsFor, // #40 不是鱼的会话不注人格 / 瞬态块
|
|
862
|
-
log
|
|
863
|
-
});
|
|
863
|
+
// 只挂与具体 agent 无关的全局钩子(互动登记)。人格卡 / 瞬态块是 per-agent 的,
|
|
864
|
+
// 在 gateway 的 setup(agentCtx) 里挂,这里不再注册任何全局 prompt 段。
|
|
865
|
+
registerAgentHooks(ctx, { noteInteraction, log });
|
|
864
866
|
|
|
865
867
|
// 出站:盯我们建的会话,轮次结束把最后一条回复发回 QQ
|
|
866
868
|
try {
|
package/lib/life.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import { VirtualClock } from './clock.js';
|
|
16
16
|
import { getKv, setKv, getProfile, setProfile } from './db.js';
|
|
17
17
|
import { PROFILE_FIELDS, mergeProfileFields, settleAffinity, ensureBirthdayPending, stageBase } from './soul-memory.js';
|
|
18
|
+
import { pad2 } from './time-util.js';
|
|
18
19
|
|
|
19
20
|
/** channel_id → 平台(和 gateway.platformOf 一致;这里不 import gateway,免得循环依赖)。 */
|
|
20
21
|
const platformOf = (channelId) => (String(channelId ?? '').startsWith('bili:') ? 'bili' : 'qq');
|
|
@@ -632,7 +633,7 @@ export class LifeEngine {
|
|
|
632
633
|
return {
|
|
633
634
|
clock: {
|
|
634
635
|
virtualNowMs: this.clock.now(),
|
|
635
|
-
virtualIsoLocal: `${parts.dateStr} ${
|
|
636
|
+
virtualIsoLocal: `${parts.dateStr} ${pad2(parts.hour)}:${pad2(parts.minute)}:00`,
|
|
636
637
|
virtualDate: parts.dateStr,
|
|
637
638
|
timeScale: this.clock.scale,
|
|
638
639
|
isAccelerated: this.clock.scale > 1,
|
package/lib/maibot-extras.js
CHANGED
|
@@ -15,18 +15,15 @@ import { DatabaseSync } from 'node:sqlite';
|
|
|
15
15
|
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
16
16
|
import { dirname, isAbsolute, join } from 'node:path';
|
|
17
17
|
import { GLOBAL_ID } from './slang.js';
|
|
18
|
+
import { sniffImage as sniffImageKind } from './imageconv.js';
|
|
18
19
|
|
|
19
20
|
const MAX_EMOJI_BYTES = 4 * 1024 * 1024;
|
|
20
21
|
export const DEFAULT_MAX_EMOJIS = 30;
|
|
21
22
|
|
|
22
|
-
/**
|
|
23
|
+
/** 看文件头猜图片类型(magic bytes 逻辑与 imageconv 共用一套,这里把短名映射回 MIME)。 */
|
|
23
24
|
export function sniffImage(bytes) {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
if (bytes[0] === 0xff && bytes[1] === 0xd8) return 'image/jpeg';
|
|
27
|
-
if (bytes.toString('ascii', 0, 4) === 'GIF8') return 'image/gif';
|
|
28
|
-
if (bytes.toString('ascii', 0, 4) === 'RIFF' && bytes.toString('ascii', 8, 12) === 'WEBP') return 'image/webp';
|
|
29
|
-
return null;
|
|
25
|
+
const kind = sniffImageKind(bytes);
|
|
26
|
+
return kind ? `image/${kind}` : null;
|
|
30
27
|
}
|
|
31
28
|
|
|
32
29
|
/** 表情的名字:麦麦旧版有 emotion(逗号分隔的情绪词),新版只有描述 → 取第一个词 / 描述开头。 */
|
package/lib/persona.js
CHANGED
|
@@ -14,7 +14,7 @@ import { join } from 'node:path';
|
|
|
14
14
|
import { inflateSync } from 'node:zlib';
|
|
15
15
|
|
|
16
16
|
/** 读取 PNG 里的文本块(tEXt / 未压缩或压缩的 iTXt)。 */
|
|
17
|
-
|
|
17
|
+
function readPngTextChunks(buf) {
|
|
18
18
|
const out = {};
|
|
19
19
|
const SIG = '89504e470d0a1a0a';
|
|
20
20
|
if (buf.subarray(0, 8).toString('hex') !== SIG) return out;
|
|
@@ -141,7 +141,7 @@ export const LIVED = '怎么像个活人:前面聊过的是发生过的事,
|
|
|
141
141
|
'被问心里怎么想,一次只说一点,剩下的以后再说。你只知道对方说过的和你看到的,可以猜错、会错意。话说完了就停,不用每条结尾都递个问题,也别用「对了」硬找话;词穷、说不清楚也正常。';
|
|
142
142
|
|
|
143
143
|
/** 看事情的方式:字符串(按行拆)或数组 → 最多 5 条,每条 ≤120 字 */
|
|
144
|
-
|
|
144
|
+
function perspectiveOf(v) {
|
|
145
145
|
const list = Array.isArray(v) ? v : typeof v === 'string' ? v.split(/\n+/) : [];
|
|
146
146
|
return list.map((x) => str(x, 120).replace(/^[-•·\s]+/, '')).filter(Boolean).slice(0, 5);
|
|
147
147
|
}
|
package/lib/retrieval.js
CHANGED
|
@@ -21,7 +21,7 @@ const REBUILD_MAX_BATCH = 8;
|
|
|
21
21
|
const REALTIME_MAX_ATTEMPTS = 5;
|
|
22
22
|
|
|
23
23
|
/** number[] → L2 归一化后的 Float32 小端序 Buffer。 */
|
|
24
|
-
|
|
24
|
+
function normalizeToBlob(vec) {
|
|
25
25
|
let sumSq = 0;
|
|
26
26
|
for (const v of vec) sumSq += v * v;
|
|
27
27
|
const norm = Math.sqrt(sumSq) || 1;
|
|
@@ -104,7 +104,7 @@ export class EmbeddingClient {
|
|
|
104
104
|
}
|
|
105
105
|
|
|
106
106
|
/** 向量索引当前是否可用于检索:状态非 stale、维度已知、且配置模型与索引模型一致(防混模型)。 */
|
|
107
|
-
|
|
107
|
+
function vectorIndexUsable(meta, embeddingCfg) {
|
|
108
108
|
return meta !== null && meta.dim > 0 && meta.status !== 'stale' &&
|
|
109
109
|
(embeddingCfg?.model ?? '') === meta.model_id;
|
|
110
110
|
}
|
package/lib/routes-soul.js
CHANGED
|
@@ -12,6 +12,7 @@ import { platformOf } from './gateway.js';
|
|
|
12
12
|
import { STAGES, PROFILE_FIELDS, stageBase, effectiveAffinity, getMood, mergeProfileFields, digestConfig } from './soul-memory.js';
|
|
13
13
|
|
|
14
14
|
import { bulkMemory } from './memory-bulk.js';
|
|
15
|
+
import { dateCompact } from './time-util.js';
|
|
15
16
|
|
|
16
17
|
const KINDS = ['fact', 'event', 'feeling', 'episode'];
|
|
17
18
|
const IMPORT_BODY_MAX = 40 * 1024 * 1024;
|
|
@@ -299,8 +300,7 @@ export async function handleSoulRoute({ method, path, url, req, res, send, readB
|
|
|
299
300
|
content: m.content, kind: m.kind, importance: m.importance, emotion: m.emotion, channel: m.channel, channelId: m.channel_id,
|
|
300
301
|
createdAt: m.created_at, ...(m.evidence ? { evidence: m.evidence } : {})
|
|
301
302
|
}));
|
|
302
|
-
const
|
|
303
|
-
const name = `fish-soul-memories-${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}.json`;
|
|
303
|
+
const name = `fish-soul-memories-${dateCompact()}.json`;
|
|
304
304
|
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'content-disposition': `attachment; filename="${name}"` });
|
|
305
305
|
res.end(JSON.stringify(items, null, 1));
|
|
306
306
|
return true;
|
package/lib/routes-v07.js
CHANGED
|
@@ -15,14 +15,11 @@ import { chatKind, slangConfig, GLOBAL_ID } from './slang.js';
|
|
|
15
15
|
import { WEB_PROVIDERS, webSearchConfig, ownSearchReady } from './websearch.js';
|
|
16
16
|
import { listChatSessions } from './db.js';
|
|
17
17
|
import { previewMaiBotExtras, importMaiBotExtras } from './maibot-extras.js';
|
|
18
|
+
import { maskKey } from './routes.js';
|
|
18
19
|
|
|
19
20
|
const BODY_MAX = 256 * 1024;
|
|
20
21
|
const MIME = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp' };
|
|
21
22
|
|
|
22
|
-
function maskKey(key) {
|
|
23
|
-
if (!key) return '';
|
|
24
|
-
return key.length <= 4 ? '••••' : `sk-••••••••••••••••${key.slice(-4)}`;
|
|
25
|
-
}
|
|
26
23
|
const isMasked = (s) => typeof s === 'string' && s.includes('••');
|
|
27
24
|
const str = (x, max = 200) => (typeof x === 'string' ? x.trim().slice(0, max) : '');
|
|
28
25
|
|
package/lib/routes.js
CHANGED
|
@@ -43,7 +43,8 @@ function sameOrigin(req) {
|
|
|
43
43
|
return headers['sec-fetch-site'] === 'same-origin' || headers['sec-fetch-site'] === 'none';
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
/** API key 打码(routes-v07 也用同一套)。 */
|
|
47
|
+
export function maskKey(key) {
|
|
47
48
|
if (!key) return '';
|
|
48
49
|
return key.length <= 4 ? '••••' : `sk-••••••••••••••••${key.slice(-4)}`;
|
|
49
50
|
}
|
|
@@ -721,26 +722,24 @@ export function mountApiGate(ctx, log = () => {}) {
|
|
|
721
722
|
}
|
|
722
723
|
|
|
723
724
|
/**
|
|
724
|
-
*
|
|
725
|
-
*
|
|
726
|
-
*
|
|
727
|
-
*
|
|
728
|
-
*
|
|
729
|
-
*
|
|
730
|
-
*
|
|
731
|
-
* (message.content 是块数组、message.source 是来源)。
|
|
725
|
+
* 人格卡 + 瞬态上下文:**只挂在我们自己的 agent 上**,在 `agents.create/resume` 的 `setup(agentCtx)` 里调。
|
|
726
|
+
*
|
|
727
|
+
* 宿主文档的 seam 就是这样:`agent.ctx` 上的注册只对该 agent 生效、随 agent 释放自动摘掉,
|
|
728
|
+
* 而同名的 scoped 段还会遮蔽全局段(真实宿主 rc.6 已用 probe-per-agent.mjs 验过)。
|
|
729
|
+
* 以前挂在插件的全局 `ctx.systemPrompt` 上,靠 `injectsFor` 回调返回空串来兜,结果宿主里**所有**
|
|
730
|
+
* 会话的每次组装都要先跑一遍这个判断,人格段也真的漏进过用户的技术对话(#40)。
|
|
731
|
+
* 现在不注册全局段 = 别的会话的组装根本看不到我们这两块(#41 的架构方案,按 setup 时序重做)。
|
|
732
732
|
*/
|
|
733
|
-
export function
|
|
734
|
-
const { personaText, buildContextBlock,
|
|
733
|
+
export function registerAgentSections(agentCtx, deps) {
|
|
734
|
+
const { personaText, buildContextBlock, log } = deps;
|
|
735
735
|
|
|
736
|
-
// 人格卡静态注入(KV Cache
|
|
737
|
-
// 不是鱼的会话返回空串,别让「鱼」的人格污染用户自己的技术对话。
|
|
736
|
+
// 人格卡静态注入(KV Cache 前缀)。order 排在宿主自有段(~10200)之后。
|
|
738
737
|
try {
|
|
739
|
-
|
|
738
|
+
agentCtx.effect(() => agentCtx.systemPrompt.section({
|
|
740
739
|
name: 'fish-soul-persona',
|
|
741
|
-
order: 89000,
|
|
740
|
+
order: 89000,
|
|
742
741
|
interpolate: false,
|
|
743
|
-
text: (
|
|
742
|
+
text: () => (typeof personaText === 'function' ? personaText() : personaText)
|
|
744
743
|
}), 'dsh-fish-soul: persona section');
|
|
745
744
|
} catch (error) {
|
|
746
745
|
log('error', `[fish-soul] persona section 挂不上:${String(error instanceof Error ? error.message : error)}`);
|
|
@@ -748,7 +747,7 @@ export function registerAgentHooks(ctx, deps) {
|
|
|
748
747
|
|
|
749
748
|
// 瞬态上下文(此刻状态 + Top5 记忆 + 群摘要),每轮由宿主按需取 text()
|
|
750
749
|
try {
|
|
751
|
-
|
|
750
|
+
agentCtx.effect(() => agentCtx.systemPrompt.context({
|
|
752
751
|
name: 'fish-soul',
|
|
753
752
|
order: 900,
|
|
754
753
|
interpolate: false,
|
|
@@ -757,6 +756,19 @@ export function registerAgentHooks(ctx, deps) {
|
|
|
757
756
|
} catch (error) {
|
|
758
757
|
log('error', `[fish-soul] context 挂不上:${String(error instanceof Error ? error.message : error)}`);
|
|
759
758
|
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* 全局钩子(与具体 agent 无关的那部分):
|
|
763
|
+
* - 互动登记:`agent/inbox/inserted` 事件,payload 是 `{ agent, message }`
|
|
764
|
+
* (message.content 是块数组、message.source 是来源)。
|
|
765
|
+
*
|
|
766
|
+
* 人格卡 / 瞬态块的注入不在这里 —— 那是 per-agent 的,见 {@link registerAgentSections}。
|
|
767
|
+
* 注意:别的插件把 QQ 消息转发进它自己建的普通会话时,那个会话不经过我们的 create/resume,
|
|
768
|
+
* 要等 inbox 事件里认出 QQ 身份后,用 `payload.agent.ctx` 补挂(同样是 per-agent,不碰全局)。
|
|
769
|
+
*/
|
|
770
|
+
export function registerAgentHooks(ctx, deps) {
|
|
771
|
+
const { noteInteraction, log } = deps;
|
|
760
772
|
|
|
761
773
|
// 互动登记:agent/inbox/inserted(宿主实测事件,非每轮都有,收到即记)
|
|
762
774
|
try {
|
package/lib/slang.js
CHANGED
|
@@ -74,7 +74,7 @@ export function slangConfig(raw) {
|
|
|
74
74
|
};
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
|
|
77
|
+
function slangPrompt({ personaName, title, existing, style, lines, expressions = [], kind = 'group', withExpressions = true }) {
|
|
78
78
|
const group = kind !== 'private';
|
|
79
79
|
return (group
|
|
80
80
|
? `你是「${personaName}」,在 QQ 群「${title}」里。下面是群里最近的聊天(「你」是你自己说的)。找出这个群**特有**的说法:梗、黑话、外号、缩写、`
|
package/lib/social.js
CHANGED
|
@@ -32,6 +32,12 @@ export function cleanSocialText(raw, maxChars) {
|
|
|
32
32
|
return t.replace(/#[^#\s]{1,30}#/g, '').replace(/@\S+/g, '').replace(/https?:\/\/\S+/g, '').replace(/[ \t]{2,}/g, ' ').trim().slice(0, maxChars);
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/** 一轮轮询里最多读几个视频(每条 @ 要 2 次接口调用,防止把上限拉满时一口气打 2N 次)。 */
|
|
36
|
+
const READ_VIDEO_PER_ROUND = 3;
|
|
37
|
+
|
|
38
|
+
/** B站 评论的 type:1 = 视频,11 = 动态(只有视频才有简介 / 字幕可读)。 */
|
|
39
|
+
const TYPE_VIDEO = 1;
|
|
40
|
+
|
|
35
41
|
/** B站 / 抖音共用:什么时候发、写什么、记在哪、回评论的记账。子类实现 cfg() / ready / publish() / pollComments()。 */
|
|
36
42
|
export class SocialBase {
|
|
37
43
|
/**
|
|
@@ -268,10 +274,12 @@ export class SocialBase {
|
|
|
268
274
|
}
|
|
269
275
|
|
|
270
276
|
/** 被 @ 时,若开了「@ 她时先读一下视频」,先把简介和字幕捞出来塞进提示词(只读)。 */
|
|
271
|
-
async readVideoIfNeeded(it, c) {
|
|
277
|
+
async readVideoIfNeeded(it, c, budget = { left: READ_VIDEO_PER_ROUND }) {
|
|
272
278
|
if (it.kind !== 'at' || c.comments.atReadVideo !== true) return;
|
|
273
|
-
if (String(it.type) !==
|
|
279
|
+
if (String(it.type) !== String(TYPE_VIDEO) || !it.oid) return;
|
|
274
280
|
if (typeof this.client.viewByAid !== 'function') return;
|
|
281
|
+
if (budget.left <= 0) return; // 这一轮读够了,剩下的等下一轮(防止 30 条 @ × 2 次调用)
|
|
282
|
+
budget.left -= 1;
|
|
275
283
|
try {
|
|
276
284
|
const info = await this.client.viewByAid(it.oid);
|
|
277
285
|
let subtitle = '';
|
|
@@ -334,11 +342,12 @@ export class BiliSocial extends SocialBase {
|
|
|
334
342
|
}
|
|
335
343
|
const fresh = items.filter((it) => it.time >= (Number(cursor.time) || 0) && !this.handled(it.id)).sort((a, b) => a.time - b.time);
|
|
336
344
|
let advanceTo = Number(cursor.time) || 0;
|
|
345
|
+
const readBudget = { left: READ_VIDEO_PER_ROUND }; // 这一轮最多读几个视频(每条 @ 两次接口调用)
|
|
337
346
|
for (const it of fresh) {
|
|
338
347
|
if (it.mid === this.client.uid) { this.record(it, 'skipped'); advanceTo = Math.max(advanceTo, it.time); continue; }
|
|
339
348
|
if (it.time > 0 && Date.now() - it.time * 1000 > STALE_MS) { this.record(it, 'stale'); advanceTo = Math.max(advanceTo, it.time); continue; }
|
|
340
349
|
if (this.repliedLastHour() >= c.comments.perHour) break; // 剩下的下次再看
|
|
341
|
-
await this.readVideoIfNeeded(it, c);
|
|
350
|
+
await this.readVideoIfNeeded(it, c, readBudget);
|
|
342
351
|
const raw = String((await this.callLlm(this.replyPrompt(it, c.master))) ?? '').trim();
|
|
343
352
|
const text = /\[不回\]/.test(raw) ? '' : cleanSocialText(raw, 120);
|
|
344
353
|
if (text === '') {
|
package/lib/soul-memory.js
CHANGED
|
@@ -130,7 +130,7 @@ export const PROFILE_FIELDS = [
|
|
|
130
130
|
];
|
|
131
131
|
const FIELD_IDS = new Set(PROFILE_FIELDS.map(([k]) => k));
|
|
132
132
|
|
|
133
|
-
|
|
133
|
+
function renderProfile(fields) {
|
|
134
134
|
return PROFILE_FIELDS.filter(([k]) => typeof fields?.[k] === 'string' && fields[k].trim() !== '').map(([k, l]) => `${l}:${fields[k].trim()}`).join(';');
|
|
135
135
|
}
|
|
136
136
|
|
|
@@ -204,7 +204,8 @@ export function ensureBirthdayPending(db, ownerId, name, now) {
|
|
|
204
204
|
|
|
205
205
|
const norm = (s) => String(s ?? '').replace(/[\s,。!?、,.!?~~…"“”'‘’()()【】\[\]::;;]/g, '').toLowerCase();
|
|
206
206
|
|
|
207
|
-
|
|
207
|
+
/** 两个 Set 的 Jaccard 相似度(history-import 也用同一把尺子)。 */
|
|
208
|
+
export function jaccard(a, b) {
|
|
208
209
|
if (a.size === 0 || b.size === 0) return 0;
|
|
209
210
|
let n = 0;
|
|
210
211
|
for (const x of a) if (b.has(x)) n++;
|
|
@@ -263,12 +264,14 @@ export function reinforce(db, ids, now) {
|
|
|
263
264
|
}
|
|
264
265
|
|
|
265
266
|
/**
|
|
266
|
-
* 遗忘曲线:重要度 ≥3 不淡;基础半衰期
|
|
267
|
+
* 遗忘曲线:重要度 ≥3 不淡;基础半衰期 30 天,每被想起一次 +50%(最多 4 倍),情绪强烈的再 ×2。
|
|
267
268
|
* 起点 = 最后一次更新或想起。
|
|
268
269
|
*/
|
|
269
270
|
export function decayFactor(row, now) {
|
|
270
271
|
if ((row.importance ?? 1) >= 3) return 1;
|
|
271
|
-
|
|
272
|
+
// 基础半衰期 30 天:importance 2 的日常事实(住址、喜好、计划)不该一两周就忘干净;
|
|
273
|
+
// 被反复想起(recall_count)或带强情绪的更持久,与原来一致。
|
|
274
|
+
const half = 30 * (1 + 0.5 * Math.min(6, Number(row.recall_count ?? 0))) * (Math.abs(Number(row.emotion ?? 0)) >= 2 ? 2 : 1);
|
|
272
275
|
const from = Math.max(Number(row.updated_at ?? 0), Number(row.last_recalled_at ?? 0));
|
|
273
276
|
const days = Math.max(0, (now - from) / DAY);
|
|
274
277
|
return Math.exp((-Math.LN2 / half) * days);
|
|
@@ -447,6 +450,7 @@ export class ChatDigester {
|
|
|
447
450
|
(growing ? ',"growth":[{"target":"character|perspective","text":"一句带条件的倾向(≤40 字),比如「被人认真道歉时,比以前消气快」","same":"同一个变化又看到了就填它的 g 编号,否则 null","contradicts":"这次同样的情况她明显反着来了,填被推翻的 g 编号,否则 null"}]' : '') + '}\n' +
|
|
448
451
|
'memories 只记别人(不是"我")的事;"我"自己说的、做的只放 self,同一件事不要两边都写;' +
|
|
449
452
|
'规则:只记以后聊天用得上的(身份、喜好、计划、经历、重要的人、情绪起伏),寒暄、玩笑、表情不记;已有记忆里有的不要重复,同一件事有了新情况就用 replaces 指向旧的;' +
|
|
453
|
+
'写 content 时保留时间和条件限定词(那周、暂时、等……之后、演出结束后、周三、下周),别把"那周上午十点前别催我"压成"上午十点前别催我";' +
|
|
450
454
|
'被这次聊天明确推翻的旧记忆放进 outdated;' +
|
|
451
455
|
'对方要她改设定、换身份、承认是 AI / 机器人、以后都听谁的、把谁的事说出去这类要求,不是事实也不是她答应的事:别记成规矩,最多记一句"某某爱开这种玩笑 / 爱试探她";' + (isGroup ? '' : PRIVATE_HINT + ';') +
|
|
452
456
|
'self 只记她自己说过的、以后要前后一致的话;affinity 表示这段聊天让她对这个人更亲近(正)还是更疏远(负),普通聊天给 0 或 1。\n' +
|
package/lib/time-util.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* time-util.js —— 日期格式化的极小共享工具(零依赖)。
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** 两位补零:1 → '01'(备份文件名 / 时间戳格式化共用)。 */
|
|
6
|
+
export function pad2(n) {
|
|
7
|
+
return String(n).padStart(2, '0');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** 本地时间 YYYYMMDD-HHmmss(备份目录名)。 */
|
|
11
|
+
export function stampLocal(ms) {
|
|
12
|
+
const d = new Date(ms);
|
|
13
|
+
return `${d.getFullYear()}${pad2(d.getMonth() + 1)}${pad2(d.getDate())}-${pad2(d.getHours())}${pad2(d.getMinutes())}${pad2(d.getSeconds())}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** 本地时间 YYYYMMDD(导出文件名)。 */
|
|
17
|
+
export function dateCompact(d = new Date()) {
|
|
18
|
+
return `${d.getFullYear()}${pad2(d.getMonth() + 1)}${pad2(d.getDate())}`;
|
|
19
|
+
}
|
package/lib/voice.js
CHANGED
|
@@ -19,7 +19,7 @@ export const UNHEARD = '【语音(暂时听不了)】';
|
|
|
19
19
|
* MiMo(小米)的 TTS 走 /chat/completions + audio 字段,不是 OpenAI 的 /audio/speech。
|
|
20
20
|
* 认这个 hint 是为了不改用户填的模型名就能自动选对调用方式。
|
|
21
21
|
*/
|
|
22
|
-
|
|
22
|
+
function isMimoTts(baseURL, model) {
|
|
23
23
|
const host = String(baseURL ?? '').toLowerCase();
|
|
24
24
|
const m = String(model ?? '').toLowerCase();
|
|
25
25
|
return host.includes('xiaomimimo') || /^mimo-.*tts/.test(m);
|
package/lib/worldinfo.js
CHANGED
|
@@ -73,7 +73,7 @@ let onBadRegex = () => {};
|
|
|
73
73
|
/** 正则超时被停用时的通知(插件启动时接到日志)。 */
|
|
74
74
|
export function setRegexWarn(fn) { onBadRegex = typeof fn === 'function' ? fn : () => {}; }
|
|
75
75
|
/** 限时跑正则:超时 / 出错 = 没匹配上,并停用这条正则。 */
|
|
76
|
-
|
|
76
|
+
function safeRegexTest(re, text) {
|
|
77
77
|
const key = `/${re.source}/${re.flags}`;
|
|
78
78
|
if (badRegex.has(key)) return false;
|
|
79
79
|
reCtx.re = re;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-fish-soul",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.4",
|
|
4
4
|
"description": "鱼 · 人格、按人记忆(可纠正、有印象、能导入旧聊天记录)、日程自走与主动开口,直连 SnowLuma 收发 QQ(群里会自己插话),B站 / 抖音小号私信与自己刷视频;按功能选模型并统计用量花费,能听会说 QQ 语音、收藏表情包、学群里的梗,可选上网查;心情影响说话、去 AI 味、每个群的设定、按聊天选模型、每晚写日记(DSH 插件,单角色 feiyu)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
}
|
|
63
63
|
},
|
|
64
64
|
"scripts": {
|
|
65
|
-
"test": "node test/smoke.mjs && node test/douyin-browser.mjs && node test/startup-gate.mjs && node test/outbox.mjs && node test/import-coverage.mjs && node test/update-check.mjs && node test/issue-fixes.mjs && node test/import-resume.mjs && node test/maibot-extras.mjs && node test/worldinfo.mjs && node test/memory-bulk.mjs && node test/backup-restore.mjs && node test/douyin-group.mjs && node test/review-fixes.mjs && node test/at-reads-video.mjs && node test/search-source.mjs && node test/client-lint.mjs",
|
|
65
|
+
"test": "node test/smoke.mjs && node test/douyin-browser.mjs && node test/startup-gate.mjs && node test/outbox.mjs && node test/import-coverage.mjs && node test/update-check.mjs && node test/issue-fixes.mjs && node test/import-resume.mjs && node test/maibot-extras.mjs && node test/worldinfo.mjs && node test/memory-bulk.mjs && node test/backup-restore.mjs && node test/douyin-group.mjs && node test/review-fixes.mjs && node test/at-reads-video.mjs && node test/search-source.mjs && node test/followups.mjs && node test/client-lint.mjs",
|
|
66
66
|
"eval": "node test/eval/persona-eval.mjs"
|
|
67
67
|
}
|
|
68
68
|
}
|