dsh-memory-tiers 0.3.0 → 0.5.0

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/ARCHITECTURE.md CHANGED
@@ -834,10 +834,24 @@ dsh 有两套注入接口,语义完全不同:
834
834
 
835
835
  系统提示词因此变成逐字节不变 → 首轮之后全量命中缓存。
836
836
 
837
- **② L1 不再注入(`injectHot: false`)**
837
+ **② L1 默认不注入(`injectHot: false`)—— 但这条理由后来被更正了**
838
838
 
839
- L1 存的是当前会话的对话原文,模型在历史里已经看过。把它再塞进注入块,
840
- 唯一效果就是让注入块每轮都变、缓存每轮都断。跨会话恢复靠 L2/L3(沉淀过的)+ `memory_search`(按需检索)。
839
+ > **⚠️ 2026-11-01 更正**:这里原先写的是「L1 每轮都变 → 注入块每轮变化 → **打掉缓存**」。
840
+ > 那个结论只在 `section()` 时代成立。改用 `context()` 之后,记忆块是**追加**的 user 快照,
841
+ > 位置在末尾,**前面历史缓存完全不受影响**。用户当场指出了这点,是对的。
842
+ >
843
+ > 真实成本只剩下「每轮多追加一条快照」,而 L1 通常只有几百到一千多字 —— 很小。
844
+ > 所以 `injectHot` 该不该开,取决于下面的**配套关系**,不是缓存问题。
845
+
846
+ `injectHot` 和 `compactKeepTurns` 必须配套:
847
+
848
+ | 组合 | 模型能看到最近对话吗 | 说明 |
849
+ |---|---|---|
850
+ | `keepTurns=1` + `injectHot=false` | 能(靠 dsh 历史) | 默认。快照稳定、不重复付费;代价是历史留 1 轮原文 |
851
+ | **`keepTurns=0` + `injectHot=true`** | 能(**只能靠 L1 快照**) | 彻底接管:dsh 历史一条不留,最近原话由 L1 提供 |
852
+ | `keepTurns=0` + `injectHot=false` | **不能,完全失忆** | 禁用组合 |
853
+
854
+ 跨会话恢复始终靠 L2/L3(沉淀过的)+ `memory_search`(按需检索)。
841
855
 
842
856
  **③ 节流(`refreshMinMs`,默认 5 分钟)**
843
857
 
@@ -1384,3 +1398,216 @@ link 安装的插件 **import 不到** dsh 自己的包,三个解法:
1384
1398
  **`memory5.mjs` 一行都不用改**——它只依赖 `{ text, speaker, channel, at }` 这种纯数据。
1385
1399
  `compaction.mjs` 只依赖 `session.surface.nodes` / `eventAt` / `append` 三个方法,
1386
1400
  适配层写个小 shim 就能复用。
1401
+
1402
+ ---
1403
+
1404
+ ## 20. 设置页:DSH「设置 → 记忆」(2026-11-01,v0.4.0)
1405
+
1406
+ > 之前只能在 `~/.dsh/storages/memory-v5/config.json` 里手改,改错一个逗号插件就起不来。
1407
+ > 现在有了图形界面,12 个字段分 5 组,外加只读运行状态。
1408
+
1409
+ ### 为什么手写界面,不用 schemastery
1410
+
1411
+ dsh 的自动表单要 `@deepseek-ai/schemastery` 声明 schema,**link 安装的插件 import 不到**
1412
+ (同 `defineTool` / `CompactionEngine` 那个坑,见 19.5)。所以走「自己开 HTTP 接口 + 自己写 React 组件」:
1413
+
1414
+ ```
1415
+ package.json
1416
+ "dsh": { "client": { "platform": "web", "inject": [] } }
1417
+ "exports": { "./client": "./client.mjs" }
1418
+
1419
+ client.mjs 浏览器端:React section
1420
+ settings.mjs 服务端:/dsh-memory/api/*
1421
+ ```
1422
+
1423
+ ### 客户端:三个关键点
1424
+
1425
+ ```js
1426
+ window.__ModuleLoader__.load({
1427
+ id: 'dsh-memory-tiers',
1428
+ factory: (require) => {
1429
+ const React = require('react') // 宿主提供的 react
1430
+ const { createElement: h } = React
1431
+ // ...组件...
1432
+ exports.apply = apply
1433
+ return module.exports
1434
+ },
1435
+ })
1436
+
1437
+ const inject = ['slots']
1438
+ function apply(ctx) {
1439
+ ctx.effect(() => ctx.slots.inject('settings.section', () =>
1440
+ ctx.slots.register(
1441
+ { name: 'settings.section', id: 'memory-tiers', order: 7, label: () => '记忆' },
1442
+ MemoryTiersSection,
1443
+ )))
1444
+ }
1445
+ ```
1446
+
1447
+ - 模块不是标准 ESM,是 **`window.__ModuleLoader__.load({id, factory})`**(照抄 dsh-bili-whale)
1448
+ - `require('react')` 由宿主注入,**不需要打包工具**(手写 47KB 也能跑,没有 devDependencies)
1449
+ - CSS 通过 `<style data-dmt=…>` 注入,颜色用 `--dsw-alias-*` 变量,深浅色主题自适应
1450
+
1451
+ `order: 7` 排在养鱼(6)后面。`label()` 是函数,支持后续改文案不用重载。
1452
+
1453
+ ### 服务端:一个路径只能注册一个 handler
1454
+
1455
+ ```js
1456
+ ctx.webServer.register({ kind: 'exact', path: '/dsh-memory/api/settings', handler })
1457
+ ```
1458
+
1459
+ > ⚠️ **踩过的坑**:读和写注册成两个 handler(同一路径两次),宿主「首个匹配即终止」,
1460
+ > 结果 GET 200 而 POST 405。必须**合成一个 handler,内部按 `req.method` 分派**。
1461
+ > 这个坑 dsh-bili-whale 的源码注释里提过,我们还是踩了一遍。
1462
+
1463
+ 字段写入走**白名单**(`WRITABLE` 表),前端传 `dir`、`evil` 都会被忽略——
1464
+ 配置目录不能让前端改,否则等于开了任意路径写文件。
1465
+
1466
+ ### 界面结构
1467
+
1468
+ | 分组 | 字段 |
1469
+ |---|---|
1470
+ | 运行状态(只读) | L1/L2/L3/DL3 条数、向量数、数据目录、语义检索开关 |
1471
+ | 模型连接 | `apiKey`(密码框)、`apiBase` |
1472
+ | 三层容量 | `memoryCfg.hotMaxChars` / `consolidateMaxChars` / `longMaxChars` |
1473
+ | 上下文注入 | `injectHot`、`refreshMinMs` |
1474
+ | 历史压缩 | `autoCompact`、`compactKeepTurns`、`compactArchiveChars` |
1475
+ | 写入捕获 | `userCaptureChars`、`toolCaptureChars` |
1476
+ | 底部 | `capture.log` 最后 15 行(每轮的归档与压缩记录) |
1477
+
1478
+ ### 改配置需要重启
1479
+
1480
+ 保存只写 `config.json`,运行中的 `mem` 实例不会重建。面板上明确写了「重启 dsh 后生效」。
1481
+
1482
+ 要做热重载的话,得在保存接口里 dispose 旧 `mem` 再 `createMemory5` 一遍,
1483
+ 但内存里的分层状态、队列、定时器都要跟着重建,风险大于收益——**现阶段就要求重启**。
1484
+
1485
+ ### 验证
1486
+
1487
+ ```
1488
+ 注册的路由: /dsh-memory/api/settings:exact, /dsh-memory/api/log:exact
1489
+
1490
+ GET → HTTP 200, config: apiKey/bootMaxChars/injectHot/memoryCfg
1491
+ live: {counts:{L1:0,L2:0,L3:0,DL1:0,DL2:0,DL3:0}, vectors:0, semantic:true}
1492
+ POST → HTTP 200, applied: ["apiKey","injectHot","memoryCfg.hotMaxChars","memoryCfg.longMaxChars"]
1493
+ ignored: [] needRestart: true
1494
+ 落盘正确;传 dir / evil 被白名单挡住
1495
+
1496
+ 客户端: 模块加载 → 导出 apply/inject/MemoryTiersSection/FIELDS
1497
+ 12 个字段 / 5 个分组
1498
+ slots.register({name:'settings.section', id:'memory-tiers', order:7, label:'记忆'})
1499
+ ```
1500
+
1501
+ ### 21:00 补充:`compactKeepTurns: 0` 是受支持的显式选项(2026-11-01)
1502
+
1503
+ 用户要的是「dsh 历史一条不留,全部交给记忆」。实测**能做**:
1504
+
1505
+ ```
1506
+ 第 1 轮压缩: removed=2 | surface 剩 2 条
1507
+ 第 2 轮压缩: removed=3 | surface 剩 2 条
1508
+ 第 3 轮压缩: removed=3 | surface 剩 2 条
1509
+ 第 4 轮压缩: removed=3 | surface 剩 2 条
1510
+
1511
+ 最终 surface:
1512
+ system/message: sys
1513
+ user/message: (此前 1 轮对话已归档到长期记忆,需要细节可以让我检索)
1514
+ ```
1515
+
1516
+ 聊多少轮,dsh 侧都只剩 **system + 一条占位**。
1517
+
1518
+ **以前这段是靠巧合工作的**:`userIdx[length - 0]` 得到 `undefined`,`slice(first, undefined)`
1519
+ 恰好等于「取到末尾」。虽然结果对,但纯属运气——已改成显式分支:
1520
+
1521
+ ```js
1522
+ const keepStart = keepTurns === 0 ? nodes.length : userIdx[userIdx.length - keepTurns]
1523
+ ```
1524
+
1525
+ **必须同时开 `injectHot: true`**,否则模型完全看不见上一句问了什么(见第 16 节的配套表)。
1526
+
1527
+ 注入块对照(真实库):
1528
+
1529
+ | 配置 | 注入块 | 构成 |
1530
+ |---|---|---|
1531
+ | `injectHot: false` | 4625 字 ≈ 3083 token | L3 4150 + L2 458 |
1532
+ | `injectHot: true` | 6088 字 ≈ 4059 token | L3 4150 + L2 458 + L1 1455 |
1533
+
1534
+ 每轮多约 1000 token(追加新快照,全价)——相对于「dsh 历史归零」这点代价可以接受。
1535
+
1536
+ 配置改动落在 `~/.dsh/storages/memory-v5/config.json`:
1537
+
1538
+ ```json
1539
+ { "injectHot": true, "compactKeepTurns": 0 }
1540
+ ```
1541
+
1542
+ ---
1543
+
1544
+ ## 21. 模型分阶段配置(2026-11-01,v0.5.0)
1545
+
1546
+ > 背景:原来四个模型角色(向量 / 重排 / 轻任务 / 摘要)**共用一套 `apiKey` + `apiBase`**,
1547
+ > 设置页也没暴露,等于绑死在硅基流动上。现在每个阶段都能单独指定 `{ base, apiKey, model }`。
1548
+
1549
+ ### 四个阶段
1550
+
1551
+ | 阶段 | 干什么 | 调用频率 | 默认模型 |
1552
+ |---|---|---|---|
1553
+ | `embed` | 向量化,检索和入库判重的基础 | 每条记忆 1 次 | `Qwen/Qwen3-Embedding-0.6B` |
1554
+ | `rerank` | 检索结果精排 | 每次检索 | `Qwen/Qwen3-Reranker-0.6B` |
1555
+ | `chat` | 判重、判矛盾、分类等高频小任务 | 很频繁 | `inclusionAI/Ling-mini-2.0` |
1556
+ | `summary` | 把一批对话压成摘要 | 低频但重 | `deepseek-ai/DeepSeek-V4.1-Flash` |
1557
+
1558
+ ### 解析规则
1559
+
1560
+ ```js
1561
+ function stage(name) {
1562
+ const over = cfg.models[name] || {}
1563
+ const base = over.base || cfg.base // 没覆盖就用顶层
1564
+ const apiKey = over.apiKey || cfg.apiKey
1565
+ const model = over.model || cfg[name + 'Model'] // 再退到默认模型名
1566
+ return { base, apiKey, model, chat: base + '/chat/completions', embed: …, rerank: … }
1567
+ }
1568
+ ```
1569
+
1570
+ **优先级:`models[stage].x` > 顶层 `cfg.x` > 该阶段默认模型名。**
1571
+ 空字符串是「删除这条覆盖、回落到顶层」,不是「设成空值」——这样前端不用区分“没填过”和“清空了”。
1572
+
1573
+ 配置示例(摘要单独走 DeepSeek 官方,其余留硅基流动):
1574
+
1575
+ ```json
1576
+ {
1577
+ "apiKey": "sk-siliconflow",
1578
+ "apiBase": "https://api.siliconflow.cn/v1",
1579
+ "models": {
1580
+ "summary": {
1581
+ "base": "https://api.deepseek.com",
1582
+ "apiKey": "sk-deepseek",
1583
+ "model": "deepseek-chat"
1584
+ }
1585
+ }
1586
+ }
1587
+ ```
1588
+
1589
+ ### 实现要点
1590
+
1591
+ - `chat()` 多了一个 `stage` 参数,内部用该阶段的 url 和 key 发请求——**摘要能挂到另一家**
1592
+ - `embed` / `rerank` 各自调 `stage('embed')` / `stage('rerank')`,不再直接用全局 `cfg.apiKey`
1593
+ - key 检查放宽:`cfg.apiKey || 任一 models[s].apiKey`,否则只配了摘要一家时会被误判为"没配"
1594
+ - `silicon.resolveStage(name)` 和 `silicon.stages()` 暴露给设置页,显示**实际生效**的值(覆盖已合并)
1595
+
1596
+ ### 验证
1597
+
1598
+ ```
1599
+ 顶层硅基流动 + summary 单独挂 deepseek + embed 换 bge-m3:
1600
+ embed {"base":"…siliconflow.cn/v1","model":"BAAI/bge-m3","hasKey":true}
1601
+ rerank {"base":"…siliconflow.cn/v1","model":"Qwen/Qwen3-Reranker-0.6B","hasKey":true}
1602
+ chat {"base":"…siliconflow.cn/v1","model":"inclusionAI/Ling-mini-2.0","hasKey":true}
1603
+ summary {"base":"https://api.deepseek.com","model":"deepseek-chat","hasKey":true}
1604
+
1605
+ 设置页写入 → applied: ["models.summary.base","models.summary.apiKey","models.summary.model"]
1606
+ 清空 base → 该键被删除,回落顶层(不是留一个空字符串)
1607
+ 全空 → 抛「缺少 apiKey(顶层或某个阶段的 models[s].apiKey)」
1608
+ ```
1609
+
1610
+ ### 一个提醒
1611
+
1612
+ 换 `embed` 模型会导致**向量维度变化**,旧库的向量全部作废。真要换得清空 `vectors-part-*.json` 并重建,
1613
+ 否则检索结果会乱。设置页的提示里写了这条。
package/client.mjs ADDED
@@ -0,0 +1,425 @@
1
+ /**
2
+ * dsh-memory-tiers —— 客户端半边:DSH「设置」里的 **记忆** section。
3
+ *
4
+ * 宿主提供的运行时:window.__ModuleLoader__.load({ id, factory }) + require('react')。
5
+ * 挂载点:ctx.slots.register('settings.section', { id, order, label }, Component)。
6
+ * 数据来源:本插件服务端的 /dsh-memory/api/*(settings.mjs)。
7
+ *
8
+ * 为什么不用 schemastery 声明 schema 让 dsh 自动生成表单:
9
+ * link 安装的插件 import 不到 @deepseek-ai/*(见 ARCHITECTURE 19.5),拿不到那个包。
10
+ * 手写反而能把「只读运行状态」「三层容量对照」「重启提示」排在一起。
11
+ */
12
+ window.__ModuleLoader__.load({
13
+ id: 'dsh-memory-tiers',
14
+ factory: (require) => {
15
+ const module = { exports: {} }
16
+ const exports = module.exports
17
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
18
+
19
+ const React = require('react')
20
+ const { createElement: h, useCallback, useEffect, useState } = React
21
+
22
+ const API = '/dsh-memory/api'
23
+ const CSS_TAG = 'dsh-memory-tiers/section.css'
24
+ const CSS = [
25
+ '.dmt{max-width:760px;color:var(--dsw-alias-label-primary);display:flex;flex-direction:column;gap:6px}',
26
+ '.dmt-title{margin:0;font-size:16px;font-weight:500;line-height:24px}',
27
+ '.dmt-intro{color:var(--dsw-alias-label-tertiary);margin:0 0 4px;font-size:13px;line-height:20px}',
28
+ '.dmt-group{margin-top:16px;font-size:13px;font-weight:500;line-height:20px}',
29
+ '.dmt-rows{display:flex;flex-direction:column}',
30
+ '.dmt-row{display:flex;justify-content:space-between;align-items:flex-start;gap:12px 20px;padding:10px 0;border-bottom:.5px solid var(--dsw-alias-border-l2);flex-wrap:wrap}',
31
+ '.dmt-row:last-child{border-bottom:none}',
32
+ '.dmt-row>div:first-child{flex:1;min-width:190px}',
33
+ '.dmt-label{font-size:13px;line-height:19px}',
34
+ '.dmt-hint{color:var(--dsw-alias-label-tertiary);margin-top:3px;font-size:11.5px;line-height:17px}',
35
+ '.dmt-input,.dmt-select{background:transparent;border:1px solid var(--dsw-alias-border-l2);border-radius:6px;color:var(--dsw-alias-label-primary);font-size:12.5px;height:28px;padding:0 8px;min-width:180px;outline:none}',
36
+ '.dmt-input:focus-visible{border-color:var(--dsw-alias-brand-primary)}',
37
+ '.dmt-input.dmt-wide{min-width:280px}',
38
+ '.dmt-check{width:16px;height:16px;accent-color:var(--dsw-alias-brand-primary)}',
39
+ '.dmt-actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:16px;padding-top:12px;border-top:.5px solid var(--dsw-alias-border-l2)}',
40
+ '.dmt-btn{height:28px;padding:0 14px;border-radius:6px;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary);font-size:12.5px;cursor:pointer}',
41
+ '.dmt-btn:hover{border-color:var(--dsw-alias-border-l1)}',
42
+ '.dmt-btn.dmt-primary{background:var(--dsw-alias-brand-primary);border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-primary-foreground,#fff)}',
43
+ '.dmt-btn:disabled{opacity:.5;cursor:default}',
44
+ '.dmt-msg{font-size:12.5px;line-height:18px}',
45
+ '.dmt-msg.dmt-ok{color:var(--dsw-alias-label-success,#3b6d11)}',
46
+ '.dmt-msg.dmt-err{color:var(--dsw-alias-label-danger,#a32d2d)}',
47
+ '.dmt-stats{display:flex;gap:18px;flex-wrap:wrap;margin-top:8px}',
48
+ '.dmt-stat{min-width:92px}',
49
+ '.dmt-stat b{display:block;font-size:15px;font-weight:500;line-height:22px}',
50
+ '.dmt-stat span{color:var(--dsw-alias-label-tertiary);font-size:11.5px}',
51
+ '.dmt-log{margin-top:8px;padding:8px 10px;border-radius:6px;background:var(--dsw-alias-bg-secondary,rgba(127,127,127,.06));font-family:var(--dsw-font-mono,ui-monospace,monospace);font-size:11px;line-height:16px;white-space:pre-wrap;word-break:break-all;max-height:200px;overflow:auto}',
52
+ '.dmt-mono{font-family:var(--dsw-font-mono,ui-monospace,monospace);font-size:11.5px;word-break:break-all}',
53
+ ].join('\n')
54
+
55
+ function ensureCss() {
56
+ if (typeof document === 'undefined') return
57
+ if (document.querySelector('style[data-dmt="' + CSS_TAG + '"]')) return
58
+ const el = document.createElement('style')
59
+ el.dataset.dmt = CSS_TAG
60
+ el.textContent = CSS
61
+ document.head.appendChild(el)
62
+ }
63
+
64
+ const getPath = (obj, path) => path.reduce((o, k) => (o == null ? undefined : o[k]), obj)
65
+ const setPath = (obj, path, value) => {
66
+ const next = { ...obj }
67
+ let cur = next
68
+ for (let i = 0; i < path.length - 1; i++) {
69
+ const k = path[i]
70
+ cur[k] = { ...(cur[k] || {}) }
71
+ cur = cur[k]
72
+ }
73
+ cur[path[path.length - 1]] = value
74
+ return next
75
+ }
76
+
77
+ // ── 字段定义:改这里就能增减配置项(服务端白名单见 settings.mjs 的 WRITABLE) ──
78
+ const FIELDS = [
79
+ {
80
+ group: '模型连接',
81
+ path: ['apiKey'],
82
+ label: 'API Key',
83
+ type: 'password',
84
+ wide: true,
85
+ hint: '硅基流动的 key。留空则用本地哈希向量——能跑,但语义检索会弱很多。改完需重启。',
86
+ },
87
+ {
88
+ group: '模型连接',
89
+ path: ['apiBase'],
90
+ label: 'API 地址',
91
+ type: 'text',
92
+ wide: true,
93
+ hint: '默认 https://api.siliconflow.cn/v1。换别家兼容 OpenAI 接口的服务时改这里。',
94
+ },
95
+
96
+ {
97
+ group: '模型(每个阶段可分开指定,留空走默认)',
98
+ path: ['models', 'summary', 'model'],
99
+ label: '摘要模型',
100
+ type: 'text',
101
+ wide: true,
102
+ hint: '把一批对话压成摘要用哪个。留空 = deepseek-ai/DeepSeek-V4.1-Flash。摘要量最大,想单独走一家便宜的就填这里,并配上下面两项地址和 key。',
103
+ },
104
+ {
105
+ group: '模型(每个阶段可分开指定,留空走默认)',
106
+ path: ['models', 'summary', 'base'],
107
+ label: '摘要 API 地址',
108
+ type: 'text',
109
+ wide: true,
110
+ hint: '留空 = 用上面「模型连接」里的 API 地址。例:https://api.deepseek.com',
111
+ },
112
+ {
113
+ group: '模型(每个阶段可分开指定,留空走默认)',
114
+ path: ['models', 'summary', 'apiKey'],
115
+ label: '摘要 API Key',
116
+ type: 'password',
117
+ wide: true,
118
+ hint: '留空 = 用上面的 API Key。填了就表示摘要单独走另一家。',
119
+ },
120
+ {
121
+ group: '模型(每个阶段可分开指定,留空走默认)',
122
+ path: ['models', 'chat', 'model'],
123
+ label: '轻任务模型',
124
+ type: 'text',
125
+ wide: true,
126
+ hint: '判重、判矛盾、分类这些高频小任务,选快而便宜的。留空 = inclusionAI/Ling-mini-2.0。',
127
+ },
128
+ {
129
+ group: '模型(每个阶段可分开指定,留空走默认)',
130
+ path: ['models', 'embed', 'model'],
131
+ label: '向量模型',
132
+ type: 'text',
133
+ wide: true,
134
+ hint: '检索和入库判重都靠它。留空 = Qwen/Qwen3-Embedding-0.6B。换模型后向量维度不同,旧库要重建。',
135
+ },
136
+ {
137
+ group: '模型(每个阶段可分开指定,留空走默认)',
138
+ path: ['models', 'rerank', 'model'],
139
+ label: '重排模型',
140
+ type: 'text',
141
+ wide: true,
142
+ hint: '检索结果精排。留空 = Qwen/Qwen3-Reranker-0.6B。',
143
+ },
144
+
145
+ {
146
+ group: '三层容量(决定每轮注入多少字)',
147
+ path: ['memoryCfg', 'hotMaxChars'],
148
+ label: 'L1 即时层',
149
+ type: 'number',
150
+ hint: '按时间保存的原文。满了取最老一半分流沉淀。默认 2000。',
151
+ },
152
+ {
153
+ group: '三层容量(决定每轮注入多少字)',
154
+ path: ['memoryCfg', 'consolidateMaxChars'],
155
+ label: 'L2 巩固层',
156
+ type: 'number',
157
+ hint: '较重要的原话。默认 2500。',
158
+ },
159
+ {
160
+ group: '三层容量(决定每轮注入多少字)',
161
+ path: ['memoryCfg', 'longMaxChars'],
162
+ label: 'L3 长期层',
163
+ type: 'number',
164
+ hint: '摘要。这个值直接决定每轮注入块的大小,默认 2000,调大更全但也更贵。',
165
+ },
166
+
167
+ {
168
+ group: '上下文注入',
169
+ path: ['injectHot'],
170
+ label: '把 L1 也注入上下文',
171
+ type: 'boolean',
172
+ hint: '默认关。当前会话的对话本来就在历史里,重复注入会让注入块每轮变化、打掉 prompt cache。',
173
+ },
174
+ {
175
+ group: '上下文注入',
176
+ path: ['refreshMinMs'],
177
+ label: '记忆块刷新间隔(毫秒)',
178
+ type: 'number',
179
+ hint: '默认 1800000(30 分钟)。内容变了也要等过节流窗口才刷新,保护缓存。',
180
+ },
181
+
182
+ {
183
+ group: '历史压缩',
184
+ path: ['autoCompact'],
185
+ label: '自动压缩历史',
186
+ type: 'boolean',
187
+ hint: '每轮结束把宿主的会话历史压成一条占位,原文归档进记忆,不调用模型。',
188
+ },
189
+ {
190
+ group: '历史压缩',
191
+ path: ['compactKeepTurns'],
192
+ label: '保留最近几轮原文',
193
+ type: 'number',
194
+ hint: '默认 1。设 0 会让模型完全看不见上一句问了什么,不建议。',
195
+ },
196
+ {
197
+ group: '历史压缩',
198
+ path: ['compactArchiveChars'],
199
+ label: '单次归档字数上限',
200
+ type: 'number',
201
+ hint: '默认 1200。超出的部分截断收录,不会整段丢。',
202
+ },
203
+
204
+ {
205
+ group: '写入捕获',
206
+ path: ['userCaptureChars'],
207
+ label: '单条用户消息上限',
208
+ type: 'number',
209
+ hint: '默认 2000 字。超长消息只记前这么多。',
210
+ },
211
+ {
212
+ group: '写入捕获',
213
+ path: ['toolCaptureChars'],
214
+ label: '工具输出上限',
215
+ type: 'number',
216
+ hint: '默认 500 字。bash 一次能吐几万字,L1 才 2000 字。',
217
+ },
218
+ ]
219
+
220
+ const GROUPS = []
221
+ for (const f of FIELDS) if (!GROUPS.includes(f.group)) GROUPS.push(f.group)
222
+
223
+ async function api(path, init) {
224
+ const res = await fetch(API + path, {
225
+ credentials: 'same-origin',
226
+ headers: { 'content-type': 'application/json' },
227
+ ...init,
228
+ })
229
+ const text = await res.text()
230
+ let data
231
+ try { data = JSON.parse(text) } catch { data = { ok: false, error: text.slice(0, 200) } }
232
+ if (!res.ok || data.ok === false) throw new Error(data.error || 'HTTP ' + res.status)
233
+ return data
234
+ }
235
+
236
+ function Field({ field, value, onChange }) {
237
+ const kind = field.type
238
+ const id = 'dmt-' + field.path.join('-')
239
+ let control
240
+ if (kind === 'boolean') {
241
+ control = h('input', {
242
+ id,
243
+ className: 'dmt-check',
244
+ type: 'checkbox',
245
+ checked: value === true,
246
+ onChange: (e) => onChange(e.target.checked),
247
+ })
248
+ } else {
249
+ control = h('input', {
250
+ id,
251
+ className: 'dmt-input' + (field.wide ? ' dmt-wide' : ''),
252
+ type: kind === 'number' ? 'number' : kind === 'password' ? 'password' : 'text',
253
+ value: value === undefined || value === null ? '' : String(value),
254
+ placeholder: kind === 'password' ? '留空则用本地哈希向量' : '',
255
+ onChange: (e) => onChange(kind === 'number' ? (e.target.value === '' ? '' : Number(e.target.value)) : e.target.value),
256
+ })
257
+ }
258
+ return h('div', { className: 'dmt-row' },
259
+ h('div', null,
260
+ h('label', { className: 'dmt-label', htmlFor: id }, field.label),
261
+ field.hint ? h('div', { className: 'dmt-hint' }, field.hint) : null,
262
+ ),
263
+ control,
264
+ )
265
+ }
266
+
267
+ function MemoryTiersSection() {
268
+ const [cfg, setCfg] = useState(null)
269
+ const [live, setLive] = useState(null)
270
+ const [logs, setLogs] = useState([])
271
+ const [busy, setBusy] = useState(false)
272
+ const [msg, setMsg] = useState(null)
273
+ const [err, setErr] = useState(null)
274
+
275
+ const load = useCallback(async () => {
276
+ try {
277
+ const d = await api('/settings')
278
+ setCfg(d.config || {})
279
+ setLive(d.live || null)
280
+ const l = await api('/log').catch(() => ({ lines: [] }))
281
+ setLogs(l.lines || [])
282
+ setErr(null)
283
+ } catch (e) {
284
+ setErr(String(e.message || e))
285
+ }
286
+ }, [])
287
+
288
+ useEffect(() => { ensureCss(); load() }, [load])
289
+
290
+ const setField = (path, value) => {
291
+ setCfg((cur) => setPath(cur || {}, path, value))
292
+ setMsg(null)
293
+ }
294
+
295
+ const save = async () => {
296
+ setBusy(true)
297
+ setMsg(null)
298
+ setErr(null)
299
+ try {
300
+ const r = await api('/settings', { method: 'POST', body: JSON.stringify({ config: cfg }) })
301
+ const applied = r.applied?.length ?? 0
302
+ setMsg(applied > 0
303
+ ? '已保存 ' + applied + ' 项。重启 dsh 后生效。'
304
+ : '没有可保存的改动。')
305
+ await load()
306
+ } catch (e) {
307
+ setErr('保存失败:' + String(e.message || e))
308
+ } finally {
309
+ setBusy(false)
310
+ }
311
+ }
312
+
313
+ if (err && !cfg) {
314
+ return h('div', { className: 'dmt' },
315
+ h('h3', { className: 'dmt-title' }, '记忆'),
316
+ h('div', { className: 'dmt-msg dmt-err' }, '读取配置失败:' + err),
317
+ h('div', { className: 'dmt-actions' }, h('button', { className: 'dmt-btn', onClick: load }, '重试')),
318
+ )
319
+ }
320
+
321
+ if (!cfg) return h('div', { className: 'dmt' }, h('div', { className: 'dmt-hint' }, '加载中…'))
322
+
323
+ const c = live?.counts || {}
324
+
325
+ return h('div', { className: 'dmt' },
326
+ h('h3', { className: 'dmt-title' }, '记忆'),
327
+ h('p', { className: 'dmt-intro' },
328
+ '六层记忆 + 零 LLM 历史压缩。同一会话里记忆负责「记得住」,压缩负责「上下文不涨」。改完需要重启 dsh 生效。'),
329
+
330
+ // ── 运行状态(只读) ──
331
+ h('div', { className: 'dmt-group' }, '运行状态'),
332
+ h('div', { className: 'dmt-stats' },
333
+ h('div', { className: 'dmt-stat' }, h('b', null, String(c.L1 ?? '-')), h('span', null, 'L1 即时')),
334
+ h('div', { className: 'dmt-stat' }, h('b', null, String(c.L2 ?? '-')), h('span', null, 'L2 巩固')),
335
+ h('div', { className: 'dmt-stat' }, h('b', null, String(c.L3 ?? '-')), h('span', null, 'L3 长期')),
336
+ h('div', { className: 'dmt-stat' }, h('b', null, String(c.DL3 ?? '-')), h('span', null, 'DL3 摘要库')),
337
+ h('div', { className: 'dmt-stat' }, h('b', null, String(live?.vectors ?? '-')), h('span', null, '向量')),
338
+ ),
339
+ h('div', { className: 'dmt-row' },
340
+ h('div', null,
341
+ h('div', { className: 'dmt-label' }, '数据目录'),
342
+ h('div', { className: 'dmt-hint' }, '整个目录是自包含的,拷走即可备份或迁移;用环境变量 MEMORY_TIERS_DIR 可换位置。'),
343
+ ),
344
+ h('div', { className: 'dmt-mono' }, live?.dir || '-'),
345
+ ),
346
+ h('div', { className: 'dmt-row' },
347
+ h('div', null,
348
+ h('div', { className: 'dmt-label' }, '语义检索'),
349
+ h('div', { className: 'dmt-hint' }, live?.semantic ? '已接真模型' : '未配置 key,当前用本地哈希向量,关键词匹配为主'),
350
+ ),
351
+ h('div', { className: 'dmt-msg ' + (live?.semantic ? 'dmt-ok' : '') }, live?.semantic ? '开' : '关'),
352
+ ),
353
+ live?.models
354
+ ? h('div', { className: 'dmt-row' },
355
+ h('div', null,
356
+ h('div', { className: 'dmt-label' }, '实际生效的模型'),
357
+ h('div', { className: 'dmt-hint' }, '留空的项已回落到顶层配置或默认值。改完需重启才会重新解析。'),
358
+ ),
359
+ h('div', {
360
+ className: 'dmt-mono',
361
+ style: { whiteSpace: 'pre-line', textAlign: 'right' },
362
+ }, Object.entries(live.models)
363
+ .map(([k, v]) => k + ': ' + (v.model || '?') + ' @ ' + String(v.base || '').replace(/^https?:\/\//, ''))
364
+ .join('\n')),
365
+ )
366
+ : null,
367
+
368
+ // ── 配置字段 ──
369
+ ...GROUPS.map((g) =>
370
+ h('div', { key: g },
371
+ h('div', { className: 'dmt-group' }, g),
372
+ h('div', { className: 'dmt-rows' },
373
+ FIELDS.filter((f) => f.group === g).map((f) =>
374
+ h(Field, {
375
+ key: f.path.join('.'),
376
+ field: f,
377
+ value: getPath(cfg, f.path),
378
+ onChange: (v) => setField(f.path, v),
379
+ }),
380
+ ),
381
+ ),
382
+ ),
383
+ ),
384
+
385
+ // ── 操作 ──
386
+ h('div', { className: 'dmt-actions' },
387
+ h('button', { className: 'dmt-btn dmt-primary', onClick: save, disabled: busy }, busy ? '保存中…' : '保存'),
388
+ h('button', { className: 'dmt-btn', onClick: load, disabled: busy }, '重新读取'),
389
+ msg ? h('span', { className: 'dmt-msg dmt-ok' }, msg) : null,
390
+ err ? h('span', { className: 'dmt-msg dmt-err' }, err) : null,
391
+ ),
392
+
393
+ // ── 最近审计 ──
394
+ h('div', { className: 'dmt-group' }, '最近归档 / 压缩(capture.log 末尾)'),
395
+ logs.length
396
+ ? h('div', { className: 'dmt-log' }, logs.join('\n'))
397
+ : h('div', { className: 'dmt-hint' }, '还没有记录。聊几轮后这里会出现每轮的归档与压缩行。'),
398
+ )
399
+ }
400
+
401
+ const inject = ['slots']
402
+ const NS = 'memory-tiers'
403
+ const NAMES = { nav: '记忆' }
404
+
405
+ function apply(ctx) {
406
+ ctx.effect(
407
+ () =>
408
+ ctx.slots.inject('settings.section', () =>
409
+ ctx.slots.register(
410
+ { name: 'settings.section', id: NS, order: 7, label: () => NAMES.nav },
411
+ MemoryTiersSection,
412
+ ),
413
+ 'memory-tiers: settings section',
414
+ ),
415
+ 'memory-tiers: settings section',
416
+ )
417
+ }
418
+
419
+ exports.apply = apply
420
+ exports.inject = inject
421
+ exports.MemoryTiersSection = MemoryTiersSection
422
+ exports.FIELDS = FIELDS
423
+ return module.exports
424
+ },
425
+ })
package/compaction.mjs CHANGED
@@ -93,7 +93,11 @@ export async function compactSurface(session, mem, opts = {}) {
93
93
  }
94
94
 
95
95
  // 选区 = [first, keepStart),keepStart 是「保留的第一轮」的 user 位置
96
- const keepStart = userIdx[userIdx.length - keepTurns]
96
+ // ★ keepTurns === 0 是**受支持的显式选项**:整段历史全压成占位,dsh 侧一条原文不留。
97
+ // 以前靠 `userIdx[length - 0] === undefined` + `slice(first, undefined)` 恰好取到末尾
98
+ // 才"能用",纯属巧合;这里写死,免得以后重构踩空。
99
+ // ⚠️ 用 0 时必须同时开 injectHot(注入 L1),否则模型完全看不见刚才聊了什么。
100
+ const keepStart = keepTurns === 0 ? nodes.length : userIdx[userIdx.length - keepTurns]
97
101
  if (keepStart <= first) {
98
102
  return { compacted: false, removed: 0, archived: 0, reason: 'nothing-to-compact' }
99
103
  }
package/index.mjs CHANGED
@@ -20,6 +20,7 @@ import { join } from 'node:path'
20
20
  import { createMemory5, LAYER, DEFAULTS as MEM_DEFAULTS } from './memory5.mjs'
21
21
  import { createSilicon, keyFromAstrbot } from './silicon.mjs'
22
22
  import { compactSurface } from './compaction.mjs'
23
+ import { installSettingsApi } from './settings.mjs'
23
24
 
24
25
  export const name = 'memory-tiers'
25
26
  export const inject = ['tools']
@@ -211,6 +212,10 @@ export function apply(ctx, config = {}) {
211
212
  compactKeepTurns: 1,
212
213
  compactArchiveChars: 1200,
213
214
  memoryCfg: { longMaxChars: 4000 },
215
+ // 每个模型阶段可单独覆盖 { base, apiKey, model };留空继承顶层 apiKey/apiBase。
216
+ // 例:想让它摘要走 DeepSeek 官方而不走硅基流动
217
+ // models: { summary: { base: 'https://api.deepseek.com', apiKey: 'sk-...', model: 'deepseek-chat' } }
218
+ models: {},
214
219
  _说明: [
215
220
  'apiKey 填硅基流动的 key(也可以设环境变量 SILICONFLOW_API_KEY);',
216
221
  '留空则用本地哈希向量,语义检索会变弱。改完重启 dsh 生效。',
@@ -231,7 +236,13 @@ export function apply(ctx, config = {}) {
231
236
  let silicon = null
232
237
  if (auth) {
233
238
  try {
234
- silicon = createSilicon({ apiKey: auth.apiKey, base: auth.base, budgetTokens: cfg.budgetTokens })
239
+ silicon = createSilicon({
240
+ apiKey: auth.apiKey,
241
+ base: auth.base,
242
+ budgetTokens: cfg.budgetTokens,
243
+ // 每个阶段可单独指定 { base, apiKey, model };留空继承上面的 apiKey/base
244
+ models: cfg.models || {},
245
+ })
235
246
  } catch (err) {
236
247
  log.warn?.('[memory-tiers] silicon 初始化失败:%s', err?.message ?? err)
237
248
  }
@@ -549,6 +560,29 @@ export function apply(ctx, config = {}) {
549
560
  toolCount, !!silicon, cfg.autoCapture !== false)
550
561
  })
551
562
 
563
+ // ── 设置页的服务端接口:读写 config.json(前端见 client.mjs) ──
564
+ // webServer 可能比插件晚就绪,用 inject 等它,而不是直接读 ctx.webServer。
565
+ ctx.inject(['webServer'], (c) => {
566
+ const off = installSettingsApi(c, {
567
+ dir,
568
+ getCfg: () => cfg,
569
+ log,
570
+ mem,
571
+ getModels: () => {
572
+ if (!silicon?.resolveStage) return null
573
+ const out = {}
574
+ for (const st of silicon.stages()) out[st] = silicon.resolveStage(st)
575
+ return out
576
+ },
577
+ })
578
+ if (typeof off === 'function') {
579
+ try { c.effect(() => off, 'memory-tiers: settings api') } catch { /* 老版本没有 effect 也不影响接口可用 */ }
580
+ } else {
581
+ log.warn?.('[memory-tiers] webServer 不可用,设置页接口没挂上')
582
+ }
583
+ log.info?.('[memory-tiers] 设置页接口已挂:/dsh-memory/api/settings')
584
+ })
585
+
552
586
  // ── 斜杠命令:手动查看/维护入口 ──
553
587
  ctx.inject(['commands'], (c) => {
554
588
  c.commands.register({
package/package.json CHANGED
@@ -1,11 +1,17 @@
1
1
  {
2
2
  "name": "dsh-memory-tiers",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "六层记忆插件:L1/L2/L3 活跃层 + DL1/DL2/DL3 深层,自动捕获写入、摘要沉淀、按需召回,并接管历史压缩(零 LLM)。",
5
5
  "type": "module",
6
6
  "main": "index.mjs",
7
7
  "license": "MIT",
8
- "keywords": ["dsh", "dsh-plugin", "memory", "compaction", "context"],
8
+ "keywords": [
9
+ "dsh",
10
+ "dsh-plugin",
11
+ "memory",
12
+ "compaction",
13
+ "context"
14
+ ],
9
15
  "repository": {
10
16
  "type": "git",
11
17
  "url": "git+https://github.com/1129742588/dsh-memory-tiers.git"
@@ -17,10 +23,15 @@
17
23
  "dsh": {
18
24
  "bundle": {
19
25
  "patch": "./cordis.patch.yml"
26
+ },
27
+ "client": {
28
+ "inject": [],
29
+ "platform": "web"
20
30
  }
21
31
  },
22
32
  "exports": {
23
33
  ".": "./index.mjs",
34
+ "./client": "./client.mjs",
24
35
  "./compaction.mjs": "./compaction.mjs",
25
36
  "./memory5.mjs": "./memory5.mjs",
26
37
  "./cordis.patch.yml": "./cordis.patch.yml",
@@ -28,6 +39,8 @@
28
39
  },
29
40
  "files": [
30
41
  "index.mjs",
42
+ "client.mjs",
43
+ "settings.mjs",
31
44
  "compaction.mjs",
32
45
  "memory5.mjs",
33
46
  "silicon.mjs",
package/settings.mjs ADDED
@@ -0,0 +1,241 @@
1
+ /**
2
+ * 设置页的服务端半边:读写 config.json + 暴露运行状态。
3
+ *
4
+ * 为什么要自己写 HTTP 接口而不是用 schemastery 声明 schema:
5
+ * link 安装的插件 import 不到 @deepseek-ai/*,拿不到 dsh 的 schema 包(见 ARCHITECTURE 19.5)。
6
+ * 走 webServer + 自己的 React 组件反而更可控——字段说明、分组、只读状态都能自己排版。
7
+ */
8
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
9
+ import { join } from 'node:path'
10
+ import { isIP } from 'node:net'
11
+
12
+ /** 可写字段白名单:只管运行参数,dir 和 _说明 不让前端改 */
13
+ const WRITABLE = {
14
+ apiKey: 'string',
15
+ apiBase: 'string',
16
+ bootMaxChars: 'number',
17
+ injectHot: 'boolean',
18
+ refreshMinMs: 'number',
19
+ autoCompact: 'boolean',
20
+ compactKeepTurns: 'number',
21
+ compactArchiveChars: 'number',
22
+ userCaptureChars: 'number',
23
+ toolCaptureChars: 'number',
24
+ budgetTokens: 'number',
25
+ flushEveryMs: 'number',
26
+ sweepEveryMs: 'number',
27
+ }
28
+
29
+ /** 三层容量在 memoryCfg 里,单独处理 */
30
+ const MEMORY_CFG_WRITABLE = {
31
+ hotMaxChars: 'number',
32
+ consolidateMaxChars: 'number',
33
+ longMaxChars: 'number',
34
+ dl2MaxChars: 'number',
35
+ }
36
+
37
+ /** 每个模型阶段可单独覆盖的连接参数;留空字符串 = 清除覆盖、回落到顶层 */
38
+ const STAGES = ['embed', 'rerank', 'chat', 'summary']
39
+ const STAGE_FIELDS = { base: 'string', apiKey: 'string', model: 'string' }
40
+
41
+ function json(res, code, obj) {
42
+ const body = JSON.stringify(obj)
43
+ res.statusCode = code
44
+ res.setHeader('content-type', 'application/json; charset=utf-8')
45
+ res.setHeader('cache-control', 'no-store')
46
+ res.end(body)
47
+ }
48
+
49
+ function readBody(req) {
50
+ return new Promise((resolve, reject) => {
51
+ let buf = ''
52
+ req.on('data', (c) => {
53
+ buf += c
54
+ if (buf.length > 1 << 20) reject(new Error('body too large'))
55
+ })
56
+ req.on('end', () => resolve(buf))
57
+ req.on('error', reject)
58
+ })
59
+ }
60
+
61
+ /** 只信任本机回环;远程连接交给 dsh 的 connection 服务判定 */
62
+ function isTrustedReq(ctx, req) {
63
+ const addr = req.socket?.remoteAddress || ''
64
+ const bare = addr.replace(/^::ffff:/, '')
65
+ if (isIP(bare) && (bare === '127.0.0.1' || bare === '::1')) return true
66
+ try {
67
+ const conn = ctx.get?.('connection') || ctx.connection
68
+ if (conn && typeof conn.isTrusted === 'function') return conn.isTrusted(req) === true
69
+ } catch { /* 栅栏不可用按不信任处理 */ }
70
+ return false
71
+ }
72
+
73
+ function coerce(kind, value) {
74
+ if (kind === 'boolean') {
75
+ if (typeof value === 'boolean') return value
76
+ if (value === 'true') return true
77
+ if (value === 'false') return false
78
+ return undefined
79
+ }
80
+ if (kind === 'number') {
81
+ const n = Number(value)
82
+ return Number.isFinite(n) ? n : undefined
83
+ }
84
+ if (kind === 'string') return typeof value === 'string' ? value : undefined
85
+ return undefined
86
+ }
87
+
88
+ /**
89
+ * 挂上 /dsh-memory/api/*。
90
+ * @returns {() => void} disposer;webServer 不可用时返回 null
91
+ */
92
+ export function installSettingsApi(ctx, opts) {
93
+ const { dir, getCfg, log, mem, getModels } = opts
94
+ const webServer = ctx.webServer
95
+ if (!webServer?.register) return null
96
+
97
+ const cfgPath = join(dir, 'config.json')
98
+ const disposers = []
99
+
100
+ const readCfgFile = () => {
101
+ try {
102
+ if (!existsSync(cfgPath)) return {}
103
+ return JSON.parse(readFileSync(cfgPath, 'utf8'))
104
+ } catch (err) {
105
+ log?.warn?.('[memory-tiers] 读 config.json 失败:%s', err?.message ?? err)
106
+ return {}
107
+ }
108
+ }
109
+
110
+ function route(path, handler) {
111
+ const off = webServer.register({
112
+ kind: 'exact',
113
+ path,
114
+ handler: async (req, res) => {
115
+ if (!isTrustedReq(ctx, req)) { json(res, 403, { ok: false, error: '不受信任的访问来源' }); return }
116
+ try { await handler(req, res) } catch (err) {
117
+ log?.warn?.('[memory-tiers] 设置接口出错:%s', err?.message ?? err)
118
+ json(res, 500, { ok: false, error: String(err?.message ?? err) })
119
+ }
120
+ },
121
+ })
122
+ if (typeof off === 'function') disposers.push(off)
123
+ }
124
+
125
+ // ── settings:读和写必须**合成一个 handler** ──
126
+ // 踩过的坑:同一路径注册两次,若宿主「首个匹配即终止」,后注册的永远收不到请求。
127
+ // 实测表现是 GET 正常、POST 405(或反之),排查半天。
128
+ route('/dsh-memory/api/settings', async (req, res) => {
129
+ if (req.method === 'GET') {
130
+ const file = readCfgFile()
131
+ const live = getCfg() || {}
132
+ const stats = (() => {
133
+ try { return mem?.stats?.() ?? null } catch { return null }
134
+ })()
135
+ json(res, 200, {
136
+ ok: true,
137
+ config: {
138
+ ...file,
139
+ memoryCfg: { ...(file.memoryCfg || {}) },
140
+ },
141
+ live: {
142
+ dir,
143
+ counts: stats?.count ?? null,
144
+ vectors: stats?.vectors ?? null,
145
+ semantic: !!(live.apiKey || process.env.SILICONFLOW_API_KEY ||
146
+ Object.values(live.models || {}).some((m) => m?.apiKey)),
147
+ autoCompact: live.autoCompact !== false,
148
+ // 各阶段最终解析成什么(覆盖已合并),设置页拿来显示"实际生效"
149
+ models: (() => {
150
+ try { return getModels?.() ?? null } catch { return null }
151
+ })(),
152
+ },
153
+ configPath: cfgPath,
154
+ })
155
+ return
156
+ }
157
+
158
+ if (req.method !== 'POST') { json(res, 405, { ok: false, error: '只接受 GET / POST' }); return }
159
+
160
+ const raw = await readBody(req)
161
+ let body
162
+ try { body = JSON.parse(raw || '{}') } catch { json(res, 400, { ok: false, error: 'JSON 解析失败' }); return }
163
+
164
+ // 兼容 { config: {...} } 和直接 {...} 两种写法(踩过:只认一种会「保存成功但没变」)
165
+ const incoming = (body && typeof body.config === 'object' && body.config) || body || {}
166
+
167
+ const cur = readCfgFile()
168
+ const next = { ...cur }
169
+ const applied = []
170
+ const ignored = []
171
+
172
+ for (const [k, kind] of Object.entries(WRITABLE)) {
173
+ if (!(k in incoming)) continue
174
+ const v = coerce(kind, incoming[k])
175
+ if (v === undefined) { ignored.push(k); continue }
176
+ next[k] = v
177
+ applied.push(k)
178
+ }
179
+
180
+ if (incoming.memoryCfg && typeof incoming.memoryCfg === 'object') {
181
+ const mc = { ...(cur.memoryCfg || {}) }
182
+ for (const [k, kind] of Object.entries(MEMORY_CFG_WRITABLE)) {
183
+ if (!(k in incoming.memoryCfg)) continue
184
+ const v = coerce(kind, incoming.memoryCfg[k])
185
+ if (v === undefined) { ignored.push('memoryCfg.' + k); continue }
186
+ mc[k] = v
187
+ applied.push('memoryCfg.' + k)
188
+ }
189
+ next.memoryCfg = mc
190
+ }
191
+
192
+ // ── 每阶段模型覆盖 ──
193
+ // 语义:填了就覆盖,留空(空字符串)= 删掉这条覆盖,回落到顶层的 apiKey/apiBase 和默认模型名。
194
+ // 这样「全走一家」和「摘要单独挂别家」都能表达,不用区分"没填过"和"清空了"。
195
+ if (incoming.models && typeof incoming.models === 'object') {
196
+ const mm = { ...(cur.models || {}) }
197
+ for (const st of STAGES) {
198
+ const src = incoming.models[st]
199
+ if (!src || typeof src !== 'object') continue
200
+ const dst = { ...(mm[st] || {}) }
201
+ for (const [k, kind] of Object.entries(STAGE_FIELDS)) {
202
+ if (!(k in src)) continue
203
+ const v = coerce(kind, src[k])
204
+ if (v === undefined) { ignored.push(`models.${st}.${k}`); continue }
205
+ if (v === '') delete dst[k]
206
+ else dst[k] = v
207
+ applied.push(`models.${st}.${k}`)
208
+ }
209
+ if (Object.keys(dst).length) mm[st] = dst
210
+ else delete mm[st]
211
+ }
212
+ next.models = mm
213
+ }
214
+
215
+ try {
216
+ mkdirSync(dir, { recursive: true })
217
+ writeFileSync(cfgPath, JSON.stringify(next, null, 2), 'utf8')
218
+ } catch (err) {
219
+ json(res, 500, { ok: false, error: '写入失败:' + String(err?.message ?? err) })
220
+ return
221
+ }
222
+ log?.info?.('[memory-tiers] 设置已更新:%s 项,重启 dsh 生效', applied.length)
223
+ json(res, 200, { ok: true, applied, ignored, needRestart: true })
224
+ })
225
+
226
+ // ── 最近归档/压缩审计(设置页底部展示) ──
227
+ route('/dsh-memory/api/log', async (req, res) => {
228
+ if (req.method !== 'GET') { json(res, 405, { ok: false }); return }
229
+ let lines = []
230
+ try {
231
+ const p = join(dir, 'capture.log')
232
+ if (existsSync(p)) {
233
+ const all = readFileSync(p, 'utf8').trim().split('\n')
234
+ lines = all.slice(-15)
235
+ }
236
+ } catch { /* 读不到就当空 */ }
237
+ json(res, 200, { ok: true, lines })
238
+ })
239
+
240
+ return () => { for (const off of disposers) { try { off() } catch { /* 已卸 */ } } }
241
+ }
package/silicon.mjs CHANGED
@@ -319,7 +319,34 @@ const RESULT_PATTERNS = /(跑通|改完|部署|提交|补完|删掉|清理|扩
319
319
 
320
320
  export function createSilicon(options = {}) {
321
321
  const cfg = { ...DEFAULTS, ...options }
322
- if (!cfg.apiKey) throw new Error('[silicon] 缺少 apiKey')
322
+ // models 段允许每个阶段单独覆盖 { base, apiKey, model },留空则继承顶层的 base/apiKey
323
+ if (!cfg.models || typeof cfg.models !== 'object') cfg.models = {}
324
+
325
+ const STAGES = ['embed', 'rerank', 'chat', 'summary']
326
+
327
+ /**
328
+ * 解析某个阶段的最终连接参数。
329
+ * 优先级:models[stage].xxx > 顶层 cfg.xxx > 该阶段的默认模型名。
330
+ * 这样既能"全部走硅基流动",也能"摘要单独挂 DeepSeek 官方、向量走本地服务"。
331
+ */
332
+ function stage(name) {
333
+ const over = cfg.models[name] || {}
334
+ const base = String(over.base || cfg.base || '').replace(/\/+$/, '')
335
+ const apiKey = over.apiKey || cfg.apiKey || ''
336
+ const model = over.model || cfg[name + 'Model'] || ''
337
+ return {
338
+ base,
339
+ apiKey,
340
+ model,
341
+ chat: base + '/chat/completions',
342
+ embed: base + '/embeddings',
343
+ rerank: base + '/rerank',
344
+ }
345
+ }
346
+
347
+ // 只要任意一个阶段拿到了 key 就能跑;全部为空才认为没配(由调用方降级到本地哈希向量)
348
+ const anyKey = cfg.apiKey || STAGES.some((s) => cfg.models?.[s]?.apiKey)
349
+ if (!anyKey) throw new Error('[silicon] 缺少 apiKey(顶层或某个阶段的 models[s].apiKey)')
323
350
 
324
351
  const chatUrl = `${cfg.base.replace(/\/+$/, '')}/chat/completions`
325
352
  const embedUrl = `${cfg.base.replace(/\/+$/, '')}/embeddings`
@@ -404,21 +431,26 @@ export function createSilicon(options = {}) {
404
431
  const {
405
432
  maxTokens = 400,
406
433
  temperature = 0,
407
- model = cfg.chatModel,
434
+ stage: stageName = 'chat',
435
+ model,
408
436
  timeout = cfg.timeoutMs,
409
437
  thinking = false,
410
438
  } = opts
411
439
 
440
+ const st = stage(stageName)
441
+ const useModel = model || st.model
442
+
412
443
  const body = {
413
- model,
444
+ model: useModel,
414
445
  messages: [{ role: 'system', content: system }, { role: 'user', content: user }],
415
446
  max_tokens: maxTokens,
416
447
  temperature,
417
448
  }
418
449
  if (thinking === false) body.enable_thinking = false
419
450
 
420
- const d = await post(chatUrl, cfg.apiKey, body, timeout, cfg.retries, cfg.maxConcurrent, cfg.rateLimitRetries, cfg.rateLimitBaseMs, guard)
421
- note('chat', d.usage?.total_tokens, model)
451
+ // ★ 用该阶段自己的 url / key:摘要可以挂到另一家 provider 上
452
+ const d = await post(st.chat, st.apiKey, body, timeout, cfg.retries, cfg.maxConcurrent, cfg.rateLimitRetries, cfg.rateLimitBaseMs, guard)
453
+ note(stageName, d.usage?.total_tokens, useModel)
422
454
  return (d.choices?.[0]?.message?.content || '').trim()
423
455
  }
424
456
 
@@ -429,7 +461,8 @@ export function createSilicon(options = {}) {
429
461
  const list = (Array.isArray(texts) ? texts : [texts]).map((t) => String(t || '').trim()).filter(Boolean)
430
462
  if (!list.length) return null
431
463
  try {
432
- const d = await post(embedUrl, cfg.apiKey, { model: cfg.embedModel, input: list }, cfg.timeoutMs, cfg.retries, cfg.maxConcurrent, cfg.rateLimitRetries, cfg.rateLimitBaseMs, guard)
464
+ const es = stage('embed')
465
+ const d = await post(es.embed, es.apiKey, { model: es.model, input: list }, cfg.timeoutMs, cfg.retries, cfg.maxConcurrent, cfg.rateLimitRetries, cfg.rateLimitBaseMs, guard)
433
466
  note('embed', d.usage?.total_tokens || d.meta?.tokens?.input_tokens)
434
467
  const vecs = (d.data || []).map((x) => x.embedding)
435
468
  return vecs.length === list.length ? vecs : null
@@ -443,9 +476,10 @@ export function createSilicon(options = {}) {
443
476
  const list = (docs || []).map((t) => String(t || ''))
444
477
  if (!list.length) return null
445
478
  try {
446
- const body = { model: cfg.rerankModel, query: String(query || ''), documents: list }
479
+ const rs = stage('rerank')
480
+ const body = { model: rs.model, query: String(query || ''), documents: list }
447
481
  if (topN > 0) body.top_n = Math.min(topN, list.length)
448
- const d = await post(rerankUrl, cfg.apiKey, body, cfg.timeoutMs, cfg.retries, cfg.maxConcurrent, cfg.rateLimitRetries, cfg.rateLimitBaseMs, guard)
482
+ const d = await post(rs.rerank, rs.apiKey, body, cfg.timeoutMs, cfg.retries, cfg.maxConcurrent, cfg.rateLimitRetries, cfg.rateLimitBaseMs, guard)
449
483
  note('rerank', d.meta?.tokens?.input_tokens)
450
484
  return (d.results || []).map((x) => ({ index: x.index, score: Number(x.relevance_score) }))
451
485
  } catch {
@@ -524,7 +558,7 @@ export function createSilicon(options = {}) {
524
558
  // 再留 250 给"多写一行"的余量。上限 4000 是模型的安全区。
525
559
  maxTokens: Math.min(250 + 10 * list.length, 4000),
526
560
  temperature: 0.2,
527
- model: cfg.summaryModel,
561
+ stage: 'summary',
528
562
  timeout: cfg.summaryTimeoutMs,
529
563
  // ★ 必须显式关掉推理。
530
564
  // 这里原来传的是 null,而 chat() 里只处理 `thinking === false`,
@@ -656,7 +690,7 @@ export function createSilicon(options = {}) {
656
690
  const out = await chat(sys, user, {
657
691
  maxTokens: Math.min(300 + 12 * list.length, 4000),
658
692
  temperature: 0.2,
659
- model: cfg.summaryModel,
693
+ stage: 'summary',
660
694
  timeout: cfg.summaryTimeoutMs,
661
695
  thinking: false,
662
696
  })
@@ -869,15 +903,31 @@ export function createSilicon(options = {}) {
869
903
  ms: Date.now() - t0,
870
904
  dim: v[0].length,
871
905
  models: {
872
- embed: cfg.embedModel,
873
- rerank: cfg.rerankModel,
874
- chat: cfg.chatModel,
875
- summary: cfg.summaryModel,
906
+ // 显示**实际生效**的值(阶段覆盖已合并),而不是原始配置
907
+ embed: stage('embed').model,
908
+ rerank: stage('rerank').model,
909
+ chat: stage('chat').model,
910
+ summary: stage('summary').model,
911
+ },
912
+ endpoints: {
913
+ embed: stage('embed').base,
914
+ rerank: stage('rerank').base,
915
+ chat: stage('chat').base,
916
+ summary: stage('summary').base,
876
917
  },
877
918
  }
878
919
  }
879
920
 
880
- return { embed, rerank, summarize, summarizeWithSources, classify, classifyBatch, sameEvent, relation, ping, cos, config: cfg, usage, resetUsage, budget }
921
+ return {
922
+ embed, rerank, summarize, summarizeWithSources, classify, classifyBatch, sameEvent, relation, ping, cos,
923
+ config: cfg, usage, resetUsage, budget,
924
+ /** 给设置页/诊断用:某阶段最终解析成什么 */
925
+ resolveStage: (name) => {
926
+ const st = stage(name)
927
+ return { base: st.base, model: st.model, hasKey: !!st.apiKey }
928
+ },
929
+ stages: () => STAGES,
930
+ }
881
931
  }
882
932
 
883
933
  /** 从 AstrBot 的 cmd_config.json 里取 siliconflow 的 key,省去手抄 */