@a9i5k4/dsh-auto-memory 2.5.3 → 3.0.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.
Files changed (104) hide show
  1. package/README.md +171 -1
  2. package/README.zh-CN.md +171 -1
  3. package/docs/CONTRIBUTORS.html +471 -0
  4. package/docs/HANDOFF-CRITERIA.md +92 -0
  5. package/docs/INTEGRATION-ANALYSIS.md +350 -348
  6. package/docs/USER-GUIDE.en.md +56 -1
  7. package/docs/USER-GUIDE.zh-CN.md +57 -2
  8. package/docs/internal/ACCEPT-35-LIVE.md +143 -0
  9. package/docs/internal/ACCEPTANCE-20260914.md +90 -0
  10. package/docs/internal/ARCH-REVIEW-BRIEF.md +411 -0
  11. package/docs/internal/ARCH-REVIEW-REQUEST.md +201 -0
  12. package/docs/internal/ARCH-REVIEW-ROUND2.md +169 -0
  13. package/docs/internal/ARCH-REVIEW-ROUND3.md +206 -0
  14. package/docs/internal/AUDIT-WB-GRAPH-FULL-20260916.md +314 -0
  15. package/docs/internal/CONCURRENCY-INVESTIGATION-20260917.md +192 -0
  16. package/docs/internal/CROSS-SESSION-SEARCH-PATH-DECISION.md +72 -0
  17. package/docs/internal/CROSS-SESSION-SEARCH-RESEARCH.md +131 -0
  18. package/docs/internal/DECISIONS-20260914-SESSION.md +269 -0
  19. package/docs/internal/DESIGN-P1-STATE-COMMIT-20260915.md +219 -0
  20. package/docs/internal/DIRECTION-CHECK-WB-GRAPH-20260916.md +132 -0
  21. package/docs/internal/FEEDBACK-TO-DSHAPI-RELAY.md +13 -0
  22. package/docs/internal/GH-DISCUSSION-5732-COMMENT.md +74 -0
  23. package/docs/internal/GPT-ACCEPTANCE-PROMPT-20260916.md +352 -0
  24. package/docs/internal/GPT-REVIEW-PROMPT.md +216 -0
  25. package/docs/internal/GROUP-WEBHOOK-SETUP.md +33 -0
  26. package/docs/internal/KICKOFF-P0.md +254 -0
  27. package/docs/internal/MASTER-PLAN-3.0.md +411 -0
  28. package/docs/internal/MEMORY-MUTATION-AND-INDEX-DESIGN.md +85 -0
  29. package/docs/internal/MERGE-CONFLICT-SCAN-20260914.md +222 -0
  30. package/docs/internal/PENDING-FIXES-20260916.md +289 -0
  31. package/docs/internal/RAG-KARPATHY-PROGRAM.md +229 -0
  32. package/docs/internal/REPORT-P0-NIGHTLY.md +212 -0
  33. package/docs/internal/REPORT-P5-ACCEPTANCE.md +31 -0
  34. package/docs/internal/REPORT-WB-GRAPH-NIGHTLY.md +153 -0
  35. package/docs/internal/REVIEW-WB-GRAPH-SELF.md +81 -0
  36. package/docs/internal/ROADMAP-20260917-WEEK.md +305 -0
  37. package/docs/internal/ROADMAP.md +106 -0
  38. package/docs/internal/RUN-P0-NIGHTLY.md +227 -0
  39. package/docs/internal/S10-CONSTRUCTION-HANDOFF-20260917.md +175 -0
  40. package/docs/internal/S10-GAPS-PLAIN-20260917.md +125 -0
  41. package/docs/internal/SEMANTIC-ARCHITECTURE-SPEC.md +360 -0
  42. package/docs/internal/SESSION-FILE-REPAIR-PROTOCOL.md +90 -0
  43. package/docs/internal/THREE-LAYER-CONTRACT.md +210 -0
  44. package/docs/internal/TODO-BACKLOG.md +263 -142
  45. package/docs/internal/TODO-GRAPH.html +715 -0
  46. package/docs/internal/TODO-GRAPH.html.bak-20260914-v2 +493 -0
  47. package/docs/internal/TODO-GRAPH.html.bak-20260915-alsfix +710 -0
  48. package/docs/internal/TODO-GRAPH.html.bak-20260915-p1 +710 -0
  49. package/docs/internal/TODO-GRAPH.html.bak-20260915-p6a-rev +703 -0
  50. package/docs/internal/TODO-GRAPH.html.bak-20260915-wshint +710 -0
  51. package/docs/internal/TODO-GRAPH.html.bak-20260916-batch +715 -0
  52. package/docs/internal/WB-FORMAT-CONVENTION.md +112 -0
  53. package/docs/internal/WB-GRAPH-DECISIONS-20260914.md +71 -0
  54. package/docs/internal/reviews/CLAIM-VERIFICATION-20260914.md +56 -0
  55. package/docs/internal/reviews/PLAN-gpt6astra-round2-20260914.md +787 -0
  56. package/docs/internal/reviews/REVIEW-gpt6astra-20260914.md +112 -0
  57. package/docs/internal/reviews/ROUND3-REVIEW-INTEGRATION-20260914.md +230 -0
  58. package/docs/prompts/M8-3-enable-verify.md +49 -49
  59. package/lib/acceptance.js +71 -0
  60. package/lib/activation-host.js +90 -9
  61. package/lib/activation-inbox.js +25 -7
  62. package/lib/board-mode.js +30 -0
  63. package/lib/client.js +878 -75
  64. package/lib/context-bridge.js +2 -2
  65. package/lib/context-host.js +70 -6
  66. package/lib/engine-identity.js +149 -0
  67. package/lib/engine-switch.js +247 -0
  68. package/lib/episodic-store.js +11 -10
  69. package/lib/evidence-store.js +2 -2
  70. package/lib/fact-store.js +1 -1
  71. package/lib/fs-retry.js +46 -0
  72. package/lib/index.js +1987 -153
  73. package/lib/intent-clean-safe.js +40 -0
  74. package/lib/intent-clean.js +12 -16
  75. package/lib/l0-extract.js +263 -149
  76. package/lib/l0-index-sync.js +195 -0
  77. package/lib/l0-index.js +349 -239
  78. package/lib/ledger-criteria.js +142 -0
  79. package/lib/m7-index-sync-host.js +65 -4
  80. package/lib/m7-wire.js +3 -3
  81. package/lib/memory-anchor.js +56 -1
  82. package/lib/memory-envelope.js +252 -0
  83. package/lib/memory-hub.js +14 -4
  84. package/lib/memory-mutation.js +246 -0
  85. package/lib/memory-writer.js +204 -24
  86. package/lib/procedure-observation.js +48 -0
  87. package/lib/procedure-store.js +34 -17
  88. package/lib/python-setup.js +1 -1
  89. package/lib/rerank-host.js +160 -0
  90. package/lib/rules-layer.js +261 -0
  91. package/lib/semantic-js.js +15 -0
  92. package/lib/shadow-retrieval.js +3 -3
  93. package/lib/state-commit.js +245 -0
  94. package/lib/subagent-gc.js +4 -8
  95. package/lib/tier-layer-inject.js +650 -0
  96. package/lib/tier0-catalog.js +693 -0
  97. package/lib/water-window.js +263 -186
  98. package/lib/wb-contract.js +495 -0
  99. package/lib/wb-sidecar.js +839 -0
  100. package/lib/ws-overview-rank.js +2 -2
  101. package/package.json +1 -1
  102. package/python/m7_embedding_v1.py +5 -5
  103. package/python/worker_semantic_v1.py +17 -6
  104. package/python/worker_v1.py +38 -4
@@ -0,0 +1,693 @@
1
+ /**
2
+ * Tier-0 目录生成器(tier0_catalog_v1)—— 三层检索契约(`docs/internal/THREE-LAYER-CONTRACT.md`)C4。
3
+ *
4
+ * 2026-09-14 建立。作用=**指引层**:把四类来源(用户级记忆 / 项目笔记 / 当日日志 / 白板 PLAN)
5
+ * 压成"每条 1 行"的短目录,用来回答「要不要用某条记忆」,从而决定是否下探 Tier-1 / Tier-2。
6
+ *
7
+ * 一行格式(契约 §1):`标题 · 一句结论 · layer · status · 日期`
8
+ * - layer ∈ { user | project | log | reflection | whiteboard },按**来源路径**归属(§2 判定规则);
9
+ * - status 一律 `current`(superseded / retracted 的判定属写入侧,指引层不臆断);
10
+ * - 预算 ≤ B0 token(契约 I1;默认 800,待实验 E1 校准);
11
+ * - 裁剪优先级:project > whiteboard > user > log(同层按日期倒序);`reflection` 排在 user 与 log
12
+ * 之间,与 §2「notes(project) > log(reflection) > log」一致。
13
+ *
14
+ * **per-layer 配额(2026-09-14 C5,契约 §2.1;opt-in)**:纯严格优先级会被 `project` 吃满预算——真实
15
+ * 语料实测 77 块把 800 token 全占,`whiteboard`/`user`/`log` 一条都进不来,"分层"退化成单层。
16
+ * 故开立 `quota` 选项(`{projectRatio=0.6, floorRatio=0.1, floorLayers=['whiteboard','user']}`):
17
+ * - `project ≤ projectRatio·maxTokens`(硬上限);
18
+ * - `whiteboard`/`user` 各**保底** `floorRatio·maxTokens`(先预留额度再按优先级填充,保底不足则二轮补齐);
19
+ * - 剩余额度按优先级继续填;被裁条目**按层计数**返回(`droppedByLayer` / `layerAccounting`),
20
+ * `degradedLayers` 明确列出"有候选却一条没进"的层 —— 落实 I7「不得静默丢层」。
21
+ * **默认关闭(`quota` 缺省 = 旧行为)**:默认关闭是为保住既有调用方的逐字节行为(C4 的极小预算
22
+ * 压力用例锁的是"纯严格优先级"语义);自动注入路径(`lib/tier-layer-inject.js`)显式开启。
23
+ *
24
+ * 预算口径(**重要**):I1 是硬上限,而仓库现有的 token 估算是 lib/index.js 的
25
+ * `estimateSessionTokens()`(官方 token-meter 移植,`ceil(字符/4)+4`)。该口径对 CJK 语料会**低估
26
+ * 约 2-4 倍**(中文接近 1 token/字符),拿它当预算门会静默突破 B0。故本模块两种口径都实现:
27
+ * - `estimateMode:'repo'` —— 原样复刻仓库既有口径(供对齐 / 复算);
28
+ * - `estimateMode:'conservative'` —— `ceil(字符/2)`,**默认**用它当预算门(CJK 密集体裁的保守上界)。
29
+ * 验收口径:**两种口径下 text 都 ≤ maxTokens**(见 `tests/smoke/smoke-test-tier0-catalog-pre.mjs`)。
30
+ *
31
+ * 单元("一条")切分:① 有 `<!-- memory:mem_<32hex> -->` 锚点 → 一块一条(与 l0-extract-pre 同约定:
32
+ * 锚点标记其**后**内容);② 无锚点 → 按最浅的 ≥2 级标题切(`#` 文档标题成为独立一条);③ 无标题 →
33
+ * 按顶层 `- ` 条目切;④ 都没有 → 整个文件算一条。
34
+ * 标题 = 该单元内**最深**的有效标题(剥掉 `(HH:MM)`/`(YYYY-MM-DD)` 尾巴;纯日期标题不算标题),
35
+ * 否则取首行的 `【标签】`,都没有则为空串(不硬造)。一句结论 = 首条实质内容行的首句,剥掉列表标记 /
36
+ * 时间前缀(`07:41 `)/ 序号(`①`、`1.`),≤ 80 字。
37
+ *
38
+ * 边界(契约 I7 精神):路径为空 / 文件不存在 / 是目录 / 过大 / 空文件 / 格式乱 → 一律**跳过并计数**,
39
+ * 绝不抛异常;目录为空时 text 输出显式空标记,不允许静默无声。
40
+ *
41
+ * 纯函数内核(零 IO,可单独测):estimateTokensPre / splitTier0UnitsPre / extractTier0ItemsPre /
42
+ * renderCatalogLinePre / buildTier0CatalogFromTextPre;IO 只集中在 buildTier0CatalogPre 与
43
+ * readTextSafePre。零第三方依赖(仅 node 内建 fs)。UTF-8 无 BOM。
44
+ */
45
+ import { readFileSync, statSync } from 'node:fs'
46
+
47
+ export const TIER0_CATALOG_VERSION = 'tier0_catalog_v1'
48
+
49
+ export const TIER0_DEFAULTS = Object.freeze({
50
+ maxTokens: 800,
51
+ oneLineChars: 80,
52
+ titleChars: 40,
53
+ minChars: 15,
54
+ maxFileBytes: 4 * 1024 * 1024,
55
+ estimateMode: 'conservative',
56
+ })
57
+
58
+ /** layer 优先级:数字小者优先保留(契约 §2)。 */
59
+ const LAYER_RANK = Object.freeze({ project: 0, whiteboard: 1, user: 2, reflection: 3, log: 4 })
60
+
61
+ /** 允许的 layer 取值(契约 §2)。 */
62
+ const KNOWN_LAYERS = Object.freeze(['user', 'project', 'log', 'reflection', 'whiteboard'])
63
+
64
+ /** 默认输入键 → layer(契约 §2 判定规则)。 */
65
+ const SOURCE_SPECS = Object.freeze([
66
+ Object.freeze({ key: 'workspaceMemoryPath', layer: 'project' }),
67
+ Object.freeze({ key: 'userMemoryPath', layer: 'user' }),
68
+ Object.freeze({ key: 'todayLogPath', layer: 'log' }),
69
+ Object.freeze({ key: 'handoffPlanPath', layer: 'whiteboard' }),
70
+ ])
71
+
72
+ /** 锚点:`<!-- memory:mem_<32hex> -->`(允许空白浮动)。 */
73
+ const MEM_ANCHOR_RE = /<!--\s*memory:(mem_[0-9a-f]{32})\s*-->/g
74
+ /** 标题行:`## 标题`(≤3 空格缩进)。 */
75
+ const HEADING_RE = /^\s{0,3}(#{1,6})\s+(.*?)\s*$/
76
+ /** 列表标记:`- ` / `* ` / `+ ` / `• `。 */
77
+ const BULLET_RE = /^\s{0,3}[-*+•]\s+/
78
+ /** 行首时间戳:`07:41 ` / `14:5x `(日志惯例)。 */
79
+ const LEAD_TIME_RE = /^\s*\d{1,2}:\d{2}(?::\d{2})?[a-z]?\s+/
80
+ /** 行首序号:`①` / `1.` / `(2)` / `[3]` / `1、`。 */
81
+ const LEAD_ORDER_RE = /^\s*(?:[((]\s*\d{1,3}\s*[))]|\[\d{1,3}\]|\d{1,3}\s*[.)、.]|[\u2460-\u2473])\s*/
82
+ /** 行首 `【标签】` / `[标签]` / `(标签)`。 */
83
+ const LABEL_RE = /^\s*[【[((]\s*([^】\]))]{2,40})\s*[】\]))]/
84
+ /** 句末/句读分隔符(与 l0-extract-pre 同一字符类)。 */
85
+ const SENTENCE_SPLIT_RE = /[。;;!!??\n]/
86
+ /** 标题尾巴:`(07:41)` / `(2026-09-14)`;标题**开头**的日期与分隔符;日期串本体。 */
87
+ const HEADING_SUFFIX_RE = /\s*[((]\s*(?:\d{1,2}:\d{2}|\d{4}[-/.]\d{1,2}[-/.]\d{1,2})\s*[))]\s*$/
88
+ const LEAD_DATE_RE = /^\s*\d{4}[-/.]\d{1,2}[-/.]\d{1,2}\s*[·::\-—–]?\s*/
89
+ const DATE_STR_RE = /(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})/g
90
+ /** 日期:`2026-09-14` / `2026/9/14`。 */
91
+ const DATE_RE = /(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})/g
92
+ /** 分隔线 / 注释 / 强调标记。 */
93
+ const RULE_LINE_RE = /^\s*(?:-{3,}|\*{3,}|_{3,}|={3,})\s*$/
94
+ const COMMENT_LINE_RE = /^\s*<!--/
95
+ const EMPHASIS_RE = /\*\*|__/g
96
+ /** 含字母/汉字才算"实质内容行"。 */
97
+ const HAS_LETTER_RE = /\p{L}/u
98
+
99
+ const clean = (s) => String(s == null ? '' : s).replace(/\u0000/g, '').trim()
100
+ const stripBom = (s) => (s && s.charCodeAt(0) === 0xfeff ? s.slice(1) : s)
101
+ const cut = (s, n) => (s.length <= n ? s : s.slice(0, Math.max(1, n - 1)) + '…')
102
+
103
+ /**
104
+ * 纯日期标题(`2026-08-14`、`2026-08-14(周一)`)不作标题用——
105
+ * 判定=把日期串与分隔符剥光后什么都不剩。
106
+ */
107
+ function isDateOnly(s) {
108
+ const t = String(s || '')
109
+ if (!findDatePre(t)) return false
110
+ DATE_STR_RE.lastIndex = 0
111
+ return t.replace(DATE_STR_RE, '').replace(/[\s\-—–·::()()[\]【】.,,、/]+/g, '') === ''
112
+ }
113
+
114
+ /**
115
+ * token 估算。两种口径都可用,默认走保守口径(见文件头"预算口径")。
116
+ * 保守口径取 `max(ceil(字符/2), repo 口径)`——即**永不弱于**仓库既有口径(短文本时 repo 口径的
117
+ * `+4` 框定开销更大,取 max 后两边都盖住)。
118
+ * @param {string} text
119
+ * @param {{mode?: 'repo'|'conservative'}} [opts]
120
+ * @returns {number}
121
+ */
122
+ export function estimateTokensPre(text, opts = {}) {
123
+ const s = String(text == null ? '' : text)
124
+ if (!s) return 0
125
+ const mode = opts && opts.mode === 'repo' ? 'repo' : 'conservative'
126
+ // repo 口径:lib/index.js estimateSessionTokens()(官方 token-meter 移植)的文本分支。
127
+ const repo = Math.ceil(s.length / 4) + 4
128
+ if (mode === 'repo') return repo
129
+ // 保守口径:CJK 密集时 4 字符≈1 token 会低估,按 2 字符≈1 token 封顶。
130
+ return Math.max(Math.ceil(s.length / 2), repo)
131
+ }
132
+
133
+ /** 取文本中首个合法日期(`YYYY-MM-DD`);无则空串。 */
134
+ export function findDatePre(text) {
135
+ const s = String(text == null ? '' : text)
136
+ DATE_RE.lastIndex = 0
137
+ let m
138
+ while ((m = DATE_RE.exec(s)) !== null) {
139
+ const y = Number(m[1]), mo = Number(m[2]), d = Number(m[3])
140
+ if (y >= 1900 && y <= 2999 && mo >= 1 && mo <= 12 && d >= 1 && d <= 31) {
141
+ return `${m[1]}-${String(mo).padStart(2, '0')}-${String(d).padStart(2, '0')}`
142
+ }
143
+ if (m.index === DATE_RE.lastIndex) DATE_RE.lastIndex++
144
+ }
145
+ return ''
146
+ }
147
+
148
+ /**
149
+ * 切分单元(纯函数)。返回 `{ groups, headLines }`;`groups` 每项是一个单元的行数组。
150
+ * 顺序:锚点 → 标题 → 顶层 `- ` 条目 → 整文件兜底。
151
+ */
152
+ export function splitTier0UnitsPre(text) {
153
+ const src = String(text == null ? '' : text)
154
+ const empty = { groups: [], headLines: [] }
155
+ if (!src.trim()) return empty
156
+ const allLines = src.split(/\r?\n/)
157
+ const headLines = allLines.map(clean).filter(Boolean).slice(0, 3)
158
+
159
+ // ① 记忆锚点:锚点标记其后内容(与 l0-extract-pre.parseMemoryItemsPre 同约定)
160
+ MEM_ANCHOR_RE.lastIndex = 0
161
+ const marks = []
162
+ let m
163
+ while ((m = MEM_ANCHOR_RE.exec(src)) !== null) {
164
+ marks.push({ start: m.index, end: m.index + m[0].length })
165
+ if (m.index === MEM_ANCHOR_RE.lastIndex) MEM_ANCHOR_RE.lastIndex++
166
+ }
167
+ if (marks.length) {
168
+ const groups = []
169
+ for (let i = 0; i < marks.length; i++) {
170
+ const from = marks[i].end
171
+ const to = i + 1 < marks.length ? marks[i + 1].start : src.length
172
+ const body = clean(src.slice(from, to))
173
+ if (body) groups.push(body.split(/\r?\n/))
174
+ }
175
+ if (groups.length) return { groups, headLines }
176
+ }
177
+
178
+ // ② 标题切分:取最浅的 ≥2 级标题作分界(全为 1 级时退化为按 1 级)
179
+ const heads = []
180
+ allLines.forEach((line, i) => {
181
+ const h = HEADING_RE.exec(line)
182
+ if (h) heads.push({ i, level: h[1].length })
183
+ })
184
+ if (heads.length) {
185
+ const levels = [...new Set(heads.map((h) => h.level))].sort((a, b) => a - b)
186
+ const splitLevel = levels.find((lv) => lv >= 2) || levels[0]
187
+ const bounds = heads.filter((h) => h.level <= splitLevel).map((h) => h.i)
188
+ const groups = []
189
+ for (let i = 0; i < bounds.length; i++) {
190
+ const from = bounds[i]
191
+ const to = i + 1 < bounds.length ? bounds[i + 1] : allLines.length
192
+ const lines = allLines.slice(from, to)
193
+ if (lines.some((l) => clean(l))) groups.push(lines)
194
+ }
195
+ if (groups.length) return { groups, headLines }
196
+ }
197
+
198
+ // ③ 无标题:按顶层 `- ` 条目切
199
+ const bullets = []
200
+ allLines.forEach((line, i) => { if (BULLET_RE.test(line)) bullets.push(i) })
201
+ if (bullets.length >= 2) {
202
+ return { groups: bullets.map((i) => [allLines[i]]), headLines }
203
+ }
204
+
205
+ // ④ 兜底:整个文件算一条
206
+ return { groups: [allLines], headLines }
207
+ }
208
+
209
+ /** 单元内最深的有效标题(纯日期标题不算;剥掉头尾日期与(HH:MM)尾巴);无则空串。 */
210
+ function pickHeadingTitle(lines, titleChars) {
211
+ let best = null
212
+ for (const line of lines) {
213
+ const h = HEADING_RE.exec(line)
214
+ if (!h) continue
215
+ const t = clean(String(h[2]).replace(HEADING_SUFFIX_RE, '').replace(LEAD_DATE_RE, ''))
216
+ if (!t || isDateOnly(t)) continue
217
+ const level = h[1].length
218
+ if (!best || level > best.level) best = { level, text: t }
219
+ }
220
+ return best ? cut(best.text, titleChars) : ''
221
+ }
222
+
223
+ /** 首条实质内容行:剥列表标记 / 序号 / 时间前缀 / 引用符 / 强调标记;返回 `{ label, body }`。 */
224
+ function pickContentLine(lines) {
225
+ for (const line of lines) {
226
+ if (HEADING_RE.test(line) || COMMENT_LINE_RE.test(line) || RULE_LINE_RE.test(line)) continue
227
+ let t = clean(String(line).replace(/^\s*>\s?/, ''))
228
+ t = t.replace(BULLET_RE, '')
229
+ t = t.replace(LEAD_ORDER_RE, '')
230
+ t = t.replace(LEAD_TIME_RE, '')
231
+ t = clean(t.replace(EMPHASIS_RE, ''))
232
+ if (!t || !HAS_LETTER_RE.test(t)) continue
233
+ const lab = LABEL_RE.exec(t)
234
+ if (lab) {
235
+ const label = clean(lab[1])
236
+ const rest = clean(t.slice(lab[0].length))
237
+ if (label && rest) return { label, body: rest }
238
+ }
239
+ return { label: '', body: t }
240
+ }
241
+ return null
242
+ }
243
+
244
+ /** 首句;不足 minChars 时并接后续句子(同 l0-extract-pre 的 growToMin 思路)。 */
245
+ function firstSentence(text, minChars, maxChars) {
246
+ const flat = clean(String(text).replace(/\s+/g, ' '))
247
+ if (!flat) return ''
248
+ const parts = flat.split(SENTENCE_SPLIT_RE).map(clean).filter(Boolean)
249
+ if (!parts.length) return ''
250
+ let acc = parts[0]
251
+ for (let i = 1; i < parts.length && acc.length < minChars; i++) acc += '。' + parts[i]
252
+ return cut(acc, maxChars)
253
+ }
254
+
255
+ /** 日期:单元标题行 → 文件名 → 文件头 → 单元正文。 */
256
+ function resolveDatePre(lines, ctx) {
257
+ for (const line of lines) {
258
+ if (!HEADING_RE.test(line)) continue
259
+ const d = findDatePre(line)
260
+ if (d) return d
261
+ }
262
+ if (ctx.baseDate) return ctx.baseDate
263
+ if (ctx.fileDate) return ctx.fileDate
264
+ for (const line of lines) {
265
+ const d = findDatePre(line)
266
+ if (d) return d
267
+ }
268
+ return ''
269
+ }
270
+
271
+ /** 单个单元 → 一条目录项;无实质内容返回 null。 */
272
+ function buildRecordPre(layer, unitLines, ctx, cfg) {
273
+ const lines = unitLines.map(clean).filter(Boolean)
274
+ if (!lines.length) return null
275
+ let title = pickHeadingTitle(lines, cfg.titleChars)
276
+ const content = pickContentLine(lines)
277
+ let oneLine = ''
278
+ if (content) {
279
+ if (!title && content.label) title = cut(content.label, cfg.titleChars)
280
+ oneLine = firstSentence(content.body, cfg.minChars, cfg.oneLineChars)
281
+ }
282
+ if (!title && !oneLine) return null
283
+ return { layer, status: 'current', date: resolveDatePre(lines, ctx), title, oneLine }
284
+ }
285
+
286
+ /**
287
+ * 单份文件文本 → 目录项数组(纯函数;layer 由调用方给定,非法 layer 返回空数组,不抛错)。
288
+ * @param {string} text
289
+ * @param {'user'|'project'|'log'|'reflection'|'whiteboard'} layer
290
+ * @param {{path?:string, maxTokens?:number, oneLineChars?:number, titleChars?:number, minChars?:number}} [opts]
291
+ */
292
+ export function extractTier0ItemsPre(text, layer, opts = {}) {
293
+ const o = opts && typeof opts === 'object' ? opts : {}
294
+ return extractSourcePre(text, layer, { path: clean(o.path) }, resolveOptsPre(o)).items
295
+ }
296
+
297
+ /** 单份来源的抽取(内部):同时给出单元数与丢弃数。 */
298
+ function extractSourcePre(text, layer, ctx, cfg) {
299
+ if (!KNOWN_LAYERS.includes(layer)) return { items: [], units: 0, dropped: 0 }
300
+ const src = stripBom(String(text == null ? '' : text))
301
+ if (!src.trim()) return { items: [], units: 0, dropped: 0 }
302
+ const { groups, headLines } = splitTier0UnitsPre(src)
303
+ const base = clean(String(ctx.path || '').replace(/\\/g, '/').split('/').pop())
304
+ const dateCtx = { baseDate: findDatePre(base), fileDate: findDatePre(headLines.join(' ')) }
305
+ const items = []
306
+ let dropped = 0
307
+ for (const unitLines of groups) {
308
+ const rec = buildRecordPre(layer, unitLines, dateCtx, cfg)
309
+ if (rec) items.push(rec)
310
+ else dropped++
311
+ }
312
+ return { items, units: groups.length, dropped }
313
+ }
314
+
315
+ /** 一条目录项 → 一行文本。**导出**:渲染口径的唯一实现。 */
316
+ export function renderCatalogLinePre(item) {
317
+ const it = item && typeof item === 'object' ? item : {}
318
+ const title = clean(it.title)
319
+ const oneLine = clean(it.oneLine)
320
+ const date = clean(it.date)
321
+ const segs = []
322
+ if (title && title !== oneLine) segs.push(title)
323
+ if (oneLine) segs.push(oneLine)
324
+ segs.push(clean(it.layer) || 'log')
325
+ segs.push(clean(it.status) || 'current')
326
+ if (date) segs.push(date)
327
+ return segs.join(' · ')
328
+ }
329
+
330
+ /** 契约要求的对外字段(五字段,顺序固定)。 */
331
+ const toPublicItemPre = (r) => ({
332
+ layer: r.layer, status: r.status, date: r.date, title: r.title, oneLine: r.oneLine,
333
+ })
334
+
335
+ /** 排序:layer 优先级升序 → 同层日期倒序 → 原顺序(确定性)。 */
336
+ function compareRecordsPre(a, b) {
337
+ const ra = LAYER_RANK[a.layer] === undefined ? 9 : LAYER_RANK[a.layer]
338
+ const rb = LAYER_RANK[b.layer] === undefined ? 9 : LAYER_RANK[b.layer]
339
+ if (ra !== rb) return ra - rb
340
+ if (a.date !== b.date) {
341
+ if (!a.date) return 1
342
+ if (!b.date) return -1
343
+ return a.date < b.date ? 1 : -1
344
+ }
345
+ return a._seq - b._seq
346
+ }
347
+
348
+ /** per-layer 配额默认值(契约 §2.1)。 */
349
+ export const TIER0_QUOTA_DEFAULTS = Object.freeze({
350
+ projectRatio: 0.6,
351
+ floorRatio: 0.1,
352
+ projectLayer: 'project',
353
+ floorLayers: Object.freeze(['whiteboard', 'user']),
354
+ })
355
+
356
+ /** 配额参数归一化(`quota===true` 即全默认;非法值一律回退默认,不抛错)。 */
357
+ function resolveQuotaPre(q) {
358
+ const o = q === true ? {} : (q && typeof q === 'object' ? q : {})
359
+ const ratio = (v, d) => { const n = Number(v); return Number.isFinite(n) && n > 0 && n <= 1 ? n : d }
360
+ const fl = Array.isArray(o.floorLayers) ? o.floorLayers.filter((l) => KNOWN_LAYERS.includes(l)) : [...TIER0_QUOTA_DEFAULTS.floorLayers]
361
+ return {
362
+ projectRatio: ratio(o.projectRatio, TIER0_QUOTA_DEFAULTS.projectRatio),
363
+ floorRatio: ratio(o.floorRatio, TIER0_QUOTA_DEFAULTS.floorRatio),
364
+ projectLayer: KNOWN_LAYERS.includes(o.projectLayer) ? o.projectLayer : TIER0_QUOTA_DEFAULTS.projectLayer,
365
+ floorLayers: [...new Set(fl)],
366
+ }
367
+ }
368
+
369
+ /**
370
+ * per-layer 配额分配(纯函数,确定性;契约 §2.1)。
371
+ *
372
+ * 两轮:
373
+ * ① 优先级填充(project > whiteboard > user > reflection > log,同层日期倒序):逐条试装;
374
+ * 超 `project` 层上限(`projectRatio·maxTokens`)或超"总预算 − 其它保底层尚欠额度"者跳过;
375
+ * ② 保底补齐:对仍低于保底的层(`floorLayers`),把①里被预留挤掉的条目补进来(仍受总预算约束)。
376
+ *
377
+ * @param {Array<{layer:string,date:string,title:string,oneLine:string,status:string,_seq?:number}>} candidates
378
+ * @param {{maxTokens?:number, estimateMode?:'repo'|'conservative',
379
+ * quota?:boolean|{projectRatio?:number,floorRatio?:number,projectLayer?:string,floorLayers?:string[]}}} [opts]
380
+ * @returns {{picked:Array, text:string, tokens:number, dropped:number,
381
+ * droppedByLayer:Record<string,number>, perLayer:Record<string,object>,
382
+ * caps:Record<string,number|null>, floors:Record<string,number>,
383
+ * degradedLayers:string[], emptyLayers:string[], quotaApplied:boolean}}
384
+ */
385
+ export function allocateTier0QuotaPre(candidates, opts = {}) {
386
+ const cfg = opts && typeof opts === 'object' ? opts : {}
387
+ const maxTokens = Number(cfg.maxTokens) > 0 ? Number(cfg.maxTokens) : TIER0_DEFAULTS.maxTokens
388
+ const estimateMode = cfg.estimateMode === 'repo' ? 'repo' : 'conservative'
389
+ const q = resolveQuotaPre(cfg.quota === undefined ? true : cfg.quota)
390
+ const list = (Array.isArray(candidates) ? candidates : []).map((r, i) => Object.assign({}, r, {
391
+ _seq: Number.isFinite(r && r._seq) ? r._seq : i,
392
+ }))
393
+ const sorted = list.slice().sort(compareRecordsPre)
394
+ const has = new Set(sorted.map((r) => r.layer))
395
+
396
+ const caps = {}
397
+ const floors = {}
398
+ for (const layer of KNOWN_LAYERS) {
399
+ caps[layer] = layer === q.projectLayer ? Math.max(1, Math.floor(maxTokens * q.projectRatio)) : null
400
+ floors[layer] = (q.floorLayers.includes(layer) && has.has(layer)) ? Math.max(1, Math.ceil(maxTokens * q.floorRatio)) : 0
401
+ }
402
+
403
+ const layerChars = {}
404
+ const tokensOf = (layer) => (layerChars[layer] ? estimateTokensPre(layerChars[layer], { mode: estimateMode }) : 0)
405
+ /** 其它保底层尚欠的额度(要预留,不能被高优先级层先吃掉)。 */
406
+ const reserveFor = (exceptLayer) => {
407
+ let sum = 0
408
+ for (const layer of q.floorLayers) {
409
+ const f = floors[layer] || 0
410
+ if (!f || layer === exceptLayer) continue
411
+ sum += Math.max(0, f - tokensOf(layer))
412
+ }
413
+ return sum
414
+ }
415
+ const chosen = []
416
+ const accepted = new Set()
417
+ let text = ''
418
+ const accept = (rec, line) => {
419
+ layerChars[rec.layer] = layerChars[rec.layer] ? layerChars[rec.layer] + '\n' + line : line
420
+ chosen.push(rec)
421
+ accepted.add(rec)
422
+ text = text ? text + '\n' + line : line
423
+ }
424
+
425
+ // ① 优先级填充(预留保底额度)
426
+ for (const rec of sorted) {
427
+ const line = renderCatalogLinePre(rec)
428
+ const layerTok = estimateTokensPre(layerChars[rec.layer] ? layerChars[rec.layer] + '\n' + line : line, { mode: estimateMode })
429
+ if (caps[rec.layer] !== null && layerTok > caps[rec.layer]) continue
430
+ const reserve = reserveFor(rec.layer) + Math.max(0, (floors[rec.layer] || 0) - layerTok)
431
+ const next = text ? text + '\n' + line : line
432
+ if (estimateTokensPre(next, { mode: estimateMode }) > maxTokens - reserve) continue
433
+ accept(rec, line)
434
+ }
435
+ // ② 保底补齐(补①中被预留挤掉的保底层条目)
436
+ for (const rec of sorted) {
437
+ if (accepted.has(rec)) continue
438
+ const floor = floors[rec.layer] || 0
439
+ if (!floor || tokensOf(rec.layer) >= floor) continue
440
+ const line = renderCatalogLinePre(rec)
441
+ const layerTok = estimateTokensPre(layerChars[rec.layer] ? layerChars[rec.layer] + '\n' + line : line, { mode: estimateMode })
442
+ if (caps[rec.layer] !== null && layerTok > caps[rec.layer]) continue
443
+ const next = text ? text + '\n' + line : line
444
+ if (estimateTokensPre(next, { mode: estimateMode }) > maxTokens) continue
445
+ accept(rec, line)
446
+ }
447
+
448
+ chosen.sort(compareRecordsPre)
449
+ const perLayer = {}
450
+ const droppedByLayer = {}
451
+ const degradedLayers = []
452
+ const emptyLayers = []
453
+ for (const layer of KNOWN_LAYERS) {
454
+ const total = sorted.filter((r) => r.layer === layer).length
455
+ const picked = chosen.filter((r) => r.layer === layer).length
456
+ perLayer[layer] = {
457
+ candidates: total, picked, dropped: total - picked, tokens: tokensOf(layer),
458
+ cap: caps[layer] === null ? null : caps[layer], floor: floors[layer] || 0,
459
+ }
460
+ if (total - picked > 0) droppedByLayer[layer] = total - picked
461
+ if (total > 0 && picked === 0) degradedLayers.push(layer)
462
+ if (total === 0) emptyLayers.push(layer)
463
+ }
464
+ return {
465
+ picked: chosen,
466
+ text,
467
+ tokens: text ? estimateTokensPre(text, { mode: estimateMode }) : 0,
468
+ dropped: sorted.length - chosen.length,
469
+ droppedByLayer,
470
+ perLayer,
471
+ caps,
472
+ floors,
473
+ degradedLayers,
474
+ emptyLayers,
475
+ quotaApplied: true,
476
+ projectRatio: q.projectRatio,
477
+ floorRatio: q.floorRatio,
478
+ floorLayers: [...q.floorLayers],
479
+ }
480
+ }
481
+
482
+ function resolveOptsPre(opts) {
483
+ const o = opts && typeof opts === 'object' ? opts : {}
484
+ const num = (v, d) => {
485
+ const n = Number(v)
486
+ return Number.isFinite(n) && n > 0 ? n : d
487
+ }
488
+ return {
489
+ maxTokens: num(o.maxTokens, TIER0_DEFAULTS.maxTokens),
490
+ oneLineChars: num(o.oneLineChars, TIER0_DEFAULTS.oneLineChars),
491
+ titleChars: num(o.titleChars, TIER0_DEFAULTS.titleChars),
492
+ minChars: num(o.minChars, TIER0_DEFAULTS.minChars),
493
+ maxFileBytes: num(o.maxFileBytes, TIER0_DEFAULTS.maxFileBytes),
494
+ estimateMode: o.estimateMode === 'repo' ? 'repo' : 'conservative',
495
+ markTruncation: o.markTruncation === true,
496
+ // C5:per-layer 配额(opt-in;缺省=旧行为,逐字节不变)
497
+ quota: o.quota === true || (o.quota && typeof o.quota === 'object') ? o.quota : false,
498
+ }
499
+ }
500
+
501
+ /**
502
+ * 已读到内存的多来源 → Tier-0 目录(纯函数,无 IO;确定性)。
503
+ *
504
+ * @param {Array<{layer:string, text:string, path?:string}>} sources
505
+ * @param {{maxTokens?:number, estimateMode?:'repo'|'conservative', oneLineChars?:number,
506
+ * titleChars?:number, minChars?:number, markTruncation?:boolean}} [opts]
507
+ * @returns {{text:string, items:Array<{layer:string,status:string,date:string,title:string,oneLine:string}>,
508
+ * tokens:number, maxTokens:number, estimateMode:string, tokenRepo:number, dropped:number,
509
+ * truncated:boolean, units:number, droppedUnits:number, candidates:number,
510
+ * skipped:Array<{layer:string,path:string,reason:string}>}}
511
+ */
512
+ export function buildTier0CatalogFromTextPre(sources, opts = {}) {
513
+ const cfg = resolveOptsPre(opts)
514
+ const list = Array.isArray(sources) ? sources : []
515
+ const candidates = []
516
+ const skipped = []
517
+ let units = 0
518
+ let droppedUnits = 0
519
+ let seq = 0
520
+
521
+ for (const src of list) {
522
+ const layer = clean(src && src.layer)
523
+ const path = clean(src && src.path)
524
+ if (!KNOWN_LAYERS.includes(layer)) {
525
+ skipped.push({ layer, path, reason: 'unknown-layer' })
526
+ continue
527
+ }
528
+ if (!clean(src && src.text)) {
529
+ skipped.push({ layer, path, reason: 'empty' })
530
+ continue
531
+ }
532
+ const r = extractSourcePre(src.text, layer, { path }, cfg)
533
+ units += r.units
534
+ droppedUnits += r.dropped
535
+ if (!r.items.length) {
536
+ skipped.push({ layer, path, reason: 'no-items' })
537
+ continue
538
+ }
539
+ for (const rec of r.items) candidates.push(Object.assign({}, rec, { _seq: seq++, _path: path }))
540
+ }
541
+
542
+ candidates.sort(compareRecordsPre)
543
+
544
+ const picked = []
545
+ let text = ''
546
+ let dropped = 0
547
+ // C5:per-layer 配额(opt-in)。关闭时下面这段 = C4 既有贪心(逐字节不变)。
548
+ const alloc = cfg.quota
549
+ ? allocateTier0QuotaPre(candidates, { maxTokens: cfg.maxTokens, estimateMode: cfg.estimateMode, quota: cfg.quota })
550
+ : null
551
+ if (alloc) {
552
+ for (const rec of alloc.picked) picked.push(rec)
553
+ text = alloc.text
554
+ dropped = alloc.dropped
555
+ } else {
556
+ for (const rec of candidates) {
557
+ const line = renderCatalogLinePre(rec)
558
+ const next = text ? text + '\n' + line : line
559
+ if (estimateTokensPre(next, { mode: cfg.estimateMode }) > cfg.maxTokens) {
560
+ dropped++
561
+ continue
562
+ }
563
+ picked.push(rec)
564
+ text = next
565
+ }
566
+ }
567
+
568
+ if (!picked.length) {
569
+ // I7 精神:目录为空也必须显式发声,不许静默注入空串。
570
+ text = skipped.length
571
+ ? `[Tier-0 目录为空 · 跳过来源 ${skipped.length}]`
572
+ : '[Tier-0 目录为空 · 无可渲染条目]'
573
+ } else if (cfg.markTruncation && dropped > 0) {
574
+ const mark = `…已裁剪 ${dropped} 条(低优先级)`
575
+ if (estimateTokensPre(text + '\n' + mark, { mode: cfg.estimateMode }) <= cfg.maxTokens) {
576
+ text = text + '\n' + mark
577
+ }
578
+ }
579
+
580
+ return {
581
+ text,
582
+ items: picked.map(toPublicItemPre),
583
+ tokens: estimateTokensPre(text, { mode: cfg.estimateMode }),
584
+ maxTokens: cfg.maxTokens,
585
+ estimateMode: cfg.estimateMode,
586
+ tokenRepo: estimateTokensPre(text, { mode: 'repo' }),
587
+ dropped,
588
+ truncated: dropped > 0,
589
+ units,
590
+ droppedUnits,
591
+ candidates: candidates.length,
592
+ skipped,
593
+ // C5 配额账(quota 关闭时 quota=null,键仍在,调用方无需判空取法)
594
+ quota: alloc
595
+ ? {
596
+ applied: true,
597
+ projectRatio: alloc.projectRatio,
598
+ floorRatio: alloc.floorRatio,
599
+ floorLayers: alloc.floorLayers,
600
+ caps: alloc.caps,
601
+ floors: alloc.floors,
602
+ perLayer: alloc.perLayer,
603
+ droppedByLayer: alloc.droppedByLayer,
604
+ degradedLayers: alloc.degradedLayers,
605
+ emptyLayers: alloc.emptyLayers,
606
+ }
607
+ : null,
608
+ }
609
+ }
610
+
611
+ /**
612
+ * 读文本文件(容错,永不抛错)。
613
+ * @param {string} p
614
+ * @param {{maxFileBytes?:number}} [opts]
615
+ * @returns {{ok:boolean, text:string, path:string, reason:string}}
616
+ */
617
+ export function readTextSafePre(p, opts = {}) {
618
+ const maxBytes = Number((opts && opts.maxFileBytes) || TIER0_DEFAULTS.maxFileBytes) || TIER0_DEFAULTS.maxFileBytes
619
+ const path = typeof p === 'string' ? p.trim() : ''
620
+ const fail = (reason) => ({ ok: false, text: '', path, reason })
621
+ if (!path) return fail('missing-path')
622
+ let st = null
623
+ try {
624
+ st = statSync(path)
625
+ } catch (e) {
626
+ return fail(e && e.code === 'ENOENT' ? 'not-found' : 'stat-error')
627
+ }
628
+ try {
629
+ if (st.isDirectory()) return fail('not-a-file')
630
+ if (Number(st.size) > maxBytes) return fail('too-large')
631
+ } catch (e) {
632
+ return fail('stat-error')
633
+ }
634
+ try {
635
+ const text = stripBom(readFileSync(path, 'utf8'))
636
+ if (!text.trim()) return fail('empty')
637
+ return { ok: true, text, path, reason: '' }
638
+ } catch (e) {
639
+ return fail('read-error')
640
+ }
641
+ }
642
+
643
+ /**
644
+ * Tier-0 目录生成器(主入口,含 IO)。四类路径**都可能不存在**,逐一路径容错并计数。
645
+ *
646
+ * @param {{userMemoryPath?:string, workspaceMemoryPath?:string, todayLogPath?:string,
647
+ * handoffPlanPath?:string, extraPaths?:Array<{path:string, layer:string}>, [k:string]:any}} input
648
+ * @param {object} [opts] 同 buildTier0CatalogFromTextPre(另加 maxFileBytes)
649
+ * @returns {ReturnType<typeof buildTier0CatalogFromTextPre>}
650
+ */
651
+ export function buildTier0CatalogPre(input = {}, opts = {}) {
652
+ const cfg = resolveOptsPre(opts)
653
+ const src = input && typeof input === 'object' ? input : {}
654
+ const sources = []
655
+ const skipped = []
656
+
657
+ for (const spec of SOURCE_SPECS) {
658
+ const r = readTextSafePre(src[spec.key], cfg)
659
+ if (!r.ok) {
660
+ skipped.push({ layer: spec.layer, path: String(src[spec.key] == null ? '' : src[spec.key]), reason: r.reason })
661
+ continue
662
+ }
663
+ sources.push({ layer: spec.layer, text: r.text, path: r.path })
664
+ }
665
+
666
+ // 扩展点(C5 接反思目录等):显式给出 layer,避免路径猜层。
667
+ const extra = Array.isArray(src.extraPaths) ? src.extraPaths : []
668
+ for (const e of extra) {
669
+ const layer = clean(e && e.layer)
670
+ const r = readTextSafePre(e && e.path, cfg)
671
+ if (!r.ok) {
672
+ skipped.push({ layer, path: String((e && e.path) == null ? '' : e.path), reason: r.reason })
673
+ continue
674
+ }
675
+ sources.push({ layer, text: r.text, path: r.path })
676
+ }
677
+
678
+ const out = buildTier0CatalogFromTextPre(sources, cfg)
679
+ const merged = out.skipped.concat(skipped)
680
+ // 目录为空时,空标记必须用**合并后**的跳过数重算(IO 层的跳过发生在纯函数之外)。
681
+ if (!out.items.length) {
682
+ const text = merged.length
683
+ ? `[Tier-0 目录为空 · 跳过来源 ${merged.length}]`
684
+ : '[Tier-0 目录为空 · 无可渲染条目]'
685
+ return Object.assign({}, out, {
686
+ text,
687
+ tokens: estimateTokensPre(text, { mode: cfg.estimateMode }),
688
+ tokenRepo: estimateTokensPre(text, { mode: 'repo' }),
689
+ skipped: merged,
690
+ })
691
+ }
692
+ return Object.assign({}, out, { skipped: merged })
693
+ }