autonomous-flow-daemon 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,8 @@
1
1
  import { daemonRequest } from "../daemon/client";
2
+ import { fmtNum, visualWidth, localizedBoast } from "../core/boast";
3
+ import type { ShiftSummary } from "../core/boast";
4
+ import { getSystemLanguage } from "../core/locale";
5
+ import { getMessages, t } from "../core/i18n/messages";
2
6
 
3
7
  interface HologramScore {
4
8
  requests: number;
@@ -28,6 +32,12 @@ interface EcosystemScore {
28
32
  primary: string;
29
33
  }
30
34
 
35
+ interface SuppressionScore {
36
+ massEventsSkipped: number;
37
+ dormantTransitions: number;
38
+ activeTaps: number;
39
+ }
40
+
31
41
  interface ScoreData {
32
42
  uptime: number;
33
43
  filesDetected: number;
@@ -39,6 +49,7 @@ interface ScoreData {
39
49
  hologram: HologramScore;
40
50
  immune: ImmuneScore;
41
51
  ecosystem: EcosystemScore;
52
+ suppression: SuppressionScore;
42
53
  }
43
54
 
44
55
  function formatUptime(seconds: number): string {
@@ -60,66 +71,95 @@ function formatChars(n: number): string {
60
71
  return `${(n / 1_000_000).toFixed(1)}M`;
61
72
  }
62
73
 
63
- const W = 46; // inner box width
74
+ const W = 46;
64
75
  const line = "\u2500".repeat(W);
65
- const row = (content: string) => `\u2502${content.padEnd(W)}\u2502`;
76
+ const sep = "\u2500".repeat(30);
77
+
78
+ function row(content: string): string {
79
+ const vw = visualWidth(content);
80
+ const padSize = Math.max(0, W - vw);
81
+ return `\u2502${content}${" ".repeat(padSize)}\u2502`;
82
+ }
83
+
84
+ function vwPad(s: string, target: number): string {
85
+ const vw = visualWidth(s);
86
+ return s + " ".repeat(Math.max(0, target - vw));
87
+ }
88
+
89
+ /** Render a labeled key-value row with visual-width-aware padding. */
90
+ function kv(label: string, value: string): string {
91
+ return row(` ${vwPad(label, 13)}: ${value}`);
92
+ }
66
93
 
67
94
  export async function scoreCommand() {
95
+ const lang = getSystemLanguage();
96
+ const i18n = getMessages(lang);
97
+
68
98
  try {
69
99
  const data = await daemonRequest<ScoreData>("/score");
70
100
  const h = data.hologram;
71
101
 
102
+ // Title
72
103
  console.log(`\u250C${line}\u2510`);
73
- console.log(row(" afd score \u2014 Daemon Diagnostics"));
104
+ console.log(row(` ${i18n.SCORE_TITLE}`));
74
105
  console.log(`\u251C${line}\u2524`);
75
- console.log(row(` Ecosystem : ${data.ecosystem.primary}`));
106
+
107
+ // Ecosystem
108
+ console.log(kv(i18n.SCORE_ECOSYSTEM, data.ecosystem.primary));
76
109
  if (data.ecosystem.detected.length > 1) {
77
110
  const others = data.ecosystem.detected.slice(1).map(e => e.name).join(", ");
78
- console.log(row(` Also found : ${others}`));
111
+ console.log(kv(i18n.SCORE_ALSO_FOUND, others));
79
112
  }
113
+
114
+ // Uptime / Events / Files
80
115
  console.log(`\u251C${line}\u2524`);
81
- console.log(row(` Uptime : ${formatUptime(data.uptime)}`));
82
- console.log(row(` Events : ${data.totalEvents}`));
83
- console.log(row(` Files Found : ${data.watchedFiles.length}`));
116
+ console.log(kv(i18n.SCORE_UPTIME, formatUptime(data.uptime)));
117
+ console.log(kv(i18n.SCORE_EVENTS, String(data.totalEvents)));
118
+ console.log(kv(i18n.SCORE_FILES_FOUND, String(data.watchedFiles.length)));
84
119
  console.log(`\u251C${line}\u2524`);
85
- console.log(row(` Activity ${heatBar(data.totalEvents, 100)}`));
120
+ console.log(row(` ${vwPad(i18n.SCORE_ACTIVITY, 10)}${heatBar(data.totalEvents, 100)}`));
86
121
 
87
- // Context Efficiency section
122
+ // Hologram section
88
123
  console.log(`\u251C${line}\u2524`);
89
- console.log(row(" Context Efficiency (Hologram)"));
90
- console.log(row(` \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500`));
124
+ console.log(row(` ${i18n.SCORE_HOLOGRAM_TITLE}`));
125
+ console.log(row(` ${sep}`));
91
126
  if (h.requests > 0) {
92
127
  const saved = h.originalChars - h.hologramChars;
93
- console.log(row(` Requests : ${h.requests}`));
94
- console.log(row(` Original : ${formatChars(h.originalChars)} chars`));
95
- console.log(row(` Hologram : ${formatChars(h.hologramChars)} chars`));
96
- console.log(row(` Saved : ${formatChars(saved)} chars (${h.savings}%)`));
97
- console.log(row(` Efficiency ${heatBar(h.savings, 100)}`));
128
+ console.log(kv(i18n.SCORE_HOLOGRAM_REQUESTS, String(h.requests)));
129
+ console.log(kv(i18n.SCORE_HOLOGRAM_ORIGINAL, `${formatChars(h.originalChars)} chars`));
130
+ console.log(kv(i18n.SCORE_HOLOGRAM_COMPRESSED, `${formatChars(h.hologramChars)} chars`));
131
+ console.log(kv(i18n.SCORE_HOLOGRAM_SAVED, `${formatChars(saved)} chars (${h.savings}%)`));
132
+ console.log(row(` ${vwPad(i18n.SCORE_HOLOGRAM_EFFICIENCY, 10)}${heatBar(h.savings, 100)}`));
98
133
  } else {
99
- console.log(row(" No hologram requests yet."));
100
- console.log(row(" Use: GET /hologram?file=<path>"));
134
+ console.log(row(` ${i18n.SCORE_HOLOGRAM_EMPTY}`));
135
+ console.log(row(` ${i18n.SCORE_HOLOGRAM_HINT}`));
101
136
  }
102
137
 
103
138
  // Immune System section
104
139
  console.log(`\u251C${line}\u2524`);
105
- console.log(row(" Immune System"));
106
- console.log(row(` \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500`));
140
+ console.log(row(` ${i18n.SCORE_IMMUNE_TITLE}`));
141
+ console.log(row(` ${sep}`));
107
142
  const ab = data.immune.antibodies;
108
143
  const ah = data.immune.autoHealed;
109
- const immuneLevel = ab === 0 ? "Vulnerable" : ab < 3 ? "Learning" : ab < 6 ? "Guarded" : "Fortified";
110
- console.log(row(` Antibodies : ${ab}`));
111
- console.log(row(` Level : ${immuneLevel}`));
112
- console.log(row(` Immunity ${heatBar(ab, 10)}`));
113
- console.log(row(` Auto-healed : ${ah} background event${ah !== 1 ? "s" : ""}`));
144
+ const immuneLevel = ab === 0 ? i18n.SCORE_IMMUNE_VULNERABLE
145
+ : ab < 3 ? i18n.SCORE_IMMUNE_LEARNING
146
+ : ab < 6 ? i18n.SCORE_IMMUNE_GUARDED
147
+ : i18n.SCORE_IMMUNE_FORTIFIED;
148
+ console.log(kv(i18n.SCORE_ANTIBODIES, String(ab)));
149
+ console.log(kv(i18n.SCORE_LEVEL, immuneLevel));
150
+ console.log(row(` ${vwPad(i18n.SCORE_IMMUNITY, 10)}${heatBar(ab, 10)}`));
151
+ const healedStr = t(i18n.SCORE_AUTO_HEALED, { count: ah, s: ah !== 1 ? "s" : "" });
152
+ console.log(kv(i18n.SCORE_AUTO_HEALED_LABEL, healedStr));
114
153
  if (data.immune.lastAutoHeal) {
115
154
  const ago = formatUptime(Math.floor((Date.now() - data.immune.lastAutoHeal.at) / 1000));
116
- console.log(row(` Last heal : ${data.immune.lastAutoHeal.id} (${ago} ago)`));
155
+ const healStr = t(i18n.SCORE_LAST_HEAL, { id: data.immune.lastAutoHeal.id, ago });
156
+ console.log(kv(i18n.SCORE_LAST_EVENT, healStr));
117
157
  }
118
158
 
119
159
  // Watched files
120
160
  console.log(`\u251C${line}\u2524`);
121
161
  if (data.watchedFiles.length > 0) {
122
- console.log(row(" Watched Files:"));
162
+ console.log(row(` ${i18n.SCORE_WATCHED_FILES}`));
123
163
  for (const f of data.watchedFiles.slice(0, 8)) {
124
164
  console.log(row(` ${f.substring(0, W - 6)}`));
125
165
  }
@@ -127,18 +167,38 @@ export async function scoreCommand() {
127
167
  console.log(row(` ... +${data.watchedFiles.length - 8} more`));
128
168
  }
129
169
  } else {
130
- console.log(row(" No files detected yet."));
170
+ console.log(row(` ${i18n.SCORE_NO_FILES}`));
131
171
  }
132
172
 
173
+ // Last event
133
174
  if (data.lastEvent) {
134
175
  const ago = data.lastEventAt
135
- ? formatUptime(Math.floor((Date.now() - data.lastEventAt) / 1000)) + " ago"
176
+ ? t(i18n.SCORE_AGO, { time: formatUptime(Math.floor((Date.now() - data.lastEventAt) / 1000)) })
136
177
  : "unknown";
137
178
  console.log(`\u251C${line}\u2524`);
138
- console.log(row(` Last: ${data.lastEvent.substring(0, 36)}`));
179
+ console.log(row(` ${vwPad(i18n.SCORE_LAST_EVENT, 6)}: ${data.lastEvent.substring(0, 34)}`));
139
180
  console.log(row(` ${ago}`));
140
181
  }
141
182
 
183
+ // Value Metrics section
184
+ try {
185
+ const summary = await daemonRequest<ShiftSummary>("/shift-summary");
186
+ console.log(`\u251C${line}\u2524`);
187
+ console.log(row(` ${i18n.SCORE_VALUE_TITLE}`));
188
+ console.log(row(` ${sep}`));
189
+ console.log(kv(i18n.SHIFT_TOKENS, `~${fmtNum(summary.totalTokensSaved)}`));
190
+ console.log(kv(i18n.SHIFT_TIME, `~${summary.totalMinutesSaved} min`));
191
+ console.log(kv(i18n.SHIFT_COST, `~$${summary.totalCostSaved.toFixed(2)}`));
192
+ if (summary.suppressionsSkipped > 0) {
193
+ console.log(kv(i18n.SHIFT_SUPPRESSED, `${summary.suppressionsSkipped}`));
194
+ }
195
+ console.log(`\u251C${line}\u2524`);
196
+ const boast = localizedBoast(lang);
197
+ console.log(row(` \uD83D\uDDE3\uFE0F ${boast.substring(0, W - 6)}`));
198
+ } catch {
199
+ // Non-fatal
200
+ }
201
+
142
202
  console.log(`\u2514${line}\u2518`);
143
203
  } catch (err: unknown) {
144
204
  const msg = err instanceof Error ? err.message : String(err);
@@ -1,42 +1,65 @@
1
- import { spawn } from "child_process";
2
1
  import { resolve } from "path";
2
+ import { spawn } from "child_process";
3
+ import { openSync, mkdirSync } from "fs";
3
4
  import { getDaemonInfo, isDaemonAlive } from "../daemon/client";
4
- import { AFD_DIR } from "../constants";
5
- import { mkdirSync } from "fs";
5
+ import { AFD_DIR, LOG_FILE, WATCH_TARGETS } from "../constants";
6
6
  import { detectEcosystem } from "../adapters/index";
7
+ import { detachedSpawnOptions, IS_WINDOWS } from "../platform";
8
+ import { getSystemLanguage } from "../core/locale";
9
+ import { getMessages, t } from "../core/i18n/messages";
10
+ import { discoverWatchTargets } from "../core/discovery";
11
+
12
+ const STARTUP_POLL_INTERVAL_MS = 100;
13
+ const STARTUP_POLL_MAX_MS = 3000;
14
+
15
+ export async function startCommand(options?: { mcp?: boolean }) {
16
+ // MCP stdio mode: run daemon in foreground with stdio transport
17
+ if (options?.mcp) {
18
+ const { main: runDaemon } = await import("../daemon/server");
19
+ runDaemon({ mcp: true });
20
+ return; // never reaches here — stdio loop blocks
21
+ }
22
+
23
+ const lang = getSystemLanguage();
24
+ const msg = getMessages(lang);
7
25
 
8
- export async function startCommand() {
9
26
  mkdirSync(AFD_DIR, { recursive: true });
10
27
 
11
- // Check if already running
28
+ // ── Idempotency: check if already running ──
12
29
  const existing = getDaemonInfo();
13
- if (existing && await isDaemonAlive(existing)) {
14
- console.log(`[afd] Daemon already running (pid=${existing.pid}, port=${existing.port})`);
30
+ if (existing && (await isDaemonAlive(existing))) {
31
+ console.log(msg.DAEMON_ALREADY_RUNNING);
15
32
  return;
16
33
  }
17
34
 
18
- // Spawn detached daemon
35
+ // ── Spawn detached daemon with log redirection ──
19
36
  const daemonScript = resolve(import.meta.dirname, "../daemon/server.ts");
20
- const bunPath = process.execPath;
37
+ const logPath = resolve(LOG_FILE);
38
+ const logFd = openSync(logPath, "a"); // append mode
39
+
40
+ // On Windows, wrap in shell for proper detach; quote path for spaces
41
+ const args = IS_WINDOWS
42
+ ? ["run", `"${daemonScript}"`]
43
+ : ["run", daemonScript];
21
44
 
22
- const child = spawn(bunPath, ["run", daemonScript], {
23
- detached: true,
24
- stdio: ["ignore", "ignore", "ignore"],
25
- cwd: process.cwd(),
26
- env: { ...process.env },
27
- });
45
+ const child = spawn("bun", args, detachedSpawnOptions(logFd));
28
46
 
47
+ // Detach: allow parent to exit without killing child
29
48
  child.unref();
30
49
 
31
- // Wait for daemon to write its port file (Windows needs more time)
32
- await new Promise((r) => setTimeout(r, 1500));
50
+ // ── Poll for daemon readiness instead of fixed sleep ──
51
+ const info = await pollForDaemon(STARTUP_POLL_MAX_MS, STARTUP_POLL_INTERVAL_MS);
33
52
 
34
- const info = getDaemonInfo();
35
- if (info && await isDaemonAlive(info)) {
36
- console.log(`[afd] Daemon started (pid=${info.pid}, port=${info.port})`);
37
- console.log(`[afd] Watching: .claude/, CLAUDE.md, .cursorrules`);
53
+ if (info) {
54
+ console.log(t(msg.DAEMON_STARTED, { pid: info.pid, port: info.port }));
38
55
 
39
- // Silently inject auto-heal hook and status line into detected ecosystem
56
+ // Smart Discovery: show what we're actually watching
57
+ const discovery = discoverWatchTargets(WATCH_TARGETS);
58
+ console.log(t(msg.DAEMON_WATCHING, { count: discovery.targets.length }));
59
+ console.log(`[afd] Targets: ${discovery.targets.join(", ")}`);
60
+ console.log(t(msg.DAEMON_LOGS, { path: logPath }));
61
+
62
+ // Inject hooks into detected ecosystems
40
63
  const ecosystems = detectEcosystem(process.cwd());
41
64
  for (const { adapter } of ecosystems) {
42
65
  if (adapter.injectHooks) {
@@ -49,7 +72,25 @@ export async function startCommand() {
49
72
  }
50
73
  }
51
74
  } else {
52
- console.error("[afd] Failed to start daemon. Check logs.");
75
+ console.error(t(msg.DAEMON_START_FAILED, { path: logPath }));
53
76
  process.exit(1);
54
77
  }
55
78
  }
79
+
80
+ /** Poll until daemon PID/port files appear and health check passes */
81
+ async function pollForDaemon(
82
+ maxMs: number,
83
+ intervalMs: number,
84
+ ): Promise<{ pid: number; port: number } | null> {
85
+ const deadline = Date.now() + maxMs;
86
+
87
+ while (Date.now() < deadline) {
88
+ const info = getDaemonInfo();
89
+ if (info && (await isDaemonAlive(info))) {
90
+ return info;
91
+ }
92
+ await Bun.sleep(intervalMs);
93
+ }
94
+
95
+ return null;
96
+ }
@@ -1,6 +1,10 @@
1
1
  import { getDaemonInfo, isDaemonAlive, daemonRequest } from "../daemon/client";
2
2
  import { unlinkSync } from "fs";
3
3
  import { PID_FILE, PORT_FILE } from "../constants";
4
+ import { formatShiftSummary } from "../core/boast";
5
+ import type { ShiftSummary } from "../core/boast";
6
+ import { getSystemLanguage } from "../core/locale";
7
+ import { getMessages, t } from "../core/i18n/messages";
4
8
 
5
9
  function cleanupFiles() {
6
10
  try { unlinkSync(PID_FILE); } catch {}
@@ -8,27 +12,37 @@ function cleanupFiles() {
8
12
  }
9
13
 
10
14
  export async function stopCommand() {
15
+ const lang = getSystemLanguage();
16
+ const msg = getMessages(lang);
11
17
  const info = getDaemonInfo();
18
+
12
19
  if (!info) {
13
- console.log("[afd] No daemon running.");
20
+ console.log(msg.DAEMON_NOT_RUNNING);
14
21
  return;
15
22
  }
16
23
 
17
24
  if (await isDaemonAlive(info)) {
25
+ // Fetch shift summary before stopping
26
+ try {
27
+ const summary = await daemonRequest<ShiftSummary>("/shift-summary");
28
+ console.log(formatShiftSummary(summary, lang));
29
+ } catch {
30
+ // Non-fatal: summary is a nicety, not a requirement
31
+ }
32
+
18
33
  try {
19
34
  await daemonRequest("/stop");
20
- console.log(`[afd] Daemon stopped (pid=${info.pid})`);
35
+ console.log(t(msg.DAEMON_STOPPED, { pid: info.pid }));
21
36
  } catch {
22
- // Force kill if graceful stop fails
23
37
  try {
24
38
  process.kill(info.pid, "SIGTERM");
25
- console.log(`[afd] Daemon killed (pid=${info.pid})`);
39
+ console.log(t(msg.DAEMON_KILLED, { pid: info.pid }));
26
40
  } catch {
27
41
  console.log("[afd] Daemon process already gone.");
28
42
  }
29
43
  }
30
44
  } else {
31
- console.log("[afd] Daemon not responding. Cleaning up stale PID files.");
45
+ console.log(msg.DAEMON_NOT_RESPONDING);
32
46
  }
33
47
 
34
48
  cleanupFiles();
package/src/constants.ts CHANGED
@@ -4,4 +4,5 @@ export const AFD_DIR = ".afd";
4
4
  export const PID_FILE = join(AFD_DIR, "daemon.pid");
5
5
  export const PORT_FILE = join(AFD_DIR, "daemon.port");
6
6
  export const DB_FILE = join(AFD_DIR, "antibodies.sqlite");
7
- export const WATCH_TARGETS = [".claude/", "CLAUDE.md", ".cursorrules"];
7
+ export const LOG_FILE = join(AFD_DIR, "daemon.log");
8
+ export const WATCH_TARGETS = [".claude/", "CLAUDE.md", ".cursorrules", ".claudeignore", ".gitignore"];
@@ -0,0 +1,265 @@
1
+ /**
2
+ * Boastful Doctor — Gamification & Delightful Logging
3
+ *
4
+ * Lightweight value calculations that stay well under the 270ms budget.
5
+ * All math is O(1) — no I/O, no async.
6
+ * All strings are localized via the i18n dictionary.
7
+ */
8
+
9
+ import { getSystemLanguage } from "./locale";
10
+ import type { SupportedLang } from "./locale";
11
+ import { getMessages, t } from "./i18n/messages";
12
+ import type { MessageDict } from "./i18n/messages";
13
+
14
+ // ── Token & Cost Estimation ──
15
+
16
+ /** Rough chars-per-token ratio for code (conservative estimate) */
17
+ const CHARS_PER_TOKEN = 3.5;
18
+
19
+ /** Average cost per 1K input tokens (Claude Sonnet ballpark) */
20
+ const COST_PER_1K_TOKENS = 0.003;
21
+
22
+ /** Estimated minutes a developer spends debugging a missing config */
23
+ const DEBUG_MINUTES_BASE = 8;
24
+ const DEBUG_MINUTES_PER_KB = 2;
25
+
26
+ export interface HealMetrics {
27
+ fileSize: number;
28
+ healTimeMs: number;
29
+ tokensSaved: number;
30
+ minutesSaved: number;
31
+ costSaved: number;
32
+ }
33
+
34
+ /** Calculate mock "value saved" from a heal event. O(1), no I/O. */
35
+ export function calcHealMetrics(fileSize: number, healTimeMs: number): HealMetrics {
36
+ const tokensSaved = Math.round(fileSize / CHARS_PER_TOKEN);
37
+ const fileSizeKB = fileSize / 1024;
38
+ const minutesSaved = Math.round(DEBUG_MINUTES_BASE + fileSizeKB * DEBUG_MINUTES_PER_KB);
39
+ const costSaved = Math.round(tokensSaved / 1000 * COST_PER_1K_TOKENS * 100) / 100;
40
+ return { fileSize, healTimeMs, tokensSaved, minutesSaved, costSaved };
41
+ }
42
+
43
+ export interface ShiftSummary {
44
+ uptimeFormatted: string;
45
+ totalEvents: number;
46
+ healsPerformed: number;
47
+ totalTokensSaved: number;
48
+ totalMinutesSaved: number;
49
+ totalCostSaved: number;
50
+ suppressionsSkipped: number;
51
+ dormantTransitions: number;
52
+ boast: string;
53
+ }
54
+
55
+ /** Build a shift summary from aggregated daemon stats. */
56
+ export function buildShiftSummary(stats: {
57
+ uptimeSeconds: number;
58
+ totalEvents: number;
59
+ healsPerformed: number;
60
+ totalFileBytesSaved: number;
61
+ suppressionsSkipped: number;
62
+ dormantTransitions: number;
63
+ }, lang?: SupportedLang): ShiftSummary {
64
+ const l = lang ?? getSystemLanguage();
65
+ const msg = getMessages(l);
66
+ const totalTokensSaved = Math.round(stats.totalFileBytesSaved / CHARS_PER_TOKEN);
67
+ const totalMinutesSaved = stats.healsPerformed * DEBUG_MINUTES_BASE;
68
+ const totalCostSaved = Math.round(totalTokensSaved / 1000 * COST_PER_1K_TOKENS * 100) / 100;
69
+
70
+ return {
71
+ uptimeFormatted: formatUptime(stats.uptimeSeconds),
72
+ totalEvents: stats.totalEvents,
73
+ healsPerformed: stats.healsPerformed,
74
+ totalTokensSaved,
75
+ totalMinutesSaved,
76
+ totalCostSaved,
77
+ suppressionsSkipped: stats.suppressionsSkipped,
78
+ dormantTransitions: stats.dormantTransitions,
79
+ boast: pick(msg.BOAST_SHIFT_END),
80
+ };
81
+ }
82
+
83
+ // ── Boast Selection ──
84
+
85
+ function msg(lang?: SupportedLang): MessageDict {
86
+ return getMessages(lang ?? getSystemLanguage());
87
+ }
88
+
89
+ /** Pick a random heal boast. 1-in-N chance (anti-annoyance). */
90
+ export function maybeHealBoast(triggerChance = 5, lang?: SupportedLang): string | null {
91
+ if (Math.floor(Math.random() * triggerChance) !== 0) return null;
92
+ const m = msg(lang);
93
+ return pick(m.BOAST_HEAL);
94
+ }
95
+
96
+ /** Always returns a dormant boast (rare event, always worth noting). */
97
+ export function dormantBoast(lang?: SupportedLang): string {
98
+ return pick(msg(lang).BOAST_DORMANT);
99
+ }
100
+
101
+ /** Pick a random shift-end boast in the given locale. */
102
+ export function localizedBoast(lang?: SupportedLang): string {
103
+ return pick(msg(lang).BOAST_SHIFT_END);
104
+ }
105
+
106
+ /** Format a single heal log line with metrics. */
107
+ export function formatHealLog(
108
+ fileName: string,
109
+ metrics: HealMetrics,
110
+ boast: string | null,
111
+ lang?: SupportedLang,
112
+ ): string {
113
+ const m = msg(lang);
114
+ const vars = {
115
+ fileName,
116
+ ms: metrics.healTimeMs,
117
+ tokens: metrics.tokensSaved,
118
+ mins: metrics.minutesSaved,
119
+ };
120
+ const base = t(m.HEAL_LOG, vars);
121
+ if (!boast) return base;
122
+ const boastLine = t(boast, vars);
123
+ return `${base}\n${m.BOAST_HEAL_PREFIX} ${boastLine}`;
124
+ }
125
+
126
+ /** Format dormant log line. */
127
+ export function formatDormantLog(
128
+ antibodyId: string,
129
+ lang?: SupportedLang,
130
+ ): string {
131
+ const m = msg(lang);
132
+ const boast = pick(m.BOAST_DORMANT);
133
+ return t(m.DORMANT_LOG, { id: antibodyId, boast });
134
+ }
135
+
136
+ /** Format the full shift summary for terminal output. */
137
+ export function formatShiftSummary(s: ShiftSummary, lang?: SupportedLang): string {
138
+ const m = msg(lang);
139
+ const lines = [
140
+ "",
141
+ "┌──────────────────────────────────────────────┐",
142
+ pad(` ${m.SHIFT_TITLE}`),
143
+ "├──────────────────────────────────────────────┤",
144
+ padKV(m.SHIFT_ON_DUTY, s.uptimeFormatted),
145
+ padKV(m.SHIFT_EVENTS, String(s.totalEvents)),
146
+ padKV(m.SHIFT_HEALS, String(s.healsPerformed)),
147
+ padKV(m.SHIFT_TOKENS, `~${fmtNum(s.totalTokensSaved)}`),
148
+ padKV(m.SHIFT_TIME, `~${s.totalMinutesSaved} min`),
149
+ padKV(m.SHIFT_COST, `~$${s.totalCostSaved.toFixed(2)}`),
150
+ ];
151
+
152
+ if (s.suppressionsSkipped > 0) {
153
+ padKVPush(lines, m.SHIFT_SUPPRESSED, `${s.suppressionsSkipped} mass events`);
154
+ }
155
+ if (s.dormantTransitions > 0) {
156
+ padKVPush(lines, m.SHIFT_RETIRED, `${s.dormantTransitions} antibodies`);
157
+ }
158
+
159
+ lines.push("├──────────────────────────────────────────────┤");
160
+ // Override server-side boast with locale-appropriate one
161
+ const localBoast = pick(m.BOAST_SHIFT_END);
162
+ lines.push(pad(` ${localBoast}`));
163
+ lines.push("└──────────────────────────────────────────────┘");
164
+ lines.push("");
165
+
166
+ return lines.join("\n");
167
+ }
168
+
169
+ /** Format value section for score command. */
170
+ export function formatValueSection(s: ShiftSummary, lang?: SupportedLang): string[] {
171
+ const m = msg(lang);
172
+ const lines: string[] = [];
173
+ lines.push(m.SCORE_VALUE_TITLE);
174
+ lines.push(`${m.SHIFT_TOKENS}: ~${fmtNum(s.totalTokensSaved)}`);
175
+ lines.push(`${m.SHIFT_TIME}: ~${s.totalMinutesSaved} min`);
176
+ lines.push(`${m.SHIFT_COST}: ~$${s.totalCostSaved.toFixed(2)}`);
177
+ return lines;
178
+ }
179
+
180
+ // ── Helpers ──
181
+
182
+ function pick<T>(arr: T[]): T {
183
+ return arr[Math.floor(Math.random() * arr.length)];
184
+ }
185
+
186
+ function formatUptime(seconds: number): string {
187
+ if (seconds < 60) return `${seconds}s`;
188
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
189
+ const h = Math.floor(seconds / 3600);
190
+ const m = Math.floor((seconds % 3600) / 60);
191
+ return `${h}h ${m}m`;
192
+ }
193
+
194
+ const W = 46;
195
+
196
+ function pad(content: string): string {
197
+ const visual = visualWidth(content);
198
+ if (visual > W) {
199
+ let len = 0;
200
+ let cut = 0;
201
+ for (const ch of content) {
202
+ const cw = isWideChar(ch) ? 2 : 1;
203
+ if (len + cw > W - 1) break;
204
+ len += cw;
205
+ cut += ch.length;
206
+ }
207
+ const trimmed = content.slice(0, cut) + "…";
208
+ const trimVw = visualWidth(trimmed);
209
+ return `│${trimmed}${" ".repeat(Math.max(0, W - trimVw))}│`;
210
+ }
211
+ return `│${content}${" ".repeat(Math.max(0, W - visual))}│`;
212
+ }
213
+
214
+ /** Pad a key-value row with aligned colon, visual-width-aware. */
215
+ function padKV(key: string, value: string): string {
216
+ const keyVw = visualWidth(key);
217
+ const padSize = Math.max(0, 13 - keyVw);
218
+ return pad(` ${key}${" ".repeat(padSize)}: ${value}`);
219
+ }
220
+
221
+ function padKVPush(lines: string[], key: string, value: string): void {
222
+ lines.push(padKV(key, value));
223
+ }
224
+
225
+ export function visualWidth(s: string): number {
226
+ let w = 0;
227
+ for (const ch of s) {
228
+ w += isWideChar(ch) ? 2 : 1;
229
+ }
230
+ return w;
231
+ }
232
+
233
+ function isWideChar(ch: string): boolean {
234
+ const code = ch.codePointAt(0) ?? 0;
235
+ return (
236
+ // CJK Unified Ideographs
237
+ (code >= 0x4E00 && code <= 0x9FFF) ||
238
+ // CJK Extension A
239
+ (code >= 0x3400 && code <= 0x4DBF) ||
240
+ // Hangul Syllables
241
+ (code >= 0xAC00 && code <= 0xD7AF) ||
242
+ // Hangul Jamo
243
+ (code >= 0x1100 && code <= 0x11FF) ||
244
+ // Hangul Compatibility Jamo
245
+ (code >= 0x3130 && code <= 0x318F) ||
246
+ // CJK Compatibility
247
+ (code >= 0x3300 && code <= 0x33FF) ||
248
+ // Fullwidth Forms
249
+ (code >= 0xFF01 && code <= 0xFF60) ||
250
+ // Common emoji ranges
251
+ (code >= 0x1F300 && code <= 0x1FBFF) ||
252
+ (code >= 0x2600 && code <= 0x27BF) ||
253
+ (code >= 0xFE00 && code <= 0xFE0F) ||
254
+ (code >= 0x200D && code <= 0x200D) ||
255
+ (code >= 0x231A && code <= 0x23FA) ||
256
+ code === 0x2764 ||
257
+ code === 0x2139
258
+ );
259
+ }
260
+
261
+ export function fmtNum(n: number): string {
262
+ if (n < 1000) return `${n}`;
263
+ if (n < 1_000_000) return `${(n / 1000).toFixed(1)}K`;
264
+ return `${(n / 1_000_000).toFixed(1)}M`;
265
+ }