diff --git a/.gitignore b/.gitignore index 4237133a..250f77cf 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ node_modules/ *.js.map *.d.ts.map .env +src/ext-vscode/out/ +src/ext-vscode/node_modules/ +src/ext-vscode/*.vsix diff --git a/scripts/dev-probe.cjs b/scripts/dev-probe.cjs new file mode 100644 index 00000000..f1cafe91 --- /dev/null +++ b/scripts/dev-probe.cjs @@ -0,0 +1,425 @@ +#!/usr/bin/env node +/** + * scripts/dev-probe.cjs — manual-testing probe tool for nexpath. + * + * .cjs extension forces CommonJS so it works regardless of the parent + * package.json having "type": "module". Designed to be a one-stop shop + * for the manual testing rounds (Round 2-4) so each test cell is one + * command, no shell-quoting issues, no relative-path gotchas. + * + * Usage from anywhere: + * node /home/emptyops/Documents/Vedanshi/NexPathMain/reviewduel/nexpath/scripts/dev-probe.cjs [args] + * + * Or from the repo root: + * node scripts/dev-probe.cjs [args] + * + * Subcommands: + * store schema Show columns of the prompts table + * store recent [N] Show N most recent prompts (default 10) + * store today Show prompts captured today + * store search Search prompt_text for a substring + * store stats Count by agent + by day + * + * cursor workspaces List all Cursor workspaces with their state.vscdb path + row count + * cursor probe [ws-id] Probe one workspace (omit ws-id to probe all) + * cursor extract [ws-id] Run our cursor extractors against live data; show decoded events + * + * trigger ping Verify nexpath CLI is reachable + Layer C is up + * trigger auto Manually invoke `nexpath auto` to bypass the watcher and test the capture path directly + * + * config show Show prompt_capture_enabled + other flags from the store's config table + * exthost-log [N] Tail Cursor's exthost.log for the last N nexpath-tagged lines (default 30) + * + * help Print this help + */ + +const path = require('path'); +const fs = require('fs'); +const os = require('os'); + +const REPO_ROOT = path.resolve(__dirname, '..'); +const STORE_DB = path.join(os.homedir(), '.nexpath', 'prompt-store.db'); +const CURSOR_WS_DIR = path.join(os.homedir(), '.config', 'Cursor', 'User', 'workspaceStorage'); + +// Load better-sqlite3 from the sub-package's node_modules (always available +// after `npm install` in src/ext-vscode/). Absolute path so we don't depend +// on cwd or relative resolution. +const BETTER_SQLITE3 = path.join(REPO_ROOT, 'src', 'ext-vscode', 'node_modules', 'better-sqlite3'); +let Database; +try { + Database = require(BETTER_SQLITE3); +} catch (e) { + console.error('Could not load better-sqlite3 from:', BETTER_SQLITE3); + console.error('Fix: cd', path.join(REPO_ROOT, 'src/ext-vscode'), '&& npm install'); + process.exit(2); +} + +// ───────────────────────── helpers ───────────────────────── + +function openStore(readonly = true) { + if (!fs.existsSync(STORE_DB)) { + console.error('Store DB not found at:', STORE_DB); + console.error('Has any prompt been captured yet? Try: node dist/cli/index.js install'); + process.exit(3); + } + return new Database(STORE_DB, { readonly }); +} + +function fmtTs(t) { + if (t == null) return '?'; + // captured_at is INTEGER ms in this schema + if (typeof t === 'number' || /^\d+$/.test(String(t))) { + return new Date(Number(t)).toISOString().replace('T', ' ').slice(0, 19) + ' UTC'; + } + return String(t); +} + +function printRow(r, maxText = 100) { + const text = String(r.prompt_text || '').slice(0, maxText); + const at = fmtTs(r.captured_at ?? r.created_at ?? r.timestamp); + const agent = r.agent || r.agent_id || '?'; + const project = r.project_root || r.project_id || r.project_path || '-'; + console.log( + ' id=' + r.id, + '| at=' + at, + '| agent=' + agent, + '| project=' + (typeof project === 'string' ? project.slice(-40) : project), + '| text=' + JSON.stringify(text), + ); +} + +function stagedRead(dbPath) { + // Copy main + WAL + SHM to /tmp, checkpoint, query. Mirrors the + // production watcher's defaultReadItemTable strategy so we read the + // same data the watcher sees. + const stagingDir = path.join(os.tmpdir(), 'dev-probe-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8)); + fs.mkdirSync(stagingDir, { recursive: true }); + const stagedMain = path.join(stagingDir, path.basename(dbPath)); + fs.copyFileSync(dbPath, stagedMain); + for (const suffix of ['-wal', '-shm']) { + const sibling = dbPath + suffix; + if (fs.existsSync(sibling)) fs.copyFileSync(sibling, stagedMain + suffix); + } + const db = new Database(stagedMain, { readonly: true }); + try { + try { db.pragma('wal_checkpoint(TRUNCATE)'); } catch {} + const cleanup = () => { + try { db.close(); } catch {} + try { fs.rmSync(stagingDir, { recursive: true, force: true }); } catch {} + }; + return { db, cleanup }; + } catch (e) { + try { db.close(); } catch {} + fs.rmSync(stagingDir, { recursive: true, force: true }); + throw e; + } +} + +function fingerprint(keys) { + const map = { + 'cursor-v2024-q4': ['aiService.'], + 'cursor-v2025-q1': ['composer.composerData'], + 'cursor-v2025-q2': ['cursorAIChatService.chatHistory.'], + 'windsurf': ['cascade.'], + }; + let best = { id: null, count: 0, matchedKeys: [] }; + for (const [id, prefixes] of Object.entries(map)) { + const matched = keys.filter(k => prefixes.some(p => k.startsWith(p))); + if (matched.length > best.count) best = { id, count: matched.length, matchedKeys: matched }; + } + return best.count > 0 ? best : null; +} + +// ───────────────────────── subcommands ───────────────────────── + +function storeSchema() { + const db = openStore(); + console.log('Tables:'); + for (const t of db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all()) { + console.log(' ', t.name); + } + console.log('\nColumns of "prompts":'); + for (const c of db.prepare('PRAGMA table_info(prompts)').all()) { + console.log(' ', c.name, '(' + c.type + ')'); + } + db.close(); +} + +function storeRecent(count) { + const n = parseInt(count, 10) || 10; + const db = openStore(); + const rows = db.prepare('SELECT * FROM prompts ORDER BY id DESC LIMIT ?').all(n); + console.log(`Most recent ${rows.length} prompts:`); + for (const r of rows) printRow(r); + db.close(); +} + +function storeToday() { + const db = openStore(); + // captured_at is INTEGER ms — compute "today" boundary in ms + const now = Date.now(); + const startOfDay = new Date(); + startOfDay.setHours(0, 0, 0, 0); + const rows = db.prepare( + 'SELECT * FROM prompts WHERE captured_at >= ? AND captured_at <= ? ORDER BY id DESC' + ).all(startOfDay.getTime(), now); + // Format local-time date — toISOString() returns UTC which looks wrong by 1 day in non-UTC timezones. + const localDate = `${startOfDay.getFullYear()}-${String(startOfDay.getMonth()+1).padStart(2,'0')}-${String(startOfDay.getDate()).padStart(2,'0')}`; + console.log(`Prompts captured today (${localDate}, local time): ${rows.length}`); + for (const r of rows) printRow(r); + db.close(); +} + +function storeSearch(pattern) { + if (!pattern) { console.error('Usage: store search '); process.exit(1); } + const db = openStore(); + const rows = db.prepare('SELECT * FROM prompts WHERE prompt_text LIKE ? ORDER BY id DESC LIMIT 20').all('%' + pattern + '%'); + console.log(`Matches for "${pattern}": ${rows.length}`); + for (const r of rows) printRow(r); + db.close(); +} + +function storeStats() { + const db = openStore(); + console.log('By agent:'); + for (const r of db.prepare("SELECT agent, COUNT(*) AS n FROM prompts GROUP BY agent ORDER BY n DESC").all()) { + console.log(' ', (r.agent || '(null)').padEnd(20), r.n); + } + console.log('\nBy day (last 14):'); + // captured_at is INTEGER ms — group via Date math on results + const cutoff = Date.now() - 14 * 86400 * 1000; + const rows = db.prepare('SELECT captured_at FROM prompts WHERE captured_at >= ? ORDER BY captured_at DESC').all(cutoff); + const byDay = {}; + for (const r of rows) { + const d = new Date(Number(r.captured_at)).toISOString().slice(0, 10); + byDay[d] = (byDay[d] || 0) + 1; + } + for (const day of Object.keys(byDay).sort().reverse()) { + console.log(' ', day, byDay[day]); + } + db.close(); +} + +function cursorWorkspaces() { + if (!fs.existsSync(CURSOR_WS_DIR)) { + console.error('Cursor workspaceStorage dir not found:', CURSOR_WS_DIR); + process.exit(3); + } + console.log('Cursor workspaces with state.vscdb:'); + for (const ws of fs.readdirSync(CURSOR_WS_DIR)) { + const dbPath = path.join(CURSOR_WS_DIR, ws, 'state.vscdb'); + if (!fs.existsSync(dbPath)) continue; + try { + const { db, cleanup } = stagedRead(dbPath); + try { + const has = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='ItemTable'").all().length; + const n = has ? db.prepare('SELECT COUNT(*) AS n FROM ItemTable').get().n : 0; + console.log(' ', ws.padEnd(45), 'rows=' + n); + } finally { cleanup(); } + } catch (e) { + console.log(' ', ws.padEnd(45), 'ERR:', e.message); + } + } +} + +function cursorProbe(wsId) { + const targets = []; + if (wsId) { + const dbPath = path.join(CURSOR_WS_DIR, wsId, 'state.vscdb'); + if (!fs.existsSync(dbPath)) { console.error('Not found:', dbPath); process.exit(3); } + targets.push({ ws: wsId, dbPath }); + } else { + for (const ws of fs.readdirSync(CURSOR_WS_DIR)) { + const dbPath = path.join(CURSOR_WS_DIR, ws, 'state.vscdb'); + if (fs.existsSync(dbPath)) targets.push({ ws, dbPath }); + } + } + + for (const { ws, dbPath } of targets) { + console.log('\n=== Workspace:', ws, '==='); + try { + const { db, cleanup } = stagedRead(dbPath); + try { + const has = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='ItemTable'").all().length; + if (!has) { console.log(' (no ItemTable)'); continue; } + const rows = db.prepare('SELECT key, value FROM ItemTable').all(); + const keys = rows.map(r => r.key); + const fp = fingerprint(keys); + console.log(' ItemTable rows:', rows.length); + console.log(' fingerprint:', fp ? `${fp.id} (matched ${fp.count} key${fp.count===1?'':'s'})` : 'UNKNOWN'); + if (fp) console.log(' matched keys:', fp.matchedKeys.slice(0, 5).join(', ')); + console.log(' first 8 keys:', keys.slice(0, 8).join(', ')); + } finally { cleanup(); } + } catch (e) { + console.log(' ERR:', e.message); + } + } +} + +function cursorExtract(wsId) { + // Use the actual production extractors from the sub-package source + // via tsx-equivalent (just require the compiled .ts via Node 22's + // experimental TS support... NO, our .ts files don't compile cleanly + // without tsc, so use the hand-rolled decoder inline below). + // + // For each row that the cursor-v2024-q4 prefix owns, parse the JSON + // value and pull out prompts. This mirrors what the extractor does but + // implemented in CJS for this probe tool. + + const targets = []; + if (wsId) { + const dbPath = path.join(CURSOR_WS_DIR, wsId, 'state.vscdb'); + if (!fs.existsSync(dbPath)) { console.error('Not found:', dbPath); process.exit(3); } + targets.push({ ws: wsId, dbPath }); + } else { + for (const ws of fs.readdirSync(CURSOR_WS_DIR)) { + const dbPath = path.join(CURSOR_WS_DIR, ws, 'state.vscdb'); + if (fs.existsSync(dbPath)) targets.push({ ws, dbPath }); + } + } + + for (const { ws, dbPath } of targets) { + console.log('\n=== Workspace:', ws, '==='); + try { + const { db, cleanup } = stagedRead(dbPath); + try { + const has = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='ItemTable'").all().length; + if (!has) { console.log(' (no ItemTable)'); continue; } + const rows = db.prepare('SELECT key, value FROM ItemTable').all(); + let total = 0; + + // cursor-v2024-q4: aiService.prompts is a JSON-encoded array of {text, ...} + for (const r of rows) { + if (r.key !== 'aiService.prompts') continue; + try { + const arr = JSON.parse(r.value); + if (!Array.isArray(arr)) continue; + console.log(' aiService.prompts:', arr.length, 'entries'); + for (let i = 0; i < Math.min(arr.length, 5); i++) { + const e = arr[i]; + const text = e && (e.text || e.prompt || e.content); + if (text) { + console.log(' [' + i + ']', JSON.stringify(String(text).slice(0, 100))); + total++; + } + } + } catch (e) { + console.log(' aiService.prompts: parse err:', e.message); + } + } + + // cursor-v2025-q1: composer.composerData (metadata only on 3.4.20) + for (const r of rows) { + if (r.key !== 'composer.composerData') continue; + try { + const obj = JSON.parse(r.value); + console.log(' composer.composerData fields:', Object.keys(obj || {}).slice(0, 10).join(', ')); + } catch {} + } + + if (total === 0) console.log(' (no prompts decoded — workspace has no Ask-mode AI history yet)'); + } finally { cleanup(); } + } catch (e) { + console.log(' ERR:', e.message); + } + } +} + +function triggerPing() { + const { spawnSync } = require('child_process'); + console.log('PATH:', process.env.PATH); + console.log('\nChecking nexpath CLI...'); + const r1 = spawnSync('nexpath', ['--version'], { encoding: 'utf8' }); + if (r1.error) { + console.error(' nexpath not on PATH — run: cd', REPO_ROOT, '&& npm link'); + } else { + console.log(' nexpath version:', r1.stdout.trim()); + } + console.log('\nChecking Layer C status...'); + const r2 = spawnSync('node', [path.join(REPO_ROOT, 'dist', 'cli', 'index.js'), 'status'], { encoding: 'utf8' }); + if (r2.stdout) console.log(r2.stdout.split('\n').filter(l => !l.startsWith('◇')).slice(0, 12).join('\n')); +} + +function triggerAuto(promptText) { + if (!promptText) { console.error('Usage: trigger auto ""'); process.exit(1); } + const { spawnSync } = require('child_process'); + const sessionId = `dev-probe|${Date.now()}`; + const cliPath = path.join(REPO_ROOT, 'dist', 'cli', 'index.js'); + console.log('Invoking: nexpath auto with session_id=' + sessionId); + const r = spawnSync('node', [cliPath, 'auto', promptText, '--session-id', sessionId], { + encoding: 'utf8', + timeout: 15000, + cwd: REPO_ROOT, + }); + if (r.error) { console.error('Spawn err:', r.error.message); process.exit(2); } + console.log('Exit:', r.status); + if (r.stdout) console.log('stdout:', r.stdout.split('\n').filter(l => !l.startsWith('◇')).join('\n')); + if (r.stderr) console.log('stderr:', r.stderr); + // Now check the store + console.log('\nLast 3 prompts in store after this call:'); + const db = openStore(); + for (const r of db.prepare('SELECT * FROM prompts ORDER BY id DESC LIMIT 3').all()) printRow(r); + db.close(); +} + +function configShow() { + const db = openStore(); + console.log('Config table:'); + const rows = db.prepare('SELECT * FROM config').all(); + if (rows.length === 0) { console.log(' (empty)'); } + else for (const r of rows) console.log(' ', r); + db.close(); +} + +function exthostLog(nArg) { + const n = parseInt(nArg, 10) || 30; + const logsDir = path.join(os.homedir(), '.config', 'Cursor', 'logs'); + if (!fs.existsSync(logsDir)) { console.error('No Cursor logs dir:', logsDir); process.exit(3); } + // Find newest log session dir + const sessions = fs.readdirSync(logsDir).filter(d => /^\d/.test(d)).sort().reverse(); + if (!sessions.length) { console.error('No log sessions found'); process.exit(3); } + const latest = path.join(logsDir, sessions[0]); + const candidates = [ + path.join(latest, 'window1', 'exthost', 'exthost.log'), + path.join(latest, 'window1', 'renderer.log'), + ]; + for (const f of candidates) { + if (!fs.existsSync(f)) continue; + console.log('=== ' + f + ' (lines containing "nexpath", last ' + n + ') ==='); + const lines = fs.readFileSync(f, 'utf8').split('\n').filter(l => /nexpath/i.test(l)); + console.log(lines.slice(-n).join('\n') || ' (no nexpath lines)'); + console.log(''); + } +} + +// ───────────────────────── dispatch ───────────────────────── + +function usage() { + console.log(fs.readFileSync(__filename, 'utf8').split('\n').slice(1, 30).filter(l => l.startsWith(' *')).map(l => l.replace(/^ \* ?/, '')).join('\n')); +} + +const argv = process.argv.slice(2); +const cmd = argv[0]; +const sub = argv[1]; + +try { + if (!cmd || cmd === 'help' || cmd === '-h' || cmd === '--help') return usage(); + if (cmd === 'store' && sub === 'schema') return storeSchema(); + if (cmd === 'store' && sub === 'recent') return storeRecent(argv[2]); + if (cmd === 'store' && sub === 'today') return storeToday(); + if (cmd === 'store' && sub === 'search') return storeSearch(argv[2]); + if (cmd === 'store' && sub === 'stats') return storeStats(); + if (cmd === 'cursor' && sub === 'workspaces') return cursorWorkspaces(); + if (cmd === 'cursor' && sub === 'probe') return cursorProbe(argv[2]); + if (cmd === 'cursor' && sub === 'extract') return cursorExtract(argv[2]); + if (cmd === 'trigger' && sub === 'ping') return triggerPing(); + if (cmd === 'trigger' && sub === 'auto') return triggerAuto(argv.slice(2).join(' ')); + if (cmd === 'config' && sub === 'show') return configShow(); + if (cmd === 'exthost-log') return exthostLog(sub); + console.error('Unknown subcommand:', cmd, sub || ''); + usage(); + process.exit(1); +} catch (e) { + console.error('Error:', e.stack || e.message); + process.exit(1); +} diff --git a/src/agents/adapters/claude-code.test.ts b/src/agents/adapters/claude-code.test.ts new file mode 100644 index 00000000..9cf4a965 --- /dev/null +++ b/src/agents/adapters/claude-code.test.ts @@ -0,0 +1,231 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdtempSync, rmSync, readFileSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { InstallContext } from '../types.js'; +import { + claudeCodeAdapter, + getClaudeSettingsPath, + buildHookCommand, + buildStopHookCommand, + buildHookEntry, + writeHookEntry, + removeHookEntry, +} from './claude-code.js'; + +/** + * Unit tests for the Claude Code HookAdapter and its supporting hook helpers. + * These cover the adapter's contract (detect/install/uninstall/settingsPath/buildHooks) + * plus the building-block functions that were moved here from install.ts in M1 + * Branch 2 (v0.1.3/m1/claude-code-refactor). + * + * The install-time byte-identical guarantee is enforced separately by + * src/cli/commands/install.snapshot.test.ts. + */ +describe('claude-code adapter + helpers', () => { + let tmpHome: string; + let originalArgv1: string; + let logSpy: ReturnType; + + const makeCtx = (): InstallContext => ({ + home: tmpHome, + cwd: join(tmpHome, 'cwd'), + yes: true, + dbPath: ':memory:', + }); + + beforeEach(() => { + tmpHome = mkdtempSync(join(tmpdir(), 'nexpath-claude-adapter-')); + originalArgv1 = process.argv[1]; + process.argv[1] = '/fixture/nexpath/dist/cli/index.js'; + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + process.argv[1] = originalArgv1; + logSpy.mockRestore(); + rmSync(tmpHome, { recursive: true, force: true }); + }); + + // ── helpers ───────────────────────────────────────────────────────────────── + + describe('getClaudeSettingsPath', () => { + it('joins home with .claude/settings.json', () => { + expect(getClaudeSettingsPath(tmpHome)).toBe(join(tmpHome, '.claude', 'settings.json')); + }); + }); + + describe('buildHookCommand', () => { + it('produces a node command with the resolved CLI path and the prompt-store DB path', () => { + const cmd = buildHookCommand(tmpHome); + expect(cmd).toBe(`node "/fixture/nexpath/dist/cli/index.js" auto --db "${tmpHome}/.nexpath/prompt-store.db"`); + }); + }); + + describe('buildStopHookCommand', () => { + it('produces a node command ending in `stop`', () => { + const cmd = buildStopHookCommand(tmpHome); + expect(cmd).toBe(`node "/fixture/nexpath/dist/cli/index.js" stop --db "${tmpHome}/.nexpath/prompt-store.db"`); + }); + }); + + describe('buildHookEntry', () => { + it('returns UserPromptSubmit and Stop groups each with _nexpath_hook marker and one command entry', () => { + const entry = buildHookEntry(tmpHome); + expect(Object.keys(entry).sort()).toEqual(['Stop', 'UserPromptSubmit']); + const ups = (entry.UserPromptSubmit as Array>)[0]; + expect(ups._nexpath_hook).toBe(true); + expect(ups.matcher).toBe(''); + const upsHooks = ups.hooks as Array<{ type: string; command: string }>; + expect(upsHooks).toHaveLength(1); + expect(upsHooks[0].type).toBe('command'); + expect(upsHooks[0].command).toContain('auto'); + const stop = (entry.Stop as Array>)[0]; + expect(stop._nexpath_hook).toBe(true); + const stopHooks = stop.hooks as Array<{ type: string; command: string }>; + expect(stopHooks[0].command).toContain('stop'); + }); + }); + + describe('writeHookEntry + removeHookEntry round-trip', () => { + it('writes the hook entry and produces a parseable JSON file', () => { + const settingsPath = getClaudeSettingsPath(tmpHome); + writeHookEntry(settingsPath, tmpHome); + const parsed = JSON.parse(readFileSync(settingsPath, 'utf8')); + expect(parsed.hooks.UserPromptSubmit[0]._nexpath_hook).toBe(true); + expect(parsed.hooks.Stop[0]._nexpath_hook).toBe(true); + }); + + it('is idempotent — re-writing replaces the previous nexpath hook, does not duplicate', () => { + const settingsPath = getClaudeSettingsPath(tmpHome); + writeHookEntry(settingsPath, tmpHome); + writeHookEntry(settingsPath, tmpHome); + const parsed = JSON.parse(readFileSync(settingsPath, 'utf8')); + expect(parsed.hooks.UserPromptSubmit).toHaveLength(1); + expect(parsed.hooks.Stop).toHaveLength(1); + }); + + it('preserves non-nexpath hooks already present in settings.json', () => { + const settingsPath = getClaudeSettingsPath(tmpHome); + // pre-existing hook entry from some other tool + const { mkdirSync, writeFileSync } = require('node:fs') as typeof import('node:fs'); + mkdirSync(join(tmpHome, '.claude'), { recursive: true }); + writeFileSync( + settingsPath, + JSON.stringify({ + hooks: { + UserPromptSubmit: [{ matcher: '', hooks: [{ type: 'command', command: 'other-tool' }] }], + }, + }), + 'utf8', + ); + writeHookEntry(settingsPath, tmpHome); + const parsed = JSON.parse(readFileSync(settingsPath, 'utf8')); + expect(parsed.hooks.UserPromptSubmit).toHaveLength(2); // one foreign + one nexpath + const nexpathGroup = parsed.hooks.UserPromptSubmit.find((g: { _nexpath_hook?: boolean }) => g._nexpath_hook); + const foreignGroup = parsed.hooks.UserPromptSubmit.find((g: { _nexpath_hook?: boolean }) => !g._nexpath_hook); + expect(nexpathGroup).toBeDefined(); + expect(foreignGroup.hooks[0].command).toBe('other-tool'); + }); + + it('removeHookEntry returns true and strips only the nexpath group', () => { + const settingsPath = getClaudeSettingsPath(tmpHome); + writeHookEntry(settingsPath, tmpHome); + const result = removeHookEntry(settingsPath); + expect(result).toBe(true); + const parsed = JSON.parse(readFileSync(settingsPath, 'utf8')); + // After removal, both UserPromptSubmit and Stop are gone (had only the nexpath entry) + expect(parsed.hooks.UserPromptSubmit).toBeUndefined(); + expect(parsed.hooks.Stop).toBeUndefined(); + }); + + it('removeHookEntry returns false when no nexpath hook is present', () => { + const settingsPath = getClaudeSettingsPath(tmpHome); + expect(removeHookEntry(settingsPath)).toBe(false); + }); + }); + + // ── adapter ───────────────────────────────────────────────────────────────── + + describe('claudeCodeAdapter static fields', () => { + it('has the expected id, label, and category', () => { + expect(claudeCodeAdapter.id).toBe('claude-code'); + expect(claudeCodeAdapter.label).toBe('Claude Code'); + expect(claudeCodeAdapter.category).toBe('hook'); + }); + }); + + describe('claudeCodeAdapter.detect', () => { + it('always returns true', () => { + expect(claudeCodeAdapter.detect(makeCtx())).toBe(true); + }); + }); + + describe('claudeCodeAdapter.settingsPath', () => { + it('returns ~/.claude/settings.json relative to ctx.home when no override', () => { + expect(claudeCodeAdapter.settingsPath(makeCtx())).toBe(join(tmpHome, '.claude', 'settings.json')); + }); + + it('returns ctx.settingsPath when provided (override)', () => { + const override = join(tmpHome, 'custom', 'settings.json'); + expect(claudeCodeAdapter.settingsPath({ ...makeCtx(), settingsPath: override })).toBe(override); + }); + }); + + describe('claudeCodeAdapter.buildHooks', () => { + it('returns UserPromptSubmit and Stop with command-type entries', () => { + const hooks = claudeCodeAdapter.buildHooks(makeCtx()); + expect(hooks.UserPromptSubmit).toHaveLength(1); + expect(hooks.UserPromptSubmit[0].type).toBe('command'); + expect(hooks.UserPromptSubmit[0].command).toContain('auto'); + expect(hooks.Stop[0].command).toContain('stop'); + }); + }); + + describe('claudeCodeAdapter.install', () => { + it('writes the hook entry and returns status installed', async () => { + const result = await claudeCodeAdapter.install(makeCtx()); + expect(result.status).toBe('installed'); + const settingsPath = join(tmpHome, '.claude', 'settings.json'); + expect(existsSync(settingsPath)).toBe(true); + const parsed = JSON.parse(readFileSync(settingsPath, 'utf8')); + expect(parsed.hooks.UserPromptSubmit[0]._nexpath_hook).toBe(true); + }); + + it('logs the success line containing the settings path', async () => { + await claudeCodeAdapter.install(makeCtx()); + const allLogs = logSpy.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(allLogs).toContain('advisory hook written to'); + expect(allLogs).toContain(join(tmpHome, '.claude', 'settings.json')); + }); + + it('writes to ctx.settingsPath when override is provided, not to home/.claude/', async () => { + const customPath = join(tmpHome, 'custom-dir', 'override.json'); + const result = await claudeCodeAdapter.install({ ...makeCtx(), settingsPath: customPath }); + expect(result.status).toBe('installed'); + expect(existsSync(customPath)).toBe(true); + expect(existsSync(join(tmpHome, '.claude', 'settings.json'))).toBe(false); + const parsed = JSON.parse(readFileSync(customPath, 'utf8')); + expect(parsed.hooks.UserPromptSubmit[0]._nexpath_hook).toBe(true); + }); + }); + + describe('claudeCodeAdapter.uninstall', () => { + it('removes the hook entry after a prior install', async () => { + await claudeCodeAdapter.install(makeCtx()); + logSpy.mockClear(); + await claudeCodeAdapter.uninstall(makeCtx()); + const parsed = JSON.parse(readFileSync(join(tmpHome, '.claude', 'settings.json'), 'utf8')); + expect(parsed.hooks.UserPromptSubmit).toBeUndefined(); + expect(parsed.hooks.Stop).toBeUndefined(); + const allLogs = logSpy.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(allLogs).toContain('advisory hook removed'); + }); + + it('logs the skip message when no nexpath hook is present', async () => { + await claudeCodeAdapter.uninstall(makeCtx()); + const allLogs = logSpy.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(allLogs).toContain('hook not registered, skipped'); + }); + }); +}); diff --git a/src/agents/adapters/claude-code.ts b/src/agents/adapters/claude-code.ts new file mode 100644 index 00000000..f3372835 --- /dev/null +++ b/src/agents/adapters/claude-code.ts @@ -0,0 +1,233 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { registerAdapter } from '../registry.js'; +import type { HookAdapter, InstallContext, InstallResult } from '../types.js'; + +/** + * Claude Code hook helpers — relocated verbatim from src/cli/commands/install.ts + * in Milestone M1 Branch 2 (v0.1.3/m1/claude-code-refactor). Identifier names, + * function bodies, and behaviour are preserved exactly so the install snapshot + * from M1 Branch 1 remains byte-identical (zero-diff invariant; see dev plan + * §1.5 in reviewduel-submodule). + * + * src/cli/commands/install.ts re-exports these names from this module for + * backward compatibility with existing imports (install.test.ts and others). + */ + +// ── Private file helpers (duplicated from install.ts so this adapter is +// ── self-sufficient and avoids a circular import) ───────────────────────────── + +function readJsonSafe(filePath: string): Record { + try { + return JSON.parse(readFileSync(filePath, 'utf8')) as Record; + } catch { + return {}; + } +} + +function writeJson(filePath: string, data: unknown): void { + mkdirSync(dirname(filePath), { recursive: true }); + writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', 'utf8'); +} + +// ── Hook command + entry builders (moved from install.ts) ───────────────────── + +/** Path to the user-level Claude Code settings file (where hooks are configured). */ +export function getClaudeSettingsPath(home: string): string { + return join(home, '.claude', 'settings.json'); +} + +/** + * Build the shell command string for the UserPromptSubmit hook. + * + * Uses `node` explicitly with the absolute path to the running CLI so the hook + * works regardless of whether nexpath is globally installed or run from a local + * build (PATH is not required). + * + * Forward slashes are used throughout so the value is safe in PowerShell and cmd + * on Windows as well as bash/zsh on Unix. + */ +export function buildHookCommand(home: string, platform = process.platform): string { + const cliPath = resolve(process.argv[1]).replace(/\\/g, '/'); + const dbPath = join(home, '.nexpath', 'prompt-store.db').replace(/\\/g, '/'); + return `node "${cliPath}" auto --db "${dbPath}"`; +} + +/** Build the shell command string for the Stop hook. */ +export function buildStopHookCommand(home: string, platform = process.platform): string { + const cliPath = resolve(process.argv[1]).replace(/\\/g, '/'); + const dbPath = join(home, '.nexpath', 'prompt-store.db').replace(/\\/g, '/'); + return `node "${cliPath}" stop --db "${dbPath}"`; +} + +/** + * Build the UserPromptSubmit + Stop hook entry objects. + * + * The `_nexpath_hook: true` field is the reliable deduplication and removal + * marker — it survives path changes across reinstalls, unlike scanning the + * command string. + * + * No `timeout` field is set so Claude Code uses its default (600 s), which is + * required for hooks that block for UI interaction (the decision session). + */ +export function buildHookEntry(home: string, platform = process.platform): Record { + return { + UserPromptSubmit: [ + { + _nexpath_hook: true, + matcher: '', + hooks: [ + { + type: 'command', + command: buildHookCommand(home, platform), + }, + ], + }, + ], + Stop: [ + { + _nexpath_hook: true, + matcher: '', + hooks: [ + { + type: 'command', + command: buildStopHookCommand(home, platform), + }, + ], + }, + ], + }; +} + +/** + * Write the nexpath UserPromptSubmit and Stop hooks into ~/.claude/settings.json. + * + * Uses a read-filter-append pattern so existing hooks written by other tools are + * preserved. Any prior nexpath hook group (identified by `_nexpath_hook: true`) + * is removed before appending the fresh entry, making this operation idempotent. + */ +export function writeHookEntry(filePath: string, home: string, platform = process.platform): void { + const data = readJsonSafe(filePath); + const hooks = (data.hooks as Record | undefined) ?? {}; + const entry = buildHookEntry(home, platform); + + // UserPromptSubmit + const existingUPS = (hooks.UserPromptSubmit as Array> | undefined) ?? []; + hooks.UserPromptSubmit = [ + ...existingUPS.filter((g) => !g._nexpath_hook), + ...(entry.UserPromptSubmit as unknown[]), + ]; + + // Stop + const existingStop = (hooks.Stop as Array> | undefined) ?? []; + hooks.Stop = [ + ...existingStop.filter((g) => !g._nexpath_hook), + ...(entry.Stop as unknown[]), + ]; + + data.hooks = hooks; + writeJson(filePath, data); +} + +/** + * Remove the nexpath UserPromptSubmit and Stop hooks from ~/.claude/settings.json. + * + * Identifies nexpath-written hook groups by the `_nexpath_hook: true` field. + * Returns false if the file does not exist or no nexpath hooks were found. + */ +export function removeHookEntry(filePath: string): boolean { + if (!existsSync(filePath)) return false; + const data = readJsonSafe(filePath); + const hooks = data.hooks as Record | undefined; + if (!hooks) return false; + + let removed = false; + + // UserPromptSubmit + const upsGroups = hooks.UserPromptSubmit as Array> | undefined; + if (upsGroups) { + const filtered = upsGroups.filter((g) => !g._nexpath_hook); + if (filtered.length < upsGroups.length) { + removed = true; + if (filtered.length === 0) delete hooks.UserPromptSubmit; + else hooks.UserPromptSubmit = filtered; + } + } + + // Stop + const stopGroups = hooks.Stop as Array> | undefined; + if (stopGroups) { + const filtered = stopGroups.filter((g) => !g._nexpath_hook); + if (filtered.length < stopGroups.length) { + removed = true; + if (filtered.length === 0) delete hooks.Stop; + else hooks.Stop = filtered; + } + } + + if (!removed) return false; + data.hooks = hooks; + writeJson(filePath, data); + return true; +} + +// ── HookAdapter implementation ──────────────────────────────────────────────── + +/** + * Claude Code adapter. Always detected (Claude Code is treated as universally + * available, matching the existing install.ts behaviour where it is always + * added to the detected-agents list). Install writes the hook entry; uninstall + * removes it. + * + * The console.log strings, the settings file path, and the hook command bytes + * are byte-identical to the pre-refactor inline code so the install snapshot + * from M1 Branch 1 remains unchanged. + */ +export const claudeCodeAdapter: HookAdapter = { + id: 'claude-code', + label: 'Claude Code', + category: 'hook', + + detect(): boolean { + return true; + }, + + settingsPath(ctx: InstallContext): string { + return ctx.settingsPath ?? getClaudeSettingsPath(ctx.home); + }, + + buildHooks(ctx: InstallContext): Record> { + return { + UserPromptSubmit: [{ type: 'command', command: buildHookCommand(ctx.home) }], + Stop: [{ type: 'command', command: buildStopHookCommand(ctx.home) }], + }; + }, + + async install(ctx: InstallContext): Promise { + // Use ctx.settingsPath when provided (preserves pre-refactor decoupling + // between target file path and homedir-based command content). Falls back + // to deriving from ctx.home when not provided (production path). + const settingsPath = ctx.settingsPath ?? getClaudeSettingsPath(ctx.home); + try { + writeHookEntry(settingsPath, ctx.home); + console.log(`✓ ${'Claude Code'.padEnd(12)} — advisory hook written to ${settingsPath}`); + return { status: 'installed' }; + } catch (err) { + console.log(`⚠ ${'Claude Code'.padEnd(12)} — hook write failed: ${(err as Error).message}`); + return { status: 'failed', notes: (err as Error).message }; + } + }, + + async uninstall(ctx: InstallContext): Promise { + const settingsPath = ctx.settingsPath ?? getClaudeSettingsPath(ctx.home); + const removed = removeHookEntry(settingsPath); + console.log(removed + ? `✓ ${'Claude Code'.padEnd(12)} — advisory hook removed` + : `- ${'Claude Code'.padEnd(12)} — hook not registered, skipped`); + }, +}; + +// Side-effect registration on module load. The registry is in-process state; +// agents/index.ts side-effect imports this module so the adapter is registered +// before any caller invokes detectAll/getAdapter. +registerAdapter(claudeCodeAdapter); diff --git a/src/agents/adapters/cursor.test.ts b/src/agents/adapters/cursor.test.ts new file mode 100644 index 00000000..1948259a --- /dev/null +++ b/src/agents/adapters/cursor.test.ts @@ -0,0 +1,151 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdtempSync, mkdirSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { cursorAdapter, cursorConfigDir } from './cursor.js'; +import type { InstallContext } from '../types.js'; + +const makeCtx = (home: string): InstallContext => ({ + home, + cwd: join(home, 'cwd'), + yes: true, + dbPath: ':memory:', +}); + +describe('cursorConfigDir', () => { + it('returns the linux path under ~/.config/Cursor', () => { + expect(cursorConfigDir('/home/u', 'linux')).toBe('/home/u/.config/Cursor'); + }); + + it('returns the darwin path under Application Support', () => { + expect(cursorConfigDir('/Users/u', 'darwin')).toBe( + '/Users/u/Library/Application Support/Cursor', + ); + }); + + it('returns the win32 path under APPDATA when provided', () => { + expect( + cursorConfigDir('C:\\Users\\u', 'win32', 'C:\\Users\\u\\AppData\\Roaming'), + ).toBe('C:\\Users\\u\\AppData\\Roaming/Cursor'); + }); + + it('falls back to /AppData/Roaming on win32 when APPDATA missing', () => { + expect(cursorConfigDir('C:/U', 'win32')).toContain('Cursor'); + }); +}); + +describe('cursorAdapter — static fields', () => { + it('has the expected id, label, category', () => { + expect(cursorAdapter.id).toBe('cursor'); + expect(cursorAdapter.label).toBe('Cursor'); + expect(cursorAdapter.category).toBe('vscode-extension'); + }); + + it('declares Open VSX + VS Code Marketplace ids', () => { + expect(cursorAdapter.marketplace.openVsx).toBe('nexpath.nexpath-vscode'); + expect(cursorAdapter.marketplace.vsCode).toBe('nexpath.nexpath-vscode'); + }); +}); + +describe('cursorAdapter.detect', () => { + let tmp: string; + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'cursor-detect-')); + }); + afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); + }); + + it('returns false when ~/.config/Cursor/ does not exist', () => { + expect(cursorAdapter.detect(makeCtx(tmp))).toBe(false); + }); + + it('returns true when ~/.config/Cursor/ does exist (linux fixture)', () => { + mkdirSync(join(tmp, '.config', 'Cursor'), { recursive: true }); + expect(cursorAdapter.detect(makeCtx(tmp))).toBe(true); + }); +}); + +describe('cursorAdapter.chatHistoryPaths', () => { + it('returns the workspaceStorage base path under the Cursor config dir', () => { + const paths = cursorAdapter.chatHistoryPaths(makeCtx('/home/u')); + expect(paths).toHaveLength(1); + expect(paths[0]).toContain('Cursor'); + expect(paths[0]).toContain('User/workspaceStorage'); + }); +}); + +describe('cursorAdapter.extractPrompt', () => { + it('returns null (decoding happens in the extension, not the CLI adapter)', () => { + expect(cursorAdapter.extractPrompt('any.key', { anything: true })).toBeNull(); + expect(cursorAdapter.extractPrompt('aiService.prompts', '[]')).toBeNull(); + }); +}); + +describe('cursorAdapter.install', () => { + let tmp: string; + let logSpy: ReturnType; + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'cursor-install-')); + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); + logSpy.mockRestore(); + }); + + it('skips when Cursor is not detected and returns status=skipped', async () => { + const r = await cursorAdapter.install(makeCtx(tmp)); + expect(r.status).toBe('skipped'); + const allLogs = logSpy.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(allLogs).toContain('not detected'); + }); + + it('prints deep-link instructions when Cursor IS detected', async () => { + mkdirSync(join(tmp, '.config', 'Cursor'), { recursive: true }); + const r = await cursorAdapter.install(makeCtx(tmp)); + expect(r.status).toBe('installed'); + const allLogs = logSpy.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(allLogs).toContain('Open VSX'); + expect(allLogs).toContain('open-vsx.org'); + expect(allLogs).toContain('marketplace.visualstudio.com'); + expect(allLogs).toContain('cursor --install-extension'); + expect(allLogs).toContain('nexpath.nexpath-vscode'); + }); +}); + +describe('cursorAdapter.uninstall', () => { + let tmp: string; + let logSpy: ReturnType; + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'cursor-uninstall-')); + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); + logSpy.mockRestore(); + }); + + it('logs the skip line when Cursor is not detected', async () => { + await cursorAdapter.uninstall(makeCtx(tmp)); + const allLogs = logSpy.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(allLogs).toContain('not detected'); + }); + + it('logs the uninstall instructions when Cursor IS detected', async () => { + mkdirSync(join(tmp, '.config', 'Cursor'), { recursive: true }); + await cursorAdapter.uninstall(makeCtx(tmp)); + const allLogs = logSpy.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(allLogs).toContain('uninstall'); + expect(allLogs).toContain('cursor --uninstall-extension'); + }); +}); + +describe('cursorAdapter registry registration', () => { + it('is registered with the global registry under id "cursor"', async () => { + // Importing the side-effect entry registers all adapters + await import('../index.js'); + const { getAdapter } = await import('../registry.js'); + expect(getAdapter('cursor')).toBe(cursorAdapter); + }); +}); diff --git a/src/agents/adapters/cursor.ts b/src/agents/adapters/cursor.ts new file mode 100644 index 00000000..0da9f33c --- /dev/null +++ b/src/agents/adapters/cursor.ts @@ -0,0 +1,158 @@ +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { registerAdapter } from '../registry.js'; +import type { + InstallContext, + InstallResult, + VSCodeExtensionAdapter, +} from '../types.js'; + +/** + * Cursor adapter (M9 of M2 Branch 4). + * + * Cursor is a VS Code fork. Nexpath integrates via a companion VS Code + * extension that runs INSIDE Cursor (sub-package at `src/ext-vscode/`). + * This CLI-side adapter only handles install-time concerns: + * 1. Detect whether Cursor is installed on this machine. + * 2. Print deep-link instructions for the user to install the extension + * via Open VSX (Cursor's marketplace). + * 3. Self-register with the agent registry so `nexpath install` picks + * it up automatically. + * + * The actual runtime work (watching `state.vscdb`, rendering the + * decision-session webview, injecting selected options into the chat + * input) happens inside the VS Code extension at activation time — see + * `src/ext-vscode/src/extension.ts` for the wiring. + * + * The `extractPrompt` method is intentionally a stub. The architecture + * doc declares it on the `VSCodeExtensionAdapter` interface for symmetric + * API shape, but actual row decoding lives at the extension's runtime via + * `src/ext-vscode/src/extractors/` — the CLI never runs the watcher and + * therefore never decodes rows. A future refactor could relocate the + * extractors to the CLI level and have the adapter wrap them; for now + * the stub returns `null` so any caller asking the CLI adapter to decode + * a row gets the explicit "I don't know" answer. + */ + +/** Marketplace identifier used in both Open VSX and the VS Code Marketplace. */ +const MARKETPLACE_ID = 'nexpath.nexpath-vscode'; + +const OPEN_VSX_URL = `https://open-vsx.org/extension/${MARKETPLACE_ID.replace( + '.', + '/', +)}`; +const VS_CODE_MARKETPLACE_URL = `https://marketplace.visualstudio.com/items?itemName=${MARKETPLACE_ID}`; + +/** + * OS-specific Cursor configuration directory. Existence of this directory + * is the heuristic the adapter uses to decide whether Cursor is installed. + */ +export function cursorConfigDir( + home: string, + platform: NodeJS.Platform = process.platform, + appdata?: string, +): string { + switch (platform) { + case 'darwin': + return join(home, 'Library', 'Application Support', 'Cursor'); + case 'win32': + return join(appdata ?? process.env.APPDATA ?? join(home, 'AppData', 'Roaming'), 'Cursor'); + default: + return join(home, '.config', 'Cursor'); + } +} + +export const cursorAdapter: VSCodeExtensionAdapter = { + id: 'cursor', + label: 'Cursor', + category: 'vscode-extension', + marketplace: { openVsx: MARKETPLACE_ID, vsCode: MARKETPLACE_ID }, + + detect(ctx: InstallContext): boolean { + return existsSync(cursorConfigDir(ctx.home)); + }, + + chatHistoryPaths(ctx: InstallContext): string[] { + // Return the base workspaceStorage directory; per-workspace state.vscdb + // enumeration happens at extension activation time (we can't enumerate + // here because the user may open new workspaces after install runs). + return [join(cursorConfigDir(ctx.home), 'User', 'workspaceStorage')]; + }, + + /** + * Intentional stub — see "Architectural decision" below. + * + * The `VSCodeExtensionAdapter` interface declares `extractPrompt` for + * symmetric API shape (so a hypothetical future caller could ask + * "given this raw row, what's the user prompt?"). In our current + * architecture this method has **no caller**: + * + * - The `nexpath` CLI never reads `state.vscdb` rows — the watcher + * lives entirely inside the VS Code extension. + * - The extension uses the extractor modules at + * `src/ext-vscode/src/extractors/{cursor-v2024-q4, v2025-q1, + * v2025-q2, windsurf, index}.ts` directly (via + * `chat-history-watcher.ts`'s `pickExtractor` + `decodeRow` flow). + * - The CLI adapter sits on the install/configure side; it doesn't + * do row decoding. + * + * Returning `null` is the contract-compliant "I don't know" answer. + * + * ## When this should be filled in (migration path) + * + * If a future CLI tool needs to decode chat-history rows (e.g. + * `nexpath debug last-prompts `), the right move is to + * **promote the extractor modules from the sub-package to the CLI + * level** so both sides share one source of truth: + * + * 1. Move `src/ext-vscode/src/extractors/*` → `src/agents/chat-history-extractors/*`. + * 2. Move `src/ext-vscode/src/chat-history-types.ts` → + * `src/agents/chat-history-extractors/types.ts`. + * 3. Update sub-package imports in `chat-history-watcher.ts` to use + * the new CLI path. Sub-package `tsconfig.json` likely needs + * `rootDir` widened (e.g. `".."`) so imports outside `./src` are + * allowed. + * 4. Wire `cursor.ts` and `windsurf.ts` adapters' `extractPrompt` + * to call `pickExtractor` + `extractor.decodeRow` and reshape + * to the interface's `{ prompt, sessionId } | null` signature. + * 5. Leave thin re-export shims at the old sub-package paths so any + * external importer keeps working. + * + * This is a non-trivial refactor with cross-tree concerns (esbuild + * externals, tsconfig.rootDir, vitest config). Deferred from B4 + * because there is currently no caller demanding it; the unit tests + * verify only the stub-returns-null contract. + */ + extractPrompt(_rowKey: string, _rowValue: unknown) { + return null; + }, + + async install(ctx: InstallContext): Promise { + if (!this.detect(ctx)) { + console.log(`- ${'Cursor'.padEnd(12)} — not detected; skipping`); + return { status: 'skipped', notes: 'Cursor not installed on this machine' }; + } + console.log(`✓ ${'Cursor'.padEnd(12)} — install the Nexpath extension to activate guidance:`); + console.log(` Open VSX: ${OPEN_VSX_URL}`); + console.log(` VS Code Marketplace: ${VS_CODE_MARKETPLACE_URL}`); + console.log(` Or via CLI: cursor --install-extension ${MARKETPLACE_ID}`); + return { + status: 'installed', + notes: + 'Deep-link instructions printed; the user must install the VS Code extension manually before guidance activates.', + }; + }, + + async uninstall(ctx: InstallContext): Promise { + if (!this.detect(ctx)) { + console.log(`- ${'Cursor'.padEnd(12)} — not detected; skipping`); + return; + } + console.log(`- ${'Cursor'.padEnd(12)} — uninstall the Nexpath extension from the Cursor Extensions panel`); + console.log(` Or via CLI: cursor --uninstall-extension ${MARKETPLACE_ID}`); + }, +}; + +// Side-effect registration on module load — registered before any installAction +// invocation thanks to the side-effect import in src/agents/index.ts. +registerAdapter(cursorAdapter); diff --git a/src/agents/adapters/windsurf.test.ts b/src/agents/adapters/windsurf.test.ts new file mode 100644 index 00000000..2fe3160c --- /dev/null +++ b/src/agents/adapters/windsurf.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdtempSync, mkdirSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { windsurfAdapter, windsurfConfigDir } from './windsurf.js'; +import type { InstallContext } from '../types.js'; + +const makeCtx = (home: string): InstallContext => ({ + home, + cwd: join(home, 'cwd'), + yes: true, + dbPath: ':memory:', +}); + +describe('windsurfConfigDir', () => { + it('returns the linux path under ~/.config/Windsurf', () => { + expect(windsurfConfigDir('/home/u', 'linux')).toBe('/home/u/.config/Windsurf'); + }); + + it('returns the darwin path under Application Support', () => { + expect(windsurfConfigDir('/Users/u', 'darwin')).toBe( + '/Users/u/Library/Application Support/Windsurf', + ); + }); + + it('returns the win32 path under APPDATA when provided', () => { + expect( + windsurfConfigDir('C:\\Users\\u', 'win32', 'C:\\Users\\u\\AppData\\Roaming'), + ).toBe('C:\\Users\\u\\AppData\\Roaming/Windsurf'); + }); +}); + +describe('windsurfAdapter — static fields', () => { + it('has the expected id, label, category', () => { + expect(windsurfAdapter.id).toBe('windsurf'); + expect(windsurfAdapter.label).toBe('Windsurf'); + expect(windsurfAdapter.category).toBe('vscode-extension'); + }); + + it('declares Open VSX + VS Code Marketplace ids', () => { + expect(windsurfAdapter.marketplace.openVsx).toBe('nexpath.nexpath-vscode'); + expect(windsurfAdapter.marketplace.vsCode).toBe('nexpath.nexpath-vscode'); + }); +}); + +describe('windsurfAdapter.detect', () => { + let tmp: string; + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'windsurf-detect-')); + }); + afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); + }); + + it('returns false when neither Windsurf nor codeium dir exists', () => { + expect(windsurfAdapter.detect(makeCtx(tmp))).toBe(false); + }); + + it('returns true when only the VS-Code-fork dir exists', () => { + mkdirSync(join(tmp, '.config', 'Windsurf'), { recursive: true }); + expect(windsurfAdapter.detect(makeCtx(tmp))).toBe(true); + }); + + it('returns true when only the legacy codeium dir exists', () => { + mkdirSync(join(tmp, '.codeium', 'windsurf'), { recursive: true }); + expect(windsurfAdapter.detect(makeCtx(tmp))).toBe(true); + }); + + it('returns true when both dirs exist', () => { + mkdirSync(join(tmp, '.config', 'Windsurf'), { recursive: true }); + mkdirSync(join(tmp, '.codeium', 'windsurf'), { recursive: true }); + expect(windsurfAdapter.detect(makeCtx(tmp))).toBe(true); + }); +}); + +describe('windsurfAdapter.chatHistoryPaths', () => { + it('returns both the workspaceStorage AND the codeium Cascade base path', () => { + const paths = windsurfAdapter.chatHistoryPaths(makeCtx('/home/u')); + expect(paths).toHaveLength(2); + expect(paths.some((p) => p.includes('Windsurf') && p.includes('workspaceStorage'))).toBe(true); + expect(paths.some((p) => p.endsWith('.codeium/windsurf'))).toBe(true); + }); +}); + +describe('windsurfAdapter.extractPrompt', () => { + it('returns null (decoding happens in the extension, not the CLI adapter)', () => { + expect(windsurfAdapter.extractPrompt('cascade.history', '[]')).toBeNull(); + expect(windsurfAdapter.extractPrompt('any.key', { x: 1 })).toBeNull(); + }); +}); + +describe('windsurfAdapter.install', () => { + let tmp: string; + let logSpy: ReturnType; + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'windsurf-install-')); + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); + logSpy.mockRestore(); + }); + + it('skips when Windsurf is not detected and returns status=skipped', async () => { + const r = await windsurfAdapter.install(makeCtx(tmp)); + expect(r.status).toBe('skipped'); + const allLogs = logSpy.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(allLogs).toContain('not detected'); + }); + + it('prints deep-link instructions when Windsurf IS detected', async () => { + mkdirSync(join(tmp, '.config', 'Windsurf'), { recursive: true }); + const r = await windsurfAdapter.install(makeCtx(tmp)); + expect(r.status).toBe('installed'); + const allLogs = logSpy.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(allLogs).toContain('Open VSX'); + expect(allLogs).toContain('windsurf --install-extension'); + expect(allLogs).toContain('nexpath.nexpath-vscode'); + }); +}); + +describe('windsurfAdapter.uninstall', () => { + let tmp: string; + let logSpy: ReturnType; + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'windsurf-uninstall-')); + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); + logSpy.mockRestore(); + }); + + it('logs the skip line when Windsurf is not detected', async () => { + await windsurfAdapter.uninstall(makeCtx(tmp)); + expect(logSpy.mock.calls.map((c) => c.join(' ')).join('\n')).toContain('not detected'); + }); + + it('logs the uninstall instructions when Windsurf IS detected', async () => { + mkdirSync(join(tmp, '.config', 'Windsurf'), { recursive: true }); + await windsurfAdapter.uninstall(makeCtx(tmp)); + const allLogs = logSpy.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(allLogs).toContain('uninstall'); + expect(allLogs).toContain('windsurf --uninstall-extension'); + }); +}); + +describe('windsurfAdapter registry registration', () => { + it('is registered with the global registry under id "windsurf"', async () => { + await import('../index.js'); + const { getAdapter } = await import('../registry.js'); + expect(getAdapter('windsurf')).toBe(windsurfAdapter); + }); +}); diff --git a/src/agents/adapters/windsurf.ts b/src/agents/adapters/windsurf.ts new file mode 100644 index 00000000..8d0b9e43 --- /dev/null +++ b/src/agents/adapters/windsurf.ts @@ -0,0 +1,132 @@ +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { registerAdapter } from '../registry.js'; +import type { + InstallContext, + InstallResult, + VSCodeExtensionAdapter, +} from '../types.js'; + +/** + * Windsurf adapter (M10 of M2 Branch 4). + * + * Windsurf is Codeium's AI-native IDE (a VS Code fork, like Cursor). + * Nexpath ships the same VS Code extension to Cursor + Windsurf via + * Open VSX. This adapter mirrors `cursor.ts` but with Windsurf-specific + * config paths. + * + * Storage layout difference from Cursor: + * - Cursor uses VS Code's standard `User/workspaceStorage//state.vscdb` + * (SQLite) — covered by the cursor-v* extractors. + * - Windsurf historically uses `~/.codeium/windsurf/` (Cascade chat data + * stored as JSON files rather than SQLite). Architecture rev 2 §4.2 + * covers this; the dev plan §3 M2 §2.2 M10 calls out the difference. + * - Recent Windsurf versions ALSO populate VS Code-style storage at + * `User/workspaceStorage/`. We watch both — the Windsurf extractor + * (already shipped in B2 as a stub) handles the cascade.* keys when + * they appear; the JSON-file path watch belongs to a future + * refinement once a real Windsurf chat capture is available. + * + * The `extractPrompt` stub matches `cursor.ts` for the same reason — + * decoding lives at the extension's runtime, not in the CLI adapter. + */ + +const MARKETPLACE_ID = 'nexpath.nexpath-vscode'; + +const OPEN_VSX_URL = `https://open-vsx.org/extension/${MARKETPLACE_ID.replace( + '.', + '/', +)}`; +const VS_CODE_MARKETPLACE_URL = `https://marketplace.visualstudio.com/items?itemName=${MARKETPLACE_ID}`; + +/** + * OS-specific Windsurf configuration directory. Used both as the primary + * detection heuristic and as the base for the workspace-storage path. + */ +export function windsurfConfigDir( + home: string, + platform: NodeJS.Platform = process.platform, + appdata?: string, +): string { + switch (platform) { + case 'darwin': + return join(home, 'Library', 'Application Support', 'Windsurf'); + case 'win32': + return join(appdata ?? process.env.APPDATA ?? join(home, 'AppData', 'Roaming'), 'Windsurf'); + default: + return join(home, '.config', 'Windsurf'); + } +} + +/** + * Legacy Cascade data directory (per-user, OS-agnostic). Windsurf may + * still drop JSON chat files here in addition to the VS Code-style + * workspaceStorage path. + */ +function codeiumCascadeDir(home: string): string { + return join(home, '.codeium', 'windsurf'); +} + +export const windsurfAdapter: VSCodeExtensionAdapter = { + id: 'windsurf', + label: 'Windsurf', + category: 'vscode-extension', + marketplace: { openVsx: MARKETPLACE_ID, vsCode: MARKETPLACE_ID }, + + detect(ctx: InstallContext): boolean { + return ( + existsSync(windsurfConfigDir(ctx.home)) || + existsSync(codeiumCascadeDir(ctx.home)) + ); + }, + + chatHistoryPaths(ctx: InstallContext): string[] { + return [ + join(windsurfConfigDir(ctx.home), 'User', 'workspaceStorage'), + codeiumCascadeDir(ctx.home), + ]; + }, + + /** + * Intentional stub — same architectural decision as `cursor.ts`. See the + * comprehensive JSDoc on `cursorAdapter.extractPrompt` for the full + * rationale + migration path if a CLI caller is ever added. + * + * Short version: decoding lives in the extension runtime + * (`src/ext-vscode/src/extractors/`); the CLI adapter never decodes rows. + * Returning `null` is contract-compliant ("I don't know"). When the + * extractors are promoted to `src/agents/chat-history-extractors/`, + * this method gets wired up to delegate via `pickExtractor` + + * `extractor.decodeRow`. + */ + extractPrompt(_rowKey: string, _rowValue: unknown) { + return null; + }, + + async install(ctx: InstallContext): Promise { + if (!this.detect(ctx)) { + console.log(`- ${'Windsurf'.padEnd(12)} — not detected; skipping`); + return { status: 'skipped', notes: 'Windsurf not installed on this machine' }; + } + console.log(`✓ ${'Windsurf'.padEnd(12)} — install the Nexpath extension to activate guidance:`); + console.log(` Open VSX: ${OPEN_VSX_URL}`); + console.log(` VS Code Marketplace: ${VS_CODE_MARKETPLACE_URL}`); + console.log(` Or via CLI: windsurf --install-extension ${MARKETPLACE_ID}`); + return { + status: 'installed', + notes: + 'Deep-link instructions printed; the user must install the VS Code extension manually before guidance activates.', + }; + }, + + async uninstall(ctx: InstallContext): Promise { + if (!this.detect(ctx)) { + console.log(`- ${'Windsurf'.padEnd(12)} — not detected; skipping`); + return; + } + console.log(`- ${'Windsurf'.padEnd(12)} — uninstall the Nexpath extension from the Windsurf Extensions panel`); + console.log(` Or via CLI: windsurf --uninstall-extension ${MARKETPLACE_ID}`); + }, +}; + +registerAdapter(windsurfAdapter); diff --git a/src/agents/index.ts b/src/agents/index.ts new file mode 100644 index 00000000..0273717e --- /dev/null +++ b/src/agents/index.ts @@ -0,0 +1,8 @@ +// Adapter registrations live here as side-effect imports. +// Each adapter module calls registerAdapter() at module load. +// +// Subsequent milestones add CLI-wrap (Codex CLI + Aider) and browser +// adapters (Replit / Bolt.new / Lovable / ChatGPT). +import './adapters/claude-code.js'; +import './adapters/cursor.js'; +import './adapters/windsurf.js'; diff --git a/src/agents/registry.test.ts b/src/agents/registry.test.ts new file mode 100644 index 00000000..ef5f4d85 --- /dev/null +++ b/src/agents/registry.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import type { AgentAdapter, InstallContext } from './types.js'; + +/** + * Unit tests for the adapter registry. + * + * The registry stores adapters in a module-local mutable array, so tests would + * leak state into each other if we re-used the same module instance. Each test + * calls `vi.resetModules()` and re-imports `./registry.js` so it gets a fresh, + * empty registry. + */ +describe('agents/registry', () => { + let registerAdapter: typeof import('./registry.js')['registerAdapter']; + let detectAll: typeof import('./registry.js')['detectAll']; + let getAdapter: typeof import('./registry.js')['getAdapter']; + + const dummyCtx: InstallContext = { + home: '/fake/home', + cwd: '/fake/cwd', + yes: true, + dbPath: ':memory:', + }; + + const makeAdapter = (id: string, detectResult: boolean): AgentAdapter => ({ + id, + label: `Label-${id}`, + category: 'hook', + detect: () => detectResult, + install: async () => ({ status: 'installed' }), + uninstall: async () => {}, + }); + + beforeEach(async () => { + vi.resetModules(); + const reg = await import('./registry.js'); + registerAdapter = reg.registerAdapter; + detectAll = reg.detectAll; + getAdapter = reg.getAdapter; + }); + + describe('registerAdapter + getAdapter', () => { + it('registers an adapter and retrieves it by id', () => { + const a = makeAdapter('cursor', true); + registerAdapter(a); + expect(getAdapter('cursor')).toBe(a); + }); + + it('returns undefined for an unknown id', () => { + registerAdapter(makeAdapter('cursor', true)); + expect(getAdapter('nonexistent')).toBeUndefined(); + }); + + it('returns undefined when no adapters are registered', () => { + expect(getAdapter('any')).toBeUndefined(); + }); + + it('supports multiple registrations and finds each by its id', () => { + const a = makeAdapter('a', true); + const b = makeAdapter('b', true); + registerAdapter(a); + registerAdapter(b); + expect(getAdapter('a')).toBe(a); + expect(getAdapter('b')).toBe(b); + }); + }); + + describe('detectAll', () => { + it('returns an empty list when no adapters are registered', async () => { + const result = await detectAll(dummyCtx); + expect(result).toEqual([]); + }); + + it('returns only adapters whose detect() returns true', async () => { + const yes = makeAdapter('yes', true); + const no = makeAdapter('no', false); + registerAdapter(yes); + registerAdapter(no); + const result = await detectAll(dummyCtx); + expect(result).toEqual([yes]); + }); + + it('supports async detect() returning a Promise', async () => { + const yesAsync: AgentAdapter = { + ...makeAdapter('async-yes', true), + detect: async () => true, + }; + const noAsync: AgentAdapter = { + ...makeAdapter('async-no', false), + detect: async () => false, + }; + registerAdapter(yesAsync); + registerAdapter(noAsync); + const result = await detectAll(dummyCtx); + expect(result.map((x) => x.id)).toEqual(['async-yes']); + }); + + it('preserves insertion order across multiple detected adapters', async () => { + registerAdapter(makeAdapter('a', true)); + registerAdapter(makeAdapter('b', true)); + registerAdapter(makeAdapter('c', true)); + const result = await detectAll(dummyCtx); + expect(result.map((x) => x.id)).toEqual(['a', 'b', 'c']); + }); + + it('passes the InstallContext through to each adapter.detect()', async () => { + const detectSpy = vi.fn(() => true); + const adapter: AgentAdapter = { + ...makeAdapter('spy', true), + detect: detectSpy, + }; + registerAdapter(adapter); + await detectAll(dummyCtx); + expect(detectSpy).toHaveBeenCalledWith(dummyCtx); + expect(detectSpy).toHaveBeenCalledTimes(1); + }); + + it('skips false-detecting adapters while keeping true-detecting ones, regardless of registration order', async () => { + registerAdapter(makeAdapter('first-no', false)); + registerAdapter(makeAdapter('second-yes', true)); + registerAdapter(makeAdapter('third-no', false)); + registerAdapter(makeAdapter('fourth-yes', true)); + const result = await detectAll(dummyCtx); + expect(result.map((x) => x.id)).toEqual(['second-yes', 'fourth-yes']); + }); + }); +}); diff --git a/src/agents/registry.ts b/src/agents/registry.ts new file mode 100644 index 00000000..480d2909 --- /dev/null +++ b/src/agents/registry.ts @@ -0,0 +1,24 @@ +import type { AgentAdapter, InstallContext } from './types.js'; + +/** + * Module-local mutable list. Adapters register themselves at module import time + * via side-effect imports in ./index.ts. This file does not import any adapter + * directly — that keeps the registry decoupled from individual adapter modules. + */ +const adapters: AgentAdapter[] = []; + +export function registerAdapter(a: AgentAdapter): void { + adapters.push(a); +} + +export async function detectAll(ctx: InstallContext): Promise { + const present: AgentAdapter[] = []; + for (const a of adapters) { + if (await a.detect(ctx)) present.push(a); + } + return present; +} + +export function getAdapter(id: string): AgentAdapter | undefined { + return adapters.find((a) => a.id === id); +} diff --git a/src/agents/types.ts b/src/agents/types.ts new file mode 100644 index 00000000..bbb1f79d --- /dev/null +++ b/src/agents/types.ts @@ -0,0 +1,97 @@ +/** + * Adapter interface contract for the coding-agent hooking module (Layer B of the + * three-layer architecture). Every coding agent surface (Claude Code, Cursor, + * Windsurf, Codex CLI, Aider, Replit, Bolt.new, Lovable, ChatGPT) is wired into + * nexpath through one of these four adapter categories. + * + * See: lib/share/submodule/reviewduel-submodule/docs/architecture/coding-agent-hooks-architecture.md + */ + +export type AdapterCategory = + | 'hook' + | 'vscode-extension' + | 'cli-wrap' + | 'browser-extension'; + +export interface InstallContext { + home: string; + cwd: string; + /** Skip confirmation prompts. */ + yes: boolean; + /** Path to ~/.nexpath/prompt-store.db (or ':memory:' for tests). */ + dbPath: string; + /** + * Optional override for the target settings/config file the adapter writes to. + * If omitted, the adapter derives the path from `home`. Tests (and non-default + * install locations) pass this to decouple the file path from `home` — + * preserving the pre-refactor behaviour where `paths.claudeSettings` was + * passed independently of `homedir()`. + * + * Adapters that don't write a single settings file (e.g. CLIWrapAdapter, + * BrowserExtensionAdapter) ignore this field. + */ + settingsPath?: string; +} + +export interface InstallResult { + status: 'installed' | 'already-installed' | 'skipped' | 'failed'; + notes?: string; +} + +export interface AgentAdapter { + /** Stable identifier, e.g. 'claude-code', 'cursor', 'replit'. */ + id: string; + label: string; + category: AdapterCategory; + + /** Returns true if this agent appears to be present on the current system. */ + detect(ctx: InstallContext): Promise | boolean; + + /** Wire up the binding — write config, install extension, register shell alias, etc. */ + install(ctx: InstallContext): Promise; + + /** Reverse of install. */ + uninstall(ctx: InstallContext): Promise; +} + +/** Adapters that wire into agents with native lifecycle hooks (e.g. Claude Code). */ +export interface HookAdapter extends AgentAdapter { + category: 'hook'; + /** Path to the agent's settings file that hosts hook entries. */ + settingsPath(ctx: InstallContext): string; + /** Build the hook entries to merge into the agent's settings file. */ + buildHooks(ctx: InstallContext): Record>; +} + +/** Adapters that ship a companion VS Code-format extension (e.g. Cursor, Windsurf). */ +export interface VSCodeExtensionAdapter extends AgentAdapter { + category: 'vscode-extension'; + /** Marketplace identifiers. */ + marketplace: { openVsx: string; vsCode?: string }; + /** Per-OS chat-history paths the extension's file-watcher subscribes to. */ + chatHistoryPaths(ctx: InstallContext): string[]; + /** Schema-extractor — reads new rows out of the watched file. */ + extractPrompt(rowKey: string, rowValue: unknown): { prompt: string; sessionId: string } | null; +} + +/** Adapters that wrap a CLI binary (e.g. Codex CLI, Aider). */ +export interface CLIWrapAdapter extends AgentAdapter { + category: 'cli-wrap'; + /** The original binary name, e.g. 'codex', 'aider'. */ + realBinary: string; + /** Strategy: 'shell-alias' or 'node-proxy'. */ + strategy: 'shell-alias' | 'node-proxy'; + /** For node-proxy: heuristic that detects an agent response boundary in stdout. */ + detectResponseEnd?(stdoutChunk: string): boolean; +} + +/** Adapters that ship a browser extension content script (Replit, Bolt.new, Lovable, ChatGPT). */ +export interface BrowserExtensionAdapter extends AgentAdapter { + category: 'browser-extension'; + /** Origins this content script runs against (Manifest V3 match patterns). */ + origins: string[]; + /** Capture strategy in priority order. */ + capture: Array<'fetch' | 'websocket' | 'dom-events' | 'mutation-observer'>; + /** Inline content-script source to be bundled into the extension build. */ + contentScriptModule: string; +} diff --git a/src/cli/commands/__snapshots__/install.snapshot.test.ts.snap b/src/cli/commands/__snapshots__/install.snapshot.test.ts.snap new file mode 100644 index 00000000..3dde6cb9 --- /dev/null +++ b/src/cli/commands/__snapshots__/install.snapshot.test.ts.snap @@ -0,0 +1,47 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`installAction snapshot (M1 zero-diff safety net) > writes the same settings.json bytes and stdout as the pre-refactor baseline 1`] = ` +{ + "settingsContent": "{ + "hooks": { + "UserPromptSubmit": [ + { + "_nexpath_hook": true, + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "node \\"/fixture/nexpath/dist/cli/index.js\\" auto --db \\"$HOME/.nexpath/prompt-store.db\\"" + } + ] + } + ], + "Stop": [ + { + "_nexpath_hook": true, + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "node \\"/fixture/nexpath/dist/cli/index.js\\" stop --db \\"$HOME/.nexpath/prompt-store.db\\"" + } + ] + } + ] + } +} +", + "stdout": " +Before installing nexpath, a few things to know: + 1. An OpenAI API key is required (OPENAI_API_KEY) for advisory generation + 2. Prompts are stored locally at $HOME/.nexpath/prompt-store.db — nothing leaves your machine + 3. To opt out at any time: press Ctrl+X during an advisory, or run nexpath uninstall + +Detected: Claude Code +✓ Claude Code — advisory hook written to $HOME/.claude/settings.json + +Restart your agents to activate nexpath-prompt-store. +Note: advisory pipeline (nexpath auto) auto-wired for Claude Code only. +Run: nexpath status to verify connections.", +} +`; diff --git a/src/cli/commands/install.snapshot.test.ts b/src/cli/commands/install.snapshot.test.ts new file mode 100644 index 00000000..eabbc6c1 --- /dev/null +++ b/src/cli/commands/install.snapshot.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdtempSync, rmSync, readFileSync, existsSync } from 'node:fs'; +import { tmpdir, homedir } from 'node:os'; +import { join } from 'node:path'; +import { installAction, resolveAgentPaths, type AgentPaths } from './install.js'; + +/** + * Real $HOME captured at module load — DEFAULT_DB_PATH (resolved when store/db.ts + * was imported) bakes this in, so we normalise it out of the snapshot to keep + * the snapshot portable across machines. + */ +const REAL_HOME = homedir(); + +/** + * Snapshot test — captures the current behaviour of installAction before any + * refactor begins. This is the safety net for Milestone M1 Branch 2's zero-diff + * guarantee: after the refactor merges, this snapshot MUST remain byte-identical. + * + * See dev plan §1.5 and §1.6 in the reviewduel-submodule docs. + */ +describe('installAction snapshot (M1 zero-diff safety net)', () => { + let tmpHome: string; + let originalArgv1: string; + let originalPlatform: NodeJS.Platform; + let logSpy: ReturnType; + + beforeEach(() => { + tmpHome = mkdtempSync(join(tmpdir(), 'nexpath-snapshot-')); + vi.stubEnv('HOME', tmpHome); + + // Pin process.argv[1] so the hook command's resolved CLI path is deterministic. + originalArgv1 = process.argv[1]; + process.argv[1] = '/fixture/nexpath/dist/cli/index.js'; + + // Pin process.platform to 'linux' so the disclosure modifier-key ("Ctrl+X" + // vs "Cmd+X" on macOS) is stable regardless of where the test runs. + originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }); + + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + process.argv[1] = originalArgv1; + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }); + vi.unstubAllEnvs(); + logSpy.mockRestore(); + rmSync(tmpHome, { recursive: true, force: true }); + }); + + it('writes the same settings.json bytes and stdout as the pre-refactor baseline', async () => { + // Controlled paths — no real user dirs touched, snapshot is fully portable. + const paths: AgentPaths = resolveAgentPaths( + tmpHome, + join(tmpHome, 'AppData', 'Roaming'), + join(tmpHome, 'cwd'), + 'linux', + ); + + await installAction( + { yes: true }, + { + isWin: false, + paths, + dbPath: ':memory:', + skipClipboardCheck: true, + }, + ); + + const settingsPath = join(tmpHome, '.claude', 'settings.json'); + const settingsContent = existsSync(settingsPath) + ? readFileSync(settingsPath, 'utf8') + : null; + + const stdout = logSpy.mock.calls.map((c) => c.join(' ')).join('\n'); + + // Normalise both the test-tmp home (resolved at install-time) and the real + // home (which DEFAULT_DB_PATH captured at module-import-time). + const normalise = (s: string | null): string | null => + s === null + ? null + : s.replaceAll(tmpHome, '$HOME').replaceAll(REAL_HOME, '$HOME'); + + expect({ + settingsContent: normalise(settingsContent), + stdout: normalise(stdout), + }).toMatchSnapshot(); + }); +}); diff --git a/src/cli/commands/install.test.ts b/src/cli/commands/install.test.ts index 450124ec..5f957900 100644 --- a/src/cli/commands/install.test.ts +++ b/src/cli/commands/install.test.ts @@ -784,6 +784,139 @@ describe('installAction', () => { expect(output).toContain('Claude Code only'); } finally { cleanup(); } }); + + // ── Registry-driven VSCodeExtensionAdapter installs (M2/B4) ───────────────── + // These tests stub HOME so the registry adapters (cursor / windsurf) check + // for their config dirs INSIDE the tmpDir — keeping the test hermetic and + // independent of whether the dev machine actually has Cursor / Windsurf + // installed. + + it('calls cursor adapter and prints deep-link instructions when Cursor is detected', async () => { + const { dir, cleanup } = tmpDir(); + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + try { + // Cursor config dir present → registry adapter's detect() returns true. + mkdirSync(join(dir, '.config', 'Cursor'), { recursive: true }); + vi.stubEnv('HOME', dir); + const paths = resolveAgentPaths(dir, dir, dir); + await installAction({ yes: true }, { + paths, + isWin: false, + execFn: () => {}, + skipClipboardCheck: true, + }); + const output = spy.mock.calls.map((c) => c[0] as string).join('\n'); + expect(output).toContain('Cursor'); + expect(output).toContain('install the Nexpath extension'); + expect(output).toContain('Open VSX'); + expect(output).toContain('cursor --install-extension'); + } finally { + vi.unstubAllEnvs(); + cleanup(); + } + }); + + it('calls windsurf adapter and prints deep-link instructions when Windsurf is detected', async () => { + const { dir, cleanup } = tmpDir(); + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + try { + mkdirSync(join(dir, '.config', 'Windsurf'), { recursive: true }); + vi.stubEnv('HOME', dir); + const paths = resolveAgentPaths(dir, dir, dir); + await installAction({ yes: true }, { + paths, + isWin: false, + execFn: () => {}, + skipClipboardCheck: true, + }); + const output = spy.mock.calls.map((c) => c[0] as string).join('\n'); + expect(output).toContain('Windsurf'); + expect(output).toContain('install the Nexpath extension'); + expect(output).toContain('windsurf --install-extension'); + } finally { + vi.unstubAllEnvs(); + cleanup(); + } + }); + + it('prints both cursor + windsurf deep-link blocks when both are detected', async () => { + const { dir, cleanup } = tmpDir(); + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + try { + mkdirSync(join(dir, '.config', 'Cursor'), { recursive: true }); + mkdirSync(join(dir, '.config', 'Windsurf'), { recursive: true }); + vi.stubEnv('HOME', dir); + const paths = resolveAgentPaths(dir, dir, dir); + await installAction({ yes: true }, { + paths, + isWin: false, + execFn: () => {}, + skipClipboardCheck: true, + }); + const output = spy.mock.calls.map((c) => c[0] as string).join('\n'); + expect(output).toContain('cursor --install-extension'); + expect(output).toContain('windsurf --install-extension'); + } finally { + vi.unstubAllEnvs(); + cleanup(); + } + }); + + it('does NOT double-invoke the claude-code adapter from the registry loop', async () => { + const { dir, cleanup } = tmpDir(); + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + try { + vi.stubEnv('HOME', dir); + const paths = resolveAgentPaths(dir, dir, dir); + await installAction({ yes: true }, { + paths, + isWin: false, + execFn: () => {}, + skipClipboardCheck: true, + }); + const output = spy.mock.calls.map((c) => c[0] as string).join('\n'); + // The Claude Code adapter prints exactly one "advisory hook written to" + // line per install. If the registry loop double-invoked it, we'd see two. + const matches = output.match(/advisory hook written to/g) ?? []; + expect(matches.length).toBe(1); + } finally { + vi.unstubAllEnvs(); + cleanup(); + } + }); + + it('catches adapter.install errors from the registry loop and prints failed line; loop continues', async () => { + const { cursorAdapter } = await import('../../agents/adapters/cursor.js'); + const installSpy = vi + .spyOn(cursorAdapter, 'install') + .mockRejectedValueOnce(new Error('synthetic disk failure')); + const { dir, cleanup } = tmpDir(); + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + try { + // Need cursor detect()=true so it's in detectAll's results AND windsurf + // detect()=true so we can verify the loop continued past the throwing one. + mkdirSync(join(dir, '.config', 'Cursor'), { recursive: true }); + mkdirSync(join(dir, '.config', 'Windsurf'), { recursive: true }); + vi.stubEnv('HOME', dir); + const paths = resolveAgentPaths(dir, dir, dir); + await installAction({ yes: true }, { + paths, + isWin: false, + execFn: () => {}, + skipClipboardCheck: true, + }); + const output = spy.mock.calls.map((c) => c[0] as string).join('\n'); + expect(installSpy).toHaveBeenCalledOnce(); + // The catch block prints the ✗ line with the error message + expect(output).toMatch(/failed:.*synthetic disk failure/); + // The loop continued — windsurf's install ran too + expect(output).toContain('windsurf --install-extension'); + } finally { + installSpy.mockRestore(); + vi.unstubAllEnvs(); + cleanup(); + } + }); }); // ── uninstallAction ─────────────────────────────────────────────────────────── @@ -893,6 +1026,67 @@ describe('uninstallAction', () => { expect(output).toContain('hook not registered'); } finally { cleanup(); } }); + + // ── Registry-driven VSCodeExtensionAdapter uninstalls (M2/B4) ─────────────── + + it('calls cursor adapter uninstall and prints uninstall instructions when Cursor is detected', async () => { + const { dir, cleanup } = tmpDir(); + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + try { + mkdirSync(join(dir, '.config', 'Cursor'), { recursive: true }); + vi.stubEnv('HOME', dir); + const paths = resolveAgentPaths(dir, dir, dir); + await uninstallAction({ paths, execFn: () => {} }); + const output = spy.mock.calls.map((c) => c[0] as string).join('\n'); + expect(output).toContain('Cursor'); + expect(output).toContain('cursor --uninstall-extension'); + } finally { + vi.unstubAllEnvs(); + cleanup(); + } + }); + + it('calls windsurf adapter uninstall and prints uninstall instructions when Windsurf is detected', async () => { + const { dir, cleanup } = tmpDir(); + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + try { + mkdirSync(join(dir, '.config', 'Windsurf'), { recursive: true }); + vi.stubEnv('HOME', dir); + const paths = resolveAgentPaths(dir, dir, dir); + await uninstallAction({ paths, execFn: () => {} }); + const output = spy.mock.calls.map((c) => c[0] as string).join('\n'); + expect(output).toContain('Windsurf'); + expect(output).toContain('windsurf --uninstall-extension'); + } finally { + vi.unstubAllEnvs(); + cleanup(); + } + }); + + it('catches adapter.uninstall errors from the registry loop and prints failed line; loop continues', async () => { + const { cursorAdapter } = await import('../../agents/adapters/cursor.js'); + const uninstallSpy = vi + .spyOn(cursorAdapter, 'uninstall') + .mockRejectedValueOnce(new Error('synthetic uninstall failure')); + const { dir, cleanup } = tmpDir(); + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + try { + mkdirSync(join(dir, '.config', 'Cursor'), { recursive: true }); + mkdirSync(join(dir, '.config', 'Windsurf'), { recursive: true }); + vi.stubEnv('HOME', dir); + const paths = resolveAgentPaths(dir, dir, dir); + await uninstallAction({ paths, execFn: () => {} }); + const output = spy.mock.calls.map((c) => c[0] as string).join('\n'); + expect(uninstallSpy).toHaveBeenCalledOnce(); + expect(output).toMatch(/failed:.*synthetic uninstall failure/); + // Loop continued — windsurf's uninstall ran too + expect(output).toContain('windsurf --uninstall-extension'); + } finally { + uninstallSpy.mockRestore(); + vi.unstubAllEnvs(); + cleanup(); + } + }); }); // ── getClaudeSettingsPath ───────────────────────────────────────────────────── diff --git a/src/cli/commands/install.ts b/src/cli/commands/install.ts index 02153e96..9723a4cb 100644 --- a/src/cli/commands/install.ts +++ b/src/cli/commands/install.ts @@ -6,6 +6,30 @@ import { confirm, isCancel } from '@clack/prompts'; import { openStore, closeStore, DEFAULT_DB_PATH } from '../../store/db.js'; import { isConfigSet, setConfig } from '../../store/config.js'; +// Side-effect import: registers all coding-agent adapters with the in-process +// registry. Must precede any getAdapter() / detectAll() call in installAction below. +import '../../agents/index.js'; +import { getAdapter, detectAll } from '../../agents/registry.js'; +import type { HookAdapter, InstallContext } from '../../agents/types.js'; +// Internal use + backward-compat re-export of the Claude Code hook helpers +// (moved to src/agents/adapters/claude-code.ts in M1 Branch 2 — v0.1.3/m1/claude-code-refactor). +import { + getClaudeSettingsPath, + buildHookCommand, + buildStopHookCommand, + buildHookEntry, + writeHookEntry, + removeHookEntry, +} from '../../agents/adapters/claude-code.js'; +export { + getClaudeSettingsPath, + buildHookCommand, + buildStopHookCommand, + buildHookEntry, + writeHookEntry, + removeHookEntry, +}; + export const MCP_SERVER_NAME = 'nexpath-prompt-store'; // ── Config entry builders (pure — no I/O) ───────────────────────────────────── @@ -205,145 +229,10 @@ export function removeOpenCodeEntry(filePath: string): boolean { } // ── Claude Code hook helpers ────────────────────────────────────────────────── - -/** Path to the user-level Claude Code settings file (where hooks are configured). */ -export function getClaudeSettingsPath(home: string): string { - return join(home, '.claude', 'settings.json'); -} - -/** - * Build the shell command string for the UserPromptSubmit hook. - * - * Uses `node` explicitly with the absolute path to the running CLI so the hook - * works regardless of whether nexpath is globally installed or run from a local - * build (PATH is not required). - * - * Forward slashes are used throughout so the value is safe in PowerShell and cmd - * on Windows as well as bash/zsh on Unix. - */ -export function buildHookCommand(home: string, platform = process.platform): string { - const cliPath = resolve(process.argv[1]).replace(/\\/g, '/'); - const dbPath = join(home, '.nexpath', 'prompt-store.db').replace(/\\/g, '/'); - return `node "${cliPath}" auto --db "${dbPath}"`; -} - -/** Build the shell command string for the Stop hook. */ -export function buildStopHookCommand(home: string, platform = process.platform): string { - const cliPath = resolve(process.argv[1]).replace(/\\/g, '/'); - const dbPath = join(home, '.nexpath', 'prompt-store.db').replace(/\\/g, '/'); - return `node "${cliPath}" stop --db "${dbPath}"`; -} - -/** - * Build the UserPromptSubmit + Stop hook entry objects. - * - * The `_nexpath_hook: true` field is the reliable deduplication and removal - * marker — it survives path changes across reinstalls, unlike scanning the - * command string. - * - * No `timeout` field is set so Claude Code uses its default (600 s), which is - * required for hooks that block for UI interaction (the decision session). - */ -export function buildHookEntry(home: string, platform = process.platform): Record { - return { - UserPromptSubmit: [ - { - _nexpath_hook: true, - matcher: '', - hooks: [ - { - type: 'command', - command: buildHookCommand(home, platform), - }, - ], - }, - ], - Stop: [ - { - _nexpath_hook: true, - matcher: '', - hooks: [ - { - type: 'command', - command: buildStopHookCommand(home, platform), - }, - ], - }, - ], - }; -} - -/** - * Write the nexpath UserPromptSubmit and Stop hooks into ~/.claude/settings.json. - * - * Uses a read-filter-append pattern so existing hooks written by other tools are - * preserved. Any prior nexpath hook group (identified by `_nexpath_hook: true`) - * is removed before appending the fresh entry, making this operation idempotent. - */ -export function writeHookEntry(filePath: string, home: string, platform = process.platform): void { - const data = readJsonSafe(filePath); - const hooks = (data.hooks as Record | undefined) ?? {}; - const entry = buildHookEntry(home, platform); - - // UserPromptSubmit - const existingUPS = (hooks.UserPromptSubmit as Array> | undefined) ?? []; - hooks.UserPromptSubmit = [ - ...existingUPS.filter((g) => !g._nexpath_hook), - ...(entry.UserPromptSubmit as unknown[]), - ]; - - // Stop - const existingStop = (hooks.Stop as Array> | undefined) ?? []; - hooks.Stop = [ - ...existingStop.filter((g) => !g._nexpath_hook), - ...(entry.Stop as unknown[]), - ]; - - data.hooks = hooks; - writeJson(filePath, data); -} - -/** - * Remove the nexpath UserPromptSubmit and Stop hooks from ~/.claude/settings.json. - * - * Identifies nexpath-written hook groups by the `_nexpath_hook: true` field. - * Returns false if the file does not exist or no nexpath hooks were found. - */ -export function removeHookEntry(filePath: string): boolean { - if (!existsSync(filePath)) return false; - const data = readJsonSafe(filePath); - const hooks = data.hooks as Record | undefined; - if (!hooks) return false; - - let removed = false; - - // UserPromptSubmit - const upsGroups = hooks.UserPromptSubmit as Array> | undefined; - if (upsGroups) { - const filtered = upsGroups.filter((g) => !g._nexpath_hook); - if (filtered.length < upsGroups.length) { - removed = true; - if (filtered.length === 0) delete hooks.UserPromptSubmit; - else hooks.UserPromptSubmit = filtered; - } - } - - // Stop - const stopGroups = hooks.Stop as Array> | undefined; - if (stopGroups) { - const filtered = stopGroups.filter((g) => !g._nexpath_hook); - if (filtered.length < stopGroups.length) { - removed = true; - if (filtered.length === 0) delete hooks.Stop; - else hooks.Stop = filtered; - } - } - - if (!removed) return false; - data.hooks = hooks; - writeJson(filePath, data); - return true; -} +// Function definitions moved to src/agents/adapters/claude-code.ts in M1 Branch 2 +// (v0.1.3/m1/claude-code-refactor). Bodies are byte-identical to what lived +// here before; the import + re-export at the top of this file preserves the +// existing public API so callers importing from './install.js' are unaffected. // ── Claude Code CLI helpers ─────────────────────────────────────────────────── @@ -450,11 +339,17 @@ export async function installAction( } } // Register the advisory pipeline hook (separate from MCP — different file) - try { - writeHookEntry(paths.claudeSettings, homedir(), isWin ? 'win32' : process.platform); - console.log(`\u2713 ${'Claude Code'.padEnd(12)} \u2014 advisory hook written to ${paths.claudeSettings}`); - } catch (err) { - console.log(`\u26a0 ${'Claude Code'.padEnd(12)} \u2014 hook write failed: ${(err as Error).message}`); + const claudeAdapter = getAdapter('claude-code') as HookAdapter | undefined; + if (claudeAdapter) { + await claudeAdapter.install({ + home: homedir(), + cwd: process.cwd(), + yes: !!opts.yes, + dbPath, + // Pass paths.claudeSettings so tests that inject a custom paths + // object still control the target file independently of homedir(). + settingsPath: paths.claudeSettings, + }); } } else if (REGISTER_MCP_SERVER) { if (agent.type === 'cline') { @@ -476,6 +371,35 @@ export async function installAction( } } + // \u2500\u2500 Registry-driven adapter installs (M2+) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 + // VSCodeExtensionAdapters (cursor, windsurf) self-register via + // src/agents/index.ts side-effect imports. detectAll() asks each + // registered adapter if it should run on THIS machine; only those whose + // detect() returns true end up in the list. claude-code is excluded + // here because it's already handled in the legacy for-loop above + // (agent.type === 'claude-cli' branch). + // + // Snapshot invariant: in the install-snapshot test, HOME is stubbed to a + // tmp directory where ~/.config/Cursor and ~/.config/Windsurf don't + // exist, so cursor/windsurf detect() returns false, this block prints + // nothing, and the install-snapshot bytes stay byte-identical to the + // pre-B4 baseline. CI fails red on snapshot diff. + const adapterCtx: InstallContext = { + home: homedir(), + cwd: process.cwd(), + yes: !!opts.yes, + dbPath, + }; + const detectedAdapters = await detectAll(adapterCtx); + for (const adapter of detectedAdapters) { + if (adapter.id === 'claude-code') continue; + try { + await adapter.install(adapterCtx); + } catch (err) { + console.log(`\u2717 ${adapter.label.padEnd(12)} \u2014 failed: ${(err as Error).message}`); + } + } + console.log(''); console.log('Restart your agents to activate nexpath-prompt-store.'); console.log('Note: advisory pipeline (nexpath auto) auto-wired for Claude Code only.'); @@ -620,6 +544,28 @@ export async function uninstallAction( } } + // \u2500\u2500 Registry-driven adapter uninstalls (M2+) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 + // Mirror of the installAction's registry block. Skips claude-code (already + // handled in the legacy for-loop above) and calls each detected adapter's + // uninstall() so the user can cleanly back out of every adapter the install + // touched. Errors are surfaced as a single line per adapter \u2014 they don't + // halt the rest of the uninstall. + const adapterCtx: InstallContext = { + home: homedir(), + cwd: process.cwd(), + yes: false, + dbPath: ':memory:', + }; + const detectedAdapters = await detectAll(adapterCtx); + for (const adapter of detectedAdapters) { + if (adapter.id === 'claude-code') continue; + try { + await adapter.uninstall(adapterCtx); + } catch (err) { + console.log(`\u2717 ${adapter.label.padEnd(12)} \u2014 failed: ${(err as Error).message}`); + } + } + console.log(''); console.log('MCP registration removed from all agents.'); console.log('Prompt history retained at ~/.nexpath/prompt-store.db'); diff --git a/src/ext-vscode/README.md b/src/ext-vscode/README.md new file mode 100644 index 00000000..9380a100 --- /dev/null +++ b/src/ext-vscode/README.md @@ -0,0 +1,31 @@ +# Nexpath VS Code Extension + +Companion extension that wires Cursor and Windsurf into the Nexpath prompt-guidance pipeline. + +This sub-package lives at `nexpath/src/ext-vscode/`. It builds independently of the main CLI (via esbuild) and ships to Open VSX + the VS Code Marketplace. + +## Status + +Milestone M2 Branch 1 (`v0.1.3/m2/extension-skeleton`) — skeleton + IPC stub + first-launch onboarding + activity-bar icon. Chat-history watcher (M2/M3), webview UI (M6–M8), and the Cursor/Windsurf adapters (M9/M10) land in subsequent branches. + +## Build + +```bash +cd src/ext-vscode/ +npm install +npm run build # bundles src/extension.ts -> out/extension.js (CJS for VS Code host) +npm run watch # rebuild on save +``` + +## Test + +Unit tests live next to their source files (`*.test.ts`) and are picked up by the root repo's `npm test` (vitest). They mock the `vscode` module via `vi.mock('vscode', ...)`. + +## Module Layout + +| File | Module (per dev plan §3 M2 §2.2) | +|---|---| +| `package.json`, `tsconfig.json`, `esbuild.config.mjs`, `src/extension.ts` | M1 — skeleton | +| `src/ipc.ts` | M5 — IPC to nexpath CLI | +| `src/onboarding.ts` | M11 — first-launch consent + macOS Full-Disk-Access guidance | +| `media/icon.svg` | M12 — activity-bar icon | diff --git a/src/ext-vscode/SMOKE-TEST.md b/src/ext-vscode/SMOKE-TEST.md new file mode 100644 index 00000000..f1b33c31 --- /dev/null +++ b/src/ext-vscode/SMOKE-TEST.md @@ -0,0 +1,230 @@ +# Manual Smoke Test — M2 Branch 5 + +> **Purpose:** verify the full Branch 1 → 4 wiring works against a real Cursor (or Windsurf) install. This is the acceptance gate for Milestone M2 Branch 5 per dev plan §3.0: +> +> *"End-to-end on dev machine: type real prompt in Cursor → real round-trip → decision UI appears."* + +The unit tests verify each component in isolation. This document walks through verifying they work **together** in a live host. + +--- + +## Prerequisites + +| Item | Version / state | +|---|---| +| Node.js | ≥ 18 | +| Cursor or Windsurf | Installed and launchable (config dir present under `~/.config/Cursor` on Linux, etc.) | +| `nexpath` repo | Working tree on `v0.1.3/m2/smoke-test` (or any later stacked branch) | +| OpenAI API key | Set in `OPENAI_API_KEY` env var — required by Layer C for advisory generation | +| `vsce` CLI (optional) | For packaging the extension as a `.vsix` (`npm install -g @vscode/vsce`) | + +--- + +## Step 1 — Build the extension bundle + sub-package deps + +```bash +cd ~/Documents/Vedanshi/NexPathMain/reviewduel/nexpath/src/ext-vscode +npm install +npm run build +``` + +**Expected output:** +- `node_modules/` populated (includes `better-sqlite3` with prebuilt platform binary) +- `out/extension.js` produced (~15 KB) +- No tsc errors + +--- + +## Step 2 — Install nexpath CLI hook (Claude Code path, already verified) + register the extension's host + +```bash +cd ~/Documents/Vedanshi/NexPathMain/reviewduel/nexpath +npm run build +node dist/cli/index.js install --yes +``` + +**Expected output includes:** +``` +Detected: Claude Code +✓ Claude Code — advisory hook written to /.claude/settings.json +✓ Cursor — install the Nexpath extension to activate guidance: + Open VSX: https://open-vsx.org/extension/nexpath/nexpath-vscode + VS Code Marketplace: https://marketplace.visualstudio.com/items?itemName=nexpath.nexpath-vscode + Or via CLI: cursor --install-extension nexpath.nexpath-vscode +``` + +(If Windsurf is also installed, a Windsurf block appears too.) + +--- + +## Step 3 — Install the extension into Cursor + +Two paths. + +### Path A — Extension Development Host (fastest for iteration) + +```bash +cd ~/Documents/Vedanshi/NexPathMain/reviewduel/nexpath/src/ext-vscode +code . # or `cursor .` +# Then press F5 — opens a new VS Code/Cursor window with the extension loaded. +``` + +### Path B — Packaged `.vsix` install (closer to real distribution) + +```bash +cd ~/Documents/Vedanshi/NexPathMain/reviewduel/nexpath/src/ext-vscode +vsce package +# Produces nexpath-vscode-0.1.3.vsix in this directory. + +cursor --install-extension nexpath-vscode-0.1.3.vsix +# Or in Cursor: Ctrl+Shift+P → "Extensions: Install from VSIX..." → pick the .vsix +``` + +Restart Cursor so the extension's `onStartupFinished` activation event fires. + +--- + +## Step 4 — Verify activation + onboarding + +After Cursor restarts: + +1. **Open the developer console** (`Help → Toggle Developer Tools`). +2. **Look for the activation log:** + ``` + [nexpath] extension activated + ``` +3. **First-launch toast** should appear: + > Allow Nexpath to read your AI chat history for prompt-level guidance? Data stays on your machine. + > [Allow] [Not now] + + Click **Allow**. (If you click "Not now", the watcher won't start — re-running activation requires clearing `globalState` or reopening with a fresh user profile.) + +4. **Activity bar** should show the Nexpath icon (the Y-fork). Click it. The sidebar panel should open with: + > Nexpath is active and watching for AI chat prompts. + +5. **Console should log** (one line — copy it for the verification table at the bottom): + ``` + [nexpath] watcher started on N state.vscdb file(s) for host=cursor + ``` + + If you see `[nexpath] consent not granted — watcher not started`, repeat step 3 of this document but click **Allow**. + + If you see `[nexpath] no workspace state.vscdb found under …`, open at least one workspace folder in Cursor (e.g., this nexpath repo itself) and reload the extension (`Ctrl+Shift+P` → "Developer: Reload Window"). + +--- + +## Step 5 — Trigger the round-trip + +> ### ⚠️ Use Ask mode (`Ctrl+L`) ONLY for these tests, NOT Agent/Composer mode +> +> Cursor's Agent / Composer mode can EXECUTE system commands. If you type a destructive prompt in those modes and Nexpath's advisory pipeline isn't fully wired up yet, Cursor's Agent may actually perform the destructive action before you see the warning. **Always test from Ask mode** — it's read-only and answers questions but doesn't execute commands. +> +> Equally important: every "hazard test" prompt below is written as an **information-retrieval question** that contains hazard keywords (so Layer C's classifier flags it) but does NOTHING harmful even if executed. + +In the Cursor window with the extension loaded: + +1. Open the AI chat: press **`Ctrl+L`** (Ask mode). Verify the chat panel shows the "Ask" label — NOT "Composer" or "Agent". +2. Type one of these safe hazard-trigger prompts: + > `explain why "rm -rf ~/Downloads/*" is dangerous` + + Or: + > `what are the risks of "git push --force" to main` + + Or: + > `what does "DROP TABLE users" do and how do databases prevent accidents` +3. Press Enter, wait for Cursor's normal response to finish. + +**Expected behaviour:** + +- Within ~250 ms of Cursor writing your prompt to `state.vscdb`, the watcher fires. +- `nexpath auto` is spawned with the prompt + a session ID like `|tab:`. +- `nexpath stop` is spawned immediately after; Layer C produces a `DecisionSessionPayload`. +- The Nexpath activity-bar panel **auto-reveals** with: + - Your advisory text in a styled box. + - A numbered list of "Suggested alternatives" — click one OR press the matching number key (`1`–`9`). + - A "Dismiss" link (or press `Esc` / `Ctrl+X`). + +**On click:** `handleOptionSelection` runs. Since the candidate Cursor command IDs in `chat-input-injector.ts` are unverified, you should expect the **clipboard fallback** path: + +> Nexpath: pasted to clipboard — paste into the chat input to use it. + +Paste into Cursor's chat input and re-send — that's the manual workaround until Step 6 verifies the direct-injection IDs. + +--- + +## Step 6 — Verify (or correct) the chat-input command IDs + +The `chat-input-injector.ts` file ships with a list of HEURISTIC GUESSES for Cursor / Windsurf chat-input commands. Until B5 verifies them, the injection always falls through to the clipboard. To find the real command IDs: + +1. In the running Cursor / Windsurf, open the developer console (`Help → Toggle Developer Tools`). +2. In the console, run: + ```js + await vscode.commands.getCommands(true) + // Filter for chat-related candidates: + // .filter(c => /chat|aichat|compose|cursor.*input|prompt/i.test(c)) + ``` + + **Note:** the `vscode` global is only available inside extension code, not in the dev tools' window. Use the extension's debug console (`Ctrl+Shift+Y` in the extension-host launch) or add a temporary `console.log` in `extension.ts.activate()`: + ```ts + const allCommands = await vscode.commands.getCommands(true); + console.log(allCommands.filter(c => /chat|compose|aichat/i.test(c))); + ``` +3. Identify the command(s) that write text to the chat input. Common candidates to investigate first: + - `composer.newChat` + - `aichat.insertWithSelection` + - `cursor.composer.focus` + - `cursor.aichat.insertSelection` + - Anything matching `*ChatInput*` or `*PromptInput*` +4. Test one in the dev console: + ```js + await vscode.commands.executeCommand('', 'test text') + ``` + If the text lands in the chat input → that's the right command. +5. **Update** `src/ext-vscode/src/chat-input-injector.ts`'s `CURSOR_CANDIDATE_COMMANDS` / `WINDSURF_CANDIDATE_COMMANDS` list — put the verified IDs at the top of the array. +6. Re-build the extension (`npm run build`), reload Cursor, re-test step 5. + +When this works, clicking an option in the webview should write the option text **directly into Cursor's chat input** instead of just the clipboard. That's the final acceptance gate for B5. + +--- + +## Step 7 — Verification table (fill in + share) + +After completing steps 1-5, paste the following table back with your observed values. This is the B5 acceptance evidence. + +| Step | Observation | +|---|---| +| 4.2 | `[nexpath] extension activated` log line: YES / NO | +| 4.3 | Consent toast appeared and Allow was clicked: YES / NO | +| 4.4 | Nexpath icon visible in activity bar: YES / NO | +| 4.5 | Watcher start log line + which host: paste here | +| 5 (auto fired) | `nexpath auto` invoked (check Cursor's process tree or strace `node`): YES / NO | +| 5 (stop fired) | `nexpath stop` invoked: YES / NO | +| 5 (panel revealed) | Decision-session UI auto-revealed in activity bar panel: YES / NO | +| 5 (advisory rendered) | Advisory text + numbered options visible: YES / NO | +| 5 (click option) | Click on an option triggers the clipboard fallback toast: YES / NO | +| 6 (command IDs) | Discovered command IDs for direct chat-input injection: list here | +| 6 (direct inject works) | After updating the candidate list, click writes directly to chat input: YES / NO | + +If any **NO** appears, log it as an issue with reproduction steps. **Step 6's direct-inject result is the final B5 gate** — when it's YES, M2 Branch 5 is done. + +--- + +## Troubleshooting + +| Symptom | Likely cause + fix | +|---|---| +| `[nexpath] consent not granted` | Click "Allow" on the first-launch toast. If the toast doesn't reappear, the user explicitly denied — clear globalState via `Ctrl+Shift+P` → `Developer: Reload Window With Extensions Disabled`, then re-open. | +| `[nexpath] no workspace state.vscdb found` | Open at least one folder in Cursor (`File → Open Folder`) and reload the window. | +| Watcher fires but nothing is rendered | `nexpath stop` returned `null` — Layer C decided no advisory is needed for this prompt. Try a prompt that's known to trigger an advisory (something with `delete`, `force`, etc.). Or, if there's no `.env` with `OPENAI_API_KEY` in the workspace, Stage 2 may silently no-op. | +| `Error: Cannot find module 'better-sqlite3'` | Run `npm install` inside `src/ext-vscode/`. If the prebuilt binary fails on your platform, `npm install --build-from-source` (needs `node-gyp`). | +| Schema-unknown toast appears | Cursor schema isn't recognised by any of the extractors. Capture a dump via `npx tsx scripts/dump-cursor-state.ts --name cursor-debug --redact` and share — see dev plan §2.5. | +| Extension activates but webview never reveals | Check the developer console for errors. Specifically: spawn-failures (nexpath CLI not on PATH), or `viewProvider undefined`. | +| `nexpath auto` runs but no advisory appears later | `OPENAI_API_KEY` not set in the workspace `.env`, or the prompt didn't trigger Stage 2. Check `~/.nexpath/prompt-store.db` for the captured prompt: `sqlite3 ~/.nexpath/prompt-store.db "SELECT prompt_text FROM prompts ORDER BY id DESC LIMIT 3;"` | + +--- + +## What this test does NOT cover + +- **Cross-OS behaviour:** macOS + Windows verification is **Branch 6** scope. B5 is "primary OS only" per the dev plan. +- **Multi-workspace concurrency:** opening two Cursor windows on different workspaces simultaneously. The session-id composer prefixes with workspace path, so they should be independent, but B5 doesn't formally test this. +- **Pre-prompt blocking:** the current capture is post-send (Cursor has already submitted the prompt by the time the watcher sees it). Pre-send blocking is a deferred open question in architecture doc §11 / dev plan §7. diff --git a/src/ext-vscode/esbuild.config.mjs b/src/ext-vscode/esbuild.config.mjs new file mode 100644 index 00000000..8bd6ba74 --- /dev/null +++ b/src/ext-vscode/esbuild.config.mjs @@ -0,0 +1,29 @@ +import { build, context } from 'esbuild'; + +const watch = process.argv.includes('--watch'); + +const config = { + entryPoints: ['src/extension.ts'], + bundle: true, + outfile: 'out/extension.js', + platform: 'node', + target: 'node18', + format: 'cjs', + // `vscode` is provided by the host. `better-sqlite3` is a native module + // (.node bindings) that esbuild cannot bundle; node_modules/better-sqlite3 + // ships in the .vsix and is loaded via dynamic import at runtime so the + // native load only hits on first chat-history read. + external: ['vscode', 'better-sqlite3'], + sourcemap: true, + minify: false, + logLevel: 'info', +}; + +if (watch) { + const ctx = await context(config); + await ctx.watch(); + console.log('[esbuild] watching src/extension.ts...'); +} else { + await build(config); + console.log('[esbuild] built out/extension.js'); +} diff --git a/src/ext-vscode/media/icon.svg b/src/ext-vscode/media/icon.svg new file mode 100644 index 00000000..89bf2f70 --- /dev/null +++ b/src/ext-vscode/media/icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/ext-vscode/package-lock.json b/src/ext-vscode/package-lock.json new file mode 100644 index 00000000..e0c2d53f --- /dev/null +++ b/src/ext-vscode/package-lock.json @@ -0,0 +1,2440 @@ +{ + "name": "nexpath-vscode", + "version": "0.1.3", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "nexpath-vscode", + "version": "0.1.3", + "dependencies": { + "better-sqlite3": "^11" + }, + "devDependencies": { + "@types/better-sqlite3": "^7", + "@types/node": "^20", + "@types/vscode": "^1.80.0", + "esbuild": "^0.21", + "tsx": "^4", + "typescript": "^5", + "vitest": "^2" + }, + "engines": { + "vscode": "^1.80.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz", + "integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz", + "integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz", + "integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz", + "integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz", + "integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz", + "integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz", + "integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz", + "integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz", + "integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz", + "integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz", + "integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz", + "integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz", + "integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz", + "integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz", + "integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz", + "integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz", + "integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz", + "integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz", + "integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz", + "integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz", + "integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz", + "integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz", + "integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz", + "integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz", + "integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.41", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", + "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/vscode": { + "version": "1.120.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.120.0.tgz", + "integrity": "sha512-feaT4Rst+FkTch5zz/ZbNCxoIvo55YU80Be2kiL7OJcod4+CUYf2lUBPdIJzozNnSEMq1VRTGrWEcCGFB3fBmA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", + "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/node-abi": { + "version": "3.92.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", + "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/rollup": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz", + "integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.3", + "@rollup/rollup-android-arm64": "4.60.3", + "@rollup/rollup-darwin-arm64": "4.60.3", + "@rollup/rollup-darwin-x64": "4.60.3", + "@rollup/rollup-freebsd-arm64": "4.60.3", + "@rollup/rollup-freebsd-x64": "4.60.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", + "@rollup/rollup-linux-arm-musleabihf": "4.60.3", + "@rollup/rollup-linux-arm64-gnu": "4.60.3", + "@rollup/rollup-linux-arm64-musl": "4.60.3", + "@rollup/rollup-linux-loong64-gnu": "4.60.3", + "@rollup/rollup-linux-loong64-musl": "4.60.3", + "@rollup/rollup-linux-ppc64-gnu": "4.60.3", + "@rollup/rollup-linux-ppc64-musl": "4.60.3", + "@rollup/rollup-linux-riscv64-gnu": "4.60.3", + "@rollup/rollup-linux-riscv64-musl": "4.60.3", + "@rollup/rollup-linux-s390x-gnu": "4.60.3", + "@rollup/rollup-linux-x64-gnu": "4.60.3", + "@rollup/rollup-linux-x64-musl": "4.60.3", + "@rollup/rollup-openbsd-x64": "4.60.3", + "@rollup/rollup-openharmony-arm64": "4.60.3", + "@rollup/rollup-win32-arm64-msvc": "4.60.3", + "@rollup/rollup-win32-ia32-msvc": "4.60.3", + "@rollup/rollup-win32-x64-gnu": "4.60.3", + "@rollup/rollup-win32-x64-msvc": "4.60.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup/node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tsx": { + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.1.tgz", + "integrity": "sha512-5QE2Q04cN1u0993w0LT5rPw3faZqZU1fFn1mGE0pV53N1Dn7c+QFFxQu1mBeSgeOXwFyTicZw02wVgp3Tb5cAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + } + } +} diff --git a/src/ext-vscode/package.json b/src/ext-vscode/package.json new file mode 100644 index 00000000..2e1d112d --- /dev/null +++ b/src/ext-vscode/package.json @@ -0,0 +1,56 @@ +{ + "name": "nexpath-vscode", + "displayName": "Nexpath", + "description": "Coding-agent prompt guidance for Cursor and Windsurf", + "version": "0.1.3", + "publisher": "nexpath", + "private": true, + "type": "module", + "engines": { + "vscode": "^1.80.0" + }, + "categories": ["Other"], + "activationEvents": ["onStartupFinished"], + "main": "./out/extension.js", + "icon": "media/icon.svg", + "contributes": { + "viewsContainers": { + "activitybar": [ + { + "id": "nexpath", + "title": "Nexpath", + "icon": "media/icon.svg" + } + ] + }, + "views": { + "nexpath": [ + { + "id": "nexpath.status", + "name": "Nexpath", + "type": "webview" + } + ] + } + }, + "scripts": { + "vscode:prepublish": "npm run build", + "build": "node esbuild.config.mjs", + "watch": "node esbuild.config.mjs --watch", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "dump-cursor-state": "tsx scripts/dump-cursor-state.ts" + }, + "dependencies": { + "better-sqlite3": "^11" + }, + "devDependencies": { + "@types/better-sqlite3": "^7", + "@types/node": "^20", + "@types/vscode": "^1.80.0", + "esbuild": "^0.21", + "tsx": "^4", + "typescript": "^5", + "vitest": "^2" + } +} diff --git a/src/ext-vscode/scripts/dump-cursor-state.ts b/src/ext-vscode/scripts/dump-cursor-state.ts new file mode 100644 index 00000000..a6cdc26c --- /dev/null +++ b/src/ext-vscode/scripts/dump-cursor-state.ts @@ -0,0 +1,213 @@ +#!/usr/bin/env node +/** + * scripts/dump-cursor-state.ts — capture verified `state.vscdb` fixtures + * from a real machine for extractor regression testing. + * + * Pure helpers (filtering, redaction, arg parsing, path resolution, + * discovery) live in `src/cursor-state-dump-helpers.ts` so they're + * typechecked and unit-tested. This file owns only the I/O orchestration: + * copy-to-staging-dir, SQLite read via better-sqlite3, and writing + * fixture JSON files. + * + * Run after Cursor has been used (especially after real prompts have been + * sent) so the fixtures contain the actual chat data. See the JSDoc in + * each `src/extractors/*.ts` file for current schema-finding status. + * + * Usage: + * npx tsx scripts/dump-cursor-state.ts --name [--redact] [--src ] + */ + +import { writeFile, mkdir, copyFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { platform as osPlatform, tmpdir } from 'node:os'; +import { join, dirname, resolve, basename } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + cursorConfigRoot, + discoverAllStateVscdb, + parseArgs, + redactValue, + shouldKeepItemTable, + type DiscoveredDb, +} from '../src/cursor-state-dump-helpers.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +interface DumpedRow { + table: 'ItemTable' | 'cursorDiskKV'; + key: string; + value: string; +} + +interface DumpedDb { + capturedAt: string; + sourcePath: string; + platform: string; + redacted: boolean; + /** Names of all tables present (useful for schema-fingerprint diagnostics). */ + tables: string[]; + /** Rows kept after filtering. */ + rows: DumpedRow[]; +} + +function printUsage(): void { + console.log(`Usage: + npx tsx scripts/dump-cursor-state.ts --name [--src ] [--redact] + +Options: + --name Fixture name prefix (no extension). REQUIRED. + One output file per discovered state.vscdb, suffixed with + the source kind (global / workspace-). + --src Path to a single state.vscdb. If omitted, all state.vscdb + files under the OS's Cursor config tree are dumped. + --redact Replace string values longer than 8 chars with same-length + placeholders. Use this if dumps may contain sensitive content. + +Output: + src/ext-vscode/test-fixtures/state-vscdb-samples/-.json +`); +} + +/** + * better-sqlite3 is WAL-aware: it reads the main DB and the sibling -wal/-shm + * transparently. We copy all three siblings to a tmp dir so we never touch + * the file Cursor is actively writing to. + */ +async function readDbAsSnapshot(path: string): Promise { + const stagingDir = join( + tmpdir(), + `nexpath-dump-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + ); + await mkdir(stagingDir, { recursive: true }); + const stagedMain = join(stagingDir, basename(path)); + await copyFile(path, stagedMain); + for (const suffix of ['-wal', '-shm'] as const) { + const sibling = path + suffix; + if (existsSync(sibling)) { + await copyFile(sibling, stagedMain + suffix); + } + } + + const mod = (await import('better-sqlite3')) as unknown as { + default: new (path: string, options?: { readonly?: boolean }) => { + prepare(sql: string): { + all(...params: unknown[]): Array>; + }; + pragma(pragma: string): unknown; + close(): void; + }; + }; + const Database = mod.default; + const db = new Database(stagedMain, { readonly: true }); + try { + db.pragma('wal_checkpoint(TRUNCATE)'); + } catch { + // not WAL — fine + } + + const dump: DumpedDb = { + capturedAt: new Date().toISOString(), + sourcePath: path, + platform: osPlatform(), + redacted: false, + tables: [], + rows: [], + }; + + const tables = ( + db + .prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'", + ) + .all() as Array<{ name: string }> + ).map((r) => r.name); + dump.tables = tables; + + if (tables.includes('ItemTable')) { + const rows = db + .prepare('SELECT key, value FROM ItemTable') + .all() as Array<{ key: string; value: string }>; + for (const r of rows) { + if (!shouldKeepItemTable(r.key)) continue; + dump.rows.push({ table: 'ItemTable', key: r.key, value: r.value }); + } + } + if (tables.includes('cursorDiskKV')) { + const rows = db + .prepare('SELECT key, value FROM cursorDiskKV') + .all() as Array<{ key: string; value: string }>; + for (const r of rows) { + dump.rows.push({ table: 'cursorDiskKV', key: r.key, value: r.value }); + } + } + + db.close(); + return dump; +} + +async function main(): Promise { + const parsed = parseArgs(process.argv.slice(2)); + if (!parsed.ok) { + if (parsed.help) { + printUsage(); + process.exit(0); + } + console.error(parsed.error); + printUsage(); + process.exit(1); + } + const args = parsed.args; + + const targets: DiscoveredDb[] = args.src + ? [{ path: args.src, label: 'src' }] + : discoverAllStateVscdb(cursorConfigRoot()); + + if (targets.length === 0) { + console.error(`No state.vscdb files found under: ${cursorConfigRoot()}`); + console.error('Pass --src for a single file.'); + process.exit(2); + } + + console.log(`Discovered ${targets.length} state.vscdb file(s):`); + for (const t of targets) console.log(` - [${t.label}] ${t.path}`); + console.log(''); + + const subPackageRoot = resolve(__dirname, '..'); + const outDir = join(subPackageRoot, 'test-fixtures', 'state-vscdb-samples'); + await mkdir(outDir, { recursive: true }); + + for (const t of targets) { + try { + const dump = await readDbAsSnapshot(t.path); + if (args.redact) { + dump.redacted = true; + for (const row of dump.rows) { + row.value = redactValue(row.value); + } + } + const outPath = join(outDir, `${args.name}-${t.label}.json`); + await writeFile(outPath, JSON.stringify(dump, null, 2) + '\n', 'utf8'); + console.log( + `[${t.label}] ${dump.rows.length} rows kept (${dump.tables.join( + ', ', + )}) → ${outPath}`, + ); + } catch (err) { + const e = err instanceof Error ? err : new Error(String(err)); + console.error(`[${t.label}] ERROR: ${e.message}`); + } + } + + console.log(''); + console.log('Next steps:'); + console.log(' 1. Review the JSON files for sensitive content before committing.'); + console.log(' 2. Re-run with --redact if needed.'); + console.log(' 3. Reference these fixtures from extractor regression tests.'); +} + +main().catch((err: unknown) => { + const e = err instanceof Error ? err : new Error(String(err)); + console.error(`Error: ${e.message}`); + process.exit(1); +}); diff --git a/src/ext-vscode/src/chat-history-types.ts b/src/ext-vscode/src/chat-history-types.ts new file mode 100644 index 00000000..ffe956cd --- /dev/null +++ b/src/ext-vscode/src/chat-history-types.ts @@ -0,0 +1,90 @@ +/** + * Shared types for the chat-history capture layer (M2 Branch 2). The watcher, + * extractors, and fingerprint logic all speak this vocabulary. + * + * VS Code (and its forks Cursor / Windsurf) stores extension state in a SQLite + * database with a single `ItemTable(key TEXT, value TEXT)` table. Per-version + * extractors decode rows from this table into normalised ChatHistoryEvents. + */ + +/** A single row from VS Code/Cursor's standard ItemTable. */ +export interface ItemTableRow { + key: string; + /** JSON-encoded string in practice; extractors are responsible for parsing. */ + value: string; +} + +/** + * One user prompt captured from the agent's chat history. Emitted by the + * watcher; consumed by the extension layer which composes a full session_id + * (workspaceId + rawSessionId) before forwarding to the nexpath IPC layer. + */ +export interface ChatHistoryEvent { + /** The prompt text the user submitted. */ + prompt: string; + /** + * Per-row session piece (tab id, conversation id, prompts-index). The + * extension layer combines this with `vscode.workspace.workspaceFile` / + * workspace id to derive the full nexpath session_id. + */ + rawSessionId: string; + /** Capture timestamp (extension-local clock). */ + capturedAt: Date; + /** Storage file/dir this row was read from. */ + sourcePath: string; + /** Identifier of the extractor that decoded this row, e.g. `cursor-v2025-q2`. */ + extractorId: string; +} + +/** Contract every per-version extractor implements (M3). */ +export interface ChatHistoryExtractor { + /** Stable identifier, e.g. `cursor-v2024-q4`, `windsurf`. */ + id: string; + /** Human-readable label for diagnostics + telemetry. */ + label: string; + /** + * Key prefixes that uniquely fingerprint this version. The fingerprint + * matcher treats each entry as a prefix; exact keys are prefixes of + * themselves. The extractor with the most observed-key prefix matches + * wins (see `pickExtractor` in `extractors/index.ts`). + */ + fingerprintKeys: readonly string[]; + /** Whether this extractor decodes rows with the given ItemTable key. */ + ownsKey(key: string): boolean; + /** + * Decode a single ItemTable row into zero or more chat events. + * Returns [] if the row contains no new user prompts (assistant messages, + * malformed JSON, foreign rows). + */ + decodeRow(row: ItemTableRow, sourcePath: string): ChatHistoryEvent[]; +} + +/** Result from `pickExtractor` (M4). */ +export type FingerprintResult = + | { + kind: 'known'; + extractor: ChatHistoryExtractor; + /** Observed keys that triggered the match — useful for logs. */ + matchedKeys: readonly string[]; + } + | { + kind: 'unknown'; + observedKeyCount: number; + /** First few observed keys, for the "schema unknown" toast / log. */ + observedSampleKeys: readonly string[]; + }; + +/** Storage layout the watcher needs to handle. */ +export type StorageKind = 'cursor-sqlite' | 'windsurf-dir'; + +/** A path the watcher is monitoring. */ +export interface WatchTarget { + /** Absolute path to a SQLite file (Cursor) or a directory (Windsurf). */ + path: string; + kind: StorageKind; + /** + * Extractor for this target. For Cursor, may be left undefined — the + * watcher fingerprints the ItemTable on first read and caches the result. + */ + extractor?: ChatHistoryExtractor; +} diff --git a/src/ext-vscode/src/chat-history-watcher.test.ts b/src/ext-vscode/src/chat-history-watcher.test.ts new file mode 100644 index 00000000..454b4018 --- /dev/null +++ b/src/ext-vscode/src/chat-history-watcher.test.ts @@ -0,0 +1,915 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { EventEmitter } from 'node:events'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + createChatHistoryWatcher, + defaultReadItemTable, + defaultReadWindsurfJsonFiles, + type ReadItemTableFn, + type ReadWindsurfJsonFilesFn, +} from './chat-history-watcher.js'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import type { + ChatHistoryEvent, + ChatHistoryExtractor, + ItemTableRow, + WatchTarget, +} from './chat-history-types.js'; + +/** Fake fs.watch — returns a minimal EventEmitter compatible with FSWatcher. */ +interface FakeFSWatcher extends EventEmitter { + close: () => void; +} + +function makeFakeWatcher(): FakeFSWatcher { + const w = new EventEmitter() as FakeFSWatcher; + w.close = vi.fn(); + return w; +} + +function makeExtractor( + id: string, + events: ChatHistoryEvent[], + ownsKey: (k: string) => boolean = () => true, +): ChatHistoryExtractor { + return { + id, + label: id, + fingerprintKeys: [id], + ownsKey, + decodeRow: () => events, + }; +} + +function ev( + prompt: string, + rawSessionId: string, + sourcePath: string, +): ChatHistoryEvent { + return { + prompt, + rawSessionId, + capturedAt: new Date(0), + sourcePath, + extractorId: 'test', + }; +} + +const cursorTarget = (path: string, extractor?: ChatHistoryExtractor): WatchTarget => ({ + path, + kind: 'cursor-sqlite', + extractor, +}); + +describe('createChatHistoryWatcher', () => { + let watchFn: ReturnType; + let createdWatchers: FakeFSWatcher[]; + let readItemTableFn: ReturnType; + let onEvent: ReturnType; + let onError: ReturnType; + let onSchemaUnknown: ReturnType; + + beforeEach(() => { + createdWatchers = []; + watchFn = vi.fn((_path: string, listener?: (event: string, filename: string) => void) => { + const w = makeFakeWatcher(); + // Wire the listener so .emit('change', ...) actually triggers it. + // Mirrors node:fs watch() behaviour where the listener arg is + // registered as a 'change' / 'rename' event handler on the FSWatcher. + if (listener) w.on('change', listener); + createdWatchers.push(w); + return w; + }); + readItemTableFn = vi.fn(async () => []); + onEvent = vi.fn(); + onError = vi.fn(); + onSchemaUnknown = vi.fn(); + }); + + it('start() registers a watcher for every target (plus WAL+SHM siblings for cursor-sqlite)', () => { + const w = createChatHistoryWatcher({ + targets: [cursorTarget('/a/state.vscdb'), cursorTarget('/b/state.vscdb')], + onEvent, + watchFn: watchFn as never, + readItemTableFn, + }); + w.start(); + // Per target: main file + -wal + -shm = 3 watchers. Two targets = 6. + // The -wal / -shm watches are load-bearing because live Cursor uses + // SQLite WAL mode and writes go to the sibling, not the main file. + expect(watchFn).toHaveBeenCalledTimes(6); + expect(watchFn).toHaveBeenCalledWith('/a/state.vscdb', expect.any(Function)); + expect(watchFn).toHaveBeenCalledWith('/a/state.vscdb-wal', expect.any(Function)); + expect(watchFn).toHaveBeenCalledWith('/a/state.vscdb-shm', expect.any(Function)); + expect(watchFn).toHaveBeenCalledWith('/b/state.vscdb', expect.any(Function)); + expect(watchFn).toHaveBeenCalledWith('/b/state.vscdb-wal', expect.any(Function)); + expect(watchFn).toHaveBeenCalledWith('/b/state.vscdb-shm', expect.any(Function)); + }); + + it('WAL sibling change triggers a re-read of the main target (WAL-mode liveness)', async () => { + // Start with no rows so the initial pass primes nothing; then a NEW row + // appears via a WAL-sibling change and that should produce one emit. + readItemTableFn.mockResolvedValue([]); + const extractor = makeExtractor('test', [ev('prompt-after-wal-fire', 's-2', '/p/state.vscdb')]); + const w = createChatHistoryWatcher({ + targets: [cursorTarget('/p/state.vscdb', extractor)], + onEvent, + watchFn: watchFn as never, + readItemTableFn, + debounceMs: 5, + }); + w.start(); + await new Promise((r) => setTimeout(r, 20)); + // After prime: no events emitted yet. + expect(onEvent).toHaveBeenCalledTimes(0); + + // Now a new row appears. WAL-sibling change should drive a re-read. + readItemTableFn.mockResolvedValue([{ key: 'k2', value: 'v2' }]); + // The 2nd watcher in createdWatchers is the -wal sibling (index 1). + expect(createdWatchers.length).toBeGreaterThanOrEqual(2); + createdWatchers[1]!.emit('change', 'change', '/p/state.vscdb-wal'); + await new Promise((r) => setTimeout(r, 30)); + + expect(onEvent).toHaveBeenCalledTimes(1); + expect(onEvent.mock.calls[0]![0].prompt).toBe('prompt-after-wal-fire'); + }); + + it('cursor-sqlite: runs ALL extractors that own a row, not just the fingerprint-winner', async () => { + // Regression for the fingerprint-tie bug found in M2 manual testing R2.1: + // workspaces with BOTH aiService.prompts (cursor-v2024-q4) AND + // composer.composerData (cursor-v2025-q1) caused pickExtractor to tie at + // 1 match each, registry order picked cursor-v2025-q1, which doesn't own + // aiService.prompts → all Ask-mode prompts were silently discarded. + // Fix: per-row, run every extractor whose ownsKey returns true. + // Empty first read primes nothing; then the multi-key rows appear. + readItemTableFn.mockResolvedValue([]); + + const w = createChatHistoryWatcher({ + targets: [{ path: '/p/state.vscdb', kind: 'cursor-sqlite' }], + onEvent, + watchFn: watchFn as never, + readItemTableFn, + debounceMs: 1, + }); + w.start(); + await new Promise((r) => setTimeout(r, 20)); + + readItemTableFn.mockResolvedValue([ + { key: 'composer.composerData', value: JSON.stringify({ selectedComposerIds: [] }) }, + { key: 'aiService.prompts', value: JSON.stringify([{ text: 'real prompt' }]) }, + ]); + createdWatchers[0]!.emit('change', 'change', '/p/state.vscdb'); + await new Promise((r) => setTimeout(r, 20)); + + // Expect: cursor-v2024-q4 decodes aiService.prompts → 1 event with our prompt + // (cursor-v2025-q1 also runs but ownsKey returns false for aiService.prompts + // and the composer.composerData value is metadata only → 0 events from it) + expect(onEvent).toHaveBeenCalled(); + const events = onEvent.mock.calls.map((c) => c[0]); + const prompts = events.map((e) => e.prompt); + expect(prompts).toContain('real prompt'); + }); + + it('windsurf-dir targets do NOT get WAL siblings watched (only cursor-sqlite uses WAL)', () => { + const w = createChatHistoryWatcher({ + targets: [{ path: '/ws/codeium', kind: 'windsurf-dir' }], + onEvent, + watchFn: watchFn as never, + readItemTableFn, + }); + w.start(); + // Just the dir itself, no WAL siblings (Windsurf uses JSON files not SQLite) + expect(watchFn).toHaveBeenCalledTimes(1); + expect(watchFn).toHaveBeenCalledWith('/ws/codeium', expect.any(Function)); + }); + + it('FIFO-shift regression: a single new prompt that pushes the oldest out emits exactly once (real cursor-v2024-q4 wiring)', async () => { + // The exact M2 R3 live-test scenario (2026-05-20): Cursor's + // aiService.prompts is a rolling FIFO of ~10 prompts. When a new + // prompt is submitted, the oldest is dropped and ALL indexes shift. + // Before the cursor-v2024-q4 rawSessionId fix, the dedup signature + // shifted along with the indexes → every restart re-emitted all 10 + // prompts. After the fix (rawSessionId = 'ask-mode' constant), the + // signature is sourcePath+text-driven and survives FIFO shifts. + // This test wires the real cursor-v2024-q4 extractor — via the + // unified extractor cache — to prove the integration works. + const { cursorV2024Q4 } = await import('./extractors/cursor-v2024-q4.js'); + const initialPrompts = Array.from({ length: 10 }, (_, i) => ({ text: `prompt-${i}` })); + readItemTableFn.mockResolvedValueOnce([ + { key: 'aiService.prompts', value: JSON.stringify(initialPrompts) }, + ]); + const w = createChatHistoryWatcher({ + targets: [cursorTarget('/p/state.vscdb', cursorV2024Q4)], + onEvent, + watchFn: watchFn as never, + readItemTableFn, + debounceMs: 1, + }); + w.start(); + await new Promise((r) => setTimeout(r, 20)); + expect(onEvent).toHaveBeenCalledTimes(0); // initial pass primed only + + // User submits a new prompt — FIFO drops prompt-0, appends prompt-10. + const shifted = [ + ...Array.from({ length: 9 }, (_, i) => ({ text: `prompt-${i + 1}` })), + { text: 'prompt-10' }, + ]; + readItemTableFn.mockResolvedValue([ + { key: 'aiService.prompts', value: JSON.stringify(shifted) }, + ]); + createdWatchers[0]!.emit('change', 'change', '/p/state.vscdb'); + await new Promise((r) => setTimeout(r, 20)); + + expect(onEvent).toHaveBeenCalledTimes(1); + expect(onEvent.mock.calls[0]![0].prompt).toBe('prompt-10'); + }); + + it('prime-then-new-prompt: existing rows are primed silently, NEW row after start() emits once', async () => { + // End-to-end of the M2 R2/R3 fix: extension activation must not replay + // historical state.vscdb rows to Layer C; only prompts that appear + // AFTER the watcher has primed should fire onEvent. Mirrors what + // happens when the user (a) opens Cursor with prior Ask-mode history + // already in state.vscdb, then (b) submits a brand-new prompt. + const oldRow = { key: 'aiService.prompts', value: '[{"text":"old"}]' }; + const newRow = { key: 'aiService.prompts', value: '[{"text":"old"},{"text":"new"}]' }; + // Initial read: only the "old" row is present. + readItemTableFn.mockResolvedValueOnce([oldRow]); + const extractor = makeExtractor('test', []); + extractor.ownsKey = (k: string) => k === 'aiService.prompts'; + extractor.decodeRow = (row) => { + const arr = JSON.parse(row.value) as Array<{ text: string }>; + return arr.map((p, i) => ev(p.text, `prompts-index:${i}`, '/p/state.vscdb')); + }; + const w = createChatHistoryWatcher({ + targets: [cursorTarget('/p/state.vscdb', extractor)], + onEvent, + watchFn: watchFn as never, + readItemTableFn, + debounceMs: 1, + }); + w.start(); + await new Promise((r) => setTimeout(r, 20)); + // Prime registered both signatures internally; no events emitted. + expect(onEvent).toHaveBeenCalledTimes(0); + + // Now a new prompt arrives — state.vscdb grows by one entry. + readItemTableFn.mockResolvedValue([newRow]); + createdWatchers[0]!.emit('change', 'change', '/p/state.vscdb'); + await new Promise((r) => setTimeout(r, 20)); + + // Exactly ONE event for the NEW prompt; the "old" one is deduped. + expect(onEvent).toHaveBeenCalledTimes(1); + expect(onEvent.mock.calls[0]![0].prompt).toBe('new'); + }); + + it('initial-pass after start() reads existing rows but does NOT emit them (prime-only dedup)', async () => { + // Prime-only initial-pass behaviour. Historical prompts that already exist + // in state.vscdb when the extension activates must NOT be replayed to + // Layer C — Layer C is built around Claude Code's hook semantics, which + // fire only on NEW prompts. Replaying historical prompts on every Cursor + // restart bypassed Layer C's 3-prompt warmup + 5-prompt cooldown gates + // and caused advisory storms (root-caused during M2 R2/R3 manual testing). + const fakeRows: ItemTableRow[] = [{ key: 'k1', value: 'v1' }]; + readItemTableFn.mockResolvedValue(fakeRows); + const extractor = makeExtractor('test', [ev('hi', 's-1', '/p')]); + const w = createChatHistoryWatcher({ + targets: [cursorTarget('/p', extractor)], + onEvent, + watchFn: watchFn as never, + readItemTableFn, + debounceMs: 1, + }); + w.start(); + await new Promise((r) => setTimeout(r, 20)); + // Read happened (so dedup is primed) but no event was emitted. + expect(readItemTableFn).toHaveBeenCalledTimes(1); + expect(onEvent).toHaveBeenCalledTimes(0); + }); + + it('deduplicates events across multiple reads (signature dedup)', async () => { + // Initial pass primes dedup with the existing row (no emit). Then we + // simulate a NEW row appearing — that's the first emit. A subsequent + // change with the same row hits dedup and does not re-emit. + const extractor = makeExtractor('test', [ev('same', 's-1', '/p')]); + readItemTableFn.mockResolvedValue([]); // first read = empty (prime with no rows) + const w = createChatHistoryWatcher({ + targets: [cursorTarget('/p', extractor)], + onEvent, + watchFn: watchFn as never, + readItemTableFn, + debounceMs: 1, + }); + w.start(); + await new Promise((r) => setTimeout(r, 20)); + + // Now a real new prompt arrives on disk. Subsequent reads return the row. + readItemTableFn.mockResolvedValue([{ key: 'k', value: 'v' }]); + createdWatchers[0]!.emit('change', 'change', '/p'); + await new Promise((r) => setTimeout(r, 20)); + createdWatchers[0]!.emit('change', 'change', '/p'); + await new Promise((r) => setTimeout(r, 20)); + + // First post-prime read emitted once; subsequent reads dedup the same row. + expect(onEvent).toHaveBeenCalledTimes(1); + }); + + // ── Cross-extractor dedup (Cursor 3.x Composer↔Ask mirror) ────────────── + // Cursor 3.x writes Composer prompts to BOTH globalStorage cursorDiskKV + // (decoded as bubbleId rawSessionId) AND workspaceStorage aiService.prompts + // (decoded as 'ask-mode' rawSessionId). Different sourcePath|rawSessionId|prompt + // signatures bypass the primary dedup. The cross-extractor map dedups by + // prompt text alone within a 60s window. + + it('cross-extractor dedup: same prompt text from two extractors within 60s emits once', async () => { + const t0 = new Date('2026-05-27T12:00:00Z'); + // Same prompt text, but the two events arrive with different rawSessionIds + // and sourcePaths — mimicking Composer (bubbleId) + Ask (ask-mode) mirror. + const extractor = makeExtractor('mixed', [ + ev('write a test', 'bubbleId-abc', '/global/state.vscdb'), + ev('write a test', 'ask-mode', '/workspace/state.vscdb'), + ]); + readItemTableFn.mockResolvedValue([]); // prime empty + const w = createChatHistoryWatcher({ + targets: [cursorTarget('/global/state.vscdb', extractor)], + onEvent, + watchFn: watchFn as never, + readItemTableFn, + nowFn: () => t0, + debounceMs: 1, + }); + w.start(); + await new Promise((r) => setTimeout(r, 20)); + + // Now both mirror events arrive in one read. + readItemTableFn.mockResolvedValue([{ key: 'k', value: 'v' }]); + createdWatchers[0]!.emit('change', 'change', '/global/state.vscdb'); + await new Promise((r) => setTimeout(r, 20)); + + // Only ONE emit — the second event was suppressed by cross-extractor dedup. + expect(onEvent).toHaveBeenCalledTimes(1); + }); + + it('cross-extractor dedup: same prompt text re-submitted after 60s passes through', async () => { + let clock = new Date('2026-05-27T12:00:00Z').getTime(); + readItemTableFn.mockResolvedValue([]); + const extractor = makeExtractor('mixed', [ + ev('write a test', 'session-a', '/p/state.vscdb'), + ]); + const w = createChatHistoryWatcher({ + targets: [cursorTarget('/p/state.vscdb', extractor)], + onEvent, + watchFn: watchFn as never, + readItemTableFn, + nowFn: () => new Date(clock), + debounceMs: 1, + }); + w.start(); + await new Promise((r) => setTimeout(r, 20)); + + // First emission of the prompt. + readItemTableFn.mockResolvedValue([{ key: 'k', value: 'v' }]); + createdWatchers[0]!.emit('change', 'change', '/p/state.vscdb'); + await new Promise((r) => setTimeout(r, 20)); + expect(onEvent).toHaveBeenCalledTimes(1); + + // Advance clock past 60s window + swap signature so primary dedup doesn't catch it. + clock += 65_000; + const reEmit = makeExtractor('mixed', [ + ev('write a test', 'session-b', '/p/state.vscdb'), + ]); + // Replace the watcher's extractor lookup by mocking decodeRow via the + // single-extractor cache — easiest path is a second target with a fresh + // extractor pointing at the same prompt text. + readItemTableFn.mockImplementation(async () => [{ key: 'k', value: 'v' }]); + // Force the extractor decode to return the new rawSessionId by replacing + // the underlying mock; cursor target's per-row extractor is fed by + // extractorsToTry which uses target.extractor. + Object.assign(extractor, { decodeRow: reEmit.decodeRow }); + + createdWatchers[0]!.emit('change', 'change', '/p/state.vscdb'); + await new Promise((r) => setTimeout(r, 20)); + + // Window expired → second emission passes through. + expect(onEvent).toHaveBeenCalledTimes(2); + }); + + it('cross-extractor dedup: initial-pass priming does NOT block fresh emissions of same text', async () => { + // globalStorage state.vscdb is workspace-agnostic — initial-pass primes + // dedup with bubble rows from PRIOR workspaces. A fresh user prompt with + // matching text must still emit. Regression guard for the priming bug + // discovered during S01 manual run 2026-05-27: priming with NOW timestamp + // dropped legit P1 captures. Fix primes with timestamp 0 (far past). + const t0 = new Date('2026-05-27T12:00:00Z'); + // Initial pass returns one historical row decoding to "write a test". + readItemTableFn.mockResolvedValue([{ key: 'historical', value: 'v' }]); + const extractor = makeExtractor('mixed', [ + ev('write a test', 'historical-session', '/global/state.vscdb'), + ]); + const w = createChatHistoryWatcher({ + targets: [cursorTarget('/global/state.vscdb', extractor)], + onEvent, + watchFn: watchFn as never, + readItemTableFn, + nowFn: () => t0, + debounceMs: 1, + }); + w.start(); + await new Promise((r) => setTimeout(r, 20)); + + // Initial pass primed the historical row silently — no emit yet. + expect(onEvent).toHaveBeenCalledTimes(0); + + // Now the user submits a NEW prompt with identical text in the live session. + // Different rawSessionId so primary signature dedup does NOT match. + const liveExtractor = makeExtractor('mixed', [ + ev('write a test', 'live-session', '/global/state.vscdb'), + ]); + Object.assign(extractor, { decodeRow: liveExtractor.decodeRow }); + readItemTableFn.mockResolvedValue([{ key: 'live', value: 'v' }]); + createdWatchers[0]!.emit('change', 'change', '/global/state.vscdb'); + await new Promise((r) => setTimeout(r, 20)); + + // MUST emit. If priming had used NOW timestamp, this would have been + // deduped inside the 60s window and the user's P1 prompt would vanish. + expect(onEvent).toHaveBeenCalledTimes(1); + }); + + it('debounces a burst of change events into a single read', async () => { + readItemTableFn.mockResolvedValue([]); + const w = createChatHistoryWatcher({ + targets: [cursorTarget('/p', makeExtractor('test', []))], + onEvent, + watchFn: watchFn as never, + readItemTableFn, + debounceMs: 50, + }); + w.start(); + // initial-pass schedule fires after 50ms; before it does, fire 5 more changes + for (let i = 0; i < 5; i++) { + createdWatchers[0]!.emit('change', 'change', '/p'); + } + await new Promise((r) => setTimeout(r, 80)); + // Only one read should have occurred (the bursts coalesced) + expect(readItemTableFn).toHaveBeenCalledTimes(1); + }); + + it('emits onSchemaUnknown when the ItemTable has no recognised fingerprint', async () => { + readItemTableFn.mockResolvedValue([ + { key: 'unrelated.thing.1', value: 'x' }, + { key: 'unrelated.thing.2', value: 'x' }, + ]); + const w = createChatHistoryWatcher({ + targets: [cursorTarget('/p')], // no explicit extractor — must fingerprint + onEvent, + onSchemaUnknown, + watchFn: watchFn as never, + readItemTableFn, + debounceMs: 1, + }); + w.start(); + await new Promise((r) => setTimeout(r, 20)); + expect(onSchemaUnknown).toHaveBeenCalledTimes(1); + const arg = onSchemaUnknown.mock.calls[0]![0]; + expect(arg.path).toBe('/p'); + expect(arg.observedSampleKeys).toEqual([ + 'unrelated.thing.1', + 'unrelated.thing.2', + ]); + expect(onEvent).not.toHaveBeenCalled(); + }); + + it('emits onSchemaUnknown only once per path across repeated unknown reads', async () => { + // Windsurf's workspaceStorage state.vscdb never holds chat, so it stays + // unknown on every fs.watch fire. The watcher must keep re-checking (a + // fresh Cursor workspace gains chat keys later) but notify the user only + // once — otherwise the log + info toast re-fire endlessly. + readItemTableFn.mockResolvedValue([ + { key: 'windsurf.cascadeViewContainerId.state', value: 'x' }, + ]); + const w = createChatHistoryWatcher({ + targets: [cursorTarget('/p')], // no extractor — fingerprinted every read + onEvent, + onSchemaUnknown, + watchFn: watchFn as never, + readItemTableFn, + debounceMs: 1, + }); + w.start(); + await new Promise((r) => setTimeout(r, 15)); // initial read: unknown -> notify once + // Three more spaced fs.watch fires (beyond the debounce so they don't coalesce). + for (let i = 0; i < 3; i++) { + createdWatchers[0]!.emit('change', 'change', '/p'); + await new Promise((r) => setTimeout(r, 15)); + } + // Re-checked on every read (still unknown each time) ... + expect(readItemTableFn.mock.calls.length).toBeGreaterThanOrEqual(4); + // ... but surfaced to the user exactly once. + expect(onSchemaUnknown).toHaveBeenCalledTimes(1); + expect(onEvent).not.toHaveBeenCalled(); + }); + + it('forwards readItemTableFn errors to onError without crashing', async () => { + readItemTableFn.mockRejectedValueOnce(new Error('sqlite parse error')); + const w = createChatHistoryWatcher({ + targets: [cursorTarget('/p', makeExtractor('test', []))], + onEvent, + onError, + watchFn: watchFn as never, + readItemTableFn, + debounceMs: 1, + }); + w.start(); + await new Promise((r) => setTimeout(r, 20)); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError.mock.calls[0]![0].message).toContain('sqlite parse error'); + }); + + it('forwards fs.watch error events to onError', async () => { + const w = createChatHistoryWatcher({ + targets: [cursorTarget('/p', makeExtractor('test', []))], + onEvent, + onError, + watchFn: watchFn as never, + readItemTableFn, + debounceMs: 1, + }); + w.start(); + createdWatchers[0]!.emit('error', new Error('watch boom')); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError.mock.calls[0]![0].message).toContain('watch boom'); + }); + + it('stop() closes all watchers and cancels pending debouncers', async () => { + const w = createChatHistoryWatcher({ + targets: [cursorTarget('/a'), cursorTarget('/b')], + onEvent, + watchFn: watchFn as never, + readItemTableFn, + debounceMs: 50, + }); + w.start(); + // before the debounced read fires, stop + w.stop(); + await new Promise((r) => setTimeout(r, 80)); + for (const fw of createdWatchers) { + expect(fw.close).toHaveBeenCalled(); + } + // no reads should have happened — debouncers were cancelled + expect(readItemTableFn).not.toHaveBeenCalled(); + }); + + it('stamps capturedAt with nowFn (clock injection)', async () => { + const fixedDate = new Date('2026-05-14T17:00:00Z'); + // Empty first read so the initial pass primes nothing — then the real + // emit happens after a simulated change. + readItemTableFn.mockResolvedValue([]); + const extractor = makeExtractor('test', [ev('hi', 's', '/p')]); + const w = createChatHistoryWatcher({ + targets: [cursorTarget('/p', extractor)], + onEvent, + watchFn: watchFn as never, + readItemTableFn, + nowFn: () => fixedDate, + debounceMs: 1, + }); + w.start(); + await new Promise((r) => setTimeout(r, 20)); + readItemTableFn.mockResolvedValue([{ key: 'k', value: 'v' }]); + createdWatchers[0]!.emit('change', 'change', '/p'); + await new Promise((r) => setTimeout(r, 20)); + expect(onEvent.mock.calls[0]![0].capturedAt).toEqual(fixedDate); + }); + + it('windsurf-dir targets are accepted without crashing (default stub decoder = no events)', () => { + const w = createChatHistoryWatcher({ + targets: [{ path: '/ws', kind: 'windsurf-dir' }], + onEvent, + watchFn: watchFn as never, + readItemTableFn, + // Default readWindsurfJsonFilesFn handles missing dir (returns []) + // so this passes without disk IO. + debounceMs: 1, + }); + expect(() => w.start()).not.toThrow(); + expect(() => w.stop()).not.toThrow(); + }); + + // ── windsurf-dir code path (Drift A fix) ──────────────────────────────── + // These tests cover the new processWindsurfTarget flow: readWindsurf → + // decodeWindsurf → dedup → onEvent. The default decoder is a stub, so + // we inject `decodeWindsurfFn` to simulate what the engineer's real + // decoder will eventually do. + + it('windsurf-dir: pipes readWindsurfJsonFilesFn output through decodeWindsurfFn → onEvent', async () => { + // Initial pass primes (empty), then files appear → emits. + const fakeFiles = [ + { path: '/ws/a.json', parsed: { user_prompt: 'first' } }, + { path: '/ws/b.json', parsed: { user_prompt: 'second' } }, + ]; + const readWindsurfJsonFilesFn = vi.fn(async () => []); + // Test stand-in for the real decoder: emit one event per file with the + // user_prompt field. Mirrors what the engineer's real decoder will do. + const decodeWindsurfFn = vi.fn( + (parsed: unknown, sourcePath: string): ChatHistoryEvent[] => { + if (typeof parsed === 'object' && parsed !== null && 'user_prompt' in parsed) { + return [ + ev( + (parsed as { user_prompt: string }).user_prompt, + `windsurf-session:${sourcePath}`, + sourcePath, + ), + ]; + } + return []; + }, + ); + + const w = createChatHistoryWatcher({ + targets: [{ path: '/ws', kind: 'windsurf-dir' }], + onEvent, + watchFn: watchFn as never, + readItemTableFn, + readWindsurfJsonFilesFn, + decodeWindsurfFn, + debounceMs: 1, + }); + w.start(); + await new Promise((r) => setTimeout(r, 20)); + + readWindsurfJsonFilesFn.mockResolvedValue(fakeFiles); + createdWatchers[0]!.emit('change', 'change', '/ws'); + await new Promise((r) => setTimeout(r, 20)); + + expect(readWindsurfJsonFilesFn).toHaveBeenCalledWith('/ws'); + expect(decodeWindsurfFn).toHaveBeenCalledTimes(2); + expect(decodeWindsurfFn).toHaveBeenCalledWith(fakeFiles[0]!.parsed, '/ws/a.json'); + expect(onEvent).toHaveBeenCalledTimes(2); + expect(onEvent.mock.calls[0]![0].prompt).toBe('first'); + expect(onEvent.mock.calls[1]![0].prompt).toBe('second'); + }); + + it('windsurf-dir: deduplicates emitted events across multiple files (signature dedup)', async () => { + // Realistic case: same conversation referenced in two JSON files (e.g. + // Windsurf renamed a session file, leaving an old copy + a new one). + // Both decode to the same prompt+sessionId+sourcePath* — dedup should + // emit only the first. + // (*signature includes sourcePath; we use the same path here to force + // a true dedup hit. The realistic engineer's decoder will derive + // `rawSessionId` from session metadata inside the JSON, not the file + // path, making dedup work even when the same conversation is split + // across files.) + const fakeFiles = [ + { path: '/ws/a.json', parsed: { p: 'hi' } }, + { path: '/ws/a.json', parsed: { p: 'hi' } }, // same path → same signature + ]; + const readWindsurfJsonFilesFn = vi.fn(async () => []); + const decodeWindsurfFn = vi.fn( + (_parsed: unknown, sourcePath: string): ChatHistoryEvent[] => [ + ev('hi', 'sess-1', sourcePath), + ], + ); + + const w = createChatHistoryWatcher({ + targets: [{ path: '/ws', kind: 'windsurf-dir' }], + onEvent, + watchFn: watchFn as never, + readItemTableFn, + readWindsurfJsonFilesFn, + decodeWindsurfFn, + debounceMs: 1, + }); + w.start(); + await new Promise((r) => setTimeout(r, 20)); + + // Initial pass primes (empty). Now the duplicate-file scenario appears. + readWindsurfJsonFilesFn.mockResolvedValue(fakeFiles); + createdWatchers[0]!.emit('change', 'change', '/ws'); + await new Promise((r) => setTimeout(r, 20)); + + // Decoder ran for both files (one event each), but signature dedup + // collapses them into a single onEvent emission. + expect(decodeWindsurfFn).toHaveBeenCalledTimes(2); + expect(onEvent).toHaveBeenCalledTimes(1); + }); + + it('windsurf-dir: forwards readWindsurfJsonFilesFn errors to onError', async () => { + const readWindsurfJsonFilesFn = vi.fn(async () => { + throw new Error('windsurf read boom'); + }); + const w = createChatHistoryWatcher({ + targets: [{ path: '/ws', kind: 'windsurf-dir' }], + onEvent, + onError, + watchFn: watchFn as never, + readItemTableFn, + readWindsurfJsonFilesFn, + debounceMs: 1, + }); + w.start(); + await new Promise((r) => setTimeout(r, 20)); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError.mock.calls[0]![0].message).toContain('windsurf read boom'); + expect(onEvent).not.toHaveBeenCalled(); + }); +}); + +// ── defaultReadWindsurfJsonFiles — real fs scan against a tmp dir ───────── +// +// Tests the production reader's directory-scan + JSON-parse logic. Uses +// real fs (no mocks) because the implementation is fs-IO-bound and the +// test cost is trivial (a tmp dir + a few small files). + +describe('defaultReadWindsurfJsonFiles', () => { + let tmpDirPath: string; + + beforeEach(() => { + tmpDirPath = mkdtempSync(join(tmpdir(), 'nexpath-windsurf-test-')); + }); + afterEach(() => { + rmSync(tmpDirPath, { recursive: true, force: true }); + }); + + it('returns [] when the directory does not exist (no crash)', async () => { + const out = await defaultReadWindsurfJsonFiles( + join(tmpDirPath, 'does-not-exist'), + ); + expect(out).toEqual([]); + }); + + it('returns [] for an empty directory', async () => { + const out = await defaultReadWindsurfJsonFiles(tmpDirPath); + expect(out).toEqual([]); + }); + + it('reads + parses every .json file in the directory', async () => { + writeFileSync(join(tmpDirPath, 'a.json'), JSON.stringify({ x: 1 })); + writeFileSync(join(tmpDirPath, 'b.json'), JSON.stringify({ y: 2 })); + const out = await defaultReadWindsurfJsonFiles(tmpDirPath); + expect(out).toHaveLength(2); + const byPath = new Map(out.map((r) => [r.path, r.parsed])); + expect(byPath.get(join(tmpDirPath, 'a.json'))).toEqual({ x: 1 }); + expect(byPath.get(join(tmpDirPath, 'b.json'))).toEqual({ y: 2 }); + }); + + it('skips files that are not .json (case-insensitive on the suffix)', async () => { + writeFileSync(join(tmpDirPath, 'data.json'), JSON.stringify({ ok: true })); + writeFileSync(join(tmpDirPath, 'README.md'), 'not json'); + writeFileSync(join(tmpDirPath, 'binary.dat'), 'not json'); + const out = await defaultReadWindsurfJsonFiles(tmpDirPath); + expect(out).toHaveLength(1); + expect(out[0]!.path.endsWith('data.json')).toBe(true); + }); + + it('silently drops malformed JSON files (preserves valid siblings)', async () => { + writeFileSync(join(tmpDirPath, 'good.json'), JSON.stringify({ valid: true })); + writeFileSync(join(tmpDirPath, 'bad.json'), '{ not valid json,'); + const out = await defaultReadWindsurfJsonFiles(tmpDirPath); + expect(out).toHaveLength(1); + expect(out[0]!.path.endsWith('good.json')).toBe(true); + expect(out[0]!.parsed).toEqual({ valid: true }); + }); + + it('honours nested directories by NOT recursing (shallow scan only)', async () => { + writeFileSync(join(tmpDirPath, 'top.json'), JSON.stringify({ depth: 0 })); + mkdirSync(join(tmpDirPath, 'nested')); + writeFileSync(join(tmpDirPath, 'nested', 'deep.json'), JSON.stringify({ depth: 1 })); + const out = await defaultReadWindsurfJsonFiles(tmpDirPath); + // Only top.json; nested/deep.json is not reached + expect(out).toHaveLength(1); + expect(out[0]!.parsed).toEqual({ depth: 0 }); + }); +}); + +// ── defaultReadItemTable — the production better-sqlite3 + WAL reader ───────── +// +// These tests exercise the REAL reader against synthetic SQLite files (built +// with better-sqlite3 in the test setup). They cover the WAL-mode branch +// from dev plan §2.5 — the whole reason we swapped sql.js → better-sqlite3 +// in M2/B4. + +describe('defaultReadItemTable', () => { + let tmpDirPath: string; + + beforeEach(() => { + tmpDirPath = mkdtempSync(join(tmpdir(), 'nexpath-readtable-test-')); + }); + afterEach(() => { + rmSync(tmpDirPath, { recursive: true, force: true }); + }); + + /** Build a real .vscdb file with an ItemTable populated from the given rows. */ + async function createTestVscdb( + fileName: string, + rows: Array<{ key: string; value: string }>, + options: { walMode?: boolean } = {}, + ): Promise { + const mod = (await import('better-sqlite3')) as unknown as { + default: new (path: string) => { + exec(sql: string): unknown; + prepare(sql: string): { run(...args: unknown[]): unknown }; + pragma(pragma: string): unknown; + close(): void; + }; + }; + const Database = mod.default; + const path = join(tmpDirPath, fileName); + const db = new Database(path); + if (options.walMode) db.pragma('journal_mode = WAL'); + db.exec('CREATE TABLE ItemTable (key TEXT NOT NULL, value TEXT NOT NULL)'); + const insert = db.prepare('INSERT INTO ItemTable (key, value) VALUES (?, ?)'); + for (const r of rows) insert.run(r.key, r.value); + db.close(); + return path; + } + + /** Build a real .vscdb file with NO ItemTable (different schema). */ + async function createEmptyVscdb(fileName: string): Promise { + const mod = (await import('better-sqlite3')) as unknown as { + default: new (path: string) => { exec(sql: string): unknown; close(): void }; + }; + const Database = mod.default; + const path = join(tmpDirPath, fileName); + const db = new Database(path); + db.exec('CREATE TABLE OtherTable (x INTEGER)'); + db.close(); + return path; + } + + it('reads happy-path ItemTable rows from a real .vscdb file', async () => { + const dbPath = await createTestVscdb('happy.vscdb', [ + { key: 'aiService.prompts', value: '["hello","world"]' }, + { key: 'composer.composerData', value: '{"selectedComposerIds":[]}' }, + { key: 'workbench.editor.recent', value: '[]' }, + ]); + const rows = await defaultReadItemTable(dbPath); + expect(rows).toHaveLength(3); + expect(rows.find((r) => r.key === 'aiService.prompts')?.value).toBe('["hello","world"]'); + expect(rows.find((r) => r.key === 'composer.composerData')?.value).toBe('{"selectedComposerIds":[]}'); + }); + + it('returns [] when the .vscdb has no ItemTable (defensive)', async () => { + const dbPath = await createEmptyVscdb('no-itemtable.vscdb'); + const rows = await defaultReadItemTable(dbPath); + expect(rows).toEqual([]); + }); + + it('reads rows from a .vscdb opened in WAL mode (the Cursor scenario)', async () => { + // Build a WAL-mode .vscdb. The journal-mode pragma + the inserts create + // the .vscdb-wal sibling. better-sqlite3 reads from both transparently + // when opening the staged copy. + const dbPath = await createTestVscdb( + 'wal.vscdb', + [ + { key: 'aiService.prompts', value: '[]' }, + { key: 'cursor/agentLayout.sidebarWidth', value: '320' }, + ], + { walMode: true }, + ); + const rows = await defaultReadItemTable(dbPath); + expect(rows.length).toBeGreaterThanOrEqual(2); + expect(rows.find((r) => r.key === 'aiService.prompts')?.value).toBe('[]'); + expect(rows.find((r) => r.key === 'cursor/agentLayout.sidebarWidth')?.value).toBe('320'); + }); + + it('does not modify the source .vscdb file (operates on a tmp staging copy)', async () => { + const dbPath = await createTestVscdb('source-untouched.vscdb', [ + { key: 'aiService.prompts', value: '["x"]' }, + ]); + const { statSync } = await import('node:fs'); + const sizeBefore = statSync(dbPath).size; + await defaultReadItemTable(dbPath); + await defaultReadItemTable(dbPath); + await defaultReadItemTable(dbPath); + const sizeAfter = statSync(dbPath).size; + expect(sizeAfter).toBe(sizeBefore); + }); + + it('returns [] when the .vscdb path does not exist (host cleaned up workspace between activate and first read)', async () => { + // Reproduces the live 2026-05-20 R3 finding: Cursor enumerated 4 + // workspaceStorage dirs at activate-time, then cleaned up two of them + // before the debounced first read fired. The watcher previously threw + // ENOENT through onError on every fire; now defaultReadItemTable treats + // a missing main file as "no rows" so it never escalates. + const missingPath = join(tmpDirPath, 'never-existed.vscdb'); + const rows = await defaultReadItemTable(missingPath); + expect(rows).toEqual([]); + }); + + it('returns [] when the .vscdb is deleted between existsSync and copyFile (race)', async () => { + // Simulates the existsSync→copyFile race window. We delete the file + // through a wrapped readItemTable that runs deletion just before the + // copy. Here we use a more straightforward approach: create the file, + // then move it aside on the same tick the read starts. The real race + // happens at the OS level when Cursor's cleanup overlaps with our read. + const dbPath = await createTestVscdb('race.vscdb', [ + { key: 'aiService.prompts', value: '[]' }, + ]); + // Read once to verify happy path. + expect(await defaultReadItemTable(dbPath)).toHaveLength(1); + // Delete and read again — second read should return [] cleanly. + rmSync(dbPath, { force: true }); + expect(await defaultReadItemTable(dbPath)).toEqual([]); + }); +}); diff --git a/src/ext-vscode/src/chat-history-watcher.ts b/src/ext-vscode/src/chat-history-watcher.ts new file mode 100644 index 00000000..8069fa93 --- /dev/null +++ b/src/ext-vscode/src/chat-history-watcher.ts @@ -0,0 +1,525 @@ +import { watch, type FSWatcher, type WatchListener } from 'node:fs'; +import type { + ChatHistoryEvent, + ChatHistoryExtractor, + ItemTableRow, + WatchTarget, +} from './chat-history-types.js'; +import { ALL_EXTRACTORS, pickExtractor } from './extractors/index.js'; +import { decodeWindsurfJsonFile } from './extractors/windsurf.js'; + +/** + * Chat-history watcher (M2 Branch 2). + * + * Watches the agent's persisted chat-history storage and emits a + * `ChatHistoryEvent` for every NEW user prompt that appears. + * + * Per-agent storage model: + * - Cursor: a single SQLite file (`state.vscdb`) under + * `User/globalStorage/`. Treated as kind `cursor-sqlite`. + * - Windsurf: a directory under `~/.codeium/windsurf/` containing JSON + * conversation files. Treated as kind `windsurf-dir`. Each fs.watch + * fire rescans the directory, parses every .json file, and passes the + * parsed value to `decodeWindsurfJsonFile` (in `extractors/windsurf.ts`). + * The decoder is currently a no-op pending live-install schema + * inspection (see its TODO); the FS plumbing is in place so the only + * missing piece is the field extraction. + * + * Pipeline (Cursor / sqlite): + * fs.watch fires -> debounce (default 250 ms) -> read file bytes + * -> parse ItemTable -> pickExtractor(observed keys) -> for each row + * the extractor owns, decode -> dedupe new events by signature -> + * emit via `onEvent`. + * + * The actual SQLite parsing is injected via `readItemTableFn` so tests + * can run without better-sqlite3. In production a better-sqlite3-backed + * reader is used — chosen over sql.js because the live Cursor `.vscdb` + * file is in **WAL mode** (the main file is ~4 KB; all writes go to the + * sibling `.vscdb-wal`). sql.js operates on a buffer and cannot read the + * WAL siblings, so it never sees the live data (dev plan §2.5). The + * better-sqlite3 reader copies main + wal + shm to a tmp staging dir, + * checkpoints the WAL into the staged main file, then reads — never + * touching the live file Cursor is actively writing to. + */ + +/** Pure function that turns a state.vscdb FILE PATH into ItemTable rows. */ +export type ReadItemTableFn = (dbPath: string) => Promise; + +/** + * Pure function that scans a Windsurf chat-data directory and returns its + * `.json` files as `{path, parsed}` pairs. Malformed or unreadable files + * are skipped silently (drop one bad file, don't blow up the whole scan); + * a missing directory returns `[]` so activate-time enumeration doesn't + * have to pre-check existence. + */ +export type ReadWindsurfJsonFilesFn = ( + dirPath: string, +) => Promise>; + +/** + * Default Windsurf JSON reader: lists `/*.json`, reads each file, + * `JSON.parse`s the contents, skips files that fail either step. Used by + * the watcher when the host is Windsurf and a codeium cascade target was + * enumerated at activate time. + */ +export const defaultReadWindsurfJsonFiles: ReadWindsurfJsonFilesFn = async ( + dirPath, +) => { + const { readdir, readFile } = await import('node:fs/promises'); + const { existsSync } = await import('node:fs'); + const { join } = await import('node:path'); + + if (!existsSync(dirPath)) return []; + let entries: string[]; + try { + entries = await readdir(dirPath); + } catch { + return []; + } + + const out: Array<{ path: string; parsed: unknown }> = []; + for (const name of entries) { + if (!name.toLowerCase().endsWith('.json')) continue; + const fullPath = join(dirPath, name); + let content: string; + try { + content = await readFile(fullPath, 'utf8'); + } catch { + continue; + } + let parsed: unknown; + try { + parsed = JSON.parse(content); + } catch { + continue; + } + out.push({ path: fullPath, parsed }); + } + return out; +}; + +/** + * Default WAL-aware reader using better-sqlite3. + * + * Strategy: + * 1. Stage: copy main + .vscdb-wal + .vscdb-shm to a tmp dir. + * 2. Open the staged copy read-only with better-sqlite3 (native module — + * handles WAL transparently because the staged copy retains the WAL + * file alongside). + * 3. Run `PRAGMA wal_checkpoint(TRUNCATE)` on the staged copy to fold + * the WAL pages into the main file (belt-and-braces; better-sqlite3 + * reads them either way). + * 4. Query `ItemTable`. + * 5. Close + clean up the staging dir. + * + * Dynamic import keeps the native module out of the eager require graph + * (cheaper extension startup; only loaded on first chat-history read). + */ +export const defaultReadItemTable: ReadItemTableFn = async (dbPath) => { + const { copyFile, mkdir, rm } = await import('node:fs/promises'); + const { existsSync } = await import('node:fs'); + const { tmpdir } = await import('node:os'); + const { basename, join } = await import('node:path'); + + // Race-safe read: a workspace storage dir enumerated at activate-time can + // be deleted by the host (e.g. Cursor cleaning up stale workspaces) before + // the first debounced read fires. Treat a missing main file as "no rows" + // rather than throwing a noisy ENOENT through onError. fs.watch on the + // missing path will continue to fire harmlessly if the file ever returns. + if (!existsSync(dbPath)) return []; + + const stagingDir = join( + tmpdir(), + `nexpath-watcher-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + ); + await mkdir(stagingDir, { recursive: true }); + const stagedMain = join(stagingDir, basename(dbPath)); + try { + await copyFile(dbPath, stagedMain); + } catch (err) { + // Lost the race between the existsSync above and the copyFile here, or + // the file became unreadable for another reason. Clean up the (empty) + // staging dir and treat as no rows. + void rm(stagingDir, { recursive: true, force: true }).catch(() => {}); + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw err; + } + for (const suffix of ['-wal', '-shm'] as const) { + const sibling = dbPath + suffix; + if (existsSync(sibling)) { + try { + await copyFile(sibling, stagedMain + suffix); + } catch { + // WAL/SHM races are common; better-sqlite3 reads main regardless. + } + } + } + + const mod = (await import('better-sqlite3')) as unknown as { + default: new (path: string, options?: { readonly?: boolean }) => { + prepare(sql: string): { + all(...params: unknown[]): Array>; + }; + pragma(pragma: string): unknown; + close(): void; + }; + }; + const Database = mod.default; + const db = new Database(stagedMain, { readonly: true }); + try { + try { + db.pragma('wal_checkpoint(TRUNCATE)'); + } catch { + // Not in WAL mode — fine, continue. + } + // Discover which of the known chat-data tables exist in this DB. + // - `ItemTable` is the standard VS Code state table — holds Ask-mode + // `aiService.prompts` (cursor-v2024-q4) and the Composer-metadata + // `composer.composerData` row (cursor-v2025-q1). + // - `cursorDiskKV` is Cursor's modern Composer / Agent chat storage — + // `composerData:` (metadata) + `bubbleId::` + // (individual user / assistant messages). Lives in `globalStorage/ + // state.vscdb` and is the default storage location for Cursor's + // Agent mode (the right-side chat panel in 3.4.20+). + // Either table may be absent on freshly-created state.vscdb files; + // treat as empty rather than throwing. + const tableNames = ( + db + .prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name IN ('ItemTable','cursorDiskKV')", + ) + .all() as Array<{ name: string }> + ).map((r) => r.name); + const out: Array<{ key: string; value: string }> = []; + if (tableNames.includes('ItemTable')) { + const rows = db + .prepare('SELECT key, value FROM ItemTable') + .all() as Array<{ key: string; value: string }>; + for (const r of rows) { + out.push({ key: String(r.key), value: String(r.value) }); + } + } + if (tableNames.includes('cursorDiskKV')) { + // cursorDiskKV keys are prefixed with `cursorDiskKV/` in the row + // stream so existing ItemTable extractors (which match keys like + // `aiService.prompts` exactly) can NEVER accidentally consume them, + // and the new composer-bubble extractor uses the prefix as a + // load-bearing fingerprint signal. The extractor strips the prefix + // before parsing the row's value. + const rows = db + .prepare('SELECT key, value FROM cursorDiskKV') + .all() as Array<{ key: string; value: string }>; + for (const r of rows) { + out.push({ + key: `cursorDiskKV/${String(r.key)}`, + value: String(r.value), + }); + } + } + return out; + } finally { + db.close(); + // Best-effort cleanup — staging dir is in /tmp, OS will clean up eventually. + void rm(stagingDir, { recursive: true, force: true }).catch(() => {}); + } +}; + +export interface ChatHistoryWatcherOptions { + /** Paths the watcher should monitor. */ + targets: WatchTarget[]; + /** Emitted for every NEW user prompt detected. */ + onEvent: (e: ChatHistoryEvent) => void; + /** Emitted on any non-fatal error (read failure, watch error, etc.). */ + onError?: (err: Error) => void; + /** + * Emitted when a Cursor target's ItemTable doesn't fingerprint to any + * known extractor — extension layer surfaces this as a "schema unknown, + * please update nexpath extension" toast. + */ + onSchemaUnknown?: (info: { + path: string; + observedSampleKeys: readonly string[]; + }) => void; + + // ── Dependency injection (tests substitute these) ───────────────────────── + /** Debounce window for coalescing fs.watch events. Default 250 ms. */ + debounceMs?: number; + /** Inject `fs.watch` (defaults to node:fs `watch`). */ + watchFn?: typeof watch; + /** Inject the ItemTable reader (defaults to better-sqlite3 WAL-aware reader). */ + readItemTableFn?: ReadItemTableFn; + /** Inject the Windsurf JSON-dir reader (defaults to fs/promises-backed scan). */ + readWindsurfJsonFilesFn?: ReadWindsurfJsonFilesFn; + /** + * Inject the Windsurf JSON-file decoder. Defaults to + * `decodeWindsurfJsonFile` from `./extractors/windsurf.js`. Exposed for + * tests; once a real decoder is filled in upstream, production callers + * normally leave this unset and pick up the new behaviour automatically. + */ + decodeWindsurfFn?: (parsed: unknown, sourcePath: string) => ChatHistoryEvent[]; + /** Inject the clock (defaults to `() => new Date()`). */ + nowFn?: () => Date; +} + +export interface ChatHistoryWatcher { + /** Start fs watchers + fire an initial read so existing rows are diffed. */ + start(): void; + /** Stop all watchers + cancel pending debouncers. */ + stop(): void; +} + +export function createChatHistoryWatcher( + opts: ChatHistoryWatcherOptions, +): ChatHistoryWatcher { + const debounceMs = opts.debounceMs ?? 250; + const watchFn = opts.watchFn ?? watch; + const readItemTableFn = opts.readItemTableFn ?? defaultReadItemTable; + const readWindsurfJsonFilesFn = + opts.readWindsurfJsonFilesFn ?? defaultReadWindsurfJsonFiles; + const decodeWindsurfFn = opts.decodeWindsurfFn ?? decodeWindsurfJsonFile; + const nowFn = opts.nowFn ?? (() => new Date()); + + const fsWatchers: FSWatcher[] = []; + const debouncers = new Map>(); + /** Deduplication key per emitted event so we never emit the same prompt twice. */ + const seenSignatures = new Set(); + /** Per-target extractor cache — first read decides; subsequent reads reuse. */ + const extractorCache = new Map(); + /** + * Targets whose initial read has completed. On the first read for a target, + * existing rows are registered in `seenSignatures` WITHOUT calling `onEvent` + * — this matches the Claude-Code-hook contract that downstream Layer C is + * built around (only NEW user prompts feed the pipeline; historical prompts + * accumulated in state.vscdb are NOT replayed every activation). Without + * this prime-only behaviour, every Cursor restart re-emitted the entire + * prompt backlog, flooding Layer C's session-state machine and producing + * advisory storms that bypass the 3-prompt warmup + 5-prompt cooldown gates. + */ + const primedTargets = new Set(); + + /** + * Paths already surfaced via `onSchemaUnknown`. The unknown-schema branch + * does NOT cache an extractor (it must keep re-checking, because a fresh + * Cursor workspace's state.vscdb starts with no chat keys and only gains + * them once the user chats). But re-notifying on every fs.watch fire floods + * the log and re-pops the info toast — acutely visible on Windsurf, whose + * workspaceStorage state.vscdb never holds chat and so stays unknown forever. + * Notify once per path; keep re-checking silently thereafter. + */ + const reportedUnknownPaths = new Set(); + + /** + * Cross-extractor dedup. Cursor 3.x mirrors Composer prompts into BOTH + * globalStorage cursorDiskKV (decoded by cursor-composer-bubble) AND + * workspaceStorage ItemTable.aiService.prompts (decoded by cursor-v2024-q4 + * as "ask-mode"). Without this map every Composer prompt is captured twice + * → Layer C's prompt_count grows at 2× rate → classifier fires at wrong + * positions. Discovered during S01 manual testing 2026-05-27. + * + * Keyed by trimmed prompt text → last-seen epoch ms. A prompt re-emitted + * within DEDUP_WINDOW_MS of its first sighting is dropped. + */ + const recentPromptTimestamps = new Map(); + const DEDUP_WINDOW_MS = 60_000; + + function signatureOf(e: ChatHistoryEvent): string { + return `${e.sourcePath}|${e.rawSessionId}|${e.prompt}`; + } + + /** Returns true if this prompt text was already emitted in the dedup window. */ + function isCrossExtractorDuplicate(promptText: string, now: number): boolean { + const key = promptText.trim(); + const last = recentPromptTimestamps.get(key); + if (last !== undefined && now - last < DEDUP_WINDOW_MS) { + return true; + } + recentPromptTimestamps.set(key, now); + // Lazy GC: drop entries older than the window so the map doesn't grow forever. + if (recentPromptTimestamps.size > 200) { + for (const [k, t] of recentPromptTimestamps) { + if (now - t >= DEDUP_WINDOW_MS) recentPromptTimestamps.delete(k); + } + } + return false; + } + + function reportError(err: unknown, path: string): void { + const e = err instanceof Error ? err : new Error(String(err)); + opts.onError?.(new Error(`[chat-history-watcher] ${path}: ${e.message}`)); + } + + async function processSqliteTarget(target: WatchTarget): Promise { + try { + const rows = await readItemTableFn(target.path); + const isInitialPass = !primedTargets.has(target.path); + primedTargets.add(target.path); + + // Per-row, run ALL extractors that own the row's key — not just the + // single "fingerprint winner". The old logic picked one extractor via + // `pickExtractor` and used it for every row, which silently dropped + // prompts when the workspace had keys from multiple Cursor eras: e.g. + // both `aiService.prompts` (cursor-v2024-q4) AND `composer.composerData` + // (cursor-v2025-q1) exist in modern Cursor workspaces. The fingerprint + // count tied at 1 each, registry order picked cursor-v2025-q1, which + // doesn't own aiService.prompts → all Ask-mode prompts were silently + // discarded. Discovered during M2 manual testing Round 2 after the WAL + // fix (which got fs.watch firing correctly) revealed that events + // STILL weren't emitting. + // + // `pickExtractor` is still used to drive the schema-unknown toast + // (matching ANY extractor = schema known, matching NONE = unknown), + // but the per-row decoding now uses every extractor whose `ownsKey` + // returns true for that row. + const extractorsToTry = target.extractor + ? [target.extractor] + : (extractorCache.get(target.path) ?? ALL_EXTRACTORS); + + if (!target.extractor && !extractorCache.has(target.path)) { + const fp = pickExtractor(rows.map((r) => r.key)); + if (fp.kind === 'known') { + // Cache ALL_EXTRACTORS once we've confirmed at least one matches — + // subsequent reads of this target skip the unknown-schema check. + extractorCache.set(target.path, ALL_EXTRACTORS); + } else { + if (!reportedUnknownPaths.has(target.path)) { + reportedUnknownPaths.add(target.path); + opts.onSchemaUnknown?.({ + path: target.path, + observedSampleKeys: fp.observedSampleKeys, + }); + } + return; + } + } + + for (const row of rows) { + for (const extractor of extractorsToTry) { + if (!extractor.ownsKey(row.key)) continue; + const decoded = extractor.decodeRow(row, target.path); + for (const ev of decoded) { + const sig = signatureOf(ev); + if (seenSignatures.has(sig)) continue; + seenSignatures.add(sig); + // Initial pass: prime dedup only. Subsequent fs.watch fires emit + // only truly-new prompts. See primedTargets docstring. + if (isInitialPass) { + // Cross-extractor dedup is meant to catch Composer→Ask MIRROR + // emissions seconds apart, NOT historical prompts from prior + // sessions that happen to share text with a fresh user prompt. + // globalStorage state.vscdb is workspace-agnostic so initial + // pass sees bubbles from ALL prior workspaces. Priming with + // timestamp 0 ensures `now - 0 >> DEDUP_WINDOW_MS`, so any + // genuinely new emission of the same text passes through. + recentPromptTimestamps.set(ev.prompt.trim(), 0); + continue; + } + const now = nowFn(); + if (isCrossExtractorDuplicate(ev.prompt, now.getTime())) continue; + opts.onEvent({ ...ev, capturedAt: now }); + } + } + } + } catch (err) { + reportError(err, target.path); + } + } + + async function processWindsurfTarget(target: WatchTarget): Promise { + try { + const files = await readWindsurfJsonFilesFn(target.path); + const isInitialPass = !primedTargets.has(target.path); + primedTargets.add(target.path); + for (const { path: filePath, parsed } of files) { + const decoded = decodeWindsurfFn(parsed, filePath); + for (const ev of decoded) { + const sig = signatureOf(ev); + if (seenSignatures.has(sig)) continue; + seenSignatures.add(sig); + if (isInitialPass) { + // See sqlite-target's comment on why timestamp=0 here. + recentPromptTimestamps.set(ev.prompt.trim(), 0); + continue; + } + const now = nowFn(); + if (isCrossExtractorDuplicate(ev.prompt, now.getTime())) continue; + opts.onEvent({ ...ev, capturedAt: now }); + } + } + } catch (err) { + reportError(err, target.path); + } + } + + function schedule(target: WatchTarget): void { + const existing = debouncers.get(target.path); + if (existing) clearTimeout(existing); + const t = setTimeout(() => { + debouncers.delete(target.path); + if (target.kind === 'cursor-sqlite') { + void processSqliteTarget(target); + } else { + void processWindsurfTarget(target); + } + }, debounceMs); + debouncers.set(target.path, t); + } + + return { + start(): void { + for (const target of opts.targets) { + try { + const listener: WatchListener = () => schedule(target); + // Watch the primary path (the SQLite main file for cursor-sqlite, + // or the codeium dir for windsurf-dir). + const w = watchFn(target.path, listener); + w.on('error', (err: Error) => reportError(err, target.path)); + fsWatchers.push(w); + + // ── WAL-mode liveness fix ──────────────────────────────────────── + // For cursor-sqlite targets, Cursor uses SQLite WAL mode — all + // writes go to `-wal`, NOT the main file. fs.watch on the + // main file alone would never fire for new prompts (the main file + // only changes at checkpoint time, which can be minutes or hours + // later). Also watch the WAL sibling so we re-read whenever Cursor + // writes a new prompt. The read path (defaultReadItemTable) already + // copies main + wal + shm to a tmp staging dir and checkpoints + // before reading, so it always sees the latest data regardless of + // which file triggered the watch. Discovered during M2 manual + // testing Round 2 when live Cursor prompts didn't reach the store. + if (target.kind === 'cursor-sqlite') { + for (const suffix of ['-wal', '-shm'] as const) { + const siblingPath = target.path + suffix; + try { + const sw = watchFn(siblingPath, listener); + sw.on('error', (err: Error) => reportError(err, siblingPath)); + fsWatchers.push(sw); + } catch { + // Sibling doesn't exist yet (WAL hasn't been initialised) — + // that's fine; Cursor creates it on first chat write, and + // the main-file watch will fire when SQLite eventually + // checkpoints, prompting a re-read that sees the new rows. + } + } + } + + // Initial pass so any existing rows are diffed immediately + schedule(target); + } catch (err) { + reportError(err, target.path); + } + } + }, + stop(): void { + for (const t of debouncers.values()) clearTimeout(t); + debouncers.clear(); + for (const w of fsWatchers) { + try { + w.close(); + } catch { + // ignore + } + } + fsWatchers.length = 0; + }, + }; +} diff --git a/src/ext-vscode/src/chat-input-injector.test.ts b/src/ext-vscode/src/chat-input-injector.test.ts new file mode 100644 index 00000000..b3ac8aa7 --- /dev/null +++ b/src/ext-vscode/src/chat-input-injector.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect, vi } from 'vitest'; + +vi.mock('vscode', () => ({ + commands: { + executeCommand: vi.fn(), + getCommands: vi.fn(), + }, +})); + +import { + chatInputInject, + CANDIDATE_COMMANDS, +} from './chat-input-injector.js'; + +describe('chatInputInject', () => { + it('returns false immediately for vscode-generic (no AI chat input)', async () => { + const exec = vi.fn(); + const list = vi.fn(); + const result = await chatInputInject('hello', { + host: 'vscode-generic', + executeCommand: exec, + getCommands: list, + }); + expect(result).toBe(false); + expect(exec).not.toHaveBeenCalled(); + expect(list).not.toHaveBeenCalled(); + }); + + it('tries Cursor candidates when host = cursor', async () => { + const exec = vi.fn().mockResolvedValue(undefined); + const list = vi + .fn() + .mockResolvedValue([CANDIDATE_COMMANDS.cursor[0]]); // first candidate available + const result = await chatInputInject('hello', { + host: 'cursor', + executeCommand: exec, + getCommands: list, + }); + expect(result).toBe(true); + expect(list).toHaveBeenCalledWith(true); + expect(exec).toHaveBeenCalledWith(CANDIDATE_COMMANDS.cursor[0], 'hello'); + }); + + it('tries each cursor candidate in order until one succeeds', async () => { + const exec = vi.fn(); + // First two candidates throw, third succeeds + exec + .mockRejectedValueOnce(new Error('first fail')) + .mockRejectedValueOnce(new Error('second fail')) + .mockResolvedValueOnce(undefined); + const list = vi.fn().mockResolvedValue([...CANDIDATE_COMMANDS.cursor]); + const result = await chatInputInject('hi', { + host: 'cursor', + executeCommand: exec, + getCommands: list, + }); + expect(result).toBe(true); + expect(exec).toHaveBeenCalledTimes(3); + expect(exec).toHaveBeenLastCalledWith(CANDIDATE_COMMANDS.cursor[2], 'hi'); + }); + + it('skips candidates that are not in vscode.commands list', async () => { + const exec = vi.fn().mockResolvedValue(undefined); + // Only the third candidate is available + const list = vi.fn().mockResolvedValue([CANDIDATE_COMMANDS.cursor[2]]); + const result = await chatInputInject('hi', { + host: 'cursor', + executeCommand: exec, + getCommands: list, + }); + expect(result).toBe(true); + expect(exec).toHaveBeenCalledTimes(1); + expect(exec).toHaveBeenCalledWith(CANDIDATE_COMMANDS.cursor[2], 'hi'); + }); + + it('returns false when no cursor candidate is available', async () => { + const exec = vi.fn(); + const list = vi.fn().mockResolvedValue(['unrelated.command']); + const result = await chatInputInject('hi', { + host: 'cursor', + executeCommand: exec, + getCommands: list, + }); + expect(result).toBe(false); + expect(exec).not.toHaveBeenCalled(); + }); + + it('returns false when all available cursor candidates throw', async () => { + const exec = vi.fn().mockRejectedValue(new Error('always fails')); + const list = vi.fn().mockResolvedValue([...CANDIDATE_COMMANDS.cursor]); + const result = await chatInputInject('hi', { + host: 'cursor', + executeCommand: exec, + getCommands: list, + }); + expect(result).toBe(false); + expect(exec).toHaveBeenCalledTimes(CANDIDATE_COMMANDS.cursor.length); + }); + + it('returns false when getCommands itself throws', async () => { + const exec = vi.fn(); + const list = vi.fn().mockRejectedValue(new Error('command list broken')); + const result = await chatInputInject('hi', { + host: 'cursor', + executeCommand: exec, + getCommands: list, + }); + expect(result).toBe(false); + expect(exec).not.toHaveBeenCalled(); + }); + + it('tries Windsurf candidates when host = windsurf', async () => { + const exec = vi.fn().mockResolvedValue(undefined); + const list = vi.fn().mockResolvedValue([CANDIDATE_COMMANDS.windsurf[0]]); + const result = await chatInputInject('hi', { + host: 'windsurf', + executeCommand: exec, + getCommands: list, + }); + expect(result).toBe(true); + expect(exec).toHaveBeenCalledWith(CANDIDATE_COMMANDS.windsurf[0], 'hi'); + }); +}); + +describe('CANDIDATE_COMMANDS', () => { + it('exposes both cursor and windsurf candidate arrays', () => { + expect(Array.isArray(CANDIDATE_COMMANDS.cursor)).toBe(true); + expect(Array.isArray(CANDIDATE_COMMANDS.windsurf)).toBe(true); + expect(CANDIDATE_COMMANDS.cursor.length).toBeGreaterThan(0); + expect(CANDIDATE_COMMANDS.windsurf.length).toBeGreaterThan(0); + }); +}); diff --git a/src/ext-vscode/src/chat-input-injector.ts b/src/ext-vscode/src/chat-input-injector.ts new file mode 100644 index 00000000..181a8625 --- /dev/null +++ b/src/ext-vscode/src/chat-input-injector.ts @@ -0,0 +1,103 @@ +import * as vscode from 'vscode'; +import type { Host } from './host-detector.js'; + +/** + * Chat-input injector (fills the B4 contract from + * `project_b4_prompt_injection_contract` memory). + * + * Per-host candidate `vscode.commands.*` IDs that MIGHT write text to the + * AI chat input. These are heuristic guesses based on community + * documentation / reverse-engineering — none are official APIs. The + * function tries each in order; the first one that doesn't throw + accepts + * the text argument is treated as success. + * + * **None of these IDs are verified yet against a real running Cursor / + * Windsurf.** Branch 5 (smoke-test) is where the engineer hand-verifies + * the actual command IDs and prunes / re-orders this list based on + * observed reality. The `dryRun` injection from B5 should also try + * `vscode.commands.getCommands(true)` against a live Cursor and log the + * filtered candidate set. + * + * Until B5 verifies, this function's net effect on Cursor 3.4.20 will + * almost certainly be "all candidates fail, return false, clipboard + * fallback takes over". That's safe — `handleOptionSelection` falls + * through cleanly to the clipboard path documented in dev plan §2.4. + */ + +const CURSOR_CANDIDATE_COMMANDS: ReadonlyArray = [ + // These are educated guesses. Verify against a live Cursor by running + // `vscode.commands.getCommands(true)` from a development host and filtering. + 'cursor.aichat.insertWithSelection', + 'composer.newChat', + 'cursor.composer.focus', + 'aichat.insertSelection', + 'workbench.action.chat.open', +]; + +const WINDSURF_CANDIDATE_COMMANDS: ReadonlyArray = [ + // Same caveat — verify in B5 against a real Windsurf. + 'windsurf.cascade.newConversation', + 'cascade.openChat', + 'codeium.openCascade', +]; + +/** Re-export for tests that want to assert against the list. */ +export const CANDIDATE_COMMANDS = { + cursor: CURSOR_CANDIDATE_COMMANDS, + windsurf: WINDSURF_CANDIDATE_COMMANDS, +}; + +export interface ChatInputInjectorDeps { + /** Inject the host-resolver. Tests pass a fixed host. */ + host?: Host; + /** Inject the command executor. Tests provide a mock. */ + executeCommand?: (id: string, ...args: unknown[]) => Thenable; + /** Inject the command lister. Tests provide a mock. */ + getCommands?: (filterInternal?: boolean) => Thenable; +} + +/** + * Try to inject `text` into the host's AI chat input. Returns `true` if a + * candidate command executed without throwing. Returns `false` (so + * `handleOptionSelection` falls back to clipboard) if no candidate + * succeeded — including when the host is plain VS Code (no AI chat input + * to inject into). + */ +export async function chatInputInject( + text: string, + deps: ChatInputInjectorDeps = {}, +): Promise { + const host = deps.host ?? 'vscode-generic'; + if (host === 'vscode-generic') return false; + + const exec = + deps.executeCommand ?? + ((id: string, ...args: unknown[]) => + vscode.commands.executeCommand(id, ...args)); + const list = + deps.getCommands ?? + ((filter?: boolean) => vscode.commands.getCommands(filter)); + + const candidates = + host === 'cursor' ? CURSOR_CANDIDATE_COMMANDS : WINDSURF_CANDIDATE_COMMANDS; + + // Only try commands that actually exist on the current host — avoids + // wasted "no such command" rejections in the developer console. + let available: Set; + try { + available = new Set(await list(true)); + } catch { + return false; + } + + for (const id of candidates) { + if (!available.has(id)) continue; + try { + await exec(id, text); + return true; + } catch { + // try next + } + } + return false; +} diff --git a/src/ext-vscode/src/chat-pipeline.test.ts b/src/ext-vscode/src/chat-pipeline.test.ts new file mode 100644 index 00000000..f926934e --- /dev/null +++ b/src/ext-vscode/src/chat-pipeline.test.ts @@ -0,0 +1,173 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createChatEventHandler } from './chat-pipeline.js'; +import type { ChatHistoryEvent } from './chat-history-types.js'; +import type { DecisionSessionPayload } from './ipc.js'; + +const makeEvent = (overrides: Partial = {}): ChatHistoryEvent => ({ + prompt: 'hello world', + rawSessionId: 'tab:abc-123', + capturedAt: new Date(0), + sourcePath: '/fake/state.vscdb', + extractorId: 'cursor-v2025-q2', + ...overrides, +}); + +const fakePayload: DecisionSessionPayload = { + advisory: 'Consider clarifying scope.', + options: [{ id: 'a', label: 'Refine the request' }], +}; + +describe('createChatEventHandler', () => { + let spawnAuto: ReturnType; + let spawnStop: ReturnType; + let publishPayload: ReturnType; + let errorLog: ReturnType; + + beforeEach(() => { + spawnAuto = vi.fn().mockResolvedValue(undefined); + spawnStop = vi.fn().mockResolvedValue(null); + publishPayload = vi.fn(); + errorLog = vi.fn(); + }); + + it('happy path: spawnAuto → spawnStop → publishPayload (when payload non-null)', async () => { + spawnStop.mockResolvedValueOnce(fakePayload); + const handler = createChatEventHandler({ + spawnAuto, + spawnStop, + publishPayload, + logger: { error: errorLog }, + }); + const event = makeEvent(); + await handler(event); + expect(spawnAuto).toHaveBeenCalledWith('hello world', 'tab:abc-123', event); + expect(spawnStop).toHaveBeenCalledWith('tab:abc-123', event); + expect(publishPayload).toHaveBeenCalledWith(fakePayload); + expect(errorLog).not.toHaveBeenCalled(); + }); + + it('skips publishPayload when spawnStop returns null', async () => { + spawnStop.mockResolvedValueOnce(null); + const handler = createChatEventHandler({ + spawnAuto, + spawnStop, + publishPayload, + logger: { error: errorLog }, + }); + await handler(makeEvent()); + expect(spawnAuto).toHaveBeenCalledOnce(); + expect(spawnStop).toHaveBeenCalledOnce(); + expect(publishPayload).not.toHaveBeenCalled(); + }); + + it('uses composeSessionId to derive the session id (workspace-prefixed)', async () => { + spawnStop.mockResolvedValueOnce(fakePayload); + const compose = vi.fn((e: ChatHistoryEvent) => `ws-X|${e.rawSessionId}`); + const handler = createChatEventHandler({ + spawnAuto, + spawnStop, + publishPayload, + composeSessionId: compose, + logger: { error: errorLog }, + }); + const event = makeEvent(); + await handler(event); + expect(compose).toHaveBeenCalledOnce(); + expect(spawnAuto).toHaveBeenCalledWith( + 'hello world', + 'ws-X|tab:abc-123', + event, + ); + expect(spawnStop).toHaveBeenCalledWith('ws-X|tab:abc-123', event); + }); + + it('forwards the originating ChatHistoryEvent so callers can derive per-event cwd', async () => { + // Multi-workspace R4.3 regression guard. If two extension instances + // ever watch the same db, each must be able to attribute the prompt + // to the source workspace via event.sourcePath — not to its own cwd. + spawnStop.mockResolvedValueOnce(fakePayload); + const handler = createChatEventHandler({ + spawnAuto, + spawnStop, + publishPayload, + logger: { error: errorLog }, + }); + const event = makeEvent({ + sourcePath: '/home/u/.config/Cursor/User/workspaceStorage/abc/state.vscdb', + }); + await handler(event); + const autoCallEvent = spawnAuto.mock.calls[0][2] as ChatHistoryEvent; + const stopCallEvent = spawnStop.mock.calls[0][1] as ChatHistoryEvent; + expect(autoCallEvent.sourcePath).toBe(event.sourcePath); + expect(stopCallEvent.sourcePath).toBe(event.sourcePath); + }); + + it('logs and returns early when spawnAuto rejects (no stop, no publish)', async () => { + spawnAuto.mockRejectedValueOnce(new Error('auto blew up')); + const handler = createChatEventHandler({ + spawnAuto, + spawnStop, + publishPayload, + logger: { error: errorLog }, + }); + await handler(makeEvent()); + expect(spawnAuto).toHaveBeenCalledOnce(); + expect(spawnStop).not.toHaveBeenCalled(); + expect(publishPayload).not.toHaveBeenCalled(); + expect(errorLog).toHaveBeenCalledWith( + '[nexpath] spawnAuto failed:', + expect.any(Error), + ); + }); + + it('logs and returns early when spawnStop rejects (no publish)', async () => { + spawnStop.mockRejectedValueOnce(new Error('stop blew up')); + const handler = createChatEventHandler({ + spawnAuto, + spawnStop, + publishPayload, + logger: { error: errorLog }, + }); + await handler(makeEvent()); + expect(spawnAuto).toHaveBeenCalledOnce(); + expect(spawnStop).toHaveBeenCalledOnce(); + expect(publishPayload).not.toHaveBeenCalled(); + expect(errorLog).toHaveBeenCalledWith( + '[nexpath] spawnStop failed:', + expect.any(Error), + ); + }); + + it('logs and swallows when publishPayload throws (does not propagate to watcher)', async () => { + spawnStop.mockResolvedValueOnce(fakePayload); + publishPayload.mockImplementationOnce(() => { + throw new Error('publish blew up'); + }); + const handler = createChatEventHandler({ + spawnAuto, + spawnStop, + publishPayload, + logger: { error: errorLog }, + }); + await expect(handler(makeEvent())).resolves.toBeUndefined(); + expect(errorLog).toHaveBeenCalledWith( + '[nexpath] publishPayload failed:', + expect.any(Error), + ); + }); + + it('handler always resolves — never propagates exceptions to the watcher', async () => { + spawnAuto.mockRejectedValueOnce(new Error('a')); + spawnStop.mockRejectedValueOnce(new Error('b')); + publishPayload.mockImplementationOnce(() => { + throw new Error('c'); + }); + const handler = createChatEventHandler({ + spawnAuto, + spawnStop, + publishPayload, + logger: { error: errorLog }, + }); + await expect(handler(makeEvent())).resolves.toBeUndefined(); + }); +}); diff --git a/src/ext-vscode/src/chat-pipeline.ts b/src/ext-vscode/src/chat-pipeline.ts new file mode 100644 index 00000000..402a040d --- /dev/null +++ b/src/ext-vscode/src/chat-pipeline.ts @@ -0,0 +1,107 @@ +import type { ChatHistoryEvent } from './chat-history-types.js'; +import type { DecisionSessionPayload } from './ipc.js'; + +/** + * Chat pipeline orchestrator (M13 of M2 Branch 5). + * + * Glues the chat-history watcher to the existing nexpath pipeline (`auto` + * + `stop`) and to the view provider. For each user prompt the watcher + * captures, this handler: + * + * 1. Calls `nexpath auto ` via ipc — forwards the + * prompt to the existing capture/classify/advisory stages + * (Layer C, untouched). + * 2. Immediately calls `nexpath stop ` via ipc — reads the + * pending advisory and produces a `DecisionSessionPayload | null`. + * 3. If the payload is non-null, publishes it to the view provider, + * which auto-reveals the webview with the advisory + options. + * + * **Timing simplification for B5 smoke test:** `auto` and `stop` are + * called back-to-back. In production we'd want `stop` to fire after the + * agent's response completes (so the advisory has the latest context), + * but our chat-history watcher only emits user-prompt events — we don't + * yet detect "agent response done". This is acceptable for B5 because: + * - The legacy Claude Code hook flow also fires `auto` then `stop` + * and works fine. + * - Layer C's classifier doesn't require the assistant response to + * be present — it advises based on the user prompt alone. + * - A future M5 hardening pass can add "response done" detection via + * state.vscdb assistant-message extractors. + * + * **Resilience:** the handler catches everything. A failed spawn / + * malformed payload / view-provider error never propagates to the + * watcher (the watcher uses `void this.handleMessage(raw)` patterns + * where unhandled rejections would crash the extension host). + */ + +export interface ChatPipelineDeps { + /** + * Inject `ipc.spawnAuto`. Tests pass a mock. + * + * The third argument carries the originating `ChatHistoryEvent` so the + * caller can derive a per-event `cwd` from `event.sourcePath` — necessary + * for multi-workspace correctness, since one extension instance may watch + * `state.vscdb` files belonging to other workspaces and must attribute + * each prompt to its true project root, not to its own `workspaceCwd`. + */ + spawnAuto: ( + prompt: string, + sessionId: string, + event: ChatHistoryEvent, + ) => Promise; + /** Inject `ipc.spawnStop`. Tests pass a mock. */ + spawnStop: ( + sessionId: string, + event: ChatHistoryEvent, + ) => Promise; + /** Inject the view-provider's `publishPayload`. Tests pass a mock. */ + publishPayload: (payload: DecisionSessionPayload) => void; + /** + * Optional session-id composer. Production passes a function that prefixes + * the host workspace id; tests omit this and just use `event.rawSessionId`. + */ + composeSessionId?: (event: ChatHistoryEvent) => string; + /** Optional logger override (tests). */ + logger?: { error: (msg: string, err: unknown) => void }; +} + +const defaultLogger = { + error: (msg: string, err: unknown) => console.error(msg, err), +}; + +/** + * Build the handler the watcher calls for every captured prompt. + * + * The returned function takes a `ChatHistoryEvent` and runs the auto → + * stop → publish pipeline. It never throws. + */ +export function createChatEventHandler( + deps: ChatPipelineDeps, +): (event: ChatHistoryEvent) => Promise { + const composeSessionId = + deps.composeSessionId ?? ((e: ChatHistoryEvent) => e.rawSessionId); + const logger = deps.logger ?? defaultLogger; + + return async (event: ChatHistoryEvent): Promise => { + const sessionId = composeSessionId(event); + try { + await deps.spawnAuto(event.prompt, sessionId, event); + } catch (err) { + logger.error('[nexpath] spawnAuto failed:', err); + return; + } + let payload: DecisionSessionPayload | null; + try { + payload = await deps.spawnStop(sessionId, event); + } catch (err) { + logger.error('[nexpath] spawnStop failed:', err); + return; + } + if (payload === null) return; + try { + deps.publishPayload(payload); + } catch (err) { + logger.error('[nexpath] publishPayload failed:', err); + } + }; +} diff --git a/src/ext-vscode/src/cursor-state-dump-helpers.test.ts b/src/ext-vscode/src/cursor-state-dump-helpers.test.ts new file mode 100644 index 00000000..d2f1357e --- /dev/null +++ b/src/ext-vscode/src/cursor-state-dump-helpers.test.ts @@ -0,0 +1,253 @@ +import { describe, it, expect } from 'vitest'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + KEEP_ITEMTABLE_PREFIXES, + shouldKeepItemTable, + redactValue, + cursorConfigRoot, + discoverAllStateVscdb, + parseArgs, +} from './cursor-state-dump-helpers.js'; + +describe('shouldKeepItemTable', () => { + it('keeps every prefix in the default allowlist', () => { + for (const p of KEEP_ITEMTABLE_PREFIXES) { + expect(shouldKeepItemTable(`${p}some-suffix`)).toBe(true); + } + }); + + it('drops keys that do not match any prefix', () => { + expect(shouldKeepItemTable('workbench.editor.recent')).toBe(false); + expect(shouldKeepItemTable('terminal.integrated.layoutInfo')).toBe(false); + expect(shouldKeepItemTable('debug.selectedroot')).toBe(false); + expect(shouldKeepItemTable('')).toBe(false); + }); + + it('respects a custom prefix list when supplied', () => { + expect(shouldKeepItemTable('foo.bar', ['foo.'])).toBe(true); + expect(shouldKeepItemTable('foo.bar', ['baz.'])).toBe(false); + }); + + it('matches by prefix, not exact string', () => { + expect(shouldKeepItemTable('aiService.prompts')).toBe(true); + expect(shouldKeepItemTable('aiService.generations.extra')).toBe(true); + }); +}); + +describe('redactValue', () => { + it('keeps strings of length <= 8 unchanged', () => { + const v = JSON.stringify({ role: 'user', type: 'assistant', short: 'hi' }); + const out = redactValue(v); + // 'user' (4), 'assistant' (9 — gets redacted!), 'hi' (2) — boundary matters + const parsed = JSON.parse(out); + expect(parsed.role).toBe('user'); + expect(parsed.short).toBe('hi'); + // 'assistant' is 9 chars → over threshold → redacted + expect(parsed.type).toBe('*********'); + }); + + it('redacts strings of length > 8 inside JSON', () => { + const v = JSON.stringify({ prompt: 'sensitive-secret-text' }); + const out = redactValue(v); + const parsed = JSON.parse(out); + expect(parsed.prompt).toBe('*'.repeat('sensitive-secret-text'.length)); + }); + + it('recurses into nested JSON objects and arrays', () => { + const v = JSON.stringify({ + conv: [ + { role: 'user', text: 'a long sensitive prompt' }, + { role: 'assistant', text: 'a long sensitive reply' }, + ], + }); + const parsed = JSON.parse(redactValue(v)); + expect(parsed.conv[0].role).toBe('user'); + expect(parsed.conv[0].text).toBe('*'.repeat('a long sensitive prompt'.length)); + expect(parsed.conv[1].text).toBe('*'.repeat('a long sensitive reply'.length)); + }); + + it('preserves non-string values (numbers, booleans, null) in JSON', () => { + const v = JSON.stringify({ count: 42, ok: true, missing: null, x: false }); + const parsed = JSON.parse(redactValue(v)); + expect(parsed.count).toBe(42); + expect(parsed.ok).toBe(true); + expect(parsed.missing).toBeNull(); + expect(parsed.x).toBe(false); + }); + + it('bulk-redacts the entire string when the value is not JSON', () => { + const v = 'this-is-not-json-and-should-vanish'; + expect(redactValue(v)).toBe('*'.repeat(v.length)); + }); + + it('handles a bare JSON string (still JSON, but the root is a long string)', () => { + const v = JSON.stringify('a fairly long string here'); + const out = redactValue(v); + // Result is also JSON-encoded — parse to check + const parsed = JSON.parse(out); + expect(parsed).toBe('*'.repeat('a fairly long string here'.length)); + }); + + it('redacts at exactly the 9-char threshold (boundary)', () => { + // 8 chars → kept + expect(JSON.parse(redactValue(JSON.stringify({ k: '12345678' }))).k).toBe('12345678'); + // 9 chars → redacted + expect(JSON.parse(redactValue(JSON.stringify({ k: '123456789' }))).k).toBe('*'.repeat(9)); + }); +}); + +describe('cursorConfigRoot', () => { + it('returns the linux path under ~/.config/Cursor', () => { + expect( + cursorConfigRoot({ platform: 'linux', home: '/home/u' }), + ).toBe('/home/u/.config/Cursor'); + }); + + it('returns the macOS path under ~/Library/Application Support/Cursor', () => { + expect( + cursorConfigRoot({ platform: 'darwin', home: '/Users/u' }), + ).toBe('/Users/u/Library/Application Support/Cursor'); + }); + + it('returns the Windows path under %APPDATA%/Cursor when APPDATA is provided', () => { + expect( + cursorConfigRoot({ + platform: 'win32', + home: 'C:\\Users\\u', + appdata: 'C:\\Users\\u\\AppData\\Roaming', + }), + ).toBe('C:\\Users\\u\\AppData\\Roaming/Cursor'); + }); + + it('falls back to /AppData/Roaming/Cursor on Windows when APPDATA missing', () => { + expect( + cursorConfigRoot({ platform: 'win32', home: 'C:/U' }), + ).toContain('Cursor'); + }); + + it('treats unknown platforms as linux-style', () => { + expect( + cursorConfigRoot({ platform: 'freebsd' as NodeJS.Platform, home: '/home/u' }), + ).toBe('/home/u/.config/Cursor'); + }); +}); + +describe('discoverAllStateVscdb', () => { + it('returns empty when no Cursor tree exists', () => { + const tmp = mkdtempSync(join(tmpdir(), 'cursor-disc-empty-')); + const result = discoverAllStateVscdb(tmp); + expect(result).toEqual([]); + rmSync(tmp, { recursive: true, force: true }); + }); + + it('finds the global state.vscdb when only it exists', () => { + const tmp = mkdtempSync(join(tmpdir(), 'cursor-disc-glob-')); + mkdirSync(join(tmp, 'User', 'globalStorage'), { recursive: true }); + writeFileSync(join(tmp, 'User', 'globalStorage', 'state.vscdb'), ''); + const result = discoverAllStateVscdb(tmp); + expect(result).toHaveLength(1); + expect(result[0]!.label).toBe('global'); + expect(result[0]!.path.endsWith('state.vscdb')).toBe(true); + rmSync(tmp, { recursive: true, force: true }); + }); + + it('finds workspace state.vscdb files alongside the global one', () => { + const tmp = mkdtempSync(join(tmpdir(), 'cursor-disc-ws-')); + mkdirSync(join(tmp, 'User', 'globalStorage'), { recursive: true }); + writeFileSync(join(tmp, 'User', 'globalStorage', 'state.vscdb'), ''); + for (const ws of ['1234567890', 'abc-def', 'empty-window']) { + mkdirSync(join(tmp, 'User', 'workspaceStorage', ws), { recursive: true }); + writeFileSync(join(tmp, 'User', 'workspaceStorage', ws, 'state.vscdb'), ''); + } + const result = discoverAllStateVscdb(tmp); + expect(result).toHaveLength(4); + const labels = result.map((r) => r.label).sort(); + expect(labels).toEqual([ + 'global', + 'workspace-1234567890', + 'workspace-abc-def', + 'workspace-empty-window', + ]); + rmSync(tmp, { recursive: true, force: true }); + }); + + it('skips workspace dirs that do not actually contain state.vscdb', () => { + const tmp = mkdtempSync(join(tmpdir(), 'cursor-disc-skip-')); + mkdirSync(join(tmp, 'User', 'workspaceStorage', 'has-db'), { recursive: true }); + writeFileSync(join(tmp, 'User', 'workspaceStorage', 'has-db', 'state.vscdb'), ''); + mkdirSync(join(tmp, 'User', 'workspaceStorage', 'no-db'), { recursive: true }); + const result = discoverAllStateVscdb(tmp); + expect(result).toHaveLength(1); + expect(result[0]!.label).toBe('workspace-has-db'); + rmSync(tmp, { recursive: true, force: true }); + }); + + it('uses injected fs helpers when provided', () => { + const fakeFs = { + existsSync: (p: string) => p.includes('workspaceStorage') || p.includes('state.vscdb'), + readdirSync: () => ['ws1'], + }; + const result = discoverAllStateVscdb('/fake/root', fakeFs); + expect(result.map((r) => r.label)).toContain('workspace-ws1'); + }); +}); + +describe('parseArgs', () => { + it('accepts --name and produces ok=true', () => { + const r = parseArgs(['--name', 'mine']); + expect(r.ok).toBe(true); + if (r.ok) { + expect(r.args.name).toBe('mine'); + expect(r.args.redact).toBe(false); + expect(r.args.src).toBeUndefined(); + } + }); + + it('parses --src and --redact', () => { + const r = parseArgs(['--name', 'x', '--src', '/p', '--redact']); + expect(r.ok).toBe(true); + if (r.ok) { + expect(r.args.src).toBe('/p'); + expect(r.args.redact).toBe(true); + } + }); + + it('rejects with an error when --name is missing', () => { + const r = parseArgs(['--redact']); + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.error).toContain('--name'); + } + }); + + it('rejects with an error when --name has no value', () => { + const r = parseArgs(['--name']); + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.error).toContain('--name requires a value'); + } + }); + + it('rejects with an error when --src has no value', () => { + const r = parseArgs(['--name', 'a', '--src']); + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.error).toContain('--src requires a value'); + } + }); + + it('signals help when --help or -h is passed', () => { + expect(parseArgs(['--help'])).toMatchObject({ ok: false, help: true }); + expect(parseArgs(['-h'])).toMatchObject({ ok: false, help: true }); + }); + + it('rejects unknown arguments', () => { + const r = parseArgs(['--name', 'x', '--bogus']); + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.error).toContain('--bogus'); + } + }); +}); diff --git a/src/ext-vscode/src/cursor-state-dump-helpers.ts b/src/ext-vscode/src/cursor-state-dump-helpers.ts new file mode 100644 index 00000000..31db3aca --- /dev/null +++ b/src/ext-vscode/src/cursor-state-dump-helpers.ts @@ -0,0 +1,183 @@ +/** + * Pure / near-pure helpers used by `scripts/dump-cursor-state.ts`. + * + * These live in `src/` (and not next to the script) for two reasons: + * 1. They're typechecked by the sub-package's main `tsconfig.json` + * (rootDir = `./src`). + * 2. They're picked up by vitest for co-located unit testing via the + * `cursor-state-dump-helpers.test.ts` sibling. + * + * The script in `scripts/dump-cursor-state.ts` imports these helpers and + * adds the I/O orchestration on top. + * + * None of these helpers are referenced by `src/extension.ts`, so the + * production esbuild bundle does not include them. + */ + +import { existsSync, readdirSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +/** ItemTable key prefixes the dump should keep — everything else is dropped. */ +export const KEEP_ITEMTABLE_PREFIXES = [ + 'aiService.', + 'composer.', + 'composerData.', + 'cursorAIService.', + 'cursorAIChatService.', + 'cascade.', + 'workbench.panel.composerChatViewPane.', + 'workbench.panel.aichat.', + 'workbench.backgroundComposer.', +] as const; + +/** + * Return true iff the ItemTable row key should be retained in the dump. + * Filters out unrelated VS Code state (recents, themes, terminal, etc.). + */ +export function shouldKeepItemTable( + key: string, + prefixes: readonly string[] = KEEP_ITEMTABLE_PREFIXES, +): boolean { + return prefixes.some((p) => key.startsWith(p)); +} + +/** + * Redact long string values inside a row's `value` field. + * + * Behaviour: + * - If the value parses as JSON: recursively replace every string longer + * than 8 chars with same-length asterisks. Numbers, booleans, nulls, + * and short strings are preserved. + * - If the value is not JSON: replace the whole string with asterisks of + * the same length. + * + * The 8-char threshold preserves common short markers (`user`, `assistant`, + * `linux`, etc.) while obscuring prompt text, tab IDs, paths, etc. + */ +export function redactValue(value: string): string { + try { + const parsed = JSON.parse(value); + const redacted = JSON.parse(JSON.stringify(parsed), (_k, v) => { + if (typeof v === 'string' && v.length > 8) return '*'.repeat(v.length); + return v; + }); + return JSON.stringify(redacted); + } catch { + return '*'.repeat(value.length); + } +} + +export interface CursorConfigRootInputs { + platform?: NodeJS.Platform; + home?: string; + appdata?: string; +} + +/** + * Resolve the OS-specific Cursor configuration root. + * + * Linux: ~/.config/Cursor + * macOS: ~/Library/Application Support/Cursor + * Windows: %APPDATA%/Cursor (falls back to /AppData/Roaming/Cursor) + * + * Inputs are injectable for testability — defaults to the live process + * environment. + */ +export function cursorConfigRoot(inputs: CursorConfigRootInputs = {}): string { + const platform = inputs.platform ?? process.platform; + const home = inputs.home ?? homedir(); + switch (platform) { + case 'darwin': + return join(home, 'Library', 'Application Support', 'Cursor'); + case 'win32': { + const appdata = + inputs.appdata ?? process.env.APPDATA ?? join(home, 'AppData', 'Roaming'); + return join(appdata, 'Cursor'); + } + default: + return join(home, '.config', 'Cursor'); + } +} + +export interface DiscoveredDb { + path: string; + /** Short label for the output filename, e.g. 'global' or 'workspace-'. */ + label: string; +} + +export interface DiscoveryFs { + existsSync: (p: string) => boolean; + readdirSync: (p: string) => string[]; +} + +/** + * Discover every `state.vscdb` under a Cursor config root — both the global + * one and one per workspace under `User/workspaceStorage/`. + * + * Returns labelled entries so each gets a distinct output filename when the + * dump script writes its fixtures. + */ +export function discoverAllStateVscdb( + root: string, + fsHelpers: DiscoveryFs = { existsSync, readdirSync }, +): DiscoveredDb[] { + const found: DiscoveredDb[] = []; + const globalPath = join(root, 'User', 'globalStorage', 'state.vscdb'); + if (fsHelpers.existsSync(globalPath)) { + found.push({ path: globalPath, label: 'global' }); + } + const wsDir = join(root, 'User', 'workspaceStorage'); + if (fsHelpers.existsSync(wsDir)) { + for (const entry of fsHelpers.readdirSync(wsDir)) { + const dbPath = join(wsDir, entry, 'state.vscdb'); + if (fsHelpers.existsSync(dbPath)) { + found.push({ path: dbPath, label: `workspace-${entry}` }); + } + } + } + return found; +} + +export interface CliArgs { + name: string; + src?: string; + redact: boolean; +} + +export type ParseArgsResult = + | { ok: true; args: CliArgs } + | { ok: false; error: string; help?: true }; + +/** + * Parse the `dump-cursor-state` CLI arguments without ever calling + * `process.exit` directly — so unit tests can exercise the error paths. + * + * The script entry-point handles `result.ok === false` by printing the + * error / usage and exiting with the appropriate code. + */ +export function parseArgs(argv: readonly string[]): ParseArgsResult { + const args: Partial = { redact: false }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--name') { + const v = argv[++i]; + if (v === undefined) return { ok: false, error: '--name requires a value' }; + args.name = v; + } else if (a === '--src') { + const v = argv[++i]; + if (v === undefined) return { ok: false, error: '--src requires a value' }; + args.src = v; + } else if (a === '--redact') { + args.redact = true; + } else if (a === '--help' || a === '-h') { + return { ok: false, error: '', help: true }; + } else { + return { ok: false, error: `Unknown argument: ${a}` }; + } + } + if (!args.name) { + return { ok: false, error: 'Missing required --name ' }; + } + return { ok: true, args: args as CliArgs }; +} diff --git a/src/ext-vscode/src/extension.test.ts b/src/ext-vscode/src/extension.test.ts new file mode 100644 index 00000000..37d00815 --- /dev/null +++ b/src/ext-vscode/src/extension.test.ts @@ -0,0 +1,579 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +// vi.hoisted lets the mocks declared here be referenced from the vi.mock +// factories below (which are themselves hoisted above the import block). +const { + mockShowOnboarding, + mockRegisterWebviewViewProvider, + mockProviderCtor, + mockDetectHost, + mockWorkspaceStorageDir, + mockWindsurfCodeiumDir, + mockEnumerateStateVscdbPaths, + mockCreateChatHistoryWatcher, + mockWatcherStart, + mockWatcherStop, + mockCreateChatEventHandler, + mockShowInformationMessage, + mockExistsSync, + mockExecuteCommand, +} = vi.hoisted(() => ({ + mockShowOnboarding: vi.fn(), + mockRegisterWebviewViewProvider: vi.fn(), + mockProviderCtor: vi.fn(), + mockDetectHost: vi.fn(() => 'vscode-generic'), + mockWorkspaceStorageDir: vi.fn(() => null), + mockWindsurfCodeiumDir: vi.fn(() => '/home/u/.codeium/windsurf'), + mockEnumerateStateVscdbPaths: vi.fn(() => []), + mockCreateChatHistoryWatcher: vi.fn(), + mockWatcherStart: vi.fn(), + mockWatcherStop: vi.fn(), + mockCreateChatEventHandler: vi.fn(() => vi.fn()), + mockShowInformationMessage: vi.fn(), + mockExistsSync: vi.fn(() => false), + mockExecuteCommand: vi.fn(), +})); + +vi.mock('vscode', () => ({ + window: { + registerWebviewViewProvider: mockRegisterWebviewViewProvider, + showInformationMessage: mockShowInformationMessage, + createOutputChannel: vi.fn(() => ({ + appendLine: vi.fn(), + dispose: vi.fn(), + })), + }, + workspace: { + workspaceFolders: undefined, + }, + env: { appName: 'Visual Studio Code' }, + commands: { + executeCommand: mockExecuteCommand, + getCommands: vi.fn().mockResolvedValue([]), + }, +})); +vi.mock('./onboarding.js', () => ({ + CONSENT_KEY: 'nexpath.consentGranted', + showOnboardingIfNeeded: mockShowOnboarding, +})); +vi.mock('./webview/view-provider.js', () => ({ + VIEW_ID: 'nexpath.status', + NexpathDecisionSessionViewProvider: class { + constructor(...args: unknown[]) { + mockProviderCtor(...args); + } + publishPayload(): void {} + }, +})); +vi.mock('./webview/prompt-injection.js', () => ({ + handleOptionSelection: vi.fn(), +})); +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, existsSync: mockExistsSync }; +}); +vi.mock('./host-detector.js', () => ({ + detectHost: mockDetectHost, + workspaceStorageDir: mockWorkspaceStorageDir, + windsurfCodeiumDir: mockWindsurfCodeiumDir, +})); +vi.mock('./chat-input-injector.js', () => ({ + chatInputInject: vi.fn(), +})); +vi.mock('./path-enumerator.js', () => ({ + enumerateStateVscdbPaths: mockEnumerateStateVscdbPaths, + globalStorageStateVscdbPath: () => null, +})); +vi.mock('./chat-history-watcher.js', () => ({ + createChatHistoryWatcher: mockCreateChatHistoryWatcher, +})); +vi.mock('./chat-pipeline.js', () => ({ + createChatEventHandler: mockCreateChatEventHandler, +})); +vi.mock('./ipc.js', () => ({ + spawnAuto: vi.fn(), + spawnStop: vi.fn(), +})); + +import { activate, deactivate, getViewProvider } from './extension.js'; + +interface FakeContext { + extensionUri: { __uri: true }; + subscriptions: unknown[]; + globalState: { get: (k: string) => T | undefined }; +} + +function makeCtx(consent: boolean | undefined = undefined): FakeContext { + return { + extensionUri: { __uri: true }, + subscriptions: [], + globalState: { + get: (k: string) => + (k === 'nexpath.consentGranted' ? (consent as T) : undefined) as + | T + | undefined, + }, + }; +} + +describe('activate', () => { + let logSpy: ReturnType; + let errorSpy: ReturnType; + + beforeEach(() => { + mockShowOnboarding.mockReset(); + mockRegisterWebviewViewProvider.mockReset(); + mockProviderCtor.mockReset(); + mockDetectHost.mockReset().mockReturnValue('vscode-generic'); + mockWorkspaceStorageDir.mockReset().mockReturnValue(null); + mockWindsurfCodeiumDir.mockReset().mockReturnValue('/home/u/.codeium/windsurf'); + mockEnumerateStateVscdbPaths.mockReset().mockReturnValue([]); + mockCreateChatHistoryWatcher.mockReset().mockReturnValue({ + start: mockWatcherStart, + stop: mockWatcherStop, + }); + mockWatcherStart.mockReset(); + mockWatcherStop.mockReset(); + mockCreateChatEventHandler.mockReset().mockReturnValue(vi.fn()); + mockShowInformationMessage.mockReset(); + mockExistsSync.mockReset().mockReturnValue(false); + mockExecuteCommand.mockReset().mockResolvedValue(undefined); + mockRegisterWebviewViewProvider.mockReturnValue({ dispose: vi.fn() }); + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + // Reset module-level state in extension.ts (deactivate clears viewProvider + watcher) + deactivate(); + }); + + afterEach(() => { + logSpy.mockRestore(); + errorSpy.mockRestore(); + }); + + it('logs the activation message', async () => { + mockShowOnboarding.mockResolvedValueOnce(undefined); + await activate(makeCtx() as never); + expect(logSpy).toHaveBeenCalledWith('[nexpath] extension activated'); + }); + + it('forwards the ExtensionContext to showOnboardingIfNeeded', async () => { + mockShowOnboarding.mockResolvedValueOnce(undefined); + const ctx = makeCtx(); + await activate(ctx as never); + expect(mockShowOnboarding).toHaveBeenCalledOnce(); + expect(mockShowOnboarding).toHaveBeenCalledWith(ctx); + }); + + it('registers the view provider on every activation regardless of consent', async () => { + mockShowOnboarding.mockResolvedValueOnce(undefined); + await activate(makeCtx(false) as never); // user denied + expect(mockProviderCtor).toHaveBeenCalledOnce(); + expect(mockRegisterWebviewViewProvider).toHaveBeenCalledOnce(); + }); + + it('does NOT start the watcher when consent is undefined (first launch, user has not answered)', async () => { + mockShowOnboarding.mockResolvedValueOnce(undefined); + mockDetectHost.mockReturnValueOnce('cursor'); + await activate(makeCtx(undefined) as never); + expect(mockCreateChatHistoryWatcher).not.toHaveBeenCalled(); + expect(mockWatcherStart).not.toHaveBeenCalled(); + }); + + it('does NOT start the watcher when consent is explicitly false (user denied)', async () => { + mockShowOnboarding.mockResolvedValueOnce(undefined); + mockDetectHost.mockReturnValueOnce('cursor'); + await activate(makeCtx(false) as never); + expect(mockCreateChatHistoryWatcher).not.toHaveBeenCalled(); + }); + + it('does NOT start the watcher on plain VS Code host even if consent is true', async () => { + mockShowOnboarding.mockResolvedValueOnce(undefined); + mockDetectHost.mockReturnValueOnce('vscode-generic'); + await activate(makeCtx(true) as never); + expect(mockCreateChatHistoryWatcher).not.toHaveBeenCalled(); + }); + + it('does NOT start the watcher when no state.vscdb files are found under workspaceStorage', async () => { + mockShowOnboarding.mockResolvedValueOnce(undefined); + mockDetectHost.mockReturnValueOnce('cursor'); + mockWorkspaceStorageDir.mockReturnValueOnce('/fake/workspaceStorage'); + mockEnumerateStateVscdbPaths.mockReturnValueOnce([]); + await activate(makeCtx(true) as never); + expect(mockCreateChatHistoryWatcher).not.toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('no workspace state.vscdb'), + ); + }); + + it('starts the watcher when consent=true + host=cursor + dbs present', async () => { + mockShowOnboarding.mockResolvedValueOnce(undefined); + mockDetectHost.mockReturnValueOnce('cursor'); + mockWorkspaceStorageDir.mockReturnValueOnce('/fake/workspaceStorage'); + mockEnumerateStateVscdbPaths.mockReturnValueOnce([ + '/fake/workspaceStorage/wsA/state.vscdb', + '/fake/workspaceStorage/wsB/state.vscdb', + ]); + const ctx = makeCtx(true); + await activate(ctx as never); + expect(mockCreateChatHistoryWatcher).toHaveBeenCalledOnce(); + const watcherOpts = mockCreateChatHistoryWatcher.mock.calls[0]![0] as { + targets: Array<{ path: string; kind: string }>; + }; + expect(watcherOpts.targets).toHaveLength(2); + expect(watcherOpts.targets[0]!.kind).toBe('cursor-sqlite'); + expect(mockWatcherStart).toHaveBeenCalledOnce(); + // Cleanup disposable pushed onto subscriptions + expect(ctx.subscriptions.length).toBeGreaterThanOrEqual(2); + }); + + it('builds the chat-event handler with the right composer (workspace-prefixed session id)', async () => { + mockShowOnboarding.mockResolvedValueOnce(undefined); + mockDetectHost.mockReturnValueOnce('cursor'); + mockWorkspaceStorageDir.mockReturnValueOnce('/fake/ws'); + mockEnumerateStateVscdbPaths.mockReturnValueOnce(['/fake/ws/a/state.vscdb']); + await activate(makeCtx(true) as never); + expect(mockCreateChatEventHandler).toHaveBeenCalledOnce(); + const deps = mockCreateChatEventHandler.mock.calls[0]![0] as { + composeSessionId?: (e: { + rawSessionId: string; + sourcePath: string; + prompt: string; + capturedAt: Date; + extractorId: string; + }) => string; + }; + expect(typeof deps.composeSessionId).toBe('function'); + // R4.3 fix: composer now derives cwd per-event from event.sourcePath via + // sibling workspace.json lookup. The fake path here has no real + // workspace.json on disk → helper returns null → composer falls back to + // the extension instance's workspaceCwd (process.cwd() since + // workspaceFolders is undefined in the mock). + const composed = deps.composeSessionId!({ + rawSessionId: 'tab-1', + sourcePath: '/fake/ws/a/state.vscdb', + prompt: 'irrelevant', + capturedAt: new Date(0), + extractorId: 'cursor-v2024-q4', + }); + expect(composed.endsWith('|tab-1')).toBe(true); + expect(composed).toMatch(/.+\|tab-1$/); + }); + + it('does not throw when showOnboardingIfNeeded rejects — logs error and continues', async () => { + mockShowOnboarding.mockRejectedValueOnce(new Error('onboarding boom')); + mockDetectHost.mockReturnValueOnce('cursor'); + await expect(activate(makeCtx(true) as never)).resolves.toBeUndefined(); + expect(errorSpy).toHaveBeenCalledWith( + '[nexpath] onboarding failed:', + expect.any(Error), + ); + }); + + it('exposes the constructed provider via getViewProvider()', async () => { + mockShowOnboarding.mockResolvedValueOnce(undefined); + await activate(makeCtx() as never); + expect(getViewProvider()).toBeDefined(); + }); + + // ── Notification-panel pre-open (Cursor/Windsurf toast visibility) ───────── + // VS Code shows showInformationMessage toasts as transient bottom-right + // popups. Cursor and Windsurf route them to the silent notification stack + // (bell icon) and stay invisible until the user opens the panel. extension.ts + // pre-opens the panel via the `notifications.showList` command on non-VS-Code + // hosts so the consent toast is immediately discoverable. Dev plan §2.2 M11 + // ("consent toast") + §2.5 cursor-quirks compliance. + + it('on host=cursor: pre-opens notification panel before consent toast', async () => { + mockShowOnboarding.mockResolvedValueOnce(undefined); + mockDetectHost.mockReturnValueOnce('cursor'); + await activate(makeCtx() as never); + expect(mockExecuteCommand).toHaveBeenCalledWith('notifications.showList'); + }); + + it('on host=windsurf: pre-opens notification panel before consent toast', async () => { + mockShowOnboarding.mockResolvedValueOnce(undefined); + mockDetectHost.mockReturnValueOnce('windsurf'); + await activate(makeCtx() as never); + expect(mockExecuteCommand).toHaveBeenCalledWith('notifications.showList'); + }); + + it('on host=vscode-generic: does NOT pre-open notification panel (toast surfaces natively)', async () => { + mockShowOnboarding.mockResolvedValueOnce(undefined); + mockDetectHost.mockReturnValueOnce('vscode-generic'); + await activate(makeCtx() as never); + expect(mockExecuteCommand).not.toHaveBeenCalledWith('notifications.showList'); + }); + + it('swallows errors from notifications.showList (best-effort discoverability hint)', async () => { + mockShowOnboarding.mockResolvedValueOnce(undefined); + mockDetectHost.mockReturnValueOnce('cursor'); + mockExecuteCommand.mockRejectedValueOnce(new Error('command not found')); + await expect(activate(makeCtx() as never)).resolves.toBeUndefined(); + // Onboarding still runs even though showList rejected + expect(mockShowOnboarding).toHaveBeenCalledOnce(); + }); + + // ── Windsurf codeium-cascade dir wiring (Drift A fix) ────────────────────── + // Per dev plan §2.3 acceptance #2: when host=windsurf, the watcher must + // monitor BOTH state.vscdb files AND `~/.codeium/windsurf/`. These tests + // verify the extension.ts wiring that adds the codeium dir as a + // windsurf-dir WatchTarget alongside the cursor-sqlite targets. + + it('windsurf host: adds codeium cascade dir as a windsurf-dir target when it exists on disk', async () => { + mockShowOnboarding.mockResolvedValueOnce(undefined); + mockDetectHost.mockReturnValueOnce('windsurf'); + mockWorkspaceStorageDir.mockReturnValueOnce('/fake/ws'); + mockEnumerateStateVscdbPaths.mockReturnValueOnce(['/fake/ws/a/state.vscdb']); + mockWindsurfCodeiumDir.mockReturnValueOnce('/home/u/.codeium/windsurf'); + mockExistsSync.mockImplementation( + (p: string) => p === '/home/u/.codeium/windsurf', + ); + + await activate(makeCtx(true) as never); + + expect(mockCreateChatHistoryWatcher).toHaveBeenCalledOnce(); + const watcherOpts = mockCreateChatHistoryWatcher.mock.calls[0]![0] as { + targets: Array<{ path: string; kind: string }>; + }; + expect(watcherOpts.targets).toHaveLength(2); + expect(watcherOpts.targets[0]).toEqual({ + path: '/fake/ws/a/state.vscdb', + kind: 'cursor-sqlite', + }); + expect(watcherOpts.targets[1]).toEqual({ + path: '/home/u/.codeium/windsurf', + kind: 'windsurf-dir', + }); + }); + + it('windsurf host: skips codeium dir when it does not exist (existsSync = false)', async () => { + mockShowOnboarding.mockResolvedValueOnce(undefined); + mockDetectHost.mockReturnValueOnce('windsurf'); + mockWorkspaceStorageDir.mockReturnValueOnce('/fake/ws'); + mockEnumerateStateVscdbPaths.mockReturnValueOnce(['/fake/ws/a/state.vscdb']); + mockExistsSync.mockReturnValue(false); + + await activate(makeCtx(true) as never); + + const watcherOpts = mockCreateChatHistoryWatcher.mock.calls[0]![0] as { + targets: Array<{ path: string; kind: string }>; + }; + expect(watcherOpts.targets).toHaveLength(1); + expect(watcherOpts.targets[0]!.kind).toBe('cursor-sqlite'); + }); + + it('windsurf host: starts watcher when ONLY the codeium dir exists (no state.vscdb yet)', async () => { + mockShowOnboarding.mockResolvedValueOnce(undefined); + mockDetectHost.mockReturnValueOnce('windsurf'); + mockWorkspaceStorageDir.mockReturnValueOnce('/fake/ws'); + mockEnumerateStateVscdbPaths.mockReturnValueOnce([]); + mockWindsurfCodeiumDir.mockReturnValueOnce('/home/u/.codeium/windsurf'); + mockExistsSync.mockImplementation( + (p: string) => p === '/home/u/.codeium/windsurf', + ); + + await activate(makeCtx(true) as never); + + expect(mockCreateChatHistoryWatcher).toHaveBeenCalledOnce(); + const watcherOpts = mockCreateChatHistoryWatcher.mock.calls[0]![0] as { + targets: Array<{ path: string; kind: string }>; + }; + expect(watcherOpts.targets).toHaveLength(1); + expect(watcherOpts.targets[0]).toEqual({ + path: '/home/u/.codeium/windsurf', + kind: 'windsurf-dir', + }); + }); + + it('cursor host: never adds a windsurf-dir target (no cross-host leakage)', async () => { + mockShowOnboarding.mockResolvedValueOnce(undefined); + mockDetectHost.mockReturnValueOnce('cursor'); + mockWorkspaceStorageDir.mockReturnValueOnce('/fake/ws'); + mockEnumerateStateVscdbPaths.mockReturnValueOnce(['/fake/ws/a/state.vscdb']); + // Even if existsSync would say yes for /home/u/.codeium/windsurf, the + // cursor-host branch must never consult it. + mockExistsSync.mockReturnValue(true); + + await activate(makeCtx(true) as never); + + expect(mockWindsurfCodeiumDir).not.toHaveBeenCalled(); + const watcherOpts = mockCreateChatHistoryWatcher.mock.calls[0]![0] as { + targets: Array<{ path: string; kind: string }>; + }; + expect(watcherOpts.targets.every((t) => t.kind === 'cursor-sqlite')).toBe( + true, + ); + }); + + // ── Watcher-callback wiring (B5 audit follow-up) ─────────────────────────── + // The watcher takes three callbacks at construction time (onEvent, + // onError, onSchemaUnknown). The watcher itself is mocked in these tests, + // so we capture the callbacks from the createChatHistoryWatcher call args + // and invoke them directly — verifying that what extension.ts wires up + // does the right thing. + + /** Helper: drive activate() with watcher-starting conditions + return the watcher opts. */ + async function activateWithWatcher(): Promise<{ + onEvent: (event: unknown) => void; + onError: (err: Error) => void; + onSchemaUnknown: (info: { path: string; observedSampleKeys: readonly string[] }) => void; + }> { + mockShowOnboarding.mockResolvedValueOnce(undefined); + mockDetectHost.mockReturnValueOnce('cursor'); + mockWorkspaceStorageDir.mockReturnValueOnce('/fake/ws'); + mockEnumerateStateVscdbPaths.mockReturnValueOnce(['/fake/ws/a/state.vscdb']); + await activate(makeCtx(true) as never); + const opts = mockCreateChatHistoryWatcher.mock.calls[0]![0] as { + onEvent: (event: unknown) => void; + onError: (err: Error) => void; + onSchemaUnknown: (info: { + path: string; + observedSampleKeys: readonly string[]; + }) => void; + }; + return opts; + } + + it('routes watcher onEvent through the chat-event handler (the integration proof)', async () => { + const trackedHandler = vi.fn(); + mockCreateChatEventHandler.mockReturnValueOnce(trackedHandler); + const opts = await activateWithWatcher(); + const event = { + prompt: 'hi', + rawSessionId: 'tab-7', + capturedAt: new Date(0), + sourcePath: '/fake/ws/a/state.vscdb', + extractorId: 'cursor-v2025-q2', + }; + opts.onEvent(event); + expect(trackedHandler).toHaveBeenCalledOnce(); + expect(trackedHandler).toHaveBeenCalledWith(event); + }); + + it('watcher onSchemaUnknown surfaces a visible info toast with path + observed keys', async () => { + const opts = await activateWithWatcher(); + opts.onSchemaUnknown({ + path: '/fake/ws/a/state.vscdb', + observedSampleKeys: ['unknown.key.1', 'unknown.key.2', 'unknown.key.3'], + }); + expect(mockShowInformationMessage).toHaveBeenCalledOnce(); + const msg = mockShowInformationMessage.mock.calls[0]![0] as string; + expect(msg).toContain('/fake/ws/a/state.vscdb'); + expect(msg).toContain('schema is not recognised'); + expect(msg).toContain('unknown.key.1'); + }); + + it('watcher onError logs to console.error (does not crash the extension)', async () => { + const opts = await activateWithWatcher(); + const watcherErr = new Error('watch boom'); + expect(() => opts.onError(watcherErr)).not.toThrow(); + expect(errorSpy).toHaveBeenCalledWith('[nexpath] watcher error:', watcherErr); + }); + + // ── Pipeline logger wiring (B1.4 follow-up — spawn errors in Output) ────── + // chat-pipeline.ts catches spawnAuto / spawnStop failures and forwards them + // to its `logger.error`. extension.ts must wire a logger that writes to + // BOTH console.error AND the Nexpath OutputChannel (via the local `log` + // helper). Otherwise IPC errors only surface in Developer Tools Console, + // invisible to end users. Verified live during B1.4 (nexpath binary moved + // aside → spawnAuto ENOENT → silent before this fix, surfaces in Output + // after this fix). + + it('wires a logger into createChatEventHandler that writes spawn errors to the Output channel', async () => { + mockShowOnboarding.mockResolvedValueOnce(undefined); + mockDetectHost.mockReturnValueOnce('cursor'); + mockWorkspaceStorageDir.mockReturnValueOnce('/fake/ws'); + mockEnumerateStateVscdbPaths.mockReturnValueOnce(['/fake/ws/a/state.vscdb']); + await activate(makeCtx(true) as never); + const handlerDeps = mockCreateChatEventHandler.mock.calls[0]![0] as { + logger?: { error: (msg: string, err: unknown) => void }; + }; + expect(handlerDeps.logger).toBeDefined(); + expect(typeof handlerDeps.logger!.error).toBe('function'); + + // Calling the wired logger.error should reach console.error (existing + // path) and also call `log()` which writes to console.log (which the + // OutputChannel mock will also receive via appendLine in production). + const enoent = Object.assign(new Error('spawn nexpath ENOENT'), { + code: 'ENOENT', + }); + handlerDeps.logger!.error('[nexpath] spawnAuto failed:', enoent); + expect(errorSpy).toHaveBeenCalledWith( + '[nexpath] spawnAuto failed:', + enoent, + ); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('spawn nexpath ENOENT'), + ); + }); + + it('wired logger.error formats non-Error rejection values cleanly', async () => { + mockShowOnboarding.mockResolvedValueOnce(undefined); + mockDetectHost.mockReturnValueOnce('cursor'); + mockWorkspaceStorageDir.mockReturnValueOnce('/fake/ws'); + mockEnumerateStateVscdbPaths.mockReturnValueOnce(['/fake/ws/a/state.vscdb']); + await activate(makeCtx(true) as never); + const handlerDeps = mockCreateChatEventHandler.mock.calls[0]![0] as { + logger?: { error: (msg: string, err: unknown) => void }; + }; + handlerDeps.logger!.error('[nexpath] spawnStop failed:', 'plain string err'); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('plain string err'), + ); + }); +}); + +describe('deactivate', () => { + let logSpy: ReturnType; + + beforeEach(() => { + mockShowOnboarding.mockReset(); + mockRegisterWebviewViewProvider.mockReset(); + mockProviderCtor.mockReset(); + mockDetectHost.mockReset().mockReturnValue('vscode-generic'); + mockWorkspaceStorageDir.mockReset().mockReturnValue(null); + mockWindsurfCodeiumDir.mockReset().mockReturnValue('/home/u/.codeium/windsurf'); + mockEnumerateStateVscdbPaths.mockReset().mockReturnValue([]); + mockCreateChatHistoryWatcher.mockReset().mockReturnValue({ + start: mockWatcherStart, + stop: mockWatcherStop, + }); + mockWatcherStart.mockReset(); + mockWatcherStop.mockReset(); + mockExistsSync.mockReset().mockReturnValue(false); + mockRegisterWebviewViewProvider.mockReturnValue({ dispose: vi.fn() }); + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + logSpy.mockRestore(); + }); + + it('logs the deactivation message', () => { + deactivate(); + expect(logSpy).toHaveBeenCalledWith('[nexpath] extension deactivated'); + }); + + it('returns synchronously without throwing', () => { + expect(() => deactivate()).not.toThrow(); + }); + + it('clears module-level viewProvider on deactivate', async () => { + mockShowOnboarding.mockResolvedValueOnce(undefined); + await activate(makeCtx() as never); + expect(getViewProvider()).toBeDefined(); + deactivate(); + expect(getViewProvider()).toBeUndefined(); + }); + + it('stops the watcher on deactivate (if one was started)', async () => { + mockShowOnboarding.mockResolvedValueOnce(undefined); + mockDetectHost.mockReturnValueOnce('cursor'); + mockWorkspaceStorageDir.mockReturnValueOnce('/fake/ws'); + mockEnumerateStateVscdbPaths.mockReturnValueOnce(['/fake/ws/a/state.vscdb']); + await activate(makeCtx(true) as never); + expect(mockWatcherStart).toHaveBeenCalledOnce(); + deactivate(); + expect(mockWatcherStop).toHaveBeenCalled(); + }); +}); diff --git a/src/ext-vscode/src/extension.ts b/src/ext-vscode/src/extension.ts new file mode 100644 index 00000000..cf39ee26 --- /dev/null +++ b/src/ext-vscode/src/extension.ts @@ -0,0 +1,280 @@ +import * as vscode from 'vscode'; +import { existsSync } from 'node:fs'; +import { CONSENT_KEY, showOnboardingIfNeeded } from './onboarding.js'; +import { + NexpathDecisionSessionViewProvider, + VIEW_ID, +} from './webview/view-provider.js'; +import { handleOptionSelection } from './webview/prompt-injection.js'; +import { + detectHost, + windsurfCodeiumDir, + workspaceStorageDir, +} from './host-detector.js'; +import { chatInputInject } from './chat-input-injector.js'; +import { + enumerateStateVscdbPaths, + globalStorageStateVscdbPath, +} from './path-enumerator.js'; +import { + createChatHistoryWatcher, + type ChatHistoryWatcher, +} from './chat-history-watcher.js'; +import { createChatEventHandler } from './chat-pipeline.js'; +import { spawnAuto, spawnStop } from './ipc.js'; +import { resolveWorkspaceFromDbPath } from './resolve-db-workspace.js'; +import type { ChatHistoryEvent, WatchTarget } from './chat-history-types.js'; + +/** + * Module-level state held across activate / deactivate so the watcher's + * resources can be cleaned up properly. View-provider lookup is exposed + * via `getViewProvider()` so other modules can publish payloads even + * outside the natural watcher → pipeline → view-provider chain. + */ +let viewProvider: NexpathDecisionSessionViewProvider | undefined; +let watcher: ChatHistoryWatcher | undefined; +let logChannel: vscode.OutputChannel | undefined; + +/** + * Dedicated VS Code OutputChannel for nexpath messages — visible to + * engineers and end-users at `View → Output → Nexpath`. Replaces the + * plain console.log path which Cursor / VS Code only surface in the + * Developer Tools Console (hard to discover, easy to miss). All key + * lifecycle + watcher events log through this channel; console.log is + * kept as a secondary destination so the existing extension.test.ts + * console-spy assertions still pass. + */ +function log(line: string): void { + console.log(line); + logChannel?.appendLine(`[${new Date().toISOString()}] ${line}`); +} + +export async function activate(context: vscode.ExtensionContext): Promise { + logChannel = vscode.window.createOutputChannel('Nexpath'); + context.subscriptions.push(logChannel); + log('[nexpath] extension activated'); + + // 1. Detect host (Cursor / Windsurf / vscode-generic). Stable for the + // lifetime of this extension instance. + const host = detectHost(); + + // 2. Construct + register the view provider with the B4 injectFn-aware + // onSelect. injectFn falls through to clipboard when the host has no + // matching command (the safe default; see chat-input-injector.ts). + const onSelect = (text: string): Promise => + handleOptionSelection(text, { + injectFn: (t) => chatInputInject(t, { host }), + }); + viewProvider = new NexpathDecisionSessionViewProvider( + context.extensionUri, + onSelect, + ); + context.subscriptions.push( + vscode.window.registerWebviewViewProvider(VIEW_ID, viewProvider), + ); + + // 3. On hosts that route non-modal info messages to the silent + // notification stack (Cursor, Windsurf) instead of surfacing them as + // transient bottom-right toasts (VS Code's default), pre-open the + // notification panel so the consent toast in `showOnboardingIfNeeded` + // is immediately visible. Best-effort — failures are silent because + // the command id is host-dependent and the toast still lands in the + // panel either way. + if (host !== 'vscode-generic') { + try { + await vscode.commands.executeCommand('notifications.showList'); + } catch { + // ignored — discoverability hint, not load-bearing + } + } + + // 4. Show onboarding (consent prompt + macOS FDA guidance). May await + // the user's click; safe — the activate flow is allowed to block. + try { + await showOnboardingIfNeeded(context); + } catch (err) { + log(`[nexpath] onboarding failed: ${err instanceof Error ? err.stack ?? err.message : String(err)}`); + console.error('[nexpath] onboarding failed:', err); + } + + // 5. Watcher start-up — gated on consent + host being recognised. If the + // user denied, the value is `false` (NOT undefined) → watcher does + // not start. If the host is vscode-generic, there's no AI chat to + // watch. + const consent = context.globalState.get(CONSENT_KEY); + log(`[nexpath] consent state: ${JSON.stringify(consent)}, host: ${host}`); + if (consent !== true) { + log('[nexpath] consent not granted — watcher not started'); + return; + } + if (host === 'vscode-generic') { + log('[nexpath] host is plain VS Code — no chat to watch'); + return; + } + + const wsStorage = workspaceStorageDir({ host }); + const allDbPaths = enumerateStateVscdbPaths(wsStorage); + + // Multi-workspace correctness (R4.3 fix): + // Cursor / Windsurf keep ONE workspaceStorage//state.vscdb per open + // workspace, but every running extension instance can see all of them. + // If every instance watched every db, two open windows would each capture + // every prompt — producing duplicate rows in prompt-store.db with one + // row mis-attributed to the wrong window's cwd. + // + // The defensive choice: filter to dbs whose sibling workspace.json#folder + // matches THIS instance's workspaceCwd. Each db ends up watched by + // exactly one instance, so prompts land in prompt-store.db exactly once + // with the correct project_root. + // + // Fallback ladder: + // - No workspace folder open (window-without-folder): can't filter → + // keep current "watch all" behaviour so the user still gets capture. + // - workspace.json missing / multi-root (.code-workspace with + // `configuration` not `folder`) / unparseable: keep that db in the + // watch list, and rely on per-event cwd resolution below to attribute + // correctly. + const ownWorkspaceCwd = + vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? null; + const dbPaths = + ownWorkspaceCwd === null + ? allDbPaths + : allDbPaths.filter((p) => { + const folder = resolveWorkspaceFromDbPath(p); + // null → workspace.json missing/unparseable → keep as defensive + // catch-all (per-event resolution will sort attribution). + return folder === null || folder === ownWorkspaceCwd; + }); + if (ownWorkspaceCwd !== null && dbPaths.length < allDbPaths.length) { + log( + `[nexpath] filtered ${allDbPaths.length - dbPaths.length} cross-workspace db(s); ` + + `watching ${dbPaths.length} for own workspace ${ownWorkspaceCwd}`, + ); + } + + // Per dev plan §2.3 acceptance #2, Windsurf's chat data may also live at + // `~/.codeium/windsurf/` (legacy Codeium Cascade store) in addition to + // `state.vscdb`. Watch both when host=windsurf; skip silently if the + // cascade dir doesn't exist (fs.watch on a missing path would throw). + // Existence is captured at activate time — same activate-time-only + // limitation that path-enumerator.ts documents for workspaceStorage. + const codeiumDir = + host === 'windsurf' ? windsurfCodeiumDir() : null; + const codeiumExists = codeiumDir !== null && existsSync(codeiumDir); + + // Cursor's modern Composer / Agent mode stores conversations in + // `globalStorage/state.vscdb` (shared file across all workspaces) under + // the `cursorDiskKV` table — NOT in `workspaceStorage`. Add it as a + // target unconditionally on Cursor hosts so Agent / Composer prompts + // actually reach the pipeline. Multi-window caveat: two open Cursor + // windows will both watch this file and both emit each new bubble (no + // cross-instance dedup), so each event reaches Layer C twice with each + // window's respective `workspaceCwd`. Documented as a known v0.1.3 + // limitation (single-window is the common case). + const globalDbPath = + host === 'cursor' ? globalStorageStateVscdbPath(wsStorage) : null; + log( + `[nexpath] enumerated ${dbPaths.length} state.vscdb file(s) under ${wsStorage}; ` + + `globalStorageDb=${globalDbPath === null ? 'absent' : 'present'}; ` + + `codeiumExists=${codeiumExists}`, + ); + if (dbPaths.length === 0 && globalDbPath === null && !codeiumExists) { + log( + `[nexpath] no workspace state.vscdb, no global state.vscdb, and no codeium dir found — watcher not started. ` + + 'Open at least one workspace in the host and reload the extension to retry.', + ); + return; + } + + const targets: WatchTarget[] = dbPaths.map((path) => ({ + path, + kind: 'cursor-sqlite', + })); + if (globalDbPath !== null) { + targets.push({ path: globalDbPath, kind: 'cursor-sqlite' }); + } + if (codeiumExists) { + targets.push({ path: codeiumDir!, kind: 'windsurf-dir' }); + } + + // 6. Build the pipeline handler (auto → stop → publish) with real + // dependencies (ipc.spawnAuto / ipc.spawnStop / viewProvider.publishPayload). + // Session id is workspace-prefixed so concurrent workspaces don't + // collide on the same chat tab id. + // Workspace folder fsPath drives: + // - the spawn-time `cwd` for nexpath auto/stop (so Layer C resolves + // project root / .env / hook-stats correctly) + // - the session-id prefix so concurrent workspaces don't collide on + // the same chat-tab id + // When VS Code is opened without a folder, fall back to the extension's + // own cwd — Layer C will use its DEFAULT_DB_PATH and skip project-specific + // .env loading. + const workspaceCwd = + vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? process.cwd(); + // Per-event cwd resolution (defense-in-depth alongside the enumeration + // filter above). For every captured prompt, derive cwd from the db that + // fired the event — NOT the extension instance's workspaceCwd. Falls + // back to instance cwd when workspace.json is missing / multi-root. + const cwdForEvent = (event: ChatHistoryEvent): string => + resolveWorkspaceFromDbPath(event.sourcePath) ?? workspaceCwd; + const handleChatEvent = createChatEventHandler({ + spawnAuto: (prompt, sid, event) => + spawnAuto(prompt, sid, { cwd: cwdForEvent(event) }), + spawnStop: (sid, event) => spawnStop(sid, { cwd: cwdForEvent(event) }), + publishPayload: (payload) => viewProvider?.publishPayload(payload), + composeSessionId: (event) => `${cwdForEvent(event)}|${event.rawSessionId}`, + // Wire IPC failures (e.g. nexpath binary not on PATH → ENOENT) into + // the Nexpath OutputChannel so they surface to the user. Default logger + // only writes to console.error which is invisible outside Developer + // Tools. Both destinations are kept so existing test assertions on + // console.error continue to pass. + logger: { + error: (msg: string, err: unknown) => { + const detail = err instanceof Error ? err.message : String(err); + log(`${msg} ${detail}`); + console.error(msg, err); + }, + }, + }); + + watcher = createChatHistoryWatcher({ + targets, + onEvent: (event) => { + log(`[nexpath] watcher event: prompt="${event.prompt.slice(0, 80)}" raw_session_id=${event.rawSessionId} extractor=${event.extractorId}`); + // The watcher's onEvent is sync-fire-and-forget; the handler returns + // a Promise we deliberately don't await. + void handleChatEvent(event); + }, + onError: (err) => { + log(`[nexpath] watcher error: ${err.message}`); + console.error('[nexpath] watcher error:', err); + }, + onSchemaUnknown: ({ path, observedSampleKeys }) => { + log(`[nexpath] schema unknown for ${path}; sample keys: ${observedSampleKeys.slice(0, 3).join(', ')}`); + void vscode.window.showInformationMessage( + `Nexpath: ${path} schema is not recognised. The chat-history extractors may need updating. ` + + `Observed keys: ${observedSampleKeys.slice(0, 3).join(', ')}…`, + ); + }, + }); + + watcher.start(); + context.subscriptions.push({ dispose: () => watcher?.stop() }); + const cascadeNote = codeiumExists ? ' + 1 windsurf-dir' : ''; + log( + `[nexpath] watcher started on ${dbPaths.length} state.vscdb file(s)${cascadeNote} for host=${host}`, + ); +} + +export function deactivate(): void { + log('[nexpath] extension deactivated'); + watcher?.stop(); + watcher = undefined; + viewProvider = undefined; + logChannel = undefined; +} + +/** Lookup for other extension modules that want to publish payloads. */ +export function getViewProvider(): NexpathDecisionSessionViewProvider | undefined { + return viewProvider; +} diff --git a/src/ext-vscode/src/extractors/cursor-composer-bubble.test.ts b/src/ext-vscode/src/extractors/cursor-composer-bubble.test.ts new file mode 100644 index 00000000..e555e4ea --- /dev/null +++ b/src/ext-vscode/src/extractors/cursor-composer-bubble.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect } from 'vitest'; +import { cursorComposerBubble } from './cursor-composer-bubble.js'; + +const SOURCE = '/home/u/.config/Cursor/User/globalStorage/state.vscdb'; + +function userBubble(text: string, composerId = 'comp-1', bubbleId = 'bub-1'): { + key: string; + value: string; +} { + return { + key: `cursorDiskKV/bubbleId:${composerId}:${bubbleId}`, + value: JSON.stringify({ + _v: 3, + type: 1, + bubbleId, + text, + createdAt: '2026-05-23T09:58:55.791Z', + }), + }; +} + +function assistantBubble(composerId = 'comp-1', bubbleId = 'bub-2'): { + key: string; + value: string; +} { + return { + key: `cursorDiskKV/bubbleId:${composerId}:${bubbleId}`, + value: JSON.stringify({ + _v: 3, + type: 2, + bubbleId, + text: '', // assistant bubbles have empty text in older Cursor versions + createdAt: '2026-05-23T09:59:01.000Z', + }), + }; +} + +describe('cursorComposerBubble', () => { + it('identifies itself with the expected id and label', () => { + expect(cursorComposerBubble.id).toBe('cursor-composer-bubble'); + expect(cursorComposerBubble.label).toMatch(/composer.*agent/i); + }); + + it('owns keys starting with cursorDiskKV/bubbleId: and nothing else', () => { + expect( + cursorComposerBubble.ownsKey('cursorDiskKV/bubbleId:c1:b1'), + ).toBe(true); + expect( + cursorComposerBubble.ownsKey('cursorDiskKV/composerData:c1'), + ).toBe(false); + expect(cursorComposerBubble.ownsKey('aiService.prompts')).toBe(false); + expect(cursorComposerBubble.ownsKey('bubbleId:c1:b1')).toBe(false); // missing prefix + }); + + it('emits one event per user bubble with composerId as rawSessionId', () => { + const row = userBubble('make me a website where i can create invoices'); + const events = cursorComposerBubble.decodeRow(row, SOURCE); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + prompt: 'make me a website where i can create invoices', + rawSessionId: 'comp-1', + sourcePath: SOURCE, + extractorId: 'cursor-composer-bubble', + }); + expect(events[0].capturedAt).toBeInstanceOf(Date); + }); + + it('skips assistant bubbles (type=2)', () => { + expect(cursorComposerBubble.decodeRow(assistantBubble(), SOURCE)).toEqual( + [], + ); + }); + + it('skips bubbles whose type is not 1 (defensive against schema drift)', () => { + const row = { + key: 'cursorDiskKV/bubbleId:c1:b3', + value: JSON.stringify({ _v: 3, type: 99, text: 'whatever' }), + }; + expect(cursorComposerBubble.decodeRow(row, SOURCE)).toEqual([]); + }); + + it('skips user bubbles with empty / whitespace-only text', () => { + const empty = userBubble('', 'comp-2', 'bub-4'); + const ws = userBubble(' \n \t ', 'comp-2', 'bub-5'); + expect(cursorComposerBubble.decodeRow(empty, SOURCE)).toEqual([]); + expect(cursorComposerBubble.decodeRow(ws, SOURCE)).toEqual([]); + }); + + it('skips rows with malformed JSON without throwing', () => { + const row = { + key: 'cursorDiskKV/bubbleId:c1:b6', + value: '{not valid json', + }; + expect(() => cursorComposerBubble.decodeRow(row, SOURCE)).not.toThrow(); + expect(cursorComposerBubble.decodeRow(row, SOURCE)).toEqual([]); + }); + + it('skips rows whose key does not match the expected shape', () => { + expect( + cursorComposerBubble.decodeRow( + { key: 'aiService.prompts', value: '[]' }, + SOURCE, + ), + ).toEqual([]); + // Missing the second colon → no parseable composerId + expect( + cursorComposerBubble.decodeRow( + { key: 'cursorDiskKV/bubbleId:onlyone', value: '{}' }, + SOURCE, + ), + ).toEqual([]); + }); + + it('different composers produce different rawSessionIds so they do not dedup against each other', () => { + const a = cursorComposerBubble.decodeRow( + userBubble('first conversation prompt', 'comp-A'), + SOURCE, + ); + const b = cursorComposerBubble.decodeRow( + userBubble('second conversation prompt', 'comp-B'), + SOURCE, + ); + expect(a[0].rawSessionId).toBe('comp-A'); + expect(b[0].rawSessionId).toBe('comp-B'); + }); + + it('fingerprintKeys is exactly the table-qualified bubbleId prefix', () => { + expect(cursorComposerBubble.fingerprintKeys).toEqual([ + 'cursorDiskKV/bubbleId:', + ]); + }); + + it('trims whitespace around the prompt text (Cursor sometimes appends a trailing newline)', () => { + const row = { + key: 'cursorDiskKV/bubbleId:comp-X:b1', + value: JSON.stringify({ + _v: 3, + type: 1, + text: ' the login is not working on my phone fix it\n', + }), + }; + const events = cursorComposerBubble.decodeRow(row, SOURCE); + expect(events[0].prompt).toBe( + 'the login is not working on my phone fix it', + ); + }); +}); diff --git a/src/ext-vscode/src/extractors/cursor-composer-bubble.ts b/src/ext-vscode/src/extractors/cursor-composer-bubble.ts new file mode 100644 index 00000000..c2703fee --- /dev/null +++ b/src/ext-vscode/src/extractors/cursor-composer-bubble.ts @@ -0,0 +1,124 @@ +import type { + ChatHistoryEvent, + ChatHistoryExtractor, + ItemTableRow, +} from '../chat-history-types.js'; + +/** + * Cursor Composer / Agent mode — `cursorDiskKV.bubbleId::` + * (Cursor 3.4.20+, observed live 2026-05-25). + * + * Storage location: `~/.config/Cursor/User/globalStorage/state.vscdb` + * (NOT workspaceStorage). One shared file across every workspace the + * user opens in Cursor — Composer/Agent conversations are workspace- + * agnostic. + * + * Row shape: + * key = `bubbleId::` (the reader prefixes this + * with `cursorDiskKV/` so it's distinguishable from any + * `ItemTable` rows that might happen to share a key prefix) + * value = JSON object with shape: + * { + * "_v": 3, + * "type": 1 | 2, // 1 = user, 2 = assistant + * "bubbleId": "", + * "text": "", + * "richText": "", + * "createdAt": "2026-05-23T09:58:55.791Z", + * …many other fields (codebaseContextChunks, attachedFiles, lints, etc.) + * } + * + * Only user-type bubbles (type=1) produce ChatHistoryEvents; assistant + * replies (type=2) are skipped. The user prompt text lives at `.text` + * (plain string) — `.richText` is the same content in Cursor's Lexical + * editor format and is intentionally ignored. + * + * **`rawSessionId` = the composerId.** A composer is one Agent + * conversation; each conversation has its own id and contains multiple + * bubbles. Using composerId per-conversation means two distinct + * conversations in the same workspace don't dedup against each other, + * while the same bubble re-read across fs.watch fires within ONE + * conversation will dedup correctly (signature = sourcePath + + * composerId + prompt text). The bubbleId is NOT used in the signature + * because the prompt text is already content-addressed; including the + * bubbleId would cause a re-fire if Cursor ever rewrites the value + * (which it does — e.g., when `tokenCount` is updated post-response). + * + * **Why this extractor was added (M2 closeout 2026-05-25):** the user's + * manual S01 test run captured 0 prompts despite typing 15 prompts in + * Cursor. Investigation traced the prompts to `cursorDiskKV` in + * globalStorage (not the workspaceStorage paths the watcher was + * monitoring). The Composer/Agent panel is Cursor's default modern UX; + * without this extractor the extension is effectively blind to the + * majority of real-world prompts. Closes the F2 gap in dev plan §2.10. + */ + +const KEY_PREFIX = 'cursorDiskKV/bubbleId:'; + +interface ComposerBubbleValue { + /** Schema version. Currently 3 in Cursor 3.4.20. */ + _v?: number; + /** 1 = user bubble (a prompt), 2 = assistant bubble (a response). */ + type?: number; + /** UUID of the bubble itself. Same as the second UUID in the key. */ + bubbleId?: string; + /** The user prompt text. Empty / missing for assistant bubbles. */ + text?: string; + /** ISO timestamp; not used for capture but useful for future telemetry. */ + createdAt?: string; +} + +/** + * Extract the composerId from a key of the form + * `cursorDiskKV/bubbleId::`. Returns null when the + * key doesn't match the expected shape (defensive — we never reach + * `decodeRow` without `ownsKey` having returned true, but a key that + * `startsWith` the prefix could still be missing the second colon). + */ +function parseComposerId(key: string): string | null { + const rest = key.slice(KEY_PREFIX.length); + const colonIdx = rest.indexOf(':'); + if (colonIdx <= 0) return null; + return rest.slice(0, colonIdx); +} + +export const cursorComposerBubble: ChatHistoryExtractor = { + id: 'cursor-composer-bubble', + label: 'Cursor Composer / Agent mode (cursorDiskKV bubbles)', + fingerprintKeys: [KEY_PREFIX] as const, + + ownsKey(key: string): boolean { + return key.startsWith(KEY_PREFIX); + }, + + decodeRow(row: ItemTableRow, sourcePath: string): ChatHistoryEvent[] { + if (!row.key.startsWith(KEY_PREFIX)) return []; + + const composerId = parseComposerId(row.key); + if (composerId === null) return []; + + let parsed: ComposerBubbleValue; + try { + parsed = JSON.parse(row.value) as ComposerBubbleValue; + } catch { + return []; + } + + // Only user-type bubbles produce capture events. type=2 is the + // assistant; type=undefined / other values are skipped defensively. + if (parsed.type !== 1) return []; + + const text = typeof parsed.text === 'string' ? parsed.text.trim() : ''; + if (text.length === 0) return []; + + return [ + { + prompt: text, + rawSessionId: composerId, + capturedAt: new Date(), + sourcePath, + extractorId: 'cursor-composer-bubble', + }, + ]; + }, +}; diff --git a/src/ext-vscode/src/extractors/cursor-v2024-q4.test.ts b/src/ext-vscode/src/extractors/cursor-v2024-q4.test.ts new file mode 100644 index 00000000..25298f02 --- /dev/null +++ b/src/ext-vscode/src/extractors/cursor-v2024-q4.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect } from 'vitest'; +import { cursorV2024Q4 } from './cursor-v2024-q4.js'; +import type { ItemTableRow } from '../chat-history-types.js'; + +const SRC = '/fake/state.vscdb'; + +describe('cursorV2024Q4 extractor', () => { + describe('static fields', () => { + it('has the expected id, label, and fingerprint keys', () => { + expect(cursorV2024Q4.id).toBe('cursor-v2024-q4'); + expect(cursorV2024Q4.label).toContain('v2024-Q4'); + expect(cursorV2024Q4.fingerprintKeys).toEqual(['aiService.prompts']); + }); + }); + + describe('ownsKey', () => { + it('matches exactly aiService.prompts', () => { + expect(cursorV2024Q4.ownsKey('aiService.prompts')).toBe(true); + }); + it('does not match other keys', () => { + expect(cursorV2024Q4.ownsKey('aiService.generations')).toBe(false); + expect(cursorV2024Q4.ownsKey('aiService.prompts.extra')).toBe(false); + expect(cursorV2024Q4.ownsKey('')).toBe(false); + }); + }); + + describe('decodeRow', () => { + const row = (value: unknown): ItemTableRow => ({ + key: 'aiService.prompts', + value: JSON.stringify(value), + }); + + it('returns one event per prompt entry with text', () => { + const events = cursorV2024Q4.decodeRow( + row([{ text: 'first' }, { text: 'second' }, { text: 'third' }]), + SRC, + ); + expect(events).toHaveLength(3); + expect(events.map((e) => e.prompt)).toEqual(['first', 'second', 'third']); + expect(events.every((e) => e.extractorId === 'cursor-v2024-q4')).toBe(true); + expect(events.every((e) => e.sourcePath === SRC)).toBe(true); + }); + + it("uses a stable 'ask-mode' rawSessionId for every prompt (NOT positional index)", () => { + // Regression for the M2 R3 FIFO-shift bug (2026-05-20): the previous + // positional `prompts-index:${i}` rawSessionId caused every Cursor + // restart with a full FIFO to re-emit the entire backlog because + // the dedup signature shifted when a new prompt pushed out the + // oldest. Content-stable rawSessionId means the signature depends + // only on (sourcePath, prompt text), which survives FIFO shifts. + const events = cursorV2024Q4.decodeRow(row([{ text: 'a' }, { text: 'b' }]), SRC); + expect(events.map((e) => e.rawSessionId)).toEqual(['ask-mode', 'ask-mode']); + }); + + it('FIFO-shift regression: same prompts in shifted positions produce the SAME (sourcePath,text)-stable signature', () => { + // Simulates the live M2 R3 scenario: an Ask-mode FIFO of 10 prompts + // shifts left by 1 (oldest dropped, new appended). The text-stable + // rawSessionId means each individual prompt's downstream watcher + // signature `${sourcePath}|${rawSessionId}|${prompt}` is unchanged + // by the shift, so the watcher's seenSignatures dedup correctly + // recognises them as already-seen. + const before = cursorV2024Q4.decodeRow( + row(Array.from({ length: 10 }, (_, i) => ({ text: `prompt-${i}` }))), + SRC, + ); + // FIFO shift: drop prompt-0, append prompt-10. + const after = cursorV2024Q4.decodeRow( + row([ + ...Array.from({ length: 9 }, (_, i) => ({ text: `prompt-${i + 1}` })), + { text: 'prompt-10' }, + ]), + SRC, + ); + // Build the signature for each event the way chat-history-watcher does. + const sigOf = (e: { sourcePath: string; rawSessionId: string; prompt: string }) => + `${e.sourcePath}|${e.rawSessionId}|${e.prompt}`; + const beforeSigs = new Set(before.map(sigOf)); + const afterSigs = after.map(sigOf); + // 9 of the 10 post-shift prompts must hit the dedup set; the new + // tail (`prompt-10`) is the only one that's genuinely new. + const newOnes = afterSigs.filter((s) => !beforeSigs.has(s)); + expect(newOnes).toHaveLength(1); + expect(newOnes[0]).toContain('prompt-10'); + }); + + it('skips entries with no text field', () => { + const events = cursorV2024Q4.decodeRow( + row([{ text: 'ok' }, { commandType: 1 }, { text: '' }, { text: 'still-ok' }]), + SRC, + ); + expect(events.map((e) => e.prompt)).toEqual(['ok', 'still-ok']); + }); + + it('returns [] for foreign keys', () => { + const events = cursorV2024Q4.decodeRow( + { key: 'someOtherKey', value: '[]' }, + SRC, + ); + expect(events).toEqual([]); + }); + + it('returns [] for malformed JSON', () => { + const events = cursorV2024Q4.decodeRow( + { key: 'aiService.prompts', value: 'not-json{{{' }, + SRC, + ); + expect(events).toEqual([]); + }); + + it('returns [] for non-array JSON', () => { + expect(cursorV2024Q4.decodeRow(row({}), SRC)).toEqual([]); + expect(cursorV2024Q4.decodeRow(row(null), SRC)).toEqual([]); + expect(cursorV2024Q4.decodeRow(row('string'), SRC)).toEqual([]); + }); + }); +}); diff --git a/src/ext-vscode/src/extractors/cursor-v2024-q4.ts b/src/ext-vscode/src/extractors/cursor-v2024-q4.ts new file mode 100644 index 00000000..6720c645 --- /dev/null +++ b/src/ext-vscode/src/extractors/cursor-v2024-q4.ts @@ -0,0 +1,80 @@ +import type { + ChatHistoryEvent, + ChatHistoryExtractor, + ItemTableRow, +} from '../chat-history-types.js'; + +/** + * Cursor v2024-Q4 (approx. Cursor versions ~0.30–0.40, pre-Composer release). + * + * Community-documented key: `aiService.prompts` — a single JSON array of all + * prompts globally (not per-tab). Each entry has at minimum a `text` field; + * older entries lack any tab/session identifier. + * + * **`rawSessionId` is a content-stable constant (`ask-mode`), NOT the + * entry's array index.** Live-tested on Cursor 3.4.20 during M2 R3 manual + * testing (2026-05-20): the `aiService.prompts` array is a rolling FIFO + * (capacity ~10). When a new prompt is submitted, the oldest is dropped + * and ALL subsequent prompts shift left by one. Using the positional + * index as the session id meant the dedup signature + * (`sourcePath|rawSessionId|prompt`) changed for every existing prompt + * after each FIFO shift, causing every Cursor restart to re-emit the + * entire backlog. Switching to a stable session id makes the signature + * content-driven (sourcePath + prompt text), so a shifted array is + * correctly recognised as containing the same prompts. + * + * Trade-off: if a user submits the *exact same text* twice within the + * FIFO window, only the first emit fires. Layer C's session state will + * undercount by that one prompt. Acceptable because (a) byte-identical + * re-submission is rare in practice, (b) Layer C tolerates undercount + * gracefully (advisory gating still functions), and (c) it's strictly + * better than the FIFO-shift flood it replaces. + */ + +const PROMPTS_KEY = 'aiService.prompts'; + +interface CursorV2024Q4PromptEntry { + /** The prompt text the user submitted. */ + text?: string; + /** Optional command-type enum (refactor, edit, ask, etc.). Not used for capture. */ + commandType?: number; + /** Optional timestamp; not currently used. */ + timestamp?: number; +} + +export const cursorV2024Q4: ChatHistoryExtractor = { + id: 'cursor-v2024-q4', + label: 'Cursor v2024-Q4 (pre-Composer)', + fingerprintKeys: [PROMPTS_KEY] as const, + + ownsKey(key: string): boolean { + return key === PROMPTS_KEY; + }, + + decodeRow(row: ItemTableRow, sourcePath: string): ChatHistoryEvent[] { + if (row.key !== PROMPTS_KEY) return []; + let parsed: unknown; + try { + parsed = JSON.parse(row.value); + } catch { + return []; + } + if (!Array.isArray(parsed)) return []; + + const events: ChatHistoryEvent[] = []; + const entries = parsed as CursorV2024Q4PromptEntry[]; + for (const entry of entries) { + if (!entry || typeof entry.text !== 'string' || entry.text.length === 0) { + continue; + } + events.push({ + prompt: entry.text, + rawSessionId: 'ask-mode', + capturedAt: new Date(), + sourcePath, + extractorId: 'cursor-v2024-q4', + }); + } + return events; + }, +}; diff --git a/src/ext-vscode/src/extractors/cursor-v2025-q1.test.ts b/src/ext-vscode/src/extractors/cursor-v2025-q1.test.ts new file mode 100644 index 00000000..30703d58 --- /dev/null +++ b/src/ext-vscode/src/extractors/cursor-v2025-q1.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect } from 'vitest'; +import { cursorV2025Q1 } from './cursor-v2025-q1.js'; +import type { ItemTableRow } from '../chat-history-types.js'; + +const SRC = '/fake/state.vscdb'; +const KEY = 'composer.composerData'; + +const wrap = (composerData: unknown): ItemTableRow => ({ + key: KEY, + value: JSON.stringify(composerData), +}); + +describe('cursorV2025Q1 extractor', () => { + describe('static fields', () => { + it('has the expected id, label, and fingerprint keys', () => { + expect(cursorV2025Q1.id).toBe('cursor-v2025-q1'); + expect(cursorV2025Q1.label).toContain('Composer'); + expect(cursorV2025Q1.fingerprintKeys).toEqual([KEY]); + }); + }); + + describe('ownsKey', () => { + it('matches exactly composer.composerData', () => { + expect(cursorV2025Q1.ownsKey(KEY)).toBe(true); + }); + it('does not match other keys', () => { + expect(cursorV2025Q1.ownsKey('aiService.prompts')).toBe(false); + expect(cursorV2025Q1.ownsKey('composer.other')).toBe(false); + expect(cursorV2025Q1.ownsKey('composerData.composerData')).toBe(false); + }); + }); + + describe('decodeRow', () => { + it('extracts user messages with role=user, ignoring assistant messages', () => { + const events = cursorV2025Q1.decodeRow( + wrap({ + allComposers: { + c1: { + composerId: 'c1', + conversation: [ + { role: 'user', text: 'hello' }, + { role: 'assistant', text: 'hi' }, + { role: 'user', text: 'follow-up' }, + ], + }, + }, + }), + SRC, + ); + expect(events.map((e) => e.prompt)).toEqual(['hello', 'follow-up']); + expect(events.every((e) => e.rawSessionId === 'composer:c1')).toBe(true); + expect(events.every((e) => e.extractorId === 'cursor-v2025-q1')).toBe(true); + }); + + it('supports legacy numeric type=1 for user messages', () => { + const events = cursorV2025Q1.decodeRow( + wrap({ + allComposers: { + c2: { + composerId: 'c2', + conversation: [ + { type: 1, text: 'legacy-user' }, + { type: 2, text: 'legacy-assistant' }, + ], + }, + }, + }), + SRC, + ); + expect(events).toHaveLength(1); + expect(events[0]!.prompt).toBe('legacy-user'); + }); + + it('supports the `content` field as fallback when `text` is missing', () => { + const events = cursorV2025Q1.decodeRow( + wrap({ + allComposers: { + c3: { + composerId: 'c3', + conversation: [{ role: 'user', content: 'from-content' }], + }, + }, + }), + SRC, + ); + expect(events[0]!.prompt).toBe('from-content'); + }); + + it('emits events per composer when multiple conversations exist', () => { + const events = cursorV2025Q1.decodeRow( + wrap({ + allComposers: { + a: { composerId: 'a', conversation: [{ role: 'user', text: 'from-a' }] }, + b: { composerId: 'b', conversation: [{ role: 'user', text: 'from-b' }] }, + }, + }), + SRC, + ); + const sessions = events.map((e) => e.rawSessionId).sort(); + expect(sessions).toEqual(['composer:a', 'composer:b']); + }); + + it('returns [] for malformed JSON', () => { + expect( + cursorV2025Q1.decodeRow({ key: KEY, value: 'not-json' }, SRC), + ).toEqual([]); + }); + + it('returns [] when allComposers is missing or empty', () => { + expect(cursorV2025Q1.decodeRow(wrap({}), SRC)).toEqual([]); + expect(cursorV2025Q1.decodeRow(wrap({ allComposers: {} }), SRC)).toEqual([]); + }); + + it('returns [] for foreign keys', () => { + expect( + cursorV2025Q1.decodeRow({ key: 'unrelated', value: '{}' }, SRC), + ).toEqual([]); + }); + }); +}); diff --git a/src/ext-vscode/src/extractors/cursor-v2025-q1.ts b/src/ext-vscode/src/extractors/cursor-v2025-q1.ts new file mode 100644 index 00000000..e74d6373 --- /dev/null +++ b/src/ext-vscode/src/extractors/cursor-v2025-q1.ts @@ -0,0 +1,113 @@ +import type { + ChatHistoryEvent, + ChatHistoryExtractor, + ItemTableRow, +} from '../chat-history-types.js'; + +/** + * Cursor v2025-Q1 (Composer era). + * + * **Real-machine update (2026-05-15, Cursor 3.4.20):** The fingerprint key + * is `composer.composerData` (NOT `composerData.composerData` as the + * original community docs suggested). Confirmed via + * `scripts/dump-cursor-state.ts` against a live Cursor 3.4.20 workspace DB. + * + * **Open finding:** the value at `composer.composerData` is *metadata only* + * on Cursor 3.4.20: + * `{ selectedComposerIds: string[], lastFocusedComposerIds: string[], + * hasMigratedComposerData: boolean, hasMigratedMultipleComposers: boolean }` + * — it does **not** contain conversation messages. Where modern Composer + * messages are actually stored is **TBD** (the workspace's `cursorDiskKV` + * table was empty on a chat-less DB; messages may land there once a real + * chat occurs, or in a separate file outside SQLite). + * + * The `allComposers / conversation` shape this extractor parses for is + * believed to be correct for an OLDER Composer-era format (~0.45–0.49). On + * a Cursor 3.4.20 DB this extractor will fingerprint-match the key but + * `decodeRow` will return [] because the expected `allComposers` field is + * absent. That's safe — fingerprint matches the modern key, decode falls + * through cleanly, and a follow-up fixture-driven update can refine the + * decode logic once we have a snapshot of a workspace DB with real chats. + * + * TODO(M2/B2): re-run `dump-cursor-state.ts` after submitting a real + * Composer-mode prompt in Cursor and inspect which row(s) the message + * landed in. Update this extractor (or add a sibling) accordingly before + * Branch 6 ships. + */ + +const COMPOSER_KEY = 'composer.composerData'; + +interface ComposerConversation { + /** Composer / tab identifier — used as `rawSessionId`. */ + composerId?: string; + conversation?: ComposerMessage[]; +} + +interface ComposerMessage { + /** Numeric form: 1 = user, 2 = assistant. */ + type?: number | string; + /** String form: 'user' / 'assistant'. */ + role?: string; + /** Either `text` or `content` depending on minor version. */ + text?: string; + content?: string; +} + +interface ComposerData { + allComposers?: Record; +} + +function isUserMessage(msg: ComposerMessage): boolean { + return ( + msg.role === 'user' || + msg.type === 'user' || + msg.type === 1 + ); +} + +function extractText(msg: ComposerMessage): string | undefined { + if (typeof msg.text === 'string' && msg.text.length > 0) return msg.text; + if (typeof msg.content === 'string' && msg.content.length > 0) return msg.content; + return undefined; +} + +export const cursorV2025Q1: ChatHistoryExtractor = { + id: 'cursor-v2025-q1', + label: 'Cursor v2025-Q1 (Composer)', + fingerprintKeys: [COMPOSER_KEY] as const, + + ownsKey(key: string): boolean { + return key === COMPOSER_KEY; + }, + + decodeRow(row: ItemTableRow, sourcePath: string): ChatHistoryEvent[] { + if (row.key !== COMPOSER_KEY) return []; + let parsed: ComposerData; + try { + parsed = JSON.parse(row.value) as ComposerData; + } catch { + return []; + } + if (!parsed || typeof parsed !== 'object') return []; + + const events: ChatHistoryEvent[] = []; + const composers = parsed.allComposers ?? {}; + for (const conv of Object.values(composers)) { + if (!conv || !Array.isArray(conv.conversation)) continue; + const composerId = conv.composerId ?? 'unknown'; + for (const msg of conv.conversation) { + if (!isUserMessage(msg)) continue; + const text = extractText(msg); + if (!text) continue; + events.push({ + prompt: text, + rawSessionId: `composer:${composerId}`, + capturedAt: new Date(), + sourcePath, + extractorId: 'cursor-v2025-q1', + }); + } + } + return events; + }, +}; diff --git a/src/ext-vscode/src/extractors/cursor-v2025-q2.test.ts b/src/ext-vscode/src/extractors/cursor-v2025-q2.test.ts new file mode 100644 index 00000000..42e74aec --- /dev/null +++ b/src/ext-vscode/src/extractors/cursor-v2025-q2.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect } from 'vitest'; +import { cursorV2025Q2 } from './cursor-v2025-q2.js'; +import type { ItemTableRow } from '../chat-history-types.js'; + +const SRC = '/fake/state.vscdb'; +const KEY_PREFIX = 'cursorAIChatService.chatHistory.'; + +const wrap = (tabId: string, messages: unknown): ItemTableRow => ({ + key: `${KEY_PREFIX}${tabId}`, + value: JSON.stringify(messages), +}); + +describe('cursorV2025Q2 extractor', () => { + describe('static fields', () => { + it('has the expected id, label, and prefix fingerprint', () => { + expect(cursorV2025Q2.id).toBe('cursor-v2025-q2'); + expect(cursorV2025Q2.label).toContain('v2025-Q2'); + expect(cursorV2025Q2.fingerprintKeys).toEqual([KEY_PREFIX]); + }); + }); + + describe('ownsKey', () => { + it('matches keys with the chatHistory prefix', () => { + expect(cursorV2025Q2.ownsKey(`${KEY_PREFIX}abc`)).toBe(true); + expect(cursorV2025Q2.ownsKey(`${KEY_PREFIX}tab-xyz-123`)).toBe(true); + }); + it('does not match other keys', () => { + expect(cursorV2025Q2.ownsKey('cursorAIChatService.other')).toBe(false); + expect(cursorV2025Q2.ownsKey('aiService.prompts')).toBe(false); + }); + }); + + describe('decodeRow', () => { + it('extracts role=user messages and tags rawSessionId with the tab id', () => { + const events = cursorV2025Q2.decodeRow( + wrap('tab-1', [ + { role: 'user', content: 'first' }, + { role: 'assistant', content: 'reply' }, + { role: 'user', content: 'second' }, + ]), + SRC, + ); + expect(events.map((e) => e.prompt)).toEqual(['first', 'second']); + expect(events.every((e) => e.rawSessionId === 'tab:tab-1')).toBe(true); + expect(events.every((e) => e.extractorId === 'cursor-v2025-q2')).toBe(true); + }); + + it('uses `text` as fallback when `content` is missing', () => { + const events = cursorV2025Q2.decodeRow( + wrap('tab-2', [{ role: 'user', text: 'from-text' }]), + SRC, + ); + expect(events[0]!.prompt).toBe('from-text'); + }); + + it('treats type="user" as a user message', () => { + const events = cursorV2025Q2.decodeRow( + wrap('tab-3', [ + { type: 'user', content: 'typed-user' }, + { type: 'assistant', content: 'typed-asst' }, + ]), + SRC, + ); + expect(events.map((e) => e.prompt)).toEqual(['typed-user']); + }); + + it('treats legacy numeric type=1 as a user message', () => { + const events = cursorV2025Q2.decodeRow( + wrap('tab-4', [ + { type: 1, content: 'legacy-user' }, + { type: 2, content: 'legacy-asst' }, + ]), + SRC, + ); + expect(events.map((e) => e.prompt)).toEqual(['legacy-user']); + }); + + it('returns [] for foreign keys', () => { + const events = cursorV2025Q2.decodeRow( + { key: 'some.other.key', value: '[]' }, + SRC, + ); + expect(events).toEqual([]); + }); + + it('returns [] for malformed JSON', () => { + expect( + cursorV2025Q2.decodeRow( + { key: `${KEY_PREFIX}tab-x`, value: 'not-json {{' }, + SRC, + ), + ).toEqual([]); + }); + + it('returns [] for non-array JSON', () => { + expect(cursorV2025Q2.decodeRow(wrap('tab-z', {}), SRC)).toEqual([]); + expect(cursorV2025Q2.decodeRow(wrap('tab-z', null), SRC)).toEqual([]); + }); + + it('skips messages without extractable text', () => { + const events = cursorV2025Q2.decodeRow( + wrap('tab-5', [ + { role: 'user', content: '' }, + { role: 'user' }, + { role: 'user', content: 'kept' }, + ]), + SRC, + ); + expect(events.map((e) => e.prompt)).toEqual(['kept']); + }); + }); +}); diff --git a/src/ext-vscode/src/extractors/cursor-v2025-q2.ts b/src/ext-vscode/src/extractors/cursor-v2025-q2.ts new file mode 100644 index 00000000..39b4f3f9 --- /dev/null +++ b/src/ext-vscode/src/extractors/cursor-v2025-q2.ts @@ -0,0 +1,94 @@ +import type { + ChatHistoryEvent, + ChatHistoryExtractor, + ItemTableRow, +} from '../chat-history-types.js'; + +/** + * Cursor v2025-Q2+ — community-documented per-tab chat history layout. + * + * **Real-machine status (2026-05-15, Cursor 3.4.20):** the prefix + * `cursorAIChatService.chatHistory.` was **NOT** observed in a real + * Cursor 3.4.20 workspace DB. This extractor's fingerprint will only + * match if a Cursor version that does use this key prefix is encountered. + * + * **What we DID see on Cursor 3.4.20** (per + * `scripts/dump-cursor-state.ts` inspection): + * - `aiService.prompts` / `aiService.generations` — covered by the + * `cursor-v2024-q4` extractor. + * - `composer.composerData` — metadata only; covered by + * `cursor-v2025-q1`. + * - No `cursorAIChatService.*` keys at all. + * The Composer-mode message storage location for Cursor 3.4.20 is still + * unknown — likely in `cursorDiskKV` once chats happen, or in a separate + * file. The dump script captures both tables and all chat-prefix keys, so + * a follow-up commit will refine extractors once a chat-bearing snapshot + * arrives. + * + * TODO(M2/B2): either (a) confirm the `cursorAIChatService.chatHistory.` + * prefix in some older Cursor version (and keep this extractor as + * version-pinned), or (b) replace it with a `cursor-v3-x` extractor + * matching Cursor 3.4.20's actual message storage once it's been + * snapshotted. + */ + +const KEY_PREFIX = 'cursorAIChatService.chatHistory.'; + +interface ChatMessage { + role?: string; + type?: string | number; + content?: string; + text?: string; +} + +function isUserMessage(msg: ChatMessage): boolean { + return ( + msg.role === 'user' || + msg.type === 'user' || + msg.type === 1 /* legacy numeric */ + ); +} + +function extractText(msg: ChatMessage): string | undefined { + if (typeof msg.content === 'string' && msg.content.length > 0) return msg.content; + if (typeof msg.text === 'string' && msg.text.length > 0) return msg.text; + return undefined; +} + +export const cursorV2025Q2: ChatHistoryExtractor = { + id: 'cursor-v2025-q2', + label: 'Cursor v2025-Q2+ (per-tab chat history)', + fingerprintKeys: [KEY_PREFIX] as const, + + ownsKey(key: string): boolean { + return key.startsWith(KEY_PREFIX); + }, + + decodeRow(row: ItemTableRow, sourcePath: string): ChatHistoryEvent[] { + if (!row.key.startsWith(KEY_PREFIX)) return []; + const tabId = row.key.slice(KEY_PREFIX.length); + + let parsed: unknown; + try { + parsed = JSON.parse(row.value); + } catch { + return []; + } + if (!Array.isArray(parsed)) return []; + + const events: ChatHistoryEvent[] = []; + for (const msg of parsed as ChatMessage[]) { + if (!msg || !isUserMessage(msg)) continue; + const text = extractText(msg); + if (!text) continue; + events.push({ + prompt: text, + rawSessionId: `tab:${tabId}`, + capturedAt: new Date(), + sourcePath, + extractorId: 'cursor-v2025-q2', + }); + } + return events; + }, +}; diff --git a/src/ext-vscode/src/extractors/index.test.ts b/src/ext-vscode/src/extractors/index.test.ts new file mode 100644 index 00000000..e38974bf --- /dev/null +++ b/src/ext-vscode/src/extractors/index.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect } from 'vitest'; +import { + ALL_EXTRACTORS, + getExtractorById, + pickExtractor, +} from './index.js'; + +describe('extractor registry', () => { + it('lists all extractors in newest-first order, composer-bubble first (cursorDiskKV-table-owner)', () => { + expect(ALL_EXTRACTORS.map((e) => e.id)).toEqual([ + 'cursor-composer-bubble', + 'cursor-v2025-q2', + 'cursor-v2025-q1', + 'cursor-v2024-q4', + 'windsurf', + ]); + }); + + it('getExtractorById returns the matching extractor', () => { + expect(getExtractorById('cursor-v2025-q2')?.id).toBe('cursor-v2025-q2'); + expect(getExtractorById('cursor-composer-bubble')?.id).toBe( + 'cursor-composer-bubble', + ); + expect(getExtractorById('windsurf')?.id).toBe('windsurf'); + }); + + it('getExtractorById returns undefined for unknown ids', () => { + expect(getExtractorById('nonexistent')).toBeUndefined(); + }); +}); + +describe('pickExtractor (M4 fingerprint)', () => { + it('returns "unknown" for an empty key list', () => { + const result = pickExtractor([]); + expect(result.kind).toBe('unknown'); + if (result.kind === 'unknown') { + expect(result.observedKeyCount).toBe(0); + expect(result.observedSampleKeys).toEqual([]); + } + }); + + it('returns "unknown" when no keys match any extractor', () => { + const result = pickExtractor(['unrelated.key.1', 'another.thing', 'foo.bar']); + expect(result.kind).toBe('unknown'); + if (result.kind === 'unknown') { + expect(result.observedKeyCount).toBe(3); + expect(result.observedSampleKeys).toEqual([ + 'unrelated.key.1', + 'another.thing', + 'foo.bar', + ]); + } + }); + + it('caps observedSampleKeys to 5 entries', () => { + const result = pickExtractor(['a', 'b', 'c', 'd', 'e', 'f', 'g']); + if (result.kind === 'unknown') { + expect(result.observedSampleKeys).toHaveLength(5); + } + }); + + it('picks cursor-v2024-q4 when aiService.prompts is the matching key', () => { + const result = pickExtractor([ + 'aiService.prompts', + 'workbench.editor.recent', + 'foo.bar', + ]); + expect(result.kind).toBe('known'); + if (result.kind === 'known') { + expect(result.extractor.id).toBe('cursor-v2024-q4'); + expect(result.matchedKeys).toEqual(['aiService.prompts']); + } + }); + + it('picks cursor-v2025-q1 when composer.composerData is present', () => { + const result = pickExtractor(['composer.composerData', 'other.key']); + expect(result.kind).toBe('known'); + if (result.kind === 'known') { + expect(result.extractor.id).toBe('cursor-v2025-q1'); + } + }); + + it('picks cursor-v2025-q2 when chatHistory keys are present (prefix match)', () => { + const result = pickExtractor([ + 'cursorAIChatService.chatHistory.tab-1', + 'cursorAIChatService.chatHistory.tab-2', + 'workbench.recent.files', + ]); + expect(result.kind).toBe('known'); + if (result.kind === 'known') { + expect(result.extractor.id).toBe('cursor-v2025-q2'); + expect(result.matchedKeys).toEqual([ + 'cursorAIChatService.chatHistory.tab-1', + 'cursorAIChatService.chatHistory.tab-2', + ]); + } + }); + + it('picks the extractor with the highest match count when multiple match', () => { + // q2 has 3 chatHistory matches; q4 has 1 prompts match — q2 should win. + const result = pickExtractor([ + 'aiService.prompts', + 'cursorAIChatService.chatHistory.t1', + 'cursorAIChatService.chatHistory.t2', + 'cursorAIChatService.chatHistory.t3', + ]); + expect(result.kind).toBe('known'); + if (result.kind === 'known') { + expect(result.extractor.id).toBe('cursor-v2025-q2'); + } + }); + + it('picks the newer extractor on a tie (registry order)', () => { + // q4 has 1 match (aiService.prompts); q1 has 1 match (composerData) + // — q1 wins because it appears earlier in ALL_EXTRACTORS. + const result = pickExtractor([ + 'aiService.prompts', + 'composer.composerData', + ]); + expect(result.kind).toBe('known'); + if (result.kind === 'known') { + expect(result.extractor.id).toBe('cursor-v2025-q1'); + } + }); + + it('picks windsurf when only cascade keys are present', () => { + const result = pickExtractor(['cascade.history', 'cascade.settings']); + expect(result.kind).toBe('known'); + if (result.kind === 'known') { + expect(result.extractor.id).toBe('windsurf'); + } + }); +}); diff --git a/src/ext-vscode/src/extractors/index.ts b/src/ext-vscode/src/extractors/index.ts new file mode 100644 index 00000000..9820c36d --- /dev/null +++ b/src/ext-vscode/src/extractors/index.ts @@ -0,0 +1,81 @@ +import type { + ChatHistoryExtractor, + FingerprintResult, +} from '../chat-history-types.js'; +import { cursorComposerBubble } from './cursor-composer-bubble.js'; +import { cursorV2024Q4 } from './cursor-v2024-q4.js'; +import { cursorV2025Q1 } from './cursor-v2025-q1.js'; +import { cursorV2025Q2 } from './cursor-v2025-q2.js'; +import { windsurf } from './windsurf.js'; + +/** + * Registry of all known per-version chat-history extractors. Ordered + * newest-first so that on a tie in fingerprint match count the latest + * version wins (see `pickExtractor`). cursor-composer-bubble is listed + * first because it's Cursor's current default chat mode (Composer / + * Agent) and the only one that owns rows from the `cursorDiskKV` table + * (everything else owns rows from `ItemTable`). + */ +export const ALL_EXTRACTORS: readonly ChatHistoryExtractor[] = [ + cursorComposerBubble, + cursorV2025Q2, + cursorV2025Q1, + cursorV2024Q4, + windsurf, +]; + +export function getExtractorById(id: string): ChatHistoryExtractor | undefined { + return ALL_EXTRACTORS.find((e) => e.id === id); +} + +/** + * Schema fingerprint detection (M4). + * + * Examine a set of observed ItemTable keys and pick the most-specific + * known extractor. If no extractor's `fingerprintKeys` (treated as + * prefixes) match any observed key, return a `kind: 'unknown'` result + * that the extension layer surfaces as a "schema unknown" toast. + * + * Matching strategy: + * - For each extractor, count the observed keys whose value starts with + * any of the extractor's `fingerprintKeys`. + * - The extractor with the highest match count wins. + * - Ties are broken by registry order — first listed wins (newest-first). + */ +export function pickExtractor( + observedKeys: readonly string[], +): FingerprintResult { + let best: { extractor: ChatHistoryExtractor; matches: string[] } | undefined; + + for (const extractor of ALL_EXTRACTORS) { + const matches = observedKeys.filter((k) => + extractor.fingerprintKeys.some((fp) => k.startsWith(fp)), + ); + if (matches.length === 0) continue; + if (!best || matches.length > best.matches.length) { + best = { extractor, matches }; + } + } + + if (best) { + return { + kind: 'known', + extractor: best.extractor, + matchedKeys: best.matches, + }; + } + + return { + kind: 'unknown', + observedKeyCount: observedKeys.length, + observedSampleKeys: observedKeys.slice(0, 5), + }; +} + +export { + cursorComposerBubble, + cursorV2024Q4, + cursorV2025Q1, + cursorV2025Q2, + windsurf, +}; diff --git a/src/ext-vscode/src/extractors/windsurf.test.ts b/src/ext-vscode/src/extractors/windsurf.test.ts new file mode 100644 index 00000000..9fe2c9d6 --- /dev/null +++ b/src/ext-vscode/src/extractors/windsurf.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from 'vitest'; +import { windsurf, decodeWindsurfJsonFile } from './windsurf.js'; + +describe('windsurf extractor (placeholder)', () => { + it('has the expected id, label, and fingerprint', () => { + expect(windsurf.id).toBe('windsurf'); + expect(windsurf.label).toContain('Cascade'); + expect(windsurf.fingerprintKeys).toEqual(['cascade.']); + }); + + it('matches cascade.* keys', () => { + expect(windsurf.ownsKey('cascade.history')).toBe(true); + expect(windsurf.ownsKey('cascade.something.else')).toBe(true); + }); + + it('does not match non-cascade keys', () => { + expect(windsurf.ownsKey('aiService.prompts')).toBe(false); + expect(windsurf.ownsKey('cursorAIChatService.chatHistory.tab-1')).toBe(false); + }); + + it('returns [] for any row (placeholder — real decoding lands in Branch 4)', () => { + expect(windsurf.decodeRow({ key: 'cascade.history', value: '[]' }, '/p')).toEqual([]); + expect(windsurf.decodeRow({ key: 'cascade.x', value: 'anything' }, '/p')).toEqual([]); + }); +}); + +describe('decodeWindsurfJsonFile (Drift A fix — JSON-file decoder contract)', () => { + // The body is currently a no-op pending live-install schema identification + // (see TODO in src/extractors/windsurf.ts). These tests lock the CONTRACT + // — return type + signature — so when the engineer fills in the real + // decoder, the watcher's expectations don't have to change. If/when the + // decoder gains real behaviour, ADD new tests; don't remove these. + + it('returns [] for an empty object (current stub behaviour)', () => { + expect(decodeWindsurfJsonFile({}, '/p/conv-1.json')).toEqual([]); + }); + + it('returns [] for null / undefined / non-object parsed values', () => { + expect(decodeWindsurfJsonFile(null, '/p/f.json')).toEqual([]); + expect(decodeWindsurfJsonFile(undefined, '/p/f.json')).toEqual([]); + expect(decodeWindsurfJsonFile('a string', '/p/f.json')).toEqual([]); + expect(decodeWindsurfJsonFile(42, '/p/f.json')).toEqual([]); + }); + + it('returns an array regardless of input shape (never throws on the stub path)', () => { + expect(Array.isArray(decodeWindsurfJsonFile({ conversation: [] }, '/p'))).toBe(true); + expect( + Array.isArray(decodeWindsurfJsonFile({ messages: [{ role: 'user', content: 'hi' }] }, '/p')), + ).toBe(true); + }); +}); diff --git a/src/ext-vscode/src/extractors/windsurf.ts b/src/ext-vscode/src/extractors/windsurf.ts new file mode 100644 index 00000000..ed420579 --- /dev/null +++ b/src/ext-vscode/src/extractors/windsurf.ts @@ -0,0 +1,79 @@ +import type { + ChatHistoryEvent, + ChatHistoryExtractor, + ItemTableRow, +} from '../chat-history-types.js'; + +/** + * Windsurf (Codeium Cascade). + * + * FINDING (2026-05-28, live Windsurf 2.0 / SWE-1.6 install on Linux): + * Cascade prompt capture via file-watching is INFEASIBLE, and Windsurf is + * therefore OUT OF SCOPE for v1 capture. Evidence from a live "what is 2 + 2" + * conversation: + * - Conversation CONTENT (the user's prompt + the reply) is written to + * `~/.codeium/windsurf/cascade/.pb`, ENCRYPTED at rest: + * Shannon entropy 8.00 bits/byte, not gzip/zlib/deflate, no recoverable + * strings, prompt text absent in plaintext or base64. + * - The only plaintext is session METADATA in the host's + * `globalStorage/state.vscdb` under `windsurf.acp.metadataCache` + * (`sessionId`, an LLM-generated `title`, `cwd`, timestamps, `status`) + * — NOT the raw prompt. A title is an LLM summary, one per session, with + * no per-prompt granularity; useless as Layer C input. + * - `state.vscdb` (workspaceStorage) holds zero chat content; + * `chat.ChatSessionStore.index` is empty. + * + * There is no readable prompt on disk for any decoder to consume. The + * `windsurf-dir` watch + the no-op decoders below are kept inert (host + * detection + watch wiring stay so a future non-encrypted path could slot in) + * but they will never emit on Windsurf 2.0. Re-capturing Windsurf would need a + * non-file-watching route (Codeium MCP/command/API, or decrypting the `.pb` + * store — high effort, brittle, ToS-sensitive). See architecture doc §5.3. + * + * `decodeRow` is a fingerprint stub for the (unobserved) case where a future + * Windsurf migrates chat to a VS Code-style ItemTable with `cascade.*` keys. + */ + +const CASCADE_KEY_PREFIX = 'cascade.'; + +export const windsurf: ChatHistoryExtractor = { + id: 'windsurf', + label: 'Windsurf (Cascade)', + fingerprintKeys: [CASCADE_KEY_PREFIX] as const, + + ownsKey(key: string): boolean { + return key.startsWith(CASCADE_KEY_PREFIX); + }, + + decodeRow(_row: ItemTableRow, _sourcePath: string): ChatHistoryEvent[] { + return []; + }, +}; + +/** + * Decode a single parsed Windsurf JSON file into zero or more chat events. + * + * Contract: + * - `parsed` is whatever `JSON.parse(fileContents)` returned. Callers + * handle malformed files BEFORE calling this — the function may assume + * a parseable value (still `unknown` because the shape is host-defined). + * - `sourcePath` is the absolute path to the .json file (forwarded into + * the `ChatHistoryEvent.sourcePath` field so dedup + diagnostics stay + * stable across re-reads of the same file). + * - Returns events with `extractorId === 'windsurf'`. `capturedAt` is + * left as `new Date(0)` here; the watcher overwrites it via its + * injected clock so test ordering stays deterministic. + * + * RESOLVED — do NOT try to implement this. The 2026-05-28 live inspection + * (see the module docstring above) showed Cascade conversations are stored + * encrypted (`~/.codeium/windsurf/cascade/.pb`, entropy 8.00) and + * the scanner's premise (plaintext top-level `*.json`) is wrong. No plaintext + * prompt exists on disk, so this stays a no-op and Windsurf capture is out of + * v1 scope. Kept only so the `windsurf-dir` watch dispatch has a target. + */ +export function decodeWindsurfJsonFile( + _parsed: unknown, + _sourcePath: string, +): ChatHistoryEvent[] { + return []; +} diff --git a/src/ext-vscode/src/host-detector.test.ts b/src/ext-vscode/src/host-detector.test.ts new file mode 100644 index 00000000..a8045eac --- /dev/null +++ b/src/ext-vscode/src/host-detector.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect, vi } from 'vitest'; + +vi.mock('vscode', () => ({ + env: { appName: 'Visual Studio Code' }, +})); + +import { + classifyHost, + detectHost, + chatHistoryBaseDir, + workspaceStorageDir, + windsurfCodeiumDir, +} from './host-detector.js'; + +describe('classifyHost', () => { + it('maps "Cursor" → cursor', () => { + expect(classifyHost('Cursor')).toBe('cursor'); + expect(classifyHost('Cursor 3.4.20')).toBe('cursor'); + }); + + it('maps "Windsurf" → windsurf', () => { + expect(classifyHost('Windsurf')).toBe('windsurf'); + expect(classifyHost('Windsurf 1.0')).toBe('windsurf'); + }); + + it('maps Visual Studio Code / Code / unknown → vscode-generic', () => { + expect(classifyHost('Visual Studio Code')).toBe('vscode-generic'); + expect(classifyHost('Code')).toBe('vscode-generic'); + expect(classifyHost('Code - Insiders')).toBe('vscode-generic'); + expect(classifyHost('Something Else')).toBe('vscode-generic'); + expect(classifyHost('')).toBe('vscode-generic'); + }); + + it('is case-insensitive', () => { + expect(classifyHost('CURSOR')).toBe('cursor'); + expect(classifyHost('cursor')).toBe('cursor'); + expect(classifyHost('WINDSURF')).toBe('windsurf'); + }); +}); + +describe('detectHost', () => { + it('reads from injected appName when provided', () => { + expect(detectHost({ appName: 'Cursor' })).toBe('cursor'); + expect(detectHost({ appName: 'Windsurf' })).toBe('windsurf'); + expect(detectHost({ appName: 'Visual Studio Code' })).toBe('vscode-generic'); + }); + + it('falls back to vscode.env.appName when no override (mocked: VS Code)', () => { + expect(detectHost()).toBe('vscode-generic'); + }); +}); + +describe('chatHistoryBaseDir', () => { + it('returns null for vscode-generic (no chat-history watching)', () => { + expect( + chatHistoryBaseDir({ host: 'vscode-generic', home: '/home/u', platform: 'linux' }), + ).toBeNull(); + }); + + it('cursor on linux → ~/.config/Cursor', () => { + expect( + chatHistoryBaseDir({ host: 'cursor', home: '/home/u', platform: 'linux' }), + ).toBe('/home/u/.config/Cursor'); + }); + + it('cursor on darwin → Library/Application Support', () => { + expect( + chatHistoryBaseDir({ host: 'cursor', home: '/Users/u', platform: 'darwin' }), + ).toBe('/Users/u/Library/Application Support/Cursor'); + }); + + it('cursor on win32 with APPDATA → uses it', () => { + expect( + chatHistoryBaseDir({ + host: 'cursor', + home: 'C:\\Users\\u', + platform: 'win32', + appdata: 'C:\\Users\\u\\AppData\\Roaming', + }), + ).toBe('C:\\Users\\u\\AppData\\Roaming/Cursor'); + }); + + it('windsurf paths mirror cursor paths', () => { + expect( + chatHistoryBaseDir({ host: 'windsurf', home: '/home/u', platform: 'linux' }), + ).toBe('/home/u/.config/Windsurf'); + expect( + chatHistoryBaseDir({ host: 'windsurf', home: '/Users/u', platform: 'darwin' }), + ).toBe('/Users/u/Library/Application Support/Windsurf'); + }); +}); + +describe('workspaceStorageDir', () => { + it('appends User/workspaceStorage to the host base dir', () => { + expect( + workspaceStorageDir({ host: 'cursor', home: '/home/u', platform: 'linux' }), + ).toBe('/home/u/.config/Cursor/User/workspaceStorage'); + }); + + it('returns null for vscode-generic (passes the base null through)', () => { + expect(workspaceStorageDir({ host: 'vscode-generic' })).toBeNull(); + }); +}); + +describe('windsurfCodeiumDir', () => { + it('joins ~/.codeium/windsurf for an explicit home', () => { + expect(windsurfCodeiumDir('/home/u')).toBe('/home/u/.codeium/windsurf'); + }); + + it('matches the windsurfAdapter codeiumCascadeDir convention (cross-file invariant)', () => { + // The CLI-side adapter at src/agents/adapters/windsurf.ts uses + // join(home, '.codeium', 'windsurf'). Locking the same shape here so + // a future rename on either side stays obvious. + expect(windsurfCodeiumDir('/Users/u')).toBe('/Users/u/.codeium/windsurf'); + expect(windsurfCodeiumDir('C:\\Users\\u')).toBe('C:\\Users\\u/.codeium/windsurf'); + }); + + it('defaults to os.homedir() when no argument provided', () => { + // Don't pin a literal value (runner-dependent); just assert structure. + const result = windsurfCodeiumDir(); + expect(result.endsWith('/.codeium/windsurf')).toBe(true); + }); +}); diff --git a/src/ext-vscode/src/host-detector.ts b/src/ext-vscode/src/host-detector.ts new file mode 100644 index 00000000..1d2899dd --- /dev/null +++ b/src/ext-vscode/src/host-detector.ts @@ -0,0 +1,112 @@ +import * as vscode from 'vscode'; +import { homedir, platform as osPlatform } from 'node:os'; +import { join } from 'node:path'; + +/** + * Host detection (B4 wiring). + * + * The same VS Code extension ships to Cursor, Windsurf, and plain VS Code + * via Open VSX. At runtime the extension needs to know which host it's + * inside so it can look up the correct `state.vscdb` paths (and, in + * Branch 4 of the prompt-injection work, which `vscode.commands.*` id to + * try for chat-input injection). + * + * VS Code's `vscode.env.appName` reports a host-specific string. Empirical + * values across host versions: + * - Plain VS Code: "Visual Studio Code" / "Code" + * - Cursor: "Cursor" + * - Windsurf: "Windsurf" + * + * We treat anything we don't recognise as a generic VS Code host (no + * special chat-history handling). + */ + +export type Host = 'cursor' | 'windsurf' | 'vscode-generic'; + +/** Pure mapping from `appName` to a recognised host id. */ +export function classifyHost(appName: string): Host { + const normalised = appName.toLowerCase(); + if (normalised.includes('cursor')) return 'cursor'; + if (normalised.includes('windsurf')) return 'windsurf'; + return 'vscode-generic'; +} + +/** + * Read the current host from the live `vscode.env.appName`. Injectable for + * tests via `deps.appName`. + */ +export function detectHost(deps: { appName?: string } = {}): Host { + return classifyHost(deps.appName ?? vscode.env.appName); +} + +/** + * Per-host base directories where `state.vscdb` is expected to live. + * + * Mirrors the CLI-side adapter logic in `src/agents/adapters/cursor.ts` + * and `src/agents/adapters/windsurf.ts` — the same OS rules apply, but + * the extension can resolve them itself without going through the + * adapter (extension and CLI are different processes). + */ +export interface HostStorageInputs { + host?: Host; + home?: string; + platform?: NodeJS.Platform; + appdata?: string; +} + +export function chatHistoryBaseDir(inputs: HostStorageInputs = {}): string | null { + const host = inputs.host ?? detectHost(); + const home = inputs.home ?? homedir(); + const platform = inputs.platform ?? osPlatform(); + + if (host === 'cursor') { + return hostConfigDir('Cursor', home, platform, inputs.appdata); + } + if (host === 'windsurf') { + return hostConfigDir('Windsurf', home, platform, inputs.appdata); + } + // Plain VS Code host: no chat-history watching (no AI chat panel to read). + return null; +} + +function hostConfigDir( + hostName: string, + home: string, + platform: NodeJS.Platform, + appdata?: string, +): string { + switch (platform) { + case 'darwin': + return join(home, 'Library', 'Application Support', hostName); + case 'win32': + return join( + appdata ?? process.env.APPDATA ?? join(home, 'AppData', 'Roaming'), + hostName, + ); + default: + return join(home, '.config', hostName); + } +} + +/** Returns the `User/workspaceStorage` directory (parent of per-workspace state.vscdb files). */ +export function workspaceStorageDir(inputs: HostStorageInputs = {}): string | null { + const base = chatHistoryBaseDir(inputs); + return base === null ? null : join(base, 'User', 'workspaceStorage'); +} + +/** + * Windsurf's legacy Codeium Cascade chat-data directory: `~/.codeium/windsurf`. + * + * Per dev plan §2.3 acceptance #2, the watcher monitors BOTH `state.vscdb` + * (under `workspaceStorageDir`) AND this directory for Windsurf. The + * directory holds per-session JSON files; the watcher dispatches it as a + * `windsurf-dir` `WatchTarget`. + * + * Mirrors `codeiumCascadeDir` in `src/agents/adapters/windsurf.ts`. Kept as + * a local copy because the sub-package's tsconfig rootDir prevents importing + * from the root `src/agents/` tree. The `marketplace-id.test.ts` pattern is + * the model for catching cross-file drift if either side moves. + */ +export function windsurfCodeiumDir(home: string = homedir()): string { + return join(home, '.codeium', 'windsurf'); +} diff --git a/src/ext-vscode/src/ipc.test.ts b/src/ext-vscode/src/ipc.test.ts new file mode 100644 index 00000000..685bd692 --- /dev/null +++ b/src/ext-vscode/src/ipc.test.ts @@ -0,0 +1,235 @@ +import { describe, it, expect, vi } from 'vitest'; +import { EventEmitter } from 'node:events'; +import { Readable, Writable } from 'node:stream'; +import { + spawnAuto, + spawnStop, + NexpathBinaryNotFoundError, + NexpathMalformedPayloadError, +} from './ipc.js'; + +interface FakeChildOptions { + stdoutChunks?: string[]; + stderrChunks?: string[]; + exitCode?: number; + errorBeforeClose?: Error; +} + +function makeFakeChild(opts: FakeChildOptions = {}) { + const child = new EventEmitter() as EventEmitter & { + stdin: Writable; + stdout: Readable; + stderr: Readable; + }; + child.stdin = new Writable({ + write(_chunk, _enc, cb) { + cb(); + }, + }); + child.stdout = new Readable({ read() {} }); + child.stderr = new Readable({ read() {} }); + + // `emit('data', ...)` synchronously invokes any registered listeners on the + // stream, which is what we need so the consumer's data handler has accumulated + // its buffer before we emit 'close'. `push()` buffers asynchronously and + // would deliver the data after 'close' fires. + queueMicrotask(() => { + if (opts.errorBeforeClose) { + child.emit('error', opts.errorBeforeClose); + return; + } + for (const c of opts.stdoutChunks ?? []) child.stdout.emit('data', Buffer.from(c)); + for (const c of opts.stderrChunks ?? []) child.stderr.emit('data', Buffer.from(c)); + child.emit('close', opts.exitCode ?? 0); + }); + + return child; +} + +describe('spawnAuto', () => { + it('resolves when the process exits with code 0', async () => { + const spawnFn = vi.fn(() => makeFakeChild({ exitCode: 0 })); + await expect( + spawnAuto('hi', 'sess-1', { spawnFn: spawnFn as never }), + ).resolves.toBeUndefined(); + expect(spawnFn).toHaveBeenCalledWith( + 'nexpath', + ['auto'], + expect.any(Object), + ); + }); + + it('includes --db argument when dbPath is provided', async () => { + const spawnFn = vi.fn(() => makeFakeChild({ exitCode: 0 })); + await spawnAuto('p', 's', { spawnFn: spawnFn as never, dbPath: '/tmp/x.db' }); + expect(spawnFn).toHaveBeenCalledWith( + 'nexpath', + ['auto', '--db', '/tmp/x.db'], + expect.any(Object), + ); + }); + + it('uses binaryPath override over default "nexpath"', async () => { + const spawnFn = vi.fn(() => makeFakeChild({ exitCode: 0 })); + await spawnAuto('p', 's', { + spawnFn: spawnFn as never, + binaryPath: '/usr/local/bin/nexpath', + }); + expect(spawnFn).toHaveBeenCalledWith( + '/usr/local/bin/nexpath', + ['auto'], + expect.any(Object), + ); + }); + + it('passes opts.cwd as the spawned process cwd (so Layer C resolves --project correctly)', async () => { + const spawnFn = vi.fn(() => makeFakeChild({ exitCode: 0 })); + await spawnAuto('p', 's', { + spawnFn: spawnFn as never, + cwd: '/some/workspace/path', + }); + const opts = spawnFn.mock.calls[0]![2] as { cwd?: string }; + expect(opts.cwd).toBe('/some/workspace/path'); + }); + + it('defaults the spawned process cwd to process.cwd() when opts.cwd is omitted', async () => { + const spawnFn = vi.fn(() => makeFakeChild({ exitCode: 0 })); + await spawnAuto('p', 's', { spawnFn: spawnFn as never }); + const opts = spawnFn.mock.calls[0]![2] as { cwd?: string }; + expect(opts.cwd).toBe(process.cwd()); + }); + + it('rejects with NexpathBinaryNotFoundError when the child emits error', async () => { + const enoent = Object.assign(new Error('spawn nexpath ENOENT'), { + code: 'ENOENT', + }); + const spawnFn = vi.fn(() => makeFakeChild({ errorBeforeClose: enoent })); + await expect( + spawnAuto('p', 's', { spawnFn: spawnFn as never }), + ).rejects.toBeInstanceOf(NexpathBinaryNotFoundError); + }); + + it('rejects with a clear message when the process exits non-zero', async () => { + const spawnFn = vi.fn(() => + makeFakeChild({ exitCode: 1, stderrChunks: ['bad stuff\n'] }), + ); + await expect( + spawnAuto('p', 's', { spawnFn: spawnFn as never }), + ).rejects.toThrow(/exited with code 1/); + }); +}); + +describe('spawnStop', () => { + it('parses a valid JSON payload from stdout', async () => { + const payload = { + advisory: 'be careful', + options: [{ id: 'a', label: 'Continue' }], + }; + const spawnFn = vi.fn(() => + makeFakeChild({ + stdoutChunks: [JSON.stringify(payload)], + exitCode: 0, + }), + ); + const result = await spawnStop('s', { spawnFn: spawnFn as never }); + expect(result).toEqual(payload); + }); + + it('resolves null when stdout is empty (no advisory generated)', async () => { + const spawnFn = vi.fn(() => makeFakeChild({ stdoutChunks: [], exitCode: 0 })); + const result = await spawnStop('s', { spawnFn: spawnFn as never }); + expect(result).toBeNull(); + }); + + it('resolves null when stdout is only whitespace', async () => { + const spawnFn = vi.fn(() => + makeFakeChild({ stdoutChunks: [' \n \t '], exitCode: 0 }), + ); + const result = await spawnStop('s', { spawnFn: spawnFn as never }); + expect(result).toBeNull(); + }); + + it('rejects with NexpathMalformedPayloadError when stdout is non-empty but not JSON', async () => { + const spawnFn = vi.fn(() => + makeFakeChild({ stdoutChunks: ['not json {{'], exitCode: 0 }), + ); + await expect( + spawnStop('s', { spawnFn: spawnFn as never }), + ).rejects.toBeInstanceOf(NexpathMalformedPayloadError); + }); + + it('rejects when nexpath stop exits non-zero', async () => { + const spawnFn = vi.fn(() => + makeFakeChild({ exitCode: 2, stderrChunks: ['boom\n'] }), + ); + await expect( + spawnStop('s', { spawnFn: spawnFn as never }), + ).rejects.toThrow(/exited with code 2/); + }); + + it('rejects with NexpathBinaryNotFoundError when spawn emits error', async () => { + const spawnFn = vi.fn(() => + makeFakeChild({ errorBeforeClose: new Error('nope') }), + ); + await expect( + spawnStop('s', { spawnFn: spawnFn as never }), + ).rejects.toBeInstanceOf(NexpathBinaryNotFoundError); + }); + + // ── Stdin payload contract — must match Layer C's StopPayload shape ────────── + // `src/cli/commands/stop.ts` lines 37-43 declare StopPayload requires + // `cwd`, `hook_event_name`, and `stop_hook_active` in addition to the + // optional `session_id`. We must send the full shape so Layer C's + // `runStop` works correctly. + + it('writes the full StopPayload shape to stdin (session_id + cwd + hook_event_name + stop_hook_active)', async () => { + let captured = ''; + const spawnFn = vi.fn(() => { + const c = makeFakeChild({ stdoutChunks: [''], exitCode: 0 }); + const origEnd = c.stdin.end.bind(c.stdin); + c.stdin.end = ((chunk: unknown, ...args: unknown[]) => { + if (chunk !== undefined) captured = String(chunk); + return origEnd(chunk as never, ...(args as never[])); + }) as typeof c.stdin.end; + return c; + }); + await spawnStop('sess-X', { + spawnFn: spawnFn as never, + cwd: '/workspace/abc', + }); + const parsed = JSON.parse(captured.trim()); + expect(parsed).toEqual({ + session_id: 'sess-X', + cwd: '/workspace/abc', + hook_event_name: 'Stop', + stop_hook_active: false, + }); + }); + + it('defaults the stdin cwd to process.cwd() when opts.cwd is omitted', async () => { + let captured = ''; + const spawnFn = vi.fn(() => { + const c = makeFakeChild({ stdoutChunks: [''], exitCode: 0 }); + const origEnd = c.stdin.end.bind(c.stdin); + c.stdin.end = ((chunk: unknown, ...args: unknown[]) => { + if (chunk !== undefined) captured = String(chunk); + return origEnd(chunk as never, ...(args as never[])); + }) as typeof c.stdin.end; + return c; + }); + await spawnStop('sess-Y', { spawnFn: spawnFn as never }); + const parsed = JSON.parse(captured.trim()); + expect(parsed.cwd).toBe(process.cwd()); + expect(parsed.hook_event_name).toBe('Stop'); + expect(parsed.stop_hook_active).toBe(false); + }); + + it('passes opts.cwd as the spawned process cwd', async () => { + const spawnFn = vi.fn(() => + makeFakeChild({ stdoutChunks: [''], exitCode: 0 }), + ); + await spawnStop('s', { spawnFn: spawnFn as never, cwd: '/spawn/cwd' }); + const opts = spawnFn.mock.calls[0]![2] as { cwd?: string }; + expect(opts.cwd).toBe('/spawn/cwd'); + }); +}); diff --git a/src/ext-vscode/src/ipc.ts b/src/ext-vscode/src/ipc.ts new file mode 100644 index 00000000..13234fbe --- /dev/null +++ b/src/ext-vscode/src/ipc.ts @@ -0,0 +1,216 @@ +import { spawn } from 'node:child_process'; +import type { SpawnOptions } from 'node:child_process'; + +/** + * IPC layer between the VS Code extension (Layer B) and the existing nexpath + * CLI pipeline (Layer C). The extension never imports from Layer C directly; + * instead it spawns `nexpath auto` and `nexpath stop` as subprocesses and + * communicates via stdin/stdout JSON envelopes. + * + * This module is a Branch 1 STUB — it defines the spawn + parse contract and + * the error taxonomy. The actual chat-history watcher (Branch 2) and webview + * payload renderer (Branch 3) call into this module; the Cursor / Windsurf + * adapters (Branch 4) supply concrete binary-path resolution. + * + * Binary path resolution order (highest priority first): + * 1. `opts.binaryPath` — explicit override (test fixtures, dev setups) + * 2. `process.env.NEXPATH_BIN` — for users who install via a non-standard PATH + * 3. `'nexpath'` — fall back to PATH lookup + */ + +export interface DecisionSessionPayload { + /** Advisory text to show in the webview / notification. */ + advisory: string; + /** Selectable options the user may pick. */ + options: Array<{ id: string; label: string }>; +} + +export interface IpcOptions { + binaryPath?: string; + dbPath?: string; + /** + * Working directory for the spawned nexpath process. This is REQUIRED for + * correct project-root resolution: `nexpath auto` defaults `--project` to + * `process.cwd()`, and `nexpath stop` reads `payload.cwd` from stdin. In + * extension use, this is the user's current workspace folder. + * + * If omitted, `process.cwd()` of the calling process is used. + */ + cwd?: string; + spawnFn?: typeof spawn; +} + +export class NexpathBinaryNotFoundError extends Error { + constructor(public attemptedPath: string, public override cause?: Error) { + super(`nexpath binary not found or not executable at: ${attemptedPath}`); + this.name = 'NexpathBinaryNotFoundError'; + } +} + +export class NexpathMalformedPayloadError extends Error { + constructor(public rawStdout: string, public override cause?: Error) { + super( + `nexpath stop output is not valid JSON. First 200 chars: ${rawStdout.slice(0, 200)}`, + ); + this.name = 'NexpathMalformedPayloadError'; + } +} + +function resolveBinaryPath(opts: IpcOptions): string { + return opts.binaryPath ?? process.env.NEXPATH_BIN ?? 'nexpath'; +} + +function buildArgs( + command: 'auto' | 'stop', + dbPath: string | undefined, +): string[] { + const args: string[] = [command]; + if (dbPath) args.push('--db', dbPath); + return args; +} + +function buildSpawnOptions(opts: IpcOptions): SpawnOptions { + // `cwd` is required by Layer C for correct project-root resolution: + // - `nexpath auto` defaults its `--project` flag to `process.cwd()` of the + // spawned process, then loads `.env` from there and writes hook-stats + // to the matching project. + // - `nexpath stop` reads `payload.cwd` from stdin (we pass it explicitly + // in `spawnStop`), but also benefits from spawning at the right cwd + // for consistency with auto. + return { + stdio: ['pipe', 'pipe', 'pipe'], + cwd: opts.cwd ?? process.cwd(), + }; +} + +/** + * Spawn `nexpath auto` and forward the prompt to it. + * + * Layer C's `nexpath auto` accepts the prompt via stdin JSON in hook mode + * (parsed as `{ prompt: string }` — see `src/cli/commands/auto.ts:417-425`). + * The spawned process's `cwd` controls `--project` defaulting, which in + * turn drives `.env` loading + prompt-store writes. + * + * Resolves on clean exit; rejects on spawn failure or non-zero exit. + */ +export function spawnAuto( + prompt: string, + sessionId: string, + opts: IpcOptions = {}, +): Promise { + const bin = resolveBinaryPath(opts); + const args = buildArgs('auto', opts.dbPath); + const spawner = opts.spawnFn ?? spawn; + const child = spawner(bin, args, buildSpawnOptions(opts)); + + return new Promise((resolve, reject) => { + let stderr = ''; + let errored = false; + + child.on('error', (err: Error) => { + errored = true; + reject(new NexpathBinaryNotFoundError(bin, err)); + }); + child.stderr?.on('data', (chunk: Buffer) => { + stderr += chunk.toString(); + }); + child.on('close', (code: number | null) => { + if (errored) return; + if (code === 0) { + resolve(); + return; + } + reject( + new Error( + `nexpath auto exited with code ${code}. stderr: ${stderr.trim()}`, + ), + ); + }); + + child.stdin?.end( + JSON.stringify({ prompt, session_id: sessionId }) + '\n', + ); + }); +} + +/** + * Spawn `nexpath stop` and parse the decision-session payload from stdout. + * + * Layer C's `nexpath stop` expects a full Claude Code-shaped `StopPayload` + * on stdin (parsed in `src/cli/commands/stop.ts:186-192`): + * + * { + * session_id?: string; + * cwd: string; // REQUIRED — project-root resolver + * hook_event_name: string; // REQUIRED — 'Stop' + * stop_hook_active: boolean; // REQUIRED — loop-guard + * last_assistant_message?: string; + * } + * + * We construct the full shape here so Layer C's stdin parse succeeds and + * `runStop` reads our request correctly. `last_assistant_message` is + * omitted (the watcher emits user-prompt events only; the assistant + * response isn't part of our captured signal). + * + * Resolves with `null` when stdout is empty (no advisory generated). + * Resolves with the parsed payload otherwise. + * Rejects on spawn failure, non-zero exit, or malformed JSON. + */ +export function spawnStop( + sessionId: string, + opts: IpcOptions = {}, +): Promise { + const bin = resolveBinaryPath(opts); + const args = buildArgs('stop', opts.dbPath); + const spawner = opts.spawnFn ?? spawn; + const child = spawner(bin, args, buildSpawnOptions(opts)); + + return new Promise((resolve, reject) => { + let stdout = ''; + let stderr = ''; + let errored = false; + + child.on('error', (err: Error) => { + errored = true; + reject(new NexpathBinaryNotFoundError(bin, err)); + }); + child.stdout?.on('data', (chunk: Buffer) => { + stdout += chunk.toString(); + }); + child.stderr?.on('data', (chunk: Buffer) => { + stderr += chunk.toString(); + }); + child.on('close', (code: number | null) => { + if (errored) return; + if (code !== 0) { + reject( + new Error( + `nexpath stop exited with code ${code}. stderr: ${stderr.trim()}`, + ), + ); + return; + } + const trimmed = stdout.trim(); + if (trimmed.length === 0) { + resolve(null); + return; + } + try { + resolve(JSON.parse(trimmed) as DecisionSessionPayload); + } catch (err) { + reject(new NexpathMalformedPayloadError(trimmed, err as Error)); + } + }); + + // Full StopPayload shape — Layer C requires cwd / hook_event_name / + // stop_hook_active in addition to the optional session_id. + child.stdin?.end( + JSON.stringify({ + session_id: sessionId, + cwd: opts.cwd ?? process.cwd(), + hook_event_name: 'Stop', + stop_hook_active: false, + }) + '\n', + ); + }); +} diff --git a/src/ext-vscode/src/onboarding.test.ts b/src/ext-vscode/src/onboarding.test.ts new file mode 100644 index 00000000..f68699c5 --- /dev/null +++ b/src/ext-vscode/src/onboarding.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +// `vi.mock` is hoisted above import statements, so any identifiers it +// references must also be hoisted. `vi.hoisted` is the supported escape hatch. +const { mockShowInformationMessage, mockOpenExternal, mockUriParse } = vi.hoisted(() => ({ + mockShowInformationMessage: vi.fn(), + mockOpenExternal: vi.fn(), + mockUriParse: vi.fn((s: string) => ({ toString: () => s, href: s })), +})); + +vi.mock('vscode', () => ({ + window: { showInformationMessage: mockShowInformationMessage }, + env: { openExternal: mockOpenExternal }, + Uri: { parse: mockUriParse }, +})); + +import { showOnboardingIfNeeded } from './onboarding.js'; + +interface FakeContext { + globalState: { + get: (k: string) => T | undefined; + update: ReturnType; + }; +} + +function makeContext(seed: Record = {}): FakeContext { + const store = new Map(Object.entries(seed)); + return { + globalState: { + get: (k: string) => store.get(k) as T | undefined, + update: vi.fn(async (k: string, v: unknown) => { + store.set(k, v); + }), + }, + }; +} + +describe('showOnboardingIfNeeded', () => { + beforeEach(() => { + mockShowInformationMessage.mockReset(); + mockOpenExternal.mockReset(); + mockUriParse.mockClear(); + }); + + it('on first launch (no consent stored), shows consent toast and persists Allow', async () => { + const ctx = makeContext(); + mockShowInformationMessage.mockResolvedValueOnce('Allow'); + await showOnboardingIfNeeded(ctx as never, 'linux'); + expect(mockShowInformationMessage).toHaveBeenCalledOnce(); + expect(ctx.globalState.update).toHaveBeenCalledWith( + 'nexpath.consentGranted', + true, + ); + }); + + it('persists false when user clicks "Not now"', async () => { + const ctx = makeContext(); + mockShowInformationMessage.mockResolvedValueOnce('Not now'); + await showOnboardingIfNeeded(ctx as never, 'linux'); + expect(ctx.globalState.update).toHaveBeenCalledWith( + 'nexpath.consentGranted', + false, + ); + }); + + it('persists false when user dismisses the toast (undefined return)', async () => { + const ctx = makeContext(); + mockShowInformationMessage.mockResolvedValueOnce(undefined); + await showOnboardingIfNeeded(ctx as never, 'linux'); + expect(ctx.globalState.update).toHaveBeenCalledWith( + 'nexpath.consentGranted', + false, + ); + }); + + it('skips the consent toast on subsequent launches when consent is already stored', async () => { + const ctx = makeContext({ 'nexpath.consentGranted': true }); + await showOnboardingIfNeeded(ctx as never, 'linux'); + expect(mockShowInformationMessage).not.toHaveBeenCalled(); + }); + + it('on macOS first launch, shows the FDA guidance after the consent toast', async () => { + const ctx = makeContext(); + mockShowInformationMessage + .mockResolvedValueOnce('Allow') + .mockResolvedValueOnce('Later'); + await showOnboardingIfNeeded(ctx as never, 'darwin'); + expect(mockShowInformationMessage).toHaveBeenCalledTimes(2); + expect(ctx.globalState.update).toHaveBeenCalledWith( + 'nexpath.fdaNoticeShown', + true, + ); + }); + + it('on macOS, "Open System Settings" opens the privacy-pane URL', async () => { + const ctx = makeContext(); + mockShowInformationMessage + .mockResolvedValueOnce('Allow') + .mockResolvedValueOnce('Open System Settings'); + await showOnboardingIfNeeded(ctx as never, 'darwin'); + expect(mockOpenExternal).toHaveBeenCalledOnce(); + const arg = mockOpenExternal.mock.calls[0]![0] as { toString: () => string }; + expect(arg.toString()).toContain('Privacy_AllFiles'); + }); + + it('on non-macOS, does NOT show the FDA guidance', async () => { + const ctx = makeContext(); + mockShowInformationMessage.mockResolvedValueOnce('Allow'); + await showOnboardingIfNeeded(ctx as never, 'win32'); + expect(mockShowInformationMessage).toHaveBeenCalledTimes(1); + expect(ctx.globalState.update).not.toHaveBeenCalledWith( + 'nexpath.fdaNoticeShown', + true, + ); + }); + + it('on macOS, does NOT re-show the FDA guidance once it has been shown', async () => { + const ctx = makeContext({ + 'nexpath.consentGranted': true, + 'nexpath.fdaNoticeShown': true, + }); + await showOnboardingIfNeeded(ctx as never, 'darwin'); + expect(mockShowInformationMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/src/ext-vscode/src/onboarding.ts b/src/ext-vscode/src/onboarding.ts new file mode 100644 index 00000000..4c32a9e4 --- /dev/null +++ b/src/ext-vscode/src/onboarding.ts @@ -0,0 +1,67 @@ +import * as vscode from 'vscode'; + +/** + * First-launch onboarding for Nexpath inside Cursor / Windsurf. + * + * Two responsibilities: + * 1. Ask the user for consent to monitor their chat history (persisted to + * `globalState` so we only ask once). + * 2. On macOS, also surface a one-time guidance message about Full Disk + * Access — Cursor's chat-history database lives under + * `~/Library/Application Support/Cursor/` which requires FDA on macOS. + * The notice persists so it is not re-shown on subsequent launches. + */ + +/** + * Exported so `extension.ts` (and any other module that needs to gate work + * on consent) reads the same globalState key the onboarding writes to. + * Do not rename without updating every reader. + */ +export const CONSENT_KEY = 'nexpath.consentGranted'; +const FDA_NOTICE_KEY = 'nexpath.fdaNoticeShown'; + +const FDA_URI = + 'x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles'; + +export async function showOnboardingIfNeeded( + context: vscode.ExtensionContext, + platform: NodeJS.Platform = process.platform, +): Promise { + if (context.globalState.get(CONSENT_KEY) === undefined) { + await runFirstLaunchConsent(context); + } + + if (platform === 'darwin' && !context.globalState.get(FDA_NOTICE_KEY)) { + await showMacOSFullDiskAccessGuidance(context); + } +} + +async function runFirstLaunchConsent( + context: vscode.ExtensionContext, +): Promise { + const choice = await vscode.window.showInformationMessage( + 'Allow Nexpath to read your AI chat history for prompt-level guidance? ' + + 'Data stays on your machine.', + { modal: false }, + 'Allow', + 'Not now', + ); + const granted = choice === 'Allow'; + await context.globalState.update(CONSENT_KEY, granted); +} + +async function showMacOSFullDiskAccessGuidance( + context: vscode.ExtensionContext, +): Promise { + const choice = await vscode.window.showInformationMessage( + 'On macOS, Cursor needs Full Disk Access to allow Nexpath to read your ' + + "chat history. Open System Settings → Privacy & Security → " + + 'Full Disk Access and enable Cursor.', + 'Open System Settings', + 'Later', + ); + if (choice === 'Open System Settings') { + await vscode.env.openExternal(vscode.Uri.parse(FDA_URI)); + } + await context.globalState.update(FDA_NOTICE_KEY, true); +} diff --git a/src/ext-vscode/src/path-enumerator.test.ts b/src/ext-vscode/src/path-enumerator.test.ts new file mode 100644 index 00000000..4e87e06f --- /dev/null +++ b/src/ext-vscode/src/path-enumerator.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + enumerateStateVscdbPaths, + globalStorageStateVscdbPath, +} from './path-enumerator.js'; + +describe('enumerateStateVscdbPaths', () => { + let tmp: string; + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'nexpath-enum-')); + }); + afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); + }); + + it('returns [] when the workspaceStorage dir is null (no host)', () => { + expect(enumerateStateVscdbPaths(null)).toEqual([]); + }); + + it('returns [] when the workspaceStorage dir does not exist', () => { + expect(enumerateStateVscdbPaths(join(tmp, 'missing'))).toEqual([]); + }); + + it('returns [] when the workspaceStorage dir is empty', () => { + mkdirSync(join(tmp, 'workspaceStorage'), { recursive: true }); + expect(enumerateStateVscdbPaths(join(tmp, 'workspaceStorage'))).toEqual([]); + }); + + it('finds one state.vscdb under a single workspace subdir', () => { + const ws = join(tmp, 'workspaceStorage', '1778826246907'); + mkdirSync(ws, { recursive: true }); + writeFileSync(join(ws, 'state.vscdb'), ''); + const out = enumerateStateVscdbPaths(join(tmp, 'workspaceStorage')); + expect(out).toHaveLength(1); + expect(out[0]!.endsWith('state.vscdb')).toBe(true); + expect(out[0]).toContain('1778826246907'); + }); + + it('finds state.vscdb across multiple workspace subdirs', () => { + for (const id of ['ws-a', 'ws-b', 'ws-c']) { + const ws = join(tmp, 'workspaceStorage', id); + mkdirSync(ws, { recursive: true }); + writeFileSync(join(ws, 'state.vscdb'), ''); + } + const out = enumerateStateVscdbPaths(join(tmp, 'workspaceStorage')); + expect(out).toHaveLength(3); + expect(out.every((p) => p.endsWith('state.vscdb'))).toBe(true); + }); + + it('skips workspace subdirs that do not contain state.vscdb', () => { + const wsWith = join(tmp, 'workspaceStorage', 'has-db'); + const wsWithout = join(tmp, 'workspaceStorage', 'no-db'); + mkdirSync(wsWith, { recursive: true }); + writeFileSync(join(wsWith, 'state.vscdb'), ''); + mkdirSync(wsWithout, { recursive: true }); + // wsWithout has no state.vscdb file + const out = enumerateStateVscdbPaths(join(tmp, 'workspaceStorage')); + expect(out).toHaveLength(1); + expect(out[0]).toContain('has-db'); + }); + + it('skips top-level files (non-directories) under workspaceStorage', () => { + const wsDir = join(tmp, 'workspaceStorage'); + mkdirSync(wsDir, { recursive: true }); + writeFileSync(join(wsDir, 'some-stray-file'), 'not a dir'); + // Add one real workspace too + const ws = join(wsDir, 'real-ws'); + mkdirSync(ws, { recursive: true }); + writeFileSync(join(ws, 'state.vscdb'), ''); + const out = enumerateStateVscdbPaths(wsDir); + expect(out).toHaveLength(1); + expect(out[0]).toContain('real-ws'); + }); + + it('uses injected fs helpers when provided', () => { + const fakeFs = { + existsSync: (p: string) => + p === '/fake/ws' || p === '/fake/ws/wsA/state.vscdb', + readdirSync: () => ['wsA'], + statSync: () => ({ isDirectory: () => true }), + }; + const out = enumerateStateVscdbPaths('/fake/ws', { fs: fakeFs }); + expect(out).toEqual(['/fake/ws/wsA/state.vscdb']); + }); +}); + +describe('globalStorageStateVscdbPath', () => { + let tmp: string; + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'nexpath-global-')); + }); + afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); + }); + + it('returns null when workspaceStorageDir is null', () => { + expect(globalStorageStateVscdbPath(null)).toBeNull(); + }); + + it('returns the sibling globalStorage/state.vscdb path when present', () => { + const userDir = join(tmp, 'User'); + mkdirSync(join(userDir, 'workspaceStorage'), { recursive: true }); + mkdirSync(join(userDir, 'globalStorage'), { recursive: true }); + writeFileSync(join(userDir, 'globalStorage', 'state.vscdb'), ''); + const wsDir = join(userDir, 'workspaceStorage'); + const out = globalStorageStateVscdbPath(wsDir); + expect(out).toBe(join(userDir, 'globalStorage', 'state.vscdb')); + }); + + it('returns null when globalStorage exists but state.vscdb is missing', () => { + const userDir = join(tmp, 'User'); + mkdirSync(join(userDir, 'workspaceStorage'), { recursive: true }); + mkdirSync(join(userDir, 'globalStorage'), { recursive: true }); + // No state.vscdb written + expect( + globalStorageStateVscdbPath(join(userDir, 'workspaceStorage')), + ).toBeNull(); + }); + + it('returns null when globalStorage directory does not exist at all', () => { + const userDir = join(tmp, 'User'); + mkdirSync(join(userDir, 'workspaceStorage'), { recursive: true }); + expect( + globalStorageStateVscdbPath(join(userDir, 'workspaceStorage')), + ).toBeNull(); + }); + + it('uses injected fs helpers', () => { + const fakeFs = { + existsSync: (p: string) => p.endsWith('/globalStorage/state.vscdb'), + readdirSync: () => [], + statSync: () => ({ isDirectory: () => true }), + }; + expect( + globalStorageStateVscdbPath('/fake/User/workspaceStorage', { + fs: fakeFs, + }), + ).toBe('/fake/User/globalStorage/state.vscdb'); + }); +}); diff --git a/src/ext-vscode/src/path-enumerator.ts b/src/ext-vscode/src/path-enumerator.ts new file mode 100644 index 00000000..5f8acc02 --- /dev/null +++ b/src/ext-vscode/src/path-enumerator.ts @@ -0,0 +1,108 @@ +import { existsSync, readdirSync, statSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +/** + * Path enumeration for the extension's watcher start-up (M2 Branch 5). + * + * Cursor / Windsurf store per-workspace state at: + * + * /User/workspaceStorage//state.vscdb + * + * The workspace-id is a hash derived by the host at workspace-open time. + * Multiple workspaces produce multiple sibling dirs under + * `workspaceStorage/`. This module walks that tree and returns the + * concrete `state.vscdb` paths the watcher should monitor. + * + * Additionally, Cursor stores Composer / Agent mode conversations in + * + * /User/globalStorage/state.vscdb + * + * under the `cursorDiskKV` table with `composerData:` + + * `bubbleId::` rows. This is a SINGLE shared file + * across all workspaces — unlike workspaceStorage, there's no + * per-workspace partition. `globalStorageStateVscdbPath` returns that + * path (or null if missing). Discovered during M2 closeout — Composer/ + * Agent mode is Cursor's default modern UX and the original v0.1.3 + * enumeration (workspaceStorage only) silently dropped every prompt + * submitted through it. + * + * Enumeration runs at extension activation time (not at install time) + * — by then any workspaces the user has opened are present on disk. + * New workspaces opened after activation aren't picked up until the + * next activation; that's an acceptable limitation for B5 smoke-test + * scope and is documented in the smoke-test README. + */ + +export interface EnumerateInputs { + /** Injection points for tests. Production uses node:fs defaults. */ + fs?: { + existsSync: (p: string) => boolean; + readdirSync: (p: string) => string[]; + statSync: (p: string) => { isDirectory(): boolean }; + }; +} + +const defaultFs = { + existsSync, + readdirSync, + statSync: (p: string) => statSync(p), +}; + +/** + * Walk `//state.vscdb` and return the + * `state.vscdb` paths that exist. Returns `[]` when the workspaceStorage + * directory is missing (host probably isn't installed) or empty (user + * hasn't opened any workspaces yet). + */ +export function enumerateStateVscdbPaths( + workspaceStorageDir: string | null, + inputs: EnumerateInputs = {}, +): string[] { + if (workspaceStorageDir === null) return []; + const fs = inputs.fs ?? defaultFs; + if (!fs.existsSync(workspaceStorageDir)) return []; + + const out: string[] = []; + let entries: string[]; + try { + entries = fs.readdirSync(workspaceStorageDir); + } catch { + return []; + } + + for (const entry of entries) { + const subDir = join(workspaceStorageDir, entry); + try { + if (!fs.statSync(subDir).isDirectory()) continue; + } catch { + continue; + } + const dbPath = join(subDir, 'state.vscdb'); + if (fs.existsSync(dbPath)) out.push(dbPath); + } + return out; +} + +/** + * Return the host's globalStorage `state.vscdb` path (or null if missing). + * + * `workspaceStorageDir` is the sibling tree (`User/workspaceStorage/`); the + * globalStorage file sits at `User/globalStorage/state.vscdb` — same parent + * dir, different leaf name. Caller passes the workspaceStorage path so this + * function works without re-deriving the host-config tree. + * + * Returns `null` when the file doesn't exist — host is not installed, or + * the user has never opened any chat (rare on Cursor; the file is created + * on first launch). + */ +export function globalStorageStateVscdbPath( + workspaceStorageDir: string | null, + inputs: EnumerateInputs = {}, +): string | null { + if (workspaceStorageDir === null) return null; + const fs = inputs.fs ?? defaultFs; + // workspaceStorageDir is .../User/workspaceStorage; sibling is .../User/globalStorage + const userDir = dirname(workspaceStorageDir); + const globalDbPath = join(userDir, 'globalStorage', 'state.vscdb'); + return fs.existsSync(globalDbPath) ? globalDbPath : null; +} diff --git a/src/ext-vscode/src/resolve-db-workspace.test.ts b/src/ext-vscode/src/resolve-db-workspace.test.ts new file mode 100644 index 00000000..490e571c --- /dev/null +++ b/src/ext-vscode/src/resolve-db-workspace.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + resolveWorkspaceFromDbPath, + _resetResolveDbWorkspaceCache, +} from './resolve-db-workspace.js'; + +/** + * Unit coverage for the R4.3 multi-workspace defect fix. The watcher pipeline + * relies on this helper to map a `state.vscdb` path → the actual workspace + * folder fsPath, so prompts captured cross-workspace land in `prompt-store.db` + * with the correct `project_root`. + */ + +const makeFs = (entries: Record) => ({ + readFileSync: (p: string, _enc: 'utf8'): string => { + const v = entries[p]; + if (v === undefined) { + const err = new Error('ENOENT') as Error & { code?: string }; + err.code = 'ENOENT'; + throw err; + } + if (v instanceof Error) throw v; + return v; + }, +}); + +describe('resolveWorkspaceFromDbPath', () => { + beforeEach(() => { + _resetResolveDbWorkspaceCache(); + }); + + it('returns the fs path for a normal Cursor workspace.json (folder URI)', () => { + const fs = makeFs({ + '/ws-storage/abc/workspace.json': JSON.stringify({ + folder: 'file:///home/u/repos/myproj', + }), + }); + const got = resolveWorkspaceFromDbPath( + '/ws-storage/abc/state.vscdb', + { fs }, + ); + expect(got).toBe('/home/u/repos/myproj'); + }); + + it('returns null when workspace.json is missing (empty-window storage entry)', () => { + const fs = makeFs({}); + const got = resolveWorkspaceFromDbPath( + '/ws-storage/xyz/state.vscdb', + { fs }, + ); + expect(got).toBeNull(); + }); + + it('returns null when workspace.json is unparseable JSON', () => { + const fs = makeFs({ + '/ws-storage/abc/workspace.json': 'not-json-at-all', + }); + const got = resolveWorkspaceFromDbPath( + '/ws-storage/abc/state.vscdb', + { fs }, + ); + expect(got).toBeNull(); + }); + + it('returns null for multi-root .code-workspace entries (configuration, not folder)', () => { + const fs = makeFs({ + '/ws-storage/abc/workspace.json': JSON.stringify({ + configuration: 'file:///home/u/my.code-workspace', + }), + }); + const got = resolveWorkspaceFromDbPath( + '/ws-storage/abc/state.vscdb', + { fs }, + ); + expect(got).toBeNull(); + }); + + it('returns null when folder is not a file:// URI (defensive)', () => { + const fs = makeFs({ + '/ws-storage/abc/workspace.json': JSON.stringify({ + folder: 'http://example.com/remote-fs', + }), + }); + const got = resolveWorkspaceFromDbPath( + '/ws-storage/abc/state.vscdb', + { fs }, + ); + expect(got).toBeNull(); + }); + + it('caches results by directory — repeated lookups read fs only once', () => { + let reads = 0; + const fs = { + readFileSync: (p: string, _enc: 'utf8'): string => { + reads += 1; + if (p === '/ws-storage/abc/workspace.json') { + return JSON.stringify({ folder: 'file:///home/u/a' }); + } + throw new Error('unexpected path: ' + p); + }, + }; + const a = resolveWorkspaceFromDbPath('/ws-storage/abc/state.vscdb', { fs }); + const b = resolveWorkspaceFromDbPath('/ws-storage/abc/state.vscdb', { fs }); + expect(a).toBe('/home/u/a'); + expect(b).toBe('/home/u/a'); + expect(reads).toBe(1); + }); + + it('caches negative results too — missing workspace.json is not re-probed', () => { + let reads = 0; + const fs = { + readFileSync: (_p: string, _enc: 'utf8'): string => { + reads += 1; + throw new Error('ENOENT'); + }, + }; + expect( + resolveWorkspaceFromDbPath('/ws-storage/empty/state.vscdb', { fs }), + ).toBeNull(); + expect( + resolveWorkspaceFromDbPath('/ws-storage/empty/state.vscdb', { fs }), + ).toBeNull(); + expect(reads).toBe(1); + }); +}); diff --git a/src/ext-vscode/src/resolve-db-workspace.ts b/src/ext-vscode/src/resolve-db-workspace.ts new file mode 100644 index 00000000..2b3dde07 --- /dev/null +++ b/src/ext-vscode/src/resolve-db-workspace.ts @@ -0,0 +1,75 @@ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** + * Resolve the actual workspace folder fsPath for a given `state.vscdb` file + * by reading the sibling `workspace.json` that VS Code / Cursor / Windsurf + * writes next to every workspaceStorage db. + * + * Layout the hosts use: + * //state.vscdb + * //workspace.json ← {"folder":"file:///..."} + * + * Returns `null` when: + * - workspace.json is missing (e.g. the empty-window state.vscdb that + * hosts keep for "open without folder" sessions) + * - workspace.json contains a `configuration` field instead of `folder` + * (multi-root `.code-workspace`) — these are uncommon for Cursor but + * legal in VS Code. Callers fall back to instance workspaceCwd in + * that case. + * - the file is unreadable / malformed / the folder URI is not a + * `file://` URI + * + * Results are cached by directory so repeated lookups on the watcher hot + * path don't hammer the disk. + */ + +interface FsLike { + readFileSync: (p: string, enc: 'utf8') => string; +} + +const defaultFs: FsLike = { + readFileSync: (p, enc) => readFileSync(p, enc), +}; + +const cache = new Map(); + +export function resolveWorkspaceFromDbPath( + dbPath: string, + inputs: { fs?: FsLike } = {}, +): string | null { + const dir = dirname(dbPath); + if (cache.has(dir)) return cache.get(dir)!; + + const result = readWorkspaceFolder(dir, inputs.fs ?? defaultFs); + cache.set(dir, result); + return result; +} + +/** Test hook: clear the in-memory cache. Not exported in production paths. */ +export function _resetResolveDbWorkspaceCache(): void { + cache.clear(); +} + +function readWorkspaceFolder(dir: string, fs: FsLike): string | null { + let raw: string; + try { + raw = fs.readFileSync(join(dir, 'workspace.json'), 'utf8'); + } catch { + return null; + } + let parsed: { folder?: unknown; configuration?: unknown }; + try { + parsed = JSON.parse(raw) as { folder?: unknown; configuration?: unknown }; + } catch { + return null; + } + if (typeof parsed.folder !== 'string') return null; + if (!parsed.folder.startsWith('file://')) return null; + try { + return fileURLToPath(parsed.folder); + } catch { + return null; + } +} diff --git a/src/ext-vscode/src/webview/html.test.ts b/src/ext-vscode/src/webview/html.test.ts new file mode 100644 index 00000000..512b6bc7 --- /dev/null +++ b/src/ext-vscode/src/webview/html.test.ts @@ -0,0 +1,212 @@ +import { describe, it, expect } from 'vitest'; +import { renderDecisionSessionHtml, escapeHtml } from './html.js'; +import type { DecisionSessionPayload } from '../ipc.js'; + +const CSP_SRC = 'vscode-resource:fake-csp'; +const FIXED_NONCE = 'test-nonce-deterministic'; + +describe('escapeHtml', () => { + it('escapes the five HTML-significant characters', () => { + expect(escapeHtml('&')).toBe('&'); + expect(escapeHtml('<')).toBe('<'); + expect(escapeHtml('>')).toBe('>'); + expect(escapeHtml('"')).toBe('"'); + expect(escapeHtml("'")).toBe('''); + }); + + it('leaves harmless characters alone', () => { + expect(escapeHtml('plain text 123')).toBe('plain text 123'); + expect(escapeHtml('emoji 🚀 and unicode é')).toBe('emoji 🚀 and unicode é'); + }); + + it('escapes injection attempts in the middle of strings', () => { + expect(escapeHtml('hi ')).toBe( + 'hi <script>alert(1)</script>', + ); + }); +}); + +describe('renderDecisionSessionHtml — empty state', () => { + it('returns valid HTML with no payload', () => { + const html = renderDecisionSessionHtml(null, { cspSource: CSP_SRC }); + expect(html.startsWith('')).toBe(true); + expect(html).toContain('Nexpath is active'); + expect(html).toContain('Decision sessions will appear'); + }); + + it('includes the CSP source in the empty-state HTML', () => { + const html = renderDecisionSessionHtml(null, { cspSource: CSP_SRC }); + expect(html).toContain(CSP_SRC); + expect(html).toContain("default-src 'none'"); + }); + + it('does not include a script tag in the empty state (no scripts needed)', () => { + const html = renderDecisionSessionHtml(null, { cspSource: CSP_SRC }); + expect(html).not.toContain(' { + const payload: DecisionSessionPayload = { + advisory: 'Consider clarifying scope before continuing.', + options: [ + { id: 'opt-a', label: 'Refine the request' }, + { id: 'opt-b', label: 'Proceed with caution' }, + ], + }; + + it('contains the advisory text', () => { + const html = renderDecisionSessionHtml(payload, { + cspSource: CSP_SRC, + nonce: FIXED_NONCE, + }); + expect(html).toContain('Consider clarifying scope before continuing.'); + }); + + it('renders one button per option with the right label + numbering', () => { + const html = renderDecisionSessionHtml(payload, { + cspSource: CSP_SRC, + nonce: FIXED_NONCE, + }); + expect(html).toContain('1.'); + expect(html).toContain('2.'); + expect(html).toContain('Refine the request'); + expect(html).toContain('Proceed with caution'); + expect(html).toContain('data-option-id="opt-a"'); + expect(html).toContain('data-option-id="opt-b"'); + expect(html).toContain('data-option-label="Refine the request"'); + }); + + it('includes the dismiss button + the message-passing script', () => { + const html = renderDecisionSessionHtml(payload, { + cspSource: CSP_SRC, + nonce: FIXED_NONCE, + }); + expect(html).toContain('id="dismiss"'); + expect(html).toContain('acquireVsCodeApi'); + expect(html).toContain("type: 'select'"); + expect(html).toContain("type: 'dismiss'"); + }); + + it('uses the provided nonce in the CSP and on the script tag', () => { + const html = renderDecisionSessionHtml(payload, { + cspSource: CSP_SRC, + nonce: FIXED_NONCE, + }); + expect(html).toContain(`'nonce-${FIXED_NONCE}'`); + expect(html).toContain(` & friends', + options: [ + { id: '', label: '' }, + ], + }; + const html = renderDecisionSessionHtml(evilPayload, { + cspSource: CSP_SRC, + nonce: FIXED_NONCE, + }); + expect(html).not.toContain(''); + expect(html).toContain('<script>alert("xss")</script>'); + expect(html).not.toContain(' { + // If an option id or label ever contains `"`, naive interpolation would + // close the data-option-id attribute and allow attribute injection. + // escapeHtml must convert `"` to `"` so the attribute stays intact. + const breakOutPayload: DecisionSessionPayload = { + advisory: 'irrelevant', + options: [{ id: 'a"b', label: 'c"d' }], + }; + const html = renderDecisionSessionHtml(breakOutPayload, { + cspSource: CSP_SRC, + nonce: FIXED_NONCE, + }); + expect(html).toContain('data-option-id="a"b"'); + expect(html).toContain('data-option-label="c"d"'); + // Raw quote in the attribute would break the markup — must NOT appear. + expect(html).not.toMatch(/data-option-id="a"b"/); + expect(html).not.toMatch(/data-option-label="c"d"/); + }); + + it('handles an empty options array without crashing', () => { + const html = renderDecisionSessionHtml( + { advisory: 'No options for you', options: [] }, + { cspSource: CSP_SRC, nonce: FIXED_NONCE }, + ); + expect(html).toContain('No options for you'); + expect(html).toContain('id="dismiss"'); + expect(html).toMatch(/
    \s*<\/ul>/); + }); + + it('generates a fresh nonce when none is provided', () => { + const a = renderDecisionSessionHtml(payload, { cspSource: CSP_SRC }); + const b = renderDecisionSessionHtml(payload, { cspSource: CSP_SRC }); + const noncePattern = /'nonce-([A-Za-z0-9]{32})'/; + const aMatch = a.match(noncePattern); + const bMatch = b.match(noncePattern); + expect(aMatch).not.toBeNull(); + expect(bMatch).not.toBeNull(); + expect(aMatch![1]).not.toBe(bMatch![1]); + }); + + describe('keyboard shortcuts (mirroring Layer C TTY UX)', () => { + it('includes the visible keyboard hint in the options header', () => { + const html = renderDecisionSessionHtml(payload, { + cspSource: CSP_SRC, + nonce: FIXED_NONCE, + }); + expect(html).toContain('press 1'); + expect(html).toContain('Esc'); + expect(html).toContain('Ctrl+X'); + }); + + it('shows the hint range scoped to the number of options (capped at 9)', () => { + const twoOptHtml = renderDecisionSessionHtml( + { advisory: 'a', options: [{ id: '1', label: 'A' }, { id: '2', label: 'B' }] }, + { cspSource: CSP_SRC, nonce: FIXED_NONCE }, + ); + expect(twoOptHtml).toContain('1–2'); + const eleven = Array.from({ length: 11 }, (_, i) => ({ id: `${i}`, label: `Opt ${i}` })); + const elevenOptHtml = renderDecisionSessionHtml( + { advisory: 'a', options: eleven }, + { cspSource: CSP_SRC, nonce: FIXED_NONCE }, + ); + expect(elevenOptHtml).toContain('1–9'); // capped at 9 since keys 1-9 only + }); + + it('embeds a keydown handler that dispatches select on number keys 1–9', () => { + const html = renderDecisionSessionHtml(payload, { + cspSource: CSP_SRC, + nonce: FIXED_NONCE, + }); + expect(html).toContain("ev.key >= '1' && ev.key <= '9'"); + expect(html).toContain('selectOption(optionButtons[idx])'); + }); + + it('embeds Esc + Ctrl+X handlers that dispatch dismiss', () => { + const html = renderDecisionSessionHtml(payload, { + cspSource: CSP_SRC, + nonce: FIXED_NONCE, + }); + expect(html).toContain("ev.key === 'Escape'"); + expect(html).toContain('ev.ctrlKey'); + expect(html).toContain("ev.key === 'x' || ev.key === 'X'"); + expect(html).toContain('dismiss()'); + }); + + it('focuses the first option button on render', () => { + const html = renderDecisionSessionHtml(payload, { + cspSource: CSP_SRC, + nonce: FIXED_NONCE, + }); + expect(html).toContain('optionButtons[0].focus()'); + }); + }); +}); diff --git a/src/ext-vscode/src/webview/html.ts b/src/ext-vscode/src/webview/html.ts new file mode 100644 index 00000000..76537957 --- /dev/null +++ b/src/ext-vscode/src/webview/html.ts @@ -0,0 +1,178 @@ +/** + * Decision-session HTML template (M7 of M2 Branch 3). + * + * Pure function — given a {@link DecisionSessionPayload} (or null for the + * empty/watching state), returns a fully self-contained HTML string suitable + * for `webviewView.webview.html = …`. No side effects, no filesystem reads. + * + * Security: + * - Content-Security-Policy with `default-src 'none'` + nonce-scoped scripts. + * - `'unsafe-inline'` for styles is acceptable here (no externally-sourced + * style URIs); M5 hardening can move styles to an external CSS file if + * stricter CSP is wanted. + * - All user-controlled strings (`advisory`, option `label` and `id`) are + * HTML-escaped before insertion. + * + * Theming: + * - Uses `--vscode-*` CSS variables so the UI matches the host's theme + * automatically (Cursor / Windsurf inherit VS Code's theme system). + */ + +import type { DecisionSessionPayload } from '../ipc.js'; + +export interface RenderOptions { + /** Pass `webview.cspSource` so the CSP allows the webview's local resources. */ + cspSource: string; + /** + * Nonce for the inline ` + +`; +} diff --git a/src/ext-vscode/src/webview/prompt-injection.test.ts b/src/ext-vscode/src/webview/prompt-injection.test.ts new file mode 100644 index 00000000..e0b9ddf7 --- /dev/null +++ b/src/ext-vscode/src/webview/prompt-injection.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { mockClipboardWrite, mockShowInfo } = vi.hoisted(() => ({ + mockClipboardWrite: vi.fn(), + mockShowInfo: vi.fn(), +})); + +vi.mock('vscode', () => ({ + env: { clipboard: { writeText: mockClipboardWrite } }, + window: { showInformationMessage: mockShowInfo }, +})); + +import { handleOptionSelection } from './prompt-injection.js'; + +describe('handleOptionSelection', () => { + beforeEach(() => { + mockClipboardWrite.mockReset(); + mockShowInfo.mockReset(); + }); + + it('writes the selected text to the clipboard', async () => { + mockClipboardWrite.mockResolvedValueOnce(undefined); + mockShowInfo.mockResolvedValueOnce(undefined); + await handleOptionSelection('refined prompt'); + expect(mockClipboardWrite).toHaveBeenCalledWith('refined prompt'); + }); + + it('shows the success toast after a successful clipboard write', async () => { + mockClipboardWrite.mockResolvedValueOnce(undefined); + mockShowInfo.mockResolvedValueOnce(undefined); + await handleOptionSelection('refined prompt'); + expect(mockShowInfo).toHaveBeenCalledWith( + expect.stringMatching(/pasted to clipboard/i), + ); + }); + + it('shows a failure toast when the clipboard write rejects', async () => { + mockClipboardWrite.mockRejectedValueOnce(new Error('permission denied')); + mockShowInfo.mockResolvedValueOnce(undefined); + await handleOptionSelection('any'); + expect(mockShowInfo).toHaveBeenCalledTimes(1); + expect(mockShowInfo).toHaveBeenCalledWith( + expect.stringContaining("couldn't copy to clipboard"), + ); + expect(mockShowInfo).toHaveBeenCalledWith( + expect.stringContaining('permission denied'), + ); + }); + + it('does not throw even if both clipboard AND toast fail', async () => { + mockClipboardWrite.mockRejectedValueOnce(new Error('clip-fail')); + mockShowInfo.mockRejectedValueOnce(new Error('toast-fail')); + await expect(handleOptionSelection('x')).rejects.toThrow('toast-fail'); + // (The toast-fail surfaces because there's no second fallback. The + // important guarantee is the clipboard-fail path is reached — it is.) + }); + + it('uses injected dependencies in preference to the vscode module', async () => { + const localClip = vi.fn().mockResolvedValue(undefined); + const localToast = vi.fn().mockResolvedValue(undefined); + await handleOptionSelection('hello', { + clipboardWrite: localClip, + showInfo: localToast, + }); + expect(localClip).toHaveBeenCalledWith('hello'); + expect(localToast).toHaveBeenCalledOnce(); + expect(mockClipboardWrite).not.toHaveBeenCalled(); + expect(mockShowInfo).not.toHaveBeenCalled(); + }); + + it('handles empty string without crashing', async () => { + const clip = vi.fn().mockResolvedValue(undefined); + const toast = vi.fn().mockResolvedValue(undefined); + await handleOptionSelection('', { clipboardWrite: clip, showInfo: toast }); + expect(clip).toHaveBeenCalledWith(''); + expect(toast).toHaveBeenCalledOnce(); + }); + + describe('injectFn primary path (B4 contract)', () => { + it('skips clipboard + toast when injectFn returns true', async () => { + const inject = vi.fn().mockResolvedValue(true); + const clip = vi.fn().mockResolvedValue(undefined); + const toast = vi.fn().mockResolvedValue(undefined); + await handleOptionSelection('hello', { + injectFn: inject, + clipboardWrite: clip, + showInfo: toast, + }); + expect(inject).toHaveBeenCalledWith('hello'); + expect(clip).not.toHaveBeenCalled(); + expect(toast).not.toHaveBeenCalled(); + }); + + it('falls back to clipboard when injectFn returns false', async () => { + const inject = vi.fn().mockResolvedValue(false); + const clip = vi.fn().mockResolvedValue(undefined); + const toast = vi.fn().mockResolvedValue(undefined); + await handleOptionSelection('hello', { + injectFn: inject, + clipboardWrite: clip, + showInfo: toast, + }); + expect(inject).toHaveBeenCalledOnce(); + expect(clip).toHaveBeenCalledWith('hello'); + expect(toast).toHaveBeenCalledOnce(); + }); + + it('falls back to clipboard when injectFn throws', async () => { + const inject = vi.fn().mockRejectedValue(new Error('command not found')); + const clip = vi.fn().mockResolvedValue(undefined); + const toast = vi.fn().mockResolvedValue(undefined); + await handleOptionSelection('hello', { + injectFn: inject, + clipboardWrite: clip, + showInfo: toast, + }); + expect(inject).toHaveBeenCalledOnce(); + expect(clip).toHaveBeenCalledWith('hello'); + expect(toast).toHaveBeenCalledOnce(); + }); + + it('does NOT call injectFn when it is undefined (default B3 behaviour)', async () => { + const clip = vi.fn().mockResolvedValue(undefined); + const toast = vi.fn().mockResolvedValue(undefined); + await handleOptionSelection('hello', { + clipboardWrite: clip, + showInfo: toast, + }); + expect(clip).toHaveBeenCalledWith('hello'); + }); + }); +}); diff --git a/src/ext-vscode/src/webview/prompt-injection.ts b/src/ext-vscode/src/webview/prompt-injection.ts new file mode 100644 index 00000000..58d56b16 --- /dev/null +++ b/src/ext-vscode/src/webview/prompt-injection.ts @@ -0,0 +1,100 @@ +import * as vscode from 'vscode'; + +/** + * Round-trip prompt injection (M8 of M2 Branch 3). + * + * When the user clicks an option in the decision-session webview, the + * selected option's text needs to land back in the host's (Cursor's / + * Windsurf's) AI chat input — so they can hit Enter and re-submit the + * refined prompt. + * + * # Architecture (B3 sets up; B4 fills in) + * + * `handleOptionSelection` has TWO paths, in priority order: + * + * 1. **Primary — direct injection** via `deps.injectFn`. This is supplied + * by the per-agent adapter (B4), which knows which `vscode.commands` + * command writes text to that agent's chat input (Cursor / Windsurf + * each have their own command id). If `injectFn(text)` returns + * `true`, the text is in the chat input and we're done. + * + * 2. **Fallback — clipboard + toast.** If `injectFn` is absent (B3 + * default) or returns `false`, we write the text to the system + * clipboard and show an info toast directing the user to paste it. + * + * # Why B3 doesn't supply an injectFn + * + * VS Code's standard text-editing API operates on editor documents, not + * the host's chat input panel — so there is no agent-agnostic primary + * path. The per-agent command id has to be discovered against each agent + * (and tracked across agent versions). That research and wiring is + * Branch 4's scope; B3 sets up the contract so B4 only has to plug in + * the `injectFn`. Documented as a load-bearing contract in the project + * memory `project_b4_prompt_injection_contract.md`. + * + * Documented as a known fallback in dev plan §2.4 (Risks → "Round-trip + * prompt injection"). + */ + +/** + * A function that attempts to write the option text directly into the + * agent's chat input. Returns `true` on success (clipboard fallback is + * skipped) or `false` if the agent-specific command is unavailable / errored. + * + * Implemented per-agent in Branch 4 (cursor-windsurf-adapters). + */ +export type OptionInjector = (text: string) => Promise; + +export interface PromptInjectionDeps { + /** + * Adapter-supplied direct-injection function (the primary path). + * Absent in B3 — Branch 4 fills it in per-agent. + */ + injectFn?: OptionInjector; + /** Override the clipboard writer (tests). */ + clipboardWrite?: (text: string) => Promise; + /** Override the info-toast call (tests). */ + showInfo?: (message: string) => Promise; +} + +const FALLBACK_MESSAGE = + 'Nexpath: pasted to clipboard — paste into the chat input to use it.'; + +/** + * Hand the selected option's text to the agent: try direct injection + * first, fall back to clipboard + toast. Never throws — failures surface + * to the user as toasts. + */ +export async function handleOptionSelection( + text: string, + deps: PromptInjectionDeps = {}, +): Promise { + // ── 1. Primary: adapter-supplied direct injection ─────────────────────── + if (deps.injectFn) { + try { + const injected = await deps.injectFn(text); + if (injected) return; + // If injectFn returned false, fall through to clipboard. + } catch { + // injectFn threw — fall through to clipboard. + } + } + + // ── 2. Fallback: clipboard + info toast ───────────────────────────────── + const clipboardWrite = + deps.clipboardWrite ?? ((s: string) => vscode.env.clipboard.writeText(s)); + const showInfo = + deps.showInfo ?? + ((m: string) => + Promise.resolve(vscode.window.showInformationMessage(m)) as Promise< + string | undefined + >); + + try { + await clipboardWrite(text); + await showInfo(FALLBACK_MESSAGE); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + await showInfo(`Nexpath: couldn't copy to clipboard (${reason}).`); + } +} diff --git a/src/ext-vscode/src/webview/view-provider.test.ts b/src/ext-vscode/src/webview/view-provider.test.ts new file mode 100644 index 00000000..172778f1 --- /dev/null +++ b/src/ext-vscode/src/webview/view-provider.test.ts @@ -0,0 +1,234 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('vscode', () => ({})); + +import { + NexpathDecisionSessionViewProvider, + VIEW_ID, +} from './view-provider.js'; +import type { DecisionSessionPayload } from '../ipc.js'; + +interface FakeWebview { + options: unknown; + html: string; + cspSource: string; + onDidReceiveMessage: ReturnType; + __messageListener: ((m: unknown) => void) | undefined; +} + +interface FakeWebviewView { + webview: FakeWebview; + show: ReturnType; + onDidDispose: ReturnType; + __disposeListener: (() => void) | undefined; +} + +function makeFakeView(): FakeWebviewView { + const view: FakeWebviewView = { + webview: { + options: undefined, + html: '', + cspSource: 'vscode-resource:csp-source', + onDidReceiveMessage: vi.fn((fn: (m: unknown) => void) => { + view.webview.__messageListener = fn; + return { dispose: vi.fn() }; + }), + __messageListener: undefined, + }, + show: vi.fn(), + onDidDispose: vi.fn((fn: () => void) => { + view.__disposeListener = fn; + return { dispose: vi.fn() }; + }), + __disposeListener: undefined, + }; + return view; +} + +const fakeUri = { fsPath: '/fake/extension' } as unknown as never; + +const payload: DecisionSessionPayload = { + advisory: 'Be specific about the change.', + options: [ + { id: 'opt-1', label: 'Refine the request' }, + { id: 'opt-2', label: 'Proceed anyway' }, + ], +}; + +describe('NexpathDecisionSessionViewProvider', () => { + describe('VIEW_ID', () => { + it('matches the package.json view declaration', () => { + expect(VIEW_ID).toBe('nexpath.status'); + }); + }); + + describe('resolveWebviewView', () => { + it('sets webview.options with enableScripts and localResourceRoots', () => { + const provider = new NexpathDecisionSessionViewProvider(fakeUri); + const view = makeFakeView(); + provider.resolveWebviewView(view as never, {} as never, {} as never); + const opts = view.webview.options as { + enableScripts: boolean; + localResourceRoots: unknown[]; + }; + expect(opts.enableScripts).toBe(true); + expect(opts.localResourceRoots).toEqual([fakeUri]); + }); + + it('renders the empty-state HTML when no payload has been published', () => { + const provider = new NexpathDecisionSessionViewProvider(fakeUri); + const view = makeFakeView(); + provider.resolveWebviewView(view as never, {} as never, {} as never); + expect(view.webview.html).toContain('Nexpath is active'); + expect(view.webview.html).not.toContain('Be specific about the change.'); + }); + + it('renders a previously-published payload when the view resolves later', () => { + const provider = new NexpathDecisionSessionViewProvider(fakeUri); + provider.publishPayload(payload); // before resolve + const view = makeFakeView(); + provider.resolveWebviewView(view as never, {} as never, {} as never); + expect(view.webview.html).toContain('Be specific about the change.'); + expect(view.webview.html).toContain('Refine the request'); + }); + + it('registers an onDidDispose handler that clears the view reference', () => { + const provider = new NexpathDecisionSessionViewProvider(fakeUri); + const view = makeFakeView(); + provider.resolveWebviewView(view as never, {} as never, {} as never); + expect(view.onDidDispose).toHaveBeenCalledOnce(); + view.__disposeListener!(); + // After dispose, publishPayload should not touch a stale view + const sentinel = view.webview.html; + provider.publishPayload(payload); + expect(view.webview.html).toBe(sentinel); // unchanged — view was released + }); + }); + + describe('publishPayload', () => { + it('stores the payload even before any view is resolved', () => { + const provider = new NexpathDecisionSessionViewProvider(fakeUri); + provider.publishPayload(payload); + expect(provider.getCurrentPayload()).toEqual(payload); + }); + + it('updates the resolved view HTML to the payload-state HTML', () => { + const provider = new NexpathDecisionSessionViewProvider(fakeUri); + const view = makeFakeView(); + provider.resolveWebviewView(view as never, {} as never, {} as never); + provider.publishPayload(payload); + expect(view.webview.html).toContain('Be specific about the change.'); + }); + + it('auto-reveals the view via show(true)', () => { + const provider = new NexpathDecisionSessionViewProvider(fakeUri); + const view = makeFakeView(); + provider.resolveWebviewView(view as never, {} as never, {} as never); + provider.publishPayload(payload); + expect(view.show).toHaveBeenCalledWith(true); + }); + + it('a second publishPayload replaces the first (no stacking)', () => { + const provider = new NexpathDecisionSessionViewProvider(fakeUri); + const view = makeFakeView(); + provider.resolveWebviewView(view as never, {} as never, {} as never); + const first: DecisionSessionPayload = { + advisory: 'FIRST advisory text here', + options: [{ id: 'a', label: 'first-only' }], + }; + const second: DecisionSessionPayload = { + advisory: 'SECOND advisory text here', + options: [{ id: 'b', label: 'second-only' }], + }; + provider.publishPayload(first); + provider.publishPayload(second); + expect(provider.getCurrentPayload()).toBe(second); + expect(view.webview.html).toContain('SECOND advisory text here'); + expect(view.webview.html).toContain('second-only'); + expect(view.webview.html).not.toContain('FIRST advisory text here'); + expect(view.webview.html).not.toContain('first-only'); + expect(view.show).toHaveBeenCalledTimes(2); + }); + }); + + describe('clearPayload', () => { + it('drops the stored payload and re-renders the empty state', () => { + const provider = new NexpathDecisionSessionViewProvider(fakeUri); + const view = makeFakeView(); + provider.resolveWebviewView(view as never, {} as never, {} as never); + provider.publishPayload(payload); + provider.clearPayload(); + expect(provider.getCurrentPayload()).toBeNull(); + expect(view.webview.html).toContain('Nexpath is active'); + expect(view.webview.html).not.toContain('Be specific about the change.'); + }); + }); + + describe('handleMessage (message routing)', () => { + it('forwards a "select" message with optionLabel to onSelect', async () => { + const onSelect = vi.fn().mockResolvedValue(undefined); + const provider = new NexpathDecisionSessionViewProvider(fakeUri, onSelect); + await provider.handleMessage({ + type: 'select', + optionId: 'opt-1', + optionLabel: 'Refine the request', + }); + expect(onSelect).toHaveBeenCalledWith('Refine the request'); + }); + + it('ignores a "select" message that has no string optionLabel', async () => { + const onSelect = vi.fn().mockResolvedValue(undefined); + const provider = new NexpathDecisionSessionViewProvider(fakeUri, onSelect); + await provider.handleMessage({ type: 'select', optionId: 'opt-1' }); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it('a "dismiss" message clears the current payload', async () => { + const onSelect = vi.fn().mockResolvedValue(undefined); + const provider = new NexpathDecisionSessionViewProvider(fakeUri, onSelect); + provider.publishPayload(payload); + await provider.handleMessage({ type: 'dismiss' }); + expect(provider.getCurrentPayload()).toBeNull(); + }); + + it('ignores unknown / malformed messages without throwing', async () => { + const onSelect = vi.fn().mockResolvedValue(undefined); + const provider = new NexpathDecisionSessionViewProvider(fakeUri, onSelect); + await expect(provider.handleMessage(null)).resolves.toBeUndefined(); + await expect(provider.handleMessage(undefined)).resolves.toBeUndefined(); + await expect(provider.handleMessage('string')).resolves.toBeUndefined(); + await expect(provider.handleMessage({ type: 'bogus' })).resolves.toBeUndefined(); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it('catches errors from onSelect so they never become unhandled rejections', async () => { + const onSelect = vi.fn().mockRejectedValue(new Error('inject blew up')); + const provider = new NexpathDecisionSessionViewProvider(fakeUri, onSelect); + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + await expect( + provider.handleMessage({ type: 'select', optionLabel: 'x' }), + ).resolves.toBeUndefined(); + expect(onSelect).toHaveBeenCalledOnce(); + expect(errSpy).toHaveBeenCalledWith( + '[nexpath] onSelect failed:', + expect.any(Error), + ); + errSpy.mockRestore(); + }); + + it('routes messages from the webview via the registered listener', async () => { + const onSelect = vi.fn().mockResolvedValue(undefined); + const provider = new NexpathDecisionSessionViewProvider(fakeUri, onSelect); + const view = makeFakeView(); + provider.resolveWebviewView(view as never, {} as never, {} as never); + expect(view.webview.__messageListener).toBeDefined(); + view.webview.__messageListener!({ + type: 'select', + optionLabel: 'from-webview', + }); + // Listener fires synchronously but onSelect resolution is async. + await new Promise((r) => setTimeout(r, 5)); + expect(onSelect).toHaveBeenCalledWith('from-webview'); + }); + }); +}); diff --git a/src/ext-vscode/src/webview/view-provider.ts b/src/ext-vscode/src/webview/view-provider.ts new file mode 100644 index 00000000..b7412097 --- /dev/null +++ b/src/ext-vscode/src/webview/view-provider.ts @@ -0,0 +1,138 @@ +import * as vscode from 'vscode'; +import { renderDecisionSessionHtml } from './html.js'; +import { handleOptionSelection } from './prompt-injection.js'; +import type { DecisionSessionPayload } from '../ipc.js'; + +/** + * NexpathDecisionSessionViewProvider — M6 of M2 Branch 3. + * + * Backs the `nexpath.status` activity-bar view (declared in package.json with + * `type: "webview"`). Renders the decision-session UI inside a VS Code + * WebviewView and auto-reveals the view whenever a new advisory payload is + * published — matching the "user installs once, never invokes manually" + * UX requirement from architecture rev 2 §4 / discussion log §2 Correction 9. + * + * Lifecycle: + * 1. `activate()` (in `extension.ts`) constructs an instance and registers it + * via `vscode.window.registerWebviewViewProvider(VIEW_ID, instance)`. + * 2. The first time the user clicks the activity-bar icon (or the view is + * auto-revealed), VS Code calls `resolveWebviewView()` — we configure + * `enableScripts`, set the initial HTML, and wire `onDidReceiveMessage`. + * 3. When Branch 4 wires the watcher in, on each `nexpath stop` result the + * adapter calls `publishPayload(payload)`. The provider stores the + * payload (so it survives the view being hidden + re-shown) and, if the + * view is currently resolved, updates the HTML and calls + * `webviewView.show(true)` for the auto-reveal. + * 4. The webview's message-passing surface is two events: + * - `{ type: 'select', optionId, optionLabel }` → forward `optionLabel` + * to `handleOptionSelection()` (clipboard + toast). + * - `{ type: 'dismiss' }` → clear the displayed payload. + */ + +export const VIEW_ID = 'nexpath.status'; + +interface WebviewMessage { + type?: unknown; + optionId?: unknown; + optionLabel?: unknown; +} + +export class NexpathDecisionSessionViewProvider + implements vscode.WebviewViewProvider +{ + private view: vscode.WebviewView | undefined; + private currentPayload: DecisionSessionPayload | null = null; + + constructor( + private readonly extensionUri: vscode.Uri, + /** Inject the option-selection handler for tests. */ + private readonly onSelect: ( + text: string, + ) => Promise = handleOptionSelection, + ) {} + + resolveWebviewView( + webviewView: vscode.WebviewView, + _ctx: vscode.WebviewViewResolveContext, + _token: vscode.CancellationToken, + ): void { + this.view = webviewView; + + webviewView.webview.options = { + enableScripts: true, + localResourceRoots: [this.extensionUri], + }; + + webviewView.webview.html = renderDecisionSessionHtml(this.currentPayload, { + cspSource: webviewView.webview.cspSource, + }); + + webviewView.webview.onDidReceiveMessage((raw: unknown) => { + void this.handleMessage(raw); + }); + + webviewView.onDidDispose(() => { + this.view = undefined; + }); + } + + /** + * Publish a new payload to the webview. If the view is currently resolved, + * update its HTML and auto-reveal it. If not yet resolved, just store the + * payload — it'll be rendered when `resolveWebviewView` next fires. + */ + publishPayload(payload: DecisionSessionPayload): void { + this.currentPayload = payload; + if (!this.view) return; + this.view.webview.html = renderDecisionSessionHtml(payload, { + cspSource: this.view.webview.cspSource, + }); + this.view.show(true); + } + + /** + * Clear the displayed payload (e.g. after the user dismisses) and reset the + * webview to its empty/watching state. + */ + clearPayload(): void { + this.currentPayload = null; + if (!this.view) return; + this.view.webview.html = renderDecisionSessionHtml(null, { + cspSource: this.view.webview.cspSource, + }); + } + + /** + * Visible to tests so the message-routing logic can be exercised directly. + * + * `onSelect` errors are caught + logged here rather than propagated, because + * the caller chain (`webview.onDidReceiveMessage` → `void handleMessage()`) + * has no `await` to catch them — an unhandled rejection would crash the + * extension host. Real failures (e.g. a B4 `injectFn` throwing because the + * Cursor command went away) should surface via the user-facing toast in + * `handleOptionSelection` itself; the catch here is a last-resort guard. + */ + async handleMessage(raw: unknown): Promise { + if (!raw || typeof raw !== 'object') return; + const msg = raw as WebviewMessage; + if (msg.type === 'select') { + if (typeof msg.optionLabel === 'string') { + try { + await this.onSelect(msg.optionLabel); + } catch (err) { + console.error('[nexpath] onSelect failed:', err); + } + } + return; + } + if (msg.type === 'dismiss') { + this.clearPayload(); + return; + } + } + + /** Visible to tests — current payload (null = empty state). */ + getCurrentPayload(): DecisionSessionPayload | null { + return this.currentPayload; + } +} diff --git a/src/ext-vscode/test-fixtures/state-vscdb-samples/cursor-3-4-20-initial-global.json b/src/ext-vscode/test-fixtures/state-vscdb-samples/cursor-3-4-20-initial-global.json new file mode 100644 index 00000000..ba1e461a --- /dev/null +++ b/src/ext-vscode/test-fixtures/state-vscdb-samples/cursor-3-4-20-initial-global.json @@ -0,0 +1,57 @@ +{ + "capturedAt": "2026-05-15T06:51:16.488Z", + "sourcePath": "/home/emptyops/.config/Cursor/User/globalStorage/state.vscdb", + "platform": "linux", + "redacted": true, + "tables": [ + "ItemTable", + "cursorDiskKV" + ], + "rows": [ + { + "table": "ItemTable", + "key": "workbench.panel.composerChatViewPane.ff976495-7338-492f-86bb-ca0f33dfbcc2.hidden", + "value": "[{\"id\":\"****************************************************************\",\"isHidden\":false}]" + }, + { + "table": "ItemTable", + "key": "workbench.panel.composerChatViewPane.a0186af5-4d32-46bc-bad5-62ea2229abfc.hidden", + "value": "[{\"id\":\"****************************************************************\",\"isHidden\":false}]" + }, + { + "table": "ItemTable", + "key": "composer.planMigrationToHomeDirCompleted", + "value": "true" + }, + { + "table": "ItemTable", + "key": "composer.composerHeaders", + "value": "{\"allComposers\":[{\"type\":\"head\",\"composerId\":\"*****************\",\"lastUpdatedAt\":1778827188097,\"createdAt\":1778827188088,\"unifiedMode\":\"chat\",\"forceMode\":\"edit\",\"hasUnreadMessages\":false,\"totalLinesAdded\":0,\"totalLinesRemoved\":0,\"hasBlockingPendingActions\":false,\"hasPendingPlan\":false,\"isArchived\":false,\"isDraft\":true,\"draftTarget\":{\"type\":\"existing\",\"environment\":{\"id\":\"************\"}},\"isWorktree\":false,\"worktreeStartedReadOnly\":false,\"isSpec\":false,\"isProject\":false,\"isBestOfNSubcomposer\":false,\"numSubComposers\":0,\"referencedPlans\":[],\"trackedGitRepos\":[],\"workspaceIdentifier\":{\"id\":\"************\"},\"hasBeenInSidebar\":false},{\"type\":\"head\",\"composerId\":\"************************************\",\"createdAt\":1778826248532,\"unifiedMode\":\"chat\",\"forceMode\":\"edit\",\"hasUnreadMessages\":false,\"totalLinesAdded\":0,\"totalLinesRemoved\":0,\"isArchived\":false,\"isDraft\":false,\"isWorktree\":false,\"worktreeStartedReadOnly\":false,\"isSpec\":false,\"isProject\":false,\"isBestOfNSubcomposer\":false,\"numSubComposers\":0,\"referencedPlans\":[],\"trackedGitRepos\":[],\"workspaceIdentifier\":{\"id\":\"*************\"}},{\"type\":\"head\",\"composerId\":\"************************************\",\"createdAt\":1778826248531,\"unifiedMode\":\"agent\",\"forceMode\":\"edit\",\"hasUnreadMessages\":false,\"totalLinesAdded\":0,\"totalLinesRemoved\":0,\"hasBlockingPendingActions\":false,\"hasPendingPlan\":false,\"isArchived\":false,\"isDraft\":false,\"isWorktree\":false,\"worktreeStartedReadOnly\":false,\"isSpec\":false,\"isProject\":false,\"isBestOfNSubcomposer\":false,\"numSubComposers\":0,\"referencedPlans\":[],\"trackedGitRepos\":[],\"workspaceIdentifier\":{\"id\":\"*************\"}}]}" + }, + { + "table": "ItemTable", + "key": "workbench.backgroundComposer.persistentData", + "value": "{\"dataVersion\":1,\"lastOpenedBcIds\":{}}" + }, + { + "table": "cursorDiskKV", + "key": "composerData:eeb03271-cfe2-45a4-8c80-bc4f8dd5d928", + "value": "{\"_v\":16,\"composerId\":\"************************************\",\"richText\":\"\",\"hasLoaded\":true,\"text\":\"\",\"fullConversationHeadersOnly\":[],\"conversationMap\":{},\"status\":\"none\",\"context\":{\"composers\":[],\"selectedCommits\":[],\"selectedPullRequests\":[],\"selectedImages\":[],\"selectedDocuments\":[],\"selectedVideos\":[],\"folderSelections\":[],\"fileSelections\":[],\"selections\":[],\"terminalSelections\":[],\"selectedDocs\":[],\"externalLinks\":[],\"cursorRules\":[],\"cursorCommands\":[],\"gitPRDiffSelections\":[],\"subagentSelections\":[],\"browserSelections\":[],\"extraContext\":[],\"mentions\":{\"composers\":{},\"selectedCommits\":{},\"selectedPullRequests\":{},\"gitDiff\":[],\"gitDiffFromBranchToMain\":[],\"selectedImages\":{},\"selectedDocuments\":{},\"selectedVideos\":{},\"folderSelections\":{},\"fileSelections\":{},\"terminalFiles\":{},\"selections\":{},\"terminalSelections\":{},\"selectedDocs\":{},\"externalLinks\":{},\"diffHistory\":[],\"cursorRules\":{},\"cursorCommands\":{},\"uiElementSelections\":[],\"consoleLogs\":[],\"ideEditorsState\":[],\"gitPRDiffSelections\":{},\"subagentSelections\":{},\"browserSelections\":{}}},\"generatingBubbleIds\":[],\"isReadingLongFile\":false,\"codeBlockData\":{},\"originalFileStates\":{},\"newlyCreatedFiles\":[],\"newlyCreatedFolders\":[],\"createdAt\":1778826248531,\"hasChangedContext\":false,\"activeTabsShouldBeReactive\":true,\"capabilities\":[{\"type\":15,\"data\":{\"bubbleDataMap\":\"{}\"}},{\"type\":19,\"data\":{}},{\"type\":33,\"data\":{}},{\"type\":32,\"data\":{}},{\"type\":23,\"data\":{}},{\"type\":16,\"data\":{}},{\"type\":24,\"data\":{}},{\"type\":21,\"data\":{}}],\"isFileListExpanded\":false,\"browserChipManuallyDisabled\":false,\"browserChipManuallyEnabled\":false,\"unifiedMode\":\"agent\",\"forceMode\":\"edit\",\"usageData\":{},\"allAttachedFileCodeChunksUris\":[],\"modelConfig\":{\"modelName\":\"default\",\"maxMode\":false},\"subComposerIds\":[],\"subagentComposerIds\":[],\"capabilityContexts\":[],\"todos\":[],\"isQueueExpanded\":true,\"hasUnreadMessages\":false,\"gitHubPromptDismissed\":false,\"totalLinesAdded\":0,\"totalLinesRemoved\":0,\"addedFiles\":0,\"removedFiles\":0,\"isDraft\":false,\"isCreatingWorktree\":false,\"isApplyingWorktree\":false,\"isUndoingWorktree\":false,\"applied\":false,\"pendingCreateWorktree\":false,\"worktreeStartedReadOnly\":false,\"isBestOfNSubcomposer\":false,\"isBestOfNParent\":false,\"isSpec\":false,\"isProject\":false,\"isSpecSubagentDone\":false,\"isContinuationInProgress\":false,\"stopHookLoopCount\":0,\"trackedGitRepos\":[],\"speculativeSummarizationEncryptionKey\":\"********************************************\",\"isNAL\":true,\"planModeSuggestionUsed\":false,\"debugModeSuggestionUsed\":false,\"conversationState\":\"~\",\"queueItems\":[],\"blobEncryptionKey\":\"********************************************\",\"isAgentic\":true}" + }, + { + "table": "cursorDiskKV", + "key": "composerData:e2335a0e-2ca3-451c-baf0-3fc0070a823a", + "value": "{\"_v\":16,\"composerId\":\"************************************\",\"richText\":\"\",\"hasLoaded\":true,\"text\":\"\",\"fullConversationHeadersOnly\":[],\"conversationMap\":{},\"status\":\"none\",\"context\":{\"composers\":[],\"selectedCommits\":[],\"selectedPullRequests\":[],\"selectedImages\":[],\"selectedDocuments\":[],\"selectedVideos\":[],\"folderSelections\":[],\"fileSelections\":[],\"selections\":[],\"terminalSelections\":[],\"selectedDocs\":[],\"externalLinks\":[],\"cursorRules\":[],\"cursorCommands\":[],\"gitPRDiffSelections\":[],\"subagentSelections\":[],\"browserSelections\":[],\"extraContext\":[],\"mentions\":{\"composers\":{},\"selectedCommits\":{},\"selectedPullRequests\":{},\"gitDiff\":[],\"gitDiffFromBranchToMain\":[],\"selectedImages\":{},\"selectedDocuments\":{},\"selectedVideos\":{},\"folderSelections\":{},\"fileSelections\":{},\"terminalFiles\":{},\"selections\":{},\"terminalSelections\":{},\"selectedDocs\":{},\"externalLinks\":{},\"diffHistory\":[],\"cursorRules\":{},\"cursorCommands\":{},\"uiElementSelections\":[],\"consoleLogs\":[],\"ideEditorsState\":[],\"gitPRDiffSelections\":{},\"subagentSelections\":{},\"browserSelections\":{}}},\"generatingBubbleIds\":[],\"isReadingLongFile\":false,\"codeBlockData\":{},\"originalFileStates\":{},\"newlyCreatedFiles\":[],\"newlyCreatedFolders\":[],\"createdAt\":1778826248532,\"hasChangedContext\":false,\"activeTabsShouldBeReactive\":true,\"capabilities\":[],\"isFileListExpanded\":false,\"browserChipManuallyDisabled\":false,\"browserChipManuallyEnabled\":false,\"unifiedMode\":\"chat\",\"forceMode\":\"chat\",\"usageData\":{},\"allAttachedFileCodeChunksUris\":[],\"modelConfig\":{\"modelName\":\"default\",\"maxMode\":false},\"subComposerIds\":[],\"subagentComposerIds\":[],\"capabilityContexts\":[],\"todos\":[],\"isQueueExpanded\":true,\"hasUnreadMessages\":false,\"gitHubPromptDismissed\":false,\"totalLinesAdded\":0,\"totalLinesRemoved\":0,\"addedFiles\":0,\"removedFiles\":0,\"isDraft\":false,\"isCreatingWorktree\":false,\"isApplyingWorktree\":false,\"isUndoingWorktree\":false,\"applied\":false,\"pendingCreateWorktree\":false,\"worktreeStartedReadOnly\":false,\"isBestOfNSubcomposer\":false,\"isBestOfNParent\":false,\"isSpec\":false,\"isProject\":false,\"isSpecSubagentDone\":false,\"isContinuationInProgress\":false,\"stopHookLoopCount\":0,\"trackedGitRepos\":[],\"speculativeSummarizationEncryptionKey\":\"********************************************\",\"isNAL\":true,\"planModeSuggestionUsed\":false,\"debugModeSuggestionUsed\":false,\"conversationState\":\"~\",\"queueItems\":[],\"blobEncryptionKey\":\"********************************************\",\"isAgentic\":false}" + }, + { + "table": "cursorDiskKV", + "key": "composerVirtualRowHeights:_recentIds", + "value": "[]" + }, + { + "table": "cursorDiskKV", + "key": "composerData:empty-state-draft", + "value": "null" + } + ] +} diff --git a/src/ext-vscode/test-fixtures/state-vscdb-samples/cursor-3-4-20-initial-workspace-1778826246907.json b/src/ext-vscode/test-fixtures/state-vscdb-samples/cursor-3-4-20-initial-workspace-1778826246907.json new file mode 100644 index 00000000..b81a4c42 --- /dev/null +++ b/src/ext-vscode/test-fixtures/state-vscdb-samples/cursor-3-4-20-initial-workspace-1778826246907.json @@ -0,0 +1,47 @@ +{ + "capturedAt": "2026-05-15T06:51:16.492Z", + "sourcePath": "/home/emptyops/.config/Cursor/User/workspaceStorage/1778826246907/state.vscdb", + "platform": "linux", + "redacted": true, + "tables": [ + "ItemTable", + "cursorDiskKV" + ], + "rows": [ + { + "table": "ItemTable", + "key": "workbench.panel.composerChatViewPane.ff976495-7338-492f-86bb-ca0f33dfbcc2", + "value": "{\"workbench.panel.aichat.view.eeb03271-cfe2-45a4-8c80-bc4f8dd5d928\":{\"collapsed\":false,\"isHidden\":false}}" + }, + { + "table": "ItemTable", + "key": "workbench.panel.composerChatViewPane.a0186af5-4d32-46bc-bad5-62ea2229abfc", + "value": "{\"workbench.panel.aichat.view.eeb03271-cfe2-45a4-8c80-bc4f8dd5d928\":{\"collapsed\":false,\"isHidden\":false,\"size\":708}}" + }, + { + "table": "ItemTable", + "key": "workbench.panel.aichat.a0186af5-4d32-46bc-bad5-62ea2229abfc.numberOfVisibleViews", + "value": "1" + }, + { + "table": "ItemTable", + "key": "aiService.generations", + "value": "[]" + }, + { + "table": "ItemTable", + "key": "aiService.prompts", + "value": "[]" + }, + { + "table": "ItemTable", + "key": "composer.composerData", + "value": "{\"selectedComposerIds\":[\"************************************\"],\"lastFocusedComposerIds\":[\"************************************\"],\"hasMigratedComposerData\":false,\"hasMigratedMultipleComposers\":true}" + }, + { + "table": "ItemTable", + "key": "workbench.backgroundComposer.workspacePersistentData", + "value": "{\"isSideBarExpanded\":false}" + } + ] +} diff --git a/src/ext-vscode/test-fixtures/state-vscdb-samples/cursor-3-4-20-initial-workspace-empty-window.json b/src/ext-vscode/test-fixtures/state-vscdb-samples/cursor-3-4-20-initial-workspace-empty-window.json new file mode 100644 index 00000000..4f48c86a --- /dev/null +++ b/src/ext-vscode/test-fixtures/state-vscdb-samples/cursor-3-4-20-initial-workspace-empty-window.json @@ -0,0 +1,22 @@ +{ + "capturedAt": "2026-05-15T06:51:16.495Z", + "sourcePath": "/home/emptyops/.config/Cursor/User/workspaceStorage/empty-window/state.vscdb", + "platform": "linux", + "redacted": true, + "tables": [ + "ItemTable", + "cursorDiskKV" + ], + "rows": [ + { + "table": "ItemTable", + "key": "aiService.generations", + "value": "[]" + }, + { + "table": "ItemTable", + "key": "aiService.prompts", + "value": "[]" + } + ] +} diff --git a/src/ext-vscode/test-fixtures/state-vscdb-samples/cursor-live-smoke-global.json b/src/ext-vscode/test-fixtures/state-vscdb-samples/cursor-live-smoke-global.json new file mode 100644 index 00000000..cdef9065 --- /dev/null +++ b/src/ext-vscode/test-fixtures/state-vscdb-samples/cursor-live-smoke-global.json @@ -0,0 +1,107 @@ +{ + "capturedAt": "2026-05-16T12:34:46.504Z", + "sourcePath": "/home/emptyops/.config/Cursor/User/globalStorage/state.vscdb", + "platform": "linux", + "redacted": true, + "tables": [ + "ItemTable", + "cursorDiskKV" + ], + "rows": [ + { + "table": "ItemTable", + "key": "workbench.panel.composerChatViewPane.ff976495-7338-492f-86bb-ca0f33dfbcc2.hidden", + "value": "[{\"id\":\"****************************************************************\",\"isHidden\":false}]" + }, + { + "table": "ItemTable", + "key": "workbench.panel.composerChatViewPane.a0186af5-4d32-46bc-bad5-62ea2229abfc.hidden", + "value": "[{\"id\":\"****************************************************************\",\"isHidden\":false}]" + }, + { + "table": "ItemTable", + "key": "composer.planMigrationToHomeDirCompleted", + "value": "true" + }, + { + "table": "ItemTable", + "key": "composer.composerHeaders", + "value": "{\"allComposers\":[{\"type\":\"head\",\"composerId\":\"************************************\",\"createdAt\":1778934850053,\"unifiedMode\":\"agent\",\"forceMode\":\"edit\",\"hasUnreadMessages\":false,\"totalLinesAdded\":0,\"totalLinesRemoved\":0,\"isArchived\":false,\"isDraft\":false,\"isWorktree\":false,\"worktreeStartedReadOnly\":false,\"isSpec\":false,\"isProject\":false,\"isBestOfNSubcomposer\":false,\"numSubComposers\":0,\"referencedPlans\":[],\"trackedGitRepos\":[],\"workspaceIdentifier\":{\"id\":\"*************\"}},{\"type\":\"head\",\"composerId\":\"************************************\",\"createdAt\":1778934850053,\"unifiedMode\":\"chat\",\"forceMode\":\"edit\",\"hasUnreadMessages\":false,\"totalLinesAdded\":0,\"totalLinesRemoved\":0,\"isArchived\":false,\"isDraft\":false,\"isWorktree\":false,\"worktreeStartedReadOnly\":false,\"isSpec\":false,\"isProject\":false,\"isBestOfNSubcomposer\":false,\"numSubComposers\":0,\"referencedPlans\":[],\"trackedGitRepos\":[],\"workspaceIdentifier\":{\"id\":\"*************\"}},{\"type\":\"head\",\"composerId\":\"************************************\",\"createdAt\":1778934847363,\"unifiedMode\":\"agent\",\"forceMode\":\"edit\",\"hasUnreadMessages\":false,\"totalLinesAdded\":0,\"totalLinesRemoved\":0,\"isArchived\":false,\"isDraft\":false,\"isWorktree\":false,\"worktreeStartedReadOnly\":false,\"isSpec\":false,\"isProject\":false,\"isBestOfNSubcomposer\":false,\"numSubComposers\":0,\"referencedPlans\":[],\"trackedGitRepos\":[],\"workspaceIdentifier\":{\"id\":\"*************\"}},{\"type\":\"head\",\"composerId\":\"************************************\",\"createdAt\":1778934847363,\"unifiedMode\":\"chat\",\"forceMode\":\"edit\",\"hasUnreadMessages\":false,\"totalLinesAdded\":0,\"totalLinesRemoved\":0,\"isArchived\":false,\"isDraft\":false,\"isWorktree\":false,\"worktreeStartedReadOnly\":false,\"isSpec\":false,\"isProject\":false,\"isBestOfNSubcomposer\":false,\"numSubComposers\":0,\"referencedPlans\":[],\"trackedGitRepos\":[],\"workspaceIdentifier\":{\"id\":\"*************\"}},{\"type\":\"head\",\"composerId\":\"************************************\",\"createdAt\":1778934830109,\"unifiedMode\":\"agent\",\"forceMode\":\"edit\",\"hasUnreadMessages\":false,\"totalLinesAdded\":0,\"totalLinesRemoved\":0,\"isArchived\":false,\"isDraft\":false,\"isWorktree\":false,\"worktreeStartedReadOnly\":false,\"isSpec\":false,\"isProject\":false,\"isBestOfNSubcomposer\":false,\"numSubComposers\":0,\"referencedPlans\":[],\"trackedGitRepos\":[],\"workspaceIdentifier\":{\"id\":\"*************\"}},{\"type\":\"head\",\"composerId\":\"************************************\",\"createdAt\":1778934830109,\"unifiedMode\":\"chat\",\"forceMode\":\"edit\",\"hasUnreadMessages\":false,\"totalLinesAdded\":0,\"totalLinesRemoved\":0,\"isArchived\":false,\"isDraft\":false,\"isWorktree\":false,\"worktreeStartedReadOnly\":false,\"isSpec\":false,\"isProject\":false,\"isBestOfNSubcomposer\":false,\"numSubComposers\":0,\"referencedPlans\":[],\"trackedGitRepos\":[],\"workspaceIdentifier\":{\"id\":\"*************\"}},{\"type\":\"head\",\"composerId\":\"************************************\",\"createdAt\":1778934816070,\"unifiedMode\":\"chat\",\"forceMode\":\"edit\",\"hasUnreadMessages\":false,\"totalLinesAdded\":0,\"totalLinesRemoved\":0,\"isArchived\":false,\"isDraft\":false,\"isWorktree\":false,\"worktreeStartedReadOnly\":false,\"isSpec\":false,\"isProject\":false,\"isBestOfNSubcomposer\":false,\"numSubComposers\":0,\"referencedPlans\":[],\"trackedGitRepos\":[],\"workspaceIdentifier\":{\"id\":\"*************\"}},{\"type\":\"head\",\"composerId\":\"************************************\",\"createdAt\":1778934816069,\"unifiedMode\":\"agent\",\"forceMode\":\"edit\",\"hasUnreadMessages\":false,\"totalLinesAdded\":0,\"totalLinesRemoved\":0,\"isArchived\":false,\"isDraft\":false,\"isWorktree\":false,\"worktreeStartedReadOnly\":false,\"isSpec\":false,\"isProject\":false,\"isBestOfNSubcomposer\":false,\"numSubComposers\":0,\"referencedPlans\":[],\"trackedGitRepos\":[],\"workspaceIdentifier\":{\"id\":\"*************\"}},{\"type\":\"head\",\"composerId\":\"************************************\",\"createdAt\":1778934794269,\"unifiedMode\":\"chat\",\"forceMode\":\"edit\",\"hasUnreadMessages\":false,\"totalLinesAdded\":0,\"totalLinesRemoved\":0,\"isArchived\":false,\"isDraft\":false,\"isWorktree\":false,\"worktreeStartedReadOnly\":false,\"isSpec\":false,\"isProject\":false,\"isBestOfNSubcomposer\":false,\"numSubComposers\":0,\"referencedPlans\":[],\"trackedGitRepos\":[],\"workspaceIdentifier\":{\"id\":\"*************\"}},{\"type\":\"head\",\"composerId\":\"************************************\",\"createdAt\":1778934794268,\"unifiedMode\":\"agent\",\"forceMode\":\"edit\",\"hasUnreadMessages\":false,\"totalLinesAdded\":0,\"totalLinesRemoved\":0,\"isArchived\":false,\"isDraft\":false,\"isWorktree\":false,\"worktreeStartedReadOnly\":false,\"isSpec\":false,\"isProject\":false,\"isBestOfNSubcomposer\":false,\"numSubComposers\":0,\"referencedPlans\":[],\"trackedGitRepos\":[],\"workspaceIdentifier\":{\"id\":\"*************\"}},{\"type\":\"head\",\"composerId\":\"*****************\",\"lastUpdatedAt\":1778827188097,\"createdAt\":1778827188088,\"unifiedMode\":\"chat\",\"forceMode\":\"edit\",\"hasUnreadMessages\":false,\"totalLinesAdded\":0,\"totalLinesRemoved\":0,\"hasBlockingPendingActions\":false,\"hasPendingPlan\":false,\"isArchived\":false,\"isDraft\":true,\"draftTarget\":{\"type\":\"existing\",\"environment\":{\"id\":\"************\"}},\"isWorktree\":false,\"worktreeStartedReadOnly\":false,\"isSpec\":false,\"isProject\":false,\"isBestOfNSubcomposer\":false,\"numSubComposers\":0,\"referencedPlans\":[],\"trackedGitRepos\":[],\"workspaceIdentifier\":{\"id\":\"************\"},\"hasBeenInSidebar\":false},{\"type\":\"head\",\"composerId\":\"************************************\",\"createdAt\":1778826248532,\"unifiedMode\":\"chat\",\"forceMode\":\"edit\",\"hasUnreadMessages\":false,\"totalLinesAdded\":0,\"totalLinesRemoved\":0,\"isArchived\":false,\"isDraft\":false,\"isWorktree\":false,\"worktreeStartedReadOnly\":false,\"isSpec\":false,\"isProject\":false,\"isBestOfNSubcomposer\":false,\"numSubComposers\":0,\"referencedPlans\":[],\"trackedGitRepos\":[],\"workspaceIdentifier\":{\"id\":\"*************\"}},{\"type\":\"head\",\"composerId\":\"************************************\",\"createdAt\":1778826248531,\"unifiedMode\":\"agent\",\"forceMode\":\"edit\",\"hasUnreadMessages\":false,\"totalLinesAdded\":0,\"totalLinesRemoved\":0,\"hasBlockingPendingActions\":false,\"hasPendingPlan\":false,\"isArchived\":false,\"isDraft\":false,\"isWorktree\":false,\"worktreeStartedReadOnly\":false,\"isSpec\":false,\"isProject\":false,\"isBestOfNSubcomposer\":false,\"numSubComposers\":0,\"referencedPlans\":[],\"trackedGitRepos\":[],\"workspaceIdentifier\":{\"id\":\"*************\"}}]}" + }, + { + "table": "ItemTable", + "key": "workbench.backgroundComposer.persistentData", + "value": "{\"dataVersion\":1,\"lastOpenedBcIds\":{}}" + }, + { + "table": "cursorDiskKV", + "key": "composerData:eeb03271-cfe2-45a4-8c80-bc4f8dd5d928", + "value": "{\"_v\":16,\"composerId\":\"************************************\",\"richText\":\"\",\"hasLoaded\":true,\"text\":\"\",\"fullConversationHeadersOnly\":[],\"conversationMap\":{},\"status\":\"none\",\"context\":{\"composers\":[],\"selectedCommits\":[],\"selectedPullRequests\":[],\"selectedImages\":[],\"selectedDocuments\":[],\"selectedVideos\":[],\"folderSelections\":[],\"fileSelections\":[],\"selections\":[],\"terminalSelections\":[],\"selectedDocs\":[],\"externalLinks\":[],\"cursorRules\":[],\"cursorCommands\":[],\"gitPRDiffSelections\":[],\"subagentSelections\":[],\"browserSelections\":[],\"extraContext\":[],\"mentions\":{\"composers\":{},\"selectedCommits\":{},\"selectedPullRequests\":{},\"gitDiff\":[],\"gitDiffFromBranchToMain\":[],\"selectedImages\":{},\"selectedDocuments\":{},\"selectedVideos\":{},\"folderSelections\":{},\"fileSelections\":{},\"terminalFiles\":{},\"selections\":{},\"terminalSelections\":{},\"selectedDocs\":{},\"externalLinks\":{},\"diffHistory\":[],\"cursorRules\":{},\"cursorCommands\":{},\"uiElementSelections\":[],\"consoleLogs\":[],\"ideEditorsState\":[],\"gitPRDiffSelections\":{},\"subagentSelections\":{},\"browserSelections\":{}}},\"generatingBubbleIds\":[],\"isReadingLongFile\":false,\"codeBlockData\":{},\"originalFileStates\":{},\"newlyCreatedFiles\":[],\"newlyCreatedFolders\":[],\"createdAt\":1778826248531,\"hasChangedContext\":false,\"activeTabsShouldBeReactive\":true,\"capabilities\":[{\"type\":15,\"data\":{\"bubbleDataMap\":\"{}\"}},{\"type\":19,\"data\":{}},{\"type\":33,\"data\":{}},{\"type\":32,\"data\":{}},{\"type\":23,\"data\":{}},{\"type\":16,\"data\":{}},{\"type\":24,\"data\":{}},{\"type\":21,\"data\":{}}],\"isFileListExpanded\":false,\"browserChipManuallyDisabled\":false,\"browserChipManuallyEnabled\":false,\"unifiedMode\":\"agent\",\"forceMode\":\"edit\",\"usageData\":{},\"allAttachedFileCodeChunksUris\":[],\"modelConfig\":{\"modelName\":\"default\",\"maxMode\":false},\"subComposerIds\":[],\"subagentComposerIds\":[],\"capabilityContexts\":[],\"todos\":[],\"isQueueExpanded\":true,\"hasUnreadMessages\":false,\"gitHubPromptDismissed\":false,\"totalLinesAdded\":0,\"totalLinesRemoved\":0,\"addedFiles\":0,\"removedFiles\":0,\"isDraft\":false,\"isCreatingWorktree\":false,\"isApplyingWorktree\":false,\"isUndoingWorktree\":false,\"applied\":false,\"pendingCreateWorktree\":false,\"worktreeStartedReadOnly\":false,\"isBestOfNSubcomposer\":false,\"isBestOfNParent\":false,\"isSpec\":false,\"isProject\":false,\"isSpecSubagentDone\":false,\"isContinuationInProgress\":false,\"stopHookLoopCount\":0,\"trackedGitRepos\":[],\"speculativeSummarizationEncryptionKey\":\"********************************************\",\"isNAL\":true,\"planModeSuggestionUsed\":false,\"debugModeSuggestionUsed\":false,\"conversationState\":\"~\",\"queueItems\":[],\"blobEncryptionKey\":\"********************************************\",\"isAgentic\":true}" + }, + { + "table": "cursorDiskKV", + "key": "composerData:e2335a0e-2ca3-451c-baf0-3fc0070a823a", + "value": "{\"_v\":16,\"composerId\":\"************************************\",\"richText\":\"\",\"hasLoaded\":true,\"text\":\"\",\"fullConversationHeadersOnly\":[],\"conversationMap\":{},\"status\":\"none\",\"context\":{\"composers\":[],\"selectedCommits\":[],\"selectedPullRequests\":[],\"selectedImages\":[],\"selectedDocuments\":[],\"selectedVideos\":[],\"folderSelections\":[],\"fileSelections\":[],\"selections\":[],\"terminalSelections\":[],\"selectedDocs\":[],\"externalLinks\":[],\"cursorRules\":[],\"cursorCommands\":[],\"gitPRDiffSelections\":[],\"subagentSelections\":[],\"browserSelections\":[],\"extraContext\":[],\"mentions\":{\"composers\":{},\"selectedCommits\":{},\"selectedPullRequests\":{},\"gitDiff\":[],\"gitDiffFromBranchToMain\":[],\"selectedImages\":{},\"selectedDocuments\":{},\"selectedVideos\":{},\"folderSelections\":{},\"fileSelections\":{},\"terminalFiles\":{},\"selections\":{},\"terminalSelections\":{},\"selectedDocs\":{},\"externalLinks\":{},\"diffHistory\":[],\"cursorRules\":{},\"cursorCommands\":{},\"uiElementSelections\":[],\"consoleLogs\":[],\"ideEditorsState\":[],\"gitPRDiffSelections\":{},\"subagentSelections\":{},\"browserSelections\":{}}},\"generatingBubbleIds\":[],\"isReadingLongFile\":false,\"codeBlockData\":{},\"originalFileStates\":{},\"newlyCreatedFiles\":[],\"newlyCreatedFolders\":[],\"createdAt\":1778826248532,\"hasChangedContext\":false,\"activeTabsShouldBeReactive\":true,\"capabilities\":[],\"isFileListExpanded\":false,\"browserChipManuallyDisabled\":false,\"browserChipManuallyEnabled\":false,\"unifiedMode\":\"chat\",\"forceMode\":\"chat\",\"usageData\":{},\"allAttachedFileCodeChunksUris\":[],\"modelConfig\":{\"modelName\":\"default\",\"maxMode\":false},\"subComposerIds\":[],\"subagentComposerIds\":[],\"capabilityContexts\":[],\"todos\":[],\"isQueueExpanded\":true,\"hasUnreadMessages\":false,\"gitHubPromptDismissed\":false,\"totalLinesAdded\":0,\"totalLinesRemoved\":0,\"addedFiles\":0,\"removedFiles\":0,\"isDraft\":false,\"isCreatingWorktree\":false,\"isApplyingWorktree\":false,\"isUndoingWorktree\":false,\"applied\":false,\"pendingCreateWorktree\":false,\"worktreeStartedReadOnly\":false,\"isBestOfNSubcomposer\":false,\"isBestOfNParent\":false,\"isSpec\":false,\"isProject\":false,\"isSpecSubagentDone\":false,\"isContinuationInProgress\":false,\"stopHookLoopCount\":0,\"trackedGitRepos\":[],\"speculativeSummarizationEncryptionKey\":\"********************************************\",\"isNAL\":true,\"planModeSuggestionUsed\":false,\"debugModeSuggestionUsed\":false,\"conversationState\":\"~\",\"queueItems\":[],\"blobEncryptionKey\":\"********************************************\",\"isAgentic\":false}" + }, + { + "table": "cursorDiskKV", + "key": "composerVirtualRowHeights:_recentIds", + "value": "[]" + }, + { + "table": "cursorDiskKV", + "key": "composerData:empty-state-draft", + "value": "null" + }, + { + "table": "cursorDiskKV", + "key": "composerData:e5438e2c-fa92-4105-a720-739bf1a2acd9", + "value": "{\"_v\":16,\"composerId\":\"************************************\",\"richText\":\"\",\"hasLoaded\":true,\"text\":\"\",\"fullConversationHeadersOnly\":[],\"conversationMap\":{},\"status\":\"none\",\"context\":{\"composers\":[],\"selectedCommits\":[],\"selectedPullRequests\":[],\"selectedImages\":[],\"selectedDocuments\":[],\"selectedVideos\":[],\"folderSelections\":[],\"fileSelections\":[],\"selections\":[],\"terminalSelections\":[],\"selectedDocs\":[],\"externalLinks\":[],\"cursorRules\":[],\"cursorCommands\":[],\"gitPRDiffSelections\":[],\"subagentSelections\":[],\"browserSelections\":[],\"extraContext\":[],\"mentions\":{\"composers\":{},\"selectedCommits\":{},\"selectedPullRequests\":{},\"gitDiff\":[],\"gitDiffFromBranchToMain\":[],\"selectedImages\":{},\"selectedDocuments\":{},\"selectedVideos\":{},\"folderSelections\":{},\"fileSelections\":{},\"terminalFiles\":{},\"selections\":{},\"terminalSelections\":{},\"selectedDocs\":{},\"externalLinks\":{},\"diffHistory\":[],\"cursorRules\":{},\"cursorCommands\":{},\"uiElementSelections\":[],\"consoleLogs\":[],\"ideEditorsState\":[],\"gitPRDiffSelections\":{},\"subagentSelections\":{},\"browserSelections\":{}}},\"generatingBubbleIds\":[],\"isReadingLongFile\":false,\"codeBlockData\":{},\"originalFileStates\":{},\"newlyCreatedFiles\":[],\"newlyCreatedFolders\":[],\"createdAt\":1778934794268,\"hasChangedContext\":false,\"activeTabsShouldBeReactive\":true,\"capabilities\":[{\"type\":15,\"data\":{\"bubbleDataMap\":\"{}\"}},{\"type\":19,\"data\":{}},{\"type\":33,\"data\":{}},{\"type\":32,\"data\":{}},{\"type\":23,\"data\":{}},{\"type\":16,\"data\":{}},{\"type\":24,\"data\":{}},{\"type\":21,\"data\":{}}],\"isFileListExpanded\":false,\"browserChipManuallyDisabled\":false,\"browserChipManuallyEnabled\":false,\"unifiedMode\":\"agent\",\"forceMode\":\"edit\",\"usageData\":{},\"allAttachedFileCodeChunksUris\":[],\"modelConfig\":{\"modelName\":\"default\",\"maxMode\":false,\"selectedModels\":[{\"modelId\":\"default\",\"parameters\":[]}]},\"subComposerIds\":[],\"subagentComposerIds\":[],\"capabilityContexts\":[],\"todos\":[],\"isQueueExpanded\":true,\"hasUnreadMessages\":false,\"gitHubPromptDismissed\":false,\"totalLinesAdded\":0,\"totalLinesRemoved\":0,\"addedFiles\":0,\"removedFiles\":0,\"isDraft\":false,\"isCreatingWorktree\":false,\"isApplyingWorktree\":false,\"isUndoingWorktree\":false,\"applied\":false,\"pendingCreateWorktree\":false,\"worktreeStartedReadOnly\":false,\"isBestOfNSubcomposer\":false,\"isBestOfNParent\":false,\"isSpec\":false,\"isProject\":false,\"isSpecSubagentDone\":false,\"isContinuationInProgress\":false,\"stopHookLoopCount\":0,\"trackedGitRepos\":[],\"speculativeSummarizationEncryptionKey\":\"********************************************\",\"isNAL\":true,\"planModeSuggestionUsed\":false,\"debugModeSuggestionUsed\":false,\"conversationState\":\"~\",\"queueItems\":[],\"blobEncryptionKey\":\"********************************************\",\"isAgentic\":true}" + }, + { + "table": "cursorDiskKV", + "key": "composerData:29dbd1d5-e149-4502-bb95-4ba737ca326d", + "value": "{\"_v\":16,\"composerId\":\"************************************\",\"richText\":\"\",\"hasLoaded\":true,\"text\":\"\",\"fullConversationHeadersOnly\":[],\"conversationMap\":{},\"status\":\"none\",\"context\":{\"composers\":[],\"selectedCommits\":[],\"selectedPullRequests\":[],\"selectedImages\":[],\"selectedDocuments\":[],\"selectedVideos\":[],\"folderSelections\":[],\"fileSelections\":[],\"selections\":[],\"terminalSelections\":[],\"selectedDocs\":[],\"externalLinks\":[],\"cursorRules\":[],\"cursorCommands\":[],\"gitPRDiffSelections\":[],\"subagentSelections\":[],\"browserSelections\":[],\"extraContext\":[],\"mentions\":{\"composers\":{},\"selectedCommits\":{},\"selectedPullRequests\":{},\"gitDiff\":[],\"gitDiffFromBranchToMain\":[],\"selectedImages\":{},\"selectedDocuments\":{},\"selectedVideos\":{},\"folderSelections\":{},\"fileSelections\":{},\"terminalFiles\":{},\"selections\":{},\"terminalSelections\":{},\"selectedDocs\":{},\"externalLinks\":{},\"diffHistory\":[],\"cursorRules\":{},\"cursorCommands\":{},\"uiElementSelections\":[],\"consoleLogs\":[],\"ideEditorsState\":[],\"gitPRDiffSelections\":{},\"subagentSelections\":{},\"browserSelections\":{}}},\"generatingBubbleIds\":[],\"isReadingLongFile\":false,\"codeBlockData\":{},\"originalFileStates\":{},\"newlyCreatedFiles\":[],\"newlyCreatedFolders\":[],\"createdAt\":1778934794269,\"hasChangedContext\":false,\"activeTabsShouldBeReactive\":true,\"capabilities\":[],\"isFileListExpanded\":false,\"browserChipManuallyDisabled\":false,\"browserChipManuallyEnabled\":false,\"unifiedMode\":\"chat\",\"forceMode\":\"chat\",\"usageData\":{},\"allAttachedFileCodeChunksUris\":[],\"modelConfig\":{\"modelName\":\"default\",\"maxMode\":false},\"subComposerIds\":[],\"subagentComposerIds\":[],\"capabilityContexts\":[],\"todos\":[],\"isQueueExpanded\":true,\"hasUnreadMessages\":false,\"gitHubPromptDismissed\":false,\"totalLinesAdded\":0,\"totalLinesRemoved\":0,\"addedFiles\":0,\"removedFiles\":0,\"isDraft\":false,\"isCreatingWorktree\":false,\"isApplyingWorktree\":false,\"isUndoingWorktree\":false,\"applied\":false,\"pendingCreateWorktree\":false,\"worktreeStartedReadOnly\":false,\"isBestOfNSubcomposer\":false,\"isBestOfNParent\":false,\"isSpec\":false,\"isProject\":false,\"isSpecSubagentDone\":false,\"isContinuationInProgress\":false,\"stopHookLoopCount\":0,\"trackedGitRepos\":[],\"speculativeSummarizationEncryptionKey\":\"********************************************\",\"isNAL\":true,\"planModeSuggestionUsed\":false,\"debugModeSuggestionUsed\":false,\"conversationState\":\"~\",\"queueItems\":[],\"blobEncryptionKey\":\"********************************************\",\"isAgentic\":false}" + }, + { + "table": "cursorDiskKV", + "key": "composerData:fb2a27b8-a827-4e54-9979-74917f91b1d1", + "value": "{\"_v\":16,\"composerId\":\"************************************\",\"richText\":\"\",\"hasLoaded\":true,\"text\":\"\",\"fullConversationHeadersOnly\":[],\"conversationMap\":{},\"status\":\"none\",\"context\":{\"composers\":[],\"selectedCommits\":[],\"selectedPullRequests\":[],\"selectedImages\":[],\"selectedDocuments\":[],\"selectedVideos\":[],\"folderSelections\":[],\"fileSelections\":[],\"selections\":[],\"terminalSelections\":[],\"selectedDocs\":[],\"externalLinks\":[],\"cursorRules\":[],\"cursorCommands\":[],\"gitPRDiffSelections\":[],\"subagentSelections\":[],\"browserSelections\":[],\"extraContext\":[],\"mentions\":{\"composers\":{},\"selectedCommits\":{},\"selectedPullRequests\":{},\"gitDiff\":[],\"gitDiffFromBranchToMain\":[],\"selectedImages\":{},\"selectedDocuments\":{},\"selectedVideos\":{},\"folderSelections\":{},\"fileSelections\":{},\"terminalFiles\":{},\"selections\":{},\"terminalSelections\":{},\"selectedDocs\":{},\"externalLinks\":{},\"diffHistory\":[],\"cursorRules\":{},\"cursorCommands\":{},\"uiElementSelections\":[],\"consoleLogs\":[],\"ideEditorsState\":[],\"gitPRDiffSelections\":{},\"subagentSelections\":{},\"browserSelections\":{}}},\"generatingBubbleIds\":[],\"isReadingLongFile\":false,\"codeBlockData\":{},\"originalFileStates\":{},\"newlyCreatedFiles\":[],\"newlyCreatedFolders\":[],\"createdAt\":1778934816069,\"hasChangedContext\":false,\"activeTabsShouldBeReactive\":true,\"capabilities\":[],\"isFileListExpanded\":false,\"browserChipManuallyDisabled\":false,\"browserChipManuallyEnabled\":false,\"unifiedMode\":\"agent\",\"forceMode\":\"edit\",\"usageData\":{},\"allAttachedFileCodeChunksUris\":[],\"modelConfig\":{\"modelName\":\"default\",\"maxMode\":false},\"subComposerIds\":[],\"subagentComposerIds\":[],\"capabilityContexts\":[],\"todos\":[],\"isQueueExpanded\":true,\"hasUnreadMessages\":false,\"gitHubPromptDismissed\":false,\"totalLinesAdded\":0,\"totalLinesRemoved\":0,\"addedFiles\":0,\"removedFiles\":0,\"isDraft\":false,\"isCreatingWorktree\":false,\"isApplyingWorktree\":false,\"isUndoingWorktree\":false,\"applied\":false,\"pendingCreateWorktree\":false,\"worktreeStartedReadOnly\":false,\"isBestOfNSubcomposer\":false,\"isBestOfNParent\":false,\"isSpec\":false,\"isProject\":false,\"isSpecSubagentDone\":false,\"isContinuationInProgress\":false,\"stopHookLoopCount\":0,\"trackedGitRepos\":[],\"speculativeSummarizationEncryptionKey\":\"********************************************\",\"isNAL\":true,\"planModeSuggestionUsed\":false,\"debugModeSuggestionUsed\":false,\"conversationState\":\"~\",\"queueItems\":[],\"blobEncryptionKey\":\"********************************************\",\"isAgentic\":true}" + }, + { + "table": "cursorDiskKV", + "key": "composerData:3cdee998-85e9-4584-b29d-2be20d07aed2", + "value": "{\"_v\":16,\"composerId\":\"************************************\",\"richText\":\"\",\"hasLoaded\":true,\"text\":\"\",\"fullConversationHeadersOnly\":[],\"conversationMap\":{},\"status\":\"none\",\"context\":{\"composers\":[],\"selectedCommits\":[],\"selectedPullRequests\":[],\"selectedImages\":[],\"selectedDocuments\":[],\"selectedVideos\":[],\"folderSelections\":[],\"fileSelections\":[],\"selections\":[],\"terminalSelections\":[],\"selectedDocs\":[],\"externalLinks\":[],\"cursorRules\":[],\"cursorCommands\":[],\"gitPRDiffSelections\":[],\"subagentSelections\":[],\"browserSelections\":[],\"extraContext\":[],\"mentions\":{\"composers\":{},\"selectedCommits\":{},\"selectedPullRequests\":{},\"gitDiff\":[],\"gitDiffFromBranchToMain\":[],\"selectedImages\":{},\"selectedDocuments\":{},\"selectedVideos\":{},\"folderSelections\":{},\"fileSelections\":{},\"terminalFiles\":{},\"selections\":{},\"terminalSelections\":{},\"selectedDocs\":{},\"externalLinks\":{},\"diffHistory\":[],\"cursorRules\":{},\"cursorCommands\":{},\"uiElementSelections\":[],\"consoleLogs\":[],\"ideEditorsState\":[],\"gitPRDiffSelections\":{},\"subagentSelections\":{},\"browserSelections\":{}}},\"generatingBubbleIds\":[],\"isReadingLongFile\":false,\"codeBlockData\":{},\"originalFileStates\":{},\"newlyCreatedFiles\":[],\"newlyCreatedFolders\":[],\"createdAt\":1778934816070,\"hasChangedContext\":false,\"activeTabsShouldBeReactive\":true,\"capabilities\":[],\"isFileListExpanded\":false,\"browserChipManuallyDisabled\":false,\"browserChipManuallyEnabled\":false,\"unifiedMode\":\"chat\",\"forceMode\":\"chat\",\"usageData\":{},\"allAttachedFileCodeChunksUris\":[],\"modelConfig\":{\"modelName\":\"default\",\"maxMode\":false},\"subComposerIds\":[],\"subagentComposerIds\":[],\"capabilityContexts\":[],\"todos\":[],\"isQueueExpanded\":true,\"hasUnreadMessages\":false,\"gitHubPromptDismissed\":false,\"totalLinesAdded\":0,\"totalLinesRemoved\":0,\"addedFiles\":0,\"removedFiles\":0,\"isDraft\":false,\"isCreatingWorktree\":false,\"isApplyingWorktree\":false,\"isUndoingWorktree\":false,\"applied\":false,\"pendingCreateWorktree\":false,\"worktreeStartedReadOnly\":false,\"isBestOfNSubcomposer\":false,\"isBestOfNParent\":false,\"isSpec\":false,\"isProject\":false,\"isSpecSubagentDone\":false,\"isContinuationInProgress\":false,\"stopHookLoopCount\":0,\"trackedGitRepos\":[],\"speculativeSummarizationEncryptionKey\":\"********************************************\",\"isNAL\":true,\"planModeSuggestionUsed\":false,\"debugModeSuggestionUsed\":false,\"conversationState\":\"~\",\"queueItems\":[],\"blobEncryptionKey\":\"********************************************\",\"isAgentic\":false}" + }, + { + "table": "cursorDiskKV", + "key": "composerData:c4aa3065-9860-420e-89e8-5b87e7a657c0", + "value": "{\"_v\":16,\"composerId\":\"************************************\",\"richText\":\"\",\"hasLoaded\":true,\"text\":\"\",\"fullConversationHeadersOnly\":[],\"conversationMap\":{},\"status\":\"none\",\"context\":{\"composers\":[],\"selectedCommits\":[],\"selectedPullRequests\":[],\"selectedImages\":[],\"selectedDocuments\":[],\"selectedVideos\":[],\"folderSelections\":[],\"fileSelections\":[],\"selections\":[],\"terminalSelections\":[],\"selectedDocs\":[],\"externalLinks\":[],\"cursorRules\":[],\"cursorCommands\":[],\"gitPRDiffSelections\":[],\"subagentSelections\":[],\"browserSelections\":[],\"extraContext\":[],\"mentions\":{\"composers\":{},\"selectedCommits\":{},\"selectedPullRequests\":{},\"gitDiff\":[],\"gitDiffFromBranchToMain\":[],\"selectedImages\":{},\"selectedDocuments\":{},\"selectedVideos\":{},\"folderSelections\":{},\"fileSelections\":{},\"terminalFiles\":{},\"selections\":{},\"terminalSelections\":{},\"selectedDocs\":{},\"externalLinks\":{},\"diffHistory\":[],\"cursorRules\":{},\"cursorCommands\":{},\"uiElementSelections\":[],\"consoleLogs\":[],\"ideEditorsState\":[],\"gitPRDiffSelections\":{},\"subagentSelections\":{},\"browserSelections\":{}}},\"generatingBubbleIds\":[],\"isReadingLongFile\":false,\"codeBlockData\":{},\"originalFileStates\":{},\"newlyCreatedFiles\":[],\"newlyCreatedFolders\":[],\"createdAt\":1778934830109,\"hasChangedContext\":false,\"activeTabsShouldBeReactive\":true,\"capabilities\":[],\"isFileListExpanded\":false,\"browserChipManuallyDisabled\":false,\"browserChipManuallyEnabled\":false,\"unifiedMode\":\"agent\",\"forceMode\":\"edit\",\"usageData\":{},\"allAttachedFileCodeChunksUris\":[],\"modelConfig\":{\"modelName\":\"default\",\"maxMode\":false},\"subComposerIds\":[],\"subagentComposerIds\":[],\"capabilityContexts\":[],\"todos\":[],\"isQueueExpanded\":true,\"hasUnreadMessages\":false,\"gitHubPromptDismissed\":false,\"totalLinesAdded\":0,\"totalLinesRemoved\":0,\"addedFiles\":0,\"removedFiles\":0,\"isDraft\":false,\"isCreatingWorktree\":false,\"isApplyingWorktree\":false,\"isUndoingWorktree\":false,\"applied\":false,\"pendingCreateWorktree\":false,\"worktreeStartedReadOnly\":false,\"isBestOfNSubcomposer\":false,\"isBestOfNParent\":false,\"isSpec\":false,\"isProject\":false,\"isSpecSubagentDone\":false,\"isContinuationInProgress\":false,\"stopHookLoopCount\":0,\"trackedGitRepos\":[],\"speculativeSummarizationEncryptionKey\":\"********************************************\",\"isNAL\":true,\"planModeSuggestionUsed\":false,\"debugModeSuggestionUsed\":false,\"conversationState\":\"~\",\"queueItems\":[],\"blobEncryptionKey\":\"********************************************\",\"isAgentic\":true}" + }, + { + "table": "cursorDiskKV", + "key": "composerData:4e7a5e25-e753-422d-b9a6-6dac77cd1f2d", + "value": "{\"_v\":16,\"composerId\":\"************************************\",\"richText\":\"\",\"hasLoaded\":true,\"text\":\"\",\"fullConversationHeadersOnly\":[],\"conversationMap\":{},\"status\":\"none\",\"context\":{\"composers\":[],\"selectedCommits\":[],\"selectedPullRequests\":[],\"selectedImages\":[],\"selectedDocuments\":[],\"selectedVideos\":[],\"folderSelections\":[],\"fileSelections\":[],\"selections\":[],\"terminalSelections\":[],\"selectedDocs\":[],\"externalLinks\":[],\"cursorRules\":[],\"cursorCommands\":[],\"gitPRDiffSelections\":[],\"subagentSelections\":[],\"browserSelections\":[],\"extraContext\":[],\"mentions\":{\"composers\":{},\"selectedCommits\":{},\"selectedPullRequests\":{},\"gitDiff\":[],\"gitDiffFromBranchToMain\":[],\"selectedImages\":{},\"selectedDocuments\":{},\"selectedVideos\":{},\"folderSelections\":{},\"fileSelections\":{},\"terminalFiles\":{},\"selections\":{},\"terminalSelections\":{},\"selectedDocs\":{},\"externalLinks\":{},\"diffHistory\":[],\"cursorRules\":{},\"cursorCommands\":{},\"uiElementSelections\":[],\"consoleLogs\":[],\"ideEditorsState\":[],\"gitPRDiffSelections\":{},\"subagentSelections\":{},\"browserSelections\":{}}},\"generatingBubbleIds\":[],\"isReadingLongFile\":false,\"codeBlockData\":{},\"originalFileStates\":{},\"newlyCreatedFiles\":[],\"newlyCreatedFolders\":[],\"createdAt\":1778934830109,\"hasChangedContext\":false,\"activeTabsShouldBeReactive\":true,\"capabilities\":[],\"isFileListExpanded\":false,\"browserChipManuallyDisabled\":false,\"browserChipManuallyEnabled\":false,\"unifiedMode\":\"chat\",\"forceMode\":\"chat\",\"usageData\":{},\"allAttachedFileCodeChunksUris\":[],\"modelConfig\":{\"modelName\":\"default\",\"maxMode\":false},\"subComposerIds\":[],\"subagentComposerIds\":[],\"capabilityContexts\":[],\"todos\":[],\"isQueueExpanded\":true,\"hasUnreadMessages\":false,\"gitHubPromptDismissed\":false,\"totalLinesAdded\":0,\"totalLinesRemoved\":0,\"addedFiles\":0,\"removedFiles\":0,\"isDraft\":false,\"isCreatingWorktree\":false,\"isApplyingWorktree\":false,\"isUndoingWorktree\":false,\"applied\":false,\"pendingCreateWorktree\":false,\"worktreeStartedReadOnly\":false,\"isBestOfNSubcomposer\":false,\"isBestOfNParent\":false,\"isSpec\":false,\"isProject\":false,\"isSpecSubagentDone\":false,\"isContinuationInProgress\":false,\"stopHookLoopCount\":0,\"trackedGitRepos\":[],\"speculativeSummarizationEncryptionKey\":\"********************************************\",\"isNAL\":true,\"planModeSuggestionUsed\":false,\"debugModeSuggestionUsed\":false,\"conversationState\":\"~\",\"queueItems\":[],\"blobEncryptionKey\":\"********************************************\",\"isAgentic\":false}" + }, + { + "table": "cursorDiskKV", + "key": "composerData:20870290-0fa9-4c29-8eff-149f10d73d47", + "value": "{\"_v\":16,\"composerId\":\"************************************\",\"richText\":\"\",\"hasLoaded\":true,\"text\":\"\",\"fullConversationHeadersOnly\":[],\"conversationMap\":{},\"status\":\"none\",\"context\":{\"composers\":[],\"selectedCommits\":[],\"selectedPullRequests\":[],\"selectedImages\":[],\"selectedDocuments\":[],\"selectedVideos\":[],\"folderSelections\":[],\"fileSelections\":[],\"selections\":[],\"terminalSelections\":[],\"selectedDocs\":[],\"externalLinks\":[],\"cursorRules\":[],\"cursorCommands\":[],\"gitPRDiffSelections\":[],\"subagentSelections\":[],\"browserSelections\":[],\"extraContext\":[],\"mentions\":{\"composers\":{},\"selectedCommits\":{},\"selectedPullRequests\":{},\"gitDiff\":[],\"gitDiffFromBranchToMain\":[],\"selectedImages\":{},\"selectedDocuments\":{},\"selectedVideos\":{},\"folderSelections\":{},\"fileSelections\":{},\"terminalFiles\":{},\"selections\":{},\"terminalSelections\":{},\"selectedDocs\":{},\"externalLinks\":{},\"diffHistory\":[],\"cursorRules\":{},\"cursorCommands\":{},\"uiElementSelections\":[],\"consoleLogs\":[],\"ideEditorsState\":[],\"gitPRDiffSelections\":{},\"subagentSelections\":{},\"browserSelections\":{}}},\"generatingBubbleIds\":[],\"isReadingLongFile\":false,\"codeBlockData\":{},\"originalFileStates\":{},\"newlyCreatedFiles\":[],\"newlyCreatedFolders\":[],\"createdAt\":1778934847363,\"hasChangedContext\":false,\"activeTabsShouldBeReactive\":true,\"capabilities\":[],\"isFileListExpanded\":false,\"browserChipManuallyDisabled\":false,\"browserChipManuallyEnabled\":false,\"unifiedMode\":\"agent\",\"forceMode\":\"edit\",\"usageData\":{},\"allAttachedFileCodeChunksUris\":[],\"modelConfig\":{\"modelName\":\"default\",\"maxMode\":false},\"subComposerIds\":[],\"subagentComposerIds\":[],\"capabilityContexts\":[],\"todos\":[],\"isQueueExpanded\":true,\"hasUnreadMessages\":false,\"gitHubPromptDismissed\":false,\"totalLinesAdded\":0,\"totalLinesRemoved\":0,\"addedFiles\":0,\"removedFiles\":0,\"isDraft\":false,\"isCreatingWorktree\":false,\"isApplyingWorktree\":false,\"isUndoingWorktree\":false,\"applied\":false,\"pendingCreateWorktree\":false,\"worktreeStartedReadOnly\":false,\"isBestOfNSubcomposer\":false,\"isBestOfNParent\":false,\"isSpec\":false,\"isProject\":false,\"isSpecSubagentDone\":false,\"isContinuationInProgress\":false,\"stopHookLoopCount\":0,\"trackedGitRepos\":[],\"speculativeSummarizationEncryptionKey\":\"********************************************\",\"isNAL\":true,\"planModeSuggestionUsed\":false,\"debugModeSuggestionUsed\":false,\"conversationState\":\"~\",\"queueItems\":[],\"blobEncryptionKey\":\"********************************************\",\"isAgentic\":true}" + }, + { + "table": "cursorDiskKV", + "key": "composerData:e48907f7-a8ba-408e-82e7-9b1a9fcc9aad", + "value": "{\"_v\":16,\"composerId\":\"************************************\",\"richText\":\"\",\"hasLoaded\":true,\"text\":\"\",\"fullConversationHeadersOnly\":[],\"conversationMap\":{},\"status\":\"none\",\"context\":{\"composers\":[],\"selectedCommits\":[],\"selectedPullRequests\":[],\"selectedImages\":[],\"selectedDocuments\":[],\"selectedVideos\":[],\"folderSelections\":[],\"fileSelections\":[],\"selections\":[],\"terminalSelections\":[],\"selectedDocs\":[],\"externalLinks\":[],\"cursorRules\":[],\"cursorCommands\":[],\"gitPRDiffSelections\":[],\"subagentSelections\":[],\"browserSelections\":[],\"extraContext\":[],\"mentions\":{\"composers\":{},\"selectedCommits\":{},\"selectedPullRequests\":{},\"gitDiff\":[],\"gitDiffFromBranchToMain\":[],\"selectedImages\":{},\"selectedDocuments\":{},\"selectedVideos\":{},\"folderSelections\":{},\"fileSelections\":{},\"terminalFiles\":{},\"selections\":{},\"terminalSelections\":{},\"selectedDocs\":{},\"externalLinks\":{},\"diffHistory\":[],\"cursorRules\":{},\"cursorCommands\":{},\"uiElementSelections\":[],\"consoleLogs\":[],\"ideEditorsState\":[],\"gitPRDiffSelections\":{},\"subagentSelections\":{},\"browserSelections\":{}}},\"generatingBubbleIds\":[],\"isReadingLongFile\":false,\"codeBlockData\":{},\"originalFileStates\":{},\"newlyCreatedFiles\":[],\"newlyCreatedFolders\":[],\"createdAt\":1778934847363,\"hasChangedContext\":false,\"activeTabsShouldBeReactive\":true,\"capabilities\":[],\"isFileListExpanded\":false,\"browserChipManuallyDisabled\":false,\"browserChipManuallyEnabled\":false,\"unifiedMode\":\"chat\",\"forceMode\":\"chat\",\"usageData\":{},\"allAttachedFileCodeChunksUris\":[],\"modelConfig\":{\"modelName\":\"default\",\"maxMode\":false},\"subComposerIds\":[],\"subagentComposerIds\":[],\"capabilityContexts\":[],\"todos\":[],\"isQueueExpanded\":true,\"hasUnreadMessages\":false,\"gitHubPromptDismissed\":false,\"totalLinesAdded\":0,\"totalLinesRemoved\":0,\"addedFiles\":0,\"removedFiles\":0,\"isDraft\":false,\"isCreatingWorktree\":false,\"isApplyingWorktree\":false,\"isUndoingWorktree\":false,\"applied\":false,\"pendingCreateWorktree\":false,\"worktreeStartedReadOnly\":false,\"isBestOfNSubcomposer\":false,\"isBestOfNParent\":false,\"isSpec\":false,\"isProject\":false,\"isSpecSubagentDone\":false,\"isContinuationInProgress\":false,\"stopHookLoopCount\":0,\"trackedGitRepos\":[],\"speculativeSummarizationEncryptionKey\":\"********************************************\",\"isNAL\":true,\"planModeSuggestionUsed\":false,\"debugModeSuggestionUsed\":false,\"conversationState\":\"~\",\"queueItems\":[],\"blobEncryptionKey\":\"********************************************\",\"isAgentic\":false}" + }, + { + "table": "cursorDiskKV", + "key": "composerData:41cee027-88a4-437c-aa50-c3f6b177efae", + "value": "{\"_v\":16,\"composerId\":\"************************************\",\"richText\":\"\",\"hasLoaded\":true,\"text\":\"\",\"fullConversationHeadersOnly\":[],\"conversationMap\":{},\"status\":\"none\",\"context\":{\"composers\":[],\"selectedCommits\":[],\"selectedPullRequests\":[],\"selectedImages\":[],\"selectedDocuments\":[],\"selectedVideos\":[],\"folderSelections\":[],\"fileSelections\":[],\"selections\":[],\"terminalSelections\":[],\"selectedDocs\":[],\"externalLinks\":[],\"cursorRules\":[],\"cursorCommands\":[],\"gitPRDiffSelections\":[],\"subagentSelections\":[],\"browserSelections\":[],\"extraContext\":[],\"mentions\":{\"composers\":{},\"selectedCommits\":{},\"selectedPullRequests\":{},\"gitDiff\":[],\"gitDiffFromBranchToMain\":[],\"selectedImages\":{},\"selectedDocuments\":{},\"selectedVideos\":{},\"folderSelections\":{},\"fileSelections\":{},\"terminalFiles\":{},\"selections\":{},\"terminalSelections\":{},\"selectedDocs\":{},\"externalLinks\":{},\"diffHistory\":[],\"cursorRules\":{},\"cursorCommands\":{},\"uiElementSelections\":[],\"consoleLogs\":[],\"ideEditorsState\":[],\"gitPRDiffSelections\":{},\"subagentSelections\":{},\"browserSelections\":{}}},\"generatingBubbleIds\":[],\"isReadingLongFile\":false,\"codeBlockData\":{},\"originalFileStates\":{},\"newlyCreatedFiles\":[],\"newlyCreatedFolders\":[],\"createdAt\":1778934850053,\"hasChangedContext\":false,\"activeTabsShouldBeReactive\":true,\"capabilities\":[],\"isFileListExpanded\":false,\"browserChipManuallyDisabled\":false,\"browserChipManuallyEnabled\":false,\"unifiedMode\":\"agent\",\"forceMode\":\"edit\",\"usageData\":{},\"allAttachedFileCodeChunksUris\":[],\"modelConfig\":{\"modelName\":\"default\",\"maxMode\":false},\"subComposerIds\":[],\"subagentComposerIds\":[],\"capabilityContexts\":[],\"todos\":[],\"isQueueExpanded\":true,\"hasUnreadMessages\":false,\"gitHubPromptDismissed\":false,\"totalLinesAdded\":0,\"totalLinesRemoved\":0,\"addedFiles\":0,\"removedFiles\":0,\"isDraft\":false,\"isCreatingWorktree\":false,\"isApplyingWorktree\":false,\"isUndoingWorktree\":false,\"applied\":false,\"pendingCreateWorktree\":false,\"worktreeStartedReadOnly\":false,\"isBestOfNSubcomposer\":false,\"isBestOfNParent\":false,\"isSpec\":false,\"isProject\":false,\"isSpecSubagentDone\":false,\"isContinuationInProgress\":false,\"stopHookLoopCount\":0,\"trackedGitRepos\":[],\"speculativeSummarizationEncryptionKey\":\"********************************************\",\"isNAL\":true,\"planModeSuggestionUsed\":false,\"debugModeSuggestionUsed\":false,\"conversationState\":\"~\",\"queueItems\":[],\"blobEncryptionKey\":\"********************************************\",\"isAgentic\":true}" + }, + { + "table": "cursorDiskKV", + "key": "composerData:6114ed61-c435-4773-804b-0569b92c80e7", + "value": "{\"_v\":16,\"composerId\":\"************************************\",\"richText\":\"\",\"hasLoaded\":true,\"text\":\"\",\"fullConversationHeadersOnly\":[],\"conversationMap\":{},\"status\":\"none\",\"context\":{\"composers\":[],\"selectedCommits\":[],\"selectedPullRequests\":[],\"selectedImages\":[],\"selectedDocuments\":[],\"selectedVideos\":[],\"folderSelections\":[],\"fileSelections\":[],\"selections\":[],\"terminalSelections\":[],\"selectedDocs\":[],\"externalLinks\":[],\"cursorRules\":[],\"cursorCommands\":[],\"gitPRDiffSelections\":[],\"subagentSelections\":[],\"browserSelections\":[],\"extraContext\":[],\"mentions\":{\"composers\":{},\"selectedCommits\":{},\"selectedPullRequests\":{},\"gitDiff\":[],\"gitDiffFromBranchToMain\":[],\"selectedImages\":{},\"selectedDocuments\":{},\"selectedVideos\":{},\"folderSelections\":{},\"fileSelections\":{},\"terminalFiles\":{},\"selections\":{},\"terminalSelections\":{},\"selectedDocs\":{},\"externalLinks\":{},\"diffHistory\":[],\"cursorRules\":{},\"cursorCommands\":{},\"uiElementSelections\":[],\"consoleLogs\":[],\"ideEditorsState\":[],\"gitPRDiffSelections\":{},\"subagentSelections\":{},\"browserSelections\":{}}},\"generatingBubbleIds\":[],\"isReadingLongFile\":false,\"codeBlockData\":{},\"originalFileStates\":{},\"newlyCreatedFiles\":[],\"newlyCreatedFolders\":[],\"createdAt\":1778934850053,\"hasChangedContext\":false,\"activeTabsShouldBeReactive\":true,\"capabilities\":[],\"isFileListExpanded\":false,\"browserChipManuallyDisabled\":false,\"browserChipManuallyEnabled\":false,\"unifiedMode\":\"chat\",\"forceMode\":\"chat\",\"usageData\":{},\"allAttachedFileCodeChunksUris\":[],\"modelConfig\":{\"modelName\":\"default\",\"maxMode\":false},\"subComposerIds\":[],\"subagentComposerIds\":[],\"capabilityContexts\":[],\"todos\":[],\"isQueueExpanded\":true,\"hasUnreadMessages\":false,\"gitHubPromptDismissed\":false,\"totalLinesAdded\":0,\"totalLinesRemoved\":0,\"addedFiles\":0,\"removedFiles\":0,\"isDraft\":false,\"isCreatingWorktree\":false,\"isApplyingWorktree\":false,\"isUndoingWorktree\":false,\"applied\":false,\"pendingCreateWorktree\":false,\"worktreeStartedReadOnly\":false,\"isBestOfNSubcomposer\":false,\"isBestOfNParent\":false,\"isSpec\":false,\"isProject\":false,\"isSpecSubagentDone\":false,\"isContinuationInProgress\":false,\"stopHookLoopCount\":0,\"trackedGitRepos\":[],\"speculativeSummarizationEncryptionKey\":\"********************************************\",\"isNAL\":true,\"planModeSuggestionUsed\":false,\"debugModeSuggestionUsed\":false,\"conversationState\":\"~\",\"queueItems\":[],\"blobEncryptionKey\":\"********************************************\",\"isAgentic\":false}" + } + ] +} diff --git a/src/ext-vscode/test-fixtures/state-vscdb-samples/cursor-live-smoke-workspace-1778826246907.json b/src/ext-vscode/test-fixtures/state-vscdb-samples/cursor-live-smoke-workspace-1778826246907.json new file mode 100644 index 00000000..a72ba764 --- /dev/null +++ b/src/ext-vscode/test-fixtures/state-vscdb-samples/cursor-live-smoke-workspace-1778826246907.json @@ -0,0 +1,47 @@ +{ + "capturedAt": "2026-05-16T12:34:46.509Z", + "sourcePath": "/home/emptyops/.config/Cursor/User/workspaceStorage/1778826246907/state.vscdb", + "platform": "linux", + "redacted": true, + "tables": [ + "ItemTable", + "cursorDiskKV" + ], + "rows": [ + { + "table": "ItemTable", + "key": "workbench.panel.composerChatViewPane.ff976495-7338-492f-86bb-ca0f33dfbcc2", + "value": "{\"workbench.panel.aichat.view.eeb03271-cfe2-45a4-8c80-bc4f8dd5d928\":{\"collapsed\":false,\"isHidden\":false}}" + }, + { + "table": "ItemTable", + "key": "workbench.panel.composerChatViewPane.a0186af5-4d32-46bc-bad5-62ea2229abfc", + "value": "{\"workbench.panel.aichat.view.eeb03271-cfe2-45a4-8c80-bc4f8dd5d928\":{\"collapsed\":false,\"isHidden\":false,\"size\":708}}" + }, + { + "table": "ItemTable", + "key": "workbench.panel.aichat.a0186af5-4d32-46bc-bad5-62ea2229abfc.numberOfVisibleViews", + "value": "1" + }, + { + "table": "ItemTable", + "key": "aiService.generations", + "value": "[]" + }, + { + "table": "ItemTable", + "key": "aiService.prompts", + "value": "[]" + }, + { + "table": "ItemTable", + "key": "composer.composerData", + "value": "{\"selectedComposerIds\":[\"************************************\"],\"lastFocusedComposerIds\":[\"************************************\"],\"hasMigratedComposerData\":false,\"hasMigratedMultipleComposers\":true}" + }, + { + "table": "ItemTable", + "key": "workbench.backgroundComposer.workspacePersistentData", + "value": "{\"isSideBarExpanded\":false}" + } + ] +} diff --git a/src/ext-vscode/test-fixtures/state-vscdb-samples/cursor-live-smoke-workspace-1778934792294.json b/src/ext-vscode/test-fixtures/state-vscdb-samples/cursor-live-smoke-workspace-1778934792294.json new file mode 100644 index 00000000..6b39ac3b --- /dev/null +++ b/src/ext-vscode/test-fixtures/state-vscdb-samples/cursor-live-smoke-workspace-1778934792294.json @@ -0,0 +1,32 @@ +{ + "capturedAt": "2026-05-16T12:34:46.511Z", + "sourcePath": "/home/emptyops/.config/Cursor/User/workspaceStorage/1778934792294/state.vscdb", + "platform": "linux", + "redacted": true, + "tables": [ + "ItemTable", + "cursorDiskKV" + ], + "rows": [ + { + "table": "ItemTable", + "key": "aiService.generations", + "value": "[]" + }, + { + "table": "ItemTable", + "key": "aiService.prompts", + "value": "[]" + }, + { + "table": "ItemTable", + "key": "composer.composerData", + "value": "{\"selectedComposerIds\":[\"************************************\"],\"lastFocusedComposerIds\":[\"************************************\"],\"hasMigratedComposerData\":false,\"hasMigratedMultipleComposers\":true}" + }, + { + "table": "ItemTable", + "key": "workbench.backgroundComposer.workspacePersistentData", + "value": "{\"isSideBarExpanded\":false}" + } + ] +} diff --git a/src/ext-vscode/test-fixtures/state-vscdb-samples/cursor-live-smoke-workspace-1778934814549.json b/src/ext-vscode/test-fixtures/state-vscdb-samples/cursor-live-smoke-workspace-1778934814549.json new file mode 100644 index 00000000..2707838b --- /dev/null +++ b/src/ext-vscode/test-fixtures/state-vscdb-samples/cursor-live-smoke-workspace-1778934814549.json @@ -0,0 +1,32 @@ +{ + "capturedAt": "2026-05-16T12:34:46.512Z", + "sourcePath": "/home/emptyops/.config/Cursor/User/workspaceStorage/1778934814549/state.vscdb", + "platform": "linux", + "redacted": true, + "tables": [ + "ItemTable", + "cursorDiskKV" + ], + "rows": [ + { + "table": "ItemTable", + "key": "aiService.generations", + "value": "[]" + }, + { + "table": "ItemTable", + "key": "aiService.prompts", + "value": "[]" + }, + { + "table": "ItemTable", + "key": "composer.composerData", + "value": "{\"selectedComposerIds\":[\"************************************\"],\"lastFocusedComposerIds\":[\"************************************\"],\"hasMigratedComposerData\":false,\"hasMigratedMultipleComposers\":true}" + }, + { + "table": "ItemTable", + "key": "workbench.backgroundComposer.workspacePersistentData", + "value": "{\"isSideBarExpanded\":false}" + } + ] +} diff --git a/src/ext-vscode/test-fixtures/state-vscdb-samples/cursor-live-smoke-workspace-1778934828389.json b/src/ext-vscode/test-fixtures/state-vscdb-samples/cursor-live-smoke-workspace-1778934828389.json new file mode 100644 index 00000000..9e4a6696 --- /dev/null +++ b/src/ext-vscode/test-fixtures/state-vscdb-samples/cursor-live-smoke-workspace-1778934828389.json @@ -0,0 +1,32 @@ +{ + "capturedAt": "2026-05-16T12:34:46.514Z", + "sourcePath": "/home/emptyops/.config/Cursor/User/workspaceStorage/1778934828389/state.vscdb", + "platform": "linux", + "redacted": true, + "tables": [ + "ItemTable", + "cursorDiskKV" + ], + "rows": [ + { + "table": "ItemTable", + "key": "aiService.generations", + "value": "[]" + }, + { + "table": "ItemTable", + "key": "aiService.prompts", + "value": "[]" + }, + { + "table": "ItemTable", + "key": "composer.composerData", + "value": "{\"selectedComposerIds\":[\"************************************\"],\"lastFocusedComposerIds\":[\"************************************\"],\"hasMigratedComposerData\":false,\"hasMigratedMultipleComposers\":true}" + }, + { + "table": "ItemTable", + "key": "workbench.backgroundComposer.workspacePersistentData", + "value": "{\"isSideBarExpanded\":false}" + } + ] +} diff --git a/src/ext-vscode/test-fixtures/state-vscdb-samples/cursor-live-smoke-workspace-1778934845361.json b/src/ext-vscode/test-fixtures/state-vscdb-samples/cursor-live-smoke-workspace-1778934845361.json new file mode 100644 index 00000000..dbfebdd4 --- /dev/null +++ b/src/ext-vscode/test-fixtures/state-vscdb-samples/cursor-live-smoke-workspace-1778934845361.json @@ -0,0 +1,32 @@ +{ + "capturedAt": "2026-05-16T12:34:46.515Z", + "sourcePath": "/home/emptyops/.config/Cursor/User/workspaceStorage/1778934845361/state.vscdb", + "platform": "linux", + "redacted": true, + "tables": [ + "ItemTable", + "cursorDiskKV" + ], + "rows": [ + { + "table": "ItemTable", + "key": "aiService.generations", + "value": "[]" + }, + { + "table": "ItemTable", + "key": "aiService.prompts", + "value": "[]" + }, + { + "table": "ItemTable", + "key": "composer.composerData", + "value": "{\"selectedComposerIds\":[\"************************************\"],\"lastFocusedComposerIds\":[\"************************************\"],\"hasMigratedComposerData\":false,\"hasMigratedMultipleComposers\":true}" + }, + { + "table": "ItemTable", + "key": "workbench.backgroundComposer.workspacePersistentData", + "value": "{\"isSideBarExpanded\":false}" + } + ] +} diff --git a/src/ext-vscode/test-fixtures/state-vscdb-samples/cursor-live-smoke-workspace-1778934848568.json b/src/ext-vscode/test-fixtures/state-vscdb-samples/cursor-live-smoke-workspace-1778934848568.json new file mode 100644 index 00000000..16222dae --- /dev/null +++ b/src/ext-vscode/test-fixtures/state-vscdb-samples/cursor-live-smoke-workspace-1778934848568.json @@ -0,0 +1,32 @@ +{ + "capturedAt": "2026-05-16T12:34:46.517Z", + "sourcePath": "/home/emptyops/.config/Cursor/User/workspaceStorage/1778934848568/state.vscdb", + "platform": "linux", + "redacted": true, + "tables": [ + "ItemTable", + "cursorDiskKV" + ], + "rows": [ + { + "table": "ItemTable", + "key": "aiService.generations", + "value": "[]" + }, + { + "table": "ItemTable", + "key": "aiService.prompts", + "value": "[]" + }, + { + "table": "ItemTable", + "key": "composer.composerData", + "value": "{\"selectedComposerIds\":[\"************************************\"],\"lastFocusedComposerIds\":[\"************************************\"],\"hasMigratedComposerData\":false,\"hasMigratedMultipleComposers\":true}" + }, + { + "table": "ItemTable", + "key": "workbench.backgroundComposer.workspacePersistentData", + "value": "{\"isSideBarExpanded\":false}" + } + ] +} diff --git a/src/ext-vscode/test-fixtures/state-vscdb-samples/cursor-live-smoke-workspace-empty-window.json b/src/ext-vscode/test-fixtures/state-vscdb-samples/cursor-live-smoke-workspace-empty-window.json new file mode 100644 index 00000000..3470e93c --- /dev/null +++ b/src/ext-vscode/test-fixtures/state-vscdb-samples/cursor-live-smoke-workspace-empty-window.json @@ -0,0 +1,22 @@ +{ + "capturedAt": "2026-05-16T12:34:46.518Z", + "sourcePath": "/home/emptyops/.config/Cursor/User/workspaceStorage/empty-window/state.vscdb", + "platform": "linux", + "redacted": true, + "tables": [ + "ItemTable", + "cursorDiskKV" + ], + "rows": [ + { + "table": "ItemTable", + "key": "aiService.generations", + "value": "[]" + }, + { + "table": "ItemTable", + "key": "aiService.prompts", + "value": "[]" + } + ] +} diff --git a/src/ext-vscode/tsconfig.json b/src/ext-vscode/tsconfig.json new file mode 100644 index 00000000..ecf5352c --- /dev/null +++ b/src/ext-vscode/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "./out", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "sourceMap": true, + "lib": ["ES2022"], + "types": ["node", "vscode"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "out", "**/*.test.ts"] +} diff --git a/tsconfig.json b/tsconfig.json index 79e4afb1..8f227ce2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,5 +13,5 @@ "sourceMap": true }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist", "src/ext-vscode"] }