Skip to content

Commit 49ab023

Browse files
committed
feat(sessions): Copy as Markdown — export a session, scrubbed
The M4 export slice (levelcode-sessions-experience.md §6): one action turns a session into a clean Markdown transcript for pasting into a PR, and it is the seed of a later share-a-run. **It scrubs, and that is not incidental.** chat-sessions-design.md §10 already decided this: transcripts at rest are the same trust class as your code, but "anything that later *shares* a session must scrub — that is that feature's burden." Export is the first surface that shares one, so redactSecrets — built last week for project memory — is passed in at the call site rather than baked into the renderer, so the scrub is visible where it happens instead of being a property you have to know the module has. **Roles are bold labels, never headings.** A turn routinely contains `## …` and fenced code; a heading-based role label is visually outranked by the content it is supposed to delimit, and `### LevelCode` above a reply opening with `#` reads as though the model wrote the section title. Bold plus a rule survives every renderer at every nesting depth. The success message counts TURNS, not characters: "Copied 14 turns" tells you whether you got the session you meant. **The fifth button.** The action row is two fixed-height lines and `flex-wrap: nowrap`, so buttons that do not fit do not wrap — they overflow. Five labelled buttons need ~300px and a sidebar is routinely narrower, so the card is now a container and the labels collapse below 360px, leaving icons at ~184px. Every button already carried title + aria-label, so nothing is lost to a pointer or a screen reader — only to the eye, and only when there is no room. Both webviews carry the card, and the pure block between the SESSIONS-PURE markers must stay byte-identical across them — sessionsView.html is synced, which is the existing test catching exactly what it was written for. Tests: 5 new in sessionEvents.test.js (structure, heading safety, the scrub, the opt-in, degenerate sessions), 2 in webviewCss.test.js pinning the overflow guard in BOTH sheets, and the action-row test updated to five buttons. The CSS guard is pinned because the failure is invisible in a wide window: whoever adds a sixth button will not see it break — a user with a narrow sidebar will. Verified non-vacuous: dropping the container query 13/15, the container-type 13/15, the aria-label 14/15, the export scrub 10/13. All 32 suites green.
1 parent b6aa236 commit 49ab023

8 files changed

Lines changed: 218 additions & 6 deletions

File tree

docs/levelcode-sessions-memory.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ The magic, delivered quietly (never a wall of text):
173173
-**Poisoning red-team pass.** `test/memoryPoisoning.test.js` — 34 cases, an adversarial corpus in the style of `commandSafety.test.js`: ten hostile shapes that must never self-promote, benign project facts that must keep working, nine credential shapes that must never reach disk, and the near-misses (git SHAs, content hashes, asset names) that must survive untouched. It found the gap it was written to look for — see §7. Every case verified non-vacuous by bypassing each guard and confirming failure.
174174
*Exit met: an adversarial repo cannot plant a load-bearing memory.* The original wording said "EXIT-TEST.md green", but that file is the **M0** fork/build checklist and was never the right home for this; an executable corpus is a better exit test than a checklist anyway, since it re-runs on every change.
175175
-**Decayed-entry recall** — surfacing an aged-out fact when a query matches it directly.
176-
- **Export** — "Copy as Markdown" for a session, and for the memory set. Cheap, since the storage is already plain text, and it seeds LevelLinks.
176+
- **Export** — "Copy as Markdown" on the session card (`sessionEvents.toMarkdown`), clipboard with a *Save as file…* follow-up. **Scrubbed**, because this is the first surface that *shares* a session and `levelcode-chat-sessions-design.md` §10 says sharing carries that burden — `redactSecrets` is passed in explicitly at the call site rather than baked into the renderer, so the scrub is visible where it happens. Roles render as bold labels, never headings: a turn's own `#`/`##` would otherwise outrank the label meant to delimit it. Memory-set export is still open.
177177

178178
**Deliberately later:** cross-*project* memory ("how did I do idempotency in the *other* service?"); a vector cache over the plain files for large corpora; team-shared project memory (rides M9 sync).
179179

extensions/levelcode-ai/extension.js

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const { registerInlineComplete } = require('./inlineComplete');
2323
const { runAgent } = require('./agent');
2424
const { findCompactionCut, estimateMsgTokens } = require('./agentMemory');
2525
const sessionStore = require('./sessionStore');
26+
const sessionEvents = require('./sessionEvents');
2627
const sessionMemory = require('./sessionMemory');
2728
const { createSessions } = require('./sessions');
2829
const { registerReview } = require('./reviewSession');
@@ -898,6 +899,7 @@ async function handleSessionAction(action, id) {
898899
postSessions({ type: 'sessionUndo', action, id, title }); // offer to undo — the card just vanished
899900
return;
900901
}
902+
if (action === 'export') { await exportSession(id); return; }
901903
if (action === 'restore') { m.restore(id); refreshSessions(); return; }
902904
if (action === 'pin') { const cur = (m.list().find((e) => e.id === id) || {}).pinned; m.setPinned(id, !cur); refreshSessions(); return; }
903905
if (action === 'rename') {
@@ -909,6 +911,52 @@ async function handleSessionAction(action, id) {
909911
} catch (e) { dbg('sessions.action.error', { action, id, msg: String((e && e.message) || e) }); }
910912
}
911913

914+
/**
915+
* Copy a session to the clipboard as Markdown, and offer to save it (experience doc §6).
916+
*
917+
* SCRUBBED on the way out. chat-sessions-design §10: transcripts at rest are the same trust class as
918+
* your code, but "anything that later *shares* a session must scrub — that is that feature's burden."
919+
* This is the first surface that shares one — the doc's own framing is paste-into-a-PR — so
920+
* `redactSecrets` (built for project memory) is passed in explicitly rather than left implied.
921+
*/
922+
async function exportSession(id) {
923+
const m = sessionsManager();
924+
if (!m || !id) { return; }
925+
const entry = (m.list().find((e) => e.id === id)) || {};
926+
const md = sessionEvents.toMarkdown(entry, m.transcript(id), { redact: sessionMemory.redactSecrets });
927+
928+
try { await vscode.env.clipboard.writeText(md); }
929+
catch (e) {
930+
vscode.window.showErrorMessage('Could not copy the session: ' + String((e && e.message) || e));
931+
return;
932+
}
933+
dbg('sessions.export', { id, chars: md.length });
934+
935+
// Report the size in TURNS, not characters: "Copied 14 turns" tells you whether you got the
936+
// session you meant; "Copied 8,214 characters" tells you nothing you can act on.
937+
const turns = sessionEvents.toDisplayTurns(m.transcript(id)).length;
938+
const pick = await vscode.window.showInformationMessage(
939+
'Copied ' + turns + ' turn' + (turns === 1 ? '' : 's') + ' as Markdown.', 'Save as file…');
940+
if (pick !== 'Save as file…') { return; }
941+
942+
// Derive a filename from the title so a folder of exports stays readable. The title has already
943+
// been through redactSecrets above, but it is sanitised again here for the FILESYSTEM's sake —
944+
// a slash or a colon in a title is a path, not a name.
945+
const stem = String(entry.title || 'session').toLowerCase()
946+
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'session';
947+
const target = await vscode.window.showSaveDialog({
948+
filters: { Markdown: ['md'] },
949+
defaultUri: vscode.Uri.file(path.join(os.homedir(), stem + '.md'))
950+
});
951+
if (!target) { return; }
952+
try {
953+
await vscode.workspace.fs.writeFile(target, Buffer.from(md, 'utf8'));
954+
vscode.window.showInformationMessage('Saved ' + path.basename(target.fsPath) + '.');
955+
} catch (e) {
956+
vscode.window.showErrorMessage('Could not save the session: ' + String((e && e.message) || e));
957+
}
958+
}
959+
912960
// Reopen a past session: restore its transcript as the live agent context (budget-fitted, §4.5), reset the
913961
// per-turn UI, and replay the readable conversation into the chat so it feels like walking back in.
914962
async function resumeSession(id) {

extensions/levelcode-ai/media/chat.html

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1106,6 +1106,13 @@
11061106
nothing wraps, reflows, or floats and the title above is left intact. The modal keeps icons + labels. */
11071107
.sesscard .sessline2 { margin-top: 4px; min-height: 26px; display: flex; align-items: center; }
11081108
.sesscard .sesssub { font-family: var(--vscode-editor-font-family, ui-monospace, monospace); font-size: 11.5px; color: var(--cc-text3); }
1109+
/* The action row is nowrap by design (the card is two fixed-height lines — nothing may reflow), so
1110+
a fifth button cannot be allowed to overflow a narrow sidebar. Below the width where five
1111+
labelled buttons fit, the labels drop and the row becomes icon-only: ~184px instead of ~300px.
1112+
`title` + `aria-label` are on every button already, so nothing is lost to a pointer or a screen
1113+
reader — only to the eye, and only when there is no room for it. */
1114+
.sesscard { container-type: inline-size; }
1115+
@container (max-width: 360px) { .sesscard .sesslbl { display: none; } }
11091116
.sesscard .sessacts { display: none; align-items: center; gap: 6px; flex-wrap: nowrap; }
11101117
.sesscard:hover .sesssub, .sesscard:focus-within .sesssub { display: none; }
11111118
.sesscard:hover .sessacts, .sesscard:focus-within .sessacts { display: flex; }
@@ -3343,7 +3350,8 @@
33433350
edit: '<svg class="ci" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M14.236 1.76386C13.2123 0.740172 11.5525 0.740171 10.5289 1.76386L2.65722 9.63549C2.28304 10.0097 2.01623 10.4775 1.88467 10.99L1.01571 14.3755C0.971767 14.5467 1.02148 14.7284 1.14646 14.8534C1.27144 14.9783 1.45312 15.028 1.62432 14.9841L5.00978 14.1151C5.52234 13.9836 5.99015 13.7168 6.36433 13.3426L14.236 5.47097C15.2596 4.44728 15.2596 2.78755 14.236 1.76386ZM11.236 2.47097C11.8691 1.8378 12.8957 1.8378 13.5288 2.47097C14.162 3.10413 14.162 4.1307 13.5288 4.76386L12.75 5.54269L10.4571 3.24979L11.236 2.47097ZM9.75002 3.9569L12.0429 6.24979L5.65722 12.6355C5.40969 12.883 5.10023 13.0595 4.76117 13.1465L2.19447 13.8053L2.85327 11.2386C2.9403 10.8996 3.1168 10.5901 3.36433 10.3426L9.75002 3.9569Z"/></svg>',
33443351
check: '<svg class="ci" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M13.6572 3.13573C13.8583 2.9465 14.175 2.95614 14.3643 3.15722C14.5535 3.35831 14.5438 3.675 14.3428 3.86425L5.84277 11.8642C5.64597 12.0494 5.33756 12.0446 5.14648 11.8535L1.64648 8.35351C1.45121 8.15824 1.45121 7.84174 1.64648 7.64647C1.84174 7.45121 2.15825 7.45121 2.35351 7.64647L5.50976 10.8027L13.6572 3.13573Z"/></svg>',
33453352
trash: '<svg class="ci" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M10 3h3v1h-1v9l-1 1H4l-1-1V4H2V3h3V2a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v1zM9 2H6v1h3V2zM4 13h7V4H4v9zm2-8H5v7h1V5zm1 0h1v7H7V5zm2 0h1v7H9V5z"/></svg>',
3346-
star: '<svg class="ci" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 1.5 L9.53 5.9 L14.18 5.99 L10.47 8.8 L11.82 13.26 L8 10.6 L4.18 13.26 L5.53 8.8 L1.82 5.99 L6.47 5.9 Z"/></svg>'
3353+
star: '<svg class="ci" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 1.5 L9.53 5.9 L14.18 5.99 L10.47 8.8 L11.82 13.26 L8 10.6 L4.18 13.26 L5.53 8.8 L1.82 5.99 L6.47 5.9 Z"/></svg>',
3354+
copy: '<svg class="ci" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M4 2h7l1 1v1h1l1 1v8l-1 1H6l-1-1v-1H4l-1-1V3l1-1zm1 1v8h1V5l1-1h4V3H5zm2 3v8h6V6H7z"/></svg>'
33473355
};
33483356
function sessActBtn(act, label, svg, cls){
33493357
return '<button type="button" class="sessact' + (cls ? ' ' + cls : '') + '" data-act="' + act + '" title="' + label + '" aria-label="' + label + '">' + svg + '<span class="sesslbl">' + label + '</span></button>';
@@ -3369,6 +3377,7 @@
33693377
+ (e.model ? ' · ' + sessModelShort(e.model) : '') + (files.length ? ' · ' + files.join(', ') : '');
33703378
const acts = '<span class="sessacts">'
33713379
+ sessActBtn('rename', 'Rename', SESS_IC.edit)
3380+
+ sessActBtn('export', 'Copy', SESS_IC.copy)
33723381
+ sessActBtn('done', 'Done', SESS_IC.check)
33733382
+ sessActBtn('delete', 'Delete', SESS_IC.trash)
33743383
+ sessActBtn('pin', e.pinned ? 'Unpin' : 'Pin', SESS_IC.star)

extensions/levelcode-ai/media/sessionsView.html

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,11 @@
8787
left intact. Sidebar buttons are label-only (icon hidden) to stay compact in a narrow panel. */
8888
.sesscard .sessline2 { margin: 2px 0 0 16px; min-height: 22px; display: flex; align-items: center; }
8989
.sesscard .sesssub { font-size: 12px; color: var(--cc-text3); }
90+
/* Same reasoning as chat.html: the row is nowrap, so a fifth button must not overflow a narrow
91+
pane. Below the width where five labelled buttons fit, drop to icons — title + aria-label are
92+
already on every button, so only the eye loses anything, and only when there is no room. */
93+
.sesscard { container-type: inline-size; }
94+
@container (max-width: 340px) { .sesscard .sesslbl { display: none; } }
9095
.sesscard .sessacts { display: none; align-items: center; gap: 4px; flex-wrap: nowrap; }
9196
.sesscard:hover .sesssub, .sesscard:focus-within .sesssub { display: none; }
9297
.sesscard:hover .sessacts, .sesscard:focus-within .sessacts { display: flex; }
@@ -145,7 +150,8 @@
145150
edit: '<svg class="ci" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M14.236 1.76386C13.2123 0.740172 11.5525 0.740171 10.5289 1.76386L2.65722 9.63549C2.28304 10.0097 2.01623 10.4775 1.88467 10.99L1.01571 14.3755C0.971767 14.5467 1.02148 14.7284 1.14646 14.8534C1.27144 14.9783 1.45312 15.028 1.62432 14.9841L5.00978 14.1151C5.52234 13.9836 5.99015 13.7168 6.36433 13.3426L14.236 5.47097C15.2596 4.44728 15.2596 2.78755 14.236 1.76386ZM11.236 2.47097C11.8691 1.8378 12.8957 1.8378 13.5288 2.47097C14.162 3.10413 14.162 4.1307 13.5288 4.76386L12.75 5.54269L10.4571 3.24979L11.236 2.47097ZM9.75002 3.9569L12.0429 6.24979L5.65722 12.6355C5.40969 12.883 5.10023 13.0595 4.76117 13.1465L2.19447 13.8053L2.85327 11.2386C2.9403 10.8996 3.1168 10.5901 3.36433 10.3426L9.75002 3.9569Z"/></svg>',
146151
check: '<svg class="ci" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M13.6572 3.13573C13.8583 2.9465 14.175 2.95614 14.3643 3.15722C14.5535 3.35831 14.5438 3.675 14.3428 3.86425L5.84277 11.8642C5.64597 12.0494 5.33756 12.0446 5.14648 11.8535L1.64648 8.35351C1.45121 8.15824 1.45121 7.84174 1.64648 7.64647C1.84174 7.45121 2.15825 7.45121 2.35351 7.64647L5.50976 10.8027L13.6572 3.13573Z"/></svg>',
147152
trash: '<svg class="ci" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M10 3h3v1h-1v9l-1 1H4l-1-1V4H2V3h3V2a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v1zM9 2H6v1h3V2zM4 13h7V4H4v9zm2-8H5v7h1V5zm1 0h1v7H7V5zm2 0h1v7H9V5z"/></svg>',
148-
star: '<svg class="ci" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 1.5 L9.53 5.9 L14.18 5.99 L10.47 8.8 L11.82 13.26 L8 10.6 L4.18 13.26 L5.53 8.8 L1.82 5.99 L6.47 5.9 Z"/></svg>'
153+
star: '<svg class="ci" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 1.5 L9.53 5.9 L14.18 5.99 L10.47 8.8 L11.82 13.26 L8 10.6 L4.18 13.26 L5.53 8.8 L1.82 5.99 L6.47 5.9 Z"/></svg>',
154+
copy: '<svg class="ci" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M4 2h7l1 1v1h1l1 1v8l-1 1H6l-1-1v-1H4l-1-1V3l1-1zm1 1v8h1V5l1-1h4V3H5zm2 3v8h6V6H7z"/></svg>'
149155
};
150156
function sessActBtn(act, label, svg, cls){
151157
return '<button type="button" class="sessact' + (cls ? ' ' + cls : '') + '" data-act="' + act + '" title="' + label + '" aria-label="' + label + '">' + svg + '<span class="sesslbl">' + label + '</span></button>';
@@ -171,6 +177,7 @@
171177
+ (e.model ? ' · ' + sessModelShort(e.model) : '') + (files.length ? ' · ' + files.join(', ') : '');
172178
const acts = '<span class="sessacts">'
173179
+ sessActBtn('rename', 'Rename', SESS_IC.edit)
180+
+ sessActBtn('export', 'Copy', SESS_IC.copy)
174181
+ sessActBtn('done', 'Done', SESS_IC.check)
175182
+ sessActBtn('delete', 'Delete', SESS_IC.trash)
176183
+ sessActBtn('pin', e.pinned ? 'Unpin' : 'Pin', SESS_IC.star)

extensions/levelcode-ai/sessionEvents.js

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,9 +122,60 @@ function tailFrom(messages, storedCount) {
122122
return msgs.slice(from);
123123
}
124124

125+
/**
126+
* Render a session as a clean Markdown transcript — the "Copy as Markdown" export
127+
* (levelcode-sessions-experience.md §6), and the seed of a later share-a-run.
128+
*
129+
* SCRUBBED, not raw. levelcode-chat-sessions-design.md §10 is explicit: transcripts at rest are the
130+
* same trust class as your code, but "anything that later *shares* a session must scrub — that is
131+
* that feature's burden." Export IS the first sharing surface — the doc's own framing is
132+
* paste-into-a-PR — so the redaction added for project memory is applied here too. A credential
133+
* pasted into chat to ask about it must not ride along into a pull request.
134+
*
135+
* STRUCTURE: bold role labels and a rule between turns, deliberately NOT headings. A turn's own text
136+
* routinely contains `## …` and fenced code; heading-based roles would be visually outranked by the
137+
* content they are supposed to delimit, and an `###` label looks broken next to a reply that opens
138+
* with `#`. Bold + `---` survives every renderer and every nesting depth.
139+
*
140+
* @param {{title?:string, id?:string, model?:string, createdAt?:string, updatedAt?:string,
141+
* filesEdited?:string[], turns?:number}} meta a sessionStore index entry
142+
* @param {Array<{role:string, content:any}>} messages the session's messages
143+
* @param {{redact?:(s:string)=>string, now?:string}} [opts] `redact` is injected so this module
144+
* stays dependency-free and the scrub is visible at the call site rather than implied
145+
*/
146+
function toMarkdown(meta, messages, opts) {
147+
const m = meta || {};
148+
const o = opts || {};
149+
const scrub = typeof o.redact === 'function' ? o.redact : (s) => s;
150+
const turns = toDisplayTurns(messages);
151+
152+
const title = scrub(String(m.title || 'Untitled session')).trim() || 'Untitled session';
153+
const when = String(m.updatedAt || m.createdAt || '').slice(0, 10);
154+
const files = (Array.isArray(m.filesEdited) ? m.filesEdited : []).map((f) => scrub(String(f)));
155+
156+
// One subtitle line of provenance. Everything on it is optional — an export of a session that
157+
// never named a model or touched a file should read as a transcript, not as a form with blanks.
158+
const bits = [];
159+
if (when) { bits.push(when); }
160+
bits.push(turns.length + ' turn' + (turns.length === 1 ? '' : 's'));
161+
if (m.model) { bits.push('`' + scrub(String(m.model)) + '`'); }
162+
if (files.length) { bits.push(files.slice(0, 6).map((f) => '`' + f + '`').join(', ')); }
163+
164+
let md = '# ' + title + '\n\n';
165+
md += '_LevelCode session · ' + bits.join(' · ') + '_\n';
166+
167+
for (const t of turns) {
168+
md += '\n---\n\n**' + (t.role === 'user' ? 'You' : 'LevelCode') + '**\n\n';
169+
md += scrub(String(t.text)).trim() + '\n';
170+
}
171+
// An empty session still exports — a file with a header and no turns is a truthful answer, and
172+
// silently producing nothing would read as a broken button.
173+
return md;
174+
}
175+
125176
module.exports = {
126177
EDIT_TOOLS,
127178
toolStatsFromMessages,
128179
userTurnEvent, agentTurnEvent, endEvent, titleEvent, labelEvent,
129-
eventsToMessages, messageText, toDisplayTurns, tailFrom
180+
eventsToMessages, messageText, toDisplayTurns, tailFrom, toMarkdown
130181
};

0 commit comments

Comments
 (0)