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,104 @@
1
+ /**
2
+ * run-pipeline — orchestrates the full Echo data pipeline.
3
+ *
4
+ * Usage:
5
+ * const { runPipeline } = require("./usecases/run-pipeline");
6
+ * runPipeline(); // current project only
7
+ * runPipeline({ allProjects: true }); // all registered projects
8
+ * runPipeline({ steps: ["convert", "validate"] });
9
+ */
10
+
11
+ const { resolveDataDirs } = require("../infra/echo-paths");
12
+
13
+ function runOneProject(dirs, steps, opts) {
14
+ const results = {};
15
+
16
+ for (const step of steps) {
17
+ try {
18
+ switch (step) {
19
+ case "convert": {
20
+ console.log(`[echo] convert — buffer -> articles (${dirs.projectId || "default"})\n`);
21
+ const { runConvert } = require("../../convert");
22
+ const r = runConvert({ dirs, cwd: opts.cwd, silent: opts.silent });
23
+ results.convert = { success: true, files: r.files.length };
24
+ console.log("");
25
+ break;
26
+ }
27
+ case "validate": {
28
+ console.log(`[echo] validate — check articles + comments (${dirs.projectId || "default"})\n`);
29
+ const { runValidate } = require("../../validate");
30
+ const r = runValidate({ dirs, cwd: opts.cwd });
31
+ results.validate = r;
32
+ if (!r.success) {
33
+ console.error(`\n[echo] Pipeline stopped: validate failed with ${r.errors.length} error(s).`);
34
+ return { ...results, _halted: true };
35
+ }
36
+ console.log("");
37
+ break;
38
+ }
39
+ case "index": {
40
+ console.log(`[echo] index — generate comment sections (${dirs.projectId || "default"})\n`);
41
+ const { runIndex } = require("../../index");
42
+ const r = runIndex({ dirs, cwd: opts.cwd });
43
+ results.index = { success: true, updated: r.updated.length, articleCount: r.articleCount, commentCount: r.commentCount };
44
+ console.log("");
45
+ break;
46
+ }
47
+ case "resolve": {
48
+ console.log(`[echo] resolve — verify annotation anchors (${dirs.projectId || "default"})\n`);
49
+ const { runResolve } = require("../../resolve");
50
+ const r = runResolve({ dirs, cwd: opts.cwd });
51
+ results.resolve = r;
52
+ if (r.broken > 0) {
53
+ console.error(`\n[echo] Pipeline stopped: resolve found ${r.broken} broken anchor(s).`);
54
+ return { ...results, _halted: true };
55
+ }
56
+ console.log("");
57
+ break;
58
+ }
59
+ default:
60
+ throw new Error(`Unknown pipeline step: ${step}`);
61
+ }
62
+ } catch (err) {
63
+ console.error(`\n[echo] Pipeline stopped: ${step} threw an error.`);
64
+ console.error(` ${err.message}`);
65
+ results[step] = { success: false, error: err.message };
66
+ return { ...results, _halted: true };
67
+ }
68
+ }
69
+
70
+ return results;
71
+ }
72
+
73
+ function runPipeline(opts = {}) {
74
+ const cwd = opts.cwd || process.cwd();
75
+ const steps = opts.steps || ["convert", "validate", "index", "resolve"];
76
+
77
+ if (opts.allProjects) {
78
+ const { aggregateAllProjects } = require("./aggregate-all-projects");
79
+ const sources = aggregateAllProjects();
80
+ const allResults = {};
81
+
82
+ for (const src of sources) {
83
+ const dirs = {
84
+ articlesDir: src.articlesDir,
85
+ commentsDir: src.commentsDir,
86
+ bufferDir: src.bufferDir,
87
+ indexDir: src.indexDir,
88
+ projectId: src.projectId,
89
+ projectRoot: src.root,
90
+ };
91
+ const result = runOneProject(dirs, steps, opts);
92
+ allResults[src.projectId] = result;
93
+ if (result._halted) return allResults;
94
+ delete result._halted;
95
+ }
96
+
97
+ return allResults;
98
+ }
99
+
100
+ const dirs = opts.dirs || resolveDataDirs({ cwd });
101
+ return runOneProject(dirs, steps, opts);
102
+ }
103
+
104
+ module.exports = { runPipeline };
@@ -0,0 +1,48 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+
4
+ function manifestPath(dataRoot) {
5
+ return path.join(dataRoot, "snapshots.json");
6
+ }
7
+
8
+ function loadManifest(dataRoot) {
9
+ try {
10
+ return JSON.parse(fs.readFileSync(manifestPath(dataRoot), "utf-8"));
11
+ } catch (err) {
12
+ if (err.code === "ENOENT") return { sessions: {} };
13
+ throw err;
14
+ }
15
+ }
16
+
17
+ function saveManifest(dataRoot, manifest) {
18
+ const p = manifestPath(dataRoot);
19
+ fs.mkdirSync(path.dirname(p), { recursive: true });
20
+ const tmp = p + ".tmp-" + Date.now();
21
+ fs.writeFileSync(tmp, JSON.stringify(manifest, null, 2));
22
+ fs.renameSync(tmp, p);
23
+ }
24
+
25
+ function recordSnapshot(dataRoot, sessionId, articleId, turnCount) {
26
+ const manifest = loadManifest(dataRoot);
27
+ if (!manifest.sessions[sessionId]) {
28
+ manifest.sessions[sessionId] = { latestArticleId: null, versions: [] };
29
+ }
30
+ const entry = manifest.sessions[sessionId];
31
+ const nextVersion = entry.versions.length + 1;
32
+ entry.versions.push({
33
+ version: nextVersion,
34
+ articleId,
35
+ publishedAt: new Date().toISOString(),
36
+ turnCount,
37
+ });
38
+ entry.latestArticleId = articleId;
39
+ saveManifest(dataRoot, manifest);
40
+ return { version: nextVersion, latest: true };
41
+ }
42
+
43
+ function getSnapshotInfo(dataRoot, sessionId) {
44
+ const manifest = loadManifest(dataRoot);
45
+ return manifest.sessions[sessionId] || null;
46
+ }
47
+
48
+ module.exports = { loadManifest, saveManifest, recordSnapshot, getSnapshotInfo };
@@ -0,0 +1,142 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const os = require("os");
4
+ const { resolveEchoHomePath } = require("../infra/workspace");
5
+ const { isCaptureEnabled } = require("../infra/config");
6
+ const { findProjectForPath } = require("./project-registry");
7
+ const { cliNames } = require("../cli/names");
8
+ const { scanLegacyCandidates } = require("./legacy-candidates");
9
+ const { discoverClaudeImportCandidates } = require("./discover-claude-imports");
10
+
11
+ function serveInfoFile() {
12
+ return path.join(resolveEchoHomePath(), ".serve.json");
13
+ }
14
+
15
+ function readServeInfo() {
16
+ try {
17
+ return JSON.parse(fs.readFileSync(serveInfoFile(), "utf-8"));
18
+ } catch (_) {
19
+ return null;
20
+ }
21
+ }
22
+
23
+ function isPidRunning(pid) {
24
+ if (!Number.isInteger(pid) || pid <= 0) return false;
25
+ try {
26
+ process.kill(pid, 0);
27
+ return true;
28
+ } catch (err) {
29
+ return err.code === "EPERM";
30
+ }
31
+ }
32
+
33
+ function extractHookCommand(entry) {
34
+ if (Array.isArray(entry.hooks)) {
35
+ const h = entry.hooks.find((h) => typeof h.command === "string");
36
+ if (h) return h.command;
37
+ }
38
+ if (typeof entry.command === "string") return entry.command;
39
+ return null;
40
+ }
41
+
42
+ function checkHookInstalled() {
43
+ const settingsPath = path.join(os.homedir(), ".claude", "settings.json");
44
+ try {
45
+ const settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
46
+ const hooks = settings.hooks || {};
47
+ for (const event of ["SessionStart", "UserPromptSubmit", "Stop", "StopFailure"]) {
48
+ const entries = hooks[event] || [];
49
+ const has = entries.some((e) => {
50
+ const cmd = extractHookCommand(e);
51
+ return cmd && (cmd.includes("echoctl") || cmd.includes("echo-mcp"));
52
+ });
53
+ if (!has) return false;
54
+ }
55
+ return true;
56
+ } catch (_) {
57
+ return false;
58
+ }
59
+ }
60
+
61
+ function countMdFiles(dir) {
62
+ try {
63
+ return fs.readdirSync(dir).filter((f) => f.endsWith(".md")).length;
64
+ } catch (_) {
65
+ return 0;
66
+ }
67
+ }
68
+
69
+ function collectStatus(opts = {}) {
70
+ const echoHome = resolveEchoHomePath(opts);
71
+ const serveInfo = readServeInfo();
72
+ const serveRunning = serveInfo ? isPidRunning(serveInfo.pid) : false;
73
+ const project = findProjectForPath(process.cwd(), { echoHome });
74
+ const TOOLS = require("../interfaces/mcp/tools").TOOLS;
75
+
76
+ let legacyBuffers = 0;
77
+ let legacyCandidates = 0;
78
+ try {
79
+ const legacyDir = path.join(echoHome, "session-buffer");
80
+ if (fs.existsSync(legacyDir)) {
81
+ legacyBuffers = fs.readdirSync(legacyDir).filter((f) => f.endsWith(".md")).length;
82
+ }
83
+ if (project) {
84
+ const result = scanLegacyCandidates(project.projectId, { echoHome });
85
+ legacyCandidates = result.candidates.length;
86
+ }
87
+ } catch (_) {}
88
+
89
+ let transcripts = null;
90
+ let transcriptNew = 0;
91
+ let transcriptUpdated = 0;
92
+ if (project) {
93
+ try {
94
+ const r = discoverClaudeImportCandidates(project.projectId, { echoHome });
95
+ transcripts = { provider: r.provider, projectDir: r.projectDir, ...r.summary };
96
+ transcriptNew = r.summary.new;
97
+ transcriptUpdated = r.summary.updated;
98
+ } catch (_) {}
99
+ }
100
+
101
+ return {
102
+ serve: {
103
+ running: serveRunning,
104
+ docsUrl: (serveRunning && serveInfo) ? `http://127.0.0.1:${serveInfo.docsPort}/` : null,
105
+ apiUrl: (serveRunning && serveInfo) ? `http://127.0.0.1:${serveInfo.apiPort}/` : null,
106
+ pid: serveInfo ? serveInfo.pid : null,
107
+ logFile: path.join(echoHome, ".serve.log"),
108
+ },
109
+ capture: { enabled: isCaptureEnabled() },
110
+ hook: { provider: "claude", installed: checkHookInstalled() },
111
+ project: project ? {
112
+ registered: true, projectId: project.projectId,
113
+ root: project.projectRoot, dataRoot: project.dataRoot,
114
+ } : {
115
+ registered: false, projectId: null, root: null, dataRoot: null,
116
+ },
117
+ data: project ? {
118
+ liveBuffers: countMdFiles(path.join(project.dataRoot, "session-buffer")),
119
+ articles: countMdFiles(path.join(project.dataRoot, "articles")),
120
+ comments: countMdFiles(path.join(project.dataRoot, "comments")),
121
+ } : { liveBuffers: 0, articles: 0, comments: 0 },
122
+ legacy: { buffers: legacyBuffers, currentProjectCandidates: legacyCandidates },
123
+ transcripts,
124
+ mcp: {
125
+ command: cliNames.canonicalName, args: ["mcp"], toolCount: TOOLS.length,
126
+ },
127
+ nextActions: [
128
+ ...(serveRunning && serveInfo && serveInfo.docsPort
129
+ ? [{ kind: "open_docs", label: "Open Docs", url: `http://127.0.0.1:${serveInfo.docsPort}/` }]
130
+ : []),
131
+ ...(legacyCandidates > 0
132
+ ? [{ kind: "review_legacy", label: "Review legacy candidates" }]
133
+ : []),
134
+ ...(transcriptNew > 0 || transcriptUpdated > 0
135
+ ? [{ kind: "review_transcripts", label: "Review unimported Claude transcripts" }]
136
+ : []),
137
+ ],
138
+ _meta: { echoHome, collectedAt: new Date().toISOString() },
139
+ };
140
+ }
141
+
142
+ module.exports = { collectStatus };
@@ -0,0 +1,7 @@
1
+ function stripCommentSections(text) {
2
+ return text
3
+ .replace(/<!-- ECHO_COMMENTS_START -->[\s\S]*?<!-- ECHO_COMMENTS_END -->\n*/g, "")
4
+ .replace(/<!-- ECHO:COMMENT_LIST -->\n*/g, "");
5
+ }
6
+
7
+ module.exports = { stripCommentSections };
@@ -0,0 +1,122 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const matter = require("gray-matter");
4
+
5
+ const { stripCommentSections } = require("./strip-comments");
6
+ const anchor = require("../domain/anchor");
7
+
8
+ function writeComment(opts) {
9
+ const { articleId, quote, comment, author, scope, dirs, store } = opts;
10
+ const evKind = opts.evolutionKind || "null";
11
+ const evOf = opts.evolutionOf || [];
12
+ const finalStatus = opts.status || "open";
13
+
14
+ const { articlesDir, commentsDir } = dirs;
15
+ const authorName = author || "vincent";
16
+
17
+ // Load article
18
+ const loaded = store.loadArticleById(articlesDir, articleId);
19
+ if (!loaded) {
20
+ const err = new Error(`Article "${articleId}" not found.`);
21
+ err.availableArticles = store.loadArticles(articlesDir).map((a) => ({ id: a.id, relPath: a.relPath }));
22
+ throw err;
23
+ }
24
+
25
+ const body = stripCommentSections(loaded.content);
26
+ const d = new Date();
27
+ const off = -d.getTimezoneOffset();
28
+ const sign = off >= 0 ? "+" : "-";
29
+ const pad = (n) => String(Math.abs(n)).padStart(2, "0");
30
+ const now = d.getFullYear() + "-" +
31
+ pad(d.getMonth() + 1) + "-" +
32
+ pad(d.getDate()) + "T" +
33
+ pad(d.getHours()) + ":" +
34
+ pad(d.getMinutes()) + ":" +
35
+ pad(d.getSeconds()) +
36
+ sign + pad(Math.floor(Math.abs(off) / 60)) + ":" + pad(Math.abs(off) % 60);
37
+ const newId = store.nextAnnotationId(commentsDir);
38
+
39
+ if (scope === "article") {
40
+ const frontmatter = {
41
+ id: newId,
42
+ type: "annotation",
43
+ target: { article_id: articleId, path: loaded.relPath },
44
+ anchor: { kind: "article" },
45
+ author: authorName,
46
+ created_at: now,
47
+ updated_at: now,
48
+ status: finalStatus,
49
+ tags: [],
50
+ evolution: { of: evOf, kind: evKind },
51
+ };
52
+ const fileContent = matter.stringify(comment.trim(), frontmatter);
53
+
54
+ if (!fs.existsSync(commentsDir)) fs.mkdirSync(commentsDir, { recursive: true });
55
+ const outPath = path.join(commentsDir, `${newId}.md`);
56
+ fs.writeFileSync(outPath, fileContent);
57
+ return { id: newId, articleId, scope: "article" };
58
+ }
59
+
60
+ // Inline comment: quote required, resolve anchor
61
+ if (!quote) {
62
+ throw new Error("quote is required for inline comments (or set scope: 'article')");
63
+ }
64
+
65
+ // Resolve anchor position
66
+ let positions;
67
+ if (opts.prefix && opts.suffix && opts.occurrence) {
68
+ // Use caller-provided anchor data, validate it
69
+ const resolved = anchor.resolveAnchor(
70
+ { anchor: { quote: opts.quote, prefix: opts.prefix, suffix: opts.suffix, line_hint: opts.lineHint } },
71
+ body
72
+ );
73
+ if (resolved.status === "broken") {
74
+ throw new Error(`Anchor invalid: ${resolved.reason}`);
75
+ }
76
+ positions = [resolved.position];
77
+ } else {
78
+ // Auto-discover
79
+ const searchBody = anchor.stripInlineFormatting(body);
80
+ const searchQuote = anchor.stripInlineFormatting(quote);
81
+ positions = anchor.findAllPositions(searchBody, searchQuote);
82
+
83
+ if (positions.length === 0) {
84
+ throw new Error(`Quote not found in article "${articleId}".`);
85
+ }
86
+ }
87
+
88
+ const chosen = positions[(opts.occurrence || 1) - 1] || positions[0];
89
+ const occurrenceIdx = positions.indexOf(chosen);
90
+
91
+ // Compute anchor metadata
92
+ const searchBody = anchor.stripInlineFormatting(body);
93
+ const searchQuote = anchor.stripInlineFormatting(quote);
94
+ const prefixRaw = searchBody.slice(Math.max(0, chosen.index - 100), chosen.index).trim();
95
+ const suffixRaw = searchBody.slice(chosen.index + searchQuote.length, chosen.index + searchQuote.length + 100).trim();
96
+
97
+ const frontmatter = {
98
+ id: newId,
99
+ type: "annotation",
100
+ target: { article_id: articleId, path: loaded.relPath },
101
+ anchor: { quote, prefix: prefixRaw, suffix: suffixRaw, occurrence: occurrenceIdx + 1, line_hint: chosen.line },
102
+ author: authorName,
103
+ created_at: now,
104
+ updated_at: now,
105
+ status: finalStatus,
106
+ tags: [],
107
+ evolution: { of: evOf, kind: evKind },
108
+ };
109
+ const fileContent = matter.stringify(comment.trim(), frontmatter);
110
+
111
+ if (!fs.existsSync(commentsDir)) fs.mkdirSync(commentsDir, { recursive: true });
112
+ const outPath = path.join(commentsDir, `${newId}.md`);
113
+
114
+ if (fs.existsSync(outPath)) {
115
+ throw new Error(`${newId}.md already exists in comments directory.`);
116
+ }
117
+
118
+ fs.writeFileSync(outPath, fileContent);
119
+ return { id: newId, articleId, scope: "inline" };
120
+ }
121
+
122
+ module.exports = { writeComment };
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env node
2
+ const { ensureDir } = require("./lib/infra/workspace");
3
+ const { resolveDataDirs } = require("./lib/infra/echo-paths");
4
+ const store = require("./lib/infra/markdown-store");
5
+ const { resolveAnchor } = require("./lib/domain/anchor");
6
+ const { stripCommentSections } = require("./lib/usecases/strip-comments");
7
+
8
+ function runResolve(opts = {}) {
9
+ const dirs = opts.dirs || resolveDataDirs();
10
+ const { articlesDir, commentsDir } = dirs;
11
+
12
+ ensureDir(articlesDir);
13
+ ensureDir(commentsDir);
14
+
15
+ const articles = {};
16
+ for (const [id, a] of Object.entries(store.indexArticles(store.loadArticles(articlesDir)))) {
17
+ articles[id] = { data: a.data, body: stripCommentSections(a.content), file: a.relPath };
18
+ }
19
+
20
+ const comments = store.loadComments(commentsDir);
21
+
22
+ let ok = 0;
23
+ let broken = 0;
24
+ let needsReview = 0;
25
+ let ambiguous = 0;
26
+
27
+ for (const c of comments) {
28
+ const articleId = c.target?.article_id;
29
+ if (!articleId || !articles[articleId]) {
30
+ console.log(`${c._file}: SKIP — article "${articleId}" not found`);
31
+ continue;
32
+ }
33
+
34
+ const result = resolveAnchor(c, articles[articleId].body);
35
+
36
+ switch (result.status) {
37
+ case "ok":
38
+ ok++;
39
+ break;
40
+ case "broken":
41
+ broken++;
42
+ console.log(`${c._file}: BROKEN — ${result.reason}`);
43
+ break;
44
+ case "needs_review":
45
+ needsReview++;
46
+ console.log(`${c._file}: NEEDS_REVIEW — ${result.reason} (guessed line ${result.position?.line})`);
47
+ break;
48
+ case "ambiguous":
49
+ ambiguous++;
50
+ console.log(`${c._file}: AMBIGUOUS — ${result.reason}`);
51
+ break;
52
+ }
53
+ }
54
+
55
+ console.log(`\n${ok} ok, ${broken} broken, ${needsReview} needs_review, ${ambiguous} ambiguous`);
56
+
57
+ return { ok, broken, needsReview, ambiguous };
58
+ }
59
+
60
+ if (require.main === module) {
61
+ const result = runResolve();
62
+ process.exit(result.broken > 0 ? 1 : 0);
63
+ }
64
+
65
+ module.exports = { runResolve };
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env node
2
+ const { ensureDir } = require("./lib/infra/workspace");
3
+ const { resolveDataDirs } = require("./lib/infra/echo-paths");
4
+ const store = require("./lib/infra/markdown-store");
5
+
6
+ function runSearch(opts = {}) {
7
+ const dirs = opts.dirs || resolveDataDirs();
8
+ const { articlesDir } = dirs;
9
+
10
+ ensureDir(articlesDir);
11
+
12
+ const keyword = opts.keyword || "";
13
+ const tag = opts.tag || "";
14
+
15
+ if (!keyword && !tag) {
16
+ if (!opts.silent) {
17
+ console.log("Usage: npm run search -- --keyword <text> [--tag <tag>]");
18
+ console.log(" --keyword, -k Full-text search in article body");
19
+ console.log(" --tag, -t Filter by tag");
20
+ }
21
+ return { results: [], count: 0 };
22
+ }
23
+
24
+ const articles = store.loadArticles(articlesDir).map((a) => ({
25
+ ...a.data,
26
+ _file: a.relPath,
27
+ _content: a.content,
28
+ }));
29
+
30
+ let results = articles;
31
+
32
+ if (tag) {
33
+ const tagLower = tag.toLowerCase();
34
+ results = results.filter((a) =>
35
+ (a.tags || []).some((t) => t.toLowerCase() === tagLower)
36
+ );
37
+ }
38
+
39
+ if (keyword) {
40
+ const kw = keyword.toLowerCase();
41
+ results = results
42
+ .map((a) => {
43
+ const body = a._content.toLowerCase();
44
+ const aliasMatch = (a.alias || "").toLowerCase().includes(kw);
45
+ const idx = body.indexOf(kw);
46
+ if (idx === -1 && !aliasMatch) return null;
47
+ const start = Math.max(0, idx - 40);
48
+ const end = Math.min(body.length, idx + kw.length + 40);
49
+ let snippet = a._content.slice(start, end).replace(/\n/g, " ");
50
+ if (start > 0) snippet = "..." + snippet;
51
+ if (end < body.length) snippet = snippet + "...";
52
+ return { ...a, alias: a.alias || "", _snippet: snippet };
53
+ })
54
+ .filter(Boolean);
55
+ }
56
+
57
+ if (!keyword) {
58
+ results = results.map((a) => ({ ...a, alias: a.alias || "" }));
59
+ }
60
+
61
+ return { results, count: results.length };
62
+ }
63
+
64
+ if (require.main === module) {
65
+ const args = process.argv.slice(2);
66
+ const opts = { keyword: "", tag: "" };
67
+ for (let i = 0; i < args.length; i++) {
68
+ if (args[i] === "--keyword" || args[i] === "-k") opts.keyword = args[++i] || "";
69
+ else if (args[i] === "--tag" || args[i] === "-t") opts.tag = args[++i] || "";
70
+ }
71
+
72
+ const result = runSearch(opts);
73
+
74
+ if (!opts.keyword && !opts.tag) {
75
+ process.exit(0);
76
+ }
77
+
78
+ if (result.count === 0) {
79
+ const filters = [];
80
+ if (opts.keyword) filters.push(`keyword="${opts.keyword}"`);
81
+ if (opts.tag) filters.push(`tag="${opts.tag}"`);
82
+ console.log(`No results for ${filters.join(", ")}.`);
83
+ process.exit(0);
84
+ }
85
+
86
+ console.log(`${result.count} result(s):\n`);
87
+ for (const a of result.results) {
88
+ const ca = a.created_at;
89
+ const d = ca instanceof Date ? ca.toISOString().slice(0, 10) : String(ca || "").slice(0, 10);
90
+ console.log(` ${a.title || a.id}`);
91
+ console.log(` ${a._file} · ${d}`);
92
+ if (a._snippet) console.log(` > ${a._snippet}`);
93
+ if (a.tags && a.tags.length) console.log(` tags: ${a.tags.join(", ")}`);
94
+ console.log();
95
+ }
96
+ }
97
+
98
+ module.exports = { runSearch };