@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/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
- await plan.after?.().catch(() => null);
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 === 0 ? `${item.name} now runs ${after?.version ?? 'what it found'}` : (result.error ?? `exit ${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) => {