@jossuealcala/madre 0.7.1 → 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/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) => {