@jossuealcala/madre 1.0.0 → 1.0.2
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 +1945 -0
- package/CHANGELOG.md +78 -1
- package/CONTRIBUTING.md +5 -1
- package/LICENSE +661 -202
- package/NOTICE +34 -3
- package/README.en.md +245 -0
- package/README.md +23 -13
- package/TRADEMARKS.en.md +50 -0
- package/TRADEMARKS.md +82 -0
- package/bin/madre.mjs +2 -0
- package/docs/INTERNALS.md +8 -2
- package/docs/REFERENCE.md +17 -7
- package/docs/en/INTERNALS.md +103 -0
- package/docs/en/REFERENCE.md +339 -0
- package/docs/en/SDK.md +306 -0
- package/docs/en/training/README.md +59 -0
- package/docs/madre-banner.png +0 -0
- package/docs/training/README.md +20 -8
- package/docs/training/train.sh +2 -1
- package/package.json +14 -5
- package/public/abduction.js +2 -0
- package/public/app.js +2066 -488
- package/public/attribution.js +71 -0
- package/public/brands.js +3 -1
- package/public/editor.js +2 -0
- package/public/es.js +1706 -1226
- package/public/i18n.js +25 -1
- package/public/index.html +13 -10
- package/public/inquiry.js +2 -0
- package/public/loader.js +2 -0
- package/public/macroverso.js +24 -2
- package/public/nostromo-engine.js +2 -0
- package/public/resay.js +6 -0
- package/public/review-ask.js +2 -0
- package/public/routing.js +23 -1
- package/public/salas-mac.css +539 -28
- package/public/salas-mac.html +30 -22
- package/public/salas-mac.js +1498 -144
- package/public/salas.css +2 -0
- package/public/salas.html +1 -0
- package/public/salas.js +4 -2
- package/public/styles.css +1123 -20
- package/public/syntax.js +2 -0
- package/public/touch.js +2 -0
- package/public/tour-chapters.js +164 -52
- package/public/tour.js +85 -12
- package/public/troubleshooting.js +20 -18
- package/src/adapters/claude.mjs +2 -0
- package/src/adapters/codex.mjs +2 -0
- package/src/adapters/gemini.mjs +3 -1
- package/src/adapters/madre.mjs +3 -1
- 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 +32 -7
- 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 +2 -0
- package/src/chats.mjs +2 -0
- package/src/checkpoint.mjs +2 -0
- 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 +112 -3
- package/src/dataset.mjs +2 -0
- package/src/directives.mjs +8 -6
- package/src/distiller.mjs +2 -0
- package/src/embeddings.mjs +2 -0
- package/src/enlaces.mjs +6 -4
- package/src/event-store.mjs +2 -0
- package/src/exam.mjs +6 -4
- package/src/extensions.mjs +33 -10
- 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 +57 -0
- package/src/i18n.mjs +15 -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 +50 -0
- package/src/looks.mjs +40 -5
- 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 +83 -3
- package/src/modules/ahp.mjs +2 -0
- package/src/modules/ash-policy.mjs +2 -0
- package/src/modules/ash.mjs +3 -1
- package/src/modules/bishop-plan.mjs +5 -3
- package/src/modules/bishop.mjs +13 -11
- package/src/modules/choices.mjs +2 -0
- package/src/modules/correo.mjs +2 -0
- package/src/modules/enlaces.mjs +2 -0
- 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 +3 -1
- 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 +8 -6
- package/src/modules/mcp-connect.mjs +293 -0
- package/src/modules/ollama.mjs +17 -3
- package/src/modules/playwright.mjs +25 -10
- package/src/modules/ripley.mjs +2 -0
- package/src/modules/runs.mjs +5 -3
- package/src/modules/sdk.mjs +2 -0
- package/src/modules/updates.mjs +2 -0
- package/src/mother.mjs +2 -0
- package/src/ollama.mjs +2 -0
- package/src/outbound.mjs +24 -5
- package/src/override.mjs +2 -0
- package/src/ports.mjs +2 -0
- 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 +3 -0
- package/src/room/ghost.mjs +2 -0
- package/src/room/guard.mjs +2 -0
- package/src/room/prompt.mjs +13 -4
- 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 +264 -45
- package/src/router.mjs +2 -0
- package/src/runs.mjs +2 -0
- package/src/runtime-detection.mjs +2 -0
- package/src/salas.mjs +2 -0
- package/src/sentinel-errors.mjs +2 -0
- package/src/server-sentinel.mjs +2 -0
- package/src/server.mjs +485 -55
- package/src/setup.mjs +2 -0
- package/src/smtp.mjs +2 -0
- package/src/tour.mjs +26 -9
- 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 +12 -10
- package/docs/madre-banner.svg +0 -25
package/src/server.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
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
4
|
import { readFile, realpath, writeFile, mkdir, cp, rename, access, stat } from 'node:fs/promises';
|
|
3
5
|
import { existsSync, openSync, rmSync } from 'node:fs';
|
|
@@ -6,6 +8,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';
|
|
11
|
+
import { gzipSync } from 'node:zlib';
|
|
9
12
|
import { homedir, hostname } from 'node:os';
|
|
10
13
|
import { execFile } from 'node:child_process';
|
|
11
14
|
import { promisify } from 'node:util';
|
|
@@ -33,6 +36,9 @@ const PACKAGE = JSON.parse(await readFile(new URL('../package.json', import.meta
|
|
|
33
36
|
// run from its source, where the repository's `.git` sits beside `src/`. What npm installs never
|
|
34
37
|
// carries a `.git` (`files` in package.json), so there no setting and no variable opens it.
|
|
35
38
|
const FROM_SOURCE = existsSync(new URL('../.git', import.meta.url));
|
|
39
|
+
// Where the author's token for the usage widget is kept: a vault name no module can take (module
|
|
40
|
+
// ids are kebab-case, src/modules/sdk.mjs), so no module ever reads it.
|
|
41
|
+
export const OWNER_VAULT = 'madre_owner';
|
|
36
42
|
let crashHandlersInstalled = false;
|
|
37
43
|
|
|
38
44
|
// RIPLEY's bridge, injected into every page it renders: reports the page's path
|
|
@@ -57,11 +63,12 @@ export function injectRipleyBridge(body) {
|
|
|
57
63
|
import { QuotaMonitor } from './quota-monitor.mjs';
|
|
58
64
|
import { defaultQuotaSources } from './quota-sources.mjs';
|
|
59
65
|
import { Room } from './room.mjs';
|
|
60
|
-
import {
|
|
66
|
+
import { COPILOT_WINDOWS } from './room/copilot.mjs';
|
|
67
|
+
import { OVERRIDE_GRACE, changeNeedsPhrase, keyHolds, mergeOverride, overrideSettings, phraseHash, publicOverride } from './override.mjs';
|
|
61
68
|
import { extensionById, findOnPath, runInstaller } from './extensions.mjs';
|
|
62
|
-
import { accountNoteFor, installPlanFor, looksLikeAdminProblem, loginPlanFor, probeAgentAuth, probeAll, TAKES_KEY } from './auth-probe.mjs';
|
|
63
|
-
import { applyKey, keyPlanFor } from './credentials.mjs';
|
|
64
|
-
import { forgetSecret, summary as vaultSummary, writeSecret } from './vault.mjs';
|
|
69
|
+
import { accountNoteFor, installPlanFor, looksLikeAdminProblem, loginPlanFor, looksLikeCodePrompt, probeAgentAuth, probeAll, TAKES_KEY } from './auth-probe.mjs';
|
|
70
|
+
import { applyKey, keyPlanFor, openCodeKeyProviders, readOpenCodeProviders, removeOpenCodeKey } from './credentials.mjs';
|
|
71
|
+
import { forgetSecret, readSecret, summary as vaultSummary, writeSecret } from './vault.mjs';
|
|
65
72
|
import { applyConfigToEnv, loadConfig as readConfig, updateConfig } from './config.mjs';
|
|
66
73
|
import { Privacy, normalizeTerms, privacySettings } from './privacy.mjs';
|
|
67
74
|
import { checkForUpdate, detectInstall, updateCommand, releaseUrl, applyCommand, fetchCommand } from './updates.mjs';
|
|
@@ -74,8 +81,8 @@ const SDK_PAGE = { es: 'https://madre.run/sdk/', en: 'https://madre.run/en/sdk/'
|
|
|
74
81
|
import { moduleUpdate } from './modules/updates.mjs';
|
|
75
82
|
import { adoptStrays, chatIndex, chatLedger, createChat, deleteChat, isChatId, listChats, openChat as openChatIndex, projectFloor, renameChat, touchChat } from './chats.mjs';
|
|
76
83
|
import { spawn } from 'node:child_process';
|
|
77
|
-
import { totalmem } from 'node:os';
|
|
78
|
-
import { discoverModels } from './models.mjs';
|
|
84
|
+
import { cpus, freemem, loadavg, totalmem } from 'node:os';
|
|
85
|
+
import { discoverModels, groupOpenCode, openCodeHidden, parseOpenCodeVerbose, providerLabel, OPENCODE_OWN } from './models.mjs';
|
|
79
86
|
import { observable } from './modules/intents-observe.mjs';
|
|
80
87
|
import { normalizeMode, setImageModule } from './capabilities.mjs';
|
|
81
88
|
import { resolveGeminiKey } from './image-studio.mjs';
|
|
@@ -97,7 +104,9 @@ import { currentNetwork, lanAddresses } from './bridge/network.mjs';
|
|
|
97
104
|
import { PHONE_MAX_MODE, writtenMode } from './bridge/policy.mjs';
|
|
98
105
|
import { qrSvg } from './bridge/qr.mjs';
|
|
99
106
|
import { readHandoff, writeHandoff } from './bridge/handoff.mjs';
|
|
100
|
-
import { changeLooks, readLooks } from './looks.mjs';
|
|
107
|
+
import { changeLooks, readLooks, changeRoomLook, roomLooks } from './looks.mjs';
|
|
108
|
+
import { localAiConfig, localAiFor, localAiPatch } from './local-ai.mjs';
|
|
109
|
+
import { heartbeat } from './heartbeat.mjs';
|
|
101
110
|
import { askRoom, associateProject, ensureSalasProject, forgetProject, isSalasRoom, knownRooms, listFolders, markProject, projectRoomId, roomComesUp, salasRoomId, startRoom, suggestProjects, universeOf } from './salas.mjs';
|
|
102
111
|
import { parseWork, recentWork, WORK_SYSTEM, workPrompt } from './lobby-work.mjs';
|
|
103
112
|
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';
|
|
@@ -105,6 +114,41 @@ import { CHANNEL_HEADER, MAX_LIMIT as ENLACE_MAX_LIMIT, NO_SUCH_LINK, NOTICES as
|
|
|
105
114
|
const sourceDirectory = dirname(fileURLToPath(import.meta.url));
|
|
106
115
|
const publicDirectory = join(sourceDirectory, '..', 'public');
|
|
107
116
|
|
|
117
|
+
// The page's own scripts and styles (2026-10-15): an ETag from the file's size and time, so a
|
|
118
|
+
// reload that finds them unchanged gets «304» and the browser reuses its copy — and the code it
|
|
119
|
+
// already compiled — instead of reading 2.5 MB again; and gzip for whoever asks, compressed once per
|
|
120
|
+
// change of the file. `no-cache` still revalidates every time, so an edit shows on the next load.
|
|
121
|
+
// Only the compressed copy is kept in memory (~0.6 MB for all of them); the plain file is read when
|
|
122
|
+
// a client does not take gzip.
|
|
123
|
+
const assetCache = new Map();
|
|
124
|
+
async function sendAsset(request, response, file, type) {
|
|
125
|
+
const info = await stat(file);
|
|
126
|
+
const tag = `W/"${info.size.toString(36)}-${Math.floor(info.mtimeMs).toString(36)}"`;
|
|
127
|
+
const headers = { 'content-type': type, 'cache-control': 'no-cache', etag: tag, vary: 'accept-encoding' };
|
|
128
|
+
if (request.headers['if-none-match'] === tag) { response.writeHead(304, headers); return response.end(); }
|
|
129
|
+
const gzip = /\bgzip\b/.test(String(request.headers['accept-encoding'] ?? '')) && info.size > 8192;
|
|
130
|
+
if (gzip) {
|
|
131
|
+
let held = assetCache.get(file);
|
|
132
|
+
if (held?.tag !== tag) { held = { tag, body: gzipSync(await readFile(file), { level: 6 }) }; assetCache.set(file, held); }
|
|
133
|
+
response.writeHead(200, { ...headers, 'content-encoding': 'gzip', 'content-length': held.body.length });
|
|
134
|
+
return response.end(held.body);
|
|
135
|
+
}
|
|
136
|
+
const body = await readFile(file);
|
|
137
|
+
response.writeHead(200, { ...headers, 'content-length': body.length });
|
|
138
|
+
return response.end(body);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// The record as a page opening it needs it (2026-10-15). `quota.updated` is how full each agent's
|
|
142
|
+
// window is, read again every few minutes; the page keeps only the last reading of each agent, so
|
|
143
|
+
// every older one was downloaded, parsed and drawn for nothing — 87% of a long conversation's
|
|
144
|
+
// record, and seconds of drawing. The ledger keeps them all; the page gets the newest of each.
|
|
145
|
+
export function forThePage(events) {
|
|
146
|
+
const lastQuota = new Map();
|
|
147
|
+
for (const event of events) if (event?.type === 'quota.updated') lastQuota.set(event.payload?.agent ?? null, event);
|
|
148
|
+
const keep = new Set(lastQuota.values());
|
|
149
|
+
return events.filter((event) => event?.type !== 'quota.updated' || keep.has(event));
|
|
150
|
+
}
|
|
151
|
+
|
|
108
152
|
function sendJson(response, status, value) {
|
|
109
153
|
response.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
|
|
110
154
|
response.end(JSON.stringify(value));
|
|
@@ -202,6 +246,9 @@ export async function createPulseServer({
|
|
|
202
246
|
// What language the screens speak. The prompt the agents read is not one of them, and the
|
|
203
247
|
// environment wins over the file, as it does for every other setting here.
|
|
204
248
|
setRoomLanguage(process.env.PULSE_LANGUAGE ?? (await readConfig(root)).language);
|
|
249
|
+
// Whether anyone set it: a MADRE nobody has set takes the language of the first browser that
|
|
250
|
+
// opens it (public/i18n.js, browserLanguage), once, and keeps it as if it had been chosen.
|
|
251
|
+
let languageChosen = Boolean(process.env.PULSE_LANGUAGE || (await readConfig(root)).language);
|
|
205
252
|
// RIPLEY renders HTML in the viewer only while the human has it switched on; read live, the switch is the module's.
|
|
206
253
|
const ripleyOn = async () => Boolean((await readConfig(root)).modules?.ripley?.enabled);
|
|
207
254
|
agentTimeouts ??= agentTimeoutsFromEnv();
|
|
@@ -357,6 +404,120 @@ export async function createPulseServer({
|
|
|
357
404
|
// rooms, asked of them at most every few minutes (each room works its zones out itself).
|
|
358
405
|
const lobbyWork = { file: join(roomDir, 'lobby-work.json'), loaded: false, key: null, at: 0, tried: 0, bullets: null, model: null, error: null, running: null };
|
|
359
406
|
const lobbyDecisions = { at: 0, list: null, running: null };
|
|
407
|
+
// A busy agent can delay a room's glance without closing the room. Keep the last confirmed
|
|
408
|
+
// reading for a short time, and distinguish a slow answer from a stopped process.
|
|
409
|
+
const lobbyGlances = new Map();
|
|
410
|
+
// HOME'S WIDGETS (2026-10-13): what each one shows comes from something that already exists —
|
|
411
|
+
// the machine (node:os), each open project's servers sentinel (/api/ports) and dataset
|
|
412
|
+
// (/api/dataset: maturity, pairs, the model trained on it), this room's own dataset for the
|
|
413
|
+
// Coordinator, and Ollama as this room probed it. A reading is kept 30 s, so a page that asks
|
|
414
|
+
// every few seconds never makes the projects work for it.
|
|
415
|
+
const widgetReadings = new Map();
|
|
416
|
+
let cpuSeen = null;
|
|
417
|
+
const cpuTimes = () => cpus().reduce((sum, one) => {
|
|
418
|
+
const times = one.times;
|
|
419
|
+
sum.idle += times.idle;
|
|
420
|
+
sum.total += times.user + times.nice + times.sys + times.idle + times.irq;
|
|
421
|
+
return sum;
|
|
422
|
+
}, { idle: 0, total: 0 });
|
|
423
|
+
async function cpuBusy() {
|
|
424
|
+
let before = cpuSeen;
|
|
425
|
+
if (!before || Date.now() - before.at > 60000) { before = { ...cpuTimes(), at: Date.now() }; await new Promise((done) => setTimeout(done, 300)); }
|
|
426
|
+
const now = { ...cpuTimes(), at: Date.now() };
|
|
427
|
+
cpuSeen = now;
|
|
428
|
+
const total = now.total - before.total;
|
|
429
|
+
return total > 0 ? Math.max(0, Math.min(1, 1 - (now.idle - before.idle) / total)) : null;
|
|
430
|
+
}
|
|
431
|
+
const readinessOf = (answer) => (answer && !answer.unreachable && !answer.outdated ? {
|
|
432
|
+
pairs: answer.readiness?.pairs ?? 0, target: answer.readiness?.target ?? 300, aberrations: answer.readiness?.aberrations ?? 0,
|
|
433
|
+
good: answer.readiness?.good ?? 0, bad: answer.readiness?.bad ?? 0, trained: answer.trained ?? null,
|
|
434
|
+
score: answer.maturity?.score ?? null, stage: answer.maturity?.stage?.label ?? null, says: answer.maturity?.stage?.says ?? null,
|
|
435
|
+
weakest: (() => { const one = (answer.maturity?.signals ?? []).find((signal) => signal.id === answer.maturity?.weakest); return one ? { label: one.label ?? null, next: one.next ?? null } : null; })(),
|
|
436
|
+
signals: (answer.maturity?.signals ?? []).map((one) => ({ id: one.id, label: one.label, value: one.value })),
|
|
437
|
+
} : null);
|
|
438
|
+
// Each project with whether it uses the local AI, for the widget that shows Ollama (src/local-ai.mjs).
|
|
439
|
+
const withLocalAi = async (projects) => { const config = await readConfig(root); return projects.map((one) => ({ ...one, localAi: localAiFor(config, one.id).on })); };
|
|
440
|
+
async function openProjects() {
|
|
441
|
+
const salas = salasRoomId(root);
|
|
442
|
+
return (await knownRooms(root)).filter((one) => one.open && one.id !== salas);
|
|
443
|
+
}
|
|
444
|
+
// Memory a program could have now. os.freemem() counts only pages nobody holds; macOS keeps
|
|
445
|
+
// most of the rest as cache it gives back at once, so «free» read 0.1 GB of 16 on an idle Mac.
|
|
446
|
+
// The system's own count: free, inactive, speculative and purgeable pages on macOS (vm_stat),
|
|
447
|
+
// MemAvailable on Linux; freemem() where neither answers.
|
|
448
|
+
async function memoryAvailable() {
|
|
449
|
+
try {
|
|
450
|
+
if (process.platform === 'darwin') {
|
|
451
|
+
const { stdout } = await execFileAsync('vm_stat', [], { timeout: 2000 });
|
|
452
|
+
const page = Number(stdout.match(/page size of (\d+) bytes/)?.[1] ?? 0);
|
|
453
|
+
const pages = (name) => Number(stdout.match(new RegExp(`Pages ${name}:\\s+(\\d+)`))?.[1] ?? 0);
|
|
454
|
+
if (page) return { bytes: (pages('free') + pages('inactive') + pages('speculative') + pages('purgeable')) * page, source: 'vm_stat' };
|
|
455
|
+
}
|
|
456
|
+
if (process.platform === 'linux') {
|
|
457
|
+
const kb = Number((await readFile('/proc/meminfo', 'utf8')).match(/^MemAvailable:\s+(\d+)/m)?.[1] ?? 0);
|
|
458
|
+
if (kb) return { bytes: kb * 1024, source: 'meminfo' };
|
|
459
|
+
}
|
|
460
|
+
} catch { /* the plain count below */ }
|
|
461
|
+
return { bytes: freemem(), source: 'freemem' };
|
|
462
|
+
}
|
|
463
|
+
async function homeWidget(kind) {
|
|
464
|
+
const kept = widgetReadings.get(kind);
|
|
465
|
+
// madre.run's count moves once a day: the author's reading is kept ten minutes, the Mac's thirty seconds.
|
|
466
|
+
if (kept && Date.now() - kept.at < (kind === 'usage' ? 600000 : 30000)) return kept.reading;
|
|
467
|
+
let reading;
|
|
468
|
+
if (kind === 'system') {
|
|
469
|
+
const total = totalmem();
|
|
470
|
+
const free = await memoryAvailable();
|
|
471
|
+
reading = { available: true, cpu: { busy: await cpuBusy(), cores: cpus().length, load: loadavg().map((one) => Math.round(one * 100) / 100) }, memory: { total, free: Math.min(total, free.bytes), source: free.source } };
|
|
472
|
+
} else if (kind === 'servers') {
|
|
473
|
+
const rooms = await openProjects();
|
|
474
|
+
const projects = await Promise.all(rooms.map(async (one) => {
|
|
475
|
+
const answer = await askRoom(one.open.port, '/api/ports', { timeoutMs: 4000 });
|
|
476
|
+
if (answer.unreachable && answer.status === 412) return { id: one.id, name: one.name, preview: false, ports: [] };
|
|
477
|
+
if (answer.unreachable || answer.outdated) return { id: one.id, name: one.name, unreachable: true, ports: [] };
|
|
478
|
+
return { id: one.id, name: one.name, preview: true, ports: (answer.ports ?? []).map((port) => ({ port: port.port, status: port.status ?? null, command: port.command ?? null })) };
|
|
479
|
+
}));
|
|
480
|
+
reading = { available: projects.some((one) => one.preview), projects };
|
|
481
|
+
} else if (kind === 'memory' || kind === 'models') {
|
|
482
|
+
const rooms = await openProjects();
|
|
483
|
+
const projects = await Promise.all(rooms.map(async (one) => ({ id: one.id, name: one.name, ...(readinessOf(await askRoom(one.open.port, '/api/dataset', { timeoutMs: 6000 })) ?? { unreachable: true }) })));
|
|
484
|
+
reading = kind === 'memory'
|
|
485
|
+
? { available: projects.some((one) => !one.unreachable), projects }
|
|
486
|
+
: { available: true, ollama: { running: Boolean(ollama.running), models: (ollama.models ?? []).map((model) => model.name).filter(Boolean) }, projects: await withLocalAi(projects) };
|
|
487
|
+
} else if (kind === 'usage') {
|
|
488
|
+
// The author's own reading of how many copies are on (src/heartbeat.mjs, madre.run's
|
|
489
|
+
// /api/usage). It exists only in a copy run from source that holds the author's token
|
|
490
|
+
// (scripts/owner-usage.mjs); anywhere else there is no such widget.
|
|
491
|
+
const token = FROM_SOURCE ? await readSecret(root, OWNER_VAULT, 'usage') : null;
|
|
492
|
+
if (!token) return null;
|
|
493
|
+
const answer = await reportFetch(process.env.PULSE_USAGE_URL ?? 'https://madre.run/api/usage', { headers: { authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(8000) }).catch(() => null);
|
|
494
|
+
const body = answer?.ok ? await answer.json().catch(() => null) : null;
|
|
495
|
+
reading = body?.ok ? { available: true, ...body } : { available: false, status: answer?.status ?? 0 };
|
|
496
|
+
} else if (kind === 'coordinator' && !salasRoom) {
|
|
497
|
+
// Home's page is served by every project too: the Coordinator's record is its own room's.
|
|
498
|
+
const coordinator = (await knownRooms(root)).find((one) => one.id === salasRoomId(root));
|
|
499
|
+
const answer = coordinator?.open ? await askRoom(coordinator.open.port, '/api/salas/widget?kind=coordinator', { timeoutMs: 6000 }) : null;
|
|
500
|
+
reading = answer && !answer.unreachable && !answer.outdated ? answer : { available: false };
|
|
501
|
+
} else if (kind === 'coordinator') {
|
|
502
|
+
const notes = memory ? memory.memories({ limit: 5000 }) : [];
|
|
503
|
+
const ready = datasetReadiness(await store.readAll(), notes);
|
|
504
|
+
const research = room.memoryResearch();
|
|
505
|
+
reading = { available: true, ...readinessOf({ readiness: ready, trained: ollama.madreModel ?? null, maturity: maturity({ readiness: ready, notes, links: research?.links ?? [], stats: research?.stats ?? null }) }), notes: notes.length };
|
|
506
|
+
} else return null;
|
|
507
|
+
widgetReadings.set(kind, { at: Date.now(), reading });
|
|
508
|
+
return reading;
|
|
509
|
+
}
|
|
510
|
+
async function lobbyGlance(one) {
|
|
511
|
+
if (!one.open) { lobbyGlances.delete(one.id); return null; }
|
|
512
|
+
const reading = await askRoom(one.open.port, '/api/glance?census=1', { timeoutMs: 1200 });
|
|
513
|
+
if (!reading.unreachable && !reading.outdated) {
|
|
514
|
+
lobbyGlances.set(one.id, { port: one.open.port, reading, at: Date.now() });
|
|
515
|
+
return reading;
|
|
516
|
+
}
|
|
517
|
+
const last = lobbyGlances.get(one.id);
|
|
518
|
+
if (reading.unreachable && last?.port === one.open.port && Date.now() - last.at < 30000) return { ...last.reading, delayed: true };
|
|
519
|
+
return reading;
|
|
520
|
+
}
|
|
360
521
|
// Every conversation's room other than the open one (see the note above mountChat); declared
|
|
361
522
|
// here because the local wiring below reaches all of them.
|
|
362
523
|
const parked = new Map(); // chatId -> { room, store, off, feed }
|
|
@@ -395,6 +556,15 @@ export async function createPulseServer({
|
|
|
395
556
|
target.setInvoker(MADRE_ADAPTER, localWiring.madre ? madreInvoker({ memory, ollama: () => ({ ...ollama, chatModel: ollama.madreModel ?? ollama.chatModel }), fetchImpl: reportFetch }) : null);
|
|
396
557
|
}
|
|
397
558
|
const ollamaSettings = async () => ({ enabled: true, embeddings: true, archivist: true, agent: true, ...((await readConfig(root)).modules?.ollama ?? {}) });
|
|
559
|
+
// Whether this project uses the local AI at all, as Home decides it (src/local-ai.mjs).
|
|
560
|
+
const thisRoomId = projectRoomId(canonicalProjectRoot);
|
|
561
|
+
const localAiHere = async () => localAiFor(await readConfig(root), thisRoomId, { home: salasRoom });
|
|
562
|
+
// This project's own switch, outside «auto»; null when Home decides.
|
|
563
|
+
async function setLocalAiHere(on) {
|
|
564
|
+
if (salasRoom || !(await localAiHere()).editable) return null;
|
|
565
|
+
await updateConfig(root, localAiPatch(await readConfig(root), { rooms: { [thisRoomId]: Boolean(on) } }));
|
|
566
|
+
return wireOllama({ probe: false });
|
|
567
|
+
}
|
|
398
568
|
// What the TRAIN card needs: where the recipe ships, the room, the model name @madre will pick up, a base that fits this machine.
|
|
399
569
|
function trainingInfo() {
|
|
400
570
|
const slug = basename(canonicalProjectRoot).toLowerCase().replace(/[^a-z0-9]+/g, '-');
|
|
@@ -407,7 +577,9 @@ export async function createPulseServer({
|
|
|
407
577
|
const slug = basename(canonicalProjectRoot).toLowerCase().replace(/[^a-z0-9]+/g, '-');
|
|
408
578
|
const trained = (ollama.models ?? []).map((model) => model.name).find((name) => name.startsWith(`madre-${slug}`)) ?? null;
|
|
409
579
|
ollama = { ...ollama, madreModel: trained };
|
|
410
|
-
const
|
|
580
|
+
const project = await localAiHere();
|
|
581
|
+
// Off for this project reads exactly as the module switched off, and only here.
|
|
582
|
+
const settings = { ...(await ollamaSettings()), enabled: project.on };
|
|
411
583
|
const useEmbeddings = ollama.running && settings.enabled && settings.embeddings && ollama.embedModel;
|
|
412
584
|
const useArchivist = ollama.running && settings.enabled && settings.archivist && ollama.chatModel;
|
|
413
585
|
if (memory) {
|
|
@@ -427,7 +599,7 @@ export async function createPulseServer({
|
|
|
427
599
|
wireLocal(room);
|
|
428
600
|
for (const held of parked.values()) wireLocal(held.room);
|
|
429
601
|
if (room) {
|
|
430
|
-
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
|
|
602
|
+
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' });
|
|
431
603
|
// That a local model is running is not the same as it being of use on this project, and
|
|
432
604
|
// the second thing is the one a person needs told. Said once per model, in the room, with
|
|
433
605
|
// the one button that settles it — the crew line above fires only when the crew CHANGES,
|
|
@@ -442,7 +614,7 @@ export async function createPulseServer({
|
|
|
442
614
|
});
|
|
443
615
|
}
|
|
444
616
|
}
|
|
445
|
-
return { ...ollama, settings, embeddings: Boolean(useEmbeddings), archivist: Boolean(useArchivist), agent: fifth.ready };
|
|
617
|
+
return { ...ollama, settings, project, embeddings: Boolean(useEmbeddings), archivist: Boolean(useArchivist), agent: fifth.ready };
|
|
446
618
|
}
|
|
447
619
|
// Memory settings the human keeps in config.json (MU/TH/UR → MEMORY); environment still wins at launch.
|
|
448
620
|
const memoryConfig = async () => ({ archivist: 'auto', archivists: null, every: 10, idleMinutes: 10, embedProvider: 'auto', recallShare: 0.3, cascade: true, ...((await readConfig(root)).memory ?? {}) });
|
|
@@ -472,8 +644,10 @@ export async function createPulseServer({
|
|
|
472
644
|
const passes = new Map(); // token -> { agent, mode, until, once }
|
|
473
645
|
const keyMisses = []; // when the last wrong keys were typed
|
|
474
646
|
const PASS_ONCE_MS = 30 * 60 * 1000;
|
|
475
|
-
|
|
476
|
-
|
|
647
|
+
// `asked` is a window the human chose on an agent's request for this mode (2026-10-14): one of
|
|
648
|
+
// the windows the project allows, never longer, and only for that agent and mode.
|
|
649
|
+
function issuePass(agent, mode, asked = 0) {
|
|
650
|
+
const minutes = asked > 0 && OVERRIDE_GRACE[mode]?.includes(asked) ? asked : overrideConfig.grace[mode] ?? 0;
|
|
477
651
|
const token = randomBytes(24).toString('hex');
|
|
478
652
|
const pass = { agent, mode, until: Date.now() + (minutes ? minutes * 60000 : PASS_ONCE_MS), once: !minutes };
|
|
479
653
|
passes.set(token, pass);
|
|
@@ -486,6 +660,18 @@ export async function createPulseServer({
|
|
|
486
660
|
if (pass.once) passes.delete(token);
|
|
487
661
|
return true;
|
|
488
662
|
}
|
|
663
|
+
// The copilot (src/room/copilot.mjs): one window for the project, opened with the #3 key, up to
|
|
664
|
+
// 24 hours. In memory only, like the passes: a restart and STOP ALL close it. Every conversation
|
|
665
|
+
// of the project asks this one when it decides whether a message is a copilot's.
|
|
666
|
+
let copilot = null; // { since, until, minutes, timer }
|
|
667
|
+
const copilotNow = () => (copilot && copilot.until > Date.now() ? { since: copilot.since, until: copilot.until, minutes: copilot.minutes } : null);
|
|
668
|
+
const copilotState = () => ({ active: Boolean(copilotNow()), ...(copilotNow() ?? {}), windows: COPILOT_WINDOWS });
|
|
669
|
+
async function endCopilot(reason) {
|
|
670
|
+
if (!copilot) return;
|
|
671
|
+
clearTimeout(copilot.timer);
|
|
672
|
+
copilot = null;
|
|
673
|
+
await room.record('copilot.ended', { reason }).catch(() => null);
|
|
674
|
+
}
|
|
489
675
|
let listening = null;
|
|
490
676
|
const listeningPort = () => listening?.address?.()?.port ?? Number(process.env.PULSE_PORT ?? 4317);
|
|
491
677
|
// One CONTROL desk for the whole project, shared by every conversation alive at once: a single
|
|
@@ -505,6 +691,7 @@ export async function createPulseServer({
|
|
|
505
691
|
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,
|
|
506
692
|
// Fase 3e: notes a link with automatic recall brings to a turn. Never in the room of Salas
|
|
507
693
|
// (BISHOP has its own); recorded in the conversation of the turn that read them.
|
|
694
|
+
copilot: copilotNow,
|
|
508
695
|
linkedForTurn: salasRoom ? null : async (turn) => recallForTurn(await moduleContext(), { ...turn, port: listeningPort(), roomDir,
|
|
509
696
|
record: (type, payload) => ((turn.turn && liveRooms().find((one) => one.runningTurn?.(turn.turn))) || room).record(type, payload) }),
|
|
510
697
|
softTokenBudget,
|
|
@@ -585,6 +772,11 @@ export async function createPulseServer({
|
|
|
585
772
|
config.opencode = { model: patch.opencode.model || undefined };
|
|
586
773
|
if (patch.opencode.model) process.env.PULSE_OPENCODE_MODEL = String(patch.opencode.model); else delete process.env.PULSE_OPENCODE_MODEL;
|
|
587
774
|
}
|
|
775
|
+
// The providers left out of the composer's chooser: ids only, the way OpenCode names them.
|
|
776
|
+
if (patch.opencode && 'hidden' in patch.opencode) {
|
|
777
|
+
if (!Array.isArray(patch.opencode.hidden) || patch.opencode.hidden.some((id) => typeof id !== 'string' || !/^[\w.-]{1,60}$/.test(id))) throw new Error(t('Hidden providers must be a list of provider names.'));
|
|
778
|
+
config.opencode = { ...(config.opencode ?? {}), hidden: [...new Set(patch.opencode.hidden)] };
|
|
779
|
+
}
|
|
588
780
|
if (patch.timeouts) {
|
|
589
781
|
const timeouts = { ...room.settings().agentTimeouts };
|
|
590
782
|
for (const [key, value] of Object.entries(patch.timeouts)) {
|
|
@@ -651,34 +843,75 @@ export async function createPulseServer({
|
|
|
651
843
|
}
|
|
652
844
|
|
|
653
845
|
let loggingIn = null;
|
|
654
|
-
|
|
846
|
+
// A sign-in in flight, as the room is allowed to see it: which door, the address to open, and
|
|
847
|
+
// whether the CLI is waiting to be told the code that page gave back. Never the code itself.
|
|
848
|
+
let loginRun = null;
|
|
849
|
+
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);
|
|
850
|
+
|
|
851
|
+
async function loginAgent(id, { method = null } = {}) {
|
|
655
852
|
const agent = agents.find((item) => item.id === id);
|
|
656
853
|
if (!agent) return { status: 404, body: { error: `Unknown agent: ${id}.` } };
|
|
657
854
|
if (!agent.detected) return { status: 412, body: { error: `${agent.label} is not installed on this computer.`, install: loginPlanFor(agent)?.install ?? [] } };
|
|
658
|
-
const plan = loginRunners[id]?.(agent) ?? loginPlanFor(agent);
|
|
855
|
+
const plan = loginRunners[id]?.(agent, { method }) ?? loginPlanFor(agent, { method });
|
|
659
856
|
if (!plan) return { status: 404, body: { error: `No sign-in flow known for ${id}.` } };
|
|
660
857
|
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 } };
|
|
661
858
|
if (loggingIn) return { status: 409, body: { error: `A sign-in is already running (${loggingIn}).` } };
|
|
662
859
|
loggingIn = id;
|
|
663
|
-
|
|
860
|
+
loginRun = { agent: id, method: plan.method ?? null, command: plan.display, url: null, asksForCode: Boolean(plan.asksForCode), awaitingCode: false, startedAt: Date.now(), child: null };
|
|
861
|
+
await room.record('connection.login.started', { agent: id, command: plan.display, note: plan.note, method: plan.method ?? null });
|
|
664
862
|
void (async () => {
|
|
665
863
|
const lines = [];
|
|
666
864
|
const result = await runInstaller({
|
|
667
865
|
command: plan.command,
|
|
668
866
|
args: plan.args,
|
|
669
867
|
projectRoot: canonicalProjectRoot,
|
|
670
|
-
|
|
868
|
+
// A person is in the middle of this: the browser, the account, the code. Five minutes was
|
|
869
|
+
// the installer's clock, and it killed sign-ins that were only waiting on a human.
|
|
870
|
+
timeoutMs: 900000,
|
|
871
|
+
// A CLI that asks for a code needs a way to be answered, and its question never ends in a
|
|
872
|
+
// newline, so the half-line is flushed rather than left sitting in the buffer.
|
|
873
|
+
stdin: plan.asksForCode ? 'pipe' : 'ignore',
|
|
874
|
+
flushIdleMs: plan.asksForCode ? 600 : 0,
|
|
875
|
+
quietNote: 'the sign-in is waiting on you: finish it in the page that opened',
|
|
876
|
+
onChild: (handle) => { if (loginRun?.agent === id) loginRun.child = handle; },
|
|
671
877
|
onLine: (line) => {
|
|
672
878
|
lines.push(line);
|
|
673
879
|
const url = line.match(/https?:\/\/\S+/)?.[0] ?? null;
|
|
880
|
+
if (loginRun?.agent === id) {
|
|
881
|
+
if (url) loginRun.url = url;
|
|
882
|
+
if (plan.asksForCode && looksLikeCodePrompt(line)) loginRun.awaitingCode = true;
|
|
883
|
+
}
|
|
674
884
|
void room.record('connection.login.output', { agent: id, line: line.slice(0, 500), url });
|
|
675
885
|
},
|
|
676
886
|
});
|
|
677
887
|
await refreshSessions();
|
|
678
888
|
await room.record('connection.login.finished', { agent: id, code: result.code, error: result.error ?? null, session: sessions[id] ?? null, tail: lines.slice(-6) });
|
|
679
889
|
loggingIn = null;
|
|
890
|
+
loginRun = null;
|
|
680
891
|
})();
|
|
681
|
-
return { status: 202, body: { accepted: true, command: plan.display } };
|
|
892
|
+
return { status: 202, body: { accepted: true, command: plan.display, method: plan.method ?? null, asksForCode: Boolean(plan.asksForCode) } };
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// The second half of a browser sign-in: the page hands back a code and the CLI is waiting for
|
|
896
|
+
// it on its own input. The room types it in. The code is a credential for the minute it lives,
|
|
897
|
+
// so it is taken from this computer only and never written to the ledger.
|
|
898
|
+
async function answerLogin(id, code) {
|
|
899
|
+
if (!loginRun || loginRun.agent !== id) return { status: 409, body: { error: 'No sign-in is waiting for a code.' } };
|
|
900
|
+
if (!loginRun.child?.write) return { status: 409, body: { error: 'That sign-in cannot be answered from here.' } };
|
|
901
|
+
const text = String(code ?? '').trim();
|
|
902
|
+
if (!text) return { status: 400, body: { error: 'Paste the code the page gave you.' } };
|
|
903
|
+
if (/\s/.test(text) || text.length > 400) return { status: 400, body: { error: 'That does not look like the code: check you copied all of it and nothing else.' } };
|
|
904
|
+
loginRun.child.write(`${text}\n`);
|
|
905
|
+
loginRun.awaitingCode = false;
|
|
906
|
+
await room.record('connection.login.answered', { agent: id });
|
|
907
|
+
return { status: 202, body: { accepted: true } };
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
async function cancelLogin(id) {
|
|
911
|
+
if (!loginRun || loginRun.agent !== id) return { status: 409, body: { error: 'No sign-in is running for that agent.' } };
|
|
912
|
+
loginRun.child?.cancel?.();
|
|
913
|
+
await room.record('connection.login.cancelled', { agent: id });
|
|
914
|
+
return { status: 202, body: { cancelled: true } };
|
|
682
915
|
}
|
|
683
916
|
|
|
684
917
|
// A CLI installed from the room appears without restarting: detection runs again, the roster
|
|
@@ -889,8 +1122,12 @@ export async function createPulseServer({
|
|
|
889
1122
|
// The human answering clears what waited; a message BISHOP's channel brought is not the human.
|
|
890
1123
|
if (event?.type === 'message.created' && said.role === 'user' && said.via !== 'bishop') { for (const key of [...asks.keys()]) asks.delete(key); }
|
|
891
1124
|
else if (event?.type === 'choice.offered') asks.set(`choice:${said.responseMessageId}`, { kind: 'choice', agent: said.agent, text: said.question ?? null, at: event.timestamp });
|
|
892
|
-
else if (event?.type === 'run.requested')
|
|
893
|
-
|
|
1125
|
+
else if (event?.type === 'run.requested') {
|
|
1126
|
+
for (const [index, command] of (said.commands ?? []).entries()) {
|
|
1127
|
+
if (command.argv) asks.set(`run:${said.responseMessageId}:${index}`, { kind: 'run', agent: said.agent, text: command.line, at: event.timestamp });
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
else if (event?.type === 'run.started') asks.delete(`run:${said.responseMessageId}:${said.index}`);
|
|
894
1131
|
// BISHOP (fase 4c): a #3/#4 proposal waits for the human's key in this room; in Salas, a
|
|
895
1132
|
// plan with something to press waits until it closes.
|
|
896
1133
|
else if (event?.type === 'bishop.order.proposed') asks.set(`bishop:${said.plan}:${said.step}`, { kind: 'bishop', agent: said.agent, mode: said.mode, text: said.text ?? null, at: event.timestamp });
|
|
@@ -903,7 +1140,7 @@ export async function createPulseServer({
|
|
|
903
1140
|
}
|
|
904
1141
|
function asksOf(target, now = Date.now()) {
|
|
905
1142
|
const modes = (target.pendingModes?.() ?? []).map((asked) => ({ kind: 'mode', agent: asked.agent, mode: asked.mode, text: null }));
|
|
906
|
-
const kept = [...(asksByRoom.get(target)?.values() ?? [])].filter((ask) => now - Date.parse(ask.at ?? 0) < ASK_FRESH_MS);
|
|
1143
|
+
const kept = [...(asksByRoom.get(target)?.values() ?? [])].filter((ask) => now - Date.parse(ask.at ?? 0) < ASK_FRESH_MS).sort((a, b) => String(b.at).localeCompare(String(a.at)));
|
|
907
1144
|
return [...modes, ...kept];
|
|
908
1145
|
}
|
|
909
1146
|
let unsubscribeAsks = () => {};
|
|
@@ -943,8 +1180,9 @@ export async function createPulseServer({
|
|
|
943
1180
|
// its own takes the first thing the human said in it.
|
|
944
1181
|
unsubscribeChats = room.subscribe((event) => {
|
|
945
1182
|
if (event?.type !== 'message.created' || event.ghost) return;
|
|
1183
|
+
// A message MADRE wrote for the human (results going back) neither names nor previews it.
|
|
946
1184
|
const human = event.payload?.role === 'user';
|
|
947
|
-
chatWrites = chatWrites.then(() => touchChat(roomDir, chatId, { text: human ? saidText(event.payload) : null, counts: human })).catch(() => null);
|
|
1185
|
+
chatWrites = chatWrites.then(() => touchChat(roomDir, chatId, { text: human && !event.payload?.auto ? saidText(event.payload) : null, counts: human })).catch(() => null);
|
|
948
1186
|
});
|
|
949
1187
|
unsubscribeActivity = watchActivity(room, chatId);
|
|
950
1188
|
sentinel.seed(historicalEvents);
|
|
@@ -999,6 +1237,11 @@ export async function createPulseServer({
|
|
|
999
1237
|
active: async () => clients.size > 0 && await ripleyOn(),
|
|
1000
1238
|
});
|
|
1001
1239
|
if (!testMode) serverSentinel.start();
|
|
1240
|
+
// «Still in use», once a day per copy (src/heartbeat.mjs): a little after start, then looked at
|
|
1241
|
+
// every few hours; the day it was already said, nothing goes.
|
|
1242
|
+
const sayAlive = () => { void heartbeat({ root, version: PACKAGE.version, fetchImpl: reportFetch }); };
|
|
1243
|
+
const aliveTimers = testMode ? [] : [setTimeout(sayAlive, 20_000), setInterval(sayAlive, 6 * 60 * 60 * 1000)];
|
|
1244
|
+
for (const timer of aliveTimers) timer.unref?.();
|
|
1002
1245
|
const endClients = (feed) => { for (const client of [...feed.clients.keys()]) { feed.clients.delete(client); try { client.end(); } catch { /* already gone */ } } };
|
|
1003
1246
|
async function closeHeld(id) {
|
|
1004
1247
|
const held = parked.get(id);
|
|
@@ -1036,7 +1279,7 @@ export async function createPulseServer({
|
|
|
1036
1279
|
const offTouch = target.subscribe((event) => {
|
|
1037
1280
|
if (event?.type !== 'message.created' || event.ghost) return;
|
|
1038
1281
|
const human = event.payload?.role === 'user';
|
|
1039
|
-
chatWrites = chatWrites.then(() => touchChat(roomDir, id, { text: human ? saidText(event.payload) : null, counts: human })).catch(() => null);
|
|
1282
|
+
chatWrites = chatWrites.then(() => touchChat(roomDir, id, { text: human && !event.payload?.auto ? saidText(event.payload) : null, counts: human })).catch(() => null);
|
|
1040
1283
|
});
|
|
1041
1284
|
const offActivity = watchActivity(target, id);
|
|
1042
1285
|
const offModules = wireModules(target, id);
|
|
@@ -1262,6 +1505,8 @@ export async function createPulseServer({
|
|
|
1262
1505
|
}),
|
|
1263
1506
|
ollama: {
|
|
1264
1507
|
state: () => ollama,
|
|
1508
|
+
project: () => localAiHere(),
|
|
1509
|
+
setProject: (on) => setLocalAiHere(on),
|
|
1265
1510
|
wire: (options) => wireOllama(options),
|
|
1266
1511
|
// Getting Ollama onto this computer, with the command in plain sight, and waking it.
|
|
1267
1512
|
install: async () => {
|
|
@@ -1424,15 +1669,11 @@ export async function createPulseServer({
|
|
|
1424
1669
|
response.writeHead(200, { 'content-type': 'text/html; charset=utf-8', ...PAGE_GUARDS });
|
|
1425
1670
|
return response.end(html);
|
|
1426
1671
|
}
|
|
1427
|
-
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)) {
|
|
1428
|
-
|
|
1429
|
-
response.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8' });
|
|
1430
|
-
return response.end(js);
|
|
1672
|
+
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'].includes(url.pathname)) {
|
|
1673
|
+
return sendAsset(request, response, join(publicDirectory, url.pathname.slice(1)), 'text/javascript; charset=utf-8');
|
|
1431
1674
|
}
|
|
1432
1675
|
if (request.method === 'GET' && url.pathname === '/styles.css') {
|
|
1433
|
-
|
|
1434
|
-
response.writeHead(200, { 'content-type': 'text/css; charset=utf-8' });
|
|
1435
|
-
return response.end(css);
|
|
1676
|
+
return sendAsset(request, response, join(publicDirectory, 'styles.css'), 'text/css; charset=utf-8');
|
|
1436
1677
|
}
|
|
1437
1678
|
const abductionAssets = new Map([
|
|
1438
1679
|
['/assets/abduction-cat.png', 'abduction-cat.png'],
|
|
@@ -1567,7 +1808,7 @@ export async function createPulseServer({
|
|
|
1567
1808
|
chats: chats.length,
|
|
1568
1809
|
chat: latest ? { id: latest.id, title: latest.title, named: latest.named, preview: latest.preview, at: latest.updatedAt, messages: latest.messages } : null,
|
|
1569
1810
|
working: [...working],
|
|
1570
|
-
waiting: waiting.slice(0, 5).map(({ kind, agent, mode, text, chat, chatTitle }) => ({ kind, agent, mode: mode ?? undefined, text: typeof text === 'string' ? text.slice(0, 140) : null, chat, chatTitle })),
|
|
1811
|
+
waiting: waiting.sort((a, b) => String(b.at ?? '').localeCompare(String(a.at ?? ''))).slice(0, 5).map(({ kind, agent, mode, text, chat, chatTitle }) => ({ kind, agent, mode: mode ?? undefined, text: typeof text === 'string' ? text.slice(0, 140) : null, chat, chatTitle })),
|
|
1571
1812
|
waitingCount: waiting.length,
|
|
1572
1813
|
// What the archive holds, counted, when Salas asks (?census=1): never the words, only how
|
|
1573
1814
|
// many of each kind and how many travel into turns.
|
|
@@ -1586,24 +1827,65 @@ export async function createPulseServer({
|
|
|
1586
1827
|
return response.end(html);
|
|
1587
1828
|
}
|
|
1588
1829
|
if (request.method === 'GET' && (url.pathname === '/salas-mac.js' || url.pathname === '/salas-mac.css' || url.pathname === '/macroverso.js')) {
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1830
|
+
return sendAsset(request, response, join(publicDirectory, url.pathname.slice(1)), url.pathname.endsWith('.js') ? 'text/javascript; charset=utf-8' : 'text/css; charset=utf-8');
|
|
1831
|
+
}
|
|
1832
|
+
// A widget of Home, read here (homeWidget above): only from this Mac's own pages.
|
|
1833
|
+
if (request.method === 'GET' && url.pathname === '/api/salas/widget') {
|
|
1834
|
+
const reading = await homeWidget(String(url.searchParams.get('kind') ?? ''));
|
|
1835
|
+
if (!reading) return sendJson(response, 404, { error: t('Home has no widget by that name.') });
|
|
1836
|
+
return sendJson(response, 200, reading);
|
|
1837
|
+
}
|
|
1838
|
+
// Which projects use the local AI (src/local-ai.mjs): the mode and each project's switch,
|
|
1839
|
+
// written once in config.json and applied at once in every open project.
|
|
1840
|
+
if (url.pathname === '/api/salas/local-ai' && (request.method === 'GET' || request.method === 'POST')) {
|
|
1841
|
+
if (request.method === 'POST') {
|
|
1842
|
+
const patch = await body(request);
|
|
1843
|
+
const before = await readConfig(root);
|
|
1844
|
+
const rooms = patch?.rooms && typeof patch.rooms === 'object' ? { ...patch.rooms } : {};
|
|
1845
|
+
// A new mode changes what NEW projects get; the ones already here keep what they had.
|
|
1846
|
+
if (patch?.mode && patch.mode !== localAiConfig(before).mode) {
|
|
1847
|
+
const salasId = salasRoomId(root);
|
|
1848
|
+
const ids = (await knownRooms(root)).map((one) => one.id);
|
|
1849
|
+
if (!salasRoom) ids.push(thisRoomId);
|
|
1850
|
+
for (const id of ids) if (id !== salasId && !(id in rooms)) rooms[id] = localAiFor(before, id).chosen;
|
|
1851
|
+
}
|
|
1852
|
+
await updateConfig(root, localAiPatch(before, { mode: patch?.mode, rooms }));
|
|
1853
|
+
await wireOllama({ probe: false });
|
|
1854
|
+
widgetReadings.delete('models');
|
|
1855
|
+
}
|
|
1856
|
+
const config = await readConfig(root);
|
|
1857
|
+
const salas = salasRoomId(root);
|
|
1858
|
+
const known = (await knownRooms(root)).filter((one) => one.id !== salas);
|
|
1859
|
+
// The project serving this page is one of them even before Home has listed it.
|
|
1860
|
+
if (!salasRoom && !known.some((one) => one.id === thisRoomId)) known.push({ id: thisRoomId, name: basename(canonicalProjectRoot), open: { port: null } });
|
|
1861
|
+
const later = [];
|
|
1862
|
+
if (request.method === 'POST') {
|
|
1863
|
+
await Promise.all(known.filter((one) => one.open && one.id !== thisRoomId).map(async (one) => {
|
|
1864
|
+
const answer = await askRoom(one.open.port, '/api/local-ai/apply', { method: 'POST', timeoutMs: 6000 });
|
|
1865
|
+
if (answer.unreachable || answer.outdated) later.push(one.name);
|
|
1866
|
+
}));
|
|
1867
|
+
}
|
|
1868
|
+
const projects = known.map((one) => ({ id: one.id, name: one.name, open: Boolean(one.open), ...localAiFor(config, one.id) }));
|
|
1869
|
+
return sendJson(response, 200, { ...localAiConfig(config), master: localAiFor(config).master, ollama: { running: Boolean(ollama.running), models: (ollama.models ?? []).length }, projects, later });
|
|
1592
1870
|
}
|
|
1593
1871
|
if (request.method === 'GET' && url.pathname === '/api/salas') {
|
|
1594
1872
|
const rooms = await knownRooms(root);
|
|
1595
1873
|
const here = basename(roomDir);
|
|
1874
|
+
const own = await roomLooks(root);
|
|
1875
|
+
// A project that never chose its own wears the wallpaper every page shared before.
|
|
1876
|
+
const before = (await readLooks(root)).looks['pulse.wallpaper'] ?? null;
|
|
1596
1877
|
const seen = await Promise.all(rooms.map(async (one) => ({
|
|
1597
1878
|
...one,
|
|
1598
1879
|
here: one.id === here,
|
|
1599
|
-
glance:
|
|
1880
|
+
glance: await lobbyGlance(one),
|
|
1881
|
+
wallpaper: own[one.id]?.wallpaper ?? before,
|
|
1600
1882
|
})));
|
|
1601
1883
|
// The links in force (docs/MACROVERSO.md, fase 3c), each with what crossed: counted from the
|
|
1602
1884
|
// lines the serving room keeps beside its record, never from a ledger. `salas` is the id of
|
|
1603
1885
|
// the room of Salas, whether or not that room exists yet (phase 3d).
|
|
1604
1886
|
const salas = salasRoomId(root);
|
|
1605
1887
|
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) })));
|
|
1606
|
-
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() });
|
|
1888
|
+
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() });
|
|
1607
1889
|
}
|
|
1608
1890
|
// The open rooms, each in one line (its glance: who works, who waits, its last conversation;
|
|
1609
1891
|
// never its census or the links), for the room's switcher (2026-10-10), asked every few
|
|
@@ -1649,9 +1931,18 @@ export async function createPulseServer({
|
|
|
1649
1931
|
if (!room) return sendJson(response, 404, { error: t('MADRE does not know that project on this Mac.') });
|
|
1650
1932
|
if (room.open) return sendJson(response, 200, { room: room.id, port: room.open.port, already: true });
|
|
1651
1933
|
if (!room.root) return sendJson(response, 404, { error: t('The folder of that project is no longer on this Mac.') });
|
|
1934
|
+
// Where start.log stood before this try: only what this one wrote is read if it fails.
|
|
1935
|
+
const logFrom = await stat(join(root, 'rooms', room.id, 'start.log')).then((info) => info.size, () => 0);
|
|
1652
1936
|
startRoom({ projectRoot: room.root, roomDir: join(root, 'rooms', room.id) });
|
|
1653
1937
|
const up = await roomComesUp(root, room.id);
|
|
1654
|
-
if (!up)
|
|
1938
|
+
if (!up) {
|
|
1939
|
+
// The commonest reason, said as itself: another MADRE (another Home, `madre start`) already
|
|
1940
|
+
// has this folder open, and one folder is one room. Where it is, so the person can go there.
|
|
1941
|
+
const said = (await readFile(join(root, 'rooms', room.id, 'start.log'), 'utf8').catch(() => '')).slice(logFrom);
|
|
1942
|
+
const there = [...said.matchAll(/already open for this project\s+(http:\/\/127\.0\.0\.1:\d+)/g)].at(-1)?.[1];
|
|
1943
|
+
if (there) return sendJson(response, 409, { error: t('This project is already open in another MADRE, at {address}. Use that one, or close it there first.', { address: there }), address: there });
|
|
1944
|
+
return sendJson(response, 504, { error: t('The project did not open. What it said on the way up is in start.log, beside its record.') });
|
|
1945
|
+
}
|
|
1655
1946
|
return sendJson(response, 200, { room: up.id, port: up.open.port });
|
|
1656
1947
|
}
|
|
1657
1948
|
// ---- The lobby adds projects (2026-10-10, src/salas.mjs). Only this page, on this Mac: a
|
|
@@ -1922,7 +2213,7 @@ export async function createPulseServer({
|
|
|
1922
2213
|
if (request.method === 'POST' && url.pathname === '/api/salas/enlaces/recall') {
|
|
1923
2214
|
const payload = await body(request).catch(() => ({}));
|
|
1924
2215
|
if (request.headers.origin === undefined) return sendJson(response, 403, { error: t('This is changed by you, from Home.') });
|
|
1925
|
-
if (typeof payload.recall !== 'boolean') return sendJson(response, 422, { error: t('recall
|
|
2216
|
+
if (typeof payload.recall !== 'boolean') return sendJson(response, 422, { error: t('automatic recall can only be on or off.') });
|
|
1926
2217
|
const contract = await recallContract(root, String(payload.id ?? ''), payload.recall, { salas: salasRoomId(root) });
|
|
1927
2218
|
if (!contract) return sendJson(response, 404, { error: NO_SUCH_LINK });
|
|
1928
2219
|
return sendJson(response, 200, { contract });
|
|
@@ -2018,6 +2309,7 @@ export async function createPulseServer({
|
|
|
2018
2309
|
// wins over the config here as it does everywhere else, and reading the file again
|
|
2019
2310
|
// would hand the page a different answer from the one the server itself is using.
|
|
2020
2311
|
language: roomLanguage(),
|
|
2312
|
+
languageChosen,
|
|
2021
2313
|
// Which conversation this page is looking at, and the others it could open.
|
|
2022
2314
|
chats: await chatsView(),
|
|
2023
2315
|
agents: agents.map((agent) => ({ ...agent, login: loginPlanFor(agent), install: installPlanFor(agent), key: keyPlanFor(agent.id), ...(accountNoteFor(agent.id) ?? {}) })),
|
|
@@ -2040,10 +2332,11 @@ export async function createPulseServer({
|
|
|
2040
2332
|
control: room.control(),
|
|
2041
2333
|
sessions,
|
|
2042
2334
|
sessionsAt,
|
|
2335
|
+
login: loginState(),
|
|
2043
2336
|
capabilities: room.capabilities(),
|
|
2044
2337
|
quotaSources: quotaMonitor.snapshot(),
|
|
2045
2338
|
budgetWindow: room.budgetWindow(),
|
|
2046
|
-
events: await store.readAll(),
|
|
2339
|
+
events: forThePage(await store.readAll()),
|
|
2047
2340
|
});
|
|
2048
2341
|
}
|
|
2049
2342
|
if (request.method === 'GET' && url.pathname === '/api/events') {
|
|
@@ -2237,18 +2530,58 @@ export async function createPulseServer({
|
|
|
2237
2530
|
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.') });
|
|
2238
2531
|
const now = Date.now();
|
|
2239
2532
|
while (keyMisses.length && keyMisses[0] < now - 60000) keyMisses.shift();
|
|
2240
|
-
if (keyMisses.length >= 8) return sendJson(response, 429, { error: t('Too many wrong
|
|
2533
|
+
if (keyMisses.length >= 8) return sendJson(response, 429, { error: t('Too many wrong confirmations. Wait a minute and try again.') });
|
|
2241
2534
|
if (!keyHolds(overrideConfig, { mode, words: payload.words, designation: basename(canonicalProjectRoot) })) {
|
|
2242
2535
|
keyMisses.push(now);
|
|
2243
2536
|
return sendJson(response, 403, { error: t('That is not it. Try again, or cancel.'), denied: true });
|
|
2244
2537
|
}
|
|
2245
|
-
return sendJson(response, 200, issuePass(agent, mode));
|
|
2538
|
+
return sendJson(response, 200, issuePass(agent, mode, Number(payload.minutes) || 0));
|
|
2539
|
+
}
|
|
2540
|
+
// The copilot: what it is now, turning it on with the #3 key for a chosen while, turning it off.
|
|
2541
|
+
if (request.method === 'GET' && url.pathname === '/api/copilot') return sendJson(response, 200, copilotState());
|
|
2542
|
+
if (request.method === 'POST' && url.pathname === '/api/copilot') {
|
|
2543
|
+
if (viaPhone) return sendJson(response, 403, { error: t('The copilot is turned on from the computer, not from the phone.') });
|
|
2544
|
+
const payload = await body(request).catch(() => ({}));
|
|
2545
|
+
const minutes = Number(payload.minutes);
|
|
2546
|
+
if (!COPILOT_WINDOWS.includes(minutes)) return sendJson(response, 400, { error: t('Choose how long the copilot lasts: up to 24 hours.') });
|
|
2547
|
+
const now = Date.now();
|
|
2548
|
+
while (keyMisses.length && keyMisses[0] < now - 60000) keyMisses.shift();
|
|
2549
|
+
if (keyMisses.length >= 8) return sendJson(response, 429, { error: t('Too many wrong confirmations. Wait a minute and try again.') });
|
|
2550
|
+
if (!keyHolds(overrideConfig, { mode: 3, words: payload.words, designation: basename(canonicalProjectRoot) })) {
|
|
2551
|
+
keyMisses.push(now);
|
|
2552
|
+
return sendJson(response, 403, { error: t('That is not it. Try again, or cancel.'), denied: true });
|
|
2553
|
+
}
|
|
2554
|
+
if (copilot) clearTimeout(copilot.timer);
|
|
2555
|
+
const until = now + minutes * 60000;
|
|
2556
|
+
copilot = { since: now, until, minutes, timer: setTimeout(() => { void endCopilot('time'); }, minutes * 60000) };
|
|
2557
|
+
copilot.timer.unref?.();
|
|
2558
|
+
await room.record('copilot.started', { until: new Date(until).toISOString(), minutes });
|
|
2559
|
+
return sendJson(response, 200, copilotState());
|
|
2560
|
+
}
|
|
2561
|
+
if (request.method === 'POST' && url.pathname === '/api/copilot/off') {
|
|
2562
|
+
await endCopilot('human');
|
|
2563
|
+
return sendJson(response, 200, copilotState());
|
|
2564
|
+
}
|
|
2565
|
+
// This project's switch for the local AI (src/local-ai.mjs). In «auto» Home decides and the
|
|
2566
|
+
// switch does not move; /apply is how Home tells an open project the choice changed.
|
|
2567
|
+
if (request.method === 'GET' && url.pathname === '/api/local-ai') return sendJson(response, 200, { project: await localAiHere(), running: Boolean(ollama.running) });
|
|
2568
|
+
if (request.method === 'POST' && url.pathname === '/api/local-ai/apply') {
|
|
2569
|
+
const status = await wireOllama({ probe: false });
|
|
2570
|
+
return sendJson(response, 200, { project: status.project });
|
|
2571
|
+
}
|
|
2572
|
+
if (request.method === 'POST' && url.pathname === '/api/local-ai') {
|
|
2573
|
+
const patch = await body(request);
|
|
2574
|
+
if (typeof patch?.on !== 'boolean') return sendJson(response, 400, { error: t('Say on or off.') });
|
|
2575
|
+
if (salasRoom) return sendJson(response, 409, { error: t('The Coordinator follows the local AI switch of Modules.') });
|
|
2576
|
+
const status = await setLocalAiHere(patch.on);
|
|
2577
|
+
if (!status) return sendJson(response, 409, { error: t('Home turns the local AI on in every project. Change it in Home › Settings › Local AI.') });
|
|
2578
|
+
return sendJson(response, 200, { project: status.project });
|
|
2246
2579
|
}
|
|
2247
2580
|
if (request.method === 'GET' && url.pathname === '/api/settings') {
|
|
2248
2581
|
await refreshPrivacy();
|
|
2249
2582
|
const stored = await readConfig(root);
|
|
2250
2583
|
if (stored.override) stored.override = publicOverride(stored.override);
|
|
2251
|
-
return sendJson(response, 200, { settings: effectiveSettings(), config: stored, sessions, sessionsAt, loggingIn, agents: agents.map((agent) => ({ ...agent, login: loginPlanFor(agent), install: installPlanFor(agent), key: keyPlanFor(agent.id), ...(accountNoteFor(agent.id) ?? {}) })) });
|
|
2584
|
+
return sendJson(response, 200, { settings: effectiveSettings(), config: stored, sessions, sessionsAt, loggingIn, login: loginState(), agents: agents.map((agent) => ({ ...agent, login: loginPlanFor(agent), install: installPlanFor(agent), key: keyPlanFor(agent.id), ...(accountNoteFor(agent.id) ?? {}) })) });
|
|
2252
2585
|
}
|
|
2253
2586
|
if (request.method === 'POST' && url.pathname === '/api/settings') {
|
|
2254
2587
|
const patch = await body(request);
|
|
@@ -2258,7 +2591,7 @@ export async function createPulseServer({
|
|
|
2258
2591
|
const proof = patch.override.proof;
|
|
2259
2592
|
delete patch.override.proof;
|
|
2260
2593
|
if (changeNeedsPhrase(overrideConfig) && !(typeof proof === 'string' && proof.trim() && phraseHash(overrideConfig.phrase.salt, proof) === overrideConfig.phrase.hash)) {
|
|
2261
|
-
return sendJson(response, 403, { error: t('Type your current phrase to change
|
|
2594
|
+
return sendJson(response, 403, { error: t('Type your current phrase to change how modes are confirmed.'), needsPhrase: true });
|
|
2262
2595
|
}
|
|
2263
2596
|
}
|
|
2264
2597
|
return sendJson(response, 200, { settings: await applySettings(patch) });
|
|
@@ -2294,6 +2627,12 @@ export async function createPulseServer({
|
|
|
2294
2627
|
const payload = request.method === 'POST' ? await body(request).catch(() => ({})) : {};
|
|
2295
2628
|
// `viaPhone` (encargo §6, excepción 4): a module can refuse the phone by its own hand.
|
|
2296
2629
|
const result = await moduleRoute.route.handler(await moduleContext(), { request, url, params: moduleRoute.params, payload, viaPhone });
|
|
2630
|
+
// A page instead of JSON, for the one place a browser lands on a module's route on its
|
|
2631
|
+
// own: the way back from a sign-in (Connect MCP's OAuth). Never cached.
|
|
2632
|
+
if (typeof result.html === 'string') {
|
|
2633
|
+
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'" });
|
|
2634
|
+
return response.end(result.html);
|
|
2635
|
+
}
|
|
2297
2636
|
return sendJson(response, result.status ?? 200, result.body ?? {});
|
|
2298
2637
|
}
|
|
2299
2638
|
// Where this room's tokens go: what each block of the briefing has cost, how much came back
|
|
@@ -2382,6 +2721,8 @@ export async function createPulseServer({
|
|
|
2382
2721
|
reports: Boolean(sentinel.settings().autoReport),
|
|
2383
2722
|
anthropic: agents.some((agent) => agent.id === 'claude' && agent.ready),
|
|
2384
2723
|
ollama: Boolean(ollama.running),
|
|
2724
|
+
alive: true,
|
|
2725
|
+
usage: FROM_SOURCE && Boolean(await readSecret(root, OWNER_VAULT, 'usage')),
|
|
2385
2726
|
},
|
|
2386
2727
|
}));
|
|
2387
2728
|
}
|
|
@@ -2444,7 +2785,13 @@ export async function createPulseServer({
|
|
|
2444
2785
|
// round trip; this is the durable one, shared by every browser on this machine.
|
|
2445
2786
|
// The looks every page shares (src/looks.mjs): Home and each project are different origins,
|
|
2446
2787
|
// so the browser alone kept a choice for one page only.
|
|
2447
|
-
if (request.method === 'GET' && url.pathname === '/api/looks') return sendJson(response, 200, await readLooks(root));
|
|
2788
|
+
if (request.method === 'GET' && url.pathname === '/api/looks') return sendJson(response, 200, await readLooks(root, { room: basename(roomDir) }));
|
|
2789
|
+
// This project's own wallpaper («only in this project»), or back to the shared one with null.
|
|
2790
|
+
if (request.method === 'POST' && url.pathname === '/api/looks/room') {
|
|
2791
|
+
const payload = await body(request).catch(() => ({}));
|
|
2792
|
+
const answer = await changeRoomLook(root, basename(roomDir), payload?.wallpaper ?? null);
|
|
2793
|
+
return sendJson(response, answer.error ? 400 : 200, answer);
|
|
2794
|
+
}
|
|
2448
2795
|
if (request.method === 'POST' && url.pathname === '/api/looks') {
|
|
2449
2796
|
const answer = await changeLooks(root, await body(request).catch(() => null));
|
|
2450
2797
|
return sendJson(response, answer.error ? 400 : 200, answer);
|
|
@@ -2453,6 +2800,7 @@ export async function createPulseServer({
|
|
|
2453
2800
|
const payload = await body(request).catch(() => ({}));
|
|
2454
2801
|
const language = payload.language === 'en' ? 'en' : 'es';
|
|
2455
2802
|
await updateConfig(root, { language });
|
|
2803
|
+
languageChosen = true;
|
|
2456
2804
|
setRoomLanguage(process.env.PULSE_LANGUAGE ?? language);
|
|
2457
2805
|
return sendJson(response, 200, { language });
|
|
2458
2806
|
}
|
|
@@ -2545,7 +2893,10 @@ export async function createPulseServer({
|
|
|
2545
2893
|
if (request.method === 'GET' && url.pathname === '/api/tour') {
|
|
2546
2894
|
const state = await readTour(root);
|
|
2547
2895
|
if (viaPhone || !fromHere) return sendJson(response, 200, { version: TOUR_VERSION, state, owed: false, returning: false, chapters: CHAPTERS, hints: HINTS });
|
|
2548
|
-
|
|
2896
|
+
// Where this person comes from: the tour of 1.0 (path «since»), 0.7.2 (their words in a room's
|
|
2897
|
+
// record, path «back»), or nowhere — a first install.
|
|
2898
|
+
const from = state.path === 'since' || (!state.path && state.before === '1.0') ? '1.0' : (state.path ? state.path === 'back' : await cameBefore(root)) ? '0.7.2' : null;
|
|
2899
|
+
return sendJson(response, 200, { version: TOUR_VERSION, state, owed: !state.bonjourAt, returning: Boolean(from), from, chapters: CHAPTERS, hints: HINTS });
|
|
2549
2900
|
}
|
|
2550
2901
|
if (viaPhone || !fromHere || (request.method === 'POST' && request.headers.origin !== `http://${host}`)) return sendJson(response, 403, { error: t('This is done on the Mac.') });
|
|
2551
2902
|
if (request.method === 'POST' && url.pathname === '/api/tour') {
|
|
@@ -2617,7 +2968,7 @@ export async function createPulseServer({
|
|
|
2617
2968
|
const payload = await body(request).catch(() => ({}));
|
|
2618
2969
|
if (!designationOk(payload.designation)) return sendJson(response, 403, { error: t('UNABLE TO COMPUTE. UNABLE TO CLARIFY.') });
|
|
2619
2970
|
const result = await room.code000({ strikes: Number(payload.strikes) || CODE000_STRIKES });
|
|
2620
|
-
return result ? sendJson(response, 200, { code: result.code, lockedForMs: result.lockedForMs, n: result.n }) : sendJson(response, 503, { error: t('Security
|
|
2971
|
+
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.') });
|
|
2621
2972
|
}
|
|
2622
2973
|
if (request.method === 'GET' && url.pathname === '/api/memory') {
|
|
2623
2974
|
if (!designationOk(url.searchParams.get('designation'))) return sendJson(response, 403, { error: t('UNABLE TO COMPUTE. UNABLE TO CLARIFY.') });
|
|
@@ -2841,7 +3192,7 @@ export async function createPulseServer({
|
|
|
2841
3192
|
if (request.method === 'POST' && url.pathname === '/api/agents/probe') {
|
|
2842
3193
|
// Look for the binaries again too: a CLI installed a moment ago must appear now.
|
|
2843
3194
|
await redetectAgents();
|
|
2844
|
-
return sendJson(response, 200, { sessions, sessionsAt, agents: agents.map((agent) => ({ ...agent, login: loginPlanFor(agent), install: installPlanFor(agent), key: keyPlanFor(agent.id), ...(accountNoteFor(agent.id) ?? {}) })) });
|
|
3195
|
+
return sendJson(response, 200, { sessions, sessionsAt, loggingIn, login: loginState(), agents: agents.map((agent) => ({ ...agent, login: loginPlanFor(agent), install: installPlanFor(agent), key: keyPlanFor(agent.id), ...(accountNoteFor(agent.id) ?? {}) })) });
|
|
2845
3196
|
}
|
|
2846
3197
|
// A provider key for a CLI that signs in from its own prompt. Local connections only: a key
|
|
2847
3198
|
// pasted from another machine would travel the network. It is written where that CLI looks
|
|
@@ -2906,17 +3257,72 @@ export async function createPulseServer({
|
|
|
2906
3257
|
}
|
|
2907
3258
|
const loginMatch = request.method === 'POST' && url.pathname.match(/^\/api\/agents\/([a-z0-9-]+)\/login$/);
|
|
2908
3259
|
if (loginMatch) {
|
|
2909
|
-
const
|
|
3260
|
+
const payload = await body(request).catch(() => ({}));
|
|
3261
|
+
const result = await loginAgent(loginMatch[1], { method: payload.method ? String(payload.method) : null });
|
|
3262
|
+
return sendJson(response, result.status, result.body);
|
|
3263
|
+
}
|
|
3264
|
+
// The code the sign-in page gives back. It is a credential while it lives, so it is taken
|
|
3265
|
+
// from this computer only — the same rule as a key — and it never reaches the ledger.
|
|
3266
|
+
const loginCodeMatch = request.method === 'POST' && url.pathname.match(/^\/api\/agents\/([a-z0-9-]+)\/login\/code$/);
|
|
3267
|
+
if (loginCodeMatch) {
|
|
3268
|
+
const address = request.socket.remoteAddress ?? '';
|
|
3269
|
+
if (viaPhone || !['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(address)) {
|
|
3270
|
+
return sendJson(response, 403, { error: t('A key is only accepted from this computer, never over the network.') });
|
|
3271
|
+
}
|
|
3272
|
+
const payload = await body(request).catch(() => ({}));
|
|
3273
|
+
const result = await answerLogin(loginCodeMatch[1], payload.code);
|
|
3274
|
+
return sendJson(response, result.status, result.body);
|
|
3275
|
+
}
|
|
3276
|
+
const loginStopMatch = request.method === 'POST' && url.pathname.match(/^\/api\/agents\/([a-z0-9-]+)\/login\/cancel$/);
|
|
3277
|
+
if (loginStopMatch) {
|
|
3278
|
+
const result = await cancelLogin(loginStopMatch[1]);
|
|
2910
3279
|
return sendJson(response, result.status, result.body);
|
|
2911
3280
|
}
|
|
2912
3281
|
if (request.method === 'GET' && url.pathname === '/api/models') {
|
|
3282
|
+
// `--verbose` adds each model's metadata, its «variants» (the reasoning rungs) among it.
|
|
2913
3283
|
const listOpenCode = async (opencode) => {
|
|
2914
|
-
const
|
|
2915
|
-
await runInstaller({ command: opencode.path, args: ['models'], projectRoot: canonicalProjectRoot, timeoutMs:
|
|
2916
|
-
return
|
|
3284
|
+
const lines = [];
|
|
3285
|
+
await runInstaller({ command: opencode.path, args: ['models', '--verbose'], projectRoot: canonicalProjectRoot, timeoutMs: 30000, onLine: (line) => { lines.push(line); } });
|
|
3286
|
+
return parseOpenCodeVerbose(lines);
|
|
2917
3287
|
};
|
|
2918
3288
|
return sendJson(response, 200, { models: await discoverModels({ agents, config: await readConfig(root), listOpenCode: url.searchParams.get('opencode') === '1' ? listOpenCode : async () => [] }) });
|
|
2919
3289
|
}
|
|
3290
|
+
// OpenCode's providers for AJUSTES: who has a key or an account, what each offers, which are
|
|
3291
|
+
// left out of the chooser. Never a key: only that one is there and of what kind.
|
|
3292
|
+
if (request.method === 'GET' && url.pathname === '/api/agents/opencode/providers') {
|
|
3293
|
+
const opencode = agents.find((agent) => agent.id === 'opencode');
|
|
3294
|
+
if (!opencode?.detected) return sendJson(response, 404, { error: t('OpenCode is not installed.') });
|
|
3295
|
+
const names = [];
|
|
3296
|
+
await runInstaller({ command: opencode.path, args: ['models'], projectRoot: canonicalProjectRoot, timeoutMs: 20000, onLine: (line) => { if (/^[\w.-]+\/[\w.:/-]+$/.test(line.trim())) names.push(line.trim()); } });
|
|
3297
|
+
const config = await readConfig(root);
|
|
3298
|
+
const hidden = new Set(openCodeHidden(config));
|
|
3299
|
+
const signed = new Map((await readOpenCodeProviders({ home: credentialHome })).map((one) => [one.id, one.type]));
|
|
3300
|
+
const groups = new Map(groupOpenCode(names).map((group) => [group.id, group]));
|
|
3301
|
+
// Every provider a key opens, from OpenCode's own catalogue (credentials.mjs), plus the
|
|
3302
|
+
// ones already signed in or listing models.
|
|
3303
|
+
const keyable = new Map((await openCodeKeyProviders({ home: credentialHome })).map((one) => [one.id, one]));
|
|
3304
|
+
const ids = [...new Set([...keyable.keys(), ...signed.keys(), ...groups.keys()])];
|
|
3305
|
+
const providers = ids.map((id) => {
|
|
3306
|
+
const known = keyable.get(id);
|
|
3307
|
+
const own = id === OPENCODE_OWN;
|
|
3308
|
+
const via = own ? 'own' : signed.get(id) ?? null;
|
|
3309
|
+
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) };
|
|
3310
|
+
}).sort((a, b) => Number(Boolean(b.via)) - Number(Boolean(a.via)) || Number(a.via === 'own') - Number(b.via === 'own') || a.label.localeCompare(b.label));
|
|
3311
|
+
return sendJson(response, 200, { providers, default: process.env.PULSE_OPENCODE_MODEL ?? null });
|
|
3312
|
+
}
|
|
3313
|
+
// A provider's key out of OpenCode, from this computer only, the same rule as putting one in.
|
|
3314
|
+
if (request.method === 'POST' && url.pathname === '/api/agents/opencode/providers/remove') {
|
|
3315
|
+
const address = request.socket.remoteAddress ?? '';
|
|
3316
|
+
if (viaPhone || !['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(address)) return sendJson(response, 403, { error: t('A key is only changed from this computer, never over the network.') });
|
|
3317
|
+
const opencode = agents.find((agent) => agent.id === 'opencode');
|
|
3318
|
+
if (!opencode?.detected) return sendJson(response, 412, { error: t('OpenCode is not installed.') });
|
|
3319
|
+
const payload = await body(request).catch(() => ({}));
|
|
3320
|
+
const result = await removeOpenCodeKey({ provider: payload.provider, home: credentialHome, executable: opencode.path ?? 'opencode' });
|
|
3321
|
+
if (!result.ok) return sendJson(response, 422, { error: result.error });
|
|
3322
|
+
await refreshSessions();
|
|
3323
|
+
await room.record('connection.key.removed', { agent: 'opencode', label: opencode.label, provider: payload.provider });
|
|
3324
|
+
return sendJson(response, 200, { ok: true });
|
|
3325
|
+
}
|
|
2920
3326
|
if (request.method === 'GET' && url.pathname === '/api/agents/opencode/models') {
|
|
2921
3327
|
const opencode = agents.find((agent) => agent.id === 'opencode');
|
|
2922
3328
|
if (!opencode?.detected) return sendJson(response, 404, { error: t('OpenCode is not installed.') });
|
|
@@ -2950,6 +3356,7 @@ export async function createPulseServer({
|
|
|
2950
3356
|
// Every conversation at work, not only the one on screen: the brake is the room's, not the chat's.
|
|
2951
3357
|
const result = await room.stopAll();
|
|
2952
3358
|
passes.clear();
|
|
3359
|
+
await endCopilot('stopall');
|
|
2953
3360
|
for (const held of parked.values()) {
|
|
2954
3361
|
const more = await held.room.stopAll().catch(() => ({ plans: 0, turns: 0 }));
|
|
2955
3362
|
result.plans += more.plans ?? 0;
|
|
@@ -2981,9 +3388,18 @@ export async function createPulseServer({
|
|
|
2981
3388
|
if (typeof payload.responseMessageId !== 'string' || !Number.isInteger(index) || index < 0) return sendJson(response, 400, { error: t('Name the request and the line to run.') });
|
|
2982
3389
|
const here = await scopeOf(url);
|
|
2983
3390
|
if (here.error) return sendJson(response, 404, here);
|
|
2984
|
-
const outcome = await here.room.runRequested({ responseMessageId: payload.responseMessageId, index });
|
|
3391
|
+
const outcome = await here.room.runRequested({ responseMessageId: payload.responseMessageId, index, returnCap: viaPhone ? PHONE_MAX_MODE : 3 });
|
|
2985
3392
|
return sendJson(response, outcome.status, outcome.ok ? outcome.result : { error: outcome.error });
|
|
2986
3393
|
}
|
|
3394
|
+
// The human ran some lines of a request and hands back what there is, without the rest.
|
|
3395
|
+
if (request.method === 'POST' && url.pathname === '/api/runs/return') {
|
|
3396
|
+
const payload = await body(request).catch(() => ({}));
|
|
3397
|
+
if (typeof payload.responseMessageId !== 'string') return sendJson(response, 400, { error: t('Say which request to hand back.') });
|
|
3398
|
+
const here = await scopeOf(url);
|
|
3399
|
+
if (here.error) return sendJson(response, 404, here);
|
|
3400
|
+
const outcome = await here.room.returnRun({ responseMessageId: payload.responseMessageId, returnCap: viaPhone ? PHONE_MAX_MODE : 3 });
|
|
3401
|
+
return sendJson(response, outcome.status, outcome.ok ? outcome : { error: outcome.error });
|
|
3402
|
+
}
|
|
2987
3403
|
if (request.method === 'GET' && url.pathname === '/api/commands') {
|
|
2988
3404
|
const ctx = await moduleContext();
|
|
2989
3405
|
const fromModules = await Promise.all(moduleCommands().map(async (command) => {
|
|
@@ -3087,11 +3503,16 @@ export async function createPulseServer({
|
|
|
3087
3503
|
}
|
|
3088
3504
|
if (request.method === 'POST' && /^\/api\/modes\/[\w-]+\/decide$/.test(url.pathname)) {
|
|
3089
3505
|
const requestId = url.pathname.split('/')[3];
|
|
3090
|
-
const { decision } = await body(request);
|
|
3506
|
+
const { decision, key } = await body(request);
|
|
3091
3507
|
const here = await scopeOf(url);
|
|
3092
3508
|
if (here.error) return sendJson(response, 404, here);
|
|
3093
|
-
|
|
3094
|
-
|
|
3509
|
+
if (!['once', 'plan', 'deny'].includes(decision)) return sendJson(response, 400, { error: t('Decision must be once, plan or deny.') });
|
|
3510
|
+
const asked = here.room.modeRequest(requestId);
|
|
3511
|
+
if (!asked) return sendJson(response, 409, { error: t('That request is no longer pending.') });
|
|
3512
|
+
if (decision !== 'deny' && asked.mode > PHONE_MAX_MODE) {
|
|
3513
|
+
if (viaPhone) return sendJson(response, 403, { error: t('#3 EDIT and #4 FULL ACCESS are opened from the Mac.') });
|
|
3514
|
+
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 });
|
|
3515
|
+
}
|
|
3095
3516
|
const result = here.room.decideMode(requestId, decision);
|
|
3096
3517
|
return sendJson(response, result.ok ? 200 : 409, result);
|
|
3097
3518
|
}
|
|
@@ -3110,12 +3531,20 @@ export async function createPulseServer({
|
|
|
3110
3531
|
const here = await scopeOf(url);
|
|
3111
3532
|
if (here.error) return sendJson(response, 404, here);
|
|
3112
3533
|
const room = here.room;
|
|
3534
|
+
// A copilot message at #3 goes with the copilot's window instead of a pass; from this
|
|
3535
|
+
// computer only. With the window closed it is said, and the page asks for the key again.
|
|
3536
|
+
if (payload.copilot === true && (viaPhone || !copilotNow())) {
|
|
3537
|
+
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 });
|
|
3538
|
+
delete payload.copilot;
|
|
3539
|
+
}
|
|
3113
3540
|
// Modes are checked before the turn is accepted, so the composer
|
|
3114
3541
|
// hears "no" with a reason instead of a silent log line.
|
|
3115
3542
|
const gate = await room.modeCheck(payload);
|
|
3116
3543
|
if (!gate.ok) return sendJson(response, gate.status ?? 403, { error: gate.error, mode: gate.mode, maxMode: gate.maxMode });
|
|
3544
|
+
const byCopilot = payload.copilot === true && gate.mode === 3;
|
|
3545
|
+
if (!byCopilot) delete payload.copilot;
|
|
3117
3546
|
// #3 and #4 go with the pass their key gave, for this agent (above).
|
|
3118
|
-
if (gate.mode >= 3 && !spendPass(payload.key, gate.agent, gate.mode)) return sendJson(response, 403, { error: t('#{n} needs
|
|
3547
|
+
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 });
|
|
3119
3548
|
delete payload.key;
|
|
3120
3549
|
const turns = await turnsPolicy();
|
|
3121
3550
|
if (turns.enabled) {
|
|
@@ -3181,6 +3610,7 @@ export async function createPulseServer({
|
|
|
3181
3610
|
return server;
|
|
3182
3611
|
};
|
|
3183
3612
|
server.on('close', () => quotaMonitor.stop());
|
|
3613
|
+
server.on('close', () => { for (const timer of aliveTimers) clearTimeout(timer); });
|
|
3184
3614
|
|
|
3185
3615
|
server.on('close', () => { void phone.stop(); });
|
|
3186
3616
|
return { server, store, agents, quotaMonitor, markOpen, releaseOpen, phone };
|