@nightpainters/dsh-ling 1.5.1 → 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 +10 -7
- package/CHANGELOG.md +140 -3
- package/DESIGN.md +162 -18
- package/README.md +66 -32
- package/lib/audit/access-log.js +181 -25
- package/lib/audit/index.js +45 -3
- package/lib/audit/patch-spec.js +1 -1
- package/lib/client.js +1183 -88
- package/lib/host/api.js +716 -83
- package/lib/host/backfill.js +8 -2
- package/lib/host/feedback.js +112 -2
- package/lib/host/freeze.js +52 -1
- package/lib/host/genesis.js +27 -5
- package/lib/host/guard.js +39 -2
- 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/inject.js +49 -12
- package/lib/host/l1.js +11 -4
- package/lib/host/lifecycle.js +13 -2
- package/lib/host/memory.js +425 -33
- package/lib/host/mode-service.js +85 -0
- package/lib/host/mode.js +29 -9
- package/lib/host/persona.js +38 -7
- package/lib/host/retitle.js +6 -0
- package/lib/host/session-line.js +688 -0
- package/lib/host/snapshot.js +48 -7
- package/lib/host/summarizer.js +159 -3
- 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 +22 -29
- package/package.json +51 -4
- package/tools/apply-access-log-patch.mjs +1 -1
package/lib/host/api.js
CHANGED
|
@@ -5,6 +5,7 @@ import { svc, pick, isoDate } from './util.js';
|
|
|
5
5
|
import { assemblePersona, pronounOf, pronounize } from './persona.js';
|
|
6
6
|
import { applyModeToSession, currentMode, currentModelSelection, MODE_LABEL, resolveModeConfig } from './mode.js';
|
|
7
7
|
import { invalidateSession, refreshStats } from './inject.js';
|
|
8
|
+
import { sessionLineProbe, sessionLineState } from './session-line.js'; // E3(2026-09-27):sessionLineState 回到面板档的 `sessionLine` 字段;轮询档仍一个字节都不带
|
|
8
9
|
import { selectL1 } from './l1.js';
|
|
9
10
|
import { applySuggestionAsRule, dismissSuggestion } from './feedback.js';
|
|
10
11
|
import { applyCorpusSuggestion, dismissCorpusSuggestion } from './suggestions.js';
|
|
@@ -16,8 +17,14 @@ import { applyImportItems } from './import-file.js';
|
|
|
16
17
|
import { sealOk, KEY_MIN } from './seal.js';
|
|
17
18
|
import { timeAnchorDate, timeAnchorLive } from './clock.js';
|
|
18
19
|
import { TONE_ADVICE_SYS, TONE_SET, sampleToneRows, parseToneAdvice, appendStyleNote } from './tone-advice.js';
|
|
19
|
-
import { logImport, listImportLog, formatLogLine } from './import-log.js';
|
|
20
|
+
import { logImport, listImportLog, formatLogLine, cleanTooBig, cleanTooBigNames, cleanFoldSkipped } from './import-log.js';
|
|
20
21
|
import { checkRequest, lanAddresses, sanitizePersonaPatch } from './guard.js';
|
|
22
|
+
// 手术门(2026-09-30 深夜):本机判据 / 每启动票据 / 承诺句 / 哈希链留痕 —— 判据本体只在 surgery.js 一处。
|
|
23
|
+
// ⚠️ 上面那行 guard.js 的 import **不许动形态**:tests/guard.test.mjs:139 逐字匹配它。
|
|
24
|
+
import {
|
|
25
|
+
gateSurgery, isLocalSurgeryRequest, readSurgeryTicket, rotateSurgeryTicket,
|
|
26
|
+
listSurgeryEvents,
|
|
27
|
+
} from './surgery.js';
|
|
21
28
|
import {
|
|
22
29
|
mapBranchList, convExistsIn,
|
|
23
30
|
opBranchCreate, opBranchDelete, opBranchReparent, opBranchRename, opBranchWeight,
|
|
@@ -39,6 +46,10 @@ import {
|
|
|
39
46
|
} from './dsweb-summary.js';
|
|
40
47
|
import { TRUNK_ID } from './memory.js';
|
|
41
48
|
import { bucketize, nameBucket } from './autotree.js';
|
|
49
|
+
import { accessLogStatus } from '../audit/index.js';
|
|
50
|
+
// E2 可见化(2026-10-01):只取**键名常量** —— 那条留痕的键名与写法归 summarizer.js 一处所有,
|
|
51
|
+
// 本文件不另拼一份字符串(两处各写一份键名,将来必然漂成两个键)。
|
|
52
|
+
import { RAW_UNKNOWN_NS_KEY } from './summarizer.js';
|
|
42
53
|
|
|
43
54
|
// (旧)只校验 cookie 名形状的守卫已废弃 —— 见 guard.js:来源栅栏 + 名字对撞(2026-09-16 加固 ①+②)
|
|
44
55
|
// 网页端聊天记录库的默认路径:私有路径不写死在代码里 —— 由 UI 传入,或用 DSH_LING_DSWEB_DB 环境变量覆盖。
|
|
@@ -60,6 +71,111 @@ function sendJson(res, status, body) {
|
|
|
60
71
|
* ② /memories/pin|delete 会拒收它 ⇒ 主人无法置顶或删除这类记忆。 */
|
|
61
72
|
const MEM_SOURCES = ['dsh', 'dsweb', 'import', 'vein'];
|
|
62
73
|
|
|
74
|
+
/** E3(审计 H-4):会话内调用**没有原话**时,规矩的原话位写的如实标注。
|
|
75
|
+
* 它**不是原话**,是"没有原话"这件事本身 —— 仿 `rules.js` 自己的「(面板直接写入)」惯例。 */
|
|
76
|
+
export const SESSION_NO_QUOTE_NOTE = '(会话内调用,未附原话)';
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* E3(审计 H-4,2026-10-01 修):规矩落库的**归因判据** —— 全仓唯一一处。
|
|
80
|
+
*
|
|
81
|
+
* 旧写法是 `source: body.quote ? 'session' : 'panel'` —— 拿**客户端可控字段** `body.quote`
|
|
82
|
+
* 反推"这条规矩是谁提的"。而**真实面板本来就不发 quote**(`lib/client.js` 的表单发的正是
|
|
83
|
+
* `{ action:'add', rule }`)⇒ 面板确实靠"没有 quote"被认出来,但**同一个形状也把
|
|
84
|
+
* 「会话内、不带 quote 的 HTTP 调用」一起吞成了 `'panel'`**。后果不是文案问题:
|
|
85
|
+
* `rules.js` 对 `'panel'` 免掉原话要求,并把原话位填成「(面板直接写入)」——
|
|
86
|
+
* 于是一次**没有原话、也没有主人在场**的会话内调用,被记成"主人亲手写的那一条",
|
|
87
|
+
* 留痕从此不能用来回答"这条规矩是谁提的"(审计 §H.1 溯源格 🟡 的实锤之一)。
|
|
88
|
+
*
|
|
89
|
+
* 两种归因是两回事,这里分开判 —— 判据只看**请求形状**,不再拿 quote 有无去反推身份:
|
|
90
|
+
* · `'panel'` = 主人在**面板**里亲手敲的。面板的形状 = 既没有会话身份、也没有原话;
|
|
91
|
+
* 他自己敲的就是同意,故**不要求**另附原话(`rules.js` 既有语义,本轮未动);
|
|
92
|
+
* · `'session'` = **会话内**代写,且带了原话 ⇒ 「经同意」可核查;
|
|
93
|
+
* · `'session-no-quote'` = **会话内**调用但**没带原话** ⇒ 手里没有「经同意」的任何凭据。
|
|
94
|
+
* 如实记这个标记,原话位写 `SESSION_NO_QUOTE_NOTE` ⇒ 不冒充面板、也不冒充同意。
|
|
95
|
+
*
|
|
96
|
+
* ⚠️ 面板身份是**形状识别,不是签名**:本机调用方仍可照抄这个形状(面板那一侧今天没有签名可用)。
|
|
97
|
+
* 要变成不可冒充,得让面板带上只有它才有的凭据 —— 那要改 `lib/client.js`,不在本轮可写清单内,
|
|
98
|
+
* 已列入 1.6 的残余风险。
|
|
99
|
+
* @param {{sessionId?:unknown, quote?:unknown}} [body] 端点收到的 JSON body
|
|
100
|
+
* @returns {{source:'panel'|'session'|'session-no-quote', sessionId:string, quote:string, fromPanel:boolean}}
|
|
101
|
+
*/
|
|
102
|
+
export function ruleWriteAttribution(body = {}) {
|
|
103
|
+
const sid = String(body?.sessionId ?? '').trim();
|
|
104
|
+
const quote = String(body?.quote ?? '').trim();
|
|
105
|
+
const fromPanel = !sid && !quote; // = client.js 面板表单的形状(它既不发 sessionId,也不发 quote)
|
|
106
|
+
const source = fromPanel ? 'panel' : (quote ? 'session' : 'session-no-quote');
|
|
107
|
+
return {
|
|
108
|
+
fromPanel,
|
|
109
|
+
source,
|
|
110
|
+
// 面板写入没有会话身份 ⇒ 留痕仍写 'panel'(可读的"这不是某个会话");会话内调用写真实 id。
|
|
111
|
+
sessionId: fromPanel ? 'panel' : sid,
|
|
112
|
+
// 原话位**只说真话**:面板传空串,由 rules.js 填它自己的「(面板直接写入)」(那一步未改);
|
|
113
|
+
// 会话内无原话时填如实标注 ⇒ 这次写入**照旧落库**(与改前一致:改前它也落库,只是被贴成
|
|
114
|
+
// 'panel'),但记录里写清了"没有原话"。若将来要**直接拒收**这一支,判据在 rules.js 的
|
|
115
|
+
// no-quote(它只认 src 是否为空)—— 那要动 rules.js,不在本轮可写清单内。
|
|
116
|
+
//
|
|
117
|
+
// ⚠️ **实测过的边界(别把它读成"没做")**:`ruleMeta.source` 落库时只有 `'panel'` / `'session'`
|
|
118
|
+
// 两个值 —— rules.js 组装 meta 的那一行是 `source: fromPanel ? 'panel' : 'session'`,
|
|
119
|
+
// **非 panel 一律折成 'session'** ⇒ 上面第三个值 `'session-no-quote'` 到不了库里的
|
|
120
|
+
// `source` 字段(它活在**判据与回执**里:/persona/rule 的响应 `source` / `unquoted`)。
|
|
121
|
+
// 库里"这次没有原话"由**原话位**承载(`SESSION_NO_QUOTE_NOTE`),面板看得见。
|
|
122
|
+
// 要把三态带进 `ruleMeta`,得改 rules.js 那一行 —— 不在本轮可写清单,已列入残余风险。
|
|
123
|
+
quote: fromPanel ? '' : (quote || SESSION_NO_QUOTE_NOTE),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** 路径归一(只用于**白名单比对**,不碰文件系统):分隔符统一 / 尾斜杠去掉;
|
|
128
|
+
* Windows 再折大小写(盘符与文件名都不区分大小写)。== 只归一化,不解析 `.`/`..` ==:
|
|
129
|
+
* 归一化只会让"管理员写的路径"与"请求里的同一路径"对上,不会让白名单外的路径对上。 */
|
|
130
|
+
const normDbPath = (p) => {
|
|
131
|
+
const s = String(p ?? '').trim().replace(/\\/g, '/').replace(/\/+$/u, '');
|
|
132
|
+
return process.platform === 'win32' ? s.toLowerCase() : s;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/** B(审计 §四.4,2026-10-01 修):`/dsweb/summary/preview?db=` 的**准入白名单** —— 全仓唯一一处。
|
|
136
|
+
*
|
|
137
|
+
* 旧写法是 `dswebDbOf(q.get('db'))`(收任意路径)→ `openSource(db)` 只读打开 ⇒ 这是一个
|
|
138
|
+
* **任意本机路径的存在性预言机**:问一句就知道某个 `.db` 在不在、里面有多少条与记忆库对得上的会话。
|
|
139
|
+
* 审计把它列进"资源型"面时特别注明:它**今天靠 `readOnly:true` 才不是写原语** ——
|
|
140
|
+
* 也就是整个安全性挂在一个 flag 上(把这个 flag 去掉就是一条任意文件的写原语)。
|
|
141
|
+
*
|
|
142
|
+
* 判据:只接受**默认库**(环境变量 `DSH_LING_DSWEB_DB`)**或 settings 白名单**里的路径;
|
|
143
|
+
* 其余一律拒并给出 `reason`(**不静默**),让调用方能区分:
|
|
144
|
+
* · `no-db` = 你没给路径(且没有默认库)—— 文案与改前逐字一致;
|
|
145
|
+
* · `db-not-allowed` = 你给的路径不许(白名单外)。
|
|
146
|
+
*
|
|
147
|
+
* 白名单键(`settings.dsweb`):`dbPath` / `db`(单值)、`dbs[]` / `dbPaths[]`(数组)。
|
|
148
|
+
* 认这几个拼法是因为这套键**到今天还没有默认值**(`persona.js` 的 DEFAULT_SETTINGS 里没有
|
|
149
|
+
* `dsweb` 子树 ⇒ 不存在"既有约定"可循),所以这里不发明新配置面,只采最直白的写法。
|
|
150
|
+
* ⚠️ 面板(`client.js`)的 db 是**输入框手填**的 ⇒ 收紧后手填的路径会被拒 ——
|
|
151
|
+
* 故端点里的拒绝文案必须给出两条出路(env / settings),否则就是"静默关功能",
|
|
152
|
+
* 与审计"别静默"的要求正好相反。
|
|
153
|
+
*
|
|
154
|
+
* @param {unknown} value 请求里的 `?db=`
|
|
155
|
+
* @param {{get?:Function}|object} settings settings 服务或裸配置对象
|
|
156
|
+
* @param {Record<string,string|undefined>} [env] 环境变量来源(测试注入,不碰真机 env)
|
|
157
|
+
* @returns {{ok:boolean, db:string, allowed:string[], asked?:string, reason?:string}}
|
|
158
|
+
*/
|
|
159
|
+
export function previewDbPolicy(value, settings, env = process.env) {
|
|
160
|
+
const asked = String(value ?? '').trim();
|
|
161
|
+
const cfg = (settings && typeof settings.get === 'function' ? settings.get() : settings) || {};
|
|
162
|
+
const d = cfg.dsweb || {};
|
|
163
|
+
const asList = (x) => (Array.isArray(x) ? x : []).map((s) => String(s || '').trim());
|
|
164
|
+
const envDb = String(env?.DSH_LING_DSWEB_DB || '').trim();
|
|
165
|
+
const allowed = [...new Set([
|
|
166
|
+
envDb,
|
|
167
|
+
String(d.dbPath || '').trim(),
|
|
168
|
+
String(d.db || '').trim(),
|
|
169
|
+
...asList(d.dbs),
|
|
170
|
+
...asList(d.dbPaths),
|
|
171
|
+
].filter(Boolean))];
|
|
172
|
+
const hit = (p) => allowed.some((a) => normDbPath(a) === normDbPath(p));
|
|
173
|
+
// 没给 db:照旧用默认库(默认库为空时由调用方回既有的 no-db 文案,与改前逐字一致)
|
|
174
|
+
if (!asked) return { ok: true, db: envDb, allowed };
|
|
175
|
+
if (hit(asked)) return { ok: true, db: asked, allowed };
|
|
176
|
+
return { ok: false, db: '', allowed, asked, reason: 'db-not-allowed' };
|
|
177
|
+
}
|
|
178
|
+
|
|
63
179
|
/** ⚠️ **写操作命中 0 行的"静默失败"**(审计 B#3,2026-09-22 修):
|
|
64
180
|
* 底层 `renameBranch` / `setBranchWeight` / `reparentBranch` 用的都是 `UPDATE ... WHERE id=?`
|
|
65
181
|
* —— id 不存在时 SQLite 命中 0 行、`changes=0`,可方法仍回 `{ok:true}`。端点据此写了
|
|
@@ -134,27 +250,141 @@ function batchLeave(kind) {
|
|
|
134
250
|
/** 整批 deadline 判据:到点即**不再开始新的一条**。 */
|
|
135
251
|
const batchTimeout = (kind, t0) => (Date.now() - t0) >= (BATCH_MS[kind] || 120000);
|
|
136
252
|
|
|
137
|
-
|
|
253
|
+
/** ⚠️ **请求体上限的入口**(2026-09-29 修:一次「批失败(网关)」误报的根治点)。
|
|
254
|
+
* 旧实现把"超限"处理成一句话:`size > maxBytes` ⇒ `reject(...)` **同一拍** `req.destroy()`。
|
|
255
|
+
* 代价(同批实测里最主要的放大器):**那句现成的错误文案永远送不出去** —— 响应还没写连接就没了,
|
|
256
|
+
* 浏览器只看到 `ECONNRESET`,前端 `.catch` 把它显示成「批失败(网关)」(`client.js:3850-3854`),
|
|
257
|
+
* 把排查方向引向网络/网关/模型;而真因只是本机同一进程内的一道体量闸门。
|
|
258
|
+
*
|
|
259
|
+
* 改判版修法:超限后**不砍连接、不立刻 reject**,改为
|
|
260
|
+
* ① 立刻停止缓存(已缓存的那几 MiB 也丢掉,不抱着它等到 end);
|
|
261
|
+
* ② 继续消费 `data`、只累加计数(排空);
|
|
262
|
+
* ③ 等 `'end'` 到达、连接健康时再 reject 一个 `code='BODY_TOO_LARGE'` / `status=413` 的错误,
|
|
263
|
+
* 由下面的 `guard` 统一翻成 `sendJson(res, 413, { reason:'too-large' })` —— 文案因此真的有通路。
|
|
264
|
+
* 排空换来的两个新代价必须各有一道闸,就是下面这两个常量。 */
|
|
265
|
+
const BODY_MAX_BYTES = 4 * 1024 * 1024;
|
|
266
|
+
/** 排空态的**硬熔断**:真实字节数超过它 ⇒ 立刻 `destroy()` + reject。
|
|
267
|
+
* 取 64 MiB = 上限的 16 倍。理由:`drainMax` 不是"允许多大",而是**给一次真实超限请求留够
|
|
268
|
+
* 把 body 发完的余量** —— 现实里最大的一份导出 ≈4.9 MB(带原文的包实测 4.66 MB),
|
|
269
|
+
* 余量不够就会把"排空"本身变成新的失败:客户端拿到的仍是 reset 而不是 413,白改一场。
|
|
270
|
+
* 同时它把最坏代价钉死:单个请求最多让我们多读 64 MiB(**纯丢弃、不缓存**)、多占一个计数变量。
|
|
271
|
+
* 取更小会误伤大文件,取更大等于把"拒绝之后还能无限灌数据"当免费午餐。 */
|
|
272
|
+
const BODY_DRAIN_MAX_BYTES = 64 * 1024 * 1024;
|
|
273
|
+
/** 排空态的**静默超时**:连续这么久收不到任何 chunk ⇒ `destroy()` + reject。
|
|
274
|
+
* 这是**替代原 `req.destroy()` 的那条防 DoS 动机**(修法原先只说"先响应再断开",
|
|
275
|
+
* 没说清"对端发到一半就不发了怎么办"):只要还有数据在流,就说明对端在正常发,不算攻击;
|
|
276
|
+
* 一旦静默,就是僵尸/半开连接,必须收掉 —— 否则"发 4 MiB 然后挂着不动"的连接能把
|
|
277
|
+
* 请求、socket 和那段已丢缓存一起永久占住。
|
|
278
|
+
* 取 30 s:与浏览器/网关的"无进展容忍时间"同量级,且远大于一次 64 MiB 的正常传输耗时。
|
|
279
|
+
* 计时器**每收到一个 chunk 就重置**(滑动窗口,**不是**总时长上限)——
|
|
280
|
+
* 慢但持续的发送不会被误杀,只有真静默才会。 */
|
|
281
|
+
const BODY_DRAIN_IDLE_MS = 30000;
|
|
282
|
+
|
|
283
|
+
/** 读满一个请求体。**导出只为可测**(`tests/bodylimit.test.mjs` 直接驱动它);
|
|
284
|
+
* 49 处调用点一律只传 `req`,签名向后兼容:第二参默认值 = 上限常量,第三参是新增可选项。
|
|
285
|
+
*
|
|
286
|
+
* ⚠️ 不许省的三条纪律:
|
|
287
|
+
* · 计时器在**三条出口**都要 `clearTimeout`(`'end'` / `'error'` / 熔断),不留悬挂 timer;
|
|
288
|
+
* · reject **只能发生一次**(`settled` 标志)—— 熔断之后 socket 上的 `'error'`/`'end'` 还会陆续
|
|
289
|
+
* 到达,重复 reject 对 Promise 虽无害,但会让"谁才是真因"变得不可判;
|
|
290
|
+
* · **首字节之前也要有计时器**(2026-09-29 红队 E5):计时器原先只在 `data` 里武装
|
|
291
|
+
* ⇒「正确 cookie + `Content-Length: 4 MiB` + **实发 0 字节**」这条形状在应用层**没有任何计时器**
|
|
292
|
+
* (红队实测 20000 ms 零响应),兜底只剩 Node 自己的 `requestTimeout`(默认 300 s)。见下面的首字节闸。
|
|
293
|
+
*/
|
|
294
|
+
export function readBody(req, maxBytes = BODY_MAX_BYTES, opts = {}) {
|
|
295
|
+
// 只认"正数"覆盖:0 / NaN / 负数一律回落默认值 —— 不发明"传 0 = 关掉闸门"这类隐藏开关,
|
|
296
|
+
// 免得将来有人以为能靠一个 0 把 DoS 口子拆掉。
|
|
297
|
+
const drainMax = (Number.isFinite(opts.drainMax) && opts.drainMax > 0) ? opts.drainMax : BODY_DRAIN_MAX_BYTES;
|
|
298
|
+
const drainIdleMs = (Number.isFinite(opts.drainIdleMs) && opts.drainIdleMs > 0) ? opts.drainIdleMs : BODY_DRAIN_IDLE_MS;
|
|
138
299
|
return new Promise((resolve, reject) => {
|
|
139
300
|
const chunks = [];
|
|
140
|
-
let size = 0;
|
|
301
|
+
let size = 0; // 真实字节数:排空态里**继续增长**,`got` 报的就是它
|
|
302
|
+
let settled = false;
|
|
303
|
+
let draining = false; // 是否已进入排空态(超限第一拍置位,只置一次)
|
|
304
|
+
let firstByteSeen = false; // 首字节到没到 —— 首字节闸的解除条件(见下面 data 分支的第一行)
|
|
305
|
+
let timer = null;
|
|
306
|
+
|
|
307
|
+
const disarm = () => { if (timer !== null) { clearTimeout(timer); timer = null; } };
|
|
308
|
+
/** 唯一的 reject 出口:settled 一旦为真,后面所有 reject 都是 no-op。 */
|
|
309
|
+
const fail = (err) => {
|
|
310
|
+
if (settled) return;
|
|
311
|
+
settled = true;
|
|
312
|
+
disarm();
|
|
313
|
+
reject(err);
|
|
314
|
+
};
|
|
315
|
+
/** 413 错误对象:`message` **逐字沿用旧文案**(不换措辞),其余字段给 guard 用。 */
|
|
316
|
+
const tooLarge = () => {
|
|
317
|
+
const e = new Error('单次请求体过大(超过 ' + Math.round(maxBytes / 1024 / 1024) + 'MB 上限),请拆分后重试');
|
|
318
|
+
e.code = 'BODY_TOO_LARGE';
|
|
319
|
+
e.status = 413;
|
|
320
|
+
e.limit = maxBytes;
|
|
321
|
+
e.got = size;
|
|
322
|
+
return e;
|
|
323
|
+
};
|
|
324
|
+
/** **首字节闸**的错误对象(红队 E5,2026-09-29)。**故意不复用 `BODY_TOO_LARGE`**:
|
|
325
|
+
* 这一条不是"体量"问题 —— 一个字节都没发,连"多大"都无从谈起(`got` 恒为 0、**没有 `limit`**);
|
|
326
|
+
* 拿 413 去说它,等于对着一个空连接喊"超过 4MB 上限"。它说的是"对端发完请求头就停住了"
|
|
327
|
+
* (半开/僵尸连接),故另起一个 code、配 408 语义。
|
|
328
|
+
* 两种静默必须分得开,否则"到底谁没发数据"不可判:
|
|
329
|
+
* · 本函数 = **首字节之前**就静默(从没开始);
|
|
330
|
+
* · `tooLarge()` 那次(排空态) = **发到一半**停了(`size` 已 > `maxBytes`)。
|
|
331
|
+
* 文案里的毫秒数取自**同一个可注入参数**:真机 30 s,测试注入 20 ms 时也就不撒谎。 */
|
|
332
|
+
const noFirstByte = () => {
|
|
333
|
+
const e = new Error('已收到请求头,但 ' + drainIdleMs + ' ms 内没有收到任何请求体数据(对端只发了请求头、不发体),已断开连接');
|
|
334
|
+
e.code = 'BODY_IDLE_NO_FIRST_BYTE';
|
|
335
|
+
e.status = 408;
|
|
336
|
+
e.idleMs = drainIdleMs;
|
|
337
|
+
e.got = size; // 恒为 0:正因为它不是体量问题
|
|
338
|
+
return e;
|
|
339
|
+
};
|
|
340
|
+
/** **唯一**的计时器工厂:同一时刻只有一个计时器在跑 —— 首字节闸(只活在首字节之前)与
|
|
341
|
+
* 排空闸(只在超限之后)在时间上互斥,所以共用一个槽位、共用一个 `disarm()`。
|
|
342
|
+
* `onIdle` 决定"这次静默意味着什么"(见上面两个错误对象的区别),`req.destroy()` 是两条
|
|
343
|
+
* 静默路的共同动作(静默 = 僵尸/半开连接,必须收掉)。计时器**每收一个 chunk 就重置**
|
|
344
|
+
* (滑动窗口,**不是**总时长上限)⇒ 慢但持续的合法上传不会被误杀。 */
|
|
345
|
+
const arm = (onIdle) => {
|
|
346
|
+
disarm();
|
|
347
|
+
timer = setTimeout(() => { onIdle(); req.destroy(); }, drainIdleMs);
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
// ── 首字节闸:进函数就武装,且必须在任何 `req.on(` 注册**之前**(那时才有"还没开始"可言)──
|
|
351
|
+
// 旧形状的代价:`arm()` 只在 `data` 里武装 ⇒ "只发头、不发体"的请求让这个 Promise **永远挂着**,
|
|
352
|
+
// 连带请求与 socket 一起占住(请求体读取既没有超时,连接也没有)。修法就是这一句。
|
|
353
|
+
arm(() => fail(noFirstByte()));
|
|
354
|
+
|
|
141
355
|
req.on('data', (c) => {
|
|
356
|
+
// 首字节到了 ⇒ 首字节闸的使命结束,**必须**在这里解除:留下来它会在**正常传输**里继续计时,
|
|
357
|
+
// 把"慢但持续的合法上传"误杀成超时(正常路径此后**不**武装任何计时器)。
|
|
358
|
+
if (!firstByteSeen) { firstByteSeen = true; disarm(); }
|
|
142
359
|
size += c.length;
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
360
|
+
// ── 正常路径(与旧实现行为逐字一致):只缓存没超限的 chunk ──────────────
|
|
361
|
+
if (size <= maxBytes) { chunks.push(c); return; }
|
|
362
|
+
// ── 超限第一拍:停缓存 **并丢弃已缓存的**(不抱着那几 MiB 等到 end)──────
|
|
363
|
+
if (!draining) { draining = true; chunks.length = 0; }
|
|
364
|
+
// ── 硬熔断:再灌下去就是真的流量攻击了 ─────────────────────────────────
|
|
365
|
+
// 先 fail 再 destroy:destroy() 若同步抛出 `'error'`(假 req、或将来 Node 改行为),
|
|
366
|
+
// 那个 error 也抢不走真因(`fail` 已被 settled 挡掉);旧代码同样是"先 reject 再 destroy"。
|
|
367
|
+
if (size > drainMax) { fail(tooLarge()); req.destroy(); return; }
|
|
368
|
+
arm(() => fail(tooLarge())); // 排空态的静默计时器(首字节闸已在上面的 data 分支解除)
|
|
149
369
|
});
|
|
150
|
-
req.on('end', () =>
|
|
151
|
-
|
|
370
|
+
req.on('end', () => {
|
|
371
|
+
disarm();
|
|
372
|
+
if (draining) { fail(tooLarge()); return; } // ← 连接健康:文案这次送得出去
|
|
373
|
+
resolve(Buffer.concat(chunks).toString('utf8'));
|
|
374
|
+
});
|
|
375
|
+
req.on('error', (e) => { disarm(); fail(e); });
|
|
152
376
|
});
|
|
153
377
|
}
|
|
154
378
|
|
|
155
379
|
export function registerApi(ctx, deps, config) {
|
|
156
380
|
const { gate, memory, settings } = deps;
|
|
157
381
|
|
|
382
|
+
// ── 手术门:票据**每启动轮换**(规格 §1.2)──────────────────────────────────────
|
|
383
|
+
// 这一句就是"每启动"的落点:插件随宿主启动装配一次 ⇒ 覆盖写一枚新的 32 字节随机票,
|
|
384
|
+
// 上一轮启动留下的票据**当场作废**(旧票仍留在文件里也没用:门比对的是本进程写下的那一枚)。
|
|
385
|
+
// 卸载/重装插件同样会重跑这里 ⇒ "轮换"跟着"装配"走,不需要额外生命周期钩子。
|
|
386
|
+
rotateSurgeryTicket();
|
|
387
|
+
|
|
158
388
|
// ---- 获取 webServer:直取 → inject 子作用域 → 定时重试 ----
|
|
159
389
|
let disposed = false;
|
|
160
390
|
let disposeRoutes = null;
|
|
@@ -231,7 +461,33 @@ export function registerApi(ctx, deps, config) {
|
|
|
231
461
|
'/deep/run-import', '/deep/run', '/deep/one',
|
|
232
462
|
'/dsweb/summary/run',
|
|
233
463
|
'/feedback/apply', '/feedback/dismiss', '/suggestions/apply', '/suggestions/dismiss',
|
|
464
|
+
// ── 1.5.3(2026-09-29 夜,用户批;依据作者的审计台账《1.5.3 端点写面 · 未进 POST_ONLY》,该台账不随包公开)──
|
|
465
|
+
// ⚠️ 版本号由主会话校正:本批**尚未发布**(`package.json` 仍是 `1.5.3-dev`)⇒ 归 **1.5.3**;
|
|
466
|
+
// 原先这里写的 `1.5.4` 是修复代理想当然的版本号,仓库里从无 1.5.4 的计划。
|
|
467
|
+
// 三条都是**形态上就不该由 GET 执行**的端点,补进同一处声明(仍守"一处维护、一眼看全"):
|
|
468
|
+
// · `/branch-log/pending`:`memory.branchLogPending()` 在"水位键缺失 + branch_log 非空"时会
|
|
469
|
+
// **一次性补种水位**(memory.js:1272 `kvSet('branch_log_wm.last', …)`)⇒ GET 能静默把
|
|
470
|
+
// "复盘守门"的欠账计数顶成 0(审计差集里**唯一一条真写**);
|
|
471
|
+
// · `/persona/draft`:handler 里 `await llmOnce(...)` ⇒ **GET 就能花一次全局模型额度**(不落盘);
|
|
472
|
+
// · `/persona/check`:handler 读 body(注释自称 POST),形态已像 POST 端点,防御性收口。
|
|
473
|
+
// 前端调用点**全部已核**(A:客户端本来就是 POST;C:本来就是 POST;B:三处已同步改 POST)。
|
|
474
|
+
'/branch-log/pending', '/persona/draft', '/persona/check',
|
|
475
|
+
// ── 1.5.3(2026-09-30 深夜,手术门)· 取票 = **发放凭据**的动作,形态上就不该由 GET 执行 ──
|
|
476
|
+
// `/surgery/ticket` 只对**本机直连**(无代理头 **且** 带 UA)发一枚 32 字节票;**不缓存**(no-store)—— 票据是每启动轮换的。
|
|
477
|
+
'/surgery/ticket',
|
|
234
478
|
]);
|
|
479
|
+
// ── 手术门下沉(2026-09-30 深夜;规格 §1.3「门下沉为一处」)──────────────────────────
|
|
480
|
+
// 判据本体**只在** lib/host/surgery.js 的 requireSurgery() 里;这里只是**唯一**的调用点。
|
|
481
|
+
// 覆盖面 = 全部 14 条能改人格/规矩/习惯的写面(逐条清单见 surgery.js 头部注释):挂在 guard(handler)
|
|
482
|
+
// 上就"一眼看全、零回归面",与 POST_ONLY / 413 出口同一个取舍 —— 而不是在 49 个 register 调用点各写一遍。
|
|
483
|
+
// 安全的位置:本调用在 POST_ONLY(405)与 guardVerdict(403)**之后** ⇒ 被挡下的请求绝不会走到 handler。
|
|
484
|
+
// ⚠️ 承诺句必须**惰性**取:多数写端点的载荷由 `await readBody(req)` 消费,而请求流**只能读一次** ——
|
|
485
|
+
// 门若在这里再读一遍,请求流就空了(那些端点会静默变成空 body)。
|
|
486
|
+
// ⇒ 这里**只**回一个"原文在哪"的函数;真正"从原文里抽承诺句"的规则在 surgery.js 的 rawUnlock()
|
|
487
|
+
// 一处(与判据同源,不在调用点各写一份正则)。
|
|
488
|
+
// `config.surgeryLog` = 留痕文件的**注入口**(测试用):默认落 $DSH_HOME/logs/surgery-YYYY-MM.jsonl,
|
|
489
|
+
// 测试可指向临时文件,免得"链是否完好"被上一段测试或真机留下的旧行干扰。
|
|
490
|
+
const surgeryLog = config?.surgeryLog;
|
|
235
491
|
const guard = (handler) => async (req, res) => {
|
|
236
492
|
// ── E4 覆盖缺口(C-02,1.5.1 红蓝对抗)──────────────────────────────────
|
|
237
493
|
// 器灵的端点在宿主路由表里注册为 **exact** 路由,先于 core 的 `/api` **前缀**路由命中
|
|
@@ -269,10 +525,30 @@ export function registerApi(ctx, deps, config) {
|
|
|
269
525
|
sendJson(res, 403, { ok: false, error: 'forbidden', reason: verdict.reason });
|
|
270
526
|
return;
|
|
271
527
|
}
|
|
528
|
+
// ── 手术门(2026-09-30 深夜)──────────────────────────────────────────────
|
|
529
|
+
// 判据本体只在 lib/host/surgery.js 的 requireSurgery() 一处;**各写端点不重判** ——
|
|
530
|
+
// 但调用点必须落在"载荷已经解析出来"的地方(见下),因为请求流只能读一次。
|
|
531
|
+
// 每个写端点的形状固定为:`const body = JSON.parse(…); if (!gateSurgery(req, res, sendJson, body, { settings, logFile: surgeryLog, endpoint: sub })) return;`
|
|
532
|
+
// · 这一句就是全部接线:判据(本机 / 每启动票据 / 承诺句)与拒绝回应都在 surgery.js;
|
|
533
|
+
// · 覆盖面 = 14 条写面,逐条清单与"改前是否过门"见 surgery.js 头部注释。
|
|
272
534
|
try {
|
|
273
535
|
await handler(req, res);
|
|
274
536
|
} catch (e) {
|
|
275
|
-
|
|
537
|
+
// ── 413 出口:一处覆盖**全部**端点(2026-09-29)────────────────────────
|
|
538
|
+
// `readBody` 超限时 reject 的 `BODY_TOO_LARGE` 到这里落地。写在这里而不是 49 个端点里:
|
|
539
|
+
// 与 POST_ONLY 同一个取舍 —— 一处维护、一眼看全、零回归面。响应体形态
|
|
540
|
+
// 沿用既定约定(`reason:'too-large'`),`limit`/`got` 让人能自己算差多少。
|
|
541
|
+
if (e && e.code === 'BODY_TOO_LARGE' && !res.writableEnded && !res.destroyed) {
|
|
542
|
+
return sendJson(res, 413, { ok: false, reason: 'too-large', message: String(e.message || ''), limit: e.limit, got: e.got });
|
|
543
|
+
}
|
|
544
|
+
// ⚠️ `res.destroyed` 是**硬要求**,不是保险:熔断 / 静默超时那两条路上 socket 已经没了
|
|
545
|
+
// (`readBody` 里 `req.destroy()`),再 `res.end()` 就是往死 socket 写。这里放下这一句,
|
|
546
|
+
// 那两条路的错误就只能"静默丢弃"——故与下面的 500 分支合流,只多一句带原因的 debug。
|
|
547
|
+
if (!res.writableEnded && !res.destroyed) {
|
|
548
|
+
sendJson(res, 500, { ok: false, error: String(e?.message ?? e) });
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
console.debug('[dsh-ling] response dropped (socket gone): %s', String(e?.message ?? e));
|
|
276
552
|
}
|
|
277
553
|
};
|
|
278
554
|
// 供状态/自检使用:本机 LAN 地址(G1 之后**不再**自动受信,仅用于提示"要不要显式打开 allowLan")
|
|
@@ -284,6 +560,31 @@ export function registerApi(ctx, deps, config) {
|
|
|
284
560
|
const register = (path, handler) =>
|
|
285
561
|
server.register({ kind: 'exact', path: '/api/dsh-ling' + path, handler: guard(handler) });
|
|
286
562
|
|
|
563
|
+
// ── 手术门 · 每启动票据的**发放口**(规格 §1.2)────────────────────────────────────
|
|
564
|
+
// 「只对**本机直连**的请求下发」(§1.1 已加强为「无代理头 **且** 带 UA」,见 surgery.js 文件头):
|
|
565
|
+
// 带 x-forwarded-for / x-real-ip 的请求、以及**没有 UA**(远端中继重建请求头时会丢 UA)的请求
|
|
566
|
+
// 连票都拿不到 —— 判据用 surgery.js 的 isLocalSurgeryRequest(与手术门 §1.1 **同一个来源**,不另写一份)。
|
|
567
|
+
// 票据每启动轮换(见 registerApi 里的 rotateSurgeryTicket()),旧票即废;
|
|
568
|
+
// 故响应带 `no-store`,免得中间层/浏览器把票缓存成"过期也能用"。
|
|
569
|
+
// 这条路是**凭据发放**,所以进了 POST_ONLY(GET 不得执行)。
|
|
570
|
+
disposers.push(register('/surgery/ticket', async (req, res) => {
|
|
571
|
+
if (!isLocalSurgeryRequest(req.headers)) {
|
|
572
|
+
return sendJson(res, 403, { ok: false, reason: 'surgery-local-only', message: '本机通行票据只发给本机直连的请求。' });
|
|
573
|
+
}
|
|
574
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
575
|
+
sendJson(res, 200, { ok: true, ticket: readSurgeryTicket(), local: true });
|
|
576
|
+
}));
|
|
577
|
+
|
|
578
|
+
// ── 手术门 · 只读留痕面(规格 §1.4)─────────────────────────────────────────────
|
|
579
|
+
// 供**主动感知层**将来消费(**本期只做读取,不做主动开口** —— 那是 Phase 2 的接口形状)。
|
|
580
|
+
// 过 guard(只有本机/受信 authority 能读);`limit` 默认 50、上限 500;
|
|
581
|
+
// 回执里带**链校验结果**(brokenAt:整链完好时为 null)⇒ 主人一眼能看出留痕有没有被动过。
|
|
582
|
+
disposers.push(register('/surgery/events', async (req, res) => {
|
|
583
|
+
const q = new URL(req.url, 'http://dsh.internal').searchParams;
|
|
584
|
+
const out = listSurgeryEvents(q.get('limit'), { file: surgeryLog });
|
|
585
|
+
sendJson(res, 200, { ok: true, ...out });
|
|
586
|
+
}));
|
|
587
|
+
|
|
287
588
|
/** 批量失效所有已定稿快照(规矩/习惯写入后调用;运行中的会话只标记 stale,空闲边界再生效)。 */
|
|
288
589
|
const invalidateAllSummaries = () =>
|
|
289
590
|
allSnapshotSessionIds(gate).map((sid) => ({ sessionId: sid, result: invalidateSession(gate, memory, settings, sid) }));
|
|
@@ -298,7 +599,31 @@ export function registerApi(ctx, deps, config) {
|
|
|
298
599
|
return anchor ? (base ? base + '\n\n' + anchor : anchor) : base;
|
|
299
600
|
};
|
|
300
601
|
|
|
301
|
-
|
|
602
|
+
/** §6.1(a) 读面**渠道分流**:承诺句只对本机直连回发。
|
|
603
|
+
* 判据 = `isLocalSurgeryRequest(headers)`(surgery.js)—— 它就是 §1.1 的**单一来源**,
|
|
604
|
+
* 与守卫⑤「伪造 loopback 必须被拒」那条、与手术门的 `surgery-local-only` **同源**。
|
|
605
|
+
* 本文件**不另写一份** IP / 代理头 / UA 判断(各写一份必然漂)。
|
|
606
|
+
* ⚠️ 2026-10-01 晚:判据已从「无代理头」加强为「**无代理头 且 带 UA**」——
|
|
607
|
+
* 远端面板的中继(remote-web-ui loopback-proxy)会重建请求头、把 UA 一并丢掉,
|
|
608
|
+
* 所以"没有 UA"正是那条路的指纹。本文件**一个字都不用改**:加强落在单一来源里,
|
|
609
|
+
* 三个读面(自动)跟着变严 —— 这正是"单一来源"的意义。
|
|
610
|
+
* ⚠️ 过滤只发生在**出站这一层**:已存档的历史条目、settings.json、库里的行**一个字节都不动**。 */
|
|
611
|
+
const readIsLocal = (req) => isLocalSurgeryRequest(req?.headers);
|
|
612
|
+
/** 非本机 ⇒ 从人格对象里剥掉承诺句、补 `hasSeal` 与 `phraseHidden`(**只读过滤,不改数据**)。
|
|
613
|
+
* ⚠️ 标记字段特意**不叫** `sealPhraseHidden`:名字里带 `sealPhrase` 子串,会让
|
|
614
|
+
* "远端响应体不含 `sealPhrase`"这条**子串**断言假红 —— 验收判据是"不含这个词",那就一个都不含。 */
|
|
615
|
+
const personaForRead = (p, local) => {
|
|
616
|
+
if (!p || typeof p !== 'object') return p ?? null;
|
|
617
|
+
if (local) return p;
|
|
618
|
+
const out = { ...p };
|
|
619
|
+
const had = !!out.sealPhrase;
|
|
620
|
+
delete out.sealPhrase;
|
|
621
|
+
out.hasSeal = had; // 剥掉内容,但"到底有没有这句"照样如实告诉远端(界面要用)
|
|
622
|
+
out.phraseHidden = had; // 客户端据此走"优雅退化"文案,而不是把"没这句"误当成"旧档案"
|
|
623
|
+
return out;
|
|
624
|
+
};
|
|
625
|
+
|
|
626
|
+
const personaInfo = (s, local = false) => ({
|
|
302
627
|
enabled: s?.persona?.enabled !== false,
|
|
303
628
|
userTitle: s?.persona?.userTitle ?? '',
|
|
304
629
|
aiName: s?.persona?.aiName ?? '',
|
|
@@ -315,8 +640,14 @@ export function registerApi(ctx, deps, config) {
|
|
|
315
640
|
bottomLines: Array.isArray(s?.persona?.bottomLines) ? s.persona.bottomLines : [],
|
|
316
641
|
sealed: s?.persona?.sealed === true,
|
|
317
642
|
hasSeal: !!s?.persona?.sealPhrase,
|
|
318
|
-
|
|
643
|
+
// §6.1(a):承诺句**只对本机直连**回发(经代理的读面一个字节都不给);远端另给
|
|
644
|
+
// `phraseHidden` 让界面能说清"这句只在本机显示"(而不是把"没这句"当成"旧档案没有明文")。
|
|
645
|
+
...(local
|
|
646
|
+
? { sealPhrase: s?.persona?.sealPhrase ?? '' } // 明文回显用(锁只造庄重,不保密;本机①照旧)
|
|
647
|
+
: { phraseHidden: !!s?.persona?.sealPhrase }),
|
|
319
648
|
extraLore: s?.persona?.extraLore ?? '',
|
|
649
|
+
// 职责(1.5.2 I-5):白名单投影必须带出,否则人格中心恒空(与 habits 那次同族的"漏字段=界面静默错")
|
|
650
|
+
duty: s?.persona?.duty ?? '',
|
|
320
651
|
pronoun: s?.persona?.pronoun || '她', // 第三人称代词(用户设定;预设 她/他/TA/它,可自定义)
|
|
321
652
|
stylesWork: s?.styles?.work ?? '',
|
|
322
653
|
stylesLife: s?.styles?.life ?? '',
|
|
@@ -395,13 +726,88 @@ export function registerApi(ctx, deps, config) {
|
|
|
395
726
|
return tmp;
|
|
396
727
|
};
|
|
397
728
|
|
|
398
|
-
|
|
729
|
+
// ---- 原文来源告警的读面(E2 可见化,2026-10-01)------------------------------------------
|
|
730
|
+
// 背景:E2 把「未知来源的原文」从**被误信**改成**被排除**,但那条留痕只落在 kv 键
|
|
731
|
+
// `dbg.raw_unknown_namespace` 里 —— **可查,但主人看不见**。本块把那句话搬到前台:
|
|
732
|
+
// 面板要能如实说出"有东西进来了,而我不知道它是什么"。
|
|
733
|
+
// ⚠️ 数据源**只有**那个 kv 键(键名 import 自 summarizer.js,见文件头;本文件不另拼一份)。
|
|
734
|
+
// `dsh_turns_raw` 将来会有 `source` 列 —— 本读面**不依赖它**,两条路并存也不冲突。
|
|
735
|
+
// ⚠️ **读面契约(前台照这个读,别改形状)**:
|
|
736
|
+
// · 字段名 `rawUnknownSources`,挂在**顶层**(与 `audit` 平级)。**不塞进 `audit` 块** ——
|
|
737
|
+
// 那块是访问日志的固定形状契约(tests/access-log.test.mjs:460「多一个少一个都算改契约」)。
|
|
738
|
+
// · **干净时该字段整个不出现**(调用点用条件展开)⇒ ①前台"没有告警就一个字符都不渲染",
|
|
739
|
+
// ②既有应答体逐字不变(负对照:V1)。绝不用 {} / null 之类空壳冒充"查过了,没事"。
|
|
740
|
+
// · 告警存在时形状(五个要素齐,顺序固定):
|
|
741
|
+
// { count, turns, names[], namespaces[{ns,turns,samples[],firstSeenAt,lastSeenAt}],
|
|
742
|
+
// firstSeenAt, lastSeenAt, at, text }
|
|
743
|
+
// ①count = 未知来源**个数**;②names/namespaces[].ns = 它们**叫什么**;
|
|
744
|
+
// ③namespaces[].turns = 各有多少轮原文;④firstSeenAt/lastSeenAt = 首次/最近见到;
|
|
745
|
+
// ⑤text = **唯一一处**拼出来的人话结论(前台原样显示,不再自己拼第二遍)。
|
|
746
|
+
// ⚠️ 唯一一个 helper 就是下面这个:`/health` 与 `/state` 两个读面都只经它取数。
|
|
747
|
+
// 将来要加第三个读面,也在这里加字段 —— **不许**在各处各写一份取数逻辑。
|
|
748
|
+
// ⚠️ 时间语义:kv 里的计数是**累积**的(summarizer 只加不减)⇒ 这是"**见过**"的留痕,不是
|
|
749
|
+
// "此刻表里还有"。所以 text 里写的是"首次/最近**见到**",不含糊成"现在有"。
|
|
750
|
+
const rawUnknownSourcesOf = () => {
|
|
751
|
+
try {
|
|
752
|
+
const raw = memory?.kvGet?.(RAW_UNKNOWN_NS_KEY);
|
|
753
|
+
if (!raw) return null; // 没写过这个键 ⇒ 干净(一个字节都没写过)
|
|
754
|
+
const parsed = JSON.parse(String(raw));
|
|
755
|
+
const src = parsed?.namespaces;
|
|
756
|
+
// 两种写法都吃:对象表(**当前** summarizer 的写法 —— 名字是**键**,值里没有 ns 字段;
|
|
757
|
+
// 第一版探针就在这里读错了:只认 r.ns ⇒ 一个来源都认不出、静默返回 null)与数组(将来若改形状,读面不跟着瞎)。
|
|
758
|
+
const rows = Array.isArray(src) ? src
|
|
759
|
+
: (src && typeof src === 'object' ? Object.entries(src).map(([ns, r]) => ({ ...(r && typeof r === 'object' ? r : {}), ns })) : []);
|
|
760
|
+
const byNs = new Map();
|
|
761
|
+
for (const r of rows) {
|
|
762
|
+
const ns = String((r && r.ns) || '').trim();
|
|
763
|
+
if (!ns) continue;
|
|
764
|
+
const cur = byNs.get(ns) || { ns, turns: 0, samples: [], firstSeenAt: null, lastSeenAt: null };
|
|
765
|
+
cur.turns += Number(r?.turns) || 0;
|
|
766
|
+
if (Array.isArray(r?.samples)) {
|
|
767
|
+
cur.samples = [...new Set([...cur.samples, ...r.samples.map(String)])].slice(0, 3);
|
|
768
|
+
}
|
|
769
|
+
const f = r?.firstSeenAt ? String(r.firstSeenAt) : null;
|
|
770
|
+
const l = r?.lastSeenAt ? String(r.lastSeenAt) : null;
|
|
771
|
+
if (f && (!cur.firstSeenAt || f < cur.firstSeenAt)) cur.firstSeenAt = f;
|
|
772
|
+
if (l && (!cur.lastSeenAt || l > cur.lastSeenAt)) cur.lastSeenAt = l;
|
|
773
|
+
byNs.set(ns, cur);
|
|
774
|
+
}
|
|
775
|
+
// 去重按命名空间(summarizer 那边已按 ns 归并,这里再收一次 —— 读面不假定写入侧一定整洁)
|
|
776
|
+
const namespaces = [...byNs.values()].sort((a, b) => (b.turns - a.turns) || a.ns.localeCompare(b.ns));
|
|
777
|
+
if (!namespaces.length) return null; // 有键没内容 = 空壳 ⇒ 按"没有告警"处理,不显示空行
|
|
778
|
+
const turns = namespaces.reduce((s, u) => s + u.turns, 0);
|
|
779
|
+
const stamps = (k) => namespaces.map((u) => u[k]).filter(Boolean).sort();
|
|
780
|
+
const firstSeenAt = stamps('firstSeenAt')[0] || null;
|
|
781
|
+
const lastSeenAt = stamps('lastSeenAt').slice(-1)[0] || null;
|
|
782
|
+
return {
|
|
783
|
+
count: namespaces.length,
|
|
784
|
+
turns,
|
|
785
|
+
names: namespaces.map((u) => u.ns),
|
|
786
|
+
namespaces,
|
|
787
|
+
firstSeenAt,
|
|
788
|
+
lastSeenAt,
|
|
789
|
+
at: parsed?.at ? String(parsed.at) : null,
|
|
790
|
+
text: rawUnknownSourcesText({ namespaces, turns, firstSeenAt, lastSeenAt }),
|
|
791
|
+
};
|
|
792
|
+
} catch { return null; } // 解析不出来 ⇒ 当"没有告警"(绝不拿半个读数冒充"一切正常")
|
|
793
|
+
};
|
|
794
|
+
|
|
795
|
+
// ---- /state 分档(1.5.2,设计 §3) ------------------------------------------------------
|
|
796
|
+
// 面板路径(client.js:890)恒带 `l0Preview=1`,轮询路径(client.js:604-608)从不带
|
|
797
|
+
// ⇒ 用**已有**的这个开关当"面板档"标记:**不新增查询参数、前端零改动**。
|
|
798
|
+
// 轮询档只回 `applyStateToStore`(client.js:610-620)真会读到的字段 —— 重字段在轮询应答里今天就没被读过,
|
|
799
|
+
// 省掉它们不改变任何前端行为(而"按需省字段"是危险的,见设计 §4:前端每拍**无条件**覆写 store)。
|
|
800
|
+
// ⚠️ `persona` 必须留在轮询档:client.js:612 每拍 `setPronounFrom(r.persona)`,省掉第三人称代词会卡住。
|
|
801
|
+
// ⚠️ `audit` 必须留在轮询档:client.js:618 的 `!== undefined` 守卫 + 侧栏红点,唯一"不主动查也看得见"的日志故障位。
|
|
802
|
+
// ⚠️ `rawUnknownSources` 同样留在轮询档(2026-10-01):client.js 的 applyStateToStore 会读它
|
|
803
|
+
// ⇒ 面板不必重开就能看到"有未知来源进来了"。干净时**整个字段不出现** ⇒ 轮询应答逐字不变。
|
|
804
|
+
const stateForGlobal = (q, local = false) => {
|
|
399
805
|
const base = settings.get();
|
|
400
806
|
const tmp = previewOverrides(q) || base;
|
|
401
807
|
const mode = base.mode?.lastMode === 'work' ? 'work' : 'life';
|
|
402
|
-
const
|
|
403
|
-
const
|
|
404
|
-
|
|
808
|
+
const wantPreview = q && q.get('l0Preview') === '1'; // 面板档标记
|
|
809
|
+
const rawUnknown = rawUnknownSourcesOf(); // 干净 ⇒ null ⇒ 下面条件展开=字段不出现
|
|
810
|
+
const light = {
|
|
405
811
|
ok: true,
|
|
406
812
|
plugin: 'dsh-ling',
|
|
407
813
|
sessionId: null,
|
|
@@ -410,26 +816,82 @@ export function registerApi(ctx, deps, config) {
|
|
|
410
816
|
modeLabel: MODE_LABEL[mode],
|
|
411
817
|
running: false,
|
|
412
818
|
pending: 0,
|
|
413
|
-
|
|
414
|
-
l0PreviewText: wantPreview ? l0WithClock(tmp, mode) : undefined,
|
|
415
|
-
persona: personaInfo(base),
|
|
416
|
-
modeMapping: base.mode?.mapping ?? {},
|
|
819
|
+
persona: personaInfo(base, local),
|
|
417
820
|
lastMode: mode,
|
|
821
|
+
audit: auditHealth(), // C-07:全局(无活动会话)视图也要看得到日志健康面
|
|
822
|
+
...(rawUnknown ? { rawUnknownSources: rawUnknown } : {}), // E2 可见化:新字段另起,老字段逐字不动
|
|
823
|
+
};
|
|
824
|
+
if (!wantPreview) return light; // 轮询档:到此为止(下面这些只有面板路径才付)
|
|
825
|
+
return {
|
|
826
|
+
...light,
|
|
827
|
+
snapshot: null,
|
|
828
|
+
l0PreviewText: l0WithClock(tmp, mode),
|
|
418
829
|
memory: {
|
|
419
|
-
overviews:
|
|
830
|
+
overviews: memory.countOverviews({ onlyOk: true }),
|
|
420
831
|
rawTurnSessions: rawTurnSessionCount(memory),
|
|
421
832
|
fingerprint: memory.fingerprint(),
|
|
422
833
|
},
|
|
423
|
-
updates: base.updates ?? {},
|
|
424
834
|
};
|
|
425
835
|
};
|
|
426
836
|
|
|
427
|
-
|
|
837
|
+
// ---- E3 面板档只读读数(2026-09-27)------------------------------------------------------
|
|
838
|
+
// 背景:「那一行」在盘上有**两条**,长得像但不是一回事 —— ①L2 `[记忆·开场]` 里本会话那一条
|
|
839
|
+
// (只有标题,概述器 15 分钟一拍)与 ②E0 `agent/pre-step` 追加的活行(`[本次会话·进展 #N] ...`)。
|
|
840
|
+
// 面板要把两条**各自标注**地显示出来,并把"为什么没变"变成看得见的读数 ⇒ 需要下面几个只读字段。
|
|
841
|
+
// 规矩(照 auditHealth):**每个新字段各自 try/catch** —— 任何一个读数出错都不许把 /state 带崩;
|
|
842
|
+
// 且**只在 l0Preview=1 的面板档付这个钱**(轮询档今天 43× 提速的成果不能被这几个字段吃回去)。
|
|
843
|
+
const SNAP_TEXT_CAP = 8000; // 冻结快照原文上限:面板是只读展示(看全文走面板档、纯读、不进 prompt),8k 字符够看清那一轮读到什么
|
|
844
|
+
const e3Snapshot = (snap) => {
|
|
845
|
+
try {
|
|
846
|
+
const t = String((snap && snap.text) || '');
|
|
847
|
+
return t.length > SNAP_TEXT_CAP ? { text: t.slice(0, SNAP_TEXT_CAP), truncated: true } : { text: t };
|
|
848
|
+
} catch { return {}; } // 取不到就**不给**这个字段 ⇒ 前端按"缺字段"处理(绝不用空串冒充"快照是空的")
|
|
849
|
+
};
|
|
850
|
+
// E1 深摘冻结:该会话是否已被深摘(`deepsummary.js:267` 写的就是 `done:<iso>`)—— 冻结则那一行正文不再更新
|
|
851
|
+
const e3DeepDone = (sid) => {
|
|
852
|
+
try { return String(memory.kvGet('deep:' + sid) || '').startsWith('done:'); } catch { return null; }
|
|
853
|
+
};
|
|
854
|
+
// ②活行读数:handler 侧给 sessionLineState 添了字段(text/ordinal/wouldAppend/blocked/last/counts,
|
|
855
|
+
// 富字段整体还挂在 `preview` 下、**含 `basis`**),这里只做**转发 + 容错**(少字段/抛异常都退化成 null)。
|
|
856
|
+
// `opts.settings` 必须透传(settings 就在本作用域内):预览据此尊重那个**免重启回退开关**
|
|
857
|
+
// (`memory.sessionProgressLine === false` ⇒ 报 `blocked: 'disabled'`);不透传就会照样给"会追加"的预测。
|
|
858
|
+
const e3SessionLine = (sid) => {
|
|
859
|
+
try { return sessionLineState(memory, sid, { settings }) || null; } catch { return null; }
|
|
860
|
+
};
|
|
861
|
+
// ③本会话那一行的 `self` 标记:只**加**一个字段(既有 conv_id/title/date/category/score/line 逐字不动)
|
|
862
|
+
const e3L1Items = (l1, sid) => {
|
|
863
|
+
try {
|
|
864
|
+
return l1.items.map((i) => {
|
|
865
|
+
const row = {
|
|
866
|
+
conv_id: i.conv_id, title: i.title, date: isoDate(i.updated_at || i.started_at),
|
|
867
|
+
category: i.category, score: i.score, line: i.line,
|
|
868
|
+
};
|
|
869
|
+
if (String(i.conv_id) === String(sid)) row.self = true;
|
|
870
|
+
return row;
|
|
871
|
+
});
|
|
872
|
+
} catch { return []; } // 映射本身出错也不许把 /state 带崩
|
|
873
|
+
};
|
|
874
|
+
|
|
875
|
+
const stateFor = (sessionId, { l0Preview = false, q, local = false } = {}) => {
|
|
428
876
|
const base = settings.get();
|
|
429
877
|
const tmp = q ? (previewOverrides(q) || base) : base;
|
|
430
878
|
const mode = currentMode(memory, settings, sessionId);
|
|
879
|
+
const rawUnknown = rawUnknownSourcesOf(); // 同上:干净 ⇒ 字段整个不出现
|
|
880
|
+
const light = {
|
|
881
|
+
ok: true,
|
|
882
|
+
plugin: 'dsh-ling',
|
|
883
|
+
sessionId,
|
|
884
|
+
mode,
|
|
885
|
+
modeLabel: MODE_LABEL[mode] ?? mode,
|
|
886
|
+
running: gate.isRunning(sessionId),
|
|
887
|
+
pending: gate.pendingCount(sessionId),
|
|
888
|
+
persona: personaInfo(base, local),
|
|
889
|
+
lastMode: base.mode?.lastMode ?? 'life',
|
|
890
|
+
audit: auditHealth(), // C-07:会话视图同样带上(旧客户端忽略未知字段,向后兼容)
|
|
891
|
+
...(rawUnknown ? { rawUnknownSources: rawUnknown } : {}), // E2 可见化:新字段另起,老字段逐字不动
|
|
892
|
+
};
|
|
893
|
+
if (!l0Preview) return light; // 轮询档(见上:分档说明)
|
|
431
894
|
const snap = gate.snapshotOf(sessionId);
|
|
432
|
-
const all = memory.listOverviews({ onlyOk: true });
|
|
433
895
|
const l1 = base.memory?.l1Enabled === false ? null : selectL1(memory, {
|
|
434
896
|
mode,
|
|
435
897
|
sessionId, // D9-a 血缘加权:按该会话所属枝给记忆定档
|
|
@@ -438,41 +900,58 @@ export function registerApi(ctx, deps, config) {
|
|
|
438
900
|
budgetChars: Math.max(200, Math.round((base.memory?.l1BudgetTokens ?? 1200) * 1.4)),
|
|
439
901
|
});
|
|
440
902
|
return {
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
mode,
|
|
445
|
-
modeLabel: MODE_LABEL[mode] ?? mode,
|
|
446
|
-
running: gate.isRunning(sessionId),
|
|
447
|
-
pending: gate.pendingCount(sessionId),
|
|
903
|
+
...light,
|
|
904
|
+
// E3:`snapshot` 加**正文字段**(`text`,超 8000 字符时另给 `truncated: true`)—— 面板的「看全文」读它。
|
|
905
|
+
// 既有 5 个字段(exists/builtAt/mode/stale/chars)逐字未动;只面板档带(上面的 `if (!l0Preview) return light;`)。
|
|
448
906
|
snapshot: snap
|
|
449
|
-
? { exists: true, builtAt: snap.builtAt ?? null, mode: snap.mode ?? null, stale: !!snap.stale, chars: (snap.text || '').length }
|
|
907
|
+
? { exists: true, builtAt: snap.builtAt ?? null, mode: snap.mode ?? null, stale: !!snap.stale, chars: (snap.text || '').length, ...e3Snapshot(snap) }
|
|
450
908
|
: { exists: false },
|
|
451
909
|
idleRefresh: refreshStats(memory, sessionId), // B:空闲边界刷新留痕(次数 + 最近时间)
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
910
|
+
// E3:②活行读数 + ①"为什么没变"的判据。`l0Preview` 在上面早已早退 ⇒ 这里恒为 true;
|
|
911
|
+
// 仍写成三元组是**字面**钉住"轮询档一个都不带"(将来有人挪动那句早退也不会悄悄带上重字段)。
|
|
912
|
+
sessionLine: l0Preview ? e3SessionLine(sessionId) : undefined,
|
|
913
|
+
deepDone: l0Preview ? e3DeepDone(sessionId) : null,
|
|
914
|
+
l0PreviewText: l0WithClock(tmp, mode),
|
|
456
915
|
memory: {
|
|
457
|
-
overviews:
|
|
916
|
+
overviews: memory.countOverviews({ onlyOk: true }),
|
|
458
917
|
rawTurnSessions: rawTurnSessionCount(memory),
|
|
459
918
|
fingerprint: memory.fingerprint(),
|
|
460
919
|
l1: l1 ? {
|
|
461
|
-
items: l1
|
|
462
|
-
conv_id: i.conv_id, title: i.title, date: isoDate(i.updated_at || i.started_at),
|
|
463
|
-
category: i.category, score: i.score, line: i.line,
|
|
464
|
-
})),
|
|
920
|
+
items: e3L1Items(l1, sessionId), // E3:本会话那一条多一个 `self: true`
|
|
465
921
|
totalChars: l1.totalChars,
|
|
466
922
|
dropped: l1.dropped,
|
|
467
923
|
} : null,
|
|
468
924
|
},
|
|
469
|
-
updates: base.updates ?? {},
|
|
470
925
|
};
|
|
471
926
|
};
|
|
472
927
|
|
|
928
|
+
// C-07 访问日志健康面:写盘失败后日志器会**自我停写** —— 那个状态必须常驻可查。
|
|
929
|
+
// 没有这个出口时,「日志悄悄不记了」只能事后发现(2026-09-27 静默 41.6 分钟那次)。
|
|
930
|
+
// C-07c:形状由"health 或 null"改成**两层** —— 装上了 `{installed:true, ...11 个 health 字段}`,
|
|
931
|
+
// 没装上 `{installed:false, reason}`(老后端仍是没有 audit 字段,前端一个字符都不渲染)。
|
|
932
|
+
// `enabled` 取宿主侧真实配置:有意关闭时才能说出"已按配置关闭",而不是含糊的"没装上"。
|
|
933
|
+
// 双层兜底(accessLogStatus 抛 ⇒ null):健康面永远不许把 sendJson 带崩。
|
|
934
|
+
// `enabled` 的三级取法(直取 → config 段 → plugin.config 段):DSH 各版本把插件配置放在哪一层
|
|
935
|
+
// 不完全一致,取不到就是 undefined(accessLogStatus 便不会误报"有意关闭" —— 宁可说 not-mounted)。
|
|
936
|
+
const auditHealth = () => {
|
|
937
|
+
try {
|
|
938
|
+
const al = config?.accessLog || config?.config?.accessLog || config?.plugin?.config?.accessLog;
|
|
939
|
+
return accessLogStatus({ enabled: al?.enabled });
|
|
940
|
+
} catch { return null; }
|
|
941
|
+
};
|
|
942
|
+
|
|
473
943
|
// GET /health
|
|
474
944
|
disposers.push(register('/health', async (_req, res) => {
|
|
475
|
-
|
|
945
|
+
// E0 只读探针:并进既有的 /health(**不开新路由**)—— 验收时不必离线解 zstd 会话日志,
|
|
946
|
+
// 就能看到"追加了几次、跳过了几次、有没有重复"。`limit` 只留最近 8 个会话,免把健康面撑大。
|
|
947
|
+
// E2 可见化(2026-10-01):未知原文来源的留痕也挂在这里(**只在确有告警时**)——
|
|
948
|
+
// 既有 ok/plugin/version/audit/sessionLine 五个键的顺序与取值逐字未动,新键**排在最后**。
|
|
949
|
+
const rawUnknown = rawUnknownSourcesOf();
|
|
950
|
+
sendJson(res, 200, {
|
|
951
|
+
ok: true, plugin: 'dsh-ling', version: config.version, audit: auditHealth(),
|
|
952
|
+
sessionLine: sessionLineProbe(memory, { limit: 8 }),
|
|
953
|
+
...(rawUnknown ? { rawUnknownSources: rawUnknown } : {}),
|
|
954
|
+
});
|
|
476
955
|
}));
|
|
477
956
|
|
|
478
957
|
// ---- 记忆中心 ----
|
|
@@ -651,9 +1130,12 @@ export function registerApi(ctx, deps, config) {
|
|
|
651
1130
|
sendJson(res, 200, { ok: true, total: list.length, log: list });
|
|
652
1131
|
}));
|
|
653
1132
|
|
|
654
|
-
//
|
|
1133
|
+
// POST /branch-log/pending —— 复盘守门(2026-09-22 用户:「互相复盘不应该被我绕过」)。
|
|
655
1134
|
// 返回"自器灵上次开口以来累积了多少笔结构改动还没交代";侧栏据此提示,
|
|
656
1135
|
// 并在未同步时**拦住"退出复盘模式"** —— 改完树必须让器灵有机会看到。
|
|
1136
|
+
// ⚠️ 进 `POST_ONLY` 的原因:它不是纯读 —— `memory.branchLogPending()` 在"水位键缺失 +
|
|
1137
|
+
// branch_log 非空"时会**一次性把水位补种到当前最大 id**(memory.js:1272)⇒ 一个 GET
|
|
1138
|
+
// 就能静默把欠账计数顶成 0(审计差集 26 条里**唯一一条真写**)。前端三处调用点已同步改 POST。
|
|
657
1139
|
disposers.push(register('/branch-log/pending', async (req, res) => {
|
|
658
1140
|
let r = { watermark: 0, maxId: 0, pending: 0 };
|
|
659
1141
|
try {
|
|
@@ -1506,7 +1988,8 @@ export function registerApi(ctx, deps, config) {
|
|
|
1506
1988
|
});
|
|
1507
1989
|
}));
|
|
1508
1990
|
|
|
1509
|
-
// POST /mode/toggle { sessionId?, mode?, defaultOnly? }
|
|
1991
|
+
// POST /mode/toggle { sessionId?, mode?, defaultOnly?, confirm? }
|
|
1992
|
+
// 全局默认那半(无 sessionId / defaultOnly):**必须带 confirm:true** 才落笔 —— 不带只回预览
|
|
1510
1993
|
disposers.push(register('/mode/toggle', async (req, res) => {
|
|
1511
1994
|
const body = JSON.parse((await readBody(req)) || '{}');
|
|
1512
1995
|
const wantDefault = body.defaultOnly === true;
|
|
@@ -1515,13 +1998,28 @@ export function registerApi(ctx, deps, config) {
|
|
|
1515
1998
|
// 全局默认路径(无会话或显式 defaultOnly):
|
|
1516
1999
|
// ① 平台默认模型(agent-default-model)→ 只同步**推理等级**;模型原样保留
|
|
1517
2000
|
// ② 自有 lastMode(D2 跟随源)
|
|
2001
|
+
// ⚠️ 走这条动的是**平台全局默认模型**(新会话都跟随),不是本会话 —— 故 1.5.2 起 fail-closed:
|
|
2002
|
+
// 没带 confirm:true 的请求**一个字节都不写**,只回一份"将要变什么"的预览(界面据此把后果
|
|
2003
|
+
// 念给主人听,再带着 confirm 发第二次)。凭据不在就不动手 —— 与定型门(seal.js)同一条纪律。
|
|
1518
2004
|
const cur = settings.get().mode?.lastMode === 'work' ? 'work' : 'life';
|
|
1519
2005
|
const next = body.mode && ['work', 'life'].includes(body.mode) ? body.mode : (cur === 'work' ? 'life' : 'work');
|
|
1520
2006
|
const cfg = resolveModeConfig(settings.get(), next); // 只含 reasoningEffort
|
|
1521
|
-
let warning;
|
|
1522
2007
|
const adm = svc(ctx, 'agentDefaultModel');
|
|
2008
|
+
const keep = currentModelSelection(ctx); // 当前模型:原样带回,不改
|
|
2009
|
+
const admSel = adm && typeof adm.currentSelection === 'function' ? adm.currentSelection() : null;
|
|
2010
|
+
// 平台默认档位的"改前值":没设置过就是 null,不假装知道
|
|
2011
|
+
const prevEffort = admSel && admSel.reasoningEffort != null ? String(admSel.reasoningEffort) : null;
|
|
2012
|
+
if (body.confirm !== true) {
|
|
2013
|
+
return sendJson(res, 200, {
|
|
2014
|
+
ok: false, reason: 'confirmation-required', applied: 'none', defaultOnly: true,
|
|
2015
|
+
sessionId: null, from: cur, mode: next, lastMode: cur,
|
|
2016
|
+
globalDefault: keep ? keep.provider + '/' + keep.model : null,
|
|
2017
|
+
reasoningEffort: { from: prevEffort, to: cfg.reasoningEffort },
|
|
2018
|
+
message: '这会改**全局默认模型**(新会话都跟随),不是只改当前会话 —— 确认后再发一次(带 confirm:true)。',
|
|
2019
|
+
});
|
|
2020
|
+
}
|
|
2021
|
+
let warning;
|
|
1523
2022
|
if (adm && typeof adm.saveSelection === 'function') {
|
|
1524
|
-
const keep = currentModelSelection(ctx); // 当前模型:原样带回,不改
|
|
1525
2023
|
if (!keep) {
|
|
1526
2024
|
warning = 'unknown-current-model; 默认推理等级未同步(拒绝替用户指定模型)';
|
|
1527
2025
|
console.debug('[dsh-ling]', warning);
|
|
@@ -1551,6 +2049,11 @@ export function registerApi(ctx, deps, config) {
|
|
|
1551
2049
|
ok: true, mode: next, applied: 'default-only', sessionId: null,
|
|
1552
2050
|
lastMode: s2.mode.lastMode, platformDefaultSynced: !warning, warning,
|
|
1553
2051
|
emptySessionsRefreshed: refreshed.length,
|
|
2052
|
+
// 回显(1.5.2 U8):把"改的是哪个全局默认、档位从什么变成什么、模型有没有被碰"原样交代,
|
|
2053
|
+
// 界面据此回执 —— 主人不必再去设置页核对到底动了什么。
|
|
2054
|
+
from: cur, globalDefault: keep ? keep.provider + '/' + keep.model : null,
|
|
2055
|
+
modelKept: !!keep,
|
|
2056
|
+
reasoningEffort: { from: prevEffort, to: cfg.reasoningEffort },
|
|
1554
2057
|
});
|
|
1555
2058
|
}
|
|
1556
2059
|
const cur = currentMode(memory, settings, sessionId);
|
|
@@ -1562,12 +2065,15 @@ export function registerApi(ctx, deps, config) {
|
|
|
1562
2065
|
}));
|
|
1563
2066
|
|
|
1564
2067
|
// GET /state?sessionId=&scope=global&l0Preview=1&previewPersona=&previewStyles=
|
|
2068
|
+
// §6.1(a):`/state` 的 `personaInfo` **按渠道分流** —— 带代理头(经反代/远端)的请求不回承诺句,只回 hasSeal。
|
|
2069
|
+
// 判据在 `readIsLocal` 一处;两个调用点(stateForGlobal / stateFor)同一条路,不会有一条漏掉。
|
|
1565
2070
|
disposers.push(register('/state', async (req, res) => {
|
|
1566
2071
|
const q = new URL(req.url, 'http://dsh.internal').searchParams;
|
|
1567
|
-
|
|
2072
|
+
const local = readIsLocal(req);
|
|
2073
|
+
if (q.get('scope') === 'global') return sendJson(res, 200, stateForGlobal(q, local));
|
|
1568
2074
|
const sessionId = reqSessionId(req.url) || gate.recentSessionId();
|
|
1569
|
-
if (!sessionId) return sendJson(res, 200, stateForGlobal(q));
|
|
1570
|
-
sendJson(res, 200, stateFor(sessionId, { l0Preview: q.get('l0Preview') === '1', q }));
|
|
2075
|
+
if (!sessionId) return sendJson(res, 200, stateForGlobal(q, local));
|
|
2076
|
+
sendJson(res, 200, stateFor(sessionId, { l0Preview: q.get('l0Preview') === '1', q, local }));
|
|
1571
2077
|
}));
|
|
1572
2078
|
|
|
1573
2079
|
// POST /persona 字段补丁 → settings + 全局失效(按冻结门)
|
|
@@ -1575,6 +2081,11 @@ export function registerApi(ctx, deps, config) {
|
|
|
1575
2081
|
// 上锁(sealed:true)需带新承诺句 → 覆盖哈希;撤锁清哈希;每次成功变更后存「结果档案」。
|
|
1576
2082
|
disposers.push(register('/persona', async (req, res) => {
|
|
1577
2083
|
const body = JSON.parse((await readBody(req)) || '{}');
|
|
2084
|
+
// ── 手术门(第 1/14 条写面)────────────────────────────────────────────────
|
|
2085
|
+
// 载荷必须**在这里**交上去:请求流只能读一次(见 guard 里的接线说明)。
|
|
2086
|
+
// 只交 `unlock` 一项:门只读它(§1.3 判据 3)。把整份 patch 交上去 = 让留痕的"字段指纹"
|
|
2087
|
+
// 记下一串与判据无关的内容(越少记越好;要留痕的是"这次手术",不是"他改成了什么")。
|
|
2088
|
+
if (!gateSurgery(req, res, sendJson, { unlock: body.unlock }, { settings, logFile: surgeryLog, endpoint: '/persona' })) return;
|
|
1578
2089
|
const raw = clampBottomLines(body.patch && typeof body.patch === 'object' ? body.patch : body);
|
|
1579
2090
|
// 2026-09-17 加固④(G10):白名单收窄 —— 补丁只能落在 persona / styles 两棵子树内。
|
|
1580
2091
|
// 此前 body 被原样深合并进 settings.json,`{"guard":{"enforce":false}}` 可持久化关停本守卫。
|
|
@@ -1582,19 +2093,19 @@ export function registerApi(ctx, deps, config) {
|
|
|
1582
2093
|
if (dropped.length) console.warn('[dsh-ling] /persona 丢弃白名单外的字段: %s', dropped.join(', '));
|
|
1583
2094
|
const cur = settings.get();
|
|
1584
2095
|
const wantLock = patch.persona?.sealed === true;
|
|
1585
|
-
const wasSealed = cur.persona?.sealed === true;
|
|
1586
2096
|
const doUnseal = patch.persona?.sealed === false;
|
|
1587
2097
|
if (touchesPersona(patch)) {
|
|
2098
|
+
// ── 2026-09-30 深夜(手术门):**删掉 `wasSealed` 绕过分支**(侦察 F9)──────────
|
|
2099
|
+
// 旧代码在这里是二选一:`wantLock` 直接采纳"新承诺句"、`wasSealed` 才验原句 ——
|
|
2100
|
+
// 而 `wantLock` 分支排在 `wasSealed` **之前**,于是"带着 sealed:true 的补丁"能跳过验句。
|
|
2101
|
+
// 现在:已定型时**所有**写入都由手术门(本机 + 每启动票据 + 原句)统一判过(见上面 gateSurgery),
|
|
2102
|
+
// 走到这一行只可能是"未定型"⇒ 这里只剩"首次上锁"这一种正当语义。
|
|
1588
2103
|
if (wantLock) {
|
|
1589
2104
|
const k = String(body.unlock || '').trim();
|
|
1590
2105
|
if (k.length < KEY_MIN) return sendJson(res, 200, { ok: false, reason: 'sealed', message: '上锁需先亲手写下一句 ≥' + KEY_MIN + ' 字的承诺句(明文保存,仅供解锁界面回显提醒)。' });
|
|
1591
|
-
patch.persona.sealPhrase = k; //
|
|
1592
|
-
} else if (wasSealed) {
|
|
1593
|
-
const g = sealOk(cur.persona, body.unlock);
|
|
1594
|
-
if (!g.pass) return sendJson(res, 200, { ok: false, reason: 'sealed', message: MSG_KEY });
|
|
1595
|
-
if (g.adoptKey) patch.persona.sealPhrase = g.adoptKey; // 旧档案无明文:采纳为承诺句
|
|
1596
|
-
if (doUnseal) patch.persona.sealPhrase = ''; // 永久撤锁 → 清句
|
|
2106
|
+
patch.persona.sealPhrase = k; // 首次上锁:记下这句承诺
|
|
1597
2107
|
}
|
|
2108
|
+
if (doUnseal) patch.persona.sealPhrase = ''; // 永久撤锁 → 清句
|
|
1598
2109
|
}
|
|
1599
2110
|
const s2 = await settings.update(patch);
|
|
1600
2111
|
const histKey = touchesPersona(patch) ? await archivePersona(s2, 'result') : null;
|
|
@@ -1617,9 +2128,15 @@ export function registerApi(ctx, deps, config) {
|
|
|
1617
2128
|
}));
|
|
1618
2129
|
|
|
1619
2130
|
// POST /persona/check { unlock } 客户端「解锁修改」先验钥(只读,不落盘)
|
|
2131
|
+
// 进 `POST_ONLY` 是**防御性**:今天 GET 下 body 恒为 `{}` ⇒ 只回"承诺句不一致"(无写),
|
|
2132
|
+
// 但 handler 读 body、注释也自称 POST ⇒ 形态已经是 POST 端点,补进去零成本。
|
|
1620
2133
|
disposers.push(register('/persona/check', async (req, res) => {
|
|
1621
2134
|
const body = JSON.parse((await readBody(req)) || '{}');
|
|
1622
2135
|
const cur = settings.get();
|
|
2136
|
+
// 手术门(第 2/14 条写面):**这一条本身不写** —— 它是"验句预言机"(在线猜句的入口)。
|
|
2137
|
+
// 门口径与别处**完全一致**:已定型 ⇒ 必须本机 + 带票 + 原句,否则连"这句对不对"都不告诉它
|
|
2138
|
+
// (改前它对任意调用者有求必应 ⇒ 猜句成功率只受网络延迟限制)。
|
|
2139
|
+
if (!gateSurgery(req, res, sendJson, { unlock: body.unlock }, { settings, logFile: surgeryLog, endpoint: '/persona/check' })) return;
|
|
1623
2140
|
if (cur.persona?.sealed !== true) {
|
|
1624
2141
|
return sendJson(res, 200, { ok: true, valid: true, sealed: false, message: '档案未定型' });
|
|
1625
2142
|
}
|
|
@@ -1631,12 +2148,15 @@ export function registerApi(ctx, deps, config) {
|
|
|
1631
2148
|
}));
|
|
1632
2149
|
|
|
1633
2150
|
// GET /persona/history 人格档案存档(每次保存后的结果;回滚前另有 pre-rollback 档)
|
|
1634
|
-
|
|
2151
|
+
// §6.1(a):**读时过滤** —— 存档内容一个字都不改写(那是人读的档、也是回滚的档),
|
|
2152
|
+
// 只在**出站这一层**按渠道剔除承诺句(经代理 ⇒ 剥掉并补 hasSeal / sealPhraseHidden)。
|
|
2153
|
+
disposers.push(register('/persona/history', async (req, res) => {
|
|
2154
|
+
const local = readIsLocal(req);
|
|
1635
2155
|
const items = memory.kvList('persona.hist.').map((r) => {
|
|
1636
2156
|
const k = String(r.key);
|
|
1637
2157
|
let v = null;
|
|
1638
2158
|
try { v = JSON.parse(String(r.value)); } catch {}
|
|
1639
|
-
return { ts: Number(k.slice('persona.hist.'.length)) || null, persona: v?.persona ?? null, styles: v?.styles ?? null, kind: v?.kind || 'result' };
|
|
2159
|
+
return { ts: Number(k.slice('persona.hist.'.length)) || null, persona: personaForRead(v?.persona ?? null, local), styles: v?.styles ?? null, kind: v?.kind || 'result' };
|
|
1640
2160
|
}).sort((a, b) => (b.ts || 0) - (a.ts || 0)).slice(0, 30);
|
|
1641
2161
|
sendJson(res, 200, { ok: true, items });
|
|
1642
2162
|
}));
|
|
@@ -1645,6 +2165,8 @@ export function registerApi(ctx, deps, config) {
|
|
|
1645
2165
|
// 无论是否定型,只允许更新 persona.aiTitle(定位自述)单字段;其余字段仍需手术/承诺句。
|
|
1646
2166
|
disposers.push(register('/persona/self-summary', async (req, res) => {
|
|
1647
2167
|
const body = JSON.parse((await readBody(req)) || '{}');
|
|
2168
|
+
// 手术门(第 3/14 条写面):身份自述 aiTitle 属身份保护区。只交 unlock(见 /persona 处说明)。
|
|
2169
|
+
if (!gateSurgery(req, res, sendJson, { unlock: body.unlock }, { settings, logFile: surgeryLog, endpoint: '/persona/self-summary' })) return;
|
|
1648
2170
|
const text = String(body.text ?? '').trim();
|
|
1649
2171
|
if (!text) return sendJson(res, 200, { ok: false, reason: 'empty', message: '内容为空' });
|
|
1650
2172
|
if (text.length > 500) return sendJson(res, 200, { ok: false, reason: 'long', message: '自述过长(≤500 字)' });
|
|
@@ -1667,6 +2189,8 @@ export function registerApi(ctx, deps, config) {
|
|
|
1667
2189
|
// (习惯不能直达;这里的用户点击就是确认动作),重量前先归档。
|
|
1668
2190
|
disposers.push(register('/persona/hint-adopt', async (req, res) => {
|
|
1669
2191
|
const body = JSON.parse((await readBody(req)) || '{}');
|
|
2192
|
+
// 手术门(第 4/14 条写面):一次点击即 propose+confirm 落一条习惯。只交 unlock。
|
|
2193
|
+
if (!gateSurgery(req, res, sendJson, { unlock: body.unlock }, { settings, logFile: surgeryLog, endpoint: '/persona/hint-adopt' })) return;
|
|
1670
2194
|
const text = String(body.text ?? '').trim();
|
|
1671
2195
|
if (text.length < 4 || text.length > 400) {
|
|
1672
2196
|
return sendJson(res, 200, { ok: false, reason: 'bad', message: '内容需在 4~400 字之间' });
|
|
@@ -1700,8 +2224,14 @@ export function registerApi(ctx, deps, config) {
|
|
|
1700
2224
|
|
|
1701
2225
|
// POST /persona/rule { action:'add'|'remove', rule, quote? }
|
|
1702
2226
|
// 规矩直达:add 必须带原话(quote);remove 只需正文。
|
|
2227
|
+
// ⚠️ E3(审计 H-4):"add 必须带原话"**只对会话内调用成立** —— 主人在**面板**里亲手敲的
|
|
2228
|
+
// (请求形状:无 sessionId、无 quote)本来就不需要另附原话(他自己敲的就是同意)。
|
|
2229
|
+
// "去掉 quote"**不再等于**"来自面板":归因只由 ruleWriteAttribution() 一处判(见文件头)。
|
|
1703
2230
|
disposers.push(register('/persona/rule', async (req, res) => {
|
|
1704
2231
|
const body = JSON.parse((await readBody(req)) || '{}');
|
|
2232
|
+
// 手术门(第 5+6/14 条写面:add 与 remove 同一端点,一次判过):
|
|
2233
|
+
// 规矩是他的指令(quote 是"经同意"的凭据),但**删规矩能整批删光** ⇒ 一律过门。
|
|
2234
|
+
if (!gateSurgery(req, res, sendJson, { unlock: body.unlock }, { settings, logFile: surgeryLog, endpoint: '/persona/rule' })) return;
|
|
1705
2235
|
const action = String(body.action || 'add');
|
|
1706
2236
|
if (action === 'remove') {
|
|
1707
2237
|
const r = await removeRule({ settings, rule: body.rule });
|
|
@@ -1710,9 +2240,13 @@ export function registerApi(ctx, deps, config) {
|
|
|
1710
2240
|
: { ok: false, reason: r.reason, message: '没有这条规矩' });
|
|
1711
2241
|
}
|
|
1712
2242
|
if (action !== 'add') return sendJson(res, 200, { ok: false, reason: 'bad-action' });
|
|
2243
|
+
// ── E3(审计 H-4):归因判据只在 ruleWriteAttribution() 一处 ──────────────────────
|
|
2244
|
+
// 被删掉的旧写法:`source: body.quote ? 'session' : 'panel'` —— 用"有没有 quote"
|
|
2245
|
+
// 反推"谁写的",于是**会话内、不带 quote 的 HTTP 调用**被贴成 'panel'
|
|
2246
|
+
// (= 冒充主人亲手写的那一条:rules.js 据此免掉原话要求,原话位填成「(面板直接写入)」)。
|
|
2247
|
+
const attr = ruleWriteAttribution(body);
|
|
1713
2248
|
const r = await addRule({
|
|
1714
|
-
settings, rule: body.rule, quote:
|
|
1715
|
-
source: body.quote ? 'session' : 'panel', // 面板直接写入:用户自己敲的就是同意
|
|
2249
|
+
settings, rule: body.rule, quote: attr.quote, sessionId: attr.sessionId, source: attr.source,
|
|
1716
2250
|
});
|
|
1717
2251
|
const message = !r.ok
|
|
1718
2252
|
? {
|
|
@@ -1722,13 +2256,24 @@ export function registerApi(ctx, deps, config) {
|
|
|
1722
2256
|
duplicate: '已有同义规矩,未重复写入。',
|
|
1723
2257
|
}[r.reason] || ('未写入:' + r.reason)
|
|
1724
2258
|
: undefined;
|
|
1725
|
-
sendJson(res, 200, {
|
|
2259
|
+
sendJson(res, 200, {
|
|
2260
|
+
ok: r.ok, ...r, message, affected: r.ok ? invalidateAllSummaries() : [],
|
|
2261
|
+
// 归因如实回传:面板 = 主人亲手写 / 会话内带原话 / 会话内无原话 —— 调用方不必再猜
|
|
2262
|
+
// rules.js 的 'panel' 是什么意思。`unquoted` 只在"会话内且没有原话"时为真。
|
|
2263
|
+
source: attr.source,
|
|
2264
|
+
...(attr.source === 'session-no-quote' && r.ok
|
|
2265
|
+
? { unquoted: true, note: '这次写入来自会话内调用、且没有原话 —— 留痕按「会话内调用」记,不是面板写入,也不是"经同意"。' }
|
|
2266
|
+
: {}),
|
|
2267
|
+
});
|
|
1726
2268
|
}));
|
|
1727
2269
|
|
|
1728
2270
|
// POST /persona/habit { action:'propose'|'confirm'|'reject'|'remove', habit?, evidence?, id? }
|
|
1729
2271
|
// 习惯不直达:propose(任一方可提)/ confirm(对方点头)/ reject / remove。
|
|
1730
2272
|
disposers.push(register('/persona/habit', async (req, res) => {
|
|
1731
2273
|
const body = JSON.parse((await readBody(req)) || '{}');
|
|
2274
|
+
// 手术门(第 7+8/14 条写面:propose/confirm/reject 与 remove 同一端点,一次判过)。
|
|
2275
|
+
// 改前只有 remove 过门 —— "加比删容易"正是侦察里指出的不对称,现在两条都过门。
|
|
2276
|
+
if (!gateSurgery(req, res, sendJson, { unlock: body.unlock }, { settings, logFile: surgeryLog, endpoint: '/persona/habit' })) return;
|
|
1732
2277
|
const action = String(body.action || 'propose');
|
|
1733
2278
|
if (action === 'propose') {
|
|
1734
2279
|
const r = await proposeHabit({
|
|
@@ -1748,15 +2293,8 @@ export function registerApi(ctx, deps, config) {
|
|
|
1748
2293
|
return sendJson(res, 200, { ok: r.ok, ...r, affected: r.ok ? invalidateAllSummaries() : [] });
|
|
1749
2294
|
}
|
|
1750
2295
|
if (action === 'remove') {
|
|
1751
|
-
// 习惯属于她:删改属"对她做手术"
|
|
1752
|
-
|
|
1753
|
-
const gate = cur.persona?.sealed === true ? sealOk(cur.persona, body.unlock) : { pass: true, adoptKey: null };
|
|
1754
|
-
if (!gate.pass) {
|
|
1755
|
-
return sendJson(res, 200, {
|
|
1756
|
-
ok: false, reason: 'sealed',
|
|
1757
|
-
message: pronounize('习惯是她长出来的 —— 删改需走手术门:请在人格中心「🔓 对人格做手术…」解锁后再试。', pronounOf(settings.get())) + MSG_KEY,
|
|
1758
|
-
});
|
|
1759
|
-
}
|
|
2296
|
+
// 习惯属于她:删改属"对她做手术" —— 已定型时由上面的手术门统一判过(本机 + 每启动票据 + 原句),
|
|
2297
|
+
// 这里不再自己重判一遍(旧代码在此处另有一份 sealOk 判据:同一判据两份实现 = 迟早漂移)。
|
|
1760
2298
|
const r = await removeHabit({ settings, habit: body.habit });
|
|
1761
2299
|
return sendJson(res, 200, { ok: r.ok, ...r, affected: r.ok ? invalidateAllSummaries() : [] });
|
|
1762
2300
|
}
|
|
@@ -1969,6 +2507,8 @@ export function registerApi(ctx, deps, config) {
|
|
|
1969
2507
|
// 自性类成长通道:白名单字段(语气枚举 + 风格追加),锁定下亦可采纳;不可逆操作一律不做。
|
|
1970
2508
|
disposers.push(register('/persona/grow', async (req, res) => {
|
|
1971
2509
|
const body = JSON.parse((await readBody(req)) || '{}');
|
|
2510
|
+
// 手术门(第 9/14 条写面):改语气 / 追加风格注 —— 旧注释自陈"锁定下亦可采纳",那正是缺口。
|
|
2511
|
+
if (!gateSurgery(req, res, sendJson, { unlock: body.unlock }, { settings, logFile: surgeryLog, endpoint: '/persona/grow' })) return;
|
|
1972
2512
|
const patch = {};
|
|
1973
2513
|
const toneIn = body.tone && typeof body.tone === 'object' ? body.tone : {};
|
|
1974
2514
|
if (toneIn.default && TONE_SET.includes(String(toneIn.default))) patch.tone = String(toneIn.default);
|
|
@@ -2003,6 +2543,9 @@ export function registerApi(ctx, deps, config) {
|
|
|
2003
2543
|
// POST /persona/rollback { ts, unlock? } 回滚到某历史存档(同样受定型门约束)
|
|
2004
2544
|
disposers.push(register('/persona/rollback', async (req, res) => {
|
|
2005
2545
|
const body = JSON.parse((await readBody(req)) || '{}');
|
|
2546
|
+
// 手术门(第 10/14 条写面):整体回写 {persona,styles}(含 sealed/sealPhrase)——
|
|
2547
|
+
// 回滚到定型前快照 = **撤锁**,故判据必须在"看存档是否存在"之前:格式不对的请求也不该探到存档。
|
|
2548
|
+
if (!gateSurgery(req, res, sendJson, { unlock: body.unlock }, { settings, logFile: surgeryLog, endpoint: '/persona/rollback' })) return;
|
|
2006
2549
|
const ts = Number(body.ts);
|
|
2007
2550
|
const raw = ts ? memory.kvGet('persona.hist.' + ts) : null;
|
|
2008
2551
|
if (!raw) return sendJson(res, 200, { ok: false, reason: 'notfound', message: '存档不存在' });
|
|
@@ -2044,8 +2587,11 @@ export function registerApi(ctx, deps, config) {
|
|
|
2044
2587
|
sendJson(res, 200, { ok: true, items, keep: 50 });
|
|
2045
2588
|
}));
|
|
2046
2589
|
|
|
2047
|
-
//
|
|
2048
|
-
//
|
|
2590
|
+
// POST /persona/draft 基于当前档案(惯例/底线/设定/风格)起草「定位自述」候选。
|
|
2591
|
+
// 对**库**而言是只读:不写任何键、不改 settings,不受定型门约束(采用仍需常规保存流程);
|
|
2592
|
+
// 但对**模型额度**不是 —— 下面 `await llmOnce(...)` 每次请求都真发一次全局模型调用。
|
|
2593
|
+
// ⇒ 因此它在 `POST_ONLY` 里:GET 就能被诱导触发付费调用,是"成本型"而非"写型"的攻击面
|
|
2594
|
+
// (旧注释写"只读操作…不落盘",只说了库那一半,漏掉额度这一半;2026-09-29 夜改准)。
|
|
2049
2595
|
disposers.push(register('/persona/draft', async (_req, res) => {
|
|
2050
2596
|
const s = settings.get();
|
|
2051
2597
|
const p = s.persona || {};
|
|
@@ -2056,6 +2602,7 @@ export function registerApi(ctx, deps, config) {
|
|
|
2056
2602
|
if (Array.isArray(p.bottomLines) && p.bottomLines.length) parts.push('底线:\n' + p.bottomLines.map((x) => '- ' + x).join('\n'));
|
|
2057
2603
|
if (Array.isArray(p.hardRules) && p.hardRules.length) parts.push('规矩(用户的指令):\n' + p.hardRules.map((x) => '- ' + x).join('\n'));
|
|
2058
2604
|
if (Array.isArray(p.habits) && p.habits.length) parts.push('习惯(她长出来的):\n' + p.habits.map((x) => '- ' + (x?.text || x)).join('\n'));
|
|
2605
|
+
if (typeof p.duty === 'string' && p.duty.trim()) parts.push('职责(身份级设定,她已承担的事):\n' + p.duty.trim().slice(0, 800));
|
|
2059
2606
|
if (typeof p.extraLore === 'string' && p.extraLore.trim()) parts.push('扩展设定/身世:\n' + p.extraLore.trim().slice(0, 1200));
|
|
2060
2607
|
parts.push('工作模式风格:' + (s.styles?.work || '(空)'));
|
|
2061
2608
|
parts.push('生活模式风格:' + (s.styles?.life || '(空)'));
|
|
@@ -2119,7 +2666,15 @@ export function registerApi(ctx, deps, config) {
|
|
|
2119
2666
|
}
|
|
2120
2667
|
}));
|
|
2121
2668
|
|
|
2122
|
-
// POST /import/file/batch { items, file?, runId?, at? } 通道 B:会话契约文件导入(
|
|
2669
|
+
// POST /import/file/batch { items, file?, runId?, at? } 通道 B:会话契约文件导入(上限口径见下;客户端分页)
|
|
2670
|
+
// ⚠️ **上限口径(2026-09-29 修正;原文写的「每批 ≤500」与实现自相矛盾,已作废)**:
|
|
2671
|
+
// 真正的闸门是**字节** —— 本文件 `readBody` 的 `BODY_MAX_BYTES = 4 MiB`(超限走下面的 413 出口);
|
|
2672
|
+
// 而 `items.slice(0, 500)` 只是**防呆的条数上限**(500 条要装进 4 MiB 需每条 ≤8 KB,而带正文的
|
|
2673
|
+
// 会话实测 ≈26 KB/条)。⇒ 客户端(`lib/client.js` 的 `takeChunk`)按**累计字节**切批:≤200 条 且 ≤3 MiB/批。
|
|
2674
|
+
// ⚠️ **两半是耦合的**:3 MiB(加数组封套与转义)必须留在 4 MiB 之下 —— 任何一方单独改上限,都会让
|
|
2675
|
+
// 两端口径重新错位,而**这正是本缺陷的成因**(186 条 4.89 MB 被按条数打成整批发 ⇒ 超限 ⇒ 连接被 reset)。
|
|
2676
|
+
// 超限时 `readBody` 会**排空请求体后回 `413 {ok:false, reason:'too-large', limit, got}`**(不再砍连接),
|
|
2677
|
+
// 客户端据此给人话。
|
|
2123
2678
|
// file/runId/at 仅用于记账:同一次导入(同 runId)的多批累加进**一条**账,at = 导入开始时刻
|
|
2124
2679
|
disposers.push(register('/import/file/batch', async (req, res) => {
|
|
2125
2680
|
const body = JSON.parse((await readBody(req)) || '{}');
|
|
@@ -2131,23 +2686,46 @@ export function registerApi(ctx, deps, config) {
|
|
|
2131
2686
|
kind: 'file', name: body.file, runId: body.runId, at: body.at,
|
|
2132
2687
|
accepted: r.accepted, newRows: r.newRows, refreshed: r.refreshed,
|
|
2133
2688
|
upgraded: r.upgraded, folded: r.folded, removedImport: r.removedImport,
|
|
2689
|
+
// A(2026-09-30):折叠时"因为目标已有原文而**没有替换**的轮数"也要进账(与 tooBig 同款,
|
|
2690
|
+
// 只有 >0 才真的落进账本;`= 0` 时账本逐字不变 —— `logImport` 里那道负对照管着)。
|
|
2691
|
+
foldSkippedRaw: cleanFoldSkipped(r.foldSkippedRaw),
|
|
2134
2692
|
degraded: r.degraded, rejected: (r.rejected || []).length,
|
|
2135
2693
|
});
|
|
2694
|
+
// 回执 = `{ ok, ...r }` **整份透传** ⇒ `foldSkippedRaw`(以及 folded/upgraded/…)天然在体里,
|
|
2695
|
+
// 不需要在这里另抄一行(抄了反而会与 applyImportItems 的返回漂移)。
|
|
2136
2696
|
sendJson(res, 200, { ok: true, ...r });
|
|
2137
2697
|
} catch (e) {
|
|
2138
2698
|
sendJson(res, 200, { ok: false, reason: 'import', message: '导入失败:' + String(e?.message ?? e).slice(0, 200) });
|
|
2139
2699
|
}
|
|
2140
2700
|
}));
|
|
2141
2701
|
|
|
2142
|
-
// POST /import/log { runId, at?, file?, errors? }
|
|
2702
|
+
// POST /import/log { runId, at?, file?, errors?, tooBig?, tooBigNames? }
|
|
2703
|
+
// 记账补充:客户端上报**失败批**(成功批由上面自己记账)。
|
|
2704
|
+
// #10(2026-09-29)新增第二条路:上报**被跳过的"单条过大"**。它与失败批是两码事 ——
|
|
2705
|
+
// 跳过是**已知且可解释**的(那一条自己就超过了单次上限,拆文件也拆不开它)。
|
|
2706
|
+
// ⚠️ **命门**:`errors` 的既有语义是 `Math.max(1, ...)`(**强制 ≥1**),所以"只报跳过"这条路
|
|
2707
|
+
// **绝不能**顺手带上 `errors` —— 否则账本会把"跳过"记成"失败批",正是要避免的混账;
|
|
2708
|
+
// 也正因为这条通路以前不存在,客户端当时只能**故意不报**,账本上看不到任何东西被跳过。
|
|
2709
|
+
// 输入护栏(账本要长期留着,不许原样吞任意长度用户输入):`tooBig` 夹 0..10000;
|
|
2710
|
+
// `tooBigNames` 只取前 5 条、每条截到 40 字、非字符串丢弃。两处口径的唯一实现在
|
|
2711
|
+
// `import-log.js` 的 `cleanTooBig` / `cleanTooBigNames`(这里显式过一遍,再交给 logImport
|
|
2712
|
+
// 自己防一道 —— 端点不是唯一入口,「三条入口共用」的那一份才是底线)。
|
|
2143
2713
|
disposers.push(register('/import/log', async (req, res) => {
|
|
2144
2714
|
const body = JSON.parse((await readBody(req)) || '{}');
|
|
2145
2715
|
const runId = String(body.runId || '').slice(0, 64);
|
|
2146
2716
|
if (!runId) return sendJson(res, 200, { ok: false, reason: 'runId', message: '缺少 runId' });
|
|
2147
|
-
const
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2717
|
+
const tooBig = cleanTooBig(body.tooBig);
|
|
2718
|
+
const patch = { kind: 'file', name: body.file, runId, at: body.at };
|
|
2719
|
+
// 既有通路(**逐字不变**):`errors` 一律 `max(1, ...)` —— 没传也记 1 批失败(旧行为)。
|
|
2720
|
+
// 只有"报跳过"这一路(tooBig > 0)才允许不带 errors;要同时报失败批,显式传 errors 即可。
|
|
2721
|
+
if (body.errors !== undefined || tooBig === 0) {
|
|
2722
|
+
patch.errors = Math.max(1, Number(body.errors) || 1);
|
|
2723
|
+
}
|
|
2724
|
+
if (tooBig > 0) {
|
|
2725
|
+
patch.tooBig = tooBig;
|
|
2726
|
+
patch.tooBigNames = cleanTooBigNames(body.tooBigNames);
|
|
2727
|
+
}
|
|
2728
|
+
const key = logImport(memory, patch);
|
|
2151
2729
|
sendJson(res, 200, { ok: true, key });
|
|
2152
2730
|
}));
|
|
2153
2731
|
|
|
@@ -2199,7 +2777,22 @@ export function registerApi(ctx, deps, config) {
|
|
|
2199
2777
|
// GET /dsweb/summary/preview?db=&minTurns=&all=1&onlyHit=1&limit=
|
|
2200
2778
|
disposers.push(register('/dsweb/summary/preview', async (req, res) => {
|
|
2201
2779
|
const q = dswebQuery(req);
|
|
2202
|
-
|
|
2780
|
+
// ── B(审计 §四.4,2026-10-01 修):`db` **只认**默认库 / settings 白名单 ──────────────
|
|
2781
|
+
// 改前这里是 `dswebDbOf(q.get('db'))` —— 收任意路径再 `openSource()` 只读打开,
|
|
2782
|
+
// 等于一个"任意本机路径存在吗"的预言机;审计注明它靠 `readOnly:true` 才不是写原语。
|
|
2783
|
+
// 判据(全仓唯一一处)在模块级的 previewDbPolicy();这里只接线 + 如实回 reason。
|
|
2784
|
+
// ⚠️ **只收紧这一条**:同文件的 `/dsweb/scan` 与 `/dsweb/summary/run` 仍走 `dswebDbOf`
|
|
2785
|
+
// (它们是 POST_ONLY 的显式动作,不在本条审计范围内 —— 顺手混改会改掉另两个端点的语义)。
|
|
2786
|
+
const pol = previewDbPolicy(q.get('db'), settings);
|
|
2787
|
+
if (!pol.ok) {
|
|
2788
|
+
return sendJson(res, 200, {
|
|
2789
|
+
ok: false, reason: pol.reason,
|
|
2790
|
+
message: '这个库路径不在白名单里,没有打开它:' + pol.asked
|
|
2791
|
+
+ '。要用它,先把它设成默认库或写进白名单 —— 环境变量 DSH_LING_DSWEB_DB,'
|
|
2792
|
+
+ '或 settings 的 dsweb.dbPath / dsweb.dbs[]。',
|
|
2793
|
+
});
|
|
2794
|
+
}
|
|
2795
|
+
const db = pol.db;
|
|
2203
2796
|
if (!db) {
|
|
2204
2797
|
return sendJson(res, 200, {
|
|
2205
2798
|
ok: false, reason: 'no-db',
|
|
@@ -2374,6 +2967,8 @@ export function registerApi(ctx, deps, config) {
|
|
|
2374
2967
|
// POST /feedback/apply { id }
|
|
2375
2968
|
disposers.push(register('/feedback/apply', async (req, res) => {
|
|
2376
2969
|
const body = JSON.parse((await readBody(req)) || '{}');
|
|
2970
|
+
// 手术门(第 11/14 条写面):采纳建议 → 落习惯 / 改自称。改前只有身份两 kind 有门。
|
|
2971
|
+
if (!gateSurgery(req, res, sendJson, { unlock: body.unlock }, { settings, logFile: surgeryLog, endpoint: '/feedback/apply' })) return;
|
|
2377
2972
|
const r = await applySuggestionAsRule(memory, settings, body.id);
|
|
2378
2973
|
if (!r.ok) return sendJson(res, 200, { ok: false, reason: r.reason });
|
|
2379
2974
|
await archivePersona(settings.get(), 'result'); // 惯例成长也留档
|
|
@@ -2414,6 +3009,10 @@ export function registerApi(ctx, deps, config) {
|
|
|
2414
3009
|
// POST /suggestions/apply { id }
|
|
2415
3010
|
disposers.push(register('/suggestions/apply', async (req, res) => {
|
|
2416
3011
|
const body = JSON.parse((await readBody(req)) || '{}');
|
|
3012
|
+
// 手术门(第 13/14 条写面):采纳建议。改前**只有** kind∈{aiName,userTitle} 才验句,
|
|
3013
|
+
// 惯例类(hardrule 等)可自由追加 ⇒ 现在整条端点统一过门(下面那段身份分支因此成了一道冗余的深防线,
|
|
3014
|
+
// 保留不动:判据更严的一侧永远不会把请求放过去)。
|
|
3015
|
+
if (!gateSurgery(req, res, sendJson, { unlock: body.unlock }, { settings, logFile: surgeryLog, endpoint: '/suggestions/apply' })) return;
|
|
2417
3016
|
const row = memory.getPersonaSuggestion(Number(body.id));
|
|
2418
3017
|
const kind = row?.kind ?? '';
|
|
2419
3018
|
// 定型门:自称/称呼属身份保护区,采纳需钥匙;惯例类建议(成长回路)可自由追加
|
|
@@ -2486,13 +3085,16 @@ export function registerApi(ctx, deps, config) {
|
|
|
2486
3085
|
}));
|
|
2487
3086
|
|
|
2488
3087
|
// GET /export
|
|
3088
|
+
// §6.1(a):导出包里的承诺句同样按渠道分流 —— **本机导出照旧带**(备份完整性:本机导出的包
|
|
3089
|
+
// 本来就是给本机恢复用的),经代理的导出剥掉(远端拿不到这句)。
|
|
2489
3090
|
disposers.push(register('/export', async (req, res) => {
|
|
2490
3091
|
const q = new URL(req.url, 'http://dsh.internal').searchParams;
|
|
3092
|
+
const local = readIsLocal(req);
|
|
2491
3093
|
const bundle = memory.exportBundle({ includeRaw: q.get('includeRaw') === '1' });
|
|
2492
3094
|
const s = settings.get();
|
|
2493
3095
|
// 人格/风格/最后模式随包附载(默认不在合并时覆盖目标)
|
|
2494
3096
|
bundle.persona = {
|
|
2495
|
-
persona: s.persona || {},
|
|
3097
|
+
persona: personaForRead(s.persona || {}, local),
|
|
2496
3098
|
styles: s.styles || {},
|
|
2497
3099
|
lastMode: s.mode?.lastMode || 'life',
|
|
2498
3100
|
};
|
|
@@ -2508,6 +3110,13 @@ export function registerApi(ctx, deps, config) {
|
|
|
2508
3110
|
const q = new URL(req.url, 'http://dsh.internal').searchParams;
|
|
2509
3111
|
const text = await readBody(req);
|
|
2510
3112
|
const parsed = JSON.parse(text || '{}');
|
|
3113
|
+
// 手术门(第 14/14 条写面)⚠️ **前移到 importBundle 之前** —— 这是本批唯一一处"改前门在后面"的修正:
|
|
3114
|
+
// 改前:`memory.importBundle()`(下一行)先跑,门在它**之后** ⇒ 一个带着恶意 bundle 的请求
|
|
3115
|
+
// 即使人格回写被拒,**记忆也已经整体被换掉了**(DESIGN §3 该行备注"门拦不住记忆被换")。
|
|
3116
|
+
// 现在:已定型时,未过门(非本机 / 无票 / 原句不符)的请求**一个字节都不落库**。
|
|
3117
|
+
// 载荷交的是**原文**:bundle 可达 4 MiB,门用 rawUnlock() 只抽 `"unlock"` 那一段,
|
|
3118
|
+
// 不整包 parse、不进留痕字段(见 surgery.js)。未定型时 gated:false ⇒ 行为与现状逐字一致。
|
|
3119
|
+
if (!gateSurgery(req, res, sendJson, text, { settings, logFile: surgeryLog, endpoint: '/import' })) return;
|
|
2511
3120
|
const bundle = parsed.bundle || parsed;
|
|
2512
3121
|
const report = memory.importBundle(bundle, { overwrite: q.get('overwrite') === '1' });
|
|
2513
3122
|
// 人格默认不覆盖;仅当显式 persona:true(如备份恢复)才合并(外来条目不污染本地人格)
|
|
@@ -2582,3 +3191,27 @@ function allSnapshotSessionIds(gate) {
|
|
|
2582
3191
|
function rawTurnSessionCount(memory) {
|
|
2583
3192
|
return memory.rawSessionCount();
|
|
2584
3193
|
}
|
|
3194
|
+
|
|
3195
|
+
/** 人话结论(唯一的拼法;前台**原样**显示它,不再自己拼第二遍)。
|
|
3196
|
+
* 要素:①几个未知来源 ②它们叫什么 + ③各多少轮 ④首次/最近见到 ⑤一句结论(已经怎么处理了)。
|
|
3197
|
+
* ⚠️ 措辞纪律:只说**查得到的事**。
|
|
3198
|
+
* · "已按外部内容处理"的依据 = summarizer.js 的 dshSessionSql() 把它们排除在概述之外、
|
|
3199
|
+
* habit-gen.js 复用同一判据把它们排除在习惯扫描之外(2026-10-01 逐点核对过调用点);
|
|
3200
|
+
* · **不说**"没混进你的会话统计" —— `memory.rawSessionCount()` 数的是全表 DISTINCT session_id,
|
|
3201
|
+
* **含**外来命名空间 ⇒ 面板上那个"DSH 原始轮次会话 N 个"目前确实把它们算在内。
|
|
3202
|
+
* 那句话听着漂亮但是假的;这里如实说"原文表里的会话数仍含它们"(见报告「与描述不一致的事实」)。
|
|
3203
|
+
* · kv 里的计数是累积的 ⇒ 用"见到"而不是"现在有"。 */
|
|
3204
|
+
function rawUnknownSourcesText({ namespaces, turns, firstSeenAt, lastSeenAt }) {
|
|
3205
|
+
const list = namespaces.map((u) => u.ns + '(' + u.turns + ' 轮)').join(' · ');
|
|
3206
|
+
const when = (v) => {
|
|
3207
|
+
const d = new Date(String(v));
|
|
3208
|
+
if (isNaN(d.getTime())) return String(v); // 脏值原样印出,不做自作聪明的截取
|
|
3209
|
+
const p = (n) => (n < 10 ? '0' : '') + n;
|
|
3210
|
+
return p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes());
|
|
3211
|
+
};
|
|
3212
|
+
const seen = (firstSeenAt && lastSeenAt)
|
|
3213
|
+
? '首次见到 ' + when(firstSeenAt) + ',最近见到 ' + when(lastSeenAt) + '。'
|
|
3214
|
+
: (firstSeenAt ? '首次见到 ' + when(firstSeenAt) + '(未记最近时刻)。' : '时刻未记录。');
|
|
3215
|
+
return '有 ' + namespaces.length + ' 个我没见过的原文来源:' + list + ',共 ' + turns + ' 轮原文 —— '
|
|
3216
|
+
+ '已按外部内容处理:没被概述、也没进你的习惯统计(原文表里那个"会话数"仍含它们)。' + seen;
|
|
3217
|
+
}
|