mindvest-atlas 0.68.4 → 0.91.0
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 +23 -0
- package/src/commands/chat-search.js +218 -0
- package/src/commands/computer-use.js +199 -19
- package/src/commands/connectors.js +127 -0
- package/src/commands/memory.js +308 -0
- package/src/commands/remote-control.js +313 -69
- package/src/commands/screenshot.js +1 -1
- package/src/config.js +1 -1
- package/src/helpers/atlas_env.js +30 -0
- package/src/helpers/atlas_env.py +2415 -68
- package/src/remote-control.js +679 -51
- package/src/util/help.js +16 -0
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -20,6 +20,9 @@ import { screenshotCommand } from './commands/screenshot.js';
|
|
|
20
20
|
import { runCommand } from './commands/run.js';
|
|
21
21
|
import { computerUseCommand } from './commands/computer-use.js';
|
|
22
22
|
import { dashboardCommand } from './commands/dashboard.js';
|
|
23
|
+
import { chatSearchCommand } from './commands/chat-search.js';
|
|
24
|
+
import { connectorsCommand, connectorCommand } from './commands/connectors.js';
|
|
25
|
+
import { memoryCommand } from './commands/memory.js';
|
|
23
26
|
import { remoteCheckCommand } from './commands/remote-check.js';
|
|
24
27
|
import { shareCommand } from './commands/share.js';
|
|
25
28
|
import { historicalContractCommand } from './commands/historical.js';
|
|
@@ -130,6 +133,26 @@ export async function main(rawArgs) {
|
|
|
130
133
|
// hand (owner, 2026-09-06).
|
|
131
134
|
case 'dashboard':
|
|
132
135
|
return dashboardCommand(withoutCommand(args));
|
|
136
|
+
// EVERY PAST CONVERSATION ON THIS COMPUTER, read where it lies (owner,
|
|
137
|
+
// 2026-09-18: "how do I do searching so every agent can search
|
|
138
|
+
// conversations"). Local files, so no cloud tool could do it; the
|
|
139
|
+
// running Studio knows where each agent keeps its own.
|
|
140
|
+
case 'chat-search':
|
|
141
|
+
case 'chats':
|
|
142
|
+
return await chatSearchCommand(withoutCommand(args));
|
|
143
|
+
// THE CONNECTORS ON THIS COMPUTER, brokers among them (owner,
|
|
144
|
+
// 2026-09-30: "add a CLI for these tools"). Their sign-ins never leave
|
|
145
|
+
// this machine, so no cloud tool could reach them.
|
|
146
|
+
case 'connectors':
|
|
147
|
+
return await connectorsCommand(withoutCommand(args));
|
|
148
|
+
case 'connector':
|
|
149
|
+
return await connectorCommand(withoutCommand(args));
|
|
150
|
+
// WHAT ATLAS KEEPS BETWEEN CONVERSATIONS: three tiers, decayed by use,
|
|
151
|
+
// weighted 1-10 by whoever is working (owner, 2026-09-19). Local files,
|
|
152
|
+
// so no cloud tool could do it.
|
|
153
|
+
case 'memory':
|
|
154
|
+
case 'mem':
|
|
155
|
+
return await memoryCommand(withoutCommand(args));
|
|
133
156
|
// Put the newest CLI on this machine.
|
|
134
157
|
case 'update-cli':
|
|
135
158
|
case 'self-update':
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// `atlas chat-search` — every past conversation on this computer, searched
|
|
2
|
+
// where it lies.
|
|
3
|
+
//
|
|
4
|
+
// Owner, 2026-09-18: "let's say I want to continue from another agent and my
|
|
5
|
+
// sessions are not linked, how do I do searching so every agent can search
|
|
6
|
+
// conversations … even ask() or a normal convo can check previous convo."
|
|
7
|
+
//
|
|
8
|
+
// The engine is in Atlas Studio (`main/core/chat-search.ts`) because that is
|
|
9
|
+
// where the stores are known: the adapters' own transcripts, the chats Studio
|
|
10
|
+
// keeps for every model reached over a port or a key (LM Studio, Ollama, any
|
|
11
|
+
// provider), a project's own `.chats`, and whatever its sweep has found. This
|
|
12
|
+
// command is the shell's way in, for the agent that is sitting at a prompt
|
|
13
|
+
// rather than inside a project script — the same road `atlas screenshot` takes
|
|
14
|
+
// to the dashboard.
|
|
15
|
+
//
|
|
16
|
+
// A HAND-WRITTEN COMMAND, DELIBERATELY. The rule is that a verb has to earn
|
|
17
|
+
// one by doing something a cloud tool call cannot (2026-09-15). This reads
|
|
18
|
+
// files on THIS disk; there is no cloud tool that could.
|
|
19
|
+
//
|
|
20
|
+
// NOTHING IS SHORTENED ON THE WAY THROUGH (owner, 2026-09-18: "DO NOT TRUNCATE
|
|
21
|
+
// OR CUT"). Whatever the engine returns is printed whole; when there is too
|
|
22
|
+
// much, the engine returns FEWER WHOLE TURNS and says so, and that sentence is
|
|
23
|
+
// printed too.
|
|
24
|
+
import { color, println, eprintln } from '../util/ui.js';
|
|
25
|
+
import { askAddress } from '../remote-control.js';
|
|
26
|
+
|
|
27
|
+
function notRunning() {
|
|
28
|
+
eprintln(color.red('Atlas Studio is not running on this computer.'));
|
|
29
|
+
eprintln(color.dim('It is what knows where each agent keeps its conversations. Start it and try again.'));
|
|
30
|
+
return 1;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function post(at, path, body) {
|
|
34
|
+
const res = await fetch(at.url.replace('/ask', path), {
|
|
35
|
+
method: 'POST',
|
|
36
|
+
headers: { 'Content-Type': 'application/json', 'X-Atlas-Token': at.token },
|
|
37
|
+
body: JSON.stringify(body),
|
|
38
|
+
signal: AbortSignal.timeout(180_000),
|
|
39
|
+
});
|
|
40
|
+
const out = await res.json().catch(() => ({}));
|
|
41
|
+
if (!res.ok || !out.ok) throw new Error(out.error || `Chat search failed (${res.status}).`);
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function printRows(rows, agent) {
|
|
46
|
+
for (const row of rows) {
|
|
47
|
+
const who = row.kind === 'user'
|
|
48
|
+
? 'You'
|
|
49
|
+
: row.kind === 'assistant'
|
|
50
|
+
? (agent || 'Model')
|
|
51
|
+
: `${row.kind === 'tool_call' ? 'Tool call' : 'Tool result'}: ${row.tool || 'tool'}`;
|
|
52
|
+
println(color.dim(`--- ${who} ---`));
|
|
53
|
+
println(row.text);
|
|
54
|
+
println('');
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function chatSearchCommand(args) {
|
|
59
|
+
const flags = args.flags || {};
|
|
60
|
+
const at = askAddress(flags.root);
|
|
61
|
+
if (!at) return notRunning();
|
|
62
|
+
|
|
63
|
+
const sub = String((args.positional ?? args._)?.[0] || '').trim();
|
|
64
|
+
|
|
65
|
+
// WHAT THIS COMPUTER REMEMBERS about searching: which folders hold
|
|
66
|
+
// conversations, which of them answer, and where recent answers were. Paths
|
|
67
|
+
// and counters, never a conversation (owner, 2026-09-19: "store the search
|
|
68
|
+
// path or recent important stuff, so we can read this to find info quicker
|
|
69
|
+
// next time").
|
|
70
|
+
if (sub === 'forget') {
|
|
71
|
+
try {
|
|
72
|
+
await post(at, '/chat-search/forget', {});
|
|
73
|
+
println('Forgotten. Every conversation is exactly where it was; only the shortcuts are gone.');
|
|
74
|
+
return 0;
|
|
75
|
+
} catch (err) {
|
|
76
|
+
eprintln(color.red(err.message));
|
|
77
|
+
return 1;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (sub === 'memory' || sub === 'places') {
|
|
81
|
+
let mem;
|
|
82
|
+
try {
|
|
83
|
+
mem = await post(at, '/chat-search/memory', {});
|
|
84
|
+
} catch (err) {
|
|
85
|
+
eprintln(color.red(err.message));
|
|
86
|
+
return 1;
|
|
87
|
+
}
|
|
88
|
+
if (flags.json) {
|
|
89
|
+
println(JSON.stringify(mem, null, 2));
|
|
90
|
+
return 0;
|
|
91
|
+
}
|
|
92
|
+
println(color.bold(`${mem.places.length} place(s) learned, ${mem.recent.length} pointer(s) kept`));
|
|
93
|
+
for (const p of mem.places) {
|
|
94
|
+
println(` ${p.dir} ${color.dim(`${p.agent} · ${p.parser}${p.hits ? ` · answered ${p.hits}x` : ''}`)}`);
|
|
95
|
+
}
|
|
96
|
+
for (const r of mem.recent.slice(0, 10)) {
|
|
97
|
+
println(` "${r.query}" -> ${r.sessionId} ${color.dim(`(${r.agent} · ${r.store})`)}`);
|
|
98
|
+
}
|
|
99
|
+
return 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// `chat-search show <session>` — one conversation, as deep as asked. Its own
|
|
103
|
+
// call rather than a flag, because reading a conversation whole is a
|
|
104
|
+
// different act from finding one.
|
|
105
|
+
if (sub === 'show' || flags.show) {
|
|
106
|
+
const id = String(flags.show || (args.positional ?? args._)?.[1] || '').trim();
|
|
107
|
+
if (!id) {
|
|
108
|
+
eprintln(color.red('Which conversation? `atlas chat-search show <session-id>`'));
|
|
109
|
+
return 1;
|
|
110
|
+
}
|
|
111
|
+
let out;
|
|
112
|
+
try {
|
|
113
|
+
out = await post(at, '/chat-search/show', {
|
|
114
|
+
sessionId: id,
|
|
115
|
+
file: flags.file,
|
|
116
|
+
around: flags.around == null ? undefined : Number(flags.around),
|
|
117
|
+
// `--turns 0` is the whole thing; the default keeps it to the part
|
|
118
|
+
// around the start.
|
|
119
|
+
turns: flags.turns == null ? undefined : Number(flags.turns),
|
|
120
|
+
handover: !!flags.handover,
|
|
121
|
+
discover: !!flags.discover,
|
|
122
|
+
});
|
|
123
|
+
} catch (err) {
|
|
124
|
+
eprintln(color.red(err.message));
|
|
125
|
+
return 1;
|
|
126
|
+
}
|
|
127
|
+
if (flags.json) {
|
|
128
|
+
println(JSON.stringify(out, null, 2));
|
|
129
|
+
return 0;
|
|
130
|
+
}
|
|
131
|
+
if (flags.handover) {
|
|
132
|
+
// Exactly what you paste into another model as the first message.
|
|
133
|
+
println(out.handover);
|
|
134
|
+
return 0;
|
|
135
|
+
}
|
|
136
|
+
println(color.bold(out.title));
|
|
137
|
+
println(color.dim(`${out.agent} · ${out.store} · ${out.sessionId}${out.cwd ? ` · ${out.cwd}` : ''}`));
|
|
138
|
+
println(color.dim(`Turns ${out.from}-${out.to} of ${out.total}. Nothing below is shortened.`));
|
|
139
|
+
println('');
|
|
140
|
+
printRows(out.rows, out.agent);
|
|
141
|
+
return 0;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const query = String(flags.query || flags.q || (args.positional ?? args._)?.join(' ') || '').trim();
|
|
145
|
+
if (!query) {
|
|
146
|
+
eprintln(color.red('What are you looking for? `atlas chat-search "the words you remember"`'));
|
|
147
|
+
eprintln(color.dim('Then: atlas chat-search show <session> --turns 0 (the whole conversation)'));
|
|
148
|
+
return 1;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
let out;
|
|
152
|
+
try {
|
|
153
|
+
out = await post(at, '/chat-search', {
|
|
154
|
+
query,
|
|
155
|
+
turns: flags.turns == null ? undefined : Number(flags.turns),
|
|
156
|
+
limit: flags.limit == null ? undefined : Number(flags.limit),
|
|
157
|
+
maxBytes: flags.bytes == null ? undefined : Number(flags.bytes),
|
|
158
|
+
store: flags.store,
|
|
159
|
+
agent: flags.agent,
|
|
160
|
+
since: flags.since ? Date.parse(String(flags.since)) || undefined : undefined,
|
|
161
|
+
matchCase: !!flags['match-case'],
|
|
162
|
+
regex: !!flags.regex,
|
|
163
|
+
// Look for stores nobody wrote a path for, unless told not to. What it
|
|
164
|
+
// finds is remembered as a FOLDER, so the next search already knows.
|
|
165
|
+
discover: flags['no-discover'] ? false : true,
|
|
166
|
+
// The project you are in comes first; everything else still comes back.
|
|
167
|
+
project: flags.project || flags.job || process.cwd(),
|
|
168
|
+
maxMs: flags.ms == null ? undefined : Number(flags.ms),
|
|
169
|
+
});
|
|
170
|
+
} catch (err) {
|
|
171
|
+
eprintln(color.red(err.message));
|
|
172
|
+
return 1;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (flags.json) {
|
|
176
|
+
println(JSON.stringify(out, null, 2));
|
|
177
|
+
return 0;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (!out.hits.length) {
|
|
181
|
+
println(`Nothing on this computer says "${query}".`);
|
|
182
|
+
println(color.dim(`Looked in ${out.scanned.places} place(s), ${out.scanned.files} transcript(s).`));
|
|
183
|
+
for (const note of out.notes) println(color.dim(note));
|
|
184
|
+
return 0;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// SEEN BEFORE. Offered beside the live answer, never instead of it: a store
|
|
188
|
+
// can change under a pointer.
|
|
189
|
+
if (out.remembered?.length) {
|
|
190
|
+
println(color.dim('Seen before:'));
|
|
191
|
+
for (const r of out.remembered) {
|
|
192
|
+
println(color.dim(` "${r.query}" -> ${r.sessionId} (${r.agent} · ${r.store})`));
|
|
193
|
+
}
|
|
194
|
+
println('');
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
println(color.bold(`${out.hits.length} of ${out.found} conversation(s)`));
|
|
198
|
+
println('');
|
|
199
|
+
for (const hit of out.hits) {
|
|
200
|
+
println(color.bold(hit.title));
|
|
201
|
+
println(color.dim(`${hit.agent} · ${hit.store} · ${new Date(hit.updatedAt).toLocaleString()} · ${hit.matches} match(es)`));
|
|
202
|
+
println(color.dim(`session ${hit.sessionId}${hit.cwd ? ` · ${hit.cwd}` : ''}`));
|
|
203
|
+
// The matching line verbatim — or, when that ONE line is enormous, where to
|
|
204
|
+
// find it, because showing part of it would be showing part of a value.
|
|
205
|
+
println(hit.line == null
|
|
206
|
+
? color.dim(` [one line of ${hit.lineIsHuge?.bytes} bytes. Read it whole: atlas chat-search show ${hit.sessionId} --turns 0]`)
|
|
207
|
+
: ` ${hit.line}`);
|
|
208
|
+
if (!flags.quiet) {
|
|
209
|
+
println('');
|
|
210
|
+
printRows(hit.rows, hit.agent);
|
|
211
|
+
if (hit.note) println(color.yellow(hit.note));
|
|
212
|
+
println(color.dim(`Turns ${hit.from}-${hit.to} of ${hit.total}. atlas chat-search show ${hit.sessionId} --turns 0 for all of it.`));
|
|
213
|
+
}
|
|
214
|
+
println('');
|
|
215
|
+
}
|
|
216
|
+
for (const note of out.notes) println(color.dim(note));
|
|
217
|
+
return 0;
|
|
218
|
+
}
|
|
@@ -28,6 +28,7 @@ import path from 'node:path';
|
|
|
28
28
|
import {
|
|
29
29
|
workspace, workspaceRoot, venvPython, ambientPython, jobEnv, ensureProjectFiles,
|
|
30
30
|
computerUseAllowed, computerClaim, releaseComputerClaim, askAddress,
|
|
31
|
+
listComputerSessions, renameComputerSession, writeReplay, reviewPrompt, askModel,
|
|
31
32
|
} from '../remote-control.js';
|
|
32
33
|
import { color, println, eprintln } from '../util/ui.js';
|
|
33
34
|
|
|
@@ -52,8 +53,26 @@ const HELP = `atlas computer-use <command>
|
|
|
52
53
|
find "<what>" where something is on screen
|
|
53
54
|
window "<title>" bring a window to the front
|
|
54
55
|
cursor where the pointer is
|
|
56
|
+
changed did the screen move since the last look (no picture)
|
|
57
|
+
code "<python>" run a block in the session: loops, ifs, many actions, one call
|
|
58
|
+
controls ["<window>"] the front window's buttons and fields, by name (try this before screen)
|
|
59
|
+
press "<name>" press one of them by name or id (e7), found live [--right] [--double] [--wait <s>]
|
|
60
|
+
type-into "<name>" "<text>" [--enter] fill a field by its name and read it back
|
|
61
|
+
wait-for "<name>" [--gone] [--timeout <s>] until a control or text appears (or goes)
|
|
62
|
+
web-setup fetch Playwright + Chromium once, for the web verbs
|
|
63
|
+
web-open <url> a page in Atlas's browser; your mouse stays yours
|
|
64
|
+
web-click "<what>" press a button or link by what it says (or a selector)
|
|
65
|
+
web-type "<what>" "<text>" [--enter] fill a field by its label
|
|
66
|
+
web-read ["<what>"] the page (or one part) as text
|
|
67
|
+
web-find "<what>" where something is on the page
|
|
68
|
+
web-shot a sharp picture of the page itself
|
|
69
|
+
web-close close Atlas's browser (logins are kept)
|
|
55
70
|
take-over carry on after a person used it by hand
|
|
56
71
|
stop give the computer back
|
|
72
|
+
sessions this project's recordings, with their ids
|
|
73
|
+
rename <id> "<name>" name a recording (an empty name takes it off)
|
|
74
|
+
replay <id> write replay.md: every action, with the frame at that moment
|
|
75
|
+
review <id> have the model read the replay and write review.md
|
|
57
76
|
|
|
58
77
|
--job <project> which project's log this belongs to
|
|
59
78
|
(defaults to $ATLAS_JOB, which a chat already sets)`;
|
|
@@ -61,7 +80,7 @@ const HELP = `atlas computer-use <command>
|
|
|
61
80
|
// Each subcommand as the atlas_env call it really is, and how many values it
|
|
62
81
|
// takes. Mapped rather than hand-written so this cannot invent an operation the
|
|
63
82
|
// helper does not have.
|
|
64
|
-
const OPS = {
|
|
83
|
+
export const OPS = {
|
|
65
84
|
screen: ['screen', 0],
|
|
66
85
|
screens: ['screens', 0],
|
|
67
86
|
'use-screen': ['use_screen', 1],
|
|
@@ -79,6 +98,16 @@ const OPS = {
|
|
|
79
98
|
find: ['find', 1],
|
|
80
99
|
window: ['focus_window', 1],
|
|
81
100
|
cursor: ['get_cursor', 0],
|
|
101
|
+
changed: ['changed', 0],
|
|
102
|
+
code: ['run_code', 1],
|
|
103
|
+
'web-setup': ['web_setup', 0],
|
|
104
|
+
'web-open': ['web_open', 1],
|
|
105
|
+
'web-click': ['web_click', 1],
|
|
106
|
+
'web-type': ['web_type', -1],
|
|
107
|
+
'web-read': ['web_read', -1],
|
|
108
|
+
'web-find': ['web_find', 1],
|
|
109
|
+
'web-shot': ['web_shot', 0],
|
|
110
|
+
'web-close': ['web_close', 0],
|
|
82
111
|
'take-over': ['take_over', 0],
|
|
83
112
|
ready: ['computer_ready', 0],
|
|
84
113
|
// WAIT ESPECIALLY. A sequence without it cannot pause for a page, which is
|
|
@@ -122,6 +151,14 @@ const OPS = {
|
|
|
122
151
|
// ── A PAGE THAT IS TALLER THAN THE SCREEN ─────────────────────────────
|
|
123
152
|
'scroll-to': ['scroll_to', 1],
|
|
124
153
|
'scan-page': ['scan_page', 0],
|
|
154
|
+
// ── CONTROLS, NOT PIXELS ──────────────────────────────────────────────
|
|
155
|
+
// The app's own list of its buttons and fields, read from the system's
|
|
156
|
+
// accessibility tree: named, found again at the moment of the press, and
|
|
157
|
+
// checked afterwards. No picture to read, no coordinates to guess.
|
|
158
|
+
controls: ['controls', -1],
|
|
159
|
+
press: ['press', -1],
|
|
160
|
+
'type-into': ['type_into', -1],
|
|
161
|
+
'wait-for': ['wait_for', -1],
|
|
125
162
|
};
|
|
126
163
|
// Which values are numbers. Passing "840" as a string to click() would move the
|
|
127
164
|
// pointer nowhere and report success. THE SAME SET THE PYTHON CLI USES: a verb
|
|
@@ -131,6 +168,14 @@ const NUMERIC = new Set(['use-screen', 'click', 'double-click', 'right-click',
|
|
|
131
168
|
'middle-click', 'triple-click', 'move', 'drag', 'scroll', 'wait', 'region',
|
|
132
169
|
'scroll-x', 'shift-scroll', 'swipe', 'zoom']);
|
|
133
170
|
|
|
171
|
+
/** "Last name | Smith" -> ["Last name", "Smith"]; "Quantity 25" -> ["Quantity", "25"]. */
|
|
172
|
+
export function splitTarget(text) {
|
|
173
|
+
const bar = text.indexOf('|');
|
|
174
|
+
if (bar >= 0) return [text.slice(0, bar).trim(), text.slice(bar + 1).trim()];
|
|
175
|
+
const words = text.split(/\s+/).filter(Boolean);
|
|
176
|
+
return [words[0] || '', words.slice(1).join(' ')];
|
|
177
|
+
}
|
|
178
|
+
|
|
134
179
|
/** This shell's holding id, made once and remembered. Shared with `atlas run`
|
|
135
180
|
* so everything typed at one prompt lands in ONE session: the log reads as a
|
|
136
181
|
* session rather than a pile of one-action folders, and the claim names
|
|
@@ -201,6 +246,17 @@ export function askServer(job, op, args, root) {
|
|
|
201
246
|
/** Run one atlas_env call inside the project, and hand back what it said. */
|
|
202
247
|
/** What using the computer needs, and what it is called when you import it. */
|
|
203
248
|
const NEEDED = [['pyautogui', 'pyautogui'], ['mss', 'mss'], ['Pillow', 'PIL']];
|
|
249
|
+
/** What reads an app's own buttons and fields (controls(), press()). Never a
|
|
250
|
+
* condition of working: without it the screen and the mouse still do, so it is
|
|
251
|
+
* fetched on its own and a failure costs nothing. Linux reads AT-SPI through
|
|
252
|
+
* the system's python3-gi, which pip cannot put there. */
|
|
253
|
+
export function controlPackages(platform = process.platform) {
|
|
254
|
+
if (platform === 'win32') return [['uiautomation', 'uiautomation']];
|
|
255
|
+
if (platform === 'darwin') {
|
|
256
|
+
return [['pyobjc-framework-ApplicationServices', 'ApplicationServices'], ['pyobjc-framework-Cocoa', 'AppKit']];
|
|
257
|
+
}
|
|
258
|
+
return [];
|
|
259
|
+
}
|
|
204
260
|
|
|
205
261
|
/**
|
|
206
262
|
* Put what is missing into the interpreter that is ABOUT TO BE USED.
|
|
@@ -228,20 +284,44 @@ const NEEDED = [['pyautogui', 'pyautogui'], ['mss', 'mss'], ['Pillow', 'PIL']];
|
|
|
228
284
|
const HIDDEN = { windowsHide: true };
|
|
229
285
|
|
|
230
286
|
export function ensureDeps(python, cwd, env) {
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
287
|
+
const extra = controlPackages();
|
|
288
|
+
// ONE python start answers both: 0 all here, 3 only the control reader
|
|
289
|
+
// missing, 1 the screen and mouse themselves. LOOKED UP, NOT IMPORTED:
|
|
290
|
+
// importing pyautogui on a Linux without tkinter exits the process, so the
|
|
291
|
+
// check failed on every command there and paid a pip run each time.
|
|
292
|
+
const mods = (list) => JSON.stringify(list.map(([, mod]) => mod));
|
|
293
|
+
const check = 'import importlib.util as u, sys\n'
|
|
294
|
+
+ `sys.exit(1 if not all(u.find_spec(m) for m in ${mods(NEEDED)}) else 3 if not all(u.find_spec(m) for m in ${mods(extra)}) else 0)\n`;
|
|
295
|
+
const status = () => spawnSync(python[0], [...python.slice(1), '-c', check],
|
|
296
|
+
{ cwd, env, stdio: 'ignore', ...HIDDEN }).status;
|
|
297
|
+
const pip = (names, timeout) => {
|
|
298
|
+
const inVenv = spawnSync(python[0],
|
|
299
|
+
[...python.slice(1), '-c', 'import sys; sys.exit(0 if sys.prefix != sys.base_prefix else 1)'],
|
|
300
|
+
{ cwd, env, stdio: 'ignore', ...HIDDEN }).status === 0;
|
|
301
|
+
spawnSync(python[0], [...python.slice(1), '-m', 'pip', 'install', '--quiet', ...(inVenv ? [] : ['--user']), ...names],
|
|
302
|
+
{ cwd, env, stdio: 'ignore', timeout, ...HIDDEN });
|
|
303
|
+
};
|
|
304
|
+
const first = status();
|
|
305
|
+
if (first === 0) return true;
|
|
306
|
+
if (first === 3) {
|
|
307
|
+
// Tried once a day per project, not on every command: a machine where it
|
|
308
|
+
// will not install should not pay a pip run per click.
|
|
309
|
+
const mark = path.join(cwd, '.studio', 'controls-tried');
|
|
310
|
+
let last = 0;
|
|
311
|
+
try { last = fs.statSync(mark).mtimeMs; } catch { /* never tried */ }
|
|
312
|
+
if (Date.now() - last > 24 * 3600_000) {
|
|
313
|
+
try { fs.mkdirSync(path.dirname(mark), { recursive: true }); fs.writeFileSync(mark, ''); } catch { /* best effort */ }
|
|
314
|
+
pip(extra.map(([name]) => name), 180000);
|
|
315
|
+
}
|
|
316
|
+
return true;
|
|
317
|
+
}
|
|
235
318
|
println(color.dim(' Getting this computer ready (one time)…'));
|
|
236
|
-
// --user is wrong inside a venv and right outside one, so
|
|
237
|
-
// which it is in rather than guessing.
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
spawnSync(python[0], [...python.slice(1), ...args, ...NEEDED.map(([name]) => name)],
|
|
243
|
-
{ cwd, env, stdio: 'ignore', timeout: 300000, ...HIDDEN });
|
|
244
|
-
return has();
|
|
319
|
+
// --user is wrong inside a venv and right outside one, so pip() asks the
|
|
320
|
+
// interpreter which it is in rather than guessing.
|
|
321
|
+
pip(NEEDED.map(([name]) => name), 300000);
|
|
322
|
+
if (extra.length) pip(extra.map(([name]) => name), 180000);
|
|
323
|
+
const now = status();
|
|
324
|
+
return now === 0 || now === 3;
|
|
245
325
|
}
|
|
246
326
|
|
|
247
327
|
export function runInProject(job, code, root) {
|
|
@@ -423,6 +503,58 @@ export function computerUseCommand(args) {
|
|
|
423
503
|
// here — from the runtime Atlas Studio bundles — so a model in a chat that
|
|
424
504
|
// followed the instructions was told `There is no "do" here` (owner,
|
|
425
505
|
// 2026-09-15: "see if we can unify things instead of diverging").
|
|
506
|
+
// THE RECORDINGS, AND A NAME FOR ONE (owner, 2026-09-17: "for recordings,
|
|
507
|
+
// allow the CLI the option to rename this"). The id stays what it is — the
|
|
508
|
+
// folder, the log, the parts — and the name sits beside them, the same
|
|
509
|
+
// name.json Atlas Studio's own list reads and writes.
|
|
510
|
+
if (sub === 'sessions') {
|
|
511
|
+
const rows = listComputerSessions(job);
|
|
512
|
+
if (!rows.length) { println(color.dim('No recordings for this project yet.')); return 0; }
|
|
513
|
+
for (const s of rows) {
|
|
514
|
+
const when = new Date(s.started * 1000).toLocaleString();
|
|
515
|
+
println(color.cyan(s.id) + ' ' + (s.name ? color.bold(s.name) + color.dim(' · ' + when) : when)
|
|
516
|
+
+ color.dim(` ${s.actions} action${s.actions === 1 ? '' : 's'}${s.video ? ` · ${s.parts} part${s.parts === 1 ? '' : 's'}` : ''}`));
|
|
517
|
+
}
|
|
518
|
+
return 0;
|
|
519
|
+
}
|
|
520
|
+
if (sub === 'rename') {
|
|
521
|
+
const id = String(flags.session || rest.shift() || '').trim();
|
|
522
|
+
const name = String(flags.name ?? rest.join(' ')).trim();
|
|
523
|
+
if (!id) {
|
|
524
|
+
eprintln(color.red('Which recording? `atlas computer-use rename <session-id> "<name>"` (ids: `atlas computer-use sessions`)'));
|
|
525
|
+
return 1;
|
|
526
|
+
}
|
|
527
|
+
const out = renameComputerSession(job, id, name);
|
|
528
|
+
if (!out.ok) { eprintln(color.red(out.error || 'Could not rename that.')); return 1; }
|
|
529
|
+
println(name ? `Named it "${name}".` : 'Name removed.');
|
|
530
|
+
return 0;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// A MODEL WATCHES A SESSION AS FRAMES, NOT FILM (owner, 2026-09-18: "it's
|
|
534
|
+
// just a batch of frames where actions were made"). replay.md lists
|
|
535
|
+
// every action with the picture the run took at that moment; review asks
|
|
536
|
+
// the project's model to read it, open the frames, and write review.md —
|
|
537
|
+
// what worked, what a person had to correct, rules for next time.
|
|
538
|
+
if (sub === 'replay' || sub === 'review') {
|
|
539
|
+
const id = String(flags.session || rest.shift() || '').trim();
|
|
540
|
+
if (!id) {
|
|
541
|
+
eprintln(color.red(`Which recording? \`atlas computer-use ${sub} <session-id>\` (ids: \`atlas computer-use sessions\`)`));
|
|
542
|
+
return 1;
|
|
543
|
+
}
|
|
544
|
+
const board = writeReplay(job, id);
|
|
545
|
+
if (!board.ok) { eprintln(color.red(board.error || 'Could not build the replay.')); return 1; }
|
|
546
|
+
println(color.dim(`replay: ${board.path} (${board.frames} actions, ${board.images} frames)`));
|
|
547
|
+
if (sub === 'replay') return 0;
|
|
548
|
+
return askModel(reviewPrompt(id), { job, model: String(flags.model || '') })
|
|
549
|
+
.then((text) => {
|
|
550
|
+
const said = String(text || '').trim();
|
|
551
|
+
try { fs.writeFileSync(path.join(workspace(job).dir, '.computer-use', id, 'review.md'), said + '\n'); } catch { /* the words are printed either way */ }
|
|
552
|
+
println(said);
|
|
553
|
+
return 0;
|
|
554
|
+
})
|
|
555
|
+
.catch((err) => { eprintln(color.red(`The model could not review it: ${err.message || err}`)); return 1; });
|
|
556
|
+
}
|
|
557
|
+
|
|
426
558
|
if (sub === 'do') {
|
|
427
559
|
if (!rest.length) {
|
|
428
560
|
eprintln(color.red('What should it do? e.g. do "click 820 410" "type hello"'));
|
|
@@ -440,9 +572,13 @@ export function computerUseCommand(args) {
|
|
|
440
572
|
const [name] = OPS[verb];
|
|
441
573
|
// A quoted phrase is one value: `type "hello there"` means two words
|
|
442
574
|
// typed, not a word typed twice.
|
|
443
|
-
|
|
575
|
+
let vals = (['type', 'note', 'find', 'code', 'web-open', 'web-click', 'web-read', 'web-find',
|
|
576
|
+
'controls', 'press', 'wait-for'].includes(verb))
|
|
444
577
|
? (bits.length > 1 ? [bits.slice(1).join(' ')] : [])
|
|
445
578
|
: bits.slice(1).map((v) => (NUMERIC.has(verb) ? Number(v) : v));
|
|
579
|
+
// A name can be several words, so the text comes after a "|":
|
|
580
|
+
// "type-into Last name | Smith". Without one, the first word is the name.
|
|
581
|
+
if (verb === 'type-into') vals = splitTarget(bits.slice(1).join(' '));
|
|
446
582
|
steps.push([name, ...vals]);
|
|
447
583
|
}
|
|
448
584
|
const code = 'import atlas_env as e, json\n'
|
|
@@ -456,20 +592,62 @@ export function computerUseCommand(args) {
|
|
|
456
592
|
return 1;
|
|
457
593
|
}
|
|
458
594
|
const [name, count] = OPS[sub];
|
|
595
|
+
// `press --double Save` reads "Save" as the flag's value; a switch never has
|
|
596
|
+
// one, so that word goes back where it was meant to go.
|
|
597
|
+
const on = (flag) => {
|
|
598
|
+
const v = flags[flag];
|
|
599
|
+
if (typeof v === 'string') { rest.push(v); return true; }
|
|
600
|
+
return Boolean(v);
|
|
601
|
+
};
|
|
602
|
+
const right = on('right'); const twice = on('double'); const gone = on('gone'); const enter = on('enter');
|
|
459
603
|
if (count >= 0 && rest.length !== count) {
|
|
460
604
|
eprintln(color.red(`${sub} takes ${count} value${count === 1 ? '' : 's'}.`));
|
|
461
605
|
return 1;
|
|
462
606
|
}
|
|
463
|
-
if (count === -1 && !rest.length) {
|
|
607
|
+
if (count === -1 && !rest.length && sub !== 'web-read' && sub !== 'controls') {
|
|
464
608
|
eprintln(color.red(`${sub} needs at least one.`));
|
|
465
609
|
return 1;
|
|
466
610
|
}
|
|
467
|
-
|
|
611
|
+
let values = rest.map((v) => (NUMERIC.has(sub) ? Number(v) : v));
|
|
612
|
+
// A block of code, a sentence to find, an address: the words are one value.
|
|
613
|
+
if (['code', 'note', 'find', 'type', 'web-open', 'web-click', 'web-read', 'web-find'].includes(sub) && rest.length > 1) {
|
|
614
|
+
values = [rest.join(' ')];
|
|
615
|
+
}
|
|
616
|
+
if (sub === 'press' || sub === 'wait-for' || sub === 'controls') {
|
|
617
|
+
values = rest.length ? [rest.join(' ')] : [];
|
|
618
|
+
if (sub === 'press') {
|
|
619
|
+
values.push(right ? 'right' : 'left', twice ? 2 : 1);
|
|
620
|
+
// How long it may wait for the control to appear, stop being grey, or
|
|
621
|
+
// come out from under something (default 5s).
|
|
622
|
+
if (flags.wait !== undefined) values.push(null, 1.0, Number(flags.wait));
|
|
623
|
+
}
|
|
624
|
+
if (sub === 'wait-for') {
|
|
625
|
+
values.push(gone, flags.timeout !== undefined ? Number(flags.timeout) : 10);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
if (sub === 'type-into') {
|
|
629
|
+
if (rest.length < 2) {
|
|
630
|
+
eprintln(color.red('type-into takes the field and the text: type-into "Quantity" "25" [--enter]'));
|
|
631
|
+
return 1;
|
|
632
|
+
}
|
|
633
|
+
values = [rest[0], rest.slice(1).join(' '), enter];
|
|
634
|
+
if (flags.wait !== undefined) values.push(true, null, Number(flags.wait));
|
|
635
|
+
}
|
|
636
|
+
if (sub === 'web-type') {
|
|
637
|
+
if (rest.length < 2) {
|
|
638
|
+
eprintln(color.red('web-type takes the field and the text: web-type "Email" "me@x.com" [--enter]'));
|
|
639
|
+
return 1;
|
|
640
|
+
}
|
|
641
|
+
values = [rest[0], rest.slice(1).join(' '), Boolean(flags.enter)];
|
|
642
|
+
}
|
|
468
643
|
// THE LIVE PROCESS FIRST: same function, same rules, without a python start.
|
|
469
644
|
const answer = askServer(job, name, values);
|
|
470
645
|
if (answer) {
|
|
471
646
|
if (answer.ok) {
|
|
472
|
-
|
|
647
|
+
// THE LIST AS LINES: one control per line is what gets read, and the
|
|
648
|
+
// same list as JSON is five times the words to read it in.
|
|
649
|
+
if (sub === 'controls' && answer.out && answer.out.summary) println(answer.out.summary);
|
|
650
|
+
else println(JSON.stringify(answer.out, null, 2));
|
|
473
651
|
return 0;
|
|
474
652
|
}
|
|
475
653
|
eprintln(color.red(String(answer.error || 'That did not work.')));
|
|
@@ -479,6 +657,8 @@ export function computerUseCommand(args) {
|
|
|
479
657
|
// pasting is a snippet that breaks on somebody's password.
|
|
480
658
|
const code = 'import atlas_env as e, json\n'
|
|
481
659
|
+ `out = e.${name}(*json.loads(${JSON.stringify(JSON.stringify(values))}))\n`
|
|
482
|
-
+
|
|
660
|
+
+ (sub === 'controls'
|
|
661
|
+
? 'print(out["summary"] if isinstance(out, dict) and out.get("summary") else json.dumps(out, default=str))\n'
|
|
662
|
+
: 'print(json.dumps(out, default=str))\n');
|
|
483
663
|
return runInProject(job, code);
|
|
484
664
|
}
|