@nightpainters/dsh-ling 1.7.0 → 1.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +82 -5
- package/README.md +12 -8
- package/lib/client.js +24 -6
- package/lib/host/archive.js +4 -4
- package/lib/host/deep-index-skill.js +3 -3
- package/lib/host/deepsummary.js +5 -2
- package/lib/host/dsweb-summary.js +5 -2
- package/lib/host/genesis.js +5 -3
- package/lib/host/inject.js +6 -3
- package/lib/host/l1.js +4 -3
- package/lib/host/memory.js +47 -11
- package/lib/host/rawlog.js +160 -3
- package/lib/host/recall-search.js +91 -27
- package/lib/host/retitle.js +5 -5
- package/lib/host/session-line.js +7 -2
- package/lib/host/snapshot.js +7 -4
- package/lib/host/tools.js +62 -20
- package/package.json +2 -2
package/lib/host/memory.js
CHANGED
|
@@ -5,6 +5,7 @@ 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';
|
|
@@ -14,7 +15,7 @@ const SCHEMA_VERSION = 17;
|
|
|
14
15
|
* 为什么用哨兵而不是新列:所有"排除已被取代"的读路径(开场选择 / recall / 面板)
|
|
15
16
|
* 判的都是 `superseded_by != ''` —— 哨兵让**那些路径一处都不用改**就自动不再挑它;
|
|
16
17
|
* 而"为什么作废"落在 kv `deep.retract:<id>`(人可读、可审计),不污染表语义。 */
|
|
17
|
-
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 器灵侧通道 —— 工具改的树必须记在"
|
|
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:提炼必须走审核,不直接进记忆);
|
|
18
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
|
|
19
20
|
|
|
20
21
|
/** 主干枝 id(D9-a 记忆分枝,2026-09-18):存量会话与记忆天然属于主干。 */
|
|
@@ -1003,10 +1004,23 @@ export class MemoryStore {
|
|
|
1003
1004
|
// (主键是有序对,旧写入侧没挡反向)⇒ 只删一个方向会让回执说"断开了"而树里还连着。
|
|
1004
1005
|
const a = String(fromBranch || '');
|
|
1005
1006
|
const b = String(toBranch || '');
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
)
|
|
1009
|
-
|
|
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 };
|
|
1010
1024
|
} catch {
|
|
1011
1025
|
return { ok: false, reason: 'db' };
|
|
1012
1026
|
}
|
|
@@ -1226,7 +1240,7 @@ export class MemoryStore {
|
|
|
1226
1240
|
// G4(2026-09-25):actor 记录**谁改的** —— 工具通道上线前器灵只能经 API/开库改树,
|
|
1227
1241
|
// 而旧 logBranch 无此字段,91 笔器灵改动在复盘留痕里全被显示成"主人改的"。
|
|
1228
1242
|
// 取值 'user' | 'ling'(默认 'user' 以兼容既有调用点)。
|
|
1229
|
-
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));
|
|
1230
1244
|
const who = String(actor || 'user').toLowerCase() === 'ling' ? 'ling' : 'user';
|
|
1231
1245
|
try {
|
|
1232
1246
|
this.db.prepare(`INSERT INTO branch_log (at,branch_id,action,before_val,after_val,note,actor)
|
|
@@ -1400,7 +1414,7 @@ export class MemoryStore {
|
|
|
1400
1414
|
if (/^(br|vein):/.test(k)) return '(已删枝 ' + k.slice(-8) + ')'; // 长 id 形态 ⇒ 枝
|
|
1401
1415
|
return '条目 ' + k.slice(-8); // 其余按记忆条目
|
|
1402
1416
|
};
|
|
1403
|
-
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));
|
|
1404
1418
|
const lines = picked.map((r) => {
|
|
1405
1419
|
const who = nm(r.branchId);
|
|
1406
1420
|
const line = (() => {
|
|
@@ -1450,7 +1464,7 @@ export class MemoryStore {
|
|
|
1450
1464
|
// G4(2026-09-25):每行带主语 —— 器灵经工具通道改的树记 'ling',
|
|
1451
1465
|
// 不再和"主人改的"混在同一批留痕里(Part G 之前 91 笔器灵改动全记在主人名下)。
|
|
1452
1466
|
// 中文动作短语(把「」…)直接接主语;英文动作名(distill:/gather:)前面补空格,免得拼成"主人distill:"。
|
|
1453
|
-
const subject = String(r.actor || 'user') === 'ling' ? '
|
|
1467
|
+
const subject = String(r.actor || 'user') === 'ling' ? '器灵' : '主人';
|
|
1454
1468
|
return flat(subject + (/^[A-Za-z0-9]/.test(line) ? ' ' : '') + line);
|
|
1455
1469
|
});
|
|
1456
1470
|
// ── §4「超量折叠成一行」(1.5.2,与双水位同批上)────────────────────────────
|
|
@@ -2141,7 +2155,7 @@ export class MemoryStore {
|
|
|
2141
2155
|
if (!s) return;
|
|
2142
2156
|
out.push({
|
|
2143
2157
|
source: String(src), convId: String(cid),
|
|
2144
|
-
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)),
|
|
2145
2159
|
});
|
|
2146
2160
|
};
|
|
2147
2161
|
// ⚠️ A-02(1.5.1 红蓝对抗,2026-09-25):提炼素材**必须排除已遗忘的记忆**。
|
|
@@ -2175,7 +2189,7 @@ export class MemoryStore {
|
|
|
2175
2189
|
/** 记一条提炼候选(审核队列;**不**直接进记忆)。 */
|
|
2176
2190
|
addVeinSuggestion({ veinId, text, sources = [], model = '', note = '' } = {}) {
|
|
2177
2191
|
const bid = String(veinId || '');
|
|
2178
|
-
const t = String(text || '').replace(/\s+/g, ' ').trim().slice(0, 1200);
|
|
2192
|
+
const t = wellFormed(String(text || '').replace(/\s+/g, ' ').trim().slice(0, 1200));
|
|
2179
2193
|
if (!bid || !t) return { ok: false, reason: 'bad-arg' };
|
|
2180
2194
|
const now = utcIso();
|
|
2181
2195
|
try {
|
|
@@ -2938,8 +2952,30 @@ export class MemoryStore {
|
|
|
2938
2952
|
return body;
|
|
2939
2953
|
}
|
|
2940
2954
|
|
|
2941
|
-
/** 合并(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
|
+
* **单行错误**仍按旧语义只计数不回滚 —— 那是刻意保留的"坏一行不毁整包"。 */
|
|
2942
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 } = {}) {
|
|
2943
2979
|
const report = { added: 0, skipped: 0, overwritten: 0, queueAdded: 0, queueSkipped: 0, suggAdded: 0, suggSkipped: 0, deepRestored: 0, deepSkipped: 0, errors: 0 };
|
|
2944
2980
|
if (!bundle || bundle.format !== 'dsh-ling-memory') {
|
|
2945
2981
|
report.errors = 1;
|
package/lib/host/rawlog.js
CHANGED
|
@@ -320,9 +320,166 @@ 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);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ══ 1.7.2 · recall 回执降噪(2026-10-23)══════════════════════════════════════════
|
|
394
|
+
// 三个纯函数,对应交接单的 M1 / M3 / M6。**放在文本出口这一层,不塞进渲染器** ——
|
|
395
|
+
// 回执有四个出口(检索档、目录档、原文档、错误档),每个各写一份就会各错一处
|
|
396
|
+
// (1.7 的 F-01 正是"修了三处、漏了两处"的现场)。
|
|
397
|
+
|
|
398
|
+
/** 删掉 `token` **最后出现**的那一处(只用于末尾未闭合的标记,不重排正文)。 */
|
|
399
|
+
function cutLastToken(t, token) {
|
|
400
|
+
const i = t.lastIndexOf(token);
|
|
401
|
+
if (i < 0) return t;
|
|
402
|
+
return t.slice(0, i) + t.slice(i + token.length);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** **M1 · 截断后清理未闭合标记**(不补闭合,只剥掉末尾那个)。
|
|
406
|
+
*
|
|
407
|
+
* 现场:实测 51 条回执平均 1,211 字符,`**`/`` ` `` 约占 3.5%;而截断常常落在标记中间,
|
|
408
|
+
* 于是回执末尾留一个孤零零的 `**` 或 `` ` `` —— 模型读到的是一段**语法已坏**的文本,
|
|
409
|
+
* 却看不出这是截断造成的(它会以为正文里本来就有个星号)。
|
|
410
|
+
*
|
|
411
|
+
* 口径三条:
|
|
412
|
+
* ① 只数 `**` / `` ` `` / `[` / `【` 的**奇偶**;奇数 ⇒ 最后一个没闭合 ⇒ 剥掉它
|
|
413
|
+
* (`【` 是回执自己的大纲符号,清单 §3 M1 明确要求一起数);
|
|
414
|
+
* ② **不补**闭合标记 —— 补会凭空造字(补在句中断句处,读起来更假);
|
|
415
|
+
* ③ 只动**末尾那一处**,中间成对的标记是回执自己的格式,一个字不动。 */
|
|
416
|
+
export function stripDanglingMarkers(s) {
|
|
417
|
+
let t = String(s ?? '');
|
|
418
|
+
for (let round = 0; round < 4; round += 1) {
|
|
419
|
+
const before = t;
|
|
420
|
+
// ⚠️ 代码围栏 ` ``` ` 必须**整体**算一个标记(2026-10-23 夜 · 对抗审计 A 抓到):
|
|
421
|
+
// 围栏是 3 个反引号,按"单个反引号"的奇偶去算会把它拆成 2+1 —— 于是截断后的围栏
|
|
422
|
+
// 会剩两个反引号(` ``js`)、甚至反过来把前文成对的行内代码当成悬空的那个。
|
|
423
|
+
// 先整段剥围栏,再数单个反引号,两个口径就不会互相污染。
|
|
424
|
+
if ((t.split('```').length - 1) % 2 === 1) t = cutLastToken(t, '```');
|
|
425
|
+
if ((t.split('**').length - 1) % 2 === 1) t = cutLastToken(t, '**');
|
|
426
|
+
if ((t.split('`').length - 1) % 2 === 1) t = cutLastToken(t, '`');
|
|
427
|
+
const open = (t.match(/\[/g) || []).length;
|
|
428
|
+
const close = (t.match(/]/g) || []).length;
|
|
429
|
+
if (open > close) t = cutLastToken(t, '[');
|
|
430
|
+
const openCjk = (t.match(/【/g) || []).length;
|
|
431
|
+
const closeCjk = (t.match(/】/g) || []).length;
|
|
432
|
+
if (openCjk > closeCjk) t = cutLastToken(t, '【');
|
|
433
|
+
if (t === before) break;
|
|
434
|
+
}
|
|
435
|
+
return t;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** **M3 · 只剥 snippet 里的成对标记**(选项 C:回执自身格式保留)。
|
|
439
|
+
*
|
|
440
|
+
* 为什么只剥"成对"的:`**粗体**` 在 GUI 里有意义,在上下文里只是两个字符;
|
|
441
|
+
* 但**没配对的**那个是截断残渣,归 M1 管 —— 两件事分开做,才各自可测。
|
|
442
|
+
* 支持三种成对形态:粗体 `**x**` · 行内代码 `` `x` `` · 行内链接 `[x](url)`(留文字丢地址)。 */
|
|
443
|
+
export function stripPairedMarkdown(s) {
|
|
444
|
+
let t = String(s ?? '');
|
|
445
|
+
for (let round = 0; round < 3; round += 1) {
|
|
446
|
+
const before = t;
|
|
447
|
+
t = t
|
|
448
|
+
.replace(/\*\*([^*\n]+)\*\*/g, '$1')
|
|
449
|
+
.replace(/`([^`\n]+)`/g, '$1')
|
|
450
|
+
.replace(/\[([^\]\n]+)\]\(([^)\n]+)\)/g, '$1');
|
|
451
|
+
if (t === before) break;
|
|
452
|
+
}
|
|
453
|
+
return t;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** **M6 · 回执总字数上限**(默认 4,000)。
|
|
457
|
+
*
|
|
458
|
+
* 超了**截断并报出还剩多少** —— 绝不静默截断(那会让"没显示"读成"没有")。
|
|
459
|
+
* 返回 `{ text, truncated, remaining }`;`remaining` 是被切掉的**字符数**(UTF-16 单元),
|
|
460
|
+
* 报给模型的是"还有多少没看到",不是"还有多少轮"(轮数是 read 档的 `remaining`,别混)。
|
|
461
|
+
* ⚠️ 切点走 `clipChars`(不切半个 emoji) —— 与 F4 同一道防线。
|
|
462
|
+
*
|
|
463
|
+
* ⚠️⚠️ `keepTail`(2026-10-23 夜 · 对抗审计抓到后加的):**末尾的语义警告必须保住**。
|
|
464
|
+
* 回执的"本会话被排除 N 轮 / 被范围挡掉 N 条 / 还有更多 / 库里另有 N 条只有概述"全都拼在**末尾**
|
|
465
|
+
* —— 直接按字数截断会把它们连正文一起切掉,于是"排除了 7 轮"变成"没有这回事"。
|
|
466
|
+
* 实测(审计员给的数):search 40 条长片段时三者**全部消失**,布尔 self/scope/more 均 false。
|
|
467
|
+
* ⇒ 口径:截断只截**正文**,`keepTail` 那段**原样保留**,并从预算里先扣掉它的长度。 */
|
|
468
|
+
export const RECEIPT_CHARS_MAX = 4000;
|
|
469
|
+
export function capReceiptText(s, cap = RECEIPT_CHARS_MAX, keepTail = '') {
|
|
470
|
+
const t = String(s ?? '');
|
|
471
|
+
const tail = String(keepTail ?? '');
|
|
472
|
+
const k = Math.max(400, Number(cap) || RECEIPT_CHARS_MAX);
|
|
473
|
+
if (t.length <= k) return { text: t, truncated: false, remaining: 0 };
|
|
474
|
+
const budget = Math.max(200, k - tail.length);
|
|
475
|
+
const head = clipChars(t, budget);
|
|
476
|
+
const shown = Math.max(0, head.length - 1); // 末尾那个 '…' 不算正文
|
|
477
|
+
const remaining = Math.max(0, t.length - shown - tail.length);
|
|
478
|
+
return {
|
|
479
|
+
text: head
|
|
480
|
+
+ `\n\n…(回执到 ${k} 字上限,**还有 ${remaining} 字没显示** —— 缩小查询词,或分次取(原文档用 from/to,清单档用更小的 limit)。)`
|
|
481
|
+
+ tail,
|
|
482
|
+
truncated: true,
|
|
483
|
+
remaining,
|
|
484
|
+
};
|
|
328
485
|
}
|
|
@@ -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, stripPairedMarkdown, stripDanglingMarkers, capReceiptText, RECEIPT_CHARS_MAX } from './rawlog.js';
|
|
26
26
|
|
|
27
27
|
/** 一页几个会话(默认):**5 条**(2026-10-05 主人拍板 B2,原为 10)。
|
|
28
28
|
* 起因:实测"搜什么都中今天" —— 全文索引里**当前会话自己**也在,任何词都能命中几十秒前刚打的那句话,
|
|
@@ -179,8 +179,13 @@ export function searchLocalTurns(memory, q, limit = SEARCH_HITS_DEFAULT, { selfI
|
|
|
179
179
|
return {
|
|
180
180
|
conv: id, seq: Number(r.seq) || 0, role,
|
|
181
181
|
time: r.ts ? localStamp(r.ts) : '',
|
|
182
|
-
who: isUser ? callName : '
|
|
183
|
-
|
|
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
|
+
// M3(1.7.2):snippet 是**别人的原文**,可能带 Markdown —— 只剥成对标记(选项 C);
|
|
187
|
+
// 回执自己的格式由模板负责,不在这里动。M1 再兜住截断留下的未闭合残渣。
|
|
188
|
+
snippet: stripDanglingMarkers(stripPairedMarkdown(oneLine(safeSlice(text, from, from + SEARCH_SNIPPET_CHARS), SEARCH_SNIPPET_CHARS))),
|
|
184
189
|
isSelf: Boolean(self) && id === self,
|
|
185
190
|
};
|
|
186
191
|
});
|
|
@@ -262,8 +267,8 @@ export function toSearchRows(hits, { visible = null, all = false, callName = '
|
|
|
262
267
|
cwd: header.cwd ? String(header.cwd) : '',
|
|
263
268
|
seq: m.seq ?? null,
|
|
264
269
|
time: m.time ? localStamp(m.time) : '',
|
|
265
|
-
who: isUser ? callName : '
|
|
266
|
-
snippet: oneLine(m.snippet || '', SEARCH_SNIPPET_CHARS),
|
|
270
|
+
who: isUser ? callName : '器灵',
|
|
271
|
+
snippet: stripDanglingMarkers(stripPairedMarkdown(oneLine(m.snippet || '', SEARCH_SNIPPET_CHARS))),
|
|
267
272
|
isSelf: Boolean(self) && String(id) === self,
|
|
268
273
|
});
|
|
269
274
|
}
|
|
@@ -279,8 +284,75 @@ export function toSearchRows(hits, { visible = null, all = false, callName = '
|
|
|
279
284
|
return { rows, skipped, degraded: !all && !visible };
|
|
280
285
|
}
|
|
281
286
|
|
|
287
|
+
/** **M5(1.7.2) · 深层库条目去重 + 同类折叠**。
|
|
288
|
+
*
|
|
289
|
+
* 现场(修改清单 §3 M5):样本里 4 条深层库条目全是「记忆树」相关事实、来自两个会话 ——
|
|
290
|
+
* 同一类目一次刷一屏,把**真正不同的**条目挤下去。
|
|
291
|
+
* 两条口径(**清单给的"或"两条都做,都不静默丢弃**):
|
|
292
|
+
* ① 完全重复(`conv_id` + `text` 都相同)只留第一条;
|
|
293
|
+
* ② 同一 `kind` 超过 `perKind`(默认 3)条时,前 `perKind` 条照常给,
|
|
294
|
+
* 其余**折叠成一行并报出条数** —— "没显示"不许读成"没有"。
|
|
295
|
+
* ⚠️ 不按 `conv_id` 把条目合并成一条:那会丢掉"这是两件事"这个信息(清单也把它列为风险)。
|
|
296
|
+
* 同源只做**去重**(①),不做语义合并。 */
|
|
297
|
+
export const DEEP_ROWS_PER_KIND = 3;
|
|
298
|
+
export function groupDeepRows(rows, perKind = DEEP_ROWS_PER_KIND) {
|
|
299
|
+
const keep = [];
|
|
300
|
+
const seen = new Set();
|
|
301
|
+
const byKind = new Map();
|
|
302
|
+
for (const d of Array.isArray(rows) ? rows : []) {
|
|
303
|
+
const key = `${d?.conv_id ?? ''}\u0000${d?.text ?? ''}`;
|
|
304
|
+
if (seen.has(key)) continue;
|
|
305
|
+
seen.add(key);
|
|
306
|
+
const kind = String(d?.kind || '未分类');
|
|
307
|
+
const n = byKind.get(kind) || 0;
|
|
308
|
+
byKind.set(kind, n + 1);
|
|
309
|
+
if (n < perKind) keep.push(d);
|
|
310
|
+
}
|
|
311
|
+
const folded = [];
|
|
312
|
+
for (const [kind, total] of byKind) {
|
|
313
|
+
if (total > perKind) {
|
|
314
|
+
folded.push(`· …[${kind}] **另有 ${total - perKind} 条未展开**(同类目超 ${perKind} 条时折叠;要全看请缩小查询词)…`);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return { rows: keep, folded };
|
|
318
|
+
}
|
|
319
|
+
|
|
282
320
|
/** 检索档的人读回执(纯函数,模型可见)。 */
|
|
283
321
|
export function renderSearch(v) {
|
|
322
|
+
// ⚠️ F4(2026-10-17):**出口兜底净化**。snippet 有三个来源(宿主 `bestMatch` / 库内原文层 /
|
|
323
|
+
// `rawlog` 目录),逐处修容易漏一处;这里对**整段回执**做一次 —— 任何落单代理(半个 emoji)
|
|
324
|
+
// 都换成 `U+FFFD`,保证"进模型上下文的文本"永远是合法 UTF-16(见 `rawlog.wellFormed` 的事故记录)。
|
|
325
|
+
// M1/M6(1.7.2):先按 4,000 字上限截断(超了**报出还剩多少**),再剥末尾未闭合标记 ——
|
|
326
|
+
// 顺序不能反:截断本身会造出新的未闭合标记。
|
|
327
|
+
// ⚠️ 1.7.2 自查抓到的真缺陷:**search 档会被套两次上限**(renderSearch 一次 + renderRecall 一次)
|
|
328
|
+
const full = renderSearchText(v);
|
|
329
|
+
// ⚠️ 2026-10-23 夜(对抗审计 B 抓到):**末尾警告要保住** —— selfExcluded / skipped / hasMore
|
|
330
|
+
// 原先都会被 4,000 字上限连正文一起切掉,于是"排除了 N 轮"变成"没有这回事"。
|
|
331
|
+
// 只有真有命中(rows/deep 非空)时才走这条;零命中/超时/失败的回执本来就短。
|
|
332
|
+
const hasHits = (Array.isArray(v?.rows) && v.rows.length > 0) || (Array.isArray(v?.deep) && v.deep.length > 0);
|
|
333
|
+
const tail = hasHits ? searchTailNotes(v) : '';
|
|
334
|
+
const capped = capReceiptText(full, RECEIPT_CHARS_MAX, tail);
|
|
335
|
+
return wellFormed(stripDanglingMarkers(capped.text));
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** 回执**末尾的语义警告**(与 `renderSearchText` 末尾逐字同源 —— 两处必须一起改)。
|
|
339
|
+
* 之所以抽出来:上限截断时要把这段**整段保住**(见 `capReceiptText` 的 `keepTail`)。 */
|
|
340
|
+
function searchTailNotes(v) {
|
|
341
|
+
const timeoutNote = v.timedOut
|
|
342
|
+
? `\n\n⚠️ **原文层超时**(等满 ${Math.round((Number(v.timeoutMs) || SEARCH_TIMEOUT_MS) / 1000)} 秒没返回,多半是宿主在补索引对账):`
|
|
343
|
+
+ ((Array.isArray(v.deep) && v.deep.length) ? '**条目层是本地库,不受影响,就在上面** —— 先把它们用起来。' : '这次连条目也没命中。')
|
|
344
|
+
+ '要原文就**重发同一条**(多数第二次会快,对账已经补过了),或换更窄的词。'
|
|
345
|
+
: '';
|
|
346
|
+
const selfNote = Number(v.selfExcluded) > 0
|
|
347
|
+
? `\n(另有 ${v.selfExcluded} 轮命中属于**当前会话自己**,已按默认排除 —— 要看自己刚说的传 \`includeSelf=true\`。)`
|
|
348
|
+
: '';
|
|
349
|
+
return timeoutNote + selfNote
|
|
350
|
+
+ (v.skipped ? `\n(${v.skipped} 条被范围挡掉 —— 要看它们得显式传 all=true。)` : '')
|
|
351
|
+
+ (v.hasMore ? '\n(这一页满了,**还有更多** —— 把 limit 调大,或换个更窄的词。)' : '');
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** 回执正文(不做净化)—— 出口在 `renderSearch`。 */
|
|
355
|
+
function renderSearchText(v) {
|
|
284
356
|
if (!v || v.ok !== true) {
|
|
285
357
|
// ⚠️ 兜底必须在**构造字符串之前** —— 表里的模板是立即求值的,`v` 为 null 时
|
|
286
358
|
// `v.message` 会在构造这一步就抛。这不是防御性编程:工具回执抛异常 = 模型那一步直接失败,
|
|
@@ -325,17 +397,21 @@ export function renderSearch(v) {
|
|
|
325
397
|
: '';
|
|
326
398
|
const hitRows = Array.isArray(v.rows) ? v.rows : [];
|
|
327
399
|
const deepRows = Array.isArray(v.deep) ? v.deep : [];
|
|
400
|
+
// M5(1.7.2):条目去重 + 同类目超 3 条折叠成汇总行(见 groupDeepRows)。
|
|
401
|
+
const grouped = groupDeepRows(deepRows);
|
|
328
402
|
// 条目排在前:它是**结论层**(一句话说完),会话在后(要下钻才知道内容)。
|
|
329
403
|
const deepBlock = deepRows.length
|
|
330
|
-
? '【深层库条目 · 结论层(从他的会话里提炼出来的,可直接用)】\n' +
|
|
404
|
+
? '【深层库条目 · 结论层(从他的会话里提炼出来的,可直接用)】\n' + grouped.rows.map((d) => {
|
|
331
405
|
const pin = Number(d.pinned) ? '📌 ' : '';
|
|
332
406
|
// ⚠️ **不要在这里印 `from=`**(红队 2026-10-04):条目的 `seq_from/seq_to` 是**事件流 seq**
|
|
333
407
|
// (提炼料里的 `#N`,即 `dsh_turns_raw.seq`),而 `recall(conv, from, to)` 收的是
|
|
334
408
|
// **对话轮序号 n** —— 两个坐标系在"注入消息被折叠"的会话里会叉开。实测 274 条可比条目里
|
|
335
409
|
// **207 条(75.5%)照这条指引会直接 `out-of-range`**;偶有落在范围内的,读到的也是不相干的
|
|
336
410
|
// 轮次,还会顺手给不相干条目计一次"采用"。⇒ 只给会话 id,让调用方**先看目录再取原文**。
|
|
337
|
-
|
|
338
|
-
|
|
411
|
+
// M2(1.7.2):这条指引原先**每条条目都印一遍**(6 条就是 6 遍)。现在只印一次(见下方块尾)。
|
|
412
|
+
return `· ${pin}[${d.kind}] ${d.text}\n ↳ 出自 ${d.conv_id}`;
|
|
413
|
+
}).join('\n') + (grouped.folded.length ? '\n' + grouped.folded.join('\n') : '')
|
|
414
|
+
+ '\n(要原话:把上面的 conv 传给 `recall(conv=…)` 拿轮次,再按目录里的 `#N` 取 —— 别直接给 from,两个坐标系不同)' + '\n\n'
|
|
339
415
|
: '';
|
|
340
416
|
if (!hitRows.length && !deepRows.length) {
|
|
341
417
|
// 超时是**另一种结论**,不能说成"没找到" —— 前者该"重发或换窄词",后者该"换词",
|
|
@@ -349,8 +425,9 @@ export function renderSearch(v) {
|
|
|
349
425
|
}
|
|
350
426
|
return `按内容没找到(查询「${v.q}」· ${scope})${layerNote}。`
|
|
351
427
|
+ (v.skipped ? `\n(有 ${v.skipped} 条被范围挡掉 —— 要看它们得显式传 all=true。)\n` : '\n')
|
|
352
|
-
|
|
353
|
-
|
|
428
|
+
// M2(1.7.2):完整"换词口径"已收进工具 description(模型每次都看得见);
|
|
429
|
+
// 回执只留本次特有信息。同一段话印两遍是纯噪音。
|
|
430
|
+
+ '换词再试一次(口径见工具说明:中文 2~4 字、多词 = AND、无子串匹配)。';
|
|
354
431
|
}
|
|
355
432
|
const rows = hitRows.map((r) => {
|
|
356
433
|
const at = [r.seq !== null && r.seq !== undefined ? `#${r.seq}` : '', r.time].filter(Boolean).join(' ');
|
|
@@ -365,21 +442,8 @@ export function renderSearch(v) {
|
|
|
365
442
|
? `按内容命中 **${deepRows.length} 条深层库条目**(查询「${v.q}」· ${scope})${layerNote}:`
|
|
366
443
|
: `按内容找到 ${rows.length} 轮原文(查询「${v.q}」· ${scope})${layerNote}:`;
|
|
367
444
|
const convBlock = rows.length ? `${v.local ? '【库内原文层】' : '【会话原文层】'}\n${rows.join('\n')}` : '';
|
|
368
|
-
// ⚠️
|
|
369
|
-
//
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
+ (deepRows.length ? '**条目层是本地库,不受影响,就在上面** —— 先把它们用起来。' : '这次连条目也没命中。')
|
|
373
|
-
+ '要原文就**重发同一条**(多数第二次会快,对账已经补过了),或换更窄的词。'
|
|
374
|
-
: '';
|
|
375
|
-
// ⚠️ 排除本会话要**说出来**:不说就会被读成"库里只有这些"(那是说假话)。
|
|
376
|
-
const selfNote = Number(v.selfExcluded) > 0
|
|
377
|
-
? `\n(另有 ${v.selfExcluded} 轮命中属于**当前会话自己**,已按默认排除 —— 要看自己刚说的传 \`includeSelf=true\`。)`
|
|
378
|
-
: '';
|
|
379
|
-
return `${head}\n${deepBlock}${convBlock}${timeoutNote}${selfNote}`
|
|
380
|
-
+ (v.skipped ? `\n(${v.skipped} 条被范围挡掉 —— 要看它们得显式传 all=true。)` : '')
|
|
381
|
-
+ (v.hasMore ? '\n(这一页满了,**还有更多** —— 把 limit 调大,或换个更窄的词。)' : '')
|
|
382
|
-
+ (deepRows.length
|
|
383
|
-
? '\n\n**下一步**:条目已经是可以直接用的结论;**要它背后那句话**就把上面的 conv 传给 `recall(conv=…)` 拿对话目录,再按目录里的 `#N` 取原文(`recall(conv, from, to)`)。'
|
|
384
|
-
: '\n\n**下一步**:把某个 conv 原样传给 `recall(conv)` 看它的**对话目录**,再 `recall(conv, from, to)` 取原文。');
|
|
445
|
+
// ⚠️ 超时 / 排除本会话 / 被范围挡掉 / 还有更多 —— 这四段**全在回执末尾**,统一由
|
|
446
|
+
// `searchTailNotes()` 产出:① 它们语义上同属"本次特有的警告";② 上限截断时要把它们
|
|
447
|
+
// **整段保住**(`keepTail`) —— 拆开各写一份,截断那天就会漏掉其中一段(审计实测漏的就是这三段)。
|
|
448
|
+
return `${head}\n${deepBlock}${convBlock}` + searchTailNotes(v);
|
|
385
449
|
}
|
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
|
}
|
package/lib/host/session-line.js
CHANGED
|
@@ -35,6 +35,9 @@ import { pick } from './util.js';
|
|
|
35
35
|
import { firstSentence } from './l1.js';
|
|
36
36
|
import { extractMessageText } from './snapshot.js';
|
|
37
37
|
import { canInject, kindOfAgent, sessionIdOfAgent } from './inject.js';
|
|
38
|
+
// F4(2026-10-17):按码点安全截断 —— 那一行会**进模型上下文**,原来直接 `slice` 会把 emoji
|
|
39
|
+
// 切成半个代理对,而半个代理能把官方路由打成 400(见 `rawlog.wellFormed` 的事故记录)。
|
|
40
|
+
import { clipChars } from './rawlog.js';
|
|
38
41
|
|
|
39
42
|
/** 我们那条活行的 `source.kind` —— 也是"认出自己"的唯一判据(不用记下的 seq:折叠后可能已不在面上)。 */
|
|
40
43
|
export const SESSION_LINE_SOURCE = 'dsh-ling:session-line';
|
|
@@ -74,11 +77,13 @@ function oneLine(t) {
|
|
|
74
77
|
return String(t ?? '').replace(/\s+/g, ' ').trim();
|
|
75
78
|
}
|
|
76
79
|
|
|
77
|
-
/** 硬夹到 n 字(超出留 `…`)
|
|
80
|
+
/** 硬夹到 n 字(超出留 `…`)。⚠️ F4(2026-10-17):改走 `clipChars`(按码点)—— 原来直接
|
|
81
|
+
* `slice(0, k-1)` 会把 emoji 切成半个代理对,而那一行会进模型上下文 ⇒ 半个代理能打成 400。
|
|
82
|
+
* 语义保持不变:**k 是含省略号的总长上限**。 */
|
|
78
83
|
function clip(s, n) {
|
|
79
84
|
const t = String(s ?? '');
|
|
80
85
|
const k = Math.max(1, Number(n) || 0);
|
|
81
|
-
return t.length <= k ? t : t.
|
|
86
|
+
return t.length <= k ? t : clipChars(t, Math.max(1, k - 1));
|
|
82
87
|
}
|
|
83
88
|
|
|
84
89
|
/** 本地时刻 HH:MM(与主人看到的钟一致;探针里用它判断"文本是否随对话变")。 */
|
package/lib/host/snapshot.js
CHANGED
|
@@ -7,11 +7,12 @@ import { assemblePersona } from './persona.js';
|
|
|
7
7
|
import { selectL1, formatL1Section } from './l1.js';
|
|
8
8
|
import { currentMode } from './mode.js';
|
|
9
9
|
import { neutralizeMustache } from './util.js';
|
|
10
|
+
import { wellFormed } from './rawlog.js'; // 1.7.1 F4 闭环:快照是注入面主出口
|
|
10
11
|
import { createHash } from 'node:crypto';
|
|
11
12
|
|
|
12
13
|
// §4「超量折叠成一行」的那行计数(由 `memory.js` 的 branchLogDigest 在明细之后 push 进 `lines`)
|
|
13
14
|
// —— 它是**汇总**,不是明细。零明细时它撑不起整段(见下方渲染判据);有明细时照旧显示。
|
|
14
|
-
// 判据只用开头:明细行一律以主语(
|
|
15
|
+
// 判据只用开头:明细行一律以主语(主人/器灵)或动作词开头,不会长成 `…另有 N 笔改动`。
|
|
15
16
|
// 若哪天 memory.js 改了措辞,这里退化成"照旧渲染"(fail-open)—— 只影响占不占地方,不吞明细。
|
|
16
17
|
const BRANCH_LOG_FOLD_LINE = /^…另有 \d+ 笔改动/;
|
|
17
18
|
|
|
@@ -28,7 +29,9 @@ export function buildSnapshotText(ctx, memory, settings, sessionId, { keywords =
|
|
|
28
29
|
const l0 = assemblePersona(s, mode);
|
|
29
30
|
if (l0) { l0Text = l0; parts.push(l0); }
|
|
30
31
|
}
|
|
31
|
-
|
|
32
|
+
// 1.7.1(审计 F-02):闸不再查 conv_overview —— 料已换成深层库(l1.js 的 deepLine),
|
|
33
|
+
// 查概述会在"只有深层条目"的库里把整段 L1 吞掉;交 selectL1 自己判空。
|
|
34
|
+
if (personaEnabled && s.memory?.l1Enabled !== false) {
|
|
32
35
|
const cfg = s.memory || {};
|
|
33
36
|
const result = selectL1(memory, {
|
|
34
37
|
mode,
|
|
@@ -100,7 +103,7 @@ export function buildSnapshotText(ctx, memory, settings, sessionId, { keywords =
|
|
|
100
103
|
// **开关只管"渲染",不管"交代记账"**(停推进会卡死复盘模式的退出,回归见 tree 套 9-5)。
|
|
101
104
|
if (s.memory?.branchLogEnabled !== false && logDetailLines.length) {
|
|
102
105
|
// A-03 / D-S03(1.5.1 红蓝对抗):段首此前**写死**「主人刚改动了记忆树」,而 schema v12 起
|
|
103
|
-
// 每一行都已带主语(`branchLogDigest` 按 actor
|
|
106
|
+
// 每一行都已带主语(`branchLogDigest` 按 actor 分流成「主人」/「器灵」)⇒ 器灵经工具
|
|
104
107
|
// 改的树,在它**下一轮的系统提示**里被读成"主人刚改动",与 tools.js 的设计意图正相反。
|
|
105
108
|
// 段首改中性措辞,**主语交给每一行** —— 谁改的由行内说,不由段头替它说。
|
|
106
109
|
parts.push(['【记忆树有改动】', ...logLines.map((x) => '- ' + x)].join('\n'));
|
|
@@ -110,7 +113,7 @@ export function buildSnapshotText(ctx, memory, settings, sessionId, { keywords =
|
|
|
110
113
|
// 注入面出口 ①(快照侧,2026-09-18):L0 人格 + L1 记忆在这里成文。宿主 renderPrompt
|
|
111
114
|
// 无 try/catch,未注册的 `{{名字}}` 会让该会话每一步都失败 ⇒ 成文即中和(幂等)。
|
|
112
115
|
// 时间锚不经此处,由 inject.js 的最终出口兜底。
|
|
113
|
-
const text = neutralizeMustache(parts.join('\n\n'));
|
|
116
|
+
const text = wellFormed(neutralizeMustache(parts.join('\n\n')));
|
|
114
117
|
// ── 投递留痕(1.6-A,2026-10-03)──────────────────────────────────────────────
|
|
115
118
|
// **只在首次定稿时落一条**(`bumpHits`):冻结快照的选取在此刻定下,此后同一会话每轮投递的都是同一段
|
|
116
119
|
// 文本;重建/空闲刷新再记只会把表撑大而不增信息(与 `hit_count` 只记首次定稿同一取向,见上)。
|