@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/anchor.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* anchor.js —— 把一件事挂回她当时那句话(2026-11-07,用户 m00493 的 ⑤)。
|
|
3
|
+
*
|
|
4
|
+
* 「用过 her_note,记下了:…」这句话本身没有来路。翻旧事时她看不出那次是为了
|
|
5
|
+
* 什么——是回一句话,还是记一件刚发生的事。而事件流里那笔账旁边的她那句话
|
|
6
|
+
* 就是答案:同一个 sessionId,就在调用之前几分钟。
|
|
7
|
+
*
|
|
8
|
+
* 只读事件流,不写、不改。找不到就返回空串,绝不编一句。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { HER_TYPES } from './schema.js'
|
|
12
|
+
|
|
13
|
+
/** 往前找多久:一轮里她一句话说完就动手,超过十分钟多半是别的事了。 */
|
|
14
|
+
export const ANCHOR_WINDOW_MS = 10 * 60 * 1000
|
|
15
|
+
/** 锚点只用来认路,不是复述:一句问话的开头足够。 */
|
|
16
|
+
export const ANCHOR_TEXT_MAX = 40
|
|
17
|
+
|
|
18
|
+
const clip = (text, n) => (text.length > n ? `${text.slice(0, n - 1).replace(/[\uD800-\uDBFF]$/, '')}…` : text)
|
|
19
|
+
|
|
20
|
+
function tsOf(ev) {
|
|
21
|
+
const n = Date.parse(String(ev?.ts ?? ''))
|
|
22
|
+
return Number.isFinite(n) ? n : 0
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 她这次动手之前,最近说过的、属于她自己的一句话。
|
|
27
|
+
*
|
|
28
|
+
* @param {object[]} events 全量事件
|
|
29
|
+
* @param {string} sessionId 这次调用的会话;没有就不锚(宁可没有来路,也别挂错人)
|
|
30
|
+
* @param {number|string|Date} before 调用发生的时刻
|
|
31
|
+
* @param {{windowMs?:number, max?:number}} [o]
|
|
32
|
+
* @returns {string} 已截断的一句话;找不到是空串
|
|
33
|
+
*/
|
|
34
|
+
export function anchorOf(events, sessionId, before, o = {}) {
|
|
35
|
+
const sid = String(sessionId ?? '').trim()
|
|
36
|
+
if (!sid) return ''
|
|
37
|
+
const at = before instanceof Date
|
|
38
|
+
? before.getTime()
|
|
39
|
+
: typeof before === 'number'
|
|
40
|
+
? before
|
|
41
|
+
: Date.parse(String(before ?? ''))
|
|
42
|
+
if (!Number.isFinite(at)) return ''
|
|
43
|
+
const windowMs = Math.max(0, Number(o.windowMs ?? ANCHOR_WINDOW_MS))
|
|
44
|
+
const max = Math.max(1, Number(o.max ?? ANCHOR_TEXT_MAX))
|
|
45
|
+
const types = new Set(HER_TYPES)
|
|
46
|
+
let best = null
|
|
47
|
+
for (const ev of Array.isArray(events) ? events : []) {
|
|
48
|
+
if (!ev || String(ev.sessionId ?? '') !== sid) continue
|
|
49
|
+
if (!types.has(String(ev.type))) continue
|
|
50
|
+
const ts = tsOf(ev)
|
|
51
|
+
if (!ts || ts > at || at - ts > windowMs) continue
|
|
52
|
+
const text = String(ev.text ?? '').replace(/\s+/g, ' ').trim()
|
|
53
|
+
if (!text) continue
|
|
54
|
+
if (!best || ts > best.ts) best = { ts, text }
|
|
55
|
+
}
|
|
56
|
+
return best ? clip(best.text, max) : ''
|
|
57
|
+
}
|
package/lib/ask.js
CHANGED
|
@@ -27,7 +27,9 @@
|
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
29
|
import { toJsonSchema } from './toolschema.js'
|
|
30
|
-
import {
|
|
30
|
+
import { appendFile } from 'node:fs/promises'
|
|
31
|
+
|
|
32
|
+
import { rootDir, readEvents, appendEvent, paths } from './store.js'
|
|
31
33
|
import { loadConfig, updateConfig, dateKey } from './settings.js'
|
|
32
34
|
import { deriveSelf } from './derive.js'
|
|
33
35
|
import { defineTool } from './tool.js'
|
|
@@ -43,13 +45,16 @@ export const ASK_KINDS = Object.freeze(['need-file', 'need-tool', 'time', 'rest'
|
|
|
43
45
|
export const ASK_PER_DAY = 1
|
|
44
46
|
export const ASK_PER_WEEK = 3
|
|
45
47
|
|
|
48
|
+
/** kind=time 的时段,和 schema.js 存得下的一样。 */
|
|
49
|
+
const HHMM = /^([01]\d|2[0-3]):[0-5]\d$/
|
|
50
|
+
|
|
46
51
|
/** 超过这么久还没人答,记成 later(以后再说),不占着「待处理」。 */
|
|
47
52
|
export const ASK_TTL_MS = 24 * 3600 * 1000
|
|
48
53
|
|
|
49
54
|
/** 默认的两个选项。第一个是同意,第二个是不同意。 */
|
|
50
55
|
const DEFAULT_OPTIONS = ['可以', '这次不行']
|
|
51
56
|
|
|
52
|
-
const VIA_LABEL = { chat: '会话里答的', ui: '观测台答的', timeout: '一直没人答', tool: '工具里答的' }
|
|
57
|
+
const VIA_LABEL = { chat: '会话里答的', ui: '观测台答的', timeout: '一直没人答', tool: '工具里答的', relay: '别的插件转回来的' }
|
|
53
58
|
|
|
54
59
|
/** 从事件流里拿全部 ask / answer。 */
|
|
55
60
|
export function asksOf(events = []) {
|
|
@@ -82,7 +87,8 @@ export function pendingAsks(events = [], now = Date.now()) {
|
|
|
82
87
|
/** 配额:今天问过没有、最近七天问过几次。 */
|
|
83
88
|
export function quotaOf(events = [], { now = Date.now(), dk = dateKey() } = {}) {
|
|
84
89
|
const asks = asksOf(events)
|
|
85
|
-
|
|
90
|
+
// 真的事件上 day 是成长第几天(一个整数),不是日期,只能按时间算。
|
|
91
|
+
const today = asks.filter((e) => dateKey(new Date(tsMs(e) || now)) === dk).length
|
|
86
92
|
const week = asks.filter((e) => now - tsMs(e) < 7 * 24 * 3600 * 1000).length
|
|
87
93
|
return {
|
|
88
94
|
day: today,
|
|
@@ -94,15 +100,6 @@ export function quotaOf(events = [], { now = Date.now(), dk = dateKey() } = {})
|
|
|
94
100
|
}
|
|
95
101
|
}
|
|
96
102
|
|
|
97
|
-
/**
|
|
98
|
-
* 事件上没写 day 时(老夹具、单测直接构造)用本地日期补一个。
|
|
99
|
-
* 真实写入的事件都带 day(appendEvent 由 deriveSelf 算),这里只是兜底。
|
|
100
|
-
*/
|
|
101
|
-
function dayKeyOf(ev, now = Date.now()) {
|
|
102
|
-
if (ev?.day) return String(ev.day)
|
|
103
|
-
return dateKey(new Date(tsMs(ev) || now))
|
|
104
|
-
}
|
|
105
|
-
|
|
106
103
|
/** 她今天请过假吗(rest 被同意后写一条 meta)。 */
|
|
107
104
|
export function restedToday(events = [], dk = dateKey()) {
|
|
108
105
|
return events.some((e) => e?.type === 'meta' && String(e.text ?? '').startsWith(`rest:${dk}`))
|
|
@@ -132,14 +129,16 @@ export function askView(events = [], now = Date.now()) {
|
|
|
132
129
|
|
|
133
130
|
/** 选项列表(去掉空串,最多三个,保证第一个是「可以」)。 */
|
|
134
131
|
function optionsOf(raw) {
|
|
135
|
-
|
|
132
|
+
// 事件里每个选项只存 24 个字(schema.js)。卡上的字要和账上一样,隔很久送回来的回答才对得上。
|
|
133
|
+
const list = Array.isArray(raw) ? raw.map((x) => String(x ?? '').trim().slice(0, 24).replace(/[\uD800-\uDBFF]$/, '').trim()).filter(Boolean).slice(0, 3) : []
|
|
136
134
|
return list.length ? list : DEFAULT_OPTIONS.slice()
|
|
137
135
|
}
|
|
138
136
|
|
|
139
137
|
/** 裁决落地:只有 approve 会动东西,而且只动 time / rest 两种。 */
|
|
140
138
|
async function applyVerdict(root, askEv, verdict, { log = () => {} } = {}) {
|
|
141
139
|
const kind = String(askEv?.ask?.kind ?? '')
|
|
142
|
-
|
|
140
|
+
// 回答可能隔天才来。她问的是问的那一天能不能歇,不是答的这一天。
|
|
141
|
+
const dk = dateKey(new Date(tsMs(askEv) || Date.now()))
|
|
143
142
|
const day = Number(askEv?.day ?? 0) || undefined
|
|
144
143
|
const ctx = { day, stage: Number(askEv?.stage ?? 1) || 1, source: 'system' }
|
|
145
144
|
try {
|
|
@@ -179,6 +178,7 @@ async function applyVerdict(root, askEv, verdict, { log = () => {} } = {}) {
|
|
|
179
178
|
await appendEvent({ type: 'meta', text: `ask-ok:${askEv.id}` }, ctx, root)
|
|
180
179
|
} catch (err) {
|
|
181
180
|
log(`请求事件:裁决落地失败 ${String(err?.message ?? err).slice(0, 160)}`)
|
|
181
|
+
throw err
|
|
182
182
|
}
|
|
183
183
|
}
|
|
184
184
|
|
|
@@ -186,7 +186,19 @@ async function applyVerdict(root, askEv, verdict, { log = () => {} } = {}) {
|
|
|
186
186
|
* 记下答案(这件事只做一次)。重复答会被拒。
|
|
187
187
|
* @returns {Promise<{ok: boolean, why?: string, answer?: object}>}
|
|
188
188
|
*/
|
|
189
|
-
export
|
|
189
|
+
export function settleAsk(root, opts = {}) {
|
|
190
|
+
// 先读账再写账,中间隔着好几次 await。回答可能从几条路同时来(卡片、观测台、别的插件、过期结算),
|
|
191
|
+
// 不排队的话两条都看到「还没答」,账上就留下相反的两个裁决,两边的效果还都生效了。
|
|
192
|
+
const run = (settling.get(root) ?? Promise.resolve()).then(() => settleAskNow(root, opts))
|
|
193
|
+
const tail = run.catch(() => {})
|
|
194
|
+
settling.set(root, tail)
|
|
195
|
+
void tail.then(() => { if (settling.get(root) === tail) settling.delete(root) })
|
|
196
|
+
return run
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const settling = new Map()
|
|
200
|
+
|
|
201
|
+
async function settleAskNow(root, { askId, verdict, reason = '', via = 'ui', now = Date.now() } = {}) {
|
|
190
202
|
const id = String(askId ?? '')
|
|
191
203
|
if (!id) return { ok: false, why: '没说是哪一条请求' }
|
|
192
204
|
const v = verdict === 'approve' || verdict === 'reject' || verdict === 'later' ? verdict : ''
|
|
@@ -195,19 +207,28 @@ export async function settleAsk(root, { askId, verdict, reason = '', via = 'ui',
|
|
|
195
207
|
const { events } = await readEvents(root)
|
|
196
208
|
const askEv = events.find((e) => e?.id === id && e?.type === 'ask')
|
|
197
209
|
if (!askEv) return { ok: false, why: '事件流里没有这一条请求' }
|
|
198
|
-
|
|
210
|
+
const prior = answersByAsk(events).get(id)
|
|
211
|
+
if (prior) return { ok: false, why: prior.ask?.via === 'timeout' ? '放了一天没人答,已经记成以后再说了' : '这条已经答过了' }
|
|
199
212
|
|
|
200
213
|
const { config } = await loadConfig(root)
|
|
201
214
|
const model = deriveSelf(events, { growthDays: config.growthDays })
|
|
202
215
|
const word = v === 'approve' ? '可以' : v === 'reject' ? '这次不行' : '以后再说'
|
|
203
216
|
const text = String(reason ?? '').trim().slice(0, 200) || word
|
|
217
|
+
// 先让裁决生效,再记「答过了」。反过来的话,配置写不进去也只会回「已经答过了」,再也改不回来,
|
|
218
|
+
// 账上和她听到的都是答应了,其实什么都没变。效果重放无害:同样的补丁,多一行 meta。
|
|
219
|
+
try {
|
|
220
|
+
// 账写不进去就别先改配置:不然他听到「没记上」,她的作息却已经变了。
|
|
221
|
+
await appendFile(paths(root).events, '')
|
|
222
|
+
await applyVerdict(root, askEv, v, { log: (m) => logLine('warn', [m], root) })
|
|
223
|
+
} catch (err) {
|
|
224
|
+
return { ok: false, why: `这次没落地:${String(err?.code ?? err?.message ?? err).slice(0, 80)}` }
|
|
225
|
+
}
|
|
204
226
|
const answer = await appendEvent({
|
|
205
227
|
type: 'answer',
|
|
206
228
|
text,
|
|
207
229
|
ask: { askId: id, verdict: v, reason: String(reason ?? '').slice(0, 200), via },
|
|
208
230
|
}, { day: model.day, stage: model.stage, source: 'system' }, root)
|
|
209
231
|
|
|
210
|
-
await applyVerdict(root, askEv, v, { log: (m) => logLine('warn', [m], root) })
|
|
211
232
|
return { ok: true, answer }
|
|
212
233
|
}
|
|
213
234
|
|
|
@@ -256,21 +277,33 @@ export function askChoiceIndex(options = [], selected = '') {
|
|
|
256
277
|
return -1
|
|
257
278
|
}
|
|
258
279
|
|
|
280
|
+
/** 改作息的问题,话是她写的,答应了改成什么是另一回事:把真的会写进配置的数写在卡上。 */
|
|
281
|
+
function valueNote(value) {
|
|
282
|
+
const bits = []
|
|
283
|
+
if (Number.isFinite(Number(value?.wakesPerDay))) bits.push(`每天醒 ${Number(value.wakesPerDay)} 次`)
|
|
284
|
+
if (Array.isArray(value?.window) && value.window.length === 2) bits.push(`${value.window[0]}–${value.window[1]}`)
|
|
285
|
+
return bits.length ? `(答应了就改成:${bits.join(',')})` : ''
|
|
286
|
+
}
|
|
287
|
+
|
|
259
288
|
/** 把她的问题丢进会话卡片,等人选。返回 {state, note, said, code, raw}。 */
|
|
260
|
-
async function askInCard(ctx, { sessionId, ev, options, signal }) {
|
|
289
|
+
async function askInCard(ctx, { sessionId, ev, options, signal, rawTexts, root }) {
|
|
261
290
|
const r = await askInChat(ctx, {
|
|
262
291
|
sessionId,
|
|
263
292
|
signal,
|
|
293
|
+
rawTexts,
|
|
294
|
+
root,
|
|
295
|
+
cardId: `ask:${ev.id}`,
|
|
296
|
+
detail: String(ev?.ask?.reason ?? ''),
|
|
264
297
|
questions: [
|
|
265
298
|
{
|
|
266
299
|
id: `ask-${ev.id}`,
|
|
267
300
|
header: '大肥鱼有事想问你',
|
|
268
|
-
question: String(ev.text ?? '')
|
|
301
|
+
question: `${String(ev.text ?? '')}${valueNote(ev?.ask?.value)}`,
|
|
269
302
|
options: options.map((label) => ({ label })),
|
|
270
303
|
},
|
|
271
304
|
],
|
|
272
305
|
})
|
|
273
|
-
if (!r.ok) return { state: 'unanswered', note: '问了,还没答。', said: '', code: r.code ?? 'NO_ANSWER', raw: '' }
|
|
306
|
+
if (!r.ok) return { state: 'unanswered', note: '问了,还没答。', said: '', code: r.code ?? 'NO_ANSWER', raw: '', via: r.via ?? '' }
|
|
274
307
|
const pid = `ask-${ev.id}`
|
|
275
308
|
// issue #35:他直接在卡片里打字回答时,宿主回的是 `{id, selected: [], custom: '…'}`
|
|
276
309
|
// (dsh-user-questions 的约定:custom 覆盖选项、selected 留空)。以前这里只看
|
|
@@ -348,6 +381,7 @@ export function registerAsk(ctx, deps = {}) {
|
|
|
348
381
|
note: { type: 'string' },
|
|
349
382
|
said: { type: 'string' },
|
|
350
383
|
askId: { type: 'string' },
|
|
384
|
+
via: { type: 'string' },
|
|
351
385
|
},
|
|
352
386
|
required: ['ok', 'note'],
|
|
353
387
|
},
|
|
@@ -362,7 +396,9 @@ export function registerAsk(ctx, deps = {}) {
|
|
|
362
396
|
? '[这次不行]'
|
|
363
397
|
: out.said === 'later'
|
|
364
398
|
? '[以后再说]'
|
|
365
|
-
: '
|
|
399
|
+
: out.said === 'relayed'
|
|
400
|
+
? `[已问,送到了${out.via},还没答]`
|
|
401
|
+
: '[已问]',
|
|
366
402
|
},
|
|
367
403
|
],
|
|
368
404
|
},
|
|
@@ -386,10 +422,19 @@ export function registerAsk(ctx, deps = {}) {
|
|
|
386
422
|
if (!quota.ok) return { ok: false, note: quota.why }
|
|
387
423
|
|
|
388
424
|
const options = optionsOf(args?.options)
|
|
425
|
+
// 回答认的是选项原文,两个一样的话选第二个也算第一个。
|
|
426
|
+
if (new Set(options).size < options.length) return { ok: false, note: '选项的前 24 个字不能一样,不然他分不出来。' }
|
|
389
427
|
const value = {}
|
|
390
428
|
if (kind === 'time') {
|
|
391
|
-
|
|
392
|
-
|
|
429
|
+
// 账上只存得下 0–4 的整数(schema.js 会悄悄改成最近的)。她传 5,答应后是 4,她却一直以为是 5;
|
|
430
|
+
// 传 -1 就成了 0,也就是不再定时醒。所以不认的都不问,这时还没写事件,不占额度。
|
|
431
|
+
const w = args?.wakesPerDay
|
|
432
|
+
if (/^[0-4]$/.test(String(typeof w === 'string' ? w.trim() : w))) value.wakesPerDay = Number(w)
|
|
433
|
+
const win = Array.isArray(args?.window) ? args.window.map((x) => String(x)) : null
|
|
434
|
+
if (win?.length === 2 && win.every((x) => HHMM.test(x)) && win[0] !== win[1]) value.window = win
|
|
435
|
+
if (!Object.keys(value).length || (w != null && value.wakesPerDay === undefined) || (args?.window != null && !value.window)) {
|
|
436
|
+
return { ok: false, note: 'kind=time 要写 wakesPerDay(0–4)或 window(两个不同的 HH:MM,比如 08:00)。写不对的话他答应了也改不了。' }
|
|
437
|
+
}
|
|
393
438
|
}
|
|
394
439
|
|
|
395
440
|
const { config } = await loadConfig(root)
|
|
@@ -407,7 +452,10 @@ export function registerAsk(ctx, deps = {}) {
|
|
|
407
452
|
}, { day: model.day, stage: model.stage, source: 'system' }, root)
|
|
408
453
|
|
|
409
454
|
// 卡片只挂在那条会话上;她这轮刚起来时 agent 可能在注册路上,askInChat 会等一小会儿。
|
|
410
|
-
const
|
|
455
|
+
const rawTexts = [args?.question, args?.why, ...(Array.isArray(args?.options) ? args.options : [])]
|
|
456
|
+
const r = await askInCard(ctx, { sessionId, ev, options, signal: exec?.signal, rawTexts, root })
|
|
457
|
+
// 别的插件接走了:不是没人理,只是还没答。答了走 lib/cardanswer.js 结算,这里不等。
|
|
458
|
+
if (r.code === 'RELAYED') return { ok: true, note: `问了,送到了${r.via},还没答。`, said: 'relayed', askId: ev.id, via: r.via }
|
|
411
459
|
if (r.state === 'unanswered') {
|
|
412
460
|
// 2026-10-30 真机(用户 m01756):卡片消失了,事件流里却没有 answer 行,
|
|
413
461
|
// 回执只写「已问」——事后完全看不出是「没答」还是「认不出选择」。
|
|
@@ -420,7 +468,7 @@ export function registerAsk(ctx, deps = {}) {
|
|
|
420
468
|
return {
|
|
421
469
|
ok: true,
|
|
422
470
|
note: s.ok ? r.note : '问了,但这次没记下来。',
|
|
423
|
-
said: r.state,
|
|
471
|
+
said: s.ok ? r.state : '',
|
|
424
472
|
askId: ev.id,
|
|
425
473
|
}
|
|
426
474
|
},
|
package/lib/askinchat.js
CHANGED
|
@@ -20,13 +20,79 @@
|
|
|
20
20
|
* 否则 CALLER_NOT_LIVE / DELEGATED_CALLER
|
|
21
21
|
* - 没人在线时 noAnswerer 抛 NO_PROVIDER
|
|
22
22
|
* - 卡片挂在 `ctx.sessions.scopeOf(agent)` 那条会话上
|
|
23
|
+
*
|
|
24
|
+
* 人不在这条会话前的部署(都从 IM 上说话):卡片他看不见,她这一轮还要白等 5 分钟,
|
|
25
|
+
* 一天后记成「一直没人答」。所以弹卡之前先问一声有没有插件能把话送到人在的地方
|
|
26
|
+
* (`dsh-fish/card`,AGENTS.md §7.3)。接走了就不弹卡、不等,回答走 `dsh-fish/answer`
|
|
27
|
+
* 回来(lib/cardanswer.js),人隔几个小时才答、中间重启过都能结算。
|
|
23
28
|
*/
|
|
24
29
|
|
|
30
|
+
import { looksLikeRewriting, looksLikeSecret } from './guard.js'
|
|
25
31
|
import { liveAgentOf } from './identity.js'
|
|
32
|
+
import { logLine } from './log.js'
|
|
26
33
|
|
|
27
34
|
/** 一次提问默认等多久:5 分钟。超时不当失败,当「这次没问到」。 */
|
|
28
35
|
export const ASK_TIMEOUT_MS = 5 * 60_000
|
|
29
36
|
|
|
37
|
+
/**
|
|
38
|
+
* 等别的插件接卡最多等多久。它发不出去,这张卡还来得及弹在会话上。
|
|
39
|
+
* 等得比一次发送的超时久(IM 插件常见 30 秒):先放弃的话桌面弹一张、那边又送到一张,两边都能答。
|
|
40
|
+
*/
|
|
41
|
+
export const RELAY_WAIT_MS = 35_000
|
|
42
|
+
|
|
43
|
+
/** 卡上有没有看着像凭据的字:她的问题、理由、选项都算(§3:秘密外发一律拦下)。 */
|
|
44
|
+
function cardHasSecret(card) {
|
|
45
|
+
const texts = [card.detail]
|
|
46
|
+
for (const q of card.questions) texts.push(q?.header, q?.question, ...(Array.isArray(q?.options) ? q.options.map((o) => o?.label) : []))
|
|
47
|
+
return texts.some((text) => looksLikeSecret(text).hit)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 回执里要念给她听的去处。这是别的插件写的字(§3:新输入口都过 guard.js)。
|
|
52
|
+
* 空的算没接走;看着像改写指令的,卡还是接走了,只是不把这几个字念给她。
|
|
53
|
+
*/
|
|
54
|
+
function viaOf(taken) {
|
|
55
|
+
const raw = typeof taken?.via === 'string' ? taken.via : ''
|
|
56
|
+
const via = raw.replace(/\s+/g, ' ').trim().slice(0, 40)
|
|
57
|
+
if (!via) return ''
|
|
58
|
+
// 按行看:改写指令的判断认行首,压成一行之后「QQ\nsystem: …」就认不出来了
|
|
59
|
+
const risky = raw.split(/[\r\n\u2028\u2029]+/).some((line) => looksLikeRewriting(line).hit) || looksLikeRewriting(via).hit || looksLikeSecret(raw).hit
|
|
60
|
+
return risky ? '别的地方' : via
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 问问有没有插件接这张卡。只有回了 `{ via: '说给她听的去处' }` 才算接走;
|
|
65
|
+
* 没人接、抛错、拒绝、超时、这一轮被取消都按没接,**绝不抛**。Promise 的拒绝由 race 接住:
|
|
66
|
+
* 宿主把未处理的拒绝当致命错误,超时之后才拒绝的也一样要接住。
|
|
67
|
+
* `bail` 停在第一个非空返回值上,所以 async 监听只有排第一的那个算数。
|
|
68
|
+
* 看着带凭据的卡不问:它只留在本机的卡片上,不发给任何插件。截断之前的原文也要看,
|
|
69
|
+
* 不然长 token 的前半段正好够不上判断的长度。这条判断偏严,长文件名也可能中,所以留一行日志,只记 id。
|
|
70
|
+
*/
|
|
71
|
+
async function relayCard(ctx, card, waitMs, signal, { rawTexts = [], root = undefined } = {}) {
|
|
72
|
+
if (cardHasSecret(card) || rawTexts.some((text) => looksLikeSecret(text).hit)) {
|
|
73
|
+
if (root) logLine('warn', ['卡上有看着像凭据的字,没转给别的插件,只留在本机:', card.id], root)
|
|
74
|
+
return ''
|
|
75
|
+
}
|
|
76
|
+
let timer
|
|
77
|
+
let onAbort
|
|
78
|
+
try {
|
|
79
|
+
const r = ctx?.bail?.('dsh-fish/card', card)
|
|
80
|
+
const taken = typeof r?.then === 'function'
|
|
81
|
+
? await Promise.race([r, new Promise((resolve) => {
|
|
82
|
+
timer = setTimeout(() => resolve(null), waitMs)
|
|
83
|
+
onAbort = () => resolve(null)
|
|
84
|
+
signal?.addEventListener?.('abort', onAbort, { once: true })
|
|
85
|
+
})])
|
|
86
|
+
: r
|
|
87
|
+
return viaOf(taken)
|
|
88
|
+
} catch {
|
|
89
|
+
return ''
|
|
90
|
+
} finally {
|
|
91
|
+
clearTimeout(timer)
|
|
92
|
+
if (onAbort) signal?.removeEventListener?.('abort', onAbort)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
30
96
|
/**
|
|
31
97
|
* 把她想问的话丢进会话里,等那个人选一个。
|
|
32
98
|
*
|
|
@@ -36,7 +102,12 @@ export const ASK_TIMEOUT_MS = 5 * 60_000
|
|
|
36
102
|
* @param {Array} opts.questions 宿主格式的问题数组
|
|
37
103
|
* @param {number} [opts.timeoutMs]
|
|
38
104
|
* @param {object} [opts.signal] 外部取消信号(例如这一轮已经结束了)
|
|
39
|
-
* @
|
|
105
|
+
* @param {string} [opts.cardId] 这张卡的 id(`ask:…` 等,见 lib/cardanswer.js)。不给就不会被别的插件接走
|
|
106
|
+
* @param {string} [opts.detail] 只给接卡的插件看的补充说明(卡片本身放不下的那句「为什么」)
|
|
107
|
+
* @param {Array} [opts.rawTexts] 卡上的字截断之前的原文,只用来判断带不带凭据
|
|
108
|
+
* @param {string} [opts.root] 这个家,用来留日志
|
|
109
|
+
* @returns {Promise<{ok: boolean, answers?: Array, code?: string, via?: string}>}
|
|
110
|
+
* 被接走时 `{ok:false, code:'RELAYED', via}`:调用方照「问了,还没答」处理
|
|
40
111
|
*/
|
|
41
112
|
export async function askInChat(ctx, opts = {}) {
|
|
42
113
|
const {
|
|
@@ -45,9 +116,20 @@ export async function askInChat(ctx, opts = {}) {
|
|
|
45
116
|
timeoutMs = ASK_TIMEOUT_MS,
|
|
46
117
|
signal = undefined,
|
|
47
118
|
waitAgentMs = 10_000,
|
|
119
|
+
cardId = '',
|
|
120
|
+
detail = '',
|
|
121
|
+
relayWaitMs = RELAY_WAIT_MS,
|
|
122
|
+
rawTexts = [],
|
|
123
|
+
root = undefined,
|
|
48
124
|
} = opts
|
|
49
125
|
if (!Array.isArray(questions) || questions.length === 0) return { ok: false, code: 'NO_QUESTIONS' }
|
|
50
126
|
|
|
127
|
+
if (cardId && !signal?.aborted) {
|
|
128
|
+
const via = await relayCard(ctx, { id: cardId, sessionId, questions, detail }, relayWaitMs, signal, { rawTexts, root })
|
|
129
|
+
if (via) return { ok: false, code: 'RELAYED', via }
|
|
130
|
+
if (signal?.aborted) return { ok: false, code: 'AbortError' }
|
|
131
|
+
}
|
|
132
|
+
|
|
51
133
|
// 宿主只接受 live root agent。她那轮刚被 wake 顶起来时,agent 可能还没就位,
|
|
52
134
|
// 所以这里给一小段等待窗口(默认 10 秒)而不是一次查不到就放弃。
|
|
53
135
|
const agent = await waitAgent(ctx, sessionId, { totalMs: waitAgentMs, signal })
|
package/lib/browse.js
CHANGED
|
@@ -546,6 +546,14 @@ export const LOOK_BRIEF = [
|
|
|
546
546
|
'和 growth_commit 一样,你不用它也可以。',
|
|
547
547
|
].join('\n')
|
|
548
548
|
|
|
549
|
+
/**
|
|
550
|
+
* 关着时的「上网」那一节(P3,2026-11-07)。
|
|
551
|
+
* 门不拆:整节消失会让她以为这件事根本不存在;只把门上的话换成「这会儿」。
|
|
552
|
+
*/
|
|
553
|
+
export const LOOK_OFF_BRIEF = [
|
|
554
|
+
'这会儿出不去。以前翻过、看过的东西还在;等能出去的时候,你会先看到它们。',
|
|
555
|
+
].join('\n')
|
|
556
|
+
|
|
549
557
|
/**
|
|
550
558
|
* 他丢来一条视频的时候怎么办。
|
|
551
559
|
*
|
package/lib/capture.js
CHANGED
|
@@ -186,13 +186,31 @@ export function registerCapture(ctx, deps = {}) {
|
|
|
186
186
|
return { ok: true, id: dk, note: '写下了。' }
|
|
187
187
|
}
|
|
188
188
|
|
|
189
|
+
// 写盘的 schema 会把越界的数字悄悄改成最近的(confidence 夹到 0–1)。
|
|
190
|
+
// 她传 5 会以为记下了 5,读回来却是 1——和 issue #43 的 wakesPerDay 是同一类问题。
|
|
191
|
+
// 入口先拒,别让「说好的」和「落地」不一样;这时还没写事件,可以改完重试。
|
|
192
|
+
if (args?.confidence != null) {
|
|
193
|
+
const c = Number(args.confidence)
|
|
194
|
+
if (!Number.isFinite(c) || c < 0 || c > 1) {
|
|
195
|
+
return {
|
|
196
|
+
ok: false,
|
|
197
|
+
id: '',
|
|
198
|
+
note: `confidence 要写 0–1 之间的小数(你写的是 ${String(args.confidence)})。写盘时越界会被悄悄夹到边上,所以这里先不收。`,
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
// verdict 同理:schema 认不出来的会落成 null,她以为写了 confirmed。
|
|
203
|
+
if (args?.verdict != null && !['confirmed', 'refuted', 'refined'].includes(args.verdict)) {
|
|
204
|
+
return { ok: false, id: '', note: 'verdict 只能是 confirmed / refuted / refined。' }
|
|
205
|
+
}
|
|
206
|
+
|
|
189
207
|
const ev = await appendEvent(
|
|
190
208
|
{
|
|
191
209
|
type: args?.type,
|
|
192
210
|
text: args?.text,
|
|
193
211
|
refs: args?.refs,
|
|
194
212
|
verdict: args?.verdict,
|
|
195
|
-
confidence: args?.confidence,
|
|
213
|
+
confidence: args?.confidence == null ? undefined : Number(args.confidence),
|
|
196
214
|
// 「谁写的」:这一条是她这条会话说的,不是别的屋子里那一位。
|
|
197
215
|
sessionId: who.sessionId,
|
|
198
216
|
},
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cardanswer.js —— 卡片被别的插件接走之后,人的回答从这里回来
|
|
3
|
+
*
|
|
4
|
+
* 人不在她那条会话前的部署(都从 IM 上说话),插件可以用 `dsh-fish/card` 把卡接走
|
|
5
|
+
* (lib/askinchat.js,AGENTS.md §7.3)。人可能隔几个小时才答,中间宿主可能还重启过,
|
|
6
|
+
* 所以回答不靠闭包:插件记着卡的 id,答了就发
|
|
7
|
+
*
|
|
8
|
+
* const r = await ctx.bail('dsh-fish/answer', { id: card.id, selected: ['可以'] })
|
|
9
|
+
*
|
|
10
|
+
* id 是 `种类:引用`:`ask:<事件 id>`、`keepsake:<条目 id>`、`grant:<申请 id>`。
|
|
11
|
+
* selected 和宿主 userQuestions 的回答同形,填选项原文。没选、对不上选项都不结算,
|
|
12
|
+
* 这张卡还开着:人回的可能只是一句话,猜成同意或以后再说都可能是错的,而且只结算一次,错了改不回来。
|
|
13
|
+
* 结算走的都是观测台和卡片用的同一条路,「只结算一次」也由它们管。
|
|
14
|
+
* `ok: false` 不代表这张卡结了,写盘失败也是它,接卡的插件留着 id,人再答一次就再试一次。
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { rootDir, readEvents } from './store.js'
|
|
18
|
+
import { ASK_TTL_MS, answersByAsk, asksOf, settleAsk, settleStaleAsks } from './ask.js'
|
|
19
|
+
import { tsMs } from './schema.js'
|
|
20
|
+
import { picked } from './askinchat.js'
|
|
21
|
+
import { decideKeepsake } from './keepsake.js'
|
|
22
|
+
import { YES as KEEP_YES, NO as KEEP_NO } from './keep.js'
|
|
23
|
+
import { recordGrant, GRANT_YES, GRANT_NO } from './grant.js'
|
|
24
|
+
import { pendingOffer } from './unlock.js'
|
|
25
|
+
|
|
26
|
+
const ASK_VERDICTS = ['approve', 'reject', 'later']
|
|
27
|
+
|
|
28
|
+
async function answerAsk(root, askId, label) {
|
|
29
|
+
// 过期结算是定时器做的,她出门或者宿主刚起来时可能没跑。过没过期要在这里就算数。
|
|
30
|
+
await settleStaleAsks(root)
|
|
31
|
+
const { events } = await readEvents(root)
|
|
32
|
+
const ev = asksOf(events).find((e) => e?.id === askId)
|
|
33
|
+
if (!ev) return { ok: false, why: '事件流里没有这一条请求' }
|
|
34
|
+
// 上面那次过期结算可能没写进去,过了一天的回答一样不算数。
|
|
35
|
+
if (!answersByAsk(events).has(askId) && Date.now() - tsMs(ev) >= ASK_TTL_MS) return { ok: false, why: '放了一天没人答,算以后再说了' }
|
|
36
|
+
// 只认选项原文。别的插件把人回的「1」直接传进来,不能按下标算成第二个。
|
|
37
|
+
const options = Array.isArray(ev?.ask?.options) ? ev.ask.options.map((x) => String(x ?? '').trim()) : []
|
|
38
|
+
const verdict = ASK_VERDICTS[options.indexOf(label)]
|
|
39
|
+
if (!verdict) return { ok: false, why: '这张卡没有这个选项' }
|
|
40
|
+
const r = await settleAsk(root, { askId, verdict, reason: label, via: 'relay' })
|
|
41
|
+
return r.ok ? { ok: true } : { ok: false, why: r.why }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function answerKeepsake(root, id, label) {
|
|
45
|
+
if (label !== KEEP_YES && label !== KEEP_NO) return { ok: false, why: '这张卡没有这个选项' }
|
|
46
|
+
const d = decideKeepsake(root, { id, yes: label === KEEP_YES })
|
|
47
|
+
return d.ok ? { ok: true } : { ok: false, why: d.note }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function answerGrant(root, offerId, label) {
|
|
51
|
+
if (label !== GRANT_YES && label !== GRANT_NO) return { ok: false, why: '这张卡没有这个选项' }
|
|
52
|
+
// 读不到账要照实说,不能说成申请不在了。
|
|
53
|
+
const { events } = await readEvents(root)
|
|
54
|
+
const offer = pendingOffer(events)
|
|
55
|
+
if (offer?.id !== offerId) return { ok: false, why: '这张申请已经不在等了' }
|
|
56
|
+
const w = await recordGrant(root, { offerId, rule: offer.grant?.rule || '', verdict: label === GRANT_YES ? 'approve' : 'reject' })
|
|
57
|
+
return w.ok ? { ok: true } : { ok: false, why: w.error }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 结算一张被接走的卡。**绝不抛**,别的插件不用替我们兜底。
|
|
62
|
+
* @param {string} root
|
|
63
|
+
* @param {{id: string, selected?: Array}} answer
|
|
64
|
+
* @returns {Promise<{ok: boolean, why?: string}>}
|
|
65
|
+
*/
|
|
66
|
+
export async function answerCard(root = rootDir(), answer = {}) {
|
|
67
|
+
try {
|
|
68
|
+
const id = String(answer?.id ?? '')
|
|
69
|
+
const at = id.indexOf(':')
|
|
70
|
+
const ref = at > 0 ? id.slice(at + 1) : ''
|
|
71
|
+
if (!ref) return { ok: false, why: '没说是哪一张卡' }
|
|
72
|
+
const label = picked([{ id, selected: answer?.selected }], id)
|
|
73
|
+
if (!label) return { ok: false, why: '没选' }
|
|
74
|
+
switch (id.slice(0, at)) {
|
|
75
|
+
case 'ask': return await answerAsk(root, ref, label)
|
|
76
|
+
case 'keepsake': return answerKeepsake(root, ref, label)
|
|
77
|
+
case 'grant': return await answerGrant(root, ref, label)
|
|
78
|
+
default: return { ok: false, why: '不认识这种卡' }
|
|
79
|
+
}
|
|
80
|
+
} catch (err) {
|
|
81
|
+
// 原因可能转告给人,也可能送进她的对话:只说错误码,不带本机路径。
|
|
82
|
+
return { ok: false, why: String(err?.code ?? err?.message ?? err).slice(0, 160) }
|
|
83
|
+
}
|
|
84
|
+
}
|