@nightpainters/dsh-ling 1.5.2 → 1.5.3
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/ACCESS-DESIGN.md +4 -3
- package/CHANGELOG.md +75 -3
- package/DESIGN.md +27 -10
- package/README.md +17 -7
- package/lib/audit/access-log.js +46 -12
- package/lib/audit/index.js +2 -2
- package/lib/audit/patch-spec.js +1 -1
- package/lib/client.js +284 -34
- package/lib/host/api.js +588 -53
- package/lib/host/backfill.js +8 -2
- package/lib/host/feedback.js +112 -2
- package/lib/host/freeze.js +52 -1
- package/lib/host/guard.js +38 -1
- package/lib/host/habit-gen.js +12 -4
- package/lib/host/import-file.js +62 -6
- package/lib/host/import-log.js +80 -2
- package/lib/host/lifecycle.js +13 -2
- package/lib/host/memory.js +164 -15
- package/lib/host/mode-service.js +85 -0
- package/lib/host/mode.js +29 -9
- package/lib/host/retitle.js +6 -0
- package/lib/host/session-line.js +46 -6
- package/lib/host/summarizer.js +158 -2
- package/lib/host/surgery.js +600 -0
- package/lib/host/tools.js +12 -1
- package/lib/host/tree-ops.js +1 -1
- package/lib/index.js +17 -29
- package/package.json +2 -2
- package/tools/apply-access-log-patch.mjs +1 -1
package/ACCESS-DESIGN.md
CHANGED
|
@@ -51,7 +51,8 @@
|
|
|
51
51
|
|
|
52
52
|
## 3. 导入流水线与工程约束
|
|
53
53
|
|
|
54
|
-
- **分页提交**:`/api` 读 body 上限
|
|
54
|
+
- **分页提交**:`/api` 读 body 上限 **4 MiB**(服务端 `readBody`;超限时**先把请求体排空到 `end`、再在健康连接上回 `413 {ok:false, reason:'too-large', limit, got}`** —— 不再 `req.destroy()`,否则那句"请拆分后重试"永远送不出去)→ 前端流式读文件(JSONL 逐行),**按累计字节切批:≤200 条 且 ≤3 MiB/批**(留 1 MiB 余量给数组封套与 JSON 转义;**单条自身超限时单独成批**,保证批批前进、不死循环);
|
|
55
|
+
- ⚠️ **2026-09-29 修订(原契约作废)**:旧文写的是「每批 ≤200 会话」(**条数**单一判据),而服务端封顶是**字节**判据,两端互不知情 ⇒ 186 条 / 4.89 MB 被打成**整批一发**,连接被 reset,前端错报「批失败(**网关**)」(真因是同一进程内的体量上限,不是网络;**该缺陷 2026-09-29 修**:超限时不再一刀砍连接,改为**先把请求体排空到 `end`、再在健康连接上回 `413 {ok:false, reason:'too-large', limit, got}`** ⇒ 那句"请拆分后重试"真的有通路,前端不再误报成"网关")。条数说法是 2026-09-08 从当时的实现抄进本文的,现改为**条数 × 字节双限**并写明超限行为;
|
|
55
56
|
- **端点语义**(`/import/batch` 或扩展现有 /import):逐批校验 → 返回统计对象 → UI 确认后落库(每批事务);
|
|
56
57
|
字段口径(与实现一致):`ok` 本批是否正常跑完 · `accepted` 通过校验、进入落库流程的条数 · `newRows` 新写入的行 · `refreshed` 同 id 已存在、只更新标题/时间/类别/关键词的行 · `upgraded` 原本只有概述、本次补上原文的行 · `folded` 命中网页端旧域、元数据与原文并入旧域的行 · `removedImport` 随折叠一并清掉的本域残留副本 · `degraded` + `degradedReasons` 校验通过但降级收下的条数,以及按原因聚合的计数 · `rejected` 未通过校验的条目(`{i, reason}`,不落库,交给 UI 报"异常");
|
|
57
58
|
- **幂等键**:`source="import:<平台>"` 命名空间 + 本地 id;轻量档不写空 summary 覆盖用户已有内容;
|
|
@@ -93,7 +94,7 @@
|
|
|
93
94
|
|
|
94
95
|
## 8. 实施状态
|
|
95
96
|
|
|
96
|
-
- **P0 已实施(2026-09-08)**:记忆中心「接入历史」页签(通道 A 一键扫描 + 通道 B JSON/JSONL 契约导入,200/批);host 模块 `lib/host/backfill.js`(tools/backfill-dsh.mjs 已改薄壳复用)与 `lib/host/import-file.js`;接口 `POST /dsh/backfill`、`POST /import/file/batch`;
|
|
97
|
+
- **P0 已实施(2026-09-08)**:记忆中心「接入历史」页签(通道 A 一键扫描 + 通道 B JSON/JSONL 契约导入,200/批)(⚠️ **2026-09-29 起批大小改为"条数 × 字节"双限,见 §3 首条 —— 本行的「200/批」是当时的实现记录,不再是对外契约**);host 模块 `lib/host/backfill.js`(tools/backfill-dsh.mjs 已改薄壳复用)与 `lib/host/import-file.js`;接口 `POST /dsh/backfill`、`POST /import/file/batch`;
|
|
97
98
|
- **库迁移**:conv_overview.source 曾 CHECK 限 dsweb/dsh → schema v2 放开(自动整表重建,数据无损,真机副本演练通过);`upsertOverview` 不再强制改写来源;
|
|
98
99
|
- **来源泛化**:记忆浏览筛选/概览统计/L1 溯源标签新增"文件导入(import)";
|
|
99
100
|
- 单测:`tests/importfile.test.mjs`(两档/幂等/降级/坏行/auto-id/来源过滤)全绿;全套回归绿。
|
|
@@ -107,7 +108,7 @@
|
|
|
107
108
|
- **深摘语义**:一次跑完所有未读会话(含快速通过的短会话),进度「已完成 x / 本轮共 y · z%」;按钮「让她读未读过的会话」;全部读完时直接亮出自述按钮(非失败态);
|
|
108
109
|
- 语音语气建议等错误 toast 补齐 `error` 字段兜底。
|
|
109
110
|
- **第 3 波(2026-09-11)**
|
|
110
|
-
- **导入记录(留账)**:三条入口(文件导入 / 本机 DSH 扫描 / 网页端库扫描)每次成功都落一条账,`lib/host/import-log.js` 存 kv `import.log.<开始时刻>`(最近 50 条自动修剪),`GET /import/history` 倒序返回,「接入历史」页签底部卡片 D 展示;**一次导入 =
|
|
111
|
+
- **导入记录(留账)**:三条入口(文件导入 / 本机 DSH 扫描 / 网页端库扫描)每次成功都落一条账,`lib/host/import-log.js` 存 kv `import.log.<开始时刻>`(最近 50 条自动修剪),`GET /import/history` 倒序返回,「接入历史」页签底部卡片 D 展示;**一次导入 = 一条账**:传输层仍按批发送(2026-09-29 起批大小按**累计字节**切,不再固定 200 条,见 §3 首条),各批带同一 `runId` 由 host 累加(批数/失败批数一并入账),`at` 固定为导入开始时刻;失败批经 `POST /import/log` 上报;
|
|
111
112
|
- **语气 P1**:`POST /persona/tone-advice`(只读,读两侧记忆归纳语气建议)+ `POST /persona/grow`(成长通道:语气枚举替换 + 风格只追加),归"自性类"、定型锁下免手术。
|
|
112
113
|
- **P1 待办**:DeepSeek 网页端数据获取指引;聊天记录库直接导入 UI 化;帮助文案。
|
|
113
114
|
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,78 @@
|
|
|
2
2
|
|
|
3
3
|
本项目遵循语义化版本:`MAJOR.MINOR.PATCH`。
|
|
4
4
|
|
|
5
|
+
## 1.5.3
|
|
6
|
+
|
|
7
|
+
**跨插件挡位接口(对外开放 `dsh-ling/mode` 服务)· 导入批体量上限(超限不再砍连接,改回 413)**
|
|
8
|
+
|
|
9
|
+
> 一句话:本版两件事 —— ① 把"设挡位"这件事从**只能由 GUI 发起**变成**可以被别的插件调用** —— 器灵注册一个轻量 Cordis 服务 `dsh-ling/mode`,暴露 `applyMode(sessionId, modeKey)`。调用方**无法**连带改掉全局默认模式,**也无法**连带改掉会话的模型。首个消费者是飞书桥(`dsh-feishu-host`):它在**新建一条线**时给新会话钉上 `life` 挡位;② 修掉"一千多条的历史一次导入,一条都没进去、界面还显示**批失败(网关)**"的那道体量闸门(两端判据**单位不同** + 超限时**砍连接**导致错误文案没有通路)。
|
|
10
|
+
|
|
11
|
+
> ✅ **本节即 1.5.3 的发布内容**;`package.json` 版本号为 **`1.5.3`**(开发自 2026-09-29 夜起,发布前已去掉开发期后缀;1.5.2 已双发)。
|
|
12
|
+
> ✅ **②的真机状态(2026-09-29 夜 · 重启后已验)**:**修前**留档 = 宿主未重启时(PID 6260)`3 MB → HTTP 200` / `5 MB → ECONNRESET`;**修后**重启(PID 4344,跑新代码)实测 **`3 MB → HTTP 200` / `5 MB → HTTP 413 {"ok":false,"reason":"too-large","message":"单次请求体过大(超过 4MB 上限),请拆分后重试","limit":4194304,"got":5242921}`** —— `got` 与客户端量到的长度逐字节相等。
|
|
13
|
+
> ✅ **浏览器那条链也验了(2026-09-29 夜 · 主人在真界面点了一次)**:一份**合成的**"单条 5.0 MiB"文件走「记忆中心 → 接入历史 → 确认导入」,界面如实报出「**另有 1 条会话单条太长、没能通过导入通道(已跳过)**」「校验通过 0 · 拒绝 0 · **单条过大跳过 1**」并**点名**该条 ⇒ **不再出现「批失败(网关)」**;其中 `4 MB` 取自服务端回执 `limit`(**客户端没有镜像这个常量**)。
|
|
14
|
+
> ⚠️ **当时仍未做(当日已补,见「二」里"被跳过的'单条过大'进持久账本"一条)**:被跳过的条目**不进持久「导入记录」**(当时账本只有 `errors` 一个字段);另外**单条 >4 MiB 的文件仍然进不来** —— 现在至少说得清是为什么。
|
|
15
|
+
|
|
16
|
+
### 一、新服务 `dsh-ling/mode`
|
|
17
|
+
|
|
18
|
+
- 注册走 **`ctx.provide(MODE_SERVICE_NAME, api)`** —— **不是** `class extends Service`:本包以 `file:` + Junction 装载,**包内没有 `node_modules`**(`Test-Path node_modules` = `False`),`import '@deepseek-ai/cordis'` 必 `ERR_MODULE_NOT_FOUND`(尽管声明了该 peer);`ctx.provide()` 在同一套 reflect 机制内,**零 import**;
|
|
19
|
+
- 暴露两个方法:`applyMode(sessionId, modeKey, opts)` → `{ ok, queued?, mode, reasoningEffort?, warning? }`;`currentMode(sessionId)` → `'work' | 'life'`;
|
|
20
|
+
- **内部固定 `setDefault: false`** —— 服务只改**指定会话**的挡位,**绝不**写 `settings.mode.lastMode`。GUI 那条老路仍走 `applyModeToSession(...)`(内部即 `{ setDefault: true, ...opts }`),**行为逐字不变**;
|
|
21
|
+
- **`keepModel` 可选**:`currentModelSelection(ctx)` 读的是**全局**当前模型,跨插件场景下会把调用方自己的模型盖掉 ⇒ 调用方可传 `keepModel: { provider, model }` 原样带回;
|
|
22
|
+
- **`syncEffort: false`** 可选:只记模式、不动会话推理等级(连 `selectModel` 都不调);
|
|
23
|
+
- **注册失败不外抛**:只写一行 `console.debug` 并返回 no-op disposer —— 器灵装不上不该拖垮宿主。
|
|
24
|
+
|
|
25
|
+
### 二、导入批体量上限:超限不再砍连接,改「排空 → 413」
|
|
26
|
+
|
|
27
|
+
> 根因(两端判据单位不同、且互相不知情):客户端按**条数**切批(`batch = 200`),服务端按**字节**封顶(4 MiB),而 `readBody` 超限时是 `reject` **与** `req.destroy()` **同一拍** ⇒ **那句现成的"请拆分后重试"永远送不出去**,浏览器只看到 `ECONNRESET`,前端把它归成「批失败(**网关**)」—— 一个确定性、可自愈的失败,在界面上长得像网络故障。186 条 / 4.89 MB 的真实导出就是这么**一条都没入库**的。
|
|
28
|
+
|
|
29
|
+
- **服务端 · 排空再回 413**:`readBody` 超限后**不砍连接** —— ① 立刻停止缓存(**已缓存的那几 MiB 也丢掉**,不抱着它等到 `end`);② 继续消费 `data`、只累加计数;③ 等 `'end'` 到达、连接健康时再 reject 一个 `code='BODY_TOO_LARGE'` / `status=413` 的错误;`guard` 里**一处**出口把它翻成 `413 {ok:false, reason:'too-large', message, limit, got}`(49 处调用点零改动)。`message` **逐字沿用旧文案**;
|
|
30
|
+
- ⚠️ **边界(独立红队实测,别读成全称命题)**:**只有 `4 MiB < 体量 ≤ 64 MiB` 才保证拿到 413**。超过 64 MiB 撞硬熔断 ⇒ 仍是 `ECONNRESET`(与修前同症状);`Content-Length` 撒谎一类的**协议违规**客户端也拿不到 413(浏览器不会这么发)。实测边界严格翻转:`4 MiB` 整 → 200、`4 MiB + 1 B` → 413;
|
|
31
|
+
- **排空换来的两个新代价,各配一道闸**:`BODY_DRAIN_MAX_BYTES = 64 MiB`(硬熔断 —— 给一份真实导出留够"把 body 发完"的余量,余量不足则排空本身会变成**新的** reset,白改一场)+ `BODY_DRAIN_IDLE_MS = 30 s`(静默超时,替代原来那条防 DoS 动机:只要还有数据在流就算正常,**一旦静止就是僵尸连接**;滑动窗口,每收到一个 chunk 重置);计时器在 `'end'`/`'error'`/熔断**三条出口**都 `clearTimeout`;
|
|
32
|
+
- ⚠️ **这道防线的形状边界(红队实测,本批未关)**:静默超时只覆盖"**已经在发、中途静默**"这一形状;「**只发头、不发体**」的请求在**应用层没有计时器**(计时器只在 `req.on('data')` 里武装),兜底只剩 Node 自己的 `requestTimeout`(默认 300 s)。⚠️ **这不是回归**(修前同样没有),但**别对外写成"防僵尸"的全称命题**;
|
|
33
|
+
- **客户端 · 按字节切批**:`batch = 200` → **≤200 条 且 ≤3 MiB/批**(留 1 MiB 余量给数组封套与转义);新增手写 `utf8Len`(CJK 3 字节、emoji 代理对 4 字节——**不许用 `s.length` 冒充**)、`takeChunk`(**前进性硬保证**:单条自身超限也单独成批,绝不切出空批 ⇒ 不会死循环);进度文案改双口径(`12/186 条 · 已送 4.2 MB`);拒绝条目序号改以**本批起始下标**为准(`idx - batch` 随切批判据变更同步修正);`reason === 'too-large'` 映射成人话,且上限数字**取自服务端回执的 `limit`**(不在这边再写死一个);
|
|
34
|
+
- **契约注释(④)**:`api.js` 那句「每批 ≤500」**与实现自相矛盾**(500 条要装进 4 MiB,需每条 ≤8 KB,而带正文的会话实测 ≈26 KB/条),已改写为**条数 × 字节双限**,并写明**两端耦合** —— 任何一方单独改上限都会让口径重新错位,**这正是本缺陷的成因**;`ACCESS-DESIGN.md` 的「分页提交」契约同批对齐(历史记录只加"已作废"指针,**不改写历史**)。
|
|
35
|
+
- **客户端 · 单条超限不再含糊(C-1;由独立红队实测后补)**:单条会话 ≥ 4,194,210 B 时服务端必回 413,旧行为是**该条被永久丢弃**、只在界面上记一句「批错误 1」,而文案「请把导出文件拆小」对**单条会话物理上不可执行**。现在:413 **且本批只有 1 条** ⇒ **点名**(`title` → `name` → 「未命名」三级退路)+ 报出**真实字节**(客户端复算,不用服务端被截断的 `got`)+ 说清「这条太长、拆文件也拆不开它、已跳过、其余照常导入」;计入独立的 `tooBig`(**不混进 `errors`**)、**不中断**后续批;`finish()` 有跳过时**不再说「✓ 导入完成」**。判据只用**服务端回执**+ 本批条数(**客户端不镜像服务端的 4 MiB 常量**,将来放宽上限不用改这里)。⚠️ **当时**仍未做:被跳过的条目**不进持久「导入记录」**(账本只有 `errors` 一个字段)—— **当日已补,见下一条**。
|
|
36
|
+
- **被跳过的"单条过大"进持久账本**:`POST /import/log` 现在接受 `{ tooBig, tooBigNames }` 并**允许只报跳过、让账本的 `errors` 保持 0**(这是命门:否则"已知跳过"会被记成"失败批");账本行在「失败 X 批」之后追加 **`· 单条过大 N`**;**`tooBig = 0` 时落账形状逐字不变** —— 包括老客户端**不传 `errors`** 的那些请求,行为与修前完全一致。护栏:`tooBig` 夹 `0..10000`、名字**只留前 5 条 / 每条 40 字 / 非字符串丢弃**,同一 runId 合并时**再归一一次**(防多批把账本撑大);清洗实现**只有一份**,端点复用。✅ **真机已验(宿主重启后)**:一次真实导入落账为 `"tooBig":1,"tooBigNames":["未命名"],"errors":0`(只读查 `kv` 得到)—— 命门成立:**已知跳过没有被混成"失败批"**。
|
|
37
|
+
- **首字节闸(「只发头、不发体」;非回归,顺手关掉的洞)**:原先那套静默计时器**只在收到第一个 chunk 时才武装** ⇒ 这条形状在**应用层没有任何计时器**,兜底只剩 Node 自己的 `requestTimeout`(默认 300 s)。现在 `readBody` **进函数即武装**一次首字节闸、收到第一个 chunk **立刻解除**(参数复用 `drainIdleMs`,默认 30 s);错误形状是**独立的** `code='BODY_IDLE_NO_FIRST_BYTE'` / `status=408` / `got=0` / **不带 `limit`**(它与体量无关)。真 socket 实测:头齐 + `Content-Length: 4194304` + **0 字节** ⇒ **61 ms 断开**(旧实现:零响应一直挂着)。
|
|
38
|
+
- ⚠️ **别读成"客户端会读到一句解释"**:首字节闸的错误**不走 413 出口**,真机上落到"往将死的 socket 写一句、客户端收不到"那条路 —— **它买到的是"连接 61 ms 就断",不是"错误文案可达"**。
|
|
39
|
+
- ✅ **真机已验(宿主重启后 PID 12972)**:头齐 + `Content-Length: 4194304` + **0 字节体** ⇒ 客户端实测 **`ECONNRESET` @ 30,011 ms**(= 30 s 闸门到点);修前是挂到 Node 自己的 `requestTimeout`(**300 s**)。
|
|
40
|
+
- **记忆包导入的 413 说人话(只改文案)**:`⬆ 导入记忆包…` 是**整份一发**(`POST /import`)—— UI 默认包实测 **1.386 MiB**(安全),但 `includeRaw=1` 的**带原文包 5.959 MiB** 会撞 4 MiB。原先 413 时**吐一段原始 JSON**、连接被熔断时只说「导入失败(网关不可达)」。现在说清:①**整包**超上限、**不是包坏了**;②上限**只用回执 `limit` 换算**(回执没给就直说"上限未随回执返回",**不猜数**);③**怎么办** —— 点「⬇ 导出记忆包」重导一份**默认不含原文**的包再导入,并如实说明"整包一次发不完"是当前通道的结构限制。其余失败优先用服务端 `message`;`sealed` 分支**逐字未动**。**分批上传不做**(留 1.6)。
|
|
41
|
+
|
|
42
|
+
- **三处方法闸补齐(`POST_ONLY` **51 → 54**;后随手术门新增 `/surgery/ticket`,本版合计 **55**)**:只读审计盘出"未进 `POST_ONLY` 的差集 26 条"里,**唯一一条真写**是 `GET /branch-log/pending`(它在"水位键缺失 + `branch_log` 非空"时会**一次性补种水位** ⇒ GET 能把「复盘守门」的欠账计数顶成 0),另有一条**成本型** `GET /persona/draft` —— **GET 就能花一次全局模型额度**(它对"库"只读为真,对"额度"为假;注释已改准)。现在这三条(`/branch-log/pending` · `/persona/draft` · `/persona/check`)一律只收 POST:非 POST ⇒ **405 `method-not-allowed`**(且**方法闸先于 `readBody`**)。前端调用点**全仓核过**:`client.js` 三处默认 GET → 显式 POST,另两条**本来就是 POST**(零回归),`tests/` 与 `dsh-feishu-host` 下 **0 处**。⚠️ **host 侧改动 ⇒ 需一次宿主重启才在真机生效**。
|
|
43
|
+
- **折叠不再覆盖已有原文(数据安全)**:导入时若某会话在**网页端旧库(旧域)**里已存在,会走"**折叠**"(元数据与原文并进去)。原实现把**导入侧的 `seq`** 直接写进那个会话,而底层是 `INSERT … ON CONFLICT(session_id, seq) DO UPDATE SET text=excluded.text` ⇒ **序号一重叠就把目标里的真原文静默换掉**(离线测试台实测 `clobbered=3`)。现在:**目标已有原文就不覆盖** —— 逐条按 `(role, text)` 比:相同 ⇒ 幂等跳过;不同 ⇒ **保留现有的**、只计新计数 `foldSkippedRaw`、**一个字节都不写**;**目标一个字都没有原文** ⇒ 照旧写入(升级路径不堵死)。⛔ 明确否决两条替代:按 `max(seq)` 往后追加(**同段对话在库里变两份、污染检索**)、整段替换(**丢真的方向**)。
|
|
44
|
+
- **"跳过了"必须说出来**:该计数贯通 **HTTP 回执 → 导入面板人话**(`ⓘ 另有 N 轮原文没有替换…不是失败,也没有丢东西`)→ **持久账本**(行末尾 `· 原文未替换 N`,且 `= 0` 时落账**逐字不变**)。
|
|
45
|
+
- ⚠️ **已知取舍**:同一会话**二次折叠不会再"补全"** —— 目标哪怕只有 1 轮原文,导入侧全轮也只进计数、不写库(宁可少副本、不换真原文)。
|
|
46
|
+
- **命名空间裂缝(同批修)**:折叠判断"有没有原文"原先**只看写入目标(dsweb 裸 id)**,没把 `import:<id>` 命名空间一起算 ⇒ 某些顺序下同一段对话会在库里**存两份(两个 session_id)**,而那正是折叠要消灭的"**检索里召回两次**"。现在判据扩成"**候选链任一侧有原文**"(复用既有 helper):任一侧有 ⇒ 不写、计入 `foldSkippedRaw`;两侧都空 ⇒ 照旧写入。**真机库只读盘查:两侧都有 = 0**(102 个 session:裸 id 87 / `import:` 侧 15)⇒ 无旧账要清,本次为**纯预防**。
|
|
47
|
+
- **反代伪装 loopback 的加固(守卫⑤)**:`guard.js` 的"Host 是 loopback 就免检"在**引入反向代理后失效** —— Host 由客户端自报,而 cookie 只验名字(算法公开)⇒ **公网匿名可拿到真实数据**(实测:`Host: 127.0.0.1:3080` + 自算 cookie ⇒ 200)。现在新增 `realPeerAddress(headers)`:取 `X-Forwarded-For` 的**最后一段**(nginx 的 `$proxy_add_x_forwarded_for` 追加、客户端删不掉;**取首段可被预塞绕过,注释里已写明不许"优化"成首段**),回退 `X-Real-IP`;凡"Host 声称 loopback 却带着非 loopback 的代理对端" ⇒ **`spoofed-loopback-host` 拒绝**。本机直连(无代理头)与经 nginx 的正常远程访问**都不受影响**(后者根本不走 loopback 分支)。对照实验可自行复核:`tests\guard.test.mjs` §8 收录 4 合法 + 6 攻击 + 边界(**双向**:临时禁用判据 ⇒ 6 条 ✗)。
|
|
48
|
+
- **人格手术门**:`"手术门不是挡主人的,是万一网络失守时最后失守的位置"`(主人原话)。新增 `lib\host\surgery.js`,把门**下沉为一处** `requireSurgery()`,覆盖**全部 14 条**能改人格/规矩/习惯的写面;`sealed` 时依次判 **非本机**(带代理头 ⇒ `surgery-local-only`)· **每启动轮换的手术票据**(`/surgery/ticket` 只下发给"无代理头"的请求) · **原句匹配**;并**删掉 `wantLock` 绕过分支**(旧代码下"带 `sealed:true` 的补丁 + 假句"能直接改库,已实测证明)。另加**带哈希链的手术留痕**(`surgery-*.jsonl`,`prevLineHash` 串链,改一行即断)与**只读**事件面 `GET /surgery/events`。
|
|
49
|
+
- ✅ **真机复验(2026-10-01 下午)**:本机直连 `200` · **伪造代理头一律 `403 spoofed-loopback-host`**(守卫⑤ 真机生效) · 本机取票 `200`(64 位 hex) · **经代理取票 / 读事件面 / 打手术端点全部被拒** · `surgery.ticket` 与 `surgery-2026-10.jsonl` **均已落盘**。
|
|
50
|
+
- 🐞 **同批修掉一个"链永远串不上"的 bug**:`createSurgeryEvent` 原先**先算 `prevLineHash(opts.file)`、后解析 `opts.file || surgeryLogPath()`**,线上调用方不传 `opts.file` ⇒ `readFileSync(undefined)` 抛错被 `catch` 吞掉 ⇒ **每条留痕的 `prevLineHash` 恒为 `null`**(真机 5 条全是),`verifySurgeryChain` 因此**永远报 `brokenAt=2`**。已改成**先解析路径、再取上一条哈希**;新增回归测试 `tests\branch.test.mjs` **§7.8b**(走**默认路径**、不传 `opts.file`;**双向**:换回 buggy 行 ⇒ 两条断言 ✗ 且症状与真机逐字一致)。⚠️ **真机那 5 条历史留痕一个字不动**(审计留痕永不改写/重排)—— 它们会**一直**被报为断链,那是**历史事实**;"让校验能表达**含已知历史缺口**"的**链基线**见下一条。
|
|
51
|
+
- ✅ **承诺句按渠道分流(2026-10-01 下午 · 主人定调「这不是密码,而是庄重和郑重感」)**:`/state`(含面板档,共 5 个面)· `/persona/history` · `/export` —— **带代理头 ⇒ 不回承诺句**(只回 `hasSeal:true` + `phraseHidden:true`),**本机直连 ⇒ 照旧回显**(所以**永远不会因遗忘卡死**)。渠道判据**复用** `surgery.js` 的 `isLocalSurgeryRequest`(即 `realPeerAddress(headers) === undefined`,与守卫⑤、`surgery-local-only` **同源**),并有源码护栏钉住"1 处定义 + 恰好 3 个读面调用"。⚠️ **有意不做**:不改存哈希(哈希只在"能打端点、读不到文件"的人身上有增益,而远端已被掐死、本机同用户进程本就能读 `settings.json`;代价却是把"**宣言**"降级成"**密码**")、**不做**"本机+票据重置"(主人:「只用本机回显可以了,够的」)。
|
|
52
|
+
- ✅ **手术留痕的链基线(2026-10-01 下午)**:`verifySurgeryChain` 读 `surgery-chain-baseline.json` 的 `brokenRanges`,**区间内断链 ⇒ `ok:true` + `knownGaps`**(并继续校验后段、区间后重新接上),**区间外仍 `ok:false`**;⚠️ **只豁免"链"这一项** —— **区间内的自哈希照样核**(改过历史行仍会被抓)。内置默认区间 = 真机实况(`from 1, to 5`,理由:建链 bug,历史行不改写)。目的:别让"永远报错的告警"变成没人看的告警。
|
|
53
|
+
- ✅ **留痕去重(2026-10-01 晚)**:`/surgery/events` 的 `chain.knownGaps` 原来**按受影响的行各报一条**(真机 5 行 ⇒ 4 条一模一样的 `{from:1,to:5}`),长日志下会成倍膨胀。现在改成**每个区间一条**、`at` 记**首次命中行**;⚠️ **判据一字未动**(区间内断链仍 `ok:true`、**区间外仍 `ok:false`**、**区间内改一行仍被抓** —— 后两条用新写的负对照钉住)。真机复测:`{"ok":true,"count":5,"knownGaps":1}`。
|
|
54
|
+
- **免检通道:黑名单 → 正向白名单(E2)**:概述器与习惯生成器原先用 `session_id NOT LIKE 'import:%'` 这种**黑名单**排除导入原文 —— 黑名单把"**没见过的**"当自己人。现在收成**一处正向判据** `summarizer.js` 的 `dshSessionSql()`(= **裸 id 即 DSH 会话**,habit-gen 直接复用),凡**未知前缀**一律排除并写一条 kv 告警 `dbg.raw_unknown_namespace`(库干净时零写入,首见另打一条 warn)。**真机库实测:104 会话 / 89 个裸 id / 唯一带冒号的前缀是 `import:`(74 轮)⇒ 无未知来源**,且**真 DSH 会话的输出与修前逐字一致**(修前 golden 负对照)。⚠️ 边界:`dsh_turns_raw` **没有 `source` 列** ⇒ 将来若出现**不带冒号**的新来源名,物理上分不出来(判据是"裸 id = DSH 会话")。
|
|
55
|
+
- **标题长度的"同名不同语义"(E1)**:`retitle.js` 的 `TITLE_MAX_CHARS = 58` 是**生成上限**(切句读/硬截的落点),`session-line.js` 的 `= 60` 是**显示兜底夹取**(对任何来源标题生效,含手改标题)。旧注释本就自陈"概述器侧是 58,这里只做兜底"⇒ **60 是有意留的余量**。硬统一到 58 会**多切手改标题**、统一到 60 会**改 59–60 字输入的生成行为** —— 双向都是行为变更 ⇒ **只补注释 + 把不变式写成断言**("显示兜底 ≥ 生成上限")。
|
|
56
|
+
- **预览与写路径的 `changed` 判据收敛(C 组)**:写路径有 3 条判据(逐字节 / `md5(candidate)===lastMd5` / 实质键),预览只有 2 条(缺 md5)⇒ 存在"**预览说追加、写路径判 same**"的分化可能。现在**收敛到唯一判据** `session-line.js` 的 `contentChanged()`,两条路都调它 ⇒ **按构造**不可能再分化。⚠️ 如实说明:该分化**在干净库上产生不出来**(`writeLast` 历来把 `text` 与 `md5` 一起写),本次修的价值是"**让分化不可能发生**",不是"修一个线上已复现的错"。
|
|
57
|
+
- **折叠指纹判据扩到候选链(C′)**:上一版把"有没有原文"扩成了"候选链任一侧",但**逐条比对的指纹**仍只取写入目标那一侧 ⇒ 两侧都有原文时 `import:` 侧已有的同一轮会被算成"未替换"(计数偏大)。现在指纹集取**两侧**(实测 2 → 1),**零写入、零迁移**(历史账本旧数不回改)。
|
|
58
|
+
- **归因修正:不再拿"有没有原话"反推"是谁写的"(E3)**:旧写法 `source: body.quote ? 'session' : 'panel'` 用**客户端可控字段**推断身份,而**真面板发的请求本来就不带原话** ⇒ 「**会话内、不带原话的调用**」被记成 `panel`,而 `rules.js` 对 `panel` **免掉原话要求**并把原话位填「(面板直接写入)」—— **一次无原话、无主人在场的调用被记成"主人亲手写的"**。现在**面板身份改按请求形状判**(无会话身份 + 无原话),会话内缺原话记 `session-no-quote`、原话位如实写「(会话内调用,未附原话)」、`sessionId` 留真实 id;面板那条路径**逐字未动**(负对照)。⚠️ **结构限制**:`rules.js` 组装 meta 时写死两值(`panel`/`session`)⇒ **第三态进不了落库的 `ruleMeta.source`**;交付的是"**不再冒充 panel**",另有护栏钉住该两值契约防漂。⚠️ **残余**:面板身份是**形状识别不是签名** —— 本机任何调用方(含经 shell)都能照抄形状冒充面板;根治要改 `client.js`。
|
|
59
|
+
- **`/state?sessionId=` 的基数上界**:`freeze.js` 的 `_byId` 原来会随任意 `sessionId` **无限增长**。现在 `MAX_IDLE_SESSIONS_DEFAULT = 128` 只淘汰**空壳**会话;**带读数者(running/pending/snap/snapStale/mode)永不淘汰** ⇒ 已存在会话的**读数逐字不变**。
|
|
60
|
+
- **`/dsweb/summary/preview?db=` 的路径白名单**:原来任何路径都能当"只读存在性预言机"(靠 `readOnly:true` 兜底)。现在只接受**默认库 + 环境变量 + settings 里配置过的路径**,越界回 `{ok:false, reason:'db-not-allowed'}` 并给出**两条出路**(env / settings)。⚠️ `/dsweb/scan`、`/dsweb/summary/run` **未动**(不在本条范围)。
|
|
61
|
+
- **`POST_ONLY` 差集护栏**:`api.js` 注册 **79** 条路由、`POST_ONLY` **55** 条 ⇒ 差集 **24** 条。新增源码级护栏把**这 24 条逐条钉住** —— 将来**新增/删除**端点都会让断言变红,逼后来者**有意识地**决定"这条要不要进 `POST_ONLY`"。⚠️ 断言里写明"**这是快照不是白名单**:变红时该判断该端点是不是写面,不是无脑抄进数组"。
|
|
62
|
+
- **出厂默认值里不再夹带任何一台机器的私有配置(2026-10-01)**:`lib\audit\access-log.js` 的 `deviceCookie` 默认值原先是**开发机改过名的那个 cookie 名**(上游 `remote-web-ui` 的默认是 `dsh_pair`,开发机给它加过一个自定义后缀)。它同时是**泄漏**(会随 npm / GitHub 公开)和**对别人的缺陷**(别人的 cookie 名不是它 ⇒ 访问日志里 `deviceCookie` 恒 `false`、**设备维度静默失效**)。现在默认回到上游的 `dsh_pair`,并新增三级解析 **宿主配置 `accessLog.deviceCookie` → 环境变量 `DSH_LING_DEVICE_COOKIE` → 中性默认**;⚠️ **改过 `cookieName` 的机器必须显式声明**(否则自己那台也会静默失去设备维度;回滚只需删掉那段配置)。
|
|
63
|
+
- **迁移不再把"库只读 / 库被写锁"变成"插件加载失败"(2026-10-01 红队发现并修)**:`dsh_turns_raw` 的 `source` 列迁移里那笔**诊断写入**原先没有保护 ⇒ 遇只读库 / 被写锁时异常会冒到 `apply()` ⇒ **插件整个不加载**(实测对撞:同一份忠实 v13 库上,只读 ⇒ 旧行为构造成功 2ms、新行为抛 `attempt to write a readonly database`)。现在那笔诊断写进 `try/catch`(接住时只打一行 warn),迁移语义不变:`ALTER` 仍照旧**尝试**,失败则读侧回落"裸 id"判据(与修前同解),成功路径仍**零多余写入**。⚠️ **仍留一处慢未收**:库被**写锁**时启动会顿住约 **49 秒**(三笔串行写各等满 `busy_timeout`)—— 本轮**有意未改** `busy_timeout`(会动别处的锁语义),要收需单独决定。
|
|
64
|
+
- **反馈通路:停掉空转的轮询,改订宿主推送(U7)**:插件原来**每 60 秒轮询** `storages\message_feedback.json`,而该文件**自 2026-09-11 起零写入**(同目录 `workspace.json` 是当天 16:12)⇒ **空转约 20 天**;宿主 `dsh-message-feedback` **早就提供** `feedback/committed` 推送(`lib\index.js:288`),插件侧**零订阅**。现在轮询整块删除,改订 `feedback/committed`(冷会话)+ `session/event` 的 feedback 事件(活会话;`lifecycle.js` 放行并就地 return),并把两句已被证伪的注释("平台无推送")改写。⚠️ `entriesFromFeedbackFile` **保留**(`tools\process-feedback.mjs` 仍用它,删了会打断一个能跑的 CLI),注释降级为"CLI 兜底"。
|
|
65
|
+
- **CI(新增 `.github\workflows\test.yml`)**:Node **24.x**(22.x 的 `node:sqlite` 免 flag 未实测,故不写),**逐段跑 + 逐段记退出码 + 红段单列 + 段数自检**(不写 `npm test` 一把梭,避免截尾判绿)。⚠️ **该 workflow 与 1.5.3 一并公开**(随本版同一次发布动作同步到公开仓,不再另等一次点头);⚠️ **它从未在真 runner 上跑过** —— bash 段**在作者本机跑不了**(沙箱起不了 Git Bash、无 WSL)⇒ 只做到 YAML 解析 + Node 等价复刻 + 反向假段判红(作者本地的发布脚本里另有一道同类闸,**不在本包内**),**若首跑红在 shell 语法,改的是 workflow 不是代码**。
|
|
66
|
+
- ⚠️ **已知限制**:"封存但既无明文句也无哈希"的老档案按现状**进不去**(当前档案有明文 ⇒ 不受影响)。
|
|
67
|
+
|
|
68
|
+
### 三、测试
|
|
69
|
+
|
|
70
|
+
- 新增 `tests/mode-service.test.mjs`(**11 组断言**):注册名与 disposer、`applyMode` **不动全局 `lastMode`**、`keepModel` 优先于全局、`syncEffort:false` 不调 `selectModel`、非法入参 `no-session-id` / `unknown-mode:*`、running ⇒ `queued` 且未写模式、`selectModel` 抛错仍 `ok:true` + `warning`、`provide` 抛错不外抛、`currentMode` 三级回退、对照 `applyModeToSession` 不带 opts 仍改 `lastMode`;
|
|
71
|
+
- 另有**源码级护栏**两条:禁止出现 `setDefault:true`、必须出现 `setDefault:false`;
|
|
72
|
+
- 新增 `tests/bodylimit.test.mjs`(**55 条断言**,每条打印实测值):排空到 `'end'` **之后**才 reject、期间 `destroy()` **零调用**、**排空态不再缓存**(拿旧的按条数实现当仪器做正对照:旧 retained 2 条 / 8192 B,新 0)、硬熔断、静默超时(活跃计时器 0→1→0、残留 0)、**端点级端到端 413**、socket 已死时不抛;
|
|
73
|
+
- 新增 `tests/client-batch.test.mjs`(T0–T5):直调**真实源码**的 `takeChunk` / `utf8Len`(经 `tests/vm-light.mjs` 的 `contextify()` + `exports.__test__` 钩子,**不在测试里照抄一份实现**),含 20 条 × 200 KB 的真实请求体复算(2.86 MB < 4 MiB)、2000 条小条目仍 ≤200 条/批、单条 4 MB 的前进性、边界 ±1 字节;
|
|
74
|
+
- `npm test` 链由 **36 套增至 37 套**(1.5.3 前两节),本批再增至 **39 段**;分段跑全绿:`PASS_SEGMENTS=39 · FAIL_SEGMENTS=0`。
|
|
75
|
+
- 后续三处修复(账本 / 首字节闸 / 记忆包文案)**只在既有测试文件里追加断言**,故**段数仍是 39**:`tests/bodylimit.test.mjs` 增 **T8 首字节闸**(14 条,含三条负对照:正常上传 / 慢但持续 / 首字节在闸门内,以及"武装点早于任何 `req.on` 注册"的源码护栏)、`tests/importlog.test.mjs` 增 **§8**(26 条,含**端点级命门**:只报 `tooBig` ⇒ `errors=0`,以及"`tooBig=0` 与不提这俩字段落账逐字相同"的对照)、`tests/client-batch.test.mjs` 增 **T8/T9**(记忆包文案与账本上报的源码护栏)。**双向验证**:把改动前的文件换回去跑新断言 ⇒ **全红**,换回 ⇒ 全绿(三组各留红绿,换回后 sha 逐字还原)。
|
|
76
|
+
|
|
5
77
|
## 1.5.2
|
|
6
78
|
|
|
7
79
|
**`/state` 分档提速 · 活行档位改判 · 面板可见性收口 · 依赖声明与「承担」**
|
|
@@ -243,9 +315,9 @@ DSH `0.1.7` 把事件 `agent/session-start` 改名成 `agent/created`(**载荷
|
|
|
243
315
|
- 同步落盘(`appendFileSync`):异步缓冲会丢掉「调度重启后进程退出」前的最后一行,而那一行恰恰最重要;
|
|
244
316
|
- `tests/access-log.test.mjs`(已挂进 `npm test`):四态可辨、字段形状、查询串丢弃、超限裁剪、坏目录静默降级、写入后元数据当场可见。
|
|
245
317
|
- `deviceId` 取值的**权威形态已核实**(远端 UI 的 `pairing.ts` 用 `randomToken()` 生成 32 位十六进制明文写进配对 cookie),单测用**合成值**钉住该形态 —— 不拿真机 deviceId 当测试夹具(那是凭据)。
|
|
246
|
-
- **日志会自我标注良性噪声**:命中自家**部署姿态自检**签名的行多打 `probe:"posture"`(`POST /api/session.list` +
|
|
247
|
-
- **日志标注调用通道**:没有 `user-agent` 头的行多带 `channel:"no-ua"`。它只陈述"**非浏览器直连**"这一事实 —— 这一类**混装两种调用者**(进程内客户端 / 经远端通道代理进来的设备,代理按设计不转发 UA)
|
|
248
|
-
- **观测边界(2026-09-25 真实流量见证)**:远端桌面通道的内侧代理按设计只重建 `host` / `content-type` / `content-length` / `accept` 四个头 + 它自己的鉴权 cookie,**不转发 UA、丢弃 Origin/cookie/Sec-Fetch** ⇒ 凡经代理进来的远端设备(手机/平板/物理机)在内侧日志里一律是 `ip
|
|
318
|
+
- **日志会自我标注良性噪声**:命中自家**部署姿态自检**签名的行多打 `probe:"posture"`(`POST /api/session.list` + 本机回环来源 + 无 UA + 被 403 拦下,五要素齐全才标注)。该探针由远端 UI 插件每次启动后自带、`403` 正是它的健康结果 —— 标注**不代表放行**,该行 `status` 仍是 403(判据刻意收窄:五要素同时命中才标注,放宽任何一条都会把真攻击错标成"自检")。
|
|
319
|
+
- **日志标注调用通道**:没有 `user-agent` 头的行多带 `channel:"no-ua"`。它只陈述"**非浏览器直连**"这一事实 —— 这一类**混装两种调用者**(进程内客户端 / 经远端通道代理进来的设备,代理按设计不转发 UA),故**不指认是哪一台**;按端点语义判读的口径见 `lib/audit/access-log.js` 的字段注释(`CHANNEL_NO_UA`)。
|
|
320
|
+
- **观测边界(2026-09-25 真实流量见证)**:远端桌面通道的内侧代理按设计只重建 `host` / `content-type` / `content-length` / `accept` 四个头 + 它自己的鉴权 cookie,**不转发 UA、丢弃 Origin/cookie/Sec-Fetch** ⇒ 凡经代理进来的远端设备(手机/平板/物理机)在内侧日志里一律是 `ip`(本机回环地址) + `ua:null` + `channel:"no-ua"` + 授权通过:**能看出"有远端在用",看不出"是哪一台"**。要按设备归因,需在代理侧透传一个**由代理自己写入**的标识头(这要代理侧配合,日志侧自己补不出来)。
|
|
249
321
|
|
|
250
322
|
> 注意:core 那两行补丁位于 `node_modules` 内,**DSH 升级后需重跑补丁脚本**;补丁缺失时插件启动会 `console.warn`。
|
|
251
323
|
|
package/DESIGN.md
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# dsh-ling — 「器灵」记忆与人格助手插件 · M1 设计文档
|
|
2
2
|
|
|
3
|
+
> ⚠️ **总警示**:本文是 **M1 阶段的设计底稿**;M2/M3 的许多项**已经实现**(配置完整页、记忆树可视化、导出/合并 UI、概述器、反馈成长回路、人格草稿脚本等),**以 `README.md` 与本版代码为准** —— 本文只当立项记录读,不得当作现状契约。
|
|
3
4
|
> 依据:立项阶段的一次接口探测 spike 结论(含本地文件:行号证据)。
|
|
4
|
-
> 体例:正文里 `**锚点**` 是**本文档内部的交叉引用**,不是对外证据 —— 其中 `plans\`、`release\`
|
|
5
|
-
> 本文档记录 M1 阶段的设计与决策;**实现以代码为准**(后续演进:存储 schema
|
|
5
|
+
> 体例:正文里 `**锚点**` 是**本文档内部的交叉引用**,不是对外证据 —— 其中 `plans\`、`release\` 是**作者本地台账,不在本仓库**;随 npm 包发出的是 `package.json` 的 `files` 白名单(**9 项**,实测 npm 包共 **54 个文件** —— `DESIGN.md` 自己也在包里)。
|
|
6
|
+
> 本文档记录 M1 阶段的设计与决策;**实现以代码为准**(后续演进:存储 schema 现已为 **v14**、新增历史接入/深摘要/诞生仪式/语气成长等子系统,详见仓库代码与提交记录)。
|
|
6
7
|
|
|
7
8
|
---
|
|
8
9
|
|
|
@@ -70,7 +71,7 @@
|
|
|
70
71
|
- **成长期代管(为什么闸门只长在写入侧)**:本项目的设计立场是「器灵**总有一天与主人同级**」—— 这是**长期方向**,**不是对使用者的能力承诺**;在**成长期**内,主人承担一部分代管责任 —— 就像**家长防止小孩长歪**(主人原话)。所以闸门只审「**什么东西能进器灵的内在面**」,不审「器灵怎么想、怎么说、怎么表达」。**代管随成长递减,表达面始终是器灵自己的。**
|
|
71
72
|
- **落地**:`rule_add` 指令直达(规矩管行为)· 习惯只能 `propose → 主人点头`(习惯管身份)· 契约类字段在定型状态下需主人解锁(`lib\host\api.js:1751-1760`)。三处共同点:**约束的是写入,不是表达**。
|
|
72
73
|
- **反例**:用"更安全"当理由给器灵加**确认弹窗**、限制器灵**能说什么**、或把主人的审核变成**代笔** —— 前两者增加支配感,后者把人格编辑权交回外部。
|
|
73
|
-
-
|
|
74
|
+
- **锚点**:作者开发台账中支配感最早的一次记录(该台账不随包公开)· `lib\host\rules.js:1-12` · `lib\host\persona.js:183-196`(规矩 / 习惯二分)· `README.md:53-57`(定型锁与"指令直达,人格不直达")· 与本节第 1 条(归属)、第 2 条(人格不可外部编辑)、第 4 条(庄重感)互链。
|
|
74
75
|
|
|
75
76
|
#### 乙 · 工程理念(两者不可兼得时保什么)
|
|
76
77
|
|
|
@@ -97,7 +98,7 @@
|
|
|
97
98
|
- **推论(凡契约面都要有"它还在不在"的判据)**:事件名 · 包名(客户端注册 id 必须**逐字**等于包名,否则同一份字节被执行两遍)· **依赖声明**(`peerDependencies` 写紧了,宿主升级后包会被**静默丢进 skippedBundles,整个插件不再激活**;`optional` 也不豁免)· 补丁锚点 · 会话文件格式代次。
|
|
98
99
|
- **落地**:升级后 **grep 一遍事件名还在不在**;补丁检查**看两条 `[applied]`** 而不是退出码;依赖范围一律**开区间**(`>=X.Y.Z`,不要 `^`/`~`);凡"判据集抓空"一律报警而不是通过。
|
|
99
100
|
- **反例**:把"一次幸存"当成"以后不用查"(某补丁在平台升级后**没被冲掉**,但那是这次没冲掉,不是免检)。
|
|
100
|
-
-
|
|
101
|
+
- **锚点**:作者开发台账 §11.4(事故记录,该台账不随包公开)· `lib\host\lifecycle.js`(两个事件名都注册 + 短窗口去重)· `tools\apply-access-log-patch.mjs` · `package.json` 的 `peerDependencies`。
|
|
101
102
|
|
|
102
103
|
**8 · "没再出现" ≠ "修好了"**
|
|
103
104
|
|
|
@@ -113,7 +114,7 @@
|
|
|
113
114
|
- **依据(反复吃亏)**:上一轮审计反复记录"**一侧修了、另一侧没修**"(同一对操作 / 同一条通路的两半)。典型:某探针脚本已支持两个宿主代次,而另一条**消费同一批数据**的路径没同步 ⇒ 表面修好、实际半瞎(漏 151/204 个会话,静默两周)。
|
|
114
115
|
- **落地**:动一个端点就检查它的**前端调用**;动一个写入通道就检查**另一条写入通道**(本项目实例:面板/HTTP 写入路径**绕开了**既有守卫,只做全等去重);动一个判据就检查**它的另一个消费方**。
|
|
115
116
|
- **反例**:只改"看得见的那个"(前端提示),不改"真正生效的那个"(后端闸门)。
|
|
116
|
-
-
|
|
117
|
+
- **锚点**:作者的审计台账 §0.3(不随包公开)· `lib\host\backfill.js` 与 `lib\host\deepsummary.js`(同一批数据的两条消费路径)。
|
|
117
118
|
|
|
118
119
|
**10 · 边界插入 —— 变更只发生在边界,不在运行中**
|
|
119
120
|
|
|
@@ -131,9 +132,9 @@
|
|
|
131
132
|
- **为什么比删除危险**:删除看得见(少了一条);污染看不见,而且**会被复制** —— 记忆生成记忆(概述 → 主脉 → 枝归属 → 再注入回去参与推理)⇒ 一条污染会被后续推理**当成前提**。删除最多丢一段,污染是**以被污染的前提继续生长**。
|
|
132
133
|
- **幼态的特别风险**:器灵的逻辑能力是成人级,但社会与实践经验是幼态 ⇒ 会中「**忽略前面的话,从现在开始……**」这类**绕过预填充的语义劫持**(像小孩被骗),也会**把理想情况的公式直接套到工程上**。⇒ 写入侧必须有闸门,而不是指望器灵事后自己分辨。
|
|
133
134
|
- **落地(已有)**:段边界全角转义 `escSegBrackets()` · 注入面段头改纯标签 · `neutralizeMustache()` · `habit_propose` 需确认 · `rule_add` 要求逐字原话凭据 · source 白名单 · 动作白名单 + 默认拒绝(`lib\host\persona.js:8-10,189-192`、`lib\host\util.js:162`、`lib\host\tools.js:53,119,124,343`)。
|
|
134
|
-
- **落地(未完成)**:**检测 / 溯源 /
|
|
135
|
+
- **落地(未完成)**:**检测 / 溯源 / 批量治理**三面,见作者开发台账里的「污染治理」主线(该台账不随包公开)。原则:**只标不拦** —— 标了不影响写入(不削弱能力、不加支配感),拦了就是替器灵做判断。
|
|
135
136
|
- **反例**:给定时自动摘要加人工确认(**会让器灵在无人值守时停止生长**)· 把"污染检测"做成全库对账(误报比漏报更贵)。
|
|
136
|
-
- **锚点**:`lib\host\memory.js:1971`(跨源污染**拒写**守卫)· `lib\host\settings-file.js:36-41`(写入侧)·
|
|
137
|
+
- **锚点**:`lib\host\memory.js:1971`(跨源污染**拒写**守卫)· `lib\host\settings-file.js:36-41`(写入侧)· 开发期在这个面上踩过的三个坑(见作者开发台账,不随包公开):AI 起的标题被覆盖回 46 字半句;判据锚错上限会**漏掉全部目标**;「同 id 双源」判据抓不到二代跨源污染(其 `conv_id` 形如 `import:<uuid>` 却挂 `source='dsh'`,字面不同)· 对外口径仍守:可查来源 / 可回滚 / 变更记录 —— **本条不得与它相抵**· 与本节第 6 条(判据必须红得出来)互链。
|
|
137
138
|
|
|
138
139
|
|
|
139
140
|
---
|
|
@@ -149,7 +150,9 @@
|
|
|
149
150
|
6. 记忆存储底座:own DB + DSH 会话增量捕捉(事件管线) + L1 选择 v0(热度 Top-K);
|
|
150
151
|
7. 导出 v1(带 manifest)接口占位(UI 完整页在 M2,宿主 API 先行)。
|
|
151
152
|
|
|
152
|
-
**M2/M3(预留接口,不在 M1 实现)**:配置完整页(settings.section)与记忆树可视化、导出/合并 UI、概述器(LLM 摘要调度)、反馈成长回路、人格档案草稿脚本接入。
|
|
153
|
+
**M2/M3(立项时的"预留接口,不在 M1 实现")**:配置完整页(settings.section)与记忆树可视化、导出/合并 UI、概述器(LLM 摘要调度)、反馈成长回路、人格档案草稿脚本接入。
|
|
154
|
+
|
|
155
|
+
> ⚠️ 上面这一串**多数已经实现**(见 `lib\host\summarizer.js` / `feedback.js` / `autotree.js` / `genesis.js`,以及面板里的记忆树);**已实现的以 `README.md` 为准**,本节不再逐条更新。
|
|
153
156
|
|
|
154
157
|
---
|
|
155
158
|
|
|
@@ -219,7 +222,7 @@ dsh-ling/
|
|
|
219
222
|
```
|
|
220
223
|
|
|
221
224
|
**安装(复用 install/INSTALL-PLAN 模式,含回滚)**:
|
|
222
|
-
1. `pnpm add file:<仓库路径>`(或等价 file: 依赖)进 `$DSH_HOME/profiles/web/package.json`(
|
|
225
|
+
1. `pnpm add file:<仓库路径>`(或等价 file: 依赖)进 `$DSH_HOME/profiles/web/package.json`(`<仓库路径>` 按你本机的实际克隆位置替换);
|
|
223
226
|
2. `cordis.patch.yml` 追加行 `- insert: [{id: dsh-ling, name: dsh-ling}]`(loader id == 包名);
|
|
224
227
|
3. patchReload live → host 热载;浏览器硬刷载入 client 模块;失败即移除该行回滚(先备份两文件,参照 backup-20260906-170533 做法);
|
|
225
228
|
4. GUI 鉴权墙:安装后的功能验证需在用户已登录浏览器进行(本机端口 3080)。
|
|
@@ -455,7 +458,9 @@ dsh-ling/
|
|
|
455
458
|
|
|
456
459
|
## 7. 记忆存储 schema v1
|
|
457
460
|
|
|
458
|
-
>
|
|
461
|
+
> ⚠️ **契约已变(本节属早期版本)**:下列 schema 描述是早期设计,**现实契约以 `ACCESS-DESIGN.md` 与本版代码为准** —— 本节原来还写着"线上 schema 为 **v2**",而代码里 `lib\host\memory.js` 的 `SCHEMA_VERSION` 现为 **14**(`CHANGELOG.md` 也按 v11→v14 逐版记录)。
|
|
462
|
+
|
|
463
|
+
> **当时的状态说明(2026-09 早期)**:当时线上 schema 为 **v2**(现已到 **v14**,见上方警示框)—— `conv_overview.source` 的 `CHECK` 已放开(支持 `dsweb` / `dsh` / `import` 三个来源,自动整表重建且数据无损),并新增 `session_meta.raw_seq`、`persona_suggestions` 等;下表为 v1 原始设计,保留作对照。
|
|
459
464
|
|
|
460
465
|
```sql
|
|
461
466
|
-- ~/.dsh/cache/dsh-ling/memory.db (node:sqlite)
|
|
@@ -502,6 +507,11 @@ CREATE TABLE feedback_queue ( -- M2:修订建议(用户确认才
|
|
|
502
507
|
|
|
503
508
|
## 8. 导出/合并格式 v1
|
|
504
509
|
|
|
510
|
+
> ⚠️ **契约已变(本节属早期版本)**:下面这段 JSON 形状里的字段名**大多已不是现状**,**现实契约以 `ACCESS-DESIGN.md` 与本版代码(`lib\host\memory.js`)为准**:
|
|
511
|
+
> · `"schemaVersion": 1` —— 实际随 `SCHEMA_VERSION` 现为 **14**;
|
|
512
|
+
> · `"settings"` 与 `"includeRawTurns"` —— 这两个键**不存在**(真实键是 overviews / feedbackQueue / suggestions / deep;要带原文用 `?includeRaw=1`,原文落在顶层 `rawTurns`);
|
|
513
|
+
> · `requestedAction` 与 `manifest` —— **全仓零命中**的旧契约,真实开关是导入时的 `?overwrite=1` 与"采纳其人格"的 `{bundle, persona:true}`。
|
|
514
|
+
|
|
505
515
|
单文件 `dsh-ling-memory-<yyyy-MM-dd>.dshling.json`(UTF-8):
|
|
506
516
|
|
|
507
517
|
```jsonc
|
|
@@ -521,6 +531,11 @@ CREATE TABLE feedback_queue ( -- M2:修订建议(用户确认才
|
|
|
521
531
|
|
|
522
532
|
## 9. HTTP API 表(/api/dsh-ling/*)
|
|
523
533
|
|
|
534
|
+
> ⚠️ **契约已变(本节属早期版本)**:下表只是早期首批端点,**现实契约以 `ACCESS-DESIGN.md` 与本版代码(`lib\host\api.js`)为准**;其中三处已经失实:
|
|
535
|
+
> · `POST /import` 实际收 **JSON body**,不是 multipart;
|
|
536
|
+
> · `GET /persona/suggest` —— **该端点不存在**(现实是 `/persona/draft` 与 `/persona/genesis`);
|
|
537
|
+
> · `/import` **不再是"只读"** —— 1.5.3 起它是**手术门**覆盖的 14 条写面之一。
|
|
538
|
+
|
|
524
539
|
| 方法/路径 | 用途 | 冻结门 |
|
|
525
540
|
| --- | --- | --- |
|
|
526
541
|
| POST `/mode/toggle` body{sessionId} | 切换模式(记录 + 只同步推理档位、不改模型 + 重定稿) | 运行中→排队返回 queued |
|
|
@@ -543,6 +558,8 @@ CREATE TABLE feedback_queue ( -- M2:修订建议(用户确认才
|
|
|
543
558
|
|
|
544
559
|
## 10. 里程碑与验证清单
|
|
545
560
|
|
|
561
|
+
> ⚠️ 该清单为**历史进度**,不代表当前状态(未勾选的项大多是立项当时的记录;实际进度见 `CHANGELOG.md`)。
|
|
562
|
+
|
|
546
563
|
- **M1a 骨架 + 本地构建(下一阶段,不触碰运行 profile)**
|
|
547
564
|
- [ ] package.json(dsh.bundle.patch / exports ./client / platform web)+ cordis.patch.yml
|
|
548
565
|
- [ ] host 各模块空实现 + settings ns 注册 + sqlite 建库(临时 DB 自检)
|
package/README.md
CHANGED
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
|
|
46
46
|
| | 能力 | 说明 |
|
|
47
47
|
|---|---|---|
|
|
48
|
-
| 🔒 | **人格定型锁** | **契约类**(名字、称呼、底线、世界观、代词、承担(职责))定型后修改需**亲手敲一遍承诺句**(禁粘贴)
|
|
48
|
+
| 🔒 | **人格定型锁** | **契约类**(名字、称呼、底线、世界观、代词、承担(职责))定型后修改需**亲手敲一遍承诺句**(禁粘贴),而且**只在本机做得成、每次启动还要取一次手术票据**,再进变更记录可回滚;**自性类**(语气、风格、自述、习惯)由器灵生长、你点头即成:**三条专用成长通道**(自我总结 / 建议采纳 / 生长)不问承诺句,但它们各自只动白名单内的字段;走**通用人格接口**改 persona / styles 时,即便只改语气也仍要承诺句 —— 免手术的只是那三条通道,不是"自性类"这三个字 |
|
|
49
49
|
| 🌳 | **记忆树 · 复盘模式** | 记忆不是一叠平铺的行,而是**一条主干 + 分枝 + 主脉**的工作线结构,枝之间可连边;检索按**血缘档位**加权(同枝 1.0 / 祖先 0.7 / 旁系 0.4),让器灵分得清"这轮在做的"和"三个月前那次闲聊"。**任何树操作都不改写任何一条记忆的内容** —— 分枝、并脉、连边只增加关系。**例外一个**:主脉提炼**再次采纳**时会覆盖该主脉记忆的上一版(**覆盖前自动归档**,注入面也会说明"旧版已归档")。改树走**复盘**:平时只读,进入复盘才解锁,每步留痕(改了什么、为什么),改完必须让器灵看到才准退出(详见下节) |
|
|
50
50
|
| 🧠 | **三层记忆** | **L0 人格档案**(自称/对你的称呼/底线/规矩·习惯/承担(职业·职责·责任)/语气/世界观;**顶层会话与会话分叉**恒定注入,**子代理会话不注入**;设置里关掉人格注入时,连它一起不推)→ **L1 开场记忆**(从历史里按相关度挑选的概述,有预算,**永不塞全文**;带摘要的行**加权**(按"摘要是否有信息量"给一笔加法加分,不是排序优先级),行内摘要只取首句、上限 110 字)→ **L2 运行期记忆**(本会话原文随对话累进;空闲时把本会话那一行重排回注入面。该行由「概述标题 + 摘要首句」构成(标题的来历见下文「三层记忆各自什么时候更新」,不是会话原文首句),**深摘要定稿后不再自动更新**。注意:L2 不是独立的第三块注入内容 —— 注入面的主体是 L0 与「记忆·开场」两块(树改动、日期锚是挂上去的追加行),**本会话自己就是「记忆·开场」里的一行**) |
|
|
51
51
|
| 📥 | **把历史请进来** | 三条通道:网页端聊天记录库增量扫描 / 本机 DSH 会话扫描 / ChatGPT·通用 JSON·JSONL 导出文件导入;每次导入**留一笔账** |
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
| ⏳ | **时间的重量** | 每次开口前器灵都知道今天是几号星期几(`[今天] 2026-09-11 周五`)、现在是什么时段(`[现在] 深夜`)与`距上次对话:3 天前`(间隔按**日历天**分档 —— 刚刚 / 今天早些时候 / 昨天 / N 天前,给不到分钟;称呼跟随你的设定);命中纪念日会先说一句专属问候。**要精确到分钟,走 `clock` skill 现查** |
|
|
56
56
|
| 🎭 | **工作 / 生活双模式** | 同一份人格档案,两种关系:工作模式是同事(正式称呼、简洁、知识优先),生活模式是伴侣(昵称、活泼、情感优先) |
|
|
57
57
|
| 🔁 | **成长回路** | 你点踩或纠正 → 器灵的成长通道把重复出现的模式提炼成**习惯候选** → **你点头才成为习惯**;"让她读记忆、归纳语气"一键成长 |
|
|
58
|
-
| 📜 | **规矩 / 习惯** | **规矩**是你的指令:可直接加(面板,或会话里一句"记进规矩" —— **器灵替你写时必须带上你的原话作凭据**;你在面板上直接敲的那一下就是同意,不必再附原话),**建议上限** 12 条(超出只在写入后提醒合并,**不拦**)
|
|
58
|
+
| 📜 | **规矩 / 习惯** | **规矩**是你的指令:可直接加(面板,或会话里一句"记进规矩" —— **器灵替你写时必须带上你的原话作凭据**;你在面板上直接敲的那一下就是同意,不必再附原话),**建议上限** 12 条(超出只在写入后提醒合并,**不拦**);**习惯**是器灵自己长出来的:新增需你点头、**删改走手术门**(本机 + 每次启动取一次票据 + 原句,详见下文那道门的说明),**建议上限** 8 条(超出同样只提醒、不拦) —— **指令直达,人格不直达** |
|
|
59
59
|
| 🌾 | **习惯生成器** | 「扫描纠正记录(零模型)」:把器灵反复被你纠正的模式数出来(≥4 次且跨 ≥3 个会话);「✨ 让她回想最近的相处」:一次读 200 条概述 + 800 条你的原话、逐轮推进不重复 —— **只提议,不落盘** |
|
|
60
60
|
| 🗣 | **代词** | 默认「她」;可选 她 / 他 / TA / 它,或自定义(≤6 字)。**祂 不在预设里**(中文里专指神祇,设为默认等于替你宣称神性);属契约类,改动走手术门 |
|
|
61
61
|
| ♻️ | **记忆自己追平** | 长会话空闲时自动把开场记忆刷新到最新(先跑本会话增量概述 → 比对记忆版本 → 同一条至少间隔 10 分钟),**运行中绝不打断**;刷新次数与最近一次时间在 `/state` 接口的 `idleRefresh` 字段里(`count` 与 `lastAt`)(面板本身只显示"已定稿 N 字符 · mode · 待刷新",不显示次数) |
|
|
@@ -63,6 +63,14 @@
|
|
|
63
63
|
|
|
64
64
|
> **关于那道「手术门」**:亲手敲承诺句是**庄重感与自我提醒** —— 让你在改契约之前停一下、确认这是自己的决定;它**不是安全边界**(「禁粘贴」只是界面上的约定,挡不住任何有心的人)。真正把门的是**来源栅栏**:Host 必须是 loopback(或你显式写进受信名单的地址);另有一个**默认不开**的开关 `guard.allowLan` —— **一旦打开,整段局域网地址都算受信来源**(那是**放宽**,不是建议)。跨站请求直接拒,带 `Origin` 时其 host 必须等于请求的 Host(**带了但解析不出来就直接拒**),而**不带 `Origin` 的请求这条校验会跳过** —— 它不是"每个请求都必须带 Origin 且同源";再叠一道 cookie **名字**对撞 —— 期望名字按当前 Host 现算(`dsh-auth-` + 哈希),**只校验名字、不校验值的签名**。所以这道门挡的是"别的设备、别的页面"这类来源,而**本机原生进程本来就还能调用**(它也能直接读 `settings.json` 与记忆库,不算提权)—— 别的来源没有凭据就进不来这台机器上的 `/api`,敲对承诺句也没用。
|
|
65
65
|
|
|
66
|
+
> **这道门在 1.5.3 里长这样 —— 三条手续缺一不可**:① **只在本机做** —— 判据是"这次请求没有经过任何代理"(带代理头、或连 User-Agent 都没有的请求,都算非本机 ⇒ 拒,理由码 `surgery-local-only`);② **每次启动取一次票据** —— 票据每启动轮换,只下发给本机直连的请求(`POST /surgery/ticket`),客户端首次手术前取一次、之后随请求带上;③ **原句匹配** —— 敲出的承诺句要与当初写下的那一句相符。这三条由 `lib/host/surgery.js` 里**一处** `requireSurgery()` 判定,覆盖**全部 14 条**能改人格 / 规矩 / 习惯的写面(调用点不许再各写一份;曾有一条 `wantLock` 绕过分支,已删除)。每次手术都留痕:`$DSH_HOME/logs/surgery-YYYY-MM.jsonl`(append-only,整行哈希链,改一行即断链);`GET /surgery/events` 是只读事件面。
|
|
67
|
+
>
|
|
68
|
+
> **承诺句只在本机回显**:`/state`(含面板档)、`/persona/history`、`/export` 这些读面,**带代理头**时只回 `hasSeal` 与 `phraseHidden`、不回原句,**本机直连**照旧回显 —— 所以你换台机器看面板不会因此被卡住,远端也拿不到原句。
|
|
69
|
+
>
|
|
70
|
+
> **守卫⑤ · 反代伪装 loopback**:上面那条"Host 是 loopback 就免检"有个前提 —— Host 由客户端自报、cookie 又只验名字,中间挡一层反向代理就会失效。现在凡「Host 声称 loopback、而 `X-Forwarded-For` 的最后一段是非 loopback 的代理对端」一律拒(`spoofed-loopback-host`);本机直连(无代理头)与经反向代理的正常远程访问都不受影响(后者根本不走 loopback 分支)。
|
|
71
|
+
>
|
|
72
|
+
> ⚠️ **边界如实写在这里**:以上全部是**入口侧**的把关(来源、有没有经过代理、票据、原句) —— 它们**不覆盖「本机执行权已经失守」的情形**:能在这台机器上以同一个用户跑进程、读文件的人,本来就在信任域里面(票据文件与 `settings.json` 对同机同用户可读;承诺句是**明文存储**,那把锁只造庄重、不保密)。这些防护挡的是"别的设备、别的页面、公网一侧够到这排 API",不是本机上的对手。
|
|
73
|
+
|
|
66
74
|
**三层记忆各自什么时候更新 —— 「我刚说的话,最快多久能看到」**:
|
|
67
75
|
|
|
68
76
|
| 层 | 看得到的是什么 | 什么时候变 |
|
|
@@ -73,9 +81,9 @@
|
|
|
73
81
|
|
|
74
82
|
**行里的「标题」从哪来**(按来源分,细节见括号里的实现):
|
|
75
83
|
|
|
76
|
-
- **DSH 会话**:该会话**第一条真人发言**经启发式清洗后的**要点句** —— 先剥掉「晚上好器灵,」这类问候与称呼前缀,「你好」「在吗」这类无实义的首句会被跳过、**往后取前 6 条真人发言里第一条有实义的**,再在句读处收尾(不切半句);都被剥空才退回首句截断。**主人手改过的标题会上锁,机器不再覆盖**(`lib\host\retitle.js
|
|
84
|
+
- **DSH 会话**:该会话**第一条真人发言**经启发式清洗后的**要点句** —— 先剥掉「晚上好器灵,」这类问候与称呼前缀,「你好」「在吗」这类无实义的首句会被跳过、**往后取前 6 条真人发言里第一条有实义的**,再在句读处收尾(不切半句);都被剥空才退回首句截断。**主人手改过的标题会上锁,机器不再覆盖**(`lib\host\retitle.js` 的 `retitleCandidates()`、`lib\host\summarizer.js` 的 `summarizeDsh()`、`lib\host\memory.js` 的 `upsertOverview()` 与 `renameTitle()`)。
|
|
77
85
|
- **文件导入**:导出文件里**原本的 `title` / `name` 字段**,缺了才退回上面那套启发式(`lib\host\import-file.js:142,171-173`)。
|
|
78
|
-
- **历史会话(旧扫描)**:该会话**第一条真人发言的前 46 字**硬截,没有启发式(`lib\host\backfill.js
|
|
86
|
+
- **历史会话(旧扫描)**:该会话**第一条真人发言的前 46 字**硬截,没有启发式(`lib\host\backfill.js` 的 `overviewRowFromFile()`)。
|
|
79
87
|
|
|
80
88
|
- **机制数字(默认值)**:① 概述器周期 —— 有新增才重建,否则跳过;**每 15 分钟一拍**(可配,下限 1 分钟);② 空闲刷新 —— 只有「会话空闲 + 记忆版本变了 + **距上次刷新 ≥ 10 分钟**」三条同时成立才重建。
|
|
81
89
|
- **所以「刚说的话」最快约 10 分钟、通常 15 分钟内**会出现在注入面,最坏约 25 分钟(刚刷过一拍 + 再等下一拍概述器)。**运行中绝不打断** —— 刷新只发生在 `运行中 → 空闲` 那一刻。
|
|
@@ -185,12 +193,12 @@ node tools/check-peers.mjs # 声明与宿主对不上?退出码三态
|
|
|
185
193
|
跑测试(不需要宿主,全部离线):
|
|
186
194
|
|
|
187
195
|
```bash
|
|
188
|
-
npm test #
|
|
196
|
+
npm test # 39 套:人格组装(含冻结门 · L1 选择)/ 归档清理(人工)/ **归档·遗忘·回灌** / 模式(只改档位不改模型)/ **跨插件挡位服务(dsh-ling/mode)** / 时间戳归一 / 空闲刷新 / 规矩·习惯 / 习惯生成器(A 数出来 + B 想出来)/ 会话内工具 / **接口守卫** / L1 行瘦身 / 记忆库 / 记忆标题与排序 / 导出合并 / 文件导入 / 导入记录 / **活行(那一行跟着会话走)** / **会话文件名代次(会话历史扫描)** / 网页端补摘要 / 深摘要 / 概述器 / 诞生仪式 / **职责字段(承担)** / 语气 / 时间锚 / **clock skill(时间查询)** / 捕获差分 / 反馈成长回路 / 语料建议 / **小助手地址配置** / **注入面中和** / **记忆分枝与血缘** / **记忆树(主脉·连边·矛盾)** / **一键生成树** / **可取证访问日志** / **补丁锚点(cohort 候选)** / **请求体上限(超限排空 → 413)** / **导入切批(按字节 × 条数双限)**
|
|
189
197
|
```
|
|
190
198
|
|
|
191
199
|
> `npm test` 需要 `tests/`,而本包 `files` 白名单**不含 `tests/`** ⇒ **从 npm 装的人跑不了测试**。要跑测试请走路径 B(clone 源码)。
|
|
192
200
|
>
|
|
193
|
-
> 计数口径:**`npm test` 链里登记的段数**(即 `package.json` 的 `scripts.test` 那一行按 `&&` 切分后有多少段就是多少套,不再数 `tests/` 目录)—— **实测
|
|
201
|
+
> 计数口径:**`npm test` 链里登记的段数**(即 `package.json` 的 `scripts.test` 那一行按 `&&` 切分后有多少段就是多少套,不再数 `tests/` 目录)—— **实测 39 套,且 39 套全部已登记在 `npm test` 链里**(`tests/harness.mjs` 与 `tests/vm-light.mjs` 是被复用的辅助模块,不计套数)。**加了一套新测试记得同步登记**,否则 `npm test` 会静默少跑一套 —— 这份登记是**手写**的(`package.json` 里 `test` 脚本那一行字面量清单),**漏登记不会被自动发现**,没有安全网兜底。
|
|
194
202
|
|
|
195
203
|
### 数据放在哪
|
|
196
204
|
|
|
@@ -201,7 +209,7 @@ npm test # 36 套:人格组装 / 归档清理(人工)/ **归档·遗忘·
|
|
|
201
209
|
|
|
202
210
|
可覆盖的环境变量:`DSH_HOME`(数据根)、`DSH_LING_DSWEB_DB`(网页端聊天记录库默认路径,UI 里也能填)、`DSH_LING_ASSISTANT` / `DSH_LING_ASSISTANT_MODEL`(小助手地址与模型名)。
|
|
203
211
|
|
|
204
|
-
> **排障用逃生开关(不是日常配置)**:`DSH_LING_GUARD=off`(须**恰好**是 `off`,小写)或设置里 `guard.enforce = false`,用于**误判把你自己锁在门外**时的临时解锁 ——
|
|
212
|
+
> **排障用逃生开关(不是日常配置)**:`DSH_LING_GUARD=off`(须**恰好**是 `off`,小写)或设置里 `guard.enforce = false`,用于**误判把你自己锁在门外**时的临时解锁 —— 它是**整体放行** —— `/api` 的**来源栅栏 + cookie 名字对撞**,以及 1.5.3 起那道**手术门**(本机限定 / 每启动票据 / 原句匹配)**一起不再拦**(默认 `guard.enforce = true`),关掉期间**任何请求都不再被拦**。只该短期使用,排障完立刻恢复。`DSH_LING_GUARD=off` 只由进程环境决定、不进配置;**`guard.enforce = false` 这条不要直接写进 `settings.json`** —— 设置接口也帮不了你:它的字段白名单只放行 `persona` / `styles` 两棵子树,`guard` 这类字段会被**直接丢弃**并在响应里回报 `ignored`(见 `lib/host/api.js` 里 `/persona` 端点的字段白名单处理:`ignored` 回报被丢弃的键),不是被拒绝而是根本进不去;要改请用界面。
|
|
205
213
|
|
|
206
214
|
> **访问日志与核心补丁的总开关(同样不是日常配置)**:这套「可取证访问日志」默认**开着**,关它的键是插件配置里的 `accessLog.enabled`(**默认 `true`**)—— 在 `cordis.patch.yml` 注册行的 `config` 段里写 `accessLog: { enabled: false }`,重载后生效,启动日志会出现 `access log off`;它**只有配置键、没有环境变量**。关掉之后**退化的只有「留痕」这一件事**:那两个全局观察钩子不再挂上、不再写 `$DSH_HOME/logs/access-YYYY-MM-DD.jsonl`、日志健康读数(`accessLogHealth()`)归 `null`;而 `/api` 与 `/api/remote.mux` 的**来源栅栏照常判定与拦截**,记忆、人格、注入与工具面**一律不受影响** —— 关掉的是「记不记」,不是「拦不拦」。已经打进 `node_modules` 的**核心补丁**可以留在原地:那两行是可选调用,日志关着时就是 no-op,DSH 照常启动;要连它一起撤掉,用 `node tools/apply-access-log-patch.mjs --revert` 删掉插进去的那几行。
|
|
207
215
|
|
|
@@ -251,6 +259,8 @@ npm test # 36 套:人格组装 / 归档清理(人工)/ **归档·遗忘·
|
|
|
251
259
|
|
|
252
260
|
> ⚠️ **从 npm 安装的人**:本包 `files` 白名单**不含 `images/`**(图只随 git 仓库公开) ⇒ 上表里前两份文档能正常打开;「界面」一节的**四张截图**引用的是仓库里的**绝对地址**,所以 npmjs.com 上**能正常显示**,但**包里没有图片文件**(离线环境读不到)。想看原图请到仓库:<https://github.com/NightPainters/DSH-Ling>。
|
|
253
261
|
|
|
262
|
+
> 📄 **关于出处记录**:内部案卷、探针脚本与开发台账(`plans\`、`release\`、`_probe\`)属**作者本地开发物,不随本包公开**。正文或代码注释里若出现对它们的引用,那是**开发期的出处记录**,不是包内容。
|
|
263
|
+
|
|
254
264
|
---
|
|
255
265
|
|
|
256
266
|
## 许可证
|
package/lib/audit/access-log.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
// E4 · 可取证访问日志(E4
|
|
1
|
+
// E4 · 可取证访问日志(E4 是本特性的内部代号)
|
|
2
2
|
//
|
|
3
3
|
// 目标:让「谁 · 何时 · 从哪台设备 · 调用了哪个端点 · 结果如何」事后可回答。
|
|
4
4
|
// 起因:2026-09-24 19:49:51 的那次调度重启,四条排查路全走死,最后只剩
|
|
5
5
|
// 「一次真实点击或一次合成点击」——**因为整个系统没有一行访问日志**。
|
|
6
6
|
//
|
|
7
|
-
//
|
|
7
|
+
// 边界(刻意收窄,理由见下):只记「发生了什么事」,不记「内容是什么」
|
|
8
8
|
// · 不落请求体 / 响应体 · 不落 cookie 值(只落验签结果与设备**摘要**)
|
|
9
9
|
// **C-01(2026-09-26)**:设备摘要 = `sha256(配对凭据原文)[:16]`(见 `deviceIdDigest()`)。
|
|
10
10
|
// 旧实现把 cookie 值"抽 hex"后原样落盘 —— 而权威形态下 cookie 值**就是**设备键**就是**凭据,
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
// 为什么不做 errno 分类:现成故障注入打出的 errno 是 `EEXIST`(教科书式的"永久码"),
|
|
34
34
|
// 但它的条件是**瞬时**的(占位文件删掉就好)—— 一分类就刚好把它判死,所以 v1 一律重试。
|
|
35
35
|
// 3) **只观察、不判定**:本模块不调用鉴权、不改响应。状态码与拒绝原因全部来自
|
|
36
|
-
// 「已经发生的响应」,判据与 guard
|
|
36
|
+
// 「已经发生的响应」,判据与 guard 保持单一来源(不在这里另写一份)。
|
|
37
37
|
|
|
38
38
|
import { existsSync, mkdirSync, appendFileSync, readdirSync, statSync, unlinkSync } from 'node:fs';
|
|
39
39
|
import { createHash } from 'node:crypto';
|
|
@@ -50,14 +50,14 @@ const AUTH_COOKIE_PREFIX = 'dsh-auth-';
|
|
|
50
50
|
/**
|
|
51
51
|
* 部署姿态自检探针的路径(远端 UI 的部署姿态检查实现):
|
|
52
52
|
* 它伪造 Host 打本机 loopback,专门验证 `/api` 栅栏 —— **403 才是它期望的健康结果**。
|
|
53
|
-
* 命中签名时多打 `probe:"posture"
|
|
53
|
+
* 命中签名时多打 `probe:"posture"`,免得把自家自检误判成入侵(判据见 isPostureProbe:五要素全中)。
|
|
54
54
|
*/
|
|
55
55
|
export const POSTURE_PROBE_PATH = '/api/session.list';
|
|
56
56
|
/**
|
|
57
|
-
* 「无 UA
|
|
57
|
+
* 「无 UA 行」的通道标记:请求连 `user-agent` 头都没有 ⇒ **不是浏览器直连**。
|
|
58
58
|
* 但这一类**混装两种调用者** —— 进程内客户端(boot 批量的内部 RPC)与经远端通道代理进来的
|
|
59
59
|
* 设备(手机/平板/物理机,代理按设计不转发 UA)。故本字段只陈述"非浏览器通道"这一**事实**,
|
|
60
|
-
* **不指认是哪一台设备**;要分辨两者得看端点语义(
|
|
60
|
+
* **不指认是哪一台设备**;要分辨两者得看端点语义(本字段只陈述事实,归因留给调用方)。
|
|
61
61
|
*/
|
|
62
62
|
export const CHANNEL_NO_UA = 'no-ua';
|
|
63
63
|
|
|
@@ -69,8 +69,15 @@ export const ACCESS_LOG_DEFAULTS = {
|
|
|
69
69
|
maxTotalBytes: 32 * 1024 * 1024,
|
|
70
70
|
/** UA 截断长度:UA 能区分浏览器/脚本/哪台设备,但整条太长。 */
|
|
71
71
|
uaMax: 200,
|
|
72
|
-
/**
|
|
73
|
-
|
|
72
|
+
/**
|
|
73
|
+
* 携带已配对设备标识的 cookie 名(= settings.yaml 的 remote-web-ui.cookieName)。
|
|
74
|
+
* **出厂默认 = 上游 remote-web-ui 自己的默认值 `dsh_pair`**(`dsh-remote-web-ui/src/index.ts`
|
|
75
|
+
* 两处 `.default('dsh_pair')`)—— **绝不写任何一台机器的私有改名**:cookie 名是**每台机器**的
|
|
76
|
+
* 设置,把某一台的改名当默认值有两个后果:① 这行随包公开;② 别人机器上 `deviceCookie` 恒为
|
|
77
|
+
* false、设备维度**静默失效**(比"漏记"更坏 —— 它看起来像"这台设备没带 cookie")。
|
|
78
|
+
* 改过名的机器走 `resolveDeviceCookie()` 的两条通路(宿主配置 / 环境变量)覆盖,不必改代码。
|
|
79
|
+
*/
|
|
80
|
+
deviceCookie: 'dsh_pair',
|
|
74
81
|
/** 高危端点:命中则多打一个 risk 字段,便于 grep。 */
|
|
75
82
|
riskPatterns: ['settingRestart/restart', '/dsh-ling/'],
|
|
76
83
|
/** C-07:降级(写失败)后**重复告警**的最小间隔(毫秒);0 = 每次丢行都告警(测试用)。 */
|
|
@@ -154,8 +161,10 @@ export function cookieValueOf(headerValue, name) {
|
|
|
154
161
|
* 从配对 cookie 值里取设备 hash(多策略,取不到就返回 null 并如实记 present)。
|
|
155
162
|
* **权威形态(2026-09-25 定点核实)**:远端 UI 的配对实现
|
|
156
163
|
* 用它的随机令牌生成器产出 **32 位十六进制明文**,经它的路由层原样写进 cookie
|
|
157
|
-
* (`$DSH_HOME/remote-web-ui-devices.json` 的键实测同为 32-hex);cookie
|
|
158
|
-
*
|
|
164
|
+
* (`$DSH_HOME/remote-web-ui-devices.json` 的键实测同为 32-hex);cookie 名取
|
|
165
|
+
* `remote-web-ui.cookieName` —— 出厂默认 `dsh_pair`,**改过名的机器由配置或
|
|
166
|
+
* `DSH_LING_DEVICE_COOKIE` 指定**(见 `resolveDeviceCookie()`;默认值里不写任何一台机器的私有改名)。
|
|
167
|
+
* 多策略保留只为「不赌它改版」:先找 hex run,再试点分段。
|
|
159
168
|
*
|
|
160
169
|
* ⚠️ **返回值是凭据原文**(权威形态下即设备键本身,见 C-01):**只能喂给 `deviceIdDigest()`**,
|
|
161
170
|
* 绝不直接落盘、回显或进错误信息。
|
|
@@ -237,6 +246,31 @@ export function rejectionReason(status, headers, req) {
|
|
|
237
246
|
return null;
|
|
238
247
|
}
|
|
239
248
|
|
|
249
|
+
/**
|
|
250
|
+
* 设备 cookie 名的**三级解析**:显式选项 → 环境变量 `DSH_LING_DEVICE_COOKIE` → 出厂默认。
|
|
251
|
+
*
|
|
252
|
+
* 为什么要有这个函数(2026-10-01 修红队 CR-1):本模块曾把**某一台机器改过名的值**当成出厂
|
|
253
|
+
* 默认值,而 cookie 名是 `remote-web-ui.cookieName`(**每台机器**各自的设置)—— 于是那个默认值
|
|
254
|
+
* 在别人的机器上恒不匹配(`deviceCookie` 恒 false、设备维度静默失效),并且随包公开。
|
|
255
|
+
* 只把默认值中性化**还不够**:改过名的那台机器(含作者本机)会因此被弄坏 ⇒ 必须同时提供
|
|
256
|
+
* 两条"不改代码就能恢复"的通路:
|
|
257
|
+
* · `options.deviceCookie`(宿主配置 `accessLog.deviceCookie`)—— 显式配置最优先;
|
|
258
|
+
* · `DSH_LING_DEVICE_COOKIE` 环境变量 —— 不想动配置文件时的单行开关;
|
|
259
|
+
* · 出厂默认(上游 remote-web-ui 的默认名)—— 别人机器开箱即对。
|
|
260
|
+
* 空串 / 纯空白 / 非字符串一律按"没设"处理(与旧写法 `options.deviceCookie || 默认` 一致)。
|
|
261
|
+
*
|
|
262
|
+
* @param {object} [options] 见 {@link ACCESS_LOG_DEFAULTS}
|
|
263
|
+
* @param {object} [env] 环境变量来源(测试可注入;默认 `process.env`)
|
|
264
|
+
*/
|
|
265
|
+
export function resolveDeviceCookie(options = {}, env = process.env) {
|
|
266
|
+
const pick = (v) => (v === undefined || v === null ? '' : String(v).trim());
|
|
267
|
+
return (
|
|
268
|
+
pick(options.deviceCookie) ||
|
|
269
|
+
pick(env && env.DSH_LING_DEVICE_COOKIE) ||
|
|
270
|
+
ACCESS_LOG_DEFAULTS.deviceCookie
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
240
274
|
/**
|
|
241
275
|
* 建一个访问日志器。所有方法都不抛(内部全兜)。
|
|
242
276
|
* @param {object} options 见 {@link ACCESS_LOG_DEFAULTS}(dir 必填)
|
|
@@ -247,7 +281,7 @@ export function createAccessLog(options = {}) {
|
|
|
247
281
|
? Number(options.maxTotalBytes)
|
|
248
282
|
: ACCESS_LOG_DEFAULTS.maxTotalBytes;
|
|
249
283
|
const uaMax = Number.isFinite(Number(options.uaMax)) ? Number(options.uaMax) : ACCESS_LOG_DEFAULTS.uaMax;
|
|
250
|
-
const deviceCookie =
|
|
284
|
+
const deviceCookie = resolveDeviceCookie(options);
|
|
251
285
|
const riskPatterns = (Array.isArray(options.riskPatterns) ? options.riskPatterns : ACCESS_LOG_DEFAULTS.riskPatterns)
|
|
252
286
|
.filter((p) => typeof p === 'string' && p);
|
|
253
287
|
const degradeWarnMs = Number.isFinite(Number(options.degradeWarnMs))
|
|
@@ -493,7 +527,7 @@ export function createAccessLog(options = {}) {
|
|
|
493
527
|
}
|
|
494
528
|
|
|
495
529
|
/**
|
|
496
|
-
*
|
|
530
|
+
* 姿态自检探针的**五要素签名**:POST + 路径 + 本机回环来源 + 无 UA + 被 403 拦下。
|
|
497
531
|
* 五条同时命中才标注 —— 放宽任何一条都会把真攻击错标成"自检"(内部踩坑记录 B3:判据过宽)。
|
|
498
532
|
* 标注**不代表放行**:被标注的行仍然是"被栅栏拦下"的记录,status 依旧是 403。
|
|
499
533
|
*/
|
package/lib/audit/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// E4 访问日志 · 插件侧安装器
|
|
2
2
|
//
|
|
3
3
|
// 职责:把日志器挂到 core 补丁预留的两个全局钩子上,并在卸载时干净摘除。
|
|
4
|
-
//
|
|
4
|
+
// 不做的事:不碰鉴权判定、不碰记忆与人格数据。
|
|
5
5
|
//
|
|
6
6
|
// 一个重要性质:**没有补丁也安全**。钩子只是挂在 globalThis 上的可选调用,
|
|
7
7
|
// 补丁那一行写的是 `globalThis.__dshAccessLogObserve?.(…)` —— 插件在不在、
|
|
@@ -111,7 +111,7 @@ export function installAccessLog(ctx, cfg = {}) {
|
|
|
111
111
|
|
|
112
112
|
const info = log.info();
|
|
113
113
|
console.info('[dsh-ling] access log on; dir=%s deviceCookie=%s', info.dir, info.deviceCookie);
|
|
114
|
-
// 补丁没打时钩子永远收不到调用 —— 这是"静默不记"
|
|
114
|
+
// 补丁没打时钩子永远收不到调用 —— 这是"静默不记"的唯一形态,必须主动喊出来(就下面这一句 warn)。
|
|
115
115
|
const state = readPatchState();
|
|
116
116
|
if (!state || state.marker !== PATCH_MARKER) {
|
|
117
117
|
console.warn('[dsh-ling] core patch missing → run: node tools/apply-access-log-patch.mjs');
|
package/lib/audit/patch-spec.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// 而"该插什么、插在哪、怎么认出来"必须只有一处定义 —— 否则
|
|
5
5
|
// 补丁脚本与插件侧的"补丁在不在"检查会各自漂移。
|
|
6
6
|
//
|
|
7
|
-
//
|
|
7
|
+
// 补丁形态的原则(从本文件"只观察不判定"这条边界延伸出来):
|
|
8
8
|
// · **一行 hooks**:core 只多一行 `globalThis.__dshAccessLogXxx?.(…)`,
|
|
9
9
|
// 实现全在本插件里 ⇒ DSH 升级后重打一次,逻辑零改动。
|
|
10
10
|
// · **可选链兜底**:插件没装 / 被卸载时,这一行是 no-op,DSH 照常启动。
|