@jossuealcala/madre 0.3.2 → 0.4.0
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 +425 -0
- package/CONTRIBUTING.md +6 -1
- package/README.md +67 -183
- package/SECURITY.md +2 -1
- package/bin/madre.mjs +56 -13
- package/docs/INTERNALS.md +16 -0
- package/docs/REFERENCE.md +249 -0
- package/docs/SDK.md +121 -0
- package/docs/room.png +0 -0
- package/docs/sdk/hello-module.mjs +51 -0
- package/package.json +9 -1
- package/public/app.js +3880 -849
- package/public/es.js +2050 -0
- package/public/i18n.js +66 -0
- package/public/index.html +95 -14
- package/public/inquiry.js +220 -0
- package/public/resay.js +77 -0
- package/public/styles.css +626 -68
- package/public/troubleshooting.js +173 -51
- package/src/adapters/claude.mjs +13 -6
- package/src/adapters/codex.mjs +17 -13
- package/src/adapters/gemini.mjs +27 -16
- package/src/adapters/opencode.mjs +16 -12
- package/src/adapters/process.mjs +17 -5
- package/src/asking.mjs +128 -0
- package/src/auth-probe.mjs +58 -1
- package/src/capabilities.mjs +4 -3
- package/src/chats.mjs +193 -0
- package/src/checkpoint.mjs +1 -1
- package/src/cold.mjs +56 -0
- package/src/commands.mjs +31 -3
- package/src/conversation-context.mjs +35 -3
- package/src/credentials.mjs +145 -0
- package/src/dataset.mjs +56 -4
- package/src/distiller.mjs +12 -5
- package/src/event-store.mjs +14 -8
- package/src/exam.mjs +240 -0
- package/src/extensions.mjs +3 -2
- package/src/eyecat-watch.mjs +100 -0
- package/src/eyecat.mjs +169 -0
- package/src/i18n.mjs +47 -0
- package/src/image-studio.mjs +2 -0
- package/src/launch.mjs +61 -0
- package/src/lease.mjs +5 -3
- package/src/maturity.mjs +94 -0
- package/src/mcp/image-server.mjs +12 -1
- package/src/mcp/memory-server.mjs +1 -1
- package/src/memory.mjs +325 -17
- package/src/modules/ahp.mjs +9 -7
- package/src/modules/ash.mjs +36 -0
- package/src/modules/git-pulse.mjs +6 -4
- package/src/modules/helpers.mjs +31 -0
- package/src/modules/image-studio.mjs +9 -4
- package/src/modules/index.mjs +143 -5
- package/src/modules/ollama.mjs +66 -10
- package/src/modules/playwright.mjs +90 -0
- package/src/modules/ripley.mjs +5 -3
- package/src/modules/sdk.mjs +104 -2
- package/src/modules/updates.mjs +81 -0
- package/src/ollama.mjs +5 -2
- package/src/outbound.mjs +292 -0
- package/src/privacy.mjs +54 -7
- package/src/room/context.mjs +4 -4
- package/src/room/control.mjs +4 -4
- package/src/room/economy.mjs +161 -0
- package/src/room/prompt.mjs +118 -43
- package/src/room.mjs +465 -58
- package/src/runtime-detection.mjs +27 -8
- package/src/server.mjs +724 -68
- package/src/setup.mjs +1 -1
- package/src/updates.mjs +17 -2
- package/src/usage-sentinel.mjs +13 -8
- package/src/verdict.mjs +74 -0
- package/src/ashcode.mjs +0 -64
- package/src/modules/ashcode.mjs +0 -28
package/src/room.mjs
CHANGED
|
@@ -1,13 +1,20 @@
|
|
|
1
|
-
import { invokeCodex } from './adapters/codex.mjs';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
1
|
+
import { invokeCodex, buildCodexArgs } from './adapters/codex.mjs';
|
|
2
|
+
import { t } from './i18n.mjs';
|
|
3
|
+
import { invokeClaude, buildClaudeArgs } from './adapters/claude.mjs';
|
|
4
|
+
import { invokeGemini, buildGeminiArgs } from './adapters/gemini.mjs';
|
|
5
|
+
import { invokeOpenCode, buildOpenCodeArgs } from './adapters/opencode.mjs';
|
|
5
6
|
import { parseMessage } from './router.mjs';
|
|
6
7
|
import { randomUUID } from 'node:crypto';
|
|
7
8
|
import { basename } from 'node:path';
|
|
8
9
|
import { UsageSentinel } from './usage-sentinel.mjs';
|
|
9
|
-
import { buildPrompt } from './room/prompt.mjs';
|
|
10
|
+
import { BLOCK_SOURCES, buildPrompt, promptParts, sparedChars } from './room/prompt.mjs';
|
|
11
|
+
import { turnCost, observedRate } from './room/economy.mjs';
|
|
10
12
|
import { contextFor } from './room/context.mjs';
|
|
13
|
+
import { coldNotes, coldReading } from './cold.mjs';
|
|
14
|
+
import { questionsFor } from './asking.mjs';
|
|
15
|
+
import { coverageExam, consistencyExam, matchExam, exchanges, MATCH_SAMPLE, saysFor } from './exam.mjs';
|
|
16
|
+
import { MADRE_ADAPTER, MADRE_AGENT_ID } from './adapters/madre.mjs';
|
|
17
|
+
import { verdictFor } from './verdict.mjs';
|
|
11
18
|
import { ControlDesk } from './room/control.mjs';
|
|
12
19
|
import { Attachments } from './room/attachments.mjs';
|
|
13
20
|
import { GhostLedger } from './room/ghost.mjs';
|
|
@@ -15,6 +22,7 @@ import { Escalation } from './room/escalation.mjs';
|
|
|
15
22
|
import { Budget, budgetTokens } from './room/budget.mjs';
|
|
16
23
|
import { VectorWorker } from './room/vectors.mjs';
|
|
17
24
|
import { Archivist, distillDefaults } from './room/archivist.mjs';
|
|
25
|
+
import { OLLAMA_ARCHIVIST } from './distiller.mjs';
|
|
18
26
|
import { memoryServerForTurn } from './memory-tools.mjs';
|
|
19
27
|
import { CODE000_STRIKES } from './mother.mjs';
|
|
20
28
|
import { parseDirectives } from './directives.mjs';
|
|
@@ -24,11 +32,11 @@ import { createLease, diffSnapshots, snapshot } from './lease.mjs';
|
|
|
24
32
|
import { stat as statFile } from 'node:fs/promises';
|
|
25
33
|
import { basename as baseName, join as joinPath } from 'node:path';
|
|
26
34
|
import { contentTypeFor } from './files.mjs';
|
|
35
|
+
import { isModuleFile, sdkPaths } from './modules/index.mjs';
|
|
27
36
|
import { imageStudioFor } from './image-studio.mjs';
|
|
28
37
|
import { CAPABILITIES, imageModuleState } from './capabilities.mjs';
|
|
29
38
|
import { resolveReferences } from './files.mjs';
|
|
30
|
-
import {
|
|
31
|
-
import { isGitRepo } from './checkpoint.mjs';
|
|
39
|
+
import { redactArgs } from './launch.mjs';
|
|
32
40
|
|
|
33
41
|
// Adapters can fail with multi-line stderr or stack traces. The room keeps only
|
|
34
42
|
// the first meaningful line, bounded, so the event log and the UI stay readable.
|
|
@@ -66,10 +74,15 @@ export class Room {
|
|
|
66
74
|
#contextMaxChars;
|
|
67
75
|
#memory; // RoomMemory: durable recall of everything said outside GHOST
|
|
68
76
|
#recallShare; // fraction of the context budget recall may take
|
|
77
|
+
#cascade; // whether recall also carries what a memory keeps arriving with
|
|
78
|
+
#examRunning = null; // the test in flight, if any
|
|
79
|
+
#examProgress = null;
|
|
80
|
+
#examStop = false;
|
|
69
81
|
#archivist; // room/archivist: who distils, when, and the bench
|
|
70
82
|
#vectors; // room/vectors: embeddings filled in the background
|
|
71
83
|
#escalation; // room/escalation: plan steps waiting for the human
|
|
72
84
|
#ghost = new GhostLedger();
|
|
85
|
+
#liveListeners = new Set();
|
|
73
86
|
#memoryServer; // MCP descriptor handed to every turn so the agent can query the memory itself
|
|
74
87
|
#mother = null; // MotherChannel: her coded words to the crew
|
|
75
88
|
#shuttingDown = false;
|
|
@@ -84,10 +97,11 @@ export class Room {
|
|
|
84
97
|
#scopeConfig = {};
|
|
85
98
|
#delegation;
|
|
86
99
|
#privacy = null;
|
|
100
|
+
#toolsForTurn = null;
|
|
87
101
|
#maxPlanSteps;
|
|
88
102
|
#maxConcurrentTurns;
|
|
89
103
|
#planMaxAgeMs;
|
|
90
|
-
#
|
|
104
|
+
#ashEnabled = false;
|
|
91
105
|
|
|
92
106
|
constructor({
|
|
93
107
|
store,
|
|
@@ -97,10 +111,12 @@ export class Room {
|
|
|
97
111
|
contextMaxChars = 16000,
|
|
98
112
|
memory = null,
|
|
99
113
|
recallShare = Number(process.env.PULSE_RECALL_SHARE ?? 0.3),
|
|
114
|
+
cascade = process.env.PULSE_RECALL_CASCADE !== '0',
|
|
100
115
|
distill = {},
|
|
101
116
|
memoryServer = null,
|
|
102
117
|
mother = null,
|
|
103
118
|
privacy = null,
|
|
119
|
+
toolsForTurn = null, // async ({ agent, mode, lease, scratchDir }) => MCP server specs from the modules
|
|
104
120
|
historicalEvents = [],
|
|
105
121
|
invokers = defaultInvokers,
|
|
106
122
|
agentTimeouts = {},
|
|
@@ -117,9 +133,11 @@ export class Room {
|
|
|
117
133
|
this.#contextMaxChars = contextMaxChars;
|
|
118
134
|
this.#memory = memory;
|
|
119
135
|
this.#recallShare = Math.min(0.6, Math.max(0, Number.isFinite(recallShare) ? recallShare : 0.3));
|
|
136
|
+
this.#cascade = cascade !== false;
|
|
120
137
|
this.#memoryServer = memoryServer;
|
|
121
138
|
this.#mother = mother;
|
|
122
139
|
this.#privacy = privacy;
|
|
140
|
+
this.#toolsForTurn = toolsForTurn;
|
|
123
141
|
this.#invokers = invokers;
|
|
124
142
|
this.#agentTimeouts = agentTimeouts;
|
|
125
143
|
this.#maxMessageChars = maxMessageChars;
|
|
@@ -131,6 +149,8 @@ export class Room {
|
|
|
131
149
|
this.#controlDesk = new ControlDesk({ projectRoot });
|
|
132
150
|
this.#budget = new Budget({ softBudget: softTokenBudget });
|
|
133
151
|
this.#budget.seed(historicalEvents, this.#sentinel);
|
|
152
|
+
// What this room has been charged per character, from its own past turns.
|
|
153
|
+
this.#rate = observedRate(historicalEvents);
|
|
134
154
|
this.#vectors = new VectorWorker({ memory, stopped: () => this.#shuttingDown });
|
|
135
155
|
this.#archivist = new Archivist({
|
|
136
156
|
memory,
|
|
@@ -216,6 +236,23 @@ export class Room {
|
|
|
216
236
|
decideMode(requestId, decision) { return this.#escalation.decide(requestId, decision); }
|
|
217
237
|
|
|
218
238
|
subscribeGhost(listener) { return this.#ghost.subscribe(listener); }
|
|
239
|
+
|
|
240
|
+
// A live meter is not a fact about the room: it says what a turn is reading while it reads it,
|
|
241
|
+
// and a second later the bill says what it really cost. Writing it to the ledger would spend a
|
|
242
|
+
// sequence number on something nothing will ever recall, and shift everything said after it.
|
|
243
|
+
// It goes out to whoever is watching and nowhere else.
|
|
244
|
+
#say(type, payload) {
|
|
245
|
+
const event = { id: `live-${randomUUID()}`, sequence: null, live: true, timestamp: new Date().toISOString(), type, payload };
|
|
246
|
+
for (const listener of this.#liveListeners) listener(event);
|
|
247
|
+
return event;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Whoever is watching the room right now. Separate from the ghost channel, which carries turns
|
|
251
|
+
// that happened off the record: these never happened at all, they are only being reported.
|
|
252
|
+
subscribeLive(listener) {
|
|
253
|
+
this.#liveListeners.add(listener);
|
|
254
|
+
return () => this.#liveListeners.delete(listener);
|
|
255
|
+
}
|
|
219
256
|
async #emit(type, payload) {
|
|
220
257
|
if (this.#ghost.isGhost(type, payload)) return this.#ghost.emit(type, payload);
|
|
221
258
|
const event = await this.#store.append(type, payload);
|
|
@@ -232,16 +269,29 @@ export class Room {
|
|
|
232
269
|
}
|
|
233
270
|
embedNow() { return this.#vectors.runNow(); }
|
|
234
271
|
|
|
235
|
-
|
|
236
|
-
|
|
272
|
+
// Where the transcript an agent reads begins. Held still across turns on purpose: a window
|
|
273
|
+
// that starts one message later every time is a window a CLI can never match against what it
|
|
274
|
+
// read last turn, and the transcript is most of what a loaded turn costs.
|
|
275
|
+
#contextAnchor = null;
|
|
276
|
+
|
|
277
|
+
// Characters per input token, as this room's own turns have shown. Null until it has one.
|
|
278
|
+
#rate = null;
|
|
279
|
+
|
|
280
|
+
async #contextFor(priorEvents, { messageId, text, omitSynthetic = false, by = null }) {
|
|
281
|
+
const built = await contextFor({ memory: this.#memory, priorEvents, messageId, text, contextMaxChars: this.#contextMaxChars, recallShare: this.#recallShare, remember: (events) => this.#remember(events), omitSynthetic, anchor: this.#contextAnchor, by, cascade: this.#cascade });
|
|
282
|
+
// @madre reads a different transcript to everyone else, so it never sets where the room's
|
|
283
|
+
// window begins; it only borrows it.
|
|
284
|
+
if (!omitSynthetic && Number.isInteger(built.context?.anchor)) this.#contextAnchor = built.context.anchor;
|
|
285
|
+
return built;
|
|
237
286
|
}
|
|
238
287
|
|
|
239
288
|
// The human tunes the archive from MU/TH/UR; changes apply to the next run.
|
|
240
289
|
distillSettings() {
|
|
241
|
-
return { ...this.#archivist.settings(), recallShare: this.#recallShare, ollama: Boolean(this.#invokers.ollama) };
|
|
290
|
+
return { ...this.#archivist.settings(), recallShare: this.#recallShare, cascade: this.#cascade, ollama: Boolean(this.#invokers.ollama) };
|
|
242
291
|
}
|
|
243
292
|
configureDistill(patch = {}) {
|
|
244
293
|
if (Number.isFinite(Number(patch.recallShare))) this.#recallShare = Math.min(0.6, Math.max(0, Number(patch.recallShare)));
|
|
294
|
+
if (typeof patch.cascade === 'boolean') this.#cascade = patch.cascade;
|
|
245
295
|
this.#archivist.configure(patch);
|
|
246
296
|
return this.distillSettings();
|
|
247
297
|
}
|
|
@@ -291,12 +341,197 @@ export class Room {
|
|
|
291
341
|
return { ...alert, crew };
|
|
292
342
|
}
|
|
293
343
|
|
|
344
|
+
// What the room is about to hand this turn, measured rather than guessed. The prompt is
|
|
345
|
+
// already built when the turn is announced, so its size is known exactly; only the conversion
|
|
346
|
+
// to tokens is an estimate, and it is made at the rate this room's own turns have shown. Until
|
|
347
|
+
// the room has been billed once there is no rate, and it says so rather than inventing one.
|
|
348
|
+
#reading(parts) {
|
|
349
|
+
const chars = parts.reduce((sum, part) => sum + part.text.length, 0);
|
|
350
|
+
return { chars, rate: this.#rate ? Number(this.#rate.toFixed(2)) : null, tokens: this.#rate ? Math.round(chars / this.#rate) : null };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Who is free to answer a question that is not a turn: the same pool the archivist draws on,
|
|
354
|
+
// with the local model in it when there is one. EYECAT reads this to find an impartial judge.
|
|
355
|
+
bench() {
|
|
356
|
+
const invokers = this.#invokers;
|
|
357
|
+
const agents = this.#agents.filter((agent) => agent.adapter !== 'madre-local');
|
|
358
|
+
return {
|
|
359
|
+
agents: invokers.ollama ? [OLLAMA_ARCHIVIST, ...agents] : agents,
|
|
360
|
+
invokers,
|
|
361
|
+
busy: new Set([...this.#turns.values()].map((turn) => turn.agent).filter(Boolean)),
|
|
362
|
+
benched: new Set(),
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
294
366
|
/* ---------- NOSTROMO: the human's view of the archive ---------- */
|
|
295
367
|
|
|
296
368
|
// Every distilled note with the links between those that agree, for the map.
|
|
369
|
+
// What one memory has to say about its own life: who it keeps arriving with, who asked for it,
|
|
370
|
+
// and what a refutation did to it or with it. NOSTROMO reads this while a card is open.
|
|
371
|
+
memoryTraffic(id) {
|
|
372
|
+
if (!this.#memory) return null;
|
|
373
|
+
return this.#memory.recallTraffic(id);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Is anything happening in here right now? Asked before a conversation is closed and another
|
|
377
|
+
// opened: a turn in flight is a reason to wait, not something to race.
|
|
378
|
+
working() { return this.#turns.size > 0; }
|
|
379
|
+
|
|
297
380
|
memoryResearch() {
|
|
298
381
|
if (!this.#memory) return null;
|
|
299
|
-
|
|
382
|
+
const memories = this.#memory.memories({ limit: 500 });
|
|
383
|
+
const links = this.#memory.memoryLinks();
|
|
384
|
+
// The cold zones: what the archive has never reached for, never linked, and has had plenty
|
|
385
|
+
// of chances to be. Attached to the notes themselves so the map and the card agree.
|
|
386
|
+
const cold = coldNotes({ notes: memories, links, batches: this.#memory.recallBatches(), since: this.#memory.recallsSince() });
|
|
387
|
+
return {
|
|
388
|
+
stats: this.memoryStats(),
|
|
389
|
+
memories: memories.map((note) => (cold.has(note.id) ? { ...note, cold: cold.get(note.id) } : note)),
|
|
390
|
+
links,
|
|
391
|
+
cold: coldReading(cold, memories),
|
|
392
|
+
// What the room should ask next, so a thin archive fills where it is thin instead of
|
|
393
|
+
// deeper where it is already fat. Nothing is sent: the questions are the human's to use.
|
|
394
|
+
ask: questionsFor({ notes: memories, cold, dismissed: this.#dismissedAsks() }),
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/* ---------- the three tests ---------- */
|
|
399
|
+
|
|
400
|
+
// What the room can measure about itself, and whether each test can run right now. A test that
|
|
401
|
+
// cannot run says why instead of returning a number nobody should trust.
|
|
402
|
+
async exams({ refresh = false, findings = [] } = {}) {
|
|
403
|
+
let last = {};
|
|
404
|
+
try { last = JSON.parse(this.#memory?.metaGet('exams') ?? '{}'); } catch { last = {}; }
|
|
405
|
+
// Said again in the room's language from the numbers that were stored: a reading taken in
|
|
406
|
+
// another language is still a reading, and its sentence is only how it is said.
|
|
407
|
+
last = Object.fromEntries(Object.entries(last).map(([id, result]) => [id, { ...result, says: saysFor(result) ?? result.says }]));
|
|
408
|
+
// The one test that costs nothing is never something to ask for: it is read, not run.
|
|
409
|
+
if (refresh && this.#memory) last = { ...last, consistency: this.#keepExam(consistencyExam({ findings, notes: this.#memory.memories({ limit: 500 }) })) };
|
|
410
|
+
const embedder = this.#memory?.embedder?.model ?? null;
|
|
411
|
+
const localModel = Boolean(this.#invokers[MADRE_ADAPTER]);
|
|
412
|
+
const cases = this.#memory ? exchanges(await this.#store.readAll()) : [];
|
|
413
|
+
return {
|
|
414
|
+
running: this.#examRunning,
|
|
415
|
+
progress: this.#examProgress,
|
|
416
|
+
last,
|
|
417
|
+
can: {
|
|
418
|
+
coverage: { ok: Boolean(this.#memory) && cases.length > 0, why: !this.#memory ? 'the room has no memory' : cases.length ? null : 'no exchange here is long enough to test with yet', method: embedder ? 'meaning' : 'words' },
|
|
419
|
+
consistency: { ok: Boolean(this.#memory), why: this.#memory ? null : 'the room has no memory' },
|
|
420
|
+
match: {
|
|
421
|
+
ok: Boolean(this.#memory) && localModel && Boolean(embedder) && cases.some((one) => !one.local),
|
|
422
|
+
why: !localModel ? 'the local model is not running: open MODULES → OLLAMA'
|
|
423
|
+
: !embedder ? 'this test needs embeddings, which are off'
|
|
424
|
+
: cases.some((one) => !one.local) ? null : 'nothing here was answered by an agent other than the local one',
|
|
425
|
+
sample: Math.min(MATCH_SAMPLE, cases.filter((one) => !one.local).length),
|
|
426
|
+
},
|
|
427
|
+
},
|
|
428
|
+
cases: cases.length,
|
|
429
|
+
embedder,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Embedding in batches, so a remote embedder is asked a few times and not five hundred.
|
|
434
|
+
#embedInBatches() {
|
|
435
|
+
const embedder = this.#memory?.embedder;
|
|
436
|
+
if (!embedder) return null;
|
|
437
|
+
return async (texts) => {
|
|
438
|
+
const out = [];
|
|
439
|
+
for (let start = 0; start < texts.length; start += 64) out.push(...await embedder.embed(texts.slice(start, start + 64)));
|
|
440
|
+
return out;
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Running one. Coverage and consistency answer in a moment; the match test asks the local model
|
|
445
|
+
// a real question at a time and takes minutes, so it runs in the background and reports where
|
|
446
|
+
// it is. Nothing here writes to the ledger or spends a provider turn.
|
|
447
|
+
async runExam(which, { findings = [] } = {}) {
|
|
448
|
+
if (!this.#memory) return { error: 'The room has no memory.' };
|
|
449
|
+
if (this.#examRunning) return { error: `${this.#examRunning.toUpperCase()} is already running.` };
|
|
450
|
+
const events = await this.#store.readAll();
|
|
451
|
+
if (which === 'consistency') {
|
|
452
|
+
return this.#keepExam(consistencyExam({ findings, notes: this.#memory.memories({ limit: 500 }) }));
|
|
453
|
+
}
|
|
454
|
+
if (which === 'coverage') {
|
|
455
|
+
this.#examRunning = 'coverage';
|
|
456
|
+
try {
|
|
457
|
+
const recall = async (text, before) => {
|
|
458
|
+
const queryVector = await this.#memory.embedQuery(text);
|
|
459
|
+
const notes = this.#memory.recallMemories(text, { beforeSequence: before, limit: 6, maxChars: 1600, queryVector, fallback: false, track: false, cascade: this.#cascade });
|
|
460
|
+
const quotes = this.#memory.recall(text, { beforeSequence: before, limit: 6, maxChars: 1600, excerptChars: 420, queryVector });
|
|
461
|
+
return [...notes.map((note) => note.text), ...(quotes?.entries ?? []).map((entry) => entry.excerpt ?? entry.text)];
|
|
462
|
+
};
|
|
463
|
+
return this.#keepExam(await coverageExam({ events, recall, embed: this.#embedInBatches() }));
|
|
464
|
+
} finally { this.#examRunning = null; }
|
|
465
|
+
}
|
|
466
|
+
if (which === 'match') {
|
|
467
|
+
const invoke = this.#invokers[MADRE_ADAPTER];
|
|
468
|
+
if (!invoke) return { error: 'The local model is not running. Open MODULES → OLLAMA.' };
|
|
469
|
+
this.#examRunning = 'match';
|
|
470
|
+
this.#examProgress = { done: 0, total: 0 };
|
|
471
|
+
// Answered in the background: the human keeps the room while it runs.
|
|
472
|
+
void (async () => {
|
|
473
|
+
try {
|
|
474
|
+
const result = await matchExam({
|
|
475
|
+
events,
|
|
476
|
+
ask: async (question) => (await invoke({ prompt: question, text: question, timeoutMs: 120000 }))?.text ?? '',
|
|
477
|
+
embed: this.#embedInBatches(),
|
|
478
|
+
onProgress: (at) => { this.#examProgress = at; },
|
|
479
|
+
stop: () => this.#examStop,
|
|
480
|
+
});
|
|
481
|
+
this.#keepExam(result);
|
|
482
|
+
// The one test that takes minutes is the one whose answer a person is waiting for, and
|
|
483
|
+
// until now it landed in a panel they had to go back to. It lands in the room.
|
|
484
|
+
if (result.ran) {
|
|
485
|
+
await this.#emit('local.checked', {
|
|
486
|
+
model: this.#agents.find((agent) => agent.id === MADRE_AGENT_ID)?.version ?? null,
|
|
487
|
+
passed: Boolean(result.passed), matched: result.matched ?? 0, n: result.n ?? 0, says: result.says,
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
} catch (error) {
|
|
491
|
+
this.#keepExam({ id: 'match', ran: false, at: new Date().toISOString(), says: `The test could not finish: ${error.message}` });
|
|
492
|
+
} finally { this.#examRunning = null; this.#examStop = false; this.#examProgress = null; }
|
|
493
|
+
})();
|
|
494
|
+
return { started: 'match' };
|
|
495
|
+
}
|
|
496
|
+
return { error: `No such test "${which}".` };
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Coverage costs a few seconds of local arithmetic and nothing else, so it is kept fresh
|
|
500
|
+
// behind the screen rather than asked for: once a day, only while the embeddings are local
|
|
501
|
+
// (a remote embedder is the human's money, and money is never spent without being asked), and
|
|
502
|
+
// never in front of anything. What the panel shows is whatever the last run left.
|
|
503
|
+
async freshenCoverage({ now = Date.now(), ttlMs = 24 * 60 * 60 * 1000 } = {}) {
|
|
504
|
+
if (!this.#memory || this.#examRunning) return null;
|
|
505
|
+
if (!this.#memory.embedder?.local) return null;
|
|
506
|
+
let last = {};
|
|
507
|
+
try { last = JSON.parse(this.#memory.metaGet('exams') ?? '{}'); } catch { last = {}; }
|
|
508
|
+
const at = Date.parse(last.coverage?.at ?? '');
|
|
509
|
+
if (Number.isFinite(at) && now - at < ttlMs) return null;
|
|
510
|
+
void this.runExam('coverage').catch(() => null);
|
|
511
|
+
return { started: 'coverage' };
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
stopExam() { if (this.#examRunning === 'match') this.#examStop = true; return { stopping: this.#examRunning }; }
|
|
515
|
+
|
|
516
|
+
#keepExam(result) {
|
|
517
|
+
let last = {};
|
|
518
|
+
try { last = JSON.parse(this.#memory?.metaGet('exams') ?? '{}'); } catch { last = {}; }
|
|
519
|
+
last[result.id] = result;
|
|
520
|
+
this.#memory?.metaSet('exams', JSON.stringify(last));
|
|
521
|
+
return result;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// Questions the human has waved off. Kept with the archive, because that is what they are about.
|
|
525
|
+
#dismissedAsks() {
|
|
526
|
+
try { return JSON.parse(this.#memory?.metaGet('asks_dismissed') ?? '[]'); } catch { return []; }
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Waving one off is a small, reversible thing: it stops being offered, and nothing else.
|
|
530
|
+
dismissAsk(id) {
|
|
531
|
+
if (!this.#memory || !id) return null;
|
|
532
|
+
const dismissed = [...new Set([...this.#dismissedAsks(), String(id)])].slice(-200);
|
|
533
|
+
this.#memory.metaSet('asks_dismissed', JSON.stringify(dismissed));
|
|
534
|
+
return { dismissed: dismissed.length };
|
|
300
535
|
}
|
|
301
536
|
|
|
302
537
|
/* ---------- privacy: terms that never travel through the room ---------- */
|
|
@@ -357,11 +592,12 @@ export class Room {
|
|
|
357
592
|
const wanted = normalizeMode(mode, create === true ? 2 : 1);
|
|
358
593
|
if (!parsed.target) return { ok: true, mode: wanted };
|
|
359
594
|
const scopes = this.scopesFor(parsed.target);
|
|
360
|
-
if (wanted
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
if (
|
|
364
|
-
return { ok:
|
|
595
|
+
if (wanted >= 3) {
|
|
596
|
+
// #3 CONTROL and #4 AIRLOCK: the ceiling must allow it, and there is one holder at a time.
|
|
597
|
+
// A project without git still checkpoints, in MADRE's shadow repository.
|
|
598
|
+
if (scopes.maxMode < wanted) return { ok: false, status: 403, mode: wanted, maxMode: scopes.maxMode, error: `@${parsed.target} is capped at #${scopes.maxMode} ${MODES[scopes.maxMode].label}; raise its MAX MODE to #${wanted} in CONNECTIONS to arm ${MODES[wanted].label}.` };
|
|
599
|
+
if (this.#controlDesk.holder) return { ok: false, status: 409, mode: wanted, maxMode: scopes.maxMode, error: `@${this.#controlDesk.holder.agent} already holds ${MODES[this.#controlDesk.holder.mode ?? 3].label} of this project; one holder at a time. STOPALL revokes it.` };
|
|
600
|
+
return { ok: true, mode: wanted, maxMode: scopes.maxMode };
|
|
365
601
|
}
|
|
366
602
|
// #2 above the agent's ceiling still goes out: the room answers read-only
|
|
367
603
|
// and says why (lease.refused), the way CREATE has always behaved.
|
|
@@ -399,6 +635,26 @@ export class Room {
|
|
|
399
635
|
|
|
400
636
|
budgetWindow() { return this.#budget.view(this.#agents); }
|
|
401
637
|
|
|
638
|
+
// What the turn was made of, against what it was charged. A room that cannot see this can only
|
|
639
|
+
// guess at which part of a prompt is expensive, and a guess in characters is not an answer in
|
|
640
|
+
// tokens. The words themselves are never written here, only how many of them each block was.
|
|
641
|
+
async #recordCost(agent, responseMessageId, usage, mode) {
|
|
642
|
+
const shape = this.#promptShape.get(responseMessageId);
|
|
643
|
+
this.#promptShape.delete(responseMessageId);
|
|
644
|
+
if (!shape) return;
|
|
645
|
+
// A ghost turn is off the record in every sense, this one included.
|
|
646
|
+
if (mode === 0) return;
|
|
647
|
+
try {
|
|
648
|
+
const cost = turnCost(shape.parts, usage);
|
|
649
|
+
// Each bill teaches the room what its own words cost, so the next turn can say what it is
|
|
650
|
+
// about to spend before it spends it.
|
|
651
|
+
if (cost.charsPerInputToken) this.#rate = this.#rate ? this.#rate * 0.7 + cost.charsPerInputToken * 0.3 : cost.charsPerInputToken;
|
|
652
|
+
await this.#emit('turn.cost', { agent, mode, responseMessageId, spared: shape.spared, ...cost });
|
|
653
|
+
} catch (error) {
|
|
654
|
+
console.error(`MADRE could not weigh a turn: ${error.message}`);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
402
658
|
async #recordUsage(agent, usage, { messageId = null, responseMessageId = null } = {}) {
|
|
403
659
|
if (!usage) return;
|
|
404
660
|
const charged = this.#budget.record(agent, usage);
|
|
@@ -432,13 +688,13 @@ export class Room {
|
|
|
432
688
|
return this.capabilities();
|
|
433
689
|
}
|
|
434
690
|
|
|
435
|
-
|
|
436
|
-
this.#
|
|
437
|
-
return this.#
|
|
691
|
+
setAsh(enabled) {
|
|
692
|
+
this.#ashEnabled = Boolean(enabled);
|
|
693
|
+
return this.#ashEnabled;
|
|
438
694
|
}
|
|
439
695
|
|
|
440
|
-
|
|
441
|
-
return this.#
|
|
696
|
+
ashEnabled() {
|
|
697
|
+
return this.#ashEnabled;
|
|
442
698
|
}
|
|
443
699
|
|
|
444
700
|
// A slash command's result, shared with everyone (and with agents through
|
|
@@ -513,25 +769,25 @@ export class Room {
|
|
|
513
769
|
this.#vectors.stop();
|
|
514
770
|
}
|
|
515
771
|
|
|
516
|
-
async send({ text, target, model = null, attachments = [], create = false,
|
|
772
|
+
async send({ text, target, model = null, attachments = [], create = false, ash = false, mode = undefined }) {
|
|
517
773
|
const parsed = parseMessage(text, target);
|
|
518
774
|
const requestedMode = normalizeMode(mode, create === true ? 2 : 1);
|
|
519
775
|
const gate = await this.modeCheck({ target, text, mode: requestedMode });
|
|
520
776
|
if (!gate.ok) throw new Error(gate.error);
|
|
521
777
|
create = requestedMode === 2;
|
|
522
778
|
const ghost = requestedMode === 0;
|
|
523
|
-
const control = requestedMode
|
|
779
|
+
const control = requestedMode >= 3;
|
|
524
780
|
const files = (Array.isArray(attachments) ? attachments : []).map((id) => this.attachment(id)).filter(Boolean);
|
|
525
781
|
if (!parsed.text && !files.length) throw new Error('Write a message first.');
|
|
526
782
|
if (!parsed.target) throw new Error('Choose an agent or begin with @agent.');
|
|
527
783
|
if (model !== null && model !== undefined && model !== '' && !isValidModelName(model)) throw new Error('Model name is not valid.');
|
|
528
784
|
const chosenModel = model || null;
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
const
|
|
532
|
-
const
|
|
785
|
+
// What the human wrote, sent as they wrote it. Ash used to rewrite this line before it left
|
|
786
|
+
// the room; it does not any more, so there is no original to keep beside an abbreviation.
|
|
787
|
+
const text2 = parsed.text || `(${files.length} attached file${files.length === 1 ? '' : 's'})`;
|
|
788
|
+
const ashActive = ash === true && this.#ashEnabled;
|
|
533
789
|
// "!path" tokens point the agent at project files; only existing files count.
|
|
534
|
-
const references = await resolveReferences(this.#projectRoot,
|
|
790
|
+
const references = await resolveReferences(this.#projectRoot, text2);
|
|
535
791
|
|
|
536
792
|
// A standing lease (default #2) needs no arming; a ghost never writes.
|
|
537
793
|
const standing = !ghost && !create && Boolean(this.scopesFor(parsed.target).write.always);
|
|
@@ -539,13 +795,12 @@ export class Room {
|
|
|
539
795
|
if (ghost) this.#ghost.add(messageId);
|
|
540
796
|
await this.#emit('message.created', {
|
|
541
797
|
messageId,
|
|
542
|
-
mode: ghost ? 0 : control ?
|
|
798
|
+
mode: ghost ? 0 : control ? requestedMode : (standing || create ? 2 : 1),
|
|
543
799
|
role: 'user',
|
|
544
800
|
sender: 'you',
|
|
545
801
|
target: parsed.target,
|
|
546
802
|
text: text2,
|
|
547
|
-
|
|
548
|
-
ashCode: ashActive ? { active: true, applied: abbreviated.applied, reason: abbreviated.reason, language: abbreviated.language, originalChars: abbreviated.originalChars, encodedChars: abbreviated.encodedChars } : undefined,
|
|
803
|
+
ash: ashActive ? { active: true } : undefined,
|
|
549
804
|
status: 'sent',
|
|
550
805
|
model: chosenModel,
|
|
551
806
|
attachments: files.length ? files.map((file) => ({ id: file.id, name: file.name, fileName: file.fileName, size: file.size, contentType: file.contentType })) : undefined,
|
|
@@ -609,7 +864,7 @@ export class Room {
|
|
|
609
864
|
allowDelegation = false;
|
|
610
865
|
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}`);
|
|
611
866
|
}
|
|
612
|
-
await this.#track(this.#dispatch({ messageId, targetId: parsed.target, text: text2, requester: 'you', depth: 0, allowDelegation: allowDelegation && !ghost, model: chosenModel, attachments: files, references, lease,
|
|
867
|
+
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) }));
|
|
613
868
|
}
|
|
614
869
|
|
|
615
870
|
// A #2 lease: the project itself is where new files go, and MADRE keeps a scratch folder
|
|
@@ -645,7 +900,7 @@ export class Room {
|
|
|
645
900
|
}
|
|
646
901
|
|
|
647
902
|
// Master brake: every plan and every in-flight turn, recorded as one event.
|
|
648
|
-
async stopAll(reason = 'STOPALL by the human') {
|
|
903
|
+
async stopAll(reason = t('STOPALL by the human')) {
|
|
649
904
|
const plans = this.#plans.size;
|
|
650
905
|
const turns = this.#turns.size;
|
|
651
906
|
for (const plan of this.#plans.values()) plan.stopped = reason;
|
|
@@ -679,7 +934,7 @@ export class Room {
|
|
|
679
934
|
|
|
680
935
|
// One room turn for one agent. `requester` is who asked ('you' or an
|
|
681
936
|
// orchestrating agent); `depth` 0 turns may delegate, deeper ones may not.
|
|
682
|
-
async #dispatch({ messageId, targetId, text, requester, depth, planId = null, allowDelegation = true, model = null, attachments = [], references = [], lease = null,
|
|
937
|
+
async #dispatch({ messageId, targetId, text, requester, depth, planId = null, allowDelegation = true, model = null, attachments = [], references = [], lease = null, ash = false, mode = 1, escalation = null }) {
|
|
683
938
|
if (this.#shuttingDown) return null;
|
|
684
939
|
const agent = this.#agents.find((item) => item.id === targetId);
|
|
685
940
|
if (!agent?.detected) {
|
|
@@ -693,7 +948,7 @@ export class Room {
|
|
|
693
948
|
|
|
694
949
|
const priorEvents = await this.#store.readAll();
|
|
695
950
|
// @madre never reads its own canned replies back: a small model would echo them.
|
|
696
|
-
const { context, recall, memories } = await this.#contextFor(priorEvents, { messageId, text, omitSynthetic: Boolean(agent.local) });
|
|
951
|
+
const { context, recall, memories } = await this.#contextFor(priorEvents, { messageId, text, omitSynthetic: Boolean(agent.local), by: { agent: targetId, turn: messageId } });
|
|
697
952
|
let handoffId = null;
|
|
698
953
|
if (context.previousAgent && context.previousAgent !== targetId) {
|
|
699
954
|
handoffId = randomUUID();
|
|
@@ -724,7 +979,7 @@ export class Room {
|
|
|
724
979
|
await this.#emit('agent.started', { messageId, agent: agent.id, handoffId, planId });
|
|
725
980
|
const controller = new AbortController();
|
|
726
981
|
const turn = { controller, promise: null, planId, agent: agent.id, startedAt: Date.now() };
|
|
727
|
-
turn.promise = this.#runTurn({ messageId, agent, text, requester, depth, planId, allowDelegation, context, recall, memories, handoffId, signal: controller.signal, model, attachments, references, lease,
|
|
982
|
+
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 });
|
|
728
983
|
this.#turns.set(messageId, turn);
|
|
729
984
|
let outcome = null;
|
|
730
985
|
try {
|
|
@@ -736,13 +991,135 @@ export class Room {
|
|
|
736
991
|
// The orchestrator's turn is over before its plan starts, so it is never
|
|
737
992
|
// counted as in flight while the others work.
|
|
738
993
|
if (outcome?.directives?.steps.length) {
|
|
739
|
-
await this.#runPlan({ orchestrator: agent.id, parentMessageId: outcome.responseMessageId, directives: outcome.directives, lease,
|
|
994
|
+
await this.#runPlan({ orchestrator: agent.id, parentMessageId: outcome.responseMessageId, directives: outcome.directives, lease, ash, mode });
|
|
740
995
|
}
|
|
741
996
|
return outcome?.responseMessageId ?? null;
|
|
742
997
|
}
|
|
743
998
|
|
|
999
|
+
// What the next turn to an agent would carry, built exactly the way a turn builds it and sent
|
|
1000
|
+
// nowhere. Nothing here counts: a memory read for this was not recalled, the window does not
|
|
1001
|
+
// move, and no process is started. It is the document MADRE writes in the human's name, which
|
|
1002
|
+
// until now existed only for the instant a CLI was reading it.
|
|
1003
|
+
async briefing({ agent: agentId, mode = 1, text = '' } = {}) {
|
|
1004
|
+
const agent = this.#agents.find((one) => one.id === agentId && one.detected);
|
|
1005
|
+
if (!agent) return null;
|
|
1006
|
+
const priorEvents = await this.#store.readAll();
|
|
1007
|
+
const built = await contextFor({
|
|
1008
|
+
memory: this.#memory, priorEvents, messageId: null, text,
|
|
1009
|
+
contextMaxChars: this.#contextMaxChars, recallShare: this.#recallShare,
|
|
1010
|
+
remember: () => {}, omitSynthetic: Boolean(agent.local), anchor: this.#contextAnchor,
|
|
1011
|
+
cascade: this.#cascade, track: false,
|
|
1012
|
+
});
|
|
1013
|
+
const mcpServers = this.#toolsForTurn && agent.adapter !== 'madre-local'
|
|
1014
|
+
? await this.#toolsForTurn({ agent: agent.id, mode, lease: null, scratchDir: null }).catch(() => [])
|
|
1015
|
+
: [];
|
|
1016
|
+
const scopes = this.scopesFor(agent.id);
|
|
1017
|
+
// The lease a turn at this mode would be given, shaped exactly as the turn shapes it but
|
|
1018
|
+
// creating nothing: no folder is made, no checkpoint is taken. Without it the preview would
|
|
1019
|
+
// quietly under-report what a #2 or a #3 turn is actually told it may do.
|
|
1020
|
+
const ceiling = Math.min(mode, scopes.maxMode ?? 0);
|
|
1021
|
+
const lease = ceiling >= 2 && scopes.write?.enabled && scopes.write?.wired
|
|
1022
|
+
? {
|
|
1023
|
+
leaseId: 'would-be-granted-when-you-send',
|
|
1024
|
+
outDir: this.#projectRoot,
|
|
1025
|
+
relativeDir: '.',
|
|
1026
|
+
scratchDir: '.pulse/out/<this turn>',
|
|
1027
|
+
scopes: { ...Object.fromEntries(SCOPES.map((scope) => [scope, scopes[scope].enabled && scopes[scope].wired])), write: true },
|
|
1028
|
+
create: ceiling === 2,
|
|
1029
|
+
control: ceiling >= 3,
|
|
1030
|
+
airlock: ceiling === 4,
|
|
1031
|
+
}
|
|
1032
|
+
: null;
|
|
1033
|
+
const options = this.#promptOptions({
|
|
1034
|
+
agent, text, requester: 'you', depth: 0, allowDelegation: true,
|
|
1035
|
+
context: built.context, recall: built.recall, memories: built.memories,
|
|
1036
|
+
attachments: [], references: [], lease,
|
|
1037
|
+
scopes: { web: scopes.web.enabled && scopes.web.wired, imageGen: scopes.imageGen.enabled && scopes.imageGen.wired },
|
|
1038
|
+
ash: this.ashEnabled(), mode, escalation: null, mcpServers,
|
|
1039
|
+
});
|
|
1040
|
+
// Each block with what put it here and what would take it away, so "read, never written" can
|
|
1041
|
+
// be checked one block at a time instead of believed.
|
|
1042
|
+
const parts = promptParts(options).map((part) => ({ id: part.id, text: part.text, chars: part.text.length, ...(BLOCK_SOURCES[part.id] ?? { when: null, where: null }) }));
|
|
1043
|
+
return {
|
|
1044
|
+
agent: agent.id, label: agent.label, mode,
|
|
1045
|
+
// What the mode would actually amount to for this agent: its ceiling is its own.
|
|
1046
|
+
ceiling,
|
|
1047
|
+
maxMode: scopes.maxMode ?? 0,
|
|
1048
|
+
lease: Boolean(lease),
|
|
1049
|
+
parts,
|
|
1050
|
+
chars: parts.reduce((sum, part) => sum + part.chars, 0),
|
|
1051
|
+
spared: sparedChars(options),
|
|
1052
|
+
window: { from: built.context?.firstSequence ?? null, through: built.context?.throughSequence ?? null, omitted: built.context?.omittedMessages ?? 0, carried: built.context?.messages?.length ?? 0 },
|
|
1053
|
+
recalled: built.memories?.length ?? 0,
|
|
1054
|
+
quoted: built.recall?.entries?.length ?? 0,
|
|
1055
|
+
};
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
// How this agent's CLI would actually be started: the command line its own adapter builds, the
|
|
1059
|
+
// folder it runs in, the names of what MADRE sets in its environment, and the tool servers
|
|
1060
|
+
// attached for the turn. Built from the same builders the real run uses, and nothing is spawned.
|
|
1061
|
+
//
|
|
1062
|
+
// Values of environment variables are never reported, only names. One of those variables is a
|
|
1063
|
+
// provider key, and a screen that shows what a process is given must not be the easiest place
|
|
1064
|
+
// in the product to read a secret out of.
|
|
1065
|
+
async launch({ agent: agentId, mode = 1 } = {}) {
|
|
1066
|
+
const agent = this.#agents.find((one) => one.id === agentId && one.detected);
|
|
1067
|
+
if (!agent) return null;
|
|
1068
|
+
if (agent.local || agent.adapter === 'madre-local') {
|
|
1069
|
+
return {
|
|
1070
|
+
agent: agent.id, label: agent.label, local: true,
|
|
1071
|
+
says: 'No process is started for @madre: it is the local model answering inside MADRE, through Ollama on this computer.',
|
|
1072
|
+
mcpServers: [], env: [], isolation: [],
|
|
1073
|
+
};
|
|
1074
|
+
}
|
|
1075
|
+
const scopes = this.scopesFor(agent.id);
|
|
1076
|
+
const ceiling = Math.min(mode, scopes.maxMode ?? 0);
|
|
1077
|
+
const mcpServers = this.#toolsForTurn ? await this.#toolsForTurn({ agent: agent.id, mode: ceiling, lease: null, scratchDir: null }).catch(() => []) : [];
|
|
1078
|
+
const prompt = '<the briefing above>';
|
|
1079
|
+
const shared = { projectRoot: this.#projectRoot, prompt, model: null, attachments: [], lease: null, scopes: { web: scopes.web.enabled && scopes.web.wired, imageGen: scopes.imageGen.enabled && scopes.imageGen.wired }, memoryServer: this.#memoryServer, mcpServers };
|
|
1080
|
+
const args = agent.id === 'codex' ? buildCodexArgs(shared)
|
|
1081
|
+
: agent.id === 'claude' ? buildClaudeArgs({ ...shared, attachmentsDir: null, imageStudio: null })
|
|
1082
|
+
: agent.id === 'gemini' ? buildGeminiArgs({ ...shared, policyPath: '<written for this turn, deleted after it>', attachmentsDir: null })
|
|
1083
|
+
: agent.id === 'opencode' ? buildOpenCodeArgs(shared)
|
|
1084
|
+
: null;
|
|
1085
|
+
if (!args) return { agent: agent.id, label: agent.label, local: false, says: `MADRE has no adapter for @${agent.id}.`, mcpServers: [], env: [], isolation: [] };
|
|
1086
|
+
const isolation = {
|
|
1087
|
+
codex: ['--ephemeral: the run keeps no session of its own, so nothing said here reaches another project.'],
|
|
1088
|
+
claude: ['--no-session-persistence: the run keeps no session of its own.', 'Only the project\'s own CLAUDE.md and MADRE\'s briefing are read; hooks, plugins and outside MCP servers are not.'],
|
|
1089
|
+
gemini: ['GEMINI_CLI_HOME points at a temporary home made for this turn and removed after it, so settings, hooks and extensions come from there and not from yours.', 'GEMINI_CLI_NO_RELAUNCH keeps the launcher from spawning a second process MADRE could not stop.'],
|
|
1090
|
+
opencode: ['The run is given the model you chose and nothing else of your session.'],
|
|
1091
|
+
}[agent.id] ?? [];
|
|
1092
|
+
const env = {
|
|
1093
|
+
gemini: [{ name: 'GEMINI_CLI_HOME', note: 'the temporary home for this turn' }, { name: 'GEMINI_CLI_NO_RELAUNCH', note: 'so the launcher stays killable' }],
|
|
1094
|
+
}[agent.id] ?? [];
|
|
1095
|
+
return {
|
|
1096
|
+
agent: agent.id, label: agent.label, local: false,
|
|
1097
|
+
executable: agent.path ?? agent.id,
|
|
1098
|
+
cwd: this.#projectRoot,
|
|
1099
|
+
args: redactArgs(args),
|
|
1100
|
+
promptMarker: prompt,
|
|
1101
|
+
isolation,
|
|
1102
|
+
env,
|
|
1103
|
+
mcpServers: mcpServers.map((server) => ({ name: server.name, command: server.command, args: server.args ?? [], tools: server.tools ?? [], brief: server.brief ?? null, env: Object.keys(server.env ?? {}) })),
|
|
1104
|
+
memoryServer: this.#memoryServer ? { name: this.#memoryServer.name, command: this.#memoryServer.command, args: this.#memoryServer.args ?? [], tools: this.#memoryServer.tools ?? [] } : null,
|
|
1105
|
+
};
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
// What the last prompt for each turn was made of, kept only until its bill arrives.
|
|
1109
|
+
#promptShape = new Map();
|
|
1110
|
+
|
|
1111
|
+
#promptFor(input) {
|
|
1112
|
+
const options = this.#promptOptions(input);
|
|
1113
|
+
const parts = promptParts(options);
|
|
1114
|
+
return { text: parts.map((part) => part.text).join('\n'), parts, spared: sparedChars(options) };
|
|
1115
|
+
}
|
|
1116
|
+
|
|
744
1117
|
#prompt(input) {
|
|
745
|
-
return buildPrompt(
|
|
1118
|
+
return buildPrompt(this.#promptOptions(input));
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
#promptOptions(input) {
|
|
1122
|
+
return ({
|
|
746
1123
|
...input,
|
|
747
1124
|
others: this.delegatesFor(input.agent.id),
|
|
748
1125
|
delegation: this.#delegation,
|
|
@@ -752,11 +1129,12 @@ export class Room {
|
|
|
752
1129
|
memoryServer: this.#memoryServer,
|
|
753
1130
|
controlHolder: this.#controlDesk.holder?.agent ?? null,
|
|
754
1131
|
privacyMarker: this.#privacy?.marker ?? '[ENTIDAD-ORG]',
|
|
1132
|
+
sdk: sdkPaths(),
|
|
755
1133
|
madreModel: this.#agents.find((agent) => agent.id === 'madre' && agent.ready)?.version ?? null,
|
|
756
1134
|
});
|
|
757
1135
|
}
|
|
758
1136
|
|
|
759
|
-
async #runTurn({ messageId, agent, text, requester, depth, planId, allowDelegation, context, recall = null, memories = null, handoffId, signal, model = null, attachments = [], references = [], lease: sharedLease = null,
|
|
1137
|
+
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 }) {
|
|
760
1138
|
// The lease is the human's; what each agent may do inside it is that
|
|
761
1139
|
// agent's own enabled scopes. A delegate without file creation runs
|
|
762
1140
|
// read-only even while the plan holds a lease.
|
|
@@ -768,14 +1146,14 @@ export class Room {
|
|
|
768
1146
|
let lease = null;
|
|
769
1147
|
let controlRun = null;
|
|
770
1148
|
let createRun = null;
|
|
771
|
-
if (mode
|
|
1149
|
+
if (mode >= 3 && (requester === 'you' || planId) && depth <= 1 && agentScopes.maxMode >= mode && enabled.write) {
|
|
772
1150
|
// CONTROL: the project itself is the writable root, and a checkpoint
|
|
773
1151
|
// taken now makes every change of this turn reversible. A plan step gets
|
|
774
1152
|
// it when the orchestrator, itself in #3, named #3 for that step.
|
|
775
1153
|
if (this.#controlDesk.holder) {
|
|
776
1154
|
await this.#alert('control-busy', `@${this.#controlDesk.holder.agent} still holds CONTROL; @${agent.id} answers read-only this turn.`, `control-busy:${agent.id}`);
|
|
777
1155
|
} else {
|
|
778
|
-
const seat = await this.#controlDesk.begin({ agent, messageId, enabledScopes: enabled });
|
|
1156
|
+
const seat = await this.#controlDesk.begin({ agent, messageId, enabledScopes: enabled, mode });
|
|
779
1157
|
controlRun = seat.run;
|
|
780
1158
|
lease = seat.lease;
|
|
781
1159
|
await this.#emit('control.started', seat.announcement);
|
|
@@ -830,7 +1208,7 @@ export class Room {
|
|
|
830
1208
|
? imageStudioFor({ enabled: true, model: studio.model, outDir: lease.scratchDir ? joinPath(this.#projectRoot, lease.scratchDir) : lease.outDir })
|
|
831
1209
|
: null;
|
|
832
1210
|
// The turn's effective mode: #0 for ghosts, #3 in control, #2 only while it holds a lease.
|
|
833
|
-
const turnMode = mode === 0 ? 0 : controlRun ?
|
|
1211
|
+
const turnMode = mode === 0 ? 0 : controlRun ? controlRun.mode : lease ? 2 : 1;
|
|
834
1212
|
try {
|
|
835
1213
|
const invoke = this.#invokers[agent.adapter];
|
|
836
1214
|
if (!invoke) throw new Error(`${agent.label} does not have a supported MADRE adapter.`);
|
|
@@ -839,11 +1217,33 @@ export class Room {
|
|
|
839
1217
|
const notesBefore = this.#memory ? this.#memory.maxMemoryId() : 0;
|
|
840
1218
|
const others = this.delegatesFor(agent.id);
|
|
841
1219
|
const mayDelegate = allowDelegation && this.#delegation && depth === 0;
|
|
1220
|
+
// Tools the modules hand to this turn (a browser, say), attached to the CLI for this run only.
|
|
1221
|
+
const mcpServers = this.#toolsForTurn && agent.adapter !== 'madre-local'
|
|
1222
|
+
? await this.#toolsForTurn({ agent: agent.id, mode: turnMode, lease, scratchDir: lease?.scratchDir ? joinPath(this.#projectRoot, lease.scratchDir) : null }).catch(() => [])
|
|
1223
|
+
: [];
|
|
1224
|
+
// The prompt and the shape it was built from: one is sent, the other is kept until the CLI
|
|
1225
|
+
// says what it cost, so the bill can be attributed to the blocks that caused it.
|
|
1226
|
+
const shaped = this.#promptFor({ agent, text, requester, depth, allowDelegation, context, recall, memories, attachments, references, lease, scopes: turnScopes, imageStudio, ash, 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 });
|
|
1227
|
+
this.#promptShape.set(responseMessageId, { parts: shaped.parts, spared: shaped.spared });
|
|
1228
|
+
// What this turn is about to read, said while it is still reading it. The size is exact;
|
|
1229
|
+
// only the conversion to tokens is an estimate, and it is made at this room's own rate.
|
|
1230
|
+
const reading = this.#reading(shaped.parts);
|
|
1231
|
+
this.#say('turn.reading', { messageId, agent: agent.id, ...reading });
|
|
1232
|
+
// And again as the answer arrives. What is counted is the answer itself, not the protocol
|
|
1233
|
+
// around it, so a CLI that hands over one blob at the end honestly reports nothing until
|
|
1234
|
+
// then rather than a number that means the shape of its own output format.
|
|
1235
|
+
let told = 0;
|
|
1236
|
+
const onProgress = ({ chars }) => {
|
|
1237
|
+
if (!Number.isFinite(chars) || chars <= told) return;
|
|
1238
|
+
told = chars;
|
|
1239
|
+
this.#say('turn.reading', { messageId, agent: agent.id, ...reading, out: chars, outTokens: this.#rate ? Math.round(chars / this.#rate) : null });
|
|
1240
|
+
};
|
|
842
1241
|
const result = await invoke({
|
|
1242
|
+
onProgress,
|
|
843
1243
|
executable: agent.path,
|
|
844
1244
|
projectRoot: this.#projectRoot,
|
|
845
1245
|
text,
|
|
846
|
-
prompt:
|
|
1246
|
+
prompt: shaped.text,
|
|
847
1247
|
timeoutMs: this.timeoutFor(agent.id),
|
|
848
1248
|
signal,
|
|
849
1249
|
model,
|
|
@@ -852,6 +1252,7 @@ export class Room {
|
|
|
852
1252
|
scopes: turnScopes,
|
|
853
1253
|
imageStudio,
|
|
854
1254
|
memoryServer: memoryServerForTurn(this.#memoryServer, { agent: agent.id, messageId: responseMessageId, mode: turnMode }),
|
|
1255
|
+
mcpServers,
|
|
855
1256
|
// For @madre: who asks, who it may convene, and whether a plan would run at all.
|
|
856
1257
|
requester,
|
|
857
1258
|
crew: others,
|
|
@@ -878,16 +1279,19 @@ export class Room {
|
|
|
878
1279
|
if (depth > 0 && /```pulse/i.test(result.text)) {
|
|
879
1280
|
await this.#alert('nested-delegation', `@${agent.id} tried to open a plan from inside a plan. It was ignored; the sequence stays under @${requester}. STOPALL if the room drifts.`, `nested:${agent.id}`);
|
|
880
1281
|
}
|
|
881
|
-
const abbreviated = ashCode ? compressAshCode(result.text) : null;
|
|
882
1282
|
await this.#emit('message.created', {
|
|
883
1283
|
messageId: responseMessageId,
|
|
884
1284
|
parentMessageId: messageId,
|
|
885
1285
|
role: 'assistant',
|
|
886
1286
|
sender: agent.id,
|
|
887
1287
|
target: requester,
|
|
888
|
-
text:
|
|
889
|
-
|
|
890
|
-
|
|
1288
|
+
text: result.text,
|
|
1289
|
+
// What the archive handed this turn, so the room can see its own memory working. The
|
|
1290
|
+
// whole point of remembering is invisible until the moment it is used.
|
|
1291
|
+
recalled: memories?.length
|
|
1292
|
+
? memories.slice(0, 6).map((note) => ({ id: note.id, kind: note.kind, text: note.text.length > 150 ? `${note.text.slice(0, 149)}…` : note.text, via: note.via ?? 'search' }))
|
|
1293
|
+
: undefined,
|
|
1294
|
+
ash: ash ? { active: true } : undefined,
|
|
891
1295
|
status: 'completed',
|
|
892
1296
|
planId,
|
|
893
1297
|
model,
|
|
@@ -901,6 +1305,10 @@ export class Room {
|
|
|
901
1305
|
if (guarded.hits) await this.#emit('privacy.redacted', { agent: agent.id, messageId, responseMessageId, hits: guarded.hits, marker: this.#privacy.marker });
|
|
902
1306
|
if (artifacts.length) {
|
|
903
1307
|
await this.#emit('artifacts.created', { leaseId: lease.leaseId, messageId, responseMessageId, agent: agent.id, outDir: lease.relativeDir, files: artifacts });
|
|
1308
|
+
// A <id>.module.mjs is an agent proposing a module: the human installs it from the room, or not.
|
|
1309
|
+
for (const file of artifacts.filter((item) => isModuleFile(item.path))) {
|
|
1310
|
+
await this.#emit('module.proposed', { agent: agent.id, messageId, responseMessageId, path: file.path, name: file.name });
|
|
1311
|
+
}
|
|
904
1312
|
}
|
|
905
1313
|
if (createChanges && (createChanges.existingReverted.length || createChanges.forbiddenReverted.length)) {
|
|
906
1314
|
await this.#emit('create.reverted', { checkpointId: createChanges.checkpointId, agent: agent.id, messageId, responseMessageId, existing: createChanges.existingReverted, forbidden: createChanges.forbiddenReverted, message: createChanges.message });
|
|
@@ -915,6 +1323,7 @@ export class Room {
|
|
|
915
1323
|
}
|
|
916
1324
|
if (controlChanges) await this.#emit('control.changed', { ...controlChanges, responseMessageId });
|
|
917
1325
|
await this.#recordUsage(agent.id, result.usage, { messageId, responseMessageId });
|
|
1326
|
+
await this.#recordCost(agent.id, responseMessageId, result.usage, turnMode);
|
|
918
1327
|
await this.#emit('agent.completed', { messageId, agent: agent.id, handoffId, planId });
|
|
919
1328
|
return { responseMessageId, directives };
|
|
920
1329
|
} catch (error) {
|
|
@@ -927,12 +1336,12 @@ export class Room {
|
|
|
927
1336
|
}
|
|
928
1337
|
}
|
|
929
1338
|
|
|
930
|
-
async #runPlan({ orchestrator, parentMessageId, directives, lease = null,
|
|
1339
|
+
async #runPlan({ orchestrator, parentMessageId, directives, lease = null, ash = false, mode = 1 }) {
|
|
931
1340
|
const planId = randomUUID();
|
|
932
1341
|
// The plan's ceiling is the human's mode: #3 when the orchestrator held CONTROL, #2 while a
|
|
933
1342
|
// lease exists, #1 otherwise. A step may ask for a mode ("@codex #2: …"); it gets the lowest
|
|
934
1343
|
// of what it asked, the ceiling and its own MAX MODE. Without a number it inherits the plan.
|
|
935
|
-
const ceiling = mode >= 3 ?
|
|
1344
|
+
const ceiling = mode >= 3 ? mode : lease ? 2 : 1;
|
|
936
1345
|
const stepMode = (step) => {
|
|
937
1346
|
const scopes = this.scopesFor(step.agent);
|
|
938
1347
|
const asked = normalizeMode(step.mode, lease ? 2 : 1);
|
|
@@ -956,8 +1365,7 @@ export class Room {
|
|
|
956
1365
|
if (plan.stopped) break;
|
|
957
1366
|
plan.step = index + 1;
|
|
958
1367
|
const messageId = randomUUID();
|
|
959
|
-
const
|
|
960
|
-
const stepText = abbreviated?.text ?? step.text;
|
|
1368
|
+
const stepText = step.text;
|
|
961
1369
|
// A creation step in a #1 plan: stop and ask the human before the
|
|
962
1370
|
// agent starts, once, with a clock. Permission written by the
|
|
963
1371
|
// orchestrator inside the step text never counts.
|
|
@@ -967,7 +1375,7 @@ export class Room {
|
|
|
967
1375
|
const wanted = stepMode(step);
|
|
968
1376
|
// Under a #3 ceiling the orchestrator's word is enough: a #2 step gets its project lease,
|
|
969
1377
|
// a #3 step gets CONTROL for its turn. Under #1 a creation step still asks the human.
|
|
970
|
-
if (ceiling
|
|
1378
|
+
if (ceiling >= 3 && !stepLease && wanted === 2) {
|
|
971
1379
|
stepLease = await this.#projectLease({ leaseId: randomUUID(), messageId });
|
|
972
1380
|
stepLease.scopeCeiling = { write: true, imageGen: true, web: true };
|
|
973
1381
|
stepLease.scopes = Object.fromEntries(SCOPES.map((scope) => [scope, stepScopes[scope].enabled && stepScopes[scope].wired]));
|
|
@@ -985,16 +1393,15 @@ export class Room {
|
|
|
985
1393
|
sender: orchestrator,
|
|
986
1394
|
target: step.agent,
|
|
987
1395
|
text: stepText,
|
|
988
|
-
|
|
989
|
-
ashCode: ashCode ? { active: true, applied: abbreviated.applied, reason: abbreviated.reason, language: abbreviated.language, originalChars: abbreviated.originalChars, encodedChars: abbreviated.encodedChars } : undefined,
|
|
1396
|
+
ash: ash ? { active: true } : undefined,
|
|
990
1397
|
status: 'delegated',
|
|
991
1398
|
planId,
|
|
992
|
-
mode: wanted
|
|
1399
|
+
mode: wanted >= 3 ? wanted : stepLease && stepScopes.write.enabled ? 2 : 1,
|
|
993
1400
|
escalation: escalation ?? undefined,
|
|
994
1401
|
step: index + 1,
|
|
995
1402
|
totalSteps: directives.steps.length + (directives.closing ? 1 : 0),
|
|
996
1403
|
});
|
|
997
|
-
await this.#dispatch({ messageId, targetId: step.agent, text: stepText, requester: orchestrator, depth: 1, planId, allowDelegation: false, lease: wanted
|
|
1404
|
+
await this.#dispatch({ messageId, targetId: step.agent, text: stepText, requester: orchestrator, depth: 1, planId, allowDelegation: false, lease: wanted >= 3 ? null : stepLease, ash, mode: wanted >= 3 ? wanted : stepLease && stepScopes.write.enabled ? 2 : 1, escalation });
|
|
998
1405
|
}
|
|
999
1406
|
if (!plan.stopped && directives.closing) {
|
|
1000
1407
|
plan.step = directives.steps.length + 1;
|
|
@@ -1010,7 +1417,7 @@ export class Room {
|
|
|
1010
1417
|
step: plan.step,
|
|
1011
1418
|
totalSteps: plan.step,
|
|
1012
1419
|
});
|
|
1013
|
-
await this.#dispatch({ messageId, targetId: orchestrator, text: `${directives.closing}\n(The delegated agents have answered above; this is your closing turn.)`, requester: orchestrator, depth: 1, planId, allowDelegation: false, lease,
|
|
1420
|
+
await this.#dispatch({ messageId, targetId: orchestrator, text: `${directives.closing}\n(The delegated agents have answered above; this is your closing turn.)`, requester: orchestrator, depth: 1, planId, allowDelegation: false, lease, ash });
|
|
1014
1421
|
}
|
|
1015
1422
|
} finally {
|
|
1016
1423
|
this.#plans.delete(planId);
|