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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.4.0] - 2026-05-04

### Fixed

- `findSessionStorePath` now resolves Cursor's current on-disk layout — sessions are stored at `~/.cursor/acp-sessions/<sessionId>/store.db`. Earlier versions only scanned the legacy `~/.cursor/chats/<hash>/<sessionId>/store.db` tree, which caused `SessionNotFoundError` for every session created by recent Cursor builds. The legacy layout is still scanned as a fallback so pre-migration sessions keep working.

## [0.3.0] - 2026-04-04

### Added

- `richResult: Record<string, unknown> | null` field on `EnrichedToolCall` — surfaces Cursor's `providerOptions.cursor.highLevelToolCallResult` for tool-role blobs, exposing structured result data (e.g. `workspaceResults` for Read) in addition to the plain-string `result`.

## [0.2.1] - 2026-04-02

### Fixed
Expand Down
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,19 @@ Recovers missing tool call arguments from Cursor's ACP (Agent Client Protocol) e

Cursor's ACP stream emits `tool_call` notifications with an empty `rawInput` field — you can see _that_ a tool was called but not _what arguments_ it received (which file was read, which command was run, etc.).

Cursor does write the full tool call data — including arguments — to a local SQLite database at:
Cursor does write the full tool call data — including arguments — to a local SQLite database. Recent Cursor builds use a flat layout:

```
~/.cursor/acp-sessions/<sessionId>/store.db
```

Older sessions still live under the legacy hashed layout, which this package also resolves transparently:

```
~/.cursor/chats/<hash>/<sessionId>/store.db
```

This package reads that database to recover the missing arguments, using the `toolCallId` as the join key between ACP events and store.db blobs.
This package reads whichever database is present for a given session and recovers the missing arguments, using the `toolCallId` as the join key between ACP events and store.db blobs.

## Installation

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "cursor-acp-enriched",
"version": "0.3.0",
"version": "0.4.0",
"description": "Recovers missing tool call arguments from Cursor's ACP events by reading the local SQLite store.db",
"license": "MIT",
"type": "module",
Expand Down
26 changes: 22 additions & 4 deletions src/pathDiscovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,35 @@ import { homedir } from 'node:os';

export class SessionNotFoundError extends Error {
constructor(sessionId: string, cursorDir: string) {
super(`Session "${sessionId}" not found under ${cursorDir}/chats/`);
super(
`Session "${sessionId}" not found under ${cursorDir}/acp-sessions/ or ${cursorDir}/chats/`,
);
this.name = 'SessionNotFoundError';
}
}

// Scans ~/.cursor/chats/<hash>/<sessionId>/store.db to locate the SQLite
// database for a given Cursor session. Returns the full path to store.db.
// Locates the SQLite store.db for a given Cursor ACP session.
//
// Cursor has used two on-disk layouts:
//
// 1. Current (Cursor ≥ mid-2026): flat
// ~/.cursor/acp-sessions/<sessionId>/store.db
//
// 2. Legacy: hashed
// ~/.cursor/chats/<hash>/<sessionId>/store.db
//
// The current layout is checked first because it is a single existsSync()
// call. If it is missing, fall back to scanning the legacy chats/ tree so
// long-running clients can still enrich pre-migration sessions.
export function findSessionStorePath(sessionId: string, options?: { cursorDir?: string }): string {
const cursorDir = options?.cursorDir ?? join(homedir(), '.cursor');
const chatsDir = join(cursorDir, 'chats');

const flatPath = join(cursorDir, 'acp-sessions', sessionId, 'store.db');
if (existsSync(flatPath)) {
return flatPath;
}

const chatsDir = join(cursorDir, 'chats');
let hashDirs: string[];
try {
hashDirs = readdirSync(chatsDir);
Expand Down
52 changes: 43 additions & 9 deletions test/pathDiscovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,39 +8,73 @@ import { findSessionStorePath, SessionNotFoundError } from '../src/pathDiscovery
const __dirname = dirname(fileURLToPath(import.meta.url));
const FIXTURE_DB = join(__dirname, 'fixtures', 'store.db');

function makeFakeCursorDir(sessionId: string): string {
const base = mkdtempSync(join(tmpdir(), 'cursor-test-'));
function makeLegacyCursorDir(sessionId: string): string {
const base = mkdtempSync(join(tmpdir(), 'cursor-legacy-'));
const hashDir = join(base, 'chats', 'abc123def', sessionId);
mkdirSync(hashDir, { recursive: true });
copyFileSync(FIXTURE_DB, join(hashDir, 'store.db'));
return base;
}

function makeFlatCursorDir(sessionId: string): string {
const base = mkdtempSync(join(tmpdir(), 'cursor-flat-'));
const sessionDir = join(base, 'acp-sessions', sessionId);
mkdirSync(sessionDir, { recursive: true });
copyFileSync(FIXTURE_DB, join(sessionDir, 'store.db'));
return base;
}

function makeBothLayoutsCursorDir(sessionId: string): string {
const base = mkdtempSync(join(tmpdir(), 'cursor-both-'));
const flatDir = join(base, 'acp-sessions', sessionId);
mkdirSync(flatDir, { recursive: true });
copyFileSync(FIXTURE_DB, join(flatDir, 'store.db'));
const legacyDir = join(base, 'chats', 'abc123def', sessionId);
mkdirSync(legacyDir, { recursive: true });
copyFileSync(FIXTURE_DB, join(legacyDir, 'store.db'));
return base;
}

describe('findSessionStorePath', () => {
it('returns the correct store.db path when session exists', () => {
const sessionId = 'test-session-abc';
const cursorDir = makeFakeCursorDir(sessionId);
it('returns the flat acp-sessions path when only the new layout exists', () => {
const sessionId = 'test-session-flat';
const cursorDir = makeFlatCursorDir(sessionId);
const result = findSessionStorePath(sessionId, { cursorDir });
expect(result).toBe(join(cursorDir, 'acp-sessions', sessionId, 'store.db'));
});

it('returns the legacy chats path when only the old layout exists', () => {
const sessionId = 'test-session-legacy';
const cursorDir = makeLegacyCursorDir(sessionId);
const result = findSessionStorePath(sessionId, { cursorDir });
expect(result).toBe(join(cursorDir, 'chats', 'abc123def', sessionId, 'store.db'));
});

it('throws SessionNotFoundError when session is missing', () => {
const base = mkdtempSync(join(tmpdir(), 'cursor-test-'));
it('prefers the flat acp-sessions path when both layouts exist', () => {
const sessionId = 'test-session-both';
const cursorDir = makeBothLayoutsCursorDir(sessionId);
const result = findSessionStorePath(sessionId, { cursorDir });
expect(result).toBe(join(cursorDir, 'acp-sessions', sessionId, 'store.db'));
});

it('throws SessionNotFoundError when session is missing in both layouts', () => {
const base = mkdtempSync(join(tmpdir(), 'cursor-missing-'));
mkdirSync(join(base, 'chats'), { recursive: true });
mkdirSync(join(base, 'acp-sessions'), { recursive: true });
expect(() => findSessionStorePath('nonexistent-session', { cursorDir: base })).toThrow(
SessionNotFoundError,
);
});

it('throws SessionNotFoundError when chats dir does not exist', () => {
it('throws SessionNotFoundError when neither chats nor acp-sessions exist', () => {
const base = mkdtempSync(join(tmpdir(), 'cursor-empty-'));
expect(() => findSessionStorePath('any-session', { cursorDir: base })).toThrow(
SessionNotFoundError,
);
});

it('error message includes sessionId', () => {
const base = mkdtempSync(join(tmpdir(), 'cursor-test-'));
const base = mkdtempSync(join(tmpdir(), 'cursor-msg-'));
mkdirSync(join(base, 'chats'), { recursive: true });
expect(() => findSessionStorePath('my-session-id', { cursorDir: base })).toThrow(
/my-session-id/,
Expand Down