@nightpainters/dsh-ling 1.5.2 → 1.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ACCESS-DESIGN.md +4 -3
- package/CHANGELOG.md +75 -3
- package/DESIGN.md +27 -10
- package/README.md +17 -7
- package/lib/audit/access-log.js +46 -12
- package/lib/audit/index.js +2 -2
- package/lib/audit/patch-spec.js +1 -1
- package/lib/client.js +284 -34
- package/lib/host/api.js +588 -53
- package/lib/host/backfill.js +8 -2
- package/lib/host/feedback.js +112 -2
- package/lib/host/freeze.js +52 -1
- package/lib/host/guard.js +38 -1
- package/lib/host/habit-gen.js +12 -4
- package/lib/host/import-file.js +62 -6
- package/lib/host/import-log.js +80 -2
- package/lib/host/lifecycle.js +13 -2
- package/lib/host/memory.js +164 -15
- package/lib/host/mode-service.js +85 -0
- package/lib/host/mode.js +29 -9
- package/lib/host/retitle.js +6 -0
- package/lib/host/session-line.js +46 -6
- package/lib/host/summarizer.js +158 -2
- package/lib/host/surgery.js +600 -0
- package/lib/host/tools.js +12 -1
- package/lib/host/tree-ops.js +1 -1
- package/lib/index.js +17 -29
- package/package.json +2 -2
- package/tools/apply-access-log-patch.mjs +1 -1
package/lib/host/memory.js
CHANGED
|
@@ -9,7 +9,7 @@ import { ensureDir, sha256Text, utcIso, mergeDeep, toIso, toEpochMs } from './ut
|
|
|
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):存量会话与记忆天然属于主干。 */
|
|
@@ -43,11 +43,46 @@ export const BRANCH_LOG_WM_RETIRED = 'branch_log_wm.retired';
|
|
|
43
43
|
* `<= 0` = 关闭时间窗(全部按近期展开)。 */
|
|
44
44
|
export const BRANCH_LOG_WINDOW_DAYS = 3;
|
|
45
45
|
|
|
46
|
-
/**
|
|
46
|
+
/** 命名空间分隔符(原文 id 的 `<ns>:<id>` 形态)。**唯一定义处**在本模块:
|
|
47
|
+
* summarizer.js 从这里取并再导出(既有导入面不变),免得"分隔符"出现两份定义。 */
|
|
48
|
+
export const RAW_NS_SEP = ':';
|
|
49
|
+
|
|
50
|
+
/** 原文命名空间(G4/2026-09-17):dsh_turns_raw 曾无 source 维,DSH 会话与导入原文共表。
|
|
47
51
|
* 若不隔离,概述器会把导入内容当成 DSH 会话重建(GROUP BY session_id 认领)→ 跨源双份;
|
|
48
52
|
* 同 id 时更会互相覆盖原文(不可恢复)。故导入原文一律落 'import:<conv_id>'。
|
|
49
53
|
* 历史数据(1.2.2 之前)的导入原文仍是裸 id —— 读取侧保留回退,写入侧一律带前缀。 */
|
|
50
|
-
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
|
+
}
|
|
51
86
|
|
|
52
87
|
/** 写入侧:按来源得出该会话原文应落的 session_id。 */
|
|
53
88
|
export function rawSessionId(source, convId) {
|
|
@@ -98,6 +133,10 @@ CREATE TABLE IF NOT EXISTS dsh_turns_raw (
|
|
|
98
133
|
ts TEXT,
|
|
99
134
|
model TEXT,
|
|
100
135
|
text TEXT NOT NULL DEFAULT '',
|
|
136
|
+
-- v14(2026-10-01):**真实来源标记**(dsh | import | … 见 RAW_SOURCE_DSH 段口径)。
|
|
137
|
+
-- 可空:NULL 只代表"本列落地之前的存量行"(半迁移态),读者对它回落到旧的"裸 id"判据。
|
|
138
|
+
-- ⚠️ 列尾追加(不换主键、不重建表、不动既有列)⇒ 老库上是一条秒级 ALTER。
|
|
139
|
+
source TEXT,
|
|
101
140
|
PRIMARY KEY (session_id, seq)
|
|
102
141
|
);
|
|
103
142
|
CREATE TABLE IF NOT EXISTS session_meta (
|
|
@@ -330,6 +369,29 @@ function migrateLogActor(db) {
|
|
|
330
369
|
}
|
|
331
370
|
}
|
|
332
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
|
+
|
|
333
395
|
/** 会话级成员"可展开"的谓词(审计 A#6,2026-09-22)。
|
|
334
396
|
*
|
|
335
397
|
* 展开(`branchMembers`)与计数(`branchMemberCounts` / `branchTree().members`)必须共用这一份 SQL,
|
|
@@ -369,13 +431,37 @@ export class MemoryStore {
|
|
|
369
431
|
migrateBranchCols(this.db); // v4→v5:branch 表 + session_meta.branch_id(记忆分枝 D9-a)
|
|
370
432
|
migrateSnapshotKind(this.db); // v9→v10:tree_snapshot.kind(区分自动备份与手动快照,审计 A#9)
|
|
371
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)
|
|
372
435
|
this._kvGet = this.db.prepare('SELECT value FROM kv WHERE key=?');
|
|
373
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
|
+
}
|
|
374
458
|
// schema_version 必须跟随真实结构更新,不能只写一次:
|
|
375
459
|
// v5 加了 branch 表与 session_meta.branch_id,若仍留 '4',后续会话会误判库结构。
|
|
376
460
|
// v6 加 vein_link + memory_conflict 两张新表(纯新增,无 ALTER,DDL 的 IF NOT EXISTS 即迁移)。
|
|
377
461
|
// v10 加 tree_snapshot.kind(ALTER,见 migrateSnapshotKind)。
|
|
378
|
-
|
|
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));
|
|
379
465
|
}
|
|
380
466
|
|
|
381
467
|
close() {
|
|
@@ -913,12 +999,19 @@ export class MemoryStore {
|
|
|
913
999
|
if (!s || !c) return { ok: false, reason: 'bad-key' };
|
|
914
1000
|
let wroteTurns = 0;
|
|
915
1001
|
try {
|
|
916
|
-
const ins = this.db.prepare(`INSERT INTO dsh_turns_raw (session_id,seq,role,ts,model,text)
|
|
917
|
-
VALUES (
|
|
918
|
-
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;
|
|
919
1012
|
this.db.exec('BEGIN');
|
|
920
1013
|
for (const t of (Array.isArray(turns) ? turns : [])) {
|
|
921
|
-
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);
|
|
922
1015
|
wroteTurns += 1;
|
|
923
1016
|
}
|
|
924
1017
|
this.db.exec('COMMIT');
|
|
@@ -2261,14 +2354,22 @@ export class MemoryStore {
|
|
|
2261
2354
|
}
|
|
2262
2355
|
|
|
2263
2356
|
// ---- raw turns (DSH incremental capture, passive) ----
|
|
2264
|
-
|
|
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 }) {
|
|
2265
2362
|
if (!sessionId || typeof text !== 'string' || !text) return;
|
|
2363
|
+
const src = normalizeRawSource(source) || rawSourceOf(sessionId);
|
|
2266
2364
|
this.db.prepare(
|
|
2267
|
-
`INSERT INTO dsh_turns_raw (session_id, seq, role, ts, model, text)
|
|
2268
|
-
VALUES (
|
|
2269
|
-
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 的存量行会被填上。
|
|
2270
2371
|
// 写侧归一(修 A):一律存 ISO;数字/浮点串(如 "1789006881011.0")也归一后再落库。
|
|
2271
|
-
).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);
|
|
2272
2373
|
this.db.prepare(
|
|
2273
2374
|
'INSERT INTO session_meta(session_id, raw_seq) VALUES(?,?) ON CONFLICT(session_id) DO UPDATE SET raw_seq=excluded.raw_seq',
|
|
2274
2375
|
).run(String(sessionId), Number(seq));
|
|
@@ -2276,6 +2377,54 @@ export class MemoryStore {
|
|
|
2276
2377
|
this.db.prepare('UPDATE session_meta SET archived=0 WHERE session_id=? AND archived=1').run(String(sessionId));
|
|
2277
2378
|
}
|
|
2278
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
|
+
|
|
2279
2428
|
rawTurnCount(sessionId) {
|
|
2280
2429
|
const r = this.db.prepare('SELECT COUNT(*) AS n FROM dsh_turns_raw WHERE session_id=?').get(String(sessionId));
|
|
2281
2430
|
return r ? Number(r.n) : 0;
|
|
@@ -2518,7 +2667,7 @@ export class MemoryStore {
|
|
|
2518
2667
|
* ⚠️ 代价是 0/0 也是"合法键值",所以下面三个缓存字段**只在重算真正完成后才推**。
|
|
2519
2668
|
* ⚠️ 别把这两个字段的存在当成"守卫一定在工作":`statSync` 抽风/漏 import 时它恒返 [0,0],
|
|
2520
2669
|
* 面板照旧显示、测试照旧全绿,而守卫是**静默空转**的(本轮就踩过:忘了 import statSync,
|
|
2521
|
-
* ReferenceError 被本方法的 catch 吞掉)
|
|
2670
|
+
* ReferenceError 被本方法的 catch 吞掉)。要确认它活着,得跑一次性运行时探针的
|
|
2522
2671
|
* 【陷阱守卫】断言:它必须返回真实 size/mtime,不能是 [0,0]。 */
|
|
2523
2672
|
_walStamp() {
|
|
2524
2673
|
try {
|
|
@@ -2532,7 +2681,7 @@ export class MemoryStore {
|
|
|
2532
2681
|
/** data_version 守卫(1.5.2):缓存键的**第三**来源,专补 `-wal` 那一维的盲区。
|
|
2533
2682
|
* 上面的 `_walStamp()` 有个洞:维护/备份脚本若用 `journal_mode=DELETE` 改库,**根本没有 `-wal`
|
|
2534
2683
|
* 文件**,stat 恒返 [0,0] 哨兵 ⇒ 外进程改完面板还是旧指纹。`PRAGMA data_version` 正好互补
|
|
2535
|
-
* (
|
|
2684
|
+
* (一次性运行时探针实测,数值原样):
|
|
2536
2685
|
* · 本连接自己写 ⇒ **不变**(2→2) ⇒ 它**不能**替代 `total_changes()`,只补位;
|
|
2537
2686
|
* · 别的连接提交 ⇒ **变**(2→3;DELETE 模式 1→2);
|
|
2538
2687
|
* · 外连接写完立刻 `wal_checkpoint(TRUNCATE)` 把 -wal 截成 0 ⇒ **照样变**(3→4);
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// dsh-ling host — 模式服务(P3-A,2026-09-29):把「给某个会话设模式」做成跨插件能力。
|
|
2
|
+
//
|
|
3
|
+
// 为什么需要它:器灵的模式记录是**私有**的(memory.db 的 `session_meta.mode`),
|
|
4
|
+
// 而 memory / gate / settings 三个依赖只活在器灵自己的 apply 闭包里 —— 别的插件
|
|
5
|
+
// (如飞书桥 dsh-feishu-host)拿不到,于是「来自飞书的会话默认走生活挡位」一直做不到。
|
|
6
|
+
// 本模块把这个动作注册成 cordis 服务 `dsh-ling/mode`,调用方按**软依赖**取用:
|
|
7
|
+
//
|
|
8
|
+
// const ling = ctx.get('dsh-ling/mode'); // 拿不到就是 undefined,不是错误
|
|
9
|
+
// await ling?.applyMode(sessionId, 'life', { keepModel: { provider, model } });
|
|
10
|
+
//
|
|
11
|
+
// 契约(语义与 mode.js 一致,差别只在默认值):
|
|
12
|
+
// applyMode(sessionId, modeKey, opts) -> Promise<Result>
|
|
13
|
+
// · **不改全局** `settings.mode.lastMode`
|
|
14
|
+
// —— GUI 的 `applyModeToSession` 才改,那是「同时设为新会话默认」的语义;
|
|
15
|
+
// 跨插件调用若照做,会把飞书的挡位泄漏成桌面端的默认值。
|
|
16
|
+
// · opts.keepModel 调用方自带的模型。**跨插件调用必须传** —— 不传就退回
|
|
17
|
+
// 「读全局当前选择」,会把 GUI 正选着的模型覆盖到对方会话上。
|
|
18
|
+
// · opts.syncEffort 默认 true(模式的实质就是推理等级);false = 只记模式。
|
|
19
|
+
// · **失败不抛**:以 `{ok:false, reason}` 返回。调用方不该因为器灵缺席/出错
|
|
20
|
+
// 而中断自己的流程。
|
|
21
|
+
//
|
|
22
|
+
// Result = { ok, mode, reasoningEffort, warning?, queued?, lastMode?, reason? }
|
|
23
|
+
import { KNOWN_MODES, applyModeToSession, currentMode } from './mode.js';
|
|
24
|
+
|
|
25
|
+
export const MODE_SERVICE_NAME = 'dsh-ling/mode';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 把模式能力注册成服务。
|
|
29
|
+
*
|
|
30
|
+
* 用 `ctx.provide()` 而不是 `class extends Service`:本插件的 apply 是函数形态,
|
|
31
|
+
* 且 dsh-ling 以 `file:` + Junction 加载、包内无 node_modules,`import '@deepseek-ai/cordis'`
|
|
32
|
+
* 会走 realpath 回到**本包根目录**再向上找 —— 找不到。`ctx.provide` 是同一套
|
|
33
|
+
* reflect 机制的上层入口,零 import、同样由 fiber 生命周期托管(返回 disposer)。
|
|
34
|
+
*
|
|
35
|
+
* @param {object} ctx cordis 上下文(插件自己的)。
|
|
36
|
+
* @param {object} deps `{ gate, memory, settings }`,与 mode.js 同源。
|
|
37
|
+
* @returns {() => void} disposer(拆服务)。
|
|
38
|
+
*/
|
|
39
|
+
export function registerModeService(ctx, { gate, memory, settings }) {
|
|
40
|
+
const api = {
|
|
41
|
+
name: MODE_SERVICE_NAME,
|
|
42
|
+
knownModes: KNOWN_MODES,
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 给某个会话设模式。
|
|
46
|
+
*
|
|
47
|
+
* @param {string} sessionId 目标会话(飞书侧形如 `feishu-<uuid>`)。
|
|
48
|
+
* @param {'work'|'life'} modeKey
|
|
49
|
+
* @param {{keepModel?: {provider: string, model: string}, syncEffort?: boolean}} [opts]
|
|
50
|
+
*/
|
|
51
|
+
async applyMode(sessionId, modeKey, opts = {}) {
|
|
52
|
+
if (!sessionId) return { ok: false, reason: 'no-session-id' };
|
|
53
|
+
if (!KNOWN_MODES.includes(modeKey)) return { ok: false, reason: `unknown-mode:${modeKey}` };
|
|
54
|
+
try {
|
|
55
|
+
return await applyModeToSession(ctx, gate, memory, settings, sessionId, modeKey, {
|
|
56
|
+
setDefault: false, // 跨插件:只改这条会话,不动全局默认
|
|
57
|
+
keepModel: opts.keepModel ?? null,
|
|
58
|
+
syncEffort: opts.syncEffort !== false,
|
|
59
|
+
});
|
|
60
|
+
} catch (e) {
|
|
61
|
+
return { ok: false, reason: 'throw:' + String(e?.message ?? e) };
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
/** 读某个会话当前生效的模式(session_meta → lastMode → 'life')。 */
|
|
66
|
+
currentMode(sessionId) {
|
|
67
|
+
try {
|
|
68
|
+
return currentMode(memory, settings, sessionId);
|
|
69
|
+
} catch {
|
|
70
|
+
return 'life';
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
const dispose = ctx.provide(MODE_SERVICE_NAME, api);
|
|
77
|
+
console.info('[dsh-ling] mode service provided: %s', MODE_SERVICE_NAME);
|
|
78
|
+
return typeof dispose === 'function' ? dispose : () => {};
|
|
79
|
+
} catch (e) {
|
|
80
|
+
// 同名服务已被别的 fiber 占用(例如器灵被加载了两遍):只记日志,不抛 ——
|
|
81
|
+
// 器灵其余功能照常,不能因为一个可选能力注册失败就整插件起不来。
|
|
82
|
+
console.debug('[dsh-ling] mode service provide failed', e);
|
|
83
|
+
return () => {};
|
|
84
|
+
}
|
|
85
|
+
}
|
package/lib/host/mode.js
CHANGED
|
@@ -45,13 +45,26 @@ export function currentModelSelection(ctx) {
|
|
|
45
45
|
return null;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
/**
|
|
49
|
+
* @param {object} [opts] P3-A(2026-09-29) 加的调用方选项,缺省 = 原行为(GUI 路径)。
|
|
50
|
+
* @param {boolean} [opts.setDefault] 是否把全局 `mode.lastMode` 一起改掉(=「设为新会话默认」)。
|
|
51
|
+
* GUI 的 applyModeToSession 走 true;跨插件(器灵模式服务)走 false —— 否则飞书的挡位
|
|
52
|
+
* 会泄漏成桌面端的默认值。
|
|
53
|
+
* @param {?{provider:string,model:string}} [opts.keepModel] 调用方自带的「当前模型」。
|
|
54
|
+
* 不传才退回读**全局**当前选择;跨插件调用必须传,否则会把别人的模型写回本会话。
|
|
55
|
+
* @param {boolean} [opts.syncEffort] 是否同步推理等级,默认 true(模式的实质就是它)。
|
|
56
|
+
*/
|
|
57
|
+
async function doApply(ctx, memory, settings, sessionId, modeKey, opts = {}) {
|
|
49
58
|
const reasoningEffort = resolveModeEffort(settings.get(), modeKey);
|
|
50
59
|
const controller = svc(ctx, 'sessionController');
|
|
51
60
|
let warning;
|
|
52
|
-
if (
|
|
53
|
-
|
|
54
|
-
|
|
61
|
+
if (opts.syncEffort === false) {
|
|
62
|
+
warning = 'effort-sync-skipped';
|
|
63
|
+
} else if (controller && typeof controller.selectModel === 'function') {
|
|
64
|
+
const keep = (opts.keepModel && opts.keepModel.provider && opts.keepModel.model)
|
|
65
|
+
? opts.keepModel
|
|
66
|
+
: currentModelSelection(ctx); // 当前模型:原样带回,不改
|
|
67
|
+
if (!keep || !keep.provider || !keep.model) {
|
|
55
68
|
warning = 'unknown-current-model; 推理等级未同步(拒绝替用户指定模型)';
|
|
56
69
|
console.debug('[dsh-ling]', warning);
|
|
57
70
|
} else {
|
|
@@ -73,23 +86,30 @@ async function doApply(ctx, memory, settings, sessionId, modeKey) {
|
|
|
73
86
|
warning = 'no-sessionController; mode recorded only';
|
|
74
87
|
}
|
|
75
88
|
memory.setSessionMode(sessionId, modeKey);
|
|
76
|
-
|
|
77
|
-
|
|
89
|
+
let lastMode = settings.get().mode?.lastMode;
|
|
90
|
+
if (opts.setDefault === true) {
|
|
91
|
+
const s = await settings.update({ mode: { lastMode: modeKey } }); // D2 跟随
|
|
92
|
+
lastMode = s.mode.lastMode;
|
|
93
|
+
}
|
|
94
|
+
return { ok: true, warning, sessionId, mode: modeKey, reasoningEffort, lastMode };
|
|
78
95
|
}
|
|
79
96
|
|
|
80
97
|
/**
|
|
81
98
|
* Apply mode to a session (D4): running → queue (returns queued), idle → apply now.
|
|
99
|
+
*
|
|
100
|
+
* @param {object} [opts] 见 doApply。缺省 `{setDefault:true}` = GUI 语义(改本会话 + 设为默认)。
|
|
82
101
|
*/
|
|
83
|
-
export async function applyModeToSession(ctx, gate, memory, settings, sessionId, modeKey) {
|
|
102
|
+
export async function applyModeToSession(ctx, gate, memory, settings, sessionId, modeKey, opts = {}) {
|
|
84
103
|
if (!sessionId) return { ok: false, reason: 'no-session-id' };
|
|
85
104
|
if (!KNOWN_MODES.includes(modeKey)) return { ok: false, reason: `unknown-mode:${modeKey}` };
|
|
105
|
+
const merged = { setDefault: true, ...opts };
|
|
86
106
|
const queued = gate.enqueueIfRunning(sessionId, () => {
|
|
87
|
-
doApply(ctx, memory, settings, sessionId, modeKey).catch((e) =>
|
|
107
|
+
doApply(ctx, memory, settings, sessionId, modeKey, merged).catch((e) =>
|
|
88
108
|
console.debug('[dsh-ling] queued mode apply failed', e),
|
|
89
109
|
);
|
|
90
110
|
});
|
|
91
111
|
if (queued) return { ok: true, queued: true, running: true, sessionId, mode: modeKey };
|
|
92
|
-
return await doApply(ctx, memory, settings, sessionId, modeKey);
|
|
112
|
+
return await doApply(ctx, memory, settings, sessionId, modeKey, merged);
|
|
93
113
|
}
|
|
94
114
|
|
|
95
115
|
/** Current mode for a session: session_meta → settings.lastMode. */
|
package/lib/host/retitle.js
CHANGED
|
@@ -14,6 +14,12 @@ import { summarizeWithRetry } from './dsweb-summary.js';
|
|
|
14
14
|
import { llmOnce } from './deepsummary.js';
|
|
15
15
|
import { rawSessionCandidates } from './memory.js';
|
|
16
16
|
|
|
17
|
+
/** 标题**生成上限**(`heuristicTitle` 切句读/硬截的落点:它决定"机器造出来的标题最长多少字")。
|
|
18
|
+
* ⚠️ 与 `session-line.js` 的 `TITLE_MAX_CHARS`(= 60)**同名但不同语义**(E1 判定,2026-10-01):
|
|
19
|
+
* 那个是"那一行的**显示兜底**夹取",作用于**任何来源**的标题(含源库自带、主人手改 ≤120)。
|
|
20
|
+
* 两处的读者与方向都不同 ⇒ **不要把两处"统一"成一个数**:统一到 58 会让手改/源库标题多切一刀,
|
|
21
|
+
* 统一到 60 会改变下面 `heuristicTitle` 在 59-60 字输入上的行为(两个方向都是行为变更)。
|
|
22
|
+
* 唯一的不变式是**显示兜底 ≥ 本值**(否则机器刚造出来的标题会在那一行里被再切一刀)。 */
|
|
17
23
|
export const TITLE_MAX_CHARS = 58; // 46 → 58:46 会把一句完整的话切成半句
|
|
18
24
|
export const LEGACY_TITLE_MAX = 46; // 1.2.2 之前的硬截上限 —— 长度"达到"它就说明这条标题曾被切过
|
|
19
25
|
export const MACHINE_TITLE_MAX = 40; // 机器生成标题的上限(手改上限在 memory.renameTitle:120)
|
package/lib/host/session-line.js
CHANGED
|
@@ -49,7 +49,16 @@ export const TIER_TURNS_DEFAULT = 10;
|
|
|
49
49
|
export const LINE_MAX_CHARS = 110;
|
|
50
50
|
/** 「最新进展」上限:真人发言首句。 */
|
|
51
51
|
export const PROGRESS_MAX_CHARS = 40;
|
|
52
|
-
/**
|
|
52
|
+
/** 标题段**显示兜底**上限(超出留 `…`,见 `clip`)。
|
|
53
|
+
* ⚠️ **与 `retitle.js` 的同名常量语义不同,别把两者"统一"成一个数**(E1 判定,2026-10-01):
|
|
54
|
+
* · `retitle.js:TITLE_MAX_CHARS`(= 58)= **生成上限** —— `heuristicTitle()` 切句读/硬截的落点,
|
|
55
|
+
* 它决定"机器造出来的标题最长多少字"(`summarizer.js` / `import-file.js` 只拿它当兜底);
|
|
56
|
+
* · 本常量(= 60)= **显示兜底** —— `resolveTitle()` 给那一行印标题时,把**任何来源**的标题硬夹到这里:
|
|
57
|
+
* 除本机生成(≤58)外,还有源库自带标题(不做首句截断)与主人手改(`memory.renameTitle` 上限 120)。
|
|
58
|
+
* 不变式:**本值必须 ≥ `retitle.js` 的生成上限** —— 否则机器刚造出来的标题会在那一行里被**再切一刀**,
|
|
59
|
+
* "那一行印的标题"与"记忆里的标题"当场对不上。60 = 58 + 2,余量留给非本机来源的短标题。
|
|
60
|
+
* (两处靠模块边界分开;**没有任何模块同时 import 这两个常量**,将来若要改名以消除同名,
|
|
61
|
+
* 建议本处叫 `LINE_TITLE_MAX_CHARS` —— 属结构调整,未做。) */
|
|
53
62
|
export const TITLE_MAX_CHARS = 60;
|
|
54
63
|
export const KV_LAST = 'sessline.last.';
|
|
55
64
|
export const KV_COUNTS = 'sessline.counts.';
|
|
@@ -158,6 +167,31 @@ export function progressKey(title, progress) {
|
|
|
158
167
|
return md5(oneLine(title) + '\u0000' + oneLine(progress));
|
|
159
168
|
}
|
|
160
169
|
|
|
170
|
+
/**
|
|
171
|
+
* **C 闸「内容真变了?」的唯一判据** —— 写路径(`maybeSessionLine`)与只读预览(`sessionLinePreview`)
|
|
172
|
+
* 共用**这一处**,谁都不许在自己那头另写一份。
|
|
173
|
+
* 为什么必须共用:两条路曾各自内联判据,写路径 3 条、预览只有 2 条(少 md5)
|
|
174
|
+
* ⇒ 同一份状态能出现「预览说会追加、写路径判 same」的分叉 —— 属"读数与行为不一致",最误导人。
|
|
175
|
+
*
|
|
176
|
+
* 三条判据,任一说"没变"就算没变(`changed:false`):
|
|
177
|
+
* ① 逐字节:`candidate` 与上次那条的原文相同。⚠️ **两条路的基准不同源** ——
|
|
178
|
+
* 写路径取 kv `st.text`,预览取**面上那条**(HTTP 面板路径常常根本没有会话面);
|
|
179
|
+
* ② `md5(candidate)` 与上次记录的 md5 相同(kv `sessline.last.*.md5`,裁定点名要用的那道):
|
|
180
|
+
* 它的基准**两条路都读得到** ⇒ 这是两条路唯一真正共享的判据,**一条都不许缺**;
|
|
181
|
+
* ③ 实质键:`progressKey(标题, 最新进展)` 与上次追加时记的相同(剥掉 `#N`/`第 N 轮`/`HH:MM`)。
|
|
182
|
+
* ⚠️ 预览的键基准可能退让到"从上次那条正文里反解出的标题/进展"(面板看不到进展时),与写路径未必同源
|
|
183
|
+
* —— 那是**已知且有意**的差异,注释见 `sessionLinePreview` ④。
|
|
184
|
+
* `candidate` 为空串不做额外守卫:记录里的 md5 一律来自**非空**正文(`maybeSessionLine` 从不追加空文本),
|
|
185
|
+
* 故 `md5('')` 不可能等于任何一条记录。
|
|
186
|
+
* @returns {{changed:boolean, textSame:boolean, md5Same:boolean, keySame:boolean}}
|
|
187
|
+
*/
|
|
188
|
+
export function contentChanged({ candidate = '', lastText = '', lastMd5 = '', lastKey = '', curKey = '' } = {}) {
|
|
189
|
+
const textSame = !!lastText && String(candidate) === String(lastText);
|
|
190
|
+
const md5Same = !!lastMd5 && md5(candidate) === String(lastMd5);
|
|
191
|
+
const keySame = !!lastKey && !!curKey && String(curKey) === String(lastKey);
|
|
192
|
+
return { changed: !(textSame || md5Same || keySame), textSame, md5Same, keySame };
|
|
193
|
+
}
|
|
194
|
+
|
|
161
195
|
/** 从我们那条活行的正文里把"标题 + 进展"两段拆出来(给预览/自愈当比较基准)。
|
|
162
196
|
* 正文形如 `[本次会话·进展 #N] {标题} —— 最近:{进展} · 第 T 轮 · HH:MM`,尾部整段是格式位。
|
|
163
197
|
* 解不出(老格式/被夹过)⇒ 两段都空。 */
|
|
@@ -397,11 +431,12 @@ export function maybeSessionLine({ memory, agent, payload, stateMap, now = Date.
|
|
|
397
431
|
// 为什么要 ③:只留 ①② 时,`#N` 每尝试一次就 +1 ⇒ 恒判"变了",闸等于没有(实测钉过);
|
|
398
432
|
// 为什么要 ①②:同一轮内同参重入时,只有逐字节那一侧才拦得住。`st.key` 是**新增字段**,
|
|
399
433
|
// 不动任何已有 kv 的含义;老记录没有它 ⇒ 第一次一律追加一条,之后照常。
|
|
434
|
+
// ⚠️ 这三条**只在 `contentChanged` 里实现一处**(旧写法在本函数内联三条、预览那头内联两条 ⇒ 曾能分叉);
|
|
435
|
+
// 本路径的基准:① kv `st.text` ② kv `md5` ③ kv `key`。
|
|
400
436
|
const lastMd5 = String(st.md5 || '');
|
|
401
437
|
const lastKey = String(st.key || '');
|
|
402
|
-
const textUnchanged = !!st.text && candidate === String(st.text);
|
|
403
438
|
const curKey = progressKey(title, progress);
|
|
404
|
-
const changed =
|
|
439
|
+
const changed = contentChanged({ candidate, lastText: st.text, lastMd5, lastKey, curKey }).changed;
|
|
405
440
|
const verdict = decideSessionLine({ onSurface: scan, candidate, lastMd5, changed });
|
|
406
441
|
if (verdict.action !== 'append') {
|
|
407
442
|
// kv 语义**不变**:`skippedSame` = "被内容闸挡下"(旧义是"面末条逐字节相同",新义是"与 lastMd5/面末条相同",
|
|
@@ -550,9 +585,14 @@ export function sessionLinePreview(memory, settings, sessionId, { turn } = {}) {
|
|
|
550
585
|
const basis = progress ? 'title+progress' : (scan.latestUser ? 'title+progress' : 'title-only');
|
|
551
586
|
const curKey = effTitle || effProgress ? progressKey(effTitle, effProgress) : '';
|
|
552
587
|
const judgeText = buildSessionLineText({ title: effTitle, progress: effProgress, ordinal, at: Date.now() }); // 无轮次版
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
588
|
+
// ④′ 判据**与写路径同一处**(`contentChanged`):旧写法这里只内联两条(逐字节 + 实质键),
|
|
589
|
+
// 少了 md5 那条 ⇒ 当 kv 记着 md5 而 `last.text` 缺失/不新鲜时,预览说"会追加"、写路径判 same
|
|
590
|
+
// (同一份状态两种读数)。逐字节那条的基准是**面上那条**(没有面时为空 ⇒ 该条不参与,与旧写法一致)。
|
|
591
|
+
const judge = contentChanged({
|
|
592
|
+
candidate: judgeText, lastText: scan.count === 1 ? scan.lastText : '', lastMd5, lastKey, curKey,
|
|
593
|
+
});
|
|
594
|
+
const changed = judge.changed;
|
|
595
|
+
if (process.env.DSH_LING_DBG) console.log('[pv]', JSON.stringify({ cnt: scan.count, ord: ordinal, basis, lastKey: lastKey.slice(0, 6), curKey: curKey.slice(0, 6), lastParts, effTitle, effProgress, textSame: judge.textSame, md5Same: judge.md5Same, keySame: judge.keySame, judge: judgeText }));
|
|
556
596
|
const verdict = decideSessionLine({ onSurface: scan, candidate: judgeText, lastMd5, changed });
|
|
557
597
|
out.blocked = verdict.reason;
|
|
558
598
|
out.wouldAppend = verdict.action === 'append';
|