@modusensus/dsh-mneme 0.10.1 → 0.12.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 (97) hide show
  1. package/README.md +17 -6
  2. package/bin/dsh-mneme-choro.mjs +254 -0
  3. package/docs/CONFIGURATION.md +10 -1
  4. package/lib/api-standalone.js +17 -21
  5. package/lib/api.js +414 -12
  6. package/lib/bootstrap-http.js +105 -0
  7. package/lib/bootstrap.js +5 -1
  8. package/lib/choro-command.js +104 -0
  9. package/lib/client.js +1637 -110
  10. package/lib/config-keys.js +144 -0
  11. package/lib/config.js +60 -1
  12. package/lib/document.js +9 -3
  13. package/lib/dream/clustering.js +6 -1
  14. package/lib/index.js +87 -5
  15. package/lib/inject.js +18 -4
  16. package/lib/lang.js +82 -0
  17. package/lib/profile-facts.js +356 -0
  18. package/lib/quality-filter.js +48 -12
  19. package/lib/service.js +112 -4
  20. package/lib/settings.js +13 -1
  21. package/lib/store.js +978 -27
  22. package/lib/tools.js +244 -1
  23. package/lib/user-settings.js +63 -7
  24. package/lib/wiki/inject.js +164 -0
  25. package/lib/wiki/partition.js +200 -0
  26. package/lib/wiki/plan.js +214 -0
  27. package/lib/wiki/queries.js +360 -0
  28. package/lib/wiki/render.js +187 -0
  29. package/lib/wiki/scan.js +371 -0
  30. package/lib/wiki/t0.js +463 -0
  31. package/lib/wiki/t1.js +496 -0
  32. package/lib/write-admission.js +5 -3
  33. package/package.json +4 -2
  34. package/scripts/sync-test-badge.mjs +1 -1
  35. package/scripts/test-count-sync.mjs +9 -5
  36. package/src/api-standalone.js +17 -21
  37. package/src/api.js +414 -12
  38. package/src/bootstrap-http.js +105 -0
  39. package/src/bootstrap.js +5 -1
  40. package/src/choro-command.js +104 -0
  41. package/src/config-keys.js +144 -0
  42. package/src/config.js +60 -1
  43. package/src/document.js +9 -3
  44. package/src/dream/clustering.js +6 -1
  45. package/src/index.js +87 -5
  46. package/src/inject.js +18 -4
  47. package/src/lang.js +82 -0
  48. package/src/profile-facts.js +356 -0
  49. package/src/quality-filter.js +48 -12
  50. package/src/service.js +112 -4
  51. package/src/settings.js +13 -1
  52. package/src/store.js +978 -27
  53. package/src/tools.js +244 -1
  54. package/src/user-settings.js +63 -7
  55. package/src/wiki/inject.js +164 -0
  56. package/src/wiki/partition.js +200 -0
  57. package/src/wiki/plan.js +214 -0
  58. package/src/wiki/queries.js +360 -0
  59. package/src/wiki/render.js +187 -0
  60. package/src/wiki/scan.js +371 -0
  61. package/src/wiki/t0.js +463 -0
  62. package/src/wiki/t1.js +496 -0
  63. package/src/write-admission.js +5 -3
  64. package/test/api-routes-gaps.test.js +131 -1
  65. package/test/api.test.js +9 -1
  66. package/test/bootstrap.test.js +8 -0
  67. package/test/choro-command.test.js +218 -0
  68. package/test/client.test.js +802 -21
  69. package/test/config-keys-routes.test.js +233 -0
  70. package/test/config.test.js +79 -0
  71. package/test/contrast.test.js +14 -7
  72. package/test/document.test.js +32 -0
  73. package/test/embedding-cache-f32.test.js +276 -0
  74. package/test/inject-pin-explicit.test.js +300 -0
  75. package/test/inject-pin-vector-crowd.test.js +225 -0
  76. package/test/inject.test.js +51 -0
  77. package/test/llm-audit.test.js +29 -1
  78. package/test/persona-settings.test.js +31 -0
  79. package/test/profile-facts.test.js +492 -0
  80. package/test/quality-backfill.test.js +290 -0
  81. package/test/quality-filter.test.js +113 -4
  82. package/test/service.test.js +1 -1
  83. package/test/session-gate.test.js +1 -1
  84. package/test/test-count-sync.test.js +14 -3
  85. package/test/tool-state-ignore.test.js +65 -0
  86. package/test/tools-dto-schema-matrix.test.js +23 -2
  87. package/test/tools.test.js +144 -7
  88. package/test/wiki-api.test.js +273 -0
  89. package/test/wiki-inject.test.js +265 -0
  90. package/test/wiki-partition.test.js +112 -0
  91. package/test/wiki-plan.test.js +80 -0
  92. package/test/wiki-queries.test.js +270 -0
  93. package/test/wiki-scan.test.js +208 -0
  94. package/test/wiki-store.test.js +190 -0
  95. package/test/wiki-t0.test.js +380 -0
  96. package/test/wiki-t1.test.js +235 -0
  97. package/test/wiki-tools.test.js +176 -0
@@ -0,0 +1,360 @@
1
+ // dsh-mneme/src/wiki/queries.js
2
+ // chorography 的读侧(M1):仓库列表 / 模块图 / 页面正文 / 关键词检索。
3
+ // 收尾档追加:向量检索(P37–P39,惰性物化 + cosine)。
4
+ // 检索读侧零写路径的例外只有一处——向量物化写 choro_vectors(P37:检索时惰性
5
+ // 补嵌,host 侧才有 embedder;那是向量账本,不是页面真源)。
6
+ //
7
+ // 真源纪律(spec §4 / P12):页面正文永远读仓库磁盘的 MD(root_path + DB 里的
8
+ // rel_path),库里的表只是索引与状态账。slug 只从 DB 白名单解析,路径拼装零用户
9
+ // 输入——面板与工具传进来的 slug 查不到模块行就返回 null,绝不用它拼路径。
10
+ import { readFileSync, existsSync, realpathSync } from "node:fs";
11
+ import { join, resolve, sep } from "node:path";
12
+
13
+ // 页正文去 frontmatter / CHORO 标记行后作为嵌入文本(P40:嵌页级全文)。
14
+ function pageEmbedText(content) {
15
+ const lines = String(content ?? "").split("\n");
16
+ let start = 0;
17
+ if (lines[0]?.trim() === "---") {
18
+ start = 1;
19
+ while (start < lines.length && lines[start].trim() !== "---") start++;
20
+ start++;
21
+ }
22
+ return lines
23
+ .slice(start)
24
+ .filter((l) => !l.startsWith("<!-- CHORO:T1:") && !l.startsWith("<!-- CHORO:HUMAN:"))
25
+ .join("\n")
26
+ .trim();
27
+ }
28
+
29
+ function cosine(a, b) {
30
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length || a.length === 0) return 0;
31
+ let dot = 0;
32
+ let na = 0;
33
+ let nb = 0;
34
+ for (let i = 0; i < a.length; i++) {
35
+ dot += a[i] * b[i];
36
+ na += a[i] * a[i];
37
+ nb += b[i] * b[i];
38
+ }
39
+ const denom = Math.sqrt(na) * Math.sqrt(nb);
40
+ return denom === 0 ? 0 : dot / denom;
41
+ }
42
+
43
+ export function createChoroQueries({ store }) {
44
+ const db = store.db;
45
+
46
+ /** 已登记仓库(含模块/页面计数,面板仓库选择器与工具的 repo 解析共用)。 */
47
+ function listRepos() {
48
+ const rows = db.prepare(
49
+ `SELECT r.id, r.root_path, r.name, r.plan_fingerprint, r.git_baseline, r.updated_at,
50
+ (SELECT COUNT(*) FROM choro_modules m WHERE m.repo_id = r.id) AS module_count,
51
+ (SELECT COUNT(*) FROM choro_pages p JOIN choro_modules m2 ON p.module_id = m2.id
52
+ WHERE m2.repo_id = r.id) AS page_count
53
+ FROM choro_repos r ORDER BY r.id`
54
+ ).all();
55
+ return rows;
56
+ }
57
+
58
+ /** repo 解析:数字 id 或显示名(root_path 是本机私有路径,不作为对外键)。 */
59
+ function resolveRepo(ref) {
60
+ if (ref === undefined || ref === null || ref === "") {
61
+ const rows = listRepos();
62
+ if (rows.length === 1) return rows[0];
63
+ if (rows.length === 0) return null;
64
+ return { ambiguous: rows.map((r) => ({ id: r.id, name: r.name })) };
65
+ }
66
+ const byId = db.prepare("SELECT * FROM choro_repos WHERE id = ?").get(Number(ref));
67
+ if (byId) return byId;
68
+ const byName = db.prepare("SELECT * FROM choro_repos WHERE name = ?").get(String(ref));
69
+ return byName ?? null;
70
+ }
71
+
72
+ /** 模块表 + 跨模块边(choro_module_map 与面板左侧树共用同一份数据)。 */
73
+ function moduleMap(repoId) {
74
+ const modules = db.prepare(
75
+ `SELECT m.id, m.slug, m.title, m.parent_id, m.file_count, m.fingerprint, m.updated_at,
76
+ p.status AS page_status, p.rel_path AS page_rel_path
77
+ FROM choro_modules m
78
+ LEFT JOIN choro_pages p ON p.module_id = m.id
79
+ WHERE m.repo_id = ? ORDER BY m.slug`
80
+ ).all(repoId);
81
+ const edges = db.prepare(
82
+ `SELECT from_slug, to_slug, weight FROM choro_edges WHERE repo_id = ?
83
+ ORDER BY from_slug, to_slug`
84
+ ).all(repoId);
85
+ return { modules, edges };
86
+ }
87
+
88
+ /**
89
+ * 读一页正文。slug 必须命中 DB 的模块行(P12:路径零用户输入);索引仍在但
90
+ * 文件缺失或不可读时带回相对路径,让调用方给出对应的恢复提示。
91
+ */
92
+ function readPage(repoId, slug) {
93
+ const row = db.prepare(
94
+ `SELECT m.slug, m.title, m.file_count, m.parent_id, p.rel_path, p.status,
95
+ p.git_baseline, p.template_version, p.generated_at, p.human_block_count
96
+ FROM choro_modules m JOIN choro_pages p ON p.module_id = m.id
97
+ WHERE m.repo_id = ? AND m.slug = ?`
98
+ ).get(repoId, String(slug));
99
+ if (!row) return null;
100
+ const repo = db.prepare("SELECT root_path, name FROM choro_repos WHERE id = ?").get(repoId);
101
+ const abs = join(repo.root_path, row.rel_path);
102
+ let content;
103
+ try {
104
+ content = readFileSync(abs, "utf8");
105
+ } catch (cause) {
106
+ const error = cause.code === "ENOENT" || cause.code === "ENOTDIR" ? "page-missing" : "page-unreadable";
107
+ return { error, slug: row.slug, relPath: row.rel_path };
108
+ }
109
+ return {
110
+ slug: row.slug,
111
+ title: row.title,
112
+ fileCount: row.file_count,
113
+ status: row.status,
114
+ gitBaseline: row.git_baseline,
115
+ templateVersion: row.template_version,
116
+ generatedAt: row.generated_at,
117
+ humanBlockCount: row.human_block_count,
118
+ repoName: repo.name,
119
+ relPath: row.rel_path,
120
+ content,
121
+ };
122
+ }
123
+
124
+ /**
125
+ * 关键词检索(P10):按 `## ` 分节做大小写不敏感子串匹配,命中返回页 slug、
126
+ * 节标题、起始行号与摘录。只扫 DB 里登记过的页面文件(不是全目录 glob——
127
+ * 划分变更后未重算的孤儿文件不该被搜出来)。需要提示检索不完整的调用方可接收
128
+ * 不可读的页 slug。q 为空返回空数组不报错。
129
+ */
130
+ function search(repoId, q, { moduleSlug = null, limit = 20, onUnreadablePage } = {}) {
131
+ const needle = String(q ?? "").trim().toLowerCase();
132
+ if (needle === "") return [];
133
+ const cap = Math.max(1, Math.min(100, Number(limit) || 20));
134
+ const pages = db.prepare(
135
+ `SELECT m.slug, m.title AS module_title, p.rel_path
136
+ FROM choro_modules m JOIN choro_pages p ON p.module_id = m.id
137
+ WHERE m.repo_id = ? AND (? IS NULL OR m.slug = ?)
138
+ ORDER BY m.slug`
139
+ ).all(repoId, moduleSlug, moduleSlug);
140
+ const repo = db.prepare("SELECT root_path FROM choro_repos WHERE id = ?").get(repoId);
141
+ const hits = [];
142
+ for (const page of pages) {
143
+ const abs = join(repo.root_path, page.rel_path);
144
+ let text;
145
+ try {
146
+ text = readFileSync(abs, "utf8");
147
+ } catch {
148
+ onUnreadablePage?.(page.slug);
149
+ continue;
150
+ }
151
+ const lines = text.split("\n");
152
+ // 分节:每个 `## ` 标题开启一节,节内所有行都属于该节;文件头(frontmatter
153
+ // 之后到第一个 `## `)算 "(页首)" 一节,保证对 title/摘要的命中也有落点。
154
+ let section = "(页首)";
155
+ let sectionStart = 1;
156
+ for (let i = 0; i < lines.length; i++) {
157
+ const line = lines[i];
158
+ if (i > 0 && /^## /.test(line)) {
159
+ section = line.replace(/^## /, "").trim();
160
+ sectionStart = i + 1;
161
+ }
162
+ if (line.toLowerCase().includes(needle)) {
163
+ hits.push({
164
+ slug: page.slug,
165
+ moduleTitle: page.module_title,
166
+ section,
167
+ line: i + 1,
168
+ sectionStart,
169
+ excerpt: line.trim().slice(0, 200),
170
+ });
171
+ if (hits.length >= cap) return hits;
172
+ }
173
+ }
174
+ }
175
+ return hits;
176
+ }
177
+
178
+ /**
179
+ * 按会话工作目录找已登记仓库(M3 开局注入的唯一入口)。
180
+ *
181
+ * 三个刻意的取舍:
182
+ * 1. **祖先前缀匹配**而不是精确相等——真实会话的 cwd 常常是仓库子目录
183
+ * (`D:/repo/src`),精确相等会让绝大部分会话匹配不到,等于逼用户
184
+ * 「必须在仓库根开会话」这条暗规则。
185
+ * 2. 两侧都做 realpath 归一:Windows 上盘符大小写、8.3 短名、符号链接
186
+ * 会让同一个目录写出不同字符串;t0 落库时存的就是 realpath。
187
+ * 3. **最长前缀胜出**——仓库嵌套(子模块、`.worktrees/`)时取最深那层,
188
+ * 否则内层会话会被外层仓库抢走。
189
+ * cwd 不存在或不是目录时返回 null(不是抛错):注入在热路径上,失败只
190
+ * 意味着「本帧不注入」。
191
+ */
192
+ function repoByCwd(cwd) {
193
+ const raw = String(cwd ?? "").trim();
194
+ if (raw === "") return null;
195
+ let normalized;
196
+ try {
197
+ normalized = realpathSync(resolve(raw));
198
+ } catch {
199
+ return null;
200
+ }
201
+ const cmp = (p) => (process.platform === "win32" ? p.toLowerCase() : p);
202
+ const target = cmp(normalized);
203
+ const rows = db.prepare("SELECT * FROM choro_repos ORDER BY id").all();
204
+ let best = null;
205
+ for (const row of rows) {
206
+ const root = cmp(String(row.root_path ?? ""));
207
+ if (root === "") continue;
208
+ const hit = target === root || target.startsWith(root.endsWith(sep) ? root : root + sep);
209
+ if (!hit) continue;
210
+ if (!best || root.length > cmp(String(best.root_path)).length) best = row;
211
+ }
212
+ return best ?? null;
213
+ }
214
+
215
+ /**
216
+ * 注入与 /choro 共用的最小提纲(M3):模块名 + 文件数 + 页状态 + 页相对路径。
217
+ * **不读页正文**——spec §7 要求「只注标题级不注正文」,正文走 readPage。
218
+ * parentSlug 单独解出来给渲染层做层级缩进(DB 里存的是 parent_id)。
219
+ */
220
+ function outline(repoId) {
221
+ const repo = db.prepare(
222
+ "SELECT id, root_path, name, git_baseline, plan_fingerprint, updated_at FROM choro_repos WHERE id = ?"
223
+ ).get(repoId);
224
+ if (!repo) return null;
225
+ const rows = db.prepare(
226
+ `SELECT m.id, m.slug, m.title, m.parent_id, m.file_count,
227
+ p.status AS page_status, p.rel_path AS page_rel_path, p.generated_at
228
+ FROM choro_modules m
229
+ LEFT JOIN choro_pages p ON p.module_id = m.id
230
+ WHERE m.repo_id = ? ORDER BY m.slug`
231
+ ).all(repoId);
232
+ const slugById = new Map(rows.map((r) => [r.id, r.slug]));
233
+ let generatedAt = null;
234
+ const modules = rows.map((r) => {
235
+ if (r.generated_at && (generatedAt === null || r.generated_at > generatedAt)) generatedAt = r.generated_at;
236
+ return {
237
+ slug: r.slug,
238
+ title: r.title,
239
+ fileCount: r.file_count,
240
+ parentSlug: r.parent_id === null || r.parent_id === undefined ? null : slugById.get(r.parent_id) ?? null,
241
+ status: r.page_status ?? null,
242
+ relPath: r.page_rel_path ?? null,
243
+ };
244
+ });
245
+ return {
246
+ repo: {
247
+ id: repo.id,
248
+ name: repo.name,
249
+ rootPath: repo.root_path,
250
+ gitBaseline: repo.git_baseline,
251
+ updatedAt: repo.updated_at,
252
+ },
253
+ generatedAt: generatedAt ?? repo.updated_at ?? null,
254
+ modules,
255
+ };
256
+ }
257
+
258
+ // --- chorography 向量(收尾档 P37–P39)---------------------------------
259
+ // 惰性物化(P37):只在 host 侧检索路径调用(embedder 由 index.js 注入);
260
+ // CLI 环境没有 embedder → 向量永不物化,检索退关键词。staleness 两维:
261
+ // 页 generated_at 变了(内容变了)、行 dim ≠ 本次查询向量 dim(换 embedder
262
+ // 模型了,P38)→ 重嵌。dim 失配是全表性的,遇到一次就把整仓重嵌(几十页)。
263
+ async function embedPageVectors(repoId, embedder, { limit = 200, expectDim = null } = {}) {
264
+ const pages = db.prepare(
265
+ `SELECT p.id, p.generated_at, m.slug, m.title, p.rel_path
266
+ FROM choro_pages p JOIN choro_modules m ON p.module_id = m.id
267
+ WHERE m.repo_id = ? ORDER BY m.slug`
268
+ ).all(repoId);
269
+ const repo = db.prepare("SELECT root_path FROM choro_repos WHERE id = ?").get(repoId);
270
+ const embedOne = embedder.embedSingle ? (t) => embedder.embedSingle(t) : (t) => embedder.embed(t);
271
+ const rows = store.getChoroPageVectors(repoId);
272
+ const bySlug = new Map(rows.map((r) => [r.slug, r]));
273
+ const result = { embedded: 0, skipped: 0, stale: 0, failed: [], dimMismatch: false };
274
+ // dim 失配的全量重嵌(P38):expectDim 来自查询向量(调用方先嵌查询再物化,
275
+ // 维度免费拿到)。expectDim 与任何现存行失配 → 本轮 fresh 检查失效,全量重嵌;
276
+ // 没给 expectDim(无查询场景)退化为只按 generated_at 判 staleness。
277
+ const dimDrift = expectDim !== null && rows.some((r) => r.dim !== expectDim);
278
+ if (dimDrift) result.dimMismatch = true;
279
+ for (const page of pages) {
280
+ if (result.embedded >= limit) break;
281
+ const row = bySlug.get(page.slug);
282
+ const fresh = !dimDrift && row && row.source_generated_at === page.generated_at && row.vector;
283
+ if (fresh) { result.skipped++; continue; }
284
+ if (row && row.vector) result.stale++;
285
+ const abs = join(repo.root_path, page.rel_path);
286
+ if (!existsSync(abs)) continue;
287
+ let text;
288
+ try {
289
+ text = pageEmbedText(readFileSync(abs, "utf8"));
290
+ } catch {
291
+ result.failed.push({ slug: page.slug, reason: "page-unreadable" });
292
+ continue;
293
+ }
294
+ if (text.trim() === "") { result.skipped++; continue; }
295
+ let vector;
296
+ try {
297
+ vector = await embedOne(text);
298
+ } catch (err) {
299
+ result.failed.push({ slug: page.slug, reason: String(err?.message ?? err).slice(0, 120) });
300
+ continue;
301
+ }
302
+ if (!Array.isArray(vector) || vector.length === 0) {
303
+ result.failed.push({ slug: page.slug, reason: "embedder-returned-no-vector" });
304
+ continue;
305
+ }
306
+ if (row && row.dim !== vector.length) result.dimMismatch = true;
307
+ store.saveChoroPageVector(page.id, {
308
+ vector,
309
+ dim: vector.length,
310
+ model: String(embedder.model ?? "choro"),
311
+ sourceGeneratedAt: page.generated_at,
312
+ createdAt: new Date().toISOString(),
313
+ });
314
+ result.embedded++;
315
+ }
316
+ return result;
317
+ }
318
+
319
+ /** 嵌查询文本(宿主侧)。embedder 缺席 / 不可用 → null。 */
320
+ async function embedQueryText(embedder, q) {
321
+ if (!embedder) return null;
322
+ const embedOne = embedder.embedSingle ? (t) => embedder.embedSingle(t) : (t) => embedder.embed(t);
323
+ try {
324
+ const v = await embedOne(String(q ?? ""));
325
+ return Array.isArray(v) && v.length > 0 ? v : null;
326
+ } catch {
327
+ return null;
328
+ }
329
+ }
330
+
331
+ /** 纯余弦 Top-N:向量已给定时零嵌入。行 dim 与查询失配的行过滤掉(P38)。 */
332
+ function semanticTopN(repoId, queryVector, { limit = 10 } = {}) {
333
+ if (!Array.isArray(queryVector) || queryVector.length === 0) return [];
334
+ const rows = store.getChoroPageVectors(repoId).filter((r) => r.vector && r.dim === queryVector.length);
335
+ return rows
336
+ .map((r) => ({
337
+ slug: r.slug,
338
+ title: r.title,
339
+ score: Number(cosine(queryVector, JSON.parse(r.vector)).toFixed(4)),
340
+ }))
341
+ .sort((a, b) => b.score - a.score)
342
+ .slice(0, Math.max(1, Math.min(50, Number(limit) || 10)));
343
+ }
344
+
345
+ /** 便捷组合:嵌查询 + Top-N(P39:独立数组,不与 keyword hits 混排序)。
346
+ * 空查询 → [](空白查询的「语义」是噪声,面板逐键触发时尤其如此);
347
+ * embedder 缺席 → null(调用方退纯关键词)。 */
348
+ async function searchSemantic(repoId, embedder, q, { limit = 10 } = {}) {
349
+ if (!embedder) return null;
350
+ if (String(q ?? "").trim() === "") return [];
351
+ const queryVector = await embedQueryText(embedder, q);
352
+ if (!queryVector) return [];
353
+ return semanticTopN(repoId, queryVector, { limit });
354
+ }
355
+
356
+ return {
357
+ listRepos, resolveRepo, moduleMap, readPage, search, repoByCwd, outline,
358
+ embedPageVectors, searchSemantic, embedQueryText, semanticTopN,
359
+ };
360
+ }
@@ -0,0 +1,187 @@
1
+ // dsh-mneme/src/wiki/render.js
2
+ // T0 骨架页与 index.md 的确定性渲染(spec §6 步 6),以及混合所有权的保留区机制
3
+ // (spec §4:`<!-- CHORO:HUMAN -->` 标记块重生成时逐字节保留)。
4
+ //
5
+ // 确定性纪律(spec §9 可重建不变量):正文零时间戳、零随机——所有列表先排序再拼;
6
+ // 页面唯一的时刻 frontmatter.generatedAt 由 t0.js 从 state.json 沿用(实现计划
7
+ // P3:时刻收进 state.json,失败不推进,页面字节才可能跨 run 稳定)。
8
+ //
9
+ // 保留区规则(写死、可测):
10
+ // 1. 抽取:START…END 之间连同两行标记整体抽出,一个字节不动;
11
+ // 2. 回填:新正文之后按原相对顺序接回全部块;
12
+ // 3. 重生成永不删块、永不改写块内内容(包括缩进与格式)——红线 6;
13
+ // 4. 没有块时整页覆盖(spec:人在块外的手改不保证存活,是明说语义不是事故)。
14
+
15
+ export const TEMPLATE_VERSION = "1";
16
+
17
+ export const HUMAN_START = "<!-- CHORO:HUMAN:START -->";
18
+ export const HUMAN_END = "<!-- CHORO:HUMAN:END -->";
19
+
20
+ // T1 语义段的包装标记。写的人是 t1.js,读的人还有 t0.js——重跑 T0 时它要能认出
21
+ // 「这页已经被富化过」,否则 enriched → skeleton 的退回是静默的。标记随页面格式
22
+ // 走,所以与 HUMAN 标记同住这里,t1.js 转出给调用方。
23
+ export const T1_SECTION_START = "<!-- CHORO:T1:START -->";
24
+ export const T1_SECTION_END = "<!-- CHORO:T1:END -->";
25
+
26
+ /** 抽出全部保留区。返回 { blocks: string[](含标记本身,原样), count }。 */
27
+ export function extractHumanBlocks(pageText) {
28
+ if (typeof pageText !== "string") return { blocks: [], count: 0 };
29
+ const blocks = [];
30
+ const re = new RegExp(`${escapeRegExp(HUMAN_START)}[\\s\\S]*?${escapeRegExp(HUMAN_END)}`, "g");
31
+ let m;
32
+ while ((m = re.exec(pageText)) !== null) blocks.push(m[0]);
33
+ return { blocks, count: blocks.length };
34
+ }
35
+
36
+ /** 新正文 + 保留区(按原顺序接在页尾,各隔一个空行)。 */
37
+ export function injectHumanBlocks(newContent, blocks) {
38
+ if (!blocks || blocks.length === 0) return newContent;
39
+ return `${newContent.replace(/\n*$/, "\n")}\n${blocks.join("\n\n")}\n`;
40
+ }
41
+
42
+ /**
43
+ * 渲染一页骨架。meta: { slug, title, parents[], sourceFiles[], gitBaseline,
44
+ * generatedAt, status };mod 是 partition 的产物。
45
+ */
46
+ export function renderSkeletonPage(mod, meta) {
47
+ const lines = [];
48
+ lines.push("---");
49
+ lines.push("choro: v1");
50
+ lines.push(`slug: ${meta.slug}`);
51
+ lines.push(`title: ${yamlScalar(meta.title)}`);
52
+ if (meta.parents && meta.parents.length > 0) {
53
+ lines.push("parents:");
54
+ for (const p of meta.parents) lines.push(` - ${p}`);
55
+ } else {
56
+ lines.push("parents: []");
57
+ }
58
+ lines.push("sourceFiles:");
59
+ for (const f of meta.sourceFiles) lines.push(` - ${f}`);
60
+ lines.push(`gitBaseline: ${meta.gitBaseline}`);
61
+ lines.push(`templateVersion: ${TEMPLATE_VERSION}`);
62
+ lines.push(`language: ${meta.language ?? "zh"}`);
63
+ lines.push(`status: ${meta.status}`);
64
+ lines.push(`generatedAt: ${meta.generatedAt}`);
65
+ lines.push("---");
66
+ lines.push("");
67
+ lines.push(`# ${meta.title}`);
68
+ lines.push("");
69
+ // 注意:这行说明里**不得出现成对的标记字面量**——抽取用的是全局正则,说明行
70
+ // 里一旦同时含 START 与 END,下一轮重生成就会把它当成一块保留区接在页尾
71
+ // (实测:第二轮页面凭空多两行)。所以说标记时用名字,不用原文。
72
+ lines.push(
73
+ `> 确定性骨架(T0,零模型):由文件树与 import 图生成。` +
74
+ `CHORO:HUMAN 标记对(START/END 两个 HTML 注释)包住的内容在重生成时逐字节保留;` +
75
+ `标记对之外的手改不保留(想保住一段就把它挪进标记对里)。`
76
+ );
77
+ lines.push("");
78
+ lines.push(`## 文件清单(${mod.fileCount} 个)`);
79
+ lines.push("");
80
+ lines.push("| 文件 | 行数 |");
81
+ lines.push("| --- | ---: |");
82
+ for (const [file, lines2] of mod.fileLines) lines.push(`| ${file} | ${lines2} |`);
83
+ lines.push("");
84
+ lines.push("## 入口");
85
+ lines.push("");
86
+ if (mod.entries.length > 0) {
87
+ for (const e of mod.entries) lines.push(`- \`${e}\``);
88
+ } else {
89
+ lines.push("- 无(没有 index/main 命名的文件,也不是包入口)");
90
+ }
91
+ lines.push("");
92
+ lines.push("## 内部依赖");
93
+ lines.push("");
94
+ if (mod.upstream.length > 0) {
95
+ lines.push(`- 上游依赖(本模块引用的模块):${mod.upstream.map((s) => `\`${s}\``).join("、")}`);
96
+ }
97
+ if (mod.downstream.length > 0) {
98
+ lines.push(`- 下游(引用本模块的模块):${mod.downstream.map((s) => `\`${s}\``).join("、")}`);
99
+ }
100
+ if (mod.upstream.length === 0 && mod.downstream.length === 0) {
101
+ lines.push("- 无内部依赖边(孤立模块)");
102
+ }
103
+ lines.push("");
104
+ lines.push("## 外部依赖");
105
+ lines.push("");
106
+ if (mod.externalDeps.length > 0) {
107
+ for (const d of mod.externalDeps) lines.push(`- \`${d.pkg}\` ×${d.count}`);
108
+ } else {
109
+ lines.push("- 无外部 npm 依赖");
110
+ }
111
+ lines.push("");
112
+ lines.push("## 生成说明");
113
+ lines.push("");
114
+ lines.push(
115
+ `- 模块划分:目录初稿 + 兄弟组内聚合并(内部边 ≥3),超上限逐级下切;` +
116
+ `本组 ${mod.fileCount} 个文件。`
117
+ );
118
+ if (mod.unresolvedCount > 0) {
119
+ lines.push(`- ${mod.unresolvedCount} 条相对引用没有解析到仓库内文件(别名/动态路径),未计入依赖图。`);
120
+ }
121
+ lines.push("");
122
+ return lines.join("\n");
123
+ }
124
+
125
+ /** index.md:模块目录 + 仓库级信息。同样零时间戳(时刻在 state.json)。 */
126
+ export function renderIndex(modules, repoMeta) {
127
+ const lines = [];
128
+ lines.push("---");
129
+ lines.push("choro: v1");
130
+ lines.push(`repo: ${yamlScalar(repoMeta.name)}`);
131
+ lines.push(`gitBaseline: ${repoMeta.gitBaseline}`);
132
+ lines.push(`templateVersion: ${TEMPLATE_VERSION}`);
133
+ lines.push(`generatedAt: ${repoMeta.generatedAt}`);
134
+ lines.push("language: zh");
135
+ lines.push("---");
136
+ lines.push("");
137
+ lines.push(`# ${repoMeta.name} · 仓库地志`);
138
+ lines.push("");
139
+ lines.push(
140
+ `T0 确定性骨架(零模型)。页面在 \`modules/\` 下,真源是本目录的 Markdown;` +
141
+ `本文件整页机器所有、可随时重算(spec §4)。`
142
+ );
143
+ lines.push("");
144
+ lines.push(`- 扫描 ${repoMeta.fileCount} 个文件,划出 ${modules.length} 个模块。`);
145
+ if (repoMeta.skipped > 0) lines.push(`- ${repoMeta.skipped} 个文件读取失败,已跳过(页脚与 receipt 都有记)。`);
146
+ if (repoMeta.unresolved > 0) lines.push(`- ${repoMeta.unresolved} 条相对引用未解析(别名/动态路径),未计入依赖图。`);
147
+ lines.push("");
148
+ lines.push("## 模块目录");
149
+ lines.push("");
150
+ lines.push("| 模块 | 页面 | 文件数 | 内部边 | 外部依赖 |");
151
+ lines.push("| --- | --- | ---: | ---: | ---: |");
152
+ for (const mod of modules) {
153
+ const pageRel = `modules/${mod.slug}.md`;
154
+ lines.push(
155
+ `| ${mod.title} | [${mod.slug}](${pageRel}) | ${mod.fileCount} | ` +
156
+ `${mod.internalEdges.length} | ${mod.externalDeps.length} |`
157
+ );
158
+ }
159
+ lines.push("");
160
+ lines.push("## 脚本命令(根 package.json)");
161
+ lines.push("");
162
+ const scriptNames = Object.keys(repoMeta.scripts ?? {}).sort();
163
+ if (scriptNames.length > 0) {
164
+ lines.push("| 命令 | 内容 |");
165
+ lines.push("| --- | --- |");
166
+ for (const name of scriptNames) lines.push(`| \`${name}\` | ${escapeCell(String(repoMeta.scripts[name]))} |`);
167
+ } else {
168
+ lines.push("- 无 scripts 字段或没有根 package.json。");
169
+ }
170
+ lines.push("");
171
+ return lines.join("\n");
172
+ }
173
+
174
+ // YAML 标量:含冒号/引号等特殊字符时加引号(frontmatter 里的 title/repo 是人名或
175
+ // 目录名,通常无害,但这层便宜且省掉转义 bug)。
176
+ function yamlScalar(text) {
177
+ const s = String(text ?? "");
178
+ return /[:#'\"[\]{},&*?|>%@`]/.test(s) || s.trim() !== s ? JSON.stringify(s) : s;
179
+ }
180
+
181
+ function escapeCell(text) {
182
+ return text.replace(/\|/g, "\\|").replace(/\n/g, " ");
183
+ }
184
+
185
+ function escapeRegExp(text) {
186
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
187
+ }