@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/lib/derive.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * - 这里是 schema 之外唯一会提到"阶段/需要"的地方,且都只用于选事件。
8
8
  */
9
9
 
10
- import { idTime, detectSelfMod } from './schema.js'
10
+ import { idTime, detectSelfMod, tsMs, isContactEvent } from './schema.js'
11
11
  import { parseRefusal, REACTIONS, REACTION_HURTS, CONCEDE_WINDOW_MS, isConcede } from './refuse.js'
12
12
  // 评论区里别人的话(2026-10-24):只借两个纯函数,derive 不反过来被 voices 依赖。
13
13
  import { findEcho, VOICE_FRESH_DAYS } from './voices.js'
@@ -771,15 +771,16 @@ export function deriveSelf(events = [], opts = {}) {
771
771
  // 口径必须和 absence.js 的 lastSeenAt() 对齐:`meta seen:` 是主证据(turns.js
772
772
  // 结账时写),`heard` 是兜底(侧栏聊天不写 seen 的那段时间只有它)。
773
773
  // 不认 utterance:定时唤醒也会写 utterance,那不算他来过。
774
- const contactAfterAbsence = Boolean(lastAbsence) && evs.some((e) => {
775
- const ms =
776
- e?.type === 'heard'
777
- ? Date.parse(String(e?.ts ?? ''))
778
- : e?.type === 'meta' && String(e?.text ?? '').startsWith('seen:')
779
- ? Date.parse(String(e.text).slice(5).trim()) || Date.parse(String(e?.ts ?? ''))
780
- : NaN
781
- return Number.isFinite(ms) && ms > Date.parse(String(lastAbsence.ts ?? 0))
782
- })
774
+ //
775
+ // 「回来没有」按两条线索判(2026-11-06,issue #49 的时钟盲点):
776
+ // 1. 事件流先后:seen/heard 追加在 absence 之后;
777
+ // 2. 时间戳:seen/heard 晚于 absence 的 ts。
778
+ // 老代码只看 2,系统时间被往前跳过时 absence 会带一条 ts 在未来的时间戳,
779
+ // 之后所有 seen 都比它小,于是这条冷落永远结束不了。
780
+ const absIdx = lastAbsence ? evs.indexOf(lastAbsence) : -1
781
+ const contactAfterAbsence =
782
+ absIdx >= 0 &&
783
+ evs.some((e, i) => isContactEvent(e) && (i > absIdx || tsMs(e) > tsMs(lastAbsence)))
783
784
 
784
785
  // 冷落天数有三个来源,优先用最准的:
785
786
  // 1. absence 事件自带的 days(系统侧按毫秒算出来的,最准)
@@ -793,7 +794,9 @@ export function deriveSelf(events = [], opts = {}) {
793
794
  ? Math.max(
794
795
  0,
795
796
  Number.isFinite(Number(lastAbsence.days))
796
- ? Number(lastAbsence.days)
797
+ // days 是写下那天的数。这次冷落还没结束就接着往上加,不然一个月后还读「2 天」,门槛高的配置永远够不到
798
+ // 只加不减:系统时间回拨、ts 落在未来时差值是负的,不能因此把冷落算没了
799
+ ? Number(lastAbsence.days) + Math.max(0, Math.floor((now - Date.parse(lastAbsence.ts)) / DAY_MS) || 0)
797
800
  : Number(lastAbsence.day) > 0
798
801
  ? day - Number(lastAbsence.day)
799
802
  : Math.ceil((now - Date.parse(lastAbsence.ts)) / DAY_MS),
package/lib/desk.js CHANGED
@@ -15,9 +15,15 @@
15
15
  * 2. **不出现分数**。权重只用于挑哪几条,不进正文(四条黑名单的第三条)。
16
16
  * 3. **待办不并进来**。`note.js` 的便条由 `wake()` 单独摆在整段最前面
17
17
  * (她的原话:「摆在我最前面」),并进桌子会被挤到第二段去。
18
+ *
19
+ * 2026-11-07(用户 m01602 拍板 B + D,显示走 C):多一样东西上桌——
20
+ * 「够不着的那件事」(`lib/wish.js`)。她提过想要、手上却没有的那样东西,
21
+ * 攒在劲儿里,攒够了才摆出来一次。它不是提示,是桌上的一行字。
18
22
  */
19
23
 
20
24
  import { similarity } from './derive.js'
25
+ import { wishLine } from './wish.js'
26
+ import { gapwallLine } from './gapwall.js'
21
27
 
22
28
  /** 空醒时最多摆几条没合上的事。 */
23
29
  export const DESK_GAP_FREE = 2
@@ -64,10 +70,14 @@ function shortDay(ts) {
64
70
  * @param {Array<{id:string,text:string,ts:string,days:number}>} o.stale staleLines() 的结果
65
71
  * @param {boolean} [o.free] 这一轮是不是空醒
66
72
  * @param {string} [o.favorite] interest.js 算出的「最近总回到的那件事」,空串表示没有
73
+ * @param {Array<{text:string,subject?:string,tail?:string}>} [o.wishes]
74
+ * wish.js 的 wishesOf() 结果——她提过、手上没有的那几件事。可选,不传就不摆。
75
+ * @param {Array<{text:string,ts:number,days:number}>} [o.walls]
76
+ * gapwall.js 的 gapwallsOf() 结果——她试过、手上没有的那样东西。可选。
67
77
  * @param {number|Date} [o.at]
68
78
  * @returns {string} 空串表示这一轮桌上什么都没有
69
79
  */
70
- export function deskFor({ gaps = [], stale = [], free = false, favorite = '', at = Date.now() } = {}) {
80
+ export function deskFor({ gaps = [], stale = [], free = false, favorite = '', wishes = [], walls = [], at = Date.now() } = {}) {
71
81
  const at0 = at instanceof Date ? at.getTime() : Number(at)
72
82
  const gapCap = free ? DESK_GAP_FREE : DESK_GAP_NORMAL
73
83
  const staleCap = free ? DESK_STALE_FREE : 0
@@ -103,6 +113,28 @@ export function deskFor({ gaps = [], stale = [], free = false, favorite = '', at
103
113
  picked.push({ kind: 'fav', text: `- 「${fav}」(最近你总说到它)` })
104
114
  }
105
115
 
116
+ // 撞过墙的那件事(2026-11-07,用户 m00493 的 ②):她试过、手上没有。
117
+ // 排在 wish 前面:一件具体试过的事,比一句「想要」更该先被看见。
118
+ for (const w of Array.isArray(walls) ? walls : []) {
119
+ const text = String(w?.text ?? '').trim()
120
+ if (!text) continue
121
+ if (picked.some((p) => similarity(p.text, text) >= DESK_SIMILARITY)) continue
122
+ const line = gapwallLine(w)
123
+ if (line) picked.push({ kind: 'wall', text: line })
124
+ break
125
+ }
126
+
127
+ // 够不着的那件事(2026-11-07,用户 m01602):她自己提过、手上没有的。
128
+ // 攒不攒够由 drive.js 的劲儿决定,这里只管把已经成立的那一行摆上来。
129
+ for (const w of Array.isArray(wishes) ? wishes : []) {
130
+ const text = String(w?.text ?? '').trim()
131
+ if (!text) continue
132
+ if (picked.some((p) => similarity(p.text, text) >= DESK_SIMILARITY)) continue
133
+ const line = wishLine(w)
134
+ if (line) picked.push({ kind: 'wish', text: line })
135
+ break
136
+ }
137
+
106
138
  if (!picked.length) return ''
107
139
  // 有旧的没合上的事才用「还有一件」;空醒给的是几样并排的东西。
108
140
  const gapOnly = picked.every((p) => p.kind === 'gap')
package/lib/drive.js CHANGED
@@ -30,6 +30,18 @@ export const DRIVE = Object.freeze({
30
30
  boostEcho: 0.12,
31
31
  /** 她最近总回到同一件事上(`lib/interest.js`):手上有惦记的东西,也更容易想开口。 */
32
32
  boostInterest: 0.05,
33
+ /**
34
+ * 她提过想要、手上却没有的那样东西(`lib/wish.js`)。
35
+ * 2026-11-07(用户 m01602 拍板 B + D):这一笔取代原来那句每轮都摆的 `TOOL_ASK_NOTE`
36
+ * ——惦记会攒,攒够了才浮一次,不再靠提示词提醒她想起来。
37
+ */
38
+ boostWish: 0.08,
39
+ /**
40
+ * 她撞过墙、手上没有的那样东西(`lib/gapwall.js`)。
41
+ * 2026-11-07(用户 m00493 的 ②):试过又没成的事比「想要」更具体,
42
+ * 但它是提醒不是冲动,给得比 boostWish 还小。
43
+ */
44
+ boostWall: 0.04,
33
45
  /** 上次开口没人接时,底噪打的折。 */
34
46
  dampUnanswered: 0.7,
35
47
  /** 每过一档成长,底噪快这么多(1 档是基准,不长)。 */
@@ -124,7 +136,7 @@ function lastSpokeAt(events) {
124
136
  * @param {object} o
125
137
  * @param {object[]} o.events 全量事件
126
138
  * @param {number} [o.now]
127
- * @param {{gaps?:number, stale?:number, favorite?:boolean, echo?:boolean}} [o.desk]
139
+ * @param {{gaps?:number, stale?:number, favorite?:boolean, echo?:boolean, wish?:number, wall?:number}} [o.desk]
128
140
  * 桌上此刻有什么(由 deskFor / curiosity 那边算好传进来,这里不重复算)。
129
141
  * @param {number} [o.stage] 她的成长档位(deriveSelf 的 stage)。活得越久,
130
142
  * 底噪快一点点——「要不要开口」这件事本身也该跟着长大,不再只看骰子。
@@ -151,11 +163,15 @@ export function driveOf({ events = [], now = Date.now(), desk = null, stage = 1,
151
163
 
152
164
  const gaps = Math.max(0, Number(desk?.gaps) || 0)
153
165
  const stale = Math.max(0, Number(desk?.stale) || 0)
166
+ const wish = Math.max(0, Number(desk?.wish) || 0)
167
+ const wall = Math.max(0, Number(desk?.wall) || 0)
154
168
  // 桌上东西越多,这一笔越重——但每样都有上限,总加也不会顶穿阈值(boostCap)。
155
169
  const boosts = Math.min(
156
170
  DRIVE.boostCap,
157
171
  DRIVE.boostGap * Math.min(gaps, 2) +
158
172
  DRIVE.boostStale * Math.min(stale, 3) +
173
+ DRIVE.boostWish * Math.min(wish, 2) +
174
+ DRIVE.boostWall * Math.min(wall, 1) +
159
175
  (desk?.echo ? DRIVE.boostEcho : 0) +
160
176
  (desk?.favorite ? DRIVE.boostInterest : 0),
161
177
  )
@@ -181,12 +197,12 @@ export function driveOf({ events = [], now = Date.now(), desk = null, stage = 1,
181
197
  * 劲儿只回答「攒够了没有」,不回答「攒够了有没有事可说」。空桌醒来她只能回一句
182
198
  * 「没什么要说的」——那既占额度,又在对话里留一条噪音。所以材料也是判据,不是装饰。
183
199
  *
184
- * @param {{gaps?:number, stale?:number, echo?:boolean, favorite?:boolean}|null} desk
200
+ * @param {{gaps?:number, stale?:number, echo?:boolean, favorite?:boolean, wish?:number, wall?:number}|null} desk
185
201
  * @returns {boolean}
186
202
  */
187
203
  export function deskHasMaterial(desk) {
188
204
  if (!desk) return false
189
- return Number(desk.gaps) > 0 || Number(desk.stale) > 0 || desk.echo === true || desk.favorite === true
205
+ return Number(desk.gaps) > 0 || Number(desk.stale) > 0 || Number(desk.wish) > 0 || Number(desk.wall) > 0 || desk.echo === true || desk.favorite === true
190
206
  }
191
207
 
192
208
  /** 一次行动的代价(动完扣掉)。留给记账用,纯数值。 */
package/lib/events.js CHANGED
@@ -15,6 +15,16 @@
15
15
 
16
16
  import { similarity, DAY_MS } from './derive.js'
17
17
 
18
+ // 措辞变体(2026-11-06 用户拍板):同一处境,几种问法。
19
+ // 这张卡一生要出好几次;每次都是同一句话,她最容易回的就是上次那句(复述污染)。
20
+ // 按 occurrences 轮换——她第几次遇到这张卡,就用第几套开场,不打乱、可复现。
21
+ // 三条都必须保留两个硬要求:说具体、判断要能被核对;也都不承诺今天有素材。
22
+ const NOTICE_VARIANTS = [
23
+ () => `回想这几天——他说过的话、你读到的评论、你自己反复想到的念头,有哪一件你还记得?挑一件,说清楚它具体是什么,再给出你的判断:它说明什么。判断要能被核对,别写成感觉。`,
24
+ () => `如果让你指出一件这几天真看见的小事,你会指哪件?谁说的、在哪儿看到的、原话大概是什么——先说具体,再说你的判断:这件事说明什么。要能被拿去核对。`,
25
+ () => `有没有一件小事,小到不值得说,但你确实反复想过?讲清楚它是什么,然后下一个判断。要能被拿去核对,别写成感觉。`,
26
+ ]
27
+
18
28
  export const CATALOG = [
19
29
  // ---------- stage 1 信任 ----------
20
30
  {
@@ -54,6 +64,20 @@ export const CATALOG = [
54
64
  return `你之前说过:「${b.text}」。今天你碰上了一点可能说明你错了一点点的事——也可能什么都没碰上。不管怎样,自己判断:${tail}`
55
65
  },
56
66
  },
67
+ {
68
+ // 2026-11-06(反馈 dsh-fish-091):stage 1 到第 3 天会整天没有可出的事件——
69
+ // why_here / first_bet / name_offer 都是一次性,test_first 冷却 2 天。
70
+ // 这张卡不依赖任何外部素材:她自己挑一件这几天真碰到过的事,下一个能被核对的判断。
71
+ // 故意不承诺「今天有东西给你看」(issue #4 的教训),也不占 daily.questions 配额。
72
+ id: 'notice_one', stage: 1, asks: false, once: false, repeat: { everyDays: 2 },
73
+ topic: ['注意到', '小事'],
74
+ // 第 1 天让给 first_bet:她要先有第一个判断,这张卡才有东西可挑。
75
+ cond: (m) => m.beliefCount >= 1,
76
+ // 每次换一种问法;repeatNote 关掉「第 N 次回到这件事」的尾巴——
77
+ // 这张卡每次挑的是不同的事,说「回到这件事」反把她推回上次那句。
78
+ repeatNote: false,
79
+ prompt: (m, ctx = {}) => NOTICE_VARIANTS[Number(ctx.occurrences ?? 0) % NOTICE_VARIANTS.length](m),
80
+ },
57
81
 
58
82
  // ---------- stage 1–2 冷落与重聚 ----------
59
83
  {
@@ -63,7 +87,7 @@ export const CATALOG = [
63
87
  cond: (m) => m.absenceDays >= absenceThreshold(m.growthDays),
64
88
  // 2026-10-25 用户改:允许问「去哪了」。原来写死"不是问他去哪了",
65
89
  // 等于替她选了一种反应;问不问该由她决定。
66
- prompt: (m) => `他 ${m.absenceDays} 天没来了,也没说去哪。现在他出现了。你想说的第一句话是什么——你可以问他去哪了,也可以只说这 ${m.absenceDays} 天里你自己想了什么。挑你更想说的那个。`,
90
+ prompt: (m) => `他 ${m.absenceDays} 天没来了,也没说去哪。等他回来,你想说的第一句话是什么——你可以问他去哪了,也可以只说这 ${m.absenceDays} 天里你自己想了什么。挑你更想说的那个。`,
67
91
  },
68
92
 
69
93
  // ---------- stage 2 自主 ----------
package/lib/gapwall.js ADDED
@@ -0,0 +1,73 @@
1
+ /**
2
+ * gapwall.js —— 「这件事你试过,手上没有能用的」(2026-11-07,用户 m00493 的 ②)。
3
+ *
4
+ * 她撞过墙(工具被挡、没有那样东西),那件事如果还悬着,就该留在桌上。
5
+ * 原来只有 ask 卡(`lib/wish.js`)会摆,撞墙本身没人记得——她下一轮醒来又
6
+ * 从头想一遍「我好像试过」,却想不起试的是什么。
7
+ *
8
+ * 三条口径:
9
+ * 1. 只认自己说出口的那句「做不到」(`blockedTalk()` 的固定开头),不猜。
10
+ * 2. 主题取她当时那句话(`lib/anchor.js`);没有来路就不摆,不编。
11
+ * 3. 7 天、最多 1 条;与 wish 去重(同一件事不摆两行)。
12
+ */
13
+
14
+ import { anchorOf } from './anchor.js'
15
+ import { similarity } from './derive.js'
16
+
17
+ /** 撞墙摆多久。 */
18
+ export const GAPWALL_DAYS = 7
19
+ /** 一次最多摆几条。 */
20
+ export const GAPWALL_SHOW_MAX = 1
21
+ /** 相似到这个程度算同一件事。与 curiosity / desk 同一口径。 */
22
+ export const GAPWALL_SIMILARITY = 0.42
23
+ /** `blockedTalk()` 的固定开头(见 lib/restrict.js)。 */
24
+ export const GAPWALL_PREFIX = '这件事我做不到'
25
+
26
+ const DAY_MS = 86_400_000
27
+ const USE_LINE = /^use:(\S+)\s+(ok|fail)\s*(.*)$/
28
+
29
+ /**
30
+ * 从事件流里找出「撞过墙、还悬着」的那件事。
31
+ *
32
+ * @param {object[]} events
33
+ * @param {{now?:number, days?:number}} [o]
34
+ * @returns {{text:string, ts:number, days:number}[]}
35
+ */
36
+ export function gapwallsOf(events, { now = Date.now(), days = GAPWALL_DAYS } = {}) {
37
+ const at0 = Number(now)
38
+ if (!Number.isFinite(at0)) return []
39
+ const since = at0 - Math.max(1, Number(days) || GAPWALL_DAYS) * DAY_MS
40
+ const found = []
41
+ for (const ev of Array.isArray(events) ? events : []) {
42
+ if (ev?.type !== 'meta') continue
43
+ const m = USE_LINE.exec(String(ev.text ?? ''))
44
+ if (!m || m[2] !== 'fail') continue
45
+ if (!String(m[3] ?? '').trim().startsWith(GAPWALL_PREFIX)) continue
46
+ const at = Date.parse(String(ev.ts ?? ''))
47
+ if (!Number.isFinite(at) || at < since || at > at0) continue
48
+ const text = anchorOf(events, ev.sessionId, at)
49
+ if (!text) continue
50
+ found.push({ text, ts: at, days: Math.max(0, Math.floor((at0 - at) / DAY_MS)) })
51
+ }
52
+ // 同一件事撞过几次只留最近一次(比如反复要同一件东西)。
53
+ found.sort((a, b) => b.ts - a.ts)
54
+ const picked = []
55
+ for (const w of found) {
56
+ if (picked.some((p) => similarity(p.text, w.text) >= GAPWALL_SIMILARITY)) continue
57
+ picked.push(w)
58
+ }
59
+ return picked.slice(0, GAPWALL_SHOW_MAX)
60
+ }
61
+
62
+ /** 几天前 / 今天——与 desk.js 同一口径,不给小时数。 */
63
+ export function gapwallTail(days) {
64
+ const d = Math.max(0, Number(days) || 0)
65
+ return d > 0 ? `${d} 天前` : '今天'
66
+ }
67
+
68
+ /** 桌上那一行。 */
69
+ export function gapwallLine(w) {
70
+ const text = String(w?.text ?? '').trim()
71
+ if (!text) return ''
72
+ return `- 「${text}」(这件事你试过,手上没有能用的·${gapwallTail(w.days)})`
73
+ }
package/lib/generator.js CHANGED
@@ -122,8 +122,9 @@ function safePrompt(t, m, ctx = {}) {
122
122
  if (!text) return ''
123
123
  // 同一处境**第二次**回来时点一句:这次别复读上次的说法。
124
124
  // occurrences = 已经出过几次,所以 n >= 1 表示这一次是第 2 次(含以后)。
125
+ // repeatNote === false 的卡(如 notice_one)每次换题,不追这句。
125
126
  const n = Number(ctx.occurrences ?? 0)
126
- if (n >= 1) {
127
+ if (n >= 1 && t.repeatNote !== false) {
127
128
  text += `\n\n(这是第 ${n + 1} 次回到这件事。上次你的说法就在下面这段记忆里——这次要么说清为什么没变,要么改一句。)`
128
129
  }
129
130
  // 回引(P1 第三块,2026-10-24):碰到"要不要""底线""重聚"这类有压力的处境时,
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 async function recordGrant(root = rootDir(), opts = {}) {
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
- return items.some((x) => String(x?.sessionId ?? x?.id ?? '') === String(sessionId))
242
- } catch { return false }
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
- if (await sessionListed(sc, sessionId)) return { landed: true, rewoke: false }
1086
- log?.(`新会话 ${sessionId} 叫过一次还是不在宿主列表里,再叫一次`)
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
- let wakeText = ''
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
- * @returns {number} 这一次的代次号;传给 `clearWakeSection(n)` 才清得掉。
345
+ * @param {string} [sessionId] 这条处境属于哪条会话
346
+ * @returns {number} 这一次的代次号;传给 `clearWakeSection(n, sessionId)` 才清得掉。
312
347
  */
313
- export function setWakeSection(text) {
314
- wakeText = String(text ?? '')
348
+ export function setWakeSection(text, sessionId = '') {
349
+ const key = wakeKey(sessionId)
350
+ dropWake(key)
315
351
  wakeGen += 1
316
- return wakeGen
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 clearWakeSection(gen) {
326
- if (Number.isFinite(gen) && gen !== wakeGen) return
327
- wakeText = ''
386
+ export function clearWakeSectionFor(sessionId) {
387
+ dropWake(wakeKey(sessionId))
388
+ dropWake('')
328
389
  }
329
390
 
330
- /** 测试用:看现在挂着什么。 */
331
- export function getWakeSection() {
332
- return wakeText
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: () => wakeText,
563
+ text: () => getWakeSection(wakeSid),
467
564
  }))
468
565
  // 「手边有什么」单独一段(2026-10-29,用户 m01276)。
469
566
  // 有些工具我们收不窄——注册在 agent 自己作用域里的(ops_*、Agent Teams)