mindvest-atlas 0.100.2 → 0.100.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mindvest-atlas",
3
- "version": "0.100.2",
3
+ "version": "0.100.3",
4
4
  "description": "Atlas CLI \u2014 OAuth login, tool calls, and live alert/flow streaming for the Atlas trading API",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -20,6 +20,7 @@ import { screenshotCommand } from './commands/screenshot.js';
20
20
  import { runCommand } from './commands/run.js';
21
21
  import { computerAccess } from './access.js';
22
22
  import { installCommand } from './commands/install.js';
23
+ import { deviceCommand } from './commands/device.js';
23
24
  import { computerUseCommand } from './commands/computer-use.js';
24
25
  import { dashboardCommand } from './commands/dashboard.js';
25
26
  import { chatSearchCommand } from './commands/chat-search.js';
@@ -125,6 +126,10 @@ export async function main(rawArgs) {
125
126
  // environment under the Atlas folder; Studio's Libraries tab uses it too.
126
127
  case 'install':
127
128
  return await installCommand(withoutCommand(args));
129
+ // WHAT THIS COMPUTER HAS, so heavy work is sized to it (owner,
130
+ // 2026-10-17). Read here only; nothing is sent anywhere.
131
+ case 'device':
132
+ return await deviceCommand(withoutCommand(args));
128
133
  case 'run': {
129
134
  // A run on this computer needs a plan (or the free week): asked
130
135
  // before anything starts, never in the middle of one.
@@ -0,0 +1,72 @@
1
+ // `atlas device`: what this computer has, for sizing heavy work to it (owner,
2
+ // 2026-10-17: "it first queries the user's device to see their resources: can
3
+ // this computer run this algorithm, and optimize for it").
4
+ //
5
+ // atlas device in words
6
+ // atlas device --json the answer as JSON (the helper's device())
7
+ // atlas device --job <name> a project's disk and Python (default: this folder)
8
+ // atlas device --path <dir> the disk at that folder
9
+ //
10
+ // Read on this computer only: nothing is sent anywhere. The answer is the
11
+ // helpers' own (helpers/atlas_env.js deviceAnswer, field for field the Python
12
+ // helper's device()), so a script and an agent in a shell see the same thing.
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import { println } from '../util/ui.js';
16
+ import { deviceAnswer } from '../helpers/atlas_env.js';
17
+ import { ambientPython, envPython, librariesVenv, safeSlug, venvPython, workspaceRoot } from '../remote-control.js';
18
+
19
+ /** The folder asked about: --path, else --job under the Atlas folder, else here. */
20
+ export function deviceFolder(flags = {}, cwd = process.cwd()) {
21
+ if (typeof flags.path === 'string' && flags.path) return path.resolve(flags.path);
22
+ if (typeof flags.job === 'string' && flags.job) return path.join(workspaceRoot(), 'scripts', safeSlug(flags.job));
23
+ return cwd;
24
+ }
25
+
26
+ /** The Python a project there runs with: its own .venv, the computer's
27
+ * libraries, else the one on PATH (the runner's own order). */
28
+ export function devicePythonFor(dir) {
29
+ const own = venvPython(dir);
30
+ if (own) return [own];
31
+ const shared = envPython(librariesVenv());
32
+ if (fs.existsSync(shared)) return [shared];
33
+ return ambientPython();
34
+ }
35
+
36
+ const gbWords = (n) => (n === null || n === undefined ? 'unknown' : `${Number(n).toFixed(1)} GB`);
37
+
38
+ /** The answer in words, for a person or an agent reading a terminal. */
39
+ export function deviceWords(a) {
40
+ const lines = ['This computer'];
41
+ const cpu = a.cpu || {};
42
+ lines.push(` Processor ${cpu.model}, ${cpu.cores} cores${cpu.physical_cores ? ` (${cpu.physical_cores} physical)` : ''}`
43
+ + `${cpu.load_1m !== null && cpu.load_1m !== undefined ? `, load ${Number(cpu.load_1m).toFixed(1)}` : ''}`);
44
+ lines.push(` Memory ${gbWords(a.memory?.free_gb)} free of ${gbWords(a.memory?.total_gb)}`);
45
+ const gpus = a.gpus || [];
46
+ lines.push(` Graphics ${gpus.length ? gpus.map((g) => `${g.name}${g.memory_gb ? ` (${gbWords(g.memory_gb)}${g.unified ? ', shared with memory' : ''})` : ''}`).join('; ') : 'none found'}`);
47
+ lines.push(` Disk ${gbWords(a.disk?.free_gb)} free of ${gbWords(a.disk?.total_gb)} at ${a.disk?.path}`);
48
+ if (a.python) {
49
+ const libs = Object.entries(a.python.libraries || {});
50
+ const have = libs.filter(([, v]) => v).map(([k, v]) => `${k} ${v}`);
51
+ lines.push(` Python ${a.python.version} (${a.python.path})`);
52
+ lines.push(` Libraries ${have.length ? have.join(', ') : 'none of the heavy ones'}`);
53
+ } else {
54
+ lines.push(' Python none found');
55
+ }
56
+ lines.push(` Allowed ${Math.round((a.allowed?.share ?? 0.6) * 100)}% of this computer${a.allowed?.set ? ' (set in Atlas Studio)' : ' (not set; Atlas Studio\'s default)'}`);
57
+ const ad = a.advice || {};
58
+ lines.push('');
59
+ lines.push(`Size work to: ${ad.workers} worker${ad.workers === 1 ? '' : 's'}, under ${gbWords(ad.memory_gb)} of memory, `
60
+ + `and stop if the disk has under ${gbWords(ad.disk_floor_gb)} free${ad.disk_ok ? '' : ' (it has: do not start)'}.`);
61
+ for (const n of a.notes || []) lines.push(`- ${n}`);
62
+ return lines.join('\n');
63
+ }
64
+
65
+ export async function deviceCommand(args) {
66
+ const flags = { ...(args.flags || {}) };
67
+ const where = deviceFolder(flags);
68
+ const a = deviceAnswer({ where, python: devicePythonFor(where), root: workspaceRoot() });
69
+ if (flags.json) println(JSON.stringify(a));
70
+ else println(deviceWords(a));
71
+ return 0;
72
+ }
@@ -9,19 +9,20 @@
9
9
  // A job that could NOT run is reported too: silence is indistinguishable from
10
10
  // a schedule that quietly broke, which is the failure the record exists to make
11
11
  // visible.
12
- import { spawn, spawnSync } from 'node:child_process';
12
+ import { spawn } from 'node:child_process';
13
13
  import { API, baseUrl } from '../config.js';
14
14
  import { requestJson, jobToken } from '../api/client.js';
15
15
  import { runStream } from '../util/stream-loop.js';
16
16
  import { computerAccess } from '../access.js';
17
17
  import { color, eprintln } from '../util/ui.js';
18
+ import * as children from '../util/children.js';
18
19
  import {
19
20
  DEFAULT_TIMEOUT_MS, CHECKS_TIMEOUT_MS, checksCommand, claimRun, computerName, isPaused, jobEnv, listJobFiles, listRuns, machineId,
20
21
  osLabel, readJobFile, readJobTable, releaseRun, staleness, summarize, tail, workspace, workspaceRoot,
21
22
  takeRequests,
22
23
  takeActionCommands, writeActionReply, actionArgv, runAction, actionHandler, claimAsk, REPLIES_DIR, COMMANDS_DIR,
23
24
  writeCommand,
24
- beatRunner,
25
+ beatRunner, forgetRunner, sweepActionTemp, releaseComputerClaim,
25
26
  setPaused, writeHelpers, askModel, listModels, chatModel, answerPermission, stopChat, chatState, chatUpdatedAt,
26
27
  createProject, ensureProjectFiles, listChats, readChat, deleteChat, appendChat,
27
28
  computerUseAllowed, listComputerSessions, readComputerActions, listComputerVideo, readComputerVideoPart,
@@ -86,28 +87,18 @@ async function report() {
86
87
  const running = new Map();
87
88
 
88
89
  /** Kill a run's whole process tree. On Windows a plain kill leaves the
89
- * script's own children going; taskkill /T takes the tree. */
90
+ * script's own children going; taskkill /T takes the tree. THE WHOLE GROUP
91
+ * on macOS and Linux: runs start in a process group of their own (see
92
+ * runOnce), so a script's children go with it instead of living on holding
93
+ * its output open. One implementation for every child: util/children.js. */
90
94
  function killTree(child, hard = false) {
91
95
  if (!child || child.pid === undefined) return;
92
- if (process.platform === 'win32') {
93
- try { spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { windowsHide: true }); return; } catch { /* fall through */ }
94
- }
95
- // THE WHOLE GROUP on macOS and Linux: runs start in a process group of their
96
- // own (see runOnce), so a script's children go with it instead of living on
97
- // holding its output open.
98
- const pid = child.pid;
99
- const signal = (sig) => {
100
- try { process.kill(-pid, sig); return; } catch { /* no group: the child alone */ }
101
- try { child.kill(sig); } catch { /* already gone */ }
102
- };
103
- if (hard) { signal('SIGKILL'); return; }
104
- signal('SIGTERM');
105
- setTimeout(() => {
106
- try { process.kill(pid, 0); } catch { try { process.kill(-pid, 0); } catch { return; } }
107
- signal('SIGKILL');
108
- }, 3_000).unref();
96
+ children.killTree(child, { hard, tree: process.platform !== 'win32' });
109
97
  }
110
98
 
99
+ /** The run slots this runner holds right now, so an exit gives them back. */
100
+ const heldSlots = new Set();
101
+
111
102
  /** End every run this runner started (it is going away). */
112
103
  export function stopAllRuns() {
113
104
  for (const [, entry] of running) {
@@ -185,6 +176,9 @@ export function runOnce(argv, dir, env, timeoutMs, key = path.basename(dir)) {
185
176
  const child = spawn(argv[0], argv.slice(1), {
186
177
  cwd: dir, env, windowsHide: true, detached: process.platform !== 'win32',
187
178
  });
179
+ // On the runner's list: ended with the runner, and by the next start if
180
+ // the runner is killed outright.
181
+ children.track(child, { name: key, group: 'run', tree: process.platform !== 'win32' });
188
182
  const entry = { child, stopped: false };
189
183
  running.set(key, entry);
190
184
  starting.delete(key);
@@ -396,6 +390,9 @@ async function handleAsk(ask, root) {
396
390
  PYTHONPATH: [dir, process.env.PYTHONPATH].filter(Boolean).join(path.delimiter),
397
391
  },
398
392
  });
393
+ // It ends by itself (two minutes, or when nobody holds the computer);
394
+ // on the list so a runner that goes first takes it along.
395
+ children.track(child, { name: `watch:${path.basename(dir)}`, group: 'watch' });
399
396
  let buf = '';
400
397
  child.stdout.on('data', (d) => {
401
398
  buf += d;
@@ -701,7 +698,9 @@ async function runJob(job, root, timeoutMs) {
701
698
  // releaseRun, NOT itself: calling `release()` here (0.89.0 to 0.97.0) never
702
699
  // freed the slot, so every later run of the project on this runner was
703
700
  // turned away as "already running" until the runner restarted.
704
- const release = () => { if (!released) { released = true; releaseRun(slot); } };
701
+ heldSlots.add(slot);
702
+ const release = () => { if (!released) { heldSlots.delete(slot); released = true; releaseRun(slot); } };
703
+ let computerSessionId = '';
705
704
  try {
706
705
 
707
706
  // Refreshed every run, so a job always has the supported way to reach a
@@ -722,6 +721,7 @@ async function runJob(job, root, timeoutMs) {
722
721
  ATLAS_COMPUTER_SESSION: new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19),
723
722
  }
724
723
  : {};
724
+ computerSessionId = computerEnv.ATLAS_COMPUTER_SESSION || '';
725
725
  const env = {
726
726
  ...process.env,
727
727
  // Secrets first (shared .env, then the job's own), so a job's own env can
@@ -780,6 +780,9 @@ async function runJob(job, root, timeoutMs) {
780
780
  } finally {
781
781
  release();
782
782
  if (isChecks) checksEndedAt.set(path.basename(dir), Date.now());
783
+ // And the COMPUTER, if this run took it (the Python runner always did):
784
+ // only the claim this run's own session holds, and only once it has ended.
785
+ if (computerSessionId) releaseComputerClaim(computerSessionId, root);
783
786
  }
784
787
  }
785
788
 
@@ -1179,14 +1182,24 @@ export async function remoteControlCommand(args) {
1179
1182
  // here and keep going whether or not the server can be reached. The stream
1180
1183
  // below adds one thing only: the web being able to see this computer and ask
1181
1184
  // it for something.
1185
+ // NOTHING LEFT BEHIND (owner, 2026-10-17). What an earlier runner that was
1186
+ // killed outright left running is ended first (only processes it recorded,
1187
+ // still the same ones), and its scratch files with it.
1188
+ const parentPid = takeParentPid();
1189
+ children.useStateDir(childrenDir(root));
1190
+ reportReaped(children.reapOrphans());
1191
+ sweepActionTemp();
1182
1192
  const stopSchedule = startLocalSchedule(root, timeoutMs);
1183
1193
  const stopRequests = startLocalRequests(root, timeoutMs);
1184
- process.once('exit', () => { stopSchedule(); stopRequests(); stopAllRuns(); });
1185
- // Runs have process groups of their own, so a Ctrl-C or a stop from the app
1186
- // no longer reaches them by itself: the runner takes them down on its way.
1187
- for (const sig of ['SIGINT', 'SIGTERM']) {
1188
- process.once(sig, () => { stopAllRuns(); process.exit(0); });
1194
+ const leave = onLeaving(root, [stopSchedule, stopRequests]);
1195
+ process.once('exit', leave);
1196
+ // Runs have process groups of their own, so a Ctrl-C, a closed terminal or a
1197
+ // stop from the app no longer reaches them by itself: the runner takes them
1198
+ // down on its way. Quick on purpose (Atlas Studio waits two seconds).
1199
+ for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
1200
+ process.once(sig, () => { leave(); process.exit(0); });
1189
1201
  }
1202
+ watchForParent(parentPid, leave);
1190
1203
  startAlertListener(root, timeoutMs);
1191
1204
  // Say at the start whether runs will start here: a plan or the free week
1192
1205
  // (the first ask is also what starts the week for somebody new).
@@ -1245,6 +1258,69 @@ export async function remoteControlCommand(args) {
1245
1258
  }
1246
1259
  }
1247
1260
 
1261
+ /** Where the runner keeps the list of what it started (util/children.js). */
1262
+ export const childrenDir = (root) => path.join(workspaceRoot(root), '.runner-children');
1263
+
1264
+ /**
1265
+ * The parent this runner should go with (ATLAS_PARENT_PID, which Atlas Studio
1266
+ * sets), taken OUT of the environment so no run, action, watcher or other
1267
+ * process this runner starts inherits it: only this runner acts on it.
1268
+ */
1269
+ export function takeParentPid(env = process.env) {
1270
+ const v = String(env[children.PARENT_ENV] || '').trim();
1271
+ delete env[children.PARENT_ENV];
1272
+ return v;
1273
+ }
1274
+
1275
+ function reportReaped(r) {
1276
+ for (const c of r.ended) eprintln(color.dim(`· ended ${c.name || 'a process'} (pid ${c.pid}) left by a runner that stopped`));
1277
+ }
1278
+
1279
+ /**
1280
+ * Everything a runner does on its way out, once: the clocks stopped, every
1281
+ * run and child ended with its tree, its run slots and its heartbeat given
1282
+ * back (only its own), its list removed. Synchronous: no network.
1283
+ */
1284
+ export function onLeaving(root, stops = []) {
1285
+ let left = false;
1286
+ return () => {
1287
+ if (left) return;
1288
+ left = true;
1289
+ for (const stop of stops) { try { stop(); } catch { /* going anyway */ } }
1290
+ try { stopAllRuns(); } catch { /* going anyway */ }
1291
+ try { children.killAll(); } catch { /* going anyway */ }
1292
+ for (const slot of [...heldSlots]) { heldSlots.delete(slot); releaseRun(slot); }
1293
+ forgetRunner(root);
1294
+ children.forgetStateFile();
1295
+ };
1296
+ }
1297
+
1298
+ /**
1299
+ * GO WITH THE PARENT. Atlas Studio starts this runner in a process group of its
1300
+ * own; if Studio is force-quit nothing tells the runner, and it and its runs
1301
+ * would carry on with nobody to show them. With a parent pid, this notices the
1302
+ * parent is gone (two checks in a row, three seconds apart) and leaves. A
1303
+ * closed stdout or stderr pipe asks at once, and is never on its own a reason
1304
+ * to go. Without a parent pid (a person in a terminal) nothing changes.
1305
+ */
1306
+ export function watchForParent(parentPid, leave, { intervalMs = 3_000, exit = (c) => process.exit(c) } = {}) {
1307
+ if (!parentPid) return null;
1308
+ const watch = children.watchParent(parentPid, (pid) => {
1309
+ try { eprintln(color.dim(`● the app that started this (pid ${pid}) is gone; stopping`)); } catch { /* no one to tell */ }
1310
+ leave();
1311
+ exit(0);
1312
+ }, { intervalMs });
1313
+ if (!watch) return null;
1314
+ for (const stream of [process.stdout, process.stderr]) {
1315
+ stream.on('error', (err) => {
1316
+ // A pipe nobody reads any more: ask about the parent now. The runner
1317
+ // keeps going while the parent is alive (it stops nothing on a guess).
1318
+ if (err && err.code === 'EPIPE') watch.checkNow();
1319
+ });
1320
+ }
1321
+ return watch;
1322
+ }
1323
+
1248
1324
  /** For the tests: the one road every run takes. */
1249
1325
  export const runJobForTests = (job, root, timeoutMs) => runJob(job, root, timeoutMs);
1250
1326
 
package/src/config.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import path from 'node:path';
3
3
  import os from 'node:os';
4
4
 
5
- export const VERSION = '0.100.2';
5
+ export const VERSION = '0.100.3';
6
6
  export const DEFAULT_BASE_URL = 'https://atlasmcp.finmanagerai.com';
7
7
  export const DEFAULT_SCOPE = 'atlas broker';
8
8
  export const CLIENT_NAME = 'Atlas CLI';
@@ -0,0 +1,111 @@
1
+ // A project's dashboard.json, as the website is sent it (owner, 2026-10-17:
2
+ // "a dashboard's file overflowed past 200 and the dashboard just stopped
3
+ // working").
4
+ //
5
+ // WHAT BROKE: the relay's `read` sends no text file over 256 KB (MAX_VIEW_BYTES),
6
+ // so once a long-running project's dashboard.json grew past that, every read
7
+ // from the web answered "That file is too large to show here." The web's
8
+ // dashboard keeps its last good draw on a failed read, so it simply stopped:
9
+ // the numbers froze and nothing said why. A dashboard of a few hundred rows
10
+ // with a dozen fields each, written with indent=2, is past 256 KB.
11
+ //
12
+ // NOW: dashboard.json is read here whole (up to 64 MB), every list in it is cut
13
+ // to its newest DRAW_MAX rows (the helpers' order rule: newest last unless the
14
+ // first row is dated after the last), and the answer is held under SEND_MAX by
15
+ // halving the longest list again. What was cut rides in the JSON as
16
+ // `__atlas_cut` [{list, shown, of}], which the website takes off the data and
17
+ // hands its dashboard frame to say "Showing the newest 5,000 of 48,213". A
18
+ // small file with short lists goes exactly as it is, byte for byte.
19
+ //
20
+ // The python runner does the same (read_job_file -> _dashboard_view), with the
21
+ // helper's own functions.
22
+ import fs from 'node:fs';
23
+ import { cutList, dataLists } from './helpers/atlas_env.js';
24
+
25
+ /** Rows of one list sent to be drawn. Above the helpers' 500, so a project
26
+ * that keeps its window is never cut here. */
27
+ export const DRAW_MAX = 5000;
28
+ /** What one read of the dashboard's data may send. */
29
+ export const SEND_MAX = 4 * 1024 * 1024;
30
+ /** Past this the file is not even parsed. */
31
+ export const READ_MAX = 64 * 1024 * 1024;
32
+ /** The key the cut rides under, taken off by the website before drawing. */
33
+ export const CUT_KEY = '__atlas_cut';
34
+
35
+ const sizeWords = (n) => (n < 1024 * 1024 ? `${Math.round(n / 1024)} KB` : `${(n / (1024 * 1024)).toFixed(1)} MB`);
36
+
37
+ function copyObjects(d, depth = 0) {
38
+ if (!d || typeof d !== 'object' || Array.isArray(d) || depth > 4) return d;
39
+ return Object.fromEntries(Object.entries(d).map(([k, v]) => [k, copyObjects(v, depth + 1)]));
40
+ }
41
+
42
+ /**
43
+ * The data with every list cut to its newest `max`, and what was cut:
44
+ * {data, cut: [{list, shown, of}]}. The caller's own object is not changed.
45
+ * A top-level list (not an object) is cut too, with nowhere to say so.
46
+ */
47
+ export function capDashboardData(data, max = DRAW_MAX, sendMax = Infinity) {
48
+ if (Array.isArray(data)) {
49
+ const kept = cutList(data, max)[0];
50
+ return { data: kept, cut: kept.length < data.length ? [{ list: '', shown: kept.length, of: data.length }] : [] };
51
+ }
52
+ const out = copyObjects(data);
53
+ const of = {};
54
+ for (const [p, holder, k] of dataLists(out)) {
55
+ if (holder[k].length > max) {
56
+ of[p] = holder[k].length;
57
+ holder[k] = cutList(holder[k], max)[0];
58
+ }
59
+ }
60
+ // Still too big to send: the longest list again, by halves, never under 50.
61
+ let text = JSON.stringify(out);
62
+ while (text.length > sendMax) {
63
+ const lists = dataLists(out).filter(([, h, k]) => h[k].length > 50);
64
+ if (!lists.length) break;
65
+ const [p, h, k] = lists.reduce((a, b) => (b[1][b[2]].length > a[1][a[2]].length ? b : a));
66
+ if (!(p in of)) of[p] = h[k].length;
67
+ h[k] = cutList(h[k], Math.max(50, Math.floor(h[k].length / 2)))[0];
68
+ text = JSON.stringify(out);
69
+ }
70
+ const shown = Object.fromEntries(dataLists(out).map(([p, h, k]) => [p, h[k].length]));
71
+ return { data: out, cut: Object.keys(of).map((p) => ({ list: p, shown: shown[p] ?? 0, of: of[p] })) };
72
+ }
73
+
74
+ const kept = new Map();
75
+
76
+ /**
77
+ * dashboard.json at `target` as the relay answers it: {ok, text} or {ok: false,
78
+ * error}. The same file, unchanged since the last read, is not parsed again.
79
+ */
80
+ export function dashboardView(target, viewMax = 256 * 1024) {
81
+ let st;
82
+ try { st = fs.statSync(target); } catch { return { ok: false, error: 'That file could not be read.' }; }
83
+ if (st.size > READ_MAX) {
84
+ return { ok: false, error: `This dashboard's data is ${sizeWords(st.size)}, too large to draw. Ask the agent to keep less of it on the dashboard.` };
85
+ }
86
+ const stamp = `${st.mtimeMs}:${st.size}`;
87
+ const was = kept.get(target);
88
+ if (was && was.stamp === stamp) return was.answer;
89
+ let text;
90
+ try { text = fs.readFileSync(target, 'utf8'); } catch { return { ok: false, error: 'That file could not be read.' }; }
91
+ let parsed;
92
+ try { parsed = JSON.parse(text); } catch {
93
+ // Half written, or not JSON: as it is when small (the dashboard keeps its
94
+ // last good draw), a failed read when large.
95
+ return st.size <= viewMax ? { ok: true, text } : { ok: false, error: 'That file could not be read.' };
96
+ }
97
+ const { data, cut } = capDashboardData(parsed, DRAW_MAX, SEND_MAX - 4096);
98
+ let answer;
99
+ if (!cut.length && st.size <= viewMax) {
100
+ answer = { ok: true, text };
101
+ } else {
102
+ if (cut.length && data && typeof data === 'object' && !Array.isArray(data)) data[CUT_KEY] = cut;
103
+ const out = JSON.stringify(data);
104
+ answer = out.length > SEND_MAX
105
+ ? { ok: false, error: `This dashboard's data is ${sizeWords(st.size)}, too large to send to the web. Ask the agent to keep less of it on the dashboard.` }
106
+ : { ok: true, text: out };
107
+ }
108
+ kept.set(target, { stamp, answer });
109
+ if (kept.size > 50) kept.delete(kept.keys().next().value);
110
+ return answer;
111
+ }