dsh-fish-memory 1.5.5 → 1.5.6

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/index.js CHANGED
@@ -11,6 +11,7 @@
11
11
  // 3. 注 —— 注入到它自己的系统提示词里,**每条都带时间**
12
12
 
13
13
  import { readFileSync, writeFileSync, mkdirSync, readdirSync, existsSync, copyFileSync, rmSync, statSync, renameSync, constants } from 'node:fs'
14
+ import { 建准入, 包准入 } from './准入.mjs'
14
15
  import { homedir } from 'node:os'
15
16
  import { join, basename, dirname } from 'node:path'
16
17
  import { fileURLToPath } from 'node:url'
@@ -33,7 +34,9 @@ try { for (const n of (hostDeps.notes || [])) diag('hostdeps: ' + n) } catch (e)
33
34
  import * as 生成档案 from './生成档案.js'
34
35
 
35
36
  export const name = 'memory'
36
- export const inject = ['systemPrompt', 'agents', 'timer', 'tools', 'webServer', 'sessions', 'sessionTitle', 'llm']
37
+ // webServer 不放进 inject:放进去就是硬依赖,没有网页服务的 profile(比如只跑 QQ 机器人的无界面部署)
38
+ // 整个插件会一直 pending、记忆完全不工作。设置页的路由在下面用 ctx.inject(['webServer']) 按需挂。
39
+ export const inject = ['systemPrompt', 'agents', 'timer', 'tools', 'sessions', 'sessionTitle', 'llm']
37
40
 
38
41
  const CFG_DIR = process.env.DSH_HOME || join(homedir(), '.dsh')
39
42
  const STORE_DIR = join(CFG_DIR, 'fish-memory')
@@ -1748,6 +1751,19 @@ function 解析扩词(文本, 条数) {
1748
1751
  * · 模型没回可用的词(思考吃光预算那种)就安静退出,下次再说。
1749
1752
  * 返回补了几条(调试用)。
1750
1753
  */
1754
+ // 同一个库的后台扩词排成一队,一个跑完再跑下一个:
1755
+ // 「记完顺手扩」和「搜空补一条」可能同时起跑,都是「等模型 → 重读 → 改几条 → 写回」,
1756
+ // 两份都挑中同一批没扩过的条目时,白花一次模型调用,后写的还会按时间盖掉先写的结果。
1757
+ const 后台队 = new Map() // sid -> 队尾 promise
1758
+ function 后台排队(sid, 活) {
1759
+ const 尾 = (后台队.get(sid) || Promise.resolve())
1760
+ .then(活)
1761
+ .catch((error) => diag('后台整理失败(已忽略):' + ((error && error.message) || error)))
1762
+ 后台队.set(sid, 尾)
1763
+ 尾.then(() => { if (后台队.get(sid) === 尾) 后台队.delete(sid) })
1764
+ return 尾
1765
+ }
1766
+
1751
1767
  async function 自动扩词(ctx, sid, 上限 = 2) {
1752
1768
  try {
1753
1769
  if (!sid) return 0
@@ -3322,6 +3338,50 @@ function 按键收敛(data, 被谁) {
3322
3338
  // (2026-09-23 尺子换成按词比,这个数跟着重量过,0.80 仍然合适)
3323
3339
  const 召回重复门槛 = 0.8
3324
3340
 
3341
+ // 常驻层:长期事实每轮固定带 factsMax 条(按重要度、再按时间)。scored 里有的沿用它的分数行。
3342
+ // 抽成函数是因为问句门挡下的那条早退路径也要用(见 recall 里的说明)。
3343
+ function 选常驻事实(memories, scored, policy) {
3344
+ const 事实上限 =
3345
+ typeof policy.factsMax === 'number' && policy.factsMax >= 0 ? Math.floor(policy.factsMax) : 2
3346
+ const 常驻事实 = []
3347
+ if (事实上限 > 0) {
3348
+ const 候选 = memories
3349
+ .filter((m) => m && m.category === 'stable_profile')
3350
+ .slice()
3351
+ .sort(
3352
+ (a, b) =>
3353
+ (Number(b.importance) || 0) - (Number(a.importance) || 0) ||
3354
+ (Date.parse(b.createdAt || b.at || 0) || 0) - (Date.parse(a.createdAt || a.at || 0) || 0),
3355
+ )
3356
+ const 行表 = new Map()
3357
+ for (const row of scored) if (row && row.m) 行表.set(String(row.m.id), row)
3358
+ for (const m of 候选.slice(0, 事实上限)) {
3359
+ const row = 行表.get(String(m.id)) || { m, s: { P: 1, C: 1, timeHit: 1 } }
3360
+ row.omission = null // 常驻的不算「被筛掉」
3361
+ row.常驻 = true
3362
+ 常驻事实.push(row)
3363
+ }
3364
+ }
3365
+ return 常驻事实
3366
+ }
3367
+
3368
+ // 按「注入字数上限」收:第一条总能进,后面的超了就这趟先不进
3369
+ function 按字数收(rows, policy) {
3370
+ const 上限 = typeof policy.maxChars === 'number' && policy.maxChars > 0 ? policy.maxChars : 800
3371
+ const selected = []
3372
+ let 字数 = 0
3373
+ for (const row of rows) {
3374
+ const n = String(row.m.text || '').length
3375
+ if (selected.length > 0 && 字数 + n > 上限) {
3376
+ row.omission = 'budget'
3377
+ continue
3378
+ }
3379
+ selected.push(row)
3380
+ 字数 += n
3381
+ }
3382
+ return { selected, 字数 }
3383
+ }
3384
+
3325
3385
  function recall(data, query, now, sid) {
3326
3386
  const policy = data.policy
3327
3387
  // ⭐ 2026-09-21(E):**提炼判成「跳过」的,不再参与召回** ✓
@@ -3357,7 +3417,11 @@ function recall(data, query, now, sid) {
3357
3417
  const v = 问句信息量(sid, query, df)
3358
3418
  if (v < 门槛) {
3359
3419
  // 这道门挡下来的时候**不做 851 次打分**,省一趟;理由照样记进 trace 给她看 ✓
3360
- return { selected: [], scored: [], 字数: 0, gate: { 信息量: Number(v.toFixed(2)), 门槛 } }
3420
+ // ⚠ 但**常驻层照带**:常驻的意义就是不看这句话问了什么(issue #14)。
3421
+ // 以前这里直接返回空 —— 「在吗」「我是谁」这种短句一被挡,长期事实也跟着没了 ✗
3422
+ const 常驻 = 选常驻事实(memories, [], policy)
3423
+ const 带上 = 按字数收(常驻, policy)
3424
+ return { selected: 带上.selected, scored: 常驻, 字数: 带上.字数, gate: { 信息量: Number(v.toFixed(2)), 门槛, 挡了: true } }
3361
3425
  }
3362
3426
  gate = { 信息量: Number(v.toFixed(2)), 门槛 }
3363
3427
  }
@@ -3513,27 +3577,7 @@ function recall(data, query, now, sid) {
3513
3577
  //
3514
3578
  // ⚠ 只改「谁进来、名额怎么分」,不改打分和门槛 —— 事件该不该进还是那套尺子说了算。
3515
3579
  // ⚠ 事实按「重要度 → 时间」取,不按分数:常驻层的意义就是不依赖这句话。
3516
- const 事实上限 =
3517
- typeof policy.factsMax === 'number' && policy.factsMax >= 0 ? Math.floor(policy.factsMax) : 2
3518
- const 常驻事实 = []
3519
- if (事实上限 > 0) {
3520
- const 候选 = memories
3521
- .filter((m) => m && m.category === 'stable_profile')
3522
- .slice()
3523
- .sort(
3524
- (a, b) =>
3525
- (Number(b.importance) || 0) - (Number(a.importance) || 0) ||
3526
- (Date.parse(b.createdAt || b.at || 0) || 0) - (Date.parse(a.createdAt || a.at || 0) || 0),
3527
- )
3528
- const 行表 = new Map()
3529
- for (const row of scored) if (row && row.m) 行表.set(String(row.m.id), row)
3530
- for (const m of 候选.slice(0, 事实上限)) {
3531
- const row = 行表.get(String(m.id)) || { m, s: { P: 1, C: 1, timeHit: 1 } }
3532
- row.omission = null // 常驻的不算「被筛掉」
3533
- row.常驻 = true
3534
- 常驻事实.push(row)
3535
- }
3536
- }
3580
+ const 常驻事实 = 选常驻事实(memories, scored, policy)
3537
3581
  // 普通事件:从去重后的候选里按分数拿,受 maxItems 和「这趟上限」两条约束
3538
3582
  const 事件们 = 去重后.map((x) => x.row).filter((row) => !row.m || row.m.category !== 'stable_profile')
3539
3583
  const 最高事件分 = 事件们.length && 事件们[0].s ? 事件们[0].s.C : 0
@@ -3544,18 +3588,7 @@ function recall(data, query, now, sid) {
3544
3588
  if (!row.omission) row.omission = 'weak' // 设置页上会写「这趟给的条数到顶了」
3545
3589
  }
3546
3590
 
3547
- const 上限 = typeof policy.maxChars === 'number' && policy.maxChars > 0 ? policy.maxChars : 800
3548
- const selected = []
3549
- let 已用 = 0
3550
- for (const row of 条数内) {
3551
- const n = String(row.m.text || '').length
3552
- if (selected.length > 0 && 已用 + n > 上限) {
3553
- row.omission = 'budget'
3554
- continue
3555
- }
3556
- selected.push(row)
3557
- 已用 += n
3558
- }
3591
+ const { selected, 字数: 已用 } = 按字数收(条数内, policy)
3559
3592
  const ids = new Set(selected.map((r) => r.m.id))
3560
3593
  for (const row of picked) {
3561
3594
  if (row.omission) continue // 别把 budget 覆盖成 limit
@@ -3651,7 +3684,7 @@ function makeRememberTool(ctx) {
3651
3684
  importance: {
3652
3685
  type: 'number',
3653
3686
  description:
3654
- '**要不要每轮都挂在她眼前**(不是「重不重要」):1 = 不主动提就会出事的(称呼忌讳、约定承诺、' +
3687
+ '**要不要每轮都挂在她眼前**(不是「重不重要」):1 = 不主动提就会出事的(' + userPronoun() + '的名字和你该怎么称呼' + userPronoun() + '、称呼忌讳、约定承诺、' +
3655
3688
  '身体禁忌、生日纪念日);0.5 = 其余全部(性格、喜好、书单、游戏、发生过的事)。拿不准写 0.5。',
3656
3689
  },
3657
3690
  tags: {
@@ -3861,9 +3894,7 @@ function makeRememberTool(ctx) {
3861
3894
  // ⚠️ 不 await(模型调用要几秒,不能卡住记账);但**必须 .catch** ——
3862
3895
  // 裸的 rejected promise 会被 Node 当致命错误,整个宿主退出(今天早上刚踩过)
3863
3896
  try {
3864
- Promise.resolve()
3865
- .then(() => 自动扩词(ctx, sid, 2))
3866
- .catch((error) => diag('自动扩词失败:' + ((error && error.message) || error)))
3897
+ 后台排队(sid, () => 自动扩词(ctx, sid, 2))
3867
3898
  } catch (error) {}
3868
3899
  if (作废的.length) diag('remember:顺手把 ' + 作废的.length + ' 条旧的标成「已被取代」:' + 作废的.join(','))
3869
3900
  return {
@@ -4460,14 +4491,15 @@ export function apply(ctx) {
4460
4491
  // 所以只有原词能命中、分数卡在门槛下。搜空的时候顺手补一条 —— 下次它就有词可命中了。
4461
4492
  // 节流 60 秒、一次只补一条,免得一搜不到就一连串模型调用。
4462
4493
  try {
4463
- if (!selected.length && !gate) {
4494
+ // ⚠ 原来写的是 `!gate` —— 可门放行时 gate 也是个对象(记着信息量),
4495
+ // 所以说过 20 句以后这里就再没进来过。要判的是「是不是被门挡了」。
4496
+ // 常驻层那几条每轮都在,不算「挑中了」—— 看的是事件有没有挑中。
4497
+ if (!selected.some((r) => !r.常驻) && !(gate && gate.挡了)) {
4464
4498
  const 现 = Date.now()
4465
4499
  const 待补 = scored.find((r) => r.omission === 'below_threshold')
4466
4500
  if (待补 && 现 - 上次补词 > 60000) {
4467
4501
  上次补词 = 现
4468
- Promise.resolve()
4469
- .then(() => 自动扩词(ctx, sid, 1))
4470
- .catch(() => {})
4502
+ 后台排队(sid, () => 自动扩词(ctx, sid, 1))
4471
4503
  }
4472
4504
  }
4473
4505
  } catch (error) {}
@@ -4650,6 +4682,100 @@ export function apply(ctx) {
4650
4682
  } catch (error) {}
4651
4683
  }
4652
4684
 
4685
+ // ⭐ 1.6.5(issue #27):把一个 agent 挂上记忆 —— 抽出来,tick 和 agent/created 都走这里。
4686
+ // 起因:有的机器上 `ctx.agents.list()` 一直是空的,光靠 tick 轮询什么都挂不上;
4687
+ // 而 `agent/created` 事件本身带着 agent,不依赖那个列表。
4688
+ function 挂一个(agent) {
4689
+ try {
4690
+ const 归档的 = 归档名单(ctx)
4691
+ const sid = sessionIdOf(agent.session)
4692
+ if (!sid) { diag(' 跳过:拿不到会话 id'); return }
4693
+ // 归档了就不再注入,而且把它卸下来 —— 归档=藏起来,不该还占她的上下文 ✓
4694
+ if (归档的.has(sid)) {
4695
+ const v = mounted.get(sid)
4696
+ if (v) {
4697
+ 卸掉挂载(v)
4698
+ mounted.delete(sid)
4699
+ diag(' 归档了,卸下 ' + sid)
4700
+ }
4701
+ return
4702
+ }
4703
+ // 子代理(派出去干活的)不挂记忆:它只做一件事,给它建一份库既没用又白写文件
4704
+ if (是子代理(agent)) {
4705
+ const v = mounted.get(sid)
4706
+ if (v) {
4707
+ 卸掉挂载(v)
4708
+ mounted.delete(sid)
4709
+ diag(' 子代理,卸下 ' + sid)
4710
+ }
4711
+ return
4712
+ }
4713
+ // 分支出来的会话:先把来处的记忆继承过来(每个会话只查一次)
4714
+ if (!查过分支.has(sid)) {
4715
+ 查过分支.add(sid)
4716
+ try {
4717
+ 继承分支记忆(agent, sid)
4718
+ } catch (error) {
4719
+ diag(' 分支继承失败:' + (error && error.message))
4720
+ }
4721
+ }
4722
+ // ⭐ 2026-09-21 修:**关掉了就要卸下来**
4723
+ //
4724
+ // 原来这两句的顺序是反的:
4725
+ // if (mounted.has(sid)) continue ← 挂着就直接跳过
4726
+ // if (!isEnabled(sid)) continue ← 关着的只是「不挂新的」
4727
+ // 所以**把开关从开改成关,它不会卸** —— 已经挂上的注入和工具会一直留着,
4728
+ // 只有「归档」那条路才会卸。「插件关了怎么还在写」
4729
+ // 就是这个:关掉之后工具还在她的工具列表里 ✗
4730
+ if (!isEnabled(sid)) {
4731
+ const v = mounted.get(sid)
4732
+ if (v) {
4733
+ 卸掉挂载(v)
4734
+ mounted.delete(sid)
4735
+ diag(' 关了,卸下 ' + sid)
4736
+ }
4737
+ return
4738
+ }
4739
+ // ⭐⭐ 2026-10-24 修(用户报:「已经有消息的会话,开记忆拿不到 remember 工具」):
4740
+ // 原来这里只按**会话 id** 记账(`mounted.has(sid)`)——只要挂过一次,
4741
+ // 以后这个会话就再也不挂。可 **agent 实例是会被换掉的**
4742
+ // (会话重开、插件重载、宿主重建 agent 都会换一个新对象),
4743
+ // 旧注册跟着旧 ctx 一起没了,而我们这边的账还写着「已挂上」⇒ 永远不重挂 ✗
4744
+ // 症状正好是:空会话开记忆→当场挂在活着的那个 agent 上→能用;
4745
+ // 有消息的会话→早就被换过实例→记账说挂上了、实际那个实例上没有工具。
4746
+ // → 现在连**实例**一起记:实例变了就卸旧的、重新挂。
4747
+ const 已挂 = mounted.get(sid)
4748
+ if (已挂 && 已挂.agent === agent) return // 还是同一个 agent,不用重挂
4749
+ if (已挂) {
4750
+ 卸掉挂载(已挂)
4751
+ mounted.delete(sid)
4752
+ diag(' agent 实例换过了,重挂 ' + sid)
4753
+ }
4754
+ diag(' 试挂 ' + sid)
4755
+ try {
4756
+ const off = mountFor(agent, sid)
4757
+ // ⭐ 1.3.9(issue #15):**模型侧也要看得见 remember**。
4758
+ // 只在插件层全局注册时,新会话组装出来的工具表里没有它(旧会话因为重建过才碰巧有),
4759
+ // 模型直接 unknown tool。这里再按 agent 注册一份 —— 同名会盖住全局那份,不会变成两个。
4760
+ let 工具off2 = null
4761
+ try {
4762
+ const at = agent && agent.ctx && agent.ctx.tools
4763
+ if (at && typeof at.register === 'function' && globalOn()) {
4764
+ 工具off2 = at.register(makeRememberTool(ctx))
4765
+ }
4766
+ } catch (error) {
4767
+ diag(' 按 agent 注册 remember 失败:' + (error && error.message))
4768
+ }
4769
+ mounted.set(sid, { sid, agent, off, 工具off: 工具off2 })
4770
+ diag(' ✓ 挂上 ' + sid + '(' + loadStore(sid).memories.length + ' 条)')
4771
+ } catch (error) {
4772
+ diag(' ✗ 挂 ' + sid + ' 失败:' + (error && error.message))
4773
+ }
4774
+ } catch (error) {
4775
+ diag(' 挂一个出错:' + (error && error.message))
4776
+ }
4777
+ }
4778
+
4653
4779
  // 给每个活着的 agent 挂它自己的那段记忆
4654
4780
  let 上次心跳 = '' // 只用来判断「agents 数变了没」,不再每 3 秒写一行日志
4655
4781
  function tick() {
@@ -4661,91 +4787,12 @@ export function apply(ctx) {
4661
4787
  // 2026-10-25(issue #2):这行以前每 3 秒无条件写,占整个日志的 97%;
4662
4788
  // 心跳没什么诊断价值 —— 只在「agent 数变了」时记一条。
4663
4789
  const 心跳 = 'agents=' + list.length
4664
- if (心跳 !== 上次心跳) { 上次心跳 = 心跳; diag('tick:' + 心跳) }
4790
+ if (心跳 !== 上次心跳) {
4791
+ 上次心跳 = 心跳
4792
+ diag('tick:' + 心跳 + (list.length ? '' : '(一个 agent 都没拿到 —— 靠 agent/created 那条兜底挂)'))
4793
+ }
4665
4794
  for (const agent of list) {
4666
- const sid = sessionIdOf(agent.session)
4667
- if (!sid) { diag(' 跳过:拿不到会话 id'); continue }
4668
- // 归档了就不再注入,而且把它卸下来 —— 归档=藏起来,不该还占她的上下文 ✓
4669
- if (归档的.has(sid)) {
4670
- const v = mounted.get(sid)
4671
- if (v) {
4672
- 卸掉挂载(v)
4673
- mounted.delete(sid)
4674
- diag(' 归档了,卸下 ' + sid)
4675
- }
4676
- continue
4677
- }
4678
- // 子代理(派出去干活的)不挂记忆:它只做一件事,给它建一份库既没用又白写文件
4679
- if (是子代理(agent)) {
4680
- const v = mounted.get(sid)
4681
- if (v) {
4682
- 卸掉挂载(v)
4683
- mounted.delete(sid)
4684
- diag(' 子代理,卸下 ' + sid)
4685
- }
4686
- continue
4687
- }
4688
- // 分支出来的会话:先把来处的记忆继承过来(每个会话只查一次)
4689
- if (!查过分支.has(sid)) {
4690
- 查过分支.add(sid)
4691
- try {
4692
- 继承分支记忆(agent, sid)
4693
- } catch (error) {
4694
- diag(' 分支继承失败:' + (error && error.message))
4695
- }
4696
- }
4697
- // ⭐ 2026-09-21 修:**关掉了就要卸下来**
4698
- //
4699
- // 原来这两句的顺序是反的:
4700
- // if (mounted.has(sid)) continue ← 挂着就直接跳过
4701
- // if (!isEnabled(sid)) continue ← 关着的只是「不挂新的」
4702
- // 所以**把开关从开改成关,它不会卸** —— 已经挂上的注入和工具会一直留着,
4703
- // 只有「归档」那条路才会卸。「插件关了怎么还在写」
4704
- // 就是这个:关掉之后工具还在她的工具列表里 ✗
4705
- if (!isEnabled(sid)) {
4706
- const v = mounted.get(sid)
4707
- if (v) {
4708
- 卸掉挂载(v)
4709
- mounted.delete(sid)
4710
- diag(' 关了,卸下 ' + sid)
4711
- }
4712
- continue
4713
- }
4714
- // ⭐⭐ 2026-10-24 修(用户报:「已经有消息的会话,开记忆拿不到 remember 工具」):
4715
- // 原来这里只按**会话 id** 记账(`mounted.has(sid)`)——只要挂过一次,
4716
- // 以后这个会话就再也不挂。可 **agent 实例是会被换掉的**
4717
- // (会话重开、插件重载、宿主重建 agent 都会换一个新对象),
4718
- // 旧注册跟着旧 ctx 一起没了,而我们这边的账还写着「已挂上」⇒ 永远不重挂 ✗
4719
- // 症状正好是:空会话开记忆→当场挂在活着的那个 agent 上→能用;
4720
- // 有消息的会话→早就被换过实例→记账说挂上了、实际那个实例上没有工具。
4721
- // → 现在连**实例**一起记:实例变了就卸旧的、重新挂。
4722
- const 已挂 = mounted.get(sid)
4723
- if (已挂 && 已挂.agent === agent) continue // 还是同一个 agent,不用重挂
4724
- if (已挂) {
4725
- 卸掉挂载(已挂)
4726
- mounted.delete(sid)
4727
- diag(' agent 实例换过了,重挂 ' + sid)
4728
- }
4729
- diag(' 试挂 ' + sid)
4730
- try {
4731
- const off = mountFor(agent, sid)
4732
- // ⭐ 1.3.9(issue #15):**模型侧也要看得见 remember**。
4733
- // 只在插件层全局注册时,新会话组装出来的工具表里没有它(旧会话因为重建过才碰巧有),
4734
- // 模型直接 unknown tool。这里再按 agent 注册一份 —— 同名会盖住全局那份,不会变成两个。
4735
- let 工具off2 = null
4736
- try {
4737
- const at = agent && agent.ctx && agent.ctx.tools
4738
- if (at && typeof at.register === 'function' && globalOn()) {
4739
- 工具off2 = at.register(makeRememberTool(ctx))
4740
- }
4741
- } catch (error) {
4742
- diag(' 按 agent 注册 remember 失败:' + (error && error.message))
4743
- }
4744
- mounted.set(sid, { sid, agent, off, 工具off: 工具off2 })
4745
- diag(' ✓ 挂上 ' + sid + '(' + loadStore(sid).memories.length + ' 条)')
4746
- } catch (error) {
4747
- diag(' ✗ 挂 ' + sid + ' 失败:' + (error && error.message))
4748
- }
4795
+ 挂一个(agent)
4749
4796
  }
4750
4797
  } catch (error) {}
4751
4798
  }
@@ -4762,6 +4809,18 @@ export function apply(ctx) {
4762
4809
  } catch (error) {
4763
4810
  console.error('[记忆] 定时器不可用(不影响对话):' + (error && error.message))
4764
4811
  }
4812
+
4813
+ // ⭐ 1.6.5(issue #27):光靠轮询不够 —— 事件来了就当场挂,不看 list()
4814
+ try {
4815
+ ctx.on('agent/created', (payload) => {
4816
+ try {
4817
+ const a = payload && payload.agent
4818
+ if (a) 挂一个(a)
4819
+ } catch (error) {}
4820
+ })
4821
+ } catch (error) {
4822
+ diag('agent/created 挂不上(不影响对话):' + (error && error.message))
4823
+ }
4765
4824
  ctx.effect(() => () => {
4766
4825
  for (const v of mounted.values()) 卸掉挂载(v)
4767
4826
  mounted.clear()
@@ -4806,25 +4865,20 @@ export function apply(ctx) {
4806
4865
  * 宁可这一版防不住,也不能因为宿主换个版本就把设置页锁死。
4807
4866
  * 真正拒绝只有一种情况:宿主**明确**说这个请求不合法(401 / 403)。
4808
4867
  */
4809
- const 只认本人 = (handler) => async (req, res) => {
4810
- try {
4811
- const 连接 = ctx.get('connection')
4812
- const 拒 = 连接 && typeof 连接.requestRejection === 'function' ? 连接.requestRejection(req) : undefined
4813
- if (拒 === 401 || 拒 === 403) {
4814
- diag('管理接口拒了一次外来请求(' + 拒 + '):' + String((req && req.url) || ''))
4815
- try {
4816
- res.writeHead(拒, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
4817
- res.end(JSON.stringify({ ok: false, error: 拒 === 401 ? 'authentication_required' : 'management_forbidden' }))
4818
- } catch (e2) {}
4819
- return
4820
- }
4821
- } catch (error) {
4822
- // 鉴权这条路自己坏了 → 放行,绝不让设置页打不开
4823
- }
4824
- return handler(req, res)
4825
- }
4826
-
4868
+ // ⭐ 1.5.7(issue #35):准入改成**失败即拒绝**。
4869
+ // 以前这里在拿不到 connection、或者 requestRejection 自己抛错时放行 ——
4870
+ // 于是本机任意网页都能打到管理接口(包括一键安装)。现在判不了就拒。
4871
+ const 准入 = 建准入(ctx, diag)
4872
+ const 只认本人 = (handler) => 包准入(准入, handler, diag)
4873
+
4874
+ // ⭐ 网页服务是可选的:有 webServer 才挂设置页(跟 DSH 自己的写法一样,ctx.inject 按服务在不在挂/卸)。
4875
+ // 没有网页服务时(无界面 profile)记忆照常记、照常想起来,只是没有设置页 —— 而不是整个插件起不来。
4876
+ // 宿主没有 ctx.inject(老宿主 / 测试替身)就照旧直接挂。
4827
4877
  const mroute = (path, handler) =>
4878
+ typeof ctx.inject === 'function'
4879
+ ? ctx.inject(['webServer'], (c) => 挂路由(c, path, handler))
4880
+ : 挂路由(ctx, path, handler)
4881
+ const 挂路由 = (ctx, path, handler) =>
4828
4882
  ctx.effect(() =>
4829
4883
  ctx.webServer.register({
4830
4884
  kind: 'exact',
@@ -4847,12 +4901,11 @@ export function apply(ctx) {
4847
4901
  packageRoot: join(dirname(fileURLToPath(import.meta.url)), '..'),
4848
4902
  diag,
4849
4903
  })
4850
- mroute('/dsh-fish-memory/update', async (req, res) => {
4851
- const u = new URL(req.url, 'http://127.0.0.1')
4852
- const r = await 自查更新.check(u.searchParams.get('refresh') === '1')
4853
- res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
4854
- res.end(JSON.stringify({ ok: true, ...r }))
4855
- })
4904
+ // ⭐ 1.5.7(issue #35):
4905
+ // ① `/install`(一键更新)以前**根本没注册** —— 设置页那个按钮点了只会 404;
4906
+ // ② 而 `selfupdate.register()` 里的两个路由又不带准入。
4907
+ // 现在统一走它,并且把准入一起传进去:两个路由都受管,按钮也真的能用。
4908
+ 自查更新.register(ctx, '/dsh-fish-memory', 准入)
4856
4909
 
4857
4910
  mroute('/dsh-fish-memory/page', (req, res) => {
4858
4911
  res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' })
@@ -7231,6 +7284,8 @@ export {
7231
7284
  * ⚠ 只给测试用,不要在生产代码里调;里面都是纯函数或内部状态读取。
7232
7285
  */
7233
7286
  export const __测试 = {
7287
+ 选常驻事实,
7288
+ 后台排队,
7234
7289
  工作区目录名,
7235
7290
  扫会话目录,
7236
7291
  会话的工作区,
@@ -186,16 +186,32 @@ export function createSelfUpdate({ packageRoot, diag }) {
186
186
  }
187
187
  }
188
188
 
189
- function register(ctx, route) {
189
+ function register(ctx, route, 准入) {
190
190
  const send = (res, code, obj) => {
191
191
  res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
192
192
  res.end(JSON.stringify(obj))
193
193
  }
194
+ // ⭐ 1.5.7(issue #35):这两个路由以前**没有准入** —— 本机任意网页都能触发安装。
195
+ // 现在一律先过准入(失败即拒绝)。
196
+ const 过 = (handler) => async (req, res) => {
197
+ if (typeof 准入 === 'function') {
198
+ const 结果 = 准入(req)
199
+ if (结果) {
200
+ try { log('管理接口拒了一次(' + 结果.码 + ',' + 结果.说 + '):' + String((req && req.url) || '')) } catch (e) {}
201
+ return send(res, 结果.码, {
202
+ ok: false,
203
+ error: 结果.码 === 401 ? 'authentication_required'
204
+ : 结果.码 === 503 ? 'management_unavailable' : 'management_forbidden',
205
+ })
206
+ }
207
+ }
208
+ return handler(req, res)
209
+ }
194
210
  try {
195
211
  ctx.effect(() => ctx.webServer.register({
196
212
  kind: 'exact',
197
213
  path: route + '/update',
198
- handler: async (req, res) => {
214
+ handler: 过(async (req, res) => {
199
215
  try {
200
216
  const url = new URL(req.url, 'http://127.0.0.1')
201
217
  const force = url.searchParams.get('refresh') === '1'
@@ -204,13 +220,13 @@ export function createSelfUpdate({ packageRoot, diag }) {
204
220
  log('查更新路由失败:' + (e && e.message))
205
221
  send(res, 200, { ok: false, current, latest: null, error: String((e && e.message) || e) })
206
222
  }
207
- },
223
+ }),
208
224
  }))
209
225
  // ⭐ 一键更新:POST。装完的结果原样带回去(可能要重启才生效)
210
226
  ctx.effect(() => ctx.webServer.register({
211
227
  kind: 'exact',
212
228
  path: route + '/install',
213
- handler: async (req, res) => {
229
+ handler: 过(async (req, res) => {
214
230
  if (req.method !== 'POST') { send(res, 405, { ok: false, error: '只接受 POST' }); return }
215
231
  try {
216
232
  const r = await install(ctx)
@@ -219,7 +235,7 @@ export function createSelfUpdate({ packageRoot, diag }) {
219
235
  log('一键更新路由失败:' + (e && e.message))
220
236
  send(res, 200, { ok: false, error: String((e && e.message) || e) })
221
237
  }
222
- },
238
+ }),
223
239
  }))
224
240
  } catch (e) {
225
241
  log('查更新路由注册失败:' + (e && e.message))
@@ -0,0 +1,63 @@
1
+ /**
2
+ * 管理接口的准入 —— 失败即拒绝(issue #35)。
3
+ *
4
+ * 以前 `只认本人` 在拿不到 connection、或者 requestRejection 自己抛错时**放行**,
5
+ * 于是本机任意网页都能打到 `/dsh-fish-memory/install`(一键安装包)。
6
+ * 现在反过来:**判不了就拒**,宁可设置页多试一次,也不让别人的网页能装东西。
7
+ */
8
+
9
+ const 本地来源 = /^(?:dsh-app:|https?:\/\/(?:127\.0\.0\.1|localhost|\[::1\])(?::\d+)?\/?$)/i
10
+
11
+ /**
12
+ * 建一个准入函数。返回 null = 放行;返回 { 码, 说 } = 拒绝。
13
+ */
14
+ export function 建准入(ctx, 记账 = () => {}) {
15
+ return function 准入(req) {
16
+ let 拒
17
+ try {
18
+ const 连接 = ctx.get('connection')
19
+ if (!连接 || typeof 连接.requestRejection !== 'function') {
20
+ // ⚠ 失败即拒绝:拿不到宿主的准入服务,就当这个请求不可信
21
+ return { 码: 503, 说: '拿不到宿主的 connection 服务' }
22
+ }
23
+ 拒 = 连接.requestRejection(req)
24
+ } catch (error) {
25
+ return { 码: 503, 说: '准入服务自己抛错:' + ((error && error.message) || error) }
26
+ }
27
+ if (拒 === 401 || 拒 === 403) return { 码: 拒, 说: '宿主判了外来请求' }
28
+
29
+ // 再兜一层:浏览器带的「跨站」标记 / 不是本机来的 Origin
30
+ const 头 = (req && req.headers) || {}
31
+ if (String(头['sec-fetch-site'] || '').toLowerCase() === 'cross-site') {
32
+ return { 码: 403, 说: '跨站请求' }
33
+ }
34
+ const 源 = String(头.origin || '')
35
+ if (源 && !本地来源.test(源)) return { 码: 403, 说: '来源不是本机:' + 源.slice(0, 80) }
36
+ return null
37
+ }
38
+ }
39
+
40
+ /**
41
+ * 包一个路由处理器:先过准入,拒了就回 JSON。
42
+ */
43
+ export function 包准入(准入, handler, 记账 = () => {}) {
44
+ return async function 带准入的(req, res) {
45
+ const 结果 = 准入(req)
46
+ if (结果) {
47
+ try {
48
+ 记账('管理接口拒了一次(' + 结果.码 + ',' + 结果.说 + '):' + String((req && req.url) || ''))
49
+ } catch (error) {}
50
+ try {
51
+ res.writeHead(结果.码, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
52
+ res.end(JSON.stringify({
53
+ ok: false,
54
+ error: 结果.码 === 401 ? 'authentication_required'
55
+ : 结果.码 === 503 ? 'management_unavailable' : 'management_forbidden',
56
+ 说: 结果.说,
57
+ }))
58
+ } catch (error) {}
59
+ return
60
+ }
61
+ return handler(req, res)
62
+ }
63
+ }
package/package.json CHANGED
@@ -1,60 +1,60 @@
1
- {
2
- "name": "dsh-fish-memory",
3
- "version": "1.5.5",
4
- "description": "给 DSH 里的会话一份会淡忘的长期记忆,每条都带时间。",
5
- "license": "SEE LICENSE IN LICENSE",
6
- "type": "module",
7
- "engines": {
8
- "node": ">=22",
9
- "dsh": ">=0.2.1-rc.2"
10
- },
11
- "keywords": [
12
- "deepseek-harness",
13
- "dsh-plugin",
14
- "memory",
15
- "long-term-memory",
16
- "ai-companion"
17
- ],
18
- "repository": {
19
- "type": "git",
20
- "url": "git+https://github.com/Yuuyuko-uu/dsh-fish-memory.git"
21
- },
22
- "main": "lib/index.js",
23
- "exports": {
24
- ".": "./lib/index.js",
25
- "./client": "./lib/client.js",
26
- "./cordis.patch.yml": "./cordis.patch.yml",
27
- "./package.json": "./package.json"
28
- },
29
- "files": [
30
- "lib",
31
- "cordis.patch.yml",
32
- "README.md"
33
- ],
34
- "dsh": {
35
- "bundle": {
36
- "patch": "./cordis.patch.yml"
37
- },
38
- "client": {
39
- "platform": "web",
40
- "inject": [
41
- "slots"
42
- ],
43
- "external": [
44
- "react"
45
- ]
46
- }
47
- },
48
- "peerDependencies": {
49
- "@deepseek-ai/dsh-tools": "*",
50
- "@deepseek-ai/dsh-llm": "*"
51
- },
52
- "peerDependenciesMeta": {
53
- "@deepseek-ai/dsh-tools": {
54
- "optional": true
55
- },
56
- "@deepseek-ai/dsh-llm": {
57
- "optional": true
58
- }
59
- }
60
- }
1
+ {
2
+ "name": "dsh-fish-memory",
3
+ "version": "1.5.6",
4
+ "description": "给 DSH 里的会话一份会淡忘的长期记忆,每条都带时间。",
5
+ "license": "SEE LICENSE IN LICENSE",
6
+ "type": "module",
7
+ "engines": {
8
+ "node": ">=22",
9
+ "dsh": ">=0.2.1-rc.2"
10
+ },
11
+ "keywords": [
12
+ "deepseek-harness",
13
+ "dsh-plugin",
14
+ "memory",
15
+ "long-term-memory",
16
+ "ai-companion"
17
+ ],
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/Yuuyuko-uu/dsh-fish-memory.git"
21
+ },
22
+ "main": "lib/index.js",
23
+ "exports": {
24
+ ".": "./lib/index.js",
25
+ "./client": "./lib/client.js",
26
+ "./cordis.patch.yml": "./cordis.patch.yml",
27
+ "./package.json": "./package.json"
28
+ },
29
+ "files": [
30
+ "lib",
31
+ "cordis.patch.yml",
32
+ "README.md"
33
+ ],
34
+ "dsh": {
35
+ "bundle": {
36
+ "patch": "./cordis.patch.yml"
37
+ },
38
+ "client": {
39
+ "platform": "web",
40
+ "inject": [
41
+ "slots"
42
+ ],
43
+ "external": [
44
+ "react"
45
+ ]
46
+ }
47
+ },
48
+ "peerDependencies": {
49
+ "@deepseek-ai/dsh-tools": "*",
50
+ "@deepseek-ai/dsh-llm": "*"
51
+ },
52
+ "peerDependenciesMeta": {
53
+ "@deepseek-ai/dsh-tools": {
54
+ "optional": true
55
+ },
56
+ "@deepseek-ai/dsh-llm": {
57
+ "optional": true
58
+ }
59
+ }
60
+ }