sage-mem 1.0.2 → 1.2.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/README.md +10 -2
- package/README.zh.md +10 -2
- package/lib/assembly/autodream-mount.js +24 -0
- package/lib/assembly/index.js +13 -0
- package/lib/assembly/observe-mount.js +37 -0
- package/lib/assembly/tools-mount.js +44 -0
- package/lib/assembly/write-gate-mount.js +92 -0
- package/lib/{autodream-audit.js → autodream/audit.js} +2 -2
- package/lib/autodream/engine.js +2 -2
- package/lib/autodream/llm-loop.js +1 -1
- package/lib/autodream/report.js +1 -1
- package/lib/{autodream-tools.js → autodream/tools.js} +8 -8
- package/lib/client.js +93 -16
- package/lib/index.js +43 -720
- package/lib/memory/archive.js +102 -1
- package/lib/memory/write-gate.js +1 -1
- package/lib/recall/compose.js +160 -0
- package/lib/recall/debug.js +23 -0
- package/lib/recall/index.js +15 -0
- package/lib/recall/injector.js +181 -0
- package/lib/recall/select.js +138 -0
- package/lib/recall/state.js +46 -0
- package/lib/recall/text.js +14 -0
- package/lib/settings.js +98 -0
- package/lib/typert.host.js +19 -0
- package/package.json +1 -1
- /package/lib/{autodream-prompt.js → autodream/prompt.js} +0 -0
package/lib/memory/archive.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* 就是临时目录),拿模块级常量会写错地方 —— 那是会碰到真实记忆的危险错误。
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import { ARCHIVE_DIR_NAME, addArchiveMeta, inspectDir, inspectMemoryEntry, localStamp, stripArchiveMeta } from './scan.js'
|
|
18
|
+
import { ARCHIVE_DIR_NAME, addArchiveMeta, inspectDir, inspectMemoryEntry, localStamp, readArchivedMeta, stripArchiveMeta } from './scan.js'
|
|
19
19
|
import { isReserved, nameKey, safeName } from './naming.js'
|
|
20
20
|
import { writeFileAtomic, writeFileExclusive } from './util.js'
|
|
21
21
|
import { isLocked } from './lock.js'
|
|
@@ -290,3 +290,104 @@ export async function restoreMemory(memoryDir, name) {
|
|
|
290
290
|
...(indexError ? { indexError } : {}),
|
|
291
291
|
}
|
|
292
292
|
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* 从归档区**彻底删除**一条记忆(不可恢复)。
|
|
296
|
+
*
|
|
297
|
+
* 为什么不复用 `deleteFile`:归档的承诺是「只移不删」,活记忆的 `deleteFile` 承诺的
|
|
298
|
+
* 是「删掉活的这份」。两者混在一个名字下,用户就永远分不清自己按的是哪一个;
|
|
299
|
+
* 而这个操作**没有回收站**。
|
|
300
|
+
*
|
|
301
|
+
* 三道判据,缺一不可:
|
|
302
|
+
* 1. **归档区的目录闸门**:它若被换成指向外部的联接,删掉的是别人的文件;
|
|
303
|
+
* 2. **条目闸门**:链接 / 硬链接指向的可能是记忆目录之外的内容,删它等于替别人删文件;
|
|
304
|
+
* 3. **代次(`archivedAt`)**:名字不是身份。用户从列表看到确认框、到请求到达之间,
|
|
305
|
+
* 这条完全可能被恢复回顶层、又被另一版同名记忆归档进来 —— 按名删掉的就不再是他
|
|
306
|
+
* 确认过的那一份。所以调用方必须交回它当时看见的 `archived_at`,对不上就拒绝。
|
|
307
|
+
* 老数据没有留痕时两边都是空串,仍然一致(那是「同一份、只是没有时间戳」)。
|
|
308
|
+
*
|
|
309
|
+
* ⚠️ 删除本身用 **`rename` 到私有名 + `rm` 私有名**:`inspect` 与 `unlink` 之间隔着
|
|
310
|
+
* 读文件,直接 `unlink(full)` 认的是**名字**而不是我们核对过的那份实物。`rename` 是原子的,
|
|
311
|
+
* 之后删的一定是刚移走的那一个 inode。删失败时把名字 `link()` 还回去 —— 绝不让它
|
|
312
|
+
* 以 `.deleting-` 的私有名留在盘上(扫描不认那个名字,等于文件凭空消失)。
|
|
313
|
+
*
|
|
314
|
+
* @param {string} memoryDir — 记忆根目录(调用方给,不许拿模块常量)
|
|
315
|
+
* @param {string} name — 归档区里的文件名
|
|
316
|
+
* @param {string} expectedArchivedAt — 调用方看见的 `archived_at`(没有留痕时传空串)
|
|
317
|
+
* @returns {Promise<{ok:true,file:string}|{ok:false,code:string,error:string}>}
|
|
318
|
+
*/
|
|
319
|
+
export async function deleteArchivedMemory(memoryDir, name, expectedArchivedAt = '') {
|
|
320
|
+
const gate = checkName(name, 'delete')
|
|
321
|
+
if (!gate.ok) return gate
|
|
322
|
+
const safe = gate.safe
|
|
323
|
+
const dirGate = await inspectDir(archiveDirOf(memoryDir))
|
|
324
|
+
if (!dirGate.ok) {
|
|
325
|
+
return {
|
|
326
|
+
ok: false,
|
|
327
|
+
code: dirGate.code === 'missing' ? 'not-archived' : dirGate.code,
|
|
328
|
+
error: `sage-mem: not archived: ${safe}(${dirGate.error})`,
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
const full = join(archiveDirOf(memoryDir), safe)
|
|
332
|
+
const srcGate = await inspectMemoryEntry(full, { limitBytes: null })
|
|
333
|
+
if (!srcGate.ok) {
|
|
334
|
+
return {
|
|
335
|
+
ok: false,
|
|
336
|
+
code: srcGate.code === 'missing' ? 'not-archived' : srcGate.code,
|
|
337
|
+
error: `sage-mem: not archived: ${safe}(${srcGate.error})`,
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
// 读全文只为取留痕:代次判据必须来自**盘上的那份**,不能信调用方一路传下来的展示值。
|
|
341
|
+
const onDisk = readArchivedMeta(await readFile(full, 'utf8'))
|
|
342
|
+
const expected = String(expectedArchivedAt ?? '')
|
|
343
|
+
if (expected !== onDisk.archivedAt) {
|
|
344
|
+
return {
|
|
345
|
+
ok: false,
|
|
346
|
+
code: 'changed',
|
|
347
|
+
error:
|
|
348
|
+
`sage-mem: ${safe} 在确认之后被换成了另一版(界面上是「${expected || '时间未知'}」,` +
|
|
349
|
+
`盘上是「${onDisk.archivedAt || '时间未知'}」),本次一个字节都没删 —— 请刷新列表再看一眼。`,
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
const taken = join(archiveDirOf(memoryDir), `.${safe}.deleting-${randomUUID().replace(/-/g, '')}`)
|
|
353
|
+
const moveErr = await rename(full, taken).then(
|
|
354
|
+
() => null,
|
|
355
|
+
(err) => err,
|
|
356
|
+
)
|
|
357
|
+
if (moveErr) {
|
|
358
|
+
if (moveErr?.code === 'ENOENT') {
|
|
359
|
+
return { ok: false, code: 'not-archived', error: `sage-mem: not archived: ${safe}(删除途中它从归档区消失了)` }
|
|
360
|
+
}
|
|
361
|
+
return {
|
|
362
|
+
ok: false,
|
|
363
|
+
code: 'move-failed',
|
|
364
|
+
error: `sage-mem: 删除 ${safe} 时移不动它(${moveErr?.code ?? moveErr}:${moveErr?.message ?? moveErr})。文件仍在 archive/ 里、一个字节没动 —— 可稍后重试(若是被其它程序占用,关掉它再来)。`,
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
const delErr = await rm(taken, { force: true }).then(
|
|
368
|
+
() => null,
|
|
369
|
+
(err) => err,
|
|
370
|
+
)
|
|
371
|
+
if (delErr) {
|
|
372
|
+
const restored = await link(taken, full).then(
|
|
373
|
+
() => true,
|
|
374
|
+
() => false,
|
|
375
|
+
)
|
|
376
|
+
if (!restored) {
|
|
377
|
+
return {
|
|
378
|
+
ok: false,
|
|
379
|
+
code: 'delete-failed',
|
|
380
|
+
error:
|
|
381
|
+
`sage-mem: 删除 ${safe} 失败(${delErr?.code ?? delErr}),且归还原名时被占用 —— ` +
|
|
382
|
+
`**内容仍保留在 archive/${basename(taken)}**,请手工改回 ${safe} 再试。`,
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
await rm(taken, { force: true }).catch(() => {})
|
|
386
|
+
return {
|
|
387
|
+
ok: false,
|
|
388
|
+
code: 'delete-failed',
|
|
389
|
+
error: `sage-mem: 删除 ${safe} 失败(${delErr?.code ?? delErr}:${delErr?.message ?? delErr}),文件已还回 archive/${safe},没有丢。`,
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return { ok: true, file: safe }
|
|
393
|
+
}
|
package/lib/memory/write-gate.js
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
* 临时文件;监听器不需要声明 `inject` 也能收到,与 sage-mem 主插件的处境一致)。
|
|
27
27
|
* - **插件自己调 `ctx.fs.writeText` / `editText` 时一次都不派发**(实测 0)。所以 autodream
|
|
28
28
|
* 那类**插件内部写入不经过这里**,本模块也不假装能兜底它 —— 那条路由调用方自己传
|
|
29
|
-
* `expected`(`lib/autodream
|
|
29
|
+
* `expected`(`lib/autodream/tools.js` 就是这么做的)。
|
|
30
30
|
*
|
|
31
31
|
* 由此两条硬结论:
|
|
32
32
|
*
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 注入链的**渲染与预算**阶段(1.2.0 从 `lib/index.js` 原样搬出)。
|
|
3
|
+
*
|
|
4
|
+
* 三段,每段都能单独测(`test/recall-unit-test.mjs`):
|
|
5
|
+
* - `buildCandidates`:只负责「每条候选长什么样」;
|
|
6
|
+
* - `composeDelivery`:只负责「在正文预算内该给哪几条」(`decideDelivery` 是纯函数);
|
|
7
|
+
* - `composeStatus`:只负责「缺口卡 / 检查点句柄该不该说、还能不能说」。
|
|
8
|
+
*
|
|
9
|
+
* 两件事分开是 v0.9.17 的教训:预算、渲染、去重挤在一个循环里时,谁也说不清
|
|
10
|
+
* 某条为什么没进去。
|
|
11
|
+
*/
|
|
12
|
+
import { CONTENT_BUDGET, MAX_CHARS_PER_FILE, STALE_DAYS, STATUS_RESERVE } from '../memory/config.js'
|
|
13
|
+
import { ageDays } from '../memory/retrieval.js'
|
|
14
|
+
import { decideDelivery } from '../memory/deliver.js'
|
|
15
|
+
import { renderStatusCard } from '../memory/status.js'
|
|
16
|
+
import { recordAccess } from '../memory/access.js'
|
|
17
|
+
import { dedupeByFile } from './select.js'
|
|
18
|
+
|
|
19
|
+
/** 把候选文件渲染成「要不要端上去」的条目(不含预算决策)。 */
|
|
20
|
+
export function buildCandidates({ picked, bestChunk, nowMs = Date.now() }) {
|
|
21
|
+
const candidates = []
|
|
22
|
+
for (const f of picked) {
|
|
23
|
+
// **baseline 不吃段落替换**:它的语义是「这条记忆每场会话都该完整在场」
|
|
24
|
+
// (你是谁、待办清单)。一旦用户的问句碰巧命中它,替换成单个段落就等于
|
|
25
|
+
// 把「完整在场」偷偷降级成「命中在场」—— 那正是 baseline 要防的事。
|
|
26
|
+
const chunk = f.baseline ? null : bestChunk.get(f.file)
|
|
27
|
+
// 命中段落优先;没有命中(baseline、或 query 太短没做正文检索)才回到「文件开头」。
|
|
28
|
+
// 旧写法对**任何**问题都端同一段开头,等于把「命中在哪」这个信息扔掉了。
|
|
29
|
+
const raw = chunk ? chunk.text : f.content
|
|
30
|
+
const body = raw.length > MAX_CHARS_PER_FILE
|
|
31
|
+
? raw.slice(0, MAX_CHARS_PER_FILE) + '\n…(截断)'
|
|
32
|
+
: raw
|
|
33
|
+
const days = ageDays(f, nowMs)
|
|
34
|
+
const stamp = days !== null && days >= STALE_DAYS ? `(保存于 ${days} 天前)` : ''
|
|
35
|
+
// 段落命中时补两样东西:
|
|
36
|
+
// - 行号:模型据此能直接用文件工具跳过去核对来源,「引用可核对」靠它成立;
|
|
37
|
+
// - 摘要:段落本身不含 frontmatter,补回「这一段是从哪条记忆里切出来的」。
|
|
38
|
+
const loc = chunk ? `:${chunk.startLine}-${chunk.endLine}` : ''
|
|
39
|
+
const brief = chunk && f.description ? ` · ${f.description.slice(0, 80)}` : ''
|
|
40
|
+
candidates.push({
|
|
41
|
+
file: f.file,
|
|
42
|
+
id: chunk?.id ?? `${f.file}#head`,
|
|
43
|
+
revision: chunk?.revision ?? '',
|
|
44
|
+
score: f.score ?? 0,
|
|
45
|
+
baseline: f.baseline === true,
|
|
46
|
+
lines: chunk ? [chunk.startLine, chunk.endLine] : null,
|
|
47
|
+
title: f.description || '',
|
|
48
|
+
text: `### ${f.file}${loc}${stamp}${brief}\n${body}`,
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
return candidates
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 展示层决策 + 记账。
|
|
56
|
+
*
|
|
57
|
+
* 按**实际进正文的字节**记账,而不是 `delivery.totals` —— 后者含指针的额度,
|
|
58
|
+
* 而指针没有真的进上下文。两本账要对得上,就不能把没花出去的钱记进去。
|
|
59
|
+
*/
|
|
60
|
+
export function composeDelivery({ candidates, text, intent, contentBytes, nowMs = Date.now() }) {
|
|
61
|
+
const delivery = decideDelivery(candidates, {
|
|
62
|
+
intent,
|
|
63
|
+
// ⚠️ 这里**故意传空集**:按 revision 抑制重复对「同一份文件的不同段落」太粗 ——
|
|
64
|
+
// 换一个问题往往会命中同一份文件的**另一段**,按文件级 revision 抑制会把新相关
|
|
65
|
+
// 的那段一起挡掉。要启用它得先用「段落身份」而不是「文件 revision」做键,
|
|
66
|
+
// 并且单独评测一次。这条记在 docs/decisions.md 的 D15,不是遗漏。
|
|
67
|
+
visibleRevisions: new Set(),
|
|
68
|
+
scopeDenied: new Set(),
|
|
69
|
+
conflicts: new Set(),
|
|
70
|
+
expired: new Set(),
|
|
71
|
+
needle: text ? text.slice(0, 40) : '',
|
|
72
|
+
}, { bytes: Math.max(0, CONTENT_BUDGET - contentBytes) })
|
|
73
|
+
|
|
74
|
+
const parts = []
|
|
75
|
+
const injectedFiles = []
|
|
76
|
+
let skippedForBudget = 0
|
|
77
|
+
for (const item of delivery.items) {
|
|
78
|
+
if (item.kind === 'snippet') {
|
|
79
|
+
parts.push(item.text)
|
|
80
|
+
injectedFiles.push(item.file)
|
|
81
|
+
} else {
|
|
82
|
+
// pointer / suppress / unresolved 都**不进正文**。
|
|
83
|
+
// 「被省略几条」这件事由下面的状态卡统一说,正文里再逐条列一行
|
|
84
|
+
// `### 某某.md(未展开)` 是重复的信息,还白占上下文 —— 卡片本来就带着
|
|
85
|
+
// 「最佳线索 + 一个可做的动作」。
|
|
86
|
+
skippedForBudget++
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// 进上下文的这些才算「被用过」—— 台账是自动归档阈值的唯一数据源,记错一步阈值就偏。
|
|
90
|
+
// recordAccess 同步、绝不抛:台账的问题不许升级成注入失败。
|
|
91
|
+
recordAccess(injectedFiles, nowMs)
|
|
92
|
+
|
|
93
|
+
const contentBytesNext = contentBytes + parts.reduce((n, p) => n + Buffer.byteLength(p, 'utf8'), 0)
|
|
94
|
+
return { delivery, parts, injectedFiles, skippedForBudget, contentBytes: contentBytesNext }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 缺口状态卡 + 检查点句柄(共用卡池)。
|
|
99
|
+
*
|
|
100
|
+
* 返回 `{ cardBody, cardText, cardPart, cardSig, cardBytes }`:
|
|
101
|
+
* - `cardBody` 要原样写回状态(签名没变时下一步沿用它,避免换个数字重发);
|
|
102
|
+
* - `cardPart` 是这一步真正要发出去的文本('' 表示不发)。
|
|
103
|
+
*/
|
|
104
|
+
export function composeStatus({
|
|
105
|
+
files, selected, bodyRanked, bodySearched, text, parts, injectedFiles, skippedForBudget,
|
|
106
|
+
deduped, st, toolsReady, checkpointLine, memoryDir, intentHistoryDependent,
|
|
107
|
+
}) {
|
|
108
|
+
// ── 缺口状态卡 ──
|
|
109
|
+
// 它**替换**原来那行状态文字,不是额外追加:同一件事不说两遍。
|
|
110
|
+
// 卡池与正文池互不借用 —— 正文满了照样发得出卡,这正是预留的意义(见 config.js)。
|
|
111
|
+
const bodyOnly = bodySearched ? dedupeByFile(bodyRanked) : []
|
|
112
|
+
const unread = Math.max(0, selected.length - injectedFiles.length)
|
|
113
|
+
// 最佳线索取「正文命中里第一条没被注进去的」——那正是模型下一步最该去读的东西。
|
|
114
|
+
const beacon = bodyOnly.find(h => !injectedFiles.includes(h.file)) ?? bodyOnly[0] ?? null
|
|
115
|
+
// 什么时候该说话:
|
|
116
|
+
// - **有真缺口**(有候选没读、或有候选被预算省略)→ 说;
|
|
117
|
+
// - **没有缺口也没有命中** → 只在「用户这句话在问自己的历史」时说。
|
|
118
|
+
// 后者是计划里点名的一条:没有历史依赖时不要插「你可能遗漏记忆」,否则提示
|
|
119
|
+
// 每轮都出现,等真有缺口时它已经被当噪音忽略了。
|
|
120
|
+
const gapExists = unread > 0 || skippedForBudget > 0
|
|
121
|
+
|
|
122
|
+
const wantCard = gapExists || intentHistoryDependent === true || !!checkpointLine
|
|
123
|
+
// 签名没变时沿用上一步渲染好的那份缺口卡,而不是重渲染一张。
|
|
124
|
+
// 两条理由:其一,那一步与这一步的检索面**逐字相同**,缺口也相同,重渲染只会把
|
|
125
|
+
// 同一件事换个数字再说一遍 —— 而局部变量算出来的数字还是错的(上一步注了 N 条,
|
|
126
|
+
// 这一步按本轮 parts 算是 0 条);其二,文本一变 `cardSig` 去重就失效,同一张语义
|
|
127
|
+
// 的卡会被反复追加进会话。
|
|
128
|
+
// 检查点句柄不在此列:它是可能刚刚才出现的接力棒,所以始终参与合成。
|
|
129
|
+
const cardBody = !wantCard
|
|
130
|
+
? ''
|
|
131
|
+
: deduped
|
|
132
|
+
? st.gapCard
|
|
133
|
+
: renderStatusCard({
|
|
134
|
+
scope: bodySearched ? 'all' : 'summary',
|
|
135
|
+
scanned: files.length,
|
|
136
|
+
injected: parts.length,
|
|
137
|
+
omitted: skippedForBudget,
|
|
138
|
+
unread,
|
|
139
|
+
best: beacon ? { file: beacon.file, title: beacon.chunk?.title || beacon.chunk?.headingPath || '' } : null,
|
|
140
|
+
needle: text ? text.slice(0, 40) : null,
|
|
141
|
+
toolsAvailable: toolsReady,
|
|
142
|
+
memoryDir,
|
|
143
|
+
})
|
|
144
|
+
// 句柄与卡**共用卡池**:它是同一个通道里的另一句提示,不该另开一份额度。
|
|
145
|
+
const cardText = [cardBody, checkpointLine].filter(Boolean).join('\n')
|
|
146
|
+
let cardPart = ''
|
|
147
|
+
let cardSig = st.cardSig
|
|
148
|
+
let cardBytes = st.cardBytes
|
|
149
|
+
// 卡按**内容签名**去重:签名没变就不重复发。否则每一步都追加一张一模一样的卡,
|
|
150
|
+
// 而每张卡都是落到会话里的持久消息 —— 那不叫提示,那叫刷屏。
|
|
151
|
+
if (cardText && cardText !== cardSig) {
|
|
152
|
+
const cardSize = Buffer.byteLength(cardText, 'utf8')
|
|
153
|
+
if (cardBytes + cardSize <= STATUS_RESERVE) {
|
|
154
|
+
cardBytes += cardSize
|
|
155
|
+
cardPart = cardText
|
|
156
|
+
cardSig = cardText
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return { cardBody, cardText, cardPart, cardSig, cardBytes }
|
|
160
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 注入链的调试出口(1.2.0 从 `lib/index.js` 原样搬出)。
|
|
3
|
+
*
|
|
4
|
+
* `SAGE_MEM_DEBUG=1` 才出声的一行结构化 JSON:候选数 / 命中数 / 逐条得分 /
|
|
5
|
+
* 入选与真注入的文件名 / 注入字符数 / 被会话预算挡掉几条。它回答的是
|
|
6
|
+
* 「这条记忆为什么没被召回」——一个光看上下文永远查不出的问题。
|
|
7
|
+
*
|
|
8
|
+
* ⚠️ **不开时零输出、零额外开销**:每个调用点都先 `if (debugEnabled())`,
|
|
9
|
+
* 不成立时连 payload 对象都不会被构造出来。
|
|
10
|
+
*
|
|
11
|
+
* 为什么开关是**每次现读**而不是模块级常量:原来是常量,靠「测试里用 query 重载
|
|
12
|
+
* `lib/index.js` 重新求值」来切换;1.2.0 把它拆成独立模块后,重载 index.js 不会重载
|
|
13
|
+
* 它,常量就永远停在旧值(`test/access-test.mjs` 第 4 组当场变红)。现读一次环境变量
|
|
14
|
+
* 的成本可以忽略,而它换来了「运行时也能打开调试」这个更诚实的行为。
|
|
15
|
+
*/
|
|
16
|
+
export function debugEnabled() {
|
|
17
|
+
return process.env.SAGE_MEM_DEBUG === '1'
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function debugRecall(payload) {
|
|
21
|
+
if (!debugEnabled()) return
|
|
22
|
+
console.log(`[sage-mem] recall ${JSON.stringify(payload)}`)
|
|
23
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 注入链的公开面(1.2.0 的模块边界)。
|
|
3
|
+
*
|
|
4
|
+
* 组合根只通过这个 barrel 认识注入链:它不需要知道选材 / 渲染 / 预算分在几个文件里,
|
|
5
|
+
* 也不该直接 import 内部碎片 —— 这样将来再拆一个文件,组合根不用跟着改。
|
|
6
|
+
*
|
|
7
|
+
* 各函数的行为由 `test/recall-golden-test.mjs`(11 个场景逐字节)与
|
|
8
|
+
* `test/recall-unit-test.mjs`(直接喂夹具)守着。
|
|
9
|
+
*/
|
|
10
|
+
export { createSessionInjectState } from './state.js'
|
|
11
|
+
export { createRecallInjector } from './injector.js'
|
|
12
|
+
export { dedupeByFile, selectRecall } from './select.js'
|
|
13
|
+
export { buildCandidates, composeDelivery, composeStatus } from './compose.js'
|
|
14
|
+
export { composeRecallText } from './text.js'
|
|
15
|
+
export { debugEnabled, debugRecall } from './debug.js'
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 注入链的编排骨架(1.2.0 从 `MemoryGateway` 构造函数里整段搬出)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么搬:这段逻辑(约 350 行)以前是构造函数的闭包 —— 想测「压缩后 baseline 会不会
|
|
5
|
+
* 重来」「正文池满了卡还能不能发」这类分支,只能先搭一个万能 Proxy ctx 再
|
|
6
|
+
* `new MemoryGateway(ctx)`,测的其实是「组合根能不能被构造」。
|
|
7
|
+
* 现在它是一等函数:`createRecallInjector({ ctx, state, tools })` 返回监听器,
|
|
8
|
+
* 而选材 / 渲染 / 预算三段各自是纯函数(`select.js` / `compose.js` / `text.js`)。
|
|
9
|
+
*
|
|
10
|
+
* 行为由 `test/recall-golden-test.mjs` 的 11 个场景逐字节守 —— 这个文件里任何一行
|
|
11
|
+
* 改动如果影响了注入文本,golden 会红,不是「测试太紧」。
|
|
12
|
+
*
|
|
13
|
+
* ⚠️ 两条历史坑(搬动时注释与代码必须一起搬,拆开就会重犯):
|
|
14
|
+
* 1. 只取 `source.kind === 'user'` 的消息 —— DSH 0.1.5 起注入与工具结果也存成 role:'user';
|
|
15
|
+
* 2. 代次处理(`state.begin`)必须在算正文与控制提示**之前**,否则压缩后会拿上一代的账去判重(复审 R03)。
|
|
16
|
+
*/
|
|
17
|
+
import { MEMORY_DIR, RECALL_DISCIPLINE, STATE_ROOT } from '../memory/config.js'
|
|
18
|
+
import { extractText } from '../memory/retrieval.js'
|
|
19
|
+
import { scanMemoryFiles } from '../memory/scan.js'
|
|
20
|
+
import { readCheckpoint } from '../memory/checkpoint.js'
|
|
21
|
+
import { warnLog } from '../memory/util.js'
|
|
22
|
+
import { selectRecall } from './select.js'
|
|
23
|
+
import { buildCandidates, composeDelivery, composeStatus } from './compose.js'
|
|
24
|
+
import { composeRecallText } from './text.js'
|
|
25
|
+
import { debugEnabled, debugRecall } from './debug.js'
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {object} deps
|
|
29
|
+
* @param {object} deps.ctx — 宿主 ctx(`scanMemoryFiles` / `warnLog` 用)
|
|
30
|
+
* @param {object} deps.state — `createSessionInjectState()` 的实例
|
|
31
|
+
* @param {{ready: () => boolean}} deps.tools — 检索工具是否注册就绪(异步就绪,每步读一次)
|
|
32
|
+
* @param {(session: object) => string} [deps.taskIdOf] — 任务 id 取值(检查点用)
|
|
33
|
+
* @returns {(assembly: object, context: object, next: Function) => Promise<object>}
|
|
34
|
+
*/
|
|
35
|
+
export function createRecallInjector({ ctx, state, tools, taskIdOf }) {
|
|
36
|
+
const resolveTaskId = typeof taskIdOf === 'function'
|
|
37
|
+
? taskIdOf
|
|
38
|
+
// 任务 id:`SAGE_MEM_TASK_ID` 优先(给「跨会话的同一条任务」用),否则用 session id。
|
|
39
|
+
// **绝不用查询文本自定作用域** —— 那句话是模型或用户写的,不该决定去读哪个目录。
|
|
40
|
+
: (session) => process.env.SAGE_MEM_TASK_ID || session?.id || ''
|
|
41
|
+
|
|
42
|
+
// 按问题检索注入:system-prompt/assemble 是异步 waterfall,监听器可 await。
|
|
43
|
+
// 从 agent.session 拿当前 user message,扫 memory 目录按摘要匹配选相关
|
|
44
|
+
// 文件,读全文作为动态 context 段注入——让 agent 第一轮就「想起」。
|
|
45
|
+
return async function recallInjection(assembly, context, next) {
|
|
46
|
+
const assembled = await next()
|
|
47
|
+
// session id 在 try 外先取好:catch 里也要能带上它(拿到什么带什么)。
|
|
48
|
+
const sessionId = context?.agent?.session?.id ?? ''
|
|
49
|
+
try {
|
|
50
|
+
const agent = context?.agent
|
|
51
|
+
const session = agent?.session
|
|
52
|
+
if (!session) return assembled
|
|
53
|
+
// DSH 0.1.7 起 Session 的历史读取接口在陆续调整(snapshotEvents / eventAt /
|
|
54
|
+
// ownEvents 已弃用)。deriveMessages 在 0.1.7-rc.1 实测仍在且仍同步,但这三行
|
|
55
|
+
// 不是白写的:方法一旦被改名或改成异步,老写法要么静默不注入、要么把 Promise
|
|
56
|
+
// 当数组展开成空。宁可日志里响一声,也不要"记忆不注入但日志一字没有"。
|
|
57
|
+
if (typeof session.deriveMessages !== 'function') {
|
|
58
|
+
warnLog(ctx, 'session.deriveMessages() 不可用 —— 记忆召回已跳过(DSH session API 变了?)', sessionId)
|
|
59
|
+
return assembled
|
|
60
|
+
}
|
|
61
|
+
const derived = session.deriveMessages()
|
|
62
|
+
const messages = [...(derived && typeof derived.then === 'function' ? await derived : derived)]
|
|
63
|
+
|
|
64
|
+
// 关键:DSH 0.1.5 把工具结果(source.kind === 'tool')和注入内容
|
|
65
|
+
// (source.kind === 'plugin')也存成 role: 'user' 的消息。只按 role 取
|
|
66
|
+
// 「最后一条」会取到 tool-result,extractText 得到空串,下面的长度检查
|
|
67
|
+
// 直接返回——表现就是全程静默不注入。只有 source.kind === 'user' 是真人输入。
|
|
68
|
+
const userMessages = messages.filter(m => m?.role === 'user' && m?.source?.kind === 'user')
|
|
69
|
+
const text = extractText(userMessages[userMessages.length - 1]?.content)
|
|
70
|
+
|
|
71
|
+
const files = await scanMemoryFiles(ctx, sessionId)
|
|
72
|
+
|
|
73
|
+
// ── 代次:**先处理状态转换,再算正文与控制提示** ──
|
|
74
|
+
// 压缩 / 清空(消息条数下降)在这一行里就让签名、两本账、卡签名、baseline 标记
|
|
75
|
+
// 一起归零。放到后面(比如去重之后再算)就等于「拿上一代的账本判这一代」——
|
|
76
|
+
// 复审 R03 的「同题压缩后仍被挡住」正是这么来的。
|
|
77
|
+
const ledgerKey = session.id || 'no-session-id'
|
|
78
|
+
const st = state.begin(ledgerKey, messages.length)
|
|
79
|
+
|
|
80
|
+
const { picked, baselineTaken, relevant, intentInfo, bodyRanked, bestChunk, selected, signature, deduped } =
|
|
81
|
+
selectRecall({ text, files, st })
|
|
82
|
+
|
|
83
|
+
// ⚠️ 这里**不能**再写「picked 为空就返回」:检查点句柄与未命中提示都不依赖候选,
|
|
84
|
+
// 提前返回会让它们在「目录为空」或「零命中」时永远不可达(复审 R02)。
|
|
85
|
+
// 统一的出口在下面 —— 只有**正文与控制提示都为空**才返回。
|
|
86
|
+
|
|
87
|
+
const nowMs = Date.now()
|
|
88
|
+
const candidates = buildCandidates({ picked, bestChunk, nowMs })
|
|
89
|
+
|
|
90
|
+
// ── 单会话累计预算:**拆成两本账** ──
|
|
91
|
+
// 正文池 = CONTENT_BUDGET(总上限 − 状态卡预留)/卡池 = STATUS_RESERVE
|
|
92
|
+
// 拆的理由见 config.js:到顶之后旧行为是一个字都不注、连「为什么没注、还能怎么查」
|
|
93
|
+
// 都没有;而那个上限是**本插件自己定的**,完全可以给状态卡留一份固定额度。
|
|
94
|
+
// 账记在 session id 上;picked 里 baseline 永远排在检索结果之前,所以预算不够时
|
|
95
|
+
// 先被挤掉的是「更晚才想起来的」那些 —— baseline 有优先权。
|
|
96
|
+
// 起点来自 `begin()`:它可能刚因压缩把两本账连同卡签名一起归零过。
|
|
97
|
+
const delivered = composeDelivery({ candidates, text, intent: intentInfo.intent, contentBytes: st.contentBytes, nowMs })
|
|
98
|
+
const { parts, injectedFiles, skippedForBudget, contentBytes } = delivered
|
|
99
|
+
|
|
100
|
+
// ── 任务检查点:压缩后的恢复入口 ──
|
|
101
|
+
// 宿主**什么时候**压缩上下文,插件控制不了 —— 所以这里**不承诺「压缩前保存」**,
|
|
102
|
+
// 只保证「存在检查点就给一条短句柄」。压缩后两本账与卡的签名会一起归零,
|
|
103
|
+
// 句柄因此会重新出现,这就是恢复路径(而不是靠某个压缩前钩子)。
|
|
104
|
+
const taskId = resolveTaskId(session)
|
|
105
|
+
let checkpointLine = ''
|
|
106
|
+
try {
|
|
107
|
+
const cp = taskId ? await readCheckpoint(taskId, { stateRoot: STATE_ROOT }) : null
|
|
108
|
+
if (cp?.ok && cp.handle) checkpointLine = cp.handle
|
|
109
|
+
} catch { /* 读不到检查点就当没有:它是可选的接力棒,不是必需件 */ }
|
|
110
|
+
|
|
111
|
+
const bodySearched = typeof text === 'string' && text.length >= 4
|
|
112
|
+
const status = composeStatus({
|
|
113
|
+
files, selected, bodyRanked, bodySearched, text, parts, injectedFiles, skippedForBudget,
|
|
114
|
+
deduped, st, toolsReady: tools.ready(), checkpointLine, memoryDir: MEMORY_DIR,
|
|
115
|
+
intentHistoryDependent: intentInfo.historyDependent,
|
|
116
|
+
})
|
|
117
|
+
const { cardBody, cardPart } = status
|
|
118
|
+
|
|
119
|
+
// 正文与卡都没有 → 一个字都不注。注意这与旧行为不同:旧行为「正文池满」就到此为止,
|
|
120
|
+
// 现在只要卡池还有额度就会发出卡(说清范围、缺口与下一步动作)。
|
|
121
|
+
if (parts.length === 0 && !cardPart) {
|
|
122
|
+
if (debugEnabled()) {
|
|
123
|
+
debugRecall({
|
|
124
|
+
scanned: files.length, hits: relevant.length,
|
|
125
|
+
scores: relevant.map(f => [f.file, Number(f.score.toFixed(4))]),
|
|
126
|
+
picked: picked.map(f => f.file), injected: [], chars: contentBytes, skippedByBudget: skippedForBudget,
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
return assembled
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 状态一次写回:签名、两本账、卡签名、缺口卡、条数、baseline 标记。
|
|
133
|
+
// 放在这里(而不是更早)是刻意的 —— 上面每一步都可能没真注进去东西,
|
|
134
|
+
// 那就不该让状态前进;一次失败把一条记忆永久挡在门外是最难查的一种。
|
|
135
|
+
state.commit(ledgerKey, {
|
|
136
|
+
signature,
|
|
137
|
+
contentBytes,
|
|
138
|
+
cardBytes: status.cardBytes,
|
|
139
|
+
cardSig: status.cardSig,
|
|
140
|
+
gapCard: cardBody,
|
|
141
|
+
messageCount: messages.length,
|
|
142
|
+
baselineDone: st.baselineDone || baselineTaken > 0,
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
if (debugEnabled()) {
|
|
146
|
+
debugRecall({
|
|
147
|
+
scanned: files.length,
|
|
148
|
+
hits: relevant.length,
|
|
149
|
+
scores: relevant.map(f => [f.file, Number(f.score.toFixed(4))]),
|
|
150
|
+
picked: picked.map(f => f.file),
|
|
151
|
+
injected: injectedFiles,
|
|
152
|
+
chars: contentBytes,
|
|
153
|
+
skippedByBudget: skippedForBudget,
|
|
154
|
+
card: cardPart ? 'sent' : (status.cardText ? 'deduped-or-over-budget' : 'none'),
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const recallText = composeRecallText({
|
|
159
|
+
parts,
|
|
160
|
+
cardPart,
|
|
161
|
+
memoryDir: MEMORY_DIR,
|
|
162
|
+
discipline: RECALL_DISCIPLINE,
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
...assembled,
|
|
167
|
+
contexts: [...(assembled.contexts ?? []), {
|
|
168
|
+
name: 'sage-mem:recall',
|
|
169
|
+
order: 1000,
|
|
170
|
+
text: recallText,
|
|
171
|
+
}],
|
|
172
|
+
}
|
|
173
|
+
} catch (err) {
|
|
174
|
+
// 这是整条注入链的外壳。裸吞的代价就是 :174-193 记的那个坑:权限错、编码坏、
|
|
175
|
+
// frontmatter 正则哪天不匹配,表现都是「插件在、但记忆不再注入」,日志一字没有。
|
|
176
|
+
// 现在只降级(返回未注入的 assembly),但一定留痕。
|
|
177
|
+
warnLog(ctx, `recall injection failed: ${err?.message ?? err}`, sessionId)
|
|
178
|
+
return assembled
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 注入链的**选材**阶段(1.2.0 从 `lib/index.js` 原样搬出,纯函数化)。
|
|
3
|
+
*
|
|
4
|
+
* 输入:`{ text, files, st }`(问题文本、扫到的记忆、会话状态快照)。
|
|
5
|
+
* 输出:候选文件(`picked`,顺序即注入顺序)、检索签名、各条检索线的中间结果。
|
|
6
|
+
*
|
|
7
|
+
* 为什么单独一个文件:这一段以前埋在 348 行监听器里,只能 `new MemoryGateway(ctx)` 间接测;
|
|
8
|
+
* 现在 `selectRecall()` 是纯函数,`test/recall-unit-test.mjs` 可以直接喂夹具。
|
|
9
|
+
*/
|
|
10
|
+
import { MAX_RESULTS } from '../memory/config.js'
|
|
11
|
+
import { selectBaseline, selectRelevant } from '../memory/retrieval.js'
|
|
12
|
+
import { detectIntent } from '../memory/intent.js'
|
|
13
|
+
import { rankChunks } from '../memory/search.js'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 按文件去重,保留每个文件**首次**(也就是得分最高)出现的那条正文命中。
|
|
17
|
+
*
|
|
18
|
+
* 正文检索是段落粒度的,同一份文件可能返回多个段落。注入链要的是「这份文件要不要端上来」,
|
|
19
|
+
* 所以在这里收成文件粒度 —— 而**不是**把同一文件的多个段落一起端上去:那正是
|
|
20
|
+
* `retrieval.js` 当初从「全文匹配」退回「只比摘要」要躲开的病(长文靠段数多占满候选位)。
|
|
21
|
+
*/
|
|
22
|
+
export function dedupeByFile(hits) {
|
|
23
|
+
const seen = new Set()
|
|
24
|
+
const out = []
|
|
25
|
+
for (const h of hits) {
|
|
26
|
+
if (seen.has(h.file)) continue
|
|
27
|
+
seen.add(h.file)
|
|
28
|
+
out.push(h)
|
|
29
|
+
}
|
|
30
|
+
return out
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 选出这一步要端上去的文件。
|
|
35
|
+
*
|
|
36
|
+
* @param {{text:string, files:Array, st:object, maxResults?:number}} input
|
|
37
|
+
* @returns {{picked:Array, baselineTaken:number, relevant:Array, intentInfo:object,
|
|
38
|
+
* bodyRanked:Array, bestChunk:Map, selected:Array, signature:string, deduped:boolean}}
|
|
39
|
+
*/
|
|
40
|
+
export function selectRecall({ text, files, st, maxResults = MAX_RESULTS }) {
|
|
41
|
+
const picked = []
|
|
42
|
+
const seen = new Set()
|
|
43
|
+
const take = (f) => {
|
|
44
|
+
if (seen.has(f.file)) return
|
|
45
|
+
seen.add(f.file)
|
|
46
|
+
picked.push(f)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// baseline:每个会话注入一次,**不看消息条数**。
|
|
50
|
+
//
|
|
51
|
+
// 旧写法 `if (userMessages.length === 0)` 依赖一个真实存在的时序:assemble
|
|
52
|
+
// 触发时当前这条用户消息还没写进 session。已在 DSH 0.1.7-rc.1 上核实为真 ——
|
|
53
|
+
// 宿主从 pending 队列取走本轮输入
|
|
54
|
+
// 组装 system prompt(本监听器在这里跑)
|
|
55
|
+
// 之后才把这条 user 消息落进 session
|
|
56
|
+
// 所以全新会话首步 pick 到 0 条 user message,分支命中。
|
|
57
|
+
//
|
|
58
|
+
// 但**恢复 / 续聊会话**的历史消息早就在 session 里,条数永远 ≥ 1 → 分支永不
|
|
59
|
+
// 命中 → 永不注入。而「继续吧」这类短问题在 description 里找不到匹配、检索
|
|
60
|
+
// 返回 0 条,baseline 恰恰是那时唯一的兜底。这是本会话级状态要修的东西。
|
|
61
|
+
let baselineTaken = 0
|
|
62
|
+
if (!st.baselineDone) {
|
|
63
|
+
selectBaseline(files)
|
|
64
|
+
.forEach(f => {
|
|
65
|
+
const before = picked.length
|
|
66
|
+
take(f)
|
|
67
|
+
if (picked.length > before) baselineTaken++
|
|
68
|
+
})
|
|
69
|
+
// 只有真取到才算「给过」:首步若正好撞上目录读不到(scanMemoryFiles 降级返回 []),
|
|
70
|
+
// 整场会话就再也没机会补 baseline 了。宁可下一步重算一次。
|
|
71
|
+
// 标记本身随这一步一起在 commit() 落 —— 与「提交放最后」同一条理由:
|
|
72
|
+
// 没真注进去的东西不能记成「已经给过」。
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 检索签名只覆盖「检索出来的那批」,baseline 不再掺进这个签名。
|
|
76
|
+
// 旧版两者共用 signature,首步签名一旦与后续某步相同,baseline 会连坐被吞;
|
|
77
|
+
// 反过来,旧版把 baseline 文件名也算进签名,会让同一步的检索结果在下一步
|
|
78
|
+
// 因签名变化而被重复注入。现在 baseline 由会话级标记保证只来一次,
|
|
79
|
+
// 检索由这份签名保证同一问题不重复追加,两条线互不干扰。
|
|
80
|
+
const relevant = text && text.length >= 2 ? selectRelevant(text, files) : []
|
|
81
|
+
|
|
82
|
+
// ── 意图:先判,因为它要影响下面正文检索的**门槛松紧** ──
|
|
83
|
+
// 用户是不是在问自己的历史(上次 / 之前 / 更正 / 问依据 / 继续)。
|
|
84
|
+
// 判不出来就是 `none`,不猜 —— 误判的代价是把不该抑制的正文抑制掉。
|
|
85
|
+
const intentInfo = text ? detectIntent(text) : { intent: 'none', historyDependent: false, suppressible: true }
|
|
86
|
+
|
|
87
|
+
// ── 正文检索 ──
|
|
88
|
+
// 摘要(description + aliases + 文件名)是最省的检索面,代价是**正文里的东西天生检索不到**。
|
|
89
|
+
// 这里用它做两件事:
|
|
90
|
+
// 1. 给候选文件定位「最相关的那一段」,注入时替代「文件开头 1500 字」——
|
|
91
|
+
// 旧写法对任何问题都端同一段开头,等于把「命中在哪」这件事扔掉了;
|
|
92
|
+
// 2. 摘要一条都没命中时,用正文兜一次底。此前这种情况直接等于「没有记忆可用」。
|
|
93
|
+
//
|
|
94
|
+
// query 短于 4 个字符不做正文匹配:`继续` 这种两个字的问题拿去匹配正文,
|
|
95
|
+
// 命中的全是「继续」这个词本身,与用户问什么毫无关系。
|
|
96
|
+
//
|
|
97
|
+
// **门槛按意图放宽**:默认要求「连续命中 ≥ 2 个词元」(那条防的是跨词边界的假词元,
|
|
98
|
+
// 见 search.js)。代价是整句只对上一个两字专名时会被拦下 —— 那是 0.9.16 有意付的。
|
|
99
|
+
// 但用户**明确在问自己的历史**时,宁可多给一条也不该让他拿不到依据:这类问题的
|
|
100
|
+
// 代价不对称(少给 = 答错,多给 = 多花一点上下文)。所以这时把 `minRun` 放到 1。
|
|
101
|
+
const bodyRanked = typeof text === 'string' && text.length >= 4
|
|
102
|
+
? rankChunks(text, files, {
|
|
103
|
+
limit: maxResults,
|
|
104
|
+
perFile: 1,
|
|
105
|
+
...(intentInfo.historyDependent ? { minEvidence: { minRun: 1 } } : {}),
|
|
106
|
+
})
|
|
107
|
+
: []
|
|
108
|
+
const bestChunk = new Map()
|
|
109
|
+
for (const h of bodyRanked) if (!bestChunk.has(h.file)) bestChunk.set(h.file, h.chunk)
|
|
110
|
+
|
|
111
|
+
// 「摘要命中就按摘要排;一条都没有才让正文接手」这个**保守写法是错的**,
|
|
112
|
+
// 由评测数据推翻(scripts/eval-retrieval.mjs 的对照 + 注入链实测):
|
|
113
|
+
// 摘要只要命中**任何一条**(哪怕很弱、甚至命中错的文件),正文里那份真正相关的
|
|
114
|
+
// 就再也没有机会——应注入的文件级召回从 87.0% 掉到 60.9%。而噪音并没有因此更低
|
|
115
|
+
// (正文命中本来就要过相关性门槛,实测误召回 1/10,与保守写法持平)。
|
|
116
|
+
//
|
|
117
|
+
// 所以改成**并集**:摘要命中的在前(它经过人工写的 description,精确度高),
|
|
118
|
+
// 正文独有的在后(按 BM25 + 证据强度排序)。`take` 按文件去重,
|
|
119
|
+
// 同一份文件不会因为两条检索线都命中而占两个名额。
|
|
120
|
+
const byFile = new Map(files.map(f => [f.file, f]))
|
|
121
|
+
const selected = relevant.slice()
|
|
122
|
+
const taken = new Set(selected.map(f => f.file))
|
|
123
|
+
for (const h of dedupeByFile(bodyRanked)) {
|
|
124
|
+
if (taken.has(h.file)) continue
|
|
125
|
+
taken.add(h.file)
|
|
126
|
+
const f = byFile.get(h.file)
|
|
127
|
+
if (f) selected.push(f)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const signature = text + '\u0000' + selected.map(f => f.file).join(',')
|
|
131
|
+
// 检索签名没变 = 这一代里这条问题已经处理过 → 不再重复追加同一份记忆。
|
|
132
|
+
// ⚠️ 这个判断必须排在代次处理**之后**:压缩后的同题恢复,靠的就是上面 `begin()`
|
|
133
|
+
// 把签名一起清掉(复审 R03)。
|
|
134
|
+
const deduped = st.signature === signature
|
|
135
|
+
if (!deduped) selected.forEach(take)
|
|
136
|
+
|
|
137
|
+
return { picked, baselineTaken, relevant, intentInfo, bodyRanked, bestChunk, selected, signature, deduped }
|
|
138
|
+
}
|