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/index.js
CHANGED
|
@@ -15,205 +15,24 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { AutodreamEngine } from './autodream.js'
|
|
18
|
-
import { auditMemoryDir } from './autodream
|
|
18
|
+
import { auditMemoryDir } from './autodream/audit.js'
|
|
19
19
|
import { DEFAULT_CONFIG } from './autodream/config.js'
|
|
20
|
-
import {
|
|
21
|
-
import { CONTENT_BUDGET, MAX_BASELINE, MAX_CHARS_PER_FILE, MAX_FILE_BYTES, MAX_RESULTS, MAX_SESSION_BYTES, MEMORY_DIR, RECALL_DISCIPLINE, RESERVED_FILES, SESSIONS_ROOT, STALE_DAYS, STATE_ROOT, STATUS_RESERVE } from './memory/config.js'
|
|
20
|
+
import { MAX_BASELINE, MAX_CHARS_PER_FILE, MAX_FILE_BYTES, MAX_RESULTS, MAX_SESSION_BYTES, MEMORY_DIR, RECALL_DISCIPLINE, RESERVED_FILES, SESSIONS_ROOT, STALE_DAYS, STATE_ROOT } from './memory/config.js'
|
|
22
21
|
import { parseAliases, parseBaseline, parseBaselinePriority, parseFrontmatter, parseTags } from './memory/frontmatter.js'
|
|
22
|
+
import { ageDays, bigrams, score, selectBaseline, selectRelevant } from './memory/retrieval.js'
|
|
23
23
|
import { isReserved, nameKey, safeName } from './memory/naming.js'
|
|
24
|
-
import { ageDays, bigrams, extractText, score, selectBaseline, selectRelevant } from './memory/retrieval.js'
|
|
25
24
|
import { MEMORY_ARCHIVE_DIR, addArchiveMeta, extractTitle, inspectDir, inspectMemoryEntry, readArchivedMeta, scanArchived, scanMemoryFiles, scanStars, shadowsReserved } from './memory/scan.js'
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
28
|
-
import { detectIntent } from './memory/intent.js'
|
|
29
|
-
import { decideDelivery } from './memory/deliver.js'
|
|
30
|
-
import { classifyToolExecution, accessEventKey, createDeduper } from './memory/observe.js'
|
|
31
|
-
import { readCheckpoint } from './memory/checkpoint.js'
|
|
32
|
-
import { memoryPathOf } from './memory/paths.js'
|
|
33
|
-
import { decideWriteIntent, decideEditGuard } from './memory/write-gate.js'
|
|
34
|
-
import { createMemoryTools } from './tools/memory-tools.js'
|
|
35
|
-
import { archiveMemory, restoreMemory } from './memory/archive.js'
|
|
36
|
-
import { flushAccess, pickArchiveCandidates, readAccessLedger, recordAccess, recordRead } from './memory/access.js'
|
|
25
|
+
import { archiveMemory, deleteArchivedMemory, restoreMemory } from './memory/archive.js'
|
|
26
|
+
import { flushAccess, pickArchiveCandidates, readAccessLedger } from './memory/access.js'
|
|
37
27
|
import { warnLog, writeFileAtomic } from './memory/util.js'
|
|
28
|
+
import { readLockSet, setLocked as setLockedImpl } from './memory/lock.js'
|
|
38
29
|
import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
|
|
39
30
|
import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises'
|
|
40
|
-
import {
|
|
31
|
+
import { join } from 'node:path'
|
|
41
32
|
import { checkUpdate as checkUpdateImpl } from './update.js'
|
|
42
|
-
import {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
* `SAGE_MEM_DEBUG=1` 才出声的注入链调试出口。
|
|
46
|
-
*
|
|
47
|
-
* 一行结构化 JSON:候选数 / 命中数 / 逐条得分 / 入选与真注入的文件名 / 注入字符数 /
|
|
48
|
-
* 被会话预算挡掉几条。它回答的是「这条记忆为什么没被召回」——一个光看上下文永远查不出的问题。
|
|
49
|
-
*
|
|
50
|
-
* ⚠️ **不开时零输出、零额外开销**:常量的读取只在模块加载时发生一次;调用点整个包在
|
|
51
|
-
* `if (DEBUG_RECALL)` 里,连 payload 对象都不会被构造出来。
|
|
52
|
-
*/
|
|
53
|
-
const DEBUG_RECALL = process.env.SAGE_MEM_DEBUG === '1'
|
|
54
|
-
|
|
55
|
-
function debugRecall(payload) {
|
|
56
|
-
if (!DEBUG_RECALL) return
|
|
57
|
-
console.log(`[sage-mem] recall ${JSON.stringify(payload)}`)
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* 按文件去重,保留每个文件**首次**(也就是得分最高)出现的那条正文命中。
|
|
62
|
-
*
|
|
63
|
-
* 正文检索是段落粒度的,同一份文件可能返回多个段落。注入链要的是「这份文件要不要端上来」,
|
|
64
|
-
* 所以在这里收成文件粒度 —— 而**不是**把同一文件的多个段落一起端上去:那正是
|
|
65
|
-
* `retrieval.js` 当初从「全文匹配」退回「只比摘要」要躲开的病(长文靠段数多占满候选位)。
|
|
66
|
-
*/
|
|
67
|
-
function dedupeByFile(hits) {
|
|
68
|
-
const seen = new Set()
|
|
69
|
-
const out = []
|
|
70
|
-
for (const h of hits) {
|
|
71
|
-
if (seen.has(h.file)) continue
|
|
72
|
-
seen.add(h.file)
|
|
73
|
-
out.push(h)
|
|
74
|
-
}
|
|
75
|
-
return out
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* 会话级注入状态:**一处持有、一处提交**。
|
|
80
|
-
*
|
|
81
|
-
* 为什么必须集中:原先这是三个散落的容器(检索签名 `recallCache`、baseline 标记
|
|
82
|
-
* `baselineInjected`、注入账本 `injectLedger`),而代次变化本该让它们**同时**归零 ——
|
|
83
|
-
* 旧代码把重置写在去重**之后**,于是同一问题在压缩后仍被上一代的检索签名挡住,
|
|
84
|
-
* 记忆再也回不来(外部复审 R03)。集中之后,顺序是能被读出来的:
|
|
85
|
-
*
|
|
86
|
-
* begin() 处理代次变化 → 调用方算正文与控制提示 → commit() 一次写回
|
|
87
|
-
*
|
|
88
|
-
* 为什么提交放最后:中途抛错时状态不该前进 —— 没注进去的东西不能记成「已注过」,
|
|
89
|
-
* 否则一次失败会把这条记忆永久挡在门外。唯一的例外是代次变化:那是**已经发生的事实**
|
|
90
|
-
* (上下文真的被压缩了),必须在算任何东西之前生效,否则这一步会拿着上一代的账做判断。
|
|
91
|
-
*
|
|
92
|
-
* ⚠️ 诚实边界:压缩信号是「消息条数下降」这条启发式,它**覆盖不了条数不降的压缩**
|
|
93
|
-
* (宿主原地改写历史,或截断后又在同一步补足条数)。那种情况下这里看不出代次变化,
|
|
94
|
-
* 记忆仍会被去重挡住。要真正解决得等宿主给出可比的上下文代次标识(目前没有)。
|
|
95
|
-
*/
|
|
96
|
-
function createSessionInjectState(maxSessions = 64) {
|
|
97
|
-
const sessions = new Map()
|
|
98
|
-
const fresh = () => ({
|
|
99
|
-
signature: null, // 已注入过的检索签名(问题 + 命中文件集)
|
|
100
|
-
contentBytes: 0, // 本代正文累计字节
|
|
101
|
-
cardBytes: 0, // 本代状态卡累计字节(与正文分账,见 config.js)
|
|
102
|
-
cardSig: '', // 本代已发过的那张卡(含检查点句柄的合成文本)
|
|
103
|
-
gapCard: '', // 本代渲染过的缺口卡正文(签名没变时沿用,避免换个数字重发)
|
|
104
|
-
messageCount: -1, // 上一次见效时的消息条数(压缩判据)
|
|
105
|
-
baselineDone: false, // 本代是否已经给过 baseline
|
|
106
|
-
})
|
|
107
|
-
return {
|
|
108
|
-
/**
|
|
109
|
-
* 处理代次变化,取回本会话这一代的起点。**必须在算正文与控制提示之前调用。**
|
|
110
|
-
* 返回值是一份快照 —— 调用方只读,写回统一走 `commit()`。
|
|
111
|
-
*/
|
|
112
|
-
begin(key, messageCount) {
|
|
113
|
-
const prev = sessions.get(key)
|
|
114
|
-
const generationChanged = !!prev && messageCount < prev.messageCount
|
|
115
|
-
// 条数下降 → 四样东西一起归零。少清任何一样都会留下一个「上一代的真相」:
|
|
116
|
-
// 字节账残留 → 新内容注不进来;baseline 残留 → 它再也不出现;
|
|
117
|
-
// 卡签名残留 → 恢复入口永久失效。
|
|
118
|
-
if (generationChanged) sessions.set(key, { ...fresh(), messageCount })
|
|
119
|
-
const cur = sessions.get(key) ?? fresh()
|
|
120
|
-
return { ...cur, generationChanged }
|
|
121
|
-
},
|
|
122
|
-
/** 一次写回。只防长驻进程无限增长:被挤掉的老会话下次醒来至多把同一批记忆再注一次。 */
|
|
123
|
-
commit(key, next) {
|
|
124
|
-
sessions.set(key, { ...next })
|
|
125
|
-
// FIFO 而不是 LRU:Map 重写已存在的键不会挪动位置,与旧行为一致。
|
|
126
|
-
if (sessions.size > maxSessions) sessions.delete(sessions.keys().next().value)
|
|
127
|
-
},
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* 面板可改的 5 项注入上限 —— 与 `memory/config.js` 里的 `SAGE_MEM_*` 一一对应。
|
|
133
|
-
* 边界值照抄那边的 envInt 调用(lo/hi 不许在这里另立一套)。
|
|
134
|
-
*/
|
|
135
|
-
const LIMIT_SPECS = {
|
|
136
|
-
maxResults: { env: 'SAGE_MEM_MAX_RESULTS', dflt: 10, lo: 1, hi: 30 },
|
|
137
|
-
maxChars: { env: 'SAGE_MEM_MAX_CHARS', dflt: 1500, lo: 200, hi: 20000 },
|
|
138
|
-
maxBaseline: { env: 'SAGE_MEM_MAX_BASELINE', dflt: 8, lo: 1, hi: 50 },
|
|
139
|
-
maxSessionBytes: { env: 'SAGE_MEM_MAX_SESSION_BYTES', dflt: 60 * 1024, lo: 4096, hi: 512 * 1024 },
|
|
140
|
-
staleDays: { env: 'SAGE_MEM_STALE_DAYS', dflt: 1, lo: 0, hi: 365 },
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/** 面板设置的落点:状态根下(与 autodream 的配置、台账同级)。 */
|
|
144
|
-
const SETTINGS_FILE = join(STATE_ROOT, 'settings.json')
|
|
145
|
-
const RESERVED_FILE = join(STATE_ROOT, 'reserved.json')
|
|
146
|
-
|
|
147
|
-
/**
|
|
148
|
-
* `writeRaw` 的体积上限(4 MB)。
|
|
149
|
-
*
|
|
150
|
-
* ⚠️ **故意不复用 `MAX_FILE_BYTES`(512 KB)**:保留名里就有 `session-log.md` 这种追加型
|
|
151
|
-
* 流水,它天然长得比单条记忆大得多(实测已 > 512 KB)。沿用小上限 = 用户永远存不回去。
|
|
152
|
-
*/
|
|
153
|
-
const MAX_RAW_BYTES = 4 * 1024 * 1024
|
|
154
|
-
|
|
155
|
-
/** 整数收敛(与 config.js 的 envInt 同一套语义):非有限数回落默认,越界夹到边界。 */
|
|
156
|
-
const clampInt = (v, spec) => (Number.isFinite(v) ? Math.max(spec.lo, Math.min(spec.hi, Math.round(v))) : spec.dflt)
|
|
157
|
-
|
|
158
|
-
/** 读 `<状态根>/settings.json`;缺失、坏 JSON、不是对象都当「没设置」。 */
|
|
159
|
-
async function readSettingsFile() {
|
|
160
|
-
try {
|
|
161
|
-
const parsed = JSON.parse(await readFile(SETTINGS_FILE, 'utf8'))
|
|
162
|
-
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null
|
|
163
|
-
} catch {
|
|
164
|
-
return null
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
/** 待生效的 5 项上限:`settings.json` > 环境变量 > 内置默认。 */
|
|
169
|
-
function pendingLimits(fileObj) {
|
|
170
|
-
const out = {}
|
|
171
|
-
const saved = fileObj?.limits && typeof fileObj.limits === 'object' ? fileObj.limits : {}
|
|
172
|
-
for (const [key, spec] of Object.entries(LIMIT_SPECS)) {
|
|
173
|
-
const fromFile = Number(saved[key])
|
|
174
|
-
if (Number.isFinite(fromFile)) {
|
|
175
|
-
out[key] = clampInt(fromFile, spec)
|
|
176
|
-
continue
|
|
177
|
-
}
|
|
178
|
-
const raw = process.env[spec.env]
|
|
179
|
-
out[key] = clampInt(raw === undefined || raw === '' ? NaN : Number(raw), spec)
|
|
180
|
-
}
|
|
181
|
-
return out
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/** 读 `<状态根>/reserved.json`(用户声明的保护名单)。**保留原始大小写**,好让 get 读回一致。 */
|
|
185
|
-
async function readReservedFile() {
|
|
186
|
-
try {
|
|
187
|
-
const arr = JSON.parse(await readFile(RESERVED_FILE, 'utf8'))
|
|
188
|
-
if (!Array.isArray(arr)) return []
|
|
189
|
-
return arr
|
|
190
|
-
.map((s) => String(s).trim())
|
|
191
|
-
.filter((s) => s.endsWith('.md') && !s.includes('/') && !s.includes('\\') && !s.startsWith('..'))
|
|
192
|
-
} catch {
|
|
193
|
-
return []
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* 原样读写用的名字校验:只要求「单段、`.md`」。
|
|
199
|
-
*
|
|
200
|
-
* ⚠️ **故意不走 `safeName`**:那个函数把 `MEMORY.md` / `session-log.md` 当非记忆条目排除,
|
|
201
|
-
* 而它们恰恰是 `readRaw` / `writeRaw` 的目标。这里只防路径成分与扩展名。
|
|
202
|
-
*/
|
|
203
|
-
function rawName(raw) {
|
|
204
|
-
const s = typeof raw === 'string' ? raw : ''
|
|
205
|
-
if (!s || basename(s) !== s) return null
|
|
206
|
-
// `:` 是 NTFS 交替数据流的分隔符:`MEMORY.md:evil.md` 能过扩展名判据,落盘却是写到
|
|
207
|
-
// 索引的隐藏流上(`readdir` 看不见)。与 `safeName` 同一条理由,这里也要拒。
|
|
208
|
-
if (s.includes(':')) return null
|
|
209
|
-
return /\.md$/i.test(s) ? s : null
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
/** autodream 的实际配置(缺文件/坏文件回落默认值)—— 面板要显示真实策略,不能显示想象。 */
|
|
213
|
-
async function autodreamConfigSnapshot() {
|
|
214
|
-
const parsed = await readJson(join(STATE_ROOT, 'autodream.json'))
|
|
215
|
-
return { ...DEFAULT_CONFIG, ...(parsed?.config ?? {}) }
|
|
216
|
-
}
|
|
33
|
+
import { autodreamConfigSnapshot, clampInt, LIMIT_SPECS, MAX_RAW_BYTES, pendingLimits, rawName, readReservedFile, readSettingsFile, RESERVED_FILE, SETTINGS_FILE } from './settings.js'
|
|
34
|
+
import { createRecallInjector, createSessionInjectState } from './recall/index.js'
|
|
35
|
+
import { mountAutodream, mountMemoryTools, mountObserve, mountWriteGate } from './assembly/index.js'
|
|
217
36
|
|
|
218
37
|
/**
|
|
219
38
|
* 手动 @Remote marker 注册(Node 24 不解析装饰器语法,手写 decorator context)。
|
|
@@ -237,539 +56,27 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
237
56
|
// 一并挂载星图 remote(同一记忆目录,纯只读)—— starmap.* 服务
|
|
238
57
|
new StarmapGateway(ctx)
|
|
239
58
|
|
|
240
|
-
//
|
|
241
|
-
//
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
// 卸载时把定时器与锁收干净。effect 拿不到就交给 unref 兜底(不阻止进程退出)。
|
|
254
|
-
if (typeof ctx.effect === 'function') ctx.effect(() => () => engine.dispose())
|
|
255
|
-
} catch (err) {
|
|
256
|
-
warnLog(ctx, `autodream 初始化失败(记忆本体不受影响):${err?.message ?? err}`)
|
|
257
|
-
// 异常路径额外打一行到 stderr:warnLog 走 ctx.logger,某些启动阶段它还没接上,
|
|
258
|
-
// 而「autodream 静默不工作」正是最难查的那种故障(曾为此排查很久)。
|
|
259
|
-
console.error('[sage-mem] autodream init failed:', err && err.stack ? err.stack : err)
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
// ── 检索工具:`search_memory` / `read_memory` ──
|
|
263
|
-
//
|
|
264
|
-
// 工具是否注册成功。拿不到宿主的 `tools` 服务时为 false —— 状态卡据此换文案:
|
|
265
|
-
// 有工具就教它「用 search_memory 搜」,没有就退回「直接读记忆目录」。
|
|
266
|
-
//
|
|
267
|
-
// 为什么是个可变量:注册是**异步就绪**的(`ctx.inject` 要等依赖服务上线),而注入链
|
|
268
|
-
// 每步都跑 —— 前几步可能还没就绪、后几步才就绪,这是正常的。声明必须在这一块之前。
|
|
269
|
-
let toolsReady = false
|
|
270
|
-
//
|
|
271
|
-
// ⚠️ **用 `ctx.inject` 挂子 fiber,绝不给本类加 `static inject = ['tools']`。**
|
|
272
|
-
// 理由(`docs/decisions.md` D5,有官方依据):`inject` 是**硬依赖** —— 声明了它的插件
|
|
273
|
-
// 要等依赖就绪才会被加载。宿主没有 `tools` 服务时,整个 sage-mem 会一直待机,
|
|
274
|
-
// **记忆注入跟着一起消失**。而工具只是锦上添花,不值得拿核心功能去换。
|
|
275
|
-
// `ctx.inject` 只让这个子回调等依赖就绪,主插件照常工作;依赖永远不来就只有它不生效。
|
|
276
|
-
//
|
|
277
|
-
// 另一条实测事实(`docs/host-facts.md`):不声明 inject 时 `ctx.get('tools')` 只给
|
|
278
|
-
// `undefined`(那一刻服务还没被提供,而插件不会重跑),所以「先探测再注册」在这里
|
|
279
|
-
// 是行不通的 —— 必须走 `ctx.inject`。
|
|
280
|
-
try {
|
|
281
|
-
if (typeof ctx.inject === 'function') {
|
|
282
|
-
ctx.inject(['tools'], (inner) => {
|
|
283
|
-
try {
|
|
284
|
-
const { searchMemory, readMemory } = createMemoryTools(ctx)
|
|
285
|
-
inner.tools.register(searchMemory)
|
|
286
|
-
inner.tools.register(readMemory)
|
|
287
|
-
toolsReady = true
|
|
288
|
-
} catch (err) {
|
|
289
|
-
// 注册失败 ≠ 注入失败。出声一次,然后什么也不做。
|
|
290
|
-
warnLog(ctx, `检索工具注册失败(记忆注入不受影响):${err?.message ?? err}`)
|
|
291
|
-
}
|
|
292
|
-
})
|
|
293
|
-
}
|
|
294
|
-
} catch (err) {
|
|
295
|
-
warnLog(ctx, `检索工具挂载失败(记忆注入不受影响):${err?.message ?? err}`)
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
// ── 观测:模型自己翻了记忆目录 ──
|
|
299
|
-
//
|
|
300
|
-
// 宿主的 `tools/result` 把**每一次**工具调用的 `exec`(含 name / arguments)交给我们,
|
|
301
|
-
// 于是「模型用内置 read/grep 读了哪份记忆」第一次变得可观测 —— 此前台账只记得下
|
|
302
|
-
// 「插件注入了什么」,记不下「模型主动翻了什么」。
|
|
303
|
-
//
|
|
304
|
-
// 三条纪律:
|
|
305
|
-
// 1. **只记能可靠判定的**(`observe.js` 认 read/grep/glob 的路径参数;`pwsh` 一律不认,
|
|
306
|
-
// 那是**已知观测盲区** —— 不许把「看不到」说成「没读」)。
|
|
307
|
-
// 2. **按 session × 文件去重**:翻页(read 的 offset 不同)不刷计数,那是同一个动作。
|
|
308
|
-
// 3. **失败绝不影响任何东西**:观测是副产品,它的问题不许冒泡到工具调用链上。
|
|
309
|
-
const observedReads = createDeduper()
|
|
310
|
-
try {
|
|
311
|
-
ctx.on('tools/result', (exec) => {
|
|
312
|
-
try {
|
|
313
|
-
const cls = classifyToolExecution(exec?.name, exec?.arguments, MEMORY_DIR)
|
|
314
|
-
if (cls.kind !== 'active_read' || !cls.file) return
|
|
315
|
-
const key = accessEventKey({
|
|
316
|
-
sessionId: exec?.agent?.id ?? '',
|
|
317
|
-
file: cls.file,
|
|
318
|
-
// revision 要读盘才算得出,这里拿不到 —— 所以去重粒度是「session × 文件」。
|
|
319
|
-
// 写明这条口径,免得以后有人以为它按内容去重。
|
|
320
|
-
revision: '',
|
|
321
|
-
kind: cls.kind,
|
|
322
|
-
})
|
|
323
|
-
if (!key || !observedReads.shouldCount(key)) return
|
|
324
|
-
recordRead(cls.file, '')
|
|
325
|
-
} catch { /* 观测失败静默:它不该有任何存在感 */ }
|
|
326
|
-
})
|
|
327
|
-
} catch { /* 老宿主没有这个事件:不影响注入 */ }
|
|
328
|
-
|
|
329
|
-
// ── 写入门:给记忆目录的写入补上版本守卫 ──
|
|
330
|
-
//
|
|
331
|
-
// 实测(`scripts/events-probe.mjs`,本机 headless,2026-10-15):这两个事件**只对
|
|
332
|
-
// 「工具发起」的写入**触发 —— 模型调内置 `write` / `edit` 打到记忆文件时会走这里;
|
|
333
|
-
// 而**插件自己**调 `ctx.fs.writeText` 时一次都不发(实测 0 次),那条路由调用方自己传
|
|
334
|
-
// `expected`(见 `lib/autodream-tools.js` 的 `writeMemoryText`)。
|
|
335
|
-
// 监听器**不需要声明 inject** 也能收到,所以这里不挂子 fiber。
|
|
336
|
-
//
|
|
337
|
-
// 它买到的东西:**模型的 `write` 没法在一个记忆文件被改过之后静默覆盖它** ——
|
|
338
|
-
// 守卫过不了,宿主会以 `FS_STALE_VERSION` 明确拒绝(实测确实会被拒)。
|
|
339
|
-
// 这属于「把静默的坏结果变成显式的失败」,不是「阻止写入」:这个事件**没有拒绝动作**,
|
|
340
|
-
// 我们也不发明一个(见 write-gate.js 的头注释)。
|
|
341
|
-
//
|
|
342
|
-
// 单槽纪律:返回 `undefined` 才是「不管」。目录外**必须**返回 undefined,否则会占掉槽位、
|
|
343
|
-
// 影响整块盘的写入(write-gate.js 的 `outside_memory` 判在 `caller_wins` 之前正是为此)。
|
|
344
|
-
/**
|
|
345
|
-
* 探目标的当前版本。**区分「不存在」与「不知道」**。
|
|
346
|
-
*
|
|
347
|
-
* 这个区分不是洁癖:`!!info` 会把「拿不到 fs 服务」也写成 `exists:false`,判定表据此给
|
|
348
|
-
* `createIfAbsent` —— 而目标**其实是存在的**记忆文件时,那是一次**误拦**(写入必然失败,
|
|
349
|
-
* 模型收到一句看不懂的错误)。拿不准就不干涉,比猜一个安全。
|
|
350
|
-
*/
|
|
351
|
-
// ⚠️⚠️ **这段注册必须在 `apply()` 里直接做,绝不能挪进 `ctx.inject(['fs'], …)`,也不能给
|
|
352
|
-
// 插件加 `static inject = ['fs']`。** 这不是风格偏好,是实测出来的硬约束:
|
|
353
|
-
// 这两个事件有一个**注册窗口**,窗口在 fs 服务就绪**之前**就关了。
|
|
354
|
-
// 不 inject、apply 里直接挂 → 收到 1 次 ✅
|
|
355
|
-
// 顶层 `inject:['fs']` → **0 次**
|
|
356
|
-
// 顶层 `inject:['tokenMeter']` → **0 次**(与 fs 无关,是时机问题)
|
|
357
|
-
// `ctx.inject(['fs'], sub)` 子 fiber → **0 次**
|
|
358
|
-
// 不 inject、但 apply 之后 2.5 秒再挂 → **0 次**(晚挂收不到)
|
|
359
|
-
// 证据:`scripts/host-facts-probe.mjs` 的七组对照 + `docs/host-facts.md` 3.3 节。
|
|
360
|
-
// 之所以特别标注:**按 D5 的规范直觉(可选服务要用 ctx.inject 挂子 fiber)改这里,
|
|
361
|
-
// 会让写门静默失效** —— 编译得过、测试可能也过,只是永远不触发。
|
|
362
|
-
// 而我们**不需要** inject:实测不声明 inject 时,处理器**内部** `ctx.get('fs')` 照样拿得到服务
|
|
363
|
-
// (`{hasFs:true, hasStat:true}`),取版本就靠它。
|
|
364
|
-
const probeTarget = async (target) => {
|
|
365
|
-
try {
|
|
366
|
-
const fs = typeof ctx.get === 'function' ? ctx.get('fs') : undefined
|
|
367
|
-
if (!fs || typeof fs.stat !== 'function' || !target) return { known: false }
|
|
368
|
-
const info = await fs.stat(target)
|
|
369
|
-
return { known: true, exists: !!info, version: info?.version ?? null }
|
|
370
|
-
} catch {
|
|
371
|
-
return { known: false }
|
|
372
|
-
}
|
|
373
|
-
}
|
|
374
|
-
try {
|
|
375
|
-
if (typeof ctx.on === 'function') {
|
|
376
|
-
// 公共骨架:**先判位置、再决定要不要碰链子**。
|
|
377
|
-
//
|
|
378
|
-
// ⚠️ 三条纪律(都是踩出来的):
|
|
379
|
-
// 1. **目录外连 `next()` 都不调**。宿主对**任何**工具写入都发这个事件(实测:写一个
|
|
380
|
-
// 临时文件也发),无条件调 next() + stat 等于给整个系统的每次写文件都加一次系统调用,
|
|
381
|
-
// 而我们本来就什么都不该做。直接 `return next()` 把决策权原样交出去。
|
|
382
|
-
// 2. **一旦调了 `next()`,就必须把它给的东西交回去**。`next()` 返回的是下游(或调用方
|
|
383
|
-
// 自己)已经做出的决定;此时再 `return undefined` 会把这个决定**丢掉** ——
|
|
384
|
-
// 我们本想「不干涉」,实际却比干涉还糟。
|
|
385
|
-
// 3. **探不到版本就不碰**(见 probeTarget):把「不知道」当「不存在」会误拦真实存在的文件。
|
|
386
|
-
const gateEvent = (decide) => async (target, actor, next) => {
|
|
387
|
-
try {
|
|
388
|
-
const memoryPath = memoryPathOf(target?.displayPath, MEMORY_DIR)
|
|
389
|
-
if (!memoryPath.ok) return typeof next === 'function' ? next() : undefined
|
|
390
|
-
const caller = typeof next === 'function' ? await next() : undefined
|
|
391
|
-
const probed = await probeTarget(target)
|
|
392
|
-
if (!probed.known) return caller
|
|
393
|
-
const chosen = decide({
|
|
394
|
-
memoryPath,
|
|
395
|
-
exists: probed.exists,
|
|
396
|
-
version: probed.version,
|
|
397
|
-
caller,
|
|
398
|
-
})
|
|
399
|
-
// 我们给出决定就用我们的;决定不了(undefined)就把 `caller` 原样交回去,不吞。
|
|
400
|
-
return chosen === undefined ? caller : chosen
|
|
401
|
-
} catch {
|
|
402
|
-
return undefined /* 写门在宿主的写入链路上:抛出去会把一次正常写入炸掉 */
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
ctx.on('fs/write-intent', gateEvent((facts) => decideWriteIntent(facts).intent))
|
|
406
|
-
ctx.on('fs/edit-intent', gateEvent((facts) => decideEditGuard(facts).guard))
|
|
407
|
-
}
|
|
408
|
-
} catch { /* 老宿主没有这两个事件:写入按宿主默认语义走 */ }
|
|
59
|
+
// 四块挂载(1.2.0 从构造函数里搬出的四段)。顺序保持原样:
|
|
60
|
+
// autodream → 检索工具 → 观测 → 写入门。
|
|
61
|
+
mountAutodream(ctx, {
|
|
62
|
+
AutodreamEngine,
|
|
63
|
+
AutodreamGateway,
|
|
64
|
+
memoryDir: MEMORY_DIR,
|
|
65
|
+
sessionsRoot: SESSIONS_ROOT,
|
|
66
|
+
reservedFiles: [...RESERVED_FILES],
|
|
67
|
+
})
|
|
68
|
+
const tools = mountMemoryTools(ctx)
|
|
69
|
+
mountObserve(ctx, MEMORY_DIR)
|
|
70
|
+
// ⚠️ 写入门必须在 apply 里**同步**注册(见 lib/assembly/write-gate-mount.js 头注的「注册窗口」)。
|
|
71
|
+
mountWriteGate(ctx, MEMORY_DIR)
|
|
409
72
|
|
|
410
|
-
//
|
|
411
|
-
// 一处持有、一处提交,理由与顺序见 `createSessionInjectState` 的注释。
|
|
412
|
-
//
|
|
413
|
-
// 为什么不扫会话记录去算「已经注掉多少字节」:同一轮 assemble 出来的内容会被宿主
|
|
414
|
-
// 合进**同一条** user/message(运行时上下文、工作区指令、插件注入共用一条),
|
|
415
|
-
// 按消息字节统计会把别人的内容算进本插件的账;按文本找标记又太脆。自己记账确定性最高。
|
|
416
|
-
//
|
|
417
|
-
// baseline 为什么按会话记而不是按消息条数:恢复会话的历史消息早就在 session 里,
|
|
418
|
-
// 条数永不为 0。会话 id 在整场会话里稳定,与 assemble 时当前用户消息有没有落盘无关,
|
|
419
|
-
// 所以新会话、恢复会话两种时序都成立。
|
|
73
|
+
// 会话级注入状态:一处持有、一处提交(见 lib/recall/state.js 的头注)。
|
|
420
74
|
const injectState = createSessionInjectState()
|
|
421
75
|
|
|
422
76
|
// 按问题检索注入:system-prompt/assemble 是异步 waterfall,监听器可 await。
|
|
423
|
-
//
|
|
424
|
-
//
|
|
425
|
-
ctx.on('system-prompt/assemble',
|
|
426
|
-
const assembled = await next()
|
|
427
|
-
// session id 在 try 外先取好:catch 里也要能带上它(拿到什么带什么)。
|
|
428
|
-
const sessionId = context?.agent?.session?.id ?? ''
|
|
429
|
-
try {
|
|
430
|
-
const agent = context?.agent
|
|
431
|
-
const session = agent?.session
|
|
432
|
-
if (!session) return assembled
|
|
433
|
-
// DSH 0.1.7 起 Session 的历史读取接口在陆续调整(snapshotEvents / eventAt /
|
|
434
|
-
// ownEvents 已弃用)。deriveMessages 在 0.1.7-rc.1 实测仍在且仍同步,但这三行
|
|
435
|
-
// 不是白写的:方法一旦被改名或改成异步,老写法要么静默不注入、要么把 Promise
|
|
436
|
-
// 当数组展开成空。宁可日志里响一声,也不要"记忆不注入但日志一字没有"。
|
|
437
|
-
if (typeof session.deriveMessages !== 'function') {
|
|
438
|
-
warnLog(ctx, 'session.deriveMessages() 不可用 —— 记忆召回已跳过(DSH session API 变了?)', sessionId)
|
|
439
|
-
return assembled
|
|
440
|
-
}
|
|
441
|
-
const derived = session.deriveMessages()
|
|
442
|
-
const messages = [...(derived && typeof derived.then === 'function' ? await derived : derived)]
|
|
443
|
-
|
|
444
|
-
// 关键:DSH 0.1.5 把工具结果(source.kind === 'tool')和注入内容
|
|
445
|
-
// (source.kind === 'plugin')也存成 role: 'user' 的消息。只按 role 取
|
|
446
|
-
// 「最后一条」会取到 tool-result,extractText 得到空串,下面的长度检查
|
|
447
|
-
// 直接返回——表现就是全程静默不注入。只有 source.kind === 'user' 是真人输入。
|
|
448
|
-
const userMessages = messages.filter(m => m?.role === 'user' && m?.source?.kind === 'user')
|
|
449
|
-
const text = extractText(userMessages[userMessages.length - 1]?.content)
|
|
450
|
-
|
|
451
|
-
const files = await scanMemoryFiles(ctx, sessionId)
|
|
452
|
-
|
|
453
|
-
// ── 代次:**先处理状态转换,再算正文与控制提示** ──
|
|
454
|
-
// 压缩 / 清空(消息条数下降)在这一行里就让签名、两本账、卡签名、baseline 标记
|
|
455
|
-
// 一起归零。放到后面(比如去重之后再算)就等于「拿上一代的账本判这一代」——
|
|
456
|
-
// 复审 R03 的「同题压缩后仍被挡住」正是这么来的。
|
|
457
|
-
const ledgerKey = session.id || 'no-session-id'
|
|
458
|
-
const st = injectState.begin(ledgerKey, messages.length)
|
|
459
|
-
|
|
460
|
-
const picked = []
|
|
461
|
-
const seen = new Set()
|
|
462
|
-
const take = (f) => {
|
|
463
|
-
if (seen.has(f.file)) return
|
|
464
|
-
seen.add(f.file)
|
|
465
|
-
picked.push(f)
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
// baseline:每个会话注入一次,**不看消息条数**。
|
|
469
|
-
//
|
|
470
|
-
// 旧写法 `if (userMessages.length === 0)` 依赖一个真实存在的时序:assemble
|
|
471
|
-
// 触发时当前这条用户消息还没写进 session。已在 DSH 0.1.7-rc.1 上核实为真 ——
|
|
472
|
-
// 宿主从 pending 队列取走本轮输入
|
|
473
|
-
// 组装 system prompt(本监听器在这里跑)
|
|
474
|
-
// 之后才把这条 user 消息落进 session
|
|
475
|
-
// 所以全新会话首步 pick 到 0 条 user message,分支命中。
|
|
476
|
-
//
|
|
477
|
-
// 但**恢复 / 续聊会话**的历史消息早就在 session 里,条数永远 ≥ 1 → 分支永不
|
|
478
|
-
// 命中 → 永不注入。而「继续吧」这类短问题在 description 里找不到匹配、检索
|
|
479
|
-
// 返回 0 条,baseline 恰恰是那时唯一的兜底。这是本会话级状态要修的东西。
|
|
480
|
-
let baselineTaken = 0
|
|
481
|
-
if (!st.baselineDone) {
|
|
482
|
-
selectBaseline(files)
|
|
483
|
-
.forEach(f => {
|
|
484
|
-
const before = picked.length
|
|
485
|
-
take(f)
|
|
486
|
-
if (picked.length > before) baselineTaken++
|
|
487
|
-
})
|
|
488
|
-
// 只有真取到才算「给过」:首步若正好撞上目录读不到(scanMemoryFiles 降级返回 []),
|
|
489
|
-
// 整场会话就再也没机会补 baseline 了。宁可下一步重算一次。
|
|
490
|
-
// 标记本身随这一步一起在 commit() 落 —— 与「提交放最后」同一条理由:
|
|
491
|
-
// 没真注进去的东西不能记成「已经给过」。
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
// 检索签名只覆盖「检索出来的那批」,baseline 不再掺进这个签名。
|
|
495
|
-
// 旧版两者共用 signature,首步签名一旦与后续某步相同,baseline 会连坐被吞;
|
|
496
|
-
// 反过来,旧版把 baseline 文件名也算进签名,会让同一步的检索结果在下一步
|
|
497
|
-
// 因签名变化而被重复注入。现在 baseline 由会话级标记保证只来一次,
|
|
498
|
-
// 检索由这份签名保证同一问题不重复追加,两条线互不干扰。
|
|
499
|
-
const relevant = text && text.length >= 2 ? selectRelevant(text, files) : []
|
|
500
|
-
|
|
501
|
-
// ── 意图:先判,因为它要影响下面正文检索的**门槛松紧** ──
|
|
502
|
-
// 用户是不是在问自己的历史(上次 / 之前 / 更正 / 问依据 / 继续)。
|
|
503
|
-
// 判不出来就是 `none`,不猜 —— 误判的代价是把不该抑制的正文抑制掉。
|
|
504
|
-
const intentInfo = text ? detectIntent(text) : { intent: 'none', historyDependent: false, suppressible: true }
|
|
505
|
-
|
|
506
|
-
// ── 正文检索 ──
|
|
507
|
-
// 摘要(description + aliases + 文件名)是最省的检索面,代价是**正文里的东西天生检索不到**。
|
|
508
|
-
// 这里用它做两件事:
|
|
509
|
-
// 1. 给候选文件定位「最相关的那一段」,注入时替代「文件开头 1500 字」——
|
|
510
|
-
// 旧写法对任何问题都端同一段开头,等于把「命中在哪」这件事扔掉了;
|
|
511
|
-
// 2. 摘要一条都没命中时,用正文兜一次底。此前这种情况直接等于「没有记忆可用」。
|
|
512
|
-
//
|
|
513
|
-
// query 短于 4 个字符不做正文匹配:`继续` 这种两个字的问题拿去匹配正文,
|
|
514
|
-
// 命中的全是「继续」这个词本身,与用户问什么毫无关系。
|
|
515
|
-
//
|
|
516
|
-
// **门槛按意图放宽**:默认要求「连续命中 ≥ 2 个词元」(那条防的是跨词边界的假词元,
|
|
517
|
-
// 见 search.js)。代价是整句只对上一个两字专名时会被拦下 —— 那是 0.9.16 有意付的。
|
|
518
|
-
// 但用户**明确在问自己的历史**时,宁可多给一条也不该让他拿不到依据:这类问题的
|
|
519
|
-
// 代价不对称(少给 = 答错,多给 = 多花一点上下文)。所以这时把 `minRun` 放到 1。
|
|
520
|
-
const bodyRanked = typeof text === 'string' && text.length >= 4
|
|
521
|
-
? rankChunks(text, files, {
|
|
522
|
-
limit: MAX_RESULTS,
|
|
523
|
-
perFile: 1,
|
|
524
|
-
...(intentInfo.historyDependent ? { minEvidence: { minRun: 1 } } : {}),
|
|
525
|
-
})
|
|
526
|
-
: []
|
|
527
|
-
const bestChunk = new Map()
|
|
528
|
-
for (const h of bodyRanked) if (!bestChunk.has(h.file)) bestChunk.set(h.file, h.chunk)
|
|
529
|
-
|
|
530
|
-
// 「摘要命中就按摘要排;一条都没有才让正文接手」这个**保守写法是错的**,
|
|
531
|
-
// 由评测数据推翻(scripts/eval-retrieval.mjs 的对照 + 注入链实测):
|
|
532
|
-
// 摘要只要命中**任何一条**(哪怕很弱、甚至命中错的文件),正文里那份真正相关的
|
|
533
|
-
// 就再也没有机会——应注入的文件级召回从 87.0% 掉到 60.9%。而噪音并没有因此更低
|
|
534
|
-
// (正文命中本来就要过相关性门槛,实测误召回 1/10,与保守写法持平)。
|
|
535
|
-
//
|
|
536
|
-
// 所以改成**并集**:摘要命中的在前(它经过人工写的 description,精确度高),
|
|
537
|
-
// 正文独有的在后(按 BM25 + 证据强度排序)。`take` 按文件去重,
|
|
538
|
-
// 同一份文件不会因为两条检索线都命中而占两个名额。
|
|
539
|
-
const byFile = new Map(files.map(f => [f.file, f]))
|
|
540
|
-
const selected = relevant.slice()
|
|
541
|
-
const taken = new Set(selected.map(f => f.file))
|
|
542
|
-
for (const h of dedupeByFile(bodyRanked)) {
|
|
543
|
-
if (taken.has(h.file)) continue
|
|
544
|
-
taken.add(h.file)
|
|
545
|
-
const f = byFile.get(h.file)
|
|
546
|
-
if (f) selected.push(f)
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
const signature = text + '\u0000' + selected.map(f => f.file).join(',')
|
|
550
|
-
// 检索签名没变 = 这一代里这条问题已经处理过 → 不再重复追加同一份记忆。
|
|
551
|
-
// ⚠️ 这个判断必须排在代次处理**之后**:压缩后的同题恢复,靠的就是上面 `begin()`
|
|
552
|
-
// 把签名一起清掉(复审 R03)。
|
|
553
|
-
const deduped = st.signature === signature
|
|
554
|
-
if (!deduped) selected.forEach(take)
|
|
555
|
-
|
|
556
|
-
// ⚠️ 这里**不能**再写「picked 为空就返回」:检查点句柄与未命中提示都不依赖候选,
|
|
557
|
-
// 提前返回会让它们在「目录为空」或「零命中」时永远不可达(复审 R02)。
|
|
558
|
-
// 统一的出口在下面 —— 只有**正文与控制提示都为空**才返回。
|
|
559
|
-
|
|
560
|
-
// ── 单会话累计预算:**拆成两本账** ──
|
|
561
|
-
// 正文池 = CONTENT_BUDGET(总上限 − 状态卡预留)/卡池 = STATUS_RESERVE
|
|
562
|
-
// 拆的理由见 config.js:到顶之后旧行为是一个字都不注、连「为什么没注、还能怎么查」
|
|
563
|
-
// 都没有;而那个上限是**本插件自己定的**,完全可以给状态卡留一份固定额度。
|
|
564
|
-
// 账记在 session id 上;picked 里 baseline 永远排在检索结果之前,所以预算不够时
|
|
565
|
-
// 先被挤掉的是「更晚才想起来的」那些 —— baseline 有优先权。
|
|
566
|
-
// 起点来自 `begin()`:它可能刚因压缩把两本账连同卡签名一起归零过。
|
|
567
|
-
let contentBytes = st.contentBytes
|
|
568
|
-
let cardBytes = st.cardBytes
|
|
569
|
-
let cardSig = st.cardSig
|
|
570
|
-
const nowMs = Date.now()
|
|
571
|
-
|
|
572
|
-
// ── 渲染候选,然后交给展示层决定「给正文 / 给指针 / 不给」──
|
|
573
|
-
// 两件事分开做:这一段只负责「每条候选长什么样」,`decideDelivery` 负责
|
|
574
|
-
// 「在预算内该给哪几条、为什么」。混在一起写就是上一版的样子 —— 预算、渲染、
|
|
575
|
-
// 去重三件事挤在一个循环里,谁也说不清某条为什么没进去。
|
|
576
|
-
const candidates = []
|
|
577
|
-
for (const f of picked) {
|
|
578
|
-
// **baseline 不吃段落替换**:它的语义是「这条记忆每场会话都该完整在场」
|
|
579
|
-
// (你是谁、待办清单)。一旦用户的问句碰巧命中它,替换成单个段落就等于
|
|
580
|
-
// 把「完整在场」偷偷降级成「命中在场」—— 那正是 baseline 要防的事。
|
|
581
|
-
const chunk = f.baseline ? null : bestChunk.get(f.file)
|
|
582
|
-
// 命中段落优先;没有命中(baseline、或 query 太短没做正文检索)才回到「文件开头」。
|
|
583
|
-
// 旧写法对**任何**问题都端同一段开头,等于把「命中在哪」这个信息扔掉了。
|
|
584
|
-
const raw = chunk ? chunk.text : f.content
|
|
585
|
-
const body = raw.length > MAX_CHARS_PER_FILE
|
|
586
|
-
? raw.slice(0, MAX_CHARS_PER_FILE) + '\n…(截断)'
|
|
587
|
-
: raw
|
|
588
|
-
const days = ageDays(f, nowMs)
|
|
589
|
-
const stamp = days !== null && days >= STALE_DAYS ? `(保存于 ${days} 天前)` : ''
|
|
590
|
-
// 段落命中时补两样东西:
|
|
591
|
-
// - 行号:模型据此能直接用文件工具跳过去核对来源,「引用可核对」靠它成立;
|
|
592
|
-
// - 摘要:段落本身不含 frontmatter,补回「这一段是从哪条记忆里切出来的」。
|
|
593
|
-
const loc = chunk ? `:${chunk.startLine}-${chunk.endLine}` : ''
|
|
594
|
-
const brief = chunk && f.description ? ` · ${f.description.slice(0, 80)}` : ''
|
|
595
|
-
candidates.push({
|
|
596
|
-
file: f.file,
|
|
597
|
-
id: chunk?.id ?? `${f.file}#head`,
|
|
598
|
-
revision: chunk?.revision ?? '',
|
|
599
|
-
score: f.score ?? 0,
|
|
600
|
-
baseline: f.baseline === true,
|
|
601
|
-
lines: chunk ? [chunk.startLine, chunk.endLine] : null,
|
|
602
|
-
title: f.description || '',
|
|
603
|
-
text: `### ${f.file}${loc}${stamp}${brief}\n${body}`,
|
|
604
|
-
})
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
const delivery = decideDelivery(candidates, {
|
|
608
|
-
intent: intentInfo.intent,
|
|
609
|
-
// ⚠️ 这里**故意传空集**:按 revision 抑制重复对「同一份文件的不同段落」太粗 ——
|
|
610
|
-
// 换一个问题往往会命中同一份文件的**另一段**,按文件级 revision 抑制会把新相关
|
|
611
|
-
// 的那段一起挡掉。要启用它得先用「段落身份」而不是「文件 revision」做键,
|
|
612
|
-
// 并且单独评测一次。这条记在 docs/decisions.md 的 D15,不是遗漏。
|
|
613
|
-
visibleRevisions: new Set(),
|
|
614
|
-
scopeDenied: new Set(),
|
|
615
|
-
conflicts: new Set(),
|
|
616
|
-
expired: new Set(),
|
|
617
|
-
needle: text ? text.slice(0, 40) : '',
|
|
618
|
-
}, { bytes: Math.max(0, CONTENT_BUDGET - contentBytes) })
|
|
619
|
-
|
|
620
|
-
const parts = []
|
|
621
|
-
const injectedFiles = []
|
|
622
|
-
let skippedForBudget = 0
|
|
623
|
-
for (const item of delivery.items) {
|
|
624
|
-
if (item.kind === 'snippet') {
|
|
625
|
-
parts.push(item.text)
|
|
626
|
-
injectedFiles.push(item.file)
|
|
627
|
-
} else {
|
|
628
|
-
// pointer / suppress / unresolved 都**不进正文**。
|
|
629
|
-
// 「被省略几条」这件事由下面的状态卡统一说,正文里再逐条列一行
|
|
630
|
-
// `### 某某.md(未展开)` 是重复的信息,还白占上下文 —— 卡片本来就带着
|
|
631
|
-
// 「最佳线索 + 一个可做的动作」。
|
|
632
|
-
skippedForBudget++
|
|
633
|
-
}
|
|
634
|
-
}
|
|
635
|
-
// 按**实际进正文的字节**记账,而不是 delivery.totals —— 后者含指针的额度,
|
|
636
|
-
// 而指针没有真的进上下文。两本账要对得上,就不能把没花出去的钱记进去。
|
|
637
|
-
contentBytes += parts.reduce((n, p) => n + Buffer.byteLength(p, 'utf8'), 0)
|
|
638
|
-
|
|
639
|
-
// 进上下文的这些才算「被用过」—— 台账是自动归档阈值的唯一数据源,记错一步阈值就偏。
|
|
640
|
-
// recordAccess 同步、绝不抛:台账的问题不许升级成注入失败。
|
|
641
|
-
recordAccess(injectedFiles, nowMs)
|
|
642
|
-
|
|
643
|
-
// ── 缺口状态卡 ──
|
|
644
|
-
// 它**替换**原来那行状态文字,不是额外追加:同一件事不说两遍。
|
|
645
|
-
// 卡池与正文池互不借用 —— 正文满了照样发得出卡,这正是预留的意义(见 config.js)。
|
|
646
|
-
const bodySearched = typeof text === 'string' && text.length >= 4
|
|
647
|
-
const bodyOnly = bodySearched ? dedupeByFile(bodyRanked) : []
|
|
648
|
-
const unread = Math.max(0, selected.length - injectedFiles.length)
|
|
649
|
-
// 最佳线索取「正文命中里第一条没被注进去的」——那正是模型下一步最该去读的东西。
|
|
650
|
-
const beacon = bodyOnly.find(h => !injectedFiles.includes(h.file)) ?? bodyOnly[0] ?? null
|
|
651
|
-
// 什么时候该说话:
|
|
652
|
-
// - **有真缺口**(有候选没读、或有候选被预算省略)→ 说;
|
|
653
|
-
// - **没有缺口也没有命中** → 只在「用户这句话在问自己的历史」时说。
|
|
654
|
-
// 后者是计划里点名的一条:没有历史依赖时不要插「你可能遗漏记忆」,否则提示
|
|
655
|
-
// 每轮都出现,等真有缺口时它已经被当噪音忽略了。
|
|
656
|
-
const gapExists = unread > 0 || skippedForBudget > 0
|
|
657
|
-
|
|
658
|
-
// ── 任务检查点:压缩后的恢复入口 ──
|
|
659
|
-
// 宿主**什么时候**压缩上下文,插件控制不了 —— 所以这里**不承诺「压缩前保存」**,
|
|
660
|
-
// 只保证「存在检查点就给一条短句柄」。压缩后两本账与卡的签名会一起归零,
|
|
661
|
-
// 句柄因此会重新出现,这就是恢复路径(而不是靠某个压缩前钩子)。
|
|
662
|
-
//
|
|
663
|
-
// 任务 id:`SAGE_MEM_TASK_ID` 优先(给「跨会话的同一条任务」用),否则用 session id。
|
|
664
|
-
// **绝不用查询文本自定作用域** —— 那句话是模型或用户写的,不该决定去读哪个目录。
|
|
665
|
-
const taskId = process.env.SAGE_MEM_TASK_ID || session.id || ''
|
|
666
|
-
let checkpointLine = ''
|
|
667
|
-
try {
|
|
668
|
-
const cp = taskId ? await readCheckpoint(taskId, { stateRoot: STATE_ROOT }) : null
|
|
669
|
-
if (cp?.ok && cp.handle) checkpointLine = cp.handle
|
|
670
|
-
} catch { /* 读不到检查点就当没有:它是可选的接力棒,不是必需件 */ }
|
|
671
|
-
|
|
672
|
-
const wantCard = gapExists || intentInfo.historyDependent || !!checkpointLine
|
|
673
|
-
// 签名没变时沿用上一步渲染好的那份缺口卡,而不是重渲染一张。
|
|
674
|
-
// 两条理由:其一,那一步与这一步的检索面**逐字相同**,缺口也相同,重渲染只会把
|
|
675
|
-
// 同一件事换个数字再说一遍 —— 而局部变量算出来的数字还是错的(上一步注了 N 条,
|
|
676
|
-
// 这一步按本轮 parts 算是 0 条);其二,文本一变 `cardSig` 去重就失效,同一张语义
|
|
677
|
-
// 的卡会被反复追加进会话。
|
|
678
|
-
// 检查点句柄不在此列:它是可能刚刚才出现的接力棒,所以始终参与合成。
|
|
679
|
-
const cardBody = !wantCard
|
|
680
|
-
? ''
|
|
681
|
-
: deduped
|
|
682
|
-
? st.gapCard
|
|
683
|
-
: renderStatusCard({
|
|
684
|
-
scope: bodySearched ? 'all' : 'summary',
|
|
685
|
-
scanned: files.length,
|
|
686
|
-
injected: parts.length,
|
|
687
|
-
omitted: skippedForBudget,
|
|
688
|
-
unread,
|
|
689
|
-
best: beacon ? { file: beacon.file, title: beacon.chunk?.title || beacon.chunk?.headingPath || '' } : null,
|
|
690
|
-
needle: text ? text.slice(0, 40) : null,
|
|
691
|
-
toolsAvailable: toolsReady,
|
|
692
|
-
memoryDir: MEMORY_DIR,
|
|
693
|
-
})
|
|
694
|
-
// 句柄与卡**共用卡池**:它是同一个通道里的另一句提示,不该另开一份额度。
|
|
695
|
-
const cardText = [cardBody, checkpointLine].filter(Boolean).join('\n')
|
|
696
|
-
let cardPart = ''
|
|
697
|
-
// 卡按**内容签名**去重:签名没变就不重复发。否则每一步都追加一张一模一样的卡,
|
|
698
|
-
// 而每张卡都是落到会话里的持久消息 —— 那不叫提示,那叫刷屏。
|
|
699
|
-
if (cardText && cardText !== cardSig) {
|
|
700
|
-
const cardSize = Buffer.byteLength(cardText, 'utf8')
|
|
701
|
-
if (cardBytes + cardSize <= STATUS_RESERVE) {
|
|
702
|
-
cardBytes += cardSize
|
|
703
|
-
cardPart = cardText
|
|
704
|
-
cardSig = cardText
|
|
705
|
-
}
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
// 正文与卡都没有 → 一个字都不注。注意这与旧行为不同:旧行为「正文池满」就到此为止,
|
|
709
|
-
// 现在只要卡池还有额度就会发出卡(说清范围、缺口与下一步动作)。
|
|
710
|
-
if (parts.length === 0 && !cardPart) {
|
|
711
|
-
if (DEBUG_RECALL) {
|
|
712
|
-
debugRecall({
|
|
713
|
-
scanned: files.length, hits: relevant.length,
|
|
714
|
-
scores: relevant.map(f => [f.file, Number(f.score.toFixed(4))]),
|
|
715
|
-
picked: picked.map(f => f.file), injected: [], chars: contentBytes, skippedByBudget: skippedForBudget,
|
|
716
|
-
})
|
|
717
|
-
}
|
|
718
|
-
return assembled
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
// 状态一次写回:签名、两本账、卡签名、缺口卡、条数、baseline 标记。
|
|
722
|
-
// 放在这里(而不是更早)是刻意的 —— 上面每一步都可能没真注进去东西,
|
|
723
|
-
// 那就不该让状态前进;一次失败把一条记忆永久挡在门外是最难查的一种。
|
|
724
|
-
injectState.commit(ledgerKey, {
|
|
725
|
-
signature,
|
|
726
|
-
contentBytes,
|
|
727
|
-
cardBytes,
|
|
728
|
-
cardSig,
|
|
729
|
-
gapCard: cardBody,
|
|
730
|
-
messageCount: messages.length,
|
|
731
|
-
baselineDone: st.baselineDone || baselineTaken > 0,
|
|
732
|
-
})
|
|
733
|
-
|
|
734
|
-
if (DEBUG_RECALL) {
|
|
735
|
-
debugRecall({
|
|
736
|
-
scanned: files.length,
|
|
737
|
-
hits: relevant.length,
|
|
738
|
-
scores: relevant.map(f => [f.file, Number(f.score.toFixed(4))]),
|
|
739
|
-
picked: picked.map(f => f.file),
|
|
740
|
-
injected: injectedFiles,
|
|
741
|
-
chars: contentBytes,
|
|
742
|
-
skippedByBudget: skippedForBudget,
|
|
743
|
-
card: cardPart ? 'sent' : (cardText ? 'deduped-or-over-budget' : 'none'),
|
|
744
|
-
})
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
// 只有正文时:头部 + 引用纪律 + 正文 [+ 卡]
|
|
748
|
-
// 只有卡时:**就只发卡** —— 头部那句「以下是与你当前问题相关的历史记忆」在没有正文
|
|
749
|
-
// 的情况下是假陈述,而引用纪律管的是「别把记过的东西当当前事实」,没有正文就没有
|
|
750
|
-
// 需要约束的对象。
|
|
751
|
-
const recallText = parts.length
|
|
752
|
-
? `以下是与你当前问题相关的历史记忆(文件式,来自 ${MEMORY_DIR}):\n\n` +
|
|
753
|
-
`${RECALL_DISCIPLINE}\n\n${parts.join('\n\n')}` +
|
|
754
|
-
(cardPart ? `\n\n${cardPart}` : '')
|
|
755
|
-
: cardPart
|
|
756
|
-
|
|
757
|
-
return {
|
|
758
|
-
...assembled,
|
|
759
|
-
contexts: [...(assembled.contexts ?? []), {
|
|
760
|
-
name: 'sage-mem:recall',
|
|
761
|
-
order: 1000,
|
|
762
|
-
text: recallText,
|
|
763
|
-
}],
|
|
764
|
-
}
|
|
765
|
-
} catch (err) {
|
|
766
|
-
// 这是整条注入链的外壳。裸吞的代价就是 :174-193 记的那个坑:权限错、编码坏、
|
|
767
|
-
// frontmatter 正则哪天不匹配,表现都是「插件在、但记忆不再注入」,日志一字没有。
|
|
768
|
-
// 现在只降级(返回未注入的 assembly),但一定留痕。
|
|
769
|
-
warnLog(ctx, `recall injection failed: ${err?.message ?? err}`, sessionId)
|
|
770
|
-
return assembled
|
|
771
|
-
}
|
|
772
|
-
})
|
|
77
|
+
// 编排骨架在 lib/recall/injector.js,选材 / 渲染 / 预算三段是纯函数;行为由
|
|
78
|
+
// test/recall-golden-test.mjs 的 11 个场景逐字节守着。
|
|
79
|
+
ctx.on('system-prompt/assemble', createRecallInjector({ ctx, state: injectState, tools }))
|
|
773
80
|
}
|
|
774
81
|
|
|
775
82
|
/**
|
|
@@ -1027,6 +334,21 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
1027
334
|
return { ok: true, file: safe }
|
|
1028
335
|
}
|
|
1029
336
|
|
|
337
|
+
/**
|
|
338
|
+
* **彻底删除**一条已归档记忆(不可恢复)。
|
|
339
|
+
*
|
|
340
|
+
* 与 `deleteFile` 的区别要在代码里就分开:那个删的是活动记忆,这个删的是档案馆里的退役件;
|
|
341
|
+
* 归档「只移不删」的承诺不变 —— 走进档案馆之后,删不删由人单独决定。
|
|
342
|
+
*
|
|
343
|
+
* `archivedAt` 是**身份判据**(见 `memory/archive.js` 的头注):调用方交回它当时看见的
|
|
344
|
+
* 归档时间,盘上那份对不上就拒绝。不传 / 传空串只对「本来就没有留痕的老归档件」成立。
|
|
345
|
+
*/
|
|
346
|
+
async deleteArchived(name, archivedAt) {
|
|
347
|
+
const res = await deleteArchivedMemory(MEMORY_DIR, name, archivedAt)
|
|
348
|
+
if (!res.ok) return { ok: false, error: res.error }
|
|
349
|
+
return { ok: true, file: res.file }
|
|
350
|
+
}
|
|
351
|
+
|
|
1030
352
|
/**
|
|
1031
353
|
* 记忆目录体检:结构化审计结果。
|
|
1032
354
|
*
|
|
@@ -1233,6 +555,7 @@ markRemote(MemoryGateway, 'restore', 'restore')
|
|
|
1233
555
|
markRemote(MemoryGateway, 'listArchived', 'listArchived')
|
|
1234
556
|
markRemote(MemoryGateway, 'readArchived', 'readArchived')
|
|
1235
557
|
markRemote(MemoryGateway, 'writeArchived', 'writeArchived')
|
|
558
|
+
markRemote(MemoryGateway, 'deleteArchived', 'deleteArchived')
|
|
1236
559
|
markRemote(MemoryGateway, 'audit', 'audit')
|
|
1237
560
|
markRemote(MemoryGateway, 'checkUpdate', 'checkUpdate')
|
|
1238
561
|
markRemote(MemoryGateway, 'setLocked', 'setLocked')
|