@jossuealcala/madre 0.7.2 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +817 -1
- package/CONTRIBUTING.md +5 -1
- package/NOTICE +20 -1
- package/README.md +99 -34
- package/TRADEMARKS.md +65 -0
- package/bin/madre.mjs +30 -10
- package/docs/INTERNALS.md +2 -2
- package/docs/REFERENCE.md +159 -82
- package/docs/SDK.md +23 -19
- package/docs/madre-banner.png +0 -0
- package/package.json +4 -3
- package/public/app.js +11668 -4219
- package/public/assets/brand/Geist-latin-ext.woff2 +0 -0
- package/public/assets/brand/Geist-latin.woff2 +0 -0
- package/public/assets/brand/GeistMono-latin-ext.woff2 +0 -0
- package/public/assets/brand/GeistMono-latin.woff2 +0 -0
- package/public/assets/brand/JetBrainsMono-latin-ext.woff2 +0 -0
- package/public/assets/brand/JetBrainsMono-latin.woff2 +0 -0
- package/public/assets/brand/OFL-Geist.txt +93 -0
- package/public/assets/brand/OFL-GeistMono.txt +93 -0
- package/public/assets/brand/OFL-JetBrainsMono.txt +93 -0
- package/public/assets/brand/OFL-Sora.txt +93 -0
- package/public/assets/brand/Sora-latin-ext.woff2 +0 -0
- package/public/assets/brand/Sora-latin.woff2 +0 -0
- package/public/assets/brand/madre-app-180.png +0 -0
- package/public/assets/brand/madre-app-192.png +0 -0
- package/public/assets/brand/madre-app-512.png +0 -0
- package/public/assets/brand/madre-app.svg +1 -0
- package/public/assets/brand/madre-favicon.svg +1 -0
- package/public/assets/news/1.0/alerts.webp +0 -0
- package/public/assets/news/1.0/bar.webp +0 -0
- package/public/assets/news/1.0/finder.webp +0 -0
- package/public/assets/news/1.0/github.webp +0 -0
- package/public/assets/news/1.0/intents.webp +0 -0
- package/public/assets/news/1.0/looks.webp +0 -0
- package/public/assets/news/1.0/modules.webp +0 -0
- package/public/assets/news/1.0/mother.webp +0 -0
- package/public/assets/news/1.0/nostromo.webp +0 -0
- package/public/assets/news/1.0/permissions.webp +0 -0
- package/public/assets/news/1.0/room.webp +0 -0
- package/public/assets/news/1.0/settings.webp +0 -0
- package/public/assets/wallpapers/biznaga.webp +0 -0
- package/public/assets/wallpapers/canela.webp +0 -0
- package/public/assets/wallpapers/jarrito.webp +0 -0
- package/public/assets/wallpapers/magna.webp +0 -0
- package/public/assets/wallpapers/oceano.webp +0 -0
- package/public/assets/wallpapers/paraiso.webp +0 -0
- package/public/assets/wallpapers/quila.webp +0 -0
- package/public/assets/wallpapers/tradicion.webp +0 -0
- package/public/assets/wallpapers/tropical.webp +0 -0
- package/public/assets/wallpapers/urbania.webp +0 -0
- package/public/assets/wallpapers/ventura.webp +0 -0
- package/public/assets/wallpapers/xico.webp +0 -0
- package/public/brands.js +28 -2
- package/public/editor.js +744 -0
- package/public/es.js +3443 -1425
- package/public/i18n.js +1 -1
- package/public/index.html +201 -175
- package/public/inquiry.js +11 -11
- package/public/loader.js +79 -0
- package/public/macroverso.js +1517 -0
- package/public/manifest.webmanifest +14 -0
- package/public/nostromo-engine.js +2222 -0
- package/public/resay.js +24 -0
- package/public/review-ask.js +85 -0
- package/public/salas-mac.css +1281 -0
- package/public/salas-mac.html +158 -0
- package/public/salas-mac.js +4593 -0
- package/public/salas.css +85 -0
- package/public/salas.html +47 -0
- package/public/salas.js +200 -0
- package/public/styles.css +6372 -426
- package/public/touch.js +83 -0
- package/public/tour-chapters.js +452 -0
- package/public/tour.js +546 -0
- package/public/troubleshooting.js +160 -141
- package/src/adapters/codex.mjs +46 -6
- package/src/adapters/gemini.mjs +30 -10
- package/src/adapters/madre.mjs +110 -18
- package/src/adapters/process.mjs +42 -2
- package/src/auth-probe.mjs +31 -8
- package/src/bridge/devices.mjs +202 -0
- package/src/bridge/handoff.mjs +45 -0
- package/src/bridge/host.mjs +82 -0
- package/src/bridge/network.mjs +133 -0
- package/src/bridge/policy.mjs +89 -0
- package/src/bridge/qr.mjs +287 -0
- package/src/bridge/server.mjs +269 -0
- package/src/capabilities.mjs +5 -9
- package/src/chats.mjs +59 -22
- package/src/checkpoint.mjs +137 -35
- package/src/choices.mjs +18 -3
- package/src/cli-args.mjs +12 -1
- package/src/commands.mjs +15 -15
- package/src/config.mjs +73 -9
- package/src/conversation-context.mjs +5 -2
- package/src/credentials.mjs +80 -0
- package/src/dataset.mjs +3 -1
- package/src/directives.mjs +6 -11
- package/src/enlaces.mjs +451 -0
- package/src/event-store.mjs +55 -10
- package/src/exam.mjs +5 -5
- package/src/extensions.mjs +31 -12
- package/src/file-ops.mjs +23 -0
- package/src/files.mjs +52 -2
- package/src/github.mjs +407 -0
- package/src/heartbeat.mjs +55 -0
- package/src/json-file.mjs +63 -0
- package/src/lease.mjs +2 -2
- package/src/lobby-work.mjs +103 -0
- package/src/local-ai.mjs +48 -0
- package/src/looks.mjs +88 -0
- package/src/maturity.mjs +3 -3
- package/src/mcp/github-server.mjs +164 -0
- package/src/mcp/image-server.mjs +1 -1
- package/src/mcp/memory-server.mjs +11 -3
- package/src/memory.mjs +38 -12
- package/src/models.mjs +127 -5
- package/src/modules/ahp.mjs +4 -4
- package/src/modules/ash.mjs +1 -1
- package/src/modules/bishop-plan.mjs +207 -0
- package/src/modules/bishop.mjs +645 -0
- package/src/modules/choices.mjs +2 -2
- package/src/modules/correo.mjs +1 -1
- package/src/modules/enlaces.mjs +255 -0
- package/src/modules/git-pulse.mjs +2 -2
- package/src/modules/github.mjs +322 -0
- package/src/modules/image-studio.mjs +3 -3
- package/src/modules/index.mjs +18 -4
- package/src/modules/intents-calibration.mjs +338 -0
- package/src/modules/intents-desk.mjs +481 -0
- package/src/modules/intents-observe.mjs +266 -0
- package/src/modules/intents-policy.mjs +414 -0
- package/src/modules/intents-try.mjs +91 -0
- package/src/modules/intents.mjs +145 -0
- package/src/modules/ollama.mjs +18 -6
- package/src/modules/playwright.mjs +121 -11
- package/src/modules/ripley.mjs +1 -1
- package/src/modules/runs.mjs +2 -2
- package/src/modules/sdk.mjs +10 -4
- package/src/ollama.mjs +6 -1
- package/src/outbound.mjs +36 -17
- package/src/override.mjs +100 -0
- package/src/ports.mjs +23 -1
- package/src/prices.mjs +72 -0
- package/src/privacy.mjs +30 -11
- package/src/quota-sources.mjs +0 -8
- package/src/request-guard.mjs +1 -1
- package/src/review-ask.mjs +111 -0
- package/src/room/archivist.mjs +55 -37
- package/src/room/budget.mjs +17 -5
- package/src/room/control.mjs +70 -7
- package/src/room/economy.mjs +35 -7
- package/src/room/escalation.mjs +3 -0
- package/src/room/guard.mjs +26 -2
- package/src/room/prompt.mjs +22 -15
- package/src/room/runs.mjs +84 -21
- package/src/room/vectors.mjs +19 -4
- package/src/room.mjs +411 -84
- package/src/router.mjs +3 -1
- package/src/runs.mjs +23 -5
- package/src/salas.mjs +313 -0
- package/src/sentinel-errors.mjs +1 -1
- package/src/server-sentinel.mjs +124 -0
- package/src/server.mjs +1864 -178
- package/src/setup.mjs +1 -0
- package/src/tour.mjs +174 -0
- package/src/updates.mjs +48 -8
- package/src/usage-sentinel.mjs +1 -1
- package/src/verdict.mjs +7 -7
- package/src/whats-new.mjs +160 -0
- package/docs/madre-banner.svg +0 -25
package/src/server.mjs
CHANGED
|
@@ -1,36 +1,52 @@
|
|
|
1
1
|
import http from 'node:http';
|
|
2
|
-
import { readFile, realpath, writeFile, mkdir,
|
|
3
|
-
import { rmSync } from 'node:fs';
|
|
4
|
-
import { createHash } from 'node:crypto';
|
|
5
|
-
import {
|
|
2
|
+
import { readFile, realpath, writeFile, mkdir, cp, rename, access, stat } from 'node:fs/promises';
|
|
3
|
+
import { existsSync, openSync, rmSync } from 'node:fs';
|
|
4
|
+
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
5
|
+
import { probePort, framable } from './ports.mjs';
|
|
6
|
+
import { ControlDesk } from './room/control.mjs';
|
|
6
7
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
7
8
|
import { fileURLToPath } from 'node:url';
|
|
8
|
-
import { homedir } from 'node:os';
|
|
9
|
+
import { homedir, hostname } from 'node:os';
|
|
9
10
|
import { execFile } from 'node:child_process';
|
|
10
11
|
import { promisify } from 'node:util';
|
|
11
12
|
|
|
12
13
|
const execFileAsync = promisify(execFile);
|
|
13
14
|
import { detectAgents, toolsPrefix } from './runtime-detection.mjs';
|
|
14
15
|
import { EventStore } from './event-store.mjs';
|
|
15
|
-
import { RoomMemory } from './memory.mjs';
|
|
16
|
+
import { RoomMemory, travels as memoryTravels } from './memory.mjs';
|
|
16
17
|
import { createEmbedder } from './embeddings.mjs';
|
|
17
18
|
import { memoryServerFor } from './memory-tools.mjs';
|
|
18
19
|
import { MotherChannel, CODE000_STRIKES } from './mother.mjs';
|
|
19
20
|
import { ErrorSentinel } from './sentinel-errors.mjs';
|
|
20
|
-
import { probeOllama, ollamaEmbedder, ollamaInvoker, pullModel, ollamaVersions, ownOllamaServers } from './ollama.mjs';
|
|
21
|
-
import {
|
|
21
|
+
import { probeOllama, ollamaEmbedder, ollamaGenerate, ollamaInvoker, pullModel, ollamaVersions, ownOllamaServers } from './ollama.mjs';
|
|
22
|
+
import { recallForTurn } from './modules/enlaces.mjs';
|
|
23
|
+
import { MODULES, moduleById, describeModules, findModuleRoute, toolsForTurn as modulesToolsForTurn, loadExternalModules, loadFailures, moduleFolders, moduleCommands, installModuleFile, installModuleText, verifyModuleText, moduleOrigin, removeExternalModule } from './modules/index.mjs';
|
|
22
24
|
import { madreAgent, madreInvoker, MADRE_AGENT_ID, MADRE_ADAPTER } from './adapters/madre.mjs';
|
|
23
25
|
import { exportDataset, readiness as datasetReadiness } from './dataset.mjs';
|
|
24
26
|
import { OutboundLog, outboundView, DEFAULT_REPORT_URL } from './outbound.mjs';
|
|
25
27
|
import { language as roomLanguage, setLanguage as setRoomLanguage, t } from './i18n.mjs';
|
|
26
28
|
|
|
29
|
+
// One per start of this process: a restart is a new one, a reload of the page is not.
|
|
30
|
+
const BOOT_ID = `${process.pid}-${Date.now().toString(36)}`;
|
|
27
31
|
const PACKAGE = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8').catch(() => '{}'));
|
|
32
|
+
// The phone is not in the public MADRE until it is stable (2026-10-12): it is offered only on a copy
|
|
33
|
+
// run from its source, where the repository's `.git` sits beside `src/`. What npm installs never
|
|
34
|
+
// carries a `.git` (`files` in package.json), so there no setting and no variable opens it.
|
|
35
|
+
const FROM_SOURCE = existsSync(new URL('../.git', import.meta.url));
|
|
36
|
+
// Where the author's token for the usage widget is kept: a vault name no module can take (module
|
|
37
|
+
// ids are kebab-case, src/modules/sdk.mjs), so no module ever reads it.
|
|
38
|
+
export const OWNER_VAULT = 'madre_owner';
|
|
28
39
|
let crashHandlersInstalled = false;
|
|
29
40
|
|
|
30
41
|
// RIPLEY's bridge, injected into every page it renders: reports the page's path
|
|
31
42
|
// and title to the viewer and forwards runtime errors and failed resources.
|
|
32
43
|
// It talks only upward through postMessage; the frame stays an opaque origin.
|
|
33
|
-
|
|
44
|
+
// The bridge also says, once per page, that the page reached for its storage: the sealed frame
|
|
45
|
+
// has an opaque origin, so localStorage, sessionStorage, IndexedDB and cookies throw or forget, and
|
|
46
|
+
// an app that keeps its data there cannot be tried in here. The access still fails exactly as it
|
|
47
|
+
// did; the viewer is only told, so it can say where the page can be tried with its data. Window is
|
|
48
|
+
// the global interface: browsers put its storage getters on window itself, not on its prototype.
|
|
49
|
+
export const RIPLEY_BRIDGE = '<script data-ripley>(function(){var p=location.pathname;function send(m){try{parent.postMessage(Object.assign({ripley:1},m),"*")}catch(e){}}send({type:"page",path:p,title:document.title});addEventListener("DOMContentLoaded",function(){send({type:"page",path:p,title:document.title})});addEventListener("error",function(e){if(e.target&&e.target!==window&&!e.message){var u=e.target.src||e.target.href||"";send({type:"error",message:"Failed to load "+(u||e.target.tagName.toLowerCase()),source:u,line:0});return}send({type:"error",message:String(e.message||"error"),source:e.filename||"",line:e.lineno||0})},true);addEventListener("unhandledrejection",function(e){var r=e.reason;send({type:"error",message:"Unhandled promise rejection: "+String(r&&r.message||r),source:"",line:0})});var told=0;function storage(n){if(!told){told=1;send({type:"storage",what:n})}}["localStorage","sessionStorage","indexedDB"].forEach(function(n){var d=Object.getOwnPropertyDescriptor(window,n)||Object.getOwnPropertyDescriptor(Window.prototype,n);if(!d||!d.get)return;try{Object.defineProperty(window,n,{configurable:true,get:function(){storage(n);return d.get.call(window)}})}catch(e){}});var c=Object.getOwnPropertyDescriptor(Document.prototype,"cookie");if(c&&c.get&&c.set){try{Object.defineProperty(document,"cookie",{configurable:true,get:function(){storage("cookie");return c.get.call(document)},set:function(v){storage("cookie");c.set.call(document,v)}})}catch(e){}}})();</script>';
|
|
34
50
|
export function injectRipleyBridge(body) {
|
|
35
51
|
const text = Buffer.isBuffer(body) ? body.toString('utf8') : String(body);
|
|
36
52
|
if (text.includes('data-ripley')) return Buffer.from(text);
|
|
@@ -44,34 +60,52 @@ export function injectRipleyBridge(body) {
|
|
|
44
60
|
import { QuotaMonitor } from './quota-monitor.mjs';
|
|
45
61
|
import { defaultQuotaSources } from './quota-sources.mjs';
|
|
46
62
|
import { Room } from './room.mjs';
|
|
47
|
-
import {
|
|
63
|
+
import { changeNeedsPhrase, keyHolds, mergeOverride, overrideSettings, phraseHash, publicOverride } from './override.mjs';
|
|
48
64
|
import { extensionById, findOnPath, runInstaller } from './extensions.mjs';
|
|
49
|
-
import { accountNoteFor, installPlanFor, looksLikeAdminProblem, loginPlanFor, probeAgentAuth, probeAll, TAKES_KEY } from './auth-probe.mjs';
|
|
50
|
-
import { applyKey, keyPlanFor } from './credentials.mjs';
|
|
51
|
-
import { forgetSecret, summary as vaultSummary, writeSecret } from './vault.mjs';
|
|
52
|
-
import { loadConfig as readConfig, updateConfig } from './config.mjs';
|
|
65
|
+
import { accountNoteFor, installPlanFor, looksLikeAdminProblem, loginPlanFor, looksLikeCodePrompt, probeAgentAuth, probeAll, TAKES_KEY } from './auth-probe.mjs';
|
|
66
|
+
import { applyKey, keyPlanFor, OPENCODE_PROVIDERS, readOpenCodeProviders, removeOpenCodeKey } from './credentials.mjs';
|
|
67
|
+
import { forgetSecret, readSecret, summary as vaultSummary, writeSecret } from './vault.mjs';
|
|
68
|
+
import { applyConfigToEnv, loadConfig as readConfig, updateConfig } from './config.mjs';
|
|
53
69
|
import { Privacy, normalizeTerms, privacySettings } from './privacy.mjs';
|
|
54
|
-
import { checkForUpdate, detectInstall, updateCommand, releaseUrl, applyCommand } from './updates.mjs';
|
|
55
|
-
import { bootWhatsNew, markSeen, notesFor, whatsNew } from './whats-new.mjs';
|
|
70
|
+
import { checkForUpdate, detectInstall, updateCommand, releaseUrl, applyCommand, fetchCommand } from './updates.mjs';
|
|
71
|
+
import { bootWhatsNew, markSeen, newestNotes, notesFor, whatsNew } from './whats-new.mjs';
|
|
72
|
+
import { answerAsk, tickAsk } from './review-ask.mjs';
|
|
73
|
+
import { CHAPTERS, HINTS, TOUR_VERSION, cameBefore, changeTour, readTour, recoverProject, roomsWithoutProject } from './tour.mjs';
|
|
56
74
|
|
|
57
75
|
// Where a person reads the module contract, in the language the room is speaking.
|
|
58
76
|
const SDK_PAGE = { es: 'https://madre.run/sdk/', en: 'https://madre.run/en/sdk/' };
|
|
59
77
|
import { moduleUpdate } from './modules/updates.mjs';
|
|
60
|
-
import { adoptStrays, chatIndex, chatLedger, createChat, deleteChat, isChatId, listChats, openChat as openChatIndex, projectFloor, renameChat, touchChat
|
|
78
|
+
import { adoptStrays, chatIndex, chatLedger, createChat, deleteChat, isChatId, listChats, openChat as openChatIndex, projectFloor, renameChat, touchChat } from './chats.mjs';
|
|
61
79
|
import { spawn } from 'node:child_process';
|
|
62
|
-
import { totalmem } from 'node:os';
|
|
63
|
-
import { discoverModels } from './models.mjs';
|
|
64
|
-
import {
|
|
80
|
+
import { cpus, freemem, loadavg, totalmem } from 'node:os';
|
|
81
|
+
import { discoverModels, groupOpenCode, openCodeHidden, providerLabel, OPENCODE_OWN } from './models.mjs';
|
|
82
|
+
import { observable } from './modules/intents-observe.mjs';
|
|
83
|
+
import { normalizeMode, setImageModule } from './capabilities.mjs';
|
|
65
84
|
import { resolveGeminiKey } from './image-studio.mjs';
|
|
66
85
|
import { commandByName, listCommands, parseCommand } from './commands.mjs';
|
|
67
|
-
import { listDirectory, searchFiles, readServable, storeAttachment, resolveInside, MAX_ATTACHMENT_BYTES } from './files.mjs';
|
|
68
|
-
import { planFileOp, freeCopyPath, CREATIONS } from './file-ops.mjs';
|
|
86
|
+
import { listDirectory, searchFiles, readServable, storeAttachment, resolveInside, createEmptyFile, writeEdited, MAX_ATTACHMENT_BYTES, MAX_EDIT_BYTES } from './files.mjs';
|
|
87
|
+
import { planFileOp, freeCopyPath, needsDesignationOnDisk, CREATIONS } from './file-ops.mjs';
|
|
88
|
+
import { ServerSentinel } from './server-sentinel.mjs';
|
|
69
89
|
import { Eyecat } from './eyecat-watch.mjs';
|
|
70
90
|
import { economy } from './room/economy.mjs';
|
|
71
91
|
import { maturity } from './maturity.mjs';
|
|
72
92
|
import { memoryDashboard } from './memory-dashboard.mjs';
|
|
73
93
|
import { verdictFor } from './verdict.mjs';
|
|
74
94
|
import { refuseForeign } from './request-guard.mjs';
|
|
95
|
+
import { restoreGuard } from './room/guard.mjs';
|
|
96
|
+
import { bridgeAnswers, hostBridge } from './bridge/host.mjs';
|
|
97
|
+
import { BRIDGE_PORT } from './bridge/server.mjs';
|
|
98
|
+
import { createInvite, forgetDevice, forgetNetwork, readStore, setEnabled, storeView, trustNetwork } from './bridge/devices.mjs';
|
|
99
|
+
import { currentNetwork, lanAddresses } from './bridge/network.mjs';
|
|
100
|
+
import { PHONE_MAX_MODE, writtenMode } from './bridge/policy.mjs';
|
|
101
|
+
import { qrSvg } from './bridge/qr.mjs';
|
|
102
|
+
import { readHandoff, writeHandoff } from './bridge/handoff.mjs';
|
|
103
|
+
import { changeLooks, readLooks, changeRoomLook, roomLooks } from './looks.mjs';
|
|
104
|
+
import { localAiConfig, localAiFor, localAiPatch } from './local-ai.mjs';
|
|
105
|
+
import { heartbeat } from './heartbeat.mjs';
|
|
106
|
+
import { askRoom, associateProject, ensureSalasProject, forgetProject, isSalasRoom, knownRooms, listFolders, markProject, projectRoomId, roomComesUp, salasRoomId, startRoom, suggestProjects, universeOf } from './salas.mjs';
|
|
107
|
+
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';
|
|
75
109
|
|
|
76
110
|
const sourceDirectory = dirname(fileURLToPath(import.meta.url));
|
|
77
111
|
const publicDirectory = join(sourceDirectory, '..', 'public');
|
|
@@ -81,13 +115,18 @@ function sendJson(response, status, value) {
|
|
|
81
115
|
response.end(JSON.stringify(value));
|
|
82
116
|
}
|
|
83
117
|
|
|
84
|
-
|
|
85
|
-
|
|
118
|
+
// A request's JSON, read whole before it is decoded (2026-10-12, SRV-08): a «ñ» split between two
|
|
119
|
+
// packets came out as «��». The limit is in bytes; a route that takes more says so (the editor).
|
|
120
|
+
const BODY_LIMIT = 100_000;
|
|
121
|
+
async function body(request, { limit = BODY_LIMIT } = {}) {
|
|
122
|
+
const chunks = [];
|
|
123
|
+
let size = 0;
|
|
86
124
|
for await (const chunk of request) {
|
|
87
|
-
|
|
88
|
-
if (
|
|
125
|
+
size += chunk.length;
|
|
126
|
+
if (size > limit) throw Object.assign(new Error('Request too large.'), { status: 413 });
|
|
127
|
+
chunks.push(chunk);
|
|
89
128
|
}
|
|
90
|
-
return JSON.parse(
|
|
129
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
|
|
91
130
|
}
|
|
92
131
|
|
|
93
132
|
export function openUrl(url) {
|
|
@@ -109,11 +148,20 @@ export function agentTimeoutsFromEnv(env = process.env, agentIds = ['codex', 'cl
|
|
|
109
148
|
return timeouts;
|
|
110
149
|
}
|
|
111
150
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
151
|
+
// The id of a room lives beside the rest of what Salas knows of rooms (src/salas.mjs); it is still
|
|
152
|
+
// read from here by everything that always did.
|
|
153
|
+
export { projectRoomId };
|
|
154
|
+
|
|
155
|
+
// What the pages of a project and of Home are sent with (2026-10-12, SRV-04 and WEB-01 of the 1.0
|
|
156
|
+
// review). Only this same page may frame them — the split view frames `/?pane=split` — so another
|
|
157
|
+
// page cannot lay them under its own buttons and borrow a click on RUN or STOP ALL. And images come
|
|
158
|
+
// only from here: a picture in an answer that pointed elsewhere was fetched the moment it was
|
|
159
|
+
// drawn, with whatever its address carried, and never counted as something that left the Mac.
|
|
160
|
+
const PAGE_GUARDS = {
|
|
161
|
+
'x-frame-options': 'SAMEORIGIN',
|
|
162
|
+
'content-security-policy': "frame-ancestors 'self'; img-src 'self' data: blob:",
|
|
163
|
+
'x-content-type-options': 'nosniff',
|
|
164
|
+
};
|
|
117
165
|
|
|
118
166
|
export async function createPulseServer({
|
|
119
167
|
projectRoot,
|
|
@@ -127,6 +175,8 @@ export async function createPulseServer({
|
|
|
127
175
|
quotaSources = [],
|
|
128
176
|
quotaPollIntervalMs = Number(process.env.PULSE_QUOTA_POLL_INTERVAL_MS ?? 60000),
|
|
129
177
|
broadcastIntervalMs = Number(process.env.PULSE_BROADCAST_INTERVAL_MS ?? 500),
|
|
178
|
+
// How long a conversation opened beside the one on screen waits for a page to start watching it.
|
|
179
|
+
heldGraceMs = 30000,
|
|
130
180
|
sseMaxBufferedBytes = Number(process.env.PULSE_SSE_MAX_BUFFERED_BYTES ?? 1_048_576),
|
|
131
181
|
agentTimeouts,
|
|
132
182
|
maxMessageChars = Number(process.env.PULSE_MAX_MESSAGE_CHARS ?? 20000),
|
|
@@ -153,7 +203,7 @@ export async function createPulseServer({
|
|
|
153
203
|
// an update has news to show them, or this is a first install with nothing to catch up on.
|
|
154
204
|
const novelty = await bootWhatsNew({ root, version: PACKAGE.version }).catch(() => ({ returning: false }));
|
|
155
205
|
// ~/.pulse/config.json fills in whatever the environment did not set.
|
|
156
|
-
applyConfigToEnv(await
|
|
206
|
+
applyConfigToEnv(await readConfig(root));
|
|
157
207
|
// What language the screens speak. The prompt the agents read is not one of them, and the
|
|
158
208
|
// environment wins over the file, as it does for every other setting here.
|
|
159
209
|
setRoomLanguage(process.env.PULSE_LANGUAGE ?? (await readConfig(root)).language);
|
|
@@ -164,6 +214,9 @@ export async function createPulseServer({
|
|
|
164
214
|
const agents = providedAgents ?? await detector();
|
|
165
215
|
const canonicalProjectRoot = await realpath(projectRoot).catch(() => resolve(projectRoot));
|
|
166
216
|
const roomDir = join(root, 'rooms', projectRoomId(canonicalProjectRoot));
|
|
217
|
+
// Whether this is the room of Salas (docs/MACROVERSO.md, fase 3d): the room whose project is
|
|
218
|
+
// the notebook of Salas, decided once from real paths; the page carries the MACROVERSO key there.
|
|
219
|
+
const salasRoom = isSalasRoom(canonicalProjectRoot, root);
|
|
167
220
|
// What left this machine. Every fetch this process makes — MADRE's own and any a module makes,
|
|
168
221
|
// because a module runs in here and cannot opt out — goes through one wrapper and lands in a
|
|
169
222
|
// line beside the ledger. Bodies, headers and query values are never in it. One room per
|
|
@@ -184,20 +237,78 @@ export async function createPulseServer({
|
|
|
184
237
|
const openMark = join(roomDir, 'open.json');
|
|
185
238
|
const alive = (pid) => { try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; } };
|
|
186
239
|
const held = await readFile(openMark, 'utf8').then((raw) => JSON.parse(raw)).catch(() => null);
|
|
187
|
-
|
|
240
|
+
// A live process is not enough (2026-10-12, LOG-13): after a restart of the Mac the number of the
|
|
241
|
+
// room that died is often some other program's, and the project said «already open» for good. It
|
|
242
|
+
// is taken only if that room also answers on its port.
|
|
243
|
+
const answers = async (port) => Number.isInteger(port) && await fetch(`http://127.0.0.1:${port}/api/version`, { signal: AbortSignal.timeout(1500) }).then((response) => response.ok, () => false);
|
|
244
|
+
if (held && Number.isInteger(held.pid) && held.pid !== process.pid && alive(held.pid) && await answers(held.port)) {
|
|
188
245
|
const error = new Error(`MADRE is already open for this project at http://127.0.0.1:${held.port} (process ${held.pid}). Close that room first, or open another conversation inside it.`);
|
|
189
246
|
error.code = 'ROOM_IN_USE';
|
|
190
247
|
throw error;
|
|
191
248
|
}
|
|
192
|
-
|
|
249
|
+
// The room is this one's now. A room that died in the middle of a turn left the project's .env
|
|
250
|
+
// files and MADRE's folders read-only (room/guard.mjs): they get their modes back first.
|
|
251
|
+
const unlocked = await restoreGuard(join(roomDir, 'guard.json')).catch(() => []);
|
|
252
|
+
if (unlocked.length) console.error(`MADRE: a room that closed mid-turn had left ${unlocked.length} protected path(s) read-only; they are writable again.`);
|
|
253
|
+
// The mark also says which project and which MADRE, for the bridge's list of rooms on the phone.
|
|
254
|
+
let openPort = null;
|
|
255
|
+
// Where the project is, too (src/salas.mjs): a room that is closed can be opened again from Salas.
|
|
256
|
+
const markOpen = async (port) => { openPort = port; await mkdir(roomDir, { recursive: true }).catch(() => {}); await writeFile(openMark, JSON.stringify({ pid: process.pid, port, at: new Date().toISOString(), project: basename(canonicalProjectRoot), version: PACKAGE.version ?? null })).catch(() => {}); await markProject(roomDir, canonicalProjectRoot).catch(() => {}); };
|
|
257
|
+
// The phone (docs/MOBILE.md) is not offered in MADRE 1.0 (2026-10-11, hidden 2026-10-12): Safari
|
|
258
|
+
// in HTTPS-only mode refuses the bridge's http, and the relay that fixes it is not built. Only a
|
|
259
|
+
// copy run from source (FROM_SOURCE, above) on a Mac that turned it on — `phone.preview` in
|
|
260
|
+
// config.json, or PULSE_PHONE=1 — has it. Everywhere else nothing says it exists: no key on the
|
|
261
|
+
// bar, no place in Settings, no chapter in the tour, and the bridge never opens.
|
|
262
|
+
const phonePreview = async () => FROM_SOURCE && (process.env.PULSE_PHONE === '1' || (await readConfig(root).catch(() => ({}))).phone?.preview === true);
|
|
263
|
+
// This room offers to host the bridge once it is open; see bridge/host.mjs.
|
|
264
|
+
const phone = hostBridge({ root, publicDirectory, allowed: phonePreview });
|
|
265
|
+
async function phoneView() {
|
|
266
|
+
const store = await readStore(root);
|
|
267
|
+
const here = await currentNetwork().catch(() => null);
|
|
268
|
+
return {
|
|
269
|
+
available: Boolean(here),
|
|
270
|
+
network: here ? { id: here.id, gateway: here.gateway, trusted: store.networks.some((one) => one.id === here.id) } : null,
|
|
271
|
+
addresses: here ? lanAddresses({ device: here.device }) : [],
|
|
272
|
+
port: BRIDGE_PORT,
|
|
273
|
+
bridge: { up: await bridgeAnswers(), here: phone.status().hosting },
|
|
274
|
+
...storeView(store),
|
|
275
|
+
};
|
|
276
|
+
}
|
|
193
277
|
const releaseOpen = () => { try { rmSync(openMark, { force: true }); } catch { /* it was already gone */ } };
|
|
278
|
+
// How many memories of each kind, and how many travel into turns. Counted, never read out.
|
|
279
|
+
function memoryCensus() {
|
|
280
|
+
const all = memory ? memory.memories({ limit: 20000 }) : [];
|
|
281
|
+
const kinds = {};
|
|
282
|
+
let travelling = 0;
|
|
283
|
+
for (const note of all) {
|
|
284
|
+
kinds[note.kind] = (kinds[note.kind] ?? 0) + 1;
|
|
285
|
+
if (memoryTravels(note)) travelling += 1;
|
|
286
|
+
}
|
|
287
|
+
return { memories: all.length, kinds, travelling };
|
|
288
|
+
}
|
|
194
289
|
// The human's own modules, from ~/.pulse/modules and <project>/.madre/modules.
|
|
195
290
|
await loadExternalModules({ stateRoot: root, projectRoot: canonicalProjectRoot }).catch((error) => console.error(`MADRE modules: ${error.message}`));
|
|
196
291
|
// A project has one memory and many conversations; one of them is open at a time, and the
|
|
197
292
|
// numbering is the project's, so a citation means the same thing in all of them.
|
|
198
293
|
await adoptStrays(roomDir).catch(() => null);
|
|
199
294
|
let chatId = (await chatIndex(roomDir)).active;
|
|
200
|
-
|
|
295
|
+
// Every record open in this process. Two conversations may write at once (one at work while
|
|
296
|
+
// the human is in another, or both halves of a split window), so the floor a ledger numbers
|
|
297
|
+
// from is read when it writes: what the project had on disk, or what any open ledger has
|
|
298
|
+
// handed out since, whichever is higher.
|
|
299
|
+
const liveStores = new Set();
|
|
300
|
+
let retiredHigh = 0; // the highest a ledger that closed in this process had reached
|
|
301
|
+
const openLedger = async (id) => {
|
|
302
|
+
const base = await projectFloor(roomDir, { except: id });
|
|
303
|
+
const opened = await new EventStore(chatLedger(roomDir, id), { floor: () => Math.max(base, retiredHigh, ...[...liveStores].map((each) => each.lastSequence)) }).initialize();
|
|
304
|
+
// A record with lines that are not events opens anyway (src/event-store.mjs); it is said here,
|
|
305
|
+
// with where the file is, and nothing in it is removed.
|
|
306
|
+
if (opened.damaged) console.error(`MADRE: ${opened.damaged} damaged line(s) in ${chatLedger(roomDir, id)} were stepped over; the file keeps them.`);
|
|
307
|
+
liveStores.add(opened);
|
|
308
|
+
return opened;
|
|
309
|
+
};
|
|
310
|
+
const retireLedger = (closed) => { if (!liveStores.delete(closed)) return; retiredHigh = Math.max(retiredHigh, closed.lastSequence); };
|
|
311
|
+
let store = await openLedger(chatId);
|
|
201
312
|
let historicalEvents = await store.readAll();
|
|
202
313
|
// The room's memory: derived from the ledger, rebuilt if missing or stale,
|
|
203
314
|
// and never a reason for the room not to open.
|
|
@@ -227,14 +338,191 @@ export async function createPulseServer({
|
|
|
227
338
|
// human message and whenever MU/TH/UR looks: a term named in one room guards them all.
|
|
228
339
|
const privacy = new Privacy({ ...privacySettings(await readConfig(root)), home: homedir() });
|
|
229
340
|
const refreshPrivacy = async () => { const { terms, marker, secrets, paths } = privacySettings(await readConfig(root)); privacy.set(terms, marker).setGuards({ secrets, paths }); return privacy; };
|
|
341
|
+
const memoryWasThere = await stat(join(roomDir, 'memory.sqlite')).then(() => true, () => false);
|
|
230
342
|
const memory = await new RoomMemory(join(roomDir, 'memory.sqlite')).attachPrivacy(privacy).initialize(store)
|
|
231
343
|
.catch((error) => { console.error(`MADRE memory unavailable, turns get the recent window only: ${error.message}`); return null; });
|
|
344
|
+
// A memory made again from nothing (its file deleted, a copy of the project's state without it)
|
|
345
|
+
// is filled from every conversation of the project, not only the one open (LOG-14 of the 1.0
|
|
346
|
+
// review): the index of the others was lost for good. The words are indexed once; a number
|
|
347
|
+
// already there is left as it is.
|
|
348
|
+
if (memory && !memoryWasThere) {
|
|
349
|
+
for (const chat of (await listChats(roomDir)).chats) {
|
|
350
|
+
if (chat.id === chatId) continue;
|
|
351
|
+
const file = chatLedger(roomDir, chat.id);
|
|
352
|
+
if (await stat(file).then((info) => info.isFile(), () => false)) memory.index(await new EventStore(file).readAll().catch(() => []));
|
|
353
|
+
}
|
|
354
|
+
}
|
|
232
355
|
// Meaning-aware recall through the user's own Gemini key, when there is one;
|
|
233
356
|
// and the memory as MCP tools for every agent's turn.
|
|
234
357
|
// Ollama, when it is there: local embeddings and a local archivist. Probed at
|
|
235
358
|
// start and again from MODULES; wired live, no restart.
|
|
236
359
|
let ollama = { running: false, host: null, models: [], embedModel: null, chatModel: null };
|
|
360
|
+
// The lobby's two readings (src/lobby-work.mjs): MADRE's summary of what was worked on last, kept
|
|
361
|
+
// beside this room's record so a restart does not lose it, and the recent decisions of the open
|
|
362
|
+
// rooms, asked of them at most every few minutes (each room works its zones out itself).
|
|
363
|
+
const lobbyWork = { file: join(roomDir, 'lobby-work.json'), loaded: false, key: null, at: 0, tried: 0, bullets: null, model: null, error: null, running: null };
|
|
364
|
+
const lobbyDecisions = { at: 0, list: null, running: null };
|
|
365
|
+
// A busy agent can delay a room's glance without closing the room. Keep the last confirmed
|
|
366
|
+
// reading for a short time, and distinguish a slow answer from a stopped process.
|
|
367
|
+
const lobbyGlances = new Map();
|
|
368
|
+
// HOME'S WIDGETS (2026-10-13): what each one shows comes from something that already exists —
|
|
369
|
+
// the machine (node:os), each open project's servers sentinel (/api/ports) and dataset
|
|
370
|
+
// (/api/dataset: maturity, pairs, the model trained on it), this room's own dataset for the
|
|
371
|
+
// Coordinator, and Ollama as this room probed it. A reading is kept 30 s, so a page that asks
|
|
372
|
+
// every few seconds never makes the projects work for it.
|
|
373
|
+
const widgetReadings = new Map();
|
|
374
|
+
let cpuSeen = null;
|
|
375
|
+
const cpuTimes = () => cpus().reduce((sum, one) => {
|
|
376
|
+
const times = one.times;
|
|
377
|
+
sum.idle += times.idle;
|
|
378
|
+
sum.total += times.user + times.nice + times.sys + times.idle + times.irq;
|
|
379
|
+
return sum;
|
|
380
|
+
}, { idle: 0, total: 0 });
|
|
381
|
+
async function cpuBusy() {
|
|
382
|
+
let before = cpuSeen;
|
|
383
|
+
if (!before || Date.now() - before.at > 60000) { before = { ...cpuTimes(), at: Date.now() }; await new Promise((done) => setTimeout(done, 300)); }
|
|
384
|
+
const now = { ...cpuTimes(), at: Date.now() };
|
|
385
|
+
cpuSeen = now;
|
|
386
|
+
const total = now.total - before.total;
|
|
387
|
+
return total > 0 ? Math.max(0, Math.min(1, 1 - (now.idle - before.idle) / total)) : null;
|
|
388
|
+
}
|
|
389
|
+
const readinessOf = (answer) => (answer && !answer.unreachable && !answer.outdated ? {
|
|
390
|
+
pairs: answer.readiness?.pairs ?? 0, target: answer.readiness?.target ?? 300, aberrations: answer.readiness?.aberrations ?? 0,
|
|
391
|
+
good: answer.readiness?.good ?? 0, bad: answer.readiness?.bad ?? 0, trained: answer.trained ?? null,
|
|
392
|
+
score: answer.maturity?.score ?? null, stage: answer.maturity?.stage?.label ?? null, says: answer.maturity?.stage?.says ?? null,
|
|
393
|
+
weakest: (() => { const one = (answer.maturity?.signals ?? []).find((signal) => signal.id === answer.maturity?.weakest); return one ? { label: one.label ?? null, next: one.next ?? null } : null; })(),
|
|
394
|
+
signals: (answer.maturity?.signals ?? []).map((one) => ({ id: one.id, label: one.label, value: one.value })),
|
|
395
|
+
} : null);
|
|
396
|
+
// Each project with whether it uses the local AI, for the widget that shows Ollama (src/local-ai.mjs).
|
|
397
|
+
const withLocalAi = async (projects) => { const config = await readConfig(root); return projects.map((one) => ({ ...one, localAi: localAiFor(config, one.id).on })); };
|
|
398
|
+
async function openProjects() {
|
|
399
|
+
const salas = salasRoomId(root);
|
|
400
|
+
return (await knownRooms(root)).filter((one) => one.open && one.id !== salas);
|
|
401
|
+
}
|
|
402
|
+
// Memory a program could have now. os.freemem() counts only pages nobody holds; macOS keeps
|
|
403
|
+
// most of the rest as cache it gives back at once, so «free» read 0.1 GB of 16 on an idle Mac.
|
|
404
|
+
// The system's own count: free, inactive, speculative and purgeable pages on macOS (vm_stat),
|
|
405
|
+
// MemAvailable on Linux; freemem() where neither answers.
|
|
406
|
+
async function memoryAvailable() {
|
|
407
|
+
try {
|
|
408
|
+
if (process.platform === 'darwin') {
|
|
409
|
+
const { stdout } = await execFileAsync('vm_stat', [], { timeout: 2000 });
|
|
410
|
+
const page = Number(stdout.match(/page size of (\d+) bytes/)?.[1] ?? 0);
|
|
411
|
+
const pages = (name) => Number(stdout.match(new RegExp(`Pages ${name}:\\s+(\\d+)`))?.[1] ?? 0);
|
|
412
|
+
if (page) return { bytes: (pages('free') + pages('inactive') + pages('speculative') + pages('purgeable')) * page, source: 'vm_stat' };
|
|
413
|
+
}
|
|
414
|
+
if (process.platform === 'linux') {
|
|
415
|
+
const kb = Number((await readFile('/proc/meminfo', 'utf8')).match(/^MemAvailable:\s+(\d+)/m)?.[1] ?? 0);
|
|
416
|
+
if (kb) return { bytes: kb * 1024, source: 'meminfo' };
|
|
417
|
+
}
|
|
418
|
+
} catch { /* the plain count below */ }
|
|
419
|
+
return { bytes: freemem(), source: 'freemem' };
|
|
420
|
+
}
|
|
421
|
+
async function homeWidget(kind) {
|
|
422
|
+
const kept = widgetReadings.get(kind);
|
|
423
|
+
// madre.run's count moves once a day: the author's reading is kept ten minutes, the Mac's thirty seconds.
|
|
424
|
+
if (kept && Date.now() - kept.at < (kind === 'usage' ? 600000 : 30000)) return kept.reading;
|
|
425
|
+
let reading;
|
|
426
|
+
if (kind === 'system') {
|
|
427
|
+
const total = totalmem();
|
|
428
|
+
const free = await memoryAvailable();
|
|
429
|
+
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 } };
|
|
430
|
+
} else if (kind === 'servers') {
|
|
431
|
+
const rooms = await openProjects();
|
|
432
|
+
const projects = await Promise.all(rooms.map(async (one) => {
|
|
433
|
+
const answer = await askRoom(one.open.port, '/api/ports', { timeoutMs: 4000 });
|
|
434
|
+
if (answer.unreachable && answer.status === 412) return { id: one.id, name: one.name, preview: false, ports: [] };
|
|
435
|
+
if (answer.unreachable || answer.outdated) return { id: one.id, name: one.name, unreachable: true, ports: [] };
|
|
436
|
+
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 })) };
|
|
437
|
+
}));
|
|
438
|
+
reading = { available: projects.some((one) => one.preview), projects };
|
|
439
|
+
} else if (kind === 'memory' || kind === 'models') {
|
|
440
|
+
const rooms = await openProjects();
|
|
441
|
+
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 }) })));
|
|
442
|
+
reading = kind === 'memory'
|
|
443
|
+
? { available: projects.some((one) => !one.unreachable), projects }
|
|
444
|
+
: { available: true, ollama: { running: Boolean(ollama.running), models: (ollama.models ?? []).map((model) => model.name).filter(Boolean) }, projects: await withLocalAi(projects) };
|
|
445
|
+
} else if (kind === 'usage') {
|
|
446
|
+
// The author's own reading of how many copies are on (src/heartbeat.mjs, madre.run's
|
|
447
|
+
// /api/usage). It exists only in a copy run from source that holds the author's token
|
|
448
|
+
// (scripts/owner-usage.mjs); anywhere else there is no such widget.
|
|
449
|
+
const token = FROM_SOURCE ? await readSecret(root, OWNER_VAULT, 'usage') : null;
|
|
450
|
+
if (!token) return null;
|
|
451
|
+
const answer = await reportFetch(process.env.PULSE_USAGE_URL ?? 'https://madre.run/api/usage', { headers: { authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(8000) }).catch(() => null);
|
|
452
|
+
const body = answer?.ok ? await answer.json().catch(() => null) : null;
|
|
453
|
+
reading = body?.ok ? { available: true, ...body } : { available: false, status: answer?.status ?? 0 };
|
|
454
|
+
} else if (kind === 'coordinator' && !salasRoom) {
|
|
455
|
+
// Home's page is served by every project too: the Coordinator's record is its own room's.
|
|
456
|
+
const coordinator = (await knownRooms(root)).find((one) => one.id === salasRoomId(root));
|
|
457
|
+
const answer = coordinator?.open ? await askRoom(coordinator.open.port, '/api/salas/widget?kind=coordinator', { timeoutMs: 6000 }) : null;
|
|
458
|
+
reading = answer && !answer.unreachable && !answer.outdated ? answer : { available: false };
|
|
459
|
+
} else if (kind === 'coordinator') {
|
|
460
|
+
const notes = memory ? memory.memories({ limit: 5000 }) : [];
|
|
461
|
+
const ready = datasetReadiness(await store.readAll(), notes);
|
|
462
|
+
const research = room.memoryResearch();
|
|
463
|
+
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 };
|
|
464
|
+
} else return null;
|
|
465
|
+
widgetReadings.set(kind, { at: Date.now(), reading });
|
|
466
|
+
return reading;
|
|
467
|
+
}
|
|
468
|
+
async function lobbyGlance(one) {
|
|
469
|
+
if (!one.open) { lobbyGlances.delete(one.id); return null; }
|
|
470
|
+
const reading = await askRoom(one.open.port, '/api/glance?census=1', { timeoutMs: 1200 });
|
|
471
|
+
if (!reading.unreachable && !reading.outdated) {
|
|
472
|
+
lobbyGlances.set(one.id, { port: one.open.port, reading, at: Date.now() });
|
|
473
|
+
return reading;
|
|
474
|
+
}
|
|
475
|
+
const last = lobbyGlances.get(one.id);
|
|
476
|
+
if (reading.unreachable && last?.port === one.open.port && Date.now() - last.at < 30000) return { ...last.reading, delayed: true };
|
|
477
|
+
return reading;
|
|
478
|
+
}
|
|
479
|
+
// Every conversation's room other than the open one (see the note above mountChat); declared
|
|
480
|
+
// here because the local wiring below reaches all of them.
|
|
481
|
+
const parked = new Map(); // chatId -> { room, store, off, feed }
|
|
482
|
+
// The records of the project's other conversations: the open store of one held beside, or the
|
|
483
|
+
// file of one closed (never made where none exists). And every event of the project, for what is
|
|
484
|
+
// counted across all of them (the privacy exposure).
|
|
485
|
+
async function otherLedgers() {
|
|
486
|
+
const out = [];
|
|
487
|
+
for (const chat of (await listChats(roomDir)).chats) {
|
|
488
|
+
if (chat.id === chatId) continue;
|
|
489
|
+
const held = parked.get(chat.id);
|
|
490
|
+
if (held?.store) { out.push({ store: held.store, held }); continue; }
|
|
491
|
+
const file = chatLedger(roomDir, chat.id);
|
|
492
|
+
if (await stat(file).then((info) => info.isFile(), () => false)) out.push({ store: new EventStore(file), held: null });
|
|
493
|
+
}
|
|
494
|
+
return out;
|
|
495
|
+
}
|
|
496
|
+
async function projectEvents() {
|
|
497
|
+
const all = await store.readAll();
|
|
498
|
+
for (const { store: other } of await otherLedgers()) all.push(...await other.readAll().catch(() => []));
|
|
499
|
+
return all;
|
|
500
|
+
}
|
|
501
|
+
const opening = new Map(); // chatId -> the promise of its room, while it is being built
|
|
502
|
+
// What the human sets belongs to the project: it reaches the conversation on screen and every
|
|
503
|
+
// one alive beside it. A timeout raised from a conversation in a window used to change only the
|
|
504
|
+
// one on screen, and its retry ran with the old limit.
|
|
505
|
+
const liveRooms = () => [room, ...[...parked.values()].map((held) => held.room)].filter(Boolean);
|
|
506
|
+
// What the local brain lends every room of the project: the archivist that distills in Ollama
|
|
507
|
+
// and @madre. Settled once per wiring and handed to each room as it opens, beside or switched
|
|
508
|
+
// to. A room built with @madre and without the archivist's invoker distilled with the next
|
|
509
|
+
// cheapest crew member: a provider, unannounced, on every conversation but the first.
|
|
510
|
+
let localWiring = { archivist: null, madre: false };
|
|
511
|
+
function wireLocal(target) {
|
|
512
|
+
if (!target) return;
|
|
513
|
+
target.setInvoker('ollama', localWiring.archivist);
|
|
514
|
+
target.setInvoker(MADRE_ADAPTER, localWiring.madre ? madreInvoker({ memory, ollama: () => ({ ...ollama, chatModel: ollama.madreModel ?? ollama.chatModel }), fetchImpl: reportFetch }) : null);
|
|
515
|
+
}
|
|
237
516
|
const ollamaSettings = async () => ({ enabled: true, embeddings: true, archivist: true, agent: true, ...((await readConfig(root)).modules?.ollama ?? {}) });
|
|
517
|
+
// Whether this project uses the local AI at all, as Home decides it (src/local-ai.mjs).
|
|
518
|
+
const thisRoomId = projectRoomId(canonicalProjectRoot);
|
|
519
|
+
const localAiHere = async () => localAiFor(await readConfig(root), thisRoomId, { home: salasRoom });
|
|
520
|
+
// This project's own switch, outside «auto»; null when Home decides.
|
|
521
|
+
async function setLocalAiHere(on) {
|
|
522
|
+
if (salasRoom || !(await localAiHere()).editable) return null;
|
|
523
|
+
await updateConfig(root, localAiPatch(await readConfig(root), { rooms: { [thisRoomId]: Boolean(on) } }));
|
|
524
|
+
return wireOllama({ probe: false });
|
|
525
|
+
}
|
|
238
526
|
// What the TRAIN card needs: where the recipe ships, the room, the model name @madre will pick up, a base that fits this machine.
|
|
239
527
|
function trainingInfo() {
|
|
240
528
|
const slug = basename(canonicalProjectRoot).toLowerCase().replace(/[^a-z0-9]+/g, '-');
|
|
@@ -247,7 +535,9 @@ export async function createPulseServer({
|
|
|
247
535
|
const slug = basename(canonicalProjectRoot).toLowerCase().replace(/[^a-z0-9]+/g, '-');
|
|
248
536
|
const trained = (ollama.models ?? []).map((model) => model.name).find((name) => name.startsWith(`madre-${slug}`)) ?? null;
|
|
249
537
|
ollama = { ...ollama, madreModel: trained };
|
|
250
|
-
const
|
|
538
|
+
const project = await localAiHere();
|
|
539
|
+
// Off for this project reads exactly as the module switched off, and only here.
|
|
540
|
+
const settings = { ...(await ollamaSettings()), enabled: project.on };
|
|
251
541
|
const useEmbeddings = ollama.running && settings.enabled && settings.embeddings && ollama.embedModel;
|
|
252
542
|
const useArchivist = ollama.running && settings.enabled && settings.archivist && ollama.chatModel;
|
|
253
543
|
if (memory) {
|
|
@@ -255,7 +545,7 @@ export async function createPulseServer({
|
|
|
255
545
|
const embedder = createEmbedder({ key, env: embedEnvFor(await memoryConfig()), ollama: useEmbeddings ? ollama : null, ollamaEmbedder });
|
|
256
546
|
if (room) room.setEmbedder(embedder); else memory.attachEmbedder(embedder);
|
|
257
547
|
}
|
|
258
|
-
|
|
548
|
+
localWiring.archivist = useArchivist ? ollamaInvoker({ host: ollama.host, model: ollama.chatModel, fetchImpl: reportFetch }) : null;
|
|
259
549
|
// @madre: the fifth agent, present whenever Ollama has a chat model and the human left it on.
|
|
260
550
|
const fifth = madreAgent({ ...ollama, chatModel: ollama.madreModel ?? ollama.chatModel }, { enabled: settings.enabled && settings.agent !== false });
|
|
261
551
|
const index = agents.findIndex((agent) => agent.id === MADRE_AGENT_ID);
|
|
@@ -263,8 +553,10 @@ export async function createPulseServer({
|
|
|
263
553
|
if (fifth.ready && index < 0) { agents.push(fifth); changed = true; }
|
|
264
554
|
else if (fifth.ready && (agents[index].version !== fifth.version)) { Object.assign(agents[index], fifth); changed = true; }
|
|
265
555
|
else if (!fifth.ready && index >= 0) { agents.splice(index, 1); changed = true; }
|
|
556
|
+
localWiring.madre = fifth.ready;
|
|
557
|
+
wireLocal(room);
|
|
558
|
+
for (const held of parked.values()) wireLocal(held.room);
|
|
266
559
|
if (room) {
|
|
267
|
-
room.setInvoker(MADRE_ADAPTER, fifth.ready ? madreInvoker({ memory, ollama: () => ({ ...ollama, chatModel: ollama.madreModel ?? ollama.chatModel }), fetchImpl: reportFetch }) : null);
|
|
268
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 room · ${fifth.version}` : '@madre left the room: Ollama has no chat model running' });
|
|
269
561
|
// That a local model is running is not the same as it being of use on this project, and
|
|
270
562
|
// the second thing is the one a person needs told. Said once per model, in the room, with
|
|
@@ -280,7 +572,7 @@ export async function createPulseServer({
|
|
|
280
572
|
});
|
|
281
573
|
}
|
|
282
574
|
}
|
|
283
|
-
return { ...ollama, settings, embeddings: Boolean(useEmbeddings), archivist: Boolean(useArchivist), agent: fifth.ready };
|
|
575
|
+
return { ...ollama, settings, project, embeddings: Boolean(useEmbeddings), archivist: Boolean(useArchivist), agent: fifth.ready };
|
|
284
576
|
}
|
|
285
577
|
// Memory settings the human keeps in config.json (MU/TH/UR → MEMORY); environment still wins at launch.
|
|
286
578
|
const memoryConfig = async () => ({ archivist: 'auto', archivists: null, every: 10, idleMinutes: 10, embedProvider: 'auto', recallShare: 0.3, cascade: true, ...((await readConfig(root)).memory ?? {}) });
|
|
@@ -298,25 +590,63 @@ export async function createPulseServer({
|
|
|
298
590
|
// file, so a deleted or edited .pulse/mother.env is noticed.
|
|
299
591
|
const mother = new MotherChannel(join(canonicalProjectRoot, '.pulse', 'mother.env'), { meta: memory ? { get: (key) => memory.metaGet(key), set: (key, value) => memory.metaSet(key, value) } : null });
|
|
300
592
|
const motherOutcome = await mother.load().catch((error) => { console.error(`MADRE could not open MOTHER's channel: ${error.message}`); return null; });
|
|
301
|
-
|
|
593
|
+
// What the human last set for memory: a conversation opened later distills with it, not with
|
|
594
|
+
// what MADRE booted with.
|
|
595
|
+
let memorySettings = await memoryConfig();
|
|
596
|
+
// Which key unlocks #3 and #4, and for how long an accepted one holds. See src/override.mjs.
|
|
597
|
+
let overrideConfig = overrideSettings((await readConfig(root)).override);
|
|
598
|
+
// The passes the key gives (2026-10-12). The page asks for the key, the project checks it and
|
|
599
|
+
// hands back a pass for that agent and that mode; a message at #3 or #4 goes only with one. Without
|
|
600
|
+
// a remembered permission a pass opens one message; with one, any until it runs out. STOP ALL and
|
|
601
|
+
// a restart take them all back. Wrong keys are counted: a program trying words is slowed down.
|
|
602
|
+
const passes = new Map(); // token -> { agent, mode, until, once }
|
|
603
|
+
const keyMisses = []; // when the last wrong keys were typed
|
|
604
|
+
const PASS_ONCE_MS = 30 * 60 * 1000;
|
|
605
|
+
function issuePass(agent, mode) {
|
|
606
|
+
const minutes = overrideConfig.grace[mode] ?? 0;
|
|
607
|
+
const token = randomBytes(24).toString('hex');
|
|
608
|
+
const pass = { agent, mode, until: Date.now() + (minutes ? minutes * 60000 : PASS_ONCE_MS), once: !minutes };
|
|
609
|
+
passes.set(token, pass);
|
|
610
|
+
return { token, until: new Date(pass.until).toISOString(), once: pass.once };
|
|
611
|
+
}
|
|
612
|
+
function spendPass(token, agent, mode) {
|
|
613
|
+
const pass = typeof token === 'string' ? passes.get(token) : null;
|
|
614
|
+
if (!pass || pass.agent !== agent || pass.mode < mode) return false;
|
|
615
|
+
if (pass.until < Date.now()) { passes.delete(token); return false; }
|
|
616
|
+
if (pass.once) passes.delete(token);
|
|
617
|
+
return true;
|
|
618
|
+
}
|
|
302
619
|
let listening = null;
|
|
303
620
|
const listeningPort = () => listening?.address?.()?.port ?? Number(process.env.PULSE_PORT ?? 4317);
|
|
304
|
-
|
|
621
|
+
// One CONTROL desk for the whole project, shared by every conversation alive at once: a single
|
|
622
|
+
// holder of the working tree whichever chat asked, and an UNDO that works from any of them.
|
|
623
|
+
const controlDesk = new ControlDesk({ projectRoot, guardRecord: join(roomDir, 'guard.json') });
|
|
624
|
+
// The open conversation's record unless another is named: a conversation held open beside it
|
|
625
|
+
// builds its room the same way, over its own record.
|
|
626
|
+
const roomOptions = (own = {}) => ({
|
|
305
627
|
store,
|
|
306
628
|
agents,
|
|
307
629
|
projectRoot,
|
|
630
|
+
controlDesk,
|
|
308
631
|
privacy,
|
|
309
632
|
// Modules may hand tools to a turn; the room asks once per turn, the server knows the port.
|
|
310
633
|
toolsForTurn: async (turn) => modulesToolsForTurn(await moduleContext(), { ...turn, port: listeningPort(), roomDir }),
|
|
634
|
+
// What BISHOP reads for a turn is recorded in that turn's conversation, not the open one.
|
|
635
|
+
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
|
+
// Fase 3e: notes a link with automatic recall brings to a turn. Never in the room of Salas
|
|
637
|
+
// (BISHOP has its own); recorded in the conversation of the turn that read them.
|
|
638
|
+
linkedForTurn: salasRoom ? null : async (turn) => recallForTurn(await moduleContext(), { ...turn, port: listeningPort(), roomDir,
|
|
639
|
+
record: (type, payload) => ((turn.turn && liveRooms().find((one) => one.runningTurn?.(turn.turn))) || room).record(type, payload) }),
|
|
311
640
|
softTokenBudget,
|
|
312
641
|
contextMaxChars,
|
|
313
|
-
recallShare: Number(process.env.PULSE_RECALL_SHARE ??
|
|
314
|
-
cascade: process.env.PULSE_RECALL_CASCADE !== undefined ? process.env.PULSE_RECALL_CASCADE !== '0' :
|
|
642
|
+
recallShare: Number(process.env.PULSE_RECALL_SHARE ?? memorySettings.recallShare),
|
|
643
|
+
cascade: process.env.PULSE_RECALL_CASCADE !== undefined ? process.env.PULSE_RECALL_CASCADE !== '0' : memorySettings.cascade !== false,
|
|
315
644
|
distill: {
|
|
316
|
-
every: Number(process.env.PULSE_DISTILL_EVERY ??
|
|
317
|
-
idleMs: Number(process.env.PULSE_DISTILL_IDLE_MS ??
|
|
318
|
-
agent: process.env.PULSE_DISTILL_AGENT ?? (
|
|
319
|
-
allowed: Array.isArray(
|
|
645
|
+
every: Number(process.env.PULSE_DISTILL_EVERY ?? memorySettings.every),
|
|
646
|
+
idleMs: Number(process.env.PULSE_DISTILL_IDLE_MS ?? memorySettings.idleMinutes * 60000),
|
|
647
|
+
agent: process.env.PULSE_DISTILL_AGENT ?? (memorySettings.archivist && memorySettings.archivist !== 'auto' ? memorySettings.archivist : null),
|
|
648
|
+
allowed: Array.isArray(memorySettings.archivists) && memorySettings.archivists.length ? memorySettings.archivists : null,
|
|
649
|
+
...(typeof memorySettings.enabled === 'boolean' ? { enabled: memorySettings.enabled } : {}),
|
|
320
650
|
},
|
|
321
651
|
memory,
|
|
322
652
|
memoryServer,
|
|
@@ -325,8 +655,11 @@ export async function createPulseServer({
|
|
|
325
655
|
invokers,
|
|
326
656
|
agentTimeouts,
|
|
327
657
|
maxMessageChars,
|
|
328
|
-
|
|
658
|
+
// S-032: in the room of Salas nobody delegates. BISHOP speaks there, and what it sets in
|
|
659
|
+
// motion goes through its own plans, never through a ```pulse plan of that room.
|
|
660
|
+
delegation: salasRoom ? false : delegation,
|
|
329
661
|
maxPlanSteps,
|
|
662
|
+
...own,
|
|
330
663
|
});
|
|
331
664
|
room = new Room(roomOptions());
|
|
332
665
|
await wireOllama({ probe: false });
|
|
@@ -360,6 +693,7 @@ export async function createPulseServer({
|
|
|
360
693
|
memory: memorySettingsView(),
|
|
361
694
|
updates: { check: updatesEnabled(), envWins: process.env.PULSE_UPDATE_CHECK !== undefined },
|
|
362
695
|
privacy: { terms: privacy.terms, marker: privacy.marker, envWins: privacySettings({}, process.env).envWins, ...privacy.guards },
|
|
696
|
+
override: publicOverride(overrideConfig),
|
|
363
697
|
};
|
|
364
698
|
}
|
|
365
699
|
// What MU/TH/UR shows under MEMORY: the live values, who could distil, and what embeds today.
|
|
@@ -381,6 +715,11 @@ export async function createPulseServer({
|
|
|
381
715
|
config.opencode = { model: patch.opencode.model || undefined };
|
|
382
716
|
if (patch.opencode.model) process.env.PULSE_OPENCODE_MODEL = String(patch.opencode.model); else delete process.env.PULSE_OPENCODE_MODEL;
|
|
383
717
|
}
|
|
718
|
+
// The providers left out of the composer's chooser: ids only, the way OpenCode names them.
|
|
719
|
+
if (patch.opencode && 'hidden' in patch.opencode) {
|
|
720
|
+
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.'));
|
|
721
|
+
config.opencode = { ...(config.opencode ?? {}), hidden: [...new Set(patch.opencode.hidden)] };
|
|
722
|
+
}
|
|
384
723
|
if (patch.timeouts) {
|
|
385
724
|
const timeouts = { ...room.settings().agentTimeouts };
|
|
386
725
|
for (const [key, value] of Object.entries(patch.timeouts)) {
|
|
@@ -395,7 +734,7 @@ export async function createPulseServer({
|
|
|
395
734
|
}
|
|
396
735
|
if (patch.room) {
|
|
397
736
|
config.room = { ...((await readConfig(root)).room ?? {}) };
|
|
398
|
-
if (typeof patch.room.delegation === 'boolean') { config.room.delegation = patch.room.delegation; live.delegation = patch.room.delegation; }
|
|
737
|
+
if (typeof patch.room.delegation === 'boolean') { config.room.delegation = patch.room.delegation; live.delegation = salasRoom ? false : patch.room.delegation; }
|
|
399
738
|
if (Number(patch.room.maxPlanSteps) > 0) { config.room.maxPlanSteps = Number(patch.room.maxPlanSteps); live.maxPlanSteps = Number(patch.room.maxPlanSteps); }
|
|
400
739
|
if (Number(patch.room.softTokenBudget) > 0) { config.room.softTokenBudget = Number(patch.room.softTokenBudget); live.softTokenBudget = Number(patch.room.softTokenBudget); }
|
|
401
740
|
}
|
|
@@ -412,7 +751,9 @@ export async function createPulseServer({
|
|
|
412
751
|
if (typeof m.cascade === 'boolean') next.cascade = m.cascade;
|
|
413
752
|
if (typeof m.enabled === 'boolean') next.enabled = m.enabled;
|
|
414
753
|
config.memory = next;
|
|
415
|
-
|
|
754
|
+
memorySettings = next;
|
|
755
|
+
const distill = { every: next.every, idleMs: next.idleMinutes * 60000, agent: next.archivist === 'auto' ? null : next.archivist, allowed: next.archivists, recallShare: next.recallShare, cascade: next.cascade !== false, ...(typeof next.enabled === 'boolean' ? { enabled: next.enabled } : {}) };
|
|
756
|
+
for (const each of liveRooms()) each.configureDistill(distill);
|
|
416
757
|
live.memory = true;
|
|
417
758
|
}
|
|
418
759
|
if (patch.scopes && typeof patch.scopes === 'object') {
|
|
@@ -426,14 +767,18 @@ export async function createPulseServer({
|
|
|
426
767
|
if (Number.isInteger(Number(scopes.maxMode)) && Number(scopes.maxMode) >= 0 && Number(scopes.maxMode) <= 4) { config.scopes[agentId].maxMode = Number(scopes.maxMode); delete config.scopes[agentId].write; }
|
|
427
768
|
if (Number.isInteger(Number(scopes.defaultMode)) && Number(scopes.defaultMode) >= 1 && Number(scopes.defaultMode) <= 2) { config.scopes[agentId].defaultMode = Number(scopes.defaultMode); delete config.scopes[agentId].alwaysCreate; }
|
|
428
769
|
}
|
|
429
|
-
|
|
770
|
+
for (const each of liveRooms()) each.setScopes(config.scopes);
|
|
771
|
+
}
|
|
772
|
+
if (patch.override && typeof patch.override === 'object') {
|
|
773
|
+
overrideConfig = mergeOverride((await readConfig(root)).override, patch.override);
|
|
774
|
+
config.override = overrideConfig;
|
|
430
775
|
}
|
|
431
776
|
if (patch.gemini) {
|
|
432
777
|
config.gemini = { ...((await readConfig(root)).gemini ?? {}) };
|
|
433
778
|
if (Number(patch.gemini.idleMs) > 0) { config.gemini.idleMs = Number(patch.gemini.idleMs); process.env.PULSE_GEMINI_IDLE_MS = String(Number(patch.gemini.idleMs)); }
|
|
434
779
|
if (Number.isFinite(Number(patch.gemini.retries))) { config.gemini.retries = Number(patch.gemini.retries); process.env.PULSE_GEMINI_RETRIES = String(Number(patch.gemini.retries)); }
|
|
435
780
|
}
|
|
436
|
-
|
|
781
|
+
for (const each of liveRooms()) each.configure(live);
|
|
437
782
|
await updateConfig(root, config);
|
|
438
783
|
if (live.memory) await wireOllama({ probe: false });
|
|
439
784
|
await room.record('room.settings', { changed: Object.keys(config), settings: effectiveSettings() });
|
|
@@ -441,34 +786,75 @@ export async function createPulseServer({
|
|
|
441
786
|
}
|
|
442
787
|
|
|
443
788
|
let loggingIn = null;
|
|
444
|
-
|
|
789
|
+
// A sign-in in flight, as the room is allowed to see it: which door, the address to open, and
|
|
790
|
+
// whether the CLI is waiting to be told the code that page gave back. Never the code itself.
|
|
791
|
+
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);
|
|
793
|
+
|
|
794
|
+
async function loginAgent(id, { method = null } = {}) {
|
|
445
795
|
const agent = agents.find((item) => item.id === id);
|
|
446
796
|
if (!agent) return { status: 404, body: { error: `Unknown agent: ${id}.` } };
|
|
447
797
|
if (!agent.detected) return { status: 412, body: { error: `${agent.label} is not installed on this computer.`, install: loginPlanFor(agent)?.install ?? [] } };
|
|
448
|
-
const plan = loginRunners[id]?.(agent) ?? loginPlanFor(agent);
|
|
798
|
+
const plan = loginRunners[id]?.(agent, { method }) ?? loginPlanFor(agent, { method });
|
|
449
799
|
if (!plan) return { status: 404, body: { error: `No sign-in flow known for ${id}.` } };
|
|
450
800
|
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 } };
|
|
451
801
|
if (loggingIn) return { status: 409, body: { error: `A sign-in is already running (${loggingIn}).` } };
|
|
452
802
|
loggingIn = id;
|
|
453
|
-
|
|
803
|
+
loginRun = { agent: id, method: plan.method ?? null, command: plan.display, url: null, asksForCode: Boolean(plan.asksForCode), awaitingCode: false, startedAt: Date.now(), child: null };
|
|
804
|
+
await room.record('connection.login.started', { agent: id, command: plan.display, note: plan.note, method: plan.method ?? null });
|
|
454
805
|
void (async () => {
|
|
455
806
|
const lines = [];
|
|
456
807
|
const result = await runInstaller({
|
|
457
808
|
command: plan.command,
|
|
458
809
|
args: plan.args,
|
|
459
810
|
projectRoot: canonicalProjectRoot,
|
|
460
|
-
|
|
811
|
+
// A person is in the middle of this: the browser, the account, the code. Five minutes was
|
|
812
|
+
// the installer's clock, and it killed sign-ins that were only waiting on a human.
|
|
813
|
+
timeoutMs: 900000,
|
|
814
|
+
// A CLI that asks for a code needs a way to be answered, and its question never ends in a
|
|
815
|
+
// 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,
|
|
818
|
+
quietNote: 'the sign-in is waiting on you: finish it in the page that opened',
|
|
819
|
+
onChild: (handle) => { if (loginRun?.agent === id) loginRun.child = handle; },
|
|
461
820
|
onLine: (line) => {
|
|
462
821
|
lines.push(line);
|
|
463
822
|
const url = line.match(/https?:\/\/\S+/)?.[0] ?? null;
|
|
823
|
+
if (loginRun?.agent === id) {
|
|
824
|
+
if (url) loginRun.url = url;
|
|
825
|
+
if (plan.asksForCode && looksLikeCodePrompt(line)) loginRun.awaitingCode = true;
|
|
826
|
+
}
|
|
464
827
|
void room.record('connection.login.output', { agent: id, line: line.slice(0, 500), url });
|
|
465
828
|
},
|
|
466
829
|
});
|
|
467
830
|
await refreshSessions();
|
|
468
831
|
await room.record('connection.login.finished', { agent: id, code: result.code, error: result.error ?? null, session: sessions[id] ?? null, tail: lines.slice(-6) });
|
|
469
832
|
loggingIn = null;
|
|
833
|
+
loginRun = null;
|
|
470
834
|
})();
|
|
471
|
-
return { status: 202, body: { accepted: true, command: plan.display } };
|
|
835
|
+
return { status: 202, body: { accepted: true, command: plan.display, method: plan.method ?? null, asksForCode: Boolean(plan.asksForCode) } };
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// The second half of a browser sign-in: the page hands back a code and the CLI is waiting for
|
|
839
|
+
// it on its own input. The room types it in. The code is a credential for the minute it lives,
|
|
840
|
+
// so it is taken from this computer only and never written to the ledger.
|
|
841
|
+
async function answerLogin(id, code) {
|
|
842
|
+
if (!loginRun || loginRun.agent !== id) return { status: 409, body: { error: 'No sign-in is waiting for a code.' } };
|
|
843
|
+
if (!loginRun.child?.write) return { status: 409, body: { error: 'That sign-in cannot be answered from here.' } };
|
|
844
|
+
const text = String(code ?? '').trim();
|
|
845
|
+
if (!text) return { status: 400, body: { error: 'Paste the code the page gave you.' } };
|
|
846
|
+
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.' } };
|
|
847
|
+
loginRun.child.write(`${text}\n`);
|
|
848
|
+
loginRun.awaitingCode = false;
|
|
849
|
+
await room.record('connection.login.answered', { agent: id });
|
|
850
|
+
return { status: 202, body: { accepted: true } };
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
async function cancelLogin(id) {
|
|
854
|
+
if (!loginRun || loginRun.agent !== id) return { status: 409, body: { error: 'No sign-in is running for that agent.' } };
|
|
855
|
+
loginRun.child?.cancel?.();
|
|
856
|
+
await room.record('connection.login.cancelled', { agent: id });
|
|
857
|
+
return { status: 202, body: { cancelled: true } };
|
|
472
858
|
}
|
|
473
859
|
|
|
474
860
|
// A CLI installed from the room appears without restarting: detection runs again, the roster
|
|
@@ -571,53 +957,54 @@ export async function createPulseServer({
|
|
|
571
957
|
await quotaMonitor.start();
|
|
572
958
|
// SSE fan-out works from the durable log, not from in-memory emits, so events
|
|
573
959
|
// appended by another MADRE process on the same room reach open pages too.
|
|
574
|
-
// `clients` maps each SSE response to the last
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
//
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
960
|
+
// A feed is one record and the pages watching it: `clients` maps each SSE response to the last
|
|
961
|
+
// sequence it already holds, `sent` is the last sequence broadcast and `offset` the byte offset
|
|
962
|
+
// of the log already read. The open conversation has one; a conversation held open beside it
|
|
963
|
+
// (the window split in two) has its own, so neither page hears the other's record.
|
|
964
|
+
const feedOver = async (source, events) => ({ store: source, clients: new Map(), sent: events.at(-1)?.sequence ?? 0, offset: (await source.tail(0)).offset, inFlight: null, dirty: false });
|
|
965
|
+
const mainFeed = await feedOver(store, historicalEvents);
|
|
966
|
+
const clients = mainFeed.clients;
|
|
581
967
|
// A client that stops draining is dropped instead of buffering without bound.
|
|
582
|
-
const writeEvent = (client, event) => {
|
|
968
|
+
const writeEvent = (client, event, feed = mainFeed) => {
|
|
583
969
|
// Ghost events have no sequence: they are never in the log, so no `id:`
|
|
584
970
|
// line, and a reconnecting client will not ask for them again.
|
|
585
971
|
client.write(`${event.sequence ? `id: ${event.sequence}\n` : ''}data: ${JSON.stringify(event)}\n\n`);
|
|
586
972
|
if (client.writableLength > sseMaxBufferedBytes) {
|
|
587
|
-
clients.delete(client);
|
|
973
|
+
feed.clients.delete(client);
|
|
588
974
|
client.destroy();
|
|
589
975
|
return false;
|
|
590
976
|
}
|
|
591
977
|
return true;
|
|
592
978
|
};
|
|
593
979
|
|
|
594
|
-
function
|
|
595
|
-
if (inFlight) {
|
|
596
|
-
dirty = true;
|
|
597
|
-
return inFlight;
|
|
980
|
+
function pump(feed) {
|
|
981
|
+
if (feed.inFlight) {
|
|
982
|
+
feed.dirty = true;
|
|
983
|
+
return feed.inFlight;
|
|
598
984
|
}
|
|
599
|
-
inFlight = (async () => {
|
|
985
|
+
feed.inFlight = (async () => {
|
|
600
986
|
try {
|
|
601
987
|
do {
|
|
602
|
-
dirty = false;
|
|
603
|
-
const { events, offset } = await store.tail(
|
|
604
|
-
|
|
988
|
+
feed.dirty = false;
|
|
989
|
+
const { events, offset } = await feed.store.tail(feed.offset);
|
|
990
|
+
feed.offset = offset;
|
|
605
991
|
for (const event of events) {
|
|
606
|
-
if (event.sequence <=
|
|
607
|
-
for (const [client, floor] of clients) {
|
|
608
|
-
if (event.sequence > floor) writeEvent(client, event);
|
|
992
|
+
if (event.sequence <= feed.sent) continue;
|
|
993
|
+
for (const [client, floor] of feed.clients) {
|
|
994
|
+
if (event.sequence > floor) writeEvent(client, event, feed);
|
|
609
995
|
}
|
|
610
|
-
|
|
996
|
+
feed.sent = event.sequence;
|
|
611
997
|
}
|
|
612
|
-
} while (dirty);
|
|
998
|
+
} while (feed.dirty);
|
|
613
999
|
} catch (error) {
|
|
614
1000
|
console.error(`MADRE broadcast error: ${error.message}`);
|
|
615
1001
|
} finally {
|
|
616
|
-
inFlight = null;
|
|
1002
|
+
feed.inFlight = null;
|
|
617
1003
|
}
|
|
618
1004
|
})();
|
|
619
|
-
return inFlight;
|
|
1005
|
+
return feed.inFlight;
|
|
620
1006
|
}
|
|
1007
|
+
const broadcastPending = () => pump(mainFeed);
|
|
621
1008
|
let unsubscribe = () => {};
|
|
622
1009
|
|
|
623
1010
|
// The error sentinel: failures MU/TH/UR cannot classify, and crashes, become
|
|
@@ -665,8 +1052,69 @@ export async function createPulseServer({
|
|
|
665
1052
|
// Everything that listens to the open conversation, attached in one place so that opening
|
|
666
1053
|
// another attaches exactly the same things to it. Live readings go straight to whoever is
|
|
667
1054
|
// watching and are never stored, so a page that opens later simply never sees them.
|
|
1055
|
+
// What waits for the human in a conversation (docs/MOBILE.md, MOV-004): a choice an agent offered
|
|
1056
|
+
// or a line it asked to run, since the human last spoke; a mode a plan asked for, until it is
|
|
1057
|
+
// decided. Kept as the room speaks, so the list of rooms on a phone never reads a ledger to know.
|
|
1058
|
+
// What was left unanswered half a day ago is not waiting for anybody any more.
|
|
1059
|
+
const asksByRoom = new WeakMap();
|
|
1060
|
+
const ASK_FRESH_MS = 12 * 60 * 60 * 1000;
|
|
1061
|
+
function trackAsks(target, history = []) {
|
|
1062
|
+
const asks = new Map();
|
|
1063
|
+
const feed = (event) => {
|
|
1064
|
+
const said = event?.payload ?? {};
|
|
1065
|
+
// The human answering clears what waited; a message BISHOP's channel brought is not the human.
|
|
1066
|
+
if (event?.type === 'message.created' && said.role === 'user' && said.via !== 'bishop') { for (const key of [...asks.keys()]) asks.delete(key); }
|
|
1067
|
+
else if (event?.type === 'choice.offered') asks.set(`choice:${said.responseMessageId}`, { kind: 'choice', agent: said.agent, text: said.question ?? null, at: event.timestamp });
|
|
1068
|
+
else if (event?.type === 'run.requested') {
|
|
1069
|
+
for (const [index, command] of (said.commands ?? []).entries()) {
|
|
1070
|
+
if (command.argv) asks.set(`run:${said.responseMessageId}:${index}`, { kind: 'run', agent: said.agent, text: command.line, at: event.timestamp });
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
else if (event?.type === 'run.started') asks.delete(`run:${said.responseMessageId}:${said.index}`);
|
|
1074
|
+
// BISHOP (fase 4c): a #3/#4 proposal waits for the human's key in this room; in Salas, a
|
|
1075
|
+
// plan with something to press waits until it closes.
|
|
1076
|
+
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 });
|
|
1077
|
+
else if (event?.type === 'bishop.plan.proposed' && [...(said.orders ?? []), ...(said.links ?? [])].some((item) => item.status === 'ready')) asks.set(`plan:${said.plan}`, { kind: 'plan', agent: 'bishop', text: said.reason ?? null, at: event.timestamp });
|
|
1078
|
+
else if (event?.type === 'bishop.plan.closed') asks.delete(`plan:${said.plan}`);
|
|
1079
|
+
};
|
|
1080
|
+
for (const event of history) feed(event);
|
|
1081
|
+
asksByRoom.set(target, asks);
|
|
1082
|
+
return target.subscribe(feed);
|
|
1083
|
+
}
|
|
1084
|
+
function asksOf(target, now = Date.now()) {
|
|
1085
|
+
const modes = (target.pendingModes?.() ?? []).map((asked) => ({ kind: 'mode', agent: asked.agent, mode: asked.mode, text: null }));
|
|
1086
|
+
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)));
|
|
1087
|
+
return [...modes, ...kept];
|
|
1088
|
+
}
|
|
1089
|
+
let unsubscribeAsks = () => {};
|
|
1090
|
+
// What a conversation's name and preview show of a message. One BISHOP's channel brought starts
|
|
1091
|
+
// with the header its agent reads; the person sees the task, as the page shows it.
|
|
1092
|
+
const saidText = (payload) => (payload?.via === 'bishop' ? String(payload.text ?? '').replace(/^BISHOP › [^\n]*\n+/, '') : payload?.text);
|
|
1093
|
+
// What a module hears of the ledger (`onEvent`, docs/SDK.md; S-027 in docs/MACROVERSO.md): each
|
|
1094
|
+
// event of each conversation alive in this room, once, with that conversation's room and id.
|
|
1095
|
+
// A module hears only while it is on, gets the same context as everywhere else, and can only
|
|
1096
|
+
// record what `record()` already allows (never a message, never a turn). What it does with an
|
|
1097
|
+
// event is its own business and never holds up the room: a failure is logged and dropped.
|
|
1098
|
+
function wireModules(target, id) {
|
|
1099
|
+
return target.subscribe((event) => {
|
|
1100
|
+
const listening = MODULES.filter((module) => typeof module.onEvent === 'function');
|
|
1101
|
+
if (!listening.length) return;
|
|
1102
|
+
void (async () => {
|
|
1103
|
+
const ctx = await moduleContext();
|
|
1104
|
+
for (const module of listening) {
|
|
1105
|
+
const settings = module.settingsFrom(ctx.config);
|
|
1106
|
+
if (module.kind === 'builtin' && !settings.enabled) continue;
|
|
1107
|
+
await Promise.resolve(module.onEvent({ ...ctx, settings, room: target, chat: id, record: (type, payload) => target.record(type, payload) }, event))
|
|
1108
|
+
.catch((error) => console.error(`MADRE module ${module.id}: onEvent failed: ${error.message}`));
|
|
1109
|
+
}
|
|
1110
|
+
})().catch((error) => console.error(`MADRE: a module could not hear an event: ${error.message}`));
|
|
1111
|
+
});
|
|
1112
|
+
}
|
|
1113
|
+
let unsubscribeModules = () => {};
|
|
668
1114
|
function wireRoom() {
|
|
669
1115
|
unsubscribe = room.subscribe(() => { void broadcastPending(); });
|
|
1116
|
+
unsubscribeModules = wireModules(room, chatId);
|
|
1117
|
+
unsubscribeAsks = trackAsks(room, historicalEvents);
|
|
670
1118
|
unsubscribeSentinel = room.subscribe((event) => { void sentinel.observe(event).catch(() => null); });
|
|
671
1119
|
unsubscribeEyecat = room.subscribe((event) => { void eyecat.observe(event).catch(() => null); });
|
|
672
1120
|
unsubscribeGhost = room.subscribeGhost((event) => { for (const client of clients.keys()) writeEvent(client, event); });
|
|
@@ -676,8 +1124,9 @@ export async function createPulseServer({
|
|
|
676
1124
|
unsubscribeChats = room.subscribe((event) => {
|
|
677
1125
|
if (event?.type !== 'message.created' || event.ghost) return;
|
|
678
1126
|
const human = event.payload?.role === 'user';
|
|
679
|
-
chatWrites = chatWrites.then(() => touchChat(roomDir, chatId, { text: human ? event.payload
|
|
1127
|
+
chatWrites = chatWrites.then(() => touchChat(roomDir, chatId, { text: human ? saidText(event.payload) : null, counts: human })).catch(() => null);
|
|
680
1128
|
});
|
|
1129
|
+
unsubscribeActivity = watchActivity(room, chatId);
|
|
681
1130
|
sentinel.seed(historicalEvents);
|
|
682
1131
|
eyecat.seed(historicalEvents);
|
|
683
1132
|
room.restoreAttachments(historicalEvents
|
|
@@ -685,40 +1134,240 @@ export async function createPulseServer({
|
|
|
685
1134
|
.map((event) => ({ ...event.payload, path: join(attachmentsRoot, event.payload.fileName), dir: attachmentsRoot })));
|
|
686
1135
|
}
|
|
687
1136
|
function unwireRoom() {
|
|
688
|
-
for (const off of [unsubscribe, unsubscribeSentinel, unsubscribeEyecat, unsubscribeGhost, unsubscribeLive, unsubscribeChats]) { try { off(); } catch { /* already gone */ } }
|
|
689
|
-
unsubscribe = unsubscribeSentinel = unsubscribeEyecat = unsubscribeGhost = unsubscribeLive = unsubscribeChats = () => {};
|
|
1137
|
+
for (const off of [unsubscribe, unsubscribeSentinel, unsubscribeEyecat, unsubscribeGhost, unsubscribeLive, unsubscribeChats, unsubscribeActivity, unsubscribeAsks, unsubscribeModules]) { try { off(); } catch { /* already gone */ } }
|
|
1138
|
+
unsubscribe = unsubscribeSentinel = unsubscribeEyecat = unsubscribeGhost = unsubscribeLive = unsubscribeChats = unsubscribeActivity = unsubscribeModules = () => {};
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
// TURNS: how much the room may do at once. On by default. Several conversations may be at work
|
|
1142
|
+
// together (a turn that is taking its time no longer holds the human hostage) and each may run a
|
|
1143
|
+
// few turns, both within limits the human sets in MU/TH/UR. Off, the room is what it was: one
|
|
1144
|
+
// conversation drives the crew, and switching away from one that is working is refused.
|
|
1145
|
+
const turnsPolicy = async () => {
|
|
1146
|
+
const saved = (await readConfig(root)).turns ?? {};
|
|
1147
|
+
const clamp = (value, low, high, fallback) => (Number.isInteger(Number(value)) ? Math.min(high, Math.max(low, Number(value))) : fallback);
|
|
1148
|
+
// Three conversations by default: as many as fit on screen at once (the open one and two beside
|
|
1149
|
+
// it, MAX_BESIDE in app.js), so every one the human can see may be at work.
|
|
1150
|
+
return { enabled: saved.enabled !== false, chats: clamp(saved.chats, 1, 6, 3), turnsPerChat: clamp(saved.turnsPerChat, 1, 8, 3) };
|
|
1151
|
+
};
|
|
1152
|
+
// Conversations held open beside the one on screen. Two reasons to hold one: it is still at work
|
|
1153
|
+
// after the human moved to another (parked: it keeps writing its record, and the page only hears
|
|
1154
|
+
// that it is working and when a turn ends), or a page is watching it in the other half of the
|
|
1155
|
+
// window (it has a feed of its own and takes messages addressed to it). One room per record,
|
|
1156
|
+
// always: opening a held conversation takes the room that is already there. When nothing is
|
|
1157
|
+
// running in it and nobody is watching, it closes itself.
|
|
1158
|
+
const busyChats = () => [...parked.entries()].filter(([, held]) => held.room.busy()).map(([id]) => id);
|
|
1159
|
+
const sayToAll = (type, payload) => {
|
|
1160
|
+
const event = { id: `live-${randomUUID()}`, sequence: null, live: true, timestamp: new Date().toISOString(), type, payload };
|
|
1161
|
+
for (const client of clients.keys()) writeEvent(client, event);
|
|
1162
|
+
};
|
|
1163
|
+
// The servers sentinel: this project's servers, watched while a page is open and RIPLEY is on,
|
|
1164
|
+
// with what comes up and what goes down said live to every page and to no ledger.
|
|
1165
|
+
const serverSentinel = new ServerSentinel({
|
|
1166
|
+
projectRoot: canonicalProjectRoot,
|
|
1167
|
+
self: () => listening?.address?.()?.port ?? null,
|
|
1168
|
+
listen: async () => (await execFileAsync('lsof', ['-nP', '-iTCP', '-sTCP:LISTEN'], { timeout: 4000, maxBuffer: 2 * 1024 * 1024 })).stdout,
|
|
1169
|
+
// lsof exits 1 when one of the processes is already gone, and still prints the others.
|
|
1170
|
+
cwdsOf: (pids) => execFileAsync('lsof', ['-a', '-p', pids.join(','), '-d', 'cwd', '-Fpn'], { timeout: 4000, maxBuffer: 1024 * 1024 })
|
|
1171
|
+
.then((result) => result.stdout, (error) => String(error.stdout ?? '')),
|
|
1172
|
+
probe: (port) => probePort(port, { fetchImpl: reportFetch }),
|
|
1173
|
+
isRoom: async (port) => {
|
|
1174
|
+
const answer = await reportFetch(`http://127.0.0.1:${port}/api/version`, { signal: AbortSignal.timeout(800) });
|
|
1175
|
+
const said = answer.ok ? await answer.json().catch(() => null) : null;
|
|
1176
|
+
return said?.name === PACKAGE.name;
|
|
1177
|
+
},
|
|
1178
|
+
emit: (type, payload) => sayToAll(type, payload),
|
|
1179
|
+
active: async () => clients.size > 0 && await ripleyOn(),
|
|
1180
|
+
});
|
|
1181
|
+
if (!testMode) serverSentinel.start();
|
|
1182
|
+
// «Still in use», once a day per copy (src/heartbeat.mjs): a little after start, then looked at
|
|
1183
|
+
// every few hours; the day it was already said, nothing goes.
|
|
1184
|
+
const sayAlive = () => { void heartbeat({ root, version: PACKAGE.version, fetchImpl: reportFetch }); };
|
|
1185
|
+
const aliveTimers = testMode ? [] : [setTimeout(sayAlive, 20_000), setInterval(sayAlive, 6 * 60 * 60 * 1000)];
|
|
1186
|
+
for (const timer of aliveTimers) timer.unref?.();
|
|
1187
|
+
const endClients = (feed) => { for (const client of [...feed.clients.keys()]) { feed.clients.delete(client); try { client.end(); } catch { /* already gone */ } } };
|
|
1188
|
+
async function closeHeld(id) {
|
|
1189
|
+
const held = parked.get(id);
|
|
1190
|
+
if (!held) return;
|
|
1191
|
+
parked.delete(id);
|
|
1192
|
+
held.off();
|
|
1193
|
+
await held.room.shutdown().catch(() => null);
|
|
1194
|
+
retireLedger(held.store);
|
|
1195
|
+
}
|
|
1196
|
+
// A held conversation with no turn running and nobody watching it has no reason to stay open.
|
|
1197
|
+
async function releaseIfIdle(id, target = null) {
|
|
1198
|
+
const held = parked.get(id);
|
|
1199
|
+
if (!held || (target && held.room !== target)) return;
|
|
1200
|
+
if (held.room.busy() || held.room.hasLiveRuns?.() || held.feed?.clients.size) return;
|
|
1201
|
+
await closeHeld(id);
|
|
1202
|
+
}
|
|
1203
|
+
// A conversation's turns starting and ending, said to every page whichever conversation it shows.
|
|
1204
|
+
// The settle after an end is read a moment later: the turn leaves the room after its last event.
|
|
1205
|
+
function watchActivity(target, id) {
|
|
1206
|
+
return target.subscribe((event) => {
|
|
1207
|
+
if (!['agent.started', 'agent.completed', 'message.failed'].includes(event?.type)) return;
|
|
1208
|
+
const started = event.type === 'agent.started';
|
|
1209
|
+
sayToAll('chat.activity', { chatId: id, working: true, ...(started ? {} : { finished: { agent: event.payload?.agent ?? event.payload?.target ?? null, ok: event.type === 'agent.completed' } }) });
|
|
1210
|
+
if (started) return;
|
|
1211
|
+
setTimeout(() => {
|
|
1212
|
+
if (target.busy()) return;
|
|
1213
|
+
sayToAll('chat.activity', { chatId: id, working: false });
|
|
1214
|
+
void releaseIfIdle(id, target);
|
|
1215
|
+
}, 400).unref?.();
|
|
1216
|
+
});
|
|
1217
|
+
}
|
|
1218
|
+
// What a held conversation keeps doing while it is not the one on screen: its list entry follows
|
|
1219
|
+
// what is said in it, and every page hears when a turn in it starts and ends.
|
|
1220
|
+
function hold(id, target, source) {
|
|
1221
|
+
const offTouch = target.subscribe((event) => {
|
|
1222
|
+
if (event?.type !== 'message.created' || event.ghost) return;
|
|
1223
|
+
const human = event.payload?.role === 'user';
|
|
1224
|
+
chatWrites = chatWrites.then(() => touchChat(roomDir, id, { text: human ? saidText(event.payload) : null, counts: human })).catch(() => null);
|
|
1225
|
+
});
|
|
1226
|
+
const offActivity = watchActivity(target, id);
|
|
1227
|
+
const offModules = wireModules(target, id);
|
|
1228
|
+
let offAsks = () => {};
|
|
1229
|
+
void source?.readAll?.().then((history) => { offAsks = trackAsks(target, history); }).catch(() => null);
|
|
1230
|
+
const entry = { room: target, store: source, feed: null, off: () => { offTouch(); offActivity(); offAsks(); offModules(); } };
|
|
1231
|
+
parked.set(id, entry);
|
|
1232
|
+
return entry;
|
|
1233
|
+
}
|
|
1234
|
+
// A page starts watching a held conversation: it gets its own feed, fed by its own room.
|
|
1235
|
+
async function watchHeld(entry) {
|
|
1236
|
+
if (entry.feed) return entry.feed;
|
|
1237
|
+
const feed = await feedOver(entry.store, await entry.store.readAll());
|
|
1238
|
+
if (entry.feed) return entry.feed;
|
|
1239
|
+
entry.feed = feed;
|
|
1240
|
+
const offs = [
|
|
1241
|
+
entry.room.subscribe(() => { void pump(feed); }),
|
|
1242
|
+
entry.room.subscribeGhost((event) => { for (const client of [...feed.clients.keys()]) writeEvent(client, event, feed); }),
|
|
1243
|
+
entry.room.subscribeLive((event) => { for (const client of [...feed.clients.keys()]) writeEvent(client, event, feed); }),
|
|
1244
|
+
];
|
|
1245
|
+
const before = entry.off;
|
|
1246
|
+
entry.off = () => { before(); for (const off of offs) { try { off(); } catch { /* already gone */ } } endClients(feed); };
|
|
1247
|
+
return feed;
|
|
1248
|
+
}
|
|
1249
|
+
// The conversation a request is about. Without `?chat=`, or naming the one on screen, it is the
|
|
1250
|
+
// open conversation, exactly as before. Naming another opens it beside, without changing which
|
|
1251
|
+
// one is on screen: the same room a parked conversation already has, or a new one built the way
|
|
1252
|
+
// mountChat builds one.
|
|
1253
|
+
// The room on screen, for the places where a route names its own conversation `room`.
|
|
1254
|
+
const openRoom = () => room;
|
|
1255
|
+
async function scopeOf(url) {
|
|
1256
|
+
const asked = url.searchParams.get('chat');
|
|
1257
|
+
if (!asked || asked === chatId) return { id: chatId, room, store, feed: mainFeed, main: true };
|
|
1258
|
+
if (!isChatId(asked)) return { error: `No conversation "${asked}".` };
|
|
1259
|
+
let entry = parked.get(asked);
|
|
1260
|
+
if (!entry) {
|
|
1261
|
+
if (!opening.has(asked)) {
|
|
1262
|
+
opening.set(asked, (async () => {
|
|
1263
|
+
if (!(await listChats(roomDir)).chats.some((chat) => chat.id === asked)) return null;
|
|
1264
|
+
const source = await openLedger(asked);
|
|
1265
|
+
const events = await source.readAll();
|
|
1266
|
+
const opened = new Room(roomOptions({ store: source, historicalEvents: events }));
|
|
1267
|
+
// A conversation opened from disk runs nothing yet: a turn it shows as started was cut
|
|
1268
|
+
// by a room that died, and is closed as one (LOG-14 of the 1.0 review). Only the
|
|
1269
|
+
// conversation open at startup was reconciled, so the others «thought» forever.
|
|
1270
|
+
await opened.reconcile();
|
|
1271
|
+
wireLocal(opened);
|
|
1272
|
+
await applyRoomSettings(opened, room);
|
|
1273
|
+
opened.restoreAttachments(events
|
|
1274
|
+
.filter((event) => event.type === 'attachment.stored')
|
|
1275
|
+
.map((event) => ({ ...event.payload, path: join(attachmentsRoot, event.payload.fileName), dir: attachmentsRoot })));
|
|
1276
|
+
const made = hold(asked, opened, source);
|
|
1277
|
+
// Opened to be looked at; if no page comes to watch it, it closes again.
|
|
1278
|
+
setTimeout(() => { void releaseIfIdle(asked, opened); }, heldGraceMs).unref?.();
|
|
1279
|
+
return made;
|
|
1280
|
+
})().finally(() => opening.delete(asked)));
|
|
1281
|
+
}
|
|
1282
|
+
entry = await opening.get(asked);
|
|
1283
|
+
if (!entry) return { error: `No conversation "${asked}".` };
|
|
1284
|
+
}
|
|
1285
|
+
// It may have become the open one while it was being built.
|
|
1286
|
+
if (asked === chatId) return { id: chatId, room, store, feed: mainFeed, main: true };
|
|
1287
|
+
return { id: asked, room: entry.room, store: entry.store, entry, main: false };
|
|
1288
|
+
}
|
|
1289
|
+
let unsubscribeActivity = () => {};
|
|
1290
|
+
const chatsView = async () => {
|
|
1291
|
+
const list = await listChats(roomDir);
|
|
1292
|
+
const working = new Set([...busyChats(), ...(room?.busy() ? [chatId] : [])]);
|
|
1293
|
+
return { ...list, chats: list.chats.map((chat) => (working.has(chat.id) ? { ...chat, working: true } : chat)) };
|
|
1294
|
+
};
|
|
1295
|
+
// What the human set while the room was open belongs to the project, not to one conversation's
|
|
1296
|
+
// record: a room opened for another chat used to start with no MAX MODE, no Ash, no Choices and
|
|
1297
|
+
// the timeouts MADRE booted with.
|
|
1298
|
+
async function applyRoomSettings(next, previous) {
|
|
1299
|
+
const config = await readConfig(root);
|
|
1300
|
+
next.setScopes(config.scopes ?? {});
|
|
1301
|
+
next.setAsh(Boolean(config.modules?.ash?.enabled));
|
|
1302
|
+
next.setChoices(Boolean(config.modules?.choices?.enabled));
|
|
1303
|
+
next.setRuns(Boolean(config.modules?.runs?.enabled));
|
|
1304
|
+
if (previous) next.configure(previous.settings());
|
|
690
1305
|
}
|
|
691
1306
|
|
|
692
1307
|
// Opening another conversation. The project keeps its memory, its crew, its modules, its
|
|
693
|
-
// privacy list and its numbering; what changes is the record being
|
|
694
|
-
// conversation
|
|
695
|
-
//
|
|
1308
|
+
// privacy list and its numbering; what changes is the record being shown. With TURNS on, a
|
|
1309
|
+
// conversation still at work is parked, not stopped; with it off, a turn in flight is a reason to
|
|
1310
|
+
// refuse, as it always was.
|
|
696
1311
|
async function mountChat(id) {
|
|
697
1312
|
if (!isChatId(id)) return { error: `No conversation "${id}".` };
|
|
698
|
-
|
|
1313
|
+
const policy = await turnsPolicy();
|
|
1314
|
+
const leaving = room;
|
|
1315
|
+
const leavingBusy = Boolean(leaving?.busy());
|
|
1316
|
+
if (leavingBusy && !policy.enabled) return { error: t('A turn is running in this conversation. Let it finish, or STOP ALL, and try again.') };
|
|
699
1317
|
const opened = await openChatIndex(roomDir, id);
|
|
700
1318
|
if (!opened) return { error: `No conversation "${id}".` };
|
|
1319
|
+
// A room being built for it beside is the room it gets; never a second one over its record.
|
|
1320
|
+
await opening.get(id)?.catch(() => null);
|
|
701
1321
|
unwireRoom();
|
|
702
1322
|
await chatWrites.catch(() => null);
|
|
703
|
-
|
|
1323
|
+
// A conversation with a command still alive (a server it started) is parked too, never closed
|
|
1324
|
+
// under the process: its card is where that process is stopped.
|
|
1325
|
+
if (leaving && (leavingBusy || leaving.hasLiveRuns?.()) && chatId !== id) {
|
|
1326
|
+
// Still writing its record and touching the list; only the page stops watching it.
|
|
1327
|
+
hold(chatId, leaving, store);
|
|
1328
|
+
} else if (leaving) {
|
|
1329
|
+
await leaving.shutdown().catch(() => null);
|
|
1330
|
+
retireLedger(store);
|
|
1331
|
+
}
|
|
704
1332
|
chatId = id;
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
1333
|
+
const held = parked.get(id);
|
|
1334
|
+
if (held) {
|
|
1335
|
+
// Back to a conversation that kept working, or that was open beside: the same room, not a
|
|
1336
|
+
// second one over its record. A page watching it there comes back to it as the open one.
|
|
1337
|
+
parked.delete(id);
|
|
1338
|
+
held.off();
|
|
1339
|
+
room = held.room;
|
|
1340
|
+
store = held.store;
|
|
1341
|
+
historicalEvents = await store.readAll();
|
|
1342
|
+
} else {
|
|
1343
|
+
store = await openLedger(id);
|
|
1344
|
+
historicalEvents = await store.readAll();
|
|
1345
|
+
room = new Room(roomOptions());
|
|
1346
|
+
// Opened from disk: what it shows as started was cut by a room that died (LOG-14).
|
|
1347
|
+
if (await room.reconcile()) historicalEvents = await store.readAll();
|
|
1348
|
+
wireLocal(room);
|
|
1349
|
+
await applyRoomSettings(room, leaving);
|
|
1350
|
+
}
|
|
709
1351
|
wireRoom();
|
|
710
|
-
// Whoever is watching is watching the wrong record now: their stream
|
|
711
|
-
// to the one that is open.
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
1352
|
+
// Whoever is watching the open conversation is watching the wrong record now: their stream
|
|
1353
|
+
// ends and they come back to the one that is open. A page watching another conversation
|
|
1354
|
+
// beside it has a feed of its own and is not touched.
|
|
1355
|
+
mainFeed.store = store;
|
|
1356
|
+
mainFeed.sent = historicalEvents.at(-1)?.sequence ?? 0;
|
|
1357
|
+
mainFeed.offset = (await store.tail(0)).offset;
|
|
1358
|
+
endClients(mainFeed);
|
|
715
1359
|
return { chat: opened };
|
|
716
1360
|
}
|
|
717
1361
|
wireRoom();
|
|
718
|
-
const poller = setInterval(() => {
|
|
1362
|
+
const poller = setInterval(() => {
|
|
1363
|
+
void broadcastPending();
|
|
1364
|
+
for (const held of parked.values()) if (held.feed) void pump(held.feed);
|
|
1365
|
+
}, broadcastIntervalMs);
|
|
719
1366
|
poller.unref();
|
|
720
1367
|
|
|
721
1368
|
let installing = null;
|
|
1369
|
+
// Intenciones' reading of every conversation, kept per ledger file until that file changes.
|
|
1370
|
+
const observedLedgers = new Map();
|
|
722
1371
|
// The context a module works in: the project, its settings, the room, and what this server offers.
|
|
723
1372
|
async function moduleContext() {
|
|
724
1373
|
const config = await readConfig(root);
|
|
@@ -728,8 +1377,68 @@ export async function createPulseServer({
|
|
|
728
1377
|
updateConfig: (patch) => updateConfig(root, patch),
|
|
729
1378
|
record: (type, payload) => room.record(type, payload),
|
|
730
1379
|
services: {
|
|
1380
|
+
// Into one named conversation of this room, opened beside if it is not alive; the open one
|
|
1381
|
+
// when the name is missing or no longer exists. The same `record()` and the same refusals.
|
|
1382
|
+
recordIn: async (id, type, payload) => {
|
|
1383
|
+
const here = typeof id === 'string' && id ? await scopeOf(new URL(`http://room/?chat=${encodeURIComponent(id)}`)) : { room };
|
|
1384
|
+
return (here.error ? room : here.room).record(type, payload);
|
|
1385
|
+
},
|
|
731
1386
|
imageKey,
|
|
732
1387
|
setImageModule,
|
|
1388
|
+
// Intenciones keeps its measurements beside the project's room, reads the open
|
|
1389
|
+
// conversation, and asks agents through the same bench EYECAT and the archivist draw on.
|
|
1390
|
+
intents: {
|
|
1391
|
+
roomDir,
|
|
1392
|
+
events: () => store.readAll(),
|
|
1393
|
+
bench: () => room.bench(),
|
|
1394
|
+
// The questions to calibrate with are the project's, not one conversation's: a new
|
|
1395
|
+
// conversation would otherwise have nothing to ask. Only what was said is read.
|
|
1396
|
+
projectEvents: async () => {
|
|
1397
|
+
const said = [];
|
|
1398
|
+
for (const chat of (await listChats(roomDir)).chats) {
|
|
1399
|
+
const text = await readFile(chatLedger(roomDir, chat.id), 'utf8').catch(() => '');
|
|
1400
|
+
for (const line of text.split('\n')) {
|
|
1401
|
+
if (!line.includes('"message.created"')) continue;
|
|
1402
|
+
try { const event = JSON.parse(line); if (event.type === 'message.created') said.push(event); } catch { /* a torn last line */ }
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
return said;
|
|
1406
|
+
},
|
|
1407
|
+
// Every conversation's turns, cut down to what learning from real use reads: who was
|
|
1408
|
+
// asked what, at which effort, how it ended, what it cost and what the human said of it.
|
|
1409
|
+
// A ledger is parsed again only when it changed on disk.
|
|
1410
|
+
ledgers: async () => {
|
|
1411
|
+
const turns = [];
|
|
1412
|
+
for (const chat of (await listChats(roomDir)).chats) {
|
|
1413
|
+
const file = chatLedger(roomDir, chat.id);
|
|
1414
|
+
const seen = await stat(file).catch(() => null);
|
|
1415
|
+
if (!seen) continue;
|
|
1416
|
+
let kept = observedLedgers.get(file);
|
|
1417
|
+
if (!kept || kept.mtimeMs !== seen.mtimeMs || kept.size !== seen.size) {
|
|
1418
|
+
const text = await readFile(file, 'utf8').catch(() => '');
|
|
1419
|
+
const events = [];
|
|
1420
|
+
for (const line of text.split('\n')) {
|
|
1421
|
+
if (!/"(message\.created|message\.failed|message\.rated|usage\.recorded|turn\.stopped)"/.test(line)) continue;
|
|
1422
|
+
try { const one = observable(JSON.parse(line), chat.id); if (one) events.push(one); } catch { /* a torn last line */ }
|
|
1423
|
+
}
|
|
1424
|
+
kept = { mtimeMs: seen.mtimeMs, size: seen.size, events };
|
|
1425
|
+
observedLedgers.set(file, kept);
|
|
1426
|
+
}
|
|
1427
|
+
turns.push(...kept.events);
|
|
1428
|
+
}
|
|
1429
|
+
return turns;
|
|
1430
|
+
},
|
|
1431
|
+
// The effort Codex runs at when told none, from its own config: a turn sent without one
|
|
1432
|
+
// ran there. Claude never says, and its turns stay at «its default».
|
|
1433
|
+
defaults: async () => {
|
|
1434
|
+
const codex = agents.find((agent) => agent.id === 'codex');
|
|
1435
|
+
if (!codex) return {};
|
|
1436
|
+
const found = (await discoverModels({ agents: [codex], config: await readConfig(root) }))?.codex;
|
|
1437
|
+
if (!found) return {};
|
|
1438
|
+
const byModel = Object.fromEntries(Object.entries(found.effortsByModel ?? {}).filter(([, one]) => one?.default).map(([slug, one]) => [slug, one.default]));
|
|
1439
|
+
return { codex: { effort: found.effortDefault ?? null, byModel } };
|
|
1440
|
+
},
|
|
1441
|
+
},
|
|
733
1442
|
// What a card knows about being up to date. Reading is free: it answers from the day's
|
|
734
1443
|
// cache. The button on a card is what forces a look outside.
|
|
735
1444
|
moduleUpdate: (item, { force = false, cacheOnly = !force } = {}) => moduleUpdate(item, {
|
|
@@ -738,6 +1447,8 @@ export async function createPulseServer({
|
|
|
738
1447
|
}),
|
|
739
1448
|
ollama: {
|
|
740
1449
|
state: () => ollama,
|
|
1450
|
+
project: () => localAiHere(),
|
|
1451
|
+
setProject: (on) => setLocalAiHere(on),
|
|
741
1452
|
wire: (options) => wireOllama(options),
|
|
742
1453
|
// Getting Ollama onto this computer, with the command in plain sight, and waking it.
|
|
743
1454
|
install: async () => {
|
|
@@ -826,7 +1537,12 @@ export async function createPulseServer({
|
|
|
826
1537
|
const extension = extensionById(id);
|
|
827
1538
|
if (!extension) return { status: 404, body: { error: `Unknown module: ${id}.` } };
|
|
828
1539
|
// Built-ins are switches the module itself defines.
|
|
829
|
-
if (extension.toggle)
|
|
1540
|
+
if (extension.toggle) {
|
|
1541
|
+
const toggled = await extension.toggle(await moduleContext(), payload);
|
|
1542
|
+
// The switch flips the open conversation; the ones alive beside it follow.
|
|
1543
|
+
for (const held of parked.values()) await applyRoomSettings(held.room, null);
|
|
1544
|
+
return toggled;
|
|
1545
|
+
}
|
|
830
1546
|
if (confirm !== true) return { status: 400, body: { error: t('Installing a module writes into the project; send { "confirm": true } to proceed.') } };
|
|
831
1547
|
if (installing) return { status: 409, body: { error: `Another install is running (${installing}).` } };
|
|
832
1548
|
const preflight = extension.preflight ? await extension.preflight(canonicalProjectRoot) : { ok: true, problems: [] };
|
|
@@ -885,13 +1601,17 @@ export async function createPulseServer({
|
|
|
885
1601
|
// not beside the first route that happened to need it: a const sits in its temporal dead zone
|
|
886
1602
|
// until its own line runs, so any route above it threw instead of refusing.
|
|
887
1603
|
const designationOk = (given) => typeof given === 'string' && given.trim().toLowerCase() === basename(canonicalProjectRoot).toLowerCase();
|
|
1604
|
+
// A request the bridge passed on from a paired phone. It reaches the room from 127.0.0.1 like
|
|
1605
|
+
// anything on this Mac, so «only from this computer» has to ask this too.
|
|
1606
|
+
const viaPhone = typeof request.headers['x-madre-device'] === 'string';
|
|
1607
|
+
const phoneKind = viaPhone ? String(request.headers['x-madre-device-kind'] ?? 'other').slice(0, 20) : null;
|
|
888
1608
|
try {
|
|
889
1609
|
if (request.method === 'GET' && url.pathname === '/') {
|
|
890
1610
|
const html = await readFile(join(publicDirectory, 'index.html'));
|
|
891
|
-
response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
|
1611
|
+
response.writeHead(200, { 'content-type': 'text/html; charset=utf-8', ...PAGE_GUARDS });
|
|
892
1612
|
return response.end(html);
|
|
893
1613
|
}
|
|
894
|
-
if (request.method === 'GET' && ['/app.js', '/brands.js', '/troubleshooting.js', '/inquiry.js', '/i18n.js', '/es.js', '/resay.js', '/syntax.js', '/routing.js', '/abduction.js'].includes(url.pathname)) {
|
|
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)) {
|
|
895
1615
|
const js = await readFile(join(publicDirectory, url.pathname.slice(1)));
|
|
896
1616
|
response.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8' });
|
|
897
1617
|
return response.end(js);
|
|
@@ -907,6 +1627,56 @@ export async function createPulseServer({
|
|
|
907
1627
|
['/assets/abduction-cat-control-left.png', 'abduction-cat-control-left.png'],
|
|
908
1628
|
['/assets/abduction-cat-control-right.png', 'abduction-cat-control-right.png'],
|
|
909
1629
|
]);
|
|
1630
|
+
// The brand: its typefaces (SIL OFL, licences beside them) and the favicon, served from the
|
|
1631
|
+
// package itself. Nothing is fetched from a font service: the room never leaves this machine
|
|
1632
|
+
// to draw a letter.
|
|
1633
|
+
const brandAssets = new Map([
|
|
1634
|
+
['/assets/brand/Sora-latin.woff2', 'font/woff2'],
|
|
1635
|
+
['/assets/brand/Sora-latin-ext.woff2', 'font/woff2'],
|
|
1636
|
+
['/assets/brand/JetBrainsMono-latin.woff2', 'font/woff2'],
|
|
1637
|
+
['/assets/brand/JetBrainsMono-latin-ext.woff2', 'font/woff2'],
|
|
1638
|
+
['/assets/brand/Geist-latin.woff2', 'font/woff2'],
|
|
1639
|
+
['/assets/brand/Geist-latin-ext.woff2', 'font/woff2'],
|
|
1640
|
+
['/assets/brand/GeistMono-latin.woff2', 'font/woff2'],
|
|
1641
|
+
['/assets/brand/GeistMono-latin-ext.woff2', 'font/woff2'],
|
|
1642
|
+
['/assets/brand/OFL-Geist.txt', 'text/plain; charset=utf-8'],
|
|
1643
|
+
['/assets/brand/OFL-GeistMono.txt', 'text/plain; charset=utf-8'],
|
|
1644
|
+
['/assets/brand/OFL-Sora.txt', 'text/plain; charset=utf-8'],
|
|
1645
|
+
['/assets/brand/OFL-JetBrainsMono.txt', 'text/plain; charset=utf-8'],
|
|
1646
|
+
['/assets/brand/madre-favicon.svg', 'image/svg+xml'],
|
|
1647
|
+
// The conversation's wallpapers (Appearance): twelve pictures, encoded by hand from their
|
|
1648
|
+
// masters (cwebp), whose light and dark, layer or mesh and intensity are layers in the CSS.
|
|
1649
|
+
['/assets/wallpapers/jarrito.webp', 'image/webp'],
|
|
1650
|
+
['/assets/wallpapers/canela.webp', 'image/webp'],
|
|
1651
|
+
['/assets/wallpapers/paraiso.webp', 'image/webp'],
|
|
1652
|
+
['/assets/wallpapers/ventura.webp', 'image/webp'],
|
|
1653
|
+
['/assets/wallpapers/magna.webp', 'image/webp'],
|
|
1654
|
+
['/assets/wallpapers/biznaga.webp', 'image/webp'],
|
|
1655
|
+
['/assets/wallpapers/oceano.webp', 'image/webp'],
|
|
1656
|
+
['/assets/wallpapers/quila.webp', 'image/webp'],
|
|
1657
|
+
['/assets/wallpapers/xico.webp', 'image/webp'],
|
|
1658
|
+
['/assets/wallpapers/tropical.webp', 'image/webp'],
|
|
1659
|
+
['/assets/wallpapers/tradicion.webp', 'image/webp'],
|
|
1660
|
+
['/assets/wallpapers/urbania.webp', 'image/webp'],
|
|
1661
|
+
// The pictures of the «what's new» board, one per thing it tells (src/whats-new.mjs).
|
|
1662
|
+
['/assets/news/1.0/settings.webp', 'image/webp'],
|
|
1663
|
+
['/assets/news/1.0/mother.webp', 'image/webp'],
|
|
1664
|
+
['/assets/news/1.0/intents.webp', 'image/webp'],
|
|
1665
|
+
['/assets/news/1.0/permissions.webp', 'image/webp'],
|
|
1666
|
+
['/assets/news/1.0/github.webp', 'image/webp'],
|
|
1667
|
+
['/assets/news/1.0/bar.webp', 'image/webp'],
|
|
1668
|
+
['/assets/news/1.0/modules.webp', 'image/webp'],
|
|
1669
|
+
['/assets/news/1.0/nostromo.webp', 'image/webp'],
|
|
1670
|
+
['/assets/news/1.0/room.webp', 'image/webp'],
|
|
1671
|
+
['/assets/news/1.0/looks.webp', 'image/webp'],
|
|
1672
|
+
['/assets/news/1.0/alerts.webp', 'image/webp'],
|
|
1673
|
+
['/assets/news/1.0/finder.webp', 'image/webp'],
|
|
1674
|
+
]);
|
|
1675
|
+
if (request.method === 'GET' && brandAssets.has(url.pathname)) {
|
|
1676
|
+
const file = await readFile(join(publicDirectory, url.pathname.slice(1)));
|
|
1677
|
+
response.writeHead(200, { 'content-type': brandAssets.get(url.pathname), 'content-length': file.length, 'cache-control': 'public, max-age=86400' });
|
|
1678
|
+
return response.end(file);
|
|
1679
|
+
}
|
|
910
1680
|
if (request.method === 'GET' && abductionAssets.has(url.pathname)) {
|
|
911
1681
|
const image = await readFile(join(publicDirectory, 'assets', abductionAssets.get(url.pathname)));
|
|
912
1682
|
response.writeHead(200, {
|
|
@@ -916,20 +1686,579 @@ export async function createPulseServer({
|
|
|
916
1686
|
});
|
|
917
1687
|
return response.end(image);
|
|
918
1688
|
}
|
|
1689
|
+
// ---------- The phone (docs/MOBILE.md) ----------
|
|
1690
|
+
// Pairing and the list of phones are the Mac's alone: nothing that came through the bridge
|
|
1691
|
+
// reaches them.
|
|
1692
|
+
if (url.pathname === '/api/phone' || url.pathname.startsWith('/api/phone/')) {
|
|
1693
|
+
if (viaPhone) return sendJson(response, 403, { error: t('This is done on the Mac.') });
|
|
1694
|
+
if (!(await phonePreview())) {
|
|
1695
|
+
if (request.method === 'GET' && url.pathname === '/api/phone') return sendJson(response, 200, { preview: false });
|
|
1696
|
+
return sendJson(response, 404, { error: t('Not found.') });
|
|
1697
|
+
}
|
|
1698
|
+
if (request.method === 'GET' && url.pathname === '/api/phone') return sendJson(response, 200, { preview: true, ...(await phoneView()) });
|
|
1699
|
+
const payload = request.method === 'POST' ? await body(request).catch(() => ({})) : {};
|
|
1700
|
+
if (request.method === 'POST' && url.pathname === '/api/phone/trust') {
|
|
1701
|
+
const here = await currentNetwork().catch(() => null);
|
|
1702
|
+
if (!here) return sendJson(response, 412, { error: t('MADRE cannot tell which network this computer is on.') });
|
|
1703
|
+
await trustNetwork(root, { id: here.id, name: typeof payload.name === 'string' ? payload.name : null });
|
|
1704
|
+
await phone.tick();
|
|
1705
|
+
return sendJson(response, 200, await phoneView());
|
|
1706
|
+
}
|
|
1707
|
+
if (request.method === 'POST' && url.pathname === '/api/phone/invite') {
|
|
1708
|
+
const here = await currentNetwork().catch(() => null);
|
|
1709
|
+
const store = await readStore(root);
|
|
1710
|
+
if (!here || !store.networks.some((one) => one.id === here.id)) return sendJson(response, 412, { error: t('Mark this network as trusted first.'), trust: true });
|
|
1711
|
+
const address = lanAddresses({ device: here.device })[0];
|
|
1712
|
+
if (!address) return sendJson(response, 412, { error: t('This Mac has no address on the local network.') });
|
|
1713
|
+
if (!store.enabled) await setEnabled(root, true);
|
|
1714
|
+
const invite = await createInvite(root);
|
|
1715
|
+
await phone.tick();
|
|
1716
|
+
// Whichever room hosts it, the bridge has to answer before the code is shown.
|
|
1717
|
+
for (let wait = 0; wait < 10 && !(await bridgeAnswers()); wait += 1) await new Promise((done) => setTimeout(done, 200));
|
|
1718
|
+
if (!(await bridgeAnswers())) return sendJson(response, 503, { error: t('MADRE on your phone did not open on port {port}. Another program may be using it.', { port: BRIDGE_PORT }) });
|
|
1719
|
+
const link = `http://${address}:${BRIDGE_PORT}/bridge/pair?c=${invite.code}`;
|
|
1720
|
+
return sendJson(response, 200, { invite: { id: invite.id, expiresAt: invite.expiresAt }, url: link, qr: qrSvg(link, { label: t('Code to pair your phone') }), ...(await phoneView()) });
|
|
1721
|
+
}
|
|
1722
|
+
if (request.method === 'POST' && url.pathname === '/api/phone/forget') {
|
|
1723
|
+
await forgetDevice(root, String(payload.id ?? ''));
|
|
1724
|
+
await phone.tick();
|
|
1725
|
+
return sendJson(response, 200, await phoneView());
|
|
1726
|
+
}
|
|
1727
|
+
if (request.method === 'POST' && url.pathname === '/api/phone/networks/forget') {
|
|
1728
|
+
await forgetNetwork(root, String(payload.id ?? ''));
|
|
1729
|
+
await phone.tick();
|
|
1730
|
+
return sendJson(response, 200, await phoneView());
|
|
1731
|
+
}
|
|
1732
|
+
if (request.method === 'POST' && url.pathname === '/api/phone/enabled') {
|
|
1733
|
+
await setEnabled(root, payload.on !== false);
|
|
1734
|
+
await phone.tick();
|
|
1735
|
+
return sendJson(response, 200, await phoneView());
|
|
1736
|
+
}
|
|
1737
|
+
return sendJson(response, 404, { error: t('Not found.') });
|
|
1738
|
+
}
|
|
1739
|
+
// The room in one line, for the list of rooms on the phone.
|
|
1740
|
+
if (request.method === 'GET' && url.pathname === '/api/glance') {
|
|
1741
|
+
const { chats } = await listChats(roomDir);
|
|
1742
|
+
const latest = chats[0] ?? null;
|
|
1743
|
+
const working = new Set(room.activeTurns().map((turn) => turn.agent));
|
|
1744
|
+
for (const held of parked.values()) for (const turn of held.room.activeTurns()) working.add(turn.agent);
|
|
1745
|
+
// What waits for the human, and in which conversation, so the phone opens straight to it.
|
|
1746
|
+
const titleOf = (id) => chats.find((one) => one.id === id)?.title ?? null;
|
|
1747
|
+
const waiting = [
|
|
1748
|
+
...asksOf(room).map((ask) => ({ ...ask, chat: chatId, chatTitle: titleOf(chatId) })),
|
|
1749
|
+
...[...parked.entries()].flatMap(([id, held]) => asksOf(held.room).map((ask) => ({ ...ask, chat: id, chatTitle: titleOf(id) }))),
|
|
1750
|
+
];
|
|
1751
|
+
return sendJson(response, 200, {
|
|
1752
|
+
project: basename(canonicalProjectRoot),
|
|
1753
|
+
version: PACKAGE.version ?? null,
|
|
1754
|
+
chats: chats.length,
|
|
1755
|
+
chat: latest ? { id: latest.id, title: latest.title, named: latest.named, preview: latest.preview, at: latest.updatedAt, messages: latest.messages } : null,
|
|
1756
|
+
working: [...working],
|
|
1757
|
+
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 })),
|
|
1758
|
+
waitingCount: waiting.length,
|
|
1759
|
+
// What the archive holds, counted, when Salas asks (?census=1): never the words, only how
|
|
1760
|
+
// many of each kind and how many travel into turns.
|
|
1761
|
+
...(url.searchParams.get('census') === '1' ? { census: memoryCensus() } : {}),
|
|
1762
|
+
});
|
|
1763
|
+
}
|
|
1764
|
+
// ---- Salas on the Mac (src/salas.mjs, docs/MACROVERSO.md). The page speaks only to this
|
|
1765
|
+
// room; this room speaks to the others from here, the way the phone's bridge does. Opening,
|
|
1766
|
+
// closing and stopping rooms is the Mac's: a phone never reaches these.
|
|
1767
|
+
if ((url.pathname === '/salas' || url.pathname.startsWith('/api/salas') || url.pathname === '/api/room/close') && viaPhone) {
|
|
1768
|
+
return sendJson(response, 403, { error: t('This is done on the Mac.') });
|
|
1769
|
+
}
|
|
1770
|
+
if (request.method === 'GET' && url.pathname === '/salas') {
|
|
1771
|
+
const html = await readFile(join(publicDirectory, 'salas-mac.html'));
|
|
1772
|
+
response.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache', ...PAGE_GUARDS });
|
|
1773
|
+
return response.end(html);
|
|
1774
|
+
}
|
|
1775
|
+
if (request.method === 'GET' && (url.pathname === '/salas-mac.js' || url.pathname === '/salas-mac.css' || url.pathname === '/macroverso.js')) {
|
|
1776
|
+
const file = await readFile(join(publicDirectory, url.pathname.slice(1)));
|
|
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);
|
|
1779
|
+
}
|
|
1780
|
+
// A widget of Home, read here (homeWidget above): only from this Mac's own pages.
|
|
1781
|
+
if (request.method === 'GET' && url.pathname === '/api/salas/widget') {
|
|
1782
|
+
const reading = await homeWidget(String(url.searchParams.get('kind') ?? ''));
|
|
1783
|
+
if (!reading) return sendJson(response, 404, { error: t('Home has no widget by that name.') });
|
|
1784
|
+
return sendJson(response, 200, reading);
|
|
1785
|
+
}
|
|
1786
|
+
// Which projects use the local AI (src/local-ai.mjs): the mode and each project's switch,
|
|
1787
|
+
// written once in config.json and applied at once in every open project.
|
|
1788
|
+
if (url.pathname === '/api/salas/local-ai' && (request.method === 'GET' || request.method === 'POST')) {
|
|
1789
|
+
if (request.method === 'POST') {
|
|
1790
|
+
const patch = await body(request);
|
|
1791
|
+
const before = await readConfig(root);
|
|
1792
|
+
const rooms = patch?.rooms && typeof patch.rooms === 'object' ? { ...patch.rooms } : {};
|
|
1793
|
+
// A new mode changes what NEW projects get; the ones already here keep what they had.
|
|
1794
|
+
if (patch?.mode && patch.mode !== localAiConfig(before).mode) {
|
|
1795
|
+
const salasId = salasRoomId(root);
|
|
1796
|
+
const ids = (await knownRooms(root)).map((one) => one.id);
|
|
1797
|
+
if (!salasRoom) ids.push(thisRoomId);
|
|
1798
|
+
for (const id of ids) if (id !== salasId && !(id in rooms)) rooms[id] = localAiFor(before, id).chosen;
|
|
1799
|
+
}
|
|
1800
|
+
await updateConfig(root, localAiPatch(before, { mode: patch?.mode, rooms }));
|
|
1801
|
+
await wireOllama({ probe: false });
|
|
1802
|
+
widgetReadings.delete('models');
|
|
1803
|
+
}
|
|
1804
|
+
const config = await readConfig(root);
|
|
1805
|
+
const salas = salasRoomId(root);
|
|
1806
|
+
const known = (await knownRooms(root)).filter((one) => one.id !== salas);
|
|
1807
|
+
// The project serving this page is one of them even before Home has listed it.
|
|
1808
|
+
if (!salasRoom && !known.some((one) => one.id === thisRoomId)) known.push({ id: thisRoomId, name: basename(canonicalProjectRoot), open: { port: null } });
|
|
1809
|
+
const later = [];
|
|
1810
|
+
if (request.method === 'POST') {
|
|
1811
|
+
await Promise.all(known.filter((one) => one.open && one.id !== thisRoomId).map(async (one) => {
|
|
1812
|
+
const answer = await askRoom(one.open.port, '/api/local-ai/apply', { method: 'POST', timeoutMs: 6000 });
|
|
1813
|
+
if (answer.unreachable || answer.outdated) later.push(one.name);
|
|
1814
|
+
}));
|
|
1815
|
+
}
|
|
1816
|
+
const projects = known.map((one) => ({ id: one.id, name: one.name, open: Boolean(one.open), ...localAiFor(config, one.id) }));
|
|
1817
|
+
return sendJson(response, 200, { ...localAiConfig(config), master: localAiFor(config).master, ollama: { running: Boolean(ollama.running), models: (ollama.models ?? []).length }, projects, later });
|
|
1818
|
+
}
|
|
1819
|
+
if (request.method === 'GET' && url.pathname === '/api/salas') {
|
|
1820
|
+
const rooms = await knownRooms(root);
|
|
1821
|
+
const here = basename(roomDir);
|
|
1822
|
+
const own = await roomLooks(root);
|
|
1823
|
+
// A project that never chose its own wears the wallpaper every page shared before.
|
|
1824
|
+
const before = (await readLooks(root)).looks['pulse.wallpaper'] ?? null;
|
|
1825
|
+
const seen = await Promise.all(rooms.map(async (one) => ({
|
|
1826
|
+
...one,
|
|
1827
|
+
here: one.id === here,
|
|
1828
|
+
glance: await lobbyGlance(one),
|
|
1829
|
+
wallpaper: own[one.id]?.wallpaper ?? before,
|
|
1830
|
+
})));
|
|
1831
|
+
// The links in force (docs/MACROVERSO.md, fase 3c), each with what crossed: counted from the
|
|
1832
|
+
// lines the serving room keeps beside its record, never from a ledger. `salas` is the id of
|
|
1833
|
+
// the room of Salas, whether or not that room exists yet (phase 3d).
|
|
1834
|
+
const salas = salasRoomId(root);
|
|
1835
|
+
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() });
|
|
1837
|
+
}
|
|
1838
|
+
// The open rooms, each in one line (its glance: who works, who waits, its last conversation;
|
|
1839
|
+
// never its census or the links), for the room's switcher (2026-10-10), asked every few
|
|
1840
|
+
// seconds while a room's page is in view. Read only by this Mac's own pages, as the work is.
|
|
1841
|
+
if (request.method === 'GET' && url.pathname === '/api/salas/open') {
|
|
1842
|
+
const host = String(request.headers.host ?? '');
|
|
1843
|
+
const site = request.headers['sec-fetch-site'];
|
|
1844
|
+
if (!/^(127\.0\.0\.1|localhost)(:\d+)?$/.test(host) || (site !== undefined && site !== 'same-origin' && site !== 'none')) return sendJson(response, 403, { error: t('This is done from Home, on this Mac.') });
|
|
1845
|
+
const here = basename(roomDir);
|
|
1846
|
+
const rooms = (await knownRooms(root)).filter((one) => one.open);
|
|
1847
|
+
const seen = await Promise.all(rooms.map(async (one) => ({
|
|
1848
|
+
id: one.id,
|
|
1849
|
+
name: one.name,
|
|
1850
|
+
port: one.open.port,
|
|
1851
|
+
here: one.id === here,
|
|
1852
|
+
glance: one.id === here ? null : await askRoom(one.open.port, '/api/glance'),
|
|
1853
|
+
})));
|
|
1854
|
+
return sendJson(response, 200, { here, salas: salasRoomId(root), rooms: seen });
|
|
1855
|
+
}
|
|
1856
|
+
// One room as the Macroverso draws it: its memories and the veins between them, read from
|
|
1857
|
+
// that room the way its own NOSTROMO reads them (`/api/memory`, with its designation). Without
|
|
1858
|
+
// `full` no words cross: a far universe is drawn from kinds, zones and sizes alone; the chosen
|
|
1859
|
+
// one asks for them. A phone never reaches this (above).
|
|
1860
|
+
if (request.method === 'GET' && url.pathname === '/api/salas/universe') {
|
|
1861
|
+
const id = url.searchParams.get('id') ?? '';
|
|
1862
|
+
const room = (await knownRooms(root)).find((one) => one.id === id);
|
|
1863
|
+
if (!room) return sendJson(response, 404, { error: t('MADRE does not know that project on this Mac.') });
|
|
1864
|
+
if (!room.open) return sendJson(response, 200, { id, asleep: true });
|
|
1865
|
+
const answer = await askRoom(room.open.port, `/api/memory?designation=${encodeURIComponent(room.name)}`, { timeoutMs: 4000 });
|
|
1866
|
+
if (answer.outdated) return sendJson(response, 200, { id, outdated: true });
|
|
1867
|
+
if (answer.unreachable) return sendJson(response, 200, { id, sealed: answer.status === 423, unreachable: answer.status !== 423 });
|
|
1868
|
+
return sendJson(response, 200, { id, ...universeOf(answer, { full: url.searchParams.get('full') === '1' }) });
|
|
1869
|
+
}
|
|
1870
|
+
if (request.method === 'POST' && url.pathname === '/api/salas/start') {
|
|
1871
|
+
const payload = await body(request).catch(() => ({}));
|
|
1872
|
+
let room = (await knownRooms(root)).find((one) => one.id === payload.room);
|
|
1873
|
+
// BISHOP's own room is MADRE's: talking to it from the lobby wakes it even the first time,
|
|
1874
|
+
// before it was ever opened (its folder is made then, empty, as `madre lobby` makes it).
|
|
1875
|
+
if (!room && payload.room === salasRoomId(root)) {
|
|
1876
|
+
const folder = await ensureSalasProject(root);
|
|
1877
|
+
room = { id: payload.room, root: folder, open: null };
|
|
1878
|
+
}
|
|
1879
|
+
if (!room) return sendJson(response, 404, { error: t('MADRE does not know that project on this Mac.') });
|
|
1880
|
+
if (room.open) return sendJson(response, 200, { room: room.id, port: room.open.port, already: true });
|
|
1881
|
+
if (!room.root) return sendJson(response, 404, { error: t('The folder of that project is no longer on this Mac.') });
|
|
1882
|
+
// Where start.log stood before this try: only what this one wrote is read if it fails.
|
|
1883
|
+
const logFrom = await stat(join(root, 'rooms', room.id, 'start.log')).then((info) => info.size, () => 0);
|
|
1884
|
+
startRoom({ projectRoot: room.root, roomDir: join(root, 'rooms', room.id) });
|
|
1885
|
+
const up = await roomComesUp(root, room.id);
|
|
1886
|
+
if (!up) {
|
|
1887
|
+
// The commonest reason, said as itself: another MADRE (another Home, `madre start`) already
|
|
1888
|
+
// has this folder open, and one folder is one room. Where it is, so the person can go there.
|
|
1889
|
+
const said = (await readFile(join(root, 'rooms', room.id, 'start.log'), 'utf8').catch(() => '')).slice(logFrom);
|
|
1890
|
+
const there = [...said.matchAll(/already open for this project\s+(http:\/\/127\.0\.0\.1:\d+)/g)].at(-1)?.[1];
|
|
1891
|
+
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 });
|
|
1892
|
+
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.') });
|
|
1893
|
+
}
|
|
1894
|
+
return sendJson(response, 200, { room: up.id, port: up.open.port });
|
|
1895
|
+
}
|
|
1896
|
+
// ---- The lobby adds projects (2026-10-10, src/salas.mjs). Only this page, on this Mac: a
|
|
1897
|
+
// phone was refused above; another site is refused here, whether it tries to read the
|
|
1898
|
+
// folders (Sec-Fetch-Site) or to add one (Origin), and so is any host name but this Mac's
|
|
1899
|
+
// own, so a name pointed at it from outside reaches nothing. Folders are listed inside the
|
|
1900
|
+
// 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') {
|
|
1902
|
+
const host = String(request.headers.host ?? '');
|
|
1903
|
+
const localHost = /^(127\.0\.0\.1|localhost)(:\d+)?$/.test(host);
|
|
1904
|
+
const site = request.headers['sec-fetch-site'];
|
|
1905
|
+
if (!localHost || (site !== undefined && site !== 'same-origin' && site !== 'none')) return sendJson(response, 403, { error: t('This is done from Home, on this Mac.') });
|
|
1906
|
+
if (request.method === 'POST' && request.headers.origin !== `http://${host}`) return sendJson(response, 403, { error: t('This is done from Home, on this Mac.') });
|
|
1907
|
+
const home = process.env.PULSE_LOBBY_HOME || homedir();
|
|
1908
|
+
if (request.method === 'GET' && url.pathname === '/api/salas/folders') {
|
|
1909
|
+
const listed = await listFolders({ path: url.searchParams.get('path') || null, home, stateRoot: root });
|
|
1910
|
+
if (listed.error === 'outside') return sendJson(response, 403, { error: t('Only folders inside your home folder.') });
|
|
1911
|
+
if (listed.error) return sendJson(response, 404, { error: t('That folder is not there.') });
|
|
1912
|
+
return sendJson(response, 200, listed);
|
|
1913
|
+
}
|
|
1914
|
+
if (request.method === 'GET' && url.pathname === '/api/salas/suggestions') {
|
|
1915
|
+
return sendJson(response, 200, { suggestions: await suggestProjects({ home, stateRoot: root }) });
|
|
1916
|
+
}
|
|
1917
|
+
// WHAT WAS WORKED ON LAST: the newest conversations of every room the lobby knows (read from
|
|
1918
|
+
// their folders, open or asleep), and MADRE's summary of them with its local model. The
|
|
1919
|
+
// summary is written in the background and kept until what it summarises changes; it is
|
|
1920
|
+
// written again at most every five minutes, or when the person asks (POST). Without a local
|
|
1921
|
+
// model the lobby shows the conversations as they are, and says why.
|
|
1922
|
+
if (url.pathname === '/api/salas/work' && (request.method === 'GET' || request.method === 'POST')) {
|
|
1923
|
+
const salas = salasRoomId(root);
|
|
1924
|
+
const rooms = (await knownRooms(root)).map((one) => ({ id: one.id, name: one.id === salas ? 'Coordinator' : one.name }));
|
|
1925
|
+
const items = await recentWork({ stateRoot: root, rooms, limit: 5 });
|
|
1926
|
+
const sources = items.map(({ room, name, chat, title, at, said }) => ({ room, name, chat, title, at, last: said?.at(-1) ?? null }));
|
|
1927
|
+
if (!items.length) return sendJson(response, 200, { state: 'empty', sources });
|
|
1928
|
+
if (!lobbyWork.loaded) {
|
|
1929
|
+
lobbyWork.loaded = true;
|
|
1930
|
+
const kept = await readFile(lobbyWork.file, 'utf8').then((raw) => JSON.parse(raw)).catch(() => null);
|
|
1931
|
+
if (kept?.bullets?.length) Object.assign(lobbyWork, { key: kept.key, at: kept.at, tried: kept.at, bullets: kept.bullets, model: kept.model ?? null });
|
|
1932
|
+
}
|
|
1933
|
+
const settings = await ollamaSettings();
|
|
1934
|
+
const model = ollama.madreModel ?? ollama.chatModel;
|
|
1935
|
+
if (!ollama.running || !model || !settings.enabled || settings.agent === false) return sendJson(response, 200, { state: 'unavailable', sources, bullets: lobbyWork.bullets, at: lobbyWork.at || null, model: lobbyWork.model });
|
|
1936
|
+
const key = createHash('sha256').update(items.map((item) => `${item.room}:${item.chat}:${item.at}:${item.messages}`).join('|')).digest('hex').slice(0, 16);
|
|
1937
|
+
const asked = request.method === 'POST';
|
|
1938
|
+
const due = !lobbyWork.bullets || (lobbyWork.key !== key && Date.now() - lobbyWork.tried > 5 * 60 * 1000);
|
|
1939
|
+
if (!lobbyWork.running && (asked || due)) {
|
|
1940
|
+
lobbyWork.tried = Date.now();
|
|
1941
|
+
const language = roomLanguage() === 'en' ? 'en' : 'es';
|
|
1942
|
+
lobbyWork.running = ollamaGenerate({ host: ollama.host, model, system: WORK_SYSTEM[language], prompt: workPrompt(items, language), fetchImpl: reportFetch, timeoutMs: 120000, temperature: 0.1 })
|
|
1943
|
+
.then(async (answer) => {
|
|
1944
|
+
const bullets = parseWork(answer.text, items);
|
|
1945
|
+
if (!bullets.length) throw new Error('MADRE answered nothing the lobby could check against the conversations.');
|
|
1946
|
+
Object.assign(lobbyWork, { key, at: Date.now(), bullets, model, error: null });
|
|
1947
|
+
await writeFile(lobbyWork.file, JSON.stringify({ key, at: lobbyWork.at, bullets, model })).catch(() => {});
|
|
1948
|
+
})
|
|
1949
|
+
.catch((error) => { lobbyWork.error = String(error?.message ?? error).slice(0, 200); })
|
|
1950
|
+
.finally(() => { lobbyWork.running = null; });
|
|
1951
|
+
}
|
|
1952
|
+
const state = lobbyWork.running ? 'working' : lobbyWork.bullets ? 'ready' : 'failed';
|
|
1953
|
+
return sendJson(response, 200, { state, sources, bullets: lobbyWork.bullets, at: lobbyWork.at || null, model: lobbyWork.model ?? model, stale: Boolean(lobbyWork.bullets) && lobbyWork.key !== key, error: state === 'failed' ? lobbyWork.error : null });
|
|
1954
|
+
}
|
|
1955
|
+
// RECENT DECISIONS: what the open rooms decided last, as each room keeps it (only the notes
|
|
1956
|
+
// that travel into turns: hold and bridge; never med bay or jettisoned), newest first.
|
|
1957
|
+
// Asked of the rooms at most every five minutes; a room asleep, sealed or older says nothing.
|
|
1958
|
+
if (request.method === 'GET' && url.pathname === '/api/salas/decisions') {
|
|
1959
|
+
if (!lobbyDecisions.running && (!lobbyDecisions.list || Date.now() - lobbyDecisions.at > 5 * 60 * 1000)) {
|
|
1960
|
+
const salas = salasRoomId(root);
|
|
1961
|
+
lobbyDecisions.running = (async () => {
|
|
1962
|
+
const open = (await knownRooms(root)).filter((one) => one.open);
|
|
1963
|
+
const lists = await Promise.all(open.map(async (one) => {
|
|
1964
|
+
const answer = await askRoom(one.open.port, `/api/memory?designation=${encodeURIComponent(one.name)}`, { timeoutMs: 6000 });
|
|
1965
|
+
if (!Array.isArray(answer?.memories)) return [];
|
|
1966
|
+
return answer.memories
|
|
1967
|
+
.filter((note) => note.kind === 'decision' && (note.zone === 'hold' || note.zone === 'bridge') && typeof note.text === 'string')
|
|
1968
|
+
.map((note) => ({ room: one.id, name: one.id === salas ? 'Coordinator' : one.name, text: note.text.slice(0, 260), at: note.created ?? null }));
|
|
1969
|
+
}));
|
|
1970
|
+
lobbyDecisions.list = lists.flat().sort((a, b) => String(b.at ?? '').localeCompare(String(a.at ?? ''))).slice(0, 6);
|
|
1971
|
+
lobbyDecisions.at = Date.now();
|
|
1972
|
+
})().catch(() => {}).finally(() => { lobbyDecisions.running = null; });
|
|
1973
|
+
}
|
|
1974
|
+
if (!lobbyDecisions.list) await lobbyDecisions.running;
|
|
1975
|
+
return sendJson(response, 200, { decisions: lobbyDecisions.list ?? [], at: lobbyDecisions.at || null });
|
|
1976
|
+
}
|
|
1977
|
+
if (request.method === 'POST' && url.pathname === '/api/salas/projects') {
|
|
1978
|
+
const payload = await body(request).catch(() => ({}));
|
|
1979
|
+
const roots = Array.isArray(payload.roots) ? payload.roots.filter((one) => typeof one === 'string').slice(0, 50) : [];
|
|
1980
|
+
if (!roots.length) return sendJson(response, 422, { error: t('Choose at least one folder.') });
|
|
1981
|
+
const added = [];
|
|
1982
|
+
const refused = [];
|
|
1983
|
+
for (const one of roots) {
|
|
1984
|
+
const result = await associateProject({ root: one, home, stateRoot: root });
|
|
1985
|
+
if (result.error) refused.push({ root: one, error: result.error === 'madre' ? t('That folder is MADRE\'s own.') : result.error === 'outside' ? t('Only folders inside your home folder.') : t('That folder is not there.') });
|
|
1986
|
+
else added.push(result);
|
|
1987
|
+
}
|
|
1988
|
+
return sendJson(response, added.length ? 200 : 422, { added, refused });
|
|
1989
|
+
}
|
|
1990
|
+
return sendJson(response, 405, { error: 'method' });
|
|
1991
|
+
}
|
|
1992
|
+
if (request.method === 'POST' && url.pathname === '/api/salas/close') {
|
|
1993
|
+
const payload = await body(request).catch(() => ({}));
|
|
1994
|
+
const room = (await knownRooms(root)).find((one) => one.id === payload.room);
|
|
1995
|
+
if (!room?.open) return sendJson(response, 200, { room: payload.room ?? null, closed: true });
|
|
1996
|
+
if (room.id === basename(roomDir)) {
|
|
1997
|
+
sendJson(response, 202, { room: room.id, closing: true, here: true });
|
|
1998
|
+
setTimeout(() => server.emit('madre:close'), 150);
|
|
1999
|
+
return undefined;
|
|
2000
|
+
}
|
|
2001
|
+
const answer = await askRoom(room.open.port, '/api/room/close', { method: 'POST', body: {} });
|
|
2002
|
+
if (answer.outdated) return sendJson(response, 409, { error: t('That project runs an older MADRE. Update it, or close it from its terminal.'), outdated: true });
|
|
2003
|
+
if (answer.unreachable) return sendJson(response, 502, { error: t('That project did not answer.') });
|
|
2004
|
+
return sendJson(response, 202, { room: room.id, closing: true });
|
|
2005
|
+
}
|
|
2006
|
+
if (request.method === 'POST' && url.pathname === '/api/salas/stop-all') {
|
|
2007
|
+
const rooms = (await knownRooms(root)).filter((one) => one.open);
|
|
2008
|
+
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
|
+
return sendJson(response, 200, { rooms: rooms.length, stopped: stopped.filter(Boolean).length });
|
|
2010
|
+
}
|
|
2011
|
+
// This room, asked to close by Salas: what is running stops and the room closes the way a
|
|
2012
|
+
// signal closes it (startPulse listens). Nothing here ends the process itself.
|
|
2013
|
+
if (request.method === 'POST' && url.pathname === '/api/room/close') {
|
|
2014
|
+
sendJson(response, 202, { closing: true });
|
|
2015
|
+
setTimeout(() => server.emit('madre:close'), 150);
|
|
2016
|
+
return undefined;
|
|
2017
|
+
}
|
|
2018
|
+
// ---------- ENLACES (docs/MACROVERSO.md, fase 3b) ----------
|
|
2019
|
+
// The side that serves a link. Another room on this Mac asks, from its own process, for the
|
|
2020
|
+
// notes a signed contract lets it read; every lock is here, in the room that hands them over,
|
|
2021
|
+
// and in this order: never a phone, never a page, only a process holding the key of this
|
|
2022
|
+
// Mac; one answer for every contract that does not serve this pair (missing, revoked,
|
|
2023
|
+
// expired, facing the other way: the asker is not told which); sealed by CODE000; over its
|
|
2024
|
+
// hourly ceiling. What crosses is what travels into a turn (`travels()`), of the kinds the
|
|
2025
|
+
// contract names, each text through this room's privacy guard, and nothing of the ledger.
|
|
2026
|
+
if (request.method === 'GET' && url.pathname === '/api/enlace/memories') {
|
|
2027
|
+
if (viaPhone) return sendJson(response, 403, { error: t('This is done on the Mac.') });
|
|
2028
|
+
if (request.headers.origin !== undefined) return sendJson(response, 403, { error: t('A page cannot read across a connection; only a project can.') });
|
|
2029
|
+
const key = await channelKey(root).catch(() => null);
|
|
2030
|
+
if (!key || !sameKey(request.headers[CHANNEL_HEADER], key)) return sendJson(response, 403, { error: t('That is not the key of this Mac.') });
|
|
2031
|
+
const here = basename(roomDir);
|
|
2032
|
+
const asking = String(url.searchParams.get('asking') ?? '');
|
|
2033
|
+
const contract = activeContracts(await readConfig(root), { salas: salasRoomId(root) }).find((one) => one.id === url.searchParams.get('contract')) ?? null;
|
|
2034
|
+
if (!mayServe({ contract, serving: here, asking }).ok) return sendJson(response, 404, { error: NO_SUCH_LINK });
|
|
2035
|
+
const sealed = room.motherStatus()?.lockedForMs ?? 0;
|
|
2036
|
+
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 });
|
|
2037
|
+
if (!memory) return sendJson(response, 503, { error: t('The project has no memory.') });
|
|
2038
|
+
const reads = await countRecent(roomDir, contract.id, 'read');
|
|
2039
|
+
if (reads >= contract.orders.perHour) return sendJson(response, 429, { error: t('This connection was read {n} times in the last hour, its limit. Try again later.', { n: reads }) });
|
|
2040
|
+
const q = String(url.searchParams.get('q') ?? '').replace(/\s+/g, ' ').trim().slice(0, 500);
|
|
2041
|
+
const limit = Math.min(ENLACE_MAX_LIMIT, Math.max(1, Math.trunc(Number(url.searchParams.get('limit'))) || ENLACE_MAX_LIMIT));
|
|
2042
|
+
// The kinds asked for, if any, inside the kinds signed: a reading narrows, never widens.
|
|
2043
|
+
const asked = String(url.searchParams.get('kinds') ?? '').split(',').map((kind) => kind.trim()).filter(Boolean);
|
|
2044
|
+
const kinds = asked.length ? contract.kinds.filter((kind) => asked.includes(kind)) : contract.kinds;
|
|
2045
|
+
await refreshPrivacy();
|
|
2046
|
+
// The same search of notes memory_search makes (src/mcp/memory-server.mjs), untracked: a
|
|
2047
|
+
// reading from another room is not this room leaning on a note. Without words, the latest.
|
|
2048
|
+
const rows = q
|
|
2049
|
+
? memory.recallMemories(q, { limit: ENLACE_MAX_LIMIT * 3, maxChars: ENLACE_MAX_LIMIT * 3 * 300, queryVector: await memory.embedQuery(q).catch(() => null), track: false })
|
|
2050
|
+
: memory.memories({ limit: ENLACE_MAX_LIMIT * 3 });
|
|
2051
|
+
const notes = rows
|
|
2052
|
+
.filter((row) => memoryTravels(row) && kinds.includes(row.kind))
|
|
2053
|
+
.slice(0, limit)
|
|
2054
|
+
.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 }));
|
|
2055
|
+
// Which agent of the asking room read (S-021): that room's word, checked there against a turn
|
|
2056
|
+
// it is running; here it is only kept, shaped like an agent id or not at all.
|
|
2057
|
+
const reader = /^[a-z][a-z0-9_-]{0,30}$/.test(String(url.searchParams.get('agent') ?? '')) ? url.searchParams.get('agent') : null;
|
|
2058
|
+
await room.record('enlace.read', { contract: contract.id, to: asking, count: notes.length, kinds, query: q ? createHash('sha256').update(q).digest('hex').slice(0, 16) : null, ...(reader ? { agent: reader } : {}) });
|
|
2059
|
+
await noteCrossing(roomDir, { kind: 'read', contract: contract.id, other: asking, count: notes.length, agent: reader });
|
|
2060
|
+
return sendJson(response, 200, { contract: contract.id, from: here, notes });
|
|
2061
|
+
}
|
|
2062
|
+
// The side that asks (fase 3c): a tool of this room's own turn asks this room, and this room
|
|
2063
|
+
// asks the room that serves, by the channel. The same three doors as above — never a phone,
|
|
2064
|
+
// never a page, only the key — because the tool is a process of this Mac and nothing else is.
|
|
2065
|
+
// `room` is the id or the name of the room to read from; the contract is the one in force
|
|
2066
|
+
// that lets THIS room read from THAT one. What came back is counted here (`enlace.received`,
|
|
2067
|
+
// with the query as a hash) and the notes are handed on; nothing of them is kept.
|
|
2068
|
+
if (request.method === 'POST' && url.pathname === '/api/enlace/ask') {
|
|
2069
|
+
if (viaPhone) return sendJson(response, 403, { error: t('This is done on the Mac.') });
|
|
2070
|
+
if (request.headers.origin !== undefined) return sendJson(response, 403, { error: t('A page cannot read across a connection; only a project can.') });
|
|
2071
|
+
const key = await channelKey(root).catch(() => null);
|
|
2072
|
+
if (!key || !sameKey(request.headers[CHANNEL_HEADER], key)) return sendJson(response, 403, { error: t('That is not the key of this Mac.') });
|
|
2073
|
+
const payload = await body(request).catch(() => ({}));
|
|
2074
|
+
const here = basename(roomDir);
|
|
2075
|
+
// S-021: a reading belongs to a turn. The tool says which turn and which agent; this room
|
|
2076
|
+
// checks that this very turn is running here now, for that agent, above GHOST, and records
|
|
2077
|
+
// the reading in that turn's conversation. A tool that outlived its turn reads nothing.
|
|
2078
|
+
const turnId = String(payload.turn ?? '');
|
|
2079
|
+
const host = turnId ? liveRooms().find((one) => one.runningTurn?.(turnId)) : null;
|
|
2080
|
+
const running = host?.runningTurn(turnId) ?? null;
|
|
2081
|
+
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.') });
|
|
2082
|
+
const wanted = String(payload.room ?? '').trim();
|
|
2083
|
+
const rooms = await knownRooms(root);
|
|
2084
|
+
const named = rooms.filter((one) => one.id === wanted);
|
|
2085
|
+
if (!named.length) named.push(...rooms.filter((one) => one.name.toLowerCase() === wanted.toLowerCase()));
|
|
2086
|
+
if (!wanted || !named.length) return sendJson(response, 404, { error: t('MADRE does not know that project on this Mac.') });
|
|
2087
|
+
const readable = readsFrom(activeContracts(await readConfig(root), { salas: salasRoomId(root) }), here);
|
|
2088
|
+
const link = named.map((source) => ({ source, contract: readable.find((one) => one.from === source.id)?.contract ?? null })).find((one) => one.contract) ?? null;
|
|
2089
|
+
if (!link) return sendJson(response, 404, { error: NO_SUCH_LINK });
|
|
2090
|
+
const { source, contract } = link;
|
|
2091
|
+
const asked = Array.isArray(payload.kinds) ? payload.kinds.map(String) : [];
|
|
2092
|
+
const kinds = asked.length ? contract.kinds.filter((kind) => asked.includes(kind)) : contract.kinds;
|
|
2093
|
+
if (!kinds.length) return sendJson(response, 422, { error: t('This connection lets this project read only: {kinds}.', { kinds: contract.kinds.join(', ') }) });
|
|
2094
|
+
if (!source.open) return sendJson(response, 409, { error: t('That project is asleep. Wake it from Home to read from it.') });
|
|
2095
|
+
const q = String(payload.query ?? '').replace(/\s+/g, ' ').trim().slice(0, 500);
|
|
2096
|
+
const answer = await askEnlace({
|
|
2097
|
+
port: source.open.port, contract, asking: here, q, kinds: asked.length ? kinds : null, limit: payload.limit, key, turn: turnId, agent: running.agent,
|
|
2098
|
+
record: (type, event) => host.record(type, { ...event, query: q ? createHash('sha256').update(q).digest('hex').slice(0, 16) : null }),
|
|
2099
|
+
roomDir,
|
|
2100
|
+
});
|
|
2101
|
+
if (answer.notes) return sendJson(response, 200, { from: source.name, contract: contract.id, notes: answer.notes });
|
|
2102
|
+
if (answer.outdated) return sendJson(response, 409, { error: t('That project runs an older MADRE. Update it, or close it from its terminal.') });
|
|
2103
|
+
if (answer.unreachable) return sendJson(response, 502, { error: t('That project did not answer.') });
|
|
2104
|
+
return sendJson(response, answer.status ?? 502, { error: answer.refused ?? t('That project did not answer.') });
|
|
2105
|
+
}
|
|
2106
|
+
// What another room tells this one by the channel: a link of this room's was signed or
|
|
2107
|
+
// revoked. One line in the ledger, never a message; any other type is refused.
|
|
2108
|
+
if (request.method === 'POST' && url.pathname === '/api/enlace/notice') {
|
|
2109
|
+
if (viaPhone) return sendJson(response, 403, { error: t('This is done on the Mac.') });
|
|
2110
|
+
if (request.headers.origin !== undefined) return sendJson(response, 403, { error: t('A page cannot read across a connection; only a project can.') });
|
|
2111
|
+
const key = await channelKey(root).catch(() => null);
|
|
2112
|
+
if (!key || !sameKey(request.headers[CHANNEL_HEADER], key)) return sendJson(response, 403, { error: t('That is not the key of this Mac.') });
|
|
2113
|
+
const payload = await body(request).catch(() => ({}));
|
|
2114
|
+
if (!ENLACE_NOTICES.includes(payload.type) || !payload.payload || typeof payload.payload !== 'object' || Array.isArray(payload.payload)) return sendJson(response, 422, { error: t('That is not a notice a connection sends.') });
|
|
2115
|
+
await room.record(payload.type, payload.payload);
|
|
2116
|
+
return sendJson(response, 202, { accepted: true });
|
|
2117
|
+
}
|
|
2118
|
+
// Signing and revoking, from Salas (fase 3c). A change of state, so it comes only from this
|
|
2119
|
+
// room's own page or a process of this Mac (src/request-guard.mjs) and never from a phone
|
|
2120
|
+
// (above). Both rooms are told, if they are open: this one straight into its ledger, the
|
|
2121
|
+
// other by the channel; a room that is asleep reads the contract when it wakes.
|
|
2122
|
+
const tellRooms = async (type, contract, rooms) => {
|
|
2123
|
+
if (typeof contract?.rooms?.a !== 'string' || typeof contract?.rooms?.b !== 'string') return;
|
|
2124
|
+
const key = await channelKey(root).catch(() => null);
|
|
2125
|
+
const here = basename(roomDir);
|
|
2126
|
+
const [a, b] = [contract.rooms.a, contract.rooms.b].map((id) => rooms.find((one) => one.id === id) ?? { id, name: id, open: null });
|
|
2127
|
+
for (const [me, other] of [[a, b], [b, a]]) {
|
|
2128
|
+
const role = contract.direction === 'both' ? 'both' : servingRooms(contract).includes(me.id) ? 'serves' : 'reads';
|
|
2129
|
+
const payload = type === 'enlace.signed'
|
|
2130
|
+
? { contract, other: other.name, otherId: other.id, coordinator: other.id === salasRoomId(root), role, kinds: contract.kinds, expires: contract.expires, orders: contract.orders }
|
|
2131
|
+
: { contract, other: other.name, otherId: other.id, coordinator: other.id === salasRoomId(root) };
|
|
2132
|
+
if (me.id === here) await room.record(type, payload);
|
|
2133
|
+
else if (me.open && key) await noticeEnlace({ port: me.open.port, type, payload, key }).catch(() => null);
|
|
2134
|
+
}
|
|
2135
|
+
};
|
|
2136
|
+
if (request.method === 'POST' && url.pathname === '/api/salas/enlaces') {
|
|
2137
|
+
const payload = await body(request).catch(() => ({}));
|
|
2138
|
+
const rooms = await knownRooms(root);
|
|
2139
|
+
for (const id of [payload.a, payload.b]) if (!rooms.some((one) => one.id === id)) return sendJson(response, 404, { error: t('MADRE does not know that project on this Mac.') });
|
|
2140
|
+
// Two projects are connected once: signContract checks it under the lock (src/enlaces.mjs).
|
|
2141
|
+
let contract;
|
|
2142
|
+
try {
|
|
2143
|
+
// BISHOP signs only from the room's own process (no Origin: never a page), and the contract
|
|
2144
|
+
// itself holds it to a link to read that expires within a week (D-S13, src/enlaces.mjs).
|
|
2145
|
+
const signed = payload.by === 'bishop' && request.headers.origin === undefined ? { by: 'bishop' } : undefined;
|
|
2146
|
+
// «Include always» only from the page (INI-04 of the 1.0 review): its own route refuses a
|
|
2147
|
+
// process, and signing let one set it anyway, so a project got another's notes every turn.
|
|
2148
|
+
const recall = payload.recall === true && !signed && request.headers.origin !== undefined;
|
|
2149
|
+
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 }, { rooms, salas: salasRoomId(root), designation: payload.designation ?? null });
|
|
2150
|
+
} catch (error) {
|
|
2151
|
+
if (error.code === 'DESIGNATION') return sendJson(response, 403, { error: t('UNABLE TO COMPUTE. UNABLE TO CLARIFY.') });
|
|
2152
|
+
if (error.code === 'ALREADY') return sendJson(response, 409, { error: t(error.message) });
|
|
2153
|
+
return sendJson(response, error.code === 'UNKNOWN_ROOM' ? 404 : 422, { error: t(error.message) });
|
|
2154
|
+
}
|
|
2155
|
+
await tellRooms('enlace.signed', contract, rooms);
|
|
2156
|
+
return sendJson(response, 200, { contract });
|
|
2157
|
+
}
|
|
2158
|
+
// Fase 3e: the box «recall on every turn» of a link, ticked or cleared by the human from Salas
|
|
2159
|
+
// (never a phone, never a process: the page asks). It changes what each turn of the room that
|
|
2160
|
+
// reads is handed, not what that room may read; the contract is otherwise untouched.
|
|
2161
|
+
if (request.method === 'POST' && url.pathname === '/api/salas/enlaces/recall') {
|
|
2162
|
+
const payload = await body(request).catch(() => ({}));
|
|
2163
|
+
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 is on or off.') });
|
|
2165
|
+
const contract = await recallContract(root, String(payload.id ?? ''), payload.recall, { salas: salasRoomId(root) });
|
|
2166
|
+
if (!contract) return sendJson(response, 404, { error: NO_SUCH_LINK });
|
|
2167
|
+
return sendJson(response, 200, { contract });
|
|
2168
|
+
}
|
|
2169
|
+
if (request.method === 'POST' && url.pathname === '/api/salas/enlaces/revoke') {
|
|
2170
|
+
const payload = await body(request).catch(() => ({}));
|
|
2171
|
+
const id = String(payload.id ?? '');
|
|
2172
|
+
// Told once: a link revoked twice keeps its first stamp and says nothing new.
|
|
2173
|
+
const already = Boolean((await readConfig(root)).enlaces?.[id]?.revoked);
|
|
2174
|
+
const contract = await revokeContract(root, id);
|
|
2175
|
+
if (!contract) return sendJson(response, 404, { error: NO_SUCH_LINK });
|
|
2176
|
+
if (!already) await tellRooms('enlace.revoked', contract, await knownRooms(root)).catch(() => null);
|
|
2177
|
+
return sendJson(response, 200, { contract });
|
|
2178
|
+
}
|
|
2179
|
+
// Taking a project out of MADRE (2026-10-10), from Home and only from its page on this Mac, as
|
|
2180
|
+
// adding one is. Never the Coordinator's own, never the room that is asked. Open, it is closed
|
|
2181
|
+
// first; its connections end with it, so none is left pointing at a project MADRE no longer
|
|
2182
|
+
// knows; then its record goes aside (forgetProject). Its folder on the Mac is not touched.
|
|
2183
|
+
if (request.method === 'POST' && url.pathname === '/api/salas/forget') {
|
|
2184
|
+
const host = String(request.headers.host ?? '');
|
|
2185
|
+
const site = request.headers['sec-fetch-site'];
|
|
2186
|
+
if (!/^(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('This is done from Home, on this Mac.') });
|
|
2187
|
+
const payload = await body(request).catch(() => ({}));
|
|
2188
|
+
const id = String(payload.room ?? '');
|
|
2189
|
+
const salas = salasRoomId(root);
|
|
2190
|
+
if (id === salas || id === basename(roomDir)) return sendJson(response, 422, { error: t('The Coordinator\'s project stays in MADRE.') });
|
|
2191
|
+
const find = async () => (await knownRooms(root)).find((one) => one.id === id) ?? null;
|
|
2192
|
+
let one = await find();
|
|
2193
|
+
if (!one) return sendJson(response, 404, { error: t('MADRE does not know that project on this Mac.') });
|
|
2194
|
+
if (one.open) {
|
|
2195
|
+
const answer = await askRoom(one.open.port, '/api/room/close', { method: 'POST', body: {} });
|
|
2196
|
+
if (answer.outdated) return sendJson(response, 409, { error: t('That project runs an older MADRE. Update it, or close it from its terminal.'), outdated: true });
|
|
2197
|
+
if (answer.unreachable) return sendJson(response, 502, { error: t('That project did not answer.') });
|
|
2198
|
+
for (let tries = 0; tries < 40 && one?.open; tries += 1) { await new Promise((resolve) => setTimeout(resolve, 250)); one = await find(); }
|
|
2199
|
+
if (one?.open) return sendJson(response, 504, { error: t('That project did not close. Close it and try again.') });
|
|
2200
|
+
}
|
|
2201
|
+
const ended = [];
|
|
2202
|
+
for (const contract of activeContracts(await readConfig(root), { salas })) {
|
|
2203
|
+
if (contract.rooms.a !== id && contract.rooms.b !== id) continue;
|
|
2204
|
+
const revoked = await revokeContract(root, contract.id);
|
|
2205
|
+
if (!revoked) continue;
|
|
2206
|
+
ended.push(revoked.id);
|
|
2207
|
+
await tellRooms('enlace.revoked', revoked, await knownRooms(root)).catch(() => null);
|
|
2208
|
+
}
|
|
2209
|
+
const forgotten = await forgetProject({ stateRoot: root, id });
|
|
2210
|
+
// A room from before MADRE kept the path has no record to set aside: closed, it is gone.
|
|
2211
|
+
if (forgotten.error && await find()) return sendJson(response, 404, { error: t('MADRE does not know that project on this Mac.') });
|
|
2212
|
+
return sendJson(response, 200, { id, name: forgotten.name ?? one?.name ?? id, root: forgotten.root ?? null, ended });
|
|
2213
|
+
}
|
|
2214
|
+
// ---------- fin ENLACES ----------
|
|
2215
|
+
// Where the person was, to pick it up on the other side (bridge/handoff.mjs).
|
|
2216
|
+
if (url.pathname === '/api/handoff') {
|
|
2217
|
+
if (request.method === 'GET') return sendJson(response, 200, await readHandoff(root));
|
|
2218
|
+
if (request.method === 'POST') {
|
|
2219
|
+
const payload = await body(request).catch(() => ({}));
|
|
2220
|
+
const { chats } = await listChats(roomDir);
|
|
2221
|
+
const chat = chats.find((one) => one.id === payload.chat) ?? chats.find((one) => one.active) ?? null;
|
|
2222
|
+
const saved = await writeHandoff(root, viaPhone ? 'phone' : 'mac', { room: basename(roomDir), project: basename(canonicalProjectRoot), port: openPort, chat: chat?.id ?? null, chatTitle: chat?.title ?? null, anchor: payload.anchor, draft: payload.draft, kind: phoneKind });
|
|
2223
|
+
return sendJson(response, 200, { saved });
|
|
2224
|
+
}
|
|
2225
|
+
}
|
|
919
2226
|
if (request.method === 'GET' && url.pathname === '/api/state') {
|
|
2227
|
+
// `?chat=` reads another conversation, held open beside the one on screen; the room-wide
|
|
2228
|
+
// parts are the same, the record and what is running in it are that conversation's.
|
|
2229
|
+
const here = await scopeOf(url);
|
|
2230
|
+
if (here.error) return sendJson(response, 404, here);
|
|
2231
|
+
const room = here.room;
|
|
2232
|
+
const store = here.store;
|
|
920
2233
|
return sendJson(response, 200, {
|
|
2234
|
+
...(here.main ? {} : { chat: here.id }),
|
|
921
2235
|
projectRoot,
|
|
2236
|
+
// The project's name as this system writes paths (WEB-11): the page cut it at «/», and on
|
|
2237
|
+
// Windows the name was the whole path, so the key for #3 never matched.
|
|
2238
|
+
projectName: basename(canonicalProjectRoot),
|
|
922
2239
|
platform: process.platform,
|
|
2240
|
+
// Behind the bridge, the page is on a phone and knows it. On the Mac it learns whether a
|
|
2241
|
+
// phone is paired, so it keeps its place for the phone only when there is one.
|
|
2242
|
+
device: viaPhone ? { kind: phoneKind } : null,
|
|
2243
|
+
// Whether this Mac offers the phone (a preview in 1.0); behind the bridge, it does.
|
|
2244
|
+
phonePreview: viaPhone ? true : await phonePreview(),
|
|
2245
|
+
// The room of Salas knows it is: true only there, false in every other room.
|
|
2246
|
+
salas: salasRoom,
|
|
2247
|
+
room: basename(roomDir),
|
|
2248
|
+
phones: viaPhone ? undefined : await phonePreview() ? (await readStore(root).catch(() => ({ devices: [] }))).devices.length : 0,
|
|
923
2249
|
// Which MADRE this is. The page prints it in the corner: the one fact a bug report
|
|
924
2250
|
// always needs and nobody ever remembers to ask for.
|
|
925
2251
|
version: PACKAGE.version,
|
|
2252
|
+
// Which start of this process the page is talking to. The page draws its logo when this
|
|
2253
|
+
// changes — MADRE restarted — and not on every reload of the same running room.
|
|
2254
|
+
boot: BOOT_ID,
|
|
926
2255
|
// What language this machine set MADRE to, for a browser that has never chosen. It is
|
|
927
2256
|
// the language the room is SPEAKING, not the one written in the file: PULSE_LANGUAGE
|
|
928
2257
|
// wins over the config here as it does everywhere else, and reading the file again
|
|
929
2258
|
// would hand the page a different answer from the one the server itself is using.
|
|
930
2259
|
language: roomLanguage(),
|
|
931
2260
|
// Which conversation this page is looking at, and the others it could open.
|
|
932
|
-
chats: await
|
|
2261
|
+
chats: await chatsView(),
|
|
933
2262
|
agents: agents.map((agent) => ({ ...agent, login: loginPlanFor(agent), install: installPlanFor(agent), key: keyPlanFor(agent.id), ...(accountNoteFor(agent.id) ?? {}) })),
|
|
934
2263
|
ash: { enabled: room.ashEnabled() },
|
|
935
2264
|
choices: { enabled: room.choicesEnabled() },
|
|
@@ -941,6 +2270,7 @@ export async function createPulseServer({
|
|
|
941
2270
|
ripleyWeb: Boolean((await readConfig(root)).modules?.ripley?.web),
|
|
942
2271
|
ripley: { enabled: await ripleyOn() },
|
|
943
2272
|
softTokenBudget,
|
|
2273
|
+
override: publicOverride(overrideConfig),
|
|
944
2274
|
timeouts: Object.fromEntries(agents.map((agent) => [agent.id, room.timeoutFor(agent.id)])),
|
|
945
2275
|
delegation: { enabled: room.settings().delegation, maxPlanSteps: room.settings().maxPlanSteps },
|
|
946
2276
|
plans: room.activePlans(),
|
|
@@ -949,6 +2279,7 @@ export async function createPulseServer({
|
|
|
949
2279
|
control: room.control(),
|
|
950
2280
|
sessions,
|
|
951
2281
|
sessionsAt,
|
|
2282
|
+
login: loginState(),
|
|
952
2283
|
capabilities: room.capabilities(),
|
|
953
2284
|
quotaSources: quotaMonitor.snapshot(),
|
|
954
2285
|
budgetWindow: room.budgetWindow(),
|
|
@@ -958,6 +2289,10 @@ export async function createPulseServer({
|
|
|
958
2289
|
if (request.method === 'GET' && url.pathname === '/api/events') {
|
|
959
2290
|
const sinceValue = request.headers['last-event-id'] ?? url.searchParams.get('since');
|
|
960
2291
|
const since = sinceValue === undefined || sinceValue === null || sinceValue === '' ? null : Number(sinceValue);
|
|
2292
|
+
// `?chat=` watches a conversation held open beside the one on screen, on a feed of its own.
|
|
2293
|
+
const here = await scopeOf(url);
|
|
2294
|
+
if (here.error) return sendJson(response, 404, here);
|
|
2295
|
+
const feed = here.main ? mainFeed : await watchHeld(here.entry);
|
|
961
2296
|
response.writeHead(200, {
|
|
962
2297
|
'content-type': 'text/event-stream',
|
|
963
2298
|
'cache-control': 'no-cache',
|
|
@@ -966,17 +2301,22 @@ export async function createPulseServer({
|
|
|
966
2301
|
response.write(': connected\n\n');
|
|
967
2302
|
// Replay what this client missed between /api/state and this connection,
|
|
968
2303
|
// then register it so the shared cursor delivers everything newer.
|
|
969
|
-
const events = Number.isFinite(since) ? await store.readAll() : [];
|
|
2304
|
+
const events = Number.isFinite(since) ? await feed.store.readAll() : [];
|
|
970
2305
|
let floor = Number.isFinite(since) ? since : 0;
|
|
971
2306
|
for (const event of events) {
|
|
972
|
-
if (event.sequence > floor && event.sequence <=
|
|
973
|
-
writeEvent(response, event);
|
|
2307
|
+
if (event.sequence > floor && event.sequence <= feed.sent) {
|
|
2308
|
+
writeEvent(response, event, feed);
|
|
974
2309
|
floor = event.sequence;
|
|
975
2310
|
}
|
|
976
2311
|
}
|
|
977
|
-
clients.set(response, floor);
|
|
978
|
-
request.on('close', () =>
|
|
979
|
-
|
|
2312
|
+
feed.clients.set(response, floor);
|
|
2313
|
+
request.on('close', () => {
|
|
2314
|
+
feed.clients.delete(response);
|
|
2315
|
+
// The last page watching a held conversation went away: it closes unless it is at work. A
|
|
2316
|
+
// moment later, so a page that is only reconnecting finds the same room.
|
|
2317
|
+
if (!here.main) setTimeout(() => { void releaseIfIdle(here.id, here.room); }, 1500).unref?.();
|
|
2318
|
+
});
|
|
2319
|
+
void pump(feed);
|
|
980
2320
|
return;
|
|
981
2321
|
}
|
|
982
2322
|
// Copying and moving, by the human's own hand. Both ends are resolved inside the project
|
|
@@ -996,6 +2336,7 @@ export async function createPulseServer({
|
|
|
996
2336
|
}
|
|
997
2337
|
const plan = planFileOp(payload);
|
|
998
2338
|
if (!plan.ok) return sendJson(response, 400, { error: t(plan.error) });
|
|
2339
|
+
if (!plan.guarded) plan.guarded = await needsDesignationOnDisk(canonicalProjectRoot, plan.from, plan.to);
|
|
999
2340
|
if (plan.guarded && !designationOk(payload.designation)) {
|
|
1000
2341
|
return sendJson(response, 403, { error: t('UNABLE TO COMPUTE. UNABLE TO CLARIFY.'), guarded: true });
|
|
1001
2342
|
}
|
|
@@ -1040,9 +2381,43 @@ export async function createPulseServer({
|
|
|
1040
2381
|
'x-content-type-options': 'nosniff',
|
|
1041
2382
|
'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; img-src data:",
|
|
1042
2383
|
'content-disposition': `inline; filename="${encodeURIComponent(basename(file.path))}"`,
|
|
2384
|
+
// When the editor opened it: saving checks the file is still the one it opened.
|
|
2385
|
+
'x-mtime': String(file.mtimeMs ?? ''),
|
|
1043
2386
|
});
|
|
1044
2387
|
return response.end(file.body);
|
|
1045
2388
|
}
|
|
2389
|
+
// The editor saves a project file. The human's own hand, so no mode and no lease — but the
|
|
2390
|
+
// same two fences the file manager keeps: inside the project after following links, and the
|
|
2391
|
+
// zones the room guards (.git, .pulse, .madre, a .env) ask for the project's designation.
|
|
2392
|
+
// A new, empty file from the Finder; guarded paths ask for the designation, like saving.
|
|
2393
|
+
if (request.method === 'POST' && url.pathname === '/api/files') {
|
|
2394
|
+
const payload = await body(request).catch(() => ({}));
|
|
2395
|
+
const relative = String(payload.path ?? '').trim().replace(/^\/+/, '');
|
|
2396
|
+
if (relative && await needsDesignationOnDisk(canonicalProjectRoot, relative) && !designationOk(payload.designation)) {
|
|
2397
|
+
return sendJson(response, 403, { error: t('This file is one the project guards. Type the project name to save it.'), guarded: true });
|
|
2398
|
+
}
|
|
2399
|
+
const result = await createEmptyFile(canonicalProjectRoot, relative);
|
|
2400
|
+
if (result.status !== 201) return sendJson(response, result.status, { error: t(result.error) });
|
|
2401
|
+
await room.record('project.file.created', { path: result.path });
|
|
2402
|
+
return sendJson(response, 201, { ok: true, path: result.path });
|
|
2403
|
+
}
|
|
2404
|
+
if (request.method === 'PUT' && url.pathname === '/api/files') {
|
|
2405
|
+
// The editor saves up to MAX_EDIT_BYTES; written as JSON a file can take a few times its
|
|
2406
|
+
// size (every newline and quote escaped). Too large is said as that, not as «Nothing to save».
|
|
2407
|
+
let payload;
|
|
2408
|
+
try { payload = await body(request, { limit: MAX_EDIT_BYTES * 3 + 65536 }); } catch (error) {
|
|
2409
|
+
return sendJson(response, error.status === 413 ? 413 : 400, { error: error.status === 413 ? t('The editor saves files up to {mb} MB.', { mb: MAX_EDIT_BYTES / 1024 / 1024 }) : t('That could not be read.') });
|
|
2410
|
+
}
|
|
2411
|
+
const relative = String(payload.path ?? '').replace(/^\/+/, '');
|
|
2412
|
+
if (!relative) return sendJson(response, 400, { error: t('Nothing to save.') });
|
|
2413
|
+
if (await needsDesignationOnDisk(canonicalProjectRoot, relative) && !designationOk(payload.designation)) {
|
|
2414
|
+
return sendJson(response, 403, { error: t('This file is one the project guards. Type the project name to save it.'), guarded: true });
|
|
2415
|
+
}
|
|
2416
|
+
const result = await writeEdited(canonicalProjectRoot, relative, payload.content, { mtimeMs: payload.mtimeMs, force: payload.force === true });
|
|
2417
|
+
if (result.status !== 200) return sendJson(response, result.status, { error: result.status === 413 ? t('The editor saves files up to {mb} MB.', { mb: MAX_EDIT_BYTES / 1024 / 1024 }) : t(result.error), changed: result.changed, mtimeMs: result.mtimeMs });
|
|
2418
|
+
await room.record('project.file.saved', { path: relative, bytes: result.bytes });
|
|
2419
|
+
return sendJson(response, 200, { ok: true, bytes: result.bytes, mtimeMs: result.mtimeMs });
|
|
2420
|
+
}
|
|
1046
2421
|
// RIPLEY: the project rendered as a site, inside a frame that runs scripts but
|
|
1047
2422
|
// reaches nothing: an opaque origin (sandbox without same-origin), no network
|
|
1048
2423
|
// (connect-src none), no forms, no top navigation, assets only from MADRE's
|
|
@@ -1050,7 +2425,7 @@ export async function createPulseServer({
|
|
|
1050
2425
|
// resolve to the same route.
|
|
1051
2426
|
const previewMatch = request.method === 'GET' && url.pathname.match(/^\/preview\/(project|attachments)\/(.*)$/);
|
|
1052
2427
|
if (previewMatch) {
|
|
1053
|
-
if (!(await ripleyOn())) return sendJson(response, 412, { error: t('
|
|
2428
|
+
if (!(await ripleyOn())) return sendJson(response, 412, { error: t('Preview is off. Enable it in MODULES to render files.') });
|
|
1054
2429
|
const which = previewMatch[1] === 'attachments' ? attachmentsRoot : canonicalProjectRoot;
|
|
1055
2430
|
const relative = decodeURIComponent(previewMatch[2]);
|
|
1056
2431
|
const file = await readServable(which, relative);
|
|
@@ -1069,6 +2444,13 @@ export async function createPulseServer({
|
|
|
1069
2444
|
'content-disposition': `inline; filename="${encodeURIComponent(basename(file.path))}"`,
|
|
1070
2445
|
};
|
|
1071
2446
|
if (isPage) headers['content-security-policy'] = `sandbox allow-scripts; default-src 'none'; script-src 'unsafe-inline' ${origin}; style-src 'unsafe-inline' ${origin}; img-src ${origin} data: blob:; font-src ${origin} data:; media-src ${origin} data: blob:; connect-src 'none'; frame-src 'none'; object-src 'none'; form-action 'none'; base-uri 'none'`;
|
|
2447
|
+
// Everything else is sealed too (SRV-02, 2026-10-12): an SVG or an XML file carries scripts,
|
|
2448
|
+
// and opened on its own — a link in an answer, a frame the project's server navigates — it
|
|
2449
|
+
// ran with the project's origin and reached its API. Sealed, it is drawn and runs nothing.
|
|
2450
|
+
// A PDF keeps the browser's own viewer, which a sealed document cannot open; the scripts and
|
|
2451
|
+
// styles a page links are left as they were (opened on their own they are text, and a
|
|
2452
|
+
// worker the page starts would be shut by a policy meant for documents).
|
|
2453
|
+
else if (!/^(application\/pdf|text\/css|(text|application)\/javascript)\b/i.test(file.contentType ?? '')) headers['content-security-policy'] = `sandbox; default-src 'none'; img-src ${origin} data:; style-src 'unsafe-inline'; media-src ${origin} data: blob:`;
|
|
1072
2454
|
response.writeHead(200, headers);
|
|
1073
2455
|
return response.end(bodyOut);
|
|
1074
2456
|
}
|
|
@@ -1080,15 +2462,61 @@ export async function createPulseServer({
|
|
|
1080
2462
|
return sendJson(response, error.status ?? 400, { error: error.message });
|
|
1081
2463
|
}
|
|
1082
2464
|
const name = decodeURIComponent(request.headers['x-pulse-filename'] ?? url.searchParams.get('name') ?? 'attachment');
|
|
1083
|
-
const
|
|
2465
|
+
const here = await scopeOf(url);
|
|
2466
|
+
if (here.error) return sendJson(response, 404, here);
|
|
2467
|
+
const record = await here.room.registerAttachment(await storeAttachment(attachmentsRoot, { name, bytes }));
|
|
1084
2468
|
return sendJson(response, 201, { attachment: { id: record.id, name: record.name, fileName: record.fileName, size: record.size, contentType: record.contentType, url: `/api/files?root=attachments&path=${encodeURIComponent(record.fileName)}` } });
|
|
1085
2469
|
}
|
|
2470
|
+
// The key for #3 and #4, checked here (src/override.mjs): what was typed, once or twice, and a
|
|
2471
|
+
// pass back for that agent and mode. Never from a phone: #3 and #4 are opened on the Mac.
|
|
2472
|
+
if (request.method === 'POST' && url.pathname === '/api/override/arm') {
|
|
2473
|
+
if (viaPhone) return sendJson(response, 403, { error: t('#3 EDIT and #4 FULL ACCESS are opened from the Mac.') });
|
|
2474
|
+
const payload = await body(request).catch(() => ({}));
|
|
2475
|
+
const mode = Number(payload.mode);
|
|
2476
|
+
const agent = String(payload.agent ?? '');
|
|
2477
|
+
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
|
+
const now = Date.now();
|
|
2479
|
+
while (keyMisses.length && keyMisses[0] < now - 60000) keyMisses.shift();
|
|
2480
|
+
if (keyMisses.length >= 8) return sendJson(response, 429, { error: t('Too many wrong keys. Wait a minute and try again.') });
|
|
2481
|
+
if (!keyHolds(overrideConfig, { mode, words: payload.words, designation: basename(canonicalProjectRoot) })) {
|
|
2482
|
+
keyMisses.push(now);
|
|
2483
|
+
return sendJson(response, 403, { error: t('That is not it. Try again, or cancel.'), denied: true });
|
|
2484
|
+
}
|
|
2485
|
+
return sendJson(response, 200, issuePass(agent, mode));
|
|
2486
|
+
}
|
|
2487
|
+
// This project's switch for the local AI (src/local-ai.mjs). In «auto» Home decides and the
|
|
2488
|
+
// switch does not move; /apply is how Home tells an open project the choice changed.
|
|
2489
|
+
if (request.method === 'GET' && url.pathname === '/api/local-ai') return sendJson(response, 200, { project: await localAiHere(), running: Boolean(ollama.running) });
|
|
2490
|
+
if (request.method === 'POST' && url.pathname === '/api/local-ai/apply') {
|
|
2491
|
+
const status = await wireOllama({ probe: false });
|
|
2492
|
+
return sendJson(response, 200, { project: status.project });
|
|
2493
|
+
}
|
|
2494
|
+
if (request.method === 'POST' && url.pathname === '/api/local-ai') {
|
|
2495
|
+
const patch = await body(request);
|
|
2496
|
+
if (typeof patch?.on !== 'boolean') return sendJson(response, 400, { error: t('Say on or off.') });
|
|
2497
|
+
if (salasRoom) return sendJson(response, 409, { error: t('The Coordinator follows the local AI switch of Modules.') });
|
|
2498
|
+
const status = await setLocalAiHere(patch.on);
|
|
2499
|
+
if (!status) return sendJson(response, 409, { error: t('Home turns the local AI on in every project. Change it in Home › Settings › Local AI.') });
|
|
2500
|
+
return sendJson(response, 200, { project: status.project });
|
|
2501
|
+
}
|
|
1086
2502
|
if (request.method === 'GET' && url.pathname === '/api/settings') {
|
|
1087
2503
|
await refreshPrivacy();
|
|
1088
|
-
|
|
2504
|
+
const stored = await readConfig(root);
|
|
2505
|
+
if (stored.override) stored.override = publicOverride(stored.override);
|
|
2506
|
+
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) ?? {}) })) });
|
|
1089
2507
|
}
|
|
1090
2508
|
if (request.method === 'POST' && url.pathname === '/api/settings') {
|
|
1091
|
-
|
|
2509
|
+
const patch = await body(request);
|
|
2510
|
+
// Permissions and the key that opens #3 and #4 are changed on the Mac.
|
|
2511
|
+
if (viaPhone && (patch?.scopes !== undefined || patch?.override !== undefined)) return sendJson(response, 403, { error: t('Permissions are changed on the Mac.') });
|
|
2512
|
+
if (patch?.override && typeof patch.override === 'object') {
|
|
2513
|
+
const proof = patch.override.proof;
|
|
2514
|
+
delete patch.override.proof;
|
|
2515
|
+
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 the keys.'), needsPhrase: true });
|
|
2517
|
+
}
|
|
2518
|
+
}
|
|
2519
|
+
return sendJson(response, 200, { settings: await applySettings(patch) });
|
|
1092
2520
|
}
|
|
1093
2521
|
// NOSTROMO: the archive is behind the project designation, like CONTROL.
|
|
1094
2522
|
// Sentinel: reports, settings, the manual road (a prefilled issue) and the automatic one.
|
|
@@ -1119,7 +2547,8 @@ export async function createPulseServer({
|
|
|
1119
2547
|
const moduleRoute = findModuleRoute(request.method, url.pathname);
|
|
1120
2548
|
if (moduleRoute) {
|
|
1121
2549
|
const payload = request.method === 'POST' ? await body(request).catch(() => ({})) : {};
|
|
1122
|
-
|
|
2550
|
+
// `viaPhone` (encargo §6, excepción 4): a module can refuse the phone by its own hand.
|
|
2551
|
+
const result = await moduleRoute.route.handler(await moduleContext(), { request, url, params: moduleRoute.params, payload, viaPhone });
|
|
1123
2552
|
return sendJson(response, result.status ?? 200, result.body ?? {});
|
|
1124
2553
|
}
|
|
1125
2554
|
// Where this room's tokens go: what each block of the briefing has cost, how much came back
|
|
@@ -1146,7 +2575,7 @@ export async function createPulseServer({
|
|
|
1146
2575
|
if (!/^https?:$/.test(target.protocol)) return sendJson(response, 400, { error: t('Only http and https.') });
|
|
1147
2576
|
const local = ['127.0.0.1', 'localhost', '[::1]'].includes(target.hostname);
|
|
1148
2577
|
if (!local && !(await readConfig(root)).modules?.ripley?.web) {
|
|
1149
|
-
return sendJson(response, 403, { error: t('
|
|
2578
|
+
return sendJson(response, 403, { error: t('PREVIEW reaches this computer only · turn the web on in MODULES to open an address outside it') });
|
|
1150
2579
|
}
|
|
1151
2580
|
try {
|
|
1152
2581
|
const controller = new AbortController();
|
|
@@ -1159,32 +2588,36 @@ export async function createPulseServer({
|
|
|
1159
2588
|
}
|
|
1160
2589
|
}
|
|
1161
2590
|
if (request.method === 'GET' && url.pathname === '/api/ports') {
|
|
1162
|
-
if (!(await ripleyOn())) return sendJson(response, 412, { error: t('
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
} catch { return sendJson(response, 200, { ports: [], wider: [], probed: false }); }
|
|
1168
|
-
const ports = [];
|
|
1169
|
-
for (const found of listening.ports.slice(0, 24)) {
|
|
1170
|
-
const probe = await probePort(found.port, { fetchImpl: reportFetch });
|
|
1171
|
-
if (probe.ok) ports.push({ ...found, status: probe.status, html: probe.html, contentType: probe.contentType, framable: probe.framable });
|
|
1172
|
-
}
|
|
1173
|
-
// What is listening on every interface travels too. It is not offered — this room does not
|
|
1174
|
-
// help anyone frame something reachable from the network — but saying nothing about it is
|
|
1175
|
-
// what made an empty list read as «there is nothing here» when there plainly was.
|
|
1176
|
-
return sendJson(response, 200, { ports, wider: listening.wider.slice(0, 8).map(({ port, command }) => ({ port, command })), probed: true });
|
|
2591
|
+
if (!(await ripleyOn())) return sendJson(response, 412, { error: t('Preview is off. Enable it in MODULES to render files.') });
|
|
2592
|
+
// Only this project's servers, read by the servers sentinel: the same reading its watch
|
|
2593
|
+
// makes, so the menu and the live updates never disagree about what is here.
|
|
2594
|
+
const read = await serverSentinel.scan();
|
|
2595
|
+
return sendJson(response, 200, { ...read, wider: read.wider.slice(0, 8) });
|
|
1177
2596
|
}
|
|
1178
2597
|
if (request.method === 'GET' && url.pathname === '/api/questions') {
|
|
1179
2598
|
const limit = Math.min(5, Math.max(1, Number(url.searchParams.get('limit') ?? 3) || 3));
|
|
1180
|
-
|
|
2599
|
+
const here = await scopeOf(url);
|
|
2600
|
+
if (here.error) return sendJson(response, 404, here);
|
|
2601
|
+
return sendJson(response, 200, { questions: here.room.questions({ limit }) });
|
|
1181
2602
|
}
|
|
1182
2603
|
if (request.method === 'GET' && url.pathname === '/api/decisions') {
|
|
1183
2604
|
const limit = Math.min(5, Math.max(1, Number(url.searchParams.get('limit') ?? 3) || 3));
|
|
1184
|
-
|
|
2605
|
+
const here = await scopeOf(url);
|
|
2606
|
+
if (here.error) return sendJson(response, 404, here);
|
|
2607
|
+
return sendJson(response, 200, { decisions: here.room.decisions({ limit }) });
|
|
1185
2608
|
}
|
|
2609
|
+
// The economy of the room: every conversation's turns, not only the open one's. The card and
|
|
2610
|
+
// the panel say «this room», and they used to weigh one conversation's ledger under that name.
|
|
1186
2611
|
if (url.pathname === '/api/economy' && request.method === 'GET') {
|
|
1187
|
-
|
|
2612
|
+
const weighed = [];
|
|
2613
|
+
for (const chat of (await listChats(roomDir)).chats) {
|
|
2614
|
+
const text = await readFile(chatLedger(roomDir, chat.id), 'utf8').catch(() => '');
|
|
2615
|
+
for (const line of text.split('\n')) {
|
|
2616
|
+
if (!line.includes('"turn.cost"')) continue;
|
|
2617
|
+
try { const event = JSON.parse(line); if (event.type === 'turn.cost') weighed.push(event); } catch { /* a torn last line */ }
|
|
2618
|
+
}
|
|
2619
|
+
}
|
|
2620
|
+
return sendJson(response, 200, { ...economy(weighed), conversations: (await listChats(roomDir)).chats.length });
|
|
1188
2621
|
}
|
|
1189
2622
|
|
|
1190
2623
|
// What left this machine: every address MADRE may reach, whether it is on today, and the
|
|
@@ -1204,6 +2637,8 @@ export async function createPulseServer({
|
|
|
1204
2637
|
reports: Boolean(sentinel.settings().autoReport),
|
|
1205
2638
|
anthropic: agents.some((agent) => agent.id === 'claude' && agent.ready),
|
|
1206
2639
|
ollama: Boolean(ollama.running),
|
|
2640
|
+
alive: true,
|
|
2641
|
+
usage: FROM_SOURCE && Boolean(await readSecret(root, OWNER_VAULT, 'usage')),
|
|
1207
2642
|
},
|
|
1208
2643
|
}));
|
|
1209
2644
|
}
|
|
@@ -1224,7 +2659,7 @@ export async function createPulseServer({
|
|
|
1224
2659
|
if (eyecatVerdict) {
|
|
1225
2660
|
const payload = await body(request).catch(() => ({}));
|
|
1226
2661
|
if (!designationOk(payload.designation)) return sendJson(response, 403, { error: t('UNABLE TO COMPUTE. UNABLE TO CLARIFY.') });
|
|
1227
|
-
if (!memory) return sendJson(response, 503, { error: t('The
|
|
2662
|
+
if (!memory) return sendJson(response, 503, { error: t('The project has no memory.') });
|
|
1228
2663
|
const finding = eyecat.findings().find((item) => item.key === payload.key);
|
|
1229
2664
|
if (!finding) return sendJson(response, 404, { error: t('EYECAT is not holding that one.') });
|
|
1230
2665
|
const settled = eyecat.settle(finding.key, { verdict: eyecatVerdict[1] === 'confirm' ? 'aberration' : 'dismissed' });
|
|
@@ -1264,6 +2699,19 @@ export async function createPulseServer({
|
|
|
1264
2699
|
// PRIVACY: the terms live in config.json only; the ledger records counts, never words.
|
|
1265
2700
|
// Which language MADRE speaks. The page keeps its own copy so the first paint needs no
|
|
1266
2701
|
// round trip; this is the durable one, shared by every browser on this machine.
|
|
2702
|
+
// The looks every page shares (src/looks.mjs): Home and each project are different origins,
|
|
2703
|
+
// 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) }));
|
|
2705
|
+
// This project's own wallpaper («only in this project»), or back to the shared one with null.
|
|
2706
|
+
if (request.method === 'POST' && url.pathname === '/api/looks/room') {
|
|
2707
|
+
const payload = await body(request).catch(() => ({}));
|
|
2708
|
+
const answer = await changeRoomLook(root, basename(roomDir), payload?.wallpaper ?? null);
|
|
2709
|
+
return sendJson(response, answer.error ? 400 : 200, answer);
|
|
2710
|
+
}
|
|
2711
|
+
if (request.method === 'POST' && url.pathname === '/api/looks') {
|
|
2712
|
+
const answer = await changeLooks(root, await body(request).catch(() => null));
|
|
2713
|
+
return sendJson(response, answer.error ? 400 : 200, answer);
|
|
2714
|
+
}
|
|
1267
2715
|
if (request.method === 'POST' && url.pathname === '/api/language') {
|
|
1268
2716
|
const payload = await body(request).catch(() => ({}));
|
|
1269
2717
|
const language = payload.language === 'en' ? 'en' : 'es';
|
|
@@ -1273,7 +2721,7 @@ export async function createPulseServer({
|
|
|
1273
2721
|
}
|
|
1274
2722
|
if (request.method === 'GET' && url.pathname === '/api/privacy') {
|
|
1275
2723
|
await refreshPrivacy();
|
|
1276
|
-
return sendJson(response, 200, { terms: privacy.terms, marker: privacy.marker, ...privacy.guards, exposure: room.privacyExposure(await
|
|
2724
|
+
return sendJson(response, 200, { terms: privacy.terms, marker: privacy.marker, ...privacy.guards, exposure: room.privacyExposure(await projectEvents()) });
|
|
1277
2725
|
}
|
|
1278
2726
|
if (request.method === 'POST' && url.pathname === '/api/privacy') {
|
|
1279
2727
|
const patch = await body(request).catch(() => ({}));
|
|
@@ -1287,36 +2735,101 @@ export async function createPulseServer({
|
|
|
1287
2735
|
await refreshPrivacy();
|
|
1288
2736
|
// The room records that the setting changed and what it is now, never the words.
|
|
1289
2737
|
await room.record('privacy.updated', { terms: privacy.terms.length, marker: privacy.marker, ...privacy.guards });
|
|
1290
|
-
return sendJson(response, 200, { terms: privacy.terms, marker: privacy.marker, ...privacy.guards, exposure: room.privacyExposure(await
|
|
2738
|
+
return sendJson(response, 200, { terms: privacy.terms, marker: privacy.marker, ...privacy.guards, exposure: room.privacyExposure(await projectEvents()) });
|
|
1291
2739
|
}
|
|
1292
2740
|
if (request.method === 'POST' && url.pathname === '/api/privacy/purge') {
|
|
1293
2741
|
const payload = await body(request).catch(() => ({}));
|
|
1294
2742
|
if (!designationOk(payload.designation)) return sendJson(response, 403, { error: t('UNABLE TO COMPUTE. UNABLE TO CLARIFY.') });
|
|
1295
2743
|
await refreshPrivacy();
|
|
1296
|
-
if (!privacy.enabled) return sendJson(response, 412, { error: t('No
|
|
2744
|
+
if (!privacy.enabled) return sendJson(response, 412, { error: t('No hidden data is set. Write it first.') });
|
|
1297
2745
|
const result = await room.purgePrivate();
|
|
1298
2746
|
// The log was rewritten in place: the broadcaster's byte offset is stale, the sequences are not.
|
|
1299
|
-
|
|
1300
|
-
|
|
2747
|
+
mainFeed.offset = (await store.tail(0)).offset;
|
|
2748
|
+
// And every other conversation of the project too (LOG-03 of the 1.0 review): the purge
|
|
2749
|
+
// cleaned only the one on screen, the others kept the words in plain text and sent them to
|
|
2750
|
+
// the providers the next time they were opened, while the count said nothing was left.
|
|
2751
|
+
for (const { store: other, held } of await otherLedgers()) {
|
|
2752
|
+
const done = await other.rewrite((event) => {
|
|
2753
|
+
const { value, hits } = privacy.redactDeep(event.payload);
|
|
2754
|
+
return hits ? { ...event, payload: value } : event;
|
|
2755
|
+
});
|
|
2756
|
+
result.events += done.changed;
|
|
2757
|
+
if (held?.feed) held.feed.offset = (await other.tail(0)).offset;
|
|
2758
|
+
}
|
|
2759
|
+
return sendJson(response, 200, { purged: result, exposure: room.privacyExposure(await projectEvents()) });
|
|
1301
2760
|
}
|
|
1302
2761
|
// The human's verdict on a reply, for the dataset: good, bad, none.
|
|
1303
2762
|
if (request.method === 'POST' && url.pathname === '/api/messages/rate') {
|
|
1304
2763
|
const payload = await body(request).catch(() => ({}));
|
|
1305
2764
|
try {
|
|
1306
|
-
const
|
|
2765
|
+
const here = await scopeOf(url);
|
|
2766
|
+
if (here.error) return sendJson(response, 404, here);
|
|
2767
|
+
const event = await here.room.rateMessage(String(payload.messageId ?? ''), String(payload.rating ?? ''));
|
|
1307
2768
|
return sendJson(response, 200, { rated: event.payload });
|
|
1308
2769
|
} catch (error) { return sendJson(response, 400, { error: error.message }); }
|
|
1309
2770
|
}
|
|
1310
2771
|
// Release channel: current, latest on npm, and the command for how this copy runs.
|
|
1311
2772
|
// What this version brings, once, to someone who came from an older one.
|
|
1312
2773
|
if (request.method === 'GET' && url.pathname === '/api/whats-new') {
|
|
1313
|
-
|
|
1314
|
-
|
|
2774
|
+
// `version`, with `any`: another version's sheet, to look at it before it ships. It reads
|
|
2775
|
+
// and never marks anything as seen; only a sheet with notes can be asked for.
|
|
2776
|
+
const any = url.searchParams.get('any') === '1';
|
|
2777
|
+
const asked = url.searchParams.get('version');
|
|
2778
|
+
const version = any && asked && notesFor(asked) ? asked : PACKAGE.version;
|
|
2779
|
+
const news = await whatsNew({ root, version, returning: novelty.returning, any });
|
|
2780
|
+
return sendJson(response, 200, { ...news, preview: version !== PACKAGE.version, newest: newestNotes(), changes: { es: releaseUrl(PACKAGE.repository, version, 'es'), en: releaseUrl(PACKAGE.repository, version, 'en') } });
|
|
1315
2781
|
}
|
|
1316
2782
|
if (request.method === 'POST' && url.pathname === '/api/whats-new/seen') {
|
|
1317
2783
|
await markSeen(root, PACKAGE.version);
|
|
1318
2784
|
return sendJson(response, 200, { seen: PACKAGE.version });
|
|
1319
2785
|
}
|
|
2786
|
+
// Asking for a star or a review (src/review-ask.mjs): a page in view says so once a minute and
|
|
2787
|
+
// hears whether a moment is due; the answer ends the asking or lets that moment pass. A phone
|
|
2788
|
+
// is never asked.
|
|
2789
|
+
if (request.method === 'POST' && url.pathname === '/api/review-ask/tick') {
|
|
2790
|
+
if (viaPhone) return sendJson(response, 200, { due: null });
|
|
2791
|
+
// Never on the day of the bonjour: one welcome is enough for a day.
|
|
2792
|
+
return sendJson(response, 200, await tickAsk(root, Date.now(), { quietSince: (await readTour(root)).bonjourAt }));
|
|
2793
|
+
}
|
|
2794
|
+
if (request.method === 'POST' && url.pathname === '/api/review-ask/answer') {
|
|
2795
|
+
if (viaPhone) return sendJson(response, 403, { error: t('This is done on the Mac.') });
|
|
2796
|
+
const payload = await body(request).catch(() => ({}));
|
|
2797
|
+
const result = await answerAsk(root, { moment: String(payload.moment ?? ''), answer: String(payload.answer ?? '') });
|
|
2798
|
+
if (result.error) return sendJson(response, 422, { error: t('That is not an answer to this question.') });
|
|
2799
|
+
return sendJson(response, 200, result);
|
|
2800
|
+
}
|
|
2801
|
+
// The welcome of 1.0 (src/tour.mjs): where the person is in it, kept in ~/.pulse for every
|
|
2802
|
+
// project and port, and the projects of 0.7.2 brought to Home. Only the pages of this Mac:
|
|
2803
|
+
// a phone never gets the bonjour, and nothing here is changed from anywhere else.
|
|
2804
|
+
if (url.pathname === '/api/tour' || url.pathname.startsWith('/api/tour/')) {
|
|
2805
|
+
const host = String(request.headers.host ?? '');
|
|
2806
|
+
const site = request.headers['sec-fetch-site'];
|
|
2807
|
+
const fromHere = /^(127\.0\.0\.1|localhost)(:\d+)?$/.test(host) && (site === undefined || site === 'same-origin' || site === 'none');
|
|
2808
|
+
if (request.method === 'GET' && url.pathname === '/api/tour') {
|
|
2809
|
+
const state = await readTour(root);
|
|
2810
|
+
if (viaPhone || !fromHere) return sendJson(response, 200, { version: TOUR_VERSION, state, owed: false, returning: false, chapters: CHAPTERS, hints: HINTS });
|
|
2811
|
+
// Where this person comes from: the tour of 1.0 (path «since»), 0.7.2 (their words in a room's
|
|
2812
|
+
// record, path «back»), or nowhere — a first install.
|
|
2813
|
+
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;
|
|
2814
|
+
return sendJson(response, 200, { version: TOUR_VERSION, state, owed: !state.bonjourAt, returning: Boolean(from), from, chapters: CHAPTERS, hints: HINTS });
|
|
2815
|
+
}
|
|
2816
|
+
if (viaPhone || !fromHere || (request.method === 'POST' && request.headers.origin !== `http://${host}`)) return sendJson(response, 403, { error: t('This is done on the Mac.') });
|
|
2817
|
+
if (request.method === 'POST' && url.pathname === '/api/tour') {
|
|
2818
|
+
const changed = await changeTour(root, await body(request).catch(() => null));
|
|
2819
|
+
if (changed.error) return sendJson(response, 422, { error: t('That is not a change to the tour.') });
|
|
2820
|
+
return sendJson(response, 200, { state: changed });
|
|
2821
|
+
}
|
|
2822
|
+
if (request.method === 'GET' && url.pathname === '/api/tour/projects') {
|
|
2823
|
+
return sendJson(response, 200, { rooms: await roomsWithoutProject(root) });
|
|
2824
|
+
}
|
|
2825
|
+
if (request.method === 'POST' && url.pathname === '/api/tour/projects') {
|
|
2826
|
+
const payload = await body(request).catch(() => ({}));
|
|
2827
|
+
const found = await recoverProject({ stateRoot: root, home: process.env.PULSE_LOBBY_HOME || homedir(), id: String(payload.id ?? '') });
|
|
2828
|
+
if (found.error) return sendJson(response, 404, { error: t('MADRE does not know that project on this Mac.') });
|
|
2829
|
+
return sendJson(response, 200, found);
|
|
2830
|
+
}
|
|
2831
|
+
return sendJson(response, 404, { error: 'Not found' });
|
|
2832
|
+
}
|
|
1320
2833
|
if (request.method === 'GET' && url.pathname === '/api/version') {
|
|
1321
2834
|
return sendJson(response, 200, await versionView({ force: url.searchParams.get('force') === '1' }));
|
|
1322
2835
|
}
|
|
@@ -1325,16 +2838,32 @@ export async function createPulseServer({
|
|
|
1325
2838
|
if (request.method === 'POST' && url.pathname === '/api/updates/apply') {
|
|
1326
2839
|
const info = await versionView();
|
|
1327
2840
|
if (!info.available) return sendJson(response, 409, { error: `Nothing to apply: ${info.current} is the latest MADRE knows of.` });
|
|
2841
|
+
if (install === 'source') return sendJson(response, 412, { error: t('This copy runs from source: pull the repository and start it again.') });
|
|
1328
2842
|
const command = applyCommand({ install, name: PACKAGE.name, version: info.latest, port: request.socket.localPort, projectRoot: canonicalProjectRoot });
|
|
1329
|
-
|
|
2843
|
+
const fetching = fetchCommand({ name: PACKAGE.name, version: info.latest });
|
|
2844
|
+
// Only a version semver would write reaches a shell, and the restart never runs without the
|
|
2845
|
+
// download first (src/updates.mjs): an unchecked one used to skip the download and run.
|
|
2846
|
+
if (!command || !fetching) return sendJson(response, 412, { error: t('The version the registry gave ({version}) is not one MADRE installs.', { version: String(info.latest).slice(0, 40) }) });
|
|
1330
2847
|
if (room.activeTurns().length || room.activePlans().length) return sendJson(response, 409, { error: t('Agents are still working. STOPALL or wait, then update.') });
|
|
2848
|
+
{
|
|
2849
|
+
// npm is a .cmd on Windows and needs a shell there; every argument here is fixed or checked above.
|
|
2850
|
+
const fetched = await new Promise((resolve) => execFile(fetching.command, fetching.args, { cwd: root, timeout: 180000, shell: process.platform === 'win32', windowsHide: true, maxBuffer: 1024 * 1024 }, (error, stdout, stderr) => resolve(error ? (String(stderr || error.message).split('\n').map((line) => line.trim()).find((line) => line && !/complete log|^npm (error|ERR!)(\s+code\b.*)?\s*$/i.test(line)) ?? error.message) : null)));
|
|
2851
|
+
if (fetched !== null) {
|
|
2852
|
+
await room.record('room.update.refused', { from: info.current, to: info.latest, error: fetched.slice(0, 300) });
|
|
2853
|
+
return sendJson(response, 502, { error: t('npm does not hand out {version} yet; a new release can take a few minutes. The project stays open: try again in a moment.', { version: info.latest }) });
|
|
2854
|
+
}
|
|
2855
|
+
}
|
|
1331
2856
|
await room.record('room.updating', { from: info.current, to: info.latest, install, command });
|
|
1332
2857
|
sendJson(response, 202, { restarting: true, from: info.current, to: info.latest, command });
|
|
1333
2858
|
setTimeout(() => {
|
|
1334
2859
|
server.close(() => {
|
|
1335
2860
|
// Each system has its own shell: the command is one line either way.
|
|
1336
2861
|
const shell = process.platform === 'win32' ? { file: process.env.COMSPEC ?? 'cmd.exe', args: ['/d', '/s', '/c', command] } : { file: '/bin/sh', args: ['-c', command] };
|
|
1337
|
-
|
|
2862
|
+
// What the new copy says on the way up lands beside the ledger: if it never comes back,
|
|
2863
|
+
// this file is the only witness.
|
|
2864
|
+
let log = 'ignore';
|
|
2865
|
+
try { log = openSync(join(roomDir, 'update.log'), 'a'); } catch { /* no witness, still restart */ }
|
|
2866
|
+
const child = spawn(shell.file, shell.args, { cwd: canonicalProjectRoot, detached: process.platform !== 'win32', windowsHide: true, stdio: ['ignore', log, log], env: { ...process.env, PULSE_UPDATE_RESTART: '1' } });
|
|
1338
2867
|
child.unref();
|
|
1339
2868
|
setTimeout(() => process.exit(0), 200);
|
|
1340
2869
|
});
|
|
@@ -1354,14 +2883,14 @@ export async function createPulseServer({
|
|
|
1354
2883
|
const payload = await body(request).catch(() => ({}));
|
|
1355
2884
|
if (!designationOk(payload.designation)) return sendJson(response, 403, { error: t('UNABLE TO COMPUTE. UNABLE TO CLARIFY.') });
|
|
1356
2885
|
const result = await room.code000({ strikes: Number(payload.strikes) || CODE000_STRIKES });
|
|
1357
|
-
return result ? sendJson(response, 200, { code: result.code, lockedForMs: result.lockedForMs, n: result.n }) : sendJson(response, 503, { error: t('
|
|
2886
|
+
return result ? sendJson(response, 200, { code: result.code, lockedForMs: result.lockedForMs, n: result.n }) : sendJson(response, 503, { error: t('Security is silent.') });
|
|
1358
2887
|
}
|
|
1359
2888
|
if (request.method === 'GET' && url.pathname === '/api/memory') {
|
|
1360
2889
|
if (!designationOk(url.searchParams.get('designation'))) return sendJson(response, 403, { error: t('UNABLE TO COMPUTE. UNABLE TO CLARIFY.') });
|
|
1361
2890
|
const sealed = room.motherStatus()?.lockedForMs ?? 0;
|
|
1362
2891
|
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 });
|
|
1363
2892
|
const research = room.memoryResearch();
|
|
1364
|
-
if (!research) return sendJson(response, 503, { error: t('The
|
|
2893
|
+
if (!research) return sendJson(response, 503, { error: t('The project has no memory.') });
|
|
1365
2894
|
// How grown this archive is, read from the same notes the map draws.
|
|
1366
2895
|
const grown = maturity({ readiness: datasetReadiness(await store.readAll(), research.memories), notes: research.memories, links: research.links, stats: research.stats });
|
|
1367
2896
|
return sendJson(response, 200, { ...research, maturity: grown });
|
|
@@ -1383,6 +2912,13 @@ export async function createPulseServer({
|
|
|
1383
2912
|
}
|
|
1384
2913
|
// The MEMORY dashboard: what the archive did day by day and what it is made of. Counts
|
|
1385
2914
|
// only, no text of any memory, so it is read like /api/maturity, without the designation.
|
|
2915
|
+
// What the archive says about something on the screen, for the questions a surface offers.
|
|
2916
|
+
// Memory text, so behind the designation like the archive itself.
|
|
2917
|
+
if (request.method === 'GET' && url.pathname === '/api/memory/about') {
|
|
2918
|
+
if (!designationOk(url.searchParams.get('designation'))) return sendJson(response, 403, { error: t('UNABLE TO COMPUTE. UNABLE TO CLARIFY.') });
|
|
2919
|
+
if ((room.motherStatus()?.lockedForMs ?? 0) > 0) return sendJson(response, 423, { notes: [] });
|
|
2920
|
+
return sendJson(response, 200, { notes: room.notesAbout(url.searchParams.get('text') ?? '', { limit: 3 }) });
|
|
2921
|
+
}
|
|
1386
2922
|
if (request.method === 'GET' && url.pathname === '/api/memory/dashboard') {
|
|
1387
2923
|
return sendJson(response, 200, { dashboard: memoryDashboard({ activity: room.memoryActivity(), stats: room.memoryStats() }) });
|
|
1388
2924
|
}
|
|
@@ -1393,6 +2929,9 @@ export async function createPulseServer({
|
|
|
1393
2929
|
const mode = Math.min(4, Math.max(0, Number(url.searchParams.get('mode') ?? 1) || 0));
|
|
1394
2930
|
const text = String(url.searchParams.get('text') ?? '').slice(0, 2000);
|
|
1395
2931
|
const ash = url.searchParams.get('ash') === 'true';
|
|
2932
|
+
const here = await scopeOf(url);
|
|
2933
|
+
if (here.error) return sendJson(response, 404, here);
|
|
2934
|
+
const room = here.room;
|
|
1396
2935
|
const briefing = await room.briefing({ agent: asked, mode, text, ash });
|
|
1397
2936
|
if (!briefing) return sendJson(response, 404, { error: `No agent "${asked}" on this computer.` });
|
|
1398
2937
|
return sendJson(response, 200, {
|
|
@@ -1403,35 +2942,45 @@ export async function createPulseServer({
|
|
|
1403
2942
|
}
|
|
1404
2943
|
// The conversations of this project. One memory, one crew, one numbering; many records.
|
|
1405
2944
|
if (request.method === 'GET' && url.pathname === '/api/chats') {
|
|
1406
|
-
return sendJson(response, 200, await
|
|
2945
|
+
return sendJson(response, 200, await chatsView());
|
|
1407
2946
|
}
|
|
1408
2947
|
if (request.method === 'POST' && url.pathname === '/api/chats') {
|
|
1409
|
-
if (room.
|
|
2948
|
+
if (room.busy() && !(await turnsPolicy()).enabled) return sendJson(response, 409, { error: t('A turn is running in this conversation. Let it finish, or STOP ALL, and try again.') });
|
|
1410
2949
|
const payload = await body(request).catch(() => ({}));
|
|
2950
|
+
// A phone looks at conversations by their own address and never moves the one the room has
|
|
2951
|
+
// open: the Mac would be left showing one conversation while sending into another.
|
|
2952
|
+
if (viaPhone) {
|
|
2953
|
+
const made = await createChat(roomDir, { title: typeof payload.title === 'string' ? payload.title : null, activate: false });
|
|
2954
|
+
return sendJson(response, 200, { ...(await chatsView()), opened: made.id });
|
|
2955
|
+
}
|
|
1411
2956
|
const made = await createChat(roomDir, { title: typeof payload.title === 'string' ? payload.title : null });
|
|
1412
2957
|
const mounted = await mountChat(made.id);
|
|
1413
2958
|
if (mounted.error) return sendJson(response, 409, mounted);
|
|
1414
|
-
return sendJson(response, 200, { ...(await
|
|
2959
|
+
return sendJson(response, 200, { ...(await chatsView()), opened: made.id });
|
|
1415
2960
|
}
|
|
1416
2961
|
const chatMatch = url.pathname.match(/^\/api\/chats\/([a-z0-9]+)$/);
|
|
1417
2962
|
if (chatMatch && request.method === 'POST') {
|
|
2963
|
+
if (viaPhone) return sendJson(response, 200, { ...(await chatsView()), opened: chatMatch[1] });
|
|
1418
2964
|
const mounted = await mountChat(chatMatch[1]);
|
|
1419
2965
|
if (mounted.error) return sendJson(response, 409, mounted);
|
|
1420
|
-
return sendJson(response, 200, { ...(await
|
|
2966
|
+
return sendJson(response, 200, { ...(await chatsView()), opened: chatMatch[1] });
|
|
1421
2967
|
}
|
|
1422
2968
|
if (chatMatch && request.method === 'PATCH') {
|
|
1423
2969
|
const payload = await body(request).catch(() => ({}));
|
|
1424
2970
|
const named = await renameChat(roomDir, chatMatch[1], payload.title);
|
|
1425
|
-
return named ? sendJson(response, 200, { ...(await
|
|
2971
|
+
return named ? sendJson(response, 200, { ...(await chatsView()) }) : sendJson(response, 404, { error: t('No such conversation.') });
|
|
1426
2972
|
}
|
|
1427
2973
|
if (chatMatch && request.method === 'DELETE') {
|
|
1428
|
-
if (chatMatch[1] === chatId && room.
|
|
2974
|
+
if ((chatMatch[1] === chatId && room.busy()) || parked.get(chatMatch[1])?.room.busy()) return sendJson(response, 409, { error: t('A turn is running in this conversation.') });
|
|
2975
|
+
// Open beside the one on screen and idle: its room closes, and the page watching it hears
|
|
2976
|
+
// its stream end, before its record goes.
|
|
2977
|
+
await closeHeld(chatMatch[1]);
|
|
1429
2978
|
const gone = await deleteChat(roomDir, chatMatch[1]);
|
|
1430
2979
|
if (!gone) return sendJson(response, 404, { error: t('No such conversation.') });
|
|
1431
2980
|
if (gone.error) return sendJson(response, 409, gone);
|
|
1432
2981
|
// What the archivist distilled from it is the project's memory and stays where it is.
|
|
1433
2982
|
if (chatMatch[1] === chatId) { const mounted = await mountChat(gone.active); if (mounted.error) return sendJson(response, 409, mounted); }
|
|
1434
|
-
return sendJson(response, 200, { ...(await
|
|
2983
|
+
return sendJson(response, 200, { ...(await chatsView()), deleted: gone.deleted });
|
|
1435
2984
|
}
|
|
1436
2985
|
// The three tests. Reading is free; running one is the human's call, and the slow one says
|
|
1437
2986
|
// where it is while it works.
|
|
@@ -1535,7 +3084,7 @@ export async function createPulseServer({
|
|
|
1535
3084
|
const payload = await body(request).catch(() => ({}));
|
|
1536
3085
|
if (!designationOk(payload.designation)) return sendJson(response, 403, { error: t('UNABLE TO COMPUTE. UNABLE TO CLARIFY.') });
|
|
1537
3086
|
const done = room.dismissAsk(String(payload.id ?? '').slice(0, 80));
|
|
1538
|
-
return done ? sendJson(response, 200, done) : sendJson(response, 404, { error: t('The
|
|
3087
|
+
return done ? sendJson(response, 200, done) : sendJson(response, 404, { error: t('The project has no memory.') });
|
|
1539
3088
|
}
|
|
1540
3089
|
// One memory's own traffic: who it keeps arriving with, who asked for it, what a
|
|
1541
3090
|
// refutation did. Read while its card is open, so the card is alive rather than a snapshot.
|
|
@@ -1543,7 +3092,7 @@ export async function createPulseServer({
|
|
|
1543
3092
|
if (trafficMatch) {
|
|
1544
3093
|
if (!designationOk(url.searchParams.get('designation'))) return sendJson(response, 403, { error: t('UNABLE TO COMPUTE. UNABLE TO CLARIFY.') });
|
|
1545
3094
|
const sealed = room.motherStatus()?.lockedForMs ?? 0;
|
|
1546
|
-
if (sealed > 0) return sendJson(response, 423, { error: t('
|
|
3095
|
+
if (sealed > 0) return sendJson(response, 423, { error: t('TEMPORARY LOCK. THE ARCHIVE IS SEALED.'), lockedForMs: sealed });
|
|
1547
3096
|
const traffic = room.memoryTraffic(Number(trafficMatch[1]));
|
|
1548
3097
|
return traffic ? sendJson(response, 200, traffic) : sendJson(response, 404, { error: t('No such memory.') });
|
|
1549
3098
|
}
|
|
@@ -1551,14 +3100,14 @@ export async function createPulseServer({
|
|
|
1551
3100
|
if (forgetMatch) {
|
|
1552
3101
|
const payload = await body(request).catch(() => ({}));
|
|
1553
3102
|
if (!designationOk(payload.designation)) return sendJson(response, 403, { error: t('UNABLE TO COMPUTE. UNABLE TO CLARIFY.') });
|
|
1554
|
-
if ((room.motherStatus()?.lockedForMs ?? 0) > 0) return sendJson(response, 423, { error: t('
|
|
3103
|
+
if ((room.motherStatus()?.lockedForMs ?? 0) > 0) return sendJson(response, 423, { error: t('TEMPORARY LOCK. THE ARCHIVE IS SEALED.') });
|
|
1555
3104
|
const row = await room.forgetMemory(forgetMatch[1]);
|
|
1556
3105
|
return row ? sendJson(response, 200, { forgotten: row, stats: room.memoryStats() }) : sendJson(response, 404, { error: t('No such memory.') });
|
|
1557
3106
|
}
|
|
1558
3107
|
if (request.method === 'POST' && url.pathname === '/api/agents/probe') {
|
|
1559
3108
|
// Look for the binaries again too: a CLI installed a moment ago must appear now.
|
|
1560
3109
|
await redetectAgents();
|
|
1561
|
-
return sendJson(response, 200, { sessions, sessionsAt, agents: agents.map((agent) => ({ ...agent, login: loginPlanFor(agent), install: installPlanFor(agent), key: keyPlanFor(agent.id), ...(accountNoteFor(agent.id) ?? {}) })) });
|
|
3110
|
+
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) ?? {}) })) });
|
|
1562
3111
|
}
|
|
1563
3112
|
// A provider key for a CLI that signs in from its own prompt. Local connections only: a key
|
|
1564
3113
|
// pasted from another machine would travel the network. It is written where that CLI looks
|
|
@@ -1571,7 +3120,7 @@ export async function createPulseServer({
|
|
|
1571
3120
|
if (vaultMatch && (request.method === 'POST' || request.method === 'DELETE')) {
|
|
1572
3121
|
const id = vaultMatch[1];
|
|
1573
3122
|
const address = request.socket.remoteAddress ?? '';
|
|
1574
|
-
if (!['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(address)) {
|
|
3123
|
+
if (viaPhone || !['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(address)) {
|
|
1575
3124
|
return sendJson(response, 403, { error: t('A key is only accepted from this computer, never over the network.') });
|
|
1576
3125
|
}
|
|
1577
3126
|
const module = moduleById(id);
|
|
@@ -1595,7 +3144,7 @@ export async function createPulseServer({
|
|
|
1595
3144
|
if (keyMatch) {
|
|
1596
3145
|
const id = keyMatch[1];
|
|
1597
3146
|
const address = request.socket.remoteAddress ?? '';
|
|
1598
|
-
if (!['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(address)) {
|
|
3147
|
+
if (viaPhone || !['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(address)) {
|
|
1599
3148
|
return sendJson(response, 403, { error: t('A key is only accepted from this computer, never over the network.') });
|
|
1600
3149
|
}
|
|
1601
3150
|
const agent = agents.find((item) => item.id === id);
|
|
@@ -1623,7 +3172,25 @@ export async function createPulseServer({
|
|
|
1623
3172
|
}
|
|
1624
3173
|
const loginMatch = request.method === 'POST' && url.pathname.match(/^\/api\/agents\/([a-z0-9-]+)\/login$/);
|
|
1625
3174
|
if (loginMatch) {
|
|
1626
|
-
const
|
|
3175
|
+
const payload = await body(request).catch(() => ({}));
|
|
3176
|
+
const result = await loginAgent(loginMatch[1], { method: payload.method ? String(payload.method) : null });
|
|
3177
|
+
return sendJson(response, result.status, result.body);
|
|
3178
|
+
}
|
|
3179
|
+
// The code the sign-in page gives back. It is a credential while it lives, so it is taken
|
|
3180
|
+
// from this computer only — the same rule as a key — and it never reaches the ledger.
|
|
3181
|
+
const loginCodeMatch = request.method === 'POST' && url.pathname.match(/^\/api\/agents\/([a-z0-9-]+)\/login\/code$/);
|
|
3182
|
+
if (loginCodeMatch) {
|
|
3183
|
+
const address = request.socket.remoteAddress ?? '';
|
|
3184
|
+
if (viaPhone || !['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(address)) {
|
|
3185
|
+
return sendJson(response, 403, { error: t('A key is only accepted from this computer, never over the network.') });
|
|
3186
|
+
}
|
|
3187
|
+
const payload = await body(request).catch(() => ({}));
|
|
3188
|
+
const result = await answerLogin(loginCodeMatch[1], payload.code);
|
|
3189
|
+
return sendJson(response, result.status, result.body);
|
|
3190
|
+
}
|
|
3191
|
+
const loginStopMatch = request.method === 'POST' && url.pathname.match(/^\/api\/agents\/([a-z0-9-]+)\/login\/cancel$/);
|
|
3192
|
+
if (loginStopMatch) {
|
|
3193
|
+
const result = await cancelLogin(loginStopMatch[1]);
|
|
1627
3194
|
return sendJson(response, result.status, result.body);
|
|
1628
3195
|
}
|
|
1629
3196
|
if (request.method === 'GET' && url.pathname === '/api/models') {
|
|
@@ -1634,6 +3201,39 @@ export async function createPulseServer({
|
|
|
1634
3201
|
};
|
|
1635
3202
|
return sendJson(response, 200, { models: await discoverModels({ agents, config: await readConfig(root), listOpenCode: url.searchParams.get('opencode') === '1' ? listOpenCode : async () => [] }) });
|
|
1636
3203
|
}
|
|
3204
|
+
// OpenCode's providers for AJUSTES: who has a key or an account, what each offers, which are
|
|
3205
|
+
// left out of the chooser. Never a key: only that one is there and of what kind.
|
|
3206
|
+
if (request.method === 'GET' && url.pathname === '/api/agents/opencode/providers') {
|
|
3207
|
+
const opencode = agents.find((agent) => agent.id === 'opencode');
|
|
3208
|
+
if (!opencode?.detected) return sendJson(response, 404, { error: t('OpenCode is not installed.') });
|
|
3209
|
+
const names = [];
|
|
3210
|
+
await runInstaller({ command: opencode.path, args: ['models'], projectRoot: canonicalProjectRoot, timeoutMs: 20000, onLine: (line) => { if (/^[\w.-]+\/[\w.:/-]+$/.test(line.trim())) names.push(line.trim()); } });
|
|
3211
|
+
const config = await readConfig(root);
|
|
3212
|
+
const hidden = new Set(openCodeHidden(config));
|
|
3213
|
+
const signed = new Map((await readOpenCodeProviders({ home: credentialHome })).map((one) => [one.id, one.type]));
|
|
3214
|
+
const groups = new Map(groupOpenCode(names).map((group) => [group.id, group]));
|
|
3215
|
+
const ids = [...new Set([...OPENCODE_PROVIDERS.map((one) => one.id), ...signed.keys(), ...groups.keys()])];
|
|
3216
|
+
const providers = ids.map((id) => {
|
|
3217
|
+
const known = OPENCODE_PROVIDERS.find((one) => one.id === id);
|
|
3218
|
+
const own = id === OPENCODE_OWN;
|
|
3219
|
+
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) };
|
|
3221
|
+
}).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
|
+
return sendJson(response, 200, { providers, default: process.env.PULSE_OPENCODE_MODEL ?? null });
|
|
3223
|
+
}
|
|
3224
|
+
// A provider's key out of OpenCode, from this computer only, the same rule as putting one in.
|
|
3225
|
+
if (request.method === 'POST' && url.pathname === '/api/agents/opencode/providers/remove') {
|
|
3226
|
+
const address = request.socket.remoteAddress ?? '';
|
|
3227
|
+
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.') });
|
|
3228
|
+
const opencode = agents.find((agent) => agent.id === 'opencode');
|
|
3229
|
+
if (!opencode?.detected) return sendJson(response, 412, { error: t('OpenCode is not installed.') });
|
|
3230
|
+
const payload = await body(request).catch(() => ({}));
|
|
3231
|
+
const result = await removeOpenCodeKey({ provider: payload.provider, home: credentialHome, executable: opencode.path ?? 'opencode' });
|
|
3232
|
+
if (!result.ok) return sendJson(response, 422, { error: result.error });
|
|
3233
|
+
await refreshSessions();
|
|
3234
|
+
await room.record('connection.key.removed', { agent: 'opencode', label: opencode.label, provider: payload.provider });
|
|
3235
|
+
return sendJson(response, 200, { ok: true });
|
|
3236
|
+
}
|
|
1637
3237
|
if (request.method === 'GET' && url.pathname === '/api/agents/opencode/models') {
|
|
1638
3238
|
const opencode = agents.find((agent) => agent.id === 'opencode');
|
|
1639
3239
|
if (!opencode?.detected) return sendJson(response, 404, { error: t('OpenCode is not installed.') });
|
|
@@ -1644,25 +3244,61 @@ export async function createPulseServer({
|
|
|
1644
3244
|
// One answer, not the room. The master brake lives next door and means something else.
|
|
1645
3245
|
if (request.method === 'POST' && url.pathname === '/api/stop-turn') {
|
|
1646
3246
|
const payload = await body(request).catch(() => ({}));
|
|
1647
|
-
const
|
|
3247
|
+
const here = await scopeOf(url);
|
|
3248
|
+
if (here.error) return sendJson(response, 404, here);
|
|
3249
|
+
const stopped = await here.room.stopTurn(String(payload.messageId ?? ''));
|
|
1648
3250
|
return sendJson(response, stopped ? 200 : 404, stopped ? { stopped: true } : { error: t('That turn is not running any more.') });
|
|
1649
3251
|
}
|
|
3252
|
+
// TURNS, read and set from MU/TH/UR. The limits apply to the next message; nothing running stops.
|
|
3253
|
+
if (request.method === 'GET' && url.pathname === '/api/turns') {
|
|
3254
|
+
return sendJson(response, 200, { turns: await turnsPolicy(), working: { here: room.turnsRunning(), chats: busyChats().length + (room.busy() ? 1 : 0) } });
|
|
3255
|
+
}
|
|
3256
|
+
if (request.method === 'POST' && url.pathname === '/api/turns') {
|
|
3257
|
+
const patch = await body(request).catch(() => ({}));
|
|
3258
|
+
const current = (await readConfig(root)).turns ?? {};
|
|
3259
|
+
const next = { ...current };
|
|
3260
|
+
if (typeof patch.enabled === 'boolean') next.enabled = patch.enabled;
|
|
3261
|
+
for (const key of ['chats', 'turnsPerChat']) if (Number.isInteger(Number(patch[key])) && Number(patch[key]) >= 1) next[key] = Number(patch[key]);
|
|
3262
|
+
await updateConfig(root, { turns: next });
|
|
3263
|
+
await room.record('room.turns', { turns: await turnsPolicy() });
|
|
3264
|
+
return sendJson(response, 200, { turns: await turnsPolicy() });
|
|
3265
|
+
}
|
|
1650
3266
|
if (request.method === 'POST' && url.pathname === '/api/stop-all') {
|
|
3267
|
+
// Every conversation at work, not only the one on screen: the brake is the room's, not the chat's.
|
|
1651
3268
|
const result = await room.stopAll();
|
|
3269
|
+
passes.clear();
|
|
3270
|
+
for (const held of parked.values()) {
|
|
3271
|
+
const more = await held.room.stopAll().catch(() => ({ plans: 0, turns: 0 }));
|
|
3272
|
+
result.plans += more.plans ?? 0;
|
|
3273
|
+
result.turns += more.turns ?? 0;
|
|
3274
|
+
}
|
|
1652
3275
|
return sendJson(response, 202, { stopped: true, ...result });
|
|
1653
3276
|
}
|
|
1654
3277
|
const stopMatch = request.method === 'POST' && url.pathname.match(/^\/api\/plans\/([0-9a-f-]+)\/stop$/);
|
|
1655
3278
|
if (stopMatch) {
|
|
1656
|
-
const
|
|
3279
|
+
const here = await scopeOf(url);
|
|
3280
|
+
if (here.error) return sendJson(response, 404, here);
|
|
3281
|
+
const stopped = await here.room.stopPlan(stopMatch[1]);
|
|
1657
3282
|
return sendJson(response, stopped ? 202 : 404, stopped ? { stopped: true } : { error: t('No running plan with that id.') });
|
|
1658
3283
|
}
|
|
1659
3284
|
// One line of a ```pulse-run, pressed by the human. The body names the request and the line;
|
|
1660
3285
|
// the command itself is read from the room's record, never from the page.
|
|
3286
|
+
// STOP on one running command's card: a server, or anything still at it.
|
|
3287
|
+
if (request.method === 'POST' && url.pathname === '/api/runs/stop') {
|
|
3288
|
+
const payload = await body(request).catch(() => ({}));
|
|
3289
|
+
const index = Number(payload.index);
|
|
3290
|
+
if (typeof payload.responseMessageId !== 'string' || !Number.isInteger(index) || index < 0) return sendJson(response, 400, { error: t('Name the request and the line to run.') });
|
|
3291
|
+
const here = await scopeOf(url);
|
|
3292
|
+
if (here.error) return sendJson(response, 404, here);
|
|
3293
|
+
return here.room.stopRun({ responseMessageId: payload.responseMessageId, index }) ? sendJson(response, 202, { stopped: true }) : sendJson(response, 404, { error: t('That command is not running.') });
|
|
3294
|
+
}
|
|
1661
3295
|
if (request.method === 'POST' && url.pathname === '/api/runs') {
|
|
1662
3296
|
const payload = await body(request).catch(() => ({}));
|
|
1663
3297
|
const index = Number(payload.index);
|
|
1664
3298
|
if (typeof payload.responseMessageId !== 'string' || !Number.isInteger(index) || index < 0) return sendJson(response, 400, { error: t('Name the request and the line to run.') });
|
|
1665
|
-
const
|
|
3299
|
+
const here = await scopeOf(url);
|
|
3300
|
+
if (here.error) return sendJson(response, 404, here);
|
|
3301
|
+
const outcome = await here.room.runRequested({ responseMessageId: payload.responseMessageId, index });
|
|
1666
3302
|
return sendJson(response, outcome.status, outcome.ok ? outcome.result : { error: outcome.error });
|
|
1667
3303
|
}
|
|
1668
3304
|
if (request.method === 'GET' && url.pathname === '/api/commands') {
|
|
@@ -1699,7 +3335,9 @@ export async function createPulseServer({
|
|
|
1699
3335
|
} catch (error) { result = { ok: false, title: own.title, text: `/${parsed.name} failed: ${error.message}` }; }
|
|
1700
3336
|
command = own;
|
|
1701
3337
|
}
|
|
1702
|
-
const
|
|
3338
|
+
const here = await scopeOf(url);
|
|
3339
|
+
if (here.error) return sendJson(response, 404, here);
|
|
3340
|
+
const event = await here.room.recordCommand({ name: parsed.name, args: parsed.args, ...result });
|
|
1703
3341
|
return sendJson(response, result.ok ? 200 : 422, { command: parsed.name, title: result.title, ok: result.ok, sequence: event.sequence });
|
|
1704
3342
|
}
|
|
1705
3343
|
if (request.method === 'GET' && url.pathname === '/api/extensions') {
|
|
@@ -1753,25 +3391,63 @@ export async function createPulseServer({
|
|
|
1753
3391
|
}
|
|
1754
3392
|
const installMatch = request.method === 'POST' && url.pathname.match(/^\/api\/extensions\/([a-z0-9-]+)\/install$/);
|
|
1755
3393
|
if (installMatch) {
|
|
3394
|
+
// A phone switches MADRE's own modules; installing one runs commands in the project.
|
|
3395
|
+
if (viaPhone && !extensionById(installMatch[1])?.toggle) return sendJson(response, 403, { error: t('This is done on the Mac.') });
|
|
1756
3396
|
const result = await installExtension(installMatch[1], await body(request));
|
|
1757
3397
|
return sendJson(response, result.status, result.body);
|
|
1758
3398
|
}
|
|
1759
3399
|
if (request.method === 'POST' && /^\/api\/control\/[\w-]+\/undo$/.test(url.pathname)) {
|
|
1760
|
-
const
|
|
3400
|
+
const here = await scopeOf(url);
|
|
3401
|
+
if (here.error) return sendJson(response, 404, here);
|
|
3402
|
+
const result = await here.room.undoControl(url.pathname.split('/')[3]);
|
|
1761
3403
|
return sendJson(response, result.ok ? 200 : result.status ?? 400, result);
|
|
1762
3404
|
}
|
|
1763
3405
|
if (request.method === 'POST' && /^\/api\/modes\/[\w-]+\/decide$/.test(url.pathname)) {
|
|
1764
3406
|
const requestId = url.pathname.split('/')[3];
|
|
1765
|
-
const { decision } = await body(request);
|
|
1766
|
-
const
|
|
3407
|
+
const { decision, key } = await body(request);
|
|
3408
|
+
const here = await scopeOf(url);
|
|
3409
|
+
if (here.error) return sendJson(response, 404, here);
|
|
3410
|
+
if (!['once', 'plan', 'deny'].includes(decision)) return sendJson(response, 400, { error: t('Decision must be once, plan or deny.') });
|
|
3411
|
+
const asked = here.room.modeRequest(requestId);
|
|
3412
|
+
if (!asked) return sendJson(response, 409, { error: t('That request is no longer pending.') });
|
|
3413
|
+
if (decision !== 'deny' && asked.mode > PHONE_MAX_MODE) {
|
|
3414
|
+
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 its key. Unlock it again and send.', { n: asked.mode }), needsKey: true });
|
|
3416
|
+
}
|
|
3417
|
+
const result = here.room.decideMode(requestId, decision);
|
|
1767
3418
|
return sendJson(response, result.ok ? 200 : 409, result);
|
|
1768
3419
|
}
|
|
1769
3420
|
if (request.method === 'POST' && url.pathname === '/api/messages') {
|
|
1770
3421
|
const payload = await body(request);
|
|
3422
|
+
// A phone asks up to #2. The bridge already said so; the room says it again, and records
|
|
3423
|
+
// where the message came from. Only the bridge's word counts for that, never the body's.
|
|
3424
|
+
if (viaPhone) {
|
|
3425
|
+
const written = writtenMode(payload.text);
|
|
3426
|
+
// Read the way the room reads it: «"4"» is #4 there, so it is #4 here (TEL-01).
|
|
3427
|
+
if (Math.max(normalizeMode(payload.mode, payload.create === true ? 2 : 1), written) > PHONE_MAX_MODE) return sendJson(response, 403, { error: t('#3 EDIT and #4 FULL ACCESS are opened from the Mac.') });
|
|
3428
|
+
payload.via = phoneKind;
|
|
3429
|
+
} else delete payload.via;
|
|
3430
|
+
// `?chat=` sends into a conversation held open beside the one on screen. TURNS counts it
|
|
3431
|
+
// like any other: the conversation on screen is one of the "others" when it is working.
|
|
3432
|
+
const here = await scopeOf(url);
|
|
3433
|
+
if (here.error) return sendJson(response, 404, here);
|
|
3434
|
+
const room = here.room;
|
|
1771
3435
|
// Modes are checked before the turn is accepted, so the composer
|
|
1772
3436
|
// hears "no" with a reason instead of a silent log line.
|
|
1773
3437
|
const gate = await room.modeCheck(payload);
|
|
1774
3438
|
if (!gate.ok) return sendJson(response, gate.status ?? 403, { error: gate.error, mode: gate.mode, maxMode: gate.maxMode });
|
|
3439
|
+
// #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 its key. Unlock it again and send.', { n: gate.mode }), mode: gate.mode, needsKey: true });
|
|
3441
|
+
delete payload.key;
|
|
3442
|
+
const turns = await turnsPolicy();
|
|
3443
|
+
if (turns.enabled) {
|
|
3444
|
+
if (room.turnsRunning() >= turns.turnsPerChat) return sendJson(response, 429, { error: t('This conversation already has {n} turns running, and TURNS allows {max} at a time. Wait for one to finish, open another conversation, or raise it in Diagnostics → TURNS.', { n: room.turnsRunning(), max: turns.turnsPerChat }) });
|
|
3445
|
+
const others = busyChats().filter((id) => id !== here.id).length + (!here.main && openRoom().busy() ? 1 : 0);
|
|
3446
|
+
if (!room.busy() && others + 1 > turns.chats) return sendJson(response, 429, { error: t('{n} other conversations are already working, and TURNS allows {max} at a time. Wait for one to finish, or raise it in Diagnostics → TURNS.', { n: others, max: turns.chats }) });
|
|
3447
|
+
} else if (!here.main && !room.busy() && openRoom().busy()) {
|
|
3448
|
+
// TURNS off: one conversation drives the crew at a time, beside or not.
|
|
3449
|
+
return sendJson(response, 409, { error: t('A turn is running in this conversation. Let it finish, or STOP ALL, and try again.') });
|
|
3450
|
+
}
|
|
1775
3451
|
await refreshPrivacy();
|
|
1776
3452
|
void room.send(payload).catch((error) => {
|
|
1777
3453
|
console.error(`MADRE room error: ${error.message}`);
|
|
@@ -1798,38 +3474,43 @@ export async function createPulseServer({
|
|
|
1798
3474
|
const nativeClose = server.close.bind(server);
|
|
1799
3475
|
server.close = (callback) => {
|
|
1800
3476
|
clearInterval(poller);
|
|
3477
|
+
serverSentinel.stop();
|
|
1801
3478
|
clearTimeout(embedKick);
|
|
1802
3479
|
unwatchOutbound();
|
|
1803
3480
|
// In-flight agent processes are killed and their turns recorded as failed
|
|
1804
3481
|
// before the SSE clients go away, so open pages see the outcome.
|
|
1805
|
-
const shutdown = room.shutdown().catch((error) => console.error(`MADRE shutdown error: ${error.message}`));
|
|
1806
|
-
|
|
3482
|
+
const shutdown = Promise.all([room, ...[...parked.values()].map((held) => held.room)].map((each) => each.shutdown().catch((error) => console.error(`MADRE shutdown error: ${error.message}`))));
|
|
3483
|
+
// off() also ends the streams of pages watching a conversation held open beside.
|
|
3484
|
+
for (const held of parked.values()) held.off();
|
|
3485
|
+
parked.clear();
|
|
1807
3486
|
shutdown.then(() => {
|
|
1808
3487
|
void broadcastPending().then(() => chatWrites).then(() => {
|
|
1809
3488
|
unsubscribe();
|
|
1810
3489
|
unsubscribeSentinel();
|
|
1811
3490
|
unsubscribeEyecat();
|
|
1812
3491
|
unsubscribeLive();
|
|
1813
|
-
|
|
3492
|
+
unsubscribeGhost();
|
|
1814
3493
|
for (const client of clients.keys()) client.end();
|
|
1815
3494
|
clients.clear();
|
|
1816
3495
|
// The memory file closes last, once nothing else writes to it; WAL and shm go with it.
|
|
1817
3496
|
try { memory?.close(); } catch { /* already closed */ }
|
|
1818
3497
|
void outbound.drain();
|
|
1819
3498
|
releaseOpen();
|
|
1820
|
-
|
|
3499
|
+
nativeClose(callback);
|
|
1821
3500
|
server.closeIdleConnections?.();
|
|
1822
3501
|
});
|
|
1823
3502
|
});
|
|
1824
3503
|
return server;
|
|
1825
3504
|
};
|
|
1826
3505
|
server.on('close', () => quotaMonitor.stop());
|
|
3506
|
+
server.on('close', () => { for (const timer of aliveTimers) clearTimeout(timer); });
|
|
1827
3507
|
|
|
1828
|
-
|
|
3508
|
+
server.on('close', () => { void phone.stop(); });
|
|
3509
|
+
return { server, store, agents, quotaMonitor, markOpen, releaseOpen, phone };
|
|
1829
3510
|
}
|
|
1830
3511
|
|
|
1831
|
-
export async function startPulse({ port, projectRoot, openBrowser }) {
|
|
1832
|
-
const { server, agents, markOpen } = await createPulseServer({ projectRoot, quotaSources: defaultQuotaSources() });
|
|
3512
|
+
export async function startPulse({ port, projectRoot, openBrowser, openPath = '' }) {
|
|
3513
|
+
const { server, agents, markOpen, phone } = await createPulseServer({ projectRoot, quotaSources: defaultQuotaSources() });
|
|
1833
3514
|
await new Promise((resolve, reject) => {
|
|
1834
3515
|
server.once('error', reject);
|
|
1835
3516
|
server.listen(port, '127.0.0.1', resolve);
|
|
@@ -1837,13 +3518,18 @@ export async function startPulse({ port, projectRoot, openBrowser }) {
|
|
|
1837
3518
|
const address = server.address();
|
|
1838
3519
|
// The room is taken from here on, and says so on disk for anyone who tries to open it again.
|
|
1839
3520
|
await markOpen(address.port);
|
|
3521
|
+
// From here this room offers to host the phone's bridge, when there is a phone to reach it.
|
|
3522
|
+
phone.start();
|
|
1840
3523
|
const url = `http://127.0.0.1:${address.port}`;
|
|
1841
3524
|
const ready = agents.filter((agent) => agent.ready).map((agent) => agent.label).join(', ') || 'none';
|
|
1842
3525
|
const detected = agents.filter((agent) => agent.detected).map((agent) => agent.label).join(', ') || 'none';
|
|
1843
|
-
console.log(`\nMADRE is ready\n\n ${url}\n Project: ${projectRoot}\n Ready: ${ready}\n Detected: ${detected}\n`);
|
|
1844
|
-
if (openBrowser) openUrl(url);
|
|
1845
|
-
|
|
3526
|
+
console.log(`\nMADRE is ready\n\n ${url}${openPath}\n Project: ${projectRoot}\n Ready: ${ready}\n Detected: ${detected}\n`);
|
|
3527
|
+
if (openBrowser) openUrl(`${url}${openPath}`);
|
|
3528
|
+
// SIGHUP too: closing the Terminal the room was started in is a way of closing it.
|
|
3529
|
+
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
|
1846
3530
|
process.once(signal, () => server.close(() => process.exit(0)));
|
|
1847
3531
|
}
|
|
3532
|
+
// Salas asked this room to close: the same as a signal.
|
|
3533
|
+
server.once('madre:close', () => server.close(() => process.exit(0)));
|
|
1848
3534
|
return { server, url };
|
|
1849
3535
|
}
|