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.
- package/LICENSE +21 -0
- package/README.md +171 -0
- package/bin/echoctl.js +2 -0
- package/package.json +56 -0
- package/scripts/annotate.js +73 -0
- package/scripts/build-docs.js +805 -0
- package/scripts/cli/commands/capture.js +20 -0
- package/scripts/cli/commands/constants.js +70 -0
- package/scripts/cli/commands/doctor.js +10 -0
- package/scripts/cli/commands/helpers.js +27 -0
- package/scripts/cli/commands/hook.js +48 -0
- package/scripts/cli/commands/import_cmd.js +184 -0
- package/scripts/cli/commands/init.js +45 -0
- package/scripts/cli/commands/mcp.js +16 -0
- package/scripts/cli/commands/migrate.js +65 -0
- package/scripts/cli/commands/pipeline.js +26 -0
- package/scripts/cli/commands/project.js +35 -0
- package/scripts/cli/commands/refresh.js +14 -0
- package/scripts/cli/commands/search.js +28 -0
- package/scripts/cli/commands/serve.js +73 -0
- package/scripts/cli/commands/status.js +11 -0
- package/scripts/cli/commands/stop.js +136 -0
- package/scripts/cli/commands/tag.js +89 -0
- package/scripts/cli/echoctl.js +44 -0
- package/scripts/convert.js +55 -0
- package/scripts/import-sessions.js +213 -0
- package/scripts/index.js +92 -0
- package/scripts/lib/cli/names.js +33 -0
- package/scripts/lib/domain/anchor.js +78 -0
- package/scripts/lib/domain/echo-format.js +265 -0
- package/scripts/lib/domain/errors.js +8 -0
- package/scripts/lib/domain/validation.js +126 -0
- package/scripts/lib/hooks/capture.js +401 -0
- package/scripts/lib/hooks/status.js +78 -0
- package/scripts/lib/i18n/format.js +183 -0
- package/scripts/lib/i18n/messages/en.js +41 -0
- package/scripts/lib/i18n/messages/zh-CN.js +40 -0
- package/scripts/lib/import/manifest.js +87 -0
- package/scripts/lib/import/providers/claude-code.js +272 -0
- package/scripts/lib/import/scanner.js +128 -0
- package/scripts/lib/infra/config.js +36 -0
- package/scripts/lib/infra/echo-paths.js +44 -0
- package/scripts/lib/infra/markdown-store.js +161 -0
- package/scripts/lib/infra/query-log.js +27 -0
- package/scripts/lib/infra/read-stdin.js +11 -0
- package/scripts/lib/infra/workspace.js +93 -0
- package/scripts/lib/interfaces/mcp/server.js +151 -0
- package/scripts/lib/interfaces/mcp/tools.js +152 -0
- package/scripts/lib/mcp-server.js +3 -0
- package/scripts/lib/usecases/aggregate-all-projects.js +45 -0
- package/scripts/lib/usecases/convert-buffer.js +43 -0
- package/scripts/lib/usecases/discover-claude-imports.js +80 -0
- package/scripts/lib/usecases/import-claude-project.js +89 -0
- package/scripts/lib/usecases/init-workspace.js +52 -0
- package/scripts/lib/usecases/install-claude-hook.js +139 -0
- package/scripts/lib/usecases/legacy-candidates.js +134 -0
- package/scripts/lib/usecases/live-session-state.js +109 -0
- package/scripts/lib/usecases/migrate-legacy-buffer.js +209 -0
- package/scripts/lib/usecases/project-registry.js +170 -0
- package/scripts/lib/usecases/query-articles.js +380 -0
- package/scripts/lib/usecases/refresh-serve.js +77 -0
- package/scripts/lib/usecases/run-doctor.js +213 -0
- package/scripts/lib/usecases/run-pipeline.js +104 -0
- package/scripts/lib/usecases/snapshot-manifest.js +48 -0
- package/scripts/lib/usecases/status-collector.js +142 -0
- package/scripts/lib/usecases/strip-comments.js +7 -0
- package/scripts/lib/usecases/write-comment.js +122 -0
- package/scripts/resolve.js +65 -0
- package/scripts/search.js +98 -0
- package/scripts/serve.js +778 -0
- package/scripts/validate.js +79 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
function run(args) {
|
|
2
|
+
const json = args.includes("--json");
|
|
3
|
+
const langIdx = args.indexOf("--lang");
|
|
4
|
+
const lang = langIdx !== -1 ? args[langIdx + 1] : (process.env.ECHO_LANG || null);
|
|
5
|
+
const { collectStatus } = require("../../lib/usecases/status-collector");
|
|
6
|
+
const { formatStatus } = require("../../lib/i18n/format");
|
|
7
|
+
const model = collectStatus();
|
|
8
|
+
console.log(formatStatus(model, { json, lang }));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
module.exports = run;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
function run(args) {
|
|
2
|
+
(async () => {
|
|
3
|
+
const {
|
|
4
|
+
readServeInfo,
|
|
5
|
+
clearServeInfo,
|
|
6
|
+
findServeProcessCandidates,
|
|
7
|
+
isValidPositivePid,
|
|
8
|
+
verifyProcessIdentity,
|
|
9
|
+
} = require("../../serve");
|
|
10
|
+
|
|
11
|
+
function childPidsFrom(info) {
|
|
12
|
+
return [
|
|
13
|
+
...(Array.isArray(info.childPids) ? info.childPids : []),
|
|
14
|
+
info.vitepressPid,
|
|
15
|
+
].filter((pid, index, arr) => isValidPositivePid(pid) && pid !== info.pid && arr.indexOf(pid) === index);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function signalPid(pid, signal = "SIGTERM") {
|
|
19
|
+
try {
|
|
20
|
+
process.kill(pid, signal);
|
|
21
|
+
return true;
|
|
22
|
+
} catch (err) {
|
|
23
|
+
if (err.code === "ESRCH") return false;
|
|
24
|
+
throw err;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function stopExtraPids(pids) {
|
|
29
|
+
const stopped = [];
|
|
30
|
+
for (const pid of pids) {
|
|
31
|
+
if (signalPid(pid)) stopped.push(pid);
|
|
32
|
+
}
|
|
33
|
+
return stopped;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let info;
|
|
37
|
+
try {
|
|
38
|
+
info = readServeInfo();
|
|
39
|
+
} catch (err) {
|
|
40
|
+
console.error(`Error: ${err.message}`);
|
|
41
|
+
clearServeInfo();
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
if (!info) {
|
|
45
|
+
const candidates = findServeProcessCandidates();
|
|
46
|
+
if (candidates.length === 0) {
|
|
47
|
+
console.log("No running serve instance found.");
|
|
48
|
+
process.exit(0);
|
|
49
|
+
}
|
|
50
|
+
const stopped = stopExtraPids(candidates.map((p) => p.pid));
|
|
51
|
+
if (stopped.length > 0) {
|
|
52
|
+
console.log(`No serve state found, but stopped orphaned Echo process(es): ${stopped.join(", ")}.`);
|
|
53
|
+
} else {
|
|
54
|
+
console.log("No running serve instance found.");
|
|
55
|
+
}
|
|
56
|
+
clearServeInfo();
|
|
57
|
+
process.exit(0);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const pid = info.pid;
|
|
61
|
+
if (!isValidPositivePid(pid)) {
|
|
62
|
+
console.error(`Error: invalid pid in serve state: ${JSON.stringify(info)}. Cleaning up.`);
|
|
63
|
+
clearServeInfo();
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Verify the pid is alive, owned by us, and is an echo serve process
|
|
68
|
+
try {
|
|
69
|
+
process.kill(pid, 0);
|
|
70
|
+
} catch (err) {
|
|
71
|
+
if (err.code === "ESRCH") {
|
|
72
|
+
console.log(`Process ${pid} is no longer running.`);
|
|
73
|
+
const stopped = stopExtraPids(childPidsFrom(info));
|
|
74
|
+
if (stopped.length > 0) console.log(`Stopped child process(es): ${stopped.join(", ")}.`);
|
|
75
|
+
clearServeInfo();
|
|
76
|
+
process.exit(0);
|
|
77
|
+
}
|
|
78
|
+
if (err.code === "EPERM") {
|
|
79
|
+
console.error(`Error: process ${pid} belongs to another user. Cannot stop.`);
|
|
80
|
+
process.exit(1);
|
|
81
|
+
}
|
|
82
|
+
throw err;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!verifyProcessIdentity(info)) {
|
|
86
|
+
console.error(`Error: process ${pid} is not an echo serve. PID may have been reused. State file preserved — check manually.`);
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Send SIGTERM — handle race where process exits between check and signal
|
|
91
|
+
try {
|
|
92
|
+
process.kill(pid, "SIGTERM");
|
|
93
|
+
} catch (err) {
|
|
94
|
+
if (err.code === "ESRCH") {
|
|
95
|
+
console.log(`Process ${pid} already exited.`);
|
|
96
|
+
clearServeInfo();
|
|
97
|
+
process.exit(0);
|
|
98
|
+
}
|
|
99
|
+
throw err;
|
|
100
|
+
}
|
|
101
|
+
console.log(`Sent SIGTERM to serve (pid ${pid}, API port ${info.apiPort}, docs port ${info.docsPort}).`);
|
|
102
|
+
|
|
103
|
+
// Poll for exit (2s timeout, 100ms intervals)
|
|
104
|
+
const POLL_MS = 2000;
|
|
105
|
+
const INTERVAL_MS = 100;
|
|
106
|
+
const startTime = Date.now();
|
|
107
|
+
let exited = false;
|
|
108
|
+
while (Date.now() - startTime < POLL_MS) {
|
|
109
|
+
await new Promise((r) => setTimeout(r, INTERVAL_MS));
|
|
110
|
+
try {
|
|
111
|
+
process.kill(pid, 0);
|
|
112
|
+
} catch (err) {
|
|
113
|
+
if (err.code === "ESRCH") {
|
|
114
|
+
exited = true;
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
throw err;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (exited) {
|
|
122
|
+
const stopped = stopExtraPids(childPidsFrom(info));
|
|
123
|
+
if (stopped.length > 0) console.log(`Stopped child process(es): ${stopped.join(", ")}.`);
|
|
124
|
+
console.log(`Serve stopped (pid ${pid}).`);
|
|
125
|
+
clearServeInfo();
|
|
126
|
+
} else {
|
|
127
|
+
console.error(`Warning: SIGTERM sent but process ${pid} is still running. State file preserved — check the process manually.`);
|
|
128
|
+
process.exit(1);
|
|
129
|
+
}
|
|
130
|
+
})().catch((err) => {
|
|
131
|
+
console.error(`Failed to stop serve: ${err.message}`);
|
|
132
|
+
process.exit(1);
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
module.exports = run;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
const { commandFor } = require("../../lib/cli/names");
|
|
2
|
+
|
|
3
|
+
function run(args) {
|
|
4
|
+
const { resolveDataDirs } = require("../../lib/infra/echo-paths");
|
|
5
|
+
const store = require("../../lib/infra/markdown-store");
|
|
6
|
+
const { listTags, addTags, removeTags, renameTag, purgeTag } = require("../../lib/usecases/query-articles");
|
|
7
|
+
const dirs = resolveDataDirs();
|
|
8
|
+
const deps = { dirs, store };
|
|
9
|
+
const sub = args[1];
|
|
10
|
+
|
|
11
|
+
if (sub === "list") {
|
|
12
|
+
const tags = listTags({}, deps);
|
|
13
|
+
if (tags.length === 0) {
|
|
14
|
+
console.log("No tags found.");
|
|
15
|
+
} else {
|
|
16
|
+
console.log(`${"Tag".padEnd(30)} Usage`);
|
|
17
|
+
console.log("-".repeat(42));
|
|
18
|
+
for (const { tag, count } of tags) {
|
|
19
|
+
console.log(`${tag.padEnd(30)} ${count}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
} else if (sub === "add") {
|
|
23
|
+
const articleId = args[2];
|
|
24
|
+
const tags = args.slice(3);
|
|
25
|
+
if (!articleId || tags.length === 0) {
|
|
26
|
+
console.error(`Usage: ${commandFor(["tag", "add", "<article-id>", "<tag1>", "[tag2...]"])}`);
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
const result = addTags({ id: articleId, tags }, deps);
|
|
31
|
+
console.log(`Article: ${result.id}`);
|
|
32
|
+
console.log(`Tags: ${result.tags.join(", ")}`);
|
|
33
|
+
console.log(`Added: ${result.added.join(", ")}`);
|
|
34
|
+
} catch (err) {
|
|
35
|
+
console.error(`Error: ${err.message}`);
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
} else if (sub === "remove") {
|
|
39
|
+
const articleId = args[2];
|
|
40
|
+
const tags = args.slice(3);
|
|
41
|
+
if (!articleId || tags.length === 0) {
|
|
42
|
+
console.error(`Usage: ${commandFor(["tag", "remove", "<article-id>", "<tag1>", "[tag2...]"])}`);
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
const result = removeTags({ id: articleId, tags }, deps);
|
|
47
|
+
console.log(`Article: ${result.id}`);
|
|
48
|
+
console.log(`Tags: ${result.tags.join(", ") || "(none)"}`);
|
|
49
|
+
console.log(`Removed: ${result.removed.join(", ")}`);
|
|
50
|
+
} catch (err) {
|
|
51
|
+
console.error(`Error: ${err.message}`);
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
} else if (sub === "rename") {
|
|
55
|
+
const oldTag = args[2];
|
|
56
|
+
const newTag = args[3];
|
|
57
|
+
if (!oldTag || !newTag) {
|
|
58
|
+
console.error(`Usage: ${commandFor(["tag", "rename", "<old-tag>", "<new-tag>"])}`);
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
const result = renameTag({ oldTag, newTag }, deps);
|
|
63
|
+
console.log(`Renamed: ${result.oldTag} → ${result.newTag}`);
|
|
64
|
+
console.log(`Updated: ${result.renamed} article(s)`);
|
|
65
|
+
} catch (err) {
|
|
66
|
+
console.error(`Error: ${err.message}`);
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
} else if (sub === "purge") {
|
|
70
|
+
const tag = args[2];
|
|
71
|
+
if (!tag) {
|
|
72
|
+
console.error(`Usage: ${commandFor(["tag", "purge", "<tag>"])}`);
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
const result = purgeTag({ tag }, deps);
|
|
77
|
+
console.log(`Purged: ${result.tag}`);
|
|
78
|
+
console.log(`Removed: from ${result.purged} article(s)`);
|
|
79
|
+
} catch (err) {
|
|
80
|
+
console.error(`Error: ${err.message}`);
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
} else {
|
|
84
|
+
console.error(`Usage: ${commandFor(["tag", "list|add|remove|rename|purge"])}`);
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
module.exports = run;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const { USAGE } = require("./commands/constants");
|
|
3
|
+
|
|
4
|
+
const commands = {
|
|
5
|
+
status: require("./commands/status"),
|
|
6
|
+
hook: require("./commands/hook"),
|
|
7
|
+
init: require("./commands/init"),
|
|
8
|
+
project: require("./commands/project"),
|
|
9
|
+
doctor: require("./commands/doctor"),
|
|
10
|
+
migrate: require("./commands/migrate"),
|
|
11
|
+
refresh: require("./commands/refresh"),
|
|
12
|
+
all: require("./commands/pipeline"),
|
|
13
|
+
convert: require("./commands/pipeline"),
|
|
14
|
+
validate: require("./commands/pipeline"),
|
|
15
|
+
resolve: require("./commands/pipeline"),
|
|
16
|
+
search: require("./commands/search"),
|
|
17
|
+
mcp: require("./commands/mcp"),
|
|
18
|
+
import: require("./commands/import_cmd"),
|
|
19
|
+
serve: require("./commands/serve"),
|
|
20
|
+
stop: require("./commands/stop"),
|
|
21
|
+
capture: require("./commands/capture"),
|
|
22
|
+
tag: require("./commands/tag"),
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const args = process.argv.slice(2);
|
|
26
|
+
const cmd = args[0];
|
|
27
|
+
|
|
28
|
+
if (cmd === "--version" || cmd === "-v" || cmd === "-V") {
|
|
29
|
+
const { version } = require("../../package.json");
|
|
30
|
+
console.log(version);
|
|
31
|
+
process.exit(0);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (!cmd || cmd === "--help" || cmd === "-h" || cmd === "help") {
|
|
35
|
+
console.log(USAGE);
|
|
36
|
+
process.exit(0);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (commands[cmd]) {
|
|
40
|
+
commands[cmd](args);
|
|
41
|
+
} else {
|
|
42
|
+
console.log(USAGE);
|
|
43
|
+
process.exit(cmd ? 1 : 0);
|
|
44
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const fs = require("fs");
|
|
3
|
+
const path = require("path");
|
|
4
|
+
const { ensureDir } = require("./lib/infra/workspace");
|
|
5
|
+
const { resolveDataDirs } = require("./lib/infra/echo-paths");
|
|
6
|
+
const { parseBuffer, buildArticle } = require("./lib/usecases/convert-buffer");
|
|
7
|
+
|
|
8
|
+
function runConvert(opts = {}) {
|
|
9
|
+
const dirs = opts.dirs || resolveDataDirs();
|
|
10
|
+
const { bufferDir, articlesDir } = dirs;
|
|
11
|
+
|
|
12
|
+
ensureDir(articlesDir);
|
|
13
|
+
ensureDir(bufferDir);
|
|
14
|
+
|
|
15
|
+
const bufferFiles = fs.readdirSync(bufferDir)
|
|
16
|
+
.filter((f) => f.startsWith("session-") && f.endsWith(".md"))
|
|
17
|
+
.sort();
|
|
18
|
+
|
|
19
|
+
const files = [];
|
|
20
|
+
|
|
21
|
+
if (bufferFiles.length === 0) {
|
|
22
|
+
if (!opts.silent) console.log("No buffer files to convert.");
|
|
23
|
+
return { files, bufferDir, articlesDir };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
for (const bf of bufferFiles) {
|
|
27
|
+
const bufferPath = path.join(bufferDir, bf);
|
|
28
|
+
const raw = fs.readFileSync(bufferPath, "utf-8");
|
|
29
|
+
const { turns } = parseBuffer(raw);
|
|
30
|
+
if (turns.length === 0) { console.log(`${bf}: empty — skipped`); continue; }
|
|
31
|
+
|
|
32
|
+
const { id, article, title, turnCount } = buildArticle(bf, turns, { project: dirs.projectId });
|
|
33
|
+
const articlePath = path.join(articlesDir, `${id}.md`);
|
|
34
|
+
|
|
35
|
+
if (fs.existsSync(articlePath)) {
|
|
36
|
+
const existingTurns = (fs.readFileSync(articlePath, "utf-8").match(/<!-- turn:/g) || []).length;
|
|
37
|
+
if (existingTurns === turnCount) {
|
|
38
|
+
if (!opts.silent) console.log(`${id}.md: unchanged (${turnCount} turns) — skipped`);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
fs.writeFileSync(articlePath, article);
|
|
44
|
+
console.log(`${id}.md: created (${turnCount} turns) — "${title}"`);
|
|
45
|
+
files.push({ id, title, turnCount });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return { files, bufferDir, articlesDir };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (require.main === module) {
|
|
52
|
+
runConvert();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = { runConvert };
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const fs = require("fs");
|
|
3
|
+
const path = require("path");
|
|
4
|
+
const os = require("os");
|
|
5
|
+
const { ensureDir } = require("./lib/infra/workspace");
|
|
6
|
+
const { resolveDataDirs } = require("./lib/infra/echo-paths");
|
|
7
|
+
const ef = require("./lib/domain/echo-format");
|
|
8
|
+
|
|
9
|
+
// ---- helpers ----
|
|
10
|
+
|
|
11
|
+
function isSystemNoise(text) {
|
|
12
|
+
if (!text || !text.trim()) return true;
|
|
13
|
+
const t = text.trim();
|
|
14
|
+
if (t.startsWith("Base directory for this skill:")) return true;
|
|
15
|
+
if (t.startsWith("<!-- AUTO-GENERATED")) return true;
|
|
16
|
+
if (/^```bash\n_UPD=/.test(t)) return true;
|
|
17
|
+
if (t.startsWith("<local-command-caveat>")) return true;
|
|
18
|
+
if (t.startsWith("<command-name>")) return true;
|
|
19
|
+
if (t.startsWith("<command-message>")) return true;
|
|
20
|
+
if (t.startsWith("<command-args>")) return true;
|
|
21
|
+
if (t.startsWith("<local-command-stdout>")) return true;
|
|
22
|
+
if (t.length > 3000) return true;
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function cleanUserMessage(content) {
|
|
27
|
+
if (typeof content === "string") return content.trim();
|
|
28
|
+
if (Array.isArray(content)) {
|
|
29
|
+
return content
|
|
30
|
+
.filter((b) => b.type === "text")
|
|
31
|
+
.map((b) => b.text || "")
|
|
32
|
+
.join(" ")
|
|
33
|
+
.trim();
|
|
34
|
+
}
|
|
35
|
+
return "";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function cleanAssistantBlocks(blocks) {
|
|
39
|
+
if (!Array.isArray(blocks)) return [];
|
|
40
|
+
const out = [];
|
|
41
|
+
for (const b of blocks) {
|
|
42
|
+
if (b.type === "text" && b.text) {
|
|
43
|
+
out.push({ type: "text", content: b.text.trim() });
|
|
44
|
+
} else if (b.type === "tool_use") {
|
|
45
|
+
out.push({ type: "tool", content: `[调用工具: ${b.name}]` });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const merged = [];
|
|
49
|
+
for (const item of out) {
|
|
50
|
+
if (item.type === "text" && merged.length > 0 && merged[merged.length - 1].type === "text") {
|
|
51
|
+
merged[merged.length - 1].content += "\n\n" + item.content;
|
|
52
|
+
} else {
|
|
53
|
+
merged.push(item);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return merged;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function formatDate(isoStr) {
|
|
60
|
+
return isoStr.slice(0, 10);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function parseSession(filePath) {
|
|
64
|
+
const raw = fs.readFileSync(filePath, "utf-8");
|
|
65
|
+
const lines = raw.trim().split("\n");
|
|
66
|
+
|
|
67
|
+
const events = [];
|
|
68
|
+
for (const line of lines) {
|
|
69
|
+
try { events.push(JSON.parse(line)); } catch (_) {}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const turns = [];
|
|
73
|
+
let pendingUser = null;
|
|
74
|
+
let pendingAssistantBlocks = [];
|
|
75
|
+
let models = new Set();
|
|
76
|
+
let firstTs = null;
|
|
77
|
+
let lastTs = null;
|
|
78
|
+
|
|
79
|
+
function flushAssistant() {
|
|
80
|
+
if (pendingAssistantBlocks.length === 0) return;
|
|
81
|
+
const cleaned = cleanAssistantBlocks(pendingAssistantBlocks);
|
|
82
|
+
const text = cleaned.map((c) => c.content).join("\n\n").trim();
|
|
83
|
+
if (text) turns.push({ speaker: "ai", content: text });
|
|
84
|
+
pendingAssistantBlocks = [];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function flushUser() {
|
|
88
|
+
if (pendingUser !== null) {
|
|
89
|
+
turns.push({ speaker: "vincent", content: pendingUser });
|
|
90
|
+
pendingUser = null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
for (const ev of events) {
|
|
95
|
+
if (ev.timestamp) {
|
|
96
|
+
if (!firstTs) firstTs = ev.timestamp;
|
|
97
|
+
lastTs = ev.timestamp;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (ev.type === "user") {
|
|
101
|
+
const text = cleanUserMessage(ev.message?.content || "");
|
|
102
|
+
if (text && !isSystemNoise(text)) {
|
|
103
|
+
flushAssistant();
|
|
104
|
+
flushUser();
|
|
105
|
+
pendingUser = text;
|
|
106
|
+
}
|
|
107
|
+
} else if (ev.type === "assistant") {
|
|
108
|
+
const msg = ev.message || {};
|
|
109
|
+
const blocks = msg.content || [];
|
|
110
|
+
const model = msg.model || "";
|
|
111
|
+
if (model && model !== "<synthetic>") models.add(model);
|
|
112
|
+
for (const b of blocks) {
|
|
113
|
+
pendingAssistantBlocks.push(b);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
flushAssistant();
|
|
119
|
+
flushUser();
|
|
120
|
+
|
|
121
|
+
return { turns, models: [...models], firstTs, lastTs };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function buildArticle(sessionId, turns, models, firstTs, opts = {}) {
|
|
125
|
+
const date = firstTs ? formatDate(firstTs) : "unknown-date";
|
|
126
|
+
const id = `session-${sessionId.slice(0, 8)}`;
|
|
127
|
+
const dateStr = `${date}T00:00:00+08:00`;
|
|
128
|
+
|
|
129
|
+
const speakers = {
|
|
130
|
+
human: { id: "vincent", role: "human" },
|
|
131
|
+
ai: { id: "ai", role: "ai", model: models[0] || "unknown" },
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const article = ef.createArticle({
|
|
135
|
+
id,
|
|
136
|
+
created_at: dateStr,
|
|
137
|
+
alias: ef.inferTitle(turns),
|
|
138
|
+
source_session: sessionId,
|
|
139
|
+
turns,
|
|
140
|
+
speakers,
|
|
141
|
+
project: opts.project,
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
return { id, article: ef.toMarkdown(article), title: article.title, alias: article.alias, turnCount: article.turns.length };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function runImportSessions(opts = {}) {
|
|
148
|
+
const dirs = opts.dirs || resolveDataDirs();
|
|
149
|
+
const { articlesDir } = dirs;
|
|
150
|
+
|
|
151
|
+
const PROJECT = opts.project || process.argv[2] || "-Users-vincenthuang-myNote";
|
|
152
|
+
const SESSIONS_DIR = path.join(os.homedir(), ".claude", "projects", PROJECT);
|
|
153
|
+
const MIN_REAL_TURNS = opts.minTurns || 2;
|
|
154
|
+
|
|
155
|
+
// Resolve project ID from cwd
|
|
156
|
+
let importProject = dirs.projectId;
|
|
157
|
+
if (!importProject) {
|
|
158
|
+
try {
|
|
159
|
+
const { findProjectForPath } = require("./lib/usecases/project-registry");
|
|
160
|
+
const project = findProjectForPath(opts.cwd || process.cwd());
|
|
161
|
+
if (project) importProject = project.projectId;
|
|
162
|
+
} catch (_) {}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
ensureDir(articlesDir);
|
|
166
|
+
|
|
167
|
+
if (!fs.existsSync(SESSIONS_DIR)) {
|
|
168
|
+
console.error(`Sessions directory not found: ${SESSIONS_DIR}`);
|
|
169
|
+
process.exit(1);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const files = fs.readdirSync(SESSIONS_DIR)
|
|
173
|
+
.filter((f) => f.endsWith(".jsonl"))
|
|
174
|
+
.sort();
|
|
175
|
+
|
|
176
|
+
console.log(`Scanning ${files.length} session files in ${SESSIONS_DIR}...\n`);
|
|
177
|
+
|
|
178
|
+
let imported = 0;
|
|
179
|
+
let skipped = 0;
|
|
180
|
+
|
|
181
|
+
for (const file of files) {
|
|
182
|
+
const filePath = path.join(SESSIONS_DIR, file);
|
|
183
|
+
const sessionId = path.basename(file, ".jsonl");
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
const { turns, models, firstTs } = parseSession(filePath);
|
|
187
|
+
const realTurns = turns.filter((t) => t.speaker === "vincent").length;
|
|
188
|
+
|
|
189
|
+
if (realTurns < MIN_REAL_TURNS) {
|
|
190
|
+
skipped++;
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const { id, article, title, turnCount } = buildArticle(sessionId, turns, models, firstTs, { project: importProject });
|
|
195
|
+
const articlePath = path.join(articlesDir, `${id}.md`);
|
|
196
|
+
|
|
197
|
+
fs.writeFileSync(articlePath, article);
|
|
198
|
+
console.log(`${id}.md ← ${sessionId} (${turnCount} turns, ${models.length} models) — "${title}"`);
|
|
199
|
+
imported++;
|
|
200
|
+
} catch (err) {
|
|
201
|
+
console.error(`${sessionId}: ERROR — ${err.message}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
console.log(`\nDone: ${imported} imported, ${skipped} skipped.`);
|
|
206
|
+
return { imported, skipped };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (require.main === module) {
|
|
210
|
+
runImportSessions();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
module.exports = { runImportSessions };
|
package/scripts/index.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const fs = require("fs");
|
|
3
|
+
const { ensureDir } = require("./lib/infra/workspace");
|
|
4
|
+
const { resolveDataDirs } = require("./lib/infra/echo-paths");
|
|
5
|
+
const store = require("./lib/infra/markdown-store");
|
|
6
|
+
|
|
7
|
+
function runIndex(opts = {}) {
|
|
8
|
+
const dirs = opts.dirs || resolveDataDirs();
|
|
9
|
+
const { articlesDir, commentsDir } = dirs;
|
|
10
|
+
|
|
11
|
+
ensureDir(articlesDir);
|
|
12
|
+
ensureDir(commentsDir);
|
|
13
|
+
|
|
14
|
+
const comments = store.loadComments(commentsDir).sort(
|
|
15
|
+
(a, b) => String(a.created_at).localeCompare(String(b.created_at))
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
const articles = store.indexArticles(store.loadArticles(articlesDir));
|
|
19
|
+
|
|
20
|
+
function buildCommentList(articleId) {
|
|
21
|
+
const articleComments = comments.filter(
|
|
22
|
+
(c) => c.target?.article_id === articleId
|
|
23
|
+
);
|
|
24
|
+
if (articleComments.length === 0) return "<!-- ECHO_COMMENTS_START -->\n\n<!-- ECHO_COMMENTS_END -->";
|
|
25
|
+
|
|
26
|
+
const lines = ["<!-- ECHO_COMMENTS_START -->", "", "## 评论区", ""];
|
|
27
|
+
for (const c of articleComments) {
|
|
28
|
+
const quote = c.anchor?.quote || c.id;
|
|
29
|
+
const author = c.author;
|
|
30
|
+
const d = new Date(c.created_at);
|
|
31
|
+
const date = isNaN(d.getTime()) ? String(c.created_at || "").slice(0, 10)
|
|
32
|
+
: d.toISOString().slice(0, 10);
|
|
33
|
+
let line = `- [${quote}](${c._file}) — ${author} · ${date}`;
|
|
34
|
+
|
|
35
|
+
const ofList = c.evolution?.of || [];
|
|
36
|
+
if (ofList.length > 0) {
|
|
37
|
+
const targets = ofList.map((tid) => {
|
|
38
|
+
const t = comments.find((x) => x.id === tid);
|
|
39
|
+
const tQuote = t?.anchor?.quote || tid;
|
|
40
|
+
const tFile = t?._file || "";
|
|
41
|
+
return `["${tQuote}"](${tFile})`;
|
|
42
|
+
});
|
|
43
|
+
line += " → " + targets.join(", ");
|
|
44
|
+
}
|
|
45
|
+
lines.push(line);
|
|
46
|
+
}
|
|
47
|
+
lines.push("", "<!-- ECHO_COMMENTS_END -->");
|
|
48
|
+
return lines.join("\n");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const updated = [];
|
|
52
|
+
|
|
53
|
+
for (const [id, article] of Object.entries(articles)) {
|
|
54
|
+
const commentSection = buildCommentList(id);
|
|
55
|
+
const filePath = article.absPath;
|
|
56
|
+
const raw = fs.readFileSync(filePath, "utf-8");
|
|
57
|
+
|
|
58
|
+
let newRaw;
|
|
59
|
+
const startMarker = "<!-- ECHO_COMMENTS_START -->";
|
|
60
|
+
const endMarker = "<!-- ECHO_COMMENTS_END -->";
|
|
61
|
+
const legacyMarker = "<!-- ECHO:COMMENT_LIST -->";
|
|
62
|
+
|
|
63
|
+
if (raw.includes(startMarker) && raw.includes(endMarker)) {
|
|
64
|
+
newRaw = raw.replace(
|
|
65
|
+
new RegExp(startMarker + "[\\s\\S]*" + endMarker, "g"),
|
|
66
|
+
commentSection
|
|
67
|
+
);
|
|
68
|
+
console.log(`${article.relPath}: re-indexed (${comments.filter((c) => c.target?.article_id === id).length} comments)`);
|
|
69
|
+
} else if (raw.includes(legacyMarker)) {
|
|
70
|
+
newRaw = raw.replace(legacyMarker, commentSection);
|
|
71
|
+
console.log(`${article.relPath}: first index (${comments.filter((c) => c.target?.article_id === id).length} comments)`);
|
|
72
|
+
} else {
|
|
73
|
+
newRaw = raw.trimEnd() + "\n\n" + commentSection + "\n";
|
|
74
|
+
console.log(`${article.relPath}: appended (${comments.filter((c) => c.target?.article_id === id).length} comments)`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (newRaw === raw) {
|
|
78
|
+
console.log(`${article.relPath}: unchanged — skipped`);
|
|
79
|
+
} else {
|
|
80
|
+
fs.writeFileSync(filePath, newRaw);
|
|
81
|
+
updated.push(article.relPath);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return { updated, articleCount: Object.keys(articles).length, commentCount: comments.length };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (require.main === module) {
|
|
89
|
+
runIndex();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
module.exports = { runIndex };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Central CLI naming configuration — single source of truth for all command names.
|
|
2
|
+
// Usage: require("../cli/names") — then use commandFor(...) for output, isKnownCliCommand() for detection.
|
|
3
|
+
|
|
4
|
+
const cliNames = {
|
|
5
|
+
canonicalName: "echoctl",
|
|
6
|
+
legacyNames: ["echo-mcp"],
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const mcpServerInfo = {
|
|
10
|
+
name: "echo-mcp",
|
|
11
|
+
version: "0.2.0",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function commandFor(args) {
|
|
15
|
+
return [cliNames.canonicalName, ...args].join(" ");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function allCliNames() {
|
|
19
|
+
return [cliNames.canonicalName, ...cliNames.legacyNames];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isKnownCliCommand(command) {
|
|
23
|
+
if (typeof command !== "string") return false;
|
|
24
|
+
return allCliNames().some((name) => command === name || command.startsWith(`${name} `));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
module.exports = {
|
|
28
|
+
cliNames,
|
|
29
|
+
mcpServerInfo,
|
|
30
|
+
commandFor,
|
|
31
|
+
allCliNames,
|
|
32
|
+
isKnownCliCommand,
|
|
33
|
+
};
|