mindvest-atlas 0.14.2 → 0.14.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 +1 -1
- package/src/commands/remote-control.js +182 -3
- package/src/config.js +1 -1
- package/src/helpers/atlas_env.js +154 -0
- package/src/helpers/atlas_env.py +191 -0
- package/src/remote-control.js +259 -58
- package/src/util/help.js +1 -0
package/package.json
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
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 } from 'node:child_process';
|
|
12
|
+
import { spawn, spawnSync } 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';
|
|
@@ -17,8 +17,9 @@ import { color, eprintln } from '../util/ui.js';
|
|
|
17
17
|
import {
|
|
18
18
|
DEFAULT_TIMEOUT_MS, claimRun, computerName, isPaused, jobEnv, listJobFiles, listRuns, machineId,
|
|
19
19
|
osLabel, readJobFile, releaseRun, staleness, summarize, tail, workspace, workspaceRoot,
|
|
20
|
-
setPaused, writeHelpers, askModel, listModels, chatModel,
|
|
20
|
+
setPaused, writeHelpers, askModel, listModels, chatModel, answerPermission,
|
|
21
21
|
createProject, ensureProjectFiles, listChats, readChat, deleteChat, appendChat,
|
|
22
|
+
readSchedule, cronMatches, dueMinutes, SCRIPTS_DIR, projectsForAlert,
|
|
22
23
|
} from '../remote-control.js';
|
|
23
24
|
import fs from 'node:fs';
|
|
24
25
|
import os from 'node:os';
|
|
@@ -67,12 +68,44 @@ async function report() {
|
|
|
67
68
|
return undefined;
|
|
68
69
|
}
|
|
69
70
|
|
|
71
|
+
/** The run going on in each project right now, by folder name, so Stop can
|
|
72
|
+
* reach it. One run per project means one entry per project. */
|
|
73
|
+
const running = new Map();
|
|
74
|
+
|
|
75
|
+
/** Kill a run's whole process tree. On Windows a plain kill leaves the
|
|
76
|
+
* script's own children going; taskkill /T takes the tree. */
|
|
77
|
+
function killTree(child) {
|
|
78
|
+
if (!child || child.pid === undefined) return;
|
|
79
|
+
if (process.platform === 'win32') {
|
|
80
|
+
try { spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { windowsHide: true }); return; } catch { /* fall through */ }
|
|
81
|
+
}
|
|
82
|
+
try { child.kill('SIGTERM'); } catch { /* already gone */ }
|
|
83
|
+
const pid = child.pid;
|
|
84
|
+
setTimeout(() => { try { process.kill(pid, 0); process.kill(pid, 'SIGKILL'); } catch { /* gone */ } }, 3_000).unref();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Stop the run in one project, if there is one. Returns whether there was. */
|
|
88
|
+
export function stopRun(slug) {
|
|
89
|
+
const entry = running.get(slug);
|
|
90
|
+
if (!entry) return false;
|
|
91
|
+
entry.stopped = true;
|
|
92
|
+
killTree(entry.child);
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
|
|
70
96
|
function runOnce(argv, dir, env, timeoutMs) {
|
|
71
97
|
return new Promise((resolve) => {
|
|
72
98
|
let out = '';
|
|
73
99
|
let done = false;
|
|
74
100
|
const child = spawn(argv[0], argv.slice(1), { cwd: dir, env });
|
|
75
|
-
const
|
|
101
|
+
const entry = { child, stopped: false };
|
|
102
|
+
running.set(path.basename(dir), entry);
|
|
103
|
+
const finish = (r) => {
|
|
104
|
+
if (done) return;
|
|
105
|
+
done = true;
|
|
106
|
+
running.delete(path.basename(dir));
|
|
107
|
+
resolve(entry.stopped ? { stopped: true, out } : r);
|
|
108
|
+
};
|
|
76
109
|
const timer = setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* gone */ }
|
|
77
110
|
finish({ timedOut: true, out }); }, timeoutMs);
|
|
78
111
|
const cap = (c) => { out += String(c); if (out.length > 200_000) out = out.slice(-200_000); };
|
|
@@ -124,6 +157,13 @@ async function handleAsk(ask, root) {
|
|
|
124
157
|
return answer(ask.request_id, { ok: true, paused: setPaused(dir, Boolean(ask.paused)) });
|
|
125
158
|
}
|
|
126
159
|
if (ask.op === 'runs') return answer(ask.request_id, { ok: true, runs: listRuns(root) });
|
|
160
|
+
if (ask.op === 'stop') {
|
|
161
|
+
// Stop replaces Pause on the web (owner, 2026-09-03): what a person wants
|
|
162
|
+
// from the button is the run going right now to end, not a flag for later.
|
|
163
|
+
const { dir } = workspace(ask.job || '', root);
|
|
164
|
+
const had = stopRun(path.basename(dir));
|
|
165
|
+
return answer(ask.request_id, had ? { ok: true, stopped: true } : { ok: true, stopped: false, error: 'Nothing is running.' });
|
|
166
|
+
}
|
|
127
167
|
if (ask.op === 'read') return answer(ask.request_id, readJobFile(ask.job || '', ask.path || '', root));
|
|
128
168
|
if (ask.op === 'chat') {
|
|
129
169
|
// A person typing at the web, talking to the model on this machine. Each
|
|
@@ -160,6 +200,14 @@ async function handleAsk(ask, root) {
|
|
|
160
200
|
});
|
|
161
201
|
return undefined;
|
|
162
202
|
}
|
|
203
|
+
if (ask.op === 'permission') {
|
|
204
|
+
// The person's answer to a question the model asked mid-turn. Its own ask,
|
|
205
|
+
// not part of the chat stream: the stream is one-way. `permission_id` is
|
|
206
|
+
// the question; `request_id` is this ask, which is what gets the ack.
|
|
207
|
+
void answerPermission(ask.permission_id || '', ask.option_id ?? null)
|
|
208
|
+
.then((r) => answer(ask.request_id, r));
|
|
209
|
+
return undefined;
|
|
210
|
+
}
|
|
163
211
|
if (ask.op === 'create') return answer(ask.request_id, createProject(ask.job || '', root));
|
|
164
212
|
if (ask.op === 'chats') return answer(ask.request_id, { ok: true, chats: listChats(ask.job || '', root) });
|
|
165
213
|
if (ask.op === 'chat_read') return answer(ask.request_id, readChat(ask.job || '', ask.chat_id || '', root));
|
|
@@ -286,6 +334,13 @@ async function runJob(job, root, timeoutMs) {
|
|
|
286
334
|
};
|
|
287
335
|
const r = await runOnce(argv, dir, env, timeoutMs);
|
|
288
336
|
const finished = Date.now();
|
|
337
|
+
if (r.stopped) {
|
|
338
|
+
// The owner pressed Stop. Not a failure: they asked, it stopped.
|
|
339
|
+
eprintln(color.yellow(`■ ${name} — stopped`));
|
|
340
|
+
writeLog(dir, job, 'stopped', started, finished, `Stopped by you.\n${tail(r.out) || ''}`);
|
|
341
|
+
releaseRun(dir);
|
|
342
|
+
return report(job, 'stopped', started, finished);
|
|
343
|
+
}
|
|
289
344
|
if (r.timedOut) {
|
|
290
345
|
eprintln(color.red(`✗ ${name} — timed out`));
|
|
291
346
|
writeLog(dir, job, 'failed', started, finished,
|
|
@@ -307,6 +362,123 @@ async function runJob(job, root, timeoutMs) {
|
|
|
307
362
|
return report(job, ok ? 'completed' : 'failed', started, finished);
|
|
308
363
|
}
|
|
309
364
|
|
|
365
|
+
/**
|
|
366
|
+
* The local clock (owner decision 2026-09-03: schedule on the computer, not the
|
|
367
|
+
* server).
|
|
368
|
+
*
|
|
369
|
+
* Every 20s, for each project whose atlas.json names a `schedule`, look at the
|
|
370
|
+
* minutes since this project was last checked. The current minute firing starts
|
|
371
|
+
* a run through the same path a queued job takes (one run per project, same
|
|
372
|
+
* logs, same paused rule). Any EARLIER minute that fired while nobody was
|
|
373
|
+
* checking — the app was closed, the laptop asleep — is written down as one
|
|
374
|
+
* "Computer was off" row, because a schedule that misses in silence is the one
|
|
375
|
+
* failure the owner can never see. The last-checked minute lives in
|
|
376
|
+
* runs/.schedule so a restart knows what it slept through.
|
|
377
|
+
*/
|
|
378
|
+
const SCHEDULE_STATE = '.schedule';
|
|
379
|
+
const TICK_MS = 20_000;
|
|
380
|
+
const warnedSchedules = new Set();
|
|
381
|
+
|
|
382
|
+
function lastChecked(dir) {
|
|
383
|
+
try {
|
|
384
|
+
const n = parseInt(fs.readFileSync(path.join(dir, 'runs', SCHEDULE_STATE), 'utf8'), 10);
|
|
385
|
+
return Number.isFinite(n) ? n : null;
|
|
386
|
+
} catch { return null; }
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function markChecked(dir, minute) {
|
|
390
|
+
try {
|
|
391
|
+
fs.mkdirSync(path.join(dir, 'runs'), { recursive: true });
|
|
392
|
+
fs.writeFileSync(path.join(dir, 'runs', SCHEDULE_STATE), `${minute}\n`);
|
|
393
|
+
} catch { /* the next tick tries again */ }
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export function scheduleTick(root, timeoutMs, nowSec = Date.now() / 1000) {
|
|
397
|
+
const base = path.join(workspaceRoot(root), SCRIPTS_DIR);
|
|
398
|
+
let dirs = [];
|
|
399
|
+
try {
|
|
400
|
+
dirs = fs.readdirSync(base, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
|
|
401
|
+
} catch { return; }
|
|
402
|
+
const now = Math.floor(nowSec / 60) * 60;
|
|
403
|
+
for (const slug of dirs) {
|
|
404
|
+
const dir = path.join(base, slug);
|
|
405
|
+
const sched = readSchedule(dir);
|
|
406
|
+
if (!sched) continue;
|
|
407
|
+
if (sched.error) {
|
|
408
|
+
if (!warnedSchedules.has(slug)) {
|
|
409
|
+
warnedSchedules.add(slug);
|
|
410
|
+
eprintln(color.yellow(`· ${slug} — ${sched.error}`));
|
|
411
|
+
}
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
const last = lastChecked(dir);
|
|
415
|
+
if (last !== null && now <= last) continue; // this minute is already handled
|
|
416
|
+
markChecked(dir, now);
|
|
417
|
+
const job = { automation_id: slug, name: slug, source: 'schedule', scheduled_for_epoch: now };
|
|
418
|
+
if (last === null) {
|
|
419
|
+
// First sight of this schedule: start from now. Nothing before it is a
|
|
420
|
+
// gap, because nothing before it was promised.
|
|
421
|
+
if (cronMatches(sched.cron, now, sched.tz)) spawnJob(job, root, timeoutMs);
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
const due = dueMinutes(sched.cron, last, now, sched.tz);
|
|
425
|
+
const missed = due.filter((t) => t !== now);
|
|
426
|
+
if (missed.length && !isPaused(dir)) {
|
|
427
|
+
const first = missed[0] * 1000;
|
|
428
|
+
const lastMissed = missed[missed.length - 1] * 1000;
|
|
429
|
+
eprintln(color.yellow(`· ${slug} — ${missed.length} scheduled run(s) missed while this was not running`));
|
|
430
|
+
writeLog(dir, job, 'skipped_offline', first, lastMissed,
|
|
431
|
+
`Computer was off or asleep. ${missed.length} run(s) due between `
|
|
432
|
+
+ `${iso(first)} and ${iso(lastMissed)} did not happen.\n`);
|
|
433
|
+
}
|
|
434
|
+
if (due.includes(now)) spawnJob(job, root, timeoutMs);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function startLocalSchedule(root, timeoutMs) {
|
|
439
|
+
scheduleTick(root, timeoutMs);
|
|
440
|
+
const t = setInterval(() => scheduleTick(root, timeoutMs), TICK_MS);
|
|
441
|
+
t.unref?.();
|
|
442
|
+
return () => clearInterval(t);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* The owner's alerts, as a trigger (owner decision 2026-09-03: triggers kept
|
|
447
|
+
* local, like the schedule).
|
|
448
|
+
*
|
|
449
|
+
* This computer already holds one outbound stream for jobs; this is a second,
|
|
450
|
+
* to the same alert feed `atlas alerts` shows, with charts left out. When an
|
|
451
|
+
* alert fires, every project whose atlas.json `on_alert` names it runs through
|
|
452
|
+
* the same path a queued job takes, with the whole alert event as the job's
|
|
453
|
+
* payload — so a script can read what fired and ask the model what to do about
|
|
454
|
+
* it. Nothing is registered on the server: the feed was always the owner's.
|
|
455
|
+
*/
|
|
456
|
+
export function onAlertEvent(event, root, timeoutMs, spawn = spawnJob) {
|
|
457
|
+
if (!event || typeof event !== 'object') return [];
|
|
458
|
+
const slugs = projectsForAlert(root, event);
|
|
459
|
+
const now = Math.floor(Date.now() / 1000);
|
|
460
|
+
for (const slug of slugs) {
|
|
461
|
+
eprintln(color.dim(`⚡ ${event.label || event.name || 'alert'}${event.symbol ? ` ${event.symbol}` : ''} → ${slug}`));
|
|
462
|
+
spawn({ automation_id: slug, name: slug, source: 'alert', scheduled_for_epoch: now,
|
|
463
|
+
payload: { alert: event } }, root, timeoutMs);
|
|
464
|
+
}
|
|
465
|
+
return slugs;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function startAlertListener(root, timeoutMs) {
|
|
469
|
+
// Deliberately NOT awaited: it reconnects on its own for as long as the
|
|
470
|
+
// process lives, beside the jobs stream, and a computer with no on_alert
|
|
471
|
+
// projects costs the server one idle subscriber.
|
|
472
|
+
void runStream(`${API.alerts}?chart=0`, {
|
|
473
|
+
label: 'alerts',
|
|
474
|
+
reconnect: true,
|
|
475
|
+
onEvent: ({ event, payload }) => {
|
|
476
|
+
if (event === 'meta' || event === 'error') return;
|
|
477
|
+
onAlertEvent(payload, root, timeoutMs);
|
|
478
|
+
},
|
|
479
|
+
}).catch((err) => eprintln(color.dim(`● alert feed: ${err.message}`)));
|
|
480
|
+
}
|
|
481
|
+
|
|
310
482
|
export async function remoteControlCommand(args) {
|
|
311
483
|
const flags = args.flags;
|
|
312
484
|
const root = typeof flags.dir === 'string' ? flags.dir : undefined;
|
|
@@ -315,6 +487,13 @@ export async function remoteControlCommand(args) {
|
|
|
315
487
|
eprintln(color.dim(`${computerName()} — Atlas can run jobs here. Workspace: ${workspaceRoot(root)}`));
|
|
316
488
|
eprintln(color.dim('Nothing runs while this is closed. Ctrl-C to stop.'));
|
|
317
489
|
|
|
490
|
+
// The schedules are kept HERE, on this computer, and keep going whether or
|
|
491
|
+
// not the server can be reached: the stream below only carries manual runs
|
|
492
|
+
// and the web's questions.
|
|
493
|
+
const stopSchedule = startLocalSchedule(root, timeoutMs);
|
|
494
|
+
process.once('exit', stopSchedule);
|
|
495
|
+
startAlertListener(root, timeoutMs);
|
|
496
|
+
|
|
318
497
|
// Identity is the machine id; the name and OS are how it is DISPLAYED. Two
|
|
319
498
|
// processes on one computer therefore share one row instead of splitting it.
|
|
320
499
|
const q = new URLSearchParams({ computer: computerName(), machine_id: machineId(), os: osLabel() });
|
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.14.
|
|
5
|
+
export const VERSION = '0.14.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,154 @@
|
|
|
1
|
+
// Secrets for this job. Written by Atlas — edits are overwritten.
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
//
|
|
4
|
+
// Values come from the environment Atlas set up before starting this script. Do
|
|
5
|
+
// NOT open .env: this module is the supported way to reach a secret, and the
|
|
6
|
+
// file itself is not something a script or a model needs to read.
|
|
7
|
+
|
|
8
|
+
/** The value of one secret, for USE — never for printing or logging. */
|
|
9
|
+
export const secret = (name, fallback) => process.env[name] ?? fallback;
|
|
10
|
+
|
|
11
|
+
/** Whether a secret has been filled in. True/false only, never the value. */
|
|
12
|
+
export const hasSecret = (name) => Boolean(process.env[name]);
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Call an Atlas tool over the REST API. Returns the parsed JSON.
|
|
16
|
+
*
|
|
17
|
+
* This is the way to do anything real — quotes, chains, orders, alerts. The
|
|
18
|
+
* `atlas` CLI shells out a whole process per call and hands back text to
|
|
19
|
+
* re-parse; this is one HTTPS request that returns data, which is what you
|
|
20
|
+
* actually want inside a loop over twenty tickers.
|
|
21
|
+
*
|
|
22
|
+
* The token and base URL are already in this job's environment. Do not read
|
|
23
|
+
* them, print them, or pass them anywhere else.
|
|
24
|
+
*
|
|
25
|
+
* const quote = await atlas("stock-quote", { symbol: "SPY" });
|
|
26
|
+
*/
|
|
27
|
+
export async function atlas(tool, params = {}) {
|
|
28
|
+
const base = (process.env.ATLAS_BASE_URL || "https://atlasmcp.finmanagerai.com").replace(/\/+$/, "");
|
|
29
|
+
const token = process.env.ATLAS_TOKEN;
|
|
30
|
+
if (!token) throw new Error("This job has no Atlas token. Is Local Mode signed in?");
|
|
31
|
+
const res = await fetch(`${base}/api/v1/tools/${tool}`, {
|
|
32
|
+
method: "POST",
|
|
33
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
34
|
+
body: JSON.stringify(params),
|
|
35
|
+
});
|
|
36
|
+
if (!res.ok) throw new Error(`Atlas ${tool} failed (${res.status}): ${(await res.text()).slice(0, 500)}`);
|
|
37
|
+
return res.json();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Ask YOUR OWN model a question and get its answer back as text.
|
|
42
|
+
*
|
|
43
|
+
* For the steps only a model can do: reading a filing and pulling out the
|
|
44
|
+
* guidance, judging whether a headline is about the company or its sector,
|
|
45
|
+
* summarising what changed since yesterday. Everything else - fetching,
|
|
46
|
+
* counting, comparing, placing orders - is faster and more reliable as plain
|
|
47
|
+
* code, so use this for judgement, not for arithmetic.
|
|
48
|
+
*
|
|
49
|
+
* It runs on this computer, through the model already connected in Atlas
|
|
50
|
+
* Remote Control. The prompt never reaches Atlas.
|
|
51
|
+
*
|
|
52
|
+
* const verdict = await ask(`Is this headline about AAPL itself or the sector?\n${line}`);
|
|
53
|
+
*
|
|
54
|
+
* tools:true lets the model reach Atlas data while it answers; false is faster
|
|
55
|
+
* for pure text. fresh:false keeps ONE conversation for this job (the model
|
|
56
|
+
* starts up once, later questions can refer to earlier ones); fresh:true is for
|
|
57
|
+
* independent questions that should not pile into one context.
|
|
58
|
+
*/
|
|
59
|
+
export async function ask(prompt, { tools = true, fresh = false, timeoutMs = 600000 } = {}) {
|
|
60
|
+
const url = process.env.ATLAS_ASK_URL;
|
|
61
|
+
const token = process.env.ATLAS_ASK_TOKEN;
|
|
62
|
+
if (!url || !token) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
"No model is connected on this computer, so this script cannot ask one. " +
|
|
65
|
+
"Connect a model in Atlas Remote Control, or do this step in code."
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
const res = await fetch(url, {
|
|
69
|
+
method: "POST",
|
|
70
|
+
headers: { "Content-Type": "application/json", "X-Atlas-Token": token },
|
|
71
|
+
body: JSON.stringify({ job: process.env.ATLAS_JOB || "", prompt: String(prompt), tools, fresh }),
|
|
72
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
73
|
+
});
|
|
74
|
+
const out = await res.json().catch(() => ({}));
|
|
75
|
+
if (!res.ok || !out.ok) throw new Error(`The model could not answer: ${out.error || res.status}`);
|
|
76
|
+
return out.answer || "";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Call a tool on one of YOUR connected MCP servers.
|
|
81
|
+
*
|
|
82
|
+
* Robinhood, Stripe, whatever is linked to the Atlas account. One credential
|
|
83
|
+
* does it: this job already carries an Atlas token, and Atlas holds the
|
|
84
|
+
* connector's own sign-in, does its handshake and keeps the session warm. The
|
|
85
|
+
* script never sees that server's credentials and never speaks JSON-RPC.
|
|
86
|
+
*
|
|
87
|
+
* const quotes = await mcp("Robinhood", "get_equity_quotes", { symbols: ["SPY"] });
|
|
88
|
+
*
|
|
89
|
+
* Use mcpTools() to see what is connected and what each tool takes.
|
|
90
|
+
*/
|
|
91
|
+
export async function mcp(server, tool, args = {}) {
|
|
92
|
+
return atlas("mcp-call", { server, tool, arguments: args });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* What is connected, and what each tool takes. Names and input schemas only —
|
|
97
|
+
* narrow it with `server` or `search` rather than reading a catalogue of sixty.
|
|
98
|
+
*/
|
|
99
|
+
export async function mcpTools({ server = "", search = "" } = {}) {
|
|
100
|
+
return atlas("mcp-tools", { server, search });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Stop cleanly when a secret has not been filled in yet.
|
|
105
|
+
*
|
|
106
|
+
* Names what is missing and nothing else: a blank key is the owner's to fill in
|
|
107
|
+
* Atlas Remote Control, not something a script should work around.
|
|
108
|
+
*/
|
|
109
|
+
export function require_(...names) {
|
|
110
|
+
const missing = names.filter((n) => !process.env[n]);
|
|
111
|
+
if (missing.length) {
|
|
112
|
+
console.log(`Missing: ${missing.join(", ")}. Add the value in Atlas Remote Control, then run again.`);
|
|
113
|
+
process.exit(2);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Write one CLOSED trade into this project's Performance page.
|
|
119
|
+
*
|
|
120
|
+
* Appends to trades.json beside the script; Atlas draws the equity curve, the
|
|
121
|
+
* win/loss bar and the calendar from it. Same fields as every Atlas performance
|
|
122
|
+
* row, so it looks exactly like a workflow's record:
|
|
123
|
+
*
|
|
124
|
+
* recordTrade({ symbol: "SPY", quantity: 2, entry_price: 1.20, exit_price: 1.85,
|
|
125
|
+
* opened_at: "2026-09-03T13:35:00Z", closed_at: "2026-09-03T14:10:00Z",
|
|
126
|
+
* option_type: "CALL", strike: 560, expiration: "2026-09-05" });
|
|
127
|
+
*
|
|
128
|
+
* option_type is "CALL" / "PUT" for a contract (P&L is per 100), null for
|
|
129
|
+
* shares. pnl is worked out when not given. The first real trade replaces the
|
|
130
|
+
* example row the project started with.
|
|
131
|
+
*/
|
|
132
|
+
export function recordTrade(t) {
|
|
133
|
+
const mult = t.option_type ? 100 : 1;
|
|
134
|
+
const qty = Number(t.quantity || 0);
|
|
135
|
+
const entry = Number(t.entry_price || 0);
|
|
136
|
+
const exit = Number(t.exit_price || 0);
|
|
137
|
+
const pnl = t.pnl_dollars ?? Math.round((exit - entry) * qty * mult * 100) / 100;
|
|
138
|
+
const cost = Math.abs(entry * qty * mult);
|
|
139
|
+
const row = {
|
|
140
|
+
id: Math.random().toString(36).slice(2) + Date.now().toString(36),
|
|
141
|
+
symbol: String(t.symbol).toUpperCase(),
|
|
142
|
+
option_type: t.option_type ? String(t.option_type).toUpperCase() : null,
|
|
143
|
+
strike: t.strike ?? null, expiration: t.expiration ?? null, quantity: qty,
|
|
144
|
+
entry_price: entry, exit_price: exit, pnl_dollars: pnl,
|
|
145
|
+
pnl_pct: t.pnl_pct ?? (cost ? Math.round((pnl / cost) * 10000) / 100 : 0),
|
|
146
|
+
opened_at: t.opened_at, closed_at: t.closed_at,
|
|
147
|
+
};
|
|
148
|
+
let data = {};
|
|
149
|
+
try { data = JSON.parse(fs.readFileSync("trades.json", "utf8")); } catch { data = {}; }
|
|
150
|
+
const trades = (data.trades || []).filter((x) => !x.example);
|
|
151
|
+
trades.push(row);
|
|
152
|
+
fs.writeFileSync("trades.json", JSON.stringify({ trades }, null, 2));
|
|
153
|
+
return row;
|
|
154
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Secrets for this job. Written by Atlas — edits are overwritten.
|
|
2
|
+
|
|
3
|
+
Values come from the environment Atlas set up before starting this script. Do
|
|
4
|
+
NOT open .env: this module is the supported way to reach a secret, and the file
|
|
5
|
+
itself is not something a script or a model needs to read.
|
|
6
|
+
"""
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def secret(name, default=None):
|
|
12
|
+
"""The value of one secret, for USE — never for printing or logging."""
|
|
13
|
+
return os.environ.get(name, default)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def has_secret(name):
|
|
17
|
+
"""Whether a secret has been filled in. True/False only, never the value."""
|
|
18
|
+
return bool(os.environ.get(name))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def atlas(tool, **params):
|
|
22
|
+
"""Call an Atlas tool over the REST API. Returns the parsed JSON.
|
|
23
|
+
|
|
24
|
+
This is the way to do anything real — quotes, chains, orders, alerts. The
|
|
25
|
+
`atlas` CLI shells out a whole process per call and hands back text to
|
|
26
|
+
re-parse; this is one HTTPS request that returns data, which is what you
|
|
27
|
+
actually want inside a loop over twenty tickers.
|
|
28
|
+
|
|
29
|
+
The token and the base URL are already in this job's environment. Do not
|
|
30
|
+
read them, print them, or pass them anywhere else.
|
|
31
|
+
|
|
32
|
+
quote = atlas("stock-quote", symbol="SPY")
|
|
33
|
+
print(quote["price"])
|
|
34
|
+
"""
|
|
35
|
+
import json as _json
|
|
36
|
+
import urllib.error
|
|
37
|
+
import urllib.request
|
|
38
|
+
|
|
39
|
+
base = os.environ.get("ATLAS_BASE_URL", "https://atlasmcp.finmanagerai.com").rstrip("/")
|
|
40
|
+
token = os.environ.get("ATLAS_TOKEN", "")
|
|
41
|
+
if not token:
|
|
42
|
+
raise RuntimeError("This job has no Atlas token. Is Local Mode signed in?")
|
|
43
|
+
req = urllib.request.Request(
|
|
44
|
+
"%s/api/v1/tools/%s" % (base, tool),
|
|
45
|
+
data=_json.dumps(params).encode("utf-8"),
|
|
46
|
+
headers={"Content-Type": "application/json", "Authorization": "Bearer " + token},
|
|
47
|
+
method="POST",
|
|
48
|
+
)
|
|
49
|
+
try:
|
|
50
|
+
with urllib.request.urlopen(req, timeout=120) as r:
|
|
51
|
+
return _json.loads(r.read().decode("utf-8"))
|
|
52
|
+
except urllib.error.HTTPError as e:
|
|
53
|
+
body = e.read().decode("utf-8", "replace")[:500]
|
|
54
|
+
raise RuntimeError("Atlas %s failed (%d): %s" % (tool, e.code, body))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def ask(prompt, tools=True, fresh=False, timeout=600):
|
|
58
|
+
"""Ask YOUR OWN model a question and get its answer back as text.
|
|
59
|
+
|
|
60
|
+
For the steps only a model can do: reading a filing and pulling out the
|
|
61
|
+
guidance, judging whether a headline is about the company or its sector,
|
|
62
|
+
summarising what changed since yesterday. Everything else - fetching,
|
|
63
|
+
counting, comparing, placing orders - is faster and more reliable as plain
|
|
64
|
+
code, so use this for judgement, not for arithmetic.
|
|
65
|
+
|
|
66
|
+
It runs on this computer, through the model already connected in Atlas
|
|
67
|
+
Remote Control. The prompt never reaches Atlas.
|
|
68
|
+
|
|
69
|
+
verdict = ask("Is this headline about AAPL itself or the sector?\n" + line)
|
|
70
|
+
if "itself" in verdict.lower():
|
|
71
|
+
...
|
|
72
|
+
|
|
73
|
+
tools=True lets the model reach Atlas data while it answers; pass
|
|
74
|
+
tools=False for pure text work, which is faster.
|
|
75
|
+
|
|
76
|
+
fresh=False keeps ONE conversation for this job, so later questions can
|
|
77
|
+
refer to earlier ones and the model starts up only once. Pass fresh=True
|
|
78
|
+
when each question is independent - twenty classifications should not pile
|
|
79
|
+
into one enormous context.
|
|
80
|
+
"""
|
|
81
|
+
import json as _json
|
|
82
|
+
import urllib.error
|
|
83
|
+
import urllib.request
|
|
84
|
+
|
|
85
|
+
url = os.environ.get("ATLAS_ASK_URL", "")
|
|
86
|
+
token = os.environ.get("ATLAS_ASK_TOKEN", "")
|
|
87
|
+
if not url or not token:
|
|
88
|
+
raise RuntimeError(
|
|
89
|
+
"No model is connected on this computer, so this script cannot ask one. "
|
|
90
|
+
"Connect a model in Atlas Remote Control, or do this step in code."
|
|
91
|
+
)
|
|
92
|
+
body = {"job": os.environ.get("ATLAS_JOB", ""), "prompt": str(prompt),
|
|
93
|
+
"tools": bool(tools), "fresh": bool(fresh)}
|
|
94
|
+
req = urllib.request.Request(
|
|
95
|
+
url, data=_json.dumps(body).encode("utf-8"),
|
|
96
|
+
headers={"Content-Type": "application/json", "X-Atlas-Token": token},
|
|
97
|
+
method="POST",
|
|
98
|
+
)
|
|
99
|
+
try:
|
|
100
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
101
|
+
out = _json.loads(r.read().decode("utf-8"))
|
|
102
|
+
except urllib.error.HTTPError as e:
|
|
103
|
+
body_text = e.read().decode("utf-8", "replace")[:500]
|
|
104
|
+
try:
|
|
105
|
+
body_text = _json.loads(body_text).get("error") or body_text
|
|
106
|
+
except Exception:
|
|
107
|
+
pass
|
|
108
|
+
raise RuntimeError("The model could not answer: %s" % body_text)
|
|
109
|
+
if not out.get("ok"):
|
|
110
|
+
raise RuntimeError("The model could not answer: %s" % (out.get("error") or "unknown"))
|
|
111
|
+
return out.get("answer") or ""
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def mcp(server, tool, **arguments):
|
|
115
|
+
"""Call a tool on one of YOUR connected MCP servers.
|
|
116
|
+
|
|
117
|
+
Robinhood, Stripe, whatever is linked to the Atlas account. One credential
|
|
118
|
+
does it: this job already carries an Atlas token, and Atlas holds the
|
|
119
|
+
connector's own sign-in, does its handshake and keeps the session warm. The
|
|
120
|
+
script never sees that server's credentials and never speaks JSON-RPC.
|
|
121
|
+
|
|
122
|
+
quotes = mcp("Robinhood", "get_equity_quotes", symbols=["SPY"])
|
|
123
|
+
|
|
124
|
+
Use mcp_tools() to see what is connected and what each tool takes.
|
|
125
|
+
"""
|
|
126
|
+
return atlas("mcp-call", server=server, tool=tool, arguments=arguments)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def mcp_tools(server="", search=""):
|
|
130
|
+
"""What is connected, and what each tool takes.
|
|
131
|
+
|
|
132
|
+
Names and input schemas only. Narrow it with `server` or `search` rather
|
|
133
|
+
than reading a catalogue of sixty.
|
|
134
|
+
"""
|
|
135
|
+
return atlas("mcp-tools", server=server or "", search=search or "")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def require(*names):
|
|
139
|
+
"""Stop cleanly when a secret has not been filled in yet.
|
|
140
|
+
|
|
141
|
+
Names what is missing and nothing else: a blank key is the owner's to fill
|
|
142
|
+
in Atlas Remote Control, not something a script should work around.
|
|
143
|
+
"""
|
|
144
|
+
missing = [n for n in names if not os.environ.get(n)]
|
|
145
|
+
if missing:
|
|
146
|
+
print("Missing: %s. Add the value in Atlas Remote Control, then run again."
|
|
147
|
+
% ", ".join(missing))
|
|
148
|
+
sys.exit(2)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def record_trade(symbol, quantity, entry_price, exit_price, opened_at, closed_at,
|
|
152
|
+
option_type=None, strike=None, expiration=None, pnl_dollars=None, pnl_pct=None):
|
|
153
|
+
"""Write one CLOSED trade into this project's Performance page.
|
|
154
|
+
|
|
155
|
+
Appends to trades.json beside the script; Atlas draws the equity curve, the
|
|
156
|
+
win/loss bar and the calendar from it. Same fields as every Atlas
|
|
157
|
+
performance row, so it looks exactly like a workflow's record:
|
|
158
|
+
|
|
159
|
+
record_trade("SPY", 2, 1.20, 1.85, "2026-09-03T13:35:00Z", "2026-09-03T14:10:00Z",
|
|
160
|
+
option_type="CALL", strike=560, expiration="2026-09-05")
|
|
161
|
+
|
|
162
|
+
option_type is "CALL" / "PUT" for a contract (P&L is per 100), None for
|
|
163
|
+
shares. pnl is worked out when not given. The first real trade replaces the
|
|
164
|
+
example row the project started with.
|
|
165
|
+
"""
|
|
166
|
+
import json as _json
|
|
167
|
+
import uuid as _uuid
|
|
168
|
+
mult = 100 if option_type else 1
|
|
169
|
+
qty = float(quantity or 0)
|
|
170
|
+
if pnl_dollars is None:
|
|
171
|
+
pnl_dollars = round((float(exit_price or 0) - float(entry_price or 0)) * qty * mult, 2)
|
|
172
|
+
if pnl_pct is None:
|
|
173
|
+
cost = abs(float(entry_price or 0) * qty * mult)
|
|
174
|
+
pnl_pct = round(pnl_dollars / cost * 100, 2) if cost else 0.0
|
|
175
|
+
row = {
|
|
176
|
+
"id": _uuid.uuid4().hex, "symbol": str(symbol).upper(),
|
|
177
|
+
"option_type": (str(option_type).upper() if option_type else None),
|
|
178
|
+
"strike": strike, "expiration": expiration, "quantity": qty,
|
|
179
|
+
"entry_price": float(entry_price or 0), "exit_price": float(exit_price or 0),
|
|
180
|
+
"pnl_dollars": pnl_dollars, "pnl_pct": pnl_pct,
|
|
181
|
+
"opened_at": opened_at, "closed_at": closed_at,
|
|
182
|
+
}
|
|
183
|
+
try:
|
|
184
|
+
data = _json.load(open("trades.json", encoding="utf-8"))
|
|
185
|
+
except Exception:
|
|
186
|
+
data = {}
|
|
187
|
+
trades = [t for t in (data.get("trades") or []) if not t.get("example")]
|
|
188
|
+
trades.append(row)
|
|
189
|
+
with open("trades.json", "w", encoding="utf-8") as fh:
|
|
190
|
+
_json.dump({"trades": trades}, fh, indent=2)
|
|
191
|
+
return row
|
package/src/remote-control.js
CHANGED
|
@@ -9,6 +9,7 @@ import fs from 'node:fs';
|
|
|
9
9
|
import os from 'node:os';
|
|
10
10
|
import path from 'node:path';
|
|
11
11
|
import { randomUUID } from 'node:crypto';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
12
13
|
import { configDir } from './config.js';
|
|
13
14
|
|
|
14
15
|
/**
|
|
@@ -195,6 +196,181 @@ export function readManifest(dir) {
|
|
|
195
196
|
}
|
|
196
197
|
}
|
|
197
198
|
|
|
199
|
+
// ---- the schedule, kept IN the project ---------------------------------------
|
|
200
|
+
//
|
|
201
|
+
// LOCAL CRON (owner decision 2026-09-03, reversing 2026-08-29). The schedule
|
|
202
|
+
// lives in atlas.json on the computer that runs it, and this process keeps the
|
|
203
|
+
// time; the server is not in the loop. "Make things as local as possible": a
|
|
204
|
+
// project is a folder that knows how to start itself AND when. The one thing a
|
|
205
|
+
// local clock cannot do is record a run the computer slept through, so the
|
|
206
|
+
// ticker writes a "Computer was off" row for any gap it finds when it wakes.
|
|
207
|
+
|
|
208
|
+
const CRON_RANGES = [[0, 59], [0, 23], [1, 31], [1, 12], [0, 7]];
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Parse a five-field cron line into sets, or null when it is not one.
|
|
212
|
+
*
|
|
213
|
+
* Supports what people actually write: `*`, `n`, `a-b`, `* /n` (no space),
|
|
214
|
+
* `a-b/n` and comma lists. Day-of-week 7 is Sunday like 0. No names.
|
|
215
|
+
*/
|
|
216
|
+
export function parseCron(expr) {
|
|
217
|
+
const parts = String(expr || '').trim().split(/\s+/);
|
|
218
|
+
if (parts.length !== 5) return null;
|
|
219
|
+
const fields = [];
|
|
220
|
+
for (let i = 0; i < 5; i += 1) {
|
|
221
|
+
const set = new Set();
|
|
222
|
+
for (const piece of parts[i].split(',')) {
|
|
223
|
+
const m = /^(\*|\d+(?:-\d+)?)(?:\/(\d+))?$/.exec(piece);
|
|
224
|
+
if (!m) return null;
|
|
225
|
+
let [lo, hi] = CRON_RANGES[i];
|
|
226
|
+
if (m[1] !== '*') {
|
|
227
|
+
const [a, b] = m[1].split('-').map(Number);
|
|
228
|
+
lo = a; hi = b === undefined ? a : b;
|
|
229
|
+
}
|
|
230
|
+
const step = m[2] ? Number(m[2]) : 1;
|
|
231
|
+
if (!(step > 0) || lo < CRON_RANGES[i][0] || hi > CRON_RANGES[i][1] || lo > hi) return null;
|
|
232
|
+
for (let v = lo; v <= hi; v += step) set.add(i === 4 && v === 7 ? 0 : v);
|
|
233
|
+
}
|
|
234
|
+
fields.push(set);
|
|
235
|
+
}
|
|
236
|
+
return {
|
|
237
|
+
minute: fields[0], hour: fields[1], dom: fields[2], month: fields[3], dow: fields[4],
|
|
238
|
+
domStar: parts[2] === '*', dowStar: parts[4] === '*',
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const DOW = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
|
|
243
|
+
|
|
244
|
+
/** The wall-clock fields of a moment in a time zone (or this machine's). */
|
|
245
|
+
export function localFields(epochSec, tz) {
|
|
246
|
+
const date = new Date(epochSec * 1000);
|
|
247
|
+
let fmt = null;
|
|
248
|
+
if (tz) {
|
|
249
|
+
try {
|
|
250
|
+
fmt = new Intl.DateTimeFormat('en-US', {
|
|
251
|
+
timeZone: tz, hour12: false, minute: 'numeric', hour: 'numeric',
|
|
252
|
+
day: 'numeric', month: 'numeric', weekday: 'short',
|
|
253
|
+
});
|
|
254
|
+
} catch { fmt = null; /* an unknown zone falls back to local time */ }
|
|
255
|
+
}
|
|
256
|
+
if (!fmt) {
|
|
257
|
+
return { minute: date.getMinutes(), hour: date.getHours(), dom: date.getDate(),
|
|
258
|
+
month: date.getMonth() + 1, dow: date.getDay() };
|
|
259
|
+
}
|
|
260
|
+
const out = {};
|
|
261
|
+
for (const part of fmt.formatToParts(date)) {
|
|
262
|
+
if (part.type === 'minute') out.minute = Number(part.value);
|
|
263
|
+
else if (part.type === 'hour') out.hour = Number(part.value) % 24;
|
|
264
|
+
else if (part.type === 'day') out.dom = Number(part.value);
|
|
265
|
+
else if (part.type === 'month') out.month = Number(part.value);
|
|
266
|
+
else if (part.type === 'weekday') out.dow = DOW[part.value] ?? 0;
|
|
267
|
+
}
|
|
268
|
+
return out;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Does this cron fire at this minute? Standard rule: when BOTH the day-of-month
|
|
272
|
+
* and the day-of-week are restricted, either one matching is enough. */
|
|
273
|
+
export function cronMatches(cron, epochSec, tz) {
|
|
274
|
+
if (!cron) return false;
|
|
275
|
+
const f = localFields(epochSec, tz);
|
|
276
|
+
if (!cron.minute.has(f.minute) || !cron.hour.has(f.hour) || !cron.month.has(f.month)) return false;
|
|
277
|
+
const domOk = cron.dom.has(f.dom);
|
|
278
|
+
const dowOk = cron.dow.has(f.dow);
|
|
279
|
+
if (cron.domStar && cron.dowStar) return true;
|
|
280
|
+
if (cron.domStar) return dowOk;
|
|
281
|
+
if (cron.dowStar) return domOk;
|
|
282
|
+
return domOk || dowOk;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Every minute in (from, to] at which the cron fires, as epoch seconds.
|
|
287
|
+
*
|
|
288
|
+
* Bounded to the last 24 hours so a laptop shut for a fortnight comes back to
|
|
289
|
+
* one honest gap row, not a loop over twenty thousand minutes.
|
|
290
|
+
*/
|
|
291
|
+
export function dueMinutes(cron, fromEpoch, toEpoch, tz) {
|
|
292
|
+
const out = [];
|
|
293
|
+
if (!cron) return out;
|
|
294
|
+
const end = Math.floor(toEpoch / 60) * 60;
|
|
295
|
+
let t = Math.floor(fromEpoch / 60) * 60 + 60;
|
|
296
|
+
if (t < end - 86400) t = end - 86400;
|
|
297
|
+
for (; t <= end; t += 60) if (cronMatches(cron, t, tz)) out.push(t);
|
|
298
|
+
return out;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** The schedule a project declares in atlas.json, or null when it has none.
|
|
302
|
+
* `{ expr, cron, tz, error }` — `error` names a line that could not be read,
|
|
303
|
+
* so the owner is told instead of the project silently never running. */
|
|
304
|
+
export function readSchedule(dir) {
|
|
305
|
+
const m = readManifest(dir);
|
|
306
|
+
const expr = typeof m.schedule === 'string' ? m.schedule.trim() : '';
|
|
307
|
+
if (!expr) return null;
|
|
308
|
+
const tz = typeof m.timezone === 'string' && m.timezone.trim() ? m.timezone.trim() : '';
|
|
309
|
+
const cron = parseCron(expr);
|
|
310
|
+
return { expr, cron, tz, error: cron ? null : `schedule "${expr}" is not a cron line I understand` };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* The alerts a project wants to run on, from atlas.json `on_alert`.
|
|
315
|
+
*
|
|
316
|
+
* A string or a list. Each entry names an alert the owner already has: its
|
|
317
|
+
* label ("SPY DEX flip"), its id, its symbol ("SPY"), or its kind ("king_node").
|
|
318
|
+
* "*" means any alert of theirs. Matching is case-insensitive. Empty = none:
|
|
319
|
+
* the project runs on its schedule or Run now only. The server is not told
|
|
320
|
+
* about any of this; the computer listens to the owner's own alert feed and
|
|
321
|
+
* decides for itself (owner, 2026-09-03: triggers, kept local).
|
|
322
|
+
*/
|
|
323
|
+
export function readAlertTriggers(dir) {
|
|
324
|
+
const raw = readManifest(dir).on_alert;
|
|
325
|
+
const list = Array.isArray(raw) ? raw : (typeof raw === 'string' && raw.trim() ? [raw] : []);
|
|
326
|
+
return list.map((v) => String(v || '').trim().toLowerCase()).filter(Boolean);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Does a fired alert (the /api/v1/alerts event) match a project's on_alert list? */
|
|
330
|
+
export function alertMatches(triggers, event) {
|
|
331
|
+
if (!triggers || !triggers.length || !event || typeof event !== 'object') return false;
|
|
332
|
+
const facets = [event.label, event.id, event.symbol, event.name, event.alert_type]
|
|
333
|
+
.map((v) => String(v || '').trim().toLowerCase()).filter(Boolean);
|
|
334
|
+
return triggers.some((t) => t === '*' || facets.includes(t));
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Every project on this computer that wants this alert, as folder names. */
|
|
338
|
+
export function projectsForAlert(root, event) {
|
|
339
|
+
const base = path.join(workspaceRoot(root), SCRIPTS_DIR);
|
|
340
|
+
let dirs = [];
|
|
341
|
+
try {
|
|
342
|
+
dirs = fs.readdirSync(base, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
|
|
343
|
+
} catch { return []; }
|
|
344
|
+
return dirs.filter((slug) => alertMatches(readAlertTriggers(path.join(base, slug)), event));
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* What the running script last said about where it is, from the progress file
|
|
349
|
+
* the manifest names (runs/progress.json by default). Null when there is none.
|
|
350
|
+
* Clipped, typed and confined to the project: this is read for the web.
|
|
351
|
+
*/
|
|
352
|
+
export function readProgress(dir) {
|
|
353
|
+
const rel = readManifest(dir).progress;
|
|
354
|
+
const want = typeof rel === 'string' && rel.trim() ? rel.trim() : 'runs/progress.json';
|
|
355
|
+
const file = path.resolve(dir, want);
|
|
356
|
+
if (!file.startsWith(path.resolve(dir) + path.sep)) return null;
|
|
357
|
+
try {
|
|
358
|
+
const st = fs.statSync(file);
|
|
359
|
+
const d = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
360
|
+
if (!d || typeof d !== 'object') return null;
|
|
361
|
+
const num = (v) => (Number.isFinite(Number(v)) && v !== null && v !== '' ? Number(v) : null);
|
|
362
|
+
return {
|
|
363
|
+
step: typeof d.step === 'string' ? d.step.slice(0, 200) : '',
|
|
364
|
+
done: num(d.done),
|
|
365
|
+
total: num(d.total),
|
|
366
|
+
message: typeof d.message === 'string' ? d.message.slice(0, 500) : '',
|
|
367
|
+
at: st.mtimeMs / 1000,
|
|
368
|
+
};
|
|
369
|
+
} catch {
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
198
374
|
/** The command the agent declared, split for spawning, or null. */
|
|
199
375
|
export function manifestCommand(dir) {
|
|
200
376
|
const run = readManifest(dir).run;
|
|
@@ -277,59 +453,16 @@ export function readEnvFile(file) {
|
|
|
277
453
|
* changes nothing.
|
|
278
454
|
*/
|
|
279
455
|
export function writeHelpers(dir) {
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
import os
|
|
287
|
-
import sys
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
def secret(name, default=None):
|
|
291
|
-
"""The value of one secret, for USE — never for printing or logging."""
|
|
292
|
-
return os.environ.get(name, default)
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
def has_secret(name):
|
|
296
|
-
"""Whether a secret has been filled in. True/False only, never the value."""
|
|
297
|
-
return bool(os.environ.get(name))
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
def require(*names):
|
|
301
|
-
"""Stop cleanly when a secret has not been filled in yet."""
|
|
302
|
-
missing = [n for n in names if not os.environ.get(n)]
|
|
303
|
-
if missing:
|
|
304
|
-
print("Missing: %s. Add the value in Atlas Remote Control, then run again."
|
|
305
|
-
% ", ".join(missing))
|
|
306
|
-
sys.exit(2)
|
|
307
|
-
`;
|
|
308
|
-
const js = `// Secrets for this job. Written by Atlas — edits are overwritten.
|
|
309
|
-
//
|
|
310
|
-
// Values come from the environment Atlas set up before starting this script. Do
|
|
311
|
-
// NOT open .env: this module is the supported way to reach a secret, and the
|
|
312
|
-
// file itself is not something a script or a model needs to read.
|
|
313
|
-
|
|
314
|
-
/** The value of one secret, for USE — never for printing or logging. */
|
|
315
|
-
export const secret = (name, fallback) => process.env[name] ?? fallback;
|
|
316
|
-
|
|
317
|
-
/** Whether a secret has been filled in. True/false only, never the value. */
|
|
318
|
-
export const hasSecret = (name) => Boolean(process.env[name]);
|
|
319
|
-
|
|
320
|
-
/** Stop cleanly when a secret has not been filled in yet. */
|
|
321
|
-
export function require_(...names) {
|
|
322
|
-
const missing = names.filter((n) => !process.env[n]);
|
|
323
|
-
if (missing.length) {
|
|
324
|
-
console.log(\`Missing: \${missing.join(", ")}. Add the value in Atlas Remote Control, then run again.\`);
|
|
325
|
-
process.exit(2);
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
`;
|
|
456
|
+
// ONE helper text for both CLIs. The python CLI keeps the source of truth
|
|
457
|
+
// inline (HELPER_PY / HELPER_JS in remote_control.py); these files are that
|
|
458
|
+
// text, shipped inside the npm package. They used to be a shorter copy with
|
|
459
|
+
// no ask()/atlas()/mcp(), so a script written to the build prompt failed on
|
|
460
|
+
// a computer running THIS CLI (which is every Studio install, 2026-09-03).
|
|
461
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
329
462
|
try {
|
|
330
|
-
fs.writeFileSync(path.join(dir, 'atlas_env.py'), py);
|
|
331
|
-
fs.writeFileSync(path.join(dir, 'atlas_env.js'), js);
|
|
332
|
-
} catch { /*
|
|
463
|
+
fs.writeFileSync(path.join(dir, 'atlas_env.py'), fs.readFileSync(path.join(here, 'helpers', 'atlas_env.py'), 'utf8'));
|
|
464
|
+
fs.writeFileSync(path.join(dir, 'atlas_env.js'), fs.readFileSync(path.join(here, 'helpers', 'atlas_env.js'), 'utf8'));
|
|
465
|
+
} catch { /* best effort: a run without helpers still runs */ }
|
|
333
466
|
}
|
|
334
467
|
|
|
335
468
|
export function jobEnv(dir, root) {
|
|
@@ -581,10 +714,15 @@ export function listRuns(root, limit = 50) {
|
|
|
581
714
|
} catch { /* mid-write */ }
|
|
582
715
|
runs.push({ job, name: nm, status: 'running', started_at: started, duration_s: null, log: '' });
|
|
583
716
|
}
|
|
717
|
+
// "If we come back later we see the progress" (owner, 2026-09-03): the
|
|
718
|
+
// newest row of a project carries what its progress file last said, so a
|
|
719
|
+
// run that is going shows a bar and a finished one shows where it ended.
|
|
720
|
+
const newest = runs.length;
|
|
721
|
+
const progress = readProgress(path.join(base, job));
|
|
584
722
|
let files = [];
|
|
585
723
|
try {
|
|
586
724
|
files = fs.readdirSync(path.join(base, job, 'runs')).filter((f) => f.endsWith('.log')).sort().reverse();
|
|
587
|
-
} catch { continue; }
|
|
725
|
+
} catch { if (progress && runs[newest]) runs[newest].progress = progress; continue; }
|
|
588
726
|
for (const f of files.slice(0, limit)) {
|
|
589
727
|
const stem = f.replace(/\.log$/, '');
|
|
590
728
|
const parts = stem.split('-');
|
|
@@ -604,6 +742,9 @@ export function listRuns(root, limit = 50) {
|
|
|
604
742
|
} catch { /* unreadable: the filename still tells the story */ }
|
|
605
743
|
runs.push({ job, name, status, started_at: Number.isFinite(started) ? started : 0, duration_s: took, log: f });
|
|
606
744
|
}
|
|
745
|
+
// Only when the file was touched during or after that run: a stale bar
|
|
746
|
+
// from last week under today's row would be a lie.
|
|
747
|
+
if (progress && runs[newest] && progress.at >= runs[newest].started_at - 5) runs[newest].progress = progress;
|
|
607
748
|
}
|
|
608
749
|
runs.sort((a, b) => b.started_at - a.started_at);
|
|
609
750
|
return runs.slice(0, limit);
|
|
@@ -713,6 +854,35 @@ export async function chatModel(prompt, onEvent, { job = '', model = '', timeout
|
|
|
713
854
|
}
|
|
714
855
|
}
|
|
715
856
|
|
|
857
|
+
/**
|
|
858
|
+
* Hand the model the person's answer to a question it asked mid-turn.
|
|
859
|
+
*
|
|
860
|
+
* With "Ask before the model uses a tool" on, the bridge streams the question
|
|
861
|
+
* as a `permission` event and the model waits. The answer arrives from the web
|
|
862
|
+
* as its own ask (a stream is one-way), and this passes it to the bridge. `ok`
|
|
863
|
+
* false with a reason when nothing was waiting: the turn ended, it timed out,
|
|
864
|
+
* or the id was never asked.
|
|
865
|
+
*/
|
|
866
|
+
export async function answerPermission(requestId, optionId, timeoutMs = 15000) {
|
|
867
|
+
const url = process.env.ATLAS_ASK_URL;
|
|
868
|
+
const token = process.env.ATLAS_ASK_TOKEN;
|
|
869
|
+
if (!url || !token) return { ok: false, error: 'No model is connected on this computer.' };
|
|
870
|
+
if (!requestId) return { ok: false, error: 'Which question?' };
|
|
871
|
+
try {
|
|
872
|
+
const res = await fetch(url.replace('/ask', '/chat/permission'), {
|
|
873
|
+
method: 'POST',
|
|
874
|
+
headers: { 'Content-Type': 'application/json', 'X-Atlas-Token': token },
|
|
875
|
+
body: JSON.stringify({ request_id: String(requestId), option_id: optionId ?? null }),
|
|
876
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
877
|
+
});
|
|
878
|
+
const out = await res.json().catch(() => ({}));
|
|
879
|
+
if (!res.ok || !out.ok) return { ok: false, error: out.error || `The model did not take the answer (${res.status}).` };
|
|
880
|
+
return { ok: true };
|
|
881
|
+
} catch (err) {
|
|
882
|
+
return { ok: false, error: String(err?.message || err) };
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
|
|
716
886
|
// The starter a project gets the moment it is created, so an agent opening the
|
|
717
887
|
// folder finds a shape to work in rather than an empty directory. Nothing here
|
|
718
888
|
// runs: it is scaffolding, the model replaces main.* on the first build.
|
|
@@ -725,8 +895,14 @@ const STARTER_README = (name) => [
|
|
|
725
895
|
'- `.env` your keys. Values stay on this computer and are never read',
|
|
726
896
|
' back by anything, including the model.',
|
|
727
897
|
'- `atlas_env.py` helpers: secret(), require(), atlas(), ask().',
|
|
728
|
-
'- `flow.md` a diagram of what happens, written after the first build.',
|
|
729
|
-
'
|
|
898
|
+
'- `flow.md` a diagram of what happens, written after the first build.',
|
|
899
|
+
'- `atlas.json` how to start it, and WHEN: `schedule` is a cron line this',
|
|
900
|
+
' computer keeps to. Empty = only when you press Run now.',
|
|
901
|
+
'- `runs/` one log per run, and progress.json while it works.',
|
|
902
|
+
'- `trades.json` the trades this project made, shown on its Performance',
|
|
903
|
+
' page. record_trade() in atlas_env writes one.', '',
|
|
904
|
+
'Nothing here is stored on Atlas servers. The schedule runs on this computer,',
|
|
905
|
+
'while Atlas Remote Control is open.', '',
|
|
730
906
|
].join('\n');
|
|
731
907
|
|
|
732
908
|
const STARTER_MAIN = (name) => [
|
|
@@ -762,11 +938,35 @@ const STARTER_FLOW = (name) => [
|
|
|
762
938
|
|
|
763
939
|
const STARTER_MANIFEST = JSON.stringify({
|
|
764
940
|
run: '',
|
|
941
|
+
schedule: '',
|
|
942
|
+
timezone: '',
|
|
943
|
+
on_alert: [],
|
|
765
944
|
progress: 'runs/progress.json',
|
|
766
945
|
_note: 'run: how to start this project, e.g. "python main.py" or "py -3 -m mypkg". '
|
|
767
|
-
+ 'Empty means Atlas looks for main.py itself.
|
|
768
|
-
+ '
|
|
769
|
-
+ '
|
|
946
|
+
+ 'Empty means Atlas looks for main.py itself. '
|
|
947
|
+
+ 'schedule: WHEN to run it, as a five-field cron line kept by this computer, e.g. '
|
|
948
|
+
+ '"*/5 9-16 * * 1-5" (every 5 minutes, 9am-4pm, weekdays). Empty means only when '
|
|
949
|
+
+ 'someone presses Run now. timezone: the zone the schedule is read in, e.g. '
|
|
950
|
+
+ '"America/New_York"; empty means this computer\'s own clock. '
|
|
951
|
+
+ 'on_alert: run when one of the owner\'s Atlas alerts fires - a list of alert labels, '
|
|
952
|
+
+ 'ids, symbols or kinds, or ["*"] for any; the alert arrives in $ATLAS_JOB_PAYLOAD as '
|
|
953
|
+
+ '{"alert": {label, symbol, message, result, ...}}. '
|
|
954
|
+
+ 'progress: a file you overwrite with {step, done, total, message} while you work, '
|
|
955
|
+
+ 'and Atlas draws a bar from it. None of these is required.',
|
|
956
|
+
}, null, 2) + '\n';
|
|
957
|
+
|
|
958
|
+
// A project's Performance page starts with one EXAMPLE trade, so the model can
|
|
959
|
+
// see every field it must fill and the owner sees what the page looks like
|
|
960
|
+
// before anything real has happened. The first real trade replaces it.
|
|
961
|
+
const STARTER_TRADES = JSON.stringify({
|
|
962
|
+
_note: 'One closed trade per row, written by record_trade() / recordTrade() in atlas_env. '
|
|
963
|
+
+ 'Fields are exactly the Atlas performance row. option_type CALL/PUT for a contract '
|
|
964
|
+
+ '(P&L per 100), null for shares. The example row is dropped by the first real trade.',
|
|
965
|
+
trades: [{
|
|
966
|
+
id: 'example', example: true, symbol: 'SPY', option_type: 'CALL', strike: 560, expiration: '2026-09-05',
|
|
967
|
+
quantity: 2, entry_price: 1.20, exit_price: 1.85, pnl_dollars: 130, pnl_pct: 54.17,
|
|
968
|
+
opened_at: '2026-09-03T13:35:00Z', closed_at: '2026-09-03T14:10:00Z',
|
|
969
|
+
}],
|
|
770
970
|
}, null, 2) + '\n';
|
|
771
971
|
|
|
772
972
|
/**
|
|
@@ -783,6 +983,7 @@ export function starterFiles(slug) {
|
|
|
783
983
|
['main.py', STARTER_MAIN(slug)],
|
|
784
984
|
['flow.md', STARTER_FLOW(slug)],
|
|
785
985
|
['atlas.json', STARTER_MANIFEST],
|
|
986
|
+
['trades.json', STARTER_TRADES],
|
|
786
987
|
['.env', '# KEY=value - one per line. Values stay on this computer.\n'],
|
|
787
988
|
];
|
|
788
989
|
}
|
package/src/util/help.js
CHANGED
|
@@ -55,6 +55,7 @@ export function printRootHelp() {
|
|
|
55
55
|
println(c.dim(' atlas create-task --name "Close SPY 3:55" --instruction "Close my SPY position" --schedule-times 15:55'));
|
|
56
56
|
println(c.dim(' atlas run-task --task-id <id>'));
|
|
57
57
|
println(c.dim(' atlas workflow-create --name "GEX signal" --schedule "30 9 * * 1-5" --tools Stock-Quote --instruction "…"'));
|
|
58
|
+
println(c.dim(' atlas workflow-create --name "FOMC day" --run-once-at "2026-09-17 14:00" --tools Stock-Quote --instruction "…" # once, at specific dates/times; --trigger-source manual = Run now only'));
|
|
58
59
|
println(c.dim(' atlas alerts --symbol SPY --name greek_exposure --save-charts ./charts'));
|
|
59
60
|
println(c.dim(' atlas flow stream --symbol NVDA'));
|
|
60
61
|
}
|