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 +1 -1
- package/src/commands/remote-control.js +20 -3
- package/src/remote-control.js +139 -0
package/package.json
CHANGED
|
@@ -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
|
|
121
|
-
|
|
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(() =>
|
|
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;
|
package/src/remote-control.js
CHANGED
|
@@ -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
|
+
}
|