mindvest-atlas 0.9.0 → 0.9.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mindvest-atlas",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
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,7 +17,7 @@ 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, askModel, listModels,
20
+ setPaused, writeHelpers, askModel, listModels, chatModel,
21
21
  } from '../remote-control.js';
22
22
  import fs from 'node:fs';
23
23
  import os from 'node:os';
@@ -97,7 +97,14 @@ async function answer(requestId, payload) {
97
97
  * confined to the jobs folder: it can list what is there and show one file; it
98
98
  * cannot write, delete, or reach outside. */
99
99
  async function handleAsk(ask, root) {
100
+ // ADDRESSED TO ONE MACHINE. The ask channel is per-USER, so every computer
101
+ // this person owns hears every question. Fine for "list your files" — they
102
+ // each answer about themselves — but a BUILD must land on the machine they
103
+ // picked, not on whichever happens to reply first. An ask naming a computer
104
+ // is ignored by every other one; an ask naming none is for anybody.
100
105
  if (!ask?.request_id) return;
106
+ const want = String(ask.machine_id || '').trim();
107
+ if (want && want !== machineId()) return undefined;
101
108
  if (ask.op === 'list') return answer(ask.request_id, { ok: true, jobs: listJobFiles(root) });
102
109
  if (ask.op === 'pause') {
103
110
  const { dir } = workspace(ask.job || '', root);
@@ -105,6 +112,17 @@ async function handleAsk(ask, root) {
105
112
  }
106
113
  if (ask.op === 'runs') return answer(ask.request_id, { ok: true, runs: listRuns(root) });
107
114
  if (ask.op === 'read') return answer(ask.request_id, readJobFile(ask.job || '', ask.path || '', root));
115
+ if (ask.op === 'chat') {
116
+ // A person typing at the web, talking to the model on this machine. Each
117
+ // event is answered SEPARATELY against the same request id, so the web sees
118
+ // the build as it happens; the server relays and stores none of it.
119
+ let seq = 0;
120
+ const send = (ev) => answer(ask.request_id, { ok: true, stream: true, seq: ++seq, ...ev });
121
+ void chatModel(ask.prompt || '', send, { job: ask.job || '', model: ask.model || '' })
122
+ .catch((err) => send({ kind: 'error', message: String(err?.message || err) }))
123
+ .finally(() => send({ kind: 'done' }));
124
+ return undefined;
125
+ }
108
126
  if (ask.op === 'models') {
109
127
  void listModels().then((m) => answer(ask.request_id, { ok: true, ...m }));
110
128
  return undefined;
@@ -541,3 +541,54 @@ export async function listModels(timeoutMs = 15000) {
541
541
  return empty;
542
542
  }
543
543
  }
544
+
545
+ /**
546
+ * Stream a chat turn from the model on THIS computer.
547
+ *
548
+ * Calls onEvent once per event as it arrives — text chunks, the tool it is
549
+ * running, an error — so the person watching on the web sees the build happen
550
+ * rather than staring at nothing for two minutes and assuming it hung.
551
+ */
552
+ export async function chatModel(prompt, onEvent, { job = '', model = '', timeoutMs = 1800000 } = {}) {
553
+ const url = process.env.ATLAS_ASK_URL;
554
+ const token = process.env.ATLAS_ASK_TOKEN;
555
+ if (!url || !token) {
556
+ onEvent({ kind: 'error', message:
557
+ 'No model is connected on this computer. Open Atlas Remote Control, '
558
+ + 'connect a model, and turn Local Mode on.' });
559
+ return;
560
+ }
561
+ try {
562
+ const res = await fetch(url.replace('/ask', '/chat'), {
563
+ method: 'POST',
564
+ headers: { 'Content-Type': 'application/json', 'X-Atlas-Token': token },
565
+ body: JSON.stringify({ job, prompt: String(prompt), model }),
566
+ signal: AbortSignal.timeout(timeoutMs),
567
+ });
568
+ if (!res.ok || !res.body) {
569
+ onEvent({ kind: 'error', message: `The model could not answer (${res.status}).` });
570
+ return;
571
+ }
572
+ // Line-delimited JSON. A chunk can split a line in half, so hold the tail
573
+ // until a newline proves it complete — parsing early yields half an event.
574
+ const reader = res.body.getReader();
575
+ const dec = new TextDecoder();
576
+ let buf = '';
577
+ for (;;) {
578
+ const { done, value } = await reader.read();
579
+ if (done) break;
580
+ buf += dec.decode(value, { stream: true });
581
+ const lines = buf.split('\n');
582
+ buf = lines.pop() ?? '';
583
+ for (const line of lines) {
584
+ const t = line.trim();
585
+ if (!t) continue;
586
+ try { onEvent(JSON.parse(t)); } catch { /* skip a malformed line */ }
587
+ }
588
+ }
589
+ const tail = buf.trim();
590
+ if (tail) { try { onEvent(JSON.parse(tail)); } catch { /* ignore */ } }
591
+ } catch (err) {
592
+ onEvent({ kind: 'error', message: String(err?.message || err) });
593
+ }
594
+ }