@ssamurias/dsh-fish 0.9.0 → 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.
Files changed (50) hide show
  1. package/AGENTS.md +60 -4
  2. package/CHANGELOG.md +85 -1
  3. package/README.md +13 -6
  4. package/RELEASE-NOTES-0.9.1.md +296 -0
  5. package/RELEASE-NOTES-0.9.2.md +46 -0
  6. package/RELEASE.md +152 -0
  7. package/lib/absence.js +41 -27
  8. package/lib/ask.js +91 -30
  9. package/lib/askinchat.js +83 -1
  10. package/lib/browse.js +1 -0
  11. package/lib/capture.js +35 -2
  12. package/lib/cardanswer.js +84 -0
  13. package/lib/client.js +116 -19
  14. package/lib/comment.js +111 -2
  15. package/lib/compact.js +45 -1
  16. package/lib/curiosity.js +6 -2
  17. package/lib/deliver.js +46 -0
  18. package/lib/derive.js +25 -13
  19. package/lib/desk.js +3 -1
  20. package/lib/dreamtable.js +13 -2
  21. package/lib/drive.js +14 -2
  22. package/lib/events.js +25 -1
  23. package/lib/freewake.js +4 -1
  24. package/lib/generator.js +2 -1
  25. package/lib/grant.js +18 -2
  26. package/lib/home.js +142 -29
  27. package/lib/identity.js +111 -14
  28. package/lib/index.js +50 -10
  29. package/lib/interest.js +3 -1
  30. package/lib/keep.js +21 -9
  31. package/lib/keepsake.js +20 -13
  32. package/lib/look.js +23 -3
  33. package/lib/note.js +255 -20
  34. package/lib/observe.js +14 -4
  35. package/lib/owner.js +5 -2
  36. package/lib/persona.js +26 -9
  37. package/lib/proactive.js +67 -12
  38. package/lib/say.js +73 -13
  39. package/lib/scheduler.js +186 -56
  40. package/lib/schema.js +18 -0
  41. package/lib/selfdoc.js +5 -3
  42. package/lib/selfupdate.js +8 -1
  43. package/lib/settings.js +5 -0
  44. package/lib/status.js +56 -6
  45. package/lib/store.js +14 -2
  46. package/lib/tenant.js +12 -0
  47. package/lib/turns.js +47 -2
  48. package/lib/upgrade.js +4 -1
  49. package/package.json +2 -1
  50. package/scripts/fix-legacy-link.mjs +197 -90
package/lib/home.js CHANGED
@@ -197,8 +197,16 @@ export async function ensureTalkSession(ctx, opts = {}) {
197
197
  * 现在先把「归档的那条」和「这个工作区里另外的活会话」分开判断:只要还有一条
198
198
  * 没归档的,就认它,而不是让整个家停摆。
199
199
  *
200
- * 取最后一条(宿主按创建顺序往 sessionIds 里追加,末尾即最近那条),但不做别的假设:
201
- * 拿不到 registry、sessionIds 不是数组、全是归档的,都退回空串,由调用方按原样拒绝。
200
+ * 取最前面那条(宿主建会话时是 `sessionIds: [新id, ...旧的]`,往**前**插,
201
+ * 所以头部才是最新那条),但不做别的假设:拿不到 registry、sessionIds 不是数组、
202
+ * 全是归档的,都退回空串,由调用方按原样拒绝。
203
+ *
204
+ * issue #33:这里以前取 `live[live.length - 1]`,注释还写着「宿主按创建顺序往
205
+ * sessionIds 里追加」——方向反了。结果是当前会话被归档、同一工作区还有别的活会话时,
206
+ * 认回来的是**最旧**那条,她于是坐进一条早就没在用的对话。
207
+ * 需要提醒的是:用户在侧栏拖动过顺序时 `live[0]` 是列表最上面那条,未必就是最新;
208
+ * 真要按时间严格排序,得问 `sessionController.list()`(`sessionListed` 那条路)。
209
+ * 这里只修「明显反了」的方向,不对侧栏顺序做额外假设。
202
210
  */
203
211
  function pickLiveSession(reg, wsId, avoid = '') {
204
212
  try {
@@ -208,7 +216,7 @@ function pickLiveSession(reg, wsId, avoid = '') {
208
216
  const live = ids
209
217
  .map((id) => String(id ?? ''))
210
218
  .filter((id) => id && id !== avoid && !archived.includes(id))
211
- return live.length ? live[live.length - 1] : ''
219
+ return live.length ? live[0] : ''
212
220
  } catch { return '' }
213
221
  }
214
222
 
@@ -226,12 +234,31 @@ function pickLiveSession(reg, wsId, avoid = '') {
226
234
  * (返回 false,维持原来的判定,不凭空认活)。
227
235
  */
228
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) {
229
254
  try {
230
- if (typeof sc?.list !== 'function') return false
255
+ if (typeof sc?.list !== 'function') return { found: false, blank: undefined }
231
256
  const l = await sc.list({})
232
257
  const items = l?.items ?? l?.value?.items ?? []
233
- return items.some((x) => String(x?.sessionId ?? x?.id ?? '') === String(sessionId))
234
- } 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 } }
235
262
  }
236
263
 
237
264
  /**
@@ -433,7 +460,7 @@ async function ensureTalkSessionOnce(ctx, { root = rootDir(), wsId, title = TITL
433
460
  * 只记**成功送达**的那一次:宿主的 prompt 是「这一轮被收下了」就 resolve,
434
461
  * 收下就一定会跑出内容;抛错(比如漏传 signal)则不写,下次启动还会再试。
435
462
  */
436
- 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 } = {}) {
437
464
  if (!sessionId) return { woke: false, why: '没有会话 id' }
438
465
  const sc = ctx?.get?.('sessionController') ?? ctx?.sessionController
439
466
  if (!sc) return { woke: false, why: '宿主没有 sessionController 服务' }
@@ -484,6 +511,14 @@ export async function wakeIfBlank(ctx, { sessionId, root = '', log, freshOnly =
484
511
  content: [{ type: 'text', text: freshWakeText(new Date(), { rotated }) }],
485
512
  }, new AbortController().signal)
486
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
+ })
487
522
  .catch((err) => log?.(`新屋子唤醒失败:${String(err?.message ?? err).slice(0, 140)}`))
488
523
  log?.('新屋子还是空的,叫了她一声(要等她这一轮动起来,侧栏才会显示)')
489
524
  return { woke: true, why: '' }
@@ -684,6 +719,44 @@ export const LAST_WORDS_WAIT_MS = 60_000
684
719
  */
685
720
  export const WAKE_VERIFY_MS = 12_000
686
721
 
722
+ /**
723
+ * 正在复核的会话 id(2026-11-06 换房报告)。
724
+ *
725
+ * 换房、`/reset`、`/home/rebuild` 都会在叫醒后挂一次复核,12 秒后各自问一遍。
726
+ * 同一批里挂两次,就会在这条新会话上连着叫两声——她那边看见的是两条「醒一醒」。
727
+ * 复核自己也会再叫一次(force),所以「同一条正在复核」必须直接跳过。
728
+ */
729
+ const verifying = new Set()
730
+
731
+ /**
732
+ * 换会话失败之后多久再试(2026-11-06)。
733
+ *
734
+ * 结账那条路上每次人说话都会 bumpTurns,`turns` 到了线就试着换。换失败时
735
+ * `turns` 不归零(这是对的:屋子确实还没换),于是下一轮又会试——宿主真
736
+ * 建不出会话时就成了每回一句话都重试一次。这里记下失败的时刻,冷却期内
737
+ * 直接跳过,不再打扰宿主,也不再往对话里发东西。
738
+ */
739
+ export const ROTATE_RETRY_MS = 5 * 60_000
740
+
741
+ /**
742
+ * 记一笔「这次没换成」。只写 home.json 的两个字段,任何失败都吞掉:
743
+ * 记不下来顶多是下次不回退避,不能让记账本身把错误放大。
744
+ */
745
+ async function noteRotateFail(root, why) {
746
+ const at = new Date().toISOString()
747
+ try {
748
+ await mutateHome(async () => {
749
+ const fresh = (await readJson(homeFile(root), null)) ?? {}
750
+ await writeAtomic(
751
+ homeFile(root),
752
+ JSON.stringify({ ...fresh, rotateFailAt: at, rotateFailWhy: String(why ?? '').slice(0, 160) }, null, 2),
753
+ )
754
+ })
755
+ } catch {
756
+ /* 记不下来就算了 */
757
+ }
758
+ }
759
+
687
760
  /** 她说「不用」的各种说法。命中就当没留。 */
688
761
  const DECLINE_RE = /^(不用|不想留|不想|不留|不了|算了|没事|没有|无|no|nope)[。.!!…\s]*$/i
689
762
 
@@ -854,24 +927,65 @@ async function rotateTalkSessionOnce(ctx, { root = rootDir(), wsId, title = TITL
854
927
  log?.(`换会话跳过:这次要换的是 ${fromId},屋子已经指向 ${old}`)
855
928
  return { ok: true, sessionId: old, old: String(saved.rotatedFrom ?? ''), skipped: true }
856
929
  }
930
+ // 2026-11-06:建会话失败后冷却一段时间再试。
931
+ // 不冷却的话,结账每轮都会走到这里:`turns` 没清零,下一轮结账又到点,
932
+ // 于是每回一句话都重试一次(旧版还会每次都先问一遍「要不要留话」,见 2.5 步)。
933
+ // 宿主建不出会话多半是这类问题要待一会儿才好,5 分钟够它缓过来,也不至于
934
+ // 让屋子永远卡在旧会话里。
935
+ const failAt = Date.parse(String(saved.rotateFailAt ?? ''))
936
+ if (Number.isFinite(failAt) && Date.now() - failAt < ROTATE_RETRY_MS) {
937
+ return {
938
+ ok: false,
939
+ sessionId: '',
940
+ cooled: true,
941
+ why: `上次没换成(${String(saved.rotateFailWhy ?? '原因不明').slice(0, 80)}),${Math.ceil((ROTATE_RETRY_MS - (Date.now() - failAt)) / 60_000)} 分钟后再试`,
942
+ }
943
+ }
944
+
857
945
  // 2026-11-03(issue #19):原来 `new Date().toISOString().slice(0,10)` 取的是 **UTC 日期**,
858
946
  // 东八区晚上 8 点之后换会话,旧会话会被改名成「…·昨天的日期 之前」.
859
947
  // dateKey() 用的是 getFullYear/getMonth/getDate,本地钟。
860
948
  const when = stamp ?? dateKey()
861
949
 
950
+ // 1) 旧会话改名留档。失败不影响后面(改不了名也还是那条会话)。
951
+ if (old && typeof sc.rename === 'function') {
952
+ await sc.rename({ sessionId: old, title: `${title}·${when} 之前` }).catch(() => {})
953
+ }
954
+
955
+ // 2) 建新的
956
+ let sessionId = ''
957
+ try {
958
+ const created = await sc.create({ workspaceId: wid })
959
+ sessionId = String(created?.sessionId ?? created?.id ?? '')
960
+ } catch (err) {
961
+ await noteRotateFail(root, `建会话失败:${String(err?.message ?? err).slice(0, 160)}`)
962
+ return { ok: false, sessionId: '', why: `建会话失败:${String(err?.message ?? err).slice(0, 160)}` }
963
+ }
964
+ if (!sessionId) {
965
+ await noteRotateFail(root, '宿主建了会话但没给 id')
966
+ return { ok: false, sessionId: '', why: '宿主建了会话但没给 id' }
967
+ }
968
+ if (typeof sc.rename === 'function') await sc.rename({ sessionId, title }).catch(() => {})
969
+
862
970
  /**
863
- * 0) 换房间之前,先请旧会话里的她自己留一句话(0.8.7,用户 m00998)。
971
+ * 2.5) 换房间之前,先请旧会话里的她自己留一句话(0.8.7,用户 m00998)。
864
972
  *
865
973
  * 为什么要这一句:轮换之后新会话拿到的身份文档里全是**结论**,没有一句是
866
974
  * 「上一个房间里的我留下的」。于是她读到旧日记会说「像另一个人写的」。
867
975
  * 一句原话解决的不是记忆问题(原文一直都在),是**归属**问题——
868
976
  * 「那是你说的」这句话只有她本人说出来才算数。
869
977
  *
978
+ * **为什么放在建会话成功之后**(2026-11-06 修):这句话是往她正在用的那条
979
+ * 会话里发一条 prompt,会作为一条消息直接出现在他的对话窗口里。原来写在
980
+ * 第 0 步(建会话之前),于是只要 `sc.create()` 抛错/不回 id,这一问就已经
981
+ * 发出去了、轮换却失败了,`turns` 也不清零——下一轮结账又到点、又问一遍,
982
+ * 他那边就变成「每次她回完都弹这一句」。挪到建会话成功之后,换不成就不问。
983
+ *
870
984
  * **只把话发出去,不等她回**。轮换比这句话重要得多:
871
985
  * 上一版是 `await askForLastWords(...)`,宿主不派 `turn/end` 时会一直等到
872
986
  * 60 秒超时,把整个轮换(以及叫醒新会话)全卡住。所以这里只拿到一个
873
- * Promise,改名/建会话/写 home 照常往下走;等这句话回来了再补写进
874
- * home.json 并刷一次身份文档(她那一下的人设段是新房间里第一次读到的)。
987
+ * Promise,写 home 照常往下走;等这句话回来了再补写进 home.json 并刷一次
988
+ * 身份文档(她那一下的人设段是新房间里第一次读到的)。
875
989
  *
876
990
  * 三个约束:
877
991
  * - 失败绝不影响轮换:没有 prompt / 超时 / 她说「不用」都只是没有这一节;
@@ -889,22 +1003,6 @@ async function rotateTalkSessionOnce(ctx, { root = rootDir(), wsId, title = TITL
889
1003
  pendingWords = null
890
1004
  }
891
1005
 
892
- // 1) 旧会话改名留档。失败不影响后面(改不了名也还是那条会话)。
893
- if (old && typeof sc.rename === 'function') {
894
- await sc.rename({ sessionId: old, title: `${title}·${when} 之前` }).catch(() => {})
895
- }
896
-
897
- // 2) 建新的
898
- let sessionId = ''
899
- try {
900
- const created = await sc.create({ workspaceId: wid })
901
- sessionId = String(created?.sessionId ?? created?.id ?? '')
902
- } catch (err) {
903
- return { ok: false, sessionId: '', why: `建会话失败:${String(err?.message ?? err).slice(0, 160)}` }
904
- }
905
- if (!sessionId) return { ok: false, sessionId: '', why: '宿主建了会话但没给 id' }
906
- if (typeof sc.rename === 'function') await sc.rename({ sessionId, title }).catch(() => {})
907
-
908
1006
  // 3) 原话搬运
909
1007
  // 2026-10-31(issue #14 第二报):这里以前是**整个覆盖写**,只留七个字段,
910
1008
  // 于是 `preset` / `title` / `wokeFor` / `hearAt` / `adoptedAt` 换一次会话全丢:
@@ -932,6 +1030,9 @@ async function rotateTalkSessionOnce(ctx, { root = rootDir(), wsId, title = TITL
932
1030
  }
933
1031
  delete next.status
934
1032
  delete next.archivedAt
1033
+ // 换成了,把上次失败的记账清掉(否则下一次到点时会被误判成还在冷却期)
1034
+ delete next.rotateFailAt
1035
+ delete next.rotateFailWhy
935
1036
  // 0.8.7:这一轮先清掉旧的那句。她要是留了,后台回来之后再补写
936
1037
  // (见下面 pendingWords.then)。留着上一轮那句会让新房间读到一句
937
1038
  // 不是这次说的「最后一句」——比没有更糟。
@@ -977,7 +1078,7 @@ async function rotateTalkSessionOnce(ctx, { root = rootDir(), wsId, title = TITL
977
1078
  // 2026-11-02(用户 m00324):这条路是**换会话**,不是造新屋子。
978
1079
  // 传 rotated:true 让触发语说「对话换了一条新的,屋子还是原来那间」,
979
1080
  // 而不是「屋子刚建好」——后者会让她以为每 40 轮就变成新的一条鱼。
980
- 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 })
981
1082
  if (!woke.woke && woke.why) log?.(`轮换后没叫醒新会话:${woke.why}`)
982
1083
  // 4.5) 叫了不等于叫醒(0.8.7,用户 m01775 那张截图里的第 4、5 条)
983
1084
  // 宿主的 prompt 是「这一轮被收下了」就 resolve。可新会话是插件用 API 建的空白会话,
@@ -1009,6 +1110,10 @@ async function rotateTalkSessionOnce(ctx, { root = rootDir(), wsId, title = TITL
1009
1110
  */
1010
1111
  export async function verifyWoke(ctx, { root = '', sessionId = '', log, delayMs = WAKE_VERIFY_MS } = {}) {
1011
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))
1012
1117
  try {
1013
1118
  if (delayMs > 0) {
1014
1119
  await new Promise((ok) => {
@@ -1017,14 +1122,22 @@ export async function verifyWoke(ctx, { root = '', sessionId = '', log, delayMs
1017
1122
  })
1018
1123
  }
1019
1124
  const sc = ctx?.get?.('sessionController') ?? ctx?.sessionController
1020
- if (await sessionListed(sc, sessionId)) return { landed: true, rewoke: false }
1021
- 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} 叫过一次还是不在宿主列表里,再叫一次`)
1022
1133
  const again = await wakeIfBlank(ctx, { sessionId, root, log, freshOnly: true, rotated: true, force: true })
1023
1134
  if (!again.woke && again.why) log?.(`再叫一次也没成:${again.why}`)
1024
1135
  return { landed: false, rewoke: again.woke === true }
1025
1136
  } catch (err) {
1026
1137
  log?.(`复核叫醒失败:${String(err?.message ?? err).slice(0, 140)}`)
1027
1138
  return { landed: false, rewoke: false }
1139
+ } finally {
1140
+ verifying.delete(String(sessionId))
1028
1141
  }
1029
1142
  }
1030
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)
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'
@@ -46,7 +49,7 @@ import { logBoot, makeLogger } from './log.js'
46
49
  export const name = 'dsh-fish'
47
50
 
48
51
  /** 版本号:只用于日志导出(排查时第一件事是确认跑的是哪一版)。 */
49
- export const VERSION = '0.9.0'
52
+ export const VERSION = '0.9.1'
50
53
 
51
54
  // ⚠️ 一个数组写全,不要拆成对象
52
55
  // 2026-10-30(m02961):`userQuestions` **不再写进 inject**。写进 inject 的东西缺一个,
@@ -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
- const claim = await claimRoot(root, { version: VERSION })
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
- const off = registerTurns(ctx, { root, log: (...a) => logger.info(...a) })
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
- try {
405
- const id = tenancy.tenant?.instanceId
406
- if (id) releaseRoot(root, id).catch(() => {})
407
- } catch { /* ignore */ }
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
- ctx.on?.('dispose', shutdown)
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/interest.js CHANGED
@@ -7,12 +7,13 @@
7
7
  *
8
8
  * 三条边界:
9
9
  * 1. **只读她说过的话**(`HER_TYPES`),不去猜主人喜欢什么——兴趣是她的,不是画像。
10
+ * 只留证的不算(isProofOnly):「好的,我看一下」说十遍也不是她最近总回到的事。
10
11
  * 2. **正文里没有分数、没有次数**。权重只决定摆哪一条(四条黑名单第三条)。
11
12
  * 3. **会腻**:同一件事刚在 24 小时内出现过,这一次的分量打对折;
12
13
  * 两周前的老话题按 14 天半衰——越久没提,越不像「最近总回到」。
13
14
  */
14
15
 
15
- import { similarity } from './derive.js'
16
+ import { isProofOnly, similarity } from './derive.js'
16
17
  import { tsMs, HER_TYPES } from './schema.js'
17
18
 
18
19
  /** 至少在几个不同时刻出现过,才算「反复」。 */
@@ -52,6 +53,7 @@ export function topicsOf(events = []) {
52
53
  const out = []
53
54
  for (const e of events) {
54
55
  if (!HER_TYPES.includes(e?.type)) continue
56
+ if (isProofOnly(e)) continue
55
57
  const ts = tsMs(e)
56
58
  if (!Number.isFinite(ts)) continue
57
59
  for (const s of sentences(e.text)) {