yappr 0.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.
Files changed (149) hide show
  1. package/.env.example +115 -0
  2. package/config/context/personality.md +7 -0
  3. package/config/context/security.md +10 -0
  4. package/config/hooks/example.ts +47 -0
  5. package/config/hooks/holder.ts +154 -0
  6. package/config/hooks/user-memory.ts +102 -0
  7. package/config/skills/compute/handler.ts +6 -0
  8. package/config/skills/compute/skill.md +7 -0
  9. package/config/skills/cron/handler.ts +89 -0
  10. package/config/skills/cron/skill.md +36 -0
  11. package/config/skills/generate-image/handler.ts +133 -0
  12. package/config/skills/generate-image/skill.md +20 -0
  13. package/config/skills/generate-meme-prompt/handler.ts +40 -0
  14. package/config/skills/generate-meme-prompt/skill.md +23 -0
  15. package/config/skills/stats/handler.ts +76 -0
  16. package/config/skills/stats/skill.md +18 -0
  17. package/config/skills/wallet/handler.ts +56 -0
  18. package/config/skills/wallet/skill.md +17 -0
  19. package/config/skills/x/handler.ts +135 -0
  20. package/config/skills/x/skill.md +163 -0
  21. package/dist/config/hooks/example.d.ts +2 -0
  22. package/dist/config/hooks/example.js +37 -0
  23. package/dist/config/hooks/holder.d.ts +2 -0
  24. package/dist/config/hooks/holder.js +147 -0
  25. package/dist/config/hooks/user-memory.d.ts +2 -0
  26. package/dist/config/hooks/user-memory.js +79 -0
  27. package/dist/config/skills/compute/handler.d.ts +2 -0
  28. package/dist/config/skills/compute/handler.js +5 -0
  29. package/dist/config/skills/cron/handler.d.ts +2 -0
  30. package/dist/config/skills/cron/handler.js +84 -0
  31. package/dist/config/skills/generate-image/handler.d.ts +2 -0
  32. package/dist/config/skills/generate-image/handler.js +122 -0
  33. package/dist/config/skills/generate-meme/handler.d.ts +2 -0
  34. package/dist/config/skills/generate-meme/handler.js +121 -0
  35. package/dist/config/skills/generate-meme-prompt/handler.d.ts +2 -0
  36. package/dist/config/skills/generate-meme-prompt/handler.js +38 -0
  37. package/dist/config/skills/stats/handler.d.ts +2 -0
  38. package/dist/config/skills/stats/handler.js +71 -0
  39. package/dist/config/skills/wallet/handler.d.ts +2 -0
  40. package/dist/config/skills/wallet/handler.js +54 -0
  41. package/dist/config/skills/x/handler.d.ts +2 -0
  42. package/dist/config/skills/x/handler.js +115 -0
  43. package/dist/src/agent-prompt.d.ts +1 -0
  44. package/dist/src/agent-prompt.js +45 -0
  45. package/dist/src/bankr.d.ts +41 -0
  46. package/dist/src/bankr.js +76 -0
  47. package/dist/src/cli/backup.d.ts +7 -0
  48. package/dist/src/cli/backup.js +78 -0
  49. package/dist/src/cli/charts.d.ts +32 -0
  50. package/dist/src/cli/charts.js +222 -0
  51. package/dist/src/cli/config-sync.d.ts +7 -0
  52. package/dist/src/cli/config-sync.js +71 -0
  53. package/dist/src/cli/deploy.d.ts +2 -0
  54. package/dist/src/cli/deploy.js +1059 -0
  55. package/dist/src/cli/env.d.ts +4 -0
  56. package/dist/src/cli/env.js +50 -0
  57. package/dist/src/cli/host-key.d.ts +4 -0
  58. package/dist/src/cli/host-key.js +50 -0
  59. package/dist/src/cli/index.d.ts +2 -0
  60. package/dist/src/cli/index.js +71 -0
  61. package/dist/src/cli/init.d.ts +1 -0
  62. package/dist/src/cli/init.js +51 -0
  63. package/dist/src/cli/ssh.d.ts +2 -0
  64. package/dist/src/cli/ssh.js +141 -0
  65. package/dist/src/cli/status.d.ts +7 -0
  66. package/dist/src/cli/status.js +1184 -0
  67. package/dist/src/cli/tui.d.ts +18 -0
  68. package/dist/src/cli/tui.js +115 -0
  69. package/dist/src/cli/ui.d.ts +30 -0
  70. package/dist/src/cli/ui.js +164 -0
  71. package/dist/src/cli/update.d.ts +1 -0
  72. package/dist/src/cli/update.js +263 -0
  73. package/dist/src/cli/x-login.d.ts +6 -0
  74. package/dist/src/cli/x-login.js +70 -0
  75. package/dist/src/compute.d.ts +11 -0
  76. package/dist/src/compute.js +109 -0
  77. package/dist/src/config-loader.d.ts +19 -0
  78. package/dist/src/config-loader.js +82 -0
  79. package/dist/src/config.d.ts +29 -0
  80. package/dist/src/config.js +68 -0
  81. package/dist/src/cron/capability.d.ts +6 -0
  82. package/dist/src/cron/capability.js +66 -0
  83. package/dist/src/cron/runner.d.ts +2 -0
  84. package/dist/src/cron/runner.js +113 -0
  85. package/dist/src/cron/schedule.d.ts +19 -0
  86. package/dist/src/cron/schedule.js +154 -0
  87. package/dist/src/cron/store.d.ts +46 -0
  88. package/dist/src/cron/store.js +220 -0
  89. package/dist/src/db.d.ts +4 -0
  90. package/dist/src/db.js +53 -0
  91. package/dist/src/hooks/loader.d.ts +1 -0
  92. package/dist/src/hooks/loader.js +17 -0
  93. package/dist/src/hooks/registry.d.ts +17 -0
  94. package/dist/src/hooks/registry.js +78 -0
  95. package/dist/src/hooks/types.d.ts +45 -0
  96. package/dist/src/hooks/types.js +1 -0
  97. package/dist/src/index.d.ts +25 -0
  98. package/dist/src/index.js +35 -0
  99. package/dist/src/llm/index.d.ts +23 -0
  100. package/dist/src/llm/index.js +213 -0
  101. package/dist/src/llm/prompts.d.ts +6 -0
  102. package/dist/src/llm/prompts.js +99 -0
  103. package/dist/src/log.d.ts +2 -0
  104. package/dist/src/log.js +30 -0
  105. package/dist/src/reply/agent.d.ts +20 -0
  106. package/dist/src/reply/agent.js +215 -0
  107. package/dist/src/reply/context-blocks.d.ts +12 -0
  108. package/dist/src/reply/context-blocks.js +22 -0
  109. package/dist/src/reply/gating.d.ts +3 -0
  110. package/dist/src/reply/gating.js +35 -0
  111. package/dist/src/reply/pipeline.d.ts +3 -0
  112. package/dist/src/reply/pipeline.js +144 -0
  113. package/dist/src/reply/poller.d.ts +5 -0
  114. package/dist/src/reply/poller.js +79 -0
  115. package/dist/src/skills/holder-access.d.ts +7 -0
  116. package/dist/src/skills/holder-access.js +53 -0
  117. package/dist/src/skills/loader.d.ts +2 -0
  118. package/dist/src/skills/loader.js +64 -0
  119. package/dist/src/skills/registry.d.ts +4 -0
  120. package/dist/src/skills/registry.js +10 -0
  121. package/dist/src/skills/types.d.ts +16 -0
  122. package/dist/src/skills/types.js +1 -0
  123. package/dist/src/state.d.ts +5 -0
  124. package/dist/src/state.js +26 -0
  125. package/dist/src/stats-cli.d.ts +1 -0
  126. package/dist/src/stats-cli.js +82 -0
  127. package/dist/src/stats.d.ts +41 -0
  128. package/dist/src/stats.js +236 -0
  129. package/dist/src/storage.d.ts +16 -0
  130. package/dist/src/storage.js +107 -0
  131. package/dist/src/treasury/abi.d.ts +99 -0
  132. package/dist/src/treasury/abi.js +71 -0
  133. package/dist/src/treasury/cycle.d.ts +16 -0
  134. package/dist/src/treasury/cycle.js +154 -0
  135. package/dist/src/treasury/index.d.ts +28 -0
  136. package/dist/src/treasury/index.js +222 -0
  137. package/dist/src/util.d.ts +3 -0
  138. package/dist/src/util.js +18 -0
  139. package/dist/src/wallet.d.ts +5 -0
  140. package/dist/src/wallet.js +241 -0
  141. package/dist/src/x/client.d.ts +74 -0
  142. package/dist/src/x/client.js +323 -0
  143. package/dist/src/x/types.d.ts +61 -0
  144. package/dist/src/x/types.js +1 -0
  145. package/dist/src/x402.d.ts +6 -0
  146. package/dist/src/x402.js +11 -0
  147. package/dist/src/yappr.d.ts +1 -0
  148. package/dist/src/yappr.js +85 -0
  149. package/package.json +52 -0
@@ -0,0 +1,1184 @@
1
+ // Live status dashboard for the deployed yappr instance (the `yappr status` command).
2
+ //
3
+ // yappr status # uses COMPUTE_INSTANCE_ID / cached COMPUTE_HOST
4
+ // yappr status <instanceId> # override the instance id
5
+ //
6
+ // Connects over SSH (no manual login) and renders a multi-panel terminal
7
+ // dashboard: agent config, server specs + pm2 health, activity counters from the
8
+ // stats DB, and a live `pm2 logs` feed. Also auto-launched at the end of deploy.
9
+ // Ctrl+C to exit.
10
+ import "dotenv/config";
11
+ import { resolve } from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+ import { spawn } from "node:child_process";
14
+ import { NodeSSH } from "node-ssh";
15
+ import stringWidth from "string-width";
16
+ import { createPublicClient, http, formatUnits } from "viem";
17
+ import { base } from "viem/chains";
18
+ import { resolveEvmAddress, fetchComputeInstance, fetchOneTimePassword, computeInstanceIp, computeInstancePassword, remainingComputeHours, } from "../compute.js";
19
+ import { dim, bold, green, yellow, red, cyan, accent, YAPPR_LOGO, fit, kv, panel, sideBySide, padRows, centerRows, themeLine, toggleTheme, setTheme, detectTerminalTheme, } from "./ui.js";
20
+ import { backupRemoteDb, backupLabel } from "./backup.js";
21
+ import { hostKeyConfig } from "./host-key.js";
22
+ import { SPENT_RGB, EARN_RGB, CAT_RGB, catColor, HOUR_MS, renderLineChart, renderHourlyBars, renderHourlySpentEarned, } from "./charts.js";
23
+ import { envNumber } from "../util.js";
24
+ const TREASURY_INTERVAL_MS = envNumber("TREASURY_INTERVAL_MS", 3_600_000);
25
+ // How often the dashboard pulls a DB snapshot into instance/backups/ (default 20 min).
26
+ const BACKUP_INTERVAL_MS = envNumber("STATUS_BACKUP_INTERVAL_MS", 1_200_000);
27
+ // Runway model. Below this many hours of recorded activity the measured burn rate is
28
+ // too noisy to trust, so we fall back to a predicted floor from the poll cadence.
29
+ const RUNWAY_MIN_DATA_HOURS = 1;
30
+ // Predicted cold-start burn: the always-on cost is the mentions poll (~$0.005 per x402
31
+ // call) at the configured cadence. Event-driven costs (LLM, replies, compute) only join
32
+ // once the measured window takes over.
33
+ const X_API_POLL_COST_USD = 0.005;
34
+ const POLL_METHOD = (process.env.POLL_METHOD || "search").toLowerCase();
35
+ const POLL_SECONDS = Math.round(envNumber("POLL_INTERVAL_MS", 20_000) / 1000);
36
+ // Treasury balances + remaining compute refresh on this cadence (default 5 min).
37
+ const BALANCE_INTERVAL_MS = envNumber("STATUS_BALANCE_INTERVAL_MS", 300_000);
38
+ // Bankr LLM Gateway (inference credits). Read only to display the live balance (the
39
+ // remaining inference budget); the agent owns inference *spend* tracking, costed
40
+ // per-request from token usage × per-model pricing, in the ledger.
41
+ const LLM_URL = process.env.BANKR_LLM_URL || "https://llm.bankr.bot";
42
+ const LLM_KEY = process.env.BANKR_LLM_KEY || process.env.BANKR_API_KEY;
43
+ // How the dashboard reads the agent's stats. The agent (always-on) records every
44
+ // counted event into a SQLite DB; the dashboard only reads, via the compiled CLI
45
+ // (`stats-cli summary`), which prints rolled-up totals as JSON. No log scraping.
46
+ // `cd /yappr` so stats-cli's `dotenv/config` picks up /yappr/.env (DB_PATH →
47
+ // /var/lib/yappr/yappr.db), opening the same DB the agent writes to. The engine is
48
+ // installed under node_modules/yappr, so the compiled CLI lives there.
49
+ const STATS_QUERY_CMD = process.env.STATUS_STATS_CMD || "cd /yappr && node node_modules/yappr/dist/src/stats-cli.js summary";
50
+ // Same CLI, `cron` subcommand — the cron_jobs rows as JSON for the CRON JOBS page.
51
+ const CRON_QUERY_CMD = process.env.STATUS_CRON_CMD || "cd /yappr && node node_modules/yappr/dist/src/stats-cli.js cron";
52
+ // ─── on-chain balances (Base) ──────────────────────────────────────────────────
53
+ const WETH_ADDR = "0x4200000000000000000000000000000000000006";
54
+ // Where the treasury cycle burns the agent's token (same constant as treasury/index.ts).
55
+ const BURN_ADDR = "0x000000000000000000000000000000000000dead";
56
+ // Every Bankr token launch is a fixed-supply Clanker deploy: 100B tokens, always —
57
+ // so the burned % of supply is computed against this constant rather than fetched.
58
+ const TOKEN_TOTAL_SUPPLY = 100_000_000_000;
59
+ const USDC_ADDR = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
60
+ const ERC20_VIEW_ABI = [
61
+ { name: "balanceOf", type: "function", stateMutability: "view", inputs: [{ name: "o", type: "address" }], outputs: [{ type: "uint256" }] },
62
+ { name: "symbol", type: "function", stateMutability: "view", inputs: [], outputs: [{ type: "string" }] },
63
+ { name: "decimals", type: "function", stateMutability: "view", inputs: [], outputs: [{ type: "uint8" }] },
64
+ ];
65
+ const baseClient = createPublicClient({ chain: base, transport: http() });
66
+ // Spot USD prices for the treasury assets, via DefiLlama's free coins API. Returns
67
+ // null prices on failure so the total degrades to "unavailable" rather than wrong.
68
+ async function fetchPrices(tokenAddress) {
69
+ try {
70
+ const keys = [`base:${WETH_ADDR}`, `base:${USDC_ADDR}`, `base:${tokenAddress}`];
71
+ const res = await fetch(`https://coins.llama.fi/prices/current/${keys.join(",")}`);
72
+ if (!res.ok)
73
+ return null;
74
+ const coins = (await res.json())?.coins ?? {};
75
+ const price = (k) => Number(coins[k.toLowerCase()]?.price ?? coins[k]?.price ?? 0);
76
+ return { eth: price(`base:${WETH_ADDR}`), usdc: price(`base:${USDC_ADDR}`) || 1, token: price(`base:${tokenAddress}`) };
77
+ }
78
+ catch {
79
+ return null;
80
+ }
81
+ }
82
+ async function fetchBalances(address, tokenAddress) {
83
+ try {
84
+ // All contract reads ride ONE Multicall3 eth_call: the public Base RPC
85
+ // rate-limits concurrent requests per IP (HTTP 429 at just a handful), so
86
+ // firing them individually made the whole fetch fail more often than not
87
+ // and left the TREASURY panel stuck on "...". RPC requests here: 2 total.
88
+ const [mc, eth, prices] = await Promise.all([
89
+ baseClient.multicall({
90
+ contracts: [
91
+ { address: tokenAddress, abi: ERC20_VIEW_ABI, functionName: "balanceOf", args: [address] },
92
+ { address: WETH_ADDR, abi: ERC20_VIEW_ABI, functionName: "balanceOf", args: [address] },
93
+ { address: USDC_ADDR, abi: ERC20_VIEW_ABI, functionName: "balanceOf", args: [address] },
94
+ { address: tokenAddress, abi: ERC20_VIEW_ABI, functionName: "balanceOf", args: [BURN_ADDR] },
95
+ { address: tokenAddress, abi: ERC20_VIEW_ABI, functionName: "symbol" },
96
+ { address: tokenAddress, abi: ERC20_VIEW_ABI, functionName: "decimals" },
97
+ ],
98
+ allowFailure: true,
99
+ }),
100
+ baseClient.getBalance({ address }),
101
+ fetchPrices(tokenAddress),
102
+ ]);
103
+ const big = (i) => (mc[i].status === "success" ? mc[i].result : null);
104
+ const [token, weth, usdc, burned] = [big(0), big(1), big(2), big(3)];
105
+ // Balances must be right or absent — a failed read keeps the "..." placeholder.
106
+ if (token === null || weth === null || usdc === null || burned === null)
107
+ return null;
108
+ const symbol = mc[4].status === "success" ? mc[4].result : "TOKEN";
109
+ const dec = mc[5].status === "success" ? Number(mc[5].result) : 18;
110
+ let usdTotal = null;
111
+ let usd = null;
112
+ if (prices) {
113
+ usd = {
114
+ token: Number(formatUnits(token, dec)) * prices.token,
115
+ weth: Number(formatUnits(weth, 18)) * prices.eth,
116
+ eth: Number(formatUnits(eth, 18)) * prices.eth,
117
+ usdc: Number(formatUnits(usdc, 6)) * prices.usdc,
118
+ };
119
+ usdTotal = usd.token + usd.weth + usd.eth + usd.usdc;
120
+ }
121
+ return { token, weth, eth, usdc, burned, symbol, decimals: dec, usdTotal, ethUsd: prices?.eth ?? null, usd };
122
+ }
123
+ catch {
124
+ return null;
125
+ }
126
+ }
127
+ // ─── inference credits (Bankr LLM Gateway) ─────────────────────────────────────
128
+ // Current LLM credit balance in USD, or null if unavailable. 402 means "no credits"
129
+ // → 0. This is the inference budget the agent draws down on every LLM request.
130
+ async function fetchLlmCredits() {
131
+ if (!LLM_KEY)
132
+ return null;
133
+ try {
134
+ const res = await fetch(`${LLM_URL}/v1/credits`, {
135
+ headers: { "X-API-Key": LLM_KEY, "User-Agent": "yappr-status/0.1" },
136
+ signal: AbortSignal.timeout(10_000),
137
+ });
138
+ if (res.status === 402)
139
+ return 0;
140
+ if (!res.ok)
141
+ return null;
142
+ const body = (await res.json());
143
+ return Number(body.balanceUsd ?? 0);
144
+ }
145
+ catch {
146
+ return null;
147
+ }
148
+ }
149
+ // All-time activity + spend, queried from the agent's SQLite stats DB via the CLI
150
+ // (the agent is the always-on writer, so this counts everything — including events
151
+ // that happened while the dashboard was closed). Spend already includes x-api,
152
+ // compute, inference and x402; earnings come back as WETH — no client-side aggregation.
153
+ async function fetchAgentStats(ssh) {
154
+ const res = await ssh.execCommand(STATS_QUERY_CMD, { cwd: "/" }).catch(() => null);
155
+ if (!res?.stdout)
156
+ return null;
157
+ try {
158
+ const s = JSON.parse(res.stdout);
159
+ const numArr = (a) => (Array.isArray(a) ? a.map(Number) : []);
160
+ const cs = (c) => ({ spendUsd: numArr(c?.spendUsd), earnedWeth: numArr(c?.earnedWeth), startMs: Number(c?.startMs) || 0, endMs: Number(c?.endMs) || 0 });
161
+ return {
162
+ mentions: Number(s.mentions) || 0,
163
+ replies: Number(s.replies) || 0,
164
+ llmTurns: Number(s.llm) || 0,
165
+ spentUsd: Number(s.spentUsd) || 0,
166
+ warns: Number(s.warns) || 0,
167
+ errors: Number(s.errors) || 0,
168
+ earnedWeth: Number(s.earnedWeth) || 0,
169
+ devWeth: Number(s.devWeth) || 0,
170
+ spentUsdWindow: Number(s.spentUsdWindow) || 0,
171
+ inferenceUsdWindow: Number(s.inferenceUsdWindow) || 0,
172
+ earnedWethWindow: Number(s.earnedWethWindow) || 0,
173
+ rateWindowHours: Number(s.rateWindowHours) || 0,
174
+ spentByType: {
175
+ "x-api": Number(s.spentByType?.["x-api"]) || 0,
176
+ inference: Number(s.spentByType?.inference) || 0,
177
+ compute: Number(s.spentByType?.compute) || 0,
178
+ x402: Number(s.spentByType?.x402) || 0,
179
+ },
180
+ chart: {
181
+ day: cs(s.chart?.day),
182
+ byType: {
183
+ startMs: Number(s.chart?.byType?.startMs) || 0,
184
+ xapi: numArr(s.chart?.byType?.xapi),
185
+ inference: numArr(s.chart?.byType?.inference),
186
+ compute: numArr(s.chart?.byType?.compute),
187
+ x402: numArr(s.chart?.byType?.x402),
188
+ earned: numArr(s.chart?.byType?.earned),
189
+ },
190
+ },
191
+ };
192
+ }
193
+ catch {
194
+ return null;
195
+ }
196
+ }
197
+ // Cron jobs for the CRON JOBS page, read via `stats-cli cron` (same DB, same
198
+ // pattern as fetchAgentStats). Returns null when the query fails or the deployed
199
+ // engine predates the subcommand — the page shows a placeholder in that case.
200
+ async function fetchCronJobs(ssh) {
201
+ const res = await ssh.execCommand(CRON_QUERY_CMD, { cwd: "/" }).catch(() => null);
202
+ if (!res?.stdout)
203
+ return null;
204
+ try {
205
+ const arr = JSON.parse(res.stdout);
206
+ if (!Array.isArray(arr))
207
+ return null;
208
+ return arr.map((j) => ({
209
+ id: Number(j.id) || 0,
210
+ prompt: String(j.prompt ?? ""),
211
+ schedule: String(j.schedule ?? ""),
212
+ creator: String(j.creator ?? ""),
213
+ enabled: !!j.enabled,
214
+ nextRunAt: Number(j.nextRunAt) || 0,
215
+ lastRunAt: j.lastRunAt != null ? Number(j.lastRunAt) : null,
216
+ lastResult: j.lastResult != null ? String(j.lastResult) : null,
217
+ lastError: j.lastError != null ? String(j.lastError) : null,
218
+ runs: Number(j.runs) || 0,
219
+ consecutiveFailures: Number(j.consecutiveFailures) || 0,
220
+ }));
221
+ }
222
+ catch {
223
+ return null;
224
+ }
225
+ }
226
+ const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, "");
227
+ // Reject if a promise hasn't settled within `ms` — used to bound the on-quit backup
228
+ // so a hung SSH/snapshot can't block the dashboard from exiting.
229
+ const withTimeout = (p, ms) => Promise.race([p, new Promise((_, reject) => setTimeout(() => reject(new Error("timed out")), ms))]);
230
+ // Display a log line with pm2/pino-style coloring. pino-pretty (colorize:true)
231
+ // already writes ANSI colors into the pm2 log files; we strip those and recolor
232
+ // every `[time] LEVEL: msg {json}` line ourselves (dim timestamp, severity-colored
233
+ // level, plain message, dim JSON tail) so the feed follows the dashboard palette
234
+ // instead of pino's 16-color codes (which terminal themes remap arbitrarily).
235
+ function displayLog(raw) {
236
+ const line = stripAnsi(raw);
237
+ const m = line.match(/^(\[[^\]]*\])\s+(\w+):\s*(.*)$/s);
238
+ if (!m)
239
+ return dim(line);
240
+ const [, ts, level, rest] = m;
241
+ const u = level.toUpperCase();
242
+ const lvl = u === "ERROR" || u === "FATAL" ? red : u === "WARN" ? yellow : u === "INFO" ? green : u === "DEBUG" || u === "TRACE" ? cyan : dim;
243
+ const om = rest.match(/^(.*?)\s(\{.*\}|\[.*\])\s*$/s);
244
+ const msg = om ? om[1] : rest;
245
+ const obj = om ? om[2] : "";
246
+ return `${dim(ts)} ${bold(lvl(u))}${dim(":")} ${msg}${obj ? " " + dim(obj) : ""}`;
247
+ }
248
+ // ─── formatting ─────────────────────────────────────────────────────────────
249
+ function fmtDuration(ms) {
250
+ if (!ms || ms < 0)
251
+ return "—";
252
+ const sec = Math.floor(ms / 1000);
253
+ const d = Math.floor(sec / 86400), h = Math.floor((sec % 86400) / 3600), m = Math.floor((sec % 3600) / 60);
254
+ if (d)
255
+ return `${d}d ${h}h`;
256
+ if (h)
257
+ return `${h}h ${m}m`;
258
+ if (m)
259
+ return `${m}m ${sec % 60}s`;
260
+ return `${sec}s`;
261
+ }
262
+ const fmtMem = (bytes) => (bytes ? `${(bytes / 1024 / 1024).toFixed(0)}MB` : "-");
263
+ // Compact token amount (thousands/millions) from a wei bigint.
264
+ function fmtToken(v, decimals) {
265
+ const n = Number(formatUnits(v, decimals));
266
+ if (n === 0)
267
+ return "0";
268
+ if (n >= 1_000_000)
269
+ return `${(n / 1_000_000).toFixed(2)}M`;
270
+ if (n >= 1_000)
271
+ return `${(n / 1_000).toFixed(2)}K`;
272
+ if (n >= 1)
273
+ return n.toFixed(2);
274
+ return n.toPrecision(2);
275
+ }
276
+ // ETH/WETH with 4 decimals; tiny non-zero balances clamp to "<0.0001".
277
+ function fmtEth(v) {
278
+ const n = Number(formatUnits(v, 18));
279
+ if (n === 0)
280
+ return "0";
281
+ if (n < 0.0001)
282
+ return "<0.0001";
283
+ return n.toFixed(4);
284
+ }
285
+ // Percentage of the fixed 100B supply, with enough precision to show early burns
286
+ // (e.g. "0.0025%") without padding mature ones (e.g. "12.4%").
287
+ function fmtSupplyPct(tokens) {
288
+ const p = (tokens / TOKEN_TOTAL_SUPPLY) * 100;
289
+ if (p === 0)
290
+ return "0%";
291
+ if (p >= 1)
292
+ return `${p.toFixed(1)}%`;
293
+ return `${p.toPrecision(2)}%`;
294
+ }
295
+ const fmtUsdc = (v) => `$${Number(formatUnits(v, 6)).toFixed(2)}`;
296
+ const fmtUsd = (n) => `$${n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
297
+ // Spend is often sub-cent (calls cost ~$0.0025–$0.01), so show 4 dp until it tops $1.
298
+ const fmtSpent = (n) => `$${n.toFixed(n >= 1 ? 2 : 4)}`;
299
+ // Space-between justification of segments to exactly `width` columns.
300
+ function justify(segments, width) {
301
+ if (segments.length === 1)
302
+ return fit(segments[0], width);
303
+ const content = segments.reduce((n, s) => n + stringWidth(s), 0);
304
+ const gaps = segments.length - 1;
305
+ const space = width - content;
306
+ if (space <= gaps)
307
+ return fit(segments.join(" "), width);
308
+ const base = Math.floor(space / gaps);
309
+ let rem = space - base * gaps;
310
+ let line = segments[0];
311
+ for (let i = 1; i < segments.length; i++) {
312
+ line += " ".repeat(base + (rem-- > 0 ? 1 : 0)) + segments[i];
313
+ }
314
+ return line;
315
+ }
316
+ // ─── remote queries ───────────────────────────────────────────────────────────
317
+ async function fetchPm2(ssh) {
318
+ const res = await ssh.execCommand("pm2 jlist", { cwd: "/" });
319
+ if (!res.stdout)
320
+ return null;
321
+ let list;
322
+ try {
323
+ list = JSON.parse(res.stdout);
324
+ }
325
+ catch {
326
+ return null;
327
+ }
328
+ const proc = Array.isArray(list) ? list.find((x) => x?.name === "yappr") : null;
329
+ if (!proc)
330
+ return null;
331
+ const env = proc.pm2_env ?? {};
332
+ return {
333
+ status: env.status ?? "unknown",
334
+ bootMs: env.status === "online" && env.pm_uptime ? env.pm_uptime : 0,
335
+ restarts: env.restart_time ?? 0,
336
+ mem: proc.monit?.memory ?? 0,
337
+ cpu: proc.monit?.cpu ?? 0,
338
+ };
339
+ }
340
+ async function fetchSpecs(ssh) {
341
+ const run = async (cmd) => (await ssh.execCommand(cmd, { cwd: "/" })).stdout.trim();
342
+ const [cpu, ram, disk, os] = await Promise.all([
343
+ run("nproc"),
344
+ run("free -h | awk 'NR==2{print $2}'"),
345
+ run("df -h / | awk 'NR==2{print $2}'"),
346
+ run(". /etc/os-release 2>/dev/null; echo $PRETTY_NAME"),
347
+ ]).catch(() => ["", "", "", ""]);
348
+ return { cpu: cpu || "?", ram: ram || "?", disk: disk || "?", os: os || "Linux" };
349
+ }
350
+ // Whole-system usage (all processes), refreshed on the pm2 tick: RAM used in MB
351
+ // (`free -m`, the "used" column), CPU busy % over a 1s sample (`vmstat 1 2`,
352
+ // 100 − idle), and disk used on / (`df -h`, the "Used" column). Distinct from
353
+ // pm2's per-process figures.
354
+ async function fetchSysUsage(ssh) {
355
+ const cmd = `echo "MEM:$(free -m | awk 'NR==2{print $3}')"; echo "CPU:$(vmstat 1 2 | tail -1 | awk '{print 100-$15}')"; echo "DISK:$(df -h / | awk 'NR==2{print $3}')"`;
356
+ const res = await ssh.execCommand(cmd, { cwd: "/" }).catch(() => null);
357
+ const out = res?.stdout ?? "";
358
+ const mem = out.match(/MEM:(\d+)/);
359
+ const cpu = out.match(/CPU:(\d+(?:\.\d+)?)/);
360
+ const disk = out.match(/DISK:(\S+)/);
361
+ return {
362
+ memMb: mem ? Number(mem[1]) : null,
363
+ cpu: cpu ? Math.round(Number(cpu[1])) : null,
364
+ diskUsed: disk ? disk[1] : null,
365
+ };
366
+ }
367
+ // Footer line: a pending y/n confirmation when armed, otherwise the key hints.
368
+ function footerLine(state, hints, cols) {
369
+ const footer = state.confirm
370
+ ? `${yellow(state.confirm.prompt)} ${accent("y")}${dim("/")}${accent("Enter")} ${dim("to confirm, any other key cancels")}`
371
+ : hints.join(dim(" ")) + ` ${dim("· safe to quit — reopen with")} ${accent("npx yappr status")}`;
372
+ return fit(footer, cols);
373
+ }
374
+ const key = (k, label) => `${accent(k)} ${dim(label)}`;
375
+ // ─── cron jobs page ────────────────────────────────────────────────────────────
376
+ // Each job renders as its own panel: 3 content lines + 2 border lines, so
377
+ // pagination is simple: as many whole boxes as fit the terminal, ←/→ to page.
378
+ const CRON_LINES_PER_JOB = 5;
379
+ function buildCronFrame(state, cols, rows) {
380
+ const out = [""];
381
+ const now = Date.now();
382
+ const jobs = state.cron;
383
+ const PAD = 8; // label column of the box body lines
384
+ // Box rows available: total − top margin (1) − header (1) − footer (1).
385
+ const bodyRows = Math.max(CRON_LINES_PER_JOB, rows - 3);
386
+ const pageSize = Math.max(1, Math.floor(bodyRows / CRON_LINES_PER_JOB));
387
+ const pages = Math.max(1, Math.ceil((jobs?.length ?? 0) / pageSize));
388
+ state.cronPage = Math.min(Math.max(0, state.cronPage), pages - 1);
389
+ // Header line: counts + pagination.
390
+ const active = jobs?.filter((j) => j.enabled).length ?? 0;
391
+ const paused = (jobs?.length ?? 0) - active;
392
+ const counts = jobs ? ` ${dim("·")} ${green(String(active))} ${dim("active ·")} ${yellow(String(paused))} ${dim("paused ·")}` : "";
393
+ out.push(fit(` ${bold("CRON JOBS")}${counts} ${dim(`page ${state.cronPage + 1}/${pages} ←/→`)}`, cols));
394
+ const lines = [];
395
+ if (!jobs) {
396
+ lines.push(...panel("CRON JOBS", [dim("loading cron jobs… (redeploy if the agent predates this feature)")], cols));
397
+ }
398
+ else if (jobs.length === 0) {
399
+ lines.push(...panel("CRON JOBS", [dim("no cron jobs yet — ask the agent on X to schedule one")], cols));
400
+ }
401
+ else {
402
+ const slice = jobs.slice(state.cronPage * pageSize, (state.cronPage + 1) * pageSize);
403
+ const oneLine = (s) => s.replace(/\s+/g, " ").trim();
404
+ for (const j of slice) {
405
+ const status = j.enabled
406
+ ? green("active")
407
+ : j.consecutiveFailures > 0 ? red("auto-paused") : yellow("paused");
408
+ const fails = j.consecutiveFailures > 0 ? ` ${red(`${j.consecutiveFailures} consecutive fails`)}` : "";
409
+ const next = !j.enabled
410
+ ? dim("—")
411
+ : j.nextRunAt <= now ? cyan("due now") : cyan(`in ${fmtDuration(j.nextRunAt - now)}`);
412
+ const last = j.lastRunAt != null ? `${fmtDuration(now - j.lastRunAt)} ${dim("ago")}` : dim("never");
413
+ const outcome = j.lastError != null
414
+ ? `${red("error")} ${red(oneLine(j.lastError))}`
415
+ : j.lastResult
416
+ ? `${dim("result".padEnd(PAD))}${oneLine(j.lastResult)}`
417
+ : `${dim("result".padEnd(PAD))}${dim("—")}`;
418
+ const title = `${accent(`#${j.id}`)} ${accent("@" + j.creator)} ${dim("·")} ${cyan(j.schedule)} ${dim("·")} ${status} ${dim("·")} ${dim("runs")} ${j.runs}${fails}`;
419
+ lines.push(...panel(title, [
420
+ `${dim("prompt".padEnd(PAD))}${oneLine(j.prompt)}`,
421
+ `${dim("next".padEnd(PAD))}${next} ${dim("last run")} ${last}`,
422
+ outcome,
423
+ ], cols));
424
+ }
425
+ }
426
+ out.push(...padRows(lines, bodyRows).map((l) => fit(l, cols)));
427
+ out.push(footerLine(state, [
428
+ key("←/→", "page"), key("shift+←/→", "status"), key("t", "theme"),
429
+ key("r", "restart"), key("s", "stop"), key("S", "start"), key("d", "redeploy"), key("u", "update"),
430
+ key("q", "quit"),
431
+ ], cols));
432
+ return out;
433
+ }
434
+ function buildFrame(state, cols, rows) {
435
+ // Page 2: the cron jobs dashboard (shift+←/→ cycles between the two pages).
436
+ if (((state.view % 2) + 2) % 2 === 1)
437
+ return buildCronFrame(state, cols, rows);
438
+ // Four columns: LOGO (fixed) | AGENT | TREASURY | SERVER, with three 1-col gaps.
439
+ const logoW = 21; // 17-wide logo art + borders/padding
440
+ const usable = cols - 3 - logoW;
441
+ const leftW = Math.floor(usable / 3);
442
+ const midW = Math.floor(usable / 3);
443
+ const rightW = usable - leftW - midW;
444
+ const out = [];
445
+ // One blank line of top margin (no header).
446
+ out.push("");
447
+ // AGENT panel
448
+ const p = state.pm2;
449
+ const elapsed = p?.bootMs ? Date.now() - p.bootMs : 0;
450
+ // Approximate: phase-aligned to the pm2 process start, but the agent's recurring
451
+ // treasury timer actually starts a few seconds into boot (and an extra startup
452
+ // cycle fires ~10s in) — hence the "~" where this renders.
453
+ const nextTreasury = p?.bootMs ? TREASURY_INTERVAL_MS - (elapsed % TREASURY_INTERVAL_MS) : 0;
454
+ const wallet = state.wallet ? `${state.wallet.slice(0, 6)}..${state.wallet.slice(-4)}` : (process.env.BANKR_API_KEY ? dim("resolving") : dim("-"));
455
+ // Two separate fuel tanks: USDC pays x-api + compute + x402, LLM credits pay inference. (We
456
+ // exclude the agent's own token; we don't assume it stays liquid.) Burn rates: once
457
+ // there's ≥ RUNWAY_MIN_DATA_HOURS of recorded activity, measure each from the trailing
458
+ // window (which grows up to 24h); before that, predict the USDC burn from the poll
459
+ // cadence (the always-on x-api cost) and treat LLM as not-yet-binding — tagged "~".
460
+ const st = state.stats;
461
+ const bb = state.balances;
462
+ const usdcUsd = bb ? (bb.usd?.usdc ?? Number(formatUnits(bb.usdc, 6))) : null;
463
+ const hasRate = st.rateWindowHours >= RUNWAY_MIN_DATA_HOURS && st.spentUsdWindow > 0;
464
+ const estimated = !hasRate;
465
+ // Predicted always-on USDC burn from the poll cadence (the dominant cost). Doubles as
466
+ // the cold-start estimate AND a floor on the measured rate: the window's spend is
467
+ // divided by wall-clock hours, so if the agent was DOWN for part of the window its
468
+ // spend is spread over fewer active hours than counted — understating the burn and
469
+ // inflating the runway. Flooring at the poll cost keeps it realistic. When running
470
+ // normally the measured rate already meets/exceeds this, so the floor is a no-op.
471
+ const predictedUsdcBurn = POLL_SECONDS > 0 ? (3600 / POLL_SECONDS) * X_API_POLL_COST_USD : 0;
472
+ const usdcBurn = hasRate // x-api + compute (USDC)
473
+ ? Math.max((st.spentUsdWindow - st.inferenceUsdWindow) / st.rateWindowHours, predictedUsdcBurn)
474
+ : predictedUsdcBurn;
475
+ const llmBurn = hasRate ? st.inferenceUsdWindow / st.rateWindowHours : 0; // inference (credits)
476
+ // Runway: how long the treasury lasts at the current GROSS burn (ignores incoming
477
+ // earnings) — the first of the two tanks to empty. Always a time, never "sustaining".
478
+ let runway;
479
+ if (usdcUsd == null) {
480
+ runway = dim("…");
481
+ }
482
+ else {
483
+ const usdcRunwayH = usdcBurn > 0 ? usdcUsd / usdcBurn : Infinity;
484
+ const llmRunwayH = llmBurn > 0 ? (state.creditUsd != null ? state.creditUsd / llmBurn : Infinity) : Infinity;
485
+ const hours = Math.min(usdcRunwayH, llmRunwayH);
486
+ runway = Number.isFinite(hours)
487
+ ? cyan((estimated ? "~" : "") + fmtDuration(hours * 3_600_000))
488
+ : dim("∞");
489
+ }
490
+ // Sustainable (current-rate self-sustaining): do earnings keep up with the burn over the
491
+ // trailing window (same window as Runway)? Both sides are window sums, so this is just
492
+ // earnings_window(USD) ≥ spend_window. "…" only when we can't compare yet (no spend
493
+ // recorded, or no live price to convert earnings).
494
+ let sustainable;
495
+ if (st.spentUsdWindow <= 0 || (st.earnedWethWindow > 0 && bb?.ethUsd == null)) {
496
+ sustainable = dim("…"); // nothing spent yet, or earnings exist but no price to convert
497
+ }
498
+ else {
499
+ sustainable = st.earnedWethWindow * (bb?.ethUsd ?? 0) >= st.spentUsdWindow ? green("yes") : yellow("no");
500
+ }
501
+ // Profitable (all-time net): cumulative earnings (WETH → USD at the live price) vs every
502
+ // dollar ever spent — a lifetime break-even flag (distinct from Sustainable: the agent
503
+ // can be profitable over its life yet currently burning, or vice-versa). "…" until
504
+ // there's spend to compare and a price to convert with.
505
+ let profitable;
506
+ if (st.spentUsd <= 0 || (st.earnedWeth > 0 && bb?.ethUsd == null)) {
507
+ profitable = dim("…"); // nothing spent yet, or earnings exist but no price to convert
508
+ }
509
+ else {
510
+ const net = st.earnedWeth * (bb?.ethUsd ?? 0) - st.spentUsd; // all-time earnings − spend
511
+ const amt = `(${net >= 0 ? "+" : "-"}${fmtSpent(Math.abs(net))})`;
512
+ profitable = `${net >= 0 ? green("yes") : yellow("no")} ${net >= 0 ? green(amt) : red(amt)}`;
513
+ }
514
+ // Dev revenue (WETH paid to the dev address, all-time) — only shown once there's any.
515
+ const devUsd = bb?.ethUsd != null ? st.devWeth * bb.ethUsd : null;
516
+ const devRev = `${st.devWeth.toFixed(4)} WETH${devUsd != null ? " " + dim(`(${fmtSpent(devUsd)})`) : ""}`;
517
+ const agentRows = [
518
+ kv("Handle", accent("@" + state.handle), 7),
519
+ kv("Wallet", wallet, 7),
520
+ kv("Admins", state.admins, 7),
521
+ kv("Poll", `${POLL_METHOD} ${dim("|")} ${POLL_SECONDS}s`, 7),
522
+ kv("Claim", p?.bootMs ? `in ${cyan("~" + fmtDuration(nextTreasury))}` : dim("-"), 7),
523
+ kv("Runway", runway, 7),
524
+ kv("Sustainable", sustainable, 12),
525
+ kv("Profitable", profitable, 12),
526
+ ...(st.devWeth > 0 ? [kv("Dev rev", devRev, 12)] : []),
527
+ ];
528
+ // TREASURY panel — live on-chain balances, refreshed every BALANCE_INTERVAL_MS.
529
+ // All balance values share one color; the USD total is the highlighted headline.
530
+ const b = state.balances;
531
+ const missing = process.env.BANKR_API_KEY ? dim("...") : dim("-");
532
+ const creditMissing = LLM_KEY ? dim("...") : dim("-");
533
+ const TPAD = 12; // label column width — fits "LLM credits" so values stay aligned
534
+ // On-chain assets, sorted by USD value DESC, each shown as "<qty> <USD value>"
535
+ // (the qty is padded so the USD column lines up). When prices are unavailable the
536
+ // USD column is dropped and the order is left as-is.
537
+ const assets = b
538
+ ? [
539
+ { label: (b.symbol ?? "TOKEN").slice(0, 5), qty: fmtToken(b.token, b.decimals), usd: b.usd?.token ?? null },
540
+ { label: "USDC", qty: fmtUsdc(b.usdc), usd: b.usd?.usdc ?? null },
541
+ { label: "WETH", qty: fmtEth(b.weth), usd: b.usd?.weth ?? null },
542
+ { label: "ETH", qty: fmtEth(b.eth), usd: b.usd?.eth ?? null },
543
+ ].sort((x, y) => (y.usd ?? -1) - (x.usd ?? -1))
544
+ : null;
545
+ const treasuryRows = assets
546
+ ? [
547
+ ...assets.map((a) => kv(a.label, a.usd != null ? `${a.qty.padEnd(9)} ${dim(fmtUsd(a.usd))}` : a.qty, TPAD)),
548
+ // LLM inference credits — an off-chain balance at the LLM gateway, so it sits
549
+ // just under the on-chain assets, above the $ total.
550
+ kv("LLM credits", state.creditUsd != null ? cyan(fmtUsd(state.creditUsd)) : creditMissing, TPAD),
551
+ // Total treasury value = on-chain assets + the inference credit balance (it's
552
+ // prepaid USDC, so it counts toward what the treasury is worth).
553
+ kv("Total", b?.usdTotal != null ? bold(accent(fmtUsd(b.usdTotal + (state.creditUsd ?? 0)))) : missing, TPAD),
554
+ ]
555
+ : [
556
+ // No balances resolved yet — show labels with a placeholder.
557
+ kv((b?.symbol ?? "TOKEN").slice(0, 5), missing, TPAD),
558
+ kv("USDC", missing, TPAD),
559
+ kv("WETH", missing, TPAD),
560
+ kv("ETH", missing, TPAD),
561
+ kv("LLM credits", state.creditUsd != null ? cyan(fmtUsd(state.creditUsd)) : creditMissing, TPAD),
562
+ kv("Total", missing, TPAD),
563
+ ];
564
+ // SERVER panel
565
+ const sp = state.specs;
566
+ const compute = state.computeHours != null ? `${cyan(fmtDuration(state.computeHours * 3_600_000))} ${dim("remaining")}` : dim("...");
567
+ // CPU/RAM as: <yappr process> | <whole system> of <capacity>.
568
+ const cpuUsage = `${p ? `${p.cpu}%` : dim("...")} ${dim("|")} ${state.sysCpu != null ? `${state.sysCpu}%` : dim("...")} ${dim("of")} ${sp ? sp.cpu + " vCPU" : "?"}`;
569
+ const ramUsage = `${p ? fmtMem(p.mem) : dim("...")} ${dim("|")} ${state.sysMemMb != null ? `${state.sysMemMb}MB` : dim("...")} ${dim("of")} ${sp?.ram ?? "?"}`;
570
+ const diskUsage = `${state.sysDiskUsed ?? dim("...")} ${dim("of")} ${sp?.disk ?? "?"}`;
571
+ const status = p?.status === "online"
572
+ ? `${cyan("online")} ${dim("for")} ${fmtDuration(elapsed)}`
573
+ : p?.status === "stopped"
574
+ ? red("stopped")
575
+ : (p?.status && p.status !== "unknown" ? yellow : dim)(p?.status ?? "offline");
576
+ const serverRows = [
577
+ kv("IP", state.ip, 8),
578
+ kv("OS", sp?.os ?? dim("..."), 8),
579
+ kv("CPU", cpuUsage, 8),
580
+ kv("RAM", ramUsage, 8),
581
+ kv("Disk", diskUsage, 8),
582
+ kv("Compute", compute, 8),
583
+ kv("Status", status, 8),
584
+ ];
585
+ // Equalize heights so every box lines up (the logo is the tallest at 8 rows).
586
+ const h = Math.max(YAPPR_LOGO.length, agentRows.length, treasuryRows.length, serverRows.length);
587
+ const logo = panel("YAPPR", padRows(YAPPR_LOGO, h), logoW);
588
+ const agent = panel("AGENT", centerRows(agentRows, h), leftW);
589
+ const treasury = panel("TREASURY", centerRows(treasuryRows, h), midW);
590
+ const server = panel("SERVER", centerRows(serverRows, h), rightW);
591
+ let row = sideBySide(logo, logoW, agent, leftW, 1);
592
+ row = sideBySide(row, logoW + 1 + leftW, treasury, midW, 1);
593
+ row = sideBySide(row, logoW + 1 + leftW + 1 + midW, server, rightW, 1);
594
+ out.push(...row);
595
+ // ACTIVITY panel (full width, justified) — ascii only, no emoji.
596
+ const s = state.stats;
597
+ // Earned = all-time creator fees (WETH from the ledger), shown in USD via the ETH
598
+ // price when we have it, else the raw WETH amount.
599
+ // Earned shown in WETH (the raw creator fees) with the USD value at the current ETH
600
+ // price in parens, e.g. "0.0512 WETH ($153.60)".
601
+ const earnedUsd = b?.ethUsd != null ? s.earnedWeth * b.ethUsd : null;
602
+ const earnedStr = `${green(`${s.earnedWeth.toFixed(4)} WETH`)}${earnedUsd != null ? " " + dim(`(${fmtSpent(earnedUsd)})`) : ""}`;
603
+ // Burned = the agent's token held at the dead address, as a quantity and a % of
604
+ // the fixed 100B supply every Bankr launch ships with.
605
+ const burnedStr = b
606
+ ? `${accent(fmtToken(b.burned, b.decimals))} ${dim(`(${fmtSupplyPct(Number(formatUnits(b.burned, b.decimals)))})`)}`
607
+ : dim("...");
608
+ out.push(...panel("ACTIVITY", [justify([
609
+ `${String(s.mentions)} ${dim("mentions")}`,
610
+ `${String(s.replies)} ${dim("replies")}`,
611
+ `${String(s.llmTurns)} ${dim("llm requests")}`,
612
+ `${earnedStr} ${dim("earned")}`,
613
+ `${red(fmtSpent(s.spentUsd))} ${dim("spent")}`,
614
+ `${burnedStr} ${dim("burned")}`,
615
+ `${red(String(s.errors))} ${dim("errors")}`,
616
+ ], cols - 4)], cols));
617
+ // CHART panel — three views cycled with ←/→: (0) spent/earned last 24h, (1) hourly spent
618
+ // vs earned, (2) hourly expenses by category. Sits between ACTIVITY and LOGS (shrinks
619
+ // LOGS). Views show a placeholder until there's data.
620
+ const ci = ((state.chartIndex % 3) + 3) % 3;
621
+ const nav = dim(`[${ci + 1}/3 ←/→]`);
622
+ const placeholder = [dim("collecting data… (redeploy if the agent predates this feature)")];
623
+ const hasData = (c) => c.spendUsd.length >= 2 && c.endMs > c.startMs;
624
+ // The hourly views need a real signal, not just a fetched series: byType.startMs
625
+ // is always set once the summary arrives, so gate on any nonzero bucket instead.
626
+ const hasHourly = s.chart.byType.startMs > 0 &&
627
+ [s.chart.byType.xapi, s.chart.byType.inference, s.chart.byType.compute, s.chart.byType.x402, s.chart.byType.earned]
628
+ .some((a) => a.some((v) => v > 0));
629
+ let chartTitle;
630
+ let chartLines;
631
+ if (ci === 1) {
632
+ chartTitle = `HOURLY SPENT vs EARNED ${dim("· 24h ·")} ${catColor(SPENT_RGB())("spent")} ${dim("/")} ${catColor(EARN_RGB())("earned")} ${nav}`;
633
+ chartLines = hasHourly ? renderHourlySpentEarned(cols, s.chart.byType, b?.ethUsd ?? null) : placeholder;
634
+ }
635
+ else if (ci === 2) {
636
+ chartTitle = `HOURLY EXPENSES ${dim("· 24h ·")} ${catColor(CAT_RGB().xapi)("x-api")} ${dim("/")} ${catColor(CAT_RGB().inference)("inference")} ${dim("/")} ${catColor(CAT_RGB().compute)("compute")} ${dim("/")} ${catColor(CAT_RGB().x402)("x402")} ${nav}`;
637
+ chartLines = hasHourly ? renderHourlyBars(cols, s.chart.byType) : placeholder;
638
+ }
639
+ else {
640
+ chartTitle = `SPENT vs EARNED ${dim("· 24h ·")} ${catColor(SPENT_RGB())("spent")} ${dim("/")} ${catColor(EARN_RGB())("earned")} ${nav}`;
641
+ chartLines = hasData(s.chart.day) ? renderLineChart(cols, s.chart.day, b?.ethUsd ?? null, s.chart.day.startMs, s.chart.day.startMs + 24 * HOUR_MS) : placeholder;
642
+ }
643
+ out.push(...panel(chartTitle, chartLines, cols));
644
+ // LOGS panel fills the rest (less one row for the footer), with a scroll offset
645
+ // (0 = following the live tail).
646
+ const logRows = Math.max(3, rows - out.length - 3);
647
+ state.logRows = logRows;
648
+ const maxScroll = Math.max(0, state.logs.length - logRows);
649
+ state.scroll = Math.min(Math.max(0, state.scroll), maxScroll); // keep in range as logs grow/expire
650
+ const end = state.logs.length - state.scroll; // exclusive index of the bottom visible line
651
+ const recent = state.logs.slice(Math.max(0, end - logRows), end).map((l) => displayLog(l));
652
+ while (recent.length < logRows)
653
+ recent.push("");
654
+ const title = state.scroll > 0
655
+ ? `LOGS ${dim(`[paused] ${state.scroll} below ${dim("|")} G=live`)}`
656
+ : "LOGS";
657
+ out.push(...panel(title, recent, cols));
658
+ // Footer: a confirmation prompt when a command is pending, otherwise key hints.
659
+ out.push(footerLine(state, [
660
+ key("up/dn", "scroll"), key("g/G", "top/live"), key("←/→", "chart"), key("shift+←/→", "cron"),
661
+ key("t", "theme"), key("r", "restart"), key("s", "stop"), key("S", "start"), key("d", "redeploy"), key("u", "update"),
662
+ key("q", "quit"),
663
+ ], cols));
664
+ return out;
665
+ }
666
+ function render(state) {
667
+ // Reserve the last terminal column: writing into it triggers autowrap, which
668
+ // (with the per-line erase below) drops the right border. Drawing one column
669
+ // narrower keeps every box closed on the right.
670
+ const cols = Math.max(48, (process.stdout.columns ?? 80) - 1);
671
+ const rows = Math.max(16, process.stdout.rows ?? 24);
672
+ const out = buildFrame(state, cols, rows);
673
+ process.stdout.write("\x1b[H" + out.map((l) => themeLine(l) + "\x1b[K").join("\n") + "\x1b[0J");
674
+ }
675
+ // ─── main dashboard loop ──────────────────────────────────────────────────────
676
+ export async function runStatus(target) {
677
+ const handle = target.handle || process.env.AGENT_HANDLE || "agent";
678
+ // Match the terminal's background unless STATUS_THEME pins one explicitly.
679
+ // Done before any rendering or key handling (the OSC query borrows stdin).
680
+ if (!process.env.STATUS_THEME) {
681
+ const detected = await detectTerminalTheme().catch(() => null);
682
+ if (detected)
683
+ setTheme(detected);
684
+ }
685
+ if (!target.password) {
686
+ console.error(` No SSH password available for root@${target.ip}. Set COMPUTE_SSH_PASSWORD in .env or use \`npm run ssh\`.`);
687
+ return;
688
+ }
689
+ const ssh = new NodeSSH();
690
+ try {
691
+ await ssh.connect({ host: target.ip, username: "root", password: target.password, tryKeyboard: true, ...hostKeyConfig(target.ip) });
692
+ }
693
+ catch (err) {
694
+ console.error(` Could not connect to root@${target.ip}: ${err instanceof Error ? err.message : String(err)}`);
695
+ return;
696
+ }
697
+ const admins = (process.env.ADMIN_HANDLES || "").split(",").map((h) => h.trim()).filter(Boolean)
698
+ .map((h) => (h.startsWith("@") ? h : "@" + h)).join(", ");
699
+ const interactive = !!process.stdout.isTTY;
700
+ const state = {
701
+ ip: target.ip, handle, admins: admins || dim("none"), wallet: null,
702
+ stats: { mentions: 0, replies: 0, llmTurns: 0, spentUsd: 0, warns: 0, errors: 0, earnedWeth: 0, devWeth: 0, spentUsdWindow: 0, inferenceUsdWindow: 0, earnedWethWindow: 0, rateWindowHours: 0, spentByType: { "x-api": 0, inference: 0, compute: 0, x402: 0 }, chart: { day: { spendUsd: [], earnedWeth: [], startMs: 0, endMs: 0 }, byType: { startMs: 0, xapi: [], inference: [], compute: [], x402: [], earned: [] } } },
703
+ logs: [], pm2: null, specs: null, balances: null, computeHours: null,
704
+ creditUsd: null,
705
+ sysCpu: null, sysMemMb: null, sysDiskUsed: null, scroll: 0, logRows: 0, confirm: null,
706
+ chartIndex: 0,
707
+ view: 0, cron: null, cronPage: 0,
708
+ };
709
+ let renderTimer;
710
+ let pm2Timer;
711
+ let balanceTimer;
712
+ let backupTimer;
713
+ let computeRetryTimer;
714
+ let onKey;
715
+ let redeployPromise = null;
716
+ let updatePromise = null;
717
+ let done = false;
718
+ const stopTimers = () => {
719
+ if (renderTimer)
720
+ clearInterval(renderTimer);
721
+ if (pm2Timer)
722
+ clearInterval(pm2Timer);
723
+ if (balanceTimer)
724
+ clearInterval(balanceTimer);
725
+ if (backupTimer)
726
+ clearInterval(backupTimer);
727
+ if (computeRetryTimer)
728
+ clearTimeout(computeRetryTimer);
729
+ };
730
+ const restoreTerminal = () => {
731
+ if (onKey && process.stdin.isTTY) {
732
+ try {
733
+ process.stdin.setRawMode(false);
734
+ }
735
+ catch { /* ignore */ }
736
+ process.stdin.pause();
737
+ process.stdin.removeListener("data", onKey);
738
+ }
739
+ };
740
+ const cleanup = () => {
741
+ if (done)
742
+ return;
743
+ done = true;
744
+ stopTimers();
745
+ restoreTerminal();
746
+ if (interactive)
747
+ process.stdout.write("\x1b[?25h\n"); // restore cursor
748
+ try {
749
+ ssh.dispose();
750
+ }
751
+ catch { /* ignore */ }
752
+ };
753
+ // Quit path: pull one final stats snapshot before tearing down, so the local backup
754
+ // is current as of exit. We restore the terminal first (so the status line shows),
755
+ // keep SSH alive for the backup, then dispose + exit. Bounded so a hung snapshot
756
+ // can't trap the user. Idempotent via `exiting`.
757
+ let exiting = false;
758
+ const quit = async () => {
759
+ if (exiting)
760
+ return;
761
+ exiting = true;
762
+ stopTimers();
763
+ restoreTerminal();
764
+ if (interactive)
765
+ process.stdout.write("\x1b[?25h\x1b[2J\x1b[3J\x1b[H"); // restore cursor + clear TUI
766
+ process.stdout.write(" Backing up the database…\n");
767
+ try {
768
+ const f = await withTimeout(backupRemoteDb(ssh), 20_000);
769
+ process.stdout.write(` ${green("✓")} Backup saved: ${backupLabel(f)}\n`);
770
+ }
771
+ catch (err) {
772
+ process.stdout.write(` ${yellow("⚠")} Backup skipped: ${err instanceof Error ? err.message : String(err)}\n`);
773
+ }
774
+ done = true; // we've torn down; keep the stream's finally from re-running cleanup
775
+ try {
776
+ ssh.dispose();
777
+ }
778
+ catch { /* ignore */ }
779
+ process.exit(0);
780
+ };
781
+ process.on("SIGINT", quit);
782
+ // Treasury balances + remaining compute: needs the Bankr-resolved wallet address.
783
+ // Both are on-chain/API reads, so refresh them together on the balance interval.
784
+ const apiKey = process.env.BANKR_API_KEY;
785
+ const tokenAddress = process.env.TOKEN_ADDRESS;
786
+ const instanceId = process.env.COMPUTE_INSTANCE_ID;
787
+ const refreshBalances = (address) => {
788
+ if (tokenAddress)
789
+ fetchBalances(address, tokenAddress).then((bal) => { if (bal)
790
+ state.balances = bal; }).catch(() => { });
791
+ };
792
+ // Remaining compute needs a Bankr-signed instance lookup, which is occasionally
793
+ // slow or fails — unlike the plain RPC balance reads. Retry on a short cadence
794
+ // until the first success so a transient miss doesn't leave "..." for minutes;
795
+ // after that the 5-min balance timer keeps it fresh.
796
+ const refreshCompute = async (address) => {
797
+ if (!apiKey || !instanceId)
798
+ return true; // nothing to fetch; stop retrying
799
+ try {
800
+ const h = remainingComputeHours(await fetchComputeInstance(apiKey, address, instanceId));
801
+ if (h != null) {
802
+ state.computeHours = h;
803
+ return true;
804
+ }
805
+ }
806
+ catch { /* retry */ }
807
+ return false;
808
+ };
809
+ const ensureCompute = (address) => {
810
+ refreshCompute(address).then((ok) => {
811
+ // Tracked + re-checked at fire time so stopTimers()/cleanup() really ends the
812
+ // chain (an untracked timeout would fire one more API call mid-redeploy/quit).
813
+ if (!ok && !done)
814
+ computeRetryTimer = setTimeout(() => { if (!done)
815
+ ensureCompute(address); }, 30_000);
816
+ });
817
+ };
818
+ const refreshTreasury = (address) => { refreshBalances(address); ensureCompute(address); };
819
+ const refreshSysUsage = () => fetchSysUsage(ssh).then((u) => { state.sysCpu = u.cpu; state.sysMemMb = u.memMb; state.sysDiskUsed = u.diskUsed; }).catch(() => { });
820
+ // Live LLM credit balance, for display only. Spend is tracked per-request by the
821
+ // agent (token usage × pricing), not here; this just shows the remaining budget.
822
+ const refreshCredits = () => fetchLlmCredits().then((cur) => { if (cur != null)
823
+ state.creditUsd = cur; }).catch(() => { });
824
+ // All-time activity + spend counters, read from the agent's stats summary.
825
+ const refreshStats = () => fetchAgentStats(ssh).then((s) => { if (s)
826
+ state.stats = s; }).catch(() => { });
827
+ // Cron jobs for the CRON JOBS page. Fetched once at launch (so the first switch
828
+ // is instant), on every switch to the page, and on the 5s tick while it's shown.
829
+ const refreshCron = () => fetchCronJobs(ssh).then((c) => { if (c)
830
+ state.cron = c; }).catch(() => { });
831
+ // ── lifecycle commands (footer keys) ──
832
+ // Push a dashboard-originated note into the log feed so command output is visible.
833
+ const note = (msg) => { state.logs.push(`\x1b[36m[dashboard]\x1b[0m ${msg}`); if (state.logs.length > 400)
834
+ state.logs.shift(); };
835
+ // Periodic stats backup (the on-launch + every-N-minutes path; quit() does its own
836
+ // final one). Best-effort: result/failure is surfaced in the log feed, never thrown.
837
+ const runBackup = async () => {
838
+ // The DB doesn't change while the bot is stopped — skip the snapshot.
839
+ if (state.pm2 && state.pm2.status !== "online")
840
+ return;
841
+ try {
842
+ note(`database backed up → ${backupLabel(await backupRemoteDb(ssh))}`);
843
+ }
844
+ catch (err) {
845
+ note(`database backup failed: ${err instanceof Error ? err.message : String(err)}`);
846
+ }
847
+ };
848
+ // pm2 restart/stop/start over the existing SSH connection (separate channel from
849
+ // the log stream). Result is echoed into the feed and pm2 status refreshed.
850
+ const runPm2 = async (action) => {
851
+ note(`pm2 ${action} yappr…`);
852
+ const r = await ssh.execCommand(`pm2 ${action} yappr`, { cwd: "/" }).catch((e) => ({ stdout: "", stderr: String(e) }));
853
+ const line = (r.stdout || r.stderr || "").trim().split("\n").pop() ?? "";
854
+ note(line || `pm2 ${action} done`);
855
+ fetchPm2(ssh).then((p) => { if (p)
856
+ state.pm2 = p; }).catch(() => { });
857
+ };
858
+ // Re-deploy hands the terminal off to `npm run deploy` (which itself relaunches the
859
+ // dashboard when it finishes). We tear down the TUI first so the two don't fight
860
+ // over stdin/stdout, then await the child so the process doesn't exit early. cwd is
861
+ // the project root the command was launched from (where deploy expects to run).
862
+ const ROOT = process.cwd();
863
+ const redeploy = () => {
864
+ if (redeployPromise)
865
+ return;
866
+ redeployPromise = new Promise((res) => {
867
+ cleanup(); // ends log stream + timers, restores cursor/raw mode
868
+ process.stdout.write("\x1b[?25h\x1b[2J\x1b[3J\x1b[H");
869
+ console.log("Re-deploying — handing off to `npm run deploy`…\n");
870
+ const child = spawn("npm", ["run", "deploy"], { stdio: "inherit", cwd: ROOT });
871
+ child.on("exit", () => res());
872
+ child.on("error", (e) => { console.error(e); res(); });
873
+ });
874
+ };
875
+ // Update hands off to `npx yappr update` (engine bump + config sync), which itself
876
+ // offers to redeploy at the end (relaunching this dashboard). Same teardown as
877
+ // redeploy so the child owns stdin/stdout.
878
+ const update = () => {
879
+ if (updatePromise)
880
+ return;
881
+ updatePromise = new Promise((res) => {
882
+ cleanup();
883
+ process.stdout.write("\x1b[?25h\x1b[2J\x1b[3J\x1b[H");
884
+ console.log("Updating — handing off to `npx yappr update`…\n");
885
+ const child = spawn("npx", ["yappr", "update"], { stdio: "inherit", cwd: ROOT });
886
+ child.on("exit", () => res());
887
+ child.on("error", (e) => { console.error(e); res(); });
888
+ });
889
+ };
890
+ // Best-effort enrichments (don't block the dashboard).
891
+ state.pm2 = await fetchPm2(ssh).catch(() => null);
892
+ fetchSpecs(ssh).then((sp) => { state.specs = sp; }).catch(() => { });
893
+ refreshSysUsage();
894
+ refreshCredits();
895
+ refreshStats();
896
+ refreshCron();
897
+ if (apiKey) {
898
+ resolveEvmAddress(apiKey).then((a) => { state.wallet = a; refreshTreasury(a); }).catch(() => { });
899
+ }
900
+ // First backup on launch, then one every BACKUP_INTERVAL_MS while the dashboard runs.
901
+ void runBackup();
902
+ backupTimer = setInterval(() => void runBackup(), BACKUP_INTERVAL_MS);
903
+ if (interactive) {
904
+ process.stdout.write("\x1b[?25l\x1b[2J\x1b[3J\x1b[H"); // hide cursor, clear screen
905
+ renderTimer = setInterval(() => render(state), 250);
906
+ pm2Timer = setInterval(() => { fetchPm2(ssh).then((p) => { if (p)
907
+ state.pm2 = p; }).catch(() => { }); refreshSysUsage(); refreshStats(); if (state.view === 1)
908
+ refreshCron(); }, 5000);
909
+ balanceTimer = setInterval(() => { if (state.wallet)
910
+ refreshTreasury(state.wallet); refreshCredits(); }, BALANCE_INTERVAL_MS);
911
+ // Scrollable LOGS. Raw mode lets us read arrow/page keys; since it also swallows
912
+ // the default Ctrl+C, we handle ^C (and q) here to quit. scroll counts lines up
913
+ // from the live tail; buildFrame clamps it to the available history.
914
+ if (process.stdin.isTTY) {
915
+ const page = () => Math.max(1, state.logRows - 1);
916
+ onKey = (buf) => {
917
+ const s = buf.toString();
918
+ // A pending command intercepts the next key: "y" or Enter runs it; anything cancels.
919
+ if (state.confirm) {
920
+ const c = state.confirm;
921
+ state.confirm = null;
922
+ if (s === "y" || s === "Y" || s === "\r" || s === "\n")
923
+ c.action();
924
+ render(state);
925
+ return;
926
+ }
927
+ if (s === "\x03" || s === "q") {
928
+ void quit();
929
+ return;
930
+ }
931
+ // Lifecycle commands — armed here, executed only after y confirmation.
932
+ if (s === "r") {
933
+ state.confirm = { prompt: "Restart yappr?", action: () => void runPm2("restart") };
934
+ render(state);
935
+ return;
936
+ }
937
+ if (s === "s") {
938
+ state.confirm = { prompt: "Stop yappr?", action: () => void runPm2("stop") };
939
+ render(state);
940
+ return;
941
+ }
942
+ if (s === "S") {
943
+ state.confirm = { prompt: "Start yappr?", action: () => void runPm2("start") };
944
+ render(state);
945
+ return;
946
+ }
947
+ if (s === "d") {
948
+ state.confirm = { prompt: "Re-deploy yappr? (exits dashboard)", action: redeploy };
949
+ render(state);
950
+ return;
951
+ }
952
+ if (s === "u") {
953
+ state.confirm = { prompt: "Update yappr & sync skills? (exits dashboard)", action: update };
954
+ render(state);
955
+ return;
956
+ }
957
+ if (s === "t") {
958
+ toggleTheme();
959
+ render(state);
960
+ return;
961
+ } // dark ↔ light palette
962
+ // shift+←/→ slides between the two pages (cyclical, so both keys work).
963
+ if (s === "\x1b[1;2C" || s === "\x1b[1;2D") {
964
+ state.view = (state.view + 1) % 2;
965
+ if (state.view === 1)
966
+ refreshCron();
967
+ render(state);
968
+ return;
969
+ }
970
+ if (s === "\x1b[C") { // → next chart / next cron page
971
+ if (state.view === 1)
972
+ state.cronPage++; // buildCronFrame clamps to the last page
973
+ else
974
+ state.chartIndex = (state.chartIndex + 1) % 3;
975
+ render(state);
976
+ return;
977
+ }
978
+ if (s === "\x1b[D") { // ← prev chart / prev cron page
979
+ if (state.view === 1)
980
+ state.cronPage = Math.max(0, state.cronPage - 1);
981
+ else
982
+ state.chartIndex = (state.chartIndex + 2) % 3;
983
+ render(state);
984
+ return;
985
+ }
986
+ let sc = state.scroll;
987
+ if (s === "\x1b[A" || s === "k")
988
+ sc += 1; // up
989
+ else if (s === "\x1b[B" || s === "j")
990
+ sc -= 1; // down
991
+ else if (s === "\x1b[5~")
992
+ sc += page(); // page up
993
+ else if (s === "\x1b[6~")
994
+ sc -= page(); // page down
995
+ else if (s === "g" || s === "\x1b[H")
996
+ sc = Number.MAX_SAFE_INTEGER; // top
997
+ else if (s === "G" || s === "\x1b[F")
998
+ sc = 0; // live tail
999
+ else
1000
+ return;
1001
+ state.scroll = Math.max(0, sc);
1002
+ render(state); // immediate feedback (buildFrame clamps to maxScroll)
1003
+ };
1004
+ process.stdin.setRawMode(true);
1005
+ process.stdin.resume();
1006
+ process.stdin.on("data", onKey);
1007
+ }
1008
+ render(state);
1009
+ }
1010
+ else {
1011
+ console.log(`Streaming logs for @${handle} (root@${target.ip}) — Ctrl+C to stop.\n`);
1012
+ }
1013
+ // Stream pm2 logs until the connection ends or the user interrupts.
1014
+ let pending = "";
1015
+ const onData = (chunk) => {
1016
+ pending += chunk.toString("utf8");
1017
+ const parts = pending.split("\n");
1018
+ pending = parts.pop() ?? "";
1019
+ for (const raw of parts) {
1020
+ const rawLine = raw.replace(/\s+$/, ""); // keep ANSI colors; trim line ending
1021
+ const plain = stripAnsi(rawLine);
1022
+ if (!plain)
1023
+ continue;
1024
+ state.logs.push(rawLine); // store colored for display (stats come from the ledger, not here)
1025
+ if (state.logs.length > 400)
1026
+ state.logs.shift();
1027
+ // When scrolled up (paused), advance the offset so the viewport stays on the
1028
+ // same lines as new ones arrive below; buildFrame clamps once history expires.
1029
+ if (state.scroll > 0)
1030
+ state.scroll++;
1031
+ if (!interactive)
1032
+ process.stdout.write(rawLine + "\n");
1033
+ }
1034
+ };
1035
+ try {
1036
+ // The LOGS panel just tails recent lines for display; counters come from the
1037
+ // agent's stats summary, so there's nothing to seed here.
1038
+ await ssh.execCommand("pm2 logs yappr --raw --lines 25", { cwd: "/", onStdout: onData, onStderr: onData });
1039
+ }
1040
+ catch {
1041
+ // Connection torn down (e.g. by a re-deploy handoff disposing the SSH session).
1042
+ }
1043
+ finally {
1044
+ process.off("SIGINT", quit);
1045
+ cleanup();
1046
+ }
1047
+ // If a re-deploy or update is in progress, keep the process alive until it finishes.
1048
+ if (redeployPromise)
1049
+ await redeployPromise;
1050
+ if (updatePromise)
1051
+ await updatePromise;
1052
+ }
1053
+ // ─── connection target resolution ─────────────────────────────────────────────
1054
+ // Fast path: cached IP (COMPUTE_HOST) + password (COMPUTE_SSH_PASSWORD) — zero API
1055
+ // calls. Otherwise resolve via the compute API (Bankr key + wallet signatures).
1056
+ async function resolveTarget(instanceIdArg) {
1057
+ const handle = process.env.AGENT_HANDLE;
1058
+ const cachedIp = process.env.COMPUTE_HOST;
1059
+ const cachedPw = process.env.COMPUTE_SSH_PASSWORD;
1060
+ if (!instanceIdArg && cachedIp && cachedPw)
1061
+ return { ip: cachedIp, password: cachedPw, handle };
1062
+ const apiKey = process.env.BANKR_API_KEY;
1063
+ if (!apiKey)
1064
+ throw new Error("BANKR_API_KEY not set in .env");
1065
+ const instanceId = instanceIdArg || process.env.COMPUTE_INSTANCE_ID;
1066
+ if (!instanceId)
1067
+ throw new Error("No instance id — pass one as an argument or set COMPUTE_INSTANCE_ID in .env");
1068
+ const address = await resolveEvmAddress(apiKey);
1069
+ const instance = await fetchComputeInstance(apiKey, address, instanceId);
1070
+ const ip = computeInstanceIp(instance);
1071
+ if (!ip)
1072
+ throw new Error(`Instance has no IP yet (status: ${instance?.status ?? "unknown"})`);
1073
+ let password = computeInstancePassword(instance) || cachedPw || undefined;
1074
+ if (!password)
1075
+ password = await fetchOneTimePassword(apiKey, address, instanceId);
1076
+ return { ip, password, handle };
1077
+ }
1078
+ // Render one frame with mock data — for eyeballing the layout without a server:
1079
+ // npm run status -- --demo # the status page
1080
+ // npm run status -- --demo --cron # the cron jobs page
1081
+ function demo() {
1082
+ const demoCron = [
1083
+ { id: 1, prompt: "Check the ETH price and store a one-line market note", schedule: "every 30 min", creator: "alice", enabled: true, nextRunAt: Date.now() + 720_000, lastRunAt: Date.now() - 1_080_000, lastResult: "ETH is at $3,012 (+1.2% on the day), gas 14 gwei.", lastError: null, runs: 41, consecutiveFailures: 0 },
1084
+ { id: 2, prompt: "Summarize replies to the pinned tweet and flag anything needing an answer", schedule: "daily at 09:00 Europe/Paris", creator: "bob", enabled: true, nextRunAt: Date.now() + 14_400_000, lastRunAt: Date.now() - 72_000_000, lastResult: "3 new replies, none need an answer.", lastError: null, runs: 6, consecutiveFailures: 0 },
1085
+ { id: 3, prompt: "Claim creator fees if above threshold", schedule: "every 360 min", creator: "alice", enabled: false, nextRunAt: 0, lastRunAt: Date.now() - 200_000_000, lastResult: null, lastError: 'Access denied: "wallet" requires admin privileges.', runs: 9, consecutiveFailures: 3 },
1086
+ ];
1087
+ const state = {
1088
+ ip: "203.0.113.7", handle: "evvrbot", admins: "@alice, @bob", wallet: "0xA1b2C3d4E5f6A7b8C9d0E1f2A3b4C5d6E7f80910",
1089
+ stats: { mentions: 37, replies: 29, llmTurns: 84, spentUsd: 0.7345, warns: 1, errors: 0, earnedWeth: 0.0512, devWeth: 0.0123, spentUsdWindow: 96, inferenceUsdWindow: 1.2, earnedWethWindow: 0.004, rateWindowHours: 24,
1090
+ spentByType: { "x-api": 0.55, inference: 0.06, compute: 0.12, x402: 0.08 },
1091
+ chart: (() => { const sp = [], ew = []; let a = 0, b2 = 0; for (let i = 0; i < 60; i++) {
1092
+ a += 0.012;
1093
+ b2 += i > 15 ? 0.0009 : 0;
1094
+ sp.push(a);
1095
+ ew.push(b2);
1096
+ } const day = { spendUsd: sp, earnedWeth: ew, startMs: Date.now() - 5 * 3_600_000, endMs: Date.now() }; const x = [], inf = [], c = [], x4 = [], ea = []; for (let i = 0; i < 24; i++) {
1097
+ x.push(i >= 12 ? 0.02 + (i % 3) * 0.005 : 0);
1098
+ inf.push(i >= 12 ? 0.003 : 0);
1099
+ c.push(i === 18 ? 0.06 : 0);
1100
+ x4.push(i >= 16 ? 0.021 + (i % 2) * 0.032 : 0);
1101
+ ea.push(i >= 14 ? 0.000005 + (i % 4) * 0.000002 : 0);
1102
+ } const byType = { startMs: Date.now() - 23 * 3_600_000, xapi: x, inference: inf, compute: c, x402: x4, earned: ea }; return { day, byType }; })() },
1103
+ pm2: { status: "online", bootMs: Date.now() - 8_120_000, restarts: 2, mem: 149 * 1024 * 1024, cpu: 3 },
1104
+ specs: { cpu: "2", ram: "1.9Gi", disk: "25G", os: "Ubuntu 24.04.1 LTS" },
1105
+ balances: { token: 1234567n * 10n ** 18n, weth: 42000000000000000n, eth: 3500000000000000n, usdc: 1875000000n, burned: 2450000n * 10n ** 18n, symbol: "EVVR", decimals: 18, usdTotal: 2_104.37, ethUsd: 3000, usd: { token: 92.87, weth: 126, eth: 10.5, usdc: 1875 } },
1106
+ computeHours: 19.5,
1107
+ creditUsd: 4.21,
1108
+ sysCpu: 3, sysMemMb: 600, sysDiskUsed: "12G",
1109
+ scroll: 0, logRows: 0,
1110
+ confirm: null,
1111
+ chartIndex: 0,
1112
+ view: process.argv.includes("--cron") ? 1 : 0, cron: demoCron, cronPage: 0,
1113
+ logs: [
1114
+ "[2026-06-08 12:30:01] INFO: poll cycle start",
1115
+ '[2026-06-08 12:30:02] INFO: new mentions found {"count":2}',
1116
+ "[2026-06-08 12:30:02] INFO: processing mention {\"author\":\"alice\"}",
1117
+ "[2026-06-08 12:30:05] INFO: LLM request (3 messages)",
1118
+ "[2026-06-08 12:30:09] INFO: replied {\"id\":\"206...\"}",
1119
+ "[2026-06-08 12:30:21] WARN: previous poll still running, skipping tick",
1120
+ ],
1121
+ };
1122
+ render(state);
1123
+ process.stdout.write("\n");
1124
+ }
1125
+ // Build a frame at a fixed size and report any line whose display width != cols.
1126
+ function check(cols = 143, rows = 40) {
1127
+ const long = String.raw `[2026-06-08 15:21:32] INFO: x-api GET /tweets/mentions {"path":"/tweets/mentions","params":{"auth_token":"[redacted]","ct0":"[redacted]"}}`;
1128
+ const state = {
1129
+ ip: "95.179.144.82", handle: "evvrbot", admins: "@alexben0006", wallet: "0xe6440ce076a5b491e7d6378223517d60a96b1326",
1130
+ stats: { mentions: 0, replies: 0, llmTurns: 0, spentUsd: 0, warns: 0, errors: 0, earnedWeth: 0, devWeth: 0, spentUsdWindow: 12, inferenceUsdWindow: 1, earnedWethWindow: 0.001, rateWindowHours: 24, spentByType: { "x-api": 8, inference: 1, compute: 3, x402: 2 }, chart: { day: { spendUsd: Array.from({ length: 60 }, (_, i) => i * 0.2), earnedWeth: Array.from({ length: 60 }, (_, i) => i * 0.00005), startMs: Date.now() - 24 * 3_600_000, endMs: Date.now() }, byType: { startMs: Date.now() - 23 * 3_600_000, xapi: Array.from({ length: 24 }, (_, i) => i >= 10 ? 0.02 : 0), inference: Array.from({ length: 24 }, (_, i) => i >= 10 ? 0.003 : 0), compute: Array.from({ length: 24 }, (_, i) => i === 16 ? 0.05 : 0), x402: Array.from({ length: 24 }, (_, i) => i >= 16 ? 0.021 : 0), earned: Array.from({ length: 24 }, (_, i) => i >= 14 ? 0.000006 : 0) } } },
1131
+ pm2: { status: "online", bootMs: Date.now() - 945_000, restarts: 1, mem: 110 * 1024 * 1024, cpu: 0.4 },
1132
+ specs: { cpu: "1", ram: "951Mi", disk: "23G", os: "Ubuntu 22.04.5 LTS" }, scroll: 0, logRows: 0,
1133
+ balances: { token: 1234567n * 10n ** 18n, weth: 42000000000000000n, eth: 3500000000000000n, usdc: 1875000000n, burned: 2450000n * 10n ** 18n, symbol: "EVVR", decimals: 18, usdTotal: 2_104.37, ethUsd: 3000, usd: { token: 92.87, weth: 126, eth: 10.5, usdc: 1875 } },
1134
+ computeHours: 19.5, creditUsd: 4.21, sysCpu: 4, sysMemMb: 740, sysDiskUsed: "9.1G", confirm: null, chartIndex: 0,
1135
+ view: 0, cron: null, cronPage: 0,
1136
+ logs: Array.from({ length: 30 }, () => long),
1137
+ };
1138
+ // Both pages: the status frame, then the cron frame (with an overlong prompt
1139
+ // and result so truncation is exercised too).
1140
+ state.cron = [
1141
+ { id: 12, prompt: long.repeat(2), schedule: "every 30 min", creator: "alexben0006", enabled: true, nextRunAt: Date.now() + 60_000, lastRunAt: Date.now() - 60_000, lastResult: long.repeat(2), lastError: null, runs: 99, consecutiveFailures: 0 },
1142
+ { id: 13, prompt: "short", schedule: "daily at 09:00 Europe/Paris", creator: "alexben0006", enabled: false, nextRunAt: 0, lastRunAt: null, lastResult: null, lastError: long, runs: 0, consecutiveFailures: 4 },
1143
+ ];
1144
+ const frame = [...buildFrame(state, cols, rows), ...buildFrame({ ...state, view: 1 }, cols, rows)];
1145
+ let bad = 0;
1146
+ frame.forEach((l, i) => { const w = stringWidth(l); if (w !== cols) {
1147
+ bad++;
1148
+ console.log(`line ${i}: width ${w} != ${cols} ${JSON.stringify(stripAnsi(l).slice(0, 30))}`);
1149
+ } });
1150
+ console.log(bad ? `\n${bad} mismatched line(s)` : `all ${frame.length} lines == ${cols} ✓`);
1151
+ }
1152
+ async function main() {
1153
+ if (process.argv.includes("--check")) {
1154
+ check();
1155
+ return;
1156
+ }
1157
+ if (process.argv.includes("--demo")) {
1158
+ demo();
1159
+ return;
1160
+ }
1161
+ const target = await resolveTarget(process.argv[2]);
1162
+ await runStatus(target);
1163
+ process.exit(0);
1164
+ }
1165
+ // The `yappr status` entry. (deploy imports runStatus directly for its hand-off.)
1166
+ export async function run() {
1167
+ await main();
1168
+ }
1169
+ // Auto-run only when invoked directly (not when imported by the bin/deploy).
1170
+ const invokedDirectly = (() => {
1171
+ try {
1172
+ return fileURLToPath(import.meta.url) === resolve(process.argv[1] ?? "");
1173
+ }
1174
+ catch {
1175
+ return false;
1176
+ }
1177
+ })();
1178
+ if (invokedDirectly) {
1179
+ main().catch((err) => {
1180
+ process.stdout.write("\x1b[?25h");
1181
+ console.error(`\n x ${err instanceof Error ? err.message : String(err)}`);
1182
+ process.exit(1);
1183
+ });
1184
+ }