@jossuealcala/madre 0.3.2 → 0.4.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 +425 -0
- package/CONTRIBUTING.md +6 -1
- package/README.md +67 -183
- package/SECURITY.md +2 -1
- package/bin/madre.mjs +56 -13
- package/docs/INTERNALS.md +16 -0
- package/docs/REFERENCE.md +249 -0
- package/docs/SDK.md +121 -0
- package/docs/room.png +0 -0
- package/docs/sdk/hello-module.mjs +51 -0
- package/package.json +9 -1
- package/public/app.js +3880 -849
- package/public/es.js +2050 -0
- package/public/i18n.js +66 -0
- package/public/index.html +95 -14
- package/public/inquiry.js +220 -0
- package/public/resay.js +77 -0
- package/public/styles.css +626 -68
- package/public/troubleshooting.js +173 -51
- package/src/adapters/claude.mjs +13 -6
- package/src/adapters/codex.mjs +17 -13
- package/src/adapters/gemini.mjs +27 -16
- package/src/adapters/opencode.mjs +16 -12
- package/src/adapters/process.mjs +17 -5
- package/src/asking.mjs +128 -0
- package/src/auth-probe.mjs +58 -1
- package/src/capabilities.mjs +4 -3
- package/src/chats.mjs +193 -0
- package/src/checkpoint.mjs +1 -1
- package/src/cold.mjs +56 -0
- package/src/commands.mjs +31 -3
- package/src/conversation-context.mjs +35 -3
- package/src/credentials.mjs +145 -0
- package/src/dataset.mjs +56 -4
- package/src/distiller.mjs +12 -5
- package/src/event-store.mjs +14 -8
- package/src/exam.mjs +240 -0
- package/src/extensions.mjs +3 -2
- package/src/eyecat-watch.mjs +100 -0
- package/src/eyecat.mjs +169 -0
- package/src/i18n.mjs +47 -0
- package/src/image-studio.mjs +2 -0
- package/src/launch.mjs +61 -0
- package/src/lease.mjs +5 -3
- package/src/maturity.mjs +94 -0
- package/src/mcp/image-server.mjs +12 -1
- package/src/mcp/memory-server.mjs +1 -1
- package/src/memory.mjs +325 -17
- package/src/modules/ahp.mjs +9 -7
- package/src/modules/ash.mjs +36 -0
- package/src/modules/git-pulse.mjs +6 -4
- package/src/modules/helpers.mjs +31 -0
- package/src/modules/image-studio.mjs +9 -4
- package/src/modules/index.mjs +143 -5
- package/src/modules/ollama.mjs +66 -10
- package/src/modules/playwright.mjs +90 -0
- package/src/modules/ripley.mjs +5 -3
- package/src/modules/sdk.mjs +104 -2
- package/src/modules/updates.mjs +81 -0
- package/src/ollama.mjs +5 -2
- package/src/outbound.mjs +292 -0
- package/src/privacy.mjs +54 -7
- package/src/room/context.mjs +4 -4
- package/src/room/control.mjs +4 -4
- package/src/room/economy.mjs +161 -0
- package/src/room/prompt.mjs +118 -43
- package/src/room.mjs +465 -58
- package/src/runtime-detection.mjs +27 -8
- package/src/server.mjs +724 -68
- package/src/setup.mjs +1 -1
- package/src/updates.mjs +17 -2
- package/src/usage-sentinel.mjs +13 -8
- package/src/verdict.mjs +74 -0
- package/src/ashcode.mjs +0 -64
- package/src/modules/ashcode.mjs +0 -28
package/src/server.mjs
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import http from 'node:http';
|
|
2
|
-
import { readFile, realpath } from 'node:fs/promises';
|
|
2
|
+
import { readFile, realpath, writeFile, mkdir, rm } from 'node:fs/promises';
|
|
3
|
+
import { rmSync } from 'node:fs';
|
|
3
4
|
import { createHash } from 'node:crypto';
|
|
4
5
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
5
6
|
import { fileURLToPath } from 'node:url';
|
|
6
7
|
import { homedir } from 'node:os';
|
|
7
8
|
import { execFile } from 'node:child_process';
|
|
8
|
-
import { detectAgents } from './runtime-detection.mjs';
|
|
9
|
+
import { detectAgents, toolsPrefix } from './runtime-detection.mjs';
|
|
9
10
|
import { EventStore } from './event-store.mjs';
|
|
10
11
|
import { RoomMemory } from './memory.mjs';
|
|
11
12
|
import { createEmbedder } from './embeddings.mjs';
|
|
@@ -13,9 +14,11 @@ import { memoryServerFor } from './memory-tools.mjs';
|
|
|
13
14
|
import { MotherChannel, CODE000_STRIKES } from './mother.mjs';
|
|
14
15
|
import { ErrorSentinel } from './sentinel-errors.mjs';
|
|
15
16
|
import { probeOllama, ollamaEmbedder, ollamaInvoker, pullModel } from './ollama.mjs';
|
|
16
|
-
import { moduleById, describeModules, findModuleRoute } from './modules/index.mjs';
|
|
17
|
+
import { moduleById, describeModules, findModuleRoute, toolsForTurn as modulesToolsForTurn, loadExternalModules, loadFailures, moduleFolders, moduleCommands, installModuleFile, installModuleText, verifyModuleText, moduleOrigin, removeExternalModule } from './modules/index.mjs';
|
|
17
18
|
import { madreAgent, madreInvoker, MADRE_AGENT_ID, MADRE_ADAPTER } from './adapters/madre.mjs';
|
|
18
19
|
import { exportDataset, readiness as datasetReadiness } from './dataset.mjs';
|
|
20
|
+
import { OutboundLog, outboundView, DEFAULT_REPORT_URL } from './outbound.mjs';
|
|
21
|
+
import { setLanguage as setRoomLanguage, t } from './i18n.mjs';
|
|
19
22
|
|
|
20
23
|
const PACKAGE = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8').catch(() => '{}'));
|
|
21
24
|
let crashHandlersInstalled = false;
|
|
@@ -34,22 +37,29 @@ export function injectRipleyBridge(body) {
|
|
|
34
37
|
return Buffer.from(RIPLEY_BRIDGE + text);
|
|
35
38
|
}
|
|
36
39
|
// The author's collector: SEND and AUTO-REPORT are available out of the box; AUTO-REPORT stays off until the human turns it on.
|
|
37
|
-
const DEFAULT_REPORT_URL = 'https://madre-reports.jossue-alcala-o.workers.dev/v1/reports';
|
|
38
40
|
import { QuotaMonitor } from './quota-monitor.mjs';
|
|
39
41
|
import { defaultQuotaSources } from './quota-sources.mjs';
|
|
40
42
|
import { Room } from './room.mjs';
|
|
41
43
|
import { applyConfigToEnv, loadConfig } from './config.mjs';
|
|
42
|
-
import { extensionById, runInstaller } from './extensions.mjs';
|
|
43
|
-
import { loginPlanFor, probeAll } from './auth-probe.mjs';
|
|
44
|
+
import { extensionById, findOnPath, runInstaller } from './extensions.mjs';
|
|
45
|
+
import { accountNoteFor, installPlanFor, looksLikeAdminProblem, loginPlanFor, probeAgentAuth, probeAll, TAKES_KEY } from './auth-probe.mjs';
|
|
46
|
+
import { applyKey, keyPlanFor } from './credentials.mjs';
|
|
44
47
|
import { loadConfig as readConfig, updateConfig } from './config.mjs';
|
|
45
48
|
import { Privacy, normalizeTerms, privacySettings } from './privacy.mjs';
|
|
46
|
-
import { checkForUpdate, detectInstall, updateCommand, releaseUrl } from './updates.mjs';
|
|
49
|
+
import { checkForUpdate, detectInstall, updateCommand, releaseUrl, applyCommand } from './updates.mjs';
|
|
50
|
+
import { moduleUpdate } from './modules/updates.mjs';
|
|
51
|
+
import { adoptStrays, chatIndex, chatLedger, createChat, deleteChat, isChatId, listChats, openChat as openChatIndex, projectFloor, renameChat, touchChat, MAIN_CHAT } from './chats.mjs';
|
|
52
|
+
import { spawn } from 'node:child_process';
|
|
47
53
|
import { totalmem } from 'node:os';
|
|
48
54
|
import { discoverModels } from './models.mjs';
|
|
49
55
|
import { setImageModule } from './capabilities.mjs';
|
|
50
56
|
import { resolveGeminiKey } from './image-studio.mjs';
|
|
51
57
|
import { commandByName, listCommands, parseCommand } from './commands.mjs';
|
|
52
58
|
import { listDirectory, searchFiles, readServable, storeAttachment, MAX_ATTACHMENT_BYTES } from './files.mjs';
|
|
59
|
+
import { Eyecat } from './eyecat-watch.mjs';
|
|
60
|
+
import { economy } from './room/economy.mjs';
|
|
61
|
+
import { maturity } from './maturity.mjs';
|
|
62
|
+
import { verdictFor } from './verdict.mjs';
|
|
53
63
|
|
|
54
64
|
const sourceDirectory = dirname(fileURLToPath(import.meta.url));
|
|
55
65
|
const publicDirectory = join(sourceDirectory, '..', 'public');
|
|
@@ -68,7 +78,7 @@ async function body(request) {
|
|
|
68
78
|
return JSON.parse(text || '{}');
|
|
69
79
|
}
|
|
70
80
|
|
|
71
|
-
function openUrl(url) {
|
|
81
|
+
export function openUrl(url) {
|
|
72
82
|
const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
|
|
73
83
|
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
|
|
74
84
|
execFile(command, args, () => {});
|
|
@@ -114,6 +124,11 @@ export async function createPulseServer({
|
|
|
114
124
|
// Tests substitute the real installers with local scripts.
|
|
115
125
|
installers = {},
|
|
116
126
|
loginRunners = {},
|
|
127
|
+
// Looking again for the CLIs on this computer. A test that hands a fixed roster also hands
|
|
128
|
+
// this, or the room keeps the roster it was given.
|
|
129
|
+
detect = null,
|
|
130
|
+
// Where a pasted provider key is written: each CLI's own place under the human's home.
|
|
131
|
+
credentialHome = homedir(),
|
|
117
132
|
probe = probeAll,
|
|
118
133
|
imageKey = resolveGeminiKey,
|
|
119
134
|
// The sentinel's outbound channel; tests hand in a fake.
|
|
@@ -124,14 +139,51 @@ export async function createPulseServer({
|
|
|
124
139
|
const root = stateRoot ?? process.env.PULSE_HOME ?? join(homedir(), '.pulse');
|
|
125
140
|
// ~/.pulse/config.json fills in whatever the environment did not set.
|
|
126
141
|
applyConfigToEnv(await loadConfig(root));
|
|
142
|
+
// What language the screens speak. The prompt the agents read is not one of them, and the
|
|
143
|
+
// environment wins over the file, as it does for every other setting here.
|
|
144
|
+
setRoomLanguage(process.env.PULSE_LANGUAGE ?? (await readConfig(root)).language);
|
|
127
145
|
// RIPLEY renders HTML in the viewer only while the human has it switched on; read live, the switch is the module's.
|
|
128
146
|
const ripleyOn = async () => Boolean((await readConfig(root)).modules?.ripley?.enabled);
|
|
129
147
|
agentTimeouts ??= agentTimeoutsFromEnv();
|
|
130
|
-
const
|
|
148
|
+
const detector = detect ?? (providedAgents ? null : detectAgents);
|
|
149
|
+
const agents = providedAgents ?? await detector();
|
|
131
150
|
const canonicalProjectRoot = await realpath(projectRoot).catch(() => resolve(projectRoot));
|
|
132
151
|
const roomDir = join(root, 'rooms', projectRoomId(canonicalProjectRoot));
|
|
133
|
-
|
|
134
|
-
|
|
152
|
+
// What left this machine. Every fetch this process makes — MADRE's own and any a module makes,
|
|
153
|
+
// because a module runs in here and cannot opt out — goes through one wrapper and lands in a
|
|
154
|
+
// line beside the ledger. Bodies, headers and query values are never in it. One room per
|
|
155
|
+
// process holds the watch; a second would only log what the first already logged.
|
|
156
|
+
const outbound = await new OutboundLog({ file: join(roomDir, 'outbound.jsonl') }).load();
|
|
157
|
+
const unwatchOutbound = outbound.install();
|
|
158
|
+
process.env.PULSE_OUTBOUND_LOG = join(roomDir, 'outbound.jsonl');
|
|
159
|
+
reportFetch = outbound.watch(reportFetch);
|
|
160
|
+
|
|
161
|
+
// Attachments live beside the room log, never inside the project, and belong to the project
|
|
162
|
+
// rather than to one conversation.
|
|
163
|
+
const attachmentsRoot = join(roomDir, 'attachments');
|
|
164
|
+
// One server per project, and now it is said out loud. A project numbers its exchanges once
|
|
165
|
+
// across every conversation, so two servers writing different conversations of the same project
|
|
166
|
+
// would hand out the same number twice and the archive would quietly keep one of them. The CLI
|
|
167
|
+
// already looked for a room nearby; a room that was started on a distant port walked past that
|
|
168
|
+
// check. This is the room itself saying it is taken.
|
|
169
|
+
const openMark = join(roomDir, 'open.json');
|
|
170
|
+
const alive = (pid) => { try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; } };
|
|
171
|
+
const held = await readFile(openMark, 'utf8').then((raw) => JSON.parse(raw)).catch(() => null);
|
|
172
|
+
if (held && Number.isInteger(held.pid) && held.pid !== process.pid && alive(held.pid)) {
|
|
173
|
+
const error = new Error(`MADRE is already open for this project at http://127.0.0.1:${held.port} (process ${held.pid}). Close that room first, or open another conversation inside it.`);
|
|
174
|
+
error.code = 'ROOM_IN_USE';
|
|
175
|
+
throw error;
|
|
176
|
+
}
|
|
177
|
+
const markOpen = async (port) => { await mkdir(roomDir, { recursive: true }).catch(() => {}); await writeFile(openMark, JSON.stringify({ pid: process.pid, port, at: new Date().toISOString() })).catch(() => {}); };
|
|
178
|
+
const releaseOpen = () => { try { rmSync(openMark, { force: true }); } catch { /* it was already gone */ } };
|
|
179
|
+
// The human's own modules, from ~/.pulse/modules and <project>/.madre/modules.
|
|
180
|
+
await loadExternalModules({ stateRoot: root, projectRoot: canonicalProjectRoot }).catch((error) => console.error(`MADRE modules: ${error.message}`));
|
|
181
|
+
// A project has one memory and many conversations; one of them is open at a time, and the
|
|
182
|
+
// numbering is the project's, so a citation means the same thing in all of them.
|
|
183
|
+
await adoptStrays(roomDir).catch(() => null);
|
|
184
|
+
let chatId = (await chatIndex(roomDir)).active;
|
|
185
|
+
let store = await new EventStore(chatLedger(roomDir, chatId), { floor: await projectFloor(roomDir, { except: chatId }) }).initialize();
|
|
186
|
+
let historicalEvents = await store.readAll();
|
|
135
187
|
// The room's memory: derived from the ledger, rebuilt if missing or stale,
|
|
136
188
|
// and never a reason for the room not to open.
|
|
137
189
|
// Release channel: one registry read a day, cached for every room on this machine. On by
|
|
@@ -141,13 +193,25 @@ export async function createPulseServer({
|
|
|
141
193
|
const install = detectInstall({ projectRoot: canonicalProjectRoot });
|
|
142
194
|
async function versionView({ force = false } = {}) {
|
|
143
195
|
const check = await checkForUpdate({ name: PACKAGE.name, current: PACKAGE.version, cacheFile: join(root, 'updates.json'), fetchImpl: reportFetch, enabled: updatesEnabled(), force });
|
|
144
|
-
return { ...check, name: PACKAGE.name, install, command: updateCommand(install, PACKAGE.name, check.latest ?? 'latest'), release: check.latest ? releaseUrl(PACKAGE.repository, check.latest) : null, envWins: process.env.PULSE_UPDATE_CHECK !== undefined };
|
|
196
|
+
return { project: canonicalProjectRoot, ...check, name: PACKAGE.name, install, command: updateCommand(install, PACKAGE.name, check.latest ?? 'latest'), release: check.latest ? releaseUrl(PACKAGE.repository, check.latest) : null, envWins: process.env.PULSE_UPDATE_CHECK !== undefined };
|
|
145
197
|
}
|
|
198
|
+
// Keeping the day's cache warm. MODULES is served from what is already on disk, so the looking
|
|
199
|
+
// happens after the screen is answered, never in front of it: at most once an hour per room,
|
|
200
|
+
// only while the release channel is on, and each check still holds to its own day-long cache.
|
|
201
|
+
let warmedAt = 0;
|
|
202
|
+
async function warmModuleUpdates(items, { now = Date.now() } = {}) {
|
|
203
|
+
if (!updatesEnabled() || now - warmedAt < 60 * 60 * 1000) return;
|
|
204
|
+
warmedAt = now;
|
|
205
|
+
const context = await moduleContext().catch(() => null);
|
|
206
|
+
if (!context) return;
|
|
207
|
+
for (const item of items) await context.services.moduleUpdate(item, { cacheOnly: false }).catch(() => null);
|
|
208
|
+
}
|
|
209
|
+
|
|
146
210
|
// Privacy: the terms that never travel through this room, from config.json and the environment.
|
|
147
211
|
// config.json is shared by every room on this machine, so the list is re-read before each
|
|
148
212
|
// human message and whenever MU/TH/UR looks: a term named in one room guards them all.
|
|
149
|
-
const privacy = new Privacy(privacySettings(await readConfig(root)));
|
|
150
|
-
const refreshPrivacy = async () => { const { terms, marker } = privacySettings(await readConfig(root)); privacy.set(terms, marker); return privacy; };
|
|
213
|
+
const privacy = new Privacy({ ...privacySettings(await readConfig(root)), home: homedir() });
|
|
214
|
+
const refreshPrivacy = async () => { const { terms, marker, secrets, paths } = privacySettings(await readConfig(root)); privacy.set(terms, marker).setGuards({ secrets, paths }); return privacy; };
|
|
151
215
|
const memory = await new RoomMemory(join(roomDir, 'memory.sqlite')).attachPrivacy(privacy).initialize(store)
|
|
152
216
|
.catch((error) => { console.error(`MADRE memory unavailable, turns get the recent window only: ${error.message}`); return null; });
|
|
153
217
|
// Meaning-aware recall through the user's own Gemini key, when there is one;
|
|
@@ -187,11 +251,24 @@ export async function createPulseServer({
|
|
|
187
251
|
if (room) {
|
|
188
252
|
room.setInvoker(MADRE_ADAPTER, fifth.ready ? madreInvoker({ memory, ollama: () => ({ ...ollama, chatModel: ollama.madreModel ?? ollama.chatModel }), fetchImpl: reportFetch }) : null);
|
|
189
253
|
if (changed) await room.record('agents.updated', { agents: agents.map((agent) => ({ id: agent.id, label: agent.label, detected: agent.detected, ready: agent.ready, version: agent.version, local: Boolean(agent.local) })), removed: fifth.ready ? [] : [MADRE_AGENT_ID], reason: fifth.ready ? `@madre is in the room · ${fifth.version}` : '@madre left the room: Ollama has no chat model running' });
|
|
254
|
+
// That a local model is running is not the same as it being of use on this project, and
|
|
255
|
+
// the second thing is the one a person needs told. Said once per model, in the room, with
|
|
256
|
+
// the one button that settles it — the crew line above fires only when the crew CHANGES,
|
|
257
|
+
// and a room whose Ollama was already up when it opened would never have heard it.
|
|
258
|
+
if (fifth.ready && memory && memory.metaGet('local.announced') !== fifth.version) {
|
|
259
|
+
let checked = null;
|
|
260
|
+
try { checked = JSON.parse(memory.metaGet('exams') ?? '{}').match ?? null; } catch { checked = null; }
|
|
261
|
+
memory.metaSet('local.announced', fifth.version);
|
|
262
|
+
await room.record('local.present', {
|
|
263
|
+
model: fifth.version,
|
|
264
|
+
checked: checked?.ran ? { passed: Boolean(checked.passed), matched: checked.matched ?? 0, n: checked.n ?? 0, at: checked.at ?? null } : null,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
190
267
|
}
|
|
191
268
|
return { ...ollama, settings, embeddings: Boolean(useEmbeddings), archivist: Boolean(useArchivist), agent: fifth.ready };
|
|
192
269
|
}
|
|
193
270
|
// Memory settings the human keeps in config.json (MU/TH/UR → MEMORY); environment still wins at launch.
|
|
194
|
-
const memoryConfig = async () => ({ archivist: 'auto', archivists: null, every: 10, idleMinutes: 10, embedProvider: 'auto', recallShare: 0.3, ...((await readConfig(root)).memory ?? {}) });
|
|
271
|
+
const memoryConfig = async () => ({ archivist: 'auto', archivists: null, every: 10, idleMinutes: 10, embedProvider: 'auto', recallShare: 0.3, cascade: true, ...((await readConfig(root)).memory ?? {}) });
|
|
195
272
|
const embedEnvFor = (cfg) => {
|
|
196
273
|
const view = { ...process.env };
|
|
197
274
|
if (!process.env.PULSE_EMBED_PROVIDER && !process.env.PULSE_EMBED) {
|
|
@@ -207,14 +284,19 @@ export async function createPulseServer({
|
|
|
207
284
|
const mother = new MotherChannel(join(canonicalProjectRoot, '.pulse', 'mother.env'), { meta: memory ? { get: (key) => memory.metaGet(key), set: (key, value) => memory.metaSet(key, value) } : null });
|
|
208
285
|
const motherOutcome = await mother.load().catch((error) => { console.error(`MADRE could not open MOTHER's channel: ${error.message}`); return null; });
|
|
209
286
|
const startupMemory = await memoryConfig();
|
|
210
|
-
|
|
287
|
+
let listening = null;
|
|
288
|
+
const listeningPort = () => listening?.address?.()?.port ?? Number(process.env.PULSE_PORT ?? 4317);
|
|
289
|
+
const roomOptions = () => ({
|
|
211
290
|
store,
|
|
212
291
|
agents,
|
|
213
292
|
projectRoot,
|
|
214
293
|
privacy,
|
|
294
|
+
// Modules may hand tools to a turn; the room asks once per turn, the server knows the port.
|
|
295
|
+
toolsForTurn: async (turn) => modulesToolsForTurn(await moduleContext(), { ...turn, port: listeningPort(), roomDir }),
|
|
215
296
|
softTokenBudget,
|
|
216
297
|
contextMaxChars,
|
|
217
298
|
recallShare: Number(process.env.PULSE_RECALL_SHARE ?? startupMemory.recallShare),
|
|
299
|
+
cascade: process.env.PULSE_RECALL_CASCADE !== undefined ? process.env.PULSE_RECALL_CASCADE !== '0' : startupMemory.cascade !== false,
|
|
218
300
|
distill: {
|
|
219
301
|
every: Number(process.env.PULSE_DISTILL_EVERY ?? startupMemory.every),
|
|
220
302
|
idleMs: Number(process.env.PULSE_DISTILL_IDLE_MS ?? startupMemory.idleMinutes * 60000),
|
|
@@ -231,6 +313,7 @@ export async function createPulseServer({
|
|
|
231
313
|
delegation,
|
|
232
314
|
maxPlanSteps,
|
|
233
315
|
});
|
|
316
|
+
room = new Room(roomOptions());
|
|
234
317
|
await wireOllama({ probe: false });
|
|
235
318
|
const embedKick = memory?.embedder ? setTimeout(() => void room.embedNow(), 2000) : null;
|
|
236
319
|
embedKick?.unref?.();
|
|
@@ -261,7 +344,7 @@ export async function createPulseServer({
|
|
|
261
344
|
capabilities: room.capabilities(),
|
|
262
345
|
memory: memorySettingsView(),
|
|
263
346
|
updates: { check: updatesEnabled(), envWins: process.env.PULSE_UPDATE_CHECK !== undefined },
|
|
264
|
-
privacy: { terms: privacy.terms, marker: privacy.marker, envWins: privacySettings({}, process.env).envWins },
|
|
347
|
+
privacy: { terms: privacy.terms, marker: privacy.marker, envWins: privacySettings({}, process.env).envWins, ...privacy.guards },
|
|
265
348
|
};
|
|
266
349
|
}
|
|
267
350
|
// What MU/TH/UR shows under MEMORY: the live values, who could distil, and what embeds today.
|
|
@@ -269,7 +352,7 @@ export async function createPulseServer({
|
|
|
269
352
|
const distill = room.distillSettings();
|
|
270
353
|
const candidates = [...(distill.ollama ? [{ id: 'ollama', label: `Ollama · ${ollama.chatModel ?? 'local'}`, local: true }] : []), ...agents.filter((agent) => agent.detected && !agent.local).map((agent) => ({ id: agent.id, label: agent.label, local: false }))];
|
|
271
354
|
return {
|
|
272
|
-
enabled: distill.enabled, every: distill.every, idleMinutes: Math.round(distill.idleMs / 60000), archivist: distill.agent ?? 'auto', archivists: distill.allowed, recallShare: distill.recallShare,
|
|
355
|
+
enabled: distill.enabled, every: distill.every, idleMinutes: Math.round(distill.idleMs / 60000), archivist: distill.agent ?? 'auto', archivists: distill.allowed, recallShare: distill.recallShare, cascade: distill.cascade,
|
|
273
356
|
candidates, embedder: memory?.embedder?.model ?? null, embedProvider: process.env.PULSE_EMBED === '0' ? 'off' : (process.env.PULSE_EMBED_PROVIDER ?? null),
|
|
274
357
|
ollama: { running: ollama.running, embedModel: ollama.embedModel, chatModel: ollama.chatModel },
|
|
275
358
|
stats: room.memoryStats() ? { entries: room.memoryStats().entries, memories: room.memoryStats().memories, pending: room.memoryStats().pending } : null,
|
|
@@ -311,9 +394,10 @@ export async function createPulseServer({
|
|
|
311
394
|
if (Array.isArray(m.archivists)) next.archivists = m.archivists.length ? m.archivists.map(String) : null;
|
|
312
395
|
if (['auto', 'ollama', 'gemini', 'off'].includes(m.embedProvider)) next.embedProvider = m.embedProvider;
|
|
313
396
|
if (Number.isFinite(Number(m.recallShare))) next.recallShare = Math.min(0.6, Math.max(0, Number(m.recallShare)));
|
|
397
|
+
if (typeof m.cascade === 'boolean') next.cascade = m.cascade;
|
|
314
398
|
if (typeof m.enabled === 'boolean') next.enabled = m.enabled;
|
|
315
399
|
config.memory = next;
|
|
316
|
-
room.configureDistill({ every: next.every, idleMs: next.idleMinutes * 60000, agent: next.archivist === 'auto' ? null : next.archivist, allowed: next.archivists, recallShare: next.recallShare, ...(typeof next.enabled === 'boolean' ? { enabled: next.enabled } : {}) });
|
|
400
|
+
room.configureDistill({ every: next.every, idleMs: next.idleMinutes * 60000, agent: next.archivist === 'auto' ? null : next.archivist, allowed: next.archivists, recallShare: next.recallShare, cascade: next.cascade !== false, ...(typeof next.enabled === 'boolean' ? { enabled: next.enabled } : {}) });
|
|
317
401
|
live.memory = true;
|
|
318
402
|
}
|
|
319
403
|
if (patch.scopes && typeof patch.scopes === 'object') {
|
|
@@ -324,7 +408,7 @@ export async function createPulseServer({
|
|
|
324
408
|
config.scopes[agentId] = { ...(current[agentId] ?? {}) };
|
|
325
409
|
for (const scope of ['write', 'imageGen', 'web', 'alwaysCreate']) if (typeof scopes[scope] === 'boolean') config.scopes[agentId][scope] = scopes[scope];
|
|
326
410
|
// MAX MODE and DEFAULT MODE are the two controls; setting them retires the older flags they replace.
|
|
327
|
-
if (Number.isInteger(Number(scopes.maxMode)) && Number(scopes.maxMode) >= 0 && Number(scopes.maxMode) <=
|
|
411
|
+
if (Number.isInteger(Number(scopes.maxMode)) && Number(scopes.maxMode) >= 0 && Number(scopes.maxMode) <= 4) { config.scopes[agentId].maxMode = Number(scopes.maxMode); delete config.scopes[agentId].write; }
|
|
328
412
|
if (Number.isInteger(Number(scopes.defaultMode)) && Number(scopes.defaultMode) >= 1 && Number(scopes.defaultMode) <= 2) { config.scopes[agentId].defaultMode = Number(scopes.defaultMode); delete config.scopes[agentId].alwaysCreate; }
|
|
329
413
|
}
|
|
330
414
|
room.setScopes(config.scopes);
|
|
@@ -372,12 +456,78 @@ export async function createPulseServer({
|
|
|
372
456
|
return { status: 202, body: { accepted: true, command: plan.display } };
|
|
373
457
|
}
|
|
374
458
|
|
|
375
|
-
//
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
459
|
+
// A CLI installed from the room appears without restarting: detection runs again, the roster
|
|
460
|
+
// is updated in place (the room holds the same array) and the change is announced.
|
|
461
|
+
async function redetectAgents({ announce = true } = {}) {
|
|
462
|
+
if (!detector) return { changed: false, agents };
|
|
463
|
+
let changed = false;
|
|
464
|
+
for (const next of await detector()) {
|
|
465
|
+
const index = agents.findIndex((item) => item.id === next.id);
|
|
466
|
+
if (index === -1) { agents.push(next); changed = true; continue; }
|
|
467
|
+
const before = agents[index];
|
|
468
|
+
if (before.detected !== next.detected || before.ready !== next.ready || before.version !== next.version || before.path !== next.path) {
|
|
469
|
+
agents[index] = { ...before, ...next };
|
|
470
|
+
changed = true;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
await refreshSessions();
|
|
474
|
+
if (changed && announce) {
|
|
475
|
+
await room.record('agents.updated', {
|
|
476
|
+
agents: agents.map((agent) => ({ id: agent.id, label: agent.label, detected: agent.detected, ready: agent.ready, version: agent.version, local: Boolean(agent.local) })),
|
|
477
|
+
removed: [],
|
|
478
|
+
reason: 'the room looked again for the agents on this computer',
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
return { changed, agents };
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// The bridge installs a CLI with its own package manager, streaming into the room like any
|
|
485
|
+
// other install, and looks again when it finishes. Nothing is written into the project.
|
|
486
|
+
async function installAgent(id) {
|
|
487
|
+
const agent = agents.find((item) => item.id === id);
|
|
488
|
+
if (!agent) return { status: 404, body: { error: `Unknown agent: ${id}.` } };
|
|
489
|
+
if (agent.detected) return { status: 409, body: { error: `${agent.label} is already installed.` } };
|
|
490
|
+
const plan = installPlanFor(agent, { prefix: toolsPrefix() });
|
|
491
|
+
if (!plan) return { status: 404, body: { error: `MADRE does not know how to install ${id}.` } };
|
|
492
|
+
if (installing) return { status: 409, body: { error: `Another install is running (${installing}).` } };
|
|
493
|
+
installing = `agent:${id}`;
|
|
494
|
+
await room.record('connection.install.started', { agent: id, label: agent.label, command: plan.display });
|
|
495
|
+
void (async () => {
|
|
496
|
+
const lines = [];
|
|
497
|
+
const attempt = (step) => (installers[id] ?? runInstaller)({
|
|
498
|
+
command: step.command,
|
|
499
|
+
args: step.args,
|
|
500
|
+
projectRoot: canonicalProjectRoot,
|
|
501
|
+
timeoutMs: 600000,
|
|
502
|
+
onLine: (line) => { lines.push(line); void room.record('connection.install.output', { agent: id, line: line.slice(0, 500) }); },
|
|
503
|
+
});
|
|
504
|
+
let result = await attempt(plan);
|
|
505
|
+
let where = 'system';
|
|
506
|
+
// Walled out of the system folders: install into MADRE's own, where it will be found.
|
|
507
|
+
if (result.code !== 0 && plan.fallback && looksLikeAdminProblem(`${lines.join('\n')}\n${result.error ?? ''}`)) {
|
|
508
|
+
await room.record('connection.install.output', { agent: id, line: `npm cannot write to this computer's system folder without an administrator. Installing into MADRE's own folder instead: ${toolsPrefix()}` });
|
|
509
|
+
result = await attempt(plan.fallback);
|
|
510
|
+
where = 'madre';
|
|
511
|
+
}
|
|
512
|
+
await redetectAgents();
|
|
513
|
+
const found = agents.find((item) => item.id === id);
|
|
514
|
+
await room.record('connection.install.finished', {
|
|
515
|
+
agent: id,
|
|
516
|
+
label: agent.label,
|
|
517
|
+
where,
|
|
518
|
+
prefix: where === 'madre' ? toolsPrefix() : null,
|
|
519
|
+
code: result.code ?? null,
|
|
520
|
+
error: result.error ?? null,
|
|
521
|
+
detected: Boolean(found?.detected),
|
|
522
|
+
version: found?.version ?? null,
|
|
523
|
+
session: sessions[id] ?? null,
|
|
524
|
+
tail: lines.slice(-6),
|
|
525
|
+
});
|
|
526
|
+
installing = null;
|
|
527
|
+
})();
|
|
528
|
+
return { status: 202, body: { accepted: true, command: plan.display } };
|
|
529
|
+
}
|
|
530
|
+
|
|
381
531
|
|
|
382
532
|
async function rawBody(request, limit) {
|
|
383
533
|
const chunks = [];
|
|
@@ -393,7 +543,8 @@ export async function createPulseServer({
|
|
|
393
543
|
const startupConfig = await readConfig(root);
|
|
394
544
|
room.setScopes(startupConfig.scopes ?? {});
|
|
395
545
|
setImageModule(startupConfig.modules?.imageStudio ?? {});
|
|
396
|
-
|
|
546
|
+
// Rooms opened before Ash was renamed carry the old key; the human's switch is not lost.
|
|
547
|
+
room.setAsh(Boolean(startupConfig.modules?.ash?.enabled ?? startupConfig.modules?.ash?.enabled));
|
|
397
548
|
const recoveredTurns = await room.reconcile();
|
|
398
549
|
if (recoveredTurns) console.error(`MADRE recovered ${recoveredTurns} unfinished turn(s) from a previous run.`);
|
|
399
550
|
const quotaMonitor = new QuotaMonitor({
|
|
@@ -451,7 +602,7 @@ export async function createPulseServer({
|
|
|
451
602
|
})();
|
|
452
603
|
return inFlight;
|
|
453
604
|
}
|
|
454
|
-
|
|
605
|
+
let unsubscribe = () => {};
|
|
455
606
|
|
|
456
607
|
// The error sentinel: failures MU/TH/UR cannot classify, and crashes, become
|
|
457
608
|
// redacted reports in the ledger. Sending them anywhere is the human's call.
|
|
@@ -464,8 +615,8 @@ export async function createPulseServer({
|
|
|
464
615
|
emit: (type, payload) => room.record(type, payload),
|
|
465
616
|
save: async (settings) => { const current = await readConfig(root); await updateConfig(root, { telemetry: { ...(current.telemetry ?? {}), ...settings } }); },
|
|
466
617
|
});
|
|
467
|
-
sentinel.
|
|
468
|
-
|
|
618
|
+
outbound.watchReportUrl(sentinel.settings().reportUrl);
|
|
619
|
+
let unsubscribeSentinel = () => {};
|
|
469
620
|
if (!testMode && !crashHandlersInstalled) {
|
|
470
621
|
crashHandlersInstalled = true;
|
|
471
622
|
for (const origin of ['uncaughtException', 'unhandledRejection']) {
|
|
@@ -475,7 +626,79 @@ export async function createPulseServer({
|
|
|
475
626
|
});
|
|
476
627
|
}
|
|
477
628
|
}
|
|
478
|
-
|
|
629
|
+
// EYECAT: the watcher that asks whether the room still believes what it wrote down. It is not
|
|
630
|
+
// a module and takes no turn: it subscribes from outside, the way the error sentinel does, and
|
|
631
|
+
// everything it finds is a question for the human rather than an entry in the archive.
|
|
632
|
+
const eyecat = new Eyecat({
|
|
633
|
+
enabled: () => process.env.PULSE_EYECAT !== '0',
|
|
634
|
+
research: () => room.memoryResearch(),
|
|
635
|
+
entriesFor: (notes) => (memory ? memory.entriesAt(notes.flatMap((note) => note.sources ?? [])) : []),
|
|
636
|
+
bench: () => room.bench(),
|
|
637
|
+
emit: (type, payload) => room.record(type, payload),
|
|
638
|
+
});
|
|
639
|
+
let unsubscribeEyecat = () => {};
|
|
640
|
+
|
|
641
|
+
let unsubscribeGhost = () => {};
|
|
642
|
+
let unsubscribeLive = () => {};
|
|
643
|
+
let unsubscribeChats = () => {};
|
|
644
|
+
// Writes to the conversation list are fire-and-forget, so the last one has to be waited for
|
|
645
|
+
// when the room closes: a file that reappears while its folder is being removed is a test that
|
|
646
|
+
// fails for a reason that has nothing to do with it.
|
|
647
|
+
let chatWrites = Promise.resolve();
|
|
648
|
+
|
|
649
|
+
// Everything that listens to the open conversation, attached in one place so that opening
|
|
650
|
+
// another attaches exactly the same things to it. Live readings go straight to whoever is
|
|
651
|
+
// watching and are never stored, so a page that opens later simply never sees them.
|
|
652
|
+
function wireRoom() {
|
|
653
|
+
unsubscribe = room.subscribe(() => { void broadcastPending(); });
|
|
654
|
+
unsubscribeSentinel = room.subscribe((event) => { void sentinel.observe(event).catch(() => null); });
|
|
655
|
+
unsubscribeEyecat = room.subscribe((event) => { void eyecat.observe(event).catch(() => null); });
|
|
656
|
+
unsubscribeGhost = room.subscribeGhost((event) => { for (const client of clients.keys()) writeEvent(client, event); });
|
|
657
|
+
unsubscribeLive = room.subscribeLive((event) => { for (const client of clients.keys()) writeEvent(client, event); });
|
|
658
|
+
// What the list of conversations shows, kept as it happens. A conversation with no name of
|
|
659
|
+
// its own takes the first thing the human said in it.
|
|
660
|
+
unsubscribeChats = room.subscribe((event) => {
|
|
661
|
+
if (event?.type !== 'message.created' || event.ghost) return;
|
|
662
|
+
const human = event.payload?.role === 'user';
|
|
663
|
+
chatWrites = chatWrites.then(() => touchChat(roomDir, chatId, { text: human ? event.payload.text : null, counts: human })).catch(() => null);
|
|
664
|
+
});
|
|
665
|
+
sentinel.seed(historicalEvents);
|
|
666
|
+
eyecat.seed(historicalEvents);
|
|
667
|
+
room.restoreAttachments(historicalEvents
|
|
668
|
+
.filter((event) => event.type === 'attachment.stored')
|
|
669
|
+
.map((event) => ({ ...event.payload, path: join(attachmentsRoot, event.payload.fileName), dir: attachmentsRoot })));
|
|
670
|
+
}
|
|
671
|
+
function unwireRoom() {
|
|
672
|
+
for (const off of [unsubscribe, unsubscribeSentinel, unsubscribeEyecat, unsubscribeGhost, unsubscribeLive, unsubscribeChats]) { try { off(); } catch { /* already gone */ } }
|
|
673
|
+
unsubscribe = unsubscribeSentinel = unsubscribeEyecat = unsubscribeGhost = unsubscribeLive = unsubscribeChats = () => {};
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// Opening another conversation. The project keeps its memory, its crew, its modules, its
|
|
677
|
+
// privacy list and its numbering; what changes is the record being written and read. One
|
|
678
|
+
// conversation drives the crew at a time — two threads editing the same working tree at once
|
|
679
|
+
// is not a feature, it is a way to lose work — so a turn in flight is a reason to refuse.
|
|
680
|
+
async function mountChat(id) {
|
|
681
|
+
if (!isChatId(id)) return { error: `No conversation "${id}".` };
|
|
682
|
+
if (room && room.working()) return { error: t('A turn is running in this conversation. Let it finish, or STOP ALL, and try again.') };
|
|
683
|
+
const opened = await openChatIndex(roomDir, id);
|
|
684
|
+
if (!opened) return { error: `No conversation "${id}".` };
|
|
685
|
+
unwireRoom();
|
|
686
|
+
await chatWrites.catch(() => null);
|
|
687
|
+
await room?.shutdown().catch(() => null);
|
|
688
|
+
chatId = id;
|
|
689
|
+
store = await new EventStore(chatLedger(roomDir, id), { floor: await projectFloor(roomDir, { except: id }) }).initialize();
|
|
690
|
+
historicalEvents = await store.readAll();
|
|
691
|
+
room = new Room(roomOptions());
|
|
692
|
+
room.setInvoker(MADRE_ADAPTER, madreInvoker({ memory, ollama: () => ({ ...ollama, chatModel: ollama.madreModel ?? ollama.chatModel }), fetchImpl: reportFetch }));
|
|
693
|
+
wireRoom();
|
|
694
|
+
// Whoever is watching is watching the wrong record now: their stream ends and they come back
|
|
695
|
+
// to the one that is open.
|
|
696
|
+
lastBroadcastSequence = historicalEvents.at(-1)?.sequence ?? 0;
|
|
697
|
+
tailOffset = (await store.tail(0)).offset;
|
|
698
|
+
for (const client of [...clients.keys()]) { clients.delete(client); try { client.end(); } catch { /* already gone */ } }
|
|
699
|
+
return { chat: opened };
|
|
700
|
+
}
|
|
701
|
+
wireRoom();
|
|
479
702
|
const poller = setInterval(() => { void broadcastPending(); }, broadcastIntervalMs);
|
|
480
703
|
poller.unref();
|
|
481
704
|
|
|
@@ -491,9 +714,48 @@ export async function createPulseServer({
|
|
|
491
714
|
services: {
|
|
492
715
|
imageKey,
|
|
493
716
|
setImageModule,
|
|
717
|
+
// What a card knows about being up to date. Reading is free: it answers from the day's
|
|
718
|
+
// cache. The button on a card is what forces a look outside.
|
|
719
|
+
moduleUpdate: (item, { force = false, cacheOnly = !force } = {}) => moduleUpdate(item, {
|
|
720
|
+
stateRoot: root, madre: { name: PACKAGE.name, version: PACKAGE.version },
|
|
721
|
+
fetchImpl: reportFetch, enabled: updatesEnabled(), force, cacheOnly,
|
|
722
|
+
}),
|
|
494
723
|
ollama: {
|
|
495
724
|
state: () => ollama,
|
|
496
725
|
wire: (options) => wireOllama(options),
|
|
726
|
+
// Getting Ollama onto this computer, with the command in plain sight, and waking it.
|
|
727
|
+
install: async () => {
|
|
728
|
+
if (installing) return { ok: false, error: `Another install is running (${installing}).` };
|
|
729
|
+
const { ollamaInstallPlan } = await import('./modules/ollama.mjs');
|
|
730
|
+
const plan = ollamaInstallPlan({ brew: await findOnPath('brew') });
|
|
731
|
+
if (!plan.command) return { ok: false, error: plan.note, download: plan.download };
|
|
732
|
+
installing = 'ollama';
|
|
733
|
+
await room.record('extension.install.started', { id: 'ollama', name: 'OLLAMA', command: plan.display, platforms: [], alreadyInstalled: false });
|
|
734
|
+
void (async () => {
|
|
735
|
+
const lines = [];
|
|
736
|
+
const result = await runInstaller({ command: plan.command, args: plan.args, projectRoot: canonicalProjectRoot, timeoutMs: 900000, onLine: (line) => { lines.push(line); void room.record('extension.install.output', { id: 'ollama', lines: [line.slice(0, 500)] }); } });
|
|
737
|
+
const status = await wireOllama();
|
|
738
|
+
await room.record('extension.install.finished', { id: 'ollama', name: 'OLLAMA', ok: result.code === 0, installed: Boolean(await findOnPath('ollama')), detail: result.code === 0 ? 'Ollama installed · START it to wake it' : (result.error ?? `exit ${result.code}`), ollama: status });
|
|
739
|
+
installing = null;
|
|
740
|
+
})();
|
|
741
|
+
return { ok: true, command: plan.display };
|
|
742
|
+
},
|
|
743
|
+
start: async () => {
|
|
744
|
+
if (ollama.running) return { ok: true, already: true };
|
|
745
|
+
const binary = await findOnPath('ollama');
|
|
746
|
+
if (!binary) return { ok: false, error: t('Ollama is not on this computer yet.') };
|
|
747
|
+
await room.record('extension.install.started', { id: 'ollama', name: 'OLLAMA', command: 'ollama serve', platforms: [], alreadyInstalled: true });
|
|
748
|
+
const child = spawn(binary, ['serve'], { detached: process.platform !== 'win32', windowsHide: true, stdio: 'ignore' });
|
|
749
|
+
child.unref();
|
|
750
|
+
// It answers in a moment or it does not: poll its own port rather than guess.
|
|
751
|
+
let status = ollama;
|
|
752
|
+
for (let attempt = 0; attempt < 20 && !status.running; attempt += 1) {
|
|
753
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
754
|
+
status = await wireOllama();
|
|
755
|
+
}
|
|
756
|
+
await room.record('extension.install.finished', { id: 'ollama', name: 'OLLAMA', ok: status.running, installed: true, detail: status.running ? `running · ${status.chatModel ? `@madre with ${status.chatModel}` : 'no chat model yet'}` : 'Ollama did not answer; open the Ollama app and press RECHECK.', ollama: status });
|
|
757
|
+
return status.running ? { ok: true } : { ok: false, error: t('Ollama did not answer. Open the Ollama app, then press RECHECK.') };
|
|
758
|
+
},
|
|
497
759
|
// Pulls stream into the room like a module install; one at a time.
|
|
498
760
|
pull: async (model) => {
|
|
499
761
|
if (installing) return { ok: false, error: `Another install is running (${installing}).` };
|
|
@@ -526,7 +788,7 @@ export async function createPulseServer({
|
|
|
526
788
|
if (!extension) return { status: 404, body: { error: `Unknown module: ${id}.` } };
|
|
527
789
|
// Built-ins are switches the module itself defines.
|
|
528
790
|
if (extension.toggle) return extension.toggle(await moduleContext(), payload);
|
|
529
|
-
if (confirm !== true) return { status: 400, body: { error: 'Installing a module writes into the project; send { "confirm": true } to proceed.' } };
|
|
791
|
+
if (confirm !== true) return { status: 400, body: { error: t('Installing a module writes into the project; send { "confirm": true } to proceed.') } };
|
|
530
792
|
if (installing) return { status: 409, body: { error: `Another install is running (${installing}).` } };
|
|
531
793
|
const preflight = extension.preflight ? await extension.preflight(canonicalProjectRoot) : { ok: true, problems: [] };
|
|
532
794
|
if (!preflight.ok && !installers[id]) {
|
|
@@ -584,7 +846,7 @@ export async function createPulseServer({
|
|
|
584
846
|
response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
|
585
847
|
return response.end(html);
|
|
586
848
|
}
|
|
587
|
-
if (request.method === 'GET' && ['/app.js', '/brands.js', '/troubleshooting.js'].includes(url.pathname)) {
|
|
849
|
+
if (request.method === 'GET' && ['/app.js', '/brands.js', '/troubleshooting.js', '/inquiry.js', '/i18n.js', '/es.js', '/resay.js'].includes(url.pathname)) {
|
|
588
850
|
const js = await readFile(join(publicDirectory, url.pathname.slice(1)));
|
|
589
851
|
response.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8' });
|
|
590
852
|
return response.end(js);
|
|
@@ -597,8 +859,13 @@ export async function createPulseServer({
|
|
|
597
859
|
if (request.method === 'GET' && url.pathname === '/api/state') {
|
|
598
860
|
return sendJson(response, 200, {
|
|
599
861
|
projectRoot,
|
|
600
|
-
|
|
601
|
-
|
|
862
|
+
platform: process.platform,
|
|
863
|
+
// What language this machine set MADRE to, for a browser that has never chosen.
|
|
864
|
+
language: (await readConfig(root)).language === 'en' ? 'en' : 'es',
|
|
865
|
+
// Which conversation this page is looking at, and the others it could open.
|
|
866
|
+
chats: await listChats(roomDir),
|
|
867
|
+
agents: agents.map((agent) => ({ ...agent, login: loginPlanFor(agent), install: installPlanFor(agent), key: keyPlanFor(agent.id), ...(accountNoteFor(agent.id) ?? {}) })),
|
|
868
|
+
ash: { enabled: room.ashEnabled() },
|
|
602
869
|
ripley: { enabled: await ripleyOn() },
|
|
603
870
|
softTokenBudget,
|
|
604
871
|
timeouts: Object.fromEntries(agents.map((agent) => [agent.id, room.timeoutFor(agent.id)])),
|
|
@@ -669,7 +936,7 @@ export async function createPulseServer({
|
|
|
669
936
|
// resolve to the same route.
|
|
670
937
|
const previewMatch = request.method === 'GET' && url.pathname.match(/^\/preview\/(project|attachments)\/(.*)$/);
|
|
671
938
|
if (previewMatch) {
|
|
672
|
-
if (!(await ripleyOn())) return sendJson(response, 412, { error: 'RIPLEY is off. Enable it in MODULES to render files.' });
|
|
939
|
+
if (!(await ripleyOn())) return sendJson(response, 412, { error: t('RIPLEY is off. Enable it in MODULES to render files.') });
|
|
673
940
|
const which = previewMatch[1] === 'attachments' ? attachmentsRoot : canonicalProjectRoot;
|
|
674
941
|
const relative = decodeURIComponent(previewMatch[2]);
|
|
675
942
|
const file = await readServable(which, relative);
|
|
@@ -704,7 +971,7 @@ export async function createPulseServer({
|
|
|
704
971
|
}
|
|
705
972
|
if (request.method === 'GET' && url.pathname === '/api/settings') {
|
|
706
973
|
await refreshPrivacy();
|
|
707
|
-
return sendJson(response, 200, { settings: effectiveSettings(), config: await readConfig(root), sessions, sessionsAt, loggingIn, agents: agents.map((agent) => ({ ...agent, login: loginPlanFor(agent) })) });
|
|
974
|
+
return sendJson(response, 200, { settings: effectiveSettings(), config: await readConfig(root), sessions, sessionsAt, loggingIn, agents: agents.map((agent) => ({ ...agent, login: loginPlanFor(agent), install: installPlanFor(agent), key: keyPlanFor(agent.id), ...(accountNoteFor(agent.id) ?? {}) })) });
|
|
708
975
|
}
|
|
709
976
|
if (request.method === 'POST' && url.pathname === '/api/settings') {
|
|
710
977
|
return sendJson(response, 200, { settings: await applySettings(await body(request)) });
|
|
@@ -717,13 +984,15 @@ export async function createPulseServer({
|
|
|
717
984
|
}
|
|
718
985
|
if (request.method === 'POST' && url.pathname === '/api/sentinel/settings') {
|
|
719
986
|
const patch = await body(request).catch(() => ({}));
|
|
720
|
-
|
|
987
|
+
const saved = await sentinel.setSettings(patch);
|
|
988
|
+
outbound.watchReportUrl(saved.reportUrl);
|
|
989
|
+
return sendJson(response, 200, { settings: saved });
|
|
721
990
|
}
|
|
722
991
|
const sentinelMatch = url.pathname.match(/^\/api\/sentinel\/([a-f0-9]{10})\/(issue|send)$/);
|
|
723
992
|
if (sentinelMatch && request.method === (sentinelMatch[2] === 'issue' ? 'GET' : 'POST')) {
|
|
724
993
|
if (sentinelMatch[2] === 'issue') {
|
|
725
994
|
const issue = sentinel.issueUrl(sentinelMatch[1]);
|
|
726
|
-
return issue ? sendJson(response, 200, { url: issue }) : sendJson(response, 404, { error: 'No such report, or no repository to file it in.' });
|
|
995
|
+
return issue ? sendJson(response, 200, { url: issue }) : sendJson(response, 404, { error: t('No such report, or no repository to file it in.') });
|
|
727
996
|
}
|
|
728
997
|
const outcome = await sentinel.send(sentinelMatch[1]);
|
|
729
998
|
return sendJson(response, outcome.ok ? 200 : (outcome.status === 404 || outcome.status === 412 ? outcome.status : 502), outcome);
|
|
@@ -735,23 +1004,99 @@ export async function createPulseServer({
|
|
|
735
1004
|
const result = await moduleRoute.route.handler(await moduleContext(), { request, url, params: moduleRoute.params, payload });
|
|
736
1005
|
return sendJson(response, result.status ?? 200, result.body ?? {});
|
|
737
1006
|
}
|
|
1007
|
+
// Where this room's tokens go: what each block of the briefing has cost, how much came back
|
|
1008
|
+
// from a CLI's own cache, and how many characters the room spends per token it is charged.
|
|
1009
|
+
if (url.pathname === '/api/economy' && request.method === 'GET') {
|
|
1010
|
+
return sendJson(response, 200, economy(await store.readAll()));
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
// What left this machine: every address MADRE may reach, whether it is on today, and the
|
|
1014
|
+
// last requests this process actually made. The declaration is checked against the log.
|
|
1015
|
+
if (url.pathname === '/api/outbound' && request.method === 'GET') {
|
|
1016
|
+
const embedder = memory?.embedder?.model ?? null;
|
|
1017
|
+
const modules = (await readConfig(root)).modules ?? {};
|
|
1018
|
+
return sendJson(response, 200, outboundView({
|
|
1019
|
+
log: outbound,
|
|
1020
|
+
agents,
|
|
1021
|
+
state: {
|
|
1022
|
+
crew: agents.some((agent) => agent.detected && !agent.local),
|
|
1023
|
+
embeddings: Boolean(embedder && /^gemini/i.test(embedder)),
|
|
1024
|
+
image: Boolean(modules['image-studio']?.enabled),
|
|
1025
|
+
npm: updatesEnabled(),
|
|
1026
|
+
github: updatesEnabled(),
|
|
1027
|
+
reports: Boolean(sentinel.settings().autoReport),
|
|
1028
|
+
anthropic: agents.some((agent) => agent.id === 'claude' && agent.ready),
|
|
1029
|
+
ollama: Boolean(ollama.running),
|
|
1030
|
+
},
|
|
1031
|
+
}));
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
// EYECAT: what it is holding, and the two answers a person can give it. Confirming writes
|
|
1035
|
+
// the aberration and takes the note it refutes out of circulation; dismissing says the room
|
|
1036
|
+
// was right all along and the pair is never raised again.
|
|
1037
|
+
if (url.pathname === '/api/eyecat' && request.method === 'GET') {
|
|
1038
|
+
return sendJson(response, 200, { findings: eyecat.findings(), settings: eyecat.settings(), aberrations: memory ? memory.aberrations({ limit: 100 }) : [] });
|
|
1039
|
+
}
|
|
1040
|
+
if (url.pathname === '/api/eyecat/sweep' && request.method === 'POST') {
|
|
1041
|
+
const payload = await body(request).catch(() => ({}));
|
|
1042
|
+
if (!designationOk(payload.designation)) return sendJson(response, 403, { error: t('UNABLE TO COMPUTE. UNABLE TO CLARIFY.') });
|
|
1043
|
+
const raised = await eyecat.sweep({ reason: 'asked' }).catch(() => null);
|
|
1044
|
+
return sendJson(response, 200, { raised: raised?.length ?? 0, findings: eyecat.findings() });
|
|
1045
|
+
}
|
|
1046
|
+
const eyecatVerdict = request.method === 'POST' && url.pathname.match(/^\/api\/eyecat\/(confirm|dismiss)$/);
|
|
1047
|
+
if (eyecatVerdict) {
|
|
1048
|
+
const payload = await body(request).catch(() => ({}));
|
|
1049
|
+
if (!designationOk(payload.designation)) return sendJson(response, 403, { error: t('UNABLE TO COMPUTE. UNABLE TO CLARIFY.') });
|
|
1050
|
+
if (!memory) return sendJson(response, 503, { error: t('The room has no memory.') });
|
|
1051
|
+
const finding = eyecat.findings().find((item) => item.key === payload.key);
|
|
1052
|
+
if (!finding) return sendJson(response, 404, { error: t('EYECAT is not holding that one.') });
|
|
1053
|
+
const settled = eyecat.settle(finding.key, { verdict: eyecatVerdict[1] === 'confirm' ? 'aberration' : 'dismissed' });
|
|
1054
|
+
let flagged = null;
|
|
1055
|
+
if (eyecatVerdict[1] === 'confirm') {
|
|
1056
|
+
flagged = memory.flagAberration({
|
|
1057
|
+
text: finding.claim.text,
|
|
1058
|
+
correction: finding.correction,
|
|
1059
|
+
contradicts: finding.claim.id,
|
|
1060
|
+
sources: [],
|
|
1061
|
+
agent: finding.judge,
|
|
1062
|
+
detector: 'eyecat',
|
|
1063
|
+
confidence: finding.confidence,
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
await room.record('eyecat.settled', { key: finding.key, verdict: settled?.settledAs ?? 'dismissed', aberration: flagged?.id ?? null, refuted: flagged?.refuted ?? null });
|
|
1067
|
+
return sendJson(response, 200, { settled: settled?.settledAs ?? 'dismissed', aberration: flagged, findings: eyecat.findings() });
|
|
1068
|
+
}
|
|
1069
|
+
|
|
738
1070
|
// The dataset behind MADRE AI: exported on demand next to the ledger.
|
|
739
1071
|
if (url.pathname === '/api/dataset' && (request.method === 'GET' || request.method === 'POST')) {
|
|
740
1072
|
const dir = join(roomDir, 'dataset');
|
|
741
1073
|
const notes = memory ? memory.memories({ limit: 5000 }) : [];
|
|
742
1074
|
if (request.method === 'GET') {
|
|
743
1075
|
const manifest = await readFile(join(dir, 'manifest.json'), 'utf8').then(JSON.parse).catch(() => null);
|
|
744
|
-
|
|
1076
|
+
const ready = datasetReadiness(await store.readAll(), notes);
|
|
1077
|
+
const research = room.memoryResearch();
|
|
1078
|
+
return sendJson(response, 200, { dataset: manifest, dir, trained: ollama.madreModel ?? null, readiness: ready, maturity: maturity({ readiness: ready, notes, links: research?.links ?? [], stats: research?.stats ?? null }), training: trainingInfo() });
|
|
745
1079
|
}
|
|
746
1080
|
const events = await store.readAll();
|
|
747
1081
|
const result = await exportDataset({ events, notes, dir, project: basename(canonicalProjectRoot), home: homedir(), privacy });
|
|
748
1082
|
await room.record('dataset.exported', { pairs: result.pairs, turns: result.turns, notes: result.notes, train: result.train, valid: result.valid, dir });
|
|
749
|
-
|
|
1083
|
+
const after = datasetReadiness(events, notes);
|
|
1084
|
+
const drawn = room.memoryResearch();
|
|
1085
|
+
return sendJson(response, 200, { dataset: result, dir, trained: ollama.madreModel ?? null, readiness: after, maturity: maturity({ readiness: after, notes, links: drawn?.links ?? [], stats: drawn?.stats ?? null }), training: trainingInfo() });
|
|
750
1086
|
}
|
|
751
1087
|
// PRIVACY: the terms live in config.json only; the ledger records counts, never words.
|
|
1088
|
+
// Which language MADRE speaks. The page keeps its own copy so the first paint needs no
|
|
1089
|
+
// round trip; this is the durable one, shared by every browser on this machine.
|
|
1090
|
+
if (request.method === 'POST' && url.pathname === '/api/language') {
|
|
1091
|
+
const payload = await body(request).catch(() => ({}));
|
|
1092
|
+
const language = payload.language === 'en' ? 'en' : 'es';
|
|
1093
|
+
await updateConfig(root, { language });
|
|
1094
|
+
setRoomLanguage(process.env.PULSE_LANGUAGE ?? language);
|
|
1095
|
+
return sendJson(response, 200, { language });
|
|
1096
|
+
}
|
|
752
1097
|
if (request.method === 'GET' && url.pathname === '/api/privacy') {
|
|
753
1098
|
await refreshPrivacy();
|
|
754
|
-
return sendJson(response, 200, { terms: privacy.terms, marker: privacy.marker, exposure: room.privacyExposure(await store.readAll()) });
|
|
1099
|
+
return sendJson(response, 200, { terms: privacy.terms, marker: privacy.marker, ...privacy.guards, exposure: room.privacyExposure(await store.readAll()) });
|
|
755
1100
|
}
|
|
756
1101
|
if (request.method === 'POST' && url.pathname === '/api/privacy') {
|
|
757
1102
|
const patch = await body(request).catch(() => ({}));
|
|
@@ -759,16 +1104,19 @@ export async function createPulseServer({
|
|
|
759
1104
|
const next = { ...current };
|
|
760
1105
|
if ('terms' in patch) next.terms = normalizeTerms(patch.terms);
|
|
761
1106
|
if (typeof patch.marker === 'string' && patch.marker.trim()) next.marker = patch.marker.trim().slice(0, 40);
|
|
1107
|
+
if ('secrets' in patch) next.secrets = Boolean(patch.secrets);
|
|
1108
|
+
if ('paths' in patch) next.paths = Boolean(patch.paths);
|
|
762
1109
|
await updateConfig(root, { privacy: next });
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
1110
|
+
await refreshPrivacy();
|
|
1111
|
+
// The room records that the setting changed and what it is now, never the words.
|
|
1112
|
+
await room.record('privacy.updated', { terms: privacy.terms.length, marker: privacy.marker, ...privacy.guards });
|
|
1113
|
+
return sendJson(response, 200, { terms: privacy.terms, marker: privacy.marker, ...privacy.guards, exposure: room.privacyExposure(await store.readAll()) });
|
|
766
1114
|
}
|
|
767
1115
|
if (request.method === 'POST' && url.pathname === '/api/privacy/purge') {
|
|
768
1116
|
const payload = await body(request).catch(() => ({}));
|
|
769
|
-
if (!designationOk(payload.designation)) return sendJson(response, 403, { error: 'UNABLE TO COMPUTE. UNABLE TO CLARIFY.' });
|
|
1117
|
+
if (!designationOk(payload.designation)) return sendJson(response, 403, { error: t('UNABLE TO COMPUTE. UNABLE TO CLARIFY.') });
|
|
770
1118
|
await refreshPrivacy();
|
|
771
|
-
if (!privacy.enabled) return sendJson(response, 412, { error: 'No private terms are set. Write them first.' });
|
|
1119
|
+
if (!privacy.enabled) return sendJson(response, 412, { error: t('No private terms are set. Write them first.') });
|
|
772
1120
|
const result = await room.purgePrivate();
|
|
773
1121
|
// The log was rewritten in place: the broadcaster's byte offset is stale, the sequences are not.
|
|
774
1122
|
tailOffset = (await store.tail(0)).offset;
|
|
@@ -786,6 +1134,28 @@ export async function createPulseServer({
|
|
|
786
1134
|
if (request.method === 'GET' && url.pathname === '/api/version') {
|
|
787
1135
|
return sendJson(response, 200, await versionView({ force: url.searchParams.get('force') === '1' }));
|
|
788
1136
|
}
|
|
1137
|
+
// One click: install the newer version and come back on the same port. The room records it,
|
|
1138
|
+
// closes its listener so the port is free, hands the command to a detached shell and exits.
|
|
1139
|
+
if (request.method === 'POST' && url.pathname === '/api/updates/apply') {
|
|
1140
|
+
const info = await versionView();
|
|
1141
|
+
if (!info.available) return sendJson(response, 409, { error: `Nothing to apply: ${info.current} is the latest MADRE knows of.` });
|
|
1142
|
+
const command = applyCommand({ install, name: PACKAGE.name, version: info.latest, port: request.socket.localPort, projectRoot: canonicalProjectRoot });
|
|
1143
|
+
if (!command) return sendJson(response, 412, { error: t('This copy runs from source: pull the repository and start it again.') });
|
|
1144
|
+
if (room.activeTurns().length || room.activePlans().length) return sendJson(response, 409, { error: t('Agents are still working. STOPALL or wait, then update.') });
|
|
1145
|
+
await room.record('room.updating', { from: info.current, to: info.latest, install, command });
|
|
1146
|
+
sendJson(response, 202, { restarting: true, from: info.current, to: info.latest, command });
|
|
1147
|
+
setTimeout(() => {
|
|
1148
|
+
server.close(() => {
|
|
1149
|
+
// Each system has its own shell: the command is one line either way.
|
|
1150
|
+
const shell = process.platform === 'win32' ? { file: process.env.COMSPEC ?? 'cmd.exe', args: ['/d', '/s', '/c', command] } : { file: '/bin/sh', args: ['-c', command] };
|
|
1151
|
+
const child = spawn(shell.file, shell.args, { cwd: canonicalProjectRoot, detached: process.platform !== 'win32', windowsHide: true, stdio: 'ignore', env: { ...process.env, PULSE_UPDATE_RESTART: '1' } });
|
|
1152
|
+
child.unref();
|
|
1153
|
+
setTimeout(() => process.exit(0), 200);
|
|
1154
|
+
});
|
|
1155
|
+
for (const client of clients.keys()) { try { client.end(); } catch { /* gone */ } }
|
|
1156
|
+
}, 300);
|
|
1157
|
+
return undefined;
|
|
1158
|
+
}
|
|
789
1159
|
if (request.method === 'POST' && url.pathname === '/api/updates/settings') {
|
|
790
1160
|
const patch = await body(request).catch(() => ({}));
|
|
791
1161
|
if (typeof patch.check === 'boolean') { updatesConfig = { ...updatesConfig, check: patch.check }; await updateConfig(root, { updates: updatesConfig }); }
|
|
@@ -796,27 +1166,236 @@ export async function createPulseServer({
|
|
|
796
1166
|
}
|
|
797
1167
|
if (request.method === 'POST' && url.pathname === '/api/mother/code000') {
|
|
798
1168
|
const payload = await body(request).catch(() => ({}));
|
|
799
|
-
if (!designationOk(payload.designation)) return sendJson(response, 403, { error: 'UNABLE TO COMPUTE. UNABLE TO CLARIFY.' });
|
|
1169
|
+
if (!designationOk(payload.designation)) return sendJson(response, 403, { error: t('UNABLE TO COMPUTE. UNABLE TO CLARIFY.') });
|
|
800
1170
|
const result = await room.code000({ strikes: Number(payload.strikes) || CODE000_STRIKES });
|
|
801
|
-
return result ? sendJson(response, 200, { code: result.code, lockedForMs: result.lockedForMs, n: result.n }) : sendJson(response, 503, { error: 'MOTHER is silent.' });
|
|
1171
|
+
return result ? sendJson(response, 200, { code: result.code, lockedForMs: result.lockedForMs, n: result.n }) : sendJson(response, 503, { error: t('MOTHER is silent.') });
|
|
802
1172
|
}
|
|
803
1173
|
if (request.method === 'GET' && url.pathname === '/api/memory') {
|
|
804
|
-
if (!designationOk(url.searchParams.get('designation'))) return sendJson(response, 403, { error: 'UNABLE TO COMPUTE. UNABLE TO CLARIFY.' });
|
|
1174
|
+
if (!designationOk(url.searchParams.get('designation'))) return sendJson(response, 403, { error: t('UNABLE TO COMPUTE. UNABLE TO CLARIFY.') });
|
|
805
1175
|
const sealed = room.motherStatus()?.lockedForMs ?? 0;
|
|
806
1176
|
if (sealed > 0) return sendJson(response, 423, { error: `CODE000. THE ARCHIVE IS SEALED FOR ${Math.ceil(sealed / 60000)} MORE MINUTE${Math.ceil(sealed / 60000) === 1 ? '' : 'S'}.`, lockedForMs: sealed });
|
|
807
1177
|
const research = room.memoryResearch();
|
|
808
|
-
|
|
1178
|
+
if (!research) return sendJson(response, 503, { error: t('The room has no memory.') });
|
|
1179
|
+
// How grown this archive is, read from the same notes the map draws.
|
|
1180
|
+
const grown = maturity({ readiness: datasetReadiness(await store.readAll(), research.memories), notes: research.memories, links: research.links, stats: research.stats });
|
|
1181
|
+
return sendJson(response, 200, { ...research, maturity: grown });
|
|
1182
|
+
}
|
|
1183
|
+
// Where this room stands, in one place: what the archive is made of, whether it works, and
|
|
1184
|
+
// the one thing worth doing about it. Nothing here waits on a test — the free one is read
|
|
1185
|
+
// as it is asked for, the cheap one keeps itself fresh behind the screen, and the slow one
|
|
1186
|
+
// is the human's to start.
|
|
1187
|
+
if (request.method === 'GET' && url.pathname === '/api/maturity') {
|
|
1188
|
+
const research = room.memoryResearch();
|
|
1189
|
+
const notes = research?.memories ?? [];
|
|
1190
|
+
const grown = research ? maturity({ readiness: datasetReadiness(await store.readAll(), notes), notes, links: research.links, stats: research.stats }) : null;
|
|
1191
|
+
const exams = await room.exams({ refresh: true, findings: eyecat.findings() });
|
|
1192
|
+
void room.freshenCoverage();
|
|
1193
|
+
return sendJson(response, 200, {
|
|
1194
|
+
maturity: grown, exams, cold: research?.cold ?? null, asks: research?.ask?.length ?? 0,
|
|
1195
|
+
verdict: verdictFor({ maturity: grown, exams: exams.last, cold: research?.cold, asks: research?.ask?.length ?? 0 }),
|
|
1196
|
+
});
|
|
1197
|
+
}
|
|
1198
|
+
// Inside the core: the document MADRE writes in the human's name for the next turn, block
|
|
1199
|
+
// by block. Built on demand and stored nowhere; asking costs nothing and sends nothing.
|
|
1200
|
+
if (request.method === 'GET' && url.pathname === '/api/briefing') {
|
|
1201
|
+
const asked = url.searchParams.get('agent') ?? agents.find((agent) => agent.ready && !agent.local)?.id ?? agents[0]?.id;
|
|
1202
|
+
const mode = Math.min(4, Math.max(0, Number(url.searchParams.get('mode') ?? 1) || 0));
|
|
1203
|
+
const text = String(url.searchParams.get('text') ?? '').slice(0, 2000);
|
|
1204
|
+
const briefing = await room.briefing({ agent: asked, mode, text });
|
|
1205
|
+
if (!briefing) return sendJson(response, 404, { error: `No agent "${asked}" on this computer.` });
|
|
1206
|
+
return sendJson(response, 200, {
|
|
1207
|
+
...briefing,
|
|
1208
|
+
launch: await room.launch({ agent: asked, mode }),
|
|
1209
|
+
agents: agents.filter((agent) => agent.detected).map((agent) => ({ id: agent.id, label: agent.label, ready: agent.ready })),
|
|
1210
|
+
});
|
|
1211
|
+
}
|
|
1212
|
+
// The conversations of this project. One memory, one crew, one numbering; many records.
|
|
1213
|
+
if (request.method === 'GET' && url.pathname === '/api/chats') {
|
|
1214
|
+
return sendJson(response, 200, await listChats(roomDir));
|
|
1215
|
+
}
|
|
1216
|
+
if (request.method === 'POST' && url.pathname === '/api/chats') {
|
|
1217
|
+
if (room.working()) return sendJson(response, 409, { error: t('A turn is running in this conversation. Let it finish, or STOP ALL, and try again.') });
|
|
1218
|
+
const payload = await body(request).catch(() => ({}));
|
|
1219
|
+
const made = await createChat(roomDir, { title: typeof payload.title === 'string' ? payload.title : null });
|
|
1220
|
+
const mounted = await mountChat(made.id);
|
|
1221
|
+
if (mounted.error) return sendJson(response, 409, mounted);
|
|
1222
|
+
return sendJson(response, 200, { ...(await listChats(roomDir)), opened: made.id });
|
|
1223
|
+
}
|
|
1224
|
+
const chatMatch = url.pathname.match(/^\/api\/chats\/([a-z0-9]+)$/);
|
|
1225
|
+
if (chatMatch && request.method === 'POST') {
|
|
1226
|
+
const mounted = await mountChat(chatMatch[1]);
|
|
1227
|
+
if (mounted.error) return sendJson(response, 409, mounted);
|
|
1228
|
+
return sendJson(response, 200, { ...(await listChats(roomDir)), opened: chatMatch[1] });
|
|
1229
|
+
}
|
|
1230
|
+
if (chatMatch && request.method === 'PATCH') {
|
|
1231
|
+
const payload = await body(request).catch(() => ({}));
|
|
1232
|
+
const named = await renameChat(roomDir, chatMatch[1], payload.title);
|
|
1233
|
+
return named ? sendJson(response, 200, { ...(await listChats(roomDir)) }) : sendJson(response, 404, { error: t('No such conversation.') });
|
|
1234
|
+
}
|
|
1235
|
+
if (chatMatch && request.method === 'DELETE') {
|
|
1236
|
+
if (chatMatch[1] === chatId && room.working()) return sendJson(response, 409, { error: t('A turn is running in this conversation.') });
|
|
1237
|
+
const gone = await deleteChat(roomDir, chatMatch[1]);
|
|
1238
|
+
if (!gone) return sendJson(response, 404, { error: t('No such conversation.') });
|
|
1239
|
+
if (gone.error) return sendJson(response, 409, gone);
|
|
1240
|
+
// What the archivist distilled from it is the project's memory and stays where it is.
|
|
1241
|
+
if (chatMatch[1] === chatId) { const mounted = await mountChat(gone.active); if (mounted.error) return sendJson(response, 409, mounted); }
|
|
1242
|
+
return sendJson(response, 200, { ...(await listChats(roomDir)), deleted: gone.deleted });
|
|
1243
|
+
}
|
|
1244
|
+
// The three tests. Reading is free; running one is the human's call, and the slow one says
|
|
1245
|
+
// where it is while it works.
|
|
1246
|
+
if (request.method === 'GET' && url.pathname === '/api/maturity/exams') {
|
|
1247
|
+
return sendJson(response, 200, await room.exams({ refresh: true, findings: eyecat.findings() }));
|
|
1248
|
+
}
|
|
1249
|
+
if (request.method === 'POST' && url.pathname === '/api/maturity/exam') {
|
|
1250
|
+
const payload = await body(request).catch(() => ({}));
|
|
1251
|
+
if (payload.stop) return sendJson(response, 200, room.stopExam());
|
|
1252
|
+
const result = await room.runExam(String(payload.which ?? ''), { findings: eyecat.findings() });
|
|
1253
|
+
return sendJson(response, result?.error ? 409 : 200, { ...result, exams: await room.exams() });
|
|
1254
|
+
}
|
|
1255
|
+
// Somebody's own module, handed to MADRE from the panel. It runs inside MADRE with the
|
|
1256
|
+
// human's permissions, so it goes through the same door as everything else: written to a
|
|
1257
|
+
// scratch copy, imported there, checked against the house rules, and only then installed.
|
|
1258
|
+
if (request.method === 'POST' && url.pathname === '/api/extensions/upload') {
|
|
1259
|
+
const payload = await body(request).catch(() => ({}));
|
|
1260
|
+
const text = String(payload.text ?? '');
|
|
1261
|
+
const name = String(payload.name ?? 'module.mjs');
|
|
1262
|
+
if (!text.trim()) return sendJson(response, 400, { error: t('That file is empty.') });
|
|
1263
|
+
if (text.length > 400_000) return sendJson(response, 413, { error: t('A module file that big is not a module. Keep it under 400 KB.') });
|
|
1264
|
+
if (!/\.m?js$/.test(name)) return sendJson(response, 400, { error: t('A module is a .mjs file.') });
|
|
1265
|
+
try {
|
|
1266
|
+
const installed = await installModuleText({
|
|
1267
|
+
text, name, scope: payload.scope === 'project' ? 'project' : 'user',
|
|
1268
|
+
stateRoot: root, projectRoot: canonicalProjectRoot,
|
|
1269
|
+
// A browser hands over the bytes, not a path: there is nowhere to go back to. A
|
|
1270
|
+
// module that wants to be updatable says so itself, with updates: { url }.
|
|
1271
|
+
source: null,
|
|
1272
|
+
});
|
|
1273
|
+
await room.record('extension.installed', { id: installed.id, name: installed.name, origin: installed.origin, file: installed.file, version: installed.version, by: 'upload' });
|
|
1274
|
+
return sendJson(response, 200, { installed, extensions: await describeModules(await moduleContext()) });
|
|
1275
|
+
} catch (error) {
|
|
1276
|
+
// The author reads exactly what failed: a module that will not load is not installed.
|
|
1277
|
+
return sendJson(response, 422, { error: error.message });
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
// A newer copy of somebody's own module, from where they publish it or from the file it was
|
|
1281
|
+
// installed from. Asked for, never automatic: fetching means running what comes back.
|
|
1282
|
+
const refreshMatch = request.method === 'POST' && url.pathname.match(/^\/api\/extensions\/([a-z0-9-]+)\/refresh$/);
|
|
1283
|
+
if (refreshMatch) {
|
|
1284
|
+
const module = moduleById(refreshMatch[1]);
|
|
1285
|
+
if (!module?.external) return sendJson(response, 404, { error: t('Only a module you installed yourself can be refreshed.') });
|
|
1286
|
+
const origin = await moduleOrigin(module);
|
|
1287
|
+
if (!origin) return sendJson(response, 412, { error: `${module.name} does not say where a newer copy would come from. Declare updates: { url } in the module, or install it again from its file.` });
|
|
1288
|
+
const payload = await body(request).catch(() => ({}));
|
|
1289
|
+
let text;
|
|
1290
|
+
try {
|
|
1291
|
+
text = origin.kind === 'url'
|
|
1292
|
+
? await reportFetch(origin.from, { signal: AbortSignal.timeout(10000) }).then(async (answer) => { if (!answer.ok) throw new Error(`HTTP ${answer.status}`); return answer.text(); })
|
|
1293
|
+
: await readFile(origin.from, 'utf8');
|
|
1294
|
+
} catch (error) { return sendJson(response, 502, { error: `Could not read ${origin.from}: ${error.message}` }); }
|
|
1295
|
+
if (!payload.confirm) {
|
|
1296
|
+
// What would be installed, checked before it is offered. Checking installs nothing.
|
|
1297
|
+
try {
|
|
1298
|
+
const candidate = await verifyModuleText({ text, name: `${module.id}.mjs` });
|
|
1299
|
+
return sendJson(response, 200, { origin, current: module.version ?? null, candidate: candidate.version ?? null, same: (candidate.version ?? null) === (module.version ?? null) });
|
|
1300
|
+
} catch (error) { return sendJson(response, 422, { error: error.message, origin }); }
|
|
1301
|
+
}
|
|
1302
|
+
try {
|
|
1303
|
+
const installed = await installModuleText({ text, name: `${module.id}.mjs`, scope: module.origin === 'project' ? 'project' : 'user', stateRoot: root, projectRoot: canonicalProjectRoot, source: origin });
|
|
1304
|
+
await room.record('extension.installed', { id: installed.id, name: installed.name, origin: installed.origin, file: installed.file, version: installed.version, by: 'refresh' });
|
|
1305
|
+
return sendJson(response, 200, { installed, extensions: await describeModules(await moduleContext()) });
|
|
1306
|
+
} catch (error) { return sendJson(response, 422, { error: error.message }); }
|
|
1307
|
+
}
|
|
1308
|
+
// Updating what a module drives. Two calls: the first asks what would run and answers with
|
|
1309
|
+
// the command, the second runs exactly that. MADRE never runs a command the human has not
|
|
1310
|
+
// read, and the room carries the output line by line like any other install.
|
|
1311
|
+
const updateRunMatch = request.method === 'POST' && url.pathname.match(/^\/api\/extensions\/([a-z0-9-]+)\/update$/);
|
|
1312
|
+
if (updateRunMatch) {
|
|
1313
|
+
const module = moduleById(updateRunMatch[1]);
|
|
1314
|
+
if (!module?.updatePlan) return sendJson(response, 405, { error: `${updateRunMatch[1]} cannot update what it drives from here.` });
|
|
1315
|
+
const payload = await body(request).catch(() => ({}));
|
|
1316
|
+
const context = await moduleContext();
|
|
1317
|
+
const item = await module.describe(context);
|
|
1318
|
+
const known = await context.services.moduleUpdate(item, { force: Boolean(payload.check) });
|
|
1319
|
+
const plan = await module.updatePlan(context, { latest: known?.latest ?? null, current: item.version ?? null });
|
|
1320
|
+
if (!plan?.command) return sendJson(response, 412, { error: plan?.note ?? 'There is no way to update this from here.', download: plan?.download ?? null });
|
|
1321
|
+
if (payload.confirm !== true) return sendJson(response, 200, { plan: { display: plan.display, note: plan.note ?? '' }, update: known });
|
|
1322
|
+
if (installing) return sendJson(response, 409, { error: `Another install is running (${installing}).` });
|
|
1323
|
+
installing = module.id;
|
|
1324
|
+
await room.record('extension.install.started', { id: module.id, name: item.name, command: plan.display, platforms: [], alreadyInstalled: true });
|
|
1325
|
+
void (async () => {
|
|
1326
|
+
const result = await runInstaller({ command: plan.command, args: plan.args, projectRoot: canonicalProjectRoot, timeoutMs: 900000, onLine: (line) => { void room.record('extension.install.output', { id: module.id, lines: [String(line).slice(0, 500)] }); } });
|
|
1327
|
+
await plan.after?.().catch(() => null);
|
|
1328
|
+
const after = await module.describe(await moduleContext()).catch(() => null);
|
|
1329
|
+
await room.record('extension.install.finished', {
|
|
1330
|
+
id: module.id, name: item.name, ok: result.code === 0, installed: Boolean(after?.version),
|
|
1331
|
+
detail: result.code === 0 ? `${item.name} now runs ${after?.version ?? 'what it found'}` : (result.error ?? `exit ${result.code}`),
|
|
1332
|
+
});
|
|
1333
|
+
installing = null;
|
|
1334
|
+
})();
|
|
1335
|
+
return sendJson(response, 202, { running: true, command: plan.display });
|
|
1336
|
+
}
|
|
1337
|
+
// A question the human has no use for. It stops being offered; nothing else changes.
|
|
1338
|
+
if (request.method === 'POST' && url.pathname === '/api/memory/ask/dismiss') {
|
|
1339
|
+
const payload = await body(request).catch(() => ({}));
|
|
1340
|
+
if (!designationOk(payload.designation)) return sendJson(response, 403, { error: t('UNABLE TO COMPUTE. UNABLE TO CLARIFY.') });
|
|
1341
|
+
const done = room.dismissAsk(String(payload.id ?? '').slice(0, 80));
|
|
1342
|
+
return done ? sendJson(response, 200, done) : sendJson(response, 404, { error: t('The room has no memory.') });
|
|
1343
|
+
}
|
|
1344
|
+
// One memory's own traffic: who it keeps arriving with, who asked for it, what a
|
|
1345
|
+
// refutation did. Read while its card is open, so the card is alive rather than a snapshot.
|
|
1346
|
+
const trafficMatch = request.method === 'GET' && url.pathname.match(/^\/api\/memory\/(\d+)\/traffic$/);
|
|
1347
|
+
if (trafficMatch) {
|
|
1348
|
+
if (!designationOk(url.searchParams.get('designation'))) return sendJson(response, 403, { error: t('UNABLE TO COMPUTE. UNABLE TO CLARIFY.') });
|
|
1349
|
+
const sealed = room.motherStatus()?.lockedForMs ?? 0;
|
|
1350
|
+
if (sealed > 0) return sendJson(response, 423, { error: t('CODE000. THE ARCHIVE IS SEALED.'), lockedForMs: sealed });
|
|
1351
|
+
const traffic = room.memoryTraffic(Number(trafficMatch[1]));
|
|
1352
|
+
return traffic ? sendJson(response, 200, traffic) : sendJson(response, 404, { error: t('No such memory.') });
|
|
809
1353
|
}
|
|
810
1354
|
const forgetMatch = request.method === 'DELETE' && url.pathname.match(/^\/api\/memory\/(\d+)$/);
|
|
811
1355
|
if (forgetMatch) {
|
|
812
1356
|
const payload = await body(request).catch(() => ({}));
|
|
813
|
-
if (!designationOk(payload.designation)) return sendJson(response, 403, { error: 'UNABLE TO COMPUTE. UNABLE TO CLARIFY.' });
|
|
814
|
-
if ((room.motherStatus()?.lockedForMs ?? 0) > 0) return sendJson(response, 423, { error: 'CODE000. THE ARCHIVE IS SEALED.' });
|
|
1357
|
+
if (!designationOk(payload.designation)) return sendJson(response, 403, { error: t('UNABLE TO COMPUTE. UNABLE TO CLARIFY.') });
|
|
1358
|
+
if ((room.motherStatus()?.lockedForMs ?? 0) > 0) return sendJson(response, 423, { error: t('CODE000. THE ARCHIVE IS SEALED.') });
|
|
815
1359
|
const row = await room.forgetMemory(forgetMatch[1]);
|
|
816
|
-
return row ? sendJson(response, 200, { forgotten: row, stats: room.memoryStats() }) : sendJson(response, 404, { error: 'No such memory.' });
|
|
1360
|
+
return row ? sendJson(response, 200, { forgotten: row, stats: room.memoryStats() }) : sendJson(response, 404, { error: t('No such memory.') });
|
|
817
1361
|
}
|
|
818
1362
|
if (request.method === 'POST' && url.pathname === '/api/agents/probe') {
|
|
819
|
-
|
|
1363
|
+
// Look for the binaries again too: a CLI installed a moment ago must appear now.
|
|
1364
|
+
await redetectAgents();
|
|
1365
|
+
return sendJson(response, 200, { sessions, sessionsAt, agents: agents.map((agent) => ({ ...agent, login: loginPlanFor(agent), install: installPlanFor(agent), key: keyPlanFor(agent.id), ...(accountNoteFor(agent.id) ?? {}) })) });
|
|
1366
|
+
}
|
|
1367
|
+
// A provider key for a CLI that signs in from its own prompt. Local connections only: a key
|
|
1368
|
+
// pasted from another machine would travel the network. It is written where that CLI looks
|
|
1369
|
+
// for it and never reaches MADRE's config, its ledger or its logs.
|
|
1370
|
+
const keyMatch = request.method === 'POST' && url.pathname.match(/^\/api\/agents\/([a-z0-9-]+)\/key$/);
|
|
1371
|
+
if (keyMatch) {
|
|
1372
|
+
const id = keyMatch[1];
|
|
1373
|
+
const address = request.socket.remoteAddress ?? '';
|
|
1374
|
+
if (!['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(address)) {
|
|
1375
|
+
return sendJson(response, 403, { error: t('A key is only accepted from this computer, never over the network.') });
|
|
1376
|
+
}
|
|
1377
|
+
const agent = agents.find((item) => item.id === id);
|
|
1378
|
+
if (!agent?.detected) return sendJson(response, 412, { error: `${agent?.label ?? id} is not installed on this computer.` });
|
|
1379
|
+
if (!TAKES_KEY.has(id)) return sendJson(response, 400, { error: `${agent.label} signs in with a click, not with a key.` });
|
|
1380
|
+
const payload = await body(request).catch(() => ({}));
|
|
1381
|
+
const result = await applyKey({
|
|
1382
|
+
agent: id,
|
|
1383
|
+
key: payload.key,
|
|
1384
|
+
provider: payload.provider ?? null,
|
|
1385
|
+
home: credentialHome,
|
|
1386
|
+
executable: agent.path ?? id,
|
|
1387
|
+
probe: () => probeAgentAuth(agent),
|
|
1388
|
+
});
|
|
1389
|
+
if (!result.ok) return sendJson(response, 422, { error: result.error });
|
|
1390
|
+
await refreshSessions();
|
|
1391
|
+
// What the room remembers: that a key was set, and for which provider. Never the key.
|
|
1392
|
+
await room.record('connection.key.set', { agent: id, label: agent.label, provider: payload.provider ?? null, detail: result.detail ?? null });
|
|
1393
|
+
return sendJson(response, 200, { ok: true, session: sessions[id] ?? null });
|
|
1394
|
+
}
|
|
1395
|
+
const installAgentMatch = request.method === 'POST' && url.pathname.match(/^\/api\/agents\/([a-z0-9-]+)\/install$/);
|
|
1396
|
+
if (installAgentMatch) {
|
|
1397
|
+
const result = await installAgent(installAgentMatch[1]);
|
|
1398
|
+
return sendJson(response, result.status, result.body);
|
|
820
1399
|
}
|
|
821
1400
|
const loginMatch = request.method === 'POST' && url.pathname.match(/^\/api\/agents\/([a-z0-9-]+)\/login$/);
|
|
822
1401
|
if (loginMatch) {
|
|
@@ -833,7 +1412,7 @@ export async function createPulseServer({
|
|
|
833
1412
|
}
|
|
834
1413
|
if (request.method === 'GET' && url.pathname === '/api/agents/opencode/models') {
|
|
835
1414
|
const opencode = agents.find((agent) => agent.id === 'opencode');
|
|
836
|
-
if (!opencode?.detected) return sendJson(response, 404, { error: 'OpenCode is not installed.' });
|
|
1415
|
+
if (!opencode?.detected) return sendJson(response, 404, { error: t('OpenCode is not installed.') });
|
|
837
1416
|
const models = [];
|
|
838
1417
|
await runInstaller({ command: opencode.path, args: ['models'], projectRoot: canonicalProjectRoot, timeoutMs: 20000, onLine: (line) => { if (/^[\w.-]+\/[\w.:-]+$/.test(line.trim())) models.push(line.trim()); } });
|
|
839
1418
|
return sendJson(response, 200, { models });
|
|
@@ -845,24 +1424,93 @@ export async function createPulseServer({
|
|
|
845
1424
|
const stopMatch = request.method === 'POST' && url.pathname.match(/^\/api\/plans\/([0-9a-f-]+)\/stop$/);
|
|
846
1425
|
if (stopMatch) {
|
|
847
1426
|
const stopped = await room.stopPlan(stopMatch[1]);
|
|
848
|
-
return sendJson(response, stopped ? 202 : 404, stopped ? { stopped: true } : { error: 'No running plan with that id.' });
|
|
1427
|
+
return sendJson(response, stopped ? 202 : 404, stopped ? { stopped: true } : { error: t('No running plan with that id.') });
|
|
849
1428
|
}
|
|
850
1429
|
if (request.method === 'GET' && url.pathname === '/api/commands') {
|
|
851
|
-
|
|
1430
|
+
const ctx = await moduleContext();
|
|
1431
|
+
const fromModules = await Promise.all(moduleCommands().map(async (command) => {
|
|
1432
|
+
const settings = command.module.settingsFrom(ctx.config);
|
|
1433
|
+
const on = command.module.kind !== 'builtin' || Boolean(settings.enabled);
|
|
1434
|
+
let available = on;
|
|
1435
|
+
if (on && command.available) { try { available = Boolean(await command.available({ ...ctx, settings })); } catch { available = false; } }
|
|
1436
|
+
return { name: command.name, module: command.module.id, title: command.title, usage: command.usage, summary: command.summary, available };
|
|
1437
|
+
}));
|
|
1438
|
+
return sendJson(response, 200, { commands: [...(await listCommands({ projectRoot: canonicalProjectRoot })), ...fromModules] });
|
|
852
1439
|
}
|
|
853
1440
|
if (request.method === 'POST' && url.pathname === '/api/commands') {
|
|
854
1441
|
const { text } = await body(request);
|
|
855
1442
|
const parsed = parseCommand(text);
|
|
856
|
-
if (!parsed) return sendJson(response, 400, { error: 'Not a command. Commands start with "/" followed by a name.' });
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
if (
|
|
860
|
-
|
|
1443
|
+
if (!parsed) return sendJson(response, 400, { error: t('Not a command. Commands start with "/" followed by a name.') });
|
|
1444
|
+
let command = commandByName(parsed.name);
|
|
1445
|
+
let result;
|
|
1446
|
+
if (command) {
|
|
1447
|
+
if (!(await command.available({ projectRoot: canonicalProjectRoot }))) return sendJson(response, 412, { error: `/${parsed.name} is not available in this project (${command.title}).` });
|
|
1448
|
+
result = await command.execute({ projectRoot: canonicalProjectRoot, args: parsed.args });
|
|
1449
|
+
} else {
|
|
1450
|
+
// A module's command: runs with the module's ctx and settings, only while the module is on.
|
|
1451
|
+
const own = moduleCommands().find((item) => item.name === parsed.name);
|
|
1452
|
+
if (!own) return sendJson(response, 404, { error: `Unknown command /${parsed.name}.` });
|
|
1453
|
+
const ctx = await moduleContext();
|
|
1454
|
+
const settings = own.module.settingsFrom(ctx.config);
|
|
1455
|
+
if (own.module.kind === 'builtin' && !settings.enabled) return sendJson(response, 412, { error: `/${parsed.name} belongs to ${own.module.name}, which is off. Enable it in MODULES.` });
|
|
1456
|
+
if (own.available && !(await own.available({ ...ctx, settings }).catch(() => false))) return sendJson(response, 412, { error: `/${parsed.name} is not available here (${own.title}).` });
|
|
1457
|
+
try {
|
|
1458
|
+
const answer = await own.execute({ ...ctx, settings }, parsed.args);
|
|
1459
|
+
result = { ok: answer?.ok !== false, title: answer?.title ?? own.title, text: String(answer?.text ?? '') };
|
|
1460
|
+
} catch (error) { result = { ok: false, title: own.title, text: `/${parsed.name} failed: ${error.message}` }; }
|
|
1461
|
+
command = own;
|
|
1462
|
+
}
|
|
861
1463
|
const event = await room.recordCommand({ name: parsed.name, args: parsed.args, ...result });
|
|
862
1464
|
return sendJson(response, result.ok ? 200 : 422, { command: parsed.name, title: result.title, ok: result.ok, sequence: event.sequence });
|
|
863
1465
|
}
|
|
864
1466
|
if (request.method === 'GET' && url.pathname === '/api/extensions') {
|
|
865
|
-
|
|
1467
|
+
const extensions = await describeModules(await moduleContext());
|
|
1468
|
+
void warmModuleUpdates(extensions);
|
|
1469
|
+
return sendJson(response, 200, { installing, extensions, failures: loadFailures, folders: moduleFolders({ stateRoot: root, projectRoot: canonicalProjectRoot }), sdk: 'https://github.com/jossuealcacao-exe/madre/blob/main/docs/SDK.md' });
|
|
1470
|
+
}
|
|
1471
|
+
// The human installs a module file an agent wrote (or they did): checked first, then copied into the chosen folder.
|
|
1472
|
+
if (request.method === 'POST' && url.pathname === '/api/extensions/install-file') {
|
|
1473
|
+
const payload = await body(request).catch(() => ({}));
|
|
1474
|
+
const relative = String(payload.path ?? '').replace(/^\/+/, '');
|
|
1475
|
+
if (!relative || relative.includes('..')) return sendJson(response, 400, { error: t('Give the project-relative path of a <id>.module.mjs file.') });
|
|
1476
|
+
try {
|
|
1477
|
+
const installed = await installModuleFile({ source: join(canonicalProjectRoot, relative), scope: payload.scope === 'project' ? 'project' : 'user', stateRoot: root, projectRoot: canonicalProjectRoot, roomDir });
|
|
1478
|
+
await room.record('extension.installed', { id: installed.id, name: installed.name, origin: installed.origin, from: relative, by: 'you' });
|
|
1479
|
+
return sendJson(response, 200, { installed, extensions: await describeModules(await moduleContext()) });
|
|
1480
|
+
} catch (error) { return sendJson(response, 422, { error: error.message }); }
|
|
1481
|
+
}
|
|
1482
|
+
const removeMatch = request.method === 'DELETE' && url.pathname.match(/^\/api\/extensions\/([a-z0-9-]+)$/);
|
|
1483
|
+
if (removeMatch) {
|
|
1484
|
+
try {
|
|
1485
|
+
const removed = await removeExternalModule({ id: removeMatch[1], stateRoot: root, projectRoot: canonicalProjectRoot });
|
|
1486
|
+
await room.record('extension.removed', { id: removed.id, name: removed.name, by: 'you' });
|
|
1487
|
+
return sendJson(response, 200, { removed, extensions: await describeModules(await moduleContext()) });
|
|
1488
|
+
} catch (error) { return sendJson(response, moduleById(removeMatch[1]) ? 403 : 404, { error: error.message }); }
|
|
1489
|
+
}
|
|
1490
|
+
// The human edited or added a module file: load it again without restarting the room.
|
|
1491
|
+
if (request.method === 'POST' && url.pathname === '/api/extensions/reload') {
|
|
1492
|
+
const outcome = await loadExternalModules({ stateRoot: root, projectRoot: canonicalProjectRoot });
|
|
1493
|
+
await room.record('extension.reloaded', { loaded: outcome.loaded.map((module) => module.id), failures: outcome.failures.length });
|
|
1494
|
+
return sendJson(response, 200, { loaded: outcome.loaded.map((module) => ({ id: module.id, name: module.name, origin: module.origin, file: module.file })), failures: outcome.failures, extensions: await describeModules(await moduleContext()) });
|
|
1495
|
+
}
|
|
1496
|
+
// The button beside a version: look now, outside the day's cache. It answers for this card
|
|
1497
|
+
// alone, and what it asks about is the name of a package or a repository, nothing else.
|
|
1498
|
+
const updateMatch = request.method === 'POST' && url.pathname.match(/^\/api\/extensions\/([a-z0-9-]+)\/updates$/);
|
|
1499
|
+
if (updateMatch) {
|
|
1500
|
+
const context = await moduleContext();
|
|
1501
|
+
const module = moduleById(updateMatch[1]);
|
|
1502
|
+
if (!module) return sendJson(response, 404, { error: `No module "${updateMatch[1]}".` });
|
|
1503
|
+
const item = await module.describe(context);
|
|
1504
|
+
const update = await context.services.moduleUpdate(item, { force: true });
|
|
1505
|
+
return sendJson(response, 200, { id: item.id, version: item.version, update });
|
|
1506
|
+
}
|
|
1507
|
+
// One setting from a card's own floor. The module declared it; MADRE saves it.
|
|
1508
|
+
const settingMatch = request.method === 'POST' && url.pathname.match(/^\/api\/extensions\/([a-z0-9-]+)\/settings$/);
|
|
1509
|
+
if (settingMatch) {
|
|
1510
|
+
const module = moduleById(settingMatch[1]);
|
|
1511
|
+
if (!module?.setControl) return sendJson(response, 404, { error: `No settings on "${settingMatch[1]}".` });
|
|
1512
|
+
const result = await module.setControl(await moduleContext(), await body(request));
|
|
1513
|
+
return sendJson(response, result.status, result.body);
|
|
866
1514
|
}
|
|
867
1515
|
const installMatch = request.method === 'POST' && url.pathname.match(/^\/api\/extensions\/([a-z0-9-]+)\/install$/);
|
|
868
1516
|
if (installMatch) {
|
|
@@ -900,30 +1548,36 @@ export async function createPulseServer({
|
|
|
900
1548
|
});
|
|
901
1549
|
return sendJson(response, 200, { emitted: Boolean(warning), warning });
|
|
902
1550
|
}
|
|
903
|
-
sendJson(response, 404, { error: 'Not found.' });
|
|
1551
|
+
sendJson(response, 404, { error: t('Not found.') });
|
|
904
1552
|
} catch (error) {
|
|
905
1553
|
sendJson(response, 400, { error: error.message });
|
|
906
1554
|
}
|
|
907
1555
|
});
|
|
1556
|
+
listening = server;
|
|
908
1557
|
// `server.close()` only resolves once every connection has ended, so the
|
|
909
1558
|
// long-lived SSE responses must be ended before the native close runs.
|
|
910
1559
|
const nativeClose = server.close.bind(server);
|
|
911
1560
|
server.close = (callback) => {
|
|
912
1561
|
clearInterval(poller);
|
|
913
1562
|
clearTimeout(embedKick);
|
|
1563
|
+
unwatchOutbound();
|
|
914
1564
|
// In-flight agent processes are killed and their turns recorded as failed
|
|
915
1565
|
// before the SSE clients go away, so open pages see the outcome.
|
|
916
1566
|
const shutdown = room.shutdown().catch((error) => console.error(`MADRE shutdown error: ${error.message}`));
|
|
917
1567
|
let result;
|
|
918
1568
|
shutdown.then(() => {
|
|
919
|
-
void broadcastPending().then(() => {
|
|
1569
|
+
void broadcastPending().then(() => chatWrites).then(() => {
|
|
920
1570
|
unsubscribe();
|
|
921
1571
|
unsubscribeSentinel();
|
|
1572
|
+
unsubscribeEyecat();
|
|
1573
|
+
unsubscribeLive();
|
|
922
1574
|
unsubscribeGhost();
|
|
923
1575
|
for (const client of clients.keys()) client.end();
|
|
924
1576
|
clients.clear();
|
|
925
1577
|
// The memory file closes last, once nothing else writes to it; WAL and shm go with it.
|
|
926
1578
|
try { memory?.close(); } catch { /* already closed */ }
|
|
1579
|
+
void outbound.drain();
|
|
1580
|
+
releaseOpen();
|
|
927
1581
|
result = nativeClose(callback);
|
|
928
1582
|
server.closeIdleConnections?.();
|
|
929
1583
|
});
|
|
@@ -932,16 +1586,18 @@ export async function createPulseServer({
|
|
|
932
1586
|
};
|
|
933
1587
|
server.on('close', () => quotaMonitor.stop());
|
|
934
1588
|
|
|
935
|
-
return { server, store, agents, quotaMonitor };
|
|
1589
|
+
return { server, store, agents, quotaMonitor, markOpen, releaseOpen };
|
|
936
1590
|
}
|
|
937
1591
|
|
|
938
1592
|
export async function startPulse({ port, projectRoot, openBrowser }) {
|
|
939
|
-
const { server, agents } = await createPulseServer({ projectRoot, quotaSources: defaultQuotaSources() });
|
|
1593
|
+
const { server, agents, markOpen } = await createPulseServer({ projectRoot, quotaSources: defaultQuotaSources() });
|
|
940
1594
|
await new Promise((resolve, reject) => {
|
|
941
1595
|
server.once('error', reject);
|
|
942
1596
|
server.listen(port, '127.0.0.1', resolve);
|
|
943
1597
|
});
|
|
944
1598
|
const address = server.address();
|
|
1599
|
+
// The room is taken from here on, and says so on disk for anyone who tries to open it again.
|
|
1600
|
+
await markOpen(address.port);
|
|
945
1601
|
const url = `http://127.0.0.1:${address.port}`;
|
|
946
1602
|
const ready = agents.filter((agent) => agent.ready).map((agent) => agent.label).join(', ') || 'none';
|
|
947
1603
|
const detected = agents.filter((agent) => agent.detected).map((agent) => agent.label).join(', ') || 'none';
|