memoir-cli 3.12.0 → 3.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +128 -137
  2. package/bin/memoir-work.js +9 -0
  3. package/bin/memoir.js +50 -8
  4. package/docs/AUDIT-REMEDIATION.md +55 -0
  5. package/docs/CASE_TAPE_AMNESIA.md +39 -0
  6. package/docs/HANDOFF-SECURITY-AUDIT.md +106 -0
  7. package/docs/LOCAL-HANDOFF-VALIDATION.md +129 -0
  8. package/docs/MCP-V2-MIGRATION.md +17 -0
  9. package/docs/PROJECT-HANDOFF.md +301 -0
  10. package/docs/PROJECT-MAP-TRIAL.md +149 -0
  11. package/docs/PROJECT-VIEW-DEBUG.md +66 -0
  12. package/docs/PROJECT-VIEW-VALIDATION.md +136 -0
  13. package/docs/RELEASE-3.14-VALIDATION.md +36 -0
  14. package/docs/RELIABILITY-ROLLOUT.md +57 -0
  15. package/docs/RETRIEVAL-INDEX.md +45 -0
  16. package/docs/RETRIEVAL-RESULTS.md +26 -0
  17. package/docs/SPEC.md +684 -0
  18. package/evals/CONTINUITY-PROTOCOL.md +45 -0
  19. package/evals/cases.json +200 -0
  20. package/evals/results/retrieval-2026-09-05.json +5333 -0
  21. package/evals/retrieval-performance.mjs +99 -0
  22. package/evals/run.mjs +87 -0
  23. package/package.json +13 -5
  24. package/src/adapters/index.js +13 -6
  25. package/src/adapters/restore.js +83 -36
  26. package/src/cloud/storage.js +130 -93
  27. package/src/commands/activate.js +18 -7
  28. package/src/commands/cloud.js +55 -4
  29. package/src/commands/consolidate.js +49 -10
  30. package/src/commands/diff.js +2 -2
  31. package/src/commands/doctor.js +3 -3
  32. package/src/commands/push.js +156 -161
  33. package/src/commands/recall.js +1 -1
  34. package/src/commands/restore.js +32 -44
  35. package/src/commands/resume.js +15 -164
  36. package/src/commands/session.js +51 -9
  37. package/src/commands/snapshot.js +6 -7
  38. package/src/commands/status.js +23 -1
  39. package/src/commands/upgrade.js +11 -9
  40. package/src/commands/validate.js +3 -0
  41. package/src/commands/view.js +2 -2
  42. package/src/commands/why.js +4 -3
  43. package/src/config.js +9 -40
  44. package/src/context/capture.js +126 -32
  45. package/src/context/handoffs.js +72 -0
  46. package/src/events/summary.js +122 -0
  47. package/src/integrations/setup.js +88 -0
  48. package/src/mcp.js +105 -152
  49. package/src/memory/lexical-index.js +65 -0
  50. package/src/memory/repository.js +16 -0
  51. package/src/memory/scope.js +65 -0
  52. package/src/memory/search.js +165 -70
  53. package/src/memory/store.js +141 -0
  54. package/src/providers/index.js +182 -51
  55. package/src/providers/restore.js +5 -1
  56. package/src/security/encryption.js +34 -60
  57. package/src/security/files.js +155 -0
  58. package/src/session/brief.js +47 -0
  59. package/src/session/inject.js +12 -6
  60. package/src/session/lock.js +39 -118
  61. package/src/session/migrations.js +6 -0
  62. package/src/session/render.js +34 -4
  63. package/src/session/state.js +200 -33
  64. package/src/work/cli.js +64 -0
  65. package/src/work/errors.js +8 -0
  66. package/src/work/server.js +28 -0
  67. package/src/work/setup.js +96 -0
  68. package/src/work/store.js +340 -0
  69. package/src/work/ui/app.js +398 -0
  70. package/src/work/ui/index.html +45 -0
  71. package/src/work/ui/style.css +248 -0
  72. package/src/work/view.js +93 -0
  73. package/src/workspace/tracker.js +84 -332
  74. package/supabase/migrations/202609050001_backup_versions.sql +50 -0
@@ -0,0 +1,398 @@
1
+ const $ = id => document.getElementById(id);
2
+ const fragment = new URLSearchParams(location.hash.slice(1));
3
+ let token = fragment.get('token');
4
+ // Restricted storage must not break the active launch link. Keep the capability
5
+ // in memory for this page even when the browser refuses session storage.
6
+ try { if (token) sessionStorage.setItem('memoir-view-token', token); else token = sessionStorage.getItem('memoir-view-token'); } catch {}
7
+ if (fragment.has('token')) history.replaceState(null, '', '/');
8
+ let state, selected = 'overview', editing, latestEdit, editorOpener, busy = false, stateRequest = 0, renderAfterEditor = false;
9
+ let viewMode = 'records', focusKey = 'project', showSuggestions = false;
10
+ const labels = { overview: 'Overview', next: 'Next actions', answer: 'Answers', decision: 'Decisions', check: 'Checks', goal: 'Goals', removed: 'Removed' };
11
+ const descriptions = { overview: 'What matters for your next session.', answer: 'Already answered. Ready to carry forward.', decision: 'What you decided, with the reasons behind it.', check: 'Completed checks and the changes that need another look.', next: 'Open work first. Completed actions stay in the record.', goal: 'What this project is working toward.', removed: 'Hidden from the handoff. You can restore records here.' };
12
+ function el(tag, text, className) { const node = document.createElement(tag); if (text !== undefined) node.textContent = text; if (className) node.className = className; return node; }
13
+ function button(text, action, className = '') { const node = el('button', text, className); node.type = 'button'; node.addEventListener('click', action); return node; }
14
+ function date(value) { return new Date(value).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }); }
15
+ function searchableText(item) { return [item.text,item.answer,item.title,item.why,item.source,item.id,...Object.keys(item.inputs||{})].filter(Boolean).join(' ').toLowerCase(); }
16
+ function entryLabel(item) {
17
+ const text = item.kind === 'next' || /^record\.[0-9a-f-]{36}$/i.test(item.id) ? item.text.split(/(?<=[.!?])\s/)[0] : item.id.replace(/^(?:record|decision|answer|check|next|goal)\./, '').replace(/[._-]+/g, ' ').replace(/^./, c => c.toUpperCase()).replace(/\bcli\b/ig, 'CLI');
18
+ return text.length > 62 ? text.slice(0,59).trimEnd() + '…' : text;
19
+ }
20
+ // A bounded, local projection of existing records. Shared words are suggestions,
21
+ // never saved relationships, causal claims, or proof that a decision is correct.
22
+ function buildProjectMap(data, { query = '', focus = 'project', kind = 'all' } = {}) {
23
+ const body = item => [item.text, item.answer, item.title, item.why, item.source].filter(Boolean).join(' ');
24
+ const label = entryLabel;
25
+ const all = [...data.records.map(item => ({key:'record:'+item.id,kind:item.kind,item,label:label(item)})), ...data.checks.map(item => ({key:'check:'+item.id,kind:'check',item,label:label(item)}))].sort((a,b) => b.item.revision-a.item.revision);
26
+ const search = query.toLowerCase().trim();
27
+ const matches = node => (kind==='all'||node.kind===kind) && (!search || searchableText(node.item).includes(search));
28
+ const ordered = [...all].sort((a,b) => (b.key===focus)-(a.key===focus) || Number(matches(b))-Number(matches(a)));
29
+ const nodes = ordered.slice(0,120), edges = [];
30
+ for (const node of nodes) edges.push({source:'project',target:node.key,type:'recorded',label:'Stored in this project on the current branch.'});
31
+ const stop = new Set('about after again also already another before being branch checks completed context current decisions during existing files first found from have into local memoir memory needs next only passed personal project record records release review saved session should source state test tests that their these they this through using verified version were what when which will with work workflow would'.split(' '));
32
+ const terms = new Map(nodes.map(node => [node.key,new Set((body(node.item).normalize('NFKC').toLowerCase().match(/[\p{L}\p{N}]+/gu)||[]).filter(word => word.length>=4 && /\p{L}/u.test(word) && !stop.has(word)).slice(0,180))]));
33
+ const frequency = new Map(); for (const set of terms.values()) for (const term of set) frequency.set(term,(frequency.get(term)||0)+1);
34
+ const references=new Map(nodes.map(node=>[node.key,new Set((body(node.item).match(/[A-Za-z0-9][A-Za-z0-9._/-]*/g)||[]).map(token=>token.replace(/\.+$/,'')))]));
35
+ for (let i=0;i<nodes.length;i++) for (let j=i+1;j<nodes.length;j++) {
36
+ const a=nodes[i], b=nodes[j];
37
+ const reference = references.get(a.key).has(b.item.id) ? [a,b] : references.get(b.key).has(a.item.id) ? [b,a] : null;
38
+ if (reference) { edges.push({source:a.key,target:b.key,type:'recorded',label:reference[0].label+' explicitly mentions '+reference[1].item.id+'.'}); continue; }
39
+ const receipt = a.kind==='check'?a:b.kind==='check'?b:null, note=receipt===a?b:a;
40
+ const paths = receipt && note.kind!=='check' ? Object.keys(receipt.item.inputs||{}).filter(file=>references.get(note.key).has(file)) : [];
41
+ if (paths.length) { edges.push({source:a.key,target:b.key,type:'recorded',label:'This entry names '+paths.slice(0,3).join(', ')+'. The check declares '+(paths.length===1?'that file':'those files')+' as input; this does not verify the entry’s claims.'}); continue; }
42
+ const shared=[...terms.get(a.key)].filter(word=>terms.get(b.key).has(word) && frequency.get(word)<=Math.max(3,nodes.length*.6));
43
+ if (shared.length>=3) {
44
+ shared.sort((x,y)=>frequency.get(x)-frequency.get(y)||x.localeCompare(y));
45
+ edges.push({source:a.key,target:b.key,type:'suggested',score:shared.reduce((sum,word)=>sum+1/frequency.get(word),0),label:'Suggested from shared words: '+shared.slice(0,5).join(', ')+'. This is not a saved relationship.'});
46
+ }
47
+ }
48
+ return {nodes,edges,total:all.length,matches:nodes.filter(matches),matchingTotal:all.filter(matches).length};
49
+ }
50
+ function mapNeighborhood(model, focus, suggestions = false, overview = false) {
51
+ const linked = focus ? model.edges.filter(edge => edge.source !== 'project' && edge.target !== 'project' &&
52
+ (edge.source === focus.key || edge.target === focus.key) && (suggestions || edge.type === 'recorded'))
53
+ .sort((a,b) => (a.type === 'suggested') - (b.type === 'suggested') || (b.score || 0) - (a.score || 0)) : [];
54
+ let ranked = focus ? linked.map(edge => model.nodes.find(node => node.key === (edge.source === focus.key ? edge.target : edge.source))) : [...model.matches];
55
+ if (!focus && overview) {
56
+ const picked = [];
57
+ for (const kind of ['goal','next','answer','decision','check']) {
58
+ const entry = ranked.find(node => node.kind === kind && (kind !== 'next' || node.item.status !== 'done'));
59
+ if (entry) picked.push(entry);
60
+ }
61
+ ranked = [...picked,...ranked.filter(node => !picked.includes(node))];
62
+ }
63
+ const shown = ranked.slice(0,6), visibleKeys = new Set(shown.map(node => node.key));
64
+ const edges = focus ? linked.filter(edge => visibleKeys.has(edge.source) || visibleKeys.has(edge.target)) :
65
+ model.edges.filter(edge => edge.source === 'project' && visibleKeys.has(edge.target));
66
+ return {ranked,shown,edges,linked};
67
+ }
68
+ function selectMapNode(key) {
69
+ focusKey=key; renderMap(true);
70
+ }
71
+ function renderMap(restoreFocus = false) {
72
+ if (!state) return;
73
+ const mapKind = selected === 'overview' || selected === 'removed' ? 'all' : selected;
74
+ const model=buildProjectMap(state,{query:$('search').value,focus:focusKey,kind:mapKind});
75
+ if (focusKey!=='project'&&!model.nodes.some(node=>node.key===focusKey)) focusKey='project';
76
+ const focus=model.nodes.find(node=>node.key===focusKey);
77
+ $('map-workspace').dataset.focused=String(!!focus);
78
+ const {ranked,shown,edges,linked}=mapNeighborhood(model,focus,showSuggestions,!$('search').value.trim()&&mapKind==='all');
79
+ $('map-count').textContent=focus ? shown.length+' of '+ranked.length+' connections · '+focus.label :
80
+ shown.length+' of '+model.matchingTotal+' entries'+(model.total>120?' · search to find older entries':'');
81
+ $('map-suggestions').setAttribute('aria-pressed',String(showSuggestions));
82
+ $('map-suggested-legend').hidden=!showSuggestions;
83
+ const canvas=$('map-canvas');canvas.replaceChildren();
84
+ const positions=[[22,18],[78,18],[19,47],[81,47],[22,77],[78,77]];
85
+ const center=focus||{key:'project',kind:'project',label:state.project_name};
86
+ const points=new Map([[center.key,[50,47]],...shown.map((node,index)=>[node.key,positions[index]])]);
87
+ const svg=document.createElementNS('http://www.w3.org/2000/svg','svg');svg.setAttribute('viewBox','0 0 1000 640');svg.setAttribute('preserveAspectRatio','none');svg.setAttribute('aria-hidden','true');svg.setAttribute('class','map-lines');
88
+ for(const edge of edges) {
89
+ const a=points.get(edge.source),b=points.get(edge.target),line=document.createElementNS('http://www.w3.org/2000/svg','path');
90
+ line.setAttribute('d',`M ${a[0]*10} ${a[1]*6.4} L ${b[0]*10} ${b[1]*6.4}`);
91
+ line.setAttribute('class','map-line '+edge.type+((edge.source===focusKey||edge.target===focusKey)&&focusKey!=='project'?' focused':''));svg.append(line);
92
+ }
93
+ canvas.append(svg);
94
+ let focusedButton;
95
+ for(const node of [center,...shown]) {
96
+ const active=node.key===focusKey;
97
+ const entry=button('',()=>selectMapNode(node.key),'map-node'+(active?' selected':''));
98
+ entry.dataset.center=String(node.key===center.key);
99
+ entry.dataset.kind=node.kind;entry.dataset.nodeKey=node.key;entry.style.left=points.get(node.key)[0]+'%';entry.style.top=points.get(node.key)[1]+'%';entry.setAttribute('aria-pressed',String(active));entry.setAttribute('aria-label',(node.kind==='project'?'Project':labels[node.kind])+': '+(node.item?.text||node.item?.title||node.label));
100
+ const dot=el('span',undefined,'node-dot');dot.setAttribute('aria-hidden','true');
101
+ entry.append(dot,el('span',node.label,'node-label'),el('span',node.kind==='project'?model.total+' entries':node.kind==='check'?(node.item.freshness==='inputs-match'?'Inputs match':'Needs review'):node.kind==='next'?(node.item.status==='done'?'Completed':'Next action'):labels[node.kind],'node-kind'));
102
+ canvas.append(entry);if(active)focusedButton=entry;
103
+ }
104
+ if(!shown.length)canvas.append(el('p',focus?(showSuggestions?'No connected entries found.':'No recorded connections yet. Try Suggested links.'):$('search').value?'No matching entries. Try another term.':'No entries in this category yet.','map-empty'));
105
+ const panel=$('map-inspector');panel.replaceChildren();
106
+ if(focus)panel.append(button('← Back to project',()=>{focusKey='project';renderMap(true);},'inspector-back'));
107
+ const panelTitle=el('h2',focus?focus.label:state.project_name);panelTitle.tabIndex=-1;
108
+ panel.append(el('p',focus?'IN FOCUS':'PROJECT BRIEF','inspector-eyebrow'),panelTitle);
109
+ if(focus) {
110
+ const card=focus.kind==='check'?checkCard(focus.item):recordCard(focus.item);card.className+=' expanded';panel.append(card);
111
+ panel.append(button('Open in Records ↗', () => { selected = focus.kind; $('search').value = ''; setView('records'); $('section-title').focus(); }, 'inspector-back'));
112
+ const connections=el('div',undefined,'inspector-connections');connections.append(el('h3','Connected context'));
113
+ const relevant=linked.slice(0,6);
114
+ if(!relevant.length)connections.append(el('p',showSuggestions?'No recorded references or shared topics found.':'No recorded references found. Suggested links can reveal possible shared topics.','connection-note'));
115
+ for(const edge of relevant) {
116
+ const other=model.nodes.find(node=>node.key===(edge.source===focusKey?edge.target:edge.source));
117
+ const row=button('',()=>selectMapNode(other.key),'connection-row');
118
+ row.append(el('span',edge.type==='recorded'?'RECORDED LINK':'SUGGESTED LINK','connection-type '+edge.type),el('strong',other.label),el('span',edge.label,'connection-reason'));connections.append(row);
119
+ }
120
+ if(linked.length>6)connections.append(el('p','Showing 6 of '+linked.length+' connections. Search for another entry to explore more.','connection-note'));
121
+ panel.append(connections);
122
+ } else {
123
+ const goal=state.records.filter(item=>item.kind==='goal').sort((a,b)=>b.revision-a.revision)[0];
124
+ panel.append(el('p',goal?.text||'Start with an answer, a decision or a next action. Connections appear as your project record grows.','project-brief'));
125
+ panel.append(el('p','Select an entry to read the full context and see why it is connected.','inspector-hint'));
126
+ panel.append(button('+ Add project context',()=>edit(), 'map-add'));
127
+ const index=el('div',undefined,'inspector-index');index.append(el('h3',$('search').value?'Search results':mapKind==='all'?'Project entries':labels[mapKind]));
128
+ for(const node of ranked.slice(0,24)) {const row=button('',()=>selectMapNode(node.key),'index-entry');row.dataset.kind=node.kind;row.setAttribute('aria-label',labels[node.kind]+': '+node.label);row.append(el('i'),el('span',node.label),el('span','↗'));index.append(row);}
129
+ if(ranked.length>24)index.append(el('p','Search to narrow '+ranked.length+' matching entries.','connection-note'));
130
+ panel.append(index);
131
+ }
132
+ panel.scrollTop=0;
133
+ // In narrow windows the detail panel follows the map in normal flow. Move
134
+ // focus to its heading so the selected context is visible without an overlay.
135
+ if(restoreFocus)(focus?panelTitle:focusedButton)?.focus();
136
+ }
137
+ function setView(mode) {
138
+ viewMode=mode;
139
+ if (mode === 'map' && selected === 'removed') { selected = 'overview'; focusKey = 'project'; }
140
+ $('map-workspace').hidden=mode!=='map';$('records-workspace').hidden=mode!=='records';
141
+ $('show-map').setAttribute('aria-pressed',String(mode==='map'));$('show-records').setAttribute('aria-pressed',String(mode==='records'));
142
+ render();
143
+ }
144
+ function notice(message, error = false, undo) {
145
+ $('notice').replaceChildren(); const node = el('div', undefined, 'notice' + (error ? ' error' : '')); node.append(el('span', message));
146
+ if (undo) node.append(button('Undo', undo)); $('notice').append(node);
147
+ }
148
+ async function request(route, input) {
149
+ const controller = new AbortController();
150
+ const timeout = setTimeout(() => controller.abort(), 15000);
151
+ try {
152
+ let response, result;
153
+ try {
154
+ response = await fetch(route, { method: input ? 'POST' : 'GET', signal: controller.signal, cache: 'no-store', headers: { Authorization: 'Bearer ' + (token || ''), ...(input ? { 'Content-Type': 'application/json' } : {}) }, ...(input ? { body: JSON.stringify(input) } : {}) });
155
+ result = await response.json();
156
+ } catch {
157
+ const error = new Error(input ? 'The connection was interrupted. The change may already be saved. Your draft is kept; review the latest version before trying again.' : 'Could not refresh. Check that the local Memoir view is still running, then try Refresh.');
158
+ error.code = input ? 'save_unconfirmed' : 'connection_failed'; throw error;
159
+ }
160
+ if (!response.ok) { const error = new Error(result.error || 'Could not save. Refresh and try again.'); error.code = result.code; throw error; } return result;
161
+ } finally { clearTimeout(timeout); }
162
+ }
163
+ async function refresh() {
164
+ if (busy || $('editor').open) return;
165
+ const generation = ++stateRequest;
166
+ try {
167
+ const result = await request('/api/state');
168
+ if (generation !== stateRequest || busy || $('editor').open) return;
169
+ state = result; render();
170
+ } catch (error) { if (generation === stateRequest) notice(error.message, true); }
171
+ }
172
+ function nav() {
173
+ $('navigation').replaceChildren();
174
+ for (const [key, label] of Object.entries(labels)) {
175
+ const count = key === 'overview' ? null : key === 'removed' ? state.removed.length : key === 'check' ? state.checks.length : state.records.filter(r => r.kind === key).length;
176
+ const node = button('', () => { selected = key; focusKey = 'project'; if (key === 'removed') setView('records'); else render(); [...$('navigation').children].find(entry => entry.dataset.kind === key)?.focus({preventScroll:true}); });
177
+ node.dataset.kind = key;
178
+ const icon = el('span', undefined, 'nav-icon icon-' + key); icon.setAttribute('aria-hidden', 'true'); node.append(icon, el('span', label, 'nav-name'));
179
+ if (selected === key) node.setAttribute('aria-current', 'page');
180
+ if (count !== null) node.append(el('span', count, 'count')); $('navigation').append(node);
181
+ }
182
+ }
183
+ function summary() {
184
+ const stale = state.checks.filter(r => r.freshness !== 'inputs-match').length;
185
+ const entries = [
186
+ ['next', state.records.filter(r => r.kind === 'next' && r.status !== 'done').length, 'Open actions'],
187
+ ['answer', state.records.filter(r => r.kind === 'answer').length, 'Answers saved'],
188
+ ['check', stale || state.checks.length, stale ? 'Checks to review' : 'Checks match files'],
189
+ ];
190
+ $('summary').replaceChildren();
191
+ for (const [kind, count, label] of entries) {
192
+ const node = button('', () => { selected = kind; focusKey = 'project'; $('search').value = ''; render(); $('section-title').focus(); }, 'summary-item' + (kind === 'check' && stale ? ' needs-attention' : ''));
193
+ const arrow = el('span', '↗', 'summary-arrow'); arrow.setAttribute('aria-hidden', 'true');
194
+ node.append(el('strong', count), el('span', label), arrow); $('summary').append(node);
195
+ }
196
+ }
197
+ function metadata(item, check = false) {
198
+ const details = el('details', undefined, 'metadata'); details.append(el('summary', check ? 'Evidence and covered files' : 'Source and earlier versions'));
199
+ details.append(el('p', `Saved ${date(item.recorded_at)} · revision ${item.revision}`));
200
+ if (check) {
201
+ details.append(el('p', `Exit status: ${item.exit_code ?? 'unavailable'}. Local receipt; not authenticated.`));
202
+ const list = el('ul'); Object.keys(item.inputs).forEach(file => list.append(el('li', file))); details.append(list);
203
+ details.append(el('p', 'Output was discarded. Its fingerprint:')); details.append(el('code', item.output_sha256));
204
+ } else {
205
+ details.append(el('p', item.source)); if (item.why && item.kind !== 'next') details.append(el('p', 'Why: ' + item.why));
206
+ const history = state.history.filter(r => r.id === item.id && r.revision < item.revision).reverse();
207
+ for (const old of history) { const row = el('p', `Revision ${old.revision} · ${date(old.recorded_at)}\n${old.text}${old.answer ? '\n' + old.answer : ''}\nSource: ${old.source}`); details.append(row); }
208
+ if (!history.length) details.append(el('p', 'No earlier versions.'));
209
+ }
210
+ return details;
211
+ }
212
+ function recordCard(item, removed = false) {
213
+ const card = el('article', undefined, 'card' + (item.kind === 'next' && item.status === 'done' ? ' done' : ''));
214
+ card.dataset.recordId = item.id;
215
+ const kind = item.kind === 'next' ? (item.status === 'done' ? 'DONE' : 'TO DO') : item.kind === 'answer' ? 'ANSWERED' : item.kind.toUpperCase();
216
+ const heading = el('div', undefined, 'card-heading'); heading.append(el('span', removed ? 'REMOVED' : kind, 'badge' + (removed ? ' neutral' : '')), el('span', new Date(item.recorded_at).toLocaleDateString([], { month:'short', day:'numeric' }), 'record-date')); card.append(heading);
217
+ const copy = el('div', undefined, 'record-copy');
218
+ copy.append(el('h3', item.kind === 'answer' ? item.text : entryLabel(item)));
219
+ if (item.kind !== 'answer') copy.append(el('p', item.text, 'record-text'));
220
+ if (item.kind === 'next' && item.why) copy.append(el('p', item.why, 'record-text'));
221
+ if (item.answer) copy.append(el('p', item.answer, 'answer')); card.append(copy);
222
+ const compact = selected === 'overview' && !$('search').value.trim() && viewMode === 'records';
223
+ if (compact || item.text.length > 200 || item.answer?.length > 220 || item.kind === 'next' && item.why?.length > 220) {
224
+ card.className += ' has-preview';
225
+ let expanded = focusKey === 'record:' + item.id;
226
+ if (expanded) card.className += ' expanded';
227
+ const collapsedLabel = compact ? 'Details' : 'Read full entry';
228
+ const more = button(collapsedLabel, () => { expanded = !expanded; card.className = card.className.replace(' expanded', '') + (expanded ? ' expanded' : ''); more.textContent = expanded ? 'Show less' : collapsedLabel; more.setAttribute('aria-expanded', String(expanded)); }, 'read-more');
229
+ more.textContent = expanded ? 'Show less' : collapsedLabel;
230
+ more.setAttribute('aria-expanded', String(expanded)); card.append(more);
231
+ }
232
+ const actions = el('div', undefined, 'card-actions');
233
+ if (removed) actions.append(button('Restore to handoff', () => restore(item)));
234
+ else {
235
+ actions.append(button('Correct', () => edit(item)));
236
+ if (item.kind === 'next') actions.append(button(item.status === 'done' ? 'Reopen' : 'Mark done', () => changeStatus(item)));
237
+ if (viewMode === 'records') actions.append(button('Connections', () => { focusKey = 'record:' + item.id; setView('map'); renderMap(true); }));
238
+ actions.append(button('Remove from handoff', () => remove(item), 'remove'));
239
+ }
240
+ card.append(actions, metadata(item)); return card;
241
+ }
242
+ function checkCard(item, removed = false) {
243
+ const matched = item.freshness === 'inputs-match';
244
+ const card = el('article', undefined, 'card');
245
+ card.append(el('span', removed ? 'REMOVED RECEIPT' : matched ? 'PASSED · FILES MATCH' : 'NEEDS RECHECK', 'badge' + (removed ? ' neutral' : matched ? '' : ' warn')));
246
+ card.append(el('h3', item.title));
247
+ if (!removed && item.reasons.length) { const reasons = el('ul', undefined, 'reasons'); item.reasons.forEach(reason => reasons.append(el('li', reason))); card.append(reasons); }
248
+ if (!removed) card.append(el('p', matched ? 'This result still covers the listed files. External settings need their own verification.' : 'Tell the next agent to review these changes before relying on the old result.', 'hidden-note'));
249
+ else card.append(el('p', 'Run a new authorized check to replace this receipt.', 'hidden-note'));
250
+ card.append(metadata(item, true)); if (!removed) { const actions = el('div', undefined, 'card-actions'); actions.append(button('Remove from handoff', () => remove(item, 'check'), 'remove')); card.append(actions); }
251
+ return card;
252
+ }
253
+ function matches(item) { const query = $('search').value.toLowerCase().trim(); return !query || searchableText(item).includes(query); }
254
+ function group(kind, items, limit = Infinity) {
255
+ const section = el('section', undefined, 'group'); const heading = el('div', undefined, 'group-title'); heading.append(el('h3', labels[kind]));
256
+ if (selected === 'overview') heading.append(button('View all →', () => { selected = kind; render(); $('section-title').focus(); }));
257
+ if (selected === 'overview') section.append(heading);
258
+ const cards = el('div', undefined, 'cards'); items.filter(matches).slice(0, limit).forEach(item => cards.append(kind === 'check' ? checkCard(item) : recordCard(item))); section.append(cards);
259
+ if (selected === 'overview' && !$('search').value.trim() && ['answer','decision'].includes(kind)) {
260
+ const archive = el('details', undefined, 'archive-group');
261
+ archive.append(el('summary', (kind === 'answer' ? 'Saved answers' : 'Recent decisions') + ' · ' + items.length), section);
262
+ return archive;
263
+ }
264
+ return section;
265
+ }
266
+ function render() {
267
+ if (!state) return;
268
+ renderAfterEditor = false; $('add').disabled = busy;
269
+ nav(); summary(); $('project').replaceChildren(el('span', state.project_name, 'project-name'), el('span', state.branch || 'No Git branch', 'branch-name'));
270
+ $('revision').textContent = `Handoff revision ${state.revision}. Refreshed ${new Date().toLocaleTimeString()}.`;
271
+ $('section-title').textContent = labels[selected]; $('section-description').textContent = descriptions[selected];
272
+ $('goal').replaceChildren();
273
+ $('view-caption').textContent = viewMode === 'map' ? 'Connections' : 'Saved context';
274
+ $('summary').hidden = selected !== 'overview' || viewMode === 'map';
275
+ if (selected === 'overview' && viewMode === 'records') {
276
+ const goal = state.records.filter(r => r.kind === 'goal').sort((a,b) => b.revision - a.revision)[0];
277
+ if (goal) {
278
+ const node = el('details', undefined, 'goal');
279
+ node.append(el('summary', 'Current focus'), el('p', goal.text), button('Edit goal', () => edit(goal), 'quiet')); $('goal').append(node);
280
+ }
281
+ }
282
+ const content = $('content'); content.replaceChildren();
283
+ content.dataset.compact = String(selected === 'overview' && !$('search').value.trim());
284
+ if (selected === 'removed') {
285
+ const cards = el('div', undefined, 'cards'); state.removed.filter(r => matches(r.item)).forEach(r => cards.append(r.category === 'check' ? checkCard(r.item, true) : recordCard(r.item, true))); content.append(cards);
286
+ } else if (selected === 'overview') {
287
+ for (const kind of ['next','answer','check','decision','goal']) {
288
+ let items = [...(kind === 'check' ? state.checks : state.records.filter(r => r.kind === kind))].sort((a,b) => b.revision - a.revision);
289
+ if (kind === 'goal' && !$('search').value.trim()) continue;
290
+ if (kind === 'next' && !$('search').value.trim()) items = items.filter(item => item.status !== 'done');
291
+ if (kind === 'check' && !$('search').value.trim()) items = items.filter(item => item.freshness !== 'inputs-match');
292
+ if (kind === 'check') items.sort((a,b) => (a.freshness === 'inputs-match') - (b.freshness === 'inputs-match') || b.revision - a.revision);
293
+ if (items.some(matches)) content.append(group(kind, items, $('search').value.trim() || kind === 'next' || kind === 'check' ? Infinity : 2));
294
+ }
295
+ } else {
296
+ const items = [...(selected === 'check' ? state.checks : state.records.filter(r => r.kind === selected))].sort((a,b) => b.revision - a.revision);
297
+ if (selected === 'next') items.sort((a,b) => (a.status === 'done') - (b.status === 'done') || b.revision - a.revision);
298
+ if (selected === 'check') items.sort((a,b) => (a.freshness === 'inputs-match') - (b.freshness === 'inputs-match') || b.revision - a.revision);
299
+ if (focusKey !== 'project') items.sort((a,b) => Number(focusKey.endsWith(':' + b.id)) - Number(focusKey.endsWith(':' + a.id)));
300
+ content.append(group(selected, items));
301
+ }
302
+ if (!content.querySelector('.card')) { const empty = el('div', undefined, 'empty'); empty.append(el('strong', $('search').value ? 'No matching memories' : selected === 'removed' ? 'Nothing removed' : 'A fresh start'), el('span', $('search').value ? 'Try a shorter search.' : selected === 'removed' ? 'Items you remove will appear here.' : 'Add an answer, a decision or the next step.')); content.replaceChildren(empty); }
303
+ renderMap();
304
+ }
305
+ function kindFields() {
306
+ $('answer-label').hidden = $('kind').value !== 'answer'; $('answer').required = $('kind').value === 'answer';
307
+ $('status-label').hidden = $('kind').value !== 'next'; $('text-label').textContent = $('kind').value === 'answer' ? 'Question' : $('kind').value === 'next' ? 'Next step' : $('kind').value === 'goal' ? 'Goal' : 'Decision';
308
+ }
309
+ function edit(item) {
310
+ if (!state || busy) return;
311
+ ++stateRequest;
312
+ editorOpener = document.activeElement; latestEdit = null;
313
+ // Reuse a new record's ID after an uncertain response. A retry must conflict
314
+ // with a committed save instead of creating a second copy of the same draft.
315
+ editing = { item, id: item?.id || 'record.' + crypto.randomUUID(), branch: state.branch };
316
+ $('review-latest').hidden = true; $('comparison').hidden = true;
317
+ $('editor-title').textContent = item ? 'Correct memory' : 'Add memory'; $('save').textContent = item ? 'Save correction' : 'Save memory';
318
+ $('kind').value = item?.kind || 'answer'; $('kind').disabled = !!item;
319
+ $('text').value = item?.text || ''; $('answer').value = item?.answer || ''; $('why').value = item?.why || ''; $('status').value = item?.status || 'open';
320
+ $('form-error').textContent = ''; kindFields(); $('editor').showModal(); $('text').focus();
321
+ }
322
+ async function action(input) {
323
+ if (busy) throw new Error('A change is already being saved.'); busy = true; ++stateRequest;
324
+ $('add').disabled = true; $('refresh').disabled = true;
325
+ try { state = await request('/api/action', input); render(); }
326
+ finally { busy = false; $('add').disabled = !state; $('refresh').disabled = false; }
327
+ }
328
+ async function remove(item, category = 'record') {
329
+ const branch = state.branch;
330
+ try { await action({ action:'remove', branch, id:item.id, category, expected_revision:item.revision }); notice('Removed from the handoff. Earlier versions are kept locally.', false, category === 'record' ? () => restore(item, branch) : undefined); }
331
+ catch (error) { notice(error.message, true); }
332
+ }
333
+ async function restore(item, branch = state.branch) {
334
+ try { await action({ action:'restore', branch, id:item.id, expected_revision:item.revision }); notice('Restored to the handoff.'); }
335
+ catch (error) { notice(error.message, true); }
336
+ }
337
+ function fields(item) { return { kind:item.kind, text:item.text, ...(item.answer ? {answer:item.answer} : {}), ...(item.why ? {why:item.why} : {}), status:item.status }; }
338
+ async function changeStatus(item) {
339
+ try { await action({ action:'save', branch:state.branch, id:item.id, expected_revision:item.revision, fields:{...fields(item),status:item.status === 'done' ? 'open' : 'done'} }); notice(item.status === 'done' ? 'Step reopened.' : 'Step marked done.'); }
340
+ catch (error) { notice(error.message, true); }
341
+ }
342
+ function editorSaving(saving) {
343
+ for (const id of ['text', 'answer', 'why', 'status', 'save', 'cancel', 'cancel-top', 'review-latest', 'keep-draft']) $(id).disabled = saving;
344
+ $('kind').disabled = saving || !!editing?.item;
345
+ }
346
+ $('edit-form').addEventListener('submit', async event => {
347
+ event.preventDefault(); if (!editing || busy) return;
348
+ const submitted = editing;
349
+ submitted.saving = true; editorSaving(true); $('form-error').textContent = '';
350
+ try {
351
+ const kind = $('kind').value;
352
+ await action({ action:'save', branch:submitted.branch, id:submitted.id, expected_revision:submitted.item?.revision || 0, fields:{kind,text:$('text').value, ...(kind === 'answer' ? {answer:$('answer').value} : {}), ...($('why').value ? {why:$('why').value} : {}),status:kind === 'next' ? $('status').value : 'open'} });
353
+ // A successful save must be visible even if the old search or category
354
+ // would exclude it. Both views continue from the same saved entry.
355
+ focusKey = 'record:' + submitted.id; selected = kind; $('search').value = ''; render();
356
+ if (editing === submitted) $('editor').close();
357
+ notice(submitted.item ? 'Correction saved. The next session will use this version.' : 'Memory saved for the next session.');
358
+ } catch (error) { if (editing === submitted) { $('form-error').textContent = error.message; $('review-latest').hidden = !['refresh_required', 'save_unconfirmed'].includes(error.code); } }
359
+ finally { submitted.saving = false; if (editing === submitted || !editing) editorSaving(false); }
360
+ });
361
+ $('review-latest').addEventListener('click', async () => {
362
+ if (!editing || busy) return;
363
+ const reviewed = editing, generation = ++stateRequest;
364
+ try {
365
+ const latest = await request('/api/state');
366
+ if (editing !== reviewed || generation !== stateRequest) return;
367
+ if (latest.branch !== reviewed.branch) throw new Error('The project is on a different branch. Copy any draft you need, then close this editor and refresh to review that branch.');
368
+ state = latest; renderAfterEditor = true;
369
+ const item = latest.records.find(record => record.id === reviewed.id);
370
+ if (!item) throw new Error(reviewed.item || latest.removed.some(record => record.item.id === reviewed.id) ? 'This item was removed. Your draft is still here. Close the editor and refresh, then use Removed to review or restore it.' : 'This new memory is not in the saved handoff yet. Your draft is kept. Try Save memory again.');
371
+ latestEdit = { item, id:item.id, branch: latest.branch };
372
+ $('latest-text').textContent = item.text + (item.answer ? '\n\n' + item.answer : '') + (item.why ? '\n\nWhy: ' + item.why : '') + (item.kind === 'next' ? '\nProgress: ' + item.status : '');
373
+ $('comparison').hidden = false; $('keep-draft').focus();
374
+ } catch (error) { if (editing === reviewed && generation === stateRequest) $('form-error').textContent = error.message; }
375
+ });
376
+ $('keep-draft').addEventListener('click', () => {
377
+ if (!latestEdit) return;
378
+ editing = latestEdit; latestEdit = null; $('comparison').hidden = true; $('review-latest').hidden = true;
379
+ $('kind').value = editing.item.kind; $('kind').disabled = true; kindFields();
380
+ $('save').textContent = 'Save correction';
381
+ $('form-error').textContent = 'Latest version reviewed. Save correction when your draft is ready.'; $('text').focus();
382
+ });
383
+ $('editor').addEventListener('close', () => {
384
+ ++stateRequest;
385
+ if (renderAfterEditor) render();
386
+ const activeWorkspace = $(viewMode === 'map' ? 'map-inspector' : 'content');
387
+ const card = [...activeWorkspace.querySelectorAll('[data-record-id]')].find(node => node.dataset.recordId === editing?.id);
388
+ (editorOpener?.isConnected ? editorOpener : card?.querySelector('.card-actions button') || (editing?.item?.kind === 'goal' && $('goal').querySelector('button')) || $('add')).focus();
389
+ editing = null; latestEdit = null;
390
+ });
391
+ $('editor').addEventListener('cancel', event => { if (editing?.saving) event.preventDefault(); });
392
+ $('kind').addEventListener('change', kindFields); $('cancel').addEventListener('click', () => $('editor').close()); $('cancel-top').addEventListener('click', () => $('editor').close());
393
+ $('add').addEventListener('click', () => edit()); $('refresh').addEventListener('click', refresh); $('search').addEventListener('input', () => { focusKey = 'project'; render(); });
394
+ $('show-map').addEventListener('click',()=>setView('map'));$('show-records').addEventListener('click',()=>setView('records'));
395
+ $('map-suggestions').addEventListener('click',()=>{showSuggestions=!showSuggestions;renderMap();});
396
+ $('map-reset').addEventListener('click',()=>{focusKey='project';selected='overview';$('search').value='';render();});
397
+ document.addEventListener('visibilitychange', () => { if (!document.hidden && !$('editor').open && !busy) refresh(); });
398
+ refresh();
@@ -0,0 +1,45 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Memoir · Project memory</title><link rel="stylesheet" href="/style.css"><script src="/app.js" defer></script></head>
4
+ <body>
5
+ <a id="skip-link" class="skip-link" href="#section-title">Skip to project memory</a>
6
+ <header class="workspace-bar">
7
+ <a class="workspace-brand" href="/" aria-label="Memoir project home"><span aria-hidden="true">m</span> memoir</a>
8
+ <span class="workspace-divider" aria-hidden="true">/</span><div id="project">Opening project…</div>
9
+ <span class="workspace-local"><i></i> On this computer</span><button id="refresh" class="quiet">Refresh</button>
10
+ </header>
11
+ <main class="workspace">
12
+ <header class="workspace-heading"><div><h1>Project memory</h1><p class="subtitle">Answers, decisions and checks for your next coding session.</p></div><button id="add" class="primary" disabled>+ Add context</button></header>
13
+ <div id="notice" role="status" aria-live="polite"></div>
14
+ <div class="workspace-tools">
15
+ <label class="search-label"><span class="search-icon" aria-hidden="true"></span><span class="sr-only">Search saved project context</span><input id="search" type="search" placeholder="Search answers, decisions, files…" autocomplete="off"></label>
16
+ <div class="view-switch" aria-label="Project view"><button id="show-records" type="button" aria-pressed="true">Records</button><button id="show-map" type="button" aria-pressed="false">Map</button></div>
17
+ </div>
18
+ <nav id="navigation" aria-label="Memory categories"></nav>
19
+ <div class="review-summary"><section id="summary" class="summary-strip" aria-label="Project at a glance"></section><section id="goal" aria-label="Current goal"></section></div>
20
+ <div class="section-heading"><div><h2 id="section-title" tabindex="-1">Overview</h2><p id="section-description">What matters for your next session.</p></div><span id="view-caption">Saved context</span></div>
21
+ <section id="records-workspace" aria-label="Project records"><div id="content" aria-live="polite"></div></section>
22
+ <section id="map-workspace" hidden aria-label="Project knowledge map">
23
+ <div class="map-controls"><p>Choose an entry to follow its connections.</p><button id="map-suggestions" type="button" aria-pressed="false">Suggested links</button></div>
24
+ <div class="map-layout">
25
+ <div class="map-surface"><div class="canvas-caption"><span id="map-count">Opening project memory</span><button id="map-reset" type="button">Back to overview</button></div><div id="map-canvas" class="map-canvas" aria-label="Connected project entries"></div><div class="map-bottom"><div class="map-legend"><span><i class="line-solid"></i> Recorded</span><span id="map-suggested-legend" hidden><i class="line-dashed"></i> Suggested word match</span></div></div></div>
26
+ <aside id="map-inspector" class="map-inspector" aria-live="polite" aria-label="Selected context"></aside>
27
+ </div>
28
+ <p class="map-footnote">Recorded links show project membership, named records or covered files. Suggested links are optional word matches.</p>
29
+ </section>
30
+ <footer><span id="revision"></span><details><summary>Used by your coding tools</summary><p>Memoir’s CLI, connected coding tools and this view read the same local project record. Saved corrections are available the next time a tool reads it. You can close this page and keep working.</p><p>Personal memory is separate. Check results cover their listed files; changed files may need a new check. A saved record is never permission to run a command or publish.</p></details></footer>
31
+ </main>
32
+ <dialog id="editor" aria-labelledby="editor-title"><form id="edit-form">
33
+ <div class="dialog-heading"><h2 id="editor-title">Correct memory</h2><button type="button" id="cancel-top" class="quiet" aria-label="Close editor">×</button></div>
34
+ <label>Type<select id="kind"><option value="answer">Answered question</option><option value="decision">Decision</option><option value="next">Next step</option><option value="goal">Goal</option></select></label>
35
+ <label><span id="text-label">Question</span><textarea id="text" rows="3" maxlength="2000" required></textarea></label>
36
+ <label id="answer-label">Answer<textarea id="answer" rows="3" maxlength="2000"></textarea></label>
37
+ <label><span>Why this matters <span class="optional">(optional)</span></span><textarea id="why" rows="2" maxlength="2000"></textarea></label>
38
+ <label id="status-label">Progress<select id="status"><option value="open">To do</option><option value="done">Done</option></select></label>
39
+ <p class="hint">Only save project context. Keep credentials and personal details out. Corrections keep earlier versions.</p>
40
+ <p id="form-error" class="form-error" role="alert"></p>
41
+ <button type="button" id="review-latest" class="quiet" hidden>Review latest version</button>
42
+ <section id="comparison" class="comparison" hidden aria-label="Latest saved version"><h3>Latest saved version</h3><pre id="latest-text"></pre><p>Your draft is still in the fields above. Compare it before continuing.</p><button type="button" id="keep-draft" class="quiet">Keep my draft and continue</button></section>
43
+ <div class="dialog-actions"><button type="button" id="cancel" class="quiet">Cancel</button><button type="submit" id="save" class="primary">Save correction</button></div>
44
+ </form></dialog>
45
+ </body></html>