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,401 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const { spawn } = require("child_process");
4
+ const {
5
+ resolveEchoHomePath,
6
+ ensureDir,
7
+ } = require("../infra/workspace");
8
+ const { isCaptureEnabled, getSpeakers } = require("../infra/config");
9
+ const { findProjectForPath, listProjects } = require("../usecases/project-registry");
10
+ const { readStdin } = require("../infra/read-stdin");
11
+ const { writeLiveSessionState } = require("../usecases/live-session-state");
12
+ const { renderTurnMarker } = require("../domain/echo-format");
13
+
14
+ function claudeProjectDirName(projectPath) {
15
+ return "-" + path.resolve(projectPath).slice(1).split(path.sep).join("-");
16
+ }
17
+
18
+ function projectPathFromTranscriptPath(transcriptPath) {
19
+ if (!transcriptPath) return null;
20
+ const dirName = path.basename(path.dirname(transcriptPath));
21
+ if (!dirName.startsWith("-")) return null;
22
+ return "/" + dirName.slice(1).replace(/-/g, "/");
23
+ }
24
+
25
+ function projectFromTranscriptPath(transcriptPath, echoHome) {
26
+ if (!transcriptPath) return null;
27
+ const dirName = path.basename(path.dirname(transcriptPath));
28
+ for (const project of listProjects(echoHome)) {
29
+ if (claudeProjectDirName(project.root) === dirName) {
30
+ return project;
31
+ }
32
+ }
33
+ const decoded = projectPathFromTranscriptPath(transcriptPath);
34
+ return decoded ? findProjectForPath(decoded, { echoHome }) : null;
35
+ }
36
+
37
+ function getLocalDate() {
38
+ return new Intl.DateTimeFormat("zh-CN", {
39
+ timeZone: process.env.TZ || "Asia/Shanghai",
40
+ year: "numeric", month: "2-digit", day: "2-digit",
41
+ }).format(new Date()).replace(/\//g, "-");
42
+ }
43
+
44
+ function resolveBufferRoot(data) {
45
+ const echoHome = resolveEchoHomePath();
46
+ const transcriptProject = projectFromTranscriptPath(data.transcript_path, echoHome);
47
+ if (transcriptProject) {
48
+ return { bufferRoot: transcriptProject.dataRoot, project: transcriptProject };
49
+ }
50
+
51
+ const candidates = [data.cwd, process.cwd()].filter(Boolean);
52
+
53
+ for (const candidate of candidates) {
54
+ const project = findProjectForPath(candidate, { echoHome });
55
+ if (project) {
56
+ return { bufferRoot: project.dataRoot, project };
57
+ }
58
+ }
59
+ return { bufferRoot: echoHome, project: null };
60
+ }
61
+
62
+ function getSessionFile(sid, bufferRoot) {
63
+ const mapPath = path.join(bufferRoot, "session-map.txt");
64
+ ensureDir(bufferRoot);
65
+
66
+ if (fs.existsSync(mapPath)) {
67
+ const map = fs.readFileSync(mapPath, "utf-8");
68
+ for (const line of map.split("\n")) {
69
+ const [k, v] = line.split("=");
70
+ if (k === sid && v) return v;
71
+ }
72
+ }
73
+
74
+ const base = `session-${getLocalDate()}`;
75
+ let v = 1;
76
+ let file;
77
+ while (true) {
78
+ const candidate = path.join(bufferRoot, `${base}-v${v}.md`);
79
+ try {
80
+ const fd = fs.openSync(candidate, "wx");
81
+ fs.closeSync(fd);
82
+ file = candidate;
83
+ break;
84
+ } catch (e) {
85
+ if (e.code === "EEXIST") {
86
+ v++;
87
+ continue;
88
+ }
89
+ throw e;
90
+ }
91
+ }
92
+ fs.appendFileSync(mapPath, `${sid}=${file}\n`);
93
+ return file;
94
+ }
95
+
96
+ function scheduleServeRefresh() {
97
+ const { getRunningServeInfo } = require("../usecases/refresh-serve");
98
+ if (!getRunningServeInfo()) return false;
99
+ const bin = path.resolve(__dirname, "../../../bin/echoctl.js");
100
+ const child = spawn(process.execPath, [bin, "refresh", "--quiet"], {
101
+ detached: true,
102
+ stdio: "ignore",
103
+ env: process.env,
104
+ });
105
+ child.unref();
106
+ return true;
107
+ }
108
+
109
+ function extractAuqBlock(hookData, lastCount) {
110
+ const transcriptPath = hookData.transcript_path;
111
+ if (!transcriptPath || !fs.existsSync(transcriptPath)) return { block: "", newCount: lastCount };
112
+
113
+ const entries = fs.readFileSync(transcriptPath, "utf-8")
114
+ .split("\n").filter(Boolean)
115
+ .map((line) => JSON.parse(line));
116
+
117
+ const allAuqs = [];
118
+ for (let i = 0; i < entries.length; i++) {
119
+ const entry = entries[i];
120
+ if (entry.type !== "assistant") continue;
121
+ const content = entry.message?.content || [];
122
+
123
+ // Collect ordered blocks preserving interleaved text and AUQ sequence
124
+ const orderedBlocks = [];
125
+ for (const block of content) {
126
+ if (typeof block === "object" && block !== null) {
127
+ if (block.type === "text") {
128
+ orderedBlocks.push({ type: "text", text: block.text || "" });
129
+ } else if (block.type === "tool_use" && block.name === "AskUserQuestion") {
130
+ orderedBlocks.push({
131
+ type: "auq",
132
+ id: block.id || "",
133
+ input: block.input || {},
134
+ });
135
+ }
136
+ }
137
+ }
138
+
139
+ const hasAuq = orderedBlocks.some((b) => b.type === "auq");
140
+ if (!hasAuq) continue;
141
+
142
+ // Check previous entry for orphaned narrative text (Problem 1 fix)
143
+ let prevText = "";
144
+ if (i > 0) {
145
+ const prevEntry = entries[i - 1];
146
+ if (prevEntry.type === "assistant") {
147
+ const prevContent = prevEntry.message?.content || [];
148
+ const hasPrevAuq = prevContent.some(
149
+ (b) => typeof b === "object" && b !== null && b.type === "tool_use" && b.name === "AskUserQuestion"
150
+ );
151
+ if (!hasPrevAuq) {
152
+ for (const b of prevContent) {
153
+ if (typeof b === "object" && b !== null && b.type === "text") {
154
+ prevText += b.text || "";
155
+ }
156
+ }
157
+ }
158
+ }
159
+ }
160
+
161
+ // Collect answers keyed by tool_use_id for reliable pairing
162
+ const answersById = {};
163
+ let farthestAnswerIdx = i;
164
+ for (let j = i + 1; j < Math.min(i + 5, entries.length); j++) {
165
+ const nxt = entries[j];
166
+ if (nxt.type !== "user") continue;
167
+ const nxtContent = nxt.message?.content || [];
168
+ for (const cb of nxtContent) {
169
+ if (typeof cb === "object" && cb !== null && cb.type === "tool_result") {
170
+ const tuid = cb.tool_use_id || "";
171
+ const rc = cb.content;
172
+ let raw = "";
173
+ if (Array.isArray(rc)) {
174
+ for (const rci of rc) {
175
+ if (typeof rci === "object" && rci !== null) {
176
+ raw += rci.text || "";
177
+ }
178
+ }
179
+ } else if (typeof rc === "string") {
180
+ raw = rc;
181
+ }
182
+ if (tuid && raw) {
183
+ answersById[tuid] = raw;
184
+ farthestAnswerIdx = Math.max(farthestAnswerIdx, j);
185
+ }
186
+ }
187
+ }
188
+ }
189
+
190
+ allAuqs.push({ prevText, orderedBlocks, answersById, answerEntryIdx: farthestAnswerIdx });
191
+ }
192
+
193
+ // Collect trailing text after the last AUQ's answer (text-only assistant
194
+ // messages that fall after the answer but before the next non-tool user entry)
195
+ if (allAuqs.length > 0) {
196
+ const lastItem = allAuqs[allAuqs.length - 1];
197
+ let trailingText = "";
198
+ for (let j = lastItem.answerEntryIdx + 1; j < entries.length; j++) {
199
+ const entry = entries[j];
200
+ if (entry.type === "assistant") {
201
+ const content = entry.message?.content || [];
202
+ const hasAuq = content.some(
203
+ (b) => typeof b === "object" && b !== null && b.type === "tool_use" && b.name === "AskUserQuestion"
204
+ );
205
+ if (hasAuq) break;
206
+ for (const block of content) {
207
+ if (typeof block === "object" && block !== null && block.type === "text") {
208
+ trailingText += block.text || "";
209
+ }
210
+ }
211
+ } else if (entry.type === "user") {
212
+ const content = entry.message?.content || [];
213
+ const hasToolResult = content.some(
214
+ (b) => typeof b === "object" && b !== null && b.type === "tool_result"
215
+ );
216
+ if (!hasToolResult) break;
217
+ }
218
+ }
219
+ lastItem.trailingText = trailingText;
220
+ }
221
+
222
+ const newCount = allAuqs.length;
223
+ if (newCount <= lastCount) return { block: "", newCount: lastCount };
224
+
225
+ let block = "";
226
+ for (const item of allAuqs.slice(lastCount)) {
227
+ // Preserve interleaved text/AUQ order (Problem 1 fix)
228
+ if (item.prevText) {
229
+ block += item.prevText + "\n\n";
230
+ }
231
+
232
+ for (const b of item.orderedBlocks) {
233
+ if (b.type === "text") {
234
+ block += b.text + "\n\n";
235
+ } else if (b.type === "auq") {
236
+ const questions = b.input.questions || [];
237
+ block += "\n*[AI 提供了以下选项:]*\n\n";
238
+ for (const q of questions) {
239
+ const header = q.header || "";
240
+ const questionText = q.question || "";
241
+ block += `> **${header}**\n`;
242
+ block += `> ${questionText}\n>\n`;
243
+ for (const opt of q.options || []) {
244
+ block += `> - **${opt.label || ""}** — ${opt.description || ""}\n`;
245
+ }
246
+ block += "\n";
247
+ }
248
+
249
+ // Answer by tool_use_id, per-question display (Problem 2 fix)
250
+ const raw = item.answersById[b.id] || "";
251
+ if (raw) {
252
+ const parsed = [...raw.matchAll(/"([^"]*)"="([^"]*)"/g)];
253
+ if (parsed.length === 1) {
254
+ block += `*你的选择:${parsed[0][2]}*\n\n`;
255
+ } else if (parsed.length > 1) {
256
+ block += "*你的选择:*\n";
257
+ for (const [, qText, ans] of parsed) {
258
+ block += `- ${qText}:**${ans}**\n`;
259
+ }
260
+ block += "\n";
261
+ } else {
262
+ block += `*你的选择:${raw}*\n\n`;
263
+ }
264
+ } else {
265
+ block += "*(未收到回答)*\n\n";
266
+ }
267
+ }
268
+ }
269
+
270
+ if (item.trailingText) {
271
+ block += item.trailingText + "\n\n";
272
+ }
273
+ }
274
+ return { block, newCount };
275
+ }
276
+
277
+ async function handleUserPromptSubmit(data, bufferRoot) {
278
+ const pendingDir = path.join(bufferRoot, "session-buffer", "pending");
279
+ ensureDir(pendingDir);
280
+ const pendingFile = path.join(pendingDir, `${data.session_id || "unknown"}.json`);
281
+ fs.writeFileSync(pendingFile, JSON.stringify({
282
+ prompt: data.prompt || "",
283
+ session_id: data.session_id || "",
284
+ transcript_path: data.transcript_path || "",
285
+ cwd: data.cwd || "",
286
+ created_at: data.timestamp || "",
287
+ }, null, 2));
288
+ console.log("pending saved");
289
+ }
290
+
291
+ async function handleStop(data, bufferRoot) {
292
+ const bufDir = path.join(bufferRoot, "session-buffer");
293
+ const pendingDir = path.join(bufDir, "pending");
294
+ const sid = data.session_id || "unknown";
295
+ const pendingFile = path.join(pendingDir, `${sid}.json`);
296
+
297
+ if (!fs.existsSync(pendingFile)) {
298
+ console.log("(no pending prompt — skipping)");
299
+ return;
300
+ }
301
+
302
+ const pending = JSON.parse(fs.readFileSync(pendingFile, "utf-8"));
303
+ const aiText = data.last_assistant_message || "";
304
+ if (!aiText) {
305
+ console.log("no assistant message — skipping");
306
+ return;
307
+ }
308
+
309
+ const sessionFile = getSessionFile(sid, bufDir);
310
+
311
+ let turnNum = 1;
312
+ if (fs.existsSync(sessionFile)) {
313
+ turnNum = (fs.readFileSync(sessionFile, "utf-8").match(/<!-- turn:/g) || []).length + 1;
314
+ }
315
+
316
+ const auqCounterPath = path.join(bufDir, "auq-counter.txt");
317
+ let lastCount = 0;
318
+ if (fs.existsSync(auqCounterPath)) {
319
+ lastCount = parseInt(fs.readFileSync(auqCounterPath, "utf-8").trim() || "0", 10);
320
+ }
321
+ const { block: auqBlock, newCount } = extractAuqBlock(data, lastCount);
322
+ if (newCount > lastCount) {
323
+ fs.writeFileSync(auqCounterPath, String(newCount));
324
+ }
325
+
326
+ const speakers = getSpeakers();
327
+
328
+ const userTurnId = `t${String(turnNum).padStart(3, "0")}`;
329
+ const aiTurnId = `t${String(turnNum + 1).padStart(3, "0")}`;
330
+
331
+ const entry = `
332
+ ${renderTurnMarker(userTurnId, speakers.user)}
333
+ ${speakers.user}:${pending.prompt}
334
+
335
+ ${renderTurnMarker(aiTurnId, speakers.ai, userTurnId)}
336
+ ## ${speakers.ai} 的回复
337
+ ${auqBlock}
338
+ ${aiText}
339
+
340
+ `;
341
+
342
+ fs.appendFileSync(sessionFile, entry);
343
+ writeLiveSessionState(bufferRoot, sessionFile);
344
+ fs.unlinkSync(pendingFile);
345
+ console.log(`turn t${String(turnNum).padStart(3, "0")}-t${String(turnNum + 1).padStart(3, "0")} saved`);
346
+ scheduleServeRefresh();
347
+ }
348
+
349
+ async function handleStopFailure(data, bufferRoot) {
350
+ const bufDir = path.join(bufferRoot, "session-buffer");
351
+ ensureDir(bufDir);
352
+ fs.appendFileSync(path.join(bufDir, "failures.jsonl"), JSON.stringify({
353
+ ts: data.timestamp || "",
354
+ session_id: data.session_id || "",
355
+ error: data.error || "",
356
+ }) + "\n");
357
+ }
358
+
359
+ async function main() {
360
+ if (!isCaptureEnabled()) {
361
+ process.exit(0);
362
+ }
363
+
364
+ const raw = await readStdin();
365
+
366
+ let data;
367
+ try {
368
+ data = JSON.parse(raw);
369
+ } catch (_) {
370
+ process.exit(0);
371
+ }
372
+
373
+ const { bufferRoot } = resolveBufferRoot(data);
374
+ ensureDir(path.join(bufferRoot, "session-buffer"));
375
+
376
+ const event = data.hook_event_name || "";
377
+ if (event === "UserPromptSubmit") await handleUserPromptSubmit(data, bufferRoot);
378
+ else if (event === "Stop") await handleStop(data, bufferRoot);
379
+ else if (event === "StopFailure") await handleStopFailure(data, bufferRoot);
380
+
381
+ process.exit(0);
382
+ }
383
+
384
+ if (require.main === module) {
385
+ main().catch(() => process.exit(0));
386
+ }
387
+
388
+ module.exports = {
389
+ getLocalDate,
390
+ claudeProjectDirName,
391
+ projectPathFromTranscriptPath,
392
+ projectFromTranscriptPath,
393
+ resolveBufferRoot,
394
+ getSessionFile,
395
+ scheduleServeRefresh,
396
+ extractAuqBlock,
397
+ handleUserPromptSubmit,
398
+ handleStop,
399
+ handleStopFailure,
400
+ main,
401
+ };
@@ -0,0 +1,78 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const { resolveEchoHomePath } = require("../infra/workspace");
4
+ const { isCaptureEnabled } = require("../infra/config");
5
+ const { findProjectForPath } = require("../usecases/project-registry");
6
+ const { commandFor } = require("../cli/names");
7
+ const { readStdin } = require("../infra/read-stdin");
8
+
9
+ function parseStatusFile(content) {
10
+ const done = (content.match(/^- \[x\]/gim) || []).length;
11
+ const sections = { "进行中": [], "待做": [] };
12
+ let currentSection = null;
13
+
14
+ for (const line of content.split("\n")) {
15
+ const stripped = line.trim();
16
+ if (stripped.startsWith("## 进行中")) { currentSection = "进行中"; continue; }
17
+ if (stripped.startsWith("## 待做")) { currentSection = "待做"; continue; }
18
+ if (stripped.startsWith("## ") && !stripped.startsWith("### ")) { currentSection = null; continue; }
19
+ if (currentSection && stripped.startsWith("- [ ]")) {
20
+ sections[currentSection].push(stripped.slice(5).trim());
21
+ }
22
+ }
23
+
24
+ return { done, inProgress: sections["进行中"], todos: sections["待做"] };
25
+ }
26
+
27
+ async function main() {
28
+ const raw = await readStdin();
29
+ let data;
30
+ try { data = JSON.parse(raw); } catch (_) { process.exit(0); }
31
+
32
+ if (data.hook_event_name !== "SessionStart") process.exit(0);
33
+
34
+ const cwd = data.cwd || "";
35
+ const statusFile = path.join(cwd, "ECHO_STATUS.md");
36
+ if (!fs.existsSync(statusFile)) process.exit(0);
37
+
38
+ const content = fs.readFileSync(statusFile, "utf-8");
39
+ const { done, inProgress, todos } = parseStatusFile(content);
40
+
41
+ const captureActive = isCaptureEnabled();
42
+ const captureStatus = captureActive ? "开启中" : "已暂停";
43
+ const captureHint = captureActive ? `${commandFor(["capture", "off"])} 暂停` : `${commandFor(["capture", "on"])} 开启`;
44
+
45
+ const echoHome = resolveEchoHomePath();
46
+ const project = findProjectForPath(cwd, { echoHome });
47
+ const projectLabel = project ? ` (${project.projectId})` : "";
48
+
49
+ let systemMsg = `Echo${projectLabel}: ${done} done | 自动记录 ${captureStatus} | ${captureHint}`;
50
+ if (inProgress.length > 0) {
51
+ systemMsg += " | In progress: " + inProgress.slice(0, 2).join(", ");
52
+ }
53
+
54
+ const ctx = [`Echo 项目状态:${done} 项已完成`];
55
+ if (project) ctx.push(`当前项目:${project.projectId} (${project.dataRoot})`);
56
+ ctx.push("请使用 Skill 工具调用 gstack 了解项目全貌。");
57
+ if (inProgress.length > 0) {
58
+ ctx.push("");
59
+ ctx.push("**进行中:**");
60
+ for (const item of inProgress) ctx.push(` - ${item}`);
61
+ }
62
+ if (todos.length > 0) {
63
+ ctx.push("");
64
+ ctx.push("**下一步:**");
65
+ for (const item of todos.slice(0, 3)) ctx.push(` - ${item}`);
66
+ }
67
+
68
+ console.log(JSON.stringify({
69
+ hookSpecificOutput: {
70
+ hookEventName: "SessionStart",
71
+ additionalContext: ctx.join("\n"),
72
+ systemMessage: systemMsg,
73
+ },
74
+ }));
75
+ process.exit(0);
76
+ }
77
+
78
+ main().catch(() => process.exit(0));
@@ -0,0 +1,183 @@
1
+ const en = require("./messages/en");
2
+ const zh = require("./messages/zh-CN");
3
+
4
+ function t(key, lang) {
5
+ if (lang === "zh-CN") return zh[key] || en[key] || key;
6
+ return en[key] || key;
7
+ }
8
+
9
+ function bilingual(key) {
10
+ const e = en[key] || key;
11
+ const z = zh[key] || key;
12
+ if (e === z) return e;
13
+ return `${e} / ${z}`;
14
+ }
15
+
16
+ const NEXT_KEY_MAP = { open_docs: "next.openDocs", review_legacy: "next.reviewLegacy" };
17
+
18
+ function nextLabel(kind, lang) {
19
+ const key = NEXT_KEY_MAP[kind] || kind;
20
+ return lang ? t(key, lang) : bilingual(key);
21
+ }
22
+
23
+ function mcpCountLabel(toolCount, lang) {
24
+ if (lang) return `${toolCount} ${t("value.available", lang)}`;
25
+ return `${toolCount} ${en["value.available"]} / ${toolCount} ${zh["value.available"]}`;
26
+ }
27
+
28
+ function formatStatus(model, opts = {}) {
29
+ const lang = opts.lang;
30
+ const json = opts.json === true;
31
+
32
+ if (json) {
33
+ const out = { ...model };
34
+ delete out._meta;
35
+ return JSON.stringify(out, null, 2);
36
+ }
37
+
38
+ if (lang === "en" || lang === "zh-CN") {
39
+ return formatSingleLang(model, lang);
40
+ }
41
+
42
+ return formatBilingual(model);
43
+ }
44
+
45
+ function kvSection(lang, sectionKey, rows) {
46
+ const lines = [];
47
+ const heading = lang ? t(sectionKey, lang) : bilingual(sectionKey);
48
+ lines.push(heading);
49
+ for (const [fieldKey, value] of rows) {
50
+ const label = lang ? t(fieldKey, lang) : bilingual(fieldKey);
51
+ lines.push(` ${label.padEnd(22)} ${value}`);
52
+ }
53
+ lines.push("");
54
+ return lines;
55
+ }
56
+
57
+ function formatSingleLang(model, lang) {
58
+ const lines = [];
59
+ lines.push(t("status.title", lang));
60
+ lines.push("");
61
+
62
+ const s = model.serve || {};
63
+ lines.push(...kvSection(lang, "section.serve", [
64
+ ["field.status", s.running ? t("value.running", lang) : t("value.stopped", lang)],
65
+ ...(s.docsUrl ? [["field.docs", s.docsUrl]] : []),
66
+ ...(s.apiUrl ? [["field.api", s.apiUrl]] : []),
67
+ ...(s.pid ? [["field.pid", String(s.pid)]] : []),
68
+ ...(s.logFile ? [["field.log", s.logFile]] : []),
69
+ ]));
70
+
71
+ const c = model.capture || {};
72
+ lines.push(...kvSection(lang, "section.capture", [
73
+ ["field.captureStatus", c.enabled ? t("value.on", lang) : t("value.off", lang)],
74
+ ]));
75
+
76
+ const h = model.hook || {};
77
+ lines.push(...kvSection(lang, "section.hook", [
78
+ ["field.hookStatus", h.installed ? t("value.installed", lang) : t("value.missing", lang)],
79
+ ]));
80
+
81
+ const p = model.project || {};
82
+ lines.push(...kvSection(lang, "section.project", [
83
+ ["field.projectStatus", p.registered ? t("value.registered", lang) : t("value.unregistered", lang)],
84
+ ...(p.projectId ? [["field.project", p.projectId]] : []),
85
+ ...(p.root ? [["field.root", p.root]] : []),
86
+ ...(p.dataRoot ? [["field.dataRoot", p.dataRoot]] : []),
87
+ ]));
88
+
89
+ const d = model.data || {};
90
+ const dataRows = [];
91
+ if (d.liveBuffers !== undefined) dataRows.push(["field.liveBuffers", String(d.liveBuffers)]);
92
+ if (d.articles !== undefined) dataRows.push(["field.articles", String(d.articles)]);
93
+ if (d.comments !== undefined) dataRows.push(["field.comments", String(d.comments)]);
94
+ if (dataRows.length > 0) lines.push(...kvSection(lang, "section.data", dataRows));
95
+
96
+ const leg = model.legacy || {};
97
+ const legacyRows = [];
98
+ if (leg.buffers !== undefined) legacyRows.push(["field.legacyBuffers", String(leg.buffers)]);
99
+ if (leg.currentProjectCandidates !== undefined) legacyRows.push(["field.legacyCandidates", String(leg.currentProjectCandidates)]);
100
+ if (legacyRows.length > 0) lines.push(...kvSection(lang, "section.legacy", legacyRows));
101
+
102
+ const m = model.mcp || {};
103
+ const mcpRows = [];
104
+ if (m.command) mcpRows.push(["field.config", `${m.command} ${(m.args || []).join(" ")}`]);
105
+ if (m.toolCount !== undefined) mcpRows.push(["field.tools", mcpCountLabel(m.toolCount, lang)]);
106
+ if (mcpRows.length > 0) lines.push(...kvSection(lang, "section.mcp", mcpRows));
107
+
108
+ const next = model.nextActions || [];
109
+ if (next.length > 0) {
110
+ lines.push(t("section.next", lang));
111
+ for (const a of next) {
112
+ if (a.url) lines.push(` ${nextLabel(a.kind, lang)}: ${a.url}`);
113
+ else lines.push(` ${nextLabel(a.kind, lang)}`);
114
+ }
115
+ }
116
+
117
+ return lines.join("\n");
118
+ }
119
+
120
+ function formatBilingual(model) {
121
+ const lines = [];
122
+ lines.push(bilingual("status.title"));
123
+ lines.push("");
124
+
125
+ const s = model.serve || {};
126
+ lines.push(...kvSection(null, "section.serve", [
127
+ ["field.status", s.running ? bilingual("value.running") : bilingual("value.stopped")],
128
+ ...(s.docsUrl ? [["field.docs", s.docsUrl]] : []),
129
+ ...(s.apiUrl ? [["field.api", s.apiUrl]] : []),
130
+ ...(s.pid ? [["field.pid", String(s.pid)]] : []),
131
+ ...(s.logFile ? [["field.log", s.logFile]] : []),
132
+ ]));
133
+
134
+ const c = model.capture || {};
135
+ lines.push(...kvSection(null, "section.capture", [
136
+ ["field.captureStatus", c.enabled ? bilingual("value.on") : bilingual("value.off")],
137
+ ]));
138
+
139
+ const h = model.hook || {};
140
+ lines.push(...kvSection(null, "section.hook", [
141
+ ["field.hookStatus", h.installed ? bilingual("value.installed") : bilingual("value.missing")],
142
+ ]));
143
+
144
+ const p = model.project || {};
145
+ lines.push(...kvSection(null, "section.project", [
146
+ ["field.projectStatus", p.registered ? bilingual("value.registered") : bilingual("value.unregistered")],
147
+ ...(p.projectId ? [["field.project", p.projectId]] : []),
148
+ ...(p.root ? [["field.root", p.root]] : []),
149
+ ...(p.dataRoot ? [["field.dataRoot", p.dataRoot]] : []),
150
+ ]));
151
+
152
+ const d = model.data || {};
153
+ const dataRows = [];
154
+ if (d.liveBuffers !== undefined) dataRows.push(["field.liveBuffers", String(d.liveBuffers)]);
155
+ if (d.articles !== undefined) dataRows.push(["field.articles", String(d.articles)]);
156
+ if (d.comments !== undefined) dataRows.push(["field.comments", String(d.comments)]);
157
+ if (dataRows.length > 0) lines.push(...kvSection(null, "section.data", dataRows));
158
+
159
+ const leg = model.legacy || {};
160
+ const legacyRows = [];
161
+ if (leg.buffers !== undefined) legacyRows.push(["field.legacyBuffers", String(leg.buffers)]);
162
+ if (leg.currentProjectCandidates !== undefined) legacyRows.push(["field.legacyCandidates", String(leg.currentProjectCandidates)]);
163
+ if (legacyRows.length > 0) lines.push(...kvSection(null, "section.legacy", legacyRows));
164
+
165
+ const m = model.mcp || {};
166
+ const mcpRows = [];
167
+ if (m.command) mcpRows.push(["field.config", `${m.command} ${(m.args || []).join(" ")}`]);
168
+ if (m.toolCount !== undefined) mcpRows.push(["field.tools", mcpCountLabel(m.toolCount, null)]);
169
+ if (mcpRows.length > 0) lines.push(...kvSection(null, "section.mcp", mcpRows));
170
+
171
+ const next = model.nextActions || [];
172
+ if (next.length > 0) {
173
+ lines.push(bilingual("section.next"));
174
+ for (const a of next) {
175
+ if (a.url) lines.push(` ${nextLabel(a.kind, null)}: ${a.url}`);
176
+ else lines.push(` ${nextLabel(a.kind, null)}`);
177
+ }
178
+ }
179
+
180
+ return lines.join("\n");
181
+ }
182
+
183
+ module.exports = { formatStatus, t, bilingual };