@ssamurias/dsh-fish 0.9.1 → 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 +27 -2
- package/CHANGELOG.md +39 -0
- package/README.md +4 -2
- package/RELEASE-NOTES-0.9.2.md +46 -0
- package/RELEASE.md +6 -5
- package/lib/absence.js +41 -27
- package/lib/ask.js +73 -25
- package/lib/askinchat.js +83 -1
- package/lib/capture.js +19 -1
- package/lib/cardanswer.js +84 -0
- package/lib/client.js +66 -8
- package/lib/comment.js +37 -2
- package/lib/deliver.js +46 -0
- package/lib/derive.js +14 -11
- package/lib/events.js +25 -1
- 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 +49 -9
- package/lib/keep.js +21 -9
- package/lib/keepsake.js +20 -13
- package/lib/note.js +255 -20
- package/lib/observe.js +5 -4
- package/lib/persona.js +10 -7
- package/lib/say.js +73 -13
- package/lib/scheduler.js +90 -32
- package/lib/schema.js +18 -0
- package/lib/status.js +25 -3
- package/lib/turns.js +42 -1
- package/package.json +1 -1
package/lib/grant.js
CHANGED
|
@@ -29,7 +29,18 @@ import { pendingOffer } from './unlock.js'
|
|
|
29
29
|
* @param {string} [opts.reason] 她的原话;空则用「可以。」/「这次不行。」
|
|
30
30
|
* @returns {Promise<{ok: boolean, error?: string}>}
|
|
31
31
|
*/
|
|
32
|
-
export
|
|
32
|
+
export function recordGrant(root = rootDir(), opts = {}) {
|
|
33
|
+
// 观测台、会话里的卡、别的插件送回来的回答都走这里。先查再写,排队才不会留下相反的两行。
|
|
34
|
+
const run = (recording.get(root) ?? Promise.resolve()).then(() => recordGrantNow(root, opts))
|
|
35
|
+
const tail = run.catch(() => {})
|
|
36
|
+
recording.set(root, tail)
|
|
37
|
+
void tail.then(() => { if (recording.get(root) === tail) recording.delete(root) })
|
|
38
|
+
return run
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const recording = new Map()
|
|
42
|
+
|
|
43
|
+
async function recordGrantNow(root, opts) {
|
|
33
44
|
const verdict = opts.verdict === 'approve' ? 'approve' : opts.verdict === 'reject' ? 'reject' : null
|
|
34
45
|
if (!verdict) return { ok: false, error: 'verdict 只能是 approve 或 reject' }
|
|
35
46
|
const offerId = String(opts.offerId ?? '')
|
|
@@ -37,6 +48,7 @@ export async function recordGrant(root = rootDir(), opts = {}) {
|
|
|
37
48
|
|
|
38
49
|
const { config } = opts.config ? { config: opts.config } : await loadConfig(root)
|
|
39
50
|
const { events } = await readEvents(root).catch(() => ({ events: [] }))
|
|
51
|
+
if (events.some((e) => e?.type === 'grant' && e.grant?.offerId === offerId)) return { ok: false, error: '这张申请已经答过了' }
|
|
40
52
|
const model = deriveSelf(events, config)
|
|
41
53
|
const reason = String(opts.reason ?? '').trim()
|
|
42
54
|
const text = reason || (verdict === 'approve' ? '可以。' : '这次不行。')
|
|
@@ -59,13 +71,17 @@ export async function recordGrant(root = rootDir(), opts = {}) {
|
|
|
59
71
|
root,
|
|
60
72
|
)
|
|
61
73
|
} catch (err) {
|
|
62
|
-
return { ok: false, error: err?.message || '写入失败' }
|
|
74
|
+
return { ok: false, error: err?.code || err?.message || '写入失败' }
|
|
63
75
|
}
|
|
64
76
|
|
|
65
77
|
if (verdict === 'approve') await refreshSelfDoc(root, { config }).catch(() => {})
|
|
66
78
|
return { ok: true }
|
|
67
79
|
}
|
|
68
80
|
|
|
81
|
+
/** 会话里问这张申请时的两个选项(lib/scheduler.js 弹卡,lib/cardanswer.js 认回答,要对得上)。 */
|
|
82
|
+
export const GRANT_YES = '可以'
|
|
83
|
+
export const GRANT_NO = '这次不行'
|
|
84
|
+
|
|
69
85
|
/** 现在还有没有一张等人裁决的申请(顺手把 id 带出来,省得调用方再查一遍)。 */
|
|
70
86
|
export async function currentOffer(root = rootDir(), now = Date.now()) {
|
|
71
87
|
const { events } = await readEvents(root).catch(() => ({ events: [] }))
|
package/lib/home.js
CHANGED
|
@@ -234,12 +234,31 @@ function pickLiveSession(reg, wsId, avoid = '') {
|
|
|
234
234
|
* (返回 false,维持原来的判定,不凭空认活)。
|
|
235
235
|
*/
|
|
236
236
|
async function sessionListed(sc, sessionId) {
|
|
237
|
+
const row = await sessionRow(sc, sessionId)
|
|
238
|
+
return row.found
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* 在宿主会话列表里找这一条,把 `blank` 一起带出来。
|
|
243
|
+
*
|
|
244
|
+
* 为什么要看 `blank` 而不只是「在不在列表里」(2026-11-06 换房报告):
|
|
245
|
+
* 宿主的 `list()` 会把**每一条活着的会话都推出来**(`summaryFor` 无条件 push),
|
|
246
|
+
* 空白会话也在里面,只是侧栏按 `blank && id !== current` 把它藏起来。
|
|
247
|
+
* 所以「列表里有这一行」只说明会话被持久化了,**不说明它跑出过内容**——
|
|
248
|
+
* 拿它当「叫醒落地了」的判据,会让复核永远当成功,那句「再叫一次」是死代码。
|
|
249
|
+
* 真正跑过一轮的标志是 `blank === false`(宿主在 `turn/start` 时清掉它)。
|
|
250
|
+
*
|
|
251
|
+
* @returns {Promise<{found:boolean, blank:boolean|undefined}>} 查不到/查失败都是 `found:false`
|
|
252
|
+
*/
|
|
253
|
+
export async function sessionRow(sc, sessionId) {
|
|
237
254
|
try {
|
|
238
|
-
if (typeof sc?.list !== 'function') return false
|
|
255
|
+
if (typeof sc?.list !== 'function') return { found: false, blank: undefined }
|
|
239
256
|
const l = await sc.list({})
|
|
240
257
|
const items = l?.items ?? l?.value?.items ?? []
|
|
241
|
-
|
|
242
|
-
|
|
258
|
+
const row = items.find((x) => String(x?.sessionId ?? x?.id ?? '') === String(sessionId))
|
|
259
|
+
if (row === undefined) return { found: false, blank: undefined }
|
|
260
|
+
return { found: true, blank: typeof row.blank === 'boolean' ? row.blank : undefined }
|
|
261
|
+
} catch { return { found: false, blank: undefined } }
|
|
243
262
|
}
|
|
244
263
|
|
|
245
264
|
/**
|
|
@@ -441,7 +460,7 @@ async function ensureTalkSessionOnce(ctx, { root = rootDir(), wsId, title = TITL
|
|
|
441
460
|
* 只记**成功送达**的那一次:宿主的 prompt 是「这一轮被收下了」就 resolve,
|
|
442
461
|
* 收下就一定会跑出内容;抛错(比如漏传 signal)则不写,下次启动还会再试。
|
|
443
462
|
*/
|
|
444
|
-
export async function wakeIfBlank(ctx, { sessionId, root = '', log, freshOnly = false, rotated = false, force = false } = {}) {
|
|
463
|
+
export async function wakeIfBlank(ctx, { sessionId, root = '', log, freshOnly = false, rotated = false, force = false, verify = false } = {}) {
|
|
445
464
|
if (!sessionId) return { woke: false, why: '没有会话 id' }
|
|
446
465
|
const sc = ctx?.get?.('sessionController') ?? ctx?.sessionController
|
|
447
466
|
if (!sc) return { woke: false, why: '宿主没有 sessionController 服务' }
|
|
@@ -492,6 +511,14 @@ export async function wakeIfBlank(ctx, { sessionId, root = '', log, freshOnly =
|
|
|
492
511
|
content: [{ type: 'text', text: freshWakeText(new Date(), { rotated }) }],
|
|
493
512
|
}, new AbortController().signal)
|
|
494
513
|
.then(() => markWoke(root, sessionId))
|
|
514
|
+
// 复核(2026-11-06 换房报告):prompt 只等消息进收件箱,宿主收下了不等于
|
|
515
|
+
// 那一轮真跑了(wakeDriver 可能因为上一轮还没结束直接丢掉)。隔 12 秒回头
|
|
516
|
+
// 看 blank 翻没翻;没翻就再叫一次。默认关:测试里的替身不会真跑 turn,
|
|
517
|
+
// 挂了复核会在 12 秒后冒出第二次 prompt,把断言搅乱。
|
|
518
|
+
.then(() => {
|
|
519
|
+
if (!verify) return
|
|
520
|
+
void verifyWoke(ctx, { root, sessionId, log }).catch(() => {})
|
|
521
|
+
})
|
|
495
522
|
.catch((err) => log?.(`新屋子唤醒失败:${String(err?.message ?? err).slice(0, 140)}`))
|
|
496
523
|
log?.('新屋子还是空的,叫了她一声(要等她这一轮动起来,侧栏才会显示)')
|
|
497
524
|
return { woke: true, why: '' }
|
|
@@ -692,6 +719,15 @@ export const LAST_WORDS_WAIT_MS = 60_000
|
|
|
692
719
|
*/
|
|
693
720
|
export const WAKE_VERIFY_MS = 12_000
|
|
694
721
|
|
|
722
|
+
/**
|
|
723
|
+
* 正在复核的会话 id(2026-11-06 换房报告)。
|
|
724
|
+
*
|
|
725
|
+
* 换房、`/reset`、`/home/rebuild` 都会在叫醒后挂一次复核,12 秒后各自问一遍。
|
|
726
|
+
* 同一批里挂两次,就会在这条新会话上连着叫两声——她那边看见的是两条「醒一醒」。
|
|
727
|
+
* 复核自己也会再叫一次(force),所以「同一条正在复核」必须直接跳过。
|
|
728
|
+
*/
|
|
729
|
+
const verifying = new Set()
|
|
730
|
+
|
|
695
731
|
/**
|
|
696
732
|
* 换会话失败之后多久再试(2026-11-06)。
|
|
697
733
|
*
|
|
@@ -1042,7 +1078,7 @@ async function rotateTalkSessionOnce(ctx, { root = rootDir(), wsId, title = TITL
|
|
|
1042
1078
|
// 2026-11-02(用户 m00324):这条路是**换会话**,不是造新屋子。
|
|
1043
1079
|
// 传 rotated:true 让触发语说「对话换了一条新的,屋子还是原来那间」,
|
|
1044
1080
|
// 而不是「屋子刚建好」——后者会让她以为每 40 轮就变成新的一条鱼。
|
|
1045
|
-
const woke = await wakeIfBlank(ctx, { sessionId, root, log, freshOnly: true, rotated: true })
|
|
1081
|
+
const woke = await wakeIfBlank(ctx, { sessionId, root, log, freshOnly: true, rotated: true, verify: true })
|
|
1046
1082
|
if (!woke.woke && woke.why) log?.(`轮换后没叫醒新会话:${woke.why}`)
|
|
1047
1083
|
// 4.5) 叫了不等于叫醒(0.8.7,用户 m01775 那张截图里的第 4、5 条)
|
|
1048
1084
|
// 宿主的 prompt 是「这一轮被收下了」就 resolve。可新会话是插件用 API 建的空白会话,
|
|
@@ -1074,6 +1110,10 @@ async function rotateTalkSessionOnce(ctx, { root = rootDir(), wsId, title = TITL
|
|
|
1074
1110
|
*/
|
|
1075
1111
|
export async function verifyWoke(ctx, { root = '', sessionId = '', log, delayMs = WAKE_VERIFY_MS } = {}) {
|
|
1076
1112
|
if (!sessionId) return { landed: false, rewoke: false }
|
|
1113
|
+
// 同一批只复核一次:换房与 /reset 两条路都会顺手挂复核,重复挂会在她那里
|
|
1114
|
+
// 变成两声「醒一醒」——那正是这段代码要防的事。
|
|
1115
|
+
if (verifying.has(String(sessionId))) return { landed: false, rewoke: false, skipped: true }
|
|
1116
|
+
verifying.add(String(sessionId))
|
|
1077
1117
|
try {
|
|
1078
1118
|
if (delayMs > 0) {
|
|
1079
1119
|
await new Promise((ok) => {
|
|
@@ -1082,14 +1122,22 @@ export async function verifyWoke(ctx, { root = '', sessionId = '', log, delayMs
|
|
|
1082
1122
|
})
|
|
1083
1123
|
}
|
|
1084
1124
|
const sc = ctx?.get?.('sessionController') ?? ctx?.sessionController
|
|
1085
|
-
|
|
1086
|
-
|
|
1125
|
+
// 判据是 blank,不是「在不在列表里」:宿主的 list() 连空白会话一起推,
|
|
1126
|
+
// 在列表里只说明它被持久化了,说明不了它跑出过内容(详见 sessionRow 的注释)。
|
|
1127
|
+
// 老宿主不给 blank 字段时退回老判据——有这一行就当落地,宁可漏补一次。
|
|
1128
|
+
const row = await sessionRow(sc, sessionId)
|
|
1129
|
+
if (row.found && row.blank !== true) return { landed: true, rewoke: false }
|
|
1130
|
+
log?.(row.found
|
|
1131
|
+
? `新会话 ${sessionId} 叫过一次还是空的(没跑出 turn),再叫一次`
|
|
1132
|
+
: `新会话 ${sessionId} 叫过一次还是不在宿主列表里,再叫一次`)
|
|
1087
1133
|
const again = await wakeIfBlank(ctx, { sessionId, root, log, freshOnly: true, rotated: true, force: true })
|
|
1088
1134
|
if (!again.woke && again.why) log?.(`再叫一次也没成:${again.why}`)
|
|
1089
1135
|
return { landed: false, rewoke: again.woke === true }
|
|
1090
1136
|
} catch (err) {
|
|
1091
1137
|
log?.(`复核叫醒失败:${String(err?.message ?? err).slice(0, 140)}`)
|
|
1092
1138
|
return { landed: false, rewoke: false }
|
|
1139
|
+
} finally {
|
|
1140
|
+
verifying.delete(String(sessionId))
|
|
1093
1141
|
}
|
|
1094
1142
|
}
|
|
1095
1143
|
|
package/lib/identity.js
CHANGED
|
@@ -290,8 +290,18 @@ export function attachAssembleFix(agent, { log } = {}) {
|
|
|
290
290
|
}
|
|
291
291
|
}
|
|
292
292
|
|
|
293
|
-
/**
|
|
294
|
-
|
|
293
|
+
/**
|
|
294
|
+
* 这一次唤醒的处境正文,**按会话分开存**(2026-11-06,用户 m03265 二次报)。
|
|
295
|
+
*
|
|
296
|
+
* 原来是进程级的一个 `let wakeText = ''`。可一个进程里她可能同时有好几条会话——
|
|
297
|
+
* 家、群聊房间、轮换下来的旧房、出门那一条——而处境段是挂在**每个 agent** 上的
|
|
298
|
+
* 同一份闭包。于是 A 房间的一次叫醒,会把「这一轮是你自己的……不想说就什么都别写」
|
|
299
|
+
* 塞进 B 房间正在跑的那一轮:那边明明是他刚说的话,她却按「不用回应」处理,
|
|
300
|
+
* 只思考、一个字不写。宿主把空正文画成「已完成分析」,用户看到的就是
|
|
301
|
+
* 「她突然不说话了,连着好几轮」。
|
|
302
|
+
*/
|
|
303
|
+
const wakeBySession = new Map()
|
|
304
|
+
|
|
295
305
|
/**
|
|
296
306
|
* 处境段代次(2026-11-04,用户 m01466)。
|
|
297
307
|
*
|
|
@@ -305,15 +315,48 @@ let wakeText = ''
|
|
|
305
315
|
*/
|
|
306
316
|
let wakeGen = 0
|
|
307
317
|
|
|
318
|
+
/**
|
|
319
|
+
* 处境最多活这么久。
|
|
320
|
+
*
|
|
321
|
+
* 为什么要有这条硬超时(2026-11-06):清处境原来只有一条路——那条会话的 `turn/end`。
|
|
322
|
+
* 可 `turn/end` 可能永远不来:投递被丢弃、监听没挂上、插件重启把 watcher 丢了、
|
|
323
|
+
* `isBusy` 一直为真让兜底每 30 秒重排却永不收尾。于是这段「不用说话」的处境会
|
|
324
|
+
* **一直挂到进程退出**,之后她每一轮都只思考不开口——真机就是这样哑了一整晚。
|
|
325
|
+
* 处境本来就只属于那一次叫醒,它没有任何理由活过十分钟:到点无条件清掉。
|
|
326
|
+
* 宁可这一次叫醒白叫,也不能让她一直哑着。
|
|
327
|
+
*/
|
|
328
|
+
const WAKE_TTL_MS = 10 * 60_000
|
|
329
|
+
|
|
330
|
+
/** 会话 id 两边写法不一定一样(带不带 `session-` 前缀),统一成一个键。 */
|
|
331
|
+
function wakeKey(sessionId) {
|
|
332
|
+
return String(sessionId ?? '').replace(/^session-/, '')
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function dropWake(key) {
|
|
336
|
+
const rec = wakeBySession.get(key)
|
|
337
|
+
if (!rec) return
|
|
338
|
+
if (rec.timer) clearTimeout(rec.timer)
|
|
339
|
+
wakeBySession.delete(key)
|
|
340
|
+
}
|
|
341
|
+
|
|
308
342
|
/**
|
|
309
343
|
* 设这一次的处境正文。传空串等于清掉。
|
|
310
344
|
* @param {string} text
|
|
311
|
-
* @
|
|
345
|
+
* @param {string} [sessionId] 这条处境属于哪条会话
|
|
346
|
+
* @returns {number} 这一次的代次号;传给 `clearWakeSection(n, sessionId)` 才清得掉。
|
|
312
347
|
*/
|
|
313
|
-
export function setWakeSection(text) {
|
|
314
|
-
|
|
348
|
+
export function setWakeSection(text, sessionId = '') {
|
|
349
|
+
const key = wakeKey(sessionId)
|
|
350
|
+
dropWake(key)
|
|
315
351
|
wakeGen += 1
|
|
316
|
-
|
|
352
|
+
const gen = wakeGen
|
|
353
|
+
const rec = { text: String(text ?? ''), gen, timer: null }
|
|
354
|
+
rec.timer = setTimeout(() => {
|
|
355
|
+
if (wakeBySession.get(key)?.gen === gen) dropWake(key)
|
|
356
|
+
}, WAKE_TTL_MS)
|
|
357
|
+
rec.timer.unref?.()
|
|
358
|
+
wakeBySession.set(key, rec)
|
|
359
|
+
return gen
|
|
317
360
|
}
|
|
318
361
|
|
|
319
362
|
/**
|
|
@@ -321,22 +364,71 @@ export function setWakeSection(text) {
|
|
|
321
364
|
* @param {number} [gen] 传了就在代次对不上时**不清**——
|
|
322
365
|
* 防的是「后一轮刚挂上处境,前一轮的 turn/end 晚到把它的抹掉」。
|
|
323
366
|
* 不传就无条件清(卸载、失败收尾这类场合)。
|
|
367
|
+
* @param {string} [sessionId] 只清这一条会话;不传就按下面的口径清
|
|
368
|
+
* (带 gen 时清所有代次对得上的,不带 gen 时全清)。
|
|
369
|
+
*/
|
|
370
|
+
export function clearWakeSection(gen, sessionId) {
|
|
371
|
+
const only = sessionId === undefined ? undefined : wakeKey(sessionId)
|
|
372
|
+
for (const [key, rec] of [...wakeBySession]) {
|
|
373
|
+
if (only !== undefined && key !== only) continue
|
|
374
|
+
if (Number.isFinite(gen) && rec.gen !== gen) continue
|
|
375
|
+
dropWake(key)
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* 他一开口,那次叫醒就作废(2026-11-06)。
|
|
381
|
+
*
|
|
382
|
+
* 最后一道保险:不管是代次错配、硬超时没跑到、还是别的会话串过来的,只要有人
|
|
383
|
+
* 在这条会话里真的说了话,那段「这一轮是你自己的」就不该再影响这一轮。
|
|
384
|
+
* 认不出会话 id 的那一份(挂在空键上)也一起清——它只可能是没人认领的残留。
|
|
324
385
|
*/
|
|
325
|
-
export function
|
|
326
|
-
|
|
327
|
-
|
|
386
|
+
export function clearWakeSectionFor(sessionId) {
|
|
387
|
+
dropWake(wakeKey(sessionId))
|
|
388
|
+
dropWake('')
|
|
328
389
|
}
|
|
329
390
|
|
|
330
|
-
/**
|
|
331
|
-
|
|
332
|
-
|
|
391
|
+
/**
|
|
392
|
+
* 看现在挂着什么。测试里不带会话就是「现在挂着的那一份」。
|
|
393
|
+
* 带了会话而键对不上(宿主换了 id 写法)时,只有一份才兜底返回,
|
|
394
|
+
* 免得处境凭空消失、叫醒变成一次空醒。
|
|
395
|
+
*/
|
|
396
|
+
export function getWakeSection(sessionId) {
|
|
397
|
+
if (sessionId === undefined) {
|
|
398
|
+
if (wakeBySession.size === 0) return ''
|
|
399
|
+
if (wakeBySession.size === 1) return [...wakeBySession.values()][0].text
|
|
400
|
+
return [...wakeBySession.values()].map((r) => r.text).filter(Boolean).join('\n')
|
|
401
|
+
}
|
|
402
|
+
const key = wakeKey(sessionId)
|
|
403
|
+
const rec = wakeBySession.get(key)
|
|
404
|
+
if (rec) return rec.text
|
|
405
|
+
// 宿主两处给的会话 id 写法可能不完全一样:带不带 `session-` 前缀已由 wakeKey 抹平,
|
|
406
|
+
// 剩下一种常见的是「完整 id 与它的后缀」。只有**一条**处境、且两边互为后缀时才兜底给,
|
|
407
|
+
// 免得叫醒变成一次空醒;两条不同的会话之间绝不允许串——那正是「只思考不说话」的成因。
|
|
408
|
+
if (wakeBySession.size === 1) {
|
|
409
|
+
const onlyKey = [...wakeBySession.keys()][0]
|
|
410
|
+
if (onlyKey && key && (onlyKey.endsWith(key) || key.endsWith(onlyKey))) {
|
|
411
|
+
return wakeBySession.get(onlyKey).text
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
return ''
|
|
333
415
|
}
|
|
334
416
|
|
|
335
417
|
/** 测试用:看现在的代次号。 */
|
|
336
|
-
export function getWakeGen() {
|
|
418
|
+
export function getWakeGen(sessionId) {
|
|
419
|
+
if (sessionId !== undefined) return wakeBySession.get(wakeKey(sessionId))?.gen ?? 0
|
|
337
420
|
return wakeGen
|
|
338
421
|
}
|
|
339
422
|
|
|
423
|
+
/** 从 agent 上取会话 id(宿主不同版本字段位置不一样)。 */
|
|
424
|
+
function sessionIdOf(agent) {
|
|
425
|
+
try {
|
|
426
|
+
return String(agent?.session?.header?.id ?? agent?.session?.id ?? '')
|
|
427
|
+
} catch {
|
|
428
|
+
return ''
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
340
432
|
const cache = { file: '', mtimeMs: -1, text: '' }
|
|
341
433
|
|
|
342
434
|
/**
|
|
@@ -460,10 +552,15 @@ export function attachIdentity(agent, root, { frozen = '', herDir = '', log } =
|
|
|
460
552
|
}))
|
|
461
553
|
// 「这一次的处境」单独一段:平时是空串(等于不存在),唤醒前才填。
|
|
462
554
|
// 它不进对话历史,所以他看不到系统在跟她说些什么。
|
|
555
|
+
//
|
|
556
|
+
// 2026-11-06:**按这条会话取**,不再读一个进程级变量。她可能同时住着好几条
|
|
557
|
+
// 会话(家 + 群房 + 旧房 + 出门那条),共用一个变量会把 A 房间的处境
|
|
558
|
+
// 塞进 B 房间正在跑的那一轮,那边她就只思考不说话了。
|
|
559
|
+
const wakeSid = sessionIdOf(agent)
|
|
463
560
|
remember(agent, 'section', sp.section({
|
|
464
561
|
name: WAKE_SECTION,
|
|
465
562
|
order: WAKE_ORDER,
|
|
466
|
-
text: () =>
|
|
563
|
+
text: () => getWakeSection(wakeSid),
|
|
467
564
|
}))
|
|
468
565
|
// 「手边有什么」单独一段(2026-10-29,用户 m01276)。
|
|
469
566
|
// 有些工具我们收不窄——注册在 agent 自己作用域里的(ops_*、Agent Teams)
|
package/lib/index.js
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* dispose → 停定时器、注销路由。停用插件 = 零后台活动。
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import { setTimeout as sleep } from 'node:timers/promises'
|
|
14
|
+
|
|
13
15
|
import { rootDir, readEvents, appendEvent, readJson, paths, writeAtomic, ensureHer } from './store.js'
|
|
14
16
|
import { loadConfig } from './settings.js'
|
|
15
17
|
import { deriveSelf } from './derive.js'
|
|
@@ -23,6 +25,7 @@ import { registerLetter } from './letter.js'
|
|
|
23
25
|
import { registerNote } from './note.js'
|
|
24
26
|
import { registerBringBack } from './keep.js'
|
|
25
27
|
import { registerAsk } from './ask.js'
|
|
28
|
+
import { answerCard } from './cardanswer.js'
|
|
26
29
|
import { registerDream } from './dream.js'
|
|
27
30
|
import { registerProactive } from './proactive.js'
|
|
28
31
|
import { registerObserve } from './observe.js'
|
|
@@ -81,6 +84,11 @@ export function apply(ctx, baseConfig = {}) {
|
|
|
81
84
|
// 写进 inject 的代价是「缺了就整个插件不装」——而缺的那个服务只关掉一条功能。
|
|
82
85
|
const compat = { checked: false, userQuestions: false }
|
|
83
86
|
let heartbeat = null
|
|
87
|
+
let stopped = false
|
|
88
|
+
// 正在认领的那次:shutdown 要等它放开,新实例才不会把旧认领读成「被占着」
|
|
89
|
+
let claiming = null
|
|
90
|
+
// 在途的那次心跳:放开之前要等它,不然它会把放开的认领写回去
|
|
91
|
+
let beating = null
|
|
84
92
|
|
|
85
93
|
async function boot() {
|
|
86
94
|
logBoot(root, `${VERSION} · ${process.version} · ${process.platform}`)
|
|
@@ -119,21 +127,24 @@ export function apply(ctx, baseConfig = {}) {
|
|
|
119
127
|
// 她自己的房间必须先存在:她的会话 cwd 指向这里(不是 root),
|
|
120
128
|
// 这样她不会一睁眼就站在自己的 events.jsonl 旁边。
|
|
121
129
|
const herDir = await ensureHer(root)
|
|
130
|
+
if (stopped) return
|
|
122
131
|
|
|
123
132
|
// ---- 一个 root 只认一个实例(2026-10-28 m01319)----
|
|
124
133
|
// root 是进程级解析的:两个工作区如果都落到同一个家,光看调用者 cwd 分辨不出来
|
|
125
134
|
// (两边 cwd 都在这个 root 里,校验会同时通过)。所以加载时先占位:
|
|
126
135
|
// 谁先占谁是房主;后进来的认出来之后**不写也不崩** —— 她还能说话,只是不落盘。
|
|
127
136
|
try {
|
|
128
|
-
|
|
137
|
+
claiming = claimRoot(root, { version: VERSION })
|
|
138
|
+
const claim = await claiming
|
|
129
139
|
tenancy.tenant = claim
|
|
130
140
|
tenancy.herDir = herDir
|
|
141
|
+
if (stopped) return
|
|
131
142
|
if (!claim.ok) {
|
|
132
143
|
logger.error(`[dsh-fish] ${claim.why}`)
|
|
133
144
|
} else if (claim.instanceId) {
|
|
134
145
|
const id = claim.instanceId
|
|
135
146
|
heartbeat = setInterval(() => {
|
|
136
|
-
beatRoot(root, id)
|
|
147
|
+
beating = beatRoot(root, id)
|
|
137
148
|
.then((still) => {
|
|
138
149
|
if (still === false && tenancy.tenant.ok !== false) {
|
|
139
150
|
// 续不上 = 家被别人接管了。从此不再写,也不再抢回来(否则两边来回覆盖)。
|
|
@@ -185,6 +196,7 @@ export function apply(ctx, baseConfig = {}) {
|
|
|
185
196
|
// 首次启动播种:她最初的那一点点性格,是**第一个事实**,不是可调参数。
|
|
186
197
|
// 之所以写成事件而不是配置项:配置项会被"打开设置页改一下",
|
|
187
198
|
// 事件只能追加——想改就得由她自己 revise,这是 N4 的地基。
|
|
199
|
+
if (stopped) return
|
|
188
200
|
const hasSeed = events.some((e) => e.type === 'seed')
|
|
189
201
|
if (!hasSeed) {
|
|
190
202
|
const seedText = String(
|
|
@@ -209,6 +221,7 @@ export function apply(ctx, baseConfig = {}) {
|
|
|
209
221
|
}
|
|
210
222
|
|
|
211
223
|
const model = deriveSelf(await readEvents(root).then((r) => r.events), { growthDays: config.growthDays })
|
|
224
|
+
if (stopped) return
|
|
212
225
|
|
|
213
226
|
// ---- 她的屋子:工作区 + 专属会话(P3 核心)----
|
|
214
227
|
// 这一步**先于**一切:她得先有个能被你看见的地方,才有"说话"这件事。
|
|
@@ -244,6 +257,7 @@ export function apply(ctx, baseConfig = {}) {
|
|
|
244
257
|
} catch (err) {
|
|
245
258
|
logger.warn('[dsh-fish] 身份文本预写失败', err?.message ?? err)
|
|
246
259
|
}
|
|
260
|
+
if (stopped) return
|
|
247
261
|
try {
|
|
248
262
|
const off = registerIdentity(ctx, { root, herDir, log: (...a) => logger.info(...a) })
|
|
249
263
|
if (typeof off === 'function') disposers.push(off)
|
|
@@ -272,6 +286,7 @@ export function apply(ctx, baseConfig = {}) {
|
|
|
272
286
|
} catch (err) {
|
|
273
287
|
home = { ok: false, why: String(err?.message ?? err).slice(0, 160), wsId: '', sessionId: '' }
|
|
274
288
|
}
|
|
289
|
+
if (stopped) return
|
|
275
290
|
// 观测台要能把「这个家在别处也开着」和普通失败分开报(2026-10-29 m02162)。
|
|
276
291
|
tenancy.home = { ok: home.ok, blocked: home.blocked === true, sessionId: home.sessionId ?? '', why: home.why ?? '' }
|
|
277
292
|
if (home.blocked === true) logger.warn(`[dsh-fish] ${home.why}`)
|
|
@@ -287,6 +302,7 @@ export function apply(ctx, baseConfig = {}) {
|
|
|
287
302
|
}
|
|
288
303
|
}
|
|
289
304
|
|
|
305
|
+
if (stopped) return
|
|
290
306
|
// ---- 身份文本再落一次盘:屋子建好之后 activeCarry 等才齐,内容可能变 ----
|
|
291
307
|
// 这条改动之后它**不再被宿主注入**(那是旧的 AGENTS.md 通道),只是落盘副本;
|
|
292
308
|
// 真正进她上下文的是上面注册的 system prompt 段(每轮组装时现读文件)。
|
|
@@ -311,11 +327,14 @@ export function apply(ctx, baseConfig = {}) {
|
|
|
311
327
|
logger.warn(`[dsh-fish] 口袋排序失败:${String(err?.message ?? err).slice(0, 120)}`)
|
|
312
328
|
}
|
|
313
329
|
|
|
330
|
+
if (stopped) return
|
|
314
331
|
// ---- 侧栏轮数:在侧栏亲口跟她说话也算一轮(issue #6,2026-10-28)----
|
|
315
332
|
// 原来只有 /say 路由会 bumpTurns,用户直接打字时 rotateAfter 永不触发。
|
|
316
333
|
// 观察者只读会话事件,认来源是"真人打的",一轮结束再结账。
|
|
317
334
|
try {
|
|
318
|
-
|
|
335
|
+
// 2026-11-06(外部日志):被占用的实例也在写 `seen:`。轮数观察者现在是写盘方,
|
|
336
|
+
// 必须拿到活着的房主身份——占用权丢了(心跳续不上)会当场改成 ok:false。
|
|
337
|
+
const off = registerTurns(ctx, { root, tenant: tenancy.tenant, log: (...a) => logger.info(...a) })
|
|
319
338
|
if (typeof off === 'function') disposers.push(off)
|
|
320
339
|
} catch (err) {
|
|
321
340
|
logger.warn('[dsh-fish] 轮数观察没挂上', err?.message ?? err)
|
|
@@ -394,23 +413,44 @@ export function apply(ctx, baseConfig = {}) {
|
|
|
394
413
|
}
|
|
395
414
|
|
|
396
415
|
// 宿主可能不 await apply,所以自己兜住异常:启动失败不能带走整个插件树
|
|
397
|
-
boot().catch((err) => {
|
|
416
|
+
const booted = boot().catch((err) => {
|
|
398
417
|
logger.error('[dsh-fish] 启动失败', err?.stack ?? err)
|
|
399
418
|
console.error('[dsh-fish] 启动失败', err)
|
|
400
419
|
})
|
|
401
420
|
|
|
421
|
+
// 卡片被别的插件接走之后(dsh-fish/card),人的回答从这里回来(lib/cardanswer.js)。
|
|
422
|
+
// 同步挂上:宿主重启后 IM 插件可能先连上、先收到人的回答,这时启动还没走完。等它走完才知道这个家是不是自己的。
|
|
423
|
+
// 没认领到就不写,和那些工具同一条理由:被占着,或者启动没走到认领那一步(默认值 ok 是真的,不能信)。
|
|
424
|
+
ctx.on?.('dsh-fish/answer', async (answer) => {
|
|
425
|
+
await booted
|
|
426
|
+
if (tenancy.tenant?.ok === false || !tenancy.tenant?.instanceId) {
|
|
427
|
+
return { ok: false, why: tenancy.tenant?.why || '启动没走完,不知道这个家是不是自己的,这次没写。' }
|
|
428
|
+
}
|
|
429
|
+
return answerCard(root, answer)
|
|
430
|
+
})
|
|
431
|
+
|
|
402
432
|
function shutdown() {
|
|
433
|
+
if (stopped) return
|
|
434
|
+
stopped = true
|
|
403
435
|
try { clearInterval(heartbeat) } catch { /* ignore */ }
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
436
|
+
// 返回给 cordis:它等完 effect 才重新 apply,下一次认领一定在放开之后
|
|
437
|
+
const released = Promise.resolve(claiming)
|
|
438
|
+
.then(async (claim) => {
|
|
439
|
+
await beating
|
|
440
|
+
return claim?.ok && claim.instanceId ? releaseRoot(root, claim.instanceId) : false
|
|
441
|
+
})
|
|
442
|
+
.catch(() => {})
|
|
408
443
|
try { scheduler?.dispose?.() } catch { /* ignore */ }
|
|
409
444
|
try { clearWakeSection() } catch { /* ignore */ }
|
|
410
445
|
for (const d of disposers) { try { d() } catch { /* ignore */ } }
|
|
446
|
+
// 磁盘卡住时别把卸载也挂住,依赖这个插件的服务要等它。用不 ref 的定时器,它不该拖住进程
|
|
447
|
+
return Promise.race([released, sleep(2000, undefined, { ref: false })])
|
|
411
448
|
}
|
|
412
449
|
|
|
413
|
-
|
|
450
|
+
// 宿主的 cordis 4 没有 'dispose' 事件,也不管 apply 返回的 { dispose },只认 ctx.effect。
|
|
451
|
+
// 只挂事件的话,插件停用后定时器还在跑,再启用时新旧两个调度器一起叫她。
|
|
452
|
+
if (typeof ctx.effect === 'function') ctx.effect(() => shutdown, 'dsh-fish.shutdown')
|
|
453
|
+
else ctx.on?.('dispose', shutdown)
|
|
414
454
|
|
|
415
455
|
return {
|
|
416
456
|
dispose() {
|
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
|
/** 还等着点头的那几件(界面用)。 */
|