@nightpainters/dsh-ling 1.5.3 → 1.6.0
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 +75 -6
- package/DESIGN.md +8 -4
- package/README.md +181 -7
- package/lib/client.js +35 -35
- package/lib/host/api.js +29 -22
- package/lib/host/archive.js +15 -9
- package/lib/host/autotree.js +2 -2
- package/lib/host/clock-skill.js +1 -1
- package/lib/host/deep-index-skill.js +111 -0
- package/lib/host/inject.js +1 -1
- package/lib/host/l1.js +350 -23
- package/lib/host/lifecycle.js +65 -10
- package/lib/host/memory.js +323 -37
- package/lib/host/persona.js +2 -2
- package/lib/host/rawlog.js +317 -0
- package/lib/host/recall-search.js +210 -0
- package/lib/host/retitle.js +5 -5
- package/lib/host/snapshot.js +65 -13
- package/lib/host/summarizer.js +10 -4
- package/lib/host/surgery.js +2 -2
- package/lib/host/tools.js +327 -14
- package/lib/host/tree-ops.js +12 -6
- package/lib/host/util.js +1 -1
- package/lib/index.js +41 -2
- package/package.json +6 -2
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
// dsh-ling host — 直通原文(1.6 · P2 第一优先)。
|
|
2
|
+
//
|
|
3
|
+
// 为什么要有这个模块(2026-10-04 尝生定「第一优先级就是先把读原文的这个事儿给办了」):
|
|
4
|
+
// 器灵此前**没有任何正规通道**能读会话原文 —— 7 个工具里一个都读不到,
|
|
5
|
+
// 而 memory.db 的 `dsh_turns_raw` 是**残的**(本会话实测:库里 13 条 vs 磁盘 473 条,≈2.7%)。
|
|
6
|
+
// 真全文只躺在磁盘上: ~/.dsh/sessions/<项目目录>/<会话目录>/session.v4.jsonl.zstd
|
|
7
|
+
//
|
|
8
|
+
// 磁盘格式(实测): **多 frame zstd 容器**,一帧一批事件,帧内是 JSONL。
|
|
9
|
+
// ⚠️ 三个坑,每个都踩过:
|
|
10
|
+
// ① `zstdDecompressSync(整文件)` 只解**第一帧**就返回(实测本会话只出 191 字节的会话头),
|
|
11
|
+
// 看起来"解压成功",极易被当成"文件就是这么小";
|
|
12
|
+
// ② 流式 `createZstdDecompress()` 直接报 `Unknown frame descriptor`;
|
|
13
|
+
// ③ 靠 magic 逐字节暴力试解边界是 **O(n²)** —— 实测 498 帧耗时 **119 秒**,不可用。
|
|
14
|
+
// ⇒ 正解是**纯结构扫描**帧边界(不解压),再逐帧解压。本模块照 DSH 官方实现重写
|
|
15
|
+
// (@deepseek-ai/dsh-session-persistence-jsonl 的 `scanZstdFrames`,见其 lib/index.js:1300-1363),
|
|
16
|
+
// 实测 **516 帧扫描 1ms + 全量解压解析 53ms**(相对暴力法快约 2200 倍)。
|
|
17
|
+
// 抄而不 import 的理由:不把插件绑到宿主内部包上(那是 P3 的劣,主人 2026-10-04 拍「先 P2」)。
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import os from 'node:os';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { zstdDecompressSync, constants as zc } from 'node:zlib';
|
|
22
|
+
import { isMemoryEligibleHeader } from './util.js'; // F3(2026-10-04):真人判据要按**会话级**分通道
|
|
23
|
+
|
|
24
|
+
/** Zstandard 帧魔数(小端读作 UInt32LE 时的值)。 */
|
|
25
|
+
const ZSTD_MAGIC = 0xfd2fb528;
|
|
26
|
+
/** 未完成末帧的兜底解压选项(与官方同款:`ZSTD_e_flush` 抑制末帧/校验和完成判定)。 */
|
|
27
|
+
const INCOMPLETE_FRAME_OPTIONS = { finishFlush: zc.ZSTD_e_flush };
|
|
28
|
+
/** 会话日志的候选文件名(**新→旧**)。DSH 换过三代: v4 / v3 / 无版本。
|
|
29
|
+
* ⚠️ 2026-10-04 实测:此处原先写死 v4 ⇒ 磁盘上 **117 个 `session.v3.jsonl.zstd` + 53 个
|
|
30
|
+
* `session.jsonl.zstd`** 全部读不到(仅**一个**会话目录下就有 82 个)。
|
|
31
|
+
* 而三代**事件同构** —— 都以 `{"type":"session","version":N}` 开头、事件类型一致、
|
|
32
|
+
* `dialogTurns` 直接可用(v3 与无版本各实测两份,零格式适配)。
|
|
33
|
+
* ⇒ 判据只能是「**文件在不在**」,不是「叫什么名字」。 */
|
|
34
|
+
const SESSION_LOG_NAMES = ['session.v4.jsonl.zstd', 'session.v3.jsonl.zstd', 'session.jsonl.zstd'];
|
|
35
|
+
|
|
36
|
+
/** 在一个会话目录里按 新→旧 找到日志文件;找不到返回 null。
|
|
37
|
+
* @returns {{file:string,name:string,bytes:number,mtime:number}|null} */
|
|
38
|
+
function findLogIn(dir) {
|
|
39
|
+
for (const name of SESSION_LOG_NAMES) {
|
|
40
|
+
const file = path.join(dir, name);
|
|
41
|
+
try {
|
|
42
|
+
const st = fs.statSync(file);
|
|
43
|
+
if (st.isFile()) return { file, name, bytes: st.size, mtime: Math.round(st.mtimeMs) };
|
|
44
|
+
} catch { /* 试下一个名字 */ }
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 会话根目录: ~/.dsh/sessions。 */
|
|
50
|
+
export function sessionsRoot() {
|
|
51
|
+
return path.join(os.homedir(), '.dsh', 'sessions');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 纯结构扫描 zstd 帧边界 —— **不解压任何 block**。
|
|
56
|
+
* 逐帧: magic(4) → frame header descriptor(1) → 剩余头 → block 循环(每块 3 字节头 + 载荷)。
|
|
57
|
+
* @param {Buffer} buffer 会话日志的全部字节
|
|
58
|
+
* @param {number} [maxFrames] 只要前 N 帧(元数据读取用;省掉后面的扫描)
|
|
59
|
+
* @returns {{frames:{start:number,end:number}[], tornStart?:number}} 完整帧区间;末尾被截断时给起始偏移
|
|
60
|
+
*/
|
|
61
|
+
export function scanZstdFrames(buffer, maxFrames = Number.POSITIVE_INFINITY) {
|
|
62
|
+
const frames = [];
|
|
63
|
+
let offset = 0;
|
|
64
|
+
while (offset < buffer.length) {
|
|
65
|
+
const start = offset;
|
|
66
|
+
if (buffer.length - offset < 4) return { frames, tornStart: start };
|
|
67
|
+
if (buffer.readUInt32LE(offset) !== ZSTD_MAGIC) {
|
|
68
|
+
throw new Error(`会话日志损坏:字节 ${offset} 处不是 zstd 帧头`);
|
|
69
|
+
}
|
|
70
|
+
offset += 4;
|
|
71
|
+
if (offset === buffer.length) return { frames, tornStart: start };
|
|
72
|
+
const descriptor = buffer.readUInt8(offset);
|
|
73
|
+
offset += 1;
|
|
74
|
+
if ((descriptor & 24) !== 0) throw new Error(`会话日志损坏:字节 ${offset - 1} 处保留位非零`);
|
|
75
|
+
const contentSizeFlag = descriptor >>> 6;
|
|
76
|
+
const singleSegment = (descriptor & 32) !== 0;
|
|
77
|
+
const checksum = (descriptor & 4) !== 0;
|
|
78
|
+
const dictionaryFlag = descriptor & 3;
|
|
79
|
+
const dictionaryBytes = dictionaryFlag === 3 ? 4 : dictionaryFlag;
|
|
80
|
+
const contentSizeBytes = contentSizeFlag === 0 ? (singleSegment ? 1 : 0) : 1 << contentSizeFlag;
|
|
81
|
+
const remainingHeaderBytes = (singleSegment ? 0 : 1) + dictionaryBytes + contentSizeBytes;
|
|
82
|
+
if (buffer.length - offset < remainingHeaderBytes) return { frames, tornStart: start };
|
|
83
|
+
offset += remainingHeaderBytes;
|
|
84
|
+
for (;;) {
|
|
85
|
+
if (buffer.length - offset < 3) return { frames, tornStart: start };
|
|
86
|
+
const blockHeader = buffer.readUIntLE(offset, 3);
|
|
87
|
+
offset += 3;
|
|
88
|
+
const lastBlock = (blockHeader & 1) !== 0;
|
|
89
|
+
const blockType = (blockHeader >>> 1) & 3;
|
|
90
|
+
const blockSize = blockHeader >>> 3;
|
|
91
|
+
if (blockType === 3) throw new Error(`会话日志损坏:字节 ${offset - 3} 处保留块类型`);
|
|
92
|
+
const payloadBytes = blockType === 1 ? 1 : blockSize;
|
|
93
|
+
if (buffer.length - offset < payloadBytes) return { frames, tornStart: start };
|
|
94
|
+
offset += payloadBytes;
|
|
95
|
+
if (lastBlock) break;
|
|
96
|
+
}
|
|
97
|
+
if (checksum) {
|
|
98
|
+
if (buffer.length - offset < 4) return { frames, tornStart: start };
|
|
99
|
+
offset += 4;
|
|
100
|
+
}
|
|
101
|
+
frames.push({ start, end: offset });
|
|
102
|
+
if (frames.length === maxFrames) return { frames };
|
|
103
|
+
}
|
|
104
|
+
return { frames };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 列出磁盘上的会话目录(权威的"有原文可读"清单) —— 只读目录名与文件大小,不解压。
|
|
109
|
+
* 为什么不从库里列: `conv_overview` 只有 1,600+ 概述、`dsh_turns_raw` 只有 116 个会话,
|
|
110
|
+
* 而磁盘有 500+ 个会话目录 ⇒ **库比磁盘少**,要读原文就得认磁盘。
|
|
111
|
+
* @returns {{ok:boolean, sessions:{conv:string, project:string, bytes:number, mtime:number}[], projects:number}}
|
|
112
|
+
*/
|
|
113
|
+
export function listSessionFiles({ limit = 40, project = '' } = {}) {
|
|
114
|
+
const root = sessionsRoot();
|
|
115
|
+
let projs;
|
|
116
|
+
try {
|
|
117
|
+
projs = fs.readdirSync(root, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
|
|
118
|
+
} catch (e) {
|
|
119
|
+
return { ok: false, reason: 'no-sessions-root', message: String(e?.message ?? e), sessions: [], projects: 0 };
|
|
120
|
+
}
|
|
121
|
+
const sessions = [];
|
|
122
|
+
for (const p of projs) {
|
|
123
|
+
if (project && p !== project) continue;
|
|
124
|
+
let dirs;
|
|
125
|
+
try {
|
|
126
|
+
dirs = fs.readdirSync(path.join(root, p), { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
|
|
127
|
+
} catch { continue; }
|
|
128
|
+
for (const c of dirs) {
|
|
129
|
+
const hit = findLogIn(path.join(root, p, c)); // 没有会话日志的目录(如纯 taskfold)不算
|
|
130
|
+
if (!hit) continue;
|
|
131
|
+
sessions.push({ conv: c, project: p, bytes: hit.bytes, mtime: hit.mtime, log: hit.name });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
sessions.sort((a, b) => b.mtime - a.mtime); // 最近动过的在前 —— 想读的多半是刚发生的
|
|
135
|
+
return { ok: true, sessions: sessions.slice(0, Math.max(1, limit)), total: sessions.length, projects: projs.length };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* 定位一个会话的日志文件。conv 接受三种写法: `session-<uuid>` / `<uuid>` / 目录名原样。
|
|
140
|
+
* @returns {{file:string, dir:string, project:string, id:string}|null}
|
|
141
|
+
*/
|
|
142
|
+
export function resolveSessionFile(conv) {
|
|
143
|
+
const want = String(conv || '').trim();
|
|
144
|
+
if (!want) return null;
|
|
145
|
+
const bare = want.replace(/^session-/, '');
|
|
146
|
+
const names = Array.from(new Set([want, bare, `session-${bare}`]));
|
|
147
|
+
const root = sessionsRoot();
|
|
148
|
+
let projs;
|
|
149
|
+
try {
|
|
150
|
+
projs = fs.readdirSync(root, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
|
|
151
|
+
} catch { return null; }
|
|
152
|
+
for (const p of projs) {
|
|
153
|
+
for (const n of names) {
|
|
154
|
+
const dir = path.join(root, p, n);
|
|
155
|
+
const hit = findLogIn(dir);
|
|
156
|
+
if (hit) return { file: hit.file, dir, project: p, id: n, log: hit.name };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* 读一个会话的全部事件。**这是唯一"解压"的入口** —— 单会话实测 ~53ms。
|
|
164
|
+
* @returns {{ok:boolean, reason?:string, events?:object[], frames?:number, tornStart?:number, bytes?:number}}
|
|
165
|
+
*/
|
|
166
|
+
export function readSessionEvents(conv) {
|
|
167
|
+
const hit = resolveSessionFile(conv);
|
|
168
|
+
if (!hit) return { ok: false, reason: 'no-session', conv: String(conv || '') };
|
|
169
|
+
let buf;
|
|
170
|
+
try { buf = fs.readFileSync(hit.file); } catch (e) {
|
|
171
|
+
return { ok: false, reason: 'io', conv: hit.id, message: String(e?.message ?? e) };
|
|
172
|
+
}
|
|
173
|
+
let scanned;
|
|
174
|
+
try { scanned = scanZstdFrames(buf); } catch (e) {
|
|
175
|
+
return { ok: false, reason: 'corrupt', conv: hit.id, message: String(e?.message ?? e) };
|
|
176
|
+
}
|
|
177
|
+
const events = [];
|
|
178
|
+
for (const { start, end } of scanned.frames) {
|
|
179
|
+
let txt;
|
|
180
|
+
try { txt = zstdDecompressSync(buf.subarray(start, end)).toString('utf8'); } catch { continue; }
|
|
181
|
+
for (const line of txt.split('\n')) {
|
|
182
|
+
const s = line.trim();
|
|
183
|
+
if (!s) continue;
|
|
184
|
+
try { events.push(JSON.parse(s)); } catch { /* 半行/非 JSON,跳过 */ }
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
let tornPlain = 0;
|
|
188
|
+
if (scanned.tornStart !== undefined) {
|
|
189
|
+
// 末帧被截断(会话正写到一半):官方用 ZSTD_e_flush 兜底 —— 捞出能捞的部分,别整份判失败。
|
|
190
|
+
try {
|
|
191
|
+
tornPlain = zstdDecompressSync(buf.subarray(scanned.tornStart), INCOMPLETE_FRAME_OPTIONS)
|
|
192
|
+
.toString('utf8').split('\n').filter((l) => l.trim()).length;
|
|
193
|
+
} catch { /* 捞不出来就算了 */ }
|
|
194
|
+
}
|
|
195
|
+
return {
|
|
196
|
+
ok: true, ...hit, events,
|
|
197
|
+
frames: scanned.frames.length,
|
|
198
|
+
tornStart: scanned.tornStart,
|
|
199
|
+
tornLines: tornPlain,
|
|
200
|
+
bytes: buf.length,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** 从 content 数组里取文本(可限制只取哪几类块;默认只取正文,不取思维链)。 */
|
|
205
|
+
function textOfContent(content, allow = ['text']) {
|
|
206
|
+
if (typeof content === 'string') return content;
|
|
207
|
+
if (!Array.isArray(content)) return '';
|
|
208
|
+
const parts = [];
|
|
209
|
+
for (const b of content) {
|
|
210
|
+
if (!b || typeof b !== 'object') continue;
|
|
211
|
+
if (!allow.includes(b.type)) continue;
|
|
212
|
+
if (typeof b.text === 'string' && b.text) parts.push(b.text);
|
|
213
|
+
}
|
|
214
|
+
return parts.join('\n');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* 把事件流收成**对话轮** —— 这就是"深层库要什么"的第一层过滤:
|
|
219
|
+
* 只留 `user/message` 与 `assistant/message`,丢掉 tool/call、tool/result、step、compaction 等全部过程事件。
|
|
220
|
+
* ⚠️ user 消息里**真人只占少数**(注入的 runtime-context / skill-catalog / task-marks 也是 user/message)。
|
|
221
|
+
* 判据是 `data.source.kind === 'user'`(真人)**且**带 rpcId;其余一律标 `human:false`。
|
|
222
|
+
* @returns {{turns:object[], injected:number, assistantEmpty:number}}
|
|
223
|
+
*/
|
|
224
|
+
export function dialogTurns(events) {
|
|
225
|
+
const evs = events || [];
|
|
226
|
+
// ⚠️ F3(蓝队 2026-10-04 全盘实测 + 修法设计):事件里**没有通道字段** —— 飞书真人、身份注入块、
|
|
227
|
+
// 子代理委托种子在消息级**完全同形**(都经 `agent/inbox/spliced`)。"按通道分"只能落在**会话级**:
|
|
228
|
+
// 从 `session` 事件取 header(**字段在事件顶层,不在 `data` 里**)过 `isMemoryEligibleHeader`,
|
|
229
|
+
// 与捕获侧(`lifecycle.js:217/355`)同一把尺。
|
|
230
|
+
// 判据五条:① `kind==='user'` ② 会话合格(top|fork) ③ 合格会话里**无 rpcId** 的 user 认真人
|
|
231
|
+
// (飞书桥来的主人真话没有 rpcId) ④ 正文行首 `/^\[身份[·・]/` 仍算注入(persona.js 自有段头)
|
|
232
|
+
// ⑤ 取不到 session 头 ⇒ 只认 rpcId(与修前逐字同解,保住无头夹具与旧日志)。
|
|
233
|
+
// 实测(蓝队全盘 501 会话):真人轮 951 → **1001**(+49 飞书真人 +1 探针),注入 7049 → 6999,
|
|
234
|
+
// 子代理种子 387 条 **true→false 回归 0**。
|
|
235
|
+
let sessionEligible = null;
|
|
236
|
+
for (const e of evs) {
|
|
237
|
+
if (e && typeof e === 'object' && e.type === 'session' && e.header) {
|
|
238
|
+
sessionEligible = isMemoryEligibleHeader(e.header);
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
const turns = [];
|
|
243
|
+
let injected = 0;
|
|
244
|
+
let assistantEmpty = 0;
|
|
245
|
+
for (const e of evs) {
|
|
246
|
+
if (!e || typeof e !== 'object') continue;
|
|
247
|
+
if (e.type === 'user/message') {
|
|
248
|
+
const d = e.data || {};
|
|
249
|
+
const src = d?.source || {};
|
|
250
|
+
const text = textOfContent(d.content);
|
|
251
|
+
const identityBlock = /^\[身份[·・]/.test(String(text || '').trim());
|
|
252
|
+
let human;
|
|
253
|
+
if (identityBlock) human = false; // ④ 身份块永远是注入
|
|
254
|
+
else if (src.kind !== 'user') human = false; // ① kind 必须是 user
|
|
255
|
+
else if (src.rpcId) human = sessionEligible !== false; // ②⑤ 有 rpcId:沿用旧判据(子代理会话除外)
|
|
256
|
+
else human = sessionEligible === true; // ③ 无 rpcId:只在合格会话里认真人
|
|
257
|
+
if (!human) injected += 1;
|
|
258
|
+
turns.push({
|
|
259
|
+
seq: e.seq, time: e.time, role: 'user', human, text,
|
|
260
|
+
});
|
|
261
|
+
} else if (e.type === 'assistant/message') {
|
|
262
|
+
const d = e.data || {};
|
|
263
|
+
const msg = d.message || {};
|
|
264
|
+
const text = textOfContent(msg.content, ['text']);
|
|
265
|
+
// ⚠️ 实测(本会话 114 条 assistant/message):`text` 块只有 10 条,**绝大多数轮次是"只调工具、没有正文"**
|
|
266
|
+
// (块类型是 `reasoning` / `tool-call` / `text` —— 注意是 `tool-call` 不是 `tool_use`)。
|
|
267
|
+
// ⇒ 无正文时把**调了哪些工具**带出来,否则目录里会是一串空白行,读不出发生过什么。
|
|
268
|
+
// 同名工具**合并计数**(实测:一轮里并行调两个 pwsh,原样列出就是 `pwsh, pwsh` 这种噪音)。
|
|
269
|
+
const tools = [];
|
|
270
|
+
if (Array.isArray(msg.content)) {
|
|
271
|
+
const counts = new Map();
|
|
272
|
+
for (const b of msg.content) {
|
|
273
|
+
if (b?.type !== 'tool-call' || !b.name) continue;
|
|
274
|
+
const k = String(b.name);
|
|
275
|
+
counts.set(k, (counts.get(k) || 0) + 1);
|
|
276
|
+
}
|
|
277
|
+
for (const [k, c] of counts) tools.push(c > 1 ? `${k}×${c}` : k);
|
|
278
|
+
}
|
|
279
|
+
if (!text) assistantEmpty += 1;
|
|
280
|
+
turns.push({ seq: e.seq, time: e.time, role: 'assistant', human: false, text, tools, turn: d.turn, step: d.step });
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
turns.forEach((t, i) => { t.n = i + 1; }); // 人读序号(1-based);seq 是事件流里的原始序号
|
|
284
|
+
return { turns, injected, assistantEmpty };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** 把 `dsh_turns_raw` 的行(seq/role/ts/text)还原成**事件形状**,好让 `dialogTurns` 直接吃。
|
|
288
|
+
* 1.6(2026-10-04):`recall` 原本只读磁盘 ⇒ 库里的原文(实测 15 个 `import:<id>` 会话)够不着,
|
|
289
|
+
* 而磁盘那 465 个会话里没有它们。这是"读侧改道"的**反面补充** —— 库里那份也要能读,
|
|
290
|
+
* 只是它没有事件流、只有轮次。
|
|
291
|
+
* ⚠️ 库里的 user 行**都已是真人**(写入时已按 `source.kind === 'user'` 过滤过) ⇒ 直接按真人标。 */
|
|
292
|
+
export function eventsFromRows(rows) {
|
|
293
|
+
return (rows || []).map((r) => {
|
|
294
|
+
const ts = Date.parse(r?.ts);
|
|
295
|
+
const time = Number.isFinite(ts) ? ts : 0;
|
|
296
|
+
const text = String(r?.text ?? '');
|
|
297
|
+
return r?.role === 'user'
|
|
298
|
+
? { type: 'user/message', seq: r?.seq, time, data: { content: [{ type: 'text', text }], source: { kind: 'user', rpcId: 'db' } } }
|
|
299
|
+
: { type: 'assistant/message', seq: r?.seq, time, data: { message: { content: [{ type: 'text', text }] } } };
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** 毫秒时间戳 → 本地 `MM-DD HH:mm`(与 memory.js 的口径一致:给人看的走本地钟)。 */
|
|
304
|
+
export function localStamp(ms) {
|
|
305
|
+
const n = Number(ms);
|
|
306
|
+
if (!Number.isFinite(n) || n <= 0) return '';
|
|
307
|
+
const d = new Date(n);
|
|
308
|
+
const p = (x) => String(x).padStart(2, '0');
|
|
309
|
+
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** 单行化 + 截断(目录视图用)。名字不叫 flatOne:`tools.js` 里已有一个同名的(签名不同)。 */
|
|
313
|
+
export function oneLine(s, cap = 60) {
|
|
314
|
+
const t = String(s || '').replace(/\s+/g, ' ').trim();
|
|
315
|
+
if (!t) return '';
|
|
316
|
+
return t.length > cap ? t.slice(0, cap) + '…' : t;
|
|
317
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// dsh-ling host — recall 的**检索档**(1.6 §7 步 1,2026-10-04)。
|
|
2
|
+
//
|
|
3
|
+
// 它是什么:给模型一个「**按内容找会话**」的入口。此前只有 `recall()` 的磁盘清单(按时间列文件)
|
|
4
|
+
// 与 `recall(conv)` 的目录 —— 要"找"只能靠翻,翻不动就靠猜。
|
|
5
|
+
//
|
|
6
|
+
// 为什么接宿主检索面而不是自建(2026-10-04 全实测):
|
|
7
|
+
// · 宿主 `@deepseek-ai/dsh-session-query` 的 SQLite 后端已由主人在 2026-09-20 启用
|
|
8
|
+
// (`profiles\web\cordis.patch.yml:59-62` → `session-search.db`);
|
|
9
|
+
// · 索引**懒对账**:每次 `searchSessions` 先跑 `_reconcile` → `_observeStable`,把磁盘上新增的
|
|
10
|
+
// 会话补进索引(代码 :537/:543/:707/:724 + 数据 `global_generation=2` 两侧证实)
|
|
11
|
+
// ⇒ 停更是"没人搜过",不是坏了;
|
|
12
|
+
// · 中文实测:2~4 字短词命中良好(`记忆树` 1024 / `血缘` 288 / `注入面` 453),
|
|
13
|
+
// **多词 = AND**(`记忆树 血缘` ≡ `"记忆树" "血缘"` ≡ 159),**无子串召回**(`call` 命中 0);
|
|
14
|
+
// · 官方为 tokenizer 取舍(`unicode61`,否决 trigram)做过实测 ⇒ 重开选型是重复踩坑。
|
|
15
|
+
// ⇒ 结论:**分两层用** —— 找会话走宿主,找原文走 `rawlog`(磁盘直读)。
|
|
16
|
+
//
|
|
17
|
+
// 范围限制(D6 已拍 2026-10-04):默认**只搜「主人自己的会话」**;越界要显式 `all:true`。
|
|
18
|
+
// 判据**复用** `isMemoryEligibleHeader`(顶层 + 分叉入、子代理不入)—— 与入库/注入同一把尺,
|
|
19
|
+
// 不另立一份,否则会出现"能读到的"与"能记住的"两套边界。
|
|
20
|
+
// 官方 README 的硬约束原文:「**无调用方授权** …… 模型工具或 UI **必须限制调用方可检查的会话**」
|
|
21
|
+
// ⇒ 照抄它的形状:`listSessions()` 造可见集 → 结果逐条 `visibleIds.has(...)`
|
|
22
|
+
// (官方同款实现在 `dsh-api-session-controller\lib\index.js:1934` / `:1984`)。
|
|
23
|
+
|
|
24
|
+
import { isMemoryEligibleHeader, sessionKind } from './util.js';
|
|
25
|
+
import { localStamp, oneLine } from './rawlog.js';
|
|
26
|
+
|
|
27
|
+
/** 一页几个会话(默认)。 */
|
|
28
|
+
export const SEARCH_HITS_DEFAULT = 10;
|
|
29
|
+
/** 一页几个会话(上限):给模型的是"下一步去哪",不是把库倒出来。 */
|
|
30
|
+
export const SEARCH_HITS_MAX = 50;
|
|
31
|
+
/** 单条摘要的显示字数 —— **报短摘要而不是 id**(2026-10-04 生态复核:同生态对手的 README 逐字
|
|
32
|
+
* 「查 id 是什么的那一步最容易省略,省略了就等于没报」)。这一条是本次复核直接抄来的。 */
|
|
33
|
+
export const SEARCH_SNIPPET_CHARS = 160;
|
|
34
|
+
|
|
35
|
+
/** 条数夹取(纯函数,便于单测)。 */
|
|
36
|
+
export function clampSearchLimit(n) {
|
|
37
|
+
const v = Math.floor(Number(n));
|
|
38
|
+
return Number.isFinite(v) ? Math.min(SEARCH_HITS_MAX, Math.max(1, v)) : SEARCH_HITS_DEFAULT;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 一次检索最多带出几条**深层库条目**(1.6 §7 步 4,2026-10-04)。 */
|
|
42
|
+
export const SEARCH_DEEP_MAX = 12;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 在**深层库条目**里按内容找(1.6 §7 步 4)。
|
|
46
|
+
*
|
|
47
|
+
* 为什么要和"找会话"并在一起:主人的问句多半是「我说过什么关于 X 的」——
|
|
48
|
+
* 而答案更可能在**结论层**(`deep_item`)而不是某一段原始对话里。两者一起给,
|
|
49
|
+
* 「找条目」与「找会话」一次到位,少一轮往返。
|
|
50
|
+
*
|
|
51
|
+
* 多词语义**必须与宿主检索面同解(AND)**:那边实测 `记忆树 血缘` ≡ `"记忆树" "血缘"`,
|
|
52
|
+
* 这里就也是"每词都要出现"。两处"多词"含义打架的话,同一个问句会给出互相矛盾的结果。
|
|
53
|
+
*
|
|
54
|
+
* 排面用表自己的口径(置顶 → 加固 → 新);**已取代的条目不参与** —— 它们是
|
|
55
|
+
* "曾经成立、现已作废"的留痕(见 `supersedeDeepItem`),主动召回不该再挑它。
|
|
56
|
+
*
|
|
57
|
+
* 表还没建时(宿主未重启)**静默返回空**:检索面不该因为深层库缺席而整体失败。
|
|
58
|
+
*/
|
|
59
|
+
export function searchDeepItems(memory, q, limit = SEARCH_DEEP_MAX) {
|
|
60
|
+
const db = memory?.db;
|
|
61
|
+
if (!db?.prepare) return [];
|
|
62
|
+
const words = String(q || '').trim().split(/\s+/).map((w) => w.replace(/[%_\\]/g, '')).filter(Boolean);
|
|
63
|
+
if (!words.length) return [];
|
|
64
|
+
try {
|
|
65
|
+
const where = words.map(() => "text LIKE ? ESCAPE '\\'").join(' AND ');
|
|
66
|
+
return db.prepare(
|
|
67
|
+
`SELECT id, kind, text, conv_id, src, seq_from, seq_to, pinned, hit_count FROM deep_item
|
|
68
|
+
WHERE superseded_by='' AND ${where}
|
|
69
|
+
ORDER BY pinned DESC, hit_count DESC, created_at DESC LIMIT ?`,
|
|
70
|
+
).all(...words.map((w) => `%${w}%`), Math.max(1, Math.min(50, Number(limit) || SEARCH_DEEP_MAX)));
|
|
71
|
+
} catch { return []; }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 从 `listSessions()` 的记录里造「主人自己的会话」id 集(纯函数)。
|
|
76
|
+
* 拿不到 header 的**不放入** —— 宁少不滥,这是可见性过滤,不是统计。
|
|
77
|
+
*/
|
|
78
|
+
/** 1.6 步 5:**采用信号** —— `recall` 去取某段原文时,把**与所取区间相交**的深层库条目加固 +1。
|
|
79
|
+
*
|
|
80
|
+
* 为什么这是"采用"而不是"注入":注入是系统单方面推给模型的,模型完全可以不理;
|
|
81
|
+
* 而**肯为一条结论下钻去读它指向的原话**,是它真在用这条结论的证据。
|
|
82
|
+
* `deep_item` 列注释里那条语义位(「`hit_count` 只由真被采用推进,不由被注入推进」)就落在这里 ——
|
|
83
|
+
* 否则注入越多分越高、分越高越容易被注入,是个自我强化回路(抄自 stratagate 的做法)。
|
|
84
|
+
*
|
|
85
|
+
* ⚠️ **seq 坐标系**:必须用 `dsh_turns_raw.seq`(即提炼料文件里的 `**#N role**`),
|
|
86
|
+
* **不是**目录里的对话轮序号 `n` —— 两者在"注入消息被折叠"的会话里会叉开。
|
|
87
|
+
* 相交判据 `seq_from <= hi && seq_to >= lo`;已取代的条目不加固(它是"曾经成立"的留痕)。
|
|
88
|
+
* 返回被加固的条数(0 = 没有相交条目,正常)。任何异常都吞成 0 —— 加固失败不该让读原文失败。 */
|
|
89
|
+
export function adoptDeepItems(memory, conv, seqLo, seqHi) {
|
|
90
|
+
try {
|
|
91
|
+
if (!memory?.db || typeof memory.bumpDeepHit !== 'function') return 0;
|
|
92
|
+
const c = String(conv || '');
|
|
93
|
+
const lo = Number(seqLo) || 0;
|
|
94
|
+
const hi = Number(seqHi) || 0;
|
|
95
|
+
if (!c || !lo || !hi) return 0;
|
|
96
|
+
const rows = memory.db.prepare(
|
|
97
|
+
"SELECT id FROM deep_item WHERE conv_id=? AND superseded_by='' AND seq_from<=? AND seq_to>=? LIMIT 50",
|
|
98
|
+
).all(c, hi, lo);
|
|
99
|
+
let n = 0;
|
|
100
|
+
for (const r of rows) if (memory.bumpDeepHit(r.id)?.ok) n += 1;
|
|
101
|
+
return n;
|
|
102
|
+
} catch {
|
|
103
|
+
return 0;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function visibleIdSet(records) {
|
|
108
|
+
const out = new Set();
|
|
109
|
+
for (const r of records || []) {
|
|
110
|
+
const h = r?.header;
|
|
111
|
+
if (!h || typeof h !== 'object') continue;
|
|
112
|
+
const id = h.id ?? r?.id;
|
|
113
|
+
if (!id) continue;
|
|
114
|
+
if (isMemoryEligibleHeader(h)) out.add(String(id));
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 把宿主的 `SessionSearchHit[]` 折成回执行(纯函数)。
|
|
121
|
+
* @param hits `{ header, bestMatch: { seq, time, type, snippet } }[]`
|
|
122
|
+
* @param visible 可见集(来自 `visibleIdSet`);`all === true` 时忽略
|
|
123
|
+
* @param all 越界开关(D6:显式才生效)
|
|
124
|
+
* @returns `{ rows, skipped }` —— `skipped` 是**被范围挡掉**的条数,必须报出来(不静默丢)
|
|
125
|
+
*/
|
|
126
|
+
export function toSearchRows(hits, { visible = null, all = false, callName = '主人' } = {}) {
|
|
127
|
+
const rows = [];
|
|
128
|
+
let skipped = 0;
|
|
129
|
+
for (const h of hits || []) {
|
|
130
|
+
const header = h?.header ?? {};
|
|
131
|
+
const id = header.id ?? h?.sessionId;
|
|
132
|
+
if (!all && visible && (!id || !visible.has(String(id)))) { skipped += 1; continue; }
|
|
133
|
+
const m = h?.bestMatch ?? {};
|
|
134
|
+
const isUser = String(m.type || '').startsWith('user');
|
|
135
|
+
rows.push({
|
|
136
|
+
conv: String(id || '(无 id)'),
|
|
137
|
+
kind: sessionKind(header),
|
|
138
|
+
cwd: header.cwd ? String(header.cwd) : '',
|
|
139
|
+
seq: m.seq ?? null,
|
|
140
|
+
time: m.time ? localStamp(m.time) : '',
|
|
141
|
+
who: isUser ? callName : '鱼姬',
|
|
142
|
+
snippet: oneLine(m.snippet || '', SEARCH_SNIPPET_CHARS),
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
// ⚠️ `degraded`(蓝队 2026-10-04 抓到 P3):宿主**没有** `listSessions` 时,`visible` 恒为 null
|
|
146
|
+
// ⇒ 范围限制**整条失效**(一条不挡),而回执却仍写着"已在主人自己的会话内" —— 那是**说假话**。
|
|
147
|
+
// 取舍(主代理 2026-10-04 定):**保持 fail-open 但如实标注** —— 完全禁用会让"按内容找会话"
|
|
148
|
+
// 在宿主版本变动时突然死掉,而这里泄漏面只是子代理会话(不是外部);关键是**不许假装限过范围**。
|
|
149
|
+
return { rows, skipped, degraded: !all && !visible };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** 检索档的人读回执(纯函数,模型可见)。 */
|
|
153
|
+
export function renderSearch(v) {
|
|
154
|
+
if (!v || v.ok !== true) {
|
|
155
|
+
// ⚠️ 兜底必须在**构造字符串之前** —— 表里的模板是立即求值的,`v` 为 null 时
|
|
156
|
+
// `v.message` 会在构造这一步就抛。这不是防御性编程:工具回执抛异常 = 模型那一步直接失败,
|
|
157
|
+
// 而"检索失败"恰恰是最该被好好说出来的时刻。本文件第一次跑测试就抓到了这一处。
|
|
158
|
+
const msg = v?.message || '(原因未知)';
|
|
159
|
+
const why = {
|
|
160
|
+
'search-unavailable':
|
|
161
|
+
`宿主检索面不可用,这次没搜成。原因:${msg}`
|
|
162
|
+
+ ' 这不是"库里没有",是**通道没接上** —— 按内容找会话这条路暂时走不通,'
|
|
163
|
+
+ '可改用**不带参数**的 recall() 按时间翻清单。',
|
|
164
|
+
'search-failed': `检索报错,这次没搜成:${msg}。换个更短的词再试(中文 2~4 字命中最好)。`,
|
|
165
|
+
}[v?.reason];
|
|
166
|
+
return why || `未搜到内容(${v?.reason || '未知'})。`;
|
|
167
|
+
}
|
|
168
|
+
const scope = v.all
|
|
169
|
+
? '**未限定范围**(all=true —— 含子代理等非主人会话)'
|
|
170
|
+
: (v.degraded
|
|
171
|
+
? '⚠️ **没能限定范围** —— 宿主这次没给 `listSessions`,所以下面这份结果**可能含子代理或其它会话**,'
|
|
172
|
+
+ '不是"只搜主人自己的会话"(范围限制这条通道暂时失效,不是"库里只有这些")'
|
|
173
|
+
: '已在「**主人自己的会话**」内(默认范围)');
|
|
174
|
+
const hitRows = Array.isArray(v.rows) ? v.rows : [];
|
|
175
|
+
const deepRows = Array.isArray(v.deep) ? v.deep : [];
|
|
176
|
+
// 条目排在前:它是**结论层**(一句话说完),会话在后(要下钻才知道内容)。
|
|
177
|
+
const deepBlock = deepRows.length
|
|
178
|
+
? '【深层库条目 · 结论层(从他的会话里提炼出来的,可直接用)】\n' + deepRows.map((d) => {
|
|
179
|
+
const pin = Number(d.pinned) ? '📌 ' : '';
|
|
180
|
+
// ⚠️ **不要在这里印 `from=`**(红队 2026-10-04):条目的 `seq_from/seq_to` 是**事件流 seq**
|
|
181
|
+
// (提炼料里的 `#N`,即 `dsh_turns_raw.seq`),而 `recall(conv, from, to)` 收的是
|
|
182
|
+
// **对话轮序号 n** —— 两个坐标系在"注入消息被折叠"的会话里会叉开。实测 274 条可比条目里
|
|
183
|
+
// **207 条(75.5%)照这条指引会直接 `out-of-range`**;偶有落在范围内的,读到的也是不相干的
|
|
184
|
+
// 轮次,还会顺手给不相干条目计一次"采用"。⇒ 只给会话 id,让调用方**先看目录再取原文**。
|
|
185
|
+
return `· ${pin}[${d.kind}] ${d.text}\n ↳ 出自 ${d.conv_id} —— 要看原话:先 \`recall(conv="${d.conv_id}")\` 拿轮次,再按目录里的 \`#N\` 取`;
|
|
186
|
+
}).join('\n') + '\n\n'
|
|
187
|
+
: '';
|
|
188
|
+
if (!hitRows.length && !deepRows.length) {
|
|
189
|
+
return `按内容没找到(查询「${v.q}」· ${scope})。`
|
|
190
|
+
+ (v.skipped ? `\n(有 ${v.skipped} 条被范围挡掉 —— 要看它们得显式传 all=true。)\n` : '\n')
|
|
191
|
+
+ '换词试试:中文 **2~4 字**短词命中最好(`记忆树` / `血缘` / `注入面`);'
|
|
192
|
+
+ '**多个词用空格分隔 = 同时满足**(AND);**没有子串匹配**(`call` 搜不到 `recall`)。';
|
|
193
|
+
}
|
|
194
|
+
const rows = hitRows.map((r) => {
|
|
195
|
+
const at = [r.seq !== null && r.seq !== undefined ? `#${r.seq}` : '', r.time].filter(Boolean).join(' ');
|
|
196
|
+
return `· ${r.conv} · ${at} ${r.who}\n ${r.snippet}`;
|
|
197
|
+
});
|
|
198
|
+
const head = deepRows.length && rows.length
|
|
199
|
+
? `按内容命中 **${deepRows.length} 条深层库条目** + ${rows.length} 个会话(查询「${v.q}」· ${scope}):`
|
|
200
|
+
: deepRows.length
|
|
201
|
+
? `按内容命中 **${deepRows.length} 条深层库条目**(查询「${v.q}」· ${scope}):`
|
|
202
|
+
: `按内容找到 ${rows.length} 个会话(查询「${v.q}」· ${scope}):`;
|
|
203
|
+
const convBlock = rows.length ? `【会话原文层】\n${rows.join('\n')}` : '';
|
|
204
|
+
return `${head}\n${deepBlock}${convBlock}`
|
|
205
|
+
+ (v.skipped ? `\n(${v.skipped} 条被范围挡掉 —— 要看它们得显式传 all=true。)` : '')
|
|
206
|
+
+ (v.hasMore ? '\n(这一页满了,**还有更多** —— 把 limit 调大,或换个更窄的词。)' : '')
|
|
207
|
+
+ (deepRows.length
|
|
208
|
+
? '\n\n**下一步**:条目已经是可以直接用的结论;**要它背后那句话**就把上面的 conv 传给 `recall(conv=…)` 拿对话目录,再按目录里的 `#N` 取原文(`recall(conv, from, to)`)。'
|
|
209
|
+
: '\n\n**下一步**:把某个 conv 原样传给 `recall(conv)` 看它的**对话目录**,再 `recall(conv, from, to)` 取原文。');
|
|
210
|
+
}
|
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
|
}
|