@nightpainters/dsh-ling 1.5.1 → 1.5.2
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 +6 -4
- package/CHANGELOG.md +65 -0
- package/DESIGN.md +141 -14
- package/README.md +55 -31
- package/lib/audit/access-log.js +135 -13
- package/lib/audit/index.js +43 -1
- package/lib/client.js +902 -57
- package/lib/host/api.js +129 -31
- package/lib/host/genesis.js +27 -5
- package/lib/host/guard.js +1 -1
- package/lib/host/inject.js +49 -12
- package/lib/host/l1.js +11 -4
- package/lib/host/memory.js +263 -20
- package/lib/host/persona.js +38 -7
- package/lib/host/session-line.js +648 -0
- package/lib/host/snapshot.js +48 -7
- package/lib/host/summarizer.js +1 -1
- package/lib/index.js +5 -0
- package/package.json +51 -4
package/lib/host/memory.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Schema per DESIGN §7. All access single-process (host).
|
|
3
3
|
import { DatabaseSync } from 'node:sqlite';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
5
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
6
6
|
import { randomUUID } from 'node:crypto';
|
|
7
7
|
import { ensureDir, sha256Text, utcIso, mergeDeep, toIso, toEpochMs } from './util.js';
|
|
8
8
|
// A-01(1.5.1):主脉记忆被覆盖前**必须先归档**。archive.js 只依赖 node:fs / node:path,
|
|
@@ -23,6 +23,26 @@ export const LINEAGE_ANCESTOR = 0.7; // 枝读其祖先(直系血缘:主干就
|
|
|
23
23
|
export const LINEAGE_SIDE = 0.4; // 旁系 / 祖先读后代(枝的内容不构成主干的记忆)
|
|
24
24
|
export const CONFLICT_DOWNWEIGHT = 0.3; // 矛盾败方降权系数(D9-b:未复盘时以最新为准,旧的一方乘此值)
|
|
25
25
|
|
|
26
|
+
/** B3(1.5.2):树改动播报的**两条全局水位** —— 与按会话水位并存,语义各自独立:
|
|
27
|
+
* · `announced`:全局"**已示人**"边界 —— 只推进到**真正渲染过**的行(branchLogDigest 的
|
|
28
|
+
* shownMaxId)。它的职责是"已经给谁看过的不再重播"(修 B3-1:新会话从 0 起把最新 5 条又播一遍)。
|
|
29
|
+
* · `retired` :全局"**已按时间窗计数式处置**"的边界 —— 超窗老改动只计数、不展开,
|
|
30
|
+
* **不算已示人**,所以必须独立成键。为什么不能复用它(1.5.2 定案:两条路都堵死):
|
|
31
|
+
* (a) 不给独立游标 ⇒ 某一轮"可展开行 = 0"时 `shownMaxId=0`,**水位永不前进、每轮重播同一行**(死锁);
|
|
32
|
+
* (b) 把它并进 `announced` ⇒ 把**从没展开过的**老账标成"已示人" = B3-2 的"没看就清"换个马甲。
|
|
33
|
+
* 回归:死锁构造(`id` 更大但 `at` 为 30 天前)见 tests/tree.test.mjs 第 9-4b / 9-4c 条。
|
|
34
|
+
* 另两个**本版一字未动**:`branch_log_wm.<sessionId>`(会话水位)与 `branch_log_wm.last`
|
|
35
|
+
* (全表最大 id —— 复盘模式的退出判据,消费点见 branchLogPending)。 */
|
|
36
|
+
export const BRANCH_LOG_WM_ANNOUNCED = 'branch_log_wm.announced';
|
|
37
|
+
export const BRANCH_LOG_WM_RETIRED = 'branch_log_wm.retired';
|
|
38
|
+
|
|
39
|
+
/** §2 时间窗默认天数(1.5.2):只**展开**最近这么多天的树改动,更老的只计数不展开。
|
|
40
|
+
* 取 3 的依据(实测 live 库,394 笔留痕):92% 是 ≥3 天前的程序批量(单日最高 243 笔),
|
|
41
|
+
* 近 3 天只占 32 笔 —— 3 天正好把"刚改的树"与"很久以前那批"分开;
|
|
42
|
+
* 改小(1 天)会把 09-24 那批 24 笔也算老账,改大(7 天)会把 09-23 的 243 笔重新拉回明细。
|
|
43
|
+
* `<= 0` = 关闭时间窗(全部按近期展开)。 */
|
|
44
|
+
export const BRANCH_LOG_WINDOW_DAYS = 3;
|
|
45
|
+
|
|
26
46
|
/** 原文命名空间(G4/2026-09-17):dsh_turns_raw 无 source 维,DSH 会话与导入原文共表。
|
|
27
47
|
* 若不隔离,概述器会把导入内容当成 DSH 会话重建(GROUP BY session_id 认领)→ 跨源双份;
|
|
28
48
|
* 同 id 时更会互相覆盖原文(不可恢复)。故导入原文一律落 'import:<conv_id>'。
|
|
@@ -947,22 +967,52 @@ export class MemoryStore {
|
|
|
947
967
|
* 水位用**自增 id** 而非时间戳 —— 同一毫秒内的多条改动用时间戳比较会漏。
|
|
948
968
|
* 只报结构操作,不碰任何记忆内容(与"内容零改写"不变量一致)。
|
|
949
969
|
*/
|
|
950
|
-
branchLogDigest({ limit = 5, afterId = null } = {}) {
|
|
970
|
+
branchLogDigest({ limit = 5, afterId = null, now = Date.now(), windowDays = BRANCH_LOG_WINDOW_DAYS } = {}) {
|
|
951
971
|
const max = Math.max(1, Math.min(20, Number(limit) || 5));
|
|
952
972
|
// B#9(2026-09-22 审计):水位必须**按会话** —— 全局键会让会话 A 消费之后、
|
|
953
973
|
// 会话 B 再也看不到同一批改动。调用方传 afterId(取自 kv `branch_log_wm.<sessionId>`);
|
|
954
974
|
// 未传才退回全局键(仅为兼容旧调用点,新代码不应依赖)。
|
|
955
|
-
|
|
975
|
+
// ── B3(1.5.2)在旁边加注:上面这条裁决**保留原文、今天依然成立** ——
|
|
976
|
+
// 它管的是"别漏";它没管另一头:新会话水位从 0 起会把**已经播过的**又播一遍(B3-1),
|
|
977
|
+
// 而"播完就把水位推到全表最新 id"会把**从没示人**的中间那些标成已读(B3-2)。
|
|
978
|
+
// 所以 1.5.2 **不是改回全局单键**,而是**两条并存**,有效下界取三者更大者:
|
|
979
|
+
// · `branch_log_wm.<sessionId>` 本会话已看到哪(会话增量,B#9 原样保留);
|
|
980
|
+
// · `branch_log_wm.announced` **全局**"已示人"边界(只推进到真正渲染过的行 ⇒
|
|
981
|
+
// 已经给谁看过的不再重播,新会话因此不重播);
|
|
982
|
+
// · `branch_log_wm.retired` **全局**"已按时间窗计数式处置"的边界(§2 老改动
|
|
983
|
+
// 只计数不展开 ⇒ **不算已示人**,故独立成键,只防同一批老账每轮重复计数)。
|
|
984
|
+
const sessionWm = (afterId !== null && afterId !== undefined && Number.isFinite(Number(afterId)))
|
|
956
985
|
? Number(afterId)
|
|
957
986
|
: Number(this.kvGet('branch_log_wm') || 0);
|
|
987
|
+
const wm = Math.max(
|
|
988
|
+
sessionWm,
|
|
989
|
+
Number(this.kvGet(BRANCH_LOG_WM_ANNOUNCED) || 0) || 0,
|
|
990
|
+
Number(this.kvGet(BRANCH_LOG_WM_RETIRED) || 0) || 0,
|
|
991
|
+
);
|
|
958
992
|
let rows = [];
|
|
959
993
|
try {
|
|
960
|
-
|
|
994
|
+
// B3-2(1.5.2):候选从"全表最新 120 行里挑最新的 limit 条"改成**水位之上最早的 limit 条**
|
|
995
|
+
// (FIFO)。病根就在取数顺序 —— 这是本次修复的**实质**,别当成等价改写:
|
|
996
|
+
// 旧取数是 `ORDER BY id DESC` + `slice(0, max)`,渲染出来的是**最新那 5 条**(一个后缀);
|
|
997
|
+
// 于是原代码的 `maxId: Number(fresh[0].id)` **看着像"渲染到的最大 id",其实从来不是** ——
|
|
998
|
+
// 它取的是**最新那条**。水位一跳,落在"最新 5 条"与"上一轮水位"之间的那批改动
|
|
999
|
+
// (**比这 5 条更老**)既没示人、又被标成已读:live 上实测中间 114 笔。
|
|
1000
|
+
// FIFO 之后"渲染到的最大 id"才真正小于"全表最新","下一轮还能看到剩下的"才成立。
|
|
1001
|
+
rows = this.listBranchLog({ limit: 120, afterId: wm, ascending: true });
|
|
961
1002
|
} catch {
|
|
962
|
-
return { lines: [], maxId: wm, total: 0 };
|
|
1003
|
+
return { lines: [], maxId: wm, shownMaxId: 0, retiredMaxId: 0, total: 0, expired: 0 };
|
|
963
1004
|
}
|
|
964
|
-
|
|
965
|
-
|
|
1005
|
+
if (!rows.length) {
|
|
1006
|
+
return { lines: [], maxId: wm, shownMaxId: 0, retiredMaxId: 0, total: 0, expired: 0 };
|
|
1007
|
+
}
|
|
1008
|
+
// 全表最大 id:与 1.5.1 同值(那时取 id DESC 结果的 `fresh[0].id`),现在显式问一次 MAX,
|
|
1009
|
+
// 与取数顺序解耦。**只喂 `branch_log_wm.last`**(复盘退出判据),不参与"已示人"判定。
|
|
1010
|
+
const windowMaxId = rows.reduce((m, r) => Math.max(m, Number(r.id) || 0), 0);
|
|
1011
|
+
let tableMaxId = windowMaxId;
|
|
1012
|
+
try {
|
|
1013
|
+
const t = this.db.prepare('SELECT MAX(id) AS m FROM branch_log').get();
|
|
1014
|
+
tableMaxId = Number((t && t.m) || 0) || windowMaxId;
|
|
1015
|
+
} catch { /* 兜底:窗口内最大(已知下限) */ }
|
|
966
1016
|
// ── B1(1.5.1):真实剩余笔数**必须问库** ─────────────────────────────────────
|
|
967
1017
|
// 上面 listBranchLog 只取最近 120 行,积压超过 120 时窗口恒满;旧代码拿
|
|
968
1018
|
// `fresh.length - lines.length`(= limit 夹取差,恒为 120-5=115)当"另有 N 笔",
|
|
@@ -970,12 +1020,88 @@ export class MemoryStore {
|
|
|
970
1020
|
// 这里补一条独立 COUNT:branch_log.id 是 INTEGER PRIMARY KEY(AUTOINCREMENT),
|
|
971
1021
|
// `WHERE id > ?` 是主键范围扫描 —— 只走索引不拉行,代价与积压量无关,
|
|
972
1022
|
// 所以**不需要**把 limit 调大去"凑数"(那才是真把几百行读进内存)。
|
|
973
|
-
//
|
|
974
|
-
|
|
1023
|
+
// 只服务展示:水位推进 1.5.2 起由下方 `shownMaxId` / `retiredMaxId` 负责
|
|
1024
|
+
// (1.5.1 时此处写的是"B3 按主人的决定推迟到 1.5.2",现已实现)。
|
|
1025
|
+
let realTotal = rows.length; // 兜底:COUNT 失败时退回窗口内条数(已知下限)
|
|
975
1026
|
try {
|
|
976
1027
|
const agg = this.db.prepare('SELECT COUNT(*) AS n FROM branch_log WHERE id > ?').get(wm);
|
|
977
|
-
if (agg) realTotal = Math.max(Number(agg.n) || 0,
|
|
1028
|
+
if (agg) realTotal = Math.max(Number(agg.n) || 0, rows.length);
|
|
978
1029
|
} catch { /* 库出错时退回窗口口径:摘要宁可少报,也不能整段消失 */ }
|
|
1030
|
+
// ── §2 时间窗(1.5.2):只**展开**最近 windowDays 天的改动 ─────────────────────
|
|
1031
|
+
// 超窗行**只计数不展开**,并且**不算已示人** ⇒ 计数落在 retiredMaxId,由投递侧写进
|
|
1032
|
+
// `branch_log_wm.retired`,让水位能越过它们(否则同一批老账每轮重算、水位永不前进)。
|
|
1033
|
+
// 判定用 JS 逐行做(而不是在 SQL 里比字符串):展示口径只有一处;且"at 读不出来"的行
|
|
1034
|
+
// 明确回退成**展开**(宁可多占一行,也不静默折叠)。windowDays<=0 ⇒ 不做时间窗。
|
|
1035
|
+
const cutoff = Number(windowDays) > 0 ? Number(now) - Number(windowDays) * 864e5 : 0;
|
|
1036
|
+
const isExpired = (r) => {
|
|
1037
|
+
if (!cutoff) return false;
|
|
1038
|
+
const t = Date.parse(String(r.at || ''));
|
|
1039
|
+
return Number.isFinite(t) ? t < cutoff : false;
|
|
1040
|
+
};
|
|
1041
|
+
// 前缀扫描:水位之上按 id 升序,遇到第 max 条"要展开的"行即停 —— 这一段里每一行
|
|
1042
|
+
// 要么展开(进 lines)、要么按超窗计数,**没有第三种**;而水位只推进到这一段之内,
|
|
1043
|
+
// 所以水位之下的行全部已被处置,不会出现"跳过去又没交代"的空洞。
|
|
1044
|
+
// ── expired 低报修复(B3 后续,2026-09-27 实测)────────────────────────────────
|
|
1045
|
+
// 病根:`expired` 原先只在**扫描窗口内**(rows = 水位之上最早的 120 行)逐行累加,
|
|
1046
|
+
// 而 ann/min(20,limit) 之外还有一个**常量 120** 的窗口上限 ⇒ 超窗行超过 120 时
|
|
1047
|
+
// 计数**封顶在 120**。live 实测:真库 branch_log 395 行,真分界 id=368
|
|
1048
|
+
// (超窗 368 / 窗内 27),而注入面报的是「其中 120 笔已超过 3 天」——低报 248。
|
|
1049
|
+
// 更糟的是它不是"暂时不准":`branch_log_wm.announced` 一旦推进到全表最新,
|
|
1050
|
+
// 有效下界 wm = max(会话水位, announced, retired) 就把扫描顶死,**括号永不修正**。
|
|
1051
|
+
// 修法(二选一中的**路径 b**):计数与取数**分开** ——
|
|
1052
|
+
// · 取明细仍用 120 行窗口(性能照旧,不把几百行读进内存);
|
|
1053
|
+
// · `expired` 单独再扫一遍**水位之上的全部行**只做计数(本机存量 395,量级很小)。
|
|
1054
|
+
// 为什么不用 SQL COUNT(路径 a):`at` 是 TEXT 且**历史形态不齐** —— 写入端是 utcIso()
|
|
1055
|
+
// 的 `…Z` 毫秒串,但库里有秒级/无毫秒/带偏移的存量行;SQL 的 `at < ?` 是**字符串比较**,
|
|
1056
|
+
// 与 JS 的 `Date.parse` **不是同一个口径**(例:`'2026-09-24T01:05:48Z' < '2026-09-24T01:05:48.036Z'`
|
|
1057
|
+
// 在字符串序里为真,而 parse 出来 t == cutoff ⇒ JS 判**不超窗**、SQL 判超窗)。
|
|
1058
|
+
// 要"语义完全一致"就得在 SQL 里复刻 Date.parse 的宽松解析,那等于把口径写成两份 ⇒
|
|
1059
|
+
// 违背本函数 `:1033` 那条「展示口径只有一处」的设计意图。JS 单点口径天然一致:
|
|
1060
|
+
// 计数与渲染调的是**同一个** `isExpired`(含"at 读不出来 ⇒ 回退成展开"的分支)。
|
|
1061
|
+
// 边界:JS 判 `t < cutoff`,即 `at` **恰好等于** cutoff 的行算**窗内**(不超窗)——
|
|
1062
|
+
// 新计数与渲染同用它,故边界行既不进 expired、也不会被渲染侧判成超窗(回归见 tree 套 9-7)。
|
|
1063
|
+
// 注意 `retiredMaxId` 在此处**仍只认扫描窗口内**超窗行的最大 id;窗口外的整段老账
|
|
1064
|
+
// 由下面那段"连续超窗前缀"扫描补上(它不是"水位跳过未见过的行":前缀扫描碰到第一条
|
|
1065
|
+
// 窗内行就停 ⇒ 不会略过任何还没轮到展开的行,而 B3-2 的病根正是那种略过)。
|
|
1066
|
+
const picked = [];
|
|
1067
|
+
let retiredMaxId = 0;
|
|
1068
|
+
for (const r of rows) {
|
|
1069
|
+
if (isExpired(r)) {
|
|
1070
|
+
retiredMaxId = Math.max(retiredMaxId, Number(r.id) || 0);
|
|
1071
|
+
continue;
|
|
1072
|
+
}
|
|
1073
|
+
picked.push(r);
|
|
1074
|
+
if (picked.length >= max) break;
|
|
1075
|
+
}
|
|
1076
|
+
// 独立计数扫描:只数笔数,不取字段以外的任何东西(与上面 realTotal 的 COUNT 一样
|
|
1077
|
+
// 走 `WHERE id > ?` 主键范围扫描);**只在需要出括号时**才扫(pending<=0 时括号不渲染)。
|
|
1078
|
+
// 兜底:库出错 ⇒ 退回扫描窗口内的口径(已知下限),摘要宁可少报也不能整段消失。
|
|
1079
|
+
let expired = rows.reduce((n, r) => n + (isExpired(r) ? 1 : 0), 0);
|
|
1080
|
+
if (realTotal - picked.length > 0 && cutoff) {
|
|
1081
|
+
try {
|
|
1082
|
+
const above = this.db.prepare(
|
|
1083
|
+
'SELECT id, at FROM branch_log WHERE id > ? ORDER BY id ASC LIMIT 100000'
|
|
1084
|
+
).all(wm);
|
|
1085
|
+
expired = above.reduce((n, r) => n + (isExpired(r) ? 1 : 0), 0);
|
|
1086
|
+
// ── 同一批老账还要能"越过"(2026-09-27,修 expired 时暴露的**饥饿**)──────────
|
|
1087
|
+
// 上面把计数改真之后,暴露出原先没被看见的后果:`retiredMaxId` 只认**扫描窗口内**
|
|
1088
|
+
// 超窗行的最大 id ⇒ 窗口(120 行)被老账占满时,水位每轮只前进 120,
|
|
1089
|
+
// 而下一轮同样的老账又占满窗口 ⇒ **明细行永远渲染不出来**(live 实测:真库 368 条
|
|
1090
|
+
// 超窗行、`retired` 卡在 120;注入面只剩一行计数,一条明细也看不到)。
|
|
1091
|
+
// 修法:让 retired 一次走到**连续超窗前缀的末尾** —— 从"已处置游标 + 1"起按 id
|
|
1092
|
+
// 升序走,只要还是超窗就继续,碰到**第一条窗内行即停**。它天然不越过任何窗内行
|
|
1093
|
+
// (B3-2 的病根正是"水位跳过了没示人的行"),所以既解饥饿又不重蹈覆辙。
|
|
1094
|
+
// 只**加宽**、不回退(游标单调),故与上面扫描窗口的结论取更大者。
|
|
1095
|
+
let lastRetired = Number(this.kvGet(BRANCH_LOG_WM_RETIRED) || 0) || 0;
|
|
1096
|
+
for (const r of above) {
|
|
1097
|
+
if (Number(r.id) <= lastRetired) continue;
|
|
1098
|
+
if (!isExpired(r)) break;
|
|
1099
|
+
lastRetired = Number(r.id) || lastRetired;
|
|
1100
|
+
}
|
|
1101
|
+
retiredMaxId = Math.max(retiredMaxId, lastRetired);
|
|
1102
|
+
} catch { /* 退回窗口口径(已知下限) */ }
|
|
1103
|
+
}
|
|
1104
|
+
const shownMaxId = picked.length ? Number(picked[picked.length - 1].id) || 0 : 0;
|
|
979
1105
|
const names = new Map(this.listBranches().map((b) => [b.id, b.name || b.id]));
|
|
980
1106
|
// 提示面净化(审计 §2.5 + B#2,2026-09-22):
|
|
981
1107
|
// ① 枝名/id 一律翻译或缩成短号,**永不吐裸 uuid**(实测:gather/autobuild 两支漏了 nm(),
|
|
@@ -996,7 +1122,7 @@ export class MemoryStore {
|
|
|
996
1122
|
return '条目 ' + k.slice(-8); // 其余按记忆条目
|
|
997
1123
|
};
|
|
998
1124
|
const flat = (s) => String(s == null ? '' : s).replace(/[\r\n\t]+/g, ' ').trim().slice(0, 80);
|
|
999
|
-
const lines =
|
|
1125
|
+
const lines = picked.map((r) => {
|
|
1000
1126
|
const who = nm(r.branchId);
|
|
1001
1127
|
const line = (() => {
|
|
1002
1128
|
switch (String(r.action || '')) {
|
|
@@ -1048,16 +1174,45 @@ export class MemoryStore {
|
|
|
1048
1174
|
const subject = String(r.actor || 'user') === 'ling' ? '器灵' : '主人';
|
|
1049
1175
|
return flat(subject + (/^[A-Za-z0-9]/.test(line) ? ' ' : '') + line);
|
|
1050
1176
|
});
|
|
1051
|
-
//
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1177
|
+
// ── §4「超量折叠成一行」(1.5.2,与双水位同批上)────────────────────────────
|
|
1178
|
+
// 最多 max 行明细 + 一行计数;**折算的余量走 realTotal**(B1:真实剩余笔数,不是窗口差)。
|
|
1179
|
+
// 这里能成立的关键是水位的口径:水位停在**最后一条明细行**(shownMaxId),被折掉的行
|
|
1180
|
+
// id 仍在水位之上 ⇒ 它们**没被标成已示人**,下一轮继续展开,直到真的示人。
|
|
1181
|
+
// 1.5.1 那次上折叠反效果的机制正在此:折叠了、水位却照样跳到全表最新 ⇒ 折掉的内容
|
|
1182
|
+
// 再也看不见(改前至少还露出 5 条)。**永久折叠(超阈值只留一行摘要 + 推进水位)与
|
|
1183
|
+
// "折掉的行仍是未示人"逻辑上不可两全**:折了不推进 ⇒ 每轮重播同一行,推进 ⇒ 内容丢失。
|
|
1184
|
+
// 所以 1.5.2 只做这条**可收敛的折叠**,不设 BRANCH_LOG_FOLD_THRESHOLD 之类的阈值常量。
|
|
1185
|
+
const pending = Math.max(0, realTotal - picked.length);
|
|
1186
|
+
// 夹取保留(兜底:防"其中 N 笔"超过"另有 M 笔")。修好 expired 之后它**不会触发**:
|
|
1187
|
+
// 计数扫描的范围是 `id > wm`,而 pending = realTotal - picked.length 是同一范围内
|
|
1188
|
+
// 除已展开行以外的全部行 ⇒ 超窗行(既非 picked、又必在 realTotal 里)**是 pending 的子集**,
|
|
1189
|
+
// 故恒有 expired <= pending。唯一例外是计数扫描失败退回窗口口径,那时夹取正好兜住低报方向的错。
|
|
1190
|
+
if (pending > 0) {
|
|
1191
|
+
lines.push('…另有 ' + pending + ' 笔改动'
|
|
1192
|
+
+ (expired > 0
|
|
1193
|
+
? '(其中 ' + Math.min(expired, pending) + ' 笔已超过 ' + Math.round(Number(windowDays)) + ' 天,只计数不展开)'
|
|
1194
|
+
: ''));
|
|
1195
|
+
}
|
|
1196
|
+
return { lines, maxId: tableMaxId, shownMaxId, retiredMaxId, total: realTotal, expired };
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
/** 复盘留痕列表(默认最近的在前)。
|
|
1200
|
+
* B3(1.5.2)加了两个**可选**参数给播报用,不传时行为与 1.5.1 逐字一致(侧栏/API 照旧):
|
|
1201
|
+
* · `afterId`:只要 id 更大的行(主键范围扫描,配 ascending 取"水位之上最早的 N 条");
|
|
1202
|
+
* · `ascending`:按 id 升序(FIFO)。播报必须**从最早未示人的一条开始** —— 见
|
|
1203
|
+
* branchLogDigest 里 B3-2 的注解。 */
|
|
1204
|
+
listBranchLog({ limit = 100, afterId = null, ascending = false } = {}) {
|
|
1058
1205
|
try {
|
|
1059
1206
|
const n = Math.max(1, Math.min(500, Number(limit) || 100));
|
|
1060
|
-
|
|
1207
|
+
const floor = (afterId !== null && afterId !== undefined && Number.isFinite(Number(afterId)))
|
|
1208
|
+
? Number(afterId)
|
|
1209
|
+
: null;
|
|
1210
|
+
// ascending 只做字面拼接(调用方传布尔,不接外部输入);floor 走占位符。
|
|
1211
|
+
const sql = 'SELECT * FROM branch_log'
|
|
1212
|
+
+ (floor === null ? '' : ' WHERE id > ?')
|
|
1213
|
+
+ ' ORDER BY id ' + (ascending ? 'ASC' : 'DESC') + ' LIMIT ?';
|
|
1214
|
+
const args = floor === null ? [n] : [floor, n];
|
|
1215
|
+
return this.db.prepare(sql).all(...args).map((r) => ({
|
|
1061
1216
|
id: Number(r.id),
|
|
1062
1217
|
at: String(r.at || ''),
|
|
1063
1218
|
branchId: String(r.branch_id || ''),
|
|
@@ -1924,6 +2079,19 @@ export class MemoryStore {
|
|
|
1924
2079
|
return rows.map(mapRow);
|
|
1925
2080
|
}
|
|
1926
2081
|
|
|
2082
|
+
/** 只数条数(1.5.2):`/state` 的两处调用点只要 `listOverviews({onlyOk:true}).length`,
|
|
2083
|
+
* 而全表 `SELECT *` + mapRow×N 实测 4.82ms、`COUNT(*)` 0.08ms。where 口径与 `listOverviews` 逐字一致
|
|
2084
|
+
* ⇒ **同库同参下两者恒等**(只有"全表读"换成"计数")。返回 number。 */
|
|
2085
|
+
countOverviews({ onlyOk, source } = {}) {
|
|
2086
|
+
let sql = 'SELECT COUNT(*) AS n FROM conv_overview';
|
|
2087
|
+
const where = [];
|
|
2088
|
+
if (onlyOk) where.push('overview_ok=1');
|
|
2089
|
+
if (source) where.push('source=?');
|
|
2090
|
+
if (where.length) sql += ' WHERE ' + where.join(' AND ');
|
|
2091
|
+
const row = source ? this.db.prepare(sql).get(source) : this.db.prepare(sql).get();
|
|
2092
|
+
return Number(row?.n ?? 0);
|
|
2093
|
+
}
|
|
2094
|
+
|
|
1927
2095
|
/** 记忆中心查询:过滤(source/category/q)+ 排序 + 分页;返回 {items,total}。
|
|
1928
2096
|
* source 不限 dsweb/dsh——文件导入等第三方来源(source='import')同样可按源过滤。 */
|
|
1929
2097
|
queryOverviews({ source, category, q, branch, sort = 'updated', limit = 100, offset = 0 } = {}) {
|
|
@@ -2340,9 +2508,84 @@ export class MemoryStore {
|
|
|
2340
2508
|
return report;
|
|
2341
2509
|
}
|
|
2342
2510
|
|
|
2511
|
+
/** WAL 守卫(1.5.2):缓存键的第二来源。
|
|
2512
|
+
* `total_changes()` 是**本连接**的计数 —— 别的进程在宿主运行期间直写 memory.db 时它一动不动,
|
|
2513
|
+
* 只比 n 会让面板指纹**停在旧值**(旧实现每次全表读,是看得见的)。提交必然落进 `-wal`,
|
|
2514
|
+
* 所以取 `-wal` 的 size + mtimeMs 并进键:外部写入 ⇒ 文件动 ⇒ 缓存失效。
|
|
2515
|
+
* 实测两次 statSync 约 0.014ms(1500 行库,/state 上可忽略;一次重算实测约 10.7ms)。
|
|
2516
|
+
* `-wal` 不存在(非 WAL / 已关连接 / 连接刚开还没写过)⇒ 视为 0/0 哨兵,**绝不抛**:
|
|
2517
|
+
* 这条路径挂在一个每拍都跑的面板接口上,statSync 抛错就会把 /state 变成 500。
|
|
2518
|
+
* ⚠️ 代价是 0/0 也是"合法键值",所以下面三个缓存字段**只在重算真正完成后才推**。
|
|
2519
|
+
* ⚠️ 别把这两个字段的存在当成"守卫一定在工作":`statSync` 抽风/漏 import 时它恒返 [0,0],
|
|
2520
|
+
* 面板照旧显示、测试照旧全绿,而守卫是**静默空转**的(本轮就踩过:忘了 import statSync,
|
|
2521
|
+
* ReferenceError 被本方法的 catch 吞掉)。要确认它活着,见 _probe/tmp-fp-wal-guard.mjs 的
|
|
2522
|
+
* 【陷阱守卫】断言:它必须返回真实 size/mtime,不能是 [0,0]。 */
|
|
2523
|
+
_walStamp() {
|
|
2524
|
+
try {
|
|
2525
|
+
const s = statSync(this.dbPath + '-wal', { throwIfNoEntry: false });
|
|
2526
|
+
return s ? [s.size, s.mtimeMs] : [0, 0];
|
|
2527
|
+
} catch {
|
|
2528
|
+
return [0, 0]; // EACCES/EBUSY 等一律降级为哨兵:宁可回退到"每次都重算",也不让 /state 炸
|
|
2529
|
+
}
|
|
2530
|
+
}
|
|
2531
|
+
|
|
2532
|
+
/** data_version 守卫(1.5.2):缓存键的**第三**来源,专补 `-wal` 那一维的盲区。
|
|
2533
|
+
* 上面的 `_walStamp()` 有个洞:维护/备份脚本若用 `journal_mode=DELETE` 改库,**根本没有 `-wal`
|
|
2534
|
+
* 文件**,stat 恒返 [0,0] 哨兵 ⇒ 外进程改完面板还是旧指纹。`PRAGMA data_version` 正好互补
|
|
2535
|
+
* (实测见 _probe/tmp-dv-semantics.mjs,数值原样):
|
|
2536
|
+
* · 本连接自己写 ⇒ **不变**(2→2) ⇒ 它**不能**替代 `total_changes()`,只补位;
|
|
2537
|
+
* · 别的连接提交 ⇒ **变**(2→3;DELETE 模式 1→2);
|
|
2538
|
+
* · 外连接写完立刻 `wal_checkpoint(TRUNCATE)` 把 -wal 截成 0 ⇒ **照样变**(3→4);
|
|
2539
|
+
* · 本连接纯读 ⇒ **不变**(4→4)⇒ 不会平白抖掉缓存。
|
|
2540
|
+
* 取不到(旧 SQLite / 语句被拒)⇒ 降级为 -1 哨兵,**绝不抛**(这条路径挂在每拍一次的 /state 上)。
|
|
2541
|
+
* ⚠️ 与 `_walStamp()` 同一个陷阱:降级值 -1 也是**合法键值**,长期取不到会静默退化成一维空转 ——
|
|
2542
|
+
* 别把"字段存在"当成"守卫在工作"。探针的【陷阱守卫】断言要求这里必须是**真实整数**,不能是 -1。 */
|
|
2543
|
+
_dataVersion() {
|
|
2544
|
+
try {
|
|
2545
|
+
const r = this.db.prepare('PRAGMA data_version').get();
|
|
2546
|
+
const v = Number(r ? (r.data_version ?? Object.values(r)[0]) : NaN);
|
|
2547
|
+
return Number.isInteger(v) ? v : -1;
|
|
2548
|
+
} catch {
|
|
2549
|
+
return -1; // 退化成"这一维不参与",由其余三维兜,行为等价于加它之前
|
|
2550
|
+
}
|
|
2551
|
+
}
|
|
2552
|
+
|
|
2553
|
+
/** 全表指纹:64 位小写 sha256(面板 client.js:980 取前 16 位展示,tests\unit.mjs:165 断言 length===64)。
|
|
2554
|
+
* 1.5.2:唯一消费方是**每拍一次**的 `/state`,而这里每次全表读 + JSON + sha256 实测约 10.7ms
|
|
2555
|
+
* (库 1612 行 ⇒ JSON 910KB)⇒ 以**五维**缓存键替代重算,键没动就直接返回上次那个字符串:
|
|
2556
|
+
* `_fpN`(`total_changes()`,管本连接自己写)+ `_fpWalSize`/`_fpWalMtime`(兜外进程写)
|
|
2557
|
+
* + `_fpDv`(`PRAGMA data_version`,兜不经 -wal 的写)+ `_fpDb`(句柄,兜关连接再开)。
|
|
2558
|
+
* 四者**互补**,任一维动都会重算 —— 宁可白算,不可漏读。**返回值逐字不变**。 */
|
|
2343
2559
|
fingerprint() {
|
|
2560
|
+
// `total_changes()` 是**本连接**的累计写行数:写就动、只读不动、0 行 UPDATE 不动
|
|
2561
|
+
// ⇒ 偏保守(至多多算一次),不会漏算本连接的任何一次写入。
|
|
2562
|
+
// 连 `this.db` 句柄一起比:万一将来出现"关连接再开"的替换,total_changes 会从 0 重来,
|
|
2563
|
+
// 只比 n 就可能撞回上一世的旧值;比句柄则必然重算。
|
|
2564
|
+
const n = this.db.prepare('SELECT total_changes() AS n').get().n;
|
|
2565
|
+
const [walSize, walMtime] = this._walStamp(); // 兜**外进程**写入:它们不进本连接的计数
|
|
2566
|
+
const dv = this._dataVersion(); // 兜**不经 -wal** 的写入(DELETE 模式维护脚本),见该方法注释
|
|
2567
|
+
// ⚠️ 顺序有讲究:三维**必须先于**下面那次全表读。反过来(先读表、后读守卫)会出现:
|
|
2568
|
+
// 表读到旧内容 → 外进程此刻提交 → 守卫读到新值 ⇒ 于是"旧内容哈希"被记在新键下,
|
|
2569
|
+
// 下一次守卫与新键相等 ⇒ 命中 ⇒ 面板**永停在旧指纹**。守卫先读则最坏只是多算一次。
|
|
2570
|
+
if (
|
|
2571
|
+
this._fpVal !== undefined &&
|
|
2572
|
+
this._fpN === n &&
|
|
2573
|
+
this._fpWalSize === walSize &&
|
|
2574
|
+
this._fpWalMtime === walMtime &&
|
|
2575
|
+
this._fpDv === dv &&
|
|
2576
|
+
this._fpDb === this.db
|
|
2577
|
+
) {
|
|
2578
|
+
return this._fpVal;
|
|
2579
|
+
}
|
|
2344
2580
|
const rows = this.db.prepare('SELECT * FROM conv_overview ORDER BY source, conv_id').all();
|
|
2345
|
-
|
|
2581
|
+
const val = sha256Text(JSON.stringify(rows.map(mapRow)));
|
|
2582
|
+
this._fpN = n; // ⚠️ 缓存字段只在**重算真的完成之后**才推(中途抛错 ⇒ 键不动 ⇒ 下次仍重算)
|
|
2583
|
+
this._fpWalSize = walSize;
|
|
2584
|
+
this._fpWalMtime = walMtime;
|
|
2585
|
+
this._fpDv = dv;
|
|
2586
|
+
this._fpDb = this.db;
|
|
2587
|
+
this._fpVal = val;
|
|
2588
|
+
return val;
|
|
2346
2589
|
}
|
|
2347
2590
|
}
|
|
2348
2591
|
|
package/lib/host/persona.js
CHANGED
|
@@ -42,6 +42,10 @@ export const DEFAULT_SETTINGS = Object.freeze({
|
|
|
42
42
|
toneWork: '', // 工作模式语气('' = 跟随 tone)
|
|
43
43
|
toneLife: '', // 生活模式语气('' = 跟随 tone)
|
|
44
44
|
extraLore: '', // 自由扩展设定
|
|
45
|
+
duty: '', // 职业/职责/责任(1.5.2 I-5):改变率极低的身份级设定。**只收陈述句**
|
|
46
|
+
// (「我承担/我擅长/我能给出」),**禁授权式**(「我应当/我必须/我的任务/我的责任是」)——
|
|
47
|
+
// 授权式写法把人写成"被授权者",是语义劫持最好用的钩子(同 [规矩] 段头那次的教训)。
|
|
48
|
+
// 默认只能是 ''(约束 B):机制不设非空默认、也不从历史/模式推断预填。
|
|
45
49
|
pronoun: '她', // 第三人称代词(她/他/TA/它,或自定义):用于「给模型看的提示词」;界面文案第二步跟随
|
|
46
50
|
},
|
|
47
51
|
styles: {
|
|
@@ -75,6 +79,11 @@ export const DEFAULT_SETTINGS = Object.freeze({
|
|
|
75
79
|
trackWorkspaces: ['*'],
|
|
76
80
|
idleRefresh: true, // B:长会话在空闲边界按需刷新 L1(运行中永不刷新)
|
|
77
81
|
idleRefreshMinIntervalMin: 10, // 同一个会话两次空闲刷新的最小间隔(分钟;0 = 不限制)
|
|
82
|
+
// E0(1.5.2)「那一行跟着会话走」:每轮用户在 `agent/pre-step` 追加一条带序号的活行
|
|
83
|
+
// (标题 + 最新进展,只追不替,见 lib/host/session-line.js)。
|
|
84
|
+
// 关掉即回到原样:已写入的历史行留着(平台无删除),但不再追加。
|
|
85
|
+
// ⚠️ 这是**内存态**设置(`settings-file.js` 的 get() 每轮读),面板 update() 立即改 ⇒ 不用重启宿主。
|
|
86
|
+
sessionProgressLine: true,
|
|
78
87
|
},
|
|
79
88
|
updates: { applyWhileRunning: false }, // D4 语义固化:默认冻结
|
|
80
89
|
guard: { // /api 守卫(2026-09-16 加固 ①+②;2026-09-17 加固 ③)
|
|
@@ -160,12 +169,16 @@ export function assemblePersona(settings, modeKey) {
|
|
|
160
169
|
// 为什么不把每条 push 都用 '\n\n' 连接:那样条目之间会变成双倍空行(段内反而更糊)。
|
|
161
170
|
const segs = [];
|
|
162
171
|
const self = [];
|
|
163
|
-
|
|
172
|
+
// 身份段正文过 escSegBrackets:与底线/规矩/习惯/承担/设定同规格,禁止正文自造段边界。
|
|
173
|
+
// **只包正文**:段头 [身份·X] 的方括号是字面量,保持半角(段边界判据是"行首是不是 [")。
|
|
174
|
+
// 但段头里嵌着 aiName —— 名字本身是用户文本,所以**逐字段**转义:名字/称呼/自述各过一遍,
|
|
175
|
+
// 有意的引号(")、分号(;)、逗号(,)一律不动(它们不是段边界判据的一部分)。
|
|
176
|
+
const who = p.aiName ? `你是"${escSegBrackets(p.aiName)}"` : '你是 DeepSeek 助手';
|
|
164
177
|
const callName = userTitleForMode(p.userTitle, mode); // 工作=正式名(首段),生活=昵称(尾段)
|
|
165
|
-
const call = callName ? `;你称用户为"${callName}"` : '';
|
|
178
|
+
const call = callName ? `;你称用户为"${escSegBrackets(callName)}"` : '';
|
|
166
179
|
// 定位自述允许多行书写,注入时压缩为一行(避免破坏身份句段落)
|
|
167
180
|
const titleOneLine = p.aiTitle ? p.aiTitle.replace(/\s+/g, ' ').trim() : '';
|
|
168
|
-
const title = titleOneLine ? `,${titleOneLine}` : '';
|
|
181
|
+
const title = titleOneLine ? `,${escSegBrackets(titleOneLine)}` : '';
|
|
169
182
|
// 结尾标点归一:title 自带句尾标点时不重复补(否则会出现 "拜上。。")
|
|
170
183
|
const headSelf = `${who}${call}${title}`;
|
|
171
184
|
self.push(headSelf.replace(/([。.!!??])\s*$/, '') + '。');
|
|
@@ -174,7 +187,10 @@ export function assemblePersona(settings, modeKey) {
|
|
|
174
187
|
const tPick = mode === 'work' ? p.toneWork : p.toneLife;
|
|
175
188
|
const tone = (tPick && TONES[tPick]) ? tPick : ((p.tone && TONES[p.tone]) ? p.tone : '');
|
|
176
189
|
if (tone) self.push(`语气:${TONES[tone]}`);
|
|
177
|
-
|
|
190
|
+
// 段头里的名字(aiName)与正文各字段一样是用户文本 ⇒ 那一处也必须转义(最容易漏的一处);
|
|
191
|
+
// 而 [身份· 与 ] 两个字面量**不转** —— 跟着一起转掉等于整段从注入面凭空消失。
|
|
192
|
+
// 正文再逐行兜一次(幂等):语气文案/语言文案将来若带上 [, 不必回来改这里。
|
|
193
|
+
segs.push([`[身份·${escSegBrackets(coreNameOf(p.aiName))}]`, ...self.map((s) => escSegBrackets(s))].join('\n'));
|
|
178
194
|
if (Array.isArray(p.bottomLines) && p.bottomLines.length) {
|
|
179
195
|
const seg = ['[底线]'];
|
|
180
196
|
for (const r of p.bottomLines) if (typeof r === 'string' && r.trim()) seg.push(`- ${escSegBrackets(r.trim())}`);
|
|
@@ -199,16 +215,31 @@ export function assemblePersona(settings, modeKey) {
|
|
|
199
215
|
(h) => h && h.byUser === true && h.amendedBy !== 'ling' && typeof h.text === 'string' && h.text.trim(),
|
|
200
216
|
);
|
|
201
217
|
if (awaiting.length) {
|
|
202
|
-
|
|
218
|
+
// 正文(h.text)过 escSegBrackets:与底线/规矩/习惯/承担/设定同规格,禁止正文自造段边界。
|
|
219
|
+
// **只包正文**:段头 [待我回应] 保持半角;包在正文外的「」引号与 、 分隔符是有意的结构,不动它们
|
|
220
|
+
// (转义只落在引号**里面**的文本上,「」配对关系逐字不变)。
|
|
221
|
+
// 为什么这一段特别要兜:此处**直接读 p.habitsPending**(没走 habitsPendingOf() 的 norm()),
|
|
222
|
+
// 而 norm() 会把换行压成空格、trim() 不会 —— 所以手改 settings.json 往某条候选的 text 里
|
|
223
|
+
// 塞「换行 + 半角 [」是通的,注入面就会真多出一个行首段头。出口转义是这条路上唯一一道闸。
|
|
224
|
+
segs.push(`[待我回应] 用户提的习惯候选:${awaiting.map((h) => `「${escSegBrackets(h.text.trim())}」`).join('、')}`
|
|
203
225
|
+ '——用 habit_resolve 表态(accept / amend 改说法 / decline)');
|
|
204
226
|
}
|
|
227
|
+
// 职责(1.5.2 I-5):身份级、改变率极低。空值**不渲染整段**(不留空段头 —— 空段头会被模型当成"有内容但被藏起来")。
|
|
228
|
+
// 正文过 escSegBrackets:与底线/规矩/习惯同规格,禁止正文自造段边界。
|
|
229
|
+
if (typeof p.duty === 'string' && p.duty.trim()) {
|
|
230
|
+
segs.push(`[承担]\n${escSegBrackets(p.duty.trim())}`);
|
|
231
|
+
}
|
|
232
|
+
// 正文过 escSegBrackets:与底线/规矩/习惯/承担同规格,禁止正文自造段边界。
|
|
233
|
+
// **只包正文**:段头 [设定] 必须保持半角 —— 段边界判据是"行首是不是 [",段头被转全角等于整段凭空消失。
|
|
205
234
|
if (typeof p.extraLore === 'string' && p.extraLore.trim()) {
|
|
206
|
-
segs.push(`[设定]\n${p.extraLore.trim()}`);
|
|
235
|
+
segs.push(`[设定]\n${escSegBrackets(p.extraLore.trim())}`);
|
|
207
236
|
}
|
|
208
237
|
const style = settings?.styles?.[mode];
|
|
209
238
|
if (typeof style === 'string' && style.trim()) {
|
|
210
239
|
// 段头与内容合并为一行(2026-09-16 瘦身:原来两行且重复"本会话为X模式")
|
|
211
|
-
|
|
240
|
+
// 正文过 escSegBrackets:与底线/规矩/习惯/承担同规格,禁止正文自造段边界(style 里的换行 + [ 也能伪造段头)。
|
|
241
|
+
// **只包正文**:段头 [工作模式]/[生活模式] 保持半角,否则段边界判据失效。
|
|
242
|
+
segs.push(`[${mode === 'work' ? '工作' : '生活'}模式]${escSegBrackets(style.trim())}`);
|
|
212
243
|
}
|
|
213
244
|
return segs.join('\n\n');
|
|
214
245
|
}
|