@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/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
|
+
}
|
package/src/event-store.mjs
CHANGED
|
@@ -3,8 +3,31 @@ import { dirname, join } from 'node:path';
|
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
4
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
5
5
|
|
|
6
|
+
// A record cut halfway (2026-10-12, LOG-02 of the 1.0 review): a `kill -9`, a power cut or a full
|
|
7
|
+
// disk in the middle of an append leaves half a line. Every read used to throw on it, so the
|
|
8
|
+
// conversation never opened again — and the next event was written glued to the half line, lost
|
|
9
|
+
// with it. Now a line that is not an event is stepped over and counted (`damaged`), never removed:
|
|
10
|
+
// the file keeps it for whoever wants to look. And an append that finds the file not ending in a
|
|
11
|
+
// newline writes one first, so what comes next starts on a line of its own.
|
|
12
|
+
function parseLines(text) {
|
|
13
|
+
const events = [];
|
|
14
|
+
let damaged = 0;
|
|
15
|
+
for (const line of text.split('\n')) {
|
|
16
|
+
if (!line.trim()) continue;
|
|
17
|
+
try {
|
|
18
|
+
const event = JSON.parse(line);
|
|
19
|
+
if (event && typeof event === 'object' && Number.isFinite(event.sequence)) events.push(event);
|
|
20
|
+
else damaged += 1;
|
|
21
|
+
} catch {
|
|
22
|
+
damaged += 1;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return { events, damaged };
|
|
26
|
+
}
|
|
27
|
+
|
|
6
28
|
export class EventStore {
|
|
7
29
|
#file;
|
|
30
|
+
#damaged = 0;
|
|
8
31
|
#nextSequence = 1;
|
|
9
32
|
#writeQueue = Promise.resolve();
|
|
10
33
|
#lockDirectory;
|
|
@@ -14,16 +37,29 @@ export class EventStore {
|
|
|
14
37
|
// numbering across all of its conversations, so #1411 means one exchange in this project and
|
|
15
38
|
// not one in each thread — the memory index, every citation and NOSTROMO all lean on that.
|
|
16
39
|
// Gaps in a single ledger are fine: sequences are an order, not a count.
|
|
40
|
+
// It may be a function, read at every append, when other conversations of the project are
|
|
41
|
+
// writing at the same time as this one: a number fixed when the ledger opened would hand out
|
|
42
|
+
// a sequence another conversation has taken since.
|
|
17
43
|
constructor(file, { floor = 0 } = {}) {
|
|
18
44
|
this.#file = file;
|
|
19
|
-
this.#floor = Number.isFinite(floor) ? floor : 0;
|
|
45
|
+
this.#floor = typeof floor === 'function' || Number.isFinite(floor) ? floor : 0;
|
|
20
46
|
this.#lockDirectory = `${file}.lock`;
|
|
21
47
|
}
|
|
22
48
|
|
|
49
|
+
#floorNow() {
|
|
50
|
+
const value = typeof this.#floor === 'function' ? Number(this.#floor()) : this.#floor;
|
|
51
|
+
return Number.isFinite(value) ? value : 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// The last sequence this ledger handed out (or the floor it opened above).
|
|
55
|
+
get lastSequence() { return this.#nextSequence - 1; }
|
|
56
|
+
// How many lines the last full read stepped over because they were not events.
|
|
57
|
+
get damaged() { return this.#damaged; }
|
|
58
|
+
|
|
23
59
|
async initialize() {
|
|
24
60
|
await mkdir(dirname(this.#file), { recursive: true });
|
|
25
61
|
const existing = await this.readAll();
|
|
26
|
-
this.#nextSequence = Math.max(existing.at(-1)?.sequence ?? 0, this.#
|
|
62
|
+
this.#nextSequence = Math.max(existing.at(-1)?.sequence ?? 0, this.#floorNow()) + 1;
|
|
27
63
|
return this;
|
|
28
64
|
}
|
|
29
65
|
|
|
@@ -59,7 +95,7 @@ export class EventStore {
|
|
|
59
95
|
const lastNewline = text.lastIndexOf('\n');
|
|
60
96
|
if (lastNewline < 0) return { events: [], offset: start };
|
|
61
97
|
text = text.slice(0, lastNewline + 1);
|
|
62
|
-
const events = text
|
|
98
|
+
const { events } = parseLines(text);
|
|
63
99
|
return { events, offset: start + Buffer.byteLength(text, 'utf8') };
|
|
64
100
|
} finally {
|
|
65
101
|
await handle.close();
|
|
@@ -71,8 +107,9 @@ export class EventStore {
|
|
|
71
107
|
|
|
72
108
|
async #readAllUnlocked() {
|
|
73
109
|
try {
|
|
74
|
-
const
|
|
75
|
-
|
|
110
|
+
const { events, damaged } = parseLines(await readFile(this.#file, 'utf8'));
|
|
111
|
+
this.#damaged = damaged;
|
|
112
|
+
return events;
|
|
76
113
|
} catch (error) {
|
|
77
114
|
if (error.code === 'ENOENT') return [];
|
|
78
115
|
throw error;
|
|
@@ -144,7 +181,7 @@ export class EventStore {
|
|
|
144
181
|
const temp = `${this.#file}.rewrite-${process.pid}`;
|
|
145
182
|
await writeFile(temp, next.map((event) => JSON.stringify(event)).join('\n') + (next.length ? '\n' : ''));
|
|
146
183
|
await rename(temp, this.#file);
|
|
147
|
-
this.#nextSequence = Math.max(next.at(-1)?.sequence ?? 0, this.#
|
|
184
|
+
this.#nextSequence = Math.max(next.at(-1)?.sequence ?? 0, this.#floorNow()) + 1;
|
|
148
185
|
return { total: next.length, changed };
|
|
149
186
|
} finally {
|
|
150
187
|
await release();
|
|
@@ -159,7 +196,11 @@ export class EventStore {
|
|
|
159
196
|
const release = await this.#acquireLock();
|
|
160
197
|
try {
|
|
161
198
|
const existing = await this.#readAllUnlocked();
|
|
162
|
-
const sequence = Math.max(existing.at(-1)?.sequence ?? 0, this.#
|
|
199
|
+
const sequence = Math.max(existing.at(-1)?.sequence ?? 0, this.#floorNow()) + 1;
|
|
200
|
+
// Taken in the same tick it was read: another conversation's ledger asks for the floor
|
|
201
|
+
// while this one is still on its way to the disk, and must already see this number as
|
|
202
|
+
// spent. A write that fails leaves a gap, and gaps are fine.
|
|
203
|
+
this.#nextSequence = sequence + 1;
|
|
163
204
|
const event = {
|
|
164
205
|
id: randomUUID(),
|
|
165
206
|
sequence,
|
|
@@ -167,14 +208,18 @@ export class EventStore {
|
|
|
167
208
|
timestamp: new Date().toISOString(),
|
|
168
209
|
payload,
|
|
169
210
|
};
|
|
170
|
-
const handle = await open(this.#file, 'a');
|
|
211
|
+
const handle = await open(this.#file, 'a+');
|
|
171
212
|
try {
|
|
172
|
-
|
|
213
|
+
// A file that does not end in a newline ends in half a line: start on a new one.
|
|
214
|
+
const { size } = await handle.stat();
|
|
215
|
+
const last = Buffer.alloc(1);
|
|
216
|
+
if (size > 0) await handle.read(last, 0, 1, size - 1);
|
|
217
|
+
const lead = size > 0 && last[0] !== 0x0a ? '\n' : '';
|
|
218
|
+
await handle.writeFile(`${lead}${JSON.stringify(event)}\n`);
|
|
173
219
|
await handle.sync();
|
|
174
220
|
} finally {
|
|
175
221
|
await handle.close();
|
|
176
222
|
}
|
|
177
|
-
this.#nextSequence = sequence + 1;
|
|
178
223
|
return event;
|
|
179
224
|
} finally {
|
|
180
225
|
await release();
|
package/src/exam.mjs
CHANGED
|
@@ -102,7 +102,7 @@ export function spread(items, size) {
|
|
|
102
102
|
export async function coverageExam({ events = [], recall, embed = null, sample = COVERAGE_SAMPLE, bar = null } = {}) {
|
|
103
103
|
const all = exchanges(events);
|
|
104
104
|
const cases = spread(all, sample);
|
|
105
|
-
if (!cases.length) return { id: 'coverage', ran: false, says: t('No exchange in this
|
|
105
|
+
if (!cases.length) return { id: 'coverage', ran: false, says: t('No exchange in this project is long enough to test with yet.') };
|
|
106
106
|
const found = [];
|
|
107
107
|
for (const one of cases) {
|
|
108
108
|
const held = await recall(one.asked, one.sequence);
|
|
@@ -146,7 +146,7 @@ export async function coverageExam({ events = [], recall, embed = null, sample =
|
|
|
146
146
|
id: 'coverage', ran: true, at: new Date().toISOString(), n: cases.length, hits, rate, method, bar: used,
|
|
147
147
|
mean: mean(scores), control: controlled ? mean(controls) : null,
|
|
148
148
|
passed: rate >= PASS.coverage,
|
|
149
|
-
says: t('{hits} of {n} questions this
|
|
149
|
+
says: t('{hits} of {n} questions this project actually asked had their answer already in the archive, matched by {method}{control}.', { hits, n: cases.length, method: t(method), control: controlled ? t(' and against a control') : '' }),
|
|
150
150
|
};
|
|
151
151
|
}
|
|
152
152
|
|
|
@@ -186,10 +186,10 @@ export function consistencyExam({ findings = [], notes = [], now = Date.now() }
|
|
|
186
186
|
// `ask(question)` is the local model with this archive behind it. `embed` is needed: comparing
|
|
187
187
|
// two answers by their words rewards copying the question back.
|
|
188
188
|
export async function matchExam({ events = [], ask, embed, sample = MATCH_SAMPLE, bar = MATCH_BAR, onProgress = () => {}, stop = () => false } = {}) {
|
|
189
|
-
if (!embed) return { id: 'match', ran: false, says: t('This test needs
|
|
189
|
+
if (!embed) return { id: 'match', ran: false, says: t('This test needs smart search: two answers cannot be compared by their words alone.') };
|
|
190
190
|
const all = exchanges(events).filter((one) => !one.local); // a model is not tested against itself
|
|
191
191
|
const cases = spread(all, sample);
|
|
192
|
-
if (!cases.length) return { id: 'match', ran: false, says: t('No question in this
|
|
192
|
+
if (!cases.length) return { id: 'match', ran: false, says: t('No question in this project was answered by an agent other than the local one yet.') };
|
|
193
193
|
const mine = [];
|
|
194
194
|
for (const [index, one] of cases.entries()) {
|
|
195
195
|
if (stop()) return { id: 'match', ran: false, stopped: true, says: t('Stopped after {index} of {n}.', { index, n: cases.length }) };
|
|
@@ -222,7 +222,7 @@ export async function matchExam({ events = [], ask, embed, sample = MATCH_SAMPLE
|
|
|
222
222
|
export function saysFor(result) {
|
|
223
223
|
if (!result?.ran) return result?.says ?? null;
|
|
224
224
|
if (result.id === 'coverage' && Number.isFinite(result.hits)) {
|
|
225
|
-
return t('{hits} of {n} questions this
|
|
225
|
+
return t('{hits} of {n} questions this project actually asked had their answer already in the archive, matched by {method}{control}.',
|
|
226
226
|
{ hits: result.hits, n: result.n, method: t(result.method ?? 'words'), control: result.control === null || result.control === undefined ? '' : t(' and against a control') });
|
|
227
227
|
}
|
|
228
228
|
if (result.id === 'match' && Number.isFinite(result.matched)) {
|