@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/memory.js
CHANGED
|
@@ -2,14 +2,14 @@
|
|
|
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,
|
|
9
9
|
// 且把 memory 当参数传(依赖注入)⇒ memory.js 引它**不成环**。
|
|
10
10
|
import { archiveBefore } from './archive.js';
|
|
11
11
|
|
|
12
|
-
const SCHEMA_VERSION =
|
|
12
|
+
const SCHEMA_VERSION = 14; // v14:dsh_turns_raw.source(来源从"推断"变"记录",2026-10-01 主人定「来源也加个列吧」—— 旧判据是"裸 id = DSH 会话",边界是"将来若出现不带冒号的新来源名,物理上分不出来");v13:forgotten(软遗忘标记 —— 永远不彻底删除,先归档再打标记);v12:branch_log.actor(G4 器灵侧通道 —— 工具改的树必须记在"器灵"名下,不再一律记成主人);v11:vein_suggestions(主脉提炼审核队列 R7 —— 设计稿 §5:提炼必须走审核,不直接进记忆);
|
|
13
13
|
// v10:tree_snapshot.kind('manual'|'auto',审计 A#9 —— 自动备份与手动快照此前外表完全一致,删错了就没有撤回路径);v9:tree_snapshot(树的保存/恢复 R1);v8:conv_branch(非会话源的枝归属覆盖层,方案 A);v7:branch_log(复盘留痕);v6:vein_link + memory_conflict(D9-b 树的生长);v5:branch(记忆分枝 D9-a);v4:title_by(定名者:user/ai);v3:title_locked;v2:conv_overview.source 放开 CHECK
|
|
14
14
|
|
|
15
15
|
/** 主干枝 id(D9-a 记忆分枝,2026-09-18):存量会话与记忆天然属于主干。 */
|
|
@@ -23,11 +23,66 @@ 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
|
-
/**
|
|
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
|
+
|
|
46
|
+
/** 命名空间分隔符(原文 id 的 `<ns>:<id>` 形态)。**唯一定义处**在本模块:
|
|
47
|
+
* summarizer.js 从这里取并再导出(既有导入面不变),免得"分隔符"出现两份定义。 */
|
|
48
|
+
export const RAW_NS_SEP = ':';
|
|
49
|
+
|
|
50
|
+
/** 原文命名空间(G4/2026-09-17):dsh_turns_raw 曾无 source 维,DSH 会话与导入原文共表。
|
|
27
51
|
* 若不隔离,概述器会把导入内容当成 DSH 会话重建(GROUP BY session_id 认领)→ 跨源双份;
|
|
28
52
|
* 同 id 时更会互相覆盖原文(不可恢复)。故导入原文一律落 'import:<conv_id>'。
|
|
29
53
|
* 历史数据(1.2.2 之前)的导入原文仍是裸 id —— 读取侧保留回退,写入侧一律带前缀。 */
|
|
30
|
-
export const RAW_IMPORT_PREFIX = 'import
|
|
54
|
+
export const RAW_IMPORT_PREFIX = 'import' + RAW_NS_SEP;
|
|
55
|
+
|
|
56
|
+
/* ── v14(2026-10-01)来源列:把"来源"从**推断**变成**记录** ─────────────────────────
|
|
57
|
+
* 为什么加(主人原话:「来源也加个列吧」):在此之前的范围判据全建在 id 文本的形状上
|
|
58
|
+
* ("有没有冒号")—— 它是**推断**,边界很硬:将来出现一个**不带冒号**的新来源名,物理上
|
|
59
|
+
* 分不出来(会被当成主人的 DSH 会话认领)。有了这一列,写入者当场把来源记下来,读者照读。
|
|
60
|
+
* 取值口径(三方共用,不许各写一份):
|
|
61
|
+
* · 'dsh' —— DSH 宿主会话轮次(lifecycle.js 捕获)。**读者唯一认作"自己人"的来源**;
|
|
62
|
+
* · 命名空间名(去尾冒号),如 'import' —— 一切外来来源(别人的对话);
|
|
63
|
+
* · NULL/'' —— **只可能来自本列落地之前的存量行**(半迁移态)。读者对这类行回落到旧判据
|
|
64
|
+
* "裸 id = DSH 会话"(见 summarizer 的整行判据),免得同一份库里两种行为并存。
|
|
65
|
+
* ⚠️ 写侧一律落**非空**来源,故**新行永远不靠猜**;靠猜的只有历史行与"写入者没给来源"的缺省。 */
|
|
66
|
+
export const RAW_SOURCE_DSH = 'dsh';
|
|
67
|
+
|
|
68
|
+
/** 来源名归一:只做 TRIM(与读取侧 SQL 的 `TRIM(source)` **同解**,两形态必须逐字同判)。
|
|
69
|
+
* ⚠️ 刻意不做别的花样(不去尾冒号、不转小写):归一每多一条规则,SQL 形态就得跟着模仿一条,
|
|
70
|
+
* 两处一旦漂移就会出现"JS 说在范围内、SQL 说不在"这种最难查的分歧。写侧本来就不会给出
|
|
71
|
+
* 带冒号的来源名(rawSourceOf 已去尾冒号),所以这里没有需要补救的畸形输入。 */
|
|
72
|
+
export function normalizeRawSource(source) {
|
|
73
|
+
return String(source ?? '').trim();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** 写入侧/回填侧:按 session_id 得出该行应落的来源名(裸 id ⇒ 'dsh';`<ns>:<id>` ⇒ 'ns')。
|
|
77
|
+
* ⚠️ 注意这是**按 id 形状推导**、不是"记录" —— 它只用于两处:
|
|
78
|
+
* ① 写入者没显式给 source 时的缺省(与读者回落判据同解 ⇒ 老裁决一个字都不变);
|
|
79
|
+
* ② 存量行回填(显式动作,见 backfillRawSource)。
|
|
80
|
+
* 真来源应当由写入方**显式给**(lifecycle 给 'dsh'、导入给 'import'),那才是这一列的意义。 */
|
|
81
|
+
export function rawSourceOf(sessionId) {
|
|
82
|
+
const id = String(sessionId ?? '');
|
|
83
|
+
const i = id.indexOf(RAW_NS_SEP);
|
|
84
|
+
return i < 0 ? RAW_SOURCE_DSH : id.slice(0, i);
|
|
85
|
+
}
|
|
31
86
|
|
|
32
87
|
/** 写入侧:按来源得出该会话原文应落的 session_id。 */
|
|
33
88
|
export function rawSessionId(source, convId) {
|
|
@@ -78,6 +133,10 @@ CREATE TABLE IF NOT EXISTS dsh_turns_raw (
|
|
|
78
133
|
ts TEXT,
|
|
79
134
|
model TEXT,
|
|
80
135
|
text TEXT NOT NULL DEFAULT '',
|
|
136
|
+
-- v14(2026-10-01):**真实来源标记**(dsh | import | … 见 RAW_SOURCE_DSH 段口径)。
|
|
137
|
+
-- 可空:NULL 只代表"本列落地之前的存量行"(半迁移态),读者对它回落到旧的"裸 id"判据。
|
|
138
|
+
-- ⚠️ 列尾追加(不换主键、不重建表、不动既有列)⇒ 老库上是一条秒级 ALTER。
|
|
139
|
+
source TEXT,
|
|
81
140
|
PRIMARY KEY (session_id, seq)
|
|
82
141
|
);
|
|
83
142
|
CREATE TABLE IF NOT EXISTS session_meta (
|
|
@@ -310,6 +369,29 @@ function migrateLogActor(db) {
|
|
|
310
369
|
}
|
|
311
370
|
}
|
|
312
371
|
|
|
372
|
+
/** v13→v14:dsh_turns_raw 增列 source(来源从**推断**变**记录**,2026-10-01 主人定)。
|
|
373
|
+
*
|
|
374
|
+
* 为什么必须是**纯增列**:这张表是记忆的**原文底账**(真机库 5062 轮 / 104 会话),任何
|
|
375
|
+
* "重建表 / 换主键 / 重写行"的迁移都会在一条语句里同时动到全部行的字节;而 ADD COLUMN 是
|
|
376
|
+
* SQLite 的**元数据操作** —— 不触碰任何既有行、不动主键,失败面最小(红线:不许重建表)。
|
|
377
|
+
* 故这一列**不做回填**:历史行的 source 留 NULL,读者按旧判据("裸 id = DSH 会话")回落 ⇒
|
|
378
|
+
* 迁移前后**裁决逐字一致**;要动历史行得走显式动作 backfillRawSource(会汇报条数)。
|
|
379
|
+
* 幂等:先 `PRAGMA table_info` 探列,已有直接返回 —— 可重复运行,第二遍是零写入。
|
|
380
|
+
* 代价:老库上一条 ALTER,毫秒级;不重建表 ⇒ 不阻塞宿主启动。
|
|
381
|
+
* @returns {boolean} 列是否确实在位(迁移后自证 —— 供构造器写 `dbg.raw_source_col` 与失败告警) */
|
|
382
|
+
function migrateRawSource(db) {
|
|
383
|
+
const has = () => {
|
|
384
|
+
try { return db.prepare('PRAGMA table_info(dsh_turns_raw)').all().some((c) => String(c.name) === 'source'); } catch { return false; }
|
|
385
|
+
};
|
|
386
|
+
try {
|
|
387
|
+
if (has()) return true; // 已迁移(或新库:DDL 里已带此列)
|
|
388
|
+
db.exec('ALTER TABLE dsh_turns_raw ADD COLUMN source TEXT');
|
|
389
|
+
} catch (e) {
|
|
390
|
+
console.warn('[dsh-ling] dsh_turns_raw.source 迁移失败(忽略):', String(e?.message ?? e).slice(0, 200));
|
|
391
|
+
}
|
|
392
|
+
return has(); // 自证:ALTER 是否真的生效(失败 ⇒ 构造器留一条可见的诊断,而不是让读侧静默抛错)
|
|
393
|
+
}
|
|
394
|
+
|
|
313
395
|
/** 会话级成员"可展开"的谓词(审计 A#6,2026-09-22)。
|
|
314
396
|
*
|
|
315
397
|
* 展开(`branchMembers`)与计数(`branchMemberCounts` / `branchTree().members`)必须共用这一份 SQL,
|
|
@@ -349,13 +431,37 @@ export class MemoryStore {
|
|
|
349
431
|
migrateBranchCols(this.db); // v4→v5:branch 表 + session_meta.branch_id(记忆分枝 D9-a)
|
|
350
432
|
migrateSnapshotKind(this.db); // v9→v10:tree_snapshot.kind(区分自动备份与手动快照,审计 A#9)
|
|
351
433
|
migrateLogActor(this.db); // v11→v12:branch_log.actor(区分"谁改的树",Part G G4)
|
|
434
|
+
const rawSourceOk = migrateRawSource(this.db); // v13→v14:dsh_turns_raw.source(来源从推断变记录,2026-10-01)
|
|
352
435
|
this._kvGet = this.db.prepare('SELECT value FROM kv WHERE key=?');
|
|
353
436
|
this._kvSet = this.db.prepare('INSERT INTO kv(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value');
|
|
437
|
+
// 迁移**自证**(2026-10-01):纯增列理论上不会失败,但万一 ALTER 被库锁/权限挡下(它会静默 catch),
|
|
438
|
+
// 读侧的 source 判据就会去读一列不存在的列 —— 症状是"概述器/习惯扫描每次抛 no such column",
|
|
439
|
+
// 而它只会在 console.debug 里出现一次。故失败时留一条可查的 kv + 一行 warn(成功路径**零 kv 写入**,
|
|
440
|
+
// 免得"干净库的 kv 键集合不变"这条既有断言被诊断项污染;陈旧告警顺手清掉)。
|
|
441
|
+
if (rawSourceOk) {
|
|
442
|
+
try { this.kvDel('dbg.raw_source_col'); } catch { /* 旧告警清不掉不影响主流程 */ }
|
|
443
|
+
} else {
|
|
444
|
+
// ⚠️ 这一笔**必须**留在 try 里(红队 2026-10-01 实测的阻断项):库只读(SQLITE_READONLY)/被写锁(SQLITE_BUSY)时
|
|
445
|
+
// 它**一定**抛,而它只是"留一条可查的痕迹",不是主流程。修前异常一路冒到 lib/index.js 的 apply() ⇒ **整个插件
|
|
446
|
+
// 加载失败**,那条为迁移失败准备的降级路径(本 else 分支 + 读侧回落"裸 id"判据)反而永远走不到(形同死代码)。
|
|
447
|
+
// 只用 try 护**这一笔诊断写入**,不把 kvSet 整体改成吞异常(那会把别处的真错误一起吞掉);
|
|
448
|
+
// "库真的打不开/表都建不出来"这类错误仍照旧往上抛(见构造器开头,DLL/DDL 未做任何兜底)。
|
|
449
|
+
try {
|
|
450
|
+
this.kvSet('dbg.raw_source_col', 'missing');
|
|
451
|
+
} catch (e) {
|
|
452
|
+
console.warn('[dsh-ling] 迁移诊断没能写进库(少了 dbg.raw_source_col 这条可查痕迹,不影响运行):'
|
|
453
|
+
+ String(e?.message ?? e).slice(0, 200));
|
|
454
|
+
}
|
|
455
|
+
console.warn('[dsh-ling] dsh_turns_raw.source 列缺失(迁移未生效)—— 概述器与习惯扫描的来源判据会读不到该列,'
|
|
456
|
+
+ '请检查库锁/磁盘权限;读侧回落判据(instr(session_id, 冒号) = 0)不受影响,但需人工处置。');
|
|
457
|
+
}
|
|
354
458
|
// schema_version 必须跟随真实结构更新,不能只写一次:
|
|
355
459
|
// v5 加了 branch 表与 session_meta.branch_id,若仍留 '4',后续会话会误判库结构。
|
|
356
460
|
// v6 加 vein_link + memory_conflict 两张新表(纯新增,无 ALTER,DDL 的 IF NOT EXISTS 即迁移)。
|
|
357
461
|
// v10 加 tree_snapshot.kind(ALTER,见 migrateSnapshotKind)。
|
|
358
|
-
|
|
462
|
+
// v14 加 dsh_turns_raw.source(ALTER,见 migrateRawSource);失败时 schema_version **不前进**,
|
|
463
|
+
// 下一次启动还会再试一遍(探列幂等 ⇒ 成功即跳过)。
|
|
464
|
+
if (rawSourceOk && this.kvGet('schema_version') !== String(SCHEMA_VERSION)) this.kvSet('schema_version', String(SCHEMA_VERSION));
|
|
359
465
|
}
|
|
360
466
|
|
|
361
467
|
close() {
|
|
@@ -893,12 +999,19 @@ export class MemoryStore {
|
|
|
893
999
|
if (!s || !c) return { ok: false, reason: 'bad-key' };
|
|
894
1000
|
let wroteTurns = 0;
|
|
895
1001
|
try {
|
|
896
|
-
const ins = this.db.prepare(`INSERT INTO dsh_turns_raw (session_id,seq,role,ts,model,text)
|
|
897
|
-
VALUES (
|
|
898
|
-
role=excluded.role, ts=excluded.ts, model=excluded.model, text=excluded.text
|
|
1002
|
+
const ins = this.db.prepare(`INSERT INTO dsh_turns_raw (session_id,seq,role,ts,model,text,source)
|
|
1003
|
+
VALUES (?,?,?,?,?,?,?) ON CONFLICT(session_id,seq) DO UPDATE SET
|
|
1004
|
+
role=excluded.role, ts=excluded.ts, model=excluded.model, text=excluded.text,
|
|
1005
|
+
source=COALESCE(dsh_turns_raw.source, excluded.source)`);
|
|
1006
|
+
// v14:回灌也要带来源。取 `rawSourceOf(c)`(= 写入目标 id 的形状)而**不是**概述的 `source` 字段:
|
|
1007
|
+
// · 目标 id 带前缀(import:)⇒ 'import' —— 与写入侧同一口径;
|
|
1008
|
+
// · 目标 id 是裸的 ⇒ 'dsh' —— 与读者对空 source 的回落判据同解 ⇒ 回灌前后裁决一致
|
|
1009
|
+
// (若改按 `source` 字段记 'dsweb'/'vein',这些回灌的原文会**当场从概述器眼里消失**:越权改裁决)。
|
|
1010
|
+
// 冲突分支同样 COALESCE(首写为准),不回退已记录的来源。
|
|
1011
|
+
const rawSrc = rawSourceOf(c) || null;
|
|
899
1012
|
this.db.exec('BEGIN');
|
|
900
1013
|
for (const t of (Array.isArray(turns) ? turns : [])) {
|
|
901
|
-
ins.run(c, Number(t.seq) || 0, String(t.role || ''), t.ts ?? null, t.model ?? null, String(t.text ?? ''));
|
|
1014
|
+
ins.run(c, Number(t.seq) || 0, String(t.role || ''), t.ts ?? null, t.model ?? null, String(t.text ?? ''), rawSrc);
|
|
902
1015
|
wroteTurns += 1;
|
|
903
1016
|
}
|
|
904
1017
|
this.db.exec('COMMIT');
|
|
@@ -947,22 +1060,52 @@ export class MemoryStore {
|
|
|
947
1060
|
* 水位用**自增 id** 而非时间戳 —— 同一毫秒内的多条改动用时间戳比较会漏。
|
|
948
1061
|
* 只报结构操作,不碰任何记忆内容(与"内容零改写"不变量一致)。
|
|
949
1062
|
*/
|
|
950
|
-
branchLogDigest({ limit = 5, afterId = null } = {}) {
|
|
1063
|
+
branchLogDigest({ limit = 5, afterId = null, now = Date.now(), windowDays = BRANCH_LOG_WINDOW_DAYS } = {}) {
|
|
951
1064
|
const max = Math.max(1, Math.min(20, Number(limit) || 5));
|
|
952
1065
|
// B#9(2026-09-22 审计):水位必须**按会话** —— 全局键会让会话 A 消费之后、
|
|
953
1066
|
// 会话 B 再也看不到同一批改动。调用方传 afterId(取自 kv `branch_log_wm.<sessionId>`);
|
|
954
1067
|
// 未传才退回全局键(仅为兼容旧调用点,新代码不应依赖)。
|
|
955
|
-
|
|
1068
|
+
// ── B3(1.5.2)在旁边加注:上面这条裁决**保留原文、今天依然成立** ——
|
|
1069
|
+
// 它管的是"别漏";它没管另一头:新会话水位从 0 起会把**已经播过的**又播一遍(B3-1),
|
|
1070
|
+
// 而"播完就把水位推到全表最新 id"会把**从没示人**的中间那些标成已读(B3-2)。
|
|
1071
|
+
// 所以 1.5.2 **不是改回全局单键**,而是**两条并存**,有效下界取三者更大者:
|
|
1072
|
+
// · `branch_log_wm.<sessionId>` 本会话已看到哪(会话增量,B#9 原样保留);
|
|
1073
|
+
// · `branch_log_wm.announced` **全局**"已示人"边界(只推进到真正渲染过的行 ⇒
|
|
1074
|
+
// 已经给谁看过的不再重播,新会话因此不重播);
|
|
1075
|
+
// · `branch_log_wm.retired` **全局**"已按时间窗计数式处置"的边界(§2 老改动
|
|
1076
|
+
// 只计数不展开 ⇒ **不算已示人**,故独立成键,只防同一批老账每轮重复计数)。
|
|
1077
|
+
const sessionWm = (afterId !== null && afterId !== undefined && Number.isFinite(Number(afterId)))
|
|
956
1078
|
? Number(afterId)
|
|
957
1079
|
: Number(this.kvGet('branch_log_wm') || 0);
|
|
1080
|
+
const wm = Math.max(
|
|
1081
|
+
sessionWm,
|
|
1082
|
+
Number(this.kvGet(BRANCH_LOG_WM_ANNOUNCED) || 0) || 0,
|
|
1083
|
+
Number(this.kvGet(BRANCH_LOG_WM_RETIRED) || 0) || 0,
|
|
1084
|
+
);
|
|
958
1085
|
let rows = [];
|
|
959
1086
|
try {
|
|
960
|
-
|
|
1087
|
+
// B3-2(1.5.2):候选从"全表最新 120 行里挑最新的 limit 条"改成**水位之上最早的 limit 条**
|
|
1088
|
+
// (FIFO)。病根就在取数顺序 —— 这是本次修复的**实质**,别当成等价改写:
|
|
1089
|
+
// 旧取数是 `ORDER BY id DESC` + `slice(0, max)`,渲染出来的是**最新那 5 条**(一个后缀);
|
|
1090
|
+
// 于是原代码的 `maxId: Number(fresh[0].id)` **看着像"渲染到的最大 id",其实从来不是** ——
|
|
1091
|
+
// 它取的是**最新那条**。水位一跳,落在"最新 5 条"与"上一轮水位"之间的那批改动
|
|
1092
|
+
// (**比这 5 条更老**)既没示人、又被标成已读:live 上实测中间 114 笔。
|
|
1093
|
+
// FIFO 之后"渲染到的最大 id"才真正小于"全表最新","下一轮还能看到剩下的"才成立。
|
|
1094
|
+
rows = this.listBranchLog({ limit: 120, afterId: wm, ascending: true });
|
|
961
1095
|
} catch {
|
|
962
|
-
return { lines: [], maxId: wm, total: 0 };
|
|
1096
|
+
return { lines: [], maxId: wm, shownMaxId: 0, retiredMaxId: 0, total: 0, expired: 0 };
|
|
1097
|
+
}
|
|
1098
|
+
if (!rows.length) {
|
|
1099
|
+
return { lines: [], maxId: wm, shownMaxId: 0, retiredMaxId: 0, total: 0, expired: 0 };
|
|
963
1100
|
}
|
|
964
|
-
|
|
965
|
-
|
|
1101
|
+
// 全表最大 id:与 1.5.1 同值(那时取 id DESC 结果的 `fresh[0].id`),现在显式问一次 MAX,
|
|
1102
|
+
// 与取数顺序解耦。**只喂 `branch_log_wm.last`**(复盘退出判据),不参与"已示人"判定。
|
|
1103
|
+
const windowMaxId = rows.reduce((m, r) => Math.max(m, Number(r.id) || 0), 0);
|
|
1104
|
+
let tableMaxId = windowMaxId;
|
|
1105
|
+
try {
|
|
1106
|
+
const t = this.db.prepare('SELECT MAX(id) AS m FROM branch_log').get();
|
|
1107
|
+
tableMaxId = Number((t && t.m) || 0) || windowMaxId;
|
|
1108
|
+
} catch { /* 兜底:窗口内最大(已知下限) */ }
|
|
966
1109
|
// ── B1(1.5.1):真实剩余笔数**必须问库** ─────────────────────────────────────
|
|
967
1110
|
// 上面 listBranchLog 只取最近 120 行,积压超过 120 时窗口恒满;旧代码拿
|
|
968
1111
|
// `fresh.length - lines.length`(= limit 夹取差,恒为 120-5=115)当"另有 N 笔",
|
|
@@ -970,12 +1113,88 @@ export class MemoryStore {
|
|
|
970
1113
|
// 这里补一条独立 COUNT:branch_log.id 是 INTEGER PRIMARY KEY(AUTOINCREMENT),
|
|
971
1114
|
// `WHERE id > ?` 是主键范围扫描 —— 只走索引不拉行,代价与积压量无关,
|
|
972
1115
|
// 所以**不需要**把 limit 调大去"凑数"(那才是真把几百行读进内存)。
|
|
973
|
-
//
|
|
974
|
-
|
|
1116
|
+
// 只服务展示:水位推进 1.5.2 起由下方 `shownMaxId` / `retiredMaxId` 负责
|
|
1117
|
+
// (1.5.1 时此处写的是"B3 按主人的决定推迟到 1.5.2",现已实现)。
|
|
1118
|
+
let realTotal = rows.length; // 兜底:COUNT 失败时退回窗口内条数(已知下限)
|
|
975
1119
|
try {
|
|
976
1120
|
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,
|
|
1121
|
+
if (agg) realTotal = Math.max(Number(agg.n) || 0, rows.length);
|
|
978
1122
|
} catch { /* 库出错时退回窗口口径:摘要宁可少报,也不能整段消失 */ }
|
|
1123
|
+
// ── §2 时间窗(1.5.2):只**展开**最近 windowDays 天的改动 ─────────────────────
|
|
1124
|
+
// 超窗行**只计数不展开**,并且**不算已示人** ⇒ 计数落在 retiredMaxId,由投递侧写进
|
|
1125
|
+
// `branch_log_wm.retired`,让水位能越过它们(否则同一批老账每轮重算、水位永不前进)。
|
|
1126
|
+
// 判定用 JS 逐行做(而不是在 SQL 里比字符串):展示口径只有一处;且"at 读不出来"的行
|
|
1127
|
+
// 明确回退成**展开**(宁可多占一行,也不静默折叠)。windowDays<=0 ⇒ 不做时间窗。
|
|
1128
|
+
const cutoff = Number(windowDays) > 0 ? Number(now) - Number(windowDays) * 864e5 : 0;
|
|
1129
|
+
const isExpired = (r) => {
|
|
1130
|
+
if (!cutoff) return false;
|
|
1131
|
+
const t = Date.parse(String(r.at || ''));
|
|
1132
|
+
return Number.isFinite(t) ? t < cutoff : false;
|
|
1133
|
+
};
|
|
1134
|
+
// 前缀扫描:水位之上按 id 升序,遇到第 max 条"要展开的"行即停 —— 这一段里每一行
|
|
1135
|
+
// 要么展开(进 lines)、要么按超窗计数,**没有第三种**;而水位只推进到这一段之内,
|
|
1136
|
+
// 所以水位之下的行全部已被处置,不会出现"跳过去又没交代"的空洞。
|
|
1137
|
+
// ── expired 低报修复(B3 后续,2026-09-27 实测)────────────────────────────────
|
|
1138
|
+
// 病根:`expired` 原先只在**扫描窗口内**(rows = 水位之上最早的 120 行)逐行累加,
|
|
1139
|
+
// 而 ann/min(20,limit) 之外还有一个**常量 120** 的窗口上限 ⇒ 超窗行超过 120 时
|
|
1140
|
+
// 计数**封顶在 120**。live 实测:真库 branch_log 395 行,真分界 id=368
|
|
1141
|
+
// (超窗 368 / 窗内 27),而注入面报的是「其中 120 笔已超过 3 天」——低报 248。
|
|
1142
|
+
// 更糟的是它不是"暂时不准":`branch_log_wm.announced` 一旦推进到全表最新,
|
|
1143
|
+
// 有效下界 wm = max(会话水位, announced, retired) 就把扫描顶死,**括号永不修正**。
|
|
1144
|
+
// 修法(二选一中的**路径 b**):计数与取数**分开** ——
|
|
1145
|
+
// · 取明细仍用 120 行窗口(性能照旧,不把几百行读进内存);
|
|
1146
|
+
// · `expired` 单独再扫一遍**水位之上的全部行**只做计数(本机存量 395,量级很小)。
|
|
1147
|
+
// 为什么不用 SQL COUNT(路径 a):`at` 是 TEXT 且**历史形态不齐** —— 写入端是 utcIso()
|
|
1148
|
+
// 的 `…Z` 毫秒串,但库里有秒级/无毫秒/带偏移的存量行;SQL 的 `at < ?` 是**字符串比较**,
|
|
1149
|
+
// 与 JS 的 `Date.parse` **不是同一个口径**(例:`'2026-09-24T01:05:48Z' < '2026-09-24T01:05:48.036Z'`
|
|
1150
|
+
// 在字符串序里为真,而 parse 出来 t == cutoff ⇒ JS 判**不超窗**、SQL 判超窗)。
|
|
1151
|
+
// 要"语义完全一致"就得在 SQL 里复刻 Date.parse 的宽松解析,那等于把口径写成两份 ⇒
|
|
1152
|
+
// 违背本函数 `:1033` 那条「展示口径只有一处」的设计意图。JS 单点口径天然一致:
|
|
1153
|
+
// 计数与渲染调的是**同一个** `isExpired`(含"at 读不出来 ⇒ 回退成展开"的分支)。
|
|
1154
|
+
// 边界:JS 判 `t < cutoff`,即 `at` **恰好等于** cutoff 的行算**窗内**(不超窗)——
|
|
1155
|
+
// 新计数与渲染同用它,故边界行既不进 expired、也不会被渲染侧判成超窗(回归见 tree 套 9-7)。
|
|
1156
|
+
// 注意 `retiredMaxId` 在此处**仍只认扫描窗口内**超窗行的最大 id;窗口外的整段老账
|
|
1157
|
+
// 由下面那段"连续超窗前缀"扫描补上(它不是"水位跳过未见过的行":前缀扫描碰到第一条
|
|
1158
|
+
// 窗内行就停 ⇒ 不会略过任何还没轮到展开的行,而 B3-2 的病根正是那种略过)。
|
|
1159
|
+
const picked = [];
|
|
1160
|
+
let retiredMaxId = 0;
|
|
1161
|
+
for (const r of rows) {
|
|
1162
|
+
if (isExpired(r)) {
|
|
1163
|
+
retiredMaxId = Math.max(retiredMaxId, Number(r.id) || 0);
|
|
1164
|
+
continue;
|
|
1165
|
+
}
|
|
1166
|
+
picked.push(r);
|
|
1167
|
+
if (picked.length >= max) break;
|
|
1168
|
+
}
|
|
1169
|
+
// 独立计数扫描:只数笔数,不取字段以外的任何东西(与上面 realTotal 的 COUNT 一样
|
|
1170
|
+
// 走 `WHERE id > ?` 主键范围扫描);**只在需要出括号时**才扫(pending<=0 时括号不渲染)。
|
|
1171
|
+
// 兜底:库出错 ⇒ 退回扫描窗口内的口径(已知下限),摘要宁可少报也不能整段消失。
|
|
1172
|
+
let expired = rows.reduce((n, r) => n + (isExpired(r) ? 1 : 0), 0);
|
|
1173
|
+
if (realTotal - picked.length > 0 && cutoff) {
|
|
1174
|
+
try {
|
|
1175
|
+
const above = this.db.prepare(
|
|
1176
|
+
'SELECT id, at FROM branch_log WHERE id > ? ORDER BY id ASC LIMIT 100000'
|
|
1177
|
+
).all(wm);
|
|
1178
|
+
expired = above.reduce((n, r) => n + (isExpired(r) ? 1 : 0), 0);
|
|
1179
|
+
// ── 同一批老账还要能"越过"(2026-09-27,修 expired 时暴露的**饥饿**)──────────
|
|
1180
|
+
// 上面把计数改真之后,暴露出原先没被看见的后果:`retiredMaxId` 只认**扫描窗口内**
|
|
1181
|
+
// 超窗行的最大 id ⇒ 窗口(120 行)被老账占满时,水位每轮只前进 120,
|
|
1182
|
+
// 而下一轮同样的老账又占满窗口 ⇒ **明细行永远渲染不出来**(live 实测:真库 368 条
|
|
1183
|
+
// 超窗行、`retired` 卡在 120;注入面只剩一行计数,一条明细也看不到)。
|
|
1184
|
+
// 修法:让 retired 一次走到**连续超窗前缀的末尾** —— 从"已处置游标 + 1"起按 id
|
|
1185
|
+
// 升序走,只要还是超窗就继续,碰到**第一条窗内行即停**。它天然不越过任何窗内行
|
|
1186
|
+
// (B3-2 的病根正是"水位跳过了没示人的行"),所以既解饥饿又不重蹈覆辙。
|
|
1187
|
+
// 只**加宽**、不回退(游标单调),故与上面扫描窗口的结论取更大者。
|
|
1188
|
+
let lastRetired = Number(this.kvGet(BRANCH_LOG_WM_RETIRED) || 0) || 0;
|
|
1189
|
+
for (const r of above) {
|
|
1190
|
+
if (Number(r.id) <= lastRetired) continue;
|
|
1191
|
+
if (!isExpired(r)) break;
|
|
1192
|
+
lastRetired = Number(r.id) || lastRetired;
|
|
1193
|
+
}
|
|
1194
|
+
retiredMaxId = Math.max(retiredMaxId, lastRetired);
|
|
1195
|
+
} catch { /* 退回窗口口径(已知下限) */ }
|
|
1196
|
+
}
|
|
1197
|
+
const shownMaxId = picked.length ? Number(picked[picked.length - 1].id) || 0 : 0;
|
|
979
1198
|
const names = new Map(this.listBranches().map((b) => [b.id, b.name || b.id]));
|
|
980
1199
|
// 提示面净化(审计 §2.5 + B#2,2026-09-22):
|
|
981
1200
|
// ① 枝名/id 一律翻译或缩成短号,**永不吐裸 uuid**(实测:gather/autobuild 两支漏了 nm(),
|
|
@@ -996,7 +1215,7 @@ export class MemoryStore {
|
|
|
996
1215
|
return '条目 ' + k.slice(-8); // 其余按记忆条目
|
|
997
1216
|
};
|
|
998
1217
|
const flat = (s) => String(s == null ? '' : s).replace(/[\r\n\t]+/g, ' ').trim().slice(0, 80);
|
|
999
|
-
const lines =
|
|
1218
|
+
const lines = picked.map((r) => {
|
|
1000
1219
|
const who = nm(r.branchId);
|
|
1001
1220
|
const line = (() => {
|
|
1002
1221
|
switch (String(r.action || '')) {
|
|
@@ -1048,16 +1267,45 @@ export class MemoryStore {
|
|
|
1048
1267
|
const subject = String(r.actor || 'user') === 'ling' ? '器灵' : '主人';
|
|
1049
1268
|
return flat(subject + (/^[A-Za-z0-9]/.test(line) ? ' ' : '') + line);
|
|
1050
1269
|
});
|
|
1051
|
-
//
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1270
|
+
// ── §4「超量折叠成一行」(1.5.2,与双水位同批上)────────────────────────────
|
|
1271
|
+
// 最多 max 行明细 + 一行计数;**折算的余量走 realTotal**(B1:真实剩余笔数,不是窗口差)。
|
|
1272
|
+
// 这里能成立的关键是水位的口径:水位停在**最后一条明细行**(shownMaxId),被折掉的行
|
|
1273
|
+
// id 仍在水位之上 ⇒ 它们**没被标成已示人**,下一轮继续展开,直到真的示人。
|
|
1274
|
+
// 1.5.1 那次上折叠反效果的机制正在此:折叠了、水位却照样跳到全表最新 ⇒ 折掉的内容
|
|
1275
|
+
// 再也看不见(改前至少还露出 5 条)。**永久折叠(超阈值只留一行摘要 + 推进水位)与
|
|
1276
|
+
// "折掉的行仍是未示人"逻辑上不可两全**:折了不推进 ⇒ 每轮重播同一行,推进 ⇒ 内容丢失。
|
|
1277
|
+
// 所以 1.5.2 只做这条**可收敛的折叠**,不设 BRANCH_LOG_FOLD_THRESHOLD 之类的阈值常量。
|
|
1278
|
+
const pending = Math.max(0, realTotal - picked.length);
|
|
1279
|
+
// 夹取保留(兜底:防"其中 N 笔"超过"另有 M 笔")。修好 expired 之后它**不会触发**:
|
|
1280
|
+
// 计数扫描的范围是 `id > wm`,而 pending = realTotal - picked.length 是同一范围内
|
|
1281
|
+
// 除已展开行以外的全部行 ⇒ 超窗行(既非 picked、又必在 realTotal 里)**是 pending 的子集**,
|
|
1282
|
+
// 故恒有 expired <= pending。唯一例外是计数扫描失败退回窗口口径,那时夹取正好兜住低报方向的错。
|
|
1283
|
+
if (pending > 0) {
|
|
1284
|
+
lines.push('…另有 ' + pending + ' 笔改动'
|
|
1285
|
+
+ (expired > 0
|
|
1286
|
+
? '(其中 ' + Math.min(expired, pending) + ' 笔已超过 ' + Math.round(Number(windowDays)) + ' 天,只计数不展开)'
|
|
1287
|
+
: ''));
|
|
1288
|
+
}
|
|
1289
|
+
return { lines, maxId: tableMaxId, shownMaxId, retiredMaxId, total: realTotal, expired };
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
/** 复盘留痕列表(默认最近的在前)。
|
|
1293
|
+
* B3(1.5.2)加了两个**可选**参数给播报用,不传时行为与 1.5.1 逐字一致(侧栏/API 照旧):
|
|
1294
|
+
* · `afterId`:只要 id 更大的行(主键范围扫描,配 ascending 取"水位之上最早的 N 条");
|
|
1295
|
+
* · `ascending`:按 id 升序(FIFO)。播报必须**从最早未示人的一条开始** —— 见
|
|
1296
|
+
* branchLogDigest 里 B3-2 的注解。 */
|
|
1297
|
+
listBranchLog({ limit = 100, afterId = null, ascending = false } = {}) {
|
|
1058
1298
|
try {
|
|
1059
1299
|
const n = Math.max(1, Math.min(500, Number(limit) || 100));
|
|
1060
|
-
|
|
1300
|
+
const floor = (afterId !== null && afterId !== undefined && Number.isFinite(Number(afterId)))
|
|
1301
|
+
? Number(afterId)
|
|
1302
|
+
: null;
|
|
1303
|
+
// ascending 只做字面拼接(调用方传布尔,不接外部输入);floor 走占位符。
|
|
1304
|
+
const sql = 'SELECT * FROM branch_log'
|
|
1305
|
+
+ (floor === null ? '' : ' WHERE id > ?')
|
|
1306
|
+
+ ' ORDER BY id ' + (ascending ? 'ASC' : 'DESC') + ' LIMIT ?';
|
|
1307
|
+
const args = floor === null ? [n] : [floor, n];
|
|
1308
|
+
return this.db.prepare(sql).all(...args).map((r) => ({
|
|
1061
1309
|
id: Number(r.id),
|
|
1062
1310
|
at: String(r.at || ''),
|
|
1063
1311
|
branchId: String(r.branch_id || ''),
|
|
@@ -1924,6 +2172,19 @@ export class MemoryStore {
|
|
|
1924
2172
|
return rows.map(mapRow);
|
|
1925
2173
|
}
|
|
1926
2174
|
|
|
2175
|
+
/** 只数条数(1.5.2):`/state` 的两处调用点只要 `listOverviews({onlyOk:true}).length`,
|
|
2176
|
+
* 而全表 `SELECT *` + mapRow×N 实测 4.82ms、`COUNT(*)` 0.08ms。where 口径与 `listOverviews` 逐字一致
|
|
2177
|
+
* ⇒ **同库同参下两者恒等**(只有"全表读"换成"计数")。返回 number。 */
|
|
2178
|
+
countOverviews({ onlyOk, source } = {}) {
|
|
2179
|
+
let sql = 'SELECT COUNT(*) AS n FROM conv_overview';
|
|
2180
|
+
const where = [];
|
|
2181
|
+
if (onlyOk) where.push('overview_ok=1');
|
|
2182
|
+
if (source) where.push('source=?');
|
|
2183
|
+
if (where.length) sql += ' WHERE ' + where.join(' AND ');
|
|
2184
|
+
const row = source ? this.db.prepare(sql).get(source) : this.db.prepare(sql).get();
|
|
2185
|
+
return Number(row?.n ?? 0);
|
|
2186
|
+
}
|
|
2187
|
+
|
|
1927
2188
|
/** 记忆中心查询:过滤(source/category/q)+ 排序 + 分页;返回 {items,total}。
|
|
1928
2189
|
* source 不限 dsweb/dsh——文件导入等第三方来源(source='import')同样可按源过滤。 */
|
|
1929
2190
|
queryOverviews({ source, category, q, branch, sort = 'updated', limit = 100, offset = 0 } = {}) {
|
|
@@ -2093,14 +2354,22 @@ export class MemoryStore {
|
|
|
2093
2354
|
}
|
|
2094
2355
|
|
|
2095
2356
|
// ---- raw turns (DSH incremental capture, passive) ----
|
|
2096
|
-
|
|
2357
|
+
/** 追加一条原文轮次(幂等:(session_id,seq) 冲突时更新正文)。
|
|
2358
|
+
* v14:`source` 记**这条原文属于哪个来源**(取值口径见 RAW_SOURCE_DSH 段)。缺省 null ⇒ 按 id
|
|
2359
|
+
* 形状推导(rawSourceOf)—— 与读者对空 source 的回落判据**同解**,故老调用点一个字不改也写不错;
|
|
2360
|
+
* 但**知道来源的调用方应当显式给**(lifecycle 给 'dsh'、导入给 'import'):那才是这一列的意义。 */
|
|
2361
|
+
appendRawTurn(sessionId, { seq, role, ts, model, text, source = null }) {
|
|
2097
2362
|
if (!sessionId || typeof text !== 'string' || !text) return;
|
|
2363
|
+
const src = normalizeRawSource(source) || rawSourceOf(sessionId);
|
|
2098
2364
|
this.db.prepare(
|
|
2099
|
-
`INSERT INTO dsh_turns_raw (session_id, seq, role, ts, model, text)
|
|
2100
|
-
VALUES (
|
|
2101
|
-
ON CONFLICT(session_id, seq) DO UPDATE SET text=excluded.text
|
|
2365
|
+
`INSERT INTO dsh_turns_raw (session_id, seq, role, ts, model, text, source)
|
|
2366
|
+
VALUES (?,?,?,?,?,?,?)
|
|
2367
|
+
ON CONFLICT(session_id, seq) DO UPDATE SET text=excluded.text,
|
|
2368
|
+
source=COALESCE(dsh_turns_raw.source, excluded.source)`,
|
|
2369
|
+
// 冲突分支的来源取 COALESCE(首写为准):同一轮被重扫/重导时**不把已记录的来源降级成推断值**
|
|
2370
|
+
// (例如一行已被显式记成某个外来来源,重扫不该把它改回 'dsh');而 NULL 的存量行会被填上。
|
|
2102
2371
|
// 写侧归一(修 A):一律存 ISO;数字/浮点串(如 "1789006881011.0")也归一后再落库。
|
|
2103
|
-
).run(String(sessionId), Number(seq), String(role ?? '?'), toIso(ts), model || null, text);
|
|
2372
|
+
).run(String(sessionId), Number(seq), String(role ?? '?'), toIso(ts), model || null, text, src || null);
|
|
2104
2373
|
this.db.prepare(
|
|
2105
2374
|
'INSERT INTO session_meta(session_id, raw_seq) VALUES(?,?) ON CONFLICT(session_id) DO UPDATE SET raw_seq=excluded.raw_seq',
|
|
2106
2375
|
).run(String(sessionId), Number(seq));
|
|
@@ -2108,6 +2377,54 @@ export class MemoryStore {
|
|
|
2108
2377
|
this.db.prepare('UPDATE session_meta SET archived=0 WHERE session_id=? AND archived=1').run(String(sessionId));
|
|
2109
2378
|
}
|
|
2110
2379
|
|
|
2380
|
+
/** 存量行来源回填(v14,**可选**动作,2026-10-01)。**只有显式调用才会跑** —— 构造器与概述器
|
|
2381
|
+
* 都不会自己碰它:这是唯一会**写历史行**的动作,偷偷跑等于让这一列的可信度打折(读者会以为
|
|
2382
|
+
* 每行的来源都是写入当时记下来的)。规则(顺序不可调):
|
|
2383
|
+
* ① 只填 `source IS NULL` 或空串的行 —— **已有的非空来源绝不覆盖**(记录优先于推断);
|
|
2384
|
+
* ② 值按 session_id 推导(rawSourceOf:裸 id ⇒ 'dsh';`<ns>:<id>` ⇒ 'ns');
|
|
2385
|
+
* ③ 幂等(第二遍 filled=0);④ 汇报条数(filled / 按来源分组 / 推不出来的 unresolved)。
|
|
2386
|
+
* ⚠️ 回填只给**已经存在的行**加一个字段,**不改变任何裁决** —— 推导口径与读者的空 source 回落
|
|
2387
|
+
* 判据同解(裸 id ⇒ dsh、带前缀 ⇒ 排除),迁移前后同一份库的概述集逐字相同。
|
|
2388
|
+
* ⚠️ 代价(明说):历史行的 source 是**事后推断**的,不是当场记的 —— 若某个老来源的 id 恰好是裸
|
|
2389
|
+
* 形状(不带冒号),回填会把它**固化成 'dsh'**,此后再也分不出来。故本动作须由人显式触发。
|
|
2390
|
+
* @returns {{ok:boolean,filled:number,scanned:number,bySource:Object,unresolved:number}} */
|
|
2391
|
+
backfillRawSource() {
|
|
2392
|
+
const out = { ok: true, filled: 0, scanned: 0, bySource: {}, unresolved: 0 };
|
|
2393
|
+
try {
|
|
2394
|
+
const rows = this.db.prepare(
|
|
2395
|
+
"SELECT session_id, COUNT(*) AS n FROM dsh_turns_raw WHERE source IS NULL OR TRIM(source)='' GROUP BY session_id",
|
|
2396
|
+
).all();
|
|
2397
|
+
const upd = this.db.prepare(
|
|
2398
|
+
"UPDATE dsh_turns_raw SET source=? WHERE session_id=? AND (source IS NULL OR TRIM(source)='')",
|
|
2399
|
+
);
|
|
2400
|
+
this.db.exec('BEGIN');
|
|
2401
|
+
for (const r of rows) {
|
|
2402
|
+
const sid = String(r?.session_id ?? '');
|
|
2403
|
+
const n = Number(r?.n) || 0;
|
|
2404
|
+
out.scanned += n;
|
|
2405
|
+
const src = rawSourceOf(sid); // 裸 id ⇒ 'dsh';'<ns>:<id>' ⇒ 'ns'
|
|
2406
|
+
if (!src) { out.unresolved += n; continue; } // id 以冒号开头(推不出来源名)⇒ 留 NULL,交给读者回落/告警
|
|
2407
|
+
const res = upd.run(src, sid);
|
|
2408
|
+
const filled = Number(res?.changes || 0);
|
|
2409
|
+
out.filled += filled;
|
|
2410
|
+
out.bySource[src] = (out.bySource[src] || 0) + filled;
|
|
2411
|
+
}
|
|
2412
|
+
this.db.exec('COMMIT');
|
|
2413
|
+
} catch (e) {
|
|
2414
|
+
try { this.db.exec('ROLLBACK'); } catch { /* ignore */ }
|
|
2415
|
+
return { ...out, ok: false, message: String(e?.message ?? e).slice(0, 160) };
|
|
2416
|
+
}
|
|
2417
|
+
return out;
|
|
2418
|
+
}
|
|
2419
|
+
|
|
2420
|
+
/** 来源分布(只读;给报告/诊断用):`{[source]: 行数}`,NULL 记作 '(null)'。 */
|
|
2421
|
+
rawSourceCounts() {
|
|
2422
|
+
const rows = this.db.prepare(
|
|
2423
|
+
"SELECT COALESCE(NULLIF(TRIM(source),''),'(null)') AS s, COUNT(*) AS n FROM dsh_turns_raw GROUP BY s ORDER BY n DESC",
|
|
2424
|
+
).all();
|
|
2425
|
+
return Object.fromEntries(rows.map((r) => [String(r.s), Number(r.n)]));
|
|
2426
|
+
}
|
|
2427
|
+
|
|
2111
2428
|
rawTurnCount(sessionId) {
|
|
2112
2429
|
const r = this.db.prepare('SELECT COUNT(*) AS n FROM dsh_turns_raw WHERE session_id=?').get(String(sessionId));
|
|
2113
2430
|
return r ? Number(r.n) : 0;
|
|
@@ -2340,9 +2657,84 @@ export class MemoryStore {
|
|
|
2340
2657
|
return report;
|
|
2341
2658
|
}
|
|
2342
2659
|
|
|
2660
|
+
/** WAL 守卫(1.5.2):缓存键的第二来源。
|
|
2661
|
+
* `total_changes()` 是**本连接**的计数 —— 别的进程在宿主运行期间直写 memory.db 时它一动不动,
|
|
2662
|
+
* 只比 n 会让面板指纹**停在旧值**(旧实现每次全表读,是看得见的)。提交必然落进 `-wal`,
|
|
2663
|
+
* 所以取 `-wal` 的 size + mtimeMs 并进键:外部写入 ⇒ 文件动 ⇒ 缓存失效。
|
|
2664
|
+
* 实测两次 statSync 约 0.014ms(1500 行库,/state 上可忽略;一次重算实测约 10.7ms)。
|
|
2665
|
+
* `-wal` 不存在(非 WAL / 已关连接 / 连接刚开还没写过)⇒ 视为 0/0 哨兵,**绝不抛**:
|
|
2666
|
+
* 这条路径挂在一个每拍都跑的面板接口上,statSync 抛错就会把 /state 变成 500。
|
|
2667
|
+
* ⚠️ 代价是 0/0 也是"合法键值",所以下面三个缓存字段**只在重算真正完成后才推**。
|
|
2668
|
+
* ⚠️ 别把这两个字段的存在当成"守卫一定在工作":`statSync` 抽风/漏 import 时它恒返 [0,0],
|
|
2669
|
+
* 面板照旧显示、测试照旧全绿,而守卫是**静默空转**的(本轮就踩过:忘了 import statSync,
|
|
2670
|
+
* ReferenceError 被本方法的 catch 吞掉)。要确认它活着,得跑一次性运行时探针的
|
|
2671
|
+
* 【陷阱守卫】断言:它必须返回真实 size/mtime,不能是 [0,0]。 */
|
|
2672
|
+
_walStamp() {
|
|
2673
|
+
try {
|
|
2674
|
+
const s = statSync(this.dbPath + '-wal', { throwIfNoEntry: false });
|
|
2675
|
+
return s ? [s.size, s.mtimeMs] : [0, 0];
|
|
2676
|
+
} catch {
|
|
2677
|
+
return [0, 0]; // EACCES/EBUSY 等一律降级为哨兵:宁可回退到"每次都重算",也不让 /state 炸
|
|
2678
|
+
}
|
|
2679
|
+
}
|
|
2680
|
+
|
|
2681
|
+
/** data_version 守卫(1.5.2):缓存键的**第三**来源,专补 `-wal` 那一维的盲区。
|
|
2682
|
+
* 上面的 `_walStamp()` 有个洞:维护/备份脚本若用 `journal_mode=DELETE` 改库,**根本没有 `-wal`
|
|
2683
|
+
* 文件**,stat 恒返 [0,0] 哨兵 ⇒ 外进程改完面板还是旧指纹。`PRAGMA data_version` 正好互补
|
|
2684
|
+
* (一次性运行时探针实测,数值原样):
|
|
2685
|
+
* · 本连接自己写 ⇒ **不变**(2→2) ⇒ 它**不能**替代 `total_changes()`,只补位;
|
|
2686
|
+
* · 别的连接提交 ⇒ **变**(2→3;DELETE 模式 1→2);
|
|
2687
|
+
* · 外连接写完立刻 `wal_checkpoint(TRUNCATE)` 把 -wal 截成 0 ⇒ **照样变**(3→4);
|
|
2688
|
+
* · 本连接纯读 ⇒ **不变**(4→4)⇒ 不会平白抖掉缓存。
|
|
2689
|
+
* 取不到(旧 SQLite / 语句被拒)⇒ 降级为 -1 哨兵,**绝不抛**(这条路径挂在每拍一次的 /state 上)。
|
|
2690
|
+
* ⚠️ 与 `_walStamp()` 同一个陷阱:降级值 -1 也是**合法键值**,长期取不到会静默退化成一维空转 ——
|
|
2691
|
+
* 别把"字段存在"当成"守卫在工作"。探针的【陷阱守卫】断言要求这里必须是**真实整数**,不能是 -1。 */
|
|
2692
|
+
_dataVersion() {
|
|
2693
|
+
try {
|
|
2694
|
+
const r = this.db.prepare('PRAGMA data_version').get();
|
|
2695
|
+
const v = Number(r ? (r.data_version ?? Object.values(r)[0]) : NaN);
|
|
2696
|
+
return Number.isInteger(v) ? v : -1;
|
|
2697
|
+
} catch {
|
|
2698
|
+
return -1; // 退化成"这一维不参与",由其余三维兜,行为等价于加它之前
|
|
2699
|
+
}
|
|
2700
|
+
}
|
|
2701
|
+
|
|
2702
|
+
/** 全表指纹:64 位小写 sha256(面板 client.js:980 取前 16 位展示,tests\unit.mjs:165 断言 length===64)。
|
|
2703
|
+
* 1.5.2:唯一消费方是**每拍一次**的 `/state`,而这里每次全表读 + JSON + sha256 实测约 10.7ms
|
|
2704
|
+
* (库 1612 行 ⇒ JSON 910KB)⇒ 以**五维**缓存键替代重算,键没动就直接返回上次那个字符串:
|
|
2705
|
+
* `_fpN`(`total_changes()`,管本连接自己写)+ `_fpWalSize`/`_fpWalMtime`(兜外进程写)
|
|
2706
|
+
* + `_fpDv`(`PRAGMA data_version`,兜不经 -wal 的写)+ `_fpDb`(句柄,兜关连接再开)。
|
|
2707
|
+
* 四者**互补**,任一维动都会重算 —— 宁可白算,不可漏读。**返回值逐字不变**。 */
|
|
2343
2708
|
fingerprint() {
|
|
2709
|
+
// `total_changes()` 是**本连接**的累计写行数:写就动、只读不动、0 行 UPDATE 不动
|
|
2710
|
+
// ⇒ 偏保守(至多多算一次),不会漏算本连接的任何一次写入。
|
|
2711
|
+
// 连 `this.db` 句柄一起比:万一将来出现"关连接再开"的替换,total_changes 会从 0 重来,
|
|
2712
|
+
// 只比 n 就可能撞回上一世的旧值;比句柄则必然重算。
|
|
2713
|
+
const n = this.db.prepare('SELECT total_changes() AS n').get().n;
|
|
2714
|
+
const [walSize, walMtime] = this._walStamp(); // 兜**外进程**写入:它们不进本连接的计数
|
|
2715
|
+
const dv = this._dataVersion(); // 兜**不经 -wal** 的写入(DELETE 模式维护脚本),见该方法注释
|
|
2716
|
+
// ⚠️ 顺序有讲究:三维**必须先于**下面那次全表读。反过来(先读表、后读守卫)会出现:
|
|
2717
|
+
// 表读到旧内容 → 外进程此刻提交 → 守卫读到新值 ⇒ 于是"旧内容哈希"被记在新键下,
|
|
2718
|
+
// 下一次守卫与新键相等 ⇒ 命中 ⇒ 面板**永停在旧指纹**。守卫先读则最坏只是多算一次。
|
|
2719
|
+
if (
|
|
2720
|
+
this._fpVal !== undefined &&
|
|
2721
|
+
this._fpN === n &&
|
|
2722
|
+
this._fpWalSize === walSize &&
|
|
2723
|
+
this._fpWalMtime === walMtime &&
|
|
2724
|
+
this._fpDv === dv &&
|
|
2725
|
+
this._fpDb === this.db
|
|
2726
|
+
) {
|
|
2727
|
+
return this._fpVal;
|
|
2728
|
+
}
|
|
2344
2729
|
const rows = this.db.prepare('SELECT * FROM conv_overview ORDER BY source, conv_id').all();
|
|
2345
|
-
|
|
2730
|
+
const val = sha256Text(JSON.stringify(rows.map(mapRow)));
|
|
2731
|
+
this._fpN = n; // ⚠️ 缓存字段只在**重算真的完成之后**才推(中途抛错 ⇒ 键不动 ⇒ 下次仍重算)
|
|
2732
|
+
this._fpWalSize = walSize;
|
|
2733
|
+
this._fpWalMtime = walMtime;
|
|
2734
|
+
this._fpDv = dv;
|
|
2735
|
+
this._fpDb = this.db;
|
|
2736
|
+
this._fpVal = val;
|
|
2737
|
+
return val;
|
|
2346
2738
|
}
|
|
2347
2739
|
}
|
|
2348
2740
|
|