@jossuealcala/madre 0.7.1 → 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.
Files changed (165) hide show
  1. package/CHANGELOG.md +833 -1
  2. package/README.md +92 -28
  3. package/SECURITY.md +1 -0
  4. package/bin/madre.mjs +30 -10
  5. package/docs/REFERENCE.md +158 -79
  6. package/docs/SDK.md +23 -19
  7. package/package.json +1 -1
  8. package/public/app.js +11599 -4186
  9. package/public/assets/brand/Geist-latin-ext.woff2 +0 -0
  10. package/public/assets/brand/Geist-latin.woff2 +0 -0
  11. package/public/assets/brand/GeistMono-latin-ext.woff2 +0 -0
  12. package/public/assets/brand/GeistMono-latin.woff2 +0 -0
  13. package/public/assets/brand/JetBrainsMono-latin-ext.woff2 +0 -0
  14. package/public/assets/brand/JetBrainsMono-latin.woff2 +0 -0
  15. package/public/assets/brand/OFL-Geist.txt +93 -0
  16. package/public/assets/brand/OFL-GeistMono.txt +93 -0
  17. package/public/assets/brand/OFL-JetBrainsMono.txt +93 -0
  18. package/public/assets/brand/OFL-Sora.txt +93 -0
  19. package/public/assets/brand/Sora-latin-ext.woff2 +0 -0
  20. package/public/assets/brand/Sora-latin.woff2 +0 -0
  21. package/public/assets/brand/madre-app-180.png +0 -0
  22. package/public/assets/brand/madre-app-192.png +0 -0
  23. package/public/assets/brand/madre-app-512.png +0 -0
  24. package/public/assets/brand/madre-app.svg +1 -0
  25. package/public/assets/brand/madre-favicon.svg +1 -0
  26. package/public/assets/news/1.0/alerts.webp +0 -0
  27. package/public/assets/news/1.0/bar.webp +0 -0
  28. package/public/assets/news/1.0/finder.webp +0 -0
  29. package/public/assets/news/1.0/github.webp +0 -0
  30. package/public/assets/news/1.0/intents.webp +0 -0
  31. package/public/assets/news/1.0/looks.webp +0 -0
  32. package/public/assets/news/1.0/modules.webp +0 -0
  33. package/public/assets/news/1.0/mother.webp +0 -0
  34. package/public/assets/news/1.0/nostromo.webp +0 -0
  35. package/public/assets/news/1.0/permissions.webp +0 -0
  36. package/public/assets/news/1.0/room.webp +0 -0
  37. package/public/assets/news/1.0/settings.webp +0 -0
  38. package/public/assets/wallpapers/biznaga.webp +0 -0
  39. package/public/assets/wallpapers/canela.webp +0 -0
  40. package/public/assets/wallpapers/jarrito.webp +0 -0
  41. package/public/assets/wallpapers/magna.webp +0 -0
  42. package/public/assets/wallpapers/oceano.webp +0 -0
  43. package/public/assets/wallpapers/paraiso.webp +0 -0
  44. package/public/assets/wallpapers/quila.webp +0 -0
  45. package/public/assets/wallpapers/tradicion.webp +0 -0
  46. package/public/assets/wallpapers/tropical.webp +0 -0
  47. package/public/assets/wallpapers/urbania.webp +0 -0
  48. package/public/assets/wallpapers/ventura.webp +0 -0
  49. package/public/assets/wallpapers/xico.webp +0 -0
  50. package/public/brands.js +28 -2
  51. package/public/editor.js +744 -0
  52. package/public/es.js +2863 -1008
  53. package/public/i18n.js +1 -1
  54. package/public/index.html +201 -175
  55. package/public/inquiry.js +11 -11
  56. package/public/loader.js +79 -0
  57. package/public/macroverso.js +1497 -0
  58. package/public/manifest.webmanifest +14 -0
  59. package/public/nostromo-engine.js +2222 -0
  60. package/public/resay.js +24 -0
  61. package/public/review-ask.js +85 -0
  62. package/public/salas-mac.css +787 -0
  63. package/public/salas-mac.html +154 -0
  64. package/public/salas-mac.js +3264 -0
  65. package/public/salas.css +85 -0
  66. package/public/salas.html +47 -0
  67. package/public/salas.js +200 -0
  68. package/public/styles.css +6303 -414
  69. package/public/touch.js +83 -0
  70. package/public/tour-chapters.js +342 -0
  71. package/public/tour.js +510 -0
  72. package/public/troubleshooting.js +159 -140
  73. package/src/adapters/claude.mjs +4 -3
  74. package/src/adapters/codex.mjs +49 -8
  75. package/src/adapters/gemini.mjs +30 -10
  76. package/src/adapters/madre.mjs +110 -18
  77. package/src/adapters/process.mjs +42 -2
  78. package/src/auth-probe.mjs +2 -2
  79. package/src/bridge/devices.mjs +202 -0
  80. package/src/bridge/handoff.mjs +45 -0
  81. package/src/bridge/host.mjs +82 -0
  82. package/src/bridge/network.mjs +133 -0
  83. package/src/bridge/policy.mjs +89 -0
  84. package/src/bridge/qr.mjs +287 -0
  85. package/src/bridge/server.mjs +269 -0
  86. package/src/capabilities.mjs +5 -9
  87. package/src/chats.mjs +59 -22
  88. package/src/checkpoint.mjs +137 -35
  89. package/src/choices.mjs +45 -6
  90. package/src/cli-args.mjs +12 -1
  91. package/src/commands.mjs +15 -15
  92. package/src/config.mjs +73 -9
  93. package/src/conversation-context.mjs +5 -2
  94. package/src/dataset.mjs +3 -1
  95. package/src/directives.mjs +0 -5
  96. package/src/enlaces.mjs +451 -0
  97. package/src/event-store.mjs +55 -10
  98. package/src/exam.mjs +5 -5
  99. package/src/extensions.mjs +0 -2
  100. package/src/file-ops.mjs +42 -0
  101. package/src/files.mjs +52 -2
  102. package/src/github.mjs +407 -0
  103. package/src/json-file.mjs +63 -0
  104. package/src/lease.mjs +2 -2
  105. package/src/lobby-work.mjs +103 -0
  106. package/src/looks.mjs +55 -0
  107. package/src/maturity.mjs +3 -3
  108. package/src/mcp/github-server.mjs +164 -0
  109. package/src/mcp/image-server.mjs +1 -1
  110. package/src/mcp/memory-server.mjs +11 -3
  111. package/src/memory.mjs +38 -12
  112. package/src/models.mjs +109 -1
  113. package/src/modules/ahp.mjs +4 -4
  114. package/src/modules/ash.mjs +1 -1
  115. package/src/modules/bishop-plan.mjs +207 -0
  116. package/src/modules/bishop.mjs +645 -0
  117. package/src/modules/choices.mjs +2 -2
  118. package/src/modules/correo.mjs +1 -1
  119. package/src/modules/enlaces.mjs +255 -0
  120. package/src/modules/git-pulse.mjs +2 -2
  121. package/src/modules/github.mjs +322 -0
  122. package/src/modules/image-studio.mjs +3 -3
  123. package/src/modules/index.mjs +19 -4
  124. package/src/modules/intents-calibration.mjs +338 -0
  125. package/src/modules/intents-desk.mjs +481 -0
  126. package/src/modules/intents-observe.mjs +266 -0
  127. package/src/modules/intents-policy.mjs +414 -0
  128. package/src/modules/intents-try.mjs +91 -0
  129. package/src/modules/intents.mjs +145 -0
  130. package/src/modules/ollama.mjs +9 -5
  131. package/src/modules/playwright.mjs +121 -11
  132. package/src/modules/ripley.mjs +1 -1
  133. package/src/modules/runs.mjs +34 -0
  134. package/src/modules/sdk.mjs +10 -4
  135. package/src/ollama.mjs +25 -1
  136. package/src/outbound.mjs +19 -17
  137. package/src/override.mjs +100 -0
  138. package/src/ports.mjs +23 -1
  139. package/src/prices.mjs +72 -0
  140. package/src/privacy.mjs +30 -11
  141. package/src/quota-sources.mjs +0 -8
  142. package/src/request-guard.mjs +1 -1
  143. package/src/review-ask.mjs +111 -0
  144. package/src/room/archivist.mjs +55 -37
  145. package/src/room/budget.mjs +17 -5
  146. package/src/room/control.mjs +88 -8
  147. package/src/room/economy.mjs +35 -7
  148. package/src/room/escalation.mjs +2 -0
  149. package/src/room/guard.mjs +26 -2
  150. package/src/room/prompt.mjs +32 -13
  151. package/src/room/runs.mjs +151 -0
  152. package/src/room/vectors.mjs +19 -4
  153. package/src/room.mjs +402 -54
  154. package/src/router.mjs +3 -1
  155. package/src/runs.mjs +137 -0
  156. package/src/salas.mjs +313 -0
  157. package/src/sentinel-errors.mjs +1 -1
  158. package/src/server-sentinel.mjs +124 -0
  159. package/src/server.mjs +1578 -167
  160. package/src/setup.mjs +1 -0
  161. package/src/tour.mjs +159 -0
  162. package/src/updates.mjs +48 -8
  163. package/src/usage-sentinel.mjs +1 -1
  164. package/src/verdict.mjs +7 -7
  165. package/src/whats-new.mjs +160 -0
@@ -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.#floor) + 1;
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.split('\n').filter(Boolean).map((line) => JSON.parse(line));
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 text = await readFile(this.#file, 'utf8');
75
- return text.split('\n').filter(Boolean).map((line) => JSON.parse(line));
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.#floor) + 1;
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.#floor) + 1;
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
- await handle.writeFile(`${JSON.stringify(event)}\n`);
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 room is long enough to test with yet.') };
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 room 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') : '' }),
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 embeddings: two answers cannot be compared by their words alone.') };
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 room was answered by an agent other than the local one yet.') };
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 room actually asked had their answer already in the archive, matched by {method}{control}.',
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)) {
@@ -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}.` };
@@ -43,3 +66,22 @@ export function planFileOp({ operation, from, to }) {
43
66
  if (operation === 'move' && `${target}/`.startsWith(`${source}/`)) return { ok: false, error: 'A folder cannot be moved inside itself.' };
44
67
  return { ok: true, operation, from: source, to: target, guarded: needsDesignation(source, target) };
45
68
  }
69
+
70
+ // The name a copy takes when its own is already used where it lands: "notes copy.md", then
71
+ // "notes copy 2.md", the way a file manager does it. `word` is the room's word for "copy", and
72
+ // `taken` answers whether a project-relative path already holds something.
73
+ export async function freeCopyPath(to, { taken, word = 'copy', limit = 100 }) {
74
+ const path = String(to ?? '').replace(/^\/+/, '').trim();
75
+ if (!path || !(await taken(path))) return path;
76
+ const slash = path.lastIndexOf('/');
77
+ const dir = slash >= 0 ? path.slice(0, slash + 1) : '';
78
+ const name = path.slice(slash + 1);
79
+ // A leading dot is part of the name (".env"), not an extension.
80
+ const dot = name.lastIndexOf('.');
81
+ const [stem, ext] = dot > 0 ? [name.slice(0, dot), name.slice(dot)] : [name, ''];
82
+ for (let n = 1; n <= limit; n += 1) {
83
+ const candidate = `${dir}${stem} ${word}${n > 1 ? ` ${n}` : ''}${ext}`;
84
+ if (!(await taken(candidate))) return candidate;
85
+ }
86
+ return path;
87
+ }
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';