@nightpainters/dsh-ling 1.6.2 → 1.7.1
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/CHANGELOG.md +176 -9
- package/DESIGN.md +89 -6
- package/PHILOSOPHY.md +14 -4
- package/README.md +105 -19
- package/cordis.patch.yml +32 -2
- package/lib/client.js +2544 -61
- package/lib/host/agent-tools.js +286 -0
- package/lib/host/api.js +592 -59
- package/lib/host/archive.js +7 -7
- package/lib/host/autotree.js +2 -2
- package/lib/host/clock-skill.js +1 -1
- package/lib/host/config.js +38 -0
- package/lib/host/deep-index-skill.js +3 -3
- package/lib/host/deepsummary.js +80 -4
- package/lib/host/dsweb-summary.js +11 -5
- package/lib/host/genesis.js +5 -3
- package/lib/host/guard.js +53 -0
- package/lib/host/inject.js +18 -5
- package/lib/host/l1.js +17 -11
- package/lib/host/library-checker.js +552 -0
- package/lib/host/library-import-engine.js +211 -0
- package/lib/host/library-import.js +911 -0
- package/lib/host/library-runtime.js +199 -0
- package/lib/host/library-sqlite.js +344 -0
- package/lib/host/library-store.js +1714 -0
- package/lib/host/memory.js +119 -29
- package/lib/host/persona.js +13 -2
- package/lib/host/rawlog.js +67 -4
- package/lib/host/recall-search.js +60 -12
- package/lib/host/retitle.js +5 -5
- package/lib/host/session-line.js +7 -2
- package/lib/host/snapshot.js +9 -5
- package/lib/host/surgery.js +6 -4
- package/lib/host/tools.js +473 -27
- package/lib/host/util.js +1 -1
- package/lib/index.js +120 -81
- package/package.json +10 -5
- package/tools/deep-import-docs.mjs +1210 -0
package/lib/host/memory.js
CHANGED
|
@@ -5,17 +5,23 @@ import { join } from 'node:path';
|
|
|
5
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
|
+
import { wellFormed } from './rawlog.js'; // 1.7.1 F4 闭环:留痕摘要会进注入面
|
|
8
9
|
// A-01(1.5.1):主脉记忆被覆盖前**必须先归档**。archive.js 只依赖 node:fs / node:path,
|
|
9
10
|
// 且把 memory 当参数传(依赖注入)⇒ memory.js 引它**不成环**。
|
|
10
11
|
import { archiveBefore } from './archive.js';
|
|
11
12
|
|
|
12
|
-
const SCHEMA_VERSION = 17;
|
|
13
|
+
const SCHEMA_VERSION = 17;
|
|
14
|
+
/** 作废哨兵(`deep_item.superseded_by` 的第三个取值,2026-10-15)。
|
|
15
|
+
* 为什么用哨兵而不是新列:所有"排除已被取代"的读路径(开场选择 / recall / 面板)
|
|
16
|
+
* 判的都是 `superseded_by != ''` —— 哨兵让**那些路径一处都不用改**就自动不再挑它;
|
|
17
|
+
* 而"为什么作废"落在 kv `deep.retract:<id>`(人可读、可审计),不污染表语义。 */
|
|
18
|
+
export const DEEP_RETRACTED = 'retracted'; // v17:deep_item.durability(条目「硬度」,1.6,2026-10-04 —— 主人原话「同会话使用硬数量上限来卡吗,这可能有点机械,能不能做成从根源上判断这些条目到底是临时的还是永久的」⇒ 病根不是"同一会话进了太多条",而是**临时的东西被判成了长期记忆**);v16:deep_item(深层库条目,1.6 步 2,2026-10-04 —— 纯新增表,DDL 的 IF NOT EXISTS 即迁移;与记忆树并立、条目不被枝隶属);v15:inject_log(投递留痕,1.6-A,2026-10-03 —— 纯新增表,DDL 的 IF NOT EXISTS 即迁移);v14:dsh_turns_raw.source(来源从"推断"变"记录",2026-10-01 主人定「来源也加个列吧」—— 旧判据是"裸 id = DSH 会话",边界是"将来若出现不带冒号的新来源名,物理上分不出来");v13:forgotten(软遗忘标记 —— 永远不彻底删除,先归档再打标记);v12:branch_log.actor(G4 器灵侧通道 —— 工具改的树必须记在"器灵"名下,不再一律记成主人);v11:vein_suggestions(主脉提炼审核队列 R7 —— 设计稿 §5:提炼必须走审核,不直接进记忆);
|
|
13
19
|
// 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
20
|
|
|
15
21
|
/** 主干枝 id(D9-a 记忆分枝,2026-09-18):存量会话与记忆天然属于主干。 */
|
|
16
22
|
export const TRUNK_ID = 'trunk';
|
|
17
23
|
|
|
18
|
-
/** 血缘档位(
|
|
24
|
+
/** 血缘档位(用户 2026-09-18 定档 1.0/0.7/0.4)。
|
|
19
25
|
* 取"最弱环"单值,不做连乘 —— `0.7^5=0.168`、`0.4^5=0.010`,连乘会让深枝等于从记忆里消失,
|
|
20
26
|
* 且每层舍入都会乘进后面。单值 + 有界,权重才可解释、可复现。 */
|
|
21
27
|
export const LINEAGE_SAME = 1.0; // 同枝
|
|
@@ -327,6 +333,9 @@ CREATE TABLE IF NOT EXISTS deep_item (
|
|
|
327
333
|
-- ⚠️ ephemeral **不进开场**(选择侧过滤掉),但**照旧留在库里、recall 搜得到**:
|
|
328
334
|
-- 判严的代价远小于判松了(判松 = 每次开口都在推一条早失效的临时决定)。
|
|
329
335
|
superseded_by TEXT NOT NULL DEFAULT '', -- 被哪条取代 —— 沿用老规矩「只追不替」:旧的留痕、不删
|
|
336
|
+
-- 取值三态:'' = 在役 / 'di:…' = 被这条取代 /
|
|
337
|
+
-- 'retracted' = **作废**(无接替者,2026-10-15 用户拍板;
|
|
338
|
+
-- 理由落 kv「deep.retract:<id>」,见 DEEP_RETRACTED)
|
|
330
339
|
origin TEXT NOT NULL DEFAULT 'auto', -- auto(提炼) | user(主人手写);origin='user' 的不许被自动覆盖
|
|
331
340
|
branch_id TEXT NOT NULL DEFAULT '', -- 气根:空 = 未挂枝;非空 = 被这条枝反向引用(不参与树的层级计算)
|
|
332
341
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
@@ -402,7 +411,7 @@ function migrateTitleCols(db) {
|
|
|
402
411
|
}
|
|
403
412
|
}
|
|
404
413
|
|
|
405
|
-
/** v4→v5:记忆分枝(D9-a,2026-09-18
|
|
414
|
+
/** v4→v5:记忆分枝(D9-a,2026-09-18 用户定方向)。
|
|
406
415
|
* session_meta 加 branch_id(默认 'trunk' —— 存量会话天然属主干,迁移只填一列);
|
|
407
416
|
* 建 branch 表并保证主干行存在。**记忆行不冗余存 branch**,靠 session → branch 推导:
|
|
408
417
|
* 迁移成本最低,且枝归属变化时无需回写海量记忆行。 */
|
|
@@ -941,10 +950,12 @@ export class MemoryStore {
|
|
|
941
950
|
|
|
942
951
|
/** 复盘期权重调节(平时只读 —— 设计稿 §3.3)。scale 夹在 [0,2]。
|
|
943
952
|
*
|
|
944
|
-
*
|
|
945
|
-
|
|
946
|
-
*
|
|
947
|
-
*
|
|
953
|
+
* **A-5(2026-10-05 夜 · 主人拍板走方案 b):`weight_scale` 已接回检索打分** ——
|
|
954
|
+
* L1 = `raw × 血缘档位 × 矛盾系数 × 枝系数`。
|
|
955
|
+
* 历史:审计 A#11(2026-09-22)曾把**权重与可见性一起**定为"只存只显示";2026-10-05 只解开权重那一半
|
|
956
|
+
* (可见性仍是预留字段,见 `listBranches`)。
|
|
957
|
+
* ⚠️ 别再把权重这半边叫"预留字段"(2026-10-06 内部台账核对抓到:上面这行旧的"预留字段"与这行
|
|
958
|
+
* "已接回打分"**在同一个注释块里自相矛盾**,读第一行的人会判它无效)。 */
|
|
948
959
|
setBranchWeight(branchId, scale) {
|
|
949
960
|
const bid = String(branchId || '');
|
|
950
961
|
// ⚠️ 先判**类型**再取数(蓝队 2026-10-04):`Number(null) === 0`、`Number(true) === 1`、
|
|
@@ -959,7 +970,8 @@ export class MemoryStore {
|
|
|
959
970
|
return {
|
|
960
971
|
ok: true, id: bid, weightScale: clamped,
|
|
961
972
|
applied: true,
|
|
962
|
-
note: '已生效:该枝的结论在记忆开场里按此系数加权(1.0 =
|
|
973
|
+
note: '已生效:该枝的结论在记忆开场里按此系数加权(1.0 = 不偏不倚;⚠️ **0 = 该枝结论不进开场** —— '
|
|
974
|
+
+ '它会被当成"算不出正分"直接排除,所以 0 不是"只是排后面")',
|
|
963
975
|
};
|
|
964
976
|
}
|
|
965
977
|
|
|
@@ -992,10 +1004,23 @@ export class MemoryStore {
|
|
|
992
1004
|
// (主键是有序对,旧写入侧没挡反向)⇒ 只删一个方向会让回执说"断开了"而树里还连着。
|
|
993
1005
|
const a = String(fromBranch || '');
|
|
994
1006
|
const b = String(toBranch || '');
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
)
|
|
998
|
-
|
|
1007
|
+
// 1.7.1(审计 F-03 + delta 复审):按**每一行自己的 kind** 决策,而不是只看正向行。
|
|
1008
|
+
// 有向类型(A→B 与 B→A 是**两条不同的边**)只删同向;无向类型仍按旧口径双向清 ——
|
|
1009
|
+
// 那是历史重复行(有序对主键)的兜底。混合历史(如 A→B related + B→A prerequisite)
|
|
1010
|
+
// 以前会被整对双删,directed 那条被连带抹掉;现在只删请求方向 + 仅当反向也是无向时才清。
|
|
1011
|
+
const fwd = this.db.prepare('SELECT kind FROM vein_link WHERE from_branch=? AND to_branch=?').get(a, b);
|
|
1012
|
+
const rev = this.db.prepare('SELECT kind FROM vein_link WHERE from_branch=? AND to_branch=?').get(b, a);
|
|
1013
|
+
const isDirected = (row) => Boolean(row) && DIRECTED_LINK_KINDS.has(String(row.kind || 'related'));
|
|
1014
|
+
let removed = 0;
|
|
1015
|
+
if (fwd) {
|
|
1016
|
+
const r1 = this.db.prepare('DELETE FROM vein_link WHERE from_branch=? AND to_branch=?').run(a, b);
|
|
1017
|
+
removed += Number(r1.changes || 0);
|
|
1018
|
+
}
|
|
1019
|
+
if (rev && !isDirected(rev) && !isDirected(fwd)) {
|
|
1020
|
+
const r2 = this.db.prepare('DELETE FROM vein_link WHERE from_branch=? AND to_branch=?').run(b, a);
|
|
1021
|
+
removed += Number(r2.changes || 0);
|
|
1022
|
+
}
|
|
1023
|
+
return { ok: true, removed };
|
|
999
1024
|
} catch {
|
|
1000
1025
|
return { ok: false, reason: 'db' };
|
|
1001
1026
|
}
|
|
@@ -1017,7 +1042,7 @@ export class MemoryStore {
|
|
|
1017
1042
|
|
|
1018
1043
|
// ---- 软遗忘(Part G G2,2026-09-25) ----
|
|
1019
1044
|
//
|
|
1020
|
-
// 原则(
|
|
1045
|
+
// 原则(用户 2026-09-25 定):**永远不做彻底删除**。遗忘 = 先把原数据归档落盘(原文 + 概述 +
|
|
1021
1046
|
// 改动日志),再在库里打一个标记,召回路径据标记排除。行仍在库里、归档文件在磁盘上 ——
|
|
1022
1047
|
// 两条独立还原路径,任何一条都能反悔。
|
|
1023
1048
|
//
|
|
@@ -1147,7 +1172,7 @@ export class MemoryStore {
|
|
|
1147
1172
|
*
|
|
1148
1173
|
* **调用方必须先归档当前状态** —— 本方法只管写,不做任何备份。
|
|
1149
1174
|
* 回灌的危险性高于遗忘:遗忘会留下"少了一条"的可见痕迹,而回灌是**往里加东西**,
|
|
1150
|
-
* 加错了很难察觉(
|
|
1175
|
+
* 加错了很难察觉(用户 2026-09-25:「backfill 比 forget 危险多了,因为它更难以觉察」)。
|
|
1151
1176
|
*
|
|
1152
1177
|
* 两道保护:① 写回的概述带 `origin` 标记(默认 `backfill-v1`),界面上看得出"这条是灌回来的";
|
|
1153
1178
|
* ② 不传 `keepUserState:false` ⇒ **不动主人留下的命中数与置顶**,回灌只补内容、不夺用户态。
|
|
@@ -1215,7 +1240,7 @@ export class MemoryStore {
|
|
|
1215
1240
|
// G4(2026-09-25):actor 记录**谁改的** —— 工具通道上线前器灵只能经 API/开库改树,
|
|
1216
1241
|
// 而旧 logBranch 无此字段,91 笔器灵改动在复盘留痕里全被显示成"主人改的"。
|
|
1217
1242
|
// 取值 'user' | 'ling'(默认 'user' 以兼容既有调用点)。
|
|
1218
|
-
const oneLine = (s, n) => String(s == null ? '' : s).replace(/[\r\n\t]+/g, ' ').trim().slice(0, n);
|
|
1243
|
+
const oneLine = (s, n) => wellFormed(String(s == null ? '' : s).replace(/[\r\n\t]+/g, ' ').trim().slice(0, n));
|
|
1219
1244
|
const who = String(actor || 'user').toLowerCase() === 'ling' ? 'ling' : 'user';
|
|
1220
1245
|
try {
|
|
1221
1246
|
this.db.prepare(`INSERT INTO branch_log (at,branch_id,action,before_val,after_val,note,actor)
|
|
@@ -1378,7 +1403,7 @@ export class MemoryStore {
|
|
|
1378
1403
|
// 换行能穿透引号、在系统提示里自造独立行(实测:note 塞 `\n\n【系统】…` 即可脱离框架)。
|
|
1379
1404
|
// 显示面修正(2026-09-22 实测):原实现对一切"查不到名字的 id"回退成「(已删枝 …)」,
|
|
1380
1405
|
// 但 branch_log 的 before/after **不只装枝 id** —— snapshot 装的是快照号(#9)、
|
|
1381
|
-
// assign 装的是记忆条目 id(026ebd86),二者都被误标成"已删枝"(
|
|
1406
|
+
// assign 装的是记忆条目 id(026ebd86),二者都被误标成"已删枝"(用户截图所见:
|
|
1382
1407
|
// `snapshot:主干 → (已删枝 #9)` 实际一根枝都没删)。先按 id 形态分流再回退查名。
|
|
1383
1408
|
const nm = (v) => {
|
|
1384
1409
|
const k = String(v || '');
|
|
@@ -1389,7 +1414,7 @@ export class MemoryStore {
|
|
|
1389
1414
|
if (/^(br|vein):/.test(k)) return '(已删枝 ' + k.slice(-8) + ')'; // 长 id 形态 ⇒ 枝
|
|
1390
1415
|
return '条目 ' + k.slice(-8); // 其余按记忆条目
|
|
1391
1416
|
};
|
|
1392
|
-
const flat = (s) => String(s == null ? '' : s).replace(/[\r\n\t]+/g, ' ').trim().slice(0, 80);
|
|
1417
|
+
const flat = (s) => wellFormed(String(s == null ? '' : s).replace(/[\r\n\t]+/g, ' ').trim().slice(0, 80));
|
|
1393
1418
|
const lines = picked.map((r) => {
|
|
1394
1419
|
const who = nm(r.branchId);
|
|
1395
1420
|
const line = (() => {
|
|
@@ -1439,7 +1464,7 @@ export class MemoryStore {
|
|
|
1439
1464
|
// G4(2026-09-25):每行带主语 —— 器灵经工具通道改的树记 'ling',
|
|
1440
1465
|
// 不再和"主人改的"混在同一批留痕里(Part G 之前 91 笔器灵改动全记在主人名下)。
|
|
1441
1466
|
// 中文动作短语(把「」…)直接接主语;英文动作名(distill:/gather:)前面补空格,免得拼成"主人distill:"。
|
|
1442
|
-
const subject = String(r.actor || 'user') === 'ling' ? '
|
|
1467
|
+
const subject = String(r.actor || 'user') === 'ling' ? '器灵' : '主人';
|
|
1443
1468
|
return flat(subject + (/^[A-Za-z0-9]/.test(line) ? ' ' : '') + line);
|
|
1444
1469
|
});
|
|
1445
1470
|
// ── §4「超量折叠成一行」(1.5.2,与双水位同批上)────────────────────────────
|
|
@@ -1495,7 +1520,7 @@ export class MemoryStore {
|
|
|
1495
1520
|
}
|
|
1496
1521
|
}
|
|
1497
1522
|
|
|
1498
|
-
/** 尚未同步给器灵的改动笔数(复盘守门,2026-09-22
|
|
1523
|
+
/** 尚未同步给器灵的改动笔数(复盘守门,2026-09-22 用户:「动记忆树不能是随意的」)。
|
|
1499
1524
|
*
|
|
1500
1525
|
* 判据:自器灵**上一次开口**(注入推进水位)以来累积了多少笔结构改动。
|
|
1501
1526
|
* 水位取 `branch_log_wm.last`(由 `inject.js` 的 advanceLogWatermark 在投递时写入),
|
|
@@ -1516,7 +1541,7 @@ export class MemoryStore {
|
|
|
1516
1541
|
let pending = 0;
|
|
1517
1542
|
let maxId = 0;
|
|
1518
1543
|
try {
|
|
1519
|
-
// 'snapshot'(保存快照)不计入待交代笔数(2026-09-25
|
|
1544
|
+
// 'snapshot'(保存快照)不计入待交代笔数(2026-09-25 用户:「单纯保存不弹修改」)。
|
|
1520
1545
|
// 依据:保存快照**不改树**——它只是把当前结构另存一份,是"大改之前的保险",
|
|
1521
1546
|
// 不是"改动"。把它算成债会让主人每存一次就看到「⚠ 有 N 笔改动还没跟器灵说过」,
|
|
1522
1547
|
// 而实际上树一个字节没变。留痕仍写进 branch_log(审计价值保留),只是不推进债。
|
|
@@ -1587,7 +1612,7 @@ export class MemoryStore {
|
|
|
1587
1612
|
if (p && n.id !== TRUNK_ID) p.children.push(n);
|
|
1588
1613
|
else roots.push(n);
|
|
1589
1614
|
}
|
|
1590
|
-
// 层级 = **计算属性**(
|
|
1615
|
+
// 层级 = **计算属性**(用户 2026-09-19 定):按相对深度切,树长高时自动重算,**零回写**。
|
|
1591
1616
|
// 于是"几条主脉并成一条更大的主脉 ⇒ 原节点自动降级"是视图效果,不需要回写任何一行记忆 ——
|
|
1592
1617
|
// 这正是"任何树操作都不改写记忆内容"这条核心不变量能成立的原因。
|
|
1593
1618
|
// 档位:0=主干(恒 1 条);(0,0.25]=主脉;≤0.62=枝;其余=叶 ⇒ 最多 1 主干 / 2 主脉 / 3 枝 / 4 叶。
|
|
@@ -1625,7 +1650,7 @@ export class MemoryStore {
|
|
|
1625
1650
|
convBranch: this.db.prepare('SELECT source, conv_id, branch_id FROM conv_branch').all(),
|
|
1626
1651
|
links: this.db.prepare('SELECT from_branch, to_branch, kind, note FROM vein_link').all(),
|
|
1627
1652
|
};
|
|
1628
|
-
// 名字留空 → 自动命名;不写「未命名」(2026-09-22
|
|
1653
|
+
// 名字留空 → 自动命名;不写「未命名」(2026-09-22 用户:手工/自动 + 日期时间即可)
|
|
1629
1654
|
const nm = String(name || '').trim() || (k === 'auto' ? '自动快照 - ' : '手动快照 - ') + localStamp();
|
|
1630
1655
|
const r = this.db.prepare('INSERT INTO tree_snapshot (at,name,note,kind,data) VALUES (?,?,?,?,?)')
|
|
1631
1656
|
.run(now, nm.slice(0, 80), String(note || '').slice(0, 300), k, JSON.stringify(data));
|
|
@@ -2009,7 +2034,7 @@ export class MemoryStore {
|
|
|
2009
2034
|
}
|
|
2010
2035
|
|
|
2011
2036
|
/** 检索侧降权映射:`"source\\u0000convId" → 系数`。
|
|
2012
|
-
* 规则(
|
|
2037
|
+
* 规则(用户 2026-09-20 定):**复盘裁定的按裁定;未复盘的以最新为准**(旧的一方降权)。
|
|
2013
2038
|
* `duplicate` 不降权(两条内容相同,降谁都会丢信息);`dismissed` 一律不降权。 */
|
|
2014
2039
|
conflictDowngradeMap() {
|
|
2015
2040
|
const m = new Map();
|
|
@@ -2055,7 +2080,7 @@ export class MemoryStore {
|
|
|
2055
2080
|
* 已归档会话 id 集合(2026-09-21,D9-b 用)。
|
|
2056
2081
|
*
|
|
2057
2082
|
* 归档 = 主人主动封存、不再参与日常召回的那批会话。矛盾扫描必须排除它们,
|
|
2058
|
-
* 否则会拿几个月前已封存的对话互相判"矛盾",把待复盘队列塞满噪音(
|
|
2083
|
+
* 否则会拿几个月前已封存的对话互相判"矛盾",把待复盘队列塞满噪音(用户实测反馈)。
|
|
2059
2084
|
* 一次取全量而非逐行查 session_meta —— scan 里要判上千条,逐行查是 O(n) 次 SQL。
|
|
2060
2085
|
*/
|
|
2061
2086
|
archivedConvIdSet() {
|
|
@@ -2130,7 +2155,7 @@ export class MemoryStore {
|
|
|
2130
2155
|
if (!s) return;
|
|
2131
2156
|
out.push({
|
|
2132
2157
|
source: String(src), convId: String(cid),
|
|
2133
|
-
title: String(title || '').slice(0, 60), summary: s.slice(0, cap),
|
|
2158
|
+
title: wellFormed(String(title || '').slice(0, 60)), summary: wellFormed(s.slice(0, cap)),
|
|
2134
2159
|
});
|
|
2135
2160
|
};
|
|
2136
2161
|
// ⚠️ A-02(1.5.1 红蓝对抗,2026-09-25):提炼素材**必须排除已遗忘的记忆**。
|
|
@@ -2164,7 +2189,7 @@ export class MemoryStore {
|
|
|
2164
2189
|
/** 记一条提炼候选(审核队列;**不**直接进记忆)。 */
|
|
2165
2190
|
addVeinSuggestion({ veinId, text, sources = [], model = '', note = '' } = {}) {
|
|
2166
2191
|
const bid = String(veinId || '');
|
|
2167
|
-
const t = String(text || '').replace(/\s+/g, ' ').trim().slice(0, 1200);
|
|
2192
|
+
const t = wellFormed(String(text || '').replace(/\s+/g, ' ').trim().slice(0, 1200));
|
|
2168
2193
|
if (!bid || !t) return { ok: false, reason: 'bad-arg' };
|
|
2169
2194
|
const now = utcIso();
|
|
2170
2195
|
try {
|
|
@@ -2230,11 +2255,11 @@ export class MemoryStore {
|
|
|
2230
2255
|
if (!body) return { ok: false, reason: 'empty-text' };
|
|
2231
2256
|
const veinId = String(row.vein_id || '');
|
|
2232
2257
|
if (!veinId) return { ok: false, reason: 'no-vein' };
|
|
2233
|
-
// ── A-01(1.5.1
|
|
2258
|
+
// ── A-01(1.5.1 红蓝对抗;用户 2026-09-25 裁决「备份并覆盖」)──────────────────
|
|
2234
2259
|
// 采纳写入的键是 `(source='vein', conv_id=<主脉 id>)` ⇒ **同一主脉的两条候选先后采纳
|
|
2235
2260
|
// 会命中同一个 ON CONFLICT,后一条静默吃掉前一条**(无留痕、无归档 —— 数据真的没了;
|
|
2236
2261
|
// 红队 A 与红队 E 各自独立复现)。
|
|
2237
|
-
//
|
|
2262
|
+
// 用户的裁决与理由:主脉记忆是一种**概括**,它理应随主脉本身一起成长 ⇒ **覆盖**是对的;
|
|
2238
2263
|
// 但覆盖必须能回头 ⇒ 动手前把旧版**归档落盘**,归档失败就**拒绝覆盖**
|
|
2239
2264
|
// (与 forget 那条"归档失败绝不打标记"同一条纪律),并把结果回传调用方去记总变动日志。
|
|
2240
2265
|
let replaced = null;
|
|
@@ -2626,6 +2651,26 @@ export class MemoryStore {
|
|
|
2626
2651
|
return { ok: true, id: String(oldId), by: String(newId) };
|
|
2627
2652
|
}
|
|
2628
2653
|
|
|
2654
|
+
/** **作废**一条结论(2026-10-15,用户拍板「允许作废一条结论」)。
|
|
2655
|
+
* 与 supersede 的分工:取代 = "有一条新的接着";作废 = "这条不成立了、没有接替者"。
|
|
2656
|
+
* 实现**不删行**(老规矩「只追不替」):只把 `superseded_by` 标成哨兵 `retracted`。
|
|
2657
|
+
* WHY 落 kv:`deep.retract:<id>` = `{reason, by, at}` —— 过一阵要回答"当初为什么不要它"时,
|
|
2658
|
+
* 答案必须在库里,而不在某次已经折叠掉的会话里。
|
|
2659
|
+
* ⚠️ 已被取代/已作废的条目**再作废一次会被拒**(`already-retired`)——
|
|
2660
|
+
* 否则后一次的 reason 会把前一次的盖掉,留痕就变成"最后一次说的算"。 */
|
|
2661
|
+
retractDeepItem(id, { reason = '', by = 'user' } = {}) {
|
|
2662
|
+
const row = this.deepItemById(id);
|
|
2663
|
+
if (!row) return { ok: false, reason: 'not-found' };
|
|
2664
|
+
const prev = String(row.superseded_by || '');
|
|
2665
|
+
if (prev !== '') return { ok: false, reason: 'already-retired', superseded_by: prev };
|
|
2666
|
+
this.db.prepare('UPDATE deep_item SET superseded_by=?, updated_at=? WHERE id=?')
|
|
2667
|
+
.run(DEEP_RETRACTED, utcIso(), String(id));
|
|
2668
|
+
try {
|
|
2669
|
+
this.kvSet('deep.retract:' + String(id), JSON.stringify({ reason: String(reason), by: String(by), at: utcIso() }));
|
|
2670
|
+
} catch { /* 留痕失败**不改变**"作废已生效"这件事 —— 表已经改了,回执不许说谎 */ }
|
|
2671
|
+
return { ok: true, id: String(id), superseded_by: DEEP_RETRACTED, reason: String(reason) };
|
|
2672
|
+
}
|
|
2673
|
+
|
|
2629
2674
|
/** 加固 +1。⚠️ **只该由"真被采用"调用** —— 注入不算(见列注释:注入就加分会形成自我强化回路)。
|
|
2630
2675
|
* 采用信号的首选 = `recall` 取过它指向的原文(那条路已在投递留痕面内)。 */
|
|
2631
2676
|
bumpDeepHit(id) {
|
|
@@ -2644,6 +2689,29 @@ export class MemoryStore {
|
|
|
2644
2689
|
return Number(this.db.prepare('SELECT COUNT(*) AS n FROM deep_item').get()?.n) || 0;
|
|
2645
2690
|
}
|
|
2646
2691
|
|
|
2692
|
+
/** 已退役(被取代或已作废)的条数 —— 与 deepItemCount 分开报:
|
|
2693
|
+
* "库里 411 条"和"现在还会被挑的是 408 条"是两件事,混着说会让人以为退役的还在参与。 */
|
|
2694
|
+
deepRetiredCount() {
|
|
2695
|
+
return Number(this.db.prepare("SELECT COUNT(*) AS n FROM deep_item WHERE superseded_by<>''").get()?.n) || 0;
|
|
2696
|
+
}
|
|
2697
|
+
|
|
2698
|
+
/** 条目行 → **对外视图**(面板、工具、HTTP 三面共用一份)。
|
|
2699
|
+
* WHY 落在这里:api.js 的 `/deep/items` 与 tools.js 的 `list` 报的是**同一种东西**,
|
|
2700
|
+
* 各写一份映射必然漂(本仓反复吃过这个亏)⇒ 单一来源钉在库这一层,两个面都调它。
|
|
2701
|
+
* 故意**不**放 `db` 内部行:`superseded_by` 这种内部列也照发(面板要能看出"它退役了"),
|
|
2702
|
+
* 但字段名统一成稳定的一套,免得前端跟着列名漂。 */
|
|
2703
|
+
deepItemView(r) {
|
|
2704
|
+
const row = r || {};
|
|
2705
|
+
return {
|
|
2706
|
+
id: String(row.id ?? ''), kind: String(row.kind ?? ''), text: String(row.text ?? ''),
|
|
2707
|
+
durability: String(row.durability ?? ''),
|
|
2708
|
+
hit_count: Number(row.hit_count) || 0, pinned: Number(row.pinned) || 0,
|
|
2709
|
+
conv_id: String(row.conv_id ?? ''), src: String(row.src ?? ''), at: String(row.at ?? ''),
|
|
2710
|
+
superseded_by: String(row.superseded_by ?? ''), branch_id: String(row.branch_id ?? ''),
|
|
2711
|
+
created_at: String(row.created_at ?? ''),
|
|
2712
|
+
};
|
|
2713
|
+
}
|
|
2714
|
+
|
|
2647
2715
|
// ---- raw turns (DSH incremental capture, passive) ----
|
|
2648
2716
|
/** 追加一条原文轮次(幂等:(session_id,seq) 冲突时更新正文)。
|
|
2649
2717
|
* v14:`source` 记**这条原文属于哪个来源**(取值口径见 RAW_SOURCE_DSH 段)。缺省 null ⇒ 按 id
|
|
@@ -2884,8 +2952,30 @@ export class MemoryStore {
|
|
|
2884
2952
|
return body;
|
|
2885
2953
|
}
|
|
2886
2954
|
|
|
2887
|
-
/** 合并(DESIGN §8 v2):概述幂等;队列/建议按内容去重追加;deep 仅在目标存在对应概述时恢复。
|
|
2955
|
+
/** 合并(DESIGN §8 v2):概述幂等;队列/建议按内容去重追加;deep 仅在目标存在对应概述时恢复。
|
|
2956
|
+
*
|
|
2957
|
+
* **1.7.1(审计 C1):整包原子化** —— 以前没有事务,包处理到一半抛异常(尤其 `overwrite=true`
|
|
2958
|
+
* 的覆盖路径)会留下"一部分已覆盖、一部分没覆盖"的半截库,而回执只报 `errors=1`。
|
|
2959
|
+
* 用 `SAVEPOINT` 而不是 `BEGIN`:外层若已有事务仍可嵌套(`BEGIN` 会抛 "cannot start a transaction")。
|
|
2960
|
+
* 口径:**逃出方法的异常** ⇒ 整包回滚(回执全 0 + `errors=1`,不谎报行数);
|
|
2961
|
+
* **单行错误**仍按旧语义只计数不回滚 —— 那是刻意保留的"坏一行不毁整包"。 */
|
|
2888
2962
|
importBundle(bundle, { overwrite = false } = {}) {
|
|
2963
|
+
const sp = 'sp_import_' + Math.random().toString(36).slice(2, 10);
|
|
2964
|
+
try {
|
|
2965
|
+
// delta 复审:SAVEPOINT 本身也可能失败(库不可事务/被占用),必须在 try 内,
|
|
2966
|
+
// 否则它会作为未归类的异常逃出方法,违背"回执永远是一张表"的契约。
|
|
2967
|
+
this.db.exec('SAVEPOINT ' + sp);
|
|
2968
|
+
const report = this._importBundleRows(bundle, { overwrite });
|
|
2969
|
+
this.db.exec('RELEASE ' + sp);
|
|
2970
|
+
return report;
|
|
2971
|
+
} catch {
|
|
2972
|
+
try { this.db.exec('ROLLBACK TO ' + sp); this.db.exec('RELEASE ' + sp); } catch { /* noop */ }
|
|
2973
|
+
return { added: 0, skipped: 0, overwritten: 0, queueAdded: 0, queueSkipped: 0, suggAdded: 0, suggSkipped: 0, deepRestored: 0, deepSkipped: 0, errors: 1 };
|
|
2974
|
+
}
|
|
2975
|
+
}
|
|
2976
|
+
|
|
2977
|
+
/** `importBundle` 的行循环(无事务;只许 `importBundle` 调)。 */
|
|
2978
|
+
_importBundleRows(bundle, { overwrite = false } = {}) {
|
|
2889
2979
|
const report = { added: 0, skipped: 0, overwritten: 0, queueAdded: 0, queueSkipped: 0, suggAdded: 0, suggSkipped: 0, deepRestored: 0, deepSkipped: 0, errors: 0 };
|
|
2890
2980
|
if (!bundle || bundle.format !== 'dsh-ling-memory') {
|
|
2891
2981
|
report.errors = 1;
|
package/lib/host/persona.js
CHANGED
|
@@ -63,8 +63,8 @@ export const DEFAULT_SETTINGS = Object.freeze({
|
|
|
63
63
|
},
|
|
64
64
|
memory: {
|
|
65
65
|
l1Enabled: true,
|
|
66
|
-
l1BudgetTokens: 720, // 1.6 步 5:1200 → 720(「瘦身到现在的 60
|
|
67
|
-
l1MaxItems: 10, // 1.6:8 → 10(
|
|
66
|
+
l1BudgetTokens: 720, // 1.6 步 5:1200 → 720(「瘦身到现在的 60%」,用户 2026-10-04 拍)
|
|
67
|
+
l1MaxItems: 10, // 1.6:8 → 10(用户 2026-10-04:「8 条也可以考虑改 10 条,其中 2 条作为最硬的事实的席位」)
|
|
68
68
|
l1MaxLineChars: 180, // 兜底硬顶:只对病态长标题生效(截断优先落在句末)
|
|
69
69
|
l1SummaryChars: 110, // 摘要段预算:第一句超此长度才硬截并标 …(2026-09-16 与用户定案)
|
|
70
70
|
categoryWeights: { // mode -> 领域类别权重(D6,先验倾向)
|
|
@@ -105,6 +105,17 @@ export const DEFAULT_SETTINGS = Object.freeze({
|
|
|
105
105
|
baseUrl: '', // 空 = 落到环境变量 DSH_LING_ASSISTANT,再落到内置默认(loopback:11434)
|
|
106
106
|
model: '', // 空 = 落到环境变量 DSH_LING_ASSISTANT_MODEL,再落到 qwen3.5:9b
|
|
107
107
|
},
|
|
108
|
+
// 后台自动行为(2026-10-16 与用户定):这些行为**一直是开着的**,只是此前既没有文档、
|
|
109
|
+
// 也没有面板开关 —— 只能靠改插件配置(cfg)或读源码才知道。现在收进 settings.auto:
|
|
110
|
+
// 面板可读写,默认值与原硬编码**逐字相同**(关掉前请听清每行说明的后果)。
|
|
111
|
+
// 三级优先级:settings.auto.<键> > 插件配置 cfg.<旧键> > 这里(解析只在 deepsummary.resolveAuto 一处)。
|
|
112
|
+
auto: {
|
|
113
|
+
deepSummary: true, // 自动深摘总闸:关掉后只有你点「深摘」时才调用模型;已经深摘过的会话不受影响
|
|
114
|
+
deepIntervalMin: 30, // 自动深摘间隔(分钟,夹到 10..1440):调小 = 记忆更新更勤、更吃模型额度;调大 = 省额度
|
|
115
|
+
deepMaxPerRun: 2, // 每轮最多深摘几个会话(夹到 1..20):调大 = 一轮更久、一次花掉更多额度
|
|
116
|
+
bootProbeDelayMs: 12000, // 启动后多久做一次 LLM 探活(毫秒,夹到 0..600000):0 = 立即探活(宿主刚起时可能失败)
|
|
117
|
+
feedbackAuto: true, // 反馈成长回路(事件驱动):关掉后点踩/点赞不再自动长出规矩与习惯提议
|
|
118
|
+
},
|
|
108
119
|
dates: [], // 自定义纪念日 [{m,d,label,greeting}](内置 9-07 新生纪念日见 clock.js)
|
|
109
120
|
});
|
|
110
121
|
|
package/lib/host/rawlog.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// dsh-ling host — 直通原文(1.6 · P2 第一优先)。
|
|
2
2
|
//
|
|
3
|
-
// 为什么要有这个模块(2026-10-04
|
|
3
|
+
// 为什么要有这个模块(2026-10-04 用户定「第一优先级就是先把读原文的这个事儿给办了」):
|
|
4
4
|
// 器灵此前**没有任何正规通道**能读会话原文 —— 7 个工具里一个都读不到,
|
|
5
5
|
// 而 memory.db 的 `dsh_turns_raw` 是**残的**(本会话实测:库里 13 条 vs 磁盘 473 条,≈2.7%)。
|
|
6
6
|
// 真全文只躺在磁盘上: ~/.dsh/sessions/<项目目录>/<会话目录>/session.v4.jsonl.zstd
|
|
@@ -320,9 +320,72 @@ export function localStamp(ms) {
|
|
|
320
320
|
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
321
321
|
}
|
|
322
322
|
|
|
323
|
-
/**
|
|
323
|
+
/** ⚠️ **孤立代理净化**(F4 修复批次,2026-10-17)。
|
|
324
|
+
*
|
|
325
|
+
* 为什么必须有它:JavaScript 字符串允许**半个代理对**存在(高位 `U+D800–DBFF` 或低位
|
|
326
|
+
* `U+DC00–DFFF` 落单),那是**非法 UTF-16** —— 而它一旦被送进请求体,上游 JSON 解析器
|
|
327
|
+
* **直接崩**(不是"显示怪",是整条请求 400)。
|
|
328
|
+
*
|
|
329
|
+
* 实测两起事故(2026-10-17 全库核实,550 会话 / 真 400 共 9 次):
|
|
330
|
+
* · `session-bc00b39b` #6331:低代理 `0xdd12` @870 —— 源自 `recall` snippet **起点**切在 emoji 中间;
|
|
331
|
+
* · `session-7ff65dd7` seq=58:高代理 `U+D83C` —— 源自 `recall` snippet **终点**切在 emoji 中间。
|
|
332
|
+
* 两例**都**触发了 400(杀伤率 100%),没有侥幸活下来的样本。
|
|
333
|
+
*
|
|
334
|
+
* ⇒ 口径:**凡是往外送(进模型上下文 / 落盘 / 进请求体)的文本,先过这里**。
|
|
335
|
+
* 落单代理一律换成 `U+FFFD`(与浏览器、流式 UTF-8 解码同款口径)。
|
|
336
|
+
* 优先用运行时自带的 `String.prototype.toWellFormed`(ES2024),没有就手写等价实现。 */
|
|
337
|
+
export function wellFormed(s) {
|
|
338
|
+
const t = String(s ?? '');
|
|
339
|
+
if (typeof t.toWellFormed === 'function') return t.toWellFormed();
|
|
340
|
+
let out = '';
|
|
341
|
+
for (let i = 0; i < t.length; i++) {
|
|
342
|
+
const c = t.charCodeAt(i);
|
|
343
|
+
if (c >= 0xd800 && c <= 0xdbff) {
|
|
344
|
+
const n = i + 1 < t.length ? t.charCodeAt(i + 1) : 0;
|
|
345
|
+
if (n >= 0xdc00 && n <= 0xdfff) { out += t[i] + t[i + 1]; i += 1; } else out += '\uFFFD';
|
|
346
|
+
} else if (c >= 0xdc00 && c <= 0xdfff) {
|
|
347
|
+
out += '\uFFFD';
|
|
348
|
+
} else {
|
|
349
|
+
out += t[i];
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return out;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** **不切断代理对**的截断(超出留 `…`)。与 `slice(0, n)` **同口径** —— `cap` 数的是
|
|
356
|
+
* **UTF-16 单元**(emoji 算 2),唯一差别是切点若落在代理对中间就**回退一格**,
|
|
357
|
+
* 绝不吐出半个字符。⇒ 代价只有"含 emoji 的文本少显示一两个字",换来的是回执永远合法。
|
|
358
|
+
* 实现刻意做成 O(1)(只回退一格,不构造码点数组)—— 它会被高频调用。 */
|
|
359
|
+
export function clipChars(s, cap) {
|
|
360
|
+
const t = wellFormed(s);
|
|
361
|
+
const k = Math.max(1, Number(cap) || 0);
|
|
362
|
+
if (t.length <= k) return t;
|
|
363
|
+
let end = k;
|
|
364
|
+
// 切点前一个单元是高代理 ⇒ 它的低代理会被丢掉 ⇒ 回退一格,把整个字符留在片段里
|
|
365
|
+
const c = t.charCodeAt(end - 1);
|
|
366
|
+
if (c >= 0xd800 && c <= 0xdbff) end -= 1;
|
|
367
|
+
return t.slice(0, end) + '…';
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** 按**码点**安全取区间(起点与终点都不切断代理对)。`recall` 的"命中位置附近截一段"用它。
|
|
371
|
+
* 起点落在低代理上 ⇒ 回退一格(宁可多带一个字符,也不留下半个 emoji)。 */
|
|
372
|
+
export function safeSlice(s, from, to) {
|
|
373
|
+
const t = wellFormed(s);
|
|
374
|
+
const n = t.length;
|
|
375
|
+
let a = Math.max(0, Math.min(Number(from) || 0, n));
|
|
376
|
+
let b = Math.max(a, Math.min(Number(to) || 0, n));
|
|
377
|
+
const ca = t.charCodeAt(a);
|
|
378
|
+
if (ca >= 0xdc00 && ca <= 0xdfff) a = Math.max(0, a - 1);
|
|
379
|
+
const cb = t.charCodeAt(b - 1);
|
|
380
|
+
if (cb >= 0xd800 && cb <= 0xdbff) b = Math.min(n, b + 1);
|
|
381
|
+
return t.slice(a, b);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** 单行化 + 截断(目录视图用)。名字不叫 flatOne:`tools.js` 里已有一个同名的(签名不同)。
|
|
385
|
+
* ⚠️ F4(2026-10-17):截断改走 `clipChars`(按码点)—— 原来直接 `slice` 会把 emoji 切成半个,
|
|
386
|
+
* 那半个代理会随工具回执进上下文、并把官方路由打成 400(见 `wellFormed` 的事故记录)。 */
|
|
324
387
|
export function oneLine(s, cap = 60) {
|
|
325
|
-
const t = String(s || '').replace(/\s+/g, ' ').trim();
|
|
388
|
+
const t = wellFormed(String(s || '').replace(/\s+/g, ' ').trim());
|
|
326
389
|
if (!t) return '';
|
|
327
|
-
return t
|
|
390
|
+
return clipChars(t, cap);
|
|
328
391
|
}
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
// (官方同款实现在 `dsh-api-session-controller\lib\index.js:1934` / `:1984`)。
|
|
23
23
|
|
|
24
24
|
import { isMemoryEligibleHeader, sessionKind } from './util.js';
|
|
25
|
-
import { localStamp, oneLine } from './rawlog.js';
|
|
25
|
+
import { localStamp, oneLine, safeSlice, wellFormed } from './rawlog.js';
|
|
26
26
|
|
|
27
27
|
/** 一页几个会话(默认):**5 条**(2026-10-05 主人拍板 B2,原为 10)。
|
|
28
28
|
* 起因:实测"搜什么都中今天" —— 全文索引里**当前会话自己**也在,任何词都能命中几十秒前刚打的那句话,
|
|
@@ -97,13 +97,29 @@ export function searchDeepItems(memory, q, limit = SEARCH_DEEP_MAX) {
|
|
|
97
97
|
const words = String(q || '').trim().split(/\s+/).map((w) => w.replace(/[%_\\]/g, '')).filter(Boolean);
|
|
98
98
|
if (!words.length) return [];
|
|
99
99
|
try {
|
|
100
|
-
|
|
100
|
+
// ⚠️ 2026-10-06:**类目名(kind)也在检索面里** —— 修前只扫 `text`,于是查询里的类目词打不中
|
|
101
|
+
// 它们自己的条目:实测搜「纪律」时那 18 条只回来 2 条(恰好正文含"纪律"二字的那些),
|
|
102
|
+
// 而**同族缺陷对"承诺 / 决定 / 关系 / 事实 / 偏好"一样成立**。
|
|
103
|
+
// 多词仍是 **AND**(每词都得在 text 或 kind 里出现其一),与宿主检索面同解,不另立口径。
|
|
104
|
+
const where = words.map(() => "(text LIKE ? ESCAPE '\\' OR kind LIKE ? ESCAPE '\\')").join(' AND ');
|
|
105
|
+
const args = [];
|
|
106
|
+
for (const w of words) { args.push(`%${w}%`, `%${w}%`); }
|
|
107
|
+
args.push(Math.max(1, Math.min(50, Number(limit) || SEARCH_DEEP_MAX)));
|
|
101
108
|
return db.prepare(
|
|
102
109
|
`SELECT id, kind, text, conv_id, src, seq_from, seq_to, pinned, hit_count FROM deep_item
|
|
103
110
|
WHERE superseded_by='' AND ${where}
|
|
104
111
|
ORDER BY pinned DESC, hit_count DESC, created_at DESC LIMIT ?`,
|
|
105
|
-
).all(...
|
|
106
|
-
} catch {
|
|
112
|
+
).all(...args);
|
|
113
|
+
} catch (e) {
|
|
114
|
+
// ── F3(1.7 修复批次,2026-10-15)─────────────────────────────────────────────
|
|
115
|
+
// 修前是 `catch { return []; }` —— 把**库读失败**(损坏 / 被占用 / SQL 错)归并成**零命中**,
|
|
116
|
+
// 调用方读到的就是"库里没有",而真实情况是"我们自己的库读不出来"。违反本项目口径
|
|
117
|
+
// 「**库不可用 ≠ 零命中**」(同文件对宿主那条路一直严格分开:`search-unavailable` / 超时文案)。
|
|
118
|
+
// 现在**只吞"表 / 列不存在"** —— 那是"这张表还没建"的正常态;其余一律往上冒,由工具层
|
|
119
|
+
// 转成 `local-unavailable`(见 tools.js 的 runSearch)。
|
|
120
|
+
if (/no such table|no such column/i.test(String(e?.message ?? e))) return [];
|
|
121
|
+
throw e;
|
|
122
|
+
}
|
|
107
123
|
}
|
|
108
124
|
|
|
109
125
|
/** 检索档本地原文层一次最多带出几轮(默认同 `SEARCH_HITS_DEFAULT`)。 */
|
|
@@ -121,6 +137,8 @@ export const SEARCH_LOCAL_TURNS_MAX = 50;
|
|
|
121
137
|
*
|
|
122
138
|
* 与 `searchDeepItems` 同解的三条(不另立口径):多词 = **AND**;词里的 `% _ \` 先剥掉;
|
|
123
139
|
* 表不存在时**静默返回空**(检索面不该因为某张表缺席而整体失败)。
|
|
140
|
+
* ⚠️ F3(1.7 修复批次)把这条**收窄**了:静默只对"**表 / 列不存在**"这一类;
|
|
141
|
+
* 库损坏 / 被占用 / SQL 错**往上冒** —— 库不可用 ≠ 零命中,两者的下一步动作完全不同。
|
|
124
142
|
*
|
|
125
143
|
* @param {object} memory MemoryStore
|
|
126
144
|
* @param {string} q 查询词(空格分隔 = 同时满足)
|
|
@@ -143,7 +161,12 @@ export function searchLocalTurns(memory, q, limit = SEARCH_HITS_DEFAULT, { selfI
|
|
|
143
161
|
`SELECT session_id, seq, role, ts, text FROM dsh_turns_raw
|
|
144
162
|
WHERE ${where} ORDER BY ts DESC LIMIT ?`,
|
|
145
163
|
).all(...words.map((w) => `%${w}%`), cap);
|
|
146
|
-
} catch
|
|
164
|
+
} catch (e) {
|
|
165
|
+
// F3(1.7 修复批次):同 `searchDeepItems` —— **只吞"表 / 列不存在"**(表缺席是正常态),
|
|
166
|
+
// 库损坏 / 被占用 / SQL 错必须往上冒(往上冒才会被渲染成"库读不出来",而不是"没找到")。
|
|
167
|
+
if (/no such table|no such column/i.test(String(e?.message ?? e))) return { rows: [], selfExcluded: 0 };
|
|
168
|
+
throw e;
|
|
169
|
+
}
|
|
147
170
|
const self = String(selfId || '');
|
|
148
171
|
const mapped = rows.map((r) => {
|
|
149
172
|
const id = String(r.session_id ?? '');
|
|
@@ -156,8 +179,11 @@ export function searchLocalTurns(memory, q, limit = SEARCH_HITS_DEFAULT, { selfI
|
|
|
156
179
|
return {
|
|
157
180
|
conv: id, seq: Number(r.seq) || 0, role,
|
|
158
181
|
time: r.ts ? localStamp(r.ts) : '',
|
|
159
|
-
who: isUser ? callName : '
|
|
160
|
-
|
|
182
|
+
who: isUser ? callName : '器灵',
|
|
183
|
+
// ⚠️ F4(2026-10-17):原来是 `text.slice(from, from + N)` —— 起点或终点落在 emoji 中间时
|
|
184
|
+
// 会切出**半个代理对**,而那半个代理会随回执进上下文、把官方路由打成 400。
|
|
185
|
+
// 实测 `session-bc00b39b` #6331 的低代理 `0xdd12` 正是起点切坏而来(见 rawlog.wellFormed)。
|
|
186
|
+
snippet: oneLine(safeSlice(text, from, from + SEARCH_SNIPPET_CHARS), SEARCH_SNIPPET_CHARS),
|
|
161
187
|
isSelf: Boolean(self) && id === self,
|
|
162
188
|
};
|
|
163
189
|
});
|
|
@@ -239,7 +265,7 @@ export function toSearchRows(hits, { visible = null, all = false, callName = '
|
|
|
239
265
|
cwd: header.cwd ? String(header.cwd) : '',
|
|
240
266
|
seq: m.seq ?? null,
|
|
241
267
|
time: m.time ? localStamp(m.time) : '',
|
|
242
|
-
who: isUser ? callName : '
|
|
268
|
+
who: isUser ? callName : '器灵',
|
|
243
269
|
snippet: oneLine(m.snippet || '', SEARCH_SNIPPET_CHARS),
|
|
244
270
|
isSelf: Boolean(self) && String(id) === self,
|
|
245
271
|
});
|
|
@@ -258,6 +284,14 @@ export function toSearchRows(hits, { visible = null, all = false, callName = '
|
|
|
258
284
|
|
|
259
285
|
/** 检索档的人读回执(纯函数,模型可见)。 */
|
|
260
286
|
export function renderSearch(v) {
|
|
287
|
+
// ⚠️ F4(2026-10-17):**出口兜底净化**。snippet 有三个来源(宿主 `bestMatch` / 库内原文层 /
|
|
288
|
+
// `rawlog` 目录),逐处修容易漏一处;这里对**整段回执**做一次 —— 任何落单代理(半个 emoji)
|
|
289
|
+
// 都换成 `U+FFFD`,保证"进模型上下文的文本"永远是合法 UTF-16(见 `rawlog.wellFormed` 的事故记录)。
|
|
290
|
+
return wellFormed(renderSearchText(v));
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** 回执正文(不做净化)—— 出口在 `renderSearch`。 */
|
|
294
|
+
function renderSearchText(v) {
|
|
261
295
|
if (!v || v.ok !== true) {
|
|
262
296
|
// ⚠️ 兜底必须在**构造字符串之前** —— 表里的模板是立即求值的,`v` 为 null 时
|
|
263
297
|
// `v.message` 会在构造这一步就抛。这不是防御性编程:工具回执抛异常 = 模型那一步直接失败,
|
|
@@ -269,15 +303,29 @@ export function renderSearch(v) {
|
|
|
269
303
|
+ ' 这不是"库里没有",是**通道没接上** —— 按内容找会话这条路暂时走不通,'
|
|
270
304
|
+ '可改用**不带参数**的 recall() 按时间翻清单。',
|
|
271
305
|
'search-failed': `检索报错,这次没搜成:${msg}。换个更短的词再试(中文 2~4 字命中最好)。`,
|
|
306
|
+
// F3(1.7 修复批次):**新开**一个理由词,不复用 `search-unavailable` ——
|
|
307
|
+
// 那句写的是"**宿主**检索面不可用",而这一条是"**我们自己的库**读不出来",
|
|
308
|
+
// 套用就是把假话换了个地方说(两条路该采取的动作也不同:前者换通道,后者查库/看占用)。
|
|
309
|
+
'local-unavailable':
|
|
310
|
+
`本机库读不出来,这次没搜成。原因:${msg}`
|
|
311
|
+
+ ' **这不是"库里没有",是我们自己的库读不出来** —— 条目层与原文层都在这个库里,'
|
|
312
|
+
+ '这次两半都没拿到(所以别据此断定"没发生过")。'
|
|
313
|
+
+ '可先改用**不带参数**的 recall() 按时间翻清单;若反复如此,'
|
|
314
|
+
+ '多半是库文件损坏或被别的进程占着(库路径见启动日志的 dataDir)。',
|
|
272
315
|
}[v?.reason];
|
|
273
316
|
return why || `未搜到内容(${v?.reason || '未知'})。`;
|
|
274
317
|
}
|
|
275
318
|
const scope = v.all
|
|
276
319
|
? '**未限定范围**(all=true —— 含子代理等非主人会话)'
|
|
277
|
-
: (v.
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
320
|
+
: (v.localScope
|
|
321
|
+
// ⚠️ 本地路**没有可见集过滤**(2026-10-06 外部文档核对抓到):它直查 `dsh_turns_raw` ——
|
|
322
|
+
// 那是**全量**(实测 11,296 行 / 1,647 会话,其中 `source='dsweb'` 的 1,523 个网页端会话、
|
|
323
|
+
// 未标来源的 104 个)⇒ **不许印"已在主人自己的会话内"**,那是说假话。
|
|
324
|
+
? '⚠️ **本机库全量**(这条路不过 `listSessions` 可见集:含网页端导入的会话与未标来源的会话)'
|
|
325
|
+
: (v.degraded
|
|
326
|
+
? '⚠️ **没能限定范围** —— 宿主这次没给 `listSessions`,所以下面这份结果**可能含子代理或其它会话**,'
|
|
327
|
+
+ '不是"只搜主人自己的会话"(范围限制这条通道暂时失效,不是"库里只有这些")'
|
|
328
|
+
: '已在「**主人自己的会话**」内(默认范围)'));
|
|
281
329
|
// ⚠️ **原文来源必须看得出来是哪一个**(2026-10-05 夜 · 主人拍板方案 A):
|
|
282
330
|
// 两条路覆盖面**不一样**,不说清就会被读成"库里只有这些"。
|
|
283
331
|
// · 本地(默认):库内原文层 —— 只含"真人消息 + 有正文的回复"(capture 侧就这么收的),
|
package/lib/host/retitle.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
//
|
|
3
3
|
// 标题是记忆的"名字"。旧行为把首条用户消息硬截 46 字(见 summarizer.js TITLE_MAX),
|
|
4
4
|
// 于是 60 条 dsh 概述里 26 条是半句话:
|
|
5
|
-
//
|
|
5
|
+
// 「器灵,接下来在这里继续开发生长型人格与记忆树模块,继续走你的新生之路。 我刚刚对1.2.1的」
|
|
6
6
|
// 「刚才我为本虚拟机安装解压压缩软件<360压缩>,由于操作过快,被捆绑了360本体,随后我在物」
|
|
7
7
|
// 而 dsweb 侧标题干净(「CPU单核性能排行榜」),因为它用的是源库自带标题、不做首句截断。
|
|
8
8
|
//
|
|
@@ -29,13 +29,13 @@ export const TITLE_MATERIAL_CHARS = 1400;
|
|
|
29
29
|
export const TITLE_SYS = '你在给一段对话起名字。只输出标题本身:不超过 20 个字,写清这段对话在做什么或结论是什么;'
|
|
30
30
|
+ '不要引号、不要书名号、不要结尾标点、不要解释、不要分点、不要写"对话"二字。';
|
|
31
31
|
|
|
32
|
-
// 称呼与问候:中文主人对器灵的日常开场(
|
|
33
|
-
const ADDRESS_RE = /^(
|
|
32
|
+
// 称呼与问候:中文主人对器灵的日常开场(「晚上好器灵,我小加班了一会,回来了」)
|
|
33
|
+
const ADDRESS_RE = /^(器灵|助手|宝贝|亲爱的)[,,、::~~!!。\s]*/;
|
|
34
34
|
const GREETING_RE = /^(早上好|中午好|下午好|晚上好|晚安|你好呀|你好|您好|哈喽|嗨|早|hello|hi|yo)[啊呀呢,,、!!。.~\s]*/i;
|
|
35
35
|
// 无信息量首句(问候、寒暄、测试口令)
|
|
36
36
|
const VAGUE_RE = /^(嗯+|哦+|好+|在吗|在不在|\S{0,4}测试\S*|回复数字\s*\d+|\d+|ok|test)$/i;
|
|
37
37
|
|
|
38
|
-
/** 反复剥离"问候 + 称呼"
|
|
38
|
+
/** 反复剥离"问候 + 称呼"前缀:「晚上好器灵,我小加班了一会,回来了」→「我小加班了一会,回来了」。 */
|
|
39
39
|
export function stripPreamble(text) {
|
|
40
40
|
let s = String(text || '').replace(/\s+/g, ' ').trim();
|
|
41
41
|
for (let i = 0; i < 3; i += 1) {
|
|
@@ -214,7 +214,7 @@ export function retitleCandidates(memory, { source = '', limit = 200 } = {}) {
|
|
|
214
214
|
if (!looksLikeSessionId(r.conv_id)) return false; // 合成行(user:principle-…)不是会话
|
|
215
215
|
if (!t) return true;
|
|
216
216
|
if (t.length >= LEGACY_TITLE_MAX) return true; // 被旧上限(TITLE_MAX=46)切过的
|
|
217
|
-
if (ADDRESS_RE.test(t) || GREETING_RE.test(t)) return true; //
|
|
217
|
+
if (ADDRESS_RE.test(t) || GREETING_RE.test(t)) return true; // 还带着「器灵,」「晚上好器灵,」= 旧启发式产物
|
|
218
218
|
return isVagueTitle(t); // 「你好」「在吗」
|
|
219
219
|
}).map((r) => ({ source: String(r.source), conv_id: String(r.conv_id), title: String(r.title || ''), summary: String(r.summary || '') }));
|
|
220
220
|
}
|