@ssamurias/dsh-fish 0.9.1 → 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/AGENTS.md +27 -2
- package/CHANGELOG.md +94 -0
- package/README.md +8 -2
- package/RELEASE-NOTES-0.9.2.md +46 -0
- package/RELEASE-NOTES-0.9.3.md +91 -0
- package/RELEASE.md +6 -5
- package/lib/absence.js +41 -27
- package/lib/anchor.js +57 -0
- package/lib/ask.js +73 -25
- package/lib/askinchat.js +83 -1
- package/lib/browse.js +8 -0
- package/lib/capture.js +19 -1
- package/lib/cardanswer.js +84 -0
- package/lib/client.js +90 -13
- package/lib/comment.js +189 -30
- package/lib/deliver.js +46 -0
- package/lib/derive.js +14 -11
- package/lib/desk.js +33 -1
- package/lib/drive.js +19 -3
- package/lib/events.js +25 -1
- package/lib/gapwall.js +73 -0
- package/lib/generator.js +2 -1
- package/lib/grant.js +18 -2
- package/lib/home.js +55 -7
- package/lib/identity.js +111 -14
- package/lib/index.js +104 -54
- package/lib/keep.js +21 -9
- package/lib/keepsake.js +20 -13
- package/lib/note.js +255 -20
- package/lib/observe.js +9 -5
- package/lib/persona.js +47 -33
- package/lib/proactive.js +45 -16
- package/lib/quality.js +32 -8
- package/lib/recall.js +33 -2
- package/lib/redact.js +70 -0
- package/lib/restrict.js +40 -2
- package/lib/rules.js +1 -1
- package/lib/say.js +79 -13
- package/lib/scheduler.js +107 -41
- package/lib/schema.js +18 -0
- package/lib/selfdoc.js +19 -7
- package/lib/speak.js +16 -11
- package/lib/status.js +25 -3
- package/lib/turns.js +42 -1
- package/lib/use.js +212 -0
- package/lib/wish.js +147 -0
- package/package.json +3 -2
package/lib/turns.js
CHANGED
|
@@ -54,6 +54,7 @@ import { refreshSelfDoc } from './selfdoc.js'
|
|
|
54
54
|
import { growthDay, stageOf } from './derive.js'
|
|
55
55
|
import { hearTurn } from './hear.js'
|
|
56
56
|
import { stripToolMarkup, hasToolMarkup } from './markup.js'
|
|
57
|
+
import { clearWakeSectionFor } from './identity.js'
|
|
57
58
|
|
|
58
59
|
/**
|
|
59
60
|
* 我们自己 prompt 进去的消息,rpcId 会带这些前缀。
|
|
@@ -240,6 +241,9 @@ function cut(s) {
|
|
|
240
241
|
return t.length > TURN_TEXT_MAX ? t.slice(0, TURN_TEXT_MAX) + '…' : t
|
|
241
242
|
}
|
|
242
243
|
|
|
244
|
+
/** 已经挂过观察者的宿主上下文(同一进程只挂一次,见 registerTurns) */
|
|
245
|
+
const TURNS_BOUND = new WeakSet()
|
|
246
|
+
|
|
243
247
|
/**
|
|
244
248
|
* 注册"侧栏轮数"观察者。
|
|
245
249
|
*
|
|
@@ -252,6 +256,30 @@ export function registerTurns(ctx, deps = {}) {
|
|
|
252
256
|
const log = deps.log ?? (() => {})
|
|
253
257
|
if (typeof ctx?.on !== 'function') return () => {}
|
|
254
258
|
|
|
259
|
+
/**
|
|
260
|
+
* 2026-11-06(外部日志:被占用的实例照样往共享事件流里写 `seen:`)。
|
|
261
|
+
*
|
|
262
|
+
* 观测者以前是**无条件**写盘的:它看到「他来过」就 appendEvent,从不过问这个
|
|
263
|
+
* 家是不是自己的。于是同一个数据目录被两个实例开着时,只读那一侧也在写——
|
|
264
|
+
* 日志里那三条同一毫秒的 `seen:` 就是两个写者各写一遍。
|
|
265
|
+
*
|
|
266
|
+
* 这里和工具侧用同一道闸(tenant.js 的 guardWrite):房主身份 ok===false 时
|
|
267
|
+
* 一句都不写。tenant 是**活引用**(占用权丢了会原地改写成 ok:false),
|
|
268
|
+
* 所以必须在每次写之前现查,不能在注册时查一次就完。
|
|
269
|
+
*/
|
|
270
|
+
const tenant = deps.tenant ?? null
|
|
271
|
+
const writable = () => !(tenant && tenant.ok === false)
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* 同一份宿主上下文只挂一次。
|
|
275
|
+
*
|
|
276
|
+
* 2026-11-06:插件被同一个进程加载两遍时(两个入口 / bundles 里重复),
|
|
277
|
+
* 两个观察者各有各的节流记忆,同一条人话会写出两条同一毫秒的 `seen:`——
|
|
278
|
+
* 记账行一重复,事件流就再也不能当证据用。第二次注册直接交白卷。
|
|
279
|
+
*/
|
|
280
|
+
if (TURNS_BOUND.has(ctx)) return () => {}
|
|
281
|
+
TURNS_BOUND.add(ctx)
|
|
282
|
+
|
|
255
283
|
/** 正在等 turn/end 的那一轮:{ sessionId, msgs:[], sinceId, at } */
|
|
256
284
|
let pending = null
|
|
257
285
|
/** 正在异步认来源的那条人话(见 takeTurnEnd:事件跟得太紧时要等它) */
|
|
@@ -289,6 +317,8 @@ export function registerTurns(ctx, deps = {}) {
|
|
|
289
317
|
* 非 HER_TYPES 的类型,写成 tool 会被 schema 悄悄拒掉。
|
|
290
318
|
*/
|
|
291
319
|
async function markSeen() {
|
|
320
|
+
// 2026-11-06:写盘之前先过所有权闸门(被占用的实例一条都不许写)。
|
|
321
|
+
if (!writable()) return
|
|
292
322
|
lastSeenAt = Date.now()
|
|
293
323
|
await appendEvent(
|
|
294
324
|
{ type: 'meta', text: `seen:${new Date(lastSeenAt).toISOString()}` },
|
|
@@ -301,6 +331,16 @@ export function registerTurns(ctx, deps = {}) {
|
|
|
301
331
|
|
|
302
332
|
/** 收到真人消息:只认"她住的那个会话",并把这一轮开始前的事件 id 记下来 */
|
|
303
333
|
async function onHuman(sessionId, data) {
|
|
334
|
+
if (!writable()) return
|
|
335
|
+
// 2026-11-06(用户报「叫醒之后她就不说话了」):他一开口,上一次叫醒留下的
|
|
336
|
+
// 「处境」当场作废。
|
|
337
|
+
//
|
|
338
|
+
// 处境原本只该出现在**她自己醒的那一轮**里,靠 turn/end 撤。但 turn/end 不是
|
|
339
|
+
// 一定能认到(监听按会话挂、重启会丢、两个渠道挨得近时也会错号),那一段
|
|
340
|
+
// 「这一轮是你自己的,不用回应任何人;不想说就什么都别写」就留在 system prompt
|
|
341
|
+
// 里,接下来他说的每一句都带着它——她就只思考、不开口,而且连续多轮都这样。
|
|
342
|
+
// 人真的说话了,这段就不该还在;清得比 turn/end 早一步也不会出错。
|
|
343
|
+
try { clearWakeSectionFor(sessionId) } catch { /* ignore */ }
|
|
304
344
|
const home = await homeStatus(root)
|
|
305
345
|
if (!home.sessionId) return
|
|
306
346
|
if (home.sessionId !== sessionId) {
|
|
@@ -385,7 +425,7 @@ export function registerTurns(ctx, deps = {}) {
|
|
|
385
425
|
|
|
386
426
|
/** turn/end:结账 */
|
|
387
427
|
async function settle(p) {
|
|
388
|
-
if (stopped) return
|
|
428
|
+
if (stopped || !writable()) return
|
|
389
429
|
if (busy) {
|
|
390
430
|
log('turns · 上一轮还没结完,这次不记')
|
|
391
431
|
return
|
|
@@ -580,6 +620,7 @@ export function registerTurns(ctx, deps = {}) {
|
|
|
580
620
|
return () => {
|
|
581
621
|
stopped = true
|
|
582
622
|
pending = null
|
|
623
|
+
TURNS_BOUND.delete(ctx)
|
|
583
624
|
try {
|
|
584
625
|
if (typeof off === 'function') off()
|
|
585
626
|
} catch {
|
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.
|
|
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",
|