autonomous-flow-daemon 1.0.0 → 1.6.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 (59) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.ko.md +142 -125
  3. package/README.md +119 -134
  4. package/package.json +11 -5
  5. package/src/adapters/index.ts +247 -35
  6. package/src/cli.ts +79 -1
  7. package/src/commands/benchmark.ts +187 -0
  8. package/src/commands/diagnose.ts +56 -14
  9. package/src/commands/doctor.ts +243 -0
  10. package/src/commands/evolution.ts +107 -0
  11. package/src/commands/fix.ts +22 -2
  12. package/src/commands/hooks.ts +136 -0
  13. package/src/commands/lang.ts +41 -0
  14. package/src/commands/mcp.ts +129 -0
  15. package/src/commands/restart.ts +14 -0
  16. package/src/commands/score.ts +192 -64
  17. package/src/commands/start.ts +137 -37
  18. package/src/commands/stats.ts +103 -0
  19. package/src/commands/status.ts +157 -0
  20. package/src/commands/stop.ts +42 -9
  21. package/src/commands/sync.ts +253 -20
  22. package/src/commands/vaccine.ts +177 -0
  23. package/src/constants.ts +26 -1
  24. package/src/core/boast.ts +280 -0
  25. package/src/core/config.ts +49 -0
  26. package/src/core/db.ts +74 -3
  27. package/src/core/discovery.ts +65 -0
  28. package/src/core/evolution.ts +215 -0
  29. package/src/core/hologram/engine.ts +71 -0
  30. package/src/core/hologram/fallback.ts +11 -0
  31. package/src/core/hologram/incremental.ts +227 -0
  32. package/src/core/hologram/py-extractor.ts +132 -0
  33. package/src/core/hologram/ts-extractor.ts +320 -0
  34. package/src/core/hologram/types.ts +25 -0
  35. package/src/core/hologram.ts +64 -236
  36. package/src/core/hook-manager.ts +259 -0
  37. package/src/core/i18n/messages.ts +309 -0
  38. package/src/core/immune.ts +8 -123
  39. package/src/core/locale.ts +88 -0
  40. package/src/core/log-rotate.ts +33 -0
  41. package/src/core/log-utils.ts +38 -0
  42. package/src/core/lru-map.ts +61 -0
  43. package/src/core/notify.ts +53 -14
  44. package/src/core/rule-engine.ts +287 -0
  45. package/src/core/semantic-diff.ts +432 -0
  46. package/src/core/telemetry.ts +94 -0
  47. package/src/core/vaccine-registry.ts +212 -0
  48. package/src/core/workspace.ts +28 -0
  49. package/src/core/yaml-minimal.ts +176 -0
  50. package/src/daemon/client.ts +34 -6
  51. package/src/daemon/event-batcher.ts +108 -0
  52. package/src/daemon/guards.ts +13 -0
  53. package/src/daemon/http-routes.ts +293 -0
  54. package/src/daemon/mcp-handler.ts +270 -0
  55. package/src/daemon/server.ts +492 -273
  56. package/src/daemon/types.ts +100 -0
  57. package/src/daemon/workspace-map.ts +92 -0
  58. package/src/platform.ts +60 -0
  59. package/src/version.ts +15 -0
@@ -0,0 +1,177 @@
1
+ /**
2
+ * afd vaccine — Vaccine registry CLI
3
+ *
4
+ * Sub-commands:
5
+ * afd vaccine list — list available packages
6
+ * afd vaccine search <query> — search packages
7
+ * afd vaccine install <name> — install a vaccine package
8
+ * afd vaccine publish <file> — publish a vaccine package
9
+ * afd vaccine installed — list installed packages
10
+ */
11
+
12
+ import { readFileSync, existsSync } from "fs";
13
+ import {
14
+ publishPackage,
15
+ searchPackages,
16
+ installPackage,
17
+ listInstalled,
18
+ getPackage,
19
+ } from "../core/vaccine-registry";
20
+ import type { VaccinePackage } from "../core/vaccine-registry";
21
+ import { getSystemLanguage } from "../core/locale";
22
+
23
+ const msgs = {
24
+ en: {
25
+ title: "afd vaccine — Registry",
26
+ list: "Available Packages",
27
+ search: "Search Results",
28
+ install: "Install",
29
+ publish: "Publish",
30
+ installed: "Installed Packages",
31
+ noPackages: "No packages found.",
32
+ noResults: "No matching packages.",
33
+ notFound: "Package not found.",
34
+ usage: `Usage:
35
+ afd vaccine list List available packages
36
+ afd vaccine search <query> Search packages
37
+ afd vaccine install <name> Install a vaccine package
38
+ afd vaccine publish <file> Publish from vaccine.json
39
+ afd vaccine installed List installed packages`,
40
+ },
41
+ ko: {
42
+ title: "afd vaccine — 레지스트리",
43
+ list: "사용 가능한 패키지",
44
+ search: "검색 결과",
45
+ install: "설치",
46
+ publish: "발행",
47
+ installed: "설치된 패키지",
48
+ noPackages: "패키지가 없습니다.",
49
+ noResults: "일치하는 패키지가 없습니다.",
50
+ notFound: "패키지를 찾을 수 없습니다.",
51
+ usage: `사용법:
52
+ afd vaccine list 사용 가능한 패키지 목록
53
+ afd vaccine search <query> 패키지 검색
54
+ afd vaccine install <name> 백신 패키지 설치
55
+ afd vaccine publish <file> vaccine.json에서 발행
56
+ afd vaccine installed 설치된 패키지 목록`,
57
+ },
58
+ };
59
+
60
+ const BOX = { tl: "┌", tr: "┐", bl: "└", br: "┘", h: "─", v: "│", ml: "├", mr: "┤" };
61
+ const W = 54;
62
+ function hline(l: string, r: string) { return `${l}${BOX.h.repeat(W)}${r}`; }
63
+ function row(s: string) {
64
+ const pad = Math.max(0, W - 2 - s.length);
65
+ return `${BOX.v} ${s}${" ".repeat(pad)} ${BOX.v}`;
66
+ }
67
+
68
+ export async function vaccineCommand(subcommand?: string, arg?: string) {
69
+ const lang = getSystemLanguage();
70
+ const m = msgs[lang];
71
+
72
+ if (!subcommand) {
73
+ console.log(m.usage);
74
+ return;
75
+ }
76
+
77
+ switch (subcommand) {
78
+ case "list": {
79
+ const packages = searchPackages();
80
+ console.log(hline(BOX.tl, BOX.tr));
81
+ console.log(row(`💉 ${m.title} — ${m.list}`));
82
+ console.log(hline(BOX.ml, BOX.mr));
83
+ if (packages.length === 0) {
84
+ console.log(row(m.noPackages));
85
+ } else {
86
+ for (const p of packages) {
87
+ console.log(row(`📦 ${p.name}@${p.version} (${p.antibodyCount} rules)`));
88
+ console.log(row(` ${p.description}`));
89
+ console.log(row(` ${p.ecosystem} | by ${p.author}`));
90
+ console.log(row(""));
91
+ }
92
+ }
93
+ console.log(hline(BOX.bl, BOX.br));
94
+ break;
95
+ }
96
+
97
+ case "search": {
98
+ const results = searchPackages(arg);
99
+ console.log(hline(BOX.tl, BOX.tr));
100
+ console.log(row(`🔍 ${m.title} — ${m.search}: "${arg ?? ""}"`));
101
+ console.log(hline(BOX.ml, BOX.mr));
102
+ if (results.length === 0) {
103
+ console.log(row(m.noResults));
104
+ } else {
105
+ for (const p of results) {
106
+ console.log(row(`📦 ${p.name}@${p.version} — ${p.description}`));
107
+ }
108
+ }
109
+ console.log(hline(BOX.bl, BOX.br));
110
+ break;
111
+ }
112
+
113
+ case "install": {
114
+ if (!arg) {
115
+ console.error("Usage: afd vaccine install <name>");
116
+ process.exit(1);
117
+ }
118
+ const result = installPackage(arg);
119
+ if (!result.success) {
120
+ console.error(`[afd vaccine] ${result.message}`);
121
+ process.exit(1);
122
+ }
123
+ console.log(hline(BOX.tl, BOX.tr));
124
+ console.log(row(`✅ ${m.install}: ${arg}`));
125
+ console.log(hline(BOX.ml, BOX.mr));
126
+ console.log(row(result.message));
127
+ console.log(hline(BOX.bl, BOX.br));
128
+ break;
129
+ }
130
+
131
+ case "publish": {
132
+ const filePath = arg ?? "vaccine.json";
133
+ if (!existsSync(filePath)) {
134
+ console.error(`[afd vaccine] File not found: ${filePath}`);
135
+ process.exit(1);
136
+ }
137
+ let pkg: VaccinePackage;
138
+ try {
139
+ pkg = JSON.parse(readFileSync(filePath, "utf-8"));
140
+ } catch {
141
+ console.error("[afd vaccine] Invalid JSON in vaccine file.");
142
+ process.exit(1);
143
+ }
144
+ const result = publishPackage(pkg);
145
+ console.log(hline(BOX.tl, BOX.tr));
146
+ console.log(row(`📤 ${m.publish}`));
147
+ console.log(hline(BOX.ml, BOX.mr));
148
+ console.log(row(result.message));
149
+ console.log(hline(BOX.bl, BOX.br));
150
+ break;
151
+ }
152
+
153
+ case "installed": {
154
+ const pkgs = listInstalled();
155
+ console.log(hline(BOX.tl, BOX.tr));
156
+ console.log(row(`📋 ${m.title} — ${m.installed}`));
157
+ console.log(hline(BOX.ml, BOX.mr));
158
+ if (pkgs.length === 0) {
159
+ console.log(row(m.noPackages));
160
+ } else {
161
+ for (const name of pkgs) {
162
+ const pkg = getPackage(name);
163
+ if (pkg) {
164
+ console.log(row(`📦 ${pkg.name}@${pkg.version} (${pkg.antibodies.length} rules)`));
165
+ } else {
166
+ console.log(row(`📦 ${name}`));
167
+ }
168
+ }
169
+ }
170
+ console.log(hline(BOX.bl, BOX.br));
171
+ break;
172
+ }
173
+
174
+ default:
175
+ console.log(m.usage);
176
+ }
177
+ }
package/src/constants.ts CHANGED
@@ -1,7 +1,32 @@
1
1
  import { join } from "path";
2
+ import { findWorkspaceRoot } from "./core/workspace";
2
3
 
4
+ // Relative paths (used when cwd is already the workspace root)
3
5
  export const AFD_DIR = ".afd";
4
6
  export const PID_FILE = join(AFD_DIR, "daemon.pid");
5
7
  export const PORT_FILE = join(AFD_DIR, "daemon.port");
6
8
  export const DB_FILE = join(AFD_DIR, "antibodies.sqlite");
7
- export const WATCH_TARGETS = [".claude/", "CLAUDE.md", ".cursorrules"];
9
+ export const LOG_FILE = join(AFD_DIR, "daemon.log");
10
+ export const QUARANTINE_DIR = join(AFD_DIR, "quarantine");
11
+ export const WATCH_TARGETS = [
12
+ ".claude/", "CLAUDE.md", ".cursorrules", ".claudeignore", ".gitignore",
13
+ ".windsurfrules", ".windsurf/", "codex.md", ".codex/",
14
+ ".cursorignore", ".windsurfignore", ".codexignore",
15
+ ];
16
+
17
+ /**
18
+ * Resolve all `.afd/` paths against the workspace root.
19
+ * Works correctly even when CLI is invoked from a subdirectory.
20
+ */
21
+ export function resolveWorkspacePaths(from?: string) {
22
+ const root = findWorkspaceRoot(from);
23
+ return {
24
+ root,
25
+ afdDir: join(root, AFD_DIR),
26
+ pidFile: join(root, PID_FILE),
27
+ portFile: join(root, PORT_FILE),
28
+ dbFile: join(root, DB_FILE),
29
+ logFile: join(root, LOG_FILE),
30
+ quarantineDir: join(root, QUARANTINE_DIR),
31
+ };
32
+ }
@@ -0,0 +1,280 @@
1
+ /**
2
+ * Boastful Doctor — Gamification & Delightful Logging
3
+ *
4
+ * O(1) math only — no I/O, no async. All strings localized via i18n.
5
+ */
6
+
7
+ import { getSystemLanguage } from "./locale";
8
+ import type { SupportedLang } from "./locale";
9
+ import { getMessages, t } from "./i18n/messages";
10
+ import type { MessageDict } from "./i18n/messages";
11
+
12
+ // ── Token & Cost Estimation ──
13
+
14
+ const CHARS_PER_TOKEN = 3.5;
15
+ const COST_PER_1K_TOKENS = 0.003;
16
+ const DEBUG_MINUTES_BASE = 8;
17
+ const DEBUG_MINUTES_PER_KB = 2;
18
+
19
+ export interface HealMetrics {
20
+ fileSize: number;
21
+ healTimeMs: number;
22
+ tokensSaved: number;
23
+ minutesSaved: number;
24
+ costSaved: number;
25
+ }
26
+
27
+ /** Calculate mock "value saved" from a heal event. O(1), no I/O. */
28
+ export function calcHealMetrics(fileSize: number, healTimeMs: number): HealMetrics {
29
+ const tokensSaved = Math.round(fileSize / CHARS_PER_TOKEN);
30
+ const fileSizeKB = fileSize / 1024;
31
+ const minutesSaved = Math.round(DEBUG_MINUTES_BASE + fileSizeKB * DEBUG_MINUTES_PER_KB);
32
+ const costSaved = Math.round(tokensSaved / 1000 * COST_PER_1K_TOKENS * 100) / 100;
33
+ return { fileSize, healTimeMs, tokensSaved, minutesSaved, costSaved };
34
+ }
35
+
36
+ export interface ShiftSummary {
37
+ uptimeFormatted: string;
38
+ totalEvents: number;
39
+ healsPerformed: number;
40
+ totalTokensSaved: number;
41
+ totalMinutesSaved: number;
42
+ totalCostSaved: number;
43
+ suppressionsSkipped: number;
44
+ dormantTransitions: number;
45
+ boast: string;
46
+ // Unified ROI breakdown
47
+ healTokensSaved: number;
48
+ healCostSaved: number;
49
+ hologramTokensSaved: number;
50
+ hologramCostSaved: number;
51
+ }
52
+
53
+ /** Build a shift summary from aggregated daemon stats. */
54
+ export function buildShiftSummary(stats: {
55
+ uptimeSeconds: number;
56
+ totalEvents: number;
57
+ healsPerformed: number;
58
+ totalFileBytesSaved: number;
59
+ suppressionsSkipped: number;
60
+ dormantTransitions: number;
61
+ hologramSavedChars?: number;
62
+ }, lang?: SupportedLang): ShiftSummary {
63
+ const l = lang ?? getSystemLanguage();
64
+ const m = getMessages(l);
65
+
66
+ // Auto-Heal ROI
67
+ const healTokensSaved = Math.round(stats.totalFileBytesSaved / CHARS_PER_TOKEN);
68
+ const healCostSaved = Math.round(healTokensSaved / 1000 * COST_PER_1K_TOKENS * 100) / 100;
69
+
70
+ // Hologram ROI
71
+ const holoSavedChars = stats.hologramSavedChars ?? 0;
72
+ const hologramTokensSaved = Math.round(holoSavedChars / CHARS_PER_TOKEN);
73
+ const hologramCostSaved = Math.round(hologramTokensSaved / 1000 * COST_PER_1K_TOKENS * 100) / 100;
74
+
75
+ // Unified totals
76
+ const totalTokensSaved = healTokensSaved + hologramTokensSaved;
77
+ const totalMinutesSaved = stats.healsPerformed * DEBUG_MINUTES_BASE;
78
+ const totalCostSaved = Math.round((healCostSaved + hologramCostSaved) * 100) / 100;
79
+
80
+ return {
81
+ uptimeFormatted: formatUptime(stats.uptimeSeconds),
82
+ totalEvents: stats.totalEvents,
83
+ healsPerformed: stats.healsPerformed,
84
+ totalTokensSaved,
85
+ totalMinutesSaved,
86
+ totalCostSaved,
87
+ suppressionsSkipped: stats.suppressionsSkipped,
88
+ dormantTransitions: stats.dormantTransitions,
89
+ boast: pick(m.BOAST_SHIFT_END),
90
+ healTokensSaved,
91
+ healCostSaved,
92
+ hologramTokensSaved,
93
+ hologramCostSaved,
94
+ };
95
+ }
96
+
97
+ // ── Boast Selection ──
98
+
99
+ function msg(lang?: SupportedLang): MessageDict {
100
+ return getMessages(lang ?? getSystemLanguage());
101
+ }
102
+
103
+ /** Pick a random heal boast. 1-in-N chance (anti-annoyance). */
104
+ export function maybeHealBoast(triggerChance = 5, lang?: SupportedLang): string | null {
105
+ if (Math.floor(Math.random() * triggerChance) !== 0) return null;
106
+ const m = msg(lang);
107
+ return pick(m.BOAST_HEAL);
108
+ }
109
+
110
+ /** Always returns a dormant boast (rare event, always worth noting). */
111
+ export function dormantBoast(lang?: SupportedLang): string {
112
+ return pick(msg(lang).BOAST_DORMANT);
113
+ }
114
+
115
+ /** Pick a random shift-end boast in the given locale. */
116
+ export function localizedBoast(lang?: SupportedLang): string {
117
+ return pick(msg(lang).BOAST_SHIFT_END);
118
+ }
119
+
120
+ /** Format a single heal log line with metrics. */
121
+ export function formatHealLog(
122
+ fileName: string,
123
+ metrics: HealMetrics,
124
+ boast: string | null,
125
+ lang?: SupportedLang,
126
+ ): string {
127
+ const m = msg(lang);
128
+ const vars = {
129
+ fileName,
130
+ ms: metrics.healTimeMs,
131
+ tokens: metrics.tokensSaved,
132
+ mins: metrics.minutesSaved,
133
+ };
134
+ const base = t(m.HEAL_LOG, vars);
135
+ if (!boast) return base;
136
+ const boastLine = t(boast, vars);
137
+ return `${base}\n${m.BOAST_HEAL_PREFIX} ${boastLine}`;
138
+ }
139
+
140
+ /** Format dormant log line. */
141
+ export function formatDormantLog(
142
+ antibodyId: string,
143
+ lang?: SupportedLang,
144
+ ): string {
145
+ const m = msg(lang);
146
+ const boast = pick(m.BOAST_DORMANT);
147
+ return t(m.DORMANT_LOG, { id: antibodyId, boast });
148
+ }
149
+
150
+ /** Format the full shift summary for terminal output. */
151
+ export function formatShiftSummary(s: ShiftSummary, lang?: SupportedLang): string {
152
+ const m = msg(lang);
153
+ const lines = [
154
+ "",
155
+ "┌──────────────────────────────────────────────┐",
156
+ pad(` ${m.SHIFT_TITLE}`),
157
+ "├──────────────────────────────────────────────┤",
158
+ padKV(m.SHIFT_ON_DUTY, s.uptimeFormatted),
159
+ padKV(m.SHIFT_EVENTS, String(s.totalEvents)),
160
+ padKV(m.SHIFT_HEALS, String(s.healsPerformed)),
161
+ padKV(m.SHIFT_TOKENS, `~${fmtNum(s.totalTokensSaved)}`),
162
+ padKV(m.SHIFT_TIME, `~${s.totalMinutesSaved} min`),
163
+ padKV(m.SHIFT_COST, `~$${s.totalCostSaved.toFixed(2)}`),
164
+ ];
165
+
166
+ if (s.suppressionsSkipped > 0) {
167
+ padKVPush(lines, m.SHIFT_SUPPRESSED, `${s.suppressionsSkipped} mass events`);
168
+ }
169
+ if (s.dormantTransitions > 0) {
170
+ padKVPush(lines, m.SHIFT_RETIRED, `${s.dormantTransitions} antibodies`);
171
+ }
172
+
173
+ lines.push("├──────────────────────────────────────────────┤");
174
+ // Override server-side boast with locale-appropriate one
175
+ const localBoast = pick(m.BOAST_SHIFT_END);
176
+ lines.push(pad(` ${localBoast}`));
177
+ lines.push("└──────────────────────────────────────────────┘");
178
+ lines.push("");
179
+
180
+ return lines.join("\n");
181
+ }
182
+
183
+ /** Format value section for score command. */
184
+ export function formatValueSection(s: ShiftSummary, lang?: SupportedLang): string[] {
185
+ const m = msg(lang);
186
+ const lines: string[] = [];
187
+ lines.push(m.SCORE_VALUE_TITLE);
188
+ lines.push(`${m.SHIFT_TOKENS}: ~${fmtNum(s.totalTokensSaved)}`);
189
+ lines.push(`${m.SHIFT_TIME}: ~${s.totalMinutesSaved} min`);
190
+ lines.push(`${m.SHIFT_COST}: ~$${s.totalCostSaved.toFixed(2)}`);
191
+ return lines;
192
+ }
193
+
194
+ // ── Helpers ──
195
+
196
+ function pick<T>(arr: T[]): T {
197
+ if (arr.length === 0) return "" as unknown as T;
198
+ return arr[Math.floor(Math.random() * arr.length)];
199
+ }
200
+
201
+ function formatUptime(seconds: number): string {
202
+ if (seconds < 60) return `${seconds}s`;
203
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
204
+ const h = Math.floor(seconds / 3600);
205
+ const m = Math.floor((seconds % 3600) / 60);
206
+ return `${h}h ${m}m`;
207
+ }
208
+
209
+ const W = 46;
210
+
211
+ function pad(content: string): string {
212
+ const visual = visualWidth(content);
213
+ if (visual > W) {
214
+ let len = 0;
215
+ let cut = 0;
216
+ for (const ch of content) {
217
+ const cw = isWideChar(ch) ? 2 : 1;
218
+ if (len + cw > W - 1) break;
219
+ len += cw;
220
+ cut += ch.length;
221
+ }
222
+ const trimmed = content.slice(0, cut) + "…";
223
+ const trimVw = visualWidth(trimmed);
224
+ return `│${trimmed}${" ".repeat(Math.max(0, W - trimVw))}│`;
225
+ }
226
+ return `│${content}${" ".repeat(Math.max(0, W - visual))}│`;
227
+ }
228
+
229
+ /** Pad a key-value row with aligned colon, visual-width-aware. */
230
+ function padKV(key: string, value: string): string {
231
+ const keyVw = visualWidth(key);
232
+ const padSize = Math.max(0, 13 - keyVw);
233
+ return pad(` ${key}${" ".repeat(padSize)}: ${value}`);
234
+ }
235
+
236
+ function padKVPush(lines: string[], key: string, value: string): void {
237
+ lines.push(padKV(key, value));
238
+ }
239
+
240
+ export function visualWidth(s: string): number {
241
+ let w = 0;
242
+ for (const ch of s) {
243
+ w += isWideChar(ch) ? 2 : 1;
244
+ }
245
+ return w;
246
+ }
247
+
248
+ function isWideChar(ch: string): boolean {
249
+ const code = ch.codePointAt(0) ?? 0;
250
+ return (
251
+ // CJK Unified Ideographs
252
+ (code >= 0x4E00 && code <= 0x9FFF) ||
253
+ // CJK Extension A
254
+ (code >= 0x3400 && code <= 0x4DBF) ||
255
+ // Hangul Syllables
256
+ (code >= 0xAC00 && code <= 0xD7AF) ||
257
+ // Hangul Jamo
258
+ (code >= 0x1100 && code <= 0x11FF) ||
259
+ // Hangul Compatibility Jamo
260
+ (code >= 0x3130 && code <= 0x318F) ||
261
+ // CJK Compatibility
262
+ (code >= 0x3300 && code <= 0x33FF) ||
263
+ // Fullwidth Forms
264
+ (code >= 0xFF01 && code <= 0xFF60) ||
265
+ // Common emoji ranges
266
+ (code >= 0x1F300 && code <= 0x1FBFF) ||
267
+ (code >= 0x2600 && code <= 0x27BF) ||
268
+ (code >= 0xFE00 && code <= 0xFE0F) ||
269
+ (code >= 0x200D && code <= 0x200D) ||
270
+ (code >= 0x231A && code <= 0x23FA) ||
271
+ code === 0x2764 ||
272
+ code === 0x2139
273
+ );
274
+ }
275
+
276
+ export function fmtNum(n: number): string {
277
+ if (n < 1000) return `${n}`;
278
+ if (n < 1_000_000) return `${(n / 1000).toFixed(1)}K`;
279
+ return `${(n / 1_000_000).toFixed(1)}M`;
280
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Persistent user configuration — ~/.afdrc
3
+ *
4
+ * JSON file with user preferences. Created on first `afd lang` call.
5
+ * Read is sync and cached; write is sync (rare operation).
6
+ */
7
+
8
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
9
+ import { join, dirname } from "path";
10
+ import { homedir } from "os";
11
+
12
+ export interface AfdConfig {
13
+ lang?: string;
14
+ }
15
+
16
+ const RC_PATH = join(homedir(), ".afdrc");
17
+
18
+ let configCache: AfdConfig | null = null;
19
+
20
+ /** Read ~/.afdrc. Returns empty object if missing or invalid. */
21
+ export function readConfig(): AfdConfig {
22
+ if (configCache) return configCache;
23
+ if (!existsSync(RC_PATH)) {
24
+ configCache = {};
25
+ return configCache;
26
+ }
27
+ try {
28
+ configCache = JSON.parse(readFileSync(RC_PATH, "utf-8"));
29
+ return configCache!;
30
+ } catch {
31
+ configCache = {};
32
+ return configCache;
33
+ }
34
+ }
35
+
36
+ /** Write a key to ~/.afdrc. Merges with existing config. */
37
+ export function writeConfig(partial: Partial<AfdConfig>): AfdConfig {
38
+ const current = readConfig();
39
+ const merged = { ...current, ...partial };
40
+ mkdirSync(dirname(RC_PATH), { recursive: true });
41
+ writeFileSync(RC_PATH, JSON.stringify(merged, null, 2) + "\n", "utf-8");
42
+ configCache = merged;
43
+ return merged;
44
+ }
45
+
46
+ /** Get the RC file path (for display). */
47
+ export function getConfigPath(): string {
48
+ return RC_PATH;
49
+ }
package/src/core/db.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  import { mkdirSync } from "fs";
2
2
  import { Database } from "bun:sqlite";
3
- import { AFD_DIR, DB_FILE } from "../constants";
3
+ import { resolveWorkspacePaths } from "../constants";
4
4
 
5
5
  export function initDb(): Database {
6
- mkdirSync(AFD_DIR, { recursive: true });
7
- const db = new Database(DB_FILE);
6
+ const paths = resolveWorkspacePaths();
7
+ mkdirSync(paths.afdDir, { recursive: true });
8
+ const db = new Database(paths.dbFile);
8
9
  db.exec("PRAGMA journal_mode = WAL");
9
10
 
10
11
  db.exec(`
@@ -42,5 +43,75 @@ export function initDb(): Database {
42
43
  )
43
44
  `);
44
45
 
46
+ // ── Hologram Stats: lifetime (single row) + daily (7-day rolling) ──
47
+ db.exec(`
48
+ CREATE TABLE IF NOT EXISTS hologram_lifetime (
49
+ id INTEGER PRIMARY KEY CHECK (id = 1),
50
+ total_requests INTEGER NOT NULL DEFAULT 0,
51
+ total_original_chars INTEGER NOT NULL DEFAULT 0,
52
+ total_hologram_chars INTEGER NOT NULL DEFAULT 0
53
+ )
54
+ `);
55
+ db.exec(`INSERT OR IGNORE INTO hologram_lifetime (id) VALUES (1)`);
56
+
57
+ db.exec(`
58
+ CREATE TABLE IF NOT EXISTS hologram_daily (
59
+ date TEXT PRIMARY KEY,
60
+ requests INTEGER NOT NULL DEFAULT 0,
61
+ original_chars INTEGER NOT NULL DEFAULT 0,
62
+ hologram_chars INTEGER NOT NULL DEFAULT 0
63
+ )
64
+ `);
65
+ // Purge entries older than 7 days
66
+ db.exec(`DELETE FROM hologram_daily WHERE date < date('now', '-7 days')`);
67
+
68
+ // ── Telemetry: feature usage tracking ──
69
+ db.exec(`
70
+ CREATE TABLE IF NOT EXISTS telemetry (
71
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
72
+ category TEXT NOT NULL,
73
+ action TEXT NOT NULL,
74
+ detail TEXT,
75
+ duration_ms REAL,
76
+ timestamp INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
77
+ )
78
+ `);
79
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_telemetry_cat_ts ON telemetry(category, timestamp)`);
80
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_telemetry_action ON telemetry(action)`);
81
+ // Purge raw telemetry older than 30 days
82
+ db.exec(`DELETE FROM telemetry WHERE timestamp < unixepoch() * 1000 - 30 * 86400000`);
83
+
84
+ // ── Mistake History: passive defense tracking ──
85
+ db.exec(`
86
+ CREATE TABLE IF NOT EXISTS mistake_history (
87
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
88
+ file_path TEXT NOT NULL,
89
+ mistake_type TEXT NOT NULL,
90
+ description TEXT NOT NULL,
91
+ antibody_id TEXT,
92
+ timestamp INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
93
+ )
94
+ `);
95
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_mistake_history_path ON mistake_history(file_path)`);
96
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_mistake_history_ts ON mistake_history(timestamp)`);
97
+ db.exec(`DELETE FROM mistake_history WHERE timestamp < unixepoch() * 1000 - 30 * 86400000`);
98
+
99
+ // Migration: move data from old hologram_stats table if it exists
100
+ try {
101
+ const old = db.prepare("SELECT total_requests, total_original_chars, total_hologram_chars FROM hologram_stats WHERE id = 1").get() as {
102
+ total_requests: number; total_original_chars: number; total_hologram_chars: number;
103
+ } | null;
104
+ if (old && old.total_requests > 0) {
105
+ db.transaction(() => {
106
+ db.prepare(
107
+ "UPDATE hologram_lifetime SET total_requests = ?, total_original_chars = ?, total_hologram_chars = ? WHERE id = 1"
108
+ ).run(old.total_requests, old.total_original_chars, old.total_hologram_chars);
109
+ db.exec("DROP TABLE hologram_stats");
110
+ })();
111
+ }
112
+ } catch {
113
+ // Old table doesn't exist — clean install
114
+ }
115
+
45
116
  return db;
46
117
  }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Smart Discovery — scans project root for AI-agent config patterns.
3
+ *
4
+ * Runs ONCE at startup. O(n) existsSync calls on a known candidate list.
5
+ * No directory traversal, no glob — just fast stat checks (< 5ms).
6
+ */
7
+
8
+ import { existsSync } from "fs";
9
+
10
+ /** All known AI-agent config patterns to probe. */
11
+ const DISCOVERY_CANDIDATES = [
12
+ // Claude Code ecosystem
13
+ ".claude/",
14
+ "CLAUDE.md",
15
+ ".claudeignore",
16
+ // Cursor ecosystem
17
+ ".cursorrules",
18
+ ".cursorignore",
19
+ // Git essentials
20
+ ".gitignore",
21
+ // MCP configs
22
+ "mcp-config.json",
23
+ ".mcp.json",
24
+ // Generic AI config directories
25
+ ".ai/",
26
+ // Custom rules (various tools)
27
+ ".customrules",
28
+ ".windsurfrules",
29
+ // Copilot
30
+ ".github/copilot-instructions.md",
31
+ ] as const;
32
+
33
+ export interface DiscoveryResult {
34
+ /** All targets that exist on disk (merged with defaults, deduplicated). */
35
+ targets: string[];
36
+ /** How many were found via smart discovery (beyond the hardcoded defaults). */
37
+ discoveredCount: number;
38
+ /** Elapsed time in ms. */
39
+ elapsedMs: number;
40
+ }
41
+
42
+ /**
43
+ * Discover AI-context files in the project root.
44
+ * Merges found targets with the provided defaults, deduplicates, and returns.
45
+ */
46
+ export function discoverWatchTargets(defaults: readonly string[]): DiscoveryResult {
47
+ const t0 = performance.now();
48
+ const seen = new Set<string>(defaults);
49
+ let discoveredCount = 0;
50
+
51
+ for (const candidate of DISCOVERY_CANDIDATES) {
52
+ if (seen.has(candidate)) continue;
53
+ if (existsSync(candidate)) {
54
+ seen.add(candidate);
55
+ discoveredCount++;
56
+ }
57
+ }
58
+
59
+ const elapsedMs = Math.round((performance.now() - t0) * 100) / 100;
60
+ return {
61
+ targets: [...seen],
62
+ discoveredCount,
63
+ elapsedMs,
64
+ };
65
+ }