dsh-fish-memory 1.5.5 → 1.5.7

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')
@@ -154,7 +157,6 @@ const DEFAULT_POLICY = {
154
157
  rarityK: 0.8, // ⭐ 1.4.0:1 → 0.8。稀有度饱和点:加起来到多少算「一半相关」(越小越容易给)
155
158
  queryGate: 0.8, // 问句信息量门槛:这句话本身太没内容就不翻记忆。0 = 关掉这道门
156
159
  // ⭐ 2026-09-21 中午加的(C+D:两条队)
157
- factsMax: 2, // 长期事实每轮固定带几条(常驻层,不占 maxItems 的位置)。0 = 不带长期事实
158
160
  // ⭐ 2026-09-21 深夜(她的决定):默认从 1 调到 **0.5** ✓
159
161
  // 她的直觉:「我知道时间才是劣势」——有日期的那 188 条,反倒排不到没日期的前面去 ✗
160
162
  // 实测:门槛卡 C 之后这个旋钮**只影响排序**,扫 1/0.75/0.5/0.25/0「该给到」全是 11/13 一动不动 ✓
@@ -177,7 +179,6 @@ const POLICY_SPEC = [
177
179
  { key: 'dailyQuota', group: 'gate', label: '每天最多记几条', tip: '她自己一天最多记几条', min: 1, max: 30, step: 1 },
178
180
  { key: 'rarityK', group: 'gate', label: '稀有度饱和点', tip: '命中词的稀有度(0~1)加起来到多少算「一半相关」。越小越容易给', min: 0.3, max: 6, step: 0.1 },
179
181
  { key: 'queryGate', group: 'gate', label: '问句信息量门槛', tip: '这句话本身太没内容(像「在吗」)就不翻记忆。0 = 关掉这道门', min: 0, max: 2, step: 0.05 },
180
- { key: 'factsMax', group: 'gate', label: '长期事实每轮带几条', tip: '要记一辈子的事(画像、喜好、约定)每轮都带上,不看这句话问了什么,也不占「最多注入几条」的位置。0 = 不带长期事实', min: 0, max: 6, step: 1 },
181
182
  { key: 'unknownActivation', group: 'gate', label: '没写时间的算多鲜活', tip: '没写时间戳的记忆(含长期事实)鲜活度按几成算。1 = 当成刚发生的(默认)。调小只让它们排在有时间戳的后面,不影响谁进得来', min: 0, max: 1, step: 0.05 },
182
183
  ]
183
184
 
@@ -1748,6 +1749,19 @@ function 解析扩词(文本, 条数) {
1748
1749
  * · 模型没回可用的词(思考吃光预算那种)就安静退出,下次再说。
1749
1750
  * 返回补了几条(调试用)。
1750
1751
  */
1752
+ // 同一个库的后台扩词排成一队,一个跑完再跑下一个:
1753
+ // 「记完顺手扩」和「搜空补一条」可能同时起跑,都是「等模型 → 重读 → 改几条 → 写回」,
1754
+ // 两份都挑中同一批没扩过的条目时,白花一次模型调用,后写的还会按时间盖掉先写的结果。
1755
+ const 后台队 = new Map() // sid -> 队尾 promise
1756
+ function 后台排队(sid, 活) {
1757
+ const 尾 = (后台队.get(sid) || Promise.resolve())
1758
+ .then(活)
1759
+ .catch((error) => diag('后台整理失败(已忽略):' + ((error && error.message) || error)))
1760
+ 后台队.set(sid, 尾)
1761
+ 尾.then(() => { if (后台队.get(sid) === 尾) 后台队.delete(sid) })
1762
+ return 尾
1763
+ }
1764
+
1751
1765
  async function 自动扩词(ctx, sid, 上限 = 2) {
1752
1766
  try {
1753
1767
  if (!sid) return 0
@@ -3322,6 +3336,24 @@ function 按键收敛(data, 被谁) {
3322
3336
  // (2026-09-23 尺子换成按词比,这个数跟着重量过,0.80 仍然合适)
3323
3337
  const 召回重复门槛 = 0.8
3324
3338
 
3339
+
3340
+ // 按「注入字数上限」收:第一条总能进,后面的超了就这趟先不进
3341
+ function 按字数收(rows, policy) {
3342
+ const 上限 = typeof policy.maxChars === 'number' && policy.maxChars > 0 ? policy.maxChars : 800
3343
+ const selected = []
3344
+ let 字数 = 0
3345
+ for (const row of rows) {
3346
+ const n = String(row.m.text || '').length
3347
+ if (selected.length > 0 && 字数 + n > 上限) {
3348
+ row.omission = 'budget'
3349
+ continue
3350
+ }
3351
+ selected.push(row)
3352
+ 字数 += n
3353
+ }
3354
+ return { selected, 字数 }
3355
+ }
3356
+
3325
3357
  function recall(data, query, now, sid) {
3326
3358
  const policy = data.policy
3327
3359
  // ⭐ 2026-09-21(E):**提炼判成「跳过」的,不再参与召回** ✓
@@ -3357,7 +3389,8 @@ function recall(data, query, now, sid) {
3357
3389
  const v = 问句信息量(sid, query, df)
3358
3390
  if (v < 门槛) {
3359
3391
  // 这道门挡下来的时候**不做 851 次打分**,省一趟;理由照样记进 trace 给她看 ✓
3360
- return { selected: [], scored: [], 字数: 0, gate: { 信息量: Number(v.toFixed(2)), 门槛 } }
3392
+ // 门挡住了就什么都不带(长期事实那层已经删掉了)
3393
+ return { selected: [], scored: [], 字数: 0, gate: { 信息量: Number(v.toFixed(2)), 门槛, 挡了: true } }
3361
3394
  }
3362
3395
  gate = { 信息量: Number(v.toFixed(2)), 门槛 }
3363
3396
  }
@@ -3502,65 +3535,28 @@ function recall(data, query, now, sid) {
3502
3535
  }
3503
3536
  去重后.push(x)
3504
3537
  }
3505
- // ⭐⭐ 2026-10-27(issue #14):**常驻层** —— 长期事实不看这句话问的是什么,每轮都带上
3506
- //
3507
- // 当时的建议(转给用户那条):「把长期事实改为每轮注入,其他记忆打分淡忘」。
3508
- // 以前事实也走召回:这句话没撞上「我住在杭州」,这条就进不来 —— 可它明明是「要记一辈子」的事。
3509
- // 现在:
3510
- // · **长期事实**:每轮固定带 factsMax 条(按重要度、再按时间取前几名),
3511
- // 不看这句话问了什么,也**不占** maxItems 的位置;
3512
- // · **普通事件**:照旧走召回 + 打分淡忘,用 maxItems 个位置,且受「这趟上限」约束(不硬凑)。
3513
- //
3514
- // ⚠ 只改「谁进来、名额怎么分」,不改打分和门槛 —— 事件该不该进还是那套尺子说了算。
3515
- // ⚠ 事实按「重要度 → 时间」取,不按分数:常驻层的意义就是不依赖这句话。
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
- }
3538
+ // ⭐ 1.5.7:**长期事实(每轮无条件带几条)已整个删除**。
3539
+ // 理由:闲聊时把跟当下无关的记忆塞进去,会把话带跑;身份/称呼这类由星汐每轮开头带。
3540
+ // 现在只有「这句话检索命中」的记忆才会进上下文。
3537
3541
  // 普通事件:从去重后的候选里按分数拿,受 maxItems 和「这趟上限」两条约束
3538
3542
  const 事件们 = 去重后.map((x) => x.row).filter((row) => !row.m || row.m.category !== 'stable_profile')
3539
3543
  const 最高事件分 = 事件们.length && 事件们[0].s ? 事件们[0].s.C : 0
3540
3544
  const 事件本趟上限 = 事件们.length ? 分档.find(([线]) => 最高事件分 >= 线)[1] : 0
3541
3545
  const 事件名额 = Math.min(事件本趟上限, policy.maxItems)
3542
- const 条数内 = 常驻事实.concat(事件们.slice(0, 事件名额))
3546
+ const 条数内 = 事件们.slice(0, 事件名额)
3543
3547
  for (const row of 事件们.slice(事件名额)) {
3544
3548
  if (!row.omission) row.omission = 'weak' // 设置页上会写「这趟给的条数到顶了」
3545
3549
  }
3546
3550
 
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
- }
3551
+ const { selected, 字数: 已用 } = 按字数收(条数内, policy)
3559
3552
  const ids = new Set(selected.map((r) => r.m.id))
3553
+ // ⭐ 1.5.7:以前把所有没选中的一律标成 limit,于是明明一条都没命中,第一条也显示「超过条数上限」。
3554
+ // 现在按真实原因标:在名额里但被字数卡掉 → budget;根本没进名额 → 分数不够。
3555
+ const 在名额里 = new Set(条数内.map((r) => r.m.id))
3560
3556
  for (const row of picked) {
3561
3557
  if (row.omission) continue // 别把 budget 覆盖成 limit
3562
3558
  if (ids.has(row.m.id)) continue
3563
- row.omission = 'limit'
3559
+ row.omission = 在名额里.has(row.m.id) ? 'budget' : 'below_threshold'
3564
3560
  }
3565
3561
 
3566
3562
  // ⚠ scored 要按分数排一下再交出去 —— 不然设置页那个「最近一次召回」
@@ -3651,7 +3647,7 @@ function makeRememberTool(ctx) {
3651
3647
  importance: {
3652
3648
  type: 'number',
3653
3649
  description:
3654
- '**要不要每轮都挂在她眼前**(不是「重不重要」):1 = 不主动提就会出事的(称呼忌讳、约定承诺、' +
3650
+ '**要不要每轮都挂在她眼前**(不是「重不重要」):1 = 不主动提就会出事的(' + userPronoun() + '的名字和你该怎么称呼' + userPronoun() + '、称呼忌讳、约定承诺、' +
3655
3651
  '身体禁忌、生日纪念日);0.5 = 其余全部(性格、喜好、书单、游戏、发生过的事)。拿不准写 0.5。',
3656
3652
  },
3657
3653
  tags: {
@@ -3861,9 +3857,7 @@ function makeRememberTool(ctx) {
3861
3857
  // ⚠️ 不 await(模型调用要几秒,不能卡住记账);但**必须 .catch** ——
3862
3858
  // 裸的 rejected promise 会被 Node 当致命错误,整个宿主退出(今天早上刚踩过)
3863
3859
  try {
3864
- Promise.resolve()
3865
- .then(() => 自动扩词(ctx, sid, 2))
3866
- .catch((error) => diag('自动扩词失败:' + ((error && error.message) || error)))
3860
+ 后台排队(sid, () => 自动扩词(ctx, sid, 2))
3867
3861
  } catch (error) {}
3868
3862
  if (作废的.length) diag('remember:顺手把 ' + 作废的.length + ' 条旧的标成「已被取代」:' + 作废的.join(','))
3869
3863
  return {
@@ -4460,14 +4454,15 @@ export function apply(ctx) {
4460
4454
  // 所以只有原词能命中、分数卡在门槛下。搜空的时候顺手补一条 —— 下次它就有词可命中了。
4461
4455
  // 节流 60 秒、一次只补一条,免得一搜不到就一连串模型调用。
4462
4456
  try {
4463
- if (!selected.length && !gate) {
4457
+ // ⚠ 原来写的是 `!gate` —— 可门放行时 gate 也是个对象(记着信息量),
4458
+ // 所以说过 20 句以后这里就再没进来过。要判的是「是不是被门挡了」。
4459
+ // 常驻层那几条每轮都在,不算「挑中了」—— 看的是事件有没有挑中。
4460
+ if (!selected.some((r) => !r.常驻) && !(gate && gate.挡了)) {
4464
4461
  const 现 = Date.now()
4465
4462
  const 待补 = scored.find((r) => r.omission === 'below_threshold')
4466
4463
  if (待补 && 现 - 上次补词 > 60000) {
4467
4464
  上次补词 = 现
4468
- Promise.resolve()
4469
- .then(() => 自动扩词(ctx, sid, 1))
4470
- .catch(() => {})
4465
+ 后台排队(sid, () => 自动扩词(ctx, sid, 1))
4471
4466
  }
4472
4467
  }
4473
4468
  } catch (error) {}
@@ -4650,6 +4645,100 @@ export function apply(ctx) {
4650
4645
  } catch (error) {}
4651
4646
  }
4652
4647
 
4648
+ // ⭐ 1.6.5(issue #27):把一个 agent 挂上记忆 —— 抽出来,tick 和 agent/created 都走这里。
4649
+ // 起因:有的机器上 `ctx.agents.list()` 一直是空的,光靠 tick 轮询什么都挂不上;
4650
+ // 而 `agent/created` 事件本身带着 agent,不依赖那个列表。
4651
+ function 挂一个(agent) {
4652
+ try {
4653
+ const 归档的 = 归档名单(ctx)
4654
+ const sid = sessionIdOf(agent.session)
4655
+ if (!sid) { diag(' 跳过:拿不到会话 id'); return }
4656
+ // 归档了就不再注入,而且把它卸下来 —— 归档=藏起来,不该还占她的上下文 ✓
4657
+ if (归档的.has(sid)) {
4658
+ const v = mounted.get(sid)
4659
+ if (v) {
4660
+ 卸掉挂载(v)
4661
+ mounted.delete(sid)
4662
+ diag(' 归档了,卸下 ' + sid)
4663
+ }
4664
+ return
4665
+ }
4666
+ // 子代理(派出去干活的)不挂记忆:它只做一件事,给它建一份库既没用又白写文件
4667
+ if (是子代理(agent)) {
4668
+ const v = mounted.get(sid)
4669
+ if (v) {
4670
+ 卸掉挂载(v)
4671
+ mounted.delete(sid)
4672
+ diag(' 子代理,卸下 ' + sid)
4673
+ }
4674
+ return
4675
+ }
4676
+ // 分支出来的会话:先把来处的记忆继承过来(每个会话只查一次)
4677
+ if (!查过分支.has(sid)) {
4678
+ 查过分支.add(sid)
4679
+ try {
4680
+ 继承分支记忆(agent, sid)
4681
+ } catch (error) {
4682
+ diag(' 分支继承失败:' + (error && error.message))
4683
+ }
4684
+ }
4685
+ // ⭐ 2026-09-21 修:**关掉了就要卸下来**
4686
+ //
4687
+ // 原来这两句的顺序是反的:
4688
+ // if (mounted.has(sid)) continue ← 挂着就直接跳过
4689
+ // if (!isEnabled(sid)) continue ← 关着的只是「不挂新的」
4690
+ // 所以**把开关从开改成关,它不会卸** —— 已经挂上的注入和工具会一直留着,
4691
+ // 只有「归档」那条路才会卸。「插件关了怎么还在写」
4692
+ // 就是这个:关掉之后工具还在她的工具列表里 ✗
4693
+ if (!isEnabled(sid)) {
4694
+ const v = mounted.get(sid)
4695
+ if (v) {
4696
+ 卸掉挂载(v)
4697
+ mounted.delete(sid)
4698
+ diag(' 关了,卸下 ' + sid)
4699
+ }
4700
+ return
4701
+ }
4702
+ // ⭐⭐ 2026-10-24 修(用户报:「已经有消息的会话,开记忆拿不到 remember 工具」):
4703
+ // 原来这里只按**会话 id** 记账(`mounted.has(sid)`)——只要挂过一次,
4704
+ // 以后这个会话就再也不挂。可 **agent 实例是会被换掉的**
4705
+ // (会话重开、插件重载、宿主重建 agent 都会换一个新对象),
4706
+ // 旧注册跟着旧 ctx 一起没了,而我们这边的账还写着「已挂上」⇒ 永远不重挂 ✗
4707
+ // 症状正好是:空会话开记忆→当场挂在活着的那个 agent 上→能用;
4708
+ // 有消息的会话→早就被换过实例→记账说挂上了、实际那个实例上没有工具。
4709
+ // → 现在连**实例**一起记:实例变了就卸旧的、重新挂。
4710
+ const 已挂 = mounted.get(sid)
4711
+ if (已挂 && 已挂.agent === agent) return // 还是同一个 agent,不用重挂
4712
+ if (已挂) {
4713
+ 卸掉挂载(已挂)
4714
+ mounted.delete(sid)
4715
+ diag(' agent 实例换过了,重挂 ' + sid)
4716
+ }
4717
+ diag(' 试挂 ' + sid)
4718
+ try {
4719
+ const off = mountFor(agent, sid)
4720
+ // ⭐ 1.3.9(issue #15):**模型侧也要看得见 remember**。
4721
+ // 只在插件层全局注册时,新会话组装出来的工具表里没有它(旧会话因为重建过才碰巧有),
4722
+ // 模型直接 unknown tool。这里再按 agent 注册一份 —— 同名会盖住全局那份,不会变成两个。
4723
+ let 工具off2 = null
4724
+ try {
4725
+ const at = agent && agent.ctx && agent.ctx.tools
4726
+ if (at && typeof at.register === 'function' && globalOn()) {
4727
+ 工具off2 = at.register(makeRememberTool(ctx))
4728
+ }
4729
+ } catch (error) {
4730
+ diag(' 按 agent 注册 remember 失败:' + (error && error.message))
4731
+ }
4732
+ mounted.set(sid, { sid, agent, off, 工具off: 工具off2 })
4733
+ diag(' ✓ 挂上 ' + sid + '(' + loadStore(sid).memories.length + ' 条)')
4734
+ } catch (error) {
4735
+ diag(' ✗ 挂 ' + sid + ' 失败:' + (error && error.message))
4736
+ }
4737
+ } catch (error) {
4738
+ diag(' 挂一个出错:' + (error && error.message))
4739
+ }
4740
+ }
4741
+
4653
4742
  // 给每个活着的 agent 挂它自己的那段记忆
4654
4743
  let 上次心跳 = '' // 只用来判断「agents 数变了没」,不再每 3 秒写一行日志
4655
4744
  function tick() {
@@ -4661,91 +4750,12 @@ export function apply(ctx) {
4661
4750
  // 2026-10-25(issue #2):这行以前每 3 秒无条件写,占整个日志的 97%;
4662
4751
  // 心跳没什么诊断价值 —— 只在「agent 数变了」时记一条。
4663
4752
  const 心跳 = 'agents=' + list.length
4664
- if (心跳 !== 上次心跳) { 上次心跳 = 心跳; diag('tick:' + 心跳) }
4753
+ if (心跳 !== 上次心跳) {
4754
+ 上次心跳 = 心跳
4755
+ diag('tick:' + 心跳 + (list.length ? '' : '(一个 agent 都没拿到 —— 靠 agent/created 那条兜底挂)'))
4756
+ }
4665
4757
  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
- }
4758
+ 挂一个(agent)
4749
4759
  }
4750
4760
  } catch (error) {}
4751
4761
  }
@@ -4762,6 +4772,18 @@ export function apply(ctx) {
4762
4772
  } catch (error) {
4763
4773
  console.error('[记忆] 定时器不可用(不影响对话):' + (error && error.message))
4764
4774
  }
4775
+
4776
+ // ⭐ 1.6.5(issue #27):光靠轮询不够 —— 事件来了就当场挂,不看 list()
4777
+ try {
4778
+ ctx.on('agent/created', (payload) => {
4779
+ try {
4780
+ const a = payload && payload.agent
4781
+ if (a) 挂一个(a)
4782
+ } catch (error) {}
4783
+ })
4784
+ } catch (error) {
4785
+ diag('agent/created 挂不上(不影响对话):' + (error && error.message))
4786
+ }
4765
4787
  ctx.effect(() => () => {
4766
4788
  for (const v of mounted.values()) 卸掉挂载(v)
4767
4789
  mounted.clear()
@@ -4806,25 +4828,20 @@ export function apply(ctx) {
4806
4828
  * 宁可这一版防不住,也不能因为宿主换个版本就把设置页锁死。
4807
4829
  * 真正拒绝只有一种情况:宿主**明确**说这个请求不合法(401 / 403)。
4808
4830
  */
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
-
4831
+ // ⭐ 1.5.7(issue #35):准入改成**失败即拒绝**。
4832
+ // 以前这里在拿不到 connection、或者 requestRejection 自己抛错时放行 ——
4833
+ // 于是本机任意网页都能打到管理接口(包括一键安装)。现在判不了就拒。
4834
+ const 准入 = 建准入(ctx, diag)
4835
+ const 只认本人 = (handler) => 包准入(准入, handler, diag)
4836
+
4837
+ // ⭐ 网页服务是可选的:有 webServer 才挂设置页(跟 DSH 自己的写法一样,ctx.inject 按服务在不在挂/卸)。
4838
+ // 没有网页服务时(无界面 profile)记忆照常记、照常想起来,只是没有设置页 —— 而不是整个插件起不来。
4839
+ // 宿主没有 ctx.inject(老宿主 / 测试替身)就照旧直接挂。
4827
4840
  const mroute = (path, handler) =>
4841
+ typeof ctx.inject === 'function'
4842
+ ? ctx.inject(['webServer'], (c) => 挂路由(c, path, handler))
4843
+ : 挂路由(ctx, path, handler)
4844
+ const 挂路由 = (ctx, path, handler) =>
4828
4845
  ctx.effect(() =>
4829
4846
  ctx.webServer.register({
4830
4847
  kind: 'exact',
@@ -4847,12 +4864,11 @@ export function apply(ctx) {
4847
4864
  packageRoot: join(dirname(fileURLToPath(import.meta.url)), '..'),
4848
4865
  diag,
4849
4866
  })
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
- })
4867
+ // ⭐ 1.5.7(issue #35):
4868
+ // ① `/install`(一键更新)以前**根本没注册** —— 设置页那个按钮点了只会 404;
4869
+ // ② 而 `selfupdate.register()` 里的两个路由又不带准入。
4870
+ // 现在统一走它,并且把准入一起传进去:两个路由都受管,按钮也真的能用。
4871
+ 自查更新.register(ctx, '/dsh-fish-memory', 准入)
4856
4872
 
4857
4873
  mroute('/dsh-fish-memory/page', (req, res) => {
4858
4874
  res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' })
@@ -7231,6 +7247,7 @@ export {
7231
7247
  * ⚠ 只给测试用,不要在生产代码里调;里面都是纯函数或内部状态读取。
7232
7248
  */
7233
7249
  export const __测试 = {
7250
+ 后台排队,
7234
7251
  工作区目录名,
7235
7252
  扫会话目录,
7236
7253
  会话的工作区,
@@ -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.7",
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
+ }