@jossuealcala/madre 0.7.0 → 0.7.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/CHANGELOG.md +68 -1
- package/SECURITY.md +1 -0
- package/docs/REFERENCE.md +4 -1
- package/package.json +1 -1
- package/public/app.js +467 -65
- package/public/es.js +102 -5
- package/public/styles.css +126 -19
- package/src/adapters/claude.mjs +4 -3
- package/src/adapters/codex.mjs +7 -4
- package/src/adapters/process.mjs +5 -2
- package/src/choices.mjs +30 -6
- package/src/file-ops.mjs +19 -0
- package/src/models.mjs +25 -1
- package/src/modules/index.mjs +2 -1
- package/src/modules/ollama.mjs +5 -1
- package/src/modules/runs.mjs +34 -0
- package/src/ollama.mjs +19 -0
- package/src/room/control.mjs +18 -1
- package/src/room/prompt.mjs +16 -4
- package/src/room/runs.mjs +88 -0
- package/src/room.mjs +73 -11
- package/src/runs.mjs +119 -0
- package/src/server.mjs +55 -7
package/src/runs.mjs
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// An agent asking the human to run a command.
|
|
2
|
+
//
|
|
3
|
+
// Below #4 AIRLOCK no agent runs anything: it reads, it writes where its mode allows, and that is
|
|
4
|
+
// all. Some answers still turn on a command — the tests after a fix, a build, a linter — and the
|
|
5
|
+
// agent used to say "run npm test and tell me", leaving the human to copy it into a terminal and
|
|
6
|
+
// paste the output back. This gives the request a shape. ```pulse-run carries the reason and the
|
|
7
|
+
// commands; MADRE shows each one as a button; the human presses; the output lands in the room as
|
|
8
|
+
// a card every agent reads on its next turn.
|
|
9
|
+
//
|
|
10
|
+
// Inert, like ```pulse-ask: parsing it runs nothing. The press is the human's, line by line.
|
|
11
|
+
//
|
|
12
|
+
// ```pulse-run
|
|
13
|
+
// Check that the fix did not break the parser
|
|
14
|
+
// $ npm test
|
|
15
|
+
// $ node --check src/runs.mjs
|
|
16
|
+
// ```
|
|
17
|
+
//
|
|
18
|
+
// One command per line, and no shell. A line becomes an argv the way a POSIX shell would split
|
|
19
|
+
// it — quotes and backslashes — and nothing else: no pipes, no `&&`, no redirections, no `$VAR`,
|
|
20
|
+
// no globs. Without a shell those would arrive at the program as literal characters and do
|
|
21
|
+
// something other than what the human read on the button, so a line that has one is refused and
|
|
22
|
+
// says why. Running it through a shell instead would hand an agent's text to `sh` or `cmd.exe`
|
|
23
|
+
// (WIN-016): the human approves a line, and the line that runs has to be the one they read.
|
|
24
|
+
|
|
25
|
+
export const MAX_COMMANDS = 5;
|
|
26
|
+
const MAX_REASON = 300;
|
|
27
|
+
const MAX_LINE = 500;
|
|
28
|
+
|
|
29
|
+
// Shell builtins: without a shell they are not programs, or they are programs that change nothing.
|
|
30
|
+
const BUILTINS = new Set(['cd', 'export', 'source', '.', 'alias', 'unset', 'set', 'eval', 'exec', 'pushd', 'popd']);
|
|
31
|
+
|
|
32
|
+
function clip(text, max) {
|
|
33
|
+
return text.length <= max ? text : `${text.slice(0, max - 1).trimEnd()}…`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// A line split into words the way sh would, without anything sh would expand. Returns
|
|
37
|
+
// { argv } or { why } when the line needs a shell to mean what it says.
|
|
38
|
+
export function splitCommand(line) {
|
|
39
|
+
const argv = [];
|
|
40
|
+
let word = '';
|
|
41
|
+
let inWord = false;
|
|
42
|
+
let quote = null;
|
|
43
|
+
for (let i = 0; i < line.length; i += 1) {
|
|
44
|
+
const char = line[i];
|
|
45
|
+
if (quote === "'") {
|
|
46
|
+
if (char === "'") quote = null; else word += char;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (quote === '"') {
|
|
50
|
+
if (char === '"') { quote = null; continue; }
|
|
51
|
+
if (char === '\\' && i + 1 < line.length && '"\\$`'.includes(line[i + 1])) { word += line[i + 1]; i += 1; continue; }
|
|
52
|
+
if (char === '$' || char === '`') return { why: 'expands a variable or a command inside quotes; there is no shell to expand it' };
|
|
53
|
+
word += char;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (char === "'" || char === '"') { quote = char; inWord = true; continue; }
|
|
57
|
+
if (char === '\\') {
|
|
58
|
+
if (i + 1 < line.length) { word += line[i + 1]; i += 1; inWord = true; continue; }
|
|
59
|
+
return { why: 'ends in a lone backslash' };
|
|
60
|
+
}
|
|
61
|
+
if (/\s/.test(char)) {
|
|
62
|
+
if (inWord) { argv.push(word); word = ''; inWord = false; }
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if ('|&;<>()'.includes(char)) return { why: 'uses "{char}", which needs a shell; put each command on its own line', whyArgs: { char } };
|
|
66
|
+
if (char === '$' || char === '`') return { why: 'expands a variable or a command; there is no shell to expand it' };
|
|
67
|
+
if (char === '*' || char === '?' || char === '[') return { why: 'uses "{char}" as a glob; there is no shell to expand it, name the files', whyArgs: { char } };
|
|
68
|
+
if (char === '~' && !inWord) return { why: 'starts a word with "~"; there is no shell to expand it, write the path' };
|
|
69
|
+
if (char === '#' && !inWord) return { why: 'carries a "#" comment, which only a shell would drop; say it in the reason' };
|
|
70
|
+
word += char;
|
|
71
|
+
inWord = true;
|
|
72
|
+
}
|
|
73
|
+
if (quote) return { why: 'leaves a quote open' };
|
|
74
|
+
if (inWord) argv.push(word);
|
|
75
|
+
if (!argv.length) return { why: 'is empty' };
|
|
76
|
+
if (BUILTINS.has(argv[0])) return { why: '"{name}" is a shell builtin; every line already runs from the project root', whyArgs: { name: argv[0] } };
|
|
77
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(argv[0])) return { why: 'sets an environment variable, which needs a shell' };
|
|
78
|
+
return { argv };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// A refusal is a template so the room can say it in the human's language; this fills it in.
|
|
82
|
+
export const whyText = (command) => String(command?.why ?? '').replace(/\{(\w+)\}/g, (match, key) => command?.whyArgs?.[key] ?? match);
|
|
83
|
+
|
|
84
|
+
// The last ```pulse-run block of a reply, if it closes the reply. Returns null for anything else:
|
|
85
|
+
// a block quoted mid-answer is an example, and an example is not a request.
|
|
86
|
+
export function parseRunRequest(text) {
|
|
87
|
+
const source = String(text ?? '');
|
|
88
|
+
const blocks = [...source.matchAll(/```pulse-run\s*\n([\s\S]*?)```/gi)];
|
|
89
|
+
if (!blocks.length) return null;
|
|
90
|
+
const last = blocks.at(-1);
|
|
91
|
+
if (source.slice(last.index + last[0].length).trim()) return null;
|
|
92
|
+
|
|
93
|
+
const lines = last[1].split('\n').map((line) => line.trim()).filter(Boolean);
|
|
94
|
+
const reason = lines[0] && !lines[0].startsWith('$') ? clip(lines[0], MAX_REASON) : null;
|
|
95
|
+
const commands = [];
|
|
96
|
+
const seen = new Set();
|
|
97
|
+
for (const raw of lines.slice(reason ? 1 : 0)) {
|
|
98
|
+
if (!raw.startsWith('$')) continue;
|
|
99
|
+
const line = raw.slice(1).trim();
|
|
100
|
+
if (!line || seen.has(line)) continue;
|
|
101
|
+
seen.add(line);
|
|
102
|
+
if (commands.length === MAX_COMMANDS) break;
|
|
103
|
+
if (line.length > MAX_LINE) { commands.push({ line: clip(line, MAX_LINE), why: 'is too long to read before pressing it' }); continue; }
|
|
104
|
+
const split = splitCommand(line);
|
|
105
|
+
commands.push(split.argv ? { line, argv: split.argv } : { line, why: split.why, ...(split.whyArgs ? { whyArgs: split.whyArgs } : {}) });
|
|
106
|
+
}
|
|
107
|
+
// A request without a reason asks the human to run something on faith.
|
|
108
|
+
if (!reason || !commands.length) return null;
|
|
109
|
+
return { reason, commands };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// What the reply reads as once the block has been lifted out of it: the room shows the commands
|
|
113
|
+
// as buttons, so leaving the raw fence in the text would say everything twice.
|
|
114
|
+
export function withoutRunRequest(text) {
|
|
115
|
+
const source = String(text ?? '');
|
|
116
|
+
if (!parseRunRequest(source)) return source;
|
|
117
|
+
const last = [...source.matchAll(/```pulse-run\s*\n[\s\S]*?```/gi)].at(-1);
|
|
118
|
+
return source.slice(0, last.index).trimEnd();
|
|
119
|
+
}
|
package/src/server.mjs
CHANGED
|
@@ -17,7 +17,7 @@ import { createEmbedder } from './embeddings.mjs';
|
|
|
17
17
|
import { memoryServerFor } from './memory-tools.mjs';
|
|
18
18
|
import { MotherChannel, CODE000_STRIKES } from './mother.mjs';
|
|
19
19
|
import { ErrorSentinel } from './sentinel-errors.mjs';
|
|
20
|
-
import { probeOllama, ollamaEmbedder, ollamaInvoker, pullModel } from './ollama.mjs';
|
|
20
|
+
import { probeOllama, ollamaEmbedder, ollamaInvoker, pullModel, ollamaVersions, ownOllamaServers } from './ollama.mjs';
|
|
21
21
|
import { moduleById, describeModules, findModuleRoute, toolsForTurn as modulesToolsForTurn, loadExternalModules, loadFailures, moduleFolders, moduleCommands, installModuleFile, installModuleText, verifyModuleText, moduleOrigin, removeExternalModule } from './modules/index.mjs';
|
|
22
22
|
import { madreAgent, madreInvoker, MADRE_AGENT_ID, MADRE_ADAPTER } from './adapters/madre.mjs';
|
|
23
23
|
import { exportDataset, readiness as datasetReadiness } from './dataset.mjs';
|
|
@@ -65,7 +65,7 @@ import { setImageModule } from './capabilities.mjs';
|
|
|
65
65
|
import { resolveGeminiKey } from './image-studio.mjs';
|
|
66
66
|
import { commandByName, listCommands, parseCommand } from './commands.mjs';
|
|
67
67
|
import { listDirectory, searchFiles, readServable, storeAttachment, resolveInside, MAX_ATTACHMENT_BYTES } from './files.mjs';
|
|
68
|
-
import { planFileOp, CREATIONS } from './file-ops.mjs';
|
|
68
|
+
import { planFileOp, freeCopyPath, CREATIONS } from './file-ops.mjs';
|
|
69
69
|
import { Eyecat } from './eyecat-watch.mjs';
|
|
70
70
|
import { economy } from './room/economy.mjs';
|
|
71
71
|
import { maturity } from './maturity.mjs';
|
|
@@ -560,6 +560,7 @@ export async function createPulseServer({
|
|
|
560
560
|
setImageModule(startupConfig.modules?.imageStudio ?? {});
|
|
561
561
|
room.setAsh(Boolean(startupConfig.modules?.ash?.enabled));
|
|
562
562
|
room.setChoices(Boolean(startupConfig.modules?.choices?.enabled));
|
|
563
|
+
room.setRuns(Boolean(startupConfig.modules?.runs?.enabled));
|
|
563
564
|
const recoveredTurns = await room.reconcile();
|
|
564
565
|
if (recoveredTurns) console.error(`MADRE recovered ${recoveredTurns} unfinished turn(s) from a previous run.`);
|
|
565
566
|
const quotaMonitor = new QuotaMonitor({
|
|
@@ -771,6 +772,29 @@ export async function createPulseServer({
|
|
|
771
772
|
await room.record('extension.install.finished', { id: 'ollama', name: 'OLLAMA', ok: status.running, installed: true, detail: status.running ? `running · ${status.chatModel ? `@madre with ${status.chatModel}` : 'no chat model yet'}` : 'Ollama did not answer; open the Ollama app and press RECHECK.', ollama: status });
|
|
772
773
|
return status.running ? { ok: true } : { ok: false, error: t('Ollama did not answer. Open the Ollama app, then press RECHECK.') };
|
|
773
774
|
},
|
|
775
|
+
// After an upgrade the binary is new and the server answering on the port is still the old
|
|
776
|
+
// one, so the card kept reading the old version and the update looked like it did nothing.
|
|
777
|
+
// An `ollama serve` this user started is stopped and started again from the new binary;
|
|
778
|
+
// anything else (the app, a service of another user) is named, not touched.
|
|
779
|
+
async restart() {
|
|
780
|
+
const binary = await findOnPath('ollama');
|
|
781
|
+
if (!binary) return { ok: false, error: t('Ollama is not on this computer yet.') };
|
|
782
|
+
const run = (command, args) => new Promise((resolve) => execFile(command, args, { timeout: 5000 }, (error, stdout, stderr) => resolve(`${stdout ?? ''}${stderr ?? ''}`)));
|
|
783
|
+
const { client } = ollamaVersions(await run(binary, ['--version']));
|
|
784
|
+
const status = await wireOllama();
|
|
785
|
+
if (!status.running) return this.start();
|
|
786
|
+
if (!client || !status.version || status.version === client) return { ok: true, already: true };
|
|
787
|
+
const pids = process.platform === 'win32' || typeof process.getuid !== 'function' ? [] : ownOllamaServers(await run('ps', ['-axo', 'pid=,uid=,args=']), process.getuid());
|
|
788
|
+
if (!pids.length) return { ok: false, error: t('Ollama {client} is installed, but {running} is still the one answering. Quit Ollama (or restart its service) and open it again, then RECHECK.', { client, running: status.version }) };
|
|
789
|
+
for (const pid of pids) { try { process.kill(pid, 'SIGTERM'); } catch { /* already gone */ } }
|
|
790
|
+
let down = status;
|
|
791
|
+
for (let attempt = 0; attempt < 20 && down.running; attempt += 1) {
|
|
792
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
793
|
+
down = await wireOllama();
|
|
794
|
+
}
|
|
795
|
+
if (down.running) return { ok: false, error: t('The old Ollama ({running}) did not stop. Quit it by hand, then press START OLLAMA.', { running: status.version }) };
|
|
796
|
+
return this.start();
|
|
797
|
+
},
|
|
774
798
|
// Pulls stream into the room like a module install; one at a time.
|
|
775
799
|
pull: async (model) => {
|
|
776
800
|
if (installing) return { ok: false, error: `Another install is running (${installing}).` };
|
|
@@ -909,6 +933,7 @@ export async function createPulseServer({
|
|
|
909
933
|
agents: agents.map((agent) => ({ ...agent, login: loginPlanFor(agent), install: installPlanFor(agent), key: keyPlanFor(agent.id), ...(accountNoteFor(agent.id) ?? {}) })),
|
|
910
934
|
ash: { enabled: room.ashEnabled() },
|
|
911
935
|
choices: { enabled: room.choicesEnabled() },
|
|
936
|
+
runs: { enabled: room.runsEnabled() },
|
|
912
937
|
// Read live, not from the config this process loaded at boot. A switch the human moves
|
|
913
938
|
// in MODULES while the room is open has to be the answer the room gives afterwards —
|
|
914
939
|
// otherwise the switch says on and the browser keeps refusing, which is how a setting
|
|
@@ -959,8 +984,18 @@ export async function createPulseServer({
|
|
|
959
984
|
// room guards from agents ask for the project's designation first.
|
|
960
985
|
if (request.method === 'POST' && url.pathname === '/api/tree/op') {
|
|
961
986
|
const payload = await body(request).catch(() => ({}));
|
|
987
|
+
// Pasting a copy where its name is taken — beside itself, most often — names the copy
|
|
988
|
+
// instead of refusing. Only a copy, and only when the page asked: a move or a rename onto
|
|
989
|
+
// a taken name is still a mistake the human should hear about.
|
|
990
|
+
if (payload.operation === 'copy' && payload.unique) {
|
|
991
|
+
payload.to = await freeCopyPath(payload.to, { word: t('copy'), taken: async (path) => {
|
|
992
|
+
const dir = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '.';
|
|
993
|
+
const parent = await resolveInside(canonicalProjectRoot, dir);
|
|
994
|
+
return Boolean(parent) && access(join(parent, path.slice(path.lastIndexOf('/') + 1))).then(() => true, () => false);
|
|
995
|
+
} });
|
|
996
|
+
}
|
|
962
997
|
const plan = planFileOp(payload);
|
|
963
|
-
if (!plan.ok) return sendJson(response, 400, { error: plan.error });
|
|
998
|
+
if (!plan.ok) return sendJson(response, 400, { error: t(plan.error) });
|
|
964
999
|
if (plan.guarded && !designationOk(payload.designation)) {
|
|
965
1000
|
return sendJson(response, 403, { error: t('UNABLE TO COMPUTE. UNABLE TO CLARIFY.'), guarded: true });
|
|
966
1001
|
}
|
|
@@ -980,7 +1015,7 @@ export async function createPulseServer({
|
|
|
980
1015
|
else if (plan.operation === 'copy') await cp(source, destination, { recursive: true, errorOnExist: true, force: false });
|
|
981
1016
|
else await rename(source, destination);
|
|
982
1017
|
} catch (error) {
|
|
983
|
-
return sendJson(response, 500, { error: error.message });
|
|
1018
|
+
return sendJson(response, 500, { error: t('That could not be done: {error}', { error: error.message }) });
|
|
984
1019
|
}
|
|
985
1020
|
await room.record(CREATIONS.includes(plan.operation) ? 'project.file.created' : 'project.file.moved', { operation: plan.operation, from: plan.from, to: plan.to, guarded: plan.guarded });
|
|
986
1021
|
return sendJson(response, 200, { ok: true, operation: plan.operation, from: plan.from, to: plan.to });
|
|
@@ -1481,11 +1516,15 @@ export async function createPulseServer({
|
|
|
1481
1516
|
await room.record('extension.install.started', { id: module.id, name: item.name, command: plan.display, platforms: [], alreadyInstalled: true });
|
|
1482
1517
|
void (async () => {
|
|
1483
1518
|
const result = await runInstaller({ command: plan.command, args: plan.args, projectRoot: canonicalProjectRoot, timeoutMs: 900000, onLine: (line) => { void room.record('extension.install.output', { id: module.id, lines: [String(line).slice(0, 500)] }); } });
|
|
1484
|
-
|
|
1519
|
+
// What the plan does once the command is done (Ollama: restart the old server). If it could
|
|
1520
|
+
// not, that is the outcome: «now runs <the old version>» would read as an update that worked.
|
|
1521
|
+
const settled = result.code === 0 ? await (async () => plan.after?.())().catch((error) => ({ ok: false, error: error.message })) : null;
|
|
1485
1522
|
const after = await module.describe(await moduleContext()).catch(() => null);
|
|
1486
1523
|
await room.record('extension.install.finished', {
|
|
1487
|
-
id: module.id, name: item.name, ok: result.code === 0, installed: Boolean(after?.version),
|
|
1488
|
-
detail: result.code
|
|
1524
|
+
id: module.id, name: item.name, ok: result.code === 0 && settled?.ok !== false, installed: Boolean(after?.version),
|
|
1525
|
+
detail: result.code !== 0 ? (result.error ?? `exit ${result.code}`)
|
|
1526
|
+
: settled?.ok === false ? settled.error
|
|
1527
|
+
: `${item.name} now runs ${after?.version ?? 'what it found'}`,
|
|
1489
1528
|
});
|
|
1490
1529
|
installing = null;
|
|
1491
1530
|
})();
|
|
@@ -1617,6 +1656,15 @@ export async function createPulseServer({
|
|
|
1617
1656
|
const stopped = await room.stopPlan(stopMatch[1]);
|
|
1618
1657
|
return sendJson(response, stopped ? 202 : 404, stopped ? { stopped: true } : { error: t('No running plan with that id.') });
|
|
1619
1658
|
}
|
|
1659
|
+
// One line of a ```pulse-run, pressed by the human. The body names the request and the line;
|
|
1660
|
+
// the command itself is read from the room's record, never from the page.
|
|
1661
|
+
if (request.method === 'POST' && url.pathname === '/api/runs') {
|
|
1662
|
+
const payload = await body(request).catch(() => ({}));
|
|
1663
|
+
const index = Number(payload.index);
|
|
1664
|
+
if (typeof payload.responseMessageId !== 'string' || !Number.isInteger(index) || index < 0) return sendJson(response, 400, { error: t('Name the request and the line to run.') });
|
|
1665
|
+
const outcome = await room.runRequested({ responseMessageId: payload.responseMessageId, index });
|
|
1666
|
+
return sendJson(response, outcome.status, outcome.ok ? outcome.result : { error: outcome.error });
|
|
1667
|
+
}
|
|
1620
1668
|
if (request.method === 'GET' && url.pathname === '/api/commands') {
|
|
1621
1669
|
const ctx = await moduleContext();
|
|
1622
1670
|
const fromModules = await Promise.all(moduleCommands().map(async (command) => {
|