convoptics 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +366 -0
- package/bin/convoptics.js +2 -0
- package/package.json +36 -0
- package/src/claude-code/exporter.js +201 -0
- package/src/claude-code/pricing.js +58 -0
- package/src/claude-code/scanner.js +99 -0
- package/src/cli.js +267 -0
- package/src/cursor/exporter.js +264 -0
- package/src/cursor/scanner.js +167 -0
- package/src/matcher.js +63 -0
- package/src/query.js +108 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import Database from 'better-sqlite3';
|
|
5
|
+
|
|
6
|
+
export function defaultCursorRoot() {
|
|
7
|
+
const home = os.homedir();
|
|
8
|
+
if (process.platform === 'darwin') {
|
|
9
|
+
return path.join(home, 'Library', 'Application Support', 'Cursor', 'User');
|
|
10
|
+
}
|
|
11
|
+
if (process.platform === 'win32') {
|
|
12
|
+
const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
|
|
13
|
+
return path.join(appData, 'Cursor', 'User');
|
|
14
|
+
}
|
|
15
|
+
return path.join(home, '.config', 'Cursor', 'User');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function fileUriToPath(uri) {
|
|
19
|
+
if (typeof uri !== 'string') return null;
|
|
20
|
+
if (!uri.startsWith('file://')) return uri;
|
|
21
|
+
try {
|
|
22
|
+
const url = new URL(uri);
|
|
23
|
+
let pathname = decodeURIComponent(url.pathname);
|
|
24
|
+
if (process.platform === 'win32' && /^\/[A-Za-z]:/.test(pathname)) {
|
|
25
|
+
pathname = pathname.slice(1);
|
|
26
|
+
}
|
|
27
|
+
return pathname;
|
|
28
|
+
} catch {
|
|
29
|
+
return uri;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isoFromMs(ms) {
|
|
34
|
+
if (typeof ms !== 'number' || !Number.isFinite(ms) || ms <= 0) return null;
|
|
35
|
+
try {
|
|
36
|
+
return new Date(ms).toISOString();
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function parseJsonValue(value) {
|
|
43
|
+
if (value === null || value === undefined) return null;
|
|
44
|
+
const text = Buffer.isBuffer(value) ? value.toString('utf8') : String(value);
|
|
45
|
+
try {
|
|
46
|
+
return JSON.parse(text);
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function buildRegistry(workspaceStorageDir) {
|
|
53
|
+
const registry = new Map();
|
|
54
|
+
let entries;
|
|
55
|
+
try {
|
|
56
|
+
entries = await fs.readdir(workspaceStorageDir, { withFileTypes: true });
|
|
57
|
+
} catch {
|
|
58
|
+
return registry;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
for (const entry of entries) {
|
|
62
|
+
if (!entry.isDirectory()) continue;
|
|
63
|
+
const wsPath = path.join(workspaceStorageDir, entry.name);
|
|
64
|
+
const wsJsonPath = path.join(wsPath, 'workspace.json');
|
|
65
|
+
const wsDbPath = path.join(wsPath, 'state.vscdb');
|
|
66
|
+
|
|
67
|
+
let folder = null;
|
|
68
|
+
try {
|
|
69
|
+
const raw = await fs.readFile(wsJsonPath, 'utf8');
|
|
70
|
+
const wsJson = JSON.parse(raw);
|
|
71
|
+
folder = fileUriToPath(wsJson.folder);
|
|
72
|
+
} catch {
|
|
73
|
+
// workspace.json missing or unreadable — leave folder null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let db;
|
|
77
|
+
try {
|
|
78
|
+
db = new Database(wsDbPath, { readonly: true, fileMustExist: true });
|
|
79
|
+
} catch {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
const row = db
|
|
85
|
+
.prepare('SELECT value FROM ItemTable WHERE key = ?')
|
|
86
|
+
.get('composer.composerData');
|
|
87
|
+
const data = parseJsonValue(row?.value);
|
|
88
|
+
if (!data || !Array.isArray(data.allComposers)) continue;
|
|
89
|
+
for (const c of data.allComposers) {
|
|
90
|
+
if (!c || !c.composerId) continue;
|
|
91
|
+
registry.set(c.composerId, {
|
|
92
|
+
cwd: folder,
|
|
93
|
+
branch: c.createdOnBranch ?? null,
|
|
94
|
+
name: c.name ?? null,
|
|
95
|
+
subtitle: c.subtitle ?? null,
|
|
96
|
+
isArchived: !!c.isArchived,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
} finally {
|
|
100
|
+
db.close();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return registry;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function* scanSessions(root) {
|
|
108
|
+
const globalDbPath = path.join(root, 'globalStorage', 'state.vscdb');
|
|
109
|
+
const wsDir = path.join(root, 'workspaceStorage');
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
await fs.access(globalDbPath);
|
|
113
|
+
} catch {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const registry = await buildRegistry(wsDir);
|
|
118
|
+
|
|
119
|
+
const gdb = new Database(globalDbPath, { readonly: true, fileMustExist: true });
|
|
120
|
+
try {
|
|
121
|
+
const composerStmt = gdb.prepare(
|
|
122
|
+
"SELECT key, value FROM cursorDiskKV WHERE key LIKE 'composerData:%'",
|
|
123
|
+
);
|
|
124
|
+
const countStmt = gdb.prepare(
|
|
125
|
+
'SELECT COUNT(*) AS n FROM cursorDiskKV WHERE key LIKE ?',
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
for (const row of composerStmt.iterate()) {
|
|
129
|
+
const composerId = row.key.slice('composerData:'.length);
|
|
130
|
+
if (!composerId) continue;
|
|
131
|
+
const header = parseJsonValue(row.value);
|
|
132
|
+
if (!header) continue;
|
|
133
|
+
|
|
134
|
+
const meta = registry.get(composerId) ?? {};
|
|
135
|
+
const { n: bubbleCount } = countStmt.get(`bubbleId:${composerId}:%`);
|
|
136
|
+
|
|
137
|
+
const conversationOrder = Array.isArray(header.conversation)
|
|
138
|
+
? header.conversation.map((c) => c?.bubbleId).filter(Boolean)
|
|
139
|
+
: [];
|
|
140
|
+
|
|
141
|
+
yield {
|
|
142
|
+
path: globalDbPath,
|
|
143
|
+
sessionId: composerId,
|
|
144
|
+
projectFolder: meta.cwd ? path.basename(meta.cwd) : null,
|
|
145
|
+
cwd: meta.cwd ?? null,
|
|
146
|
+
gitBranch: meta.branch ?? null,
|
|
147
|
+
version: null,
|
|
148
|
+
startedAt: isoFromMs(header.createdAt),
|
|
149
|
+
endedAt: isoFromMs(header.lastUpdatedAt),
|
|
150
|
+
summary: meta.name ?? header.name ?? null,
|
|
151
|
+
messageCount: bubbleCount ?? 0,
|
|
152
|
+
malformedCount: 0,
|
|
153
|
+
tokens: { input: 0, output: 0, cacheCreation: 0, cacheRead: 0 },
|
|
154
|
+
tokensByModel: {},
|
|
155
|
+
_cursor: {
|
|
156
|
+
dbPath: globalDbPath,
|
|
157
|
+
composerId,
|
|
158
|
+
isArchived: !!meta.isArchived,
|
|
159
|
+
subtitle: meta.subtitle ?? null,
|
|
160
|
+
conversationOrder,
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
} finally {
|
|
165
|
+
gdb.close();
|
|
166
|
+
}
|
|
167
|
+
}
|
package/src/matcher.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
function globToRegExp(pattern) {
|
|
2
|
+
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
|
|
3
|
+
const tokens = escaped.split('**').map((part) => part.replace(/\*/g, '[^/]*'));
|
|
4
|
+
return new RegExp(`^${tokens.join('.*')}$`, 'i');
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function matchesGlob(value, pattern) {
|
|
8
|
+
if (pattern === value) {
|
|
9
|
+
return true;
|
|
10
|
+
}
|
|
11
|
+
const regex = globToRegExp(pattern);
|
|
12
|
+
return regex.test(value);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function isoDatePart(value) {
|
|
16
|
+
if (!value || typeof value !== 'string') return null;
|
|
17
|
+
const date = value.slice(0, 10);
|
|
18
|
+
return date.match(/^\d{4}-\d{2}-\d{2}$/) ? date : null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function match(filter, session) {
|
|
22
|
+
if (filter.branch) {
|
|
23
|
+
if (!session.gitBranch) {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
const branchMatch = filter.branch.some((pattern) => matchesGlob(session.gitBranch, pattern));
|
|
27
|
+
if (!branchMatch) return false;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (filter.cwd) {
|
|
31
|
+
if (!session.cwd) return false;
|
|
32
|
+
const cwdLower = session.cwd.toLowerCase();
|
|
33
|
+
const anyCwd = filter.cwd.some((token) => cwdLower.includes(token.toLowerCase()));
|
|
34
|
+
if (!anyCwd) return false;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (filter.project) {
|
|
38
|
+
const anyProject = filter.project.some((token) => token === session.projectFolder);
|
|
39
|
+
if (!anyProject) return false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (filter.session) {
|
|
43
|
+
const anySession = filter.session.some((token) => session.sessionId.startsWith(token));
|
|
44
|
+
if (!anySession) return false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (filter.version) {
|
|
48
|
+
const anyVersion = filter.version.some((token) => token === session.version);
|
|
49
|
+
if (!anyVersion) return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (filter.date) {
|
|
53
|
+
const started = isoDatePart(session.startedAt);
|
|
54
|
+
if (!started) return false;
|
|
55
|
+
if (filter.date.eq && started !== filter.date.eq) return false;
|
|
56
|
+
if (filter.date.gte && started < filter.date.gte) return false;
|
|
57
|
+
if (filter.date.lte && started > filter.date.lte) return false;
|
|
58
|
+
if (filter.date.gt && started <= filter.date.gt) return false;
|
|
59
|
+
if (filter.date.lt && started >= filter.date.lt) return false;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return true;
|
|
63
|
+
}
|
package/src/query.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
const VALID_KEYS = new Set(['tool', 'branch', 'cwd', 'project', 'session', 'version', 'date', 'diffs']);
|
|
2
|
+
const VALID_TOOLS = new Set(['claude-code', 'cursor']);
|
|
3
|
+
const TOOL_UNSUPPORTED_KEYS = {
|
|
4
|
+
cursor: new Set(['version']),
|
|
5
|
+
};
|
|
6
|
+
const DATE_OPERATORS = new Set([':', '=', '>=', '<=', '>', '<']);
|
|
7
|
+
const DIFFS_VALUES = new Set(['diffs', 'no-diffs']);
|
|
8
|
+
|
|
9
|
+
export function parseQuery(argv) {
|
|
10
|
+
const spec = {
|
|
11
|
+
tool: null,
|
|
12
|
+
branch: [],
|
|
13
|
+
cwd: [],
|
|
14
|
+
project: [],
|
|
15
|
+
session: [],
|
|
16
|
+
version: [],
|
|
17
|
+
date: {},
|
|
18
|
+
diffs: 'diffs',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
if (!Array.isArray(argv)) {
|
|
22
|
+
throw new Error('parseQuery expects an array of arguments');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
for (const token of argv) {
|
|
26
|
+
if (!token || typeof token !== 'string') {
|
|
27
|
+
throw new Error(`Invalid query token: ${token}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const match = token.match(/^(\w+)(>=|<=|:|=|>|<)(.+)$/);
|
|
31
|
+
if (!match) {
|
|
32
|
+
throw new Error(`Invalid query token: ${token}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const [, key, op, rawValue] = match;
|
|
36
|
+
if (!VALID_KEYS.has(key)) {
|
|
37
|
+
throw new Error(`Unknown query key: ${key}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const value = rawValue.trim();
|
|
41
|
+
if (!value) {
|
|
42
|
+
throw new Error(`Empty value for query token: ${token}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (key === 'date') {
|
|
46
|
+
if (!DATE_OPERATORS.has(op)) {
|
|
47
|
+
throw new Error(`Invalid date operator in token: ${token}`);
|
|
48
|
+
}
|
|
49
|
+
const normalized = op === ':' ? 'eq' : op === '=' ? 'eq' : op === '>=' ? 'gte' : op === '<=' ? 'lte' : op === '>' ? 'gt' : 'lt';
|
|
50
|
+
spec.date[normalized] = value;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (op !== ':' && op !== '=') {
|
|
55
|
+
throw new Error(`Operator ${op} not supported for ${key} in token: ${token}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
switch (key) {
|
|
59
|
+
case 'tool':
|
|
60
|
+
spec.tool = value;
|
|
61
|
+
break;
|
|
62
|
+
case 'diffs':
|
|
63
|
+
if (!DIFFS_VALUES.has(value)) {
|
|
64
|
+
throw new Error(`Invalid value for diffs (expected "diffs" or "no-diffs"): ${value}`);
|
|
65
|
+
}
|
|
66
|
+
spec.diffs = value;
|
|
67
|
+
break;
|
|
68
|
+
case 'branch':
|
|
69
|
+
case 'cwd':
|
|
70
|
+
case 'project':
|
|
71
|
+
case 'session':
|
|
72
|
+
case 'version':
|
|
73
|
+
spec[key].push(value);
|
|
74
|
+
break;
|
|
75
|
+
default:
|
|
76
|
+
throw new Error(`Unsupported query key: ${key}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!spec.tool) {
|
|
81
|
+
throw new Error('Missing required filter: tool');
|
|
82
|
+
}
|
|
83
|
+
if (!VALID_TOOLS.has(spec.tool)) {
|
|
84
|
+
throw new Error(`Unknown tool: ${spec.tool} (expected one of ${[...VALID_TOOLS].join(', ')})`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const unsupported = TOOL_UNSUPPORTED_KEYS[spec.tool];
|
|
88
|
+
if (unsupported) {
|
|
89
|
+
for (const key of unsupported) {
|
|
90
|
+
if (spec[key] && spec[key].length > 0) {
|
|
91
|
+
throw new Error(`Filter "${key}:" is not supported for tool:${spec.tool}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Normalize empty arrays to undefined for easier matcher handling.
|
|
97
|
+
for (const key of ['branch', 'cwd', 'project', 'session', 'version']) {
|
|
98
|
+
if (spec[key].length === 0) {
|
|
99
|
+
delete spec[key];
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (Object.keys(spec.date).length === 0) {
|
|
104
|
+
delete spec.date;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return spec;
|
|
108
|
+
}
|