@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/github.mjs
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
// GitHub, as the GITHUB module and its MCP server both speak to it.
|
|
2
|
+
//
|
|
3
|
+
// Two ways in, and the order matters. First the `gh` CLI, if this computer has it and it is
|
|
4
|
+
// signed in: then MADRE holds no credential at all, the session lives where gh keeps it, and gh
|
|
5
|
+
// makes the request. Only without one, a fine-grained token the human pasted into MADRE's vault,
|
|
6
|
+
// sent as `Authorization: Bearer` from this process. Neither is ever written by this file: not
|
|
7
|
+
// in a result, not in an error, not in the outbound log.
|
|
8
|
+
//
|
|
9
|
+
// Everything that runs a process goes through `client.run`, without a shell and with an
|
|
10
|
+
// argument array (docs/WINDOWS.md, WIN-016): a title or a comment is somebody's words, and a
|
|
11
|
+
// shell would read `&`, `|` and `%VAR%` inside them. A `gh` that resolves to a .cmd or .bat shim
|
|
12
|
+
// cannot be run that way, so it is treated as absent and the token path is offered instead.
|
|
13
|
+
//
|
|
14
|
+
// What comes back is trimmed to what a person or an agent reads: numbers, names, states, links.
|
|
15
|
+
// GitHub's own payloads are large, and a turn pays for every character it is handed.
|
|
16
|
+
|
|
17
|
+
import { spawn } from 'node:child_process';
|
|
18
|
+
import { findExecutable } from './runtime-detection.mjs';
|
|
19
|
+
|
|
20
|
+
export const API_HOST = 'api.github.com';
|
|
21
|
+
const API = `https://${API_HOST}`;
|
|
22
|
+
const WEB_HOST = 'github.com';
|
|
23
|
+
|
|
24
|
+
// The four answers `src/outbound.mjs` asks of any destination. Here and not in the module
|
|
25
|
+
// because the MCP server is a process of its own and must declare the same thing to its log.
|
|
26
|
+
export const REACHES = [{
|
|
27
|
+
id: 'api',
|
|
28
|
+
host: API_HOST,
|
|
29
|
+
to: 'GitHub · the repository of this project, with your own account',
|
|
30
|
+
what: 'What you or an agent asked for: which pull requests, issues or checks to read, and — only when you confirm one or an agent in #4 AIRLOCK posts one — the title and text of a pull request, issue or comment. The credential is your gh session or your token.',
|
|
31
|
+
when: 'When you type /gh in the room or an agent uses a GitHub tool. Writing needs your confirm in the room, or a turn in #4 AIRLOCK.',
|
|
32
|
+
where: 'Modules › GitHub: switch it off, or forget its token, and nothing reaches GitHub.',
|
|
33
|
+
}];
|
|
34
|
+
|
|
35
|
+
// The seam every outside effect passes through, so a test can answer for git, gh and GitHub
|
|
36
|
+
// without any of them being installed and without a byte leaving the machine.
|
|
37
|
+
export const client = {
|
|
38
|
+
which: (name, env = process.env) => findExecutable([name], env.PATH ?? '', env),
|
|
39
|
+
run: runProcess,
|
|
40
|
+
fetch: (...args) => globalThis.fetch(...args),
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// One process, no shell, its output collected. `quiet` sends stdout nowhere: asking `gh auth
|
|
44
|
+
// token` whether a session exists must not bring the token into this process to find out.
|
|
45
|
+
export function runProcess(file, args, { input = null, env = process.env, cwd = undefined, timeout = 20000, quiet = false } = {}) {
|
|
46
|
+
return new Promise((resolve) => {
|
|
47
|
+
let child;
|
|
48
|
+
try {
|
|
49
|
+
child = spawn(file, args, { cwd, env, shell: false, windowsHide: true, stdio: [input == null ? 'ignore' : 'pipe', quiet ? 'ignore' : 'pipe', quiet ? 'ignore' : 'pipe'] });
|
|
50
|
+
} catch (error) {
|
|
51
|
+
resolve({ code: -1, stdout: '', stderr: '', error: error.code ?? 'SPAWN' });
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
let stdout = '';
|
|
55
|
+
let stderr = '';
|
|
56
|
+
let done = false;
|
|
57
|
+
const finish = (result) => { if (!done) { done = true; clearTimeout(timer); resolve(result); } };
|
|
58
|
+
const timer = setTimeout(() => { try { child.kill(); } catch { /* already gone */ } finish({ code: -1, stdout, stderr, error: 'TIMEOUT' }); }, timeout);
|
|
59
|
+
child.stdout?.setEncoding('utf8').on('data', (chunk) => { if (stdout.length < 4 * 1024 * 1024) stdout += chunk; });
|
|
60
|
+
child.stderr?.setEncoding('utf8').on('data', (chunk) => { if (stderr.length < 64 * 1024) stderr += chunk; });
|
|
61
|
+
child.on('error', (error) => finish({ code: -1, stdout, stderr, error: error.code ?? 'SPAWN' }));
|
|
62
|
+
child.on('close', (code) => finish({ code: code ?? -1, stdout, stderr }));
|
|
63
|
+
if (input != null) { child.stdin.on('error', () => {}); child.stdin.end(input); }
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── The repository this project is ────────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
const SEGMENT = /^[A-Za-z0-9_.-]+$/;
|
|
70
|
+
const GITHUB_HOSTS = new Set([WEB_HOST, `www.${WEB_HOST}`, `ssh.${WEB_HOST}`]);
|
|
71
|
+
|
|
72
|
+
// `owner/repo` out of whatever a git remote says, for github.com only. Enterprise hosts and every
|
|
73
|
+
// other forge answer null: the module would otherwise send a credential for github.com to them.
|
|
74
|
+
export function repoFromRemote(url) {
|
|
75
|
+
const text = String(url ?? '').trim();
|
|
76
|
+
if (!text) return null;
|
|
77
|
+
let host;
|
|
78
|
+
let path;
|
|
79
|
+
const scp = text.match(/^(?:[^@/\s]+@)?([^:/\s]+):(?!\/\/)(.+)$/); // git@github.com:owner/repo.git
|
|
80
|
+
if (scp && scp[2].includes('/')) {
|
|
81
|
+
[, host, path] = scp;
|
|
82
|
+
} else {
|
|
83
|
+
let parsed;
|
|
84
|
+
try { parsed = new URL(text); } catch { return null; }
|
|
85
|
+
if (!['https:', 'http:', 'ssh:', 'git:', 'git+ssh:', 'ssh+git:'].includes(parsed.protocol)) return null;
|
|
86
|
+
host = parsed.hostname;
|
|
87
|
+
path = decodeURIComponent(parsed.pathname);
|
|
88
|
+
}
|
|
89
|
+
if (!GITHUB_HOSTS.has(String(host).toLowerCase())) return null;
|
|
90
|
+
const parts = path.replace(/^\/+|\/+$/g, '').replace(/\.git$/i, '').split('/');
|
|
91
|
+
if (parts.length !== 2) return null;
|
|
92
|
+
const [owner, repo] = parts;
|
|
93
|
+
if (!SEGMENT.test(owner) || !SEGMENT.test(repo) || /^\.+$/.test(owner) || /^\.+$/.test(repo)) return null;
|
|
94
|
+
return { owner, repo, slug: `${owner}/${repo}` };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function git(root, args, env) {
|
|
98
|
+
const result = await client.run('git', ['-C', root, ...args], { env, timeout: 5000 });
|
|
99
|
+
return result.code === 0 ? result.stdout.trim() : null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// What the project says about itself: whether it is a repository, which remote is GitHub's, and
|
|
103
|
+
// the branch it is on. All of it read from git on this computer; nothing asks GitHub.
|
|
104
|
+
export async function projectRepo(root, { env = process.env } = {}) {
|
|
105
|
+
if (!root) return { git: false, repo: null, remote: null };
|
|
106
|
+
const top = await git(root, ['rev-parse', '--show-toplevel'], env);
|
|
107
|
+
if (!top) return { git: false, repo: null, remote: null };
|
|
108
|
+
const names = (await git(root, ['remote'], env) ?? '').split('\n').map((one) => one.trim()).filter(Boolean);
|
|
109
|
+
const order = [...names.filter((one) => one === 'origin'), ...names.filter((one) => one === 'upstream'), ...names.filter((one) => one !== 'origin' && one !== 'upstream')];
|
|
110
|
+
for (const remote of order) {
|
|
111
|
+
const repo = repoFromRemote(await git(root, ['remote', 'get-url', remote], env));
|
|
112
|
+
if (repo) return { git: true, repo, remote };
|
|
113
|
+
}
|
|
114
|
+
return { git: true, repo: null, remote: null, remotes: names.length };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function currentBranch(root, { env = process.env } = {}) {
|
|
118
|
+
const branch = await git(root, ['rev-parse', '--abbrev-ref', 'HEAD'], env);
|
|
119
|
+
return branch && branch !== 'HEAD' ? branch : null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Whether the branch is on a remote at all. A pull request from a branch GitHub has never seen
|
|
123
|
+
// is refused there with a sentence about refs; saying «push it first» here is kinder.
|
|
124
|
+
export async function upstreamOf(root, { env = process.env } = {}) {
|
|
125
|
+
return git(root, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], env);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── Which credential ──────────────────────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
// A `gh` this file can run, signed in to github.com. Both questions are answered from files gh
|
|
131
|
+
// keeps on this computer: who is signed in from its config, and — only when the config names
|
|
132
|
+
// nobody — whether it holds a token at all, with stdout sent nowhere so the token never arrives.
|
|
133
|
+
export async function ghSession({ env = process.env } = {}) {
|
|
134
|
+
// PULSE_GH=0 leaves gh out of it: the token is the only way in (and the suite asks nothing).
|
|
135
|
+
if (env.PULSE_GH === '0') return null;
|
|
136
|
+
const gh = await client.which('gh', env);
|
|
137
|
+
if (!gh || /\.(cmd|bat)$/i.test(gh)) return null;
|
|
138
|
+
const ghEnv = { ...env, GH_PROMPT_DISABLED: '1', GH_NO_UPDATE_NOTIFIER: '1', NO_COLOR: '1' };
|
|
139
|
+
const user = await client.run(gh, ['config', 'get', 'user', '-h', WEB_HOST], { env: ghEnv, timeout: 5000 });
|
|
140
|
+
const login = user.code === 0 ? user.stdout.trim().split('\n')[0].trim() : '';
|
|
141
|
+
if (login && /^[A-Za-z0-9-]+$/.test(login)) return { kind: 'gh', gh, login };
|
|
142
|
+
const held = await client.run(gh, ['auth', 'token', '-h', WEB_HOST], { env: ghEnv, timeout: 5000, quiet: true });
|
|
143
|
+
return held.code === 0 ? { kind: 'gh', gh, login: null } : null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Asked once a minute at most: a card is drawn often, and the answer changes when somebody signs
|
|
147
|
+
// in or out, which is not often.
|
|
148
|
+
const sessions = new Map();
|
|
149
|
+
export const forgetSession = () => sessions.clear();
|
|
150
|
+
export async function cachedSession({ env = process.env, ttlMs = 60000, now = Date.now() } = {}) {
|
|
151
|
+
const key = String(env.PATH ?? '');
|
|
152
|
+
const known = sessions.get(key);
|
|
153
|
+
if (known && now - known.at < ttlMs) return known.session;
|
|
154
|
+
const session = await ghSession({ env });
|
|
155
|
+
sessions.set(key, { at: now, session });
|
|
156
|
+
return session;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// The gh session when there is one, the token when there is not, null when neither.
|
|
160
|
+
export async function resolveAuth({ env = process.env, token = null, session = undefined } = {}) {
|
|
161
|
+
const gh = session === undefined ? await cachedSession({ env }) : session;
|
|
162
|
+
if (gh) return gh;
|
|
163
|
+
const value = String(token ?? '').trim();
|
|
164
|
+
return value ? { kind: 'token', token: value } : null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// What a card or a brief may say about the credential: never the credential.
|
|
168
|
+
export const authLabel = (auth) => (auth?.kind === 'gh' ? `gh · ${auth.login ? `@${auth.login}` : 'github.com'}` : auth?.kind === 'token' ? 'token' : null);
|
|
169
|
+
|
|
170
|
+
// ── One request ───────────────────────────────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
export class GithubError extends Error {
|
|
173
|
+
constructor(code, message, { status = null, detail = null } = {}) {
|
|
174
|
+
super(message);
|
|
175
|
+
this.code = code;
|
|
176
|
+
this.status = status;
|
|
177
|
+
this.detail = detail;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Fixed sentences, so a screen can say them in its own language. GitHub's own words, when it
|
|
182
|
+
// gives any, travel separately in `detail`.
|
|
183
|
+
export const ERRORS = {
|
|
184
|
+
UNAUTHORIZED_GH: 'GitHub did not accept the gh session on this computer. Sign in again with gh auth login.',
|
|
185
|
+
UNAUTHORIZED_TOKEN: 'GitHub did not accept the token. It may have expired or been revoked: make a new one and paste it in Modules › GitHub.',
|
|
186
|
+
NOT_FOUND: 'GitHub says it is not there: the repository or that number is not visible with this credential.',
|
|
187
|
+
RATE_LIMIT: 'This credential has used up its GitHub rate limit. Try again in a while.',
|
|
188
|
+
FORBIDDEN: 'This credential is not allowed to do that here. A fine-grained token needs that permission on this repository.',
|
|
189
|
+
INVALID: 'GitHub refused the request as it was written.',
|
|
190
|
+
NETWORK: 'GitHub could not be reached from this computer.',
|
|
191
|
+
NO_SESSION: 'gh is on this computer but not signed in. Sign in with gh auth login, or paste a token in Modules › GitHub.',
|
|
192
|
+
FAILED: 'GitHub answered with an error.',
|
|
193
|
+
INVALID_NUMBER: 'That is not a pull request or issue number.',
|
|
194
|
+
NO_BRANCH: 'There is no branch to ask about: the project is not on one.',
|
|
195
|
+
NO_TITLE: 'It needs a title.',
|
|
196
|
+
NO_BODY: 'There is nothing to post.',
|
|
197
|
+
NO_BASE: 'There is no base branch to open it against.',
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
function explain({ status, data, kind, rateLimited = false, stderr = '' }) {
|
|
201
|
+
const said = typeof data?.message === 'string' ? data.message.slice(0, 200) : null;
|
|
202
|
+
const errors = Array.isArray(data?.errors) ? data.errors.map((one) => (typeof one === 'string' ? one : one?.message ?? one?.code)).filter(Boolean).join('; ').slice(0, 300) : '';
|
|
203
|
+
const detail = [said, errors].filter(Boolean).join(' · ') || null;
|
|
204
|
+
if (status === 401) return new GithubError('UNAUTHORIZED', kind === 'gh' ? ERRORS.UNAUTHORIZED_GH : ERRORS.UNAUTHORIZED_TOKEN, { status });
|
|
205
|
+
if (status === 404) return new GithubError('NOT_FOUND', ERRORS.NOT_FOUND, { status });
|
|
206
|
+
if ((status === 403 || status === 429) && (rateLimited || /rate limit/i.test(`${said ?? ''} ${stderr}`))) return new GithubError('RATE_LIMIT', ERRORS.RATE_LIMIT, { status });
|
|
207
|
+
if (status === 403) return new GithubError('FORBIDDEN', ERRORS.FORBIDDEN, { status, detail });
|
|
208
|
+
if (status === 422) return new GithubError('INVALID', ERRORS.INVALID, { status, detail });
|
|
209
|
+
return new GithubError(status ? `HTTP_${status}` : 'FAILED', ERRORS.FAILED, { status, detail });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// `gh api --include` prints the status line and headers, a blank line, then the body.
|
|
213
|
+
export function parseIncluded(text) {
|
|
214
|
+
const raw = String(text ?? '');
|
|
215
|
+
const split = raw.search(/\r?\n\r?\n/);
|
|
216
|
+
const head = split >= 0 ? raw.slice(0, split) : raw;
|
|
217
|
+
const body = split >= 0 ? raw.slice(split).replace(/^\r?\n\r?\n/, '') : '';
|
|
218
|
+
const status = Number(head.match(/^HTTP\/[\d.]+\s+(\d{3})/)?.[1]) || null;
|
|
219
|
+
const remaining = head.match(/^x-ratelimit-remaining:\s*(\d+)/im)?.[1];
|
|
220
|
+
let data = null;
|
|
221
|
+
try { data = body.trim() ? JSON.parse(body) : null; } catch { data = null; }
|
|
222
|
+
return { status, data, rateLimited: remaining === '0' };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// One request to the REST API. `log` is an OutboundLog: gh makes its own connection, so its line
|
|
226
|
+
// is written here; fetch goes through the log's wrapper, which is the same wrapper the room
|
|
227
|
+
// already has installed — it notices that and does not count the request twice. Either way the
|
|
228
|
+
// line holds the method, the address, whether it went and how long it took. Never a body, never
|
|
229
|
+
// a header, never the credential.
|
|
230
|
+
export async function githubRequest({ method = 'GET', path, body = null, auth, log = null, env = process.env, timeoutMs = 20000 }) {
|
|
231
|
+
if (!auth) throw new GithubError('NO_CREDENTIAL', ERRORS.NO_SESSION);
|
|
232
|
+
if (!String(path ?? '').startsWith('/')) throw new GithubError('FAILED', ERRORS.FAILED);
|
|
233
|
+
const verb = String(method).toUpperCase();
|
|
234
|
+
const url = `${API}${path}`;
|
|
235
|
+
const started = Date.now();
|
|
236
|
+
if (auth.kind === 'gh') {
|
|
237
|
+
const args = ['api', '--method', verb, '--include', '-H', 'Accept: application/vnd.github+json', '-H', 'X-GitHub-Api-Version: 2022-11-28', path.replace(/^\/+/, '')];
|
|
238
|
+
if (body != null) args.push('--input', '-');
|
|
239
|
+
// GH_HOST pinned: a session for an enterprise host in the environment must not take this
|
|
240
|
+
// request anywhere but the host the module declared.
|
|
241
|
+
const result = await client.run(auth.gh, args, { env: { ...env, GH_HOST: WEB_HOST, GH_PROMPT_DISABLED: '1', GH_NO_UPDATE_NOTIFIER: '1', NO_COLOR: '1' }, input: body != null ? JSON.stringify(body) : null, timeout: timeoutMs });
|
|
242
|
+
const parsed = parseIncluded(result.stdout);
|
|
243
|
+
const ok = Boolean(parsed.status && parsed.status < 300);
|
|
244
|
+
log?.record({ url: url.split('?')[0], method: verb, status: parsed.status, ok, ms: Date.now() - started, ...(parsed.status ? {} : { error: result.error ?? 'GH' }) });
|
|
245
|
+
if (ok) return { status: parsed.status, data: parsed.data };
|
|
246
|
+
if (!parsed.status) {
|
|
247
|
+
if (/auth login|not logged in|authentication/i.test(result.stderr)) throw new GithubError('NO_SESSION', ERRORS.NO_SESSION);
|
|
248
|
+
throw new GithubError('NETWORK', ERRORS.NETWORK);
|
|
249
|
+
}
|
|
250
|
+
throw explain({ ...parsed, kind: 'gh', stderr: result.stderr });
|
|
251
|
+
}
|
|
252
|
+
// In the room, fetch is already watched and the request is logged there; wrapping it again
|
|
253
|
+
// would count it twice. In the MCP server's own process nothing watches it but this.
|
|
254
|
+
const send = log && !globalThis.fetch?.watched ? log.watch(client.fetch) : client.fetch;
|
|
255
|
+
let response;
|
|
256
|
+
try {
|
|
257
|
+
response = await send(url, {
|
|
258
|
+
method: verb,
|
|
259
|
+
headers: {
|
|
260
|
+
accept: 'application/vnd.github+json',
|
|
261
|
+
'x-github-api-version': '2022-11-28',
|
|
262
|
+
'user-agent': 'MADRE',
|
|
263
|
+
authorization: `Bearer ${auth.token}`,
|
|
264
|
+
...(body != null ? { 'content-type': 'application/json' } : {}),
|
|
265
|
+
},
|
|
266
|
+
...(body != null ? { body: JSON.stringify(body) } : {}),
|
|
267
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
268
|
+
});
|
|
269
|
+
} catch {
|
|
270
|
+
throw new GithubError('NETWORK', ERRORS.NETWORK);
|
|
271
|
+
}
|
|
272
|
+
const data = await response.json().catch(() => null);
|
|
273
|
+
if (response.ok) return { status: response.status, data };
|
|
274
|
+
throw explain({ status: response.status, data, kind: 'token', rateLimited: response.headers?.get?.('x-ratelimit-remaining') === '0' });
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ── What a person or an agent asks for, trimmed ───────────────────────────────────────────────
|
|
278
|
+
|
|
279
|
+
const cut = (text, max) => { const value = String(text ?? '').trim(); return value.length > max ? `${value.slice(0, max - 1)}…` : value; };
|
|
280
|
+
const base = (repo) => `/repos/${encodeURIComponent(repo.owner)}/${encodeURIComponent(repo.repo)}`;
|
|
281
|
+
const number = (value) => {
|
|
282
|
+
const n = Number(String(value ?? '').replace(/^#/, ''));
|
|
283
|
+
if (!Number.isInteger(n) || n < 1) throw new GithubError('INVALID_NUMBER', ERRORS.INVALID_NUMBER);
|
|
284
|
+
return n;
|
|
285
|
+
};
|
|
286
|
+
const per = (value, fallback, max = 50) => Math.min(Math.max(Number(value) || fallback, 1), max);
|
|
287
|
+
|
|
288
|
+
export async function getRepo({ repo, ...rest }) {
|
|
289
|
+
const { data } = await githubRequest({ path: base(repo), ...rest });
|
|
290
|
+
return { slug: repo.slug, defaultBranch: data?.default_branch ?? null, private: Boolean(data?.private), openIssuesAndPulls: data?.open_issues_count ?? null, url: data?.html_url ?? null };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export async function listPulls({ repo, limit = 20, ...rest }) {
|
|
294
|
+
const { data } = await githubRequest({ path: `${base(repo)}/pulls?state=open&per_page=${per(limit, 20, 100)}`, ...rest });
|
|
295
|
+
return (Array.isArray(data) ? data : []).map((pr) => ({
|
|
296
|
+
number: pr.number, title: cut(pr.title, 140), author: pr.user?.login ?? null, branch: pr.head?.ref ?? null, base: pr.base?.ref ?? null, draft: Boolean(pr.draft), updated: pr.updated_at ?? null, url: pr.html_url ?? null,
|
|
297
|
+
}));
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// The latest review of each reviewer decides; a comment after an approval does not undo it.
|
|
301
|
+
export function reviewState(reviews = []) {
|
|
302
|
+
const latest = new Map();
|
|
303
|
+
for (const review of Array.isArray(reviews) ? reviews : []) {
|
|
304
|
+
const who = review?.user?.login;
|
|
305
|
+
if (!who || !['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED'].includes(review.state)) continue;
|
|
306
|
+
latest.set(who, review.state);
|
|
307
|
+
}
|
|
308
|
+
const approved = [...latest].filter(([, state]) => state === 'APPROVED').map(([who]) => who);
|
|
309
|
+
const changes = [...latest].filter(([, state]) => state === 'CHANGES_REQUESTED').map(([who]) => who);
|
|
310
|
+
return { state: changes.length ? 'changes requested' : approved.length ? 'approved' : 'no review yet', approved, changesRequested: changes };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export async function getPull({ repo, number: n, ...rest }) {
|
|
314
|
+
const id = number(n);
|
|
315
|
+
const [{ data: pr }, { data: reviews }] = await Promise.all([
|
|
316
|
+
githubRequest({ path: `${base(repo)}/pulls/${id}`, ...rest }),
|
|
317
|
+
githubRequest({ path: `${base(repo)}/pulls/${id}/reviews?per_page=100`, ...rest }),
|
|
318
|
+
]);
|
|
319
|
+
return {
|
|
320
|
+
number: pr?.number ?? id,
|
|
321
|
+
title: cut(pr?.title, 200),
|
|
322
|
+
state: pr?.merged_at ? 'merged' : pr?.state ?? null,
|
|
323
|
+
draft: Boolean(pr?.draft),
|
|
324
|
+
author: pr?.user?.login ?? null,
|
|
325
|
+
branch: pr?.head?.ref ?? null,
|
|
326
|
+
base: pr?.base?.ref ?? null,
|
|
327
|
+
mergeable: pr?.mergeable ?? null,
|
|
328
|
+
mergeableState: pr?.mergeable_state ?? null,
|
|
329
|
+
additions: pr?.additions ?? null,
|
|
330
|
+
deletions: pr?.deletions ?? null,
|
|
331
|
+
changedFiles: pr?.changed_files ?? null,
|
|
332
|
+
commits: pr?.commits ?? null,
|
|
333
|
+
review: reviewState(reviews),
|
|
334
|
+
body: cut(pr?.body, 1200),
|
|
335
|
+
url: pr?.html_url ?? null,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// One word for a ref: the old commit statuses and the newer check runs, folded together. Any
|
|
340
|
+
// failure is a failure; anything still running is pending; nothing at all is said to be nothing.
|
|
341
|
+
export function foldChecks({ statuses = [], runs = [] }) {
|
|
342
|
+
const checks = [
|
|
343
|
+
...statuses.map((one) => ({ name: one.context ?? 'status', state: one.state === 'success' ? 'success' : one.state === 'pending' ? 'pending' : 'failure' })),
|
|
344
|
+
...runs.map((one) => ({ name: one.name ?? 'check', state: one.status !== 'completed' ? 'pending' : ['success', 'neutral', 'skipped'].includes(one.conclusion) ? 'success' : 'failure' })),
|
|
345
|
+
];
|
|
346
|
+
const state = !checks.length ? 'none' : checks.some((one) => one.state === 'failure') ? 'failure' : checks.some((one) => one.state === 'pending') ? 'pending' : 'success';
|
|
347
|
+
return { state, checks: checks.slice(0, 30) };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export async function ciStatus({ repo, ref, ...rest }) {
|
|
351
|
+
if (!ref) throw new GithubError('NO_BRANCH', ERRORS.NO_BRANCH);
|
|
352
|
+
const at = encodeURIComponent(ref);
|
|
353
|
+
const [{ data: combined }, { data: checks }] = await Promise.all([
|
|
354
|
+
githubRequest({ path: `${base(repo)}/commits/${at}/status`, ...rest }),
|
|
355
|
+
githubRequest({ path: `${base(repo)}/commits/${at}/check-runs?per_page=50`, ...rest }),
|
|
356
|
+
]);
|
|
357
|
+
return { ref, ...foldChecks({ statuses: combined?.statuses ?? [], runs: checks?.check_runs ?? [] }) };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export async function workflowRuns({ repo, branch, limit = 5, ...rest }) {
|
|
361
|
+
const query = `per_page=${per(limit, 5, 20)}${branch ? `&branch=${encodeURIComponent(branch)}` : ''}`;
|
|
362
|
+
const { data } = await githubRequest({ path: `${base(repo)}/actions/runs?${query}`, ...rest });
|
|
363
|
+
return (data?.workflow_runs ?? []).map((run) => ({
|
|
364
|
+
name: cut(run.name, 80), status: run.status ?? null, conclusion: run.conclusion ?? null, event: run.event ?? null, sha: String(run.head_sha ?? '').slice(0, 7), created: run.created_at ?? null, url: run.html_url ?? null,
|
|
365
|
+
}));
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export async function listIssues({ repo, limit = 20, ...rest }) {
|
|
369
|
+
const { data } = await githubRequest({ path: `${base(repo)}/issues?state=open&per_page=${per(limit, 20, 100)}`, ...rest });
|
|
370
|
+
// The issues endpoint lists pull requests too; they have their own list.
|
|
371
|
+
return (Array.isArray(data) ? data : []).filter((issue) => !issue.pull_request).map((issue) => ({
|
|
372
|
+
number: issue.number, title: cut(issue.title, 140), author: issue.user?.login ?? null, labels: (issue.labels ?? []).map((one) => (typeof one === 'string' ? one : one?.name)).filter(Boolean).slice(0, 6), comments: issue.comments ?? 0, updated: issue.updated_at ?? null, url: issue.html_url ?? null,
|
|
373
|
+
}));
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export async function getIssue({ repo, number: n, ...rest }) {
|
|
377
|
+
const { data: issue } = await githubRequest({ path: `${base(repo)}/issues/${number(n)}`, ...rest });
|
|
378
|
+
return {
|
|
379
|
+
number: issue?.number ?? number(n), title: cut(issue?.title, 200), state: issue?.state ?? null, author: issue?.user?.login ?? null,
|
|
380
|
+
labels: (issue?.labels ?? []).map((one) => (typeof one === 'string' ? one : one?.name)).filter(Boolean).slice(0, 10),
|
|
381
|
+
comments: issue?.comments ?? 0, isPullRequest: Boolean(issue?.pull_request), body: cut(issue?.body, 1500), url: issue?.html_url ?? null,
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// ── Writes. Each one posts under the human's name and does not come back. ─────────────────────
|
|
386
|
+
|
|
387
|
+
const required = (value, code) => {
|
|
388
|
+
const text = String(value ?? '').trim();
|
|
389
|
+
if (!text) throw new GithubError(code, ERRORS[code]);
|
|
390
|
+
return text;
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
export async function createPull({ repo, title, body = '', head, base: into = null, ...rest }) {
|
|
394
|
+
const target = into || (await getRepo({ repo, ...rest })).defaultBranch;
|
|
395
|
+
const { data } = await githubRequest({ method: 'POST', path: `${base(repo)}/pulls`, body: { title: required(title, 'NO_TITLE'), head: required(head, 'NO_BRANCH'), base: required(target, 'NO_BASE'), body: String(body ?? '') }, ...rest });
|
|
396
|
+
return { number: data?.number ?? null, url: data?.html_url ?? null, head, base: target };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export async function comment({ repo, number: n, body, ...rest }) {
|
|
400
|
+
const { data } = await githubRequest({ method: 'POST', path: `${base(repo)}/issues/${number(n)}/comments`, body: { body: required(body, 'NO_BODY') }, ...rest });
|
|
401
|
+
return { number: number(n), url: data?.html_url ?? null };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export async function createIssue({ repo, title, body = '', ...rest }) {
|
|
405
|
+
const { data } = await githubRequest({ method: 'POST', path: `${base(repo)}/issues`, body: { title: required(title, 'NO_TITLE'), body: String(body ?? '') }, ...rest });
|
|
406
|
+
return { number: data?.number ?? null, url: data?.html_url ?? null };
|
|
407
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Small files that more than one thing changes at once (2026-10-12, Lote 2 of the 1.0 review): the
|
|
2
|
+
// tour, the review ask, a project's record of where it is, the list of a room's conversations.
|
|
3
|
+
// Each was read, changed and written with nothing between two changes, so the second one wrote
|
|
4
|
+
// over the first — a rename lost to a message arriving, a chapter read lost to a tab saying it
|
|
5
|
+
// held the tour — and the drafts had a name per process, so two writes from the same room fought
|
|
6
|
+
// over one draft and one of them failed.
|
|
7
|
+
//
|
|
8
|
+
// `writeWhole` writes a file whole: a draft with a name of its own, renamed over it, so nobody
|
|
9
|
+
// ever reads half of one. `oneAtATime` runs one change of a file at a time: in this process by a
|
|
10
|
+
// queue, and between the rooms of this Mac by a lock file beside it, as config.json is kept
|
|
11
|
+
// (src/config.mjs). A lock left by a process that died is stale after a few seconds.
|
|
12
|
+
|
|
13
|
+
import { randomUUID } from 'node:crypto';
|
|
14
|
+
import { mkdir, open, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
15
|
+
import { dirname } from 'node:path';
|
|
16
|
+
|
|
17
|
+
export async function writeWhole(file, text, { mode } = {}) {
|
|
18
|
+
await mkdir(dirname(file), { recursive: true });
|
|
19
|
+
const draft = `${file}.${process.pid}.${randomUUID()}.tmp`;
|
|
20
|
+
try {
|
|
21
|
+
await writeFile(draft, text, mode ? { mode } : undefined);
|
|
22
|
+
await rename(draft, file);
|
|
23
|
+
} catch (error) {
|
|
24
|
+
await rm(draft, { force: true }).catch(() => {});
|
|
25
|
+
throw error;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const STALE_MS = 5000;
|
|
30
|
+
async function lockBeside(file) {
|
|
31
|
+
const lock = `${file}.lock`;
|
|
32
|
+
await mkdir(dirname(lock), { recursive: true });
|
|
33
|
+
for (let attempt = 0; attempt < 250; attempt += 1) {
|
|
34
|
+
try {
|
|
35
|
+
await (await open(lock, 'wx')).close();
|
|
36
|
+
return () => rm(lock, { force: true });
|
|
37
|
+
} catch (error) {
|
|
38
|
+
if (error.code !== 'EEXIST') throw error;
|
|
39
|
+
const since = await stat(lock).catch(() => null);
|
|
40
|
+
if (since && Date.now() - since.mtimeMs > STALE_MS) { await rm(lock, { force: true }); continue; }
|
|
41
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
throw new Error(`${file} is being changed by another room; try again.`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const queues = new Map();
|
|
48
|
+
export function oneAtATime(file, work) {
|
|
49
|
+
const run = async () => {
|
|
50
|
+
const release = await lockBeside(file);
|
|
51
|
+
try {
|
|
52
|
+
return await work();
|
|
53
|
+
} finally {
|
|
54
|
+
await release();
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
const before = queues.get(file) ?? Promise.resolve();
|
|
58
|
+
const turn = before.then(run, run);
|
|
59
|
+
const settled = turn.catch(() => {});
|
|
60
|
+
queues.set(file, settled);
|
|
61
|
+
void settled.then(() => { if (queues.get(file) === settled) queues.delete(file); });
|
|
62
|
+
return turn;
|
|
63
|
+
}
|
package/src/lease.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { mkdir, readdir, realpath, stat } from 'node:fs/promises';
|
|
2
2
|
import { join, relative, sep } from 'node:path';
|
|
3
|
-
import {
|
|
3
|
+
import { declaredTypeFor } from './files.mjs';
|
|
4
4
|
|
|
5
5
|
// A creation lease is the human saying, for one message (and the plan it may
|
|
6
6
|
// start): "these agents may create files, here". MADRE makes a fresh
|
|
@@ -90,7 +90,7 @@ export function diffSnapshots(before, after, { relativeDir }) {
|
|
|
90
90
|
name: name.split('/').pop(),
|
|
91
91
|
path: `${relativeDir.split(sep).join('/')}/${name}`,
|
|
92
92
|
size: info.size,
|
|
93
|
-
contentType:
|
|
93
|
+
contentType: declaredTypeFor(name),
|
|
94
94
|
status: previous ? 'changed' : 'created',
|
|
95
95
|
});
|
|
96
96
|
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// What was worked on last on this Mac, for the lobby (2026-10-10): the newest conversations of
|
|
2
|
+
// every room the lobby knows, open or asleep, each with its last few lines, and the summary MADRE
|
|
3
|
+
// writes of them with its local model. Everything here READS: a room's list of conversations
|
|
4
|
+
// (chats.json) and the end of a conversation's ledger, from the room's folder, never asking the
|
|
5
|
+
// room and never writing to it. The summary is checked against what it was given: a line that does
|
|
6
|
+
// not carry the number of a conversation it was given is dropped, and the project of each line is
|
|
7
|
+
// put by the lobby, from that number, never taken from the model's words.
|
|
8
|
+
|
|
9
|
+
import { open } from 'node:fs/promises';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { chatLedger, listChats } from './chats.mjs';
|
|
12
|
+
|
|
13
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
14
|
+
|
|
15
|
+
// The last messages of one conversation, read from the end of its ledger without reading all of
|
|
16
|
+
// it (a ledger also carries quota readings and turns, and grows for months). A cut first line is
|
|
17
|
+
// dropped; a line that is not a message is passed over; delegations between agents are the
|
|
18
|
+
// plan's own bookkeeping, not what was said.
|
|
19
|
+
export async function lastMessages(file, { limit = 4, window = 512 * 1024, maxChars = 360 } = {}) {
|
|
20
|
+
let handle = null;
|
|
21
|
+
try {
|
|
22
|
+
handle = await open(file, 'r');
|
|
23
|
+
const { size } = await handle.stat();
|
|
24
|
+
const start = Math.max(0, size - window);
|
|
25
|
+
const buffer = Buffer.alloc(size - start);
|
|
26
|
+
await handle.read(buffer, 0, buffer.length, start);
|
|
27
|
+
const lines = buffer.toString('utf8').split('\n');
|
|
28
|
+
if (start > 0) lines.shift();
|
|
29
|
+
const said = [];
|
|
30
|
+
for (let index = lines.length - 1; index >= 0 && said.length < limit; index -= 1) {
|
|
31
|
+
if (!lines[index].includes('"message.created"')) continue;
|
|
32
|
+
let event = null;
|
|
33
|
+
try { event = JSON.parse(lines[index]); } catch { continue; }
|
|
34
|
+
const payload = event?.payload ?? {};
|
|
35
|
+
if (event?.type !== 'message.created' || payload.status === 'delegated') continue;
|
|
36
|
+
// Read as words: without the marks a message is written in (bold, code, headings, links).
|
|
37
|
+
const text = String(payload.text ?? '').replace(/```[\s\S]*?```/g, ' ').replace(/\[([^\]]+)\]\([^)]+\)/g, '$1').replace(/(\*\*|__|`)/g, '').replace(/^#+\s*/gm, '').replace(/\s+/g, ' ').trim();
|
|
38
|
+
if (!text) continue;
|
|
39
|
+
const person = payload.role === 'user' || payload.sender === 'human';
|
|
40
|
+
said.unshift({ who: person ? 'person' : String(payload.sender ?? 'agent'), text: text.slice(0, maxChars), at: event.timestamp ?? null });
|
|
41
|
+
}
|
|
42
|
+
return said;
|
|
43
|
+
} catch {
|
|
44
|
+
return [];
|
|
45
|
+
} finally {
|
|
46
|
+
await handle?.close().catch(() => {});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// The newest conversations across the rooms, at most `perRoom` from each and `limit` in all, from
|
|
51
|
+
// the last `days` days; each with its last lines. `rooms` is [{ id, name }] as the lobby names them.
|
|
52
|
+
export async function recentWork({ stateRoot, rooms, now = Date.now(), days = 14, limit = 6, perRoom = 2 }) {
|
|
53
|
+
const since = now - days * DAY_MS;
|
|
54
|
+
const found = [];
|
|
55
|
+
for (const room of rooms) {
|
|
56
|
+
const dir = join(stateRoot, 'rooms', room.id);
|
|
57
|
+
const { chats } = await listChats(dir).catch(() => ({ chats: [] }));
|
|
58
|
+
const fresh = chats.filter((chat) => chat.messages > 0 && Date.parse(chat.updatedAt ?? 0) >= since).slice(0, perRoom);
|
|
59
|
+
for (const chat of fresh) found.push({ room: room.id, name: room.name, chat: chat.id, title: chat.title, at: chat.updatedAt, messages: chat.messages });
|
|
60
|
+
}
|
|
61
|
+
found.sort((a, b) => Date.parse(b.at) - Date.parse(a.at));
|
|
62
|
+
const newest = found.slice(0, limit);
|
|
63
|
+
for (const item of newest) item.said = await lastMessages(chatLedger(join(stateRoot, 'rooms', item.room), item.chat));
|
|
64
|
+
return newest;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// What MADRE is told: who it is writing for, one line per numbered conversation, and that it may
|
|
68
|
+
// only say what that conversation says. It never names the project: the lobby puts the name to
|
|
69
|
+
// each line by its number, so a small local model cannot give one project's work to another.
|
|
70
|
+
export const WORK_SYSTEM = {
|
|
71
|
+
es: 'Eres MADRE. Resumes para la persona lo último que se trabajó. Recibes conversaciones numeradas (#1, #2…). Para cada una escribe UNA línea con el formato `#número: frase`, en el mismo orden. La frase, corta y en pasado, dice qué se hizo o se decidió en ESA conversación y, si lo hay, qué quedó pendiente; si no queda claro, di de qué trata. Usa solo lo que dice esa conversación: no mezcles conversaciones ni inventes nombres, cifras o conclusiones. No escribas nada más.',
|
|
72
|
+
en: 'You are MADRE. You summarise for the person what was worked on last. You get numbered conversations (#1, #2…). For each one write ONE line in the form `#number: sentence`, in the same order. The sentence, short and in the past tense, says what was done or decided in THAT conversation and, if there is one, what was left pending; if it is not clear, say what it is about. Use only what that conversation says: do not mix conversations or invent names, figures or conclusions. Write nothing else.',
|
|
73
|
+
};
|
|
74
|
+
export function workPrompt(items, language = 'es') {
|
|
75
|
+
const person = language === 'es' ? 'Persona' : 'Person';
|
|
76
|
+
return items.map((item, index) => {
|
|
77
|
+
const lines = (item.said ?? []).map((one) => ` ${one.who === 'person' ? person : `@${one.who}`}: ${one.text}`);
|
|
78
|
+
return [`#${index + 1} · «${item.title}»`, ...lines].join('\n');
|
|
79
|
+
}).join('\n\n');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// The answer, as lines the lobby can show: each must carry the number of a conversation it was
|
|
83
|
+
// given, once; the project is that conversation's, never a word of the model's.
|
|
84
|
+
export function parseWork(text, items, { max = 5 } = {}) {
|
|
85
|
+
const seen = new Set();
|
|
86
|
+
const bullets = [];
|
|
87
|
+
for (const raw of String(text ?? '').split('\n')) {
|
|
88
|
+
const line = raw.replace(/\*\*/g, '').replace(/^\s*(?:[-*•·]\s*)?/, '').trim();
|
|
89
|
+
const match = /^#?\s*(\d{1,2})\s*[:.)\-–—]\s*(.+)$/.exec(line);
|
|
90
|
+
if (!match) continue;
|
|
91
|
+
const index = Number(match[1]) - 1;
|
|
92
|
+
const item = items[index];
|
|
93
|
+
if (!item || seen.has(index)) continue;
|
|
94
|
+
const said = match[2].replace(/^[«"“]|[»"”]$/g, '').trim();
|
|
95
|
+
// A line that only says the conversation's name again says nothing about the work.
|
|
96
|
+
const bare = (words) => String(words ?? '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
|
|
97
|
+
if (said.length < 8 || bare(said) === bare(item.title)) continue;
|
|
98
|
+
seen.add(index);
|
|
99
|
+
bullets.push({ room: item.room, name: item.name, text: said.slice(0, 280) });
|
|
100
|
+
if (bullets.length >= max) break;
|
|
101
|
+
}
|
|
102
|
+
return bullets;
|
|
103
|
+
}
|
package/src/looks.mjs
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// How the person wants MADRE to look, once for every page (2026-10-12). Each project is its own
|
|
2
|
+
// origin — another port — and Home is one more, so what a browser keeps for one is invisible to
|
|
3
|
+
// the others: the dark theme chosen in Home left every project light. The choices live here, in
|
|
4
|
+
// ~/.pulse/looks.json, beside tour.json and whats-new.json; every page reads them when it opens or
|
|
5
|
+
// comes back into view, and writes them when the person changes one. The browser's own copy stays,
|
|
6
|
+
// so a page paints in the last known look before it hears from here.
|
|
7
|
+
//
|
|
8
|
+
// Only what the pages share: the theme, the accent, the wallpaper and its dials, the bubbles and
|
|
9
|
+
// the text of the conversation. How Home lays out its own cards stays in Home's browser.
|
|
10
|
+
|
|
11
|
+
import { readFile } from 'node:fs/promises';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { oneAtATime, writeWhole } from './json-file.mjs';
|
|
14
|
+
|
|
15
|
+
const CHOICE = /^[a-z][a-z0-9-]{0,23}$/;
|
|
16
|
+
const NUMBER = /^\d{1,3}$/;
|
|
17
|
+
export const LOOK_KEYS = {
|
|
18
|
+
'pulse.theme': CHOICE,
|
|
19
|
+
'pulse.accent': CHOICE,
|
|
20
|
+
'pulse.wallpaper': CHOICE,
|
|
21
|
+
'pulse.wallpaper.style': CHOICE,
|
|
22
|
+
'pulse.wallpaper.veil': NUMBER,
|
|
23
|
+
'pulse.wallpaper.blur': NUMBER,
|
|
24
|
+
'pulse.bubbles': CHOICE,
|
|
25
|
+
'pulse.text': CHOICE,
|
|
26
|
+
};
|
|
27
|
+
const looksFile = (stateRoot) => join(stateRoot, 'looks.json');
|
|
28
|
+
|
|
29
|
+
// What is kept, read key by key: anything else in the file, or a value of the wrong shape, is not
|
|
30
|
+
// a look. `null` for a key means the first value — the room as it always was.
|
|
31
|
+
export async function readLooks(stateRoot) {
|
|
32
|
+
const raw = await readFile(looksFile(stateRoot), 'utf8').then((text) => JSON.parse(text)).catch(() => null);
|
|
33
|
+
const looks = {};
|
|
34
|
+
for (const [key, shape] of Object.entries(LOOK_KEYS)) {
|
|
35
|
+
const value = raw?.looks?.[key];
|
|
36
|
+
looks[key] = typeof value === 'string' && shape.test(value) ? value : null;
|
|
37
|
+
}
|
|
38
|
+
return { looks, saved: Boolean(raw && typeof raw.looks === 'object') };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// A change from a page: the keys it names, set or cleared. A key it does not know, or a value of
|
|
42
|
+
// the wrong shape, refuses the whole change rather than half of it.
|
|
43
|
+
export function changeLooks(stateRoot, patch, now = new Date()) {
|
|
44
|
+
return oneAtATime(looksFile(stateRoot), async () => {
|
|
45
|
+
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) return { error: 'looks' };
|
|
46
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
47
|
+
if (!Object.hasOwn(LOOK_KEYS, key)) return { error: 'key' };
|
|
48
|
+
if (value !== null && (typeof value !== 'string' || !LOOK_KEYS[key].test(value))) return { error: 'value' };
|
|
49
|
+
}
|
|
50
|
+
const { looks } = await readLooks(stateRoot);
|
|
51
|
+
Object.assign(looks, patch);
|
|
52
|
+
await writeWhole(looksFile(stateRoot), `${JSON.stringify({ looks, at: new Date(now).toISOString() }, null, 2)}\n`);
|
|
53
|
+
return { looks, saved: true };
|
|
54
|
+
});
|
|
55
|
+
}
|