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.
- package/README.ko.md +93 -36
- package/README.md +84 -28
- package/package.json +1 -1
- package/src/adapters/index.ts +2 -1
- package/src/cli.ts +9 -1
- package/src/commands/lang.ts +41 -0
- package/src/commands/score.ts +91 -31
- package/src/commands/start.ts +64 -23
- package/src/commands/stop.ts +19 -5
- package/src/constants.ts +2 -1
- package/src/core/boast.ts +265 -0
- package/src/core/config.ts +49 -0
- package/src/core/discovery.ts +65 -0
- package/src/core/i18n/messages.ts +266 -0
- package/src/core/locale.ts +88 -0
- package/src/core/notify.ts +43 -12
- package/src/daemon/server.ts +150 -17
- package/src/platform.ts +39 -0
package/src/commands/score.ts
CHANGED
|
@@ -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;
|
|
74
|
+
const W = 46;
|
|
64
75
|
const line = "\u2500".repeat(W);
|
|
65
|
-
const
|
|
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(
|
|
104
|
+
console.log(row(` ${i18n.SCORE_TITLE}`));
|
|
74
105
|
console.log(`\u251C${line}\u2524`);
|
|
75
|
-
|
|
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(
|
|
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(
|
|
82
|
-
console.log(
|
|
83
|
-
console.log(
|
|
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(`
|
|
120
|
+
console.log(row(` ${vwPad(i18n.SCORE_ACTIVITY, 10)}${heatBar(data.totalEvents, 100)}`));
|
|
86
121
|
|
|
87
|
-
//
|
|
122
|
+
// Hologram section
|
|
88
123
|
console.log(`\u251C${line}\u2524`);
|
|
89
|
-
console.log(row(
|
|
90
|
-
console.log(row(`
|
|
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(
|
|
94
|
-
console.log(
|
|
95
|
-
console.log(
|
|
96
|
-
console.log(
|
|
97
|
-
console.log(row(`
|
|
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(
|
|
100
|
-
console.log(row(
|
|
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(
|
|
106
|
-
console.log(row(`
|
|
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 ?
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
console.log(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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))
|
|
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(`
|
|
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);
|
package/src/commands/start.ts
CHANGED
|
@@ -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
|
-
//
|
|
28
|
+
// ── Idempotency: check if already running ──
|
|
12
29
|
const existing = getDaemonInfo();
|
|
13
|
-
if (existing && await isDaemonAlive(existing)) {
|
|
14
|
-
console.log(
|
|
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
|
|
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(
|
|
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
|
-
//
|
|
32
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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
|
-
//
|
|
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(
|
|
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
|
+
}
|
package/src/commands/stop.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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
|
+
}
|