dsh-fish-memory 1.4.3 → 1.4.5

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/README.md CHANGED
@@ -49,15 +49,16 @@
49
49
  在 DSH 里:**设置 → 插件 → 安装**,填下面这一行;或者命令行:
50
50
 
51
51
  ```
52
- github:Yuuyuko-uu/dsh-fish-memory
52
+ dsh-fish-memory
53
53
  ```
54
54
 
55
55
  ```sh
56
- dsh plugin --profile web add github:Yuuyuko-uu/dsh-fish-memory
56
+ dsh plugin --profile web add dsh-fish-memory
57
57
  ```
58
58
 
59
- > ⚠ 上面这行会让包管理器去 **clone 仓库**,机器上没装 `git` 会报 `ERR_PNPM_GIT_FETCH_FAILED`。
60
- > 没 git 就用下面这条 **tarball 直链**,一样能装,不用 git:
59
+ > 也可以在 npm 上直接看到这个包(含历史版本):<https://www.npmjs.com/package/dsh-fish-memory>
60
+
61
+ > 上面那条要是碰到网络问题(npm 抽风、或者想固定某一次的代码快照),可以改用 tarball 直链,不用 git:
61
62
 
62
63
  ```bash
63
64
  dsh plugin --profile web add https://codeload.github.com/Yuuyuko-uu/dsh-fish-memory/tar.gz/refs/heads/main
@@ -70,8 +71,7 @@ dsh plugin --profile web add https://codeload.github.com/Yuuyuko-uu/dsh-fish-mem
70
71
 
71
72
  重启后,打开 **设置 → 鱼的记忆**。
72
73
 
73
- > 还没上 npm,所以**要用上面那条 GitHub 的**。
74
- > (以后要是上了 npm,`dsh plugin add dsh-fish-memory` 也能用。)
74
+ > 也可以直接装 npm 包:`dsh plugin add dsh-fish-memory`。
75
75
  > ⭐ 最省事的一条:把仓库链接贴给你自己的 DSH,说「**帮我装这个插件**」。
76
76
 
77
77
  ### 装完起不来?先看这个
@@ -122,7 +122,7 @@ $p = "把上面 BOM=True 那一行的路径粘到这里"
122
122
 
123
123
  ```bash
124
124
  dsh plugin --profile desktop remove dsh-fish-memory
125
- dsh plugin --profile desktop add github:Yuuyuko-uu/dsh-fish-memory
125
+ dsh plugin --profile desktop add dsh-fish-memory
126
126
  ```
127
127
 
128
128
  > ⚠️ **必须先卸再装**:直接装可能拿到缓存里的旧副本,装完还是老的。
package/lib/client.js CHANGED
@@ -3949,6 +3949,36 @@ const 洗键进度 = { 已: 0, 总: 0, 在跑: false, 干跑: false }
3949
3949
  : '全关着 —— 哪个会话都不记也不读,工具列表里也不会出现它。',
3950
3950
  ),
3951
3951
  ),
3952
+ // ⭐ 1.4.5:记忆按什么归类 —— 按会话 / 按工作区
3953
+ React.createElement(
3954
+ 'div',
3955
+ { style: Object.assign({}, S.row, S.条, { alignItems: 'center' }) },
3956
+ React.createElement(
3957
+ 'button',
3958
+ {
3959
+ style: Object.assign({}, S.btn, {
3960
+ flex: 'none',
3961
+ color: d.模式 === 'workspace' ? '#8fd0a0' : 'inherit',
3962
+ borderColor: d.模式 === 'workspace' ? 'rgba(143,208,160,.6)' : 'rgba(128,128,128,.35)',
3963
+ }),
3964
+ disabled: busy,
3965
+ onClick: () => {
3966
+ setBusy(true)
3967
+ post('/mode', { 模式: d.模式 === 'workspace' ? 'session' : 'workspace' })
3968
+ .then(() => { setBusy(false); load() })
3969
+ .catch(() => setBusy(false))
3970
+ },
3971
+ },
3972
+ (d.模式 === 'workspace' ? '按工作区' : '按会话'),
3973
+ ),
3974
+ React.createElement(
3975
+ 'div',
3976
+ { style: Object.assign({}, S.dim, { flex: 1, minWidth: 0 }) },
3977
+ d.模式 === 'workspace'
3978
+ ? '整个工作区共用一个记忆库 —— 新开的会话自动接上,归档旧会话记忆也不会跟着走。'
3979
+ : '每个会话各记各的。会话开久了变卡、要归档换新会话时,切成「按工作区」就接得上了。',
3980
+ ),
3981
+ ),
3952
3982
  (d.sessions || []).map((s) =>
3953
3983
  React.createElement(
3954
3984
  'div',
package/lib/index.js CHANGED
@@ -39,6 +39,50 @@ const CFG_DIR = process.env.DSH_HOME || join(homedir(), '.dsh')
39
39
  const STORE_DIR = join(CFG_DIR, 'fish-memory')
40
40
  const BACKUP_DIR = join(CFG_DIR, 'fish-memory-backup')
41
41
  const CFG_PATH = join(CFG_DIR, 'fish-memory-config.json')
42
+
43
+ /**
44
+ * ⭐ 1.4.5:工作区标识 —— 跟 DSH 自己的 `projectKey(cwd)` **一字不差**。
45
+ *
46
+ * DSH 把会话存在 `~/.dsh/sessions/<工作区目录名>/<会话id>/`,那层目录名就是它算出来的键。
47
+ * 规则(跟着 DSH 抄,别自己发明):
48
+ * · 分隔符(`/` `\` `:`)压成一个 `-`(连续的分隔符只留一个);
49
+ * · 只有 `[A-Za-z0-9._-]` 原样保留 —— 其余字符(含 `~`、`'`、中文)一律写成
50
+ * `~` + 4 位大写十六进制(按 UTF-16 码元);
51
+ * · 去掉开头连续的 `-`(空了就是 `root`),截到 251 个字符,两头各包一个 `-`。
52
+ * 例:`C:\Users\me\Desktop\项目目录\dsh`
53
+ * → `--C-Users-me-Desktop-~9879~76EE~76EE~5F55-dsh--`
54
+ *
55
+ * ⚠ 算法必须跟 DSH 完全一致:差一个字符,同一个工作区就会裂成两份。
56
+ * 单引号 / `~` 以前被简化成 `-` 就踩过这个坑(心情插件那边修过一次)。
57
+ */
58
+ export function 工作区目录名(p) {
59
+ const s = String(p == null ? '' : p).trim()
60
+ if (!s) return ''
61
+ if (/^--.*--$/.test(s)) return s // 给的本来就是目录名
62
+ let 可读 = ''
63
+ let 连续分隔 = false
64
+ for (let i = 0; i < s.length; i++) {
65
+ const ch = s[i]
66
+ if (ch === '/' || ch === '\\' || ch === ':') {
67
+ if (!连续分隔) 可读 += '-'
68
+ 连续分隔 = true
69
+ } else if (ch !== '~' && /^[A-Za-z0-9._-]$/.test(ch)) {
70
+ 可读 += ch
71
+ 连续分隔 = false
72
+ } else {
73
+ 可读 += '~' + ch.charCodeAt(0).toString(16).toUpperCase().padStart(4, '0')
74
+ 连续分隔 = false
75
+ }
76
+ }
77
+ 可读 = 可读.replace(/^-+/, '') || 'root'
78
+ return '--' + 可读.slice(0, 251) + '--'
79
+ }
80
+
81
+ /** 工作区的可读路径:从会话目录名反查不回来,就直接给目录名 */
82
+ function 工作区可读名(键) {
83
+ return String(键 || '').replace(/^--/, '').replace(/--$/, '') || '(未分组)'
84
+ }
85
+
42
86
  const DAY = 86400000
43
87
  // ⭐⭐ 2026-09-21(反馈:「别人导入会踩什么坑」时改的):**时区不能再写死北京** ✗
44
88
  // 原来写死 +8。实测:换纽约、换东京,日期显示一模一样 ——
@@ -225,9 +269,93 @@ function diag(msg) {
225
269
  // ---------- 每个会话一份库 ----------
226
270
 
227
271
  function storePath(sid) {
228
- return join(STORE_DIR, String(sid).replace(/[^a-zA-Z0-9_-]/g, '_') + '.json')
272
+ // ⭐ 1.4.5:工作区模式下,同一工作区的所有会话指向**同一个库文件**。
273
+ // 放在最底层改(而不是改每个调用点)—— 少一处漏改就少一个「这个库怎么没带上记忆」。
274
+ // 模式从配置里读一次(用户可能在设置页切);读不到 / 算不出工作区都退回按会话,
275
+ // 绝不因为工作区算不出来就把记忆弄丢。
276
+ const 用名 = String(库名(sid, { 模式: 读模式() }) || sid)
277
+ return join(STORE_DIR, 用名.replace(/[^a-zA-Z0-9_-]/g, '_') + '.json')
278
+ }
279
+
280
+ // --------------------------------------------------------------------------
281
+ // ⭐ 1.4.5:工作区模式
282
+ // DSH 的会话都躺在 `~/.dsh/sessions/<工作区目录名>/<会话 id>/`,所以:
283
+ // · 工作区标识 = 中间那层目录名(跨机器可复制、不依赖绝对路径)
284
+ // · 工作区模式下:**同一工作区的会话共用一个记忆库**
285
+ // · 跟「按会话」二选一:同时开等于一份记忆被两套归属抢,必出 bug
286
+ //
287
+ // 为什么需要:一个会话久了会大到打开都卡,为了流畅只能归档、开新会话。
288
+ // 可记忆是按会话存的,新会话里什么都不记得 —— 工作区模式就是治这个的。
289
+ // --------------------------------------------------------------------------
290
+ const 工作区缓存 = { at: 0, 数据: null }
291
+
292
+ function 目录们(p) {
293
+ try {
294
+ return readdirSync(p, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name)
295
+ } catch (error) {
296
+ return []
297
+ }
298
+ }
299
+
300
+ /**
301
+ * 扫一遍会话根目录:会话 → 工作区目录名、工作区目录名 → 会话 ids。
302
+ * 结果缓存 5 秒(心跳每 3 秒一次,别每次都读盘)。
303
+ * ⚠ 跟心情插件一个写法:**只认 CFG_DIR**,不给测试留参数(测试靠先设 DSH_HOME 再 import)。
304
+ */
305
+ export function 扫会话目录(强制 = false) {
306
+ const 现在 = Date.now()
307
+ if (!强制 && 工作区缓存.数据 && 现在 - 工作区缓存.at < 5000) return 工作区缓存.数据
308
+ const 根 = join(CFG_DIR, 'sessions')
309
+ const bySession = new Map() // 会话 id -> 工作区目录名
310
+ const byWorkspace = new Map() // 工作区目录名 -> [会话 id]
311
+ for (const ws of 目录们(根)) {
312
+ const 会话们 = []
313
+ for (const sid of 目录们(join(根, ws))) {
314
+ const k = String(sid || '').trim()
315
+ if (!k) continue
316
+ 会话们.push(sid)
317
+ if (!bySession.has(k)) bySession.set(k, ws)
318
+ // 老会话可能带 `session-` 前缀,两种写法都记上
319
+ if (k.startsWith('session-')) {
320
+ const 短 = k.slice('session-'.length)
321
+ if (短 && !bySession.has(短)) bySession.set(短, ws)
322
+ } else if (!bySession.has('session-' + k)) {
323
+ bySession.set('session-' + k, ws)
324
+ }
325
+ }
326
+ if (会话们.length) byWorkspace.set(ws, 会话们)
327
+ }
328
+ const 数据 = { bySession, byWorkspace, at: 现在 }
329
+ 工作区缓存.data = 数据
330
+ 工作区缓存.at = 现在
331
+ return 数据
332
+ }
333
+
334
+ /** 这个会话属于哪个工作区(认不出来就空串 → 退回按会话) */
335
+ export function 会话的工作区(sid) {
336
+ const k = String(sid == null ? '' : sid).trim()
337
+ if (!k) return ''
338
+ const 表 = 扫会话目录()
339
+ return 表.bySession.get(k) || ''
340
+ }
341
+
342
+ /**
343
+ * ⭐ 1.4.5:工作区模式下,一份记忆归**哪个库**。
344
+ *
345
+ * 用工作区目录名当库名(加个前缀,别跟会话 id 撞)。这样:
346
+ * · 同一个工作区里开多少新会话,读写的都是同一份;
347
+ * · 归档旧会话不会把记忆带走 —— 新会话一开就接上了。
348
+ */
349
+ export function 库名(sid, 配置) {
350
+ // ⚠ 不在这里读全局配置 —— 调用方把「模式」传进来。
351
+ // 自己读配置会让这个函数没法单测(而且 storePath 在模块加载早期就会被调用)。
352
+ const 模式 = String((配置 && 配置.模式) || 'session')
353
+ if (模式 !== 'workspace') return String(sid)
354
+ const ws = 会话的工作区(sid)
355
+ return ws ? 'ws_' + ws : String(sid)
229
356
  }
230
357
 
358
+
231
359
  // ⭐ 1.2.7(issue #7):**全局参数**
232
360
  // 以前参数只存在每个记忆库(memory-store/<会话>.json)里 —— 一个库都没有时,
233
361
  // 保存等于没保存,F5 一刷就回默认值。
@@ -279,12 +407,21 @@ function loadStore(sid) {
279
407
  return data
280
408
  }
281
409
  corruptIncidents.delete(path)
410
+ // ⭐ 1.4.4:老版本把「运行环境 / 记忆提醒列表项 / 技能目录」这些机器文本当成线索存了进来,
411
+ // 白占召回记录的 100 条上限。读的时候清一遍:净化之后什么都不剩的,那条记录本来就没有用。
412
+ // (只动 traces 这种诊断记录,记忆本身一条都不碰。)
413
+ const 旧记录 = Array.isArray(raw.traces) ? raw.traces : []
414
+ const traces = 旧记录.filter((t) => {
415
+ const q = typeof (t && t.query) === 'string' ? t.query : ''
416
+ if (!q) return true // 没有 query 的老记录不动
417
+ return !明显是机器写的(清掉注入上下文(q))
418
+ })
282
419
  return {
283
420
  version: 2,
284
421
  purgeEpoch: purgeEpochOf(raw),
285
422
  policy: 合并参数(raw.policy),
286
423
  memories: Array.isArray(raw.memories) ? raw.memories : [],
287
- traces: Array.isArray(raw.traces) ? raw.traces : [],
424
+ traces,
288
425
  deleted: Array.isArray(raw.deleted) ? raw.deleted : [],
289
426
  }
290
427
  } catch (error) {
@@ -442,6 +579,15 @@ function saveStore(sid, data, 选项) {
442
579
  if (!(选项 && 选项.raw)) {
443
580
  if (盘 && Array.isArray(盘.memories)) data = 合并库(盘, data, 选项)
444
581
  }
582
+ // ⭐ 1.4.4:合并会把盘上的旧 trace 捡回来,所以**这里也得清一遍**
583
+ // (只在加载时清没用:下一次保存又原样回来了)。
584
+ if (Array.isArray(data.traces) && data.traces.length) {
585
+ data.traces = data.traces.filter((t) => {
586
+ const q = typeof (t && t.query) === 'string' ? t.query : ''
587
+ if (!q) return true
588
+ return !明显是机器写的(清掉注入上下文(q))
589
+ })
590
+ }
445
591
  // 与内容一起原子发布;其他实例后续读取时也能识别旧代际(并非跨进程事务)。
446
592
  // 先构造待写副本;写失败时保留恢复候选的原代际,允许同对象安全重试。
447
593
  data = { ...data, purgeEpoch: 选项 && 选项.raw ? randomUUID() : epoch }
@@ -528,6 +674,17 @@ function setGlobalOn(on) {
528
674
  }
529
675
  }
530
676
 
677
+ // ⭐ 1.4.5:记忆按什么归类 —— 'session'(默认,跟以前一样)/ 'workspace'(同一工作区共用一份)。
678
+ // 两种模式二选一:同时开等于一份记忆被两套归属抢,必出 bug。
679
+ function 读模式() {
680
+ try {
681
+ const c = loadCfg()
682
+ return String(c.模式 || 'session') === 'workspace' ? 'workspace' : 'session'
683
+ } catch (error) {
684
+ return 'session'
685
+ }
686
+ }
687
+
531
688
  // 取消一个注册(DSH 那套返回的是**函数**,个别地方是 { off })
532
689
  function 卸掉效果(d) {
533
690
  try {
@@ -1826,13 +1983,18 @@ export function 清掉注入上下文(文) {
1826
1983
  // ③ 记忆提醒里的固定句子(下面是你们过去真实发生过的事 / (如果此刻你想起什么 …))。
1827
1984
  let 跳到空行 = false
1828
1985
  let 跳元信息 = false
1829
- const 元信息行 = /^(Current runtime context|Current DSH|Current working directory|Current DeepSeek Harness|Browser time zone|Time sampled|Approval prompts)/i
1986
+ // ⭐ 1.4.4:方块(【…】)跳过之后,**紧跟的列表项也算那块的一部分** ——
1987
+ // render() 写出来的是 `- 3 天前(10-27):…`、`- 一直:…`,以前那张白名单只认
1988
+ // 昨天/前天/今天/日期,这些全漏了出来,被当成「她这一轮说的话」去检索。
1989
+ let 方块之后 = false
1990
+ const 元信息行 = /^(Current runtime context|Current DSH|Current working directory|Current DeepSeek Harness|Browser time zone|Time sampled|Elapsed since|Approval (policy|prompts))/i
1830
1991
  const 固定句 = /^(下面是你们过去真实发生过的事|(?如果此刻你想起什么|有时间的事会写在前面)/
1831
1992
  for (const 行 of 行们) {
1832
1993
  const 净 = 行.trim()
1833
1994
  if (跳到空行) {
1834
1995
  if (!净) {
1835
1996
  跳到空行 = false
1997
+ 方块之后 = true
1836
1998
  留.push('')
1837
1999
  }
1838
2000
  continue
@@ -1850,6 +2012,11 @@ export function 清掉注入上下文(文) {
1850
2012
  跳到空行 = true
1851
2013
  continue
1852
2014
  }
2015
+ // 刚跳过一块之后,跟着的列表项(不管前面写的是「3 天前」还是「一直」)都是那块的东西
2016
+ if (方块之后) {
2017
+ if (/^[-*]\s/.test(净)) continue
2018
+ 方块之后 = false
2019
+ }
1853
2020
  // 记忆提醒里的列表项(`- 前天 12:56:…`)也一起跳 —— 那是「提醒」,不是她这一轮说的话
1854
2021
  if (/^-\s*(前天|昨天|今天|\d{1,2}-\d{1,2}|\d{4}-\d{2}-\d{2})/.test(净) && 净.includes(':')) {
1855
2022
  continue
@@ -1867,6 +2034,29 @@ export function 清掉注入上下文(文) {
1867
2034
  return 留.join(String.fromCharCode(10)).trim()
1868
2035
  }
1869
2036
 
2037
+ /**
2038
+ * ⭐ 1.4.4:这一行**明显是机器写的**,不是人说的话。
2039
+ *
2040
+ * 只给「清老记录」用(召回记录里那些历史烂条目):trace 里只剩下净化后的残渣,
2041
+ * 没有上下文了,认不出「它原本跟着哪一块」,所以按长相判。
2042
+ * ⚠ 别拿它去决定「这一轮用哪句检索」—— 那个走 source.kind(见 不是人说的话)。
2043
+ */
2044
+ export function 明显是机器写的(文) {
2045
+ const q = String(文 == null ? '' : 文).trim()
2046
+ if (!q) return true
2047
+ if (/^(Current runtime context|Current DSH|Current working directory|Current DeepSeek Harness|Browser time zone|Time sampled|Elapsed since|Approval (policy|prompts))/i.test(q)) return true
2048
+ if (/^<system-reminder>/i.test(q)) return true
2049
+ if (/^\[model changed/i.test(q)) return true
2050
+ if (/^This is an automatically generated checkpoint/i.test(q)) return true
2051
+ // 记忆提醒里的列表项:`- 昨天 16:10:…` / `- 3 天前(10-27):…` / `- 一直:…`
2052
+ // ⚠ 「N 天前」这种写法以前漏了,正是面板上一堆 `- 3 天前(10-27)` 的来源。
2053
+ if (/^[-*]\s*(一直|今天|昨天|前天|\d+\s*(天|个月|年)前|\d{1,2}-\d{1,2}|\d{4}-\d{2}-\d{2})/.test(q)) return true
2054
+ // 别的插件塞进来的提示(心情 / 陪看字幕 / 骰子喊她聊天 / 自动刷B站)——
2055
+ // 这些是「插件对模型说的话」,不是她说的,老记录里混进来的一样清掉。
2056
+ if (/^((此刻你|下面这句是|上面这些是|你俩正一起看着|这是我游戏里|到谷时)/.test(q)) return true
2057
+ return false
2058
+ }
2059
+
1870
2060
  /**
1871
2061
  * ⭐ 1.4.2:线索太短就**借上文**。
1872
2062
  *
@@ -1912,6 +2102,7 @@ function 最近几句(session, n = 3) {
1912
2102
  const e = ev[i]
1913
2103
  const type = e && (e.type || e.kind)
1914
2104
  if (type !== 'user/message') continue
2105
+ if (不是人说的话(e)) continue // ⭐ 1.4.4:宿主塞的上下文整条跳过
1915
2106
  const p = e.payload !== undefined ? e.payload : e.data
1916
2107
  const txt = textOfMessage(p)
1917
2108
  if (!txt) continue
@@ -1927,22 +2118,77 @@ function 最近几句(session, n = 3) {
1927
2118
  return 出
1928
2119
  }
1929
2120
 
2121
+ // ⭐ 1.4.4:哪些来源**根本不是人说的话** —— 直接跳过,别指望文本白名单兜得住。
2122
+ //
2123
+ // 坑的来历:以前只靠「净化后是不是空的」来判断,而净化是一张文本白名单
2124
+ // (`Current …` / `Approval prompts` / `- 昨天 …`)。可宿主每加一句新措辞就漏一条:
2125
+ // · 审批策略从 "never" 换成 "ask" 之后,那一行变成 `Approval policy: ask. …`,白名单没跟上;
2126
+ // · 插件自己 render 出来的列表项是 `- 3 天前(10-27):…` / `- 一直:…`,也不在白名单里。
2127
+ // 漏出来的那几行就成了「这一轮她说的那句话」,被拿去检索 —— 面板上一堆重复的英文/列表项。
2128
+ //
2129
+ // 源头上 DSH 每条 user/message 都带 `source.kind`,比猜文本可靠得多。
2130
+ // 这里列的是**宿主/插件塞进来的、不是人说的话**那些来源:
2131
+ // runtime-context / time-context / skill-catalog / model-selection / compact-checkpoint …
2132
+ // 是 DSH 自己往会话里补的上下文;speak-up / her-words(插件定时替人开口那种「该去找他聊天了」)、
2133
+ // fishbuddy-watch(片子字幕)、bili-duty-scan(自动刷B站)是插件替她开口或替她看的。
2134
+ // ⚠ **用黑名单不用白名单**:别的 IM 桥(im-connect 之类)用的是什么 kind 我们不知道,
2135
+ // 认成「不是人话」就等于让那些用户永远检索不到记忆 —— 比原来的毛病更糟。
2136
+ // 认不出来的来源**一律当人话**,靠上面的文本净化兜底。
2137
+ const 机器来源 = new Set([
2138
+ 'runtime-context', 'time-context', 'skill-catalog', 'model-selection',
2139
+ 'compact-checkpoint', 'repeat-tool-reminder', 'user-approval',
2140
+ 'system-prompt', 'working-directory', 'ptc-mode', 'tool-registry',
2141
+ 'agent-instructions', 'subagent-settled', 'team-message', 'agent-message',
2142
+ 'speak-up', 'her-words', 'bili-duty-scan', 'fishbuddy-watch',
2143
+ ])
2144
+
2145
+ function 来源种类(e) {
2146
+ try {
2147
+ const 源 = e && e.data && e.data.source
2148
+ return (源 && typeof 源.kind === 'string' && 源.kind) || ''
2149
+ } catch (error) {
2150
+ return ''
2151
+ }
2152
+ }
2153
+
2154
+ // 认不出来源的老会话 / 第三方渠道:**不拦**(交给文本净化兜底),免得把真话也丢了
2155
+ function 不是人说的话(e) {
2156
+ const 种 = 来源种类(e)
2157
+ if (!种) return false
2158
+ if (机器来源.has(种)) return true
2159
+ // `plugin:xxx` 那种是插件自报家门塞进来的(如 plugin:chat-import / plugin:meow-memory)
2160
+ return 种.startsWith('plugin:')
2161
+ }
2162
+
2163
+ /**
2164
+ * ⭐ 1.4.4:从事件流里挑「这一轮她真正说的那句话」——抽成纯函数,好单测。
2165
+ * 返回原(未净化)文本;调用方再净化一次(跟以前一样,净化结果为空就说明这条不算)。
2166
+ * @param {Array} 事件们 session.snapshotEvents() 的结果
2167
+ * @returns {string} 原文本;找不到返回 ''
2168
+ */
2169
+ export function 挑这一轮的真话(事件们) {
2170
+ const ev = Array.isArray(事件们) ? 事件们 : []
2171
+ for (let i = ev.length - 1; i >= 0; i--) {
2172
+ const e = ev[i]
2173
+ const type = e && (e.type || e.kind)
2174
+ if (type !== 'user/message') continue
2175
+ // ⭐ 1.4.4:宿主塞的上下文(运行环境 / 时间 / 技能目录 / 记忆提醒 / 压缩摘要 …)整条跳过
2176
+ if (不是人说的话(e)) continue
2177
+ const p = e.payload !== undefined ? e.payload : e.data
2178
+ const txt = textOfMessage(p)
2179
+ if (!txt) continue
2180
+ // ⭐ 1.4.1:宿主会把「运行环境」「【你记得的事】」这类东西跟真话夹在同一条里 ——
2181
+ // 净化之后是空的就**往前再找一条**,别拿它当线索。
2182
+ const 净 = 清掉注入上下文(txt)
2183
+ if (净 && 净.trim()) return txt
2184
+ }
2185
+ return ''
2186
+ }
2187
+
1930
2188
  function lastUserTextOf(session) {
1931
2189
  try {
1932
2190
  if (!session || typeof session.snapshotEvents !== 'function') return ''
1933
- const ev = session.snapshotEvents() || []
1934
- for (let i = ev.length - 1; i >= 0; i--) {
1935
- const e = ev[i]
1936
- const type = e && (e.type || e.kind)
1937
- if (type !== 'user/message') continue
1938
- const p = e.payload !== undefined ? e.payload : e.data
1939
- const txt = textOfMessage(p)
1940
- if (!txt) continue
1941
- // ⭐ 1.4.1:宿主会把「运行环境」「【你记得的事】」这类东西当成 user 消息塞进来 ——
1942
- // 有的轮次里整条就只剩这些。净化之后是空的就**往前再找一条**,别拿它当线索。
1943
- const 净 = 清掉注入上下文(txt)
1944
- if (净 && 净.trim()) return txt
1945
- }
2191
+ return 挑这一轮的真话(session.snapshotEvents() || [])
1946
2192
  } catch (error) {}
1947
2193
  return ''
1948
2194
  }
@@ -2533,6 +2779,9 @@ function 补历史尺(sid, session) {
2533
2779
  for (let i = ev.length - 1; i >= 0 && 收 < 2000; i--) {
2534
2780
  const e = ev[i]
2535
2781
  if (!e || e.type !== 'user/message') continue
2782
+ // ⭐ 1.4.4:尺子是「她怎么说话」,喂进去的全得是人话;
2783
+ // 运行环境 / 片子字幕 / 骰子提示那些会把词频带偏。
2784
+ if (不是人说的话(e)) continue
2536
2785
  const p = e.payload !== undefined ? e.payload : e.data && e.data.message !== undefined ? e.data.message : e.data
2537
2786
  const txt = textOfMessage(p)
2538
2787
  if (!txt) continue
@@ -3056,7 +3305,7 @@ function recall(data, query, now, sid) {
3056
3305
  }
3057
3306
  // ⭐⭐ 2026-10-27(issue #14):**常驻层** —— 长期事实不看这句话问的是什么,每轮都带上
3058
3307
  //
3059
- // 她的原话(转给用户那条建议):「把长期事实改为每轮注入,其他记忆打分淡忘」。
3308
+ // 当时的建议(转给用户那条):「把长期事实改为每轮注入,其他记忆打分淡忘」。
3060
3309
  // 以前事实也走召回:这句话没撞上「我住在杭州」,这条就进不来 —— 可它明明是「要记一辈子」的事。
3061
3310
  // 现在:
3062
3311
  // · **长期事实**:每轮固定带 factsMax 条(按重要度、再按时间取前几名),
@@ -3533,7 +3782,7 @@ function 分支来处Of(ctx, sid) {
3533
3782
  }
3534
3783
 
3535
3784
  function buildMemoryList(ctx) {
3536
- const out = { ok: true, sessions: [], policy: null, ghosts: 0, 全局开关: globalOn() }
3785
+ const out = { ok: true, sessions: [], policy: null, ghosts: 0, 全局开关: globalOn(), 模式: 读模式() }
3537
3786
  // ⭐ 1.2.7:设置页显示「全局参数」—— 以前是「碰到的第一个库的参数」,没有库就什么都没有
3538
3787
  try {
3539
3788
  const c0 = loadCfg()
@@ -3812,6 +4061,9 @@ export function apply(ctx) {
3812
4061
  try {
3813
4062
  agent.ctx.on('agent/inbox/inserted', (payload) => {
3814
4063
  const msg = payload && payload.message ? payload.message : payload
4064
+ // ⭐ 1.4.4:插件替人开口的那些(骰子喊人聊天 / 自动刷B站 …)不算人说的话,
4065
+ // 既不能当线索兜底,也不能喂进「她自己怎么说话」那把尺子。
4066
+ if (msg && 不是人说的话({ data: msg })) return
3815
4067
  const txt = textOfMessage(msg)
3816
4068
  if (txt) {
3817
4069
  lastText = txt
@@ -3979,7 +4231,14 @@ export function apply(ctx) {
3979
4231
  // 而每轮都要把整份读进来再整份写回去 → 每轮白白多花几十毫秒
3980
4232
  // (每轮开销大就是这儿)
3981
4233
  // scored 已经按 P 排好序了,所以前 24 条 = 挑中的 + 最接近的落选 ✓
3982
- data.traces.push({
4234
+ // ⭐ 1.4.4:**同一轮别记两遍**。这段 text() 在一次请求里会被调用两次
4235
+ // (装配一次、真正发请求再一次),每次都 push 一条 trace —— 实测 222 条里
4236
+ // 有 108 条是「同一句、相隔 3 秒内」的重复,100 条上限白白占掉一半。
4237
+ // 判据:上一句跟这一句一模一样、而且才过了一小会儿 → 当作同一次,直接覆盖。
4238
+ const 上一条 = data.traces[data.traces.length - 1]
4239
+ const 同一次 = 上一条 && 上一条.query === query.slice(0, 200) &&
4240
+ Math.abs(now - Number(上一条.at || 0)) < 3000
4241
+ const 这条 = {
3983
4242
  at: now,
3984
4243
  session: sid,
3985
4244
  query: query.slice(0, 200),
@@ -3996,7 +4255,9 @@ export function apply(ctx) {
3996
4255
  // 进到提示词里的算 assembled;后面她回复里用上了就变 consumed ✓
3997
4256
  status: r.omission ? null : 'assembled',
3998
4257
  })),
3999
- })
4258
+ }
4259
+ if (同一次) data.traces[data.traces.length - 1] = 这条
4260
+ else data.traces.push(这条)
4000
4261
  // Trace persistence is auxiliary: an I/O failure must not hide a valid
4001
4262
  // snapshot already read from disk. saveStore records the failure.
4002
4263
  try { saveStore(sid, data) } catch (error) {}
@@ -4422,8 +4683,25 @@ export function apply(ctx) {
4422
4683
  res.end(JSON.stringify({ ok: true, on }))
4423
4684
  })
4424
4685
 
4425
- mroute('/dsh-fish-memory/toggle', async (req, res) => {
4686
+ // ⭐ 1.4.5:切换「按会话 / 按工作区」。切换不会搬动已有记忆 ——
4687
+ // 只是此后读写的库文件换了,旧的还在(想合并得单独做,不在这条路上)。
4688
+ mroute('/dsh-fish-memory/mode', async (req, res) => {
4426
4689
  let body = ''
4690
+ for await (const chunk of req) body += chunk
4691
+ let b = {}
4692
+ try { b = JSON.parse(body || '{}') } catch (error) {}
4693
+ const 模式 = String(b.模式 || '') === 'workspace' ? 'workspace' : 'session'
4694
+ try {
4695
+ const c = loadCfg()
4696
+ c.模式 = 模式
4697
+ saveCfg(c)
4698
+ diag('记忆归类模式 → ' + 模式)
4699
+ } catch (error) {}
4700
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
4701
+ res.end(JSON.stringify({ ok: true, 模式 }))
4702
+ })
4703
+
4704
+ mroute('/dsh-fish-memory/toggle', async (req, res) => { let body = ''
4427
4705
  for await (const chunk of req) body += chunk
4428
4706
  let b = {}
4429
4707
  try {
@@ -6241,7 +6519,7 @@ mroute('/dsh-fish-memory/wash-keys/undo', async (req, res) => {
6241
6519
 
6242
6520
  // 把记忆复制到**另一个会话** —— 她想在新对话里测(新对话没有上下文,最干净)✓
6243
6521
  // { session: 从哪来, to: 到哪去 }
6244
- // ⭐ 1.3.7(她 2026-10-28 报的):以前是**整份覆盖** —— 目标会话原来记的东西全没了。
6522
+ // ⭐ 1.3.7(2026-10-28 报的):以前是**整份覆盖** —— 目标会话原来记的东西全没了。
6245
6523
  // 现在改成**并进去**:那边原有的都留着,只补它还没有的(同一句话算重复,跳过)。
6246
6524
  mroute('/dsh-fish-memory/copy-to', async (req, res) => {
6247
6525
  let body = ''
@@ -6498,3 +6776,15 @@ export {
6498
6776
  标作废,
6499
6777
  真重复门槛,
6500
6778
  }
6779
+
6780
+ /**
6781
+ * ⭐ 1.4.5:给自测用的出口(跟心情插件一个写法)。
6782
+ * ⚠ 只给测试用,不要在生产代码里调;里面都是纯函数或内部状态读取。
6783
+ */
6784
+ export const __测试 = {
6785
+ 工作区目录名,
6786
+ 扫会话目录,
6787
+ 会话的工作区,
6788
+ 库名,
6789
+ 读模式,
6790
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-fish-memory",
3
- "version": "1.4.3",
3
+ "version": "1.4.5",
4
4
  "description": "给 DSH 里的会话一份会淡忘的长期记忆,每条都带时间。",
5
5
  "license": "MIT",
6
6
  "type": "module",