openvibers/openvibe.codes Commits
Memory M2b: the Memory page, what a person's own assistants know about them (#196)
Changes
@@@ -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 sidebarof 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 atz-index 10010, 120-280 px down). The page's own title stays above them: a once-a-day decoration may pass behind aheading, 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)}@@@ -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 throughnnew file mode 100644@@@ -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 };@@@ -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;}nnew file mode 100644@@@ -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 };@@@ -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@@@ -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’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');nnew file mode 100644@@@ -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); });@@@ -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};@@@ -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',
@@ -1,4 +1,4 @@
@@ -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
/* 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). */
of its own (openvibe.design/patterns/single-navbar). */
.c4-main{min-width:0;max-width:1200px;width:100%;padding-top:34px}
.c4-main{min-width:0;max-width:1200px;width:100%;padding-top:34px}
@@ -22,7 +22,7 @@
@@ -22,7 +22,7 @@
.c4-model-other[hidden]{display:none}
.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 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-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-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-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}
.c4-empty,.c4-emptyline{color:var(--c4-muted);font-size:13px;line-height:1.55}
@@ -37,20 +37,20 @@
@@ -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-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-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-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-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-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-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}
.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: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}}
@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-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-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}
: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}
.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}
.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. */
/* 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}
.c4-rows{display:grid;gap:0;list-style:none;margin:6px 0 0;padding:0}
@@ -130,7 +130,7 @@
@@ -130,7 +130,7 @@
.c4-saved{margin:10px 0 0}
.c4-saved{margin:10px 0 0}
.c4-saved .c4-chip svg{width:14px;height:14px;margin-right:5px}
.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,.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-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-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)}
: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 @@
@@ -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
/* 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
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. */
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
\ 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)}
@@ -51,6 +51,7 @@ const { createCodesReadiness } = require('./observability');
@@ -51,6 +51,7 @@ const { createCodesReadiness } = require('./observability');
const { createActorLimits } = require('./http/actor-limits');
const { createActorLimits } = require('./http/actor-limits');
const { createForgejoClient } = require('./domain/forgejo');
const { createForgejoClient } = require('./domain/forgejo');
const { createMachines } = require('./domain/machines');
const { createMachines } = require('./domain/machines');
+const { createMemory } = require('./domain/memory');
const { createFleet } = require('./domain/fleet');
const { createFleet } = require('./domain/fleet');
const { createNotices } = require('./domain/notices');
const { createNotices } = require('./domain/notices');
const { createUserSettings } = require('./domain/user-settings');
const { createUserSettings } = require('./domain/user-settings');
@@ -99,6 +100,9 @@ async function createApp(opts = {}) {
@@ -99,6 +100,9 @@ async function createApp(opts = {}) {
ctx.forgejo = createForgejoClient({ config, log });
ctx.forgejo = createForgejoClient({ config, log });
// The C4b screens: the person's machines (from Network), their own choices, the GitHub reader and their usage.
// 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 });
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.github = createGitHub({ config, store, fetchImpl: fetchImpl || globalThis.fetch, log });
ctx.usage = createUsage({ store });
ctx.usage = createUsage({ store });
// The conductor (Codes C9b) and its approvals inbox. The conductor starts Run jobs and reads their events through
// The conductor (Codes C9b) and its approvals inbox. The conductor starts Run jobs and reads their events through
new file mode 100644
new file mode 100644
@@ -0,0 +1,114 @@
@@ -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 };
@@ -9,6 +9,7 @@
@@ -9,6 +9,7 @@
* /agents the person's connections: their provider keys (they live in OpenVibe.AI), added, tested, removed
* /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
* /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)
* /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)
* /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.
* 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-
@@ -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;
return r;
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,510 @@
@@ -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 };
@@ -41,6 +41,7 @@ const WORKSPACE_NAV = [
@@ -41,6 +41,7 @@ const WORKSPACE_NAV = [
{ label: 'Conductor', href: '/conductor', icon: 'fa-diagram-project' },
{ label: 'Conductor', href: '/conductor', icon: 'fa-diagram-project' },
{ label: 'Agents', href: '/agents', icon: 'fa-robot' },
{ label: 'Agents', href: '/agents', icon: 'fa-robot' },
{ label: 'Machines', href: '/machines', icon: 'fa-display' },
{ label: 'Machines', href: '/machines', icon: 'fa-display' },
+ { label: 'Memory', href: '/memory', icon: 'fa-brain' },
{ label: 'Usage', href: '/usage', icon: 'fa-chart-line' },
{ label: 'Usage', href: '/usage', icon: 'fa-chart-line' },
{ label: 'Settings', href: '/settings', icon: 'fa-gear' },
{ label: 'Settings', href: '/settings', icon: 'fa-gear' },
];
];
@@ -71,7 +72,7 @@ function renderPage(o) {
@@ -71,7 +72,7 @@ function renderPage(o) {
const pathNow = o.path || '/';
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).
// 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 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 loginNext = encodeURIComponent(pathNow);
const canonical = `${o.config.baseUrl}${pathNow.split('?')[0]}`;
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
// One navigation per page. On the workspace pages a signed-in person's navbar carries the workspace sections, with
@@ -35,7 +35,7 @@ const split = (links) => ({
@@ -35,7 +35,7 @@ const split = (links) => ({
assert.strictEqual(page.status, 200);
assert.strictEqual(page.status, 200);
const links = navbarOf(page.text).links;
const links = navbarOf(page.text).links;
const { top, more } = split(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’s places fold into More');
assert.ok(more.includes('Get started') && more.includes('Run on your machine'), 'the product’s places fold into More');
assert.ok(!top.includes('Agents') || top.filter((l) => l === 'Agents').length === 1, 'Agents is in one place only');
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');
const tasks = links.find((l) => l.href === '/tasks');
new file mode 100644
new file mode 100644
@@ -0,0 +1,292 @@
@@ -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); });
@@ -18,8 +18,8 @@ const BUDGETS = {
@@ -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)
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)
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.
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
externalFiles: 2, // 0
};
};
@@ -54,6 +54,7 @@ const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`,
@@ -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/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/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/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/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/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',
'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',