@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.
- package/README.md +189 -7
- package/README.zh-CN.md +189 -7
- package/docs/CONTRIBUTORS.html +471 -0
- package/docs/FRONTEND-CO-CREATION.md +191 -0
- package/docs/GM53-HOMEPAGE-PROMPT.md +323 -0
- package/docs/HANDOFF-CRITERIA.md +92 -0
- package/docs/HOMEPAGE-CONTENT-FOR-GM53.md +299 -0
- package/docs/INTEGRATION-ANALYSIS.md +350 -348
- package/docs/PROMO-PROMPT-3.0.md +100 -0
- package/docs/USER-GUIDE.en.md +58 -3
- package/docs/USER-GUIDE.zh-CN.md +59 -4
- package/docs/WHITEPAPER.md +207 -0
- package/docs/internal/ACCEPT-35-LIVE.md +143 -0
- package/docs/internal/ACCEPTANCE-20260914.md +90 -0
- package/docs/internal/ARCH-REVIEW-BRIEF.md +411 -0
- package/docs/internal/ARCH-REVIEW-REQUEST.md +201 -0
- package/docs/internal/ARCH-REVIEW-ROUND2.md +169 -0
- package/docs/internal/ARCH-REVIEW-ROUND3.md +206 -0
- package/docs/internal/ARCHITECTURE-FOR-ZCODE-20260920.md +397 -0
- package/docs/internal/ART-DIRECTION-DEEPSEEK-20260920.md +351 -0
- package/docs/internal/ART-DIRECTION-WIREFRAME.md +191 -181
- package/docs/internal/ART-DIRECTION-WIREFRAME.md.bak-superseded +181 -0
- package/docs/internal/AUDIT-WB-GRAPH-FULL-20260916.md +314 -0
- package/docs/internal/BATTLE-PLAN-20260917.md +871 -0
- package/docs/internal/CONCURRENCY-INVESTIGATION-20260917.md +192 -0
- package/docs/internal/CROSS-SESSION-SEARCH-PATH-DECISION.md +72 -0
- package/docs/internal/CROSS-SESSION-SEARCH-RESEARCH.md +131 -0
- package/docs/internal/DECISIONS-20260914-SESSION.md +269 -0
- package/docs/internal/DESIGN-P1-STATE-COMMIT-20260915.md +219 -0
- package/docs/internal/DIRECTION-CHECK-WB-GRAPH-20260916.md +132 -0
- package/docs/internal/FEATURE-INVENTORY.md +531 -0
- package/docs/internal/FEEDBACK-TO-DSHAPI-RELAY.md +13 -0
- package/docs/internal/G-SERIES-EXECUTION-20260917.md +248 -0
- package/docs/internal/G3-DESIGN-20260918.md +82 -0
- package/docs/internal/G3-DISK-FORMAT-GAP-20260919.md +92 -0
- package/docs/internal/GH-DISCUSSION-5732-COMMENT.md +74 -0
- package/docs/internal/GPT-ACCEPTANCE-PROMPT-20260916.md +352 -0
- package/docs/internal/GPT-REVIEW-PROMPT.md +216 -0
- package/docs/internal/GROUP-WEBHOOK-SETUP.md +33 -0
- package/docs/internal/HANDOFF-TO-ZCODE-20260920.md +309 -0
- package/docs/internal/HERMES-DATA-VERIFICATION-20260919.md +120 -0
- package/docs/internal/HERMES-LEGACY-STATUS-20260919.md +74 -0
- package/docs/internal/ISSUE-55-58-VERIFICATION-20260918.md +175 -0
- package/docs/internal/ISSUE10-FIX-EXECUTION-20260919.md +389 -0
- package/docs/internal/ISSUE10-PLAN-20260919.md +254 -0
- package/docs/internal/ISSUE10B-FORENSICS-20260919.md +468 -0
- package/docs/internal/ISSUE9-PURGE-AND-R1-PLAIN-20260919.md +150 -0
- package/docs/internal/ISSUE9-RESIDUAL-FORENSICS-20260919.md +114 -0
- package/docs/internal/KICKOFF-P0.md +254 -0
- package/docs/internal/LESSON-TO-CANDIDATE-STATUS-20260919.md +79 -0
- package/docs/internal/MASTER-PLAN-3.0.md +411 -0
- package/docs/internal/MEMORY-GOVERNANCE-20260917.md +309 -0
- package/docs/internal/MEMORY-MUTATION-AND-INDEX-DESIGN.md +85 -0
- package/docs/internal/MERGE-CONFLICT-SCAN-20260914.md +222 -0
- package/docs/internal/PENDING-FIXES-20260916.md +289 -0
- package/docs/internal/PRE-FRONTEND-CHECKLIST-20260919.md +705 -0
- package/docs/internal/PRE-FRONTEND-CHECKLIST-20260919.md.bak-s10 +649 -0
- package/docs/internal/PROCEDURAL-MEMORY-AND-APPROVAL-DESIGN-20260918.md +225 -0
- package/docs/internal/PROGRESS-20260917.md +93 -0
- package/docs/internal/PROMPT-GAP-AUDIT-20260920.md +128 -0
- package/docs/internal/R1-DEGRADE-AUDIT-20260918.md +163 -0
- package/docs/internal/R1-READABILITY-FORENSICS-20260919.md +127 -0
- package/docs/internal/R2-EVIDENCE-DEEP-AUDIT-20260918.md +140 -0
- package/docs/internal/R3-DEGRADE-LEDGER-DESIGN-20260918.md +138 -0
- package/docs/internal/R4-RECALL-QUOTA-PLAN-20260918.md +218 -0
- package/docs/internal/RAG-KARPATHY-PROGRAM.md +229 -0
- package/docs/internal/REPORT-P0-NIGHTLY.md +212 -0
- package/docs/internal/REPORT-P5-ACCEPTANCE.md +31 -0
- package/docs/internal/REPORT-WB-GRAPH-NIGHTLY.md +153 -0
- package/docs/internal/RESUME-20260918.md +171 -0
- package/docs/internal/RESUME-20260919.md +104 -0
- package/docs/internal/REVIEW-WB-GRAPH-SELF.md +81 -0
- package/docs/internal/RHINELAB-TO-DEEPSEEK-FEASIBILITY.md +198 -0
- package/docs/internal/ROADMAP-20260917-WEEK.md +439 -0
- package/docs/internal/ROADMAP.md +106 -0
- package/docs/internal/RUN-P0-NIGHTLY.md +227 -0
- package/docs/internal/S10-CONSTRUCTION-HANDOFF-20260917.md +185 -0
- package/docs/internal/S10-GAP-INVENTORY-20260917.md +239 -0
- package/docs/internal/S10-GAPS-PLAIN-20260917.md +125 -0
- package/docs/internal/SEMANTIC-ARCHITECTURE-SPEC.md +360 -0
- package/docs/internal/SESSION-FILE-REPAIR-PROTOCOL.md +90 -0
- package/docs/internal/T6-EXECUTION-20260920.md +130 -0
- package/docs/internal/TELEMETRY-EFFECT-REPORT-DESIGN-20260918.md +146 -0
- package/docs/internal/THESIS-GAP-ANALYSIS-20260918.md +89 -0
- package/docs/internal/THESIS-OUTLINE-20260918.md +147 -0
- package/docs/internal/THREE-LAYER-CONTRACT.md +219 -0
- package/docs/internal/TODO-BACKLOG.md +263 -142
- package/docs/internal/TODO-GRAPH.html +715 -0
- package/docs/internal/TODO-GRAPH.html.bak-20260914-v2 +493 -0
- package/docs/internal/TODO-GRAPH.html.bak-20260915-alsfix +710 -0
- package/docs/internal/TODO-GRAPH.html.bak-20260915-p1 +710 -0
- package/docs/internal/TODO-GRAPH.html.bak-20260915-p6a-rev +703 -0
- package/docs/internal/TODO-GRAPH.html.bak-20260915-wshint +710 -0
- package/docs/internal/TODO-GRAPH.html.bak-20260916-batch +715 -0
- package/docs/internal/UPSTREAM-ISSUE-PR-TRIAGE-20260919.md +297 -0
- package/docs/internal/UPSTREAM-ISSUES-3RD-AUDIT-20260920.md +104 -0
- package/docs/internal/WB-FORMAT-CONVENTION.md +112 -0
- package/docs/internal/WB-GRAPH-DECISIONS-20260914.md +71 -0
- package/docs/internal/reviews/CLAIM-VERIFICATION-20260914.md +56 -0
- package/docs/internal/reviews/PLAN-gpt6astra-round2-20260914.md +787 -0
- package/docs/internal/reviews/REVIEW-gpt6astra-20260914.md +112 -0
- package/docs/internal/reviews/ROUND3-REVIEW-INTEGRATION-20260914.md +230 -0
- package/docs/prompts/M8-3-enable-verify.md +49 -49
- package/docs/screenshots/promo/promo-0-banner-v3.png +0 -0
- package/lib/acceptance.js +71 -0
- package/lib/activation-host.js +153 -18
- package/lib/activation-inbox.js +25 -7
- package/lib/board-mode.js +30 -0
- package/lib/client.js +1758 -90
- package/lib/config-io.js +156 -0
- package/lib/context-bridge.js +5 -2
- package/lib/context-host.js +86 -15
- package/lib/degrade.js +385 -0
- package/lib/dsh-home.js +143 -0
- package/lib/engine-identity.js +149 -0
- package/lib/engine-switch.js +247 -0
- package/lib/episodic-store.js +63 -12
- package/lib/evidence-store.js +10 -3
- package/lib/fact-store.js +22 -3
- package/lib/fs-retry.js +46 -0
- package/lib/index-sync.js +13 -1
- package/lib/index.js +3446 -263
- package/lib/intent-clean-safe.js +258 -0
- package/lib/intent-clean.js +12 -16
- package/lib/l0-extract.js +478 -149
- package/lib/l0-index-sync.js +195 -0
- package/lib/l0-index.js +349 -239
- package/lib/ledger-criteria.js +142 -0
- package/lib/m4-corpus.js +8 -2
- package/lib/m7-index-sync-host.js +73 -5
- package/lib/m7-wire.js +3 -3
- package/lib/memory-anchor.js +56 -1
- package/lib/memory-envelope.js +257 -0
- package/lib/memory-hub.js +138 -13
- package/lib/memory-index.js +4 -2
- package/lib/memory-mutation.js +246 -0
- package/lib/memory-writer.js +204 -24
- package/lib/note-status-apply.js +118 -0
- package/lib/note-status.js +196 -0
- package/lib/procedure-observation.js +48 -0
- package/lib/procedure-store.js +118 -20
- package/lib/python-setup.js +1 -1
- package/lib/python-sidecar-client.js +29 -3
- package/lib/recall-fusion.js +83 -12
- package/lib/rerank-host.js +160 -0
- package/lib/rules-edit.js +159 -0
- package/lib/rules-layer.js +261 -0
- package/lib/semantic-decide.js +41 -8
- package/lib/semantic-js.js +66 -6
- package/lib/shadow-host.js +3 -5
- package/lib/shadow-retrieval.js +3 -3
- package/lib/skill-export-host.js +153 -0
- package/lib/skill-export.js +239 -0
- package/lib/state-commit.js +245 -0
- package/lib/storage-manage.js +6 -0
- package/lib/subagent-gc.js +4 -8
- package/lib/temporal-parse.js +191 -159
- package/lib/tier-layer-inject.js +650 -0
- package/lib/tier0-catalog.js +735 -0
- package/lib/water-window.js +263 -186
- package/lib/wb-contract.js +691 -0
- package/lib/wb-sidecar.js +890 -0
- package/lib/ws-overview-rank.js +2 -2
- package/package.json +1 -1
- package/python/m7_embedding_v1.py +5 -5
- package/python/worker_semantic_v1.py +17 -6
- package/python/worker_v1.py +38 -4
|
@@ -0,0 +1,890 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* wb-sidecar-pre —— WB-GRAPH P2 结构化 sidecar(2026-09-16, wb_sidecar_v1)。
|
|
3
|
+
*
|
|
4
|
+
* 权威依据: WB-GRAPH-INTEGRATION-PLAN.md §5 P2-1/P2-2 + WB-FORMAT-CONVENTION §3(索引派生)/§4(写入门)
|
|
5
|
+
* + 拍板 A2: sidecar 放 memoryRoot/<ws>/handoff/ 旁挂。
|
|
6
|
+
* + §3.3 index.json 结构契约(本次修复补齐 by_tag/by_cue/versions/ws 与条目级字段)。
|
|
7
|
+
*
|
|
8
|
+
* 设计:
|
|
9
|
+
* - index.json **完全可重建**: 从 PLAN.md + 账本白名单文件确定性派生(dsh-graph「events 是真源、
|
|
10
|
+
* 文件是投影」的反向取舍——我们的 Markdown 文件是真源, sidecar 只是派生索引, 丢了自愈重建)。
|
|
11
|
+
* - 条目 id = WB-FORMAT-CONVENTION §2 锚点契约: mem_ + sha256(workspaceKey+'\0'+relPath+'\0'+title) 前 32 hex。
|
|
12
|
+
* **relPath 一律正斜杠、title 一律经 normalizeTitlePre** —— write 与 rebuild 两条路径必须算出同一 id
|
|
13
|
+
* (2026-09-16 修复 BUG-10: 此前 write 传 path.relative(反斜杠) + title 含 handoff- 前缀,
|
|
14
|
+
* rebuild 传正斜杠 + 去前缀, 同一条目两个 id, 破坏「完全可重建」)。
|
|
15
|
+
* - tag 提取 = 确定性词法: 行内 `tag:xxx` / `type:xxx` / `topic:xxx` 模式, 零 LLM。
|
|
16
|
+
* - 仅 boardMode='graph' 时被调用; legacy 模式下零文件写入。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { createHash } from 'node:crypto'
|
|
20
|
+
|
|
21
|
+
export const WB_SIDECAR_VERSION = 'wb_sidecar_v1'
|
|
22
|
+
|
|
23
|
+
/** 章节标题 → tag(截断 24 字, 去掉 markdown 井号前缀)。 */
|
|
24
|
+
function sectionTagPre(sectionTitle) {
|
|
25
|
+
const t = String(sectionTitle || '').replace(/^#+\s*/, '').trim()
|
|
26
|
+
return t ? 'sec:' + t.slice(0, 24) : ''
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 从一段文本提取确定性 tag(零 LLM)。约定: 行内 `tag:` `type:` `topic:` 前缀 + 章节标题降 tag。
|
|
31
|
+
* 前置边界放宽为「行首 / 空白 / 常见中英标点」——2026-09-16 修复 BUG-13:
|
|
32
|
+
* 旧边界 `(^|\s)` 会漏掉「(topic:登录流程)」「格式:type:dead-end」等中文标点紧邻写法。
|
|
33
|
+
* 仍刻意不匹配 `a:type:xxx`(要求 tag 前是边界而非任意字符), 避免误吞 `xxxtype:` 类假阳性。
|
|
34
|
+
*/
|
|
35
|
+
export function extractTagsPre(text, sectionTitle) {
|
|
36
|
+
const tags = new Set()
|
|
37
|
+
const src = String(text || '')
|
|
38
|
+
// 边界规则(2026-09-16 两轮修正): tag 前缀**左边紧邻**的字符若仍是字母/数字/下划线/汉字,
|
|
39
|
+
// 说明它是某个更长单词的一部分(如 `xxxtype:foo` / `中文type:foo`)⇒ 拒绝;
|
|
40
|
+
// 行首、空白、中英标点(含中文括号/顿号/冒号)一律接受 —— 这样才能采到
|
|
41
|
+
// 「(topic:登录流程)」「格式:type:dead-end」这类中文写作里的常见写法(修 BUG-13)。
|
|
42
|
+
for (const m of src.matchAll(/(?:tag|type|topic):[\w\u4e00-\u9fff-]{2,24}/g)) {
|
|
43
|
+
const at = m.index
|
|
44
|
+
const prev = at > 0 ? src[at - 1] : ''
|
|
45
|
+
if (prev && /[\w\u4e00-\u9fff]/.test(prev)) continue
|
|
46
|
+
tags.add(m[0])
|
|
47
|
+
}
|
|
48
|
+
const sec = sectionTagPre(sectionTitle)
|
|
49
|
+
if (sec) tags.add(sec)
|
|
50
|
+
return [...tags]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* relPath 规范化: 一律正斜杠。write 侧传 path.relative(...) 在 Windows 得反斜杠,
|
|
55
|
+
* rebuild 侧拼 'handoff/'+f 得正斜杠 ⇒ 不统一就会同一条目两个 id。
|
|
56
|
+
*/
|
|
57
|
+
export function normalizeRelPathPre(relPath) {
|
|
58
|
+
return String(relPath || '').replace(/\\/g, '/')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* title 规范化(与 write 侧同一个口径):
|
|
63
|
+
* - 白板: '白板 PLAN'
|
|
64
|
+
* - 账本: '交接账本 handoff-20260916-020000'(**保留 handoff- 前缀**——write 侧用 basename 就带前缀)
|
|
65
|
+
* 传入可以是文件名(含 .md)或裸标题, 统一收敛。
|
|
66
|
+
*/
|
|
67
|
+
export function normalizeTitlePre(nameOrTitle) {
|
|
68
|
+
const raw = String(nameOrTitle || '').trim()
|
|
69
|
+
if (!raw) return ''
|
|
70
|
+
const base = raw.replace(/\.md$/i, '')
|
|
71
|
+
if (base === 'PLAN' || base === '白板 PLAN') return '白板 PLAN'
|
|
72
|
+
if (base.startsWith('交接账本 ')) return base
|
|
73
|
+
if (base.startsWith('handoff-')) return '交接账本 ' + base
|
|
74
|
+
return base
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** WB-FORMAT-CONVENTION §2 锚点 id(与写入门同源契约)。relPath 强制正斜杠, title 强制规范化。 */
|
|
78
|
+
export function wbEntryIdPre(workspaceKey, relPath, title) {
|
|
79
|
+
const h = createHash('sha256')
|
|
80
|
+
.update(String(workspaceKey || '') + '\0' + normalizeRelPathPre(relPath) + '\0' + normalizeTitlePre(title))
|
|
81
|
+
.digest('hex')
|
|
82
|
+
return 'mem_' + h.slice(0, 32)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 条目引用规范化(**write 与 rebuild 的唯一构造口径**, 修 BUG-10)。
|
|
87
|
+
*
|
|
88
|
+
* 传参宽容——写侧可能给绝对/相对路径 + 带 `handoff-` 的文件名, 读侧给 `handoff/xxx.md`:
|
|
89
|
+
* - relPath 一律转正斜杠;
|
|
90
|
+
* - 若给的是绝对路径, 取 `handoff/` 之后的部分(保持与 projectDir 无关的稳定口径);
|
|
91
|
+
* - title 走 normalizeTitlePre(账本**保留** `handoff-` 前缀, 与写侧 basename 同形)。
|
|
92
|
+
*
|
|
93
|
+
* @param {string} relPathOrName 形如 'handoff/PLAN.md' / 'handoff/handoff-2026...md' / 'handoff-2026...md'
|
|
94
|
+
* @param {string} [titleHint] 期望标题(可省; 省时由 relPathOrName 推导)
|
|
95
|
+
* @returns {{relPath:string, title:string}}
|
|
96
|
+
*/
|
|
97
|
+
export function wbRefPre(relPathOrName, titleHint) {
|
|
98
|
+
let rel = normalizeRelPathPre(relPathOrName)
|
|
99
|
+
const m = rel.match(/(?:^|\/)handoff\/(.+)$/)
|
|
100
|
+
if (m) rel = 'handoff/' + m[1]
|
|
101
|
+
else if (!rel.includes('/') && rel) rel = 'handoff/' + rel
|
|
102
|
+
const base = rel.split('/').pop() || ''
|
|
103
|
+
const derived = base.replace(/\.md$/i, '')
|
|
104
|
+
// titleHint 优先(写侧可能显式给 '白板 PLAN'); 否则由文件名推导
|
|
105
|
+
const title = titleHint ? normalizeTitlePre(titleHint) : normalizeTitlePre(derived)
|
|
106
|
+
return { relPath: rel, title }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** 从文本抽取 cue(入口线索: 路径 / 反引号代码 / 长的数字串)。供 §4.1 的 cues 字段与 tag 展开的入口匹配。 */
|
|
110
|
+
export function extractCuesPre(text) {
|
|
111
|
+
const out = new Set()
|
|
112
|
+
const src = String(text || '')
|
|
113
|
+
for (const m of src.matchAll(/[A-Za-z]:\\[^\s"'`)]+|(?:[\w.@-]+\/)+[\w.@-]+\.\w+|`[^`\n]{2,60}`/g)) {
|
|
114
|
+
let s = m[0].trim()
|
|
115
|
+
if (s.startsWith('`') && s.endsWith('`')) s = s.slice(1, -1).trim()
|
|
116
|
+
if (s.length >= 3) out.add(s.slice(0, 120))
|
|
117
|
+
if (out.size >= 24) break
|
|
118
|
+
}
|
|
119
|
+
return [...out]
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** 从文本切出所在小节标题(最近的 `## ` / `### ` 行)。 */
|
|
123
|
+
function sectionOfPre(text, fallback) {
|
|
124
|
+
const lines = String(text || '').split('\n')
|
|
125
|
+
let cur = String(fallback || '')
|
|
126
|
+
for (const l of lines) {
|
|
127
|
+
const m = l.match(/^(#{2,4})\s+(.+)$/)
|
|
128
|
+
if (m) cur = m[2].trim().slice(0, 60)
|
|
129
|
+
}
|
|
130
|
+
return cur
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
134
|
+
// 小节切分(2026-09-16 看板 v2「大刀阔斧」批) —— 看板卡片粒度的**唯一权威口径**。
|
|
135
|
+
//
|
|
136
|
+
// 为什么必须按小节切: 白板格式约定 (§2 锚点契约) 本来就规定「每个卡片/小节一张卡、
|
|
137
|
+
// 标题行下方紧跟锚点」。而 v1 的 sidecar 是**文件级**条目 ⇒ 一篇账本压成一根卡片,
|
|
138
|
+
// 真实数据实测 92 文件 → 92 条, 而按小节切是 415 条(×4.5), 按条目行切 797 条(×8.7)。
|
|
139
|
+
// ⇒ 看板"看起来没内容"的根因不是 cap 太小, 是**切分粒度错了一个层级**。
|
|
140
|
+
//
|
|
141
|
+
// 与 v1 的关系: **新增, 不改旧语义**。文件级条目(entries)与 by_tag/by_cue/versions 一律不动
|
|
142
|
+
// (既有套件断言 rebuildSidecarIndexPre('ws', 2 docs).entries.length === 2, 改动即回归),
|
|
143
|
+
// 本函数只在其上**派生**第二层投影, 供看板与检索使用。
|
|
144
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* 把一篇白板/账本正文切成小节(纯函数, 零 IO)。
|
|
148
|
+
*
|
|
149
|
+
* 切分规则:
|
|
150
|
+
* - 以 `## ` 二级标题为主切点(白板/账本正文结构与 §2 契约一致);
|
|
151
|
+
* - `# ` 一级标题作为**文档标题**(不单独成节, 记入 docTitle);
|
|
152
|
+
* - `### ` 及更深**不切**(归入最近的 `## ` 节), 避免卡片过碎;
|
|
153
|
+
* - 正文里**没有 `## ` 时**落一个 `__preamble__` 节(整篇一节, 不丢内容);
|
|
154
|
+
* - 紧随标题行的锚点行 `<!-- memory:mem_xxx -->` 被识别为该节的 **anchorId**。
|
|
155
|
+
*
|
|
156
|
+
* @returns {{docTitle:string, sections:Array<{title:string, body:string, anchorId:string, lineStart:number, bullets:number, chars:number}>}}
|
|
157
|
+
*/
|
|
158
|
+
export function splitSectionsPre(text) {
|
|
159
|
+
const src = String(text || '')
|
|
160
|
+
const lines = src.split('\n')
|
|
161
|
+
let docTitle = ''
|
|
162
|
+
const sections = []
|
|
163
|
+
let cur = null
|
|
164
|
+
|
|
165
|
+
const push = () => {
|
|
166
|
+
if (!cur) return
|
|
167
|
+
const body = cur.bodyLines.join('\n').trim()
|
|
168
|
+
cur.bullets = (body.match(/^\s*[-*]\s+\S/gm) || []).length
|
|
169
|
+
cur.chars = body.length
|
|
170
|
+
cur.body = body
|
|
171
|
+
delete cur.bodyLines
|
|
172
|
+
sections.push(cur)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
for (let i = 0; i < lines.length; i++) {
|
|
176
|
+
const line = lines[i]
|
|
177
|
+
const h1 = line.match(/^#\s+(.+)$/)
|
|
178
|
+
if (h1 && !docTitle) { docTitle = h1[1].trim().slice(0, 120) }
|
|
179
|
+
const h2 = line.match(/^##\s+(.+)$/)
|
|
180
|
+
if (h2) {
|
|
181
|
+
push()
|
|
182
|
+
cur = {
|
|
183
|
+
title: h2[1].trim().slice(0, 120),
|
|
184
|
+
bodyLines: [],
|
|
185
|
+
anchorId: '',
|
|
186
|
+
lineStart: i + 1,
|
|
187
|
+
bullets: 0,
|
|
188
|
+
chars: 0,
|
|
189
|
+
}
|
|
190
|
+
continue
|
|
191
|
+
}
|
|
192
|
+
if (!cur) {
|
|
193
|
+
// 首个 ## 之前的引言部分: 只在有实质内容时保留
|
|
194
|
+
cur = { title: '', bodyLines: [], anchorId: '', lineStart: 1, bullets: 0, chars: 0, preamble: true }
|
|
195
|
+
}
|
|
196
|
+
// 引言区里的 `# 文档标题` 行不是内容 —— 若不剔除, 它会变成一张只有标题的垃圾卡
|
|
197
|
+
if (cur.preamble && /^#\s+/.test(line)) continue
|
|
198
|
+
// 标题行紧随的锚点行进 anchorId(§2 契约形态)
|
|
199
|
+
const am = line.trim().match(WB_ANCHOR_LINE_RE_V1)
|
|
200
|
+
if (am && !cur.bodyLines.some((x) => x.trim())) { cur.anchorId = am[1]; continue }
|
|
201
|
+
cur.bodyLines.push(line)
|
|
202
|
+
}
|
|
203
|
+
push()
|
|
204
|
+
|
|
205
|
+
const real = sections.filter((s) => !s.preamble || s.body.length > 0)
|
|
206
|
+
if (!real.length) {
|
|
207
|
+
// 完全没有 ## 的文档: 整体一节, 标题回落文档标题
|
|
208
|
+
const body = src.trim()
|
|
209
|
+
return {
|
|
210
|
+
docTitle,
|
|
211
|
+
sections: body ? [{
|
|
212
|
+
title: docTitle || '(全文)', body, anchorId: '', lineStart: 1,
|
|
213
|
+
bullets: (body.match(/^\s*[-*]\s+\S/gm) || []).length, chars: body.length,
|
|
214
|
+
}] : [],
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return { docTitle, sections: real }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* 把一批文档投影成**小节级卡片**(看板数据源, 纯函数零 IO, 确定性)。
|
|
222
|
+
*
|
|
223
|
+
* id 口径(**P3 修复, 2026-09-16 实测修正**):
|
|
224
|
+
* 卡片主键**一律用可复算规范式** `wbEntryIdPre(ws, relPath, 小节标题)` —— 只由
|
|
225
|
+
* (工作区, 文件, 标题) 决定 ⇒ **唯一、与输入顺序无关**, 与 §2 锚点契约算法同式。
|
|
226
|
+
*
|
|
227
|
+
* ⚠ 为什么不直接用正文锚点 id 当主键: 实测真实数据(92 文件/417 卡)发现 **7 组不同文件
|
|
228
|
+
* 共用同一锚点 id**(如 handoff-20260908-183928.md 与 -184901.md 的「## 目标」同为
|
|
229
|
+
* mem_af41b679…) —— 历史锚点由早期写入/整篇复制遗留, **野外不保证唯一**。拿它当主键会导致
|
|
230
|
+
* 多卡同 id ⇒ 排序破平失效(**实测同集合乱序投影结果不一致**)、前端 key 冲突。
|
|
231
|
+
* ⇒ 锚点降级为 `anchorId` 参照字段(展示/回溯用), 不作主键。
|
|
232
|
+
* 同文件内同名小节(极少)按出现序号加后缀, 保证主键唯一。
|
|
233
|
+
*
|
|
234
|
+
* @param {string} workspaceKey
|
|
235
|
+
* @param {Array<{relPath:string, text:string, kind?:string, mtime?:number, criteria?:string, ts?:string, title?:string}>} docs
|
|
236
|
+
* @param {object} [opts] { maxSectionsPerDoc }
|
|
237
|
+
*/
|
|
238
|
+
export function buildSectionCardsPre(workspaceKey, docs, opts = {}) {
|
|
239
|
+
const perDocCap = Number(opts.maxSectionsPerDoc) > 0 ? Number(opts.maxSectionsPerDoc) : 0
|
|
240
|
+
const cards = []
|
|
241
|
+
for (const d of Array.isArray(docs) ? docs : []) {
|
|
242
|
+
try {
|
|
243
|
+
const rel = normalizeRelPathPre(d.relPath)
|
|
244
|
+
const kind = String(d.kind || (rel.includes('PLAN') ? 'plan' : rel.includes('archive/') ? 'archive' : 'ledger'))
|
|
245
|
+
const mtime = Number(d.mtime) || 0
|
|
246
|
+
const criteria = String(d.criteria || 'unknown')
|
|
247
|
+
const { docTitle, sections } = splitSectionsPre(d.text)
|
|
248
|
+
const use = perDocCap > 0 ? sections.slice(0, perDocCap) : sections
|
|
249
|
+
const seen = {}
|
|
250
|
+
for (const s of use) {
|
|
251
|
+
if (!s.body && !s.title) continue
|
|
252
|
+
const secTitle = s.title || docTitle || rel
|
|
253
|
+
// 主键 = 可复算规范式(唯一 + 输入顺序无关); 同文件同名小节加序号后缀
|
|
254
|
+
let id = wbEntryIdPre(workspaceKey, rel, secTitle)
|
|
255
|
+
if (seen[id]) { seen[id] += 1; id = id + '#' + seen[id] } else { seen[id] = 1 }
|
|
256
|
+
const secText = s.body || ''
|
|
257
|
+
cards.push({
|
|
258
|
+
id,
|
|
259
|
+
anchorId: s.anchorId || '', // 正文锚点(参照; 野外可能不唯一, 不作主键)
|
|
260
|
+
// 小节标题即卡片标题; 文档标题作为来源上下文
|
|
261
|
+
title: secTitle.slice(0, 120),
|
|
262
|
+
docTitle: String(docTitle || '').slice(0, 120),
|
|
263
|
+
source: rel,
|
|
264
|
+
kind,
|
|
265
|
+
section: secTitle.slice(0, 60),
|
|
266
|
+
mtime,
|
|
267
|
+
criteria,
|
|
268
|
+
ts: String(d.ts || ''),
|
|
269
|
+
bullets: Number(s.bullets) || 0,
|
|
270
|
+
chars: Number(s.chars) || 0,
|
|
271
|
+
lineStart: Number(s.lineStart) || 0,
|
|
272
|
+
anchored: !!s.anchorId,
|
|
273
|
+
// tags/cues/preview 一律从**本节正文**抽取(不是整篇) —— 这是小节级切分的意义所在
|
|
274
|
+
tags: extractTagsPre(secText, secTitle),
|
|
275
|
+
cues: extractCuesPre(secText),
|
|
276
|
+
preview: secText.replace(/\s+/g, ' ').trim().slice(0, 400),
|
|
277
|
+
body: secText,
|
|
278
|
+
})
|
|
279
|
+
}
|
|
280
|
+
} catch (_) { /* 单文件损坏不拖垮整体 */ }
|
|
281
|
+
}
|
|
282
|
+
return cards
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* 从账本/白板全文构建条目(纯函数, 零 IO)。
|
|
287
|
+
* 契约对齐 WB-GRAPH-INTEGRATION-PLAN §4.1(条目级): id/kind/source/section/tags/cues/preview/mtime/criteria。
|
|
288
|
+
* 兼容旧字段: title/cue/chars/ts 保留(既有套件与工具描述在用)。
|
|
289
|
+
*
|
|
290
|
+
* ★M1(2026-09-18)新增 `layer`/`status`:契约 §3 的索引派生行要求两者
|
|
291
|
+
* (`… · layer=whiteboard · status=current · 日期`)。此前 entry 只有 `id`(已是
|
|
292
|
+
* `mem_<32hex>` 形态),缺这两个字段 ⇒ 渲染侧拿不到值,一直退化成纯文本行。
|
|
293
|
+
* - `layer` 恒为 `'whiteboard'`:本模块**只服务于白板/账本**这一层,
|
|
294
|
+
* 不是从调用方传入的自由值(避免调用方各传各的、导致层级语义漂移)。
|
|
295
|
+
* - `status` 取 `o.status`,缺省 `'current'`(与契约 §3 尾注
|
|
296
|
+
* 「缺 status 视为 current」、与 `isCurrentPre` 口径一致)。
|
|
297
|
+
* 两个字段**只增不改**:既有字段顺序与语义逐字节不变。
|
|
298
|
+
*
|
|
299
|
+
* @param {{workspaceKey:string, relPath:string, text:string, title:string, ts?:string,
|
|
300
|
+
* kind?:string, mtime?:number, criteria?:string, status?:string}} o
|
|
301
|
+
*/
|
|
302
|
+
export function buildSidecarEntryPre(o = {}) {
|
|
303
|
+
const src = String(o.text || '')
|
|
304
|
+
const lines = src.split('\n')
|
|
305
|
+
const cueLine = lines.find((l) => /[\\/][\w.-]+|`[^`]+`|\d{2,}/.test(l)) || lines.find((l) => l.trim()) || ''
|
|
306
|
+
const relPath = normalizeRelPathPre(o.relPath)
|
|
307
|
+
const title = normalizeTitlePre(o.title)
|
|
308
|
+
const tags = extractTagsPre(src, title)
|
|
309
|
+
return {
|
|
310
|
+
// ── §4.1 契约字段 ──
|
|
311
|
+
id: wbEntryIdPre(o.workspaceKey, relPath, title),
|
|
312
|
+
kind: String(o.kind || (relPath.includes('PLAN') ? 'plan' : relPath.includes('archive/') ? 'archive' : 'ledger')),
|
|
313
|
+
source: relPath,
|
|
314
|
+
section: sectionOfPre(src, title),
|
|
315
|
+
tags,
|
|
316
|
+
cues: extractCuesPre(src),
|
|
317
|
+
preview: src.replace(/\s+/g, ' ').trim().slice(0, 120),
|
|
318
|
+
mtime: Number(o.mtime) || 0,
|
|
319
|
+
criteria: String(o.criteria || 'unknown'),
|
|
320
|
+
// ── ★M1:契约 §3 索引派生行所需(只增不改)──
|
|
321
|
+
layer: 'whiteboard',
|
|
322
|
+
status: String(o.status || 'current'),
|
|
323
|
+
// ── 兼容旧套件/工具描述的字段 ──
|
|
324
|
+
title,
|
|
325
|
+
cue: cueLine.trim().slice(0, 120),
|
|
326
|
+
chars: src.length,
|
|
327
|
+
ts: String(o.ts || ''),
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* ★M1(2026-09-18):sidecar 条目 → Tier-0 目录项(**契约 §8 的桥**)。
|
|
333
|
+
*
|
|
334
|
+
* 契约 §8 要求:「由页面派生出的 index 与 Tier-0 目录条目**一致**」。
|
|
335
|
+
* 实现方式 = **同一次派生、两种渲染** —— 本函数**只做字段映射,不复制任何逻辑**:
|
|
336
|
+
* entries 与 Tier-0 条目本就是同一份数据,映射后交给
|
|
337
|
+
* `renderCatalogLinePre` 渲染,一致性天然成立(无需额外同步机制)。
|
|
338
|
+
*
|
|
339
|
+
* 为什么不在此处直接渲染成字符串:渲染口径必须**只有一处实现**
|
|
340
|
+
* (`renderCatalogLinePre`),否则「两种渲染」会退化成「两个渲染器」,
|
|
341
|
+
* 长期必然漂移 —— 这正是 §8 想防的。
|
|
342
|
+
*
|
|
343
|
+
* 字段映射(只映射,不发明):
|
|
344
|
+
* - title ← entry.title
|
|
345
|
+
* - oneLine ← entry.preview(已是单行化 + 120 字截断,即「一句话摘要」)
|
|
346
|
+
* - layer ← entry.layer(本模块恒为 'whiteboard')
|
|
347
|
+
* - status ← entry.status(缺省 'current')
|
|
348
|
+
* - date ← entry.ts 的日期部分(无则空;不编造)
|
|
349
|
+
* - path ← entry.source(相对路径,渲染时拼锚点)
|
|
350
|
+
* - id ← entry.id(已是 mem_<32hex>)
|
|
351
|
+
*
|
|
352
|
+
* @param {object} entry buildSidecarEntryPre 的产物
|
|
353
|
+
* @returns {{title:string,oneLine:string,layer:string,status:string,date:string,path:string,id:string}}
|
|
354
|
+
*/
|
|
355
|
+
export function sidecarEntryToCatalogItemPre(entry) {
|
|
356
|
+
const e = entry && typeof entry === 'object' ? entry : {}
|
|
357
|
+
const ts = String(e.ts || '')
|
|
358
|
+
const dateOnly = (ts.match(/^\d{4}-\d{2}-\d{2}/) || [''])[0]
|
|
359
|
+
return {
|
|
360
|
+
title: String(e.title || ''),
|
|
361
|
+
oneLine: String(e.preview || ''),
|
|
362
|
+
layer: String(e.layer || 'whiteboard'),
|
|
363
|
+
status: String(e.status || 'current'),
|
|
364
|
+
date: dateOnly,
|
|
365
|
+
path: String(e.source || ''),
|
|
366
|
+
id: String(e.id || ''),
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** tag → 条目 id 倒排(§3.3 by_tag)。 */
|
|
371
|
+
export function buildByTagPre(entries) {
|
|
372
|
+
const byTag = {}
|
|
373
|
+
for (const e of Array.isArray(entries) ? entries : []) {
|
|
374
|
+
for (const t of Array.isArray(e.tags) ? e.tags : []) {
|
|
375
|
+
if (!byTag[t]) byTag[t] = []
|
|
376
|
+
if (!byTag[t].includes(e.id)) byTag[t].push(e.id)
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
return byTag
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** cue → 条目 id 倒排(§3.3 by_cue)。 */
|
|
383
|
+
export function buildByCuePre(entries) {
|
|
384
|
+
const byCue = {}
|
|
385
|
+
for (const e of Array.isArray(entries) ? entries : []) {
|
|
386
|
+
for (const c of Array.isArray(e.cues) ? e.cues : []) {
|
|
387
|
+
if (!byCue[c]) byCue[c] = []
|
|
388
|
+
if (!byCue[c].includes(e.id)) byCue[c].push(e.id)
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return byCue
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* 版本链(§3.3 versions): entry_id → 归档文件相对路径数组。
|
|
396
|
+
* 归档文件(archive/PLAN-*.md)没有自己的条目 id(它不参与 by_tag), 故按 kind='archive' 的条目
|
|
397
|
+
* 与同标题前缀的现行条目关联。调用方负责把归档文件也传进 docs。
|
|
398
|
+
*/
|
|
399
|
+
export function buildVersionsPre(entries) {
|
|
400
|
+
const versions = {}
|
|
401
|
+
for (const e of Array.isArray(entries) ? entries : []) {
|
|
402
|
+
if (e.kind !== 'archive') continue
|
|
403
|
+
if (!versions[e.id]) versions[e.id] = []
|
|
404
|
+
versions[e.id].push(e.source)
|
|
405
|
+
}
|
|
406
|
+
return versions
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* 从 PLAN.md + 账本文件清单重建 index.json 内容(纯函数: 调用方负责读文件)。
|
|
411
|
+
* @param {string} workspaceKey
|
|
412
|
+
* @param {Array<{relPath:string, text:string, title:string, ts?:string, kind?:string, mtime?:number, criteria?:string}>} docs
|
|
413
|
+
*/
|
|
414
|
+
export function rebuildSidecarIndexPre(workspaceKey, docs) {
|
|
415
|
+
const entries = []
|
|
416
|
+
for (const d of Array.isArray(docs) ? docs : []) {
|
|
417
|
+
try {
|
|
418
|
+
entries.push(buildSidecarEntryPre({
|
|
419
|
+
workspaceKey,
|
|
420
|
+
relPath: d.relPath,
|
|
421
|
+
text: d.text,
|
|
422
|
+
title: d.title,
|
|
423
|
+
ts: d.ts,
|
|
424
|
+
kind: d.kind,
|
|
425
|
+
mtime: d.mtime,
|
|
426
|
+
criteria: d.criteria,
|
|
427
|
+
}))
|
|
428
|
+
} catch (_) { /* 单文件损坏不拖垮重建 */ }
|
|
429
|
+
}
|
|
430
|
+
return {
|
|
431
|
+
version: WB_SIDECAR_VERSION,
|
|
432
|
+
ws: String(workspaceKey || ''),
|
|
433
|
+
rebuilt_at: new Date().toISOString(),
|
|
434
|
+
entries,
|
|
435
|
+
by_tag: buildByTagPre(entries),
|
|
436
|
+
by_cue: buildByCuePre(entries),
|
|
437
|
+
versions: buildVersionsPre(entries),
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** 生成 §4.1 的 hint 文案(工具返回值里引导模型向下走)。 */
|
|
442
|
+
function hintForExpand() {
|
|
443
|
+
return '可用 memory_trace(id) 回溯该条目的 cue/tag/邻居与归档版本'
|
|
444
|
+
}
|
|
445
|
+
function hintForTrace() {
|
|
446
|
+
return '可用 memory_expand(tag) 按 tag 展开同主题条目; neighbors 为同 tag/同 cue 的相邻条目'
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* 正向遍历: 按 tag 过滤条目(展开)。tag 精确匹配; '*' 返回全部。
|
|
451
|
+
* 返回契约对齐 §4.1: {total, entries[], truncated, remaining, hint}。
|
|
452
|
+
*/
|
|
453
|
+
export function expandByTagPre(index, tag, limit = 10) {
|
|
454
|
+
const t = String(tag || '').trim()
|
|
455
|
+
const cap = Math.min(Math.max(Number(limit) || 10, 1), 20)
|
|
456
|
+
const all = index && Array.isArray(index.entries) ? index.entries : []
|
|
457
|
+
// 优先走 by_tag 倒排(§3.3); 索引缺倒排时回落全表扫描(向后兼容旧 index.json)
|
|
458
|
+
let hits
|
|
459
|
+
if (t !== '*' && index && index.by_tag && Array.isArray(index.by_tag[t])) {
|
|
460
|
+
const ids = index.by_tag[t]
|
|
461
|
+
const map = new Map(all.map((e) => [e.id, e]))
|
|
462
|
+
hits = ids.map((id) => map.get(id)).filter(Boolean)
|
|
463
|
+
} else {
|
|
464
|
+
hits = all.filter((e) => t === '*' || (Array.isArray(e.tags) && e.tags.includes(t)))
|
|
465
|
+
}
|
|
466
|
+
const shown = hits.slice(0, cap)
|
|
467
|
+
return {
|
|
468
|
+
total: hits.length,
|
|
469
|
+
entries: shown.map((e) => ({
|
|
470
|
+
id: e.id,
|
|
471
|
+
kind: e.kind,
|
|
472
|
+
section: e.section,
|
|
473
|
+
preview: e.preview,
|
|
474
|
+
source: e.source,
|
|
475
|
+
title: e.title,
|
|
476
|
+
tags: e.tags,
|
|
477
|
+
cues: e.cues,
|
|
478
|
+
mtime: e.mtime,
|
|
479
|
+
criteria: e.criteria,
|
|
480
|
+
})),
|
|
481
|
+
truncated: hits.length > cap,
|
|
482
|
+
remaining: Math.max(0, hits.length - cap),
|
|
483
|
+
hint: hintForExpand(),
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* 反向回溯: 按 id 找条目并给出相邻线索(同 tag 条目 + 同 cue)。
|
|
489
|
+
* 返回契约对齐 §4.1: {found, entry, cues, tags, neighbors, versions, hint}。
|
|
490
|
+
* 兼容旧字段 related(既有套件断言在用)。
|
|
491
|
+
*/
|
|
492
|
+
export function traceByIdPre(index, id) {
|
|
493
|
+
const wanted = String(id || '').trim()
|
|
494
|
+
const entries = index && Array.isArray(index.entries) ? index.entries : []
|
|
495
|
+
const self = entries.find((e) => e.id === wanted)
|
|
496
|
+
if (!self) return { found: false, reason: 'no-entry' }
|
|
497
|
+
const sameTags = entries.filter((e) => e.id !== wanted && Array.isArray(e.tags) && Array.isArray(self.tags) && e.tags.some((t) => self.tags.includes(t))).slice(0, 5)
|
|
498
|
+
const versions = (index && index.versions && Array.isArray(index.versions[wanted])) ? index.versions[wanted] : []
|
|
499
|
+
return {
|
|
500
|
+
found: true,
|
|
501
|
+
entry: self,
|
|
502
|
+
cues: Array.isArray(self.cues) ? self.cues : [],
|
|
503
|
+
tags: Array.isArray(self.tags) ? self.tags : [],
|
|
504
|
+
neighbors: sameTags.map((e) => ({ id: e.id, title: e.title, tags: e.tags, section: e.section })),
|
|
505
|
+
versions,
|
|
506
|
+
hint: hintForTrace(),
|
|
507
|
+
related: sameTags.map((e) => ({ id: e.id, title: e.title, tags: e.tags })),
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** 判据确认状态排序权重(§4.2 剪枝信号 1: passed > warned > unknown); 供调用方排序用。 */
|
|
512
|
+
export function criteriaWeightPre(criteria) {
|
|
513
|
+
return criteria === 'passed' ? 3 : criteria === 'warned' ? 2 : criteria === 'unknown' ? 1 : 0
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** 锚点行正则(与 L0 抽取的 MEM_ANCHOR_RE 同形)。 */
|
|
517
|
+
export const WB_ANCHOR_LINE_RE_V1 = /^<!--\s*memory:(mem_[0-9a-f]{32})\s*-->$/
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* WB-FORMAT-CONVENTION §2 锚点写入(纯函数, 零 IO)。
|
|
521
|
+
*
|
|
522
|
+
* 契约: 每个卡片/小节标题行下方紧跟一行 `<!-- memory:mem_<32hex> -->`;
|
|
523
|
+
* id = mem_ + sha256(workspaceKey + '\0' + relPath + '\0' + 卡片标题) 前 32 hex。
|
|
524
|
+
* ⇒ **重排/移动不改 id; 改标题 = 新 id**(旧 id 走 supersede 留痕)。
|
|
525
|
+
*
|
|
526
|
+
* 纪律:
|
|
527
|
+
* - **幂等**: 标题下方若已有锚点行, 按当前标题重算并就地更新(不叠加第二行)。
|
|
528
|
+
* - **只认 `## `–`#### ` 级标题**: `# ` 一级标题(文档标题)不加锚点 —— 它是文件身份, 不是卡片。
|
|
529
|
+
* - 锚点写在标题行**正下方**; 若标题下已有正文, 锚点插在标题与正文之间(不写在正文中间)。
|
|
530
|
+
* - 已有锚点但标题没变 ⇒ 逐字节不动(保证"同输入同字节", 不制造无谓 diff)。
|
|
531
|
+
* - 跳过 fenced code block 内的 `##` 行(避免把代码注释当标题)。
|
|
532
|
+
*
|
|
533
|
+
* @returns {{text:string, added:number, updated:number, unchanged:number}}
|
|
534
|
+
*/
|
|
535
|
+
export function applyAnchorsPre(workspaceKey, relPath, text) {
|
|
536
|
+
const src = String(text == null ? '' : String(text))
|
|
537
|
+
if (!src) return { text: src, added: 0, updated: 0, unchanged: 0 }
|
|
538
|
+
const rel = normalizeRelPathPre(relPath)
|
|
539
|
+
const lines = src.split('\n')
|
|
540
|
+
const out = []
|
|
541
|
+
let inFence = false
|
|
542
|
+
let added = 0, updated = 0, unchanged = 0
|
|
543
|
+
for (let i = 0; i < lines.length; i++) {
|
|
544
|
+
const line = lines[i]
|
|
545
|
+
if (/^\s*```/.test(line)) inFence = !inFence
|
|
546
|
+
out.push(line)
|
|
547
|
+
if (inFence) continue
|
|
548
|
+
const m = line.match(/^(#{2,4})\s+(.+?)\s*$/)
|
|
549
|
+
if (!m) continue
|
|
550
|
+
const cardTitle = m[2].trim()
|
|
551
|
+
if (!cardTitle) continue
|
|
552
|
+
const want = wbEntryIdPre(workspaceKey, rel, cardTitle)
|
|
553
|
+
const anchor = '<!-- memory:' + want + ' -->'
|
|
554
|
+
const next = lines[i + 1] == null ? '' : lines[i + 1]
|
|
555
|
+
const have = next.trim().match(WB_ANCHOR_LINE_RE_V1)
|
|
556
|
+
if (have) {
|
|
557
|
+
// 标题未变 ⇒ **原样保留既有锚点行**(不重发、不删除; 否则二次应用会丢失锚点, 破坏幂等)
|
|
558
|
+
if (have[1] === want) { unchanged++; out.push(next) } else { updated++; out.push(anchor) }
|
|
559
|
+
i++ // 吞掉旧锚点行(更新场景下已改为重发新锚点)
|
|
560
|
+
continue
|
|
561
|
+
}
|
|
562
|
+
added++
|
|
563
|
+
out.push(anchor)
|
|
564
|
+
}
|
|
565
|
+
return { text: out.join('\n'), added, updated, unchanged }
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/** 从文本抽出所有锚点 id(供 lint / 一致性检查)。 */
|
|
569
|
+
export function collectAnchorIdsPre(text) {
|
|
570
|
+
const ids = []
|
|
571
|
+
for (const line of String(text || '').split('\n')) {
|
|
572
|
+
const m = line.trim().match(WB_ANCHOR_LINE_RE_V1)
|
|
573
|
+
if (m) ids.push(m[1])
|
|
574
|
+
}
|
|
575
|
+
return ids
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
579
|
+
// 看板投影(2026-09-16「兼并 dsh-graph」)—— 把 sidecar 条目确定性投影成泳道卡片。
|
|
580
|
+
//
|
|
581
|
+
// 立场: 不搬 dsh-graph 的代码(它的领域模型是「目标/Attempt/Supervisor」, 与白板语料不同),
|
|
582
|
+
// 只吸收其**可视化形态**(列式泳道 + 状态徽章 + 卡片), 数据源仍是自己的 handoff/index.json
|
|
583
|
+
// ⇒ 零新插件、零新存储、零 profile 改动, 白板与看板同源同生。
|
|
584
|
+
//
|
|
585
|
+
// 列的划分直接用本项目自己的语义(比 dsh-graph 的通用五列更贴材料):
|
|
586
|
+
// 目标 / 进行中 / 失败与弯路 / 进度与下一步 / 版本归档 —— 「失败与弯路」是本项目最贵的信息。
|
|
587
|
+
// 确定性: 同输入 → 同分列 + 同排序(id 破平), 便于测试与「同输入同字节」比对。
|
|
588
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
589
|
+
|
|
590
|
+
/** 泳道定义(顺序即渲染顺序)。match 按 kind → tag → 小节名 → 小节标题正则, 先命中先归。
|
|
591
|
+
* v2(2026-09-16): 新增 matchTitle 正则 —— 小节级卡片没有 tag(白板正文里几乎没有语义 tag),
|
|
592
|
+
* 只能靠**小节标题**分列; 实测真实数据的标题形如「任务状态」「已试方案与失败原因」「关键坑(血泪)」。 */
|
|
593
|
+
export const WB_KANBAN_LANES_V1 = [
|
|
594
|
+
{ key: 'goal', label: '目标', matchTags: ['type:goal'], matchSections: ['目标'], matchTitle: /^(?:总体)?目标|这是什么|定位|背景/ },
|
|
595
|
+
{ key: 'state', label: '进行中', matchTags: ['type:state'], matchSections: ['任务状态'], matchTitle: /任务状态|现状|进行中|在做/ },
|
|
596
|
+
{ key: 'deadend', label: '失败与弯路', matchTags: ['type:dead-end'], matchSections: ['已试方案与失败原因', '失败'], matchTitle: /失败|弯路|坑|教训|血泪|报错|阻塞/ },
|
|
597
|
+
{ key: 'progress', label: '进度与下一步', matchTags: ['type:progress'], matchSections: ['进度与下一步', '下一步', '进度'], matchTitle: /进度|下一步|待办|todo|后续/i },
|
|
598
|
+
{ key: 'archive', label: '版本归档', matchKinds: ['archive'] },
|
|
599
|
+
]
|
|
600
|
+
|
|
601
|
+
/** 判据 → 徽章:{ text, tone }(tone 供前端上色: ok / warn / unknown)。 */
|
|
602
|
+
function criteriaBadgePre(criteria) {
|
|
603
|
+
const c = String(criteria || 'unknown')
|
|
604
|
+
if (c === 'passed') return { text: '判据通过', tone: 'ok' }
|
|
605
|
+
if (c === 'warned') return { text: '判据告警', tone: 'warn' }
|
|
606
|
+
return { text: '判据未校验', tone: 'unknown' }
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** 条目/小节卡 → 泳道 key(确定性; 命中第一个匹配的泳道, 都不中则归 'misc')。 */
|
|
610
|
+
export function laneOfEntryPre(entry) {
|
|
611
|
+
const e = entry || {}
|
|
612
|
+
const tags = Array.isArray(e.tags) ? e.tags : []
|
|
613
|
+
const sec = String(e.section || e.title || '')
|
|
614
|
+
const kind = String(e.kind || '')
|
|
615
|
+
for (const lane of WB_KANBAN_LANES_V1) {
|
|
616
|
+
if (Array.isArray(lane.matchKinds) && lane.matchKinds.includes(kind)) return lane.key
|
|
617
|
+
if (Array.isArray(lane.matchTags) && lane.matchTags.some((t) => tags.includes(t))) return lane.key
|
|
618
|
+
if (Array.isArray(lane.matchSections) && sec) {
|
|
619
|
+
for (const s of lane.matchSections) if (sec === s || sec.includes(s) || s.includes(sec)) return lane.key
|
|
620
|
+
}
|
|
621
|
+
// 小节标题正则(小节级卡片的主判据)
|
|
622
|
+
if (lane.matchTitle && sec && lane.matchTitle.test(sec)) return lane.key
|
|
623
|
+
}
|
|
624
|
+
return 'misc'
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/** `sec:xxx` 这类自动降 tag 不是语义 tag, 单独归到 sectionTags(不丢弃 —— 它是小节来源标记)。 */
|
|
628
|
+
function splitTagsPre(tags, cap = 6) {
|
|
629
|
+
const real = [], sec = []
|
|
630
|
+
for (const t of Array.isArray(tags) ? tags : []) {
|
|
631
|
+
const s = String(t)
|
|
632
|
+
if (s.startsWith('sec:')) { if (sec.length < 2) sec.push(s.slice(4)) }
|
|
633
|
+
else if (real.length < cap) real.push(s)
|
|
634
|
+
}
|
|
635
|
+
return { tags: real, sectionTags: sec }
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* 把 index.json(+可选小节卡) 投影成看板载荷(纯函数, 零 IO, 确定性)。
|
|
640
|
+
*
|
|
641
|
+
* v2(2026-09-16): `opts.cards` 给定时**以小节卡为数据源**(推荐); 未给定时回落文件级 entries
|
|
642
|
+
* (向后兼容既有调用与套件)。perLaneCap=0(缺省) 表示**不截断** —— v1 默认 60 会静默丢卡
|
|
643
|
+
* (实测丢 19 条), 改为「默认全给 + 前端分页/折叠」, 只有显式传 cap 才截断且回收进 omitted。
|
|
644
|
+
*
|
|
645
|
+
* @returns {{lanes:Array, stats:Object, recent:Array, generatedAt:string}}
|
|
646
|
+
*/
|
|
647
|
+
export function buildKanbanPre(index, opts = {}) {
|
|
648
|
+
const entries = (index && Array.isArray(index.entries)) ? index.entries : []
|
|
649
|
+
const byTag = (index && index.by_tag) || {}
|
|
650
|
+
const versions = (index && index.versions) || {}
|
|
651
|
+
const sectionCards = Array.isArray(opts.cards) && opts.cards.length ? opts.cards : null
|
|
652
|
+
const perLaneCap = Number(opts.perLaneCap) > 0 ? Number(opts.perLaneCap) : 0
|
|
653
|
+
|
|
654
|
+
const lanes = WB_KANBAN_LANES_V1.map((l) => ({ key: l.key, label: l.label, cards: [] }))
|
|
655
|
+
const misc = { key: 'misc', label: '其它', cards: [] }
|
|
656
|
+
|
|
657
|
+
if (sectionCards) {
|
|
658
|
+
// ── 小节级数据源(推荐) ──
|
|
659
|
+
for (const c of sectionCards) {
|
|
660
|
+
const laneKey = laneOfEntryPre(c)
|
|
661
|
+
const t = splitTagsPre(c.tags)
|
|
662
|
+
const body = String(c.body || c.preview || '')
|
|
663
|
+
const card = {
|
|
664
|
+
id: c.id,
|
|
665
|
+
short: String(c.id || '').slice(4, 12),
|
|
666
|
+
title: String(c.title || c.source || ''),
|
|
667
|
+
docTitle: String(c.docTitle || ''),
|
|
668
|
+
source: String(c.source || ''),
|
|
669
|
+
section: String(c.section || ''),
|
|
670
|
+
preview: body.replace(/\s+/g, ' ').trim().slice(0, 400),
|
|
671
|
+
full: body.slice(0, 6000), // P12: 卡上可展开看全文
|
|
672
|
+
tags: t.tags,
|
|
673
|
+
sectionTags: t.sectionTags,
|
|
674
|
+
cues: (Array.isArray(c.cues) ? c.cues : []).slice(0, 3),
|
|
675
|
+
criteria: String(c.criteria || 'unknown'),
|
|
676
|
+
badge: criteriaBadgePre(c.criteria),
|
|
677
|
+
mtime: Number(c.mtime) || 0,
|
|
678
|
+
ts: String(c.ts || ''),
|
|
679
|
+
chars: Number(c.chars) || 0,
|
|
680
|
+
bullets: Number(c.bullets) || 0,
|
|
681
|
+
lineStart: Number(c.lineStart) || 0,
|
|
682
|
+
anchored: !!c.anchored,
|
|
683
|
+
kind: String(c.kind || ''),
|
|
684
|
+
versionCount: Array.isArray(versions[c.id]) ? versions[c.id].length : 0,
|
|
685
|
+
}
|
|
686
|
+
const target = laneKey === 'misc' ? misc : lanes.find((l) => l.key === laneKey)
|
|
687
|
+
if (target) target.cards.push(card)
|
|
688
|
+
}
|
|
689
|
+
} else {
|
|
690
|
+
for (const e of entries) {
|
|
691
|
+
const laneKey = laneOfEntryPre(e)
|
|
692
|
+
const t = splitTagsPre(e.tags)
|
|
693
|
+
const card = {
|
|
694
|
+
id: e.id,
|
|
695
|
+
short: String(e.id || '').slice(4, 12),
|
|
696
|
+
title: String(e.title || e.source || ''),
|
|
697
|
+
docTitle: '',
|
|
698
|
+
source: String(e.source || ''),
|
|
699
|
+
section: String(e.section || ''),
|
|
700
|
+
preview: String(e.preview || '').slice(0, 400),
|
|
701
|
+
full: String(e.preview || '').slice(0, 6000),
|
|
702
|
+
tags: t.tags,
|
|
703
|
+
sectionTags: t.sectionTags,
|
|
704
|
+
cues: (Array.isArray(e.cues) ? e.cues : []).slice(0, 3),
|
|
705
|
+
criteria: String(e.criteria || 'unknown'),
|
|
706
|
+
badge: criteriaBadgePre(e.criteria),
|
|
707
|
+
mtime: Number(e.mtime) || 0,
|
|
708
|
+
ts: String(e.ts || ''),
|
|
709
|
+
chars: Number(e.chars) || 0,
|
|
710
|
+
bullets: 0,
|
|
711
|
+
lineStart: 0,
|
|
712
|
+
anchored: false,
|
|
713
|
+
kind: String(e.kind || ''),
|
|
714
|
+
versionCount: Array.isArray(versions[e.id]) ? versions[e.id].length : 0,
|
|
715
|
+
}
|
|
716
|
+
const target = laneKey === 'misc' ? misc : lanes.find((l) => l.key === laneKey)
|
|
717
|
+
if (target) target.cards.push(card)
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// 确定性排序: 判据权重降序 → mtime 降序 → id 升序(破平)
|
|
722
|
+
const sortCards = (arr) => arr.sort((a, b) => {
|
|
723
|
+
const wa = criteriaWeightPre(a.criteria), wb = criteriaWeightPre(b.criteria)
|
|
724
|
+
if (wa !== wb) return wb - wa
|
|
725
|
+
if (a.mtime !== b.mtime) return b.mtime - a.mtime
|
|
726
|
+
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0
|
|
727
|
+
})
|
|
728
|
+
for (const l of lanes) sortCards(l.cards)
|
|
729
|
+
sortCards(misc.cards)
|
|
730
|
+
|
|
731
|
+
const all = lanes.concat(misc.cards.length ? [misc] : [])
|
|
732
|
+
let omitted = 0
|
|
733
|
+
const out = all.map((l) => {
|
|
734
|
+
const cards = perLaneCap > 0 ? l.cards.slice(0, perLaneCap) : l.cards
|
|
735
|
+
const om = l.cards.length - cards.length
|
|
736
|
+
omitted += om
|
|
737
|
+
return {
|
|
738
|
+
key: l.key, label: l.label,
|
|
739
|
+
count: l.cards.length, // 真实总数(不是显示数 —— 前端据此显示「还有 N 条」)
|
|
740
|
+
shown: cards.length,
|
|
741
|
+
omitted: om, // P8: 显式回收, 不再静默丢失
|
|
742
|
+
truncated: om > 0,
|
|
743
|
+
cards,
|
|
744
|
+
}
|
|
745
|
+
})
|
|
746
|
+
|
|
747
|
+
// P9: 跨泳道「最近更新」聚合视图(取全部卡按 mtime 倒序前 40)
|
|
748
|
+
const flat = all.reduce((a, l) => a.concat(l.cards.map((c) => Object.assign({ laneKey: l.key, laneLabel: l.label }, c))), [])
|
|
749
|
+
const recent = flat.slice().sort((a, b) => (b.mtime - a.mtime) || (a.id < b.id ? -1 : 1)).slice(0, 40)
|
|
750
|
+
|
|
751
|
+
const tagRows = Object.keys(byTag)
|
|
752
|
+
.filter((t) => !t.startsWith('sec:'))
|
|
753
|
+
.map((t) => ({ tag: t, count: (byTag[t] || []).length }))
|
|
754
|
+
.sort((a, b) => b.count - a.count || (a.tag < b.tag ? -1 : 1))
|
|
755
|
+
.slice(0, 24)
|
|
756
|
+
|
|
757
|
+
const total = sectionCards ? sectionCards.length : entries.length
|
|
758
|
+
return {
|
|
759
|
+
lanes: out,
|
|
760
|
+
recent,
|
|
761
|
+
stats: {
|
|
762
|
+
total,
|
|
763
|
+
cardSource: sectionCards ? 'section' : 'file',
|
|
764
|
+
omitted,
|
|
765
|
+
tags: tagRows,
|
|
766
|
+
byKind: ['plan', 'ledger', 'archive'].map((k) => ({ kind: k, count: flat.filter((c) => c.kind === k).length })),
|
|
767
|
+
passed: flat.filter((c) => c.criteria === 'passed').length,
|
|
768
|
+
warned: flat.filter((c) => c.criteria === 'warned').length,
|
|
769
|
+
anchored: flat.filter((c) => c.anchored).length,
|
|
770
|
+
},
|
|
771
|
+
generatedAt: String(opts.now || ''),
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
776
|
+
// 矩阵投影(2026-09-16「整页看板」批) —— 为 conversation.view 宽屏承载面准备的**矩阵视图**。
|
|
777
|
+
//
|
|
778
|
+
// 为什么需要第二套投影: 侧边面板 440px 装不下矩阵(5 列 × 210px), 而整页 ~1600px 可以。
|
|
779
|
+
// ⇒ 同一份小节卡, 按容器宽度选形态: 窄屏用 buildKanbanPre 的列表视图,
|
|
780
|
+
// 宽屏用本函数的矩阵视图(行 = 日期分组, 列 = 小节类型)。
|
|
781
|
+
//
|
|
782
|
+
// 行为什么按日期而不是按文件: 本项目材料是**时间序列**(每天多篇账本),
|
|
783
|
+
// 按日期分组能一眼看出「哪几天没写失败原因」——这是记忆系统的健康度指标,
|
|
784
|
+
// 而按文件平铺 92 行只是流水账。组内仍保留每篇账本的逐条卡片。
|
|
785
|
+
//
|
|
786
|
+
// 与 buildKanbanPre 的关系: 二者**同源**(都吃 buildSectionCardsPre 的卡), 不重复抽取逻辑。
|
|
787
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
788
|
+
|
|
789
|
+
/** 从 source 路径推导账本日期 `YYYY-MM-DD`(handoff-YYYYMMDD-HHMMSS.md); 推不出返回 ''。 */
|
|
790
|
+
export function ledgerDateOfPre(source) {
|
|
791
|
+
const m = String(source || '').match(/handoff-(\d{4})(\d{2})(\d{2})-/)
|
|
792
|
+
if (m) return m[1] + '-' + m[2] + '-' + m[3]
|
|
793
|
+
if (/PLAN\.md$/i.test(String(source || ''))) return '' // 白板是「现在时」, 无日期
|
|
794
|
+
return ''
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* 卡片集合 → 矩阵载荷(纯函数, 零 IO, 确定性)。
|
|
799
|
+
*
|
|
800
|
+
* @param {Array} cards buildSectionCardsPre 的输出
|
|
801
|
+
* @param {object} [opts] { now, maxRows, colKeys }
|
|
802
|
+
* @returns {{columns:Array<{key,label,count}>, rows:Array<{key,label,cells:Object,count}>, stats:Object, others:Object}}
|
|
803
|
+
*/
|
|
804
|
+
export function buildKanbanMatrixPre(cards, opts = {}) {
|
|
805
|
+
const list = Array.isArray(cards) ? cards : []
|
|
806
|
+
const colKeys = Array.isArray(opts.colKeys) && opts.colKeys.length
|
|
807
|
+
? opts.colKeys
|
|
808
|
+
: WB_KANBAN_LANES_V1.map((l) => l.key).concat(['misc'])
|
|
809
|
+
const columns = colKeys.map((k) => {
|
|
810
|
+
const lane = WB_KANBAN_LANES_V1.find((l) => l.key === k)
|
|
811
|
+
return { key: k, label: lane ? lane.label : (k === 'misc' ? '其它' : k), count: 0 }
|
|
812
|
+
})
|
|
813
|
+
const colOf = {}
|
|
814
|
+
for (const c of columns) colOf[c.key] = c
|
|
815
|
+
|
|
816
|
+
// 行分组: 有日期的按日期倒序; 无日期(PLAN/归档)归入单独一行
|
|
817
|
+
const groups = new Map()
|
|
818
|
+
for (const card of list) {
|
|
819
|
+
const d = ledgerDateOfPre(card.source)
|
|
820
|
+
const rowKey = d || '__undated__'
|
|
821
|
+
if (!groups.has(rowKey)) groups.set(rowKey, [])
|
|
822
|
+
groups.get(rowKey).push(card)
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
const mkRow = (key) => ({ key, label: key === '__undated__' ? '白板 / 归档' : key, cells: {}, count: 0, docs: 0 })
|
|
826
|
+
const rows = []
|
|
827
|
+
const dated = [...groups.keys()].filter((k) => k !== '__undated__').sort().reverse()
|
|
828
|
+
const order = dated.concat(groups.has('__undated__') ? ['__undated__'] : [])
|
|
829
|
+
for (const rk of order) {
|
|
830
|
+
const row = mkRow(rk)
|
|
831
|
+
const srcSet = new Set()
|
|
832
|
+
for (const c of groups.get(rk) || []) {
|
|
833
|
+
const laneKey = laneOfEntryPre(c)
|
|
834
|
+
const key = colOf[laneKey] ? laneKey : 'misc'
|
|
835
|
+
if (!row.cells[key]) row.cells[key] = []
|
|
836
|
+
const t = splitTagsPre(c.tags)
|
|
837
|
+
const body = String(c.body || c.preview || '')
|
|
838
|
+
row.cells[key].push({
|
|
839
|
+
id: c.id,
|
|
840
|
+
short: String(c.id || '').slice(4, 12),
|
|
841
|
+
title: String(c.title || ''),
|
|
842
|
+
docTitle: String(c.docTitle || ''),
|
|
843
|
+
source: String(c.source || ''),
|
|
844
|
+
preview: body.replace(/\s+/g, ' ').trim().slice(0, 220),
|
|
845
|
+
full: body.slice(0, 6000),
|
|
846
|
+
bullets: Number(c.bullets) || 0,
|
|
847
|
+
criteria: String(c.criteria || 'unknown'),
|
|
848
|
+
badge: criteriaBadgePre(c.criteria),
|
|
849
|
+
anchored: !!c.anchored,
|
|
850
|
+
anchorId: String(c.anchorId || ''),
|
|
851
|
+
tags: t.tags,
|
|
852
|
+
sectionTags: t.sectionTags,
|
|
853
|
+
mtime: Number(c.mtime) || 0,
|
|
854
|
+
})
|
|
855
|
+
row.count++
|
|
856
|
+
if (colOf[key]) colOf[key].count++
|
|
857
|
+
srcSet.add(String(c.source || ''))
|
|
858
|
+
}
|
|
859
|
+
row.docs = srcSet.size
|
|
860
|
+
// 组内确定性排序(判据权重 → mtime → id 破平, 与列表视图同口径)
|
|
861
|
+
const sortIn = (arr) => arr.sort((a, b) => {
|
|
862
|
+
const wa = criteriaWeightPre(a.criteria), wb = criteriaWeightPre(b.criteria)
|
|
863
|
+
if (wa !== wb) return wb - wa
|
|
864
|
+
if (a.mtime !== b.mtime) return b.mtime - a.mtime
|
|
865
|
+
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0
|
|
866
|
+
})
|
|
867
|
+
for (const k of Object.keys(row.cells)) sortIn(row.cells[k])
|
|
868
|
+
if (row.count) rows.push(row)
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
const maxRows = Number(opts.maxRows) > 0 ? Number(opts.maxRows) : 0
|
|
872
|
+
const shown = maxRows > 0 ? rows.slice(0, maxRows) : rows
|
|
873
|
+
|
|
874
|
+
// 健康度: 有内容但缺「失败与弯路」列的日期(记忆系统最容易退化的位置)
|
|
875
|
+
const missingDeadend = shown.filter((r) => r.count > 0 && !(r.cells.deadend && r.cells.deadend.length)).map((r) => r.label)
|
|
876
|
+
|
|
877
|
+
return {
|
|
878
|
+
columns,
|
|
879
|
+
rows: shown,
|
|
880
|
+
stats: {
|
|
881
|
+
totalCards: list.length,
|
|
882
|
+
totalRows: rows.length,
|
|
883
|
+
shownRows: shown.length,
|
|
884
|
+
dateFrom: dated.length ? dated[dated.length - 1] : '',
|
|
885
|
+
dateTo: dated.length ? dated[0] : '',
|
|
886
|
+
missingDeadend,
|
|
887
|
+
},
|
|
888
|
+
generatedAt: String(opts.now || ''),
|
|
889
|
+
}
|
|
890
|
+
}
|