echoctl 0.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 (71) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +171 -0
  3. package/bin/echoctl.js +2 -0
  4. package/package.json +56 -0
  5. package/scripts/annotate.js +73 -0
  6. package/scripts/build-docs.js +805 -0
  7. package/scripts/cli/commands/capture.js +20 -0
  8. package/scripts/cli/commands/constants.js +70 -0
  9. package/scripts/cli/commands/doctor.js +10 -0
  10. package/scripts/cli/commands/helpers.js +27 -0
  11. package/scripts/cli/commands/hook.js +48 -0
  12. package/scripts/cli/commands/import_cmd.js +184 -0
  13. package/scripts/cli/commands/init.js +45 -0
  14. package/scripts/cli/commands/mcp.js +16 -0
  15. package/scripts/cli/commands/migrate.js +65 -0
  16. package/scripts/cli/commands/pipeline.js +26 -0
  17. package/scripts/cli/commands/project.js +35 -0
  18. package/scripts/cli/commands/refresh.js +14 -0
  19. package/scripts/cli/commands/search.js +28 -0
  20. package/scripts/cli/commands/serve.js +73 -0
  21. package/scripts/cli/commands/status.js +11 -0
  22. package/scripts/cli/commands/stop.js +136 -0
  23. package/scripts/cli/commands/tag.js +89 -0
  24. package/scripts/cli/echoctl.js +44 -0
  25. package/scripts/convert.js +55 -0
  26. package/scripts/import-sessions.js +213 -0
  27. package/scripts/index.js +92 -0
  28. package/scripts/lib/cli/names.js +33 -0
  29. package/scripts/lib/domain/anchor.js +78 -0
  30. package/scripts/lib/domain/echo-format.js +265 -0
  31. package/scripts/lib/domain/errors.js +8 -0
  32. package/scripts/lib/domain/validation.js +126 -0
  33. package/scripts/lib/hooks/capture.js +401 -0
  34. package/scripts/lib/hooks/status.js +78 -0
  35. package/scripts/lib/i18n/format.js +183 -0
  36. package/scripts/lib/i18n/messages/en.js +41 -0
  37. package/scripts/lib/i18n/messages/zh-CN.js +40 -0
  38. package/scripts/lib/import/manifest.js +87 -0
  39. package/scripts/lib/import/providers/claude-code.js +272 -0
  40. package/scripts/lib/import/scanner.js +128 -0
  41. package/scripts/lib/infra/config.js +36 -0
  42. package/scripts/lib/infra/echo-paths.js +44 -0
  43. package/scripts/lib/infra/markdown-store.js +161 -0
  44. package/scripts/lib/infra/query-log.js +27 -0
  45. package/scripts/lib/infra/read-stdin.js +11 -0
  46. package/scripts/lib/infra/workspace.js +93 -0
  47. package/scripts/lib/interfaces/mcp/server.js +151 -0
  48. package/scripts/lib/interfaces/mcp/tools.js +152 -0
  49. package/scripts/lib/mcp-server.js +3 -0
  50. package/scripts/lib/usecases/aggregate-all-projects.js +45 -0
  51. package/scripts/lib/usecases/convert-buffer.js +43 -0
  52. package/scripts/lib/usecases/discover-claude-imports.js +80 -0
  53. package/scripts/lib/usecases/import-claude-project.js +89 -0
  54. package/scripts/lib/usecases/init-workspace.js +52 -0
  55. package/scripts/lib/usecases/install-claude-hook.js +139 -0
  56. package/scripts/lib/usecases/legacy-candidates.js +134 -0
  57. package/scripts/lib/usecases/live-session-state.js +109 -0
  58. package/scripts/lib/usecases/migrate-legacy-buffer.js +209 -0
  59. package/scripts/lib/usecases/project-registry.js +170 -0
  60. package/scripts/lib/usecases/query-articles.js +380 -0
  61. package/scripts/lib/usecases/refresh-serve.js +77 -0
  62. package/scripts/lib/usecases/run-doctor.js +213 -0
  63. package/scripts/lib/usecases/run-pipeline.js +104 -0
  64. package/scripts/lib/usecases/snapshot-manifest.js +48 -0
  65. package/scripts/lib/usecases/status-collector.js +142 -0
  66. package/scripts/lib/usecases/strip-comments.js +7 -0
  67. package/scripts/lib/usecases/write-comment.js +122 -0
  68. package/scripts/resolve.js +65 -0
  69. package/scripts/search.js +98 -0
  70. package/scripts/serve.js +778 -0
  71. package/scripts/validate.js +79 -0
@@ -0,0 +1,805 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+
4
+ const { resolveDataDirs } = require("./lib/infra/echo-paths");
5
+ const { resolveEchoHomePath } = require("./lib/infra/workspace");
6
+ const store = require("./lib/infra/markdown-store");
7
+ const { stripCommentSections } = require("./lib/usecases/strip-comments");
8
+ const { TURN_MARKER_REGEX } = require("./lib/domain/echo-format");
9
+
10
+ const PACKAGE_DOCS_ROOT = path.resolve(__dirname, "../../docs");
11
+ const PACKAGE_ROOT = path.resolve(__dirname, "..");
12
+
13
+ function defaultDocsRoot() {
14
+ return path.join(resolveEchoHomePath(), ".site");
15
+ }
16
+
17
+ function sitePaths(docsRoot) {
18
+ return {
19
+ docsRoot,
20
+ generatedArticlesDir: path.join(docsRoot, "articles", "generated"),
21
+ generatedLiveDir: path.join(docsRoot, "live", "generated"),
22
+ sidebarFile: path.join(docsRoot, ".vitepress", "echo-sidebar.mts"),
23
+ };
24
+ }
25
+
26
+ function ensureDir(dir) {
27
+ fs.mkdirSync(dir, { recursive: true });
28
+ }
29
+
30
+ function cleanDir(dir) {
31
+ fs.rmSync(dir, { recursive: true, force: true });
32
+ ensureDir(dir);
33
+ }
34
+
35
+ function copyDir(src, dest) {
36
+ fs.rmSync(dest, { recursive: true, force: true });
37
+ fs.cpSync(src, dest, { recursive: true });
38
+ }
39
+
40
+ function ensureRuntimeDependencies(docsRoot) {
41
+ if (path.resolve(docsRoot) === path.resolve(PACKAGE_DOCS_ROOT)) return;
42
+
43
+ const siteModules = path.join(docsRoot, "node_modules");
44
+ ensureDir(siteModules);
45
+
46
+ for (const name of ["vitepress", "vue"]) {
47
+ const target = path.join(PACKAGE_ROOT, "node_modules", name);
48
+ const link = path.join(siteModules, name);
49
+ if (!fs.existsSync(target) || fs.existsSync(link)) continue;
50
+ try {
51
+ fs.symlinkSync(target, link, "dir");
52
+ } catch (_) {}
53
+ }
54
+ }
55
+
56
+ function ensureSiteScaffold(docsRoot) {
57
+ ensureDir(docsRoot);
58
+ ensureDir(path.join(docsRoot, "articles"));
59
+ ensureDir(path.join(docsRoot, "tags"));
60
+ ensureDir(path.join(docsRoot, "live"));
61
+
62
+ if (path.resolve(docsRoot) !== path.resolve(PACKAGE_DOCS_ROOT)) {
63
+ copyDir(path.join(PACKAGE_DOCS_ROOT, ".vitepress"), path.join(docsRoot, ".vitepress"));
64
+ }
65
+
66
+ ensureRuntimeDependencies(docsRoot);
67
+ }
68
+
69
+ function escapeHtml(value) {
70
+ return String(value ?? "")
71
+ .replace(/&/g, "&")
72
+ .replace(/</g, "&lt;")
73
+ .replace(/>/g, "&gt;")
74
+ .replace(/"/g, "&quot;");
75
+ }
76
+
77
+ function escapeFrontmatterString(value) {
78
+ return String(value ?? "").replace(/\\/g, "\\\\").replace(/"/g, '\\"');
79
+ }
80
+
81
+ function articleSlug(article) {
82
+ const base = String(article.id || path.basename(article.relPath, ".md"));
83
+ const slug = base
84
+ .trim()
85
+ .toLowerCase()
86
+ .replace(/[^a-z0-9._-]+/g, "-")
87
+ .replace(/^-+|-+$/g, "");
88
+ const articleIdSlug = slug || encodeURIComponent(base).replace(/%/g, "").toLowerCase();
89
+ const project = articleProject(article);
90
+ if (!project) return articleIdSlug;
91
+ return `${slugText(project)}--${articleIdSlug}`;
92
+ }
93
+
94
+ function slugText(value) {
95
+ const slug = String(value ?? "")
96
+ .trim()
97
+ .toLowerCase()
98
+ .replace(/\s+/g, "-")
99
+ .replace(/[^\p{L}\p{N}._-]+/gu, "-")
100
+ .replace(/^-+|-+$/g, "");
101
+ return slug || "tag";
102
+ }
103
+
104
+ function tagAnchor(tag, count) {
105
+ return `tag-${slugText(tag)}-${count}`;
106
+ }
107
+
108
+ function displayTitle(article) {
109
+ return article.data.alias || article.data.title || article.id;
110
+ }
111
+
112
+ function displayProjectName(projectId) {
113
+ return projectId || "未归类";
114
+ }
115
+
116
+ function articleProject(article) {
117
+ return article.data.project || article._project || null;
118
+ }
119
+
120
+ function articleDisplayTags(article) {
121
+ const projectTag = displayProjectName(articleProject(article));
122
+ const tags = Array.isArray(article.data.tags) ? article.data.tags : [];
123
+ return [projectTag, ...tags.filter((tag) => tag !== projectTag)];
124
+ }
125
+
126
+ function normalizeDate(value) {
127
+ if (!value) return "";
128
+ if (typeof value === "string") {
129
+ const m = value.match(/^(\d{4}-\d{2}-\d{2})/);
130
+ if (m) return m[1];
131
+ }
132
+ const date = value instanceof Date ? value : new Date(value);
133
+ if (Number.isNaN(date.getTime())) return String(value).slice(0, 10);
134
+ return date.toISOString().slice(0, 10);
135
+ }
136
+
137
+ function sortByUpdatedDesc(a, b) {
138
+ const av = new Date(a.data.updated_at || a.data.created_at || 0).getTime();
139
+ const bv = new Date(b.data.updated_at || b.data.created_at || 0).getTime();
140
+ return bv - av || String(a.data.title || a.id).localeCompare(String(b.data.title || b.id));
141
+ }
142
+
143
+ function stripFirstHeading(content) {
144
+ const titleLine = content.match(/^\s*# .*(?:\r?\n|$)/);
145
+ if (!titleLine) return content.trim();
146
+ return content.slice(titleLine[0].length).trim();
147
+ }
148
+
149
+ function renderTurnMarker(raw) {
150
+ const m = raw.match(TURN_MARKER_REGEX);
151
+ const id = m?.[1] || "";
152
+ const speaker = m?.[2] || "unknown";
153
+ const replyTo = m?.[3];
154
+ const replyAttr = replyTo ? ` data-reply-to="${escapeHtml(replyTo)}"` : "";
155
+ return `\n\n<span class="echo-turn-marker" hidden aria-hidden="true" data-turn-id="${escapeHtml(id)}" data-speaker="${escapeHtml(speaker)}"${replyAttr}></span>\n\n`;
156
+ }
157
+
158
+ function escapeHtmlTagsOutsideCode(content) {
159
+ let inFence = false;
160
+ return content.split(/\r?\n/).map((line) => {
161
+ if (/^\s*(```|~~~)/.test(line)) {
162
+ inFence = !inFence;
163
+ return line;
164
+ }
165
+ if (inFence) return line;
166
+ return line.replace(/<\/?[A-Za-z][^>\n]*>/g, (tag) => escapeHtml(tag));
167
+ }).join("\n");
168
+ }
169
+
170
+ function renderBody(article) {
171
+ const withoutComments = stripCommentSections(article.content);
172
+ const withoutTitle = stripFirstHeading(withoutComments);
173
+ return escapeHtmlTagsOutsideCode(withoutTitle)
174
+ .replace(/<!--\s*turn:[\s\S]*?-->/g, renderTurnMarker)
175
+ .trim();
176
+ }
177
+
178
+ function commentsForArticle(article, comments) {
179
+ const project = articleProject(article) || null;
180
+ return comments
181
+ .filter((comment) => comment.target?.article_id === article.id && (comment._project || null) === project)
182
+ .sort((a, b) => String(a.id).localeCompare(String(b.id)));
183
+ }
184
+
185
+ function renderComments(article, comments) {
186
+ const related = commentsForArticle(article, comments);
187
+ if (related.length === 0) {
188
+ return "## 评论区\n\n暂无评论。";
189
+ }
190
+
191
+ const rows = related.map((comment) => {
192
+ const quote = comment.anchor?.quote || comment.id;
193
+ const author = comment.author || "unknown";
194
+ const date = normalizeDate(comment.created_at);
195
+ const replyTo = (comment.evolution?.of || []).join(", ");
196
+ const reply = replyTo ? `<span>回复 ${escapeHtml(replyTo)}</span>` : "";
197
+ const content = escapeHtmlTagsOutsideCode(String(comment.content || "")).trim();
198
+ return [
199
+ `<section class="echo-comment" data-comment-id="${escapeHtml(comment.id)}">`,
200
+ `<div class="echo-comment-head"><strong>${escapeHtml(author)}</strong><span>${escapeHtml(date)}</span>${reply}</div>`,
201
+ `<blockquote>${escapeHtml(quote)}</blockquote>`,
202
+ content || "_无正文_",
203
+ `</section>`,
204
+ ].join("\n\n");
205
+ });
206
+
207
+ return `## 评论区\n\n<div class="echo-comment-list">\n\n${rows.join("\n\n")}\n\n</div>`;
208
+ }
209
+
210
+ function renderCommentsJson(article, comments) {
211
+ const related = commentsForArticle(article, comments);
212
+ const items = related.map((comment) => ({
213
+ id: comment.id,
214
+ author: comment.author || "unknown",
215
+ date: normalizeDate(comment.created_at),
216
+ content: (String(comment.content || "")).trim(),
217
+ quote: comment.anchor?.quote || null,
218
+ evolutionOf: comment.evolution?.of || [],
219
+ evolutionKind: comment.evolution?.kind || "null",
220
+ }));
221
+ return `<script id="echo-comments-data" type="application/json">${JSON.stringify(items)}</script>`;
222
+ }
223
+
224
+ function highlightAnnotations(body, article, comments) {
225
+ const inlineAnnotations = comments.filter(
226
+ (c) => c.target?.article_id === article.id && c.anchor?.quote && c.anchor?.kind !== "article"
227
+ );
228
+ if (inlineAnnotations.length === 0) return body;
229
+
230
+ let result = body;
231
+ for (const ann of inlineAnnotations) {
232
+ const q = ann.anchor.quote;
233
+ const occ = ann.anchor.occurrence || 1;
234
+ const escaped = escapeHtml(q);
235
+
236
+ let idx = -1;
237
+ for (let i = 0; i < occ; i++) {
238
+ idx = result.indexOf(escaped, idx + 1);
239
+ if (idx === -1) break;
240
+ }
241
+ if (idx === -1) continue;
242
+
243
+ const before = result.slice(0, idx);
244
+ const after = result.slice(idx + escaped.length);
245
+ result = `${before}<mark class="echo-highlight" data-ann="${ann.id}">${escaped}</mark>${after}`;
246
+ }
247
+ return result;
248
+ }
249
+
250
+ function renderArticlePage(article, comments) {
251
+ const title = displayTitle(article);
252
+ const tags = articleDisplayTags(article);
253
+ const participants = Array.isArray(article.data.participants)
254
+ ? article.data.participants.map((p) => p.id || p.role).filter(Boolean).join(", ")
255
+ : "";
256
+ const created = normalizeDate(article.data.created_at);
257
+ const updated = normalizeDate(article.data.updated_at);
258
+ const summary = article.data.summary || "";
259
+ const project = articleProject(article) || "";
260
+ const tagHtml = tags.length
261
+ ? tags.map((tag) => `<span>${escapeHtml(tag)}</span>`).join("")
262
+ : "<span>未标记</span>";
263
+
264
+ const projectMeta = project ? `<div><strong>项目</strong><span>${escapeHtml(project)}</span></div>` : "";
265
+
266
+ const bodyHtml = highlightAnnotations(renderBody(article), article, comments);
267
+
268
+ return `---
269
+ title: "${escapeFrontmatterString(title)}"
270
+ echo:
271
+ articleId: ${article.id}
272
+ projectId: ${project ? `"${escapeFrontmatterString(project)}"` : 'null'}
273
+ interactive: ${project && project.startsWith('echo-') ? 'false' : 'true'}
274
+ ---
275
+
276
+ # ${title}
277
+
278
+ <div class="echo-meta-grid">
279
+ <div><strong>创建</strong><span>${escapeHtml(created || "-")}</span></div>
280
+ <div><strong>更新</strong><span>${escapeHtml(updated || "-")}</span></div>
281
+ <div><strong>参与者</strong><span>${escapeHtml(participants || "-")}</span></div>
282
+ <div><strong>ID</strong><span>${escapeHtml(article.id)}</span></div>
283
+ ${projectMeta}
284
+ </div>
285
+
286
+ <div class="echo-tags">${tagHtml}</div>
287
+
288
+ ${summary ? `<p class="echo-summary">${escapeHtml(summary)}</p>` : ""}
289
+
290
+ ${bodyHtml}
291
+
292
+ ---
293
+
294
+ ${renderComments(article, comments)}
295
+
296
+ ${renderCommentsJson(article, comments)}
297
+
298
+ `;
299
+ }
300
+
301
+ function collectProjects(articles) {
302
+ const projects = new Map();
303
+ for (const article of articles) {
304
+ const p = article._project;
305
+ if (p && !projects.has(p)) {
306
+ projects.set(p, { projectId: p, count: 0 });
307
+ }
308
+ if (p) projects.get(p).count++;
309
+ else {
310
+ if (!projects.has("__other__")) projects.set("__other__", { projectId: null, count: 0 });
311
+ projects.get("__other__").count++;
312
+ }
313
+ }
314
+ return [...projects.values()];
315
+ }
316
+
317
+ function groupArticlesByProject(articles) {
318
+ const groups = new Map();
319
+ for (const article of articles) {
320
+ const projectId = article._project || null;
321
+ const key = projectId || "__unassigned__";
322
+ if (!groups.has(key)) {
323
+ groups.set(key, {
324
+ projectId,
325
+ text: displayProjectName(projectId),
326
+ articles: [],
327
+ });
328
+ }
329
+ groups.get(key).articles.push(article);
330
+ }
331
+ return [...groups.values()].sort((a, b) => {
332
+ if (a.projectId === b.projectId) return 0;
333
+ if (a.projectId === null) return 1;
334
+ if (b.projectId === null) return -1;
335
+ return a.text.localeCompare(b.text);
336
+ });
337
+ }
338
+
339
+ function renderArticleIndex(articles) {
340
+ const projectPayload = groupArticlesByProject(articles).map((group) => ({
341
+ anchor: `project-${slugText(group.text)}`,
342
+ key: group.projectId || "__unassigned__",
343
+ label: group.text,
344
+ articles: group.articles.map((article) => ({
345
+ href: `./generated/${articleSlug(article)}`,
346
+ summary: article.data.summary || "无摘要",
347
+ tags: articleDisplayTags(article),
348
+ title: displayTitle(article),
349
+ updated: normalizeDate(article.data.updated_at || article.data.created_at),
350
+ })),
351
+ }));
352
+ const payload = encodeURIComponent(JSON.stringify(projectPayload));
353
+
354
+ return `# 文章
355
+
356
+ 共 ${articles.length} 篇 Echo 文章。
357
+
358
+ <EchoProjectTabs payload="${payload}" />
359
+
360
+ <EchoClaudeImportBanner />
361
+ `;
362
+ }
363
+
364
+ function collectTags(articles) {
365
+ const map = new Map();
366
+ for (const article of articles) {
367
+ const tags = articleDisplayTags(article);
368
+ for (const tag of tags) {
369
+ if (!map.has(tag)) map.set(tag, []);
370
+ map.get(tag).push(article);
371
+ }
372
+ }
373
+ return [...map.entries()].sort((a, b) => b[1].length - a[1].length || a[0].localeCompare(b[0]));
374
+ }
375
+
376
+ function renderTagsIndex(articles) {
377
+ const groups = collectTags(articles);
378
+ const tagPayload = groups.map(([tag, taggedArticles]) => {
379
+ const anchor = tagAnchor(tag, taggedArticles.length);
380
+ return {
381
+ anchor,
382
+ tag,
383
+ articles: taggedArticles.map((article) => ({
384
+ id: article.data.id || article.id,
385
+ title: displayTitle(article),
386
+ summary: article.data.summary || "",
387
+ href: `/articles/generated/${articleSlug(article)}`,
388
+ })),
389
+ };
390
+ });
391
+ const payload = encodeURIComponent(JSON.stringify(tagPayload));
392
+
393
+ return `# 标签
394
+
395
+ 共 ${groups.length} 个标签,来自 ${articles.length} 篇文章。
396
+
397
+ <EchoTagsPage payload="${payload}" />
398
+ `;
399
+ }
400
+
401
+ function renderHomeArticles(articles) {
402
+ return articles.slice(0, 6).map((article) => {
403
+ const title = displayTitle(article);
404
+ const date = normalizeDate(article.data.updated_at || article.data.created_at);
405
+ return `- [${title}](/articles/generated/${articleSlug(article)}) · ${date}`;
406
+ }).join("\n");
407
+ }
408
+
409
+ function updateHome(articles, docsRoot) {
410
+ const recentList = renderHomeArticles(articles);
411
+ const home = `---
412
+ layout: home
413
+
414
+ hero:
415
+ name: "Echo"
416
+ text: "本地优先的 AI 对话知识论坛"
417
+ tagline: 把 AI 编程会话捕获为不可变的 Markdown 文章,支持浏览、搜索、标签、评论、MCP 访问。
418
+ actions:
419
+ - theme: brand
420
+ text: 浏览文章
421
+ link: /articles/
422
+ - theme: alt
423
+ text: 按标签检索
424
+ link: /tags/
425
+
426
+ features:
427
+ - icon: "📝"
428
+ title: 不可变归档
429
+ details: 文章正文一旦创建即不可修改。AI 对话作为源记录永久保存,后续整理通过标签、评论、标注完成。
430
+ - icon: "🔴"
431
+ title: Live Session 实时预览
432
+ details: 正在聊天的内容通过 live session 页面实时查看,前端心跳自动检测更新,无需手动刷新。
433
+ - icon: "🤖"
434
+ title: MCP AI 接口
435
+ details: 9 个 MCP 工具让 AI 助手直接读取、搜索 Echo 本地归档,成为 AI 的长期记忆。
436
+ - icon: "📁"
437
+ title: 多项目支持
438
+ details: 每个项目独立管理,会话自动归入对应项目。空目录需显式注册,未注册目录降级写入 legacy buffer。
439
+ - icon: "🔍"
440
+ title: 全文搜索
441
+ details: 本地搜索索引,通过 CLI 或网页快速找到历史对话中的关键信息。
442
+ - icon: "🏷️"
443
+ title: 标签管理
444
+ details: 为文章打标签、分类整理,支持添加、移除、重命名、删除标签,构建个人知识体系。
445
+ ---
446
+
447
+ ## 最近文章
448
+
449
+ ${recentList || "暂无文章。"}
450
+
451
+ ---
452
+
453
+ *Echo 处于开发阶段,当前通过 npm link 使用开发版。功能和使用说明详见 [README](https://github.com/daxiguaguagua-hash/echo/blob/main/README.md)。*
454
+ `;
455
+ fs.writeFileSync(path.join(docsRoot, "index.md"), home, "utf-8");
456
+ }
457
+
458
+ function writeSidebar(articles, liveSessions, sidebarFile) {
459
+ const renderItem = (article, indent = " ") => {
460
+ const title = displayTitle(article);
461
+ return `${indent}{ text: ${JSON.stringify(title)}, link: '/articles/generated/${articleSlug(article)}' }`;
462
+ };
463
+
464
+ const projectGroupsData = groupArticlesByProject(articles);
465
+ const groupedSlugs = new Set();
466
+ for (const g of projectGroupsData) {
467
+ for (const a of g.articles) {
468
+ groupedSlugs.add(articleSlug(a));
469
+ }
470
+ }
471
+ const recentItems = articles
472
+ .filter((a) => !groupedSlugs.has(articleSlug(a)))
473
+ .slice(0, 10)
474
+ .map((article) => renderItem(article))
475
+ .join(",\n");
476
+ const projectGroups = projectGroupsData.map((group) => {
477
+ const items = group.articles.slice(0, 30).map((article) => renderItem(article, " ")).join(",\n");
478
+ return ` {
479
+ text: ${JSON.stringify(`${group.text} (${group.articles.length})`)},
480
+ collapsed: false,
481
+ items: [
482
+ ${items}
483
+ ],
484
+ }`;
485
+ }).join(",\n");
486
+
487
+ // [LIVE_SESSION_DISABLED] 后期恢复时取消注释
488
+ // const liveItems = (liveSessions || []).map((s) => {
489
+ // const label = s.publishedSlug ? `${s.sessionId} (已发布)` : `${s.sessionId} (LIVE)`;
490
+ // return ` { text: ${JSON.stringify(label)}, link: '/live/generated/${liveSessionSlug(s)}' }`;
491
+ // }).join(",\n");
492
+ const liveItems = "";
493
+
494
+ const liveSection = (liveSessions || []).length > 0 ? ` {
495
+ text: 'Live Sessions',
496
+ collapsed: false,
497
+ items: [
498
+ ${liveItems}
499
+ ],
500
+ },
501
+ ` : "";
502
+
503
+ const sidebar = `export const articleSidebar = [
504
+ {
505
+ text: '文章列表',
506
+ items: [
507
+ { text: '全部文章', link: '/articles/' },
508
+ {
509
+ text: '最近文章',
510
+ collapsed: true,
511
+ items: [
512
+ ${recentItems}
513
+ ],
514
+ },
515
+ {
516
+ text: '项目',
517
+ collapsed: false,
518
+ items: [
519
+ ${projectGroups}
520
+ ],
521
+ },
522
+ ${liveSection} ],
523
+ },
524
+ ]
525
+ `;
526
+ fs.writeFileSync(sidebarFile, sidebar, "utf-8");
527
+ }
528
+
529
+ function loadLiveSessions() {
530
+ const sessions = [];
531
+ const seenRoots = new Set();
532
+ let registeredProjects = [];
533
+
534
+ function addSource(source) {
535
+ const root = path.resolve(source.root);
536
+ if (seenRoots.has(root)) return;
537
+ seenRoots.add(root);
538
+ sources.push({ ...source, root });
539
+ }
540
+
541
+ const sources = [];
542
+
543
+ try {
544
+ const { listProjects } = require("./lib/usecases/project-registry");
545
+ registeredProjects = listProjects();
546
+ } catch (_) {}
547
+
548
+ if (registeredProjects.length > 0) {
549
+ for (const p of registeredProjects) {
550
+ addSource({
551
+ projectId: p.projectId,
552
+ root: p.dataRoot,
553
+ bufferDir: path.join(p.dataRoot, "session-buffer"),
554
+ articlesDir: path.join(p.dataRoot, "articles"),
555
+ });
556
+ }
557
+ } else {
558
+ const dirs = resolveDataDirs();
559
+ addSource({
560
+ projectId: dirs.projectId || null,
561
+ root: dirs.projectRoot,
562
+ bufferDir: dirs.bufferDir,
563
+ articlesDir: dirs.articlesDir,
564
+ });
565
+ }
566
+
567
+ for (const source of sources) {
568
+ if (!fs.existsSync(source.bufferDir)) continue;
569
+ const bufferFiles = fs.readdirSync(source.bufferDir)
570
+ .filter((f) => f.startsWith("session-") && f.endsWith(".md"))
571
+ .sort();
572
+
573
+ for (const bf of bufferFiles) {
574
+ const bufferPath = path.join(source.bufferDir, bf);
575
+ const sessionId = path.basename(bf, ".md");
576
+ let raw;
577
+ try { raw = fs.readFileSync(bufferPath, "utf-8"); } catch (_) { continue; }
578
+ const turnCount = (raw.match(/<!-- turn:/g) || []).length;
579
+ if (turnCount === 0) continue;
580
+
581
+ let publishedSlug = null;
582
+ const articlePath = path.join(source.articlesDir, `${sessionId}.md`);
583
+ if (fs.existsSync(articlePath)) {
584
+ try {
585
+ const article = store.readMarkdownFile(articlePath);
586
+ publishedSlug = articleSlug({ id: sessionId, data: article.data, _project: source.projectId });
587
+ } catch (_) {}
588
+ }
589
+
590
+ sessions.push({
591
+ projectId: source.projectId,
592
+ sessionId,
593
+ bufferPath,
594
+ content: raw,
595
+ turnCount,
596
+ publishedSlug,
597
+ });
598
+ }
599
+ }
600
+
601
+ return sessions;
602
+ }
603
+
604
+ function liveSessionSlug(session) {
605
+ const base = String(session.sessionId)
606
+ .trim()
607
+ .toLowerCase()
608
+ .replace(/[^a-z0-9._-]+/g, "-")
609
+ .replace(/^-+|-+$/g, "");
610
+ const project = session.projectId;
611
+ if (!project) return base;
612
+ return `${slugText(project)}--${base}`;
613
+ }
614
+
615
+ function renderLiveSessionPage(session) {
616
+ const title = `Live: ${session.sessionId}`;
617
+ const project = session.projectId || "";
618
+ const bodyHtml = escapeHtmlTagsOutsideCode(session.content)
619
+ .replace(/<!--\s*turn:[\s\S]*?-->/g, renderTurnMarker)
620
+ .trim();
621
+
622
+ const publishedLink = session.publishedSlug
623
+ ? `<a href="/articles/generated/${session.publishedSlug}">查看已发布文章</a>`
624
+ : "";
625
+
626
+ return `---
627
+ title: "${escapeFrontmatterString(title)}"
628
+ echo:
629
+ sessionId: ${session.sessionId}
630
+ projectId: ${project ? `"${escapeFrontmatterString(project)}"` : "null"}
631
+ live: true
632
+ published: ${session.publishedSlug ? "true" : "false"}
633
+ turnCount: ${session.turnCount}
634
+ ---
635
+
636
+ # ${title}
637
+
638
+ <div class="echo-live-badge">
639
+ <span class="echo-live-dot"></span>
640
+ LIVE · ${session.turnCount} turns · 有更新时自动刷新
641
+ ${publishedLink}
642
+ </div>
643
+
644
+ <EchoLiveSession
645
+ project-id="${escapeHtml(project)}"
646
+ session-id="${escapeHtml(session.sessionId)}"
647
+ published="${session.publishedSlug ? "true" : "false"}"
648
+ published-slug="${escapeHtml(session.publishedSlug || "")}"
649
+ />
650
+
651
+ ${bodyHtml}
652
+ `;
653
+ }
654
+
655
+ function renderLiveSessionsIndex(sessions) {
656
+ if (sessions.length === 0) {
657
+ return `# Live Sessions
658
+
659
+ 暂无正在进行的 AI 会话。开始一个新的 AI 对话后,实时会话将自动出现在这里。
660
+ `;
661
+ }
662
+
663
+ const items = sessions.map((s) => {
664
+ const project = s.projectId || "未归类";
665
+ const badge = s.publishedSlug ? "已发布" : "LIVE";
666
+ const badgeClass = s.publishedSlug ? "echo-ls-published" : "echo-ls-live";
667
+ return `- <span class="echo-ls-badge ${badgeClass}">${badge}</span> [${s.sessionId}](./generated/${liveSessionSlug(s)}) · ${project} · ${s.turnCount} turns`;
668
+ }).join("\n");
669
+
670
+ return `# Live Sessions
671
+
672
+ 正在进行或最近结束的 AI 会话。页面每 30 秒自动刷新。
673
+
674
+ 共 ${sessions.length} 个会话。
675
+
676
+ ${items}
677
+ `;
678
+ }
679
+
680
+ function loadAllArticlesAndComments() {
681
+ const dirs = resolveDataDirs();
682
+ const allArticles = [];
683
+ const allComments = [];
684
+ const sources = [];
685
+ const seenRoots = new Set();
686
+ let registeredProjects = [];
687
+
688
+ function addSource(source) {
689
+ const root = path.resolve(source.root);
690
+ if (seenRoots.has(root)) return;
691
+ seenRoots.add(root);
692
+ sources.push({ ...source, root });
693
+ }
694
+
695
+ try {
696
+ const { listProjects } = require("./lib/usecases/project-registry");
697
+ registeredProjects = listProjects();
698
+ } catch (_) {}
699
+
700
+ if (registeredProjects.length > 0) {
701
+ for (const p of registeredProjects) {
702
+ addSource({
703
+ projectId: p.projectId,
704
+ root: p.dataRoot,
705
+ articlesDir: path.join(p.dataRoot, "articles"),
706
+ commentsDir: path.join(p.dataRoot, "comments"),
707
+ });
708
+ }
709
+ } else {
710
+ addSource({
711
+ projectId: dirs.projectId || null,
712
+ root: dirs.projectRoot,
713
+ articlesDir: dirs.articlesDir,
714
+ commentsDir: dirs.commentsDir,
715
+ });
716
+ }
717
+
718
+ for (const source of sources) {
719
+ const articles = store.loadArticles(source.articlesDir);
720
+ for (const a of articles) {
721
+ a._project = a.data.project || source.projectId || null;
722
+ }
723
+ allArticles.push(...articles);
724
+
725
+ const comments = store.loadComments(source.commentsDir);
726
+ for (const c of comments) {
727
+ c._project = source.projectId || null;
728
+ }
729
+ allComments.push(...comments);
730
+ }
731
+
732
+ // Deduplicate by project-qualified ID (keep first occurrence)
733
+ const seen = new Set();
734
+ const uniqueArticles = [];
735
+ for (const a of allArticles) {
736
+ const key = `${a._project ?? "__none__"}:${a.id}`;
737
+ if (!seen.has(key)) {
738
+ seen.add(key);
739
+ uniqueArticles.push(a);
740
+ }
741
+ }
742
+
743
+ return { articles: uniqueArticles, comments: allComments };
744
+ }
745
+
746
+ function runBuildDocs(opts = {}) {
747
+ const docsRoot = opts.docsRoot || defaultDocsRoot();
748
+ const paths = sitePaths(docsRoot);
749
+ const { articles, comments } = loadAllArticlesAndComments();
750
+ articles.sort(sortByUpdatedDesc);
751
+
752
+ ensureSiteScaffold(docsRoot);
753
+ cleanDir(paths.generatedArticlesDir);
754
+
755
+ // Generate article pages
756
+ for (const article of articles) {
757
+ fs.writeFileSync(
758
+ path.join(paths.generatedArticlesDir, `${articleSlug(article)}.md`),
759
+ renderArticlePage(article, comments),
760
+ "utf-8"
761
+ );
762
+ }
763
+
764
+ // [LIVE_SESSION_DISABLED] 后期恢复时取消下面注释,并删除空数组赋值
765
+ // const liveDir = path.join(docsRoot, "live", "generated");
766
+ // cleanDir(liveDir);
767
+ // const liveSessions = loadLiveSessions();
768
+ // for (const session of liveSessions) {
769
+ // fs.writeFileSync(
770
+ // path.join(liveDir, `${liveSessionSlug(session)}.md`),
771
+ // renderLiveSessionPage(session),
772
+ // "utf-8"
773
+ // );
774
+ // }
775
+ const liveSessions = [];
776
+
777
+ fs.writeFileSync(path.join(docsRoot, "articles", "index.md"), renderArticleIndex(articles), "utf-8");
778
+ fs.writeFileSync(path.join(docsRoot, "tags", "index.md"), renderTagsIndex(articles), "utf-8");
779
+ // fs.writeFileSync(path.join(docsRoot, "live", "index.md"), renderLiveSessionsIndex(liveSessions), "utf-8");
780
+ updateHome(articles, docsRoot);
781
+ writeSidebar(articles, liveSessions, paths.sidebarFile);
782
+
783
+ const summary = [`${articles.length} articles`, `${comments.length} comments`];
784
+ // if (liveSessions.length > 0) summary.push(`${liveSessions.length} live sessions`);
785
+ console.log(`Generated VitePress docs for ${summary.join(", ")}.`);
786
+ return { articles: articles.length, comments: comments.length, liveSessions: 0, docsRoot };
787
+ }
788
+
789
+ if (require.main === module) {
790
+ runBuildDocs();
791
+ }
792
+
793
+ module.exports = {
794
+ runBuildDocs,
795
+ displayTitle,
796
+ loadAllArticlesAndComments,
797
+ loadLiveSessions,
798
+ ensureSiteScaffold,
799
+ articleDisplayTags,
800
+ tagAnchor,
801
+ renderCommentsJson,
802
+ PACKAGE_DOCS_ROOT,
803
+ defaultDocsRoot,
804
+ ensureRuntimeDependencies,
805
+ };