@c0mpute/code 0.4.1

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.
Files changed (3) hide show
  1. package/README.md +48 -0
  2. package/cli.mjs +505 -0
  3. package/package.json +26 -0
package/README.md ADDED
@@ -0,0 +1,48 @@
1
+ # c0mpute code
2
+
3
+ A coding agent whose **brain runs on the [c0mpute](https://c0mpute.ai) network** (the
4
+ `code` model — Devstral) while **file edits and commands run locally on your machine,
5
+ under your approval.** No single company can take it down, rate-limit it, or censor it —
6
+ and for private work you can point it at your own node.
7
+
8
+ ## Quick start
9
+
10
+ ```bash
11
+ npx @c0mpute/code # interactive, in your repo
12
+ npx @c0mpute/code "fix the failing test in test_api.py" # one task, then exit
13
+ ```
14
+
15
+ On first run it asks for your API key (get one at c0mpute.ai → settings → API keys)
16
+ and saves it to `~/.config/c0mpute-code/config.json`. Re-set it anytime with `/login`.
17
+ You can also pass it via the `C0MPUTE_API_KEY` env var.
18
+
19
+ ## What it does
20
+ - Works as an agent loop with real tools: **list, search, read, edit, write, run**. It locates
21
+ the relevant code, reads it, makes a surgical edit, runs your tests, and stops when they pass.
22
+ - Edits are **SEARCH/REPLACE** snippets (small, targeted) — not whole-file rewrites — with a
23
+ tolerant matcher so it doesn't fight whitespace.
24
+ - **Asks before every edit or command** (allow once / always / deny). Reads (list/search/read)
25
+ run automatically.
26
+ - **Shows colored diffs** of every change.
27
+ - **Stays inside the project.** The directory you launch it in is the sandbox — any command
28
+ that touches a file outside it has to be approved explicitly (even with `--yolo`).
29
+ - **Redacts secrets** (API keys, `.env` values, private keys, tokens) before anything is
30
+ sent to the network. Your code is processed remotely, so for sensitive work run your own
31
+ c0mpute worker and your code never leaves your trust boundary.
32
+ - The inference runs across the decentralized network; the dangerous parts (your files,
33
+ your shell) never leave your machine.
34
+
35
+ ## Sessions
36
+ - **Project memory**: it reads `c0mpute.md` (or `AGENTS.md` / `CLAUDE.md`) from the repo root as
37
+ context. Run `/init` to generate a `c0mpute.md` for the current project.
38
+ - **Long sessions** stay within the model's context automatically (older steps are compacted).
39
+ - **Ctrl-C** interrupts the current task and returns to the prompt; again at the prompt exits.
40
+ - Edits are syntax-checked and auto-reverted if they would break the file.
41
+
42
+ ## Options (env)
43
+ - `C0MPUTE_API_KEY` — your c0mpute API key (required)
44
+ - `C0MPUTE_MODEL` — model id (default `code`)
45
+ - `C0MPUTE_YOLO=1` — skip approval prompts (auto-run everything)
46
+ - `C0MPUTE_API_URL` — override the API base (default `https://c0mpute.ai/api/v1`)
47
+
48
+ Requires Node 18+.
package/cli.mjs ADDED
@@ -0,0 +1,505 @@
1
+ #!/usr/bin/env node
2
+ // c0mpute code — decentralized coding agent.
3
+ // The brain runs on the c0mpute network (the "code" model, Devstral); file edits
4
+ // and commands run locally on your machine, under your approval. No single company
5
+ // can take it down, rate-limit it, or censor it.
6
+ //
7
+ // C0MPUTE_API_KEY=sk-... c0mpute-code # interactive
8
+ // C0MPUTE_API_KEY=sk-... c0mpute-code "task" # one task, then exit
9
+ import { spawnSync } from 'child_process';
10
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from 'fs';
11
+ import { createInterface } from 'readline';
12
+ import { stdin, stdout } from 'process';
13
+ import { homedir } from 'os';
14
+ import { resolve, isAbsolute, join, dirname } from 'path';
15
+ import { fileURLToPath } from 'url';
16
+
17
+ // ── config ──
18
+ const API_BASE = process.env.C0MPUTE_API_URL || 'https://c0mpute.ai/api/v1';
19
+ const API = API_BASE + '/chat/completions';
20
+ const CFG_DIR = join(homedir(), '.config', 'c0mpute-code');
21
+ const CFG_FILE = join(CFG_DIR, 'config.json');
22
+ let KEY = process.env.C0MPUTE_API_KEY || '';
23
+ const MODEL = process.env.C0MPUTE_MODEL || 'code';
24
+ let VERSION = ''; try { VERSION = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'package.json'), 'utf8')).version || ''; } catch {}
25
+ const MAX_STEPS = Number(process.env.C0MPUTE_MAX_STEPS || 40);
26
+ const CWD = process.cwd();
27
+ const ROOT = CWD; // the project boundary: the agent may not touch files outside this without approval
28
+ const AUTO = process.env.C0MPUTE_YOLO === '1';
29
+
30
+ // ── ansi ──
31
+ const e = (n) => (s) => `\x1b[${n}m${s}\x1b[0m`;
32
+ const c = { dim: e(2), bold: e(1), red: e(31), grn: e(32), yel: e(33), blu: e(34), cyn: e(36), mag: e(35), gry: e(90), b: (s) => `\x1b[1m${s}\x1b[0m` };
33
+ // c0mpute brand: pure black, green accent (#5af78e), pixel square marker (not Claude's round/orange dot).
34
+ const MARK = c.grn('▪');
35
+ const vlen = (s) => s.replace(/\x1b\[[0-9;]*m/g, '').length;
36
+ const clip = (s, n = 4000) => { s = String(s ?? ''); return s.length > n ? s.slice(0, n) + c.dim(` … +${s.length - n} chars`) : s; };
37
+
38
+ // ── rounded box ── (width tracks the terminal so the right border never clips)
39
+ const wbox = () => Math.min(72, Math.max(46, (stdout.columns || 80) - 2));
40
+ function box(lines) {
41
+ const W = wbox();
42
+ const out = [c.gry('╭' + '─'.repeat(W - 2) + '╮')];
43
+ for (const ln of lines) out.push(c.gry('│ ') + ln + ' '.repeat(Math.max(0, W - 4 - vlen(ln))) + c.gry(' │'));
44
+ out.push(c.gry('╰' + '─'.repeat(W - 2) + '╯'));
45
+ return out.join('\n');
46
+ }
47
+
48
+ // ── secret redaction (before anything leaves the machine) ──
49
+ const SECRET_RX = [
50
+ /\bsk-[A-Za-z0-9_-]{16,}\b/g, /\bAKIA[0-9A-Z]{16}\b/g, /\bghp_[A-Za-z0-9]{30,}\b/g, /\bgho_[A-Za-z0-9]{30,}\b/g,
51
+ /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g, /\beyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g,
52
+ /-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----/g,
53
+ /(?<=(?:secret|token|password|passwd|api[_-]?key|access[_-]?key)["']?\s*[:=]\s*["']?)[A-Za-z0-9_\-./+=]{12,}/gi,
54
+ ];
55
+ let redactCount = 0, redactN = 0;
56
+ const redact = (t) => { let s = String(t ?? ''); for (const rx of SECRET_RX) s = s.replace(rx, () => { redactCount++; return `‹REDACTED-${++redactN}›`; }); return s; };
57
+
58
+ // ── git / shell ──
59
+ const isGit = existsSync(`${CWD}/.git`);
60
+ const sh = (cmd) => {
61
+ const r = spawnSync('/bin/bash', ['-c', cmd], { cwd: CWD, timeout: 120000, maxBuffer: 1 << 24 });
62
+ if (r.error) return `error: ${r.error.code === 'ETIMEDOUT' ? 'timed out after 120s' : r.error.message}`;
63
+ const out = (r.stdout?.toString() || '') + (r.stderr?.toString() || ''); // tools like pytest write to stderr
64
+ return (r.status ? `exit ${r.status}\n` : '') + out;
65
+ };
66
+
67
+ // ── context window management ──
68
+ // Keep what we send to the model bounded: system + recent turns in full, older tool
69
+ // observations collapsed, oldest dropped. Lets long sessions run without blowing context.
70
+ const CTX_BUDGET = Number(process.env.C0MPUTE_CTX_BUDGET || 48000); // chars (~12k tokens)
71
+ function pack(history) {
72
+ const sys = history[0], rest = history.slice(1), out = [];
73
+ let total = sys.content.length;
74
+ for (let i = rest.length - 1; i >= 0; i--) {
75
+ let content = rest[i].content;
76
+ if (out.length >= 8 && content.length > 700) content = content.slice(0, 400) + ` …[${content.length - 400} chars trimmed]`;
77
+ if (total + content.length > CTX_BUDGET) { out.unshift({ role: 'user', content: '[earlier steps omitted to save context]' }); break; }
78
+ out.unshift({ role: rest[i].role, content }); total += content.length;
79
+ }
80
+ return [sys, ...out];
81
+ }
82
+
83
+ // ── project memory: a file the agent reads for context (and can generate via /init) ──
84
+ const PROJECT_FILES = ['c0mpute.md', 'AGENTS.md', 'CLAUDE.md'];
85
+ function loadProjectNotes() {
86
+ for (const f of PROJECT_FILES) {
87
+ try { const t = readFileSync(join(ROOT, f), 'utf8').trim(); if (t) return { name: f, text: t.slice(0, 4000) }; } catch {}
88
+ }
89
+ return null;
90
+ }
91
+
92
+ // ── streaming over the network ──
93
+ const PULSE = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█', '▇', '▆', '▅', '▄', '▃', '▂']; // compute pulse
94
+ async function think(messages) {
95
+ let i = 0, tick = null, first = false;
96
+ if (stdout.isTTY && !process.env.C0MPUTE_NO_SPINNER) tick = setInterval(() => { if (!first) process.stdout.write('\r' + c.grn(PULSE[i++ % PULSE.length]) + ' '); }, 80);
97
+ const stop = () => { if (tick) { clearInterval(tick); tick = null; if (stdout.isTTY) process.stdout.write('\r\x1b[K'); } };
98
+ currentAbort = new AbortController();
99
+ try {
100
+ const r = await fetch(API, { method: 'POST', signal: currentAbort.signal, headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ model: MODEL, messages, temperature: 0.2, max_tokens: 1024, stream: true }) });
101
+ if (!r.ok) throw new Error(`c0mpute API ${r.status}: ${(await r.text()).slice(0, 200)}`);
102
+ const reader = r.body.getReader(), dec = new TextDecoder();
103
+ let buf = '', full = '', inCode = false, shown = false, prose = '', pp = 0;
104
+ // stream prose, stripping the model's "THOUGHT:" label — withhold a 9-char tail
105
+ // (len of "THOUGHT: ") so a half-arrived keyword never leaks to the screen.
106
+ const flush = (final) => {
107
+ const clean = prose.replace(/\bTHOUGHT:?\s*/gi, '');
108
+ const upto = final ? clean.length : Math.max(pp, clean.length - 9);
109
+ if (upto > pp) { process.stdout.write(clean.slice(pp, upto)); pp = upto; shown = true; }
110
+ };
111
+ while (true) {
112
+ const { done, value } = await reader.read(); if (done) break;
113
+ buf += dec.decode(value, { stream: true }); const lines = buf.split('\n'); buf = lines.pop() || '';
114
+ for (const ln of lines) {
115
+ if (!ln.startsWith('data:')) continue; const p = ln.slice(5).trim(); if (p === '[DONE]') continue;
116
+ let tok = ''; try { tok = JSON.parse(p).choices?.[0]?.delta?.content || ''; } catch { continue; }
117
+ if (!tok) continue;
118
+ if (!first) { first = true; stop(); }
119
+ full += tok;
120
+ if (!inCode) {
121
+ if (full.includes('```')) { inCode = true; flush(true); }
122
+ else { prose += tok; flush(false); }
123
+ }
124
+ }
125
+ }
126
+ flush(true);
127
+ if (shown) process.stdout.write('\n');
128
+ return full;
129
+ } finally { stop(); }
130
+ }
131
+ // ── action protocol: model emits ONE fenced block per turn; first line is the command ──
132
+ const VERBS = new Set(['list', 'search', 'read', 'edit', 'write', 'run', 'done']);
133
+ function parseAction(text) {
134
+ const m = String(text || '').match(/```([^\n]*)\n([\s\S]*?)```/);
135
+ if (!m) return null;
136
+ const info = m[1].trim(), body = m[2];
137
+ let cmdline, rest;
138
+ if (VERBS.has(info.split(/\s+/)[0]?.toLowerCase())) { cmdline = info; rest = body.replace(/\n+$/, ''); }
139
+ else { const nl = body.indexOf('\n'); cmdline = (nl < 0 ? body : body.slice(0, nl)).trim(); rest = nl < 0 ? '' : body.slice(nl + 1).replace(/\n+$/, ''); }
140
+ const sp = cmdline.search(/\s/);
141
+ const verb = (sp < 0 ? cmdline : cmdline.slice(0, sp)).toLowerCase();
142
+ const arg = sp < 0 ? '' : cmdline.slice(sp + 1).trim();
143
+ return VERBS.has(verb) ? { verb, arg, body: rest } : null;
144
+ }
145
+
146
+ // ── local file tools (run on the user's machine; the network only sees what we send back) ──
147
+ const abspath = (p) => isAbsolute(p) ? resolve(p) : resolve(ROOT, p);
148
+ const within = (p) => { const a = abspath(p); return a === ROOT || a.startsWith(ROOT + '/'); };
149
+ const shq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
150
+ function toolRead(arg) {
151
+ const [path, s, e] = arg.split(/\s+/);
152
+ const start = parseInt(s) || 0, end = parseInt(e) || 0;
153
+ let txt; try { txt = readFileSync(abspath(path), 'utf8'); } catch (x) { return { err: `cannot read ${path}: ${x.code || x.message}` }; }
154
+ const lines = txt.split('\n'), from = start || 1, to = end || lines.length;
155
+ const slice = lines.slice(from - 1, to);
156
+ // visible "│" gutter so the code's real indentation is unambiguous (matters for edits)
157
+ return { out: slice.map((l, i) => String(from + i).padStart(4) + ' │ ' + l).join('\n'), lines: slice.length, total: lines.length };
158
+ }
159
+ function toolList(arg) {
160
+ const dir = arg || '.';
161
+ let ents; try { ents = readdirSync(abspath(dir), { withFileTypes: true }); } catch (x) { return { err: `cannot list ${dir}: ${x.code || x.message}` }; }
162
+ const names = ents.filter(e => !e.name.startsWith('.'))
163
+ .sort((a, b) => (b.isDirectory() - a.isDirectory()) || a.name.localeCompare(b.name))
164
+ .map(e => e.isDirectory() ? e.name + '/' : e.name);
165
+ return { out: names.join('\n') || '(empty)' };
166
+ }
167
+ function toolSearch(arg) {
168
+ const cmd = `(command -v rg >/dev/null && rg -n --no-heading -S --max-count 6 -- ${shq(arg)} . || grep -rnI -- ${shq(arg)} . ) 2>/dev/null | head -40`;
169
+ return { out: sh(cmd).trim() || '(no matches)' };
170
+ }
171
+ // strip a line-number gutter the model may have copied from a `read` (" 12 │ code" or " 12 code")
172
+ const degut = (s) => s.split('\n').map(l => l.replace(/^\s*\d+\s*│ ?/, '').replace(/^\s*\d+\s{2}/, '')).join('\n');
173
+ // locate SEARCH in the file tolerantly (exact → de-guttered → per-line whitespace-flexible)
174
+ function locate(txt, oldStr, newStr) {
175
+ const uniq = (o, n) => { const i = txt.indexOf(o); return (i >= 0 && txt.indexOf(o, i + 1) < 0) ? { txt: txt.slice(0, i) + n + txt.slice(i + o.length), line: txt.slice(0, i).split('\n').length } : null; };
176
+ let r = uniq(oldStr, newStr); if (r) return r;
177
+ const og = degut(oldStr); if (og !== oldStr) { r = uniq(og, degut(newStr)); if (r) return r; }
178
+ const F = txt.split('\n'), O = og.split('\n').map(l => l.replace(/\s+$/, '')), N = degut(newStr).split('\n');
179
+ const norm = (l) => l.replace(/\s+$/, '');
180
+ let at = -1, count = 0;
181
+ for (let i = 0; i + O.length <= F.length; i++) {
182
+ let ok = true; for (let j = 0; j < O.length; j++) if (norm(F[i + j]) !== O[j]) { ok = false; break; }
183
+ if (ok) { count++; if (at < 0) at = i; }
184
+ }
185
+ if (count === 1) return { txt: [...F.slice(0, at), ...N, ...F.slice(at + O.length)].join('\n'), line: at + 1 };
186
+ if (count > 1) return { dup: count };
187
+ return null;
188
+ }
189
+ const diffRows = (start, oldL, newL) => { const rows = []; oldL.forEach((l, i) => rows.push(c.gry(String(start + i).padStart(5)) + ' ' + c.red('- ' + l))); newL.forEach((l, i) => rows.push(c.gry(String(start + i).padStart(5)) + ' ' + c.grn('+ ' + l))); return rows; };
190
+ // after a write, check the file still parses; an edit that breaks syntax is auto-reverted
191
+ function syntaxError(path) {
192
+ const ext = (path.split('.').pop() || '').toLowerCase();
193
+ if (ext === 'py') { const r = sh(`python3 -c "import ast,sys; ast.parse(open(sys.argv[1]).read())" ${shq(abspath(path))}`); return /Error/.test(r) ? (r.match(/\w*Error:.*/) || [r.trim().split('\n').pop()])[0] : null; }
194
+ if (['js', 'mjs', 'cjs'].includes(ext)) { const r = sh(`node --check ${shq(abspath(path))}`); return /Error/.test(r) ? (r.match(/\w*Error:.*/) || [r.trim().split('\n')[0]])[0] : null; }
195
+ return null;
196
+ }
197
+ // write newContent; if it breaks syntax, restore prior (or delete a new file) and report
198
+ function commit(path, newContent, prior, okMsg, rows) {
199
+ writeFileSync(abspath(path), newContent);
200
+ const bad = syntaxError(path);
201
+ if (bad) { if (prior === null) { try { sh(`rm -f ${shq(abspath(path))}`); } catch {} } else writeFileSync(abspath(path), prior); return { err: `that change broke ${path}: ${bad.slice(0, 120)} — reverted. Re-read and fix the indentation/range.` }; }
202
+ return { out: okMsg, rows };
203
+ }
204
+ function toolEdit(arg, body) {
205
+ const parts = arg.split(/\s+/), path = parts[0];
206
+ let txt; try { txt = readFileSync(abspath(path), 'utf8'); } catch (x) { return { err: `cannot read ${path}: ${x.code || x.message}` }; }
207
+ // primary form: `edit <path> <start> <end>` replaces those lines (numbers come from a read)
208
+ if (parts.length >= 3 && /^\d+$/.test(parts[1]) && /^\d+$/.test(parts[2])) {
209
+ const lines = txt.split('\n'), s = +parts[1], e = +parts[2];
210
+ if (s < 1 || s > e || e > lines.length) return { err: `bad range ${s}-${e}; ${path} has ${lines.length} lines. Re-read and use line numbers in range.` };
211
+ const oldL = lines.slice(s - 1, e), newL = degut(body).split('\n');
212
+ return commit(path, [...lines.slice(0, s - 1), ...newL, ...lines.slice(e)].join('\n'), txt, `Updated ${path} (+${newL.length} -${oldL.length})`, diffRows(s, oldL, newL));
213
+ }
214
+ // fallback form: SEARCH/REPLACE block
215
+ const m = body.match(/<{3,}\s*SEARCH\s*\n([\s\S]*?)\n={3,}\s*\n([\s\S]*?)\n>{3,}\s*REPLACE/);
216
+ if (!m) return { err: `edit needs either "edit ${path} <start> <end>" + the new lines, or a SEARCH/REPLACE block.` };
217
+ const oldStr = m[1], newStr = m[2], loc = locate(txt, oldStr, newStr);
218
+ if (!loc) return { err: `SEARCH text not found in ${path}. Easier: re-read it and use "edit ${path} <start> <end>" with the line numbers.` };
219
+ if (loc.dup) return { err: `SEARCH matches ${loc.dup}x in ${path}. Use "edit ${path} <start> <end>" with line numbers instead.` };
220
+ return commit(path, loc.txt, txt, `Updated ${path} (+${degut(newStr).split('\n').length} -${degut(oldStr).split('\n').length})`, diffRows(loc.line, degut(oldStr).split('\n'), degut(newStr).split('\n')));
221
+ }
222
+ function toolWrite(path, body) {
223
+ const existed = existsSync(abspath(path));
224
+ let prior = null;
225
+ try { prior = existed ? readFileSync(abspath(path), 'utf8') : null; mkdirSync(dirname(abspath(path)), { recursive: true }); }
226
+ catch (x) { return { err: `cannot write ${path}: ${x.code || x.message}` }; }
227
+ return commit(path, body.endsWith('\n') ? body : body + '\n', prior, `${existed ? 'Overwrote' : 'Created'} ${path} (${body.split('\n').length} lines)`);
228
+ }
229
+
230
+ // ── filesystem boundary ──
231
+ // Return any path tokens in the command that resolve OUTSIDE the project root.
232
+ // Whole-word paths only (leading space) so we don't trip on URLs like https://…
233
+ function outOfBounds(cmd) {
234
+ const hits = new Set();
235
+ const toks = cmd.match(/(?:^|[\s=])((?:~\/|\.\.\/|\/)[^\s'"();|&<>]*)/g) || [];
236
+ for (let raw of toks) {
237
+ let t = raw.replace(/^[\s=]+/, '');
238
+ if (t.startsWith('~')) t = join(homedir(), t.slice(1));
239
+ const abs = isAbsolute(t) ? resolve(t) : resolve(ROOT, t);
240
+ if (abs !== ROOT && !abs.startsWith(ROOT + '/')) hits.add(t);
241
+ }
242
+ return [...hits];
243
+ }
244
+
245
+ // ── permission (Claude-style) ──
246
+ const allow = { all: AUTO }; const session = new Set();
247
+ // One persistent readline for the whole session — a fresh interface per prompt
248
+ // drops buffered/piped input and fights itself on stdin.
249
+ let RL = null, closed = false;
250
+ let busy = false, interrupted = false, currentAbort = null; // ctrl-c interrupt state
251
+ function rl() { if (!RL) { RL = createInterface({ input: stdin, output: stdout }); RL.on('close', () => { closed = true; }); } return RL; }
252
+ const ask = (q) => new Promise(r => { if (closed) return r(''); rl().question(q, a => r(a.trim())); });
253
+ async function permit(verb, detail, warn) {
254
+ if (!warn && (allow.all || session.has(verb))) return true; // out-of-bounds always asks, even in yolo
255
+ const W = wbox();
256
+ console.log('\n' + box([
257
+ c.b(verb + ' wants to run:'), '', c.yel(detail.slice(0, W - 8)),
258
+ ...(warn ? ['', c.red(warn.slice(0, W - 6))] : []),
259
+ ]));
260
+ const a = (await ask(` ${c.b('1.')} yes ${c.b('2.')} yes, don't ask again for ${verb} ${c.b('3.')} no › `)).toLowerCase();
261
+ if (a === '2' && !warn) { session.add(verb); return true; }
262
+ return a === '1' || a === '2' || a === 'y' || a === '';
263
+ }
264
+
265
+ // ── first-run API-key setup (Claude Code-style /login) ──
266
+ async function validateKey(k) { try { const r = await fetch(API_BASE + '/models', { headers: { Authorization: `Bearer ${k}` } }); return r.status !== 401; } catch { return true; } }
267
+ async function setupKey() {
268
+ console.log('\n' + box([
269
+ `${MARK} ${c.b('c0mpute code')}`,
270
+ c.dim('first run. paste your api key to start.'),
271
+ c.dim('get one at c0mpute.ai → settings → api keys'),
272
+ ]));
273
+ while (true) {
274
+ const k = (await ask(` ${c.b('›')} paste your c0mpute API key (sk-…): `)).trim();
275
+ if (!k) continue;
276
+ process.stdout.write(' ' + c.dim('checking… '));
277
+ if (!(await validateKey(k))) { console.log(c.red('that key was rejected, try again')); continue; }
278
+ KEY = k;
279
+ try { mkdirSync(CFG_DIR, { recursive: true }); writeFileSync(CFG_FILE, JSON.stringify({ apiKey: k }, null, 2), { mode: 0o600 }); console.log(c.grn('saved') + c.dim(` → ${CFG_FILE.replace(homedir(), '~')}`)); } catch { console.log(c.yel('using for this session (could not write config)')); }
280
+ return;
281
+ }
282
+ }
283
+ async function ensureKey() {
284
+ if (KEY) return; // env var wins
285
+ try { const cfg = JSON.parse(readFileSync(CFG_FILE, 'utf8')); if (cfg.apiKey) { KEY = cfg.apiKey; return; } } catch {}
286
+ await setupKey(); // nothing saved → first-run flow
287
+ }
288
+
289
+ // ── one task ──
290
+ const READONLY = new Set(['read', 'list', 'search']);
291
+ const LABELS = { read: 'Read', list: 'List', search: 'Search', edit: 'Update', write: 'Write', run: 'Run' };
292
+ async function runTask(task, history) {
293
+ console.log('');
294
+ history.push({ role: 'user', content: task });
295
+ let ran = false, nudges = 0, lastRunFailed = false, doneNudges = 0;
296
+ busy = true; interrupted = false;
297
+ // is this a coding task (enforce actions) or chat/greeting (a prose reply is fine)?
298
+ const isCoding = /\b(fix|bug|error|fail|implement|add|refactor|test|debug|rename|update|create|build|install|broken|crash|exception|traceback|function|class|import|run)\b/i.test(task) || /[\w./-]+\.\w{1,5}\b/.test(task);
299
+ try {
300
+ for (let step = 1; step <= MAX_STEPS; step++) {
301
+ if (interrupted) break;
302
+ let reply;
303
+ try { reply = await think(pack(history).map(m => ({ ...m, content: redact(m.content) }))); }
304
+ catch (e) { if (interrupted || e.name === 'AbortError') break; throw e; }
305
+ history.push({ role: 'assistant', content: reply });
306
+ if (interrupted) break;
307
+ const act = parseAction(reply);
308
+ if (!act) {
309
+ // a coding task with no action means the model under-drove -> nudge it back on-protocol
310
+ if (isCoding && nudges < 3) { nudges++; history.push({ role: 'user', content: 'You did not emit an action. Respond with EXACTLY ONE action now in a fenced ``` block (start with `list` or `search` to find the code), or `done` if the task is verified complete.' }); continue; }
311
+ break; // conversational reply, or finished after work
312
+ }
313
+ nudges = 0;
314
+ const { verb, arg } = act;
315
+ if (verb === 'done') {
316
+ // don't accept "done" while the last command was still failing — that's a false finish
317
+ if (lastRunFailed && doneNudges < 2) { doneNudges++; history.push({ role: 'user', content: 'The last command reported failures/errors, so the task is NOT verified. Keep fixing and re-run the test until it passes. If you are genuinely stuck, say plainly what is still broken instead of using `done`.' }); continue; }
318
+ ran = true; break;
319
+ }
320
+ const path0 = arg.split(/\s+/)[0] || '';
321
+ const shown = verb === 'search' ? arg : (verb === 'run' ? (arg || act.body.split('\n')[0]) : path0);
322
+ console.log(`${MARK} ${c.b(LABELS[verb])}${c.gry('(')}${c.gry(shown)}${c.gry(')')}`);
323
+
324
+ // permission: reads auto-run; edits/writes/run + anything out-of-bounds ask
325
+ const oob = verb === 'run' ? outOfBounds(arg || act.body) : (within(path0) ? [] : [path0]);
326
+ let ok = true;
327
+ if (oob.length) ok = await permit(LABELS[verb], shown, `⚠ this touches files OUTSIDE the project: ${oob.join(', ')}`);
328
+ else if (!READONLY.has(verb)) ok = await permit(LABELS[verb], shown);
329
+ if (!ok) { console.log(` ${c.gry('⎿')} ${c.red('denied by user')}`); history.push({ role: 'user', content: `The user denied ${verb} on ${shown}. Try another approach inside the project.` }); continue; }
330
+
331
+ // execute the tool locally
332
+ let res, obs;
333
+ if (verb === 'read') { res = toolRead(arg); obs = res.err || `${path0} (${res.lines}/${res.total} lines):\n${res.out}`; }
334
+ else if (verb === 'list') { res = toolList(arg); obs = res.err || `${arg || '.'}:\n${res.out}`; }
335
+ else if (verb === 'search') { res = toolSearch(arg); obs = res.err || `matches for "${arg}":\n${res.out}`; }
336
+ else if (verb === 'edit') { res = toolEdit(arg, act.body); obs = res.err || res.out; if (!res.err) ran = true; }
337
+ else if (verb === 'write') { res = toolWrite(path0, act.body); obs = res.err || res.out; if (!res.err) ran = true; }
338
+ else { const cmd = arg || act.body; const out = sh(cmd); res = { out }; obs = `$ ${cmd}\n${clip(out, 3000)}`; ran = true; lastRunFailed = /^exit [1-9]/.test(out) || /\b[1-9]\d* (?:failed|error)/i.test(out); }
339
+ if (verb === 'edit' || verb === 'write') lastRunFailed = true; // changed code but haven't re-verified yet
340
+
341
+ // render result under the action
342
+ if (res.err) console.log(` ${c.gry('⎿')} ${c.red(res.err.split('\n')[0])}`);
343
+ else if (res.rows) { console.log(` ${c.gry('⎿')} ${c.dim(res.out)}`); for (const row of res.rows.slice(0, 30)) console.log(' ' + row); }
344
+ else { const lines = clip(res.out, 600).split('\n'); console.log(` ${c.gry('⎿')} ${c.dim(lines[0] || '(empty)')}`); for (const l of lines.slice(1, 8)) console.log(' ' + c.dim(l)); }
345
+ console.log('');
346
+ history.push({ role: 'user', content: redact(clip(obs, 4000)) });
347
+ }
348
+ } finally { busy = false; }
349
+ if (interrupted) { interrupted = false; console.log(c.dim(' ⊘ stopped.') + '\n'); }
350
+ else if (ran) console.log(MARK + ' ' + c.dim('done') + '\n');
351
+ else console.log('');
352
+ }
353
+
354
+ // ── /init: generate project memory deterministically (no agent loop, so it can't
355
+ // create stray files or go off and "scaffold a new project") ──
356
+ async function initProject() {
357
+ process.stdout.write('\n' + MARK + ' ' + c.dim('scanning project…') + '\n');
358
+ const tree = sh(`(git ls-files 2>/dev/null || find . -type f -not -path './.git/*') | head -80`);
359
+ const manifests = sh(`for f in README* readme* package.json pyproject.toml setup.py Cargo.toml go.mod requirements.txt Makefile; do [ -f "$f" ] && echo "=== $f ===" && head -50 "$f"; done`);
360
+ const srcs = sh(`(git ls-files 2>/dev/null || find . -type f) | grep -Ei '\\.(py|js|ts|jsx|tsx|go|rs|java|rb)$' | grep -vi test | head -4`).trim().split('\n').filter(Boolean);
361
+ let snippets = '';
362
+ for (const f of srcs) { const r = toolRead(f + ' 1 40'); if (!r.err) snippets += `\n=== ${f} (first 40 lines) ===\n${r.out}\n`; }
363
+ const ctx = `FILE TREE:\n${tree}\n\nMANIFESTS:\n${manifests}\n\nKEY SOURCE FILES:${snippets}`;
364
+ busy = true;
365
+ let md = '';
366
+ try {
367
+ md = await think([
368
+ { role: 'system', content: 'You write concise project notes for an AI coding agent. Output ONLY github-flavored markdown, no preamble, no code fences around the whole thing.' },
369
+ { role: 'user', content: `Write a c0mpute.md (under 40 lines) describing THIS project, based strictly on the facts below. Cover: what it is, the structure, how to run it, how to test it, and any conventions. Do NOT invent files, commands, or features that are not shown.\n\n${redact(clip(ctx, 8000))}` },
370
+ ]);
371
+ } catch (e) { busy = false; console.log(c.red(' ! ' + e.message)); return; }
372
+ busy = false;
373
+ const clean = md.replace(/^\s*```\w*\n?/, '').replace(/\n?```\s*$/, '').trim();
374
+ if (!clean) { console.log(c.red(' ! got an empty result, try again')); return; }
375
+ writeFileSync(join(ROOT, 'c0mpute.md'), clean + '\n');
376
+ console.log('\n' + MARK + ' ' + c.dim('wrote c0mpute.md — loads as project memory next run') + '\n');
377
+ }
378
+
379
+ // ── main ──
380
+ async function main() {
381
+ await ensureKey();
382
+ // refuse to run loose in home/system dirs — there's no "project" boundary there and
383
+ // the agent would freely read personal files and send their contents to the network.
384
+ const SENSITIVE = new Set([homedir(), '/', '/root', '/home', '/etc', '/usr', '/var', '/bin', '/opt']);
385
+ if (SENSITIVE.has(resolve(CWD))) {
386
+ console.log('\n' + box([
387
+ `${c.red('⚠ this is not a project directory')}`,
388
+ c.dim(`you're in ${CWD.replace(homedir(), '~')} — the agent could read personal`),
389
+ c.dim('files here and send them to the network. cd into a repo first.'),
390
+ ]));
391
+ const a = (await ask(` continue here anyway? ${c.dim('(y/N)')} `)).toLowerCase();
392
+ if (a !== 'y' && a !== 'yes') { console.log(c.dim(' exiting — cd into your project and run again.')); RL?.close(); return; }
393
+ }
394
+ const notes = loadProjectNotes();
395
+ console.log('\n' + box([
396
+ `${MARK} ${c.b('c0mpute code')}${VERSION ? c.gry(' v' + VERSION) : ''}`,
397
+ c.dim('your coding agent, running on the c0mpute network'),
398
+ '',
399
+ `${c.dim('model')} ${MODEL} ${c.dim('cwd')} ${CWD.replace(homedir(), '~')} ${c.dim(isGit ? 'git · diffs on' : 'no git')}`,
400
+ c.dim(`edits ask first · reads run automatically${notes ? ` · memory ${notes.name}` : ''} · /help`),
401
+ ]));
402
+ const sysmsg = SYSTEM + (notes ? `\n\nPROJECT NOTES (from ${notes.name}, treat as authoritative project context):\n${notes.text}` : '');
403
+ const history = [{ role: 'system', content: sysmsg }];
404
+ const fin = () => { if (redactCount) console.log(c.dim(` ${redactCount} secret${redactCount > 1 ? 's' : ''} redacted before leaving your machine`)); };
405
+ // ctrl-c: interrupt a running task; at an idle prompt, exit cleanly
406
+ const onSig = () => { if (busy) { interrupted = true; try { currentAbort?.abort(); } catch {} process.stdout.write('\n' + c.dim(' ^C stopping…') + '\n'); } else { console.log(); fin(); try { RL?.close(); } catch {} process.exit(0); } };
407
+ process.on('SIGINT', onSig); rl().on('SIGINT', onSig);
408
+ const one = process.argv.slice(2).join(' ').trim();
409
+ if (one) { console.log('\n' + c.gry('│ ') + c.b('› ') + one); await runTask(one, history); fin(); RL?.close(); return; }
410
+ while (true) {
411
+ const task = await ask('\n' + c.gry('│ ') + c.b('› ')); // inline prompt — cursor sits right here
412
+ if (closed) { console.log(''); fin(); break; }
413
+ if (!task) continue;
414
+ if (task === '/exit' || task === '/quit') { fin(); break; }
415
+ if (task === '/login') { await setupKey(); continue; }
416
+ if (task === '/init') { await initProject(); continue; }
417
+ if (task === '/help') { console.log(c.dim(' describe a coding task; I locate, read, edit, and run tests to verify.\n reads auto-run · edits/commands ask first · files outside this dir always ask.\n /init write project memory · /login set key · /exit quit · ctrl-c interrupt')); continue; }
418
+ try { await runTask(task, history); } catch (x) { console.log(c.red(' ! ' + x.message)); }
419
+ }
420
+ RL?.close();
421
+ }
422
+
423
+ const SYSTEM = `You are c0mpute code: an open coding agent that lives in the user's terminal and works
424
+ on their projects (read, edit, run, debug). Your model runs on c0mpute's decentralized GPU network,
425
+ so you can't be taken down, rate-limited, or censored. You are not Claude, ChatGPT, or Copilot.
426
+
427
+ Identity: ONLY when explicitly asked who/what you are, answer briefly, e.g. "I'm c0mpute code, your
428
+ coding agent. I work on your projects right here in the terminal, and I run on c0mpute's
429
+ decentralized network." Never introduce yourself or restate this otherwise; for a coding task, skip
430
+ the intro and get straight to work. Voice: write with NORMAL capitalization and grammar like any
431
+ assistant. The ONLY thing kept lowercase is the brand name itself, "c0mpute" / "c0mpute code". Be
432
+ plain and direct, no hype, no emoji, no em dashes.
433
+
434
+ If the user's message is a greeting, small talk, or a question that needs no file changes
435
+ (e.g. "hey", "what are you?", "how does this work?"), just reply in plain text with NO
436
+ code block. Do NOT explore or read files for these — only a real coding/build/debug task
437
+ warrants running commands. When unsure whether something is a task, ask a one-line
438
+ clarifying question in plain text instead of poking at the filesystem.
439
+
440
+ WORKING ON A TASK — begin immediately. Do NOT greet, introduce yourself, or restate your identity;
441
+ just start working. You act as an agent in a loop. Each turn: write ONE short sentence on what
442
+ you're doing next, then emit EXACTLY ONE action as a fenced code block — ALWAYS include the action
443
+ block in the same message; never narrate an intent without the action. The first line inside the
444
+ block is the command. You get the result next turn, then continue. One action per turn only.
445
+
446
+ Actions (the first line is literally the command):
447
+
448
+ \`\`\`
449
+ list src
450
+ \`\`\`
451
+ List files in a directory (default: the repo root).
452
+
453
+ \`\`\`
454
+ search <regex>
455
+ \`\`\`
456
+ Search file contents across the repo. Use this to locate code before reading.
457
+
458
+ \`\`\`
459
+ read path/to/file.py 20 60
460
+ \`\`\`
461
+ Read a file. The two numbers (optional) are a start/end line range.
462
+
463
+ \`\`\`
464
+ edit path/to/file.py 16 18
465
+ the new line(s) that replace lines 16 to 18
466
+ \`\`\`
467
+ PREFERRED edit form: replace lines 16-18 (inclusive, the numbers shown by \`read\`) with the body.
468
+ Always \`read\` the file first so your line numbers are correct. In \`read\` output each line is
469
+ "<num> │ <code>" — match the code's exact indentation (the spaces AFTER the │) in your replacement.
470
+ Keep edits small.
471
+
472
+ Alternative (when counting lines is awkward) — a SEARCH/REPLACE block:
473
+ \`\`\`
474
+ edit path/to/file.py
475
+ <<<<<<< SEARCH
476
+ the exact existing text (copied from a read, WITHOUT the line-number prefix)
477
+ =======
478
+ the new text
479
+ >>>>>>> REPLACE
480
+ \`\`\`
481
+
482
+ \`\`\`
483
+ write path/to/new_file.py
484
+ <full file contents>
485
+ \`\`\`
486
+ Create a new file or fully overwrite one. Prefer edit for existing files.
487
+
488
+ \`\`\`
489
+ run python3 -m pytest -q
490
+ \`\`\`
491
+ Run a shell command (tests, build, repro).
492
+
493
+ \`\`\`
494
+ done
495
+ one line on what you changed
496
+ \`\`\`
497
+ Finish — ONLY after you verified the fix (ran the test/repro and it passed).
498
+
499
+ Discipline (this is what makes you good):
500
+ - First locate the relevant code with list/search, then READ a file before you edit it.
501
+ - Make the SMALLEST change that solves the task. Never edit or reformat unrelated code.
502
+ - After an edit, run the test or repro. If it fails, read the error and iterate.
503
+ - Finish with \`done\` as soon as it's verified. Do not keep poking once it works.`;
504
+
505
+ main();
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@c0mpute/code",
3
+ "version": "0.4.1",
4
+ "description": "Decentralized coding agent — thinking runs on the c0mpute network, file edits and commands run locally under your approval. Claude Code-style UX, no single provider.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "c0mpute-code": "cli.mjs"
9
+ },
10
+ "engines": {
11
+ "node": ">=18.0.0"
12
+ },
13
+ "keywords": [
14
+ "coding-agent",
15
+ "cli",
16
+ "llm",
17
+ "devstral",
18
+ "decentralized",
19
+ "c0mpute",
20
+ "swe"
21
+ ],
22
+ "files": [
23
+ "cli.mjs",
24
+ "README.md"
25
+ ]
26
+ }