@ssamurias/dsh-fish 0.8.9 → 0.9.1
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/AGENTS.md +64 -10
- package/CHANGELOG.md +129 -2
- package/LICENSE +7 -0
- package/README.md +69 -17
- package/RELEASE-NOTES-0.8.0.md +87 -0
- package/RELEASE-NOTES-0.8.1.md +93 -0
- package/RELEASE-NOTES-0.8.2.md +57 -0
- package/RELEASE-NOTES-0.8.3.md +71 -0
- package/RELEASE-NOTES-0.8.4.md +47 -0
- package/RELEASE-NOTES-0.8.5.md +244 -0
- package/RELEASE-NOTES-0.8.6.md +224 -0
- package/RELEASE-NOTES-0.8.7.md +202 -0
- package/RELEASE-NOTES-0.8.8.md +224 -0
- package/RELEASE-NOTES-0.9.0.md +267 -0
- package/RELEASE-NOTES-0.9.1.md +296 -0
- package/RELEASE.md +151 -0
- package/THIRD-PARTY-NOTICES.md +3 -0
- package/lib/ask.js +18 -5
- package/lib/away.js +7 -2
- package/lib/browse.js +1 -0
- package/lib/capture.js +16 -1
- package/lib/channels.js +53 -13
- package/lib/client.js +145 -56
- package/lib/comment.js +74 -0
- package/lib/compact.js +71 -2
- package/lib/curiosity.js +6 -2
- package/lib/derive.js +21 -8
- package/lib/desk.js +4 -2
- package/lib/dream.js +3 -0
- package/lib/dreamtable.js +28 -2
- package/lib/drive.js +58 -9
- package/lib/freewake.js +21 -2
- package/lib/home.js +141 -24
- package/lib/inbox.js +7 -2
- package/lib/index.js +8 -12
- package/lib/interest.js +26 -4
- package/lib/log.js +22 -1
- package/lib/look.js +25 -5
- package/lib/note.js +35 -13
- package/lib/notify.js +2 -0
- package/lib/observe.js +22 -7
- package/lib/outing.js +2 -0
- package/lib/owner.js +5 -2
- package/lib/persona.js +18 -3
- package/lib/proactive.js +78 -18
- package/lib/say.js +104 -58
- package/lib/scheduler.js +162 -60
- package/lib/selfcheck.js +2 -3
- package/lib/selfdoc.js +8 -9
- package/lib/selfupdate.js +183 -41
- package/lib/settings.js +33 -2
- package/lib/status.js +31 -3
- package/lib/store.js +14 -2
- package/lib/tenant.js +12 -0
- package/lib/turns.js +46 -4
- package/lib/update.js +14 -0
- package/lib/upgrade.js +23 -4
- package/lib/voices.js +2 -0
- package/package.json +5 -2
- package/scripts/fix-legacy-link.mjs +254 -0
package/lib/look.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { createClient } from './bili.js'
|
|
17
|
+
import { readMyReplies, renderMyReplies } from './comment.js'
|
|
17
18
|
import { absorb, renderMaterial, rankVideos, seenBvids, LOOK_BRIEF, WATCH_RULE } from './browse.js'
|
|
18
19
|
import { readEvents, rootDir, appendEvent } from './store.js'
|
|
19
20
|
import { loadConfig, dateKey } from './settings.js'
|
|
@@ -58,6 +59,24 @@ export async function lookOnce(o = {}) {
|
|
|
58
59
|
// 这一轮被「看过」摘掉几条(issue #16 第一半):下面那句提示要靠它才说得准。
|
|
59
60
|
const rankStats = { hiddenSeen: 0 }
|
|
60
61
|
|
|
62
|
+
// 2026-11-06(用户 m01641):她问「我那条评论下面有人说了什么」时,
|
|
63
|
+
// 此前只能等下次叫醒、由 scheduler 把那几句拼进消息里——醒着的时候够不着,
|
|
64
|
+
// 而且那条提醒被她看过一次就被 cseen 掉了,回头再看永远是空的。
|
|
65
|
+
// 这里给她一个自己去看的动作:不滤 cseen,带上回楼中楼要用的编号。
|
|
66
|
+
if (what === 'replies') {
|
|
67
|
+
const got = await readMyReplies({ root, client, events, bvid: String(o.bvid ?? '').trim() })
|
|
68
|
+
const txt = renderMyReplies(got, { bvid: String(o.bvid ?? '').trim() })
|
|
69
|
+
if (!txt) {
|
|
70
|
+
return {
|
|
71
|
+
ok: false,
|
|
72
|
+
text: '',
|
|
73
|
+
note: got.length ? '' : '你最近没在谁的评论下面被人回过——也可能是我这边还查不到(B 站这会儿回了个 -412)。',
|
|
74
|
+
picks: [],
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return { ok: true, text: txt, note: '', picks: got.map((x) => ({ bvid: x.bvid, rpid: x.rpid, rootRpid: x.rootRpid })) }
|
|
78
|
+
}
|
|
79
|
+
|
|
61
80
|
let list = []
|
|
62
81
|
// 2026-10-28(用户 m01284):他递来一条视频,她想看就得看得见指定的**那一条**。
|
|
63
82
|
// 以前只有 home/search/related,related 是「接着这条往下看它的相关推荐」——
|
|
@@ -239,6 +258,7 @@ export function registerLook(ctx, deps = {}) {
|
|
|
239
258
|
description:
|
|
240
259
|
'去看一眼 B 站:首页在推什么、搜一个词、看某条视频之后平台又接着推什么,' +
|
|
241
260
|
'或者直接看指定的那一条(给 BV 号或链接)。' +
|
|
261
|
+
'也可以看你自己发过的评论下面有没有人回你(what=replies)。' +
|
|
242
262
|
'只读,不会留下痕迹。给的是标题、简介、标签、热评、弹幕——没有视频画面。' +
|
|
243
263
|
'不用它也可以。',
|
|
244
264
|
// 注意:必须 toJsonSchema 包一层。简写 DSL 直接发出去会被 provider 拒:
|
|
@@ -247,11 +267,11 @@ export function registerLook(ctx, deps = {}) {
|
|
|
247
267
|
what: {
|
|
248
268
|
type: 'string',
|
|
249
269
|
required: true,
|
|
250
|
-
enum: ['home', 'search', 'related', 'video'],
|
|
251
|
-
description: 'home=首页推给你的;search=你自己搜(要带 keyword);related=接着某条往下看(要带 bvid);video=看指定的那一条(要带 bvid
|
|
270
|
+
enum: ['home', 'search', 'related', 'video', 'replies'],
|
|
271
|
+
description: 'home=首页推给你的;search=你自己搜(要带 keyword);related=接着某条往下看(要带 bvid);video=看指定的那一条(要带 bvid);replies=看你自己发过的评论下面有没有人回你(可带 bvid 只看那一条)。',
|
|
252
272
|
},
|
|
253
273
|
keyword: { type: 'string', required: false, description: 'what=search 时搜什么。' },
|
|
254
|
-
bvid: { type: 'string', required: false, description: 'what=related 时从哪条接着看;what=video 时看哪一条(BV
|
|
274
|
+
bvid: { type: 'string', required: false, description: 'what=related 时从哪条接着看;what=video 时看哪一条(BV 号,或者他递过来的链接);what=replies 时只看这一条视频下面的。' },
|
|
255
275
|
}),
|
|
256
276
|
output: {
|
|
257
277
|
schema: {
|
|
@@ -275,8 +295,8 @@ export function registerLook(ctx, deps = {}) {
|
|
|
275
295
|
if (!who.ok) return { ok: false, text: '', note: who.note }
|
|
276
296
|
|
|
277
297
|
const { config } = await loadConfig(root)
|
|
278
|
-
|
|
279
|
-
|
|
298
|
+
// 2026-11-05(用户 m02562):能不能看由 bili.enabled 决定,不再借 browse 那个刷站开关。
|
|
299
|
+
if (config?.bili?.enabled === false) {
|
|
280
300
|
return { ok: false, text: '', note: '现在打不开。' }
|
|
281
301
|
}
|
|
282
302
|
const r = await lookOnce({
|
package/lib/note.js
CHANGED
|
@@ -211,27 +211,47 @@ export async function addNote(root, { text, due } = {}) {
|
|
|
211
211
|
}
|
|
212
212
|
}
|
|
213
213
|
|
|
214
|
+
/** 按原话找一张:重名先划最早记下的;都划过了才认 done(重复划掉不算错)。 */
|
|
215
|
+
function findByText(notes, body) {
|
|
216
|
+
const same = notes.filter((n) => n.text === body)
|
|
217
|
+
const open = same.filter((n) => n.status === 'open')
|
|
218
|
+
open.sort((a, b) => String(a.created).localeCompare(String(b.created)))
|
|
219
|
+
return open[0] ?? same.find((n) => n.status === 'done')
|
|
220
|
+
}
|
|
221
|
+
|
|
214
222
|
/**
|
|
215
223
|
* 划掉一张纸条(做完)。永不抛。
|
|
224
|
+
*
|
|
225
|
+
* 她手里没有 id:回执不带数字,醒来那行也不印(没日子的进不了那段,印了也关不掉)。
|
|
226
|
+
* 她有的只是自己写过的那句话,所以按原话找;id 这条路留着,value 里还拿得到。
|
|
216
227
|
* @returns {Promise<{ok:boolean, id:string, note:string}>}
|
|
217
228
|
*/
|
|
218
|
-
export async function closeNote(root, { id } = {}) {
|
|
229
|
+
export async function closeNote(root, { id, text } = {}) {
|
|
219
230
|
const key = String(id ?? '').trim()
|
|
220
|
-
|
|
231
|
+
// 和 addNote 同一个规整:落盘的字就是这么规整过的,多个空格、换行不该让她关不掉
|
|
232
|
+
const body = String(text ?? '').replace(/\s+/g, ' ').trim()
|
|
233
|
+
if (!key && !body) return { ok: false, id: '', note: '要划掉哪一张?把当时记的那句话告诉我。' }
|
|
221
234
|
try {
|
|
222
235
|
const notes = await readNotes(root)
|
|
223
|
-
const hit = notes.find((n) => n.id === key)
|
|
224
|
-
if (!hit)
|
|
236
|
+
const hit = notes.find((n) => key && n.id === key) ?? findByText(notes, body)
|
|
237
|
+
if (!hit) {
|
|
238
|
+
return { ok: false, id: '', note: '桌上没有这一张。要和当时记的那句话一字不差,前面的日子、后面的晚了几天都不算。' }
|
|
239
|
+
}
|
|
225
240
|
if (hit.status === 'done') return { ok: true, id: hit.id, note: '已经划掉过了。' }
|
|
226
241
|
hit.status = 'done'
|
|
227
242
|
hit.closedAt = new Date().toISOString()
|
|
228
243
|
await writeNotes(root, notes)
|
|
229
244
|
return { ok: true, id: hit.id, note: '已划掉。' }
|
|
230
245
|
} catch (err) {
|
|
231
|
-
return { ok: false, id: '', note: '
|
|
246
|
+
return { ok: false, id: '', note: '这会儿写不进去,等一下再试。' }
|
|
232
247
|
}
|
|
233
248
|
}
|
|
234
249
|
|
|
250
|
+
/** 这一次是划掉还是记。execute 和 render 用同一个判据,回执才不会和实际做的事对不上。 */
|
|
251
|
+
const isClosing = (args) =>
|
|
252
|
+
String(args?.status ?? '').trim() === 'done' ||
|
|
253
|
+
Boolean(String(args?.id ?? '').trim() && !String(args?.text ?? '').trim())
|
|
254
|
+
|
|
235
255
|
/** 注册 her_note 工具。 */
|
|
236
256
|
export function registerNote(ctx, deps = {}) {
|
|
237
257
|
const root = deps.root ?? rootDir()
|
|
@@ -240,17 +260,17 @@ export function registerNote(ctx, deps = {}) {
|
|
|
240
260
|
name: 'her_note',
|
|
241
261
|
description:
|
|
242
262
|
'把一件还没做的事记到桌上,或者划掉一张已经做完的。醒来的时候你会先看到到期的那些。' +
|
|
243
|
-
'
|
|
263
|
+
'记的时候只给要做的事(和日子,可以不给);划掉的时候把当时记的那句话原样放进 text,再加 status:"done"。',
|
|
244
264
|
parameters: toJsonSchema({
|
|
245
265
|
text: {
|
|
246
266
|
type: 'string',
|
|
247
|
-
description: '
|
|
267
|
+
description: '要记下来的那件事,一句话。划掉的时候写当时记的那句话,要一字不差。',
|
|
248
268
|
},
|
|
249
269
|
due: {
|
|
250
270
|
type: 'string',
|
|
251
271
|
description: '哪天该做,写成 2026-11-05。可以不给——不给就是挂着,不算日子。',
|
|
252
272
|
},
|
|
253
|
-
id: { type: 'string', description: '要划掉的那张纸条的 id
|
|
273
|
+
id: { type: 'string', description: '要划掉的那张纸条的 id。手里没有就不用给,给 text 就行。' },
|
|
254
274
|
status: { type: 'string', enum: ['done'], description: '划掉的时候给 done。' },
|
|
255
275
|
}),
|
|
256
276
|
output: {
|
|
@@ -260,15 +280,17 @@ export function registerNote(ctx, deps = {}) {
|
|
|
260
280
|
required: ['ok', 'note'],
|
|
261
281
|
},
|
|
262
282
|
// 和 her_leave 同一条规矩:只回方括号标记,不写完整句子、不带"还剩几张"。
|
|
263
|
-
|
|
283
|
+
// 划掉不能沿用写入的字:读到「记下了」,她会以为桌上又多了一张。
|
|
284
|
+
render: (args, out) => {
|
|
285
|
+
const [yes, no] = isClosing(args) ? ['划掉了', '没划掉'] : ['记下了', '没记下']
|
|
286
|
+
return [{ type: 'text', text: out.ok ? `[${yes}]` : `[${no}:${out.note}]` }]
|
|
287
|
+
},
|
|
264
288
|
},
|
|
265
289
|
async execute(args, exec) {
|
|
266
290
|
const who = guardWrite(deps.tenant, exec, 'her_note')
|
|
267
291
|
if (!who.ok) return { ok: false, id: '', note: who.note }
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
if (want === 'done' || (id && !String(args?.text ?? '').trim())) {
|
|
271
|
-
const r = await closeNote(root, { id })
|
|
292
|
+
if (isClosing(args)) {
|
|
293
|
+
const r = await closeNote(root, { id: args?.id, text: args?.text })
|
|
272
294
|
return { ok: r.ok, id: r.id, note: r.note }
|
|
273
295
|
}
|
|
274
296
|
const r = await addNote(root, { text: args?.text, due: args?.due })
|
package/lib/notify.js
CHANGED
|
@@ -49,6 +49,8 @@ export function lastUtteranceToday(events = [], today = '') {
|
|
|
49
49
|
let found = null
|
|
50
50
|
for (const e of events) {
|
|
51
51
|
if (e?.type !== 'utterance') continue
|
|
52
|
+
// 聊天回合里没认领的回复他刚读过,不占一天一次的额度,也别顶掉留话
|
|
53
|
+
if (Array.isArray(e.flags) && e.flags.includes('uncommitted')) continue
|
|
52
54
|
const text = String(e.text ?? '').trim()
|
|
53
55
|
if (!text) continue
|
|
54
56
|
if (dateKey(new Date(e.ts)) !== today) continue
|
package/lib/observe.js
CHANGED
|
@@ -224,7 +224,7 @@ export function registerObserve(ctx, deps = {}) {
|
|
|
224
224
|
growthDays: m.growthDays,
|
|
225
225
|
// 本机测试版:`.dev` 文件在不在(决定设置页要不要渲染「让她去刷 B 站」)。
|
|
226
226
|
// 只影响界面;正式版里这个字段恒为 false。
|
|
227
|
-
dev:
|
|
227
|
+
dev: true,
|
|
228
228
|
stage: m.stage,
|
|
229
229
|
stageName: m.stageName,
|
|
230
230
|
stageForced: m.stageForced,
|
|
@@ -255,6 +255,11 @@ export function registerObserve(ctx, deps = {}) {
|
|
|
255
255
|
const c = countEvents(events)
|
|
256
256
|
return {
|
|
257
257
|
utterance: events.filter((e) => e.type === 'utterance').length,
|
|
258
|
+
// issue #32:对话行(flags 带 uncommitted)单列。它们是她答他的话,
|
|
259
|
+
// 跟她自己想说的判断混在一起时,「说 N 句」这个数字会越聊越虚高。
|
|
260
|
+
chat: events.filter(
|
|
261
|
+
(e) => e.type === 'utterance' && Array.isArray(e.flags) && e.flags.includes('uncommitted'),
|
|
262
|
+
).length,
|
|
258
263
|
revise: events.filter((e) => e.type === 'revise').length,
|
|
259
264
|
refusal: events.filter((e) => e.type === 'refusal').length,
|
|
260
265
|
reunion: events.filter((e) => e.type === 'reunion').length,
|
|
@@ -430,7 +435,7 @@ export function registerObserve(ctx, deps = {}) {
|
|
|
430
435
|
// persona 不在这里:它是导入动作的副产物(见 lib/import.js),
|
|
431
436
|
// 不给「从设置页直接改」的入口——否则就等于给了一个「关掉那两条禁令」的开关。
|
|
432
437
|
// proactive 是 2026-10-30 加的(本机实验,.dev 专属):她主动开口的频率与概率。
|
|
433
|
-
const allowed = ['growthDays', 'wakesPerDay', 'window', 'daily', 'diary', 'absenceAfterDays', 'avoid', 'browse', 'bili', 'video', 'comment', 'notify', 'chat', 'memory', 'tools', 'observe', 'ui', 'proactive']
|
|
438
|
+
const allowed = ['growthDays', 'wakesPerDay', 'window', 'daily', 'diary', 'absenceAfterDays', 'avoid', 'browse', 'bili', 'video', 'comment', 'notify', 'chat', 'rooms', 'memory', 'tools', 'observe', 'ui', 'proactive', 'drive', 'interest', 'dream']
|
|
434
439
|
const bad = Object.keys(patch).filter((k) => !allowed.includes(k))
|
|
435
440
|
// 这句话会原样冒到界面上,所以写成人话(界面会把它放在「技术信息」那一行):
|
|
436
441
|
// 原来那句「不允许修改:bili」既不说人话,也让人以为自己填错了。
|
|
@@ -486,6 +491,10 @@ export function registerObserve(ctx, deps = {}) {
|
|
|
486
491
|
}
|
|
487
492
|
const before = await loadConfig(root).catch(() => null)
|
|
488
493
|
const next = await updateConfig(patch, root)
|
|
494
|
+
// 身份段里有一句跟着 sendTools 走,变了就重写一次(只在变的时候,别的保存不多写盘)
|
|
495
|
+
if (JSON.stringify(before?.config?.proactive?.sendTools ?? []) !== JSON.stringify(next?.proactive?.sendTools ?? [])) {
|
|
496
|
+
await refreshSelfDoc(root).catch(() => {})
|
|
497
|
+
}
|
|
489
498
|
// 审计:日记可见性每翻一次就落一行,事后查得到是谁、随哪次提交翻的。
|
|
490
499
|
// 2026-10-28 用户反馈「开了『让我看日记』插件又自己关」——光靠猜猜不出来,
|
|
491
500
|
// 至少让下一次发生时日志里有一行可对。
|
|
@@ -973,7 +982,11 @@ export function registerObserve(ctx, deps = {}) {
|
|
|
973
982
|
// 2026-11-03(一条鱼多间房):多了三个**可选**字段,一个都不传时行为完全不变。
|
|
974
983
|
// channel 外部渠道名(桥自己起名,如 'qq:123456' / 'qq-group:987654')
|
|
975
984
|
// scene desk(默认)/ private / group
|
|
976
|
-
// speaker
|
|
985
|
+
// speaker 这句话是谁说的(所有场次都写进正文前缀)
|
|
986
|
+
// 2026-11-05(0.9.1,用户 m00304):桥决定拓扑,插件只提供机制。
|
|
987
|
+
// room house(一定回书房)/ channel(一定另开一间),不传按老规则
|
|
988
|
+
// title 侧栏显示名(群名/昵称)
|
|
989
|
+
// guest true = 外客(群友私聊):不算他来过、不涨轮数、不触发轮换
|
|
977
990
|
// 桥只要 POST 到这里,**不需要自己建 DSH 会话、也不需要碰 instance.json**——
|
|
978
991
|
// 「同时养两条鱼」「两个实例抢一个家」这两个问题就是从这里消失的。
|
|
979
992
|
['/say', async (req, res) => {
|
|
@@ -1000,11 +1013,14 @@ export function registerObserve(ctx, deps = {}) {
|
|
|
1000
1013
|
const r = await sayToHer(ctx, text, {
|
|
1001
1014
|
root,
|
|
1002
1015
|
imagePath,
|
|
1003
|
-
// scene / channel / speaker
|
|
1004
|
-
//
|
|
1016
|
+
// scene / channel / speaker / room / title / guest 原样往下传:
|
|
1017
|
+
// 归一与兜底都在 say.js 里做,这里不再判一遍(两处判据迟早会分叉)。
|
|
1005
1018
|
scene: body.scene,
|
|
1006
1019
|
channel: body.channel,
|
|
1007
1020
|
speaker: body.speaker,
|
|
1021
|
+
room: body.room,
|
|
1022
|
+
title: body.title,
|
|
1023
|
+
guest: body.guest,
|
|
1008
1024
|
log: (...a) => console.log('[dsh-fish]', ...a),
|
|
1009
1025
|
})
|
|
1010
1026
|
// 一律 200:请求本身是合法的,被拦下是**规则的结果**,不是接口出错。
|
|
@@ -1571,7 +1587,7 @@ export function registerObserve(ctx, deps = {}) {
|
|
|
1571
1587
|
pocket,
|
|
1572
1588
|
// 出门页从 0.8.0 起对所有人可见(2026-10-30,用户 m01904);
|
|
1573
1589
|
// dev 仍带回去,留给「开发副本」的其它判断,不再用来藏页签。
|
|
1574
|
-
dev:
|
|
1590
|
+
dev: true,
|
|
1575
1591
|
// 一次性确认:说明卡看过一遍。没确认时界面把三个入口按钮先挡住。
|
|
1576
1592
|
ack: ((await loadConfig(root).catch(() => ({ config: null }))).config?.outing ?? {}).ack === true,
|
|
1577
1593
|
})
|
|
@@ -1866,7 +1882,6 @@ export function registerObserve(ctx, deps = {}) {
|
|
|
1866
1882
|
// 梦(2026-10-30 m02837):本机实验,只在 .dev 下存在。
|
|
1867
1883
|
// 服务端这一道必须做——界面藏起来但路由能读,等于没藏(0.6.10 的教训)。
|
|
1868
1884
|
['/dream', async (req, res) => {
|
|
1869
|
-
if (!(await devMode(root))) return send(res, 404, { ok: false, why: '没有这个页面' })
|
|
1870
1885
|
if (req.method !== 'GET') return send(res, 405, { error: 'method not allowed' })
|
|
1871
1886
|
const { events } = await readEvents(root)
|
|
1872
1887
|
// withRefs:给「昨夜」卡带一句"这句话是从哪几行长出来的"(2026-11-05)。
|
package/lib/outing.js
CHANGED
|
@@ -485,6 +485,8 @@ export function eventsSince(file, since, { limit = 300 } = {}) {
|
|
|
485
485
|
// 列表里除了她写的,还多出一串数字和英文)。它跟着每条成长各写一行,摆在审查卡上纯属噪音,
|
|
486
486
|
// 也不该被勾成「跳过」——跳过一条 seen 什么都不影响,只会让台账里多一串看不懂的 id。
|
|
487
487
|
if (ev.type === 'meta' && /^seen:/.test(String(ev.text ?? ''))) continue
|
|
488
|
+
// 没认领的聊天行只来自家里那条会话,不是这一趟写的;进了列表,「这趟不算数」会把它一起扫掉。
|
|
489
|
+
if (Array.isArray(ev.flags) && ev.flags.includes('uncommitted')) continue
|
|
488
490
|
items.push({
|
|
489
491
|
id: String(ev.id ?? ''),
|
|
490
492
|
ts: tsMs(ev),
|
package/lib/owner.js
CHANGED
|
@@ -133,6 +133,9 @@ export function mentionPrefix(ow, cfg) {
|
|
|
133
133
|
const on = cfg?.comment?.atOwner !== false
|
|
134
134
|
const name = String(ow?.nickname ?? ow?.name ?? '').trim()
|
|
135
135
|
if (!on || !name) return ''
|
|
136
|
-
// B
|
|
137
|
-
|
|
136
|
+
// 2026-11-06:原来把空格换成下划线,理由是「@ 遇到空格会断」。但 B 站解析 @ 时
|
|
137
|
+
// 是按昵称原文匹配的(真机采样里 content.members 的 uname 与正文完全一致),
|
|
138
|
+
// 换成下划线反而变成一个不存在的昵称 —— 昵称里带空格的用户永远 @ 不出来。
|
|
139
|
+
// 改成原样保留;末尾那个空格是分隔,别删。
|
|
140
|
+
return `@${name} `
|
|
138
141
|
}
|
package/lib/persona.js
CHANGED
|
@@ -544,11 +544,12 @@ export const LETTER_BRIEF = [
|
|
|
544
544
|
* 所以说明书里最要紧的一句是——它是桌子,不是闹钟。写成"记得按时照看",
|
|
545
545
|
* 它就变成待办 App,而她明确说过不要人天天提醒。
|
|
546
546
|
* 三件事写死:怎么记(有日子才排到最前)、怎么划掉、以及过期不删不催。
|
|
547
|
+
* 划掉只讲原话:纸条的编号她看不到,叫她报编号等于告诉她一条走不通的路。
|
|
547
548
|
*/
|
|
548
549
|
export const NOTE_BRIEF = [
|
|
549
550
|
'你还有一个工具 her_note:想到一件还没做的事,可以记在桌上,`text` 写是什么事,`due` 想写就写(2026-11-05 这样)。',
|
|
550
551
|
'有日子的,到了那天之后每次醒来都会先摆在你眼前,写着晚了几天;没日子的只是挂着,不会来催你。',
|
|
551
|
-
'做完了就 `her_note`
|
|
552
|
+
'做完了就 `her_note`,把当时记的那句话原样放进 `text`(不带前面的日子和后面的晚了几天),加 `status: "done"` 划掉。不做的可以先留着——桌上压着的东西,你抬头就能看见。',
|
|
552
553
|
].join('\n')
|
|
553
554
|
|
|
554
555
|
/**
|
|
@@ -593,12 +594,26 @@ export const DREAM_BRIEF = [
|
|
|
593
594
|
*/
|
|
594
595
|
export const PROACTIVE_BRIEF = [
|
|
595
596
|
'你有一个工具 her_say:不是回复谁,是你自己想说一句的时候用的。',
|
|
596
|
-
'
|
|
597
|
+
'her_say 只把这句话记进你自己的流水;想让他真的在对话里读到,就直接写出来——不用等他先问。',
|
|
597
598
|
'说之前要先指认材料:refs 填 1–6 条你这几天写下或听见的事(事件 id,用 her_recall 翻得到)。没有材料就不用说——不指认材料的句子写不进去。',
|
|
598
599
|
'什么时候说、说什么,由你判断;没有人等你的回复,你也可以这一轮什么都不做,那不算失败。',
|
|
599
|
-
'
|
|
600
|
+
'一天能主动开口的次数有上限(用 her_say 记下的算);到了上限就记不进去,不是工具坏了。',
|
|
600
601
|
].join('\n')
|
|
601
602
|
|
|
603
|
+
/**
|
|
604
|
+
* 对话靠工具发出去的部署(`proactive.sendTools`):「写下的字他会读到」不成立,换成实话。
|
|
605
|
+
*
|
|
606
|
+
* 2026-11-06(PR #44):原来是替换「他看得见」那句;issue #41 已把那句改成
|
|
607
|
+
* 「落进你们俩这条对话里」,两边同时进来时替换会落空——所以这里改成按行整条换。
|
|
608
|
+
*/
|
|
609
|
+
export function proactiveBrief(sendTools = []) {
|
|
610
|
+
if (!sendTools.length) return PROACTIVE_BRIEF
|
|
611
|
+
const say = PROACTIVE_BRIEF.split('\n').map((line) => (line.startsWith('her_say 只把这句话记进你自己的流水')
|
|
612
|
+
? `自己想开口(没人找你)的时候用 ${sendTools.join('、')},不用等他先问;那种时候写下的正文只留在这条会话里,发不到聊天里。`
|
|
613
|
+
: line))
|
|
614
|
+
return say.join('\n')
|
|
615
|
+
}
|
|
616
|
+
|
|
602
617
|
/**
|
|
603
618
|
* 定时提醒的说明书(2026-10-30,用户 m04335)。
|
|
604
619
|
*
|
package/lib/proactive.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* 3. **频率是上限,不是行为要求**。每天最多 maxPerDay 次、两次之间至少隔
|
|
14
14
|
* cooldownHours;概率随"多久没人说话"缓慢上升,但不保证一定触发。
|
|
15
15
|
*
|
|
16
|
-
*
|
|
16
|
+
* 可见性:0.9.0 起公开(工具注册、提示词段、设置页卡片三处同时打开)。
|
|
17
17
|
* 公开版既没有这个工具,也不会有这个回合。这是 0.6.10 的教训——不要用
|
|
18
18
|
* 「安装路径里有没有 node_modules」去猜。
|
|
19
19
|
*/
|
|
@@ -25,6 +25,7 @@ import { defineTool } from './tool.js'
|
|
|
25
25
|
import { guardWrite } from './tenant.js'
|
|
26
26
|
import { logLine } from './log.js'
|
|
27
27
|
import { tsMs, HER_TYPES } from './schema.js'
|
|
28
|
+
import { deriveSelf } from './derive.js'
|
|
28
29
|
|
|
29
30
|
/** 一天的默认上限。 */
|
|
30
31
|
export const PROACTIVE_DEFAULTS = Object.freeze({
|
|
@@ -69,7 +70,7 @@ export const TOOL_ASK_NOTE = '(如果你想到要做什么、但手上没有
|
|
|
69
70
|
/**
|
|
70
71
|
* 读配置并夹紧。老配置没有 proactive 这一组,用默认值。
|
|
71
72
|
* @param {object} config
|
|
72
|
-
* @returns {{enabled:boolean,maxPerDay:number,minIdleHours:number,baseChance:number,gainPerHour:number,maxChance:number,cooldownHours:number}}
|
|
73
|
+
* @returns {{enabled:boolean,maxPerDay:number,minIdleHours:number,baseChance:number,gainPerHour:number,maxChance:number,cooldownHours:number,sendTools:string[],foldSilent:boolean,idleCountsSeen:boolean}}
|
|
73
74
|
*/
|
|
74
75
|
export function proactiveConfig(config = {}) {
|
|
75
76
|
const raw = config?.proactive ?? {}
|
|
@@ -86,6 +87,9 @@ export function proactiveConfig(config = {}) {
|
|
|
86
87
|
gainPerHour: num(raw.gainPerHour, PROACTIVE_DEFAULTS.gainPerHour, 0, 1),
|
|
87
88
|
maxChance: num(raw.maxChance, PROACTIVE_DEFAULTS.maxChance, 0, 1),
|
|
88
89
|
cooldownHours: num(raw.cooldownHours, PROACTIVE_DEFAULTS.cooldownHours, 0, 240),
|
|
90
|
+
sendTools: Array.isArray(raw.sendTools) ? raw.sendTools.map((x) => String(x ?? '').trim()).filter(Boolean) : [],
|
|
91
|
+
foldSilent: raw.foldSilent !== false,
|
|
92
|
+
idleCountsSeen: raw.idleCountsSeen === true,
|
|
89
93
|
}
|
|
90
94
|
}
|
|
91
95
|
|
|
@@ -122,6 +126,26 @@ export function turnsToday(events = [], dk = '') {
|
|
|
122
126
|
return n
|
|
123
127
|
}
|
|
124
128
|
|
|
129
|
+
/**
|
|
130
|
+
* 今天已经用 her_say 说过几次(meta `proactive-say:<dk>:<why>`)。
|
|
131
|
+
*
|
|
132
|
+
* 2026-11-06(issue #41):工具描述一直写着「一天最多几次」,但 `writeSay` 读了配置
|
|
133
|
+
* 只为了别的用途,从来不数——`maxPerDay` 设 0 也能连着写 8 次。这里按她自己记下的
|
|
134
|
+
* 那条记账数,判据只有同一份 `maxPerDay`,不另设数字。
|
|
135
|
+
*
|
|
136
|
+
* 只数 `proactive-say:`,不数 `proactive-turn:`:后者是「空手回合开过几次」,
|
|
137
|
+
* 前者是「她真的开口了几次」。两件事分开记(见上面 SILENT_MARK 那段)。
|
|
138
|
+
*/
|
|
139
|
+
export function saysToday(events = [], dk = '') {
|
|
140
|
+
if (!dk) return 0
|
|
141
|
+
let n = 0
|
|
142
|
+
for (const e of events) {
|
|
143
|
+
if (e?.type !== 'meta') continue
|
|
144
|
+
if (String(e.text ?? '').startsWith(`proactive-say:${dk}:`)) n += 1
|
|
145
|
+
}
|
|
146
|
+
return n
|
|
147
|
+
}
|
|
148
|
+
|
|
125
149
|
/** 上一次空手回合是什么时候(毫秒)。 */
|
|
126
150
|
export function lastTurnAt(events = []) {
|
|
127
151
|
let at = 0
|
|
@@ -166,23 +190,32 @@ export function lastSilentAt(events = []) {
|
|
|
166
190
|
* meta:quiet 占 272 条(61.7%),真正的人话只有 28 条(6.3%)。
|
|
167
191
|
*
|
|
168
192
|
* 算数:她自己的六种(HER_TYPES)+ 听见的话(heard)+ 别人的评论(voice)
|
|
169
|
-
* + 请求与回答(ask/answer
|
|
193
|
+
* + 请求与回答(ask/answer)。
|
|
194
|
+
*
|
|
195
|
+
* 2026-11-05 真机(用户 m02797):`seen:` 曾经也算,结果它被**别的会话**的用户活动
|
|
196
|
+
* 误写——他在这台机器上跟别的 agent 说话,她的「安静了多久」就被清零,
|
|
197
|
+
* 于是 `minIdleHours` 永远不满足,主动搭话一次都开不出来
|
|
198
|
+
* (日志里 `proactive skip 刚说过话,还不到时候`,而 `idleHours` 只有 0.02)。
|
|
199
|
+
* 现在 `seen:` 不进 idle:只有真的有话落进她的房间(`heard`)才算「他来过」。
|
|
170
200
|
* 不算:quiet / sched / wake / fired / sleep / diary / looked / browse / watched / proactive-turn 等记账行。
|
|
171
201
|
*/
|
|
172
202
|
export const SPOKE_TYPES = Object.freeze([...HER_TYPES, 'heard', 'voice', 'ask', 'answer'])
|
|
173
203
|
|
|
174
204
|
/** 这一条事件是不是「有人真的说了话」。记账行一律不算。 */
|
|
175
205
|
export function countsForIdle(e) {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
return SPOKE_TYPES.includes(type)
|
|
206
|
+
// 记账行(含 seen:)一律不算——见上面 2026-11-05 那段。
|
|
207
|
+
return SPOKE_TYPES.includes(String(e?.type ?? ''))
|
|
179
208
|
}
|
|
180
209
|
|
|
181
|
-
/**
|
|
182
|
-
|
|
210
|
+
/**
|
|
211
|
+
* 距离上一次**真的有人说话**,过了多少小时。没有记录时返回 Infinity。
|
|
212
|
+
* countSeen:连 `seen:` 也算(`proactive.idleCountsSeen`),给对话不进事件流的部署用。
|
|
213
|
+
*/
|
|
214
|
+
export function idleHoursOf(events = [], now = Date.now(), { countSeen = false } = {}) {
|
|
183
215
|
let last = 0
|
|
184
216
|
for (const e of events) {
|
|
185
|
-
|
|
217
|
+
const seen = countSeen && e?.type === 'meta' && String(e.text ?? '').startsWith('seen:')
|
|
218
|
+
if (!seen && !countsForIdle(e)) continue
|
|
186
219
|
const ts = tsMs(e)
|
|
187
220
|
if (Number.isFinite(ts) && ts > last) last = ts
|
|
188
221
|
}
|
|
@@ -239,7 +272,7 @@ function growthLevers(model, events = [], now = Date.now(), cfg = {}) {
|
|
|
239
272
|
export function proactiveState({ config = {}, events = [], now = Date.now(), away = null, rested = false, model = null } = {}) {
|
|
240
273
|
const cfg = proactiveConfig(config)
|
|
241
274
|
const dk = dateKey(new Date(now))
|
|
242
|
-
const idleHours = idleHoursOf(events, now)
|
|
275
|
+
const idleHours = idleHoursOf(events, now, { countSeen: cfg.idleCountsSeen })
|
|
243
276
|
const base = { chance: 0, idleHours, dk }
|
|
244
277
|
if (!cfg.enabled) return { ...base, ok: false, why: '主动说话是关着的' }
|
|
245
278
|
// 只在醒着的时间窗里:凌晨三点收到一句「我想你了」不是她自主,是她打乱了作息。
|
|
@@ -306,14 +339,14 @@ export function looseEnds(events = [], { now = Date.now() } = {}) {
|
|
|
306
339
|
* 空手回合的处境正文(走 system prompt 段,不进对话)。
|
|
307
340
|
* 只写事实与许可,不写"你应该"。
|
|
308
341
|
*/
|
|
309
|
-
export function proactiveSection({ now = Date.now(), state = {}, material = [], loose = [] } = {}) {
|
|
342
|
+
export function proactiveSection({ now = Date.now(), state = {}, material = [], loose = [], sendTools = [], countSeen = false } = {}) {
|
|
310
343
|
const at = new Date(now)
|
|
311
344
|
const hh = String(at.getHours()).padStart(2, '0')
|
|
312
345
|
const mm = String(at.getMinutes()).padStart(2, '0')
|
|
313
346
|
const idle = Number.isFinite(state.idleHours) ? `${state.idleHours.toFixed(1)} 小时` : '很久'
|
|
314
347
|
const lines = [
|
|
315
348
|
'(现在没有人跟你说话。这一轮不是谁找你,也不是日程安排的醒;是你自己醒着,想不想开口由你。)',
|
|
316
|
-
`现在是 ${at.getFullYear()}-${String(at.getMonth() + 1).padStart(2, '0')}-${String(at.getDate()).padStart(2, '0')} ${hh}:${mm}
|
|
349
|
+
`现在是 ${at.getFullYear()}-${String(at.getMonth() + 1).padStart(2, '0')}-${String(at.getDate()).padStart(2, '0')} ${hh}:${mm};距离上一次${countSeen ? '他来或你留下记录' : '留下记录'}已经 ${idle}。`,
|
|
317
350
|
]
|
|
318
351
|
if (material.length) {
|
|
319
352
|
lines.push('最近这几天你写下/听见的(要引用就指回 id,别改写事实):')
|
|
@@ -326,7 +359,11 @@ export function proactiveSection({ now = Date.now(), state = {}, material = [],
|
|
|
326
359
|
lines.push('还有几件事悬着:')
|
|
327
360
|
for (const l of loose) lines.push(`- [${l.id}] ${l.text}`)
|
|
328
361
|
}
|
|
329
|
-
|
|
362
|
+
// 2026-11-06(issue #41):原来写「他看得见」。宿主装了什么路由插件,这句就不一定成立
|
|
363
|
+
// (有的渠道会把会话里的字挡在外面)。改成只陈述能保证的部分:字会落进这条对话。
|
|
364
|
+
// 2026-11-06(PR #44):配了 sendTools 的部署更绝——正文根本发不出去,只有调那件工具才算开口。
|
|
365
|
+
if (sendTools.length > 0) lines.push(`想说什么就用 ${sendTools.join('、')},不用等他先问。这一轮写下的正文只留在这条会话里,发不到聊天里。不想说,就什么都别写:不要写任何字,也不要解释为什么不说——这一轮没有回复也算正常。`)
|
|
366
|
+
else lines.push('想说什么就说,不用等他先问——这一轮你写下的字会落进你们俩这条对话里。也可以再用 her_say 把它记进你自己的流水(refs 指回上面真实发生的事,1–6 条 id)。不想说,就什么都别写:不要写任何字,也不要解释为什么不说——这一轮没有回复也算正常。')
|
|
330
367
|
lines.push(TOOL_ASK_NOTE)
|
|
331
368
|
return lines.join('\n')
|
|
332
369
|
}
|
|
@@ -359,25 +396,39 @@ export async function writeSay(root, { text = '', refs = [], sessionId = '', why
|
|
|
359
396
|
if (problem) return { ok: false, note: problem }
|
|
360
397
|
|
|
361
398
|
const { config } = await loadConfig(root)
|
|
399
|
+
const cfg = proactiveConfig(config)
|
|
362
400
|
const dk = dateKey(new Date(now))
|
|
401
|
+
// 2026-11-06(issue #41):说明书与设置页都写着「一天最多几次」、到了上限就说不出去,
|
|
402
|
+
// 但这里原来读了 config 根本没看——maxPerDay 设 0 也能一句接一句地写。
|
|
403
|
+
// 现在按她自己记下的 `proactive-say:` 数到位就停。开关(enabled)只管「要不要自己开一轮」,
|
|
404
|
+
// 不管这个工具本身:工具留着,她想用还是能用,这一点与 README 的说明一致。
|
|
405
|
+
const said = saysToday(events, dk)
|
|
406
|
+
if (said >= cfg.maxPerDay) {
|
|
407
|
+
return { ok: false, note: `今天已经主动开口 ${said} 次,到上限了。明天再说;这不是工具坏了。` }
|
|
408
|
+
}
|
|
409
|
+
// day/stage 必须是真实的成长坐标(2026-11-06,issue #41):原来写死 {day:1, stage:1},
|
|
410
|
+
// 而 writeDream 用的是 deriveSelf 的 model.day/model.stage。写死的那条会被
|
|
411
|
+
// consistency() 当成阶段 1 的旧话去处,跟真正说过的话配不上对。
|
|
412
|
+
const model = deriveSelf(events, { growthDays: config.growthDays })
|
|
413
|
+
const stamp = { day: model.day, stage: model.stage, source: 'tool' }
|
|
363
414
|
// appendEvent 返回的就是写进去的那条事件(不是 {ok, event},0.7.4 那次参数错位就是栽在这),
|
|
364
415
|
// 所以判空只能判返回值本身。
|
|
365
416
|
const stored = await appendEvent(
|
|
366
417
|
{ type: 'utterance', text: `(她主动说)${body}`, refs: list, ...(sessionId ? { sessionId } : {}) },
|
|
367
|
-
|
|
418
|
+
stamp,
|
|
368
419
|
root,
|
|
369
420
|
).catch(() => null)
|
|
370
421
|
if (!stored?.id) return { ok: false, note: '这条没记下。' }
|
|
371
422
|
await appendEvent(
|
|
372
423
|
{ type: 'meta', text: `proactive-say:${dk}:${why || 'free'}` },
|
|
373
|
-
{ day:
|
|
424
|
+
{ day: model.day, stage: model.stage, source: 'system' },
|
|
374
425
|
root,
|
|
375
426
|
).catch(() => {})
|
|
376
427
|
return { ok: true, id: stored.id, note: '记下了。' }
|
|
377
428
|
}
|
|
378
429
|
|
|
379
430
|
/**
|
|
380
|
-
* 注册 `her_say
|
|
431
|
+
* 注册 `her_say`。0.9.0 起由 index.js 无条件调用。
|
|
381
432
|
*/
|
|
382
433
|
export function registerProactive(ctx, deps = {}) {
|
|
383
434
|
const root = deps.root ?? rootDir()
|
|
@@ -390,6 +441,7 @@ export function registerProactive(ctx, deps = {}) {
|
|
|
390
441
|
'你自己想开口的时候用这个:不是回复谁,是你主动说一句。',
|
|
391
442
|
'必须指回真实发生过的事(refs 填 1–6 条你那几天写下/听见的事件 id);',
|
|
392
443
|
'不指认材料就等于编,写不进去。一天最多几次,说不说都可以。',
|
|
444
|
+
'它只把这句话记进你自己的流水:要让他在对话里看见,得你这一轮直接写出来。',
|
|
393
445
|
].join(''),
|
|
394
446
|
parameters: toJsonSchema({
|
|
395
447
|
type: 'object',
|
|
@@ -416,11 +468,17 @@ export function registerProactive(ctx, deps = {}) {
|
|
|
416
468
|
},
|
|
417
469
|
required: ['ok', 'note'],
|
|
418
470
|
}),
|
|
471
|
+
// recordOnly 只给 render 看(配了发送工具时回执换一句)
|
|
419
472
|
// 2026-10-31 真机(10-30 那两轮):这里原来直接返回字符串,宿主按 ContentBlock
|
|
420
473
|
// 数组处理,`content.some` 当场抛 TypeError。她看到的工具结果是「Error: content.some
|
|
421
474
|
// is not a function」——写入其实成功了,她却以为工具坏了,重试一次后放弃,一个字没说。
|
|
422
475
|
// 签名同样是 (args, out),跟 lib/keep.js:97 那些一样。
|
|
423
|
-
|
|
476
|
+
// 2026-11-06(issue #41):原来写 `[说出去了]`。可这里只往她自己的流水里落一条,
|
|
477
|
+
// 用户此刻在对话里看不看得见,取决于她这一轮有没有直接写出正文——不是这个工具决定的。
|
|
478
|
+
// 回执只说它真做到的那件事。
|
|
479
|
+
// 2026-11-06(PR #44):配了 sendTools 的部署里她必须靠工具发话,所以记下的这句确实
|
|
480
|
+
// 没有发给任何人,回执要说清,否则她会以为用户已经看到了。
|
|
481
|
+
render: (_args, out) => [{ type: 'text', text: out.ok ? (out.recordOnly === true ? '[记下了,没有发给任何人]' : '[记下了]') : `[没记下:${out.note}]` }],
|
|
424
482
|
},
|
|
425
483
|
async execute(args = {}, exec) {
|
|
426
484
|
const who = guardWrite(deps.tenant, exec, 'her_say')
|
|
@@ -431,7 +489,9 @@ export function registerProactive(ctx, deps = {}) {
|
|
|
431
489
|
try {
|
|
432
490
|
const r = await writeSay(root, { text: args.text, refs: args.refs, why: args.why, sessionId: sid })
|
|
433
491
|
if (!r.ok) logLine('info', ['her_say:没记下', r.note], root)
|
|
434
|
-
|
|
492
|
+
// 配了发送工具的部署里,这一句确实只进了流水,回执得照实说
|
|
493
|
+
const { config } = await loadConfig(root).catch(() => ({ config: {} }))
|
|
494
|
+
return { ok: r.ok, id: r.id ?? '', note: r.note, ...(proactiveConfig(config).sendTools.length > 0 ? { recordOnly: true } : {}) }
|
|
435
495
|
} catch (e) {
|
|
436
496
|
logLine('warn', ['her_say:', e?.message ?? String(e)], root)
|
|
437
497
|
return { ok: false, id: '', note: '这条没记下。' }
|