@jossuealcala/madre 0.2.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.
Files changed (47) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +5 -0
  4. package/README.md +281 -0
  5. package/bin/madre.mjs +134 -0
  6. package/docs/madre-banner.svg +25 -0
  7. package/package.json +63 -0
  8. package/public/app.js +4031 -0
  9. package/public/brands.js +59 -0
  10. package/public/index.html +231 -0
  11. package/public/styles.css +976 -0
  12. package/public/troubleshooting.js +460 -0
  13. package/src/adapters/claude.mjs +105 -0
  14. package/src/adapters/codex.mjs +84 -0
  15. package/src/adapters/gemini.mjs +335 -0
  16. package/src/adapters/opencode.mjs +148 -0
  17. package/src/adapters/process.mjs +132 -0
  18. package/src/ashcode.mjs +64 -0
  19. package/src/auth-probe.mjs +145 -0
  20. package/src/capabilities.mjs +126 -0
  21. package/src/checkpoint.mjs +106 -0
  22. package/src/cli-args.mjs +38 -0
  23. package/src/commands.mjs +98 -0
  24. package/src/config.mjs +59 -0
  25. package/src/conversation-context.mjs +59 -0
  26. package/src/directives.mjs +70 -0
  27. package/src/distiller.mjs +78 -0
  28. package/src/embeddings.mjs +80 -0
  29. package/src/event-store.mjs +152 -0
  30. package/src/extensions.mjs +263 -0
  31. package/src/files.mjs +177 -0
  32. package/src/image-studio.mjs +25 -0
  33. package/src/lease.mjs +83 -0
  34. package/src/mcp/image-server.mjs +169 -0
  35. package/src/mcp/memory-server.mjs +221 -0
  36. package/src/memory-tools.mjs +34 -0
  37. package/src/memory.mjs +543 -0
  38. package/src/models.mjs +78 -0
  39. package/src/mother.mjs +163 -0
  40. package/src/quota-monitor.mjs +119 -0
  41. package/src/quota-sources.mjs +161 -0
  42. package/src/room.mjs +1159 -0
  43. package/src/router.mjs +13 -0
  44. package/src/runtime-detection.mjs +85 -0
  45. package/src/server.mjs +693 -0
  46. package/src/setup.mjs +192 -0
  47. package/src/usage-sentinel.mjs +65 -0
package/src/memory.mjs ADDED
@@ -0,0 +1,543 @@
1
+ // MADRE's durable room memory, two layers in one SQLite file next to the ledger.
2
+ // entries everything ever said in the room outside GHOST, indexed for
3
+ // full-text recall: exact quotes handed to a turn when they match.
4
+ // Derived from the ledger alone; rebuilt whenever it is stale.
5
+ // memories short durable notes an agent distils from those entries every
6
+ // so often (decisions, verified facts, the human's preferences,
7
+ // open questions), each citing the sequences it came from. These
8
+ // cost a model call, so a schema change keeps them.
9
+ // vectors embeddings of both, when an embedder is attached, so recall can
10
+ // match meaning and not only words. Filled in the background.
11
+ // GHOST events never reach the ledger, so they never reach here either.
12
+
13
+ import { mkdir, rm } from 'node:fs/promises';
14
+ import { dirname } from 'node:path';
15
+ import { messageEntry } from './conversation-context.mjs';
16
+ import { cosine, toBlob, fromBlob } from './embeddings.mjs';
17
+
18
+ export const MEMORY_SCHEMA_VERSION = 3;
19
+ export const MEMORY_KINDS = ['decision', 'fact', 'preference', 'question'];
20
+
21
+ // Words that carry no meaning for recall, in the two languages the rooms speak.
22
+ const STOPWORDS = new Set(('the and for with that this from what which where when have has are was were will would could should about into your you our their there here they them then than also just like only over under some any all not but can does did done been being make made use used using please into onto ' +
23
+ 'los las une una unos unas del con que por para como cuando donde este esta estos estas ese esa esos esas aquel aquella sobre entre hacia desde hasta pero sino porque aunque mientras también tambien muy mas más menos todo toda todos todas algo alguien nada nadie cada otro otra otros otras ser estar haber hacer tener puede pueden podemos quiero quieres queremos hay son fue era está esta están estan sea sean sido siendo tiene tienen tengo dame dime hazlo ahora luego antes después despues aquí aqui allí alli así asi bien mal ver revisa revisar mira dónde cómo qué cuál cuáles quién quiénes cuándo cuánto cuánta cuántos por qué').split(/\s+/));
24
+
25
+ // The terms worth searching for in a request: identifiers, paths and words of
26
+ // three letters or more that are not stopwords, longest first, at most 16.
27
+ export function queryTerms(text, { limit = 16 } = {}) {
28
+ const seen = new Set();
29
+ const terms = [];
30
+ for (const raw of String(text ?? '').toLowerCase().split(/[^\p{L}\p{N}_./@#-]+/u)) {
31
+ const term = raw.replace(/^[./@#-]+|[./-]+$/g, '');
32
+ if (term.length < 3 || STOPWORDS.has(term) || seen.has(term)) continue;
33
+ if (/^\d+$/.test(term) && term.length < 4) continue;
34
+ seen.add(term);
35
+ terms.push(term);
36
+ }
37
+ return terms.sort((a, b) => b.length - a.length).slice(0, limit);
38
+ }
39
+
40
+ // A window of the entry's text around the first term that matches, so the
41
+ // agent reads the sentence that made the entry relevant, not its opening.
42
+ export function excerpt(text, terms, { maxChars = 360 } = {}) {
43
+ const clean = String(text).replace(/\s+/g, ' ').trim();
44
+ if (clean.length <= maxChars) return clean;
45
+ const lower = clean.toLowerCase();
46
+ let at = -1;
47
+ for (const term of terms) { const index = lower.indexOf(term); if (index >= 0 && (at < 0 || index < at)) at = index; }
48
+ if (at < 0) return `${clean.slice(0, maxChars - 1)}…`;
49
+ const start = Math.max(0, Math.min(at - Math.floor(maxChars / 3), clean.length - maxChars));
50
+ const end = Math.min(clean.length, start + maxChars);
51
+ return `${start > 0 ? '…' : ''}${clean.slice(start, end).trim()}${end < clean.length ? '…' : ''}`;
52
+ }
53
+
54
+ // node:sqlite is still flagged experimental; the room does not need to hear that on every start.
55
+ async function loadSqlite() {
56
+ const original = process.emitWarning;
57
+ process.emitWarning = (warning, ...rest) => {
58
+ const message = typeof warning === 'string' ? warning : warning?.message ?? '';
59
+ if (message.includes('SQLite is an experimental feature')) return undefined;
60
+ return original.call(process, warning, ...rest);
61
+ };
62
+ try {
63
+ return await import('node:sqlite');
64
+ } finally {
65
+ process.emitWarning = original;
66
+ }
67
+ }
68
+
69
+ export class RoomMemory {
70
+ #file;
71
+ #db = null;
72
+ #Database = null;
73
+ #insert;
74
+ #meta;
75
+ #setMeta;
76
+ #embedder = null;
77
+
78
+ constructor(file) {
79
+ this.#file = file;
80
+ }
81
+
82
+ get file() { return this.#file; }
83
+
84
+ // Opens (or rebuilds) the index, then catches up with the ledger if given.
85
+ async initialize(store = null) {
86
+ if (this.#file !== ':memory:') await mkdir(dirname(this.#file), { recursive: true });
87
+ ({ DatabaseSync: this.#Database } = await loadSqlite());
88
+ this.#open();
89
+ if (this.#metaValue('schema') !== String(MEMORY_SCHEMA_VERSION)) this.#rebuildEntries();
90
+ if (store) await this.catchUp(store);
91
+ return this;
92
+ }
93
+
94
+ // The entries index is derived, so a stale schema just drops it and lets the
95
+ // ledger fill it again; the distilled memories are kept.
96
+ #rebuildEntries() {
97
+ this.#db.exec(`
98
+ DROP TRIGGER IF EXISTS entries_ai; DROP TRIGGER IF EXISTS entries_ad;
99
+ DROP TABLE IF EXISTS entries_fts; DROP TABLE IF EXISTS entry_vectors; DROP TABLE IF EXISTS entries;
100
+ DELETE FROM meta WHERE key IN ('last_sequence');
101
+ `);
102
+ this.#createSchema();
103
+ this.#setMeta.run('schema', String(MEMORY_SCHEMA_VERSION));
104
+ }
105
+
106
+ #open() {
107
+ this.#db = new this.#Database(this.#file);
108
+ if (this.#file !== ':memory:') this.#db.exec('PRAGMA journal_mode = WAL');
109
+ this.#createSchema();
110
+ this.#meta = this.#db.prepare('SELECT value FROM meta WHERE key = ?');
111
+ this.#setMeta = this.#db.prepare('INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value');
112
+ this.#insert = this.#db.prepare('INSERT OR IGNORE INTO entries (sequence, event_id, timestamp, type, role, sender, target, message_id, text) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)');
113
+ if (this.#metaValue('schema') === null) this.#setMeta.run('schema', String(MEMORY_SCHEMA_VERSION));
114
+ }
115
+
116
+ #createSchema() {
117
+ this.#db.exec(`
118
+ CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
119
+ CREATE TABLE IF NOT EXISTS entries (
120
+ sequence INTEGER PRIMARY KEY,
121
+ event_id TEXT NOT NULL,
122
+ timestamp TEXT,
123
+ type TEXT NOT NULL,
124
+ role TEXT NOT NULL,
125
+ sender TEXT NOT NULL,
126
+ target TEXT,
127
+ message_id TEXT,
128
+ text TEXT NOT NULL,
129
+ distilled INTEGER NOT NULL DEFAULT 0
130
+ );
131
+ CREATE VIRTUAL TABLE IF NOT EXISTS entries_fts USING fts5(text, sender, content='entries', content_rowid='sequence', tokenize='trigram');
132
+ CREATE TRIGGER IF NOT EXISTS entries_ai AFTER INSERT ON entries BEGIN
133
+ INSERT INTO entries_fts(rowid, text, sender) VALUES (new.sequence, new.text, new.sender);
134
+ END;
135
+ CREATE TRIGGER IF NOT EXISTS entries_ad AFTER DELETE ON entries BEGIN
136
+ INSERT INTO entries_fts(entries_fts, rowid, text, sender) VALUES ('delete', old.sequence, old.text, old.sender);
137
+ END;
138
+ CREATE TABLE IF NOT EXISTS memories (
139
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
140
+ created TEXT NOT NULL,
141
+ kind TEXT NOT NULL,
142
+ text TEXT NOT NULL,
143
+ norm TEXT NOT NULL UNIQUE,
144
+ from_sequence INTEGER NOT NULL,
145
+ through_sequence INTEGER NOT NULL,
146
+ sources TEXT NOT NULL,
147
+ agent TEXT NOT NULL
148
+ );
149
+ CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(text, kind, content='memories', content_rowid='id', tokenize='trigram');
150
+ CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN
151
+ INSERT INTO memories_fts(rowid, text, kind) VALUES (new.id, new.text, new.kind);
152
+ END;
153
+ CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN
154
+ INSERT INTO memories_fts(memories_fts, rowid, text, kind) VALUES ('delete', old.id, old.text, old.kind);
155
+ END;
156
+ CREATE TABLE IF NOT EXISTS entry_vectors (sequence INTEGER PRIMARY KEY, model TEXT NOT NULL, vec BLOB NOT NULL);
157
+ CREATE TABLE IF NOT EXISTS memory_vectors (id INTEGER PRIMARY KEY, model TEXT NOT NULL, vec BLOB NOT NULL);
158
+ `);
159
+ // Older files: each entry remembers whether it was distilled (the old watermark seeds it).
160
+ const entryColumns = this.#db.prepare('PRAGMA table_info(entries)').all().map((column) => column.name);
161
+ if (entryColumns.length && !entryColumns.includes('distilled')) {
162
+ this.#db.exec('ALTER TABLE entries ADD COLUMN distilled INTEGER NOT NULL DEFAULT 0');
163
+ const watermark = Number(this.#db.prepare("SELECT value FROM meta WHERE key = 'last_distilled'").get()?.value ?? 0);
164
+ if (watermark > 0) this.#db.prepare('UPDATE entries SET distilled = 1 WHERE sequence <= ?').run(watermark);
165
+ }
166
+ // Notes written on the human's request carry where they came from; older files gain the columns in place.
167
+ const columns = this.#db.prepare('PRAGMA table_info(memories)').all().map((column) => column.name);
168
+ if (!columns.includes('origin')) this.#db.exec("ALTER TABLE memories ADD COLUMN origin TEXT NOT NULL DEFAULT 'distilled'");
169
+ if (!columns.includes('message_id')) this.#db.exec('ALTER TABLE memories ADD COLUMN message_id TEXT');
170
+ }
171
+
172
+ /* ---------- embeddings ---------- */
173
+
174
+ attachEmbedder(embedder) { this.#embedder = embedder ?? null; return this; }
175
+ get embedder() { return this.#embedder; }
176
+
177
+ // Entries and notes without a vector for the current model, oldest first.
178
+ pendingVectors({ limit = 100 } = {}) {
179
+ if (!this.#embedder) return { entries: [], memories: [] };
180
+ const model = this.#embedder.model;
181
+ return {
182
+ entries: this.#db.prepare('SELECT e.sequence, e.text FROM entries e LEFT JOIN entry_vectors v ON v.sequence = e.sequence AND v.model = ? WHERE v.sequence IS NULL ORDER BY e.sequence DESC LIMIT ?').all(model, limit),
183
+ memories: this.#db.prepare('SELECT m.id, m.text FROM memories m LEFT JOIN memory_vectors v ON v.id = m.id AND v.model = ? WHERE v.id IS NULL ORDER BY m.id DESC LIMIT ?').all(model, limit),
184
+ };
185
+ }
186
+
187
+ // Embeds one batch of what is pending. Returns how many vectors were stored.
188
+ async embedPending({ limit = 100 } = {}) {
189
+ if (!this.#embedder || !this.#db) return 0;
190
+ const pending = this.pendingVectors({ limit });
191
+ const texts = [...pending.entries.map((row) => row.text), ...pending.memories.map((row) => row.text)];
192
+ if (!texts.length) return 0;
193
+ const vectors = await this.#embedder.embed(texts, { query: false });
194
+ const model = this.#embedder.model;
195
+ const putEntry = this.#db.prepare('INSERT OR REPLACE INTO entry_vectors (sequence, model, vec) VALUES (?, ?, ?)');
196
+ const putMemory = this.#db.prepare('INSERT OR REPLACE INTO memory_vectors (id, model, vec) VALUES (?, ?, ?)');
197
+ this.#db.exec('BEGIN');
198
+ try {
199
+ pending.entries.forEach((row, index) => putEntry.run(row.sequence, model, toBlob(vectors[index])));
200
+ pending.memories.forEach((row, index) => putMemory.run(row.id, model, toBlob(vectors[pending.entries.length + index])));
201
+ this.#db.exec('COMMIT');
202
+ } catch (error) { this.#db.exec('ROLLBACK'); throw error; }
203
+ return texts.length;
204
+ }
205
+
206
+ vectorCounts() {
207
+ if (!this.#embedder) return { entries: 0, memories: 0 };
208
+ return {
209
+ entries: this.#db.prepare('SELECT COUNT(*) AS n FROM entry_vectors WHERE model = ?').get(this.#embedder.model).n,
210
+ memories: this.#db.prepare('SELECT COUNT(*) AS n FROM memory_vectors WHERE model = ?').get(this.#embedder.model).n,
211
+ };
212
+ }
213
+
214
+ // The query's vector, or null when there is no embedder or it fails: recall then stays lexical.
215
+ async embedQuery(text, { timeoutMs = 2500 } = {}) {
216
+ if (!this.#embedder) return null;
217
+ try {
218
+ const [vector] = await Promise.race([
219
+ this.#embedder.embed([String(text ?? '').slice(0, 2000)], { query: true }),
220
+ new Promise((_, reject) => setTimeout(() => reject(new Error('embedding timed out')), timeoutMs).unref?.()),
221
+ ]);
222
+ return vector ?? null;
223
+ } catch { return null; }
224
+ }
225
+
226
+ // Cosine scores of every stored vector against the query, above a floor.
227
+ #semanticScores(table, key, queryVector, { beforeSequence, floor }) {
228
+ if (!queryVector || !this.#embedder) return new Map();
229
+ const rows = table === 'entry_vectors'
230
+ ? this.#db.prepare('SELECT sequence AS key, vec FROM entry_vectors WHERE model = ? AND sequence < ?').all(this.#embedder.model, beforeSequence)
231
+ : this.#db.prepare('SELECT v.id AS key, v.vec FROM memory_vectors v JOIN memories m ON m.id = v.id WHERE v.model = ? AND m.through_sequence < ?').all(this.#embedder.model, beforeSequence);
232
+ // Embedding models differ in how similar "unrelated" looks, so the cut is
233
+ // relative to the best match as well as absolute: close seconds stay,
234
+ // the long tail goes.
235
+ const scored = rows.map((row) => [row.key, cosine(queryVector, fromBlob(row.vec))]);
236
+ const best = Math.max(0, ...scored.map(([, score]) => score));
237
+ const cut = Math.max(floor, best - 0.12);
238
+ return new Map(scored.filter(([, score]) => score >= cut));
239
+ }
240
+
241
+ // Lexical scores (rarity weighted, 0..1 after normalisation) and semantic
242
+ // scores (cosine) fused: an item found by both wins, one found only by
243
+ // meaning still surfaces.
244
+ static fuse(lexical, semantic, { lexicalWeight = 0.55 } = {}) {
245
+ const max = Math.max(0, ...lexical.values()) || 1;
246
+ const fused = new Map();
247
+ for (const [key, score] of lexical) fused.set(key, lexicalWeight * (score / max));
248
+ for (const [key, score] of semantic) fused.set(key, (fused.get(key) ?? 0) + (1 - lexicalWeight) * score);
249
+ return [...fused.entries()].sort((a, b) => b[1] - a[1] || b[0] - a[0]);
250
+ }
251
+
252
+ // Odds and ends other modules keep here, away from files a human might delete.
253
+ metaGet(key) { return this.#metaValue(`x_${key}`); }
254
+ metaSet(key, value) { this.#setMeta.run(`x_${key}`, String(value)); }
255
+
256
+ /* ---------- distilled memories ---------- */
257
+
258
+ lastDistilled() { return this.#db.prepare('SELECT COALESCE(MAX(sequence), 0) AS n FROM entries WHERE distilled = 1').get().n; }
259
+ // Marks entries as distilled: a list of sequences, or everything up to a sequence.
260
+ markDistilled(sequences) {
261
+ if (Array.isArray(sequences)) {
262
+ const mark = this.#db.prepare('UPDATE entries SET distilled = 1 WHERE sequence = ?');
263
+ this.#db.exec('BEGIN');
264
+ try { for (const sequence of sequences) mark.run(sequence); this.#db.exec('COMMIT'); } catch (error) { this.#db.exec('ROLLBACK'); throw error; }
265
+ } else {
266
+ this.#db.prepare('UPDATE entries SET distilled = 1 WHERE sequence <= ?').run(Number(sequences) || 0);
267
+ }
268
+ }
269
+ undistilledCount() { return this.#db.prepare('SELECT COUNT(*) AS n FROM entries WHERE distilled = 0').get().n; }
270
+ memoryCount() { return this.#db.prepare('SELECT COUNT(*) AS n FROM memories').get().n; }
271
+
272
+ // The next batch to distil: the NEWEST entries nobody has distilled, cut at a
273
+ // character budget so one run stays cheap, returned in ledger order. What was
274
+ // just said becomes memory first; an old backlog drains behind it, batch by
275
+ // batch. `remaining` says how many entries still wait.
276
+ undistilled({ maxChars = 6000 } = {}) {
277
+ const rows = this.#db.prepare('SELECT sequence, timestamp, role, sender, target, message_id AS messageId, text FROM entries WHERE distilled = 0 ORDER BY sequence DESC').all();
278
+ const batch = [];
279
+ let used = 0;
280
+ for (const row of rows) {
281
+ const cost = Math.min(row.text.length, 1600) + 40;
282
+ if (batch.length && used + cost > maxChars) break;
283
+ batch.push({ ...row, text: row.text.length > 1600 ? `${row.text.slice(0, 1600)}…` : row.text });
284
+ used += cost;
285
+ }
286
+ batch.sort((a, b) => a.sequence - b.sequence);
287
+ return { entries: batch, sequences: batch.map((row) => row.sequence), fromSequence: batch[0]?.sequence ?? null, throughSequence: batch.at(-1)?.sequence ?? null, remaining: rows.length - batch.length };
288
+ }
289
+
290
+ // Stores distilled memories; a note already held (same text, ignoring case
291
+ // and punctuation) is not stored twice. Returns how many were new.
292
+ addMemories(list, { agent, fromSequence, throughSequence, origin = 'distilled', messageId = null }) {
293
+ const insert = this.#db.prepare('INSERT OR IGNORE INTO memories (created, kind, text, norm, from_sequence, through_sequence, sources, agent, origin, message_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
294
+ const now = new Date().toISOString();
295
+ let added = 0;
296
+ this.#db.exec('BEGIN');
297
+ try {
298
+ for (const memory of list) {
299
+ const text = String(memory.text ?? '').trim();
300
+ if (!text) continue;
301
+ const kind = MEMORY_KINDS.includes(memory.kind) ? memory.kind : 'fact';
302
+ const sources = (Array.isArray(memory.sources) ? memory.sources : []).filter((n) => Number.isInteger(n));
303
+ const result = insert.run(now, kind, text, normalizeMemory(text), fromSequence ?? sources[0] ?? 0, throughSequence ?? sources.at(-1) ?? 0, JSON.stringify(sources), agent, origin, messageId);
304
+ added += Number(result.changes ?? 0);
305
+ }
306
+ this.#db.exec('COMMIT');
307
+ } catch (error) {
308
+ this.#db.exec('ROLLBACK');
309
+ throw error;
310
+ }
311
+ return added;
312
+ }
313
+
314
+ // Exact text of a stretch of the ledger, capped so a tool answer stays readable.
315
+ range({ from = 1, through = Number.MAX_SAFE_INTEGER, limit = 40, maxChars = 12000 } = {}) {
316
+ const rows = this.#db.prepare('SELECT sequence, timestamp, type, role, sender, target, message_id AS messageId, text FROM entries WHERE sequence >= ? AND sequence <= ? ORDER BY sequence LIMIT ?').all(from, through, limit + 1);
317
+ const out = [];
318
+ let used = 0;
319
+ for (const row of rows.slice(0, limit)) {
320
+ const text = row.text.length > maxChars - used ? `${row.text.slice(0, Math.max(0, maxChars - used - 1))}…` : row.text;
321
+ out.push({ ...row, text });
322
+ used += text.length;
323
+ if (used >= maxChars) break;
324
+ }
325
+ return { entries: out, truncated: rows.length > limit || used >= maxChars };
326
+ }
327
+
328
+ // The latest entries, newest first, one line each.
329
+ timeline({ since = 0, limit = 30 } = {}) {
330
+ return this.#db.prepare('SELECT sequence, timestamp, role, sender, target, substr(text, 1, 200) AS text FROM entries WHERE sequence > ? ORDER BY sequence DESC LIMIT ?').all(since, limit);
331
+ }
332
+
333
+ // Forgetting is the one edit a human makes to the archive: the note and its vector go.
334
+ deleteMemory(id) {
335
+ const row = this.#db.prepare('SELECT id, kind, text, from_sequence AS fromSequence, through_sequence AS throughSequence, agent FROM memories WHERE id = ?').get(id);
336
+ if (!row) return null;
337
+ this.#db.exec('BEGIN');
338
+ try {
339
+ this.#db.prepare('DELETE FROM memory_vectors WHERE id = ?').run(id);
340
+ this.#db.prepare('DELETE FROM memories WHERE id = ?').run(id);
341
+ this.#db.exec('COMMIT');
342
+ } catch (error) { this.#db.exec('ROLLBACK'); throw error; }
343
+ return row;
344
+ }
345
+
346
+ // Which notes are about the same thing: pairs whose vectors agree, strongest
347
+ // first, a few per note. Empty without an embedder or vectors.
348
+ memoryLinks({ floor = 0.6, maxPerNode = 4 } = {}) {
349
+ if (!this.#embedder) return [];
350
+ const rows = this.#db.prepare('SELECT id, vec FROM memory_vectors WHERE model = ?').all(this.#embedder.model).map((row) => ({ id: row.id, vec: fromBlob(row.vec) }));
351
+ const links = [];
352
+ for (let i = 0; i < rows.length; i += 1) {
353
+ for (let j = i + 1; j < rows.length; j += 1) {
354
+ const weight = cosine(rows[i].vec, rows[j].vec);
355
+ if (weight >= floor) links.push({ a: rows[i].id, b: rows[j].id, weight: Number(weight.toFixed(3)) });
356
+ }
357
+ }
358
+ links.sort((x, y) => y.weight - x.weight);
359
+ const degree = new Map();
360
+ return links.filter((link) => {
361
+ const da = degree.get(link.a) ?? 0;
362
+ const db = degree.get(link.b) ?? 0;
363
+ if (da >= maxPerNode || db >= maxPerNode) return false;
364
+ degree.set(link.a, da + 1);
365
+ degree.set(link.b, db + 1);
366
+ return true;
367
+ });
368
+ }
369
+
370
+ maxMemoryId() { return this.#db.prepare('SELECT COALESCE(MAX(id), 0) AS n FROM memories').get().n; }
371
+
372
+ // Notes an agent wrote after a point: what a turn saved through memory_note.
373
+ notesSince(id, { agent = null } = {}) {
374
+ const rows = agent
375
+ ? this.#db.prepare('SELECT id, created, kind, text, from_sequence AS fromSequence, through_sequence AS throughSequence, sources, agent, origin, message_id AS messageId FROM memories WHERE id > ? AND agent = ? ORDER BY id').all(id, agent)
376
+ : this.#db.prepare('SELECT id, created, kind, text, from_sequence AS fromSequence, through_sequence AS throughSequence, sources, agent, origin, message_id AS messageId FROM memories WHERE id > ? ORDER BY id').all(id);
377
+ return rows.map((row) => ({ ...row, sources: JSON.parse(row.sources) }));
378
+ }
379
+
380
+ memories({ limit = 50, kind = null } = {}) {
381
+ if (kind) {
382
+ return this.#db.prepare('SELECT id, created, kind, text, from_sequence AS fromSequence, through_sequence AS throughSequence, sources, agent, origin, message_id AS messageId FROM memories WHERE kind = ? ORDER BY id DESC LIMIT ?').all(kind, limit)
383
+ .map((row) => ({ ...row, sources: JSON.parse(row.sources) }));
384
+ }
385
+ return this.#db.prepare('SELECT id, created, kind, text, from_sequence AS fromSequence, through_sequence AS throughSequence, sources, agent, origin, message_id AS messageId FROM memories ORDER BY id DESC LIMIT ?').all(limit)
386
+ .map((row) => ({ ...row, sources: JSON.parse(row.sources) }));
387
+ }
388
+
389
+ // Distilled memories for a request: the ones matching its terms (rarity
390
+ // weighted, like entries) and, when few match, the most recent decisions and
391
+ // preferences, all from before `beforeSequence` so they add to the window
392
+ // rather than repeat it, within a character budget.
393
+ recallMemories(text, { beforeSequence = Number.MAX_SAFE_INTEGER, limit = 6, maxChars = 1200, queryVector = null, semanticFloor = 0.45 } = {}) {
394
+ if (!this.#db) return [];
395
+ const total = this.memoryCount();
396
+ if (!total) return [];
397
+ const terms = queryTerms(text);
398
+ const semantic = this.#semanticScores('memory_vectors', 'id', queryVector, { beforeSequence, floor: semanticFloor });
399
+ const scores = new Map();
400
+ const lookup = this.#db.prepare('SELECT m.id FROM memories_fts JOIN memories m ON m.id = memories_fts.rowid WHERE memories_fts MATCH ? AND m.through_sequence < ? LIMIT 500');
401
+ for (const term of terms) {
402
+ let rows;
403
+ try { rows = lookup.all(`"${term.replaceAll('"', '""')}"`, beforeSequence); } catch { continue; }
404
+ if (!rows.length || (terms.length > 1 && rows.length > Math.max(8, total * 0.35))) continue;
405
+ const weight = Math.log(1 + total / rows.length) * (1 + Math.min(term.length, 12) / 12);
406
+ for (const { id } of rows) scores.set(id, (scores.get(id) ?? 0) + weight);
407
+ }
408
+ const ids = RoomMemory.fuse(scores, semantic).map(([id]) => id);
409
+ if (ids.length < 2) {
410
+ const recent = this.#db.prepare("SELECT id FROM memories WHERE through_sequence < ? AND kind IN ('decision', 'preference') ORDER BY id DESC LIMIT ?").all(beforeSequence, limit);
411
+ for (const { id } of recent) if (!ids.includes(id)) ids.push(id);
412
+ }
413
+ const fetch = this.#db.prepare('SELECT id, created, kind, text, from_sequence AS fromSequence, through_sequence AS throughSequence, sources, agent, origin, message_id AS messageId FROM memories WHERE id = ?');
414
+ const chosen = [];
415
+ let remaining = Math.max(0, maxChars);
416
+ for (const id of ids) {
417
+ if (chosen.length >= limit) break;
418
+ const row = fetch.get(id);
419
+ if (!row) continue;
420
+ const cost = row.text.length + 24;
421
+ if (cost > remaining) continue;
422
+ remaining -= cost;
423
+ chosen.push({ ...row, sources: JSON.parse(row.sources) });
424
+ }
425
+ return chosen.sort((a, b) => a.fromSequence - b.fromSequence || a.id - b.id);
426
+ }
427
+
428
+ #metaValue(key) { return this.#meta.get(key)?.value ?? null; }
429
+
430
+ // The last ledger sequence this index has seen, indexed or not.
431
+ lastSequence() { return Number(this.#metaValue('last_sequence') ?? 0); }
432
+ count() { return this.#db.prepare('SELECT COUNT(*) AS n FROM entries').get().n; }
433
+
434
+ // Indexes the events that carry text (messages, failures, command cards).
435
+ // Ghost events and anything without a sequence are skipped by construction;
436
+ // an event already indexed is ignored, so two servers on one room are safe.
437
+ index(events) {
438
+ let last = this.lastSequence();
439
+ let added = 0;
440
+ this.#db.exec('BEGIN');
441
+ try {
442
+ for (const event of events) {
443
+ if (!event || event.ghost || !Number.isInteger(event.sequence)) continue;
444
+ if (event.sequence > last) last = event.sequence;
445
+ const entry = messageEntry(event);
446
+ if (!entry) continue;
447
+ const result = this.#insert.run(event.sequence, event.id ?? `seq-${event.sequence}`, event.timestamp ?? null, event.type, entry.role, entry.sender, entry.target ?? null, entry.messageId ?? null, entry.text);
448
+ added += Number(result.changes ?? 0);
449
+ }
450
+ this.#setMeta.run('last_sequence', String(last));
451
+ this.#db.exec('COMMIT');
452
+ } catch (error) {
453
+ this.#db.exec('ROLLBACK');
454
+ throw error;
455
+ }
456
+ return added;
457
+ }
458
+
459
+ // Indexes whatever the ledger holds beyond the last sequence seen.
460
+ async catchUp(store) {
461
+ const last = this.lastSequence();
462
+ const events = await store.readAll();
463
+ return this.index(events.filter((event) => event.sequence > last));
464
+ }
465
+
466
+ // Older exchanges matched to a request. Each term is looked up on its own
467
+ // and weighted by how rare it is in this room (a word in a third of the
468
+ // entries says nothing; a path or a name says a lot), so an entry matching
469
+ // two rare terms beats one matching a common word many times. Only before
470
+ // `beforeSequence` (the recent window the agent gets verbatim anyway), never
471
+ // the request itself, within a character budget, oldest first so they read
472
+ // as a timeline.
473
+ recall(text, { beforeSequence = Number.MAX_SAFE_INTEGER, excludeMessageId = null, limit = 6, maxChars = 4000, excerptChars = 360, queryVector = null, semanticFloor = 0.45 } = {}) {
474
+ const terms = queryTerms(text);
475
+ if (!this.#db) return { terms, entries: [], omitted: 0 };
476
+ const total = this.count();
477
+ if (!total) return { terms, entries: [], omitted: 0 };
478
+ const semantic = this.#semanticScores('entry_vectors', 'sequence', queryVector, { beforeSequence, floor: semanticFloor });
479
+ if (!terms.length && !semantic.size) return { terms, entries: [], omitted: 0 };
480
+ const lookup = this.#db.prepare('SELECT rowid FROM entries_fts WHERE entries_fts MATCH ? AND rowid < ? LIMIT 2000');
481
+ const scores = new Map();
482
+ const matchedTerms = [];
483
+ for (const term of terms) {
484
+ let rows;
485
+ try { rows = lookup.all(`"${term.replaceAll('"', '""')}"`, beforeSequence); } catch { continue; }
486
+ if (!rows.length) continue;
487
+ // A term present in over a third of a room of any size carries no signal when others exist.
488
+ if (terms.length > 1 && rows.length > Math.max(8, total * 0.35)) continue;
489
+ matchedTerms.push(term);
490
+ const weight = Math.log(1 + total / rows.length) * (1 + Math.min(term.length, 12) / 12);
491
+ for (const { rowid } of rows) scores.set(rowid, (scores.get(rowid) ?? 0) + weight);
492
+ }
493
+ if (!scores.size && !semantic.size) return { terms, entries: [], omitted: 0 };
494
+ const ranked = RoomMemory.fuse(scores, semantic).slice(0, limit * 4);
495
+ const fetch = this.#db.prepare('SELECT sequence, timestamp, type, role, sender, target, message_id AS messageId, text FROM entries WHERE sequence = ?');
496
+ const chosen = [];
497
+ let remaining = Math.max(0, maxChars);
498
+ let seen = 0;
499
+ for (const [sequence, score] of ranked) {
500
+ const row = fetch.get(sequence);
501
+ if (!row || (excludeMessageId && row.messageId === excludeMessageId)) continue;
502
+ seen += 1;
503
+ if (chosen.length >= limit) break;
504
+ const text2 = excerpt(row.text, matchedTerms.length ? matchedTerms : terms, { maxChars: excerptChars });
505
+ const cost = text2.length + 48;
506
+ if (cost > remaining) continue;
507
+ remaining -= cost;
508
+ chosen.push({ sequence: row.sequence, timestamp: row.timestamp, type: row.type, role: row.role, sender: row.sender, target: row.target, messageId: row.messageId, excerpt: text2, score, semantic: semantic.get(row.sequence) ?? 0 });
509
+ }
510
+ chosen.sort((a, b) => a.sequence - b.sequence);
511
+ return { terms: matchedTerms, entries: chosen, omitted: Math.max(0, seen - chosen.length), semantic: semantic.size > 0 };
512
+ }
513
+
514
+ close() {
515
+ this.#db?.close();
516
+ this.#db = null;
517
+ }
518
+ }
519
+
520
+ export function normalizeMemory(text) {
521
+ return String(text).toLowerCase().normalize('NFKD').replace(/[\u0300-\u036f]/g, '').replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
522
+ }
523
+
524
+ // The block an agent reads for distilled memories: kind, where it came from, the note.
525
+ export function formatMemories(list) {
526
+ if (!list?.length) return '';
527
+ return list.map((memory) => {
528
+ const span = memory.fromSequence === memory.throughSequence ? `#${memory.fromSequence}` : `#${memory.fromSequence}–#${memory.throughSequence}`;
529
+ return `- [${memory.kind} · ${span}] ${memory.text}`;
530
+ }).join('\n');
531
+ }
532
+
533
+ // The block an agent reads: one exact quote per line group, stamped with the
534
+ // ledger sequence so anyone can go back to the original.
535
+ export function formatRecall(recall) {
536
+ if (!recall?.entries?.length) return '';
537
+ return recall.entries.map((entry) => {
538
+ const when = entry.timestamp ? entry.timestamp.slice(0, 16).replace('T', ' ') : '';
539
+ const who = entry.role === 'command' ? entry.sender : `@${entry.sender}`;
540
+ const to = entry.target && entry.target !== 'room' ? ` → ${entry.target === 'you' ? 'human' : `@${entry.target}`}` : '';
541
+ return `[#${entry.sequence}${when ? ` · ${when}` : ''} · ${who} (${entry.role})${to}] ${entry.excerpt}`;
542
+ }).join('\n\n');
543
+ }
package/src/models.mjs ADDED
@@ -0,0 +1,78 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+
5
+ // Which model a CLI should use for one turn. Every CLI accepts --model; the
6
+ // names come from what is installed locally (Codex's model cache, OpenCode's
7
+ // list) plus the aliases each vendor documents. Users can add their own in
8
+ // ~/.pulse/config.json under "models".
9
+
10
+ export const KNOWN_MODELS = {
11
+ codex: {
12
+ defaults: ['gpt-5.6-sol', 'gpt-5.6-luna', 'gpt-5.6-terra', 'gpt-5.5'],
13
+ note: 'Names from ~/.codex/models_cache.json; the CLI default comes from ~/.codex/config.toml.',
14
+ },
15
+ claude: {
16
+ defaults: ['fable', 'opus', 'sonnet', 'haiku'],
17
+ note: 'Aliases resolve to the latest model of each family (e.g. fable → claude-fable-5-1). Full names also work.',
18
+ },
19
+ gemini: {
20
+ defaults: ['auto', 'gemini-3-pro-preview', 'gemini-3-flash-preview', 'gemini-2.5-pro', 'gemini-2.5-flash'],
21
+ note: '"auto" lets Gemini CLI route between pro and flash.',
22
+ },
23
+ opencode: {
24
+ defaults: [],
25
+ note: 'provider/model, from `opencode models`. The room default is PULSE_OPENCODE_MODEL.',
26
+ },
27
+ };
28
+
29
+ export function parseCodexModelCache(json) {
30
+ try {
31
+ const data = JSON.parse(json);
32
+ const slugs = new Set();
33
+ const walk = (node) => {
34
+ if (!node || typeof node !== 'object') return;
35
+ if (typeof node.slug === 'string' && /^gpt-/.test(node.slug) && !/reserve|auto-review/.test(node.slug)) slugs.add(node.slug);
36
+ for (const value of Object.values(node)) walk(value);
37
+ };
38
+ walk(data);
39
+ return [...slugs].sort();
40
+ } catch {
41
+ return [];
42
+ }
43
+ }
44
+
45
+ export function parseCodexDefaultModel(toml) {
46
+ return String(toml ?? '').match(/^\s*model\s*=\s*"([^"]+)"/m)?.[1] ?? null;
47
+ }
48
+
49
+ export function isValidModelName(name) {
50
+ return typeof name === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,120}$/.test(name);
51
+ }
52
+
53
+ const unique = (list) => [...new Set(list.filter(isValidModelName))];
54
+
55
+ export async function discoverModels({ agents = [], config = {}, home = homedir(), listOpenCode = async () => [] } = {}) {
56
+ const result = {};
57
+ for (const agent of agents) {
58
+ const known = KNOWN_MODELS[agent.id] ?? { defaults: [], note: '' };
59
+ let discovered = [];
60
+ let cliDefault = null;
61
+ if (agent.id === 'codex') {
62
+ discovered = parseCodexModelCache(await readFile(join(home, '.codex', 'models_cache.json'), 'utf8').catch(() => ''));
63
+ cliDefault = parseCodexDefaultModel(await readFile(join(home, '.codex', 'config.toml'), 'utf8').catch(() => ''));
64
+ }
65
+ if (agent.id === 'opencode' && agent.detected) {
66
+ discovered = await listOpenCode(agent).catch(() => []);
67
+ cliDefault = process.env.PULSE_OPENCODE_MODEL ?? null;
68
+ }
69
+ const custom = Array.isArray(config.models?.[agent.id]) ? config.models[agent.id] : [];
70
+ result[agent.id] = {
71
+ models: unique([...custom, ...discovered, ...known.defaults]),
72
+ default: cliDefault,
73
+ note: known.note,
74
+ source: discovered.length ? 'discovered' : 'known',
75
+ };
76
+ }
77
+ return result;
78
+ }