@jossuealcala/madre 0.5.1 → 0.6.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 +95 -0
- package/package.json +1 -1
- package/public/abduction.js +384 -0
- package/public/app.js +1174 -78
- 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 +182 -16
- package/public/index.html +48 -3
- package/public/routing.js +74 -0
- package/public/styles.css +390 -22
- package/src/choices.mjs +64 -0
- package/src/file-ops.mjs +45 -0
- 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/index.mjs +2 -1
- package/src/modules/playwright.mjs +5 -1
- package/src/modules/ripley.mjs +13 -1
- package/src/ports.mjs +71 -0
- package/src/room/economy.mjs +83 -1
- package/src/room/prompt.mjs +13 -1
- package/src/room.mjs +91 -9
- package/src/server.mjs +127 -7
package/src/room.mjs
CHANGED
|
@@ -33,6 +33,8 @@ import { stat as statFile } from 'node:fs/promises';
|
|
|
33
33
|
import { basename as baseName, join as joinPath } from 'node:path';
|
|
34
34
|
import { contentTypeFor } from './files.mjs';
|
|
35
35
|
import { isModuleFile, sdkPaths } from './modules/index.mjs';
|
|
36
|
+
import { validateAshReply } from './modules/ash-policy.mjs';
|
|
37
|
+
import { parseChoice, withoutChoice } from './choices.mjs';
|
|
36
38
|
import { imageStudioFor } from './image-studio.mjs';
|
|
37
39
|
import { CAPABILITIES, imageModuleState } from './capabilities.mjs';
|
|
38
40
|
import { resolveReferences } from './files.mjs';
|
|
@@ -64,6 +66,34 @@ const CREATION_VERB = /\b(genera(r|d)?|crea(r|d)?|escrib(e|a|ir)|guarda(r)?|prod
|
|
|
64
66
|
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
67
|
export const looksLikeCreation = (text) => CREATION_VERB.test(String(text ?? '')) && CREATION_OBJECT.test(String(text ?? ''));
|
|
66
68
|
|
|
69
|
+
// Which decisions the project is standing on, out of what the archive holds. A refuted one is
|
|
70
|
+
// kept by the archive — what was once decided is part of the record — and left out here, because
|
|
71
|
+
// a strip that says "decided" cannot carry something the room has since established is false.
|
|
72
|
+
export function settledDecisions(notes = [], { limit = 3 } = {}) {
|
|
73
|
+
return notes
|
|
74
|
+
.filter((note) => note && !note.refutedBy)
|
|
75
|
+
.slice(0, limit)
|
|
76
|
+
.map((note) => ({ id: note.id, text: note.text, created: note.created, agent: note.agent, fromSequence: note.fromSequence }));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Open questions the room could put back on the table, newest and most-carried first.
|
|
80
|
+
//
|
|
81
|
+
// `questionsFor` has three wells; this reads only the first. The other two — memories nobody has
|
|
82
|
+
// ever reached for, a kind of note the archive is short of — are about the health of the archive
|
|
83
|
+
// and belong in NOSTROMO beside the map that shows them. What belongs under a composer is the
|
|
84
|
+
// narrower thing: a question this room asked, recorded as open, and never answered.
|
|
85
|
+
//
|
|
86
|
+
// Refuted and dismissed are out. Carried into more turns ranks higher, because a question the
|
|
87
|
+
// room keeps dragging along unanswered is the one most worth asking out loud.
|
|
88
|
+
export function openQuestions(notes = [], { dismissed = [], limit = 3 } = {}) {
|
|
89
|
+
const skip = new Set(dismissed ?? []);
|
|
90
|
+
return notes
|
|
91
|
+
.filter((note) => note && note.kind === 'question' && !note.refutedBy && !skip.has(`open:${note.id}`))
|
|
92
|
+
.sort((a, b) => (Number(b.recalled ?? 0) - Number(a.recalled ?? 0)) || (b.id - a.id))
|
|
93
|
+
.slice(0, limit)
|
|
94
|
+
.map((note) => ({ id: note.id, text: note.text, carried: Number(note.recalled ?? 0) }));
|
|
95
|
+
}
|
|
96
|
+
|
|
67
97
|
export class Room {
|
|
68
98
|
#store;
|
|
69
99
|
#agents;
|
|
@@ -102,6 +132,7 @@ export class Room {
|
|
|
102
132
|
#maxConcurrentTurns;
|
|
103
133
|
#planMaxAgeMs;
|
|
104
134
|
#ashEnabled = false;
|
|
135
|
+
#choicesEnabled = false;
|
|
105
136
|
|
|
106
137
|
constructor({
|
|
107
138
|
store,
|
|
@@ -377,6 +408,26 @@ export class Room {
|
|
|
377
408
|
// opened: a turn in flight is a reason to wait, not something to race.
|
|
378
409
|
working() { return this.#turns.size > 0; }
|
|
379
410
|
|
|
411
|
+
// The decisions this project is standing on, newest first.
|
|
412
|
+
//
|
|
413
|
+
// Continuity is the point: a conversation opened a week later, or by another agent, starts
|
|
414
|
+
// knowing what was already settled instead of relitigating it. Read-only, and it costs no
|
|
415
|
+
// prompt — these never enter a briefing from here, they are shown to the human.
|
|
416
|
+
//
|
|
417
|
+
// A refuted decision is left out. The archive keeps it, because what was once decided is part
|
|
418
|
+
// of the record, but showing it in a strip titled "what we decided" would be the room asserting
|
|
419
|
+
// something it has since established is false.
|
|
420
|
+
// The same shape as decisions(): read from the archive, shown to the human, never in a prompt.
|
|
421
|
+
questions({ limit = 3 } = {}) {
|
|
422
|
+
if (!this.#memory) return [];
|
|
423
|
+
return openQuestions(this.#memory.memories({ kind: 'question', limit: limit * 6 }), { dismissed: this.#dismissedAsks(), limit });
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
decisions({ limit = 3 } = {}) {
|
|
427
|
+
if (!this.#memory) return [];
|
|
428
|
+
return settledDecisions(this.#memory.memories({ kind: 'decision', limit: limit * 4 }), { limit });
|
|
429
|
+
}
|
|
430
|
+
|
|
380
431
|
memoryResearch() {
|
|
381
432
|
if (!this.#memory) return null;
|
|
382
433
|
const memories = this.#memory.memories({ limit: 500 });
|
|
@@ -649,7 +700,7 @@ export class Room {
|
|
|
649
700
|
// Each bill teaches the room what its own words cost, so the next turn can say what it is
|
|
650
701
|
// about to spend before it spends it.
|
|
651
702
|
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 });
|
|
703
|
+
await this.#emit('turn.cost', { agent, mode, responseMessageId, ash: Boolean(cost.blocks.ash), spared: shape.spared, ...cost });
|
|
653
704
|
} catch (error) {
|
|
654
705
|
console.error(`MADRE could not weigh a turn: ${error.message}`);
|
|
655
706
|
}
|
|
@@ -693,6 +744,15 @@ export class Room {
|
|
|
693
744
|
return this.#ashEnabled;
|
|
694
745
|
}
|
|
695
746
|
|
|
747
|
+
setChoices(enabled) {
|
|
748
|
+
this.#choicesEnabled = Boolean(enabled);
|
|
749
|
+
return this.#choicesEnabled;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
choicesEnabled() {
|
|
753
|
+
return this.#choicesEnabled;
|
|
754
|
+
}
|
|
755
|
+
|
|
696
756
|
ashEnabled() {
|
|
697
757
|
return this.#ashEnabled;
|
|
698
758
|
}
|
|
@@ -772,6 +832,10 @@ export class Room {
|
|
|
772
832
|
async send({ text, target, model = null, attachments = [], create = false, ash = false, mode = undefined }) {
|
|
773
833
|
const parsed = parseMessage(text, target);
|
|
774
834
|
const requestedMode = normalizeMode(mode, create === true ? 2 : 1);
|
|
835
|
+
// This is the decision the human made when pressing Send. Capture it before modeCheck() —
|
|
836
|
+
// the first await — so a concurrent module toggle cannot change the turn after that click.
|
|
837
|
+
const ashRequested = ash === true;
|
|
838
|
+
const ashActive = ashRequested && this.#ashEnabled;
|
|
775
839
|
const gate = await this.modeCheck({ target, text, mode: requestedMode });
|
|
776
840
|
if (!gate.ok) throw new Error(gate.error);
|
|
777
841
|
create = requestedMode === 2;
|
|
@@ -785,7 +849,6 @@ export class Room {
|
|
|
785
849
|
// What the human wrote, sent as they wrote it. Ash used to rewrite this line before it left
|
|
786
850
|
// the room; it does not any more, so there is no original to keep beside an abbreviation.
|
|
787
851
|
const text2 = parsed.text || `(${files.length} attached file${files.length === 1 ? '' : 's'})`;
|
|
788
|
-
const ashActive = ash === true && this.#ashEnabled;
|
|
789
852
|
// "!path" tokens point the agent at project files; only existing files count.
|
|
790
853
|
const references = await resolveReferences(this.#projectRoot, text2);
|
|
791
854
|
|
|
@@ -800,7 +863,7 @@ export class Room {
|
|
|
800
863
|
sender: 'you',
|
|
801
864
|
target: parsed.target,
|
|
802
865
|
text: text2,
|
|
803
|
-
ash:
|
|
866
|
+
ash: ashRequested ? { requested: true, active: ashActive } : undefined,
|
|
804
867
|
status: 'sent',
|
|
805
868
|
model: chosenModel,
|
|
806
869
|
attachments: files.length ? files.map((file) => ({ id: file.id, name: file.name, fileName: file.fileName, size: file.size, contentType: file.contentType })) : undefined,
|
|
@@ -1012,7 +1075,7 @@ export class Room {
|
|
|
1012
1075
|
// nowhere. Nothing here counts: a memory read for this was not recalled, the window does not
|
|
1013
1076
|
// move, and no process is started. It is the document MADRE writes in the human's name, which
|
|
1014
1077
|
// until now existed only for the instant a CLI was reading it.
|
|
1015
|
-
async briefing({ agent: agentId, mode = 1, text = '' } = {}) {
|
|
1078
|
+
async briefing({ agent: agentId, mode = 1, text = '', ash = false } = {}) {
|
|
1016
1079
|
const agent = this.#agents.find((one) => one.id === agentId && one.detected);
|
|
1017
1080
|
if (!agent) return null;
|
|
1018
1081
|
const priorEvents = await this.#store.readAll();
|
|
@@ -1047,7 +1110,7 @@ export class Room {
|
|
|
1047
1110
|
context: built.context, recall: built.recall, memories: built.memories,
|
|
1048
1111
|
attachments: [], references: [], lease,
|
|
1049
1112
|
scopes: { web: scopes.web.enabled && scopes.web.wired, imageGen: scopes.imageGen.enabled && scopes.imageGen.wired },
|
|
1050
|
-
ash: this.ashEnabled(), mode, escalation: null, mcpServers,
|
|
1113
|
+
ash: ash === true && this.ashEnabled(), mode, escalation: null, mcpServers,
|
|
1051
1114
|
});
|
|
1052
1115
|
// Each block with what put it here and what would take it away, so "read, never written" can
|
|
1053
1116
|
// be checked one block at a time instead of believed.
|
|
@@ -1158,6 +1221,7 @@ export class Room {
|
|
|
1158
1221
|
let lease = null;
|
|
1159
1222
|
let controlRun = null;
|
|
1160
1223
|
let createRun = null;
|
|
1224
|
+
let responseMessageId = null;
|
|
1161
1225
|
if (mode >= 3 && (requester === 'you' || planId) && depth <= 1 && agentScopes.maxMode >= mode && enabled.write) {
|
|
1162
1226
|
// CONTROL: the project itself is the writable root, and a checkpoint
|
|
1163
1227
|
// taken now makes every change of this turn reversible. A plan step gets
|
|
@@ -1228,7 +1292,7 @@ export class Room {
|
|
|
1228
1292
|
const invoke = this.#invokers[agent.adapter];
|
|
1229
1293
|
if (!invoke) throw new Error(`${agent.label} does not have a supported MADRE adapter.`);
|
|
1230
1294
|
const before = lease && !lease.control && !lease.create ? await snapshot(lease.outDir) : null;
|
|
1231
|
-
|
|
1295
|
+
responseMessageId = randomUUID();
|
|
1232
1296
|
const notesBefore = this.#memory ? this.#memory.maxMemoryId() : 0;
|
|
1233
1297
|
const others = this.delegatesFor(agent.id);
|
|
1234
1298
|
const mayDelegate = allowDelegation && this.#delegation && depth === 0;
|
|
@@ -1238,7 +1302,7 @@ export class Room {
|
|
|
1238
1302
|
: [];
|
|
1239
1303
|
// The prompt and the shape it was built from: one is sent, the other is kept until the CLI
|
|
1240
1304
|
// 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 });
|
|
1305
|
+
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
1306
|
this.#promptShape.set(responseMessageId, { parts: shaped.parts, spared: shaped.spared });
|
|
1243
1307
|
// What this turn is about to read, said while it is still reading it. The size is exact;
|
|
1244
1308
|
// only the conversion to tokens is an estimate, and it is made at this room's own rate.
|
|
@@ -1294,13 +1358,15 @@ export class Room {
|
|
|
1294
1358
|
if (depth > 0 && /```pulse/i.test(result.text)) {
|
|
1295
1359
|
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
1360
|
}
|
|
1361
|
+
// A question the agent put to the human: it leaves the text and comes back as buttons.
|
|
1362
|
+
const asked = this.#choicesEnabled && turnMode !== 0 ? parseChoice(result.text) : null;
|
|
1297
1363
|
await this.#emit('message.created', {
|
|
1298
1364
|
messageId: responseMessageId,
|
|
1299
1365
|
parentMessageId: messageId,
|
|
1300
1366
|
role: 'assistant',
|
|
1301
1367
|
sender: agent.id,
|
|
1302
1368
|
target: requester,
|
|
1303
|
-
text: result.text,
|
|
1369
|
+
text: asked ? withoutChoice(result.text) : result.text,
|
|
1304
1370
|
// What the archive handed this turn, so the room can see its own memory working. The
|
|
1305
1371
|
// whole point of remembering is invisible until the moment it is used.
|
|
1306
1372
|
recalled: memories?.length
|
|
@@ -1317,6 +1383,11 @@ export class Room {
|
|
|
1317
1383
|
artifacts: artifacts.length ? artifacts : undefined,
|
|
1318
1384
|
delegates: directives.steps.length ? directives.steps.map((step) => step.agent) : undefined,
|
|
1319
1385
|
});
|
|
1386
|
+
if (ash && turnMode !== 0) {
|
|
1387
|
+
const validation = validateAshReply({ request: text, response: result.text });
|
|
1388
|
+
await this.#emit('ash.validated', { messageId, responseMessageId, agent: agent.id, ...validation });
|
|
1389
|
+
}
|
|
1390
|
+
if (asked) await this.#emit('choice.offered', { messageId, responseMessageId, agent: agent.id, question: asked.question, options: asked.options });
|
|
1320
1391
|
if (guarded.hits) await this.#emit('privacy.redacted', { agent: agent.id, messageId, responseMessageId, hits: guarded.hits, marker: this.#privacy.marker });
|
|
1321
1392
|
if (artifacts.length) {
|
|
1322
1393
|
await this.#emit('artifacts.created', { leaseId: lease.leaseId, messageId, responseMessageId, agent: agent.id, outDir: lease.relativeDir, files: artifacts });
|
|
@@ -1342,6 +1413,14 @@ export class Room {
|
|
|
1342
1413
|
await this.#emit('agent.completed', { messageId, agent: agent.id, handoffId, planId });
|
|
1343
1414
|
return { responseMessageId, directives };
|
|
1344
1415
|
} catch (error) {
|
|
1416
|
+
if (responseMessageId && this.#promptShape.has(responseMessageId)) {
|
|
1417
|
+
const shape = this.#promptShape.get(responseMessageId);
|
|
1418
|
+
this.#promptShape.delete(responseMessageId);
|
|
1419
|
+
if (turnMode !== 0) {
|
|
1420
|
+
const cost = turnCost(shape.parts, null);
|
|
1421
|
+
await this.#emit('turn.cost', { agent: agent.id, mode: turnMode, responseMessageId, ash: Boolean(cost.blocks.ash), failed: true, spared: shape.spared, ...cost });
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1345
1424
|
await this.#emit('message.failed', { messageId, target: agent.id, planId, error: this.#privacy ? this.#privacy.redact(failureMessage(error)).text : failureMessage(error) });
|
|
1346
1425
|
return null;
|
|
1347
1426
|
} finally {
|
|
@@ -1421,18 +1500,21 @@ export class Room {
|
|
|
1421
1500
|
if (!plan.stopped && directives.closing) {
|
|
1422
1501
|
plan.step = directives.steps.length + 1;
|
|
1423
1502
|
const messageId = randomUUID();
|
|
1503
|
+
const closingMode = ceiling >= 3 ? ceiling : lease ? 2 : 1;
|
|
1424
1504
|
await this.#emit('message.created', {
|
|
1425
1505
|
messageId,
|
|
1426
1506
|
role: 'assistant',
|
|
1427
1507
|
sender: orchestrator,
|
|
1428
1508
|
target: orchestrator,
|
|
1429
1509
|
text: directives.closing,
|
|
1510
|
+
ash: ash ? { active: true } : undefined,
|
|
1511
|
+
mode: closingMode,
|
|
1430
1512
|
status: 'delegated',
|
|
1431
1513
|
planId,
|
|
1432
1514
|
step: plan.step,
|
|
1433
1515
|
totalSteps: plan.step,
|
|
1434
1516
|
});
|
|
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 });
|
|
1517
|
+
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
1518
|
}
|
|
1437
1519
|
} finally {
|
|
1438
1520
|
this.#plans.delete(planId);
|
package/src/server.mjs
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import http from 'node:http';
|
|
2
|
-
import { readFile, realpath, writeFile, mkdir, rm } from 'node:fs/promises';
|
|
2
|
+
import { readFile, realpath, writeFile, mkdir, rm, cp, rename, access } from 'node:fs/promises';
|
|
3
3
|
import { rmSync } from 'node:fs';
|
|
4
4
|
import { createHash } from 'node:crypto';
|
|
5
|
+
import { parseListening, probePort, framable } from './ports.mjs';
|
|
5
6
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
6
7
|
import { fileURLToPath } from 'node:url';
|
|
7
8
|
import { homedir } from 'node:os';
|
|
8
9
|
import { execFile } from 'node:child_process';
|
|
10
|
+
import { promisify } from 'node:util';
|
|
11
|
+
|
|
12
|
+
const execFileAsync = promisify(execFile);
|
|
9
13
|
import { detectAgents, toolsPrefix } from './runtime-detection.mjs';
|
|
10
14
|
import { EventStore } from './event-store.mjs';
|
|
11
15
|
import { RoomMemory } from './memory.mjs';
|
|
@@ -58,7 +62,8 @@ import { discoverModels } from './models.mjs';
|
|
|
58
62
|
import { setImageModule } from './capabilities.mjs';
|
|
59
63
|
import { resolveGeminiKey } from './image-studio.mjs';
|
|
60
64
|
import { commandByName, listCommands, parseCommand } from './commands.mjs';
|
|
61
|
-
import { listDirectory, searchFiles, readServable, storeAttachment, MAX_ATTACHMENT_BYTES } from './files.mjs';
|
|
65
|
+
import { listDirectory, searchFiles, readServable, storeAttachment, resolveInside, MAX_ATTACHMENT_BYTES } from './files.mjs';
|
|
66
|
+
import { planFileOp, CREATIONS } from './file-ops.mjs';
|
|
62
67
|
import { Eyecat } from './eyecat-watch.mjs';
|
|
63
68
|
import { economy } from './room/economy.mjs';
|
|
64
69
|
import { maturity } from './maturity.mjs';
|
|
@@ -546,8 +551,8 @@ export async function createPulseServer({
|
|
|
546
551
|
const startupConfig = await readConfig(root);
|
|
547
552
|
room.setScopes(startupConfig.scopes ?? {});
|
|
548
553
|
setImageModule(startupConfig.modules?.imageStudio ?? {});
|
|
549
|
-
|
|
550
|
-
room.
|
|
554
|
+
room.setAsh(Boolean(startupConfig.modules?.ash?.enabled));
|
|
555
|
+
room.setChoices(Boolean(startupConfig.modules?.choices?.enabled));
|
|
551
556
|
const recoveredTurns = await room.reconcile();
|
|
552
557
|
if (recoveredTurns) console.error(`MADRE recovered ${recoveredTurns} unfinished turn(s) from a previous run.`);
|
|
553
558
|
const quotaMonitor = new QuotaMonitor({
|
|
@@ -843,13 +848,17 @@ export async function createPulseServer({
|
|
|
843
848
|
|
|
844
849
|
const server = http.createServer(async (request, response) => {
|
|
845
850
|
const url = new URL(request.url, 'http://127.0.0.1');
|
|
851
|
+
// The project's own folder name, typed by the human. Declared at the top of the handler and
|
|
852
|
+
// not beside the first route that happened to need it: a const sits in its temporal dead zone
|
|
853
|
+
// until its own line runs, so any route above it threw instead of refusing.
|
|
854
|
+
const designationOk = (given) => typeof given === 'string' && given.trim().toLowerCase() === basename(canonicalProjectRoot).toLowerCase();
|
|
846
855
|
try {
|
|
847
856
|
if (request.method === 'GET' && url.pathname === '/') {
|
|
848
857
|
const html = await readFile(join(publicDirectory, 'index.html'));
|
|
849
858
|
response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
|
850
859
|
return response.end(html);
|
|
851
860
|
}
|
|
852
|
-
if (request.method === 'GET' && ['/app.js', '/brands.js', '/troubleshooting.js', '/inquiry.js', '/i18n.js', '/es.js', '/resay.js', '/syntax.js'].includes(url.pathname)) {
|
|
861
|
+
if (request.method === 'GET' && ['/app.js', '/brands.js', '/troubleshooting.js', '/inquiry.js', '/i18n.js', '/es.js', '/resay.js', '/syntax.js', '/routing.js', '/abduction.js'].includes(url.pathname)) {
|
|
853
862
|
const js = await readFile(join(publicDirectory, url.pathname.slice(1)));
|
|
854
863
|
response.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8' });
|
|
855
864
|
return response.end(js);
|
|
@@ -859,10 +868,28 @@ export async function createPulseServer({
|
|
|
859
868
|
response.writeHead(200, { 'content-type': 'text/css; charset=utf-8' });
|
|
860
869
|
return response.end(css);
|
|
861
870
|
}
|
|
871
|
+
const abductionAssets = new Map([
|
|
872
|
+
['/assets/abduction-cat.png', 'abduction-cat.png'],
|
|
873
|
+
['/assets/abduction-cat-blink.png', 'abduction-cat-blink.png'],
|
|
874
|
+
['/assets/abduction-cat-control-left.png', 'abduction-cat-control-left.png'],
|
|
875
|
+
['/assets/abduction-cat-control-right.png', 'abduction-cat-control-right.png'],
|
|
876
|
+
]);
|
|
877
|
+
if (request.method === 'GET' && abductionAssets.has(url.pathname)) {
|
|
878
|
+
const image = await readFile(join(publicDirectory, 'assets', abductionAssets.get(url.pathname)));
|
|
879
|
+
response.writeHead(200, {
|
|
880
|
+
'content-type': 'image/png',
|
|
881
|
+
'content-length': image.length,
|
|
882
|
+
'cache-control': 'public, max-age=86400',
|
|
883
|
+
});
|
|
884
|
+
return response.end(image);
|
|
885
|
+
}
|
|
862
886
|
if (request.method === 'GET' && url.pathname === '/api/state') {
|
|
863
887
|
return sendJson(response, 200, {
|
|
864
888
|
projectRoot,
|
|
865
889
|
platform: process.platform,
|
|
890
|
+
// Which MADRE this is. The page prints it in the corner: the one fact a bug report
|
|
891
|
+
// always needs and nobody ever remembers to ask for.
|
|
892
|
+
version: PACKAGE.version,
|
|
866
893
|
// What language this machine set MADRE to, for a browser that has never chosen. It is
|
|
867
894
|
// the language the room is SPEAKING, not the one written in the file: PULSE_LANGUAGE
|
|
868
895
|
// wins over the config here as it does everywhere else, and reading the file again
|
|
@@ -872,6 +899,12 @@ export async function createPulseServer({
|
|
|
872
899
|
chats: await listChats(roomDir),
|
|
873
900
|
agents: agents.map((agent) => ({ ...agent, login: loginPlanFor(agent), install: installPlanFor(agent), key: keyPlanFor(agent.id), ...(accountNoteFor(agent.id) ?? {}) })),
|
|
874
901
|
ash: { enabled: room.ashEnabled() },
|
|
902
|
+
choices: { enabled: room.choicesEnabled() },
|
|
903
|
+
// Read live, not from the config this process loaded at boot. A switch the human moves
|
|
904
|
+
// in MODULES while the room is open has to be the answer the room gives afterwards —
|
|
905
|
+
// otherwise the switch says on and the browser keeps refusing, which is how a setting
|
|
906
|
+
// stops being believed.
|
|
907
|
+
ripleyWeb: Boolean((await readConfig(root)).modules?.ripley?.web),
|
|
875
908
|
ripley: { enabled: await ripleyOn() },
|
|
876
909
|
softTokenBudget,
|
|
877
910
|
timeouts: Object.fromEntries(agents.map((agent) => [agent.id, room.timeoutFor(agent.id)])),
|
|
@@ -912,6 +945,37 @@ export async function createPulseServer({
|
|
|
912
945
|
void broadcastPending();
|
|
913
946
|
return;
|
|
914
947
|
}
|
|
948
|
+
// Copying and moving, by the human's own hand. Both ends are resolved inside the project
|
|
949
|
+
// after following symlinks, so no path typed or pasted can reach outside it; the zones the
|
|
950
|
+
// room guards from agents ask for the project's designation first.
|
|
951
|
+
if (request.method === 'POST' && url.pathname === '/api/tree/op') {
|
|
952
|
+
const payload = await body(request).catch(() => ({}));
|
|
953
|
+
const plan = planFileOp(payload);
|
|
954
|
+
if (!plan.ok) return sendJson(response, 400, { error: plan.error });
|
|
955
|
+
if (plan.guarded && !designationOk(payload.designation)) {
|
|
956
|
+
return sendJson(response, 403, { error: t('UNABLE TO COMPUTE. UNABLE TO CLARIFY.'), guarded: true });
|
|
957
|
+
}
|
|
958
|
+
const source = plan.from ? await resolveInside(canonicalProjectRoot, plan.from) : null;
|
|
959
|
+
if (plan.from && !source) return sendJson(response, 404, { error: t('Not found.') });
|
|
960
|
+
// The destination does not exist yet, so its PARENT is what has to be inside the project.
|
|
961
|
+
const targetDir = plan.to.includes('/') ? plan.to.slice(0, plan.to.lastIndexOf('/')) : '.';
|
|
962
|
+
const parent = await resolveInside(canonicalProjectRoot, targetDir);
|
|
963
|
+
if (!parent) return sendJson(response, 400, { error: t('The destination is outside this project.') });
|
|
964
|
+
const destination = join(parent, plan.to.slice(plan.to.lastIndexOf('/') + 1));
|
|
965
|
+
if (await access(destination).then(() => true, () => false)) {
|
|
966
|
+
return sendJson(response, 409, { error: t('There is already something at {path}.', { path: plan.to }) });
|
|
967
|
+
}
|
|
968
|
+
try {
|
|
969
|
+
if (plan.operation === 'new-folder') await mkdir(destination);
|
|
970
|
+
else if (plan.operation === 'new-file') await writeFile(destination, '', { flag: 'wx' });
|
|
971
|
+
else if (plan.operation === 'copy') await cp(source, destination, { recursive: true, errorOnExist: true, force: false });
|
|
972
|
+
else await rename(source, destination);
|
|
973
|
+
} catch (error) {
|
|
974
|
+
return sendJson(response, 500, { error: error.message });
|
|
975
|
+
}
|
|
976
|
+
await room.record(CREATIONS.includes(plan.operation) ? 'project.file.created' : 'project.file.moved', { operation: plan.operation, from: plan.from, to: plan.to, guarded: plan.guarded });
|
|
977
|
+
return sendJson(response, 200, { ok: true, operation: plan.operation, from: plan.from, to: plan.to });
|
|
978
|
+
}
|
|
915
979
|
if (request.method === 'GET' && url.pathname === '/api/tree/search') {
|
|
916
980
|
const matches = await searchFiles(canonicalProjectRoot, url.searchParams.get('q') ?? '', { limit: Math.min(Number(url.searchParams.get('limit')) || 20, 50) });
|
|
917
981
|
return sendJson(response, 200, { matches });
|
|
@@ -983,7 +1047,6 @@ export async function createPulseServer({
|
|
|
983
1047
|
return sendJson(response, 200, { settings: await applySettings(await body(request)) });
|
|
984
1048
|
}
|
|
985
1049
|
// NOSTROMO: the archive is behind the project designation, like CONTROL.
|
|
986
|
-
const designationOk = (given) => typeof given === 'string' && given.trim().toLowerCase() === basename(canonicalProjectRoot).toLowerCase();
|
|
987
1050
|
// Sentinel: reports, settings, the manual road (a prefilled issue) and the automatic one.
|
|
988
1051
|
if (request.method === 'GET' && url.pathname === '/api/sentinel') {
|
|
989
1052
|
return sendJson(response, 200, { reports: sentinel.reports(), settings: sentinel.settings(), environment: sentinel.environment(), feedbackUrl: sentinel.feedbackUrl(), formUrl: sentinel.formUrl() });
|
|
@@ -1017,6 +1080,62 @@ export async function createPulseServer({
|
|
|
1017
1080
|
}
|
|
1018
1081
|
// Where this room's tokens go: what each block of the briefing has cost, how much came back
|
|
1019
1082
|
// from a CLI's own cache, and how many characters the room spends per token it is charged.
|
|
1083
|
+
// What this project has settled, for the strip at the top of a conversation. Not gated
|
|
1084
|
+
// behind the designation the archive is: these same sentences already appear in the room
|
|
1085
|
+
// under any answer that recalled one.
|
|
1086
|
+
// Open questions, for the strip under the composer. Read-only and ungated for the same
|
|
1087
|
+
// reason the decisions are: these sentences already appear in the room whenever one is
|
|
1088
|
+
// recalled. Dismissing one still happens in NOSTROMO, behind the designation.
|
|
1089
|
+
// What is listening on loopback that answers HTTP, for RIPLEY's address bar. Reading only:
|
|
1090
|
+
// lsof reports and changes nothing, nothing in the project is run, and a port that does not
|
|
1091
|
+
// speak HTTP never reaches the list. Starting a server is not here — that runs a command.
|
|
1092
|
+
// Whether an address agrees to be put behind glass. A page is entitled to refuse, and most
|
|
1093
|
+
// serious ones do; what RIPLEY can do is ask before framing it, so the human gets MADRE's
|
|
1094
|
+
// sentence and a real tab instead of the browser's bare "refused to connect".
|
|
1095
|
+
//
|
|
1096
|
+
// This is the one place the room itself reaches the web, and only after the human turned
|
|
1097
|
+
// the web on and typed the address. The request goes through the same watched fetch as
|
|
1098
|
+
// everything else, so it lands in the outbound log like any other departure.
|
|
1099
|
+
if (request.method === 'GET' && url.pathname === '/api/can-frame') {
|
|
1100
|
+
let target = null;
|
|
1101
|
+
try { target = new URL(url.searchParams.get('url') ?? ''); } catch { return sendJson(response, 400, { error: t('That is not an address.') }); }
|
|
1102
|
+
if (!/^https?:$/.test(target.protocol)) return sendJson(response, 400, { error: t('Only http and https.') });
|
|
1103
|
+
const local = ['127.0.0.1', 'localhost', '[::1]'].includes(target.hostname);
|
|
1104
|
+
if (!local && !(await readConfig(root)).modules?.ripley?.web) {
|
|
1105
|
+
return sendJson(response, 403, { error: t('RIPLEY reaches this computer only · turn the web on in MODULES to open an address outside it') });
|
|
1106
|
+
}
|
|
1107
|
+
try {
|
|
1108
|
+
const controller = new AbortController();
|
|
1109
|
+
const timer = setTimeout(() => controller.abort(), 6000);
|
|
1110
|
+
const probe = await reportFetch(target.href, { signal: controller.signal, redirect: 'follow' });
|
|
1111
|
+
clearTimeout(timer);
|
|
1112
|
+
return sendJson(response, 200, { framable: framable(probe.headers), status: probe.status });
|
|
1113
|
+
} catch (error) {
|
|
1114
|
+
return sendJson(response, 200, { framable: true, status: null, unreachable: true });
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
if (request.method === 'GET' && url.pathname === '/api/ports') {
|
|
1118
|
+
if (!(await ripleyOn())) return sendJson(response, 412, { error: t('RIPLEY is off. Enable it in MODULES to render files.') });
|
|
1119
|
+
let listening = [];
|
|
1120
|
+
try {
|
|
1121
|
+
const { stdout } = await execFileAsync('lsof', ['-nP', '-iTCP', '-sTCP:LISTEN'], { timeout: 4000, maxBuffer: 2 * 1024 * 1024 });
|
|
1122
|
+
listening = parseListening(stdout, { self: request.socket.localPort });
|
|
1123
|
+
} catch { return sendJson(response, 200, { ports: [], probed: false }); }
|
|
1124
|
+
const ports = [];
|
|
1125
|
+
for (const found of listening.slice(0, 24)) {
|
|
1126
|
+
const probe = await probePort(found.port, { fetchImpl: reportFetch });
|
|
1127
|
+
if (probe.ok) ports.push({ ...found, status: probe.status, html: probe.html, contentType: probe.contentType, framable: probe.framable });
|
|
1128
|
+
}
|
|
1129
|
+
return sendJson(response, 200, { ports, probed: true });
|
|
1130
|
+
}
|
|
1131
|
+
if (request.method === 'GET' && url.pathname === '/api/questions') {
|
|
1132
|
+
const limit = Math.min(5, Math.max(1, Number(url.searchParams.get('limit') ?? 3) || 3));
|
|
1133
|
+
return sendJson(response, 200, { questions: room.questions({ limit }) });
|
|
1134
|
+
}
|
|
1135
|
+
if (request.method === 'GET' && url.pathname === '/api/decisions') {
|
|
1136
|
+
const limit = Math.min(5, Math.max(1, Number(url.searchParams.get('limit') ?? 3) || 3));
|
|
1137
|
+
return sendJson(response, 200, { decisions: room.decisions({ limit }) });
|
|
1138
|
+
}
|
|
1020
1139
|
if (url.pathname === '/api/economy' && request.method === 'GET') {
|
|
1021
1140
|
return sendJson(response, 200, economy(await store.readAll()));
|
|
1022
1141
|
}
|
|
@@ -1212,7 +1331,8 @@ export async function createPulseServer({
|
|
|
1212
1331
|
const asked = url.searchParams.get('agent') ?? agents.find((agent) => agent.ready && !agent.local)?.id ?? agents[0]?.id;
|
|
1213
1332
|
const mode = Math.min(4, Math.max(0, Number(url.searchParams.get('mode') ?? 1) || 0));
|
|
1214
1333
|
const text = String(url.searchParams.get('text') ?? '').slice(0, 2000);
|
|
1215
|
-
const
|
|
1334
|
+
const ash = url.searchParams.get('ash') === 'true';
|
|
1335
|
+
const briefing = await room.briefing({ agent: asked, mode, text, ash });
|
|
1216
1336
|
if (!briefing) return sendJson(response, 404, { error: `No agent "${asked}" on this computer.` });
|
|
1217
1337
|
return sendJson(response, 200, {
|
|
1218
1338
|
...briefing,
|