@a9i5k4/dsh-auto-memory 3.0.0 → 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 (90) hide show
  1. package/README.md +19 -7
  2. package/README.zh-CN.md +19 -7
  3. package/docs/FRONTEND-CO-CREATION.md +191 -0
  4. package/docs/GM53-HOMEPAGE-PROMPT.md +323 -0
  5. package/docs/HOMEPAGE-CONTENT-FOR-GM53.md +299 -0
  6. package/docs/PROMO-PROMPT-3.0.md +100 -0
  7. package/docs/USER-GUIDE.en.md +2 -2
  8. package/docs/USER-GUIDE.zh-CN.md +2 -2
  9. package/docs/WHITEPAPER.md +207 -0
  10. package/docs/internal/ARCHITECTURE-FOR-ZCODE-20260920.md +397 -0
  11. package/docs/internal/ART-DIRECTION-DEEPSEEK-20260920.md +351 -0
  12. package/docs/internal/ART-DIRECTION-WIREFRAME.md +191 -181
  13. package/docs/internal/ART-DIRECTION-WIREFRAME.md.bak-superseded +181 -0
  14. package/docs/internal/BATTLE-PLAN-20260917.md +871 -0
  15. package/docs/internal/FEATURE-INVENTORY.md +531 -0
  16. package/docs/internal/G-SERIES-EXECUTION-20260917.md +248 -0
  17. package/docs/internal/G3-DESIGN-20260918.md +82 -0
  18. package/docs/internal/G3-DISK-FORMAT-GAP-20260919.md +92 -0
  19. package/docs/internal/HANDOFF-TO-ZCODE-20260920.md +309 -0
  20. package/docs/internal/HERMES-DATA-VERIFICATION-20260919.md +120 -0
  21. package/docs/internal/HERMES-LEGACY-STATUS-20260919.md +74 -0
  22. package/docs/internal/ISSUE-55-58-VERIFICATION-20260918.md +175 -0
  23. package/docs/internal/ISSUE10-FIX-EXECUTION-20260919.md +389 -0
  24. package/docs/internal/ISSUE10-PLAN-20260919.md +254 -0
  25. package/docs/internal/ISSUE10B-FORENSICS-20260919.md +468 -0
  26. package/docs/internal/ISSUE9-PURGE-AND-R1-PLAIN-20260919.md +150 -0
  27. package/docs/internal/ISSUE9-RESIDUAL-FORENSICS-20260919.md +114 -0
  28. package/docs/internal/LESSON-TO-CANDIDATE-STATUS-20260919.md +79 -0
  29. package/docs/internal/MEMORY-GOVERNANCE-20260917.md +309 -0
  30. package/docs/internal/PRE-FRONTEND-CHECKLIST-20260919.md +705 -0
  31. package/docs/internal/PRE-FRONTEND-CHECKLIST-20260919.md.bak-s10 +649 -0
  32. package/docs/internal/PROCEDURAL-MEMORY-AND-APPROVAL-DESIGN-20260918.md +225 -0
  33. package/docs/internal/PROGRESS-20260917.md +93 -0
  34. package/docs/internal/PROMPT-GAP-AUDIT-20260920.md +128 -0
  35. package/docs/internal/R1-DEGRADE-AUDIT-20260918.md +163 -0
  36. package/docs/internal/R1-READABILITY-FORENSICS-20260919.md +127 -0
  37. package/docs/internal/R2-EVIDENCE-DEEP-AUDIT-20260918.md +140 -0
  38. package/docs/internal/R3-DEGRADE-LEDGER-DESIGN-20260918.md +138 -0
  39. package/docs/internal/R4-RECALL-QUOTA-PLAN-20260918.md +218 -0
  40. package/docs/internal/RESUME-20260918.md +171 -0
  41. package/docs/internal/RESUME-20260919.md +104 -0
  42. package/docs/internal/RHINELAB-TO-DEEPSEEK-FEASIBILITY.md +198 -0
  43. package/docs/internal/ROADMAP-20260917-WEEK.md +134 -0
  44. package/docs/internal/S10-CONSTRUCTION-HANDOFF-20260917.md +13 -3
  45. package/docs/internal/S10-GAP-INVENTORY-20260917.md +239 -0
  46. package/docs/internal/T6-EXECUTION-20260920.md +130 -0
  47. package/docs/internal/TELEMETRY-EFFECT-REPORT-DESIGN-20260918.md +146 -0
  48. package/docs/internal/THESIS-GAP-ANALYSIS-20260918.md +89 -0
  49. package/docs/internal/THESIS-OUTLINE-20260918.md +147 -0
  50. package/docs/internal/THREE-LAYER-CONTRACT.md +10 -1
  51. package/docs/internal/UPSTREAM-ISSUE-PR-TRIAGE-20260919.md +297 -0
  52. package/docs/internal/UPSTREAM-ISSUES-3RD-AUDIT-20260920.md +104 -0
  53. package/docs/screenshots/promo/promo-0-banner-v3.png +0 -0
  54. package/lib/activation-host.js +63 -9
  55. package/lib/board-mode.js +1 -1
  56. package/lib/client.js +892 -27
  57. package/lib/config-io.js +156 -0
  58. package/lib/context-bridge.js +3 -0
  59. package/lib/context-host.js +16 -9
  60. package/lib/degrade.js +385 -0
  61. package/lib/dsh-home.js +143 -0
  62. package/lib/episodic-store.js +52 -2
  63. package/lib/evidence-store.js +8 -1
  64. package/lib/fact-store.js +21 -2
  65. package/lib/index-sync.js +13 -1
  66. package/lib/index.js +1507 -158
  67. package/lib/intent-clean-safe.js +258 -40
  68. package/lib/l0-extract.js +231 -16
  69. package/lib/m4-corpus.js +8 -2
  70. package/lib/m7-index-sync-host.js +8 -1
  71. package/lib/memory-envelope.js +6 -1
  72. package/lib/memory-hub.js +127 -12
  73. package/lib/memory-index.js +4 -2
  74. package/lib/note-status-apply.js +118 -0
  75. package/lib/note-status.js +196 -0
  76. package/lib/procedure-store.js +84 -3
  77. package/lib/python-sidecar-client.js +29 -3
  78. package/lib/recall-fusion.js +83 -12
  79. package/lib/rules-edit.js +159 -0
  80. package/lib/semantic-decide.js +41 -8
  81. package/lib/semantic-js.js +51 -6
  82. package/lib/shadow-host.js +3 -5
  83. package/lib/skill-export-host.js +153 -0
  84. package/lib/skill-export.js +239 -0
  85. package/lib/storage-manage.js +6 -0
  86. package/lib/temporal-parse.js +191 -159
  87. package/lib/tier0-catalog.js +45 -3
  88. package/lib/wb-contract.js +198 -2
  89. package/lib/wb-sidecar.js +54 -3
  90. package/package.json +1 -1
@@ -1,159 +1,191 @@
1
- /**
2
- * temporal-parse-pre —— 中文时间表达解析(P12 后新增时间检索臂 Phase 1, 2026-09-09)。
3
- *
4
- * 目标:把查询里的中文时间表达("上周""三天前""最近一周""上个月"等)解析为
5
- * [startMs, endMs) 时间范围,供检索的软性第三臂(time arm)做 rank-space 提升。
6
- * 查询无时间表达 → 返回 null → 调用方零行为变更。
7
- *
8
- * 边界:纯函数、零 IO、零依赖;禁止 LLM/第三方日期库;确定性(同输入同 now 逐字节相同输出)。
9
- * 时刻必须经 opts.now 注入(缺省 Date.now() 仅为便利;测试一律注入)。
10
- *
11
- * 时区假设(显式声明):全部自然日/周/月/年边界用宿主**本地时间**计算
12
- * (new Date(y,m,d) 系列本地方法)。部署机时区为 Asia/Shanghai(CST,UTC+8,无夏令时),
13
- * 与记忆日志文件名日期(本地日期)一致;若部署到其他时区,范围语义跟随宿主本地时区。
14
- */
15
-
16
- /** 中文数字表(支持零〇一二两三四五六七八九十百,≤999,够 366 上限)。 */
17
- const CN_DIGIT = { '零': 0, '〇': 0, '一': 1, '二': 2, '两': 2, '三': 3, '四': 4, '五': 5, '六': 6, '七': 7, '八': 8, '九': 9 }
18
-
19
- /** N 的合法区间(含端点);超界视为未识别。 */
20
- const N_MIN = 1
21
- const N_MAX = 366
22
-
23
- const DAY_MS = 86400000
24
-
25
- /** 中文/阿拉伯数字 → 整数;非法返回 NaN。 */
26
- function numToInt(s) {
27
- if (/^\d{1,3}$/.test(s)) return parseInt(s, 10)
28
- let total = 0
29
- let num = 0
30
- for (const ch of String(s)) {
31
- if (ch === '十') { total += (num || 1) * 10; num = 0 }
32
- else if (ch === '百') { total += (num || 1) * 100; num = 0 }
33
- else if (ch === '零' || ch === '〇') { /* 占位,跳过 */ }
34
- else if (CN_DIGIT[ch] !== undefined) { num = CN_DIGIT[ch] }
35
- else return NaN
36
- }
37
- const out = total + num
38
- return out > 0 ? out : NaN
39
- }
40
-
41
- /** 本地自然日 00:00 的毫秒时间戳。 */
42
- function startOfDay(d) {
43
- return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()
44
- }
45
-
46
- /** 本地自然周(周一 00:00 起)的毫秒时间戳。 */
47
- function startOfWeek(d) {
48
- const monday = new Date(d.getFullYear(), d.getMonth(), d.getDate())
49
- monday.setDate(monday.getDate() - ((monday.getDay() + 6) % 7))
50
- return monday.getTime()
51
- }
52
-
53
- /** 本地自然月 1 日 00:00 的毫秒时间戳。 */
54
- function startOfMonth(d) {
55
- return new Date(d.getFullYear(), d.getMonth(), 1).getTime()
56
- }
57
-
58
- /** 按本地月序号(可为负,自动借位)取自然月起点。 */
59
- function monthStartShift(d, shift) {
60
- return new Date(d.getFullYear(), d.getMonth() + shift, 1).getTime()
61
- }
62
-
63
- /** 从 d 往前/后 n 个日历月,锚定同一日与时分秒(月末溢出由 Date 自动进位,确定性)。 */
64
- function nowShiftMonths(d, n) {
65
- return new Date(d.getFullYear(), d.getMonth() + n, d.getDate(), d.getHours(), d.getMinutes(), d.getSeconds(), d.getMilliseconds()).getTime()
66
- }
67
-
68
- /** 日期 d 所在月的下个月起点。 */
69
- function nextMonthStart(d) {
70
- return new Date(d.getFullYear(), d.getMonth() + 1, 1).getTime()
71
- }
72
-
73
- /**
74
- * 解析查询文本中的中文时间表达。
75
- *
76
- * @param {string} text 查询文本
77
- * @param {{now?:number}} [opts] now:毫秒时间戳(注入以保证确定性;缺省 Date.now())
78
- * @returns {null | {startMs:number, endMs:number, matched:string}}
79
- * null=未识别到时间表达(调用方零行为变更);否则为半开区间 [startMs, endMs)
80
- * 与命中的原文子串 matched。多个表达命中时取**首个**(按下方模式表顺序,确定性)。
81
- */
82
- export function parseTemporalQueryPre(text, opts = {}) {
83
- try {
84
- if (typeof text !== 'string' || !text) return null
85
- const nowMs = Number.isFinite(opts.now) ? opts.now : Date.now()
86
- const now = new Date(nowMs)
87
- const today0 = startOfDay(now)
88
- // 模式表(顺序即优先级:长/特异表达在前,防止子串误配,如"大前天"先于"前天"、"上上周"先于"上周")
89
- const patterns = [
90
- { re: /大前天/, fn: () => ({ startMs: today0 - 3 * DAY_MS, endMs: today0 - 2 * DAY_MS }) },
91
- { re: /前天/, fn: () => ({ startMs: today0 - 2 * DAY_MS, endMs: today0 - DAY_MS }) },
92
- { re: /昨天/, fn: () => ({ startMs: today0 - DAY_MS, endMs: today0 }) },
93
- { re: /今天/, fn: () => ({ startMs: today0, endMs: today0 + DAY_MS }) },
94
- { re: /上上周/, fn: () => ({ startMs: startOfWeek(now) - 14 * DAY_MS, endMs: startOfWeek(now) - 7 * DAY_MS }) },
95
- { re: /上周/, fn: () => ({ startMs: startOfWeek(now) - 7 * DAY_MS, endMs: startOfWeek(now) }) },
96
- { re: /本周|这一周|这个星期/, fn: () => ({ startMs: startOfWeek(now), endMs: startOfWeek(now) + 7 * DAY_MS }) },
97
- { re: /上个月|上月/, fn: () => ({ startMs: monthStartShift(now, -1), endMs: startOfMonth(now) }) },
98
- { re: /本月|这个月/, fn: () => ({ startMs: startOfMonth(now), endMs: nextMonthStart(now) }) },
99
- { re: /去年/, fn: () => ({ startMs: new Date(now.getFullYear() - 1, 0, 1).getTime(), endMs: new Date(now.getFullYear(), 0, 1).getTime() }) },
100
- { re: /今年/, fn: () => ({ startMs: new Date(now.getFullYear(), 0, 1).getTime(), endMs: new Date(now.getFullYear() + 1, 0, 1).getTime() }) },
101
- // 最近 N 个自然周期:从 now 往前 N 个周期(半开区间 [now-N周期, now));月按日历月锚定 now 时刻
102
- { re: /最近\s*([0-9零〇一二两三四五六七八九十百]+)\s*个?\s*月/, fn: (m) => {
103
- const n = numToInt(m[1]); if (!Number.isInteger(n) || n < N_MIN || n > N_MAX) return null
104
- return { startMs: nowShiftMonths(now, -n), endMs: nowMs }
105
- } },
106
- { re: /最近\s*([0-9零〇一二两三四五六七八九十百]+)\s*个?\s*(?:星期|周)/, fn: (m) => {
107
- const n = numToInt(m[1]); if (!Number.isInteger(n) || n < N_MIN || n > N_MAX) return null
108
- return { startMs: nowMs - n * 7 * DAY_MS, endMs: nowMs }
109
- } },
110
- { re: /最近\s*([0-9零〇一二两三四五六七八九十百]+)\s*(?:天|日)/, fn: (m) => {
111
- const n = numToInt(m[1]); if (!Number.isInteger(n) || n < N_MIN || n > N_MAX) return null
112
- return { startMs: nowMs - n * DAY_MS, endMs: nowMs }
113
- } },
114
- // N 个月前:定位到那个自然月(整月)
115
- { re: /([0-9零〇一二两三四五六七八九十百]+)\s*个?\s*月前/, fn: (m) => {
116
- const n = numToInt(m[1]); if (!Number.isInteger(n) || n < N_MIN || n > N_MAX) return null
117
- return { startMs: monthStartShift(now, -n), endMs: monthStartShift(now, -n + 1) }
118
- } },
119
- // N 周(星期)前:定位到那个自然周(周一 00:00 起整周)
120
- { re: /([0-9零〇一二两三四五六七八九十百]+)\s*个?\s*(?:星期|周)前/, fn: (m) => {
121
- const n = numToInt(m[1]); if (!Number.isInteger(n) || n < N_MIN || n > N_MAX) return null
122
- const base = startOfWeek(now) - n * 7 * DAY_MS
123
- return { startMs: base, endMs: base + 7 * DAY_MS }
124
- } },
125
- // N 天(日)前:定位到那个自然日 00:00–24:00
126
- { re: /([0-9零〇一二两三四五六七八九十百]+)\s*(?:天|日)前/, fn: (m) => {
127
- const n = numToInt(m[1]); if (!Number.isInteger(n) || n < N_MIN || n > N_MAX) return null
128
- const base = today0 - n * DAY_MS
129
- return { startMs: base, endMs: base + DAY_MS }
130
- } },
131
- ]
132
- for (const p of patterns) {
133
- const m = p.re.exec(text)
134
- if (!m) continue
135
- const range = p.fn(m)
136
- if (!range) continue
137
- return { startMs: range.startMs, endMs: range.endMs, matched: m[0] }
138
- }
139
- return null
140
- } catch (_) {
141
- return null
142
- }
143
- }
144
-
145
- /**
146
- * 从 L0 候选 label 抽取候选时间戳(当日 00:00 本地)。
147
- * label 形如日志/反思文件名(含 YYYY-MM-DD,如 "2026-09-09.md"、"reflections/2026-08-19.md");
148
- * 非日期来源(".../MEMORY.md"、"~/MEMORY.md")→ null → 时间臂对该候选不参与(中性)。
149
- */
150
- export function labelToDateMsPre(label) {
151
- try {
152
- const m = /(\d{4})-(\d{2})-(\d{2})/.exec(String(label || ''))
153
- if (!m) return null
154
- const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]))
155
- return Number.isFinite(d.getTime()) ? d.getTime() : null
156
- } catch (_) {
157
- return null
158
- }
159
- }
1
+ /**
2
+ * temporal-parse-pre —— 中文时间表达解析(P12 后新增时间检索臂 Phase 1, 2026-09-09)。
3
+ *
4
+ * 目标:把查询里的中文时间表达("上周""三天前""最近一周""上个月"等)解析为
5
+ * [startMs, endMs) 时间范围,供检索的软性第三臂(time arm)做 rank-space 提升。
6
+ * 查询无时间表达 → 返回 null → 调用方零行为变更。
7
+ *
8
+ * 边界:纯函数、零 IO、零依赖;禁止 LLM/第三方日期库;确定性(同输入同 now 逐字节相同输出)。
9
+ * 时刻必须经 opts.now 注入(缺省 Date.now() 仅为便利;测试一律注入)。
10
+ *
11
+ * 时区假设(显式声明):全部自然日/周/月/年边界用宿主**本地时间**计算
12
+ * (new Date(y,m,d) 系列本地方法)。部署机时区为 Asia/Shanghai(CST,UTC+8,无夏令时),
13
+ * 与记忆日志文件名日期(本地日期)一致;若部署到其他时区,范围语义跟随宿主本地时区。
14
+ */
15
+
16
+ /** 中文数字表(支持零〇一二两三四五六七八九十百,≤999,够 366 上限)。 */
17
+ const CN_DIGIT = { '零': 0, '〇': 0, '一': 1, '二': 2, '两': 2, '三': 3, '四': 4, '五': 5, '六': 6, '七': 7, '八': 8, '九': 9 }
18
+
19
+ /** N 的合法区间(含端点);超界视为未识别。 */
20
+ const N_MIN = 1
21
+ const N_MAX = 366
22
+
23
+ const DAY_MS = 86400000
24
+
25
+ /** 中文/阿拉伯数字 → 整数;非法返回 NaN。 */
26
+ function numToInt(s) {
27
+ if (/^\d{1,3}$/.test(s)) return parseInt(s, 10)
28
+ let total = 0
29
+ let num = 0
30
+ for (const ch of String(s)) {
31
+ if (ch === '十') { total += (num || 1) * 10; num = 0 }
32
+ else if (ch === '百') { total += (num || 1) * 100; num = 0 }
33
+ else if (ch === '零' || ch === '〇') { /* 占位,跳过 */ }
34
+ else if (CN_DIGIT[ch] !== undefined) { num = CN_DIGIT[ch] }
35
+ else return NaN
36
+ }
37
+ const out = total + num
38
+ return out > 0 ? out : NaN
39
+ }
40
+
41
+ /** 本地自然日 00:00 的毫秒时间戳。 */
42
+ function startOfDay(d) {
43
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()
44
+ }
45
+
46
+ /** 本地自然周(周一 00:00 起)的毫秒时间戳。 */
47
+ function startOfWeek(d) {
48
+ const monday = new Date(d.getFullYear(), d.getMonth(), d.getDate())
49
+ monday.setDate(monday.getDate() - ((monday.getDay() + 6) % 7))
50
+ return monday.getTime()
51
+ }
52
+
53
+ /** 本地自然月 1 日 00:00 的毫秒时间戳。 */
54
+ function startOfMonth(d) {
55
+ return new Date(d.getFullYear(), d.getMonth(), 1).getTime()
56
+ }
57
+
58
+ /** 按本地月序号(可为负,自动借位)取自然月起点。 */
59
+ function monthStartShift(d, shift) {
60
+ return new Date(d.getFullYear(), d.getMonth() + shift, 1).getTime()
61
+ }
62
+
63
+ /**
64
+ * 从 d 往前/后 n 个日历月,锚定同一日与时分秒。
65
+ * ★ issue #74 CB-6 修复(2026-09-19):**月末钳制**。
66
+ * 旧实现直接 `new Date(y, m+n, d)` ⇒ d=31 而目标月只有 30 天时**自动进位到下月 1 日**
67
+ * ⇒ now=2026-03-31 的「最近一个月」起点变成 03-03(而非 02-28/03-01),窗口缩水 2–3 天,
68
+ * 且 29/30/31 日**周期性复发**;而「N 个月前」走 `monthStartShift(day=1)` 无此病
69
+ * ⇒ 两个时间臂语义不一致。现钳制到目标月最后一天(与「同一天,不存在则取月末」的直觉一致)。
70
+ */
71
+ function nowShiftMonths(d, n) {
72
+ const y = d.getFullYear()
73
+ const m = d.getMonth() + n
74
+ // 目标月的最后一天:把「下月第 0 天」交给 Date 归一化即得
75
+ const lastDayOfTarget = new Date(y, m + 1, 0).getDate()
76
+ const day = Math.min(d.getDate(), lastDayOfTarget)
77
+ return new Date(y, m, day, d.getHours(), d.getMinutes(), d.getSeconds(), d.getMilliseconds()).getTime()
78
+ }
79
+
80
+ /** 日期 d 所在月的下个月起点。 */
81
+ function nextMonthStart(d) {
82
+ return new Date(d.getFullYear(), d.getMonth() + 1, 1).getTime()
83
+ }
84
+
85
+ /**
86
+ * 解析查询文本中的中文时间表达。
87
+ *
88
+ * @param {string} text 查询文本
89
+ * @param {{now?:number}} [opts] now:毫秒时间戳(注入以保证确定性;缺省 Date.now())
90
+ * @returns {null | {startMs:number, endMs:number, matched:string}}
91
+ * null=未识别到时间表达(调用方零行为变更);否则为半开区间 [startMs, endMs)
92
+ * 与命中的原文子串 matched。多个表达命中时取**首个**(按下方模式表顺序,确定性)。
93
+ */
94
+ export function parseTemporalQueryPre(text, opts = {}) {
95
+ try {
96
+ if (typeof text !== 'string' || !text) return null
97
+ const nowMs = Number.isFinite(opts.now) ? opts.now : Date.now()
98
+ const now = new Date(nowMs)
99
+ const today0 = startOfDay(now)
100
+ // 模式表(顺序即优先级:长/特异表达在前,防止子串误配,如"大前天"先于"前天"、"上上周"先于"上周")
101
+ const patterns = [
102
+ { re: /大前天/, fn: () => ({ startMs: today0 - 3 * DAY_MS, endMs: today0 - 2 * DAY_MS }) },
103
+ { re: /前天/, fn: () => ({ startMs: today0 - 2 * DAY_MS, endMs: today0 - DAY_MS }) },
104
+ { re: /昨天/, fn: () => ({ startMs: today0 - DAY_MS, endMs: today0 }) },
105
+ { re: /今天/, fn: () => ({ startMs: today0, endMs: today0 + DAY_MS }) },
106
+ { re: /上上周/, fn: () => ({ startMs: startOfWeek(now) - 14 * DAY_MS, endMs: startOfWeek(now) - 7 * DAY_MS }) },
107
+ { re: /上周/, fn: () => ({ startMs: startOfWeek(now) - 7 * DAY_MS, endMs: startOfWeek(now) }) },
108
+ { re: /本周|这一周|这个星期/, fn: () => ({ startMs: startOfWeek(now), endMs: startOfWeek(now) + 7 * DAY_MS }) },
109
+ { re: /上个月|上月/, fn: () => ({ startMs: monthStartShift(now, -1), endMs: startOfMonth(now) }) },
110
+ { re: /本月|这个月/, fn: () => ({ startMs: startOfMonth(now), endMs: nextMonthStart(now) }) },
111
+ { re: /去年/, fn: () => ({ startMs: new Date(now.getFullYear() - 1, 0, 1).getTime(), endMs: new Date(now.getFullYear(), 0, 1).getTime() }) },
112
+ { re: /今年/, fn: () => ({ startMs: new Date(now.getFullYear(), 0, 1).getTime(), endMs: new Date(now.getFullYear() + 1, 0, 1).getTime() }) },
113
+ // 最近 N 个自然周期:从 now 往前 N 个周期(半开区间 [now-N周期, now));月按日历月锚定 now 时刻
114
+ { re: /最近\s*([0-9零〇一二两三四五六七八九十百]+)\s*个?\s*月/, fn: (m) => {
115
+ const n = numToInt(m[1]); if (!Number.isInteger(n) || n < N_MIN || n > N_MAX) return null
116
+ return { startMs: nowShiftMonths(now, -n), endMs: nowMs }
117
+ } },
118
+ { re: /最近\s*([0-9零〇一二两三四五六七八九十百]+)\s*个?\s*(?:星期|周)/, fn: (m) => {
119
+ const n = numToInt(m[1]); if (!Number.isInteger(n) || n < N_MIN || n > N_MAX) return null
120
+ return { startMs: nowMs - n * 7 * DAY_MS, endMs: nowMs }
121
+ } },
122
+ { re: /最近\s*([0-9零〇一二两三四五六七八九十百]+)\s*(?:天|日)/, fn: (m) => {
123
+ const n = numToInt(m[1]); if (!Number.isInteger(n) || n < N_MIN || n > N_MAX) return null
124
+ return { startMs: nowMs - n * DAY_MS, endMs: nowMs }
125
+ } },
126
+ // N 个月前:定位到那个自然月(整月)
127
+ { re: /([0-9零〇一二两三四五六七八九十百]+)\s*个?\s*月前/, fn: (m) => {
128
+ const n = numToInt(m[1]); if (!Number.isInteger(n) || n < N_MIN || n > N_MAX) return null
129
+ return { startMs: monthStartShift(now, -n), endMs: monthStartShift(now, -n + 1) }
130
+ } },
131
+ // N 周(星期)前:定位到那个自然周(周一 00:00 起整周)
132
+ { re: /([0-9零〇一二两三四五六七八九十百]+)\s*个?\s*(?:星期|周)前/, fn: (m) => {
133
+ const n = numToInt(m[1]); if (!Number.isInteger(n) || n < N_MIN || n > N_MAX) return null
134
+ const base = startOfWeek(now) - n * 7 * DAY_MS
135
+ return { startMs: base, endMs: base + 7 * DAY_MS }
136
+ } },
137
+ // N 天(日)前:定位到那个自然日 00:00–24:00
138
+ { re: /([0-9零〇一二两三四五六七八九十百]+)\s*(?:天|日)前/, fn: (m) => {
139
+ const n = numToInt(m[1]); if (!Number.isInteger(n) || n < N_MIN || n > N_MAX) return null
140
+ const base = today0 - n * DAY_MS
141
+ return { startMs: base, endMs: base + DAY_MS }
142
+ } },
143
+ ]
144
+ for (const p of patterns) {
145
+ const m = p.re.exec(text)
146
+ if (!m) continue
147
+ // ★ issue #74 CC-3a 修复(2026-09-19):**模糊量词不得落精确日期**。
148
+ // 旧行为:`几十天前` 里数字类从「十」起匹配 ⇒ `matched='十天前'` ⇒ 被当**精确** -10 天
149
+ // (实测 startMs=now-10d 单日区间),而用户说的是「几十」(不确定量)⇒ 语义错位。
150
+ // 现加一道统一守卫:数字捕获组**紧跟「几」之后**即视为模糊表达,本模式不适用
151
+ // (`几天前`/`几个月前` 本就不匹配数字类;此处补的是 `几十天前`/`几十个月前` 这类
152
+ // 「几 + 数字字」被数字类从中间截断匹配的形态)。不落精确日期 ⇒ 落 null(未识别)。
153
+ if (m.index > 0 && text[m.index - 1] === '几') continue
154
+ const range = p.fn(m)
155
+ if (!range) continue
156
+ return { startMs: range.startMs, endMs: range.endMs, matched: m[0] }
157
+ }
158
+ return null
159
+ } catch (_) {
160
+ return null
161
+ }
162
+ }
163
+
164
+ /**
165
+ * 从 L0 候选 label 抽取候选时间戳(当日 00:00 本地)。
166
+ * label 形如日志/反思文件名(含 YYYY-MM-DD,如 "2026-09-09.md"、"reflections/2026-08-19.md");
167
+ * 非日期来源(".../MEMORY.md"、"~/MEMORY.md")→ null → 时间臂对该候选不参与(中性)。
168
+ */
169
+ export function labelToDateMsPre(label) {
170
+ try {
171
+ // ★ issue #74 CC-3b 修复(2026-09-19):正则**锚定日期形态**(原先裸露的
172
+ // `/(\d{4})-(\d{2})-(\d{2})/` 会从 `x2026-09-09y`、`v1-2026-09-09-extra` 这类
173
+ // 任意含该子串的 label 里**抽取伪日期**)。现要求日期处于**边界**:
174
+ // 前置 = 串首 或 分隔符(`/`、`\`、`.`、`-`、`_`、空格);
175
+ // 后置 = 串尾 或 扩展名/分隔符起点。
176
+ // 真实生产者形态全覆盖:`2026-09-09.md`、`reflections/2026-08-19.md`、`…/MEMORY.md`(不匹配→null)。
177
+ const m = /(^|[\/\\.\-_ ])(\d{4})-(\d{2})-(\d{2})(?=$|[.\\/_\-\s])/.exec(String(label || ''))
178
+ if (!m) return null
179
+ const Y = Number(m[2]); const Mo = Number(m[3]); const D = Number(m[4])
180
+ // ★ issue #74 CC-3b(第二半):**日期分量范围校验**。旧实现 `new Date(2026, 12, 45)`
181
+ // 会被 Date **静默进位**成 2027-02-14(实测)⇒ 非法日期伪装成合法时间戳。
182
+ // 现显式拒绝越界分量,并**回读校验**(防闰年外的 2/30 等被进位)。
183
+ if (!(Mo >= 1 && Mo <= 12)) return null
184
+ if (!(D >= 1 && D <= 31)) return null
185
+ const d = new Date(Y, Mo - 1, D)
186
+ if (d.getFullYear() !== Y || d.getMonth() !== Mo - 1 || d.getDate() !== D) return null
187
+ return Number.isFinite(d.getTime()) ? d.getTime() : null
188
+ } catch (_) {
189
+ return null
190
+ }
191
+ }
@@ -46,6 +46,19 @@ import { readFileSync, statSync } from 'node:fs'
46
46
 
47
47
  export const TIER0_CATALOG_VERSION = 'tier0_catalog_v1'
48
48
 
49
+ /**
50
+ * ★M1(2026-09-18):白板锚点 id 的**形态判据**(契约 §2)。
51
+ *
52
+ * 与 `wb-sidecar.js:466 WB_ANCHOR_LINE_RE_V1`、契约 §2 的
53
+ * `^mem_[0-9a-f]{32}$` **同源同形**。此处**本地定义而非 import**:
54
+ * - tier0-catalog 是本仓最底层的目录渲染模块,不应反向依赖 wb-sidecar(避免耦合/循环);
55
+ * - 两处形态由**同一份契约文档**约束,套件断言两者**逐字相同**(防双源漂移)。
56
+ *
57
+ * 用途:**只在匹配时才走契约 §3 的链接形态**;id 形态非法则退回纯文本
58
+ * —— fail-closed:宁可输出纯文本,也不产出**指向不存在锚点的坏链接**。
59
+ */
60
+ export const TIER0_ANCHOR_ID_RE = /^mem_[0-9a-f]{32}$/
61
+
49
62
  export const TIER0_DEFAULTS = Object.freeze({
50
63
  maxTokens: 800,
51
64
  oneLineChars: 80,
@@ -312,17 +325,46 @@ function extractSourcePre(text, layer, ctx, cfg) {
312
325
  return { items, units: groups.length, dropped }
313
326
  }
314
327
 
315
- /** 一条目录项 → 一行文本。**导出**:渲染口径的唯一实现。 */
328
+ /**
329
+ * 一条目录项 → 一行文本。**导出**:渲染口径的唯一实现。
330
+ *
331
+ * ★M1 契约行渲染(2026-09-18):**同一次派生、两种渲染**。
332
+ * - 无 `path`/`id` ⇒ 纯文本(**逐字节同旧实现**,老调用方与老套件零影响)
333
+ * - 有 `path`+`id` ⇒ 契约 §3 链接形态:
334
+ * `- [标题](<页面路径>#mem_<32hex>) — 一句话摘要 · layer=whiteboard · status=current · 日期`
335
+ *
336
+ * 为什么是"两种渲染"而不是"新文件":契约 §8 要求
337
+ * 「由页面派生出的 index 与 Tier-0 目录条目**一致**」——
338
+ * 两者出自**同一份 items**、同一个渲染函数,一致性**天然成立**,不需要额外同步机制。
339
+ * (新建独立文件会新增状态源,违反 S10.4「不建状态机」。)
340
+ */
316
341
  export function renderCatalogLinePre(item) {
317
342
  const it = item && typeof item === 'object' ? item : {}
318
343
  const title = clean(it.title)
319
344
  const oneLine = clean(it.oneLine)
320
345
  const date = clean(it.date)
346
+ const layer = clean(it.layer) || 'log'
347
+ const status = clean(it.status) || 'current'
348
+
349
+ // ── 契约 §3 链接形态(仅当 path + id 齐备时启用)──
350
+ const p = clean(it.path)
351
+ const id = clean(it.id)
352
+ if (p && id && TIER0_ANCHOR_ID_RE.test(id)) {
353
+ const head = title || oneLine
354
+ const link = '[' + head + '](' + p + '#' + id + ')'
355
+ const segs = [link]
356
+ // 摘要:与标题不同才追加(同旧实现口径:title===oneLine 时不重复)
357
+ if (oneLine && oneLine !== title) segs.push(oneLine)
358
+ const line = segs.join(' — ')
359
+ return '- ' + line + ' · layer=' + layer + ' · status=' + status + (date ? ' · ' + date : '')
360
+ }
361
+
362
+ // ── 纯文本形态(逐字节同旧实现)──
321
363
  const segs = []
322
364
  if (title && title !== oneLine) segs.push(title)
323
365
  if (oneLine) segs.push(oneLine)
324
- segs.push(clean(it.layer) || 'log')
325
- segs.push(clean(it.status) || 'current')
366
+ segs.push(layer)
367
+ segs.push(status)
326
368
  if (date) segs.push(date)
327
369
  return segs.join(' · ')
328
370
  }