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,41 @@
1
+ // English messages for Echo CLI output
2
+ module.exports = {
3
+ "status.title": "Echo status",
4
+ "section.serve": "Serve",
5
+ "section.capture": "Capture",
6
+ "section.hook": "Claude hook",
7
+ "section.project": "Current project",
8
+ "section.data": "Data",
9
+ "section.legacy": "Legacy",
10
+ "section.mcp": "MCP",
11
+ "section.next": "Next",
12
+ "field.status": "Status",
13
+ "field.docs": "Docs",
14
+ "field.api": "API",
15
+ "field.pid": "PID",
16
+ "field.log": "Log",
17
+ "field.captureStatus": "Auto capture",
18
+ "field.hookStatus": "Claude hook",
19
+ "field.projectStatus": "Status",
20
+ "field.project": "Project",
21
+ "field.root": "Root",
22
+ "field.dataRoot": "Data",
23
+ "field.liveBuffers": "Live buffers",
24
+ "field.articles": "Articles",
25
+ "field.comments": "Comments",
26
+ "field.legacyBuffers": "Legacy buffers",
27
+ "field.legacyCandidates": "Current project candidates",
28
+ "field.config": "Config",
29
+ "field.tools": "Tools",
30
+ "value.running": "running",
31
+ "value.stopped": "stopped",
32
+ "value.on": "on",
33
+ "value.off": "off",
34
+ "value.installed": "installed",
35
+ "value.missing": "missing",
36
+ "value.registered": "registered",
37
+ "value.unregistered": "unregistered",
38
+ "value.available": "available",
39
+ "next.openDocs": "Open Docs",
40
+ "next.reviewLegacy": "Review legacy candidates in the web UI.",
41
+ };
@@ -0,0 +1,40 @@
1
+ module.exports = {
2
+ "status.title": "Echo 状态",
3
+ "section.serve": "服务",
4
+ "section.capture": "收集",
5
+ "section.hook": "Claude 钩子",
6
+ "section.project": "当前项目",
7
+ "section.data": "数据",
8
+ "section.legacy": "历史待处理",
9
+ "section.mcp": "AI 访问接口",
10
+ "section.next": "下一步",
11
+ "field.status": "状态",
12
+ "field.docs": "文档地址",
13
+ "field.api": "接口地址",
14
+ "field.pid": "进程号",
15
+ "field.log": "日志",
16
+ "field.captureStatus": "自动收集",
17
+ "field.hookStatus": "Claude 钩子",
18
+ "field.projectStatus": "状态",
19
+ "field.project": "项目",
20
+ "field.root": "根目录",
21
+ "field.dataRoot": "数据目录",
22
+ "field.liveBuffers": "实时会话缓存",
23
+ "field.articles": "文章",
24
+ "field.comments": "评论",
25
+ "field.legacyBuffers": "legacy 会话",
26
+ "field.legacyCandidates": "当前项目候选",
27
+ "field.config": "配置",
28
+ "field.tools": "工具",
29
+ "value.running": "运行中",
30
+ "value.stopped": "已停止",
31
+ "value.on": "开启",
32
+ "value.off": "关闭",
33
+ "value.installed": "已安装",
34
+ "value.missing": "未安装",
35
+ "value.registered": "已注册",
36
+ "value.unregistered": "未注册",
37
+ "value.available": "个可用",
38
+ "next.openDocs": "打开网页",
39
+ "next.reviewLegacy": "请在网页中确认是否迁移 legacy 会话。",
40
+ };
@@ -0,0 +1,87 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+
4
+ const MANIFEST_VERSION = 1;
5
+
6
+ function loadManifest(manifestPath) {
7
+ if (!fs.existsSync(manifestPath)) {
8
+ return { version: MANIFEST_VERSION, imports: {} };
9
+ }
10
+ let raw = "";
11
+ try {
12
+ raw = fs.readFileSync(manifestPath, "utf-8");
13
+ if (!raw.trim()) return { version: MANIFEST_VERSION, imports: {} };
14
+ const data = JSON.parse(raw);
15
+ return {
16
+ version: data.version || MANIFEST_VERSION,
17
+ imports: data.imports || {},
18
+ };
19
+ } catch (e) {
20
+ if (raw.trim() && raw.trim()[0] === "{") {
21
+ throw new Error(`corrupt manifest JSON: ${e.message}`);
22
+ }
23
+ return { version: MANIFEST_VERSION, imports: {} };
24
+ }
25
+ }
26
+
27
+ function saveManifest(manifest, manifestPath) {
28
+ const dir = path.dirname(manifestPath);
29
+ fs.mkdirSync(dir, { recursive: true });
30
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
31
+ }
32
+
33
+ function isImported(manifest, sessionId) {
34
+ if (!manifest || !sessionId) return false;
35
+ return !!(manifest.imports && manifest.imports[sessionId]);
36
+ }
37
+
38
+ function isModified(manifest, sessionId, fileHash) {
39
+ if (!manifest || !sessionId) return false;
40
+ const entry = manifest.imports && manifest.imports[sessionId];
41
+ if (!entry) return false;
42
+ return entry.fileHash !== fileHash;
43
+ }
44
+
45
+ function recordImport(manifest, sessionId, articleId, fileHash, metadata) {
46
+ const existing = manifest.imports && manifest.imports[sessionId];
47
+ if (existing && existing.fileHash === fileHash) {
48
+ return { success: false, reason: "duplicate", articleId: existing.articleId };
49
+ }
50
+
51
+ if (!manifest.imports) manifest.imports = {};
52
+
53
+ manifest.imports[sessionId] = {
54
+ articleId,
55
+ fileHash,
56
+ importedAt: new Date().toISOString(),
57
+ ...(metadata || {}),
58
+ };
59
+
60
+ return { success: true, articleId };
61
+ }
62
+
63
+ function validateManifest(data) {
64
+ if (!data || typeof data !== "object") {
65
+ return { valid: false, errors: ["manifest must be an object"] };
66
+ }
67
+ if (data.version !== MANIFEST_VERSION) {
68
+ return { valid: false, errors: [`unsupported version: ${data.version}`] };
69
+ }
70
+ if (!data.imports || typeof data.imports !== "object") {
71
+ return { valid: false, errors: ["imports must be an object"] };
72
+ }
73
+ for (const [sessionId, entry] of Object.entries(data.imports)) {
74
+ if (!entry.articleId) {
75
+ return { valid: false, errors: [`import ${sessionId}: missing articleId`] };
76
+ }
77
+ if (!entry.fileHash) {
78
+ return { valid: false, errors: [`import ${sessionId}: missing fileHash`] };
79
+ }
80
+ if (!entry.importedAt) {
81
+ return { valid: false, errors: [`import ${sessionId}: missing importedAt`] };
82
+ }
83
+ }
84
+ return { valid: true, errors: [] };
85
+ }
86
+
87
+ module.exports = { loadManifest, saveManifest, isImported, isModified, recordImport, validateManifest };
@@ -0,0 +1,272 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const crypto = require("crypto");
4
+ const { renderTurnMarker } = require("../../domain/echo-format");
5
+
6
+ // ---- noise detection ----
7
+
8
+ const NOISE_PATTERNS = [
9
+ /^Base directory for this skill:/,
10
+ /^<!-- AUTO-GENERATED/,
11
+ /^```bash\n_UPD=/,
12
+ /^<local-command-caveat>/,
13
+ /^<command-name>/,
14
+ /^<command-message>/,
15
+ /^<command-args>/,
16
+ /^<local-command-stdout>/,
17
+ ];
18
+
19
+ function isNoise(text) {
20
+ if (!text || !text.trim()) return true;
21
+ const t = text.trim();
22
+ if (t.length > 3000) return true;
23
+ for (const p of NOISE_PATTERNS) {
24
+ if (p.test(t)) return true;
25
+ }
26
+ if (t.startsWith("```bash") && t.includes("_UPD=")) return true;
27
+ return false;
28
+ }
29
+
30
+ // ---- helpers ----
31
+
32
+ function normalizeSpeaker(turn) {
33
+ const s = (turn.speaker || turn.role || "").toLowerCase();
34
+ if (s === "human" || s === "user") return "human";
35
+ if (s === "ai" || s === "assistant" || s === "model") return "ai";
36
+ return s || "unknown";
37
+ }
38
+
39
+ // ---- session reading ----
40
+
41
+ function readSessionTurns(filePath) {
42
+ const raw = fs.readFileSync(filePath, "utf-8");
43
+ const lines = raw.split("\n");
44
+ const turns = [];
45
+
46
+ for (const line of lines) {
47
+ if (!line.trim()) continue;
48
+ let entry;
49
+ try {
50
+ entry = JSON.parse(line);
51
+ } catch {
52
+ continue;
53
+ }
54
+
55
+ const msgType = entry.type;
56
+ const msg = entry.message || {};
57
+ const msgRole = msg.role || (msgType === "user" ? "user" : "assistant");
58
+
59
+ let content = "";
60
+ if (typeof msg.content === "string") {
61
+ content = msg.content;
62
+ } else if (Array.isArray(msg.content)) {
63
+ const parts = [];
64
+ for (const b of msg.content) {
65
+ if (b.type === "text" && b.text) {
66
+ parts.push(b.text);
67
+ } else if (b.type === "tool_use") {
68
+ parts.push(`[调用工具: ${b.name}]`);
69
+ }
70
+ }
71
+ content = parts.join(" ");
72
+ }
73
+
74
+ const toolCalls = [];
75
+ if (Array.isArray(msg.content)) {
76
+ for (const b of msg.content) {
77
+ if (b.type === "tool_use") {
78
+ toolCalls.push({ name: b.name, id: b.id });
79
+ }
80
+ }
81
+ }
82
+
83
+ const speaker = msgRole === "user" ? "human" : "ai";
84
+
85
+ turns.push({
86
+ speaker,
87
+ content: content.trim(),
88
+ toolCalls,
89
+ timestamp: entry.timestamp || null,
90
+ sessionId: entry.sessionId || null,
91
+ model: msg.model || null,
92
+ });
93
+ }
94
+
95
+ return turns;
96
+ }
97
+
98
+ // ---- session classification ----
99
+
100
+ function classifySession(turns) {
101
+ const humanTurns = turns.filter((t) => normalizeSpeaker(t) === "human" && !isNoise(t.content));
102
+ const meaningfulTurns = turns.filter((t) => !isNoise(t.content));
103
+
104
+ if (turns.length === 0) {
105
+ return { isMeaningful: false, reason: "empty session", turnCount: 0, userTurnCount: 0, estimatedQuality: "low" };
106
+ }
107
+
108
+ if (humanTurns.length === 0) {
109
+ return { isMeaningful: false, reason: "no user turns", turnCount: turns.length, userTurnCount: 0, estimatedQuality: "low" };
110
+ }
111
+
112
+ let quality = "low";
113
+ const avgContentLength = meaningfulTurns.reduce((sum, t) => sum + t.content.length, 0) / Math.max(meaningfulTurns.length, 1);
114
+
115
+ if (meaningfulTurns.length >= 6 && humanTurns.length >= 3) {
116
+ quality = "high";
117
+ } else if (meaningfulTurns.length >= 4 && humanTurns.length >= 2 && avgContentLength > 50) {
118
+ quality = "high";
119
+ } else if (meaningfulTurns.length >= 4 && humanTurns.length >= 2 && avgContentLength > 25) {
120
+ quality = "medium";
121
+ }
122
+
123
+ return {
124
+ isMeaningful: humanTurns.length >= 2 || meaningfulTurns.length >= 4,
125
+ reason: humanTurns.length < 2 ? `only ${humanTurns.length} user turns` : "ok",
126
+ turnCount: turns.length,
127
+ userTurnCount: humanTurns.length,
128
+ estimatedQuality: quality,
129
+ };
130
+ }
131
+
132
+ // ---- metadata extraction ----
133
+
134
+ function extractMetadata(turns) {
135
+ const meaningfulTurns = turns.filter((t) => !isNoise(t.content));
136
+ const humanTurns = meaningfulTurns.filter((t) => normalizeSpeaker(t) === "human");
137
+
138
+ let title = "";
139
+ if (humanTurns.length > 0) {
140
+ title = humanTurns[0].content.slice(0, 77);
141
+ if (humanTurns[0].content.length > 77) title += "...";
142
+ }
143
+
144
+ let date = "";
145
+ for (const t of turns) {
146
+ if (t.timestamp) {
147
+ date = t.timestamp;
148
+ break;
149
+ }
150
+ }
151
+
152
+ const seenSpeakers = new Set();
153
+ const participants = [];
154
+ for (const t of meaningfulTurns) {
155
+ const s = normalizeSpeaker(t);
156
+ if (!seenSpeakers.has(s)) {
157
+ seenSpeakers.add(s);
158
+ const p = { role: s };
159
+ if (s === "ai" && t.model) p.model = t.model;
160
+ participants.push(p);
161
+ }
162
+ }
163
+
164
+ let model = "";
165
+ for (const t of meaningfulTurns) {
166
+ if (normalizeSpeaker(t) === "ai" && t.model) {
167
+ model = t.model;
168
+ break;
169
+ }
170
+ }
171
+
172
+ return { title: title || "Untitled", date, participants, model };
173
+ }
174
+
175
+ // ---- article generation ----
176
+
177
+ function toEchoArticle(turns, metadata, opts = {}) {
178
+ const { sessionId, alias } = opts;
179
+ const articleId = sessionId
180
+ ? `session-${sessionId.slice(0, 8)}`
181
+ : `session-${crypto.randomUUID().slice(0, 8)}`;
182
+
183
+ const meaningfulTurns = turns.filter((t) => !isNoise(t.content));
184
+ const sourceHash = crypto.createHash("sha256").update(JSON.stringify(turns)).digest("hex");
185
+
186
+ // Build YAML frontmatter
187
+ const lines = [];
188
+ lines.push("---");
189
+ lines.push(`id: ${articleId}`);
190
+ lines.push(`title: "${(metadata.title || "Untitled").replace(/"/g, '\\"')}"`);
191
+ if (alias) lines.push(`alias: "${alias.replace(/"/g, '\\"')}"`);
192
+ const dateStr = metadata.date ? metadata.date.slice(0, 10) : new Date().toISOString().slice(0, 10);
193
+ lines.push(`created_at: ${dateStr}`);
194
+ lines.push("tags: []");
195
+ lines.push(`summary: "${(metadata.title || "").replace(/"/g, '\\"')}"`);
196
+
197
+ if (metadata.participants && metadata.participants.length > 0) {
198
+ lines.push("participants:");
199
+ for (const p of metadata.participants) {
200
+ lines.push(` - id: ${p.id || p.role}`);
201
+ lines.push(` role: ${p.role}`);
202
+ if (p.model) lines.push(` model: ${p.model}`);
203
+ }
204
+ }
205
+
206
+ if (metadata.model) lines.push(`ai_model: ${metadata.model}`);
207
+
208
+ lines.push("source:");
209
+ lines.push(` session_id: "${sessionId || ""}"`);
210
+ lines.push(` source_file_hash: "${sourceHash}"`);
211
+ lines.push(` imported_at: "${new Date().toISOString()}"`);
212
+
213
+ if (opts.project) lines.push(`project: ${opts.project}`);
214
+
215
+ lines.push("---");
216
+ lines.push("");
217
+
218
+ // Body
219
+ lines.push(`# ${metadata.title || "Untitled"}`);
220
+ lines.push("");
221
+
222
+ let turnNum = 0;
223
+ for (const turn of meaningfulTurns) {
224
+ turnNum++;
225
+ const speaker = normalizeSpeaker(turn);
226
+ const speakerLabel = speaker === "human" ? (opts.userSpeaker || "human") : (opts.aiSpeaker || "ai");
227
+ const turnId = `t${String(turnNum).padStart(2, "0")}`;
228
+ lines.push(renderTurnMarker(turnId, speakerLabel));
229
+ lines.push("");
230
+ lines.push(turn.content);
231
+ lines.push("");
232
+ }
233
+
234
+ // Comment section markers
235
+ lines.push("<!-- ECHO_COMMENTS_START -->");
236
+ lines.push("<!-- ECHO_COMMENTS_END -->");
237
+
238
+ return lines.join("\n") + "\n";
239
+ }
240
+
241
+ // ---- project scanning ----
242
+
243
+ function scanProjectDir(dirPath) {
244
+ if (!fs.existsSync(dirPath)) return [];
245
+
246
+ const entries = fs.readdirSync(dirPath);
247
+ const sessions = [];
248
+
249
+ for (const entry of entries) {
250
+ if (!entry.endsWith(".jsonl")) continue;
251
+ const filePath = path.join(dirPath, entry);
252
+ let stat;
253
+ try { stat = fs.statSync(filePath); } catch { continue; }
254
+ if (!stat.isFile()) continue;
255
+
256
+ const sessionId = entry.replace(/\.jsonl$/, "");
257
+ const content = fs.readFileSync(filePath, "utf-8");
258
+ const jsonlLines = content.trim().split("\n");
259
+ const firstLine = jsonlLines[0] ? (() => { try { return JSON.parse(jsonlLines[0]); } catch { return null; } })() : null;
260
+ const lastLine = jsonlLines[jsonlLines.length - 1] ? (() => { try { return JSON.parse(jsonlLines[jsonlLines.length - 1]); } catch { return null; } })() : null;
261
+
262
+ const turnCount = jsonlLines.length;
263
+ const firstTurn = firstLine ? firstLine.timestamp || "" : "";
264
+ const lastTurn = lastLine ? lastLine.timestamp || "" : "";
265
+
266
+ sessions.push({ sessionId, filePath, turnCount, firstTurn, lastTurn });
267
+ }
268
+
269
+ return sessions;
270
+ }
271
+
272
+ module.exports = { scanProjectDir, readSessionTurns, classifySession, extractMetadata, toEchoArticle, isNoise };
@@ -0,0 +1,128 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const crypto = require("crypto");
4
+
5
+ const DEFAULT_EXCLUSIONS = ["claude-mem-observer-sessions", "private-tmp"];
6
+
7
+ function scanClaudeProjects(claudeProjectsDir, opts = {}) {
8
+ const excludeList = opts.excludeDirs || opts.exclude || DEFAULT_EXCLUSIONS;
9
+
10
+ if (!fs.existsSync(claudeProjectsDir)) return [];
11
+
12
+ const entries = fs.readdirSync(claudeProjectsDir);
13
+ const projects = [];
14
+
15
+ for (const entry of entries) {
16
+ if (entry.startsWith(".")) continue;
17
+ if (excludeList.some((p) => entry.includes(p))) continue;
18
+
19
+ const fullPath = path.join(claudeProjectsDir, entry);
20
+ let stat;
21
+ try {
22
+ stat = fs.statSync(fullPath);
23
+ } catch {
24
+ continue;
25
+ }
26
+ if (!stat.isDirectory()) continue;
27
+
28
+ const fileNames = fs.readdirSync(fullPath).filter((f) => f.endsWith(".jsonl"));
29
+ if (fileNames.length === 0) continue;
30
+
31
+ const jsonlFiles = fileNames.map((f) => ({
32
+ sessionId: f.replace(/\.jsonl$/, ""),
33
+ fileName: f,
34
+ absPath: path.join(fullPath, f),
35
+ }));
36
+
37
+ const decoded = decodeProjectPath(entry);
38
+
39
+ projects.push({
40
+ dirName: entry,
41
+ absPath: fullPath,
42
+ sessionCount: jsonlFiles.length,
43
+ jsonlFiles,
44
+ decodedPath: decoded.value,
45
+ pathConfidence: decoded.confidence,
46
+ });
47
+ }
48
+
49
+ return projects;
50
+ }
51
+
52
+ function decodeProjectPath(dirName) {
53
+ if (!dirName || dirName.trim() === "") {
54
+ return { value: "", confidence: "inferred" };
55
+ }
56
+
57
+ if (!dirName.startsWith("-")) {
58
+ return { value: dirName, confidence: "inferred" };
59
+ }
60
+
61
+ const pathStr = "/" + dirName.slice(1).replace(/-/g, "/");
62
+
63
+ return { value: pathStr, confidence: "inferred" };
64
+ }
65
+
66
+ function buildImportPlan(projects, manifest, opts = {}) {
67
+ const maxSessionsPerProject = opts.maxSessionsPerProject || Infinity;
68
+ const plan = {
69
+ new: [],
70
+ updated: [],
71
+ skipped: [],
72
+ summary: { total: 0, newCount: 0, updatedCount: 0, skippedCount: 0 },
73
+ };
74
+
75
+ const computeHash = opts.getFileHash || ((absPath) => {
76
+ try {
77
+ const content = fs.readFileSync(absPath, "utf-8");
78
+ return crypto.createHash("sha256").update(content).digest("hex");
79
+ } catch {
80
+ return crypto.createHash("sha256").update(absPath).digest("hex");
81
+ }
82
+ });
83
+
84
+ for (const project of projects) {
85
+ const jsonlFiles = project.jsonlFiles || [];
86
+ let projectSessionCount = 0;
87
+
88
+ for (const jf of jsonlFiles) {
89
+ if (projectSessionCount >= maxSessionsPerProject) break;
90
+
91
+ const sessionId = jf.sessionId;
92
+ const filePath = jf.absPath;
93
+ const fileHash = computeHash(filePath);
94
+
95
+ const entry = {
96
+ sessionId,
97
+ filePath,
98
+ fileHash,
99
+ projectDir: project.dirName,
100
+ decodedPath: project.decodedPath,
101
+ pathConfidence: project.pathConfidence,
102
+ turnCount: 0,
103
+ };
104
+
105
+ if (manifest.imports && manifest.imports[sessionId]) {
106
+ const existing = manifest.imports[sessionId];
107
+ if (existing.fileHash === fileHash) {
108
+ plan.skipped.push({ ...entry, articleId: existing.articleId });
109
+ } else {
110
+ plan.updated.push({ ...entry, previousArticleId: existing.articleId });
111
+ }
112
+ } else {
113
+ plan.new.push(entry);
114
+ }
115
+
116
+ projectSessionCount++;
117
+ }
118
+ }
119
+
120
+ plan.summary.total = plan.new.length + plan.updated.length + plan.skipped.length;
121
+ plan.summary.newCount = plan.new.length;
122
+ plan.summary.updatedCount = plan.updated.length;
123
+ plan.summary.skippedCount = plan.skipped.length;
124
+
125
+ return plan;
126
+ }
127
+
128
+ module.exports = { scanClaudeProjects, decodeProjectPath, buildImportPlan };
@@ -0,0 +1,36 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const { getConfig, resolveWorkspace } = require("./workspace");
4
+
5
+ function isCaptureEnabled(env = process.env) {
6
+ if (env.ECHO_CAPTURE === "off") return false;
7
+ if (env.ECHO_CAPTURE === "on") return true;
8
+
9
+ const config = getConfig();
10
+ if (config.capture_enabled === false) return false;
11
+
12
+ return true;
13
+ }
14
+
15
+ function setCaptureEnabled(value) {
16
+ const ws = resolveWorkspace();
17
+ const configPath = path.join(ws, "echo.json");
18
+ const config = getConfig();
19
+ config.capture_enabled = value;
20
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
21
+ return { capture_enabled: value, configPath };
22
+ }
23
+
24
+ function getSpeakers(env = process.env) {
25
+ return {
26
+ user: env.ECHO_USER_SPEAKER || "vincent",
27
+ ai: env.ECHO_AI_SPEAKER || "ai",
28
+ };
29
+ }
30
+
31
+ function getAuthor() {
32
+ const config = getConfig();
33
+ return config.author || "vincent";
34
+ }
35
+
36
+ module.exports = { isCaptureEnabled, setCaptureEnabled, getSpeakers, getAuthor };
@@ -0,0 +1,44 @@
1
+ const path = require("path");
2
+ const {
3
+ resolveEchoHomePath,
4
+ } = require("./workspace");
5
+
6
+ function resolveDataDirs(opts = {}) {
7
+ const cwd = opts.cwd || process.cwd();
8
+ const echoHome = opts.echoHome || resolveEchoHomePath(opts);
9
+
10
+ let projectRoot = echoHome;
11
+ let projectId = null;
12
+
13
+ if (opts.registry) {
14
+ const project = opts.registry.findProjectForPath(cwd, { echoHome });
15
+ if (project) {
16
+ projectRoot = project.dataRoot;
17
+ projectId = project.projectId;
18
+ }
19
+ } else {
20
+ try {
21
+ const { findProjectForPath } = require("../usecases/project-registry");
22
+ const project = findProjectForPath(cwd, { echoHome });
23
+ if (project) {
24
+ projectRoot = project.dataRoot;
25
+ projectId = project.projectId;
26
+ }
27
+ } catch (err) {
28
+ // Only swallow MODULE_NOT_FOUND — registry may not be installed.
29
+ // Registry corruption or parse errors must surface.
30
+ if (err.code !== "MODULE_NOT_FOUND") throw err;
31
+ }
32
+ }
33
+
34
+ return {
35
+ articlesDir: path.join(projectRoot, "articles"),
36
+ commentsDir: path.join(projectRoot, "comments"),
37
+ bufferDir: path.join(projectRoot, "session-buffer"),
38
+ indexDir: path.join(projectRoot, "index"),
39
+ projectId,
40
+ projectRoot,
41
+ };
42
+ }
43
+
44
+ module.exports = { resolveDataDirs };