@dafantong01/dsh-memory-palace 0.0.0-stage → 0.1.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/lib/index.js ADDED
@@ -0,0 +1,2107 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import os from 'node:os'
4
+ import { randomUUID } from 'node:crypto'
5
+ import z from '@deepseek-ai/schemastery'
6
+ import {
7
+ resolveSessionLog, decodeEvents, decompressLog, estimateTokens, buildTurns, writeArchive, appendMonthSummary, reconcilePendingIndex, alreadyArchived, retainedFrom, retainedFromCapped, turnBytes, turnTokens, fmtTurnSpan, detectContextWindow, fmtTime, fmtMonth,
8
+ } from './archive.js'
9
+ import { createMemoryPalaceRoute, MEMORY_PALACE_ROUTE } from './route.js'
10
+
11
+ export const name = 'dsh-memory-palace'
12
+
13
+ /** Cordis 服务注入:不声明 inject,ctx.tools 会报 "cannot get property tools without inject"。 */
14
+ export const inject = ['tools', 'commands', 'webServer', 'agents', 'tokenMeter']
15
+
16
+ const TEXT_OUTPUT = {
17
+ schema: { type: 'string' },
18
+ render: (_args, value) => [{ type: 'text', text: typeof value === 'string' ? value : JSON.stringify(value, null, 2) }],
19
+ }
20
+
21
+ /**
22
+ * 全局默认配置(profile 级)。
23
+ * 所有字段声明为 volatile:DSH 插件页只把可热更新的字段渲染成表单,读取一律走 .get()。
24
+ * 工作区级的 memory-palace/config.json 会覆盖这里的同名项。
25
+ */
26
+ export const Config = z.object({
27
+ enabled: z.boolean()
28
+ .volatile()
29
+ .default(true)
30
+ .description('全局总闸。默认允许,但真正生效还需要目标工作区里显式开启(memory-palace/config.json 的 enabled=true)。'),
31
+ triggerPressure: z.number().min(0.2).max(0.95)
32
+ .volatile()
33
+ .default(0.6)
34
+ .description('上下文占用估算达到该比例时触发归档(DSH 原生压缩在 80%,默认 60% 留余量)。'),
35
+ contextWindowTokens: z.number().min(1000)
36
+ .volatile()
37
+ .default(1048576)
38
+ .description('用于估算占用率的上下文窗口大小(token)。'),
39
+ retainBytes: z.number().min(0)
40
+ .volatile()
41
+ .default(100000)
42
+ .description('保留最近多少字节不归档(retainFraction<=0 时才用它)。'),
43
+ batchByteFraction: z.number().min(0.05).max(1)
44
+ .volatile()
45
+ .default(0.5)
46
+ .description('批量兜底:可归档文本量达到「无需归档量」的这个比例时,即使轮数不够最小批量也触发。'),
47
+ retainFraction: z.number().min(0).max(0.5)
48
+ .volatile()
49
+ .default(0.12)
50
+ .description('保留窗口占上下文窗口的比例上限(默认 12%,低于 DSH 压缩默认的 16% 尾部)。'),
51
+ maxFileBytes: z.number().min(1000)
52
+ .volatile()
53
+ .default(5000000)
54
+ .description('单个归档文件上限(字节)。超过会按轮次切分成 -p1、-p2 多个文件(按轮粒度:单轮超限时该轮独占一卷,可能超过上限)。'),
55
+ enabledSessions: z.array(z.string())
56
+ .volatile()
57
+ .default([])
58
+ .description('会话白名单(session id)。非空时只有这些会话可能触发,其余静默。'),
59
+ enableSteer: z.boolean()
60
+ .volatile()
61
+ .default(true)
62
+ .description('是否用 steer 注入归档提醒。关闭后只保留工具,不主动提示。'),
63
+ compactAfterArchive: z.boolean()
64
+ .volatile()
65
+ .default(false)
66
+ .description('归档后是否触发一次上下文压缩。compactNow 不接受范围,压缩引擎按 DSH 自己的保留策略决定留多少(默认约 16% 历史 + 64K 余量),与本插件的 30 轮/100KB 保留窗口无关;两者不一致时可能压掉尚未归档的近期轮次。默认关闭。'),
67
+ includeReasoning: z.boolean()
68
+ .volatile()
69
+ .default(true)
70
+ .description('归档是否包含助手思考原文(reasoning)。关掉可避免源码片段/内部判断进入归档。'),
71
+ checkEveryTurns: z.number().min(1)
72
+ .volatile()
73
+ .default(5)
74
+ .description('每多少轮检查一次是否该归档(越小越灵敏,开销也越大)。'),
75
+ minBatchTurns: z.number().min(1)
76
+ .volatile()
77
+ .default(10)
78
+ .description('一次至少攒够多少轮才触发归档;不够就继续等,避免每几轮生成一个文件。'),
79
+ selfNameSlug: z.string()
80
+ .volatile()
81
+ .default('assistant')
82
+ .description('归档文件名前缀:会话模型自称的拼音(默认 assistant)。'),
83
+ rootDirName: z.string()
84
+ .volatile()
85
+ .default('memory-palace')
86
+ .description('工作区下的根目录名,必须全英文小写。'),
87
+ enabledWorkspaces: z.array(z.string())
88
+ .volatile()
89
+ .default([])
90
+ .description('只在这些工作区生效(绝对路径)。空数组 = 不启用(fail-closed),必须显式填写才会工作。'),
91
+ injectNotice: z.boolean()
92
+ .volatile()
93
+ .default(true)
94
+ .description('触发时是否注入一条极短提示,叫模型调用归档工具。'),
95
+ searchTurnLimit: z.number().min(1).max(30)
96
+ .volatile()
97
+ .default(12)
98
+ .description('palace_search 默认按轮聚合时返回多少轮(整数,1–30)。'),
99
+ groupByTurn: z.boolean()
100
+ .volatile()
101
+ .default(true)
102
+ .description('palace_search 是否默认按轮聚合(每轮一条代表命中)。'),
103
+ recallStrength: z.string()
104
+ .volatile()
105
+ .default('low')
106
+ .description('回忆强度:off / low / medium / high / forced。off=只归档不读;low=优先读摘要;medium=摘要→检索→原文;high=允许递归;forced=每条用户消息后都必须检索并回报。'),
107
+ recursionMaxDepth: z.number().min(1).max(5)
108
+ .volatile()
109
+ .default(1)
110
+ .description('递归回忆的最大轮次(仅 high/forced 生效,整数 1–5)。'),
111
+ recursionMaxWidth: z.number().min(1).max(5)
112
+ .volatile()
113
+ .default(1)
114
+ .description('每轮递归最多追几个关键词(仅 high/forced 生效,整数 1–5)。旧键,等价于 recallBudgetKeywords。'),
115
+ recallBudgetRounds: z.number().min(1).max(5)
116
+ .volatile()
117
+ .default(1)
118
+ .description('回忆预算:最多几轮(仅 high/forced 生效,1–5)。只是给模型的建议上限,插件不强制执行递归。'),
119
+ recallBudgetKeywords: z.number().min(1).max(5)
120
+ .volatile()
121
+ .default(1)
122
+ .description('回忆预算:每轮最多几个关键词(仅 high/forced 生效,1–5)。同样只是建议上限。'),
123
+ })
124
+
125
+ /** 允许工作区级覆盖的键:enabled 也开放,工作区配置文件就是开关。 */
126
+ const OVERRIDABLE = [
127
+ 'enabled', 'triggerPressure', 'contextWindowTokens', 'retainFraction', 'batchByteFraction', 'retainBytes', 'maxFileBytes',
128
+ 'checkEveryTurns', 'minBatchTurns', 'selfNameSlug', 'injectNotice', 'includeReasoning', 'compactAfterArchive',
129
+ 'searchTurnLimit', 'groupByTurn', 'recallStrength', 'recursionMaxDepth', 'recursionMaxWidth',
130
+ 'recallBudgetRounds', 'recallBudgetKeywords',
131
+ ]
132
+
133
+ /** 去掉 UTF-8 BOM(旧文件可能是带 BOM 写的,按行 JSON.parse 会全失败)。 */
134
+ function stripBom(s) {
135
+ return String(s ?? '').replace(/^\uFEFF/, '')
136
+ }
137
+
138
+ /** 读取一个 volatile 配置的当前值(每次调用都取最新)。 */
139
+ function makeReader(config) {
140
+ return (name) => {
141
+ const v = config?.[name]
142
+ if (v && typeof v.get === 'function') {
143
+ try { return v.get() } catch { return undefined }
144
+ }
145
+ return v
146
+ }
147
+ }
148
+
149
+ /**
150
+ * 写 inbox 前的形状校验。
151
+ * 事故教训:steer() 只接受 UserMessage 对象,传字符串会写坏 agent/inbox/spliced,
152
+ * 让会话加载失败。校验不过就绝对不写。
153
+ */
154
+ export function assertUserMessage(m) {
155
+ if (!m || typeof m !== 'object') throw new Error('steer 需要 UserMessage 对象,收到 ' + typeof m)
156
+ if (typeof m.id !== 'string' || !m.id) throw new Error('UserMessage 缺 id')
157
+ if (m.role !== 'user') throw new Error('UserMessage.role 必须是 user')
158
+ if (!Array.isArray(m.content) || m.content.length === 0) throw new Error('UserMessage.content 必须是非空数组')
159
+ for (const b of m.content) {
160
+ if (!b || typeof b !== 'object' || typeof b.type !== 'string') throw new Error('content 块非法')
161
+ if (b.type === 'text' && typeof b.text !== 'string') throw new Error('text 块必须带字符串 text')
162
+ }
163
+ if (!m.source || typeof m.source !== 'object' || typeof m.source.kind !== 'string' || m.source.kind === 'plugin') throw new Error('UserMessage.source 必须是带 kind 的对象')
164
+ return m
165
+ }
166
+
167
+ /** 插件内部日志,任何情况下都不抛出。 */
168
+ function safeLog(ctx, msg) {
169
+ try { ctx?.logger?.warn?.('[dsh-memory-palace] ' + msg) } catch { /* ignore */ }
170
+ }
171
+
172
+ /** 读工作区级配置(现在只当“新会话的默认值”);不存在或损坏时返回空对象。 */
173
+ function loadWorkspaceConfig(cwd, rootDirName) {
174
+ try {
175
+ const raw = JSON.parse(fs.readFileSync(path.join(cwd, rootDirName, 'config.json'), 'utf8'))
176
+ const out = {}
177
+ for (const k of OVERRIDABLE) if (raw[k] !== undefined) out[k] = raw[k]
178
+ return out
179
+ } catch {
180
+ return {}
181
+ }
182
+ }
183
+
184
+ /** 根目录名只接受英文小写 slug,防止把归档写到工作区外。 */
185
+ function safeRootDirName(name) {
186
+ const s = String(name ?? '')
187
+ return /^[a-z][a-z0-9-]{1,63}$/.test(s) ? s : 'memory-palace'
188
+ }
189
+
190
+ /**
191
+ * 记忆命名:英文(大小写均可)+ 数字 + 空格 + 下划线 + 连字符,1–32 位。
192
+ * 首尾不允许空格;中文与路径分隔符一律拒绝。
193
+ */
194
+ function sanitizeName(input) {
195
+ const raw = String(input ?? '')
196
+ if (!raw.trim()) return { ok: true, name: '' }
197
+ if (raw !== raw.trim()) return { ok: false, error: '名字的首尾不能有空格' }
198
+ if (raw.length > 32) return { ok: false, error: '名字最长 32 个字符' }
199
+ if (!/^[A-Za-z0-9][A-Za-z0-9 _-]{0,31}$/.test(raw)) {
200
+ return { ok: false, error: '名字只能用英文(大小写均可)、数字、空格、下划线、连字符;不能中文,首尾不能是空格或符号' }
201
+ }
202
+ if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(raw)) return { ok: false, error: '这是 Windows 保留名,换一个' }
203
+ return { ok: true, name: raw }
204
+ }
205
+
206
+ function nsMapPath(cwd, rootDirName) {
207
+ return path.join(cwd, rootDirName, '.namespaces.json')
208
+ }
209
+
210
+ function loadNsMap(cwd, rootDirName) {
211
+ try {
212
+ const raw = JSON.parse(fs.readFileSync(nsMapPath(cwd, rootDirName), 'utf8'))
213
+ return raw && typeof raw === 'object' ? raw : {}
214
+ } catch {
215
+ return {}
216
+ }
217
+ }
218
+
219
+ function saveNsMap(cwd, rootDirName, map) {
220
+ const dir = path.join(cwd, rootDirName)
221
+ fs.mkdirSync(dir, { recursive: true })
222
+ const file = nsMapPath(cwd, rootDirName)
223
+ const tmp = `${file}.tmp-${process.pid}`
224
+ fs.writeFileSync(tmp, JSON.stringify(map, null, 2), 'utf8')
225
+ fs.renameSync(tmp, file)
226
+ }
227
+
228
+ /** 本会话的命名空间目录:命名后 = 名字,未命名 = 会话ID。 */
229
+ function nsFor(cwd, rootDirName, sid) {
230
+ const map = loadNsMap(cwd, rootDirName)
231
+ const raw = sid ? map[sid] : undefined
232
+ // map 里的值也要校验:手改 map 不能把读写导向 rootDirName 之外。
233
+ const safe = typeof raw === 'string' && /^[A-Za-z0-9][A-Za-z0-9 _-]{0,31}$/.test(raw) ? raw : null
234
+ const folder = safe || sid || '_no-session'
235
+ return { folder, dir: path.join(cwd, rootDirName, folder), map }
236
+ }
237
+
238
+ /** 读本会话的配置文件(命名空间目录下)。 */
239
+ function loadSessionConfig(cwd, rootDirName, sid) {
240
+ const { dir } = nsFor(cwd, rootDirName, sid)
241
+ try {
242
+ const raw = JSON.parse(fs.readFileSync(path.join(dir, 'config.json'), 'utf8'))
243
+ const out = {}
244
+ for (const k of OVERRIDABLE) if (raw[k] !== undefined) out[k] = raw[k]
245
+ if (typeof raw.name === 'string') out.name = raw.name
246
+ return out
247
+ } catch {
248
+ return {}
249
+ }
250
+ }
251
+
252
+ /** 合并:全局默认 ← 工作区默认 ← 本会话配置;并附上命名空间信息。 */
253
+ function resolveConfig(read, cwd, sid) {
254
+ const rootDirName = safeRootDirName(read('rootDirName'))
255
+ const ws = cwd ? loadWorkspaceConfig(cwd, rootDirName) : {}
256
+ const base = {
257
+ enabled: Boolean(read('enabled')),
258
+ triggerPressure: Number(read('triggerPressure') ?? 0.6),
259
+ contextWindowTokens: Number(read('contextWindowTokens') ?? 1048576),
260
+ retainBytes: Number(read('retainBytes') ?? 100000),
261
+ retainFraction: Number(read('retainFraction') ?? 0.12),
262
+ batchByteFraction: Number(read('batchByteFraction') ?? 0.5),
263
+ maxFileBytes: Number(read('maxFileBytes') ?? 5000000),
264
+ checkEveryTurns: Number(read('checkEveryTurns') ?? 5),
265
+ minBatchTurns: Number(read('minBatchTurns') ?? 10),
266
+ selfNameSlug: String(read('selfNameSlug') ?? 'assistant'),
267
+ rootDirName,
268
+ enabledWorkspaces: read('enabledWorkspaces') ?? [],
269
+ enabledSessions: read('enabledSessions') ?? [],
270
+ enableSteer: read('enableSteer') !== false,
271
+ includeReasoning: read('includeReasoning') !== false,
272
+ compactAfterArchive: read('compactAfterArchive') !== false,
273
+ injectNotice: Boolean(read('injectNotice')),
274
+ searchTurnLimit: Number(read('searchTurnLimit') ?? 12),
275
+ groupByTurn: read('groupByTurn') !== false,
276
+ recallStrength: String(read('recallStrength') ?? 'low'),
277
+ workspaceEnabled: ws.enabled === true,
278
+ }
279
+ const cfg = Object.assign(base, ws)
280
+ const sess = cwd ? loadSessionConfig(cwd, rootDirName, sid) : {}
281
+ Object.assign(cfg, sess)
282
+ // 预算:会话里的新键 > 会话里的旧键 > 全局默认;两个名字始终同步,避免新键默认值盖住旧键。
283
+ const budgetRounds = sess.recallBudgetRounds ?? sess.recursionMaxDepth
284
+ const budgetWidth = sess.recallBudgetKeywords ?? sess.recursionMaxWidth
285
+ cfg.recallBudgetRounds = Number(budgetRounds ?? read('recallBudgetRounds') ?? 1)
286
+ cfg.recallBudgetKeywords = Number(budgetWidth ?? read('recallBudgetKeywords') ?? 1)
287
+ cfg.recursionMaxDepth = cfg.recallBudgetRounds
288
+ cfg.recursionMaxWidth = cfg.recallBudgetKeywords
289
+ const ns = cwd ? nsFor(cwd, rootDirName, sid) : null
290
+ cfg.name = typeof sess.name === 'string' ? sess.name : ''
291
+ cfg.folder = ns ? ns.folder : ''
292
+ cfg.dir = ns ? ns.dir : ''
293
+ cfg.hasSession = Boolean(sid)
294
+ cfg.sessionEnabled = sess.enabled === true
295
+ // 文件名前缀:会话显式设置 > 跟随记忆名 > 全局默认。
296
+ // 记忆名可能带空格/大小写,落成文件名时把空格换成连字符。
297
+ const safeSlug = (s) => String(s ?? '').replace(/[^A-Za-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'session'
298
+ if (sess.selfNameSlug) {
299
+ cfg.selfNameSlug = safeSlug(sess.selfNameSlug)
300
+ } else if (cfg.name) {
301
+ cfg.selfNameSlug = safeSlug(cfg.name)
302
+ } else {
303
+ cfg.selfNameSlug = safeSlug(cfg.selfNameSlug)
304
+ }
305
+ // 真正生效:全局总闸 + 本会话开关 + 已命名(未命名不归档)
306
+ cfg.active = Boolean(cfg.enabled) && cfg.sessionEnabled
307
+ return cfg
308
+ }
309
+
310
+ /** 统一生效门:把全局/会话/命名/提示开关收成一处,返回“为什么没生效”。 */
311
+ function gateState(cfg) {
312
+ const reasons = []
313
+ if (!cfg.enabled) reasons.push('全局总闸关闭')
314
+ if (!cfg.sessionEnabled) reasons.push('本会话开关关闭')
315
+ if (!cfg.name) reasons.push('未命名(不会归档)')
316
+ const active = reasons.length === 0
317
+ const steer = active && cfg.enableSteer !== false && cfg.injectNotice !== false
318
+ return { active, steer, reasons }
319
+ }
320
+
321
+ /** 本会话是否启用:全局总闸 + 会话开关 + 工具可见性 + 可选白名单。 */
322
+ function isActiveFor(ctx, cfg, agent) {
323
+ const gate = gateState(cfg)
324
+ if (!gate.active) return false
325
+ let visible = true
326
+ try {
327
+ const globalTool = typeof ctx.tools.get === 'function' ? ctx.tools.get('palace_archive') : undefined
328
+ if (globalTool) {
329
+ visible = Boolean(ctx.tools.get('palace_archive', agent.ctx))
330
+ } else if (typeof ctx.tools.schemas === 'function') {
331
+ const schemas = ctx.tools.schemas(agent.ctx)
332
+ if (Array.isArray(schemas)) visible = schemas.some((s) => s?.name === 'palace_archive')
333
+ }
334
+ } catch {
335
+ visible = false
336
+ }
337
+ if (!visible) return false
338
+ // 可选白名单:填了就再收紧一层;空数组不拦人。
339
+ const list = cfg.enabledWorkspaces
340
+ if (Array.isArray(list) && list.length > 0) {
341
+ const cwd = String(agent?.session?.header?.cwd ?? '')
342
+ if (!list.some((p) => p && cwd.toLowerCase().startsWith(String(p).toLowerCase()))) return false
343
+ }
344
+ const sessions = cfg.enabledSessions
345
+ if (Array.isArray(sessions) && sessions.length > 0) {
346
+ const sid = String(agent?.session?.header?.id ?? '')
347
+ if (!sessions.includes(sid)) return false
348
+ }
349
+ return true
350
+ }
351
+
352
+ function sessionIdOf(agent) {
353
+ return agent?.session?.header?.id ?? agent?.session?.id ?? null
354
+ }
355
+
356
+ /** 子智能体会话:不归档、不主动 steer(它们不是主线上下文,只保留任务与结果)。 */
357
+ function isSubagentSession(agent) {
358
+ const h = agent?.session?.header ?? {}
359
+ return Number(h.delegationDepth ?? 0) > 0
360
+ || Boolean(h.parentSessionId)
361
+ || h.subagent === true
362
+ || String(h.id ?? '').startsWith('subagent-')
363
+ }
364
+
365
+ function readState(cwd, rootDirName, sid) {
366
+ const { dir } = nsFor(cwd, rootDirName, sid)
367
+ try {
368
+ return JSON.parse(fs.readFileSync(path.join(dir, '.state.json'), 'utf8'))
369
+ } catch {
370
+ return {}
371
+ }
372
+ }
373
+
374
+ function writeState(cwd, rootDirName, state, sid) {
375
+ const { dir } = nsFor(cwd, rootDirName, sid)
376
+ fs.mkdirSync(dir, { recursive: true })
377
+ const file = path.join(dir, '.state.json')
378
+ const tmp = `${file}.tmp-${process.pid}`
379
+ fs.writeFileSync(tmp, JSON.stringify(state, null, 2), 'utf8')
380
+ fs.renameSync(tmp, file)
381
+ }
382
+
383
+ /**
384
+ * 读会话轮次。按日志 mtime+size 缓存,避免每 checkEveryTurns 轮都全量解压重建(P1-5)。
385
+ * 缓存只保留最近 8 个会话;顺带把日志里的真实上下文窗口一起带出来。
386
+ */
387
+ const decodeCache = new Map()
388
+ // 真实窗口按会话缓存,进程启动后只取一次(下次启动 DSH 才重新读)。
389
+ const windowCache = new Map()
390
+
391
+ async function loadTurns(agent, cfg = {}) {
392
+ const includeReasoning = cfg.includeReasoning !== false
393
+ const sid = sessionIdOf(agent)
394
+ const log = sid ? resolveSessionLog(sid) : null
395
+ if (!log) return { sid, turns: null, log: null, contextWindow: null }
396
+ let st
397
+ try {
398
+ st = fs.statSync(log)
399
+ } catch {
400
+ return { sid, turns: null, log, contextWindow: null }
401
+ }
402
+ const cacheKey = `${log}|${includeReasoning ? 'r1' : 'r0'}`
403
+ const hit = decodeCache.get(cacheKey)
404
+ if (hit && hit.mtimeMs === st.mtimeMs && hit.size === st.size) {
405
+ return { sid, turns: hit.turns, log, cached: true, contextWindow: hit.contextWindow }
406
+ }
407
+ const events = await decodeEvents(log)
408
+ const turns = buildTurns(events, { includeReasoning })
409
+ const contextWindow = detectContextWindow(events)
410
+ decodeCache.set(cacheKey, { mtimeMs: st.mtimeMs, size: st.size, turns, contextWindow })
411
+ if (decodeCache.size > 8) decodeCache.delete(decodeCache.keys().next().value)
412
+ return { sid, turns, log, cached: false, contextWindow }
413
+ }
414
+
415
+ /** 有效窗口:优先日志里的真实值(进程内只读一次),否则回落到配置值。 */
416
+ async function effectiveWindow(agent, cfg) {
417
+ const sid = sessionIdOf(agent)
418
+ if (sid && windowCache.has(sid)) return windowCache.get(sid)
419
+ // 复用 scanSessionLog 的缓存:避免为了窗口再全量解码一遍日志。
420
+ const logPath = sid ? resolveSessionLog(sid) : null
421
+ const scan = logPath ? await scanSessionLog(logPath) : { win: null }
422
+ const value = Number(scan?.win) > 0 ? Number(scan.win) : Number(cfg.contextWindowTokens) || 1048576
423
+ if (sid) windowCache.set(sid, value)
424
+ return value
425
+ }
426
+
427
+ function retentionCapBytes(cfg, win) {
428
+ const frac = Number(cfg.retainFraction)
429
+ if (frac > 0 && win > 0) return Math.round(win * frac * 3)
430
+ return Number(cfg.retainBytes) || 0
431
+ }
432
+
433
+ /**
434
+ * 上下文压力。优先用 DSH 自己的计量(ctx.tokenMeter.measure),压缩后它会回落;
435
+ * 日志累加法不会——日志里的旧原文永远都在,所以压缩后仍会误报 60%+。
436
+ * 拿不到计量服务时才退回日志累加,并标记来源为 log-sum。
437
+ */
438
+ /**
439
+ * 廉价压力探测:只问 tokenMeter,不解码日志。用于“低于阈值就直接返回”的快路径,
440
+ * 避免每 checkEveryTurns 轮都为了算归档区间去解压整份日志(实测会卡 5–10 秒)。
441
+ */
442
+ function measurePressureFast(ctx, agent, cfg) {
443
+ try {
444
+ const meter = ctx?.tokenMeter
445
+ if (meter && typeof meter.measure === 'function' && agent?.session) {
446
+ const m = meter.measure(agent.session)
447
+ const tokens = Number(m?.totalTokens)
448
+ if (Number.isFinite(tokens) && tokens > 0) {
449
+ const win = Number(cfg.contextWindowTokens) || 1048576
450
+ return { pressure: tokens / win, tokens, source: 'tokenMeter' }
451
+ }
452
+ }
453
+ } catch { /* ignore */ }
454
+ return null
455
+ }
456
+
457
+ function measurePressure(ctx, agent, turns, win) {
458
+ try {
459
+ const meter = ctx?.tokenMeter
460
+ if (meter && typeof meter.measure === 'function' && agent?.session) {
461
+ const m = meter.measure(agent.session)
462
+ const tokens = Number(m?.totalTokens)
463
+ if (Number.isFinite(tokens) && tokens > 0) {
464
+ return { pressure: tokens / win, tokens, source: 'tokenMeter' }
465
+ }
466
+ }
467
+ } catch { /* 落到下限兜底 */ }
468
+ const tokens = turns.reduce((sum, t) => sum + turnTokens(t), 0)
469
+ return { pressure: tokens / win, tokens, source: 'log-sum' }
470
+ }
471
+
472
+ function planRange(turns, cfg, state, sid, win) {
473
+ // 连续下界只取区间最大值(不能把空洞填平);archivedThroughTurn 仅作旧状态兜底。
474
+ const ranges = Array.isArray(state[sid]?.archivedRanges) ? state[sid].archivedRanges : []
475
+ const from = ranges.length > 0
476
+ ? ranges.reduce((m, r) => Math.max(m, Number(r[1]) || 0), 0)
477
+ : Math.max(Number(state[sid]?.archivedThroughTurn ?? 0) || 0, 0)
478
+ const tailFrom = retainedFromCapped(turns, Number.MAX_SAFE_INTEGER, retentionCapBytes(cfg, win))
479
+ const eligible = turns.slice(0, tailFrom).filter((t) => t.turn > from)
480
+ return { eligible, tailFrom, from }
481
+ }
482
+
483
+ /** 把轮次数组切成连续区间。 */
484
+ function contiguousRuns(turns) {
485
+ const runs = []
486
+ for (const t of turns) {
487
+ const last = runs[runs.length - 1]
488
+ if (last && t.turn === last[1] + 1) last[1] = t.turn
489
+ else runs.push([t.turn, t.turn])
490
+ }
491
+ return runs
492
+ }
493
+
494
+ /** 合并重叠/相邻区间。 */
495
+ function mergeRanges(ranges) {
496
+ const sorted = [...ranges].filter((r) => Array.isArray(r) && r[0] <= r[1]).sort((a, b) => a[0] - b[0])
497
+ const out = []
498
+ for (const [a, b] of sorted) {
499
+ const last = out[out.length - 1]
500
+ if (last && a <= last[1] + 1) last[1] = Math.max(last[1], b)
501
+ else out.push([a, b])
502
+ }
503
+ return out
504
+ }
505
+
506
+ /** 区间覆盖的轮次总数。 */
507
+ function countRange(ranges) {
508
+ let n = 0
509
+ for (const [a, b] of ranges) if (b >= a) n += b - a + 1
510
+ return n
511
+ }
512
+
513
+ /**
514
+ * 会话日志扫描缓存:解压+逐行解析一次约几秒(日志 10 MB+)。
515
+ * 页签 GET 同时需要 cwd、窗口、总轮数,之前各扫一遍;这里按 (path, mtime, size) 缓存,
516
+ * 进程内复用,日志变了自动重扫。
517
+ */
518
+ const logScanCache = new Map()
519
+ export async function scanSessionLog(logPath) {
520
+ if (!logPath) return { cwd: '', win: null, totalTurns: 0 }
521
+ let st
522
+ try { st = fs.statSync(logPath) } catch { return { cwd: '', win: null, totalTurns: 0 } }
523
+ const key = `${logPath}|${st.mtimeMs}|${st.size}`
524
+ const hit = logScanCache.get(key)
525
+ if (hit) return hit
526
+ let out = { cwd: '', win: null, totalTurns: 0 }
527
+ try {
528
+ // 冷启动优化:只整份解压一次,然后逐行“按需解析”——
529
+ // 只 JSON.parse turn/start、session、request/context 三类行,避免对上百 MB 文本全量 parse。
530
+ const text = await decompressLog(logPath)
531
+ let cwd = ''
532
+ let lastTurn = 0
533
+ let lastCtx = null
534
+ for (const line of text.split('\n')) {
535
+ if (!line) continue
536
+ if (line.includes('"turn/start"')) {
537
+ try {
538
+ const e = JSON.parse(line)
539
+ if (e?.type === 'turn/start') lastTurn = Math.max(lastTurn, Number(e.data?.turn ?? 0) || 0)
540
+ } catch { /* 坏行跳过 */ }
541
+ continue
542
+ }
543
+ if (!cwd && line.includes('"session"')) {
544
+ try {
545
+ const e = JSON.parse(line)
546
+ if (e?.type === 'session') cwd = String(e.cwd ?? e.data?.cwd ?? '')
547
+ } catch { /* ignore */ }
548
+ continue
549
+ }
550
+ if (line.includes('"request/context"')) {
551
+ try {
552
+ const e = JSON.parse(line)
553
+ if (e?.type === 'request/context') lastCtx = e
554
+ } catch { /* ignore */ }
555
+ }
556
+ }
557
+ out = { cwd, win: lastCtx ? detectContextWindow([lastCtx]) : null, totalTurns: lastTurn }
558
+ } catch { /* 解不出来就返回空 */ }
559
+ logScanCache.set(key, out)
560
+ if (logScanCache.size > 8) logScanCache.delete(logScanCache.keys().next().value)
561
+ return out
562
+ }
563
+
564
+ /**
565
+ * 当前会话的轮次统计。不落盘;总轮数直接用日志扫描缓存,避免重复解码。
566
+ */
567
+ /** 页签统计结果的短 TTL 缓存:避免在模型思考/流式输出时每次打开都重解压日志。 */
568
+ const statsCache = new Map()
569
+ const STATS_TTL_MS = 60_000
570
+ function invalidateStats(sid) {
571
+ if (sid) statsCache.delete(sid)
572
+ }
573
+
574
+ async function turnStats(sid, cwd, cfg) {
575
+ if (!sid || !cwd) return null
576
+ const hit = statsCache.get(sid)
577
+ if (hit && Date.now() - hit.at < STATS_TTL_MS) return hit.value
578
+ let totalTurns = 0
579
+ try {
580
+ const scan = await scanSessionLog(resolveSessionLog(sid))
581
+ totalTurns = scan.totalTurns
582
+ } catch { /* ignore */ }
583
+ if (!totalTurns) {
584
+ const pseudo = { session: { header: { id: sid, cwd } } }
585
+ try {
586
+ const { turns } = await loadTurns(pseudo, cfg)
587
+ if (turns && turns.length > 0) totalTurns = turns[turns.length - 1].turn
588
+ } catch { /* 读不到就都记 0 */ }
589
+ }
590
+ const state = readState(cwd, cfg.rootDirName, sid)
591
+ const ranges = Array.isArray(state[sid]?.archivedRanges) && state[sid].archivedRanges.length > 0
592
+ ? state[sid].archivedRanges
593
+ : (Number(state[sid]?.archivedThroughTurn ?? 0) > 0 ? [[1, Number(state[sid].archivedThroughTurn)]] : [])
594
+ const archivedTurns = countRange(ranges)
595
+ const value = {
596
+ totalTurns,
597
+ archivedTurns,
598
+ pendingTurns: Math.max(0, totalTurns - archivedTurns),
599
+ watermark: ranges.reduce((m, r) => Math.max(m, r[1]), 0),
600
+ }
601
+ statsCache.set(sid, { at: Date.now(), value })
602
+ if (statsCache.size > 16) statsCache.delete(statsCache.keys().next().value)
603
+ return value
604
+ }
605
+
606
+ function registerArchiveTool(ctx, read) {
607
+ const def = {
608
+ name: 'palace_archive',
609
+ description:
610
+ '把较早的对话轮次归档进当前工作区的 memory-palace 目录。'
611
+ + '调用前先判断这些轮次属于什么类别,用一个英文小写 slug 作为类别名(如 migration-engineering、novel-worldbuilding、dsh-environment)。'
612
+ + '插件会从会话日志逐字取出原文写入文件,你不需要复述内容。'
613
+ + '如果这个会话里你有明确的自称(名字),请用 selfName 传进来(英文小写拼音),文件名会用它;不传则用工作区配置。'
614
+ + '校验很严:区间里出现「日志中不存在的 turn」「超出保留边界」「已归档」「同一轮分到多个类别」时,整次调用失败、一个字节都不写,并列出具体 turn。'
615
+ + '归档前请先 palace_turns 看清每轮内容,不要用“第几条消息”换算 turn。'
616
+ + '归档文件写入后只读,任何情况下都不要修改旧档。',
617
+ parameters: {
618
+ type: 'object',
619
+ properties: {
620
+ category: { type: 'string', description: '英文小写分类 slug:a-z、0-9、连字符。整段归一类时用。' },
621
+ fromTurn: { type: 'number', description: '起始轮次(含)。省略则从上次归档水位之后开始。' },
622
+ toTurn: { type: 'number', description: '结束轮次(含)。省略则到保留边界为止。' },
623
+ segments: {
624
+ type: 'array',
625
+ description: '逐轮判断类别时用:按类别切分的轮次区间数组,每项 {category, fromTurn, toTurn}。'
626
+ + '同一个 category 可以出现多次;给了 segments 就忽略 category/fromTurn/toTurn。'
627
+ + '每个 turn 只能属于一个类别,区间不要重叠。',
628
+ items: {
629
+ type: 'object',
630
+ properties: {
631
+ category: { type: 'string' },
632
+ fromTurn: { type: 'number' },
633
+ toTurn: { type: 'number' },
634
+ },
635
+ required: ['category', 'fromTurn', 'toTurn'],
636
+ },
637
+ },
638
+ selfName: { type: 'string', description: '可选:你在本会话里的自称(英文小写拼音/slug),用作归档文件名前缀。省略则用工作区配置。' },
639
+ summaries: {
640
+ type: 'array',
641
+ description: '每个类别一段摘要(推荐):[{category, text}, …]。text 只概述该类别下“本次被归档的那几轮”,200–800 字,不要复述更早历史。'
642
+ + '会逐段追加到当月摘要文件 <会话>/<YYYY-MM>/_summary.md 末尾(前缀是归档日期 + 类别 + 轮次范围),只追加不改旧内容。',
643
+ items: {
644
+ type: 'object',
645
+ properties: {
646
+ category: { type: 'string' },
647
+ text: { type: 'string' },
648
+ },
649
+ required: ['category', 'text'],
650
+ },
651
+ },
652
+ summary: {
653
+ type: 'string',
654
+ description: '仅在只归档一个类别时可用:等同于给该类别一段摘要。多类别时请用 summaries,否则各分类拿不到各自的摘要。',
655
+ },
656
+ },
657
+ required: [],
658
+ },
659
+ output: TEXT_OUTPUT,
660
+ execute: async (args, exec) => {
661
+ const agent = exec.agent
662
+ const hasSegments = Array.isArray(args.segments) && args.segments.length > 0
663
+ const slug = String(args.category ?? '').trim()
664
+ if (!hasSegments && !/^[a-z][a-z0-9-]{1,63}$/.test(slug)) {
665
+ throw new Error('类别必须是英文小写 slug(a-z、0-9、连字符),例如 migration-engineering;或改用 segments 逐段指定类别。')
666
+ }
667
+ const cwd = String(agent?.session?.header?.cwd ?? '')
668
+ if (!cwd) throw new Error('读不到当前工作区路径,无法归档。')
669
+ if (isSubagentSession(agent)) {
670
+ throw new Error('子智能体会话不归档:它的内容不是主线上下文,只需保留任务与结果摘要。')
671
+ }
672
+ const sidEarly = sessionIdOf(agent)
673
+ const cfg = resolveConfig(read, cwd, sidEarly)
674
+ // fail-closed:本会话未开启不允许写归档。
675
+ if (!cfg.enabled || !cfg.sessionEnabled) {
676
+ throw new Error('记忆回廊未在本会话开启:先在页签里打开开关,或执行 /palace on。')
677
+ }
678
+ if (!cfg.name) {
679
+ throw new Error('本会话还没有命名记忆。先在「记忆回廊」页签里填一个名字并保存,之后才会开始归档。')
680
+ }
681
+ const { sid, turns } = await loadTurns(agent, cfg)
682
+ if (!turns || turns.length === 0) throw new Error('读不到会话日志或日志为空,未归档。')
683
+ const state = readState(cwd, cfg.rootDirName, sid)
684
+ // 上次归档若出现「文件写了、索引没写」,先补索引再继续。
685
+ let reconcileNote = ''
686
+ try {
687
+ const rec = reconcilePendingIndex(cfg.dir)
688
+ if (rec.added > 0) reconcileNote = `(已补写上次漏记的 ${rec.added} 行索引)`
689
+ } catch { /* 补索引失败不影响本次归档 */ }
690
+ // 保留边界只由保留策略决定,与水位无关——水位高了也不能把边界算没(R-1)。
691
+ const win = await effectiveWindow(agent, cfg)
692
+ const tailFrom = retainedFromCapped(turns, Number.MAX_SAFE_INTEGER, retentionCapBytes(cfg, win))
693
+ // boundary = 可归档的最后一轮;0 表示“全部在保留区”,不是“无限制”。
694
+ // 再压一层:永远不归档最后一轮(它可能正在生成,写下去会截断当前轮)。
695
+ const logLastTurn = turns[turns.length - 1].turn
696
+ const safeLast = logLastTurn > 1 ? logLastTurn - 1 : 0
697
+ const rawBoundary = tailFrom > 0 ? turns[tailFrom - 1].turn : 0
698
+ const boundary = rawBoundary > 0 ? Math.min(rawBoundary, safeLast) : 0
699
+ const stateRanges = Array.isArray(state[sid]?.archivedRanges) && state[sid].archivedRanges.length > 0
700
+ ? state[sid].archivedRanges
701
+ : []
702
+ // 覆盖集以「索引里真实存在的行」为准;没有索引才退回 state 区间。
703
+ // 这样 legacy 的 archivedThroughTurn(单点最大值)不会把中间空洞填平。
704
+ const covered = new Set()
705
+ const indexPath = path.join(cfg.dir, 'index.jsonl')
706
+ if (fs.existsSync(indexPath)) {
707
+ try {
708
+ for (const line of stripBom(fs.readFileSync(indexPath, 'utf8')).split('\n')) {
709
+ if (!line.trim()) continue
710
+ let row
711
+ try { row = JSON.parse(line) } catch { continue }
712
+ if (Array.isArray(row.turns) && row.turns.length > 0) {
713
+ for (const t of row.turns) covered.add(Number(t))
714
+ } else {
715
+ const a = Number(row.fromTurn)
716
+ const b = Number(row.toTurn)
717
+ if (Number.isFinite(a) && Number.isFinite(b)) for (let t = a; t <= b; t++) covered.add(t)
718
+ }
719
+ }
720
+ } catch { /* 索引坏则退回 state */ }
721
+ }
722
+ if (covered.size === 0) {
723
+ for (const [a, b] of stateRanges) for (let t = a; t <= b; t++) covered.add(t)
724
+ }
725
+ const watermark = covered.size > 0
726
+ ? Math.max(...covered)
727
+ : (Number(state[sid]?.archivedThroughTurn ?? 0) || 0)
728
+ const nameArg = String(args.selfName ?? '').trim()
729
+ if (nameArg && !/^[a-z][a-z0-9-]{1,63}$/.test(nameArg)) {
730
+ throw new Error('selfName 必须是英文小写 slug(a-z、0-9、连字符)。')
731
+ }
732
+
733
+ // 按 turn 号索引可归档轮次(有内容的才算)。
734
+ const byTurn = new Map(turns.filter((t) => t.items.length > 0).map((t) => [t.turn, t]))
735
+ // 日志里实际存在的 turn(含无正文的工具轮),用于区分「这一轮没内容」和「这一轮根本不存在」。
736
+ const allTurns = new Set(turns.map((t) => t.turn))
737
+ const lastLogTurn = logLastTurn
738
+ const groups = new Map()
739
+ const addTurn = (cat, t) => {
740
+ if (!groups.has(cat)) groups.set(cat, [])
741
+ groups.get(cat).push(t)
742
+ }
743
+
744
+ const explicitLo = Number.isFinite(args.fromTurn)
745
+ let skippedEmpty = 0
746
+ if (hasSegments) {
747
+ // 逐段指定类别:轮次顺序不变,每个 turn 只能落一个类别。
748
+ // 任何“不该归档/不存在”的轮次都让整次调用失败,绝不静默跳过(否则水位会骗人)。
749
+ const claimed = new Set()
750
+ const beyond = []
751
+ const alreadyDone = []
752
+ const missing = []
753
+ const claimedDupes = []
754
+ for (const seg of args.segments) {
755
+ const cat = String(seg?.category ?? '').trim()
756
+ if (!/^[a-z][a-z0-9-]{1,63}$/.test(cat)) {
757
+ throw new Error(`segments 里的类别不合法:${cat || '(空)'}(必须是英文小写 slug)`)
758
+ }
759
+ const a = Number(seg.fromTurn)
760
+ const b = Number(seg.toTurn)
761
+ if (!Number.isInteger(a) || !Number.isInteger(b)) {
762
+ throw new Error(`turn 必须是整数:${seg.fromTurn}–${seg.toTurn}`)
763
+ }
764
+ if (!Number.isFinite(a) || !Number.isFinite(b) || a > b) {
765
+ throw new Error(`segments 范围无效:${seg.fromTurn}–${seg.toTurn}`)
766
+ }
767
+ if (a < 1) throw new Error(`segments 起点不能小于 1:${a}(turn 从 1 开始)`)
768
+ for (let n = a; n <= b; n++) {
769
+ if (n > lastLogTurn) { missing.push(n); continue }
770
+ if (boundary <= 0) { beyond.push(n); continue } // boundary=0:全部在保留区,不允许归档
771
+ if (n > boundary) { beyond.push(n); continue }
772
+ if (!allTurns.has(n)) { skippedEmpty += 1; continue } // 无正文轮(纯 steer/注入)
773
+ if (covered.has(n)) { alreadyDone.push(n); continue }
774
+ if (claimed.has(n)) { claimedDupes.push(n); continue }
775
+ const t = byTurn.get(n)
776
+ if (!t) { skippedEmpty += 1; continue }
777
+ claimed.add(n)
778
+ addTurn(cat, t)
779
+ }
780
+ }
781
+ if (beyond.length > 0 || alreadyDone.length > 0 || missing.length > 0 || claimedDupes.length > 0) {
782
+ const fmt = (arr) => arr.slice(0, 12).join(', ') + (arr.length > 12 ? ` …共 ${arr.length} 个` : '')
783
+ throw new Error(
784
+ 'segments 里包含了不能归档的轮次,整次调用已拒绝(一个字节都没写):'
785
+ + (missing.length ? `\n- 日志里不存在这些 turn(最新只到 ${lastLogTurn}):${fmt(missing)}` : '')
786
+ + (beyond.length
787
+ ? (boundary <= 0
788
+ ? `\n- 当前无可归档轮次(全部在保留区,或只剩正在生成的最后一轮),不是轮次号写错了。被请求的 turn:${fmt(beyond)}`
789
+ : `\n- 超出保留边界(可归档到 turn ${boundary} 为止):${fmt(beyond)}`)
790
+ : '')
791
+ + (alreadyDone.length ? `\n- 已经归档过:${fmt(alreadyDone)}` : '')
792
+ + (claimedDupes.length ? `\n- 同一轮被分到多个类别:${fmt(claimedDupes)}` : '')
793
+ + '\n请先用 palace_turns 看清每轮内容与范围,只归档保留边界以内、尚未归档的轮次;不要用“第几条消息”换算 turn。',
794
+ )
795
+ }
796
+ } else {
797
+ // 显式给了区间:越界同样整次失败,不静默截断。
798
+ if (explicitLo) {
799
+ if (!Number.isInteger(args.fromTurn)) throw new Error(`fromTurn 必须是整数:${args.fromTurn}`)
800
+ if (args.fromTurn < 1) throw new Error(`fromTurn 不能小于 1:${args.fromTurn}`)
801
+ }
802
+ if (Number.isFinite(args.toTurn)) {
803
+ if (!Number.isInteger(args.toTurn)) throw new Error(`toTurn 必须是整数:${args.toTurn}`)
804
+ if (args.toTurn > lastLogTurn) {
805
+ throw new Error(`toTurn ${args.toTurn} 超出日志末尾(最新 turn ${lastLogTurn}),整次调用已拒绝。`)
806
+ }
807
+ if (boundary > 0 && args.toTurn > boundary) {
808
+ throw new Error(`toTurn ${args.toTurn} 超出保留边界(turn ${boundary}),这些轮次还没到归档时机;整次调用已拒绝。`)
809
+ }
810
+ }
811
+ const lo = explicitLo ? Math.max(1, args.fromTurn) : Math.max(1, watermark + 1)
812
+ const hi = Number.isFinite(args.toTurn) ? args.toTurn : boundary
813
+ const clampedHi = boundary > 0 ? Math.min(hi, boundary) : 0
814
+ if (lo > clampedHi) {
815
+ // 水位已经越过保留边界:不是数据丢失,是最近的轮次还受保留区保护。
816
+ if (boundary <= 0) {
817
+ return `当前无可归档轮次(全部在保留区;或只剩正在生成的最后一轮)。`
818
+ }
819
+ if (watermark >= boundary) {
820
+ return `当前无可归档轮次(全部在保留区):turn 1–${watermark} 都已归档;turn ${boundary + 1} 之后属于保留区(约 ${(retentionCapBytes(cfg, win) / 1024).toFixed(0)} KB ≈ 窗口的 ${Math.round((cfg.retainFraction || 0) * 100)}%),等会话继续增长、边界越过 ${watermark} 后再归档。`
821
+ }
822
+ return `当前无可归档轮次(全部在保留区):边界 turn ${boundary},已归档到 turn ${watermark},请求范围 ${lo}–${hi},保留文本量约 ${(retentionCapBytes(cfg, win) / 1024).toFixed(0)} KB(窗口的 ${Math.round((cfg.retainFraction || 0) * 100)}%)。`
823
+ }
824
+ for (const t of byTurn.values()) {
825
+ if (t.turn < lo || t.turn > clampedHi) continue
826
+ if (covered.has(t.turn)) continue
827
+ addTurn(slug, t)
828
+ }
829
+ }
830
+
831
+ const allPicked = [...groups.values()].flat().sort((a, b) => a.turn - b.turn)
832
+ if (allPicked.length === 0) {
833
+ return '这些轮次都已经归档过(或不在可归档范围内),没有新的可归档内容。'
834
+ }
835
+
836
+ // 先解析 summaries 并校验类别,任何“写了却不生效”的情况必须在落盘之前拒绝(FIX-4)。
837
+ const sumArg = args.summaries
838
+ const perCat = new Map()
839
+ if (Array.isArray(sumArg)) {
840
+ for (const it of sumArg) {
841
+ if (!it || typeof it !== 'object') continue
842
+ const c = String(it.category ?? '').trim()
843
+ if (c) perCat.set(c, String(it.text ?? ''))
844
+ }
845
+ } else if (sumArg && typeof sumArg === 'object') {
846
+ for (const [k, v] of Object.entries(sumArg)) perCat.set(String(k), String(v ?? ''))
847
+ }
848
+ const fallbackSummaryEarly = String(args.summary ?? '').trim()
849
+ const plannedCats = new Set(groups.keys())
850
+ const unmatched = [...perCat.keys()].filter((c) => !plannedCats.has(c))
851
+ if (perCat.size > 0 && unmatched.length === perCat.size) {
852
+ throw new Error(
853
+ `summaries 的类别(${unmatched.join(', ')})都不在本次 segments 中(实际类别:${[...plannedCats].join(', ')});`
854
+ + '已拒绝,未写任何文件。请先核对 segments 与 summaries 的 category。',
855
+ )
856
+ }
857
+
858
+ // 每个类别一次写入 = 一个文件(超过 maxFileBytes 才按轮分卷)。
859
+ const results = []
860
+ const nsLabel = `${cfg.rootDirName}/${cfg.folder}`
861
+ const nowMs = Date.now() // 本次归档统一时间,文件与摘要落在同一个月
862
+ for (const [cat, list] of groups) {
863
+ list.sort((a, b) => a.turn - b.turn)
864
+ const res = writeArchive({
865
+ nsDir: cfg.dir,
866
+ nsLabel,
867
+ selfNameSlug: nameArg || cfg.selfNameSlug,
868
+ category: cat,
869
+ turns: list,
870
+ now: nowMs,
871
+ maxFileBytes: Number(cfg.maxFileBytes) > 0 ? Number(cfg.maxFileBytes) : 5_000_000,
872
+ meta: {
873
+ sessionId: sid,
874
+ sessionTitle: agent?.session?.header?.title,
875
+ reason: hasSegments ? '会话模型按类别分段归档' : (explicitLo ? '会话模型指定范围归档' : '会话模型按保留边界归档'),
876
+ },
877
+ })
878
+ results.push({ cat, count: list.length, res, turns: list.map((t) => t.turn) })
879
+ }
880
+
881
+ const lastTurn = allPicked[allPicked.length - 1].turn
882
+ const mergedRanges = mergeRanges([...stateRanges, ...contiguousRuns(allPicked)])
883
+ // archivedThroughTurn 只表示「从 1 开始连续覆盖到哪」,不能拿最大值当连续下界(否则空洞被填平)。
884
+ let throughTurn = 0
885
+ for (const [a, b] of mergedRanges.slice().sort((x, y) => x[0] - y[0])) {
886
+ if (a <= throughTurn + 1) throughTurn = Math.max(throughTurn, b)
887
+ else break
888
+ }
889
+ state[sid] = {
890
+ ...(state[sid] ?? {}),
891
+ archivedRanges: mergedRanges,
892
+ archivedThroughTurn: throughTurn,
893
+ lastArchiveAt: fmtTime(nowMs),
894
+ lastArchiveRange: `${allPicked[0].turn}-${lastTurn}`,
895
+ }
896
+ // 先写摘要、后写状态:摘要失败时 state 还没更新,重试不会变成“已归档过”。
897
+ // sumArg/perCat 已在落盘前解析并校验(FIX-4),这里直接复用。
898
+ const fallbackSummary = fallbackSummaryEarly
899
+ const month = fmtMonth(nowMs)
900
+ let wrote = 0
901
+ const wroteCats = []
902
+ for (const r of results) {
903
+ const text = String(perCat.get(r.cat) ?? (results.length === 1 ? fallbackSummary : '')).trim()
904
+ if (!text) continue
905
+ appendMonthSummary({
906
+ nsDir: cfg.dir,
907
+ month,
908
+ label: `[${r.cat}] ${fmtTurnSpan(r.turns)}`,
909
+ text,
910
+ })
911
+ wrote += 1
912
+ wroteCats.push(r.cat)
913
+ }
914
+ const usedCats = new Set(results.map((r) => r.cat))
915
+ const unusedCats = [...perCat.keys()].filter((c) => !usedCats.has(c))
916
+ const summaryNote = wrote > 0
917
+ ? `\n已写入 ${wrote} 段摘要(${wroteCats.join(', ')}):${cfg.rootDirName}/${cfg.folder}/${month}/_summary.md`
918
+ + (unusedCats.length ? `\n⚠ summaries 里这些类别不在本次 segments 中,已忽略:${unusedCats.join(', ')}` : '')
919
+ : '\n(本次没给 summaries,摘要文件未更新。多类别时请用 summaries=[{category,text},…]。)'
920
+ writeState(cwd, cfg.rootDirName, state, sid)
921
+ invalidateStats(sid) // 归档后统计会变,下一次页签打开重新算
922
+
923
+ // 归档后把旧轮次从活跃上下文里换掉(等同一次 /compact):全文已经在磁盘上,不会丢。
924
+ let compactNote = ''
925
+ if (cfg.compactAfterArchive === true) {
926
+ try {
927
+ if (ctx.compaction && typeof ctx.compaction.compactNow === 'function') {
928
+ ctx.compaction.compactNow(agent, AbortSignal.timeout(180000), 'memory-palace')
929
+ .then(() => safeLog(ctx, 'compaction finished after archive'))
930
+ .catch((e) => safeLog(ctx, 'compaction skipped: ' + (e?.message ?? String(e))))
931
+ compactNote = '\n已触发上下文压缩(把旧轮次换成摘要)。'
932
+ } else {
933
+ compactNote = '\n(宿主未提供 compaction 服务,未压缩上下文。)'
934
+ }
935
+ } catch (err) {
936
+ safeLog(ctx, 'compaction trigger failed: ' + (err?.message ?? String(err)))
937
+ }
938
+ }
939
+
940
+ const lines = results.map((r) => {
941
+ const parts = r.res.parts > 1 ? `,分 ${r.res.parts} 卷` : ''
942
+ return `[${r.cat}] ${fmtTurnSpan(r.turns)}${parts}\n ${r.res.files.join('\n ')}`
943
+ })
944
+ const skippedNote = skippedEmpty > 0 ? `\n(跳过 ${skippedEmpty} 个无正文轮次)` : ''
945
+ return `已归档 ${allPicked.length} 轮,共 ${results.length} 个类别:\n${lines.join('\n')}${reconcileNote}${summaryNote}${compactNote}${skippedNote}`
946
+ },
947
+ }
948
+ ctx.tools.register(def)
949
+ return def
950
+ }
951
+
952
+ /**
953
+ * 在归档正文里找轮次标题,返回 [{turn, line}](行号从 0 起)。
954
+ * 新文件用 `## TURN n ·`;只有整份都没有新标记时才退回旧格式 `## 第 n 轮 ·`。
955
+ * 这样正文里被引用的旧标题不会把切片切错。
956
+ */
957
+ function turnIndex(lines) {
958
+ const modern = []
959
+ const legacy = []
960
+ for (let i = 0; i < lines.length; i++) {
961
+ const line = lines[i]
962
+ const m = /^## TURN (\d+) · /.exec(line)
963
+ if (m) { modern.push({ turn: Number(m[1]), line: i }); continue }
964
+ const l = /^## 第 (\d+) 轮 · /.exec(line)
965
+ if (l) legacy.push({ turn: Number(l[1]), line: i })
966
+ }
967
+ // 多数派决定用哪套标记:正文里偶尔出现大写 TURN 的引用,不会把整份文件带偏。
968
+ if (modern.length === 0) return legacy
969
+ if (legacy.length === 0) return modern
970
+ return modern.length >= legacy.length ? modern : legacy
971
+ }
972
+
973
+ /**
974
+ * 进程内检索缓存:同一份文件(mtime+size 没变)只读一次磁盘。
975
+ * 不落盘,重启即清空;最多缓存 5 份文件,避免内存膨胀。
976
+ */
977
+ const searchCache = new Map()
978
+
979
+ /** 最近事件环形缓冲:steer/归档/检索失败都记一行,页签可见(不用翻会话日志)。 */
980
+ const eventLog = []
981
+ function recordEvent(kind, detail) {
982
+ try {
983
+ eventLog.push({ t: new Date().toISOString().replace('T', ' ').slice(0, 19), kind: String(kind), detail: String(detail ?? '').slice(0, 300) })
984
+ while (eventLog.length > 50) eventLog.shift()
985
+ } catch { /* ignore */ }
986
+ }
987
+
988
+ function loadSearchable(abs) {
989
+ let st
990
+ try { st = fs.statSync(abs) } catch { return null }
991
+ const hit = searchCache.get(abs)
992
+ if (hit && hit.mtimeMs === st.mtimeMs && hit.size === st.size) return hit
993
+ let text
994
+ try { text = fs.readFileSync(abs, 'utf8') } catch { return null }
995
+ const lines = text.split('\n')
996
+ const entry = {
997
+ mtimeMs: st.mtimeMs,
998
+ size: st.size,
999
+ lines,
1000
+ lowerLines: lines.map((l) => l.toLowerCase()),
1001
+ turns: turnIndex(lines),
1002
+ }
1003
+ searchCache.set(abs, entry)
1004
+ if (searchCache.size > 5) searchCache.delete(searchCache.keys().next().value)
1005
+ return entry
1006
+ }
1007
+
1008
+ /** 取指定行区间的轮次集合(fromTurn/toTurn),没有范围就返回 null。 */
1009
+ function sliceTurns(lines, fromTurn, toTurn) {
1010
+ const idx = turnIndex(lines)
1011
+ if (idx.length === 0) return null
1012
+ const want = idx.filter((t) => t.turn >= fromTurn && t.turn <= toTurn)
1013
+ if (want.length === 0) return ''
1014
+ const start = want[0].line
1015
+ const last = want[want.length - 1]
1016
+ const next = idx.find((t) => t.line > last.line)
1017
+ const end = next ? next.line : lines.length
1018
+ return lines.slice(start, end).join('\n')
1019
+ }
1020
+
1021
+ /** 解析归档文件路径:索引里存“相对命名空间”的路径;老数据是 memory-palace/ 开头的相对工作区路径。 */
1022
+ function resolveArchivePath(cwd, rootDirName, nsDir, rel) {
1023
+ const norm = String(rel ?? '').replace(/\\/g, '/')
1024
+ if (norm === rootDirName || norm.startsWith(rootDirName + '/')) return path.resolve(cwd, norm)
1025
+ return path.join(nsDir, norm)
1026
+ }
1027
+
1028
+ /** 选命名空间目录:默认自己;给了 namespace 就按名字找(大小写不敏感)。 */
1029
+ function pickNsDir(cwd, rootDirName, sid, namespace) {
1030
+ const mine = nsFor(cwd, rootDirName, sid)
1031
+ const wanted = String(namespace ?? '').trim()
1032
+ if (!wanted) return { dir: mine.dir, folder: mine.folder, ok: true }
1033
+ const lower = wanted.toLowerCase()
1034
+ if (mine.folder.toLowerCase() === lower) return { dir: mine.dir, folder: mine.folder, ok: true }
1035
+ const map = loadNsMap(cwd, rootDirName)
1036
+ for (const folder of Object.values(map)) {
1037
+ // map 的值也要校验:手改 .namespaces.json 不能把读取导向别的目录。
1038
+ const name = String(folder)
1039
+ if (!/^[A-Za-z0-9][A-Za-z0-9 _-]{0,31}$/.test(name)) continue
1040
+ if (name.toLowerCase() === lower) {
1041
+ return { dir: path.join(cwd, rootDirName, name), folder: name, ok: true }
1042
+ }
1043
+ }
1044
+ return { ok: false, dir: mine.dir, folder: mine.folder }
1045
+ }
1046
+
1047
+ function registerSearchTool(ctx, read) {
1048
+ return ctx.tools.register({
1049
+ name: 'palace_search',
1050
+ description:
1051
+ '检索当前工作区 memory-palace 的归档。默认只查索引(快、省);给 keyword 时会在归档正文里搜,'
1052
+ + '默认按轮聚合(每轮只返回一条代表命中 + 该轮命中数),多关键词用空格分隔(需全部命中)。'
1053
+ + '只回片段不返回全文——需要细节再用 palace_read 按轮次取。只读。',
1054
+ parameters: {
1055
+ type: 'object',
1056
+ properties: {
1057
+ keyword: { type: 'string', description: '关键词;多个词用空格分隔(AND)。留空则按时间倒序列索引。' },
1058
+ date: { type: 'string', description: '可选 YYYY-MM-DD,只看该日归档。' },
1059
+ category: { type: 'string', description: '可选:只在该类别里搜(例如 debugging)。' },
1060
+ namespace: { type: 'string', description: '可选:别的会话的记忆命名空间名;省略 = 只查自己。' },
1061
+ all: { type: 'boolean', description: '可选:true 时扫描本工作区所有命名空间(结果标来源命名空间);默认只查自己。' },
1062
+ includeSummary: { type: 'boolean', description: '可选:true 时额外扫摘要(_summary.md),结果标为“摘要里提到”。默认只在原文 0 命中时自动回查。' },
1063
+ groupByTurn: { type: 'boolean', description: '默认 true:每轮只返回一条代表命中。设 false 则按原始命中行返回。' },
1064
+ limitTurns: { type: 'number', description: '按轮聚合时最多返回多少轮,默认取配置(12),上限 30。' },
1065
+ limit: { type: 'number', description: '旧参数:按原始命中行计数(groupByTurn=false 时生效),默认 20,上限 200。' },
1066
+ },
1067
+ required: [],
1068
+ },
1069
+ output: TEXT_OUTPUT,
1070
+ execute: async (args, exec) => {
1071
+ const cwd = String(exec.agent?.session?.header?.cwd ?? '')
1072
+ const sid = sessionIdOf(exec.agent)
1073
+ const cfg = resolveConfig(read, cwd, sid)
1074
+ // 目标命名空间:默认自己;`all:true` 扫全部(排除备份目录)。
1075
+ let targets
1076
+ if (args.all) {
1077
+ const map = loadNsMap(cwd, cfg.rootDirName)
1078
+ const dirs = new Map([[cfg.folder, cfg.dir]])
1079
+ for (const f of Object.values(map)) {
1080
+ const folder = String(f)
1081
+ if (!/^[A-Za-z0-9][A-Za-z0-9 _-]{0,31}$/.test(folder)) continue
1082
+ dirs.set(folder, path.join(cwd, cfg.rootDirName, folder))
1083
+ }
1084
+ targets = [...dirs].filter(([, d]) => fs.existsSync(path.join(d, 'index.jsonl'))).map(([folder, dir]) => ({ folder, dir }))
1085
+ } else {
1086
+ const picked = pickNsDir(cwd, cfg.rootDirName, sid, args.namespace)
1087
+ if (!picked.ok) return `找不到记忆命名空间「${String(args.namespace).trim()}」。`
1088
+ targets = [{ folder: picked.folder, dir: picked.dir }]
1089
+ }
1090
+ if (targets.length === 0) return '归档记忆为空(没有任何命名空间有归档)。'
1091
+ let rows = []
1092
+ for (const t of targets) {
1093
+ try {
1094
+ const list = stripBom(fs.readFileSync(path.join(t.dir, 'index.jsonl'), 'utf8')).split('\n').filter(Boolean).map((l) => {
1095
+ try { return JSON.parse(l) } catch { return null }
1096
+ }).filter(Boolean)
1097
+ rows.push(...list.map((r) => ({ ...r, _folder: t.folder, _dir: t.dir })))
1098
+ } catch (err) {
1099
+ recordEvent('search-error', `${t.folder}: ${String(err?.message ?? err)}`)
1100
+ }
1101
+ }
1102
+ if (rows.length === 0) {
1103
+ return args.all ? '归档记忆为空(索引都读不到)。' : '归档记忆为空(本会话还没有任何归档)。'
1104
+ }
1105
+ let hits = rows
1106
+ if (args.date) hits = hits.filter((r) => (r.archivedAt ?? '').startsWith(args.date))
1107
+ if (args.category) hits = hits.filter((r) => r.category === String(args.category).trim())
1108
+ const keyword = String(args.keyword ?? '').trim()
1109
+ const showFile = (rel, folder) => {
1110
+ const norm = String(rel ?? '').replace(/\\/g, '/')
1111
+ return norm.startsWith(cfg.rootDirName + '/') ? norm : `${folder}/${norm}`
1112
+ }
1113
+ const catLabel = (h) => (args.all ? `${h.ns} · ${h.cat}` : h.cat)
1114
+
1115
+ if (!keyword) {
1116
+ const n = Math.min(Math.max(Number(args.limit) || 20, 1), 200)
1117
+ const list = hits.slice(-n).reverse()
1118
+ if (list.length === 0) return '没有匹配的归档。'
1119
+ return list.map((r) => `${r.archivedAt} [${args.all ? r._folder + ' · ' : ''}${r.category}] ${showFile(r.file, r._folder)} (${(r.bytes / 1024).toFixed(1)} KB, turn ${r.fromTurn}–${r.toTurn}) 来源 ${String(r.sessionId ?? '').replace(/^session-/, '').slice(0, 8)}…`).join('\n')
1120
+ }
1121
+
1122
+ // 多关键词 AND(空格分隔;引号包住的短语整体匹配)
1123
+ const tokens = (keyword.match(/"([^"]+)"|[^\s"]+/g) ?? [keyword]).map((t) => t.replace(/^"|"$/g, '').toLowerCase()).filter(Boolean)
1124
+ // 旧键语义冻结:只给了 limit(没给 groupByTurn/limitTurns)时,按老的“原始命中条数”模式跑。
1125
+ const legacyCall = args.limit !== undefined && args.groupByTurn === undefined && args.limitTurns === undefined
1126
+ const groupByTurn = args.groupByTurn !== undefined
1127
+ ? args.groupByTurn !== false
1128
+ : (!legacyCall && cfg.groupByTurn !== false)
1129
+ const capTurns = Math.min(Math.max(Number(args.limitTurns) || Number(cfg.searchTurnLimit) || 12, 1), 30)
1130
+ const capRaw = Math.min(Math.max(Number(args.limit) || 20, 1), 200)
1131
+ const MAX_OUT = 8000
1132
+ // 参数冲突提示:groupByTurn=true 时 limit 不生效,别让人以为条数被限制了。
1133
+ const limitNote = (args.limit !== undefined && groupByTurn) ? '(groupByTurn=true 时 limit 不生效,请用 limitTurns)\n' : ''
1134
+ let readErrors = 0
1135
+ const perTurn = new Map() // key: file|turn → { file, cat, turn, best, count }
1136
+ const rawHits = []
1137
+
1138
+ for (const r of hits.slice().reverse()) {
1139
+ const abs = resolveArchivePath(cwd, cfg.rootDirName, r._dir, r.file)
1140
+ let entry
1141
+ try {
1142
+ if (!fs.existsSync(abs)) { readErrors += 1; continue }
1143
+ entry = loadSearchable(abs)
1144
+ } catch { readErrors += 1; continue }
1145
+ if (!entry) { readErrors += 1; continue }
1146
+ const { lines, lowerLines, turns: idx } = entry
1147
+ const firstTurnLine = idx.length > 0 ? idx[0].line : 0
1148
+ const byLine = new Map(idx.map((t) => [t.line, t.turn]))
1149
+ let lastTurn = null
1150
+ let inUserBlock = false
1151
+ for (let i = 0; i < lines.length; i++) {
1152
+ if (byLine.has(i)) lastTurn = byLine.get(i)
1153
+ if (i < firstTurnLine || lines[i].length < 4) continue
1154
+ const line = lines[i]
1155
+ if (line.startsWith('### 用户')) { inUserBlock = true; continue }
1156
+ if (line.startsWith('### ')) { inUserBlock = false; continue }
1157
+ const low = lowerLines[i]
1158
+ let ok = true
1159
+ let firstAt = -1
1160
+ let count = 0
1161
+ for (const tk of tokens) {
1162
+ const at = low.indexOf(tk)
1163
+ if (at < 0) { ok = false; break }
1164
+ if (firstAt < 0 || at < firstAt) firstAt = at
1165
+ let p = at
1166
+ while (p >= 0) { count += 1; p = low.indexOf(tk, p + tk.length) }
1167
+ }
1168
+ if (!ok) continue
1169
+ const turn = lastTurn
1170
+ const from = Math.max(0, firstAt - 100)
1171
+ const snippet = line.slice(from, from + 220).replace(/\s+/g, ' ').trim()
1172
+ // 工具/JSON/命令行行的识别:这种行适合当证据,不适合当“这轮在聊什么”的代表
1173
+ const toolish = /^\s*(?:\{|\[|```|\$|>|\d+[::]\s)|\bfile_path\b|"command"|\\n|Select-Object|ForEach-Object|Import-Module/.test(line)
1174
+ const hit = { file: r.file, cat: r.category, ns: r._folder, folder: r._folder, turn, lineNo: i + 1, snippet, count, inUser: inUserBlock, toolish, src: String(r.sessionId ?? '') }
1175
+ if (groupByTurn) {
1176
+ const key = `${r._folder}|${r.file}|${turn ?? '?'}`
1177
+ const cur = perTurn.get(key)
1178
+ if (!cur) perTurn.set(key, { ...hit, best: hit, total: count })
1179
+ else {
1180
+ cur.total += count
1181
+ // 代表行优先级:用户消息行(3) > 普通文本行(2) > 工具/JSON/代码行(1);同级比密度,再比长度
1182
+ const tier = (h) => (h.inUser ? 3 : (h.toolish ? 1 : 2))
1183
+ const better = tier(hit) > tier(cur.best)
1184
+ || (tier(hit) === tier(cur.best) && hit.count > cur.best.count)
1185
+ || (tier(hit) === tier(cur.best) && hit.count === cur.best.count && hit.snippet.length > cur.best.snippet.length)
1186
+ if (better) cur.best = hit
1187
+ }
1188
+ } else {
1189
+ rawHits.push(hit)
1190
+ if (rawHits.length >= capRaw) break
1191
+ }
1192
+ }
1193
+ if (!groupByTurn && rawHits.length >= capRaw) break
1194
+ }
1195
+
1196
+ const fmtHit = (h, extra) => `[${catLabel(h)}] turn ${h.turn ?? '?'}${extra} · 行 ${h.lineNo}\n 文件:${showFile(h.file, h.folder)}\n 来源会话:${h.src ? h.src.replace(/^session-/, '').slice(0, 8) + '…' : '未知'}\n …${h.snippet}…`
1197
+
1198
+ // 摘要兜底:原文 0 命中时扫一遍 _summary.md(每月只有几 KB),
1199
+ // 避免“摘要里的概述性说法搜不到”被误当成“记忆里没有”。摘要命中不返回轮次,明确标注。
1200
+ const scanSummaries = (dirs, toks, limit = 5) => {
1201
+ const out = []
1202
+ for (const d of dirs) {
1203
+ let entries = []
1204
+ try { entries = fs.readdirSync(d, { withFileTypes: true }) } catch { continue }
1205
+ for (const e of entries) {
1206
+ if (!e.isDirectory() || !/^\d{4}-\d{2}$/.test(e.name)) continue
1207
+ let raw = ''
1208
+ try { raw = stripBom(fs.readFileSync(path.join(d, e.name, '_summary.md'), 'utf8')) } catch { continue }
1209
+ for (const b of raw.split(/\n(?=## )/)) {
1210
+ if (!b.startsWith('## ')) continue
1211
+ const low = b.toLowerCase()
1212
+ if (!toks.every((t) => low.includes(t))) continue
1213
+ const at = Math.max(0, Math.min(...toks.map((t) => { const i = low.indexOf(t); return i < 0 ? 999999 : i })) - 80)
1214
+ out.push({
1215
+ folder: path.basename(d),
1216
+ month: e.name,
1217
+ head: b.split('\n')[0].replace(/^## /, '').slice(0, 120),
1218
+ snippet: b.slice(at, at + 220).replace(/^## [^\n]*\n?/, '').replace(/\s+/g, ' ').trim(),
1219
+ })
1220
+ if (out.length >= limit) return out
1221
+ }
1222
+ }
1223
+ }
1224
+ return out
1225
+ }
1226
+ const summarySection = () => {
1227
+ try {
1228
+ const found = scanSummaries(targets.map((t) => t.dir), tokens, 5)
1229
+ if (!found.length) return ''
1230
+ return '\n\n摘要里提到(**摘要不是轮次**;要原文请换关键词,或用 palace_read 按轮次读):\n'
1231
+ + found.map((s) => ` · ${s.month} [${s.folder}] ${s.head}\n …${s.snippet}…`).join('\n')
1232
+ } catch (err) {
1233
+ recordEvent('summary-fallback-error', String(err?.message ?? err))
1234
+ return ''
1235
+ }
1236
+ }
1237
+
1238
+ let text
1239
+ if (groupByTurn) {
1240
+ const list = [...perTurn.values()].slice(0, capTurns)
1241
+ if (list.length === 0) {
1242
+ return limitNote + `正文里没有找到「${keyword}」` + (readErrors ? `(另有 ${readErrors} 个文件读取失败)` : '') + '。' + summarySection()
1243
+ }
1244
+ text = `命中 ${list.length} 轮(按轮聚合;每轮只给一条代表命中,括号内是该轮总命中数):\n\n`
1245
+ + list.map((h) => fmtHit(h.best, `(${h.total} 处)`)).join('\n\n')
1246
+ } else {
1247
+ if (rawHits.length === 0) {
1248
+ return limitNote + `正文里没有找到「${keyword}」` + (readErrors ? `(另有 ${readErrors} 个文件读取失败)` : '') + '。' + summarySection()
1249
+ }
1250
+ text = `命中 ${rawHits.length} 行(原始命中;要按轮聚合用 groupByTurn=true):\n\n` + rawHits.map((h) => fmtHit(h, '')).join('\n\n')
1251
+ }
1252
+ text = limitNote + text
1253
+ if (args.includeSummary) text += summarySection()
1254
+ if (readErrors > 0) text += `\n\n(有 ${readErrors} 个归档文件读取失败,已跳过)`
1255
+ if (text.length > MAX_OUT) {
1256
+ text = text.slice(0, MAX_OUT) + `\n…(结果过长已截断;请缩小范围:加 category / date,或调小 limitTurns)`
1257
+ recordEvent('search-truncated', `keyword=${keyword} chars=${text.length}`)
1258
+ }
1259
+ return text
1260
+ },
1261
+ })
1262
+ }
1263
+
1264
+ /** 轮次清单:归档前先看清“哪一轮装了什么”,避免用消息条数换算 turn。 */
1265
+ function registerTurnsTool(ctx, read) {
1266
+ return ctx.tools.register({
1267
+ name: 'palace_turns',
1268
+ description:
1269
+ '列出本会话的轮次清单(turn 号 + 发言者 + 开头片段),用于归档前确认“每一轮装的是什么”。'
1270
+ + 'turn 是日志事件序号(含系统指令、插件 steer、纯工具轮),不要用“第几条消息”换算。'
1271
+ + '不传范围时给最近 40 轮;最多返回 80 行。只读。',
1272
+ parameters: {
1273
+ type: 'object',
1274
+ properties: {
1275
+ fromTurn: { type: 'number', description: '起始轮次(含)。' },
1276
+ toTurn: { type: 'number', description: '结束轮次(含)。' },
1277
+ limit: { type: 'number', description: '最多返回多少行,默认 40(上限 80)。' },
1278
+ },
1279
+ required: [],
1280
+ },
1281
+ output: TEXT_OUTPUT,
1282
+ execute: async (args, exec) => {
1283
+ const agent = exec.agent
1284
+ const cwd = String(agent?.session?.header?.cwd ?? '')
1285
+ const sid = sessionIdOf(agent)
1286
+ const cfg = resolveConfig(read, cwd, sid)
1287
+ const { turns } = await loadTurns(agent, cfg)
1288
+ if (!turns || turns.length === 0) throw new Error('读不到会话日志。')
1289
+ const last = turns[turns.length - 1].turn
1290
+ const cap0 = Math.min(Math.max(Number(args.limit) || 40, 1), 80)
1291
+ // 不传范围 = 最近 cap0 轮(从尾部取,不是从会话开头)。
1292
+ const lo = Number.isFinite(args.fromTurn) ? Number(args.fromTurn) : Math.max(1, last - cap0 + 1)
1293
+ const hi = Number.isFinite(args.toTurn) ? Number(args.toTurn) : last
1294
+ const cap = cap0
1295
+ const list = turns.filter((t) => t.turn >= lo && t.turn <= hi).slice(0, cap)
1296
+ if (list.length === 0) return `turn ${lo}–${hi} 之间没有轮次(日志里最新的是 turn ${last})。`
1297
+ const pick = (t, kinds) => t.items.find((it) => kinds.includes(it.kind) && it.text)
1298
+ // 每轮摘要:主句最多 70 字;主句太短(<=30 字)时补上一轮/下一轮首句,各最多 30 字;
1299
+ // 整行硬上限 150 字——能看懂「已重启」「晚安」在干什么,清单也不会变长。
1300
+ const clip = (s, n) => String(s ?? '').replace(/\s+/g, ' ').trim().slice(0, n)
1301
+ const summarizeTurn = (t) => {
1302
+ const u = pick(t, ['user'])
1303
+ const a = pick(t, ['text'])
1304
+ const tool = t.items.find((it) => it.kind === 'tool-call')
1305
+ if (u) return { who: '用户', text: clip(u.text, 70) }
1306
+ if (a) return { who: '助手', text: clip(a.text, 70) }
1307
+ if (tool) return { who: '工具轮', text: `tool: ${String(tool.name ?? '').split(/[\\/]/).pop()}` }
1308
+ if (t.items.some((it) => it.kind === 'reasoning')) return { who: '仅思考', text: '' }
1309
+ return { who: '空', text: '' }
1310
+ }
1311
+ const lines = list.map((t) => {
1312
+ const s = summarizeTurn(t)
1313
+ let line = `[${t.turn}] ${s.who}:${s.text}`
1314
+ if (s.text.length <= 30) {
1315
+ const i = turns.findIndex((x) => x.turn === t.turn)
1316
+ const ctx = []
1317
+ if (i > 0) {
1318
+ const p = summarizeTurn(turns[i - 1])
1319
+ if (p.text) ctx.push(`前[${turns[i - 1].turn}] ${p.who}:${clip(p.text, 30)}`)
1320
+ }
1321
+ if (i >= 0 && i < turns.length - 1) {
1322
+ const n = summarizeTurn(turns[i + 1])
1323
+ if (n.text) ctx.push(`后[${turns[i + 1].turn}] ${n.who}:${clip(n.text, 30)}`)
1324
+ }
1325
+ if (ctx.length) line += ` | ${ctx.join(' | ')}`
1326
+ }
1327
+ return line.length > 150 ? line.slice(0, 150) + '…' : line
1328
+ })
1329
+ const more = turns.filter((t) => t.turn >= lo && t.turn <= hi).length > cap ? `\n(只显示前 ${cap} 行;缩小范围或调小 limit)` : ''
1330
+ return `turn ${lo}–${hi}(日志共 ${turns.length} 轮,最新 turn ${last}):\n` + lines.join('\n') + more
1331
+ },
1332
+ })
1333
+ }
1334
+
1335
+ function registerSummaryTool(ctx, read) {
1336
+ return ctx.tools.register({
1337
+ name: 'palace_summary',
1338
+ description:
1339
+ '读本会话的「月度记忆摘要」(<会话>/<YYYY-MM>/_summary.md,每月一份、只追加,段头标注类别+轮次)。'
1340
+ + '不知道某段记忆在哪个年月时:先按大致年月范围读摘要 → 定位到具体归档文件 → 再用 palace_search / palace_read 取原文。'
1341
+ + '不指定 month 时列出最近几个月的摘要末尾;给了 month(YYYY-MM)返回该月摘要全文,可加 category 只看某类。只读。',
1342
+ parameters: {
1343
+ type: 'object',
1344
+ properties: {
1345
+ namespace: { type: 'string', description: '可选:别的会话的命名空间名;省略 = 只看自己。' },
1346
+ append: {
1347
+ type: 'string',
1348
+ description: '可选:写一段摘要到当月(或 month 指定月)的 _summary.md 末尾。'
1349
+ + '用于给“还没归档、但已经聊过的重要近况”补摘要——只概述你指定的那几轮,200–800 字。',
1350
+ },
1351
+ fromTurn: { type: 'number', description: '配合 append:这段摘要覆盖的起始轮次(用于段头标注)。' },
1352
+ toTurn: { type: 'number', description: '配合 append:这段摘要覆盖的结束轮次。' },
1353
+ month: { type: 'string', description: '可选 YYYY-MM;给了就返回该月摘要全文(上限 6 万字符);配合 append 时指定写入月份。' },
1354
+ category: { type: 'string', description: '可选:只看该月里某个类别的摘要段落。' },
1355
+ limit: { type: 'number', description: '不指定 month 时列出最近几个月,默认 12(上限 36)。' },
1356
+ },
1357
+ required: [],
1358
+ },
1359
+ output: TEXT_OUTPUT,
1360
+ execute: async (args, exec) => {
1361
+ const cwd = String(exec.agent?.session?.header?.cwd ?? '')
1362
+ const sid = sessionIdOf(exec.agent)
1363
+ const cfg = resolveConfig(read, cwd, sid)
1364
+ const picked = pickNsDir(cwd, cfg.rootDirName, sid, args.namespace)
1365
+ if (!picked.ok) throw new Error(`找不到记忆命名空间「${String(args.namespace).trim()}」。`)
1366
+ const nsDir = picked.dir
1367
+ const catFilter = String(args.category ?? '').trim()
1368
+
1369
+ // 补写摘要:给「还没归档、但已经聊过」的近期内容落一段摘要,让摘要层永远覆盖到最近。
1370
+ const appendText = String(args.append ?? '').trim()
1371
+ if (appendText) {
1372
+ if (!cfg.name) throw new Error('本会话还没命名,不能写摘要。先在页签里命名。')
1373
+ // 只能写自己的命名空间:跨会话写摘要等于污染别人的记忆。
1374
+ if (picked.folder !== cfg.folder) {
1375
+ throw new Error(`只能写本会话(${cfg.folder})的摘要,不能写「${picked.folder}」。`)
1376
+ }
1377
+ const month = String(args.month ?? '').trim() || fmtMonth(Date.now())
1378
+ if (!/^\d{4}-\d{2}$/.test(month)) throw new Error('month 格式是 YYYY-MM,例如 2026-10。')
1379
+ const lo = Number(args.fromTurn)
1380
+ const hi = Number(args.toTurn)
1381
+ const span = Number.isFinite(lo) && Number.isFinite(hi)
1382
+ ? `[${catFilter || 'live'}] ${lo === hi ? `turn ${lo}` : `turn ${lo}–${hi}`}`
1383
+ : `[${catFilter || 'live'}] 补记`
1384
+ const file = appendMonthSummary({ nsDir, month, label: span, text: appendText })
1385
+ return `已追加摘要:${path.relative(cwd, file).replace(/\\/g, '/')}`
1386
+ }
1387
+
1388
+ const months = fs.existsSync(nsDir)
1389
+ ? fs.readdirSync(nsDir).filter((n) => {
1390
+ if (!/^\d{4}-\d{2}$/.test(n)) return false
1391
+ try { return fs.statSync(path.join(nsDir, n)).isDirectory() } catch { return false }
1392
+ }).sort()
1393
+ : []
1394
+ if (months.length === 0) return '还没有任何月度摘要(归档之后才会有)。'
1395
+
1396
+ const countFiles = (m) => {
1397
+ let c = 0
1398
+ const d = path.join(nsDir, m)
1399
+ for (const cat of fs.readdirSync(d)) {
1400
+ const p = path.join(d, cat)
1401
+ try { if (!fs.statSync(p).isDirectory()) continue } catch { continue }
1402
+ c += fs.readdirSync(p).filter((f) => f.endsWith('.md') && f !== '_summary.md').length
1403
+ }
1404
+ return c
1405
+ }
1406
+
1407
+ const month = String(args.month ?? '').trim()
1408
+ if (month) {
1409
+ if (!/^\d{4}-\d{2}$/.test(month)) throw new Error('month 格式是 YYYY-MM,例如 2026-10。')
1410
+ const file = path.join(nsDir, month, '_summary.md')
1411
+ if (!fs.existsSync(file)) throw new Error(`没有 ${month} 的摘要文件。`)
1412
+ let text = fs.readFileSync(file, 'utf8')
1413
+ if (catFilter) {
1414
+ const blocks = text.split(/\n(?=## )/)
1415
+ const head = blocks.shift() ?? ''
1416
+ const keep = blocks.filter((b) => b.includes(`[${catFilter}]`))
1417
+ if (keep.length === 0) throw new Error(`${month} 的摘要里没有类别「${catFilter}」。`)
1418
+ text = [head, ...keep].join('\n')
1419
+ }
1420
+ // 动态补文件路径:段头不写死路径,读的时候按索引实时解析(改名/搬移不会产生死链)。
1421
+ try {
1422
+ const rowsPath = path.join(nsDir, 'index.jsonl')
1423
+ const rows = fs.existsSync(rowsPath)
1424
+ ? stripBom(fs.readFileSync(rowsPath, 'utf8')).split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l) } catch { return null } }).filter(Boolean)
1425
+ : []
1426
+ const showFile = (rel) => {
1427
+ const norm = String(rel ?? '').replace(/\\/g, '/')
1428
+ return norm.startsWith(cfg.rootDirName + '/') ? norm : `${picked.folder}/${norm}`
1429
+ }
1430
+ text = text.replace(/^## (.+?)$/gm, (line, rest) => {
1431
+ const m = /\[([^\]]+)\]\s*turn\s+(\d+)(?:\s*[–-]\s*(\d+))?/.exec(rest)
1432
+ if (!m) return line
1433
+ const cat = m[1]
1434
+ const a = Number(m[2])
1435
+ const b = m[3] ? Number(m[3]) : a
1436
+ const row = rows.find((r) => r.category === cat && Number(r.fromTurn) <= a && b <= Number(r.toTurn))
1437
+ return row ? `${line} → 文件:${showFile(row.file)}` : line
1438
+ })
1439
+ } catch { /* 补路径失败不影响读摘要 */ }
1440
+ const cap = 60000
1441
+ return text.length > cap ? text.slice(0, cap) + `\n…(已截断;共 ${text.length} 字符)` : text
1442
+ }
1443
+
1444
+ const limit = Math.min(Math.max(Number(args.limit) || 12, 1), 36)
1445
+ const recent = months.slice(-limit).reverse()
1446
+ const parts = recent.map((m) => {
1447
+ const files = countFiles(m)
1448
+ const file = path.join(nsDir, m, '_summary.md')
1449
+ if (!fs.existsSync(file)) return `${m}:无摘要(${files} 个归档文件)`
1450
+ const text = fs.readFileSync(file, 'utf8').trim()
1451
+ const tail = text.length > 700 ? '…' + text.slice(-700) : text
1452
+ return `${m}:${files} 个归档文件\n摘要末尾:\n${tail}`
1453
+ })
1454
+ return `共 ${months.length} 个月份。读某月全文:palace_summary(month="YYYY-MM")。\n\n` + parts.join('\n\n— — —\n\n')
1455
+ },
1456
+ })
1457
+ }
1458
+
1459
+ function registerReadTool(ctx, read) {
1460
+ return ctx.tools.register({
1461
+ name: 'palace_read',
1462
+ description:
1463
+ '读归档文件。推荐用法:先 palace_search 拿到文件与轮次,再用 palace_read 取那些轮次的完整原文。'
1464
+ + '可以只给 fromTurn/toTurn(自动找文件);不给轮次则只返回开头一段(有上限)。'
1465
+ + '永远不要把整份大文件灌进上下文。只读。',
1466
+ parameters: {
1467
+ type: 'object',
1468
+ properties: {
1469
+ path: { type: 'string', description: '归档文件路径(可省略;省略时用 fromTurn 在索引里找)。' },
1470
+ namespace: { type: 'string', description: '可选:别的会话的记忆命名空间名;省略 = 只看自己。' },
1471
+ fromTurn: { type: 'number', description: '起始轮次(含)。给了就和 toTurn 一起按轮次切片。' },
1472
+ toTurn: { type: 'number', description: '结束轮次(含)。' },
1473
+ maxChars: { type: 'number', description: '最多返回字符数,默认 60000(上限 60000)。' },
1474
+ offset: { type: 'number', description: '字符偏移;单轮超过上限时用它分段读,返回值会告诉下一次的 offset。' },
1475
+ },
1476
+ required: [],
1477
+ },
1478
+ output: TEXT_OUTPUT,
1479
+ execute: async (args, exec) => {
1480
+ const cwd = String(exec.agent?.session?.header?.cwd ?? '')
1481
+ const sid = sessionIdOf(exec.agent)
1482
+ const cfg = resolveConfig(read, cwd, sid)
1483
+ const picked = pickNsDir(cwd, cfg.rootDirName, sid, args.namespace)
1484
+ if (!picked.ok) throw new Error(`找不到记忆命名空间「${String(args.namespace).trim()}」。`)
1485
+ const nsDir = picked.dir
1486
+ const root = path.resolve(cwd, cfg.rootDirName)
1487
+ // path 可省略:给了 fromTurn 就去索引里找覆盖该轮次的归档文件。
1488
+ let relPath = String(args.path ?? '').replace(/\\/g, '/')
1489
+ // 允许把 search 显示的「文件夹/…」路径直接粘回来,去掉前缀,避免双拼一层。
1490
+ if (relPath === picked.folder) relPath = ''
1491
+ else if (relPath.startsWith(picked.folder + '/')) relPath = relPath.slice(picked.folder.length + 1)
1492
+ if (!relPath) {
1493
+ const wantTurn = Number(args.fromTurn)
1494
+ if (!Number.isFinite(wantTurn)) {
1495
+ throw new Error('要么给 path,要么给 fromTurn(会自动找包含该轮次的归档文件)。')
1496
+ }
1497
+ const indexPath = path.join(nsDir, 'index.jsonl')
1498
+ const rows = fs.existsSync(indexPath)
1499
+ ? stripBom(fs.readFileSync(indexPath, 'utf8')).split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l) } catch { return null } }).filter(Boolean)
1500
+ : []
1501
+ const hit = rows.filter((r) => Number(r.fromTurn) <= wantTurn && wantTurn <= Number(r.toTurn)).pop()
1502
+ if (!hit) throw new Error(`索引里没有覆盖 turn ${wantTurn} 的归档。`)
1503
+ relPath = resolveArchivePath(cwd, cfg.rootDirName, nsDir, hit.file)
1504
+ }
1505
+ const abs = path.isAbsolute(relPath) ? relPath : resolveArchivePath(cwd, cfg.rootDirName, nsDir, relPath)
1506
+ const inside = path.relative(root, abs)
1507
+ if (!inside || inside.startsWith('..') || path.isAbsolute(inside)) {
1508
+ throw new Error(`只能读 ${cfg.rootDirName}/ 下的文件。`)
1509
+ }
1510
+ // 只允许读本命名空间(或 legacy 根下本命名空间的归档),且不暴露元数据文件。
1511
+ const normRel = String(relPath).replace(/\\/g, '/')
1512
+ const isLegacy = normRel.startsWith(cfg.rootDirName + '/')
1513
+ const inNs = path.relative(nsDir, abs)
1514
+ if (isLegacy) {
1515
+ // legacy 前缀路径必须落在本会话命名空间内,否则等于绕过隔离直读别人原文。
1516
+ const afterRoot = normRel.slice(cfg.rootDirName.length + 1)
1517
+ const firstSeg = afterRoot.split('/')[0]
1518
+ if (firstSeg !== picked.folder) {
1519
+ throw new Error(`只能读本会话命名空间(${picked.folder})下的文件;跨会话请用 namespace 参数走工具接口。`)
1520
+ }
1521
+ } else if (inNs === '' || inNs.startsWith('..') || path.isAbsolute(inNs)) {
1522
+ throw new Error('只能读本会话命名空间下的归档文件。')
1523
+ }
1524
+ if (/^(\.namespaces\.json|\.state\.json|config\.json|index\.jsonl|\.pending-index\.jsonl)$/.test(path.basename(abs))) {
1525
+ throw new Error('元数据文件不可读,请用 palace_config / palace_summary。')
1526
+ }
1527
+ if (!fs.existsSync(abs)) throw new Error('文件不存在:' + relPath)
1528
+ const size = fs.statSync(abs).size
1529
+ const reqMax = Number(args.maxChars)
1530
+ const cap = Math.min(Math.max(Number.isFinite(reqMax) && reqMax > 0 ? reqMax : 60000, 500), 60000)
1531
+ const capNote = Number.isFinite(reqMax) && reqMax > 60000 ? `(maxChars 上限 60000,已按上限读取;请求 ${reqMax})` : ''
1532
+ const text = fs.readFileSync(abs, 'utf8')
1533
+
1534
+ let body = text
1535
+ let note = ''
1536
+ const hasRange = Number.isFinite(args.fromTurn) || Number.isFinite(args.toTurn)
1537
+ if (hasRange) {
1538
+ const from = Number.isFinite(args.fromTurn) ? Number(args.fromTurn) : 0
1539
+ const to = Number.isFinite(args.toTurn) ? Number(args.toTurn) : Number.MAX_SAFE_INTEGER
1540
+ const sliced = sliceTurns(text.split('\n'), from, to)
1541
+ if (sliced === null) throw new Error('这个文件里没有轮次标题,改用 maxChars 读开头。')
1542
+ if (!sliced) throw new Error(`文件里没有 turn ${from}–${to}。`)
1543
+ body = sliced
1544
+ note = `(只返回 turn ${from === 0 ? '最早' : from}–${to === Number.MAX_SAFE_INTEGER ? '最新' : to} 的原文)`
1545
+ }
1546
+ const rawOff = Number(args.offset)
1547
+ const off = Math.max(0, Number.isFinite(rawOff) ? rawOff : 0)
1548
+ const offNote = Number.isFinite(rawOff) && rawOff < 0 ? `(offset 为负,已按 0 处理)` : ''
1549
+ if (off > 0 && off >= body.length) throw new Error(`offset ${off} 超出范围(共 ${body.length} 字符)。`)
1550
+ const end = Math.min(off + cap, body.length)
1551
+ const piece = body.slice(off, end)
1552
+ if (capNote) note += capNote
1553
+ if (offNote) note += offNote
1554
+ if (off > 0 || end < body.length) {
1555
+ note += `(返回第 ${off}–${end} 字符 / 共 ${body.length};${end < body.length ? `用 offset=${end} 继续` : '已到结尾'})`
1556
+ }
1557
+ // 回链:只从“本次返回的轮次”里提取,避免把整份归档文件里的无关轮次/文件名算进来。
1558
+ // 没有指定范围时明确标成“文件级”,不冒充本轮引用。
1559
+ let links = ''
1560
+ try {
1561
+ const linkSource = hasRange ? body : ''
1562
+ const turnSet = new Set()
1563
+ for (const m of linkSource.matchAll(/\bturn\s+(\d{1,6})\b/gi)) turnSet.add(Number(m[1]))
1564
+ const files = new Set()
1565
+ for (const m of linkSource.matchAll(/[A-Za-z0-9_.\-\u4e00-\u9fa5]+\.(?:md|js|cjs|mjs|ts|json|ps1|cmd|txt|zip|tgz|png|jpg)\b/gi)) files.add(m[0])
1566
+ const tools = new Set()
1567
+ for (const m of linkSource.matchAll(/\b(?:palace_archive|palace_search|palace_read|palace_summary|palace_turns|palace_config)\b/g)) tools.add(m[0])
1568
+ const ownTurns = new Set()
1569
+ if (hasRange) {
1570
+ const from = Number.isFinite(args.fromTurn) ? Number(args.fromTurn) : 0
1571
+ const to = Number.isFinite(args.toTurn) ? Number(args.toTurn) : Number.MAX_SAFE_INTEGER
1572
+ for (let t = from; t <= Math.min(to, from + 200); t++) ownTurns.add(t)
1573
+ }
1574
+ const otherTurns = [...turnSet].filter((t) => !ownTurns.has(t)).sort((a, b) => a - b).slice(0, 10)
1575
+ const parts = []
1576
+ if (otherTurns.length) parts.push(`turn ${otherTurns.join(', ')}`)
1577
+ if (files.size) parts.push(`文件 ${[...files].slice(0, 5).join(', ')}`)
1578
+ if (tools.size) parts.push(`工具 ${[...tools].slice(0, 5).join(', ')}`)
1579
+ if (parts.length) {
1580
+ links = hasRange
1581
+ ? `\n\n回链(仅限本次读取的轮次):${parts.join(' · ')}(自动提取,仅供参考)`
1582
+ : `\n\n回链(文件级,非本轮引用;要本轮级别请传 fromTurn/toTurn):${parts.join(' · ')}(自动提取,仅供参考)`
1583
+ }
1584
+ } catch { /* 提取失败不影响读取 */ }
1585
+ const out = note ? `${note}\n\n${piece}` : piece
1586
+ return (links && off === 0 ? out + links : out)
1587
+ },
1588
+ })
1589
+ }
1590
+
1591
+ /** 工作区级配置工具:让会话模型能查/改本工作区的阈值,不需要界面。 */
1592
+ function registerConfigTool(ctx, read) {
1593
+ return ctx.tools.register({
1594
+ name: 'palace_config',
1595
+ description:
1596
+ '查看或修改当前工作区的记忆回廊配置(memory-palace/config.json)。'
1597
+ + '可改项:enabled、triggerPressure、retainBytes、maxFileBytes、checkEveryTurns、minBatchTurns、selfNameSlug、includeReasoning、compactAfterArchive、injectNotice。'
1598
+ + '只影响当前工作区,不改变其他工作区与全局默认。',
1599
+ parameters: {
1600
+ type: 'object',
1601
+ properties: {
1602
+ action: { type: 'string', enum: ['get', 'set'], description: 'get 查看,set 修改。' },
1603
+ values: { type: 'object', description: 'set 时的键值对,只接受可改项。' },
1604
+ },
1605
+ required: ['action'],
1606
+ },
1607
+ output: TEXT_OUTPUT,
1608
+ execute: async (args, exec) => {
1609
+ const cwd = String(exec.agent?.session?.header?.cwd ?? '')
1610
+ if (!cwd) throw new Error('读不到当前工作区路径。')
1611
+ const sid = sessionIdOf(exec.agent)
1612
+ const cfg = resolveConfig(read, cwd, sid)
1613
+ if (args.action === 'get') {
1614
+ const ws = loadWorkspaceConfig(cwd, cfg.rootDirName)
1615
+ const sc = loadSessionConfig(cwd, cfg.rootDirName, sid)
1616
+ // 只展示正式键 recallBudget*;旧键 recursionMax* 仅保留在读取兼容层,不出现在输出里。
1617
+ const strip = (o) => {
1618
+ if (!o || typeof o !== 'object') return o
1619
+ const { recursionMaxDepth, recursionMaxWidth, ...rest } = o
1620
+ return rest
1621
+ }
1622
+ return JSON.stringify({ effective: strip(cfg), workspaceDefaults: strip(ws), sessionConfig: strip(sc) }, null, 2)
1623
+ }
1624
+ const input = args.values ?? {}
1625
+ const clean = {}
1626
+ for (const [k, v] of Object.entries(input)) {
1627
+ if (!OVERRIDABLE.includes(k)) throw new Error(`不允许修改的键:${k}`)
1628
+ if (k === 'enabled' || k === 'includeReasoning' || k === 'compactAfterArchive' || k === 'injectNotice' || k === 'groupByTurn') {
1629
+ clean[k] = Boolean(v)
1630
+ } else if (k === 'contextWindowTokens') {
1631
+ throw new Error(k + ' 由插件固定/自动探测,不允许手动修改。')
1632
+ } else if (k === 'searchTurnLimit') {
1633
+ const n = Number(v)
1634
+ if (!Number.isInteger(n) || n < 1 || n > 30) throw new Error('searchTurnLimit 必须是 1–30 的整数')
1635
+ clean[k] = n
1636
+ } else if (k === 'recallStrength') {
1637
+ const allowed = ['off', 'low', 'medium', 'high', 'forced']
1638
+ const s = String(v)
1639
+ if (!allowed.includes(s)) throw new Error('recallStrength 必须是 off / low / medium / high / forced')
1640
+ clean[k] = s
1641
+ } else if (k === 'recursionMaxDepth' || k === 'recursionMaxWidth' || k === 'recallBudgetRounds' || k === 'recallBudgetKeywords') {
1642
+ const n = Number(v)
1643
+ if (!Number.isInteger(n) || n < 1 || n > 5) throw new Error(`${k} 必须是 1–5 的整数`)
1644
+ clean[k] = n
1645
+ } else if (k === 'retainFraction') {
1646
+ const n = Number(v)
1647
+ if (!(n >= 0.02 && n <= 0.5)) throw new Error('retainFraction 必须在 0.02–0.5')
1648
+ clean[k] = n
1649
+ } else if (k === 'batchByteFraction') {
1650
+ const n = Number(v)
1651
+ if (!(n >= 0.05 && n <= 1)) throw new Error('batchByteFraction 必须在 0.05–1')
1652
+ clean[k] = n
1653
+ } else if (k === 'triggerPressure') {
1654
+ const n = Number(v); if (!(n >= 0.2 && n <= 0.95)) throw new Error('triggerPressure 必须在 0.2–0.95')
1655
+ clean[k] = n
1656
+ } else if (['contextWindowTokens', 'retainBytes', 'maxFileBytes', 'checkEveryTurns', 'minBatchTurns'].includes(k)) {
1657
+ const n = Number(v); if (!Number.isFinite(n) || n < 0) throw new Error(`${k} 必须是非负数字`)
1658
+ clean[k] = Math.floor(n)
1659
+ } else if (k === 'selfNameSlug') {
1660
+ const s = String(v); if (!/^[a-z][a-z0-9-]{1,63}$/.test(s)) throw new Error('selfNameSlug 必须是英文小写 slug')
1661
+ clean[k] = s
1662
+ }
1663
+ }
1664
+ if (Object.keys(clean).length === 0) throw new Error('没有可写入的字段。')
1665
+ // 写会话层(<会话>/config.json),与 /palace on|off 一致;会话层优先级最高。
1666
+ const file = path.join(cfg.dir, 'config.json')
1667
+ const prev = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : {}
1668
+ const next = { ...prev, ...clean }
1669
+ fs.mkdirSync(path.dirname(file), { recursive: true })
1670
+ fs.writeFileSync(file, JSON.stringify(next, null, 2), 'utf8')
1671
+ return `已更新本会话配置:${JSON.stringify(clean)}\n文件:${path.relative(cwd, file).replace(/\\/g, '/')}`
1672
+ },
1673
+ })
1674
+ }
1675
+
1676
+ /** 工作区开关命令:/palace [status|on|off]。给人用的入口,不需要界面。 */
1677
+ function registerPalaceCommand(ctx, read) {
1678
+ if (!ctx?.commands || typeof ctx.commands.register !== 'function') return null
1679
+ return ctx.commands.register({
1680
+ name: 'palace',
1681
+ description: '记忆回廊:查看状态、开启/关闭当前工作区的归档',
1682
+ input: { hint: '[status|on|off]' },
1683
+ handler: async ({ agent, rawInput }) => {
1684
+ const cwd = String(agent?.session?.header?.cwd ?? '')
1685
+ if (!cwd) return { kind: 'error', text: '读不到当前工作区路径。' }
1686
+ const sid = sessionIdOf(agent)
1687
+ const cfg = resolveConfig(read, cwd, sid)
1688
+ const file = path.join(cfg.dir, 'config.json')
1689
+ const arg = String(rawInput ?? '').trim().toLowerCase() || 'status'
1690
+
1691
+ if (arg === 'status') {
1692
+ const ws = loadWorkspaceConfig(cwd, cfg.rootDirName)
1693
+ const stats = await turnStats(sid, cwd, cfg)
1694
+ return {
1695
+ kind: 'success',
1696
+ text: [
1697
+ `工作区:${cwd}`,
1698
+ `记忆命名:${cfg.name || '(未命名,暂不归档)'}`,
1699
+ `文件夹:${cfg.rootDirName}/${cfg.folder}`,
1700
+ `本会话开关:${cfg.sessionEnabled ? '开' : '关'}`,
1701
+ `全局总闸:${read('enabled') === false ? '关' : '开'}(原始配置)`,
1702
+ `当前生效:${cfg.enabled && cfg.sessionEnabled && cfg.name ? '是' : '否'}`,
1703
+ `触发阈值:${(cfg.triggerPressure * 100).toFixed(0)}%(估算窗口 ${cfg.contextWindowTokens} tokens)`,
1704
+ `无需归档的近期文本量:${Math.round((cfg.contextWindowTokens || 0) * (cfg.retainFraction || 0)).toLocaleString()} tokens(窗口 × ${Math.round((cfg.retainFraction || 0) * 100)}%)`,
1705
+ `单文件上限:${(cfg.maxFileBytes / 1000000).toFixed(1)} MB(超出按轮分卷)`,
1706
+ `自动提示 steer:${cfg.enableSteer ? '开' : '关'}`,
1707
+ stats
1708
+ ? `轮次:总 ${stats.totalTurns} · 已归档 ${stats.archivedTurns} · 待归档 ${stats.pendingTurns} · 水位 ${stats.watermark}`
1709
+ : '轮次:读不到会话日志',
1710
+ (() => {
1711
+ const idx = path.join(cfg.dir, 'index.jsonl')
1712
+ if (!fs.existsSync(idx)) return '归档总量:0 份'
1713
+ let c = 0
1714
+ let b = 0
1715
+ for (const line of stripBom(fs.readFileSync(idx, 'utf8')).split('\n')) {
1716
+ if (!line.trim()) continue
1717
+ try { const r = JSON.parse(line); c += 1; b += Number(r.bytes) || 0 } catch { /* ignore */ }
1718
+ }
1719
+ return `归档总量:${c} 份 · ${(b / 1024 / 1024).toFixed(2)} MB`
1720
+ })(),
1721
+ (() => {
1722
+ const st = readState(cwd, cfg.rootDirName, sid)
1723
+ const s = st[sid] ?? {}
1724
+ return `本会话:上次检查 turn ${s.lastCheckTurn ?? '-'},上次归档 ${s.lastArchiveRange ?? '-'}(${s.lastArchiveAt ?? '-'})`
1725
+ })(),
1726
+ (() => {
1727
+ // 对账:状态区间 vs 索引真实覆盖,把“区间说有、索引没有”的轮次揪出来。
1728
+ const st = readState(cwd, cfg.rootDirName, sid)
1729
+ const s = st[sid] ?? {}
1730
+ const ranges = Array.isArray(s.archivedRanges) ? s.archivedRanges : []
1731
+ if (ranges.length === 0) return '对账:无归档区间'
1732
+ const idx = path.join(cfg.dir, 'index.jsonl')
1733
+ if (!fs.existsSync(idx)) return `对账:状态有 ${ranges.length} 段区间,但索引不存在`
1734
+ const cov = new Set()
1735
+ for (const line of stripBom(fs.readFileSync(idx, 'utf8')).split('\n')) {
1736
+ if (!line.trim()) continue
1737
+ let r
1738
+ try { r = JSON.parse(line) } catch { continue }
1739
+ if (Array.isArray(r.turns) && r.turns.length > 0) for (const t of r.turns) cov.add(Number(t))
1740
+ else for (let t = Number(r.fromTurn); t <= Number(r.toTurn); t++) cov.add(t)
1741
+ }
1742
+ const missing = []
1743
+ for (const [a, b] of ranges) for (let t = a; t <= b; t++) if (!cov.has(t)) missing.push(t)
1744
+ if (missing.length === 0) return '对账:状态区间与索引一致'
1745
+ return `对账:状态里有 ${missing.length} 轮没进索引(${missing.slice(0, 10).join(', ')}${missing.length > 10 ? ' …' : ''}),可对这些轮次补归档`
1746
+ })(),
1747
+ `工作区覆盖:${JSON.stringify(ws)}`,
1748
+ `配置文件:${path.relative(cwd, file).replace(/\\/g, '/')}`,
1749
+ ].join('\n'),
1750
+ }
1751
+ }
1752
+
1753
+ if (arg === 'on' || arg === 'off') {
1754
+ let prev = {}
1755
+ try { prev = JSON.parse(fs.readFileSync(file, 'utf8')) } catch { prev = {} }
1756
+ prev.enabled = arg === 'on'
1757
+ delete prev.retainTurns // 废弃键,写配置时顺手清掉
1758
+ fs.mkdirSync(path.dirname(file), { recursive: true })
1759
+ fs.writeFileSync(file, JSON.stringify(prev, null, 2), 'utf8')
1760
+ return {
1761
+ kind: 'success',
1762
+ text: arg === 'on'
1763
+ ? `记忆回廊已对本会话开启。文件夹:${cfg.rootDirName}/` + (cfg.name ? '' : '(未命名,命名并保存后才开始归档)')
1764
+ : '记忆回廊已对本会话关闭。已有归档不会被删除。',
1765
+ }
1766
+ }
1767
+
1768
+ return { kind: 'error', text: '用法:/palace [status|on|off]' }
1769
+ },
1770
+ })
1771
+ }
1772
+
1773
+ export function apply(ctx, config) {
1774
+ const read = makeReader(config)
1775
+ // 页签里的「手动归档」按钮复用工具执行器,避免两套归档逻辑。
1776
+ let archiveExecute = null
1777
+ // 注册失败只记日志,绝不把异常抛给插件树(一次空包曾把 workspaceController 整个带崩)。
1778
+ for (const register of [registerArchiveTool, registerTurnsTool, registerSearchTool, registerSummaryTool, registerReadTool, registerConfigTool]) {
1779
+ try {
1780
+ const def = register(ctx, read)
1781
+ if (def && def.name === 'palace_archive' && typeof def.execute === 'function') archiveExecute = def.execute
1782
+ } catch (err) {
1783
+ safeLog(ctx, 'tool registration failed: ' + (err?.message ?? String(err)))
1784
+ }
1785
+ }
1786
+ try {
1787
+ registerPalaceCommand(ctx, read)
1788
+ } catch (err) {
1789
+ safeLog(ctx, 'command registration failed: ' + (err?.message ?? String(err)))
1790
+ }
1791
+ // 常驻提示:让「查长期记忆」成为默认动作,而不是碰巧想起。
1792
+ try {
1793
+ const guidance = '【长期记忆·记忆回廊】你的记忆在 memory-palace/<你的记忆名>/,'
1794
+ + '这个文件夹里的文件都属于你,不要靠文件名前缀判断归属(前缀只是历史署名);记忆名在「记忆回廊」页签里能看到。'
1795
+ + '何时查:涉及更早的细节、旧决定、被压缩掉的历史,或用户问起"之前说过什么";前文能回答的别查,别反复搜同一件事。'
1796
+ + '怎么查:不确定在哪个年月时,先 palace_summary 读大致年月范围的摘要(摘要在 <年月>/_summary.md,每月一份、按类别分段;可用 category 只看某类),定位到具体归档文件,再 palace_search 找轮次(可加 category 过滤),最后 palace_read 取原文;不要凭印象编造,没命中就直说没有记录。'
1797
+ + '补摘要:重要近况还没归档时,可用 palace_summary(append=…, fromTurn, toTurn) 主动补一段到当月摘要,让摘要层覆盖到最近。'
1798
+ + '归档:只在系统提示你归档时调用 palace_archive,按 segments 逐轮给类别,并用 summaries 只概述“本次被归档的那几轮”(每个类别一段);本会话未命名时不要归档。'
1799
+ + '归档前先用 palace_turns 看清每轮内容再分类;turn 是日志事件序号(含系统指令/插件提示/纯工具轮),禁止用“第几条消息”换算。'
1800
+ + '跨会话:默认只查自己;要读同工作区其他会话,显式传 namespace(对方文件夹名)。别人的记忆只作参考,别把别人的约定当成你的规则。'
1801
+ + '只读:不要修改或删除 memory-palace 下的任何文件。'
1802
+ ctx.inject(['systemPrompt'], (prompted) => {
1803
+ prompted.get('systemPrompt').section({
1804
+ name: 'memory-palace-recall',
1805
+ order: 160,
1806
+ text: () => (read('enabled') === false ? '' : guidance),
1807
+ })
1808
+ // 静默前置注入:请求组装前把头部/压缩摘要放进上下文(不走 steer、不产生二次回答)。
1809
+ prompted.get('systemPrompt').context({
1810
+ name: 'memory-palace-recall-context',
1811
+ order: 900,
1812
+ interpolate: false,
1813
+ // required:宿主抑制“可选”运行时上下文时也会保留;不标记可能被过滤,
1814
+ // 表现为 provider 被调用过、但模型上下文里看不到。
1815
+ required: true,
1816
+ text: (assembleCtx) => recallContextText(read, assembleCtx),
1817
+ })
1818
+ })
1819
+ } catch (err) {
1820
+ safeLog(ctx, 'guidance registration failed: ' + (err?.message ?? String(err)))
1821
+ }
1822
+ // 会话页签的数据接口(客户端半通过 fetch 调它)。
1823
+ // 只挂 exact:DSH 的 prefix 路由匹配规则与预期不同,实测 exact 稳定。
1824
+ try {
1825
+ let web = ctx.webServer
1826
+ if (!web && typeof ctx.get === 'function') { try { web = ctx.get('webServer') } catch { /* ignore */ } }
1827
+ if (web && typeof web.register === 'function') {
1828
+ const handler = createMemoryPalaceRoute({
1829
+ ctx, read, resolveConfig, readState, writeState, effectiveWindow, turnStats,
1830
+ ns: { sanitizeName, loadNsMap, saveNsMap, nsFor },
1831
+ getEvents: () => eventLog.slice(-20),
1832
+ gateState,
1833
+ logScan: scanSessionLog,
1834
+ archive: archiveExecute
1835
+ ? async ({ cwd, sessionId, category, fromTurn, toTurn }) => {
1836
+ const fakeAgent = { session: { header: { cwd, id: sessionId } } }
1837
+ return archiveExecute({ category, fromTurn, toTurn }, { agent: fakeAgent })
1838
+ }
1839
+ : null,
1840
+ // 手动归档 = 给模型发一条指令,类别由模型自己判断。
1841
+ requestArchive: async ({ cwd, sessionId }) => {
1842
+ const agent = ctx.agents?.get?.(sessionId)
1843
+ if (!agent) throw new Error('这个会话当前不在运行,没法给它发归档指令;请切到该会话再点。')
1844
+ const cfg = resolveConfig(read, cwd, sessionId)
1845
+ const { turns } = await loadTurns(agent, cfg)
1846
+ if (!turns || turns.length === 0) throw new Error('读不到会话日志,未发送指令。')
1847
+ const win = await effectiveWindow(agent, cfg)
1848
+ const state = readState(cwd, cfg.rootDirName, sessionId)
1849
+ const { eligible } = planRange(turns, cfg, state, sessionId, win)
1850
+ if (eligible.length === 0) {
1851
+ return '暂无可归档轮次:其余都在保留区内(窗口的 12%)。'
1852
+ }
1853
+ const lo = eligible[0].turn
1854
+ const hi = eligible[eligible.length - 1].turn
1855
+ const text = `【手动归档】用户在页签里点了「现在归档」。请调用 palace_archive,用 segments 逐段给类别,覆盖 turn ${lo}–${hi};`
1856
+ + '先调用 palace_turns 看清每一轮装的是什么,再决定类别——turn 是日志事件序号,不要用“第几条消息”换算。'
1857
+ + '逐轮判断每轮属于哪个类别(英文小写 slug),同一类别可拆多段,每个 turn 只能归一类;同一次调用里同一类别写进同一个文件。'
1858
+ + 'summaries 只概述“本次被归档的那几轮”,不要写归档区间之外的内容(否则摘要会指向不存在的原文)。'
1859
+ + '不要解释、不要复述原文,完成后一句话告诉我结果即可。'
1860
+ const message = assertUserMessage({
1861
+ id: randomUUID(),
1862
+ role: 'user',
1863
+ content: [{ type: 'text', text }],
1864
+ source: { kind: 'dsh-memory-palace' },
1865
+ })
1866
+ agent.steer(message)
1867
+ safeLog(ctx, `manual archive requested: turn ${lo}-${hi}`)
1868
+ return `已提醒模型归档 turn ${lo}–${hi},类别由它判断。`
1869
+ },
1870
+ log: (m) => safeLog(ctx, m),
1871
+ })
1872
+ ctx.effect(() => web.register({ kind: 'exact', path: MEMORY_PALACE_ROUTE, handler }), 'memory-palace: data route')
1873
+ }
1874
+ } catch (err) {
1875
+ safeLog(ctx, 'route registration failed: ' + (err?.message ?? String(err)))
1876
+ }
1877
+
1878
+ // 监听上下文压缩完成:给该会话打“下次组装注入完整摘要”的标记。
1879
+ // 事件来自首方用法(dsh-client-product-analytics 也这么听);收不到不会报错,只会静默失效,
1880
+ // 所以这里额外记事件,页签“最近事件”里能看到有没有触发。
1881
+ try {
1882
+ ctx.on('session/event', (session, event) => {
1883
+ try {
1884
+ if (!event || event.type !== 'compaction/end') return
1885
+ const sid = session?.header?.id ?? session?.id
1886
+ if (!sid) return
1887
+ pendingFullAfterCompaction.add(String(sid))
1888
+ recordEvent('compaction-detected', String(sid).slice(0, 8) + '…')
1889
+ } catch { /* ignore */ }
1890
+ })
1891
+ } catch (err) {
1892
+ safeLog(ctx, 'session/event subscribe failed: ' + (err?.message ?? String(err)))
1893
+ }
1894
+
1895
+ // 每 checkEveryTurns 轮检查一次:估算占用率,达到阈值就 steer 一句,让模型去归档。
1896
+ ctx.on('agent/turn-stopping', async ({ agent, turn }) => {
1897
+ try {
1898
+ await onTurnStopping(ctx, read, agent, turn)
1899
+ } catch (err) {
1900
+ safeLog(ctx, 'turn-stopping failed: ' + (err?.message ?? String(err)))
1901
+ }
1902
+ })
1903
+ }
1904
+
1905
+ /** 回忆强度:forced 每条消息后强制检索;中/高由 systemPrompt.context 前置注入摘要。进程级状态,重启清空。 */
1906
+ const recallForcedAt = new Map()
1907
+ // 注入后标记“下一轮是模型对这条 steer 的回应”,跳过它,避免 steer→turn→steer 自激。
1908
+ const awaitingSteerReply = new Set()
1909
+
1910
+ /**
1911
+ * 记忆注入策略(两条独立链,都是一次性普通上下文消息,不是系统提示):
1912
+ * 1. 每次启动后首轮:注入极简头部(约百来字),只提示“有记忆可用”;
1913
+ * 2. 每次上下文压缩完成后(compaction/end):注入最近一个月的完整摘要一次,直到下次压缩。
1914
+ * 最近一个月摘要不足 500 token 时,最多再往前补一个月;没有就停。
1915
+ */
1916
+ const pendingFullAfterCompaction = new Set()
1917
+
1918
+ /**
1919
+ * 压缩后只给“去读哪个归档摘要”的指引,不注入全文(避免与宿主压缩摘要抢注意力,也省 token)。
1920
+ * 当前年月没有摘要时,最多往前追溯一个月;都没有就不注入。
1921
+ */
1922
+ function listSummaryMonths(cfg) {
1923
+ try {
1924
+ return fs.readdirSync(cfg.dir, { withFileTypes: true })
1925
+ .filter((e) => e.isDirectory() && /^\d{4}-\d{2}$/.test(e.name))
1926
+ .map((e) => e.name)
1927
+ .sort()
1928
+ .reverse()
1929
+ .filter((m) => fs.existsSync(path.join(cfg.dir, m, '_summary.md')))
1930
+ } catch { return [] }
1931
+ }
1932
+
1933
+ function pickCompactionNotice(cfg, minTokens = 500) {
1934
+ // 用“实际有摘要的最新月份”,而不是日历当前月:跨月/跨年很久没聊时,
1935
+ // 日历月可能是空的,按日历算会指向不存在的摘要。
1936
+ const months = listSummaryMonths(cfg)
1937
+ if (months.length === 0) return null
1938
+ const picked = [months[0]]
1939
+ let firstText = ''
1940
+ try { firstText = stripBom(fs.readFileSync(path.join(cfg.dir, months[0], '_summary.md'), 'utf8')) } catch { /* ignore */ }
1941
+ if (estimateTokens(firstText) < minTokens && months[1]) picked.push(months[1])
1942
+ return {
1943
+ months: picked,
1944
+ files: picked.map((m) => `${cfg.rootDirName}/${cfg.folder}/${m}/_summary.md`),
1945
+ }
1946
+ }
1947
+
1948
+ /**
1949
+ * 动态上下文 provider(请求组装前求值)。任何异常都返回空串,绝不让提示词组装失败。
1950
+ */
1951
+ function recallContextText(read, assembleCtx) {
1952
+ try {
1953
+ const agent = assembleCtx?.agent
1954
+ const cwd = String(agent?.session?.header?.cwd ?? '')
1955
+ const sid = sessionIdOf(agent)
1956
+ if (!cwd || !sid) return ''
1957
+ if (isSubagentSession(agent)) return ''
1958
+ const cfg = resolveConfig(read, cwd, sid)
1959
+ if (!gateState(cfg).active) return ''
1960
+ const strength = ['off', 'low', 'medium', 'high', 'forced'].includes(cfg.recallStrength) ? cfg.recallStrength : 'low'
1961
+ if (strength === 'off' || strength === 'low') return ''
1962
+ if (cfg.enableSteer === false) return ''
1963
+ // 压缩后:只给“去读哪份归档摘要”的指引,不注入全文。
1964
+ if (pendingFullAfterCompaction.has(sid)) {
1965
+ pendingFullAfterCompaction.delete(sid)
1966
+ const notice = pickCompactionNotice(cfg)
1967
+ recordEvent('recall-after-compaction', `…${sid.slice(-6)} ${notice ? notice.months.join(' / ') : '无摘要'}`)
1968
+ if (!notice) return ''
1969
+ // 追溯与否由代码决定:选了几个月就让模型读几个月,不再把“太短”的判断丢回给模型。
1970
+ const calls = notice.months.map((m) => `palace_summary(month="${m}")`).join(' 和 ')
1971
+ const filesTxt = notice.files.join('、')
1972
+ return '【记忆回廊 · 压缩后提示】你刚刚被自动压缩,较早的对话已被系统折成一段摘要放在上下文里。'
1973
+ + `压缩后请先调用 ${calls} 读取近期归档摘要(按类别整理;文件:${filesTxt}),再继续当前对话。`
1974
+ + '系统压缩摘要优先;它没覆盖到的内容,用归档摘要补充。'
1975
+ }
1976
+ // 启动时不注入:重启后会话历史本身已在上下文里,“上一轮聊到哪”不需要摘要补。
1977
+ // 只有压缩后(pendingFullAfterCompaction)才注入一次指引。
1978
+ return ''
1979
+ } catch (err) {
1980
+ recordEvent('recall-context-error', String(err?.message ?? err))
1981
+ return ''
1982
+ }
1983
+ }
1984
+
1985
+ function maybeRecallSteer(agent, cfg, turn, sid) {
1986
+ if (!sid) return
1987
+ if (cfg.enableSteer === false) return // 显式关掉 steer 时,任何档位都不注入
1988
+ const strength = ['off', 'low', 'medium', 'high', 'forced'].includes(cfg.recallStrength) ? cfg.recallStrength : 'low'
1989
+ if (strength === 'off') return
1990
+ if (!gateState(cfg).active) return
1991
+ if (awaitingSteerReply.has(sid)) {
1992
+ awaitingSteerReply.delete(sid)
1993
+ recordEvent('recall-steer-skip', `turn ${turn}(本轮是对上一条 steer 的回应)`)
1994
+ return
1995
+ }
1996
+ const clamp = (n) => Math.min(Math.max(Number(n) || 1, 1), 5)
1997
+ const depth = clamp(cfg.recallBudgetRounds ?? cfg.recursionMaxDepth)
1998
+ const width = clamp(cfg.recallBudgetKeywords ?? cfg.recursionMaxWidth)
1999
+ const steer = (text) => {
2000
+ const msg = assertUserMessage({
2001
+ id: randomUUID(),
2002
+ role: 'user',
2003
+ content: [{ type: 'text', text }],
2004
+ source: { kind: 'dsh-memory-palace' },
2005
+ })
2006
+ agent.steer(msg)
2007
+ awaitingSteerReply.add(sid)
2008
+ recordEvent('recall-steer', `${strength} turn ${turn}`)
2009
+ }
2010
+ if (strength === 'forced') {
2011
+ if (recallForcedAt.get(sid) === turn) return
2012
+ recallForcedAt.set(sid, turn)
2013
+ steer(`【系统指令 · 记忆回忆】从用户这条消息里挑关键词,调用 palace_search 检索已归档原文(必要时加 all:true 扫全部命名空间)。`
2014
+ + `若返回「归档记忆为空」就如实说没有记录,不要编造。检索后简短回答回忆到了什么。`
2015
+ + `递归预算:最多 ${depth} 轮、每轮最多 ${width} 个关键词(建议,非强制;插件不校验)。`)
2016
+ return
2017
+ }
2018
+ // 中/高/强制的“首条摘要”已改为 systemPrompt.context 前置注入(请求前生效),
2019
+ // 这里不再发 steer,避免“答完再注入导致模型二次回答”。
2020
+ }
2021
+
2022
+ async function onTurnStopping(ctx, read, agent, turn) {
2023
+ {
2024
+ if (isSubagentSession(agent)) return // 子智能体不归档、不 steer
2025
+ const cwd = String(agent?.session?.header?.cwd ?? '')
2026
+ const sid0 = sessionIdOf(agent)
2027
+ const cfg = resolveConfig(read, cwd, sid0)
2028
+ const gate = gateState(cfg)
2029
+ if (!gate.active) {
2030
+ recordEvent('gate-off', gate.reasons.join(' / '))
2031
+ return
2032
+ }
2033
+ if (!isActiveFor(ctx, cfg, agent)) return
2034
+ try { maybeRecallSteer(agent, cfg, turn, sid0) } catch (err) { recordEvent('recall-steer-error', String(err?.message ?? err)) }
2035
+ const every = Number(cfg.checkEveryTurns) || 5
2036
+ if (every < 1 || turn % every !== 0) return
2037
+ // 快路径:先只看计量,压力没到阈值就不解码日志(否则每 3 轮卡 5–10 秒)。
2038
+ const fast = measurePressureFast(ctx, agent, cfg)
2039
+ const threshold = Number(cfg.triggerPressure) || 0.6
2040
+ if (fast && fast.pressure < threshold) {
2041
+ const st = readState(cwd, cfg.rootDirName, sid0)
2042
+ st[sid0] = {
2043
+ ...(st[sid0] ?? {}),
2044
+ lastCheckTurn: turn,
2045
+ lastCheckAt: fmtTime(Date.now()),
2046
+ lastPressure: Number(fast.pressure.toFixed(3)),
2047
+ lastPressureSource: fast.source,
2048
+ }
2049
+ try { writeState(cwd, cfg.rootDirName, st, sid0) } catch { /* ignore */ }
2050
+ return
2051
+ }
2052
+ let loaded
2053
+ try {
2054
+ loaded = await loadTurns(agent, cfg)
2055
+ } catch {
2056
+ return
2057
+ }
2058
+ const { sid, turns } = loaded
2059
+ if (!turns || turns.length === 0) return
2060
+ const state = readState(cwd, cfg.rootDirName, sid)
2061
+ const win = await effectiveWindow(agent, cfg)
2062
+ const { eligible, tailFrom } = planRange(turns, cfg, state, sid, win)
2063
+ const measured = measurePressure(ctx, agent, turns, win)
2064
+ const pressure = measured.pressure
2065
+ // 可观测性(P3-1):先记检查点,再判可归档区间,避免“eligible 为空就什么都不留”。
2066
+ state[sid] = { ...(state[sid] ?? {}), lastCheckTurn: turn, lastCheckAt: fmtTime(Date.now()), lastPressure: Number(pressure.toFixed(3)), lastPressureSource: measured.source }
2067
+ writeState(cwd, cfg.rootDirName, state, sid)
2068
+ // 批量门槛:轮数够了,或者可归档文本量已经达到保留上限的一半(长轮次会话也能触发)。
2069
+ const minBatch = Math.max(1, Number(cfg.minBatchTurns) || 1)
2070
+ const capB = retentionCapBytes(cfg, win)
2071
+ const eligibleBytes = eligible.reduce((s, t) => s + turnBytes(t), 0)
2072
+ const byteGate = capB * (Number(cfg.batchByteFraction) || 0.5)
2073
+ if (eligible.length < minBatch && eligibleBytes < byteGate) return
2074
+ if (pressure < cfg.triggerPressure) return
2075
+ // 统一门:injectNotice / enableSteer 任一关掉就不主动提示(工具仍可用)。
2076
+ if (!gate.steer) { recordEvent('steer-off', `injectNotice=${cfg.injectNotice} enableSteer=${cfg.enableSteer}`); return }
2077
+ // 冷却:同一次归档没发生前,不重复催。否则每 checkEveryTurns 轮就弹一次,所有窗口都会刷屏。
2078
+ const lastSteer = Number(state[sid]?.lastSteerTurn ?? -99999)
2079
+ if (turn - lastSteer < Math.max(10, every * 2)) return
2080
+ const lo = eligible[0].turn
2081
+ const hi = eligible[eligible.length - 1].turn
2082
+ const kept = tailFrom < turns.length ? turns.length - tailFrom : 0
2083
+ const text =
2084
+ `【系统指令 · 记忆回廊】会话上下文占用 ${(Math.min(pressure, 9.99) * 100).toFixed(1)}%(窗口 ${cfg.contextWindowTokens} tokens${measured.source === 'log-sum' ? ',日志估算' : ',DSH 计量'}),已超过阈值 ${(cfg.triggerPressure * 100).toFixed(0)}%。`
2085
+ + `先调用 palace_turns(fromTurn=${lo}, toTurn=${hi}) 看清每一轮装的是什么,再决定类别;turn 是日志事件序号,不要用“第几条消息”换算。`
2086
+ + `再用 palace_archive 的 segments=[{category, fromTurn, toTurn}, …] 逐段给类别。`
2087
+ + `逐轮判断每轮属于哪个类别(英文小写 slug),同一类别可以拆成多段;每个 turn 只能归一类,区间不要重叠。`
2088
+ + `同一次调用里,同一类别会写进同一个文件(超过单文件上限才分卷)。`
2089
+ + `还要带 summaries=[{category, text}, …]:每个类别只概述它那几轮的关键内容(200–800 字),**不要写区间之外的内容**,否则摘要会指向不存在的原文。`
2090
+ + `不要解释、不要复述原文、不要问我,直接调用。这是内部归档动作,完成后用一句话告诉我结果即可。`
2091
+ + `(最近 ${kept} 轮保留不动,不会被归档。)`
2092
+ try {
2093
+ // steer 要的是 UserMessage 对象;传裸字符串会写坏 inbox、让会话无法加载。
2094
+ const message = assertUserMessage({
2095
+ id: randomUUID(),
2096
+ role: 'user',
2097
+ content: [{ type: 'text', text }],
2098
+ source: { kind: 'dsh-memory-palace' },
2099
+ })
2100
+ agent.steer(message)
2101
+ state[sid] = { ...(state[sid] ?? {}), lastSteerTurn: turn, updatedAt: fmtTime(Date.now()) }
2102
+ writeState(cwd, cfg.rootDirName, state, sid)
2103
+ } catch (err) {
2104
+ safeLog(ctx, 'steer skipped: ' + (err?.message ?? String(err)))
2105
+ }
2106
+ }
2107
+ }