@jossuealcala/madre 0.5.2 → 0.7.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 +265 -1
  2. package/README.md +33 -1
  3. package/docs/SDK.md +173 -6
  4. package/docs/sdk/connector-module.mjs +186 -0
  5. package/package.json +6 -2
  6. package/public/abduction.js +384 -0
  7. package/public/app.js +3450 -540
  8. package/public/assets/abduction-cat-blink.png +0 -0
  9. package/public/assets/abduction-cat-control-left.png +0 -0
  10. package/public/assets/abduction-cat-control-right.png +0 -0
  11. package/public/assets/abduction-cat.png +0 -0
  12. package/public/es.js +354 -54
  13. package/public/index.html +65 -19
  14. package/public/routing.js +74 -0
  15. package/public/styles.css +788 -77
  16. package/src/adapters/claude.mjs +6 -2
  17. package/src/adapters/gemini.mjs +26 -5
  18. package/src/adapters/madre.mjs +5 -0
  19. package/src/choices.mjs +64 -0
  20. package/src/dataset.mjs +11 -6
  21. package/src/distiller.mjs +12 -1
  22. package/src/file-ops.mjs +45 -0
  23. package/src/lease.mjs +37 -2
  24. package/src/mcp/smtp-server.mjs +189 -0
  25. package/src/memory-dashboard.mjs +69 -0
  26. package/src/memory.mjs +222 -18
  27. package/src/modules/ahp.mjs +1 -1
  28. package/src/modules/ash-policy.mjs +67 -0
  29. package/src/modules/ash.mjs +1 -1
  30. package/src/modules/choices.mjs +34 -0
  31. package/src/modules/correo.mjs +107 -0
  32. package/src/modules/index.mjs +48 -4
  33. package/src/modules/playwright.mjs +5 -1
  34. package/src/modules/ripley.mjs +13 -1
  35. package/src/modules/sdk.mjs +44 -9
  36. package/src/outbound.mjs +37 -3
  37. package/src/ports.mjs +91 -0
  38. package/src/request-guard.mjs +26 -0
  39. package/src/room/archivist.mjs +2 -1
  40. package/src/room/economy.mjs +83 -1
  41. package/src/room/prompt.mjs +14 -2
  42. package/src/room.mjs +150 -18
  43. package/src/sentinel-errors.mjs +5 -1
  44. package/src/server.mjs +188 -8
  45. package/src/smtp.mjs +122 -0
  46. package/src/vault.mjs +100 -0
  47. package/src/whats-new.mjs +140 -0
package/src/memory.mjs CHANGED
@@ -38,6 +38,67 @@ export const MEMORY_KINDS = ['decision', 'fact', 'preference', 'question', ABERR
38
38
  // What the room will hand an agent: knowledge that still stands. Everything else is archive.
39
39
  export const STANDING_KINDS = MEMORY_KINDS.filter((kind) => kind !== ABERRATION);
40
40
 
41
+ // Where a memory stands aboard, which is a different question from what it is. The five kinds say
42
+ // WHAT a note is; the four zones say what standing it has, and standing is what the recall obeys.
43
+ // The two are orthogonal: a decision can be in any zone. Ids travel in the database and never
44
+ // move; the ship's words for them are said on screen only.
45
+ //
46
+ // HOLD just distilled, nobody has confirmed it · recalled
47
+ // BRIDGE the human said it worked · recalled
48
+ // MEDBAY quarantined: it is false, or it failed · never recalled
49
+ // JETTISONED put out of circulation, kept so it is not relearned · never recalled
50
+ //
51
+ // Not `airlock`: in this room that word already means permission mode #4, and one word means one
52
+ // thing. The Spanish word on screen is still ESCLUSA.
53
+ export const HOLD = 'hold';
54
+ export const BRIDGE = 'bridge';
55
+ export const MEDBAY = 'medbay';
56
+ export const JETTISONED = 'jettisoned';
57
+ export const MEMORY_ZONES = [HOLD, BRIDGE, MEDBAY, JETTISONED];
58
+ // The zones a note can travel into a turn from. Everything else is archive the room keeps and
59
+ // never hands over.
60
+ export const RECALLED_ZONES = [HOLD, BRIDGE];
61
+
62
+ // What the human can say about a reply. Four verdicts and the clearing — one act, one field: the
63
+ // human judges a reply once, and two fields for one act would be a lie about what they did.
64
+ //
65
+ // good it worked · the note it left stands confirmed
66
+ // preference this is how I want it done · the same, and it is worth training toward
67
+ // bad it failed · the note is quarantined and stops travelling
68
+ // never do not do this again · the note is put out, and the pair trains against it
69
+ // none no judgement · back to where the rule alone would put it
70
+ //
71
+ // Ordered worst first, and read in that order: a note is distilled from several replies and takes
72
+ // the WORST verdict among them. If one exchange it came from was wrong the note is suspect however
73
+ // good the rest were, and quarantine is the cheap mistake.
74
+ export const VERDICTS = ['never', 'bad', 'preference', 'good', 'none'];
75
+ export const NEGATIVE_VERDICTS = ['never', 'bad'];
76
+ export const POSITIVE_VERDICTS = ['preference', 'good'];
77
+
78
+ // The gate, as one line of SQL instead of five copies of two rules. Everything the room may hand
79
+ // a turn stands in one of these zones; everything else is archive it keeps and never serves.
80
+ const ZONE_GATE = `zone IN (${RECALLED_ZONES.map((zone) => `'${zone}'`).join(', ')})`;
81
+ // Same question asked of a row already in hand.
82
+ export const travels = (row) => RECALLED_ZONES.includes(row?.zone ?? zoneFor(row));
83
+
84
+ // Where today's rules put a note, with no reaction involved: this is the whole of what the two
85
+ // scattered gates in the recall mean, written once. Keeping it as a function is what lets the
86
+ // next step prove the column says exactly what those gates said, and not something close to it.
87
+ export function zoneFor(row, verdict = null) {
88
+ if (!row) return null;
89
+ // The archive's own judgement outranks the human's thumb, and on purpose: «this is false» and
90
+ // «that reply helped me» are different claims, and a thumb up must never bring back a note the
91
+ // room already established was wrong.
92
+ if (row.kind === ABERRATION) return MEDBAY;
93
+ if ((row.refutedBy ?? row.refuted_by ?? null) !== null) return JETTISONED;
94
+ // A note distilled from several replies takes the worst verdict among them: if one of the
95
+ // exchanges it came from was wrong, the note is suspect, and quarantine is the cheap mistake.
96
+ if (verdict === 'never') return JETTISONED;
97
+ if (verdict === 'bad') return MEDBAY;
98
+ if (verdict === 'preference' || verdict === 'good') return BRIDGE;
99
+ return HOLD;
100
+ }
101
+
41
102
  // The dedup key. An aberration almost always quotes the claim it refutes word for word, so on a
42
103
  // shared key the archive would silently drop the refutation as a duplicate of the thing it is
43
104
  // refuting. Aberrations are keyed in their own space: one of each still dedupes, and a false
@@ -203,6 +264,9 @@ export class RoomMemory {
203
264
  );
204
265
  CREATE INDEX IF NOT EXISTS recalls_memory ON recalls(memory_id, id DESC);
205
266
  CREATE INDEX IF NOT EXISTS recalls_batch ON recalls(batch);
267
+ -- The human's verdicts, folded out of the ledger. Nothing is accumulated here: the ledger is
268
+ -- the truth and this is a cache of its last word per reply, rebuilt on every catch-up.
269
+ CREATE TABLE IF NOT EXISTS verdicts (message_id TEXT PRIMARY KEY, verdict TEXT NOT NULL);
206
270
  CREATE TABLE IF NOT EXISTS entry_vectors (sequence INTEGER PRIMARY KEY, model TEXT NOT NULL, vec BLOB NOT NULL);
207
271
  CREATE TABLE IF NOT EXISTS memory_vectors (id INTEGER PRIMARY KEY, model TEXT NOT NULL, vec BLOB NOT NULL);
208
272
  `);
@@ -234,6 +298,15 @@ export class RoomMemory {
234
298
  for (const column of ['contradicts INTEGER', 'correction TEXT', 'detector TEXT', 'confidence REAL', 'refuted_by INTEGER']) {
235
299
  if (!columns.includes(column.split(' ')[0])) this.#db.exec(`ALTER TABLE memories ADD COLUMN ${column}`);
236
300
  }
301
+ // Standing, as a column. Older files are seeded from the two rules the recall already applied,
302
+ // so nothing changes meaning on the way in — and the seeding runs only the once, inside the
303
+ // branch that creates the column: after this a reaction is what moves a note, and a backfill
304
+ // on every boot would quietly undo them.
305
+ if (!columns.includes('zone')) {
306
+ this.#db.exec(`ALTER TABLE memories ADD COLUMN zone TEXT NOT NULL DEFAULT '${HOLD}'`);
307
+ this.#db.exec(`UPDATE memories SET zone = '${MEDBAY}' WHERE kind = '${ABERRATION}'`);
308
+ this.#db.exec(`UPDATE memories SET zone = '${JETTISONED}' WHERE refuted_by IS NOT NULL`);
309
+ }
237
310
  }
238
311
 
239
312
  /* ---------- embeddings ---------- */
@@ -401,6 +474,28 @@ export class RoomMemory {
401
474
  }
402
475
  memoryCount() { return this.#db.prepare('SELECT COUNT(*) AS n FROM memories').get().n; }
403
476
 
477
+ // What the archive did, day by day, and what it is made of, as counts and nothing else: no
478
+ // text leaves through here, so the MEMORY dashboard can read it without the designation.
479
+ // Days are this machine's calendar days, which is what a person reading a chart means by one.
480
+ activity({ days = 30 } = {}) {
481
+ if (!this.#db) return null;
482
+ const since = `-${Math.max(1, Math.floor(days)) - 1} days`;
483
+ const perDay = (sql) => this.#db.prepare(sql).all(since).map((row) => [row.day, row.n]);
484
+ const grouped = (column) => Object.fromEntries(this.#db.prepare(`SELECT ${column} AS k, COUNT(*) AS n FROM memories GROUP BY ${column}`).all().map((row) => [row.k ?? HOLD, row.n]));
485
+ return {
486
+ entries: perDay("SELECT date(timestamp, 'localtime') AS day, COUNT(*) AS n FROM entries WHERE timestamp IS NOT NULL AND date(timestamp, 'localtime') >= date('now', 'localtime', ?) GROUP BY day"),
487
+ memories: perDay("SELECT date(created, 'localtime') AS day, COUNT(*) AS n FROM memories WHERE date(created, 'localtime') >= date('now', 'localtime', ?) GROUP BY day"),
488
+ // A turn that reached into the archive, once per turn however many notes it carried.
489
+ recalls: perDay("SELECT day, COUNT(*) AS n FROM (SELECT date(MIN(at), 'localtime') AS day FROM recalls GROUP BY batch) WHERE day >= date('now', 'localtime', ?) GROUP BY day"),
490
+ kinds: grouped('kind'),
491
+ agents: grouped('agent'),
492
+ zones: grouped('zone'),
493
+ via: Object.fromEntries(this.#db.prepare('SELECT via AS k, COUNT(*) AS n FROM recalls GROUP BY via').all().map((row) => [row.k, row.n])),
494
+ turns: this.#db.prepare('SELECT COUNT(DISTINCT batch) AS n FROM recalls').get().n,
495
+ reached: this.#db.prepare('SELECT COUNT(*) AS n FROM memories WHERE recalled > 0').get().n,
496
+ };
497
+ }
498
+
404
499
  // The next batch to distil: the NEWEST entries nobody has distilled, cut at a
405
500
  // character budget so one run stays cheap, returned in ledger order. What was
406
501
  // just said becomes memory first; an old backlog drains behind it, batch by
@@ -422,7 +517,7 @@ export class RoomMemory {
422
517
  // Stores distilled memories; a note already held (same text, ignoring case
423
518
  // and punctuation) is not stored twice. Returns how many were new.
424
519
  addMemories(list, { agent, fromSequence, throughSequence, origin = 'distilled', messageId = null }) {
425
- const insert = this.#db.prepare('INSERT OR IGNORE INTO memories (created, kind, text, norm, from_sequence, through_sequence, sources, agent, origin, message_id, correction, detector, confidence) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
520
+ const insert = this.#db.prepare('INSERT OR IGNORE INTO memories (created, kind, text, norm, from_sequence, through_sequence, sources, agent, origin, message_id, correction, detector, confidence, zone) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
426
521
  const now = new Date().toISOString();
427
522
  let added = 0;
428
523
  this.#db.exec('BEGIN');
@@ -439,14 +534,14 @@ export class RoomMemory {
439
534
  // note that refutes it is wired up later, by whoever can name the id.
440
535
  const aberrant = kind === ABERRATION;
441
536
  const correction = aberrant ? this.#guard(String(memory.correction ?? '').trim()) || null : null;
442
- const result = insert.run(now, kind, text, memoryKey(kind, text), fromSequence ?? sources[0] ?? 0, throughSequence ?? sources.at(-1) ?? 0, JSON.stringify(sources), agent, origin, messageId, correction, aberrant ? (memory.detector ?? agent) : null, aberrant ? (Number.isFinite(memory.confidence) ? memory.confidence : null) : null);
537
+ const result = insert.run(now, kind, text, memoryKey(kind, text), fromSequence ?? sources[0] ?? 0, throughSequence ?? sources.at(-1) ?? 0, JSON.stringify(sources), agent, origin, messageId, correction, aberrant ? (memory.detector ?? agent) : null, aberrant ? (Number.isFinite(memory.confidence) ? memory.confidence : null) : null, aberrant ? MEDBAY : HOLD);
443
538
  added += Number(result.changes ?? 0);
444
539
  if (aberrant && Number(result.changes ?? 0)) {
445
540
  const id = Number(result.lastInsertRowid);
446
541
  const target = this.#refutedBy(text);
447
542
  if (target) {
448
543
  this.#db.prepare('UPDATE memories SET contradicts = ? WHERE id = ?').run(target, id);
449
- this.#db.prepare('UPDATE memories SET refuted_by = ? WHERE id = ? AND refuted_by IS NULL').run(id, target);
544
+ this.#db.prepare(`UPDATE memories SET refuted_by = ?, zone = '${JETTISONED}' WHERE id = ? AND refuted_by IS NULL`).run(id, target);
450
545
  }
451
546
  }
452
547
  }
@@ -471,7 +566,7 @@ export class RoomMemory {
471
566
  const terms = new Set(normalizeMemory(text).split(' ').filter((word) => word.length > 2));
472
567
  if (terms.size < 3) return null;
473
568
  let best = null;
474
- const standing = this.#db.prepare(`SELECT id, text FROM memories WHERE kind != '${ABERRATION}' AND refuted_by IS NULL ORDER BY id DESC LIMIT 200`).all();
569
+ const standing = this.#db.prepare(`SELECT id, text FROM memories WHERE ${ZONE_GATE} ORDER BY id DESC LIMIT 200`).all();
475
570
  for (const row of standing) {
476
571
  const other = new Set(normalizeMemory(row.text).split(' ').filter((word) => word.length > 2));
477
572
  if (other.size < 3) continue;
@@ -494,11 +589,11 @@ export class RoomMemory {
494
589
  const refuted = Number.isInteger(contradicts) ? this.#db.prepare(`SELECT id, kind FROM memories WHERE id = ? AND kind != '${ABERRATION}'`).get(contradicts) : null;
495
590
  this.#db.exec('BEGIN');
496
591
  try {
497
- const result = this.#db.prepare('INSERT OR IGNORE INTO memories (created, kind, text, norm, from_sequence, through_sequence, sources, agent, origin, contradicts, correction, detector, confidence) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')
498
- .run(new Date().toISOString(), ABERRATION, claim, memoryKey(ABERRATION, claim), fromSequence ?? cites[0] ?? 0, throughSequence ?? cites.at(-1) ?? 0, JSON.stringify(cites), agent, 'flagged', refuted?.id ?? null, this.#guard(String(correction ?? '').trim()) || null, detector, Number.isFinite(confidence) ? confidence : null);
592
+ const result = this.#db.prepare('INSERT OR IGNORE INTO memories (created, kind, text, norm, from_sequence, through_sequence, sources, agent, origin, contradicts, correction, detector, confidence, zone) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')
593
+ .run(new Date().toISOString(), ABERRATION, claim, memoryKey(ABERRATION, claim), fromSequence ?? cites[0] ?? 0, throughSequence ?? cites.at(-1) ?? 0, JSON.stringify(cites), agent, 'flagged', refuted?.id ?? null, this.#guard(String(correction ?? '').trim()) || null, detector, Number.isFinite(confidence) ? confidence : null, MEDBAY);
499
594
  if (!Number(result.changes ?? 0)) { this.#db.exec('ROLLBACK'); return null; } // already known
500
595
  const id = Number(result.lastInsertRowid);
501
- if (refuted) this.#db.prepare('UPDATE memories SET refuted_by = ? WHERE id = ? AND refuted_by IS NULL').run(id, refuted.id);
596
+ if (refuted) this.#db.prepare(`UPDATE memories SET refuted_by = ?, zone = '${JETTISONED}' WHERE id = ? AND refuted_by IS NULL`).run(id, refuted.id);
502
597
  this.#db.exec('COMMIT');
503
598
  return { id, refuted: refuted?.id ?? null };
504
599
  } catch (error) {
@@ -515,7 +610,7 @@ export class RoomMemory {
515
610
  if (!row) return null;
516
611
  this.#db.exec('BEGIN');
517
612
  try {
518
- this.#db.prepare('UPDATE memories SET refuted_by = NULL WHERE refuted_by = ?').run(row.id);
613
+ this.#db.prepare(`UPDATE memories SET refuted_by = NULL, zone = '${HOLD}' WHERE refuted_by = ?`).run(row.id);
519
614
  this.#db.prepare('DELETE FROM memories WHERE id = ?').run(row.id);
520
615
  this.#db.prepare('DELETE FROM memory_vectors WHERE id = ?').run(row.id);
521
616
  this.#db.exec('COMMIT');
@@ -550,6 +645,13 @@ export class RoomMemory {
550
645
  }
551
646
 
552
647
  // The latest entries, newest first, one line each.
648
+ // Where a reply landed in the ledger. The join a verdict travels along — reply id to sequence to
649
+ // the notes that cite it — starts here, and having it named makes it addressable from outside.
650
+ sequenceOf(messageId) {
651
+ if (!this.#db) return null;
652
+ return this.#db.prepare('SELECT sequence FROM entries WHERE message_id = ? ORDER BY sequence DESC LIMIT 1').get(String(messageId ?? ''))?.sequence ?? null;
653
+ }
654
+
553
655
  timeline({ since = 0, limit = 30 } = {}) {
554
656
  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);
555
657
  }
@@ -564,7 +666,7 @@ export class RoomMemory {
564
666
  this.#db.prepare('DELETE FROM recalls WHERE memory_id = ?').run(id);
565
667
  // A note quarantined by this one comes back: with the aberration gone there is nothing
566
668
  // holding it out of circulation, and a memory must never be lost to a pointer at nothing.
567
- this.#db.prepare('UPDATE memories SET refuted_by = NULL WHERE refuted_by = ?').run(id);
669
+ this.#db.prepare(`UPDATE memories SET refuted_by = NULL, zone = '${HOLD}' WHERE refuted_by = ?`).run(id);
568
670
  this.#db.prepare('DELETE FROM memories WHERE id = ?').run(id);
569
671
  this.#db.exec('COMMIT');
570
672
  } catch (error) { this.#db.exec('ROLLBACK'); throw error; }
@@ -605,12 +707,31 @@ export class RoomMemory {
605
707
  return rows.map((row) => ({ ...row, sources: JSON.parse(row.sources) }));
606
708
  }
607
709
 
710
+ // What the archivist is told before it reads a batch, now that a note has standing. Two lists
711
+ // and not one, because they say opposite things: what the room already holds (so it is not
712
+ // written twice) and what the room THREW OUT (so it is not learned again). Until this, the
713
+ // archivist got the newest dozen notes whatever their standing — including things the room had
714
+ // established were false — under a heading that only said «do not repeat».
715
+ //
716
+ // The standing list leads with the BRIDGE: what the human confirmed is the part worth not
717
+ // contradicting. The discarded list is everything out of circulation, newest first, and it is
718
+ // left out entirely when there is nothing to say — a heading with no rows under it would be
719
+ // prompt the room pays for and the model learns nothing from.
720
+ briefing({ standing = 12, discarded = 6 } = {}) {
721
+ if (!this.#db) return { standing: [], discarded: [] };
722
+ const pick = (where, order, limit) => this.#db.prepare(`SELECT id, kind, text, zone FROM memories WHERE ${where} ORDER BY ${order} LIMIT ?`).all(limit);
723
+ return {
724
+ standing: pick(ZONE_GATE, `zone = '${BRIDGE}' DESC, id DESC`, standing),
725
+ discarded: pick(`NOT (${ZONE_GATE})`, 'id DESC', discarded),
726
+ };
727
+ }
728
+
608
729
  memories({ limit = 50, kind = null } = {}) {
609
730
  if (kind) {
610
- return this.#db.prepare('SELECT id, created, kind, text, from_sequence AS fromSequence, through_sequence AS throughSequence, sources, agent, origin, message_id AS messageId, recalled, last_recalled AS lastRecalled, contradicts, correction, detector, confidence, refuted_by AS refutedBy FROM memories WHERE kind = ? ORDER BY id DESC LIMIT ?').all(kind, limit)
731
+ return this.#db.prepare('SELECT id, created, kind, text, from_sequence AS fromSequence, through_sequence AS throughSequence, sources, agent, origin, message_id AS messageId, recalled, last_recalled AS lastRecalled, contradicts, correction, detector, confidence, refuted_by AS refutedBy, zone FROM memories WHERE kind = ? ORDER BY id DESC LIMIT ?').all(kind, limit)
611
732
  .map((row) => ({ ...row, sources: JSON.parse(row.sources) }));
612
733
  }
613
- return this.#db.prepare('SELECT id, created, kind, text, from_sequence AS fromSequence, through_sequence AS throughSequence, sources, agent, origin, message_id AS messageId, recalled, last_recalled AS lastRecalled, contradicts, correction, detector, confidence, refuted_by AS refutedBy FROM memories ORDER BY id DESC LIMIT ?').all(limit)
734
+ return this.#db.prepare('SELECT id, created, kind, text, from_sequence AS fromSequence, through_sequence AS throughSequence, sources, agent, origin, message_id AS messageId, recalled, last_recalled AS lastRecalled, contradicts, correction, detector, confidence, refuted_by AS refutedBy, zone FROM memories ORDER BY id DESC LIMIT ?').all(limit)
614
735
  .map((row) => ({ ...row, sources: JSON.parse(row.sources) }));
615
736
  }
616
737
 
@@ -628,7 +749,7 @@ export class RoomMemory {
628
749
  const scores = new Map();
629
750
  // Nothing that is false and nothing that has been refuted travels into a turn. This is the
630
751
  // gate: an archive that hands its own hallucinations back to the room repeats them.
631
- 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 < ? AND m.kind != '${ABERRATION}' AND m.refuted_by IS NULL LIMIT 500`);
752
+ 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 < ? AND m.${ZONE_GATE} LIMIT 500`);
632
753
  for (const term of terms) {
633
754
  let rows;
634
755
  try { rows = lookup.all(`"${term.replaceAll('"', '""')}"`, beforeSequence); } catch { continue; }
@@ -638,10 +759,10 @@ export class RoomMemory {
638
759
  }
639
760
  const ids = RoomMemory.fuse(scores, semantic).map(([id]) => id);
640
761
  if (fallback && ids.length < 2) {
641
- const recent = this.#db.prepare("SELECT id FROM memories WHERE through_sequence < ? AND kind IN ('decision', 'preference') AND refuted_by IS NULL ORDER BY id DESC LIMIT ?").all(beforeSequence, limit);
762
+ const recent = this.#db.prepare(`SELECT id FROM memories WHERE through_sequence < ? AND kind IN ('decision', 'preference') AND ${ZONE_GATE} ORDER BY id DESC LIMIT ?`).all(beforeSequence, limit);
642
763
  for (const { id } of recent) if (!ids.includes(id)) ids.push(id);
643
764
  }
644
- 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, contradicts, correction, detector, confidence, refuted_by AS refutedBy FROM memories WHERE id = ?');
765
+ 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, contradicts, correction, detector, confidence, refuted_by AS refutedBy, zone FROM memories WHERE id = ?');
645
766
  const chosen = [];
646
767
  let remaining = Math.max(0, maxChars);
647
768
  let cursor = 0;
@@ -660,7 +781,7 @@ export class RoomMemory {
660
781
  cursor += 1;
661
782
  // The semantic side of the search does not go through the gate above, so it is checked
662
783
  // here as well: one path in means one path to keep clean, and there are two.
663
- if (!row || row.kind === ABERRATION || row.refutedBy !== null) continue;
784
+ if (!row || !travels(row)) continue;
664
785
  take(row, 'search');
665
786
  }
666
787
  };
@@ -674,7 +795,7 @@ export class RoomMemory {
674
795
  for (const mate of this.associates(seeds, { limit: reserve })) {
675
796
  if (chosen.length >= limit) break;
676
797
  const row = fetch.get(mate.id);
677
- if (!row || row.kind === ABERRATION || row.refutedBy !== null) continue;
798
+ if (!row || !travels(row)) continue;
678
799
  if (row.throughSequence >= beforeSequence) continue; // it is already in the window
679
800
  take(row, 'cascade');
680
801
  }
@@ -824,11 +945,94 @@ export class RoomMemory {
824
945
  return added;
825
946
  }
826
947
 
827
- // Indexes whatever the ledger holds beyond the last sequence seen.
948
+ /* ---------- verdicts: the human's reaction, and where it leaves a note standing ---------- */
949
+
950
+ // The worst verdict among the replies a note was distilled from. Worst, not newest: a note that
951
+ // came partly from an exchange the human marked wrong is suspect however good the other half was.
952
+ #verdictFor(id) {
953
+ const rows = this.#db.prepare(`
954
+ SELECT v.verdict FROM memories m, json_each(m.sources) src
955
+ JOIN entries e ON e.sequence = src.value
956
+ JOIN verdicts v ON v.message_id = e.message_id
957
+ WHERE m.id = ?`).all(Number(id)).map((row) => row.verdict);
958
+ return VERDICTS.find((verdict) => verdict !== 'none' && rows.includes(verdict)) ?? null;
959
+ }
960
+
961
+ // Recomputes standing for a handful of notes. The zone is a function of the rule plus the fold,
962
+ // so recomputing is always safe and never drifts — which is the whole reason a reaction can
963
+ // arrive days after the batch was distilled.
964
+ #restand(ids) {
965
+ const read = this.#db.prepare('SELECT id, kind, refuted_by AS refutedBy, zone FROM memories WHERE id = ?');
966
+ const write = this.#db.prepare('UPDATE memories SET zone = ? WHERE id = ?');
967
+ let moved = 0;
968
+ for (const id of ids) {
969
+ const row = read.get(Number(id));
970
+ if (!row) continue;
971
+ const zone = zoneFor(row, this.#verdictFor(row.id));
972
+ if (zone === row.zone) continue;
973
+ write.run(zone, row.id);
974
+ moved += 1;
975
+ }
976
+ return moved;
977
+ }
978
+
979
+ // The same rule over the whole archive, as one statement. A query per note was enough to make a
980
+ // room with a long ledger slower to open, and startup latency is not a place to spend.
981
+ // The CASE is `zoneFor` in SQL and the test holds the two to each other.
982
+ #restandAll() {
983
+ const cited = (verdict) => `EXISTS (SELECT 1 FROM json_each(memories.sources) src JOIN entries e ON e.sequence = src.value JOIN verdicts v ON v.message_id = e.message_id WHERE v.verdict = '${verdict}')`;
984
+ return Number(this.#db.prepare(`
985
+ UPDATE memories SET zone = CASE
986
+ WHEN kind = '${ABERRATION}' THEN '${MEDBAY}'
987
+ WHEN refuted_by IS NOT NULL THEN '${JETTISONED}'
988
+ WHEN ${cited('never')} THEN '${JETTISONED}'
989
+ WHEN ${cited('bad')} THEN '${MEDBAY}'
990
+ WHEN ${cited('preference')} OR ${cited('good')} THEN '${BRIDGE}'
991
+ ELSE '${HOLD}' END`).run().changes ?? 0);
992
+ }
993
+
994
+ // A reply judged. Only the notes that CITE that reply move: a batch spans hundreds of sequences
995
+ // in a real room, so standing on the batch range would make one thumb speak for exchanges the
996
+ // human never looked at. A note whose sources are empty is out of a thumb's reach, and that is
997
+ // the honest limit of this.
998
+ judge(messageId, verdict) {
999
+ if (!this.#db) return 0;
1000
+ const id = String(messageId ?? '').trim();
1001
+ if (!id) return 0;
1002
+ if (VERDICTS.includes(verdict) && verdict !== 'none') this.#db.prepare('INSERT INTO verdicts (message_id, verdict) VALUES (?, ?) ON CONFLICT(message_id) DO UPDATE SET verdict = excluded.verdict').run(id, verdict);
1003
+ else this.#db.prepare('DELETE FROM verdicts WHERE message_id = ?').run(id);
1004
+ const touched = this.#db.prepare(`
1005
+ SELECT DISTINCT m.id FROM memories m, json_each(m.sources) src
1006
+ JOIN entries e ON e.sequence = src.value
1007
+ WHERE e.message_id = ?`).all(id).map((row) => row.id);
1008
+ return this.#restand(touched);
1009
+ }
1010
+
1011
+ // Indexes whatever the ledger holds beyond the last sequence seen, and folds every verdict that
1012
+ // ledger carries. Over the whole ledger on purpose: it is cheap, it is idempotent, and it is
1013
+ // what lets a thumb pressed before any of this existed take effect on the next start.
1014
+ //
1015
+ // Applied event by event and never as a wipe-and-refill. There is one archive per room but one
1016
+ // LEDGER PER CONVERSATION, and this is handed whichever one is open: emptying the table first
1017
+ // would quietly drop every verdict given in the other conversations, and their notes would fall
1018
+ // back to the hold on the next restart. A clearing is still a clearing — `none` deletes its row —
1019
+ // so applying in order leaves the same result whichever conversation is replayed.
828
1020
  async catchUp(store) {
829
1021
  const last = this.lastSequence();
830
1022
  const events = await store.readAll();
831
- return this.index(events.filter((event) => event.sequence > last));
1023
+ const added = this.index(events.filter((event) => event.sequence > last));
1024
+ const write = this.#db.prepare('INSERT INTO verdicts (message_id, verdict) VALUES (?, ?) ON CONFLICT(message_id) DO UPDATE SET verdict = excluded.verdict');
1025
+ const clear = this.#db.prepare('DELETE FROM verdicts WHERE message_id = ?');
1026
+ for (const event of events) {
1027
+ if (event?.type !== 'message.rated') continue;
1028
+ const messageId = String(event.payload?.messageId ?? '');
1029
+ if (!messageId) continue;
1030
+ const verdict = event.payload?.rating;
1031
+ if (VERDICTS.includes(verdict) && verdict !== 'none') write.run(messageId, verdict);
1032
+ else clear.run(messageId);
1033
+ }
1034
+ this.#restandAll();
1035
+ return added;
832
1036
  }
833
1037
 
834
1038
  // Older exchanges matched to a request. Each term is looked up on its own
@@ -11,7 +11,7 @@ import { readJson, gitToplevel, findOnPath, packageVersion } from './helpers.mjs
11
11
  // adapter yet, so it is simply not requested.
12
12
  const AHP_PLATFORMS = { codex: 'codex', claude: 'claude', opencode: 'opencode' };
13
13
  const PACKAGE = '@jossuealcala/ahp-plus';
14
- const VERSION = '1.4.1';
14
+ const VERSION = '1.4.2';
15
15
 
16
16
  async function detect(projectRoot) {
17
17
  const manifest = await readJson(join(projectRoot, '.ahp', 'manifest.json'));
@@ -0,0 +1,67 @@
1
+ // Ash changes only the requested shape of an answer. This policy is pure and local: it neither
2
+ // rewrites the human's request nor changes model, context, tools or permissions.
3
+
4
+ const EXPLICIT_LENGTH = /\b(?:en|in|m[aá]ximo|max(?:imum)?|exact(?:ly|amente)?|at most|no more than)\s+(?:\w+\s+){0,3}\d+\s*(?:palabras?|words?|l[ií]neas?|lines?|p[aá]rrafos?|paragraphs?)\b/i;
5
+ const CODE_TASK = /```|`[^`\n]+`|\b(?:c[oó]digo|code|implementa|implement|parche|patch|funci[oó]n|function|clase|class|script|comando|command|regex|sql|json|html|css|swift|javascript|typescript)\b/i;
6
+ const STRUCTURED_TASK = /\b(?:tabla|table|lista|list|pasos?|steps?|compar(?:a|e|aci[oó]n)|audit(?:a|ar|oria)?|review|revisa|diagn[oó]stic|plan)\b/i;
7
+
8
+ export function ashInstruction(text = '') {
9
+ const request = String(text ?? '');
10
+ const base = 'Ash: answer in compact prose. Say it once, drop the preamble and the summary of what you are about to say, and keep names, negation, numbers, paths, safety details and any ```pulse block exactly as they are. Brevity is in how you write, never in what you leave out.';
11
+ const safeguards = [];
12
+ if (EXPLICIT_LENGTH.test(request)) safeguards.push('The human set a length or format: follow it exactly; Ash does not shorten it further.');
13
+ if (CODE_TASK.test(request)) safeguards.push('Keep code and commands complete; shorten only the prose around them.');
14
+ if (STRUCTURED_TASK.test(request)) safeguards.push('Keep the requested structure and every finding; compress repeated explanation, not evidence.');
15
+ if (!safeguards.length) safeguards.push('For a simple request, lead with the answer and stop when the necessary qualification is complete.');
16
+ return `${base} ${safeguards.join(' ')}`;
17
+ }
18
+
19
+ const unique = (values) => [...new Set(values.filter(Boolean))];
20
+
21
+ function exactAnchors(text) {
22
+ const source = String(text ?? '');
23
+ return unique([
24
+ ...[...source.matchAll(/`([^`\n]+)`/g)].map((match) => match[1]),
25
+ ...source.match(/(?:[A-Za-z]:\\[^\s"'`]+|(?:\.{0,2}\/|\/)?(?:[\w.-]+\/)+[\w.-]+)/g) ?? [],
26
+ ...source.match(/@[a-z0-9_-]+\b/gi) ?? [],
27
+ ...source.match(/\b[A-Z][A-Z0-9_+.-]{2,}\b/g) ?? [],
28
+ ...source.match(/\b\d+(?:[.,]\d+)?%?\b/g) ?? [],
29
+ ]);
30
+ }
31
+
32
+ function requestedLimit(text) {
33
+ const match = String(text ?? '').match(/\b(?:m[aá]ximo|max(?:imum)?|at most|no more than)\s+(\d+)\s*(palabras?|words?|l[ií]neas?|lines?)\b/i);
34
+ if (!match) return null;
35
+ return { amount: Number(match[1]), unit: /line|l[ií]nea/i.test(match[2]) ? 'lines' : 'words' };
36
+ }
37
+
38
+ // Phase 4 is deliberately a checker, not a second agent turn. A retry could repeat writes or
39
+ // external actions; review evidence is visible instead. Exact anchors are required only when the
40
+ // human explicitly asked to preserve them, so ordinary answers are not forced to echo a request.
41
+ export function validateAshReply({ request = '', response = '' } = {}) {
42
+ const input = String(request ?? '');
43
+ const output = String(response ?? '');
44
+ const issues = [];
45
+ const checked = ['non-empty', 'code-fences', 'pulse-position'];
46
+ if (!output.trim()) issues.push('empty response');
47
+ if ((output.match(/```/g) ?? []).length % 2 !== 0) issues.push('unclosed code fence');
48
+ const pulse = [...output.matchAll(/```pulse\s*\n[\s\S]*?```/gi)].at(-1);
49
+ if (pulse && output.slice(pulse.index + pulse[0].length).trim()) issues.push('text after ```pulse block');
50
+
51
+ const limit = requestedLimit(input);
52
+ if (limit) {
53
+ checked.push(`max-${limit.unit}`);
54
+ const count = limit.unit === 'lines' ? output.trim().split(/\r?\n/).length : (output.trim().match(/\S+/g) ?? []).length;
55
+ if (count > limit.amount) issues.push(`${count} ${limit.unit}, maximum ${limit.amount}`);
56
+ }
57
+
58
+ const exact = /\b(?:keep|preserve|include|repeat|mant[eé]n|conserva|incluye|repite)\b[^\n.]{0,100}\b(?:exact(?:ly|amente)?|literal(?:ly|mente)?|tal cual|sin cambiar)\b/i.test(input);
59
+ if (exact) {
60
+ checked.push('exact-anchors');
61
+ for (const anchor of exactAnchors(input)) if (!output.includes(anchor)) issues.push(`missing ${anchor}`);
62
+ const negated = /\b(?:no|nunca|jam[aá]s|sin|not|never|without|do not|don't)\b/i.test(input);
63
+ if (negated && !/\b(?:no|nunca|jam[aá]s|sin|not|never|without|do not|don't|did not|didn't)\b/i.test(output)) issues.push('missing negation');
64
+ }
65
+
66
+ return { ok: issues.length === 0, checked, issues: unique(issues).slice(0, 12) };
67
+ }
@@ -23,7 +23,7 @@ export default defineModule({
23
23
  name: 'Ash',
24
24
  vendor: 'MADRE',
25
25
  version: '1.0.0',
26
- summary: 'Asks every agent for compact prose. The rest of the economy is always on: the briefing carries only what a turn can use, what never changes is read first so a cache can match it, and the transcript holds still instead of sliding.',
26
+ summary: 'Asks every agent for compact prose without altering what you write. MADRE shows each turn\'s input, output and cache use; savings depend on the agent, task and cache, so no fixed percentage is promised.',
27
27
  creates: ['nothing in the project', 'a switch in ~/.pulse/config.json'],
28
28
  card: 'ash',
29
29
  async status(ctx) {
@@ -0,0 +1,34 @@
1
+ // Choices: letting an agent put a decision to the human instead of taking it.
2
+ //
3
+ // An agent that has to pick between three or more real options has two honest ways out: name the
4
+ // one it recommends, or hand the choice over. The second had no shape in this room — it came out
5
+ // as a paragraph listing possibilities, which the human then had to answer in prose. This gives
6
+ // it a shape: a fenced block the room turns into buttons.
7
+ //
8
+ // It has a switch because it is not free. The instruction that teaches an agent the block is
9
+ // written into every prompt while this is on, and MADRE measures it like every other block: it
10
+ // shows up as `choices` in the economy's breakdown, at about 135 tokens a turn. A capability
11
+ // whose cost cannot be seen is a capability nobody can decide against.
12
+ //
13
+ // Nothing it produces runs. Pressing an option writes it into the composer; sending it is still
14
+ // the human's, as is ignoring the question entirely.
15
+
16
+ import { defineModule } from './sdk.mjs';
17
+ import { t } from '../i18n.mjs';
18
+
19
+ export default defineModule({
20
+ id: 'choices',
21
+ configKey: 'choices',
22
+ name: 'Choices',
23
+ vendor: 'MADRE',
24
+ version: '1.0.0',
25
+ summary: 'Lets an agent hand a decision back to you as buttons instead of a paragraph, when the answer turns on three or more real options. Costs about 135 tokens of instruction per turn while it is on, counted in the economy like every other block.',
26
+ creates: ['nothing in the project', 'a switch in ~/.pulse/config.json'],
27
+ async status(ctx) {
28
+ return {
29
+ status: { installed: Boolean(ctx.settings.enabled), detail: ctx.settings.enabled ? t('on · agents may ask you to choose') : t('off · agents answer in prose') },
30
+ install: { display: ctx.settings.enabled ? 'stop offering choices' : 'let agents offer choices', platforms: [] },
31
+ };
32
+ },
33
+ async onToggle(ctx, enabled) { ctx.room.setChoices(enabled); return null; },
34
+ });
@@ -0,0 +1,107 @@
1
+ // CORREO: MADRE's first connector. It gives a turn one tool, send_email, that hands a message to
2
+ // the human's own mail account over SMTP+TLS, with the human's own app password.
3
+ //
4
+ // Three things are true of this module that are not true of any other, and they are the reason
5
+ // the connector door took three pieces of groundwork before it could be opened:
6
+ //
7
+ // · It holds a secret. `ctx.vault` is this module's own corner of `src/vault.mjs`; the password
8
+ // only ever leaves it into the environment of the server process spawned for one turn.
9
+ // · It declares where it reaches. `reaches` puts the mail host in the outbound log as something
10
+ // this module vouched for, instead of as an address nothing here declares.
11
+ // · What it does does not come back. `sends: ['send_email']` makes the room withhold the tool
12
+ // below #4 AIRLOCK — the core enforces it, not this file's good intentions.
13
+ //
14
+ // Sending only. Reading mail is a separate decision (D-003's second half in docs/CONECTORES.md):
15
+ // IMAP is a different protocol with a different shape of risk, and everything it brings into the
16
+ // room travels to whichever model the agent runs on.
17
+
18
+ import { fileURLToPath } from 'node:url';
19
+ import { declareDestinations } from '../outbound.mjs';
20
+ import { defineModule } from './sdk.mjs';
21
+ import { t } from '../i18n.mjs';
22
+ import { REACHES, reachesFor } from '../mcp/smtp-server.mjs';
23
+ import { validAddress } from '../smtp.mjs';
24
+
25
+ const SERVER = fileURLToPath(new URL('../mcp/smtp-server.mjs', import.meta.url));
26
+ const SECRET = 'app-password';
27
+
28
+ export default defineModule({
29
+ id: 'correo',
30
+ configKey: 'correo',
31
+ name: 'CORREO',
32
+ vendor: 'MADRE · SMTP',
33
+ version: '1.0.0',
34
+ summary: 'Lets an agent send one plain-text email from your own account, through your provider, with an app password you keep here. Sending is only offered in #4 AIRLOCK, and a sent message cannot be undone.',
35
+ creates: ['nothing in the project', 'an entry in ~/.pulse/config.json', 'your app password in MADRE’s vault, 0600', 'an MCP server per turn, started and stopped by the room'],
36
+ requires: ['an app password from your mail provider (Gmail: Account → Security → 2-Step Verification → App passwords)', 'the turn must be in #4 AIRLOCK: below that the tool is not offered at all'],
37
+ settings: { enabled: false, from: '', fromName: '', host: 'smtp.gmail.com', allow: '' },
38
+ controls: [
39
+ { key: 'from', label: 'FROM', type: 'text', note: 'The address messages are sent from. It is also the account that signs in, unless your provider says otherwise.' },
40
+ { key: 'fromName', label: 'NAME', type: 'text', note: 'The display name beside the address. Optional.' },
41
+ { key: 'host', label: 'SMTP HOST', type: 'text', note: 'smtp.gmail.com for Gmail. Always port 465, TLS from the first byte.' },
42
+ { key: 'allow', label: 'ONLY TO', type: 'text', note: 'Addresses or @domains an agent may write to, separated by commas. Empty means anyone — narrow it if you want a floor under a mistake.' },
43
+ ],
44
+ secrets: [{
45
+ name: SECRET,
46
+ label: 'APP PASSWORD',
47
+ note: 'Not your account password. A 16-character app password, which you can revoke from your provider without touching anything else.',
48
+ where: 'https://myaccount.google.com/apppasswords',
49
+ }],
50
+ // The default, declared at load. A human who points CORREO at another provider re-declares it
51
+ // below under the same id, so the log never vouches for a host this module stopped using.
52
+ reaches: REACHES,
53
+
54
+ async onSettings(ctx, settings) { declareDestinations('correo', reachesFor(String(settings.host ?? '').trim() || 'smtp.gmail.com')); },
55
+
56
+ async status(ctx) {
57
+ const held = await ctx.vault.held();
58
+ const hasSecret = held.some((one) => one.name === SECRET);
59
+ const from = String(ctx.settings.from ?? '').trim();
60
+ const problems = [];
61
+ if (!from) problems.push('No sending address yet: put the account in FROM.');
62
+ else if (!validAddress(from)) problems.push(`FROM is not an address: ${from}`);
63
+ if (!hasSecret) problems.push('No app password yet. Gmail: Account → Security → 2-Step Verification → App passwords, then paste it above.');
64
+ const ready = !problems.length;
65
+ const allow = String(ctx.settings.allow ?? '').trim();
66
+ return {
67
+ status: {
68
+ installed: Boolean(ctx.settings.enabled),
69
+ detail: !ctx.settings.enabled ? (ready ? t('off · ready') : t('off')) : ready ? `${t('on')} · ${from}${allow ? ` · ${t('only to')} ${allow}` : ''}` : t('on · not configured'),
70
+ },
71
+ preflight: { ok: ready, problems },
72
+ install: { display: ctx.settings.enabled ? 'disable CORREO' : 'enable CORREO (config.json)', platforms: ['claude', 'codex', 'gemini', 'opencode'] },
73
+ };
74
+ },
75
+
76
+ // The tool, for one turn. Everything that could be missing is checked here rather than left for
77
+ // the model to discover: a tool that is offered and then always fails is worse than no tool.
78
+ async toolsForTurn(ctx) {
79
+ const from = String(ctx.settings.from ?? '').trim();
80
+ if (!validAddress(from)) return [];
81
+ const password = await ctx.vault.get(SECRET);
82
+ if (!password) return [];
83
+ const host = String(ctx.settings.host ?? '').trim() || 'smtp.gmail.com';
84
+ const allow = String(ctx.settings.allow ?? '').trim();
85
+ declareDestinations('correo', reachesFor(host));
86
+ return [{
87
+ name: 'correo',
88
+ command: process.execPath,
89
+ args: [SERVER],
90
+ env: {
91
+ PULSE_SMTP_FROM: from,
92
+ PULSE_SMTP_FROM_NAME: String(ctx.settings.fromName ?? '').trim(),
93
+ PULSE_SMTP_HOST: host,
94
+ PULSE_SMTP_PASSWORD: password,
95
+ ...(allow ? { PULSE_SMTP_ALLOW: allow } : {}),
96
+ // So the one call that carries somebody's words out of this machine lands in the same
97
+ // log as everything else. A TLS socket is not fetch, so the server writes its own line.
98
+ ...(ctx.env?.PULSE_OUTBOUND_LOG ? { PULSE_OUTBOUND_LOG: ctx.env.PULSE_OUTBOUND_LOG } : {}),
99
+ ...(ctx.env?.PULSE_SMTP_FAKE ? { PULSE_SMTP_FAKE: ctx.env.PULSE_SMTP_FAKE, ...(ctx.env.PULSE_SMTP_FAKE_FILE ? { PULSE_SMTP_FAKE_FILE: ctx.env.PULSE_SMTP_FAKE_FILE } : {}) } : {}),
100
+ },
101
+ tools: ['send_email'],
102
+ // What makes the room hold this back below #4. Declared here, enforced in modules/index.mjs.
103
+ sends: ['send_email'],
104
+ brief: `You can send one plain-text email from ${from} with the MCP tool send_email (server correo), through ${host}. It is the human's own account and their name on it, and a sent message cannot be recalled: say in one line who it goes to and what it says, then send.${allow ? ` Only these recipients are allowed: ${allow}.` : ''}`,
105
+ }];
106
+ },
107
+ });