mindvest-atlas 0.68.0 → 0.68.2
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/alerts.js +9 -0
- package/src/commands/models.js +5 -1
- package/src/commands/signals.js +101 -0
- package/src/config.js +1 -1
- 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 +1 -0
package/package.json
CHANGED
package/src/commands/alerts.js
CHANGED
|
@@ -48,6 +48,14 @@ async function listAlerts(flags) {
|
|
|
48
48
|
return 0;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/** The shape of every event on the feed, as JSON Schema, so a script or a
|
|
52
|
+
* model can read it once and rely on it. Alerts and trades share it. */
|
|
53
|
+
async function schema() {
|
|
54
|
+
const data = await requestJson(`${API.alerts}/schema`);
|
|
55
|
+
println(prettyJson(data));
|
|
56
|
+
return 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
51
59
|
async function deleteAlerts(args) {
|
|
52
60
|
const flags = args.flags;
|
|
53
61
|
// --id may repeat; extra positionals after the verb are ids too, so
|
|
@@ -86,6 +94,7 @@ export async function alertsCommand(args) {
|
|
|
86
94
|
const verb = String((args._ && args._[0]) || '').toLowerCase();
|
|
87
95
|
if (verb === 'list' || verb === 'ls') return await listAlerts(flags);
|
|
88
96
|
if (verb === 'delete' || verb === 'rm' || verb === 'off') return await deleteAlerts(args);
|
|
97
|
+
if (verb === 'schema') return await schema();
|
|
89
98
|
if (flags.stop) return background.stop(SLOT);
|
|
90
99
|
if (flags.detach) return background.detach(SLOT);
|
|
91
100
|
const holder = background.holdUntilStopped(SLOT);
|
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));
|
package/src/commands/signals.js
CHANGED
|
@@ -22,6 +22,7 @@ ${color.bold('USAGE')}
|
|
|
22
22
|
${color.bold('COMMANDS')}
|
|
23
23
|
groups The groups you can post to, and the ones you follow
|
|
24
24
|
plays [--board community|private] The plays themselves [--days N] [--limit N]
|
|
25
|
+
post --symbol SPY ... Post a play, on its own or as a LINKED pair
|
|
25
26
|
show --id <play> One play, as a card
|
|
26
27
|
take --id <play> [--contracts N] Place a play in YOUR account [--account <id>]
|
|
27
28
|
performance --group <id> [--mine] Closed trades and the win rate for a group
|
|
@@ -31,11 +32,27 @@ ${color.bold('COMMANDS')}
|
|
|
31
32
|
follow --group <id> [--off] Follow a public group, or stop
|
|
32
33
|
schema What a play is made of — read before posting
|
|
33
34
|
|
|
35
|
+
${color.bold('POSTING')}
|
|
36
|
+
--symbol SPY --call|--put --strike 765 --expiry 2026-09-14
|
|
37
|
+
--entry 765 [--entry-unit price|contract] [--target N ...] [--stop N]
|
|
38
|
+
[--group <id>] a group of yours; left out, it goes to the community board
|
|
39
|
+
[--reason "..."] [--score 8] [--contracts 2] [--take-it-myself]
|
|
40
|
+
|
|
41
|
+
A LINKED PAIR — the second trade rides the same flags prefixed with-, and
|
|
42
|
+
anything you leave out is taken from the first leg:
|
|
43
|
+
--link oco whichever fills first cancels the other
|
|
44
|
+
--link oeo the second starts only once the first fills
|
|
45
|
+
--with-put --with-strike 760 --with-entry 759 --with-target 758 --with-stop 761
|
|
46
|
+
|
|
34
47
|
${color.bold('EXAMPLES')}
|
|
35
48
|
atlas signals groups
|
|
36
49
|
atlas signals plays --board community --days 1
|
|
37
50
|
atlas signals performance --group <id>
|
|
38
51
|
atlas signals delete --group <id> --stop-subscribers
|
|
52
|
+
atlas signals post --symbol SPY --call --strike 765 --expiry 2026-09-14 \\
|
|
53
|
+
--entry 765 --target 765.5 --target 766.1 --stop 764 --group <id>
|
|
54
|
+
atlas signals post --symbol SPY --call --strike 765 --expiry 2026-09-14 --entry 765 \\
|
|
55
|
+
--link oco --with-put --with-strike 760 --with-entry 759
|
|
39
56
|
`;
|
|
40
57
|
|
|
41
58
|
const callTool = (name, body) => requestJson(API.tool(name), { method: 'POST', body: body || {} });
|
|
@@ -60,6 +77,61 @@ function rows(list) {
|
|
|
60
77
|
return Array.isArray(list) ? list : (list && Array.isArray(list.signals) ? list.signals : []);
|
|
61
78
|
}
|
|
62
79
|
|
|
80
|
+
// One play's fields, from flags. `pre` is '' for the first leg and 'with-' for
|
|
81
|
+
// the linked one, so the two are described the same way and the second only has
|
|
82
|
+
// to say what DIFFERS — everything else falls back to the first leg.
|
|
83
|
+
export function legFrom(flags, pre, base) {
|
|
84
|
+
const f = (name) => flags[`${pre}${name}`];
|
|
85
|
+
const pick = (name, fallback) => {
|
|
86
|
+
const v = f(name);
|
|
87
|
+
return v === undefined || v === true ? fallback : v;
|
|
88
|
+
};
|
|
89
|
+
const call = f('call') === true || String(pick('option', '')).toLowerCase() === 'call';
|
|
90
|
+
const put = f('put') === true || String(pick('option', '')).toLowerCase() === 'put';
|
|
91
|
+
const targets = []
|
|
92
|
+
.concat(f('target') === undefined ? [] : f('target'))
|
|
93
|
+
.concat(f('targets') === undefined ? [] : f('targets'))
|
|
94
|
+
.filter((t) => t !== true)
|
|
95
|
+
.map((t) => num(t))
|
|
96
|
+
.filter((t) => t !== null);
|
|
97
|
+
const leg = {
|
|
98
|
+
symbol: String(pick('symbol', base?.symbol || '')).toUpperCase(),
|
|
99
|
+
side: String(pick('side', base?.side || 'buy')).toLowerCase(),
|
|
100
|
+
};
|
|
101
|
+
const assetFlag = String(pick('asset-class', '')).toLowerCase();
|
|
102
|
+
const isOption = call || put || assetFlag === 'option'
|
|
103
|
+
|| (!assetFlag && base ? base.asset_class === 'option' : false)
|
|
104
|
+
|| (!assetFlag && !base && (f('strike') !== undefined || f('expiry') !== undefined));
|
|
105
|
+
leg.asset_class = isOption ? 'option' : (assetFlag || 'stock');
|
|
106
|
+
if (isOption) {
|
|
107
|
+
leg.option_type = call ? 'call' : put ? 'put' : (base?.option_type || 'call');
|
|
108
|
+
const strike = num(pick('strike', null));
|
|
109
|
+
if (strike !== null) leg.strike = strike;
|
|
110
|
+
const exp = pick('expiry', pick('expiration', base?.expiration_date));
|
|
111
|
+
if (exp) leg.expiration_date = String(exp);
|
|
112
|
+
}
|
|
113
|
+
const entry = num(pick('entry', null));
|
|
114
|
+
if (entry !== null) leg.entry = entry;
|
|
115
|
+
const eu = pick('entry-unit', base?.entry_unit);
|
|
116
|
+
if (eu) leg.entry_unit = String(eu);
|
|
117
|
+
const eop = pick('entry-operator', null);
|
|
118
|
+
if (eop) leg.entry_operator = String(eop);
|
|
119
|
+
if (targets.length) leg.targets = targets;
|
|
120
|
+
const tu = pick('tp-unit', base?.tp_unit);
|
|
121
|
+
if (tu) leg.tp_unit = String(tu);
|
|
122
|
+
const stop = num(pick('stop', null));
|
|
123
|
+
if (stop !== null) leg.stop = stop;
|
|
124
|
+
const su = pick('stop-unit', base?.stop_unit);
|
|
125
|
+
if (su) leg.stop_unit = String(su);
|
|
126
|
+
const contracts = num(pick('contracts', null));
|
|
127
|
+
if (contracts !== null) leg.contracts = contracts;
|
|
128
|
+
const reason = pick('reason', null);
|
|
129
|
+
if (reason) leg.reason = String(reason);
|
|
130
|
+
const score = num(pick('score', null));
|
|
131
|
+
if (score !== null) leg.score = score;
|
|
132
|
+
return leg;
|
|
133
|
+
}
|
|
134
|
+
|
|
63
135
|
export async function signalsCommand(args) {
|
|
64
136
|
const flags = args.flags || {};
|
|
65
137
|
const sub = String(args._[0] || 'groups').toLowerCase();
|
|
@@ -102,6 +174,35 @@ export async function signalsCommand(args) {
|
|
|
102
174
|
}
|
|
103
175
|
return 0;
|
|
104
176
|
}
|
|
177
|
+
case 'post': {
|
|
178
|
+
const leg = legFrom(flags, '', null);
|
|
179
|
+
if (!leg.symbol) { eprintln(color.red('Which stock? --symbol SPY')); return 1; }
|
|
180
|
+
const body = { ...leg };
|
|
181
|
+
if (flags.group) body.group_id = String(flags.group);
|
|
182
|
+
if (flags['take-it-myself'] || flags['trade-for-me']) body.trade_for_me = true;
|
|
183
|
+
if (flags.account) body.snaptrade_account_id = String(flags.account);
|
|
184
|
+
// A LINKED PAIR, from the CLI (owner, 2026-09-12). The tool has taken a
|
|
185
|
+
// second leg since linked plays shipped; what did not exist was a way to
|
|
186
|
+
// say it in flags, so anyone at a terminal had to hand-write JSON — and
|
|
187
|
+
// an agent reading the CLI concluded a linked pair could not be posted
|
|
188
|
+
// directly at all.
|
|
189
|
+
const link = String(flags.link || flags['link-mode'] || '').toLowerCase();
|
|
190
|
+
if (link) {
|
|
191
|
+
if (link !== 'oco' && link !== 'oeo') {
|
|
192
|
+
eprintln(color.red('--link is oco (whichever fills first cancels the other) or oeo (the second starts once the first fills).'));
|
|
193
|
+
return 1;
|
|
194
|
+
}
|
|
195
|
+
const second = legFrom(flags, 'with-', leg);
|
|
196
|
+
if (!second.symbol) { eprintln(color.red('The linked trade needs a stock: --with-symbol, or it takes the first leg\'s.')); return 1; }
|
|
197
|
+
body.second_leg = second;
|
|
198
|
+
body.link_mode = link;
|
|
199
|
+
}
|
|
200
|
+
const data = await callTool('Signal-Post', body);
|
|
201
|
+
if (failed(data)) return printFailure(data);
|
|
202
|
+
if (json) { println(prettyJson(data)); return 0; }
|
|
203
|
+
println(data.message || (link ? 'Posted both, linked.' : 'Posted.'));
|
|
204
|
+
return 0;
|
|
205
|
+
}
|
|
105
206
|
case 'show': {
|
|
106
207
|
const id = flags.id || flags.play || flags.signal;
|
|
107
208
|
if (!id) { eprintln(color.red('Which play? --id <play-id>')); return 1; }
|
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.68.
|
|
5
|
+
export const VERSION = '0.68.1';
|
|
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';
|
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
|
@@ -37,6 +37,7 @@ export function printRootHelp() {
|
|
|
37
37
|
println();
|
|
38
38
|
println(c.bold('STREAMS'));
|
|
39
39
|
println(' alerts list [--all] [--symbol SPY] Your alerts, with their ids');
|
|
40
|
+
println(' alerts schema The shape of every feed event (JSON Schema)');
|
|
40
41
|
println(' alerts delete --id <id> [--id <id2>] [--purge] Switch alerts off, or delete for good');
|
|
41
42
|
println(' alerts [--symbol SPY] [--name greek_exposure] Live alert feed (SSE)');
|
|
42
43
|
println(' [--save-charts <dir>] [--no-chart] [--json]');
|