autonomous-flow-daemon 1.6.0 → 1.9.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 (61) hide show
  1. package/CHANGELOG.md +85 -85
  2. package/LICENSE +21 -21
  3. package/README-ko.md +282 -0
  4. package/README.md +282 -266
  5. package/mcp-config.json +10 -10
  6. package/package.json +4 -2
  7. package/src/adapters/index.ts +370 -370
  8. package/src/cli.ts +162 -127
  9. package/src/commands/benchmark.ts +187 -187
  10. package/src/commands/correlate.ts +180 -0
  11. package/src/commands/dashboard.ts +404 -0
  12. package/src/commands/evolution.ts +84 -1
  13. package/src/commands/fix.ts +158 -158
  14. package/src/commands/lang.ts +41 -41
  15. package/src/commands/plugin.ts +110 -0
  16. package/src/commands/restart.ts +14 -14
  17. package/src/commands/score.ts +276 -276
  18. package/src/commands/start.ts +155 -155
  19. package/src/commands/status.ts +157 -157
  20. package/src/commands/stop.ts +68 -68
  21. package/src/commands/suggest.ts +211 -0
  22. package/src/commands/sync.ts +329 -16
  23. package/src/constants.ts +32 -32
  24. package/src/core/boast.ts +280 -280
  25. package/src/core/config.ts +49 -49
  26. package/src/core/correlation-engine.ts +265 -0
  27. package/src/core/db.ts +145 -117
  28. package/src/core/discovery.ts +65 -65
  29. package/src/core/federation.ts +129 -0
  30. package/src/core/hologram/engine.ts +71 -71
  31. package/src/core/hologram/fallback.ts +11 -11
  32. package/src/core/hologram/go-extractor.ts +203 -0
  33. package/src/core/hologram/incremental.ts +227 -227
  34. package/src/core/hologram/py-extractor.ts +132 -132
  35. package/src/core/hologram/rust-extractor.ts +244 -0
  36. package/src/core/hologram/ts-extractor.ts +406 -320
  37. package/src/core/hologram/types.ts +27 -25
  38. package/src/core/hologram.ts +73 -71
  39. package/src/core/i18n/messages.ts +309 -309
  40. package/src/core/locale.ts +88 -88
  41. package/src/core/log-rotate.ts +33 -33
  42. package/src/core/log-utils.ts +38 -38
  43. package/src/core/lru-map.ts +61 -61
  44. package/src/core/notify.ts +74 -74
  45. package/src/core/plugin-manager.ts +225 -0
  46. package/src/core/rule-suggestion.ts +127 -0
  47. package/src/core/validator-generator.ts +224 -0
  48. package/src/core/workspace.ts +28 -28
  49. package/src/daemon/client.ts +78 -65
  50. package/src/daemon/event-batcher.ts +108 -108
  51. package/src/daemon/guards.ts +13 -13
  52. package/src/daemon/http-routes.ts +376 -293
  53. package/src/daemon/mcp-handler.ts +575 -270
  54. package/src/daemon/mcp-subscriptions.ts +81 -0
  55. package/src/daemon/mesh.ts +51 -0
  56. package/src/daemon/server.ts +655 -590
  57. package/src/daemon/types.ts +121 -100
  58. package/src/daemon/workspace-map.ts +104 -92
  59. package/src/platform.ts +60 -60
  60. package/src/version.ts +15 -15
  61. package/README.ko.md +0 -266
@@ -1,270 +1,575 @@
1
- /**
2
- * MCP stdio handler — JSON-RPC dispatcher for tools and resources.
3
- * Extracted from server.ts for modularity.
4
- */
5
-
6
- import { readFileSync } from "fs";
7
- import { resolve } from "path";
8
- import { generateHologram } from "../core/hologram";
9
- import { diagnose } from "../core/immune";
10
- import type { DaemonContext } from "./types";
11
- import { APP_VERSION } from "../version";
12
- import { assertInsideWorkspace as _assertWs } from "./guards";
13
-
14
- const MCP_MAX_BUFFER = 1024 * 1024; // 1 MB
15
-
16
- const mcpToolDefs = [
17
- {
18
- name: "afd_diagnose",
19
- description: "Run health diagnosis on the current project. Returns symptoms and healthy checks.",
20
- inputSchema: {
21
- type: "object" as const,
22
- properties: {
23
- raw: { type: "boolean" as const, description: "If true, report all symptoms ignoring antibodies" },
24
- },
25
- },
26
- },
27
- {
28
- name: "afd_score",
29
- description: "Get daemon runtime stats: uptime, events, heals, hologram savings, suppression metrics.",
30
- inputSchema: { type: "object" as const, properties: {} },
31
- },
32
- {
33
- name: "afd_hologram",
34
- description: "Generate a token-efficient hologram (type skeleton) for a source file (TS, JS, Python). Use contextFile for L1 filtering. Use diffOnly for incremental updates showing only changed declarations.",
35
- inputSchema: {
36
- type: "object" as const,
37
- properties: {
38
- file: { type: "string" as const, description: "Relative or absolute file path" },
39
- contextFile: { type: "string" as const, description: "Optional: the file that imports from 'file'. Enables L1 filtering for higher compression." },
40
- diffOnly: { type: "boolean" as const, description: "Optional: return only changed declarations since last hologram call (unified-diff format). Saves tokens on repeated reads." },
41
- },
42
- required: ["file"],
43
- },
44
- },
45
- {
46
- name: "afd_read",
47
- description: "Smart file reader that saves tokens. Files <10KB return full content. Files >=10KB return a structural hologram instead. Use 'startLine' and 'endLine' to read specific line ranges of large files at full fidelity.",
48
- inputSchema: {
49
- type: "object" as const,
50
- properties: {
51
- file: { type: "string" as const, description: "Relative or absolute file path" },
52
- startLine: { type: "number" as const, description: "Start line number (1-based, inclusive). Use with endLine to read a specific range of large files." },
53
- endLine: { type: "number" as const, description: "End line number (1-based, inclusive)." },
54
- },
55
- required: ["file"],
56
- },
57
- },
58
- ];
59
-
60
- function mcpResponse(id: unknown, result: unknown) {
61
- process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id, result }) + "\n");
62
- }
63
-
64
- function mcpError(id: unknown, code: number, message: string) {
65
- process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } }) + "\n");
66
- }
67
-
68
-
69
- async function handleMcpRequest(ctx: DaemonContext, req: { id?: unknown; method?: string; params?: Record<string, unknown> }) {
70
- const { id, method, params } = req;
71
-
72
- if (method === "initialize") {
73
- mcpResponse(id, {
74
- protocolVersion: "2024-11-05",
75
- capabilities: { tools: {}, resources: {} },
76
- serverInfo: { name: "afd", version: APP_VERSION },
77
- });
78
- return;
79
- }
80
-
81
- if (method === "notifications/initialized") return;
82
-
83
- if (method === "tools/list") {
84
- mcpResponse(id, { tools: mcpToolDefs });
85
- return;
86
- }
87
-
88
- if (method === "resources/list") {
89
- mcpResponse(id, {
90
- resources: [{
91
- uri: "afd://workspace-map",
92
- name: "Workspace Map",
93
- description: "Project file tree with export signatures. Read this first to understand the codebase structure before reading individual files.",
94
- mimeType: "text/plain",
95
- }],
96
- });
97
- return;
98
- }
99
-
100
- if (method === "resources/read") {
101
- const uri = params?.uri as string | undefined;
102
- if (uri === "afd://workspace-map") {
103
- mcpResponse(id, {
104
- contents: [{ uri: "afd://workspace-map", mimeType: "text/plain", text: ctx.getWorkspaceMap() }],
105
- });
106
- return;
107
- }
108
- mcpError(id, -32602, `Unknown resource: ${uri}`);
109
- return;
110
- }
111
-
112
- if (method === "tools/call") {
113
- const toolName = params?.name as string | undefined;
114
- const args = (params?.arguments ?? {}) as Record<string, unknown>;
115
-
116
- // Track MCP tool call
117
- if (toolName) {
118
- try { ctx.insertTelemetry.run("mcp", toolName, null, null, Date.now()); } catch { /* crash-only */ }
119
- }
120
-
121
- if (toolName === "afd_diagnose") {
122
- const raw = args.raw === true;
123
- const known = ctx.antibodyIds.all().map(r => r.id);
124
- const result = diagnose(known, { raw });
125
-
126
- const PROACTIVE_THRESHOLD = 5 * 1024;
127
- const enriched = await Promise.all(result.symptoms.map(async (s: { fileTarget: string; [k: string]: unknown }) => {
128
- const snapshot = ctx.state.fileSnapshots.get(s.fileTarget)
129
- ?? ctx.state.fileSnapshots.get(s.fileTarget.replace(/\//g, "\\"));
130
- if (!snapshot) return s;
131
- if (snapshot.length > PROACTIVE_THRESHOLD) {
132
- const hologram = await ctx.safeHologram(s.fileTarget, snapshot);
133
- return { ...s, hologram, contextNote: `File is ${(snapshot.length / 1024).toFixed(1)}KB — hologram provided to save tokens.` };
134
- }
135
- return { ...s, context: snapshot };
136
- }));
137
-
138
- mcpResponse(id, {
139
- content: [{ type: "text", text: JSON.stringify({ ...result, symptoms: enriched }, null, 2), cache_control: { type: "ephemeral" } }],
140
- });
141
- return;
142
- }
143
-
144
- if (toolName === "afd_score") {
145
- const uptime = Math.floor((Date.now() - ctx.state.startedAt) / 1000);
146
- const eventCount = ctx.db.query("SELECT COUNT(*) as cnt FROM events").get() as { cnt: number };
147
- const abCount = ctx.countAntibodies.get() as { cnt: number };
148
- const hs = ctx.state.hologramStats;
149
- mcpResponse(id, {
150
- content: [{ type: "text", text: JSON.stringify({
151
- uptime,
152
- filesDetected: ctx.state.filesDetected,
153
- totalEvents: eventCount.cnt,
154
- antibodies: abCount.cnt,
155
- autoHealed: ctx.state.autoHealCount,
156
- hologramRequests: hs.totalRequests,
157
- hologramSavings: hs.totalOriginalChars > 0
158
- ? `${Math.round((hs.totalOriginalChars - hs.totalHologramChars) / hs.totalOriginalChars * 100)}%`
159
- : "0%",
160
- suppression: {
161
- massEventsSkipped: ctx.state.suppressionSkippedCount,
162
- dormantTransitions: ctx.state.dormantTransitions.length,
163
- },
164
- }, null, 2), cache_control: { type: "ephemeral" } }],
165
- });
166
- return;
167
- }
168
-
169
- if (toolName === "afd_hologram") {
170
- const file = args.file as string | undefined;
171
- if (!file) { mcpError(id, -32602, "Missing required argument: file"); return; }
172
- try {
173
- const absPath = resolve(file);
174
- _assertWs(absPath, ctx.ws.root);
175
- const source = readFileSync(absPath, "utf-8");
176
- const contextFile = args.contextFile as string | undefined;
177
- const diffOnly = args.diffOnly === true;
178
- const opts: Record<string, unknown> = {};
179
- if (contextFile) opts.contextFile = resolve(contextFile);
180
- if (diffOnly) opts.diffOnly = true;
181
- const result = await generateHologram(file, source, Object.keys(opts).length > 0 ? opts as { contextFile?: string; diffOnly?: boolean } : undefined);
182
- ctx.persistHologramStats(result.originalLength, result.hologramLength);
183
- mcpResponse(id, {
184
- content: [{ type: "text", text: result.hologram, cache_control: { type: "ephemeral" } }],
185
- });
186
- } catch (err: unknown) {
187
- mcpError(id, -32603, err instanceof Error ? err.message : String(err));
188
- }
189
- return;
190
- }
191
-
192
- if (toolName === "afd_read") {
193
- const file = args.file as string | undefined;
194
- if (!file) { mcpError(id, -32602, "Missing required argument: file"); return; }
195
- try {
196
- const AFD_READ_THRESHOLD = 10 * 1024;
197
- const absPath = resolve(file);
198
- _assertWs(absPath, ctx.ws.root);
199
- const source = readFileSync(absPath, "utf-8");
200
- const sizeKB = (source.length / 1024).toFixed(1);
201
- const rawStart = args.startLine;
202
- const rawEnd = args.endLine;
203
- const startLine = typeof rawStart === "number" && Number.isFinite(rawStart) ? rawStart : undefined;
204
- const endLine = typeof rawEnd === "number" && Number.isFinite(rawEnd) ? rawEnd : undefined;
205
-
206
- if (startLine !== undefined && endLine !== undefined) {
207
- const lines = source.split("\n");
208
- const start = Math.max(1, Math.floor(startLine)) - 1;
209
- const end = Math.min(lines.length, Math.floor(endLine));
210
- const slice = lines.slice(start, end).map((l, i) => `${start + i + 1}\t${l}`).join("\n");
211
- mcpResponse(id, {
212
- content: [{ type: "text", text: `// ${file} lines ${start + 1}-${end} (${sizeKB}KB total)\n${slice}`, cache_control: { type: "ephemeral" } }],
213
- });
214
- return;
215
- }
216
-
217
- if (source.length < AFD_READ_THRESHOLD) {
218
- mcpResponse(id, {
219
- content: [{ type: "text", text: source, cache_control: { type: "ephemeral" } }],
220
- });
221
- return;
222
- }
223
-
224
- const result = await generateHologram(file, source);
225
- ctx.persistHologramStats(result.originalLength, result.hologramLength);
226
- const savings = Math.round((1 - result.hologramLength / result.originalLength) * 100);
227
- const header = `⚠️ [afd-Optimizer]: File is ${sizeKB}KB. To save tokens, only the structural hologram is provided (${savings}% reduction).\nUse afd_read with startLine/endLine to read specific sections at full fidelity.\nTotal lines: ${source.split("\n").length}\n\n`;
228
- mcpResponse(id, {
229
- content: [{ type: "text", text: header + result.hologram, cache_control: { type: "ephemeral" } }],
230
- });
231
- } catch (err: unknown) {
232
- mcpError(id, -32603, err instanceof Error ? err.message : String(err));
233
- }
234
- return;
235
- }
236
-
237
- mcpError(id, -32601, `Unknown tool: ${toolName}`);
238
- return;
239
- }
240
-
241
- mcpError(id, -32601, `Unknown method: ${method}`);
242
- }
243
-
244
- /** Start MCP stdio mode — blocks until stdin closes */
245
- export function startMcpStdio(ctx: DaemonContext) {
246
- console.error("[afd] MCP stdio mode awaiting JSON-RPC on stdin");
247
-
248
- (async () => {
249
- const decoder = new TextDecoder();
250
- let buffer = "";
251
- for await (const chunk of Bun.stdin.stream()) {
252
- buffer += decoder.decode(chunk);
253
- if (buffer.length > MCP_MAX_BUFFER) {
254
- console.error("[afd] MCP buffer overflow — dropping buffer");
255
- buffer = "";
256
- continue;
257
- }
258
- let newlineIdx: number;
259
- while ((newlineIdx = buffer.indexOf("\n")) !== -1) {
260
- const line = buffer.slice(0, newlineIdx).trim();
261
- buffer = buffer.slice(newlineIdx + 1);
262
- if (!line) continue;
263
- try { handleMcpRequest(ctx, JSON.parse(line)); } catch { /* crash-only */ }
264
- }
265
- }
266
- })().catch((err) => {
267
- console.error("[afd] MCP stdin loop error:", err instanceof Error ? err.message : String(err));
268
- process.exit(1);
269
- });
270
- }
1
+ /**
2
+ * MCP stdio handler — JSON-RPC dispatcher for tools and resources.
3
+ * Extracted from server.ts for modularity.
4
+ */
5
+
6
+ import { readFileSync } from "fs";
7
+ import { resolve } from "path";
8
+ import { generateHologram } from "../core/hologram";
9
+ import { diagnose } from "../core/immune";
10
+ import { suggestRules } from "../core/rule-suggestion";
11
+ import { generateValidator } from "../core/validator-generator";
12
+ import type { Database } from "bun:sqlite";
13
+ import type { DaemonContext } from "./types";
14
+ import { APP_VERSION } from "../version";
15
+ import { assertInsideWorkspace as _assertWs } from "./guards";
16
+ import { subscriptionManager } from "./mcp-subscriptions";
17
+
18
+ const MCP_MAX_BUFFER = 1024 * 1024; // 1 MB
19
+
20
+ const mcpToolDefs = [
21
+ {
22
+ name: "afd_diagnose",
23
+ description: "Run health diagnosis on the current project. Returns symptoms and healthy checks.",
24
+ inputSchema: {
25
+ type: "object" as const,
26
+ properties: {
27
+ raw: { type: "boolean" as const, description: "If true, report all symptoms ignoring antibodies" },
28
+ },
29
+ },
30
+ },
31
+ {
32
+ name: "afd_score",
33
+ description: "Get daemon runtime stats: uptime, events, heals, hologram savings, suppression metrics.",
34
+ inputSchema: { type: "object" as const, properties: {} },
35
+ },
36
+ {
37
+ name: "afd_hologram",
38
+ description: "Generate a token-efficient hologram (type skeleton) for a source file (TS, JS, Python). Use contextFile for L1 filtering. Use diffOnly for incremental updates showing only changed declarations.",
39
+ inputSchema: {
40
+ type: "object" as const,
41
+ properties: {
42
+ file: { type: "string" as const, description: "Relative or absolute file path" },
43
+ contextFile: { type: "string" as const, description: "Optional: the file that imports from 'file'. Enables L1 filtering for higher compression." },
44
+ diffOnly: { type: "boolean" as const, description: "Optional: return only changed declarations since last hologram call (unified-diff format). Saves tokens on repeated reads." },
45
+ },
46
+ required: ["file"],
47
+ },
48
+ },
49
+ {
50
+ name: "afd_suggest",
51
+ description: "Analyze mistake_history and return high-frequency vulnerability patterns as ranked suggestions. Call this FIRST before any bug fix or refactor to check for known quarantine patterns.",
52
+ inputSchema: {
53
+ type: "object" as const,
54
+ properties: {
55
+ days: { type: "number" as const, description: "Analysis window in days (default: 30)" },
56
+ min_frequency: { type: "number" as const, description: "Minimum occurrence count to surface a suggestion (default: 3)" },
57
+ limit: { type: "number" as const, description: "Maximum number of suggestions to return (default: 10)" },
58
+ },
59
+ },
60
+ },
61
+ {
62
+ name: "afd_fix",
63
+ description: "Generate and apply an auto-validator script to '.afd/validators/' for a known failure pattern. Use after afd_suggest to protect a file from recurring mistakes.",
64
+ inputSchema: {
65
+ type: "object" as const,
66
+ properties: {
67
+ file_path: { type: "string" as const, description: "Workspace-relative path of the file to protect (e.g. 'src/core/db.ts')" },
68
+ failure_type: { type: "string" as const, enum: ["corruption", "deletion"], description: "Category of failure: 'corruption' (bad content) or 'deletion' (file removed). Default: 'corruption'" },
69
+ mistake_type: { type: "string" as const, description: "Optional label from afd_suggest output embedded in the generated validator as a comment" },
70
+ },
71
+ required: ["file_path"],
72
+ },
73
+ },
74
+ {
75
+ name: "afd_sync",
76
+ description: "Push local antibodies to a remote vaccine store, or pull antibodies from a remote store into the running daemon. Wraps the bidirectional HTTP sync protocol.",
77
+ inputSchema: {
78
+ type: "object" as const,
79
+ properties: {
80
+ remote: { type: "string" as const, description: "Remote vaccine store URL (http:// or https://)" },
81
+ direction: { type: "string" as const, enum: ["push", "pull", "both"], description: "Sync direction: 'push', 'pull', or 'both' (default: 'both')" },
82
+ },
83
+ required: ["remote"],
84
+ },
85
+ },
86
+ {
87
+ name: "afd_read",
88
+ description: "Smart file reader that saves tokens. Files <10KB return full content. Files >=10KB return a structural hologram instead. Use 'startLine'/'endLine' for line ranges, or 'symbols' for pinpoint symbol extraction (L1 mode).",
89
+ inputSchema: {
90
+ type: "object" as const,
91
+ properties: {
92
+ file: { type: "string" as const, description: "Relative or absolute file path" },
93
+ startLine: { type: "number" as const, description: "Start line number (1-based, inclusive). Use with endLine to read a specific range of large files." },
94
+ endLine: { type: "number" as const, description: "End line number (1-based, inclusive)." },
95
+ symbols: {
96
+ type: "array" as const,
97
+ items: { type: "string" as const },
98
+ description: "L1 mode: extract only these named symbols (interfaces, types, classes, functions). Returns only matching declarations, maximizing token savings.",
99
+ },
100
+ },
101
+ required: ["file"],
102
+ },
103
+ },
104
+ ];
105
+
106
+ function mcpResponse(id: unknown, result: unknown) {
107
+ process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id, result }) + "\n");
108
+ }
109
+
110
+ function mcpError(id: unknown, code: number, message: string) {
111
+ process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } }) + "\n");
112
+ }
113
+
114
+ /** 동적으로 생성된 afd://history/{path} URI 추적 (list_changed 알림용) */
115
+ const _knownHistoryPaths = new Set<string>();
116
+
117
+
118
+ async function handleMcpRequest(ctx: DaemonContext, req: { id?: unknown; method?: string; params?: Record<string, unknown> }) {
119
+ const { id, method, params } = req;
120
+
121
+ if (method === "initialize") {
122
+ mcpResponse(id, {
123
+ protocolVersion: "2024-11-05",
124
+ capabilities: { tools: {}, resources: { subscribe: true } },
125
+ serverInfo: { name: "afd", version: APP_VERSION },
126
+ });
127
+ return;
128
+ }
129
+
130
+ if (method === "notifications/initialized") return;
131
+
132
+ if (method === "tools/list") {
133
+ mcpResponse(id, { tools: mcpToolDefs });
134
+ return;
135
+ }
136
+
137
+ if (method === "resources/list") {
138
+ mcpResponse(id, {
139
+ resources: [
140
+ {
141
+ uri: "afd://workspace-map",
142
+ name: "Workspace Map",
143
+ description: "Project file tree with export signatures. Read this first to understand the codebase structure before reading individual files.",
144
+ mimeType: "text/plain",
145
+ },
146
+ {
147
+ uri: "afd://antibodies",
148
+ name: "Antibody List",
149
+ description: "Live list of all active antibodies in the daemon's immune system. Each entry includes id, pattern_type, file_target, scope, version, and dormant status.",
150
+ mimeType: "application/json",
151
+ },
152
+ {
153
+ uri: "afd://quarantine",
154
+ name: "Quarantine Log",
155
+ description: "격리된 파일 목록. 패턴 격리(isolatePattern) 이벤트가 발생할 때 업데이트됩니다.",
156
+ mimeType: "application/json",
157
+ },
158
+ {
159
+ uri: "afd://events",
160
+ name: "SEAM Event Stream",
161
+ description: "실시간 S.E.A.M 이벤트 스트림. 구독 후 notifications/resources/updated 알림을 받습니다.",
162
+ mimeType: "application/json",
163
+ },
164
+ {
165
+ uri: "afd://history/{path}",
166
+ name: "File Event History",
167
+ description: "URI 템플릿: 특정 파일 경로의 이벤트 히스토리. 예: afd://history/src/core/db.ts",
168
+ mimeType: "application/json",
169
+ },
170
+ ],
171
+ });
172
+ return;
173
+ }
174
+
175
+ if (method === "resources/subscribe") {
176
+ const uri = params?.uri as string | undefined;
177
+ if (!uri) { mcpError(id, -32602, "Missing required parameter: uri"); return; }
178
+ subscriptionManager.subscribe(uri);
179
+ mcpResponse(id, {});
180
+ return;
181
+ }
182
+
183
+ if (method === "resources/unsubscribe") {
184
+ const uri = params?.uri as string | undefined;
185
+ if (!uri) { mcpError(id, -32602, "Missing required parameter: uri"); return; }
186
+ subscriptionManager.unsubscribe(uri);
187
+ mcpResponse(id, {});
188
+ return;
189
+ }
190
+
191
+ if (method === "resources/read") {
192
+ const uri = params?.uri as string | undefined;
193
+ if (uri === "afd://workspace-map") {
194
+ const mapText = ctx.getWorkspaceMap();
195
+ const { totalProjectBytes, mapBytes } = ctx.getWorkspaceMapStats();
196
+ if (totalProjectBytes > 0) {
197
+ ctx.persistCtxSavings('wsmap', totalProjectBytes, Math.max(0, totalProjectBytes - mapBytes));
198
+ }
199
+ mcpResponse(id, {
200
+ contents: [{ uri: "afd://workspace-map", mimeType: "text/plain", text: mapText }],
201
+ });
202
+ return;
203
+ }
204
+ if (uri === "afd://antibodies") {
205
+ const rows = ctx.db
206
+ .query<
207
+ { id: string; pattern_type: string; file_target: string; patch_op: string; dormant: number; scope: string; ab_version: number; updated_at: string; created_at: string },
208
+ []
209
+ >(
210
+ "SELECT id, pattern_type, file_target, patch_op, dormant, scope, ab_version, updated_at, created_at FROM antibodies ORDER BY updated_at DESC"
211
+ )
212
+ .all();
213
+ const antibodies = rows.map((r) => ({
214
+ id: r.id,
215
+ pattern_type: r.pattern_type,
216
+ file_target: r.file_target,
217
+ patch_op: r.patch_op,
218
+ dormant: r.dormant === 1,
219
+ scope: r.scope,
220
+ ab_version: r.ab_version,
221
+ updated_at: r.updated_at,
222
+ created_at: r.created_at,
223
+ }));
224
+ mcpResponse(id, {
225
+ contents: [{ uri: "afd://antibodies", mimeType: "application/json", text: JSON.stringify({ total: antibodies.length, antibodies }, null, 2) }],
226
+ });
227
+ return;
228
+ }
229
+ if (uri === "afd://quarantine") {
230
+ const entries = ctx.state.quarantineLog ?? [];
231
+ mcpResponse(id, {
232
+ contents: [{ uri: "afd://quarantine", mimeType: "application/json", text: JSON.stringify({ total: entries.length, entries }, null, 2) }],
233
+ });
234
+ return;
235
+ }
236
+ if (uri === "afd://events") {
237
+ const entries = ctx.state.seamEventLog ?? [];
238
+ mcpResponse(id, {
239
+ contents: [{ uri: "afd://events", mimeType: "application/json", text: JSON.stringify({ total: entries.length, events: entries }, null, 2) }],
240
+ });
241
+ return;
242
+ }
243
+ // URI 템플릿: afd://history/{path}
244
+ if (uri && uri.startsWith("afd://history/")) {
245
+ const filePath = uri.slice("afd://history/".length);
246
+ if (!filePath) { mcpError(id, -32602, "history URI에 파일 경로가 필요합니다"); return; }
247
+ // 새 경로 처음 조회 시 list_changed 알림 발송
248
+ if (!_knownHistoryPaths.has(filePath)) {
249
+ _knownHistoryPaths.add(filePath);
250
+ subscriptionManager.dispatchListChanged();
251
+ }
252
+ const rows = ctx.db.query(
253
+ `SELECT type, path, timestamp FROM events WHERE path LIKE ? ORDER BY timestamp DESC LIMIT 50`
254
+ ).all?.() ?? [];
255
+ const filtered = (rows as { type: string; path: string; timestamp: number }[])
256
+ .filter(r => r.path.replace(/\\/g, "/").includes(filePath));
257
+ mcpResponse(id, {
258
+ contents: [{ uri, mimeType: "application/json", text: JSON.stringify({ path: filePath, total: filtered.length, events: filtered }, null, 2) }],
259
+ });
260
+ return;
261
+ }
262
+ mcpError(id, -32602, `Unknown resource: ${uri}`);
263
+ return;
264
+ }
265
+
266
+ if (method === "tools/call") {
267
+ const toolName = params?.name as string | undefined;
268
+ const args = (params?.arguments ?? {}) as Record<string, unknown>;
269
+
270
+ // Track MCP tool call
271
+ if (toolName) {
272
+ try { ctx.insertTelemetry.run("mcp", toolName, null, null, Date.now()); } catch { /* crash-only */ }
273
+ }
274
+
275
+ if (toolName === "afd_diagnose") {
276
+ const raw = args.raw === true;
277
+ const known = ctx.antibodyIds.all().map(r => r.id);
278
+ const result = diagnose(known, { raw });
279
+
280
+ const PROACTIVE_THRESHOLD = 5 * 1024;
281
+ const enriched = await Promise.all(result.symptoms.map(async (s: { fileTarget: string; [k: string]: unknown }) => {
282
+ const snapshot = ctx.state.fileSnapshots.get(s.fileTarget)
283
+ ?? ctx.state.fileSnapshots.get(s.fileTarget.replace(/\//g, "\\"));
284
+ if (!snapshot) return s;
285
+ if (snapshot.length > PROACTIVE_THRESHOLD) {
286
+ const hologram = await ctx.safeHologram(s.fileTarget, snapshot);
287
+ return { ...s, hologram, contextNote: `File is ${(snapshot.length / 1024).toFixed(1)}KB — hologram provided to save tokens.` };
288
+ }
289
+ return { ...s, context: snapshot };
290
+ }));
291
+
292
+ mcpResponse(id, {
293
+ content: [{ type: "text", text: JSON.stringify({ ...result, symptoms: enriched }, null, 2), cache_control: { type: "ephemeral" } }],
294
+ });
295
+ return;
296
+ }
297
+
298
+ if (toolName === "afd_score") {
299
+ const uptime = Math.floor((Date.now() - ctx.state.startedAt) / 1000);
300
+ const eventCount = ctx.db.query("SELECT COUNT(*) as cnt FROM events").get() as { cnt: number };
301
+ const abCount = ctx.countAntibodies.get() as { cnt: number };
302
+ const hs = ctx.state.hologramStats;
303
+ mcpResponse(id, {
304
+ content: [{ type: "text", text: JSON.stringify({
305
+ uptime,
306
+ filesDetected: ctx.state.filesDetected,
307
+ totalEvents: eventCount.cnt,
308
+ antibodies: abCount.cnt,
309
+ autoHealed: ctx.state.autoHealCount,
310
+ hologramRequests: hs.totalRequests,
311
+ hologramSavings: hs.totalOriginalChars > 0
312
+ ? `${Math.round((hs.totalOriginalChars - hs.totalHologramChars) / hs.totalOriginalChars * 100)}%`
313
+ : "0%",
314
+ suppression: {
315
+ massEventsSkipped: ctx.state.suppressionSkippedCount,
316
+ dormantTransitions: ctx.state.dormantTransitions.length,
317
+ },
318
+ }, null, 2), cache_control: { type: "ephemeral" } }],
319
+ });
320
+ return;
321
+ }
322
+
323
+ if (toolName === "afd_hologram") {
324
+ const file = args.file as string | undefined;
325
+ if (!file) { mcpError(id, -32602, "Missing required argument: file"); return; }
326
+ try {
327
+ const absPath = resolve(file);
328
+ _assertWs(absPath, ctx.ws.root);
329
+ const source = readFileSync(absPath, "utf-8");
330
+ const contextFile = args.contextFile as string | undefined;
331
+ const diffOnly = args.diffOnly === true;
332
+ const opts: Record<string, unknown> = {};
333
+ if (contextFile) opts.contextFile = resolve(contextFile);
334
+ if (diffOnly) opts.diffOnly = true;
335
+ const result = await generateHologram(file, source, Object.keys(opts).length > 0 ? opts as { contextFile?: string; diffOnly?: boolean } : undefined);
336
+ ctx.persistHologramStats(result.originalLength, result.hologramLength);
337
+ mcpResponse(id, {
338
+ content: [{ type: "text", text: result.hologram, cache_control: { type: "ephemeral" } }],
339
+ });
340
+ } catch (err: unknown) {
341
+ mcpError(id, -32603, err instanceof Error ? err.message : String(err));
342
+ }
343
+ return;
344
+ }
345
+
346
+ if (toolName === "afd_read") {
347
+ const file = args.file as string | undefined;
348
+ if (!file) { mcpError(id, -32602, "Missing required argument: file"); return; }
349
+ try {
350
+ const AFD_READ_THRESHOLD = 10 * 1024;
351
+ const absPath = resolve(file);
352
+ _assertWs(absPath, ctx.ws.root);
353
+ const source = readFileSync(absPath, "utf-8");
354
+ const sizeKB = (source.length / 1024).toFixed(1);
355
+ const rawStart = args.startLine;
356
+ const rawEnd = args.endLine;
357
+ const startLine = typeof rawStart === "number" && Number.isFinite(rawStart) ? rawStart : undefined;
358
+ const endLine = typeof rawEnd === "number" && Number.isFinite(rawEnd) ? rawEnd : undefined;
359
+ const rawSymbols = args.symbols;
360
+ const symbols = Array.isArray(rawSymbols)
361
+ ? rawSymbols.filter((s): s is string => typeof s === "string")
362
+ : undefined;
363
+
364
+ if (startLine !== undefined && endLine !== undefined) {
365
+ const lines = source.split("\n");
366
+ const start = Math.max(1, Math.floor(startLine)) - 1;
367
+ const end = Math.min(lines.length, Math.floor(endLine));
368
+ const slice = lines.slice(start, end).map((l, i) => `${start + i + 1}\t${l}`).join("\n");
369
+ ctx.persistCtxSavings('pinpoint', source.length, Math.max(0, source.length - slice.length));
370
+ mcpResponse(id, {
371
+ content: [{ type: "text", text: `// ${file} lines ${start + 1}-${end} (${sizeKB}KB total)\n${slice}`, cache_control: { type: "ephemeral" } }],
372
+ });
373
+ return;
374
+ }
375
+
376
+ // L1 Symbol Extraction: always use hologram engine regardless of file size
377
+ if (symbols && symbols.length > 0) {
378
+ const result = await generateHologram(file, source, { symbols });
379
+ ctx.persistHologramStats(result.originalLength, result.hologramLength);
380
+ ctx.persistCtxSavings('pinpoint', result.originalLength, Math.max(0, result.originalLength - result.hologramLength));
381
+ const header = `// [afd L1] ${file} — symbols: [${symbols.join(", ")}]\n\n`;
382
+ mcpResponse(id, {
383
+ content: [{ type: "text", text: header + result.hologram, cache_control: { type: "ephemeral" } }],
384
+ });
385
+ return;
386
+ }
387
+
388
+ if (source.length < AFD_READ_THRESHOLD) {
389
+ // Record even full-content reads so the denominator reflects ALL afd_read traffic,
390
+ // not just compressed files. This makes the savings % honest.
391
+ ctx.persistHologramStats(source.length, source.length);
392
+ mcpResponse(id, {
393
+ content: [{ type: "text", text: source, cache_control: { type: "ephemeral" } }],
394
+ });
395
+ return;
396
+ }
397
+
398
+ const result = await generateHologram(file, source);
399
+ ctx.persistHologramStats(result.originalLength, result.hologramLength);
400
+ const savings = Math.round((1 - result.hologramLength / result.originalLength) * 100);
401
+ const header = `⚠️ [afd-Optimizer]: File is ${sizeKB}KB. To save tokens, only the structural hologram is provided (${savings}% reduction).\nUse afd_read with startLine/endLine to read specific sections at full fidelity.\nTotal lines: ${source.split("\n").length}\n\n`;
402
+ mcpResponse(id, {
403
+ content: [{ type: "text", text: header + result.hologram, cache_control: { type: "ephemeral" } }],
404
+ });
405
+ } catch (err: unknown) {
406
+ mcpError(id, -32603, err instanceof Error ? err.message : String(err));
407
+ }
408
+ return;
409
+ }
410
+
411
+ if (toolName === "afd_suggest") {
412
+ try {
413
+ const suggestions = suggestRules(ctx.db as unknown as Database, {
414
+ days: typeof args.days === "number" ? args.days : undefined,
415
+ minFrequency: typeof args.min_frequency === "number" ? args.min_frequency : undefined,
416
+ limit: typeof args.limit === "number" ? args.limit : undefined,
417
+ });
418
+ mcpResponse(id, {
419
+ content: [{ type: "text", text: JSON.stringify(suggestions, null, 2), cache_control: { type: "ephemeral" } }],
420
+ });
421
+ } catch (err: unknown) {
422
+ mcpError(id, -32603, err instanceof Error ? err.message : String(err));
423
+ }
424
+ return;
425
+ }
426
+
427
+ if (toolName === "afd_fix") {
428
+ const filePath = args.file_path as string | undefined;
429
+ if (!filePath) { mcpError(id, -32602, "Missing required argument: file_path"); return; }
430
+ const failureType = (args.failure_type === "deletion" ? "deletion" : "corruption") as "corruption" | "deletion";
431
+ const mistakeType = typeof args.mistake_type === "string" ? args.mistake_type : "";
432
+ try {
433
+ const result = generateValidator({
434
+ failureType,
435
+ originalPath: filePath,
436
+ corruptedContent: mistakeType ? `// mistake: ${mistakeType}` : "",
437
+ restoredContent: null,
438
+ });
439
+ mcpResponse(id, {
440
+ content: [{ type: "text", text: JSON.stringify({
441
+ success: true,
442
+ filename: result.filename,
443
+ written: result.written,
444
+ reason: result.reason,
445
+ validatorPath: `.afd/validators/${result.filename}`,
446
+ }, null, 2) }],
447
+ });
448
+ } catch (err: unknown) {
449
+ mcpError(id, -32603, err instanceof Error ? err.message : String(err));
450
+ }
451
+ return;
452
+ }
453
+
454
+ if (toolName === "afd_sync") {
455
+ const remote = args.remote as string | undefined;
456
+ if (!remote) { mcpError(id, -32602, "Missing required argument: remote"); return; }
457
+ const direction = (args.direction === "push" || args.direction === "pull") ? args.direction : "both";
458
+ const TIMEOUT_MS = 10_000;
459
+
460
+ // Validate URL
461
+ let remoteUrl: string;
462
+ try {
463
+ const u = new URL(remote);
464
+ if (u.protocol !== "http:" && u.protocol !== "https:") throw new Error("protocol must be http or https");
465
+ remoteUrl = u.toString();
466
+ } catch (err: unknown) {
467
+ mcpError(id, -32602, `Invalid remote URL: ${err instanceof Error ? err.message : String(err)}`);
468
+ return;
469
+ }
470
+
471
+ const syncResults: { direction: string; status: string; detail?: string }[] = [];
472
+
473
+ try {
474
+ // PULL: GET remote payload → POST each antibody to /antibodies/learn
475
+ if (direction === "pull" || direction === "both") {
476
+ const pullRes = await fetch(remoteUrl, {
477
+ method: "GET",
478
+ headers: { "Accept": "application/json", "User-Agent": "afd-sync/1.8" },
479
+ signal: AbortSignal.timeout(TIMEOUT_MS),
480
+ });
481
+ if (!pullRes.ok) {
482
+ syncResults.push({ direction: "pull", status: "error", detail: `HTTP ${pullRes.status} ${pullRes.statusText}` });
483
+ } else {
484
+ const json = await pullRes.json() as { antibodies?: unknown[] };
485
+ if (!json || !Array.isArray(json.antibodies)) {
486
+ syncResults.push({ direction: "pull", status: "error", detail: "Invalid response: missing antibodies array" });
487
+ } else {
488
+ let learned = 0;
489
+ for (const ab of json.antibodies as Record<string, unknown>[]) {
490
+ try {
491
+ const learnRes = await fetch(`http://127.0.0.1:${ctx.port}/antibodies/learn`, {
492
+ method: "POST",
493
+ headers: { "Content-Type": "application/json" },
494
+ body: JSON.stringify(ab),
495
+ signal: AbortSignal.timeout(2000),
496
+ });
497
+ if (learnRes.ok) learned++;
498
+ } catch { /* skip individual failures */ }
499
+ }
500
+ syncResults.push({ direction: "pull", status: "ok", detail: `${learned}/${json.antibodies.length} antibodies learned` });
501
+ }
502
+ }
503
+ }
504
+
505
+ // PUSH: fetch local payload via /sync → POST to remote
506
+ if (direction === "push" || direction === "both") {
507
+ const localRes = await fetch(`http://127.0.0.1:${ctx.port}/sync`, {
508
+ signal: AbortSignal.timeout(2000),
509
+ });
510
+ if (!localRes.ok) {
511
+ syncResults.push({ direction: "push", status: "error", detail: "Failed to fetch local payload from daemon" });
512
+ } else {
513
+ const localPayload = await localRes.json() as { antibodyCount?: number };
514
+ if ((localPayload.antibodyCount ?? 0) === 0) {
515
+ syncResults.push({ direction: "push", status: "skip", detail: "No local antibodies to push" });
516
+ } else {
517
+ const pushRes = await fetch(remoteUrl, {
518
+ method: "POST",
519
+ headers: { "Content-Type": "application/json", "User-Agent": "afd-sync/1.8" },
520
+ body: JSON.stringify(localPayload),
521
+ signal: AbortSignal.timeout(TIMEOUT_MS),
522
+ });
523
+ if (!pushRes.ok) {
524
+ syncResults.push({ direction: "push", status: "error", detail: `HTTP ${pushRes.status} ${pushRes.statusText}` });
525
+ } else {
526
+ syncResults.push({ direction: "push", status: "ok", detail: `${localPayload.antibodyCount} antibodies pushed` });
527
+ }
528
+ }
529
+ }
530
+ }
531
+
532
+ mcpResponse(id, {
533
+ content: [{ type: "text", text: JSON.stringify({ remote: remoteUrl, results: syncResults }, null, 2) }],
534
+ });
535
+ } catch (err: unknown) {
536
+ mcpError(id, -32603, err instanceof Error ? err.message : String(err));
537
+ }
538
+ return;
539
+ }
540
+
541
+ mcpError(id, -32601, `Unknown tool: ${toolName}`);
542
+ return;
543
+ }
544
+
545
+ mcpError(id, -32601, `Unknown method: ${method}`);
546
+ }
547
+
548
+ /** Start MCP stdio mode — blocks until stdin closes */
549
+ export function startMcpStdio(ctx: DaemonContext) {
550
+ subscriptionManager.enable();
551
+ console.error("[afd] MCP stdio mode — awaiting JSON-RPC on stdin");
552
+
553
+ (async () => {
554
+ const decoder = new TextDecoder();
555
+ let buffer = "";
556
+ for await (const chunk of Bun.stdin.stream()) {
557
+ buffer += decoder.decode(chunk);
558
+ if (buffer.length > MCP_MAX_BUFFER) {
559
+ console.error("[afd] MCP buffer overflow — dropping buffer");
560
+ buffer = "";
561
+ continue;
562
+ }
563
+ let newlineIdx: number;
564
+ while ((newlineIdx = buffer.indexOf("\n")) !== -1) {
565
+ const line = buffer.slice(0, newlineIdx).trim();
566
+ buffer = buffer.slice(newlineIdx + 1);
567
+ if (!line) continue;
568
+ try { handleMcpRequest(ctx, JSON.parse(line)); } catch { /* crash-only */ }
569
+ }
570
+ }
571
+ })().catch((err) => {
572
+ console.error("[afd] MCP stdin loop error:", err instanceof Error ? err.message : String(err));
573
+ process.exit(1);
574
+ });
575
+ }