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 +7 -7
- package/lib/client.js +30 -0
- package/lib/index.js +312 -22
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -49,15 +49,16 @@
|
|
|
49
49
|
在 DSH 里:**设置 → 插件 → 安装**,填下面这一行;或者命令行:
|
|
50
50
|
|
|
51
51
|
```
|
|
52
|
-
|
|
52
|
+
dsh-fish-memory
|
|
53
53
|
```
|
|
54
54
|
|
|
55
55
|
```sh
|
|
56
|
-
dsh plugin --profile web add
|
|
56
|
+
dsh plugin --profile web add dsh-fish-memory
|
|
57
57
|
```
|
|
58
58
|
|
|
59
|
-
>
|
|
60
|
-
|
|
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
|
-
>
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|