Skip to content

Commit 59dec3d

Browse files
ndemiancclaude
andcommitted
feat(ai): v0.7.1 chat UX - compaction, autopilot, cache visibility
- Compact button in the context popover: summarize older turns via a goal-boundary transcript splice (agentMemory.js) so long sessions continue instead of hitting the context window. Unit-tested. - Autopilot approvals mode (status-bar dropdown): run commands without asking except a danger set - deletion, discard-changes, sudo, force-push, remote-to-shell, publish, system writes - classified in commandSafety.js. Unit-tested. - Command-approval card redesigned as a permission dialog (title / why / command / Skip / Run); re-engage auto-scroll on decision; drop the misleading zsh-cwd label. - Cache-read line in the context popover: cache_read_input_tokens as a share of the turn's input; the dot green scales with the hit rate. - shHighlight: total tokenizer so a lone & or $ can't be dropped from the displayed command. Unit-tested. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 27529ed commit 59dec3d

9 files changed

Lines changed: 866 additions & 46 deletions

File tree

extensions/levelcode-ai/agent.js

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const path = require('path');
1515
const cp = require('child_process');
1616
const providers = require('./providers/index');
1717
const { formatVerifyFeedback, verifyOutcome, looksUnrunnable, sniffPort, looksReady } = require('./verify');
18+
const { classifyCommand, dangerLabel } = require('./commandSafety');
1819

1920
const SYSTEM_BASE = [
2021
"You are LevelCode's built-in autonomous coding agent. You accomplish the user's goal in their",
@@ -335,6 +336,12 @@ async function runTool(tu, ctx) {
335336
const abs = resolveWorkspacePath(input.path || '', { mustExist: true });
336337
if (!abs) { return 'ERROR: file not found: ' + input.path + whereHint(); }
337338
try { if (fs.statSync(abs).isDirectory()) { return 'ERROR: ' + input.path + ' is a directory — delete_file removes a single file.'; } } catch { /* */ }
339+
// Deletion is in the autopilot danger set — ask even when autopilot runs everything else silently.
340+
// (In manual mode nothing gates here: the edit is applied and reviewed with Keep/Undo as before.)
341+
if (ctx.autopilot && typeof ctx.approve === 'function') {
342+
const okDel = await ctx.approve({ kind: 'delete', path: input.path, danger: dangerLabel('deletion') });
343+
if (!okDel) { return 'User skipped deleting ' + input.path + '. Do not retry it.'; }
344+
}
338345
const ok = ctx.applyDelete ? await ctx.applyDelete({ path: input.path }) : false;
339346
if (!ok) { return 'ERROR: could not delete ' + input.path + '.'; }
340347
ctx.editCount = (ctx.editCount || 0) + 1;
@@ -351,7 +358,12 @@ async function runTool(tu, ctx) {
351358
if (!wf) { return 'ERROR: no workspace folder named "' + input.folder + '"' + whereHint(); }
352359
cwdRoot = wf.root;
353360
}
354-
const approved = await ctx.approve({ kind: 'command', command: cmd, explanation: input.explanation || '' });
361+
// Autopilot runs commands without asking — EXCEPT the danger set (deletion, sudo, force-push,
362+
// remote|shell, publish, system writes), which always asks. Manual mode asks for everything.
363+
const danger = ctx.autopilot ? classifyCommand(cmd) : { dangerous: true, category: null };
364+
const approved = danger.dangerous
365+
? await ctx.approve({ kind: 'command', command: cmd, explanation: input.explanation || '', danger: danger.category ? dangerLabel(danger.category) : null })
366+
: true;
355367
if (!approved) { return 'User skipped this command. Do not retry it.'; }
356368
const runId = tu.id || ('run-' + Date.now());
357369
ctx.post({ type: 'termRun', id: runId, command: cmd, cwd: path.basename(cwdRoot) || 'workspace', background: bg, explanation: input.explanation || '' });
@@ -459,7 +471,13 @@ async function runAgent(ctx) {
459471
const multiRootNote = wsFolders.length > 1
460472
? '\n\nWorkspace folders (multi-root — prefix paths with the folder name): ' + wsFolders.map((f) => f.name).join(', ') + '. The first folder ("' + wsFolders[0].name + '") is the default for unprefixed paths and run_command.'
461473
: '';
462-
const system = (ctx.skills ? buildSystem(ctx.skills.menu()) : SYSTEM_BASE) + multiRootNote;
474+
// Autopilot: act decisively and self-verify rather than pausing. Commands run without approval (the
475+
// host still gates the danger set — deletion, sudo, force-push, remote|shell, publish, system writes),
476+
// so the model should lean on verification, not on asking, when it's unsure.
477+
const autopilotNote = ctx.autopilot
478+
? '\n\nAUTOPILOT IS ON. Work end-to-end without pausing for confirmation. Your run_command calls execute immediately (only irreversible ones — deleting files, sudo, force-push, piping a remote script to a shell, publishing — still ask the user). Do NOT call ask_user for anything you can reasonably decide; pick a sensible default and proceed. When you are unsure whether a change is correct, do not stop to ask — verify it: run the build/tests/linters via run_command and read editor diagnostics, then fix and re-verify until clean, and only then move on. Prefer doing and checking over asking.'
479+
: '';
480+
const system = (ctx.skills ? buildSystem(ctx.skills.menu()) : SYSTEM_BASE) + multiRootNote + autopilotNote;
463481
const systemTokensEst = Math.round(system.length / 4);
464482

465483
const dbg = ctx.dbg || (() => {});
@@ -586,7 +604,7 @@ async function runAgent(ctx) {
586604
if (turn.usage.cost_micros != null) { runCostMicros += turn.usage.cost_micros; }
587605
if (turn.usage.credits_remaining_micros != null) { ctx.credits = turn.usage.credits_remaining_micros; }
588606
dbg('usage', { input: turn.usage.input_tokens, output: turn.usage.output_tokens, cacheRead: turn.usage.cache_read_input_tokens, cumulativeOutput: cumulativeOutputTokens, costMicros: turn.usage.cost_micros, creditsLeftMicros: turn.usage.credits_remaining_micros });
589-
ctx.post({ type: 'contextUsage', input: (turn.usage.input_tokens || 0) + (turn.usage.cache_read_input_tokens || 0) + (turn.usage.cache_creation_input_tokens || 0), output: turn.usage.output_tokens || 0, limit: ctx.contextLimit || 200000, model: ctx.model, system: systemTokensEst, tools: TOOLS_TOKENS_EST });
607+
ctx.post({ type: 'contextUsage', input: (turn.usage.input_tokens || 0) + (turn.usage.cache_read_input_tokens || 0) + (turn.usage.cache_creation_input_tokens || 0), output: turn.usage.output_tokens || 0, limit: ctx.contextLimit || 200000, model: ctx.model, system: systemTokensEst, tools: TOOLS_TOKENS_EST, cacheRead: turn.usage.cache_read_input_tokens || 0, cacheWrite: turn.usage.cache_creation_input_tokens || 0 });
590608
}
591609

592610
// Reasoning models (e.g. Kimi K2.7 Code) emit <think>…</think> inline in the text
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Pure transcript-surgery helpers for context compaction (see compactAgentMemory in extension.js).
3+
* Kept dependency-free so the one property that matters — the spliced transcript is still VALID
4+
* (no orphaned tool_use/tool_result pair, clean role alternation across the seam) — is unit-testable
5+
* without booting the extension. extension.js owns the impure parts (the summary model call, posting).
6+
*--------------------------------------------------------------------------------------------*/
7+
'use strict';
8+
9+
/** A "goal boundary": a user message with plain STRING content (a fresh user turn, never a tool_result).
10+
* It is the only splice point that cannot orphan a tool_use/tool_result pair — tool results always sit
11+
* in the message immediately after their tool_use, so any pair is wholly on one side of such a cut. */
12+
function isGoalBoundary(m) {
13+
return !!(m && m.role === 'user' && typeof m.content === 'string');
14+
}
15+
16+
/**
17+
* Choose where to cut a transcript for compaction: summarize messages[0..cut), keep [cut..] verbatim.
18+
* Aims to keep roughly the last `keepRecent` messages, snapping to a goal boundary so the kept tail
19+
* begins with a clean user turn. Returns a cut index in [2, len), or -1 when there is no safe cut
20+
* (transcript too short, or no goal boundary to land on).
21+
* @param {Array<{role:string, content:any}>} msgs
22+
* @param {number} keepRecent
23+
* @returns {number}
24+
*/
25+
function findCompactionCut(msgs, keepRecent) {
26+
if (!Array.isArray(msgs)) { return -1; }
27+
const len = msgs.length;
28+
if (len <= keepRecent + 2) { return -1; }
29+
// Prefer the first goal boundary at/after the keep mark; else the most recent boundary before it.
30+
let cut = Math.max(1, len - keepRecent);
31+
while (cut < len && !isGoalBoundary(msgs[cut])) { cut++; }
32+
if (cut >= len) { cut = Math.max(1, len - keepRecent); while (cut > 1 && !isGoalBoundary(msgs[cut])) { cut--; } }
33+
if (cut < 2 || cut >= len || !isGoalBoundary(msgs[cut])) { return -1; }
34+
return cut;
35+
}
36+
37+
/** Rough token estimate for a message list — the house chars/4 heuristic, used only for the UI meter. */
38+
function estimateMsgTokens(msgs) {
39+
if (!Array.isArray(msgs)) { return 0; }
40+
return Math.round(msgs.reduce((n, m) => n + JSON.stringify(m).length, 0) / 4);
41+
}
42+
43+
module.exports = { isGoalBoundary, findCompactionCut, estimateMsgTokens };
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Danger classifier for autopilot (see the run_command gate in agent.js).
3+
*
4+
* In autopilot the agent runs shell commands WITHOUT asking — except the ones this flags, which
5+
* still show the approval card. This is a SECURITY gate, so it is deliberately biased toward flagging:
6+
* a false positive costs one extra prompt; a false negative auto-runs something irreversible. When in
7+
* doubt, flag. Matching is word-boundaried and scans the WHOLE command (so `echo hi && rm -rf x` and
8+
* `xargs rm` are caught wherever the dangerous token sits), accepting that a dangerous word inside a
9+
* quoted string ("rm is scary") will over-flag — that is the safe direction.
10+
*
11+
* Scope (user-chosen "Deletion + irreversible"): file deletion, discarding uncommitted work, plus a
12+
* small set of hard-to-undo, high-blast-radius ops — sudo, force-push / history rewrite, piping a
13+
* remote script into a shell, irreversible publishes, and writes into system directories. Pure +
14+
* dependency-free so the boundary is unit-testable (test/commandSafety.test.js) without booting the extension.
15+
*
16+
* Known limitations (by design — this is a good-faith guardrail, not a sandbox): it matches command
17+
* strings, so it does NOT catch deletion smuggled through an interpreter (`node -e fs.rmSync(...)`,
18+
* `python -c shutil.rmtree(...)`), a bare truncating redirect (`> important.txt`), or a command
19+
* deliberately obfuscated to evade it. Prompt injection that steers the model into one of those forms
20+
* can therefore reach the shell unprompted in autopilot. It defends against the common case — an
21+
* obviously-destructive command the model emits in good faith — not against an adversary evading it.
22+
*--------------------------------------------------------------------------------------------*/
23+
'use strict';
24+
25+
// Each rule: [category, regex]. Ordered so the most specific/telling category wins the report.
26+
const RULES = [
27+
// --- file deletion ---
28+
['deletion', /\brm\b/i], // rm / git rm / sudo rm / xargs rm (any form)
29+
['deletion', /\brmdir\b/i],
30+
['deletion', /\bunlink\b/i],
31+
['deletion', /\bshred\b/i],
32+
['deletion', /\brimraf\b/i], // the idiomatic Node recursive delete — no \brm\b boundary inside "rimraf"
33+
['deletion', /\bgit\s+clean\b/i], // -f/-d/-x wipe untracked files
34+
['deletion', /\bfind\b[\s\S]*?-delete\b/i],
35+
['deletion', /\bfind\b[\s\S]*?-exec\s+rm\b/i],
36+
['deletion', /\btruncate\b/i], // -s 0 empties a file
37+
['deletion', /\bdd\b/i], // disk-destroyer
38+
['deletion', /\bmkfs\b/i],
39+
['deletion', />\s*\/dev\/(sd|disk|nvme|null\/)/i], // redirect over a device node
40+
41+
// --- discarding uncommitted work (same irreversible effect as reset --hard; NOT in the reflog) ---
42+
['discard-changes', /\bgit\s+reset\s+--hard\b/i], // discards the working tree
43+
// `git checkout` that targets a path/HEAD/force (not a branch switch, which is safe):
44+
['discard-changes', /\bgit\s+checkout\b[^&|;\n]*(\s--(\s|$)|\s\.(\s|$)|\bHEAD\b|--force\b|\s-f\b)/i],
45+
// `git restore <path>` overwrites the working tree; `git restore --staged` only unstages (safe) → excluded:
46+
['discard-changes', /\bgit\s+restore\b(?![^\n&|;]*--staged)/i],
47+
48+
// --- irreversible / high blast radius ---
49+
['sudo', /\bsudo\b/i],
50+
['sudo', /\bdoas\b/i],
51+
['force-push', /\bgit\s+push\b[\s\S]*?(--force\b|--force-with-lease\b|--mirror\b|\s-f\b)/i],
52+
['history-rewrite', /\bgit\s+filter-(branch|repo)\b/i],
53+
['history-rewrite', /\bgit\s+reflog\s+expire\b/i],
54+
['history-rewrite', /\bgit\s+gc\b[\s\S]*?--prune/i],
55+
['remote-exec', /\b(curl|wget|fetch)\b[\s\S]*?\|\s*(sudo\s+)?(sh|bash|zsh|ksh|fish|python3?|node|ruby|perl)\b/i],
56+
['publish', /\b(npm|yarn|pnpm)\s+publish\b/i],
57+
58+
// --- writes that escape the project into system dirs ---
59+
['system-write', />>?\s*\/(etc|usr|bin|sbin|System|Library|var|boot|opt)\b/i],
60+
['system-write', /\b(rm|mv|cp|chmod|chown|tee)\b[\s\S]*?\s\/(etc|usr|bin|sbin|System|boot)\b/i],
61+
];
62+
63+
/**
64+
* Classify a shell command for the autopilot gate.
65+
* @param {string} command
66+
* @returns {{ dangerous: boolean, category: string|null }}
67+
*/
68+
function classifyCommand(command) {
69+
const s = String(command || '');
70+
for (const [category, re] of RULES) {
71+
if (re.test(s)) { return { dangerous: true, category }; }
72+
}
73+
return { dangerous: false, category: null };
74+
}
75+
76+
/** Convenience boolean wrapper. */
77+
function isDangerousCommand(command) {
78+
return classifyCommand(command).dangerous;
79+
}
80+
81+
/** Short human label for the approval card ("why is autopilot still asking?"). */
82+
function dangerLabel(category) {
83+
switch (category) {
84+
case 'deletion': return 'deletes files';
85+
case 'discard-changes': return 'discards uncommitted changes';
86+
case 'sudo': return 'runs as root (sudo)';
87+
case 'force-push': return 'force-pushes / rewrites remote history';
88+
case 'history-rewrite': return 'rewrites git history';
89+
case 'remote-exec': return 'pipes a remote script into a shell';
90+
case 'publish': return 'publishes a package';
91+
case 'system-write': return 'writes outside the project';
92+
default: return 'is potentially destructive';
93+
}
94+
}
95+
96+
module.exports = { classifyCommand, isDangerousCommand, dangerLabel };

0 commit comments

Comments
 (0)