@jossuealcala/madre 0.3.3 → 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 +412 -3
- package/CONTRIBUTING.md +3 -1
- package/README.md +67 -185
- 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 +3825 -851
- package/public/es.js +2050 -0
- package/public/i18n.js +66 -0
- package/public/index.html +94 -13
- package/public/inquiry.js +220 -0
- package/public/resay.js +77 -0
- package/public/styles.css +602 -62
- package/public/troubleshooting.js +168 -46
- package/src/adapters/claude.mjs +2 -1
- package/src/adapters/codex.mjs +2 -1
- package/src/adapters/gemini.mjs +6 -5
- package/src/adapters/opencode.mjs +2 -1
- package/src/adapters/process.mjs +17 -5
- package/src/asking.mjs +128 -0
- package/src/auth-probe.mjs +58 -1
- package/src/chats.mjs +193 -0
- package/src/checkpoint.mjs +1 -1
- package/src/cold.mjs +56 -0
- package/src/commands.mjs +6 -0
- 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/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 +5 -3
- package/src/modules/helpers.mjs +31 -0
- package/src/modules/image-studio.mjs +9 -4
- package/src/modules/index.mjs +141 -9
- package/src/modules/ollama.mjs +66 -10
- package/src/modules/playwright.mjs +36 -19
- package/src/modules/ripley.mjs +5 -3
- package/src/modules/sdk.mjs +93 -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/economy.mjs +161 -0
- package/src/room/prompt.mjs +118 -46
- package/src/room.mjs +443 -44
- package/src/runtime-detection.mjs +27 -8
- package/src/server.mjs +699 -69
- package/src/setup.mjs +1 -1
- package/src/updates.mjs +4 -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,10 +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 {
|
|
39
|
+
import { redactArgs } from './launch.mjs';
|
|
31
40
|
|
|
32
41
|
// Adapters can fail with multi-line stderr or stack traces. The room keeps only
|
|
33
42
|
// the first meaningful line, bounded, so the event log and the UI stay readable.
|
|
@@ -65,10 +74,15 @@ export class Room {
|
|
|
65
74
|
#contextMaxChars;
|
|
66
75
|
#memory; // RoomMemory: durable recall of everything said outside GHOST
|
|
67
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;
|
|
68
81
|
#archivist; // room/archivist: who distils, when, and the bench
|
|
69
82
|
#vectors; // room/vectors: embeddings filled in the background
|
|
70
83
|
#escalation; // room/escalation: plan steps waiting for the human
|
|
71
84
|
#ghost = new GhostLedger();
|
|
85
|
+
#liveListeners = new Set();
|
|
72
86
|
#memoryServer; // MCP descriptor handed to every turn so the agent can query the memory itself
|
|
73
87
|
#mother = null; // MotherChannel: her coded words to the crew
|
|
74
88
|
#shuttingDown = false;
|
|
@@ -87,7 +101,7 @@ export class Room {
|
|
|
87
101
|
#maxPlanSteps;
|
|
88
102
|
#maxConcurrentTurns;
|
|
89
103
|
#planMaxAgeMs;
|
|
90
|
-
#
|
|
104
|
+
#ashEnabled = false;
|
|
91
105
|
|
|
92
106
|
constructor({
|
|
93
107
|
store,
|
|
@@ -97,6 +111,7 @@ 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,
|
|
@@ -118,6 +133,7 @@ export class Room {
|
|
|
118
133
|
this.#contextMaxChars = contextMaxChars;
|
|
119
134
|
this.#memory = memory;
|
|
120
135
|
this.#recallShare = Math.min(0.6, Math.max(0, Number.isFinite(recallShare) ? recallShare : 0.3));
|
|
136
|
+
this.#cascade = cascade !== false;
|
|
121
137
|
this.#memoryServer = memoryServer;
|
|
122
138
|
this.#mother = mother;
|
|
123
139
|
this.#privacy = privacy;
|
|
@@ -133,6 +149,8 @@ export class Room {
|
|
|
133
149
|
this.#controlDesk = new ControlDesk({ projectRoot });
|
|
134
150
|
this.#budget = new Budget({ softBudget: softTokenBudget });
|
|
135
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);
|
|
136
154
|
this.#vectors = new VectorWorker({ memory, stopped: () => this.#shuttingDown });
|
|
137
155
|
this.#archivist = new Archivist({
|
|
138
156
|
memory,
|
|
@@ -218,6 +236,23 @@ export class Room {
|
|
|
218
236
|
decideMode(requestId, decision) { return this.#escalation.decide(requestId, decision); }
|
|
219
237
|
|
|
220
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
|
+
}
|
|
221
256
|
async #emit(type, payload) {
|
|
222
257
|
if (this.#ghost.isGhost(type, payload)) return this.#ghost.emit(type, payload);
|
|
223
258
|
const event = await this.#store.append(type, payload);
|
|
@@ -234,16 +269,29 @@ export class Room {
|
|
|
234
269
|
}
|
|
235
270
|
embedNow() { return this.#vectors.runNow(); }
|
|
236
271
|
|
|
237
|
-
|
|
238
|
-
|
|
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;
|
|
239
286
|
}
|
|
240
287
|
|
|
241
288
|
// The human tunes the archive from MU/TH/UR; changes apply to the next run.
|
|
242
289
|
distillSettings() {
|
|
243
|
-
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) };
|
|
244
291
|
}
|
|
245
292
|
configureDistill(patch = {}) {
|
|
246
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;
|
|
247
295
|
this.#archivist.configure(patch);
|
|
248
296
|
return this.distillSettings();
|
|
249
297
|
}
|
|
@@ -293,12 +341,197 @@ export class Room {
|
|
|
293
341
|
return { ...alert, crew };
|
|
294
342
|
}
|
|
295
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
|
+
|
|
296
366
|
/* ---------- NOSTROMO: the human's view of the archive ---------- */
|
|
297
367
|
|
|
298
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
|
+
|
|
299
380
|
memoryResearch() {
|
|
300
381
|
if (!this.#memory) return null;
|
|
301
|
-
|
|
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 };
|
|
302
535
|
}
|
|
303
536
|
|
|
304
537
|
/* ---------- privacy: terms that never travel through the room ---------- */
|
|
@@ -402,6 +635,26 @@ export class Room {
|
|
|
402
635
|
|
|
403
636
|
budgetWindow() { return this.#budget.view(this.#agents); }
|
|
404
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
|
+
|
|
405
658
|
async #recordUsage(agent, usage, { messageId = null, responseMessageId = null } = {}) {
|
|
406
659
|
if (!usage) return;
|
|
407
660
|
const charged = this.#budget.record(agent, usage);
|
|
@@ -435,13 +688,13 @@ export class Room {
|
|
|
435
688
|
return this.capabilities();
|
|
436
689
|
}
|
|
437
690
|
|
|
438
|
-
|
|
439
|
-
this.#
|
|
440
|
-
return this.#
|
|
691
|
+
setAsh(enabled) {
|
|
692
|
+
this.#ashEnabled = Boolean(enabled);
|
|
693
|
+
return this.#ashEnabled;
|
|
441
694
|
}
|
|
442
695
|
|
|
443
|
-
|
|
444
|
-
return this.#
|
|
696
|
+
ashEnabled() {
|
|
697
|
+
return this.#ashEnabled;
|
|
445
698
|
}
|
|
446
699
|
|
|
447
700
|
// A slash command's result, shared with everyone (and with agents through
|
|
@@ -516,7 +769,7 @@ export class Room {
|
|
|
516
769
|
this.#vectors.stop();
|
|
517
770
|
}
|
|
518
771
|
|
|
519
|
-
async send({ text, target, model = null, attachments = [], create = false,
|
|
772
|
+
async send({ text, target, model = null, attachments = [], create = false, ash = false, mode = undefined }) {
|
|
520
773
|
const parsed = parseMessage(text, target);
|
|
521
774
|
const requestedMode = normalizeMode(mode, create === true ? 2 : 1);
|
|
522
775
|
const gate = await this.modeCheck({ target, text, mode: requestedMode });
|
|
@@ -529,12 +782,12 @@ export class Room {
|
|
|
529
782
|
if (!parsed.target) throw new Error('Choose an agent or begin with @agent.');
|
|
530
783
|
if (model !== null && model !== undefined && model !== '' && !isValidModelName(model)) throw new Error('Model name is not valid.');
|
|
531
784
|
const chosenModel = model || null;
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
const
|
|
535
|
-
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;
|
|
536
789
|
// "!path" tokens point the agent at project files; only existing files count.
|
|
537
|
-
const references = await resolveReferences(this.#projectRoot,
|
|
790
|
+
const references = await resolveReferences(this.#projectRoot, text2);
|
|
538
791
|
|
|
539
792
|
// A standing lease (default #2) needs no arming; a ghost never writes.
|
|
540
793
|
const standing = !ghost && !create && Boolean(this.scopesFor(parsed.target).write.always);
|
|
@@ -547,8 +800,7 @@ export class Room {
|
|
|
547
800
|
sender: 'you',
|
|
548
801
|
target: parsed.target,
|
|
549
802
|
text: text2,
|
|
550
|
-
|
|
551
|
-
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,
|
|
552
804
|
status: 'sent',
|
|
553
805
|
model: chosenModel,
|
|
554
806
|
attachments: files.length ? files.map((file) => ({ id: file.id, name: file.name, fileName: file.fileName, size: file.size, contentType: file.contentType })) : undefined,
|
|
@@ -612,7 +864,7 @@ export class Room {
|
|
|
612
864
|
allowDelegation = false;
|
|
613
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}`);
|
|
614
866
|
}
|
|
615
|
-
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) }));
|
|
616
868
|
}
|
|
617
869
|
|
|
618
870
|
// A #2 lease: the project itself is where new files go, and MADRE keeps a scratch folder
|
|
@@ -648,7 +900,7 @@ export class Room {
|
|
|
648
900
|
}
|
|
649
901
|
|
|
650
902
|
// Master brake: every plan and every in-flight turn, recorded as one event.
|
|
651
|
-
async stopAll(reason = 'STOPALL by the human') {
|
|
903
|
+
async stopAll(reason = t('STOPALL by the human')) {
|
|
652
904
|
const plans = this.#plans.size;
|
|
653
905
|
const turns = this.#turns.size;
|
|
654
906
|
for (const plan of this.#plans.values()) plan.stopped = reason;
|
|
@@ -682,7 +934,7 @@ export class Room {
|
|
|
682
934
|
|
|
683
935
|
// One room turn for one agent. `requester` is who asked ('you' or an
|
|
684
936
|
// orchestrating agent); `depth` 0 turns may delegate, deeper ones may not.
|
|
685
|
-
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 }) {
|
|
686
938
|
if (this.#shuttingDown) return null;
|
|
687
939
|
const agent = this.#agents.find((item) => item.id === targetId);
|
|
688
940
|
if (!agent?.detected) {
|
|
@@ -696,7 +948,7 @@ export class Room {
|
|
|
696
948
|
|
|
697
949
|
const priorEvents = await this.#store.readAll();
|
|
698
950
|
// @madre never reads its own canned replies back: a small model would echo them.
|
|
699
|
-
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 } });
|
|
700
952
|
let handoffId = null;
|
|
701
953
|
if (context.previousAgent && context.previousAgent !== targetId) {
|
|
702
954
|
handoffId = randomUUID();
|
|
@@ -727,7 +979,7 @@ export class Room {
|
|
|
727
979
|
await this.#emit('agent.started', { messageId, agent: agent.id, handoffId, planId });
|
|
728
980
|
const controller = new AbortController();
|
|
729
981
|
const turn = { controller, promise: null, planId, agent: agent.id, startedAt: Date.now() };
|
|
730
|
-
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 });
|
|
731
983
|
this.#turns.set(messageId, turn);
|
|
732
984
|
let outcome = null;
|
|
733
985
|
try {
|
|
@@ -739,13 +991,135 @@ export class Room {
|
|
|
739
991
|
// The orchestrator's turn is over before its plan starts, so it is never
|
|
740
992
|
// counted as in flight while the others work.
|
|
741
993
|
if (outcome?.directives?.steps.length) {
|
|
742
|
-
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 });
|
|
743
995
|
}
|
|
744
996
|
return outcome?.responseMessageId ?? null;
|
|
745
997
|
}
|
|
746
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
|
+
|
|
747
1117
|
#prompt(input) {
|
|
748
|
-
return buildPrompt(
|
|
1118
|
+
return buildPrompt(this.#promptOptions(input));
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
#promptOptions(input) {
|
|
1122
|
+
return ({
|
|
749
1123
|
...input,
|
|
750
1124
|
others: this.delegatesFor(input.agent.id),
|
|
751
1125
|
delegation: this.#delegation,
|
|
@@ -755,11 +1129,12 @@ export class Room {
|
|
|
755
1129
|
memoryServer: this.#memoryServer,
|
|
756
1130
|
controlHolder: this.#controlDesk.holder?.agent ?? null,
|
|
757
1131
|
privacyMarker: this.#privacy?.marker ?? '[ENTIDAD-ORG]',
|
|
1132
|
+
sdk: sdkPaths(),
|
|
758
1133
|
madreModel: this.#agents.find((agent) => agent.id === 'madre' && agent.ready)?.version ?? null,
|
|
759
1134
|
});
|
|
760
1135
|
}
|
|
761
1136
|
|
|
762
|
-
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 }) {
|
|
763
1138
|
// The lease is the human's; what each agent may do inside it is that
|
|
764
1139
|
// agent's own enabled scopes. A delegate without file creation runs
|
|
765
1140
|
// read-only even while the plan holds a lease.
|
|
@@ -846,11 +1221,29 @@ export class Room {
|
|
|
846
1221
|
const mcpServers = this.#toolsForTurn && agent.adapter !== 'madre-local'
|
|
847
1222
|
? await this.#toolsForTurn({ agent: agent.id, mode: turnMode, lease, scratchDir: lease?.scratchDir ? joinPath(this.#projectRoot, lease.scratchDir) : null }).catch(() => [])
|
|
848
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
|
+
};
|
|
849
1241
|
const result = await invoke({
|
|
1242
|
+
onProgress,
|
|
850
1243
|
executable: agent.path,
|
|
851
1244
|
projectRoot: this.#projectRoot,
|
|
852
1245
|
text,
|
|
853
|
-
prompt:
|
|
1246
|
+
prompt: shaped.text,
|
|
854
1247
|
timeoutMs: this.timeoutFor(agent.id),
|
|
855
1248
|
signal,
|
|
856
1249
|
model,
|
|
@@ -886,16 +1279,19 @@ export class Room {
|
|
|
886
1279
|
if (depth > 0 && /```pulse/i.test(result.text)) {
|
|
887
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}`);
|
|
888
1281
|
}
|
|
889
|
-
const abbreviated = ashCode ? compressAshCode(result.text) : null;
|
|
890
1282
|
await this.#emit('message.created', {
|
|
891
1283
|
messageId: responseMessageId,
|
|
892
1284
|
parentMessageId: messageId,
|
|
893
1285
|
role: 'assistant',
|
|
894
1286
|
sender: agent.id,
|
|
895
1287
|
target: requester,
|
|
896
|
-
text:
|
|
897
|
-
|
|
898
|
-
|
|
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,
|
|
899
1295
|
status: 'completed',
|
|
900
1296
|
planId,
|
|
901
1297
|
model,
|
|
@@ -909,6 +1305,10 @@ export class Room {
|
|
|
909
1305
|
if (guarded.hits) await this.#emit('privacy.redacted', { agent: agent.id, messageId, responseMessageId, hits: guarded.hits, marker: this.#privacy.marker });
|
|
910
1306
|
if (artifacts.length) {
|
|
911
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
|
+
}
|
|
912
1312
|
}
|
|
913
1313
|
if (createChanges && (createChanges.existingReverted.length || createChanges.forbiddenReverted.length)) {
|
|
914
1314
|
await this.#emit('create.reverted', { checkpointId: createChanges.checkpointId, agent: agent.id, messageId, responseMessageId, existing: createChanges.existingReverted, forbidden: createChanges.forbiddenReverted, message: createChanges.message });
|
|
@@ -923,6 +1323,7 @@ export class Room {
|
|
|
923
1323
|
}
|
|
924
1324
|
if (controlChanges) await this.#emit('control.changed', { ...controlChanges, responseMessageId });
|
|
925
1325
|
await this.#recordUsage(agent.id, result.usage, { messageId, responseMessageId });
|
|
1326
|
+
await this.#recordCost(agent.id, responseMessageId, result.usage, turnMode);
|
|
926
1327
|
await this.#emit('agent.completed', { messageId, agent: agent.id, handoffId, planId });
|
|
927
1328
|
return { responseMessageId, directives };
|
|
928
1329
|
} catch (error) {
|
|
@@ -935,7 +1336,7 @@ export class Room {
|
|
|
935
1336
|
}
|
|
936
1337
|
}
|
|
937
1338
|
|
|
938
|
-
async #runPlan({ orchestrator, parentMessageId, directives, lease = null,
|
|
1339
|
+
async #runPlan({ orchestrator, parentMessageId, directives, lease = null, ash = false, mode = 1 }) {
|
|
939
1340
|
const planId = randomUUID();
|
|
940
1341
|
// The plan's ceiling is the human's mode: #3 when the orchestrator held CONTROL, #2 while a
|
|
941
1342
|
// lease exists, #1 otherwise. A step may ask for a mode ("@codex #2: …"); it gets the lowest
|
|
@@ -964,8 +1365,7 @@ export class Room {
|
|
|
964
1365
|
if (plan.stopped) break;
|
|
965
1366
|
plan.step = index + 1;
|
|
966
1367
|
const messageId = randomUUID();
|
|
967
|
-
const
|
|
968
|
-
const stepText = abbreviated?.text ?? step.text;
|
|
1368
|
+
const stepText = step.text;
|
|
969
1369
|
// A creation step in a #1 plan: stop and ask the human before the
|
|
970
1370
|
// agent starts, once, with a clock. Permission written by the
|
|
971
1371
|
// orchestrator inside the step text never counts.
|
|
@@ -993,8 +1393,7 @@ export class Room {
|
|
|
993
1393
|
sender: orchestrator,
|
|
994
1394
|
target: step.agent,
|
|
995
1395
|
text: stepText,
|
|
996
|
-
|
|
997
|
-
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,
|
|
998
1397
|
status: 'delegated',
|
|
999
1398
|
planId,
|
|
1000
1399
|
mode: wanted >= 3 ? wanted : stepLease && stepScopes.write.enabled ? 2 : 1,
|
|
@@ -1002,7 +1401,7 @@ export class Room {
|
|
|
1002
1401
|
step: index + 1,
|
|
1003
1402
|
totalSteps: directives.steps.length + (directives.closing ? 1 : 0),
|
|
1004
1403
|
});
|
|
1005
|
-
await this.#dispatch({ messageId, targetId: step.agent, text: stepText, requester: orchestrator, depth: 1, planId, allowDelegation: false, lease: wanted >= 3 ? null : stepLease,
|
|
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 });
|
|
1006
1405
|
}
|
|
1007
1406
|
if (!plan.stopped && directives.closing) {
|
|
1008
1407
|
plan.step = directives.steps.length + 1;
|
|
@@ -1018,7 +1417,7 @@ export class Room {
|
|
|
1018
1417
|
step: plan.step,
|
|
1019
1418
|
totalSteps: plan.step,
|
|
1020
1419
|
});
|
|
1021
|
-
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 });
|
|
1022
1421
|
}
|
|
1023
1422
|
} finally {
|
|
1024
1423
|
this.#plans.delete(planId);
|