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
package/src/core/boast.ts CHANGED
@@ -1,280 +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
- }
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
+ }
@@ -1,49 +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
- }
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
+ }