autonomous-flow-daemon 1.1.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 (75) hide show
  1. package/CHANGELOG.md +85 -46
  2. package/LICENSE +21 -21
  3. package/README-ko.md +282 -0
  4. package/README.md +282 -337
  5. package/mcp-config.json +10 -10
  6. package/package.json +14 -6
  7. package/src/adapters/index.ts +370 -159
  8. package/src/cli.ts +162 -57
  9. package/src/commands/benchmark.ts +187 -0
  10. package/src/commands/correlate.ts +180 -0
  11. package/src/commands/dashboard.ts +404 -0
  12. package/src/commands/diagnose.ts +56 -14
  13. package/src/commands/doctor.ts +243 -0
  14. package/src/commands/evolution.ts +190 -0
  15. package/src/commands/fix.ts +158 -138
  16. package/src/commands/hooks.ts +136 -0
  17. package/src/commands/lang.ts +41 -41
  18. package/src/commands/mcp.ts +129 -0
  19. package/src/commands/plugin.ts +110 -0
  20. package/src/commands/restart.ts +14 -0
  21. package/src/commands/score.ts +276 -208
  22. package/src/commands/start.ts +155 -96
  23. package/src/commands/stats.ts +103 -0
  24. package/src/commands/status.ts +157 -0
  25. package/src/commands/stop.ts +68 -49
  26. package/src/commands/suggest.ts +211 -0
  27. package/src/commands/sync.ts +567 -21
  28. package/src/commands/vaccine.ts +177 -0
  29. package/src/constants.ts +32 -8
  30. package/src/core/boast.ts +280 -265
  31. package/src/core/config.ts +49 -49
  32. package/src/core/correlation-engine.ts +265 -0
  33. package/src/core/db.ts +145 -46
  34. package/src/core/discovery.ts +65 -65
  35. package/src/core/evolution.ts +215 -0
  36. package/src/core/federation.ts +129 -0
  37. package/src/core/hologram/engine.ts +71 -0
  38. package/src/core/hologram/fallback.ts +11 -0
  39. package/src/core/hologram/go-extractor.ts +203 -0
  40. package/src/core/hologram/incremental.ts +227 -0
  41. package/src/core/hologram/py-extractor.ts +132 -0
  42. package/src/core/hologram/rust-extractor.ts +244 -0
  43. package/src/core/hologram/ts-extractor.ts +406 -0
  44. package/src/core/hologram/types.ts +27 -0
  45. package/src/core/hologram.ts +73 -243
  46. package/src/core/hook-manager.ts +259 -0
  47. package/src/core/i18n/messages.ts +309 -266
  48. package/src/core/immune.ts +8 -123
  49. package/src/core/locale.ts +88 -88
  50. package/src/core/log-rotate.ts +33 -0
  51. package/src/core/log-utils.ts +38 -0
  52. package/src/core/lru-map.ts +61 -0
  53. package/src/core/notify.ts +74 -66
  54. package/src/core/plugin-manager.ts +225 -0
  55. package/src/core/rule-engine.ts +287 -0
  56. package/src/core/rule-suggestion.ts +127 -0
  57. package/src/core/semantic-diff.ts +432 -0
  58. package/src/core/telemetry.ts +94 -0
  59. package/src/core/vaccine-registry.ts +212 -0
  60. package/src/core/validator-generator.ts +224 -0
  61. package/src/core/workspace.ts +28 -0
  62. package/src/core/yaml-minimal.ts +176 -0
  63. package/src/daemon/client.ts +78 -37
  64. package/src/daemon/event-batcher.ts +108 -0
  65. package/src/daemon/guards.ts +13 -0
  66. package/src/daemon/http-routes.ts +376 -0
  67. package/src/daemon/mcp-handler.ts +575 -0
  68. package/src/daemon/mcp-subscriptions.ts +81 -0
  69. package/src/daemon/mesh.ts +51 -0
  70. package/src/daemon/server.ts +655 -504
  71. package/src/daemon/types.ts +121 -0
  72. package/src/daemon/workspace-map.ts +104 -0
  73. package/src/platform.ts +60 -39
  74. package/src/version.ts +15 -0
  75. package/README.ko.md +0 -306
@@ -1,37 +1,78 @@
1
- import { readFileSync, existsSync } from "fs";
2
- import { PID_FILE, PORT_FILE } from "../constants";
3
-
4
- export interface DaemonInfo {
5
- pid: number;
6
- port: number;
7
- }
8
-
9
- export function getDaemonInfo(): DaemonInfo | null {
10
- if (!existsSync(PID_FILE) || !existsSync(PORT_FILE)) return null;
11
- const pid = parseInt(readFileSync(PID_FILE, "utf-8").trim(), 10);
12
- const port = parseInt(readFileSync(PORT_FILE, "utf-8").trim(), 10);
13
- if (isNaN(pid) || isNaN(port)) return null;
14
- return { pid, port };
15
- }
16
-
17
- export async function isDaemonAlive(info: DaemonInfo): Promise<boolean> {
18
- try {
19
- const res = await fetch(`http://127.0.0.1:${info.port}/health`, {
20
- signal: AbortSignal.timeout(2000),
21
- });
22
- const data = await res.json() as { status: string; pid: number };
23
- return data.status === "alive" && data.pid === info.pid;
24
- } catch {
25
- return false;
26
- }
27
- }
28
-
29
- export async function daemonRequest<T = unknown>(path: string): Promise<T> {
30
- const info = getDaemonInfo();
31
- if (!info) throw new Error("Daemon not running. Run `afd start` first.");
32
- const res = await fetch(`http://127.0.0.1:${info.port}${path}`, {
33
- signal: AbortSignal.timeout(5000),
34
- });
35
- if (!res.ok) throw new Error(`Daemon returned ${res.status}`);
36
- return res.json() as T;
37
- }
1
+ import { readFileSync, existsSync, unlinkSync } from "fs";
2
+ import { resolveWorkspacePaths } from "../constants";
3
+ import type { MeshEntry } from "./mesh";
4
+
5
+ export interface DaemonInfo {
6
+ pid: number;
7
+ port: number;
8
+ workspace: string;
9
+ }
10
+
11
+ /**
12
+ * Read daemon PID/port from the workspace-local `.afd/` directory.
13
+ * Walks up from cwd to find the workspace root, so CLI commands
14
+ * work correctly even when invoked from subdirectories.
15
+ *
16
+ * If PID file exists but process is dead, cleans up stale files.
17
+ */
18
+ export function getDaemonInfo(): DaemonInfo | null {
19
+ const paths = resolveWorkspacePaths();
20
+ if (!existsSync(paths.pidFile) || !existsSync(paths.portFile)) return null;
21
+
22
+ const pid = parseInt(readFileSync(paths.pidFile, "utf-8").trim(), 10);
23
+ const port = parseInt(readFileSync(paths.portFile, "utf-8").trim(), 10);
24
+ if (isNaN(pid) || isNaN(port)) return null;
25
+
26
+ // Stale PID detection: check if process is alive at OS level
27
+ if (!isProcessAlive(pid)) {
28
+ try { unlinkSync(paths.pidFile); } catch {}
29
+ try { unlinkSync(paths.portFile); } catch {}
30
+ return null;
31
+ }
32
+
33
+ return { pid, port, workspace: paths.root };
34
+ }
35
+
36
+ /** Check if a process exists at OS level (does not verify it's afd) */
37
+ function isProcessAlive(pid: number): boolean {
38
+ try {
39
+ process.kill(pid, 0); // signal 0 = existence check
40
+ return true;
41
+ } catch {
42
+ return false;
43
+ }
44
+ }
45
+
46
+ export async function isDaemonAlive(info: DaemonInfo): Promise<boolean> {
47
+ try {
48
+ const res = await fetch(`http://127.0.0.1:${info.port}/health`, {
49
+ signal: AbortSignal.timeout(2000),
50
+ });
51
+ const data = await res.json() as { status: string; pid: number };
52
+ return data.status === "alive" && data.pid === info.pid;
53
+ } catch {
54
+ return false;
55
+ }
56
+ }
57
+
58
+ export async function daemonRequest<T = unknown>(path: string, method?: "GET"): Promise<T>;
59
+ export async function daemonRequest<T = unknown>(path: string, method: "POST", body: unknown): Promise<T>;
60
+ export async function daemonRequest<T = unknown>(path: string, method: "GET" | "POST" = "GET", body?: unknown): Promise<T> {
61
+ const info = getDaemonInfo();
62
+ if (!info) throw new Error("Daemon not running. Run `afd start` first.");
63
+ const init: RequestInit = {
64
+ method,
65
+ signal: AbortSignal.timeout(5000),
66
+ };
67
+ if (method === "POST" && body !== undefined) {
68
+ init.body = JSON.stringify(body);
69
+ init.headers = { "Content-Type": "application/json" };
70
+ }
71
+ const res = await fetch(`http://127.0.0.1:${info.port}${path}`, init);
72
+ if (!res.ok) throw new Error(`Daemon returned ${res.status}`);
73
+ return res.json() as T;
74
+ }
75
+
76
+ export async function getMeshPeers(): Promise<MeshEntry[]> {
77
+ return daemonRequest<MeshEntry[]>("/mesh/peers");
78
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * EventBatcher — Adaptive debounce for file watcher events.
3
+ *
4
+ * Strategy:
5
+ * - Immune file changes → fast-path (immediate, no debounce)
6
+ * - All other events → 300ms debounce batch
7
+ * - Deduplicates: same file multiple events → last event wins
8
+ * - Cancels out: add + unlink on same file → removed from batch
9
+ */
10
+
11
+ export interface BatchedEvent {
12
+ event: string;
13
+ path: string;
14
+ timestamp: number;
15
+ }
16
+
17
+ export interface EventBatcherOptions {
18
+ /** Debounce window in ms (default: 300) */
19
+ debounceMs?: number;
20
+ /** Check if a path is an immune-protected file (fast-path) */
21
+ isImmunePath?: (path: string) => boolean;
22
+ /** Handler for fast-path (immediate) events */
23
+ onImmediate: (event: string, path: string) => void;
24
+ /** Handler for batched events (fired after debounce window) */
25
+ onBatch: (events: BatchedEvent[]) => void;
26
+ }
27
+
28
+ export class EventBatcher {
29
+ private readonly debounceMs: number;
30
+ private readonly isImmunePath: (path: string) => boolean;
31
+ private readonly onImmediate: (event: string, path: string) => void;
32
+ private readonly onBatch: (events: BatchedEvent[]) => void;
33
+
34
+ private pendingEvents = new Map<string, BatchedEvent>();
35
+ private timer: ReturnType<typeof setTimeout> | null = null;
36
+ private batchCount = 0;
37
+
38
+ constructor(options: EventBatcherOptions) {
39
+ this.debounceMs = options.debounceMs ?? 300;
40
+ this.isImmunePath = options.isImmunePath ?? (() => false);
41
+ this.onImmediate = options.onImmediate;
42
+ this.onBatch = options.onBatch;
43
+ }
44
+
45
+ /** Push a new file event. Returns true if handled immediately (fast-path). */
46
+ push(event: string, path: string): boolean {
47
+ // Fast-path: immune file change → immediate processing for auto-heal responsiveness
48
+ if (event === "change" && this.isImmunePath(path)) {
49
+ this.onImmediate(event, path);
50
+ return true;
51
+ }
52
+
53
+ const now = Date.now();
54
+ const existing = this.pendingEvents.get(path);
55
+
56
+ // Cancel out: add + unlink on same file
57
+ if (existing) {
58
+ if ((existing.event === "add" && event === "unlink") ||
59
+ (existing.event === "unlink" && event === "add")) {
60
+ this.pendingEvents.delete(path);
61
+ return false;
62
+ }
63
+ }
64
+
65
+ // Last event wins for same file
66
+ this.pendingEvents.set(path, { event, path, timestamp: now });
67
+
68
+ // Start/reset debounce timer
69
+ if (this.timer) clearTimeout(this.timer);
70
+ this.timer = setTimeout(() => this.flush(), this.debounceMs);
71
+
72
+ return false;
73
+ }
74
+
75
+ /** Flush all pending events immediately */
76
+ flush(): void {
77
+ if (this.timer) {
78
+ clearTimeout(this.timer);
79
+ this.timer = null;
80
+ }
81
+
82
+ if (this.pendingEvents.size === 0) return;
83
+
84
+ const events = [...this.pendingEvents.values()];
85
+ this.pendingEvents.clear();
86
+ this.batchCount++;
87
+ this.onBatch(events);
88
+ }
89
+
90
+ /** Get the number of batches processed */
91
+ get totalBatches(): number {
92
+ return this.batchCount;
93
+ }
94
+
95
+ /** Get the number of pending events */
96
+ get pendingCount(): number {
97
+ return this.pendingEvents.size;
98
+ }
99
+
100
+ /** Destroy the batcher, clearing any pending timers */
101
+ destroy(): void {
102
+ if (this.timer) {
103
+ clearTimeout(this.timer);
104
+ this.timer = null;
105
+ }
106
+ this.pendingEvents.clear();
107
+ }
108
+ }
@@ -0,0 +1,13 @@
1
+ import { resolve } from "path";
2
+
3
+ /**
4
+ * Guard: reject resolved paths outside the workspace root.
5
+ * Throws if absPath is not under wsRoot.
6
+ */
7
+ export function assertInsideWorkspace(absPath: string, wsRoot: string): void {
8
+ const normalizedPath = absPath.replace(/\\/g, "/").toLowerCase();
9
+ const normalizedRoot = resolve(wsRoot).replace(/\\/g, "/").toLowerCase();
10
+ if (!normalizedPath.startsWith(normalizedRoot + "/") && normalizedPath !== normalizedRoot) {
11
+ throw new Error("Access denied: path outside workspace");
12
+ }
13
+ }
@@ -0,0 +1,376 @@
1
+ /**
2
+ * HTTP routes for daemon IPC — extracted from server.ts.
3
+ */
4
+
5
+ import { readFileSync, writeFileSync } from "fs";
6
+ import { resolve } from "path";
7
+ import { generateHologram } from "../core/hologram";
8
+ import { diagnose } from "../core/immune";
9
+ import type { PatchOp } from "../core/immune";
10
+ import { buildShiftSummary } from "../core/boast";
11
+ import { analyzeQuarantine, listQuarantine, evolve } from "../core/evolution";
12
+ import { MAX_SSE_CLIENTS } from "./types";
13
+ import { subscriptionManager } from "./mcp-subscriptions";
14
+ import type { DaemonContext } from "./types";
15
+ import { assertInsideWorkspace as _assertWs } from "./guards";
16
+ import { shouldAcceptRemote } from "../core/federation";
17
+ import { listMeshPeers } from "./mesh";
18
+
19
+ /** Create the HTTP fetch handler for Bun.serve */
20
+ export function createHttpHandler(ctx: DaemonContext, cleanup: () => void) {
21
+ return async function fetch(req: Request): Promise<Response> {
22
+ const url = new URL(req.url);
23
+
24
+ if (url.pathname === "/health") {
25
+ return Response.json({ status: "alive", pid: process.pid, workspace: ctx.ws.root, port: ctx.port });
26
+ }
27
+
28
+ if (url.pathname === "/mini-status") {
29
+ const last = ctx.state.autoHealLog.length > 0
30
+ ? ctx.state.autoHealLog[ctx.state.autoHealLog.length - 1].id
31
+ : null;
32
+ // Defense reasons from in-memory mistakeCache (not DB query — stays under 200ms)
33
+ const reasonSet = new Set<string>();
34
+ for (const entries of ctx.state.mistakeCache.values()) {
35
+ for (const e of entries) {
36
+ reasonSet.add(e.mistake_type);
37
+ if (reasonSet.size >= 3) break;
38
+ }
39
+ if (reasonSet.size >= 3) break;
40
+ }
41
+ const latestLog = ctx.state.autoHealLog.length > 0
42
+ ? ctx.state.autoHealLog[ctx.state.autoHealLog.length - 1]
43
+ : null;
44
+ const latestDefense = latestLog
45
+ ? { file: latestLog.file, healMs: latestLog.healMs, at: latestLog.at }
46
+ : null;
47
+ // session_saved_tokens_k: DB의 오늘치 stats 사용
48
+ // (MCP 프로세스와 HTTP 데몬이 별개 프로세스라 in-memory sessionOriginalChars는 항상 0)
49
+ const todayStr = ctx.today();
50
+ const dailyRows = ctx.getDailyAll.all();
51
+ const todayRow = dailyRows.find(r => r.date === todayStr);
52
+ const sessionSavedTokensK = todayRow
53
+ ? Math.round(Math.max(0, todayRow.original_chars - todayRow.hologram_chars) / 4 / 100) / 10
54
+ : 0;
55
+ return Response.json({
56
+ status: "ON",
57
+ healed_count: ctx.state.autoHealCount,
58
+ last_healed: last,
59
+ total_defenses: ctx.state.autoHealCount,
60
+ defense_reasons: [...reasonSet],
61
+ latest_defense: latestDefense,
62
+ saved_tokens_k: Math.round(Math.max(0, ctx.state.hologramStats.totalOriginalChars - ctx.state.hologramStats.totalHologramChars) / 4 / 100) / 10,
63
+ session_saved_tokens_k: sessionSavedTokensK,
64
+ });
65
+ }
66
+
67
+ // Track HTTP API calls as telemetry
68
+ const _apiPath = url.pathname.replace(/^\//, "");
69
+ if (["/hologram", "/read", "/diagnose", "/score", "/evolution", "/sync"].includes(url.pathname)) {
70
+ try { ctx.insertTelemetry.run("mcp", `http_${_apiPath}`, null, null, Date.now()); } catch { /* crash-only */ }
71
+ }
72
+
73
+ if (url.pathname === "/hologram") {
74
+ const file = url.searchParams.get("file");
75
+ if (!file) return Response.json({ error: "?file= required" }, { status: 400 });
76
+ try {
77
+ const absPath = resolve(file);
78
+ _assertWs(absPath, ctx.ws.root);
79
+ const source = readFileSync(absPath, "utf-8");
80
+ const contextFile = url.searchParams.get("contextFile");
81
+ const result = await generateHologram(file, source, contextFile ? { contextFile: resolve(contextFile) } : undefined);
82
+ ctx.persistHologramStats(result.originalLength, result.hologramLength);
83
+ return Response.json(result);
84
+ } catch (err: unknown) {
85
+ return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 404 });
86
+ }
87
+ }
88
+
89
+ if (url.pathname === "/workspace-map") {
90
+ const mapText = ctx.getWorkspaceMap();
91
+ const { totalProjectBytes, mapBytes } = ctx.getWorkspaceMapStats();
92
+ if (totalProjectBytes > 0) {
93
+ ctx.persistCtxSavings('wsmap', totalProjectBytes, Math.max(0, totalProjectBytes - mapBytes));
94
+ }
95
+ return new Response(mapText, { headers: { "Content-Type": "text/plain" } });
96
+ }
97
+
98
+ if (url.pathname === "/read") {
99
+ const file = url.searchParams.get("file");
100
+ if (!file) return Response.json({ error: "?file= required" }, { status: 400 });
101
+ try {
102
+ const AFD_READ_THRESHOLD = 10 * 1024;
103
+ const absPath = resolve(file);
104
+ _assertWs(absPath, ctx.ws.root);
105
+ const source = readFileSync(absPath, "utf-8");
106
+ const rawStart = parseInt(url.searchParams.get("startLine") ?? "", 10);
107
+ const rawEnd = parseInt(url.searchParams.get("endLine") ?? "", 10);
108
+
109
+ if (Number.isFinite(rawStart) && Number.isFinite(rawEnd)) {
110
+ const lines = source.split("\n");
111
+ const s = Math.max(1, rawStart) - 1;
112
+ const e = Math.min(lines.length, rawEnd);
113
+ return Response.json({ file, lines: lines.slice(s, e), range: [s + 1, e], totalLines: lines.length });
114
+ }
115
+ if (source.length < AFD_READ_THRESHOLD) {
116
+ return Response.json({ file, content: source, mode: "full" });
117
+ }
118
+ const result = await generateHologram(file, source);
119
+ ctx.persistHologramStats(result.originalLength, result.hologramLength);
120
+ return Response.json({ file, hologram: result.hologram, mode: "hologram", originalSize: source.length, totalLines: source.split("\n").length });
121
+ } catch (err: unknown) {
122
+ return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 404 });
123
+ }
124
+ }
125
+
126
+ if (url.pathname === "/mistake-history") {
127
+ const file = url.searchParams.get("file");
128
+ if (!file) return Response.json({ error: "?file= required" }, { status: 400 });
129
+ const normalizedFile = file.replace(/\\/g, "/");
130
+ const cached = ctx.state.mistakeCache.get(normalizedFile);
131
+ return Response.json({ mistakes: cached ?? [] });
132
+ }
133
+
134
+ if (url.pathname === "/diagnose") {
135
+ const raw = url.searchParams.get("raw") === "true";
136
+ const known = ctx.antibodyIds.all().map(r => r.id);
137
+ const result = diagnose(known, { raw });
138
+ const PROACTIVE_HOLOGRAM_THRESHOLD = 5 * 1024;
139
+
140
+ const enriched = await Promise.all(result.symptoms.map(async (s: { fileTarget: string; [k: string]: unknown }) => {
141
+ const snapshot = ctx.state.fileSnapshots.get(s.fileTarget)
142
+ ?? ctx.state.fileSnapshots.get(s.fileTarget.replace(/\//g, "\\"));
143
+ if (!snapshot) return s;
144
+ if (snapshot.length > PROACTIVE_HOLOGRAM_THRESHOLD) {
145
+ const hologram = await ctx.safeHologram(s.fileTarget, snapshot);
146
+ return {
147
+ ...s, hologram,
148
+ contextNote: `File is ${(snapshot.length / 1024).toFixed(1)}KB — hologram skeleton provided to save tokens (${Math.round((1 - hologram.length / snapshot.length) * 100)}% reduction).`,
149
+ };
150
+ }
151
+ return { ...s, context: snapshot };
152
+ }));
153
+ return Response.json({ ...result, symptoms: enriched });
154
+ }
155
+
156
+ if (url.pathname === "/antibodies") {
157
+ return Response.json({ antibodies: ctx.listAntibodies.all() });
158
+ }
159
+
160
+ if (url.pathname === "/antibodies/learn" && req.method === "POST") {
161
+ try {
162
+ const body = await req.json() as {
163
+ id: string; patternType: string; fileTarget: string; patches: PatchOp[];
164
+ scope?: string; version?: number; updatedAt?: string;
165
+ };
166
+ const scope = body.scope ?? "local";
167
+ const incomingVersion = body.version ?? 1;
168
+ const updatedAt = body.updatedAt ?? new Date().toISOString();
169
+ // Non-local antibodies are stored under their fqid to avoid collisions
170
+ const storageId = scope === "local" ? body.id : `${scope}/${body.id}`;
171
+
172
+ const incomingPatch = JSON.stringify(body.patches);
173
+
174
+ type ExistingRow = { ab_version: number; updated_at: string; patch_op: string } | null;
175
+ const existing = ctx.db.prepare(
176
+ "SELECT ab_version, updated_at, patch_op FROM antibodies WHERE id = ?"
177
+ ).get(storageId) as ExistingRow;
178
+
179
+ if (existing) {
180
+ const decision = shouldAcceptRemote(
181
+ { version: incomingVersion, updatedAt, patch: incomingPatch },
182
+ { version: existing.ab_version, updatedAt: existing.updated_at, patch: existing.patch_op },
183
+ );
184
+ if (!decision.accept) {
185
+ return Response.json({ status: "skipped", reason: decision.reason, id: storageId });
186
+ }
187
+ ctx.db.prepare(
188
+ "UPDATE antibodies SET patch_op = ?, file_target = ?, ab_version = ?, updated_at = ?, scope = ? WHERE id = ?"
189
+ ).run(incomingPatch, body.fileTarget, incomingVersion, updatedAt, scope, storageId);
190
+ return Response.json({ status: "updated", reason: decision.reason, id: storageId });
191
+ }
192
+
193
+ ctx.insertAntibody.run(storageId, body.patternType, body.fileTarget, JSON.stringify(body.patches));
194
+ subscriptionManager.dispatchResourceUpdated("afd://antibodies");
195
+ ctx.db.prepare(
196
+ "UPDATE antibodies SET scope = ?, ab_version = ?, updated_at = ? WHERE id = ?"
197
+ ).run(scope, incomingVersion, updatedAt, storageId);
198
+ return Response.json({ status: "learned", id: storageId });
199
+ } catch (err: unknown) {
200
+ return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 400 });
201
+ }
202
+ }
203
+
204
+ if (url.pathname === "/auto-heal/record" && req.method === "POST") {
205
+ try {
206
+ const body = await req.json() as { id: string; file?: string; healMs?: number };
207
+ ctx.state.autoHealCount++;
208
+ ctx.state.autoHealLog.push({ id: body.id, at: Date.now(), file: body.file ?? body.id, healMs: body.healMs ?? 0 });
209
+ if (ctx.state.autoHealLog.length > 100) ctx.state.autoHealLog.shift();
210
+ try { ctx.insertTelemetry.run("immune", "heal_hit", JSON.stringify({ antibodyId: body.id }), null, Date.now()); } catch { /* crash-only */ }
211
+ return Response.json({ status: "recorded", total: ctx.state.autoHealCount });
212
+ } catch (err: unknown) {
213
+ return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 400 });
214
+ }
215
+ }
216
+
217
+ if (url.pathname === "/score") {
218
+ const uptime = Math.floor((Date.now() - ctx.state.startedAt) / 1000);
219
+ const eventCount = ctx.db.query("SELECT COUNT(*) as cnt FROM events").get() as { cnt: number };
220
+ const abCount = ctx.countAntibodies.get() as { cnt: number };
221
+ const hs = ctx.state.hologramStats;
222
+ const globalSavings = hs.totalOriginalChars > 0
223
+ ? Math.round((hs.totalOriginalChars - hs.totalHologramChars) / hs.totalOriginalChars * 1000) / 10
224
+ : 0;
225
+ const dailyRows = ctx.getDailyAll.all() as { date: string; requests: number; original_chars: number; hologram_chars: number }[];
226
+ const todayRow = dailyRows.find(r => r.date === ctx.today());
227
+ const ctxDailyRaw = ctx.getCtxSavingsDaily.all() as { date: string; type: string; requests: number; original_chars: number; saved_chars: number }[];
228
+ const ctxLifetimeRaw = ctx.getCtxSavingsLifetime.all() as { type: string; total_requests: number; total_original_chars: number; total_saved_chars: number }[];
229
+ return Response.json({
230
+ uptime,
231
+ filesDetected: ctx.state.filesDetected,
232
+ totalEvents: eventCount.cnt,
233
+ lastEvent: ctx.state.lastEvent,
234
+ lastEventAt: ctx.state.lastEventAt,
235
+ watchedFiles: [...ctx.state.watchedFiles],
236
+ watchTargets: ctx.discoveryTargets,
237
+ hologram: {
238
+ lifetime: { requests: hs.totalRequests, originalChars: hs.totalOriginalChars, hologramChars: hs.totalHologramChars, savings: globalSavings },
239
+ today: todayRow ? {
240
+ requests: todayRow.requests, originalChars: todayRow.original_chars, hologramChars: todayRow.hologram_chars,
241
+ savings: todayRow.original_chars > 0 ? Math.round((todayRow.original_chars - todayRow.hologram_chars) / todayRow.original_chars * 1000) / 10 : 0,
242
+ } : null,
243
+ daily: dailyRows.map(r => ({ date: r.date, requests: r.requests, originalChars: r.original_chars, hologramChars: r.hologram_chars })),
244
+ },
245
+ ctxSavings: {
246
+ daily: ctxDailyRaw,
247
+ lifetime: ctxLifetimeRaw,
248
+ },
249
+ immune: {
250
+ antibodies: abCount.cnt,
251
+ autoHealed: ctx.state.autoHealCount,
252
+ lastAutoHeal: ctx.state.autoHealLog.length > 0 ? ctx.state.autoHealLog[ctx.state.autoHealLog.length - 1] : null,
253
+ },
254
+ ecosystem: {
255
+ detected: ctx.state.ecosystems.map(e => ({ name: e.adapter.name, confidence: e.confidence, schema: e.adapter.getHarnessSchema() })),
256
+ primary: ctx.state.ecosystems[0]?.adapter.name ?? "Unknown",
257
+ },
258
+ suppression: {
259
+ massEventsSkipped: ctx.state.suppressionSkippedCount,
260
+ dormantTransitions: ctx.state.dormantTransitions.length,
261
+ activeTaps: ctx.state.firstTapTimestamps.size,
262
+ },
263
+ evolution: (() => {
264
+ const q = listQuarantine();
265
+ return { totalQuarantined: q.length, totalLearned: q.filter(e => e.learned).length, pending: q.filter(e => !e.learned).length };
266
+ })(),
267
+ dynamicImmune: {
268
+ activeValidators: ctx.state.customValidators.size,
269
+ validatorNames: [...ctx.state.customValidators.keys()],
270
+ },
271
+ });
272
+ }
273
+
274
+ if (url.pathname === "/evolution") {
275
+ return Response.json(evolve());
276
+ }
277
+
278
+ if (url.pathname === "/evolution/status") {
279
+ const q = listQuarantine();
280
+ const stats = analyzeQuarantine();
281
+ return Response.json({
282
+ totalQuarantined: q.length, totalLearned: q.filter(e => e.learned).length, pending: stats.pending,
283
+ lessons: stats.lessons.map(l => ({ file: l.entry.originalPath, type: l.failureType, timestamp: l.entry.timestamp, suggestion: l.suggestion })),
284
+ });
285
+ }
286
+
287
+ if (url.pathname === "/sync") {
288
+ type AntibodyRow = { id: string; pattern_type: string; file_target: string; patch_op: string; created_at: string; scope?: string; ab_version?: number; updated_at?: string };
289
+ const rows = ctx.listAntibodies.all() as AntibodyRow[];
290
+ const sanitized = rows.flatMap(r => {
291
+ let patches: PatchOp[];
292
+ try { patches = JSON.parse(r.patch_op) as PatchOp[]; } catch { return []; }
293
+ const cleanPatches = patches.map(p => ({
294
+ ...p,
295
+ path: p.path.replace(/^[A-Za-z]:/, "").replace(/\\/g, "/"),
296
+ value: p.value?.replace(/[A-Za-z]:\\[^\s"']*/g, "<redacted>"),
297
+ }));
298
+ const scope = r.scope ?? "local";
299
+ const cleanId = r.id.replace(/^[A-Za-z]:/, "").replace(/\\/g, "/");
300
+ // fqid: non-local ids are already stored as "<scope>/<name>", local use "local/<id>"
301
+ const fqid = scope !== "local" ? cleanId : `local/${cleanId}`;
302
+ return [{
303
+ id: cleanId,
304
+ scope,
305
+ fqid,
306
+ patternType: r.pattern_type,
307
+ fileTarget: r.file_target.replace(/^[A-Za-z]:/, "").replace(/\\/g, "/"),
308
+ patches: cleanPatches,
309
+ version: r.ab_version ?? 1,
310
+ updatedAt: r.updated_at ?? r.created_at,
311
+ learnedAt: r.created_at,
312
+ }];
313
+ });
314
+ const payload = {
315
+ version: "1.7", generatedAt: new Date().toISOString(),
316
+ ecosystem: ctx.state.ecosystems[0]?.adapter.name ?? "Unknown",
317
+ scope: "local", // publisher scope — overridden by CLI syncRemotePush
318
+ antibodyCount: sanitized.length, antibodies: sanitized,
319
+ };
320
+ const payloadPath = resolve(ctx.ws.afdDir, "global-vaccine-payload.json");
321
+ writeFileSync(payloadPath, JSON.stringify(payload, null, 2), "utf-8");
322
+ return Response.json({ status: "exported", path: payloadPath, count: sanitized.length });
323
+ }
324
+
325
+ if (url.pathname === "/shift-summary") {
326
+ const uptime = Math.floor((Date.now() - ctx.state.startedAt) / 1000);
327
+ const eventCount = ctx.db.query("SELECT COUNT(*) as cnt FROM events").get() as { cnt: number };
328
+ const hs = ctx.state.hologramStats;
329
+ const hologramSavedChars = Math.max(0, hs.totalOriginalChars - hs.totalHologramChars);
330
+ return Response.json(buildShiftSummary({
331
+ uptimeSeconds: uptime, totalEvents: eventCount.cnt, healsPerformed: ctx.state.autoHealCount,
332
+ totalFileBytesSaved: ctx.state.totalFileBytesSaved, suppressionsSkipped: ctx.state.suppressionSkippedCount,
333
+ dormantTransitions: ctx.state.dormantTransitions.length, hologramSavedChars,
334
+ }));
335
+ }
336
+
337
+ if (url.pathname === "/events") {
338
+ if (ctx.state.sseClients.size >= MAX_SSE_CLIENTS) {
339
+ return Response.json({ error: "Too many SSE clients" }, { status: 429 });
340
+ }
341
+ let sseController: ReadableStreamDefaultController<Uint8Array> | null = null;
342
+ const stream = new ReadableStream<Uint8Array>({
343
+ start(controller) { sseController = controller; ctx.state.sseClients.add(controller); },
344
+ cancel() { if (sseController) ctx.state.sseClients.delete(sseController); },
345
+ });
346
+ return new Response(stream, {
347
+ headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive" },
348
+ });
349
+ }
350
+
351
+ if (url.pathname === "/telemetry") {
352
+ const days = parseInt(url.searchParams.get("days") ?? "7", 10) || 7;
353
+ const since = Date.now() - days * 86_400_000;
354
+ try {
355
+ const rows = ctx.db.prepare(
356
+ "SELECT category, action, COUNT(*) as cnt, AVG(duration_ms) as avg_ms FROM telemetry WHERE timestamp >= ? GROUP BY category, action ORDER BY cnt DESC"
357
+ ).all(since) as { category: string; action: string; cnt: number; avg_ms: number | null }[];
358
+ return Response.json({ days, rows });
359
+ } catch {
360
+ return Response.json({ days, rows: [] });
361
+ }
362
+ }
363
+
364
+ if (url.pathname === "/mesh/peers") {
365
+ return Response.json(listMeshPeers(ctx.ws.root));
366
+ }
367
+
368
+ if (url.pathname === "/stop") {
369
+ cleanup();
370
+ setTimeout(() => process.exit(0), 100);
371
+ return Response.json({ status: "stopping" });
372
+ }
373
+
374
+ return Response.json({ error: "not found" }, { status: 404 });
375
+ };
376
+ }