@a9i5k4/dsh-auto-memory 3.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/README.md +30 -13
  2. package/README.zh-CN.md +30 -13
  3. package/docs/FRONTEND-CO-CREATION.md +191 -0
  4. package/docs/GM53-HOMEPAGE-PROMPT.md +323 -0
  5. package/docs/HANDBOOK.md +88 -52
  6. package/docs/HOMEPAGE-CONTENT-FOR-GM53.md +299 -0
  7. package/docs/PROMO-PROMPT-3.0.md +100 -0
  8. package/docs/USER-GUIDE.en.md +11 -11
  9. package/docs/USER-GUIDE.zh-CN.md +11 -11
  10. package/docs/WHITEPAPER.md +207 -0
  11. package/docs/screenshots/promo/promo-0-banner-v3.png +0 -0
  12. package/docs/screenshots/promo/promo-0-banner-v4.png +0 -0
  13. package/docs/screenshots/promo/promo-1b-auto-recall.png +0 -0
  14. package/lib/activation-host.js +69 -10
  15. package/lib/board-mode.js +1 -1
  16. package/lib/client.js +1697 -285
  17. package/lib/config-io.js +156 -0
  18. package/lib/context-bridge.js +3 -0
  19. package/lib/context-host.js +23 -10
  20. package/lib/degrade.js +385 -0
  21. package/lib/dsh-home.js +143 -0
  22. package/lib/episodic-store.js +142 -18
  23. package/lib/evidence-store.js +8 -1
  24. package/lib/fact-store.js +484 -43
  25. package/lib/hub-io.js +217 -0
  26. package/lib/index-sync.js +13 -1
  27. package/lib/index.js +1730 -202
  28. package/lib/intent-clean-safe.js +258 -40
  29. package/lib/l0-extract.js +231 -16
  30. package/lib/m4-corpus.js +8 -2
  31. package/lib/m7-index-sync-host.js +8 -1
  32. package/lib/memory-envelope.js +6 -1
  33. package/lib/memory-hub.js +164 -17
  34. package/lib/memory-index.js +4 -2
  35. package/lib/note-status-apply.js +118 -0
  36. package/lib/note-status.js +204 -0
  37. package/lib/procedure-store.js +333 -31
  38. package/lib/procedure-switch.js +38 -0
  39. package/lib/python-sidecar-client.js +314 -11
  40. package/lib/recall-fusion.js +83 -12
  41. package/lib/rules-edit.js +159 -0
  42. package/lib/semantic-decide.js +41 -8
  43. package/lib/semantic-js.js +51 -6
  44. package/lib/shadow-host.js +3 -5
  45. package/lib/skill-export-host.js +153 -0
  46. package/lib/skill-export.js +239 -0
  47. package/lib/storage-manage.js +6 -0
  48. package/lib/temporal-parse.js +191 -159
  49. package/lib/tier0-catalog.js +45 -3
  50. package/lib/wb-contract.js +198 -2
  51. package/lib/wb-sidecar.js +54 -3
  52. package/package.json +6 -2
  53. package/docs/internal/ACCEPT-35-LIVE.md +0 -143
  54. package/docs/internal/ACCEPTANCE-20260914.md +0 -90
  55. package/docs/internal/ARCH-REVIEW-BRIEF.md +0 -411
  56. package/docs/internal/ARCH-REVIEW-REQUEST.md +0 -201
  57. package/docs/internal/ARCH-REVIEW-ROUND2.md +0 -169
  58. package/docs/internal/ARCH-REVIEW-ROUND3.md +0 -206
  59. package/docs/internal/ART-DIRECTION-WIREFRAME.md +0 -181
  60. package/docs/internal/AUDIT-WB-GRAPH-FULL-20260916.md +0 -314
  61. package/docs/internal/CONCURRENCY-INVESTIGATION-20260917.md +0 -192
  62. package/docs/internal/CROSS-SESSION-SEARCH-PATH-DECISION.md +0 -72
  63. package/docs/internal/CROSS-SESSION-SEARCH-RESEARCH.md +0 -131
  64. package/docs/internal/CUA-VISION-FIX-NOTES.md +0 -78
  65. package/docs/internal/DECISIONS-20260914-SESSION.md +0 -269
  66. package/docs/internal/DESIGN-OVERHAUL-PRE-RESEARCH.md +0 -292
  67. package/docs/internal/DESIGN-P1-STATE-COMMIT-20260915.md +0 -219
  68. package/docs/internal/DIRECTION-CHECK-WB-GRAPH-20260916.md +0 -132
  69. package/docs/internal/FEEDBACK-TO-DSHAPI-RELAY.md +0 -13
  70. package/docs/internal/GH-DISCUSSION-5732-COMMENT.md +0 -74
  71. package/docs/internal/GPT-ACCEPTANCE-PROMPT-20260916.md +0 -352
  72. package/docs/internal/GPT-REVIEW-PROMPT.md +0 -216
  73. package/docs/internal/GROUP-DIGEST-SETUP.md +0 -62
  74. package/docs/internal/GROUP-LISTENER-SETUP.md +0 -49
  75. package/docs/internal/GROUP-WEBHOOK-SETUP.md +0 -93
  76. package/docs/internal/HANDOFF-TO-ZCODE.md +0 -168
  77. package/docs/internal/KICKOFF-P0.md +0 -254
  78. package/docs/internal/MASTER-PLAN-3.0.md +0 -411
  79. package/docs/internal/MEMORY-MUTATION-AND-INDEX-DESIGN.md +0 -85
  80. package/docs/internal/MERGE-CONFLICT-SCAN-20260914.md +0 -222
  81. package/docs/internal/NEXT-VERSION-TODO.md +0 -95
  82. package/docs/internal/OFFICIAL-DISCUSSION-DRAFT.md +0 -80
  83. package/docs/internal/PENDING-FIXES-20260916.md +0 -289
  84. package/docs/internal/RAG-KARPATHY-PROGRAM.md +0 -229
  85. package/docs/internal/RELEASE-PROCESS.md +0 -99
  86. package/docs/internal/REPORT-P0-NIGHTLY.md +0 -212
  87. package/docs/internal/REPORT-P5-ACCEPTANCE.md +0 -31
  88. package/docs/internal/REPORT-WB-GRAPH-NIGHTLY.md +0 -153
  89. package/docs/internal/REVIEW-WB-GRAPH-SELF.md +0 -81
  90. package/docs/internal/ROADMAP-20260917-WEEK.md +0 -305
  91. package/docs/internal/ROADMAP.md +0 -106
  92. package/docs/internal/RUN-P0-NIGHTLY.md +0 -227
  93. package/docs/internal/S10-CONSTRUCTION-HANDOFF-20260917.md +0 -175
  94. package/docs/internal/S10-GAPS-PLAIN-20260917.md +0 -125
  95. package/docs/internal/SEMANTIC-ARCHITECTURE-SPEC.md +0 -360
  96. package/docs/internal/SESSION-FILE-REPAIR-PROTOCOL.md +0 -90
  97. package/docs/internal/SUBAGENT-REPORT-ROUTING-PRE-RESEARCH.md +0 -261
  98. package/docs/internal/THREE-LAYER-CONTRACT.md +0 -210
  99. package/docs/internal/TODO-BACKLOG.md +0 -263
  100. package/docs/internal/TODO-GRAPH.html +0 -715
  101. package/docs/internal/TODO-GRAPH.html.bak-20260914-v2 +0 -493
  102. package/docs/internal/TODO-GRAPH.html.bak-20260915-alsfix +0 -710
  103. package/docs/internal/TODO-GRAPH.html.bak-20260915-p1 +0 -710
  104. package/docs/internal/TODO-GRAPH.html.bak-20260915-p6a-rev +0 -703
  105. package/docs/internal/TODO-GRAPH.html.bak-20260915-wshint +0 -710
  106. package/docs/internal/TODO-GRAPH.html.bak-20260916-batch +0 -715
  107. package/docs/internal/WB-FORMAT-CONVENTION.md +0 -112
  108. package/docs/internal/WB-GRAPH-DECISIONS-20260914.md +0 -71
  109. package/docs/internal/WB-GRAPH-INTEGRATION-PLAN.md +0 -386
  110. package/docs/internal/WB-GRAPH-RESEARCH-BRIEF.md +0 -118
  111. package/docs/internal/WB-GRAPH-RESEARCH-EXTERNAL.md +0 -228
  112. package/docs/internal/WB-GRAPH-RESEARCH-LOCAL.md +0 -190
  113. package/docs/internal/reviews/CLAIM-VERIFICATION-20260914.md +0 -56
  114. package/docs/internal/reviews/PLAN-gpt6astra-round2-20260914.md +0 -787
  115. package/docs/internal/reviews/REVIEW-gpt6astra-20260914.md +0 -112
  116. package/docs/internal/reviews/ROUND3-REVIEW-INTEGRATION-20260914.md +0 -230
@@ -0,0 +1,239 @@
1
+ /**
2
+ * M9-3 Skill 导出层(procedure → SKILL.md 目录束)。
3
+ *
4
+ * ★ 2026-09-19 用户拍板(三项):
5
+ * ⑪-1 形态:`SKILL.md` + **附上过程中用到的程序**(如 `.py`),且 SKILL.md 里
6
+ * 必须明写:这些程序**只是参考性的** —— 若当前做的事情与之前**根本不同**,
7
+ * 可以用来**迁移**,**不能直接运行**。
8
+ * ⑪-2 时机:**晋升为 `active` 后自动导出**。
9
+ * ⑪-3 目录:**用户级**(软件层面,可跨项目迁移);但导出物**必须标注适用于哪个项目**,
10
+ * 项目不一样时**只作参考,不能直接用**。
11
+ *
12
+ * 本模块是**纯渲染层**(无 IO、无状态),便于直接单测:
13
+ * - `renderSkillMarkdownPre(procedure, opts)` → SKILL.md 文本
14
+ * - `skillDirNamePre(procedure)` → 目录名(稳定、可预测)
15
+ * - `SKILL_USAGE_NOTICE_V1` → 使用约束条款(导出物必须原样包含)
16
+ *
17
+ * 设计纪律(与 procedure 引擎同源):
18
+ * ① **不新增状态源** —— 导出物是 procedure 的**派生物**,不是新的事实来源;
19
+ * ② **不自动执行任何导出的程序** —— 程序是**参考资料**,不是可调用入口;
20
+ * ③ 全部 fail-soft,但**返回可观察结果**(ok/reason),不静默。
21
+ */
22
+
23
+ // ★ T7-b(2026-09-20):纯计算依赖,用于给「纯中文标题」生成稳定的 ASCII 目录名。
24
+ // node 内建模块,不引入外部依赖、不做 IO、不持有状态 —— 与本模块「纯渲染层」定位不冲突。
25
+ import { createHash } from 'node:crypto'
26
+
27
+ /** 导出物的使用约束条款 —— **必须原样出现在每个 SKILL.md 中**(用户 ⑪-1 硬要求)。 */
28
+ export const SKILL_USAGE_NOTICE_V1 = [
29
+ '> **⚠️ 使用约束(必读)**',
30
+ '>',
31
+ '> 本技能由 **dsh-auto-memory** 从一次真实工作过程**自动沉淀**而来,**不是通用最佳实践**。',
32
+ '>',
33
+ '> 1. **附带程序仅供参考**:本目录下的脚本(如 `.py`)是**当时那次工作用过的程序**,',
34
+ '> **不是可直接调用的工具**。它们可能依赖当时的路径、环境变量、数据格式或版本。',
35
+ '> 2. **场景根本不同时 → 只做迁移,不要直接运行**:若当前任务与下方「来源」描述的场景',
36
+ '> **有根本差异**,请把附带的程序**当作思路参考**,按当前场景**重写**,而不是直接执行。',
37
+ '> 3. **跨项目使用须先核对**:本技能的「适用项目」若与当前项目**不一致**,',
38
+ '> **一律只作参考**,不得直接套用其路径、命令与判据。',
39
+ '> 4. **高风险步骤需人工确认**:涉及删除、发布、付费、外部发送等动作,必须先向用户复述确认。',
40
+ ].join('\n')
41
+
42
+ /** SKILL.md 中必须出现的约束锚点(供套件断言,防止被后续改动悄悄删掉)。 */
43
+ export const SKILL_NOTICE_ANCHORS_V1 = Object.freeze([
44
+ '使用约束(必读)',
45
+ '附带程序仅供参考',
46
+ '只做迁移,不要直接运行',
47
+ '跨项目使用须先核对',
48
+ '高风险步骤需人工确认',
49
+ ])
50
+
51
+ const MAX_TITLE_LEN = 80
52
+
53
+ /**
54
+ * 目录名 / `name:` 字段:`mem-skill-<slug>-<procId前12位>` —— 稳定、可预测、不冲突。
55
+ *
56
+ * ★ T7-b(2026-09-20 用户报「所有中文技能都叫 untitled」)修复:
57
+ * **根因**:原实现 `title.replace(/[^a-z0-9]+/g,'-')` 只保留 ASCII ⇒ **纯中文标题 slug 成空串**
58
+ * ⇒ 一律回落字面量 `'untitled'`。后果是每个中文技能都叫 `mem-skill-untitled-<id>`,
59
+ * 用户看到的技能列表名字毫无意义(实测:本机导出的
60
+ * `~/.dsh/skills/mem-skill-untitled-aa2153f33507/SKILL.md`)。
61
+ *
62
+ * **为什么不能用「保留中文」这个修法**:DSH 宿主对 skill name 是**硬校验**,不是建议 ——
63
+ * `SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/`(宿主 `index.js:17`),且
64
+ * `:481` 直接 `throw new Error('loaded skill has invalid name "…"')`。
65
+ * ⇒ 中文进 name 会让**技能加载直接失败**(比 untitled 更糟)。故必须产出 ASCII 安全名。
66
+ *
67
+ * **采用的修法**:slug 为空时,取标题的 **sha1 前 8 位十六进制**做 `t-<hash>` 前缀。
68
+ * - ASCII 安全:`t-1a2b3c4d` 完全符合上面的 SKILL_NAME 正则;
69
+ * - 稳定:同一 title ⇒ 同一 hash ⇒ 目录名不变(自动化重导出不会产生新目录);
70
+ * - 可区分:不同标题 hash 不同,不再出现「一堆 untitled」;
71
+ * - 中文原文**不丢**:仍完整出现在 SKILL.md 的 `# <title>` 与 `description` 里,
72
+ * 用户/模型在技能列表看到的是中文标题,name 只承担「唯一标识」职责。
73
+ *
74
+ * ⚠️ 哈希算法用 node 内建 `createHash('sha1')`(纯计算,不引入 IO/状态),
75
+ * 与 FNV-1a 那种"自造哈希"区分开 —— 本仓有过 sha256Hex 同名异义的教训(#86-1)。
76
+ */
77
+ export function skillDirNamePre(procedure) {
78
+ const id = String((procedure && procedure.procedureId) || '')
79
+ const short = id.replace(/^proc_/, '').slice(0, 12)
80
+ const title = String((procedure && procedure.title) || '')
81
+ const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40)
82
+ // 纯中文(或纯符号)标题:ASCII slug 为空 ⇒ 用标题哈希代替,绝不留 'untitled'
83
+ const stem = slug || ('t-' + createHash('sha1').update(title, 'utf8').digest('hex').slice(0, 8))
84
+ return 'mem-skill-' + stem + '-' + (short || 'noid')
85
+ }
86
+
87
+ /** 一句话摘要(取步骤首条或标题)——用于 SKILL.md 的 description。 */
88
+ /**
89
+ * 一句话摘要 —— 用于 SKILL.md 的 `description:` 字段。
90
+ *
91
+ * ★ T7-b(2026-09-20)修正取值优先级:**title 优先,steps[0] 兜底**(原来是反的)。
92
+ * 为什么反了不行:`description` 是模型在技能目录里看到的那句话,应当回答
93
+ * 「这个技能**是干什么的**」—— title 正是这件事;而 steps[0] 是**第一个动作**
94
+ * (实测导出的描述成了「备份:Copy-Item lib/index.js …」,完全看不出技能用途)。
95
+ * 仅当 title 缺失时才用 steps[0] 兜底(此时有总比没有好)。
96
+ */
97
+ function summaryOfPre(p) {
98
+ const t = String(p.title || '').trim()
99
+ const step = Array.isArray(p.steps) && p.steps.length ? String(p.steps[0]).trim() : ''
100
+ const s = t || step
101
+ return s.length > MAX_TITLE_LEN ? s.slice(0, MAX_TITLE_LEN - 1) + '…' : s
102
+ }
103
+
104
+ /**
105
+ * 渲染 SKILL.md。
106
+ *
107
+ * @param {object} procedure procedure 记录(stage 应为 active)
108
+ * @param {object} opts
109
+ * @param {string} opts.projectName 适用项目名(**必填**,⑪-3)
110
+ * @param {string} opts.projectPath 项目根绝对路径(用于溯源)
111
+ * @param {string[]} opts.programs 附带程序文件名列表(⑪-1,仅列名)
112
+ * @param {string} opts.exportedAt 导出时间(ISO 字符串;缺省由调用方给)
113
+ * @returns {{ok:true, fileName:string, dirName:string, content:string} | {ok:false, reason:string}}
114
+ */
115
+ export function renderSkillMarkdownPre(procedure, opts = {}) {
116
+ const p = procedure || {}
117
+ if (!p.procedureId) return { ok: false, reason: 'no-procedure-id' }
118
+ if (!p.title) return { ok: false, reason: 'no-title' }
119
+ const projectName = String(opts.projectName || '').trim()
120
+ if (!projectName) return { ok: false, reason: 'no-project-name' } // ⑪-3 硬要求:必须标项目
121
+ const projectPath = String(opts.projectPath || '').trim()
122
+ const programs = Array.isArray(opts.programs) ? opts.programs.filter(Boolean).map(String) : []
123
+ const exportedAt = String(opts.exportedAt || '')
124
+
125
+ const L = []
126
+ L.push('---')
127
+ L.push('name: ' + skillDirNamePre(p))
128
+ L.push('description: ' + summaryOfPre(p).replace(/\r?\n/g, ' '))
129
+ L.push('---')
130
+ L.push('')
131
+ L.push('# ' + String(p.title).trim())
132
+ L.push('')
133
+ L.push(SKILL_USAGE_NOTICE_V1)
134
+ L.push('')
135
+
136
+ // ── 来源(⑪-3:必须标注适用于哪个项目)──
137
+ L.push('## 来源')
138
+ L.push('')
139
+ L.push('- **适用项目**:`' + projectName + '`' + (projectPath ? '(`' + projectPath + '`)' : ''))
140
+ L.push('- **沉淀自**:dsh-auto-memory 技能库(procedure `' + p.procedureId + '`)')
141
+ if (p.riskLevel) L.push('- **风险等级**:`' + p.riskLevel + '`' + (p.requiresApproval ? '(需人工批准)' : ''))
142
+ if (exportedAt) L.push('- **导出时间**:' + exportedAt)
143
+ L.push('')
144
+ L.push('> 若你的当前项目**不是** `' + projectName + '`,请**只作参考**,不要直接套用下方路径与命令。')
145
+ L.push('')
146
+
147
+ // ── 适用条件 ──
148
+ if (Array.isArray(p.preconditions) && p.preconditions.length) {
149
+ L.push('## 何时适用')
150
+ L.push('')
151
+ for (const x of p.preconditions) L.push('- ' + String(x))
152
+ L.push('')
153
+ }
154
+
155
+ // ── 步骤 ──
156
+ if (Array.isArray(p.steps) && p.steps.length) {
157
+ L.push('## 步骤')
158
+ L.push('')
159
+ p.steps.forEach((s, i) => L.push(String(i + 1) + '. ' + String(s)))
160
+ L.push('')
161
+ }
162
+
163
+ // ── 检查点 ──
164
+ if (Array.isArray(p.checks) && p.checks.length) {
165
+ L.push('## 检查点')
166
+ L.push('')
167
+ for (const x of p.checks) L.push('- [ ] ' + String(x))
168
+ L.push('')
169
+ }
170
+
171
+ // ── 成功判据 ──
172
+ if (Array.isArray(p.successCriteria) && p.successCriteria.length) {
173
+ L.push('## 成功判据')
174
+ L.push('')
175
+ for (const x of p.successCriteria) L.push('- ' + String(x))
176
+ L.push('')
177
+ }
178
+
179
+ // ── 回滚 ──
180
+ if (Array.isArray(p.rollback) && p.rollback.length) {
181
+ L.push('## 回滚')
182
+ L.push('')
183
+ for (const x of p.rollback) L.push('- ' + String(x))
184
+ L.push('')
185
+ }
186
+
187
+ // ── 附带程序(⑪-1)──
188
+ if (programs.length) {
189
+ L.push('## 附带程序(**参考性**)')
190
+ L.push('')
191
+ L.push('以下文件是**当时那次工作用过的程序**,随本技能一起导出:')
192
+ L.push('')
193
+ for (const f of programs) L.push('- `' + f + '`')
194
+ L.push('')
195
+ L.push('> **不要直接运行**。先读一遍,判断它与当前场景的差异;')
196
+ L.push('> 若场景根本不同,请**按当前场景迁移重写**;确需运行时,先向用户说明并确认。')
197
+ L.push('')
198
+ }
199
+
200
+ // ── 证据 ──
201
+ if (Array.isArray(p.sourceEpisodes) && p.sourceEpisodes.length) {
202
+ L.push('## 依据')
203
+ L.push('')
204
+ L.push('- 来源 episode 数:' + p.sourceEpisodes.length)
205
+ if (p.evidence && typeof p.evidence === 'object') {
206
+ const ev = p.evidence
207
+ const parts = []
208
+ if (Number.isFinite(ev.seen)) parts.push('seen=' + ev.seen)
209
+ if (Number.isFinite(ev.success)) parts.push('success=' + ev.success)
210
+ if (Number.isFinite(ev.correction)) parts.push('correction=' + ev.correction)
211
+ if (parts.length) L.push('- 证据统计:' + parts.join(' / '))
212
+ }
213
+ L.push('')
214
+ }
215
+
216
+ return { ok: true, fileName: 'SKILL.md', dirName: skillDirNamePre(p), content: L.join('\n') + '\n' }
217
+ }
218
+
219
+ /**
220
+ * 导出完整性校验(供套件与调用方共用)。
221
+ * **判据是"用户 ⑪-1/⑪-3 的硬要求"本身**,不是实现细节。
222
+ */
223
+ export function validateSkillMarkdownPre(content, expect = {}) {
224
+ const s = String(content == null ? '' : content)
225
+ const problems = []
226
+ if (!s.trim()) problems.push('empty')
227
+ for (const a of SKILL_NOTICE_ANCHORS_V1) if (!s.includes(a)) problems.push('missing-notice:' + a)
228
+ if (!/^---\r?\n[\s\S]*?\r?\n---/.test(s)) problems.push('no-frontmatter')
229
+ if (!/^name:\s*\S+/m.test(s)) problems.push('no-name')
230
+ if (!/^description:\s*\S+/m.test(s)) problems.push('no-description')
231
+ if (expect.projectName && !s.includes('`' + expect.projectName + '`')) problems.push('no-project-tag')
232
+ // ⑪-1:附带程序必须被运行约束**包裹**,而不是只列个名。
233
+ // ★ 判据必须锚定属于「程序块」的形态:
234
+ // 顶层约束条款里也出现「不要直接运行」四字(后接 `**:`),
235
+ // 只用该四字做判据会**同义反复**(永远为真)——套件 [5] 组已实跑抓到这一版缺陷。
236
+ // 程序块独有形态 = 加粗短语**后紧跟句号**:`**不要直接运行**。`
237
+ if (expect.hasPrograms && !/\*\*不要直接运行\*\*。/.test(s)) problems.push('programs-not-constrained')
238
+ return { ok: problems.length === 0, problems }
239
+ }
@@ -116,6 +116,12 @@ export function createStorageManagerPre(opts = {}) {
116
116
  /** 读取既有 sidecar(尽力而为):用于 rebuildSidecar 继承 epoch/version,避免无谓的 epoch 漂移。 */
117
117
  function readSidecarPrev(file) {
118
118
  try {
119
+ // ★ issue #55 修复(2026-09-18,2026-09-19 补回):函数体内**自行活读** docStore。
120
+ // 旧写法直接引用闭包里的 `docStore`,但该名只在 `repair`/`deleteMemory` 内部局部声明
121
+ // ⇒ 本函数作用域内**未定义** ⇒ 每次调用抛 ReferenceError ⇒ 被下方 `catch (_)` 吞掉
122
+ // ⇒ **恒返回 null** ⇒ `rebuildSidecar` 拿不到 prev ⇒ epoch 漂移、fresh 被翻成 stale。
123
+ // 必须走 `docStoreOf()` 工厂活读(getter 语义:memoryAnchorEnabled 后续生效也能拿到)。
124
+ const docStore = docStoreOf()
119
125
  if (!docStore || typeof docStore.sidecarPath !== 'function') return null
120
126
  const sp = docStore.sidecarPath(file)
121
127
  if (!sp) return null
@@ -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
+ }