@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/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: ashActive ? { active: true } : undefined,
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
- const responseMessageId = randomUUID();
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
- // Rooms opened before Ash was renamed carry the old key; the human's switch is not lost.
550
- room.setAsh(Boolean(startupConfig.modules?.ash?.enabled ?? startupConfig.modules?.ash?.enabled));
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 briefing = await room.briefing({ agent: asked, mode, text });
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,