@jossuealcala/madre 0.3.3 → 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 +412 -3
- package/CONTRIBUTING.md +3 -1
- package/README.md +67 -185
- 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 +3825 -851
- package/public/es.js +2050 -0
- package/public/i18n.js +66 -0
- package/public/index.html +94 -13
- package/public/inquiry.js +220 -0
- package/public/resay.js +77 -0
- package/public/styles.css +602 -62
- package/public/troubleshooting.js +168 -46
- package/src/adapters/claude.mjs +2 -1
- package/src/adapters/codex.mjs +2 -1
- package/src/adapters/gemini.mjs +6 -5
- package/src/adapters/opencode.mjs +2 -1
- package/src/adapters/process.mjs +17 -5
- package/src/asking.mjs +128 -0
- package/src/auth-probe.mjs +58 -1
- package/src/chats.mjs +193 -0
- package/src/checkpoint.mjs +1 -1
- package/src/cold.mjs +56 -0
- package/src/commands.mjs +6 -0
- 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/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 +5 -3
- package/src/modules/helpers.mjs +31 -0
- package/src/modules/image-studio.mjs +9 -4
- package/src/modules/index.mjs +141 -9
- package/src/modules/ollama.mjs +66 -10
- package/src/modules/playwright.mjs +36 -19
- package/src/modules/ripley.mjs +5 -3
- package/src/modules/sdk.mjs +93 -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/economy.mjs +161 -0
- package/src/room/prompt.mjs +118 -46
- package/src/room.mjs +443 -44
- package/src/runtime-detection.mjs +27 -8
- package/src/server.mjs +699 -69
- package/src/setup.mjs +1 -1
- package/src/updates.mjs +4 -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, toolsForTurn as modulesToolsForTurn } 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,16 +37,18 @@ 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
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';
|
|
47
52
|
import { spawn } from 'node:child_process';
|
|
48
53
|
import { totalmem } from 'node:os';
|
|
49
54
|
import { discoverModels } from './models.mjs';
|
|
@@ -51,6 +56,10 @@ import { setImageModule } from './capabilities.mjs';
|
|
|
51
56
|
import { resolveGeminiKey } from './image-studio.mjs';
|
|
52
57
|
import { commandByName, listCommands, parseCommand } from './commands.mjs';
|
|
53
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';
|
|
54
63
|
|
|
55
64
|
const sourceDirectory = dirname(fileURLToPath(import.meta.url));
|
|
56
65
|
const publicDirectory = join(sourceDirectory, '..', 'public');
|
|
@@ -69,7 +78,7 @@ async function body(request) {
|
|
|
69
78
|
return JSON.parse(text || '{}');
|
|
70
79
|
}
|
|
71
80
|
|
|
72
|
-
function openUrl(url) {
|
|
81
|
+
export function openUrl(url) {
|
|
73
82
|
const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
|
|
74
83
|
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
|
|
75
84
|
execFile(command, args, () => {});
|
|
@@ -115,6 +124,11 @@ export async function createPulseServer({
|
|
|
115
124
|
// Tests substitute the real installers with local scripts.
|
|
116
125
|
installers = {},
|
|
117
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(),
|
|
118
132
|
probe = probeAll,
|
|
119
133
|
imageKey = resolveGeminiKey,
|
|
120
134
|
// The sentinel's outbound channel; tests hand in a fake.
|
|
@@ -125,14 +139,51 @@ export async function createPulseServer({
|
|
|
125
139
|
const root = stateRoot ?? process.env.PULSE_HOME ?? join(homedir(), '.pulse');
|
|
126
140
|
// ~/.pulse/config.json fills in whatever the environment did not set.
|
|
127
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);
|
|
128
145
|
// RIPLEY renders HTML in the viewer only while the human has it switched on; read live, the switch is the module's.
|
|
129
146
|
const ripleyOn = async () => Boolean((await readConfig(root)).modules?.ripley?.enabled);
|
|
130
147
|
agentTimeouts ??= agentTimeoutsFromEnv();
|
|
131
|
-
const
|
|
148
|
+
const detector = detect ?? (providedAgents ? null : detectAgents);
|
|
149
|
+
const agents = providedAgents ?? await detector();
|
|
132
150
|
const canonicalProjectRoot = await realpath(projectRoot).catch(() => resolve(projectRoot));
|
|
133
151
|
const roomDir = join(root, 'rooms', projectRoomId(canonicalProjectRoot));
|
|
134
|
-
|
|
135
|
-
|
|
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();
|
|
136
187
|
// The room's memory: derived from the ledger, rebuilt if missing or stale,
|
|
137
188
|
// and never a reason for the room not to open.
|
|
138
189
|
// Release channel: one registry read a day, cached for every room on this machine. On by
|
|
@@ -142,13 +193,25 @@ export async function createPulseServer({
|
|
|
142
193
|
const install = detectInstall({ projectRoot: canonicalProjectRoot });
|
|
143
194
|
async function versionView({ force = false } = {}) {
|
|
144
195
|
const check = await checkForUpdate({ name: PACKAGE.name, current: PACKAGE.version, cacheFile: join(root, 'updates.json'), fetchImpl: reportFetch, enabled: updatesEnabled(), force });
|
|
145
|
-
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 };
|
|
146
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
|
+
|
|
147
210
|
// Privacy: the terms that never travel through this room, from config.json and the environment.
|
|
148
211
|
// config.json is shared by every room on this machine, so the list is re-read before each
|
|
149
212
|
// human message and whenever MU/TH/UR looks: a term named in one room guards them all.
|
|
150
|
-
const privacy = new Privacy(privacySettings(await readConfig(root)));
|
|
151
|
-
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; };
|
|
152
215
|
const memory = await new RoomMemory(join(roomDir, 'memory.sqlite')).attachPrivacy(privacy).initialize(store)
|
|
153
216
|
.catch((error) => { console.error(`MADRE memory unavailable, turns get the recent window only: ${error.message}`); return null; });
|
|
154
217
|
// Meaning-aware recall through the user's own Gemini key, when there is one;
|
|
@@ -188,11 +251,24 @@ export async function createPulseServer({
|
|
|
188
251
|
if (room) {
|
|
189
252
|
room.setInvoker(MADRE_ADAPTER, fifth.ready ? madreInvoker({ memory, ollama: () => ({ ...ollama, chatModel: ollama.madreModel ?? ollama.chatModel }), fetchImpl: reportFetch }) : null);
|
|
190
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
|
+
}
|
|
191
267
|
}
|
|
192
268
|
return { ...ollama, settings, embeddings: Boolean(useEmbeddings), archivist: Boolean(useArchivist), agent: fifth.ready };
|
|
193
269
|
}
|
|
194
270
|
// Memory settings the human keeps in config.json (MU/TH/UR → MEMORY); environment still wins at launch.
|
|
195
|
-
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 ?? {}) });
|
|
196
272
|
const embedEnvFor = (cfg) => {
|
|
197
273
|
const view = { ...process.env };
|
|
198
274
|
if (!process.env.PULSE_EMBED_PROVIDER && !process.env.PULSE_EMBED) {
|
|
@@ -210,7 +286,7 @@ export async function createPulseServer({
|
|
|
210
286
|
const startupMemory = await memoryConfig();
|
|
211
287
|
let listening = null;
|
|
212
288
|
const listeningPort = () => listening?.address?.()?.port ?? Number(process.env.PULSE_PORT ?? 4317);
|
|
213
|
-
|
|
289
|
+
const roomOptions = () => ({
|
|
214
290
|
store,
|
|
215
291
|
agents,
|
|
216
292
|
projectRoot,
|
|
@@ -220,6 +296,7 @@ export async function createPulseServer({
|
|
|
220
296
|
softTokenBudget,
|
|
221
297
|
contextMaxChars,
|
|
222
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,
|
|
223
300
|
distill: {
|
|
224
301
|
every: Number(process.env.PULSE_DISTILL_EVERY ?? startupMemory.every),
|
|
225
302
|
idleMs: Number(process.env.PULSE_DISTILL_IDLE_MS ?? startupMemory.idleMinutes * 60000),
|
|
@@ -236,6 +313,7 @@ export async function createPulseServer({
|
|
|
236
313
|
delegation,
|
|
237
314
|
maxPlanSteps,
|
|
238
315
|
});
|
|
316
|
+
room = new Room(roomOptions());
|
|
239
317
|
await wireOllama({ probe: false });
|
|
240
318
|
const embedKick = memory?.embedder ? setTimeout(() => void room.embedNow(), 2000) : null;
|
|
241
319
|
embedKick?.unref?.();
|
|
@@ -266,7 +344,7 @@ export async function createPulseServer({
|
|
|
266
344
|
capabilities: room.capabilities(),
|
|
267
345
|
memory: memorySettingsView(),
|
|
268
346
|
updates: { check: updatesEnabled(), envWins: process.env.PULSE_UPDATE_CHECK !== undefined },
|
|
269
|
-
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 },
|
|
270
348
|
};
|
|
271
349
|
}
|
|
272
350
|
// What MU/TH/UR shows under MEMORY: the live values, who could distil, and what embeds today.
|
|
@@ -274,7 +352,7 @@ export async function createPulseServer({
|
|
|
274
352
|
const distill = room.distillSettings();
|
|
275
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 }))];
|
|
276
354
|
return {
|
|
277
|
-
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,
|
|
278
356
|
candidates, embedder: memory?.embedder?.model ?? null, embedProvider: process.env.PULSE_EMBED === '0' ? 'off' : (process.env.PULSE_EMBED_PROVIDER ?? null),
|
|
279
357
|
ollama: { running: ollama.running, embedModel: ollama.embedModel, chatModel: ollama.chatModel },
|
|
280
358
|
stats: room.memoryStats() ? { entries: room.memoryStats().entries, memories: room.memoryStats().memories, pending: room.memoryStats().pending } : null,
|
|
@@ -316,9 +394,10 @@ export async function createPulseServer({
|
|
|
316
394
|
if (Array.isArray(m.archivists)) next.archivists = m.archivists.length ? m.archivists.map(String) : null;
|
|
317
395
|
if (['auto', 'ollama', 'gemini', 'off'].includes(m.embedProvider)) next.embedProvider = m.embedProvider;
|
|
318
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;
|
|
319
398
|
if (typeof m.enabled === 'boolean') next.enabled = m.enabled;
|
|
320
399
|
config.memory = next;
|
|
321
|
-
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 } : {}) });
|
|
322
401
|
live.memory = true;
|
|
323
402
|
}
|
|
324
403
|
if (patch.scopes && typeof patch.scopes === 'object') {
|
|
@@ -377,12 +456,78 @@ export async function createPulseServer({
|
|
|
377
456
|
return { status: 202, body: { accepted: true, command: plan.display } };
|
|
378
457
|
}
|
|
379
458
|
|
|
380
|
-
//
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
+
|
|
386
531
|
|
|
387
532
|
async function rawBody(request, limit) {
|
|
388
533
|
const chunks = [];
|
|
@@ -398,7 +543,8 @@ export async function createPulseServer({
|
|
|
398
543
|
const startupConfig = await readConfig(root);
|
|
399
544
|
room.setScopes(startupConfig.scopes ?? {});
|
|
400
545
|
setImageModule(startupConfig.modules?.imageStudio ?? {});
|
|
401
|
-
|
|
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));
|
|
402
548
|
const recoveredTurns = await room.reconcile();
|
|
403
549
|
if (recoveredTurns) console.error(`MADRE recovered ${recoveredTurns} unfinished turn(s) from a previous run.`);
|
|
404
550
|
const quotaMonitor = new QuotaMonitor({
|
|
@@ -456,7 +602,7 @@ export async function createPulseServer({
|
|
|
456
602
|
})();
|
|
457
603
|
return inFlight;
|
|
458
604
|
}
|
|
459
|
-
|
|
605
|
+
let unsubscribe = () => {};
|
|
460
606
|
|
|
461
607
|
// The error sentinel: failures MU/TH/UR cannot classify, and crashes, become
|
|
462
608
|
// redacted reports in the ledger. Sending them anywhere is the human's call.
|
|
@@ -469,8 +615,8 @@ export async function createPulseServer({
|
|
|
469
615
|
emit: (type, payload) => room.record(type, payload),
|
|
470
616
|
save: async (settings) => { const current = await readConfig(root); await updateConfig(root, { telemetry: { ...(current.telemetry ?? {}), ...settings } }); },
|
|
471
617
|
});
|
|
472
|
-
sentinel.
|
|
473
|
-
|
|
618
|
+
outbound.watchReportUrl(sentinel.settings().reportUrl);
|
|
619
|
+
let unsubscribeSentinel = () => {};
|
|
474
620
|
if (!testMode && !crashHandlersInstalled) {
|
|
475
621
|
crashHandlersInstalled = true;
|
|
476
622
|
for (const origin of ['uncaughtException', 'unhandledRejection']) {
|
|
@@ -480,7 +626,79 @@ export async function createPulseServer({
|
|
|
480
626
|
});
|
|
481
627
|
}
|
|
482
628
|
}
|
|
483
|
-
|
|
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();
|
|
484
702
|
const poller = setInterval(() => { void broadcastPending(); }, broadcastIntervalMs);
|
|
485
703
|
poller.unref();
|
|
486
704
|
|
|
@@ -496,9 +714,48 @@ export async function createPulseServer({
|
|
|
496
714
|
services: {
|
|
497
715
|
imageKey,
|
|
498
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
|
+
}),
|
|
499
723
|
ollama: {
|
|
500
724
|
state: () => ollama,
|
|
501
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
|
+
},
|
|
502
759
|
// Pulls stream into the room like a module install; one at a time.
|
|
503
760
|
pull: async (model) => {
|
|
504
761
|
if (installing) return { ok: false, error: `Another install is running (${installing}).` };
|
|
@@ -531,7 +788,7 @@ export async function createPulseServer({
|
|
|
531
788
|
if (!extension) return { status: 404, body: { error: `Unknown module: ${id}.` } };
|
|
532
789
|
// Built-ins are switches the module itself defines.
|
|
533
790
|
if (extension.toggle) return extension.toggle(await moduleContext(), payload);
|
|
534
|
-
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.') } };
|
|
535
792
|
if (installing) return { status: 409, body: { error: `Another install is running (${installing}).` } };
|
|
536
793
|
const preflight = extension.preflight ? await extension.preflight(canonicalProjectRoot) : { ok: true, problems: [] };
|
|
537
794
|
if (!preflight.ok && !installers[id]) {
|
|
@@ -589,7 +846,7 @@ export async function createPulseServer({
|
|
|
589
846
|
response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
|
590
847
|
return response.end(html);
|
|
591
848
|
}
|
|
592
|
-
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)) {
|
|
593
850
|
const js = await readFile(join(publicDirectory, url.pathname.slice(1)));
|
|
594
851
|
response.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8' });
|
|
595
852
|
return response.end(js);
|
|
@@ -602,8 +859,13 @@ export async function createPulseServer({
|
|
|
602
859
|
if (request.method === 'GET' && url.pathname === '/api/state') {
|
|
603
860
|
return sendJson(response, 200, {
|
|
604
861
|
projectRoot,
|
|
605
|
-
|
|
606
|
-
|
|
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() },
|
|
607
869
|
ripley: { enabled: await ripleyOn() },
|
|
608
870
|
softTokenBudget,
|
|
609
871
|
timeouts: Object.fromEntries(agents.map((agent) => [agent.id, room.timeoutFor(agent.id)])),
|
|
@@ -674,7 +936,7 @@ export async function createPulseServer({
|
|
|
674
936
|
// resolve to the same route.
|
|
675
937
|
const previewMatch = request.method === 'GET' && url.pathname.match(/^\/preview\/(project|attachments)\/(.*)$/);
|
|
676
938
|
if (previewMatch) {
|
|
677
|
-
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.') });
|
|
678
940
|
const which = previewMatch[1] === 'attachments' ? attachmentsRoot : canonicalProjectRoot;
|
|
679
941
|
const relative = decodeURIComponent(previewMatch[2]);
|
|
680
942
|
const file = await readServable(which, relative);
|
|
@@ -709,7 +971,7 @@ export async function createPulseServer({
|
|
|
709
971
|
}
|
|
710
972
|
if (request.method === 'GET' && url.pathname === '/api/settings') {
|
|
711
973
|
await refreshPrivacy();
|
|
712
|
-
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) ?? {}) })) });
|
|
713
975
|
}
|
|
714
976
|
if (request.method === 'POST' && url.pathname === '/api/settings') {
|
|
715
977
|
return sendJson(response, 200, { settings: await applySettings(await body(request)) });
|
|
@@ -722,13 +984,15 @@ export async function createPulseServer({
|
|
|
722
984
|
}
|
|
723
985
|
if (request.method === 'POST' && url.pathname === '/api/sentinel/settings') {
|
|
724
986
|
const patch = await body(request).catch(() => ({}));
|
|
725
|
-
|
|
987
|
+
const saved = await sentinel.setSettings(patch);
|
|
988
|
+
outbound.watchReportUrl(saved.reportUrl);
|
|
989
|
+
return sendJson(response, 200, { settings: saved });
|
|
726
990
|
}
|
|
727
991
|
const sentinelMatch = url.pathname.match(/^\/api\/sentinel\/([a-f0-9]{10})\/(issue|send)$/);
|
|
728
992
|
if (sentinelMatch && request.method === (sentinelMatch[2] === 'issue' ? 'GET' : 'POST')) {
|
|
729
993
|
if (sentinelMatch[2] === 'issue') {
|
|
730
994
|
const issue = sentinel.issueUrl(sentinelMatch[1]);
|
|
731
|
-
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.') });
|
|
732
996
|
}
|
|
733
997
|
const outcome = await sentinel.send(sentinelMatch[1]);
|
|
734
998
|
return sendJson(response, outcome.ok ? 200 : (outcome.status === 404 || outcome.status === 412 ? outcome.status : 502), outcome);
|
|
@@ -740,23 +1004,99 @@ export async function createPulseServer({
|
|
|
740
1004
|
const result = await moduleRoute.route.handler(await moduleContext(), { request, url, params: moduleRoute.params, payload });
|
|
741
1005
|
return sendJson(response, result.status ?? 200, result.body ?? {});
|
|
742
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
|
+
|
|
743
1070
|
// The dataset behind MADRE AI: exported on demand next to the ledger.
|
|
744
1071
|
if (url.pathname === '/api/dataset' && (request.method === 'GET' || request.method === 'POST')) {
|
|
745
1072
|
const dir = join(roomDir, 'dataset');
|
|
746
1073
|
const notes = memory ? memory.memories({ limit: 5000 }) : [];
|
|
747
1074
|
if (request.method === 'GET') {
|
|
748
1075
|
const manifest = await readFile(join(dir, 'manifest.json'), 'utf8').then(JSON.parse).catch(() => null);
|
|
749
|
-
|
|
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() });
|
|
750
1079
|
}
|
|
751
1080
|
const events = await store.readAll();
|
|
752
1081
|
const result = await exportDataset({ events, notes, dir, project: basename(canonicalProjectRoot), home: homedir(), privacy });
|
|
753
1082
|
await room.record('dataset.exported', { pairs: result.pairs, turns: result.turns, notes: result.notes, train: result.train, valid: result.valid, dir });
|
|
754
|
-
|
|
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() });
|
|
755
1086
|
}
|
|
756
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
|
+
}
|
|
757
1097
|
if (request.method === 'GET' && url.pathname === '/api/privacy') {
|
|
758
1098
|
await refreshPrivacy();
|
|
759
|
-
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()) });
|
|
760
1100
|
}
|
|
761
1101
|
if (request.method === 'POST' && url.pathname === '/api/privacy') {
|
|
762
1102
|
const patch = await body(request).catch(() => ({}));
|
|
@@ -764,16 +1104,19 @@ export async function createPulseServer({
|
|
|
764
1104
|
const next = { ...current };
|
|
765
1105
|
if ('terms' in patch) next.terms = normalizeTerms(patch.terms);
|
|
766
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);
|
|
767
1109
|
await updateConfig(root, { privacy: next });
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
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()) });
|
|
771
1114
|
}
|
|
772
1115
|
if (request.method === 'POST' && url.pathname === '/api/privacy/purge') {
|
|
773
1116
|
const payload = await body(request).catch(() => ({}));
|
|
774
|
-
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.') });
|
|
775
1118
|
await refreshPrivacy();
|
|
776
|
-
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.') });
|
|
777
1120
|
const result = await room.purgePrivate();
|
|
778
1121
|
// The log was rewritten in place: the broadcaster's byte offset is stale, the sequences are not.
|
|
779
1122
|
tailOffset = (await store.tail(0)).offset;
|
|
@@ -797,13 +1140,15 @@ export async function createPulseServer({
|
|
|
797
1140
|
const info = await versionView();
|
|
798
1141
|
if (!info.available) return sendJson(response, 409, { error: `Nothing to apply: ${info.current} is the latest MADRE knows of.` });
|
|
799
1142
|
const command = applyCommand({ install, name: PACKAGE.name, version: info.latest, port: request.socket.localPort, projectRoot: canonicalProjectRoot });
|
|
800
|
-
if (!command) return sendJson(response, 412, { error: 'This copy runs from source: pull the repository and start it again.' });
|
|
801
|
-
if (room.activeTurns().length || room.activePlans().length) return sendJson(response, 409, { error: 'Agents are still working. STOPALL or wait, then update.' });
|
|
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.') });
|
|
802
1145
|
await room.record('room.updating', { from: info.current, to: info.latest, install, command });
|
|
803
1146
|
sendJson(response, 202, { restarting: true, from: info.current, to: info.latest, command });
|
|
804
1147
|
setTimeout(() => {
|
|
805
1148
|
server.close(() => {
|
|
806
|
-
|
|
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' } });
|
|
807
1152
|
child.unref();
|
|
808
1153
|
setTimeout(() => process.exit(0), 200);
|
|
809
1154
|
});
|
|
@@ -821,27 +1166,236 @@ export async function createPulseServer({
|
|
|
821
1166
|
}
|
|
822
1167
|
if (request.method === 'POST' && url.pathname === '/api/mother/code000') {
|
|
823
1168
|
const payload = await body(request).catch(() => ({}));
|
|
824
|
-
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.') });
|
|
825
1170
|
const result = await room.code000({ strikes: Number(payload.strikes) || CODE000_STRIKES });
|
|
826
|
-
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.') });
|
|
827
1172
|
}
|
|
828
1173
|
if (request.method === 'GET' && url.pathname === '/api/memory') {
|
|
829
|
-
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.') });
|
|
830
1175
|
const sealed = room.motherStatus()?.lockedForMs ?? 0;
|
|
831
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 });
|
|
832
1177
|
const research = room.memoryResearch();
|
|
833
|
-
|
|
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.') });
|
|
834
1353
|
}
|
|
835
1354
|
const forgetMatch = request.method === 'DELETE' && url.pathname.match(/^\/api\/memory\/(\d+)$/);
|
|
836
1355
|
if (forgetMatch) {
|
|
837
1356
|
const payload = await body(request).catch(() => ({}));
|
|
838
|
-
if (!designationOk(payload.designation)) return sendJson(response, 403, { error: 'UNABLE TO COMPUTE. UNABLE TO CLARIFY.' });
|
|
839
|
-
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.') });
|
|
840
1359
|
const row = await room.forgetMemory(forgetMatch[1]);
|
|
841
|
-
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.') });
|
|
842
1361
|
}
|
|
843
1362
|
if (request.method === 'POST' && url.pathname === '/api/agents/probe') {
|
|
844
|
-
|
|
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);
|
|
845
1399
|
}
|
|
846
1400
|
const loginMatch = request.method === 'POST' && url.pathname.match(/^\/api\/agents\/([a-z0-9-]+)\/login$/);
|
|
847
1401
|
if (loginMatch) {
|
|
@@ -858,7 +1412,7 @@ export async function createPulseServer({
|
|
|
858
1412
|
}
|
|
859
1413
|
if (request.method === 'GET' && url.pathname === '/api/agents/opencode/models') {
|
|
860
1414
|
const opencode = agents.find((agent) => agent.id === 'opencode');
|
|
861
|
-
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.') });
|
|
862
1416
|
const models = [];
|
|
863
1417
|
await runInstaller({ command: opencode.path, args: ['models'], projectRoot: canonicalProjectRoot, timeoutMs: 20000, onLine: (line) => { if (/^[\w.-]+\/[\w.:-]+$/.test(line.trim())) models.push(line.trim()); } });
|
|
864
1418
|
return sendJson(response, 200, { models });
|
|
@@ -870,24 +1424,93 @@ export async function createPulseServer({
|
|
|
870
1424
|
const stopMatch = request.method === 'POST' && url.pathname.match(/^\/api\/plans\/([0-9a-f-]+)\/stop$/);
|
|
871
1425
|
if (stopMatch) {
|
|
872
1426
|
const stopped = await room.stopPlan(stopMatch[1]);
|
|
873
|
-
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.') });
|
|
874
1428
|
}
|
|
875
1429
|
if (request.method === 'GET' && url.pathname === '/api/commands') {
|
|
876
|
-
|
|
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] });
|
|
877
1439
|
}
|
|
878
1440
|
if (request.method === 'POST' && url.pathname === '/api/commands') {
|
|
879
1441
|
const { text } = await body(request);
|
|
880
1442
|
const parsed = parseCommand(text);
|
|
881
|
-
if (!parsed) return sendJson(response, 400, { error: 'Not a command. Commands start with "/" followed by a name.' });
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
if (
|
|
885
|
-
|
|
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
|
+
}
|
|
886
1463
|
const event = await room.recordCommand({ name: parsed.name, args: parsed.args, ...result });
|
|
887
1464
|
return sendJson(response, result.ok ? 200 : 422, { command: parsed.name, title: result.title, ok: result.ok, sequence: event.sequence });
|
|
888
1465
|
}
|
|
889
1466
|
if (request.method === 'GET' && url.pathname === '/api/extensions') {
|
|
890
|
-
|
|
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);
|
|
891
1514
|
}
|
|
892
1515
|
const installMatch = request.method === 'POST' && url.pathname.match(/^\/api\/extensions\/([a-z0-9-]+)\/install$/);
|
|
893
1516
|
if (installMatch) {
|
|
@@ -925,7 +1548,7 @@ export async function createPulseServer({
|
|
|
925
1548
|
});
|
|
926
1549
|
return sendJson(response, 200, { emitted: Boolean(warning), warning });
|
|
927
1550
|
}
|
|
928
|
-
sendJson(response, 404, { error: 'Not found.' });
|
|
1551
|
+
sendJson(response, 404, { error: t('Not found.') });
|
|
929
1552
|
} catch (error) {
|
|
930
1553
|
sendJson(response, 400, { error: error.message });
|
|
931
1554
|
}
|
|
@@ -937,19 +1560,24 @@ export async function createPulseServer({
|
|
|
937
1560
|
server.close = (callback) => {
|
|
938
1561
|
clearInterval(poller);
|
|
939
1562
|
clearTimeout(embedKick);
|
|
1563
|
+
unwatchOutbound();
|
|
940
1564
|
// In-flight agent processes are killed and their turns recorded as failed
|
|
941
1565
|
// before the SSE clients go away, so open pages see the outcome.
|
|
942
1566
|
const shutdown = room.shutdown().catch((error) => console.error(`MADRE shutdown error: ${error.message}`));
|
|
943
1567
|
let result;
|
|
944
1568
|
shutdown.then(() => {
|
|
945
|
-
void broadcastPending().then(() => {
|
|
1569
|
+
void broadcastPending().then(() => chatWrites).then(() => {
|
|
946
1570
|
unsubscribe();
|
|
947
1571
|
unsubscribeSentinel();
|
|
1572
|
+
unsubscribeEyecat();
|
|
1573
|
+
unsubscribeLive();
|
|
948
1574
|
unsubscribeGhost();
|
|
949
1575
|
for (const client of clients.keys()) client.end();
|
|
950
1576
|
clients.clear();
|
|
951
1577
|
// The memory file closes last, once nothing else writes to it; WAL and shm go with it.
|
|
952
1578
|
try { memory?.close(); } catch { /* already closed */ }
|
|
1579
|
+
void outbound.drain();
|
|
1580
|
+
releaseOpen();
|
|
953
1581
|
result = nativeClose(callback);
|
|
954
1582
|
server.closeIdleConnections?.();
|
|
955
1583
|
});
|
|
@@ -958,16 +1586,18 @@ export async function createPulseServer({
|
|
|
958
1586
|
};
|
|
959
1587
|
server.on('close', () => quotaMonitor.stop());
|
|
960
1588
|
|
|
961
|
-
return { server, store, agents, quotaMonitor };
|
|
1589
|
+
return { server, store, agents, quotaMonitor, markOpen, releaseOpen };
|
|
962
1590
|
}
|
|
963
1591
|
|
|
964
1592
|
export async function startPulse({ port, projectRoot, openBrowser }) {
|
|
965
|
-
const { server, agents } = await createPulseServer({ projectRoot, quotaSources: defaultQuotaSources() });
|
|
1593
|
+
const { server, agents, markOpen } = await createPulseServer({ projectRoot, quotaSources: defaultQuotaSources() });
|
|
966
1594
|
await new Promise((resolve, reject) => {
|
|
967
1595
|
server.once('error', reject);
|
|
968
1596
|
server.listen(port, '127.0.0.1', resolve);
|
|
969
1597
|
});
|
|
970
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);
|
|
971
1601
|
const url = `http://127.0.0.1:${address.port}`;
|
|
972
1602
|
const ready = agents.filter((agent) => agent.ready).map((agent) => agent.label).join(', ') || 'none';
|
|
973
1603
|
const detected = agents.filter((agent) => agent.detected).map((agent) => agent.label).join(', ') || 'none';
|