mindvest-atlas 0.8.0 → 0.9.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mindvest-atlas",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Atlas CLI \u2014 OAuth login, tool calls, and live alert/flow streaming for the Atlas trading API",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,9 +17,10 @@ import { color, eprintln } from '../util/ui.js';
17
17
  import {
18
18
  DEFAULT_TIMEOUT_MS, claimRun, computerName, isPaused, jobEnv, listJobFiles, listRuns, machineId,
19
19
  osLabel, readJobFile, releaseRun, staleness, summarize, tail, workspace, workspaceRoot,
20
- setPaused, writeHelpers,
20
+ setPaused, writeHelpers, askModel, listModels,
21
21
  } from '../remote-control.js';
22
22
  import fs from 'node:fs';
23
+ import os from 'node:os';
23
24
  import path from 'node:path';
24
25
 
25
26
  const iso = (ms) => new Date(ms).toISOString().replace(/\.\d+Z$/, 'Z');
@@ -104,18 +105,39 @@ async function handleAsk(ask, root) {
104
105
  }
105
106
  if (ask.op === 'runs') return answer(ask.request_id, { ok: true, runs: listRuns(root) });
106
107
  if (ask.op === 'read') return answer(ask.request_id, readJobFile(ask.job || '', ask.path || '', root));
108
+ if (ask.op === 'models') {
109
+ void listModels().then((m) => answer(ask.request_id, { ok: true, ...m }));
110
+ return undefined;
111
+ }
112
+ if (ask.op === 'build') {
113
+ // THE POINT OF ALL OF THIS. Words in, working script out: the owner says
114
+ // what they want in one sentence and their own model writes it, runs it,
115
+ // and fixes it here. No terminal, no questions asked back, no code shown
116
+ // unless they go looking. Deliberately NOT awaited — the model writes AND
117
+ // runs, which takes minutes, and the stream must keep serving everyone else.
118
+ void askModel(ask.prompt || '', { job: ask.job || '' })
119
+ .then((a) => answer(ask.request_id, { ok: true, answer: a }))
120
+ .catch((err) => answer(ask.request_id, { ok: false, error: String(err.message || err) }));
121
+ return undefined;
122
+ }
107
123
  return answer(ask.request_id, { ok: false, error: 'Unknown request.' });
108
124
  }
109
125
 
110
126
  /**
111
127
  * How many projects may run at once.
112
128
  *
113
- * Different projects are genuinely independent — own folder, own .env, own
114
- * single run — so serialising them would make a five-minute job delay a
115
- * five-second one for no reason. Capped so a burst of due schedules cannot
116
- * fork-bomb a laptop.
129
+ * Scaled to the machine rather than fixed at a timid number: these are mostly
130
+ * IO-bound jobs waiting on an API, so a modern laptop can carry far more than
131
+ * four, and holding a job back on an idle machine helps nobody. The floor of 4
132
+ * covers a single-core VM; the ceiling of 16 leaves the computer usable for the
133
+ * person sitting at it, which is the actual constraint — this is their machine,
134
+ * not a build server.
135
+ *
136
+ * This bounds RUNS, not threads. A single run is free to use every core it can
137
+ * (see the build prompt): one run per project is about not racing two copies of
138
+ * the same script over one folder, never about how hard that script may work.
117
139
  */
118
- const MAX_PARALLEL = 4;
140
+ const MAX_PARALLEL = Math.max(4, Math.min(16, os.cpus().length || 4));
119
141
  let active = 0;
120
142
  const waiting = [];
121
143
 
@@ -196,6 +218,9 @@ async function runJob(job, root, timeoutMs) {
196
218
  ATLAS_TOKEN: await jobToken(),
197
219
  ...(job.env && typeof job.env === 'object'
198
220
  ? Object.fromEntries(Object.entries(job.env).map(([k, v]) => [String(k), String(v)])) : {}),
221
+ // Which job is asking. The ask bridge keys the model's session on this, so
222
+ // one job's questions stay one conversation and do not blend into another's.
223
+ ATLAS_JOB: path.basename(dir),
199
224
  ATLAS_JOB_PAYLOAD: JSON.stringify(job.payload || {}),
200
225
  };
201
226
  const r = await runOnce(argv, dir, env, timeoutMs);
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.8.0';
5
+ export const VERSION = '0.8.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';
@@ -28,8 +28,15 @@ export const DEFAULT_ROOT = '~/AtlasStudio';
28
28
  * so nothing has to be declared anywhere.
29
29
  */
30
30
  export const ENTRIES = ['main.py', 'main.js', 'main.mjs'];
31
- /** Hard ceiling on one job, so a hung script cannot hold the runner forever. */
32
- export const DEFAULT_TIMEOUT_MS = 600_000;
31
+ /**
32
+ * Hard ceiling on one job, so a hung script cannot hold a slot forever.
33
+ *
34
+ * Half an hour, not ten minutes: real work — a chain walk, a wide scan, a model
35
+ * call in the middle — routinely runs past ten, and killing an honest job
36
+ * because the limit was set for a toy one is the worse failure. A script that is
37
+ * genuinely stuck is still stopped.
38
+ */
39
+ export const DEFAULT_TIMEOUT_MS = 1_800_000;
33
40
 
34
41
  export function workspaceRoot(override) {
35
42
  const raw = override || process.env.ATLAS_LOCAL_DIR
@@ -481,3 +488,56 @@ export function listRuns(root, limit = 50) {
481
488
  runs.sort((a, b) => b.started_at - a.started_at);
482
489
  return runs.slice(0, limit);
483
490
  }
491
+
492
+ /**
493
+ * Hand a plain-English instruction to the model on THIS computer.
494
+ *
495
+ * This is how "watch SPY and buy the dip" becomes a working script without
496
+ * anyone opening a terminal: the words go straight to the owner's own model
497
+ * over ACP, which writes the script into the job's folder, runs it, reads its
498
+ * own errors and fixes them until it works.
499
+ *
500
+ * Returns the model's account of what it did. Throws if no model is connected.
501
+ */
502
+ export async function askModel(prompt, { job = '', tools = true, fresh = false, timeoutMs = 900000 } = {}) {
503
+ const url = process.env.ATLAS_ASK_URL;
504
+ const token = process.env.ATLAS_ASK_TOKEN;
505
+ if (!url || !token) {
506
+ throw new Error(
507
+ 'No model is connected on this computer. Open Atlas Remote Control, '
508
+ + 'connect a model, and turn Local Mode on.'
509
+ );
510
+ }
511
+ const res = await fetch(url, {
512
+ method: 'POST',
513
+ headers: { 'Content-Type': 'application/json', 'X-Atlas-Token': token },
514
+ body: JSON.stringify({ job, prompt: String(prompt), tools, fresh }),
515
+ signal: AbortSignal.timeout(timeoutMs),
516
+ });
517
+ const out = await res.json().catch(() => ({}));
518
+ if (!res.ok || !out.ok) throw new Error(out.error || `The model could not answer (${res.status}).`);
519
+ return out.answer || '';
520
+ }
521
+
522
+ /**
523
+ * Every model this computer can build with, and which one it uses by default.
524
+ *
525
+ * Names only. Returns an empty catalog rather than throwing when no model is
526
+ * connected, because "none yet" is a state the UI shows, not an error.
527
+ */
528
+ export async function listModels(timeoutMs = 15000) {
529
+ const url = process.env.ATLAS_ASK_URL;
530
+ const token = process.env.ATLAS_ASK_TOKEN;
531
+ const empty = { models: [], default: null, agent: null };
532
+ if (!url || !token) return empty;
533
+ try {
534
+ const res = await fetch(url.replace('/ask', '/models'), {
535
+ headers: { 'X-Atlas-Token': token },
536
+ signal: AbortSignal.timeout(timeoutMs),
537
+ });
538
+ if (!res.ok) return empty;
539
+ return await res.json();
540
+ } catch {
541
+ return empty;
542
+ }
543
+ }