memoir-cli 3.14.0 → 3.16.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.
@@ -6,11 +6,141 @@ let token = fragment.get('token');
6
6
  try { if (token) sessionStorage.setItem('memoir-view-token', token); else token = sessionStorage.getItem('memoir-view-token'); } catch {}
7
7
  if (fragment.has('token')) history.replaceState(null, '', '/');
8
8
  let state, selected = 'overview', editing, latestEdit, editorOpener, busy = false, stateRequest = 0, renderAfterEditor = false;
9
- const labels = { overview: 'Overview', answer: 'Answers', decision: 'Decisions', check: 'Checks', next: 'Next steps', goal: 'Goals', removed: 'Removed' };
10
- const descriptions = { overview: 'The context your next session will use.', answer: 'Questions already answered, ready for the next session.', decision: 'What was decided, and why.', check: 'What actually ran, and what needs checking again.', next: 'Completed work and the steps still ahead.', goal: 'What this project is working toward.', removed: 'Hidden from the handoff. Earlier versions stay on this computer.' };
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.' };
11
12
  function el(tag, text, className) { const node = document.createElement(tag); if (text !== undefined) node.textContent = text; if (className) node.className = className; return node; }
12
13
  function button(text, action, className = '') { const node = el('button', text, className); node.type = 'button'; node.addEventListener('click', action); return node; }
13
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
+ }
14
144
  function notice(message, error = false, undo) {
15
145
  $('notice').replaceChildren(); const node = el('div', undefined, 'notice' + (error ? ' error' : '')); node.append(el('span', message));
16
146
  if (undo) node.append(button('Undo', undo)); $('notice').append(node);
@@ -43,11 +173,27 @@ function nav() {
43
173
  $('navigation').replaceChildren();
44
174
  for (const [key, label] of Object.entries(labels)) {
45
175
  const count = key === 'overview' ? null : key === 'removed' ? state.removed.length : key === 'check' ? state.checks.length : state.records.filter(r => r.kind === key).length;
46
- const node = button(label, () => { selected = key; $('search').value = ''; render(); $('section-title').focus(); });
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'));
47
179
  if (selected === key) node.setAttribute('aria-current', 'page');
48
180
  if (count !== null) node.append(el('span', count, 'count')); $('navigation').append(node);
49
181
  }
50
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
+ }
51
197
  function metadata(item, check = false) {
52
198
  const details = el('details', undefined, 'metadata'); details.append(el('summary', check ? 'Evidence and covered files' : 'Source and earlier versions'));
53
199
  details.append(el('p', `Saved ${date(item.recorded_at)} · revision ${item.revision}`));
@@ -56,7 +202,7 @@ function metadata(item, check = false) {
56
202
  const list = el('ul'); Object.keys(item.inputs).forEach(file => list.append(el('li', file))); details.append(list);
57
203
  details.append(el('p', 'Output was discarded. Its fingerprint:')); details.append(el('code', item.output_sha256));
58
204
  } else {
59
- details.append(el('p', item.source)); if (item.why) details.append(el('p', 'Why: ' + item.why));
205
+ details.append(el('p', item.source)); if (item.why && item.kind !== 'next') details.append(el('p', 'Why: ' + item.why));
60
206
  const history = state.history.filter(r => r.id === item.id && r.revision < item.revision).reverse();
61
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); }
62
208
  if (!history.length) details.append(el('p', 'No earlier versions.'));
@@ -67,14 +213,28 @@ function recordCard(item, removed = false) {
67
213
  const card = el('article', undefined, 'card' + (item.kind === 'next' && item.status === 'done' ? ' done' : ''));
68
214
  card.dataset.recordId = item.id;
69
215
  const kind = item.kind === 'next' ? (item.status === 'done' ? 'DONE' : 'TO DO') : item.kind === 'answer' ? 'ANSWERED' : item.kind.toUpperCase();
70
- card.append(el('span', removed ? 'REMOVED' : kind, 'badge' + (removed ? ' neutral' : '')));
71
- card.append(el('h3', item.text)); if (item.answer) card.append(el('p', item.answer, 'answer'));
72
- if (item.why && !removed) card.append(el('p', item.why, 'hidden-note'));
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
+ }
73
232
  const actions = el('div', undefined, 'card-actions');
74
233
  if (removed) actions.append(button('Restore to handoff', () => restore(item)));
75
234
  else {
76
235
  actions.append(button('Correct', () => edit(item)));
77
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); }));
78
238
  actions.append(button('Remove from handoff', () => remove(item), 'remove'));
79
239
  }
80
240
  card.append(actions, metadata(item)); return card;
@@ -90,31 +250,57 @@ function checkCard(item, removed = false) {
90
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); }
91
251
  return card;
92
252
  }
93
- function matches(item) { const query = $('search').value.toLowerCase().trim(); return !query || [item.text, item.answer, item.why, item.title, item.id].filter(Boolean).join(' ').toLowerCase().includes(query); }
253
+ function matches(item) { const query = $('search').value.toLowerCase().trim(); return !query || searchableText(item).includes(query); }
94
254
  function group(kind, items, limit = Infinity) {
95
255
  const section = el('section', undefined, 'group'); const heading = el('div', undefined, 'group-title'); heading.append(el('h3', labels[kind]));
96
- if (selected === 'overview') heading.append(button('View all →', () => { selected = kind; render(); $('section-title').focus(); })); section.append(heading);
97
- 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); return section;
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;
98
265
  }
99
266
  function render() {
100
267
  if (!state) return;
101
268
  renderAfterEditor = false; $('add').disabled = busy;
102
- nav(); $('project').textContent = `${state.project_name} / ${state.branch || 'No Git branch'}`;
269
+ nav(); summary(); $('project').replaceChildren(el('span', state.project_name, 'project-name'), el('span', state.branch || 'No Git branch', 'branch-name'));
103
270
  $('revision').textContent = `Handoff revision ${state.revision}. Refreshed ${new Date().toLocaleTimeString()}.`;
104
271
  $('section-title').textContent = labels[selected]; $('section-description').textContent = descriptions[selected];
105
272
  $('goal').replaceChildren();
106
- if (selected === 'overview') { const goal = state.records.filter(r => r.kind === 'goal').sort((a,b) => b.revision - a.revision)[0]; if (goal) { const node = el('div', undefined, 'goal'); node.append(el('p', 'CURRENT GOAL', 'eyebrow'), el('p', goal.text), button('Edit goal', () => edit(goal), 'quiet')); $('goal').append(node); } }
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
+ }
107
282
  const content = $('content'); content.replaceChildren();
283
+ content.dataset.compact = String(selected === 'overview' && !$('search').value.trim());
108
284
  if (selected === 'removed') {
109
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);
110
286
  } else if (selected === 'overview') {
111
- for (const kind of ['next','answer','check','decision']) {
287
+ for (const kind of ['next','answer','check','decision','goal']) {
112
288
  let items = [...(kind === 'check' ? state.checks : state.records.filter(r => r.kind === kind))].sort((a,b) => b.revision - a.revision);
113
- if (kind === 'next') items = [...items].sort((a,b) => (a.status === 'done') - (b.status === 'done') || b.revision - a.revision);
114
- if (items.some(matches)) content.append(group(kind, items, 2));
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));
115
294
  }
116
- } else content.append(group(selected, selected === 'check' ? state.checks : state.records.filter(r => r.kind === selected)));
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
+ }
117
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();
118
304
  }
119
305
  function kindFields() {
120
306
  $('answer-label').hidden = $('kind').value !== 'answer'; $('answer').required = $('kind').value === 'answer';
@@ -126,7 +312,7 @@ function edit(item) {
126
312
  editorOpener = document.activeElement; latestEdit = null;
127
313
  // Reuse a new record's ID after an uncertain response. A retry must conflict
128
314
  // with a committed save instead of creating a second copy of the same draft.
129
- editing = { item, id: item?.id || 'record.' + crypto.randomUUID(), branch: state.branch };
315
+ editing = { item, id: item?.id || 'record.' + crypto.randomUUID(), branch: state.branch, expected_recovery: state.recovery_id };
130
316
  $('review-latest').hidden = true; $('comparison').hidden = true;
131
317
  $('editor-title').textContent = item ? 'Correct memory' : 'Add memory'; $('save').textContent = item ? 'Save correction' : 'Save memory';
132
318
  $('kind').value = item?.kind || 'answer'; $('kind').disabled = !!item;
@@ -140,17 +326,17 @@ async function action(input) {
140
326
  finally { busy = false; $('add').disabled = !state; $('refresh').disabled = false; }
141
327
  }
142
328
  async function remove(item, category = 'record') {
143
- const branch = state.branch;
144
- 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); }
329
+ const branch = state.branch, recovery = state.recovery_id;
330
+ try { await action({ action:'remove', branch, expected_recovery: recovery, id:item.id, category, expected_revision:item.revision }); notice('Removed from the handoff. Earlier versions are kept locally.', false, category === 'record' ? () => restore(item, branch, recovery) : undefined); }
145
331
  catch (error) { notice(error.message, true); }
146
332
  }
147
- async function restore(item, branch = state.branch) {
148
- try { await action({ action:'restore', branch, id:item.id, expected_revision:item.revision }); notice('Restored to the handoff.'); }
333
+ async function restore(item, branch = state.branch, recovery = state.recovery_id) {
334
+ try { await action({ action:'restore', branch, expected_recovery: recovery, id:item.id, expected_revision:item.revision }); notice('Restored to the handoff.'); }
149
335
  catch (error) { notice(error.message, true); }
150
336
  }
151
337
  function fields(item) { return { kind:item.kind, text:item.text, ...(item.answer ? {answer:item.answer} : {}), ...(item.why ? {why:item.why} : {}), status:item.status }; }
152
338
  async function changeStatus(item) {
153
- 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.'); }
339
+ try { await action({ action:'save', branch:state.branch, expected_recovery: state.recovery_id, 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.'); }
154
340
  catch (error) { notice(error.message, true); }
155
341
  }
156
342
  function editorSaving(saving) {
@@ -163,7 +349,10 @@ $('edit-form').addEventListener('submit', async event => {
163
349
  submitted.saving = true; editorSaving(true); $('form-error').textContent = '';
164
350
  try {
165
351
  const kind = $('kind').value;
166
- 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'} });
352
+ await action({ action:'save', branch:submitted.branch, expected_recovery: submitted.expected_recovery, 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();
167
356
  if (editing === submitted) $('editor').close();
168
357
  notice(submitted.item ? 'Correction saved. The next session will use this version.' : 'Memory saved for the next session.');
169
358
  } catch (error) { if (editing === submitted) { $('form-error').textContent = error.message; $('review-latest').hidden = !['refresh_required', 'save_unconfirmed'].includes(error.code); } }
@@ -179,7 +368,7 @@ $('review-latest').addEventListener('click', async () => {
179
368
  state = latest; renderAfterEditor = true;
180
369
  const item = latest.records.find(record => record.id === reviewed.id);
181
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.');
182
- latestEdit = { item, id:item.id, branch: latest.branch };
371
+ latestEdit = { item, id:item.id, branch: latest.branch, expected_recovery: latest.recovery_id };
183
372
  $('latest-text').textContent = item.text + (item.answer ? '\n\n' + item.answer : '') + (item.why ? '\n\nWhy: ' + item.why : '') + (item.kind === 'next' ? '\nProgress: ' + item.status : '');
184
373
  $('comparison').hidden = false; $('keep-draft').focus();
185
374
  } catch (error) { if (editing === reviewed && generation === stateRequest) $('form-error').textContent = error.message; }
@@ -194,12 +383,16 @@ $('keep-draft').addEventListener('click', () => {
194
383
  $('editor').addEventListener('close', () => {
195
384
  ++stateRequest;
196
385
  if (renderAfterEditor) render();
197
- const card = [...document.querySelectorAll('[data-record-id]')].find(node => node.dataset.recordId === editing?.item?.id);
198
- (editorOpener?.isConnected ? editorOpener : card?.querySelector('button') || (editing?.item?.kind === 'goal' && $('goal').querySelector('button')) || $('add')).focus();
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();
199
389
  editing = null; latestEdit = null;
200
390
  });
201
391
  $('editor').addEventListener('cancel', event => { if (editing?.saving) event.preventDefault(); });
202
392
  $('kind').addEventListener('change', kindFields); $('cancel').addEventListener('click', () => $('editor').close()); $('cancel-top').addEventListener('click', () => $('editor').close());
203
- $('add').addEventListener('click', () => edit()); $('refresh').addEventListener('click', refresh); $('search').addEventListener('input', render);
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();});
204
397
  document.addEventListener('visibilitychange', () => { if (!document.hidden && !$('editor').open && !busy) refresh(); });
205
398
  refresh();
@@ -2,24 +2,39 @@
2
2
  <html lang="en">
3
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
4
  <body>
5
- <a class="skip-link" href="#section-title">Skip to project memory</a>
6
- <aside class="sidebar"><a class="brand" href="/" aria-label="Memoir home"><span class="mark">m</span> memoir</a><p class="eyebrow">PROJECT MEMORY</p><nav id="navigation" aria-label="Memory categories"></nav><div class="sidebar-note"><span class="local-dot"></span> On this computer<p>Your project context.<br>Your decisions to keep.</p></div></aside>
7
- <main>
8
- <header class="topbar"><div id="project">Opening project…</div><button id="refresh" class="quiet">Refresh</button></header>
9
- <section class="intro"><p class="eyebrow">A LITTLE CONTEXT GOES A LONG WAY</p><h1>Pick up where you left off.</h1><p class="subtitle">See what’s remembered. Keep what’s useful. Correct what isn’t.</p></section>
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>
10
13
  <div id="notice" role="status" aria-live="polite"></div>
11
- <section id="goal" aria-label="Current goal"></section>
12
- <div class="section-heading"><div><h2 id="section-title" tabindex="-1">Overview</h2><p id="section-description">The context your next session will use.</p></div><button id="add" class="primary" disabled>+ Add memory</button></div>
13
- <label class="search-label"><span class="sr-only">Search saved project context</span><input id="search" type="search" placeholder="Find an answer, decision or next step…" autocomplete="off"></label>
14
- <div id="content" aria-live="polite"></div>
15
- <footer>Project records only. Check results cover their listed files; they don’t grant permission to publish. <span id="revision"></span></footer>
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>
16
31
  </main>
17
32
  <dialog id="editor" aria-labelledby="editor-title"><form id="edit-form">
18
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>
19
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>
20
35
  <label><span id="text-label">Question</span><textarea id="text" rows="3" maxlength="2000" required></textarea></label>
21
36
  <label id="answer-label">Answer<textarea id="answer" rows="3" maxlength="2000"></textarea></label>
22
- <label>Why this matters <span class="optional">(optional)</span><textarea id="why" rows="2" 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>
23
38
  <label id="status-label">Progress<select id="status"><option value="open">To do</option><option value="done">Done</option></select></label>
24
39
  <p class="hint">Only save project context. Keep credentials and personal details out. Corrections keep earlier versions.</p>
25
40
  <p id="form-error" class="form-error" role="alert"></p>