@jossuealcala/madre 0.7.2 → 1.0.1
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 +817 -1
- package/CONTRIBUTING.md +5 -1
- package/NOTICE +20 -1
- package/README.md +99 -34
- package/TRADEMARKS.md +65 -0
- package/bin/madre.mjs +30 -10
- package/docs/INTERNALS.md +2 -2
- package/docs/REFERENCE.md +159 -82
- package/docs/SDK.md +23 -19
- package/docs/madre-banner.png +0 -0
- package/package.json +4 -3
- package/public/app.js +11668 -4219
- 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 +3443 -1425
- 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 +1517 -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 +1281 -0
- package/public/salas-mac.html +158 -0
- package/public/salas-mac.js +4593 -0
- package/public/salas.css +85 -0
- package/public/salas.html +47 -0
- package/public/salas.js +200 -0
- package/public/styles.css +6372 -426
- package/public/touch.js +83 -0
- package/public/tour-chapters.js +452 -0
- package/public/tour.js +546 -0
- package/public/troubleshooting.js +160 -141
- 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 +31 -8
- 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/credentials.mjs +80 -0
- package/src/dataset.mjs +3 -1
- package/src/directives.mjs +6 -11
- package/src/enlaces.mjs +451 -0
- package/src/event-store.mjs +55 -10
- package/src/exam.mjs +5 -5
- package/src/extensions.mjs +31 -12
- package/src/file-ops.mjs +23 -0
- package/src/files.mjs +52 -2
- package/src/github.mjs +407 -0
- package/src/heartbeat.mjs +55 -0
- package/src/json-file.mjs +63 -0
- package/src/lease.mjs +2 -2
- package/src/lobby-work.mjs +103 -0
- package/src/local-ai.mjs +48 -0
- package/src/looks.mjs +88 -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 +127 -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 +18 -6
- 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 +36 -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 +3 -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 +411 -84
- 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 +1864 -178
- package/src/setup.mjs +1 -0
- package/src/tour.mjs +174 -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/docs/madre-banner.svg +0 -25
package/src/router.mjs
CHANGED
|
@@ -2,7 +2,9 @@ const supported = new Set(['codex', 'claude', 'gemini', 'opencode']);
|
|
|
2
2
|
|
|
3
3
|
export function parseMessage(text, fallbackTarget = null) {
|
|
4
4
|
const value = String(text ?? '').trim();
|
|
5
|
-
|
|
5
|
+
// The mention goes with the punctuation that hangs on it: «@madre, pregúntale al crew…» was kept
|
|
6
|
+
// as «, pregúntale al crew…».
|
|
7
|
+
const match = value.match(/^@([a-z0-9_-]+)\b[\s,:;]*/i);
|
|
6
8
|
const mentioned = match?.[1]?.toLowerCase() ?? null;
|
|
7
9
|
const target = mentioned && supported.has(mentioned) ? mentioned : fallbackTarget;
|
|
8
10
|
return {
|
package/src/runs.mjs
CHANGED
|
@@ -22,6 +22,9 @@
|
|
|
22
22
|
// says why. Running it through a shell instead would hand an agent's text to `sh` or `cmd.exe`
|
|
23
23
|
// (WIN-016): the human approves a line, and the line that runs has to be the one they read.
|
|
24
24
|
|
|
25
|
+
import { homedir } from 'node:os';
|
|
26
|
+
import { join } from 'node:path';
|
|
27
|
+
|
|
25
28
|
export const MAX_COMMANDS = 5;
|
|
26
29
|
const MAX_REASON = 300;
|
|
27
30
|
const MAX_LINE = 500;
|
|
@@ -33,10 +36,14 @@ function clip(text, max) {
|
|
|
33
36
|
return text.length <= max ? text : `${text.slice(0, max - 1).trimEnd()}…`;
|
|
34
37
|
}
|
|
35
38
|
|
|
36
|
-
// A line split into words the way sh would, without anything sh would expand
|
|
37
|
-
//
|
|
39
|
+
// A line split into words the way sh would, without anything sh would expand but one: `~` or
|
|
40
|
+
// `~/…` opening a word is the human's home folder. MADRE's privacy guard writes every home path
|
|
41
|
+
// that way in the replies agents read, so an agent can only name a file there as `~/…`. Returns
|
|
42
|
+
// { argv, home } (home: the words to expand when the line runs, see expandHome) or { why } when the
|
|
43
|
+
// line needs a shell to mean what it says.
|
|
38
44
|
export function splitCommand(line) {
|
|
39
45
|
const argv = [];
|
|
46
|
+
const home = [];
|
|
40
47
|
let word = '';
|
|
41
48
|
let inWord = false;
|
|
42
49
|
let quote = null;
|
|
@@ -65,7 +72,11 @@ export function splitCommand(line) {
|
|
|
65
72
|
if ('|&;<>()'.includes(char)) return { why: 'uses "{char}", which needs a shell; put each command on its own line', whyArgs: { char } };
|
|
66
73
|
if (char === '$' || char === '`') return { why: 'expands a variable or a command; there is no shell to expand it' };
|
|
67
74
|
if (char === '*' || char === '?' || char === '[') return { why: 'uses "{char}" as a glob; there is no shell to expand it, name the files', whyArgs: { char } };
|
|
68
|
-
if (char === '~' && !inWord)
|
|
75
|
+
if (char === '~' && !inWord) {
|
|
76
|
+
const next = line[i + 1];
|
|
77
|
+
if (next !== undefined && next !== '/' && !/\s/.test(next)) return { why: 'names another user\'s home with "~"; there is no shell to expand it, write the path' };
|
|
78
|
+
home.push(argv.length);
|
|
79
|
+
}
|
|
69
80
|
if (char === '#' && !inWord) return { why: 'carries a "#" comment, which only a shell would drop; say it in the reason' };
|
|
70
81
|
word += char;
|
|
71
82
|
inWord = true;
|
|
@@ -75,7 +86,14 @@ export function splitCommand(line) {
|
|
|
75
86
|
if (!argv.length) return { why: 'is empty' };
|
|
76
87
|
if (BUILTINS.has(argv[0])) return { why: '"{name}" is a shell builtin; every line already runs from the project root', whyArgs: { name: argv[0] } };
|
|
77
88
|
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(argv[0])) return { why: 'sets an environment variable, which needs a shell' };
|
|
78
|
-
return { argv };
|
|
89
|
+
return home.length ? { argv, home } : { argv };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// The words that run. The record keeps `~` (it is what the human read, and the home path stays
|
|
93
|
+
// out of the ledger); the folder is put in only here, when the line is spawned.
|
|
94
|
+
export function expandHome(command, folder = homedir()) {
|
|
95
|
+
const marked = new Set(command?.home ?? []);
|
|
96
|
+
return (command?.argv ?? []).map((word, index) => (marked.has(index) ? (word === '~' ? folder : join(folder, word.slice(2))) : word));
|
|
79
97
|
}
|
|
80
98
|
|
|
81
99
|
// A refusal is a template so the room can say it in the human's language; this fills it in.
|
|
@@ -102,7 +120,7 @@ export function parseRunRequest(text) {
|
|
|
102
120
|
if (commands.length === MAX_COMMANDS) break;
|
|
103
121
|
if (line.length > MAX_LINE) { commands.push({ line: clip(line, MAX_LINE), why: 'is too long to read before pressing it' }); continue; }
|
|
104
122
|
const split = splitCommand(line);
|
|
105
|
-
commands.push(split.argv ? { line, argv: split.argv } : { line, why: split.why, ...(split.whyArgs ? { whyArgs: split.whyArgs } : {}) });
|
|
123
|
+
commands.push(split.argv ? { line, argv: split.argv, ...(split.home ? { home: split.home } : {}) } : { line, why: split.why, ...(split.whyArgs ? { whyArgs: split.whyArgs } : {}) });
|
|
106
124
|
}
|
|
107
125
|
// A request without a reason asks the human to run something on faith.
|
|
108
126
|
if (!reason || !commands.length) return null;
|
package/src/salas.mjs
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
// Salas on the Mac (docs/MACROVERSO.md, phase 1): every room this Mac knows, open or asleep, and
|
|
2
|
+
// what one room may ask of another — to open, to close. The list is read from what each room
|
|
3
|
+
// leaves beside its record; nothing here touches a room's ledger or its memory.
|
|
4
|
+
//
|
|
5
|
+
// A room opened from here is opened the way `madre start` opens it, by the same `bin/madre.mjs`,
|
|
6
|
+
// without a shell: the folder is an argument, never a line for `sh` or `cmd.exe` to read
|
|
7
|
+
// (docs/WINDOWS.md, WIN-016), and the script is found with fileURLToPath, never `.pathname`
|
|
8
|
+
// (WIN-004). A room is closed by asking it to close, the way a signal closes it; nothing here
|
|
9
|
+
// kills a process (WIN-007).
|
|
10
|
+
|
|
11
|
+
import { spawn } from 'node:child_process';
|
|
12
|
+
import { createHash } from 'node:crypto';
|
|
13
|
+
import { closeSync, openSync, realpathSync } from 'node:fs';
|
|
14
|
+
import { mkdir, readdir, readFile, rename, stat } from 'node:fs/promises';
|
|
15
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
import { oneAtATime, writeWhole } from './json-file.mjs';
|
|
18
|
+
|
|
19
|
+
const MADRE_BIN = fileURLToPath(new URL('../bin/madre.mjs', import.meta.url));
|
|
20
|
+
|
|
21
|
+
const alive = (pid) => { try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; } };
|
|
22
|
+
const readJson = (file) => readFile(file, 'utf8').then((raw) => JSON.parse(raw)).catch(() => null);
|
|
23
|
+
|
|
24
|
+
// The id of a room: the project's folder name and a hash of its path. Written once, here, so
|
|
25
|
+
// the server, a link and the room of Salas all name a folder the same way (the server re-exports
|
|
26
|
+
// it). It cannot be turned back into the path, which is why `project.json` keeps it (S-002).
|
|
27
|
+
export function projectRoomId(projectRoot) {
|
|
28
|
+
const path = resolve(projectRoot);
|
|
29
|
+
const digest = createHash('sha256').update(path).digest('hex').slice(0, 16);
|
|
30
|
+
return `${basename(path) || 'root'}-${digest}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// The room of Salas (docs/MACROVERSO.md, D-S08): BISHOP's notebook is a folder of MADRE's own,
|
|
34
|
+
// `~/.pulse/salas`, and its room has the id any project in that folder would have. The room is
|
|
35
|
+
// made in phase 3d; its id is needed before that, because a link already has rules about it (a
|
|
36
|
+
// link never serves from it, and orders only reach the room at its other end).
|
|
37
|
+
//
|
|
38
|
+
// The id is the one `createPulseServer` would give the folder: it names a room by the real path
|
|
39
|
+
// of its project, so the state root is resolved the same way here (a `PULSE_HOME` under /tmp is
|
|
40
|
+
// really under /private/tmp on a Mac) and the id is the same before and after the folder exists.
|
|
41
|
+
export const salasProjectRoot = (stateRoot) => join(stateRoot, 'salas');
|
|
42
|
+
export function salasRoomId(stateRoot) {
|
|
43
|
+
return projectRoomId(realFolder(salasProjectRoot(stateRoot)));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// The real path of a folder; while it is not there yet, the real path of its parent and its name.
|
|
47
|
+
// Two spellings of one folder compare equal this way, with or without a link in the path, and a
|
|
48
|
+
// folder about to be made compares equal to itself once made. Never the text as given.
|
|
49
|
+
function realFolder(path) {
|
|
50
|
+
const given = resolve(path);
|
|
51
|
+
try { return realpathSync(given); } catch { /* not there yet */ }
|
|
52
|
+
let parent = dirname(given);
|
|
53
|
+
try { parent = realpathSync(parent); } catch { /* nor its parent: as given */ }
|
|
54
|
+
return join(parent, basename(given));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Is this project the notebook of Salas, that is, is this room the room of Salas (phase 3d)?
|
|
58
|
+
// Asked of real paths on both sides, as the server names its own project.
|
|
59
|
+
export function isSalasRoom(projectRoot, stateRoot) {
|
|
60
|
+
return realFolder(projectRoot) === realFolder(salasProjectRoot(stateRoot));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The notebook is made the first time something opens it, and made empty: a folder and nothing
|
|
64
|
+
// in it. What the room keeps (its record, its memory) stays beside its record, as for any room.
|
|
65
|
+
// A notebook already there is left as it is.
|
|
66
|
+
export async function ensureSalasProject(stateRoot) {
|
|
67
|
+
const folder = salasProjectRoot(stateRoot);
|
|
68
|
+
await mkdir(folder, { recursive: true });
|
|
69
|
+
return folder;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Which project a run of `madre` is about (D-S09, revised 2026-10-10), said once and with nothing
|
|
73
|
+
// started, so the rule can be tested as a rule. `--project` wins, always. The lobby — the process
|
|
74
|
+
// that serves the list of projects and where BISHOP lives, whose folder is MADRE's own
|
|
75
|
+
// `~/.pulse/salas` — is opened by `madre lobby` and `madre salas`, by a bare `madre` (or
|
|
76
|
+
// `npx madre`) with no command word at all, and by `madre start` from the home folder, the one
|
|
77
|
+
// folder that is nobody's project. `madre start` anywhere else opens that folder, as it always
|
|
78
|
+
// did. No guess about what looks like a project; `cwd` and `home` are compared as real paths.
|
|
79
|
+
// `named` is false only when no command word was written; left out, the command was written.
|
|
80
|
+
export function projectRootFor({ command, named = true, project, cwd, home, stateRoot }) {
|
|
81
|
+
if (typeof project === 'string' && project) return resolve(project);
|
|
82
|
+
const lobby = command === 'salas' || command === 'lobby' || (command === 'start' && !named) || realFolder(cwd) === realFolder(home);
|
|
83
|
+
if (lobby) return resolve(salasProjectRoot(stateRoot));
|
|
84
|
+
return resolve(cwd);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Where the project is, kept beside its record (S-002): the room's id is a hash of the path and
|
|
88
|
+
// cannot be turned back into it, and a room that is closed has to be found to be opened again.
|
|
89
|
+
// Written whole or not at all.
|
|
90
|
+
export async function markProject(roomDir, projectRoot, { now = new Date() } = {}) {
|
|
91
|
+
await mkdir(roomDir, { recursive: true });
|
|
92
|
+
const file = join(roomDir, 'project.json');
|
|
93
|
+
await oneAtATime(file, () => writeWhole(file, `${JSON.stringify({ root: projectRoot, name: basename(projectRoot), lastOpened: now.toISOString() })}\n`));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---- The lobby: adding projects (2026-10-10). A browser cannot hand MADRE the path of a folder,
|
|
97
|
+
// so the lobby browses the folders itself, from this Mac, inside the home folder and nowhere
|
|
98
|
+
// else: names of folders, and whether a folder looks like a project (a marker file is there),
|
|
99
|
+
// never what any file says. Associating a project only writes where it is beside its record, as
|
|
100
|
+
// `madre start` would the first time; nothing is started. A folder already used with MADRE gets
|
|
101
|
+
// the same id it had, so its conversations and its memory are there when it is entered.
|
|
102
|
+
const PROJECT_MARKS = ['.git', 'package.json', 'pyproject.toml', 'requirements.txt', 'Cargo.toml', 'go.mod', 'composer.json', 'Gemfile', 'pom.xml', 'build.gradle', 'Package.swift', 'deno.json', '.pulse'];
|
|
103
|
+
const NOT_PROJECTS = new Set(['node_modules', 'Library', 'Applications', 'Pictures', 'Music', 'Movies', 'Public', 'OneDrive', 'Dropbox']);
|
|
104
|
+
// Where people keep their projects, looked at one level deep for suggestions. MADRE speaks Spanish
|
|
105
|
+
// first, and so do the folders of many who use it (a Linux in Spanish names them Documentos and
|
|
106
|
+
// Escritorio).
|
|
107
|
+
export const PROJECT_HOMES = ['', 'Projects', 'projects', 'Proyectos', 'proyectos', 'Developer', 'dev', 'Dev', 'code', 'Code', 'src', 'repos', 'Repos', 'GitHub', 'git', 'work', 'Work', 'Sites', 'Documents', 'Documentos', 'Desktop', 'Escritorio'];
|
|
108
|
+
|
|
109
|
+
const inside = (child, parent) => child === parent || child.startsWith(parent.endsWith('/') ? parent : `${parent}/`);
|
|
110
|
+
async function looksLikeProject(folder) {
|
|
111
|
+
for (const mark of PROJECT_MARKS) if (await stat(join(folder, mark)).then(() => true, () => false)) return true;
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
// The folders already in the lobby, by where they are and by their id: a room on an older MADRE
|
|
115
|
+
// may be open without having written where its project is, and its id still names its folder.
|
|
116
|
+
async function knownRoots(stateRoot) {
|
|
117
|
+
const rooms = await knownRooms(stateRoot);
|
|
118
|
+
const roots = new Set(rooms.map((room) => room.root).filter(Boolean).map((root) => realFolder(root)));
|
|
119
|
+
const ids = new Set(rooms.map((room) => room.id));
|
|
120
|
+
return { has: (folder) => roots.has(folder) || ids.has(projectRoomId(folder)) };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// The folders inside one folder of the home, the projects first. Refused outside the home.
|
|
124
|
+
export async function listFolders({ path = null, home, stateRoot }) {
|
|
125
|
+
const top = realFolder(home);
|
|
126
|
+
const here = realFolder(path ?? home);
|
|
127
|
+
if (!inside(here, top)) return { error: 'outside' };
|
|
128
|
+
const info = await stat(here).catch(() => null);
|
|
129
|
+
if (!info?.isDirectory()) return { error: 'missing' };
|
|
130
|
+
const known = await knownRoots(stateRoot);
|
|
131
|
+
const state = realFolder(stateRoot);
|
|
132
|
+
const names = await readdir(here, { withFileTypes: true }).catch(() => []);
|
|
133
|
+
const entries = [];
|
|
134
|
+
for (const entry of names) {
|
|
135
|
+
if (!entry.isDirectory() || entry.name.startsWith('.') || NOT_PROJECTS.has(entry.name)) continue;
|
|
136
|
+
const full = join(here, entry.name);
|
|
137
|
+
if (inside(full, state)) continue;
|
|
138
|
+
entries.push({ name: entry.name, path: full, project: await looksLikeProject(full), known: known.has(full) });
|
|
139
|
+
}
|
|
140
|
+
entries.sort((a, b) => Number(b.project) - Number(a.project) || a.name.localeCompare(b.name));
|
|
141
|
+
return { path: here, home: top, parent: here === top ? null : dirname(here), entries: entries.slice(0, 400) };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Folders that look like projects in the places people keep them, newest first, none already here.
|
|
145
|
+
export async function suggestProjects({ home, stateRoot, limit = 24 }) {
|
|
146
|
+
const top = realFolder(home);
|
|
147
|
+
const known = await knownRoots(stateRoot);
|
|
148
|
+
const state = realFolder(stateRoot);
|
|
149
|
+
const seen = new Map();
|
|
150
|
+
const places = new Set(); // `Projects` and `projects` are one folder on a Mac: read it once
|
|
151
|
+
for (const place of PROJECT_HOMES) {
|
|
152
|
+
const base = join(top, place);
|
|
153
|
+
const info = await stat(base).catch(() => null);
|
|
154
|
+
if (!info?.isDirectory() || places.has(`${info.dev}:${info.ino}`)) continue;
|
|
155
|
+
places.add(`${info.dev}:${info.ino}`);
|
|
156
|
+
const names = await readdir(base, { withFileTypes: true }).catch(() => []);
|
|
157
|
+
for (const entry of names) {
|
|
158
|
+
if (!entry.isDirectory() || entry.name.startsWith('.') || NOT_PROJECTS.has(entry.name)) continue;
|
|
159
|
+
const full = join(base, entry.name);
|
|
160
|
+
if (seen.has(full) || known.has(full) || inside(full, state) || !(await looksLikeProject(full))) continue;
|
|
161
|
+
const when = (await stat(full).catch(() => null))?.mtimeMs ?? 0;
|
|
162
|
+
seen.set(full, { name: entry.name, path: full, when });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return [...seen.values()].sort((a, b) => b.when - a.when).slice(0, limit).map(({ name, path }) => ({ name, path }));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Associates one folder with the lobby: a project inside the home, not the home itself and not
|
|
169
|
+
// MADRE's own state. Its record says where it is; a record already there keeps when it was last
|
|
170
|
+
// opened. Answers the room's id.
|
|
171
|
+
export async function associateProject({ root, home, stateRoot, now = new Date() }) {
|
|
172
|
+
const folder = realFolder(String(root ?? ''));
|
|
173
|
+
const top = realFolder(home);
|
|
174
|
+
if (!root || !inside(folder, top) || folder === top) return { error: 'outside' };
|
|
175
|
+
if (inside(folder, realFolder(stateRoot))) return { error: 'madre' };
|
|
176
|
+
const info = await stat(folder).catch(() => null);
|
|
177
|
+
if (!info?.isDirectory()) return { error: 'missing' };
|
|
178
|
+
const id = projectRoomId(folder);
|
|
179
|
+
const roomDir = join(stateRoot, 'rooms', id);
|
|
180
|
+
await mkdir(roomDir, { recursive: true });
|
|
181
|
+
const file = join(roomDir, 'project.json');
|
|
182
|
+
const before = await oneAtATime(file, async () => {
|
|
183
|
+
const was = await readJson(file);
|
|
184
|
+
await writeWhole(file, `${JSON.stringify({ root: folder, name: basename(folder), lastOpened: was?.lastOpened ?? null, added: was?.added ?? now.toISOString() })}\n`);
|
|
185
|
+
return was;
|
|
186
|
+
});
|
|
187
|
+
return { id, name: basename(folder), root: folder, already: Boolean(before) };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Takes a project out of MADRE (2026-10-10): its record of where it is goes aside, so Home no
|
|
191
|
+
// longer lists it. Nothing in the project's folder is touched, and its conversations and its
|
|
192
|
+
// memory stay beside the record: added again, the folder gets the same id and finds them there.
|
|
193
|
+
// A room that is open is closed first, by whoever calls; one with no record has nothing to forget.
|
|
194
|
+
export async function forgetProject({ stateRoot, id }) {
|
|
195
|
+
if (typeof id !== 'string' || !/^[^/\\]+$/.test(id) || id === '.' || id === '..') return { error: 'unknown' };
|
|
196
|
+
const roomDir = join(stateRoot, 'rooms', id);
|
|
197
|
+
const file = join(roomDir, 'project.json');
|
|
198
|
+
const record = await readJson(file);
|
|
199
|
+
if (!record) return { error: 'unknown' };
|
|
200
|
+
await rename(file, join(roomDir, 'project.forgotten.json'));
|
|
201
|
+
return { id, name: typeof record.name === 'string' ? record.name : id, root: typeof record.root === 'string' ? record.root : null };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Every room: the ones open now, and the ones asleep whose project is still on disk. A folder
|
|
205
|
+
// with neither (a test's, a temporary one, a room from before MADRE kept the path) is not
|
|
206
|
+
// offered: there is nothing to open (S-004).
|
|
207
|
+
export async function knownRooms(stateRoot) {
|
|
208
|
+
const dir = join(stateRoot, 'rooms');
|
|
209
|
+
const names = await readdir(dir).catch(() => []);
|
|
210
|
+
const out = [];
|
|
211
|
+
for (const id of names) {
|
|
212
|
+
const mark = await readJson(join(dir, id, 'open.json'));
|
|
213
|
+
const where = await readJson(join(dir, id, 'project.json'));
|
|
214
|
+
const open = mark && Number.isInteger(mark.pid) && Number.isInteger(mark.port) && mark.port > 0 && mark.port < 65536 && alive(mark.pid)
|
|
215
|
+
? { port: mark.port, pid: mark.pid, version: mark.version ?? null, at: mark.at ?? null }
|
|
216
|
+
: null;
|
|
217
|
+
const root = typeof where?.root === 'string' ? where.root : null;
|
|
218
|
+
const there = root ? await stat(root).then((info) => info.isDirectory()).catch(() => false) : false;
|
|
219
|
+
if (!open && !there) continue;
|
|
220
|
+
out.push({
|
|
221
|
+
id,
|
|
222
|
+
name: typeof where?.name === 'string' ? where.name : typeof mark?.project === 'string' ? mark.project : id.replace(/-[0-9a-f]{16}$/, ''),
|
|
223
|
+
root: there ? root : null,
|
|
224
|
+
lastOpened: where?.lastOpened ?? mark?.at ?? null,
|
|
225
|
+
open,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Opens a room for a project, as `madre start --project <root> --no-open` would. What it says on
|
|
232
|
+
// the way up lands in start.log beside its record: if it never comes up, that file says why.
|
|
233
|
+
export function startRoom({ projectRoot, roomDir, env = process.env, spawnImpl = spawn, bin = MADRE_BIN }) {
|
|
234
|
+
let log = 'ignore';
|
|
235
|
+
try { log = openSync(join(roomDir, 'start.log'), 'a'); } catch { /* no witness, still start */ }
|
|
236
|
+
// The new room is its own: what this room set for itself on the way up is not handed on.
|
|
237
|
+
const own = { ...env };
|
|
238
|
+
for (const name of ['PULSE_UPDATE_RESTART', 'PULSE_OUTBOUND_LOG']) delete own[name];
|
|
239
|
+
try {
|
|
240
|
+
const child = spawnImpl(process.execPath, [bin, 'start', '--project', projectRoot, '--no-open'], {
|
|
241
|
+
cwd: projectRoot,
|
|
242
|
+
detached: process.platform !== 'win32',
|
|
243
|
+
windowsHide: true,
|
|
244
|
+
stdio: ['ignore', log, log],
|
|
245
|
+
env: own,
|
|
246
|
+
});
|
|
247
|
+
child.unref?.();
|
|
248
|
+
return child;
|
|
249
|
+
} finally {
|
|
250
|
+
if (typeof log === 'number') { try { closeSync(log); } catch { /* the child keeps its own copy */ } }
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Waits until the room for that id is open and answers, or the time runs out.
|
|
255
|
+
// A minute (2026-10-12): a Mac busy with other projects took over 25 s to bring one up, and Home
|
|
256
|
+
// said it did not open while it was opening.
|
|
257
|
+
export async function roomComesUp(stateRoot, id, { timeoutMs = 60000, everyMs = 300, fetchImpl = globalThis.fetch } = {}) {
|
|
258
|
+
const until = Date.now() + timeoutMs;
|
|
259
|
+
while (Date.now() < until) {
|
|
260
|
+
const room = (await knownRooms(stateRoot)).find((one) => one.id === id);
|
|
261
|
+
if (room?.open) {
|
|
262
|
+
const answers = await fetchImpl(`http://127.0.0.1:${room.open.port}/api/version`, { signal: AbortSignal.timeout(1000) }).then((response) => response.ok).catch(() => false);
|
|
263
|
+
if (answers) return room;
|
|
264
|
+
}
|
|
265
|
+
await new Promise((resolve) => setTimeout(resolve, everyMs));
|
|
266
|
+
}
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// What the Macroverso needs of a room's archive to draw it as a NOSTROMO, and nothing more. Without
|
|
271
|
+
// `full`, no memory's words leave their room: a far universe is drawn from kinds, zones, how much
|
|
272
|
+
// ledger each memory covers, how often it was reached for, and the links between them. Only the
|
|
273
|
+
// chosen universe asks for the words, and only the Mac may ask (the route refuses a phone).
|
|
274
|
+
export function universeOf(research, { full = false } = {}) {
|
|
275
|
+
const memories = (research?.memories ?? []).map((memory) => ({
|
|
276
|
+
id: memory.id,
|
|
277
|
+
kind: memory.kind,
|
|
278
|
+
zone: memory.zone ?? 'hold',
|
|
279
|
+
fromSequence: memory.fromSequence ?? 0,
|
|
280
|
+
throughSequence: memory.throughSequence ?? 0,
|
|
281
|
+
sourcesCount: Array.isArray(memory.sources) ? memory.sources.length : 0,
|
|
282
|
+
recalled: memory.recalled ?? 0,
|
|
283
|
+
lastRecalled: memory.lastRecalled ?? null,
|
|
284
|
+
cold: memory.cold ?? null,
|
|
285
|
+
...(full ? { text: memory.text ?? '', agent: memory.agent ?? null, origin: memory.origin ?? null, created: memory.created ?? null } : {}),
|
|
286
|
+
}));
|
|
287
|
+
const ids = new Set(memories.map((memory) => memory.id));
|
|
288
|
+
const links = (research?.links ?? []).filter((link) => ids.has(link.a) && ids.has(link.b)).map((link) => ({ a: link.a, b: link.b, weight: link.weight ?? 0.5 }));
|
|
289
|
+
return {
|
|
290
|
+
memories,
|
|
291
|
+
links,
|
|
292
|
+
maturity: research?.maturity ?? null,
|
|
293
|
+
stats: { entries: research?.stats?.entries ?? 0, embeddings: research?.stats?.embeddings ?? 0 },
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// What a room says about itself, read from its own page's address. A room on an older MADRE
|
|
298
|
+
// answers 404 to the routes it does not have yet, and is said to be outdated (S-005).
|
|
299
|
+
export async function askRoom(port, path, { method = 'GET', body = null, fetchImpl = globalThis.fetch, timeoutMs = 1500 } = {}) {
|
|
300
|
+
try {
|
|
301
|
+
const response = await fetchImpl(`http://127.0.0.1:${port}${path}`, {
|
|
302
|
+
method,
|
|
303
|
+
headers: body ? { 'content-type': 'application/json' } : {},
|
|
304
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
305
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
306
|
+
});
|
|
307
|
+
if (response.status === 404) return { outdated: true };
|
|
308
|
+
const answer = await response.json().catch(() => ({}));
|
|
309
|
+
return response.ok ? answer : { unreachable: true, status: response.status, error: answer?.error ?? null };
|
|
310
|
+
} catch {
|
|
311
|
+
return { unreachable: true };
|
|
312
|
+
}
|
|
313
|
+
}
|
package/src/sentinel-errors.mjs
CHANGED
|
@@ -173,7 +173,7 @@ export class ErrorSentinel {
|
|
|
173
173
|
`**Agents** ${env.agents.join(', ') || 'none detected'}`,
|
|
174
174
|
`**Kind** ${report.kind} · **Fingerprint** \`${report.fingerprint}\` · **Seen** ${report.count}× since ${report.at}`,
|
|
175
175
|
'',
|
|
176
|
-
'### What
|
|
176
|
+
'### What Diagnostics recorded',
|
|
177
177
|
'```', report.error.slice(0, 3000), '```',
|
|
178
178
|
'',
|
|
179
179
|
'### What I was doing',
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// The servers sentinel: what this project has listening, kept watch over while the room is open.
|
|
2
|
+
//
|
|
3
|
+
// RIPLEY opens a project's own dev server; this is what knows which servers those are. Only the
|
|
4
|
+
// project's: a process counts when the folder it was started in is the project or anything under
|
|
5
|
+
// it. Everything else listening on the machine — another project's server, an editor, a database —
|
|
6
|
+
// is counted and never shown, because a room that lists somebody else's server invites the human
|
|
7
|
+
// to work on the wrong thing, and a room that frames it shows them something that is not theirs.
|
|
8
|
+
//
|
|
9
|
+
// It watches the way the error sentinel does, from outside and without taking a turn. Every few
|
|
10
|
+
// seconds, only while someone is looking at the room and RIPLEY is on, it reads what listens and
|
|
11
|
+
// says what changed: a server that came up, one that went down. Nothing it says is written to the
|
|
12
|
+
// ledger; a port opening is not something the conversation said.
|
|
13
|
+
//
|
|
14
|
+
// Read-only like everything about ports: `lsof` reports and changes nothing, an HTTP GET to the
|
|
15
|
+
// root asks a server what it is, and MADRE starts and stops no server of its own here.
|
|
16
|
+
|
|
17
|
+
import { classifyListening, inProject, parseCwds } from './ports.mjs';
|
|
18
|
+
|
|
19
|
+
export const SERVER_WATCH_MS = 5000;
|
|
20
|
+
// More than this is a machine in trouble or a test suite, not a project's servers.
|
|
21
|
+
const MAX_PROBED = 24;
|
|
22
|
+
|
|
23
|
+
const byPort = (a, b) => a.port - b.port;
|
|
24
|
+
|
|
25
|
+
export class ServerSentinel {
|
|
26
|
+
#projectRoot;
|
|
27
|
+
#self;
|
|
28
|
+
#listen;
|
|
29
|
+
#cwdsOf;
|
|
30
|
+
#probe;
|
|
31
|
+
#isRoom;
|
|
32
|
+
#emit;
|
|
33
|
+
#active;
|
|
34
|
+
#intervalMs;
|
|
35
|
+
#servers = new Map(); // port -> what the project has answering there
|
|
36
|
+
#notHttp = new Set(); // `${port}:${pid}` not to offer (no HTTP, or a MADRE room): not asked again
|
|
37
|
+
#wider = [];
|
|
38
|
+
#others = 0;
|
|
39
|
+
#primed = false;
|
|
40
|
+
#scanning = null;
|
|
41
|
+
#timer = null;
|
|
42
|
+
|
|
43
|
+
// `isRoom` asks whether a port is a MADRE room — this one on another port, or a second one
|
|
44
|
+
// opened on the same project. Started in the project folder, it would pass for the project's own
|
|
45
|
+
// server; it is the room, and the list is for what the human is building.
|
|
46
|
+
constructor({ projectRoot, self = () => null, listen, cwdsOf, probe, isRoom = async () => false, emit = () => {}, active = async () => true, intervalMs = SERVER_WATCH_MS }) {
|
|
47
|
+
this.#projectRoot = projectRoot;
|
|
48
|
+
this.#self = self;
|
|
49
|
+
this.#listen = listen;
|
|
50
|
+
this.#cwdsOf = cwdsOf;
|
|
51
|
+
this.#probe = probe;
|
|
52
|
+
this.#isRoom = isRoom;
|
|
53
|
+
this.#emit = emit;
|
|
54
|
+
this.#active = active;
|
|
55
|
+
this.#intervalMs = intervalMs;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// What the project has listening, as last read.
|
|
59
|
+
view(probed = true) {
|
|
60
|
+
return { ports: [...this.#servers.values()].sort(byPort), wider: this.#wider, others: this.#others, probed };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// One reading at a time: a page asking while the watch is reading gets that same reading.
|
|
64
|
+
scan() {
|
|
65
|
+
this.#scanning ??= this.#read().finally(() => { this.#scanning = null; });
|
|
66
|
+
return this.#scanning;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async #read() {
|
|
70
|
+
let listening;
|
|
71
|
+
try { listening = classifyListening(await this.#listen(), { self: this.#self() }); }
|
|
72
|
+
catch { return this.view(false); }
|
|
73
|
+
const everyone = [...listening.ports, ...listening.wider];
|
|
74
|
+
const pids = [...new Set(everyone.map((one) => one.pid))];
|
|
75
|
+
const cwds = pids.length ? parseCwds(await this.#cwdsOf(pids).catch(() => '')) : new Map();
|
|
76
|
+
const mine = (one) => inProject(cwds.get(one.pid), this.#projectRoot);
|
|
77
|
+
const own = listening.ports.filter(mine).slice(0, MAX_PROBED);
|
|
78
|
+
const wider = listening.wider.filter(mine).map(({ port, command }) => ({ port, command }));
|
|
79
|
+
|
|
80
|
+
const next = new Map();
|
|
81
|
+
for (const found of own) {
|
|
82
|
+
const known = this.#servers.get(found.port);
|
|
83
|
+
// Same port, same process: already asked what it is.
|
|
84
|
+
if (known && known.pid === found.pid) { next.set(found.port, known); continue; }
|
|
85
|
+
const key = `${found.port}:${found.pid}`;
|
|
86
|
+
if (this.#notHttp.has(key)) continue;
|
|
87
|
+
const probe = await this.#probe(found.port).catch(() => ({ ok: false }));
|
|
88
|
+
if (!probe.ok || await this.#isRoom(found.port).catch(() => false)) { this.#notHttp.add(key); continue; }
|
|
89
|
+
next.set(found.port, { ...found, status: probe.status, html: probe.html, contentType: probe.contentType, framable: probe.framable, here: true });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const started = [...next.values()].filter((one) => this.#servers.get(one.port)?.pid !== one.pid);
|
|
93
|
+
const stopped = [...this.#servers.values()].filter((one) => next.get(one.port)?.pid !== one.pid);
|
|
94
|
+
const widerChanged = JSON.stringify(wider) !== JSON.stringify(this.#wider);
|
|
95
|
+
const others = everyone.length - own.length - wider.length;
|
|
96
|
+
const changed = started.length > 0 || stopped.length > 0 || widerChanged || others !== this.#others;
|
|
97
|
+
this.#servers = next;
|
|
98
|
+
this.#wider = wider;
|
|
99
|
+
this.#others = others;
|
|
100
|
+
// The first reading is the state of things, not news: nothing "started" because a page opened.
|
|
101
|
+
if (this.#primed) {
|
|
102
|
+
for (const one of stopped) this.#emit('server.stopped', { port: one.port, command: one.command });
|
|
103
|
+
for (const one of started) this.#emit('server.started', { port: one.port, command: one.command, framable: one.framable });
|
|
104
|
+
}
|
|
105
|
+
if (changed || !this.#primed) this.#emit('servers.changed', this.view());
|
|
106
|
+
this.#primed = true;
|
|
107
|
+
return this.view();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// The watch runs on its own clock and reads only when asked to be active; a room nobody is
|
|
111
|
+
// looking at, or with RIPLEY off, costs one cheap question every few seconds and nothing else.
|
|
112
|
+
start() {
|
|
113
|
+
if (this.#timer) return;
|
|
114
|
+
this.#timer = setInterval(() => {
|
|
115
|
+
void (async () => { if (await this.#active().catch(() => false)) await this.scan(); })().catch(() => null);
|
|
116
|
+
}, this.#intervalMs);
|
|
117
|
+
this.#timer.unref?.();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
stop() {
|
|
121
|
+
clearInterval(this.#timer);
|
|
122
|
+
this.#timer = null;
|
|
123
|
+
}
|
|
124
|
+
}
|