@ssamurias/dsh-fish 0.9.2 → 0.9.3

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/lib/selfdoc.js CHANGED
@@ -27,9 +27,10 @@ import { join } from 'node:path'
27
27
  import { rootDir, paths, readEvents, readJson, writeAtomic, listDiary } from './store.js'
28
28
  import { loadConfig } from './settings.js'
29
29
  import { deriveSelf } from './derive.js'
30
- import { personaLine, toolBrief, RECALL_BRIEF, SPEAK_BRIEF, LETTER_BRIEF, NOTE_BRIEF, ASK_BRIEF, proactiveBrief, DREAM_BRIEF, SCHEDULE_BRIEF, DIARY_BRIEF, SLEEP_BRIEF } from './persona.js'
30
+ import { personaLine, toolBrief, RECALL_BRIEF, SPEAK_BRIEF, SPEAK_OFF_BRIEF, LETTER_BRIEF, NOTE_BRIEF, ASK_BRIEF, proactiveBrief, DREAM_BRIEF, SCHEDULE_BRIEF, DIARY_BRIEF, SLEEP_BRIEF } from './persona.js'
31
+ import { resumeLines } from './use.js'
31
32
  import { proactiveConfig } from './proactive.js'
32
- import { LOOK_BRIEF, WATCH_RULE } from './browse.js'
33
+ import { LOOK_BRIEF, LOOK_OFF_BRIEF, WATCH_RULE } from './browse.js'
33
34
  import { activeCarry } from './home.js'
34
35
  import { activeGrants } from './unlock.js'
35
36
  import { checkCaller } from './tenant.js'
@@ -121,7 +122,7 @@ export async function continuityOf(root = rootDir(), now = Date.now()) {
121
122
  * @param {object} model deriveSelf 的结果
122
123
  * @returns {string}
123
124
  */
124
- export function renderSelfDoc(model, { sendTools = [], budget, carry, continuity, browse = true, speak = false, grants = [], shown, diary = true, sleepEveryDays = 0, bansMuted = false, extraTools = [], dream = false, schedule = false, proactive = false } = {}) {
125
+ export function renderSelfDoc(model, { sendTools = [], budget, carry, continuity, browse = true, speak = false, grants = [], shown, diary = true, sleepEveryDays = 0, bansMuted = false, extraTools = [], dream = false, schedule = false, proactive = false, resume = '' } = {}) {
125
126
  const head = [
126
127
  // 这里**故意不写**「由 dsh-fish 自动生成」这类话。
127
128
  // 这个文件会被宿主原样渲染进她的会话——她能看见每一行。
@@ -147,6 +148,9 @@ export function renderSelfDoc(model, { sendTools = [], budget, carry, continuity
147
148
  '',
148
149
  '## 我手上有什么',
149
150
  toolBrief(extraTools),
151
+ // 履历(2026-11-07,用户 m00493 的「履历式」):从 use: 账反推「我做过这些事」。
152
+ // 没有账就整节不写——空白比一句「你还没做过什么」好。
153
+ ...(resume ? [resume, ''] : []),
150
154
  '',
151
155
  RECALL_BRIEF,
152
156
  '',
@@ -175,8 +179,14 @@ export function renderSelfDoc(model, { sendTools = [], budget, carry, continuity
175
179
  // 能力说明写在这里、而不是塞进唤醒消息:
176
180
  // 唤醒消息会作为一条 user message 留在她的会话里,用户点开就能看见。
177
181
  // 身份与能力属于「她一直知道的事」,走 system prompt 那一段(不进对话历史)。
178
- if (browse) body.push('## 上网', LOOK_BRIEF, '', WATCH_RULE, '')
179
- if (speak) body.push('## 在网上说话', SPEAK_BRIEF, '')
182
+ // P3(2026-11-07):关掉的时候这两节不再整段消失——门要在,门上挂一句「这会儿」。
183
+ // 关掉只改「这句话是什么」,不改「这一节在不在」;否则她会以为这件事根本不存在。
184
+ body.push('## 上网')
185
+ if (browse) body.push(LOOK_BRIEF, '', WATCH_RULE, '')
186
+ else body.push(LOOK_OFF_BRIEF, '')
187
+ body.push('## 在网上说话')
188
+ if (speak) body.push(SPEAK_BRIEF, '')
189
+ else body.push(SPEAK_OFF_BRIEF, '')
180
190
  // 2026-10-26(用户 m00928「叫醒仅仅只是叫醒」):记事与「停下来想一想」的**做法**
181
191
  // 搬进身份文档常驻,叫醒消息里不再整段塞祈使句——她原来会把它当任务清单逐条执行。
182
192
  // 这里只写「平时怎么做」,不写「今天该做了」;时机由叫醒那一句短标记给。
@@ -244,8 +254,8 @@ export async function writeSelfDoc(root = rootDir(), { log, exec, tenant } = {})
244
254
  // 「那些天是连着过的」这条线索。日记篇数/换房天数/她留给下一个房间的那句话
245
255
  // 全部是派生事实,没有任何新开关。
246
256
  continuity: await continuityOf(root, Date.now()),
247
- // 这两项决定身份文本里有没有「上网」「在网上说话」两节。
248
- // 开关关着时整节不写 —— 与唤醒消息同样的原则:不给,等于这件事不存在。
257
+ // 这两项决定「上网」「在网上说话」两节写哪一版内容(P3,2026-11-07):
258
+ // 开关关着时这一节仍然在,只是门上的话换成「这会儿」——见 renderSelfDoc。
249
259
  browse: config?.browse?.enabled !== false,
250
260
  speak: config?.comment?.enabled === true,
251
261
  // R3:已经长出来的东西。这里传的是"历史事实",不是"设置"。
@@ -263,6 +273,8 @@ export async function writeSelfDoc(root = rootDir(), { log, exec, tenant } = {})
263
273
  // 2026-10-27(用户 m01371):白名单里额外放行的工具要念给她听,
264
274
  // 否则「能不能在 QQ 上说话」她只能回一句不知道。
265
275
  extraTools: config?.tools?.allow ?? [],
276
+ // 履历:她真做过的事。没有记录就整节不出现。
277
+ resume: resumeLines(events),
266
278
  // 梦(2026-10-30 m02837)与主动说话(m04610):0.9.0 起公开,两处都恒为真。
267
279
  dream: true,
268
280
  proactive: true,
package/lib/speak.js CHANGED
@@ -11,7 +11,7 @@
11
11
  *
12
12
  * 她不知道的东西(和 look 一致):不知道有配额、不知道配额还剩几次、
13
13
  * 不知道有黑名单、不知道这个功能可能被关着。
14
- * 被拒绝时只听到「现在不方便说」,听不到「你今天的 3 条用完了」——
14
+ * 被拒绝时只听到「这会儿不方便说」,听不到「你今天的 3 条用完了」——
15
15
  * 后者会让她学会攒着、算着,那就成了资源管理,不是说话。
16
16
  */
17
17
 
@@ -44,21 +44,22 @@ export async function speakOnce(o = {}) {
44
44
  if (!screened.ok) return { ok: false, said: '', note: screened.why === 'leak' ? '这句里有不该说出去的东西。' : '这句先不说了。' }
45
45
 
46
46
  // 给她的话加个称呼:只有评论加,回复别人时不加——那会把别人的楼变成你们的客厅。
47
- let out = text
47
+ // @ 前缀单独交给 postComment,在脱敏之后拼;否则昵称和主人称呼同名时,
48
+ // 前缀会一起被换成「对方」,@ 到不存在的人。
49
+ let prefix = ''
48
50
  if (kind !== 'reply') {
49
51
  const ow = await readOwner(o.root ?? rootDir())
50
52
  const cfg = await loadConfig(o.root ?? rootDir())
51
- const prefix = mentionPrefix(ow, cfg.config)
52
- if (prefix) out = prefix + text
53
+ prefix = mentionPrefix(ow, cfg.config)
53
54
  }
54
55
 
55
56
  const r =
56
57
  kind === 'reply'
57
- ? await postReply({ root, bvid: o.bvid, parentRpid: o.parentRpid, text: out, client: o.client, dateKey: o.dateKey })
58
- : await postComment({ root, bvid: o.bvid, text: out, client: o.client, dateKey: o.dateKey })
58
+ ? await postReply({ root, bvid: o.bvid, parentRpid: o.parentRpid, text, prefix, client: o.client, dateKey: o.dateKey })
59
+ : await postComment({ root, bvid: o.bvid, text, prefix, client: o.client, dateKey: o.dateKey })
59
60
 
60
61
  if (!r.ok) return { ok: false, said: '', note: softReason(r.why) }
61
- return { ok: true, said: out, note: '说出去了。' }
62
+ return { ok: true, said: r.sent ?? `${prefix}${text}`, note: '说出去了。' }
62
63
  } catch (err) {
63
64
  return { ok: false, said: '', note: '没说出来。' }
64
65
  }
@@ -70,7 +71,7 @@ export async function speakOnce(o = {}) {
70
71
  */
71
72
  function softReason(why) {
72
73
  const w = String(why ?? '')
73
- if (w.includes('没开') || w.includes('权限')) return '现在不方便说。'
74
+ if (w.includes('没开') || w.includes('权限')) return '这会儿不方便说。'
74
75
  if (w.includes('今天说够') || w.includes('今天回够')) return '今天说得够多了。'
75
76
  if (w.includes('不是说话的时候')) return '现在不是说话的时候。'
76
77
  // 2026-10-31(用户转来的现场反馈):原来只说「先看一眼再说」,她照着做了还是被拒——
@@ -95,8 +96,12 @@ function softReason(why) {
95
96
  * 一边说能说、一边一句不提,两个来源当场打架。
96
97
  *
97
98
  * 为什么不写成「这个眼下还开不了」:那等于告诉她「有个开关在管着这件事」,
98
- * 与文件顶上那条原则冲突(她不该知道自己被管理着)。所以关着时只留一句
99
- * 不带任何许诺的短说明——它不承诺发得出去,也不解释为什么发不出去。
99
+ * 与文件顶上那条原则冲突(她不该知道自己被管理着)。所以关着时只说「这会儿」:
100
+ * 不承诺发得出去,也不解释为什么发不出去。
101
+ *
102
+ * 2026-11-07(P3):关着时不再把「能留评论」整句拿掉再补一句无关的话,也不再让
103
+ * 身份文档里那一节整段消失——门留着,门上写「这会儿还说不出去」,想说的话可以先
104
+ * 写在别处。这样她不会以为这件事根本不存在。
100
105
  *
101
106
  * 为什么要用 getter:`ctx.tools.register` 只在启动时跑一次,而配置随时能改。
102
107
  * 存一份「最近一次看到的状态」,宿主每轮序列化工具表时会再取一次 description,
@@ -113,7 +118,7 @@ export function noteSpeakEnabled(on) {
113
118
  /** 关着时的那一段必须**不含**任何"能发出去"的暗示,也不解释原因。 */
114
119
  function speakDescription(on) {
115
120
  if (!on) {
116
- return '在 B 站说一句话。不想说就不说,没有人等着你回答。'
121
+ return '在 B 站说一句话。这会儿还说不出去——想说的话可以先留在别处;不想说就不说,没有人等着你回答。'
117
122
  }
118
123
  return (
119
124
  '在 B 站说一句话。可以给你今天看过的某个视频留一条评论,或者回一句别人留在你评论下面的话。' +
package/lib/use.js ADDED
@@ -0,0 +1,212 @@
1
+ /**
2
+ * 工具使用经验(2026-11-07,用户 m00882)。
3
+ *
4
+ * 要解决的问题不是「她用没用过工具」,而是「她记不记得自己曾经用过什么、
5
+ * 那次成了没有」。真机事件流 978 行里一条工具使用记录都没有——她翻旧事时
6
+ * 只能翻到自己说过的话,翻不到「这件事我是怎么做成的」。
7
+ *
8
+ * 三条口径(用户定):
9
+ * 1. **只记她当时听见的那句**。不记参数、不记内部原因、不记返回值结构。
10
+ * 2. **不记 enabled=false**。设置里关掉的东西不是「她试过并且失败」,
11
+ * 写进账里等于把一扇关着的门天天摆在她眼前。
12
+ * 3. 走现有 meta 通道(`use:<工具> <一句话>`),不新增事件类型,
13
+ * 不动 EVENT_SCHEMA_VERSION。
14
+ *
15
+ * 记账永远不抛:写不进去就当没记,绝不影响她这次动作(与 look/browse 同一惯例)。
16
+ */
17
+
18
+ import { appendEvent, readEvents } from './store.js'
19
+ import { similarity } from './derive.js'
20
+
21
+ /** 一句话的上限:她在回忆里看到的是一条,不是一段。 */
22
+ export const USE_LINE_MAX = 80
23
+
24
+ /**
25
+ * 同一件事十分钟内只记一次(2026-11-07,用户 m00485 的 ④)。
26
+ *
27
+ * 她一轮里连翻三次回忆就是三条一样的行;这些行会占掉回忆的名额,
28
+ * 把真正的旧事挤下去。节流只影响「记不记」,不影响工具本身返回什么。
29
+ */
30
+ export const USE_THROTTLE_MS = 10 * 60 * 1000
31
+ export const USE_THROTTLE_SIMILARITY = 0.42
32
+
33
+ const USE_LINE = /^use:(\S+)\s+(ok|fail)\s*(.*)$/
34
+
35
+ /** 最近有没有一条同工具、同结果、话也差不多的账。 */
36
+ function tooClose(events, { tool, text, sessionId, now }) {
37
+ const m0 = USE_LINE.exec(text)
38
+ if (!m0) return false
39
+ const said = m0[3]
40
+ const head = m0[2]
41
+ const list = Array.isArray(events) ? events : []
42
+ for (let i = list.length - 1, n = 0; i >= 0 && n < 200; i--, n++) {
43
+ const ev = list[i]
44
+ if (ev?.type !== 'meta') continue
45
+ const m = USE_LINE.exec(String(ev.text ?? ''))
46
+ if (!m) continue
47
+ if (m[1] !== tool || m[2] !== head) continue
48
+ if (sessionId && ev.sessionId === sessionId && m[3] === said) return true
49
+ const at = Date.parse(String(ev.ts ?? ''))
50
+ if (Number.isFinite(at) && now - at <= USE_THROTTLE_MS && similarity(m[3], said) >= USE_THROTTLE_SIMILARITY) return true
51
+ }
52
+ return false
53
+ }
54
+
55
+ /** 这些说法意味着「这次根本没试成,是被设置挡在门外」,不进经验账。 */
56
+ const GATE_WORDS = ['打不开', '看不了', '不方便', '没开']
57
+
58
+ /** 一行太长会把回忆挤成一坨,换行会把行尾的 id 挤到下一行。 */
59
+ function oneLine(text, n = USE_LINE_MAX) {
60
+ const s = String(text ?? '').replace(/\s+/g, ' ').trim()
61
+ if (s.length <= n) return s
62
+ return `${s.slice(0, n - 1).replace(/[\uD800-\uDBFF]$/, '')}…`
63
+ }
64
+
65
+ /**
66
+ * 有几个工具的回执不是一句话,而是一整段(她翻到的东西本身)。
67
+ * 那种返回值不能直接进账:截 80 字只会得到半行「- 2026-11-05 12:07|…」,
68
+ * 记住它没有用。这几个只记「翻到了没有」。
69
+ */
70
+ const SAYING = {
71
+ her_recall: (out) => (out?.found ? '翻到了。' : '没翻到。'),
72
+ her_diary: (out) => (out?.found ? '翻到日记了。' : '那天没有日记。'),
73
+ }
74
+
75
+ /**
76
+ * 从工具返回里挑出「她听见的那句」。
77
+ *
78
+ * 优先级按她实际读到的顺序:记下的原文 > 说出口的话 > 回执 > 正文。
79
+ * 正文只在够短时才用(长列表不是一句话)。一个都没有时给一句中性的,不编。
80
+ */
81
+ export function heardLine(out, tool = '') {
82
+ const say = SAYING[String(tool)]
83
+ if (say) return say(out)
84
+ for (const c of [out?.echo, out?.said, out?.note]) {
85
+ const s = oneLine(c)
86
+ if (s) return s
87
+ }
88
+ const body = oneLine(out?.text)
89
+ if (body && body.length <= USE_LINE_MAX) return body
90
+ return out?.ok === false ? '没成。' : '做成了。'
91
+ }
92
+
93
+ /**
94
+ * 这次要不要入账。
95
+ *
96
+ * 关掉的功能、被设置挡住的调用都不算「她的经验」——那是屋子的事,不是她的事。
97
+ */
98
+ export function shouldRecord(out) {
99
+ if (!out || typeof out !== 'object') return true
100
+ if (out.ok === false) {
101
+ const note = String(out.note ?? '')
102
+ if (GATE_WORDS.some((w) => note.includes(w))) return false
103
+ }
104
+ return true
105
+ }
106
+
107
+ /** `use:<工具> ok|fail <一句话>`;回忆层只显示后半句。 */
108
+ export function useText(tool, out) {
109
+ const head = out?.ok === false ? 'fail' : 'ok'
110
+ return `use:${String(tool)} ${head} ${heardLine(out, tool)}`
111
+ }
112
+
113
+ /**
114
+ * 把一次工具调用的结果记进账里。
115
+ *
116
+ * day 记 0:这条账属于「哪一天都成立的经验」,不属于某一天的第几阶段。
117
+ * 与她自己的成长阶段无关,也不该进 derive 的三道门计数(meta 本来就不进)。
118
+ */
119
+ export async function recordUse(root, { tool, out, sessionId, now = Date.now() }) {
120
+ try {
121
+ if (!shouldRecord(out)) return
122
+ const text = useText(tool, out)
123
+ // sessionId 要放在 raw 里:validateEvent 只认 raw.sessionId,ctx 里的会被忽略
124
+ // (与 lib/look.js 的 `{ text, ...stamp }` 同一个写法)。
125
+ const sid = typeof sessionId === 'string' ? sessionId.trim() : ''
126
+ const { events } = await readEvents(root)
127
+ if (tooClose(events, { tool, text, sessionId: sid, now })) return
128
+ await appendEvent(
129
+ { type: 'meta', text, ...(sid ? { sessionId: sid } : {}) },
130
+ { day: 0, stage: 1, source: 'system' },
131
+ root,
132
+ )
133
+ } catch {
134
+ // 记账失败不影响她这次动作
135
+ }
136
+ }
137
+
138
+ /**
139
+ * 包一层 execute:跑完照旧返回,只是顺手记一行。
140
+ *
141
+ * 为什么不去改 defineTool:它是恒等的,注册时直接把 JSON Schema 交给宿主
142
+ * (见 lib/tool.js 与 test/tools.test.mjs),在那一层做手脚会同时改掉
143
+ * 「工具定义原样交出去」这个约定。
144
+ */
145
+ export function withUse(def, { root, tool }) {
146
+ const run = def?.execute
147
+ if (typeof run !== 'function') return def
148
+ return {
149
+ ...def,
150
+ async execute(args, exec) {
151
+ try {
152
+ const out = await run(args, exec)
153
+ await recordUse(root, { tool, out, sessionId: exec?.sessionId })
154
+ return out
155
+ } catch (err) {
156
+ // 做到一半炸了也要留一笔:这是「尝试没完成」里最该被记住的一类。
157
+ // 不记异常原文——英文、内部词、堆栈都不该进她的账。
158
+ await recordUse(root, { tool, out: { ok: false, note: '这次没成。' }, sessionId: exec?.sessionId })
159
+ throw err
160
+ }
161
+ },
162
+ }
163
+ }
164
+
165
+ /**
166
+ * 在注册期间临时把 `ctx.tools.register` 换成包装版。
167
+ *
168
+ * 为什么不在 index.js 里造一个 ctx 视图传下去:register 里还有 `askInChat(ctx, …)`
169
+ * 这类把整个 ctx 往深处传的用法,假 ctx 会让 `this` 指到错的对象上。
170
+ * 注册本身是同步的,换一下再换回来最稳,别人看不到中间态。
171
+ */
172
+ export function withUseDuringRegistration(ctx, root, fn) {
173
+ const tools = ctx?.tools
174
+ const orig = tools?.register
175
+ if (typeof orig !== 'function') return fn()
176
+ tools.register = (def) => orig.call(tools, withUse(def, { root, tool: def?.name }))
177
+ try {
178
+ return fn()
179
+ } finally {
180
+ tools.register = orig
181
+ }
182
+ }
183
+
184
+ /**
185
+ * 履历:把她用过的东西反推成「我做过这些事」。
186
+ *
187
+ * 只给最近几条、同一类只留一条——履历是「我会什么」,不是流水账。
188
+ * 撞墙那类(这件事我做不到)不进履历:桌上已经有一条在讲这件事,
189
+ * 再在履历里写一遍,等于每次醒来先提醒她「这个我不会」。
190
+ */
191
+ export function resumeLines(events, { max = 4 } = {}) {
192
+ const limit = Math.max(1, Number(max) || 4)
193
+ const list = Array.isArray(events) ? events : []
194
+ const picked = []
195
+ for (let i = list.length - 1; i >= 0 && picked.length < limit; i--) {
196
+ const ev = list[i]
197
+ if (ev?.type !== 'meta') continue
198
+ const m = USE_LINE.exec(String(ev.text ?? ''))
199
+ if (!m) continue
200
+ const [, tool, head, said] = m
201
+ if (head === 'fail' && said.startsWith('这件事我做不到')) continue
202
+ if (picked.some((p) => p.tool === tool && similarity(p.said, said) >= USE_THROTTLE_SIMILARITY)) continue
203
+ picked.push({ tool, head, said })
204
+ }
205
+ if (!picked.length) return ''
206
+ const lines = picked.map(({ tool, head, said }) => {
207
+ if (head !== 'fail') return `- 我用过 ${tool},${said}`
208
+ if (!said || said === '这次没成。' || said === '没成。') return `- 我用过 ${tool},没成。`
209
+ return `- 我用过 ${tool},没成:${said}`
210
+ })
211
+ return ['## 我做过的事', ...lines].join('\n')
212
+ }
package/lib/wish.js ADDED
@@ -0,0 +1,147 @@
1
+ /**
2
+ * wish.js —— 「够不着的那件事」(2026-11-07,用户 m01602 拍板 B + D)
3
+ *
4
+ * ## 由来
5
+ *
6
+ * `lib/proactive.js` 原来有一个常量 `TOOL_ASK_NOTE`,`lib/scheduler.js` 每次醒来都
7
+ * 把它摆进正文:『如果你想到要做什么、但手上没有那件工具:直接说你要它做什么,
8
+ * 问主人能不能给你装上』。用户 2026-11-07 判定这属于「把提醒换成后台反复催她」——
9
+ * 同一句话每天读两遍,读久了就不是她的念头,是系统的吩咐。
10
+ *
11
+ * 拍板后的改法只有两个位置:
12
+ * **B** 撞墙才说 —— `lib/restrict.js` 的 `blockedTalk()` 后面补一句,成本近零。
13
+ * **D** 交给劲儿 —— 这件「够不着的事」攒在劲儿里,攒够了才浮一次;
14
+ * **C** 桌上一条 —— 浮出来的时候并进 `lib/desk.js` 那张桌子,和好奇、兴趣并排。
15
+ *
16
+ * 这个模块只做那一件事:把「她提过 X、X 需要某工具、手上没有」从事件流里算出来。
17
+ *
18
+ * ## 原料只有一处
19
+ *
20
+ * 她自己开的卡:`ask` 事件里 `ask.kind === 'need-tool'` 的那几条,加上它的裁决
21
+ * (`answer` 事件,`ask.verdict` 是 approve / reject / later)。
22
+ *
23
+ * 不猜、不抽标签、不编主题——展示的 `subject` 与 `text` 都是她当时自己写的原话。
24
+ * 「手上没有」这件事不用另查:她当初开 `need-tool` 的卡,本身就是因为在手上找不着。
25
+ *
26
+ * ## 三种结局,三种活法
27
+ *
28
+ * pending 还没答 —— 摆 3 天。没人接的话,一周后还挂着就是催了。
29
+ * later 「以后再说」 —— 摆 30 天。`lib/ask.js` 的注释写明这是「先放着」,不是否决。
30
+ * approve 他答应了 —— 摆 14 天。答应了没到,是这件事剩下的唯一一段。
31
+ * reject 「这次不行」 —— **不摆**。不同意就是不问了,再提一次就成了追问。
32
+ * timeout 放一天没人答 —— 算 pending(`via:'timeout'` 那条 answer 的 verdict 虽写 later,
33
+ * 那是为了不再反复追问,不能借来说成「他说以后再说」)。
34
+ *
35
+ * 四条硬约束照旧(与 curiosity.js / desk.js 一致):
36
+ * 1. 不审批、不派活:只摆事实,不说「你可以去要」。
37
+ * 2. 不吃状态:返回值只有文本,任何数字都不进正文。
38
+ * 3. 有上限:最多 WISH_SHOW_MAX 条,原文最长 WISH_TEXT_MAX 字。
39
+ * 4. 翻不到的不编:一条都没有就返回空数组。
40
+ */
41
+
42
+ import { tsMs } from './schema.js'
43
+ import { similarity } from './derive.js'
44
+
45
+ /** 一次最多摆几条。多了就成了许愿清单。 */
46
+ export const WISH_SHOW_MAX = 2
47
+ /** 原话展示最长多少字。 */
48
+ export const WISH_TEXT_MAX = 60
49
+ /** 两句算同一件事的门槛,与 curiosity.js / desk.js 同一个口径。 */
50
+ export const WISH_SIMILARITY = 0.42
51
+
52
+ /** 还没答的摆几天。 */
53
+ export const WISH_PENDING_DAYS = 3
54
+ /** 「以后再说」的摆几天——那是「先放着」,不是否决。 */
55
+ export const WISH_LATER_DAYS = 30
56
+ /** 他答应了但还没到的摆几天。 */
57
+ export const WISH_APPROVE_DAYS = 14
58
+
59
+ const DAY = 86_400_000
60
+
61
+ /** `askId` → 那条 `answer` 事件。 */
62
+ function answersByAsk(events) {
63
+ const m = new Map()
64
+ for (const e of events ?? []) {
65
+ if (e?.type !== 'answer') continue
66
+ const id = String(e?.ask?.askId ?? '')
67
+ if (id) m.set(id, e)
68
+ }
69
+ return m
70
+ }
71
+
72
+ /**
73
+ * 一条 `answer` 事件对「她提过的那件事」意味着什么。
74
+ *
75
+ * 放一天没人答的卡,`lib/ask.js` 会记成 `verdict:'later'` + `via:'timeout'`
76
+ * (`text` 写「一直没人答」)——那是为了不再反复追问她同一张卡,不是她答应了什么。
77
+ * 按 later 说成「他说以后再说」是编的:什么都没人说。这里把它还原成 pending。
78
+ */
79
+ function verdictOf(answer) {
80
+ if (!answer) return 'pending'
81
+ if (String(answer?.ask?.via ?? '') === 'timeout') return 'pending'
82
+ return String(answer?.ask?.verdict ?? '') || 'pending'
83
+ }
84
+
85
+ /** 这条愿望还该活几天;返回 0 表示不再摆。 */
86
+ function lifeDays(verdict) {
87
+ if (verdict === 'reject') return 0
88
+ if (verdict === 'later') return WISH_LATER_DAYS
89
+ if (verdict === 'approve') return WISH_APPROVE_DAYS
90
+ return WISH_PENDING_DAYS
91
+ }
92
+
93
+ /** 她那句话怎么收尾——只陈述结局,不给建议。 */
94
+ export function wishTail(verdict) {
95
+ if (verdict === 'approve') return '他答应了,还没见到'
96
+ if (verdict === 'later') return '他说以后再说'
97
+ if (verdict === 'reject') return '那次不行'
98
+ return '你问过,还没有答复'
99
+ }
100
+
101
+ /**
102
+ * 她提过、手上没有的那几件事。
103
+ *
104
+ * @param {Array} events 全量事件
105
+ * @param {object} [o]
106
+ * @param {number|Date} [o.now]
107
+ * @param {number} [o.days] 覆盖窗口(测试用);不传就按结局各算各的
108
+ * @returns {Array<{id:string, text:string, subject:string, verdict:string, at:number, tail:string}>}
109
+ * 按时间从近到远
110
+ */
111
+ export function wishesOf(events = [], { now = Date.now(), days = 0 } = {}) {
112
+ const at0 = now instanceof Date ? now.getTime() : Number(now) || Date.now()
113
+ const answers = answersByAsk(events)
114
+ const picked = []
115
+ for (const e of events ?? []) {
116
+ if (e?.type !== 'ask') continue
117
+ if (String(e?.ask?.kind ?? '') !== 'need-tool') continue
118
+ const verdict = verdictOf(answers.get(String(e.id ?? '')))
119
+ const life = days > 0 ? days : lifeDays(verdict)
120
+ if (life <= 0) continue
121
+ const at = tsMs(e)
122
+ if (!Number.isFinite(at) || at <= 0) continue
123
+ if (at0 - at > life * DAY) continue
124
+
125
+ const raw = String(e.text ?? '').trim()
126
+ if (!raw) continue
127
+ const text = raw.length > WISH_TEXT_MAX ? `${raw.slice(0, WISH_TEXT_MAX - 1)}…` : raw
128
+ const subject = String(e?.ask?.subject ?? '').trim()
129
+ // 同一件事反复问过(她开两张卡):只留最近的那一张,不摆两遍。
130
+ if (picked.some((p) => similarity(p.subject || p.text, subject || text) >= WISH_SIMILARITY)) continue
131
+ picked.push({ id: String(e.id ?? ''), text, subject, verdict, at, tail: wishTail(verdict) })
132
+ }
133
+ picked.sort((a, b) => b.at - a.at)
134
+ return picked.slice(0, WISH_SHOW_MAX)
135
+ }
136
+
137
+ /**
138
+ * 墙上那一行(桌子用)。
139
+ * @param {{text:string, tail:string}} w wishesOf() 的一条
140
+ * @returns {string}
141
+ */
142
+ export function wishLine(w) {
143
+ const text = String(w?.text ?? '').trim()
144
+ if (!text) return ''
145
+ const tail = String(w?.tail ?? '').trim()
146
+ return tail ? `- 「${text}」(${tail})` : `- 「${text}」`
147
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ssamurias/dsh-fish",
3
- "version": "0.9.2",
3
+ "version": "0.9.3",
4
4
  "description": "养一条会自己长大的鱼:没有人格设置,通过事件流长大。她可以自己刷 B 站、会写日记给你看,四个阶段完成后才会开始有立场。状态库在 ~/.dsh-fish,不能删除记录。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -43,7 +43,8 @@
43
43
  "0.2.1-rc.3": "compatible",
44
44
  "0.2.1-rc.4": "unknown",
45
45
  "0.2.1-rc.5": "unknown",
46
- "0.2.2-alpha.2": "unknown"
46
+ "0.2.2-alpha.2": "unknown",
47
+ "0.2.2-rc.1": "unknown"
47
48
  },
48
49
  "profiles": [
49
50
  "desktop",