@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/CHANGELOG.md +61 -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 +4 -3
- 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/modules/ollama.mjs
CHANGED
|
@@ -75,7 +75,11 @@ export default defineModule({
|
|
|
75
75
|
},
|
|
76
76
|
async updatePlan(ctx) {
|
|
77
77
|
const { findOnPath } = await import('./helpers.mjs');
|
|
78
|
-
|
|
78
|
+
const plan = ollamaUpdatePlan({ brew: await findOnPath('brew') });
|
|
79
|
+
if (!plan.command) return plan;
|
|
80
|
+
// Upgrading replaces the binary, not the server already running: until it restarts, the old
|
|
81
|
+
// version keeps answering. Said before the button is pressed, done after the command.
|
|
82
|
+
return { ...plan, note: `${plan.note} If an older Ollama is still the one answering, MADRE restarts it so the new one does.`, after: () => ctx.services.ollama.restart?.() ?? null };
|
|
79
83
|
},
|
|
80
84
|
async toggle(ctx) {
|
|
81
85
|
const enabled = !(ctx.settings.enabled ?? true);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Runs: letting an agent ask the human to run a command instead of asking them to copy it.
|
|
2
|
+
//
|
|
3
|
+
// Below #4 AIRLOCK an agent cannot run anything, and some answers turn on a command anyway: the
|
|
4
|
+
// tests after a fix, a build, a linter. This gives that request a shape — a fenced block the room
|
|
5
|
+
// turns into one button per command — and brings the output back to the room as a card every
|
|
6
|
+
// agent reads on its next turn.
|
|
7
|
+
//
|
|
8
|
+
// Nothing an agent writes runs by itself. Each line is pressed by the human, runs alone from the
|
|
9
|
+
// project root with no shell, and is photographed first so UNDO can put the files back. What a
|
|
10
|
+
// command sends off this machine does not come back with UNDO, and the button says what it runs
|
|
11
|
+
// before it is pressed.
|
|
12
|
+
//
|
|
13
|
+
// It has a switch because it is not free: the instruction that teaches the block rides in every
|
|
14
|
+
// prompt below AIRLOCK while this is on, measured in the economy as `runs`.
|
|
15
|
+
|
|
16
|
+
import { defineModule } from './sdk.mjs';
|
|
17
|
+
import { t } from '../i18n.mjs';
|
|
18
|
+
|
|
19
|
+
export default defineModule({
|
|
20
|
+
id: 'runs',
|
|
21
|
+
configKey: 'runs',
|
|
22
|
+
name: 'Runs',
|
|
23
|
+
vendor: 'MADRE',
|
|
24
|
+
version: '1.0.0',
|
|
25
|
+
summary: 'Lets an agent ask you to run a command it cannot run in its mode (tests, a build, a linter). Each command is a button: nothing runs until you press it, it runs from the project root with no shell, a checkpoint is taken first, and the output comes back to the room. Costs about 190 tokens of instruction per turn below AIRLOCK while it is on.',
|
|
26
|
+
creates: ['nothing in the project', 'a switch in ~/.pulse/config.json'],
|
|
27
|
+
async status(ctx) {
|
|
28
|
+
return {
|
|
29
|
+
status: { installed: Boolean(ctx.settings.enabled), detail: ctx.settings.enabled ? t('on · agents may ask you to run commands') : t('off · agents describe the command in prose') },
|
|
30
|
+
install: { display: ctx.settings.enabled ? 'stop offering commands' : 'let agents ask for commands', platforms: [] },
|
|
31
|
+
};
|
|
32
|
+
},
|
|
33
|
+
async onToggle(ctx, enabled) { ctx.room.setRuns(enabled); return null; },
|
|
34
|
+
});
|
package/src/ollama.mjs
CHANGED
|
@@ -28,6 +28,25 @@ function pick(models, preferred, env) {
|
|
|
28
28
|
return names[0] ?? null;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
// Two versions, and they are not the same thing. `ollama --version` names the server that answers
|
|
32
|
+
// on the port and, when it differs, adds a warning with the client's own: that second one is what
|
|
33
|
+
// is installed. An upgrade replaces the binary and leaves the old server running — and that old
|
|
34
|
+
// server then starts the NEW runner, which is how a model load ends in `invalid argument`.
|
|
35
|
+
export function ollamaVersions(output) {
|
|
36
|
+
const text = String(output ?? '');
|
|
37
|
+
const server = text.match(/ollama version is\s+(\S+)/i)?.[1] ?? null;
|
|
38
|
+
const client = text.match(/client version is\s+(\S+)/i)?.[1] ?? server;
|
|
39
|
+
return { server, client };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// The `ollama serve` processes this user owns. Only those: an Ollama app, or a system service
|
|
43
|
+
// running as another user, is not MADRE's to stop, and the room says so instead.
|
|
44
|
+
export function ownOllamaServers(psOutput, uid) {
|
|
45
|
+
return String(psOutput ?? '').split('\n').map((line) => line.trim().match(/^(\d+)\s+(\d+)\s+(.+)$/)).filter(Boolean)
|
|
46
|
+
.filter(([, , owner, args]) => Number(owner) === uid && /(^|\/)ollama serve$/.test(args.trim()))
|
|
47
|
+
.map(([, pid]) => Number(pid));
|
|
48
|
+
}
|
|
49
|
+
|
|
31
50
|
// Is Ollama there, and what can it do? Quick, never throws.
|
|
32
51
|
export async function probeOllama({ host = ollamaHost(), fetchImpl = globalThis.fetch, timeoutMs = 1500, env = process.env } = {}) {
|
|
33
52
|
try {
|
package/src/room/control.mjs
CHANGED
|
@@ -75,11 +75,28 @@ export class ControlDesk {
|
|
|
75
75
|
if (this.#holder === run) this.#holder = null;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
// A photograph with no seat and no locks, for a command the human runs by hand: the human may
|
|
79
|
+
// write anywhere, so nothing is guarded and nothing is reverted, but UNDO still has a place to
|
|
80
|
+
// go back to. The same map as CONTROL's, so the same UNDO restores it.
|
|
81
|
+
async photograph({ id, label, by = 'you', command = null }) {
|
|
82
|
+
const checkpoint = await createCheckpoint(this.#projectRoot, { id, label });
|
|
83
|
+
checkpoint.agent = by;
|
|
84
|
+
checkpoint.command = command;
|
|
85
|
+
this.#checkpoints.set(checkpoint.id, checkpoint);
|
|
86
|
+
return checkpoint;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async changesSince(checkpoint) {
|
|
90
|
+
const { files, stat } = await diffCheckpoint(this.#projectRoot, checkpoint);
|
|
91
|
+
return { files, stat };
|
|
92
|
+
}
|
|
93
|
+
|
|
78
94
|
async undo(checkpointId) {
|
|
79
95
|
const checkpoint = this.#checkpoints.get(checkpointId);
|
|
80
96
|
if (!checkpoint) return { ok: false, status: 404, error: 'That checkpoint is not known to this room.' };
|
|
81
97
|
if (this.#holder?.checkpoint.id === checkpointId) return { ok: false, status: 409, error: 'That CONTROL turn is still running; STOPALL first.' };
|
|
82
98
|
const result = await restoreCheckpoint(this.#projectRoot, checkpoint);
|
|
83
|
-
|
|
99
|
+
const before = checkpoint.command ? `\`${checkpoint.command}\`` : `@${checkpoint.agent}'s CONTROL turn`;
|
|
100
|
+
return { ok: true, checkpoint, ...result, message: `Project restored to the checkpoint taken before ${before}: ${result.restored.length} file(s) restored, ${result.removed.length} removed.` };
|
|
84
101
|
}
|
|
85
102
|
}
|
package/src/room/prompt.mjs
CHANGED
|
@@ -40,13 +40,15 @@ export const BLOCK_SOURCES = {
|
|
|
40
40
|
recall: { when: 'older exchanges match the request and fit in the budget', where: '⚙ CONNECTIONS → MEMORY · RECALL · % OF CONTEXT' },
|
|
41
41
|
context: { when: 'the room has a transcript', where: 'PULSE_CONTEXT_MAX_CHARS sets the budget; RECALL · % OF CONTEXT splits it' },
|
|
42
42
|
delegation: { when: 'this turn may open a plan', where: '⚙ CONNECTIONS → ROOM SETTINGS → DELEGATION' },
|
|
43
|
+
choices: { when: 'Choices is on and the turn is not GHOST', where: 'MODULES → CHOICES' },
|
|
44
|
+
runs: { when: 'Runs is on and the turn is #1 to #3', where: 'MODULES → RUNS' },
|
|
43
45
|
abilities: { when: 'this turn may open a plan', where: '⚙ CONNECTIONS → each agent\'s abilities and MAX MODE' },
|
|
44
46
|
attachments: { when: 'the message carries attached files', where: null },
|
|
45
47
|
references: { when: 'the message points at project files with !', where: null },
|
|
46
48
|
ask: { when: 'the human is the one asking', where: null },
|
|
47
49
|
};
|
|
48
50
|
|
|
49
|
-
export const PROMPT_BLOCKS = ['room', 'who', 'style', 'privacy', 'madre', 'memory-server', 'mode', 'inspect', 'ash', 'mother', 'mcp', 'sdk', 'lease', 'control-held', 'escalation', 'web', 'shared-lease', 'memories', 'recall', 'context', 'delegation', 'abilities', 'attachments', 'references', 'ask'];
|
|
51
|
+
export const PROMPT_BLOCKS = ['room', 'who', 'style', 'privacy', 'madre', 'memory-server', 'mode', 'inspect', 'ash', 'mother', 'mcp', 'sdk', 'lease', 'control-held', 'escalation', 'web', 'shared-lease', 'memories', 'recall', 'context', 'delegation', 'choices', 'runs', 'abilities', 'attachments', 'references', 'ask'];
|
|
50
52
|
|
|
51
53
|
// Whether this turn could plausibly produce a MADRE module. The SDK block is long and is only
|
|
52
54
|
// ever of use when the human is asking for one, so it is not carried by every leased turn. The
|
|
@@ -69,7 +71,7 @@ export function sparedChars(options) {
|
|
|
69
71
|
export function promptParts({
|
|
70
72
|
agent, text, requester, depth, allowDelegation, context, recall = null, memories = null,
|
|
71
73
|
attachments = [], references = [], lease = null, scopes = null, imageStudio = null,
|
|
72
|
-
sharedLeaseHint = null, ash = false, choices = false, mode = 1, escalation = null, mcpServers = [],
|
|
74
|
+
sharedLeaseHint = null, ash = false, choices = false, runs = false, mode = 1, escalation = null, mcpServers = [],
|
|
73
75
|
// What the room adds:
|
|
74
76
|
others = [], delegation = true, maxPlanSteps = 4, scopesFor = () => ({}), motherLines = [], memoryServer = null, controlHolder = null, privacyMarker = '[ENTIDAD-ORG]', madreModel = null, sdk = null,
|
|
75
77
|
}) {
|
|
@@ -128,11 +130,21 @@ export function promptParts({
|
|
|
128
130
|
'If answering well means the human has to pick between three or more real options, end your reply with one fenced block:',
|
|
129
131
|
'```pulse-ask',
|
|
130
132
|
'<the question, one line>',
|
|
133
|
+
'- <the option you would pick> (recommended)',
|
|
131
134
|
'- <option>',
|
|
132
135
|
'- <option>',
|
|
133
|
-
'- <option>',
|
|
134
136
|
'```',
|
|
135
|
-
'Three to six options, the question first, nothing after the block. MADRE shows them as buttons and pressing one writes it into the composer; nothing runs and no turn is spent until the human presses. Not for yes/no, and never instead of a recommendation you could simply give.',
|
|
137
|
+
'Three to six options, the question first, nothing after the block. Exactly one option ends with "(recommended)": the one you would pick, listed first. Write each option whole, as the sentence the human would send; they are shown in full. MADRE shows them as buttons and pressing one writes it into the composer; nothing runs and no turn is spent until the human presses. Not for yes/no, and never instead of a recommendation you could simply give.',
|
|
138
|
+
].join('\n')
|
|
139
|
+
: null },
|
|
140
|
+
{ id: 'runs', text: runs
|
|
141
|
+
? [
|
|
142
|
+
'You cannot run commands in this mode. If your answer depends on one (tests, a build, a linter, a git query), end your reply with one fenced block and the human may run it for you:',
|
|
143
|
+
'```pulse-run',
|
|
144
|
+
'<why you need it, one line>',
|
|
145
|
+
'$ <command>',
|
|
146
|
+
'```',
|
|
147
|
+
`The reason first, then one command per line, each starting with "$", at most 5, nothing after the block. There is no shell: each line runs alone from the project root, so no pipes, &&, redirections, $VARIABLES, globs, ~ or cd. Quote arguments with spaces. MADRE shows each line as a button; nothing runs until the human presses it, and its output comes back to the room for your next turn. Ask only for what you cannot learn by reading the files, and never for something that changes what lives outside this machine.`,
|
|
136
148
|
].join('\n')
|
|
137
149
|
: null },
|
|
138
150
|
{ id: 'abilities', text: mayDelegate ? `Abilities right now (route each step to an agent that can do it):\n${[agent.id, ...others].map((id) => abilityLine(id, scopesFor(id))).join('\n')}` : null },
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// The human's hands for a command an agent asked for. One command at a time, in the project
|
|
2
|
+
// root, with no shell: the argv the parser split is the argv that runs (see src/runs.mjs).
|
|
3
|
+
//
|
|
4
|
+
// stdin is closed, so a program that wants to ask something reads end-of-file and gives up
|
|
5
|
+
// instead of hanging on a prompt nobody can see. The output is one stream, stdout and stderr
|
|
6
|
+
// interleaved the way a terminal shows them, and what is kept of a long one is its start and its
|
|
7
|
+
// end: a test run says what it is at the top and how it went at the bottom.
|
|
8
|
+
|
|
9
|
+
import { spawn } from 'node:child_process';
|
|
10
|
+
import { terminateProcessTree } from '../adapters/process.mjs';
|
|
11
|
+
|
|
12
|
+
export const RUN_TIMEOUT_MS = 10 * 60 * 1000;
|
|
13
|
+
const HEAD_CHARS = 4000;
|
|
14
|
+
const TAIL_CHARS = 14000;
|
|
15
|
+
|
|
16
|
+
export function keepEnds(text, { head = HEAD_CHARS, tail = TAIL_CHARS } = {}) {
|
|
17
|
+
if (text.length <= head + tail) return text;
|
|
18
|
+
const dropped = text.length - head - tail;
|
|
19
|
+
return `${text.slice(0, head)}\n… ${dropped} characters of output left out …\n${text.slice(-tail)}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class RunDesk {
|
|
23
|
+
#child = null;
|
|
24
|
+
#stoppedBy = null;
|
|
25
|
+
|
|
26
|
+
get busy() { return Boolean(this.#child); }
|
|
27
|
+
|
|
28
|
+
// Resolves with what happened; never rejects. A program that could not start is an answer too.
|
|
29
|
+
run({ argv, cwd, timeoutMs = RUN_TIMEOUT_MS, env = process.env }) {
|
|
30
|
+
return new Promise((resolve) => {
|
|
31
|
+
const started = Date.now();
|
|
32
|
+
// 'error' and 'close' can both arrive for one failed spawn; the first one is the answer.
|
|
33
|
+
let settled = false;
|
|
34
|
+
const done = (result) => {
|
|
35
|
+
if (settled) return;
|
|
36
|
+
settled = true;
|
|
37
|
+
this.#child = null;
|
|
38
|
+
resolve({ ...result, durationMs: Date.now() - started });
|
|
39
|
+
};
|
|
40
|
+
let child;
|
|
41
|
+
try {
|
|
42
|
+
child = spawn(argv[0], argv.slice(1), {
|
|
43
|
+
cwd,
|
|
44
|
+
env: { ...env, NO_COLOR: '1', FORCE_COLOR: '0', GIT_PAGER: 'cat', PAGER: 'cat' },
|
|
45
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
46
|
+
shell: false,
|
|
47
|
+
detached: process.platform !== 'win32',
|
|
48
|
+
windowsHide: true,
|
|
49
|
+
});
|
|
50
|
+
} catch (error) {
|
|
51
|
+
done({ ok: false, exitCode: null, text: couldNotRun(argv[0], error) });
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
this.#child = child;
|
|
55
|
+
this.#stoppedBy = null;
|
|
56
|
+
let output = '';
|
|
57
|
+
// Bounded while it runs, not only at the end: a program that prints without stopping must
|
|
58
|
+
// not grow the server's memory for ten minutes.
|
|
59
|
+
const take = (chunk) => {
|
|
60
|
+
output += chunk;
|
|
61
|
+
if (output.length > (HEAD_CHARS + TAIL_CHARS) * 2) output = keepEnds(output);
|
|
62
|
+
};
|
|
63
|
+
child.stdout.on('data', take);
|
|
64
|
+
child.stderr.on('data', take);
|
|
65
|
+
const timer = setTimeout(() => { this.#stoppedBy = `timeout (${Math.round(timeoutMs / 1000)}s)`; terminateProcessTree(child); }, timeoutMs);
|
|
66
|
+
timer.unref?.();
|
|
67
|
+
child.on('error', (error) => { clearTimeout(timer); done({ ok: false, exitCode: null, text: couldNotRun(argv[0], error) }); });
|
|
68
|
+
child.on('close', (code, signal) => {
|
|
69
|
+
clearTimeout(timer);
|
|
70
|
+
const stoppedBy = this.#stoppedBy;
|
|
71
|
+
const tail = stoppedBy ? `\n[stopped: ${stoppedBy}]` : signal ? `\n[ended on ${signal}]` : code !== 0 ? `\n[exit code ${code}]` : '';
|
|
72
|
+
done({ ok: code === 0 && !stoppedBy, exitCode: code, signal: signal ?? undefined, stoppedBy: stoppedBy ?? undefined, text: `${keepEnds(output).trimEnd()}${tail}`.trim() });
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
stop(reason = 'STOPALL') {
|
|
78
|
+
if (!this.#child) return false;
|
|
79
|
+
this.#stoppedBy = reason;
|
|
80
|
+
terminateProcessTree(this.#child);
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function couldNotRun(program, error) {
|
|
86
|
+
if (error?.code === 'ENOENT') return `${program} could not start: it is not installed, or not on the PATH MADRE was started with.`;
|
|
87
|
+
return `${program} could not start: ${error?.message ?? error}`;
|
|
88
|
+
}
|
package/src/room.mjs
CHANGED
|
@@ -17,6 +17,7 @@ import { coverageExam, consistencyExam, matchExam, exchanges, MATCH_SAMPLE, says
|
|
|
17
17
|
import { MADRE_ADAPTER, MADRE_AGENT_ID } from './adapters/madre.mjs';
|
|
18
18
|
import { verdictFor } from './verdict.mjs';
|
|
19
19
|
import { ControlDesk } from './room/control.mjs';
|
|
20
|
+
import { RunDesk } from './room/runs.mjs';
|
|
20
21
|
import { Attachments } from './room/attachments.mjs';
|
|
21
22
|
import { GhostLedger } from './room/ghost.mjs';
|
|
22
23
|
import { Escalation } from './room/escalation.mjs';
|
|
@@ -27,7 +28,7 @@ import { OLLAMA_ARCHIVIST } from './distiller.mjs';
|
|
|
27
28
|
import { memoryServerForTurn } from './memory-tools.mjs';
|
|
28
29
|
import { CODE000_STRIKES } from './mother.mjs';
|
|
29
30
|
import { parseDirectives } from './directives.mjs';
|
|
30
|
-
import { isValidModelName } from './models.mjs';
|
|
31
|
+
import { isValidModelName, isValidEffort } from './models.mjs';
|
|
31
32
|
import { MODES, SCOPES, SCOPE_LABELS, capabilitySummary, normalizeMode, resolveScopes } from './capabilities.mjs';
|
|
32
33
|
import { createLease, diffSnapshots, namedPaths, snapshot } from './lease.mjs';
|
|
33
34
|
import { stat as statFile } from 'node:fs/promises';
|
|
@@ -36,6 +37,7 @@ import { contentTypeFor } from './files.mjs';
|
|
|
36
37
|
import { isModuleFile, sdkPaths } from './modules/index.mjs';
|
|
37
38
|
import { validateAshReply } from './modules/ash-policy.mjs';
|
|
38
39
|
import { parseChoice, withoutChoice } from './choices.mjs';
|
|
40
|
+
import { parseRunRequest, withoutRunRequest, whyText } from './runs.mjs';
|
|
39
41
|
import { imageStudioFor } from './image-studio.mjs';
|
|
40
42
|
import { CAPABILITIES, imageModuleState } from './capabilities.mjs';
|
|
41
43
|
import { resolveReferences } from './files.mjs';
|
|
@@ -134,6 +136,9 @@ export class Room {
|
|
|
134
136
|
#planMaxAgeMs;
|
|
135
137
|
#ashEnabled = false;
|
|
136
138
|
#choicesEnabled = false;
|
|
139
|
+
#runsEnabled = false;
|
|
140
|
+
#runDesk = new RunDesk();
|
|
141
|
+
#runBusy = false;
|
|
137
142
|
|
|
138
143
|
constructor({
|
|
139
144
|
store,
|
|
@@ -228,7 +233,7 @@ export class Room {
|
|
|
228
233
|
const result = await this.#controlDesk.undo(checkpointId);
|
|
229
234
|
if (!result.ok) return result;
|
|
230
235
|
const { checkpoint, message, ...rest } = result;
|
|
231
|
-
await this.#emit('control.reverted', { checkpointId, agent: checkpoint.agent, removed: rest.removed, restored: rest.restored, message });
|
|
236
|
+
await this.#emit('control.reverted', { checkpointId, agent: checkpoint.agent, command: checkpoint.command ?? undefined, removed: rest.removed, restored: rest.restored, message });
|
|
232
237
|
return { ok: true, ...rest };
|
|
233
238
|
}
|
|
234
239
|
|
|
@@ -764,6 +769,53 @@ export class Room {
|
|
|
764
769
|
return this.#choicesEnabled;
|
|
765
770
|
}
|
|
766
771
|
|
|
772
|
+
setRuns(enabled) {
|
|
773
|
+
this.#runsEnabled = Boolean(enabled);
|
|
774
|
+
return this.#runsEnabled;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
runsEnabled() {
|
|
778
|
+
return this.#runsEnabled;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// The human pressed one line of a ```pulse-run an agent wrote. The client names the request and
|
|
782
|
+
// the line, never the command: what runs is read back from the room's own record of what the
|
|
783
|
+
// agent asked, so a page cannot run anything that was not shown to the human as a button.
|
|
784
|
+
async runRequested({ responseMessageId, index }) {
|
|
785
|
+
if (!this.#runsEnabled) return { ok: false, status: 412, error: 'Command requests are off. Enable them in MODULES.' };
|
|
786
|
+
const events = await this.#store.readAll();
|
|
787
|
+
const request = events.find((event) => event.type === 'run.requested' && event.payload.responseMessageId === responseMessageId)?.payload;
|
|
788
|
+
const command = request?.commands?.[index];
|
|
789
|
+
if (!command) return { ok: false, status: 404, error: 'That command request is not in this room.' };
|
|
790
|
+
if (!command.argv) return { ok: false, status: 422, error: `That line does not run: it ${whyText(command)}.` };
|
|
791
|
+
if (this.#runBusy) return { ok: false, status: 409, error: 'A command is already running; wait for it or STOPALL.' };
|
|
792
|
+
if (this.#controlDesk.holder) return { ok: false, status: 409, error: `@${this.#controlDesk.holder.agent} holds ${MODES[this.#controlDesk.holder.mode ?? 3].label} of this project; run it when the turn ends.` };
|
|
793
|
+
// Taken before the first await below: two presses in a row must not both get through.
|
|
794
|
+
this.#runBusy = true;
|
|
795
|
+
try { return await this.#runCommand({ request, command, responseMessageId, index }); } finally { this.#runBusy = false; }
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
async #runCommand({ request, command, responseMessageId, index }) {
|
|
799
|
+
const run = { responseMessageId, index, agent: request.agent, line: command.line };
|
|
800
|
+
await this.#emit('run.started', run);
|
|
801
|
+
let checkpoint = null;
|
|
802
|
+
try {
|
|
803
|
+
checkpoint = await this.#controlDesk.photograph({ id: `${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}-run-${responseMessageId.slice(0, 8)}-${index}`, label: `MADRE run @${request.agent}`, command: command.line });
|
|
804
|
+
} catch { /* no photograph, no UNDO: the command still runs, and the card says so */ }
|
|
805
|
+
const result = await this.#runDesk.run({ argv: command.argv, cwd: this.#projectRoot });
|
|
806
|
+
const changes = checkpoint ? await this.#controlDesk.changesSince(checkpoint).catch(() => null) : null;
|
|
807
|
+
const status = result.stoppedBy ? `stopped (${result.stoppedBy})` : result.exitCode === null ? 'did not start' : `exit ${result.exitCode}`;
|
|
808
|
+
const event = await this.#emit('command.output', {
|
|
809
|
+
name: 'run',
|
|
810
|
+
title: `$ ${command.line} · ${status} · ${(result.durationMs / 1000).toFixed(1)}s · asked by @${request.agent}`,
|
|
811
|
+
text: `${result.text || '(no output)'}${changes?.files.length ? `\n\nFiles changed: ${changes.files.map((file) => `${file.status} ${file.path}`).join(', ')}` : ''}`,
|
|
812
|
+
ok: result.ok,
|
|
813
|
+
args: [],
|
|
814
|
+
run: { ...run, exitCode: result.exitCode, durationMs: result.durationMs, stoppedBy: result.stoppedBy, checkpointId: checkpoint?.id, files: changes?.files.length ? changes.files.slice(0, 40) : undefined },
|
|
815
|
+
});
|
|
816
|
+
return { ok: true, status: 200, result: { ok: result.ok, exitCode: result.exitCode, sequence: event.sequence } };
|
|
817
|
+
}
|
|
818
|
+
|
|
767
819
|
ashEnabled() {
|
|
768
820
|
return this.#ashEnabled;
|
|
769
821
|
}
|
|
@@ -840,7 +892,7 @@ export class Room {
|
|
|
840
892
|
this.#vectors.stop();
|
|
841
893
|
}
|
|
842
894
|
|
|
843
|
-
async send({ text, target, model = null, attachments = [], create = false, ash = false, mode = undefined }) {
|
|
895
|
+
async send({ text, target, model = null, effort = null, attachments = [], create = false, ash = false, mode = undefined }) {
|
|
844
896
|
const parsed = parseMessage(text, target);
|
|
845
897
|
const requestedMode = normalizeMode(mode, create === true ? 2 : 1);
|
|
846
898
|
// This is the decision the human made when pressing Send. Capture it before modeCheck() —
|
|
@@ -857,6 +909,9 @@ export class Room {
|
|
|
857
909
|
if (!parsed.target) throw new Error('Choose an agent or begin with @agent.');
|
|
858
910
|
if (model !== null && model !== undefined && model !== '' && !isValidModelName(model)) throw new Error('Model name is not valid.');
|
|
859
911
|
const chosenModel = model || null;
|
|
912
|
+
// An effort the target's CLI does not take is dropped, not refused: the composer remembers it
|
|
913
|
+
// per agent, so a stale one should cost the human nothing but the flag.
|
|
914
|
+
const chosenEffort = isValidEffort(parsed.target, effort) ? effort : null;
|
|
860
915
|
// What the human wrote, sent as they wrote it. Ash used to rewrite this line before it left
|
|
861
916
|
// the room; it does not any more, so there is no original to keep beside an abbreviation.
|
|
862
917
|
const text2 = parsed.text || `(${files.length} attached file${files.length === 1 ? '' : 's'})`;
|
|
@@ -877,6 +932,7 @@ export class Room {
|
|
|
877
932
|
ash: ashRequested ? { requested: true, active: ashActive } : undefined,
|
|
878
933
|
status: 'sent',
|
|
879
934
|
model: chosenModel,
|
|
935
|
+
effort: chosenEffort ?? undefined,
|
|
880
936
|
attachments: files.length ? files.map((file) => ({ id: file.id, name: file.name, fileName: file.fileName, size: file.size, contentType: file.contentType })) : undefined,
|
|
881
937
|
references: references.length ? references.map(({ excerpt, ...reference }) => reference) : undefined,
|
|
882
938
|
create: create === true ? true : undefined,
|
|
@@ -941,7 +997,7 @@ export class Room {
|
|
|
941
997
|
allowDelegation = false;
|
|
942
998
|
await this.#alert('plan-active', `A plan by @${[...this.#plans.values()][0].orchestrator} is still running. Your message will be answered, but no second plan will start. Type STOPALL to halt every agent.`, `plan-active:${messageId}`);
|
|
943
999
|
}
|
|
944
|
-
await this.#track(this.#dispatch({ messageId, targetId: parsed.target, text: text2, requester: 'you', depth: 0, allowDelegation: allowDelegation && !ghost, model: chosenModel, attachments: files, references, lease, ash: ashActive, mode: ghost ? 0 : control ? requestedMode : (lease ? 2 : 1) }));
|
|
1000
|
+
await this.#track(this.#dispatch({ messageId, targetId: parsed.target, text: text2, requester: 'you', depth: 0, allowDelegation: allowDelegation && !ghost, model: chosenModel, effort: chosenEffort, attachments: files, references, lease, ash: ashActive, mode: ghost ? 0 : control ? requestedMode : (lease ? 2 : 1) }));
|
|
945
1001
|
}
|
|
946
1002
|
|
|
947
1003
|
// A #2 lease: the project itself is where new files go, and MADRE keeps a scratch folder
|
|
@@ -983,6 +1039,7 @@ export class Room {
|
|
|
983
1039
|
for (const plan of this.#plans.values()) plan.stopped = reason;
|
|
984
1040
|
this.#escalation.settleAll(null, 'stopped');
|
|
985
1041
|
for (const turn of this.#turns.values()) turn.controller.abort(reason);
|
|
1042
|
+
this.#runDesk.stop('STOPALL');
|
|
986
1043
|
await this.#emit('room.stopped', { reason, plans, turns, agents: [...new Set([...this.#turns.values()].map((turn) => turn.agent))] });
|
|
987
1044
|
await Promise.allSettled([...this.#turns.values()].map((turn) => turn.promise));
|
|
988
1045
|
return { plans, turns };
|
|
@@ -1032,7 +1089,7 @@ export class Room {
|
|
|
1032
1089
|
|
|
1033
1090
|
// One room turn for one agent. `requester` is who asked ('you' or an
|
|
1034
1091
|
// orchestrating agent); `depth` 0 turns may delegate, deeper ones may not.
|
|
1035
|
-
async #dispatch({ messageId, targetId, text, requester, depth, planId = null, allowDelegation = true, model = null, attachments = [], references = [], lease = null, ash = false, mode = 1, escalation = null }) {
|
|
1092
|
+
async #dispatch({ messageId, targetId, text, requester, depth, planId = null, allowDelegation = true, model = null, effort = null, attachments = [], references = [], lease = null, ash = false, mode = 1, escalation = null }) {
|
|
1036
1093
|
if (this.#shuttingDown) return null;
|
|
1037
1094
|
const agent = this.#agents.find((item) => item.id === targetId);
|
|
1038
1095
|
if (!agent?.detected) {
|
|
@@ -1089,7 +1146,7 @@ export class Room {
|
|
|
1089
1146
|
await this.#emit('agent.started', { messageId, agent: agent.id, handoffId, planId });
|
|
1090
1147
|
const controller = new AbortController();
|
|
1091
1148
|
const turn = { controller, promise: null, planId, agent: agent.id, startedAt: Date.now() };
|
|
1092
|
-
turn.promise = this.#runTurn({ messageId, agent, text, requester, depth, planId, allowDelegation, context, recall, memories, handoffId, signal: controller.signal, model, attachments, references, lease, ash, mode, escalation });
|
|
1149
|
+
turn.promise = this.#runTurn({ messageId, agent, text, requester, depth, planId, allowDelegation, context, recall, memories, handoffId, signal: controller.signal, model, effort, attachments, references, lease, ash, mode, escalation });
|
|
1093
1150
|
this.#turns.set(messageId, turn);
|
|
1094
1151
|
let outcome = null;
|
|
1095
1152
|
try {
|
|
@@ -1145,7 +1202,7 @@ export class Room {
|
|
|
1145
1202
|
context: built.context, recall: built.recall, memories: built.memories,
|
|
1146
1203
|
attachments: [], references: [], lease,
|
|
1147
1204
|
scopes: { web: scopes.web.enabled && scopes.web.wired, imageGen: scopes.imageGen.enabled && scopes.imageGen.wired },
|
|
1148
|
-
ash: ash === true && this.ashEnabled(), mode, escalation: null, mcpServers,
|
|
1205
|
+
ash: ash === true && this.ashEnabled(), choices: this.#choicesEnabled && mode !== 0, runs: this.#runsEnabled && mode !== 0 && mode < 4, mode, escalation: null, mcpServers,
|
|
1149
1206
|
});
|
|
1150
1207
|
// Each block with what put it here and what would take it away, so "read, never written" can
|
|
1151
1208
|
// be checked one block at a time instead of believed.
|
|
@@ -1244,7 +1301,7 @@ export class Room {
|
|
|
1244
1301
|
});
|
|
1245
1302
|
}
|
|
1246
1303
|
|
|
1247
|
-
async #runTurn({ messageId, agent, text, requester, depth, planId, allowDelegation, context, recall = null, memories = null, handoffId, signal, model = null, attachments = [], references = [], lease: sharedLease = null, ash = false, mode = 1, escalation = null }) {
|
|
1304
|
+
async #runTurn({ messageId, agent, text, requester, depth, planId, allowDelegation, context, recall = null, memories = null, handoffId, signal, model = null, effort = null, attachments = [], references = [], lease: sharedLease = null, ash = false, mode = 1, escalation = null }) {
|
|
1248
1305
|
// The lease is the human's; what each agent may do inside it is that
|
|
1249
1306
|
// agent's own enabled scopes. A delegate without file creation runs
|
|
1250
1307
|
// read-only even while the plan holds a lease.
|
|
@@ -1337,7 +1394,7 @@ export class Room {
|
|
|
1337
1394
|
: [];
|
|
1338
1395
|
// The prompt and the shape it was built from: one is sent, the other is kept until the CLI
|
|
1339
1396
|
// says what it cost, so the bill can be attributed to the blocks that caused it.
|
|
1340
|
-
const shaped = this.#promptFor({ agent, text, requester, depth, allowDelegation, context, recall, memories, attachments, references, lease, scopes: turnScopes, imageStudio, ash, choices: this.#choicesEnabled, mode: turnMode, escalation, mcpServers, sharedLeaseHint: sharedLease && !lease ? 'A creation lease is active for this plan, but file creation is not enabled for you: answer without creating files and say so if asked to create one.' : null });
|
|
1397
|
+
const shaped = this.#promptFor({ agent, text, requester, depth, allowDelegation, context, recall, memories, attachments, references, lease, scopes: turnScopes, imageStudio, ash, choices: this.#choicesEnabled && turnMode !== 0, runs: this.#runsEnabled && turnMode !== 0 && turnMode < 4, mode: turnMode, escalation, mcpServers, sharedLeaseHint: sharedLease && !lease ? 'A creation lease is active for this plan, but file creation is not enabled for you: answer without creating files and say so if asked to create one.' : null });
|
|
1341
1398
|
this.#promptShape.set(responseMessageId, { parts: shaped.parts, spared: shaped.spared });
|
|
1342
1399
|
// What this turn is about to read, said while it is still reading it. The size is exact;
|
|
1343
1400
|
// only the conversion to tokens is an estimate, and it is made at this room's own rate.
|
|
@@ -1361,6 +1418,7 @@ export class Room {
|
|
|
1361
1418
|
timeoutMs: this.timeoutFor(agent.id),
|
|
1362
1419
|
signal,
|
|
1363
1420
|
model,
|
|
1421
|
+
effort,
|
|
1364
1422
|
attachments,
|
|
1365
1423
|
lease,
|
|
1366
1424
|
scopes: turnScopes,
|
|
@@ -1410,13 +1468,15 @@ export class Room {
|
|
|
1410
1468
|
}
|
|
1411
1469
|
// A question the agent put to the human: it leaves the text and comes back as buttons.
|
|
1412
1470
|
const asked = this.#choicesEnabled && turnMode !== 0 ? parseChoice(result.text) : null;
|
|
1471
|
+
// Commands it wants the human to run: the same, as buttons. AIRLOCK runs its own.
|
|
1472
|
+
const requested = !asked && this.#runsEnabled && turnMode !== 0 && turnMode < 4 ? parseRunRequest(result.text) : null;
|
|
1413
1473
|
await this.#emit('message.created', {
|
|
1414
1474
|
messageId: responseMessageId,
|
|
1415
1475
|
parentMessageId: messageId,
|
|
1416
1476
|
role: 'assistant',
|
|
1417
1477
|
sender: agent.id,
|
|
1418
1478
|
target: requester,
|
|
1419
|
-
text: asked ? withoutChoice(result.text) : result.text,
|
|
1479
|
+
text: asked ? withoutChoice(result.text) : requested ? withoutRunRequest(result.text) : result.text,
|
|
1420
1480
|
// What the archive handed this turn, so the room can see its own memory working. The
|
|
1421
1481
|
// whole point of remembering is invisible until the moment it is used.
|
|
1422
1482
|
recalled: memories?.length
|
|
@@ -1426,6 +1486,7 @@ export class Room {
|
|
|
1426
1486
|
status: 'completed',
|
|
1427
1487
|
planId,
|
|
1428
1488
|
model,
|
|
1489
|
+
effort: effort ?? undefined,
|
|
1429
1490
|
synthetic: result.synthetic || undefined,
|
|
1430
1491
|
redacted: guarded.hits || undefined,
|
|
1431
1492
|
mode: turnMode,
|
|
@@ -1437,7 +1498,8 @@ export class Room {
|
|
|
1437
1498
|
const validation = validateAshReply({ request: text, response: result.text });
|
|
1438
1499
|
await this.#emit('ash.validated', { messageId, responseMessageId, agent: agent.id, ...validation });
|
|
1439
1500
|
}
|
|
1440
|
-
if (asked) await this.#emit('choice.offered', { messageId, responseMessageId, agent: agent.id, question: asked.question, options: asked.options });
|
|
1501
|
+
if (asked) await this.#emit('choice.offered', { messageId, responseMessageId, agent: agent.id, question: asked.question, options: asked.options, recommended: asked.recommended ?? undefined });
|
|
1502
|
+
if (requested) await this.#emit('run.requested', { messageId, responseMessageId, agent: agent.id, reason: requested.reason, commands: requested.commands });
|
|
1441
1503
|
if (guarded.hits) await this.#emit('privacy.redacted', { agent: agent.id, messageId, responseMessageId, hits: guarded.hits, marker: this.#privacy.marker });
|
|
1442
1504
|
if (artifacts.length) {
|
|
1443
1505
|
await this.#emit('artifacts.created', { leaseId: lease.leaseId, messageId, responseMessageId, agent: agent.id, outDir: lease.relativeDir, files: artifacts });
|
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
|
+
}
|