mindvest-atlas 0.68.1 → 0.68.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/cli.js +8 -0
- package/src/commands/alerts.js +15 -44
- package/src/commands/computer-use.js +37 -0
- package/src/commands/models.js +5 -1
- package/src/commands/moved.js +32 -0
- package/src/commands/signals.js +17 -124
- package/src/helpers/atlas_env.js +65 -3
- package/src/helpers/atlas_env.py +232 -12
- package/src/remote-control.js +17 -2
- package/src/util/help.js +6 -12
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -24,6 +24,14 @@ import { remoteCheckCommand } from './commands/remote-check.js';
|
|
|
24
24
|
import { shareCommand } from './commands/share.js';
|
|
25
25
|
import { historicalContractCommand } from './commands/historical.js';
|
|
26
26
|
import { callCommand } from './commands/call.js';
|
|
27
|
+
// WITHOUT THESE, `atlas --version` PRINTED THE BARE NUMBER AND NOTHING ELSE.
|
|
28
|
+
// They were called but never imported, and the call sits inside a try/catch
|
|
29
|
+
// that swallows anything — so the ReferenceError was invisible and the detail
|
|
30
|
+
// this exists to show (which atlas answered, what else is installed) never
|
|
31
|
+
// appeared. That is the confusion it was written to end: owner, 2026-09-04,
|
|
32
|
+
// "the atlas.cmd does not update on reinstalling, also the mindvest-atlas is
|
|
33
|
+
// using a different version — this is confusing."
|
|
34
|
+
import { installedClis, whichAtlas, versionDetailLines } from './cli-installs.js';
|
|
27
35
|
|
|
28
36
|
/**
|
|
29
37
|
* Say which Atlas answered and what else is installed.
|
package/src/commands/alerts.js
CHANGED
|
@@ -14,6 +14,7 @@ import { runStream } from '../util/stream-loop.js';
|
|
|
14
14
|
import { formatAlert } from '../util/format.js';
|
|
15
15
|
import { color, println, eprintln, prettyJson } from '../util/ui.js';
|
|
16
16
|
import { requestJson } from '../api/client.js';
|
|
17
|
+
import { moved } from './moved.js';
|
|
17
18
|
import * as background from '../util/background.js';
|
|
18
19
|
|
|
19
20
|
// Names the slot, the claim file and the log, and it is the command's own name
|
|
@@ -29,52 +30,16 @@ function sanitizeFilename(name) {
|
|
|
29
30
|
// ── list / delete ────────────────────────────────────────────────────────────
|
|
30
31
|
const callTool = (name, body) => requestJson(API.tool(name), { method: 'POST', body: body || {} });
|
|
31
32
|
|
|
32
|
-
async function listAlerts(flags) {
|
|
33
|
-
const data = await callTool('List-Alerts', {
|
|
34
|
-
...(flags.all ? { active_only: false } : {}),
|
|
35
|
-
...(flags.symbol ? { symbol: String(flags.symbol).toUpperCase() } : {}),
|
|
36
|
-
});
|
|
37
|
-
if (flags.json) { println(prettyJson(data)); return 0; }
|
|
38
|
-
const list = (data && (data.alerts || data.data)) || (Array.isArray(data) ? data : []);
|
|
39
|
-
if (!list.length) { println(color.dim('No alerts.')); return 0; }
|
|
40
|
-
for (const a of list) {
|
|
41
|
-
const state = a.is_active === false ? color.dim('off') : color.green('on');
|
|
42
|
-
println(`${state} ${color.bold(a.name || '(unnamed)')} ${color.dim([a.symbol, a.alert_type].filter(Boolean).join(' · '))}`);
|
|
43
|
-
println(color.dim(` ${a.id}`));
|
|
44
|
-
}
|
|
45
|
-
println('');
|
|
46
|
-
println(color.dim('Switch one off: atlas alerts delete --id <id>'));
|
|
47
|
-
println(color.dim('Delete for good: atlas alerts delete --id <id> --purge'));
|
|
48
|
-
return 0;
|
|
49
|
-
}
|
|
50
33
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const ids = [...fromFlags, ...(args._ || []).slice(1)]
|
|
57
|
-
.map((x) => String(x || '').trim())
|
|
58
|
-
.filter((x) => x && x !== 'true');
|
|
59
|
-
if (!ids.length) {
|
|
60
|
-
eprintln(color.red('Which alert? --id <id> (repeat for several), or list them: atlas alerts list'));
|
|
61
|
-
return 1;
|
|
62
|
-
}
|
|
63
|
-
const purge = !!flags.purge;
|
|
64
|
-
const data = await callTool('Delete-Alert', {
|
|
65
|
-
alert_ids: ids, purge,
|
|
66
|
-
});
|
|
67
|
-
if (data && data.error) { eprintln(color.red(String(data.error))); return 1; }
|
|
68
|
-
if (flags.json) { println(prettyJson(data)); return 0; }
|
|
69
|
-
println(data.message || (purge ? 'Deleted.' : 'Switched off.'));
|
|
70
|
-
// A purge that kept some says why, and the message already names them; this
|
|
71
|
-
// points at the way through rather than leaving the user to guess.
|
|
72
|
-
if (data.kept && Object.keys(data.kept).length) {
|
|
73
|
-
println(color.dim('Repoint those setups, or drop --purge to just switch the alert off.'));
|
|
74
|
-
}
|
|
34
|
+
/** The shape of every event on the feed, as JSON Schema, so a script or a
|
|
35
|
+
* model can read it once and rely on it. Alerts and trades share it. */
|
|
36
|
+
async function schema() {
|
|
37
|
+
const data = await requestJson(`${API.alerts}/schema`);
|
|
38
|
+
println(prettyJson(data));
|
|
75
39
|
return 0;
|
|
76
40
|
}
|
|
77
41
|
|
|
42
|
+
|
|
78
43
|
export async function alertsCommand(args) {
|
|
79
44
|
const flags = args.flags;
|
|
80
45
|
// MANAGING alerts, not watching them. A bare `atlas alerts` has meant "open
|
|
@@ -84,8 +49,14 @@ export async function alertsCommand(args) {
|
|
|
84
49
|
// (owner, 2026-09-12: "it's hard to manage spoiled alerts without a
|
|
85
50
|
// something to delete them").
|
|
86
51
|
const verb = String((args._ && args._[0]) || '').toLowerCase();
|
|
87
|
-
|
|
88
|
-
|
|
52
|
+
// ONE NAME PER CAPABILITY. These wrapped a single tool each; the generic path
|
|
53
|
+
// reaches it by name. See commands/moved.js.
|
|
54
|
+
const rest = { _: (args._ || []).slice(1), flags };
|
|
55
|
+
if (verb === 'list' || verb === 'ls') return await moved(`alerts ${verb}`, 'list-alerts', rest);
|
|
56
|
+
if (verb === 'delete' || verb === 'rm' || verb === 'off') return await moved(`alerts ${verb}`, 'delete-alert', rest);
|
|
57
|
+
// KEPT: there is no tool behind these two. `schema` reads a public route, and
|
|
58
|
+
// a bare `atlas alerts` is the live feed — a stream is not a tool call.
|
|
59
|
+
if (verb === 'schema') return await schema();
|
|
89
60
|
if (flags.stop) return background.stop(SLOT);
|
|
90
61
|
if (flags.detach) return background.detach(SLOT);
|
|
91
62
|
const holder = background.holdUntilStopped(SLOT);
|
|
@@ -351,6 +351,43 @@ export function computerUseCommand(args) {
|
|
|
351
351
|
return out;
|
|
352
352
|
}
|
|
353
353
|
|
|
354
|
+
// SEVERAL ACTIONS AND ONE PICTURE, in one process. The slow part was never
|
|
355
|
+
// the clicking: it was a command and a look for every step, when four of them
|
|
356
|
+
// were visible in the picture you already had.
|
|
357
|
+
//
|
|
358
|
+
// atlas computer-use do "click 820 410" "type hello" "key enter"
|
|
359
|
+
//
|
|
360
|
+
// This is the batching every skill tells a model to use, and it was missing
|
|
361
|
+
// here — from the runtime Atlas Studio bundles — so a model in a chat that
|
|
362
|
+
// followed the instructions was told `There is no "do" here` (owner,
|
|
363
|
+
// 2026-09-15: "see if we can unify things instead of diverging").
|
|
364
|
+
if (sub === 'do') {
|
|
365
|
+
if (!rest.length) {
|
|
366
|
+
eprintln(color.red('What should it do? e.g. do "click 820 410" "type hello"'));
|
|
367
|
+
return 1;
|
|
368
|
+
}
|
|
369
|
+
const steps = [];
|
|
370
|
+
for (const raw of rest) {
|
|
371
|
+
const bits = String(raw).split(/\s+/).filter(Boolean);
|
|
372
|
+
if (!bits.length) continue;
|
|
373
|
+
const verb = bits[0].toLowerCase();
|
|
374
|
+
if (!OPS[verb]) {
|
|
375
|
+
eprintln(color.red(`There is no "${verb}" to do.`));
|
|
376
|
+
return 1;
|
|
377
|
+
}
|
|
378
|
+
const [name] = OPS[verb];
|
|
379
|
+
// A quoted phrase is one value: `type "hello there"` means two words
|
|
380
|
+
// typed, not a word typed twice.
|
|
381
|
+
const vals = (verb === 'type' || verb === 'note' || verb === 'find')
|
|
382
|
+
? (bits.length > 1 ? [bits.slice(1).join(' ')] : [])
|
|
383
|
+
: bits.slice(1).map((v) => (NUMERIC.has(verb) ? Number(v) : v));
|
|
384
|
+
steps.push([name, ...vals]);
|
|
385
|
+
}
|
|
386
|
+
const code = 'import atlas_env as e, json\n'
|
|
387
|
+
+ `print(json.dumps(e.do(*[tuple(s) for s in json.loads(${JSON.stringify(JSON.stringify(steps))})]), default=str, indent=2))\n`;
|
|
388
|
+
return runInProject(job, code);
|
|
389
|
+
}
|
|
390
|
+
|
|
354
391
|
if (!OPS[sub]) {
|
|
355
392
|
eprintln(color.red(`There is no "${sub}" here.`));
|
|
356
393
|
println(HELP);
|
package/src/commands/models.js
CHANGED
|
@@ -67,9 +67,13 @@ export async function askCommand(args) {
|
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
69
|
try {
|
|
70
|
+
// THE JOB IT IS RUNNING IN, unless told otherwise: a model asking from its
|
|
71
|
+
// own shell used to land in a folder called "job", in a session unrelated
|
|
72
|
+
// to the project it was working on.
|
|
70
73
|
const said = await askModel(prompt, {
|
|
71
|
-
job: String(flags.job || ''),
|
|
74
|
+
job: String(flags.job || process.env.ATLAS_JOB || ''),
|
|
72
75
|
model,
|
|
76
|
+
peer: String(flags.peer || '').trim(),
|
|
73
77
|
timeoutMs: Number(flags.timeout || 900) * 1000,
|
|
74
78
|
});
|
|
75
79
|
println(typeof said === 'string' ? said : JSON.stringify(said, null, 2));
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// A VERB THAT ONLY WRAPPED A TOOL, kept working while it goes.
|
|
2
|
+
//
|
|
3
|
+
// Owner, 2026-09-15: "scan through all my mcp, cli, api and see if we can unify
|
|
4
|
+
// things instead of diverging ... I have divergence because now I have too much
|
|
5
|
+
// commands."
|
|
6
|
+
//
|
|
7
|
+
// Most `atlas alerts <verb>` and `atlas signals <verb>` subcommands did one
|
|
8
|
+
// thing: call a single tool and print a table. The generic path reaches every
|
|
9
|
+
// tool by name already — `atlas list-alerts`, `atlas signal-take` — against the
|
|
10
|
+
// live catalogue, which is the one road that cannot drift because nobody
|
|
11
|
+
// maintains it. A second spelling is what made the surface feel large, and it
|
|
12
|
+
// was worse than redundant: eight Trade-* and Signal-* tools never got a verb,
|
|
13
|
+
// so an agent that learned the verbs concluded closing a trade was impossible.
|
|
14
|
+
//
|
|
15
|
+
// So the verbs forward, once, with a line saying where they went. They are
|
|
16
|
+
// removed in a later release; this is here so nobody's script breaks on the day
|
|
17
|
+
// they update.
|
|
18
|
+
import { color, eprintln } from '../util/ui.js';
|
|
19
|
+
import { callCommand } from './call.js';
|
|
20
|
+
|
|
21
|
+
// An ES import binding cannot be replaced from outside, so the forward goes
|
|
22
|
+
// through this object: a test can swap `forwardTo.call` and assert WHERE a verb
|
|
23
|
+
// went without a network round trip. The Python twin patches the module
|
|
24
|
+
// attribute for the same reason.
|
|
25
|
+
export const forwardTo = { call: callCommand };
|
|
26
|
+
|
|
27
|
+
export async function moved(verb, slug, args) {
|
|
28
|
+
eprintln(color.dim(
|
|
29
|
+
`atlas ${verb} is now atlas ${slug} — same thing, one name. This forwards for now.`,
|
|
30
|
+
));
|
|
31
|
+
return await forwardTo.call(slug, args);
|
|
32
|
+
}
|
package/src/commands/signals.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import { API } from '../config.js';
|
|
14
14
|
import { requestJson } from '../api/client.js';
|
|
15
15
|
import { color, println, eprintln, prettyJson } from '../util/ui.js';
|
|
16
|
+
import { moved } from './moved.js';
|
|
16
17
|
|
|
17
18
|
const HELP = `${color.bold('atlas signals')} — plays: your groups, the board, and what they have made
|
|
18
19
|
|
|
@@ -140,39 +141,22 @@ export async function signalsCommand(args) {
|
|
|
140
141
|
const groupId = flags.group || flags['group-id'] || flags.id;
|
|
141
142
|
|
|
142
143
|
switch (sub) {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
if (flags.board) body.board = String(flags.board);
|
|
160
|
-
if (flags.group) body.group_id = String(flags.group);
|
|
161
|
-
if (flags.days !== undefined) body.days = num(flags.days);
|
|
162
|
-
if (flags.limit !== undefined) body.limit = num(flags.limit);
|
|
163
|
-
const data = await callTool('Signal-List', body);
|
|
164
|
-
if (failed(data)) return printFailure(data);
|
|
165
|
-
if (json) { println(prettyJson(data)); return 0; }
|
|
166
|
-
const list = rows(data.plays || data.signals || data);
|
|
167
|
-
if (!list.length) { println(color.dim('Nothing on that board in that window.')); return 0; }
|
|
168
|
-
for (const s of list) {
|
|
169
|
-
const p = s.payload || s;
|
|
170
|
-
const what = [p.side, p.symbol, p.strike ? `$${p.strike}` : null, (p.option_type || '').toUpperCase() || null,
|
|
171
|
-
p.expiration_date || p.expiry || null].filter(Boolean).join(' ');
|
|
172
|
-
println(`${color.bold(what || '(play)')} ${color.dim(s.status || '')}`);
|
|
173
|
-
println(color.dim(` ${s.id}${s.posted_at ? ' ' + s.posted_at : ''}`));
|
|
174
|
-
}
|
|
175
|
-
return 0;
|
|
144
|
+
// ONE NAME PER CAPABILITY (owner, 2026-09-15). Each of these wrapped a
|
|
145
|
+
// single tool and printed a table; the generic path reaches every tool by
|
|
146
|
+
// name against the live catalogue, including the eight Signal-*/Trade-*
|
|
147
|
+
// tools that never got a verb here. `post` stays — its --link/--with-*
|
|
148
|
+
// grammar for a second leg is not something a flag schema can express.
|
|
149
|
+
case 'groups': case 'plays': case 'list': case 'show': case 'take':
|
|
150
|
+
case 'performance': case 'perf': case 'delete': case 'rm':
|
|
151
|
+
case 'discover': case 'follow': case 'schema': {
|
|
152
|
+
const MOVED = {
|
|
153
|
+
groups: 'signal-groups', plays: 'signal-list', list: 'signal-list',
|
|
154
|
+
show: 'signal-show', take: 'signal-take',
|
|
155
|
+
performance: 'signal-group-performance', perf: 'signal-group-performance',
|
|
156
|
+
delete: 'signal-group-delete', rm: 'signal-group-delete',
|
|
157
|
+
discover: 'signal-discover', follow: 'signal-follow', schema: 'signal-schema',
|
|
158
|
+
};
|
|
159
|
+
return await moved(`signals ${sub}`, MOVED[sub], { _: (args._ || []).slice(1), flags });
|
|
176
160
|
}
|
|
177
161
|
case 'post': {
|
|
178
162
|
const leg = legFrom(flags, '', null);
|
|
@@ -203,97 +187,6 @@ export async function signalsCommand(args) {
|
|
|
203
187
|
println(data.message || (link ? 'Posted both, linked.' : 'Posted.'));
|
|
204
188
|
return 0;
|
|
205
189
|
}
|
|
206
|
-
case 'show': {
|
|
207
|
-
const id = flags.id || flags.play || flags.signal;
|
|
208
|
-
if (!id) { eprintln(color.red('Which play? --id <play-id>')); return 1; }
|
|
209
|
-
const data = await callTool('Signal-Show', { signal_id: String(id) });
|
|
210
|
-
if (failed(data)) return printFailure(data);
|
|
211
|
-
println(prettyJson(data));
|
|
212
|
-
return 0;
|
|
213
|
-
}
|
|
214
|
-
case 'take': {
|
|
215
|
-
const id = flags.id || flags.play || flags.signal;
|
|
216
|
-
if (!id) { eprintln(color.red('Which play? --id <play-id>')); return 1; }
|
|
217
|
-
const body = { signal_id: String(id) };
|
|
218
|
-
if (flags.contracts !== undefined) body.contracts = num(flags.contracts);
|
|
219
|
-
if (flags.account) body.account_id = String(flags.account);
|
|
220
|
-
const data = await callTool('Signal-Take', body);
|
|
221
|
-
if (failed(data)) return printFailure(data);
|
|
222
|
-
println(json ? prettyJson(data) : (data.message || 'Taken.'));
|
|
223
|
-
return 0;
|
|
224
|
-
}
|
|
225
|
-
case 'performance':
|
|
226
|
-
case 'perf': {
|
|
227
|
-
if (!groupId) { eprintln(color.red('Which group? --group <id> (atlas signals groups)')); return 1; }
|
|
228
|
-
const data = await callTool('Signal-Group-Performance', {
|
|
229
|
-
group_id: String(groupId), mine: !!flags.mine,
|
|
230
|
-
});
|
|
231
|
-
if (failed(data)) return printFailure(data);
|
|
232
|
-
if (json) { println(prettyJson(data)); return 0; }
|
|
233
|
-
const t = data.totals || {};
|
|
234
|
-
const name = data.display_name || data.name || 'this group';
|
|
235
|
-
const whose = data.whose === 'mine' ? 'your own trades' : "the author's record";
|
|
236
|
-
println(`${color.bold(name)} ${color.dim('— ' + whose)}`);
|
|
237
|
-
println(` Closed trades ${(data.rows || []).length}`);
|
|
238
|
-
println(` Win rate ${t.win_rate ?? 0}% ${color.dim(`(${t.win_count ?? 0} up, ${t.loss_count ?? 0} down)`)}`);
|
|
239
|
-
println(` Total ${money(t.total_pnl)} ${color.dim(`${t.total_pnl_pct ?? 0}%`)}`);
|
|
240
|
-
if (data.viewer_has_rows && data.whose !== 'mine') {
|
|
241
|
-
println(color.dim(' You have taken plays here too: add --mine for your own numbers.'));
|
|
242
|
-
}
|
|
243
|
-
return 0;
|
|
244
|
-
}
|
|
245
|
-
case 'delete':
|
|
246
|
-
case 'rm': {
|
|
247
|
-
if (!groupId) { eprintln(color.red('Which group? --group <id> (atlas signals groups)')); return 1; }
|
|
248
|
-
const stop = !!(flags['stop-subscribers'] || flags.stop);
|
|
249
|
-
const data = await callTool('Signal-Group-Delete', {
|
|
250
|
-
group_id: String(groupId), stop_subscribers: stop,
|
|
251
|
-
});
|
|
252
|
-
if (failed(data)) {
|
|
253
|
-
// THE REFUSAL WITH AN ANSWER. People are subscribed: say how many, say
|
|
254
|
-
// what stopping them costs them, and print the line that does it —
|
|
255
|
-
// rather than an error that names a screen this person is not looking at.
|
|
256
|
-
if (data.code === 'has_subscribers') {
|
|
257
|
-
const n = Number(data.subscriber_count) || 1;
|
|
258
|
-
eprintln(color.yellow(`${n} ${n === 1 ? 'person is' : 'people are'} subscribed to this group.`));
|
|
259
|
-
eprintln(color.dim('Deleting stops their billing at once, so there is no next payment, and they'
|
|
260
|
-
+ ' lose the rest of the month they paid for. Anything they already traded is theirs.'));
|
|
261
|
-
eprintln('');
|
|
262
|
-
eprintln(' ' + color.cyan(`atlas signals delete --group ${groupId} --stop-subscribers`));
|
|
263
|
-
return 1;
|
|
264
|
-
}
|
|
265
|
-
return printFailure(data);
|
|
266
|
-
}
|
|
267
|
-
println(json ? prettyJson(data) : (data.message || 'Group deleted.'));
|
|
268
|
-
return 0;
|
|
269
|
-
}
|
|
270
|
-
case 'discover': {
|
|
271
|
-
const data = await callTool('Signal-Discover', {});
|
|
272
|
-
if (failed(data)) return printFailure(data);
|
|
273
|
-
if (json) { println(prettyJson(data)); return 0; }
|
|
274
|
-
const list = data.groups || [];
|
|
275
|
-
if (!list.length) { println(color.dim('No public groups yet.')); return 0; }
|
|
276
|
-
for (const g of list) {
|
|
277
|
-
println(`${color.bold(g.name || '(unnamed)')} ${color.dim([g.owner_label, `${g.follower_count ?? 0} following`].filter(Boolean).join(' · '))}`);
|
|
278
|
-
println(color.dim(` ${g.id}`));
|
|
279
|
-
}
|
|
280
|
-
return 0;
|
|
281
|
-
}
|
|
282
|
-
case 'follow': {
|
|
283
|
-
if (!groupId) { eprintln(color.red('Which group? --group <id>')); return 1; }
|
|
284
|
-
const data = await callTool('Signal-Follow', {
|
|
285
|
-
group_id: String(groupId), follow: flags.off ? false : flags.follow !== false,
|
|
286
|
-
});
|
|
287
|
-
if (failed(data)) return printFailure(data);
|
|
288
|
-
println(json ? prettyJson(data) : (data.message || 'Done.'));
|
|
289
|
-
return 0;
|
|
290
|
-
}
|
|
291
|
-
case 'schema': {
|
|
292
|
-
const data = await callTool('Signal-Schema', {});
|
|
293
|
-
if (failed(data)) return printFailure(data);
|
|
294
|
-
println(typeof data === 'string' ? data : prettyJson(data));
|
|
295
|
-
return 0;
|
|
296
|
-
}
|
|
297
190
|
default:
|
|
298
191
|
eprintln(color.red(`Unknown: atlas signals ${sub}`));
|
|
299
192
|
println(HELP);
|
package/src/helpers/atlas_env.js
CHANGED
|
@@ -83,8 +83,27 @@ export async function atlas(tool, params = {}) {
|
|
|
83
83
|
* words each one takes. A name it does not have is refused BY NAME rather than
|
|
84
84
|
* quietly answered by the default, because a script that asked for a specific
|
|
85
85
|
* model and silently got another one is worse than one that stops.
|
|
86
|
+
*
|
|
87
|
+
* PEER TO PEER. Every model you name keeps ITS OWN conversation in this job,
|
|
88
|
+
* with its own tools, its own shell and its own view of the project, and it can
|
|
89
|
+
* call ask() itself (or `atlas ask --model ...` from its shell) to talk to
|
|
90
|
+
* another one. So two models can work a problem between them, each doing its
|
|
91
|
+
* own things, and you carry the words across:
|
|
92
|
+
*
|
|
93
|
+
* const plan = await ask("Plan the research; list the questions.", { model: "claude-opus-4-8" });
|
|
94
|
+
* const found = await ask(`Answer these with real data:\n${plan}`, { model: "gpt-5.4" });
|
|
95
|
+
* const call = await ask(`GPT found:\n${found}\nRevise the plan or decide.`, { model: "claude-opus-4-8" });
|
|
96
|
+
*
|
|
97
|
+
* Each call continues the conversation that model already had in this job, so
|
|
98
|
+
* the last one remembers the plan it wrote. fresh:true starts a model over.
|
|
99
|
+
*
|
|
100
|
+
* peer:"planner" names a conversation of its own on top of the model, so two
|
|
101
|
+
* ROLES on one model ("planner" and "critic", both gpt-5.4) keep separate
|
|
102
|
+
* memories. A model cannot ask itself: the bridge refuses the conversation you
|
|
103
|
+
* are already in. fanOut([...]) asks several at once (see below). A warm
|
|
104
|
+
* conversation nobody speaks to for six hours is closed and starts afresh.
|
|
86
105
|
*/
|
|
87
|
-
export async function ask(prompt, { tools = true, fresh = false, timeoutMs = 600000, model = "", commands = true } = {}) {
|
|
106
|
+
export async function ask(prompt, { tools = true, fresh = false, timeoutMs = 600000, model = "", commands = true, peer = "" } = {}) {
|
|
88
107
|
const url = process.env.ATLAS_ASK_URL;
|
|
89
108
|
const token = process.env.ATLAS_ASK_TOKEN;
|
|
90
109
|
if (!url || !token) {
|
|
@@ -93,7 +112,10 @@ export async function ask(prompt, { tools = true, fresh = false, timeoutMs = 600
|
|
|
93
112
|
"Connect a model in Atlas Studio, or do this step in code."
|
|
94
113
|
);
|
|
95
114
|
}
|
|
96
|
-
const body = { job: process.env.ATLAS_JOB || "", prompt: String(prompt), tools, fresh, commands: commands !== false
|
|
115
|
+
const body = { job: process.env.ATLAS_JOB || "", prompt: String(prompt), tools, fresh, commands: commands !== false,
|
|
116
|
+
// Who is asking, so the bridge can refuse a model asking itself.
|
|
117
|
+
from: process.env.ATLAS_ASK_PEER || "" };
|
|
118
|
+
if (String(peer || "").trim()) body.peer = String(peer).trim();
|
|
97
119
|
// Sent only when named, so a script that does not care keeps whatever is
|
|
98
120
|
// connected rather than pinning itself to today's default.
|
|
99
121
|
if (String(model || "").trim()) body.model = String(model).trim();
|
|
@@ -120,6 +142,31 @@ export async function ask(prompt, { tools = true, fresh = false, timeoutMs = 600
|
|
|
120
142
|
* const cheap = roster.find((m) => m.ready && m.id.includes("haiku"));
|
|
121
143
|
* await ask("Sector or company?", { model: cheap?.id });
|
|
122
144
|
*/
|
|
145
|
+
/**
|
|
146
|
+
* Ask several models (or peers) AT ONCE and get every answer back, in order.
|
|
147
|
+
*
|
|
148
|
+
* const answers = await fanOut([
|
|
149
|
+
* { prompt: "Bull case for NVDA, 5 lines.", model: "gpt-5.4", peer: "bull" },
|
|
150
|
+
* { prompt: "Bear case for NVDA, 5 lines.", model: "gpt-5.4", peer: "bear" },
|
|
151
|
+
* { prompt: "Read the tape and give a number.", model: "claude-opus-4-8" },
|
|
152
|
+
* ]);
|
|
153
|
+
* for (const a of answers) console.log(a.peer || a.model, "->", a.ok ? a.answer : "failed: " + a.error);
|
|
154
|
+
*
|
|
155
|
+
* Each entry takes the same keys as ask(). Different peers run in parallel
|
|
156
|
+
* (each is its own session); asks of one peer queue behind each other. One
|
|
157
|
+
* failing does not stop the others: every entry comes back as
|
|
158
|
+
* { ok, answer | error, model, peer }.
|
|
159
|
+
*/
|
|
160
|
+
export async function fanOut(asks) {
|
|
161
|
+
const list = Array.isArray(asks) ? asks : [];
|
|
162
|
+
return Promise.all(list.map(async (spec) => {
|
|
163
|
+
const { prompt, ...rest } = spec || {};
|
|
164
|
+
const out = { ok: true, model: rest.model || "", peer: rest.peer || "" };
|
|
165
|
+
try { out.answer = await ask(prompt, rest); } catch (err) { out.ok = false; out.error = String(err && err.message || err); }
|
|
166
|
+
return out;
|
|
167
|
+
}));
|
|
168
|
+
}
|
|
169
|
+
|
|
123
170
|
export async function models() {
|
|
124
171
|
const url = process.env.ATLAS_ASK_URL;
|
|
125
172
|
const token = process.env.ATLAS_ASK_TOKEN;
|
|
@@ -145,8 +192,18 @@ function alertMatches(event, match) {
|
|
|
145
192
|
const want = (Array.isArray(match) ? match : [match])
|
|
146
193
|
.map((w) => String(w ?? "").trim().toLowerCase()).filter(Boolean);
|
|
147
194
|
if (!want.length) return true;
|
|
195
|
+
const ev = event || {};
|
|
196
|
+
// A TRADE (a fill, a stop, a take-profit rung, a close, a draft signal)
|
|
197
|
+
// answers only to "trade", "trade:SPY" or its trigger id: "*" and a bare
|
|
198
|
+
// symbol keep meaning "my alerts", as they always have.
|
|
199
|
+
if (String(ev.name ?? "").trim().toLowerCase() === "trade") {
|
|
200
|
+
const sym = String(ev.symbol ?? "").trim().toLowerCase();
|
|
201
|
+
const tid = String(ev.id ?? "").trim().toLowerCase();
|
|
202
|
+
const wanted = new Set(["trade", sym ? `trade:${sym}` : "", tid].filter(Boolean));
|
|
203
|
+
return want.some((w) => wanted.has(w));
|
|
204
|
+
}
|
|
148
205
|
const facets = ["label", "id", "symbol", "name", "alert_type"]
|
|
149
|
-
.map((k) => String(
|
|
206
|
+
.map((k) => String(ev[k] ?? "").trim().toLowerCase()).filter(Boolean);
|
|
150
207
|
return want.some((w) => w === "*" || facets.includes(w));
|
|
151
208
|
}
|
|
152
209
|
|
|
@@ -414,6 +471,11 @@ export const computer = {
|
|
|
414
471
|
ready: () => useComputer("computer_ready"),
|
|
415
472
|
screen: () => useComputer("screen"),
|
|
416
473
|
region: (x, y, w, h) => useComputer("region", [x, y, w, h]),
|
|
474
|
+
/** A close-up around one spot, x3, with a ruler every 20 desktop pixels and a
|
|
475
|
+
* crosshair on the spot: read the click off THIS before a small target.
|
|
476
|
+
* const look = await computer.lookAt(850, 450); // -> { path, grid: 20, ... }
|
|
477
|
+
* await computer.click(858, 446); */
|
|
478
|
+
lookAt: (x, y, opts = {}) => useComputer("look_at", [x, y], opts),
|
|
417
479
|
screens: () => useComputer("screens"),
|
|
418
480
|
useScreen: (n) => useComputer("use_screen", [n]),
|
|
419
481
|
/**
|
package/src/helpers/atlas_env.py
CHANGED
|
@@ -54,7 +54,7 @@ def atlas(tool, **params):
|
|
|
54
54
|
raise RuntimeError("Atlas %s failed (%d): %s" % (tool, e.code, body))
|
|
55
55
|
|
|
56
56
|
|
|
57
|
-
def ask(prompt, tools=True, fresh=False, timeout=600, model="", commands=True):
|
|
57
|
+
def ask(prompt, tools=True, fresh=False, timeout=600, model="", commands=True, peer=""):
|
|
58
58
|
"""Ask YOUR OWN model, the way you would in the chat, and get its answer back.
|
|
59
59
|
|
|
60
60
|
IT IS A CHAT SESSION, NOT A TEXT BOX (owner, 2026-09-11). The model runs on
|
|
@@ -99,6 +99,31 @@ def ask(prompt, tools=True, fresh=False, timeout=600, model="", commands=True):
|
|
|
99
99
|
does not have is refused by name rather than quietly answered by the
|
|
100
100
|
default, because a script that asked for a specific model and silently got
|
|
101
101
|
another one is worse than one that stops.
|
|
102
|
+
|
|
103
|
+
PEER TO PEER. Every model you name keeps ITS OWN conversation in this job,
|
|
104
|
+
with its own tools, its own shell and its own view of the project, and it
|
|
105
|
+
can call ask() itself (or `atlas ask --model ...` from its shell) to talk
|
|
106
|
+
to another one. So two models can work a problem between them, each doing
|
|
107
|
+
its own things, and you carry the words across:
|
|
108
|
+
|
|
109
|
+
plan = ask("Plan the research; list the questions.", model="claude-opus-4-8")
|
|
110
|
+
found = ask("Answer these with real data:\n" + plan, model="gpt-5.4")
|
|
111
|
+
call = ask("GPT found:\n" + found + "\nRevise the plan or decide.", model="claude-opus-4-8")
|
|
112
|
+
|
|
113
|
+
Each of those three continues the conversation that model already had in
|
|
114
|
+
this job, so the last call remembers the plan it wrote. fresh=True starts a
|
|
115
|
+
model over. A model that should answer only from what you gave it takes
|
|
116
|
+
commands=False and does no work of its own.
|
|
117
|
+
|
|
118
|
+
peer="planner" names a conversation of its own on top of the model, so two
|
|
119
|
+
ROLES on one model ("planner" and "critic", both gpt-5.4) keep separate
|
|
120
|
+
memories. A model cannot ask itself: the bridge refuses the conversation
|
|
121
|
+
you are already in and says to name another model or peer.
|
|
122
|
+
|
|
123
|
+
FAN OUT with fan_out([...]) to ask several at once: each peer is its own
|
|
124
|
+
session, so they run in parallel; two asks of the SAME peer queue, one
|
|
125
|
+
after the other, and never mix their answers. A warm conversation that
|
|
126
|
+
nobody speaks to for six hours is closed; the next ask starts it afresh.
|
|
102
127
|
"""
|
|
103
128
|
import json as _json
|
|
104
129
|
import urllib.error
|
|
@@ -112,7 +137,11 @@ def ask(prompt, tools=True, fresh=False, timeout=600, model="", commands=True):
|
|
|
112
137
|
"Connect a model in Atlas Studio, or do this step in code."
|
|
113
138
|
)
|
|
114
139
|
body = {"job": os.environ.get("ATLAS_JOB", ""), "prompt": str(prompt),
|
|
115
|
-
"tools": bool(tools), "fresh": bool(fresh), "commands": bool(commands)
|
|
140
|
+
"tools": bool(tools), "fresh": bool(fresh), "commands": bool(commands),
|
|
141
|
+
# Who is asking, so the bridge can refuse a model asking itself.
|
|
142
|
+
"from": os.environ.get("ATLAS_ASK_PEER", "")}
|
|
143
|
+
if str(peer or "").strip():
|
|
144
|
+
body["peer"] = str(peer).strip()
|
|
116
145
|
# Sent only when named, so a script that does not care keeps whatever is
|
|
117
146
|
# connected rather than pinning itself to today's default.
|
|
118
147
|
if str(model or "").strip():
|
|
@@ -137,6 +166,42 @@ def ask(prompt, tools=True, fresh=False, timeout=600, model="", commands=True):
|
|
|
137
166
|
return out.get("answer") or ""
|
|
138
167
|
|
|
139
168
|
|
|
169
|
+
def fan_out(asks, workers=4):
|
|
170
|
+
"""Ask several models (or peers) AT ONCE and get every answer back, in order.
|
|
171
|
+
|
|
172
|
+
answers = fan_out([
|
|
173
|
+
{"prompt": "Bull case for NVDA, 5 lines.", "model": "gpt-5.4", "peer": "bull"},
|
|
174
|
+
{"prompt": "Bear case for NVDA, 5 lines.", "model": "gpt-5.4", "peer": "bear"},
|
|
175
|
+
{"prompt": "Read the tape and give a number.", "model": "claude-opus-4-8"},
|
|
176
|
+
])
|
|
177
|
+
for a in answers:
|
|
178
|
+
print(a["peer"] or a["model"], "->", a["answer"] if a["ok"] else "failed: " + a["error"])
|
|
179
|
+
|
|
180
|
+
Each entry takes the same keys as ask(). Different peers run in parallel
|
|
181
|
+
(each is its own session); asks of one peer queue behind each other. One
|
|
182
|
+
failing does not stop the others: every entry comes back as
|
|
183
|
+
{"ok", "answer" or "error", "model", "peer"} so you can decide what to do
|
|
184
|
+
with a missing voice. `workers` caps how many run at once.
|
|
185
|
+
"""
|
|
186
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
187
|
+
asks = list(asks or [])
|
|
188
|
+
|
|
189
|
+
def one(spec):
|
|
190
|
+
spec = dict(spec or {})
|
|
191
|
+
prompt = spec.pop("prompt", "")
|
|
192
|
+
out = {"ok": True, "model": spec.get("model", ""), "peer": spec.get("peer", "")}
|
|
193
|
+
try:
|
|
194
|
+
out["answer"] = ask(prompt, **spec)
|
|
195
|
+
except Exception as err:
|
|
196
|
+
out.update(ok=False, error=str(err))
|
|
197
|
+
return out
|
|
198
|
+
|
|
199
|
+
if not asks:
|
|
200
|
+
return []
|
|
201
|
+
with ThreadPoolExecutor(max_workers=max(1, min(int(workers), len(asks)))) as pool:
|
|
202
|
+
return list(pool.map(one, asks))
|
|
203
|
+
|
|
204
|
+
|
|
140
205
|
def models():
|
|
141
206
|
"""Every model this computer can reach, newest answer from the app itself.
|
|
142
207
|
|
|
@@ -178,7 +243,16 @@ def _alert_matches(event, match):
|
|
|
178
243
|
want = [str(w or "").strip().lower() for w in want if str(w or "").strip()]
|
|
179
244
|
if not want:
|
|
180
245
|
return True
|
|
181
|
-
|
|
246
|
+
ev = event or {}
|
|
247
|
+
# A TRADE (a fill, a stop, a take-profit rung, a close, a draft signal)
|
|
248
|
+
# answers only to "trade", "trade:SPY" or its trigger id: "*" and a bare
|
|
249
|
+
# symbol keep meaning "my alerts", as they always have.
|
|
250
|
+
if str(ev.get("name") or "").strip().lower() == "trade":
|
|
251
|
+
sym = str(ev.get("symbol") or "").strip().lower()
|
|
252
|
+
tid = str(ev.get("id") or "").strip().lower()
|
|
253
|
+
wanted = {"trade", "trade:%s" % sym if sym else "", tid} - {""}
|
|
254
|
+
return any(w in wanted for w in want)
|
|
255
|
+
facets = [str(ev.get(k) or "").strip().lower()
|
|
182
256
|
for k in ("label", "id", "symbol", "name", "alert_type")]
|
|
183
257
|
facets = [f for f in facets if f]
|
|
184
258
|
return any(w == "*" or w in facets for w in want)
|
|
@@ -1226,9 +1300,12 @@ def _grab(m):
|
|
|
1226
1300
|
except ImportError:
|
|
1227
1301
|
pass
|
|
1228
1302
|
# WITHOUT mss, STILL THE RIGHT SCREEN. pyautogui grabs a region of the
|
|
1229
|
-
# virtual desktop, which is all this ever needed; mss is only faster.
|
|
1303
|
+
# virtual desktop, which is all this ever needed; mss is only faster. It
|
|
1304
|
+
# is asked in POINTER steps (a Retina Mac's real pixels would put the
|
|
1305
|
+
# region twice as far out and twice as large).
|
|
1306
|
+
pb = _pointer_box(m)
|
|
1230
1307
|
try:
|
|
1231
|
-
img = _gui().screenshot(region=(
|
|
1308
|
+
img = _gui().screenshot(region=(int(pb["x"]), int(pb["y"]), int(pb["width"]), int(pb["height"])))
|
|
1232
1309
|
except Exception as err:
|
|
1233
1310
|
# Some platforms cannot reach a monitor left of the origin, where x is
|
|
1234
1311
|
# negative. Say so rather than handing back the primary and letting
|
|
@@ -1249,7 +1326,7 @@ def _grid_step(w, h):
|
|
|
1249
1326
|
return 100 if big <= 1400 else (200 if big <= 3000 else 400)
|
|
1250
1327
|
|
|
1251
1328
|
|
|
1252
|
-
def _draw_grid(img, ox, oy, unit=1.0):
|
|
1329
|
+
def _draw_grid(img, ox, oy, unit=1.0, step=None):
|
|
1253
1330
|
"""A ruler, in the numbers you would actually click.
|
|
1254
1331
|
|
|
1255
1332
|
Reading a position off a picture by eye is the one thing a model is poor at,
|
|
@@ -1273,7 +1350,7 @@ def _draw_grid(img, ox, oy, unit=1.0):
|
|
|
1273
1350
|
# numbers and then converted, never the other way round: the label has to be
|
|
1274
1351
|
# a number that can be clicked.
|
|
1275
1352
|
unit = float(unit) or 1.0
|
|
1276
|
-
step = _grid_step(int(w / unit), int(h / unit))
|
|
1353
|
+
step = int(step) if step else _grid_step(int(w / unit), int(h / unit))
|
|
1277
1354
|
try:
|
|
1278
1355
|
d = ImageDraw.Draw(img)
|
|
1279
1356
|
except Exception:
|
|
@@ -1679,11 +1756,15 @@ def serve(idle_s=SERVE_IDLE_S):
|
|
|
1679
1756
|
pass
|
|
1680
1757
|
|
|
1681
1758
|
last = [_time.time()]
|
|
1759
|
+
busy = [False]
|
|
1682
1760
|
|
|
1683
1761
|
def reap():
|
|
1684
1762
|
while True:
|
|
1685
1763
|
_time.sleep(5)
|
|
1686
|
-
|
|
1764
|
+
# Idle means NOTHING IN FLIGHT and nothing for a while: a wait()
|
|
1765
|
+
# or a slow page past the quarter hour used to take the process
|
|
1766
|
+
# down mid-command, with the caller left holding a dead socket.
|
|
1767
|
+
if not busy[0] and _time.time() - last[0] > idle_s:
|
|
1687
1768
|
try:
|
|
1688
1769
|
os.remove(_serve_note())
|
|
1689
1770
|
except OSError:
|
|
@@ -1722,6 +1803,7 @@ def serve(idle_s=SERVE_IDLE_S):
|
|
|
1722
1803
|
if not callable(fn):
|
|
1723
1804
|
reply = {"ok": False, "error": 'no "%s" here' % req.get("op")}
|
|
1724
1805
|
else:
|
|
1806
|
+
busy[0] = True
|
|
1725
1807
|
try:
|
|
1726
1808
|
out = fn(*(req.get("args") or []), **(req.get("kwargs") or {}))
|
|
1727
1809
|
reply = {"ok": True, "out": out}
|
|
@@ -1730,6 +1812,9 @@ def serve(idle_s=SERVE_IDLE_S):
|
|
|
1730
1812
|
# somebody, and every refusal here is written to say
|
|
1731
1813
|
# what to do next.
|
|
1732
1814
|
reply = {"ok": False, "error": str(err)}
|
|
1815
|
+
finally:
|
|
1816
|
+
busy[0] = False
|
|
1817
|
+
last[0] = _time.time()
|
|
1733
1818
|
conn.sendall((_json.dumps(reply, default=str) + "\n").encode("utf-8"))
|
|
1734
1819
|
except Exception:
|
|
1735
1820
|
# One bad connection is not a reason to stop serving the rest.
|
|
@@ -1964,6 +2049,29 @@ def do(*steps, **kw):
|
|
|
1964
2049
|
return out
|
|
1965
2050
|
|
|
1966
2051
|
|
|
2052
|
+
def _arrive(g, x, y):
|
|
2053
|
+
"""Put the pointer on the spot and let the app see it there before a press.
|
|
2054
|
+
|
|
2055
|
+
A pointer that appears on a button and presses in the same instant is a
|
|
2056
|
+
press some apps never see: Chromium and Electron surfaces want the hover
|
|
2057
|
+
before the button, a menu item highlights on arrival, and a drag that
|
|
2058
|
+
starts before the source is hovered picks up nothing (owner, 2026-09-14:
|
|
2059
|
+
"the clicking is sloppy"). pyautogui already waits its own 20ms after the
|
|
2060
|
+
move; ATLAS_COMPUTER_HOVER_MS (default 60, 0 for none, at most 500) is the
|
|
2061
|
+
only thing added on top, and it is the same for every kind of press.
|
|
2062
|
+
"""
|
|
2063
|
+
mv = getattr(g, "moveTo", None)
|
|
2064
|
+
if not callable(mv):
|
|
2065
|
+
return
|
|
2066
|
+
mv(x, y)
|
|
2067
|
+
try:
|
|
2068
|
+
pause = float(os.environ.get("ATLAS_COMPUTER_HOVER_MS", "60")) / 1000.0
|
|
2069
|
+
except ValueError:
|
|
2070
|
+
pause = 0.06
|
|
2071
|
+
if pause > 0:
|
|
2072
|
+
__import__("time").sleep(min(pause, 0.5))
|
|
2073
|
+
|
|
2074
|
+
|
|
1967
2075
|
def click(x, y, button="left", clicks=1, what=""):
|
|
1968
2076
|
"""Press something. SAY WHAT IT IS if you know.
|
|
1969
2077
|
|
|
@@ -1976,21 +2084,35 @@ def click(x, y, button="left", clicks=1, what=""):
|
|
|
1976
2084
|
screen or out of find(); the log is the only place that knowledge can
|
|
1977
2085
|
survive the click.
|
|
1978
2086
|
"""
|
|
1979
|
-
|
|
2087
|
+
def go():
|
|
2088
|
+
g = _gui()
|
|
2089
|
+
# ARRIVE, THEN PRESS (see _arrive). The press itself names no point:
|
|
2090
|
+
# the pointer is already there, and naming it again is a second move.
|
|
2091
|
+
_arrive(g, x, y)
|
|
2092
|
+
g.click(button=button, clicks=clicks)
|
|
2093
|
+
return _do("click", go,
|
|
1980
2094
|
{"x": x, "y": y, "button": button, "clicks": clicks,
|
|
1981
2095
|
"what": str(what)[:80], "screen": _SESSION_SCREEN["index"]},
|
|
1982
2096
|
points=[(x, y)])
|
|
1983
2097
|
|
|
1984
2098
|
|
|
1985
2099
|
def double_click(x, y, what=""):
|
|
1986
|
-
|
|
2100
|
+
def go():
|
|
2101
|
+
g = _gui()
|
|
2102
|
+
_arrive(g, x, y)
|
|
2103
|
+
g.doubleClick()
|
|
2104
|
+
return _do("double_click", go,
|
|
1987
2105
|
{"x": x, "y": y, "what": str(what)[:80],
|
|
1988
2106
|
"screen": _SESSION_SCREEN["index"]},
|
|
1989
2107
|
points=[(x, y)])
|
|
1990
2108
|
|
|
1991
2109
|
|
|
1992
2110
|
def right_click(x, y, what=""):
|
|
1993
|
-
|
|
2111
|
+
def go():
|
|
2112
|
+
g = _gui()
|
|
2113
|
+
_arrive(g, x, y)
|
|
2114
|
+
g.rightClick()
|
|
2115
|
+
return _do("right_click", go,
|
|
1994
2116
|
{"x": x, "y": y, "what": str(what)[:80],
|
|
1995
2117
|
"screen": _SESSION_SCREEN["index"]},
|
|
1996
2118
|
points=[(x, y)])
|
|
@@ -2003,7 +2125,8 @@ def move(x, y):
|
|
|
2003
2125
|
def drag(from_x, from_y, to_x, to_y, duration=0.3):
|
|
2004
2126
|
def go():
|
|
2005
2127
|
g = _gui()
|
|
2006
|
-
|
|
2128
|
+
# Hovered before the button goes down, or the drag picks up nothing.
|
|
2129
|
+
_arrive(g, from_x, from_y)
|
|
2007
2130
|
g.dragTo(to_x, to_y, duration=duration, button="left")
|
|
2008
2131
|
# BOTH ends: a drag that starts here and finishes on the other monitor is
|
|
2009
2132
|
# the same mistake, made slowly.
|
|
@@ -3364,12 +3487,109 @@ def region(x, y, width, height, path=None):
|
|
|
3364
3487
|
"""
|
|
3365
3488
|
def take():
|
|
3366
3489
|
shot = _gui().screenshot(region=(int(x), int(y), int(width), int(height)))
|
|
3490
|
+
# POINTER SPACE, like screen() and look_at(). A Retina Mac hands back
|
|
3491
|
+
# twice the pixels asked for; left at that size, a position read off
|
|
3492
|
+
# the patch was half of where it should be.
|
|
3493
|
+
sc = _capture_scale()
|
|
3494
|
+
if sc > 1.01:
|
|
3495
|
+
try:
|
|
3496
|
+
shot = shot.resize((max(1, int(round(shot.width / sc))), max(1, int(round(shot.height / sc)))))
|
|
3497
|
+
except Exception:
|
|
3498
|
+
pass
|
|
3367
3499
|
out = path or os.path.join(_session_dir(), "region-%d.png" % int(__import__("time").time() * 1000))
|
|
3368
3500
|
shot.save(out)
|
|
3369
3501
|
return out
|
|
3370
3502
|
return _do("region", take, {"x": x, "y": y, "width": width, "height": height})
|
|
3371
3503
|
|
|
3372
3504
|
|
|
3505
|
+
def look_at(x, y, radius=100, zoom=3, path=None):
|
|
3506
|
+
"""A CLOSE LOOK at one spot before pressing it, with the same ruler.
|
|
3507
|
+
|
|
3508
|
+
The ruler on a whole-screen picture is 100 or 200 desktop pixels apart, and
|
|
3509
|
+
a position judged by eye between two labels is off by ten or twenty: enough
|
|
3510
|
+
to miss a menu item, a checkbox or a small icon (owner, 2026-09-14: "the
|
|
3511
|
+
clicking is sloppy"). This photographs the patch around your guess at
|
|
3512
|
+
`zoom` times its size, with a ruler every 20 desktop pixels and a gold
|
|
3513
|
+
crosshair on the guess itself, so the second reading is a number you count
|
|
3514
|
+
to rather than one you estimate.
|
|
3515
|
+
|
|
3516
|
+
look_at(850, 450) # 200x200 around the guess, x3
|
|
3517
|
+
click(858, 446, what="Sign in") # what the close-up says
|
|
3518
|
+
|
|
3519
|
+
Same numbers in, same numbers on the ruler, as screen() and click(): desktop
|
|
3520
|
+
coordinates, nothing to add. The crosshair is where you SAID. The pointer is
|
|
3521
|
+
drawn too (a cyan ring) when it is inside the patch, so move(x, y) then
|
|
3522
|
+
look_at(x, y) shows exactly where a click would land before it does.
|
|
3523
|
+
|
|
3524
|
+
Returns {"path", "x", "y", "left", "top", "width", "height", "zoom",
|
|
3525
|
+
"grid": 20, "note"}. `radius` is in desktop pixels; the patch is clipped to
|
|
3526
|
+
the screen, so a corner gives a smaller picture rather than a refusal.
|
|
3527
|
+
"""
|
|
3528
|
+
x, y = int(x), int(y)
|
|
3529
|
+
# A patch no bigger than a screen's quarter at no more than 5x: the ruler
|
|
3530
|
+
# needs exact picture pixels per desktop step, so the size cap that keeps
|
|
3531
|
+
# a whole-screen picture small cannot apply here, and this is the limit
|
|
3532
|
+
# instead.
|
|
3533
|
+
radius = max(20, min(int(radius), 400))
|
|
3534
|
+
zoom = max(1, min(int(zoom), 5))
|
|
3535
|
+
geo = {}
|
|
3536
|
+
|
|
3537
|
+
def take():
|
|
3538
|
+
from PIL import Image as _Im, ImageDraw as _Dr
|
|
3539
|
+
# AFTER the policy checks in _do (the switch, the claim, the strip, the
|
|
3540
|
+
# screen), so a refusal is the right refusal and is logged like any
|
|
3541
|
+
# other action.
|
|
3542
|
+
m = _current_screen()
|
|
3543
|
+
box = _pointer_box(m)
|
|
3544
|
+
left = max(int(box["x"]), x - radius)
|
|
3545
|
+
top = max(int(box["y"]), y - radius)
|
|
3546
|
+
right = min(int(box["x"]) + int(box["width"]), x + radius)
|
|
3547
|
+
bottom = min(int(box["y"]) + int(box["height"]), y + radius)
|
|
3548
|
+
w, h = right - left, bottom - top
|
|
3549
|
+
if w < 1 or h < 1:
|
|
3550
|
+
raise RuntimeError("this computer did not report its screens, so there is nothing to look at")
|
|
3551
|
+
geo.update({"left": left, "top": top, "width": w, "height": h})
|
|
3552
|
+
hide_consoles()
|
|
3553
|
+
img, _raw = _grab(m)
|
|
3554
|
+
sc = _capture_scale()
|
|
3555
|
+
# The grab is in the display's real pixels; the patch was measured in
|
|
3556
|
+
# pointer steps, and so is `box` (a Retina second monitor starts at
|
|
3557
|
+
# 1440 in pointer steps and 2880 in real pixels). Crop in real pixels
|
|
3558
|
+
# from the POINTER origin, then bring it to `zoom` picture pixels per
|
|
3559
|
+
# pointer step, so the ruler's unit is exact.
|
|
3560
|
+
crop = img.crop((int(round((left - box["x"]) * sc)), int(round((top - box["y"]) * sc)),
|
|
3561
|
+
int(round((right - box["x"]) * sc)), int(round((bottom - box["y"]) * sc))))
|
|
3562
|
+
img = crop.resize((w * zoom, h * zoom), _Im.LANCZOS)
|
|
3563
|
+
img = _draw_grid(img, left, top, unit=float(zoom), step=20)
|
|
3564
|
+
d = _Dr.Draw(img)
|
|
3565
|
+
cx, cy = (x - left) * zoom, (y - top) * zoom
|
|
3566
|
+
gold = (255, 215, 0)
|
|
3567
|
+
gap = 4 * zoom
|
|
3568
|
+
d.line([(cx, 0), (cx, cy - gap)], fill=gold, width=1)
|
|
3569
|
+
d.line([(cx, cy + gap), (cx, img.height)], fill=gold, width=1)
|
|
3570
|
+
d.line([(0, cy), (cx - gap, cy)], fill=gold, width=1)
|
|
3571
|
+
d.line([(cx + gap, cy), (img.width, cy)], fill=gold, width=1)
|
|
3572
|
+
try:
|
|
3573
|
+
px, py = get_cursor()
|
|
3574
|
+
if left <= int(px) < right and top <= int(py) < bottom:
|
|
3575
|
+
qx, qy = (int(px) - left) * zoom, (int(py) - top) * zoom
|
|
3576
|
+
r = 3 * zoom
|
|
3577
|
+
d.ellipse([qx - r, qy - r, qx + r, qy + r], outline=(0, 230, 255), width=2)
|
|
3578
|
+
except Exception:
|
|
3579
|
+
pass
|
|
3580
|
+
out = path or os.path.join(_session_dir(), "look-%d.png" % int(__import__("time").time() * 1000))
|
|
3581
|
+
_save_shot(img, out)
|
|
3582
|
+
return out
|
|
3583
|
+
|
|
3584
|
+
out = _do("look_at", take, {"x": x, "y": y, "radius": radius, "zoom": zoom}, points=[(x, y)])
|
|
3585
|
+
return {"path": out, "x": x, "y": y, "left": geo.get("left"), "top": geo.get("top"),
|
|
3586
|
+
"width": geo.get("width"), "height": geo.get("height"),
|
|
3587
|
+
"zoom": zoom, "grid": 20,
|
|
3588
|
+
"note": ("a close-up around %d, %d at %dx: the ruler is labelled in DESKTOP "
|
|
3589
|
+
"coordinates every 20 pixels - click what it says; the gold crosshair "
|
|
3590
|
+
"is the point you asked about") % (x, y, zoom)}
|
|
3591
|
+
|
|
3592
|
+
|
|
3373
3593
|
def find(image, confidence=0.9):
|
|
3374
3594
|
"""Where something LOOKS like `image` on screen, as (x, y) of its middle.
|
|
3375
3595
|
|
package/src/remote-control.js
CHANGED
|
@@ -525,6 +525,16 @@ export function readAlertTriggers(dir) {
|
|
|
525
525
|
/** Does a fired alert (the /api/v1/alerts event) match a project's on_alert list? */
|
|
526
526
|
export function alertMatches(triggers, event) {
|
|
527
527
|
if (!triggers || !triggers.length || !event || typeof event !== 'object') return false;
|
|
528
|
+
// A TRADE on the feed (a fill, a stop, a take-profit rung, a close, a draft
|
|
529
|
+
// signal) is reached only by asking for it: "trade", "trade:SPY", or the
|
|
530
|
+
// trigger's id. Never by "*" or a bare symbol, which every project listening
|
|
531
|
+
// before trades joined the feed already uses to mean "my alerts" (2026-09-14).
|
|
532
|
+
if (String(event.name || '').trim().toLowerCase() === 'trade') {
|
|
533
|
+
const sym = String(event.symbol || '').trim().toLowerCase();
|
|
534
|
+
const tid = String(event.id || '').trim().toLowerCase();
|
|
535
|
+
const wanted = new Set(['trade', sym ? `trade:${sym}` : '', tid].filter(Boolean));
|
|
536
|
+
return triggers.some((t) => wanted.has(t));
|
|
537
|
+
}
|
|
528
538
|
const facets = [event.label, event.id, event.symbol, event.name, event.alert_type]
|
|
529
539
|
.map((v) => String(v || '').trim().toLowerCase()).filter(Boolean);
|
|
530
540
|
return triggers.some((t) => t === '*' || facets.includes(t));
|
|
@@ -1395,7 +1405,7 @@ export async function askModel(
|
|
|
1395
1405
|
// named model in one runtime and quietly used the default in the other. That
|
|
1396
1406
|
// is the shape of bug where an orchestrator believes it asked three models and
|
|
1397
1407
|
// asked one three times.
|
|
1398
|
-
{ job = '', tools = true, fresh = false, timeoutMs = 900000, model = '' } = {},
|
|
1408
|
+
{ job = '', tools = true, fresh = false, timeoutMs = 900000, model = '', peer = '' } = {},
|
|
1399
1409
|
) {
|
|
1400
1410
|
const url = process.env.ATLAS_ASK_URL;
|
|
1401
1411
|
const token = process.env.ATLAS_ASK_TOKEN;
|
|
@@ -1408,7 +1418,12 @@ export async function askModel(
|
|
|
1408
1418
|
const res = await fetch(url, {
|
|
1409
1419
|
method: 'POST',
|
|
1410
1420
|
headers: { 'Content-Type': 'application/json', 'X-Atlas-Token': token },
|
|
1411
|
-
body: JSON.stringify({
|
|
1421
|
+
body: JSON.stringify({
|
|
1422
|
+
job: job || process.env.ATLAS_JOB || '', prompt: String(prompt), tools, fresh,
|
|
1423
|
+
...(model ? { model } : {}), ...(peer ? { peer } : {}),
|
|
1424
|
+
// Who is asking, so the bridge can refuse a model asking itself.
|
|
1425
|
+
from: process.env.ATLAS_ASK_PEER || '',
|
|
1426
|
+
}),
|
|
1412
1427
|
signal: AbortSignal.timeout(timeoutMs),
|
|
1413
1428
|
});
|
|
1414
1429
|
const out = await res.json().catch(() => ({}));
|
package/src/util/help.js
CHANGED
|
@@ -28,16 +28,12 @@ export function printRootHelp() {
|
|
|
28
28
|
println(' [--no-greeks] [--json]');
|
|
29
29
|
println();
|
|
30
30
|
println(c.bold('PLAYS (SIGNALS)'));
|
|
31
|
-
println(' signals
|
|
32
|
-
println('
|
|
33
|
-
println(' signals performance --group <id> [--mine] A group\'s closed trades and win rate');
|
|
34
|
-
println(' signals delete --group <id> [--stop-subscribers] Delete a group (stops subscriptions first)');
|
|
35
|
-
println(' signals take --id <play> [--contracts N] Place a play in your account');
|
|
36
|
-
println(' signals <discover|follow|show|schema> ' + c.dim('atlas signals help for all of it'));
|
|
31
|
+
println(' signals post --symbol SPY ... Post a play (--link oco|oeo for a second leg)');
|
|
32
|
+
println(' signal-groups · signal-list · signal-take ' + c.dim('every Signal-* and Trade-* tool, by name'));
|
|
37
33
|
println();
|
|
38
34
|
println(c.bold('STREAMS'));
|
|
39
|
-
println(' alerts
|
|
40
|
-
println(' alerts delete --
|
|
35
|
+
println(' alerts schema The shape of every feed event (JSON Schema)');
|
|
36
|
+
println(' list-alerts · delete-alert --alert-ids <id> ' + c.dim('your alerts, and clearing spoiled ones'));
|
|
41
37
|
println(' alerts [--symbol SPY] [--name greek_exposure] Live alert feed (SSE)');
|
|
42
38
|
println(' [--save-charts <dir>] [--no-chart] [--json]');
|
|
43
39
|
println(' [--detach] [--stop] Stream in the background, or stop it');
|
|
@@ -63,14 +59,12 @@ export function printRootHelp() {
|
|
|
63
59
|
println(c.dim(' atlas options-chain --symbol SPY --expiration 2026-06-20'));
|
|
64
60
|
println(c.dim(' atlas historical-contract --symbol NVDA --expiry 2026-07-13 --strike 205 --side call --from 2026-07-06 --to 2026-07-10'));
|
|
65
61
|
println(c.dim(' atlas update-alert --alert-id <id> --updates.is-active true # nested via a dotted flag'));
|
|
66
|
-
println(c.dim(' atlas create-task --name "Close SPY 3:55" --instruction "Close my SPY position" --schedule-times 15:55'));
|
|
67
|
-
println(c.dim(' atlas run-task --task-id <id>'));
|
|
68
62
|
println(c.dim(' atlas workflow-create --name "GEX signal" --schedule "30 9 * * 1-5" --tools Stock-Quote --instruction "…"'));
|
|
69
63
|
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'));
|
|
70
64
|
println(c.dim(' atlas alerts --symbol SPY --name greek_exposure --save-charts ./charts'));
|
|
71
65
|
println(c.dim(' atlas flow stream --symbol NVDA'));
|
|
72
|
-
println(c.dim(' atlas
|
|
73
|
-
println(c.dim(' atlas
|
|
66
|
+
println(c.dim(' atlas signal-group-performance --group-id <id>'));
|
|
67
|
+
println(c.dim(' atlas signal-group-delete --group-id <id> --stop-subscribers'));
|
|
74
68
|
}
|
|
75
69
|
|
|
76
70
|
// Parse the `Args:` block of a tool docstring into { paramName: description }.
|