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
18 changes: 16 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,12 +134,12 @@ Focused evidence is a deterministic, lossy Markdown export for qualitative DX an
| --- | --- | --- |
| Codex | shared Codex DB probe list | `SPIRACHA_CODEX_DB` |
| Claude Code | `~/.claude/projects` | `SPIRACHA_CLAUDE_CODE_PROJECTS_DIR` |
| Cline | VS Code `User/globalStorage/saoudrizwan.claude-dev` | `SPIRACHA_CLINE_GLOBAL_STORAGE_DIR` |
| Cline | `~/.cline/data/sessions` | `SPIRACHA_CLINE_DATA_DIR` |
| Grok | `~/.grok/sessions` | `SPIRACHA_GROK_SESSIONS_DIR` |
| Kiro | `~/Library/Application Support/Kiro/User/globalStorage/kiro.kiroagent/workspace-sessions` | `SPIRACHA_KIRO_WORKSPACE_SESSIONS_DIR` |
| Qoder | `~/Library/Application Support/Qoder/User/globalStorage/state.vscdb` and `~/Library/Application Support/Qoder/User/workspaceStorage` | `SPIRACHA_QODER_GLOBAL_STATE_DB`, `SPIRACHA_QODER_WORKSPACE_STORAGE_DIR` |
| Cursor | `~/Library/Application Support/Cursor/User` on macOS | `SPIRACHA_CURSOR_USER_DIR`, `SPIRACHA_CURSOR_PROJECTS_DIR` |
| Antigravity | `~/.gemini/antigravity-ide` and `~/.gemini/antigravity` | `SPIRACHA_ANTIGRAVITY_DIRS`, `SPIRACHA_ANTIGRAVITY_DIR` |
| Antigravity | `~/.gemini/antigravity-ide`, `~/.gemini/antigravity-cli`, and `~/.gemini/antigravity` | `SPIRACHA_ANTIGRAVITY_DIRS`, `SPIRACHA_ANTIGRAVITY_DIR` |
| MiniMax Code | `~/.minimax/v2/sessions` and `~/.minimax/v2/sqlite/runtime-state.sqlite` | `SPIRACHA_MINIMAX_CODE_SESSIONS_DIR`, `SPIRACHA_MINIMAX_CODE_RUNTIME_DB_PATH` |
| OpenCode | `${XDG_DATA_HOME:-~/.local/share}/opencode/opencode.db` | `SPIRACHA_OPENCODE_DB` |
| UI exports | OS temp directory under `spiracha-ui-exports` | `SPIRACHA_UI_EXPORT_DIR` |
Expand All @@ -154,6 +154,20 @@ When an Antigravity conversation has a live trajectory database, Spiracha treats

Markdown transcript exports identify this parser contract with `transcript_schema: antigravity-transcript/v2`. The UI parser retains complete tool output in its event data and export, but bounds the rendered preview to 20,000 characters so a single large operation result cannot dominate the detail page.

Encrypted Antigravity transcripts use the macOS Keychain item `Antigravity Safe Storage` / `Antigravity Key` and the Electron-compatible `saltysalt` PBKDF2 derivation. Keychain access is reacquired for each protected server request; the raw secret is not stored in process-global state or returned to the browser. Non-encrypted transcripts do not require Keychain access, and other platforms report decryption as unsupported.

### Codex browser database compatibility

Codex browser reads target the `codex-state-5-thread-browse-v1` compatibility profile. The `threads` table and its browse columns are required; missing tables, missing columns, or invalid decoded row values produce an actionable compatibility error instead of a guessed result. The `thread_dynamic_tools`, `thread_goals`, and `thread_spawn_edges` tables are optional and are read when present, so older databases without those tables remain browseable.

Multi-table thread browse reads run inside one SQLite deferred read transaction. This gives each browse result a consistent database snapshot while allowing Codex's normal writers to continue; it does not claim crash-level atomicity across the main database, attached history databases, rollout files, or the session index. Destructive DB changes commit before optional session-file and session-index cleanup, and cleanup results identify what was removed.

UI batch Codex exports use one batch browse pass and include a versioned `spiracha-manifest.json` in every successful archive. The manifest preserves requested ID order and records exported, missing, unreadable, and unstable entries. A batch succeeds when at least one selected thread is exportable; a single-thread export remains fail-fast. Active rollout files are copied to an attempt-local snapshot, checked by size, inode, and high-resolution timestamps, and retried once when they mutate during the copy.

### Cursor SQLite access

Cursor reads use a retry-aware synchronous callback that opens a fresh read handle for each attempt and closes it before retrying. Cursor mutations use a same-database `BEGIN IMMEDIATE` transaction with the shared bounded SQLite retry policy and no stacked long `busy_timeout`. Missing writable databases fail closed instead of being created. Recovery and deletion keep cross-database compensation and filesystem cleanup outside retry callbacks; destructive discovery uses strict readers so exhausted locks cannot be mistaken for empty data. UI mutation entrypoints still require Cursor to be closed before writing because Cursor can rewrite its history on exit.

## UI Routes

- `/codex` and `/codex/$project` for Codex inventory and project threads.
Expand Down
42 changes: 21 additions & 21 deletions bun.lock

Large diffs are not rendered by default.

10 changes: 5 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,13 @@
"@tanstack/react-devtools": "0.10.10",
"@tanstack/react-query": "5.101.4",
"@tanstack/react-query-devtools": "5.101.4",
"@tanstack/react-router": "1.170.27",
"@tanstack/react-router": "1.170.29",
"@tanstack/react-router-devtools": "1.167.1",
"@tanstack/react-router-ssr-query": "1.167.1",
"@tanstack/react-start": "1.168.44",
"@tanstack/react-start": "1.168.46",
"@tanstack/react-table": "9.1.2",
"@tanstack/react-virtual": "3.14.9",
"@tanstack/router-plugin": "1.168.30",
"@tanstack/router-plugin": "1.168.32",
"@vitejs/plugin-react": "6.0.5",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
Expand All @@ -40,7 +40,7 @@
},
"description": "Browse and expose local Codex, Claude Code, Grok, Kiro, Qoder, Cursor, Antigravity, MiniMax Code, and OpenCode conversation history through a local UI and stable data API.",
"devDependencies": {
"@biomejs/biome": "2.5.8",
"@biomejs/biome": "2.5.9",
"@testing-library/dom": "10.4.1",
"@testing-library/react": "16.3.2",
"@types/bun": "^1.3.14",
Expand Down Expand Up @@ -127,5 +127,5 @@
"ui:preview": "bun --bun vite preview"
},
"type": "module",
"version": "2.4.1"
"version": "2.5.0"
}
8 changes: 4 additions & 4 deletions src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ describe('conversation client', () => {
locations: {
antigravityRoots: [path.join(tempRoot, 'antigravity')],
claudeCodeProjectsDir: path.join(tempRoot, 'claude'),
clineGlobalStorageDir: path.join(tempRoot, 'cline'),
clineDataDir: path.join(tempRoot, 'cline'),
codexDbPath: path.join(tempRoot, 'missing-codex.sqlite'),
cursorUserDir: path.join(tempRoot, 'cursor'),
kiroWorkspaceSessionsDir: path.join(tempRoot, 'kiro'),
Expand Down Expand Up @@ -188,7 +188,7 @@ describe('conversation client', () => {
const locations = {
antigravityRoots: [path.join(tempRoot, 'antigravity')],
claudeCodeProjectsDir: path.join(tempRoot, 'claude'),
clineGlobalStorageDir: path.join(tempRoot, 'cline'),
clineDataDir: path.join(tempRoot, 'cline'),
codexDbPath: path.join(tempRoot, 'missing-codex.sqlite'),
cursorUserDir: path.join(tempRoot, 'cursor'),
kiroWorkspaceSessionsDir: path.join(tempRoot, 'kiro'),
Expand Down Expand Up @@ -424,7 +424,7 @@ describe('conversation client', () => {

return new Response(new Blob(['zip-bytes'], { type: 'application/zip' }), {
headers: {
'Content-Disposition': "attachment; filename*=UTF-8''grok-conversations-2.zip",
'Content-Disposition': "attachment; filename*=UTF-8''grok_repo-1970-01-01-0000-threads-2.zip",
'Content-Type': 'application/zip',
},
});
Expand All @@ -444,7 +444,7 @@ describe('conversation client', () => {
});

expect(download).not.toBeNull();
expect(download!.fileName).toBe('grok-conversations-2.zip');
expect(download!.fileName).toBe('grok_repo-1970-01-01-0000-threads-2.zip');
expect(download!.mimeType).toBe('application/zip');
await expect(download!.blob.text()).resolves.toBe('zip-bytes');
expect(requests).toEqual([
Expand Down
6 changes: 5 additions & 1 deletion src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import type {
ResolvedConversationRef,
} from './lib/conversation-data/types';
import { createConversationMarkdownZip } from './lib/conversation-zip-export';
import { getExportPlatformName } from './lib/ui-export-archive';

export type {
ConversationDataLocations,
Expand Down Expand Up @@ -370,14 +371,17 @@ const exportLocalConversationsZip = async (
entries: conversations.map((conversation, index) => {
const resolvedConversation = conversation!;
return {
cwd: resolvedConversation.workspacePath,
fallbackBaseName: `${options.source}-${options.ids[index]}`,
markdown: renderLocalConversationMarkdown(resolvedConversation, {
messageSelector: options.messageSelector ?? 'all',
}),
title: resolvedConversation.title,
updatedAtMs: resolvedConversation.updatedAtMs,
};
}),
fileBaseName: `${options.source}-conversations-${options.ids.length}`,
fallbackProjectName: `${options.source}-conversations`,
platform: getExportPlatformName(options.source),
});
};

Expand Down
117 changes: 117 additions & 0 deletions src/lib/antigravity-db.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@ import {
groupAntigravityConversations,
listAntigravityConversations,
readAntigravityConversationMessages,
readAntigravitySummaryIndexWithDiagnostics,
renderAntigravityArtifactsMarkdown,
renderAntigravityConversationMarkdown,
} from './antigravity-db';
import { resolveAntigravityRoots } from './antigravity-exporter-types';
import { ANTIGRAVITY_TRANSCRIPT_MARKDOWN_VERSION } from './antigravity-transcript-contract';
import { antigravityMarkdownToThreadEvents } from './antigravity-transcript-events';

Expand Down Expand Up @@ -276,6 +278,106 @@ const runGit = async (cwd: string, args: string[]): Promise<void> => {
};

describe('antigravity db discovery', () => {
it('should include the Antigravity CLI root in default discovery roots without leaking overrides', () => {
const originalDirs = process.env.SPIRACHA_ANTIGRAVITY_DIRS;
const originalDir = process.env.SPIRACHA_ANTIGRAVITY_DIR;
try {
delete process.env.SPIRACHA_ANTIGRAVITY_DIRS;
delete process.env.SPIRACHA_ANTIGRAVITY_DIR;
expect(resolveAntigravityRoots()).toContain(path.join(os.homedir(), '.gemini', 'antigravity-cli'));
} finally {
if (originalDirs === undefined) {
delete process.env.SPIRACHA_ANTIGRAVITY_DIRS;
} else {
process.env.SPIRACHA_ANTIGRAVITY_DIRS = originalDirs;
}
if (originalDir === undefined) {
delete process.env.SPIRACHA_ANTIGRAVITY_DIR;
} else {
process.env.SPIRACHA_ANTIGRAVITY_DIR = originalDir;
}
}
});

it('should preserve valid summary records around protobuf corruption with diagnostics', async () => {
const root = await makeRoot();
const summaryPath = path.join(root, 'agyhub_summaries_proto.pb');
const first = encodeSummaryIndex([{ id: '11111111-1111-4111-8111-111111111111', title: 'First summary' }]);
const second = encodeSummaryIndex([{ id: '22222222-2222-4222-8222-222222222222', title: 'Second summary' }]);
await Bun.write(summaryPath, new Uint8Array([...first, 0xff, ...second]));

const result = await readAntigravitySummaryIndexWithDiagnostics(summaryPath);

expect(result.entries.map((entry) => entry.title)).toEqual(['First summary', 'Second summary']);
expect(result.diagnostics).toEqual([
expect.objectContaining({ byteOffset: first.byteLength, kind: 'protobuf' }),
]);
});

it('should cap protobuf diagnostics while continuing to parse later records', async () => {
const root = await makeRoot();
const summaryPath = path.join(root, 'agyhub_summaries_proto.pb');
const valid = encodeSummaryIndex([{ id: '33333333-3333-4333-8333-333333333333', title: 'After noise' }]);
await Bun.write(summaryPath, new Uint8Array([...new Array(150).fill(0x0b), ...valid]));

const result = await readAntigravitySummaryIndexWithDiagnostics(summaryPath);

expect(result.entries.map((entry) => entry.title)).toEqual(['After noise']);
expect(result.diagnostics).toHaveLength(100);
});

it('should cap summary-record diagnostics without dropping later valid records', async () => {
const root = await makeRoot();
const summaryPath = path.join(root, 'agyhub_summaries_proto.pb');
const invalid = encodeMessage(1, [0x0b]);
const valid = encodeSummaryIndex([
{ id: '44444444-4444-4444-8444-444444444444', title: 'After invalid summaries' },
]);
await Bun.write(summaryPath, new Uint8Array([...new Array(150).fill(invalid).flat(), ...valid]));

const result = await readAntigravitySummaryIndexWithDiagnostics(summaryPath);

expect(result.entries.map((entry) => entry.title)).toEqual(['After invalid summaries']);
expect(result.diagnostics).toHaveLength(100);
});

it('should continue after an oversized protobuf record with one bounded diagnostic', async () => {
const root = await makeRoot();
const summaryPath = path.join(root, 'agyhub_summaries_proto.pb');
const oversizedLength = 8 * 1024 * 1024 + 1;
const oversized = [0x0a, ...encodeVarint(oversizedLength), ...new Array(oversizedLength).fill(0)];
const valid = encodeSummaryIndex([
{ id: '55555555-5555-4555-8555-555555555555', title: 'After oversized record' },
]);
await Bun.write(summaryPath, new Uint8Array([...oversized, ...valid]));

const result = await readAntigravitySummaryIndexWithDiagnostics(summaryPath);

expect(result.entries.map((entry) => entry.title)).toEqual(['After oversized record']);
expect(result.diagnostics).toEqual([
expect.objectContaining({
byteOffset: 0,
kind: 'protobuf',
message: expect.stringContaining('exceeds'),
}),
]);
});

it('should diagnose an oversized protobuf record truncated at EOF', async () => {
const root = await makeRoot();
const summaryPath = path.join(root, 'agyhub_summaries_proto.pb');
const oversizedLength = 8 * 1024 * 1024 + 1;
await Bun.write(summaryPath, new Uint8Array([0x0a, ...encodeVarint(oversizedLength), 0x01, 0x02, 0x03]));

const result = await readAntigravitySummaryIndexWithDiagnostics(summaryPath);

expect(result.entries).toEqual([]);
expect(result.diagnostics).toEqual([
expect.objectContaining({ byteOffset: 0, message: expect.stringContaining('exceeds') }),
expect.objectContaining({ byteOffset: 0, message: 'Truncated Antigravity protobuf input' }),
]);
});

it('should resolve one conversation without requiring a collection scan', async () => {
const root = await makeRoot();
const conversationId = '12111111-1111-4111-8111-111111111111';
Expand Down Expand Up @@ -995,6 +1097,21 @@ describe('antigravity db discovery', () => {
});
});

it('should derive a workspace from a trajectory workdir before falling back to the source root', async () => {
const root = await makeRoot();
const conversationId = '78787878-7878-4787-8787-787878787878';
await writeTrajectoryDatabase(root, conversationId);

const [conversation] = await listAntigravityConversations([root]);

expect(conversation).toMatchObject({
conversationId,
workspaceFolder: '/Users/example/workspace/ushman',
workspaceKey: 'folder:/Users/example/workspace/ushman',
workspaceLabel: 'ushman',
});
});

it('should not treat artifacts as conversation transcript markdown', async () => {
const root = await makeRoot();
const conversationId = '88888888-8888-4888-8888-888888888888';
Expand Down
Loading
Loading