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,778 @@
1
+ const fs = require("fs");
2
+ const http = require("http");
3
+ const { execFileSync, spawn } = require("child_process");
4
+ const path = require("path");
5
+ const { runBuildDocs } = require("./build-docs");
6
+ const { isCaptureEnabled, setCaptureEnabled, getAuthor } = require("./lib/infra/config");
7
+ const { resolveDataDirs } = require("./lib/infra/echo-paths");
8
+ const { listProjects } = require("./lib/usecases/project-registry");
9
+ const { resolveEchoHomePath } = require("./lib/infra/workspace");
10
+ const { cliNames, mcpServerInfo } = require("./lib/cli/names");
11
+ const store = require("./lib/infra/markdown-store");
12
+
13
+ const PACKAGE_ROOT = path.resolve(__dirname, "..");
14
+ const DEFAULT_API_PORT = 8787;
15
+ const DEFAULT_DOCS_PORT = 5173;
16
+ const HOST = "127.0.0.1";
17
+
18
+ function servePidFile() {
19
+ return path.join(resolveEchoHomePath(), ".serve.pid");
20
+ }
21
+
22
+ function serveInfoFile() {
23
+ return path.join(resolveEchoHomePath(), ".serve.json");
24
+ }
25
+
26
+ function serveLogFile() {
27
+ return path.join(resolveEchoHomePath(), ".serve.log");
28
+ }
29
+
30
+ function writeServeInfo(apiPort, docsPort, vitepressPid) {
31
+ fs.writeFileSync(servePidFile(), String(process.pid));
32
+ fs.writeFileSync(serveInfoFile(), JSON.stringify({
33
+ pid: process.pid,
34
+ vitepressPid: vitepressPid || null,
35
+ childPids: vitepressPid ? [vitepressPid] : [],
36
+ apiPort,
37
+ docsPort,
38
+ startedAt: new Date().toISOString(),
39
+ identity: "echo-serve",
40
+ }, null, 2));
41
+ }
42
+
43
+ function clearServeInfo() {
44
+ try { fs.unlinkSync(servePidFile()); } catch (_) {}
45
+ try { fs.unlinkSync(serveInfoFile()); } catch (_) {}
46
+ }
47
+
48
+ function readServeInfo() {
49
+ let raw;
50
+ try {
51
+ raw = fs.readFileSync(serveInfoFile(), "utf-8");
52
+ } catch (e) {
53
+ if (e.code === "ENOENT") return null;
54
+ throw e;
55
+ }
56
+ try {
57
+ return JSON.parse(raw);
58
+ } catch (_) {
59
+ throw new Error(`Corrupted serve state file: ${serveInfoFile()}. Delete it manually or re-run serve.`);
60
+ }
61
+ }
62
+
63
+ function isPidRunning(pid) {
64
+ if (!isValidPositivePid(pid)) return false;
65
+ try {
66
+ process.kill(pid, 0);
67
+ return true;
68
+ } catch (err) {
69
+ if (err.code === "ESRCH") return false;
70
+ if (err.code === "EPERM") return true;
71
+ throw err;
72
+ }
73
+ }
74
+
75
+ function formatServeSummary(info, opts = {}) {
76
+ const captureEnabled = opts.captureEnabled !== false;
77
+ const projects = opts.projects || listProjects();
78
+ const command = cliNames.canonicalName;
79
+ const title = opts.background
80
+ ? "Echo服务在后台运行 / Echo serve started in background"
81
+ : "Echo服务正在前台运行 / Echo serve running in foreground";
82
+ const rows = [
83
+ ["Docs / 访问地址", `http://${HOST}:${info.docsPort}/`],
84
+ ["API / 接口地址", `http://${HOST}:${info.apiPort}/`],
85
+ ["State / 状态文件", serveInfoFile()],
86
+ ];
87
+ if (opts.logFile) rows.push(["Log / 日志文件", opts.logFile]);
88
+
89
+ const labelWidth = Math.max(...rows.map(([label]) => label.length), 22);
90
+ const formatRow = ([label, value]) => `${label.padEnd(labelWidth)} ${value}`;
91
+ const captureStatus = captureEnabled
92
+ ? "正在收集 AI 聊天记录 / Collecting AI chat logs"
93
+ : "已关闭 AI 聊天记录 / AI chat logging is off";
94
+ const captureCommand = captureEnabled
95
+ ? `${command} capture off`
96
+ : `${command} capture on`;
97
+ const captureHint = captureEnabled
98
+ ? "关闭收集 / Turn off"
99
+ : "开启收集 / Turn on";
100
+ const projectLines = projects.length === 0
101
+ ? [" (none) No registered projects yet."]
102
+ : projects.map((p) => ` ${p.projectId.padEnd(20)} ${p.root}`);
103
+
104
+ return [
105
+ title,
106
+ "",
107
+ ...rows.map(formatRow),
108
+ "",
109
+ `${command} serve # 后台启动 / Start in background`,
110
+ `${command} serve --foreground # 前台调试 / Run in foreground for debugging`,
111
+ `${command} stop # 停止服务 / Stop Echo serve`,
112
+ `${command} capture on/off # 控制 AI 聊天记录收集 / Toggle AI chat logging`,
113
+ "",
114
+ "AI chat capture / AI 聊天记录:",
115
+ ` Status / 当前状态 ${captureStatus}`,
116
+ ` Command / 对应命令 ${captureHint}: ${captureCommand}`,
117
+ "",
118
+ "Registered projects / 已注册项目:",
119
+ ...projectLines,
120
+ "",
121
+ "New projects must be registered before Echo can show their AI chat records.",
122
+ `新项目必须先注册,否则网页不会显示该项目的 AI 聊天记录:${command} init project --path <project-dir>`,
123
+ ].join("\n");
124
+ }
125
+
126
+ function isValidPositivePid(pid) {
127
+ return Number.isInteger(pid) && pid > 0;
128
+ }
129
+
130
+ function verifyProcessIdentity(info) {
131
+ // File-level identity marker guards against stale or manually-created state files.
132
+ // PID reuse is extremely unlikely because serve's SIGTERM handler calls
133
+ // clearServeInfo() on graceful shutdown, and EPERM prevents cross-user kills.
134
+ return info.identity === "echo-serve";
135
+ }
136
+
137
+ function isEchoServeCommand(command) {
138
+ const runtimeSite = resolveRuntimeSiteDir();
139
+ return (
140
+ (command.includes("echoctl") && command.includes("serve")) ||
141
+ (command.includes("scripts/serve") && command.includes("node")) ||
142
+ (command.includes("vitepress") && command.includes(runtimeSite))
143
+ );
144
+ }
145
+
146
+ function findServeProcessCandidates(ports = [DEFAULT_API_PORT, DEFAULT_DOCS_PORT]) {
147
+ const pids = new Set();
148
+ for (const port of ports) {
149
+ try {
150
+ const out = execFileSync("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"], { encoding: "utf-8" });
151
+ for (const line of out.split(/\r?\n/)) {
152
+ const pid = Number(line.trim());
153
+ if (isValidPositivePid(pid)) pids.add(pid);
154
+ }
155
+ } catch (_) {}
156
+ }
157
+
158
+ const candidates = [];
159
+ for (const pid of pids) {
160
+ try {
161
+ const command = execFileSync("ps", ["-p", String(pid), "-o", "command="], { encoding: "utf-8" }).trim();
162
+ if (command && isEchoServeCommand(command)) {
163
+ candidates.push({ pid, command });
164
+ }
165
+ } catch (_) {}
166
+ }
167
+ return candidates;
168
+ }
169
+
170
+ function resolveRuntimeSiteDir() {
171
+ return path.join(resolveEchoHomePath(), ".site");
172
+ }
173
+
174
+ async function findFreePort(start) {
175
+ const net = require("net");
176
+ let port = start;
177
+ while (port <= 65535) {
178
+ try {
179
+ return await new Promise((resolve, reject) => {
180
+ const s = net.createServer();
181
+ s.listen(port, HOST, () => {
182
+ const assignedPort = s.address().port;
183
+ s.close(() => resolve(assignedPort));
184
+ });
185
+ s.on("error", reject);
186
+ });
187
+ } catch (_) {
188
+ port++;
189
+ }
190
+ }
191
+ throw new Error(`No free port found from ${start} to 65535`);
192
+ }
193
+
194
+ function allowedOrigin(origin, docsPort) {
195
+ if (!origin) return `http://${HOST}:${docsPort || DEFAULT_DOCS_PORT}`;
196
+ try {
197
+ const url = new URL(origin);
198
+ const isLocalhost = url.hostname === "localhost" || url.hostname === "127.0.0.1";
199
+ const isDocsPort = url.port === String(docsPort || DEFAULT_DOCS_PORT);
200
+ if ((url.protocol === "http:" || url.protocol === "https:") && isLocalhost && isDocsPort) {
201
+ return origin;
202
+ }
203
+ } catch (_) {}
204
+ return `http://${HOST}:${docsPort || DEFAULT_DOCS_PORT}`;
205
+ }
206
+
207
+ function jsonResponse(res, code, data, docsPort) {
208
+ const origin = allowedOrigin(res.req?.headers?.origin, docsPort);
209
+ res.writeHead(code, {
210
+ "Content-Type": "application/json",
211
+ "Access-Control-Allow-Origin": origin,
212
+ });
213
+ res.end(JSON.stringify(data));
214
+ }
215
+
216
+ function readBody(req) {
217
+ return new Promise((resolve) => {
218
+ let data = "";
219
+ req.on("data", (c) => (data += c));
220
+ req.on("end", () => {
221
+ try {
222
+ resolve(data ? JSON.parse(data) : {});
223
+ } catch (_) {
224
+ resolve(null);
225
+ }
226
+ });
227
+ });
228
+ }
229
+
230
+ function resolveDirsForProject(projectId, fallbackDirs) {
231
+ if (!projectId) return fallbackDirs || resolveDataDirs();
232
+ const { findProjectById } = require("./lib/usecases/project-registry");
233
+ const project = findProjectById(projectId);
234
+ return project ? resolveDataDirs({ cwd: project.projectRoot }) : (fallbackDirs || resolveDataDirs());
235
+ }
236
+
237
+ function createRouter(deps) {
238
+ const docsPort = deps.docsPort || DEFAULT_DOCS_PORT;
239
+ return async function router(req, res) {
240
+ res.req = req;
241
+ if (req.method === "OPTIONS") {
242
+ res.writeHead(204, {
243
+ "Access-Control-Allow-Origin": allowedOrigin(req.headers.origin, docsPort),
244
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
245
+ "Access-Control-Allow-Headers": "Content-Type",
246
+ });
247
+ return res.end();
248
+ }
249
+
250
+ const url = new URL(req.url, `http://${HOST}`);
251
+ const p = url.pathname;
252
+
253
+ try {
254
+ if (p === "/api/status" && req.method === "GET") {
255
+ const dirs = deps.dirs || resolveDataDirs();
256
+ return jsonResponse(res, 200, {
257
+ ok: true,
258
+ captureEnabled: isCaptureEnabled(),
259
+ projectId: dirs.projectId || null,
260
+ version: mcpServerInfo.version,
261
+ author: getAuthor(),
262
+ }, docsPort);
263
+ }
264
+
265
+ if (p === "/api/capture" && req.method === "GET") {
266
+ return jsonResponse(res, 200, { enabled: isCaptureEnabled() }, docsPort);
267
+ }
268
+
269
+ if (p === "/api/capture" && req.method === "POST") {
270
+ const body = await readBody(req);
271
+ if (!body || typeof body.enabled !== "boolean") {
272
+ return jsonResponse(res, 400, { error: "body.enabled (boolean) required" }, docsPort);
273
+ }
274
+ const r = setCaptureEnabled(body.enabled);
275
+ return jsonResponse(res, 200, { enabled: r.capture_enabled }, docsPort);
276
+ }
277
+
278
+ if (p === "/api/comments" && req.method === "POST") {
279
+ const body = await readBody(req);
280
+ if (!body || !body.articleId || !body.comment) {
281
+ return jsonResponse(res, 400, { error: "articleId and comment required" }, docsPort);
282
+ }
283
+ const dirs = resolveDirsForProject(body.projectId, deps.dirs);
284
+ try {
285
+ const { writeComment } = require("./lib/usecases/write-comment");
286
+ const result = writeComment({
287
+ articleId: body.articleId,
288
+ quote: body.quote,
289
+ comment: body.comment,
290
+ author: body.author || "vincent",
291
+ scope: body.scope || (body.quote ? undefined : "article"),
292
+ prefix: body.prefix,
293
+ suffix: body.suffix,
294
+ occurrence: body.occurrence,
295
+ evolutionKind: body.evolutionKind || "null",
296
+ evolutionOf: body.evolutionOf || [],
297
+ status: body.status || "open",
298
+ dirs,
299
+ store,
300
+ });
301
+ try { runBuildDocs({ docsRoot: deps.docsRoot || resolveRuntimeSiteDir() }); } catch (e) {
302
+ console.error("[echo] Rebuilding docs after comment failed:", e.message);
303
+ }
304
+ return jsonResponse(res, 201, result, docsPort);
305
+ } catch (err) {
306
+ return jsonResponse(res, 422, { error: err.message }, docsPort);
307
+ }
308
+ }
309
+
310
+ if (p === "/api/tags" && req.method === "POST") {
311
+ const body = await readBody(req);
312
+ const tags = Array.isArray(body?.tags)
313
+ ? body.tags
314
+ : (body?.tag ? [body.tag] : []);
315
+ if (!body || !body.articleId || tags.length === 0) {
316
+ return jsonResponse(res, 400, { error: "articleId and tag(s) required" }, docsPort);
317
+ }
318
+ const dirs = resolveDirsForProject(body.projectId, deps.dirs);
319
+ try {
320
+ const { addTags } = require("./lib/usecases/query-articles");
321
+ const result = addTags({ id: body.articleId, tags }, { dirs, store });
322
+ try { runBuildDocs({ docsRoot: deps.docsRoot || resolveRuntimeSiteDir() }); } catch (e) {
323
+ console.error("[echo] Rebuilding docs after tag change failed:", e.message);
324
+ }
325
+ return jsonResponse(res, 201, result, docsPort);
326
+ } catch (err) {
327
+ return jsonResponse(res, err.name === "NotFoundError" ? 404 : 422, { error: err.message }, docsPort);
328
+ }
329
+ }
330
+
331
+ if (p === "/api/tags/remove" && req.method === "POST") {
332
+ const body = await readBody(req);
333
+ const tags = Array.isArray(body?.tags) ? body.tags : [];
334
+ if (!body || !body.articleId || tags.length === 0) {
335
+ return jsonResponse(res, 400, { error: "articleId and tags required" }, docsPort);
336
+ }
337
+ const dirs = resolveDirsForProject(body.projectId, deps.dirs);
338
+ try {
339
+ const { removeTags } = require("./lib/usecases/query-articles");
340
+ const result = removeTags({ id: body.articleId, tags }, { dirs, store });
341
+ try { runBuildDocs({ docsRoot: deps.docsRoot || resolveRuntimeSiteDir() }); } catch (e) {
342
+ console.error("[echo] Rebuilding docs after tag removal failed:", e.message);
343
+ }
344
+ return jsonResponse(res, 200, result, docsPort);
345
+ } catch (err) {
346
+ return jsonResponse(res, err.name === "NotFoundError" ? 404 : 422, { error: err.message }, docsPort);
347
+ }
348
+ }
349
+
350
+ if (p === "/api/tags/rename" && req.method === "POST") {
351
+ const body = await readBody(req);
352
+ if (!body || !body.oldTag || !body.newTag) {
353
+ return jsonResponse(res, 400, { error: "oldTag and newTag required" }, docsPort);
354
+ }
355
+ const dirs = resolveDirsForProject(body.projectId, deps.dirs);
356
+ try {
357
+ const { renameTag } = require("./lib/usecases/query-articles");
358
+ const result = renameTag({ oldTag: body.oldTag, newTag: body.newTag }, { dirs, store });
359
+ try { runBuildDocs({ docsRoot: deps.docsRoot || resolveRuntimeSiteDir() }); } catch (e) {
360
+ console.error("[echo] Rebuilding docs after tag rename failed:", e.message);
361
+ }
362
+ return jsonResponse(res, 200, result, docsPort);
363
+ } catch (err) {
364
+ return jsonResponse(res, err.name === "NotFoundError" ? 404 : 422, { error: err.message }, docsPort);
365
+ }
366
+ }
367
+
368
+ if (p === "/api/tags/purge" && req.method === "POST") {
369
+ const body = await readBody(req);
370
+ if (!body || !body.tag) {
371
+ return jsonResponse(res, 400, { error: "tag required" }, docsPort);
372
+ }
373
+ const dirs = resolveDirsForProject(body.projectId, deps.dirs);
374
+ try {
375
+ const { purgeTag } = require("./lib/usecases/query-articles");
376
+ const result = purgeTag({ tag: body.tag }, { dirs, store });
377
+ try { runBuildDocs({ docsRoot: deps.docsRoot || resolveRuntimeSiteDir() }); } catch (e) {
378
+ console.error("[echo] Rebuilding docs after tag purge failed:", e.message);
379
+ }
380
+ return jsonResponse(res, 200, result, docsPort);
381
+ } catch (err) {
382
+ return jsonResponse(res, err.name === "NotFoundError" ? 404 : 422, { error: err.message }, docsPort);
383
+ }
384
+ }
385
+
386
+ if (p === "/api/summary" && req.method === "POST") {
387
+ const body = await readBody(req);
388
+ if (!body || !body.articleId || body.summary === undefined) {
389
+ return jsonResponse(res, 400, { error: "articleId and summary required" }, docsPort);
390
+ }
391
+ const dirs = resolveDirsForProject(body.projectId, deps.dirs);
392
+ try {
393
+ const { updateSummary } = require("./lib/usecases/query-articles");
394
+ const result = updateSummary({ id: body.articleId, summary: body.summary }, { dirs, store });
395
+ try { runBuildDocs({ docsRoot: deps.docsRoot || resolveRuntimeSiteDir() }); } catch (e) {
396
+ console.error("[echo] Rebuilding docs after summary update failed:", e.message);
397
+ }
398
+ return jsonResponse(res, 200, result, docsPort);
399
+ } catch (err) {
400
+ return jsonResponse(res, err.name === "NotFoundError" ? 404 : 422, { error: err.message }, docsPort);
401
+ }
402
+ }
403
+
404
+ if (p === "/api/projects" && req.method === "GET") {
405
+ const echoHome = resolveEchoHomePath();
406
+ const projects = listProjects(echoHome);
407
+ const dirs = resolveDataDirs();
408
+ return jsonResponse(res, 200, {
409
+ projects: projects.map((p) => ({
410
+ id: p.projectId,
411
+ name: p.projectId,
412
+ root: p.root,
413
+ dataRoot: p.dataRoot,
414
+ })),
415
+ currentId: dirs.projectId,
416
+ }, docsPort);
417
+ }
418
+
419
+ if (p === "/api/mcp-config" && req.method === "GET") {
420
+ return jsonResponse(res, 200, {
421
+ canonical: {
422
+ command: cliNames.canonicalName,
423
+ args: ["mcp"],
424
+ },
425
+ legacy: cliNames.legacyNames.map((name) => ({
426
+ command: name,
427
+ args: ["mcp"],
428
+ })),
429
+ serverInfo: mcpServerInfo,
430
+ }, docsPort);
431
+ }
432
+
433
+ if (p === "/api/live-session-state" && req.method === "GET") {
434
+ const sessionId = url.searchParams.get("sessionId");
435
+ if (!sessionId) {
436
+ return jsonResponse(res, 400, { error: "sessionId query parameter required" }, docsPort);
437
+ }
438
+ const projectId = url.searchParams.get("projectId") || null;
439
+ try {
440
+ const dirs = resolveDirsForProject(projectId, deps.dirs);
441
+ const { getLiveSessionState } = require("./lib/usecases/live-session-state");
442
+ return jsonResponse(res, 200, getLiveSessionState(dirs, sessionId), docsPort);
443
+ } catch (err) {
444
+ return jsonResponse(res, 500, { error: err.message }, docsPort);
445
+ }
446
+ }
447
+
448
+ if (p === "/api/query-log" && req.method === "GET") {
449
+ const limit = parseInt(url.searchParams.get("limit") || "50", 10);
450
+ const dirs = deps.dirs || resolveDataDirs();
451
+ try {
452
+ const { readRecentQueryLog } = require("./lib/infra/query-log");
453
+ return jsonResponse(res, 200, readRecentQueryLog(dirs, limit), docsPort);
454
+ } catch (_) {
455
+ return jsonResponse(res, 200, [], docsPort);
456
+ }
457
+ }
458
+
459
+ if (p === "/api/publish" && req.method === "POST") {
460
+ const body = await readBody(req);
461
+ if (!body || !body.sessionId) {
462
+ return jsonResponse(res, 400, { error: "sessionId required" }, docsPort);
463
+ }
464
+ try {
465
+ const dirs = resolveDirsForProject(body.projectId, deps.dirs);
466
+ const baseSessionId = body.sessionId.replace(/-v\d+$/, "");
467
+ const bufferPath = path.join(dirs.bufferDir, `${baseSessionId}.md`);
468
+ if (!fs.existsSync(bufferPath)) {
469
+ return jsonResponse(res, 404, { error: `session buffer not found: ${body.sessionId}` }, docsPort);
470
+ }
471
+ const { parseBuffer, buildArticle } = require("./lib/usecases/convert-buffer");
472
+ const { recordSnapshot, getSnapshotInfo } = require("./lib/usecases/snapshot-manifest");
473
+ const raw = fs.readFileSync(bufferPath, "utf-8");
474
+ const { turns } = parseBuffer(raw);
475
+ if (turns.length === 0) {
476
+ return jsonResponse(res, 422, { error: "buffer has no turns" }, docsPort);
477
+ }
478
+
479
+ const snapInfo = getSnapshotInfo(dirs.dataRoot || dirs.articlesDir, baseSessionId);
480
+ if (snapInfo && snapInfo.versions.length > 0) {
481
+ const last = snapInfo.versions[snapInfo.versions.length - 1];
482
+ if (last.turnCount >= turns.length) {
483
+ return jsonResponse(res, 409, {
484
+ error: "already published with same or more turns",
485
+ existingVersion: last.version,
486
+ }, docsPort);
487
+ }
488
+ }
489
+
490
+ const version = snapInfo ? snapInfo.versions.length + 1 : 1;
491
+ const articleId = version > 1
492
+ ? `${baseSessionId}-v${version}`
493
+ : baseSessionId;
494
+ const articlePath = path.join(dirs.articlesDir, `${articleId}.md`);
495
+ if (fs.existsSync(articlePath)) {
496
+ return jsonResponse(res, 409, { error: `article ${articleId} already exists` }, docsPort);
497
+ }
498
+ const { id, article, turnCount } = buildArticle(articleId, turns, { project: dirs.projectId });
499
+ const snap = recordSnapshot(dirs.dataRoot || dirs.articlesDir, baseSessionId, id, turnCount);
500
+ fs.mkdirSync(dirs.articlesDir, { recursive: true });
501
+ fs.writeFileSync(articlePath, article);
502
+ try { runBuildDocs({ docsRoot: deps.docsRoot || resolveRuntimeSiteDir() }); } catch (e) {
503
+ console.error("[echo] Rebuilding docs after publish failed:", e.message);
504
+ }
505
+ return jsonResponse(res, 201, {
506
+ ok: true, id, slug: id, turnCount, version: snap.version, latest: true,
507
+ }, docsPort);
508
+ } catch (err) {
509
+ return jsonResponse(res, 500, { error: err.message }, docsPort);
510
+ }
511
+ }
512
+
513
+ if (p === "/api/legacy-candidates" && req.method === "GET") {
514
+ const projectId = url.searchParams.get("projectId");
515
+ if (!projectId) {
516
+ return jsonResponse(res, 400, { error: "projectId query parameter required" }, docsPort);
517
+ }
518
+ try {
519
+ const { scanLegacyCandidates } = require("./lib/usecases/legacy-candidates");
520
+ const result = scanLegacyCandidates(projectId);
521
+ return jsonResponse(res, 200, result, docsPort);
522
+ } catch (err) {
523
+ return jsonResponse(res, err.message.includes("not found") ? 404 : 500, { error: err.message }, docsPort);
524
+ }
525
+ }
526
+
527
+ if (p === "/api/legacy-candidates/migrate" && req.method === "POST") {
528
+ const body = await readBody(req);
529
+ if (!body || !body.projectId) {
530
+ return jsonResponse(res, 400, { error: "projectId required" }, docsPort);
531
+ }
532
+ try {
533
+ const { migrateLegacyBuffer } = require("./lib/usecases/migrate-legacy-buffer");
534
+ let filterFileNames = null;
535
+ const candidateIds = body.candidateIds;
536
+ if (candidateIds && candidateIds.length > 0) {
537
+ const { scanLegacyCandidates } = require("./lib/usecases/legacy-candidates");
538
+ const scan = scanLegacyCandidates(body.projectId);
539
+ const idSet = new Set(candidateIds);
540
+ filterFileNames = scan.candidates
541
+ .filter((c) => idSet.has(c.sessionId))
542
+ .map((c) => c.fileName);
543
+ if (filterFileNames.length === 0) {
544
+ return jsonResponse(res, 200, {
545
+ ok: true, migrated: 0, skipped: 0,
546
+ targetDir: null, refreshScheduled: false,
547
+ }, docsPort);
548
+ }
549
+ }
550
+ const result = migrateLegacyBuffer({
551
+ projectId: body.projectId,
552
+ apply: true,
553
+ overwrite: false,
554
+ filterFileNames,
555
+ });
556
+ try { runBuildDocs({ docsRoot: deps.docsRoot || resolveRuntimeSiteDir() }); } catch (e) {
557
+ console.error("[echo] Rebuilding docs after legacy migration failed:", e.message);
558
+ }
559
+ return jsonResponse(res, 200, {
560
+ ok: true,
561
+ migrated: result.summary.copy + result.summary.overwrite,
562
+ skipped: result.summary.skippedExisting,
563
+ targetDir: result.targetDir,
564
+ refreshScheduled: true,
565
+ }, docsPort);
566
+ } catch (err) {
567
+ return jsonResponse(res, 500, { error: err.message }, docsPort);
568
+ }
569
+ }
570
+
571
+ if (p === "/api/import/claude-candidates" && req.method === "GET") {
572
+ const projectId = url.searchParams.get("projectId");
573
+ if (!projectId) {
574
+ return jsonResponse(res, 400, { error: "projectId query parameter required" }, docsPort);
575
+ }
576
+ try {
577
+ const { discoverClaudeImportCandidates } = require("./lib/usecases/discover-claude-imports");
578
+ const result = discoverClaudeImportCandidates(projectId);
579
+ return jsonResponse(res, 200, result, docsPort);
580
+ } catch (err) {
581
+ return jsonResponse(res, err.message.includes("not found") ? 404 : 500, { error: err.message }, docsPort);
582
+ }
583
+ }
584
+
585
+ if (p === "/api/import/claude" && req.method === "POST") {
586
+ const body = await readBody(req);
587
+ if (!body || !body.projectId || !Array.isArray(body.sessionIds) || body.sessionIds.length === 0) {
588
+ return jsonResponse(res, 400, { error: "projectId and sessionIds (non-empty array) required" }, docsPort);
589
+ }
590
+ try {
591
+ const { discoverClaudeImportCandidates } = require("./lib/usecases/discover-claude-imports");
592
+ const provider = require("./lib/import/providers/claude-code");
593
+ const mf = require("./lib/import/manifest");
594
+ const candidates = discoverClaudeImportCandidates(body.projectId);
595
+ const idSet = new Set(body.sessionIds);
596
+ const toImport = candidates.candidates.filter((c) => idSet.has(c.sessionId) && c.status !== "skipped");
597
+
598
+ if (toImport.length === 0) {
599
+ return jsonResponse(res, 200, { ok: true, imported: 0, skipped: 0, articlesDir: null, refreshScheduled: false }, docsPort);
600
+ }
601
+
602
+ const echoHome = resolveEchoHomePath();
603
+ const manifestPath = path.join(echoHome, "import-manifest.json");
604
+ const manifest = mf.loadManifest(manifestPath);
605
+ const { findProjectById } = require("./lib/usecases/project-registry");
606
+ const project = findProjectById(body.projectId, { echoHome });
607
+ const articlesDir = project ? project.dataRoot + "/articles" : path.join(echoHome, "articles");
608
+
609
+ let imported = 0;
610
+ let skipped = 0;
611
+
612
+ for (const entry of toImport) {
613
+ const articleId = `session-${entry.sessionId.slice(0, 8)}`;
614
+ const articlePath = path.join(articlesDir, `${articleId}.md`);
615
+
616
+ if (fs.existsSync(articlePath)) {
617
+ // Record updated hash so this session won't show as "updated" again
618
+ if (entry.status === "updated") {
619
+ mf.recordImport(manifest, entry.sessionId, articleId, entry.fileHash, { provider: "claude-code" });
620
+ }
621
+ skipped++;
622
+ continue;
623
+ }
624
+
625
+ try {
626
+ const turns = provider.readSessionTurns(entry.filePath);
627
+ const classification = provider.classifySession(turns);
628
+ if (!classification.isMeaningful) {
629
+ skipped++;
630
+ continue;
631
+ }
632
+
633
+ const metadata = provider.extractMetadata(turns);
634
+ const markdown = provider.toEchoArticle(turns, metadata, {
635
+ sessionId: entry.sessionId,
636
+ project: body.projectId,
637
+ });
638
+
639
+ fs.mkdirSync(path.dirname(articlePath), { recursive: true });
640
+ fs.writeFileSync(articlePath, markdown);
641
+ mf.recordImport(manifest, entry.sessionId, articleId, entry.fileHash, { provider: "claude-code" });
642
+ imported++;
643
+ } catch (err) {
644
+ console.error("[echo] import claude error:", err.message);
645
+ }
646
+ }
647
+
648
+ mf.saveManifest(manifest, manifestPath);
649
+
650
+ try { runBuildDocs({ docsRoot: deps.docsRoot || resolveRuntimeSiteDir() }); } catch (e) {
651
+ console.error("[echo] Rebuilding docs after import failed:", e.message);
652
+ }
653
+
654
+ return jsonResponse(res, 200, {
655
+ ok: true, imported, skipped,
656
+ articlesDir, refreshScheduled: true,
657
+ }, docsPort);
658
+ } catch (err) {
659
+ return jsonResponse(res, err.message.includes("not found") ? 404 : 500, { error: err.message }, docsPort);
660
+ }
661
+ }
662
+
663
+ if (p === "/api/rebuild-docs" && req.method === "POST") {
664
+ try {
665
+ const { runPipeline } = require("./lib/usecases/run-pipeline");
666
+ runPipeline({ allProjects: true, silent: true, steps: ["validate", "index", "resolve"] });
667
+ runBuildDocs({ docsRoot: deps.docsRoot || resolveRuntimeSiteDir() });
668
+ return jsonResponse(res, 200, { ok: true }, docsPort);
669
+ } catch (err) {
670
+ return jsonResponse(res, 500, { error: err.message }, docsPort);
671
+ }
672
+ }
673
+
674
+ jsonResponse(res, 404, { error: "not found" }, docsPort);
675
+ } catch (err) {
676
+ jsonResponse(res, 500, { error: err.message }, docsPort);
677
+ }
678
+ };
679
+ }
680
+
681
+ async function start() {
682
+ const docsDir = resolveRuntimeSiteDir();
683
+
684
+ // Auto-run pipeline so captured buffers become visible without manual steps
685
+ console.log("[echoctl] Running pipeline...");
686
+ try {
687
+ const { runPipeline } = require("./lib/usecases/run-pipeline");
688
+ runPipeline({ allProjects: true, silent: true, steps: ["validate", "index", "resolve"] });
689
+ } catch (e) {
690
+ console.error("[echoctl] pipeline warning:", e.message);
691
+ }
692
+
693
+ console.log("[echoctl] Building docs...");
694
+ try {
695
+ runBuildDocs({ docsRoot: docsDir });
696
+ } catch (e) {
697
+ console.error("[echoctl] build-docs warning:", e.message);
698
+ }
699
+
700
+ // Ensure vitepress is resolvable from the runtime site directory
701
+ const siteModules = path.join(docsDir, "node_modules");
702
+ const pkgVitepress = path.join(PACKAGE_ROOT, "node_modules", "vitepress");
703
+ if (!fs.existsSync(siteModules)) fs.mkdirSync(siteModules, { recursive: true });
704
+ const vpLink = path.join(siteModules, "vitepress");
705
+ if (!fs.existsSync(vpLink)) {
706
+ try { fs.symlinkSync(pkgVitepress, vpLink, "dir"); } catch (_) {}
707
+ }
708
+
709
+ const apiPort = await findFreePort(DEFAULT_API_PORT);
710
+ const docsPort = await findFreePort(DEFAULT_DOCS_PORT);
711
+
712
+ const vitepress = spawn("npx", ["vitepress", "dev", docsDir, "--port", String(docsPort), "--host", HOST], {
713
+ cwd: PACKAGE_ROOT,
714
+ stdio: "pipe",
715
+ env: {
716
+ ...process.env,
717
+ VITE_ECHO_API_BASE: `http://${HOST}:${apiPort}`,
718
+ },
719
+ });
720
+
721
+ vitepress.stdout.on("data", (d) => process.stdout.write(d));
722
+ vitepress.stderr.on("data", (d) => process.stderr.write(d));
723
+ vitepress.on("error", (err) => {
724
+ console.error("[echoctl] VitePress failed:", err.message);
725
+ process.exit(1);
726
+ });
727
+
728
+ const router = createRouter({ docsPort, docsRoot: docsDir });
729
+ const server = http.createServer(router);
730
+ server.listen(apiPort, HOST, () => {
731
+ writeServeInfo(apiPort, docsPort, vitepress.pid);
732
+ console.log(`\n Echo serve started:`);
733
+ console.log(` API: http://${HOST}:${apiPort}`);
734
+ console.log(` Docs: http://${HOST}:${docsPort}`);
735
+ console.log(` Site dir: ${docsDir}`);
736
+ console.log(` MCP name: ${mcpServerInfo.name} v${mcpServerInfo.version}\n`);
737
+ console.log(formatServeSummary({
738
+ apiPort,
739
+ docsPort,
740
+ }, { captureEnabled: isCaptureEnabled(), background: false }));
741
+ console.log("");
742
+ });
743
+
744
+ function shutdown() {
745
+ console.log("\n[echoctl] shutting down...");
746
+ clearServeInfo();
747
+ server.close();
748
+ vitepress.kill("SIGTERM");
749
+ process.exit(0);
750
+ }
751
+ process.on("SIGINT", shutdown);
752
+ process.on("SIGTERM", shutdown);
753
+ }
754
+
755
+ if (require.main === module) {
756
+ start().catch((err) => {
757
+ console.error("[echoctl] serve failed:", err.message);
758
+ process.exit(1);
759
+ });
760
+ }
761
+
762
+ module.exports = {
763
+ start,
764
+ createRouter,
765
+ findFreePort,
766
+ resolveRuntimeSiteDir,
767
+ readServeInfo,
768
+ clearServeInfo,
769
+ servePidFile,
770
+ serveInfoFile,
771
+ serveLogFile,
772
+ formatServeSummary,
773
+ isPidRunning,
774
+ isValidPositivePid,
775
+ verifyProcessIdentity,
776
+ findServeProcessCandidates,
777
+ isEchoServeCommand,
778
+ };