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,161 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const matter = require("gray-matter");
|
|
4
|
+
|
|
5
|
+
const _articleCache = new Map();
|
|
6
|
+
|
|
7
|
+
function clearArticleCache(dir) {
|
|
8
|
+
if (dir) _articleCache.delete(dir);
|
|
9
|
+
else _articleCache.clear();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function listMarkdownFiles(dir) {
|
|
13
|
+
const files = [];
|
|
14
|
+
function walk(d) {
|
|
15
|
+
if (!fs.existsSync(d)) return;
|
|
16
|
+
for (const name of fs.readdirSync(d)) {
|
|
17
|
+
const full = path.join(d, name);
|
|
18
|
+
if (name.startsWith(".") || name === "node_modules") continue;
|
|
19
|
+
if (fs.statSync(full).isDirectory()) walk(full);
|
|
20
|
+
else if (name.endsWith(".md")) files.push(full);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
walk(dir);
|
|
24
|
+
return files;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function readMarkdownFile(file) {
|
|
28
|
+
const raw = fs.readFileSync(file, "utf-8");
|
|
29
|
+
let parsed;
|
|
30
|
+
try {
|
|
31
|
+
parsed = matter(raw);
|
|
32
|
+
} catch (e) {
|
|
33
|
+
throw new Error(`${file}: YAML frontmatter parse error — ${e.message}`);
|
|
34
|
+
}
|
|
35
|
+
return { data: parsed.data, content: parsed.content, raw };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function loadArticles(dir, opts) {
|
|
39
|
+
const records = [];
|
|
40
|
+
const strict = opts?.strict === true;
|
|
41
|
+
if (!fs.existsSync(dir)) {
|
|
42
|
+
_articleCache.set(dir, new Map());
|
|
43
|
+
return records;
|
|
44
|
+
}
|
|
45
|
+
const files = listMarkdownFiles(dir);
|
|
46
|
+
for (const file of files) {
|
|
47
|
+
let result;
|
|
48
|
+
try {
|
|
49
|
+
result = readMarkdownFile(file);
|
|
50
|
+
} catch (e) {
|
|
51
|
+
if (strict) throw e;
|
|
52
|
+
console.error(`[markdown-store] skipping unparseable file: ${e.message}`);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (!result.data.id || result.data.type === "annotation") continue;
|
|
56
|
+
records.push({
|
|
57
|
+
id: result.data.id,
|
|
58
|
+
data: result.data,
|
|
59
|
+
content: result.content,
|
|
60
|
+
raw: result.raw,
|
|
61
|
+
absPath: file,
|
|
62
|
+
relPath: path.relative(dir, file),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
const byId = new Map();
|
|
66
|
+
for (const r of records) byId.set(r.id, r);
|
|
67
|
+
_articleCache.set(dir, byId);
|
|
68
|
+
return records;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function writeArticleFile(absPath, data, content) {
|
|
72
|
+
const markdown = matter.stringify(content, data);
|
|
73
|
+
fs.writeFileSync(absPath, markdown, "utf-8");
|
|
74
|
+
_articleCache.clear();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function loadArticleById(dir, id) {
|
|
78
|
+
if (!fs.existsSync(dir)) return null;
|
|
79
|
+
let byId = _articleCache.get(dir);
|
|
80
|
+
if (!byId) {
|
|
81
|
+
byId = new Map();
|
|
82
|
+
for (const file of listMarkdownFiles(dir)) {
|
|
83
|
+
let result;
|
|
84
|
+
try {
|
|
85
|
+
result = readMarkdownFile(file);
|
|
86
|
+
} catch (_) {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (result.data.id && result.data.type !== "annotation") {
|
|
90
|
+
byId.set(result.data.id, {
|
|
91
|
+
id: result.data.id,
|
|
92
|
+
data: result.data,
|
|
93
|
+
content: result.content,
|
|
94
|
+
raw: result.raw,
|
|
95
|
+
absPath: file,
|
|
96
|
+
relPath: path.relative(dir, file),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
_articleCache.set(dir, byId);
|
|
101
|
+
}
|
|
102
|
+
return byId.get(id) || null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function loadComments(dir) {
|
|
106
|
+
const comments = [];
|
|
107
|
+
if (!fs.existsSync(dir)) return comments;
|
|
108
|
+
for (const file of listMarkdownFiles(dir)) {
|
|
109
|
+
let result;
|
|
110
|
+
try {
|
|
111
|
+
result = readMarkdownFile(file);
|
|
112
|
+
} catch (e) {
|
|
113
|
+
console.error(`[markdown-store] skipping unparseable file: ${e.message}`);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (result.data.type === "annotation") {
|
|
117
|
+
comments.push({
|
|
118
|
+
...result.data,
|
|
119
|
+
_file: `comments/${path.relative(dir, file)}`,
|
|
120
|
+
content: result.content,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return comments;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function indexArticles(records) {
|
|
128
|
+
const map = {};
|
|
129
|
+
for (const r of records) {
|
|
130
|
+
if (map[r.id]) {
|
|
131
|
+
console.warn(`[markdown-store] duplicate article id "${r.id}": ${r.relPath} and ${map[r.id].relPath}`);
|
|
132
|
+
}
|
|
133
|
+
map[r.id] = r;
|
|
134
|
+
}
|
|
135
|
+
return map;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function nextAnnotationId(dir) {
|
|
139
|
+
let max = 0;
|
|
140
|
+
for (const file of listMarkdownFiles(dir)) {
|
|
141
|
+
const name = path.basename(file);
|
|
142
|
+
const m = name.match(/^ann-(\d+)\.md$/);
|
|
143
|
+
if (m) {
|
|
144
|
+
const n = parseInt(m[1], 10);
|
|
145
|
+
if (n > max) max = n;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return `ann-${String(max + 1).padStart(3, "0")}`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
module.exports = {
|
|
152
|
+
clearArticleCache,
|
|
153
|
+
listMarkdownFiles,
|
|
154
|
+
readMarkdownFile,
|
|
155
|
+
writeArticleFile,
|
|
156
|
+
loadArticles,
|
|
157
|
+
loadArticleById,
|
|
158
|
+
loadComments,
|
|
159
|
+
indexArticles,
|
|
160
|
+
nextAnnotationId,
|
|
161
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
|
|
4
|
+
function logPath(dirs) {
|
|
5
|
+
return path.join(dirs.indexDir, "mcp-query-log.jsonl");
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function appendQueryLog(dirs, entry) {
|
|
9
|
+
const file = logPath(dirs);
|
|
10
|
+
if (!fs.existsSync(dirs.indexDir)) {
|
|
11
|
+
fs.mkdirSync(dirs.indexDir, { recursive: true });
|
|
12
|
+
}
|
|
13
|
+
fs.appendFileSync(file, JSON.stringify(entry) + "\n");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function readRecentQueryLog(dirs, limit) {
|
|
17
|
+
const file = logPath(dirs);
|
|
18
|
+
if (!fs.existsSync(file)) return [];
|
|
19
|
+
const raw = fs.readFileSync(file, "utf-8");
|
|
20
|
+
const lines = raw.trim().split("\n").filter(Boolean);
|
|
21
|
+
const start = Math.max(0, lines.length - (limit || 50));
|
|
22
|
+
return lines.slice(start).map((line) => {
|
|
23
|
+
try { return JSON.parse(line); } catch (_) { return null; }
|
|
24
|
+
}).filter(Boolean);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
module.exports = { appendQueryLog, readRecentQueryLog };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
function readStdin() {
|
|
2
|
+
return new Promise((resolve) => {
|
|
3
|
+
let data = "";
|
|
4
|
+
process.stdin.setEncoding("utf-8");
|
|
5
|
+
process.stdin.on("data", (chunk) => { data += chunk; });
|
|
6
|
+
process.stdin.on("end", () => { resolve(data); });
|
|
7
|
+
process.stdin.resume();
|
|
8
|
+
});
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
module.exports = { readStdin };
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const os = require("os");
|
|
4
|
+
|
|
5
|
+
const DEFAULT_WORKSPACE = path.join(os.homedir(), ".echo-workspace");
|
|
6
|
+
|
|
7
|
+
function expandHome(value, homeDir = os.homedir()) {
|
|
8
|
+
return String(value).replace(/^~(?=$|\/)/, homeDir);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function resolveEchoHomePath(opts = {}) {
|
|
12
|
+
const env = opts.env || process.env;
|
|
13
|
+
const homeDir = opts.homeDir || os.homedir();
|
|
14
|
+
if (env.ECHO_HOME) {
|
|
15
|
+
return expandHome(env.ECHO_HOME, homeDir);
|
|
16
|
+
}
|
|
17
|
+
return path.join(homeDir, ".echo-workspace");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function projectIdFromPath(projectPath) {
|
|
21
|
+
const base = path.basename(path.resolve(projectPath));
|
|
22
|
+
return base
|
|
23
|
+
.toLowerCase()
|
|
24
|
+
.replace(/[^a-z0-9._-]+/g, "-")
|
|
25
|
+
.replace(/^-+|-+$/g, "") || "project";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function resolveProjectDataRoot(projectPath, opts = {}) {
|
|
29
|
+
const echoHome = opts.echoHome || resolveEchoHomePath(opts);
|
|
30
|
+
const projectId = opts.projectId || projectIdFromPath(projectPath);
|
|
31
|
+
return path.join(echoHome, "projects", projectId);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function resolveWorkspacePath(opts = {}) {
|
|
35
|
+
const env = opts.env || process.env;
|
|
36
|
+
const homeDir = opts.homeDir || os.homedir();
|
|
37
|
+
const defaultWorkspace = opts.defaultWorkspace || resolveEchoHomePath({ env, homeDir });
|
|
38
|
+
const readConfig = opts.readConfig || ((configPath) => fs.readFileSync(configPath, "utf-8"));
|
|
39
|
+
|
|
40
|
+
if (env.ECHO_WORKSPACE) {
|
|
41
|
+
return expandHome(env.ECHO_WORKSPACE, homeDir);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const configPath = path.join(defaultWorkspace, "echo.json");
|
|
45
|
+
try {
|
|
46
|
+
const raw = readConfig(configPath);
|
|
47
|
+
const config = JSON.parse(raw);
|
|
48
|
+
if (config.workspace) {
|
|
49
|
+
return expandHome(config.workspace, homeDir);
|
|
50
|
+
}
|
|
51
|
+
} catch (_) {}
|
|
52
|
+
|
|
53
|
+
return defaultWorkspace;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function resolveWorkspace() {
|
|
57
|
+
return resolveWorkspacePath();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function getConfig() {
|
|
61
|
+
const configPath = path.join(resolveWorkspace(), "echo.json");
|
|
62
|
+
try {
|
|
63
|
+
return JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
|
64
|
+
} catch (_) {
|
|
65
|
+
return {};
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function ensureDir(dirPath) {
|
|
70
|
+
if (!fs.existsSync(dirPath)) {
|
|
71
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const ws = resolveWorkspace();
|
|
76
|
+
|
|
77
|
+
module.exports = {
|
|
78
|
+
resolveWorkspace,
|
|
79
|
+
resolveWorkspacePath,
|
|
80
|
+
resolveEchoHomePath,
|
|
81
|
+
resolveProjectDataRoot,
|
|
82
|
+
projectIdFromPath,
|
|
83
|
+
expandHome,
|
|
84
|
+
getConfig,
|
|
85
|
+
ensureDir,
|
|
86
|
+
DEFAULT_WORKSPACE,
|
|
87
|
+
workspaceRoot: ws,
|
|
88
|
+
articlesDir: path.join(ws, "articles"),
|
|
89
|
+
commentsDir: path.join(ws, "comments"),
|
|
90
|
+
bufferDir: path.join(ws, "session-buffer"),
|
|
91
|
+
indexDir: path.join(ws, "index"),
|
|
92
|
+
configPath: path.join(ws, "echo.json"),
|
|
93
|
+
};
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Echo MCP server — JSON-RPC 2.0 transport + dispatcher
|
|
2
|
+
// Business logic: usecases/query-articles.js
|
|
3
|
+
// Tool schemas: interfaces/mcp/tools.js
|
|
4
|
+
// Errors: domain/errors.js
|
|
5
|
+
|
|
6
|
+
const readline = require("readline");
|
|
7
|
+
const { TOOLS, TOOL_HANDLERS } = require("./tools");
|
|
8
|
+
const { NotFoundError } = require("../../domain/errors");
|
|
9
|
+
const { resolveDataDirs } = require("../../infra/echo-paths");
|
|
10
|
+
const { mcpServerInfo } = require("../../cli/names");
|
|
11
|
+
|
|
12
|
+
// --- JSON-RPC 2.0 ---
|
|
13
|
+
|
|
14
|
+
function jsonRpcError(id, code, message) {
|
|
15
|
+
return {
|
|
16
|
+
jsonrpc: "2.0",
|
|
17
|
+
id: id === undefined ? null : id,
|
|
18
|
+
error: { code, message },
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function jsonRpcResult(id, result) {
|
|
23
|
+
return { jsonrpc: "2.0", id, result };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function send(response) {
|
|
27
|
+
process.stdout.write(JSON.stringify(response) + "\n");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const SERVER_INFO = mcpServerInfo;
|
|
31
|
+
const CAPABILITIES = { tools: {} };
|
|
32
|
+
|
|
33
|
+
// --- Request dispatcher ---
|
|
34
|
+
|
|
35
|
+
function createHandleRequest(deps) {
|
|
36
|
+
return function handleRequest(msg) {
|
|
37
|
+
const { id, method, params } = msg;
|
|
38
|
+
|
|
39
|
+
switch (method) {
|
|
40
|
+
case "initialize":
|
|
41
|
+
return jsonRpcResult(id, {
|
|
42
|
+
protocolVersion: "2024-11-05",
|
|
43
|
+
capabilities: CAPABILITIES,
|
|
44
|
+
serverInfo: SERVER_INFO,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
case "notifications/initialized":
|
|
48
|
+
return null;
|
|
49
|
+
|
|
50
|
+
case "tools/list":
|
|
51
|
+
return jsonRpcResult(id, { tools: TOOLS });
|
|
52
|
+
|
|
53
|
+
case "tools/call": {
|
|
54
|
+
const toolName = params?.name;
|
|
55
|
+
const handler = TOOL_HANDLERS[toolName];
|
|
56
|
+
if (!handler) {
|
|
57
|
+
return jsonRpcError(id, -32601, `Unknown tool: ${toolName}`);
|
|
58
|
+
}
|
|
59
|
+
const t0 = Date.now();
|
|
60
|
+
try {
|
|
61
|
+
const result = handler(params?.arguments || {}, deps);
|
|
62
|
+
const text = typeof result === "string" ? result : JSON.stringify(result, null, 2);
|
|
63
|
+
try {
|
|
64
|
+
const { appendQueryLog } = require("../../infra/query-log");
|
|
65
|
+
appendQueryLog(deps.dirs, {
|
|
66
|
+
time: new Date().toISOString(),
|
|
67
|
+
tool: toolName,
|
|
68
|
+
args: params?.arguments || {},
|
|
69
|
+
ok: true,
|
|
70
|
+
result_count: Array.isArray(result) ? result.length : 1,
|
|
71
|
+
duration_ms: Date.now() - t0,
|
|
72
|
+
});
|
|
73
|
+
} catch (_) {}
|
|
74
|
+
return jsonRpcResult(id, { content: [{ type: "text", text }] });
|
|
75
|
+
} catch (err) {
|
|
76
|
+
try {
|
|
77
|
+
const { appendQueryLog } = require("../../infra/query-log");
|
|
78
|
+
appendQueryLog(deps.dirs, {
|
|
79
|
+
time: new Date().toISOString(),
|
|
80
|
+
tool: toolName,
|
|
81
|
+
args: params?.arguments || {},
|
|
82
|
+
ok: false,
|
|
83
|
+
result_count: 0,
|
|
84
|
+
duration_ms: Date.now() - t0,
|
|
85
|
+
});
|
|
86
|
+
} catch (_) {}
|
|
87
|
+
if (err instanceof NotFoundError) {
|
|
88
|
+
return jsonRpcError(id, -32002, err.message);
|
|
89
|
+
}
|
|
90
|
+
return jsonRpcError(id, -32000, `Tool error: ${err.message}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
case "ping":
|
|
95
|
+
return jsonRpcResult(id, {});
|
|
96
|
+
|
|
97
|
+
default:
|
|
98
|
+
if (id !== undefined) {
|
|
99
|
+
return jsonRpcError(id, -32601, `Method not found: ${method}`);
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// --- Stdio transport ---
|
|
107
|
+
|
|
108
|
+
function start(deps = {}) {
|
|
109
|
+
console.log = (...args) => console.error(...args);
|
|
110
|
+
console.warn = (...args) => console.error(...args);
|
|
111
|
+
console.info = (...args) => console.error(...args);
|
|
112
|
+
|
|
113
|
+
const dirs = deps.dirs || (deps.pathResolver ? deps.pathResolver({}) : resolveDataDirs());
|
|
114
|
+
const store = deps.store || require("../../infra/markdown-store");
|
|
115
|
+
|
|
116
|
+
const handleRequest = createHandleRequest({ dirs, store });
|
|
117
|
+
|
|
118
|
+
const rl = readline.createInterface({
|
|
119
|
+
input: process.stdin,
|
|
120
|
+
output: process.stdout,
|
|
121
|
+
terminal: false,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
rl.on("line", (line) => {
|
|
125
|
+
let msg;
|
|
126
|
+
try {
|
|
127
|
+
msg = JSON.parse(line);
|
|
128
|
+
} catch {
|
|
129
|
+
send(jsonRpcError(null, -32700, "Parse error"));
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (msg == null || typeof msg !== "object" || Array.isArray(msg)) {
|
|
134
|
+
send(jsonRpcError(null, -32600, "Invalid Request"));
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
const response = handleRequest(msg);
|
|
140
|
+
if (response) send(response);
|
|
141
|
+
} catch (err) {
|
|
142
|
+
send(jsonRpcError(msg.id !== undefined ? msg.id : null, -32603, "Internal error"));
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
rl.on("close", () => { process.exit(0); });
|
|
147
|
+
|
|
148
|
+
process.stderr.write("[echo-mcp] MCP server started\n");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
module.exports = { start, createHandleRequest, NotFoundError };
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Echo MCP tool definitions — schema-only, no business logic
|
|
2
|
+
|
|
3
|
+
const {
|
|
4
|
+
searchArticles,
|
|
5
|
+
getArticle,
|
|
6
|
+
getArticleContext,
|
|
7
|
+
listTags,
|
|
8
|
+
listRecent,
|
|
9
|
+
addTags,
|
|
10
|
+
removeTags,
|
|
11
|
+
renameTag,
|
|
12
|
+
purgeTag,
|
|
13
|
+
listProjects,
|
|
14
|
+
getProject,
|
|
15
|
+
} = require("../../usecases/query-articles");
|
|
16
|
+
|
|
17
|
+
const TOOLS = [
|
|
18
|
+
{
|
|
19
|
+
name: "search_articles",
|
|
20
|
+
description: "Full-text search across Echo articles. Supports keyword search in body text and tag filtering.",
|
|
21
|
+
inputSchema: {
|
|
22
|
+
type: "object",
|
|
23
|
+
properties: {
|
|
24
|
+
keyword: { type: "string", description: "Keyword to search in article body (case-insensitive)" },
|
|
25
|
+
tag: { type: "string", description: "Filter by tag (case-insensitive)" },
|
|
26
|
+
project: { type: "string", description: "Filter by project ID, or 'all' for all projects" },
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
name: "get_article",
|
|
32
|
+
description: "Retrieve a single article by its ID with full content.",
|
|
33
|
+
inputSchema: {
|
|
34
|
+
type: "object",
|
|
35
|
+
properties: {
|
|
36
|
+
id: { type: "string", description: "Article ID (e.g., article-ai-dialogue)" },
|
|
37
|
+
},
|
|
38
|
+
required: ["id"],
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: "get_article_context",
|
|
43
|
+
description: "Get article with its comments, evolution chain, and metadata. Use this when exploring how ideas connect across articles.",
|
|
44
|
+
inputSchema: {
|
|
45
|
+
type: "object",
|
|
46
|
+
properties: {
|
|
47
|
+
id: { type: "string", description: "Article ID" },
|
|
48
|
+
},
|
|
49
|
+
required: ["id"],
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: "list_tags",
|
|
54
|
+
description: "List all tags in the Echo workspace with usage counts.",
|
|
55
|
+
inputSchema: {
|
|
56
|
+
type: "object",
|
|
57
|
+
properties: {},
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
name: "list_recent",
|
|
62
|
+
description: "List recently created articles, ordered by creation date.",
|
|
63
|
+
inputSchema: {
|
|
64
|
+
type: "object",
|
|
65
|
+
properties: {
|
|
66
|
+
limit: { type: "integer", description: "Maximum number of articles to return (default: 20, max: 100)" },
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
name: "list_projects",
|
|
72
|
+
description: "List all registered Echo projects with their root paths and registration dates.",
|
|
73
|
+
inputSchema: {
|
|
74
|
+
type: "object",
|
|
75
|
+
properties: {},
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
name: "get_project",
|
|
80
|
+
description: "Get details of a single registered Echo project by its ID.",
|
|
81
|
+
inputSchema: {
|
|
82
|
+
type: "object",
|
|
83
|
+
properties: {
|
|
84
|
+
id: { type: "string", description: "Project ID (e.g., mynote, echo-notes)" },
|
|
85
|
+
},
|
|
86
|
+
required: ["id"],
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
name: "add_tags",
|
|
91
|
+
description: "Add one or more tags to an article. Tags are written back to the markdown file's YAML frontmatter. Duplicate tags are ignored.",
|
|
92
|
+
inputSchema: {
|
|
93
|
+
type: "object",
|
|
94
|
+
properties: {
|
|
95
|
+
id: { type: "string", description: "Article ID" },
|
|
96
|
+
tags: { type: "array", items: { type: "string" }, description: "Tags to add to the article" },
|
|
97
|
+
},
|
|
98
|
+
required: ["id", "tags"],
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
name: "remove_tags",
|
|
103
|
+
description: "Remove one or more tags from an article. Tags are written back to the markdown file's YAML frontmatter. Non-existent tags are silently ignored.",
|
|
104
|
+
inputSchema: {
|
|
105
|
+
type: "object",
|
|
106
|
+
properties: {
|
|
107
|
+
id: { type: "string", description: "Article ID" },
|
|
108
|
+
tags: { type: "array", items: { type: "string" }, description: "Tags to remove from the article" },
|
|
109
|
+
},
|
|
110
|
+
required: ["id", "tags"],
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
name: "rename_tag",
|
|
115
|
+
description: "Rename a tag across all articles that have it. All occurrences of the old tag in article frontmatter are replaced with the new tag.",
|
|
116
|
+
inputSchema: {
|
|
117
|
+
type: "object",
|
|
118
|
+
properties: {
|
|
119
|
+
oldTag: { type: "string", description: "Current tag name to rename" },
|
|
120
|
+
newTag: { type: "string", description: "New tag name" },
|
|
121
|
+
},
|
|
122
|
+
required: ["oldTag", "newTag"],
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
name: "purge_tag",
|
|
127
|
+
description: "Permanently remove a tag from all articles. The tag is deleted from every article's frontmatter.",
|
|
128
|
+
inputSchema: {
|
|
129
|
+
type: "object",
|
|
130
|
+
properties: {
|
|
131
|
+
tag: { type: "string", description: "Tag to remove from all articles" },
|
|
132
|
+
},
|
|
133
|
+
required: ["tag"],
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
];
|
|
137
|
+
|
|
138
|
+
const TOOL_HANDLERS = {
|
|
139
|
+
search_articles: searchArticles,
|
|
140
|
+
get_article: getArticle,
|
|
141
|
+
get_article_context: getArticleContext,
|
|
142
|
+
list_tags: listTags,
|
|
143
|
+
list_recent: listRecent,
|
|
144
|
+
list_projects: listProjects,
|
|
145
|
+
get_project: getProject,
|
|
146
|
+
add_tags: addTags,
|
|
147
|
+
remove_tags: removeTags,
|
|
148
|
+
rename_tag: renameTag,
|
|
149
|
+
purge_tag: purgeTag,
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
module.exports = { TOOLS, TOOL_HANDLERS };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* aggregate-all-projects — return data directories for all registered projects.
|
|
3
|
+
*
|
|
4
|
+
* Used by run-pipeline (npm run all) and build-docs (loadAllArticlesAndComments).
|
|
5
|
+
*/
|
|
6
|
+
const path = require("path");
|
|
7
|
+
const {
|
|
8
|
+
resolveEchoHomePath,
|
|
9
|
+
resolveProjectDataRoot,
|
|
10
|
+
} = require("../infra/workspace");
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {{ echoHome?: string }} [opts]
|
|
14
|
+
* @returns {Array<{ projectId: string, root: string, dataRoot: string, articlesDir: string, commentsDir: string, bufferDir: string, indexDir: string }>}
|
|
15
|
+
*/
|
|
16
|
+
function aggregateAllProjects(opts = {}) {
|
|
17
|
+
const echoHome = opts.echoHome || resolveEchoHomePath(opts);
|
|
18
|
+
let projects = [];
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
const { listProjects } = require("./project-registry");
|
|
22
|
+
projects = listProjects(echoHome);
|
|
23
|
+
} catch (e) {
|
|
24
|
+
console.error("[echo] aggregateAllProjects: failed to load project registry:", e.message);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const sources = [];
|
|
28
|
+
|
|
29
|
+
for (const p of projects) {
|
|
30
|
+
const dataRoot = p.dataRoot || resolveProjectDataRoot(p.root, { echoHome, projectId: p.projectId });
|
|
31
|
+
sources.push({
|
|
32
|
+
projectId: p.projectId,
|
|
33
|
+
root: p.root,
|
|
34
|
+
dataRoot,
|
|
35
|
+
articlesDir: path.join(dataRoot, "articles"),
|
|
36
|
+
commentsDir: path.join(dataRoot, "comments"),
|
|
37
|
+
bufferDir: path.join(dataRoot, "session-buffer"),
|
|
38
|
+
indexDir: path.join(dataRoot, "index"),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return sources;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
module.exports = { aggregateAllProjects };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
const path = require("path");
|
|
2
|
+
const ef = require("../domain/echo-format");
|
|
3
|
+
|
|
4
|
+
function parseBuffer(raw) {
|
|
5
|
+
const turns = [];
|
|
6
|
+
let currentTurn = null;
|
|
7
|
+
|
|
8
|
+
for (const line of raw.split("\n")) {
|
|
9
|
+
const turnMatch = line.match(ef.TURN_MARKER_REGEX);
|
|
10
|
+
if (turnMatch) {
|
|
11
|
+
if (currentTurn) turns.push(currentTurn);
|
|
12
|
+
currentTurn = {
|
|
13
|
+
id: turnMatch[1],
|
|
14
|
+
speaker: turnMatch[2],
|
|
15
|
+
reply_to: turnMatch[3] || null,
|
|
16
|
+
content: "",
|
|
17
|
+
};
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
if (currentTurn) currentTurn.content += line + "\n";
|
|
21
|
+
}
|
|
22
|
+
if (currentTurn) turns.push(currentTurn);
|
|
23
|
+
for (const t of turns) t.content = t.content.trimEnd();
|
|
24
|
+
return { raw, turns };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function buildArticle(bufferFile, turns, opts = {}) {
|
|
28
|
+
const sessionName = path.basename(bufferFile, ".md");
|
|
29
|
+
const dateStr = ef.extractSessionDate(sessionName);
|
|
30
|
+
const id = sessionName.startsWith("session-") ? sessionName : `session-${sessionName}`;
|
|
31
|
+
|
|
32
|
+
const article = ef.createArticle({
|
|
33
|
+
id,
|
|
34
|
+
created_at: `${dateStr}T00:00:00+08:00`,
|
|
35
|
+
alias: ef.inferTitle(turns),
|
|
36
|
+
turns,
|
|
37
|
+
project: opts.project,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
return { id, article: ef.toMarkdown(article), title: article.title, alias: article.alias, turnCount: article.turns.length };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
module.exports = { parseBuffer, buildArticle };
|