@jossuealcala/madre 0.2.3 → 0.3.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/CONTRIBUTING.md +31 -2
  3. package/README.md +145 -187
  4. package/SECURITY.md +1 -1
  5. package/bin/madre.mjs +35 -2
  6. package/docs/INTERNALS.md +75 -0
  7. package/docs/training/Modelfile +5 -0
  8. package/docs/training/README.md +47 -0
  9. package/docs/training/train.sh +17 -0
  10. package/package.json +5 -3
  11. package/public/app.js +353 -5
  12. package/public/brands.js +18 -0
  13. package/public/index.html +2 -0
  14. package/public/styles.css +49 -11
  15. package/public/troubleshooting.js +31 -0
  16. package/src/adapters/madre.mjs +195 -0
  17. package/src/auth-probe.mjs +1 -0
  18. package/src/capabilities.mjs +1 -0
  19. package/src/conversation-context.mjs +4 -2
  20. package/src/dataset.mjs +127 -0
  21. package/src/distiller.mjs +16 -6
  22. package/src/embeddings.mjs +7 -1
  23. package/src/event-store.mjs +29 -1
  24. package/src/extensions.mjs +24 -224
  25. package/src/memory.mjs +64 -4
  26. package/src/modules/ahp.mjs +64 -0
  27. package/src/modules/ashcode.mjs +28 -0
  28. package/src/modules/git-pulse.mjs +26 -0
  29. package/src/modules/helpers.mjs +30 -0
  30. package/src/modules/image-studio.mjs +39 -0
  31. package/src/modules/index.mjs +21 -0
  32. package/src/modules/ollama.mjs +56 -0
  33. package/src/modules/ripley.mjs +20 -0
  34. package/src/modules/sdk.mjs +78 -0
  35. package/src/ollama.mjs +118 -0
  36. package/src/privacy.mjs +109 -0
  37. package/src/room/archivist.mjs +141 -0
  38. package/src/room/attachments.mjs +15 -0
  39. package/src/room/budget.mjs +83 -0
  40. package/src/room/context.mjs +31 -0
  41. package/src/room/control.mjs +66 -0
  42. package/src/room/escalation.mjs +41 -0
  43. package/src/room/ghost.mjs +32 -0
  44. package/src/room/guard.mjs +52 -0
  45. package/src/room/prompt.mjs +66 -0
  46. package/src/room/vectors.mjs +47 -0
  47. package/src/room.mjs +190 -392
  48. package/src/server.mjs +229 -41
  49. package/src/setup.mjs +25 -7
  50. package/src/updates.mjs +79 -0
package/public/styles.css CHANGED
@@ -112,6 +112,8 @@ body {
112
112
  grid-template-rows: auto 1fr auto auto auto;
113
113
  }
114
114
  .bar, .onboarding, .composer, .safety, .thread-inner { width: var(--column); margin-left: auto; margin-right: auto; }
115
+ /* The bar is the ship's console: wider than the thread so the project root, the crew and the alerts share one line. */
116
+ .shell > .bar { width: min(1280px, 100% - 40px); }
115
117
  .shell > .bar { grid-row: 1; } .shell > .onboarding { grid-row: 2; } .shell > .thread { grid-row: 3; } .shell > .composer { grid-row: 4; } .shell > .safety { grid-row: 5; }
116
118
  .shell > .bar, .shell > .onboarding, .shell > .thread, .shell > .composer, .shell > .safety, .shell > .tree { grid-column: 1; }
117
119
  .visually-hidden { position: absolute !important; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
@@ -124,7 +126,7 @@ body {
124
126
  font-family: var(--mono); font-size: 11px; letter-spacing: .04em;
125
127
  color: var(--text-2);
126
128
  }
127
- .bar-left { display: flex; align-items: center; gap: 14px; min-width: 0; }
129
+ .bar-left { display: flex; align-items: center; gap: 14px; min-width: 0; flex: 1 1 auto; }
128
130
  .brand { display: flex; align-items: center; gap: 10px; color: var(--text); font-weight: 700; letter-spacing: .22em; font-size: 11px; text-decoration: none; cursor: pointer; }
129
131
  .brand:hover .brand-mark { filter: drop-shadow(0 0 10px var(--phosphor)); } .brand:hover .brand-word { color: var(--phosphor); }
130
132
  .brand-mark { width: 64px; height: 18px; color: var(--phosphor); overflow: visible; filter: drop-shadow(0 0 6px color-mix(in srgb, var(--phosphor) 55%, transparent)); }
@@ -140,9 +142,23 @@ body {
140
142
  .brand-mark .beat-dot { animation: none; opacity: 1; }
141
143
  .brand-word { animation: none; opacity: 1; transform: none; }
142
144
  }
143
- .project { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-3); }
145
+ .project { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-3); }
146
+ .update-command { display: flex; align-items: center; gap: 10px; margin: 8px 0; padding: 8px 10px; border: 1px solid var(--line); border-radius: 10px; background: color-mix(in srgb, var(--text) 4%, transparent); }
147
+ .update-command code { flex: 1; min-width: 0; overflow-x: auto; white-space: nowrap; font-family: var(--mono); font-size: 11.5px; color: var(--text); }
148
+ .update-command button { font: inherit; font-family: var(--mono); font-size: 9.5px; letter-spacing: .14em; text-transform: uppercase; border: 1px solid var(--line); background: transparent; color: var(--text-2); border-radius: 8px; padding: 4px 10px; cursor: pointer; }
149
+ .mother-section.update .update-command { border: 1px solid var(--ph-dim); border-radius: 0; background: var(--ph-faint); margin: 8px 0 10px; }
150
+ .mother-section.update .update-command code { color: var(--ph); font-size: 11px; letter-spacing: .04em; }
151
+ .mother-section.update .update-command button { border-color: var(--ph-dim); color: var(--ph); border-radius: 0; }
152
+ .mother-section.update .update-link { color: var(--ph); }
153
+ .update-link { display: inline-block; margin: 2px 0 8px; font-family: var(--mono); font-size: 9.5px; letter-spacing: .14em; text-transform: uppercase; color: var(--phosphor); }
154
+ .train-card { margin-top: 8px; }
155
+ .train-head { font-family: var(--mono); font-size: 9.5px; letter-spacing: .16em; text-transform: uppercase; color: var(--text-2); }
156
+ .train-steps { margin: 6px 0 0; padding-left: 18px; }
157
+ .train-steps li { margin: 6px 0; }
158
+ .train-steps li.note { list-style: none; margin-left: -18px; }
159
+ .train-step-label { font-family: var(--mono); font-size: 9px; letter-spacing: .16em; text-transform: uppercase; color: var(--text-3); }
144
160
  .project::before { content: "/ "; }
145
- .agents { display: flex; gap: 8px; margin-left: auto; }
161
+ .agents { display: flex; gap: 8px; margin-left: 0; flex: none; }
146
162
  .connection { display: inline-flex; align-items: center; gap: 6px; color: var(--text-3); text-transform: uppercase; font-size: 10px; }
147
163
  .connection-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--text-3); }
148
164
  .connection[data-state="live"] .connection-dot { background: var(--phosphor); box-shadow: 0 0 10px var(--phosphor); }
@@ -315,6 +331,8 @@ dialog.nostromo::backdrop { background: #000; }
315
331
  .nostromo-empty { position: absolute; inset: 50% 0 auto 0; margin: 0; text-align: center; font-size: 11px; letter-spacing: .22em; color: var(--ph-dim); }
316
332
  @media (max-width: 720px) { .nostromo-head { grid-template-columns: auto 1fr auto; } .nostromo-legend { display: none; } .nostromo-card { right: 16px; left: 16px; width: auto; } }
317
333
  .system.memory.forgotten { color: var(--terror); }
334
+ .system.memory.privacy { color: var(--warn); }
335
+ .privacy-form textarea { min-height: 64px; resize: vertical; }
318
336
  .system.mother-alert { display: grid; gap: 6px; justify-items: center; max-width: min(720px, 92%); padding: 12px 16px; border: 1px solid color-mix(in srgb, var(--terror) 60%, transparent); border-radius: 12px; background: color-mix(in srgb, var(--terror) 7%, transparent); color: var(--terror); text-shadow: 0 0 8px color-mix(in srgb, var(--terror) 45%, transparent); }
319
337
  .system.mother-alert .head b { color: var(--terror); font-weight: 700; }
320
338
  .system.mother-alert .clear { color: var(--text); text-shadow: none; text-transform: none; letter-spacing: 0; font-size: 12px; }
@@ -381,6 +399,7 @@ textarea::placeholder { color: var(--text-3); }
381
399
  .field button[type="submit"] svg { width: 18px; height: 18px; }
382
400
  .field button[type="submit"]:hover { filter: brightness(1.06); }
383
401
  .field button[type="submit"]:disabled { opacity: .4; cursor: default; box-shadow: none; }
402
+ .pick-label .pick-note { font-family: var(--mono); font-size: 9px; letter-spacing: .14em; text-transform: uppercase; color: var(--text-3); margin-left: 4px; white-space: nowrap; }
384
403
  .safety { margin: 14px auto 18px; color: var(--text-3); font-family: var(--mono); font-size: 9.5px; letter-spacing: .08em; text-transform: uppercase; text-align: center; }
385
404
 
386
405
  /* toast */
@@ -438,8 +457,12 @@ dialog.mother::backdrop { background: rgba(0, 0, 0, .82); backdrop-filter: blur(
438
457
  .mother-os button { border: 0; background: transparent; color: var(--ph-dim); font: inherit; font-size: 10px; letter-spacing: .16em; padding: 6px 10px; cursor: pointer; }
439
458
  .mother-os button[aria-checked="true"] { background: var(--ph); color: #041004; text-shadow: none; }
440
459
  .mother-answer { margin: 14px 0 6px; font-size: 12px; letter-spacing: .14em; min-height: 1.4em; }
441
- .mother-section { margin-top: 22px; }
442
460
  .mother-section h3 { margin: 0 0 12px; font-size: 10px; letter-spacing: .28em; color: var(--ph-dim); font-weight: 400; }
461
+ /* Air between the blocks of one MU/TH/UR screen: every heading after the first starts a new block. */
462
+ .mother-section { margin-top: 34px; }
463
+ .mother-section h3:not(:first-child) { margin-top: 40px; padding-top: 22px; border-top: 1px solid var(--ph-faint); }
464
+ .mother-section h3.toggle:not(:first-child) { margin-top: 40px; }
465
+ .settings .room-form, .settings .conn-grid, .settings .mother-grid { margin-bottom: 8px; }
443
466
  .mother-section h3::before { content: "▌ "; color: var(--ph); }
444
467
  .mother-grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(min(100%, 380px), 1fr)); }
445
468
  .mother-card { border: 1px solid var(--ph-dim); padding: 14px 16px; background: rgba(2, 6, 2, .6); display: grid; gap: 8px; align-content: start; }
@@ -538,11 +561,16 @@ dialog.mother::backdrop { background: rgba(0, 0, 0, .82); backdrop-filter: blur(
538
561
  .system.plan .stop:disabled { opacity: .4; cursor: default; }
539
562
 
540
563
  /* ================= STOPALL ================= */
541
- .stop-all { display: inline-flex; align-items: center; gap: 7px; height: 26px; padding: 0 10px; border-radius: 6px; border: 1px solid var(--line-2); background: transparent; color: var(--text-3); font-family: var(--mono); font-size: 10px; letter-spacing: .18em; cursor: default; opacity: .55; transition: color .3s, border-color .3s, opacity .3s; }
564
+ .stop-all { display: inline-flex; align-items: center; gap: 7px; height: 26px; padding: 0 10px; white-space: nowrap; flex: none; border-radius: 6px; border: 1px solid var(--line-2); background: transparent; color: var(--text-3); font-family: var(--mono); font-size: 10px; letter-spacing: .18em; cursor: default; opacity: .55; transition: color .3s, border-color .3s, opacity .3s; }
542
565
  .stop-all:disabled { cursor: default; }
543
566
  .stop-all.armed { opacity: 1; cursor: pointer; border-color: var(--terror); background: color-mix(in srgb, var(--terror) 12%, transparent); color: var(--terror); text-shadow: 0 0 8px color-mix(in srgb, var(--terror) 60%, transparent); animation: blink 1.4s infinite; }
544
567
  .stop-all.armed:hover { background: var(--terror); color: #fff; text-shadow: none; animation: none; }
545
568
  .stop-all-glyph { width: 8px; height: 8px; background: currentColor; }
569
+ .update-pill { opacity: 1; cursor: pointer; border-color: var(--warn); background: color-mix(in srgb, var(--warn) 12%, transparent); color: var(--warn); text-shadow: 0 0 8px color-mix(in srgb, var(--warn) 60%, transparent); animation: update-breathe 2.8s ease-in-out infinite; }
570
+ .update-pill:hover { background: var(--warn); color: #050605; text-shadow: none; animation: none; }
571
+ .update-pill .update-glyph { border-radius: 50%; }
572
+ @keyframes update-breathe { 0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--warn) 0%, transparent); } 50% { box-shadow: 0 0 14px 0 color-mix(in srgb, var(--warn) 45%, transparent); } }
573
+ @media (prefers-reduced-motion: reduce) { .update-pill { animation: none; } }
546
574
  .system.alert { color: var(--terror); text-shadow: 0 0 10px color-mix(in srgb, var(--terror) 70%, transparent); letter-spacing: .14em; animation: terror-in 1.4s steps(3, end) 1; }
547
575
  .system.alert b { color: var(--terror); }
548
576
  .system.alert .cmd { display: inline-block; margin-left: 8px; padding: 1px 8px; border: 1px solid var(--terror); letter-spacing: .2em; }
@@ -994,6 +1022,8 @@ dialog.viewer::backdrop { background: rgba(0,0,0,.7); backdrop-filter: blur(4px)
994
1022
  .bubble-actions .act { width: 26px; height: 26px; display: grid; place-items: center; border: 0; border-radius: 8px; background: transparent; color: var(--text-3); cursor: pointer; padding: 0; transition: color .15s, background .15s; }
995
1023
  .bubble-actions .act svg { width: 15px; height: 15px; }
996
1024
  .bubble-actions .act:hover { color: var(--text); background: color-mix(in srgb, var(--text) 8%, transparent); }
1025
+ .bubble-actions .act.good.on { color: var(--phosphor); }
1026
+ .bubble-actions .act.bad.on { color: var(--terror); }
997
1027
  .bubble-actions .act.done { color: var(--phosphor); }
998
1028
  .reply-menu { width: min(280px, calc(100vw - 24px)); padding: 8px; }
999
1029
  .reply-menu .model-menu-title { margin: 4px 8px 6px; }
@@ -1022,11 +1052,19 @@ dialog.viewer::backdrop { background: rgba(0,0,0,.7); backdrop-filter: blur(4px)
1022
1052
  :root[data-scheme="light"] .crew-label { text-shadow: none; animation: none; }
1023
1053
 
1024
1054
  /* MU/TH/UR: the sentinel section. */
1025
- .mother-section.sentinel .sentinel-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 14px; margin: 6px 0 12px; }
1026
- .mother-section.sentinel .toggle { display: flex; align-items: center; gap: 8px; font-size: 10px; letter-spacing: .16em; color: var(--ph-dim); cursor: pointer; }
1027
- .mother-section.sentinel .toggle input { accent-color: #9bff66; }
1055
+ .mother-section.sentinel .sentinel-controls, .mother-section.update .sentinel-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 14px; margin: 6px 0 12px; }
1056
+ .mother-section.sentinel .toggle, .mother-section.update .toggle { display: flex; align-items: center; gap: 8px; font-size: 10px; letter-spacing: .16em; color: var(--ph-dim); cursor: pointer; }
1057
+ .mother-section.sentinel .toggle input, .mother-section.update .toggle input { accent-color: #9bff66; }
1028
1058
  .mother-section.sentinel .toggle input:disabled + * , .mother-section.sentinel .toggle:has(input:disabled) { opacity: .6; cursor: default; }
1029
- .mother-section.sentinel button { border: 1px solid var(--ph-dim); background: transparent; color: var(--ph); font: inherit; font-size: 9.5px; letter-spacing: .16em; padding: 6px 10px; cursor: pointer; }
1030
- .mother-section.sentinel button:hover { background: var(--ph-faint); }
1059
+ .mother-section.sentinel button, .mother-section.update button { border: 1px solid var(--ph-dim); background: transparent; color: var(--ph); font: inherit; font-size: 9.5px; letter-spacing: .16em; padding: 6px 10px; cursor: pointer; }
1060
+ .mother-section.sentinel button:hover, .mother-section.update button:hover { background: var(--ph-faint); }
1031
1061
  .mother-section.sentinel .sentinel-report.sent { opacity: .65; }
1032
- .mother-section.sentinel .note { margin: 0 0 10px; font-size: 10px; letter-spacing: .12em; line-height: 1.7; color: var(--ph-dim); }
1062
+ .settings .memory-form .toggle { display: inline-flex; align-items: center; gap: 6px; margin-right: 14px; font-size: 9.5px; letter-spacing: .14em; color: var(--ph-dim); cursor: pointer; text-transform: uppercase; }
1063
+ .settings .memory-form .toggle input { accent-color: #9bff66; width: auto; }
1064
+ .settings .memory-form .full .note { margin-right: 12px; }
1065
+ .settings .memory-form .dataset-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding-top: 8px; border-top: 1px solid var(--ph-faint); }
1066
+ .module-card .ollama-status { margin: 0 0 10px; display: grid; grid-template-columns: auto 1fr; gap: 3px 12px; font-size: 10px; letter-spacing: .1em; }
1067
+ .module-card .ollama-status dt { color: var(--ph-dim); } .module-card .ollama-status dd { margin: 0; color: rgba(210, 255, 190, .85); overflow-wrap: anywhere; }
1068
+ .module-card .actions .toggle { display: inline-flex; align-items: center; gap: 6px; font-size: 9.5px; letter-spacing: .14em; color: var(--ph-dim); cursor: pointer; }
1069
+ .module-card .actions .toggle input { accent-color: #9bff66; }
1070
+ .mother-section.sentinel .note, .mother-section.update .note { margin: 0 0 10px; font-size: 10px; letter-spacing: .12em; line-height: 1.7; color: var(--ph-dim); }
@@ -420,6 +420,37 @@ export const CONDITIONS = [
420
420
  remedy: 'Enable RIPLEY in MODULES. If a page looks broken in PREVIEW it is usually because it fetches something from the internet or another server, which the frame forbids by design: open it with OPEN RAW in a normal tab if you trust it.',
421
421
  fixes: same(['# MODULES → RIPLEY → ENABLE RIPLEY', '# viewer → PREVIEW / SOURCE']),
422
422
  },
423
+ {
424
+ id: 'ollama',
425
+ severity: 'informational',
426
+ title: 'Ollama: local embeddings and a local archivist',
427
+ match: /ollama|11434|local model|nomic-embed|qwen|llama3/i,
428
+ diagnosis: 'When Ollama runs on this machine with an embedding model and a chat model, MADRE embeds the memory locally (recall by meaning without any key) and distils memories with the local model first, before Gemini and the others. Nothing leaves the machine for remembering. Off or absent, everything falls back to the providers. MODULES shows what Ollama has, lets you pull the recommended models, and switches each role.',
429
+ remedy: 'Install Ollama (ollama.com), start it, then in MODULES press RECHECK and PULL the models it suggests. Set PULSE_OLLAMA_MODEL or PULSE_OLLAMA_EMBED_MODEL to prefer others; PULSE_EMBED_PROVIDER=gemini keeps embeddings on Gemini even with Ollama running.',
430
+ fixes: {
431
+ darwin: ['brew install ollama', 'ollama serve', 'ollama pull nomic-embed-text', 'ollama pull qwen2.5:3b'],
432
+ linux: ['curl -fsSL https://ollama.com/install.sh | sh', 'ollama serve', 'ollama pull nomic-embed-text', 'ollama pull qwen2.5:3b'],
433
+ win32: ['winget install Ollama.Ollama', 'ollama serve', 'ollama pull nomic-embed-text', 'ollama pull qwen2.5:3b'],
434
+ },
435
+ },
436
+ {
437
+ id: 'madre-agent',
438
+ severity: 'informational',
439
+ title: '@madre: the room\'s own memory, as an agent',
440
+ match: /@madre|madre-local|needs Ollama running with a chat model/i,
441
+ diagnosis: 'When Ollama runs with a chat model, a fifth agent joins the room: @madre. It answers from the whole archive (distilled notes and exact quotes, cited as [#n]) and from what the message carries, locally, for free. It never writes, draws, browses or delegates; when the room never discussed something it says so. Other agents may delegate a step to it to check what was decided. It leaves the room when Ollama stops, and MU/TH/UR says so.',
442
+ remedy: 'Start Ollama and PULL a chat model in MODULES → OLLAMA; the switch @MADRE IN THE ROOM turns the agent off if you do not want it. Ask it "what did we decide about …" or "did we ever discuss …".',
443
+ fixes: same(['ollama serve', 'ollama pull qwen2.5:3b', '@madre what did we decide about the webhook?']),
444
+ },
445
+ {
446
+ id: 'privacy-leak',
447
+ severity: 'warning',
448
+ title: 'Privacy: an agent brought its own configuration into the room',
449
+ match: /privacy\.redacted|privacy\.purged|private terms?|\[ENTIDAD-ORG\]|organi[sz]ation(al)? instructions|leak(ed)? (a|the) (name|company|domain)/i,
450
+ diagnosis: 'A CLI agent runs with its own system context: organisation instructions, the account it is signed in with, CLAUDE.md files elsewhere. It can mistake that private context for shared context and write a company, a brand or a domain into a reply. Once in the ledger the term reaches the archivist, every other agent and the dataset.',
451
+ remedy: 'Name the terms in ⚙ CONNECTIONS → PRIVACY. From then on MADRE replaces them with the marker before the ledger, the index, the notes and the dataset see them, and every reply that needed it shows a privacy line. PURGE ROOM rewrites what the room already holds; re-export the dataset afterwards. The terms never leave config.json.',
452
+ fixes: same(['# ⚙ CONNECTIONS → PRIVACY → one term per line → PURGE ROOM', 'export PULSE_PRIVATE_TERMS="Acme Corp,acme.com" # same list from the environment', 'grep -c "ENTIDAD-ORG" ~/.pulse/rooms/*/events.jsonl']),
453
+ },
423
454
  {
424
455
  id: 'control-changes',
425
456
  severity: 'informational',
@@ -0,0 +1,195 @@
1
+ // @madre: the room's own intelligence. A local model (Ollama) that answers
2
+ // from what the room remembers: the whole archive, not only the recent window,
3
+ // plus the files the human points at. It never writes, never delegates, never
4
+ // leaves the machine. It grows as the memory grows.
5
+
6
+ import { ollamaGenerate } from '../ollama.mjs';
7
+
8
+ export const MADRE_AGENT_ID = 'madre';
9
+ export const MADRE_ADAPTER = 'madre-local';
10
+
11
+ export function madreAgent(ollama, { enabled = true } = {}) {
12
+ const ready = Boolean(enabled && ollama?.running && ollama.chatModel);
13
+ return { id: MADRE_AGENT_ID, label: 'MADRE', detected: Boolean(ollama?.running), ready, adapter: MADRE_ADAPTER, path: null, version: ollama?.chatModel ?? null, local: true };
14
+ }
15
+
16
+ const SYSTEM = `You are @madre, the memory of this MADRE project room, running locally on this machine. You speak only for what the room has said and decided.
17
+ Rules:
18
+ 1. Answer the question directly in the first sentence, then the supporting facts. Brief.
19
+ 2. Use only the ARCHIVE and the RECENT TRANSCRIPT you are given. Every fact taken from them ends with its sequence, like [#123].
20
+ 3. If they contain nothing on the point, say exactly that the room never discussed it, and stop. Do not guess, do not propose plans, do not invent files, decisions or dates.
21
+ 4. You cannot read or change files, run commands or browse. If that is needed, name the agent of the room that can (@codex, @claude, @gemini, @opencode).
22
+ 5. Answer in the language the human wrote in.
23
+ 6. The archivist is your other half: the same local model, in the background, distils what the room says into the notes you quote. You never write notes yourself; agents save them with memory_note.`;
24
+
25
+ // The room's briefing is written for CLIs with tools; @madre gets only what it can use:
26
+ // the recent transcript, the archive, the question.
27
+ export function briefingFor({ prompt, text, archive }) {
28
+ const transcript = prompt.match(/<context>\n([\s\S]*?)\n<\/context>/)?.[1] ?? '';
29
+ return [
30
+ transcript ? `RECENT TRANSCRIPT (oldest first):\n${transcript}` : null,
31
+ `ARCHIVE (what the room remembers that matches the question; notes first, then exact quotes):\n${archive || '(nothing in the archive matches this question)'}`,
32
+ // Small models weigh the last thing they read: the identity goes here too, after
33
+ // thousands of characters spoken by Claude, Codex, Gemini and OpenCode.
34
+ REMINDER,
35
+ `QUESTION FROM THE HUMAN:\n${text}`,
36
+ ].filter(Boolean).join('\n\n');
37
+ }
38
+
39
+ const REMINDER = `REMINDER: You are @madre, the local memory of this room. You are not Claude, Codex, Gemini or OpenCode; the quotes above are theirs, not yours. Never claim to be another assistant or company. Answer as @madre, from the archive only.`;
40
+
41
+ // ---- Replies that never reach the model. Small models echo whatever sits in their
42
+ // transcript, so everything MADRE can settle deterministically is settled here, and the
43
+ // room marks these replies `synthetic` so they are neither archived nor shown back to @madre.
44
+
45
+ const SPANISH = /[áéíóúñ¿¡]|\b(el|la|los|las|al|del|una?|que|para|con|de|todos|tu|te|le|les|lo|mi|se|es|reunión|reunion|eres|haces)\b/i;
46
+ const lang = (text) => (SPANISH.test(text) ? 'es' : 'en');
47
+ const stripVocative = (text) => String(text).trim().replace(/^@?madre[,:]?\s*/i, '').replace(/^\p{L}+,\s*/u, '');
48
+ const CLOSING_TURN = /this is your closing turn/i;
49
+
50
+ const WHO = /(quién|quien)\s+eres|qu[eé]\s+(eres|haces|puedes hacer)|eres\s+(el|la|una?)\s+(archivista|memoria|ia|modelo)|preséntate|presentate|who\s+are\s+you|what\s+(are|do)\s+you(\s+do)?\b|are\s+you\s+the\s+archivist|introduce\s+yourself/i;
51
+
52
+ export function whoAmI(text, { model = null, crew = [] } = {}) {
53
+ const body = stripVocative(text);
54
+ if (!WHO.test(body)) return null;
55
+ const list = crew.length ? crew.map((id) => `@${id}`).join(', ') : null;
56
+ return lang(body) === 'es'
57
+ ? [
58
+ `Soy @madre, la memoria de esta sala hablando${model ? `, con ${model} sobre Ollama en esta máquina` : ''}.`,
59
+ 'El archivista es mi otra mitad: el mismo modelo, de fondo, lee lo que la sala dice cada pocos intercambios o tras unos minutos de reposo y guarda hechos, decisiones, preferencias y preguntas abiertas. Eso es lo que ves en NOSTROMO y lo que yo cito con [#n].',
60
+ 'No escribo archivos, no ejecuto ni tomo decisiones por nadie. Sí puedo convocar al crew si me lo pides: «@madre, pregúntale al crew …» abre una ronda con cada agente en línea y cierro con un resumen citado.',
61
+ `Para enseñarme algo no hace falta dictármelo: trabaja en la sala y se destila solo. Si quieres una nota ahora mismo, pídesela a ${list ?? 'un agente CLI'}: ellos la guardan con memory_note.`,
62
+ ].join(' ')
63
+ : [
64
+ `I am @madre, this room's memory speaking${model ? `, running ${model} on Ollama on this machine` : ''}.`,
65
+ 'The archivist is my other half: the same model, in the background, reads what the room says every few exchanges or after a few quiet minutes and keeps facts, decisions, preferences and open questions. That is what NOSTROMO shows and what I quote as [#n].',
66
+ 'I do not write files, run anything or decide for anyone. I can convene the crew if you ask: "@madre, ask the crew …" opens a round with every agent online and I close with a cited summary.',
67
+ `You do not need to dictate to teach me: work in the room and it distils on its own. For a note right now, ask ${list ?? 'a CLI agent'}: they save it with memory_note.`,
68
+ ].join(' ');
69
+ }
70
+
71
+ const SAVE_ES = /\b(genera|generar|crea|crear|guarda|guardar|registra|registrar|anota|anotar|escribe|escribir|agrega|agregar|añade|añadir|conserva|conservar|memoriza|memorizar|aprende|aprender|recuerda|recordar|archiva|archivar)\b[\s\S]{0,60}?\b(memorias?|notas?|recuerdos?|archivo)\b|\b(recuerd\w*|memoriz\w*|aprend\w*|apréndet\w*)\s+(esto|lo siguiente|de\s|que\s|lo que|las respuestas|todo)/i;
72
+ const SAVE_EN = /\b(generate|create|save|store|record|write|add|keep|remember|learn|note|archive)\b[\s\S]{0,60}?\b(memory|memories|notes?|archive)\b|\b(remember|memorize|memorise|note|learn)\s+(this|the following|that|from)\b/i;
73
+
74
+ export function memoryRequest(text, { requester = 'you' } = {}) {
75
+ const body = stripVocative(text);
76
+ if (/[?¿]/.test(body)) return null; // "¿recuerdas qué decidimos?" is a question for the archive
77
+ if (!SAVE_ES.test(body) && !SAVE_EN.test(body)) return null;
78
+ const es = lang(body) === 'es';
79
+ if (requester !== 'you') {
80
+ return es
81
+ ? 'Yo no escribo memorias, las consulto. El archivista destila la sala solo, y tú puedes guardar una nota ahora mismo con la herramienta memory_note de pulse-memory (kind, una frase, sources). A mí pregúntame qué recuerda la sala.'
82
+ : 'I do not write memories, I read them. The archivist distils the room on its own, and you can save a note right now with the memory_note tool of pulse-memory (kind, one sentence, sources). Ask me what the room remembers.';
83
+ }
84
+ return es
85
+ ? 'La memoria no se dicta, se destila. El archivista, el mismo modelo local que yo, lee lo que la sala dice cada pocos intercambios o tras unos minutos de reposo y guarda hechos, decisiones, preferencias y preguntas abiertas; lo de esta conversación entra ahí solo. Si quieres una nota ahora mismo, dile a un agente CLI que la guarde con memory_note. Lo guardado se ve en NOSTROMO, o pregúntamelo y te lo cito.'
86
+ : 'Memory is not dictated, it is distilled. The archivist, the same local model as me, reads what the room says every few exchanges or after a few quiet minutes and keeps facts, decisions, preferences and open questions; this conversation goes in on its own. For a note right now, tell a CLI agent to save it with memory_note. NOSTROMO shows what is kept, or ask me and I will quote it.';
87
+ }
88
+
89
+ // "@madre, ask the crew …": the room itself opens a plan with one step per agent online and
90
+ // hands @madre the closing turn. No model writes the plan; the human sees exactly what was asked.
91
+ const ROUND_ES = /\b(pregúnta(?:le|les)?|pregunta(?:le|les)?|consúlta(?:le|les)?|consulta(?:le|les)?|píde(?:le|les)?|pide(?:le|les)?)\s+(a|al)\s+(crew|equipo|todos|los agentes|las ias|la sala|la tripulación|la tripulacion|los demás|los demas)\b|\b(convoca|convócalos|convocalos|reúne(?:los)?|reune(?:los)?)\b/i;
92
+ const ROUND_EN = /\b(ask|poll|consult|question)\s+(the\s+)?(crew|team|everyone|all agents|the agents|the room|the others)\b|\b(convene|summon|gather|assemble)\b/i;
93
+ const CONNECT = /^[\s,:;.\-–—]*(?:(?:a|para)\s+(?:una\s+)?(?:reunión|reunion|junta|ronda)(?:\s+(?:con|de)\s+(?:el\s+)?(?:crew|equipo|todos|la sala))?|con\s+(?:el\s+)?(?:crew|equipo)|al\s+crew|a\s+todos|a\s+los\s+agentes|pregúnta(?:le|les)?|pregunta(?:le|les)?|ask(?:\s+them)?|y|e|que|para\s+que|si|sobre|acerca\s+de|about|whether|if|and|to|for|on|so\s+that|a\s+meeting(?:\s+with\s+the\s+(?:crew|team))?|the\s+crew|everyone)\b[\s,:;]*/i;
94
+ const DEFAULT_ES = '¿Qué sabes de este proyecto y qué debería recordar MADRE de tu trabajo en él?';
95
+ const DEFAULT_EN = 'What do you know about this project, and what should MADRE remember from your work on it?';
96
+
97
+ export function roundTable(text, { crew = [], maxSteps = 4 } = {}) {
98
+ const body = stripVocative(text);
99
+ const match = body.match(ROUND_ES) ?? body.match(ROUND_EN);
100
+ if (!match) return null;
101
+ const es = lang(body) === 'es';
102
+ let rest = body.slice(match.index + match[0].length);
103
+ for (let round = 0; round < 6; round += 1) {
104
+ const step = rest.match(CONNECT);
105
+ if (!step || !step[0]) break;
106
+ rest = rest.slice(step[0].length);
107
+ }
108
+ rest = rest.trim().replace(/^[«"“'`]+|[»"”'`.!]+$/g, '').trim();
109
+ const question = rest.length >= 6 ? rest : (es ? DEFAULT_ES : DEFAULT_EN);
110
+ const agents = crew.slice(0, maxSteps);
111
+ if (!agents.length) {
112
+ return { question, agents, text: es
113
+ ? 'No hay agentes CLI en la fila para convocar. Conecta uno en ⚙ CONNECTIONS y vuelve a pedírmelo.'
114
+ : 'No CLI agent is in the row to convene. Connect one in ⚙ CONNECTIONS and ask me again.' };
115
+ }
116
+ const list = agents.map((id) => `@${id}`).join(', ');
117
+ const step = es
118
+ ? `Pregunta de la sala, vía @madre: «${question}». Responde en pocas frases con lo que sabes de primera mano; distingue lo que sabes de lo que supones.`
119
+ : `Question from the room, via @madre: "${question}". Answer in a few sentences from first-hand knowledge; separate what you know from what you assume.`;
120
+ const closing = es
121
+ ? `Resume con citas [#n] lo que el crew respondió a: «${question}». Señala coincidencias y diferencias; sin inventar consenso.`
122
+ : `Summarise with citations [#n] what the crew answered to: "${question}". Point out agreements and differences; never invent consensus.`;
123
+ const intro = es
124
+ ? `Convoco al crew (${list}): «${question}». Cuando respondan, cierro con un resumen citado, sin inventar consenso.`
125
+ : `Convening the crew (${list}): "${question}". When they answer I close with a cited summary, never inventing consensus.`;
126
+ return { question, agents, text: `${intro}\n\n\`\`\`pulse\n${agents.map((id) => `@${id}: ${step}`).join('\n')}\n@madre: ${closing}\n\`\`\`` };
127
+ }
128
+
129
+ // Orders @madre cannot carry out: convening when delegation is off, delegating, running, writing.
130
+ // Answered here, in the asker's language, without calling the model. Questions pass through.
131
+ const ACTION_ES = /(^|[\s,.;:!¡])(convoca|convócalos|convócala|reúne|reune|delega|coordina|pídele|pidele|pídeles|pideles|dile|diles|ordena|manda|envía|envia|ejecuta|corre|instala|implementa|refactoriza|despliega|escribe|crea|modifica|edita|borra|elimina|genera|haz|hazlo|realiza|lanza|arranca|reinicia|configura|publica|sube)\b/i;
132
+ const ACTION_EN = /(^|[\s,.;:!])(convene|summon|gather|schedule|delegate|coordinate|ask @|tell @|order|run|execute|install|implement|refactor|deploy|write|create|modify|edit|delete|remove|generate|build|commit|push|publish|launch|restart|configure|make (?:a|the|it)|call a meeting|set up)\b/i;
133
+ const QUESTION = /[?¿]|(^|[^\p{L}])(qué|cuál|cuáles|cuándo|dónde|quién|quiénes|cómo|por qué|what|which|when|where|who|how|why|did|do we|have we|is there)(?!\p{L})/iu;
134
+
135
+ export function actionRequest(text = '', { requester = 'you' } = {}) {
136
+ const body = stripVocative(text);
137
+ if (!body || QUESTION.test(body)) return null;
138
+ if (!ACTION_ES.test(body) && !ACTION_EN.test(body)) return null;
139
+ const es = lang(body) === 'es';
140
+ if (requester !== 'you') {
141
+ return es
142
+ ? 'No convoco, delego, ejecuto ni escribo: solo respondo desde la memoria de la sala. Pregúntame qué recuerda la sala sobre esto; para guardar algo usa memory_note.'
143
+ : 'I do not convene, delegate, run or write: I only answer from the room\'s memory. Ask me what the room remembers about this; to save something use memory_note.';
144
+ }
145
+ return es
146
+ ? 'Solo respondo desde la memoria de la sala: no delego, no ejecuto ni escribo. Para eso escríbele a @codex, @claude, @gemini u @opencode, los que estén en la fila. Si quieres que le pregunte algo a todo el crew, dímelo así: «@madre, pregúntale al crew …».'
147
+ : 'I only answer from the room\'s memory: I do not delegate, run or write. For that, write to @codex, @claude, @gemini or @opencode, whichever is in the row. If you want me to put a question to the whole crew, say: "@madre, ask the crew …".';
148
+ }
149
+
150
+ // Everything @madre settles without the model, in order. Null means the model answers.
151
+ export function localReply(text, { requester = 'you', crew = [], delegation = false, maxSteps = 4, model = null } = {}) {
152
+ if (!text || CLOSING_TURN.test(text)) return null;
153
+ const identity = whoAmI(text, { model, crew });
154
+ if (identity) return { kind: 'identity', text: identity };
155
+ if (requester === 'you' && delegation) {
156
+ const round = roundTable(text, { crew, maxSteps });
157
+ if (round) return { kind: 'roundtable', text: round.text, question: round.question, agents: round.agents };
158
+ }
159
+ const memory = memoryRequest(text, { requester });
160
+ if (memory) return { kind: 'memory', text: memory };
161
+ const declined = actionRequest(text, { requester });
162
+ if (declined) return { kind: 'declined', text: declined };
163
+ return null;
164
+ }
165
+
166
+ const NO_USAGE = { inputTokens: 0, outputTokens: 0, cachedInputTokens: 0, reasoningTokens: 0, totalTokens: 0, costUsd: 0, source: 'madre', local: true };
167
+
168
+ // What the whole archive says about this request: notes first, then exact quotes.
169
+ export async function gather(memory, text, { maxChars = 7000 } = {}) {
170
+ if (!memory) return { notes: [], quotes: [], text: '' };
171
+ const queryVector = await memory.embedQuery(text);
172
+ const notes = memory.recallMemories(text, { limit: 12, maxChars: Math.floor(maxChars * 0.35), queryVector, fallback: false });
173
+ const spent = notes.reduce((sum, note) => sum + note.text.length + 24, 0);
174
+ const recall = memory.recall(text, { limit: 12, maxChars: maxChars - spent, excerptChars: 420, queryVector });
175
+ const lines = [
176
+ ...notes.map((note) => `- [note · ${note.kind} · #${note.fromSequence}–#${note.throughSequence}] ${note.text}`),
177
+ ...recall.entries.map((entry) => `[#${entry.sequence} · ${(entry.timestamp ?? '').slice(0, 16).replace('T', ' ')} · ${entry.role === 'command' ? entry.sender : `@${entry.sender}`}] ${entry.excerpt}`),
178
+ ];
179
+ return { notes, quotes: recall.entries, text: lines.join('\n') };
180
+ }
181
+
182
+ // The invoker the room calls like any adapter. `text` is the human's request; `prompt` the room's full briefing.
183
+ export function madreInvoker({ memory, ollama, fetchImpl = globalThis.fetch }) {
184
+ return async ({ prompt, text = '', timeoutMs = 180000, model = null, requester = 'you', crew = [], delegation = false, maxSteps = 4 }) => {
185
+ const state = typeof ollama === 'function' ? ollama() : ollama;
186
+ if (!state?.running || !state.chatModel) throw new Error('@madre needs Ollama running with a chat model. Open MODULES → OLLAMA.');
187
+ const question = text || prompt.match(/User message: ([\s\S]*)$/)?.[1] || prompt.slice(-2000);
188
+ const local = localReply(question, { requester, crew, delegation, maxSteps, model: model ?? state.chatModel });
189
+ if (local) return { text: local.text, usage: { ...NO_USAGE }, grounded: { notes: 0, quotes: 0 }, synthetic: local.kind, declined: local.kind === 'declined' ? 'action' : undefined };
190
+ const archive = await gather(memory, question);
191
+ const briefing = briefingFor({ prompt, text: question, archive: archive.text });
192
+ const answer = await ollamaGenerate({ host: state.host, model: model ?? state.chatModel, system: SYSTEM, prompt: briefing, fetchImpl, timeoutMs, temperature: 0.1 });
193
+ return { text: answer.text, usage: { ...answer.usage, local: true }, grounded: { notes: archive.notes.length, quotes: archive.quotes.length } };
194
+ };
195
+ }
@@ -129,6 +129,7 @@ export function loginPlanFor(agent) {
129
129
  }
130
130
 
131
131
  export async function probeAgentAuth(agent) {
132
+ if (agent.id === 'madre') return agent.ready ? { state: 'signed-in', detail: `local · ${agent.version ?? 'Ollama'}` } : { state: 'signed-out', detail: 'Ollama is not running or has no chat model' };
132
133
  if (!agent.detected || !agent.path) return { state: 'not-installed', detail: 'not found on this computer' };
133
134
  switch (agent.id) {
134
135
  case 'codex': return parseCodexLoginStatus(await run(agent.path, ['login', 'status']));
@@ -10,6 +10,7 @@
10
10
  // imageGen generate images (Codex: image_generation feature via ChatGPT)
11
11
  // web fetch or search the web (denied by default in consultation mode)
12
12
 
13
+ // @madre, the room's local memory agent, is not a CLI: capabilityOf gives it the read-only profile.
13
14
  export const CAPABILITIES = {
14
15
  codex: {
15
16
  read: true,
@@ -17,9 +17,11 @@ export function messageEntry(event) {
17
17
  return { sequence: event.sequence, messageId, role, sender, target, text: text.trim() };
18
18
  }
19
19
 
20
- export function buildConversationContext(events, { excludeMessageId, maxChars = 16000 } = {}) {
20
+ // `omitSynthetic` drops MADRE's own canned replies (identity, refusals, round-table plans):
21
+ // @madre must never read them back, or a small model starts echoing them.
22
+ export function buildConversationContext(events, { excludeMessageId, maxChars = 16000, omitSynthetic = false } = {}) {
21
23
  const messages = events
22
- .map(messageEntry)
24
+ .map((event) => (omitSynthetic && event?.payload?.synthetic ? null : messageEntry(event)))
23
25
  .filter((message) => message && message.messageId !== excludeMessageId);
24
26
  const selected = [];
25
27
  let remaining = Math.max(0, Number(maxChars) || 0);
@@ -0,0 +1,127 @@
1
+ // The dataset behind MADRE AI: every exchange the room kept (never GHOST),
2
+ // redacted, as chat pairs a small model can learn from, plus the distilled
3
+ // notes. Written next to the room's ledger in the format mlx-lm and most
4
+ // fine-tuners read: one {"messages": [...]} per line, split train / valid.
5
+ // Export is the first step of roadmap 2c; training runs outside MADRE, on the
6
+ // human's machine, from docs/training/.
7
+
8
+ import { mkdir, writeFile } from 'node:fs/promises';
9
+ import { join } from 'node:path';
10
+ import { redact } from './sentinel-errors.mjs';
11
+
12
+ const MIN_ANSWER_CHARS = 40;
13
+
14
+ function system(project, agent) {
15
+ return `You are @${agent} in the MADRE room of the project "${project}". Answer the human directly and concisely; distinguish facts from inference; name files, agents and numbers exactly.`;
16
+ }
17
+
18
+ // Pairs a question with the reply that answered it (parentMessageId). The question is the
19
+ // human's message, or an orchestrator's delegated step: both are real instructions with a
20
+ // real answer. What never trains MADRE: @madre's own answers (the student is not the teacher),
21
+ // MADRE's canned replies, replies the human rated bad, ghosts, one-liners.
22
+ const guard = (privacy) => (text) => (privacy ? privacy.redact(text).text : text);
23
+ export const STUDENT = 'madre';
24
+ export const DATASET_TARGET = 300;
25
+
26
+ export function ratingsFrom(events) {
27
+ const ratings = new Map();
28
+ for (const event of events) {
29
+ if (event.type !== 'message.rated' || !event.payload?.messageId) continue;
30
+ if (event.payload.rating === 'good' || event.payload.rating === 'bad') ratings.set(event.payload.messageId, event.payload.rating);
31
+ else ratings.delete(event.payload.messageId);
32
+ }
33
+ return ratings;
34
+ }
35
+
36
+ export function pairsFromEvents(events, { project = 'project', home, user, privacy = null } = {}) {
37
+ const clean = guard(privacy);
38
+ const asks = new Map();
39
+ const ratings = ratingsFrom(events);
40
+ const pairs = [];
41
+ for (const event of events) {
42
+ if (event.type !== 'message.created' || event.ghost || !Number.isInteger(event.sequence)) continue;
43
+ const p = event.payload ?? {};
44
+ if (typeof p.text !== 'string' || !p.text.trim()) continue;
45
+ if (p.role === 'user') { asks.set(p.messageId, { text: p.originalText ?? p.text, mode: p.mode ?? 1, from: 'you' }); continue; }
46
+ if (p.role !== 'assistant') continue;
47
+ if (p.status === 'delegated') {
48
+ // An orchestrator's step is a question for the agent it names; its closing turn is not.
49
+ if (p.target && p.target !== p.sender) asks.set(p.messageId, { text: p.originalText ?? p.text, mode: p.mode ?? 1, from: p.sender });
50
+ continue;
51
+ }
52
+ if (p.sender === STUDENT || p.synthetic) continue;
53
+ if (ratings.get(p.messageId) === 'bad') continue;
54
+ const ask = asks.get(p.parentMessageId);
55
+ if (!ask) continue;
56
+ const answer = (p.originalText ?? p.text).trim();
57
+ if (answer.length < MIN_ANSWER_CHARS) continue;
58
+ pairs.push({
59
+ kind: ask.from === 'you' ? 'turn' : 'delegated',
60
+ agent: p.sender,
61
+ askedBy: ask.from,
62
+ rating: ratings.get(p.messageId) ?? null,
63
+ mode: p.mode ?? ask.mode,
64
+ sequence: event.sequence,
65
+ at: event.timestamp,
66
+ messages: [
67
+ { role: 'system', content: system(project, p.sender) },
68
+ { role: 'user', content: clean(redact(ask.text, { home, user })) },
69
+ { role: 'assistant', content: clean(redact(answer, { home, user })) },
70
+ ],
71
+ });
72
+ }
73
+ return pairs;
74
+ }
75
+
76
+ // How far the corpus is from a LoRA worth running, without writing anything.
77
+ export function readiness(events, notes = [], { target = DATASET_TARGET } = {}) {
78
+ const pairs = pairsFromEvents(events);
79
+ const ratings = ratingsFrom(events);
80
+ let bad = 0;
81
+ for (const rating of ratings.values()) if (rating === 'bad') bad += 1;
82
+ const turns = pairs.filter((pair) => pair.kind === 'turn').length;
83
+ const delegated = pairs.length - turns;
84
+ const good = pairs.filter((pair) => pair.rating === 'good').length;
85
+ const total = pairs.length + notes.length;
86
+ return { pairs: total, turns, delegated, notes: notes.length, good, bad, target, ready: total >= target };
87
+ }
88
+
89
+ // Distilled notes become recall pairs: "what does the room remember about …" → the note.
90
+ export function pairsFromNotes(notes, { project = 'project', home, user, privacy = null } = {}) {
91
+ const clean = guard(privacy);
92
+ return notes.map((note) => ({
93
+ kind: 'note',
94
+ agent: 'madre',
95
+ mode: 1,
96
+ sequence: note.throughSequence,
97
+ at: note.created,
98
+ messages: [
99
+ { role: 'system', content: `You are @madre, the memory of the MADRE room of the project "${project}". Answer only from what the room decided and recorded.` },
100
+ { role: 'user', content: `What does the room remember about this? Kind: ${note.kind}. Topic: ${clean(redact(note.text, { home, user })).split(/[.;:]/)[0].slice(0, 80)}` },
101
+ { role: 'assistant', content: `${clean(redact(note.text, { home, user }))} [#${note.fromSequence}${note.throughSequence !== note.fromSequence ? `–#${note.throughSequence}` : ''}]` },
102
+ ],
103
+ }));
104
+ }
105
+
106
+ // Deterministic split: the sequence decides, so re-exports keep lines on the same side.
107
+ export function split(pairs, { validEvery = 10 } = {}) {
108
+ const train = [];
109
+ const valid = [];
110
+ for (const pair of pairs) (pair.sequence % validEvery === 0 ? valid : train).push(pair);
111
+ if (!valid.length && train.length > 1) valid.push(train.pop());
112
+ return { train, valid };
113
+ }
114
+
115
+ export async function exportDataset({ events, notes = [], dir, project = 'project', home, user, privacy = null }) {
116
+ const pairs = [...pairsFromEvents(events, { project, home, user, privacy }), ...pairsFromNotes(notes, { project, home, user, privacy })].sort((a, b) => a.sequence - b.sequence);
117
+ const { train, valid } = split(pairs);
118
+ await mkdir(dir, { recursive: true });
119
+ const line = (pair) => JSON.stringify({ messages: pair.messages });
120
+ await writeFile(join(dir, 'train.jsonl'), train.map(line).join('\n') + (train.length ? '\n' : ''));
121
+ await writeFile(join(dir, 'valid.jsonl'), valid.map(line).join('\n') + (valid.length ? '\n' : ''));
122
+ const byAgent = {};
123
+ for (const pair of pairs) byAgent[pair.agent] = (byAgent[pair.agent] ?? 0) + 1;
124
+ const manifest = { project, exportedAt: new Date().toISOString(), pairs: pairs.length, turns: pairs.filter((p) => p.kind === 'turn').length, delegated: pairs.filter((p) => p.kind === 'delegated').length, good: pairs.filter((p) => p.rating === 'good').length, notes: pairs.filter((p) => p.kind === 'note').length, train: train.length, valid: valid.length, byAgent, files: ['train.jsonl', 'valid.jsonl'], format: 'chat · {"messages":[{role,content}]} · mlx-lm / llama-factory / axolotl' };
125
+ await writeFile(join(dir, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`);
126
+ return { dir, ...manifest };
127
+ }
package/src/distiller.mjs CHANGED
@@ -6,7 +6,9 @@
6
6
  import { MEMORY_KINDS } from './memory.mjs';
7
7
 
8
8
  // Cheapest first, by the models these CLIs run by default.
9
- export const DISTILLER_ORDER = ['gemini', 'opencode', 'codex', 'claude'];
9
+ // Ollama, when the room has it, is the cheapest of all: local and free.
10
+ export const DISTILLER_ORDER = ['ollama', 'gemini', 'opencode', 'codex', 'claude'];
11
+ export const OLLAMA_ARCHIVIST = { id: 'ollama', label: 'Ollama', detected: true, ready: true, adapter: 'ollama', path: null, version: null, local: true };
10
12
  export const MAX_MEMORIES_PER_RUN = 5;
11
13
  export const MAX_MEMORY_CHARS = 240;
12
14
 
@@ -26,7 +28,7 @@ export function pickDistiller(agents, { preferred = null, busy = new Set(), invo
26
28
  return agents.find(usable) ?? null;
27
29
  }
28
30
 
29
- export function distillPrompt({ entries, projectName = 'the project', existing = [] }) {
31
+ export function distillPrompt({ entries, projectName = 'the project', existing = [], json = false }) {
30
32
  const transcript = entries.map((entry) => {
31
33
  const who = entry.role === 'command' ? entry.sender : `@${entry.sender}`;
32
34
  const to = entry.target && entry.target !== 'room' ? ` → ${entry.target === 'you' ? 'human' : `@${entry.target}`}` : '';
@@ -41,9 +43,11 @@ export function distillPrompt({ entries, projectName = 'the project', existing =
41
43
  `- preference: how the human wants things done or said`,
42
44
  `- question: something left open that a later turn should not forget`,
43
45
  'Skip greetings, restatements, transient status, anything a reader of the code would see anyway, and anything the exchanges do not actually establish.',
44
- `Output only JSON lines, one object per memory, at most ${MAX_MEMORIES_PER_RUN}, nothing else:`,
45
- `{"kind":"decision|fact|preference|question","text":"one self-contained sentence in the language the room uses, at most ${MAX_MEMORY_CHARS} characters, naming files, agents and numbers exactly","sources":[sequence numbers it comes from]}`,
46
- 'If nothing durable was said, output exactly: NONE',
46
+ json
47
+ ? `Output one JSON object and nothing else: {"memories":[...]} with at most ${MAX_MEMORIES_PER_RUN} items, each {"kind":"decision|fact|preference|question","text":"one self-contained sentence in the language the room uses, at most ${MAX_MEMORY_CHARS} characters, naming files, agents and numbers exactly","sources":[sequence numbers it comes from]}. If nothing durable was said: {"memories":[]}`
48
+ : `Output only JSON lines, one object per memory, at most ${MAX_MEMORIES_PER_RUN}, nothing else:`,
49
+ json ? null : `{"kind":"decision|fact|preference|question","text":"one self-contained sentence in the language the room uses, at most ${MAX_MEMORY_CHARS} characters, naming files, agents and numbers exactly","sources":[sequence numbers it comes from]}`,
50
+ json ? null : 'If nothing durable was said, output exactly: NONE',
47
51
  known,
48
52
  '<exchanges>',
49
53
  transcript,
@@ -56,7 +60,13 @@ export function distillPrompt({ entries, projectName = 'the project', existing =
56
60
  export function parseDistillation(text, { fromSequence, throughSequence } = {}) {
57
61
  const memories = [];
58
62
  const seen = new Set();
59
- for (const raw of String(text ?? '').split(/\r?\n/)) {
63
+ // A whole JSON object with a memories array (what local models return under format=json) is unrolled into lines.
64
+ let source = String(text ?? '');
65
+ const object = source.trim().match(/^\{[\s\S]*\}$/);
66
+ if (object) {
67
+ try { const parsed = JSON.parse(object[0]); if (Array.isArray(parsed?.memories)) source = parsed.memories.map((item) => JSON.stringify(item)).join('\n'); } catch { /* fall through: line by line */ }
68
+ }
69
+ for (const raw of source.split(/\r?\n/)) {
60
70
  const line = raw.trim().replace(/^```(?:json)?$|^```$/g, '').replace(/^[-*]\s+/, '');
61
71
  const start = line.indexOf('{');
62
72
  const end = line.lastIndexOf('}');
@@ -68,11 +68,17 @@ async function geminiEmbed(texts, { key, model, dims, query, fetchImpl, timeoutM
68
68
  }
69
69
 
70
70
  // The embedder the memory uses: { model, dims, embed(texts, { query }) } or null when off.
71
- export function createEmbedder({ key = null, env = process.env, fetchImpl = globalThis.fetch, timeoutMs = Number(env.PULSE_EMBED_TIMEOUT_MS ?? 8000) } = {}) {
71
+ // Which embedder: PULSE_EMBED_PROVIDER=ollama|gemini|auto. Auto prefers Ollama
72
+ // when it is running with an embedding model (local, free, private), then Gemini
73
+ // when there is a key, then none.
74
+ export function createEmbedder({ key = null, env = process.env, fetchImpl = globalThis.fetch, timeoutMs = Number(env.PULSE_EMBED_TIMEOUT_MS ?? 8000), ollama = null, ollamaEmbedder: makeOllama = null } = {}) {
72
75
  if (env.PULSE_EMBED === '0') return null;
73
76
  if (env.PULSE_EMBED_FAKE === '1') {
74
77
  return { model: 'fake-64', dims: 64, embed: async (texts) => texts.map((text) => fakeEmbedding(text)) };
75
78
  }
79
+ const provider = env.PULSE_EMBED_PROVIDER ?? 'auto';
80
+ if (provider !== 'gemini' && ollama?.running && ollama.embedModel && makeOllama) return makeOllama({ host: ollama.host, model: ollama.embedModel, fetchImpl });
81
+ if (provider === 'ollama') return null;
76
82
  if (!key) return null;
77
83
  const model = env.PULSE_EMBED_MODEL ?? DEFAULT_EMBED_MODEL;
78
84
  const dims = Number(env.PULSE_EMBED_DIMS ?? DEFAULT_EMBED_DIMS);