sage-mem 0.9.16 → 0.9.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -164,7 +164,24 @@ SAGE_MEM_DIR=/path/to/your/memory
164
164
  - **换个说法也能命中**:打分是**词面重合**,不是语义的——问「我有什么待办」而文件里写的是「代办清单」,差一个字就完全不命中。给这类记忆加一行 `aliases: [待办, todo]` 就能补上
165
165
  - **注入的是命中段落,不是文件开头**。命中段落标成 `### 项目_部署.md:40-52`,agent 据此能直接跳到那几行核对来源;段落前补一句该记忆的 `description`,补回「这段是从哪条记忆切出来的」。`baseline` 与没命中正文的文件仍注入开头。单条正文超 1500 字符截断并标 `…(截断)`
166
166
  - **同一个问题不会重复注入**(按会话记签名),问第二遍不会又追加一遍
167
- - **单场会话累计注入到 60 KB 就停**(`MAX_SESSION_BYTES`)。条数上限管的是「一次注多少」,这条管「一场会话总共注多少」—— 长会话里问题一直在换、签名一直在变,累积本来没有上限。到顶之后新的记忆不再注入(这时该让 agent 直接读文件),**压缩或清空之后账面自动归零**,又能注进来
167
+ - **单场会话预算 60 KB,拆成两本账**(`MAX_SESSION_BYTES`):**正文池** = 总上限 − **状态卡预留**(5%,夹在 1–4 KB)。总上限的数字没变,变的是到顶之后的行为 —— 旧行为是「正文装不下就一个字都不注,连为什么都没有」;现在是「正文照旧不注,但缺口状态卡发得出来,告诉你漏了什么、下一步怎么查」。**压缩或清空之后两本账与卡的去重签名一起归零**,又能注进来
168
+ - **缺口状态卡只在真有缺口时说话**:有候选没读、或有候选被预算省略时才出现,说清范围、条数、最佳线索和**一个可做的动作**;一条都没命中时**只在你问自己的历史时**才说(否则提示会被当噪音忽略)。单张卡上限 512 字节,且按内容签名去重 —— 同一张卡不会每一步都追加一遍
169
+
170
+ ### 模型也能自己翻:两个检索工具
171
+
172
+ 到 0.9.17 为止,检索的触发权一直全在插件手里(它猜你想看什么)。现在**分一半给模型**:
173
+
174
+ | 工具 | 用途 |
175
+ |---|---|
176
+ | `search_memory(query, scope?, limit?, cursor?)` | 先比摘要、再按段落查正文;返回候选、命中片段、行号,以及**覆盖情况**(扫了几份、几段因相关性不够被滤掉、有没有下一页) |
177
+ | `read_memory(name, startLine?, startByte?, maxBytes?, revision?)` | 按行分页读正文。带上你上次读到的 `revision`,内容被人改过时会回 `revision_changed`,**并且不返回内容** —— 否则你会拿着过期行号读错东西 |
178
+
179
+ 两个工具走的是**同一条**安全读路径(`safeName` → 符号链接与保留名身份检查 → 体积闸门),
180
+ 没有为工具另开一条绕过闸门的入口。它们用 `ctx.inject(['tools'], …)` 挂子 fiber 注册 ——
181
+ **不是硬依赖**:宿主不提供工具服务时,插件照常工作,只是状态卡换一套「直接读记忆目录」的说法。
182
+
183
+ - **摘要侧也有相关性门槛**:中文里「怎么」「什么」这类词元在大量 `description` 里都有,一句无关的「今天天气怎么样」曾靠一个「怎么」命中十几份记忆。门槛按**语料自身的 IDF 尺度**归一,实测真实目录里无关问题的候选数降到约七分之一;离线评测集上还不只是没退化、而是更好了(Recall@5 84%→87%、误注入 14%→5.6%)—— 被清掉的弱摘要候选原先在挤占名额
184
+ - **意图识别绝不在四类显式请求上抑制重复**:「上次决定用 A 对吧」(核实)、「不用 A 了」(更正)、「之前为什么没选 A」(问依据)必须给原文;判不出来就不判,不猜
168
185
  - **每条记忆的标题带新鲜度**:`(保存于 N 天前)`,**优先读 frontmatter 的 `updated:`,缺失才回落文件 mtime** —— 手写的时间戳是权威,文件系统时间会被复制 / 备份还原 / checkout 整批改掉。理由是陈旧记忆被当事实引用时,「引用」这个动作本身会让它显得更权威而不是更可疑,所以注入文本开头还带一节**引用纪律**(提到路径先确认存在、提到函数先 grep、要动手先核实)。它**独立成节**而不是一条 bullet:Claude Code 实测同一句话埋成 bullet 命中 0/3、独立成节 3/3
169
186
  - **`baseline: true` 的记忆每场会话第一步无条件注入**,最多 8 条——给「你是谁」「待办清单」这类每次开场都该在场的记忆用。多条 baseline 之间的先后由 `baselinePriority` 决定(数字越大越前,缺省 0),**不按文件名排**
170
187
  - 记忆目录读不到、权限错、frontmatter 格式坏,这些都**降级为不注入并写日志**,不会静默失效
package/lib/index.js CHANGED
@@ -18,12 +18,16 @@ import { AutodreamEngine } from './autodream.js'
18
18
  import { auditMemoryDir } from './autodream-audit.js'
19
19
  import { DEFAULT_CONFIG } from './autodream/config.js'
20
20
  import { readJson } from './autodream/util.js'
21
- import { MAX_BASELINE, MAX_CHARS_PER_FILE, MAX_FILE_BYTES, MAX_RESULTS, MAX_SESSION_BYTES, MEMORY_DIR, RECALL_DISCIPLINE, RESERVED_FILES, SESSIONS_ROOT, STALE_DAYS, STATE_ROOT } from './memory/config.js'
21
+ import { CONTENT_BUDGET, MAX_BASELINE, MAX_CHARS_PER_FILE, MAX_FILE_BYTES, MAX_RESULTS, MAX_SESSION_BYTES, MEMORY_DIR, RECALL_DISCIPLINE, RESERVED_FILES, SESSIONS_ROOT, STALE_DAYS, STATE_ROOT, STATUS_RESERVE } from './memory/config.js'
22
22
  import { parseAliases, parseBaseline, parseBaselinePriority, parseFrontmatter, parseTags } from './memory/frontmatter.js'
23
23
  import { isReserved, nameKey, safeName } from './memory/naming.js'
24
24
  import { ageDays, bigrams, extractText, score, selectBaseline, selectRelevant } from './memory/retrieval.js'
25
25
  import { MEMORY_ARCHIVE_DIR, addArchiveMeta, extractTitle, inspectDir, inspectMemoryEntry, readArchivedMeta, scanArchived, scanMemoryFiles, scanStars, shadowsReserved } from './memory/scan.js'
26
26
  import { rankChunks } from './memory/search.js'
27
+ import { renderStatusCard } from './memory/status.js'
28
+ import { detectIntent } from './memory/intent.js'
29
+ import { decideDelivery } from './memory/deliver.js'
30
+ import { createMemoryTools } from './tools/memory-tools.js'
27
31
  import { archiveMemory, restoreMemory } from './memory/archive.js'
28
32
  import { flushAccess, pickArchiveCandidates, readAccessLedger, recordAccess } from './memory/access.js'
29
33
  import { warnLog, writeFileAtomic } from './memory/util.js'
@@ -198,6 +202,42 @@ export class MemoryGateway extends TypertRemoteService {
198
202
  console.error('[sage-mem] autodream init failed:', err && err.stack ? err.stack : err)
199
203
  }
200
204
 
205
+ // ── 检索工具:`search_memory` / `read_memory` ──
206
+ //
207
+ // 工具是否注册成功。拿不到宿主的 `tools` 服务时为 false —— 状态卡据此换文案:
208
+ // 有工具就教它「用 search_memory 搜」,没有就退回「直接读记忆目录」。
209
+ //
210
+ // 为什么是个可变量:注册是**异步就绪**的(`ctx.inject` 要等依赖服务上线),而注入链
211
+ // 每步都跑 —— 前几步可能还没就绪、后几步才就绪,这是正常的。声明必须在这一块之前。
212
+ let toolsReady = false
213
+ //
214
+ // ⚠️ **用 `ctx.inject` 挂子 fiber,绝不给本类加 `static inject = ['tools']`。**
215
+ // 理由(`docs/decisions.md` D5,有官方依据):`inject` 是**硬依赖** —— 声明了它的插件
216
+ // 要等依赖就绪才会被加载。宿主没有 `tools` 服务时,整个 sage-mem 会一直待机,
217
+ // **记忆注入跟着一起消失**。而工具只是锦上添花,不值得拿核心功能去换。
218
+ // `ctx.inject` 只让这个子回调等依赖就绪,主插件照常工作;依赖永远不来就只有它不生效。
219
+ //
220
+ // 另一条实测事实(`docs/host-facts.md`):不声明 inject 时 `ctx.get('tools')` 只给
221
+ // `undefined`(那一刻服务还没被提供,而插件不会重跑),所以「先探测再注册」在这里
222
+ // 是行不通的 —— 必须走 `ctx.inject`。
223
+ try {
224
+ if (typeof ctx.inject === 'function') {
225
+ ctx.inject(['tools'], (inner) => {
226
+ try {
227
+ const { searchMemory, readMemory } = createMemoryTools(ctx)
228
+ inner.tools.register(searchMemory)
229
+ inner.tools.register(readMemory)
230
+ toolsReady = true
231
+ } catch (err) {
232
+ // 注册失败 ≠ 注入失败。出声一次,然后什么也不做。
233
+ warnLog(ctx, `检索工具注册失败(记忆注入不受影响):${err?.message ?? err}`)
234
+ }
235
+ })
236
+ }
237
+ } catch (err) {
238
+ warnLog(ctx, `检索工具挂载失败(记忆注入不受影响):${err?.message ?? err}`)
239
+ }
240
+
201
241
  // 同一轮对话里问题不变、注入内容也不变,但 DSH 每一步都会往 session 追加
202
242
  // 一条 context 消息——不去重的话同一份记忆会被反复追加,撑爆上下文。
203
243
  const recallCache = new Map()
@@ -346,18 +386,31 @@ export class MemoryGateway extends TypertRemoteService {
346
386
  return assembled
347
387
  }
348
388
 
349
- // ── 单会话累计预算 ──
350
- // 账记在 session id 上;消息条数变小 = 压缩/清空发生过 → 账本归零(见上面
351
- // injectLedger 的注释)。picked 里 baseline 永远排在检索结果之前,所以预算
352
- // 不够时先被挤掉的是「更晚才想起来的」那些 —— baseline 有优先权。
389
+ // ── 单会话累计预算:**拆成两本账** ──
390
+ // 正文池 = CONTENT_BUDGET(总上限 − 状态卡预留)/卡池 = STATUS_RESERVE
391
+ // 拆的理由见 config.js:到顶之后旧行为是一个字都不注、连「为什么没注、还能怎么查」
392
+ // 都没有;而那个上限是**本插件自己定的**,完全可以给状态卡留一份固定额度。
393
+ // 账记在 session id 上;picked 里 baseline 永远排在检索结果之前,所以预算不够时
394
+ // 先被挤掉的是「更晚才想起来的」那些 —— baseline 有优先权。
353
395
  const ledgerKey = session.id || 'no-session-id'
354
396
  const prevLedger = injectLedger.get(ledgerKey)
355
- let usedBytes = prevLedger && messages.length >= prevLedger.messageCount ? prevLedger.bytes : 0
397
+ // 压缩 / 清空 → **两本账与卡签名一起归零**。卡签名必须一起清:不然「本会话已经发过
398
+ // 这张卡」仍然成立,而那张卡早已随旧上下文消失 —— 恢复入口就此永久失效。
399
+ // (「本会话注入过」不等于「压缩后还看得见」,这条是计划里点名要处理的。)
400
+ const carried = prevLedger && messages.length >= prevLedger.messageCount ? prevLedger : null
401
+ let contentBytes = carried?.contentBytes ?? 0
402
+ let cardBytes = carried?.cardBytes ?? 0
403
+ let cardSig = carried?.cardSig ?? ''
356
404
  const nowMs = Date.now()
357
405
 
358
- const parts = []
359
- const injectedFiles = []
360
- let skippedForBudget = 0
406
+ // 用户这句话是不是在问自己的历史 —— 展示层与状态卡都要用它,所以在两处之前算一次。
407
+ const intentInfo = text ? detectIntent(text) : { intent: 'none', historyDependent: false, suppressible: true }
408
+
409
+ // ── 渲染候选,然后交给展示层决定「给正文 / 给指针 / 不给」──
410
+ // 两件事分开做:这一段只负责「每条候选长什么样」,`decideDelivery` 负责
411
+ // 「在预算内该给哪几条、为什么」。混在一起写就是上一版的样子 —— 预算、渲染、
412
+ // 去重三件事挤在一个循环里,谁也说不清某条为什么没进去。
413
+ const candidates = []
361
414
  for (const f of picked) {
362
415
  // **baseline 不吃段落替换**:它的语义是「这条记忆每场会话都该完整在场」
363
416
  // (你是谁、待办清单)。一旦用户的问句碰巧命中它,替换成单个段落就等于
@@ -376,44 +429,112 @@ export class MemoryGateway extends TypertRemoteService {
376
429
  // - 摘要:段落本身不含 frontmatter,补回「这一段是从哪条记忆里切出来的」。
377
430
  const loc = chunk ? `:${chunk.startLine}-${chunk.endLine}` : ''
378
431
  const brief = chunk && f.description ? ` · ${f.description.slice(0, 80)}` : ''
379
- const part = `### ${f.file}${loc}${stamp}${brief}\n${body}`
380
- const size = Buffer.byteLength(part, 'utf8')
381
- if (usedBytes + size > MAX_SESSION_BYTES) { skippedForBudget++; continue }
382
- usedBytes += size
383
- parts.push(part)
384
- injectedFiles.push(f.file)
432
+ candidates.push({
433
+ file: f.file,
434
+ id: chunk?.id ?? `${f.file}#head`,
435
+ revision: chunk?.revision ?? '',
436
+ score: f.score ?? 0,
437
+ baseline: f.baseline === true,
438
+ lines: chunk ? [chunk.startLine, chunk.endLine] : null,
439
+ title: f.description || '',
440
+ text: `### ${f.file}${loc}${stamp}${brief}\n${body}`,
441
+ })
442
+ }
443
+
444
+ const delivery = decideDelivery(candidates, {
445
+ intent: intentInfo.intent,
446
+ // ⚠️ 这里**故意传空集**:按 revision 抑制重复对「同一份文件的不同段落」太粗 ——
447
+ // 换一个问题往往会命中同一份文件的**另一段**,按文件级 revision 抑制会把新相关
448
+ // 的那段一起挡掉。要启用它得先用「段落身份」而不是「文件 revision」做键,
449
+ // 并且单独评测一次。这条记在 docs/decisions.md 的 D15,不是遗漏。
450
+ visibleRevisions: new Set(),
451
+ scopeDenied: new Set(),
452
+ conflicts: new Set(),
453
+ expired: new Set(),
454
+ needle: text ? text.slice(0, 40) : '',
455
+ }, { bytes: Math.max(0, CONTENT_BUDGET - contentBytes) })
456
+
457
+ const parts = []
458
+ const injectedFiles = []
459
+ let skippedForBudget = 0
460
+ for (const item of delivery.items) {
461
+ if (item.kind === 'snippet') {
462
+ parts.push(item.text)
463
+ injectedFiles.push(item.file)
464
+ } else {
465
+ // pointer / suppress / unresolved 都**不进正文**。
466
+ // 「被省略几条」这件事由下面的状态卡统一说,正文里再逐条列一行
467
+ // `### 某某.md(未展开)` 是重复的信息,还白占上下文 —— 卡片本来就带着
468
+ // 「最佳线索 + 一个可做的动作」。
469
+ skippedForBudget++
470
+ }
385
471
  }
472
+ // 按**实际进正文的字节**记账,而不是 delivery.totals —— 后者含指针的额度,
473
+ // 而指针没有真的进上下文。两本账要对得上,就不能把没花出去的钱记进去。
474
+ contentBytes += parts.reduce((n, p) => n + Buffer.byteLength(p, 'utf8'), 0)
386
475
 
387
476
  // 进上下文的这些才算「被用过」—— 台账是自动归档阈值的唯一数据源,记错一步阈值就偏。
388
477
  // recordAccess 同步、绝不抛:台账的问题不许升级成注入失败。
389
478
  recordAccess(injectedFiles, nowMs)
390
479
 
391
- // 被预算挡掉了全部条目 —— 一个字都不注。本会话已经注进去的那批还在上下文里,
392
- // 每步再塞一条「预算已满」只是白花 token;真压缩过之后账本会自己归零。
393
- // 这条早退也要出声:一条都没注进去,正是最需要解释的那种情况。
394
- if (parts.length === 0) {
480
+ // ── 缺口状态卡 ──
481
+ // 它**替换**原来那行状态文字,不是额外追加:同一件事不说两遍。
482
+ // 卡池与正文池互不借用 —— 正文满了照样发得出卡,这正是预留的意义(见 config.js)。
483
+ const bodySearched = typeof text === 'string' && text.length >= 4
484
+ const bodyOnly = bodySearched ? dedupeByFile(bodyRanked) : []
485
+ const unread = Math.max(0, selected.length - injectedFiles.length)
486
+ // 最佳线索取「正文命中里第一条没被注进去的」——那正是模型下一步最该去读的东西。
487
+ const beacon = bodyOnly.find(h => !injectedFiles.includes(h.file)) ?? bodyOnly[0] ?? null
488
+ // 什么时候该说话:
489
+ // - **有真缺口**(有候选没读、或有候选被预算省略)→ 说;
490
+ // - **没有缺口也没有命中** → 只在「用户这句话在问自己的历史」时说。
491
+ // 后者是计划里点名的一条:没有历史依赖时不要插「你可能遗漏记忆」,否则提示
492
+ // 每轮都出现,等真有缺口时它已经被当噪音忽略了。
493
+ const gapExists = unread > 0 || skippedForBudget > 0
494
+ const wantCard = gapExists || intentInfo.historyDependent
495
+ const cardText = wantCard
496
+ ? renderStatusCard({
497
+ scope: bodySearched ? 'all' : 'summary',
498
+ scanned: files.length,
499
+ injected: parts.length,
500
+ omitted: skippedForBudget,
501
+ unread,
502
+ best: beacon ? { file: beacon.file, title: beacon.chunk?.title || beacon.chunk?.headingPath || '' } : null,
503
+ needle: text ? text.slice(0, 40) : null,
504
+ toolsAvailable: toolsReady,
505
+ memoryDir: MEMORY_DIR,
506
+ })
507
+ : ''
508
+ let cardPart = ''
509
+ // 卡按**内容签名**去重:签名没变就不重复发。否则每一步都追加一张一模一样的卡,
510
+ // 而每张卡都是落到会话里的持久消息 —— 那不叫提示,那叫刷屏。
511
+ if (cardText && cardText !== cardSig) {
512
+ const cardSize = Buffer.byteLength(cardText, 'utf8')
513
+ if (cardBytes + cardSize <= STATUS_RESERVE) {
514
+ cardBytes += cardSize
515
+ cardPart = cardText
516
+ cardSig = cardText
517
+ }
518
+ }
519
+
520
+ // 正文与卡都没有 → 一个字都不注。注意这与旧行为不同:旧行为「正文池满」就到此为止,
521
+ // 现在只要卡池还有额度就会发出卡(说清范围、缺口与下一步动作)。
522
+ if (parts.length === 0 && !cardPart) {
395
523
  if (DEBUG_RECALL) {
396
524
  debugRecall({
397
525
  scanned: files.length, hits: relevant.length,
398
526
  scores: relevant.map(f => [f.file, Number(f.score.toFixed(4))]),
399
- picked: picked.map(f => f.file), injected: [], chars: usedBytes, skippedByBudget: skippedForBudget,
527
+ picked: picked.map(f => f.file), injected: [], chars: contentBytes, skippedByBudget: skippedForBudget,
400
528
  })
401
529
  }
402
530
  return assembled
403
531
  }
404
532
 
405
- injectLedger.set(ledgerKey, { bytes: usedBytes, messageCount: messages.length })
533
+ injectLedger.set(ledgerKey, { contentBytes, cardBytes, cardSig, messageCount: messages.length })
406
534
  if (injectLedger.size > INJECT_LEDGER_MAX) {
407
535
  injectLedger.delete(injectLedger.keys().next().value)
408
536
  }
409
537
 
410
- // 状态行:让「注了几条、花了多少、被挡掉几条」在上下文里可见 ——
411
- // 「某条记忆没被注入」是最难查的一类故障,这里留一个可读的出口。
412
- const status = `(本次注入 ${parts.length} 条;本会话累计 ${Math.round(usedBytes / 1024)} KB` +
413
- ` / 上限 ${Math.round(MAX_SESSION_BYTES / 1024)} KB` +
414
- (skippedForBudget > 0 ? `;因预算省略 ${skippedForBudget} 条,需要时直接用文件工具读 ${MEMORY_DIR}` : '') +
415
- ')'
416
-
417
538
  if (DEBUG_RECALL) {
418
539
  debugRecall({
419
540
  scanned: files.length,
@@ -421,18 +542,28 @@ export class MemoryGateway extends TypertRemoteService {
421
542
  scores: relevant.map(f => [f.file, Number(f.score.toFixed(4))]),
422
543
  picked: picked.map(f => f.file),
423
544
  injected: injectedFiles,
424
- chars: usedBytes,
545
+ chars: contentBytes,
425
546
  skippedByBudget: skippedForBudget,
547
+ card: cardPart ? 'sent' : (cardText ? 'deduped-or-over-budget' : 'none'),
426
548
  })
427
549
  }
428
550
 
551
+ // 只有正文时:头部 + 引用纪律 + 正文 [+ 卡]
552
+ // 只有卡时:**就只发卡** —— 头部那句「以下是与你当前问题相关的历史记忆」在没有正文
553
+ // 的情况下是假陈述,而引用纪律管的是「别把记过的东西当当前事实」,没有正文就没有
554
+ // 需要约束的对象。
555
+ const recallText = parts.length
556
+ ? `以下是与你当前问题相关的历史记忆(文件式,来自 ${MEMORY_DIR}):\n\n` +
557
+ `${RECALL_DISCIPLINE}\n\n${parts.join('\n\n')}` +
558
+ (cardPart ? `\n\n${cardPart}` : '')
559
+ : cardPart
560
+
429
561
  return {
430
562
  ...assembled,
431
563
  contexts: [...(assembled.contexts ?? []), {
432
564
  name: 'sage-mem:recall',
433
565
  order: 1000,
434
- text: `以下是与你当前问题相关的历史记忆(文件式,来自 ${MEMORY_DIR}):\n\n` +
435
- `${RECALL_DISCIPLINE}\n\n${parts.join('\n\n')}\n\n${status}`,
566
+ text: recallText,
436
567
  }],
437
568
  }
438
569
  } catch (err) {
@@ -62,6 +62,28 @@ export const MAX_BASELINE = envInt('SAGE_MEM_MAX_BASELINE', 8, 1, 50)
62
62
  */
63
63
  export const MAX_SESSION_BYTES = envInt('SAGE_MEM_MAX_SESSION_BYTES', 60 * 1024, 4096, 512 * 1024)
64
64
 
65
+ /**
66
+ * 状态卡预留占总预算的比例,以及它的上下夹逼区间。
67
+ *
68
+ * **为什么要把 60 KB 拆成两本账**:到顶之后旧行为是 `parts.length === 0` → 一个字都不注,
69
+ * 连「为什么没注、还能怎么查」都没有。而那个上限是本插件**自己定的**(不是宿主约束),
70
+ * 所以完全可以给它留一份固定额度。
71
+ *
72
+ * 5% 与 [1 KB, 4 KB] 的取值:状态卡单张上限 512 字节,4 KB 够发 8 张;而 4 KB 在
73
+ * 60 KB 里占 6.7%,不至于挤掉正文。**总上限数字保持不变** —— 面板滑杆、既有文档、
74
+ * 用户对「60 KB」的直觉都不用改。
75
+ */
76
+ export const STATUS_RESERVE_RATIO = 0.05
77
+ export const STATUS_RESERVE = Math.max(1024, Math.min(4096, Math.round(MAX_SESSION_BYTES * STATUS_RESERVE_RATIO)))
78
+
79
+ /**
80
+ * 正文可用额度 = 总上限 − 状态卡预留。
81
+ *
82
+ * 两本账**互不借用**:正文池满了仍然发得出卡(卡的额度本来就留着,这正是预留的意义),
83
+ * 而卡池满了不会再从正文池借 —— 借了等于把「预留」这件事作废。
84
+ */
85
+ export const CONTENT_BUDGET = MAX_SESSION_BYTES - STATUS_RESERVE
86
+
65
87
  /**
66
88
  * 注入文本里标「保存于 N 天前」的门槛(天)。超了才标;0 = 每条都标。
67
89
  *
@@ -0,0 +1,225 @@
1
+ /**
2
+ * sage-mem 记忆层 —— 展示层:把「检索到的候选」落成「这次到底给什么」。
3
+ *
4
+ * 为什么单独一层:检索若顺手「因为模型读过就删候选」,「没找到」与「找到了但没展示」
5
+ * 就成了同一个输出 —— 那是排查不了的那类故障。所以检索只负责排序与证据,
6
+ * 由这里逐条给出可解释的处置(正文 / 指针 / 抑制 / 待裁决),并逐条记字节。
7
+ *
8
+ * 纯函数:不读盘、不看时钟、不改传入对象。调用方保证 `candidates` **已经过相关性门槛**,
9
+ * 本模块不重算分数 —— 门槛那套判据只该有一个出处(`bm25.js` 的 evidence)。
10
+ */
11
+
12
+ import { NO_SUPPRESS_INTENTS } from './intent.js'
13
+
14
+ /**
15
+ * 「这几类意图绝不抑制重复正文」—— 用户在核实、更正、问依据,要的就是原文。
16
+ *
17
+ * ⚠️ 这份名单**只有一个出处**(`intent.js` 的 `NO_SUPPRESS_INTENTS`),本模块不保留副本:
18
+ * 同一个业务概念写在两个文件里迟早会漂,而漂了的后果是「某一天显式核实请求被当成重复内容
19
+ * 抑制掉」—— 正是要防的那个失败模式。展示层 `DELIVERY_REASONS` 里也有这三个词,
20
+ * 但那只是**理由怎么命名**的词汇表,不是「谁不许被抑制」这份名单。
21
+ */
22
+
23
+ /** 指针的记账字节 —— 指针也占上下文,不记账就等于假装它免费。 */
24
+ export const POINTER_BYTES = 90
25
+
26
+ /**
27
+ * 展示理由枚举 —— 对外字段,必须稳定(面板、日志、评测都按它分类)。
28
+ *
29
+ * `no_relevant_hit` 不由本模块产出:候选为空时没有可供挂理由的条目,
30
+ * 它留给调用方在「压根没有候选」那条路上使用。
31
+ */
32
+ export const DELIVERY_REASONS = Object.freeze([
33
+ 'baseline', // baseline 记忆:每场会话必给正文
34
+ 'explicit_recall', // 用户在显式问历史 → 给正文,不抑制
35
+ 'evidence_missing', // 摘要没覆盖、正文命中了 → 给正文
36
+ 'same_revision_visible', // 同 revision 的正文已在当前上下文 → 抑制重复
37
+ 'budget_omitted', // 预算不够 → 给指针(不静默丢弃)
38
+ 'scope_denied', // 作用域不允许
39
+ 'conflict_pending', // 有未裁决冲突 → 不把任一方当定论
40
+ 'expired_source', // 来源明确失效
41
+ 'below_floor', // 没过相关性门槛(由调用方传入 alreadyFiltered 信息)
42
+ 'no_relevant_hit', // 压根没有相关命中
43
+ ])
44
+
45
+ /** 处置种类。`unresolved` 与 `suppress` 都不注入正文,但前者要留入口给歧义裁决。 */
46
+ export const DELIVERY_KINDS = Object.freeze(['snippet', 'pointer', 'suppress', 'unresolved'])
47
+
48
+ /**
49
+ * 判断候选是否落在某个「按文件记账」的集合里。
50
+ *
51
+ * 为什么同时认 `file` 与 `id`:作用域拒绝、冲突、失效都是**按文件**标注的,
52
+ * 但候选可能来自正文段落,调用方手里只有 chunk id。两种键都收,调用方不必先做映射。
53
+ * Set 为主,容忍数组(调用方从 JSON 复原时常常是数组)。
54
+ */
55
+ function hitsFileSet(setLike, cand) {
56
+ const keys = [cand.file, cand.id].filter(v => typeof v === 'string' && v !== '')
57
+ if (!keys.length || !setLike) return false
58
+ if (typeof setLike.has === 'function') return keys.some(k => setLike.has(k))
59
+ if (Array.isArray(setLike)) return keys.some(k => setLike.includes(k))
60
+ return false
61
+ }
62
+
63
+ /** 同上,但按 revision 记账(`visibleRevisions`)。空 revision 不参与匹配 —— 缺值不该等于相等。 */
64
+ function hitsRevisionSet(setLike, revision) {
65
+ if (typeof revision !== 'string' || revision === '' || !setLike) return false
66
+ if (typeof setLike.has === 'function') return setLike.has(revision)
67
+ if (Array.isArray(setLike)) return setLike.includes(revision)
68
+ return false
69
+ }
70
+
71
+ /**
72
+ * 候选身上可用的正文:`text` 优先,`excerpt` 兜底。
73
+ *
74
+ * 两者都是「检索层能给出的正文证据」,只是丰俭不同(全文 vs 200 字符片段)——
75
+ * 降级发生在**数据**上,不发生在逻辑里:调用方拿得到全文就给全文,只拿得到片段就给片段。
76
+ *
77
+ * ⚠️ 这条兜底不是可选项:真实 wire shape(`search.toHit`)**根本不输出 `text`**,
78
+ * 正文只以 `excerpt` 出现。若这里只认 `text`,「调用方忘了映射」的后果是普通轮次
79
+ * **全量 below_floor**,而且没有任何外部信号 —— 状态卡会说「没有相关命中」,
80
+ * 实际是找到了。展示层不该依赖调用方记得做映射。
81
+ */
82
+ function bodyOf(cand) {
83
+ if (typeof cand.text === 'string' && cand.text.length > 0) return cand.text
84
+ if (typeof cand.excerpt === 'string' && cand.excerpt.length > 0) return cand.excerpt
85
+ return ''
86
+ }
87
+
88
+ /**
89
+ * UTF-8 字节数:中文一个字三个字节,用 `length` 记账会把额度算成三倍。
90
+ */
91
+ function byteLen(s) {
92
+ return Buffer.byteLength(String(s ?? ''), 'utf8')
93
+ }
94
+
95
+ /**
96
+ * 决定每条候选怎么交付。
97
+ *
98
+ * 规则顺序即优先级,先判的赢(与 `DELIVERY_REASONS` 的注释顺序一致):
99
+ * 1. baseline → snippet(不受预算裁剪)
100
+ * 2. scopeDenied → suppress
101
+ * 3. expired → unresolved
102
+ * 4. conflicts → unresolved(保留入口,不注入任一方正文)
103
+ * 5. 同 revision 可见且意图非显式 → suppress
104
+ * 6. 显式意图 → 有正文给 snippet(`text` 优先、`excerpt` 兜底);两者都空 → pointer
105
+ * 7. 有正文(同上:`text` 或只有 `excerpt`)→ snippet
106
+ * 8. 预算放不下正文 → pointer(不静默丢弃)
107
+ * 9. 其余 → suppress(below_floor)
108
+ *
109
+ * ⚠️ 规则 6/7 与规则 8 的关系(**这一层最容易被后人改坏的地方,改之前先读完这段**):
110
+ * 6/7 决定「这条**想要**正文」,规则 8 是它的**预算闸门**。若把规则 8 当成「先于 6/7 判」
111
+ * 的一条独立规则,它就永远不可达(有正文的候选早在 7 就被定成 snippet 了),而那正好会让
112
+ * 「预算不够」这种状态从输出里消失 —— 与「不静默丢弃」相悖。所以:想要正文 → 放得下给
113
+ * snippet,放不下给 pointer。
114
+ *
115
+ * ⚠️ **显式意图不是特权通道**:那三类意图保证的是「不抑制」(用户在看原文、在更正、在问
116
+ * 依据),**不是**「无视预算」。预算面前人人平等,否则 totals 的账就不守恒了。
117
+ *
118
+ * 预算语义:`budget.bytes` 是**含 baseline** 的正文总额度,baseline 从中扣除但自身永不降级;
119
+ * 预算小到装不下 baseline 时,`totals.bytes` **允许超过** `budget.bytes` —— 超支如实暴露,
120
+ * 不靠悄悄裁掉必给项来维持一个好看的数字。对此**调用方(`lib/index.js`)有责任为 baseline
121
+ * 预留空间**:超支是在报告「调用方没留够」,不是本模块的静默行为。
122
+ *
123
+ * 指针**不参与**正文额度的竞争 —— 它是「给不起正文时留的入口」,若也扣额度,
124
+ * 一条超长候选就能把后续候选全压成指针。
125
+ *
126
+ * 未传 / 非数字的 `budget.bytes` 按「不设限」处理:调用方漏传参数时,宁可多给正文,
127
+ * 也不要静默把所有正文降级成指针(那是更难查的一种「记忆不再注入」)。
128
+ *
129
+ * items 顺序与输入顺序一致(调用方负责排序,本模块不重排 —— 重排会让「同一条候选
130
+ * 在两次调用里被处置成不同种类」,反而更难解释)。
131
+ *
132
+ * @param {Array} candidates 每条:`{file, id, revision, score, title?, lines?, text?, baseline?}`
133
+ * @param {object} ctx `{intent, visibleRevisions, scopeDenied, conflicts, expired, needle}`
134
+ * @param {object} budget `{bytes}` 本次可用于正文的字节
135
+ * @returns {{items: Array, totals: {bytes, injected, pointers, suppressed, unresolved}}}
136
+ * items 每项 `{file, id, revision, kind, reason, bytes, title, lines, text?}`;
137
+ * `text` 只在 kind 为 snippet 时出现(其余处置不带正文,避免消费方误注入)。
138
+ */
139
+ export function decideDelivery(candidates, ctx = {}, budget = {}) {
140
+ const list = Array.isArray(candidates) ? candidates : []
141
+ const c = ctx ?? {}
142
+ const intent = String(c.intent ?? '')
143
+ const explicit = NO_SUPPRESS_INTENTS.includes(intent)
144
+ const rawBudget = Number(budget?.bytes)
145
+ let remaining = Number.isFinite(rawBudget) ? Math.max(0, rawBudget) : Infinity
146
+
147
+ const items = []
148
+ const totals = { bytes: 0, injected: 0, pointers: 0, suppressed: 0, unresolved: 0 }
149
+
150
+ for (const cand of list) {
151
+ // 非对象候选直接跳过:展示层不该因为调用方混进一个 null 就把整批候选丢掉。
152
+ if (!cand || typeof cand !== 'object') continue
153
+
154
+ const revision = String(cand.revision ?? '')
155
+ const title = String(cand.title ?? '')
156
+ const lines = Array.isArray(cand.lines) ? [...cand.lines] : null
157
+ let kind = 'suppress'
158
+ let reason = 'below_floor'
159
+ let bytes = 0
160
+ let text
161
+
162
+ if (cand.baseline === true) {
163
+ // 规则 1:baseline 永远给正文,不因预算降级(它的额度由调用方在总额度里预留)。
164
+ kind = 'snippet'
165
+ reason = 'baseline'
166
+ text = String(cand.text ?? '')
167
+ bytes = byteLen(text)
168
+ remaining -= bytes
169
+ } else if (hitsFileSet(c.scopeDenied, cand)) {
170
+ kind = 'suppress'
171
+ reason = 'scope_denied'
172
+ } else if (hitsFileSet(c.expired, cand)) {
173
+ kind = 'unresolved'
174
+ reason = 'expired_source'
175
+ } else if (hitsFileSet(c.conflicts, cand)) {
176
+ // 规则 4:有未裁决冲突时两端都不当定论 —— 这里**不给正文**,只留入口。
177
+ kind = 'unresolved'
178
+ reason = 'conflict_pending'
179
+ } else if (!explicit && hitsRevisionSet(c.visibleRevisions, revision)) {
180
+ kind = 'suppress'
181
+ reason = 'same_revision_visible'
182
+ } else {
183
+ // 规则 6/7/8/9 共用这一段:先看「这条手上有没有可端的正文」,
184
+ // 再决定给正文、给指针还是不说话。
185
+ const body = bodyOf(cand)
186
+ if (!body) {
187
+ // 规则 6 的空集出口:显式意图只留入口(reason 记它为什么被端出来),
188
+ // 不产出 `text:''` 的空 snippet。规则 9:意图不指向历史又没有正文,占位不如不说话。
189
+ kind = explicit ? 'pointer' : 'suppress'
190
+ reason = explicit ? 'explicit_recall' : 'below_floor'
191
+ bytes = explicit ? POINTER_BYTES : 0
192
+ } else if (byteLen(body) <= remaining) {
193
+ kind = 'snippet'
194
+ reason = explicit ? 'explicit_recall' : 'evidence_missing'
195
+ text = body
196
+ bytes = byteLen(body)
197
+ remaining -= bytes
198
+ } else {
199
+ // 规则 8:降级成指针,reason 如实写 budget_omitted —— 消费方能看出
200
+ // 「这条不是不相关,是这次给不起」。
201
+ kind = 'pointer'
202
+ reason = 'budget_omitted'
203
+ bytes = POINTER_BYTES
204
+ }
205
+ }
206
+
207
+ const item = { file: String(cand.file ?? ''), id: String(cand.id ?? ''), revision, kind, reason, bytes, title, lines }
208
+ if (kind === 'snippet') item.text = text ?? ''
209
+ items.push(item)
210
+
211
+ if (kind === 'snippet') {
212
+ totals.injected++
213
+ totals.bytes += bytes
214
+ } else if (kind === 'pointer') {
215
+ totals.pointers++
216
+ totals.bytes += bytes
217
+ } else if (kind === 'unresolved') {
218
+ totals.unresolved++
219
+ } else {
220
+ totals.suppressed++
221
+ }
222
+ }
223
+
224
+ return { items, totals }
225
+ }