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,80 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const os = require("os");
4
+ const crypto = require("crypto");
5
+ const { resolveEchoHomePath } = require("../infra/workspace");
6
+ const { findProjectById } = require("./project-registry");
7
+ const { loadManifest } = require("../import/manifest");
8
+ const { scanProjectDir } = require("../import/providers/claude-code");
9
+
10
+ function claudeProjectDirName(projectPath) {
11
+ return "-" + path.resolve(projectPath).slice(1).split(path.sep).join("-");
12
+ }
13
+
14
+ function discoverClaudeImportCandidates(projectId, opts = {}) {
15
+ const echoHome = opts.echoHome || resolveEchoHomePath(opts);
16
+ const project = findProjectById(projectId, { echoHome });
17
+ if (!project) throw new Error(`Project "${projectId}" not found.`);
18
+
19
+ const claudeProjectsDir = opts.claudeProjectsDir || path.join(os.homedir(), ".claude", "projects");
20
+ const dirName = claudeProjectDirName(project.projectRoot);
21
+ const projectDir = path.join(claudeProjectsDir, dirName);
22
+
23
+ if (!fs.existsSync(projectDir)) {
24
+ return {
25
+ projectId,
26
+ provider: "claude-code",
27
+ projectDir,
28
+ summary: { total: 0, new: 0, updated: 0, skipped: 0 },
29
+ candidates: [],
30
+ };
31
+ }
32
+
33
+ const sessions = scanProjectDir(projectDir);
34
+ const manifestPath = path.join(echoHome, "import-manifest.json");
35
+ const manifest = loadManifest(manifestPath);
36
+
37
+ const candidates = [];
38
+ const summary = { total: 0, new: 0, updated: 0, skipped: 0 };
39
+
40
+ for (const s of sessions) {
41
+ summary.total++;
42
+ const fileHash = crypto.createHash("sha256")
43
+ .update(fs.readFileSync(s.filePath, "utf-8"))
44
+ .digest("hex");
45
+
46
+ const existing = manifest.imports && manifest.imports[s.sessionId];
47
+ let status = "new";
48
+ let articleId = `session-${s.sessionId.slice(0, 8)}`;
49
+
50
+ if (existing) {
51
+ if (existing.fileHash === fileHash) {
52
+ status = "skipped";
53
+ articleId = existing.articleId;
54
+ } else {
55
+ status = "updated";
56
+ articleId = existing.articleId;
57
+ }
58
+ }
59
+
60
+ candidates.push({
61
+ sessionId: s.sessionId,
62
+ filePath: s.filePath,
63
+ status,
64
+ articleId,
65
+ turnCount: s.turnCount,
66
+ mtime: (() => {
67
+ try { return fs.statSync(s.filePath).mtime.toISOString(); } catch (_) { return ""; }
68
+ })(),
69
+ fileHash,
70
+ });
71
+
72
+ if (status === "new") summary.new++;
73
+ else if (status === "updated") summary.updated++;
74
+ else summary.skipped++;
75
+ }
76
+
77
+ return { projectId, provider: "claude-code", projectDir, summary, candidates };
78
+ }
79
+
80
+ module.exports = { discoverClaudeImportCandidates, claudeProjectDirName };
@@ -0,0 +1,89 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+
4
+ const { resolveEchoHomePath } = require("../infra/workspace");
5
+ const { findProjectById } = require("./project-registry");
6
+ const { discoverClaudeImportCandidates } = require("./discover-claude-imports");
7
+ const provider = require("../import/providers/claude-code");
8
+ const mf = require("../import/manifest");
9
+
10
+ function importClaudeProject(projectId, opts = {}) {
11
+ const echoHome = opts.echoHome || resolveEchoHomePath(opts);
12
+ const project = findProjectById(projectId, { echoHome });
13
+ if (!project) throw new Error(`Project "${projectId}" not found.`);
14
+
15
+ const discovery = discoverClaudeImportCandidates(projectId, {
16
+ echoHome,
17
+ claudeProjectsDir: opts.claudeProjectsDir,
18
+ });
19
+ const selected = opts.sessionIds ? new Set(opts.sessionIds) : null;
20
+ const candidates = discovery.candidates.filter((entry) => {
21
+ if (entry.status === "skipped") return false;
22
+ return !selected || selected.has(entry.sessionId);
23
+ });
24
+
25
+ const articlesDir = path.join(project.dataRoot, "articles");
26
+ const manifestPath = path.join(echoHome, "import-manifest.json");
27
+ const manifest = mf.loadManifest(manifestPath);
28
+ const result = {
29
+ ok: true,
30
+ projectId,
31
+ provider: "claude-code",
32
+ projectDir: discovery.projectDir,
33
+ total: discovery.summary.total,
34
+ candidates: candidates.length,
35
+ imported: 0,
36
+ skipped: 0,
37
+ lowQuality: 0,
38
+ failed: 0,
39
+ articlesDir,
40
+ importedArticles: [],
41
+ };
42
+
43
+ for (const entry of candidates) {
44
+ const articleId = `session-${entry.sessionId.slice(0, 8)}`;
45
+ const articlePath = path.join(articlesDir, `${articleId}.md`);
46
+
47
+ if (fs.existsSync(articlePath)) {
48
+ if (entry.status === "updated") {
49
+ mf.recordImport(manifest, entry.sessionId, articleId, entry.fileHash, { provider: "claude-code" });
50
+ }
51
+ result.skipped++;
52
+ continue;
53
+ }
54
+
55
+ try {
56
+ const turns = provider.readSessionTurns(entry.filePath);
57
+ const classification = provider.classifySession(turns);
58
+ if (!classification.isMeaningful) {
59
+ mf.recordImport(manifest, entry.sessionId, `skipped-${entry.sessionId.slice(0, 8)}`, entry.fileHash, {
60
+ provider: "claude-code",
61
+ skipped: true,
62
+ reason: classification.reason,
63
+ });
64
+ result.skipped++;
65
+ result.lowQuality++;
66
+ continue;
67
+ }
68
+
69
+ const metadata = provider.extractMetadata(turns);
70
+ const markdown = provider.toEchoArticle(turns, metadata, {
71
+ sessionId: entry.sessionId,
72
+ project: projectId,
73
+ });
74
+
75
+ fs.mkdirSync(path.dirname(articlePath), { recursive: true });
76
+ fs.writeFileSync(articlePath, markdown);
77
+ mf.recordImport(manifest, entry.sessionId, articleId, entry.fileHash, { provider: "claude-code" });
78
+ result.imported++;
79
+ result.importedArticles.push({ articleId, articlePath });
80
+ } catch (_) {
81
+ result.failed++;
82
+ }
83
+ }
84
+
85
+ mf.saveManifest(manifest, manifestPath);
86
+ return result;
87
+ }
88
+
89
+ module.exports = { importClaudeProject };
@@ -0,0 +1,52 @@
1
+ const path = require("path");
2
+ const fs = require("fs");
3
+ const { resolveWorkspace, ensureDir } = require("../infra/workspace");
4
+
5
+ function initWorkspace() {
6
+ const ws = resolveWorkspace();
7
+ const configPath = path.join(ws, "echo.json");
8
+
9
+ const subdirs = ["articles", "comments", "session-buffer", "index"];
10
+ const created = [];
11
+ const skipped = [];
12
+
13
+ // Create workspace root
14
+ ensureDir(ws);
15
+
16
+ // Create subdirectories
17
+ for (const d of subdirs) {
18
+ const full = path.join(ws, d);
19
+ if (!fs.existsSync(full)) {
20
+ ensureDir(full);
21
+ created.push(d);
22
+ } else {
23
+ skipped.push(d);
24
+ }
25
+ }
26
+
27
+ // Write echo.json if missing; if malformed, warn and overwrite
28
+ let configAction = "skipped";
29
+ if (fs.existsSync(configPath)) {
30
+ try {
31
+ JSON.parse(fs.readFileSync(configPath, "utf-8"));
32
+ } catch (_) {
33
+ configAction = "replaced";
34
+ writeDefaultConfig(configPath, ws);
35
+ }
36
+ } else {
37
+ configAction = "created";
38
+ writeDefaultConfig(configPath, ws);
39
+ }
40
+
41
+ return { workspace: ws, created, skipped, configAction };
42
+ }
43
+
44
+ function writeDefaultConfig(configPath, ws) {
45
+ const relative = ws.startsWith(process.env.HOME || "") ? ws.replace(process.env.HOME || "", "~") : ws;
46
+ fs.writeFileSync(configPath, JSON.stringify({
47
+ workspace: relative,
48
+ capture_enabled: true,
49
+ }, null, 2) + "\n");
50
+ }
51
+
52
+ module.exports = { initWorkspace };
@@ -0,0 +1,139 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const os = require("os");
4
+ const { commandFor, isKnownCliCommand } = require("../cli/names");
5
+
6
+ const SETTINGS_PATH = path.join(os.homedir(), ".claude", "settings.json");
7
+
8
+ function hookEntry(command) {
9
+ return { matcher: "", hooks: [{ type: "command", command }] };
10
+ }
11
+
12
+ const DESIRED_HOOKS = {
13
+ UserPromptSubmit: [hookEntry(commandFor(["hook", "capture"]))],
14
+ Stop: [hookEntry(commandFor(["hook", "capture"]))],
15
+ StopFailure: [hookEntry(commandFor(["hook", "capture"]))],
16
+ SessionStart: [hookEntry(commandFor(["hook", "status"]))],
17
+ };
18
+
19
+ function readSettings() {
20
+ if (!fs.existsSync(SETTINGS_PATH)) return {};
21
+ try {
22
+ return JSON.parse(fs.readFileSync(SETTINGS_PATH, "utf-8"));
23
+ } catch (_) {
24
+ return {};
25
+ }
26
+ }
27
+
28
+ function extractCommand(entry, matches = () => true) {
29
+ // New format: { matcher: "", hooks: [{ type: "command", command: "..." }] }
30
+ if (Array.isArray(entry.hooks)) {
31
+ const hook = entry.hooks.find(
32
+ (h) => typeof h.command === "string" && matches(h.command)
33
+ );
34
+ if (hook) return hook.command;
35
+ }
36
+ // Old format: { command: "..." }
37
+ if (typeof entry.command === "string" && matches(entry.command)) return entry.command;
38
+ return null;
39
+ }
40
+
41
+ function isOldCommandEntry(entry) {
42
+ return typeof entry.command === "string" && !Array.isArray(entry.hooks);
43
+ }
44
+
45
+ // Returns true when two CLI commands differ only in binary name (echoctl vs echo-mcp)
46
+ // and share the same subcommand args, e.g. "echo-mcp hook capture" ~ "echoctl hook capture".
47
+ function isSameCliSubcommand(existingCmd, desiredCmd) {
48
+ if (existingCmd === desiredCmd) return true;
49
+ if (!isKnownCliCommand(existingCmd)) return false;
50
+ const existingArgs = existingCmd.split(" ").slice(1).join(" ");
51
+ const desiredArgs = desiredCmd.split(" ").slice(1).join(" ");
52
+ return existingArgs === desiredArgs;
53
+ }
54
+
55
+ function installClaudeHook({ write }) {
56
+ const current = readSettings();
57
+ const hooks = current.hooks || {};
58
+
59
+ const toAdd = [];
60
+ const alreadyInstalled = [];
61
+ const legacy = [];
62
+
63
+ // Detect legacy .sh commands AND already-installed CLI hooks across all hook events
64
+ for (const [event, entries] of Object.entries(hooks)) {
65
+ if (!Array.isArray(entries)) continue;
66
+ for (const entry of entries) {
67
+ const cmd = extractCommand(entry, (command) => command.includes(".sh"));
68
+ if (cmd && cmd.includes(".sh")) {
69
+ legacy.push({ event, command: cmd });
70
+ }
71
+ // Also detect already-installed echoctl/echo-mcp hooks via isKnownCliCommand
72
+ const cliCmd = extractCommand(entry, (command) => isKnownCliCommand(command));
73
+ if (cliCmd && isKnownCliCommand(cliCmd)) {
74
+ // Known CLI hook found — will be recognised by upsert logic below
75
+ }
76
+ }
77
+ }
78
+
79
+ // Upsert desired hooks
80
+ for (const [event, desiredEntries] of Object.entries(DESIRED_HOOKS)) {
81
+ const raw = hooks[event];
82
+ const existing = Array.isArray(raw) ? raw : [];
83
+
84
+ if (!hooks[event]) hooks[event] = [];
85
+ else if (!Array.isArray(hooks[event])) hooks[event] = [];
86
+
87
+ for (const desired of desiredEntries) {
88
+ const desiredCmd = desired.hooks[0].command;
89
+ const found = existing.find(
90
+ (e) => {
91
+ const cmd = extractCommand(e, (command) => command === desiredCmd);
92
+ if (cmd === desiredCmd) return true;
93
+ const cmdAny = extractCommand(e, (command) => isKnownCliCommand(command));
94
+ if (cmdAny && isKnownCliCommand(cmdAny) && isSameCliSubcommand(cmdAny, desiredCmd)) return true;
95
+ return false;
96
+ }
97
+ );
98
+ if (found) {
99
+ alreadyInstalled.push({ event, command: desiredCmd });
100
+ if (isOldCommandEntry(found)) {
101
+ Object.assign(found, desired);
102
+ }
103
+ } else {
104
+ toAdd.push({ event, command: desiredCmd });
105
+ }
106
+ }
107
+
108
+ if (!hooks[event]) hooks[event] = [];
109
+ for (const desired of desiredEntries) {
110
+ const desiredCmd = desired.hooks[0].command;
111
+ const found = hooks[event].find(
112
+ (e) => {
113
+ const cmd = extractCommand(e, (command) => command === desiredCmd);
114
+ if (cmd === desiredCmd) return true;
115
+ const cmdAny = extractCommand(e, (command) => isKnownCliCommand(command));
116
+ if (cmdAny && isKnownCliCommand(cmdAny) && isSameCliSubcommand(cmdAny, desiredCmd)) return true;
117
+ return false;
118
+ }
119
+ );
120
+ if (!found) {
121
+ hooks[event].push(desired);
122
+ }
123
+ }
124
+ }
125
+
126
+ if (write) {
127
+ current.hooks = hooks;
128
+ const settingsDir = path.dirname(SETTINGS_PATH);
129
+ if (!fs.existsSync(settingsDir)) fs.mkdirSync(settingsDir, { recursive: true });
130
+ const tmp = SETTINGS_PATH + ".tmp";
131
+ fs.writeFileSync(tmp, JSON.stringify(current, null, 2) + "\n");
132
+ fs.renameSync(tmp, SETTINGS_PATH);
133
+ return { written: true, toAdd, alreadyInstalled, legacy };
134
+ }
135
+
136
+ return { written: false, toAdd, alreadyInstalled, legacy };
137
+ }
138
+
139
+ module.exports = { installClaudeHook, DESIRED_HOOKS, SETTINGS_PATH };
@@ -0,0 +1,134 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const { resolveEchoHomePath } = require("../infra/workspace");
4
+ const { findProjectById, listProjects } = require("./project-registry");
5
+
6
+ function defaultLegacyBufferDir(echoHome) {
7
+ return path.join(echoHome, "session-buffer");
8
+ }
9
+
10
+ function listMarkdownFiles(dir) {
11
+ try {
12
+ return fs.readdirSync(dir)
13
+ .filter((name) => name.endsWith(".md"))
14
+ .sort();
15
+ } catch (err) {
16
+ if (err.code === "ENOENT") return [];
17
+ throw err;
18
+ }
19
+ }
20
+
21
+ function readPendingPayload(pendingDir, sessionId) {
22
+ const pendingFile = path.join(pendingDir, `${sessionId}.json`);
23
+ try {
24
+ return JSON.parse(fs.readFileSync(pendingFile, "utf-8"));
25
+ } catch (_) {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ function claudeProjectDirName(projectPath) {
31
+ return "-" + path.resolve(projectPath).slice(1).split(path.sep).join("-");
32
+ }
33
+
34
+ function resolveProjectForTranscriptPath(transcriptPath, projects) {
35
+ if (!transcriptPath) return null;
36
+ for (const p of projects) {
37
+ if (transcriptPath.includes(claudeProjectDirName(p.root))) return p;
38
+ }
39
+ return null;
40
+ }
41
+
42
+ function resolveProjectForCwd(cwd, projects) {
43
+ if (!cwd) return null;
44
+ const resolved = path.resolve(cwd);
45
+ for (const p of projects) {
46
+ const root = path.resolve(p.root);
47
+ if (resolved === root || resolved.startsWith(root + path.sep)) return p;
48
+ }
49
+ return null;
50
+ }
51
+
52
+ function scanLegacyCandidates(projectId, opts = {}) {
53
+ const echoHome = opts.echoHome || resolveEchoHomePath(opts);
54
+ const sourceDir = opts.from || defaultLegacyBufferDir(echoHome);
55
+ const project = findProjectById(projectId, { echoHome });
56
+ if (!project) throw new Error(`Project "${projectId}" not found.`);
57
+
58
+ const projects = listProjects(echoHome);
59
+ const pendingDir = path.join(sourceDir, "pending");
60
+ const markdownNames = listMarkdownFiles(sourceDir);
61
+ const candidates = [];
62
+
63
+ // First pass: match by pending JSON evidence
64
+ let hasAnyHighConfidenceMatch = false;
65
+ for (const name of markdownNames) {
66
+ const sourcePath = path.join(sourceDir, name);
67
+ const sessionId = path.basename(name, ".md");
68
+ let stat;
69
+ try { stat = fs.statSync(sourcePath); } catch (_) { continue; }
70
+
71
+ const raw = fs.readFileSync(sourcePath, "utf-8");
72
+ const turnCount = (raw.match(/<!-- turn:/g) || []).length;
73
+ if (turnCount === 0) continue;
74
+
75
+ const pending = readPendingPayload(pendingDir, sessionId);
76
+ let matched = null;
77
+ let evidence = null;
78
+ let confidence = "low";
79
+
80
+ if (pending && pending.transcript_path) {
81
+ matched = resolveProjectForTranscriptPath(pending.transcript_path, projects);
82
+ if (matched) {
83
+ evidence = { kind: "transcript_path", projectRoot: matched.root };
84
+ confidence = "high";
85
+ }
86
+ }
87
+ if (!matched && pending && pending.cwd) {
88
+ matched = resolveProjectForCwd(pending.cwd, projects);
89
+ if (matched) {
90
+ evidence = { kind: "cwd", projectRoot: matched.root };
91
+ confidence = "high";
92
+ }
93
+ }
94
+ if (!matched || matched.projectId !== projectId) continue;
95
+
96
+ hasAnyHighConfidenceMatch = true;
97
+ candidates.push({
98
+ sessionId, fileName: name, sourcePath,
99
+ turnCount, mtime: stat.mtime.toISOString(),
100
+ confidence, evidence,
101
+ });
102
+ }
103
+
104
+ // Second pass: completed sessions (pending deleted) inherit medium confidence
105
+ // if at least one other session in the same legacy buffer matched this project
106
+ if (hasAnyHighConfidenceMatch) {
107
+ const alreadyMatched = new Set(candidates.map((c) => c.sessionId));
108
+ for (const name of markdownNames) {
109
+ const sessionId = path.basename(name, ".md");
110
+ if (alreadyMatched.has(sessionId)) continue;
111
+
112
+ const sourcePath = path.join(sourceDir, name);
113
+ let stat;
114
+ try { stat = fs.statSync(sourcePath); } catch (_) { continue; }
115
+ const raw = fs.readFileSync(sourcePath, "utf-8");
116
+ const turnCount = (raw.match(/<!-- turn:/g) || []).length;
117
+ if (turnCount === 0) continue;
118
+
119
+ const pending = readPendingPayload(pendingDir, sessionId);
120
+ if (pending) continue; // has pending but didn't match above — skip
121
+
122
+ candidates.push({
123
+ sessionId, fileName: name, sourcePath,
124
+ turnCount, mtime: stat.mtime.toISOString(),
125
+ confidence: "medium",
126
+ evidence: { kind: "shared_buffer", projectRoot: project.root },
127
+ });
128
+ }
129
+ }
130
+
131
+ return { projectId, sourceDir, candidates };
132
+ }
133
+
134
+ module.exports = { scanLegacyCandidates, defaultLegacyBufferDir };
@@ -0,0 +1,109 @@
1
+ const crypto = require("crypto");
2
+ const fs = require("fs");
3
+ const path = require("path");
4
+
5
+ function countTurns(raw) {
6
+ return (String(raw || "").match(/<!-- turn:/g) || []).length;
7
+ }
8
+
9
+ function hashContent(raw) {
10
+ return crypto.createHash("sha256").update(String(raw || "")).digest("hex");
11
+ }
12
+
13
+ function liveStatePath(dataRoot) {
14
+ return path.join(dataRoot, "index", "live-state.json");
15
+ }
16
+
17
+ function readLiveState(dataRoot) {
18
+ try {
19
+ return JSON.parse(fs.readFileSync(liveStatePath(dataRoot), "utf-8"));
20
+ } catch (_) {
21
+ return { sessions: {} };
22
+ }
23
+ }
24
+
25
+ function saveLiveState(dataRoot, state) {
26
+ const filePath = liveStatePath(dataRoot);
27
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
28
+ fs.writeFileSync(filePath, JSON.stringify(state, null, 2));
29
+ }
30
+
31
+ function sessionStateFromFile(sessionFile, opts = {}) {
32
+ const sessionId = opts.sessionId || path.basename(sessionFile, ".md");
33
+ if (!fs.existsSync(sessionFile)) {
34
+ return {
35
+ ok: true,
36
+ exists: false,
37
+ projectId: opts.projectId || null,
38
+ sessionId,
39
+ turnCount: 0,
40
+ hash: null,
41
+ updatedAt: null,
42
+ };
43
+ }
44
+
45
+ const raw = fs.readFileSync(sessionFile, "utf-8");
46
+ const stat = fs.statSync(sessionFile);
47
+ return {
48
+ ok: true,
49
+ exists: true,
50
+ projectId: opts.projectId || null,
51
+ sessionId,
52
+ turnCount: countTurns(raw),
53
+ hash: hashContent(raw),
54
+ updatedAt: stat.mtime.toISOString(),
55
+ };
56
+ }
57
+
58
+ function writeLiveSessionState(dataRoot, sessionFile, opts = {}) {
59
+ const next = sessionStateFromFile(sessionFile, opts);
60
+ if (!next.exists) return next;
61
+
62
+ const state = readLiveState(dataRoot);
63
+ state.sessions = state.sessions || {};
64
+ state.sessions[next.sessionId] = {
65
+ projectId: next.projectId,
66
+ turnCount: next.turnCount,
67
+ hash: next.hash,
68
+ updatedAt: new Date().toISOString(),
69
+ sourcePath: sessionFile,
70
+ };
71
+ saveLiveState(dataRoot, state);
72
+
73
+ return {
74
+ ...next,
75
+ updatedAt: state.sessions[next.sessionId].updatedAt,
76
+ };
77
+ }
78
+
79
+ function getLiveSessionState(dirs, sessionId) {
80
+ const dataRoot = dirs.dataRoot || dirs.projectRoot || path.dirname(dirs.articlesDir);
81
+ const bufferDir = dirs.bufferDir || path.join(dataRoot, "session-buffer");
82
+ const sessionFile = path.join(bufferDir, `${sessionId}.md`);
83
+ const computed = sessionStateFromFile(sessionFile, {
84
+ projectId: dirs.projectId || null,
85
+ sessionId,
86
+ });
87
+
88
+ if (!computed.exists) return computed;
89
+
90
+ const saved = readLiveState(dataRoot).sessions?.[sessionId];
91
+ if (saved && saved.hash === computed.hash) {
92
+ return {
93
+ ...computed,
94
+ updatedAt: saved.updatedAt || computed.updatedAt,
95
+ };
96
+ }
97
+ return computed;
98
+ }
99
+
100
+ module.exports = {
101
+ countTurns,
102
+ hashContent,
103
+ liveStatePath,
104
+ readLiveState,
105
+ saveLiveState,
106
+ sessionStateFromFile,
107
+ writeLiveSessionState,
108
+ getLiveSessionState,
109
+ };