openvibers/openvibe.codes Commits

Memory M3: the guided import on the Memory page (#282)

OP OpenVibers committed 7 days ago · e09ce15175 · 4 files, +353 −7

Changes

ddiff --git a/server/domain/memory.js b/server/domain/memory.js
iindex 9b3b9f1..594cddb 100644
---- a/server/domain/memory.js
++++ b/server/domain/memory.js
@@@ -16,6 +16,10 @@
* reads(token, limit) who read the person's memory (never the text)
* split(token, id) undo a merge
* settle(token, id, keep) settle a conflict: one stays, the other goes
++ * providers(token) the cloud walkthroughs and the extraction prompt (M3)
++ * preview(token, { body, contentType }) parse an export into redacted candidates; nothing is kept
++ * commit(token, id, keep) keep the ticked candidates, and drop the draft
++ * discard(token, id) throw the draft away
*
* Every call carries the person's own Bearer token. A refusal from AI arrives as an error with its problem
* code (memory.not_found, input.invalid, …) and its detail — never a note's text.
@@@ -54,6 +58,35 @@ function createMemory({ config, fetchImpl = globalThis.fetch, log = console } =
return doc || {};
}
++ /** A raw body (an export zip or one multipart form) straight to the store; it parses it, never Codes. */
++ async function upload(token, path, body, contentType) {
++ if (!token) throw Object.assign(new Error('Not signed in'), { status: 401, code: 'memory.signed_out' });
++ let res;
++ try {
++ res = await fetchImpl(`${base}${path}`, {
++ method: 'POST',
++ headers: {
++ Authorization: `Bearer ${token}`, Accept: 'application/json',
++ 'Content-Type': contentType || 'application/octet-stream',
++ },
++ body,
++ // Parsing a large export is slower than one item; still bounded, so a stuck store frees the page.
++ signal: AbortSignal.timeout(60000),
++ });
++ } catch (e) {
++ log.warn(`[Codes] the memory store did not answer the import: ${String(e && e.message).slice(0, 120)}`);
++ throw Object.assign(new Error('The memory store did not answer'), { status: 503, code: 'memory.unavailable' });
++ }
++ const doc = await res.json().catch(() => null);
++ if (!res.ok) {
++ const code = String((doc && doc.code) || 'memory.unavailable');
++ const detail = String((doc && (doc.detail || doc.title || doc.error_description)) || 'the memory store refused the import').slice(0, 200);
++ log.warn(`[Codes] the memory store refused an import: ${res.status} ${code}`);
++ throw Object.assign(new Error(detail), { status: res.status, code });
++ }
++ return doc || {};
++ }
++
/** The person's own items. `q` is a search over the text; the store answers with at most 200 items. */
async function items(token, { scope, status, q } = {}) {
const query = new URLSearchParams();
@@@ -108,7 +141,37 @@ function createMemory({ config, fetchImpl = globalThis.fetch, log = console } =
return { kept: doc.kept || null, archived: doc.archived || null };
}
-- return { items, patch, remove, removeBySource, profile, reads, split, settle };
++ /* ── Memory M3: the guided cloud import ──────────────────────────────────────────────────────────────── */
++
++ /** The walkthroughs and the extraction prompt. No note ever comes back from this call. */
++ async function providers(token) {
++ const doc = await call(token, 'GET', `${MEMORY_PATH}/import/providers`);
++ return { providers: Array.isArray(doc.providers) ? doc.providers : [], prompt: String(doc.prompt || '') };
++ }
++
++ /**
++ * Parse one export without keeping it. The body goes to the store untouched (a zip or one multipart form);
++ * the answer is the sealed draft's id and its redacted candidates, which the page shows for a choice.
++ */
++ async function preview(token, { body, contentType } = {}) {
++ if (body == null) throw Object.assign(new Error('Choose an export file or paste an answer'), { status: 422, code: 'import.invalid' });
++ const doc = await upload(token, `${MEMORY_PATH}/import/preview`, body, contentType);
++ return { import: doc.import || null };
++ }
++
++ /** Keep the ticked candidates, and let the rest go with the draft. */
++ async function commit(token, id, keep) {
++ const doc = await call(token, 'POST', `${MEMORY_PATH}/import/${encodeURIComponent(id)}/commit`, { keep: Array.isArray(keep) ? keep : [] });
++ return { kept: Number(doc.kept) || 0, created: Number(doc.created) || 0, deduped: Number(doc.deduped) || 0, left_out: Number(doc.left_out) || 0 };
++ }
++
++ /** Throw a preview away without keeping anything. */
++ async function discard(token, id) {
++ const doc = await call(token, 'DELETE', `${MEMORY_PATH}/import/${encodeURIComponent(id)}`);
++ return { discarded: Number(doc.discarded) || 0 };
++ }
++
++ return { items, patch, remove, removeBySource, profile, reads, split, settle, providers, preview, commit, discard };
}
module.exports = { createMemory, MEMORY_PATH };
ddiff --git a/server/http/memory.js b/server/http/memory.js
iindex 62b5714..e82dfc5 100644
---- a/server/http/memory.js
++++ b/server/http/memory.js
@@@ -4,10 +4,13 @@
* The Memory page (OpenVibe Memory M2b): what the person's own assistants know about them.
*
* The notes live in OpenVibe.AI's memory store, sealed there and opened only by the person's own token. This
-- * page never holds a note: it forwards the person's token to the store (server/domain/memory.js), renders what
-- * comes back and forgets it. The token is never written into the page, and no note text is ever logged.
++ * page never stores a note: it forwards the person's token to the store (server/domain/memory.js), renders what
++ * comes back and forgets it. An import preview passes through the same way, only long enough to show the
++ * candidates. The token is never written into the page, and no note text is ever logged.
*
-- * GET /memory the page: the sources, then one tab (notes, disagree, suggested, profile, reads)
++ * GET /memory the page: the sources, then one tab (notes, disagree, suggested, profile, reads, import)
++ * POST /memory/import/preview an export zip or pasted memory list, parsed by the store into a preview
++ * POST /memory/import/:id/commit the candidates the person ticked, and the draft is gone
* GET /memory/remove what one source's removal would take away, and the one form that does it
* POST /memory/remove everything from one source, gone (the store's own delete)
* POST /memory/items/:id/edit the note's text and kind
@@@ -23,6 +26,7 @@
* only script on the site is the one the other workspace pages already load.
*/
++const express = require('express');
const { asyncRouter } = require('./router');
const { esc } = require('../render/html');
@@@ -32,6 +36,7 @@ const SOURCE_NAMES = {
copilot: 'GitHub Copilot', windsurf: 'Windsurf', cline: 'Cline', roo: 'Roo Code', kiro: 'Kiro',
continue: 'Continue', aider: 'Aider', 'command-code': 'Command Code', opencode: 'OpenCode',
actor: 'Actor', vibe: 'Vibe', know: 'Know', person: 'You', codes: 'Codes', codex: 'Codex',
++ chatgpt: 'ChatGPT', 'claude-ai': 'Claude', 'gemini-apps': 'Gemini', paste: 'Pasted memory',
};
const sourceName = (id) => SOURCE_NAMES[String(id || '')] || String(id || '').replace(/[-_]+/g, ' ');
@@@ -70,7 +75,7 @@ const agoOf = (value) => {
const MEMORY_TABS = [
['notes', 'Notes'], ['disagree', 'Disagree'], ['suggested', 'Suggested'],
-- ['profile', 'Profile'], ['reads', 'Who read it'],
++ ['profile', 'Profile'], ['reads', 'Who read it'], ['import', 'Import'],
];
/** What the store's refusals say to a person. The words are ours: no note text ever reaches a flash. */
@@@ -83,6 +88,11 @@ const PROBLEMS = {
'memory.unreadable': 'That note could not be read.',
'memory.unavailable': 'Your memory is not available right now.',
'memory.signed_out': 'Sign in again to see your memory.',
++ 'import.bad_zip': 'That file is not a zip archive we can read.',
++ 'import.invalid': 'That file is not an export this page reads.',
++ 'import.expired': 'That preview has expired. Drop the export again.',
++ 'import.not_found': 'That preview is no longer here.',
++ 'input.too_large': 'That upload is too large. Export only the assistant’s data.',
};
const problemWord = (e) => PROBLEMS[String((e && e.code) || '')] || 'Your memory could not be changed. Try again in a moment.';
@@@ -96,6 +106,17 @@ function createMemoryRoutes(ctx, kit) {
const { page, wrap, header, pill, flash, sameOrigin, viewer, form, signIn } = kit;
const { sso, memory } = ctx;
const router = asyncRouter();
++ // An export is raw bytes (one multipart form from the page, or a zip in a test); the store parses it, not Codes.
++ const importBody = express.raw({
++ type: ['multipart/form-data', 'application/zip', 'application/x-zip-compressed', 'application/octet-stream'],
++ limit: '25mb',
++ });
++ const importUpload = (req, res, next) => importBody(req, res, (err) => {
++ if (!err) return next();
++ return back(req, res, 'import', '', err.type === 'entity.too.large'
++ ? 'That upload is larger than 25 MB. Export only the assistant’s data, or paste the extraction answer.'
++ : 'That upload could not be read.');
++ });
/** The person's own token, used for one call to the store. It never leaves this process. */
async function withToken(req, res, call) {
@@@ -158,6 +179,18 @@ function createMemoryRoutes(ctx, kit) {
else if (tab === 'suggested') panel = suggestedPanel(byStatus.suggested);
else if (tab === 'profile') panel = await profilePanel(req, res, scopes, byStatus.active);
else if (tab === 'reads') panel = await readsPanel(req, res);
++ else if (tab === 'import') {
++ let walkthroughs = null;
++ let prompt = '';
++ try {
++ const out = await withToken(req, res, (token) => memory.providers(token));
++ walkthroughs = out.providers;
++ prompt = out.prompt;
++ } catch (e) {
++ if (!problem) problem = e;
++ }
++ panel = importPanel(walkthroughs, prompt);
++ }
const content = head + flash(req)
+ (problem ? note('Your memory could not be read right now. ' + problemWord(problem), 'bad') : '')
@@@ -165,6 +198,88 @@ function createMemoryRoutes(ctx, kit) {
return page(req, res, title, wrap(content));
}
++ /** The Import tab: one walkthrough per cloud assistant, then one paste card that works anywhere. */
++ function importPanel(providers, prompt) {
++ const privacy = '<p class="c4-hint">We remove passwords, keys and tokens before anything is shown. '
++ + 'What you see are candidates, not saved notes: nothing is kept until you tick it and press Keep selected. '
++ + 'A preview lasts two hours.</p>';
++ if (!Array.isArray(providers) || !providers.length) {
++ return '<section class="c4-card"><h2>Bring in an export</h2>'
++ + '<p class="c4-hint">The import is not available right now. Try again in a moment.</p></section>';
++ }
++ const card = (p) => '<article class="c4-card">'
++ + '<div class="c4-card-head"><h2>' + esc(p.name) + '</h2>'
++ + '<a class="c4-quiet-link" href="' + esc(p.url) + '" target="_blank" rel="noopener">Open ' + esc(p.name) + '</a></div>'
++ + '<p class="c4-hint">' + esc(p.what) + '</p>'
++ + '<ol class="c4-steps">' + (p.steps || []).map((step) => '<li>' + esc(step) + '</li>').join('') + '</ol>'
++ + '<p class="c4-hint"><strong>What arrives:</strong> ' + esc(p.arrives) + '<br><strong>How long:</strong> ' + esc(p.time) + '</p>'
++ + '<form class="c4-form" method="post" action="/memory/import/preview" enctype="multipart/form-data">'
++ + '<input type="hidden" name="provider" value="' + esc(p.id) + '">'
++ + '<label class="c4-file">Your export .zip <input type="file" name="file" accept=".zip,application/zip,application/x-zip-compressed" required></label>'
++ + '<button class="c4-button" type="submit">Preview ' + esc(p.name) + '</button>'
++ + '</form></article>';
++ const paste = '<section class="c4-card"><div class="c4-card-head"><h2>Any assistant: ask what it remembers</h2>'
++ + '<span class="c4-muted">works for Grok, Copilot, Perplexity too</span></div>'
++ + '<p class="c4-hint">Open a new chat in the assistant, paste the prompt, then paste its answer here. '
++ + 'This is the same method Claude’s own memory import uses.</p>'
++ + '<details class="c4-more"><summary>Show the prompt to paste</summary>'
++ + '<div class="c4-more-body"><pre class="c4-code">' + esc(prompt) + '</pre></div></details>'
++ + '<form class="c4-form" method="post" action="/memory/import/preview" enctype="multipart/form-data">'
++ + '<label>Which assistant answered?<select name="provider">'
++ + '<option value="chatgpt">ChatGPT</option><option value="claude-ai">Claude</option>'
++ + '<option value="gemini-apps">Gemini</option><option value="other">Another assistant</option>'
++ + '</select></label>'
++ + '<label>Its answer<textarea name="text" rows="8" required placeholder="Paste the bullet list here"></textarea></label>'
++ + '<button class="c4-button" type="submit">Preview the answer</button>'
++ + '</form></section>';
++ return '<p class="c4-hint">Bring in what ChatGPT, Claude or Gemini already remember about you. '
++ + 'Each export is parsed by your memory store, not on your computer, and never stored as a chat.</p>'
++ + privacy
++ + '<div class="c4-grid c4-import-sources">' + providers.map(card).join('') + '</div>'
++ + paste;
++ }
++
++ /** The preview: every candidate with its own tick. The form carries only the draft id and the ticked keys. */
++ function previewPage(req, res, out) {
++ const draft = (out && out.import) || {};
++ const title = 'Import preview';
++ const head = header(title, 'Nothing is saved until you press Keep selected.');
++ const from = sourceName(draft.provider || '');
++ const summary = draft.summary || {};
++ const items = Array.isArray(draft.items) ? draft.items : [];
++ if (!items.length) {
++ return page(req, res, title, wrap(head + flash(req)
++ + '<section class="c4-card"><h2>No notes found</h2>'
++ + '<p class="c4-hint">' + esc(summary.note || 'That export did not contain anything that looked like a memory.')
++ + ' The extraction prompt finds more than a chat export does.</p>'
++ + '<p><a class="c4-button" href="/memory?tab=import">Try the extraction prompt</a></p></section>'));
++ }
++ const when = (value) => { const day = dayOf(value); return day ? ' · ' + day : ''; };
++ const rows = items.map((item, i) => {
++ const heading = item.source_ref && item.source_ref.heading ? ' · “' + String(item.source_ref.heading) + '”' : '';
++ return '<label class="c4-check" for="keep-' + i + '">'
++ + '<input type="checkbox" id="keep-' + i + '" name="keep" value="' + esc(item.key) + '" checked>'
++ + '<span>' + esc(item.text)
++ + '<small>' + esc(kindName(item.kind)) + ' · ' + esc(sourceName(item.source)) + esc(heading) + esc(when(item.seen_at)) + '</small>'
++ + '</span></label>';
++ }).join('');
++ const secrets = Number(summary.secrets_removed) || 0;
++ const more = Number(summary.skipped) || 0;
++ const counts = items.length + (items.length === 1 ? ' possible note' : ' possible notes')
++ + (from ? ' from ' + from : '')
++ + (secrets ? ' · ' + secrets + ' secret' + (secrets === 1 ? ' was' : 's were') + ' removed before we showed them' : '')
++ + (more ? ' · ' + more + ' older ones stay in your file' : '') + '.';
++ const form = '<form class="c4-form" method="post" action="/memory/import/' + encodeURIComponent(draft.id) + '/commit">'
++ + '<p class="c4-hint">' + esc(counts) + (summary.note ? ' ' + esc(summary.note) : '') + '</p>'
++ + '<div class="c4-checks">' + rows + '</div>'
++ + '<div class="c4-actions"><button class="c4-button" type="submit">Keep selected</button>'
++ + '<button class="c4-opill c4-opill-quiet" type="submit" name="action" value="discard" formnovalidate>Discard all</button></div>'
++ + '<p class="c4-hint"><a href="/memory?tab=import">Back to the walkthroughs</a>. The ticked notes go into your memory, '
++ + 'never back to the assistant.</p></form>';
++ res.set('Cache-Control', 'no-store');
++ return page(req, res, title, wrap(head + flash(req) + '<section class="c4-card">' + form + '</section>'));
++ }
++
/** The sources card: where the notes came from, how many there are and when they were last seen. */
function sourcesPanel(rows) {
const head = '<section class="c4-card"><div class="c4-card-head"><h2>Sources</h2><span class="c4-muted">'
@@@ -369,6 +484,38 @@ function createMemoryRoutes(ctx, kit) {
router.get('/memory', async (req, res) => memoryPage(req, res));
router.get('/memory/remove', async (req, res) => removePage(req, res));
++ // Memory M3: the export is forwarded untouched to the store, which parses, redacts and holds a sealed draft.
++ // The answer is shown once; nothing is kept until the person ticks and submits the preview form.
++ router.post('/memory/import/preview', importUpload, async (req, res) => {
++ if (refuse(req, res)) return;
++ if (!Buffer.isBuffer(req.body) || !req.body.length) return back(req, res, 'import', '', 'Choose an export file or paste an answer first.');
++ try {
++ const out = await withToken(req, res, (token) => memory.preview(token, { body: req.body, contentType: req.get('content-type') || '' }));
++ return previewPage(req, res, out);
++ } catch (e) {
++ return back(req, res, 'import', '', problemWord(e));
++ }
++ });
++
++ router.post('/memory/import/:id/commit', form, async (req, res) => {
++ if (refuse(req, res)) return;
++ const id = String(req.params.id || '');
++ if (!/^mimp_[0-9A-HJKMNP-TV-Z]{26}$/.test(id)) return back(req, res, 'import', '', 'That preview is no longer here.');
++ const body = req.body || {};
++ try {
++ if (String(body.action || '') === 'discard') {
++ await withToken(req, res, (token) => memory.discard(token, id));
++ return back(req, res, 'import', 'Nothing was kept.');
++ }
++ const keep = body.keep == null ? [] : Array.isArray(body.keep) ? body.keep : [body.keep];
++ const out = await withToken(req, res, (token) => memory.commit(token, id, keep));
++ return back(req, res, 'import', 'Kept ' + out.kept + (out.kept === 1 ? ' note' : ' notes')
++ + (out.left_out ? '; ' + out.left_out + ' left out.' : '.'));
++ } catch (e) {
++ return back(req, res, 'import', '', problemWord(e));
++ }
++ });
++
router.post('/memory/remove', form, async (req, res) => {
if (refuse(req, res)) return res;
const source = String((req.body && req.body.source) || '');
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex 477a10d..0478c44 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -56,7 +56,12 @@ async function boot(opts = {}) {
let body = o.body;
if (o.json !== undefined) { body = JSON.stringify(o.json); headers['content-type'] = 'application/json'; }
if (o.form) {
-- body = new URLSearchParams({ ...o.form }).toString();
++ // A repeated field (the import's per-note ticks) is an array in the test, and repeated keys on the wire.
++ const params = new URLSearchParams();
++ for (const [key, value] of Object.entries(o.form)) {
++ for (const one of Array.isArray(value) ? value : [value]) params.append(key, String(one));
++ }
++ body = params.toString();
headers['content-type'] = 'application/x-www-form-urlencoded';
}
const res = await fetch(base + p, { method: o.method || (body ? 'POST' : 'GET'), headers, body, redirect: 'manual' });
ddiff --git a/test/memory.test.js b/test/memory.test.js
iindex 9ac6120..bcd4ee0 100644
---- a/test/memory.test.js
++++ b/test/memory.test.js
@@@ -7,8 +7,25 @@ const assert = require('assert');
const { boot, check, done } = require('./helpers/boot');
const id = (n) => 'mem_' + String(n).padStart(26, '0');
++const IMPORT_ID = 'mimp_01JZ0000000000000000000M0P';
const WHEN = new Date(Date.now() - 36e5).toISOString();
++/** One multipart form, the way the import page posts an export zip (or a pasted answer). */
++function multipartBody(fields, file) {
++ const boundary = '----codes-memory-test';
++ const parts = [];
++ for (const [name, value] of Object.entries(fields || {})) {
++ parts.push(`--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`);
++ }
++ if (file) {
++ parts.push(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${file.name}"\r\nContent-Type: application/zip\r\n\r\n`);
++ parts.push(file.data);
++ parts.push('\r\n');
++ }
++ parts.push(`--${boundary}--\r\n`);
++ return { boundary, body: Buffer.concat(parts.map((p) => Buffer.isBuffer(p) ? p : Buffer.from(p, 'utf8'))) };
++}
++
/** A stand-in for the store: 40 notes, one merged pair, 2 conflicts, 3 suggestions and 3 reads. */
async function startStore() {
const calls = [];
@@@ -63,7 +80,9 @@ async function startStore() {
req.on('end', () => {
const raw = Buffer.concat(chunks).toString('utf8');
const url = new URL(req.url, 'http://store');
-- const call = { method: req.method, path: url.pathname, query: url.search, auth: req.headers.authorization || '', body: raw ? JSON.parse(raw) : null };
++ let parsed = null;
++ try { parsed = raw ? JSON.parse(raw) : null; } catch { parsed = null; }
++ const call = { method: req.method, path: url.pathname, query: url.search, auth: req.headers.authorization || '', headers: req.headers, body: parsed, raw };
calls.push(call);
const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
if (!/^Bearer /.test(call.auth)) return json(401, { code: 'auth.denied', detail: 'no token' });
@@@ -75,6 +94,32 @@ async function startStore() {
if (call.method === 'DELETE' && url.pathname === '/api/v1/memory/items') return json(200, { deleted: 41 });
if (call.method === 'POST' && url.pathname.endsWith('/split')) return json(200, { items: [], count: 2, split: 2 });
if (call.method === 'POST' && url.pathname.endsWith('/settle')) return json(200, { kept: { id: 'kept' }, archived: { id: 'archived' } });
++ // Memory M3: the walkthroughs, the preview draft and the commit.
++ if (call.method === 'GET' && url.pathname === '/api/v1/memory/import/providers') {
++ return json(200, {
++ providers: [
++ { id: 'chatgpt', name: 'ChatGPT', what: 'Your chats.', url: 'https://chatgpt.com/', steps: ['Export data'], arrives: 'A zip.', time: 'Minutes.' },
++ { id: 'claude-ai', name: 'Claude', what: 'Claude memory.', url: 'https://claude.ai/', steps: ['Export data'], arrives: 'A zip.', time: 'Minutes.' },
++ { id: 'gemini-apps', name: 'Gemini', what: 'Takeout.', url: 'https://takeout.google.com/', steps: ['My Activity'], arrives: 'A zip.', time: 'Hours.' },
++ ],
++ prompt: 'List everything you remember about me as short bullet points.',
++ });
++ }
++ if (call.method === 'POST' && url.pathname === '/api/v1/memory/import/preview') {
++ return json(200, {
++ import: {
++ id: 'mimp_01JZ0000000000000000000M0P', provider: 'chatgpt',
++ summary: { conversations: 2, messages: 5, secrets_removed: 1, skipped: 0, note: '' },
++ items: [
++ { key: 'k1', text: 'I prefer short answers.', kind: 'preference', source: 'chatgpt', seen_at: WHEN, source_ref: { path: 'conversations.json', heading: 'How I work' } },
++ { key: 'k2', text: 'I deploy on Thursdays.', kind: 'fact', source: 'chatgpt', seen_at: WHEN },
++ ],
++ expires_at: new Date(Date.now() + 72e5).toISOString(),
++ },
++ });
++ }
++ if (call.method === 'POST' && url.pathname.endsWith('/commit')) return json(200, { kept: 2, created: 2, deduped: 0, left_out: 1 });
++ if (call.method === 'DELETE' && url.pathname.startsWith('/api/v1/memory/import/')) return json(200, { discarded: 2 });
return json(404, { code: 'memory.not_found', detail: 'no such route' });
});
});
@@@ -288,5 +333,91 @@ await check('a cross-site post is refused, and a note id that is not a note neve
} finally { await h.close(); await store.close(); }
});
++await check('the Import tab walks through the three assistants and offers the paste prompt', async () => {
++ const store = await startStore();
++ const h = await boot({ env: { CODES_AI_INTERNAL_URL: store.url } });
++ try {
++ const user = h.network.addUser('maria');
++ const res = await h.get('/memory?tab=import', { as: user });
++ assert.equal(res.status, 200);
++ assert.ok(res.text.includes('ChatGPT') && res.text.includes('Claude') && res.text.includes('Gemini'), 'the three cards');
++ assert.ok(res.text.includes('Show the prompt to paste'), 'the extraction prompt');
++ assert.ok(res.text.includes('List everything you remember about me'), 'the prompt text');
++ assert.ok(res.text.includes('name="file"') && res.text.includes('enctype="multipart/form-data"'), 'the zip form');
++ assert.ok(res.text.includes('Preview the answer'), 'the paste form');
++ assert.ok(res.text.includes('nothing is kept until you tick it'), 'the consent line');
++ assert.ok(!res.text.includes('Note number 1'), 'no note text on the import tab');
++ } finally { await h.close(); await store.close(); }
++});
++
++await check('an export preview shows every candidate with its own tick, and the page never carries the token', async () => {
++ const store = await startStore();
++ const h = await boot({ env: { CODES_AI_INTERNAL_URL: store.url } });
++ try {
++ const user = h.network.addUser('maria');
++ const cookie = h.signIn(user);
++ const token = cookie.split('=')[1];
++ const { boundary, body } = multipartBody({ provider: 'chatgpt' }, { name: 'export.zip', data: Buffer.from('PK-synthetic') });
++ const res = await h.get('/memory/import/preview', {
++ cookie, method: 'POST', body, headers: { 'content-type': `multipart/form-data; boundary=${boundary}` },
++ });
++ assert.equal(res.status, 200);
++ assert.ok(res.text.includes('I prefer short answers.'), 'the first candidate');
++ assert.ok(res.text.includes('I deploy on Thursdays.'), 'the second candidate');
++ assert.ok(res.text.includes('name="keep" value="k1" checked'), 'ticked by default');
++ assert.ok(res.text.includes('action="/memory/import/' + IMPORT_ID + '/commit"'), 'the commit form');
++ assert.ok(res.text.includes('1 secret was removed'), 'the redaction count');
++ assert.ok(res.text.includes('Discard all'), 'the way out');
++ assert.ok(!res.text.includes(token), 'no person token in the page');
++ assert.ok(!h.logs().includes('I prefer short answers.'), 'no candidate text in the logs');
++ assert.ok(!h.logs().includes(token), 'no token in the logs');
++ const call = store.last('POST', '/api/v1/memory/import/preview');
++ assert.ok(call, 'the store parsed the export');
++ assert.equal(call.auth, 'Bearer ' + token);
++ assert.ok(call.headers['content-type'].startsWith('multipart/form-data; boundary='), 'the form is forwarded untouched');
++ assert.ok(Buffer.from(call.raw, 'latin1').includes(Buffer.from('PK-synthetic')), 'the bytes reached the store');
++ } finally { await h.close(); await store.close(); }
++});
++
++await check('the commit keeps the ticked notes and the flash says what happened', async () => {
++ const store = await startStore();
++ const h = await boot({ env: { CODES_AI_INTERNAL_URL: store.url } });
++ try {
++ const user = h.network.addUser('maria');
++ const cookie = h.signIn(user);
++ const res = await h.get('/memory/import/' + IMPORT_ID + '/commit', {
++ cookie, method: 'POST', form: { keep: ['k1', 'k2'], action: 'keep', tab: 'import' },
++ });
++ assert.equal(res.status, 303);
++ assert.ok(res.headers.get('location').includes('tab=import'), 'back to the import tab');
++ assert.ok(decodeURIComponent(res.headers.get('location')).includes('Kept 2 notes'), 'the count');
++ const call = store.last('POST', '/api/v1/memory/import/' + IMPORT_ID + '/commit');
++ assert.deepStrictEqual(call.body.keep, ['k1', 'k2'], 'only the ticked keys');
++ assert.equal(call.auth, 'Bearer ' + cookie.split('=')[1]);
++ } finally { await h.close(); await store.close(); }
++});
++
++await check('Discard all throws the draft away, and a cross-site upload is refused before the store sees it', async () => {
++ const store = await startStore();
++ const h = await boot({ env: { CODES_AI_INTERNAL_URL: store.url } });
++ try {
++ const user = h.network.addUser('maria');
++ const cookie = h.signIn(user);
++ const discard = await h.get('/memory/import/' + IMPORT_ID + '/commit', {
++ cookie, method: 'POST', form: { action: 'discard', tab: 'import' },
++ });
++ assert.equal(discard.status, 303);
++ assert.ok(decodeURIComponent(discard.headers.get('location')).includes('Nothing was kept'), 'the flash');
++ assert.ok(store.last('DELETE', '/api/v1/memory/import/' + IMPORT_ID), 'the draft is deleted');
++
++ const before = store.calls.length;
++ const cross = await h.get('/memory/import/preview', {
++ cookie, method: 'POST', body: Buffer.from('PK'), headers: { origin: 'https://evil.example', 'content-type': 'application/zip' },
++ });
++ assert.equal(cross.status, 403);
++ assert.equal(store.calls.length, before, 'the store was never asked');
++ } finally { await h.close(); await store.close(); }
++});
++
done();
})().catch((e) => { console.error(e); process.exit(1); });