From 1ece9a7389be4bb4a5ea6675bc07224b571d2148 Mon Sep 17 00:00:00 2001 From: Vedansi18 Date: Wed, 13 May 2026 16:01:12 +0530 Subject: [PATCH 01/51] M1/B1: Add agent adapter registry foundation + install snapshot baseline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the new src/agents/ module: four adapter interfaces (HookAdapter, VSCodeExtensionAdapter, CLIWrapAdapter, BrowserExtensionAdapter), an in-process registry (registerAdapter, detectAll, getAdapter), and an empty index.ts placeholder for future adapter registrations. Unit tests in registry.test.ts cover the registry behaviour. Adds src/cli/commands/install.snapshot.test.ts plus its generated baseline snapshot. The snapshot captures current installAction output (settings.json bytes + stdout) with $HOME and platform-dependent strings normalised so the snapshot is portable across machines. This is the zero-diff safety net for M1 Branch 2 (claude-code refactor): that branch must keep this snapshot byte-identical. No existing source code is modified. Per dev plan §1.6 in reviewduel-submodule. Branch: v0.1.3/m1/foundation-scaffold Co-Authored-By: Claude Opus 4.7 (1M context) --- src/agents/index.ts | 4 + src/agents/registry.test.ts | 126 ++++++++++++++++++ src/agents/registry.ts | 24 ++++ src/agents/types.ts | 86 ++++++++++++ .../install.snapshot.test.ts.snap | 47 +++++++ src/cli/commands/install.snapshot.test.ts | 89 +++++++++++++ 6 files changed, 376 insertions(+) create mode 100644 src/agents/index.ts create mode 100644 src/agents/registry.test.ts create mode 100644 src/agents/registry.ts create mode 100644 src/agents/types.ts create mode 100644 src/cli/commands/__snapshots__/install.snapshot.test.ts.snap create mode 100644 src/cli/commands/install.snapshot.test.ts diff --git a/src/agents/index.ts b/src/agents/index.ts new file mode 100644 index 00000000..d332b47c --- /dev/null +++ b/src/agents/index.ts @@ -0,0 +1,4 @@ +// Adapter registrations live here as side-effect imports. +// Branch 2 (`v0.1.3/m1/claude-code-refactor`) will add: +// import './adapters/claude-code.js'; +// Subsequent milestones add Cursor, Windsurf, CLI-wrap, browser adapters. 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..6a9b6ca1 --- /dev/null +++ b/src/agents/types.ts @@ -0,0 +1,86 @@ +/** + * 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; +} + +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(); + }); +}); From d93852ecabbaef36a9643dbced1b5819b68378ba Mon Sep 17 00:00:00 2001 From: Vedansi18 Date: Wed, 13 May 2026 18:01:47 +0530 Subject: [PATCH 02/51] M1/B2: Refactor Claude Code into HookAdapter via agent registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves the six Claude Code hook helpers (getClaudeSettingsPath, buildHookCommand, buildStopHookCommand, buildHookEntry, writeHookEntry, removeHookEntry) from src/cli/commands/install.ts to src/agents/adapters/claude-code.ts. Function bodies are byte-identical. install.ts re-exports them so existing imports (and install.test.ts) continue to work unchanged. Adds claudeCodeAdapter (HookAdapter) that wraps the moved functions and self-registers via src/agents/index.ts side-effect import. installAction's Claude Code branch in the for-loop now delegates to the adapter via getAdapter('claude-code').install(ctx). Adds optional settingsPath override to InstallContext so callers can decouple the target file path from ctx.home — preserves the pre-refactor pattern where paths.claudeSettings was passed independently of homedir() (used by install.test.ts to inject custom tmp paths without stubbing HOME). Without this, tests would write hook entries to the real ~/.claude/settings.json instead of their tmp dir. Adds src/agents/adapters/claude-code.test.ts (18 unit tests) covering the moved helpers + adapter contract (detect, settingsPath, buildHooks, install, uninstall) + the settingsPath override behaviour. Zero-diff invariant preserved: install snapshot from M1 Branch 1 remains byte-identical. All 177 relevant tests pass. typecheck clean. Branch: v0.1.3/m1/claude-code-refactor (off v0.1.3/m1/foundation-scaffold, which sits on upstream/user-experience-improvements-sub-7). Per dev plan §3.0 in reviewduel-submodule. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/agents/adapters/claude-code.test.ts | 231 +++++++++++++++++++++++ src/agents/adapters/claude-code.ts | 233 ++++++++++++++++++++++++ src/agents/index.ts | 7 +- src/agents/types.ts | 11 ++ src/cli/commands/install.ts | 188 +++++-------------- 5 files changed, 522 insertions(+), 148 deletions(-) create mode 100644 src/agents/adapters/claude-code.test.ts create mode 100644 src/agents/adapters/claude-code.ts 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/index.ts b/src/agents/index.ts index d332b47c..3f0764b2 100644 --- a/src/agents/index.ts +++ b/src/agents/index.ts @@ -1,4 +1,5 @@ // Adapter registrations live here as side-effect imports. -// Branch 2 (`v0.1.3/m1/claude-code-refactor`) will add: -// import './adapters/claude-code.js'; -// Subsequent milestones add Cursor, Windsurf, CLI-wrap, browser adapters. +// Each adapter module calls registerAdapter() at module load. +// +// Subsequent milestones add Cursor, Windsurf, CLI-wrap, and browser adapters. +import './adapters/claude-code.js'; diff --git a/src/agents/types.ts b/src/agents/types.ts index 6a9b6ca1..bbb1f79d 100644 --- a/src/agents/types.ts +++ b/src/agents/types.ts @@ -20,6 +20,17 @@ export interface InstallContext { 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 { diff --git a/src/cli/commands/install.ts b/src/cli/commands/install.ts index 02153e96..f3e8d754 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() call in installAction below. +import '../../agents/index.js'; +import { getAdapter } from '../../agents/registry.js'; +import type { HookAdapter } 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 ─────────────────────────────────────────────────── @@ -449,12 +338,21 @@ export async function installAction( console.log(`\u2713 ${agent.label.padEnd(12)} \u2014 written to ${agent.configPath} (CLI fallback)`); } } - // 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}`); + // Register the advisory pipeline hook (separate from MCP — different file). + // Delegated to the Claude Code HookAdapter (registered via src/agents/index.ts + // side-effect import at the top of this file). The adapter's install() does + // the same writeHookEntry + console.log as before — byte-identical output. + 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') { From 66dd54b3ee560c39a94f866a8bb60a0e7bdd7312 Mon Sep 17 00:00:00 2001 From: Vedansi18 Date: Wed, 13 May 2026 18:14:48 +0530 Subject: [PATCH 03/51] M1/B2 follow-up: revert advisory-hook comment to original wording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The original install.ts comment was a single line: // Register the advisory pipeline hook (separate from MCP — different file) The previous M1/B2 commit (d93852e) expanded this into a four-line comment explaining the adapter delegation. Per team feedback, comments on existing pre-refactor code should be kept verbatim — the §1.5 strict zero-diff guarantee includes comments on existing code. No behavioural change. Tests + snapshot unchanged (177/177 pass, install snapshot remains byte-identical with M1 Branch 1's baseline). Branch: v0.1.3/m1/claude-code-refactor. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/cli/commands/install.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/cli/commands/install.ts b/src/cli/commands/install.ts index f3e8d754..24963b3c 100644 --- a/src/cli/commands/install.ts +++ b/src/cli/commands/install.ts @@ -338,10 +338,7 @@ export async function installAction( console.log(`\u2713 ${agent.label.padEnd(12)} \u2014 written to ${agent.configPath} (CLI fallback)`); } } - // Register the advisory pipeline hook (separate from MCP — different file). - // Delegated to the Claude Code HookAdapter (registered via src/agents/index.ts - // side-effect import at the top of this file). The adapter's install() does - // the same writeHookEntry + console.log as before — byte-identical output. + // Register the advisory pipeline hook (separate from MCP — different file) const claudeAdapter = getAdapter('claude-code') as HookAdapter | undefined; if (claudeAdapter) { await claudeAdapter.install({ From 879ed5e20a521cbb410b883d81cadc86979f7548 Mon Sep 17 00:00:00 2001 From: Vedansi18 Date: Thu, 14 May 2026 17:39:58 +0530 Subject: [PATCH 04/51] M2/B1: Add VS Code extension skeleton, IPC stub, onboarding, and icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Branch 1 of Milestone M2 (v0.1.3/m2/extension-skeleton). Establishes the src/ext-vscode/ sub-package with an esbuild-driven build pipeline (ESM source -> CJS bundle for the VS Code host), activates on onStartupFinished, and ships the four scoped modules: M1 - Skeleton: package.json (activationEvents, activity-bar container + placeholder view backed by viewsWelcome so the icon actually renders), tsconfig.json, esbuild.config.mjs, src/extension.ts entrypoint. M5 - IPC stub: src/ipc.ts. spawnAuto(prompt, sessionId) and spawnStop(sessionId) spawn the nexpath CLI as subprocesses and parse the decision-session JSON payload from stdout, with typed errors (NexpathBinaryNotFoundError, NexpathMalformedPayloadError) and configurable binary-path resolution (opts.binaryPath -> NEXPATH_BIN env -> 'nexpath' on PATH). The exact stdin envelope vs. Layer C input contract is intentionally a stub here; Branch 4 (cursor-windsurf-adapters) finalises it. M11 - Onboarding: src/onboarding.ts. First-launch consent toast persists the user's choice to globalState; on macOS, additionally shows a Full-Disk-Access guidance toast that deep-links to the System Settings privacy pane. M12 - Icon: media/icon.svg. Y-fork (branching path) representing "next path" decision points; monochrome currentColor, scalable. 25 unit tests co-located alongside source (8 onboarding, 11 ipc, 6 extension), runnable via root vitest with vi.mock('vscode') stubs. Sub-package has its own tsconfig + package-lock; root tsconfig now excludes src/ext-vscode/ so each side owns its TS build. Both root and sub-package tsc --noEmit are clean. Full root test suite: 1851 passing + 18 pre-existing unrelated TtySelectFn Windows-sim failures (carried forward from dev plan §3.0). Deferred (flagged for follow-up, not blockers for this branch): - 5 moderate npm-audit warnings in the esbuild -> vite -> vitest dev chain (dev-only; will be addressed during M5 hardening). - IPC stdin envelope contract: real wiring lands in Branch 4. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 2 + src/ext-vscode/README.md | 31 + src/ext-vscode/esbuild.config.mjs | 25 + src/ext-vscode/media/icon.svg | 6 + src/ext-vscode/package-lock.json | 1473 +++++++++++++++++++++++++ src/ext-vscode/package.json | 55 + src/ext-vscode/src/extension.test.ts | 79 ++ src/ext-vscode/src/extension.ts | 15 + src/ext-vscode/src/ipc.test.ts | 161 +++ src/ext-vscode/src/ipc.ts | 163 +++ src/ext-vscode/src/onboarding.test.ts | 125 +++ src/ext-vscode/src/onboarding.ts | 62 ++ src/ext-vscode/tsconfig.json | 17 + tsconfig.json | 2 +- 14 files changed, 2215 insertions(+), 1 deletion(-) create mode 100644 src/ext-vscode/README.md create mode 100644 src/ext-vscode/esbuild.config.mjs create mode 100644 src/ext-vscode/media/icon.svg create mode 100644 src/ext-vscode/package-lock.json create mode 100644 src/ext-vscode/package.json create mode 100644 src/ext-vscode/src/extension.test.ts create mode 100644 src/ext-vscode/src/extension.ts create mode 100644 src/ext-vscode/src/ipc.test.ts create mode 100644 src/ext-vscode/src/ipc.ts create mode 100644 src/ext-vscode/src/onboarding.test.ts create mode 100644 src/ext-vscode/src/onboarding.ts create mode 100644 src/ext-vscode/tsconfig.json diff --git a/.gitignore b/.gitignore index 4237133a..8abaa7fd 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ node_modules/ *.js.map *.d.ts.map .env +src/ext-vscode/out/ +src/ext-vscode/node_modules/ 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/esbuild.config.mjs b/src/ext-vscode/esbuild.config.mjs new file mode 100644 index 00000000..636ee98a --- /dev/null +++ b/src/ext-vscode/esbuild.config.mjs @@ -0,0 +1,25 @@ +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', + external: ['vscode'], + 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..266520c6 --- /dev/null +++ b/src/ext-vscode/package-lock.json @@ -0,0 +1,1473 @@ +{ + "name": "nexpath-vscode", + "version": "0.1.3", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "nexpath-vscode", + "version": "0.1.3", + "devDependencies": { + "@types/node": "^20", + "@types/vscode": "^1.80.0", + "esbuild": "^0.21", + "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-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-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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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" + } + } + } +} diff --git a/src/ext-vscode/package.json b/src/ext-vscode/package.json new file mode 100644 index 00000000..5af79a75 --- /dev/null +++ b/src/ext-vscode/package.json @@ -0,0 +1,55 @@ +{ + "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" + } + ] + }, + "viewsWelcome": [ + { + "view": "nexpath.status", + "contents": "Nexpath is active and watching for AI chat prompts.\n\nDecision sessions appear in this panel automatically when guidance is generated for a prompt — no manual action required." + } + ] + }, + "scripts": { + "vscode:prepublish": "npm run build", + "build": "node esbuild.config.mjs", + "watch": "node esbuild.config.mjs --watch", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "devDependencies": { + "@types/node": "^20", + "@types/vscode": "^1.80.0", + "esbuild": "^0.21", + "typescript": "^5", + "vitest": "^2" + } +} diff --git a/src/ext-vscode/src/extension.test.ts b/src/ext-vscode/src/extension.test.ts new file mode 100644 index 00000000..54f81b8d --- /dev/null +++ b/src/ext-vscode/src/extension.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +// Mocks for the two modules the extension entrypoint imports. `vi.hoisted` is +// required because `vi.mock` is hoisted above import statements, so the mock +// references must live at hoist time too. +const { mockShowOnboarding } = vi.hoisted(() => ({ + mockShowOnboarding: vi.fn(), +})); + +vi.mock('vscode', () => ({})); +vi.mock('./onboarding.js', () => ({ + showOnboardingIfNeeded: mockShowOnboarding, +})); + +import { activate, deactivate } from './extension.js'; + +describe('activate', () => { + let logSpy: ReturnType; + let errorSpy: ReturnType; + + beforeEach(() => { + mockShowOnboarding.mockReset(); + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + logSpy.mockRestore(); + errorSpy.mockRestore(); + }); + + it('logs the activation message', async () => { + mockShowOnboarding.mockResolvedValueOnce(undefined); + await activate({} as never); + expect(logSpy).toHaveBeenCalledWith('[nexpath] extension activated'); + }); + + it('forwards the ExtensionContext to showOnboardingIfNeeded', async () => { + mockShowOnboarding.mockResolvedValueOnce(undefined); + const ctx = { fake: 'context' }; + await activate(ctx as never); + expect(mockShowOnboarding).toHaveBeenCalledOnce(); + expect(mockShowOnboarding).toHaveBeenCalledWith(ctx); + }); + + it('does not throw when showOnboardingIfNeeded rejects — logs error instead', async () => { + const err = new Error('boom'); + mockShowOnboarding.mockRejectedValueOnce(err); + await expect(activate({} as never)).resolves.toBeUndefined(); + expect(errorSpy).toHaveBeenCalledWith('[nexpath] onboarding failed:', err); + }); + + it('still logs the activation message even when onboarding rejects', async () => { + mockShowOnboarding.mockRejectedValueOnce(new Error('x')); + await activate({} as never); + expect(logSpy).toHaveBeenCalledWith('[nexpath] extension activated'); + }); +}); + +describe('deactivate', () => { + let logSpy: ReturnType; + + beforeEach(() => { + 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(); + }); +}); diff --git a/src/ext-vscode/src/extension.ts b/src/ext-vscode/src/extension.ts new file mode 100644 index 00000000..71200816 --- /dev/null +++ b/src/ext-vscode/src/extension.ts @@ -0,0 +1,15 @@ +import * as vscode from 'vscode'; +import { showOnboardingIfNeeded } from './onboarding.js'; + +export async function activate(context: vscode.ExtensionContext): Promise { + console.log('[nexpath] extension activated'); + try { + await showOnboardingIfNeeded(context); + } catch (err) { + console.error('[nexpath] onboarding failed:', err); + } +} + +export function deactivate(): void { + console.log('[nexpath] extension deactivated'); +} diff --git a/src/ext-vscode/src/ipc.test.ts b/src/ext-vscode/src/ipc.test.ts new file mode 100644 index 00000000..d8d2983b --- /dev/null +++ b/src/ext-vscode/src/ipc.test.ts @@ -0,0 +1,161 @@ +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('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); + }); +}); diff --git a/src/ext-vscode/src/ipc.ts b/src/ext-vscode/src/ipc.ts new file mode 100644 index 00000000..aec88c7b --- /dev/null +++ b/src/ext-vscode/src/ipc.ts @@ -0,0 +1,163 @@ +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; + 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; +} + +const spawnOptions: SpawnOptions = { stdio: ['pipe', 'pipe', 'pipe'] }; + +/** + * Spawn `nexpath auto` and forward the prompt + session id via stdin. + * Resolves on a 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, spawnOptions); + + 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. + * 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, spawnOptions); + + 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)); + } + }); + + child.stdin?.end(JSON.stringify({ session_id: sessionId }) + '\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..26d92940 --- /dev/null +++ b/src/ext-vscode/src/onboarding.ts @@ -0,0 +1,62 @@ +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. + */ + +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/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"] } From 14e849a93f4c97bbe5fd563f30909308f32a10a0 Mon Sep 17 00:00:00 2001 From: Vedansi18 Date: Thu, 14 May 2026 18:56:33 +0530 Subject: [PATCH 05/51] M2/B2: Add chat-history watcher, extractors, and schema fingerprint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Branch 2 of Milestone M2 (v0.1.3/m2/chat-history-capture). Stacked on M2 Branch 1 (commit 879ed5e). Adds the three scoped modules: M2 - chat-history-watcher.ts: fs.watch on Cursor's state.vscdb and Windsurf's ~/.codeium/windsurf/ dir, debounced (default 250ms), reads ItemTable via injectable readItemTableFn (sql.js by default), diffs against seenSignatures, emits {prompt, rawSessionId, capturedAt, sourcePath, extractorId}. Dependency-injectable throughout (watchFn, readFileFn, readItemTableFn, nowFn) so the unit tests run without sql.js or real fs.watch. M3 - extractors/: four per-version row decoders implementing the ChatHistoryExtractor contract from chat-history-types.ts. - cursor-v2024-q4 (aiService.prompts global key, pre-Composer) - cursor-v2025-q1 (composerData.composerData, Composer era) - cursor-v2025-q2 (cursorAIChatService.chatHistory. per-tab keys, current) - windsurf (cascade.* placeholder; real Windsurf decoding lands in Branch 4 alongside windsurfAdapter) Each Cursor extractor handles both `role`/`type` and `content`/`text` field variants seen across minor versions. All four are TODO-flagged for verification against real dumps before Branch 6 publishes — scripts/dump-cursor-state.ts (below) captures those dumps. M4 - pickExtractor in extractors/index.ts: prefix-match each extractor's fingerprintKeys against the observed ItemTable keys, pick the highest match count (ties broken by registry order = newest first). Returns FingerprintResult; unknown schemas surface observedSampleKeys for the "schema unknown" toast hook. scripts/dump-cursor-state.ts: dev-only helper (npx tsx) for capturing state.vscdb fixtures from a machine with Cursor installed. Filters to chat-related key prefixes, optional --redact for sensitive content. Outputs to src/ext-vscode/test-fixtures/state-vscdb-samples/. Sub-package additions: - dependencies: sql.js ^1 (runtime; loaded via dynamic import so wasm boot is lazy). Marked external in esbuild so the .vsix ships node_modules/sql.js rather than inlining it. - devDependencies: tsx ^4 (for running the dump script). 57 new unit tests (sub-package totals: 82 passing across 9 files): cursor-v2024-q4 9 tests cursor-v2025-q1 10 tests cursor-v2025-q2 11 tests windsurf 4 tests extractors/index 12 tests chat-history-watcher 11 tests Verification: root tsc --noEmit clean; sub-package tsc --noEmit clean; sub-package vitest 82/82 pass; full root test suite 1908 passing + 18 pre-existing TtySelectFn Windows-sim failures (carried forward from M1 3.0, unrelated); esbuild bundle still builds out/extension.js. Deferred to follow-up (flagged, not blockers): - Real-dump verification of all 4 extractors (use dump-cursor-state.ts on machines with each Cursor version installed; replace TODO comments in extractors with fixture-driven regression tests). - Windsurf JSON-file decoder (Branch 4). - Wiring the watcher into extension.ts activate() (Branch 3 webview-ui or Branch 4 adapters — depends on UI surface integration). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/ext-vscode/esbuild.config.mjs | 5 +- src/ext-vscode/package-lock.json | 513 ++++++++++++++++++ src/ext-vscode/package.json | 7 +- src/ext-vscode/scripts/dump-cursor-state.ts | 209 +++++++ src/ext-vscode/src/chat-history-types.ts | 90 +++ .../src/chat-history-watcher.test.ts | 284 ++++++++++ src/ext-vscode/src/chat-history-watcher.ts | 215 ++++++++ .../src/extractors/cursor-v2024-q4.test.ts | 82 +++ .../src/extractors/cursor-v2024-q4.ts | 70 +++ .../src/extractors/cursor-v2025-q1.test.ts | 119 ++++ .../src/extractors/cursor-v2025-q1.ts | 96 ++++ .../src/extractors/cursor-v2025-q2.test.ts | 112 ++++ .../src/extractors/cursor-v2025-q2.ts | 79 +++ src/ext-vscode/src/extractors/index.test.ts | 129 +++++ src/ext-vscode/src/extractors/index.ts | 70 +++ .../src/extractors/windsurf.test.ts | 25 + src/ext-vscode/src/extractors/windsurf.ts | 41 ++ 17 files changed, 2144 insertions(+), 2 deletions(-) create mode 100644 src/ext-vscode/scripts/dump-cursor-state.ts create mode 100644 src/ext-vscode/src/chat-history-types.ts create mode 100644 src/ext-vscode/src/chat-history-watcher.test.ts create mode 100644 src/ext-vscode/src/chat-history-watcher.ts create mode 100644 src/ext-vscode/src/extractors/cursor-v2024-q4.test.ts create mode 100644 src/ext-vscode/src/extractors/cursor-v2024-q4.ts create mode 100644 src/ext-vscode/src/extractors/cursor-v2025-q1.test.ts create mode 100644 src/ext-vscode/src/extractors/cursor-v2025-q1.ts create mode 100644 src/ext-vscode/src/extractors/cursor-v2025-q2.test.ts create mode 100644 src/ext-vscode/src/extractors/cursor-v2025-q2.ts create mode 100644 src/ext-vscode/src/extractors/index.test.ts create mode 100644 src/ext-vscode/src/extractors/index.ts create mode 100644 src/ext-vscode/src/extractors/windsurf.test.ts create mode 100644 src/ext-vscode/src/extractors/windsurf.ts diff --git a/src/ext-vscode/esbuild.config.mjs b/src/ext-vscode/esbuild.config.mjs index 636ee98a..5d1a2dad 100644 --- a/src/ext-vscode/esbuild.config.mjs +++ b/src/ext-vscode/esbuild.config.mjs @@ -9,7 +9,10 @@ const config = { platform: 'node', target: 'node18', format: 'cjs', - external: ['vscode'], + // `vscode` is provided by the host. `sql.js` ships node_modules into the + // .vsix and is loaded via dynamic import at runtime so the wasm boot + // happens only on first chat-history read. + external: ['vscode', 'sql.js'], sourcemap: true, minify: false, logLevel: 'info', diff --git a/src/ext-vscode/package-lock.json b/src/ext-vscode/package-lock.json index 266520c6..27b2222b 100644 --- a/src/ext-vscode/package-lock.json +++ b/src/ext-vscode/package-lock.json @@ -7,10 +7,14 @@ "": { "name": "nexpath-vscode", "version": "0.1.3", + "dependencies": { + "sql.js": "^1" + }, "devDependencies": { "@types/node": "^20", "@types/vscode": "^1.80.0", "esbuild": "^0.21", + "tsx": "^4", "typescript": "^5", "vitest": "^2" }, @@ -307,6 +311,23 @@ "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", @@ -324,6 +345,23 @@ "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", @@ -341,6 +379,23 @@ "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", @@ -1224,6 +1279,12 @@ "node": ">=0.10.0" } }, + "node_modules/sql.js": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.1.tgz", + "integrity": "sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==", + "license": "MIT" + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -1282,6 +1343,458 @@ "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/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", diff --git a/src/ext-vscode/package.json b/src/ext-vscode/package.json index 5af79a75..48f1e6f0 100644 --- a/src/ext-vscode/package.json +++ b/src/ext-vscode/package.json @@ -43,12 +43,17 @@ "build": "node esbuild.config.mjs", "watch": "node esbuild.config.mjs --watch", "typecheck": "tsc --noEmit", - "test": "vitest run" + "test": "vitest run", + "dump-cursor-state": "tsx scripts/dump-cursor-state.ts" + }, + "dependencies": { + "sql.js": "^1" }, "devDependencies": { "@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..39e2a55d --- /dev/null +++ b/src/ext-vscode/scripts/dump-cursor-state.ts @@ -0,0 +1,209 @@ +#!/usr/bin/env node +/** + * scripts/dump-cursor-state.ts — capture a verified `state.vscdb` fixture + * from a real machine for extractor regression testing. + * + * Run this on any machine where Cursor (or Windsurf) is installed, against + * a chosen Cursor version. It: + * 1. Locates the Cursor `state.vscdb` for the OS the script is running on + * (or accepts an explicit `--src` override). + * 2. Opens the SQLite ItemTable with sql.js. + * 3. Writes a JSON snapshot of all chat-related keys (and only those keys + * — filenames, recents, theme settings, etc. are filtered out) to + * `src/ext-vscode/test-fixtures/state-vscdb-samples/.json`. + * + * The fixture file is suitable for committing to the repo (no personal data + * leaks of unrelated VS Code state) and can be replayed against extractors + * in unit tests to verify the per-version decoders. + * + * Usage: + * npx tsx src/ext-vscode/scripts/dump-cursor-state.ts \ + * --name cursor-v2025-q2-real \ + * [--src /path/to/state.vscdb] + * + * NOTE: even after filtering to chat keys, the values may contain prompt + * text the user typed. Review the JSON output before committing and redact + * any sensitive content. The `--redact` flag (below) replaces all prompt + * text with a fixed string of the same length. + */ + +import { readFile, writeFile, mkdir } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { homedir, platform as osPlatform } from 'node:os'; +import { join, dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// ESM-friendly equivalent of __dirname (sub-package uses "type": "module") +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +/** Prefixes of ItemTable keys that should be captured. Everything else is dropped. */ +const KEEP_KEY_PREFIXES = [ + 'aiService.', + 'composerData.', + 'cursorAIChatService.', + 'cursorAIService.', + 'cascade.', +]; + +interface CliArgs { + name: string; + src?: string; + redact: boolean; +} + +function parseArgs(argv: string[]): CliArgs { + const args: Partial = { redact: false }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--name') args.name = argv[++i]; + else if (a === '--src') args.src = argv[++i]; + else if (a === '--redact') args.redact = true; + else if (a === '--help' || a === '-h') { + printUsageAndExit(0); + } else { + console.error(`Unknown argument: ${a}`); + printUsageAndExit(1); + } + } + if (!args.name) { + console.error('Missing required --name '); + printUsageAndExit(1); + } + return args as CliArgs; +} + +function printUsageAndExit(code: number): never { + console.log(`Usage: + npx tsx scripts/dump-cursor-state.ts --name [--src ] [--redact] + +Options: + --name Fixture file name (no extension). REQUIRED. + Convention: cursor-v-q-real / windsurf-real. + --src Path to state.vscdb. If omitted, default OS path is used. + --redact Replace prompt text with same-length placeholder strings. + Use this if the dump may contain sensitive content. + +Output: + src/ext-vscode/test-fixtures/state-vscdb-samples/.json +`); + process.exit(code); +} + +function defaultCursorStatePath(): string { + const home = homedir(); + switch (osPlatform()) { + case 'darwin': + return join(home, 'Library', 'Application Support', 'Cursor', 'User', 'globalStorage', 'state.vscdb'); + case 'win32': + return join(process.env.APPDATA ?? home, 'Cursor', 'User', 'globalStorage', 'state.vscdb'); + default: + return join(home, '.config', 'Cursor', 'User', 'globalStorage', 'state.vscdb'); + } +} + +interface SqlJsModuleShape { + default: () => Promise<{ + Database: new (data: Uint8Array) => { + exec(sql: string): Array<{ columns: string[]; values: unknown[][] }>; + close(): void; + }; + }>; +} + +async function readItemTable(dbBytes: Buffer): Promise> { + const mod = (await import('sql.js')) as unknown as SqlJsModuleShape; + const SQL = await mod.default(); + const db = new SQL.Database(new Uint8Array(dbBytes)); + try { + const result = db.exec('SELECT key, value FROM ItemTable'); + const rows: Array<{ key: string; value: string }> = []; + for (const r of result) { + for (const v of r.values) { + rows.push({ key: String(v[0]), value: String(v[1]) }); + } + } + return rows; + } finally { + db.close(); + } +} + +function shouldKeep(key: string): boolean { + return KEEP_KEY_PREFIXES.some((p) => key.startsWith(p)); +} + +function redactValue(value: string): string { + // Best-effort: try to parse and replace any string field longer than 8 chars. + 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 { + // not JSON — redact in bulk + return '*'.repeat(value.length); + } +} + +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)); + const srcPath = args.src ?? defaultCursorStatePath(); + + if (!existsSync(srcPath)) { + console.error(`state.vscdb not found at: ${srcPath}`); + console.error('Pass --src to override.'); + process.exit(2); + } + + console.log(`Reading: ${srcPath}`); + const bytes = await readFile(srcPath); + const rows = await readItemTable(bytes); + console.log(`Found ${rows.length} total ItemTable rows.`); + + const kept = rows.filter((r) => shouldKeep(r.key)); + console.log(`Keeping ${kept.length} chat-related rows (key prefixes: ${KEEP_KEY_PREFIXES.join(', ')}).`); + + const finalRows = args.redact + ? kept.map((r) => ({ key: r.key, value: redactValue(r.value) })) + : kept; + + if (args.redact) { + console.log('Redacted all string values longer than 8 chars.'); + } + + const fixture = { + capturedAt: new Date().toISOString(), + sourcePath: srcPath, + platform: osPlatform(), + redacted: args.redact, + rows: finalRows, + }; + + const repoRoot = resolve(__dirname, '..', '..', '..', '..'); + const outDir = join( + repoRoot, + 'src', + 'ext-vscode', + 'test-fixtures', + 'state-vscdb-samples', + ); + await mkdir(outDir, { recursive: true }); + const outPath = join(outDir, `${args.name}.json`); + await writeFile(outPath, JSON.stringify(fixture, null, 2) + '\n', 'utf8'); + + console.log(`Wrote: ${outPath}`); + console.log(''); + console.log('Next steps:'); + console.log(' 1. Review the JSON for any sensitive content before committing.'); + console.log(' 2. Re-run with --redact if needed.'); + console.log(' 3. Reference this fixture from the relevant extractor test.'); +} + +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..598a73aa --- /dev/null +++ b/src/ext-vscode/src/chat-history-watcher.test.ts @@ -0,0 +1,284 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { EventEmitter } from 'node:events'; +import { + createChatHistoryWatcher, + type ReadItemTableFn, +} from './chat-history-watcher.js'; +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 readFileFn: ReturnType; + let readItemTableFn: ReturnType; + let onEvent: ReturnType; + let onError: ReturnType; + let onSchemaUnknown: ReturnType; + + beforeEach(() => { + createdWatchers = []; + watchFn = vi.fn(() => { + const w = makeFakeWatcher(); + createdWatchers.push(w); + return w; + }); + readFileFn = vi.fn(async () => Buffer.from('fake-db-bytes')); + readItemTableFn = vi.fn(async () => []); + onEvent = vi.fn(); + onError = vi.fn(); + onSchemaUnknown = vi.fn(); + }); + + it('start() registers a watcher for every target', () => { + const w = createChatHistoryWatcher({ + targets: [cursorTarget('/a/state.vscdb'), cursorTarget('/b/state.vscdb')], + onEvent, + watchFn: watchFn as never, + readFileFn, + readItemTableFn, + }); + w.start(); + expect(watchFn).toHaveBeenCalledTimes(2); + expect(watchFn).toHaveBeenCalledWith('/a/state.vscdb', expect.any(Function)); + expect(watchFn).toHaveBeenCalledWith('/b/state.vscdb', expect.any(Function)); + }); + + it('initial-pass after start() reads + emits events for already-existing rows', async () => { + 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, + readFileFn, + readItemTableFn, + debounceMs: 1, + }); + w.start(); + await new Promise((r) => setTimeout(r, 20)); + expect(onEvent).toHaveBeenCalledTimes(1); + expect(onEvent.mock.calls[0]![0].prompt).toBe('hi'); + }); + + it('deduplicates events across multiple reads (signature dedup)', async () => { + readItemTableFn.mockResolvedValue([{ key: 'k', value: 'v' }]); + const extractor = makeExtractor('test', [ev('same', 's-1', '/p')]); + const w = createChatHistoryWatcher({ + targets: [cursorTarget('/p', extractor)], + onEvent, + watchFn: watchFn as never, + readFileFn, + readItemTableFn, + debounceMs: 1, + }); + w.start(); + await new Promise((r) => setTimeout(r, 20)); + + // simulate three more fs.watch events + 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)); + + // still only one emission (the same prompt was already seen) + 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, + readFileFn, + 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, + readFileFn, + 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('forwards readFileFn errors to onError without crashing', async () => { + readFileFn.mockRejectedValueOnce(new Error('disk gone')); + const w = createChatHistoryWatcher({ + targets: [cursorTarget('/p', makeExtractor('test', []))], + onEvent, + onError, + watchFn: watchFn as never, + readFileFn, + readItemTableFn, + debounceMs: 1, + }); + w.start(); + await new Promise((r) => setTimeout(r, 20)); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError.mock.calls[0]![0].message).toContain('disk gone'); + }); + + 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, + readFileFn, + 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, + readFileFn, + 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, + readFileFn, + 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'); + readItemTableFn.mockResolvedValue([{ key: 'k', value: 'v' }]); + const extractor = makeExtractor('test', [ev('hi', 's', '/p')]); + const w = createChatHistoryWatcher({ + targets: [cursorTarget('/p', extractor)], + onEvent, + watchFn: watchFn as never, + readFileFn, + readItemTableFn, + nowFn: () => fixedDate, + debounceMs: 1, + }); + w.start(); + await new Promise((r) => setTimeout(r, 20)); + expect(onEvent.mock.calls[0]![0].capturedAt).toEqual(fixedDate); + }); + + it('windsurf-dir targets are accepted without crashing (decoding deferred to Branch 4)', () => { + const w = createChatHistoryWatcher({ + targets: [{ path: '/ws', kind: 'windsurf-dir' }], + onEvent, + watchFn: watchFn as never, + readFileFn, + readItemTableFn, + debounceMs: 1, + }); + expect(() => w.start()).not.toThrow(); + expect(() => w.stop()).not.toThrow(); + }); +}); 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..fa2d4f9f --- /dev/null +++ b/src/ext-vscode/src/chat-history-watcher.ts @@ -0,0 +1,215 @@ +import { watch, type FSWatcher, type WatchListener } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import type { + ChatHistoryEvent, + ChatHistoryExtractor, + ItemTableRow, + WatchTarget, +} from './chat-history-types.js'; +import { pickExtractor } from './extractors/index.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`. Decoding lands + * in Branch 4; here the watcher only fires the file events and + * forwards them to a no-op extractor. + * + * 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 sql.js. In production a sql.js-backed reader is used + * (loaded lazily so the wasm boot cost only hits on first read). + */ + +/** Pure function that turns a state.vscdb byte buffer into ItemTable rows. */ +export type ReadItemTableFn = (dbBytes: Buffer) => Promise; + +/** + * Default sql.js-backed reader. Dynamic import keeps wasm out of the eager + * require graph; only loaded on first read. + */ +const defaultReadItemTable: ReadItemTableFn = async (dbBytes) => { + const mod = (await import('sql.js')) as unknown as { + default: (config?: { locateFile?: (f: string) => string }) => Promise<{ + Database: new (data: Uint8Array) => { + exec(sql: string): Array<{ columns: string[]; values: unknown[][] }>; + close(): void; + }; + }>; + }; + const SQL = await mod.default(); + const db = new SQL.Database(new Uint8Array(dbBytes)); + try { + const result = db.exec('SELECT key, value FROM ItemTable'); + const rows: ItemTableRow[] = []; + for (const r of result) { + for (const v of r.values) { + rows.push({ key: String(v[0]), value: String(v[1]) }); + } + } + return rows; + } finally { + db.close(); + } +}; + +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 file reader (defaults to node:fs/promises `readFile`). */ + readFileFn?: (path: string) => Promise; + /** Inject the ItemTable reader (defaults to sql.js-backed reader). */ + readItemTableFn?: ReadItemTableFn; + /** 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 readFileFn = opts.readFileFn ?? readFile; + const readItemTableFn = opts.readItemTableFn ?? defaultReadItemTable; + 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(); + + function signatureOf(e: ChatHistoryEvent): string { + return `${e.sourcePath}|${e.rawSessionId}|${e.prompt}`; + } + + 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 buf = await readFileFn(target.path); + const rows = await readItemTableFn(buf); + + let extractor: ChatHistoryExtractor | undefined = + target.extractor ?? extractorCache.get(target.path); + + if (!extractor) { + const fp = pickExtractor(rows.map((r) => r.key)); + if (fp.kind === 'known') { + extractor = fp.extractor; + extractorCache.set(target.path, extractor); + } else { + opts.onSchemaUnknown?.({ + path: target.path, + observedSampleKeys: fp.observedSampleKeys, + }); + return; + } + } + + for (const row of rows) { + 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); + opts.onEvent({ ...ev, capturedAt: nowFn() }); + } + } + } catch (err) { + reportError(err, target.path); + } + } + + function processWindsurfTarget(_target: WatchTarget): void { + // Branch 4 wires Windsurf JSON-file decoding alongside the + // windsurfAdapter.chatHistoryPaths implementation. For Branch 2 + // we only ensure the watcher itself doesn't crash on Windsurf targets. + } + + 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 { + processWindsurfTarget(target); + } + }, debounceMs); + debouncers.set(target.path, t); + } + + return { + start(): void { + for (const target of opts.targets) { + try { + const listener: WatchListener = () => schedule(target); + const w = watchFn(target.path, listener); + w.on('error', (err: Error) => reportError(err, target.path)); + fsWatchers.push(w); + // 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/extractors/cursor-v2024-q4.test.ts b/src/ext-vscode/src/extractors/cursor-v2024-q4.test.ts new file mode 100644 index 00000000..e6632a36 --- /dev/null +++ b/src/ext-vscode/src/extractors/cursor-v2024-q4.test.ts @@ -0,0 +1,82 @@ +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 prompts-index as rawSessionId', () => { + const events = cursorV2024Q4.decodeRow(row([{ text: 'a' }, { text: 'b' }]), SRC); + expect(events.map((e) => e.rawSessionId)).toEqual([ + 'prompts-index:0', + 'prompts-index:1', + ]); + }); + + 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..89479d95 --- /dev/null +++ b/src/ext-vscode/src/extractors/cursor-v2024-q4.ts @@ -0,0 +1,70 @@ +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, so we fall back to the + * entry's index in the array as `rawSessionId`. + * + * TODO(M2/B2): verify against a real Cursor v2024-Q4 `state.vscdb` dump + * before Branch 6 ships. Schema details below reflect community + * reverse-engineering and may need adjustment. Run + * `scripts/dump-cursor-state.ts` on a machine with this Cursor version + * installed to capture a verified fixture, then update the field names + * here if they diverge. + */ + +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 (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + if (!entry || typeof entry.text !== 'string' || entry.text.length === 0) { + continue; + } + events.push({ + prompt: entry.text, + rawSessionId: `prompts-index:${i}`, + 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..db9ecf59 --- /dev/null +++ b/src/ext-vscode/src/extractors/cursor-v2025-q1.test.ts @@ -0,0 +1,119 @@ +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 = 'composerData.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 composerData.composerData', () => { + expect(cursorV2025Q1.ownsKey(KEY)).toBe(true); + }); + it('does not match other keys', () => { + expect(cursorV2025Q1.ownsKey('aiService.prompts')).toBe(false); + expect(cursorV2025Q1.ownsKey('composerData.other')).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..e77d3fb9 --- /dev/null +++ b/src/ext-vscode/src/extractors/cursor-v2025-q1.ts @@ -0,0 +1,96 @@ +import type { + ChatHistoryEvent, + ChatHistoryExtractor, + ItemTableRow, +} from '../chat-history-types.js'; + +/** + * Cursor v2025-Q1 — the Composer-launch era (Cursor versions ~0.45–0.49). + * + * Community-documented key: `composerData.composerData` — a single JSON + * object with `allComposers: Record` where + * each conversation has a `conversation: ComposerMessage[]` field. Messages + * carry a `type` (1 = user, 2 = assistant) or a `role` ('user' / 'assistant') + * depending on minor version. + * + * TODO(M2/B2): verify against a real Cursor v2025-Q1 `state.vscdb` dump + * before Branch 6 ships. Field names may need adjustment after fixture + * capture via `scripts/dump-cursor-state.ts`. + */ + +const COMPOSER_KEY = 'composerData.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..4be01b5a --- /dev/null +++ b/src/ext-vscode/src/extractors/cursor-v2025-q2.ts @@ -0,0 +1,79 @@ +import type { + ChatHistoryEvent, + ChatHistoryExtractor, + ItemTableRow, +} from '../chat-history-types.js'; + +/** + * Cursor v2025-Q2+ — current per-tab chat history layout. + * + * Community-documented key prefix: `cursorAIChatService.chatHistory.` + * — value is a JSON array of message objects. Each message has either a + * `role` ('user' / 'assistant') or a `type` (string or legacy numeric), + * and the prompt text lives in either `content` or `text`. + * + * TODO(M2/B2): verify against a real Cursor v2025-Q2+ `state.vscdb` dump + * before Branch 6 ships. Field names may need adjustment after fixture + * capture via `scripts/dump-cursor-state.ts`. + */ + +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..fc2a0da1 --- /dev/null +++ b/src/ext-vscode/src/extractors/index.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect } from 'vitest'; +import { + ALL_EXTRACTORS, + getExtractorById, + pickExtractor, +} from './index.js'; + +describe('extractor registry', () => { + it('lists all four extractors in newest-first order', () => { + expect(ALL_EXTRACTORS.map((e) => e.id)).toEqual([ + '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('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 composerData.composerData is present', () => { + const result = pickExtractor(['composerData.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', + 'composerData.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..eab7961e --- /dev/null +++ b/src/ext-vscode/src/extractors/index.ts @@ -0,0 +1,70 @@ +import type { + ChatHistoryExtractor, + FingerprintResult, +} from '../chat-history-types.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`). + */ +export const ALL_EXTRACTORS: readonly ChatHistoryExtractor[] = [ + 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 { 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..ef4b7ce8 --- /dev/null +++ b/src/ext-vscode/src/extractors/windsurf.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest'; +import { windsurf } 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([]); + }); +}); diff --git a/src/ext-vscode/src/extractors/windsurf.ts b/src/ext-vscode/src/extractors/windsurf.ts new file mode 100644 index 00000000..e378398b --- /dev/null +++ b/src/ext-vscode/src/extractors/windsurf.ts @@ -0,0 +1,41 @@ +import type { + ChatHistoryEvent, + ChatHistoryExtractor, + ItemTableRow, +} from '../chat-history-types.js'; + +/** + * Windsurf (Codeium Cascade). + * + * Storage layout differs from Cursor — Windsurf does NOT use VS Code's + * `ItemTable` SQLite database for chat history. It stores conversation data + * under `~/.codeium/windsurf/` as JSON files, one (or more) per session. + * The watcher dispatches Windsurf paths to this extractor by storage kind + * (`windsurf-dir`), so `decodeRow` is normally not invoked for Windsurf. + * + * **Placeholder**: this stub returns no events. The fingerprint match + * (`cascade.*` keys) only triggers if Windsurf migrates to a VS Code-style + * ItemTable in a future version — currently no such migration is known. + * The real Windsurf JSON-file decoder lives in Branch 4 + * (`cursor-windsurf-adapters`) alongside `windsurfAdapter.chatHistoryPaths`. + * + * TODO(M2/B2): replace this stub with the real Windsurf decoder once + * `scripts/dump-cursor-state.ts` has been adapted for Windsurf and we have + * a verified fixture. + */ + +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 []; + }, +}; From 3794bc34f0cf7f88640f8b02e94874ec0e5eea0d Mon Sep 17 00:00:00 2001 From: Vedansi18 Date: Fri, 15 May 2026 12:22:33 +0530 Subject: [PATCH 06/51] M2/B2 follow-up: WAL-aware dump script + Cursor 3.4.20 real-data fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real-machine inspection on Cursor 3.4.20 (2026-05-15) surfaced three issues with the Branch 2 extractor designs. This commit fixes the verifiable ones, captures redacted fixtures, and documents the still- unknown bits for the next round. Issue 1 — SQLite WAL mode. The dump script previously used sql.js, which only reads the buffer of the main `.vscdb` file. Live Cursor writes go to the sibling `.vscdb-wal` (185 KB while the main file was 4 KB), so sql.js saw "no such table: ItemTable" even though the table exists. Fix: switched the dump script to better-sqlite3 (native, WAL-aware). Copies main + wal + shm siblings to a tmp staging dir before reading so the live Cursor write path is never touched, then runs `PRAGMA wal_checkpoint(TRUNCATE)` on the staged copy for consistency. The PRODUCTION watcher in `chat-history-watcher.ts` still uses sql.js via dynamic import; the same WAL problem will surface when Branch 4 wires the watcher live. Flagged for Branch 4 design — options are: (a) switch the watcher to better-sqlite3 (native binding in .vsix), or (b) implement copy + checkpoint via sql.js. Out of scope for B2. Issue 2 — `cursor-v2025-q1` extractor's fingerprint key was wrong. Community docs said `composerData.composerData`; Cursor 3.4.20 actually uses `composer.composerData`. Updated the key in both the extractor and its tests + the fingerprint test. Open finding: the `composer.composerData` value on a chat-less Cursor 3.4.20 workspace DB is metadata only (selectedComposerIds, migration flags) — not the conversation messages this extractor's decodeRow logic parses for. Logic falls through cleanly (returns [] when the expected `allComposers` field is absent) and the JSDoc now documents that the real Composer message storage location is still TBD and needs a post-chat snapshot to confirm. Issue 3 — `cursor-v2025-q2` extractor's fingerprint prefix (`cursorAIChatService.chatHistory.`) was NOT observed on Cursor 3.4.20. The extractor still ships (in case older versions use it) but the JSDoc now flags this as unverified and points to the dump script for capturing a real fixture before Branch 6 ships. Dump script additions: - Discovers ALL state.vscdb under Cursor's config tree (global + per-workspace) — chat messages live in the workspace DB, not global. - Dumps both `ItemTable` (filtered to chat-related key prefixes) AND `cursorDiskKV` (Cursor 3.x's parallel KV table; currently empty but may hold Composer messages once chats happen). - One output JSON per discovered DB; suffixed with `global` or `workspace-` for traceability. - `--redact` replaces string values > 8 chars with same-length asterisks. Dependencies: - Added better-sqlite3 ^11 + @types/better-sqlite3 ^7 as devDependencies in the sub-package. Dev-only — the production extension bundle is unaffected. Captured fixtures (redacted) — all three DBs from a chat-less Cursor 3.4.20 session, committed for regression testing: - cursor-3-4-20-initial-global.json (9 rows) - cursor-3-4-20-initial-workspace-1778826246907.json (7 rows) - cursor-3-4-20-initial-workspace-empty-window.json (2 rows) Verification: - Sub-package tsc --noEmit clean. - Sub-package vitest 82/82 pass. - Root tsc --noEmit clean. - Full root test suite 1908 passing + 18 pre-existing TtySelectFn carry-forward. Next step (manual, user-driven): submit a real prompt in Cursor's Ask mode AND in Composer mode, then re-run the dump script to capture a chat-bearing snapshot. The new keys / tables that appear will pin down the Composer-mode message storage location, and a follow-up commit will finalise the extractor decode logic against that real data. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/ext-vscode/package-lock.json | 499 ++++++++++++++++++ src/ext-vscode/package.json | 2 + src/ext-vscode/scripts/dump-cursor-state.ts | 314 +++++++---- .../src/extractors/cursor-v2025-q1.test.ts | 7 +- .../src/extractors/cursor-v2025-q1.ts | 37 +- .../src/extractors/cursor-v2025-q2.ts | 31 +- src/ext-vscode/src/extractors/index.test.ts | 6 +- .../cursor-3-4-20-initial-global.json | 57 ++ ...-4-20-initial-workspace-1778826246907.json | 47 ++ ...3-4-20-initial-workspace-empty-window.json | 22 + 10 files changed, 896 insertions(+), 126 deletions(-) create mode 100644 src/ext-vscode/test-fixtures/state-vscdb-samples/cursor-3-4-20-initial-global.json create mode 100644 src/ext-vscode/test-fixtures/state-vscdb-samples/cursor-3-4-20-initial-workspace-1778826246907.json create mode 100644 src/ext-vscode/test-fixtures/state-vscdb-samples/cursor-3-4-20-initial-workspace-empty-window.json diff --git a/src/ext-vscode/package-lock.json b/src/ext-vscode/package-lock.json index 27b2222b..f781c3dd 100644 --- a/src/ext-vscode/package-lock.json +++ b/src/ext-vscode/package-lock.json @@ -11,8 +11,10 @@ "sql.js": "^1" }, "devDependencies": { + "@types/better-sqlite3": "^7", "@types/node": "^20", "@types/vscode": "^1.80.0", + "better-sqlite3": "^11", "esbuild": "^0.21", "tsx": "^4", "typescript": "^5", @@ -821,6 +823,16 @@ "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", @@ -968,6 +980,86 @@ "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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", @@ -1005,6 +1097,13 @@ "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==", + "dev": true, + "license": "ISC" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1023,6 +1122,22 @@ } } }, + "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==", + "dev": true, + "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", @@ -1033,6 +1148,36 @@ "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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", @@ -1089,6 +1234,16 @@ "@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==", + "dev": true, + "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", @@ -1099,6 +1254,20 @@ "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1114,6 +1283,48 @@ "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "ISC" + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -1131,6 +1342,36 @@ "@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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1157,6 +1398,36 @@ "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/pathe": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", @@ -1210,6 +1481,76 @@ "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.", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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", @@ -1262,6 +1603,40 @@ "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==", + "dev": true, + "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==", + "dev": true, + "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", @@ -1269,6 +1644,53 @@ "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==", + "dev": true, + "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==", + "dev": true, + "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", @@ -1299,6 +1721,56 @@ "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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", @@ -1795,6 +2267,19 @@ "@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==", + "dev": true, + "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", @@ -1816,6 +2301,13 @@ "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==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", @@ -1981,6 +2473,13 @@ "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==", + "dev": true, + "license": "ISC" } } } diff --git a/src/ext-vscode/package.json b/src/ext-vscode/package.json index 48f1e6f0..dab56642 100644 --- a/src/ext-vscode/package.json +++ b/src/ext-vscode/package.json @@ -50,8 +50,10 @@ "sql.js": "^1" }, "devDependencies": { + "@types/better-sqlite3": "^7", "@types/node": "^20", "@types/vscode": "^1.80.0", + "better-sqlite3": "^11", "esbuild": "^0.21", "tsx": "^4", "typescript": "^5", diff --git a/src/ext-vscode/scripts/dump-cursor-state.ts b/src/ext-vscode/scripts/dump-cursor-state.ts index 39e2a55d..c3c44098 100644 --- a/src/ext-vscode/scripts/dump-cursor-state.ts +++ b/src/ext-vscode/scripts/dump-cursor-state.ts @@ -1,49 +1,65 @@ #!/usr/bin/env node /** - * scripts/dump-cursor-state.ts — capture a verified `state.vscdb` fixture + * scripts/dump-cursor-state.ts — capture verified `state.vscdb` fixtures * from a real machine for extractor regression testing. * - * Run this on any machine where Cursor (or Windsurf) is installed, against - * a chosen Cursor version. It: - * 1. Locates the Cursor `state.vscdb` for the OS the script is running on - * (or accepts an explicit `--src` override). - * 2. Opens the SQLite ItemTable with sql.js. - * 3. Writes a JSON snapshot of all chat-related keys (and only those keys - * — filenames, recents, theme settings, etc. are filtered out) to - * `src/ext-vscode/test-fixtures/state-vscdb-samples/.json`. + * Run this on any machine where Cursor is installed and chat has actually + * happened. It: + * 1. Locates ALL Cursor `state.vscdb` files under the OS's Cursor config + * tree (or accepts an explicit `--src` for a single file). + * Includes global storage AND per-workspace storage — chat messages + * live in the workspace DB, not the global one. + * 2. Opens each DB with `better-sqlite3` (WAL-aware — the live `.vscdb` + * file is ~4 KB; all writes go to the sibling `.vscdb-wal`, which + * `sql.js` cannot read). + * 3. Dumps every chat-related row from `ItemTable` AND every row from + * the `cursorDiskKV` table (separate KV store Cursor 3.x uses). + * 4. Optionally redacts long string values via `--redact`. + * 5. Writes one JSON snapshot per DB to + * `src/ext-vscode/test-fixtures/state-vscdb-samples/`. * - * The fixture file is suitable for committing to the repo (no personal data - * leaks of unrelated VS Code state) and can be replayed against extractors - * in unit tests to verify the per-version decoders. + * Discovered facts about Cursor 3.4.20 (2026-05-15 real-machine + * inspection): + * - `ItemTable` schema is correct, but lives in workspace DB + * (`User/workspaceStorage//state.vscdb`), not the global one + * (`User/globalStorage/state.vscdb`). + * - WAL mode is enabled — sibling `state.vscdb-wal` holds live writes. + * - Chat-relevant keys observed: `aiService.prompts`, `aiService.generations`, + * `composer.composerData` (metadata only — selectedComposerIds, migration + * flags), `workbench.panel.composerChatViewPane.` (UI state). + * - The actual Composer-mode message storage location was NOT in `ItemTable` + * on a fresh chat-less DB. Run this script AFTER having a real chat to + * find where the messages land. * * Usage: - * npx tsx src/ext-vscode/scripts/dump-cursor-state.ts \ - * --name cursor-v2025-q2-real \ - * [--src /path/to/state.vscdb] + * npx tsx scripts/dump-cursor-state.ts --name [--redact] [--src ] * - * NOTE: even after filtering to chat keys, the values may contain prompt - * text the user typed. Review the JSON output before committing and redact - * any sensitive content. The `--redact` flag (below) replaces all prompt - * text with a fixed string of the same length. + * If --src is omitted, every state.vscdb under ~/.config/Cursor (linux), + * ~/Library/Application Support/Cursor (darwin), %APPDATA%/Cursor (win32) + * is dumped — one output file per DB, suffixed with the source path. */ -import { readFile, writeFile, mkdir } from 'node:fs/promises'; -import { existsSync } from 'node:fs'; -import { homedir, platform as osPlatform } from 'node:os'; -import { join, dirname, resolve } from 'node:path'; +import { writeFile, mkdir, copyFile } from 'node:fs/promises'; +import { existsSync, readdirSync, statSync } from 'node:fs'; +import { homedir, platform as osPlatform, tmpdir } from 'node:os'; +import { join, dirname, resolve, basename } from 'node:path'; import { fileURLToPath } from 'node:url'; -// ESM-friendly equivalent of __dirname (sub-package uses "type": "module") const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); -/** Prefixes of ItemTable keys that should be captured. Everything else is dropped. */ -const KEEP_KEY_PREFIXES = [ +/** Keep ItemTable rows whose key starts with any of these. */ +const KEEP_ITEMTABLE_PREFIXES = [ 'aiService.', + 'composer.', 'composerData.', - 'cursorAIChatService.', 'cursorAIService.', + 'cursorAIChatService.', 'cascade.', + // UI-state keys we want too, to confirm composerId associations + 'workbench.panel.composerChatViewPane.', + 'workbench.panel.aichat.', + 'workbench.backgroundComposer.', ]; interface CliArgs { @@ -59,9 +75,8 @@ function parseArgs(argv: string[]): CliArgs { if (a === '--name') args.name = argv[++i]; else if (a === '--src') args.src = argv[++i]; else if (a === '--redact') args.redact = true; - else if (a === '--help' || a === '-h') { - printUsageAndExit(0); - } else { + else if (a === '--help' || a === '-h') printUsageAndExit(0); + else { console.error(`Unknown argument: ${a}`); printUsageAndExit(1); } @@ -78,63 +93,78 @@ function printUsageAndExit(code: number): never { npx tsx scripts/dump-cursor-state.ts --name [--src ] [--redact] Options: - --name Fixture file name (no extension). REQUIRED. - Convention: cursor-v-q-real / windsurf-real. - --src Path to state.vscdb. If omitted, default OS path is used. - --redact Replace prompt text with same-length placeholder strings. - Use this if the dump may contain sensitive content. + --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 + src/ext-vscode/test-fixtures/state-vscdb-samples/-.json `); process.exit(code); } -function defaultCursorStatePath(): string { +function cursorConfigRoot(): string { const home = homedir(); switch (osPlatform()) { case 'darwin': - return join(home, 'Library', 'Application Support', 'Cursor', 'User', 'globalStorage', 'state.vscdb'); + return join(home, 'Library', 'Application Support', 'Cursor'); case 'win32': - return join(process.env.APPDATA ?? home, 'Cursor', 'User', 'globalStorage', 'state.vscdb'); + return join(process.env.APPDATA ?? home, 'Cursor'); default: - return join(home, '.config', 'Cursor', 'User', 'globalStorage', 'state.vscdb'); + return join(home, '.config', 'Cursor'); } } -interface SqlJsModuleShape { - default: () => Promise<{ - Database: new (data: Uint8Array) => { - exec(sql: string): Array<{ columns: string[]; values: unknown[][] }>; - close(): void; - }; - }>; +interface DiscoveredDb { + path: string; + /** Short label for the output filename, e.g. 'global' or 'workspace-1778826246907'. */ + label: string; } -async function readItemTable(dbBytes: Buffer): Promise> { - const mod = (await import('sql.js')) as unknown as SqlJsModuleShape; - const SQL = await mod.default(); - const db = new SQL.Database(new Uint8Array(dbBytes)); - try { - const result = db.exec('SELECT key, value FROM ItemTable'); - const rows: Array<{ key: string; value: string }> = []; - for (const r of result) { - for (const v of r.values) { - rows.push({ key: String(v[0]), value: String(v[1]) }); +function discoverAllStateVscdb(root: string): DiscoveredDb[] { + const found: DiscoveredDb[] = []; + const globalPath = join(root, 'User', 'globalStorage', 'state.vscdb'); + if (existsSync(globalPath)) { + found.push({ path: globalPath, label: 'global' }); + } + const wsDir = join(root, 'User', 'workspaceStorage'); + if (existsSync(wsDir)) { + for (const entry of readdirSync(wsDir)) { + const dbPath = join(wsDir, entry, 'state.vscdb'); + if (existsSync(dbPath)) { + found.push({ path: dbPath, label: `workspace-${entry}` }); } } - return rows; - } finally { - db.close(); } + return found; } -function shouldKeep(key: string): boolean { - return KEEP_KEY_PREFIXES.some((p) => key.startsWith(p)); +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 shouldKeepItemTable(key: string): boolean { + return KEEP_ITEMTABLE_PREFIXES.some((p) => key.startsWith(p)); } function redactValue(value: string): string { - // Best-effort: try to parse and replace any string field longer than 8 chars. try { const parsed = JSON.parse(value); const redacted = JSON.parse(JSON.stringify(parsed), (_k, v) => { @@ -143,63 +173,143 @@ function redactValue(value: string): string { }); return JSON.stringify(redacted); } catch { - // not JSON — redact in bulk return '*'.repeat(value.length); } } -async function main(): Promise { - const args = parseArgs(process.argv.slice(2)); - const srcPath = args.src ?? defaultCursorStatePath(); +/** + * better-sqlite3 is WAL-aware: it opens the main DB and the sibling -wal/-shm + * automatically. We copy all three siblings to a tmp dir to avoid any chance + * of interfering with Cursor's live write path. + */ +async function readDbAsSnapshot(path: string): Promise { + // Copy main + wal + shm so the live DB is never touched. + 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); + } + } - if (!existsSync(srcPath)) { - console.error(`state.vscdb not found at: ${srcPath}`); - console.error('Pass --src to override.'); - process.exit(2); + 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 }); + // Checkpoint the WAL into the staged copy so all data is in the main file + // before we run our SELECTs. (Belt-and-braces — better-sqlite3 reads WAL + // transparently anyway, but checkpoint guarantees consistency.) + try { + db.pragma('wal_checkpoint(TRUNCATE)'); + } catch { + // some DBs aren't in WAL mode — that's fine } - console.log(`Reading: ${srcPath}`); - const bytes = await readFile(srcPath); - const rows = await readItemTable(bytes); - console.log(`Found ${rows.length} total ItemTable rows.`); + 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) { + // Keep ALL cursorDiskKV rows — this table is sparse and likely + // chat-relevant when populated. + dump.rows.push({ table: 'cursorDiskKV', key: r.key, value: r.value }); + } + } - const kept = rows.filter((r) => shouldKeep(r.key)); - console.log(`Keeping ${kept.length} chat-related rows (key prefixes: ${KEEP_KEY_PREFIXES.join(', ')}).`); + db.close(); + return dump; +} - const finalRows = args.redact - ? kept.map((r) => ({ key: r.key, value: redactValue(r.value) })) - : kept; +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)); - if (args.redact) { - console.log('Redacted all string values longer than 8 chars.'); + 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); } - const fixture = { - capturedAt: new Date().toISOString(), - sourcePath: srcPath, - platform: osPlatform(), - redacted: args.redact, - rows: finalRows, - }; + console.log(`Discovered ${targets.length} state.vscdb file(s):`); + for (const t of targets) console.log(` - [${t.label}] ${t.path}`); + console.log(''); - const repoRoot = resolve(__dirname, '..', '..', '..', '..'); - const outDir = join( - repoRoot, - 'src', - 'ext-vscode', - 'test-fixtures', - 'state-vscdb-samples', - ); + // __dirname = /src/ext-vscode/scripts → go up 1 to reach + // the sub-package root, then write to test-fixtures/ alongside src/. + const subPackageRoot = resolve(__dirname, '..'); + const outDir = join(subPackageRoot, 'test-fixtures', 'state-vscdb-samples'); await mkdir(outDir, { recursive: true }); - const outPath = join(outDir, `${args.name}.json`); - await writeFile(outPath, JSON.stringify(fixture, null, 2) + '\n', 'utf8'); - console.log(`Wrote: ${outPath}`); + 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 for any sensitive content before committing.'); + 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 this fixture from the relevant extractor test.'); + console.log(' 3. Reference these fixtures from extractor regression tests.'); } main().catch((err: unknown) => { diff --git a/src/ext-vscode/src/extractors/cursor-v2025-q1.test.ts b/src/ext-vscode/src/extractors/cursor-v2025-q1.test.ts index db9ecf59..30703d58 100644 --- a/src/ext-vscode/src/extractors/cursor-v2025-q1.test.ts +++ b/src/ext-vscode/src/extractors/cursor-v2025-q1.test.ts @@ -3,7 +3,7 @@ import { cursorV2025Q1 } from './cursor-v2025-q1.js'; import type { ItemTableRow } from '../chat-history-types.js'; const SRC = '/fake/state.vscdb'; -const KEY = 'composerData.composerData'; +const KEY = 'composer.composerData'; const wrap = (composerData: unknown): ItemTableRow => ({ key: KEY, @@ -20,12 +20,13 @@ describe('cursorV2025Q1 extractor', () => { }); describe('ownsKey', () => { - it('matches exactly composerData.composerData', () => { + 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('composerData.other')).toBe(false); + expect(cursorV2025Q1.ownsKey('composer.other')).toBe(false); + expect(cursorV2025Q1.ownsKey('composerData.composerData')).toBe(false); }); }); diff --git a/src/ext-vscode/src/extractors/cursor-v2025-q1.ts b/src/ext-vscode/src/extractors/cursor-v2025-q1.ts index e77d3fb9..e74d6373 100644 --- a/src/ext-vscode/src/extractors/cursor-v2025-q1.ts +++ b/src/ext-vscode/src/extractors/cursor-v2025-q1.ts @@ -5,20 +5,37 @@ import type { } from '../chat-history-types.js'; /** - * Cursor v2025-Q1 — the Composer-launch era (Cursor versions ~0.45–0.49). + * Cursor v2025-Q1 (Composer era). * - * Community-documented key: `composerData.composerData` — a single JSON - * object with `allComposers: Record` where - * each conversation has a `conversation: ComposerMessage[]` field. Messages - * carry a `type` (1 = user, 2 = assistant) or a `role` ('user' / 'assistant') - * depending on minor version. + * **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. * - * TODO(M2/B2): verify against a real Cursor v2025-Q1 `state.vscdb` dump - * before Branch 6 ships. Field names may need adjustment after fixture - * capture via `scripts/dump-cursor-state.ts`. + * **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 = 'composerData.composerData'; +const COMPOSER_KEY = 'composer.composerData'; interface ComposerConversation { /** Composer / tab identifier — used as `rawSessionId`. */ diff --git a/src/ext-vscode/src/extractors/cursor-v2025-q2.ts b/src/ext-vscode/src/extractors/cursor-v2025-q2.ts index 4be01b5a..39b4f3f9 100644 --- a/src/ext-vscode/src/extractors/cursor-v2025-q2.ts +++ b/src/ext-vscode/src/extractors/cursor-v2025-q2.ts @@ -5,16 +5,31 @@ import type { } from '../chat-history-types.js'; /** - * Cursor v2025-Q2+ — current per-tab chat history layout. + * Cursor v2025-Q2+ — community-documented per-tab chat history layout. * - * Community-documented key prefix: `cursorAIChatService.chatHistory.` - * — value is a JSON array of message objects. Each message has either a - * `role` ('user' / 'assistant') or a `type` (string or legacy numeric), - * and the prompt text lives in either `content` or `text`. + * **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. * - * TODO(M2/B2): verify against a real Cursor v2025-Q2+ `state.vscdb` dump - * before Branch 6 ships. Field names may need adjustment after fixture - * capture via `scripts/dump-cursor-state.ts`. + * **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.'; diff --git a/src/ext-vscode/src/extractors/index.test.ts b/src/ext-vscode/src/extractors/index.test.ts index fc2a0da1..80033156 100644 --- a/src/ext-vscode/src/extractors/index.test.ts +++ b/src/ext-vscode/src/extractors/index.test.ts @@ -68,8 +68,8 @@ describe('pickExtractor (M4 fingerprint)', () => { } }); - it('picks cursor-v2025-q1 when composerData.composerData is present', () => { - const result = pickExtractor(['composerData.composerData', 'other.key']); + 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'); @@ -111,7 +111,7 @@ describe('pickExtractor (M4 fingerprint)', () => { // — q1 wins because it appears earlier in ALL_EXTRACTORS. const result = pickExtractor([ 'aiService.prompts', - 'composerData.composerData', + 'composer.composerData', ]); expect(result.kind).toBe('known'); if (result.kind === 'known') { 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": "[]" + } + ] +} From 94d81dc692094867d3318504560bb9762b9d0c30 Mon Sep 17 00:00:00 2001 From: Vedansi18 Date: Fri, 15 May 2026 12:29:00 +0530 Subject: [PATCH 07/51] M2/B2 follow-up: extract dump-script helpers to src/ + add 28 unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the unit-test audit gap surfaced for M2 Branch 2. The dump script had real business logic (`redactValue`, `shouldKeepItemTable`, `parseArgs`, `cursorConfigRoot`, `discoverAllStateVscdb`) with zero test coverage — `redactValue` in particular has data-leak consequences if buggy. Extracted the pure / near-pure helpers into a new module: - `src/cursor-state-dump-helpers.ts` — lives under tsconfig rootDir so it's typechecked by the sub-package's main `tsc --noEmit` and auto-picked-up by vitest. Re-exports `KEEP_ITEMTABLE_PREFIXES`, `shouldKeepItemTable`, `redactValue`, `cursorConfigRoot`, `discoverAllStateVscdb` (with injectable fs helpers), and `parseArgs` (returns a tagged-union result instead of calling `process.exit`, so the error paths are testable). Co-located tests: `src/cursor-state-dump-helpers.test.ts` — 28 tests covering: - `shouldKeepItemTable`: each default prefix matched, unrelated keys dropped, custom prefix lists, prefix-not-exact match. - `redactValue`: short-string preservation, long-string redaction, nested object/array recursion, non-string value preservation, bulk redact for non-JSON input, JSON-string root, exact 9-char boundary. - `cursorConfigRoot`: linux / darwin / win32 / unknown-platform paths and APPDATA fallback. - `discoverAllStateVscdb`: empty tree, global-only, global + multiple workspaces, skip workspace dirs missing the DB, injectable fs. - `parseArgs`: required `--name`, optional `--src` / `--redact`, `--help` / `-h` signal, missing-value rejection, unknown-argument rejection. Script entry-point `scripts/dump-cursor-state.ts` now imports from `../src/cursor-state-dump-helpers.js` and retains only the I/O orchestration (file copy to tmp staging dir, better-sqlite3 read, fixture write). Behaviour is byte-for-byte unchanged — verified by re-running against the live machine and producing identical row counts to the previous commit (`3794bc3`). Sub-package totals: - Test files: 10 (was 9) - Tests: 110 passing (was 82) — +28 helpers tests - Sub-package tsc --noEmit clean - Root tsc --noEmit clean - Full root suite: 1936 passing + 18 pre-existing TtySelectFn Windows-sim failures (M1 §3.0 carry-forward, unrelated) Closes the only remaining audit gap for M2 Branch 2. No further unit-test work pending; per the auto-commit rule the branch is now closed pending push. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/ext-vscode/scripts/dump-cursor-state.ts | 200 ++++---------- .../src/cursor-state-dump-helpers.test.ts | 253 ++++++++++++++++++ .../src/cursor-state-dump-helpers.ts | 183 +++++++++++++ 3 files changed, 483 insertions(+), 153 deletions(-) create mode 100644 src/ext-vscode/src/cursor-state-dump-helpers.test.ts create mode 100644 src/ext-vscode/src/cursor-state-dump-helpers.ts diff --git a/src/ext-vscode/scripts/dump-cursor-state.ts b/src/ext-vscode/scripts/dump-cursor-state.ts index c3c44098..a6cdc26c 100644 --- a/src/ext-vscode/scripts/dump-cursor-state.ts +++ b/src/ext-vscode/scripts/dump-cursor-state.ts @@ -3,92 +3,55 @@ * scripts/dump-cursor-state.ts — capture verified `state.vscdb` fixtures * from a real machine for extractor regression testing. * - * Run this on any machine where Cursor is installed and chat has actually - * happened. It: - * 1. Locates ALL Cursor `state.vscdb` files under the OS's Cursor config - * tree (or accepts an explicit `--src` for a single file). - * Includes global storage AND per-workspace storage — chat messages - * live in the workspace DB, not the global one. - * 2. Opens each DB with `better-sqlite3` (WAL-aware — the live `.vscdb` - * file is ~4 KB; all writes go to the sibling `.vscdb-wal`, which - * `sql.js` cannot read). - * 3. Dumps every chat-related row from `ItemTable` AND every row from - * the `cursorDiskKV` table (separate KV store Cursor 3.x uses). - * 4. Optionally redacts long string values via `--redact`. - * 5. Writes one JSON snapshot per DB to - * `src/ext-vscode/test-fixtures/state-vscdb-samples/`. + * 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. * - * Discovered facts about Cursor 3.4.20 (2026-05-15 real-machine - * inspection): - * - `ItemTable` schema is correct, but lives in workspace DB - * (`User/workspaceStorage//state.vscdb`), not the global one - * (`User/globalStorage/state.vscdb`). - * - WAL mode is enabled — sibling `state.vscdb-wal` holds live writes. - * - Chat-relevant keys observed: `aiService.prompts`, `aiService.generations`, - * `composer.composerData` (metadata only — selectedComposerIds, migration - * flags), `workbench.panel.composerChatViewPane.` (UI state). - * - The actual Composer-mode message storage location was NOT in `ItemTable` - * on a fresh chat-less DB. Run this script AFTER having a real chat to - * find where the messages land. + * 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 ] - * - * If --src is omitted, every state.vscdb under ~/.config/Cursor (linux), - * ~/Library/Application Support/Cursor (darwin), %APPDATA%/Cursor (win32) - * is dumped — one output file per DB, suffixed with the source path. */ import { writeFile, mkdir, copyFile } from 'node:fs/promises'; -import { existsSync, readdirSync, statSync } from 'node:fs'; -import { homedir, platform as osPlatform, tmpdir } from 'node:os'; +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); -/** Keep ItemTable rows whose key starts with any of these. */ -const KEEP_ITEMTABLE_PREFIXES = [ - 'aiService.', - 'composer.', - 'composerData.', - 'cursorAIService.', - 'cursorAIChatService.', - 'cascade.', - // UI-state keys we want too, to confirm composerId associations - 'workbench.panel.composerChatViewPane.', - 'workbench.panel.aichat.', - 'workbench.backgroundComposer.', -]; - -interface CliArgs { - name: string; - src?: string; - redact: boolean; +interface DumpedRow { + table: 'ItemTable' | 'cursorDiskKV'; + key: string; + value: string; } -function parseArgs(argv: string[]): CliArgs { - const args: Partial = { redact: false }; - for (let i = 0; i < argv.length; i++) { - const a = argv[i]; - if (a === '--name') args.name = argv[++i]; - else if (a === '--src') args.src = argv[++i]; - else if (a === '--redact') args.redact = true; - else if (a === '--help' || a === '-h') printUsageAndExit(0); - else { - console.error(`Unknown argument: ${a}`); - printUsageAndExit(1); - } - } - if (!args.name) { - console.error('Missing required --name '); - printUsageAndExit(1); - } - return args as CliArgs; +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 printUsageAndExit(code: number): never { +function printUsage(): void { console.log(`Usage: npx tsx scripts/dump-cursor-state.ts --name [--src ] [--redact] @@ -104,86 +67,14 @@ Options: Output: src/ext-vscode/test-fixtures/state-vscdb-samples/-.json `); - process.exit(code); -} - -function cursorConfigRoot(): string { - const home = homedir(); - switch (osPlatform()) { - case 'darwin': - return join(home, 'Library', 'Application Support', 'Cursor'); - case 'win32': - return join(process.env.APPDATA ?? home, 'Cursor'); - default: - return join(home, '.config', 'Cursor'); - } -} - -interface DiscoveredDb { - path: string; - /** Short label for the output filename, e.g. 'global' or 'workspace-1778826246907'. */ - label: string; -} - -function discoverAllStateVscdb(root: string): DiscoveredDb[] { - const found: DiscoveredDb[] = []; - const globalPath = join(root, 'User', 'globalStorage', 'state.vscdb'); - if (existsSync(globalPath)) { - found.push({ path: globalPath, label: 'global' }); - } - const wsDir = join(root, 'User', 'workspaceStorage'); - if (existsSync(wsDir)) { - for (const entry of readdirSync(wsDir)) { - const dbPath = join(wsDir, entry, 'state.vscdb'); - if (existsSync(dbPath)) { - found.push({ path: dbPath, label: `workspace-${entry}` }); - } - } - } - return found; -} - -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 shouldKeepItemTable(key: string): boolean { - return KEEP_ITEMTABLE_PREFIXES.some((p) => key.startsWith(p)); -} - -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); - } } /** - * better-sqlite3 is WAL-aware: it opens the main DB and the sibling -wal/-shm - * automatically. We copy all three siblings to a tmp dir to avoid any chance - * of interfering with Cursor's live write path. + * 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 { - // Copy main + wal + shm so the live DB is never touched. const stagingDir = join( tmpdir(), `nexpath-dump-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, @@ -209,13 +100,10 @@ async function readDbAsSnapshot(path: string): Promise { }; const Database = mod.default; const db = new Database(stagedMain, { readonly: true }); - // Checkpoint the WAL into the staged copy so all data is in the main file - // before we run our SELECTs. (Belt-and-braces — better-sqlite3 reads WAL - // transparently anyway, but checkpoint guarantees consistency.) try { db.pragma('wal_checkpoint(TRUNCATE)'); } catch { - // some DBs aren't in WAL mode — that's fine + // not WAL — fine } const dump: DumpedDb = { @@ -250,8 +138,6 @@ async function readDbAsSnapshot(path: string): Promise { .prepare('SELECT key, value FROM cursorDiskKV') .all() as Array<{ key: string; value: string }>; for (const r of rows) { - // Keep ALL cursorDiskKV rows — this table is sparse and likely - // chat-relevant when populated. dump.rows.push({ table: 'cursorDiskKV', key: r.key, value: r.value }); } } @@ -261,7 +147,17 @@ async function readDbAsSnapshot(path: string): Promise { } async function main(): Promise { - const args = parseArgs(process.argv.slice(2)); + 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' }] @@ -277,8 +173,6 @@ async function main(): Promise { for (const t of targets) console.log(` - [${t.label}] ${t.path}`); console.log(''); - // __dirname = /src/ext-vscode/scripts → go up 1 to reach - // the sub-package root, then write to test-fixtures/ alongside src/. const subPackageRoot = resolve(__dirname, '..'); const outDir = join(subPackageRoot, 'test-fixtures', 'state-vscdb-samples'); await mkdir(outDir, { recursive: true }); 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 }; +} From 883ade9f948c065885f7329eb89d7e1be8eb7f0f Mon Sep 17 00:00:00 2001 From: Vedansi18 Date: Fri, 15 May 2026 15:42:09 +0530 Subject: [PATCH 08/51] M2/B3: Add decision-session webview, HTML template, prompt injection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Branch 3 of Milestone M2 (v0.1.3/m2/webview-ui). Stacked on M2 Branch 1 (commit 879ed5e) — does NOT depend on M2 B2's watcher, only on B1's skeleton + the DecisionSessionPayload type from ipc.ts. Delivers the three scoped modules: M6 — WebviewViewProvider: src/webview/view-provider.ts. NexpathDecisionSessionViewProvider implements vscode.WebviewViewProvider for the nexpath.status activity-bar view. resolveWebviewView wires webview.options (enableScripts + localResourceRoots), sets initial HTML, registers onDidReceiveMessage + onDidDispose. publishPayload stores the payload, updates the HTML, and calls webviewView.show(true) for the auto-reveal UX matching architecture rev 2 §4. Payload survives view dispose/re-show. Injectable onSelect dependency for tests. Exposes getCurrentPayload() + handleMessage() for direct message-routing tests. M7 — HTML template: src/webview/html.ts. renderDecisionSessionHtml(payload, opts) — pure function, no I/O. Returns the full self-contained HTML for the webview. Two modes: empty/watching state (no scripts, just "Nexpath is active…") and populated state (advisory + numbered option buttons + dismiss). CSP: default-src 'none' with nonce-scoped scripts. All user-controlled strings HTML-escaped. Theming via --vscode-* CSS variables so the UI inherits Cursor/Windsurf's theme. Tests verify both states, nonce handling, HTML escaping (incl. ')).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(' { + 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(/