mindvest-atlas 0.9.2 → 0.10.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.9.2",
3
+ "version": "0.10.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": {
@@ -18,6 +18,7 @@ import {
18
18
  DEFAULT_TIMEOUT_MS, claimRun, computerName, isPaused, jobEnv, listJobFiles, listRuns, machineId,
19
19
  osLabel, readJobFile, releaseRun, staleness, summarize, tail, workspace, workspaceRoot,
20
20
  setPaused, writeHelpers, askModel, listModels, chatModel,
21
+ createProject, listChats, readChat, deleteChat, appendChat,
21
22
  } from '../remote-control.js';
22
23
  import fs from 'node:fs';
23
24
  import os from 'node:os';
@@ -117,12 +118,28 @@ async function handleAsk(ask, root) {
117
118
  // event is answered SEPARATELY against the same request id, so the web sees
118
119
  // the build as it happens; the server relays and stores none of it.
119
120
  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 || '' })
121
+ const job = ask.job || '';
122
+ const chatId = ask.chat_id || '';
123
+ // Keep the reply as it streams, so the whole answer can be written to the
124
+ // transcript once the turn is done.
125
+ const said = [];
126
+ const send = (ev) => {
127
+ if (ev.kind === 'text') said.push(ev.chunk || '');
128
+ return answer(ask.request_id, { ok: true, stream: true, seq: ++seq, ...ev });
129
+ };
130
+ if (job && chatId) appendChat(job, chatId, 'user', ask.prompt || '', root);
131
+ void chatModel(ask.prompt || '', send, { job, model: ask.model || '' })
122
132
  .catch((err) => send({ kind: 'error', message: String(err?.message || err) }))
123
- .finally(() => send({ kind: 'done' }));
133
+ .finally(() => {
134
+ if (job && chatId) appendChat(job, chatId, 'assistant', said.join(''), root);
135
+ send({ kind: 'done' });
136
+ });
124
137
  return undefined;
125
138
  }
139
+ if (ask.op === 'create') return answer(ask.request_id, createProject(ask.job || '', root));
140
+ if (ask.op === 'chats') return answer(ask.request_id, { ok: true, chats: listChats(ask.job || '', root) });
141
+ if (ask.op === 'chat_read') return answer(ask.request_id, readChat(ask.job || '', ask.chat_id || '', root));
142
+ if (ask.op === 'chat_delete') return answer(ask.request_id, deleteChat(ask.job || '', ask.chat_id || '', root));
126
143
  if (ask.op === 'models') {
127
144
  void listModels().then((m) => answer(ask.request_id, { ok: true, ...m }));
128
145
  return undefined;
@@ -592,3 +592,142 @@ export async function chatModel(prompt, onEvent, { job = '', model = '', timeout
592
592
  onEvent({ kind: 'error', message: String(err?.message || err) });
593
593
  }
594
594
  }
595
+
596
+ // The starter a project gets the moment it is created, so an agent opening the
597
+ // folder finds a shape to work in rather than an empty directory. Nothing here
598
+ // runs: it is scaffolding, the model replaces main.* on the first build.
599
+ const STARTER_README = (name) => [
600
+ `# ${name}`, '',
601
+ 'What this does', '--------------',
602
+ '(Describe it here in your own words. The model reads this when it builds.)', '',
603
+ 'Files', '-----',
604
+ '- `main.py` what runs. Replaced by the model on the first build.',
605
+ '- `.env` your keys. Values stay on this computer and are never read',
606
+ ' back by anything, including the model.',
607
+ '- `atlas_env.py` helpers: secret(), require(), atlas(), ask().',
608
+ '- `flow.md` a diagram of what happens, written after the first build.', '',
609
+ 'Nothing here is stored on Atlas servers.', '',
610
+ ].join('\n');
611
+
612
+ const STARTER_MAIN = (name) => [
613
+ '"""' + name + ' - not built yet.', '',
614
+ 'Describe what you want in the chat and the model writes this file, runs it and',
615
+ 'fixes it until it works. Anything already here is replaced.',
616
+ '"""',
617
+ 'print("Nothing built yet. Describe what this should do in Atlas.")', '',
618
+ ].join('\n');
619
+
620
+ const STARTER_FLOW = (name) => [
621
+ `# ${name}`, '', '```mermaid', 'flowchart TD',
622
+ ' A[Not built yet] --> B[Describe it in Atlas]',
623
+ ' B --> C[The model writes and tests it here]', '```', '',
624
+ ].join('\n');
625
+
626
+ /**
627
+ * Make a project folder with enough in it to start from.
628
+ *
629
+ * An empty directory tells an agent nothing. This lays down the helpers, a
630
+ * README to write the intent into, a placeholder entry script and a flow
631
+ * diagram, so whatever opens the folder next can see the shape of the thing
632
+ * before a line of it exists.
633
+ *
634
+ * Existing files are NEVER overwritten (bar the helpers, which are ours and get
635
+ * rewritten before every run anyway), so calling it on a built project is safe.
636
+ */
637
+ export function createProject(name, root) {
638
+ const slug = safeSlug(name);
639
+ const dir = path.join(workspaceRoot(root), SCRIPTS_DIR, slug);
640
+ try {
641
+ fs.mkdirSync(dir, { recursive: true });
642
+ } catch (err) {
643
+ return { ok: false, error: `Could not make the folder: ${err.message}` };
644
+ }
645
+ writeHelpers(dir);
646
+ const starters = [
647
+ ['README.md', STARTER_README(slug)],
648
+ ['main.py', STARTER_MAIN(slug)],
649
+ ['flow.md', STARTER_FLOW(slug)],
650
+ ['.env', '# KEY=value - one per line. Values stay on this computer.\n'],
651
+ ];
652
+ for (const [name_, body] of starters) {
653
+ const f = path.join(dir, name_);
654
+ if (fs.existsSync(f)) continue;
655
+ try { fs.writeFileSync(f, body, 'utf8'); } catch { /* best effort */ }
656
+ }
657
+ return { ok: true, job: slug };
658
+ }
659
+
660
+ // ---- chat sessions, stored WITH the project they belong to -----------------
661
+ //
662
+ // On this computer, never on Atlas: a build conversation is as private as the
663
+ // script it produced. Living inside the project folder means a session cannot
664
+ // outlive what it was about, and deleting the project takes its history too.
665
+ const CHATS_DIR = '.chats';
666
+ const chatsDir = (job, root) => path.join(workspaceRoot(root), SCRIPTS_DIR, safeSlug(job), CHATS_DIR);
667
+ const chatFile = (job, id, root) => path.join(chatsDir(job, root), `${safeSlug(id)}.json`);
668
+
669
+ /** Every build conversation for one project, newest first. */
670
+ export function listChats(job, root) {
671
+ let names = [];
672
+ try {
673
+ names = fs.readdirSync(chatsDir(job, root)).filter((n) => n.endsWith('.json'));
674
+ } catch { return []; }
675
+ const out = [];
676
+ for (const n of names) {
677
+ let data;
678
+ try { data = JSON.parse(fs.readFileSync(path.join(chatsDir(job, root), n), 'utf8')); } catch { continue; }
679
+ const msgs = data.messages || [];
680
+ const first = (msgs.find((m) => m.role === 'user') || {}).text || '';
681
+ out.push({
682
+ id: n.replace(/\.json$/, ''),
683
+ title: (first || 'New conversation').trim().slice(0, 80),
684
+ updated_at: data.updated_at || 0,
685
+ messages: msgs.length,
686
+ });
687
+ }
688
+ out.sort((a, b) => b.updated_at - a.updated_at);
689
+ return out.slice(0, 50);
690
+ }
691
+
692
+ /** One conversation, in full. */
693
+ export function readChat(job, id, root) {
694
+ try {
695
+ const data = JSON.parse(fs.readFileSync(chatFile(job, id, root), 'utf8'));
696
+ return { ok: true, id: safeSlug(id), messages: data.messages || [] };
697
+ } catch {
698
+ return { ok: false, error: 'That conversation is not on this computer.' };
699
+ }
700
+ }
701
+
702
+ /**
703
+ * Forget one conversation.
704
+ *
705
+ * The owner's to delete, like everything else here: their machine, their
706
+ * history. safeSlug keeps the id inside the project's own folder, so a crafted
707
+ * id cannot reach a file that is not a conversation.
708
+ */
709
+ export function deleteChat(job, id, root) {
710
+ try {
711
+ fs.unlinkSync(chatFile(job, id, root));
712
+ return { ok: true, deleted: true };
713
+ } catch (err) {
714
+ if (err.code === 'ENOENT') return { ok: true, deleted: false };
715
+ return { ok: false, error: err.message };
716
+ }
717
+ }
718
+
719
+ /**
720
+ * Add one turn to a conversation, creating it if this is the first.
721
+ * Best-effort: a history that cannot be written must never stop the build.
722
+ */
723
+ export function appendChat(job, id, role, text, root) {
724
+ if (!text) return;
725
+ try { fs.mkdirSync(chatsDir(job, root), { recursive: true }); } catch { return; }
726
+ const f = chatFile(job, id, root);
727
+ let data = { messages: [] };
728
+ try { data = JSON.parse(fs.readFileSync(f, 'utf8')); } catch { /* first turn */ }
729
+ if (!Array.isArray(data.messages)) data.messages = [];
730
+ data.messages.push({ role, text, at: Date.now() / 1000 });
731
+ data.updated_at = Date.now() / 1000;
732
+ try { fs.writeFileSync(f, JSON.stringify(data), 'utf8'); } catch { /* best effort */ }
733
+ }