@jossuealcala/madre 1.0.1 → 1.0.3
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/ADDITIONAL-TERMS.md +93 -0
- package/CHANGELOG.en.md +2013 -0
- package/CHANGELOG.md +102 -1
- package/CONTRIBUTING.md +1 -1
- package/LICENSE +661 -202
- package/NOTICE +26 -14
- package/README.en.md +241 -0
- package/README.md +21 -12
- package/TRADEMARKS.en.md +50 -0
- package/TRADEMARKS.md +33 -16
- package/bin/madre.cjs +40 -0
- package/bin/madre.mjs +7 -0
- package/docs/INTERNALS.md +6 -0
- package/docs/REFERENCE.md +40 -5
- package/docs/SDK.md +4 -2
- package/docs/en/INTERNALS.md +103 -0
- package/docs/en/REFERENCE.md +365 -0
- package/docs/en/SDK.md +308 -0
- package/docs/en/training/README.md +59 -0
- package/docs/training/README.md +48 -9
- package/docs/training/train.sh +7 -2
- package/package.json +13 -5
- package/public/abduction.js +2 -0
- package/public/app.js +3154 -539
- package/public/attribution.js +114 -0
- package/public/brands.js +23 -6
- package/public/code-tools.js +143 -0
- package/public/editor.js +22 -4
- package/public/es.js +1004 -416
- package/public/hardware.js +50 -0
- package/public/i18n.js +25 -1
- package/public/index.html +18 -11
- package/public/inquiry.js +2 -0
- package/public/loader.js +2 -0
- package/public/macroverso.js +189 -96
- package/public/market-art.js +161 -0
- package/public/market-view.js +529 -0
- package/public/market.js +189 -0
- package/public/neural.js +110 -0
- package/public/nostromo-engine.js +414 -47
- package/public/power.js +100 -0
- package/public/resay.js +11 -0
- package/public/review-ask.js +2 -0
- package/public/routing.js +23 -1
- package/public/salas-mac.css +119 -18
- package/public/salas-mac.html +14 -2
- package/public/salas-mac.js +358 -56
- package/public/salas.css +2 -0
- package/public/salas.html +1 -0
- package/public/salas.js +4 -2
- package/public/styles.css +1793 -77
- package/public/syntax.js +2 -0
- package/public/touch.js +2 -0
- package/public/tour-chapters.js +2 -0
- package/public/tour.js +84 -5
- package/public/troubleshooting.js +24 -17
- package/public/wallpaper.js +275 -0
- package/src/adapters/claude.mjs +2 -0
- package/src/adapters/codex.mjs +2 -0
- package/src/adapters/cursor.mjs +96 -0
- package/src/adapters/gemini.mjs +13 -4
- package/src/adapters/madre.mjs +5 -2
- package/src/adapters/opencode.mjs +7 -3
- package/src/adapters/process.mjs +2 -0
- package/src/asking.mjs +2 -0
- package/src/auth-probe.mjs +145 -12
- package/src/bridge/devices.mjs +2 -0
- package/src/bridge/handoff.mjs +2 -0
- package/src/bridge/host.mjs +2 -0
- package/src/bridge/network.mjs +2 -0
- package/src/bridge/policy.mjs +3 -1
- package/src/bridge/qr.mjs +2 -0
- package/src/bridge/server.mjs +2 -0
- package/src/capabilities.mjs +11 -0
- package/src/chats.mjs +2 -0
- package/src/checkpoint.mjs +18 -1
- package/src/choices.mjs +2 -0
- package/src/cli-args.mjs +2 -0
- package/src/cold.mjs +2 -0
- package/src/commands.mjs +2 -0
- package/src/config.mjs +2 -0
- package/src/conversation-context.mjs +15 -1
- package/src/credentials.mjs +35 -3
- package/src/dataset.mjs +78 -4
- package/src/directives.mjs +2 -0
- package/src/distiller.mjs +2 -0
- package/src/embeddings.mjs +2 -0
- package/src/enlaces.mjs +214 -6
- package/src/event-store.mjs +2 -0
- package/src/exam.mjs +6 -4
- package/src/extensions.mjs +13 -3
- package/src/eyecat-watch.mjs +2 -0
- package/src/eyecat.mjs +2 -0
- package/src/file-ops.mjs +2 -0
- package/src/files.mjs +32 -0
- package/src/github.mjs +2 -0
- package/src/heartbeat.mjs +2 -0
- package/src/i18n.mjs +31 -1
- package/src/image-studio.mjs +2 -0
- package/src/json-file.mjs +2 -0
- package/src/launch.mjs +2 -0
- package/src/lease.mjs +5 -1
- package/src/lobby-work.mjs +2 -0
- package/src/local-ai.mjs +2 -0
- package/src/looks.mjs +127 -2
- package/src/market-key.mjs +8 -0
- package/src/market-remote.mjs +239 -0
- package/src/maturity.mjs +3 -1
- package/src/mcp/connect.mjs +345 -0
- package/src/mcp/github-server.mjs +2 -0
- package/src/mcp/image-server.mjs +2 -0
- package/src/mcp/memory-server.mjs +2 -0
- package/src/mcp/proxy.mjs +153 -0
- package/src/mcp/smtp-server.mjs +2 -0
- package/src/memory-dashboard.mjs +2 -0
- package/src/memory-tools.mjs +2 -0
- package/src/memory.mjs +2 -0
- package/src/models.mjs +49 -3
- package/src/module-scope.mjs +68 -0
- package/src/modules/ahp.mjs +104 -7
- package/src/modules/ash-policy.mjs +2 -0
- package/src/modules/ash.mjs +3 -1
- package/src/modules/bishop-exam.mjs +122 -0
- package/src/modules/bishop-plan.mjs +5 -3
- package/src/modules/bishop.mjs +84 -23
- package/src/modules/choices.mjs +2 -0
- package/src/modules/correo.mjs +2 -0
- package/src/modules/enlaces.mjs +89 -6
- package/src/modules/git-pulse.mjs +2 -0
- package/src/modules/github.mjs +2 -0
- package/src/modules/helpers.mjs +2 -0
- package/src/modules/image-studio.mjs +13 -2
- package/src/modules/index.mjs +4 -1
- package/src/modules/intents-calibration.mjs +2 -0
- package/src/modules/intents-desk.mjs +4 -2
- package/src/modules/intents-observe.mjs +2 -0
- package/src/modules/intents-policy.mjs +4 -1
- package/src/modules/intents-try.mjs +2 -0
- package/src/modules/intents.mjs +11 -7
- package/src/modules/local-models.mjs +124 -0
- package/src/modules/mcp-connect.mjs +293 -0
- package/src/modules/ollama.mjs +15 -4
- package/src/modules/playwright.mjs +88 -15
- package/src/modules/ripley.mjs +2 -0
- package/src/modules/runs.mjs +5 -3
- package/src/modules/sdk.mjs +63 -9
- package/src/modules/updates.mjs +2 -0
- package/src/mother.mjs +2 -0
- package/src/npm-launch.mjs +18 -0
- package/src/ollama.mjs +8 -5
- package/src/outbound.mjs +16 -6
- package/src/override.mjs +2 -0
- package/src/ports.mjs +6 -2
- package/src/prices.mjs +2 -0
- package/src/privacy.mjs +2 -0
- package/src/quota-monitor.mjs +2 -0
- package/src/quota-sources.mjs +2 -0
- package/src/request-guard.mjs +2 -0
- package/src/review-ask.mjs +2 -0
- package/src/room/archivist.mjs +2 -0
- package/src/room/attachments.mjs +2 -0
- package/src/room/budget.mjs +2 -0
- package/src/room/context.mjs +2 -0
- package/src/room/control.mjs +7 -1
- package/src/room/copilot.mjs +76 -0
- package/src/room/economy.mjs +2 -0
- package/src/room/escalation.mjs +2 -0
- package/src/room/ghost.mjs +2 -0
- package/src/room/guard.mjs +2 -0
- package/src/room/prompt.mjs +25 -5
- package/src/room/returns.mjs +82 -0
- package/src/room/runs.mjs +4 -0
- package/src/room/vectors.mjs +2 -0
- package/src/room.mjs +223 -35
- package/src/router.mjs +3 -1
- package/src/runs.mjs +6 -1
- package/src/runtime-detection.mjs +11 -0
- package/src/salas.mjs +28 -0
- package/src/sentinel-errors.mjs +2 -0
- package/src/server-sentinel.mjs +2 -0
- package/src/server.mjs +757 -81
- package/src/setup.mjs +3 -1
- package/src/smtp.mjs +2 -0
- package/src/tour.mjs +2 -0
- package/src/updates.mjs +2 -0
- package/src/usage-sentinel.mjs +2 -0
- package/src/vault.mjs +2 -0
- package/src/verdict.mjs +3 -1
- package/src/whats-new.mjs +2 -0
package/src/server.mjs
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
|
|
1
3
|
import http from 'node:http';
|
|
2
|
-
import { readFile, realpath, writeFile, mkdir, cp, rename, access, stat } from 'node:fs/promises';
|
|
4
|
+
import { readFile, realpath, writeFile, mkdir, mkdtemp, cp, rename, access, rm, stat } from 'node:fs/promises';
|
|
3
5
|
import { existsSync, openSync, rmSync } from 'node:fs';
|
|
4
6
|
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
5
7
|
import { probePort, framable } from './ports.mjs';
|
|
6
8
|
import { ControlDesk } from './room/control.mjs';
|
|
7
9
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
8
10
|
import { fileURLToPath } from 'node:url';
|
|
9
|
-
import {
|
|
11
|
+
import { gzipSync } from 'node:zlib';
|
|
12
|
+
import { homedir, hostname, tmpdir } from 'node:os';
|
|
10
13
|
import { execFile } from 'node:child_process';
|
|
11
14
|
import { promisify } from 'node:util';
|
|
12
15
|
|
|
@@ -20,9 +23,10 @@ import { MotherChannel, CODE000_STRIKES } from './mother.mjs';
|
|
|
20
23
|
import { ErrorSentinel } from './sentinel-errors.mjs';
|
|
21
24
|
import { probeOllama, ollamaEmbedder, ollamaGenerate, ollamaInvoker, pullModel, ollamaVersions, ownOllamaServers } from './ollama.mjs';
|
|
22
25
|
import { recallForTurn } from './modules/enlaces.mjs';
|
|
26
|
+
import { hardwareOnce, MODEL_TIERS } from './modules/local-models.mjs';
|
|
23
27
|
import { MODULES, moduleById, describeModules, findModuleRoute, toolsForTurn as modulesToolsForTurn, loadExternalModules, loadFailures, moduleFolders, moduleCommands, installModuleFile, installModuleText, verifyModuleText, moduleOrigin, removeExternalModule } from './modules/index.mjs';
|
|
24
28
|
import { madreAgent, madreInvoker, MADRE_AGENT_ID, MADRE_ADAPTER } from './adapters/madre.mjs';
|
|
25
|
-
import { exportDataset, readiness as datasetReadiness } from './dataset.mjs';
|
|
29
|
+
import { exportDataset, forgetCorpus, keepCorpus, keptCorpus, pruneCorpora, readiness as datasetReadiness } from './dataset.mjs';
|
|
26
30
|
import { OutboundLog, outboundView, DEFAULT_REPORT_URL } from './outbound.mjs';
|
|
27
31
|
import { language as roomLanguage, setLanguage as setRoomLanguage, t } from './i18n.mjs';
|
|
28
32
|
|
|
@@ -60,10 +64,11 @@ export function injectRipleyBridge(body) {
|
|
|
60
64
|
import { QuotaMonitor } from './quota-monitor.mjs';
|
|
61
65
|
import { defaultQuotaSources } from './quota-sources.mjs';
|
|
62
66
|
import { Room } from './room.mjs';
|
|
63
|
-
import {
|
|
67
|
+
import { COPILOT_WINDOWS } from './room/copilot.mjs';
|
|
68
|
+
import { OVERRIDE_GRACE, changeNeedsPhrase, keyHolds, mergeOverride, overrideSettings, phraseHash, publicOverride } from './override.mjs';
|
|
64
69
|
import { extensionById, findOnPath, runInstaller } from './extensions.mjs';
|
|
65
|
-
import { accountNoteFor, installPlanFor, looksLikeAdminProblem, loginPlanFor, looksLikeCodePrompt, probeAgentAuth, probeAll, TAKES_KEY } from './auth-probe.mjs';
|
|
66
|
-
import { applyKey, keyPlanFor,
|
|
70
|
+
import { accountNoteFor, deviceCodeIn, geminiSignIn, installPlanFor, looksLikeAdminProblem, loginPlanFor, looksLikeCodePrompt, looksLikeConsentPrompt, probeAgentAuth, probeAll, TAKES_KEY } from './auth-probe.mjs';
|
|
71
|
+
import { applyKey, keyPlanFor, openCodeKeyProviders, readOpenCodeProviders, removeOpenCodeKey } from './credentials.mjs';
|
|
67
72
|
import { forgetSecret, readSecret, summary as vaultSummary, writeSecret } from './vault.mjs';
|
|
68
73
|
import { applyConfigToEnv, loadConfig as readConfig, updateConfig } from './config.mjs';
|
|
69
74
|
import { Privacy, normalizeTerms, privacySettings } from './privacy.mjs';
|
|
@@ -78,7 +83,7 @@ import { moduleUpdate } from './modules/updates.mjs';
|
|
|
78
83
|
import { adoptStrays, chatIndex, chatLedger, createChat, deleteChat, isChatId, listChats, openChat as openChatIndex, projectFloor, renameChat, touchChat } from './chats.mjs';
|
|
79
84
|
import { spawn } from 'node:child_process';
|
|
80
85
|
import { cpus, freemem, loadavg, totalmem } from 'node:os';
|
|
81
|
-
import { discoverModels, groupOpenCode, openCodeHidden, providerLabel, OPENCODE_OWN } from './models.mjs';
|
|
86
|
+
import { discoverModels, groupOpenCode, openCodeHidden, parseOpenCodeVerbose, providerLabel, OPENCODE_OWN } from './models.mjs';
|
|
82
87
|
import { observable } from './modules/intents-observe.mjs';
|
|
83
88
|
import { normalizeMode, setImageModule } from './capabilities.mjs';
|
|
84
89
|
import { resolveGeminiKey } from './image-studio.mjs';
|
|
@@ -100,16 +105,58 @@ import { currentNetwork, lanAddresses } from './bridge/network.mjs';
|
|
|
100
105
|
import { PHONE_MAX_MODE, writtenMode } from './bridge/policy.mjs';
|
|
101
106
|
import { qrSvg } from './bridge/qr.mjs';
|
|
102
107
|
import { readHandoff, writeHandoff } from './bridge/handoff.mjs';
|
|
103
|
-
import { changeLooks, readLooks, changeRoomLook, roomLooks } from './looks.mjs';
|
|
108
|
+
import { changeHome, changeLooks, readHome, readLooks, changeRoomLook, roomLooks, WALLPAPER_BYTES, dropWallpaper, frameWallpaper, readWallpaper, saveWallpaper, wallpaperBytes } from './looks.mjs';
|
|
104
109
|
import { localAiConfig, localAiFor, localAiPatch } from './local-ai.mjs';
|
|
110
|
+
import { moduleOnIn, scopeView, settingsIn } from './module-scope.mjs';
|
|
111
|
+
import { forgetFromMarket, installFromMarket, installedFromMarket, keptCatalog, marketAnnounced, marketAnnouncements, marketNews, marketNewsSeen, refreshCatalog, updateFromMarket } from './market-remote.mjs';
|
|
112
|
+
// Not moved project by project: the Coordinator is Home's, Git has no switch, and Local AI keeps
|
|
113
|
+
// its own list of projects (local-ai.mjs).
|
|
114
|
+
const NOT_BY_PROJECT = new Set(['bishop', 'git-pulse', 'ollama']);
|
|
105
115
|
import { heartbeat } from './heartbeat.mjs';
|
|
106
|
-
import { askRoom, associateProject, ensureSalasProject, forgetProject, isSalasRoom, knownRooms, listFolders, markProject, projectRoomId, roomComesUp, salasRoomId, startRoom, suggestProjects, universeOf } from './salas.mjs';
|
|
116
|
+
import { askRoom, associateProject, createProject, ensureSalasProject, forgetProject, isSalasRoom, knownRooms, listFolders, markProject, projectRoomId, roomComesUp, salasRoomId, startRoom, suggestProjects, universeOf } from './salas.mjs';
|
|
107
117
|
import { parseWork, recentWork, WORK_SYSTEM, workPrompt } from './lobby-work.mjs';
|
|
108
|
-
import { CHANNEL_HEADER, MAX_LIMIT as ENLACE_MAX_LIMIT, NO_SUCH_LINK, NOTICES as ENLACE_NOTICES, activeContracts, askEnlace, channelKey, countRecent, linkTraffic, mayServe, noteCrossing, noticeEnlace, readsFrom, recallContract, revokeContract, sameKey, servingRooms, signContract } from './enlaces.mjs';
|
|
118
|
+
import { CHANNEL_HEADER, CORPUS_MAX, CORPUS_PER_HOUR, DELIVER_MAX, MAX_LIMIT as ENLACE_MAX_LIMIT, NO_SUCH_LINK, NOTICES as ENLACE_NOTICES, activeContracts, allowContract, askCorpus, askEnlace, askState, channelKey, countRecent, decideDelivery, deliverable, deliverNotes, deliversTo, linkTraffic, mayServe, noteCrossing, noticeEnlace, readInbox, readsFrom, recallContract, receiveDelivery, revokeContract, sameKey, servingRooms, signContract, trainContract } from './enlaces.mjs';
|
|
119
|
+
import { readContinuity } from './modules/ahp.mjs';
|
|
120
|
+
import { readAt } from './checkpoint.mjs';
|
|
109
121
|
|
|
110
122
|
const sourceDirectory = dirname(fileURLToPath(import.meta.url));
|
|
111
123
|
const publicDirectory = join(sourceDirectory, '..', 'public');
|
|
112
124
|
|
|
125
|
+
// The page's own scripts and styles (2026-10-15): an ETag from the file's size and time, so a
|
|
126
|
+
// reload that finds them unchanged gets «304» and the browser reuses its copy — and the code it
|
|
127
|
+
// already compiled — instead of reading 2.5 MB again; and gzip for whoever asks, compressed once per
|
|
128
|
+
// change of the file. `no-cache` still revalidates every time, so an edit shows on the next load.
|
|
129
|
+
// Only the compressed copy is kept in memory (~0.6 MB for all of them); the plain file is read when
|
|
130
|
+
// a client does not take gzip.
|
|
131
|
+
const assetCache = new Map();
|
|
132
|
+
async function sendAsset(request, response, file, type) {
|
|
133
|
+
const info = await stat(file);
|
|
134
|
+
const tag = `W/"${info.size.toString(36)}-${Math.floor(info.mtimeMs).toString(36)}"`;
|
|
135
|
+
const headers = { 'content-type': type, 'cache-control': 'no-cache', etag: tag, vary: 'accept-encoding' };
|
|
136
|
+
if (request.headers['if-none-match'] === tag) { response.writeHead(304, headers); return response.end(); }
|
|
137
|
+
const gzip = /\bgzip\b/.test(String(request.headers['accept-encoding'] ?? '')) && info.size > 8192;
|
|
138
|
+
if (gzip) {
|
|
139
|
+
let held = assetCache.get(file);
|
|
140
|
+
if (held?.tag !== tag) { held = { tag, body: gzipSync(await readFile(file), { level: 6 }) }; assetCache.set(file, held); }
|
|
141
|
+
response.writeHead(200, { ...headers, 'content-encoding': 'gzip', 'content-length': held.body.length });
|
|
142
|
+
return response.end(held.body);
|
|
143
|
+
}
|
|
144
|
+
const body = await readFile(file);
|
|
145
|
+
response.writeHead(200, { ...headers, 'content-length': body.length });
|
|
146
|
+
return response.end(body);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// The record as a page opening it needs it (2026-10-15). `quota.updated` is how full each agent's
|
|
150
|
+
// window is, read again every few minutes; the page keeps only the last reading of each agent, so
|
|
151
|
+
// every older one was downloaded, parsed and drawn for nothing — 87% of a long conversation's
|
|
152
|
+
// record, and seconds of drawing. The ledger keeps them all; the page gets the newest of each.
|
|
153
|
+
export function forThePage(events) {
|
|
154
|
+
const lastQuota = new Map();
|
|
155
|
+
for (const event of events) if (event?.type === 'quota.updated') lastQuota.set(event.payload?.agent ?? null, event);
|
|
156
|
+
const keep = new Set(lastQuota.values());
|
|
157
|
+
return events.filter((event) => event?.type !== 'quota.updated' || keep.has(event));
|
|
158
|
+
}
|
|
159
|
+
|
|
113
160
|
function sendJson(response, status, value) {
|
|
114
161
|
response.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
|
|
115
162
|
response.end(JSON.stringify(value));
|
|
@@ -137,7 +184,7 @@ export function openUrl(url) {
|
|
|
137
184
|
|
|
138
185
|
// PULSE_AGENT_TIMEOUT_MS applies to every agent; PULSE_<AGENT>_TIMEOUT_MS
|
|
139
186
|
// (e.g. PULSE_CLAUDE_TIMEOUT_MS) overrides it for one.
|
|
140
|
-
export function agentTimeoutsFromEnv(env = process.env, agentIds = ['codex', 'claude', 'gemini', 'opencode']) {
|
|
187
|
+
export function agentTimeoutsFromEnv(env = process.env, agentIds = ['codex', 'claude', 'gemini', 'opencode', 'cursor']) {
|
|
141
188
|
const timeouts = {};
|
|
142
189
|
const shared = Number(env.PULSE_AGENT_TIMEOUT_MS);
|
|
143
190
|
if (Number.isFinite(shared) && shared > 0) timeouts.default = shared;
|
|
@@ -195,6 +242,10 @@ export async function createPulseServer({
|
|
|
195
242
|
imageKey = resolveGeminiKey,
|
|
196
243
|
// The sentinel's outbound channel; tests hand in a fake.
|
|
197
244
|
reportFetch = globalThis.fetch,
|
|
245
|
+
// The Market's catalog and module files from madre.run (src/market-remote.mjs); tests hand in a
|
|
246
|
+
// fake, and the key their catalog is signed with.
|
|
247
|
+
marketFetch = globalThis.fetch,
|
|
248
|
+
marketKey = null,
|
|
198
249
|
// Ollama detection; tests hand in a fake probe. PULSE_OLLAMA=0 leaves Ollama alone entirely.
|
|
199
250
|
ollamaProbe = process.env.PULSE_OLLAMA === '0' ? async () => ({ running: false, host: null, models: [], embedModel: null, chatModel: null, disabled: true }) : probeOllama,
|
|
200
251
|
}) {
|
|
@@ -207,8 +258,11 @@ export async function createPulseServer({
|
|
|
207
258
|
// What language the screens speak. The prompt the agents read is not one of them, and the
|
|
208
259
|
// environment wins over the file, as it does for every other setting here.
|
|
209
260
|
setRoomLanguage(process.env.PULSE_LANGUAGE ?? (await readConfig(root)).language);
|
|
261
|
+
// Whether anyone set it: a MADRE nobody has set takes the language of the first browser that
|
|
262
|
+
// opens it (public/i18n.js, browserLanguage), once, and keeps it as if it had been chosen.
|
|
263
|
+
let languageChosen = Boolean(process.env.PULSE_LANGUAGE || (await readConfig(root)).language);
|
|
210
264
|
// RIPLEY renders HTML in the viewer only while the human has it switched on; read live, the switch is the module's.
|
|
211
|
-
const ripleyOn = async () =>
|
|
265
|
+
const ripleyOn = async () => moduleOnHere(await readConfig(root), 'ripley');
|
|
212
266
|
agentTimeouts ??= agentTimeoutsFromEnv();
|
|
213
267
|
const detector = detect ?? (providedAgents ? null : detectAgents);
|
|
214
268
|
const agents = providedAgents ?? await detector();
|
|
@@ -225,6 +279,7 @@ export async function createPulseServer({
|
|
|
225
279
|
const unwatchOutbound = outbound.install();
|
|
226
280
|
process.env.PULSE_OUTBOUND_LOG = join(roomDir, 'outbound.jsonl');
|
|
227
281
|
reportFetch = outbound.watch(reportFetch);
|
|
282
|
+
marketFetch = outbound.watch(marketFetch);
|
|
228
283
|
|
|
229
284
|
// Attachments live beside the room log, never inside the project, and belong to the project
|
|
230
285
|
// rather than to one conversation.
|
|
@@ -316,6 +371,23 @@ export async function createPulseServer({
|
|
|
316
371
|
// default; PULSE_UPDATE_CHECK=0 or the switch in MU/TH/UR turns it off. Never self-updates.
|
|
317
372
|
const updatesEnabled = () => (process.env.PULSE_UPDATE_CHECK !== undefined ? process.env.PULSE_UPDATE_CHECK !== '0' : updatesConfig.check !== false);
|
|
318
373
|
let updatesConfig = { ...((await readConfig(root)).updates ?? {}) };
|
|
374
|
+
// THE MARKET from madre.run (src/market-remote.mjs): read when MADRE opens, while Look for new
|
|
375
|
+
// versions is on (PULSE_MARKET=0 says no to it alone). A module got from it updates itself and
|
|
376
|
+
// says so once, the next time a page asks (market/updates.json).
|
|
377
|
+
const marketOn = () => process.env.PULSE_MARKET !== '0' && updatesEnabled();
|
|
378
|
+
const marketKeyed = marketKey ? { publicKey: marketKey } : {};
|
|
379
|
+
const marketTools = () => ({ stateRoot: root, projectRoot: canonicalProjectRoot, madre: PACKAGE.version, fetchImpl: marketFetch, verifyText: verifyModuleText, installText: installModuleText });
|
|
380
|
+
let marketSync = null;
|
|
381
|
+
const syncMarket = ({ force = false } = {}) => {
|
|
382
|
+
if (!marketOn()) return Promise.resolve({ catalog: null, off: true });
|
|
383
|
+
marketSync ??= (async () => {
|
|
384
|
+
const read = await refreshCatalog({ stateRoot: root, fetchImpl: marketFetch, force, ...marketKeyed });
|
|
385
|
+
if (read.catalog) await updateFromMarket({ ...marketTools(), catalog: read.catalog, names: Object.fromEntries(MODULES.map((one) => [one.id, one.name])) }).catch(() => []);
|
|
386
|
+
return read;
|
|
387
|
+
})().finally(() => { marketSync = null; });
|
|
388
|
+
return marketSync;
|
|
389
|
+
};
|
|
390
|
+
void syncMarket().catch(() => null);
|
|
319
391
|
const install = detectInstall({ projectRoot: canonicalProjectRoot });
|
|
320
392
|
async function versionView({ force = false } = {}) {
|
|
321
393
|
const check = await checkForUpdate({ name: PACKAGE.name, current: PACKAGE.version, cacheFile: join(root, 'updates.json'), fetchImpl: reportFetch, enabled: updatesEnabled(), force });
|
|
@@ -516,6 +588,10 @@ export async function createPulseServer({
|
|
|
516
588
|
const ollamaSettings = async () => ({ enabled: true, embeddings: true, archivist: true, agent: true, ...((await readConfig(root)).modules?.ollama ?? {}) });
|
|
517
589
|
// Whether this project uses the local AI at all, as Home decides it (src/local-ai.mjs).
|
|
518
590
|
const thisRoomId = projectRoomId(canonicalProjectRoot);
|
|
591
|
+
// This project's word on a module's switch (module-scope.mjs). Home's room is not a project: it
|
|
592
|
+
// reads, and writes, the switch for every project.
|
|
593
|
+
const scopeRoom = salasRoom ? null : thisRoomId;
|
|
594
|
+
const moduleOnHere = (config, key, fallback = false) => moduleOnIn(config?.modules?.[key] ?? {}, scopeRoom, fallback);
|
|
519
595
|
const localAiHere = async () => localAiFor(await readConfig(root), thisRoomId, { home: salasRoom });
|
|
520
596
|
// This project's own switch, outside «auto»; null when Home decides.
|
|
521
597
|
async function setLocalAiHere(on) {
|
|
@@ -524,10 +600,110 @@ export async function createPulseServer({
|
|
|
524
600
|
return wireOllama({ probe: false });
|
|
525
601
|
}
|
|
526
602
|
// What the TRAIN card needs: where the recipe ships, the room, the model name @madre will pick up, a base that fits this machine.
|
|
527
|
-
|
|
603
|
+
// Every event of every conversation of this project, in order: the training export and its
|
|
604
|
+
// readiness read the whole project, not only the conversation on screen.
|
|
605
|
+
async function projectEvents() {
|
|
606
|
+
const { chats: list } = await listChats(roomDir).catch(() => ({ chats: [] }));
|
|
607
|
+
const ids = list.length ? list.map((chat) => chat.id) : ['main'];
|
|
608
|
+
const all = [];
|
|
609
|
+
for (const id of ids) {
|
|
610
|
+
const raw = await readFile(chatLedger(roomDir, id), 'utf8').catch(() => '');
|
|
611
|
+
for (const line of raw.split('\n')) {
|
|
612
|
+
if (!line) continue;
|
|
613
|
+
try { all.push(JSON.parse(line)); } catch { /* a torn line is skipped */ }
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
return all.sort((x, y) => (x.sequence ?? 0) - (y.sequence ?? 0));
|
|
617
|
+
}
|
|
618
|
+
// The code workspace (1.0.3): an agent's last EDIT turn on a file, read from every conversation
|
|
619
|
+
// of the project (only the lines about control turns are parsed), and the parts the human kept.
|
|
620
|
+
async function lastAgentChange(path) {
|
|
621
|
+
const { chats: list } = await listChats(roomDir).catch(() => ({ chats: [] }));
|
|
622
|
+
const ids = list.length ? list.map((chat) => chat.id) : ['main'];
|
|
623
|
+
let found = null;
|
|
624
|
+
const undone = new Set();
|
|
625
|
+
for (const id of ids) {
|
|
626
|
+
const raw = await readFile(chatLedger(roomDir, id), 'utf8').catch(() => '');
|
|
627
|
+
for (const line of raw.split('\n')) {
|
|
628
|
+
if (!line.includes('"control.')) continue;
|
|
629
|
+
let event;
|
|
630
|
+
try { event = JSON.parse(line); } catch { continue; }
|
|
631
|
+
if (event.type === 'control.reverted') { undone.add(event.payload?.checkpointId); continue; }
|
|
632
|
+
if (event.type !== 'control.changed') continue;
|
|
633
|
+
const file = (event.payload?.files ?? []).find((one) => one.path === path);
|
|
634
|
+
if (!file || file.status === 'D' || !event.payload.agent || event.payload.agent === 'you') continue;
|
|
635
|
+
const at = Date.parse(event.timestamp) || 0;
|
|
636
|
+
if (!found || at >= found.at) found = { at, agent: event.payload.agent, checkpointId: event.payload.checkpointId, afterTree: event.payload.afterTree ?? null, status: file.status, messageId: event.payload.responseMessageId ?? event.payload.messageId ?? null };
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
return found && !undone.has(found.checkpointId) ? { ...found, at: new Date(found.at).toISOString() } : null;
|
|
640
|
+
}
|
|
641
|
+
const reviewedFile = join(roomDir, 'code-reviewed.json');
|
|
642
|
+
let reviewedWrites = Promise.resolve();
|
|
643
|
+
async function readReviewed() {
|
|
644
|
+
try { const raw = JSON.parse(await readFile(reviewedFile, 'utf8')); return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {}; } catch { return {}; }
|
|
645
|
+
}
|
|
646
|
+
function keepReviewed(key, hunk) {
|
|
647
|
+
const run = reviewedWrites.then(async () => {
|
|
648
|
+
const all = await readReviewed();
|
|
649
|
+
const list = [...new Set([...(Array.isArray(all[key]) ? all[key] : []), hunk])].slice(-200);
|
|
650
|
+
delete all[key];
|
|
651
|
+
all[key] = list;
|
|
652
|
+
// The newest 300 files are enough: an older change was long since kept or taken back.
|
|
653
|
+
const keys = Object.keys(all);
|
|
654
|
+
for (const old of keys.slice(0, Math.max(0, keys.length - 300))) delete all[old];
|
|
655
|
+
await writeFile(reviewedFile, `${JSON.stringify(all)}\n`);
|
|
656
|
+
return list;
|
|
657
|
+
});
|
|
658
|
+
reviewedWrites = run.catch(() => null);
|
|
659
|
+
return run;
|
|
660
|
+
}
|
|
661
|
+
// D-S15 · what every project that lets it has to teach the Coordinator, asked now. A project that
|
|
662
|
+
// is asleep or does not answer is not lost: the copy Salas kept the last time it answered is used
|
|
663
|
+
// and the manifest says so; one that never answered is listed as missing. Copies of projects that
|
|
664
|
+
// no longer allow it are forgotten first.
|
|
665
|
+
async function gatherLinked(datasetDir) {
|
|
666
|
+
const salas = salasRoomId(root);
|
|
667
|
+
const contracts = activeContracts(await readConfig(root), { salas });
|
|
668
|
+
const sources = readsFrom(contracts, salas).filter(({ contract }) => contract.train);
|
|
669
|
+
await pruneCorpora(datasetDir, new Set(sources.map(({ from }) => from)));
|
|
670
|
+
if (!sources.length) return [];
|
|
671
|
+
const rooms = await knownRooms(root);
|
|
672
|
+
const key = await channelKey(root).catch(() => null);
|
|
673
|
+
const out = [];
|
|
674
|
+
for (const { contract, from } of sources) {
|
|
675
|
+
const where = rooms.find((one) => one.id === from);
|
|
676
|
+
const answer = where?.open && key
|
|
677
|
+
? await askCorpus({ port: where.open.port, contract, asking: salas, key, record: (type, event) => room.record(type, event), roomDir })
|
|
678
|
+
: { unreachable: true };
|
|
679
|
+
if (answer.notes) {
|
|
680
|
+
const at = new Date().toISOString();
|
|
681
|
+
// D-S16: with the state shared too, a dated photo of it goes with the notes.
|
|
682
|
+
const shared = contract.state ? await askState({ port: where.open.port, contract, asking: salas, key }) : null;
|
|
683
|
+
const corpus = { ...answer, ...(shared?.state ? { state: shared.state } : {}) };
|
|
684
|
+
await keepCorpus(datasetDir, corpus);
|
|
685
|
+
out.push({ ...corpus, source: 'live', at });
|
|
686
|
+
continue;
|
|
687
|
+
}
|
|
688
|
+
const kept = await keptCorpus(datasetDir, from);
|
|
689
|
+
out.push(kept && kept.contract === contract.id ? { ...kept, source: 'kept' } : { contract: contract.id, from, name: where?.name ?? from, notes: [], source: 'missing' });
|
|
690
|
+
}
|
|
691
|
+
return out;
|
|
692
|
+
}
|
|
693
|
+
// A link that stops allowing it (revoked, or the box cleared): the copy Salas kept goes now.
|
|
694
|
+
async function forgetLinkedCorpus(contract) {
|
|
695
|
+
const salas = salasRoomId(root);
|
|
696
|
+
if (contract.rooms.a !== salas && contract.rooms.b !== salas) return;
|
|
697
|
+
const other = contract.rooms.a === salas ? contract.rooms.b : contract.rooms.a;
|
|
698
|
+
await forgetCorpus(join(root, 'rooms', salas, 'dataset'), other).catch(() => null);
|
|
699
|
+
}
|
|
700
|
+
// The base to train on comes from this computer's hardware (src/modules/local-models.mjs), not
|
|
701
|
+
// from one rule for every machine: the page lists every size and what this one can do with it.
|
|
702
|
+
async function trainingInfo() {
|
|
528
703
|
const slug = basename(canonicalProjectRoot).toLowerCase().replace(/[^a-z0-9]+/g, '-');
|
|
529
|
-
const
|
|
530
|
-
|
|
704
|
+
const fit = await hardwareOnce();
|
|
705
|
+
const base = fit.recommended.train ?? MODEL_TIERS[1];
|
|
706
|
+
return { roomDir, recipeDir: fileURLToPath(new URL('../docs/training/', import.meta.url)), modelName: `madre-${slug}`, baseModel: base.base, memoryGb: fit.hardware?.memoryGb ?? Math.round(totalmem() / 1024 ** 3), fit };
|
|
531
707
|
}
|
|
532
708
|
async function wireOllama({ probe: doProbe = true } = {}) {
|
|
533
709
|
if (doProbe) ollama = await ollamaProbe();
|
|
@@ -557,7 +733,7 @@ export async function createPulseServer({
|
|
|
557
733
|
wireLocal(room);
|
|
558
734
|
for (const held of parked.values()) wireLocal(held.room);
|
|
559
735
|
if (room) {
|
|
560
|
-
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), route: agent.route ?? null })), removed: fifth.ready ? [] : [MADRE_AGENT_ID], reason: fifth.ready ? `@madre is in the
|
|
736
|
+
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), route: agent.route ?? null })), removed: fifth.ready ? [] : [MADRE_AGENT_ID], reason: fifth.ready ? `@madre is in the project · ${fifth.version}` : '@madre left the project: Ollama has no chat model running' });
|
|
561
737
|
// That a local model is running is not the same as it being of use on this project, and
|
|
562
738
|
// the second thing is the one a person needs told. Said once per model, in the room, with
|
|
563
739
|
// the one button that settles it — the crew line above fires only when the crew CHANGES,
|
|
@@ -602,8 +778,10 @@ export async function createPulseServer({
|
|
|
602
778
|
const passes = new Map(); // token -> { agent, mode, until, once }
|
|
603
779
|
const keyMisses = []; // when the last wrong keys were typed
|
|
604
780
|
const PASS_ONCE_MS = 30 * 60 * 1000;
|
|
605
|
-
|
|
606
|
-
|
|
781
|
+
// `asked` is a window the human chose on an agent's request for this mode (2026-10-14): one of
|
|
782
|
+
// the windows the project allows, never longer, and only for that agent and mode.
|
|
783
|
+
function issuePass(agent, mode, asked = 0) {
|
|
784
|
+
const minutes = asked > 0 && OVERRIDE_GRACE[mode]?.includes(asked) ? asked : overrideConfig.grace[mode] ?? 0;
|
|
607
785
|
const token = randomBytes(24).toString('hex');
|
|
608
786
|
const pass = { agent, mode, until: Date.now() + (minutes ? minutes * 60000 : PASS_ONCE_MS), once: !minutes };
|
|
609
787
|
passes.set(token, pass);
|
|
@@ -616,6 +794,18 @@ export async function createPulseServer({
|
|
|
616
794
|
if (pass.once) passes.delete(token);
|
|
617
795
|
return true;
|
|
618
796
|
}
|
|
797
|
+
// The copilot (src/room/copilot.mjs): one window for the project, opened with the #3 key, up to
|
|
798
|
+
// 24 hours. In memory only, like the passes: a restart and STOP ALL close it. Every conversation
|
|
799
|
+
// of the project asks this one when it decides whether a message is a copilot's.
|
|
800
|
+
let copilot = null; // { since, until, minutes, timer }
|
|
801
|
+
const copilotNow = () => (copilot && copilot.until > Date.now() ? { since: copilot.since, until: copilot.until, minutes: copilot.minutes } : null);
|
|
802
|
+
const copilotState = () => ({ active: Boolean(copilotNow()), ...(copilotNow() ?? {}), windows: COPILOT_WINDOWS });
|
|
803
|
+
async function endCopilot(reason) {
|
|
804
|
+
if (!copilot) return;
|
|
805
|
+
clearTimeout(copilot.timer);
|
|
806
|
+
copilot = null;
|
|
807
|
+
await room.record('copilot.ended', { reason }).catch(() => null);
|
|
808
|
+
}
|
|
619
809
|
let listening = null;
|
|
620
810
|
const listeningPort = () => listening?.address?.()?.port ?? Number(process.env.PULSE_PORT ?? 4317);
|
|
621
811
|
// One CONTROL desk for the whole project, shared by every conversation alive at once: a single
|
|
@@ -635,6 +825,7 @@ export async function createPulseServer({
|
|
|
635
825
|
bishopForTurn: salasRoom ? async (turn) => moduleById('bishop').promptForTurn({ ...(await moduleContext()), record: (type, payload) => ((turn.turn && liveRooms().find((one) => one.runningTurn?.(turn.turn))) || room).record(type, payload) }, turn) : null,
|
|
636
826
|
// Fase 3e: notes a link with automatic recall brings to a turn. Never in the room of Salas
|
|
637
827
|
// (BISHOP has its own); recorded in the conversation of the turn that read them.
|
|
828
|
+
copilot: copilotNow,
|
|
638
829
|
linkedForTurn: salasRoom ? null : async (turn) => recallForTurn(await moduleContext(), { ...turn, port: listeningPort(), roomDir,
|
|
639
830
|
record: (type, payload) => ((turn.turn && liveRooms().find((one) => one.runningTurn?.(turn.turn))) || room).record(type, payload) }),
|
|
640
831
|
softTokenBudget,
|
|
@@ -788,8 +979,9 @@ export async function createPulseServer({
|
|
|
788
979
|
let loggingIn = null;
|
|
789
980
|
// A sign-in in flight, as the room is allowed to see it: which door, the address to open, and
|
|
790
981
|
// whether the CLI is waiting to be told the code that page gave back. Never the code itself.
|
|
982
|
+
// A device sign-in's code is the opposite case: it is shown to be typed on the provider's page.
|
|
791
983
|
let loginRun = null;
|
|
792
|
-
const loginState = () => (loginRun ? { agent: loginRun.agent, method: loginRun.method, command: loginRun.command, url: loginRun.url, asksForCode: loginRun.asksForCode, awaitingCode: loginRun.awaitingCode, startedAt: loginRun.startedAt } : null);
|
|
984
|
+
const loginState = () => (loginRun ? { agent: loginRun.agent, method: loginRun.method, command: loginRun.command, url: loginRun.url, deviceCode: loginRun.deviceCode, asksForCode: loginRun.asksForCode, awaitingCode: loginRun.awaitingCode, startedAt: loginRun.startedAt } : null);
|
|
793
985
|
|
|
794
986
|
async function loginAgent(id, { method = null } = {}) {
|
|
795
987
|
const agent = agents.find((item) => item.id === id);
|
|
@@ -799,36 +991,91 @@ export async function createPulseServer({
|
|
|
799
991
|
if (!plan) return { status: 404, body: { error: `No sign-in flow known for ${id}.` } };
|
|
800
992
|
if (!plan.headless) return { status: 409, body: { error: `${agent.label} signs in from its own prompt. Run this in a terminal, then press RECHECK.`, command: plan.display, note: plan.note } };
|
|
801
993
|
if (loggingIn) return { status: 409, body: { error: `A sign-in is already running (${loggingIn}).` } };
|
|
994
|
+
// A CLI with no sign-in command of its own (Gemini) signs in on its first question: its settings
|
|
995
|
+
// are set before, and the run is stopped as soon as the credentials are written. It runs in an
|
|
996
|
+
// empty folder, so that question never sees the project.
|
|
997
|
+
const flow = typeof plan.flow === 'object' ? plan.flow : plan.flow === 'gemini-google' ? geminiSignIn() : null;
|
|
998
|
+
let cwd = canonicalProjectRoot;
|
|
999
|
+
if (flow) {
|
|
1000
|
+
try {
|
|
1001
|
+
await flow.prepare();
|
|
1002
|
+
cwd = await mkdtemp(join(tmpdir(), 'madre-signin-'));
|
|
1003
|
+
} catch (error) {
|
|
1004
|
+
await flow.undo().catch(() => {});
|
|
1005
|
+
return { status: 500, body: { error: `The sign-in could not be prepared: ${error.message}` } };
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
const env = { ...process.env, ...(plan.env ?? {}) };
|
|
1009
|
+
for (const name of [...(plan.dropEnv ?? []), ...(plan.ci === false ? ['CI'] : [])]) delete env[name];
|
|
802
1010
|
loggingIn = id;
|
|
803
|
-
loginRun = { agent: id, method: plan.method ?? null, command: plan.display, url: null, asksForCode: Boolean(plan.asksForCode), awaitingCode: false, startedAt: Date.now(), child: null };
|
|
1011
|
+
loginRun = { agent: id, method: plan.method ?? null, command: plan.display, url: null, deviceCode: null, asksForCode: Boolean(plan.asksForCode), awaitingCode: false, startedAt: Date.now(), child: null };
|
|
804
1012
|
await room.record('connection.login.started', { agent: id, command: plan.display, note: plan.note, method: plan.method ?? null });
|
|
805
1013
|
void (async () => {
|
|
806
1014
|
const lines = [];
|
|
1015
|
+
let consented = false;
|
|
1016
|
+
const answered = new Set();
|
|
1017
|
+
let finished = false;
|
|
1018
|
+
let refused = false;
|
|
1019
|
+
// Signed in is not yet working: Google lets a free personal account sign in to Gemini CLI and
|
|
1020
|
+
// refuses it at the first question (2026-10-17). So the run is let ask its one-word question,
|
|
1021
|
+
// in an empty folder, and the sign-in counts only if that answer is not a refusal. A run that
|
|
1022
|
+
// goes quiet is stopped after half a minute, and counts.
|
|
1023
|
+
const watch = flow ? setInterval(async () => {
|
|
1024
|
+
if (finished || !(await flow.signedIn())) return;
|
|
1025
|
+
finished = true;
|
|
1026
|
+
setTimeout(() => { if (loginRun?.agent === id) loginRun.child?.cancel?.(); }, flow.verifyMs ?? 30000).unref?.();
|
|
1027
|
+
}, 700) : null;
|
|
807
1028
|
const result = await runInstaller({
|
|
808
1029
|
command: plan.command,
|
|
809
1030
|
args: plan.args,
|
|
810
|
-
projectRoot:
|
|
1031
|
+
projectRoot: cwd,
|
|
1032
|
+
env,
|
|
1033
|
+
ci: plan.ci !== false,
|
|
811
1034
|
// A person is in the middle of this: the browser, the account, the code. Five minutes was
|
|
812
1035
|
// the installer's clock, and it killed sign-ins that were only waiting on a human.
|
|
813
1036
|
timeoutMs: 900000,
|
|
814
1037
|
// A CLI that asks for a code needs a way to be answered, and its question never ends in a
|
|
815
1038
|
// newline, so the half-line is flushed rather than left sitting in the buffer.
|
|
816
|
-
stdin: plan.asksForCode ? 'pipe' : 'ignore',
|
|
817
|
-
flushIdleMs: plan.asksForCode ? 600 : 0,
|
|
1039
|
+
stdin: plan.asksForCode || plan.consent || plan.answers?.length ? 'pipe' : 'ignore',
|
|
1040
|
+
flushIdleMs: plan.asksForCode || plan.consent ? 600 : 0,
|
|
818
1041
|
quietNote: 'the sign-in is waiting on you: finish it in the page that opened',
|
|
819
1042
|
onChild: (handle) => { if (loginRun?.agent === id) loginRun.child = handle; },
|
|
820
|
-
onLine: (
|
|
1043
|
+
onLine: (raw) => {
|
|
1044
|
+
// A terminal UI (OpenCode's) hides and moves its cursor even with NO_COLOR: the room
|
|
1045
|
+
// shows what it says, not the escapes it draws with.
|
|
1046
|
+
const line = raw.replace(/\u001b\[[0-9;?]*[A-Za-z]/g, '');
|
|
1047
|
+
if (!line.trim()) return;
|
|
821
1048
|
lines.push(line);
|
|
1049
|
+
if (flow?.refused?.(line) && !refused) { refused = true; loginRun?.child?.cancel?.(); }
|
|
822
1050
|
const url = line.match(/https?:\/\/\S+/)?.[0] ?? null;
|
|
1051
|
+
const deviceCode = plan.deviceCode ? deviceCodeIn(line) : null;
|
|
823
1052
|
if (loginRun?.agent === id) {
|
|
824
1053
|
if (url) loginRun.url = url;
|
|
1054
|
+
if (deviceCode) loginRun.deviceCode = deviceCode;
|
|
1055
|
+
// A menu the CLI draws before its sign-in (OpenCode asks which GitHub): the button
|
|
1056
|
+
// pressed already chose, so the room answers it once, the way the plan says.
|
|
1057
|
+
for (const answer of plan.answers ?? []) if (!answered.has(answer) && line.includes(answer.prompt)) { answered.add(answer); loginRun.child?.write?.(answer.send); }
|
|
825
1058
|
if (plan.asksForCode && looksLikeCodePrompt(line)) loginRun.awaitingCode = true;
|
|
1059
|
+
// The person already said yes: that was the button they pressed.
|
|
1060
|
+
if (plan.consent && !consented && looksLikeConsentPrompt(line)) { consented = true; loginRun.child?.write?.('y\n'); }
|
|
826
1061
|
}
|
|
827
|
-
void room.record('connection.login.output', { agent: id, line: line.slice(0, 500), url });
|
|
1062
|
+
void room.record('connection.login.output', { agent: id, line: line.slice(0, 500), url, ...(deviceCode ? { deviceCode } : {}) });
|
|
828
1063
|
},
|
|
829
1064
|
});
|
|
1065
|
+
if (watch) clearInterval(watch);
|
|
1066
|
+
const good = finished && !refused;
|
|
1067
|
+
if (flow) {
|
|
1068
|
+
// Not finished, or refused at its first question: everything goes back as it was.
|
|
1069
|
+
if (!good) await flow.undo().catch(() => {});
|
|
1070
|
+
await rm(cwd, { recursive: true, force: true }).catch(() => {});
|
|
1071
|
+
}
|
|
830
1072
|
await refreshSessions();
|
|
831
|
-
|
|
1073
|
+
// A run with no flow (Codex, Claude, OpenCode) ends on its own, and its exit says how it went.
|
|
1074
|
+
const code = flow ? (good ? 0 : (result.code || 1)) : result.code;
|
|
1075
|
+
const error = refused
|
|
1076
|
+
? t('Google signed you in, but it refuses this account on Gemini CLI (free personal accounts are refused). Gemini was left as it was: paste a Gemini API key from Google AI Studio instead.')
|
|
1077
|
+
: finished ? null : result.error ?? null;
|
|
1078
|
+
await room.record('connection.login.finished', { agent: id, code, error, session: sessions[id] ?? null, tail: lines.slice(-6) });
|
|
832
1079
|
loggingIn = null;
|
|
833
1080
|
loginRun = null;
|
|
834
1081
|
})();
|
|
@@ -943,10 +1190,7 @@ export async function createPulseServer({
|
|
|
943
1190
|
|
|
944
1191
|
const startupConfig = await readConfig(root);
|
|
945
1192
|
room.setScopes(startupConfig.scopes ?? {});
|
|
946
|
-
|
|
947
|
-
room.setAsh(Boolean(startupConfig.modules?.ash?.enabled));
|
|
948
|
-
room.setChoices(Boolean(startupConfig.modules?.choices?.enabled));
|
|
949
|
-
room.setRuns(Boolean(startupConfig.modules?.runs?.enabled));
|
|
1193
|
+
applyModuleSwitches(room, startupConfig);
|
|
950
1194
|
const recoveredTurns = await room.reconcile();
|
|
951
1195
|
if (recoveredTurns) console.error(`MADRE recovered ${recoveredTurns} unfinished turn(s) from a previous run.`);
|
|
952
1196
|
const quotaMonitor = new QuotaMonitor({
|
|
@@ -1123,8 +1367,9 @@ export async function createPulseServer({
|
|
|
1123
1367
|
// its own takes the first thing the human said in it.
|
|
1124
1368
|
unsubscribeChats = room.subscribe((event) => {
|
|
1125
1369
|
if (event?.type !== 'message.created' || event.ghost) return;
|
|
1370
|
+
// A message MADRE wrote for the human (results going back) neither names nor previews it.
|
|
1126
1371
|
const human = event.payload?.role === 'user';
|
|
1127
|
-
chatWrites = chatWrites.then(() => touchChat(roomDir, chatId, { text: human ? saidText(event.payload) : null, counts: human })).catch(() => null);
|
|
1372
|
+
chatWrites = chatWrites.then(() => touchChat(roomDir, chatId, { text: human && !event.payload?.auto ? saidText(event.payload) : null, counts: human })).catch(() => null);
|
|
1128
1373
|
});
|
|
1129
1374
|
unsubscribeActivity = watchActivity(room, chatId);
|
|
1130
1375
|
sentinel.seed(historicalEvents);
|
|
@@ -1221,7 +1466,7 @@ export async function createPulseServer({
|
|
|
1221
1466
|
const offTouch = target.subscribe((event) => {
|
|
1222
1467
|
if (event?.type !== 'message.created' || event.ghost) return;
|
|
1223
1468
|
const human = event.payload?.role === 'user';
|
|
1224
|
-
chatWrites = chatWrites.then(() => touchChat(roomDir, id, { text: human ? saidText(event.payload) : null, counts: human })).catch(() => null);
|
|
1469
|
+
chatWrites = chatWrites.then(() => touchChat(roomDir, id, { text: human && !event.payload?.auto ? saidText(event.payload) : null, counts: human })).catch(() => null);
|
|
1225
1470
|
});
|
|
1226
1471
|
const offActivity = watchActivity(target, id);
|
|
1227
1472
|
const offModules = wireModules(target, id);
|
|
@@ -1298,11 +1543,17 @@ export async function createPulseServer({
|
|
|
1298
1543
|
async function applyRoomSettings(next, previous) {
|
|
1299
1544
|
const config = await readConfig(root);
|
|
1300
1545
|
next.setScopes(config.scopes ?? {});
|
|
1301
|
-
next
|
|
1302
|
-
next.setChoices(Boolean(config.modules?.choices?.enabled));
|
|
1303
|
-
next.setRuns(Boolean(config.modules?.runs?.enabled));
|
|
1546
|
+
applyModuleSwitches(next, config);
|
|
1304
1547
|
if (previous) next.configure(previous.settings());
|
|
1305
1548
|
}
|
|
1549
|
+
// The switches a room holds itself, as this project says them (module-scope.mjs). Read again
|
|
1550
|
+
// before every message, so a switch moved in Home reaches a project that is already open.
|
|
1551
|
+
function applyModuleSwitches(target, config) {
|
|
1552
|
+
setImageModule(settingsIn(config.modules?.imageStudio ?? {}, scopeRoom));
|
|
1553
|
+
target.setAsh(moduleOnHere(config, 'ash'));
|
|
1554
|
+
target.setChoices(moduleOnHere(config, 'choices'));
|
|
1555
|
+
target.setRuns(moduleOnHere(config, 'runs'));
|
|
1556
|
+
}
|
|
1306
1557
|
|
|
1307
1558
|
// Opening another conversation. The project keeps its memory, its crew, its modules, its
|
|
1308
1559
|
// privacy list and its numbering; what changes is the record being shown. With TURNS on, a
|
|
@@ -1373,6 +1624,8 @@ export async function createPulseServer({
|
|
|
1373
1624
|
const config = await readConfig(root);
|
|
1374
1625
|
return {
|
|
1375
1626
|
projectRoot: canonicalProjectRoot, stateRoot: root, config, env: process.env, agents, room,
|
|
1627
|
+
// Which project's word a module's switch reads and writes; none in Home (module-scope.mjs).
|
|
1628
|
+
roomId: scopeRoom,
|
|
1376
1629
|
readConfig: () => readConfig(root),
|
|
1377
1630
|
updateConfig: (patch) => updateConfig(root, patch),
|
|
1378
1631
|
record: (type, payload) => room.record(type, payload),
|
|
@@ -1552,6 +1805,9 @@ export async function createPulseServer({
|
|
|
1552
1805
|
}
|
|
1553
1806
|
const before = await extension.detect(canonicalProjectRoot);
|
|
1554
1807
|
const plan = installers[id]?.({ projectRoot: canonicalProjectRoot, agents }) ?? extension.installCommand({ agents });
|
|
1808
|
+
// What the module asked MADRE to do first (Continuity: `git init` in a project with no git),
|
|
1809
|
+
// shown in the same confirmation and run before the install, each step stopping the next.
|
|
1810
|
+
const prepare = Array.isArray(preflight.prepare) ? preflight.prepare : [];
|
|
1555
1811
|
installing = id;
|
|
1556
1812
|
await room.record('extension.install.started', { id, name: extension.name, command: plan.display, platforms: plan.platforms ?? [], alreadyInstalled: before.installed });
|
|
1557
1813
|
void (async () => {
|
|
@@ -1565,17 +1821,19 @@ export async function createPulseServer({
|
|
|
1565
1821
|
pending = [];
|
|
1566
1822
|
await room.record('extension.install.output', { id, lines: batch });
|
|
1567
1823
|
};
|
|
1568
|
-
const
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
}
|
|
1578
|
-
|
|
1824
|
+
const onLine = (line) => {
|
|
1825
|
+
lines.push(line);
|
|
1826
|
+
pending.push(line.slice(0, 500));
|
|
1827
|
+
if (pending.length >= 20) void flush();
|
|
1828
|
+
else if (!flushTimer) flushTimer = setTimeout(() => { void flush(); }, 400);
|
|
1829
|
+
};
|
|
1830
|
+
let result = { code: 0 };
|
|
1831
|
+
for (const step of prepare) {
|
|
1832
|
+
onLine(`$ ${step.display}`);
|
|
1833
|
+
result = await runInstaller({ command: step.command, args: step.args, projectRoot: canonicalProjectRoot, onLine });
|
|
1834
|
+
if (result.code !== 0) break;
|
|
1835
|
+
}
|
|
1836
|
+
if (result.code === 0) result = await runInstaller({ command: plan.command, args: plan.args, projectRoot: canonicalProjectRoot, onLine });
|
|
1579
1837
|
clearTimeout(flushTimer);
|
|
1580
1838
|
await flush();
|
|
1581
1839
|
const after = await extension.detect(canonicalProjectRoot);
|
|
@@ -1611,15 +1869,11 @@ export async function createPulseServer({
|
|
|
1611
1869
|
response.writeHead(200, { 'content-type': 'text/html; charset=utf-8', ...PAGE_GUARDS });
|
|
1612
1870
|
return response.end(html);
|
|
1613
1871
|
}
|
|
1614
|
-
if (request.method === 'GET' && ['/app.js', '/nostromo-engine.js', '/brands.js', '/troubleshooting.js', '/inquiry.js', '/i18n.js', '/es.js', '/resay.js', '/syntax.js', '/editor.js', '/routing.js', '/abduction.js', '/review-ask.js', '/loader.js', '/tour.js', '/tour-chapters.js', '/touch.js'].includes(url.pathname)) {
|
|
1615
|
-
|
|
1616
|
-
response.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8' });
|
|
1617
|
-
return response.end(js);
|
|
1872
|
+
if (request.method === 'GET' && ['/app.js', '/nostromo-engine.js', '/brands.js', '/troubleshooting.js', '/inquiry.js', '/i18n.js', '/es.js', '/resay.js', '/syntax.js', '/editor.js', '/routing.js', '/abduction.js', '/review-ask.js', '/loader.js', '/tour.js', '/tour-chapters.js', '/touch.js', '/attribution.js', '/power.js', '/wallpaper.js', '/neural.js', '/hardware.js', '/code-tools.js', '/market.js', '/market-view.js', '/market-art.js'].includes(url.pathname)) {
|
|
1873
|
+
return sendAsset(request, response, join(publicDirectory, url.pathname.slice(1)), 'text/javascript; charset=utf-8');
|
|
1618
1874
|
}
|
|
1619
1875
|
if (request.method === 'GET' && url.pathname === '/styles.css') {
|
|
1620
|
-
|
|
1621
|
-
response.writeHead(200, { 'content-type': 'text/css; charset=utf-8' });
|
|
1622
|
-
return response.end(css);
|
|
1876
|
+
return sendAsset(request, response, join(publicDirectory, 'styles.css'), 'text/css; charset=utf-8');
|
|
1623
1877
|
}
|
|
1624
1878
|
const abductionAssets = new Map([
|
|
1625
1879
|
['/assets/abduction-cat.png', 'abduction-cat.png'],
|
|
@@ -1773,9 +2027,7 @@ export async function createPulseServer({
|
|
|
1773
2027
|
return response.end(html);
|
|
1774
2028
|
}
|
|
1775
2029
|
if (request.method === 'GET' && (url.pathname === '/salas-mac.js' || url.pathname === '/salas-mac.css' || url.pathname === '/macroverso.js')) {
|
|
1776
|
-
|
|
1777
|
-
response.writeHead(200, { 'content-type': url.pathname.endsWith('.js') ? 'text/javascript; charset=utf-8' : 'text/css; charset=utf-8', 'cache-control': 'no-cache' });
|
|
1778
|
-
return response.end(file);
|
|
2030
|
+
return sendAsset(request, response, join(publicDirectory, url.pathname.slice(1)), url.pathname.endsWith('.js') ? 'text/javascript; charset=utf-8' : 'text/css; charset=utf-8');
|
|
1779
2031
|
}
|
|
1780
2032
|
// A widget of Home, read here (homeWidget above): only from this Mac's own pages.
|
|
1781
2033
|
if (request.method === 'GET' && url.pathname === '/api/salas/widget') {
|
|
@@ -1833,7 +2085,7 @@ export async function createPulseServer({
|
|
|
1833
2085
|
// the room of Salas, whether or not that room exists yet (phase 3d).
|
|
1834
2086
|
const salas = salasRoomId(root);
|
|
1835
2087
|
const enlaces = await Promise.all(activeContracts(await readConfig(root), { salas }).map(async (contract) => ({ ...contract, traffic: await linkTraffic(servingRooms(contract).map((id) => join(root, 'rooms', id)), contract) })));
|
|
1836
|
-
return sendJson(response, 200, { mac: hostname().replace(/\.local$/i, ''), language: roomLanguage(), here, version: PACKAGE.version ?? null, rooms: seen, handoff: (await readHandoff(root)).mac ?? null, salas, enlaces, phonePreview: await phonePreview() });
|
|
2088
|
+
return sendJson(response, 200, { mac: hostname().replace(/\.local$/i, ''), language: roomLanguage(), languageChosen, here, version: PACKAGE.version ?? null, rooms: seen, handoff: (await readHandoff(root)).mac ?? null, salas, enlaces, phonePreview: await phonePreview() });
|
|
1837
2089
|
}
|
|
1838
2090
|
// The open rooms, each in one line (its glance: who works, who waits, its last conversation;
|
|
1839
2091
|
// never its census or the links), for the room's switcher (2026-10-10), asked every few
|
|
@@ -1898,7 +2150,7 @@ export async function createPulseServer({
|
|
|
1898
2150
|
// folders (Sec-Fetch-Site) or to add one (Origin), and so is any host name but this Mac's
|
|
1899
2151
|
// own, so a name pointed at it from outside reaches nothing. Folders are listed inside the
|
|
1900
2152
|
// home folder only; what is listed is names, never what a file says.
|
|
1901
|
-
if (url.pathname === '/api/salas/folders' || url.pathname === '/api/salas/suggestions' || url.pathname === '/api/salas/projects' || url.pathname === '/api/salas/work' || url.pathname === '/api/salas/decisions') {
|
|
2153
|
+
if (url.pathname === '/api/salas/folders' || url.pathname === '/api/salas/suggestions' || url.pathname === '/api/salas/projects' || url.pathname === '/api/salas/new' || url.pathname === '/api/salas/work' || url.pathname === '/api/salas/decisions') {
|
|
1902
2154
|
const host = String(request.headers.host ?? '');
|
|
1903
2155
|
const localHost = /^(127\.0\.0\.1|localhost)(:\d+)?$/.test(host);
|
|
1904
2156
|
const site = request.headers['sec-fetch-site'];
|
|
@@ -1974,6 +2226,21 @@ export async function createPulseServer({
|
|
|
1974
2226
|
if (!lobbyDecisions.list) await lobbyDecisions.running;
|
|
1975
2227
|
return sendJson(response, 200, { decisions: lobbyDecisions.list ?? [], at: lobbyDecisions.at || null });
|
|
1976
2228
|
}
|
|
2229
|
+
// A project from nothing: a new folder, named by the person, in the folder they were in.
|
|
2230
|
+
if (request.method === 'POST' && url.pathname === '/api/salas/new') {
|
|
2231
|
+
const payload = await body(request).catch(() => ({}));
|
|
2232
|
+
const made = await createProject({ name: typeof payload.name === 'string' ? payload.name : '', parent: typeof payload.parent === 'string' ? payload.parent : null, home, stateRoot: root });
|
|
2233
|
+
if (!made.error) return sendJson(response, 200, { added: [made] });
|
|
2234
|
+
const said = {
|
|
2235
|
+
empty: t('Write a name for the project.'),
|
|
2236
|
+
long: t('That name is too long.'),
|
|
2237
|
+
name: t('A folder cannot be called that: leave out slashes and : * ? " < > |, and do not start with a dot or end with a dot or a space.'),
|
|
2238
|
+
exists: t('There is already a folder with that name there.'),
|
|
2239
|
+
outside: t('Only folders inside your home folder.'),
|
|
2240
|
+
madre: t('That folder is MADRE\'s own.'),
|
|
2241
|
+
}[made.error] ?? t('That folder is not there.');
|
|
2242
|
+
return sendJson(response, made.error === 'exists' ? 409 : 422, { error: said, code: made.error });
|
|
2243
|
+
}
|
|
1977
2244
|
if (request.method === 'POST' && url.pathname === '/api/salas/projects') {
|
|
1978
2245
|
const payload = await body(request).catch(() => ({}));
|
|
1979
2246
|
const roots = Array.isArray(payload.roots) ? payload.roots.filter((one) => typeof one === 'string').slice(0, 50) : [];
|
|
@@ -2008,6 +2275,27 @@ export async function createPulseServer({
|
|
|
2008
2275
|
const stopped = await Promise.all(rooms.map((one) => askRoom(one.open.port, '/api/stop-all', { method: 'POST', body: {}, timeoutMs: 3000 }).then((answer) => !answer.unreachable && !answer.outdated)));
|
|
2009
2276
|
return sendJson(response, 200, { rooms: rooms.length, stopped: stopped.filter(Boolean).length });
|
|
2010
2277
|
}
|
|
2278
|
+
// Turning MADRE off (2026-10-16), from a page of this Mac only, after the person confirmed it.
|
|
2279
|
+
// Every other open project is asked to close the way Home closes one — its agents stop, their
|
|
2280
|
+
// turns are recorded, its memory and its marker are put away — and waited for; then this room
|
|
2281
|
+
// closes the same way. Ollama is its own program and keeps running. Nothing else is touched.
|
|
2282
|
+
if (request.method === 'POST' && url.pathname === '/api/madre/shutdown') {
|
|
2283
|
+
const host = String(request.headers.host ?? '');
|
|
2284
|
+
const site = request.headers['sec-fetch-site'];
|
|
2285
|
+
if (viaPhone || !/^(127\.0\.0\.1|localhost)(:\d+)?$/.test(host) || (site !== undefined && site !== 'same-origin' && site !== 'none') || request.headers.origin !== `http://${host}`) return sendJson(response, 403, { error: t('MADRE is turned off from its own page, on this computer.') });
|
|
2286
|
+
const here = basename(roomDir);
|
|
2287
|
+
const others = (await knownRooms(root)).filter((one) => one.open && one.id !== here);
|
|
2288
|
+
await Promise.all(others.map((one) => askRoom(one.open.port, '/api/room/close', { method: 'POST', body: {}, timeoutMs: 3000 }).catch(() => null)));
|
|
2289
|
+
let left = others;
|
|
2290
|
+
for (let tries = 0; tries < 60 && left.length; tries += 1) {
|
|
2291
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
2292
|
+
const now = await knownRooms(root);
|
|
2293
|
+
left = left.filter((one) => now.find((room) => room.id === one.id)?.open);
|
|
2294
|
+
}
|
|
2295
|
+
sendJson(response, 202, { closing: true, closed: others.length - left.length, stuck: left.map((one) => one.name) });
|
|
2296
|
+
setTimeout(() => server.emit('madre:close'), 150);
|
|
2297
|
+
return undefined;
|
|
2298
|
+
}
|
|
2011
2299
|
// This room, asked to close by Salas: what is running stops and the room closes the way a
|
|
2012
2300
|
// signal closes it (startPulse listens). Nothing here ends the process itself.
|
|
2013
2301
|
if (request.method === 'POST' && url.pathname === '/api/room/close') {
|
|
@@ -2059,6 +2347,89 @@ export async function createPulseServer({
|
|
|
2059
2347
|
await noteCrossing(roomDir, { kind: 'read', contract: contract.id, other: asking, count: notes.length, agent: reader });
|
|
2060
2348
|
return sendJson(response, 200, { contract: contract.id, from: here, notes });
|
|
2061
2349
|
}
|
|
2350
|
+
// D-S15 · the whole of what this project lets the Coordinator learn, for its training export.
|
|
2351
|
+
// The same doors as a reading, a contract that says `train`, and a ceiling of its own: every
|
|
2352
|
+
// note that travels (`travels()`), of the kinds signed, through this room's privacy guard.
|
|
2353
|
+
// Not a search and not the latest: all of it, up to CORPUS_MAX, so nothing the project knows
|
|
2354
|
+
// is left out of what the Coordinator learns about it. Nothing of the ledger crosses.
|
|
2355
|
+
if (request.method === 'GET' && url.pathname === '/api/enlace/corpus') {
|
|
2356
|
+
if (viaPhone) return sendJson(response, 403, { error: t('This is done on the Mac.') });
|
|
2357
|
+
if (request.headers.origin !== undefined) return sendJson(response, 403, { error: t('A page cannot read across a connection; only a project can.') });
|
|
2358
|
+
const key = await channelKey(root).catch(() => null);
|
|
2359
|
+
if (!key || !sameKey(request.headers[CHANNEL_HEADER], key)) return sendJson(response, 403, { error: t('That is not the key of this Mac.') });
|
|
2360
|
+
const here = basename(roomDir);
|
|
2361
|
+
const asking = String(url.searchParams.get('asking') ?? '');
|
|
2362
|
+
const contract = activeContracts(await readConfig(root), { salas: salasRoomId(root) }).find((one) => one.id === url.searchParams.get('contract')) ?? null;
|
|
2363
|
+
if (!contract?.train || !mayServe({ contract, serving: here, asking }).ok) return sendJson(response, 404, { error: NO_SUCH_LINK });
|
|
2364
|
+
const sealed = room.motherStatus()?.lockedForMs ?? 0;
|
|
2365
|
+
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 });
|
|
2366
|
+
if (!memory) return sendJson(response, 503, { error: t('The project has no memory.') });
|
|
2367
|
+
const taken = await countRecent(roomDir, contract.id, 'corpus');
|
|
2368
|
+
if (taken >= CORPUS_PER_HOUR) return sendJson(response, 429, { error: t('The Coordinator took what this project knows {n} times in the last hour, its limit. Try again later.', { n: taken }) });
|
|
2369
|
+
await refreshPrivacy();
|
|
2370
|
+
const notes = memory.memories({ limit: CORPUS_MAX * 2 })
|
|
2371
|
+
.filter((row) => memoryTravels(row) && contract.kinds.includes(row.kind))
|
|
2372
|
+
.slice(0, CORPUS_MAX)
|
|
2373
|
+
.map((row) => ({ id: row.id, kind: row.kind, zone: row.zone, text: privacy.redact(row.text, { always: true }).text, fromSequence: row.fromSequence, throughSequence: row.throughSequence, created: row.created }));
|
|
2374
|
+
await room.record('enlace.corpus', { contract: contract.id, to: asking, count: notes.length, kinds: contract.kinds });
|
|
2375
|
+
await noteCrossing(roomDir, { kind: 'corpus', contract: contract.id, other: asking, count: notes.length });
|
|
2376
|
+
return sendJson(response, 200, { contract: contract.id, from: here, name: basename(canonicalProjectRoot), notes });
|
|
2377
|
+
}
|
|
2378
|
+
// D-S16 · this project's canonical state (Continuity, AHP+), for the room that reads under a
|
|
2379
|
+
// link that says `state`. The same doors as a reading and the same ceiling, counted apart: the
|
|
2380
|
+
// state is read from .ahp/ now, through this room's privacy guard, and nothing else of the
|
|
2381
|
+
// project (no git, no files, no evidence) crosses. Without .ahp/ the answer says so: `null`.
|
|
2382
|
+
if (request.method === 'GET' && url.pathname === '/api/enlace/state') {
|
|
2383
|
+
if (viaPhone) return sendJson(response, 403, { error: t('This is done on the Mac.') });
|
|
2384
|
+
if (request.headers.origin !== undefined) return sendJson(response, 403, { error: t('A page cannot read across a connection; only a project can.') });
|
|
2385
|
+
const key = await channelKey(root).catch(() => null);
|
|
2386
|
+
if (!key || !sameKey(request.headers[CHANNEL_HEADER], key)) return sendJson(response, 403, { error: t('That is not the key of this Mac.') });
|
|
2387
|
+
const here = basename(roomDir);
|
|
2388
|
+
const asking = String(url.searchParams.get('asking') ?? '');
|
|
2389
|
+
const contract = activeContracts(await readConfig(root), { salas: salasRoomId(root) }).find((one) => one.id === url.searchParams.get('contract')) ?? null;
|
|
2390
|
+
if (!contract?.state || !mayServe({ contract, serving: here, asking }).ok) return sendJson(response, 404, { error: NO_SUCH_LINK });
|
|
2391
|
+
const sealed = room.motherStatus()?.lockedForMs ?? 0;
|
|
2392
|
+
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 });
|
|
2393
|
+
const reads = await countRecent(roomDir, contract.id, 'state');
|
|
2394
|
+
if (reads >= contract.orders.perHour) return sendJson(response, 429, { error: t('The state of this project was read {n} times in the last hour, its limit. Try again later.', { n: reads }) });
|
|
2395
|
+
await refreshPrivacy();
|
|
2396
|
+
const state = await readContinuity(canonicalProjectRoot, { redact: (text) => privacy.redact(text, { always: true }).text });
|
|
2397
|
+
const reader = /^[a-z][a-z0-9_-]{0,30}$/.test(String(url.searchParams.get('agent') ?? '')) ? url.searchParams.get('agent') : null;
|
|
2398
|
+
await room.record('enlace.state', { contract: contract.id, to: asking, source: state?.source ?? null, ...(reader ? { agent: reader } : {}) });
|
|
2399
|
+
await noteCrossing(roomDir, { kind: 'state', contract: contract.id, other: asking, count: state ? 1 : 0, agent: reader });
|
|
2400
|
+
return sendJson(response, 200, { contract: contract.id, from: here, name: basename(canonicalProjectRoot), state });
|
|
2401
|
+
}
|
|
2402
|
+
// D-S17 · notes another project hands to this one, under a link that says `deliver` and lets
|
|
2403
|
+
// that project read from this one. The same doors; the contract's ceiling, counted apart. They
|
|
2404
|
+
// wait in this room's inbox (enlaces-inbox.json): nothing enters the memory until this project
|
|
2405
|
+
// accepts it, by your press or by an agent of this project in #3 or above.
|
|
2406
|
+
if (request.method === 'POST' && url.pathname === '/api/enlace/deliver') {
|
|
2407
|
+
if (viaPhone) return sendJson(response, 403, { error: t('This is done on the Mac.') });
|
|
2408
|
+
if (request.headers.origin !== undefined) return sendJson(response, 403, { error: t('A page cannot deliver across a connection; only a project can.') });
|
|
2409
|
+
const key = await channelKey(root).catch(() => null);
|
|
2410
|
+
if (!key || !sameKey(request.headers[CHANNEL_HEADER], key)) return sendJson(response, 403, { error: t('That is not the key of this Mac.') });
|
|
2411
|
+
const payload = await body(request).catch(() => ({}));
|
|
2412
|
+
const here = basename(roomDir);
|
|
2413
|
+
const from = String(payload.from ?? '');
|
|
2414
|
+
const contract = activeContracts(await readConfig(root), { salas: salasRoomId(root) }).find((one) => one.id === payload.contract) ?? null;
|
|
2415
|
+
if (!contract?.deliver || !mayServe({ contract, serving: here, asking: from }).ok) return sendJson(response, 404, { error: NO_SUCH_LINK });
|
|
2416
|
+
if (!Array.isArray(payload.notes) || !payload.notes.length || payload.notes.length > DELIVER_MAX) return sendJson(response, 422, { error: t('A delivery carries between 1 and {n} notes.', { n: DELIVER_MAX }) });
|
|
2417
|
+
let notes;
|
|
2418
|
+
try { notes = payload.notes.map(deliverable); } catch (error) { return sendJson(response, 422, { error: t(error.message) }); }
|
|
2419
|
+
const received = await countRecent(roomDir, contract.id, 'delivered');
|
|
2420
|
+
if (received >= contract.orders.perHour) return sendJson(response, 429, { error: t('This project received {n} deliveries in the last hour, its limit. Try again later.', { n: received }) });
|
|
2421
|
+
// What arrives is cleaned here too, by this room's guard, before it is even shown.
|
|
2422
|
+
await refreshPrivacy();
|
|
2423
|
+
const clean = notes.map((note) => ({ ...note, text: privacy.redact(note.text, { always: true }).text, why: note.why ? privacy.redact(note.why, { always: true }).text : null }));
|
|
2424
|
+
const sender = (await knownRooms(root)).find((one) => one.id === from);
|
|
2425
|
+
const agent = /^[a-z][a-z0-9_-]{0,30}$/.test(String(payload.agent ?? '')) ? payload.agent : null;
|
|
2426
|
+
let added;
|
|
2427
|
+
try { added = await receiveDelivery(roomDir, { contract: contract.id, from, name: sender?.name ?? from, agent, notes: clean }); }
|
|
2428
|
+
catch (error) { return sendJson(response, error.code === 'FULL' ? 429 : 500, { error: t(error.message) }); }
|
|
2429
|
+
await room.record('enlace.delivered', { contract: contract.id, from, name: sender?.name ?? from, agent, coordinator: from === salasRoomId(root), notes: added.map(({ id, kind, text, why }) => ({ id, kind, text, why })) });
|
|
2430
|
+
await noteCrossing(roomDir, { kind: 'delivered', contract: contract.id, other: from, count: added.length, agent });
|
|
2431
|
+
return sendJson(response, 202, { accepted: added.length, ids: added.map((note) => note.id) });
|
|
2432
|
+
}
|
|
2062
2433
|
// The side that asks (fase 3c): a tool of this room's own turn asks this room, and this room
|
|
2063
2434
|
// asks the room that serves, by the channel. The same three doors as above — never a phone,
|
|
2064
2435
|
// never a page, only the key — because the tool is a process of this Mac and nothing else is.
|
|
@@ -2103,6 +2474,84 @@ export async function createPulseServer({
|
|
|
2103
2474
|
if (answer.unreachable) return sendJson(response, 502, { error: t('That project did not answer.') });
|
|
2104
2475
|
return sendJson(response, answer.status ?? 502, { error: answer.refused ?? t('That project did not answer.') });
|
|
2105
2476
|
}
|
|
2477
|
+
// D-S16 and D-S17, the side that asks: a tool of this room's own turn reads the state of a
|
|
2478
|
+
// project it reads from, or delivers notes to it. The same doors and the same check that the
|
|
2479
|
+
// turn is running here, for that agent, above GHOST, as /api/enlace/ask.
|
|
2480
|
+
if (request.method === 'POST' && (url.pathname === '/api/enlace/ask-state' || url.pathname === '/api/enlace/send')) {
|
|
2481
|
+
if (viaPhone) return sendJson(response, 403, { error: t('This is done on the Mac.') });
|
|
2482
|
+
if (request.headers.origin !== undefined) return sendJson(response, 403, { error: t('A page cannot read across a connection; only a project can.') });
|
|
2483
|
+
const key = await channelKey(root).catch(() => null);
|
|
2484
|
+
if (!key || !sameKey(request.headers[CHANNEL_HEADER], key)) return sendJson(response, 403, { error: t('That is not the key of this Mac.') });
|
|
2485
|
+
const payload = await body(request).catch(() => ({}));
|
|
2486
|
+
const here = basename(roomDir);
|
|
2487
|
+
const turnId = String(payload.turn ?? '');
|
|
2488
|
+
const host = turnId ? liveRooms().find((one) => one.runningTurn?.(turnId)) : null;
|
|
2489
|
+
const running = host?.runningTurn(turnId) ?? null;
|
|
2490
|
+
if (!running || running.agent !== payload.agent || !(running.mode >= 1)) return sendJson(response, 409, { error: t('That reading does not belong to a turn running in this project.') });
|
|
2491
|
+
const wanted = String(payload.room ?? '').trim();
|
|
2492
|
+
const rooms = await knownRooms(root);
|
|
2493
|
+
const named = rooms.filter((one) => one.id === wanted || one.name.toLowerCase() === wanted.toLowerCase());
|
|
2494
|
+
if (!wanted || !named.length) return sendJson(response, 404, { error: t('MADRE does not know that project on this Mac.') });
|
|
2495
|
+
const contracts = activeContracts(await readConfig(root), { salas: salasRoomId(root) });
|
|
2496
|
+
const sending = url.pathname === '/api/enlace/send';
|
|
2497
|
+
const usable = sending ? deliversTo(contracts, here).map(({ contract, to }) => ({ contract, other: to })) : readsFrom(contracts, here).filter(({ contract }) => contract.state).map(({ contract, from }) => ({ contract, other: from }));
|
|
2498
|
+
const link = named.map((source) => ({ source, contract: usable.find((one) => one.other === source.id)?.contract ?? null })).find((one) => one.contract) ?? null;
|
|
2499
|
+
if (!link) return sendJson(response, 404, { error: sending ? t('No signed connection lets this project deliver notes to that one.') : t('No signed connection lets this project read the state of that one.') });
|
|
2500
|
+
const { source, contract } = link;
|
|
2501
|
+
if (!source.open) return sendJson(response, 409, { error: t('That project is asleep. Wake it from Home to read from it.') });
|
|
2502
|
+
if (!sending) {
|
|
2503
|
+
const answer = await askState({ port: source.open.port, contract, asking: here, key, agent: running.agent });
|
|
2504
|
+
if (answer.outdated) return sendJson(response, 409, { error: t('That project runs an older MADRE. Update it, or close it from its terminal.') });
|
|
2505
|
+
if (answer.unreachable) return sendJson(response, 502, { error: t('That project did not answer.') });
|
|
2506
|
+
if (answer.refused) return sendJson(response, answer.status ?? 502, { error: answer.refused });
|
|
2507
|
+
await host.record('enlace.state.received', { contract: contract.id, from: source.id, name: source.name, agent: running.agent, turn: turnId, found: Boolean(answer.state) });
|
|
2508
|
+
return sendJson(response, 200, { from: source.name, contract: contract.id, state: answer.state });
|
|
2509
|
+
}
|
|
2510
|
+
if (!Array.isArray(payload.notes) || !payload.notes.length || payload.notes.length > DELIVER_MAX) return sendJson(response, 422, { error: t('A delivery carries between 1 and {n} notes.', { n: DELIVER_MAX }) });
|
|
2511
|
+
let notes;
|
|
2512
|
+
try { notes = payload.notes.map(deliverable); } catch (error) { return sendJson(response, 422, { error: t(error.message) }); }
|
|
2513
|
+
// What leaves is cleaned by this room's guard first, as every note that crosses.
|
|
2514
|
+
await refreshPrivacy();
|
|
2515
|
+
notes = notes.map((note) => ({ ...note, text: privacy.redact(note.text, { always: true }).text, why: note.why ? privacy.redact(note.why, { always: true }).text : null }));
|
|
2516
|
+
const sent = await deliverNotes({ port: source.open.port, contract, from: here, notes, key, agent: running.agent });
|
|
2517
|
+
if (sent.outdated) return sendJson(response, 409, { error: t('That project runs an older MADRE. Update it, or close it from its terminal.') });
|
|
2518
|
+
if (sent.unreachable) return sendJson(response, 502, { error: t('That project did not answer.') });
|
|
2519
|
+
if (sent.refused) return sendJson(response, sent.status ?? 502, { error: sent.refused });
|
|
2520
|
+
await host.record('enlace.sent', { contract: contract.id, to: source.id, name: source.name, agent: running.agent, turn: turnId, notes: notes.map(({ kind, text }) => ({ kind, text: text.slice(0, 200) })) });
|
|
2521
|
+
await noteCrossing(roomDir, { kind: 'sent', contract: contract.id, other: source.id, count: sent.accepted, agent: running.agent, turn: turnId });
|
|
2522
|
+
return sendJson(response, 202, { to: source.name, delivered: sent.accepted });
|
|
2523
|
+
}
|
|
2524
|
+
// D-S17 · the notes other projects delivered here. Read by the page or by a tool of a turn of
|
|
2525
|
+
// this room; decided by you from the page, or by an agent of this project whose turn runs in
|
|
2526
|
+
// #3 or #4 (it changes the project's memory, as CONTROL may). Accepted, the note enters the
|
|
2527
|
+
// memory as a distilled note does — held first — citing the ledger line of its acceptance.
|
|
2528
|
+
// Read like the room's other records (the guard keeps other computers and other sites out).
|
|
2529
|
+
if (url.pathname === '/api/enlace/inbox' && request.method === 'GET') {
|
|
2530
|
+
return sendJson(response, 200, { notes: (await readInbox(roomDir)).slice(-100).reverse() });
|
|
2531
|
+
}
|
|
2532
|
+
if (url.pathname === '/api/enlace/inbox/decide' && request.method === 'POST') {
|
|
2533
|
+
const payload = await body(request).catch(() => ({}));
|
|
2534
|
+
let by = 'you';
|
|
2535
|
+
if (request.headers.origin === undefined) {
|
|
2536
|
+
const key = await channelKey(root).catch(() => null);
|
|
2537
|
+
if (viaPhone || !key || !sameKey(request.headers[CHANNEL_HEADER], key)) return sendJson(response, 403, { error: t('That is not the key of this Mac.') });
|
|
2538
|
+
const turnId = String(payload.turn ?? '');
|
|
2539
|
+
const running = turnId ? liveRooms().find((one) => one.runningTurn?.(turnId))?.runningTurn(turnId) : null;
|
|
2540
|
+
if (!running || running.agent !== payload.agent) return sendJson(response, 409, { error: t('That reading does not belong to a turn running in this project.') });
|
|
2541
|
+
if (!(running.mode >= 3)) return sendJson(response, 403, { error: t('Only an agent in #3 or #4 accepts a delivered note; below that, you decide it.') });
|
|
2542
|
+
by = running.agent;
|
|
2543
|
+
} else if (viaPhone) return sendJson(response, 403, { error: t('This is done on the Mac.') });
|
|
2544
|
+
if (typeof payload.accept !== 'boolean') return sendJson(response, 422, { error: t('A delivered note is accepted or declined.') });
|
|
2545
|
+
if (payload.accept && !memory) return sendJson(response, 503, { error: t('The project has no memory.') });
|
|
2546
|
+
const note = await decideDelivery(roomDir, String(payload.id ?? ''), { accept: payload.accept, by });
|
|
2547
|
+
if (!note) return sendJson(response, 404, { error: t('That delivered note is not waiting here.') });
|
|
2548
|
+
const event = await room.record(payload.accept ? 'enlace.accepted' : 'enlace.declined', { id: note.id, contract: note.contract, from: note.from, name: note.name, kind: note.kind, text: note.text, by });
|
|
2549
|
+
if (payload.accept) {
|
|
2550
|
+
const sequence = Number(event?.sequence) || 0;
|
|
2551
|
+
memory.addMemories([{ kind: note.kind, text: `${note.text} (delivered by ${note.name})` }], { agent: by, fromSequence: sequence, throughSequence: sequence, origin: 'delivered' });
|
|
2552
|
+
}
|
|
2553
|
+
return sendJson(response, 200, { note });
|
|
2554
|
+
}
|
|
2106
2555
|
// What another room tells this one by the channel: a link of this room's was signed or
|
|
2107
2556
|
// revoked. One line in the ledger, never a message; any other type is refused.
|
|
2108
2557
|
if (request.method === 'POST' && url.pathname === '/api/enlace/notice') {
|
|
@@ -2146,7 +2595,12 @@ export async function createPulseServer({
|
|
|
2146
2595
|
// «Include always» only from the page (INI-04 of the 1.0 review): its own route refuses a
|
|
2147
2596
|
// process, and signing let one set it anyway, so a project got another's notes every turn.
|
|
2148
2597
|
const recall = payload.recall === true && !signed && request.headers.origin !== undefined;
|
|
2149
|
-
|
|
2598
|
+
// D-S15: «the Coordinator may learn from it» the same way: only you, only from the page.
|
|
2599
|
+
const train = payload.train === true && !signed && request.headers.origin !== undefined;
|
|
2600
|
+
// D-S16 and D-S17 the same way: the state and deliveries, only you, only from the page.
|
|
2601
|
+
const state = payload.state === true && !signed && request.headers.origin !== undefined;
|
|
2602
|
+
const deliver = payload.deliver === true && !signed && request.headers.origin !== undefined;
|
|
2603
|
+
contract = await signContract(root, { rooms: { a: payload.a, b: payload.b }, direction: payload.direction, kinds: payload.kinds, expires: payload.expires ?? null, orders: payload.orders ?? { allowed: false, maxMode: 0, perHour: 6 }, signed, recall, train, state, deliver }, { rooms, salas: salasRoomId(root), designation: payload.designation ?? null });
|
|
2150
2604
|
} catch (error) {
|
|
2151
2605
|
if (error.code === 'DESIGNATION') return sendJson(response, 403, { error: t('UNABLE TO COMPUTE. UNABLE TO CLARIFY.') });
|
|
2152
2606
|
if (error.code === 'ALREADY') return sendJson(response, 409, { error: t(error.message) });
|
|
@@ -2161,11 +2615,35 @@ export async function createPulseServer({
|
|
|
2161
2615
|
if (request.method === 'POST' && url.pathname === '/api/salas/enlaces/recall') {
|
|
2162
2616
|
const payload = await body(request).catch(() => ({}));
|
|
2163
2617
|
if (request.headers.origin === undefined) return sendJson(response, 403, { error: t('This is changed by you, from Home.') });
|
|
2164
|
-
if (typeof payload.recall !== 'boolean') return sendJson(response, 422, { error: t('recall
|
|
2618
|
+
if (typeof payload.recall !== 'boolean') return sendJson(response, 422, { error: t('automatic recall can only be on or off.') });
|
|
2165
2619
|
const contract = await recallContract(root, String(payload.id ?? ''), payload.recall, { salas: salasRoomId(root) });
|
|
2166
2620
|
if (!contract) return sendJson(response, 404, { error: NO_SUCH_LINK });
|
|
2167
2621
|
return sendJson(response, 200, { contract });
|
|
2168
2622
|
}
|
|
2623
|
+
// D-S15: «the Coordinator may learn from this project», ticked or cleared by the human from
|
|
2624
|
+
// Home (never a phone, never a process). Cleared, Salas forgets the copy it kept of that
|
|
2625
|
+
// project at once, and the next export leaves it out.
|
|
2626
|
+
if (request.method === 'POST' && url.pathname === '/api/salas/enlaces/train') {
|
|
2627
|
+
const payload = await body(request).catch(() => ({}));
|
|
2628
|
+
if (viaPhone || request.headers.origin === undefined) return sendJson(response, 403, { error: t('This is changed by you, from Home.') });
|
|
2629
|
+
if (typeof payload.train !== 'boolean') return sendJson(response, 422, { error: t('learning can only be on or off.') });
|
|
2630
|
+
let contract;
|
|
2631
|
+
try { contract = await trainContract(root, String(payload.id ?? ''), payload.train, { salas: salasRoomId(root) }); } catch (error) { return sendJson(response, 422, { error: t(error.message) }); }
|
|
2632
|
+
if (!contract) return sendJson(response, 404, { error: NO_SUCH_LINK });
|
|
2633
|
+
if (!contract.train) await forgetLinkedCorpus(contract);
|
|
2634
|
+
return sendJson(response, 200, { contract });
|
|
2635
|
+
}
|
|
2636
|
+
// D-S16 and D-S17: «it shares its state» and «notes can be delivered to it», ticked or cleared
|
|
2637
|
+
// by you from Home (never a phone, never a process).
|
|
2638
|
+
if (request.method === 'POST' && url.pathname === '/api/salas/enlaces/allow') {
|
|
2639
|
+
const payload = await body(request).catch(() => ({}));
|
|
2640
|
+
if (viaPhone || request.headers.origin === undefined) return sendJson(response, 403, { error: t('This is changed by you, from Home.') });
|
|
2641
|
+
if (!['state', 'deliver'].includes(payload.field) || typeof payload.on !== 'boolean') return sendJson(response, 422, { error: t('That is not something a connection can allow.') });
|
|
2642
|
+
let contract;
|
|
2643
|
+
try { contract = await allowContract(root, String(payload.id ?? ''), payload.field, payload.on, { salas: salasRoomId(root) }); } catch (error) { return sendJson(response, 422, { error: t(error.message) }); }
|
|
2644
|
+
if (!contract) return sendJson(response, 404, { error: NO_SUCH_LINK });
|
|
2645
|
+
return sendJson(response, 200, { contract });
|
|
2646
|
+
}
|
|
2169
2647
|
if (request.method === 'POST' && url.pathname === '/api/salas/enlaces/revoke') {
|
|
2170
2648
|
const payload = await body(request).catch(() => ({}));
|
|
2171
2649
|
const id = String(payload.id ?? '');
|
|
@@ -2173,6 +2651,7 @@ export async function createPulseServer({
|
|
|
2173
2651
|
const already = Boolean((await readConfig(root)).enlaces?.[id]?.revoked);
|
|
2174
2652
|
const contract = await revokeContract(root, id);
|
|
2175
2653
|
if (!contract) return sendJson(response, 404, { error: NO_SUCH_LINK });
|
|
2654
|
+
await forgetLinkedCorpus(contract);
|
|
2176
2655
|
if (!already) await tellRooms('enlace.revoked', contract, await knownRooms(root)).catch(() => null);
|
|
2177
2656
|
return sendJson(response, 200, { contract });
|
|
2178
2657
|
}
|
|
@@ -2257,6 +2736,7 @@ export async function createPulseServer({
|
|
|
2257
2736
|
// wins over the config here as it does everywhere else, and reading the file again
|
|
2258
2737
|
// would hand the page a different answer from the one the server itself is using.
|
|
2259
2738
|
language: roomLanguage(),
|
|
2739
|
+
languageChosen,
|
|
2260
2740
|
// Which conversation this page is looking at, and the others it could open.
|
|
2261
2741
|
chats: await chatsView(),
|
|
2262
2742
|
agents: agents.map((agent) => ({ ...agent, login: loginPlanFor(agent), install: installPlanFor(agent), key: keyPlanFor(agent.id), ...(accountNoteFor(agent.id) ?? {}) })),
|
|
@@ -2283,7 +2763,7 @@ export async function createPulseServer({
|
|
|
2283
2763
|
capabilities: room.capabilities(),
|
|
2284
2764
|
quotaSources: quotaMonitor.snapshot(),
|
|
2285
2765
|
budgetWindow: room.budgetWindow(),
|
|
2286
|
-
events: await store.readAll(),
|
|
2766
|
+
events: forThePage(await store.readAll()),
|
|
2287
2767
|
});
|
|
2288
2768
|
}
|
|
2289
2769
|
if (request.method === 'GET' && url.pathname === '/api/events') {
|
|
@@ -2418,6 +2898,29 @@ export async function createPulseServer({
|
|
|
2418
2898
|
await room.record('project.file.saved', { path: relative, bytes: result.bytes });
|
|
2419
2899
|
return sendJson(response, 200, { ok: true, bytes: result.bytes, mtimeMs: result.mtimeMs });
|
|
2420
2900
|
}
|
|
2901
|
+
// The code workspace (1.0.3): the last time an agent changed a file in an EDIT turn, as git
|
|
2902
|
+
// kept it — the file before the turn and the file the turn left — and which parts of it the
|
|
2903
|
+
// human already kept. A turn taken back with UNDO is no longer a change.
|
|
2904
|
+
if (request.method === 'GET' && url.pathname === '/api/files/changes') {
|
|
2905
|
+
const path = String(url.searchParams.get('path') ?? '').replace(/^\.?\/+/, '');
|
|
2906
|
+
if (!path || path.split('/').includes('..')) return sendJson(response, 400, { error: t('That is not a file of this project.') });
|
|
2907
|
+
const change = await lastAgentChange(path);
|
|
2908
|
+
if (!change) return sendJson(response, 200, { change: null, reviewed: [] });
|
|
2909
|
+
const before = change.status === 'A' ? '' : await readAt(canonicalProjectRoot, { checkpointId: change.checkpointId, path });
|
|
2910
|
+
if (before === null) return sendJson(response, 200, { change: null, reviewed: [] });
|
|
2911
|
+
const after = change.afterTree ? await readAt(canonicalProjectRoot, { tree: change.afterTree, path }) : null;
|
|
2912
|
+
const reviewed = (await readReviewed())[`${change.checkpointId}|${path}`] ?? [];
|
|
2913
|
+
return sendJson(response, 200, { change: { agent: change.agent, checkpointId: change.checkpointId, at: change.at, status: change.status, before, after, messageId: change.messageId }, reviewed });
|
|
2914
|
+
}
|
|
2915
|
+
if (request.method === 'POST' && url.pathname === '/api/files/changes/review') {
|
|
2916
|
+
const payload = await body(request).catch(() => ({}));
|
|
2917
|
+
const path = String(payload.path ?? '').replace(/^\.?\/+/, '');
|
|
2918
|
+
const checkpointId = String(payload.checkpointId ?? '');
|
|
2919
|
+
const hunk = String(payload.hunk ?? '');
|
|
2920
|
+
if (!path || path.split('/').includes('..') || !/^[0-9A-Za-z][\w-]{0,79}$/.test(checkpointId) || !/^[0-9a-f]{6,16}$/.test(hunk)) return sendJson(response, 400, { error: t('That is not a change of this project.') });
|
|
2921
|
+
const reviewed = await keepReviewed(`${checkpointId}|${path}`, hunk);
|
|
2922
|
+
return sendJson(response, 200, { reviewed });
|
|
2923
|
+
}
|
|
2421
2924
|
// RIPLEY: the project rendered as a site, inside a frame that runs scripts but
|
|
2422
2925
|
// reaches nothing: an opaque origin (sandbox without same-origin), no network
|
|
2423
2926
|
// (connect-src none), no forms, no top navigation, assets only from MADRE's
|
|
@@ -2477,12 +2980,37 @@ export async function createPulseServer({
|
|
|
2477
2980
|
if ((mode !== 3 && mode !== 4) || !agents.some((one) => one.id === agent)) return sendJson(response, 400, { error: t('Unlock #3 or #4 for an agent of this project.') });
|
|
2478
2981
|
const now = Date.now();
|
|
2479
2982
|
while (keyMisses.length && keyMisses[0] < now - 60000) keyMisses.shift();
|
|
2480
|
-
if (keyMisses.length >= 8) return sendJson(response, 429, { error: t('Too many wrong
|
|
2983
|
+
if (keyMisses.length >= 8) return sendJson(response, 429, { error: t('Too many wrong confirmations. Wait a minute and try again.') });
|
|
2481
2984
|
if (!keyHolds(overrideConfig, { mode, words: payload.words, designation: basename(canonicalProjectRoot) })) {
|
|
2482
2985
|
keyMisses.push(now);
|
|
2483
2986
|
return sendJson(response, 403, { error: t('That is not it. Try again, or cancel.'), denied: true });
|
|
2484
2987
|
}
|
|
2485
|
-
return sendJson(response, 200, issuePass(agent, mode));
|
|
2988
|
+
return sendJson(response, 200, issuePass(agent, mode, Number(payload.minutes) || 0));
|
|
2989
|
+
}
|
|
2990
|
+
// The copilot: what it is now, turning it on with the #3 key for a chosen while, turning it off.
|
|
2991
|
+
if (request.method === 'GET' && url.pathname === '/api/copilot') return sendJson(response, 200, copilotState());
|
|
2992
|
+
if (request.method === 'POST' && url.pathname === '/api/copilot') {
|
|
2993
|
+
if (viaPhone) return sendJson(response, 403, { error: t('The copilot is turned on from the computer, not from the phone.') });
|
|
2994
|
+
const payload = await body(request).catch(() => ({}));
|
|
2995
|
+
const minutes = Number(payload.minutes);
|
|
2996
|
+
if (!COPILOT_WINDOWS.includes(minutes)) return sendJson(response, 400, { error: t('Choose how long the copilot lasts: up to 24 hours.') });
|
|
2997
|
+
const now = Date.now();
|
|
2998
|
+
while (keyMisses.length && keyMisses[0] < now - 60000) keyMisses.shift();
|
|
2999
|
+
if (keyMisses.length >= 8) return sendJson(response, 429, { error: t('Too many wrong confirmations. Wait a minute and try again.') });
|
|
3000
|
+
if (!keyHolds(overrideConfig, { mode: 3, words: payload.words, designation: basename(canonicalProjectRoot) })) {
|
|
3001
|
+
keyMisses.push(now);
|
|
3002
|
+
return sendJson(response, 403, { error: t('That is not it. Try again, or cancel.'), denied: true });
|
|
3003
|
+
}
|
|
3004
|
+
if (copilot) clearTimeout(copilot.timer);
|
|
3005
|
+
const until = now + minutes * 60000;
|
|
3006
|
+
copilot = { since: now, until, minutes, timer: setTimeout(() => { void endCopilot('time'); }, minutes * 60000) };
|
|
3007
|
+
copilot.timer.unref?.();
|
|
3008
|
+
await room.record('copilot.started', { until: new Date(until).toISOString(), minutes });
|
|
3009
|
+
return sendJson(response, 200, copilotState());
|
|
3010
|
+
}
|
|
3011
|
+
if (request.method === 'POST' && url.pathname === '/api/copilot/off') {
|
|
3012
|
+
await endCopilot('human');
|
|
3013
|
+
return sendJson(response, 200, copilotState());
|
|
2486
3014
|
}
|
|
2487
3015
|
// This project's switch for the local AI (src/local-ai.mjs). In «auto» Home decides and the
|
|
2488
3016
|
// switch does not move; /apply is how Home tells an open project the choice changed.
|
|
@@ -2513,7 +3041,7 @@ export async function createPulseServer({
|
|
|
2513
3041
|
const proof = patch.override.proof;
|
|
2514
3042
|
delete patch.override.proof;
|
|
2515
3043
|
if (changeNeedsPhrase(overrideConfig) && !(typeof proof === 'string' && proof.trim() && phraseHash(overrideConfig.phrase.salt, proof) === overrideConfig.phrase.hash)) {
|
|
2516
|
-
return sendJson(response, 403, { error: t('Type your current phrase to change
|
|
3044
|
+
return sendJson(response, 403, { error: t('Type your current phrase to change how modes are confirmed.'), needsPhrase: true });
|
|
2517
3045
|
}
|
|
2518
3046
|
}
|
|
2519
3047
|
return sendJson(response, 200, { settings: await applySettings(patch) });
|
|
@@ -2549,6 +3077,12 @@ export async function createPulseServer({
|
|
|
2549
3077
|
const payload = request.method === 'POST' ? await body(request).catch(() => ({})) : {};
|
|
2550
3078
|
// `viaPhone` (encargo §6, excepción 4): a module can refuse the phone by its own hand.
|
|
2551
3079
|
const result = await moduleRoute.route.handler(await moduleContext(), { request, url, params: moduleRoute.params, payload, viaPhone });
|
|
3080
|
+
// A page instead of JSON, for the one place a browser lands on a module's route on its
|
|
3081
|
+
// own: the way back from a sign-in (Connect MCP's OAuth). Never cached.
|
|
3082
|
+
if (typeof result.html === 'string') {
|
|
3083
|
+
response.writeHead(result.status ?? 200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'" });
|
|
3084
|
+
return response.end(result.html);
|
|
3085
|
+
}
|
|
2552
3086
|
return sendJson(response, result.status ?? 200, result.body ?? {});
|
|
2553
3087
|
}
|
|
2554
3088
|
// Where this room's tokens go: what each block of the briefing has cost, how much came back
|
|
@@ -2634,6 +3168,7 @@ export async function createPulseServer({
|
|
|
2634
3168
|
image: Boolean(modules['image-studio']?.enabled),
|
|
2635
3169
|
npm: updatesEnabled(),
|
|
2636
3170
|
github: updatesEnabled(),
|
|
3171
|
+
market: marketOn(),
|
|
2637
3172
|
reports: Boolean(sentinel.settings().autoReport),
|
|
2638
3173
|
anthropic: agents.some((agent) => agent.id === 'claude' && agent.ready),
|
|
2639
3174
|
ollama: Boolean(ollama.running),
|
|
@@ -2685,29 +3220,61 @@ export async function createPulseServer({
|
|
|
2685
3220
|
const notes = memory ? memory.memories({ limit: 5000 }) : [];
|
|
2686
3221
|
if (request.method === 'GET') {
|
|
2687
3222
|
const manifest = await readFile(join(dir, 'manifest.json'), 'utf8').then(JSON.parse).catch(() => null);
|
|
2688
|
-
const ready = datasetReadiness(await
|
|
3223
|
+
const ready = datasetReadiness(await projectEvents(), notes);
|
|
2689
3224
|
const research = room.memoryResearch();
|
|
2690
|
-
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() });
|
|
3225
|
+
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: await trainingInfo() });
|
|
2691
3226
|
}
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
3227
|
+
// Every conversation of the project, not only the one open: a turn answered in another
|
|
3228
|
+
// conversation is as much the project's as the one on screen.
|
|
3229
|
+
const events = await projectEvents();
|
|
3230
|
+
// D-S15: in the room of Salas, what each project that lets it has to teach the Coordinator.
|
|
3231
|
+
const linked = salasRoom ? await gatherLinked(dir) : [];
|
|
3232
|
+
const result = await exportDataset({ events, notes, dir, project: basename(canonicalProjectRoot), home: homedir(), privacy, linked });
|
|
3233
|
+
await room.record('dataset.exported', { pairs: result.pairs, turns: result.turns, notes: result.notes, train: result.train, valid: result.valid, dir, ...(salasRoom ? { linked: result.linked.map((one) => ({ room: one.room, notes: one.notes, source: one.source })) } : {}) });
|
|
2695
3234
|
const after = datasetReadiness(events, notes);
|
|
2696
3235
|
const drawn = room.memoryResearch();
|
|
2697
|
-
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() });
|
|
3236
|
+
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: await trainingInfo() });
|
|
2698
3237
|
}
|
|
2699
3238
|
// PRIVACY: the terms live in config.json only; the ledger records counts, never words.
|
|
2700
3239
|
// Which language MADRE speaks. The page keeps its own copy so the first paint needs no
|
|
2701
3240
|
// round trip; this is the durable one, shared by every browser on this machine.
|
|
2702
3241
|
// The looks every page shares (src/looks.mjs): Home and each project are different origins,
|
|
2703
3242
|
// so the browser alone kept a choice for one page only.
|
|
2704
|
-
if (request.method === 'GET' && url.pathname === '/api/looks') return sendJson(response, 200, await readLooks(root, { room: basename(roomDir) }));
|
|
3243
|
+
if (request.method === 'GET' && url.pathname === '/api/looks') return sendJson(response, 200, { ...await readLooks(root, { room: basename(roomDir) }), custom: await readWallpaper(root) });
|
|
3244
|
+
// Your own wallpaper (src/looks.mjs, 1.0.3): one picture, kept once for every page. Served as
|
|
3245
|
+
// the picture it is, never sniffed into anything else; written only from this page.
|
|
3246
|
+
if (request.method === 'GET' && url.pathname === '/api/looks/wallpaper') {
|
|
3247
|
+
const picture = await wallpaperBytes(root);
|
|
3248
|
+
if (!picture) return sendJson(response, 404, { error: t('There is no picture of yours here.') });
|
|
3249
|
+
response.writeHead(200, { 'content-type': picture.type, 'content-length': picture.bytes.length, 'cache-control': 'private, max-age=31536000, immutable', 'x-content-type-options': 'nosniff', 'content-security-policy': "default-src 'none'" });
|
|
3250
|
+
return response.end(picture.bytes);
|
|
3251
|
+
}
|
|
3252
|
+
if (request.method === 'PUT' && url.pathname === '/api/looks/wallpaper') {
|
|
3253
|
+
let bytes;
|
|
3254
|
+
try { bytes = await rawBody(request, WALLPAPER_BYTES); } catch { return sendJson(response, 413, { error: t('The picture is larger than 8 MB.') }); }
|
|
3255
|
+
let meta = null;
|
|
3256
|
+
try { meta = JSON.parse(decodeURIComponent(String(request.headers['x-madre-wallpaper'] ?? ''))); } catch { /* checked with the picture */ }
|
|
3257
|
+
const answer = await saveWallpaper(root, bytes, meta);
|
|
3258
|
+
if (answer.error) return sendJson(response, 400, { error: answer.error === 'type' ? t('Only a WebP, JPEG or PNG picture can be a wallpaper.') : t('That picture could not be kept.') });
|
|
3259
|
+
return sendJson(response, 200, answer);
|
|
3260
|
+
}
|
|
3261
|
+
if (request.method === 'POST' && url.pathname === '/api/looks/wallpaper/frame') {
|
|
3262
|
+
const answer = await frameWallpaper(root, await body(request).catch(() => null));
|
|
3263
|
+
return sendJson(response, answer.error ? 400 : 200, answer);
|
|
3264
|
+
}
|
|
3265
|
+
if (request.method === 'DELETE' && url.pathname === '/api/looks/wallpaper') return sendJson(response, 200, await dropWallpaper(root));
|
|
2705
3266
|
// This project's own wallpaper («only in this project»), or back to the shared one with null.
|
|
2706
3267
|
if (request.method === 'POST' && url.pathname === '/api/looks/room') {
|
|
2707
3268
|
const payload = await body(request).catch(() => ({}));
|
|
2708
3269
|
const answer = await changeRoomLook(root, basename(roomDir), payload?.wallpaper ?? null);
|
|
2709
3270
|
return sendJson(response, answer.error ? 400 : 200, answer);
|
|
2710
3271
|
}
|
|
3272
|
+
// How Home is arranged (src/looks.mjs, home.json): the same on every port Home is shown from.
|
|
3273
|
+
if (request.method === 'GET' && url.pathname === '/api/home-layout') return sendJson(response, 200, await readHome(root));
|
|
3274
|
+
if (request.method === 'POST' && url.pathname === '/api/home-layout') {
|
|
3275
|
+
const answer = await changeHome(root, await body(request).catch(() => null));
|
|
3276
|
+
return sendJson(response, answer.error ? 400 : 200, answer);
|
|
3277
|
+
}
|
|
2711
3278
|
if (request.method === 'POST' && url.pathname === '/api/looks') {
|
|
2712
3279
|
const answer = await changeLooks(root, await body(request).catch(() => null));
|
|
2713
3280
|
return sendJson(response, answer.error ? 400 : 200, answer);
|
|
@@ -2716,6 +3283,7 @@ export async function createPulseServer({
|
|
|
2716
3283
|
const payload = await body(request).catch(() => ({}));
|
|
2717
3284
|
const language = payload.language === 'en' ? 'en' : 'es';
|
|
2718
3285
|
await updateConfig(root, { language });
|
|
3286
|
+
languageChosen = true;
|
|
2719
3287
|
setRoomLanguage(process.env.PULSE_LANGUAGE ?? language);
|
|
2720
3288
|
return sendJson(response, 200, { language });
|
|
2721
3289
|
}
|
|
@@ -2883,7 +3451,7 @@ export async function createPulseServer({
|
|
|
2883
3451
|
const payload = await body(request).catch(() => ({}));
|
|
2884
3452
|
if (!designationOk(payload.designation)) return sendJson(response, 403, { error: t('UNABLE TO COMPUTE. UNABLE TO CLARIFY.') });
|
|
2885
3453
|
const result = await room.code000({ strikes: Number(payload.strikes) || CODE000_STRIKES });
|
|
2886
|
-
return result ? sendJson(response, 200, { code: result.code, lockedForMs: result.lockedForMs, n: result.n }) : sendJson(response, 503, { error: t('Security
|
|
3454
|
+
return result ? sendJson(response, 200, { code: result.code, lockedForMs: result.lockedForMs, n: result.n }) : sendJson(response, 503, { error: t('The Security module did not respond.') });
|
|
2887
3455
|
}
|
|
2888
3456
|
if (request.method === 'GET' && url.pathname === '/api/memory') {
|
|
2889
3457
|
if (!designationOk(url.searchParams.get('designation'))) return sendJson(response, 403, { error: t('UNABLE TO COMPUTE. UNABLE TO CLARIFY.') });
|
|
@@ -3194,10 +3762,11 @@ export async function createPulseServer({
|
|
|
3194
3762
|
return sendJson(response, result.status, result.body);
|
|
3195
3763
|
}
|
|
3196
3764
|
if (request.method === 'GET' && url.pathname === '/api/models') {
|
|
3765
|
+
// `--verbose` adds each model's metadata, its «variants» (the reasoning rungs) among it.
|
|
3197
3766
|
const listOpenCode = async (opencode) => {
|
|
3198
|
-
const
|
|
3199
|
-
await runInstaller({ command: opencode.path, args: ['models'], projectRoot: canonicalProjectRoot, timeoutMs:
|
|
3200
|
-
return
|
|
3767
|
+
const lines = [];
|
|
3768
|
+
await runInstaller({ command: opencode.path, args: ['models', '--verbose'], projectRoot: canonicalProjectRoot, timeoutMs: 30000, onLine: (line) => { lines.push(line); } });
|
|
3769
|
+
return parseOpenCodeVerbose(lines);
|
|
3201
3770
|
};
|
|
3202
3771
|
return sendJson(response, 200, { models: await discoverModels({ agents, config: await readConfig(root), listOpenCode: url.searchParams.get('opencode') === '1' ? listOpenCode : async () => [] }) });
|
|
3203
3772
|
}
|
|
@@ -3212,12 +3781,15 @@ export async function createPulseServer({
|
|
|
3212
3781
|
const hidden = new Set(openCodeHidden(config));
|
|
3213
3782
|
const signed = new Map((await readOpenCodeProviders({ home: credentialHome })).map((one) => [one.id, one.type]));
|
|
3214
3783
|
const groups = new Map(groupOpenCode(names).map((group) => [group.id, group]));
|
|
3215
|
-
|
|
3784
|
+
// Every provider a key opens, from OpenCode's own catalogue (credentials.mjs), plus the
|
|
3785
|
+
// ones already signed in or listing models.
|
|
3786
|
+
const keyable = new Map((await openCodeKeyProviders({ home: credentialHome })).map((one) => [one.id, one]));
|
|
3787
|
+
const ids = [...new Set([...keyable.keys(), ...signed.keys(), ...groups.keys()])];
|
|
3216
3788
|
const providers = ids.map((id) => {
|
|
3217
|
-
const known =
|
|
3789
|
+
const known = keyable.get(id);
|
|
3218
3790
|
const own = id === OPENCODE_OWN;
|
|
3219
3791
|
const via = own ? 'own' : signed.get(id) ?? null;
|
|
3220
|
-
return { id, label: providerLabel(id), keyUrl: known?.keyUrl ?? null, takesKey: Boolean(known), via, models: groups.get(id)?.models ?? [], hidden: hidden.has(id) };
|
|
3792
|
+
return { id, label: known?.label ?? providerLabel(id), keyUrl: known?.keyUrl ?? null, takesKey: Boolean(known), featured: Boolean(known?.featured), via, models: groups.get(id)?.models ?? [], hidden: hidden.has(id) };
|
|
3221
3793
|
}).sort((a, b) => Number(Boolean(b.via)) - Number(Boolean(a.via)) || Number(a.via === 'own') - Number(b.via === 'own') || a.label.localeCompare(b.label));
|
|
3222
3794
|
return sendJson(response, 200, { providers, default: process.env.PULSE_OPENCODE_MODEL ?? null });
|
|
3223
3795
|
}
|
|
@@ -3267,6 +3839,7 @@ export async function createPulseServer({
|
|
|
3267
3839
|
// Every conversation at work, not only the one on screen: the brake is the room's, not the chat's.
|
|
3268
3840
|
const result = await room.stopAll();
|
|
3269
3841
|
passes.clear();
|
|
3842
|
+
await endCopilot('stopall');
|
|
3270
3843
|
for (const held of parked.values()) {
|
|
3271
3844
|
const more = await held.room.stopAll().catch(() => ({ plans: 0, turns: 0 }));
|
|
3272
3845
|
result.plans += more.plans ?? 0;
|
|
@@ -3298,9 +3871,18 @@ export async function createPulseServer({
|
|
|
3298
3871
|
if (typeof payload.responseMessageId !== 'string' || !Number.isInteger(index) || index < 0) return sendJson(response, 400, { error: t('Name the request and the line to run.') });
|
|
3299
3872
|
const here = await scopeOf(url);
|
|
3300
3873
|
if (here.error) return sendJson(response, 404, here);
|
|
3301
|
-
const outcome = await here.room.runRequested({ responseMessageId: payload.responseMessageId, index });
|
|
3874
|
+
const outcome = await here.room.runRequested({ responseMessageId: payload.responseMessageId, index, returnCap: viaPhone ? PHONE_MAX_MODE : 3 });
|
|
3302
3875
|
return sendJson(response, outcome.status, outcome.ok ? outcome.result : { error: outcome.error });
|
|
3303
3876
|
}
|
|
3877
|
+
// The human ran some lines of a request and hands back what there is, without the rest.
|
|
3878
|
+
if (request.method === 'POST' && url.pathname === '/api/runs/return') {
|
|
3879
|
+
const payload = await body(request).catch(() => ({}));
|
|
3880
|
+
if (typeof payload.responseMessageId !== 'string') return sendJson(response, 400, { error: t('Say which request to hand back.') });
|
|
3881
|
+
const here = await scopeOf(url);
|
|
3882
|
+
if (here.error) return sendJson(response, 404, here);
|
|
3883
|
+
const outcome = await here.room.returnRun({ responseMessageId: payload.responseMessageId, returnCap: viaPhone ? PHONE_MAX_MODE : 3 });
|
|
3884
|
+
return sendJson(response, outcome.status, outcome.ok ? outcome : { error: outcome.error });
|
|
3885
|
+
}
|
|
3304
3886
|
if (request.method === 'GET' && url.pathname === '/api/commands') {
|
|
3305
3887
|
const ctx = await moduleContext();
|
|
3306
3888
|
const fromModules = await Promise.all(moduleCommands().map(async (command) => {
|
|
@@ -3345,6 +3927,76 @@ export async function createPulseServer({
|
|
|
3345
3927
|
void warmModuleUpdates(extensions);
|
|
3346
3928
|
return sendJson(response, 200, { installing, extensions, failures: loadFailures, folders: moduleFolders({ stateRoot: root, projectRoot: canonicalProjectRoot }), sdk: SDK_PAGE[roomLanguage()] ?? SDK_PAGE.es });
|
|
3347
3929
|
}
|
|
3930
|
+
// THE MARKET's catalog for the page: the one kept here at once (and a fresh one asked for
|
|
3931
|
+
// behind it), what was got from it, and what it updated on its own that nobody was told yet.
|
|
3932
|
+
if (request.method === 'GET' && url.pathname === '/api/market') {
|
|
3933
|
+
const kept = await keptCatalog({ stateRoot: root, ...marketKeyed });
|
|
3934
|
+
const read = kept ? (void syncMarket().catch(() => null), { catalog: kept }) : await syncMarket().catch(() => ({ catalog: null }));
|
|
3935
|
+
return sendJson(response, 200, { catalog: read.catalog ?? null, off: !marketOn(), installed: await installedFromMarket(root), news: await marketNews(root), announce: await marketAnnouncements(root, read.catalog, MODULES.map((one) => one.id)), madre: PACKAGE.version });
|
|
3936
|
+
}
|
|
3937
|
+
if (request.method === 'POST' && url.pathname === '/api/market/seen') {
|
|
3938
|
+
await marketNewsSeen(root);
|
|
3939
|
+
return sendJson(response, 200, { ok: true });
|
|
3940
|
+
}
|
|
3941
|
+
// The Market's news a page has just told: not again, on this computer.
|
|
3942
|
+
if (request.method === 'POST' && url.pathname === '/api/market/announced') {
|
|
3943
|
+
const payload = await body(request).catch(() => ({}));
|
|
3944
|
+
await marketAnnounced(root, Array.isArray(payload.keys) ? payload.keys.slice(0, 50) : []);
|
|
3945
|
+
return sendJson(response, 200, { ok: true });
|
|
3946
|
+
}
|
|
3947
|
+
// A module from the Market onto this computer: its signed file, checked like any module, for
|
|
3948
|
+
// every project; then every project open right now loads it, and each one turns it on.
|
|
3949
|
+
if (request.method === 'POST' && url.pathname === '/api/market/install') {
|
|
3950
|
+
if (viaPhone) return sendJson(response, 403, { error: t('This is done on the Mac.') });
|
|
3951
|
+
const payload = await body(request).catch(() => ({}));
|
|
3952
|
+
const catalog = (await keptCatalog({ stateRoot: root, ...marketKeyed })) ?? (await syncMarket({ force: true }).catch(() => ({}))).catalog;
|
|
3953
|
+
if (!catalog) return sendJson(response, 503, { error: t('MADRE could not reach the Market. Check your internet connection and try again.') });
|
|
3954
|
+
try {
|
|
3955
|
+
const done = await installFromMarket({ ...marketTools(), id: String(payload.id ?? ''), catalog });
|
|
3956
|
+
await room.record('extension.installed', { id: done.id, name: done.name, origin: 'market', version: done.version, by: 'you' });
|
|
3957
|
+
for (const one of await knownRooms(root)) {
|
|
3958
|
+
if (!one.open?.port || one.id === thisRoomId) continue;
|
|
3959
|
+
await fetch(`http://127.0.0.1:${one.open.port}/api/extensions/reload`, { method: 'POST' }).catch(() => null);
|
|
3960
|
+
}
|
|
3961
|
+
return sendJson(response, 200, { installed: done, extensions: await describeModules(await moduleContext()) });
|
|
3962
|
+
} catch (error) { return sendJson(response, 422, { error: error.message }); }
|
|
3963
|
+
}
|
|
3964
|
+
// Home's view of which projects each module is on in (module-scope.mjs), and where the
|
|
3965
|
+
// modules that install into a project's folder are installed.
|
|
3966
|
+
if (request.method === 'GET' && url.pathname === '/api/modules/scope') {
|
|
3967
|
+
const config = await readConfig(root);
|
|
3968
|
+
const home = salasRoomId(root);
|
|
3969
|
+
const projects = (await knownRooms(root)).filter((one) => one.id !== home && one.root).map((one) => ({ id: one.id, name: one.name, open: Boolean(one.open) }));
|
|
3970
|
+
const roots = new Map((await knownRooms(root)).map((one) => [one.id, one.root]));
|
|
3971
|
+
const ids = projects.map((one) => one.id);
|
|
3972
|
+
const modules = [];
|
|
3973
|
+
for (const module of MODULES) {
|
|
3974
|
+
if (module.scope && !NOT_BY_PROJECT.has(module.id)) modules.push({ id: module.id, switch: true, ...scopeView(config.modules?.[module.configKey] ?? {}, Boolean(module.defaults?.enabled), ids) });
|
|
3975
|
+
else if (module.kind === 'installer' && module.detect) {
|
|
3976
|
+
const on = [];
|
|
3977
|
+
for (const id of ids) if ((await module.detect(roots.get(id)).catch(() => null))?.installed) on.push(id);
|
|
3978
|
+
modules.push({ id: module.id, switch: false, on, everywhere: false });
|
|
3979
|
+
}
|
|
3980
|
+
}
|
|
3981
|
+
return sendJson(response, 200, { projects, modules });
|
|
3982
|
+
}
|
|
3983
|
+
// The keys modules put beside MADRE's version (sdk.mjs, `footer`): only those on here.
|
|
3984
|
+
if (request.method === 'GET' && url.pathname === '/api/modules/footer') {
|
|
3985
|
+
const context = await moduleContext();
|
|
3986
|
+
const keys = MODULES.filter((module) => module.footer?.length && module.settingsFrom?.(context.config, context.roomId)?.enabled).map((module) => ({ id: module.id, name: module.name, footer: module.footer }));
|
|
3987
|
+
return sendJson(response, 200, { keys });
|
|
3988
|
+
}
|
|
3989
|
+
// Home's word: every project (`all`), or some (`rooms`). A project's own MODULES moves only its word.
|
|
3990
|
+
const scopeMatch = request.method === 'POST' && url.pathname.match(/^\/api\/extensions\/([a-z0-9-]+)\/scope$/);
|
|
3991
|
+
if (scopeMatch) {
|
|
3992
|
+
if (viaPhone) return sendJson(response, 403, { error: t('This is done on the Mac.') });
|
|
3993
|
+
const module = moduleById(scopeMatch[1]);
|
|
3994
|
+
if (!module?.scope || NOT_BY_PROJECT.has(module.id)) return sendJson(response, 404, { error: t('That module is not turned on by project.') });
|
|
3995
|
+
const payload = await body(request).catch(() => ({}));
|
|
3996
|
+
const result = await module.scope(await moduleContext(), { all: payload.all, rooms: payload.rooms, confirm: payload.confirm });
|
|
3997
|
+
for (const held of parked.values()) await applyRoomSettings(held.room, null);
|
|
3998
|
+
return sendJson(response, result.status, result.body);
|
|
3999
|
+
}
|
|
3348
4000
|
// The human installs a module file an agent wrote (or they did): checked first, then copied into the chosen folder.
|
|
3349
4001
|
if (request.method === 'POST' && url.pathname === '/api/extensions/install-file') {
|
|
3350
4002
|
const payload = await body(request).catch(() => ({}));
|
|
@@ -3360,6 +4012,7 @@ export async function createPulseServer({
|
|
|
3360
4012
|
if (removeMatch) {
|
|
3361
4013
|
try {
|
|
3362
4014
|
const removed = await removeExternalModule({ id: removeMatch[1], stateRoot: root, projectRoot: canonicalProjectRoot });
|
|
4015
|
+
await forgetFromMarket(root, removed.id).catch(() => false);
|
|
3363
4016
|
await room.record('extension.removed', { id: removed.id, name: removed.name, by: 'you' });
|
|
3364
4017
|
return sendJson(response, 200, { removed, extensions: await describeModules(await moduleContext()) });
|
|
3365
4018
|
} catch (error) { return sendJson(response, moduleById(removeMatch[1]) ? 403 : 404, { error: error.message }); }
|
|
@@ -3389,6 +4042,19 @@ export async function createPulseServer({
|
|
|
3389
4042
|
const result = await module.setControl(await moduleContext(), await body(request));
|
|
3390
4043
|
return sendJson(response, result.status, result.body);
|
|
3391
4044
|
}
|
|
4045
|
+
// What a module offers to put right before it can install (Continuity on a Mac with no git:
|
|
4046
|
+
// Apple's installer for its command line tools). Asked from this Mac only; the command is the
|
|
4047
|
+
// module's own, never one sent by the page.
|
|
4048
|
+
const fixMatch = request.method === 'POST' && url.pathname.match(/^\/api\/extensions\/([a-z0-9-]+)\/fix$/);
|
|
4049
|
+
if (fixMatch) {
|
|
4050
|
+
if (viaPhone) return sendJson(response, 403, { error: t('This is done on the Mac.') });
|
|
4051
|
+
const extension = extensionById(fixMatch[1]);
|
|
4052
|
+
const checked = extension?.preflight ? await extension.preflight(canonicalProjectRoot) : null;
|
|
4053
|
+
if (!checked?.fix) return sendJson(response, 409, { error: t('There is nothing to set up for this module.') });
|
|
4054
|
+
const ran = await runInstaller({ command: checked.fix.command, args: checked.fix.args, projectRoot: canonicalProjectRoot, timeoutMs: 60000 });
|
|
4055
|
+
await room.record('extension.fix.started', { id: fixMatch[1], command: checked.fix.display, code: ran.code });
|
|
4056
|
+
return sendJson(response, ran.code === 0 ? 202 : 500, ran.code === 0 ? { started: true, command: checked.fix.display } : { error: ran.error ?? t('It could not be started.') });
|
|
4057
|
+
}
|
|
3392
4058
|
const installMatch = request.method === 'POST' && url.pathname.match(/^\/api\/extensions\/([a-z0-9-]+)\/install$/);
|
|
3393
4059
|
if (installMatch) {
|
|
3394
4060
|
// A phone switches MADRE's own modules; installing one runs commands in the project.
|
|
@@ -3412,7 +4078,7 @@ export async function createPulseServer({
|
|
|
3412
4078
|
if (!asked) return sendJson(response, 409, { error: t('That request is no longer pending.') });
|
|
3413
4079
|
if (decision !== 'deny' && asked.mode > PHONE_MAX_MODE) {
|
|
3414
4080
|
if (viaPhone) return sendJson(response, 403, { error: t('#3 EDIT and #4 FULL ACCESS are opened from the Mac.') });
|
|
3415
|
-
if (!spendPass(key, asked.agent, asked.mode)) return sendJson(response, 403, { error: t('#{n} needs
|
|
4081
|
+
if (!spendPass(key, asked.agent, asked.mode)) return sendJson(response, 403, { error: t('#{n} needs your confirmation. Unlock it again and send.', { n: asked.mode }), needsKey: true });
|
|
3416
4082
|
}
|
|
3417
4083
|
const result = here.room.decideMode(requestId, decision);
|
|
3418
4084
|
return sendJson(response, result.ok ? 200 : 409, result);
|
|
@@ -3431,13 +4097,23 @@ export async function createPulseServer({
|
|
|
3431
4097
|
// like any other: the conversation on screen is one of the "others" when it is working.
|
|
3432
4098
|
const here = await scopeOf(url);
|
|
3433
4099
|
if (here.error) return sendJson(response, 404, here);
|
|
4100
|
+
// A switch moved in Home since this project opened reaches it here (module-scope.mjs).
|
|
4101
|
+
applyModuleSwitches(here.room, await readConfig(root));
|
|
3434
4102
|
const room = here.room;
|
|
4103
|
+
// A copilot message at #3 goes with the copilot's window instead of a pass; from this
|
|
4104
|
+
// computer only. With the window closed it is said, and the page asks for the key again.
|
|
4105
|
+
if (payload.copilot === true && (viaPhone || !copilotNow())) {
|
|
4106
|
+
if (!viaPhone) return sendJson(response, 409, { error: t('The copilot is off: it ended or was stopped. Turn it on again, or send and confirm #3.'), copilotOff: true });
|
|
4107
|
+
delete payload.copilot;
|
|
4108
|
+
}
|
|
3435
4109
|
// Modes are checked before the turn is accepted, so the composer
|
|
3436
4110
|
// hears "no" with a reason instead of a silent log line.
|
|
3437
4111
|
const gate = await room.modeCheck(payload);
|
|
3438
4112
|
if (!gate.ok) return sendJson(response, gate.status ?? 403, { error: gate.error, mode: gate.mode, maxMode: gate.maxMode });
|
|
4113
|
+
const byCopilot = payload.copilot === true && gate.mode === 3;
|
|
4114
|
+
if (!byCopilot) delete payload.copilot;
|
|
3439
4115
|
// #3 and #4 go with the pass their key gave, for this agent (above).
|
|
3440
|
-
if (gate.mode >= 3 && !spendPass(payload.key, gate.agent, gate.mode)) return sendJson(response, 403, { error: t('#{n} needs
|
|
4116
|
+
if (gate.mode >= 3 && !byCopilot && !spendPass(payload.key, gate.agent, gate.mode)) return sendJson(response, 403, { error: t('#{n} needs your confirmation. Unlock it again and send.', { n: gate.mode }), mode: gate.mode, needsKey: true });
|
|
3441
4117
|
delete payload.key;
|
|
3442
4118
|
const turns = await turnsPolicy();
|
|
3443
4119
|
if (turns.enabled) {
|