@jossuealcala/madre 0.7.1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +833 -1
- package/README.md +92 -28
- package/SECURITY.md +1 -0
- package/bin/madre.mjs +30 -10
- package/docs/REFERENCE.md +158 -79
- package/docs/SDK.md +23 -19
- package/package.json +1 -1
- package/public/app.js +11599 -4186
- 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 +2863 -1008
- 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 +1497 -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 +787 -0
- package/public/salas-mac.html +154 -0
- package/public/salas-mac.js +3264 -0
- package/public/salas.css +85 -0
- package/public/salas.html +47 -0
- package/public/salas.js +200 -0
- package/public/styles.css +6303 -414
- package/public/touch.js +83 -0
- package/public/tour-chapters.js +342 -0
- package/public/tour.js +510 -0
- package/public/troubleshooting.js +159 -140
- package/src/adapters/claude.mjs +4 -3
- package/src/adapters/codex.mjs +49 -8
- 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 +2 -2
- 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 +45 -6
- 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/dataset.mjs +3 -1
- package/src/directives.mjs +0 -5
- package/src/enlaces.mjs +451 -0
- package/src/event-store.mjs +55 -10
- package/src/exam.mjs +5 -5
- package/src/extensions.mjs +0 -2
- package/src/file-ops.mjs +42 -0
- package/src/files.mjs +52 -2
- package/src/github.mjs +407 -0
- package/src/json-file.mjs +63 -0
- package/src/lease.mjs +2 -2
- package/src/lobby-work.mjs +103 -0
- package/src/looks.mjs +55 -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 +109 -1
- 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 +19 -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 +9 -5
- package/src/modules/playwright.mjs +121 -11
- package/src/modules/ripley.mjs +1 -1
- package/src/modules/runs.mjs +34 -0
- package/src/modules/sdk.mjs +10 -4
- package/src/ollama.mjs +25 -1
- package/src/outbound.mjs +19 -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 +88 -8
- package/src/room/economy.mjs +35 -7
- package/src/room/escalation.mjs +2 -0
- package/src/room/guard.mjs +26 -2
- package/src/room/prompt.mjs +32 -13
- package/src/room/runs.mjs +151 -0
- package/src/room/vectors.mjs +19 -4
- package/src/room.mjs +402 -54
- package/src/router.mjs +3 -1
- package/src/runs.mjs +137 -0
- package/src/salas.mjs +313 -0
- package/src/sentinel-errors.mjs +1 -1
- package/src/server-sentinel.mjs +124 -0
- package/src/server.mjs +1578 -167
- package/src/setup.mjs +1 -0
- package/src/tour.mjs +159 -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/src/config.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { copyFile, mkdir, open, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
2
3
|
import { homedir } from 'node:os';
|
|
3
4
|
import { dirname, join } from 'node:path';
|
|
4
5
|
|
|
@@ -18,21 +19,84 @@ export async function loadConfig(root) {
|
|
|
18
19
|
}
|
|
19
20
|
}
|
|
20
21
|
|
|
22
|
+
// Every room on this Mac writes this one file. A write is whole — a draft renamed over the file,
|
|
23
|
+
// so nobody ever reads half of one — and a change is read, applied and written under a lock, so
|
|
24
|
+
// two rooms saving at once keep both changes instead of the last one writing over the other
|
|
25
|
+
// (docs/MACROVERSO.md, S-001).
|
|
21
26
|
export async function saveConfig(root, config) {
|
|
22
27
|
const file = configPath(root);
|
|
23
28
|
await mkdir(dirname(file), { recursive: true });
|
|
24
|
-
|
|
29
|
+
const draft = `${file}.${process.pid}.${randomUUID()}.tmp`;
|
|
30
|
+
await writeFile(draft, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
|
|
31
|
+
await rename(draft, file);
|
|
25
32
|
return file;
|
|
26
33
|
}
|
|
27
34
|
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
35
|
+
async function locked(root, work) {
|
|
36
|
+
const lock = join(dirname(configPath(root)), 'config.lock');
|
|
37
|
+
await mkdir(dirname(lock), { recursive: true });
|
|
38
|
+
let held = false;
|
|
39
|
+
for (let attempt = 0; attempt < 250 && !held; attempt += 1) {
|
|
40
|
+
try {
|
|
41
|
+
await (await open(lock, 'wx')).close();
|
|
42
|
+
held = true;
|
|
43
|
+
} catch (error) {
|
|
44
|
+
if (error.code !== 'EEXIST') throw error;
|
|
45
|
+
// A lock left by a process that died mid-write is stale after a few seconds.
|
|
46
|
+
const since = await stat(lock).catch(() => null);
|
|
47
|
+
if (since && Date.now() - since.mtimeMs > 5000) { await rm(lock, { force: true }); continue; }
|
|
48
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (!held) throw new Error('The settings are being saved by another room; try again.');
|
|
52
|
+
try {
|
|
53
|
+
return await work();
|
|
54
|
+
} finally {
|
|
55
|
+
await rm(lock, { force: true });
|
|
33
56
|
}
|
|
34
|
-
|
|
35
|
-
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// What a change starts from. A file that is there but is not JSON used to be read as empty, and the
|
|
60
|
+
// change written over it kept only itself: every other setting was gone, with nothing said
|
|
61
|
+
// (2026-10-12, review of 1.0). Now a copy of it is kept beside it first, and that is said.
|
|
62
|
+
async function readForChange(root) {
|
|
63
|
+
const file = configPath(root);
|
|
64
|
+
let text;
|
|
65
|
+
try { text = await readFile(file, 'utf8'); } catch (error) { if (error.code === 'ENOENT') return {}; throw error; }
|
|
66
|
+
try {
|
|
67
|
+
const parsed = JSON.parse(text);
|
|
68
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
|
|
69
|
+
} catch { /* kept below */ }
|
|
70
|
+
const kept = `${file}.damaged-${new Date().toISOString().replace(/[:.]/g, '-')}`;
|
|
71
|
+
await copyFile(file, kept).catch(() => {});
|
|
72
|
+
console.error(`MADRE: ${file} could not be read; a copy was kept at ${kept} before saving over it.`);
|
|
73
|
+
return {};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// A change that reads what is there and decides from it, all under the lock: checked and written
|
|
77
|
+
// in one step, so two changes at once cannot both pass a check that only one of them should
|
|
78
|
+
// (two signatures of the same pair, a revocation and a recall crossing). `change` gets a copy and
|
|
79
|
+
// returns what to save, or nothing to leave the file as it is.
|
|
80
|
+
export async function withConfig(root, change) {
|
|
81
|
+
return locked(root, async () => {
|
|
82
|
+
const current = await readForChange(root);
|
|
83
|
+
const next = await change(structuredClone(current));
|
|
84
|
+
if (!next || typeof next !== 'object') return current;
|
|
85
|
+
await saveConfig(root, next);
|
|
86
|
+
return next;
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function updateConfig(root, patch) {
|
|
91
|
+
return locked(root, async () => {
|
|
92
|
+
const current = await readForChange(root);
|
|
93
|
+
const next = { ...current, ...patch };
|
|
94
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
95
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) next[key] = { ...(current[key] ?? {}), ...value };
|
|
96
|
+
}
|
|
97
|
+
await saveConfig(root, next);
|
|
98
|
+
return next;
|
|
99
|
+
});
|
|
36
100
|
}
|
|
37
101
|
|
|
38
102
|
// Applies file-backed preferences to the process environment without
|
|
@@ -41,12 +41,15 @@ export function buildConversationContext(events, { excludeMessageId, maxChars =
|
|
|
41
41
|
for (let i = from; i < messages.length; i += 1) used += messages[i].sender.length + messages[i].role.length + messages[i].text.length + 4;
|
|
42
42
|
return used;
|
|
43
43
|
};
|
|
44
|
-
|
|
44
|
+
// -1 is «no anchor held»: an anchor on the very first message is index 0, and `!start` read it
|
|
45
|
+
// as none, so the window was cut early and the human's first request was the first to go
|
|
46
|
+
// (LOG-15 of the 1.0 review).
|
|
47
|
+
let start = -1;
|
|
45
48
|
if (Number.isInteger(anchor)) {
|
|
46
49
|
const held = messages.findIndex((message) => message.sequence >= anchor);
|
|
47
50
|
if (held >= 0 && weigh(held) <= budget) start = held;
|
|
48
51
|
}
|
|
49
|
-
if (
|
|
52
|
+
if (start < 0 || weigh(start) > budget) {
|
|
50
53
|
// Let go of a chunk and then hold: back off until the window sits well under its budget, so
|
|
51
54
|
// there is room for many turns of new words before it has to move again.
|
|
52
55
|
start = messages.length;
|
package/src/dataset.mjs
CHANGED
|
@@ -48,7 +48,9 @@ export function pairsFromEvents(events, { project = 'project', home, user, priva
|
|
|
48
48
|
if (event.type !== 'message.created' || event.ghost || !Number.isInteger(event.sequence)) continue;
|
|
49
49
|
const p = event.payload ?? {};
|
|
50
50
|
if (typeof p.text !== 'string' || !p.text.trim()) continue;
|
|
51
|
-
|
|
51
|
+
// An order of BISHOP (via 'bishop', docs/MACROVERSO.md D-S10) is a user message the human did
|
|
52
|
+
// not write: it is not their words, so neither it nor its answer becomes a pair.
|
|
53
|
+
if (p.role === 'user') { if (p.via !== 'bishop') asks.set(p.messageId, { text: p.originalText ?? p.text, mode: p.mode ?? 1, from: 'you' }); continue; }
|
|
52
54
|
if (p.role !== 'assistant') continue;
|
|
53
55
|
if (p.status === 'delegated') {
|
|
54
56
|
// An orchestrator's step is a question for the agent it names; its closing turn is not.
|
package/src/directives.mjs
CHANGED
|
@@ -66,8 +66,3 @@ export function parseDirectives(text, { self, available = [], maxSteps = 4 } = {
|
|
|
66
66
|
}
|
|
67
67
|
return { steps, closing, ignored };
|
|
68
68
|
}
|
|
69
|
-
|
|
70
|
-
// The reply text without the plan block, for readers who only want the prose.
|
|
71
|
-
export function stripDirectives(text) {
|
|
72
|
-
return String(text ?? '').replace(/```pulse\s*\n[\s\S]*?```/gi, '').trim();
|
|
73
|
-
}
|
package/src/enlaces.mjs
ADDED
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
// Links between rooms (docs/MACROVERSO.md, phase 3b): the contract two rooms sign, the key that
|
|
2
|
+
// proves a caller is a MADRE process on this Mac, the side that asks, and the counters of what
|
|
3
|
+
// crossed. A link reads; nothing here writes in another room.
|
|
4
|
+
//
|
|
5
|
+
// The contract lives in `~/.pulse/config.json` under `enlaces`, keyed by id, written with
|
|
6
|
+
// `updateConfig` so every room on this Mac reads the same one and two rooms signing at once keep
|
|
7
|
+
// both (S-001, S-007). What may cross is said once, in src/memory.mjs: `travels()` and
|
|
8
|
+
// `RECALLED_ZONES` are cited here, never copied (docs/ZONAS.md). The locks live in the room that
|
|
9
|
+
// serves (src/server.mjs, the ENLACES section), never in the room that asks.
|
|
10
|
+
//
|
|
11
|
+
// The key: one secret in `~/.pulse/credentials/enlaces.json`, the same shape the vault keeps for a
|
|
12
|
+
// module (`{ name: value }`, 0600 inside 0700), made by the first room that needs it, carried in a
|
|
13
|
+
// header and never in a URL, a body or a log. Of the key, the ledger may know its name and its
|
|
14
|
+
// length, never its value (docs/SDK.md, the vault). It proves «a MADRE process on this Mac», not
|
|
15
|
+
// which room: that limit is written down, not papered over.
|
|
16
|
+
|
|
17
|
+
import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
18
|
+
import { appendFile, chmod, link, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
import { withConfig } from './config.mjs';
|
|
21
|
+
import { RECALLED_ZONES, STANDING_KINDS } from './memory.mjs';
|
|
22
|
+
import { knownRooms } from './salas.mjs';
|
|
23
|
+
|
|
24
|
+
// The kinds a contract may name: knowledge that stands. An aberration is the one kind that is
|
|
25
|
+
// not knowledge and it never crosses, whatever a contract says.
|
|
26
|
+
export const LINK_KINDS = [...STANDING_KINDS];
|
|
27
|
+
// The zones a link may read from are the zones a turn may read from, and nothing else.
|
|
28
|
+
export const LINK_ZONES = [...RECALLED_ZONES];
|
|
29
|
+
export const DIRECTIONS = ['a→b', 'b→a', 'both'];
|
|
30
|
+
export const MAX_LIMIT = 20;
|
|
31
|
+
export const DEFAULT_PER_HOUR = 6;
|
|
32
|
+
export const MAX_ORDER_MODE = 2;
|
|
33
|
+
export const HOUR_MS = 60 * 60 * 1000;
|
|
34
|
+
// The header the asking room presents, and the one answer every refused contract gets: the side
|
|
35
|
+
// that asks is never told whether the link is missing, revoked, expired or facing the other way.
|
|
36
|
+
export const CHANNEL_HEADER = 'x-madre-enlace';
|
|
37
|
+
export const NO_SUCH_LINK = 'No such link.';
|
|
38
|
+
// `order` is one of BISHOP's orders that reached this room (fase 4b). It is counted apart from the
|
|
39
|
+
// readings, each kind against the contract's own `perHour`: asking BISHOP a question, which reads,
|
|
40
|
+
// never uses up the orders, and an order never uses up a reading.
|
|
41
|
+
export const CROSSINGS = ['read', 'received', 'order'];
|
|
42
|
+
// What one room may tell another about a link, by the channel: that it was signed or revoked.
|
|
43
|
+
// Each lands in the other room's ledger as a line, never as a message (phase 3c).
|
|
44
|
+
export const NOTICES = ['enlace.signed', 'enlace.revoked'];
|
|
45
|
+
export const DAY_MS = 24 * HOUR_MS;
|
|
46
|
+
// The longest link BISHOP may sign on its own (D-S13).
|
|
47
|
+
export const BISHOP_SIGN_DAYS = 7;
|
|
48
|
+
// The most notes a link with automatic recall brings to one turn, across every such link (3e).
|
|
49
|
+
export const RECALL_NOTES = 3;
|
|
50
|
+
|
|
51
|
+
const OWNER_ONLY = 0o600;
|
|
52
|
+
const OWNER_DIR = 0o700;
|
|
53
|
+
const KEY_HEX = /^[0-9a-f]{64}$/;
|
|
54
|
+
const ID = /^[0-9a-f]{12}-[0-9a-f]{6}$/;
|
|
55
|
+
// A room id is a folder name under ~/.pulse/rooms: never a path.
|
|
56
|
+
const ROOM = /^[^/\\\0]{1,200}$/;
|
|
57
|
+
|
|
58
|
+
const fail = (why, code = null) => { throw Object.assign(new Error(why), code ? { code } : {}); };
|
|
59
|
+
const pairHash = (a, b) => createHash('sha256').update([String(a), String(b)].sort().join('\n')).digest('hex').slice(0, 12);
|
|
60
|
+
const asDate = (value) => { const date = new Date(value); return Number.isNaN(date.getTime()) ? null : date; };
|
|
61
|
+
|
|
62
|
+
// Stable for the pair of rooms, plus a short nonce so the same two rooms can sign again after a
|
|
63
|
+
// revocation and the old id never comes back to life.
|
|
64
|
+
export function contractId({ a, b }, { nonce = randomBytes(3).toString('hex') } = {}) {
|
|
65
|
+
return `${pairHash(a, b)}-${nonce}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function isoOrNull(value, field) {
|
|
69
|
+
if (value === null || value === undefined || value === '') return null;
|
|
70
|
+
const date = typeof value === 'string' || value instanceof Date || typeof value === 'number' ? asDate(value) : null;
|
|
71
|
+
if (!date) fail(`${field} is not a date.`);
|
|
72
|
+
return date.toISOString();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// The rooms that hand their notes over under a contract: the arrow's tail, or both ends.
|
|
76
|
+
export const servingRooms = (contract) => (contract.direction === 'both' ? [contract.rooms.a, contract.rooms.b] : contract.direction === 'a→b' ? [contract.rooms.a] : [contract.rooms.b]);
|
|
77
|
+
|
|
78
|
+
// The room BISHOP's orders may reach under a contract: the end that is not the room of Salas.
|
|
79
|
+
// `null` when the contract allows no orders, or when neither end is that room.
|
|
80
|
+
export function receivesOrders(contract, salas) {
|
|
81
|
+
if (!contract?.orders?.allowed || !salas) return null;
|
|
82
|
+
const { a, b } = contract.rooms;
|
|
83
|
+
return a === salas ? b : b === salas ? a : null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// The contract, whole and checked, or the reason it is not one. The shape is §4B of the encargo:
|
|
87
|
+
// two rooms, a direction, the kinds that may cross, the zones (fixed: what travels into a turn),
|
|
88
|
+
// an expiry, a revocation, the ceiling for BISHOP's orders, and the human's signature.
|
|
89
|
+
//
|
|
90
|
+
// With `salas` (the id of the room of Salas, src/salas.mjs) two more rules hold: that room never
|
|
91
|
+
// serves — BISHOP gathers every room and is nobody's source (§4C.7) — and orders are allowed only
|
|
92
|
+
// on a link with that room at one end, which is then the end that gives them.
|
|
93
|
+
export function normalizeContract(raw, { now = new Date(), salas = null } = {}) {
|
|
94
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) fail('A link is an object.');
|
|
95
|
+
const rooms = raw.rooms && typeof raw.rooms === 'object' ? raw.rooms : {};
|
|
96
|
+
const a = typeof rooms.a === 'string' ? rooms.a.trim() : '';
|
|
97
|
+
const b = typeof rooms.b === 'string' ? rooms.b.trim() : '';
|
|
98
|
+
if (!ROOM.test(a) || !ROOM.test(b)) fail('A link names two rooms, a and b.');
|
|
99
|
+
if (a === b) fail('A project cannot be connected to itself.');
|
|
100
|
+
|
|
101
|
+
const id = raw.id === undefined || raw.id === null ? contractId({ a, b }) : String(raw.id);
|
|
102
|
+
if (!ID.test(id)) fail('That is not a link id.');
|
|
103
|
+
if (id.slice(0, 12) !== pairHash(a, b)) fail('The link id does not belong to these rooms.');
|
|
104
|
+
|
|
105
|
+
const direction = String(raw.direction ?? '').replace('->', '→');
|
|
106
|
+
if (!DIRECTIONS.includes(direction)) fail('The direction is a→b, b→a or both.');
|
|
107
|
+
|
|
108
|
+
if (!Array.isArray(raw.kinds) || !raw.kinds.length) fail('A connection names the kinds that may cross.');
|
|
109
|
+
const kinds = [...new Set(raw.kinds.map(String))];
|
|
110
|
+
for (const kind of kinds) {
|
|
111
|
+
if (kind === 'aberration') fail('An aberration never crosses a connection.');
|
|
112
|
+
if (!LINK_KINDS.includes(kind)) fail(`"${kind}" is not a kind of memory.`);
|
|
113
|
+
}
|
|
114
|
+
kinds.sort((x, y) => LINK_KINDS.indexOf(x) - LINK_KINDS.indexOf(y));
|
|
115
|
+
|
|
116
|
+
// Not configurable: a contract that names any other set of zones is refused rather than
|
|
117
|
+
// trimmed, so nobody believes they signed something the room will not do.
|
|
118
|
+
if (raw.zones !== undefined && raw.zones !== null) {
|
|
119
|
+
const asked = Array.isArray(raw.zones) ? [...new Set(raw.zones.map(String))].sort() : null;
|
|
120
|
+
if (!asked || asked.length !== LINK_ZONES.length || asked.some((zone) => !LINK_ZONES.includes(zone))) fail('The zones of a link are not configurable: only what travels into a turn crosses.');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const expires = isoOrNull(raw.expires, 'expires');
|
|
124
|
+
const revoked = isoOrNull(raw.revoked, 'revoked');
|
|
125
|
+
// Fase 3e: the room that reads also gets, on each turn, up to RECALL_NOTES of these notes without
|
|
126
|
+
// asking. Off unless the human ticks it; a reading like any other, counted against `perHour`.
|
|
127
|
+
if (raw.recall !== undefined && raw.recall !== null && typeof raw.recall !== 'boolean') fail('recall is on or off.');
|
|
128
|
+
const recall = raw.recall === true;
|
|
129
|
+
|
|
130
|
+
const orders = raw.orders && typeof raw.orders === 'object' ? raw.orders : {};
|
|
131
|
+
const maxMode = orders.maxMode === undefined ? 1 : Number(orders.maxMode);
|
|
132
|
+
if (!Number.isInteger(maxMode) || maxMode < 0) fail('orders.maxMode is a mode, #0 to #2.');
|
|
133
|
+
if (maxMode > MAX_ORDER_MODE) fail(`orders.maxMode never passes #${MAX_ORDER_MODE}: #3 and #4 are armed by the human, in that room.`);
|
|
134
|
+
const perHour = orders.perHour === undefined ? DEFAULT_PER_HOUR : Number(orders.perHour);
|
|
135
|
+
if (!Number.isInteger(perHour) || perHour < 1 || perHour > 1000) fail('orders.perHour is a whole number above zero.');
|
|
136
|
+
const allowed = Boolean(orders.allowed);
|
|
137
|
+
if (salas) {
|
|
138
|
+
const serves = direction === 'both' ? [a, b] : direction === 'a→b' ? [a] : [b];
|
|
139
|
+
if (serves.includes(salas)) fail('The Coordinator\'s project never serves a connection: the Coordinator gathers every project and is nobody\'s source.');
|
|
140
|
+
if (allowed && a !== salas && b !== salas) fail('Orders need the Coordinator\'s project at one end of the connection.');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Who signed it (D-S13): the human always may; BISHOP only a link to read, with no orders, that
|
|
144
|
+
// expires within BISHOP_SIGN_DAYS of its signature. A stored contract that breaks this is not a
|
|
145
|
+
// contract: `activeContracts` skips it.
|
|
146
|
+
const signed = raw.signed && typeof raw.signed === 'object' ? raw.signed : {};
|
|
147
|
+
const by = signed.by === undefined || signed.by === null ? 'you' : signed.by;
|
|
148
|
+
if (by !== 'you' && by !== 'bishop') fail('Only you or the Coordinator sign a connection.');
|
|
149
|
+
const at = signed.at === undefined || signed.at === null ? new Date(now).toISOString() : isoOrNull(signed.at, 'signed.at');
|
|
150
|
+
if (by === 'bishop') {
|
|
151
|
+
if (allowed) fail('The Coordinator signs only connections to read; a connection with orders is signed by you.');
|
|
152
|
+
if (expires === null || Date.parse(expires) - Date.parse(at) > BISHOP_SIGN_DAYS * DAY_MS + 60000) fail('The Coordinator signs only connections that expire within 7 days.'); // BISHOP_SIGN_DAYS, said literally for the catalogue
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
id,
|
|
157
|
+
rooms: { a, b },
|
|
158
|
+
direction,
|
|
159
|
+
kinds,
|
|
160
|
+
zones: [...LINK_ZONES],
|
|
161
|
+
expires,
|
|
162
|
+
revoked,
|
|
163
|
+
orders: { allowed, maxMode, perHour },
|
|
164
|
+
signed: { at, by },
|
|
165
|
+
recall,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Revoked or expired is the same as never signed.
|
|
170
|
+
export const inForce = (contract, now = new Date()) => Boolean(contract) && contract.revoked === null && (contract.expires === null || Date.parse(contract.expires) > new Date(now).getTime());
|
|
171
|
+
|
|
172
|
+
// Every contract in force, read from the config every room shares. A malformed entry is skipped,
|
|
173
|
+
// never repaired: a link the room cannot read is a link that does not exist.
|
|
174
|
+
export function activeContracts(config, { now = new Date(), salas = null } = {}) {
|
|
175
|
+
const raw = config?.enlaces;
|
|
176
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return [];
|
|
177
|
+
const out = [];
|
|
178
|
+
for (const [id, entry] of Object.entries(raw)) {
|
|
179
|
+
let contract;
|
|
180
|
+
try { contract = normalizeContract({ ...entry, id: entry?.id ?? id }, { now, salas }); } catch { continue; }
|
|
181
|
+
if (contract.id === id && inForce(contract, now)) out.push(contract);
|
|
182
|
+
}
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// The contracts that let `asking` read, each with the room it reads from. What a module, a tool
|
|
187
|
+
// and the asking side of the channel all need to know, said once.
|
|
188
|
+
export function readsFrom(contracts, asking, { now = new Date() } = {}) {
|
|
189
|
+
const out = [];
|
|
190
|
+
for (const contract of contracts) {
|
|
191
|
+
for (const from of [contract.rooms.a, contract.rooms.b]) {
|
|
192
|
+
if (from !== asking && mayServe({ contract, serving: from, asking, now }).ok) out.push({ contract, from });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// May `serving` hand its notes to `asking` under this contract, now? The arrow says which way
|
|
199
|
+
// notes travel: `a→b` lets b read from a, so a serves and b asks.
|
|
200
|
+
export function mayServe({ contract, serving, asking, now = new Date() }) {
|
|
201
|
+
if (!contract) return { ok: false, why: 'no-contract' };
|
|
202
|
+
if (contract.revoked !== null) return { ok: false, why: 'revoked' };
|
|
203
|
+
if (contract.expires !== null && Date.parse(contract.expires) <= new Date(now).getTime()) return { ok: false, why: 'expired' };
|
|
204
|
+
const { a, b } = contract.rooms;
|
|
205
|
+
const from = serving === a ? 'a' : serving === b ? 'b' : null;
|
|
206
|
+
const to = asking === a ? 'a' : asking === b ? 'b' : null;
|
|
207
|
+
if (!from || !to || from === to) return { ok: false, why: 'not-a-party' };
|
|
208
|
+
if (contract.direction !== 'both' && contract.direction !== `${from}→${to}`) return { ok: false, why: 'direction' };
|
|
209
|
+
return { ok: true };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Signing writes one key under `enlaces`: `updateConfig` merges that level by key, so two rooms
|
|
213
|
+
// signing at once keep both contracts, as two modules saving at once keep both (S-007). Both
|
|
214
|
+
// rooms must be known on this Mac — asleep is fine; it is read when it wakes. `rooms` may be the
|
|
215
|
+
// ids, or the rooms as `knownRooms` lists them (with their names).
|
|
216
|
+
//
|
|
217
|
+
// A link that lets orders through is signed the way #3 is armed: the human types the designation
|
|
218
|
+
// of the room that will receive them — its name, whichever letters — or nothing is signed
|
|
219
|
+
// (§4B). The error carries `code: 'DESIGNATION'` so the route can answer 403 and not 422.
|
|
220
|
+
export async function signContract(stateRoot, raw, { now = new Date(), rooms = null, salas = null, designation = null } = {}) {
|
|
221
|
+
const contract = normalizeContract(raw, { now, salas });
|
|
222
|
+
if (contract.revoked !== null) fail('A link is not signed already revoked.');
|
|
223
|
+
if (contract.expires !== null && Date.parse(contract.expires) <= new Date(now).getTime()) fail('That connection would already have expired.');
|
|
224
|
+
const known = new Map((rooms ?? await knownRooms(stateRoot)).map((room) => (typeof room === 'string' ? [room, { id: room, name: room }] : [room.id, room])));
|
|
225
|
+
for (const id of [contract.rooms.a, contract.rooms.b]) if (!known.has(id)) fail(`MADRE does not know the room ${id} on this Mac.`, 'UNKNOWN_ROOM');
|
|
226
|
+
const ordered = receivesOrders(contract, salas);
|
|
227
|
+
if (ordered) {
|
|
228
|
+
const name = String(known.get(ordered).name ?? ordered);
|
|
229
|
+
if (typeof designation !== 'string' || designation.trim().toLowerCase() !== name.toLowerCase()) fail('UNABLE TO COMPUTE. UNABLE TO CLARIFY.', 'DESIGNATION');
|
|
230
|
+
}
|
|
231
|
+
// Two projects are connected once (2026-10-11): while one connection between them is in force,
|
|
232
|
+
// whichever way it goes, no other is signed. Checked and written in one step (2026-10-12): checked
|
|
233
|
+
// before the lock, two signatures at once both passed and both stood.
|
|
234
|
+
const pair = [contract.rooms.a, contract.rooms.b].sort().join('\n');
|
|
235
|
+
await withConfig(stateRoot, (config) => {
|
|
236
|
+
if (activeContracts(config, { now, salas }).some((one) => [one.rooms.a, one.rooms.b].sort().join('\n') === pair)) fail('These two are already connected. To change it, revoke it in Connections and make a new one.', 'ALREADY');
|
|
237
|
+
config.enlaces = config.enlaces && typeof config.enlaces === 'object' && !Array.isArray(config.enlaces) ? config.enlaces : {};
|
|
238
|
+
config.enlaces[contract.id] = contract;
|
|
239
|
+
return config;
|
|
240
|
+
});
|
|
241
|
+
return contract;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Revoking stamps the moment and keeps the contract: a link that was revoked is a fact of the
|
|
245
|
+
// record, and the id never comes back (a new signature gets a new nonce).
|
|
246
|
+
// Only an id that is one: `__proto__` and its kind wrote junk into the shared file (INI-06).
|
|
247
|
+
export async function revokeContract(stateRoot, id, { now = new Date() } = {}) {
|
|
248
|
+
if (typeof id !== 'string' || !ID.test(id)) return null;
|
|
249
|
+
let revoked = null;
|
|
250
|
+
await withConfig(stateRoot, (config) => {
|
|
251
|
+
const current = config.enlaces && Object.hasOwn(config.enlaces, id) ? config.enlaces[id] : null;
|
|
252
|
+
if (!current || typeof current !== 'object') return null;
|
|
253
|
+
revoked = { ...current, id, revoked: current.revoked ?? new Date(now).toISOString() };
|
|
254
|
+
config.enlaces[id] = revoked;
|
|
255
|
+
return config;
|
|
256
|
+
});
|
|
257
|
+
return revoked;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// «Include always» on a link, ticked or cleared: only that field, and only on a link still in
|
|
261
|
+
// force, read and written in one step — written from a copy read before, it crossed a revocation
|
|
262
|
+
// and put the revoked link back in force (INI-02).
|
|
263
|
+
export async function recallContract(stateRoot, id, recall, { now = new Date(), salas = null } = {}) {
|
|
264
|
+
if (typeof id !== 'string' || !ID.test(id)) return null;
|
|
265
|
+
let changed = null;
|
|
266
|
+
await withConfig(stateRoot, (config) => {
|
|
267
|
+
if (!activeContracts(config, { now, salas }).some((one) => one.id === id)) return null;
|
|
268
|
+
config.enlaces[id] = { ...config.enlaces[id], recall: recall === true };
|
|
269
|
+
changed = activeContracts(config, { now, salas }).find((one) => one.id === id) ?? null;
|
|
270
|
+
return config;
|
|
271
|
+
});
|
|
272
|
+
return changed;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/* ---------- the key of the channel ---------- */
|
|
276
|
+
|
|
277
|
+
export const channelKeyFile = (stateRoot) => join(stateRoot, 'credentials', 'enlaces.json');
|
|
278
|
+
|
|
279
|
+
async function readKey(file) {
|
|
280
|
+
const parsed = JSON.parse(await readFile(file, 'utf8'));
|
|
281
|
+
const key = parsed && typeof parsed === 'object' ? parsed.key : null;
|
|
282
|
+
if (typeof key !== 'string' || !KEY_HEX.test(key)) fail('credentials/enlaces.json does not hold a key.');
|
|
283
|
+
return key;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// The key is made the first time a room needs it: a whole file written beside the name and
|
|
287
|
+
// linked under it, which lands only if nothing is there yet — two rooms making it at once keep
|
|
288
|
+
// the one that landed first, and nobody ever reads half a key. Read from disk on every call, so
|
|
289
|
+
// every room on this Mac holds the same one.
|
|
290
|
+
export async function channelKey(stateRoot) {
|
|
291
|
+
const file = channelKeyFile(stateRoot);
|
|
292
|
+
try {
|
|
293
|
+
return await readKey(file);
|
|
294
|
+
} catch (error) {
|
|
295
|
+
if (error.code !== 'ENOENT') throw error;
|
|
296
|
+
}
|
|
297
|
+
const dir = join(stateRoot, 'credentials');
|
|
298
|
+
await mkdir(dir, { recursive: true, mode: OWNER_DIR });
|
|
299
|
+
// An older credentials directory may predate this mode (src/vault.mjs does the same).
|
|
300
|
+
await chmod(dir, OWNER_DIR).catch(() => {});
|
|
301
|
+
const draft = `${file}.${process.pid}.${randomUUID()}.tmp`;
|
|
302
|
+
const content = `${JSON.stringify({ key: randomBytes(32).toString('hex') })}\n`;
|
|
303
|
+
await writeFile(draft, content, { mode: OWNER_ONLY });
|
|
304
|
+
try {
|
|
305
|
+
await link(draft, file);
|
|
306
|
+
} catch (error) {
|
|
307
|
+
if (error.code !== 'EEXIST') {
|
|
308
|
+
// A filesystem without hard links: the bridge's way (src/bridge/devices.mjs), one write
|
|
309
|
+
// that lands only if nothing is there yet.
|
|
310
|
+
try { await writeFile(file, content, { mode: OWNER_ONLY, flag: 'wx' }); } catch (again) { if (again.code !== 'EEXIST') throw again; }
|
|
311
|
+
}
|
|
312
|
+
} finally {
|
|
313
|
+
await rm(draft, { force: true });
|
|
314
|
+
}
|
|
315
|
+
await chmod(file, OWNER_ONLY).catch(() => {});
|
|
316
|
+
return readKey(file);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Constant time, and never a throw on a header that is not a string.
|
|
320
|
+
export function sameKey(a, b) {
|
|
321
|
+
if (typeof a !== 'string' || typeof b !== 'string' || !a || a.length !== b.length) return false;
|
|
322
|
+
return timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// What the ledger, a card or a report may know of the key: that it is there, its name, its
|
|
326
|
+
// length. Never the value.
|
|
327
|
+
export async function channelKeySummary(stateRoot) {
|
|
328
|
+
try {
|
|
329
|
+
const key = await readKey(channelKeyFile(stateRoot));
|
|
330
|
+
return { name: 'enlaces.json', bytes: key.length };
|
|
331
|
+
} catch {
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/* ---------- the side that asks ---------- */
|
|
337
|
+
|
|
338
|
+
// Asks another room for notes under a contract. The call goes from this process to that one, on
|
|
339
|
+
// this Mac: no `Origin` (a page did not make it), no `x-madre-device` (a phone did not), the key
|
|
340
|
+
// in its header. What comes back is one of four things: the notes; `outdated` (that room runs a
|
|
341
|
+
// MADRE without the route); `refused` with the reason the serving room gave (no such link, no
|
|
342
|
+
// key, sealed, over its hourly limit); or `unreachable`. A received batch is recorded through
|
|
343
|
+
// `record` when the caller hands one in, and counted beside the room's record when it hands in
|
|
344
|
+
// the room's folder.
|
|
345
|
+
export async function askEnlace({ port, contract, asking, q = '', kinds = null, limit = MAX_LIMIT, key, fetchImpl = globalThis.fetch, timeoutMs = 4000, record = null, roomDir = null, turn = null, agent = null, auto = false, now = new Date() }) {
|
|
346
|
+
const id = typeof contract === 'string' ? contract : contract?.id;
|
|
347
|
+
if (!id || !asking) return { refused: NO_SUCH_LINK, status: 404 };
|
|
348
|
+
const params = new URLSearchParams({ contract: id, asking, limit: String(Math.min(MAX_LIMIT, Math.max(1, Math.trunc(Number(limit)) || MAX_LIMIT))) });
|
|
349
|
+
if (q) params.set('q', String(q));
|
|
350
|
+
// Fewer kinds than the contract names, never more: the serving room intersects again.
|
|
351
|
+
if (Array.isArray(kinds) && kinds.length) params.set('kinds', kinds.map(String).join(','));
|
|
352
|
+
// Which agent of the asking room reads (S-021): the serving room keeps it as that room's word.
|
|
353
|
+
if (agent) params.set('agent', String(agent));
|
|
354
|
+
let response;
|
|
355
|
+
try {
|
|
356
|
+
response = await fetchImpl(`http://127.0.0.1:${port}/api/enlace/memories?${params}`, {
|
|
357
|
+
method: 'GET',
|
|
358
|
+
headers: { [CHANNEL_HEADER]: typeof key === 'string' ? key : '' },
|
|
359
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
360
|
+
});
|
|
361
|
+
} catch {
|
|
362
|
+
return { unreachable: true };
|
|
363
|
+
}
|
|
364
|
+
const answer = await response.json().catch(() => ({}));
|
|
365
|
+
if (response.status === 404) return answer?.error === NO_SUCH_LINK ? { refused: NO_SUCH_LINK, status: 404 } : { outdated: true };
|
|
366
|
+
if (!response.ok) return { refused: typeof answer?.error === 'string' ? answer.error : `HTTP ${response.status}`, status: response.status };
|
|
367
|
+
const notes = Array.isArray(answer?.notes) ? answer.notes : [];
|
|
368
|
+
const from = typeof answer?.from === 'string' ? answer.from : null;
|
|
369
|
+
if (record) await record('enlace.received', { contract: id, from, count: notes.length, turn, agent, ...(auto ? { auto: true } : {}) });
|
|
370
|
+
if (roomDir) await noteCrossing(roomDir, { kind: 'received', contract: id, other: from, count: notes.length, agent, turn, now });
|
|
371
|
+
return { contract: id, from, notes };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Tells another room, by the channel, that a link of its was signed or revoked. The same call as
|
|
375
|
+
// `askEnlace` makes, the other way: no `Origin`, no device, the key in its header, a short wait.
|
|
376
|
+
// What comes back is `{ ok }`, `{ outdated }` (that room runs a MADRE without the route),
|
|
377
|
+
// `{ refused, status }` or `{ unreachable }`; the caller never lets any of them stop a signature.
|
|
378
|
+
export async function noticeEnlace({ port, type, payload, key, fetchImpl = globalThis.fetch, timeoutMs = 3000 }) {
|
|
379
|
+
if (!NOTICES.includes(type)) fail(`A notice is one of ${NOTICES.join(', ')}.`);
|
|
380
|
+
let response;
|
|
381
|
+
try {
|
|
382
|
+
response = await fetchImpl(`http://127.0.0.1:${port}/api/enlace/notice`, {
|
|
383
|
+
method: 'POST',
|
|
384
|
+
headers: { 'content-type': 'application/json', [CHANNEL_HEADER]: typeof key === 'string' ? key : '' },
|
|
385
|
+
body: JSON.stringify({ type, payload }),
|
|
386
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
387
|
+
});
|
|
388
|
+
} catch {
|
|
389
|
+
return { unreachable: true };
|
|
390
|
+
}
|
|
391
|
+
if (response.status === 404) return { outdated: true };
|
|
392
|
+
if (!response.ok) {
|
|
393
|
+
const answer = await response.json().catch(() => ({}));
|
|
394
|
+
return { refused: typeof answer?.error === 'string' ? answer.error : `HTTP ${response.status}`, status: response.status };
|
|
395
|
+
}
|
|
396
|
+
return { ok: true };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/* ---------- the counters ---------- */
|
|
400
|
+
|
|
401
|
+
// One line per crossing beside the room's record, so the traffic of a link is counted without
|
|
402
|
+
// opening every ledger on every refresh: `{ at, kind: 'read' | 'received', contract, other, count }`.
|
|
403
|
+
export const crossingsFile = (roomDir) => join(roomDir, 'enlaces.jsonl');
|
|
404
|
+
|
|
405
|
+
export async function noteCrossing(roomDir, { kind, contract, other = null, count = 0, agent = null, turn = null, now = new Date() }) {
|
|
406
|
+
if (!CROSSINGS.includes(kind)) fail(`A crossing is one of ${CROSSINGS.join(', ')}.`);
|
|
407
|
+
const id = typeof contract === 'string' ? contract : contract?.id;
|
|
408
|
+
if (!id) fail('A crossing names its contract.');
|
|
409
|
+
await mkdir(roomDir, { recursive: true });
|
|
410
|
+
const line = { at: new Date(now).toISOString(), kind, contract: id, other, count: Number.isInteger(count) ? count : 0, ...(agent ? { agent: String(agent) } : {}), ...(turn ? { turn: String(turn) } : {}) };
|
|
411
|
+
await appendFile(crossingsFile(roomDir), `${JSON.stringify(line)}\n`);
|
|
412
|
+
return line;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// The lines of one contract and one kind beside a room's record, oldest first. A torn line is
|
|
416
|
+
// skipped, never fatal; no file is no crossing.
|
|
417
|
+
async function crossings(roomDir, contract, kind) {
|
|
418
|
+
const id = typeof contract === 'string' ? contract : contract?.id;
|
|
419
|
+
let raw = '';
|
|
420
|
+
try { raw = await readFile(crossingsFile(roomDir), 'utf8'); } catch (error) { if (error.code === 'ENOENT') return []; throw error; }
|
|
421
|
+
const out = [];
|
|
422
|
+
for (const text of raw.split('\n')) {
|
|
423
|
+
if (!text) continue;
|
|
424
|
+
let line;
|
|
425
|
+
try { line = JSON.parse(text); } catch { continue; }
|
|
426
|
+
if (line?.contract === id && line.kind === kind && Number.isFinite(Date.parse(line.at))) out.push(line);
|
|
427
|
+
}
|
|
428
|
+
return out;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// How many crossings of that kind this contract had in the window: lines, not notes, because the
|
|
432
|
+
// ceiling is on readings.
|
|
433
|
+
export async function countRecent(roomDir, contract, kind, { sinceMs = HOUR_MS, now = new Date() } = {}) {
|
|
434
|
+
const floor = new Date(now).getTime() - sinceMs;
|
|
435
|
+
return (await crossings(roomDir, contract, kind)).filter((line) => Date.parse(line.at) >= floor).length;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// What Salas shows of a link's traffic: the readings the serving room (or rooms, on a link that
|
|
439
|
+
// goes both ways) handed over in the last day and the last hour, and when the latest was. Summed
|
|
440
|
+
// across the folders given, with `countRecent`; `lastAt` is the newest line or `null`.
|
|
441
|
+
export async function linkTraffic(roomDirs, contract, { now = new Date() } = {}) {
|
|
442
|
+
const traffic = { reads24h: 0, reads1h: 0, lastAt: null, lastAgent: null, lastRoom: null };
|
|
443
|
+
for (const roomDir of roomDirs) {
|
|
444
|
+
traffic.reads24h += await countRecent(roomDir, contract, 'read', { sinceMs: DAY_MS, now });
|
|
445
|
+
traffic.reads1h += await countRecent(roomDir, contract, 'read', { sinceMs: HOUR_MS, now });
|
|
446
|
+
for (const line of await crossings(roomDir, contract, 'read')) {
|
|
447
|
+
if (traffic.lastAt === null || Date.parse(line.at) > Date.parse(traffic.lastAt)) Object.assign(traffic, { lastAt: line.at, lastAgent: line.agent ?? null, lastRoom: line.other ?? null });
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
return traffic;
|
|
451
|
+
}
|