openvibers/openvibe.codes Commits

Memory M2b: the Memory page, what a person's own assistants know about them (#196)

OP OpenVibers committed 13 days ago · 7e30445381 · 10 files, +996 −11

* Memory M2b: the Memory page, what a person's own assistants know about them

* Memory M2b review: quiet Remove links, a phone sources fold, an in-place note menu

- The Remove on a source is a muted 44px link; a phone shows the three most recent sources and a plain details for the rest, while a desktop shows them all.
- The tab panels repeat no tab heading; the search label is visually hidden.
- A note's menu opens in place, full width and opaque, pushing the rows below down; Delete is the danger style, last.
- Filled buttons use the on-accent and danger tokens: on-accent 6.38 light / 10.06 dark, danger 6.54 / 8.99.
- The CSS budget rises to 70 KB raw / 15 KB brotli (69.3 / 14.2 measured).

Changes

ddiff --git a/public/css/c4.css b/public/css/c4.css
iindex 2c2614e..dd47074 100644
---- a/public/css/c4.css
++++ b/public/css/c4.css
@@@ -1,4 +1,4 @@
--.c4{--c4-bg:#f7f8fa;--c4-surface:#fff;--c4-ink:#18212b;--c4-muted:#687583;--c4-line:#e1e6eb;--c4-accent:#176b5b;--c4-dot:#98a5aa;--c4-dot-ok:#69a24f;--c4-dot-bad:#ce7441;--c4-dot-skip:#b3bcc1;--c4-dot-open:#179579;--c4-ok-bg:#edf6e9;--c4-ok-ink:#47713d;--c4-bad-bg:#fff0e9;--c4-bad-ink:#995128;--c4-bad-line:#e7c4b2;--c4-skip-bg:#f0f2f3;--c4-skip-ink:#5b6870;--c4-open-bg:#e9f4f1;--c4-open-ink:#176b5b;--c4-quiet-bg:#eef2f3;--c4-quiet-ink:#4f5d67;--c4-quiet:#f3f5f6;--c4-ghost:#f7f8f9;color:var(--c4-ink);max-width:1540px;margin:0 auto;padding:0 32px 44px}
++.c4{--c4-bg:#f7f8fa;--c4-surface:#fff;--c4-ink:#18212b;--c4-muted:#687583;--c4-line:#e1e6eb;--c4-accent:#176b5b;--c4-on-accent:#fff;--c4-dot:#98a5aa;--c4-dot-ok:#69a24f;--c4-dot-bad:#ce7441;--c4-dot-skip:#b3bcc1;--c4-dot-open:#179579;--c4-ok-bg:#edf6e9;--c4-ok-ink:#47713d;--c4-bad-bg:#fff0e9;--c4-bad-ink:#995128;--c4-bad-line:#e7c4b2;--c4-danger:#b3261e;--c4-on-danger:#fff;--c4-skip-bg:#f0f2f3;--c4-skip-ink:#5b6870;--c4-open-bg:#e9f4f1;--c4-open-ink:#176b5b;--c4-quiet-bg:#eef2f3;--c4-quiet-ink:#4f5d67;--c4-quiet:#f3f5f6;--c4-ghost:#f7f8f9;color:var(--c4-ink);max-width:1540px;margin:0 auto;padding:0 32px 44px}
/* One navigation per page: the shared Frame navbar carries the workspace's sections, so the page has no sidebar
of its own (openvibe.design/patterns/single-navbar). */
.c4-main{min-width:0;max-width:1200px;width:100%;padding-top:34px}
@@@ -22,7 +22,7 @@
.c4-model-other[hidden]{display:none}
.c4-controls label{display:grid;gap:5px;font-size:11px;color:var(--c4-muted);min-width:120px}
.c4-controls select,.c4-controls input,.c4-search input{background:#fff;border:1px solid var(--c4-line);border-radius:7px;padding:8px;color:var(--c4-ink);font:inherit;font-size:13px;min-height:44px}
--.c4-button{border:0;border-radius:8px;background:var(--c4-accent);color:#fff;padding:10px 16px;font:inherit;font-size:13px;font-weight:650;cursor:pointer;text-decoration:none;display:inline-flex;align-items:center;justify-content:center;min-height:44px}
++.c4-button{border:0;border-radius:8px;background:var(--c4-accent);color:var(--c4-on-accent);padding:10px 16px;font:inherit;font-size:13px;font-weight:650;cursor:pointer;text-decoration:none;display:inline-flex;align-items:center;justify-content:center;min-height:44px}
.c4-button-light{background:#f3f5f6;color:var(--c4-ink);border:1px solid var(--c4-line)}
.c4-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}
.c4-empty,.c4-emptyline{color:var(--c4-muted);font-size:13px;line-height:1.55}
@@@ -37,20 +37,20 @@
.c4-running,.c4-queued{background:#e9f4f1;color:#176b5b}.c4-failed{background:#fff0e9;color:#995128}.c4-done,.c4-succeeded{background:#edf6e9;color:#47713d}.c4-cancelled{background:#f0eff5;color:#635a7a}
.c4-reasons{padding:18px 2px}.c4-reasons>h2{font-size:17px}.c4-reasons article{border-top:1px solid var(--c4-line);padding-top:12px}.c4-reasons h3{font-size:14px;margin:0 0 6px}.c4-reasons p{font-size:12px;line-height:1.5;color:var(--c4-muted);margin:0}
.c4-search{display:flex;align-items:end;gap:8px;margin:0 0 14px}.c4-search label{font-size:12px;color:var(--c4-muted)}.c4-search input{min-width:0;flex:1}
--.c4-outcome{display:grid;grid-template-columns:30px minmax(0,1fr) auto;align-items:center;gap:10px;margin:0 0 16px}.c4-obadge{width:30px;height:30px;border-radius:50%;display:grid;place-items:center;background:#fff0e9;color:#995128}.c4-obadge svg{width:15px;height:15px}.c4-outcome-copy{min-width:0;display:grid;gap:2px}.c4-outcome-copy>b{font-size:13px}.c4-outcome-copy>span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--c4-muted);font-size:12px}.c4-outcome-actions{display:flex;justify-content:flex-end;gap:6px;flex-wrap:wrap}.c4-opill{display:inline-flex;align-items:center;justify-content:center;gap:6px;min-height:44px;padding:0 14px 0 12px;border:1px solid var(--c4-line);border-radius:99px;background:var(--c4-surface);color:var(--c4-ink);font-size:12px;font-weight:650;text-decoration:none;white-space:nowrap}.c4-opill svg{width:15px;height:15px}.c4-opill-primary{background:var(--c4-accent);border-color:var(--c4-accent);color:#fff}.c4-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:14px}
++.c4-outcome{display:grid;grid-template-columns:30px minmax(0,1fr) auto;align-items:center;gap:10px;margin:0 0 16px}.c4-obadge{width:30px;height:30px;border-radius:50%;display:grid;place-items:center;background:#fff0e9;color:#995128}.c4-obadge svg{width:15px;height:15px}.c4-outcome-copy{min-width:0;display:grid;gap:2px}.c4-outcome-copy>b{font-size:13px}.c4-outcome-copy>span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--c4-muted);font-size:12px}.c4-outcome-actions{display:flex;justify-content:flex-end;gap:6px;flex-wrap:wrap}.c4-opill{display:inline-flex;align-items:center;justify-content:center;gap:6px;min-height:44px;padding:0 14px 0 12px;border:1px solid var(--c4-line);border-radius:99px;background:var(--c4-surface);color:var(--c4-ink);font-size:12px;font-weight:650;text-decoration:none;white-space:nowrap}.c4-opill svg{width:15px;height:15px}.c4-opill-primary{background:var(--c4-accent);border-color:var(--c4-accent);color:var(--c4-on-accent)}.c4-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:14px}
.c4-task-detail{padding:24px}.c4-task-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:16px}.c4-task-title-wrap{min-width:0;flex:1}.c4-brief{font-size:clamp(24px,3vw,34px);letter-spacing:-.04em;line-height:1.18;margin:0;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;overflow-wrap:anywhere}.c4-brief.is-expanded{-webkit-line-clamp:unset}.c4-brief-expand{background:none;border:0;color:var(--c4-accent);padding:9px 0;cursor:pointer;font:inherit;font-size:13px;min-height:44px}.c4-chips{display:flex;flex-wrap:wrap;gap:7px;margin:16px 0}.c4-chip{display:inline-flex;align-items:center;min-height:30px;padding:5px 10px;border:1px solid var(--c4-line);border-radius:18px;color:var(--c4-muted);font-size:12px;text-decoration:none}.c4-chip[href]{color:var(--c4-accent)}
.c4-progress-toggle{width:100%;display:flex;align-items:center;gap:9px;text-align:left;border:0;border-radius:9px;background:#edf5f2;color:var(--c4-ink);padding:12px 14px;min-height:46px;font:inherit;font-size:13px;cursor:pointer}.c4-progress-meta{color:var(--c4-muted);font-size:12px;margin-left:auto}.c4-tabs{display:flex;gap:5px;border-bottom:1px solid var(--c4-line);margin:16px 0 0;overflow-x:auto}.c4-tabs a{padding:11px 12px;color:var(--c4-muted);text-decoration:none;white-space:nowrap;border-bottom:2px solid transparent;font-size:13px;min-height:44px}.c4-tabs a[aria-selected=true]{color:var(--c4-accent);border-color:var(--c4-accent);font-weight:650}.c4-panel{padding:14px 0 4px;min-height:58px}.c4-panel[hidden]{display:none}.c4-panel p{font-size:13px;color:var(--c4-muted)}.c4-log{white-space:pre-wrap;overflow-wrap:anywhere;max-height:360px;overflow:auto;background:#f6f8f8;border-radius:8px;padding:12px;font-size:12px;color:#314047}.c4-code{white-space:pre-wrap;overflow-wrap:anywhere;background:#f5f7f7;padding:12px;border-radius:8px;font-size:12px}
.c4-settings{display:grid;gap:12px;font-size:13px}.c4-safety{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;margin-top:16px}.c4-safety-card{display:flex;align-items:flex-start;gap:10px;border:1px solid var(--c4-line);border-radius:10px;padding:14px;cursor:pointer;min-height:90px}.c4-safety-card:has(input:checked){border-color:var(--c4-accent);box-shadow:0 0 0 1px var(--c4-accent)}.c4-safety-card input{margin-top:3px;accent-color:var(--c4-accent)}.c4-safety-card span{display:grid;gap:6px}.c4-safety-card small{color:var(--c4-muted);font-size:12px;line-height:1.4}
.c4 a:focus-visible,.c4 button:focus-visible,.c4 input:focus-visible,.c4 textarea:focus-visible,.c4 select:focus-visible{outline:2px solid #188c75;outline-offset:2px}
@media(max-width:900px){.c4{padding-left:22px;padding-right:22px}}
@media(max-width:760px){.c4{padding:0 15px 24px}.c4-main{padding-top:24px}.c4-title{margin-bottom:16px}.c4-card{padding:16px;border-radius:11px}.c4-grid{grid-template-columns:1fr;gap:0}.c4-controls{display:grid;grid-template-columns:1fr 1fr}.c4-controls label{min-width:0}.c4-controls select,.c4-controls input{width:100%}.c4-controls .c4-button{grid-column:1/-1}.c4-task-detail{padding:17px}.c4-task-heading{gap:8px}.c4-task-heading>.c4-status{flex:none}.c4-chips{gap:6px}.c4-chip{font-size:11px;padding:5px 8px}.c4-outcome{grid-template-columns:30px minmax(0,1fr);gap:8px}.c4-outcome-actions{grid-column:1/-1;justify-content:flex-start;padding-left:38px}.c4-opill{min-height:44px;padding:0 11px;font-size:12px}.c4-search{align-items:stretch;flex-wrap:wrap}.c4-search label{width:100%}.c4-safety{grid-template-columns:1fr}.c4-safety-card{min-height:68px}.c4-progress-toggle{flex-wrap:wrap}.c4-progress-meta{margin-left:0}.c4-task-meta{font-size:10px}}
--:root[data-theme-mode="dark"] .c4{--c4-bg:#101518;--c4-surface:#171f23;--c4-ink:#eaf0f1;--c4-muted:#a3b0b4;--c4-line:#2d393d;--c4-accent:#75d0ba;--c4-dot:#5b6870;--c4-dot-ok:#afd39c;--c4-dot-bad:#f0b18c;--c4-dot-skip:#5b6870;--c4-dot-open:#75d0ba;--c4-ok-bg:#243423;--c4-ok-ink:#afd39c;--c4-bad-bg:#39291f;--c4-bad-ink:#f0b18c;--c4-bad-line:#5a4034;--c4-skip-bg:#263034;--c4-skip-ink:#cad4d6;--c4-open-bg:#1d3831;--c4-open-ink:#75d0ba;--c4-quiet-bg:#20292d;--c4-quiet-ink:#cad4d6;--c4-quiet:#20292d;--c4-ghost:#11181b}
++:root[data-theme-mode="dark"] .c4{--c4-bg:#101518;--c4-surface:#171f23;--c4-ink:#eaf0f1;--c4-muted:#a3b0b4;--c4-line:#2d393d;--c4-accent:#75d0ba;--c4-on-accent:#101518;--c4-dot:#5b6870;--c4-dot-ok:#afd39c;--c4-dot-bad:#f0b18c;--c4-dot-skip:#5b6870;--c4-dot-open:#75d0ba;--c4-ok-bg:#243423;--c4-ok-ink:#afd39c;--c4-bad-bg:#39291f;--c4-bad-ink:#f0b18c;--c4-bad-line:#5a4034;--c4-danger:#ff9a8f;--c4-on-danger:#101518;--c4-skip-bg:#263034;--c4-skip-ink:#cad4d6;--c4-open-bg:#1d3831;--c4-open-ink:#75d0ba;--c4-quiet-bg:#20292d;--c4-quiet-ink:#cad4d6;--c4-quiet:#20292d;--c4-ghost:#11181b}
:root[data-theme-mode="dark"] .c4-composer{background:linear-gradient(140deg,#171f23,#182722);border-color:#315148}:root[data-theme-mode="dark"] .c4 textarea,:root[data-theme-mode="dark"] .c4 select,:root[data-theme-mode="dark"] .c4 input{background:#11181b;color:var(--c4-ink)}
:root[data-theme-mode="dark"] .c4-log,:root[data-theme-mode="dark"] .c4-code{background:#11181b;color:#d3dfdf}:root[data-theme-mode="dark"] .c4-button-light{background:#20292d;color:var(--c4-ink)}:root[data-theme-mode="dark"] .c4-obadge{background:#39291f;color:#f0b18c}:root[data-theme-mode="dark"] .c4-progress-toggle{background:#1d3831;color:var(--c4-ink)}
:root[data-theme-mode="dark"] .c4-status{background:#263034;color:#cad4d6}:root[data-theme-mode="dark"] .c4-running,:root[data-theme-mode="dark"] .c4-queued{background:#1d3831;color:#75d0ba}:root[data-theme-mode="dark"] .c4-failed{background:#39291f;color:#f0b18c}:root[data-theme-mode="dark"] .c4-done,:root[data-theme-mode="dark"] .c4-succeeded{background:#243423;color:#afd39c}
.c4-progress-content{grid-template-rows:0fr;opacity:0;overflow:hidden;transition:grid-template-rows .24s ease,opacity .24s ease}.c4-progress-content.is-open,.c4-progress-content[data-live="false"]{grid-template-rows:1fr;opacity:1}.c4-progress-inner{min-height:0;overflow:hidden}.c4-usage-row{grid-template-columns:minmax(0,1fr) auto}.c4-usage-row .c4-task-title,.c4-usage-row .c4-task-meta{grid-column:1}.c4-usage-row>b{grid-column:2;grid-row:1/3;align-self:center}
.page:has(.c4){width:100%;max-width:none;padding:0;min-height:0}.c4-attempts{display:grid;gap:6px;margin:10px 0;padding:0;list-style:none}.c4-attempt:before{content:"";display:inline-block;width:7px;height:7px;margin:0 8px 1px 1px;border-radius:50%;background:var(--c4-accent)}.c4-attempt{color:var(--c4-muted);font-size:12px}.c4-log[hidden]{display:none}
--.c4-card .c4-outcome-actions a.c4-opill{color:var(--c4-ink)}.c4-card .c4-outcome-actions a.c4-opill-primary{color:#fff}
++.c4-card .c4-outcome-actions a.c4-opill{color:var(--c4-ink)}.c4-card .c4-outcome-actions a.c4-opill-primary{color:var(--c4-on-accent)}
/* C4b: the row pattern (one calm row, pill actions), the fold-out panels, and the forms that remember choices. */
.c4-rows{display:grid;gap:0;list-style:none;margin:6px 0 0;padding:0}
@@@ -130,7 +130,7 @@
.c4-saved{margin:10px 0 0}
.c4-saved .c4-chip svg{width:14px;height:14px;margin-right:5px}
.c4-card a.c4-opill,.c4-card a.c4-opill-quiet{color:var(--c4-ink)}
--.c4-card a.c4-opill-primary{color:#fff}
++.c4-card a.c4-opill-primary{color:var(--c4-on-accent)}
:root[data-theme-mode="dark"] .c4-shot-ghost,:root[data-theme-mode="dark"] .c4-diff,:root[data-theme-mode="dark"] .c4-kv code,:root[data-theme-mode="dark"] .c4-code-inline{background:#11181b;color:#d3dfdf}
:root[data-theme-mode="dark"] .c4-opill-quiet{background:#20292d;color:var(--c4-ink)}
:root[data-theme-mode="dark"] .c4-form input,:root[data-theme-mode="dark"] .c4-form select{background:#11181b;color:var(--c4-ink)}
@@@ -228,4 +228,62 @@
/* The season's leaves fall from the Frame's mark over the page (openvibe-shared/ov-mark.js, .ovm-fall, a fixed layer at
z-index 10010, 120-280 px down). The page's own title stays above them: a once-a-day decoration may pass behind a
heading, never across it. Reported to Shared so every site's heading is clear of the fall. */
--.c4 h1{position:relative;z-index:10011}
\\ No newline at end of file
++.c4 h1{position:relative;z-index:10011}
++
++/* The Memory page (OpenVibe Memory M2b): the sources, the notes, their menus and the tabs that choose a view. */
++.c4-tabs a[aria-current=page]{color:var(--c4-accent);border-color:var(--c4-accent);font-weight:650}
++.c4-chip[aria-current=page]{background:var(--c4-quiet-bg);color:var(--c4-ink)}
++.c4-sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}
++.c4-search{position:relative}
++/* A quiet link: the Remove on a source row. No border, no fill, the accent only when it is pointed at. */
++.c4-quiet-link{color:var(--c4-muted);text-decoration:none;font-size:12px;font-weight:650;background:none;border:0;padding:11px 4px;min-height:44px;display:inline-flex;align-items:center;justify-content:center;cursor:pointer;font-family:inherit}
++/* .c4-card a (the card's own link colour) is more specific than the quiet link, so name the card here too. */
++.c4-card a.c4-quiet-link{color:var(--c4-muted);font-size:12px}
++.c4-card a.c4-quiet-link:hover,.c4-card a.c4-quiet-link:focus-visible{color:var(--c4-accent)}
++.c4-source{grid-template-columns:12px minmax(0,1fr) auto}
++.c4-source > .c4-quiet-link{grid-column:3;grid-row:1/3;justify-self:end;align-self:center}
++/* The sources a phone shows first, and the rest behind a details the person opens (no JavaScript). */
++.c4-more-row{border-top:1px solid var(--c4-line)}
++.c4-more > summary{cursor:pointer;list-style:none;display:flex;align-items:center;gap:6px;min-height:44px;padding:6px 0;color:var(--c4-muted);font-size:12px;font-weight:650}
++.c4-more > summary::-webkit-details-marker{display:none}
++.c4-more > summary::marker{content:""}
++.c4-more > summary:hover,.c4-more > summary:focus-visible{color:var(--c4-accent)}
++.c4-more .c4-rows{padding-left:0}
++.c4-more .c4-row:first-child{border-top:0}
++@media(min-width:640px){
++ .c4-more > summary{display:none}
++ .c4-more:not([open]) > .c4-more-body{display:block}
++ .c4-more::details-content{content-visibility:visible}
++}
++/* A note: its text, what it is, and the menu that changes it. The menu opens in place and pushes the rows below down. */
++.c4-note{position:relative;padding:12px 0;border-top:1px solid var(--c4-line);min-width:0}
++.c4-rows > .c4-note:first-child{border-top:0;padding-top:2px}
++.c4-note-text{margin:0;padding-right:48px;font-size:13px;line-height:1.5;color:var(--c4-ink);min-width:0;overflow-wrap:anywhere}
++.c4-note-meta{margin:4px 0 0;padding-right:48px;font-size:12px;line-height:1.45;color:var(--c4-muted);display:flex;flex-wrap:wrap;gap:5px;align-items:center}
++.c4-note > .c4-actions{margin-top:10px}
++.c4-menu{margin:0}
++.c4-menu > summary{position:absolute;top:8px;right:0;width:44px;height:44px;list-style:none;cursor:pointer;display:flex;align-items:center;justify-content:center;border-radius:8px;color:var(--c4-muted);font-size:15px;line-height:1}
++.c4-menu > summary::-webkit-details-marker{display:none}
++.c4-menu > summary::marker{content:""}
++.c4-menu > summary:hover,.c4-menu > summary:focus-visible{color:var(--c4-accent)}
++.c4-menu-body{margin-top:8px;background:var(--c4-surface);border:1px solid var(--c4-line);border-radius:10px;padding:12px;display:grid;gap:8px}
++.c4-menu-body .c4-form{margin-top:0}
++.c4-menu-body .c4-button{justify-self:start}
++.c4-menu-body .c4-opill{width:100%;justify-content:flex-start}
++.c4-menu-body .c4-note-danger{margin-top:8px;padding-top:12px;border-top:1px solid var(--c4-line)}
++.c4-form textarea{background:#fff;border:1px solid var(--c4-line);border-radius:7px;padding:9px;color:var(--c4-ink);font:inherit;font-size:13px;width:100%;min-height:76px;min-width:0;resize:vertical}
++.c4-profile-text{margin:0;font-size:13px;line-height:1.55;color:var(--c4-ink);overflow-wrap:anywhere}
++.c4-conflict-pair{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}
++.c4-conflict-side{display:grid;gap:8px;align-content:start;padding:12px;border:1px solid var(--c4-line);border-radius:10px;min-width:0}
++.c4-conflict-side form{margin:0}
++.c4-conflict-side .c4-opill{justify-self:start}
++/* The destructive actions: the kit's danger colour, with the text that reads on it in both themes. */
++.c4-button-danger{background:var(--c4-danger);color:var(--c4-on-danger)}
++.c4-opill-danger{background:var(--c4-danger);border-color:var(--c4-danger);color:var(--c4-on-danger)}
++.c4-card a.c4-opill-danger{color:var(--c4-on-danger)}
++@media(max-width:760px){
++ .c4-chip[href]{min-height:44px}
++ .c4-note-text{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden}
++ .c4-conflict-pair{grid-template-columns:1fr}
++}
++:root[data-theme-mode="dark"] .c4-form textarea{background:#11181b;color:var(--c4-ink)}
ddiff --git a/server/app.js b/server/app.js
iindex c7249c5..5ad469d 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -51,6 +51,7 @@ const { createCodesReadiness } = require('./observability');
const { createActorLimits } = require('./http/actor-limits');
const { createForgejoClient } = require('./domain/forgejo');
const { createMachines } = require('./domain/machines');
++const { createMemory } = require('./domain/memory');
const { createFleet } = require('./domain/fleet');
const { createNotices } = require('./domain/notices');
const { createUserSettings } = require('./domain/user-settings');
@@@ -99,6 +100,9 @@ async function createApp(opts = {}) {
ctx.forgejo = createForgejoClient({ config, log });
// The C4b screens: the person's machines (from Network), their own choices, the GitHub reader and their usage.
ctx.machines = createMachines({ config, fetchImpl: fetchImpl || globalThis.fetch, log });
++ // The person's own memory store (OpenVibe Memory M2b): the notes live in OpenVibe.AI, sealed there, and only the
++ // person's own token opens them. Codes forwards the token and renders the answer; it never holds a note.
++ ctx.memory = createMemory({ config, fetchImpl: fetchImpl || globalThis.fetch, log });
ctx.github = createGitHub({ config, store, fetchImpl: fetchImpl || globalThis.fetch, log });
ctx.usage = createUsage({ store });
// The conductor (Codes C9b) and its approvals inbox. The conductor starts Run jobs and reads their events through
ddiff --git a/server/domain/memory.js b/server/domain/memory.js
nnew file mode 100644
iindex 0000000..9b3b9f1
---- /dev/null
++++ b/server/domain/memory.js
@@@ -0,0 +1,114 @@
++'use strict';
++
++/**
++ * The person's own memory store, as OpenVibe.AI holds it (OpenVibe Memory M2b).
++ *
++ * The store lives in OpenVibe.AI (server/memory): a note belongs to one person, it is sealed there with
++ * AI_MEMORY_KEY and only that person's own token opens it. Codes never holds a note: it forwards the person's
++ * token to AI and renders what comes back. The token stays in this process — it is never written into a page,
++ * a log line or an error message, and no note text is ever logged.
++ *
++ * items(token, { scope, status, q }) the person's own items
++ * patch(token, id, body) text, kind, scope or status
++ * remove(token, id) one note, gone
++ * removeBySource(token, source) everything from one source, gone
++ * profile(token, scope) the summary of one scope
++ * reads(token, limit) who read the person's memory (never the text)
++ * split(token, id) undo a merge
++ * settle(token, id, keep) settle a conflict: one stays, the other goes
++ *
++ * Every call carries the person's own Bearer token. A refusal from AI arrives as an error with its problem
++ * code (memory.not_found, input.invalid, …) and its detail — never a note's text.
++ */
++
++const MEMORY_PATH = '/api/v1/memory';
++
++function createMemory({ config, fetchImpl = globalThis.fetch, log = console } = {}) {
++ // The store is reached on the loopback when Codes and AI share a host (the vault does the same).
++ const base = String((config.ai && (config.ai.internalUrl || config.ai.url)) || 'https://ai.openvibe.services').replace(/\/+$/, '');
++
++ async function call(token, method, path, body) {
++ if (!token) throw Object.assign(new Error('Not signed in'), { status: 401, code: 'memory.signed_out' });
++ let res;
++ try {
++ res = await fetchImpl(`${base}${path}`, {
++ method,
++ headers: {
++ Authorization: `Bearer ${token}`, Accept: 'application/json',
++ ...(body ? { 'Content-Type': 'application/json' } : {}),
++ },
++ ...(body ? { body: JSON.stringify(body) } : {}),
++ signal: AbortSignal.timeout(8000),
++ });
++ } catch (e) {
++ log.warn(`[Codes] the memory store did not answer: ${String(e && e.message).slice(0, 120)}`);
++ throw Object.assign(new Error('The memory store did not answer'), { status: 503, code: 'memory.unavailable' });
++ }
++ const doc = await res.json().catch(() => null);
++ if (!res.ok) {
++ const code = String((doc && doc.code) || 'memory.unavailable');
++ const detail = String((doc && (doc.detail || doc.title || doc.error_description)) || 'the memory store refused the change').slice(0, 200);
++ log.warn(`[Codes] the memory store refused a request: ${res.status} ${code}`);
++ throw Object.assign(new Error(detail), { status: res.status, code });
++ }
++ return doc || {};
++ }
++
++ /** The person's own items. `q` is a search over the text; the store answers with at most 200 items. */
++ async function items(token, { scope, status, q } = {}) {
++ const query = new URLSearchParams();
++ if (scope) query.set('scope', String(scope));
++ if (status) query.set('status', String(status));
++ if (q) query.set('q', String(q));
++ const suffix = query.toString() ? `?${query}` : '';
++ const doc = await call(token, 'GET', `${MEMORY_PATH}/items${suffix}`);
++ return { items: Array.isArray(doc.items) ? doc.items : [], count: Number(doc.count) || 0 };
++ }
++
++ /** One note, changed: its text, kind, scope or status. */
++ async function patch(token, id, body) {
++ const doc = await call(token, 'PATCH', `${MEMORY_PATH}/items/${encodeURIComponent(id)}`, body || {});
++ return { item: doc.item || null };
++ }
++
++ /** One note, gone. */
++ async function remove(token, id) {
++ const doc = await call(token, 'DELETE', `${MEMORY_PATH}/items/${encodeURIComponent(id)}`);
++ return { deleted: Number(doc.deleted) || 0 };
++ }
++
++ /** Everything from one source, gone: the store's own "delete means gone". */
++ async function removeBySource(token, source) {
++ const doc = await call(token, 'DELETE', `${MEMORY_PATH}/items?source=${encodeURIComponent(source)}`);
++ return { deleted: Number(doc.deleted) || 0 };
++ }
++
++ /** The summary of one scope: the profile the store builds from the person's own notes. */
++ async function profile(token, scope) {
++ const query = scope ? `?scope=${encodeURIComponent(scope)}` : '';
++ const doc = await call(token, 'GET', `${MEMORY_PATH}/profile${query}`);
++ return { profile: doc.profile || null };
++ }
++
++ /** Who read the person's memory: the agent's name, the purpose, the scopes and how many notes. Never text. */
++ async function reads(token, limit = 50) {
++ const doc = await call(token, 'GET', `${MEMORY_PATH}/reads?limit=${encodeURIComponent(String(limit || 50))}`);
++ return { reads: Array.isArray(doc.reads) ? doc.reads : [], count: Number(doc.count) || 0 };
++ }
++
++ /** Undo a merge: the note and the one it swallowed go apart again. */
++ async function split(token, id) {
++ const doc = await call(token, 'POST', `${MEMORY_PATH}/items/${encodeURIComponent(id)}/split`, {});
++ return { items: Array.isArray(doc.items) ? doc.items : [], count: Number(doc.count) || 0, split: Number(doc.split) || 0 };
++ }
++
++ /** Settle a conflict: the note that stays, and the one that goes. */
++ async function settle(token, id, keep) {
++ const doc = await call(token, 'POST', `${MEMORY_PATH}/items/${encodeURIComponent(id)}/settle`, { keep: String(keep || '') });
++ return { kept: doc.kept || null, archived: doc.archived || null };
++ }
++
++ return { items, patch, remove, removeBySource, profile, reads, split, settle };
++}
++
++module.exports = { createMemory, MEMORY_PATH };
ddiff --git a/server/http/c4.js b/server/http/c4.js
iindex db88b52..8150cf3 100644
---- a/server/http/c4.js
++++ b/server/http/c4.js
@@@ -9,6 +9,7 @@
* /agents the person's connections: their provider keys (they live in OpenVibe.AI), added, tested, removed
* /machines their paired Node machines, their state, and Node's pairing flow with a one-time code
* /usage what Codes knows: runs, minutes and tokens per day (no prices: Billing is Billing's)
++ * /memory what the person's own assistants know about them (the notes live in OpenVibe.AI's store)
* /settings their choices, remembered per person (codes_user_settings)
*
* Every write is a form post from this site (Origin checked), every read is the signed-in person's own.
@@@ -731,6 +732,10 @@ ${report.days.length ? `<div class="table-wrap" tabindex="0" role="region" aria-
}
});
++ // The Memory page (OpenVibe Memory M2b): the person's own notes, in Codes' own kit. The routes live in their own
++ // module and borrow this one's kit (the page, the frame, the card, the pill, the flash and the same-origin check).
++ r.use(require('./memory').createMemoryRoutes(ctx, { page, wrap, header, pill, flash, sameOrigin, viewer, form, signIn, note }));
++
return r;
}
ddiff --git a/server/http/memory.js b/server/http/memory.js
nnew file mode 100644
iindex 0000000..62b5714
---- /dev/null
++++ b/server/http/memory.js
@@@ -0,0 +1,510 @@
++'use strict';
++
++/**
++ * The Memory page (OpenVibe Memory M2b): what the person's own assistants know about them.
++ *
++ * The notes live in OpenVibe.AI's memory store, sealed there and opened only by the person's own token. This
++ * page never holds a note: it forwards the person's token to the store (server/domain/memory.js), renders what
++ * comes back and forgets it. The token is never written into the page, and no note text is ever logged.
++ *
++ * GET /memory the page: the sources, then one tab (notes, disagree, suggested, profile, reads)
++ * GET /memory/remove what one source's removal would take away, and the one form that does it
++ * POST /memory/remove everything from one source, gone (the store's own delete)
++ * POST /memory/items/:id/edit the note's text and kind
++ * POST /memory/items/:id/archive the note, out of the way
++ * POST /memory/items/:id/split two notes that say the same thing, apart again
++ * POST /memory/items/:id/delete the note, gone
++ * POST /memory/items/:id/accept a suggestion, kept
++ * POST /memory/items/:id/decline a suggestion, let go
++ * POST /memory/conflicts/:id/settle the note of a pair that stays
++ *
++ * Every write is a same-origin form post and answers with a redirect back to the tab it came from, carrying a
++ * flash. The page is complete without JavaScript: the tabs are links, the menus are details elements, and the
++ * only script on the site is the one the other workspace pages already load.
++ */
++
++const { asyncRouter } = require('./router');
++const { esc } = require('../render/html');
++
++/** The adapter ids the Node reads and the services that write, and the names a person knows them by. */
++const SOURCE_NAMES = {
++ 'claude-code': 'Claude Code', agents: 'AGENTS.md', gemini: 'Gemini CLI', cursor: 'Cursor',
++ copilot: 'GitHub Copilot', windsurf: 'Windsurf', cline: 'Cline', roo: 'Roo Code', kiro: 'Kiro',
++ continue: 'Continue', aider: 'Aider', 'command-code': 'Command Code', opencode: 'OpenCode',
++ actor: 'Actor', vibe: 'Vibe', know: 'Know', person: 'You', codes: 'Codes', codex: 'Codex',
++};
++const sourceName = (id) => SOURCE_NAMES[String(id || '')] || String(id || '').replace(/[-_]+/g, ' ');
++
++/** The kinds the store knows, in a person's words. */
++const KINDS = ['preference', 'fact', 'project_rule', 'procedure', 'decision', 'lesson'];
++const kindName = (kind) => String(kind || '').replace(/_/g, ' ');
++
++/** A scope, in a person's words: "You" for their own, the project's name for a project's. */
++const scopeName = (scope) => {
++ const s = String(scope || 'user');
++ if (s === 'user') return 'You';
++ return s.startsWith('project:') ? s.slice('project:'.length) : s;
++};
++
++/** The day something happened: today, "Oct 30", or "Oct 30, 2025". */
++const dayOf = (value) => {
++ const t = Date.parse(String(value || ''));
++ if (!Number.isFinite(t)) return '';
++ const d = new Date(t);
++ const now = new Date();
++ if (d.toDateString() === now.toDateString()) return 'today';
++ const sameYear = d.getFullYear() === now.getFullYear();
++ return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + (sameYear ? '' : ', ' + d.getFullYear());
++};
++
++/** How long ago, in the page's own words: "2 h ago", "5 min ago", "3 d ago". */
++const agoOf = (value) => {
++ const ms = Date.parse(String(value || ''));
++ if (!Number.isFinite(ms)) return '';
++ const mins = Math.max(0, Math.floor((Date.now() - ms) / 60000));
++ if (mins < 1) return 'just now';
++ if (mins < 60) return mins + ' min ago';
++ if (mins < 1440) return Math.floor(mins / 60) + ' h ago';
++ return Math.floor(mins / 1440) + ' d ago';
++};
++
++const MEMORY_TABS = [
++ ['notes', 'Notes'], ['disagree', 'Disagree'], ['suggested', 'Suggested'],
++ ['profile', 'Profile'], ['reads', 'Who read it'],
++];
++
++/** What the store's refusals say to a person. The words are ours: no note text ever reaches a flash. */
++const PROBLEMS = {
++ 'memory.not_found': 'That note is gone.',
++ 'memory.not_split': 'That note was not merged with another one.',
++ 'memory.not_conflict': 'These two notes are not in conflict.',
++ 'memory.secret_only': 'That text is only a secret, so it was refused.',
++ 'input.invalid': 'That change was refused.',
++ 'memory.unreadable': 'That note could not be read.',
++ 'memory.unavailable': 'Your memory is not available right now.',
++ 'memory.signed_out': 'Sign in again to see your memory.',
++};
++const problemWord = (e) => PROBLEMS[String((e && e.code) || '')] || 'Your memory could not be changed. Try again in a moment.';
++
++/** The store's own ids: a note is a mem_ ULID. Anything else is not a note and never reaches the store. */
++const NOTE_ID = /^mem_[0-9A-HJKMNP-TV-Z]{26}$/;
++const SOURCE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,99}$/;
++
++const SEVEN_DAYS = 7 * 24 * 60 * 60 * 1000;
++
++function createMemoryRoutes(ctx, kit) {
++ const { page, wrap, header, pill, flash, sameOrigin, viewer, form, signIn } = kit;
++ const { sso, memory } = ctx;
++ const router = asyncRouter();
++
++ /** The person's own token, used for one call to the store. It never leaves this process. */
++ async function withToken(req, res, call) {
++ return await sso.asViewer(req, res, (token) => call(token));
++ }
++
++ /** Where a write goes back to: the tab it came from, with a flash. */
++ const back = (req, res, fallback, ok, error) => {
++ const asked = String((req.body && req.body.tab) || '');
++ const tab = MEMORY_TABS.some(([id]) => id === asked) ? asked : fallback;
++ const target = '/memory?tab=' + encodeURIComponent(tab);
++ if (error) return res.redirect(303, target + '&error=' + encodeURIComponent(String(error).slice(0, 180)));
++ return res.redirect(303, target + '&ok=' + encodeURIComponent(String(ok).slice(0, 140)));
++ };
++
++ /** The page. One call to the store holds every tab's data; only the open tab is built. */
++ async function memoryPage(req, res) {
++ const title = 'Memory';
++ const head = header(title, 'What your assistants know about you, in one place.');
++ if (!viewer(req)) return page(req, res, title, wrap(head + signIn(req)));
++ const asked = String(req.query.tab || '');
++ const tab = MEMORY_TABS.some(([id]) => id === asked) ? asked : 'notes';
++ const q = String(req.query.q || '').slice(0, 200);
++ const scope = String(req.query.scope || '').slice(0, 80);
++
++ let items = [];
++ let problem = null;
++ try {
++ const out = await withToken(req, res, (token) => memory.items(token, {}));
++ items = Array.isArray(out.items) ? out.items : [];
++ } catch (e) {
++ problem = e;
++ }
++
++ const byStatus = { active: [], suggested: [], archived: [], conflict: [] };
++ for (const item of items) {
++ const bucket = byStatus[item.status];
++ (bucket || byStatus.archived).push(item);
++ }
++ // The notes that swallowed another one: the store merges two notes that say the same thing and keeps
++ // the swallowed one archived with the winner's id in merged_into.
++ const merged = new Set(items.map((i) => i.merged_into).filter(Boolean));
++ const scopes = [...new Set(items.map((i) => i.scope).filter(Boolean))]
++ .sort((a, b) => (a === 'user' ? -1 : b === 'user' ? 1 : a.localeCompare(b)));
++ const pairs = conflictPairs(byStatus.conflict);
++ const sources = sourceRows(items);
++
++ const notes = byStatus.active.filter((i) => (!scope || i.scope === scope)
++ && (!q || String(i.text || '').toLowerCase().includes(q.toLowerCase())));
++
++ const tabs = '<nav class="c4-tabs" aria-label="Memory">' + MEMORY_TABS.map(([id, label]) => {
++ const count = id === 'notes' ? byStatus.active.length : id === 'disagree' ? pairs.length : id === 'suggested' ? byStatus.suggested.length : null;
++ const text = count == null ? label : label + ' (' + count + ')';
++ return '<a href="/memory?tab=' + id + '"' + (id === tab ? ' aria-current="page"' : '') + '>' + esc(text) + '</a>';
++ }).join('') + '</nav>';
++
++ let panel = '';
++ if (tab === 'notes') panel = notesPanel(notes, q, scope, scopes, merged);
++ else if (tab === 'disagree') panel = conflictPanel(pairs);
++ else if (tab === 'suggested') panel = suggestedPanel(byStatus.suggested);
++ else if (tab === 'profile') panel = await profilePanel(req, res, scopes, byStatus.active);
++ else if (tab === 'reads') panel = await readsPanel(req, res);
++
++ const content = head + flash(req)
++ + (problem ? note('Your memory could not be read right now. ' + problemWord(problem), 'bad') : '')
++ + sourcesPanel(sources) + tabs + panel;
++ return page(req, res, title, wrap(content));
++ }
++
++ /** The sources card: where the notes came from, how many there are and when they were last seen. */
++ function sourcesPanel(rows) {
++ const head = '<section class="c4-card"><div class="c4-card-head"><h2>Sources</h2><span class="c4-muted">'
++ + rows.length + (rows.length === 1 ? ' source' : ' sources') + '</span></div>';
++ if (!rows.length) {
++ return head + '<div class="c4-emptyline">Nothing here yet. On your computer, run '
++ + '<code class="c4-code-inline">openvibe-node memory upload</code>: it shows what it found and asks before sending anything. '
++ + '<a href="https://github.com/OpenVibers/OpenVibe.Node/blob/main/docs/memory.md#the-upload">The Node docs</a></div></section>';
++ }
++ const sourceRow = (row) => {
++ const fresh = row.seen && Date.now() - row.seen < SEVEN_DAYS;
++ const seen = row.seen ? agoOf(new Date(row.seen).toISOString()) : 'never';
++ return '<li class="c4-row c4-row-flat c4-source"><span class="c4-row-dot c4-dot-' + (fresh ? 'ok' : 'skip') + '" aria-hidden="true"></span>'
++ + '<span class="c4-row-label">' + esc(sourceName(row.id)) + '</span>'
++ + '<span class="c4-row-note">' + row.count + (row.count === 1 ? ' note' : ' notes') + ' · seen ' + esc(seen) + '</span>'
++ + '<a class="c4-quiet-link" href="/memory/remove?source=' + encodeURIComponent(row.id) + '">Remove…</a></li>';
++ };
++ // A phone shows the three most recent sources and the rest open behind a plain details; a desktop shows them all.
++ const first = rows.slice(0, 3);
++ const rest = rows.slice(3);
++ const fold = rest.length
++ ? '<li class="c4-more-row"><details class="c4-more"><summary>Show all ' + rows.length + ' sources</summary>'
++ + '<div class="c4-more-body"><ul class="c4-rows">' + rest.map(sourceRow).join('') + '</ul></div></details></li>'
++ : '';
++ return head + '<ul class="c4-rows">' + first.map(sourceRow).join('') + fold + '</ul></section>';
++ }
++
++ /** The notes tab: a search, the scopes, and one row per note. */
++ function notesPanel(notes, q, scope, scopes, merged) {
++ const chips = [['all', 'All'], ['user', 'You']]
++ .concat(scopes.filter((s) => s !== 'user').map((s) => [s, scopeName(s)]))
++ .map(([value, label]) => {
++ const here = value === 'all' ? !scope : scope === value;
++ const href = '/memory?tab=notes&scope=' + encodeURIComponent(value === 'all' ? '' : value)
++ + (q ? '&q=' + encodeURIComponent(q) : '');
++ return '<a class="c4-chip" href="' + href + '"' + (here ? ' aria-current="page"' : '') + '>' + esc(label) + '</a>';
++ }).join('');
++ const search = '<form class="c4-search" method="get" action="/memory"><input type="hidden" name="tab" value="notes">'
++ + '<label class="c4-sr-only" for="memory-search">Search your notes</label>'
++ + '<input id="memory-search" name="q" value="' + esc(q) + '" placeholder="Search your notes" maxlength="200">'
++ + '<button class="c4-button c4-button-light">Search</button></form>';
++ const body = notes.length
++ ? '<ul class="c4-rows">' + notes.map((item) => noteRow(item, merged.has(item.id))).join('') + '</ul>'
++ : '<div class="c4-emptyline">' + (q || scope ? 'No note matches. <a href="/memory?tab=notes">Show all</a>' : 'No notes yet.') + '</div>';
++ return '<section class="c4-card">'
++ + search + '<div class="c4-chips" aria-label="Scopes">' + chips + '</div>' + body
++ + '<p class="c4-hint">A note is one thing your tools remembered. Everything here is yours: edit it, archive it or delete it.</p></section>';
++ }
++
++ /** One note: its text, what it is, where it came from, and the menu that changes it. */
++ function noteRow(item, isMerged) {
++ const sources = Array.isArray(item.sources) && item.sources.length ? item.sources : [item.source];
++ const source = sources.length > 1 ? pill(sources.length + ' sources') : esc(sourceName(sources[0]));
++ const when = item.updated_at || item.created_at || '';
++ const meta = [esc(kindName(item.kind)), source, esc(scopeName(item.scope)),
++ '<time datetime="' + esc(when) + '">' + esc(dayOf(when)) + '</time>'].join(' · ');
++ const id = esc(item.id);
++ return '<li class="c4-note">'
++ + '<p class="c4-note-text">' + esc(item.text) + '</p>'
++ + '<p class="c4-note-meta">' + meta + '</p>'
++ + '<details class="c4-menu"><summary aria-label="More actions for this note">⋯</summary><div class="c4-menu-body">'
++ + '<form class="c4-form" method="post" action="/memory/items/' + id + '/edit"><input type="hidden" name="tab" value="notes">'
++ + '<label>Text<textarea name="text" rows="3" maxlength="2000">' + esc(item.text) + '</textarea></label>'
++ + '<label>Kind<select name="kind">' + KINDS.map((k) => '<option value="' + k + '"' + (k === item.kind ? ' selected' : '') + '>' + esc(kindName(k)) + '</option>').join('') + '</select></label>'
++ + '<button class="c4-button" type="submit">Save</button></form>'
++ + '<form method="post" action="/memory/items/' + id + '/archive"><input type="hidden" name="tab" value="notes"><button class="c4-opill c4-opill-quiet" type="submit">Archive</button></form>'
++ + (isMerged ? '<form method="post" action="/memory/items/' + id + '/split"><input type="hidden" name="tab" value="notes"><button class="c4-opill c4-opill-quiet" type="submit">Split</button></form>' : '')
++ + '<form class="c4-note-danger" method="post" action="/memory/items/' + id + '/delete"><input type="hidden" name="tab" value="notes"><button class="c4-opill c4-opill-danger" type="submit">Delete</button></form>'
++ + '</div></details></li>';
++ }
++
++ /** The pairs that disagree: each one a card with the two notes side by side. */
++ function conflictPanel(pairs) {
++ if (!pairs.length) {
++ return '<section class="c4-card c4-empty"><h2>Nothing disagrees</h2>'
++ + '<p>When two of your notes cannot both be true, they meet here and you choose.</p></section>';
++ }
++ return pairs.map((pair) => {
++ const sides = pair.map((item) => {
++ const other = pair.find((x) => x.id !== item.id);
++ const sources = Array.isArray(item.sources) && item.sources.length ? item.sources : [item.source];
++ const when = item.updated_at || item.created_at || '';
++ return '<div class="c4-conflict-side"><p class="c4-note-text">' + esc(item.text) + '</p>'
++ + '<p class="c4-note-meta">' + esc(sourceName(sources[0])) + ' · <time datetime="' + esc(when) + '">' + esc(dayOf(when)) + '</time></p>'
++ + '<form method="post" action="/memory/conflicts/' + esc(item.id) + '/settle"><input type="hidden" name="tab" value="disagree">'
++ + '<input type="hidden" name="keep" value="' + esc(other.id) + '">'
++ + '<button class="c4-opill c4-opill-primary" type="submit">Keep this one</button></form></div>';
++ }).join('');
++ return '<section class="c4-card c4-conflict"><div class="c4-card-head"><h2>These two disagree</h2></div>'
++ + '<div class="c4-conflict-pair">' + sides + '</div></section>';
++ }).join('');
++ }
++
++ /** The suggestions: notes an agent proposed, with the agent's name. */
++ function suggestedPanel(items) {
++ if (!items.length) {
++ return '<section class="c4-card c4-empty"><h2>Nothing suggested</h2>'
++ + '<p>When an agent works out something about you, it asks first: the note waits here until you accept it.</p></section>';
++ }
++ const rows = items.map((item) => {
++ const sources = Array.isArray(item.sources) && item.sources.length ? item.sources : [item.source];
++ const when = item.updated_at || item.created_at || '';
++ const id = esc(item.id);
++ return '<li class="c4-note"><p class="c4-note-text">' + esc(item.text) + '</p>'
++ + '<p class="c4-note-meta">Suggested by ' + esc(sourceName(sources[0])) + ' · ' + esc(kindName(item.kind))
++ + ' · <time datetime="' + esc(when) + '">' + esc(dayOf(when)) + '</time></p>'
++ + '<div class="c4-actions">'
++ + '<form method="post" action="/memory/items/' + id + '/accept"><input type="hidden" name="tab" value="suggested"><button class="c4-opill c4-opill-primary" type="submit">Accept</button></form>'
++ + '<form method="post" action="/memory/items/' + id + '/decline"><input type="hidden" name="tab" value="suggested"><button class="c4-opill c4-opill-quiet" type="submit">Decline</button></form>'
++ + '</div></li>';
++ }).join('');
++ return '<section class="c4-card">'
++ + '<p class="c4-sub">Notes your agents worked out on their own. Accept one to keep it; decline one and it goes.</p>'
++ + '<ul class="c4-rows">' + rows + '</ul></section>';
++ }
++
++ /** The profile: one block per scope, the summary the store built from the person's own notes. */
++ async function profilePanel(req, res, scopes, active) {
++ if (!scopes.length) {
++ return '<section class="c4-card c4-empty"><h2>No profile yet</h2>'
++ + '<p>Once your notes are here, this is the short summary of them: one for you, one for each project.</p></section>';
++ }
++ const blocks = [];
++ for (const scope of scopes) {
++ let profile = null;
++ try {
++ const out = await withToken(req, res, (token) => memory.profile(token, scope));
++ profile = out.profile || null;
++ } catch (e) { /* one scope that does not answer does not stop the others */ }
++ const count = active.filter((i) => i.scope === scope).length;
++ const built = profile && profile.built_at ? profile.built_at : '';
++ const when = built ? dayOf(built) : '';
++ const text = profile && profile.text ? profile.text : 'Nothing to say about this scope yet.';
++ blocks.push('<section class="c4-card"><div class="c4-card-head"><h2>' + esc(scopeName(scope)) + '</h2></div>'
++ + '<p class="c4-profile-text">' + esc(text) + '</p>'
++ + '<p class="c4-note-meta">Built from ' + count + (count === 1 ? ' note' : ' notes')
++ + (when ? (when === 'today' ? ', today' : ' on <time datetime="' + esc(built) + '">' + esc(when) + '</time>') : '') + '</p></section>');
++ }
++ return blocks.join('')
++ + '<p class="c4-hint">The summary rebuilds by itself when your notes change, at most once an hour. It never guesses: it says only what your notes say.</p>';
++ }
++
++ /** Who read the person's memory: the agent, the purpose and how many notes it took. Never the notes. */
++ async function readsPanel(req, res) {
++ let reads = [];
++ let problem = null;
++ try {
++ const out = await withToken(req, res, (token) => memory.reads(token, 50));
++ reads = Array.isArray(out.reads) ? out.reads : [];
++ } catch (e) { problem = e; }
++ if (problem) return '<section class="c4-card c4-empty"><h2>Who read it</h2><p>' + esc(problemWord(problem)) + '</p></section>';
++ if (!reads.length) {
++ return '<section class="c4-card c4-empty"><h2>Nobody has read it</h2>'
++ + '<p>When an agent reads your notes for a task, it shows up here: who, for what, and how much.</p></section>';
++ }
++ const rows = reads.map((read) => {
++ const actor = sourceName(String(read.actor || '').replace(/^svc:/, ''));
++ const count = Number(read.count) || 0;
++ const purpose = read.purpose ? String(read.purpose) : 'a task';
++ const when = read.created_at || '';
++ return '<li class="c4-row c4-row-flat"><span class="c4-row-dot c4-dot-skip" aria-hidden="true"></span>'
++ + '<span class="c4-row-label">' + esc(actor) + ' read ' + count + (count === 1 ? ' note' : ' notes') + '</span>'
++ + '<span class="c4-row-note">for ' + esc(purpose) + ' · <time datetime="' + esc(when) + '">' + esc(agoOf(when)) + '</time></span></li>';
++ }).join('');
++ return '<section class="c4-card">'
++ + '<p class="c4-sub">The agents that read your notes. The list says how many notes each one took, never what they said.</p>'
++ + '<ul class="c4-rows">' + rows + '</ul></section>';
++ }
++
++ /** The confirmation page: what one source's removal would take, and the one form that does it. */
++ async function removePage(req, res) {
++ const source = String(req.query.source || '');
++ const head = header('Memory', 'What your assistants know about you, in one place.');
++ if (!viewer(req)) return page(req, res, 'Memory', wrap(head + signIn(req)));
++ if (!SOURCE_ID.test(source)) return res.redirect(303, '/memory?tab=notes');
++ let items = [];
++ try {
++ const out = await withToken(req, res, (token) => memory.items(token, {}));
++ items = Array.isArray(out.items) ? out.items : [];
++ } catch (e) { items = []; }
++ const row = sourceRows(items).find((r) => r.id === source);
++ const count = row ? row.count : 0;
++ const name = sourceName(source);
++ const content = header('Remove ' + name, 'What this takes away, before it does.')
++ + '<section class="c4-card"><div class="c4-card-head"><h2>All ' + count + (count === 1 ? ' note' : ' notes') + ' from ' + esc(name) + '</h2></div>'
++ + '<p>These notes are deleted from your memory store. Your files on your computer are not touched: your other tools keep them, '
++ + 'and the next upload brings them back.</p>'
++ + '<form method="post" action="/memory/remove"><input type="hidden" name="source" value="' + esc(source) + '">'
++ + '<input type="hidden" name="tab" value="notes">'
++ + '<button class="c4-button c4-button-danger" type="submit">Delete ' + count + (count === 1 ? ' note' : ' notes') + '</button> '
++ + '<a class="c4-opill c4-opill-quiet" href="/memory?tab=notes">Keep them</a></form></section>';
++ return page(req, res, 'Memory', wrap(content));
++ }
++
++ // The writes: every one a same-origin form post, and every answer a redirect back to the tab it came from.
++ const refuse = (req, res) => {
++ if (!viewer(req)) return res.redirect(303, '/auth/login?next=' + encodeURIComponent('/memory'));
++ if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.');
++ return null;
++ };
++
++ router.get('/memory', async (req, res) => memoryPage(req, res));
++ router.get('/memory/remove', async (req, res) => removePage(req, res));
++
++ router.post('/memory/remove', form, async (req, res) => {
++ if (refuse(req, res)) return res;
++ const source = String((req.body && req.body.source) || '');
++ if (!SOURCE_ID.test(source)) return back(req, res, 'notes', '', 'That source could not be read.');
++ try {
++ const out = await withToken(req, res, (token) => memory.removeBySource(token, source));
++ return back(req, res, 'notes', 'Deleted ' + out.deleted + (out.deleted === 1 ? ' note' : ' notes') + ' from ' + sourceName(source) + '.');
++ } catch (e) {
++ return back(req, res, 'notes', '', problemWord(e));
++ }
++ });
++
++ router.post('/memory/items/:id/edit', form, async (req, res) => {
++ if (refuse(req, res)) return res;
++ const id = String(req.params.id || '');
++ if (!NOTE_ID.test(id)) return back(req, res, 'notes', '', 'That note could not be read.');
++ const body = req.body || {};
++ const text = String(body.text || '').slice(0, 2000);
++ const kind = KINDS.includes(body.kind) ? body.kind : null;
++ if (!text.trim()) return back(req, res, 'notes', '', 'A note needs a text.');
++ try {
++ await withToken(req, res, (token) => memory.patch(token, id, { text, ...(kind ? { kind } : {}) }));
++ return back(req, res, 'notes', 'The note is saved.');
++ } catch (e) {
++ return back(req, res, 'notes', '', problemWord(e));
++ }
++ });
++
++ router.post('/memory/items/:id/archive', form, async (req, res) => {
++ if (refuse(req, res)) return res;
++ const id = String(req.params.id || '');
++ if (!NOTE_ID.test(id)) return back(req, res, 'notes', '', 'That note could not be read.');
++ try {
++ await withToken(req, res, (token) => memory.patch(token, id, { status: 'archived' }));
++ return back(req, res, 'notes', 'The note is archived.');
++ } catch (e) {
++ return back(req, res, 'notes', '', problemWord(e));
++ }
++ });
++
++ router.post('/memory/items/:id/split', form, async (req, res) => {
++ if (refuse(req, res)) return res;
++ const id = String(req.params.id || '');
++ if (!NOTE_ID.test(id)) return back(req, res, 'notes', '', 'That note could not be read.');
++ try {
++ const out = await withToken(req, res, (token) => memory.split(token, id));
++ return back(req, res, 'notes', 'The notes are apart again: ' + out.split + '.');
++ } catch (e) {
++ return back(req, res, 'notes', '', problemWord(e));
++ }
++ });
++
++ router.post('/memory/items/:id/delete', form, async (req, res) => {
++ if (refuse(req, res)) return res;
++ const id = String(req.params.id || '');
++ if (!NOTE_ID.test(id)) return back(req, res, 'notes', '', 'That note could not be read.');
++ try {
++ await withToken(req, res, (token) => memory.remove(token, id));
++ return back(req, res, 'notes', 'The note is gone.');
++ } catch (e) {
++ return back(req, res, 'notes', '', problemWord(e));
++ }
++ });
++
++ router.post('/memory/items/:id/accept', form, async (req, res) => {
++ if (refuse(req, res)) return res;
++ const id = String(req.params.id || '');
++ if (!NOTE_ID.test(id)) return back(req, res, 'suggested', '', 'That note could not be read.');
++ try {
++ await withToken(req, res, (token) => memory.patch(token, id, { status: 'active' }));
++ return back(req, res, 'suggested', 'The note is kept.');
++ } catch (e) {
++ return back(req, res, 'suggested', '', problemWord(e));
++ }
++ });
++
++ router.post('/memory/items/:id/decline', form, async (req, res) => {
++ if (refuse(req, res)) return res;
++ const id = String(req.params.id || '');
++ if (!NOTE_ID.test(id)) return back(req, res, 'suggested', '', 'That note could not be read.');
++ try {
++ await withToken(req, res, (token) => memory.patch(token, id, { status: 'archived' }));
++ return back(req, res, 'suggested', 'The suggestion was declined.');
++ } catch (e) {
++ return back(req, res, 'suggested', '', problemWord(e));
++ }
++ });
++
++ router.post('/memory/conflicts/:id/settle', form, async (req, res) => {
++ if (refuse(req, res)) return res;
++ const id = String(req.params.id || '');
++ const keep = String((req.body && req.body.keep) || '');
++ if (!NOTE_ID.test(id) || !NOTE_ID.test(keep)) return back(req, res, 'disagree', '', 'Those two notes could not be read.');
++ try {
++ await withToken(req, res, (token) => memory.settle(token, id, keep));
++ return back(req, res, 'disagree', 'The note you kept is the one that stays.');
++ } catch (e) {
++ return back(req, res, 'disagree', '', problemWord(e));
++ }
++ });
++
++ return router;
++}
++
++/** The pairs that disagree: each note with the one it conflicts with, once. */
++function conflictPairs(conflicts) {
++ const out = [];
++ const seen = new Set();
++ for (const item of conflicts || []) {
++ const other = (conflicts || []).find((x) => x.id === item.conflict_with);
++ if (!other) continue;
++ const key = [item.id, other.id].sort().join('|');
++ if (seen.has(key)) continue;
++ seen.add(key);
++ out.push([item, other]);
++ }
++ return out;
++}
++
++/** The sources of the person's notes: how many notes each one has and when it was last seen. */
++function sourceRows(items) {
++ const rows = new Map();
++ for (const item of items || []) {
++ const list = Array.isArray(item.sources) && item.sources.length ? item.sources : [item.source];
++ for (const raw of list) {
++ const id = String(raw || '').trim();
++ if (!id) continue;
++ const row = rows.get(id) || { id, count: 0, seen: 0 };
++ row.count += 1;
++ const seen = Date.parse(String(item.seen_at || item.updated_at || '')) || 0;
++ if (seen > row.seen) row.seen = seen;
++ rows.set(id, row);
++ }
++ }
++ // The most recent source first: a phone shows the three most recent ones.
++ return [...rows.values()].sort((a, b) => b.seen - a.seen || b.count - a.count);
++}
++
++module.exports = { createMemoryRoutes, SOURCE_NAMES, KINDS, sourceName, scopeName, dayOf, agoOf };
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex f6b4f4d..62ca115 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -41,6 +41,7 @@ const WORKSPACE_NAV = [
{ label: 'Conductor', href: '/conductor', icon: 'fa-diagram-project' },
{ label: 'Agents', href: '/agents', icon: 'fa-robot' },
{ label: 'Machines', href: '/machines', icon: 'fa-display' },
++ { label: 'Memory', href: '/memory', icon: 'fa-brain' },
{ label: 'Usage', href: '/usage', icon: 'fa-chart-line' },
{ label: 'Settings', href: '/settings', icon: 'fa-gear' },
];
@@@ -71,7 +72,7 @@ function renderPage(o) {
const pathNow = o.path || '/';
// The workspace pages share the compact Frame: no second app bar under the Frame's own (a query must not change it).
const pathOnly = pathNow.split('?')[0];
-- const c4Workspace = pathOnly === '/' || /^\/(tasks|conductor|approvals|agents|machines|usage|settings)(\/|$)/.test(pathOnly);
++ const c4Workspace = pathOnly === '/' || /^\/(tasks|conductor|approvals|agents|machines|memory|usage|settings)(\/|$)/.test(pathOnly);
const loginNext = encodeURIComponent(pathNow);
const canonical = `${o.config.baseUrl}${pathNow.split('?')[0]}`;
// One navigation per page. On the workspace pages a signed-in person's navbar carries the workspace sections, with
ddiff --git a/test/c4c.test.js b/test/c4c.test.js
iindex b59f704..809f78f 100644
---- a/test/c4c.test.js
++++ b/test/c4c.test.js
@@@ -35,7 +35,7 @@ const split = (links) => ({
assert.strictEqual(page.status, 200);
const links = navbarOf(page.text).links;
const { top, more } = split(links);
-- assert.deepStrictEqual(top, ['Home', 'Tasks', 'Conductor', 'Agents', 'Machines', 'Usage', 'Settings'], 'the sections are the navbar');
++ assert.deepStrictEqual(top, ['Home', 'Tasks', 'Conductor', 'Agents', 'Machines', 'Memory', 'Usage', 'Settings'], 'the sections are the navbar');
assert.ok(more.includes('Get started') && more.includes('Run on your machine'), 'the product&rsquo;s places fold into More');
assert.ok(!top.includes('Agents') || top.filter((l) => l === 'Agents').length === 1, 'Agents is in one place only');
const tasks = links.find((l) => l.href === '/tasks');
ddiff --git a/test/memory.test.js b/test/memory.test.js
nnew file mode 100644
iindex 0000000..9ac6120
---- /dev/null
++++ b/test/memory.test.js
@@@ -0,0 +1,292 @@
++'use strict';
++// The Memory page (OpenVibe Memory M2b): the person's own notes, in Codes' kit. The store here is a stand-in for
++// OpenVibe.AI's memory store: it records every call, so a test can see which route the page used and whose token
++// it carried.
++const http = require('http');
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++
++const id = (n) => 'mem_' + String(n).padStart(26, '0');
++const WHEN = new Date(Date.now() - 36e5).toISOString();
++
++/** A stand-in for the store: 40 notes, one merged pair, 2 conflicts, 3 suggestions and 3 reads. */
++async function startStore() {
++ const calls = [];
++ const items = [];
++ for (let i = 0; i < 40; i++) {
++ items.push({
++ id: id(i + 1), text: 'Note number ' + (i + 1) + ' about how the person works',
++ kind: i % 3 === 0 ? 'preference' : 'fact', scope: i % 5 === 0 ? 'project:demo' : 'user',
++ source: ['claude-code', 'cursor', 'agents', 'gemini'][i % 4], sources: ['claude-code', 'cursor', 'agents', 'gemini'][i % 4],
++ status: 'active', confidence: 0.9, seen_at: WHEN, created_at: WHEN, updated_at: WHEN,
++ merged_into: null, conflict_with: null,
++ });
++ }
++ // One note that swallowed another one (the store keeps the swallowed note archived).
++ items[0].sources = ['claude-code', 'cursor'];
++ items.push({
++ id: id(90), text: 'The note that was merged into another one', kind: 'fact', scope: 'user',
++ source: 'cursor', sources: ['cursor'], status: 'archived', confidence: 0.8,
++ seen_at: WHEN, created_at: WHEN, updated_at: WHEN, merged_into: id(1), conflict_with: null,
++ });
++ // Two pairs that disagree.
++ for (let p = 0; p < 2; p++) {
++ const a = id(100 + p * 2);
++ const b = id(101 + p * 2);
++ items.push({
++ id: a, text: 'The person prefers tabs ' + (p + 1), kind: 'preference', scope: 'user',
++ source: 'claude-code', sources: ['claude-code'], status: 'conflict', confidence: 0.7,
++ seen_at: WHEN, created_at: WHEN, updated_at: WHEN, merged_into: null, conflict_with: b,
++ });
++ items.push({
++ id: b, text: 'The person prefers spaces ' + (p + 1), kind: 'preference', scope: 'user',
++ source: 'cursor', sources: ['cursor'], status: 'conflict', confidence: 0.7,
++ seen_at: WHEN, created_at: WHEN, updated_at: WHEN, merged_into: null, conflict_with: a,
++ });
++ }
++ // Three suggestions.
++ for (let s = 0; s < 3; s++) {
++ items.push({
++ id: id(120 + s), text: 'A suggested note ' + (s + 1), kind: 'fact', scope: 'user',
++ source: ['actor', 'vibe', 'know'][s], sources: [['actor', 'vibe', 'know'][s]], status: 'suggested', confidence: 0.6,
++ seen_at: WHEN, created_at: WHEN, updated_at: WHEN, merged_into: null, conflict_with: null,
++ });
++ }
++ const reads = [
++ { id: 'rd_1', actor: 'svc:actor', purpose: 'task-9', scopes: ['user'], count: 3, created_at: WHEN },
++ { id: 'rd_2', actor: 'svc:codes', purpose: 'the memory page', scopes: ['user'], count: 12, created_at: WHEN },
++ { id: 'rd_3', actor: 'svc:vibe', purpose: 'a question', scopes: ['project:demo'], count: 1, created_at: WHEN },
++ ];
++ const server = http.createServer((req, res) => {
++ const chunks = [];
++ req.on('data', (c) => chunks.push(c));
++ req.on('end', () => {
++ const raw = Buffer.concat(chunks).toString('utf8');
++ const url = new URL(req.url, 'http://store');
++ const call = { method: req.method, path: url.pathname, query: url.search, auth: req.headers.authorization || '', body: raw ? JSON.parse(raw) : null };
++ calls.push(call);
++ const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
++ if (!/^Bearer /.test(call.auth)) return json(401, { code: 'auth.denied', detail: 'no token' });
++ if (call.method === 'GET' && url.pathname === '/api/v1/memory/items') return json(200, { items, count: items.length });
++ if (call.method === 'GET' && url.pathname === '/api/v1/memory/profile') return json(200, { profile: { scope: url.searchParams.get('scope') || 'user', text: 'The person works in short bursts and prefers tabs.', built_at: WHEN } });
++ if (call.method === 'GET' && url.pathname === '/api/v1/memory/reads') return json(200, { reads, count: reads.length });
++ if (call.method === 'PATCH' && url.pathname.startsWith('/api/v1/memory/items/')) return json(200, { item: { id: url.pathname.split('/').pop(), ...(call.body || {}) } });
++ if (call.method === 'DELETE' && url.pathname.startsWith('/api/v1/memory/items/')) return json(200, { deleted: 1 });
++ if (call.method === 'DELETE' && url.pathname === '/api/v1/memory/items') return json(200, { deleted: 41 });
++ if (call.method === 'POST' && url.pathname.endsWith('/split')) return json(200, { items: [], count: 2, split: 2 });
++ if (call.method === 'POST' && url.pathname.endsWith('/settle')) return json(200, { kept: { id: 'kept' }, archived: { id: 'archived' } });
++ return json(404, { code: 'memory.not_found', detail: 'no such route' });
++ });
++ });
++ await new Promise((r) => server.listen(0, '127.0.0.1', r));
++ return {
++ url: 'http://127.0.0.1:' + server.address().port, calls, items, reads,
++ close: () => new Promise((r) => server.close(r)),
++ last: (method, part) => [...calls].reverse().find((c) => c.method === method && c.path.includes(part)),
++ };
++}
++
++(async () => {
++await check('a signed-out visitor is asked to sign in, and sees no note', async () => {
++ const h = await boot();
++ try {
++ const res = await h.get('/memory');
++ assert.equal(res.status, 200);
++ assert.ok(res.text.includes('Sign in to see your work'), 'the sign-in card');
++ assert.ok(!res.text.includes('Note number'), 'no note');
++ } finally { await h.close(); }
++});
++
++await check('every tab renders its own view, and the bar counts the others', async () => {
++ const store = await startStore();
++ const h = await boot({ env: { CODES_AI_INTERNAL_URL: store.url } });
++ try {
++ const user = h.network.addUser('maria');
++ const notes = await h.get('/memory?tab=notes', { as: user });
++ assert.equal(notes.status, 200);
++ assert.ok(notes.text.includes('Note number 1 about how the person works'), 'a note');
++ assert.ok(notes.text.includes('Notes (40)'), 'the notes count');
++ assert.ok(notes.text.includes('Disagree (2)'), 'the conflicts count');
++ assert.ok(notes.text.includes('Suggested (3)'), 'the suggestions count');
++ assert.ok(notes.text.includes('name="q"'), 'the search field');
++ assert.ok(notes.text.includes('scope=user'), 'the scope chips');
++ assert.ok(notes.text.includes('Claude Code'), 'a source name');
++
++ const disagree = await h.get('/memory?tab=disagree', { as: user });
++ assert.ok(disagree.text.includes('These two disagree'), 'the conflict card');
++ assert.ok(disagree.text.includes('Keep this one'), 'the keep button');
++ assert.ok(!disagree.text.includes('Note number 1 about how the person works'), 'only the open tab is built');
++
++ const suggested = await h.get('/memory?tab=suggested', { as: user });
++ assert.ok(suggested.text.includes('A suggested note 1'), 'a suggestion');
++ assert.ok(suggested.text.includes('Suggested by Actor'), 'the agent name');
++ assert.ok(suggested.text.includes('Accept') && suggested.text.includes('Decline'), 'the buttons');
++
++ const profile = await h.get('/memory?tab=profile', { as: user });
++ assert.ok(profile.text.includes('The person works in short bursts'), 'the summary');
++ assert.ok(profile.text.includes('Built from'), 'the count of notes');
++ assert.ok(profile.text.includes('at most once an hour'), 'the rebuild note');
++
++ const reads = await h.get('/memory?tab=reads', { as: user });
++ assert.ok(reads.text.includes('Actor read 3 notes'), 'a read');
++ assert.ok(reads.text.includes('for task-9'), 'the purpose');
++ assert.ok(!reads.text.includes('Note number'), 'the reads carry no text');
++ } finally { await h.close(); await store.close(); }
++});
++
++await check('the sources are quiet, the panels repeat no heading, and the menu opens in place', async () => {
++ const store = await startStore();
++ const h = await boot({ env: { CODES_AI_INTERNAL_URL: store.url } });
++ try {
++ const user = h.network.addUser('maria');
++ const notes = await h.get('/memory?tab=notes', { as: user });
++ // The Remove on a source row is a quiet link, not a bordered pill.
++ assert.ok(notes.text.includes('class="c4-quiet-link" href="/memory/remove?source=claude-code"'), 'a quiet Remove link');
++ assert.ok(!notes.text.includes('c4-opill c4-opill-quiet" href="/memory/remove'), 'and no bordered pill');
++ // A phone shows three sources and the rest behind a details the person opens; a desktop shows them all.
++ assert.ok(notes.text.includes('Show all 7 sources'), 'the fold');
++ assert.ok(notes.text.includes('<details class="c4-more">'), 'a plain details, no JavaScript');
++ // The tab already says "Notes (40)": the panel repeats no heading.
++ assert.ok(!notes.text.includes('<h2>Notes</h2>'), 'no repeated heading in the notes panel');
++ const suggested = await h.get('/memory?tab=suggested', { as: user });
++ assert.ok(!suggested.text.includes('<h2>Suggested</h2>'), 'no repeated heading in the suggested panel');
++ const reads = await h.get('/memory?tab=reads', { as: user });
++ assert.ok(!reads.text.includes('<h2>Who read it</h2>'), 'no repeated heading in the reads panel');
++ // The search field keeps a label, but only for a screen reader.
++ assert.ok(notes.text.includes('class="c4-sr-only" for="memory-search"'), 'a visually hidden label');
++ // The menu is part of the note's own row: the panel follows the text inside the same note.
++ const note = notes.text.slice(notes.text.indexOf('class="c4-note"'), notes.text.indexOf('class="c4-note"') + 2000);
++ assert.ok(note.indexOf('c4-note-text') < note.indexOf('class="c4-menu"'), 'the text comes first');
++ assert.ok(note.indexOf('class="c4-menu"') < note.indexOf('c4-menu-body'), 'then the menu');
++ // Delete is the danger style, and it is last.
++ assert.ok(note.includes('c4-opill-danger'), 'the danger button');
++ assert.ok(note.indexOf('c4-opill-danger') > note.indexOf('c4-opill-quiet'), 'and it is last');
++ // The remove page's delete is the danger style too, and "Keep them" is quiet.
++ const remove = await h.get('/memory/remove?source=claude-code', { as: user });
++ assert.ok(remove.text.includes('c4-button c4-button-danger'), 'the danger button');
++ assert.ok(remove.text.includes('c4-opill c4-opill-quiet" href="/memory?tab=notes"'), 'the quiet way back');
++ } finally { await h.close(); await store.close(); }
++});
++
++await check('the page never carries the person token, and no note text reaches the logs', async () => {
++ const store = await startStore();
++ const h = await boot({ env: { CODES_AI_INTERNAL_URL: store.url } });
++ try {
++ const user = h.network.addUser('maria');
++ const cookie = h.signIn(user);
++ const token = cookie.split('=')[1];
++ const res = await h.get('/memory?tab=notes', { cookie });
++ assert.ok(!res.text.includes(token), 'no token in the page');
++ assert.ok(!res.text.includes('Bearer '), 'no bearer in the page');
++ assert.ok(!h.logs().includes('Note number 1 about'), 'no note text in the logs');
++ assert.ok(!h.logs().includes(token), 'no token in the logs');
++ } finally { await h.close(); await store.close(); }
++});
++
++await check('every form calls the right store route with the person own token, and answers with a flash', async () => {
++ const store = await startStore();
++ const h = await boot({ env: { CODES_AI_INTERNAL_URL: store.url } });
++ try {
++ const user = h.network.addUser('maria');
++ const cookie = h.signIn(user);
++ const token = cookie.split('=')[1];
++ const note = id(1);
++
++ const edit = await h.get('/memory/items/' + note + '/edit', { cookie, method: 'POST', form: { text: 'A new text', kind: 'decision', tab: 'notes' } });
++ assert.equal(edit.status, 303);
++ assert.ok(edit.headers.get('location').includes('/memory?tab=notes'), 'back to the tab');
++ const patched = store.last('PATCH', note);
++ assert.ok(patched, 'the store was asked to patch');
++ assert.equal(patched.path, '/api/v1/memory/items/' + note);
++ assert.equal(patched.auth, 'Bearer ' + token, 'the person own token');
++ assert.equal(patched.body.text, 'A new text');
++ assert.equal(patched.body.kind, 'decision');
++
++ const archive = await h.get('/memory/items/' + note + '/archive', { cookie, method: 'POST', form: { tab: 'notes' } });
++ assert.equal(archive.status, 303);
++ assert.equal(store.last('PATCH', note).body.status, 'archived');
++
++ const split = await h.get('/memory/items/' + note + '/split', { cookie, method: 'POST', form: { tab: 'notes' } });
++ assert.equal(split.status, 303);
++ assert.ok(store.last('POST', '/split'), 'the store was asked to split');
++
++ const del = await h.get('/memory/items/' + note + '/delete', { cookie, method: 'POST', form: { tab: 'notes' } });
++ assert.equal(del.status, 303);
++ assert.equal(store.last('DELETE', note).method, 'DELETE');
++
++ const accept = await h.get('/memory/items/' + id(120) + '/accept', { cookie, method: 'POST', form: { tab: 'suggested' } });
++ assert.equal(accept.status, 303);
++ assert.ok(accept.headers.get('location').includes('tab=suggested'), 'back to the suggestions');
++ assert.equal(store.last('PATCH', id(120)).body.status, 'active');
++
++ const decline = await h.get('/memory/items/' + id(121) + '/decline', { cookie, method: 'POST', form: { tab: 'suggested' } });
++ assert.equal(decline.status, 303);
++ assert.equal(store.last('PATCH', id(121)).body.status, 'archived');
++
++ const settle = await h.get('/memory/conflicts/' + id(100) + '/settle', { cookie, method: 'POST', form: { keep: id(101), tab: 'disagree' } });
++ assert.equal(settle.status, 303);
++ const settled = store.last('POST', '/settle');
++ assert.equal(settled.path, '/api/v1/memory/items/' + id(100) + '/settle');
++ assert.equal(settled.body.keep, id(101));
++ assert.equal(settled.auth, 'Bearer ' + token);
++
++ // The flash says what happened, and the page it goes back to is the tab it came from.
++ const back = await h.get('/memory?tab=suggested&ok=' + encodeURIComponent('The note is kept.'), { cookie });
++ assert.ok(back.text.includes('The note is kept.'), 'the flash');
++ } finally { await h.close(); await store.close(); }
++});
++
++await check('the remove page says what goes, and the one form takes it away', async () => {
++ const store = await startStore();
++ const h = await boot({ env: { CODES_AI_INTERNAL_URL: store.url } });
++ try {
++ const user = h.network.addUser('maria');
++ const cookie = h.signIn(user);
++ const token = cookie.split('=')[1];
++ const page = await h.get('/memory/remove?source=claude-code', { cookie });
++ assert.equal(page.status, 200);
++ assert.ok(page.text.includes('notes from Claude Code'), 'what goes');
++ assert.ok(page.text.includes('Your files on your computer are not touched'), 'what does not');
++ assert.equal((page.text.match(/<form[^>]*method="post"/g) || []).length, 1, 'exactly one form');
++ assert.ok(page.text.includes('action="/memory/remove"'), 'the form');
++
++ const done = await h.get('/memory/remove', { cookie, method: 'POST', form: { source: 'claude-code', tab: 'notes' } });
++ assert.equal(done.status, 303);
++ const call = store.last('DELETE', '/api/v1/memory/items');
++ assert.ok(call, 'the store was asked to delete');
++ assert.ok(call.query.includes('source=claude-code'), 'by source');
++ assert.equal(call.auth, 'Bearer ' + token);
++ } finally { await h.close(); await store.close(); }
++});
++
++await check('an empty store shows the empty state and how to fill it', async () => {
++ const store = await startStore();
++ store.items.length = 0;
++ const h = await boot({ env: { CODES_AI_INTERNAL_URL: store.url } });
++ try {
++ const user = h.network.addUser('maria');
++ const res = await h.get('/memory', { as: user });
++ assert.equal(res.status, 200);
++ assert.ok(res.text.includes('Nothing here yet'), 'the empty line');
++ assert.ok(res.text.includes('openvibe-node memory upload'), 'the command');
++ assert.ok(res.text.includes('docs/memory.md'), 'the docs');
++ } finally { await h.close(); await store.close(); }
++});
++
++await check('a cross-site post is refused, and a note id that is not a note never reaches the store', async () => {
++ const store = await startStore();
++ const h = await boot({ env: { CODES_AI_INTERNAL_URL: store.url } });
++ try {
++ const user = h.network.addUser('maria');
++ const cross = await h.get('/memory/items/' + id(1) + '/archive', { as: user, method: 'POST', form: { tab: 'notes' }, headers: { origin: 'https://evil.example' } });
++ assert.equal(cross.status, 403);
++ assert.equal(store.calls.length, 0, 'the store was never asked');
++
++ const odd = await h.get('/memory/items/not-a-note/delete', { as: user, method: 'POST', form: { tab: 'notes' } });
++ assert.equal(odd.status, 303);
++ assert.equal(store.calls.length, 0, 'the store was never asked');
++ } finally { await h.close(); await store.close(); }
++});
++
++done();
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex 0fd4569..1dbfac7 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -18,8 +18,8 @@ const BUDGETS = {
jsRawKB: 340, // 330.9 after C9b: the conductor's message box with its images and its live turn (2.6 KB in js/c4.js); 326.2 after C4b: the home page loads the workspace script for its repository search (10.9 KB of its own); 315.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 307.8 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 300.5 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 293 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 287.2 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 273.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 263.4 with shared 4.11.0's seasonal navbar; 254.1 with openvibe-shared 4.9.0 (status text twins in every theme, 2026-10-23); 246.2 with openvibe-shared 4.2.x (Ask Actor in the navbar, 2026-10-15); 239.1 (212.2 before openvibe-shared/shell's web-runtime.js)
jsBrotliKB: 84, // 82.9 after C9b's conductor script; 80.6 after C4b's repository search on the home page; 77.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 76.1 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 74.2 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 70.5 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 67.9 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 65.3 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 61.7 with shared 4.11.0's seasonal navbar; 59.1 with openvibe-shared 4.4.0 (every site in the launcher, 2026-10-17); 56.3 (49.9 before web-runtime.js)
cssFiles: 3, // C4 adds its responsive workspace stylesheet alongside codes.css and run.css.
-- cssRawKB: 65, // 63.0 after C9c: the fleet's rows and its meters on the Agents page (public/css/c4.css, 1.2 KB); 62.4 after C9b: the conductor's turns, approvals inbox and composer (public/css/c4.css); 58.4 after C4b: the proof rows, the fold-out panels, the shots, the forms that remember choices and their light/dark styles; 46 KB after adding the C4 phone-first workspace, cards, task timeline and light/dark styles.
-- cssBrotliKB: 14, // 13.0 after C9b's conductor styles; 12.1 after C4b's rows, panels and forms; 9.9 KB after adding the C4 workspace styles.
++ cssRawKB: 70, // 69.3 after the Memory M2b review: the quiet Remove link, the phone sources fold, the in-place note menu and the danger buttons (public/css/c4.css, 2.2 KB); 67.1 after Memory M2b: the Memory page's sources, notes, note menus, conflicts and tabs (public/css/c4.css, 2.4 KB); 63.0 after C9c: the fleet's rows and its meters on the Agents page (public/css/c4.css, 1.2 KB); 62.4 after C9b: the conductor's turns, approvals inbox and composer (public/css/c4.css); 58.4 after C4b: the proof rows, the fold-out panels, the shots, the forms that remember choices and their light/dark styles; 46 KB after adding the C4 phone-first workspace, cards, task timeline and light/dark styles.
++ cssBrotliKB: 15, // 14.2 after the Memory M2b review (the same quiet link, sources fold, in-place menu and danger buttons); 13.0 after C9b's conductor styles; 12.1 after C4b's rows, panels and forms; 9.9 KB after adding the C4 workspace styles.
externalFiles: 2, // 0
};
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex d977130..89e4ffe 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -54,6 +54,7 @@ const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`,
'server/domain/run-tokens.js': 'OpenVibe.Network\'s token endpoint (OV_NETWORK_INTERNAL_URL, configured) for a run token; no URL from a person',
'server/domain/machines.js': 'OpenVibe.Network (OV_NETWORK_INTERNAL_URL, configured) /api/v1/me/nodes with the person\'s own session token: their machines and a revoke; the path id is checked against a nod_ shape, never a URL from a person',
'server/domain/conductor.js': 'OpenVibe.Network\'s projects API (OV_NETWORK_INTERNAL_URL, configured) with the person\'s own token, for the project a conductor runs in; and the Run job stream URL run-client.js builds on its own base. No URL from a person',
++ 'server/domain/memory.js': 'OpenVibe.AI (CODES_AI_INTERNAL_URL, configured) /api/v1/memory with the person\'s own session token: their own notes, their profile and their read log. The base is the configured address, the path is fixed, and a note id is checked against a mem_ shape before it goes in a path; no URL from a person',
'server/domain/github.js': 'api.github.com only, at fixed paths: the public repository search and a pull request\'s state. The owner/name/number come from a Codes-stored task result and are checked against GitHub\'s id shapes before they are put in a path',
'server/domain/fleet.js': 'OpenVibe.Bot (CODES_BOT_INTERNAL_URL, configured) /api/v1/machines with the person\'s own session token: their machines and the fleet their Nodes reported; the path is fixed, never a URL from a person',
'server/domain/ship.js': 'the service\'s own GET <publicOrigin>/release.json and api.github.com\'s compare, both at fixed paths: the origin is the contracts\' service manifest, never a URL from a person; the shas are checked hex',