mindvest-atlas 0.50.0 → 0.50.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/computer-use.js +30 -5
- package/src/config.js +1 -1
- package/src/helpers/atlas_env.py +466 -0
package/package.json
CHANGED
|
@@ -99,6 +99,16 @@ const OPS = {
|
|
|
99
99
|
// command beats a key chord, which goes wherever the focus is and reports
|
|
100
100
|
// success either way.
|
|
101
101
|
windows: ['windows', 0],
|
|
102
|
+
// A console Windows put on top of everything is machinery, not the desk.
|
|
103
|
+
'hide-consoles': ['hide_consoles', 0],
|
|
104
|
+
// ── A JOB THAT IS MOSTLY WAITING ──────────────────────────────────────
|
|
105
|
+
watch: ['watch', 1],
|
|
106
|
+
// ── ONE CALL FOR WHAT USED TO BE FOUR ─────────────────────────────────
|
|
107
|
+
'scroll-x': ['scroll_x', 1],
|
|
108
|
+
'shift-scroll': ['shift_scroll', 1],
|
|
109
|
+
swipe: ['swipe', 4],
|
|
110
|
+
zoom: ['zoom', 1],
|
|
111
|
+
page: ['page', 1],
|
|
102
112
|
minimize: ['minimize_window', 1],
|
|
103
113
|
maximize: ['maximize_window', 1],
|
|
104
114
|
restore: ['restore_window', 1],
|
|
@@ -114,7 +124,8 @@ const OPS = {
|
|
|
114
124
|
// that takes numbers in one runtime and strings in the other is a verb nobody
|
|
115
125
|
// can rely on.
|
|
116
126
|
const NUMERIC = new Set(['use-screen', 'click', 'double-click', 'right-click',
|
|
117
|
-
'middle-click', 'triple-click', 'move', 'drag', 'scroll', 'wait', 'region'
|
|
127
|
+
'middle-click', 'triple-click', 'move', 'drag', 'scroll', 'wait', 'region',
|
|
128
|
+
'scroll-x', 'shift-scroll', 'swipe', 'zoom']);
|
|
118
129
|
|
|
119
130
|
/** This shell's holding id, made once and remembered. Shared with `atlas run`
|
|
120
131
|
* so everything typed at one prompt lands in ONE session: the log reads as a
|
|
@@ -198,20 +209,34 @@ const NEEDED = [['pyautogui', 'pyautogui'], ['mss', 'mss'], ['Pillow', 'PIL']];
|
|
|
198
209
|
* Best effort and quiet: a machine with no network or no pip is one that runs
|
|
199
210
|
* without it, and every call site already says what it cannot do.
|
|
200
211
|
*/
|
|
212
|
+
/**
|
|
213
|
+
* NOTHING THIS DOES MAY PUT A WINDOW ON THE SCREEN.
|
|
214
|
+
*
|
|
215
|
+
* On Windows a console program started by a process with no console of its own
|
|
216
|
+
* gets a NEW one, drawn on top of everything. Every action here runs python, so
|
|
217
|
+
* a black console box appeared over the desktop at exactly the moment the screen
|
|
218
|
+
* was photographed — and the agent, looking at its own picture, then spent the
|
|
219
|
+
* run trying to work out what the black window was (owner, 2026-09-06: "the AI
|
|
220
|
+
* complains of a black console every time; that console is just the popup taking
|
|
221
|
+
* screenshots"). A picture of the desktop must not contain the machinery that
|
|
222
|
+
* took it.
|
|
223
|
+
*/
|
|
224
|
+
const HIDDEN = { windowsHide: true };
|
|
225
|
+
|
|
201
226
|
export function ensureDeps(python, cwd, env) {
|
|
202
227
|
const check = `import ${NEEDED.map(([, mod]) => mod).join(', ')}`;
|
|
203
228
|
const has = () => spawnSync(python[0], [...python.slice(1), '-c', check],
|
|
204
|
-
{ cwd, env, stdio: 'ignore' }).status === 0;
|
|
229
|
+
{ cwd, env, stdio: 'ignore', ...HIDDEN }).status === 0;
|
|
205
230
|
if (has()) return true;
|
|
206
231
|
println(color.dim(' Getting this computer ready (one time)…'));
|
|
207
232
|
// --user is wrong inside a venv and right outside one, so ask the interpreter
|
|
208
233
|
// which it is in rather than guessing.
|
|
209
234
|
const inVenv = spawnSync(python[0],
|
|
210
235
|
[...python.slice(1), '-c', 'import sys; sys.exit(0 if sys.prefix != sys.base_prefix else 1)'],
|
|
211
|
-
{ cwd, env, stdio: 'ignore' }).status === 0;
|
|
236
|
+
{ cwd, env, stdio: 'ignore', ...HIDDEN }).status === 0;
|
|
212
237
|
const args = ['-m', 'pip', 'install', '--quiet', ...(inVenv ? [] : ['--user'])];
|
|
213
238
|
spawnSync(python[0], [...python.slice(1), ...args, ...NEEDED.map(([name]) => name)],
|
|
214
|
-
{ cwd, env, stdio: 'ignore', timeout: 300000 });
|
|
239
|
+
{ cwd, env, stdio: 'ignore', timeout: 300000, ...HIDDEN });
|
|
215
240
|
return has();
|
|
216
241
|
}
|
|
217
242
|
|
|
@@ -239,7 +264,7 @@ export function runInProject(job, code, root) {
|
|
|
239
264
|
env.ATLAS_COMPUTER_PID = '0';
|
|
240
265
|
env.PYTHONPATH = [dir, process.env.PYTHONPATH].filter(Boolean).join(path.delimiter);
|
|
241
266
|
const out = spawnSync(python[0], [...python.slice(1), '-c', code], {
|
|
242
|
-
cwd: dir, env, stdio: 'inherit',
|
|
267
|
+
cwd: dir, env, stdio: 'inherit', ...HIDDEN,
|
|
243
268
|
});
|
|
244
269
|
if (out.error) {
|
|
245
270
|
eprintln(color.red('This computer has no Python, and using it needs one.'));
|
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.50.
|
|
5
|
+
export const VERSION = '0.50.2';
|
|
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.py
CHANGED
|
@@ -1390,6 +1390,11 @@ def screen(path=None, grid=None):
|
|
|
1390
1390
|
|
|
1391
1391
|
def take():
|
|
1392
1392
|
import hashlib
|
|
1393
|
+
# NOTHING OF OURS IN THE PICTURE. A console that Windows put on top a
|
|
1394
|
+
# moment ago is machinery, not the desk, and a run that photographs one
|
|
1395
|
+
# spends its time on it instead of on the work. Swept before the shutter,
|
|
1396
|
+
# so no picture can contain one.
|
|
1397
|
+
hide_consoles()
|
|
1393
1398
|
img, raw = _grab(m)
|
|
1394
1399
|
digest = hashlib.md5(raw).hexdigest() if raw is not None else None
|
|
1395
1400
|
seen = _last_look()
|
|
@@ -1725,6 +1730,11 @@ def do(*steps, **kw):
|
|
|
1725
1730
|
"""
|
|
1726
1731
|
settle = float(kw.get("settle", 1.2))
|
|
1727
1732
|
look = kw.get("look", True)
|
|
1733
|
+
# AND NOTHING OF OURS IN FRONT OF IT. A console Windows put on top has the
|
|
1734
|
+
# KEYBOARD, so a run of steps types its password into a black box instead of
|
|
1735
|
+
# into the form it was aiming at, and every shortcut goes nowhere. Swept
|
|
1736
|
+
# before the first step rather than discovered after the last.
|
|
1737
|
+
hide_consoles()
|
|
1728
1738
|
# What it looked like before any of this, so the wait afterwards can tell
|
|
1729
1739
|
# "nothing has happened yet" from "it happened and is done". Only worth
|
|
1730
1740
|
# measuring when there is going to be a wait: settle=0 says do not wait, and
|
|
@@ -1962,6 +1972,111 @@ def _one_window(title):
|
|
|
1962
1972
|
return got
|
|
1963
1973
|
|
|
1964
1974
|
|
|
1975
|
+
#: A CONSOLE NOBODY NAMED, recognised by SHAPE rather than by a list of names.
|
|
1976
|
+
#:
|
|
1977
|
+
#: The title of a console window is the command line of the program in it until
|
|
1978
|
+
#: something sets one. So "C:\...\python3.11.exe" is machinery, and so is
|
|
1979
|
+
#: "C:\...\Atlas Remote Control.exe" — which a list of interpreter names would
|
|
1980
|
+
#: have walked straight past, and that one was the window sitting over the
|
|
1981
|
+
#: owner's chart (owner, 2026-09-06: "screen for any console interpreter and
|
|
1982
|
+
#: let's stick with background processes for all of it").
|
|
1983
|
+
#:
|
|
1984
|
+
#: A window somebody is WORKING in has a title about their work: "trading notes
|
|
1985
|
+
#: - bash" does not end in .exe and is left alone. That is the whole boundary,
|
|
1986
|
+
#: and it is deliberately the conservative side of the line.
|
|
1987
|
+
_EXE_TITLE = __import__("re").compile(r"\.exe$", __import__("re").I)
|
|
1988
|
+
|
|
1989
|
+
|
|
1990
|
+
def _looks_like_a_console(title):
|
|
1991
|
+
"""True for a window whose title is just the program running in it."""
|
|
1992
|
+
name = str(title or "").strip()
|
|
1993
|
+
if not name:
|
|
1994
|
+
return False
|
|
1995
|
+
# `"C:\path\codex.exe" app-server` — a quoted program plus its arguments.
|
|
1996
|
+
if name.startswith('"') and '"' in name[1:]:
|
|
1997
|
+
return bool(_EXE_TITLE.search(name[1:name.index('"', 1)]))
|
|
1998
|
+
# Windows Terminal titles a tab "<something> - powershell.exe".
|
|
1999
|
+
return bool(_EXE_TITLE.search(name) or _EXE_TITLE.search(name.rsplit(" - ", 1)[-1].strip()))
|
|
2000
|
+
|
|
2001
|
+
|
|
2002
|
+
def _put_out_of_sight(win):
|
|
2003
|
+
"""Hide it outright where the platform allows, and minimise where it does not.
|
|
2004
|
+
|
|
2005
|
+
HIDDEN, NOT MINIMISED. A minimised console is still a window: it holds a
|
|
2006
|
+
taskbar button, it can be restored by a stray click, and it flashes on its
|
|
2007
|
+
way down — and the ask was for these to be BACKGROUND PROCESSES, which is
|
|
2008
|
+
what a program with no window on screen actually is. ShowWindow(SW_HIDE)
|
|
2009
|
+
takes it off the screen entirely without touching the process, so whatever is
|
|
2010
|
+
running in it carries on with its output still going where it always went.
|
|
2011
|
+
"""
|
|
2012
|
+
try:
|
|
2013
|
+
import ctypes
|
|
2014
|
+
handle = getattr(win, "_hWnd", None)
|
|
2015
|
+
if handle and os.name == "nt":
|
|
2016
|
+
ctypes.windll.user32.ShowWindow(int(handle), 0) # SW_HIDE
|
|
2017
|
+
return True
|
|
2018
|
+
except Exception:
|
|
2019
|
+
pass
|
|
2020
|
+
win.minimize()
|
|
2021
|
+
return True
|
|
2022
|
+
|
|
2023
|
+
|
|
2024
|
+
def hide_consoles():
|
|
2025
|
+
"""Put the machinery's own console windows out of the picture.
|
|
2026
|
+
|
|
2027
|
+
WHY THIS EXISTS AT ALL. Running anything on Windows can allocate a console,
|
|
2028
|
+
and Windows draws a new one on top of everything and gives it the keyboard.
|
|
2029
|
+
It should not happen — every spawn here asks for no window — but with
|
|
2030
|
+
Windows Terminal set as the default console host that request is not always
|
|
2031
|
+
honoured, and the result is a black box sitting over the person's charts,
|
|
2032
|
+
cascading down-right each time it is focused, eating the keystrokes meant
|
|
2033
|
+
for the app being driven.
|
|
2034
|
+
|
|
2035
|
+
An agent then photographs it and, quite reasonably, spends the run trying to
|
|
2036
|
+
work out what it is (owner, 2026-09-06: "can we make bash, powershell or any
|
|
2037
|
+
terminal invisible or minimized so the agent does not need to see this ...
|
|
2038
|
+
the best is to auto minimise or auto layer to the back").
|
|
2039
|
+
|
|
2040
|
+
IT IS HIDDEN, NEVER CLOSED. That window is the process running the commands:
|
|
2041
|
+
closing it ends the session issuing them. Hiding takes it off the screen
|
|
2042
|
+
without touching what is inside, and hands the keyboard back to whatever was
|
|
2043
|
+
in front, which is the other half of the damage it does.
|
|
2044
|
+
|
|
2045
|
+
Hidden rather than minimised, because a minimised console is still a window
|
|
2046
|
+
— a taskbar button, one stray click from being back, and a flash on the way
|
|
2047
|
+
down. A background process is one with no window on screen.
|
|
2048
|
+
|
|
2049
|
+
Identified by TITLE, and only the ones nobody named: a console's title is
|
|
2050
|
+
the path of the program in it until something sets one, so
|
|
2051
|
+
a path ending in python3.11.exe is machinery, and "trading notes - bash" is
|
|
2052
|
+
somebody's work and is left alone.
|
|
2053
|
+
|
|
2054
|
+
Quiet and best effort: a machine that cannot list its windows is one this
|
|
2055
|
+
does nothing on.
|
|
2056
|
+
"""
|
|
2057
|
+
hidden = []
|
|
2058
|
+
try:
|
|
2059
|
+
wins = _all_windows()
|
|
2060
|
+
except Exception:
|
|
2061
|
+
return hidden
|
|
2062
|
+
for w in wins:
|
|
2063
|
+
title = str(getattr(w, "title", "") or "")
|
|
2064
|
+
if not _looks_like_a_console(title):
|
|
2065
|
+
continue
|
|
2066
|
+
try:
|
|
2067
|
+
# ALREADY GONE IS ALREADY DONE. The test is whether it is on the
|
|
2068
|
+
# screen, not whether it is minimised: a minimised console still has
|
|
2069
|
+
# a taskbar button and is one click from being back, so it is still
|
|
2070
|
+
# something to put away.
|
|
2071
|
+
if getattr(w, "visible", True) is False:
|
|
2072
|
+
continue
|
|
2073
|
+
_put_out_of_sight(w)
|
|
2074
|
+
hidden.append(title[:120])
|
|
2075
|
+
except Exception:
|
|
2076
|
+
continue
|
|
2077
|
+
return hidden
|
|
2078
|
+
|
|
2079
|
+
|
|
1965
2080
|
def windows(title=""):
|
|
1966
2081
|
"""Every window that is open, with where it is and what state it is in.
|
|
1967
2082
|
|
|
@@ -2122,6 +2237,357 @@ def scan_page(x=None, y=None, most=10, amount=None, grid=None):
|
|
|
2122
2237
|
return _do("scan_page", go, {"x": x, "y": y, "step": step})
|
|
2123
2238
|
|
|
2124
2239
|
|
|
2240
|
+
#: What a round of watching may wait, in minutes. A model asked how long to
|
|
2241
|
+
#: leave it will occasionally say 0 (a busy loop, billed per turn) or 600 (an
|
|
2242
|
+
#: afternoon in which anything could happen). Its judgement inside these bounds
|
|
2243
|
+
#: is better than a fixed interval; outside them it is a mistake, not a choice.
|
|
2244
|
+
_WAIT_FLOOR_MIN = 0.5
|
|
2245
|
+
_WAIT_CEILING_MIN = 120.0
|
|
2246
|
+
|
|
2247
|
+
|
|
2248
|
+
def _next_wait(answer, fallback):
|
|
2249
|
+
"""How long the model asked to wait, in minutes, or `fallback` if it did not.
|
|
2250
|
+
|
|
2251
|
+
Pass fallback=None to tell the two apart: "it asked for this" and "it said
|
|
2252
|
+
nothing" lead to different behaviour, and a default that quietly stands in
|
|
2253
|
+
for silence is how a dynamic interval becomes a fixed one without anybody
|
|
2254
|
+
noticing.
|
|
2255
|
+
|
|
2256
|
+
Read from a `NEXT: <minutes>` line rather than by parsing prose. A model
|
|
2257
|
+
writing a paragraph will mention several numbers and the last thing you want
|
|
2258
|
+
is to have picked one of them out of a sentence.
|
|
2259
|
+
"""
|
|
2260
|
+
import re as _re
|
|
2261
|
+
hit = None
|
|
2262
|
+
for m in _re.finditer(r"^\s*NEXT\s*:\s*([0-9]+(?:\.[0-9]+)?)", str(answer or ""),
|
|
2263
|
+
_re.I | _re.M):
|
|
2264
|
+
hit = m.group(1)
|
|
2265
|
+
if hit is None:
|
|
2266
|
+
return fallback
|
|
2267
|
+
try:
|
|
2268
|
+
want = float(hit)
|
|
2269
|
+
except ValueError:
|
|
2270
|
+
return fallback
|
|
2271
|
+
return max(_WAIT_FLOOR_MIN, min(_WAIT_CEILING_MIN, want))
|
|
2272
|
+
|
|
2273
|
+
|
|
2274
|
+
def _watch_done(answer):
|
|
2275
|
+
"""The model saying it is finished, on a line of its own."""
|
|
2276
|
+
import re as _re
|
|
2277
|
+
return bool(_re.search(r"^\s*DONE\s*$", str(answer or ""), _re.I | _re.M))
|
|
2278
|
+
|
|
2279
|
+
|
|
2280
|
+
def _still_mine():
|
|
2281
|
+
"""Are we still the ones holding the computer?
|
|
2282
|
+
|
|
2283
|
+
STOP DELETES THE CLAIM. That is how Atlas ends a run, and it is a signal any
|
|
2284
|
+
loop can read: no claim, or somebody else's, means this run is over. A long
|
|
2285
|
+
sleep that does not check it is a run that carries on for another twenty
|
|
2286
|
+
minutes after Stop, and then acts (owner, 2026-09-06: "if I click stop, the
|
|
2287
|
+
entire thing stops, no lingering data or wait").
|
|
2288
|
+
"""
|
|
2289
|
+
held = _claim_holder()
|
|
2290
|
+
if not held:
|
|
2291
|
+
return False
|
|
2292
|
+
mine = os.environ.get("ATLAS_COMPUTER_SESSION") or ""
|
|
2293
|
+
return not mine or str(held.get("session") or "") == mine
|
|
2294
|
+
|
|
2295
|
+
|
|
2296
|
+
def _sleep_or_stop(seconds, slice_s=1.0):
|
|
2297
|
+
"""Wait, in slices, and give up the moment the run is stopped.
|
|
2298
|
+
|
|
2299
|
+
Returns False when it was stopped, True when it slept the whole way. One
|
|
2300
|
+
long sleep cannot be interrupted, so it is many short ones — the cost is a
|
|
2301
|
+
file read a second, which next to a model call is nothing.
|
|
2302
|
+
"""
|
|
2303
|
+
import time as _time
|
|
2304
|
+
left = max(0.0, float(seconds))
|
|
2305
|
+
while left > 0:
|
|
2306
|
+
if not _still_mine():
|
|
2307
|
+
return False
|
|
2308
|
+
nap = min(slice_s, left)
|
|
2309
|
+
_time.sleep(nap)
|
|
2310
|
+
left -= nap
|
|
2311
|
+
return _still_mine()
|
|
2312
|
+
|
|
2313
|
+
|
|
2314
|
+
def watch(instruction, every=5, most=200, hours=8, look=True):
|
|
2315
|
+
"""Watch the screen and act when it is time. Runs until it is done.
|
|
2316
|
+
|
|
2317
|
+
THE SHAPE OF A JOB THAT IS MOSTLY WAITING (owner, 2026-09-06: "watch this
|
|
2318
|
+
market, wait until you see a setup, when you see it put a trade, keep
|
|
2319
|
+
watching, check every 5 minutes"). Most of what somebody wants from a
|
|
2320
|
+
computer that can see is not a task with a beginning and an end; it is a
|
|
2321
|
+
watch. Written as a script it is a loop nobody gets right: too fast and it
|
|
2322
|
+
burns a model call a second, too slow and it misses the thing.
|
|
2323
|
+
|
|
2324
|
+
watch("Watch the SPY 5-minute chart. When price breaks above the "
|
|
2325
|
+
"opening range high on rising volume, buy 1 call and say so.")
|
|
2326
|
+
|
|
2327
|
+
Each round: it looks, then asks YOUR model what it sees and what to do. The
|
|
2328
|
+
model has the same computer this does, so when it decides to act it acts —
|
|
2329
|
+
there is no separate step where something else carries out its decision.
|
|
2330
|
+
|
|
2331
|
+
THE WAIT IS THE MODEL'S TO CHOOSE, and that is the point. A fixed interval is
|
|
2332
|
+
wrong in both directions all day: nothing is happening at lunch and
|
|
2333
|
+
everything is happening at the open. So each round ends by asking how long to
|
|
2334
|
+
leave it, and the answer sets the next sleep — "quiet, look again in 20" or
|
|
2335
|
+
"this is setting up, 2 minutes" (owner: "each loop wait can update, so
|
|
2336
|
+
dynamic based on what the agent said"). `every` is only the FIRST wait and
|
|
2337
|
+
the fallback when it does not say.
|
|
2338
|
+
|
|
2339
|
+
IT HOLDS THE COMPUTER THE WHOLE TIME. There is one mouse, and something that
|
|
2340
|
+
may need to act at any moment cannot hand it back between looks. Nothing else
|
|
2341
|
+
can drive while this runs, which is a real cost and the reason for `hours`.
|
|
2342
|
+
|
|
2343
|
+
IT COSTS A MODEL CALL PER ROUND. That is what the interval is really
|
|
2344
|
+
choosing, and why a model that says "nothing is happening, look again in an
|
|
2345
|
+
hour" is doing its job rather than being lazy.
|
|
2346
|
+
|
|
2347
|
+
Ends when the model writes DONE on a line by itself, when `most` rounds have
|
|
2348
|
+
passed, or when `hours` have. Pressing Stop ends it like anything else.
|
|
2349
|
+
"""
|
|
2350
|
+
import time as _time
|
|
2351
|
+
began = _time.time()
|
|
2352
|
+
deadline = began + max(0.0, float(hours)) * 3600.0
|
|
2353
|
+
wait_min = max(_WAIT_FLOOR_MIN, min(_WAIT_CEILING_MIN, float(every)))
|
|
2354
|
+
rounds = 0
|
|
2355
|
+
seen = []
|
|
2356
|
+
waits = []
|
|
2357
|
+
quiet = 0
|
|
2358
|
+
changed = None
|
|
2359
|
+
note("watching: %s" % str(instruction)[:160])
|
|
2360
|
+
stopped = False
|
|
2361
|
+
done = False
|
|
2362
|
+
try:
|
|
2363
|
+
while rounds < int(most) and _time.time() < deadline:
|
|
2364
|
+
# STOP MEANS STOPPED, AT EVERY POINT IT COULD MEAN ANYTHING ELSE.
|
|
2365
|
+
# Atlas ends a run by deleting the claim; a loop that does not read
|
|
2366
|
+
# it carries on for another twenty minutes and then ACTS (owner,
|
|
2367
|
+
# 2026-09-06: "if I click stop, the entire thing stops, no lingering
|
|
2368
|
+
# data or wait").
|
|
2369
|
+
if not _still_mine():
|
|
2370
|
+
stopped = True
|
|
2371
|
+
break
|
|
2372
|
+
rounds += 1
|
|
2373
|
+
shot = None
|
|
2374
|
+
if look:
|
|
2375
|
+
try:
|
|
2376
|
+
got = screen()
|
|
2377
|
+
shot = got if isinstance(got, str) else got.get("path")
|
|
2378
|
+
# Whether anything moved, which decides the backoff when the
|
|
2379
|
+
# model does not choose.
|
|
2380
|
+
changed = _screen_changed(shot)
|
|
2381
|
+
except Exception as err:
|
|
2382
|
+
# A look that fails is not a reason to stop watching; the
|
|
2383
|
+
# model is told, and decides whether that matters.
|
|
2384
|
+
shot = None
|
|
2385
|
+
seen.append({"round": rounds, "error": str(err)})
|
|
2386
|
+
asked = (
|
|
2387
|
+
"%s\n\n"
|
|
2388
|
+
"You are WATCHING, not running a task to completion. This is round %d.\n"
|
|
2389
|
+
"%s\n"
|
|
2390
|
+
"Decide whether anything has changed that matters, and if it is time "
|
|
2391
|
+
"to act, ACT NOW using the computer yourself - you have the same "
|
|
2392
|
+
"`atlas computer-use` this does.\n\n"
|
|
2393
|
+
"End your reply with exactly two things on their own lines:\n"
|
|
2394
|
+
" NEXT: <minutes until the next look>\n"
|
|
2395
|
+
" DONE (only if there is nothing left to watch for)\n"
|
|
2396
|
+
"CHOOSE THE INTERVAL YOURSELF and change it as things change: two or "
|
|
2397
|
+
"three minutes when something is developing, fifteen or sixty when it "
|
|
2398
|
+
"is quiet. The same number every round is a fixed poll, which is the "
|
|
2399
|
+
"one thing this is not. A model call is spent every round."
|
|
2400
|
+
% (instruction, rounds,
|
|
2401
|
+
("The screen right now: %s" % shot) if shot
|
|
2402
|
+
else "The screen could not be photographed this round.")
|
|
2403
|
+
)
|
|
2404
|
+
try:
|
|
2405
|
+
answer = ask(asked)
|
|
2406
|
+
except Exception as err:
|
|
2407
|
+
answer = ""
|
|
2408
|
+
seen.append({"round": rounds, "error": str(err)})
|
|
2409
|
+
if answer:
|
|
2410
|
+
note(str(answer).strip()[:400])
|
|
2411
|
+
seen.append({"round": rounds, "said": str(answer)[:2000]})
|
|
2412
|
+
if _watch_done(answer):
|
|
2413
|
+
note("done watching after %d rounds" % rounds)
|
|
2414
|
+
done = True
|
|
2415
|
+
break
|
|
2416
|
+
# THE MODEL'S JUDGEMENT WINS WHENEVER IT GIVES ONE.
|
|
2417
|
+
asked_for = _next_wait(answer, None)
|
|
2418
|
+
if asked_for is not None:
|
|
2419
|
+
wait_min = asked_for
|
|
2420
|
+
quiet = 0
|
|
2421
|
+
else:
|
|
2422
|
+
# AND WHEN IT DOES NOT, THE LOOP BACKS OFF BY ITSELF.
|
|
2423
|
+
#
|
|
2424
|
+
# The whole point is that the interval MOVES; a model that
|
|
2425
|
+
# answers nothing round after round has quietly turned this back
|
|
2426
|
+
# into a fixed poll and nothing would say so. Silence is not
|
|
2427
|
+
# agreement: with the screen unchanged too, the wait grows.
|
|
2428
|
+
# Something happening resets it.
|
|
2429
|
+
#
|
|
2430
|
+
# It only ever fills a gap the model left. An explicit NEXT is
|
|
2431
|
+
# obeyed, including a short one every round, because a model
|
|
2432
|
+
# watching something develop is right to keep looking.
|
|
2433
|
+
if changed is False:
|
|
2434
|
+
quiet += 1
|
|
2435
|
+
if quiet >= 2:
|
|
2436
|
+
grown = min(_WAIT_CEILING_MIN, wait_min * 1.5)
|
|
2437
|
+
if grown > wait_min:
|
|
2438
|
+
note("nothing is changing and no interval was given - "
|
|
2439
|
+
"waiting %d minutes instead of %d"
|
|
2440
|
+
% (round(grown), round(wait_min)))
|
|
2441
|
+
wait_min = grown
|
|
2442
|
+
else:
|
|
2443
|
+
quiet = 0
|
|
2444
|
+
waits.append(wait_min)
|
|
2445
|
+
# NO SLEEP AFTER THE LAST ROUND. It is over: waiting five more
|
|
2446
|
+
# minutes to discover that is five minutes holding the computer for
|
|
2447
|
+
# nothing.
|
|
2448
|
+
if rounds >= int(most):
|
|
2449
|
+
break
|
|
2450
|
+
left = deadline - _time.time()
|
|
2451
|
+
if left <= 0:
|
|
2452
|
+
break
|
|
2453
|
+
# SLICED, so Stop lands in a second rather than at the end of a
|
|
2454
|
+
# twenty-minute wait.
|
|
2455
|
+
if not _sleep_or_stop(min(wait_min * 60.0, left)):
|
|
2456
|
+
stopped = True
|
|
2457
|
+
break
|
|
2458
|
+
finally:
|
|
2459
|
+
# WHATEVER HAPPENED, LET GO. A watch holds the computer for hours, so a
|
|
2460
|
+
# run that ends any other way than tidily — an error, Stop, the ceiling —
|
|
2461
|
+
# would leave the one mouse claimed and nothing else able to drive.
|
|
2462
|
+
try:
|
|
2463
|
+
release_computer()
|
|
2464
|
+
except Exception:
|
|
2465
|
+
pass
|
|
2466
|
+
note("%s after %d rounds" % ("stopped" if stopped else
|
|
2467
|
+
"finished" if done else "ran out", rounds))
|
|
2468
|
+
# `waits` is every interval it used and `varied` says whether they moved at
|
|
2469
|
+
# all - so "the model just echoes one number back" is something anybody can
|
|
2470
|
+
# SEE rather than suspect.
|
|
2471
|
+
return {"rounds": rounds, "done": done, "stopped": stopped, "seen": seen,
|
|
2472
|
+
"waits": waits, "varied": len(set(waits)) > 1}
|
|
2473
|
+
|
|
2474
|
+
|
|
2475
|
+
def scroll_x(amount, x=None, y=None):
|
|
2476
|
+
"""Scroll SIDEWAYS. Positive goes right, negative left.
|
|
2477
|
+
|
|
2478
|
+
NOT EVERYWHERE, AND SAID RATHER THAN SILENTLY IGNORED. Windows has no
|
|
2479
|
+
horizontal wheel at the API this uses; macOS and Linux do. Where it is
|
|
2480
|
+
missing, the ways that DO work on every platform are named, because a wide
|
|
2481
|
+
table nobody can reach sideways is a wall (owner, 2026-09-06: "add left
|
|
2482
|
+
scrolling, right scrolling").
|
|
2483
|
+
|
|
2484
|
+
On Windows: shift+wheel is what applications themselves map to sideways,
|
|
2485
|
+
which is what shift_scroll() does — or drag the scrollbar, which always
|
|
2486
|
+
works because it is just a thing on the screen.
|
|
2487
|
+
"""
|
|
2488
|
+
def go():
|
|
2489
|
+
g = _gui()
|
|
2490
|
+
if x is not None and y is not None:
|
|
2491
|
+
g.moveTo(int(x), int(y))
|
|
2492
|
+
if not hasattr(g, "hscroll") or os_name() == "windows":
|
|
2493
|
+
raise RuntimeError(
|
|
2494
|
+
"This computer has no sideways wheel. Use shift_scroll(%d) — "
|
|
2495
|
+
"applications map shift+wheel to sideways — or drag the "
|
|
2496
|
+
"scrollbar, which works anywhere." % int(amount)
|
|
2497
|
+
)
|
|
2498
|
+
g.hscroll(int(amount))
|
|
2499
|
+
return _do("scroll_x", go, {"amount": amount, "x": x, "y": y})
|
|
2500
|
+
|
|
2501
|
+
|
|
2502
|
+
def shift_scroll(amount, x=None, y=None):
|
|
2503
|
+
"""Sideways the way applications actually do it: shift and the wheel.
|
|
2504
|
+
|
|
2505
|
+
WORKS ON ALL THREE, which the real horizontal wheel does not. Nearly every
|
|
2506
|
+
program that scrolls sideways binds this, so it is the first thing to try
|
|
2507
|
+
rather than the fallback. Positive is right, negative is left, same as
|
|
2508
|
+
scroll_x.
|
|
2509
|
+
"""
|
|
2510
|
+
def go():
|
|
2511
|
+
g = _gui()
|
|
2512
|
+
if x is not None and y is not None:
|
|
2513
|
+
g.moveTo(int(x), int(y))
|
|
2514
|
+
g.keyDown("shift")
|
|
2515
|
+
try:
|
|
2516
|
+
# The wheel's own sign is inverted under shift in most applications:
|
|
2517
|
+
# a positive (up) wheel with shift held goes LEFT. Negated here so
|
|
2518
|
+
# that positive means right, as it says on the tin.
|
|
2519
|
+
g.scroll(-int(amount))
|
|
2520
|
+
finally:
|
|
2521
|
+
g.keyUp("shift")
|
|
2522
|
+
return _do("shift_scroll", go, {"amount": amount, "x": x, "y": y})
|
|
2523
|
+
|
|
2524
|
+
|
|
2525
|
+
def swipe(x, y, dx=0, dy=0, seconds=0.35):
|
|
2526
|
+
"""Drag from a point BY an amount — a flick, a pan, a slider.
|
|
2527
|
+
|
|
2528
|
+
ONE CALL FOR SOMETHING THAT WAS FOUR. Panning a chart or moving a slider was
|
|
2529
|
+
move, mouse_down, move, mouse_up, and four calls is four round trips and four
|
|
2530
|
+
chances to leave the button held down if one of them is refused. The button
|
|
2531
|
+
is released in a `finally`, so a refusal in the middle does not leave the
|
|
2532
|
+
mouse stuck.
|
|
2533
|
+
|
|
2534
|
+
swipe(*middle, dx=-400) # drag the chart left by 400
|
|
2535
|
+
"""
|
|
2536
|
+
def go():
|
|
2537
|
+
g = _gui()
|
|
2538
|
+
g.moveTo(int(x), int(y))
|
|
2539
|
+
g.mouseDown()
|
|
2540
|
+
try:
|
|
2541
|
+
g.dragTo(int(x) + int(dx), int(y) + int(dy), duration=max(0.0, float(seconds)))
|
|
2542
|
+
finally:
|
|
2543
|
+
g.mouseUp()
|
|
2544
|
+
return _do("swipe", go, {"x": x, "y": y, "dx": dx, "dy": dy},
|
|
2545
|
+
points=((x, y), (int(x) + int(dx), int(y) + int(dy))))
|
|
2546
|
+
|
|
2547
|
+
|
|
2548
|
+
def zoom(steps=1, x=None, y=None):
|
|
2549
|
+
"""Zoom in (positive) or out (negative), the way every platform does it.
|
|
2550
|
+
|
|
2551
|
+
ctrl and the wheel on Windows and Linux, command and the wheel on a Mac.
|
|
2552
|
+
One call rather than a chord the agent has to remember differently per
|
|
2553
|
+
machine — and getting that wrong is a keystroke into whatever had focus.
|
|
2554
|
+
"""
|
|
2555
|
+
def go():
|
|
2556
|
+
import sys as _sys
|
|
2557
|
+
g = _gui()
|
|
2558
|
+
if x is not None and y is not None:
|
|
2559
|
+
g.moveTo(int(x), int(y))
|
|
2560
|
+
holder = "command" if _sys.platform == "darwin" else "ctrl"
|
|
2561
|
+
g.keyDown(holder)
|
|
2562
|
+
try:
|
|
2563
|
+
g.scroll(int(steps))
|
|
2564
|
+
finally:
|
|
2565
|
+
g.keyUp(holder)
|
|
2566
|
+
return _do("zoom", go, {"steps": steps, "x": x, "y": y})
|
|
2567
|
+
|
|
2568
|
+
|
|
2569
|
+
def page(way="down", times=1):
|
|
2570
|
+
"""Page down or up, the whole screenful, without a wheel.
|
|
2571
|
+
|
|
2572
|
+
What a keyboard does and a wheel approximates. `way` is "down", "up",
|
|
2573
|
+
"top" or "bottom" — the last two are one keystroke to either end, which is
|
|
2574
|
+
the fastest way to the bottom of a long page by a distance.
|
|
2575
|
+
"""
|
|
2576
|
+
keys = {"down": "pagedown", "up": "pageup", "top": "home", "bottom": "end"}
|
|
2577
|
+
which = keys.get(str(way).lower())
|
|
2578
|
+
if not which:
|
|
2579
|
+
raise RuntimeError('page() takes "down", "up", "top" or "bottom", not "%s".' % way)
|
|
2580
|
+
|
|
2581
|
+
def go():
|
|
2582
|
+
g = _gui()
|
|
2583
|
+
for _ in range(max(1, int(times))):
|
|
2584
|
+
if which in ("home", "end"):
|
|
2585
|
+
g.hotkey("ctrl", which)
|
|
2586
|
+
else:
|
|
2587
|
+
g.press(which)
|
|
2588
|
+
return _do("page", go, {"way": str(way), "times": times})
|
|
2589
|
+
|
|
2590
|
+
|
|
2125
2591
|
def wait(ms):
|
|
2126
2592
|
"""Wait, and say so. A pause nobody recorded looks like a hang in a replay.
|
|
2127
2593
|
|