mindvest-atlas 0.96.1 → 0.97.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.96.1",
3
+ "version": "0.97.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": {
@@ -21,7 +21,7 @@ import {
21
21
  takeActionCommands, writeActionReply, actionArgv, runAction, actionHandler, claimAsk, REPLIES_DIR, COMMANDS_DIR,
22
22
  writeCommand,
23
23
  beatRunner,
24
- setPaused, writeHelpers, askModel, listModels, chatModel, answerPermission,
24
+ setPaused, writeHelpers, askModel, listModels, chatModel, answerPermission, stopChat, chatState,
25
25
  createProject, ensureProjectFiles, listChats, readChat, deleteChat, appendChat,
26
26
  computerUseAllowed, listComputerSessions, readComputerActions, listComputerVideo, readComputerVideoPart,
27
27
  sessionReplay, venvPython, ambientPython,
@@ -431,7 +431,7 @@ async function handleAsk(ask, root) {
431
431
  };
432
432
  const modelUsed = ask.model || '';
433
433
  if (job && chatId) appendChat(job, chatId, 'user', ask.prompt || '', root, undefined, modelUsed);
434
- void chatModel(ask.prompt || '', send, { job, model: ask.model || '', chatId })
434
+ void chatModel(ask.prompt || '', send, { job, model: ask.model || '', chatId, instead: ask.instead === true })
435
435
  .catch((err) => send({ kind: 'error', message: String(err?.message || err) }))
436
436
  .finally(async () => {
437
437
  if (job && chatId) appendChat(job, chatId, 'assistant', said.join(''), root, undefined, modelUsed);
@@ -442,6 +442,17 @@ async function handleAsk(ask, root) {
442
442
  });
443
443
  return undefined;
444
444
  }
445
+ if (ask.op === 'chat_stop') {
446
+ // Stop, pressed on the web or said to the voice there: its own ask, like
447
+ // an answer to a question, because the chat stream is one-way.
448
+ void stopChat(ask.job || '', ask.chat_id || '', { computer: ask.computer === true, ahead: ask.ahead === true }).then((r) => answer(ask.request_id, r));
449
+ return undefined;
450
+ }
451
+ if (ask.op === 'chat_state') {
452
+ // Is a run going in that conversation: asked by a page that did not start it.
453
+ void chatState(ask.job || '', ask.chat_id || '').then((r) => answer(ask.request_id, r));
454
+ return undefined;
455
+ }
445
456
  if (ask.op === 'permission') {
446
457
  // The person's answer to a question the model asked mid-turn. Its own ask,
447
458
  // not part of the chat stream: the stream is one-way. `permission_id` is
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.96.1';
5
+ export const VERSION = '0.97.0';
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';
@@ -2,16 +2,93 @@
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import url_ from "node:url";
5
+ import os_ from "node:os";
6
+ import childProcess_ from "node:child_process";
5
7
  //
6
8
  // Values come from the environment Atlas set up before starting this script. Do
7
9
  // NOT open .env: this module is the supported way to reach a secret, and the
8
10
  // file itself is not something a script or a model needs to read.
9
11
 
10
- /** The value of one secret, for USE — never for printing or logging. */
11
- export const secret = (name, fallback) => process.env[name] ?? fallback;
12
+ /**
13
+ * What this project's own .env, and the shared one above it, say NOW.
14
+ *
15
+ * A script Atlas starts has every key in its environment already. Two cases
16
+ * did not, and both were told a key "is not filled in" while it sat in the
17
+ * file: a listener that had been running since before the key was typed in,
18
+ * and a script run by hand, by its owner or by a model working in the project.
19
+ * The file is read HERE, in the one module that is the supported way to reach
20
+ * a secret. Nothing is printed and nothing is kept.
21
+ */
22
+ function fileSecrets() {
23
+ const out = {};
24
+ const here = path.dirname(url_.fileURLToPath(import.meta.url));
25
+ let root = process.env.ATLAS_LIBRARY_DIR || process.env.ATLAS_LOCAL_DIR || path.dirname(path.dirname(here));
26
+ if (root.startsWith("~")) root = path.join(os_.homedir(), root.slice(1));
27
+ // The shared file first, then the project's own on top: the runner's order.
28
+ for (const file of [path.join(root, ".env"), path.join(here, ".env")]) {
29
+ let text;
30
+ try { text = fs.readFileSync(file, "utf8"); } catch { continue; }
31
+ for (const raw of text.split("\n")) {
32
+ const line = raw.trim();
33
+ if (!line || line.startsWith("#") || !line.includes("=")) continue;
34
+ const at = line.indexOf("=");
35
+ const k = line.slice(0, at).trim();
36
+ let v = line.slice(at + 1).trim();
37
+ if (v.length >= 2 && v[0] === v[v.length - 1] && (v[0] === String.fromCharCode(34) || v[0] === String.fromCharCode(39))) v = v.slice(1, -1);
38
+ if (k) out[k] = v;
39
+ }
40
+ }
41
+ return out;
42
+ }
43
+
44
+ /** The value of one secret, for USE — never for printing or logging. From the
45
+ * environment Atlas started this script with; when it is not there, from the
46
+ * project's .env as it stands now. */
47
+ export const secret = (name, fallback) => process.env[name] || fileSecrets()[name] || fallback;
12
48
 
13
49
  /** Whether a secret has been filled in. True/false only, never the value. */
14
- export const hasSecret = (name) => Boolean(process.env[name]);
50
+ export const hasSecret = (name) => Boolean(secret(name));
51
+
52
+ /** This computer's own Atlas sign-in, as the atlas CLI keeps it. */
53
+ function signedIn() {
54
+ const dir = process.env.ATLAS_CONFIG_DIR
55
+ || (process.env.XDG_CONFIG_HOME ? path.join(process.env.XDG_CONFIG_HOME, "atlas") : path.join(os_.homedir(), ".config", "atlas"));
56
+ try {
57
+ const found = JSON.parse(fs.readFileSync(path.join(dir, "credentials.json"), "utf8"));
58
+ return found && typeof found === "object" ? found : {};
59
+ } catch { return {}; }
60
+ }
61
+
62
+ /**
63
+ * The bearer this script calls Atlas with. Never print it or pass it on.
64
+ *
65
+ * Started by Atlas, a script is handed it in its environment. RUN BY HAND (by
66
+ * its owner in a terminal, or by a model working in the project) it had none,
67
+ * and every call failed with "no Atlas token" although the person was signed
68
+ * in on this very computer. It now uses that sign-in: the file the atlas CLI
69
+ * itself keeps, readable only by this computer's own user, which is the same
70
+ * person a script started by Atlas acts as.
71
+ *
72
+ * What this does NOT do, on purpose: no token is written into the project,
73
+ * none is printed, none is copied anywhere. Somebody on another computer, or
74
+ * another user of this one, has no such file and is refused as before. A
75
+ * sign-in that has run out is renewed by the CLI itself (its output thrown
76
+ * away), never here.
77
+ */
78
+ function atlasToken() {
79
+ if (process.env.ATLAS_TOKEN) return process.env.ATLAS_TOKEN;
80
+ const usable = (found) => (found.access_token && (!found.expires_at || Date.now() < Number(found.expires_at) - 60000)
81
+ ? String(found.access_token) : "");
82
+ const token = usable(signedIn());
83
+ if (token) return token;
84
+ try {
85
+ childProcess_.execFileSync("atlas", ["whoami"], { stdio: "ignore", timeout: 30000, shell: process.platform === "win32" });
86
+ } catch { /* not signed in, or no atlas on this computer */ }
87
+ return usable(signedIn());
88
+ }
89
+
90
+ const NO_TOKEN = "This job has no Atlas token. Is Local Mode signed in? "
91
+ + "(Run by hand, a script uses the sign-in on this computer: run `atlas login` once.)";
15
92
 
16
93
  /**
17
94
  * Call an Atlas tool over the REST API. Returns the parsed JSON.
@@ -28,8 +105,8 @@ export const hasSecret = (name) => Boolean(process.env[name]);
28
105
  */
29
106
  export async function atlas(tool, params = {}) {
30
107
  const base = (process.env.ATLAS_BASE_URL || "https://atlasmcp.finmanagerai.com").replace(/\/+$/, "");
31
- const token = process.env.ATLAS_TOKEN;
32
- if (!token) throw new Error("This job has no Atlas token. Is Local Mode signed in?");
108
+ const token = atlasToken();
109
+ if (!token) throw new Error(NO_TOKEN);
33
110
  const res = await fetch(`${base}/api/v1/tools/${tool}`, {
34
111
  method: "POST",
35
112
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
@@ -232,8 +309,8 @@ function alertMatches(event, match) {
232
309
  */
233
310
  export async function* alerts({ match = null, timeoutMs = null, reconnect = true } = {}) {
234
311
  const base = (process.env.ATLAS_BASE_URL || "https://atlasmcp.finmanagerai.com").replace(/\/+$/, "");
235
- const token = process.env.ATLAS_TOKEN;
236
- if (!token) throw new Error("This job has no Atlas token. Is Local Mode signed in?");
312
+ const token = atlasToken();
313
+ if (!token) throw new Error(NO_TOKEN);
237
314
  const deadline = timeoutMs == null ? null : Date.now() + Number(timeoutMs);
238
315
  let attempt = 0;
239
316
  while (deadline == null || Date.now() < deadline) {
@@ -8,14 +8,123 @@ import os
8
8
  import sys
9
9
 
10
10
 
11
+ def _file_secrets():
12
+ """What this project's own .env, and the shared one above it, say NOW.
13
+
14
+ A script Atlas starts has every key in its environment already. Two cases
15
+ did not, and both were told a key "is not filled in" while it sat in the
16
+ file: a listener that had been running since before the key was typed in
17
+ (its environment is the one it started with), and a script run by hand, by
18
+ its owner or by a model working in the project. So one script could say yes
19
+ and another no about the same key at the same moment.
20
+
21
+ The file is read HERE, in the one module that is the supported way to reach
22
+ a secret. Nothing is printed, nothing is kept, and a script or a model
23
+ still has no reason to open .env itself.
24
+ """
25
+ out = {}
26
+ here = os.path.dirname(os.path.abspath(__file__))
27
+ root = (os.environ.get("ATLAS_LIBRARY_DIR") or os.environ.get("ATLAS_LOCAL_DIR")
28
+ or os.path.dirname(os.path.dirname(here)))
29
+ quotes = chr(34) + chr(39)
30
+ # The shared file first, then the project's own on top: the runner's order.
31
+ for path in (os.path.join(os.path.expanduser(root), ".env"), os.path.join(here, ".env")):
32
+ try:
33
+ with open(path, encoding="utf-8") as f:
34
+ text = f.read()
35
+ except Exception:
36
+ continue
37
+ for raw in text.split("\n"):
38
+ line = raw.strip()
39
+ if not line or line.startswith("#") or "=" not in line:
40
+ continue
41
+ k, v = line.split("=", 1)
42
+ k, v = k.strip(), v.strip()
43
+ if len(v) >= 2 and v[0] == v[-1] and v[0] in quotes:
44
+ v = v[1:-1]
45
+ if k:
46
+ out[k] = v
47
+ return out
48
+
49
+
11
50
  def secret(name, default=None):
12
- """The value of one secret, for USE — never for printing or logging."""
13
- return os.environ.get(name, default)
51
+ """The value of one secret, for USE — never for printing or logging.
52
+
53
+ From the environment Atlas started this script with; when it is not there,
54
+ from the project's .env as it stands now (see _file_secrets).
55
+ """
56
+ v = os.environ.get(name)
57
+ if v:
58
+ return v
59
+ v = _file_secrets().get(name)
60
+ return v if v else default
14
61
 
15
62
 
16
63
  def has_secret(name):
17
64
  """Whether a secret has been filled in. True/False only, never the value."""
18
- return bool(os.environ.get(name))
65
+ return bool(secret(name))
66
+
67
+
68
+ def _signed_in():
69
+ """This computer's own Atlas sign-in, as the atlas CLI keeps it."""
70
+ import json as _json
71
+ base = os.environ.get("ATLAS_CONFIG_DIR") or (
72
+ os.path.join(os.environ["XDG_CONFIG_HOME"], "atlas") if os.environ.get("XDG_CONFIG_HOME")
73
+ else os.path.join(os.path.expanduser("~"), ".config", "atlas"))
74
+ try:
75
+ with open(os.path.join(base, "credentials.json"), encoding="utf-8") as f:
76
+ found = _json.load(f)
77
+ return found if isinstance(found, dict) else {}
78
+ except Exception:
79
+ return {}
80
+
81
+
82
+ def _atlas_token():
83
+ """The bearer this script calls Atlas with. Never print it or pass it on.
84
+
85
+ Started by Atlas, a script is handed it in its environment. RUN BY HAND
86
+ (by its owner in a terminal, or by a model working in the project) it had
87
+ none, and every call failed with "no Atlas token" although the person was
88
+ signed in on this very computer. It now uses that sign-in: the file the
89
+ atlas CLI itself keeps, readable only by this computer's own user, which is
90
+ the same person a script started by Atlas acts as.
91
+
92
+ What this does NOT do, on purpose: no token is written into the project,
93
+ none is printed, none is copied anywhere. Somebody on another computer, or
94
+ another user of this one, has no such file and is refused as before. A
95
+ sign-in that has run out is renewed by the CLI itself (its output thrown
96
+ away), never here.
97
+ """
98
+ token = os.environ.get("ATLAS_TOKEN", "")
99
+ if token:
100
+ return token
101
+ import time as _time
102
+
103
+ def usable(found):
104
+ got = found.get("access_token") or ""
105
+ ends = found.get("expires_at")
106
+ try:
107
+ still = (not ends) or _time.time() * 1000 < float(ends) - 60000
108
+ except Exception:
109
+ still = False
110
+ return str(got) if got and still else ""
111
+
112
+ token = usable(_signed_in())
113
+ if token:
114
+ return token
115
+ try:
116
+ import shutil as _shutil
117
+ import subprocess as _sp
118
+ cli = _shutil.which("atlas")
119
+ if cli:
120
+ _sp.run([cli, "whoami"], stdout=_sp.DEVNULL, stderr=_sp.DEVNULL, stdin=_sp.DEVNULL, timeout=30)
121
+ except Exception:
122
+ pass
123
+ return usable(_signed_in())
124
+
125
+
126
+ _NO_TOKEN = ("This job has no Atlas token. Is Local Mode signed in? "
127
+ "(Run by hand, a script uses the sign-in on this computer: run `atlas login` once.)")
19
128
 
20
129
 
21
130
  #: Tools that read a lot of options flow: minutes on a busy name, not seconds.
@@ -50,9 +159,9 @@ def atlas(tool, **params):
50
159
  slug = slug[4:] if slug.startswith("get_") else slug
51
160
  wait = params.pop("_timeout", None) or (600 if slug in _SLOW_TOOLS else 120)
52
161
  base = os.environ.get("ATLAS_BASE_URL", "https://atlasmcp.finmanagerai.com").rstrip("/")
53
- token = os.environ.get("ATLAS_TOKEN", "")
162
+ token = _atlas_token()
54
163
  if not token:
55
- raise RuntimeError("This job has no Atlas token. Is Local Mode signed in?")
164
+ raise RuntimeError(_NO_TOKEN)
56
165
  req = urllib.request.Request(
57
166
  "%s/api/v1/tools/%s" % (base, tool),
58
167
  data=_json.dumps(params).encode("utf-8"),
@@ -370,9 +479,9 @@ def alerts(match=None, timeout=None, reconnect=True):
370
479
  import urllib.request
371
480
 
372
481
  base = os.environ.get("ATLAS_BASE_URL", "https://atlasmcp.finmanagerai.com").rstrip("/")
373
- token = os.environ.get("ATLAS_TOKEN", "")
482
+ token = _atlas_token()
374
483
  if not token:
375
- raise RuntimeError("This job has no Atlas token. Is Local Mode signed in?")
484
+ raise RuntimeError(_NO_TOKEN)
376
485
  deadline = None if timeout is None else _time.time() + float(timeout)
377
486
  attempt = 0
378
487
  while True:
@@ -2101,7 +2101,7 @@ export async function studioVoice(name, args, allowed, root, opts = {}) {
2101
2101
  * running, an error — so the person watching on the web sees the build happen
2102
2102
  * rather than staring at nothing for two minutes and assuming it hung.
2103
2103
  */
2104
- export async function chatModel(prompt, onEvent, { job = '', model = '', chatId = '', timeoutMs = 1800000 } = {}) {
2104
+ export async function chatModel(prompt, onEvent, { job = '', model = '', chatId = '', instead = false, timeoutMs = 1800000 } = {}) {
2105
2105
  const url = process.env.ATLAS_ASK_URL;
2106
2106
  const token = process.env.ATLAS_ASK_TOKEN;
2107
2107
  if (!url || !token) {
@@ -2117,7 +2117,10 @@ export async function chatModel(prompt, onEvent, { job = '', model = '', chatId
2117
2117
  // chat_id: which conversation this is, so Studio gives each web chat its
2118
2118
  // own model session and a Studio chat carried on here reaches the session
2119
2119
  // already open in the app (0.87.0).
2120
- body: JSON.stringify({ job, prompt: String(prompt), model, chat_id: chatId }),
2120
+ // instead: said to a run that is working, to change what it is doing.
2121
+ // Studio ends the turn in progress and this one takes its place (0.97.0).
2122
+ // Left out unless asked for, so an ordinary message is what it always was.
2123
+ body: JSON.stringify({ job, prompt: String(prompt), model, chat_id: chatId, ...(instead ? { instead: true } : {}) }),
2121
2124
  signal: AbortSignal.timeout(timeoutMs),
2122
2125
  });
2123
2126
  if (!res.ok || !res.body) {
@@ -2148,6 +2151,69 @@ export async function chatModel(prompt, onEvent, { job = '', model = '', chatId
2148
2151
  }
2149
2152
  }
2150
2153
 
2154
+ /**
2155
+ * Stop the turn a conversation has in flight on THIS computer (0.97.0).
2156
+ *
2157
+ * Stop pressed on the web used to end only the browser's own stream: nothing
2158
+ * told this computer, so the model carried on, mouse and keyboard included,
2159
+ * and the next message waited behind it. A closed stream cannot mean stop (a
2160
+ * shut laptop looks the same, and a build must outlive that), so Stop is its
2161
+ * own ask, and this passes it to the bridge. `stopped` false when nothing of
2162
+ * that conversation was running.
2163
+ */
2164
+ export async function stopChat(job, chatId, { computer = false, ahead = false, timeoutMs = 15000 } = {}) {
2165
+ const url = process.env.ATLAS_ASK_URL;
2166
+ const token = process.env.ATLAS_ASK_TOKEN;
2167
+ if (!url || !token) return { ok: false, error: 'No model is connected on this computer.' };
2168
+ try {
2169
+ const res = await fetch(url.replace('/ask', '/chat/stop'), {
2170
+ method: 'POST',
2171
+ headers: { 'Content-Type': 'application/json', 'X-Atlas-Token': token },
2172
+ // computer: let go of the mouse and keyboard too (Stop on the live picture).
2173
+ // ahead: the page's own message is still waiting behind another run, and
2174
+ // it is that run that is to end (the message then takes its turn).
2175
+ body: JSON.stringify({ job: String(job || ''), chat_id: String(chatId || ''), ...(computer ? { computer: true } : {}), ...(ahead ? { ahead: true } : {}) }),
2176
+ signal: AbortSignal.timeout(timeoutMs),
2177
+ });
2178
+ const out = await res.json().catch(() => ({}));
2179
+ // An Atlas Studio from before this existed answers the address as a chat
2180
+ // with nothing said (400) or as not found: said plainly, so the web can
2181
+ // tell the person the stop did not reach the model.
2182
+ if (!res.ok || !out.ok) return { ok: false, error: 'Atlas Studio on this computer is out of date and cannot be stopped from here. Open it and let it update.' };
2183
+ return { ok: true, stopped: out.stopped === true };
2184
+ } catch (err) {
2185
+ return { ok: false, error: String(err?.message || err) };
2186
+ }
2187
+ }
2188
+
2189
+ /**
2190
+ * Is a run going in this conversation on THIS computer (0.97.0)?
2191
+ *
2192
+ * A web page that did not start the run (another tab, a phone, the app
2193
+ * itself), or that started it and was then reloaded, had no way to know one
2194
+ * was going, so it showed no Stop. It asks, and Atlas Studio answers yes or
2195
+ * no, and whether the page's own message is still waiting behind it.
2196
+ */
2197
+ export async function chatState(job, chatId, { timeoutMs = 8000 } = {}) {
2198
+ const url = process.env.ATLAS_ASK_URL;
2199
+ const token = process.env.ATLAS_ASK_TOKEN;
2200
+ if (!url || !token) return { ok: false, error: 'No model is connected on this computer.' };
2201
+ try {
2202
+ const res = await fetch(url.replace('/ask', '/chat/state'), {
2203
+ method: 'POST',
2204
+ headers: { 'Content-Type': 'application/json', 'X-Atlas-Token': token },
2205
+ body: JSON.stringify({ job: String(job || ''), chat_id: String(chatId || '') }),
2206
+ signal: AbortSignal.timeout(timeoutMs),
2207
+ });
2208
+ const out = await res.json().catch(() => ({}));
2209
+ // An Atlas Studio from before this existed cannot say.
2210
+ if (!res.ok || !out.ok) return { ok: false, error: 'Atlas Studio on this computer is out of date and cannot say what is running. Open it and let it update.' };
2211
+ return { ok: true, running: out.running === true, waiting: out.waiting === true };
2212
+ } catch (err) {
2213
+ return { ok: false, error: String(err?.message || err) };
2214
+ }
2215
+ }
2216
+
2151
2217
  /**
2152
2218
  * Hand the model the person's answer to a question it asked mid-turn.
2153
2219
  *