@ssamurias/dsh-fish 0.9.0 → 0.9.2
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 +60 -4
- package/CHANGELOG.md +85 -1
- package/README.md +13 -6
- package/RELEASE-NOTES-0.9.1.md +296 -0
- package/RELEASE-NOTES-0.9.2.md +46 -0
- package/RELEASE.md +152 -0
- package/lib/absence.js +41 -27
- package/lib/ask.js +91 -30
- package/lib/askinchat.js +83 -1
- package/lib/browse.js +1 -0
- package/lib/capture.js +35 -2
- package/lib/cardanswer.js +84 -0
- package/lib/client.js +116 -19
- package/lib/comment.js +111 -2
- package/lib/compact.js +45 -1
- package/lib/curiosity.js +6 -2
- package/lib/deliver.js +46 -0
- package/lib/derive.js +25 -13
- package/lib/desk.js +3 -1
- package/lib/dreamtable.js +13 -2
- package/lib/drive.js +14 -2
- package/lib/events.js +25 -1
- package/lib/freewake.js +4 -1
- package/lib/generator.js +2 -1
- package/lib/grant.js +18 -2
- package/lib/home.js +142 -29
- package/lib/identity.js +111 -14
- package/lib/index.js +50 -10
- package/lib/interest.js +3 -1
- package/lib/keep.js +21 -9
- package/lib/keepsake.js +20 -13
- package/lib/look.js +23 -3
- package/lib/note.js +255 -20
- package/lib/observe.js +14 -4
- package/lib/owner.js +5 -2
- package/lib/persona.js +26 -9
- package/lib/proactive.js +67 -12
- package/lib/say.js +73 -13
- package/lib/scheduler.js +186 -56
- package/lib/schema.js +18 -0
- package/lib/selfdoc.js +5 -3
- package/lib/selfupdate.js +8 -1
- package/lib/settings.js +5 -0
- package/lib/status.js +56 -6
- package/lib/store.js +14 -2
- package/lib/tenant.js +12 -0
- package/lib/turns.js +47 -2
- package/lib/upgrade.js +4 -1
- package/package.json +2 -1
- package/scripts/fix-legacy-link.mjs +197 -90
package/lib/keep.js
CHANGED
|
@@ -20,8 +20,8 @@ import { askKeepsake, decideKeepsake } from './keepsake.js'
|
|
|
20
20
|
import { askInChat, picked } from './askinchat.js'
|
|
21
21
|
|
|
22
22
|
/** 点头的两个选项。文案用她那个人的语气,不用「批准/拒绝」。 */
|
|
23
|
-
const YES = '准了'
|
|
24
|
-
const NO = '算了'
|
|
23
|
+
export const YES = '准了'
|
|
24
|
+
export const NO = '算了'
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
27
|
* 把「想带这个回去」问进会话里,等一个人选。
|
|
@@ -31,12 +31,20 @@ const NO = '算了'
|
|
|
31
31
|
* 算了 → 这东西留在原地
|
|
32
32
|
* 没答(超时/没人接)→ 还挂着,出门页里仍可以点头
|
|
33
33
|
*/
|
|
34
|
-
|
|
34
|
+
/** 卡开着的时候别处(观测台、接卡的插件)先答了:回执照账上的说,不照这张卡上点的说。 */
|
|
35
|
+
function handledElsewhere(d) {
|
|
36
|
+
return { state: 'answered', note: d.item?.status === 'kept' ? '别处先答了,带回来了。' : '别处先答了,没带。', said: 'failed' }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function askBack(ctx, root, { sessionId, item, signal, rawWhy }) {
|
|
35
40
|
const why = String(item?.why ?? '').trim()
|
|
36
41
|
const name = String(item?.rel ?? '').split(/[\\/]/).pop() || String(item?.rel ?? '')
|
|
37
42
|
const r = await askInChat(ctx, {
|
|
38
43
|
sessionId,
|
|
39
44
|
signal,
|
|
45
|
+
root,
|
|
46
|
+
rawTexts: [rawWhy],
|
|
47
|
+
cardId: `keepsake:${item?.id ?? ''}`,
|
|
40
48
|
questions: [
|
|
41
49
|
{
|
|
42
50
|
id: 'keepsake',
|
|
@@ -52,12 +60,14 @@ async function askBack(ctx, root, { sessionId, item, signal }) {
|
|
|
52
60
|
const said = picked(r.answers, 'keepsake')
|
|
53
61
|
if (said === YES) {
|
|
54
62
|
const d = decideKeepsake(root, { id: item.id, yes: true })
|
|
63
|
+
if (d.handled) return handledElsewhere(d)
|
|
55
64
|
return d.ok
|
|
56
65
|
? { state: 'answered', note: '准了,带回来了。', said: 'yes' }
|
|
57
|
-
: { state: 'answered', note:
|
|
66
|
+
: { state: 'answered', note: `准了,${d.note}`, said: 'failed' }
|
|
58
67
|
}
|
|
59
68
|
if (said === NO) {
|
|
60
|
-
decideKeepsake(root, { id: item.id, yes: false })
|
|
69
|
+
const d = decideKeepsake(root, { id: item.id, yes: false })
|
|
70
|
+
if (d.handled) return handledElsewhere(d)
|
|
61
71
|
return { state: 'answered', note: '没带。', said: 'no' }
|
|
62
72
|
}
|
|
63
73
|
return { state: 'unanswered', note: '问了。等他点头。', said: '' }
|
|
@@ -101,9 +111,11 @@ export function registerBringBack(ctx, deps = {}) {
|
|
|
101
111
|
? `[没问:${out.note}]`
|
|
102
112
|
: out.said === 'yes'
|
|
103
113
|
? '[准了]'
|
|
104
|
-
: out.said === '
|
|
105
|
-
?
|
|
106
|
-
: '
|
|
114
|
+
: out.said === 'failed'
|
|
115
|
+
? `[${out.note}]`
|
|
116
|
+
: out.said === 'no'
|
|
117
|
+
? '[算了]'
|
|
118
|
+
: '[已问]',
|
|
107
119
|
},
|
|
108
120
|
],
|
|
109
121
|
},
|
|
@@ -135,7 +147,7 @@ export function registerBringBack(ctx, deps = {}) {
|
|
|
135
147
|
|
|
136
148
|
// 0.7.5:问她那个人。这一步会等到有人选或者超时(默认 5 分钟),
|
|
137
149
|
// 但无论走哪条路,台账里都已经有一条 asked——不会因为没人答就丢掉这件事。
|
|
138
|
-
const back = await askBack(ctx, root, { sessionId, item: r.item, signal: exec?.signal })
|
|
150
|
+
const back = await askBack(ctx, root, { sessionId, item: r.item, signal: exec?.signal, rawWhy: args?.why })
|
|
139
151
|
return { ok: true, note: back.note, said: back.said }
|
|
140
152
|
},
|
|
141
153
|
}),
|
package/lib/keepsake.js
CHANGED
|
@@ -115,7 +115,9 @@ export function askKeepsake(root, { outing = null, sessionId = '', rel = '', why
|
|
|
115
115
|
if (mine.length >= KEEPSAKES_PER_TRIP) {
|
|
116
116
|
return { ok: false, note: '这一趟带得够多了。' }
|
|
117
117
|
}
|
|
118
|
-
if (open.length >= 1)
|
|
118
|
+
if (open.length >= 1) {
|
|
119
|
+
return { ok: false, note: open.some((it) => it.copyFailedAt) ? '上一件他点了头,只是没复制成,还没带回来。' : '上一件还等着点头。' }
|
|
120
|
+
}
|
|
119
121
|
|
|
120
122
|
const cwd = String(outing.cwd ?? '')
|
|
121
123
|
const target = resolve(cwd, raw)
|
|
@@ -156,7 +158,7 @@ export function decideKeepsake(root, { id = '', yes = false, now = Date.now() }
|
|
|
156
158
|
const db = readDb(root)
|
|
157
159
|
const item = db.items.find((it) => it && it.id === String(id))
|
|
158
160
|
if (!item) return { ok: false, note: '没找到这一件。' }
|
|
159
|
-
if (item.status !== 'asked') return { ok: false, note: '这一件已经处理过了。' }
|
|
161
|
+
if (item.status !== 'asked') return { ok: false, item, handled: true, note: '这一件已经处理过了。' }
|
|
160
162
|
|
|
161
163
|
if (!yes) {
|
|
162
164
|
item.status = 'declined'
|
|
@@ -187,23 +189,28 @@ export function decideKeepsake(root, { id = '', yes = false, now = Date.now() }
|
|
|
187
189
|
|
|
188
190
|
const dk = dateKey(new Date(now))
|
|
189
191
|
const dir = keepsakesDir(root, dk)
|
|
192
|
+
const dest = join(dir, `${item.id}-${basename(target)}`)
|
|
190
193
|
try {
|
|
191
194
|
mkdirSync(dir, { recursive: true })
|
|
192
|
-
const dest = join(dir, `${item.id}-${basename(target)}`)
|
|
193
195
|
copyFileSync(target, dest)
|
|
194
|
-
item.status = 'kept'
|
|
195
|
-
item.decidedAt = now
|
|
196
|
-
item.keptAs = `${dk}/${basename(dest)}`
|
|
197
|
-
item.size = st.size
|
|
198
|
-
writeDb(root, db)
|
|
199
|
-
return { ok: true, item, note: '带回来了。' }
|
|
200
196
|
} catch (err) {
|
|
201
|
-
|
|
202
|
-
item.
|
|
203
|
-
|
|
197
|
+
// 他说的是准了,只是这次没复制成(占用、权限)。还挂着,再答一次再试,不记成没带。
|
|
198
|
+
item.copyFailedAt = now
|
|
199
|
+
try { writeDb(root, db) } catch { /* 写不进去也还是 asked,只是下一件的说法不准 */ }
|
|
200
|
+
return { ok: false, item, note: `没复制成(${String(err?.code ?? '出错了')})。` }
|
|
201
|
+
}
|
|
202
|
+
item.status = 'kept'
|
|
203
|
+
item.decidedAt = now
|
|
204
|
+
item.keptAs = `${dk}/${basename(dest)}`
|
|
205
|
+
item.size = st.size
|
|
206
|
+
delete item.copyFailedAt
|
|
207
|
+
try {
|
|
204
208
|
writeDb(root, db)
|
|
205
|
-
|
|
209
|
+
} catch (err) {
|
|
210
|
+
// 盘上还是 asked:再答一次会覆盖同名文件,把账补上
|
|
211
|
+
return { ok: false, item, note: `文件到家了,账没记上(${String(err?.code ?? '出错了')}),再答一次。` }
|
|
206
212
|
}
|
|
213
|
+
return { ok: true, item, note: '带回来了。' }
|
|
207
214
|
}
|
|
208
215
|
|
|
209
216
|
/** 还等着点头的那几件(界面用)。 */
|
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: {
|
package/lib/note.js
CHANGED
|
@@ -9,10 +9,14 @@
|
|
|
9
9
|
* 所以这不是闹钟(到点叫我),是一张**桌子**:一天看一次,睁眼知道欠几天。
|
|
10
10
|
* 不做通知、不做界面卡片、不做红点——她要的是"睁眼时桌上有什么"。
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
12
|
+
* 0.8.8 的乙档(用户 m01465)只做了两件事:醒来摆出到期的、her_note 能写能划掉。
|
|
13
|
+
* 2026-11-06(issue #53)补齐当初说好「跑一段再说」的三样,另修一个死锁:
|
|
14
|
+
* 1. 名额只算 open。原来 200 张名额连划掉的一起算,划满之后再也记不进(死锁);
|
|
15
|
+
* 2. 回翻(list):open / due / done / dropped / all,带 offset 往后翻;
|
|
16
|
+
* 3. 放下(drop,reason 必填)——issue #22 里设计好的第三种状态,数据层一直在等它。
|
|
17
|
+
* 「没日子的只提一次」落在 add 的回执里(写下的那一下回一句原文),**不进唤醒段**:
|
|
18
|
+
* 没日子就没有账可算,抬到桌上就变成催了。这跟「以后都不上桌」不冲突。
|
|
19
|
+
* update(改一句)暂不做:直接覆盖文本会把她改过什么抹掉,要做得先定好留痕的字段。
|
|
16
20
|
*
|
|
17
21
|
* 三条硬约束(从 letter.js 沿下来的同一条线):
|
|
18
22
|
* 1. **不审批**。记一张纸条不是改自己,不设关卡。
|
|
@@ -39,6 +43,10 @@ export const NOTES_MAX = 200
|
|
|
39
43
|
export const NOTE_TEXT_MAX = 200
|
|
40
44
|
/** 醒来那一段最多列几张,剩下的只报数。 */
|
|
41
45
|
export const NOTE_SHOW_MAX = 5
|
|
46
|
+
/** 一次 list 最多列几行。往后翻靠 offset,不一次把两百张全倒出来。 */
|
|
47
|
+
export const NOTE_LIST_MAX = 20
|
|
48
|
+
/** list 能看的几类:open=还没关掉的,due=写了日子的,done/dropped=关掉的,all=全要。 */
|
|
49
|
+
export const NOTE_FILTERS = ['open', 'due', 'done', 'dropped', 'all']
|
|
42
50
|
|
|
43
51
|
export function notesPath(root = rootDir()) {
|
|
44
52
|
return join(root, NOTES_FILE)
|
|
@@ -73,6 +81,8 @@ export function normalizeNote(raw) {
|
|
|
73
81
|
status,
|
|
74
82
|
reason: raw.reason == null ? null : String(raw.reason).slice(0, 200),
|
|
75
83
|
closedAt: raw.closedAt == null ? null : String(raw.closedAt),
|
|
84
|
+
// 只有 update 会写;其余时候是 null。留着它,改过的痕迹至少还在时间上
|
|
85
|
+
revisedAt: raw.revisedAt == null ? null : String(raw.revisedAt),
|
|
76
86
|
}
|
|
77
87
|
}
|
|
78
88
|
|
|
@@ -193,8 +203,11 @@ export async function addNote(root, { text, due } = {}) {
|
|
|
193
203
|
|
|
194
204
|
try {
|
|
195
205
|
const notes = await readNotes(root)
|
|
196
|
-
|
|
197
|
-
|
|
206
|
+
// 名额只算还压在桌上的。划掉/放下的不占位——否则划满两百张之后再也记不进,
|
|
207
|
+
// 而拒绝的话还说「先划掉几张」,桌上根本没有能划的(issue #53 的死锁)。
|
|
208
|
+
const openCount = notes.filter((n) => n.status === 'open').length
|
|
209
|
+
if (openCount >= NOTES_MAX) {
|
|
210
|
+
return { ok: false, id: '', note: '桌上堆满两百张没动的了,先划掉几张,或者放下几张(放下要写一句为什么)再说。' }
|
|
198
211
|
}
|
|
199
212
|
const note = normalizeNote({
|
|
200
213
|
id: newNoteId(),
|
|
@@ -205,7 +218,7 @@ export async function addNote(root, { text, due } = {}) {
|
|
|
205
218
|
})
|
|
206
219
|
notes.push(note)
|
|
207
220
|
await writeNotes(root, notes)
|
|
208
|
-
return { ok: true, id: note.id, note: '已记。' }
|
|
221
|
+
return { ok: true, id: note.id, note: '已记。', echo: note.text }
|
|
209
222
|
} catch (err) {
|
|
210
223
|
return { ok: false, id: '', note: '这张没记下来。' }
|
|
211
224
|
}
|
|
@@ -247,6 +260,179 @@ export async function closeNote(root, { id, text } = {}) {
|
|
|
247
260
|
}
|
|
248
261
|
}
|
|
249
262
|
|
|
263
|
+
// ---- 回翻、改一句、放下(issue #53,2026-11-06)----
|
|
264
|
+
// 三件事共用一套「按她看得见的东西找条」:她手里没有 id,只有自己写过的那句话。
|
|
265
|
+
// 所以对外只认原话;id 这条路留着给代码用,不进说明书。
|
|
266
|
+
|
|
267
|
+
/** list 里的一行。关掉的那几种带上关掉的日期,放下的还要带上那句为什么。 */
|
|
268
|
+
function listLine(n) {
|
|
269
|
+
const md = (iso) => {
|
|
270
|
+
const d = String(iso ?? '').slice(0, 10)
|
|
271
|
+
return isDue(d) ? `${d.slice(5, 7)}-${d.slice(8, 10)}` : ''
|
|
272
|
+
}
|
|
273
|
+
if (n.status === 'done') {
|
|
274
|
+
const at = md(n.closedAt)
|
|
275
|
+
return `✓ ${n.text}${at ? `(${at} 划掉)` : ''}`
|
|
276
|
+
}
|
|
277
|
+
if (n.status === 'dropped') {
|
|
278
|
+
const at = md(n.closedAt)
|
|
279
|
+
return `✗ ${n.text}${at ? `(${at} 放下)` : ''}${n.reason ? `—— ${n.reason}` : ''}`
|
|
280
|
+
}
|
|
281
|
+
return n.due ? `${n.due.slice(5)} ${n.text}` : `(没日子)${n.text}`
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** 摊在桌上的先按到期日(没日子的排最后),关掉的按关掉的时间倒序。 */
|
|
285
|
+
function sortForList(notes) {
|
|
286
|
+
const open = notes.filter((n) => n.status === 'open')
|
|
287
|
+
const closed = notes.filter((n) => n.status !== 'open')
|
|
288
|
+
open.sort((a, b) => {
|
|
289
|
+
if (a.due && b.due && a.due !== b.due) return a.due < b.due ? -1 : 1
|
|
290
|
+
if (a.due && !b.due) return -1
|
|
291
|
+
if (!a.due && b.due) return 1
|
|
292
|
+
return String(a.created).localeCompare(String(b.created))
|
|
293
|
+
})
|
|
294
|
+
closed.sort(
|
|
295
|
+
(a, b) =>
|
|
296
|
+
String(b.closedAt ?? '').localeCompare(String(a.closedAt ?? '')) ||
|
|
297
|
+
String(b.created).localeCompare(String(a.created)),
|
|
298
|
+
)
|
|
299
|
+
return [...open, ...closed]
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** 按 filter 挑出这一类(纯函数,可测)。filter 不认识就当 open。 */
|
|
303
|
+
export function filterNotes(notes, filter = 'open') {
|
|
304
|
+
const f = NOTE_FILTERS.includes(String(filter)) ? String(filter) : 'open'
|
|
305
|
+
const list = (notes ?? []).filter(Boolean)
|
|
306
|
+
if (f === 'all') return sortForList(list)
|
|
307
|
+
if (f === 'open') return sortForList(list.filter((n) => n.status === 'open'))
|
|
308
|
+
if (f === 'due') return sortForList(list.filter((n) => n.status === 'open' && isDue(n.due)))
|
|
309
|
+
return sortForList(list.filter((n) => n.status === f))
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const EMPTY_LIST = {
|
|
313
|
+
open: '桌上没有还没划掉的。',
|
|
314
|
+
due: '没有哪张写着日子。',
|
|
315
|
+
done: '还没有划掉的。',
|
|
316
|
+
dropped: '还没有放下的。',
|
|
317
|
+
all: '桌上什么都没有。',
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* 翻桌子(reading 用,改不了任何东西)。
|
|
322
|
+
*
|
|
323
|
+
* 只列字,不报总数——「你还有 3 条」这种句式谢绝。列不下的写一句「后面还有」,
|
|
324
|
+
* 往后翻用 offset。没日子那种的照旧标出来:它在桌上,但醒来时不会自己出现。
|
|
325
|
+
* @returns {Promise<{ok:boolean, text:string, note:string}>}
|
|
326
|
+
*/
|
|
327
|
+
export async function listNotes(root, { filter = 'open', offset = 0, limit = NOTE_LIST_MAX } = {}) {
|
|
328
|
+
const f = NOTE_FILTERS.includes(String(filter)) ? String(filter) : 'open'
|
|
329
|
+
const off = Number(offset) > 0 ? Math.trunc(Number(offset)) : 0
|
|
330
|
+
const lim = Number(limit) > 0 ? Math.min(Math.trunc(Number(limit)), NOTE_LIST_MAX) : NOTE_LIST_MAX
|
|
331
|
+
try {
|
|
332
|
+
const all = filterNotes(await readNotes(root), f)
|
|
333
|
+
if (!all.length) return { ok: true, text: EMPTY_LIST[f], note: '' }
|
|
334
|
+
const page = all.slice(off, off + lim)
|
|
335
|
+
if (!page.length) return { ok: true, text: '后面没有了。', note: '' }
|
|
336
|
+
const lines = page.map(listLine)
|
|
337
|
+
const rest = all.length - (off + page.length)
|
|
338
|
+
if (off > 0) lines.unshift(`(从第${cnNum(off + 1)}张接着看)`)
|
|
339
|
+
if (rest > 0) lines.push('……后面还有,没一次列完(offset 往后翻)。')
|
|
340
|
+
return { ok: true, text: lines.join('\n'), note: '' }
|
|
341
|
+
} catch (err) {
|
|
342
|
+
return { ok: false, text: '', note: '这会儿翻不开,等一下再试。' }
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** 按 id 或原话找一张;先 id,再原话(没关掉的优先,其次已划掉的)。 */
|
|
347
|
+
function locate(notes, { id, text } = {}) {
|
|
348
|
+
const key = String(id ?? '').trim()
|
|
349
|
+
if (key) {
|
|
350
|
+
const byId = notes.find((n) => n.id === key)
|
|
351
|
+
if (byId) return byId
|
|
352
|
+
}
|
|
353
|
+
const body = String(text ?? '').replace(/\s+/g, ' ').trim()
|
|
354
|
+
return body ? findByText(notes, body) ?? null : null
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* 改一句(写错了)。永不抛。
|
|
359
|
+
*
|
|
360
|
+
* 只改没关掉的那张:划掉/放下了的改不了——回翻里留着当时那句话,改它等于改历史。
|
|
361
|
+
* `due` 不给就不动日子,给空串是去掉日子,给日期是改期。
|
|
362
|
+
* @returns {Promise<{ok:boolean, id:string, note:string, echo?:string}>}
|
|
363
|
+
*/
|
|
364
|
+
export async function updateNote(root, { id, text, newText, due } = {}) {
|
|
365
|
+
const key = String(id ?? '').trim()
|
|
366
|
+
const body = String(text ?? '').replace(/\s+/g, ' ').trim()
|
|
367
|
+
const next = String(newText ?? '').replace(/\s+/g, ' ').trim()
|
|
368
|
+
if (!key && !body) return { ok: false, id: '', note: '要改哪一张?把当时记的那句话告诉我。' }
|
|
369
|
+
if (!next) return { ok: false, id: '', note: '改成什么?把新的那句话告诉我。' }
|
|
370
|
+
if (next.length > NOTE_TEXT_MAX) {
|
|
371
|
+
return { ok: false, id: '', note: `太长了,压到 ${NOTE_TEXT_MAX} 字以内。` }
|
|
372
|
+
}
|
|
373
|
+
const day = due === undefined ? undefined : String(due ?? '').trim()
|
|
374
|
+
if (day !== undefined && day && !isDue(day)) {
|
|
375
|
+
return { ok: false, id: '', note: '日子要写成 2026-11-05 这样,或者给空串把日子去掉。' }
|
|
376
|
+
}
|
|
377
|
+
try {
|
|
378
|
+
const notes = await readNotes(root)
|
|
379
|
+
const hit = locate(notes, { id: key, text: body })
|
|
380
|
+
if (!hit) {
|
|
381
|
+
return { ok: false, id: '', note: '桌上没有这一张。要和当时记的那句话一字不差。' }
|
|
382
|
+
}
|
|
383
|
+
if (hit.status === 'done') return { ok: false, id: '', note: '这张已经划掉了,改不了。' }
|
|
384
|
+
if (hit.status === 'dropped') return { ok: false, id: '', note: '这张已经放下了,改不了。' }
|
|
385
|
+
hit.text = next.slice(0, NOTE_TEXT_MAX)
|
|
386
|
+
if (day !== undefined) hit.due = day
|
|
387
|
+
hit.revisedAt = new Date().toISOString()
|
|
388
|
+
await writeNotes(root, notes)
|
|
389
|
+
return { ok: true, id: hit.id, note: '改好了。', echo: hit.text }
|
|
390
|
+
} catch (err) {
|
|
391
|
+
return { ok: false, id: '', note: '这会儿写不进去,等一下再试。' }
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* 放下(决定不做,并写一句为什么)。永不抛。
|
|
397
|
+
*
|
|
398
|
+
* 跟划掉一样只是改状态、条目一条不删;差别只在多一句 reason,那句必须她自己写。
|
|
399
|
+
* 不检查写得好不好——只要求非空,不然就跟删掉没区别了。
|
|
400
|
+
* @returns {Promise<{ok:boolean, id:string, note:string}>}
|
|
401
|
+
*/
|
|
402
|
+
export async function dropNote(root, { id, text, reason } = {}) {
|
|
403
|
+
const key = String(id ?? '').trim()
|
|
404
|
+
const body = String(text ?? '').replace(/\s+/g, ' ').trim()
|
|
405
|
+
const why = String(reason ?? '').replace(/\s+/g, ' ').trim()
|
|
406
|
+
if (!key && !body) return { ok: false, id: '', note: '要放下哪一张?把当时记的那句话告诉我。' }
|
|
407
|
+
if (!why) return { ok: false, id: '', note: '放下还要说一句为什么——不然就跟删掉没区别了。' }
|
|
408
|
+
if (why.length > NOTE_TEXT_MAX) {
|
|
409
|
+
return { ok: false, id: '', note: `原因太长了,压到 ${NOTE_TEXT_MAX} 字以内。` }
|
|
410
|
+
}
|
|
411
|
+
try {
|
|
412
|
+
const notes = await readNotes(root)
|
|
413
|
+
const hit = locate(notes, { id: key, text: body })
|
|
414
|
+
if (!hit) {
|
|
415
|
+
return { ok: false, id: '', note: '桌上没有这一张。要和当时记的那句话一字不差。' }
|
|
416
|
+
}
|
|
417
|
+
if (hit.status === 'dropped') return { ok: true, id: hit.id, note: '之前就放下了。' }
|
|
418
|
+
if (hit.status === 'done') return { ok: false, id: '', note: '这张已经划掉了,不用再放下。' }
|
|
419
|
+
hit.status = 'dropped'
|
|
420
|
+
hit.reason = why.slice(0, 200)
|
|
421
|
+
hit.closedAt = new Date().toISOString()
|
|
422
|
+
await writeNotes(root, notes)
|
|
423
|
+
return { ok: true, id: hit.id, note: '放下了。' }
|
|
424
|
+
} catch (err) {
|
|
425
|
+
return { ok: false, id: '', note: '这会儿写不进去,等一下再试。' }
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** 这一次是哪件事。没写 action 的按老路走:status=done 或只给 id 是划掉,其余是记一张。 */
|
|
430
|
+
export function noteAction(args = {}) {
|
|
431
|
+
const a = String(args?.action ?? '').trim()
|
|
432
|
+
if (['add', 'list', 'update', 'drop', 'done'].includes(a)) return a
|
|
433
|
+
return isClosing(args) ? 'done' : 'add'
|
|
434
|
+
}
|
|
435
|
+
|
|
250
436
|
/** 这一次是划掉还是记。execute 和 render 用同一个判据,回执才不会和实际做的事对不上。 */
|
|
251
437
|
const isClosing = (args) =>
|
|
252
438
|
String(args?.status ?? '').trim() === 'done' ||
|
|
@@ -259,42 +445,91 @@ export function registerNote(ctx, deps = {}) {
|
|
|
259
445
|
defineTool({
|
|
260
446
|
name: 'her_note',
|
|
261
447
|
description:
|
|
262
|
-
'
|
|
263
|
-
'
|
|
448
|
+
'醒来时先看的那张桌子:记一张还没做的事、看看桌上都有什么、划掉做完的、或者放下决定不做的。' +
|
|
449
|
+
'记的时候给 text(和日子 due,可以不给);划掉把当时记的那句话原样放进 text,再加 status:"done";' +
|
|
450
|
+
'看桌上有什么给 action:"list"(filter 可以选 open / due / done / dropped / all,列不完用 offset 往后翻);' +
|
|
451
|
+
'改一句给 action:"update"(text 指认原来那句,newText 写新的);' +
|
|
452
|
+
'放下给 action:"drop"(同样用 text 指认,reason 写一句为什么)。',
|
|
264
453
|
parameters: toJsonSchema({
|
|
454
|
+
action: {
|
|
455
|
+
type: 'string',
|
|
456
|
+
enum: ['add', 'list', 'update', 'drop', 'done'],
|
|
457
|
+
description: '要做哪件事:记一张(add,不给就是这个)、翻桌子(list)、改一句(update)、放下(drop)、划掉(done)。',
|
|
458
|
+
},
|
|
265
459
|
text: {
|
|
266
460
|
type: 'string',
|
|
267
|
-
description: '
|
|
461
|
+
description: '要记下来的那件事,一句话。划掉、改、放下的时候写当时记的那句话,要一字不差。',
|
|
268
462
|
},
|
|
269
463
|
due: {
|
|
270
464
|
type: 'string',
|
|
271
|
-
description:
|
|
465
|
+
description:
|
|
466
|
+
'哪天该做,写成 2026-11-05。不写就不会在醒来时出现,只能自己 list 看;改一句时给空串是把日子去掉。',
|
|
272
467
|
},
|
|
273
|
-
id: { type: 'string', description: '
|
|
468
|
+
id: { type: 'string', description: '这张纸条的编号。手里没有就不用给,给 text 就行。' },
|
|
274
469
|
status: { type: 'string', enum: ['done'], description: '划掉的时候给 done。' },
|
|
470
|
+
filter: {
|
|
471
|
+
type: 'string',
|
|
472
|
+
enum: ['open', 'due', 'done', 'dropped', 'all'],
|
|
473
|
+
description: 'list 时看哪一类,默认 open(还没关掉的)。',
|
|
474
|
+
},
|
|
475
|
+
offset: { type: 'integer', description: 'list 时从第几张往后翻,默认从最前面。' },
|
|
476
|
+
limit: { type: 'integer', description: 'list 时一次最多列几张,默认 20。' },
|
|
477
|
+
newText: { type: 'string', description: 'update 时改成哪句话。' },
|
|
478
|
+
reason: { type: 'string', description: 'drop 时为什么不做了,必填。' },
|
|
275
479
|
}),
|
|
276
480
|
output: {
|
|
277
481
|
schema: {
|
|
278
482
|
type: 'object',
|
|
279
|
-
properties: {
|
|
483
|
+
properties: {
|
|
484
|
+
ok: { type: 'boolean' },
|
|
485
|
+
id: { type: 'string' },
|
|
486
|
+
note: { type: 'string' },
|
|
487
|
+
echo: { type: 'string' },
|
|
488
|
+
text: { type: 'string' },
|
|
489
|
+
},
|
|
280
490
|
required: ['ok', 'note'],
|
|
281
491
|
},
|
|
282
492
|
// 和 her_leave 同一条规矩:只回方括号标记,不写完整句子、不带"还剩几张"。
|
|
283
493
|
// 划掉不能沿用写入的字:读到「记下了」,她会以为桌上又多了一张。
|
|
494
|
+
// list 例外:它列的本来就是字,不是回执。
|
|
284
495
|
render: (args, out) => {
|
|
285
|
-
const
|
|
286
|
-
|
|
496
|
+
const act = noteAction(args)
|
|
497
|
+
if (act === 'list') {
|
|
498
|
+
return [{ type: 'text', text: out.ok ? out.text : `[没列出来:${out.note}]` }]
|
|
499
|
+
}
|
|
500
|
+
if (act === 'update') {
|
|
501
|
+
return [{ type: 'text', text: out.ok ? '[改好了]' : `[没改:${out.note}]` }]
|
|
502
|
+
}
|
|
503
|
+
if (act === 'drop') {
|
|
504
|
+
return [{ type: 'text', text: out.ok ? '[放下了]' : `[没放下:${out.note}]` }]
|
|
505
|
+
}
|
|
506
|
+
if (act === 'done') {
|
|
507
|
+
return [{ type: 'text', text: out.ok ? '[划掉了]' : `[没划掉:${out.note}]` }]
|
|
508
|
+
}
|
|
509
|
+
if (!out.ok) return [{ type: 'text', text: `[没记下:${out.note}]` }]
|
|
510
|
+
// 没日子的那张以后不会上桌,所以它只在这里被提这一次——这不是提醒,是入账凭证。
|
|
511
|
+
const tail = String(args?.due ?? '').trim() ? '' : '(没写日子,醒来不会出现)'
|
|
512
|
+
return [{ type: 'text', text: `[记下了:${out.echo ?? ''}]${tail}` }]
|
|
287
513
|
},
|
|
288
514
|
},
|
|
289
515
|
async execute(args, exec) {
|
|
290
516
|
const who = guardWrite(deps.tenant, exec, 'her_note')
|
|
291
517
|
if (!who.ok) return { ok: false, id: '', note: who.note }
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
518
|
+
const act = noteAction(args)
|
|
519
|
+
if (act === 'list') {
|
|
520
|
+
const r = await listNotes(root, { filter: args?.filter, offset: args?.offset, limit: args?.limit })
|
|
521
|
+
return { ok: r.ok, id: '', note: r.note, text: r.text }
|
|
522
|
+
}
|
|
523
|
+
if (act === 'update') {
|
|
524
|
+
return updateNote(root, { id: args?.id, text: args?.text, newText: args?.newText, due: args?.due })
|
|
525
|
+
}
|
|
526
|
+
if (act === 'drop') {
|
|
527
|
+
return dropNote(root, { id: args?.id, text: args?.text, reason: args?.reason })
|
|
528
|
+
}
|
|
529
|
+
if (act === 'done') {
|
|
530
|
+
return closeNote(root, { id: args?.id, text: args?.text })
|
|
295
531
|
}
|
|
296
|
-
|
|
297
|
-
return { ok: r.ok, id: r.id, note: r.note }
|
|
532
|
+
return addNote(root, { text: args?.text, due: args?.due })
|
|
298
533
|
},
|
|
299
534
|
}),
|
|
300
535
|
)
|
package/lib/observe.js
CHANGED
|
@@ -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,
|
|
@@ -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
|
// 至少让下一次发生时日志里有一行可对。
|
|
@@ -1602,7 +1611,7 @@ export function registerObserve(ctx, deps = {}) {
|
|
|
1602
1611
|
const said = b.yes === true
|
|
1603
1612
|
const r = decideKeepsake(root, { id: String(b.id ?? ''), yes: said })
|
|
1604
1613
|
logLine('info', ['纪念品:', said ? '准了' : '没带', r.item?.rel ?? '', r.item?.id ?? ''], root)
|
|
1605
|
-
return send(res, r.ok ? 200 : 409, { ok: r.ok, item: r.item ?? null, note: r.note })
|
|
1614
|
+
return send(res, r.ok ? 200 : 409, { ok: r.ok, item: r.item ?? null, note: r.note, ...(r.ok ? {} : { error: r.note }) })
|
|
1606
1615
|
}
|
|
1607
1616
|
|
|
1608
1617
|
// 复核上一次被拒收的那趟(0.6.32):文件还原后重新比对,通过就当场收下。
|
|
@@ -1865,7 +1874,8 @@ export function registerObserve(ctx, deps = {}) {
|
|
|
1865
1874
|
reason: b.reason,
|
|
1866
1875
|
via: 'ui',
|
|
1867
1876
|
})
|
|
1868
|
-
|
|
1877
|
+
// jpost 只认 error:别的地方(IM、别的页面)先答过时,要让人看到原因,不是一个 HTTP 409
|
|
1878
|
+
if (!r.ok) return send(res, 409, { ok: false, why: r.why, error: r.why })
|
|
1869
1879
|
logLine('info', ['请求事件:回答', String(b.askId || ''), String(b.verdict || '')], root)
|
|
1870
1880
|
return send(res, 200, { ok: true })
|
|
1871
1881
|
}],
|
|
@@ -2107,7 +2117,7 @@ export function registerObserve(ctx, deps = {}) {
|
|
|
2107
2117
|
// 空白会话在侧栏里是隐形的(宿主 sessionVisible:blank && id !== current → false)。
|
|
2108
2118
|
// 插件用 API 建的会话不会被设成当前会话,所以要让她先动一轮,侧栏才看得见。
|
|
2109
2119
|
const woke = fresh.ok
|
|
2110
|
-
? await wakeIfBlank(ctx, { sessionId: fresh.sessionId, root, log: (m) => console.log('[dsh-fish]', m), freshOnly: !fresh.reused })
|
|
2120
|
+
? await wakeIfBlank(ctx, { sessionId: fresh.sessionId, root, log: (m) => console.log('[dsh-fish]', m), freshOnly: !fresh.reused, verify: true })
|
|
2111
2121
|
: { woke: false }
|
|
2112
2122
|
send(res, 200, {
|
|
2113
2123
|
ok: true,
|
|
@@ -2141,7 +2151,7 @@ export function registerObserve(ctx, deps = {}) {
|
|
|
2141
2151
|
force: true,
|
|
2142
2152
|
})
|
|
2143
2153
|
const woke = fresh.ok
|
|
2144
|
-
? await wakeIfBlank(ctx, { sessionId: fresh.sessionId, root, log: (m) => console.log('[dsh-fish]', m), freshOnly: !fresh.reused })
|
|
2154
|
+
? await wakeIfBlank(ctx, { sessionId: fresh.sessionId, root, log: (m) => console.log('[dsh-fish]', m), freshOnly: !fresh.reused, verify: true })
|
|
2145
2155
|
: { woke: false }
|
|
2146
2156
|
send(res, fresh.ok ? 200 : 409, {
|
|
2147
2157
|
ok: fresh.ok,
|
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
|
}
|