memoir-cli 3.11.3 → 3.14.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.
- package/README.md +129 -124
- package/bin/memoir-work.js +9 -0
- package/bin/memoir.js +72 -8
- package/docs/AUDIT-REMEDIATION.md +55 -0
- package/docs/CASE_TAPE_AMNESIA.md +39 -0
- package/docs/HANDOFF-SECURITY-AUDIT.md +106 -0
- package/docs/LOCAL-HANDOFF-VALIDATION.md +129 -0
- package/docs/MCP-V2-MIGRATION.md +17 -0
- package/docs/PROJECT-HANDOFF.md +255 -0
- package/docs/PROJECT-VIEW-DEBUG.md +66 -0
- package/docs/PROJECT-VIEW-VALIDATION.md +136 -0
- package/docs/RELEASE-3.14-VALIDATION.md +36 -0
- package/docs/RELIABILITY-ROLLOUT.md +57 -0
- package/docs/RETRIEVAL-INDEX.md +45 -0
- package/docs/RETRIEVAL-RESULTS.md +26 -0
- package/docs/SPEC.md +684 -0
- package/evals/CONTINUITY-PROTOCOL.md +45 -0
- package/evals/cases.json +200 -0
- package/evals/results/retrieval-2026-09-05.json +5333 -0
- package/evals/retrieval-performance.mjs +99 -0
- package/evals/run.mjs +87 -0
- package/package.json +13 -5
- package/src/adapters/index.js +13 -6
- package/src/adapters/restore.js +83 -36
- package/src/cloud/auth.js +12 -15
- package/src/cloud/constants.js +6 -2
- package/src/cloud/storage.js +130 -93
- package/src/commands/activate.js +43 -9
- package/src/commands/cloud.js +56 -5
- package/src/commands/consolidate.js +49 -10
- package/src/commands/diff.js +2 -2
- package/src/commands/doctor.js +3 -3
- package/src/commands/forget.js +100 -0
- package/src/commands/push.js +164 -161
- package/src/commands/recall.js +42 -0
- package/src/commands/restore.js +32 -44
- package/src/commands/resume.js +15 -164
- package/src/commands/session.js +51 -9
- package/src/commands/snapshot.js +6 -7
- package/src/commands/status.js +23 -1
- package/src/commands/upgrade.js +13 -11
- package/src/commands/validate.js +16 -0
- package/src/commands/view.js +2 -2
- package/src/commands/why.js +4 -3
- package/src/config.js +9 -40
- package/src/context/capture.js +135 -33
- package/src/context/handoffs.js +72 -0
- package/src/events/summary.js +122 -0
- package/src/integrations/setup.js +88 -0
- package/src/mcp.js +151 -283
- package/src/memory/lexical-index.js +65 -0
- package/src/memory/repository.js +16 -0
- package/src/memory/scope.js +65 -0
- package/src/memory/search.js +598 -0
- package/src/memory/store.js +141 -0
- package/src/providers/index.js +182 -51
- package/src/providers/restore.js +5 -1
- package/src/security/encryption.js +34 -60
- package/src/security/files.js +155 -0
- package/src/session/brief.js +47 -0
- package/src/session/inject.js +12 -6
- package/src/session/lock.js +39 -118
- package/src/session/migrations.js +6 -0
- package/src/session/render.js +34 -4
- package/src/session/state.js +305 -34
- package/src/work/cli.js +64 -0
- package/src/work/errors.js +8 -0
- package/src/work/server.js +28 -0
- package/src/work/setup.js +96 -0
- package/src/work/store.js +340 -0
- package/src/work/ui/app.js +205 -0
- package/src/work/ui/index.html +30 -0
- package/src/work/ui/style.css +3 -0
- package/src/work/view.js +93 -0
- package/src/workspace/tracker.js +84 -332
- package/supabase/migrations/202609050001_backup_versions.sql +50 -0
|
@@ -0,0 +1,205 @@
|
|
|
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
|
+
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.' };
|
|
11
|
+
function el(tag, text, className) { const node = document.createElement(tag); if (text !== undefined) node.textContent = text; if (className) node.className = className; return node; }
|
|
12
|
+
function button(text, action, className = '') { const node = el('button', text, className); node.type = 'button'; node.addEventListener('click', action); return node; }
|
|
13
|
+
function date(value) { return new Date(value).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }); }
|
|
14
|
+
function notice(message, error = false, undo) {
|
|
15
|
+
$('notice').replaceChildren(); const node = el('div', undefined, 'notice' + (error ? ' error' : '')); node.append(el('span', message));
|
|
16
|
+
if (undo) node.append(button('Undo', undo)); $('notice').append(node);
|
|
17
|
+
}
|
|
18
|
+
async function request(route, input) {
|
|
19
|
+
const controller = new AbortController();
|
|
20
|
+
const timeout = setTimeout(() => controller.abort(), 15000);
|
|
21
|
+
try {
|
|
22
|
+
let response, result;
|
|
23
|
+
try {
|
|
24
|
+
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) } : {}) });
|
|
25
|
+
result = await response.json();
|
|
26
|
+
} catch {
|
|
27
|
+
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.');
|
|
28
|
+
error.code = input ? 'save_unconfirmed' : 'connection_failed'; throw error;
|
|
29
|
+
}
|
|
30
|
+
if (!response.ok) { const error = new Error(result.error || 'Could not save. Refresh and try again.'); error.code = result.code; throw error; } return result;
|
|
31
|
+
} finally { clearTimeout(timeout); }
|
|
32
|
+
}
|
|
33
|
+
async function refresh() {
|
|
34
|
+
if (busy || $('editor').open) return;
|
|
35
|
+
const generation = ++stateRequest;
|
|
36
|
+
try {
|
|
37
|
+
const result = await request('/api/state');
|
|
38
|
+
if (generation !== stateRequest || busy || $('editor').open) return;
|
|
39
|
+
state = result; render();
|
|
40
|
+
} catch (error) { if (generation === stateRequest) notice(error.message, true); }
|
|
41
|
+
}
|
|
42
|
+
function nav() {
|
|
43
|
+
$('navigation').replaceChildren();
|
|
44
|
+
for (const [key, label] of Object.entries(labels)) {
|
|
45
|
+
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(); });
|
|
47
|
+
if (selected === key) node.setAttribute('aria-current', 'page');
|
|
48
|
+
if (count !== null) node.append(el('span', count, 'count')); $('navigation').append(node);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function metadata(item, check = false) {
|
|
52
|
+
const details = el('details', undefined, 'metadata'); details.append(el('summary', check ? 'Evidence and covered files' : 'Source and earlier versions'));
|
|
53
|
+
details.append(el('p', `Saved ${date(item.recorded_at)} · revision ${item.revision}`));
|
|
54
|
+
if (check) {
|
|
55
|
+
details.append(el('p', `Exit status: ${item.exit_code ?? 'unavailable'}. Local receipt; not authenticated.`));
|
|
56
|
+
const list = el('ul'); Object.keys(item.inputs).forEach(file => list.append(el('li', file))); details.append(list);
|
|
57
|
+
details.append(el('p', 'Output was discarded. Its fingerprint:')); details.append(el('code', item.output_sha256));
|
|
58
|
+
} else {
|
|
59
|
+
details.append(el('p', item.source)); if (item.why) details.append(el('p', 'Why: ' + item.why));
|
|
60
|
+
const history = state.history.filter(r => r.id === item.id && r.revision < item.revision).reverse();
|
|
61
|
+
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
|
+
if (!history.length) details.append(el('p', 'No earlier versions.'));
|
|
63
|
+
}
|
|
64
|
+
return details;
|
|
65
|
+
}
|
|
66
|
+
function recordCard(item, removed = false) {
|
|
67
|
+
const card = el('article', undefined, 'card' + (item.kind === 'next' && item.status === 'done' ? ' done' : ''));
|
|
68
|
+
card.dataset.recordId = item.id;
|
|
69
|
+
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'));
|
|
73
|
+
const actions = el('div', undefined, 'card-actions');
|
|
74
|
+
if (removed) actions.append(button('Restore to handoff', () => restore(item)));
|
|
75
|
+
else {
|
|
76
|
+
actions.append(button('Correct', () => edit(item)));
|
|
77
|
+
if (item.kind === 'next') actions.append(button(item.status === 'done' ? 'Reopen' : 'Mark done', () => changeStatus(item)));
|
|
78
|
+
actions.append(button('Remove from handoff', () => remove(item), 'remove'));
|
|
79
|
+
}
|
|
80
|
+
card.append(actions, metadata(item)); return card;
|
|
81
|
+
}
|
|
82
|
+
function checkCard(item, removed = false) {
|
|
83
|
+
const matched = item.freshness === 'inputs-match';
|
|
84
|
+
const card = el('article', undefined, 'card');
|
|
85
|
+
card.append(el('span', removed ? 'REMOVED RECEIPT' : matched ? 'PASSED · FILES MATCH' : 'NEEDS RECHECK', 'badge' + (removed ? ' neutral' : matched ? '' : ' warn')));
|
|
86
|
+
card.append(el('h3', item.title));
|
|
87
|
+
if (!removed && item.reasons.length) { const reasons = el('ul', undefined, 'reasons'); item.reasons.forEach(reason => reasons.append(el('li', reason))); card.append(reasons); }
|
|
88
|
+
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'));
|
|
89
|
+
else card.append(el('p', 'Run a new authorized check to replace this receipt.', 'hidden-note'));
|
|
90
|
+
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
|
+
return card;
|
|
92
|
+
}
|
|
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); }
|
|
94
|
+
function group(kind, items, limit = Infinity) {
|
|
95
|
+
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;
|
|
98
|
+
}
|
|
99
|
+
function render() {
|
|
100
|
+
if (!state) return;
|
|
101
|
+
renderAfterEditor = false; $('add').disabled = busy;
|
|
102
|
+
nav(); $('project').textContent = `${state.project_name} / ${state.branch || 'No Git branch'}`;
|
|
103
|
+
$('revision').textContent = `Handoff revision ${state.revision}. Refreshed ${new Date().toLocaleTimeString()}.`;
|
|
104
|
+
$('section-title').textContent = labels[selected]; $('section-description').textContent = descriptions[selected];
|
|
105
|
+
$('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); } }
|
|
107
|
+
const content = $('content'); content.replaceChildren();
|
|
108
|
+
if (selected === 'removed') {
|
|
109
|
+
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
|
+
} else if (selected === 'overview') {
|
|
111
|
+
for (const kind of ['next','answer','check','decision']) {
|
|
112
|
+
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));
|
|
115
|
+
}
|
|
116
|
+
} else content.append(group(selected, selected === 'check' ? state.checks : state.records.filter(r => r.kind === selected)));
|
|
117
|
+
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); }
|
|
118
|
+
}
|
|
119
|
+
function kindFields() {
|
|
120
|
+
$('answer-label').hidden = $('kind').value !== 'answer'; $('answer').required = $('kind').value === 'answer';
|
|
121
|
+
$('status-label').hidden = $('kind').value !== 'next'; $('text-label').textContent = $('kind').value === 'answer' ? 'Question' : $('kind').value === 'next' ? 'Next step' : $('kind').value === 'goal' ? 'Goal' : 'Decision';
|
|
122
|
+
}
|
|
123
|
+
function edit(item) {
|
|
124
|
+
if (!state || busy) return;
|
|
125
|
+
++stateRequest;
|
|
126
|
+
editorOpener = document.activeElement; latestEdit = null;
|
|
127
|
+
// Reuse a new record's ID after an uncertain response. A retry must conflict
|
|
128
|
+
// 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 };
|
|
130
|
+
$('review-latest').hidden = true; $('comparison').hidden = true;
|
|
131
|
+
$('editor-title').textContent = item ? 'Correct memory' : 'Add memory'; $('save').textContent = item ? 'Save correction' : 'Save memory';
|
|
132
|
+
$('kind').value = item?.kind || 'answer'; $('kind').disabled = !!item;
|
|
133
|
+
$('text').value = item?.text || ''; $('answer').value = item?.answer || ''; $('why').value = item?.why || ''; $('status').value = item?.status || 'open';
|
|
134
|
+
$('form-error').textContent = ''; kindFields(); $('editor').showModal(); $('text').focus();
|
|
135
|
+
}
|
|
136
|
+
async function action(input) {
|
|
137
|
+
if (busy) throw new Error('A change is already being saved.'); busy = true; ++stateRequest;
|
|
138
|
+
$('add').disabled = true; $('refresh').disabled = true;
|
|
139
|
+
try { state = await request('/api/action', input); render(); }
|
|
140
|
+
finally { busy = false; $('add').disabled = !state; $('refresh').disabled = false; }
|
|
141
|
+
}
|
|
142
|
+
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); }
|
|
145
|
+
catch (error) { notice(error.message, true); }
|
|
146
|
+
}
|
|
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.'); }
|
|
149
|
+
catch (error) { notice(error.message, true); }
|
|
150
|
+
}
|
|
151
|
+
function fields(item) { return { kind:item.kind, text:item.text, ...(item.answer ? {answer:item.answer} : {}), ...(item.why ? {why:item.why} : {}), status:item.status }; }
|
|
152
|
+
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.'); }
|
|
154
|
+
catch (error) { notice(error.message, true); }
|
|
155
|
+
}
|
|
156
|
+
function editorSaving(saving) {
|
|
157
|
+
for (const id of ['text', 'answer', 'why', 'status', 'save', 'cancel', 'cancel-top', 'review-latest', 'keep-draft']) $(id).disabled = saving;
|
|
158
|
+
$('kind').disabled = saving || !!editing?.item;
|
|
159
|
+
}
|
|
160
|
+
$('edit-form').addEventListener('submit', async event => {
|
|
161
|
+
event.preventDefault(); if (!editing || busy) return;
|
|
162
|
+
const submitted = editing;
|
|
163
|
+
submitted.saving = true; editorSaving(true); $('form-error').textContent = '';
|
|
164
|
+
try {
|
|
165
|
+
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'} });
|
|
167
|
+
if (editing === submitted) $('editor').close();
|
|
168
|
+
notice(submitted.item ? 'Correction saved. The next session will use this version.' : 'Memory saved for the next session.');
|
|
169
|
+
} catch (error) { if (editing === submitted) { $('form-error').textContent = error.message; $('review-latest').hidden = !['refresh_required', 'save_unconfirmed'].includes(error.code); } }
|
|
170
|
+
finally { submitted.saving = false; if (editing === submitted || !editing) editorSaving(false); }
|
|
171
|
+
});
|
|
172
|
+
$('review-latest').addEventListener('click', async () => {
|
|
173
|
+
if (!editing || busy) return;
|
|
174
|
+
const reviewed = editing, generation = ++stateRequest;
|
|
175
|
+
try {
|
|
176
|
+
const latest = await request('/api/state');
|
|
177
|
+
if (editing !== reviewed || generation !== stateRequest) return;
|
|
178
|
+
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.');
|
|
179
|
+
state = latest; renderAfterEditor = true;
|
|
180
|
+
const item = latest.records.find(record => record.id === reviewed.id);
|
|
181
|
+
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 };
|
|
183
|
+
$('latest-text').textContent = item.text + (item.answer ? '\n\n' + item.answer : '') + (item.why ? '\n\nWhy: ' + item.why : '') + (item.kind === 'next' ? '\nProgress: ' + item.status : '');
|
|
184
|
+
$('comparison').hidden = false; $('keep-draft').focus();
|
|
185
|
+
} catch (error) { if (editing === reviewed && generation === stateRequest) $('form-error').textContent = error.message; }
|
|
186
|
+
});
|
|
187
|
+
$('keep-draft').addEventListener('click', () => {
|
|
188
|
+
if (!latestEdit) return;
|
|
189
|
+
editing = latestEdit; latestEdit = null; $('comparison').hidden = true; $('review-latest').hidden = true;
|
|
190
|
+
$('kind').value = editing.item.kind; $('kind').disabled = true; kindFields();
|
|
191
|
+
$('save').textContent = 'Save correction';
|
|
192
|
+
$('form-error').textContent = 'Latest version reviewed. Save correction when your draft is ready.'; $('text').focus();
|
|
193
|
+
});
|
|
194
|
+
$('editor').addEventListener('close', () => {
|
|
195
|
+
++stateRequest;
|
|
196
|
+
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();
|
|
199
|
+
editing = null; latestEdit = null;
|
|
200
|
+
});
|
|
201
|
+
$('editor').addEventListener('cancel', event => { if (editing?.saving) event.preventDefault(); });
|
|
202
|
+
$('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);
|
|
204
|
+
document.addEventListener('visibilitychange', () => { if (!document.hidden && !$('editor').open && !busy) refresh(); });
|
|
205
|
+
refresh();
|
|
@@ -0,0 +1,30 @@
|
|
|
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 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>
|
|
10
|
+
<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>
|
|
16
|
+
</main>
|
|
17
|
+
<dialog id="editor" aria-labelledby="editor-title"><form id="edit-form">
|
|
18
|
+
<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
|
+
<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
|
+
<label><span id="text-label">Question</span><textarea id="text" rows="3" maxlength="2000" required></textarea></label>
|
|
21
|
+
<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>
|
|
23
|
+
<label id="status-label">Progress<select id="status"><option value="open">To do</option><option value="done">Done</option></select></label>
|
|
24
|
+
<p class="hint">Only save project context. Keep credentials and personal details out. Corrections keep earlier versions.</p>
|
|
25
|
+
<p id="form-error" class="form-error" role="alert"></p>
|
|
26
|
+
<button type="button" id="review-latest" class="quiet" hidden>Review latest version</button>
|
|
27
|
+
<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>
|
|
28
|
+
<div class="dialog-actions"><button type="button" id="cancel" class="quiet">Cancel</button><button type="submit" id="save" class="primary">Save correction</button></div>
|
|
29
|
+
</form></dialog>
|
|
30
|
+
</body></html>
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
:root{color-scheme:light;--ink:#22362d;--muted:#657469;--paper:#f8f9f5;--line:#dee4d9;--green:#315e46;--soft:#e8efe5;--warn:#8b5b15;--warn-bg:#fff4dc;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-size:15px}*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);line-height:1.55}button,input,select,textarea{font:inherit}button{cursor:pointer;border:1px solid transparent;border-radius:8px;padding:9px 14px;font-weight:600;color:inherit;background:white}button:hover{filter:brightness(.97)}button:disabled{opacity:.5;cursor:default}:focus-visible{outline:3px solid #77a2d7;outline-offset:3px}.sidebar{position:fixed;inset:0 auto 0 0;width:230px;border-right:1px solid var(--line);padding:34px 24px;background:#f1f4ed;display:flex;flex-direction:column}.brand{display:flex;align-items:center;gap:10px;font-size:27px;font-weight:650;letter-spacing:-1px;text-decoration:none;color:var(--ink);margin-bottom:48px}.mark{display:inline-grid;place-content:center;background:var(--green);color:#fff;width:34px;height:34px;border-radius:10px;font-family:Georgia,serif;font-size:32px;line-height:1}.eyebrow{font-size:10px;font-weight:750;letter-spacing:1.7px;color:var(--muted);margin:0 0 13px}nav{display:grid;gap:5px}nav button{display:flex;justify-content:space-between;text-align:left;background:transparent;font-weight:500;padding:11px 12px}nav button[aria-current=page]{background:#dfe8da;font-weight:650}nav .count{font-size:12px;border-radius:10px;min-width:22px;text-align:center;color:var(--muted)}.sidebar-note{margin-top:auto;font-size:12px;color:var(--muted)}.sidebar-note p{font-size:12px;line-height:1.7}.local-dot{display:inline-block;width:6px;height:6px;background:#6b8c58;border-radius:50%;margin-right:6px}main{margin-left:230px;max-width:1370px;padding:0 55px 24px}.topbar{height:80px;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;font-size:13px;color:var(--muted)}.quiet{background:transparent;border-color:var(--line);font-size:13px}.intro{padding:43px 0 29px}h1{font-size:clamp(29px,3vw,42px);line-height:1.2;letter-spacing:-1.4px;margin:0 0 12px;font-weight:600}.subtitle{margin:0;color:var(--muted);font-size:15px}.goal{padding:20px 24px;background:#eaf0e5;border:1px solid #d6e0cc;border-radius:12px;margin-bottom:30px}.goal .eyebrow{margin-bottom:6px}.goal p{margin:0;font-size:16px;max-width:900px}.goal button{margin-top:12px}.section-heading{display:flex;align-items:center;justify-content:space-between;gap:15px;margin:25px 0 17px}h2{font-size:20px;letter-spacing:-.4px;margin:0}.section-heading p{color:var(--muted);font-size:13px;margin:4px 0 0}.primary{background:var(--green);color:white;font-size:13px;padding:11px 16px}.search-label{display:block;margin-bottom:23px}input[type=search]{background:white;border:1px solid var(--line);border-radius:8px;padding:12px 15px;width:100%;max-width:460px;font-size:13px}.group{margin-bottom:27px}.group-title{display:flex;justify-content:space-between;align-items:center;margin-bottom:11px}.group h3{font-size:14px;margin:0}.group-title button{font-size:12px;padding:4px 8px;background:transparent;color:var(--muted)}.cards{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.card{border:1px solid var(--line);border-radius:11px;background:white;padding:20px;min-width:0}.card h3,.card h4{font-size:14px;margin:8px 0;font-weight:650;line-height:1.5;overflow-wrap:anywhere}.card p{margin:7px 0;overflow-wrap:anywhere}.card .answer{font-size:15px;white-space:pre-wrap}.badge{display:inline-flex;padding:3px 8px;border-radius:5px;background:var(--soft);color:var(--green);font-size:10px;letter-spacing:.5px;font-weight:650}.badge.warn{background:var(--warn-bg);color:var(--warn)}.badge.neutral{background:#f0f2ef;color:var(--muted)}.card-actions{display:flex;gap:8px;margin-top:16px;flex-wrap:wrap}.card-actions button{font-size:12px;padding:6px 11px;background:#f8faf6;border-color:var(--line)}.card-actions .remove{color:var(--muted);background:transparent;border-color:transparent}.metadata{font-size:12px;color:var(--muted);margin-top:13px}.metadata p{white-space:pre-wrap}.metadata summary{cursor:pointer}.metadata ul{padding-left:19px}.metadata code{font-size:11px;word-break:break-all}.reasons{border-left:2px solid #d9ac62;padding-left:11px;color:var(--warn);font-size:12px;list-style:none}.empty{padding:33px;border:1px dashed var(--line);border-radius:10px;color:var(--muted);text-align:center}.empty strong{display:block;color:var(--ink);margin-bottom:4px}.notice{padding:12px 16px;background:#edf3e8;border:1px solid #d7e1cf;border-radius:8px;font-size:13px;margin-bottom:18px;display:flex;align-items:center;justify-content:space-between;gap:12px}.notice.error{background:#fff1e9;color:#943a1b;border-color:#edccbc}.notice button{font-size:12px;background:transparent;text-decoration:underline}.hidden-note{color:var(--muted);font-size:12px}.done .answer{text-decoration:line-through;color:var(--muted)}footer{margin:30px 0 0;border-top:1px solid var(--line);padding-top:17px;color:var(--muted);font-size:11px}footer span{display:block;margin-top:4px}dialog{border:1px solid var(--line);border-radius:15px;padding:27px;width:min(560px,calc(100% - 32px));max-height:90vh;color:var(--ink);box-shadow:0 15px 80px #11221122}dialog::backdrop{background:#1c302c66}dialog form{display:grid;gap:15px}.dialog-heading,.dialog-actions{display:flex;align-items:center;justify-content:space-between;gap:10px}.dialog-heading h2{font-size:23px}.dialog-heading button{font-size:22px;padding:0 10px}.dialog-actions{justify-content:flex-end;margin-top:8px}label{display:grid;gap:6px;font-size:13px;font-weight:600}textarea,select{width:100%;border:1px solid #cbd4c6;border-radius:7px;padding:9px 11px;font-size:14px;color:var(--ink);background:white}textarea{resize:vertical}label[hidden]{display:none}.hint,.optional{font-size:12px;font-weight:400;color:var(--muted)}.hint{margin:0}.form-error{color:#943a1b;font-size:13px;margin:0}.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}@media(min-width:1450px){main{margin-left:230px;padding-right:85px;padding-left:85px}}@media(max-width:900px){.sidebar{width:190px;padding:25px 15px}main{margin-left:190px;padding:0 25px 25px}.cards{grid-template-columns:1fr}.intro{padding-top:30px}}@media(max-width:600px){.sidebar{position:static;width:auto;padding:17px 18px;display:block;border-right:0;border-bottom:1px solid var(--line)}.brand{font-size:22px;margin:0 0 15px}.mark{width:27px;height:27px;font-size:26px}.sidebar>.eyebrow,.sidebar-note{display:none}nav{display:flex;overflow-x:auto;gap:4px}nav button{white-space:nowrap;gap:8px;padding:8px;font-size:12px}main{margin:0;padding:0 18px 18px}.topbar{height:62px}.section-heading{align-items:flex-start}.section-heading .primary{white-space:nowrap}.intro{padding-top:28px}.goal{padding:18px}.cards{grid-template-columns:1fr}h1{letter-spacing:-.8px}.goal p{font-size:14px}}
|
|
2
|
+
|
|
3
|
+
[hidden]{display:none!important}.skip-link{position:fixed;top:-100px;left:12px;z-index:10;padding:12px;background:white;color:var(--ink)}.skip-link:focus{top:12px}.comparison{padding:14px;background:var(--soft);border-radius:8px;font-size:13px}.comparison h3{margin:0 0 8px;font-size:14px}.comparison pre{white-space:pre-wrap;overflow-wrap:anywhere;font:inherit}.topbar>div{overflow-wrap:anywhere;min-width:0}.metadata li{overflow-wrap:anywhere}
|
package/src/work/view.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// A bounded local editor, never a shell or general filesystem endpoint.
|
|
2
|
+
import http from 'node:http';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { workRoot, reviewWork, recordWork, retractWork, restoreWork } from './store.js';
|
|
7
|
+
import { workErrorMessage } from './errors.js';
|
|
8
|
+
|
|
9
|
+
const assets = new Map([
|
|
10
|
+
['/', ['index.html', 'text/html; charset=utf-8']],
|
|
11
|
+
['/app.js', ['app.js', 'text/javascript; charset=utf-8']],
|
|
12
|
+
['/style.css', ['style.css', 'text/css; charset=utf-8']],
|
|
13
|
+
]);
|
|
14
|
+
const actionSchema = z.object({
|
|
15
|
+
action: z.enum(['save', 'remove', 'restore']),
|
|
16
|
+
branch: z.string().max(1024).nullable(),
|
|
17
|
+
id: z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}$/),
|
|
18
|
+
expected_revision: z.number().int().nonnegative(),
|
|
19
|
+
category: z.enum(['record', 'check']).default('record'),
|
|
20
|
+
fields: z.object({ kind: z.enum(['goal', 'answer', 'decision', 'next']), text: z.string().min(1).max(2000), answer: z.string().max(2000).optional(), why: z.string().max(2000).optional(), status: z.enum(['open', 'done']).default('open') }).strict().optional(),
|
|
21
|
+
}).strict();
|
|
22
|
+
|
|
23
|
+
const headers = {
|
|
24
|
+
'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff',
|
|
25
|
+
'Referrer-Policy': 'no-referrer', 'X-Frame-Options': 'DENY',
|
|
26
|
+
'Cross-Origin-Resource-Policy': 'same-origin', 'Cross-Origin-Opener-Policy': 'same-origin',
|
|
27
|
+
'Permissions-Policy': 'camera=(), microphone=(), geolocation=()',
|
|
28
|
+
'Content-Security-Policy': "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'none'; base-uri 'none'; object-src 'none'; frame-ancestors 'none'; form-action 'none'",
|
|
29
|
+
};
|
|
30
|
+
function reply(res, status, value, type = 'application/json; charset=utf-8') {
|
|
31
|
+
res.writeHead(status, { ...headers, 'Content-Type': type });
|
|
32
|
+
res.end(type.startsWith('application/json') ? JSON.stringify(value) : value);
|
|
33
|
+
}
|
|
34
|
+
async function body(req) {
|
|
35
|
+
const parts = []; let bytes = 0;
|
|
36
|
+
for await (const chunk of req) {
|
|
37
|
+
bytes += chunk.length;
|
|
38
|
+
if (bytes > 16384) throw new Error('Request is too large. Nothing was saved.');
|
|
39
|
+
parts.push(chunk);
|
|
40
|
+
}
|
|
41
|
+
return JSON.parse(Buffer.concat(parts).toString());
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function startWorkView(project, { port = 0 } = {}) {
|
|
45
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error('Choose a port between 0 and 65535.');
|
|
46
|
+
const root = await workRoot(project);
|
|
47
|
+
await reviewWork(root); // Refuse damaged data before announcing a working view.
|
|
48
|
+
const token = crypto.randomBytes(32).toString('base64url');
|
|
49
|
+
const expectedAuth = Buffer.from('Bearer ' + token);
|
|
50
|
+
let origin;
|
|
51
|
+
const server = http.createServer(async (req, res) => {
|
|
52
|
+
try {
|
|
53
|
+
// Reject alternate Host names (including DNS rebinding) and cross-site
|
|
54
|
+
// browser requests. No CORS permission is granted, including preflight.
|
|
55
|
+
if (req.headers.host !== new URL(origin).host) return reply(res, 403, { error: 'This view only accepts its local address.' });
|
|
56
|
+
if (req.headers.origin && req.headers.origin !== origin || req.headers['sec-fetch-site'] && !['same-origin', 'none'].includes(req.headers['sec-fetch-site'])) return reply(res, 403, { error: 'Open the local Memoir view directly.' });
|
|
57
|
+
const url = new URL(req.url, origin);
|
|
58
|
+
const asset = assets.get(url.pathname);
|
|
59
|
+
if (req.method === 'GET' && asset && !url.search) return reply(res, 200, await fs.readFile(new URL('./ui/' + asset[0], import.meta.url)), asset[1]);
|
|
60
|
+
if (!url.pathname.startsWith('/api/')) return reply(res, 404, { error: 'Not found.' });
|
|
61
|
+
const auth = Buffer.from(req.headers.authorization || '');
|
|
62
|
+
if (auth.length !== expectedAuth.length || !crypto.timingSafeEqual(auth, expectedAuth)) return reply(res, 401, { error: 'Reopen the view with its local link.' });
|
|
63
|
+
if (req.method === 'GET' && url.pathname === '/api/state' && !url.search) return reply(res, 200, await reviewWork(root));
|
|
64
|
+
if (req.method !== 'POST' || url.pathname !== '/api/action' || url.search) return reply(res, 405, { error: 'This action is unavailable.' });
|
|
65
|
+
if (req.headers.origin !== origin || req.headers['content-type'] !== 'application/json') return reply(res, 403, { error: 'Save changes from the local view.' });
|
|
66
|
+
const input = actionSchema.parse(await body(req));
|
|
67
|
+
const guard = { expectedBranch: input.branch };
|
|
68
|
+
if (input.action === 'save') {
|
|
69
|
+
if (input.category !== 'record' || !input.fields) return reply(res, 400, { error: 'Only project records can be edited.' });
|
|
70
|
+
const { answer, why, ...fields } = input.fields;
|
|
71
|
+
await recordWork(root, { ...fields, ...(answer ? { answer } : {}), ...(why ? { why } : {}), id: input.id, expected_revision: input.expected_revision, scope: 'project', source: 'Saved in the local project view; previous versions remain in history.' }, guard);
|
|
72
|
+
} else if (input.action === 'remove') {
|
|
73
|
+
await retractWork(root, input, guard);
|
|
74
|
+
} else {
|
|
75
|
+
if (input.category !== 'record') return reply(res, 400, { error: 'Run a new authorized check to replace a removed receipt.' });
|
|
76
|
+
await restoreWork(root, input, guard);
|
|
77
|
+
}
|
|
78
|
+
return reply(res, 200, await reviewWork(root));
|
|
79
|
+
} catch (error) {
|
|
80
|
+
const message = workErrorMessage(error);
|
|
81
|
+
const conflict = /branch changed|Record changed|Record was removed|expected revision|before retracting/.test(message);
|
|
82
|
+
if (!res.headersSent && !res.destroyed) reply(res, 409, conflict
|
|
83
|
+
? { error: 'Another session changed this item or branch. Review the latest version before saving. Your draft has been kept.', code: 'refresh_required' }
|
|
84
|
+
: { error: message });
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
server.requestTimeout = 10000; server.headersTimeout = 10000; server.keepAliveTimeout = 1000;
|
|
88
|
+
await new Promise((resolve, reject) => {
|
|
89
|
+
server.once('error', reject);
|
|
90
|
+
server.listen(port, '127.0.0.1', () => { origin = `http://127.0.0.1:${server.address().port}`; resolve(); });
|
|
91
|
+
});
|
|
92
|
+
return { server, origin, url: `${origin}/#token=${token}`, close: () => new Promise(resolve => { server.close(resolve); server.closeIdleConnections?.(); }) };
|
|
93
|
+
}
|