Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/levelcode-sessions-memory.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ The magic, delivered quietly (never a wall of text):
- ✅ **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.
*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.
- ⬜ **Decayed-entry recall** — surfacing an aged-out fact when a query matches it directly.
- **Export** — "Copy as Markdown" for a session, and for the memory set. Cheap, since the storage is already plain text, and it seeds LevelLinks.
- **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.

**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).

Expand Down
50 changes: 50 additions & 0 deletions extensions/levelcode-ai/extension.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const { registerInlineComplete } = require('./inlineComplete');
const { runAgent } = require('./agent');
const { findCompactionCut, estimateMsgTokens } = require('./agentMemory');
const sessionStore = require('./sessionStore');
const sessionEvents = require('./sessionEvents');
const sessionMemory = require('./sessionMemory');
const { createSessions } = require('./sessions');
const { registerReview } = require('./reviewSession');
Expand Down Expand Up @@ -898,6 +899,7 @@ async function handleSessionAction(action, id) {
postSessions({ type: 'sessionUndo', action, id, title }); // offer to undo — the card just vanished
return;
}
if (action === 'export') { await exportSession(id); return; }
if (action === 'restore') { m.restore(id); refreshSessions(); return; }
if (action === 'pin') { const cur = (m.list().find((e) => e.id === id) || {}).pinned; m.setPinned(id, !cur); refreshSessions(); return; }
if (action === 'rename') {
Expand All @@ -909,6 +911,54 @@ async function handleSessionAction(action, id) {
} catch (e) { dbg('sessions.action.error', { action, id, msg: String((e && e.message) || e) }); }
}

/**
* Copy a session to the clipboard as Markdown, and offer to save it (experience doc §6).
*
* SCRUBBED on the way out. chat-sessions-design §10: 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."
* This is the first surface that shares one — the doc's own framing is paste-into-a-PR — so
* `redactSecrets` (built for project memory) is passed in explicitly rather than left implied.
*/
async function exportSession(id) {
const m = sessionsManager();
if (!m || !id) { return; }
const entry = (m.list().find((e) => e.id === id)) || {};
const md = sessionEvents.toMarkdown(entry, m.transcript(id), { redact: sessionMemory.redactSecrets });

try { await vscode.env.clipboard.writeText(md); }
catch (e) {
vscode.window.showErrorMessage('Could not copy the session: ' + String((e && e.message) || e));
return;
}
dbg('sessions.export', { id, chars: md.length });

// Report the size in TURNS, not characters: "Copied 14 turns" tells you whether you got the
// session you meant; "Copied 8,214 characters" tells you nothing you can act on.
const turns = sessionEvents.toDisplayTurns(m.transcript(id)).length;
const pick = await vscode.window.showInformationMessage(
'Copied ' + turns + ' turn' + (turns === 1 ? '' : 's') + ' as Markdown.', 'Save as file…');
if (pick !== 'Save as file…') { return; }

// Derive a filename from the title so a folder of exports stays readable. REDACT it first: the exported
// body was scrubbed, but `entry.title` here is the raw index title (toMarkdown redacts its own copy, not
// this variable), and the filesystem sanitiser below only strips slashes/colons — a credential's own
// characters survive it (`ghp_ABC…` → `ghp-abc…`), so a token in a title would leak into the save path.
const safeTitle = sessionMemory.redactSecrets(String(entry.title || 'session'));
const stem = safeTitle.toLowerCase()
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'session';
const target = await vscode.window.showSaveDialog({
filters: { Markdown: ['md'] },
defaultUri: vscode.Uri.file(path.join(os.homedir(), stem + '.md'))
});
if (!target) { return; }
try {
await vscode.workspace.fs.writeFile(target, Buffer.from(md, 'utf8'));
vscode.window.showInformationMessage('Saved ' + path.basename(target.fsPath) + '.');
} catch (e) {
vscode.window.showErrorMessage('Could not save the session: ' + String((e && e.message) || e));
}
}

// Reopen a past session: restore its transcript as the live agent context (budget-fitted, §4.5), reset the
// per-turn UI, and replay the readable conversation into the chat so it feels like walking back in.
async function resumeSession(id) {
Expand Down
11 changes: 10 additions & 1 deletion extensions/levelcode-ai/media/chat.html
Original file line number Diff line number Diff line change
Expand Up @@ -1106,6 +1106,13 @@
nothing wraps, reflows, or floats and the title above is left intact. The modal keeps icons + labels. */
.sesscard .sessline2 { margin-top: 4px; min-height: 26px; display: flex; align-items: center; }
.sesscard .sesssub { font-family: var(--vscode-editor-font-family, ui-monospace, monospace); font-size: 11.5px; color: var(--cc-text3); }
/* The action row is nowrap by design (the card is two fixed-height lines — nothing may reflow), so
a fifth button cannot be allowed to overflow a narrow sidebar. Below the width where five
labelled buttons fit, the labels drop and the row becomes icon-only: ~184px instead of ~300px.
`title` + `aria-label` are on every button already, so nothing is lost to a pointer or a screen
reader — only to the eye, and only when there is no room for it. */
.sesscard { container-type: inline-size; }
@container (max-width: 360px) { .sesscard .sesslbl { display: none; } }
.sesscard .sessacts { display: none; align-items: center; gap: 6px; flex-wrap: nowrap; }
.sesscard:hover .sesssub, .sesscard:focus-within .sesssub { display: none; }
.sesscard:hover .sessacts, .sesscard:focus-within .sessacts { display: flex; }
Expand Down Expand Up @@ -3343,7 +3350,8 @@
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>',
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>',
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>',
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>'
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>',
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>'
};
function sessActBtn(act, label, svg, cls){
return '<button type="button" class="sessact' + (cls ? ' ' + cls : '') + '" data-act="' + act + '" title="' + label + '" aria-label="' + label + '">' + svg + '<span class="sesslbl">' + label + '</span></button>';
Expand All @@ -3369,6 +3377,7 @@
+ (e.model ? ' · ' + sessModelShort(e.model) : '') + (files.length ? ' · ' + files.join(', ') : '');
const acts = '<span class="sessacts">'
+ sessActBtn('rename', 'Rename', SESS_IC.edit)
+ sessActBtn('export', 'Copy', SESS_IC.copy)
+ sessActBtn('done', 'Done', SESS_IC.check)
+ sessActBtn('delete', 'Delete', SESS_IC.trash)
+ sessActBtn('pin', e.pinned ? 'Unpin' : 'Pin', SESS_IC.star)
Expand Down
9 changes: 8 additions & 1 deletion extensions/levelcode-ai/media/sessionsView.html
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,11 @@
left intact. Sidebar buttons are label-only (icon hidden) to stay compact in a narrow panel. */
.sesscard .sessline2 { margin: 2px 0 0 16px; min-height: 22px; display: flex; align-items: center; }
.sesscard .sesssub { font-size: 12px; color: var(--cc-text3); }
/* Same reasoning as chat.html: the row is nowrap, so a fifth button must not overflow a narrow
pane. Below the width where five labelled buttons fit, drop to icons — title + aria-label are
already on every button, so only the eye loses anything, and only when there is no room. */
.sesscard { container-type: inline-size; }
@container (max-width: 340px) { .sesscard .sesslbl { display: none; } }
.sesscard .sessacts { display: none; align-items: center; gap: 4px; flex-wrap: nowrap; }
.sesscard:hover .sesssub, .sesscard:focus-within .sesssub { display: none; }
.sesscard:hover .sessacts, .sesscard:focus-within .sessacts { display: flex; }
Expand Down Expand Up @@ -145,7 +150,8 @@
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>',
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>',
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>',
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>'
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>',
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>'
};
function sessActBtn(act, label, svg, cls){
return '<button type="button" class="sessact' + (cls ? ' ' + cls : '') + '" data-act="' + act + '" title="' + label + '" aria-label="' + label + '">' + svg + '<span class="sesslbl">' + label + '</span></button>';
Expand All @@ -171,6 +177,7 @@
+ (e.model ? ' · ' + sessModelShort(e.model) : '') + (files.length ? ' · ' + files.join(', ') : '');
const acts = '<span class="sessacts">'
+ sessActBtn('rename', 'Rename', SESS_IC.edit)
+ sessActBtn('export', 'Copy', SESS_IC.copy)
+ sessActBtn('done', 'Done', SESS_IC.check)
+ sessActBtn('delete', 'Delete', SESS_IC.trash)
+ sessActBtn('pin', e.pinned ? 'Unpin' : 'Pin', SESS_IC.star)
Expand Down
56 changes: 55 additions & 1 deletion extensions/levelcode-ai/sessionEvents.js
Original file line number Diff line number Diff line change
Expand Up @@ -122,9 +122,63 @@ function tailFrom(messages, storedCount) {
return msgs.slice(from);
}

/**
* Render a session as a clean Markdown transcript — the "Copy as Markdown" export
* (levelcode-sessions-experience.md §6), and the seed of a later share-a-run.
*
* SCRUBBED, not raw. levelcode-chat-sessions-design.md §10 is explicit: 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 sharing surface — the doc's own framing is
* paste-into-a-PR — so the redaction added for project memory is applied here too. A credential
* pasted into chat to ask about it must not ride along into a pull request.
*
* STRUCTURE: bold role labels and a rule between turns, deliberately NOT headings. A turn's own text
* routinely contains `## …` and fenced code; heading-based roles would be visually outranked by the
* content they are supposed to delimit, and an `###` label looks broken next to a reply that opens
* with `#`. Bold + `---` survives every renderer and every nesting depth.
*
* @param {{title?:string, id?:string, model?:string, createdAt?:string, updatedAt?:string,
* filesEdited?:string[], turns?:number}} meta a sessionStore index entry
* @param {Array<{role:string, content:any}>} messages the session's messages
* @param {{redact?:(s:string)=>string, now?:string}} [opts] `redact` is injected so this module
* stays dependency-free and the scrub is visible at the call site rather than implied
*/
function toMarkdown(meta, messages, opts) {
const m = meta || {};
const o = opts || {};
const scrub = typeof o.redact === 'function' ? o.redact : (s) => s;
const turns = toDisplayTurns(messages);

const title = scrub(String(m.title || 'Untitled session')).trim() || 'Untitled session';
const when = String(m.updatedAt || m.createdAt || '').slice(0, 10);
const files = (Array.isArray(m.filesEdited) ? m.filesEdited : []).map((f) => scrub(String(f)));

// One subtitle line of provenance. Everything on it is optional — an export of a session that
// never named a model or touched a file should read as a transcript, not as a form with blanks.
const bits = [];
if (when) { bits.push(when); }
bits.push(turns.length + ' turn' + (turns.length === 1 ? '' : 's'));
if (m.model) { bits.push('`' + scrub(String(m.model)) + '`'); }
if (files.length) { bits.push(files.slice(0, 6).map((f) => '`' + f + '`').join(', ')); }

let md = '# ' + title + '\n\n';
md += '_LevelCode session · ' + bits.join(' · ') + '_\n';

for (const t of turns) {
md += '\n---\n\n**' + (t.role === 'user' ? 'You' : 'LevelCode') + '**\n\n';
// Verbatim aside from redaction — NOT trimmed. Trimming changes Markdown semantics: it de-indents a
// leading 4-space (indented code block) and eats trailing " " (a hard line break). The `**role**\n\n`
// above already supplies the blank line an indented block needs after it.
md += scrub(String(t.text)) + '\n';
}
// An empty session still exports — a file with a header and no turns is a truthful answer, and
// silently producing nothing would read as a broken button.
return md;
}

module.exports = {
EDIT_TOOLS,
toolStatsFromMessages,
userTurnEvent, agentTurnEvent, endEvent, titleEvent, labelEvent,
eventsToMessages, messageText, toDisplayTurns, tailFrom
eventsToMessages, messageText, toDisplayTurns, tailFrom, toMarkdown
};
Loading