dsh-fish-soul 0.13.3 → 0.13.5
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/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/client.behavior.js +6 -2
- package/lib/client.js +3 -2
- package/lib/client.soul.js +5 -1
- package/lib/db.js +2 -2
- package/lib/douyin-social.js +1 -1
- package/lib/gateway.js +12 -2
- 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-v09.js +14 -1
- package/lib/routes.js +29 -17
- package/lib/slang.js +1 -1
- 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 +1 -1
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/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/client.behavior.js
CHANGED
|
@@ -125,7 +125,7 @@ window.__ModuleLoader__.load({
|
|
|
125
125
|
|
|
126
126
|
/** 每日日记 */
|
|
127
127
|
function Diary({ data, save, api, notify, load, relTime, ui }) {
|
|
128
|
-
const { h, Group, Row, Switch, Muted, Button, Empty, Tag } = ui;
|
|
128
|
+
const { h, Group, Row, Switch, Muted, Button, Empty, Tag, IconButton } = ui;
|
|
129
129
|
const d = data.diary;
|
|
130
130
|
const [busy, setBusy] = react.useState(false);
|
|
131
131
|
if (!d) return null;
|
|
@@ -145,7 +145,11 @@ window.__ModuleLoader__.load({
|
|
|
145
145
|
Row({ title: "发到 QQ 空间", desc: "默认关;SnowLuma 不支持的话会在动态里记一条失败" },
|
|
146
146
|
h(Switch, { label: "发到 QQ 空间", checked: c.qzone, onChange: (v) => save({ diary: { qzone: v } }) })),
|
|
147
147
|
topics.length === 0 ? null : h("div", { className: "fs-list" }, topics.map((x, i) => h("div", { className: "fs-item", key: `t${i}` },
|
|
148
|
-
Tag("想聊", "info"), h("span", { className: "fs-item-main" }, `${x.who ? `${x.who}:` : ""}${x.topic}`)
|
|
148
|
+
Tag("想聊", "info"), h("span", { className: "fs-item-main" }, `${x.who ? `${x.who}:` : ""}${x.topic}`),
|
|
149
|
+
h("span", { className: "fs-item-meta" }, IconButton("close", "删除", async () => {
|
|
150
|
+
const r = await api("POST", "/diary/topic-remove", { who: x.who ?? "", topic: x.topic });
|
|
151
|
+
if (r?.ok) { notify("删掉了"); load(); } else notify(r?.error ?? "删除失败");
|
|
152
|
+
}))))),
|
|
149
153
|
h("div", { className: "fs-list" }, (d.entries ?? []).length === 0 ? Empty("还没写过日记")
|
|
150
154
|
: d.entries.map((e) => h("div", { className: "fs-item", key: e.id, title: e.content },
|
|
151
155
|
h("span", { className: "fs-item-main" }, e.content.replace(/^\d{4}-\d{2}-\d{2} 的日记:/, "")),
|
package/lib/client.js
CHANGED
|
@@ -161,8 +161,9 @@ window.__ModuleLoader__.load({
|
|
|
161
161
|
h("select", { ...props, className: "fs-input" }, ...options), Icon("chevron", 12));
|
|
162
162
|
|
|
163
163
|
const Empty = (text) => h("div", { className: "fs-empty" }, text);
|
|
164
|
-
const Item = ({ key, lead, text, title, meta }) => h("div", { className: "fs-item", key },
|
|
165
|
-
lead ?? null, h("span", { className: "fs-item-main", title: title ?? text }, text), meta ? h("span", { className: "fs-item-meta" }, ...meta) : null
|
|
164
|
+
const Item = ({ key, lead, text, title, meta, onRemove }) => h("div", { className: "fs-item", key },
|
|
165
|
+
lead ?? null, h("span", { className: "fs-item-main", title: title ?? text }, text), meta ? h("span", { className: "fs-item-meta" }, ...meta) : null,
|
|
166
|
+
onRemove ? IconButton("close", "删除", onRemove) : null);
|
|
166
167
|
|
|
167
168
|
/** 表单:服务端数据到了才初始化一次,之后本地编辑、失焦保存。 */
|
|
168
169
|
function useForm(data, init) {
|
package/lib/client.soul.js
CHANGED
|
@@ -350,6 +350,10 @@ window.__ModuleLoader__.load({
|
|
|
350
350
|
const [sel, setSel] = react.useState(() => new Set());
|
|
351
351
|
const qs = new URLSearchParams({ ...(owner !== "all" ? { ownerId: owner } : {}), ...(qApplied ? { q: qApplied } : {}), ...(source ? { source } : {}), ...(bulk ? { limit: "500" } : {}) }).toString();
|
|
352
352
|
const [data, load] = usePoll(api, `/inspector/overview${qs ? `?${qs}` : ""}`, 0);
|
|
353
|
+
async function dropPending(id) {
|
|
354
|
+
const r = await api("POST", "/inspector/forget", { targetType: "pending", id });
|
|
355
|
+
if (r?.ok) { notify("已删掉这条待办"); load(); } else notify(r?.error ?? "删除失败");
|
|
356
|
+
}
|
|
353
357
|
const [adding, setAdding] = react.useState("");
|
|
354
358
|
react.useEffect(() => { setSel(new Set()); }, [owner, qApplied, source, bulk]);
|
|
355
359
|
if (!data) return null;
|
|
@@ -409,7 +413,7 @@ window.__ModuleLoader__.load({
|
|
|
409
413
|
h("div", { className: "fs-list" }, memories.length === 0 ? Empty("还没有记忆")
|
|
410
414
|
: memories.map((m) => h(MemoryItem, { key: m.id, m, ui, api, notify, refresh: load, curGen: data.currentGenerationId ?? 1, selectable: bulk, selected: sel.has(m.id), onSelect })))),
|
|
411
415
|
pendings.length === 0 ? null : Group({ title: "待办", aside: Tag(String(pendings.length), "outline") },
|
|
412
|
-
h("div", { className: "fs-list" }, pendings.map((p) => Item({ key: p.id, lead: Muted(props.fmtDueAt(p.dueAt), { className: "fs-unit num" }), text: p.reason, meta: [p.channelId ?? ""] })))),
|
|
416
|
+
h("div", { className: "fs-list" }, pendings.map((p) => Item({ key: p.id, lead: Muted(props.fmtDueAt(p.dueAt), { className: "fs-unit num" }), text: p.reason, meta: [p.channelId ?? ""], onRemove: () => dropPending(p.id) })))),
|
|
413
417
|
h(ImportBackup, { ui, api, notify, relTime: props.relTime, onDone: load }));
|
|
414
418
|
}
|
|
415
419
|
|
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>(同一聊天并发来消息时只建一个会话) */
|
|
@@ -396,8 +399,15 @@ export class ChatGateway {
|
|
|
396
399
|
return mode === 'all' || (mode === 'master' && this.isMasterChannel(channelId));
|
|
397
400
|
}
|
|
398
401
|
|
|
399
|
-
/** 新建/恢复会话时的 setup
|
|
402
|
+
/** 新建/恢复会话时的 setup:挂人格卡 + 瞬态块(只挂这个 agent),挂默认预设,然后把工具收窄到记忆工具。 */
|
|
400
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
|
+
}
|
|
401
411
|
const presets = service(this.ctx, 'agentPresets');
|
|
402
412
|
if (presets && typeof presets.mount === 'function') {
|
|
403
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-v09.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* 习惯开关(心情影响行为 / 去 AI 味 / 合并转发 / 群里冷热 / 小互动 / 每轮判断心情 / 日记)、
|
|
4
4
|
* 每个群的群设定和冷热、按聊天选模型、记忆调试器、现在写一篇日记。
|
|
5
5
|
*/
|
|
6
|
-
import { listChatSessions } from './db.js';
|
|
6
|
+
import { listChatSessions, getKv, setKv } from './db.js';
|
|
7
7
|
import { behaviorConfig, chatModelsConfig, resolveChatModel, groupNotes, setGroupNote } from './behavior.js';
|
|
8
8
|
|
|
9
9
|
const BODY_MAX = 64 * 1024;
|
|
@@ -102,6 +102,19 @@ export async function handleV09Route({ method, path, req, send, readBody, sameOr
|
|
|
102
102
|
send(200, { ok: true, ...(await debugRecall({ ownerId, text, inGroup: b.inGroup === true })) });
|
|
103
103
|
return true;
|
|
104
104
|
}
|
|
105
|
+
// ── 契约 42:删掉一条日记里「想聊的」──
|
|
106
|
+
if (path === '/diary/topic-remove' && post) {
|
|
107
|
+
if (!guard()) return true;
|
|
108
|
+
const b = await body();
|
|
109
|
+
const t = getKv(db, 'diary.topics', null);
|
|
110
|
+
if (!t || !Array.isArray(t.items)) { send(200, { ok: false, error: '没有想聊的' }); return true; }
|
|
111
|
+
const idx = t.items.findIndex((x) => (x.who ?? '') === String(b.who ?? '') && x.topic === String(b.topic ?? ''));
|
|
112
|
+
if (idx === -1) { send(200, { ok: false, error: '这条不在了' }); return true; }
|
|
113
|
+
t.items.splice(idx, 1);
|
|
114
|
+
setKv(db, 'diary.topics', t);
|
|
115
|
+
send(200, { ok: true });
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
105
118
|
// ── 契约 41:现在写一篇日记 ──
|
|
106
119
|
if (path === '/diary/write-now' && post) {
|
|
107
120
|
if (!guard()) return true;
|
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/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.5",
|
|
4
4
|
"description": "鱼 · 人格、按人记忆(可纠正、有印象、能导入旧聊天记录)、日程自走与主动开口,直连 SnowLuma 收发 QQ(群里会自己插话),B站 / 抖音小号私信与自己刷视频;按功能选模型并统计用量花费,能听会说 QQ 语音、收藏表情包、学群里的梗,可选上网查;心情影响说话、去 AI 味、每个群的设定、按聊天选模型、每晚写日记(DSH 插件,单角色 feiyu)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|