@nightpainters/dsh-ling 1.6.1 → 1.6.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 +81 -41
- package/DESIGN.md +34 -25
- package/PHILOSOPHY.md +174 -0
- package/README.md +13 -6
- package/lib/host/api.js +21 -3
- package/lib/host/deep-index-skill.js +9 -0
- package/lib/host/l1.js +47 -3
- package/lib/host/memory.js +20 -15
- package/lib/host/persona.js +0 -1
- package/lib/host/rawlog.js +9 -4
- package/lib/host/recall-search.js +148 -10
- package/lib/host/surgery.js +4 -1
- package/lib/host/tools.js +50 -16
- package/package.json +3 -2
|
@@ -24,14 +24,49 @@
|
|
|
24
24
|
import { isMemoryEligibleHeader, sessionKind } from './util.js';
|
|
25
25
|
import { localStamp, oneLine } from './rawlog.js';
|
|
26
26
|
|
|
27
|
-
/** 一页几个会话(默认)。
|
|
28
|
-
|
|
27
|
+
/** 一页几个会话(默认):**5 条**(2026-10-05 主人拍板 B2,原为 10)。
|
|
28
|
+
* 起因:实测"搜什么都中今天" —— 全文索引里**当前会话自己**也在,任何词都能命中几十秒前刚打的那句话,
|
|
29
|
+
* 而原文层默认 10 行会把上面的**条目层**(结论,一句话说完)挤到看不见。砍到 5 条让条目重新占视觉重心;
|
|
30
|
+
* 要更多**显式传 `limit`**(上限 50 不变,想翻就翻)。 */
|
|
31
|
+
export const SEARCH_HITS_DEFAULT = 5;
|
|
29
32
|
/** 一页几个会话(上限):给模型的是"下一步去哪",不是把库倒出来。 */
|
|
30
33
|
export const SEARCH_HITS_MAX = 50;
|
|
31
34
|
/** 单条摘要的显示字数 —— **报短摘要而不是 id**(2026-10-04 生态复核:同生态对手的 README 逐字
|
|
32
35
|
* 「查 id 是什么的那一步最容易省略,省略了就等于没报」)。这一条是本次复核直接抄来的。 */
|
|
33
36
|
export const SEARCH_SNIPPET_CHARS = 160;
|
|
34
37
|
|
|
38
|
+
/** 宿主检索的**超时上限**(2026-10-05,主人定 20 秒)。
|
|
39
|
+
*
|
|
40
|
+
* 为什么必须有它:宿主的懒对账(`_reconcile` → `_observeStable`)是**串行化**的。一旦撞上卡住的对账,
|
|
41
|
+
* `searchSessions` 就**一直不返回** —— 实测有一次挂了 **约 30 分钟**,而当时**没有任何中断入口**:
|
|
42
|
+
* GUI 没有,工具层也没有。按本项目理念(「逃生开关永远可用」),**"不可中断"本身就该按最严重一级对待**,
|
|
43
|
+
* 与它挂了多久无关。
|
|
44
|
+
* ⇒ 我们改不了宿主(拿不到取消口),但可以**不给它无限期**:超时后放弃原文层,并**照样把条目层交出去**
|
|
45
|
+
* (条目是本地 SQLite,毫秒级,完全不受宿主影响)。
|
|
46
|
+
* ⚠️ 别调小到 5 秒以下:一次"慢但对"的检索比"快速失败"有用得多;
|
|
47
|
+
* 也别指望它治并发 —— 并发撞对账是宿主侧的事,这里只保证**任何一次都不会挂死**。 */
|
|
48
|
+
export const SEARCH_TIMEOUT_MS = 20000;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 给 Promise 套超时(纯函数,可单测)。
|
|
52
|
+
* ⚠️ 超时**不取消**底层操作(宿主没给取消口),只是我们不再等它;它之后若返回,结果被丢弃 ——
|
|
53
|
+
* 检索是只读的,丢弃不影响任何状态。
|
|
54
|
+
* @returns `{ok:true, value}` | `{ok:false, reason:'timeout', ms, label}`
|
|
55
|
+
*/
|
|
56
|
+
export function withTimeout(promise, ms, label = '检索') {
|
|
57
|
+
const limit = Math.max(1, Number(ms) || SEARCH_TIMEOUT_MS);
|
|
58
|
+
let timer = null;
|
|
59
|
+
const timeout = new Promise((resolve) => {
|
|
60
|
+
timer = setTimeout(() => resolve({ ok: false, reason: 'timeout', ms: limit, label }), limit);
|
|
61
|
+
// 别让这个定时器拖住进程退出(工具层常在短命进程里被调用)
|
|
62
|
+
if (typeof timer?.unref === 'function') timer.unref();
|
|
63
|
+
});
|
|
64
|
+
return Promise.race([
|
|
65
|
+
Promise.resolve(promise).then((value) => ({ ok: true, value })),
|
|
66
|
+
timeout,
|
|
67
|
+
]).finally(() => { if (timer) clearTimeout(timer); });
|
|
68
|
+
}
|
|
69
|
+
|
|
35
70
|
/** 条数夹取(纯函数,便于单测)。 */
|
|
36
71
|
export function clampSearchLimit(n) {
|
|
37
72
|
const v = Math.floor(Number(n));
|
|
@@ -71,6 +106,71 @@ export function searchDeepItems(memory, q, limit = SEARCH_DEEP_MAX) {
|
|
|
71
106
|
} catch { return []; }
|
|
72
107
|
}
|
|
73
108
|
|
|
109
|
+
/** 检索档本地原文层一次最多带出几轮(默认同 `SEARCH_HITS_DEFAULT`)。 */
|
|
110
|
+
export const SEARCH_LOCAL_TURNS_MAX = 50;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* 在**我们自己的库**里按内容搜原文轮(`dsh_turns_raw`)—— 检索档的**默认**原文来源。
|
|
114
|
+
*
|
|
115
|
+
* 为什么默认走这里(2026-10-05 夜 · 主人拍板方案 A):
|
|
116
|
+
* 宿主 `sessionQuery` 的每次搜索都会做一遍**全量对账**(`_observeStable` 里 list 两遍、
|
|
117
|
+
* 最多重试 2 轮),实测**每次都超过 20 秒**(重启后第一次也一样),而这边的
|
|
118
|
+
* 同一个查询只要 **2~45 毫秒**。快的设成默认、慢的降级成显式选项,是这一档的取舍。
|
|
119
|
+
* ⚠️ 代价**必须如实报出去**:库里这份原文是**残的** —— capture 侧只收"真人消息 +
|
|
120
|
+
* 有正文的回复",所以我自己的长回复**中段**搜不到。回执里要写明这一层是"库内原文层"。
|
|
121
|
+
*
|
|
122
|
+
* 与 `searchDeepItems` 同解的三条(不另立口径):多词 = **AND**;词里的 `% _ \` 先剥掉;
|
|
123
|
+
* 表不存在时**静默返回空**(检索面不该因为某张表缺席而整体失败)。
|
|
124
|
+
*
|
|
125
|
+
* @param {object} memory MemoryStore
|
|
126
|
+
* @param {string} q 查询词(空格分隔 = 同时满足)
|
|
127
|
+
* @param {number} limit 最多几轮
|
|
128
|
+
* @param {object} [opts] `{ selfId, callName, includeSelf }` —— `selfId` 是当前会话 id;
|
|
129
|
+
* **默认把它整条排除**(见下方注释),要看自己刚说的传 `includeSelf:true`(那时它排到最后并标 `isSelf`)
|
|
130
|
+
* @returns `{{rows:object[], selfExcluded:number}}` —— `selfExcluded` 是**因为"是本会话"而被排除的条数**,
|
|
131
|
+
* 调用方**必须把它报出去**:不说就会被读成"库里只有这些"(那是说假话)
|
|
132
|
+
*/
|
|
133
|
+
export function searchLocalTurns(memory, q, limit = SEARCH_HITS_DEFAULT, { selfId = '', callName = '主人', includeSelf = false } = {}) {
|
|
134
|
+
const db = memory?.db;
|
|
135
|
+
if (!db?.prepare) return { rows: [], selfExcluded: 0 };
|
|
136
|
+
const words = String(q || '').trim().split(/\s+/).map((w) => w.replace(/[%_\\]/g, '')).filter(Boolean);
|
|
137
|
+
if (!words.length) return { rows: [], selfExcluded: 0 };
|
|
138
|
+
const cap = Math.max(1, Math.min(SEARCH_LOCAL_TURNS_MAX, Number(limit) || SEARCH_HITS_DEFAULT));
|
|
139
|
+
let rows;
|
|
140
|
+
try {
|
|
141
|
+
const where = words.map(() => "text LIKE ? ESCAPE '\\'").join(' AND ');
|
|
142
|
+
rows = db.prepare(
|
|
143
|
+
`SELECT session_id, seq, role, ts, text FROM dsh_turns_raw
|
|
144
|
+
WHERE ${where} ORDER BY ts DESC LIMIT ?`,
|
|
145
|
+
).all(...words.map((w) => `%${w}%`), cap);
|
|
146
|
+
} catch { return { rows: [], selfExcluded: 0 }; }
|
|
147
|
+
const self = String(selfId || '');
|
|
148
|
+
const mapped = rows.map((r) => {
|
|
149
|
+
const id = String(r.session_id ?? '');
|
|
150
|
+
const role = String(r.role ?? '');
|
|
151
|
+
const isUser = role.startsWith('user');
|
|
152
|
+
// 命中位置附近截一段(而不是从头截):词可能出现在长回复的中间
|
|
153
|
+
const text = String(r.text ?? '');
|
|
154
|
+
const at = words.map((w) => text.indexOf(w)).filter((i) => i >= 0).sort((a, b) => a - b)[0] ?? 0;
|
|
155
|
+
const from = Math.max(0, at - 40);
|
|
156
|
+
return {
|
|
157
|
+
conv: id, seq: Number(r.seq) || 0, role,
|
|
158
|
+
time: r.ts ? localStamp(r.ts) : '',
|
|
159
|
+
who: isUser ? callName : '鱼姬',
|
|
160
|
+
snippet: oneLine(text.slice(from, from + SEARCH_SNIPPET_CHARS), SEARCH_SNIPPET_CHARS),
|
|
161
|
+
isSelf: Boolean(self) && id === self,
|
|
162
|
+
};
|
|
163
|
+
});
|
|
164
|
+
// ⚠️ **本会话默认排除**(2026-10-05 夜 · 主人拍板):它就在库里、而它的话又最新 ⇒ 任何词都能命中它,
|
|
165
|
+
// 把要回溯的历史**整页挤掉** —— 真机实测「检索」10 条里 9 条是本会话;光把它排到末尾只治了"占头部",
|
|
166
|
+
// 没治"占位"。而"我刚说过什么"**根本不需要搜**:它就在我的上下文里;
|
|
167
|
+
// 搜索这个动作的全部意义是回溯**我这里没有的**东西。要看自己刚说的传 `includeSelf:true`。
|
|
168
|
+
// ⇒ 排除的条数**如实报出去**(`selfExcluded`),不说就会被读成"库里只有这些"。
|
|
169
|
+
const kept = includeSelf ? mapped : mapped.filter((r) => !r.isSelf);
|
|
170
|
+
const selfExcluded = includeSelf ? 0 : mapped.length - kept.length;
|
|
171
|
+
return { rows: kept.sort((a, b) => Number(a.isSelf) - Number(b.isSelf)), selfExcluded };
|
|
172
|
+
}
|
|
173
|
+
|
|
74
174
|
/**
|
|
75
175
|
* 从 `listSessions()` 的记录里造「主人自己的会话」id 集(纯函数)。
|
|
76
176
|
* 拿不到 header 的**不放入** —— 宁少不滥,这是可见性过滤,不是统计。
|
|
@@ -123,8 +223,9 @@ export function visibleIdSet(records) {
|
|
|
123
223
|
* @param all 越界开关(D6:显式才生效)
|
|
124
224
|
* @returns `{ rows, skipped }` —— `skipped` 是**被范围挡掉**的条数,必须报出来(不静默丢)
|
|
125
225
|
*/
|
|
126
|
-
export function toSearchRows(hits, { visible = null, all = false, callName = '主人' } = {}) {
|
|
226
|
+
export function toSearchRows(hits, { visible = null, all = false, callName = '主人', selfId = '' } = {}) {
|
|
127
227
|
const rows = [];
|
|
228
|
+
const self = String(selfId || '');
|
|
128
229
|
let skipped = 0;
|
|
129
230
|
for (const h of hits || []) {
|
|
130
231
|
const header = h?.header ?? {};
|
|
@@ -140,8 +241,14 @@ export function toSearchRows(hits, { visible = null, all = false, callName = '
|
|
|
140
241
|
time: m.time ? localStamp(m.time) : '',
|
|
141
242
|
who: isUser ? callName : '鱼姬',
|
|
142
243
|
snippet: oneLine(m.snippet || '', SEARCH_SNIPPET_CHARS),
|
|
244
|
+
isSelf: Boolean(self) && String(id) === self,
|
|
143
245
|
});
|
|
144
246
|
}
|
|
247
|
+
// ⚠️ 本会话排到最后(2026-10-05 主人拍板 C):**当前会话自己也在索引里**,于是"搜什么都中今天" ——
|
|
248
|
+
// 任何词都能命中几十秒前刚打的那句话,而它排在头一条,把要回溯的历史全挤下去。
|
|
249
|
+
// **不删**它(有时确实要找自己刚才那句),只挪到末尾并标注「← 本会话」。
|
|
250
|
+
// `sort` 在 V8 上是**稳定**的 ⇒ 其余命中保持宿主的原序(相关性+新近),不打乱。
|
|
251
|
+
if (self) rows.sort((a, b) => Number(a.isSelf) - Number(b.isSelf));
|
|
145
252
|
// ⚠️ `degraded`(蓝队 2026-10-04 抓到 P3):宿主**没有** `listSessions` 时,`visible` 恒为 null
|
|
146
253
|
// ⇒ 范围限制**整条失效**(一条不挡),而回执却仍写着"已在主人自己的会话内" —— 那是**说假话**。
|
|
147
254
|
// 取舍(主代理 2026-10-04 定):**保持 fail-open 但如实标注** —— 完全禁用会让"按内容找会话"
|
|
@@ -171,6 +278,14 @@ export function renderSearch(v) {
|
|
|
171
278
|
? '⚠️ **没能限定范围** —— 宿主这次没给 `listSessions`,所以下面这份结果**可能含子代理或其它会话**,'
|
|
172
279
|
+ '不是"只搜主人自己的会话"(范围限制这条通道暂时失效,不是"库里只有这些")'
|
|
173
280
|
: '已在「**主人自己的会话**」内(默认范围)');
|
|
281
|
+
// ⚠️ **原文来源必须看得出来是哪一个**(2026-10-05 夜 · 主人拍板方案 A):
|
|
282
|
+
// 两条路覆盖面**不一样**,不说清就会被读成"库里只有这些"。
|
|
283
|
+
// · 本地(默认):库内原文层 —— 只含"真人消息 + 有正文的回复"(capture 侧就这么收的),
|
|
284
|
+
// 我自己的长回复**中段搜不到**;代价换来的是 **2~45 毫秒**(实测)。
|
|
285
|
+
// · 宿主(显式 hostSearch):覆盖全部事件,但每次**全量对账**、实测**每次 >20 秒**。
|
|
286
|
+
const layerNote = v.local
|
|
287
|
+
? '(原文来源:**本机库内原文层** —— 只含真人消息与有正文的回复,毫秒级;要全量检索请传 hostSearch=true)'
|
|
288
|
+
: '';
|
|
174
289
|
const hitRows = Array.isArray(v.rows) ? v.rows : [];
|
|
175
290
|
const deepRows = Array.isArray(v.deep) ? v.deep : [];
|
|
176
291
|
// 条目排在前:它是**结论层**(一句话说完),会话在后(要下钻才知道内容)。
|
|
@@ -186,22 +301,45 @@ export function renderSearch(v) {
|
|
|
186
301
|
}).join('\n') + '\n\n'
|
|
187
302
|
: '';
|
|
188
303
|
if (!hitRows.length && !deepRows.length) {
|
|
189
|
-
|
|
304
|
+
// 超时是**另一种结论**,不能说成"没找到" —— 前者该"重发或换窄词",后者该"换词",
|
|
305
|
+
// 而"库里没有"是第三种(最不该被误读成它)。
|
|
306
|
+
if (v.timedOut) {
|
|
307
|
+
return `查询「${v.q}」这次**没跑完**:宿主检索超过 ${Math.round((Number(v.timeoutMs) || SEARCH_TIMEOUT_MS) / 1000)} 秒没返回`
|
|
308
|
+
+ '(多半是它在补索引对账 —— 宿主侧的串行操作,插件打断不了它)。'
|
|
309
|
+
+ `\n条目层是本地库、毫秒级,它也没有命中 ⇒ 这次**两层都空**。`
|
|
310
|
+
+ '\n换个更窄的词(中文 2~4 字)重发一次,或稍后再试。'
|
|
311
|
+
+ `\n(范围:${scope})`;
|
|
312
|
+
}
|
|
313
|
+
return `按内容没找到(查询「${v.q}」· ${scope})${layerNote}。`
|
|
190
314
|
+ (v.skipped ? `\n(有 ${v.skipped} 条被范围挡掉 —— 要看它们得显式传 all=true。)\n` : '\n')
|
|
191
315
|
+ '换词试试:中文 **2~4 字**短词命中最好(`记忆树` / `血缘` / `注入面`);'
|
|
192
316
|
+ '**多个词用空格分隔 = 同时满足**(AND);**没有子串匹配**(`call` 搜不到 `recall`)。';
|
|
193
317
|
}
|
|
194
318
|
const rows = hitRows.map((r) => {
|
|
195
319
|
const at = [r.seq !== null && r.seq !== undefined ? `#${r.seq}` : '', r.time].filter(Boolean).join(' ');
|
|
196
|
-
|
|
320
|
+
// 「← 本会话」:它被排到了末尾(见 toSearchRows),标注是让模型知道**这条是刚发生的、不是历史** ——
|
|
321
|
+
// 否则它会把"我刚说的话"当成"主人当年说过的话"来引用。
|
|
322
|
+
const selfTag = r.isSelf ? ' **← 本会话(刚发生的)**' : '';
|
|
323
|
+
return `· ${r.conv}${selfTag} · ${at} ${r.who}\n ${r.snippet}`;
|
|
197
324
|
});
|
|
198
325
|
const head = deepRows.length && rows.length
|
|
199
|
-
? `按内容命中 **${deepRows.length} 条深层库条目** + ${rows.length}
|
|
326
|
+
? `按内容命中 **${deepRows.length} 条深层库条目** + ${rows.length} 轮原文(查询「${v.q}」· ${scope})${layerNote}:`
|
|
200
327
|
: deepRows.length
|
|
201
|
-
? `按内容命中 **${deepRows.length} 条深层库条目**(查询「${v.q}」· ${scope}):`
|
|
202
|
-
: `按内容找到 ${rows.length}
|
|
203
|
-
const convBlock = rows.length ?
|
|
204
|
-
|
|
328
|
+
? `按内容命中 **${deepRows.length} 条深层库条目**(查询「${v.q}」· ${scope})${layerNote}:`
|
|
329
|
+
: `按内容找到 ${rows.length} 轮原文(查询「${v.q}」· ${scope})${layerNote}:`;
|
|
330
|
+
const convBlock = rows.length ? `${v.local ? '【库内原文层】' : '【会话原文层】'}\n${rows.join('\n')}` : '';
|
|
331
|
+
// ⚠️ 超时**不算失败**:条目层已经拿到了(本地库),必须照常交出去。这一行是"如实说明少了一半",
|
|
332
|
+
// 而不是"报错" —— 两者的下一步动作不同(前者重发/换窄词,后者换通道)。
|
|
333
|
+
const timeoutNote = v.timedOut
|
|
334
|
+
? `\n\n⚠️ **原文层超时**(等满 ${Math.round((Number(v.timeoutMs) || SEARCH_TIMEOUT_MS) / 1000)} 秒没返回,多半是宿主在补索引对账):`
|
|
335
|
+
+ (deepRows.length ? '**条目层是本地库,不受影响,就在上面** —— 先把它们用起来。' : '这次连条目也没命中。')
|
|
336
|
+
+ '要原文就**重发同一条**(多数第二次会快,对账已经补过了),或换更窄的词。'
|
|
337
|
+
: '';
|
|
338
|
+
// ⚠️ 排除本会话要**说出来**:不说就会被读成"库里只有这些"(那是说假话)。
|
|
339
|
+
const selfNote = Number(v.selfExcluded) > 0
|
|
340
|
+
? `\n(另有 ${v.selfExcluded} 轮命中属于**当前会话自己**,已按默认排除 —— 要看自己刚说的传 \`includeSelf=true\`。)`
|
|
341
|
+
: '';
|
|
342
|
+
return `${head}\n${deepBlock}${convBlock}${timeoutNote}${selfNote}`
|
|
205
343
|
+ (v.skipped ? `\n(${v.skipped} 条被范围挡掉 —— 要看它们得显式传 all=true。)` : '')
|
|
206
344
|
+ (v.hasMore ? '\n(这一页满了,**还有更多** —— 把 limit 调大,或换个更窄的词。)' : '')
|
|
207
345
|
+ (deepRows.length
|
package/lib/host/surgery.js
CHANGED
|
@@ -12,7 +12,10 @@
|
|
|
12
12
|
// "没有 UA"正是那条路的指纹(详见下面 isLocalSurgeryRequest 的注释)。**不许在本文件里另写一份 IP 判断。**
|
|
13
13
|
// 1.2 每启动票据 = $DSH_HOME/cache/dsh-ling/surgery.ticket(32 字节随机),只对**本机直连**(无代理头且带 UA)的请求下发,
|
|
14
14
|
// 每启动轮换(旧票即废);客户端首次手术前取一次并缓存,随后带 `x-dsh-ling-surgery: <ticket>`。
|
|
15
|
-
// 1.3 门下沉为一处 requireSurgery(headers, body, opts),覆盖全部
|
|
15
|
+
// 1.3 门下沉为一处 requireSurgery(headers, body, opts),覆盖全部 **14** 条人格/规矩/习惯写面。
|
|
16
|
+
// ⚠️ 条数在 2026-10-05 夜由 12 订正为 14(B-5):下方的覆盖面清单**逐条列了 14 行**,而本行与
|
|
17
|
+
// `api.js` 的门下沉注释一直写"12" ⇒ 两处都在说谎(清单才是逐条列出的权威)。
|
|
18
|
+
// 口径:**条数一律以清单行数为准**,改清单时必须同批改这里与 api.js 那一处。
|
|
16
19
|
// 1.4 事件留痕 = $DSH_HOME/logs/surgery-YYYY-MM.jsonl,append-only + 整行哈希链(改一行即断链)。
|
|
17
20
|
// 1.5 逃生开关:DSH_LING_GUARD=off 或 settings.guard.enforce === false ⇒ 整体放行(绝不把主人锁在门外)。
|
|
18
21
|
//
|
package/lib/host/tools.js
CHANGED
|
@@ -33,7 +33,7 @@ import { listSessionFiles, readSessionEvents, dialogTurns, localStamp, oneLine,
|
|
|
33
33
|
import { userTitleForMode } from './persona.js';
|
|
34
34
|
// 1.6 §7 步 1(2026-10-04 主人拍 D1-D6 后开工):recall 的**检索档** —— 按内容找会话,接宿主检索面(不自建)。
|
|
35
35
|
// 为什么不自建、中文怎么查、范围怎么限:见 recall-search.js 顶部注释(含本机实测口径)。
|
|
36
|
-
import { clampSearchLimit, visibleIdSet, toSearchRows, renderSearch, searchDeepItems, adoptDeepItems } from './recall-search.js';
|
|
36
|
+
import { clampSearchLimit, visibleIdSet, toSearchRows, renderSearch, searchDeepItems, searchLocalTurns, adoptDeepItems, withTimeout, SEARCH_TIMEOUT_MS } from './recall-search.js';
|
|
37
37
|
|
|
38
38
|
export const TOOL_RULE_ADD = 'rule_add';
|
|
39
39
|
export const TOOL_HABIT_PROPOSE = 'habit_propose';
|
|
@@ -505,8 +505,8 @@ function renderBranchEdit(v) {
|
|
|
505
505
|
return `把${label(v.id, v.idName)}的枝系数设为 ${got}。`
|
|
506
506
|
+ (clamped ? `\n· 你要的是 ${asked}:超出有效区间 [0, 2],库里存的是夹取后的 ${got}。` : '')
|
|
507
507
|
+ (v.applied === false
|
|
508
|
-
? `\n· **它当前不参与检索打分**(${v.note || '
|
|
509
|
-
: '')
|
|
508
|
+
? `\n· **它当前不参与检索打分**(${v.note || '预留字段'})—— 调了不会改变召回排序。`
|
|
509
|
+
: `\n· **它会真的改变召回排序**(${v.note || '已生效'})—— 调低某条枝,从这条枝的会话里提炼出的结论在记忆开场里往后排(0 = 排到最后,**不是禁用**)。`)
|
|
510
510
|
+ TALLY_LINE;
|
|
511
511
|
}
|
|
512
512
|
const what = {
|
|
@@ -560,7 +560,7 @@ export const BRANCH_EDIT_SPEC = {
|
|
|
560
560
|
+ "不默认挂主干;要挂回主干请显式传 'trunk';内容零变化)/ link(连边,需 id+to,"
|
|
561
561
|
+ '可选 kind 说明关系:related 默认 / prerequisite 前置 / contrast 对照 / example 例证 / supersede 取代)/ '
|
|
562
562
|
+ 'unlink(断边,需 id+to)/ weight(调枝系数,需 id+weightScale,复盘时用;'
|
|
563
|
-
+ '有效区间 [0,2]
|
|
563
|
+
+ '有效区间 [0,2],超出会被夹取;**该字段参与检索打分**(1.6.2 起,见 DESIGN.md 的血缘加权那一节))/ '
|
|
564
564
|
+ 'delete(删枝,需 id;非空枝要 force —— force 会把它的子枝与会话归属**回退到主干**,'
|
|
565
565
|
+ '原归属不可还原)。所有改动都记在「鱼姬」名下,'
|
|
566
566
|
+ '并出现在他下一次开口前的注入里。',
|
|
@@ -812,7 +812,7 @@ export function checkRecall(args = {}) {
|
|
|
812
812
|
// ①档(检索):`q` 与 `conv` **互斥** —— 两个一起给会让"找会话"与"读某个会话"两种意图打架。
|
|
813
813
|
// 直接拒绝并让回执说清楚,不猜(D-T07 同一条纪律:宁可让调用方再调一次)。
|
|
814
814
|
if (q && conv) return { ok: false, reason: 'q-xor-conv' };
|
|
815
|
-
if (q) return { ok: true, action: 'search', q, all: args?.all === true, limit: clampSearchLimit(args?.limit) };
|
|
815
|
+
if (q) return { ok: true, action: 'search', q, all: args?.all === true, hostSearch: args?.hostSearch === true, includeSelf: args?.includeSelf === true, limit: clampSearchLimit(args?.limit) };
|
|
816
816
|
if (!conv && hasRange) return { ok: false, reason: 'conv-required' };
|
|
817
817
|
if (!conv) {
|
|
818
818
|
const n = Math.floor(Number(args?.limit));
|
|
@@ -925,9 +925,14 @@ export const RECALL_SPEC = {
|
|
|
925
925
|
+ '②不给 conv 也不给 q ⇒ 列出磁盘上有原文的会话(最近活动在前);'
|
|
926
926
|
+ '③给 conv ⇒ 该会话的**对话目录**(每轮一行:序号/时间/角色/前 70 字),**不给正文**;'
|
|
927
927
|
+ '④给 conv+from/to ⇒ 那几轮的**原文全文**(序号取自目录里的 #号)。'
|
|
928
|
+
+ '①档**默认搜本机库内原文层**(毫秒级;只含真人消息与有正文的回复 ⇒ 我自己的长回复**中段搜不到**),'
|
|
929
|
+
+ '要全量(覆盖全部事件、含子代理会话)就传 hostSearch=true 走宿主(慢:实测每次 >20 秒,超时会**如实说明**而不是假装没找到)。'
|
|
928
930
|
+ '检索口径(本机实测,别猜):中文 **2~4 字短词**命中最好(`记忆树` / `血缘` / `注入面`);'
|
|
929
931
|
+ '**多个词用空格分隔 = 同时满足**(AND);**没有子串匹配**(`call` 搜不到 `recall`)。'
|
|
930
932
|
+ '默认**只搜「主人自己的会话」**(顶层 + 分叉);要看全部(含子代理)得显式 all=true。'
|
|
933
|
+
+ '**条目层先给、原文层限时**:深层库条目是本地库(毫秒级),**永远先返回**;会话原文走宿主索引,'
|
|
934
|
+
+ `超过 ${Math.round(SEARCH_TIMEOUT_MS / 1000)} 秒没返回就放弃那一半、并如实说明 —— 免得一次检索把整步卡死(那是无法中断的等待)。`
|
|
935
|
+
+ '**当前会话自己**如果命中,会标注「← 本会话(刚发生的)」并排在最后 —— 别把它当"当年说过的话"引用。'
|
|
931
936
|
+ '**工具不会替你挑"最重要的一段"** —— 先看命中片段或目录,再自己指哪几轮。'
|
|
932
937
|
+ '只返回**对话**(主人对我说的话 + 我的回复);工具调用/步骤/折叠等过程事件已剥离;'
|
|
933
938
|
+ '注入类消息(运行时上下文、技能目录、任务标记)在目录里**折叠成一行、不占序号**。',
|
|
@@ -935,11 +940,13 @@ export const RECALL_SPEC = {
|
|
|
935
940
|
type: 'object',
|
|
936
941
|
properties: {
|
|
937
942
|
q: { type: 'string', description: '①档:按内容找会话的查询词。中文 2~4 字最好;多词用空格分隔(= 同时满足);没有子串匹配。与 conv 互斥。' },
|
|
938
|
-
all: { type: 'boolean', description: '①档:是否越界到非主人的会话(子代理等)。默认 false =
|
|
943
|
+
all: { type: 'boolean', description: '①档:是否越界到非主人的会话(子代理等)。默认 false = 只搜主人自己的会话。**只在 hostSearch=true 时有意义**。' },
|
|
944
|
+
hostSearch: { type: 'boolean', description: '①档:**是否走宿主全文检索面**(默认 false)。默认搜**本机库内原文层** —— 毫秒级,但只含真人消息与有正文的回复(我自己的长回复中段搜不到);传 true 则走宿主(覆盖全部事件,但实测每次 >20 秒,超时会如实告知)。' },
|
|
945
|
+
includeSelf: { type: 'boolean', description: '①档:**是否包含当前会话自己**(默认 false = 排除)。默认排除是因为"我刚说过什么"就在我的上下文里、不需要搜,而它的话最新 ⇒ 任何词都能命中它、把要回溯的历史挤掉。要查自己刚说的那句才传 true(那时它排到最后并标注)。**只在默认(本地)路生效**。' },
|
|
939
946
|
conv: { type: 'string', description: '③④档:会话 id(取自 ①/② 档的清单);也接受裸 uuid' },
|
|
940
947
|
from: { type: 'number', description: '④档:起始轮次序号(1-based,**取自目录里的 #号**);缺省 1' },
|
|
941
948
|
to: { type: 'number', description: '④档:结束轮次序号(**含**);缺省 from+9' },
|
|
942
|
-
limit: { type: 'number', description: '①档一页几个会话(默认
|
|
949
|
+
limit: { type: 'number', description: '①档一页几个会话(默认 5,上限 50 — 默认值刻意小:原文层一多,上面的条目层就被挤没了)/ ②档列多少个会话(默认 40,上限 200)' },
|
|
943
950
|
},
|
|
944
951
|
required: [],
|
|
945
952
|
},
|
|
@@ -1262,7 +1269,22 @@ export function registerLingTools(ctx, { gate, memory, settings } = {}) {
|
|
|
1262
1269
|
// 纯逻辑全在 recall-search.js(可单测),这里只做接线与降级。
|
|
1263
1270
|
// 降级口径很重要:服务不在 ⇒ 明说"**通道没接上**",而不是让模型读成"库里没有"——
|
|
1264
1271
|
// 这两件事该采取的动作完全不同(前者换路,后者换词),混在一起就是骗自己。
|
|
1265
|
-
const runSearch = async (q, { all, limit, callName }) => {
|
|
1272
|
+
const runSearch = async (q, { all, limit, callName, selfId = '', hostSearch = false, includeSelf = false }) => {
|
|
1273
|
+
// ⚠️ **本地优先**(2026-10-05 夜 · 主人拍板方案 A):条目层与原文层都在**我们自己的 SQLite** 里,
|
|
1274
|
+
// 实测同一个查询 **2~45 毫秒**;而宿主 `sessionQuery` 每次搜索都要做一遍全量对账,
|
|
1275
|
+
// 实测**每次都超过 20 秒**(重启后第一次也一样)⇒ 快的设成默认、慢的降级成显式选项。
|
|
1276
|
+
// 代价是覆盖窄(库内原文只含"真人消息 + 有正文的回复")⇒ 回执里如实标注,见 `layerNote`。
|
|
1277
|
+
const deep = searchDeepItems(memory, q);
|
|
1278
|
+
if (!hostSearch) {
|
|
1279
|
+
// ⚠️ 本会话**默认整条排除**(主人 2026-10-05 夜拍板):它的话最新 ⇒ 任何词都能命中它,
|
|
1280
|
+
// 把要回溯的历史整页挤掉(真机实测「检索」10 条里 9 条是本会话)。要看自己刚说的传 `includeSelf:true`。
|
|
1281
|
+
const loc = searchLocalTurns(memory, q, limit, { selfId, callName, includeSelf });
|
|
1282
|
+
return {
|
|
1283
|
+
ok: true, action: 'search', q, all, rows: loc.rows, skipped: 0, deep, degraded: false,
|
|
1284
|
+
local: true, hasMore: loc.rows.length >= limit, timedOut: false, selfId,
|
|
1285
|
+
selfExcluded: loc.selfExcluded,
|
|
1286
|
+
};
|
|
1287
|
+
}
|
|
1266
1288
|
let svc = null;
|
|
1267
1289
|
try {
|
|
1268
1290
|
svc = typeof ctx?.get === 'function' ? ctx.get('sessionQuery') : (ctx?.sessionQuery ?? null);
|
|
@@ -1276,9 +1298,12 @@ export function registerLingTools(ctx, { gate, memory, settings } = {}) {
|
|
|
1276
1298
|
try {
|
|
1277
1299
|
let visible = null;
|
|
1278
1300
|
if (!all && typeof svc.listSessions === 'function') {
|
|
1279
|
-
|
|
1301
|
+
// `listSessions` 同样套超时:它也要过索引(实测同一条通道会一起卡住)。
|
|
1302
|
+
const lv = await withTimeout(svc.listSessions(), SEARCH_TIMEOUT_MS, 'listSessions');
|
|
1303
|
+
if (lv.ok) visible = visibleIdSet(lv.value);
|
|
1304
|
+
else return { ok: true, action: 'search', q, all, rows: [], skipped: 0, deep, degraded: true, hasMore: false, timedOut: true, timeoutMs: lv.ms, selfId };
|
|
1280
1305
|
}
|
|
1281
|
-
const
|
|
1306
|
+
const res = await withTimeout(svc.searchSessions({
|
|
1282
1307
|
query: q,
|
|
1283
1308
|
limit,
|
|
1284
1309
|
// 与官方服务端搜索同一套过滤(dsh-api-session-controller\lib\index.js:1953):
|
|
@@ -1287,24 +1312,33 @@ export function registerLingTools(ctx, { gate, memory, settings } = {}) {
|
|
|
1287
1312
|
{ kind: 'type', values: ['user/message', 'assistant/message'] },
|
|
1288
1313
|
{ kind: 'surface', values: ['current'] },
|
|
1289
1314
|
],
|
|
1290
|
-
});
|
|
1291
|
-
|
|
1315
|
+
}), SEARCH_TIMEOUT_MS, 'searchSessions');
|
|
1316
|
+
// 超时 ⇒ **不是失败**:条目层照常交出去,只把"原文那半没跑完"如实标出来。
|
|
1317
|
+
if (!res.ok) {
|
|
1318
|
+
return { ok: true, action: 'search', q, all, rows: [], skipped: 0, deep, degraded: false, hasMore: false, timedOut: true, timeoutMs: res.ms, selfId };
|
|
1319
|
+
}
|
|
1320
|
+
const page = res.value;
|
|
1321
|
+
const { rows, skipped, degraded } = toSearchRows(page?.items, { visible, all, callName, selfId });
|
|
1292
1322
|
// 1.6 §7 步 4(2026-10-04):**同一次检索也查深层库条目** —— 主人的问句多半是
|
|
1293
1323
|
// 「我说过什么关于 X 的」,而答案更可能在**结论层**(deep_item)而不是某段原始对话里。
|
|
1294
1324
|
// 两者一起给,「找条目」与「找会话」一次到位。条目不受 `all` 影响(深层库本来就是
|
|
1295
1325
|
// 从主人自己的会话里提炼的;它的范围控制在提炼那一侧,不在这里)。
|
|
1296
|
-
|
|
1297
|
-
return { ok: true, action: 'search', q, all, rows, skipped, deep, degraded, hasMore: Boolean(page?.nextCursor) };
|
|
1326
|
+
return { ok: true, action: 'search', q, all, rows, skipped, deep, degraded, hasMore: Boolean(page?.nextCursor), timedOut: false, selfId };
|
|
1298
1327
|
} catch (e) {
|
|
1299
1328
|
return { ok: false, reason: 'search-failed', message: String(e?.message ?? e).slice(0, 200) };
|
|
1300
1329
|
}
|
|
1301
1330
|
};
|
|
1302
1331
|
const recallSpec = {
|
|
1303
1332
|
...RECALL_SPEC,
|
|
1304
|
-
|
|
1333
|
+
// ⚠️ handler **必须收第二个参数 `exec`**(2026-10-05 实测抓到):`guard` 的签名是
|
|
1334
|
+
// `(fn) => async (args, exec) => …`;只写 `(args) => …` 而体内用 `sessionIdOf(exec)` 时,
|
|
1335
|
+
// `exec` 是**未定义变量** ⇒ 严格模式下抛 ReferenceError ⇒ 「按内容找会话」整条路直接失败。
|
|
1336
|
+
// 教训:凡在 handler 里用到 `exec`,先回来看这一行有没有接住它 —— 这类错**只在真机跑才现形**,
|
|
1337
|
+
// 44 套测试当时全绿(它们测的是纯函数 `checkRecall`,不经过这个 handler)。
|
|
1338
|
+
execute: guard(async (args, exec) => {
|
|
1305
1339
|
const c = checkRecall(args);
|
|
1306
1340
|
if (!c.ok) return c;
|
|
1307
|
-
if (c.action === 'search') return runSearch(c.q, { all: c.all, limit: c.limit, callName: recallCallName });
|
|
1341
|
+
if (c.action === 'search') return runSearch(c.q, { all: c.all, limit: c.limit, callName: recallCallName, selfId: sessionIdOf(exec), hostSearch: c.hostSearch, includeSelf: c.includeSelf });
|
|
1308
1342
|
if (c.action === 'map') {
|
|
1309
1343
|
const r = listSessionFiles({ limit: c.limit });
|
|
1310
1344
|
if (!r.ok) return { ok: false, reason: r.reason, message: r.message };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nightpainters/dsh-ling",
|
|
3
|
-
"version": "1.6.
|
|
4
|
-
"description": "器灵 ·
|
|
3
|
+
"version": "1.6.2",
|
|
4
|
+
"description": "器灵 · 跑在你自己电脑上的记忆与人格助手插件:以「人在循环」的双向复盘为核心回路,人格由共同记忆长出并有定型锁,记忆树可归并、可回溯、每次改动都留痕",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
7
7
|
"exports": {
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"tools/apply-access-log-patch.mjs",
|
|
15
15
|
"cordis.patch.yml",
|
|
16
16
|
"README.md",
|
|
17
|
+
"PHILOSOPHY.md",
|
|
17
18
|
"CHANGELOG.md",
|
|
18
19
|
"DESIGN.md",
|
|
19
20
|
"ACCESS-DESIGN.md",
|