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,209 @@
1
+ const fs = require("fs");
2
+ const os = require("os");
3
+ const path = require("path");
4
+ const { resolveEchoHomePath, ensureDir } = require("../infra/workspace");
5
+ const { findProjectById, registerProject } = require("./project-registry");
6
+
7
+ function defaultLegacyBufferDir(echoHome) {
8
+ return path.join(echoHome, "session-buffer");
9
+ }
10
+
11
+ function listMarkdownFiles(dir) {
12
+ try {
13
+ return fs.readdirSync(dir)
14
+ .filter((name) => name.endsWith(".md"))
15
+ .sort();
16
+ } catch (err) {
17
+ if (err.code === "ENOENT") return [];
18
+ throw err;
19
+ }
20
+ }
21
+
22
+ function readSessionMap(mapPath) {
23
+ const rows = [];
24
+ try {
25
+ const raw = fs.readFileSync(mapPath, "utf-8");
26
+ for (const line of raw.split(/\r?\n/)) {
27
+ if (!line.trim()) continue;
28
+ const idx = line.indexOf("=");
29
+ if (idx === -1) continue;
30
+ rows.push({ sessionId: line.slice(0, idx), filePath: line.slice(idx + 1) });
31
+ }
32
+ } catch (err) {
33
+ if (err.code !== "ENOENT") throw err;
34
+ }
35
+ return rows;
36
+ }
37
+
38
+ function writeSessionMap(mapPath, rows) {
39
+ const body = rows.map((row) => `${row.sessionId}=${row.filePath}`).join("\n");
40
+ fs.writeFileSync(mapPath, body ? body + "\n" : "");
41
+ }
42
+
43
+ function appendFileIfExists(source, dest, apply) {
44
+ if (!fs.existsSync(source)) return false;
45
+ if (!apply) return true;
46
+ ensureDir(path.dirname(dest));
47
+ fs.appendFileSync(dest, fs.readFileSync(source, "utf-8"));
48
+ return true;
49
+ }
50
+
51
+ function copyPendingFiles(sourcePendingDir, targetPendingDir, apply, overwrite) {
52
+ let names;
53
+ try {
54
+ names = fs.readdirSync(sourcePendingDir).filter((name) => name.endsWith(".json")).sort();
55
+ } catch (err) {
56
+ if (err.code === "ENOENT") return [];
57
+ throw err;
58
+ }
59
+
60
+ const pending = [];
61
+ for (const name of names) {
62
+ const source = path.join(sourcePendingDir, name);
63
+ const dest = path.join(targetPendingDir, name);
64
+ const exists = fs.existsSync(dest);
65
+ const status = exists && !overwrite ? "skipped_existing" : exists ? "overwrite" : "copy";
66
+ pending.push({ name, source, dest, status });
67
+ if (apply && status !== "skipped_existing") {
68
+ ensureDir(targetPendingDir);
69
+ fs.copyFileSync(source, dest);
70
+ }
71
+ }
72
+ return pending;
73
+ }
74
+
75
+ function resolveTargetProject(opts) {
76
+ if (opts.projectId) {
77
+ const project = findProjectById(opts.projectId, { echoHome: opts.echoHome });
78
+ if (!project) throw new Error(`Project "${opts.projectId}" not found. Run echoctl project list or use --path <dir>.`);
79
+ return project;
80
+ }
81
+ if (opts.projectPath) {
82
+ const result = registerProject(opts.projectPath, { echoHome: opts.echoHome });
83
+ return {
84
+ projectId: result.projectId,
85
+ projectRoot: result.projectRoot,
86
+ dataRoot: result.dataRoot,
87
+ registered: result.created,
88
+ };
89
+ }
90
+ throw new Error("Target project required: use --project <id> or --path <dir>.");
91
+ }
92
+
93
+ function migrateLegacyBuffer(opts = {}) {
94
+ const echoHome = opts.echoHome || resolveEchoHomePath(opts);
95
+ const apply = opts.apply === true;
96
+ const overwrite = opts.overwrite === true;
97
+ const move = opts.move === true;
98
+ const sourceDir = path.resolve(opts.from || defaultLegacyBufferDir(echoHome));
99
+ const project = resolveTargetProject({ ...opts, echoHome });
100
+ const targetDir = path.join(project.dataRoot, "session-buffer");
101
+
102
+ if (path.resolve(sourceDir) === path.resolve(targetDir)) {
103
+ throw new Error("Source legacy buffer and target project buffer are the same directory.");
104
+ }
105
+
106
+ const markdownNames = listMarkdownFiles(sourceDir);
107
+ const filterNames = opts.filterFileNames || null;
108
+ const filteredNames = filterNames
109
+ ? markdownNames.filter((n) => filterNames.includes(n))
110
+ : markdownNames;
111
+ const files = [];
112
+ const copiedNames = new Set();
113
+
114
+ for (const name of filteredNames) {
115
+ const source = path.join(sourceDir, name);
116
+ const dest = path.join(targetDir, name);
117
+ const exists = fs.existsSync(dest);
118
+ const status = exists && !overwrite ? "skipped_existing" : exists ? "overwrite" : "copy";
119
+ files.push({ name, source, dest, status });
120
+ if (status !== "skipped_existing") copiedNames.add(name);
121
+ if (apply && status !== "skipped_existing") {
122
+ ensureDir(targetDir);
123
+ fs.copyFileSync(source, dest);
124
+ if (move) fs.unlinkSync(source);
125
+ }
126
+ }
127
+
128
+ const sourceMapPath = path.join(sourceDir, "session-map.txt");
129
+ const targetMapPath = path.join(targetDir, "session-map.txt");
130
+ const sourceMapRows = readSessionMap(sourceMapPath);
131
+ const targetMapRows = readSessionMap(targetMapPath);
132
+ const targetBySession = new Map(targetMapRows.map((row) => [row.sessionId, row.filePath]));
133
+ const mapUpdates = [];
134
+ const mapConflicts = [];
135
+
136
+ for (const row of sourceMapRows) {
137
+ const name = path.basename(row.filePath);
138
+ if (!copiedNames.has(name)) continue;
139
+ const nextPath = path.join(targetDir, name);
140
+ const existing = targetBySession.get(row.sessionId);
141
+ if (existing && path.resolve(existing) !== path.resolve(nextPath) && !overwrite) {
142
+ mapConflicts.push({ sessionId: row.sessionId, existing, next: nextPath });
143
+ continue;
144
+ }
145
+ targetBySession.set(row.sessionId, nextPath);
146
+ mapUpdates.push({ sessionId: row.sessionId, filePath: nextPath });
147
+ }
148
+
149
+ if (apply && mapUpdates.length > 0) {
150
+ const merged = [...targetBySession.entries()]
151
+ .sort(([a], [b]) => a.localeCompare(b))
152
+ .map(([sessionId, filePath]) => ({ sessionId, filePath }));
153
+ ensureDir(targetDir);
154
+ writeSessionMap(targetMapPath, merged);
155
+ }
156
+
157
+ const pending = copyPendingFiles(
158
+ path.join(sourceDir, "pending"),
159
+ path.join(targetDir, "pending"),
160
+ apply,
161
+ overwrite
162
+ );
163
+
164
+ const failuresCopied = appendFileIfExists(
165
+ path.join(sourceDir, "failures.jsonl"),
166
+ path.join(targetDir, "failures.jsonl"),
167
+ apply
168
+ );
169
+
170
+ const auqCopied = fs.existsSync(path.join(sourceDir, "auq-counter.txt"));
171
+ if (apply && auqCopied) {
172
+ const sourceAuq = path.join(sourceDir, "auq-counter.txt");
173
+ const targetAuq = path.join(targetDir, "auq-counter.txt");
174
+ if (overwrite || !fs.existsSync(targetAuq)) {
175
+ ensureDir(targetDir);
176
+ fs.copyFileSync(sourceAuq, targetAuq);
177
+ }
178
+ }
179
+
180
+ return {
181
+ applied: apply,
182
+ moved: apply && move,
183
+ sourceDir,
184
+ targetDir,
185
+ projectId: project.projectId,
186
+ projectRoot: project.projectRoot,
187
+ registered: project.registered === true,
188
+ files,
189
+ mapUpdates,
190
+ mapConflicts,
191
+ pending,
192
+ failuresCopied,
193
+ auqCopied,
194
+ summary: {
195
+ copy: files.filter((f) => f.status === "copy").length,
196
+ overwrite: files.filter((f) => f.status === "overwrite").length,
197
+ skippedExisting: files.filter((f) => f.status === "skipped_existing").length,
198
+ mapUpdates: mapUpdates.length,
199
+ mapConflicts: mapConflicts.length,
200
+ pending: pending.length,
201
+ },
202
+ };
203
+ }
204
+
205
+ module.exports = {
206
+ migrateLegacyBuffer,
207
+ defaultLegacyBufferDir,
208
+ readSessionMap,
209
+ };
@@ -0,0 +1,170 @@
1
+ const path = require("path");
2
+ const fs = require("fs");
3
+ const {
4
+ resolveEchoHomePath,
5
+ projectIdFromPath,
6
+ resolveProjectDataRoot,
7
+ } = require("../infra/workspace");
8
+
9
+ const PROJECT_DATA_DIRS = ["session-buffer", "articles", "comments", "index"];
10
+
11
+ /**
12
+ * @param {string} echoHome
13
+ * @returns {{ projects: Record<string, { root: string, registeredAt: string }> }}
14
+ */
15
+ function loadRegistry(echoHome) {
16
+ const registryPath = path.join(echoHome, "registry.json");
17
+ let raw;
18
+ try {
19
+ raw = fs.readFileSync(registryPath, "utf-8");
20
+ } catch (_) {
21
+ return { projects: {} };
22
+ }
23
+ try {
24
+ return JSON.parse(raw);
25
+ } catch (_) {
26
+ const backup = registryPath + ".corrupt-" + Date.now();
27
+ fs.renameSync(registryPath, backup);
28
+ throw new Error(
29
+ `registry.json is corrupt — backed up to ${backup}. Restore manually or re-register projects.`
30
+ );
31
+ }
32
+ }
33
+
34
+ /**
35
+ * @param {string} echoHome
36
+ * @param {{ projects: Record<string, { root: string, registeredAt: string }> }} registry
37
+ */
38
+ function saveRegistry(echoHome, registry) {
39
+ const registryPath = path.join(echoHome, "registry.json");
40
+ if (!fs.existsSync(echoHome)) {
41
+ fs.mkdirSync(echoHome, { recursive: true });
42
+ }
43
+ const tmp = registryPath + ".tmp-" + Date.now();
44
+ fs.writeFileSync(tmp, JSON.stringify(registry, null, 2) + "\n");
45
+ fs.renameSync(tmp, registryPath);
46
+ }
47
+
48
+ /**
49
+ * @param {string} projectPath
50
+ * @param {{ echoHome?: string, projectId?: string }} [opts]
51
+ */
52
+ function registerProject(projectPath, opts = {}) {
53
+ const echoHome = opts.echoHome || resolveEchoHomePath(opts);
54
+ const absolutePath = path.resolve(projectPath);
55
+ const projectId = opts.projectId || projectIdFromPath(projectPath);
56
+ const registry = loadRegistry(echoHome);
57
+
58
+ const existing = registry.projects[projectId];
59
+ if (existing) {
60
+ if (path.resolve(existing.root) !== absolutePath) {
61
+ throw new Error(
62
+ `Project "${projectId}" is already registered at ${existing.root} — ` +
63
+ `cannot register a different path ${absolutePath} under the same id. ` +
64
+ `Use a unique directory name or unregister the existing one first.`
65
+ );
66
+ }
67
+ return {
68
+ projectId,
69
+ projectRoot: existing.root,
70
+ dataRoot: resolveProjectDataRoot(projectPath, { echoHome, projectId }),
71
+ created: false,
72
+ dirsCreated: [],
73
+ dirsSkipped: [],
74
+ };
75
+ }
76
+
77
+ registry.projects[projectId] = {
78
+ root: absolutePath,
79
+ registeredAt: new Date().toISOString(),
80
+ };
81
+ saveRegistry(echoHome, registry);
82
+
83
+ const dataRoot = resolveProjectDataRoot(projectPath, { echoHome, projectId });
84
+ const dirsCreated = [];
85
+ const dirsSkipped = [];
86
+ for (const d of PROJECT_DATA_DIRS) {
87
+ const full = path.join(dataRoot, d);
88
+ if (!fs.existsSync(full)) {
89
+ fs.mkdirSync(full, { recursive: true });
90
+ dirsCreated.push(d);
91
+ } else {
92
+ dirsSkipped.push(d);
93
+ }
94
+ }
95
+
96
+ return {
97
+ projectId,
98
+ projectRoot: absolutePath,
99
+ dataRoot,
100
+ created: true,
101
+ dirsCreated,
102
+ dirsSkipped,
103
+ };
104
+ }
105
+
106
+ /**
107
+ * @param {string} searchPath
108
+ * @param {{ echoHome?: string }} [opts]
109
+ */
110
+ function findProjectForPath(searchPath, opts = {}) {
111
+ const echoHome = opts.echoHome || resolveEchoHomePath(opts);
112
+ const registry = loadRegistry(echoHome);
113
+ const resolved = path.resolve(searchPath);
114
+
115
+ const entries = Object.entries(registry.projects)
116
+ .map(([id, entry]) => ({ id, root: path.resolve(entry.root) }))
117
+ .sort((a, b) => b.root.length - a.root.length);
118
+
119
+ for (const { id, root } of entries) {
120
+ if (resolved === root || resolved.startsWith(root + path.sep)) {
121
+ return {
122
+ projectId: id,
123
+ projectRoot: root,
124
+ dataRoot: resolveProjectDataRoot(root, { echoHome, projectId: id }),
125
+ };
126
+ }
127
+ }
128
+
129
+ return null;
130
+ }
131
+
132
+ /**
133
+ * @param {string} echoHome
134
+ * @returns {Array<{ projectId: string, root: string, dataRoot: string, registeredAt: string }>}
135
+ */
136
+ function listProjects(echoHome) {
137
+ const home = echoHome || resolveEchoHomePath();
138
+ const registry = loadRegistry(home);
139
+ return Object.entries(registry.projects).map(([projectId, entry]) => ({
140
+ projectId,
141
+ root: entry.root,
142
+ dataRoot: resolveProjectDataRoot(entry.root, { echoHome: home, projectId }),
143
+ registeredAt: entry.registeredAt,
144
+ }));
145
+ }
146
+
147
+ /**
148
+ * @param {string} projectId
149
+ * @param {{ echoHome?: string }} [opts]
150
+ */
151
+ function findProjectById(projectId, opts = {}) {
152
+ const echoHome = opts.echoHome || resolveEchoHomePath(opts);
153
+ const registry = loadRegistry(echoHome);
154
+ const entry = registry.projects[projectId];
155
+ if (!entry) return null;
156
+ return {
157
+ projectId,
158
+ projectRoot: entry.root,
159
+ dataRoot: resolveProjectDataRoot(entry.root, { echoHome, projectId }),
160
+ };
161
+ }
162
+
163
+ module.exports = {
164
+ loadRegistry,
165
+ saveRegistry,
166
+ registerProject,
167
+ findProjectForPath,
168
+ findProjectById,
169
+ listProjects,
170
+ };