@a9i5k4/dsh-auto-memory 2.5.3 → 3.0.1

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.
Files changed (167) hide show
  1. package/README.md +189 -7
  2. package/README.zh-CN.md +189 -7
  3. package/docs/CONTRIBUTORS.html +471 -0
  4. package/docs/FRONTEND-CO-CREATION.md +191 -0
  5. package/docs/GM53-HOMEPAGE-PROMPT.md +323 -0
  6. package/docs/HANDOFF-CRITERIA.md +92 -0
  7. package/docs/HOMEPAGE-CONTENT-FOR-GM53.md +299 -0
  8. package/docs/INTEGRATION-ANALYSIS.md +350 -348
  9. package/docs/PROMO-PROMPT-3.0.md +100 -0
  10. package/docs/USER-GUIDE.en.md +58 -3
  11. package/docs/USER-GUIDE.zh-CN.md +59 -4
  12. package/docs/WHITEPAPER.md +207 -0
  13. package/docs/internal/ACCEPT-35-LIVE.md +143 -0
  14. package/docs/internal/ACCEPTANCE-20260914.md +90 -0
  15. package/docs/internal/ARCH-REVIEW-BRIEF.md +411 -0
  16. package/docs/internal/ARCH-REVIEW-REQUEST.md +201 -0
  17. package/docs/internal/ARCH-REVIEW-ROUND2.md +169 -0
  18. package/docs/internal/ARCH-REVIEW-ROUND3.md +206 -0
  19. package/docs/internal/ARCHITECTURE-FOR-ZCODE-20260920.md +397 -0
  20. package/docs/internal/ART-DIRECTION-DEEPSEEK-20260920.md +351 -0
  21. package/docs/internal/ART-DIRECTION-WIREFRAME.md +191 -181
  22. package/docs/internal/ART-DIRECTION-WIREFRAME.md.bak-superseded +181 -0
  23. package/docs/internal/AUDIT-WB-GRAPH-FULL-20260916.md +314 -0
  24. package/docs/internal/BATTLE-PLAN-20260917.md +871 -0
  25. package/docs/internal/CONCURRENCY-INVESTIGATION-20260917.md +192 -0
  26. package/docs/internal/CROSS-SESSION-SEARCH-PATH-DECISION.md +72 -0
  27. package/docs/internal/CROSS-SESSION-SEARCH-RESEARCH.md +131 -0
  28. package/docs/internal/DECISIONS-20260914-SESSION.md +269 -0
  29. package/docs/internal/DESIGN-P1-STATE-COMMIT-20260915.md +219 -0
  30. package/docs/internal/DIRECTION-CHECK-WB-GRAPH-20260916.md +132 -0
  31. package/docs/internal/FEATURE-INVENTORY.md +531 -0
  32. package/docs/internal/FEEDBACK-TO-DSHAPI-RELAY.md +13 -0
  33. package/docs/internal/G-SERIES-EXECUTION-20260917.md +248 -0
  34. package/docs/internal/G3-DESIGN-20260918.md +82 -0
  35. package/docs/internal/G3-DISK-FORMAT-GAP-20260919.md +92 -0
  36. package/docs/internal/GH-DISCUSSION-5732-COMMENT.md +74 -0
  37. package/docs/internal/GPT-ACCEPTANCE-PROMPT-20260916.md +352 -0
  38. package/docs/internal/GPT-REVIEW-PROMPT.md +216 -0
  39. package/docs/internal/GROUP-WEBHOOK-SETUP.md +33 -0
  40. package/docs/internal/HANDOFF-TO-ZCODE-20260920.md +309 -0
  41. package/docs/internal/HERMES-DATA-VERIFICATION-20260919.md +120 -0
  42. package/docs/internal/HERMES-LEGACY-STATUS-20260919.md +74 -0
  43. package/docs/internal/ISSUE-55-58-VERIFICATION-20260918.md +175 -0
  44. package/docs/internal/ISSUE10-FIX-EXECUTION-20260919.md +389 -0
  45. package/docs/internal/ISSUE10-PLAN-20260919.md +254 -0
  46. package/docs/internal/ISSUE10B-FORENSICS-20260919.md +468 -0
  47. package/docs/internal/ISSUE9-PURGE-AND-R1-PLAIN-20260919.md +150 -0
  48. package/docs/internal/ISSUE9-RESIDUAL-FORENSICS-20260919.md +114 -0
  49. package/docs/internal/KICKOFF-P0.md +254 -0
  50. package/docs/internal/LESSON-TO-CANDIDATE-STATUS-20260919.md +79 -0
  51. package/docs/internal/MASTER-PLAN-3.0.md +411 -0
  52. package/docs/internal/MEMORY-GOVERNANCE-20260917.md +309 -0
  53. package/docs/internal/MEMORY-MUTATION-AND-INDEX-DESIGN.md +85 -0
  54. package/docs/internal/MERGE-CONFLICT-SCAN-20260914.md +222 -0
  55. package/docs/internal/PENDING-FIXES-20260916.md +289 -0
  56. package/docs/internal/PRE-FRONTEND-CHECKLIST-20260919.md +705 -0
  57. package/docs/internal/PRE-FRONTEND-CHECKLIST-20260919.md.bak-s10 +649 -0
  58. package/docs/internal/PROCEDURAL-MEMORY-AND-APPROVAL-DESIGN-20260918.md +225 -0
  59. package/docs/internal/PROGRESS-20260917.md +93 -0
  60. package/docs/internal/PROMPT-GAP-AUDIT-20260920.md +128 -0
  61. package/docs/internal/R1-DEGRADE-AUDIT-20260918.md +163 -0
  62. package/docs/internal/R1-READABILITY-FORENSICS-20260919.md +127 -0
  63. package/docs/internal/R2-EVIDENCE-DEEP-AUDIT-20260918.md +140 -0
  64. package/docs/internal/R3-DEGRADE-LEDGER-DESIGN-20260918.md +138 -0
  65. package/docs/internal/R4-RECALL-QUOTA-PLAN-20260918.md +218 -0
  66. package/docs/internal/RAG-KARPATHY-PROGRAM.md +229 -0
  67. package/docs/internal/REPORT-P0-NIGHTLY.md +212 -0
  68. package/docs/internal/REPORT-P5-ACCEPTANCE.md +31 -0
  69. package/docs/internal/REPORT-WB-GRAPH-NIGHTLY.md +153 -0
  70. package/docs/internal/RESUME-20260918.md +171 -0
  71. package/docs/internal/RESUME-20260919.md +104 -0
  72. package/docs/internal/REVIEW-WB-GRAPH-SELF.md +81 -0
  73. package/docs/internal/RHINELAB-TO-DEEPSEEK-FEASIBILITY.md +198 -0
  74. package/docs/internal/ROADMAP-20260917-WEEK.md +439 -0
  75. package/docs/internal/ROADMAP.md +106 -0
  76. package/docs/internal/RUN-P0-NIGHTLY.md +227 -0
  77. package/docs/internal/S10-CONSTRUCTION-HANDOFF-20260917.md +185 -0
  78. package/docs/internal/S10-GAP-INVENTORY-20260917.md +239 -0
  79. package/docs/internal/S10-GAPS-PLAIN-20260917.md +125 -0
  80. package/docs/internal/SEMANTIC-ARCHITECTURE-SPEC.md +360 -0
  81. package/docs/internal/SESSION-FILE-REPAIR-PROTOCOL.md +90 -0
  82. package/docs/internal/T6-EXECUTION-20260920.md +130 -0
  83. package/docs/internal/TELEMETRY-EFFECT-REPORT-DESIGN-20260918.md +146 -0
  84. package/docs/internal/THESIS-GAP-ANALYSIS-20260918.md +89 -0
  85. package/docs/internal/THESIS-OUTLINE-20260918.md +147 -0
  86. package/docs/internal/THREE-LAYER-CONTRACT.md +219 -0
  87. package/docs/internal/TODO-BACKLOG.md +263 -142
  88. package/docs/internal/TODO-GRAPH.html +715 -0
  89. package/docs/internal/TODO-GRAPH.html.bak-20260914-v2 +493 -0
  90. package/docs/internal/TODO-GRAPH.html.bak-20260915-alsfix +710 -0
  91. package/docs/internal/TODO-GRAPH.html.bak-20260915-p1 +710 -0
  92. package/docs/internal/TODO-GRAPH.html.bak-20260915-p6a-rev +703 -0
  93. package/docs/internal/TODO-GRAPH.html.bak-20260915-wshint +710 -0
  94. package/docs/internal/TODO-GRAPH.html.bak-20260916-batch +715 -0
  95. package/docs/internal/UPSTREAM-ISSUE-PR-TRIAGE-20260919.md +297 -0
  96. package/docs/internal/UPSTREAM-ISSUES-3RD-AUDIT-20260920.md +104 -0
  97. package/docs/internal/WB-FORMAT-CONVENTION.md +112 -0
  98. package/docs/internal/WB-GRAPH-DECISIONS-20260914.md +71 -0
  99. package/docs/internal/reviews/CLAIM-VERIFICATION-20260914.md +56 -0
  100. package/docs/internal/reviews/PLAN-gpt6astra-round2-20260914.md +787 -0
  101. package/docs/internal/reviews/REVIEW-gpt6astra-20260914.md +112 -0
  102. package/docs/internal/reviews/ROUND3-REVIEW-INTEGRATION-20260914.md +230 -0
  103. package/docs/prompts/M8-3-enable-verify.md +49 -49
  104. package/docs/screenshots/promo/promo-0-banner-v3.png +0 -0
  105. package/lib/acceptance.js +71 -0
  106. package/lib/activation-host.js +153 -18
  107. package/lib/activation-inbox.js +25 -7
  108. package/lib/board-mode.js +30 -0
  109. package/lib/client.js +1758 -90
  110. package/lib/config-io.js +156 -0
  111. package/lib/context-bridge.js +5 -2
  112. package/lib/context-host.js +86 -15
  113. package/lib/degrade.js +385 -0
  114. package/lib/dsh-home.js +143 -0
  115. package/lib/engine-identity.js +149 -0
  116. package/lib/engine-switch.js +247 -0
  117. package/lib/episodic-store.js +63 -12
  118. package/lib/evidence-store.js +10 -3
  119. package/lib/fact-store.js +22 -3
  120. package/lib/fs-retry.js +46 -0
  121. package/lib/index-sync.js +13 -1
  122. package/lib/index.js +3446 -263
  123. package/lib/intent-clean-safe.js +258 -0
  124. package/lib/intent-clean.js +12 -16
  125. package/lib/l0-extract.js +478 -149
  126. package/lib/l0-index-sync.js +195 -0
  127. package/lib/l0-index.js +349 -239
  128. package/lib/ledger-criteria.js +142 -0
  129. package/lib/m4-corpus.js +8 -2
  130. package/lib/m7-index-sync-host.js +73 -5
  131. package/lib/m7-wire.js +3 -3
  132. package/lib/memory-anchor.js +56 -1
  133. package/lib/memory-envelope.js +257 -0
  134. package/lib/memory-hub.js +138 -13
  135. package/lib/memory-index.js +4 -2
  136. package/lib/memory-mutation.js +246 -0
  137. package/lib/memory-writer.js +204 -24
  138. package/lib/note-status-apply.js +118 -0
  139. package/lib/note-status.js +196 -0
  140. package/lib/procedure-observation.js +48 -0
  141. package/lib/procedure-store.js +118 -20
  142. package/lib/python-setup.js +1 -1
  143. package/lib/python-sidecar-client.js +29 -3
  144. package/lib/recall-fusion.js +83 -12
  145. package/lib/rerank-host.js +160 -0
  146. package/lib/rules-edit.js +159 -0
  147. package/lib/rules-layer.js +261 -0
  148. package/lib/semantic-decide.js +41 -8
  149. package/lib/semantic-js.js +66 -6
  150. package/lib/shadow-host.js +3 -5
  151. package/lib/shadow-retrieval.js +3 -3
  152. package/lib/skill-export-host.js +153 -0
  153. package/lib/skill-export.js +239 -0
  154. package/lib/state-commit.js +245 -0
  155. package/lib/storage-manage.js +6 -0
  156. package/lib/subagent-gc.js +4 -8
  157. package/lib/temporal-parse.js +191 -159
  158. package/lib/tier-layer-inject.js +650 -0
  159. package/lib/tier0-catalog.js +735 -0
  160. package/lib/water-window.js +263 -186
  161. package/lib/wb-contract.js +691 -0
  162. package/lib/wb-sidecar.js +890 -0
  163. package/lib/ws-overview-rank.js +2 -2
  164. package/package.json +1 -1
  165. package/python/m7_embedding_v1.py +5 -5
  166. package/python/worker_semantic_v1.py +17 -6
  167. package/python/worker_v1.py +38 -4
@@ -0,0 +1,650 @@
1
+ /**
2
+ * 三层注入装配(tier_layer_inject_v1)—— 契约 C5
3
+ * (`docs/internal/THREE-LAYER-CONTRACT.md` §2.1/§5、`SEMANTIC-ARCHITECTURE-SPEC.md` S5/S9)。
4
+ *
5
+ * 2026-09-14 建立。职责 = **把三层按闸门装成一段可注入文本**:
6
+ * - **Tier-0 常驻目录**(每轮都进,不依赖命中):调 `lib/tier0-catalog.js` 的目录生成器
7
+ * (`buildTier0CatalogFromTextPre`,配额开启),≤ `B0`=800 token(`max(ceil(chars/2), repo口径)`);
8
+ * - **闸门下探 Tier-1**:仅当本轮有语义命中(或问题含深挖语义)时,给 top-`K`=8 条 L0 摘要,
9
+ * 每条 ≤ `L1`=140 字符;未命中只给目录层(省 token 的关键);
10
+ * - **按需下探 Tier-2**:问题带"要证据"语义(原文/逐字/行号/命令/复现)时,给命中块原文,
11
+ * 单块 ≤ `B2`=2400 字符,超长显式标注"已截断,全文 N 字符"。
12
+ *
13
+ * **I7 显式降级标注**(本模块的存在理由之一):任何一层缺数据都必须在这段文本里出现**可读的
14
+ * 降级标记**——索引未就绪 / 语义臂不可用 / 某层为空 / 某层有候选但被裁光 / 来源读不到 / 目录为空,
15
+ * 逐条写成 `[降级] …`,**绝不允许静默丢弃**。层账 `[层账]` 行把五层的"候选/进目录/裁剪"逐层摆出来。
16
+ *
17
+ * **S9 合规**:本模块零 IO、纯函数、不调大模型;`composeTieredInjectionPre` 同输入同输出。
18
+ * UTF-8 无 BOM。(唯一的模块依赖是 `l0-extract.js` 的 `isCurrentPre`,见下方 I5 一节;
19
+ * 那也是个零 import 的纯模块,不引入 IO。)
20
+ *
21
+ * 为什么不在 `lib/tier0-catalog.js` 里做这段渲染:那个模块是"目录生成器"(C4,纯文本内核 +
22
+ * 一条 IO 入口),本模块是"注入装配"(C5,层与闸门)。分开后 C4 的 33 条回归不受影响。
23
+ *
24
+ * I5 状态过滤(2026-09-14 P0 / C8 修复):注入侧**必须**挡下非 current 条目,见 `filterCurrentHitsPre`。
25
+ */
26
+ import {
27
+ buildTier0CatalogFromTextPre,
28
+ estimateTokensPre,
29
+ TIER0_DEFAULTS,
30
+ } from './tier0-catalog.js'
31
+ // 状态判定的**唯一权威**在检索侧(`lib/l0-extract.js:124 isCurrentPre`)。
32
+ // 这里刻意 **import 而不是复制一份**:复制会让「检索侧放行、注入侧挡下」这种漂移成为可能,
33
+ // 而本模块的 S9 检查只禁网络/LLM/子进程/await,不禁内部纯模块引用(该模块本身零 import)。
34
+ import { isCurrentPre } from './l0-extract.js'
35
+
36
+ export const TIER_INJECT_VERSION = 'tier_layer_inject_v1'
37
+
38
+ /** 契约预算(冻结;变更须同步 `THREE-LAYER-CONTRACT.md` §4.3)。 */
39
+ export const TIER_BUDGET_V1 = Object.freeze({
40
+ B0: 800,
41
+ L1: 140,
42
+ K: 8,
43
+ B2: 2400,
44
+ projectRatio: 0.6,
45
+ floorRatio: 0.1,
46
+ maxTier2Blocks: 2,
47
+ })
48
+
49
+ /** 段落标记(可读、可 grep;不改既有 Reference Tail 的固定边界行)。 */
50
+ export const TIER_MARK_V1 = Object.freeze({
51
+ degrade: '[降级]',
52
+ gate: '[闸门]',
53
+ account: '[层账]',
54
+ quota: '[配额]',
55
+ })
56
+
57
+ /** 五层展示顺序(与契约 §2 优先级一致:project > whiteboard > user > reflection > log)。 */
58
+ export const TIER_LAYER_ORDER_V1 = Object.freeze(['project', 'whiteboard', 'user', 'reflection', 'log'])
59
+
60
+ /** 深挖语义(决定"要不要下探 Tier-1"):含这些词说明用户在问"怎么/为什么/具体"。 */
61
+ const DEEP_INTENT_RE = /为什么|怎么|如何|具体|复现|细节|原理|根因|哪个文件|在哪/
62
+ /** 要证据语义(决定"要不要下探 Tier-2"):含这些词说明需要原文/行号/命令。 */
63
+ const EVIDENCE_INTENT_RE = /原文|逐字|引用|行号|命令|复现步骤|具体怎么|具体是|怎么写|怎么改|报错|堆栈|traceback/
64
+
65
+ const clean = (s) => String(s == null ? '' : s).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '').trim()
66
+ const clip = (s, n) => {
67
+ const t = clean(s)
68
+ if (t.length <= n) return t
69
+ return t.slice(0, Math.max(1, n - 1)) + '…'
70
+ }
71
+ const stamp = (v) => {
72
+ const n = Number(v)
73
+ return Number.isFinite(n) ? n.toFixed(2) : null
74
+ }
75
+
76
+ /**
77
+ * I5 状态过滤 —— **注入侧**挡下非 `current` 条目(2026-09-14 · P0 / 核实表 C8)。
78
+ *
79
+ * **为什么必须补在注入侧**:契约不变式 I5(`THREE-LAYER-CONTRACT.md:183`)要求「非 `current`
80
+ * 的条目在**检索结果与注入内容两处**都被过滤」。检索侧(`lib/index.js` 的 l0Corpus 过滤与语义臂)
81
+ * 早已过滤,注入侧却一直没做。实测证据(不是推测):`node tools/_redproof/red-proof-phase0-t01.mjs`
82
+ * 在修复前 **0 通过 / 4 报红** —— 4 条候选(2 current + 1 superseded + 1 retracted)**全部**进了
83
+ * Tier-1 注入文本与最终文本,Tier-1 条数 = 4 而非 2。
84
+ *
85
+ * **旧实现错在哪**:`buildTier1SectionPre` 只把 `h.status` 当**展示字段**渲染进 `- [layer/status]`
86
+ * 前缀(`[user/superseded]`),却从不用它做准入判断 —— 于是"知道自己是被撤回的记忆,却照样注入"。
87
+ *
88
+ * **判定口径**:直接复用检索侧的 `isCurrentPre`(status 缺失/空 ⇒ 放行=旧记录兼容;
89
+ * `'current'` ⇒ 放行;**其余任何值含未知值 ⇒ 挡下**,未知状态 fail closed)。
90
+ *
91
+ * **挡下不静默**(I7):返回 `droppedIds` 供调用方渲染 `[降级]` 行,绝不无声丢弃。
92
+ *
93
+ * @param {Array} hits 候选命中(`{memoryId,score,excerpt,layer,status}`)
94
+ * @returns {{kept:Array, dropped:Array, total:number, current:number, droppedCount:number, droppedIds:string[]}}
95
+ */
96
+ export function filterCurrentHitsPre(hits) {
97
+ const list = Array.isArray(hits) ? hits.filter(Boolean) : []
98
+ const kept = []
99
+ const dropped = []
100
+ for (const h of list) {
101
+ if (isCurrentPre(h)) kept.push(h)
102
+ else dropped.push(h)
103
+ }
104
+ return {
105
+ kept,
106
+ dropped,
107
+ total: list.length,
108
+ current: kept.length,
109
+ droppedCount: dropped.length,
110
+ droppedIds: dropped.map((h) => clean(h && h.memoryId)).filter(Boolean),
111
+ }
112
+ }
113
+
114
+ /** 状态判定别名(导出给测试与调用方做跨模块一致性对照;实体是 `l0-extract.js:isCurrentPre`)。 */
115
+ export const isCurrentStatusPre = isCurrentPre
116
+
117
+ /**
118
+ * 注入来源排除(群反馈第 4 条 / P0-④d「可排除来源」)。
119
+ *
120
+ * **要解决的问题**:记忆一旦被判定为错的(某条白板段、某天日志段),它仍会**每轮**被
121
+ * Tier-0 目录或 Tier-1 下探重新灌进上下文 —— 即群反馈原话「避免因为记忆问题导致结果一路走错」。
122
+ * `supersede` 只能覆盖"被新版本替代",覆盖不了"这条来源整个不可信、我不想再看到它"。
123
+ *
124
+ * **匹配口径**(写死、可测,只支持精确/前缀,不做模糊猜测):
125
+ * - 精确路径:`D:\ws\.dsh-memory\MEMORY.md` 命中该文件;
126
+ * - 目录前缀:`D:\ws\.dsh-memory\archive\`(以 `/` 或 `\` 结尾)命中其下全部文件;
127
+ * - 记忆 id:`mem_<32hex>` 命中该条命中项;
128
+ * - 层名:`log` / `whiteboard` / `project` / `user` / `reflection` 命中整层。
129
+ * 路径比较在 Windows 下大小写不敏感、且统一分隔符(与 `memoryWriteLockKey` 同口径)。
130
+ *
131
+ * **挡下不静默**(沿用 I7):返回 `dropped`/`matchedPatterns`,由调用方渲染 `[降级]` 行。
132
+ *
133
+ * @param {Array} hits 候选命中(`{memoryId,excerpt,layer,status,path,sourceRef}`)
134
+ * @param {Array<string>} patterns 用户配置的排除项
135
+ * @returns {{kept:Array, dropped:Array, total:number, droppedCount:number, matchedPatterns:string[]}}
136
+ */
137
+ export function filterExcludedSourcesPre(hits, patterns) {
138
+ const list = Array.isArray(hits) ? hits.filter(Boolean) : []
139
+ const pats = (Array.isArray(patterns) ? patterns : [])
140
+ .map((p) => clean(p)).filter(Boolean)
141
+ if (!pats.length) {
142
+ return { kept: list, dropped: [], total: list.length, droppedCount: 0, matchedPatterns: [] }
143
+ }
144
+ const normPath = (s) => String(s == null ? '' : s).replace(/[\\/]+/g, '/').toLowerCase()
145
+ const isDirPat = (p) => /[\\/]$/.test(p)
146
+ const layers = new Set(TIER_LAYER_ORDER_V1)
147
+ const matched = new Set()
148
+ const kept = []
149
+ const dropped = []
150
+ for (const h of list) {
151
+ const id = clean(h && h.memoryId)
152
+ const layer = clean(h && h.layer)
153
+ const candPaths = [h && h.path, h && h.sourceRef, h && h.label]
154
+ .map((x) => String(x == null ? '' : x)).filter(Boolean)
155
+ let hit = null
156
+ for (const raw of pats) {
157
+ const p = raw
158
+ // ① 记忆 id(精确)
159
+ if (/^mem_[0-9a-f]{32}$/i.test(p)) {
160
+ if (id && id.toLowerCase() === p.toLowerCase()) { hit = p; break }
161
+ continue
162
+ }
163
+ // ② 层名(整层排除;仅当该 token 恰好是一个合法层名时才按层解释,避免误吞路径)
164
+ if (layers.has(p) && !/[\\/:]/.test(p)) {
165
+ if (layer && layer.toLowerCase() === p.toLowerCase()) { hit = p; break }
166
+ continue
167
+ }
168
+ // ③/④ 路径:目录前缀 或 精确文件
169
+ const np = normPath(p)
170
+ if (isDirPat(p)) {
171
+ if (candPaths.some((c) => normPath(c).startsWith(np))) { hit = p; break }
172
+ } else if (candPaths.some((c) => normPath(c) === np)) { hit = p; break }
173
+ }
174
+ if (hit) { dropped.push(h); matched.add(hit) } else kept.push(h)
175
+ }
176
+ return {
177
+ kept, dropped,
178
+ total: list.length,
179
+ droppedCount: dropped.length,
180
+ matchedPatterns: Array.from(matched),
181
+ }
182
+ }
183
+
184
+ /**
185
+ * T0-2 复用判定的原因码 → 可读中文(2026-09-14 · P0)。
186
+ *
187
+ * **为什么需要它**:`lib/index.js` 原实现只用
188
+ * `Date.now() - gh.at < 30*60000` 判"本轮命中投影还能不能用"——**只查时间、不查版本**。
189
+ * 于是「A 快照产生的候选,到 B 快照才准备输出」时会照样被复用(A 的正文进了 B 的注入),
190
+ * 这正是 T0-2 要挡的场景(契约 I6:三层必须来自同一份快照,混版视为错误)。
191
+ */
192
+ export const TIER_HITS_REUSE_REASONS_V1 = Object.freeze({
193
+ ok: '投影仍属当前快照',
194
+ 'no-projection': '本轮无命中投影(尚未发生激活投递)',
195
+ 'stale-time': '投影超出新鲜度窗口(仅时间新鲜不足以保证版本一致)',
196
+ 'identity-unknown': '缺少会话/工作区身份,无法证明投影归属(fail closed)',
197
+ 'session-mismatch': '投影来自其它会话(A/B 串线风险)',
198
+ 'workspace-mismatch': '投影来自其它工作区',
199
+ 'version-unknown': '投影未携带版本字段或当前版本不可得,无法证明同版(fail closed)',
200
+ 'context-version-changed': '上下文版本已变化(候选产生于旧上下文)',
201
+ 'miv-changed': '记忆索引版本已变化(候选产生于旧快照)',
202
+ 'observation-mismatch': '观测身份不匹配(不是同一次观测产生的候选)',
203
+ })
204
+
205
+ /** 原因码 → 可读中文(未知码原样返回,便于排障时不吞信息)。 */
206
+ export function describeReuseReasonPre(code) {
207
+ const k = clean(code)
208
+ return TIER_HITS_REUSE_REASONS_V1[k] || k || '未知原因'
209
+ }
210
+
211
+ /**
212
+ * T0-2:判断 `_tierGateHits` 投影能否在本轮复用 —— **必须比对版本,不能只看时间**。
213
+ *
214
+ * **旧实现的错**(`lib/index.js:3894`,改为调用本函数):
215
+ * ```js
216
+ * const fresh = Date.now() - gh.at < 30*60000
217
+ * const sameSession = !agentSessionId || !gh || !gh.sessionId || gh.sessionId === agentSessionId
218
+ * ```
219
+ * 两个缺陷:① 只查时间;② 身份缺失时**fail open**(`!agentSessionId` 直接算通过)
220
+ * ⇒ 拿不到会话身份时会把别的会话的候选当本轮的用。本函数一律 **fail closed**:
221
+ * **凡不能证明是同一份快照、同一次观测、同一个会话,就不复用**。
222
+ *
223
+ * 不复用的后果只是"本轮不下探 Tier-1"(省 token 的安全侧),绝不是静默错误。
224
+ * 调用方必须把 `reason` 渲染成可见的降级行(I7:降级不静默)。
225
+ *
226
+ * @param {object} input
227
+ * @param {object|null} input.projection `engine._tierGateHits`(可能为 null 或旧形状)
228
+ * @param {number} input.now 当前时间戳
229
+ * @param {string} input.sessionId 当前 agent 的会话 id(取不到则传空串)
230
+ * @param {number|undefined} input.contextVersion 当前 runtime 的 contextVersion
231
+ * @param {string|null|undefined} input.miv 当前语料的 memoryIndexVersion(取不到传 null)
232
+ * @param {number} [input.maxAgeMs] 新鲜度窗口(默认 30 分钟;时间门仍保留,但不再是唯一门)
233
+ * @returns {{reuse:boolean, reason:string, reasonText:string, question:string, hits:Array,
234
+ * snapshot:{contextVersion:*, miv:*, observationId:*, requestKey:*, at:*}}}
235
+ */
236
+ export function selectReusableTierHitsPre(input = {}) {
237
+ const o = input && typeof input === 'object' ? input : {}
238
+ const gh = o.projection && typeof o.projection === 'object' ? o.projection : null
239
+ const now = Number.isFinite(Number(o.now)) ? Number(o.now) : Date.now()
240
+ const maxAgeMs = Number(o.maxAgeMs) > 0 ? Number(o.maxAgeMs) : 30 * 60000
241
+ const no = (reason) => ({
242
+ reuse: false, reason, reasonText: describeReuseReasonPre(reason), question: '', hits: [],
243
+ snapshot: {
244
+ contextVersion: gh ? gh.contextVersion : undefined, miv: gh ? gh.miv : undefined,
245
+ observationId: gh ? gh.observationId : undefined, requestKey: gh ? gh.requestKey : undefined,
246
+ at: gh ? gh.at : undefined,
247
+ },
248
+ })
249
+
250
+ if (!gh) return no('no-projection')
251
+ // ① 时间门(保留:它挡的是"旧投影留太久",但**它单独不足以复用**)
252
+ const at = Number(gh.at)
253
+ if (!Number.isFinite(at) || now - at >= maxAgeMs) return no('stale-time')
254
+ // ② 身份门:任一侧缺身份 ⇒ 不能证明归属 ⇒ 不复用(旧实现在这里 fail open)
255
+ const curSession = clean(o.sessionId)
256
+ const projSession = clean(gh.sessionId)
257
+ if (!curSession || !projSession) return no('identity-unknown')
258
+ if (projSession !== curSession) return no('session-mismatch')
259
+ const curWs = clean(o.workspaceKey)
260
+ const projWs = clean(gh.workspaceKey)
261
+ if (curWs && projWs && curWs !== projWs) return no('workspace-mismatch')
262
+ // ③ 版本门(T0-2 的核心):contextVersion 必须两侧可得且相等
263
+ const curCv = o.contextVersion
264
+ const projCv = gh.contextVersion
265
+ if (!Number.isInteger(Number(projCv)) || !Number.isInteger(Number(curCv))) return no('version-unknown')
266
+ if (Number(curCv) !== Number(projCv)) return no('context-version-changed')
267
+ // ④ 快照门:miv 必须两侧可得且相等(拿不到当前 miv 时 fail closed,不猜)
268
+ const curMiv = clean(o.miv)
269
+ const projMiv = clean(gh.miv)
270
+ if (!curMiv || !projMiv) return no('version-unknown')
271
+ if (curMiv !== projMiv) return no('miv-changed')
272
+ // ⑤ 观测门:投影必须能指出是哪一次观测产生的(T0-2 要求携带 observationId)
273
+ const projObs = clean(gh.observationId)
274
+ if (!projObs) return no('observation-mismatch')
275
+ const expectObs = clean(o.observationId)
276
+ if (expectObs && expectObs !== projObs) return no('observation-mismatch')
277
+
278
+ const hits = Array.isArray(gh.hits) ? gh.hits.filter(Boolean) : []
279
+ if (!hits.length) return no('no-projection')
280
+ return {
281
+ reuse: true, reason: 'ok', reasonText: describeReuseReasonPre('ok'),
282
+ question: clean(gh.question), hits,
283
+ snapshot: { contextVersion: projCv, miv: projMiv, observationId: projObs, requestKey: clean(gh.requestKey), at },
284
+ }
285
+ }
286
+
287
+ /** 跳过原因 → 可读中文(I7:降级行必须人能读懂,不能只写机器枚举)。 */
288
+ export const SKIP_REASON_TEXT_V1 = Object.freeze({
289
+ 'missing-path': '路径未提供',
290
+ 'not-found': '文件不存在',
291
+ 'not-a-file': '路径不是文件',
292
+ 'too-large': '文件超出单文件上限',
293
+ empty: '文件为空',
294
+ 'read-error': '读取失败',
295
+ 'stat-error': '无法访问',
296
+ 'unknown-layer': '层名非法',
297
+ 'no-items': '解析不出目录条目',
298
+ })
299
+
300
+ /** 原因码 → 可读中文。 */
301
+ export function describeReasonPre(reason) {
302
+ const key = clean(reason)
303
+ if (!key) return '未知原因'
304
+ return SKIP_REASON_TEXT_V1[key] || key
305
+ }
306
+
307
+ /**
308
+ * 层账行(每层候选/进目录/裁剪;`reflection 0(无数据)` 这类"没有就说没有"是 I7 的落点)。
309
+ * @param {Record<string,{candidates:number,picked:number,dropped:number,tokens:number,cap:number|null,floor:number}>} perLayer
310
+ * @param {string[]} [order]
311
+ */
312
+ export function tierLayerAccountLinePre(perLayer, order) {
313
+ const layers = Array.isArray(order) && order.length ? order : TIER_LAYER_ORDER_V1
314
+ const segs = layers.map((layer) => {
315
+ const m = (perLayer && perLayer[layer]) || null
316
+ if (!m) return layer + ' ?'
317
+ if (!m.candidates) return layer + ' 0(无数据)'
318
+ return layer + ' ' + m.picked + '/' + m.candidates + (m.dropped ? '(裁' + m.dropped + ')' : '')
319
+ })
320
+ const dropped = layers.reduce((a, l) => a + (((perLayer && perLayer[l]) || {}).dropped || 0), 0)
321
+ return TIER_MARK_V1.account + ' ' + segs.join(' · ') + (dropped ? ' · 合计裁剪 ' + dropped + ' 条' : '')
322
+ }
323
+
324
+ /**
325
+ * 收集降级项(I7)。返回 `[{code, layer?, text}]`,`text` 以 `[降级]` 开头,可直接进注入文本。
326
+ *
327
+ * 覆盖:目录为空 / 来源读不到或空 / 某层无数据 / 某层有候选却被裁光 / 引用索引未就绪 /
328
+ * 语义臂不可用。**每一类都必须显式落一条**,否则调用方测试会红(见 smoke-test-c5-*)。
329
+ */
330
+ export function collectDegradationsPre(input = {}) {
331
+ const o = input && typeof input === 'object' ? input : {}
332
+ const catalog = o.catalog || null
333
+ const out = []
334
+ const mark = TIER_MARK_V1.degrade + ' '
335
+
336
+ if (!catalog) {
337
+ out.push({ code: 'catalog-missing', text: mark + 'Tier-0 目录生成器未返回结果(本轮无目录层)' })
338
+ } else {
339
+ if (!Array.isArray(catalog.items) || !catalog.items.length) {
340
+ const n = Array.isArray(catalog.skipped) ? catalog.skipped.length : 0
341
+ out.push({ code: 'tier0-empty', text: mark + 'Tier-0 目录为空' + (n ? '(跳过来源 ' + n + ' 个)' : '(无可渲染条目)') })
342
+ }
343
+ for (const s of Array.isArray(catalog.skipped) ? catalog.skipped : []) {
344
+ out.push({
345
+ code: 'source-unavailable',
346
+ layer: clean(s && s.layer),
347
+ text: mark + clean(s && s.layer) + ' 层来源不可用 · ' + describeReasonPre(s && s.reason)
348
+ + (clean(s && s.path) ? ' · ' + clean(s.path) : '') + '(本轮该来源未进目录)',
349
+ })
350
+ }
351
+ const quota = catalog.quota || null
352
+ if (quota) {
353
+ for (const layer of Array.isArray(quota.degradedLayers) ? quota.degradedLayers : []) {
354
+ const m = (quota.perLayer || {})[layer] || {}
355
+ out.push({
356
+ code: 'layer-dropped',
357
+ layer,
358
+ text: mark + layer + ' 层有 ' + (m.candidates || 0) + ' 条候选但 0 条进目录(配额/预算裁剪);需要时用 memory_search 下探该层',
359
+ })
360
+ }
361
+ // I7 明确要求"层为空"也要显式标注(不能只在层账里露出一个 0 就算说过)。
362
+ const emptyLayers = Array.isArray(quota.emptyLayers) ? quota.emptyLayers : []
363
+ if (emptyLayers.length) {
364
+ out.push({
365
+ code: 'layers-empty',
366
+ text: mark + '空层:' + emptyLayers.join('、') + '(本轮该层无数据可注入,非静默丢弃)',
367
+ })
368
+ }
369
+ }
370
+ }
371
+
372
+ if (o.indexNotReady) {
373
+ const r = clean(typeof o.indexNotReady === 'string' ? o.indexNotReady : o.indexNotReady.reason) || 'unknown'
374
+ out.push({
375
+ code: 'index-not-ready',
376
+ text: mark + '语义索引未就绪(' + r + ')· 本轮降级为词法命中 + 常驻目录,未静默丢弃注入',
377
+ })
378
+ }
379
+ if (o.semanticArm === false || o.semanticArm === 'unavailable') {
380
+ out.push({ code: 'semantic-arm', text: mark + '语义臂不可用 · 本轮仅词法命中' })
381
+ }
382
+ for (const extra of Array.isArray(o.extraDegradations) ? o.extraDegradations : []) {
383
+ const t = clean(extra)
384
+ if (t) out.push({ code: 'extra', text: mark + t })
385
+ }
386
+ return out
387
+ }
388
+
389
+ /**
390
+ * 闸门(契约 §5):默认只给 Tier-0;命中或深挖语义 → Tier-1;要证据 → Tier-2。
391
+ * @param {{hits?:Array, question?:string}} input
392
+ * @returns {{level:'tier0'|'tier1'|'tier2', hitCount:number, reasons:string[], deep:boolean, evidence:boolean}}
393
+ */
394
+ export function decideTierGatePre(input = {}) {
395
+ const hits = Array.isArray(input && input.hits) ? input.hits.filter(Boolean) : []
396
+ const q = clean(input && input.question)
397
+ const deep = DEEP_INTENT_RE.test(q)
398
+ const evidence = EVIDENCE_INTENT_RE.test(q)
399
+ const reasons = []
400
+ if (!hits.length) {
401
+ reasons.push('no-hit')
402
+ if (deep) reasons.push('deep-intent')
403
+ if (evidence) reasons.push('evidence-intent')
404
+ // 未命中:即便问题想深挖也没有候选可下探 —— 只给目录层(省 token 的关键路径)。
405
+ return { level: 'tier0', hitCount: 0, reasons, deep, evidence }
406
+ }
407
+ reasons.push('hit=' + hits.length)
408
+ if (deep) reasons.push('deep-intent')
409
+ if (evidence) reasons.push('evidence-intent')
410
+ if (evidence) return { level: 'tier2', hitCount: hits.length, reasons, deep, evidence }
411
+ return { level: 'tier1', hitCount: hits.length, reasons, deep, evidence }
412
+ }
413
+
414
+ /** 取命中项的摘要源(L0 摘要优先;无则退到标题+摘录)。 */
415
+ function hitSummarySourcePre(h) {
416
+ const oneLine = clean(h && (h.oneLine || h.summary || h.title))
417
+ const body = clean(h && (h.excerptFull || h.excerpt || h.text))
418
+ if (oneLine && body && !body.startsWith(oneLine)) return oneLine + ':' + body
419
+ return oneLine || body
420
+ }
421
+
422
+ /**
423
+ * Tier-1 渲染:top-`K` 条(默认 8),每条摘要 ≤ `L1`(默认 140)字符,按分值降序;
424
+ * 超出 K 的部分**显式计数**(不静默截断)。
425
+ */
426
+ export function buildTier1SectionPre(hits, opts = {}) {
427
+ const K = Math.max(1, Number((opts && opts.maxItems) || TIER_BUDGET_V1.K))
428
+ const L1 = Math.max(8, Number((opts && opts.itemChars) || TIER_BUDGET_V1.L1))
429
+ const list = (Array.isArray(hits) ? hits.filter(Boolean) : [])
430
+ .map((h, i) => ({ h, i, s: Number(h.score) }))
431
+ .sort((a, b) => (Number.isFinite(b.s) ? b.s : -1) - (Number.isFinite(a.s) ? a.s : -1) || a.i - b.i)
432
+ const kept = list.slice(0, K)
433
+ const lines = kept.map(({ h }) => {
434
+ const layer = clean(h.layer) || 'log'
435
+ const status = clean(h.status) || 'current'
436
+ const sc = stamp(h.score)
437
+ const id = clean(h.memoryId)
438
+ return '- [' + layer + '/' + status + ']' + (sc ? ' (' + sc + ')' : '') + ' '
439
+ + clip(hitSummarySourcePre(h), L1) + (id ? ' · ' + id : '')
440
+ })
441
+ const overflow = list.length - kept.length
442
+ const head = '[Tier-1 命中摘要 · ' + kept.length + '/' + list.length + ' 条 · 每条 ≤' + L1 + ' 字 · K=' + K + ']'
443
+ const tail = overflow > 0 ? [TIER_MARK_V1.degrade + ' Tier-1 另 ' + overflow + ' 条命中未展开(K=' + K + ' 上限);需要时用 memory_recall 取'] : []
444
+ return {
445
+ text: [head].concat(lines, tail).join('\n'),
446
+ lines,
447
+ count: kept.length,
448
+ overflow,
449
+ truncated: overflow > 0,
450
+ }
451
+ }
452
+
453
+ /**
454
+ * Tier-2 渲染:命中块原文,单块 ≤ `B2`(默认 2400)字符;超长块显式标注"已截断,全文 N 字符"。
455
+ */
456
+ export function buildTier2SectionPre(hits, opts = {}) {
457
+ const B2 = Math.max(80, Number((opts && opts.maxChars) || TIER_BUDGET_V1.B2))
458
+ const maxBlocks = Math.max(1, Number((opts && opts.maxBlocks) || TIER_BUDGET_V1.maxTier2Blocks))
459
+ const list = (Array.isArray(hits) ? hits.filter(Boolean) : [])
460
+ .map((h, i) => ({ h, i, s: Number(h.score) }))
461
+ .sort((a, b) => (Number.isFinite(b.s) ? b.s : -1) - (Number.isFinite(a.s) ? a.s : -1) || a.i - b.i)
462
+ .slice(0, maxBlocks)
463
+ const blocks = []
464
+ for (const { h } of list) {
465
+ const raw = clean(h.excerptFull || h.excerpt || h.text)
466
+ if (!raw) continue
467
+ const layer = clean(h.layer) || 'log'
468
+ const id = clean(h.memoryId)
469
+ const clipped = raw.length > B2
470
+ ? raw.slice(0, Math.max(1, B2 - 24)) + '…(已截断,全文 ' + raw.length + ' 字符)'
471
+ : raw
472
+ blocks.push('- [' + layer + ']' + (id ? ' ' + id : '') + '\n ' + clipped)
473
+ }
474
+ return {
475
+ text: ['[Tier-2 原文块 · ' + blocks.length + ' 块 · 每块 ≤' + B2 + ' 字符]'].concat(blocks).join('\n'),
476
+ count: blocks.length,
477
+ }
478
+ }
479
+
480
+ /**
481
+ * C5 主入口:三层装配。**纯函数**(同输入同输出、零 IO、零 LLM)。
482
+ *
483
+ * @param {object} input
484
+ * @param {Array<{layer:string,text:string,path?:string}>} [input.sources] 已读入内存的语料来源
485
+ * @param {object} [input.catalog] 已建好的 Tier-0 目录结果(给了就不再自建)
486
+ * @param {number} [input.maxTokens] Tier-0 预算(≤ B0=800;默认 800)
487
+ * @param {Array} [input.hits] 当前轮语义命中(`{memoryId,score,excerpt,layer,status}`);
488
+ * **非 current 的条目会在此被 I5 挡下**(见 `filterCurrentHitsPre`),返回体的 `hits` 是过滤账。
489
+ * @param {string} [input.question] 当前轮 query(决定闸门档位)
490
+ * @param {string|{reason:string}} [input.indexNotReady] 索引未就绪原因(I7 降级标注)
491
+ * @param {boolean|'unavailable'} [input.semanticArm] 语义臂可用性(false → 显式降级)
492
+ * @param {boolean} [input.enabled] false → 返回空文本(调用方可完全关闭;不注入空壳)
493
+ * @returns {{text:string, tier0:object|null, gate:object, tier1:object|null, tier2:object|null,
494
+ * degradations:Array, tokens:number, maxTokens:number, degraded:boolean, level:string,
495
+ * hits:{kept:Array,dropped:Array,total:number,current:number,droppedCount:number,droppedIds:string[]}}}
496
+ */
497
+ export function composeTieredInjectionPre(input = {}) {
498
+ const o = input && typeof input === 'object' ? input : {}
499
+ const maxTokens = Math.max(60, Number(o.maxTokens) > 0 ? Math.min(Number(o.maxTokens), TIER_BUDGET_V1.B0) : TIER_BUDGET_V1.B0)
500
+ // I5 状态过滤(2026-09-14 P0 / C8):**闸门之前**先挡下非 current —— 顺序很关键。
501
+ // 若放在 Tier-1 渲染里过滤,闸门仍会看到"有命中"而下探、渲染出空段,浪费一轮预算且语义错乱;
502
+ // 放在闸门前,则"全是 superseded"会自然退化成 tier0(本轮无可下探内容),这才是真实处境。
503
+ const gateHits0 = filterCurrentHitsPre(o.hits)
504
+ // 群反馈第 4 条:用户显式排除的来源(坏记忆不再反复灌入)。与 I5 同层、同在闸门之前 ——
505
+ // 被排除项不得再影响下探决策,否则"排除了却仍然下探/占预算"等于没排除。
506
+ const exHits = filterExcludedSourcesPre(gateHits0.kept, o.excludeSources)
507
+ const gateHits = { ...gateHits0, kept: exHits.kept }
508
+ if (o.enabled === false) {
509
+ return {
510
+ text: '', tier0: null, tier0Tokens: 0,
511
+ gate: { level: 'tier0', hitCount: 0, reasons: ['disabled'], deep: false, evidence: false },
512
+ tier1: null, tier2: null, degradations: [], tokens: 0, maxTokens, trimmedLines: 0, degraded: false, level: 'tier0',
513
+ hits: gateHits,
514
+ }
515
+ }
516
+ const catalog = o.catalog || buildTier0CatalogFromTextPre(Array.isArray(o.sources) ? o.sources : [], {
517
+ maxTokens,
518
+ estimateMode: 'conservative',
519
+ quota: { projectRatio: TIER_BUDGET_V1.projectRatio, floorRatio: TIER_BUDGET_V1.floorRatio },
520
+ })
521
+ const degradations = collectDegradationsPre({
522
+ catalog,
523
+ indexNotReady: o.indexNotReady,
524
+ semanticArm: o.semanticArm,
525
+ extraDegradations: o.extraDegradations,
526
+ })
527
+ // 群反馈第 4 条:被用户排除的来源同样**不静默**(I7)—— 只报被排除项数与模式,不吐正文。
528
+ if (exHits.droppedCount > 0) {
529
+ degradations.push({
530
+ code: 'excluded-source',
531
+ text: TIER_MARK_V1.degrade + ' 已按用户排除项挡下 ' + exHits.droppedCount + ' 条命中('
532
+ + exHits.matchedPatterns.slice(0, 3).map((p) => clean(p)).join(' / ') + ')· 不进注入',
533
+ })
534
+ }
535
+ // I5 的可见性(I7 精神):挡下了就写清挡了几条、都是什么状态,绝不静默。
536
+ // 只写 id(32 位锚点)与状态,**不写被挡条目的正文** —— 否则等于换个位置泄露被撤回的内容。
537
+ if (gateHits.droppedCount > 0) {
538
+ const states = Array.from(new Set(gateHits.dropped.map((h) => clean(h && h.status) || '(空)')))
539
+ degradations.push({
540
+ code: 'status-filtered',
541
+ text: TIER_MARK_V1.degrade + ' 已按 I5 挡下 ' + gateHits.droppedCount + ' 条非 current 命中('
542
+ + states.join('/') + ')· 审计视图仍可见,但不进注入',
543
+ })
544
+ }
545
+ const gate = decideTierGatePre({ hits: gateHits.kept, question: o.question })
546
+ const tier1 = gate.level === 'tier1' || gate.level === 'tier2'
547
+ ? buildTier1SectionPre(gateHits.kept, { maxItems: o.K, itemChars: o.L1 })
548
+ : null
549
+ const tier2 = gate.level === 'tier2'
550
+ ? buildTier2SectionPre(gateHits.kept, { maxChars: o.B2, maxBlocks: o.maxBlocks })
551
+ : null
552
+
553
+ const head = '[Tier-0 常驻目录 · 指引层 · ≤B0=' + TIER_BUDGET_V1.B0 + ' token(实计 ' + catalog.tokens + ') · '
554
+ + (Array.isArray(catalog.items) ? catalog.items.length : 0) + ' 条]'
555
+ const headParts = [head]
556
+ if (Array.isArray(catalog.items) && catalog.items.length) headParts.push(catalog.text)
557
+ if (catalog.quota) headParts.push(tierLayerAccountLinePre(catalog.quota.perLayer))
558
+ for (const d of degradations) headParts.push(d.text)
559
+ const drillParts = []
560
+ if (gate.level === 'tier0') {
561
+ headParts.push(TIER_MARK_V1.gate + ' 本轮无语义命中(' + gate.reasons.join(',') + ')→ 仅目录层,未下探 Tier-1/Tier-2')
562
+ } else {
563
+ headParts.push(TIER_MARK_V1.gate + ' 本轮命中 ' + gate.hitCount + ' 条(' + gate.reasons.join(',') + ')→ 下探 Tier-1')
564
+ if (tier1) drillParts.push(tier1.text)
565
+ if (tier2) {
566
+ drillParts.push(TIER_MARK_V1.gate + ' 需要证据语义 → 继续下探 Tier-2(单块 ≤' + TIER_BUDGET_V1.B2 + ' 字符)')
567
+ drillParts.push(tier2.text)
568
+ }
569
+ }
570
+ // 总长门(`injectBudgetChars` 侧给出):只裁"下探段",**目录层与降级行永不裁**——
571
+ // 否则超预算会把 I7 的降级标注裁掉,"不静默"就成了空话。
572
+ //
573
+ // T0-3 修复(2026-09-14 P0):旧实现有两处,都让这个"门"名不副实(读码确认):
574
+ // ① 裁剪循环只算 `headParts`,而降级说明是**在裁剪之后**才 push 进去的
575
+ // ⇒ 最终长度 = 裁剪目标 + 降级行长度,**可以超过 maxTotalChars**(门放走了自己该拦的东西);
576
+ // ② 没有"头部本身已超"的出口:若目录 + 降级行本身就超预算,旧代码静默照写,读者无从知道。
577
+ // 现改为:**先把降级行算进 head 长度**再裁下探段;裁完把降级行**真正回写进 headParts**
578
+ // (否则文本里根本没有这一行 —— 这是本段第一版实现的 bug,靠 `claimed == len` 自检抓到);
579
+ // 若 head 本身已超,如实标注超出量(目录层与降级行按 I7 不可裁 ⇒ 尽力门 + 显式超额,不假装达标)。
580
+ let trimmed = 0
581
+ let headOverBudget = 0
582
+ let claimedTotalChars = 0
583
+ const maxTotal = Number(o.maxTotalChars) > 0 ? Number(o.maxTotalChars) : 0
584
+ if (maxTotal) {
585
+ const noteFor = (n) => TIER_MARK_V1.degrade + ' 下探段超注入预算,已裁剪 ' + n + ' 行(目录层与降级标注不受影响)'
586
+ const lines = drillParts.join('\n').split('\n').filter(Boolean)
587
+ // 用"预留降级行长度"的保守估计先裁(降级行的字数随 trimmed 变化,先按最坏情况占位)。
588
+ const reserve = noteFor(999).length
589
+ while (lines.length && (headParts.join('\n').length + reserve + 1 + lines.join('\n').length) > maxTotal) {
590
+ lines.pop(); trimmed++
591
+ }
592
+ // 裁完再用**确定的**降级行文本复核一次,确保不会因为降级行本身而超
593
+ let note = trimmed > 0 ? noteFor(trimmed) : ''
594
+ while (lines.length && (headParts.join('\n').length + (note ? note.length + 1 : 0) + 1 + lines.join('\n').length) > maxTotal) {
595
+ lines.pop(); trimmed++
596
+ note = noteFor(trimmed)
597
+ }
598
+ if (note) headParts.push(note)
599
+ drillParts.length = 0
600
+ if (lines.length) drillParts.push(lines.join('\n'))
601
+ const headLen = headParts.join('\n').length
602
+ const drillLen = drillParts.join('\n').length
603
+ claimedTotalChars = headLen + (drillLen ? 1 + drillLen : 0)
604
+ if (claimedTotalChars > maxTotal) {
605
+ // 走投无路:目录层与降级行按 I7 不可裁 ⇒ **如实标注**,绝不假装达标。
606
+ headOverBudget = claimedTotalChars - maxTotal
607
+ const overNote = TIER_MARK_V1.degrade + ' 目录层与降级标注不可裁,本轮合计仍超注入预算 '
608
+ + headOverBudget + ' 字符(尽力门 + 显式超额,不静默)'
609
+ headParts.push(overNote)
610
+ claimedTotalChars = headParts.join('\n').length + (drillLen ? 1 + drillLen : 0)
611
+ }
612
+ }
613
+ const text = headParts.filter(Boolean).concat(drillParts.filter(Boolean)).join('\n')
614
+ return {
615
+ text,
616
+ tier0: catalog,
617
+ tier0Tokens: estimateTokensPre(catalog && catalog.text ? catalog.text : ''),
618
+ gate,
619
+ tier1,
620
+ tier2,
621
+ degradations,
622
+ tokens: estimateTierTokensPre(text),
623
+ /** 实际序列化长度(唯一口径,T0-3:不再让"扣账成本"与"实际注入"两本账)。 */
624
+ textChars: text.length,
625
+ maxTokens,
626
+ maxTotalChars: maxTotal,
627
+ /** 总长门裁剪掉的行数(0 = 未裁)。 */
628
+ trimmedLines: trimmed,
629
+ /** >0 表示"不可裁部分本身就超预算",已如实标注(尽力门,不假装达标)。 */
630
+ headOverBudgetChars: headOverBudget,
631
+ /** 门所声称的合计长度(含降级行自身;`== text.length` 时为校准一致)。 */
632
+ claimedTotalChars,
633
+ degraded: degradations.length > 0,
634
+ level: gate.level,
635
+ // I5 过滤账(P0/C8):调用方与测试都靠它区分「本来没命中」与「命中被状态挡下」。
636
+ hits: gateHits,
637
+ // 群反馈第 4 条排除账:区分「本来没命中」与「命中被用户排除项挡下」(可测、可观测)。
638
+ excluded: { count: exHits.droppedCount, total: exHits.total, patterns: exHits.matchedPatterns },
639
+ }
640
+ }
641
+
642
+ /**
643
+ * 注入段的 token 记账(与 Tier-0 同一口径:`max(ceil(chars/2), ceil(chars/4)+4)`)。
644
+ * 薄封装 `tier0-catalog.js` 的估算器,保证全链路只有一个 token 口径(契约 §4.6)。
645
+ */
646
+ export function estimateTierTokensPre(text) {
647
+ return estimateTokensPre(text)
648
+ }
649
+
650
+ export { TIER0_DEFAULTS }