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,78 @@
1
+ function stripInlineFormatting(text) {
2
+ return String(text || "")
3
+ .replace(/\*\*(.+?)\*\*/g, "$1")
4
+ .replace(/__([^_]+)__/g, "$1")
5
+ .replace(/\*([^*]+)\*/g, "$1")
6
+ .replace(/_([^_]+)_/g, "$1")
7
+ .replace(/~~(.+?)~~/g, "$1");
8
+ }
9
+
10
+ function findAllPositions(text, quote) {
11
+ const positions = [];
12
+ if (!quote) return positions;
13
+
14
+ const source = String(text || "");
15
+ let idx = 0;
16
+ while (true) {
17
+ idx = source.indexOf(quote, idx);
18
+ if (idx === -1) break;
19
+ const line = source.slice(0, idx).split("\n").length;
20
+ positions.push({ index: idx, line });
21
+ idx += quote.length;
22
+ }
23
+ return positions;
24
+ }
25
+
26
+ function resolveAnchor(comment, articleBody) {
27
+ if (comment.anchor?.kind === "article") {
28
+ return { status: "ok", note: "article-level annotation" };
29
+ }
30
+
31
+ const { quote, prefix, suffix, line_hint } = comment.anchor || {};
32
+ if (!quote) return { status: "broken", reason: "no quote" };
33
+
34
+ const searchBody = stripInlineFormatting(articleBody);
35
+ const searchQuote = stripInlineFormatting(quote);
36
+ const positions = findAllPositions(searchBody, searchQuote);
37
+
38
+ if (positions.length === 0) {
39
+ return { status: "broken", reason: `quote not found: "${quote.slice(0, 50)}"` };
40
+ }
41
+
42
+ if (positions.length === 1) {
43
+ return { status: "ok", position: positions[0] };
44
+ }
45
+
46
+ const candidates = positions.filter((p) => {
47
+ const before = searchBody.slice(Math.max(0, p.index - 200), p.index);
48
+ const after = searchBody.slice(p.index + searchQuote.length, p.index + searchQuote.length + 200);
49
+ const prefixMatch = !prefix || stripInlineFormatting(before).includes(stripInlineFormatting(prefix));
50
+ const suffixMatch = !suffix || stripInlineFormatting(after).includes(stripInlineFormatting(suffix));
51
+ return prefixMatch && suffixMatch;
52
+ });
53
+
54
+ if (candidates.length === 1) {
55
+ return { status: "ok", position: candidates[0], note: "disambiguated via prefix+suffix" };
56
+ }
57
+
58
+ if (candidates.length > 1 && line_hint) {
59
+ candidates.sort((a, b) => Math.abs(a.line - line_hint) - Math.abs(b.line - line_hint));
60
+ return {
61
+ status: "needs_review",
62
+ position: candidates[0],
63
+ reason: `${candidates.length} candidates after prefix+suffix; line_hint=${line_hint}`,
64
+ };
65
+ }
66
+
67
+ return {
68
+ status: "ambiguous",
69
+ reason: `${candidates.length} occurrences, can't disambiguate`,
70
+ candidates,
71
+ };
72
+ }
73
+
74
+ module.exports = {
75
+ stripInlineFormatting,
76
+ findAllPositions,
77
+ resolveAnchor,
78
+ };
@@ -0,0 +1,265 @@
1
+ const yaml = require("js-yaml");
2
+
3
+ // ---- configurable defaults ----
4
+
5
+ const DEFAULT_SPEAKERS = {
6
+ human: { id: process.env.ECHO_USER_SPEAKER || "vincent", role: "human" },
7
+ ai: { id: process.env.ECHO_AI_SPEAKER || "ai", role: "ai", model: "unknown" },
8
+ };
9
+
10
+ // ---- turn normalization ----
11
+
12
+ // Strip a prefix once (idempotent — won't double-strip).
13
+ function stripPrefix(text, prefix) {
14
+ if (text.startsWith(prefix)) return text.slice(prefix.length);
15
+ // Also handle multiline AI heading "## ai 的回复\n\n"
16
+ const lines = text.split("\n");
17
+ if (lines[0].trim() === prefix.trim() && lines[0].startsWith("##")) {
18
+ let i = 1;
19
+ while (i < lines.length && lines[i].trim() === "") i++;
20
+ return lines.slice(i).join("\n");
21
+ }
22
+ return text;
23
+ }
24
+
25
+ function resolveSpeakerRole(speaker, speakers) {
26
+ const s = speakers || DEFAULT_SPEAKERS;
27
+ if (speaker === s.human.id) return s.human.role;
28
+ return s.ai.role;
29
+ }
30
+
31
+ function resolveSpeakerModel(speaker, speakers, hint) {
32
+ const s = speakers || DEFAULT_SPEAKERS;
33
+ if (speaker === s.human.id) return undefined;
34
+ return hint || s.ai.model;
35
+ }
36
+
37
+ /**
38
+ * createTurn(input, opts)
39
+ * Returns a canonical turn object. Idempotent — safe to call on already-normalized turns.
40
+ */
41
+ function createTurn(input, opts = {}) {
42
+ const speakers = opts.speakers || DEFAULT_SPEAKERS;
43
+ let content = input.content || "";
44
+
45
+ if (input.speaker === speakers.human.id) {
46
+ content = stripPrefix(content, "我:");
47
+ } else {
48
+ content = stripPrefix(content, "## ai 的回复\n\n");
49
+ content = stripPrefix(content, "## ai 的回复\n");
50
+ }
51
+ content = content.trim();
52
+
53
+ return {
54
+ id: input.id || null,
55
+ speaker: input.speaker,
56
+ role: resolveSpeakerRole(input.speaker, speakers),
57
+ content,
58
+ reply_to: input.reply_to || null,
59
+ model: resolveSpeakerModel(input.speaker, speakers, input.model),
60
+ };
61
+ }
62
+
63
+ // ---- participant ----
64
+
65
+ function createParticipant(input) {
66
+ const p = { id: input.id, role: input.role };
67
+ if (input.model) p.model = input.model;
68
+ return p;
69
+ }
70
+
71
+ // ---- article JSON template ----
72
+
73
+ /**
74
+ * createArticle(input)
75
+ * Returns the canonical article JSON object.
76
+ * Each turn in input.turns can be raw or already canonical — createTurn is called on each.
77
+ */
78
+ function createArticle(input) {
79
+ const speakers = input.speakers || DEFAULT_SPEAKERS;
80
+ const now = new Date().toISOString().replace(/\.\d{3}Z$/, "+08:00");
81
+
82
+ const turns = [];
83
+ let turnNum = 0;
84
+ let lastUserTurnId = null;
85
+
86
+ for (const raw of input.turns) {
87
+ turnNum++;
88
+ const turnId = raw.id || `t${String(turnNum).padStart(3, "0")}`;
89
+ const t = createTurn(raw, { speakers });
90
+ t.id = turnId;
91
+
92
+ if (t.role === "ai" && !t.reply_to && lastUserTurnId) {
93
+ t.reply_to = lastUserTurnId;
94
+ }
95
+ if (t.role === "human") {
96
+ lastUserTurnId = turnId;
97
+ }
98
+
99
+ if (t.role === "human") delete t.model;
100
+
101
+ turns.push(t);
102
+ }
103
+
104
+ let participants;
105
+ if (input.participants) {
106
+ participants = input.participants.map((p) => createParticipant(p));
107
+ } else {
108
+ const seen = new Map();
109
+ for (const t of turns) {
110
+ if (!seen.has(t.speaker)) {
111
+ seen.set(t.speaker, createParticipant({
112
+ id: t.speaker,
113
+ role: t.role,
114
+ model: t.model,
115
+ }));
116
+ }
117
+ }
118
+ participants = [...seen.values()];
119
+ }
120
+
121
+ const title = input.title || inferTitle(turns);
122
+ const alias = input.alias || title;
123
+ const summary = input.summary || inferSummary(turns);
124
+
125
+ const article = {
126
+ id: input.id,
127
+ title,
128
+ created_at: input.created_at,
129
+ updated_at: input.updated_at || now,
130
+ tags: input.tags || [],
131
+ summary,
132
+ participants,
133
+ source_session: input.source_session || undefined,
134
+ project: input.project || undefined,
135
+ turns,
136
+ };
137
+
138
+ if (alias !== title) article.alias = alias;
139
+
140
+ return article;
141
+ }
142
+
143
+ // ---- shared turn marker (single source of truth) ----
144
+
145
+ /** Regex for parsing turn markers. Must stay in sync with renderTurnMarker(). */
146
+ const TURN_MARKER_REGEX = /^<!-- turn: (\S+) speaker=(\S+)(?: reply_to=(\S+))? -->/;
147
+
148
+ /**
149
+ * renderTurnMarker(id, speaker, replyTo)
150
+ * 生成统一的 `<!-- turn: ... -->` 标记字符串。
151
+ * 所有生成和解析 turn 标记的代码应使用此函数和 TURN_MARKER_REGEX。
152
+ */
153
+ function renderTurnMarker(id, speaker, replyTo) {
154
+ const parts = [`<!-- turn: ${id} speaker=${speaker}`];
155
+ if (replyTo) parts.push(`reply_to=${replyTo}`);
156
+ parts.push("-->");
157
+ return parts.join(" ");
158
+ }
159
+
160
+ // ---- Markdown serializer (single exit point) ----
161
+
162
+ function toMarkdown(article) {
163
+ const fm = {
164
+ id: article.id,
165
+ title: article.title,
166
+ created_at: article.created_at,
167
+ updated_at: article.updated_at,
168
+ tags: article.tags,
169
+ summary: article.summary,
170
+ participants: article.participants.map((p) => {
171
+ const entry = { id: p.id, role: p.role };
172
+ if (p.model) entry.model = p.model;
173
+ return entry;
174
+ }),
175
+ };
176
+ if (article.source_session) fm.source_session = article.source_session;
177
+ if (article.alias) fm.alias = article.alias;
178
+ if (article.project) fm.project = article.project;
179
+
180
+ const frontmatter = yaml.dump(fm, {
181
+ lineWidth: -1,
182
+ noCompatMode: true,
183
+ quotingType: '"',
184
+ forceQuotes: false,
185
+ });
186
+
187
+ const turnBlocks = [];
188
+ for (const t of article.turns) {
189
+ const marker = renderTurnMarker(t.id, t.speaker, t.reply_to);
190
+
191
+ let contentLine;
192
+ if (t.role === "human") {
193
+ contentLine = `我:${t.content}`;
194
+ } else {
195
+ const modelNote = t.model && t.model !== "unknown" ? `(${t.model})` : "";
196
+ contentLine = `## ai 的回复${modelNote}\n\n${t.content}`;
197
+ }
198
+
199
+ turnBlocks.push(`${marker}\n${contentLine}`);
200
+ }
201
+
202
+ const body = turnBlocks.join("\n\n");
203
+
204
+ return [
205
+ "---",
206
+ frontmatter.trimEnd(),
207
+ "---",
208
+ "",
209
+ body,
210
+ "",
211
+ "<!-- ECHO_COMMENTS_START -->",
212
+ "",
213
+ "<!-- ECHO_COMMENTS_END -->",
214
+ "",
215
+ ].join("\n");
216
+ }
217
+
218
+ // ---- inference helpers ----
219
+
220
+ function firstUserTurn(turns) {
221
+ return turns.find((t) => {
222
+ const role = t.role || (t.speaker === DEFAULT_SPEAKERS.human.id ? "human" : "ai");
223
+ return role === "human";
224
+ });
225
+ }
226
+
227
+ function cleanText(text, maxLen) {
228
+ if (!text) return "未命名对话";
229
+ const cleaned = text.replace(/[""]/g, "").replace(/\n/g, " ").replace(/\s+/g, " ").trim();
230
+ return cleaned.length <= maxLen ? cleaned : cleaned.slice(0, maxLen - 3) + "...";
231
+ }
232
+
233
+ function inferTitle(turns) {
234
+ const first = firstUserTurn(turns);
235
+ const text = first ? first.content : "";
236
+ const raw = text.startsWith("我:") ? text.slice(2) : text;
237
+ return cleanText(raw, 60);
238
+ }
239
+
240
+ function inferSummary(turns) {
241
+ const first = firstUserTurn(turns);
242
+ const text = first ? first.content : "";
243
+ const raw = text.startsWith("我:") ? text.slice(2) : text;
244
+ return cleanText(raw, 80);
245
+ }
246
+
247
+ function extractSessionDate(sessionName) {
248
+ const m = sessionName.match(/^session-(\d{4}-\d{2}-\d{2})(?:-v\d+)?$/);
249
+ return m ? m[1] : sessionName.replace(/^session-/, "").replace(/-v\d+$/, "");
250
+ }
251
+
252
+ // ---- exports ----
253
+
254
+ module.exports = {
255
+ DEFAULT_SPEAKERS,
256
+ TURN_MARKER_REGEX,
257
+ renderTurnMarker,
258
+ createTurn,
259
+ createParticipant,
260
+ createArticle,
261
+ toMarkdown,
262
+ inferTitle,
263
+ inferSummary,
264
+ extractSessionDate,
265
+ };
@@ -0,0 +1,8 @@
1
+ class NotFoundError extends Error {
2
+ constructor(message) {
3
+ super(message);
4
+ this.name = "NotFoundError";
5
+ }
6
+ }
7
+
8
+ module.exports = { NotFoundError };
@@ -0,0 +1,126 @@
1
+ const VALID_EVOLUTION_KINDS = new Set([
2
+ null, "refines", "contradicts", "expands", "supersedes",
3
+ ]);
4
+
5
+ // ---- per-record validation ----
6
+
7
+ function validateAnnotation(record) {
8
+ const { id, data, file } = record;
9
+ const errs = [];
10
+
11
+ if (!id) {
12
+ errs.push(`${file}: missing "id"`);
13
+ return errs;
14
+ }
15
+
16
+ if (!data.target?.article_id) errs.push(`${file}: missing "target.article_id"`);
17
+ if (data.anchor?.kind === "article") {
18
+ // Article-level comment: no quote/prefix/suffix/occurrence/line_hint required
19
+ } else {
20
+ if (!data.anchor?.quote) errs.push(`${file}: missing "anchor.quote"`);
21
+ if (data.anchor?.prefix === undefined) errs.push(`${file}: missing "anchor.prefix"`);
22
+ if (data.anchor?.suffix === undefined) errs.push(`${file}: missing "anchor.suffix"`);
23
+ if (data.anchor?.occurrence === undefined) errs.push(`${file}: missing "anchor.occurrence"`);
24
+ if (data.anchor?.line_hint === undefined) errs.push(`${file}: missing "anchor.line_hint"`);
25
+ }
26
+ if (!data.author) errs.push(`${file}: missing "author"`);
27
+ if (!data.created_at) errs.push(`${file}: missing "created_at"`);
28
+ if (!data.status) errs.push(`${file}: missing "status"`);
29
+
30
+ if (!data.evolution) {
31
+ errs.push(`${file}: missing "evolution"`);
32
+ } else if (!VALID_EVOLUTION_KINDS.has(data.evolution.kind)) {
33
+ errs.push(`${file}: invalid evolution.kind "${data.evolution.kind}" (allowed: refines, contradicts, expands, supersedes, null)`);
34
+ }
35
+
36
+ const expectedName = `${id}.md`;
37
+ if (file.split("/").pop() !== expectedName) {
38
+ errs.push(`${file}: file name should be "${expectedName}" (id is "${id}")`);
39
+ }
40
+
41
+ return errs;
42
+ }
43
+
44
+ function validateArticle(record) {
45
+ const { data, file } = record;
46
+ const errs = [];
47
+
48
+ if (!data.title) errs.push(`${file}: missing "title"`);
49
+ if (!data.created_at) errs.push(`${file}: missing "created_at"`);
50
+ if (data.alias !== undefined && data.alias !== null && typeof data.alias !== "string") {
51
+ errs.push(`${file}: "alias" must be a string`);
52
+ }
53
+
54
+ return errs;
55
+ }
56
+
57
+ // ---- cross-record validation ----
58
+
59
+ function detectCycle(startId, ofMap, visited) {
60
+ const v = visited || new Set();
61
+ if (v.has(startId)) return [...v, startId];
62
+ v.add(startId);
63
+ for (const next of (ofMap[startId] || [])) {
64
+ const cycle = detectCycle(next, ofMap, new Set(v));
65
+ if (cycle) return cycle;
66
+ }
67
+ return null;
68
+ }
69
+
70
+ function checkAllCycles(commentIds, ofMap, fileMap) {
71
+ const errs = [];
72
+ for (const id of commentIds) {
73
+ const cycle = detectCycle(id, ofMap);
74
+ if (cycle) {
75
+ errs.push(`${fileMap[id]}: evolution cycle detected: ${cycle.join(" → ")}`);
76
+ }
77
+ }
78
+ return errs;
79
+ }
80
+
81
+ function checkEvolutionReferences(comments, commentsMap, fileMap) {
82
+ const errs = [];
83
+ for (const [id, c] of Object.entries(comments)) {
84
+ const ofList = c.evolution?.of || [];
85
+ for (const targetId of ofList) {
86
+ if (!commentsMap[targetId]) {
87
+ errs.push(`${fileMap[id]}: evolution.of references unknown comment "${targetId}"`);
88
+ }
89
+ }
90
+ }
91
+ return errs;
92
+ }
93
+
94
+ function checkArticleReferences(comments, articleIds, fileMap) {
95
+ const errs = [];
96
+ for (const [id, c] of Object.entries(comments)) {
97
+ const aid = c.target?.article_id;
98
+ if (aid && !articleIds.has(aid)) {
99
+ errs.push(`${fileMap[id]}: target.article_id "${aid}" not found`);
100
+ }
101
+ }
102
+ return errs;
103
+ }
104
+
105
+ function checkDuplicateIds(records) {
106
+ const errs = [];
107
+ const seen = new Set();
108
+ for (const { id, file } of records) {
109
+ if (seen.has(id)) {
110
+ errs.push(`${file}: duplicate id "${id}"`);
111
+ }
112
+ seen.add(id);
113
+ }
114
+ return errs;
115
+ }
116
+
117
+ module.exports = {
118
+ VALID_EVOLUTION_KINDS,
119
+ validateAnnotation,
120
+ validateArticle,
121
+ detectCycle,
122
+ checkAllCycles,
123
+ checkEvolutionReferences,
124
+ checkArticleReferences,
125
+ checkDuplicateIds,
126
+ };