@jossuealcala/madre 0.7.2 → 1.0.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 +773 -1
- package/README.md +92 -28
- package/bin/madre.mjs +30 -10
- package/docs/REFERENCE.md +157 -81
- package/docs/SDK.md +23 -19
- package/package.json +1 -1
- package/public/app.js +11207 -4196
- package/public/assets/brand/Geist-latin-ext.woff2 +0 -0
- package/public/assets/brand/Geist-latin.woff2 +0 -0
- package/public/assets/brand/GeistMono-latin-ext.woff2 +0 -0
- package/public/assets/brand/GeistMono-latin.woff2 +0 -0
- package/public/assets/brand/JetBrainsMono-latin-ext.woff2 +0 -0
- package/public/assets/brand/JetBrainsMono-latin.woff2 +0 -0
- package/public/assets/brand/OFL-Geist.txt +93 -0
- package/public/assets/brand/OFL-GeistMono.txt +93 -0
- package/public/assets/brand/OFL-JetBrainsMono.txt +93 -0
- package/public/assets/brand/OFL-Sora.txt +93 -0
- package/public/assets/brand/Sora-latin-ext.woff2 +0 -0
- package/public/assets/brand/Sora-latin.woff2 +0 -0
- package/public/assets/brand/madre-app-180.png +0 -0
- package/public/assets/brand/madre-app-192.png +0 -0
- package/public/assets/brand/madre-app-512.png +0 -0
- package/public/assets/brand/madre-app.svg +1 -0
- package/public/assets/brand/madre-favicon.svg +1 -0
- package/public/assets/news/1.0/alerts.webp +0 -0
- package/public/assets/news/1.0/bar.webp +0 -0
- package/public/assets/news/1.0/finder.webp +0 -0
- package/public/assets/news/1.0/github.webp +0 -0
- package/public/assets/news/1.0/intents.webp +0 -0
- package/public/assets/news/1.0/looks.webp +0 -0
- package/public/assets/news/1.0/modules.webp +0 -0
- package/public/assets/news/1.0/mother.webp +0 -0
- package/public/assets/news/1.0/nostromo.webp +0 -0
- package/public/assets/news/1.0/permissions.webp +0 -0
- package/public/assets/news/1.0/room.webp +0 -0
- package/public/assets/news/1.0/settings.webp +0 -0
- package/public/assets/wallpapers/biznaga.webp +0 -0
- package/public/assets/wallpapers/canela.webp +0 -0
- package/public/assets/wallpapers/jarrito.webp +0 -0
- package/public/assets/wallpapers/magna.webp +0 -0
- package/public/assets/wallpapers/oceano.webp +0 -0
- package/public/assets/wallpapers/paraiso.webp +0 -0
- package/public/assets/wallpapers/quila.webp +0 -0
- package/public/assets/wallpapers/tradicion.webp +0 -0
- package/public/assets/wallpapers/tropical.webp +0 -0
- package/public/assets/wallpapers/urbania.webp +0 -0
- package/public/assets/wallpapers/ventura.webp +0 -0
- package/public/assets/wallpapers/xico.webp +0 -0
- package/public/brands.js +28 -2
- package/public/editor.js +744 -0
- package/public/es.js +2784 -1026
- package/public/i18n.js +1 -1
- package/public/index.html +201 -175
- package/public/inquiry.js +11 -11
- package/public/loader.js +79 -0
- package/public/macroverso.js +1497 -0
- package/public/manifest.webmanifest +14 -0
- package/public/nostromo-engine.js +2222 -0
- package/public/resay.js +24 -0
- package/public/review-ask.js +85 -0
- package/public/salas-mac.css +787 -0
- package/public/salas-mac.html +154 -0
- package/public/salas-mac.js +3264 -0
- package/public/salas.css +85 -0
- package/public/salas.html +47 -0
- package/public/salas.js +200 -0
- package/public/styles.css +6207 -425
- package/public/touch.js +83 -0
- package/public/tour-chapters.js +342 -0
- package/public/tour.js +510 -0
- package/public/troubleshooting.js +159 -140
- package/src/adapters/codex.mjs +46 -6
- package/src/adapters/gemini.mjs +30 -10
- package/src/adapters/madre.mjs +110 -18
- package/src/adapters/process.mjs +42 -2
- package/src/auth-probe.mjs +2 -2
- package/src/bridge/devices.mjs +202 -0
- package/src/bridge/handoff.mjs +45 -0
- package/src/bridge/host.mjs +82 -0
- package/src/bridge/network.mjs +133 -0
- package/src/bridge/policy.mjs +89 -0
- package/src/bridge/qr.mjs +287 -0
- package/src/bridge/server.mjs +269 -0
- package/src/capabilities.mjs +5 -9
- package/src/chats.mjs +59 -22
- package/src/checkpoint.mjs +137 -35
- package/src/choices.mjs +18 -3
- package/src/cli-args.mjs +12 -1
- package/src/commands.mjs +15 -15
- package/src/config.mjs +73 -9
- package/src/conversation-context.mjs +5 -2
- package/src/dataset.mjs +3 -1
- package/src/directives.mjs +0 -5
- package/src/enlaces.mjs +451 -0
- package/src/event-store.mjs +55 -10
- package/src/exam.mjs +5 -5
- package/src/extensions.mjs +0 -2
- package/src/file-ops.mjs +23 -0
- package/src/files.mjs +52 -2
- package/src/github.mjs +407 -0
- package/src/json-file.mjs +63 -0
- package/src/lease.mjs +2 -2
- package/src/lobby-work.mjs +103 -0
- package/src/looks.mjs +55 -0
- package/src/maturity.mjs +3 -3
- package/src/mcp/github-server.mjs +164 -0
- package/src/mcp/image-server.mjs +1 -1
- package/src/mcp/memory-server.mjs +11 -3
- package/src/memory.mjs +38 -12
- package/src/models.mjs +89 -5
- package/src/modules/ahp.mjs +4 -4
- package/src/modules/ash.mjs +1 -1
- package/src/modules/bishop-plan.mjs +207 -0
- package/src/modules/bishop.mjs +645 -0
- package/src/modules/choices.mjs +2 -2
- package/src/modules/correo.mjs +1 -1
- package/src/modules/enlaces.mjs +255 -0
- package/src/modules/git-pulse.mjs +2 -2
- package/src/modules/github.mjs +322 -0
- package/src/modules/image-studio.mjs +3 -3
- package/src/modules/index.mjs +18 -4
- package/src/modules/intents-calibration.mjs +338 -0
- package/src/modules/intents-desk.mjs +481 -0
- package/src/modules/intents-observe.mjs +266 -0
- package/src/modules/intents-policy.mjs +414 -0
- package/src/modules/intents-try.mjs +91 -0
- package/src/modules/intents.mjs +145 -0
- package/src/modules/ollama.mjs +4 -4
- package/src/modules/playwright.mjs +121 -11
- package/src/modules/ripley.mjs +1 -1
- package/src/modules/runs.mjs +2 -2
- package/src/modules/sdk.mjs +10 -4
- package/src/ollama.mjs +6 -1
- package/src/outbound.mjs +19 -17
- package/src/override.mjs +100 -0
- package/src/ports.mjs +23 -1
- package/src/prices.mjs +72 -0
- package/src/privacy.mjs +30 -11
- package/src/quota-sources.mjs +0 -8
- package/src/request-guard.mjs +1 -1
- package/src/review-ask.mjs +111 -0
- package/src/room/archivist.mjs +55 -37
- package/src/room/budget.mjs +17 -5
- package/src/room/control.mjs +70 -7
- package/src/room/economy.mjs +35 -7
- package/src/room/escalation.mjs +2 -0
- package/src/room/guard.mjs +26 -2
- package/src/room/prompt.mjs +22 -15
- package/src/room/runs.mjs +84 -21
- package/src/room/vectors.mjs +19 -4
- package/src/room.mjs +351 -65
- package/src/router.mjs +3 -1
- package/src/runs.mjs +23 -5
- package/src/salas.mjs +313 -0
- package/src/sentinel-errors.mjs +1 -1
- package/src/server-sentinel.mjs +124 -0
- package/src/server.mjs +1526 -163
- package/src/setup.mjs +1 -0
- package/src/tour.mjs +159 -0
- package/src/updates.mjs +48 -8
- package/src/usage-sentinel.mjs +1 -1
- package/src/verdict.mjs +7 -7
- package/src/whats-new.mjs +160 -0
package/src/event-store.mjs
CHANGED
|
@@ -3,8 +3,31 @@ import { dirname, join } from 'node:path';
|
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
4
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
5
5
|
|
|
6
|
+
// A record cut halfway (2026-10-12, LOG-02 of the 1.0 review): a `kill -9`, a power cut or a full
|
|
7
|
+
// disk in the middle of an append leaves half a line. Every read used to throw on it, so the
|
|
8
|
+
// conversation never opened again — and the next event was written glued to the half line, lost
|
|
9
|
+
// with it. Now a line that is not an event is stepped over and counted (`damaged`), never removed:
|
|
10
|
+
// the file keeps it for whoever wants to look. And an append that finds the file not ending in a
|
|
11
|
+
// newline writes one first, so what comes next starts on a line of its own.
|
|
12
|
+
function parseLines(text) {
|
|
13
|
+
const events = [];
|
|
14
|
+
let damaged = 0;
|
|
15
|
+
for (const line of text.split('\n')) {
|
|
16
|
+
if (!line.trim()) continue;
|
|
17
|
+
try {
|
|
18
|
+
const event = JSON.parse(line);
|
|
19
|
+
if (event && typeof event === 'object' && Number.isFinite(event.sequence)) events.push(event);
|
|
20
|
+
else damaged += 1;
|
|
21
|
+
} catch {
|
|
22
|
+
damaged += 1;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return { events, damaged };
|
|
26
|
+
}
|
|
27
|
+
|
|
6
28
|
export class EventStore {
|
|
7
29
|
#file;
|
|
30
|
+
#damaged = 0;
|
|
8
31
|
#nextSequence = 1;
|
|
9
32
|
#writeQueue = Promise.resolve();
|
|
10
33
|
#lockDirectory;
|
|
@@ -14,16 +37,29 @@ export class EventStore {
|
|
|
14
37
|
// numbering across all of its conversations, so #1411 means one exchange in this project and
|
|
15
38
|
// not one in each thread — the memory index, every citation and NOSTROMO all lean on that.
|
|
16
39
|
// Gaps in a single ledger are fine: sequences are an order, not a count.
|
|
40
|
+
// It may be a function, read at every append, when other conversations of the project are
|
|
41
|
+
// writing at the same time as this one: a number fixed when the ledger opened would hand out
|
|
42
|
+
// a sequence another conversation has taken since.
|
|
17
43
|
constructor(file, { floor = 0 } = {}) {
|
|
18
44
|
this.#file = file;
|
|
19
|
-
this.#floor = Number.isFinite(floor) ? floor : 0;
|
|
45
|
+
this.#floor = typeof floor === 'function' || Number.isFinite(floor) ? floor : 0;
|
|
20
46
|
this.#lockDirectory = `${file}.lock`;
|
|
21
47
|
}
|
|
22
48
|
|
|
49
|
+
#floorNow() {
|
|
50
|
+
const value = typeof this.#floor === 'function' ? Number(this.#floor()) : this.#floor;
|
|
51
|
+
return Number.isFinite(value) ? value : 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// The last sequence this ledger handed out (or the floor it opened above).
|
|
55
|
+
get lastSequence() { return this.#nextSequence - 1; }
|
|
56
|
+
// How many lines the last full read stepped over because they were not events.
|
|
57
|
+
get damaged() { return this.#damaged; }
|
|
58
|
+
|
|
23
59
|
async initialize() {
|
|
24
60
|
await mkdir(dirname(this.#file), { recursive: true });
|
|
25
61
|
const existing = await this.readAll();
|
|
26
|
-
this.#nextSequence = Math.max(existing.at(-1)?.sequence ?? 0, this.#
|
|
62
|
+
this.#nextSequence = Math.max(existing.at(-1)?.sequence ?? 0, this.#floorNow()) + 1;
|
|
27
63
|
return this;
|
|
28
64
|
}
|
|
29
65
|
|
|
@@ -59,7 +95,7 @@ export class EventStore {
|
|
|
59
95
|
const lastNewline = text.lastIndexOf('\n');
|
|
60
96
|
if (lastNewline < 0) return { events: [], offset: start };
|
|
61
97
|
text = text.slice(0, lastNewline + 1);
|
|
62
|
-
const events = text
|
|
98
|
+
const { events } = parseLines(text);
|
|
63
99
|
return { events, offset: start + Buffer.byteLength(text, 'utf8') };
|
|
64
100
|
} finally {
|
|
65
101
|
await handle.close();
|
|
@@ -71,8 +107,9 @@ export class EventStore {
|
|
|
71
107
|
|
|
72
108
|
async #readAllUnlocked() {
|
|
73
109
|
try {
|
|
74
|
-
const
|
|
75
|
-
|
|
110
|
+
const { events, damaged } = parseLines(await readFile(this.#file, 'utf8'));
|
|
111
|
+
this.#damaged = damaged;
|
|
112
|
+
return events;
|
|
76
113
|
} catch (error) {
|
|
77
114
|
if (error.code === 'ENOENT') return [];
|
|
78
115
|
throw error;
|
|
@@ -144,7 +181,7 @@ export class EventStore {
|
|
|
144
181
|
const temp = `${this.#file}.rewrite-${process.pid}`;
|
|
145
182
|
await writeFile(temp, next.map((event) => JSON.stringify(event)).join('\n') + (next.length ? '\n' : ''));
|
|
146
183
|
await rename(temp, this.#file);
|
|
147
|
-
this.#nextSequence = Math.max(next.at(-1)?.sequence ?? 0, this.#
|
|
184
|
+
this.#nextSequence = Math.max(next.at(-1)?.sequence ?? 0, this.#floorNow()) + 1;
|
|
148
185
|
return { total: next.length, changed };
|
|
149
186
|
} finally {
|
|
150
187
|
await release();
|
|
@@ -159,7 +196,11 @@ export class EventStore {
|
|
|
159
196
|
const release = await this.#acquireLock();
|
|
160
197
|
try {
|
|
161
198
|
const existing = await this.#readAllUnlocked();
|
|
162
|
-
const sequence = Math.max(existing.at(-1)?.sequence ?? 0, this.#
|
|
199
|
+
const sequence = Math.max(existing.at(-1)?.sequence ?? 0, this.#floorNow()) + 1;
|
|
200
|
+
// Taken in the same tick it was read: another conversation's ledger asks for the floor
|
|
201
|
+
// while this one is still on its way to the disk, and must already see this number as
|
|
202
|
+
// spent. A write that fails leaves a gap, and gaps are fine.
|
|
203
|
+
this.#nextSequence = sequence + 1;
|
|
163
204
|
const event = {
|
|
164
205
|
id: randomUUID(),
|
|
165
206
|
sequence,
|
|
@@ -167,14 +208,18 @@ export class EventStore {
|
|
|
167
208
|
timestamp: new Date().toISOString(),
|
|
168
209
|
payload,
|
|
169
210
|
};
|
|
170
|
-
const handle = await open(this.#file, 'a');
|
|
211
|
+
const handle = await open(this.#file, 'a+');
|
|
171
212
|
try {
|
|
172
|
-
|
|
213
|
+
// A file that does not end in a newline ends in half a line: start on a new one.
|
|
214
|
+
const { size } = await handle.stat();
|
|
215
|
+
const last = Buffer.alloc(1);
|
|
216
|
+
if (size > 0) await handle.read(last, 0, 1, size - 1);
|
|
217
|
+
const lead = size > 0 && last[0] !== 0x0a ? '\n' : '';
|
|
218
|
+
await handle.writeFile(`${lead}${JSON.stringify(event)}\n`);
|
|
173
219
|
await handle.sync();
|
|
174
220
|
} finally {
|
|
175
221
|
await handle.close();
|
|
176
222
|
}
|
|
177
|
-
this.#nextSequence = sequence + 1;
|
|
178
223
|
return event;
|
|
179
224
|
} finally {
|
|
180
225
|
await release();
|
package/src/exam.mjs
CHANGED
|
@@ -102,7 +102,7 @@ export function spread(items, size) {
|
|
|
102
102
|
export async function coverageExam({ events = [], recall, embed = null, sample = COVERAGE_SAMPLE, bar = null } = {}) {
|
|
103
103
|
const all = exchanges(events);
|
|
104
104
|
const cases = spread(all, sample);
|
|
105
|
-
if (!cases.length) return { id: 'coverage', ran: false, says: t('No exchange in this
|
|
105
|
+
if (!cases.length) return { id: 'coverage', ran: false, says: t('No exchange in this project is long enough to test with yet.') };
|
|
106
106
|
const found = [];
|
|
107
107
|
for (const one of cases) {
|
|
108
108
|
const held = await recall(one.asked, one.sequence);
|
|
@@ -146,7 +146,7 @@ export async function coverageExam({ events = [], recall, embed = null, sample =
|
|
|
146
146
|
id: 'coverage', ran: true, at: new Date().toISOString(), n: cases.length, hits, rate, method, bar: used,
|
|
147
147
|
mean: mean(scores), control: controlled ? mean(controls) : null,
|
|
148
148
|
passed: rate >= PASS.coverage,
|
|
149
|
-
says: t('{hits} of {n} questions this
|
|
149
|
+
says: t('{hits} of {n} questions this project actually asked had their answer already in the archive, matched by {method}{control}.', { hits, n: cases.length, method: t(method), control: controlled ? t(' and against a control') : '' }),
|
|
150
150
|
};
|
|
151
151
|
}
|
|
152
152
|
|
|
@@ -186,10 +186,10 @@ export function consistencyExam({ findings = [], notes = [], now = Date.now() }
|
|
|
186
186
|
// `ask(question)` is the local model with this archive behind it. `embed` is needed: comparing
|
|
187
187
|
// two answers by their words rewards copying the question back.
|
|
188
188
|
export async function matchExam({ events = [], ask, embed, sample = MATCH_SAMPLE, bar = MATCH_BAR, onProgress = () => {}, stop = () => false } = {}) {
|
|
189
|
-
if (!embed) return { id: 'match', ran: false, says: t('This test needs
|
|
189
|
+
if (!embed) return { id: 'match', ran: false, says: t('This test needs smart search: two answers cannot be compared by their words alone.') };
|
|
190
190
|
const all = exchanges(events).filter((one) => !one.local); // a model is not tested against itself
|
|
191
191
|
const cases = spread(all, sample);
|
|
192
|
-
if (!cases.length) return { id: 'match', ran: false, says: t('No question in this
|
|
192
|
+
if (!cases.length) return { id: 'match', ran: false, says: t('No question in this project was answered by an agent other than the local one yet.') };
|
|
193
193
|
const mine = [];
|
|
194
194
|
for (const [index, one] of cases.entries()) {
|
|
195
195
|
if (stop()) return { id: 'match', ran: false, stopped: true, says: t('Stopped after {index} of {n}.', { index, n: cases.length }) };
|
|
@@ -222,7 +222,7 @@ export async function matchExam({ events = [], ask, embed, sample = MATCH_SAMPLE
|
|
|
222
222
|
export function saysFor(result) {
|
|
223
223
|
if (!result?.ran) return result?.says ?? null;
|
|
224
224
|
if (result.id === 'coverage' && Number.isFinite(result.hits)) {
|
|
225
|
-
return t('{hits} of {n} questions this
|
|
225
|
+
return t('{hits} of {n} questions this project actually asked had their answer already in the archive, matched by {method}{control}.',
|
|
226
226
|
{ hits: result.hits, n: result.n, method: t(result.method ?? 'words'), control: result.control === null || result.control === undefined ? '' : t(' and against a control') });
|
|
227
227
|
}
|
|
228
228
|
if (result.id === 'match' && Number.isFinite(result.matched)) {
|
package/src/extensions.mjs
CHANGED
|
@@ -6,8 +6,6 @@ export { gitToplevel, findOnPath } from './modules/helpers.mjs';
|
|
|
6
6
|
// server and the tests have always used, now answered by one file per module.
|
|
7
7
|
export const EXTENSIONS = MODULES;
|
|
8
8
|
export const AHP = moduleById('ahp');
|
|
9
|
-
export const IMAGE_STUDIO = moduleById('image-studio');
|
|
10
|
-
export const GIT_PULSE = moduleById('git-pulse');
|
|
11
9
|
export const ASH = moduleById('ash');
|
|
12
10
|
export const RIPLEY = moduleById('ripley');
|
|
13
11
|
export const OLLAMA = moduleById('ollama');
|
package/src/file-ops.mjs
CHANGED
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
// the same word the room asks for before CONTROL. Typing a folder name is a small ceremony;
|
|
13
13
|
// restoring a deleted key is not.
|
|
14
14
|
|
|
15
|
+
import { realpath } from 'node:fs/promises';
|
|
16
|
+
import { basename, dirname, join, relative, resolve, sep } from 'node:path';
|
|
15
17
|
import { isForbidden } from './checkpoint.mjs';
|
|
16
18
|
|
|
17
19
|
export const OPERATIONS = ['copy', 'move', 'new-file', 'new-folder'];
|
|
@@ -24,6 +26,27 @@ export function needsDesignation(...paths) {
|
|
|
24
26
|
return paths.filter(Boolean).some((path) => isForbidden(String(path).replace(/^\.\//, '')));
|
|
25
27
|
}
|
|
26
28
|
|
|
29
|
+
// The same question asked of where a path really lands (2026-10-12, SRV-05 of the 1.0 review): the
|
|
30
|
+
// words of a path are not its place. `src/../.git/config`, a link named `gitlink` that points at
|
|
31
|
+
// `.git`, `.ENV` on a disk that does not tell it from `.env` — each wrote into a guarded zone with
|
|
32
|
+
// no designation, because only the words were read. A path that does not exist yet is judged by
|
|
33
|
+
// the real folder it would land in.
|
|
34
|
+
export async function needsDesignationOnDisk(root, ...paths) {
|
|
35
|
+
if (needsDesignation(...paths)) return true;
|
|
36
|
+
const top = await realpath(root).catch(() => null);
|
|
37
|
+
if (!top) return false;
|
|
38
|
+
for (const path of paths.filter(Boolean)) {
|
|
39
|
+
const wanted = resolve(top, String(path).replace(/^\/+/, ''));
|
|
40
|
+
const real = await realpath(wanted).catch(async () => {
|
|
41
|
+
const folder = await realpath(dirname(wanted)).catch(() => null);
|
|
42
|
+
return folder ? join(folder, basename(wanted)) : null;
|
|
43
|
+
});
|
|
44
|
+
if (!real || (real !== top && !real.startsWith(top + sep))) continue; // outside: the fence refuses it elsewhere
|
|
45
|
+
if (needsDesignation(relative(top, real).split(sep).join('/'))) return true;
|
|
46
|
+
}
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
|
|
27
50
|
// What the room will say about an operation before doing any of it.
|
|
28
51
|
export function planFileOp({ operation, from, to }) {
|
|
29
52
|
if (!OPERATIONS.includes(operation)) return { ok: false, error: `Unknown operation: ${operation}.` };
|
package/src/files.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
2
|
import { mkdir, readdir, readFile, realpath, stat, writeFile } from 'node:fs/promises';
|
|
3
|
-
import { extname, join, resolve, sep } from 'node:path';
|
|
3
|
+
import { dirname, extname, join, resolve, sep } from 'node:path';
|
|
4
4
|
|
|
5
5
|
// Read-only file access for the room UI, and human attachments.
|
|
6
6
|
//
|
|
@@ -21,6 +21,12 @@ const TYPES = {
|
|
|
21
21
|
'.mp4': 'video/mp4', '.webm': 'video/webm', '.mp3': 'audio/mpeg', '.wav': 'audio/wav',
|
|
22
22
|
};
|
|
23
23
|
export const contentTypeFor = (name) => TYPES[extname(name).toLowerCase()] ?? 'application/octet-stream';
|
|
24
|
+
// What a file IS, for the record of what a turn made. contentTypeFor is how MADRE SERVES a project
|
|
25
|
+
// file, and a page is served as text on purpose: an HTML file from the project served as HTML would
|
|
26
|
+
// run its scripts in the room's own origin (RIPLEY renders it, sealed, elsewhere). The record said
|
|
27
|
+
// tests.html was text/plain, which is the serving rule leaking into the description.
|
|
28
|
+
const DECLARED = { '.html': 'text/html', '.htm': 'text/html', '.xml': 'application/xml', '.ts': 'text/typescript', '.tsx': 'text/typescript', '.jsx': 'text/javascript' };
|
|
29
|
+
export const declaredTypeFor = (name) => DECLARED[extname(name).toLowerCase()] ?? contentTypeFor(name);
|
|
24
30
|
export const isImage = (name) => contentTypeFor(name).startsWith('image/');
|
|
25
31
|
export const isText = (name) => /^(text\/|application\/(json|x-ndjson))/.test(contentTypeFor(name));
|
|
26
32
|
|
|
@@ -152,7 +158,51 @@ export async function readServable(root, relative, { maxBytes = MAX_FILE_BYTES }
|
|
|
152
158
|
if (!info || !info.isFile()) return { status: 404, error: 'Not found.' };
|
|
153
159
|
if (info.size > maxBytes) return { status: 413, error: `File is larger than ${Math.round(maxBytes / 1024 / 1024)} MB.` };
|
|
154
160
|
const body = await readFile(path);
|
|
155
|
-
return { status: 200, body, contentType: contentTypeFor(path), size: info.size, path };
|
|
161
|
+
return { status: 200, body, contentType: contentTypeFor(path), size: info.size, path, mtimeMs: info.mtimeMs };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Writing a file the human edited in the room. Only a file that already exists inside the root,
|
|
165
|
+
// only text, and only if it is still the file the editor opened: a file that changed on disk since
|
|
166
|
+
// (an agent wrote it, the human saved it elsewhere) is not overwritten without being asked.
|
|
167
|
+
export const MAX_EDIT_BYTES = 2 * 1024 * 1024;
|
|
168
|
+
export async function writeEdited(root, relative, content, { mtimeMs = null, force = false } = {}) {
|
|
169
|
+
if (typeof content !== 'string') return { status: 400, error: 'Nothing to save.' };
|
|
170
|
+
if (Buffer.byteLength(content, 'utf8') > MAX_EDIT_BYTES) return { status: 413, error: `The editor saves files up to ${MAX_EDIT_BYTES / 1024 / 1024} MB.` };
|
|
171
|
+
const path = await resolveInside(root, relative);
|
|
172
|
+
if (!path) return { status: 404, error: 'Not found.' };
|
|
173
|
+
const info = await stat(path).catch(() => null);
|
|
174
|
+
if (!info || !info.isFile()) return { status: 404, error: 'Not found.' };
|
|
175
|
+
if (!force && Number.isFinite(Number(mtimeMs)) && Math.abs(info.mtimeMs - Number(mtimeMs)) > 1) {
|
|
176
|
+
return { status: 409, error: 'The file changed on disk since it was opened.', changed: true, mtimeMs: info.mtimeMs };
|
|
177
|
+
}
|
|
178
|
+
await writeFile(path, content, 'utf8');
|
|
179
|
+
const after = await stat(path);
|
|
180
|
+
return { status: 200, path, bytes: after.size, mtimeMs: after.mtimeMs };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// A new, empty file in the project, from the Finder. Only a plain relative path: no `..`, no
|
|
184
|
+
// drive (`C:`) or UNC prefix, no folder of git's. Its nearest existing folder must already be
|
|
185
|
+
// inside the project (the same fence as every read and write); missing folders under it are
|
|
186
|
+
// made. An existing file is never touched.
|
|
187
|
+
export async function createEmptyFile(root, relative) {
|
|
188
|
+
const clean = String(relative ?? '').trim().replace(/^[/\\]+/, '');
|
|
189
|
+
const parts = clean.split(/[/\\]+/).filter(Boolean);
|
|
190
|
+
if (!clean || clean.includes('\0') || /[/\\]$/.test(clean) || /^[a-zA-Z]:/.test(clean) || parts.some((part) => part === '..' || part === '.') || parts[0] === '.git') {
|
|
191
|
+
return { status: 400, error: 'Not a file name this project can create.' };
|
|
192
|
+
}
|
|
193
|
+
const canonicalRoot = await realpath(root).catch(() => null);
|
|
194
|
+
if (!canonicalRoot) return { status: 404, error: 'Not found.' };
|
|
195
|
+
const target = resolve(canonicalRoot, ...parts);
|
|
196
|
+
if (!target.startsWith(canonicalRoot + sep)) return { status: 400, error: 'Not a file name this project can create.' };
|
|
197
|
+
// The nearest folder that exists is checked against the fence, symlinks resolved.
|
|
198
|
+
let folder = dirname(target);
|
|
199
|
+
while (folder !== canonicalRoot && !(await stat(folder).catch(() => null))) folder = dirname(folder);
|
|
200
|
+
const inside = await resolveInside(canonicalRoot, folder === canonicalRoot ? '.' : folder.slice(canonicalRoot.length + 1));
|
|
201
|
+
if (!inside) return { status: 400, error: 'Not a file name this project can create.' };
|
|
202
|
+
await mkdir(dirname(target), { recursive: true });
|
|
203
|
+
try { await writeFile(target, '', { encoding: 'utf8', flag: 'wx' }); }
|
|
204
|
+
catch (error) { return error.code === 'EEXIST' ? { status: 409, error: 'A file with that name is already there.' } : { status: 400, error: error.message }; }
|
|
205
|
+
return { status: 201, path: parts.join('/') };
|
|
156
206
|
}
|
|
157
207
|
|
|
158
208
|
const safeName = (name) => String(name ?? 'file').replace(/[^\w.\- ]+/g, '_').replace(/^\.+/, '').slice(0, 120) || 'file';
|