@jossuealcala/madre 0.5.2 → 0.7.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 +265 -1
- package/README.md +33 -1
- package/docs/SDK.md +173 -6
- package/docs/sdk/connector-module.mjs +186 -0
- package/package.json +6 -2
- package/public/abduction.js +384 -0
- package/public/app.js +3450 -540
- package/public/assets/abduction-cat-blink.png +0 -0
- package/public/assets/abduction-cat-control-left.png +0 -0
- package/public/assets/abduction-cat-control-right.png +0 -0
- package/public/assets/abduction-cat.png +0 -0
- package/public/es.js +354 -54
- package/public/index.html +65 -19
- package/public/routing.js +74 -0
- package/public/styles.css +788 -77
- package/src/adapters/claude.mjs +6 -2
- package/src/adapters/gemini.mjs +26 -5
- package/src/adapters/madre.mjs +5 -0
- package/src/choices.mjs +64 -0
- package/src/dataset.mjs +11 -6
- package/src/distiller.mjs +12 -1
- package/src/file-ops.mjs +45 -0
- package/src/lease.mjs +37 -2
- package/src/mcp/smtp-server.mjs +189 -0
- package/src/memory-dashboard.mjs +69 -0
- package/src/memory.mjs +222 -18
- package/src/modules/ahp.mjs +1 -1
- package/src/modules/ash-policy.mjs +67 -0
- package/src/modules/ash.mjs +1 -1
- package/src/modules/choices.mjs +34 -0
- package/src/modules/correo.mjs +107 -0
- package/src/modules/index.mjs +48 -4
- package/src/modules/playwright.mjs +5 -1
- package/src/modules/ripley.mjs +13 -1
- package/src/modules/sdk.mjs +44 -9
- package/src/outbound.mjs +37 -3
- package/src/ports.mjs +91 -0
- package/src/request-guard.mjs +26 -0
- package/src/room/archivist.mjs +2 -1
- package/src/room/economy.mjs +83 -1
- package/src/room/prompt.mjs +14 -2
- package/src/room.mjs +150 -18
- package/src/sentinel-errors.mjs +5 -1
- package/src/server.mjs +188 -8
- package/src/smtp.mjs +122 -0
- package/src/vault.mjs +100 -0
- package/src/whats-new.mjs +140 -0
package/src/room.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import { UsageSentinel } from './usage-sentinel.mjs';
|
|
|
10
10
|
import { BLOCK_SOURCES, buildPrompt, promptParts, sparedChars } from './room/prompt.mjs';
|
|
11
11
|
import { turnCost, observedRate } from './room/economy.mjs';
|
|
12
12
|
import { contextFor } from './room/context.mjs';
|
|
13
|
+
import { VERDICTS } from './memory.mjs';
|
|
13
14
|
import { coldNotes, coldReading } from './cold.mjs';
|
|
14
15
|
import { questionsFor } from './asking.mjs';
|
|
15
16
|
import { coverageExam, consistencyExam, matchExam, exchanges, MATCH_SAMPLE, saysFor } from './exam.mjs';
|
|
@@ -28,11 +29,13 @@ import { CODE000_STRIKES } from './mother.mjs';
|
|
|
28
29
|
import { parseDirectives } from './directives.mjs';
|
|
29
30
|
import { isValidModelName } from './models.mjs';
|
|
30
31
|
import { MODES, SCOPES, SCOPE_LABELS, capabilitySummary, normalizeMode, resolveScopes } from './capabilities.mjs';
|
|
31
|
-
import { createLease, diffSnapshots, snapshot } from './lease.mjs';
|
|
32
|
+
import { createLease, diffSnapshots, namedPaths, snapshot } from './lease.mjs';
|
|
32
33
|
import { stat as statFile } from 'node:fs/promises';
|
|
33
34
|
import { basename as baseName, join as joinPath } from 'node:path';
|
|
34
35
|
import { contentTypeFor } from './files.mjs';
|
|
35
36
|
import { isModuleFile, sdkPaths } from './modules/index.mjs';
|
|
37
|
+
import { validateAshReply } from './modules/ash-policy.mjs';
|
|
38
|
+
import { parseChoice, withoutChoice } from './choices.mjs';
|
|
36
39
|
import { imageStudioFor } from './image-studio.mjs';
|
|
37
40
|
import { CAPABILITIES, imageModuleState } from './capabilities.mjs';
|
|
38
41
|
import { resolveReferences } from './files.mjs';
|
|
@@ -64,6 +67,34 @@ const CREATION_VERB = /\b(genera(r|d)?|crea(r|d)?|escrib(e|a|ir)|guarda(r)?|prod
|
|
|
64
67
|
const CREATION_OBJECT = /\b(archivo|fichero|file|pdf|md|markdown|imagen|image|png|jpe?g|svg|documento|document|docx?|csv|json|xlsx?|pptx?|html|script|carpeta|folder|reporte|report|informe|entregable|poster|logo|diagrama|diagram)\b/i;
|
|
65
68
|
export const looksLikeCreation = (text) => CREATION_VERB.test(String(text ?? '')) && CREATION_OBJECT.test(String(text ?? ''));
|
|
66
69
|
|
|
70
|
+
// Which decisions the project is standing on, out of what the archive holds. A refuted one is
|
|
71
|
+
// kept by the archive — what was once decided is part of the record — and left out here, because
|
|
72
|
+
// a strip that says "decided" cannot carry something the room has since established is false.
|
|
73
|
+
export function settledDecisions(notes = [], { limit = 3 } = {}) {
|
|
74
|
+
return notes
|
|
75
|
+
.filter((note) => note && !note.refutedBy)
|
|
76
|
+
.slice(0, limit)
|
|
77
|
+
.map((note) => ({ id: note.id, text: note.text, created: note.created, agent: note.agent, fromSequence: note.fromSequence }));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Open questions the room could put back on the table, newest and most-carried first.
|
|
81
|
+
//
|
|
82
|
+
// `questionsFor` has three wells; this reads only the first. The other two — memories nobody has
|
|
83
|
+
// ever reached for, a kind of note the archive is short of — are about the health of the archive
|
|
84
|
+
// and belong in NOSTROMO beside the map that shows them. What belongs under a composer is the
|
|
85
|
+
// narrower thing: a question this room asked, recorded as open, and never answered.
|
|
86
|
+
//
|
|
87
|
+
// Refuted and dismissed are out. Carried into more turns ranks higher, because a question the
|
|
88
|
+
// room keeps dragging along unanswered is the one most worth asking out loud.
|
|
89
|
+
export function openQuestions(notes = [], { dismissed = [], limit = 3 } = {}) {
|
|
90
|
+
const skip = new Set(dismissed ?? []);
|
|
91
|
+
return notes
|
|
92
|
+
.filter((note) => note && note.kind === 'question' && !note.refutedBy && !skip.has(`open:${note.id}`))
|
|
93
|
+
.sort((a, b) => (Number(b.recalled ?? 0) - Number(a.recalled ?? 0)) || (b.id - a.id))
|
|
94
|
+
.slice(0, limit)
|
|
95
|
+
.map((note) => ({ id: note.id, text: note.text, carried: Number(note.recalled ?? 0) }));
|
|
96
|
+
}
|
|
97
|
+
|
|
67
98
|
export class Room {
|
|
68
99
|
#store;
|
|
69
100
|
#agents;
|
|
@@ -102,6 +133,7 @@ export class Room {
|
|
|
102
133
|
#maxConcurrentTurns;
|
|
103
134
|
#planMaxAgeMs;
|
|
104
135
|
#ashEnabled = false;
|
|
136
|
+
#choicesEnabled = false;
|
|
105
137
|
|
|
106
138
|
constructor({
|
|
107
139
|
store,
|
|
@@ -226,7 +258,7 @@ export class Room {
|
|
|
226
258
|
outDir: lease.relativeDir,
|
|
227
259
|
scratchDir: lease.scratchDir,
|
|
228
260
|
scopes: SCOPES.filter((scope) => enabled[scope]),
|
|
229
|
-
unavailable: SCOPES.filter((scope) => !scopes[scope].capable)
|
|
261
|
+
unavailable: SCOPES.filter((scope) => !scopes[scope].capable),
|
|
230
262
|
planId,
|
|
231
263
|
});
|
|
232
264
|
await this.#emit('mode.granted', { requestId, planId, agent: step.agent, mode, step: index + 1, scope: decision.decision, leaseId: lease.leaseId, message: decision.decision === 'plan' ? `#${mode} granted for the rest of the plan; every writable agent creates inside one lease.` : `#${mode} granted to @${step.agent} for step ${index + 1} only.` });
|
|
@@ -314,6 +346,12 @@ export class Room {
|
|
|
314
346
|
} catch { return null; }
|
|
315
347
|
}
|
|
316
348
|
|
|
349
|
+
// Counts only, for the MEMORY dashboard. See RoomMemory#activity.
|
|
350
|
+
memoryActivity({ days = 30 } = {}) {
|
|
351
|
+
if (!this.#memory) return null;
|
|
352
|
+
try { return this.#memory.activity({ days }); } catch { return null; }
|
|
353
|
+
}
|
|
354
|
+
|
|
317
355
|
/* ---------- MU/TH/UR's channel ---------- */
|
|
318
356
|
|
|
319
357
|
motherStatus() { return this.#mother ? this.#mother.status() : null; }
|
|
@@ -377,6 +415,26 @@ export class Room {
|
|
|
377
415
|
// opened: a turn in flight is a reason to wait, not something to race.
|
|
378
416
|
working() { return this.#turns.size > 0; }
|
|
379
417
|
|
|
418
|
+
// The decisions this project is standing on, newest first.
|
|
419
|
+
//
|
|
420
|
+
// Continuity is the point: a conversation opened a week later, or by another agent, starts
|
|
421
|
+
// knowing what was already settled instead of relitigating it. Read-only, and it costs no
|
|
422
|
+
// prompt — these never enter a briefing from here, they are shown to the human.
|
|
423
|
+
//
|
|
424
|
+
// A refuted decision is left out. The archive keeps it, because what was once decided is part
|
|
425
|
+
// of the record, but showing it in a strip titled "what we decided" would be the room asserting
|
|
426
|
+
// something it has since established is false.
|
|
427
|
+
// The same shape as decisions(): read from the archive, shown to the human, never in a prompt.
|
|
428
|
+
questions({ limit = 3 } = {}) {
|
|
429
|
+
if (!this.#memory) return [];
|
|
430
|
+
return openQuestions(this.#memory.memories({ kind: 'question', limit: limit * 6 }), { dismissed: this.#dismissedAsks(), limit });
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
decisions({ limit = 3 } = {}) {
|
|
434
|
+
if (!this.#memory) return [];
|
|
435
|
+
return settledDecisions(this.#memory.memories({ kind: 'decision', limit: limit * 4 }), { limit });
|
|
436
|
+
}
|
|
437
|
+
|
|
380
438
|
memoryResearch() {
|
|
381
439
|
if (!this.#memory) return null;
|
|
382
440
|
const memories = this.#memory.memories({ limit: 500 });
|
|
@@ -565,11 +623,15 @@ export class Room {
|
|
|
565
623
|
return result;
|
|
566
624
|
}
|
|
567
625
|
|
|
568
|
-
// The human's verdict on a reply: good, bad, or cleared. The dataset listens
|
|
626
|
+
// The human's verdict on a reply: good, bad, or cleared. The dataset listens, and so does the
|
|
627
|
+
// archive: what the room distilled from that reply changes standing, without a word of it being
|
|
628
|
+
// rewritten. The text of a note is what the archivist heard; where it stands is what you said.
|
|
569
629
|
async rateMessage(messageId, rating) {
|
|
570
630
|
if (typeof messageId !== 'string' || !messageId.trim()) throw new Error('Which reply? Give its messageId.');
|
|
571
|
-
if (!
|
|
572
|
-
|
|
631
|
+
if (!VERDICTS.includes(rating)) throw new Error(`A verdict is one of ${VERDICTS.join(', ')}.`);
|
|
632
|
+
const event = await this.#emit('message.rated', { messageId, rating, by: 'you' });
|
|
633
|
+
this.#memory?.judge(messageId, rating);
|
|
634
|
+
return event;
|
|
573
635
|
}
|
|
574
636
|
|
|
575
637
|
// Forgetting is recorded in the ledger like anything else the human does to the room.
|
|
@@ -649,7 +711,7 @@ export class Room {
|
|
|
649
711
|
// Each bill teaches the room what its own words cost, so the next turn can say what it is
|
|
650
712
|
// about to spend before it spends it.
|
|
651
713
|
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 });
|
|
714
|
+
await this.#emit('turn.cost', { agent, mode, responseMessageId, ash: Boolean(cost.blocks.ash), spared: shape.spared, ...cost });
|
|
653
715
|
} catch (error) {
|
|
654
716
|
console.error(`MADRE could not weigh a turn: ${error.message}`);
|
|
655
717
|
}
|
|
@@ -693,6 +755,15 @@ export class Room {
|
|
|
693
755
|
return this.#ashEnabled;
|
|
694
756
|
}
|
|
695
757
|
|
|
758
|
+
setChoices(enabled) {
|
|
759
|
+
this.#choicesEnabled = Boolean(enabled);
|
|
760
|
+
return this.#choicesEnabled;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
choicesEnabled() {
|
|
764
|
+
return this.#choicesEnabled;
|
|
765
|
+
}
|
|
766
|
+
|
|
696
767
|
ashEnabled() {
|
|
697
768
|
return this.#ashEnabled;
|
|
698
769
|
}
|
|
@@ -772,6 +843,10 @@ export class Room {
|
|
|
772
843
|
async send({ text, target, model = null, attachments = [], create = false, ash = false, mode = undefined }) {
|
|
773
844
|
const parsed = parseMessage(text, target);
|
|
774
845
|
const requestedMode = normalizeMode(mode, create === true ? 2 : 1);
|
|
846
|
+
// This is the decision the human made when pressing Send. Capture it before modeCheck() —
|
|
847
|
+
// the first await — so a concurrent module toggle cannot change the turn after that click.
|
|
848
|
+
const ashRequested = ash === true;
|
|
849
|
+
const ashActive = ashRequested && this.#ashEnabled;
|
|
775
850
|
const gate = await this.modeCheck({ target, text, mode: requestedMode });
|
|
776
851
|
if (!gate.ok) throw new Error(gate.error);
|
|
777
852
|
create = requestedMode === 2;
|
|
@@ -785,7 +860,6 @@ export class Room {
|
|
|
785
860
|
// What the human wrote, sent as they wrote it. Ash used to rewrite this line before it left
|
|
786
861
|
// the room; it does not any more, so there is no original to keep beside an abbreviation.
|
|
787
862
|
const text2 = parsed.text || `(${files.length} attached file${files.length === 1 ? '' : 's'})`;
|
|
788
|
-
const ashActive = ash === true && this.#ashEnabled;
|
|
789
863
|
// "!path" tokens point the agent at project files; only existing files count.
|
|
790
864
|
const references = await resolveReferences(this.#projectRoot, text2);
|
|
791
865
|
|
|
@@ -800,7 +874,7 @@ export class Room {
|
|
|
800
874
|
sender: 'you',
|
|
801
875
|
target: parsed.target,
|
|
802
876
|
text: text2,
|
|
803
|
-
ash:
|
|
877
|
+
ash: ashRequested ? { requested: true, active: ashActive } : undefined,
|
|
804
878
|
status: 'sent',
|
|
805
879
|
model: chosenModel,
|
|
806
880
|
attachments: files.length ? files.map((file) => ({ id: file.id, name: file.name, fileName: file.fileName, size: file.size, contentType: file.contentType })) : undefined,
|
|
@@ -818,7 +892,10 @@ export class Room {
|
|
|
818
892
|
if (create === true || standing) {
|
|
819
893
|
const scopes = this.scopesFor(parsed.target);
|
|
820
894
|
const enabled = SCOPES.filter((scope) => scopes[scope].enabled && scopes[scope].wired);
|
|
821
|
-
|
|
895
|
+
// The ids travel; the labels only build the English sentence below. A label is already a
|
|
896
|
+
// word, so the client's translator had nothing to look up and left it as it found it.
|
|
897
|
+
const unavailable = SCOPES.filter((scope) => !scopes[scope].capable);
|
|
898
|
+
const unavailableSaid = unavailable.map((scope) => SCOPE_LABELS[scope]);
|
|
822
899
|
const disabled = SCOPES.filter((scope) => scopes[scope].capable && !scopes[scope].enabled && scopes[scope].wired).map((scope) => SCOPE_LABELS[scope]);
|
|
823
900
|
if (!enabled.includes('write') || scopes.maxMode < 2) {
|
|
824
901
|
const alternatives = this.#agents.filter((agent) => agent.ready && agent.id !== parsed.target && this.scopesFor(agent.id).maxMode >= 2).map((agent) => `@${agent.id}`);
|
|
@@ -829,7 +906,7 @@ export class Room {
|
|
|
829
906
|
unavailable,
|
|
830
907
|
disabled,
|
|
831
908
|
alternatives,
|
|
832
|
-
message: `@${parsed.target} will answer read-only: ${scopes.write.capable ? 'file creation is switched off for it (enable it in CONNECTIONS)' : 'its CLI cannot create files'}${
|
|
909
|
+
message: `@${parsed.target} will answer read-only: ${scopes.write.capable ? 'file creation is switched off for it (enable it in CONNECTIONS)' : 'its CLI cannot create files'}${unavailableSaid.length ? `; it cannot ${unavailableSaid.join(' or ')}` : ''}.${alternatives.length ? ` For creation ask ${alternatives.join(' or ')}.` : ''}`,
|
|
833
910
|
});
|
|
834
911
|
} else {
|
|
835
912
|
lease = await this.#projectLease({ leaseId: randomUUID(), messageId });
|
|
@@ -919,6 +996,27 @@ export class Room {
|
|
|
919
996
|
await this.#emit('room.alert', { code, message, plans: this.activePlans().length, turns: this.#turns.size });
|
|
920
997
|
}
|
|
921
998
|
|
|
999
|
+
// Human brake for ONE answer, which is a different thing from the master brake. STOPALL takes
|
|
1000
|
+
// down every plan and every agent in the room and is meant for when things have run away;
|
|
1001
|
+
// this takes down the one turn you are looking at, because an agent that has hung or started
|
|
1002
|
+
// down the wrong road should not cost the whole room to stop. The agent's process dies with the
|
|
1003
|
+
// abort and the turn records its own failure the way any other failure does.
|
|
1004
|
+
async stopTurn(messageId, reason = t('stopped by the human')) {
|
|
1005
|
+
const turn = this.#turns.get(messageId);
|
|
1006
|
+
if (!turn) return false;
|
|
1007
|
+
// Marked before the abort lands, so the failure it causes carries the mark: the turn ends
|
|
1008
|
+
// like any other failure because the process really was killed, and only this says it was
|
|
1009
|
+
// asked for.
|
|
1010
|
+
turn.stoppedByHuman = true;
|
|
1011
|
+
await this.#emit('turn.stopped', { messageId, agent: turn.agent, planId: turn.planId, reason, by: 'you' });
|
|
1012
|
+
turn.controller.abort(reason);
|
|
1013
|
+
// Waited out, the way the master brake waits: aborting only asks, and the turn still has to
|
|
1014
|
+
// kill its process and record how it ended. Returning before that leaves the caller — and
|
|
1015
|
+
// anything tidying up after it — racing a write that has not happened yet.
|
|
1016
|
+
await turn.promise?.catch(() => {});
|
|
1017
|
+
return true;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
922
1020
|
// Human brake: stops the remaining steps of a running plan and interrupts
|
|
923
1021
|
// the step in flight. Recorded in the log like everything else.
|
|
924
1022
|
async stopPlan(planId, reason = 'stopped by the human') {
|
|
@@ -1012,7 +1110,7 @@ export class Room {
|
|
|
1012
1110
|
// nowhere. Nothing here counts: a memory read for this was not recalled, the window does not
|
|
1013
1111
|
// move, and no process is started. It is the document MADRE writes in the human's name, which
|
|
1014
1112
|
// until now existed only for the instant a CLI was reading it.
|
|
1015
|
-
async briefing({ agent: agentId, mode = 1, text = '' } = {}) {
|
|
1113
|
+
async briefing({ agent: agentId, mode = 1, text = '', ash = false } = {}) {
|
|
1016
1114
|
const agent = this.#agents.find((one) => one.id === agentId && one.detected);
|
|
1017
1115
|
if (!agent) return null;
|
|
1018
1116
|
const priorEvents = await this.#store.readAll();
|
|
@@ -1047,7 +1145,7 @@ export class Room {
|
|
|
1047
1145
|
context: built.context, recall: built.recall, memories: built.memories,
|
|
1048
1146
|
attachments: [], references: [], lease,
|
|
1049
1147
|
scopes: { web: scopes.web.enabled && scopes.web.wired, imageGen: scopes.imageGen.enabled && scopes.imageGen.wired },
|
|
1050
|
-
ash: this.ashEnabled(), mode, escalation: null, mcpServers,
|
|
1148
|
+
ash: ash === true && this.ashEnabled(), mode, escalation: null, mcpServers,
|
|
1051
1149
|
});
|
|
1052
1150
|
// Each block with what put it here and what would take it away, so "read, never written" can
|
|
1053
1151
|
// be checked one block at a time instead of believed.
|
|
@@ -1158,6 +1256,7 @@ export class Room {
|
|
|
1158
1256
|
let lease = null;
|
|
1159
1257
|
let controlRun = null;
|
|
1160
1258
|
let createRun = null;
|
|
1259
|
+
let responseMessageId = null;
|
|
1161
1260
|
if (mode >= 3 && (requester === 'you' || planId) && depth <= 1 && agentScopes.maxMode >= mode && enabled.write) {
|
|
1162
1261
|
// CONTROL: the project itself is the writable root, and a checkpoint
|
|
1163
1262
|
// taken now makes every change of this turn reversible. A plan step gets
|
|
@@ -1189,7 +1288,7 @@ export class Room {
|
|
|
1189
1288
|
outDir: lease.relativeDir,
|
|
1190
1289
|
scratchDir: lease.scratchDir,
|
|
1191
1290
|
scopes: ['write'],
|
|
1192
|
-
unavailable: SCOPES.filter((scope) => !agentScopes[scope].capable)
|
|
1291
|
+
unavailable: SCOPES.filter((scope) => !agentScopes[scope].capable),
|
|
1193
1292
|
planId,
|
|
1194
1293
|
});
|
|
1195
1294
|
}
|
|
@@ -1228,7 +1327,7 @@ export class Room {
|
|
|
1228
1327
|
const invoke = this.#invokers[agent.adapter];
|
|
1229
1328
|
if (!invoke) throw new Error(`${agent.label} does not have a supported MADRE adapter.`);
|
|
1230
1329
|
const before = lease && !lease.control && !lease.create ? await snapshot(lease.outDir) : null;
|
|
1231
|
-
|
|
1330
|
+
responseMessageId = randomUUID();
|
|
1232
1331
|
const notesBefore = this.#memory ? this.#memory.maxMemoryId() : 0;
|
|
1233
1332
|
const others = this.delegatesFor(agent.id);
|
|
1234
1333
|
const mayDelegate = allowDelegation && this.#delegation && depth === 0;
|
|
@@ -1238,7 +1337,7 @@ export class Room {
|
|
|
1238
1337
|
: [];
|
|
1239
1338
|
// The prompt and the shape it was built from: one is sent, the other is kept until the CLI
|
|
1240
1339
|
// says what it cost, so the bill can be attributed to the blocks that caused it.
|
|
1241
|
-
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 });
|
|
1340
|
+
const shaped = this.#promptFor({ agent, text, requester, depth, allowDelegation, context, recall, memories, attachments, references, lease, scopes: turnScopes, imageStudio, ash, choices: this.#choicesEnabled, mode: turnMode, escalation, mcpServers, sharedLeaseHint: sharedLease && !lease ? 'A creation lease is active for this plan, but file creation is not enabled for you: answer without creating files and say so if asked to create one.' : null });
|
|
1242
1341
|
this.#promptShape.set(responseMessageId, { parts: shaped.parts, spared: shaped.spared });
|
|
1243
1342
|
// What this turn is about to read, said while it is still reading it. The size is exact;
|
|
1244
1343
|
// only the conversion to tokens is an estimate, and it is made at this room's own rate.
|
|
@@ -1281,6 +1380,21 @@ export class Room {
|
|
|
1281
1380
|
// CREATE: what appeared stays and is shown; what existed before is put back and said.
|
|
1282
1381
|
const createChanges = createRun ? await this.#controlDesk.settle(createRun) : null;
|
|
1283
1382
|
const artifacts = createChanges ? await this.#artifactsFrom(createChanges.files) : lease && !lease.control ? diffSnapshots(before, await snapshot(lease.outDir), { relativeDir: lease.relativeDir }) : [];
|
|
1383
|
+
// A turn that could write, wrote nothing, and answers naming files. That is the shape of a
|
|
1384
|
+
// write refused without a word — a headless CLI cannot stop to ask, so what the policy does
|
|
1385
|
+
// not allow simply fails and the model reports the file as created. The room does not read
|
|
1386
|
+
// the prose for a claim; it takes the paths the reply names and asks the disk. Only what is
|
|
1387
|
+
// named AND absent is said, so an agent talking about files that exist says nothing here.
|
|
1388
|
+
if (lease && !lease.control && artifacts.length === 0) {
|
|
1389
|
+
const roots = [this.#projectRoot, lease.outDir, lease.realOutDir].filter(Boolean);
|
|
1390
|
+
const named = namedPaths(result.text, { roots });
|
|
1391
|
+
const missing = [];
|
|
1392
|
+
for (const path of named.slice(0, 12)) {
|
|
1393
|
+
const full = path.startsWith('/') ? path : joinPath(this.#projectRoot, path);
|
|
1394
|
+
if (!(await statFile(full).then(() => true, () => false))) missing.push(path);
|
|
1395
|
+
}
|
|
1396
|
+
if (missing.length) await this.#emit('create.missing', { messageId: responseMessageId, agent: agent.id, planId, files: missing });
|
|
1397
|
+
}
|
|
1284
1398
|
// CONTROL: what really changed in the project, forbidden zones reverted on the spot.
|
|
1285
1399
|
const controlChanges = controlRun ? await this.#controlDesk.settle(controlRun) : null;
|
|
1286
1400
|
const directives = mayDelegate
|
|
@@ -1294,13 +1408,15 @@ export class Room {
|
|
|
1294
1408
|
if (depth > 0 && /```pulse/i.test(result.text)) {
|
|
1295
1409
|
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}`);
|
|
1296
1410
|
}
|
|
1411
|
+
// A question the agent put to the human: it leaves the text and comes back as buttons.
|
|
1412
|
+
const asked = this.#choicesEnabled && turnMode !== 0 ? parseChoice(result.text) : null;
|
|
1297
1413
|
await this.#emit('message.created', {
|
|
1298
1414
|
messageId: responseMessageId,
|
|
1299
1415
|
parentMessageId: messageId,
|
|
1300
1416
|
role: 'assistant',
|
|
1301
1417
|
sender: agent.id,
|
|
1302
1418
|
target: requester,
|
|
1303
|
-
text: result.text,
|
|
1419
|
+
text: asked ? withoutChoice(result.text) : result.text,
|
|
1304
1420
|
// What the archive handed this turn, so the room can see its own memory working. The
|
|
1305
1421
|
// whole point of remembering is invisible until the moment it is used.
|
|
1306
1422
|
recalled: memories?.length
|
|
@@ -1317,6 +1433,11 @@ export class Room {
|
|
|
1317
1433
|
artifacts: artifacts.length ? artifacts : undefined,
|
|
1318
1434
|
delegates: directives.steps.length ? directives.steps.map((step) => step.agent) : undefined,
|
|
1319
1435
|
});
|
|
1436
|
+
if (ash && turnMode !== 0) {
|
|
1437
|
+
const validation = validateAshReply({ request: text, response: result.text });
|
|
1438
|
+
await this.#emit('ash.validated', { messageId, responseMessageId, agent: agent.id, ...validation });
|
|
1439
|
+
}
|
|
1440
|
+
if (asked) await this.#emit('choice.offered', { messageId, responseMessageId, agent: agent.id, question: asked.question, options: asked.options });
|
|
1320
1441
|
if (guarded.hits) await this.#emit('privacy.redacted', { agent: agent.id, messageId, responseMessageId, hits: guarded.hits, marker: this.#privacy.marker });
|
|
1321
1442
|
if (artifacts.length) {
|
|
1322
1443
|
await this.#emit('artifacts.created', { leaseId: lease.leaseId, messageId, responseMessageId, agent: agent.id, outDir: lease.relativeDir, files: artifacts });
|
|
@@ -1342,7 +1463,15 @@ export class Room {
|
|
|
1342
1463
|
await this.#emit('agent.completed', { messageId, agent: agent.id, handoffId, planId });
|
|
1343
1464
|
return { responseMessageId, directives };
|
|
1344
1465
|
} catch (error) {
|
|
1345
|
-
|
|
1466
|
+
if (responseMessageId && this.#promptShape.has(responseMessageId)) {
|
|
1467
|
+
const shape = this.#promptShape.get(responseMessageId);
|
|
1468
|
+
this.#promptShape.delete(responseMessageId);
|
|
1469
|
+
if (turnMode !== 0) {
|
|
1470
|
+
const cost = turnCost(shape.parts, null);
|
|
1471
|
+
await this.#emit('turn.cost', { agent: agent.id, mode: turnMode, responseMessageId, ash: Boolean(cost.blocks.ash), failed: true, spared: shape.spared, ...cost });
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
await this.#emit('message.failed', { messageId, target: agent.id, planId, error: this.#privacy ? this.#privacy.redact(failureMessage(error)).text : failureMessage(error), ...(this.#turns.get(messageId)?.stoppedByHuman ? { stopped: true } : {}) });
|
|
1346
1475
|
return null;
|
|
1347
1476
|
} finally {
|
|
1348
1477
|
// Release CONTROL whichever way the turn ended; the checkpoint stays for UNDO.
|
|
@@ -1421,18 +1550,21 @@ export class Room {
|
|
|
1421
1550
|
if (!plan.stopped && directives.closing) {
|
|
1422
1551
|
plan.step = directives.steps.length + 1;
|
|
1423
1552
|
const messageId = randomUUID();
|
|
1553
|
+
const closingMode = ceiling >= 3 ? ceiling : lease ? 2 : 1;
|
|
1424
1554
|
await this.#emit('message.created', {
|
|
1425
1555
|
messageId,
|
|
1426
1556
|
role: 'assistant',
|
|
1427
1557
|
sender: orchestrator,
|
|
1428
1558
|
target: orchestrator,
|
|
1429
1559
|
text: directives.closing,
|
|
1560
|
+
ash: ash ? { active: true } : undefined,
|
|
1561
|
+
mode: closingMode,
|
|
1430
1562
|
status: 'delegated',
|
|
1431
1563
|
planId,
|
|
1432
1564
|
step: plan.step,
|
|
1433
1565
|
totalSteps: plan.step,
|
|
1434
1566
|
});
|
|
1435
|
-
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 });
|
|
1567
|
+
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, mode: closingMode });
|
|
1436
1568
|
}
|
|
1437
1569
|
} finally {
|
|
1438
1570
|
this.#plans.delete(planId);
|
package/src/sentinel-errors.mjs
CHANGED
|
@@ -100,8 +100,12 @@ export class ErrorSentinel {
|
|
|
100
100
|
// A room event goes by: a failure MU/TH/UR cannot classify is worth a report.
|
|
101
101
|
async observe(event) {
|
|
102
102
|
if (event?.type !== 'message.failed') return null;
|
|
103
|
-
const { target, error } = event.payload ?? {};
|
|
103
|
+
const { target, error, stopped } = event.payload ?? {};
|
|
104
104
|
if (typeof error !== 'string' || !error.trim()) return null;
|
|
105
|
+
// A turn the human stopped is not a condition. It ends exactly like a failure because the
|
|
106
|
+
// process really was killed, but nothing went wrong and there is nothing for anyone to
|
|
107
|
+
// diagnose — filing it would be reporting the brake as a fault.
|
|
108
|
+
if (stopped) return null;
|
|
105
109
|
const known = diagnose(error, target ?? null);
|
|
106
110
|
if (known.length) return null;
|
|
107
111
|
return this.record({ kind: 'unknown', agent: target ?? null, error, at: event.timestamp });
|