openvibers/openvibe.codes Commits
Memory M3: the guided import on the Memory page (#282)
Changes
@@@ -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 };@@@ -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) || '');@@@ -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' });@@@ -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); });
@@ -16,6 +16,10 @@
@@ -16,6 +16,10 @@
* reads(token, limit) who read the person's memory (never the text)
* reads(token, limit) who read the person's memory (never the text)
* split(token, id) undo a merge
* split(token, id) undo a merge
* settle(token, id, keep) settle a conflict: one stays, the other goes
* 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
* 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.
* 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 } =
@@ -54,6 +58,35 @@ function createMemory({ config, fetchImpl = globalThis.fetch, log = console } =
return doc || {};
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. */
/** 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 } = {}) {
async function items(token, { scope, status, q } = {}) {
const query = new URLSearchParams();
const query = new URLSearchParams();
@@ -108,7 +141,37 @@ function createMemory({ config, fetchImpl = globalThis.fetch, log = console } =
@@ -108,7 +141,37 @@ function createMemory({ config, fetchImpl = globalThis.fetch, log = console } =
return { kept: doc.kept || null, archived: doc.archived || null };
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 };
module.exports = { createMemory, MEMORY_PATH };
@@ -4,10 +4,13 @@
@@ -4,10 +4,13 @@
* The Memory page (OpenVibe Memory M2b): what the person's own assistants know about them.
* 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
* 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
* 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/remove everything from one source, gone (the store's own delete)
* POST /memory/items/:id/edit the note's text and kind
* POST /memory/items/:id/edit the note's text and kind
@@ -23,6 +26,7 @@
@@ -23,6 +26,7 @@
* only script on the site is the one the other workspace pages already load.
* only script on the site is the one the other workspace pages already load.
*/
*/
+const express = require('express');
const { asyncRouter } = require('./router');
const { asyncRouter } = require('./router');
const { esc } = require('../render/html');
const { esc } = require('../render/html');
@@ -32,6 +36,7 @@ const SOURCE_NAMES = {
@@ -32,6 +36,7 @@ const SOURCE_NAMES = {
copilot: 'GitHub Copilot', windsurf: 'Windsurf', cline: 'Cline', roo: 'Roo Code', kiro: 'Kiro',
copilot: 'GitHub Copilot', windsurf: 'Windsurf', cline: 'Cline', roo: 'Roo Code', kiro: 'Kiro',
continue: 'Continue', aider: 'Aider', 'command-code': 'Command Code', opencode: 'OpenCode',
continue: 'Continue', aider: 'Aider', 'command-code': 'Command Code', opencode: 'OpenCode',
actor: 'Actor', vibe: 'Vibe', know: 'Know', person: 'You', codes: 'Codes', codex: 'Codex',
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, ' ');
const sourceName = (id) => SOURCE_NAMES[String(id || '')] || String(id || '').replace(/[-_]+/g, ' ');
@@ -70,7 +75,7 @@ const agoOf = (value) => {
@@ -70,7 +75,7 @@ const agoOf = (value) => {
const MEMORY_TABS = [
const MEMORY_TABS = [
['notes', 'Notes'], ['disagree', 'Disagree'], ['suggested', 'Suggested'],
['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. */
/** 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 = {
@@ -83,6 +88,11 @@ const PROBLEMS = {
'memory.unreadable': 'That note could not be read.',
'memory.unreadable': 'That note could not be read.',
'memory.unavailable': 'Your memory is not available right now.',
'memory.unavailable': 'Your memory is not available right now.',
'memory.signed_out': 'Sign in again to see your memory.',
'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.';
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) {
@@ -96,6 +106,17 @@ function createMemoryRoutes(ctx, kit) {
const { page, wrap, header, pill, flash, sameOrigin, viewer, form, signIn } = kit;
const { page, wrap, header, pill, flash, sameOrigin, viewer, form, signIn } = kit;
const { sso, memory } = ctx;
const { sso, memory } = ctx;
const router = asyncRouter();
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. */
/** The person's own token, used for one call to the store. It never leaves this process. */
async function withToken(req, res, call) {
async function withToken(req, res, call) {
@@ -158,6 +179,18 @@ function createMemoryRoutes(ctx, kit) {
@@ -158,6 +179,18 @@ function createMemoryRoutes(ctx, kit) {
else if (tab === 'suggested') panel = suggestedPanel(byStatus.suggested);
else if (tab === 'suggested') panel = suggestedPanel(byStatus.suggested);
else if (tab === 'profile') panel = await profilePanel(req, res, scopes, byStatus.active);
else if (tab === 'profile') panel = await profilePanel(req, res, scopes, byStatus.active);
else if (tab === 'reads') panel = await readsPanel(req, res);
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)
const content = head + flash(req)
+ (problem ? note('Your memory could not be read right now. ' + problemWord(problem), 'bad') : '')
+ (problem ? note('Your memory could not be read right now. ' + problemWord(problem), 'bad') : '')
@@ -165,6 +198,88 @@ function createMemoryRoutes(ctx, kit) {
@@ -165,6 +198,88 @@ function createMemoryRoutes(ctx, kit) {
return page(req, res, title, wrap(content));
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. */
/** The sources card: where the notes came from, how many there are and when they were last seen. */
function sourcesPanel(rows) {
function sourcesPanel(rows) {
const head = '<section class="c4-card"><div class="c4-card-head"><h2>Sources</h2><span class="c4-muted">'
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) {
@@ -369,6 +484,38 @@ function createMemoryRoutes(ctx, kit) {
router.get('/memory', async (req, res) => memoryPage(req, res));
router.get('/memory', async (req, res) => memoryPage(req, res));
router.get('/memory/remove', async (req, res) => removePage(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) => {
router.post('/memory/remove', form, async (req, res) => {
if (refuse(req, res)) return res;
if (refuse(req, res)) return res;
const source = String((req.body && req.body.source) || '');
const source = String((req.body && req.body.source) || '');
@@ -56,7 +56,12 @@ async function boot(opts = {}) {
@@ -56,7 +56,12 @@ async function boot(opts = {}) {
let body = o.body;
let body = o.body;
if (o.json !== undefined) { body = JSON.stringify(o.json); headers['content-type'] = 'application/json'; }
if (o.json !== undefined) { body = JSON.stringify(o.json); headers['content-type'] = 'application/json'; }
if (o.form) {
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';
headers['content-type'] = 'application/x-www-form-urlencoded';
}
}
const res = await fetch(base + p, { method: o.method || (body ? 'POST' : 'GET'), headers, body, redirect: 'manual' });
const res = await fetch(base + p, { method: o.method || (body ? 'POST' : 'GET'), headers, body, redirect: 'manual' });
@@ -7,8 +7,25 @@ const assert = require('assert');
@@ -7,8 +7,25 @@ const assert = require('assert');
const { boot, check, done } = require('./helpers/boot');
const { boot, check, done } = require('./helpers/boot');
const id = (n) => 'mem_' + String(n).padStart(26, '0');
const id = (n) => 'mem_' + String(n).padStart(26, '0');
+const IMPORT_ID = 'mimp_01JZ0000000000000000000M0P';
const WHEN = new Date(Date.now() - 36e5).toISOString();
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. */
/** A stand-in for the store: 40 notes, one merged pair, 2 conflicts, 3 suggestions and 3 reads. */
async function startStore() {
async function startStore() {
const calls = [];
const calls = [];
@@ -63,7 +80,9 @@ async function startStore() {
@@ -63,7 +80,9 @@ async function startStore() {
req.on('end', () => {
req.on('end', () => {
const raw = Buffer.concat(chunks).toString('utf8');
const raw = Buffer.concat(chunks).toString('utf8');
const url = new URL(req.url, 'http://store');
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);
calls.push(call);
const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
if (!/^Bearer /.test(call.auth)) return json(401, { code: 'auth.denied', detail: 'no token' });
if (!/^Bearer /.test(call.auth)) return json(401, { code: 'auth.denied', detail: 'no token' });
@@ -75,6 +94,32 @@ async function startStore() {
@@ -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 === '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('/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' } });
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' });
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
@@ -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(); }
} 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();
done();
})().catch((e) => { console.error(e); process.exit(1); });
})().catch((e) => { console.error(e); process.exit(1); });