@meterbility/cursor-adapter 0.3.1
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 +33 -0
- package/README.md +11 -0
- package/dist/chunk-755T3Z3H.js +88 -0
- package/dist/discover-OXMJMPOW.js +12 -0
- package/dist/index.d.ts +193 -0
- package/dist/index.js +431 -0
- package/package.json +52 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Meterbility authors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
23
|
+
----------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
Note: This MIT license covers everything in this repository EXCEPT the
|
|
26
|
+
contents of the /ee directory (when present), which are licensed under the
|
|
27
|
+
Elastic License 2.0 (ELv2) — see /ee/LICENSE.
|
|
28
|
+
|
|
29
|
+
The /ee directory is reserved for Enterprise Edition modules: multi-tenant
|
|
30
|
+
fleet orchestration, SSO/RBAC, audit logs, and long-retention features.
|
|
31
|
+
|
|
32
|
+
Nothing under /ee today; the directory is created as a forward-compatible
|
|
33
|
+
marker so the licensing boundary is visible before any /ee code lands.
|
package/README.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# @meterbility/cursor-adapter
|
|
2
|
+
|
|
3
|
+
Part of [Meterbility](https://github.com/HoneycombHairDevelopers/Meterbility) — the debugger for AI agents. Capture every run, inspect every decision, pause and inject live, fork from any step.
|
|
4
|
+
|
|
5
|
+
Ingests Cursor composer conversations from Cursor's global storage database.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @meterbility/cursor-adapter
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
See the [Meterbility documentation](https://github.com/HoneycombHairDevelopers/Meterbility#readme) for the full guide. MIT licensed.
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// src/discover.ts
|
|
2
|
+
import { readdir, stat } from "fs/promises";
|
|
3
|
+
import { existsSync } from "fs";
|
|
4
|
+
import { homedir, platform } from "os";
|
|
5
|
+
import { join } from "path";
|
|
6
|
+
function cursorPaths() {
|
|
7
|
+
if (process.env.CURSOR_USER_DIR) {
|
|
8
|
+
return {
|
|
9
|
+
globalStorage: join(process.env.CURSOR_USER_DIR, "globalStorage"),
|
|
10
|
+
workspaceStorage: join(process.env.CURSOR_USER_DIR, "workspaceStorage"),
|
|
11
|
+
globalDb: join(process.env.CURSOR_USER_DIR, "globalStorage", "state.vscdb")
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
const home = homedir();
|
|
15
|
+
let userDir;
|
|
16
|
+
switch (platform()) {
|
|
17
|
+
case "darwin":
|
|
18
|
+
userDir = join(home, "Library", "Application Support", "Cursor", "User");
|
|
19
|
+
break;
|
|
20
|
+
case "win32":
|
|
21
|
+
userDir = join(
|
|
22
|
+
process.env.APPDATA ?? join(home, "AppData", "Roaming"),
|
|
23
|
+
"Cursor",
|
|
24
|
+
"User"
|
|
25
|
+
);
|
|
26
|
+
break;
|
|
27
|
+
default:
|
|
28
|
+
userDir = join(home, ".config", "Cursor", "User");
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
globalStorage: join(userDir, "globalStorage"),
|
|
32
|
+
workspaceStorage: join(userDir, "workspaceStorage"),
|
|
33
|
+
globalDb: join(userDir, "globalStorage", "state.vscdb")
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
async function discoverCursorWorkspaces() {
|
|
37
|
+
const root = cursorPaths().workspaceStorage;
|
|
38
|
+
const out = [];
|
|
39
|
+
let entries;
|
|
40
|
+
try {
|
|
41
|
+
entries = await readdir(root);
|
|
42
|
+
} catch {
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
for (const e of entries) {
|
|
46
|
+
const full = join(root, e);
|
|
47
|
+
let s;
|
|
48
|
+
try {
|
|
49
|
+
s = await stat(full);
|
|
50
|
+
} catch {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (!s.isDirectory()) continue;
|
|
54
|
+
const wsJson = join(full, "workspace.json");
|
|
55
|
+
out.push({
|
|
56
|
+
workspace_id: e,
|
|
57
|
+
path: full,
|
|
58
|
+
size_bytes: s.size,
|
|
59
|
+
mtime: s.mtime,
|
|
60
|
+
workspace_json: existsSync(wsJson) ? wsJson : void 0
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
out.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
async function readWorkspaceCwd(ws) {
|
|
67
|
+
if (!ws.workspace_json) return void 0;
|
|
68
|
+
try {
|
|
69
|
+
const { readFile } = await import("fs/promises");
|
|
70
|
+
const buf = await readFile(ws.workspace_json, "utf-8");
|
|
71
|
+
const obj = JSON.parse(buf);
|
|
72
|
+
if (typeof obj.folder === "string") {
|
|
73
|
+
return obj.folder.replace(/^file:\/\//, "");
|
|
74
|
+
}
|
|
75
|
+
} catch {
|
|
76
|
+
}
|
|
77
|
+
return void 0;
|
|
78
|
+
}
|
|
79
|
+
function defaultGlobalDbPath() {
|
|
80
|
+
return cursorPaths().globalDb;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export {
|
|
84
|
+
cursorPaths,
|
|
85
|
+
discoverCursorWorkspaces,
|
|
86
|
+
readWorkspaceCwd,
|
|
87
|
+
defaultGlobalDbPath
|
|
88
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { Store } from '@meterbility/collector';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Cursor's chat content lives in **global** storage, not workspace
|
|
5
|
+
* storage:
|
|
6
|
+
*
|
|
7
|
+
* macOS: ~/Library/Application Support/Cursor/User/globalStorage/state.vscdb
|
|
8
|
+
* Linux: ~/.config/Cursor/User/globalStorage/state.vscdb
|
|
9
|
+
* Windows: %APPDATA%\Cursor\User\globalStorage\state.vscdb
|
|
10
|
+
*
|
|
11
|
+
* Workspace storage holds layout state but not bubbles.
|
|
12
|
+
*/
|
|
13
|
+
interface CursorPaths {
|
|
14
|
+
globalStorage: string;
|
|
15
|
+
workspaceStorage: string;
|
|
16
|
+
globalDb: string;
|
|
17
|
+
}
|
|
18
|
+
declare function cursorPaths(): CursorPaths;
|
|
19
|
+
interface DiscoveredCursorWorkspace {
|
|
20
|
+
workspace_id: string;
|
|
21
|
+
path: string;
|
|
22
|
+
size_bytes: number;
|
|
23
|
+
mtime: Date;
|
|
24
|
+
/** Path to its workspace.json which often contains the original folder URI. */
|
|
25
|
+
workspace_json?: string;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Walk workspaceStorage to map workspace_ids back to source directories
|
|
29
|
+
* (so we can attribute composers to projects). Doesn't itself contain
|
|
30
|
+
* chat bubbles.
|
|
31
|
+
*/
|
|
32
|
+
declare function discoverCursorWorkspaces(): Promise<DiscoveredCursorWorkspace[]>;
|
|
33
|
+
/** Extract the cwd of a workspace from its workspace.json (best-effort). */
|
|
34
|
+
declare function readWorkspaceCwd(ws: DiscoveredCursorWorkspace): Promise<string | undefined>;
|
|
35
|
+
declare function defaultGlobalDbPath(): string;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Cursor's cursorDiskKV schema (reverse-engineered from the global
|
|
39
|
+
* `state.vscdb` shipped with Cursor 2.x). Three key prefixes carry the
|
|
40
|
+
* conversation surface:
|
|
41
|
+
*
|
|
42
|
+
* composerData:<composerId> — conversation envelope + index
|
|
43
|
+
* bubbleId:<composerId>:<bubbleId> — one message (user or assistant)
|
|
44
|
+
* messageRequestContext:<composerId>:<requestId> — provider-side request bytes
|
|
45
|
+
*
|
|
46
|
+
* Cursor changes these schemas without notice. The adapter probes for the
|
|
47
|
+
* keys it knows and degrades gracefully when fields are missing.
|
|
48
|
+
*/
|
|
49
|
+
/** type=1 = user, type=2 = assistant. */
|
|
50
|
+
type CursorBubbleType = 1 | 2;
|
|
51
|
+
interface CursorConversationHeader {
|
|
52
|
+
bubbleId: string;
|
|
53
|
+
type: CursorBubbleType;
|
|
54
|
+
serverBubbleId?: string;
|
|
55
|
+
}
|
|
56
|
+
interface CursorComposerData {
|
|
57
|
+
_v?: number;
|
|
58
|
+
composerId: string;
|
|
59
|
+
name?: string;
|
|
60
|
+
subtitle?: string;
|
|
61
|
+
unifiedMode?: number | string;
|
|
62
|
+
forceMode?: string;
|
|
63
|
+
status?: string;
|
|
64
|
+
text?: string;
|
|
65
|
+
richText?: string;
|
|
66
|
+
fullConversationHeadersOnly?: CursorConversationHeader[];
|
|
67
|
+
conversationMap?: Record<string, unknown>;
|
|
68
|
+
context?: Record<string, unknown>;
|
|
69
|
+
capabilities?: unknown[];
|
|
70
|
+
modelConfig?: Record<string, unknown>;
|
|
71
|
+
usageData?: Record<string, unknown>;
|
|
72
|
+
contextUsagePercent?: number;
|
|
73
|
+
totalLinesAdded?: number;
|
|
74
|
+
totalLinesRemoved?: number;
|
|
75
|
+
createdAt?: number;
|
|
76
|
+
lastUpdatedAt?: number;
|
|
77
|
+
isArchived?: boolean;
|
|
78
|
+
hasUnreadMessages?: boolean;
|
|
79
|
+
}
|
|
80
|
+
interface CursorTokenCount {
|
|
81
|
+
inputTokens?: number;
|
|
82
|
+
outputTokens?: number;
|
|
83
|
+
}
|
|
84
|
+
interface CursorToolFormerData {
|
|
85
|
+
/** Cursor encodes the tool kind both as a numeric `tool` index and a
|
|
86
|
+
* human-readable `name`. We rely on `name`. */
|
|
87
|
+
tool?: number;
|
|
88
|
+
name?: string;
|
|
89
|
+
toolCallId?: string;
|
|
90
|
+
modelCallId?: string;
|
|
91
|
+
status?: "completed" | "errored" | "pending" | string;
|
|
92
|
+
/** JSON-encoded args as the model emitted them. */
|
|
93
|
+
rawArgs?: string;
|
|
94
|
+
/** Parsed/effective args after Cursor's normalization. */
|
|
95
|
+
params?: string;
|
|
96
|
+
/** Tool result body (often a stringified blob). */
|
|
97
|
+
result?: unknown;
|
|
98
|
+
additionalData?: unknown;
|
|
99
|
+
}
|
|
100
|
+
interface CursorThinkingBlock {
|
|
101
|
+
text?: string;
|
|
102
|
+
signature?: string;
|
|
103
|
+
redacted?: boolean;
|
|
104
|
+
}
|
|
105
|
+
interface CursorBubble {
|
|
106
|
+
_v?: number;
|
|
107
|
+
bubbleId: string;
|
|
108
|
+
type: CursorBubbleType;
|
|
109
|
+
text?: string;
|
|
110
|
+
richText?: string;
|
|
111
|
+
createdAt?: string;
|
|
112
|
+
requestId?: string;
|
|
113
|
+
tokenCount?: CursorTokenCount;
|
|
114
|
+
toolFormerData?: CursorToolFormerData;
|
|
115
|
+
allThinkingBlocks?: CursorThinkingBlock[];
|
|
116
|
+
toolResults?: unknown[];
|
|
117
|
+
codeBlocks?: unknown[];
|
|
118
|
+
attachedCodeChunks?: unknown[];
|
|
119
|
+
context?: Record<string, unknown>;
|
|
120
|
+
capabilityType?: number;
|
|
121
|
+
}
|
|
122
|
+
declare function isUserBubble(b: CursorBubble): boolean;
|
|
123
|
+
declare function isAssistantBubble(b: CursorBubble): boolean;
|
|
124
|
+
declare function bubbleText(b: CursorBubble): string;
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Read-only SQLite client for Cursor's `state.vscdb`. We always open
|
|
128
|
+
* with `readonly: true` and `fileMustExist: true` so we cannot
|
|
129
|
+
* accidentally mutate Cursor's state.
|
|
130
|
+
*/
|
|
131
|
+
declare class CursorDb {
|
|
132
|
+
private db;
|
|
133
|
+
constructor(path: string);
|
|
134
|
+
close(): void;
|
|
135
|
+
/**
|
|
136
|
+
* Fetch every composerId we can see in cursorDiskKV. Returns the
|
|
137
|
+
* envelopes (without bubble bodies) for cheap iteration.
|
|
138
|
+
*/
|
|
139
|
+
listComposers(): CursorComposerData[];
|
|
140
|
+
getComposer(composerId: string): CursorComposerData | undefined;
|
|
141
|
+
getBubble(composerId: string, bubbleId: string): CursorBubble | undefined;
|
|
142
|
+
/**
|
|
143
|
+
* Walk the conversation in canonical order, returning bubbles in the
|
|
144
|
+
* order Cursor records in `fullConversationHeadersOnly`. Missing
|
|
145
|
+
* bubbles (deleted, or written-but-not-flushed) are skipped.
|
|
146
|
+
*/
|
|
147
|
+
iterBubbles(composer: CursorComposerData): Iterable<CursorBubble>;
|
|
148
|
+
/** Schema sanity check — used by doctor and tests. */
|
|
149
|
+
hasCursorDiskKV(): boolean;
|
|
150
|
+
/** Total number of cursorDiskKV rows by prefix; useful for diagnostics. */
|
|
151
|
+
prefixCounts(): Record<string, number>;
|
|
152
|
+
}
|
|
153
|
+
declare function headerCount(c: CursorComposerData): number;
|
|
154
|
+
declare function isMeaningfulComposer(c: CursorComposerData): boolean;
|
|
155
|
+
declare function _conversationHeader(c: CursorComposerData, bubbleId: string): CursorConversationHeader | undefined;
|
|
156
|
+
|
|
157
|
+
interface CursorIngestResult {
|
|
158
|
+
workspace_id?: string;
|
|
159
|
+
composers_seen: number;
|
|
160
|
+
composers_ingested: number;
|
|
161
|
+
steps_added: number;
|
|
162
|
+
status: "ok" | "no_db" | "no_composers";
|
|
163
|
+
reason?: string;
|
|
164
|
+
}
|
|
165
|
+
interface IngestCursorOptions {
|
|
166
|
+
/** Override the path to Cursor's global state.vscdb. */
|
|
167
|
+
dbPath?: string;
|
|
168
|
+
/** Restrict to one composer id. */
|
|
169
|
+
composerId?: string;
|
|
170
|
+
/** Skip composers older than this (epoch ms). */
|
|
171
|
+
sinceMs?: number;
|
|
172
|
+
/** Only ingest the N newest composers. */
|
|
173
|
+
limit?: number;
|
|
174
|
+
/** Project cwd to attribute the run to (defaults to "(cursor)"). */
|
|
175
|
+
cwd?: string;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Ingest Cursor composer conversations from the global state.vscdb.
|
|
179
|
+
*
|
|
180
|
+
* Each composer becomes one Meterbility Run. Each bubble becomes one Step:
|
|
181
|
+
* - user bubbles (type=1) become "message" steps with the user text
|
|
182
|
+
* as the action.
|
|
183
|
+
* - assistant bubbles (type=2) become either tool_call steps (when
|
|
184
|
+
* `toolFormerData` is present) or message steps (when `text` is
|
|
185
|
+
* non-empty).
|
|
186
|
+
*
|
|
187
|
+
* Cursor doesn't expose system prompts in the SQLite (they're injected
|
|
188
|
+
* server-side), and per-step token usage is sparse. We capture what we
|
|
189
|
+
* can and tag steps with `cost:approx` where cost is a wild estimate.
|
|
190
|
+
*/
|
|
191
|
+
declare function ingestCursorGlobal(store: Store, opts?: IngestCursorOptions): Promise<CursorIngestResult>;
|
|
192
|
+
|
|
193
|
+
export { type CursorBubble, type CursorBubbleType, type CursorComposerData, type CursorConversationHeader, CursorDb, type CursorIngestResult, type CursorPaths, type CursorThinkingBlock, type CursorTokenCount, type CursorToolFormerData, type DiscoveredCursorWorkspace, type IngestCursorOptions, _conversationHeader, bubbleText, cursorPaths, defaultGlobalDbPath, discoverCursorWorkspaces, headerCount, ingestCursorGlobal, isAssistantBubble, isMeaningfulComposer, isUserBubble, readWorkspaceCwd };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
import {
|
|
2
|
+
cursorPaths,
|
|
3
|
+
defaultGlobalDbPath,
|
|
4
|
+
discoverCursorWorkspaces,
|
|
5
|
+
readWorkspaceCwd
|
|
6
|
+
} from "./chunk-755T3Z3H.js";
|
|
7
|
+
|
|
8
|
+
// src/types.ts
|
|
9
|
+
function isUserBubble(b) {
|
|
10
|
+
return b.type === 1;
|
|
11
|
+
}
|
|
12
|
+
function isAssistantBubble(b) {
|
|
13
|
+
return b.type === 2;
|
|
14
|
+
}
|
|
15
|
+
function bubbleText(b) {
|
|
16
|
+
if (b.text && b.text.length > 0) return b.text;
|
|
17
|
+
if (b.richText) {
|
|
18
|
+
try {
|
|
19
|
+
return extractLexicalText(JSON.parse(b.richText));
|
|
20
|
+
} catch {
|
|
21
|
+
return "";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return "";
|
|
25
|
+
}
|
|
26
|
+
function extractLexicalText(root) {
|
|
27
|
+
const out = [];
|
|
28
|
+
const walk = (n) => {
|
|
29
|
+
if (!n || typeof n !== "object") return;
|
|
30
|
+
const node = n;
|
|
31
|
+
if (node.root) walk(node.root);
|
|
32
|
+
if (typeof node.text === "string") out.push(node.text);
|
|
33
|
+
if (node.children) for (const c of node.children) walk(c);
|
|
34
|
+
};
|
|
35
|
+
walk(root);
|
|
36
|
+
return out.join("");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// src/parser.ts
|
|
40
|
+
import Database from "better-sqlite3";
|
|
41
|
+
var CursorDb = class {
|
|
42
|
+
db;
|
|
43
|
+
constructor(path) {
|
|
44
|
+
this.db = new Database(path, { readonly: true, fileMustExist: true });
|
|
45
|
+
}
|
|
46
|
+
close() {
|
|
47
|
+
this.db.close();
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Fetch every composerId we can see in cursorDiskKV. Returns the
|
|
51
|
+
* envelopes (without bubble bodies) for cheap iteration.
|
|
52
|
+
*/
|
|
53
|
+
listComposers() {
|
|
54
|
+
const rows = this.db.prepare(
|
|
55
|
+
"SELECT key, value FROM cursorDiskKV WHERE key LIKE 'composerData:%'"
|
|
56
|
+
).all();
|
|
57
|
+
const out = [];
|
|
58
|
+
for (const row of rows) {
|
|
59
|
+
try {
|
|
60
|
+
const parsed = JSON.parse(row.value);
|
|
61
|
+
if (parsed && typeof parsed === "object" && typeof parsed.composerId === "string") {
|
|
62
|
+
out.push(parsed);
|
|
63
|
+
}
|
|
64
|
+
} catch {
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
getComposer(composerId) {
|
|
70
|
+
const row = this.db.prepare("SELECT value FROM cursorDiskKV WHERE key = ?").get(`composerData:${composerId}`);
|
|
71
|
+
if (!row) return void 0;
|
|
72
|
+
try {
|
|
73
|
+
return JSON.parse(row.value);
|
|
74
|
+
} catch {
|
|
75
|
+
return void 0;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
getBubble(composerId, bubbleId) {
|
|
79
|
+
const row = this.db.prepare("SELECT value FROM cursorDiskKV WHERE key = ?").get(`bubbleId:${composerId}:${bubbleId}`);
|
|
80
|
+
if (!row) return void 0;
|
|
81
|
+
try {
|
|
82
|
+
return JSON.parse(row.value);
|
|
83
|
+
} catch {
|
|
84
|
+
return void 0;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Walk the conversation in canonical order, returning bubbles in the
|
|
89
|
+
* order Cursor records in `fullConversationHeadersOnly`. Missing
|
|
90
|
+
* bubbles (deleted, or written-but-not-flushed) are skipped.
|
|
91
|
+
*/
|
|
92
|
+
*iterBubbles(composer) {
|
|
93
|
+
const headers = composer.fullConversationHeadersOnly ?? [];
|
|
94
|
+
for (const h of headers) {
|
|
95
|
+
const b = this.getBubble(composer.composerId, h.bubbleId);
|
|
96
|
+
if (b) yield b;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** Schema sanity check — used by doctor and tests. */
|
|
100
|
+
hasCursorDiskKV() {
|
|
101
|
+
const r = this.db.prepare(
|
|
102
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name='cursorDiskKV'"
|
|
103
|
+
).get();
|
|
104
|
+
return r !== void 0;
|
|
105
|
+
}
|
|
106
|
+
/** Total number of cursorDiskKV rows by prefix; useful for diagnostics. */
|
|
107
|
+
prefixCounts() {
|
|
108
|
+
const rows = this.db.prepare(
|
|
109
|
+
"SELECT substr(key, 1, instr(key, ':') - 1) AS prefix, COUNT(*) AS n FROM cursorDiskKV GROUP BY prefix"
|
|
110
|
+
).all();
|
|
111
|
+
const out = {};
|
|
112
|
+
for (const r of rows) out[r.prefix] = r.n;
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
function headerCount(c) {
|
|
117
|
+
return (c.fullConversationHeadersOnly ?? []).length;
|
|
118
|
+
}
|
|
119
|
+
function isMeaningfulComposer(c) {
|
|
120
|
+
if (!c || typeof c !== "object") return false;
|
|
121
|
+
if (typeof c.composerId !== "string") return false;
|
|
122
|
+
return headerCount(c) > 0 && (c.text !== void 0 || c.name !== void 0);
|
|
123
|
+
}
|
|
124
|
+
function _conversationHeader(c, bubbleId) {
|
|
125
|
+
return (c.fullConversationHeadersOnly ?? []).find(
|
|
126
|
+
(h) => h.bubbleId === bubbleId
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// src/ingest.ts
|
|
131
|
+
import { randomUUID } from "crypto";
|
|
132
|
+
import { hashJson } from "@meterbility/shared";
|
|
133
|
+
import {
|
|
134
|
+
getRunBySessionId,
|
|
135
|
+
insertRun,
|
|
136
|
+
insertStep,
|
|
137
|
+
recordContextSnapshot,
|
|
138
|
+
setRunStatus,
|
|
139
|
+
updateRunTotals,
|
|
140
|
+
upsertAgent,
|
|
141
|
+
upsertProjectByCwd
|
|
142
|
+
} from "@meterbility/collector";
|
|
143
|
+
var SOURCE_RUNTIME = "cursor";
|
|
144
|
+
async function ingestCursorGlobal(store, opts = {}) {
|
|
145
|
+
const { dbPath } = await import("./discover-OXMJMPOW.js").then((m) => ({
|
|
146
|
+
dbPath: opts.dbPath ?? m.defaultGlobalDbPath()
|
|
147
|
+
}));
|
|
148
|
+
let cursor;
|
|
149
|
+
try {
|
|
150
|
+
cursor = new CursorDb(dbPath);
|
|
151
|
+
} catch (err) {
|
|
152
|
+
return {
|
|
153
|
+
composers_seen: 0,
|
|
154
|
+
composers_ingested: 0,
|
|
155
|
+
steps_added: 0,
|
|
156
|
+
status: "no_db",
|
|
157
|
+
reason: `cannot open ${dbPath}: ${err.message}`
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
if (!cursor.hasCursorDiskKV()) {
|
|
162
|
+
return {
|
|
163
|
+
composers_seen: 0,
|
|
164
|
+
composers_ingested: 0,
|
|
165
|
+
steps_added: 0,
|
|
166
|
+
status: "no_db",
|
|
167
|
+
reason: "cursorDiskKV table missing \u2014 schema may have changed"
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
let composers = cursor.listComposers().filter(isMeaningfulComposer);
|
|
171
|
+
if (opts.composerId) {
|
|
172
|
+
composers = composers.filter((c) => c.composerId === opts.composerId);
|
|
173
|
+
}
|
|
174
|
+
if (opts.sinceMs !== void 0) {
|
|
175
|
+
composers = composers.filter(
|
|
176
|
+
(c) => (c.lastUpdatedAt ?? c.createdAt ?? 0) >= opts.sinceMs
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
composers.sort(
|
|
180
|
+
(a, b) => (b.lastUpdatedAt ?? b.createdAt ?? 0) - (a.lastUpdatedAt ?? a.createdAt ?? 0)
|
|
181
|
+
);
|
|
182
|
+
if (opts.limit) composers = composers.slice(0, opts.limit);
|
|
183
|
+
if (composers.length === 0) {
|
|
184
|
+
return {
|
|
185
|
+
composers_seen: 0,
|
|
186
|
+
composers_ingested: 0,
|
|
187
|
+
steps_added: 0,
|
|
188
|
+
status: "no_composers",
|
|
189
|
+
reason: "no Cursor composers matched the filter"
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
const cwd = opts.cwd ?? "(cursor)";
|
|
193
|
+
const project = upsertProjectByCwd(store, cwd, "cursor");
|
|
194
|
+
const agent = upsertAgent(store, project.project_id, "cursor");
|
|
195
|
+
let composersIngested = 0;
|
|
196
|
+
let stepsAdded = 0;
|
|
197
|
+
for (const comp of composers) {
|
|
198
|
+
const result = await ingestOneComposer(store, cursor, comp, {
|
|
199
|
+
projectId: project.project_id,
|
|
200
|
+
agentId: agent.agent_id,
|
|
201
|
+
cwd
|
|
202
|
+
});
|
|
203
|
+
composersIngested += 1;
|
|
204
|
+
stepsAdded += result.steps_added;
|
|
205
|
+
}
|
|
206
|
+
return {
|
|
207
|
+
composers_seen: composers.length,
|
|
208
|
+
composers_ingested: composersIngested,
|
|
209
|
+
steps_added: stepsAdded,
|
|
210
|
+
status: "ok"
|
|
211
|
+
};
|
|
212
|
+
} finally {
|
|
213
|
+
cursor.close();
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
async function ingestOneComposer(store, cursor, comp, args) {
|
|
217
|
+
const sessionId = comp.composerId;
|
|
218
|
+
const existing = getRunBySessionId(store, sessionId);
|
|
219
|
+
let runId;
|
|
220
|
+
if (existing) {
|
|
221
|
+
runId = existing.run_id;
|
|
222
|
+
} else {
|
|
223
|
+
runId = `run_${randomUUID()}`;
|
|
224
|
+
const run = {
|
|
225
|
+
run_id: runId,
|
|
226
|
+
agent_id: args.agentId,
|
|
227
|
+
project_id: args.projectId,
|
|
228
|
+
source_session_id: sessionId,
|
|
229
|
+
source_runtime: SOURCE_RUNTIME,
|
|
230
|
+
title: comp.name ?? comp.subtitle,
|
|
231
|
+
status: composerStatus(comp),
|
|
232
|
+
started_at: epochMsToIso(comp.createdAt) ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
233
|
+
ended_at: epochMsToIso(comp.lastUpdatedAt),
|
|
234
|
+
git_branch: void 0,
|
|
235
|
+
cwd: args.cwd,
|
|
236
|
+
tokens_total_input: 0,
|
|
237
|
+
tokens_total_output: 0,
|
|
238
|
+
tokens_total_cached: 0,
|
|
239
|
+
cost_cents: 0,
|
|
240
|
+
step_count: 0,
|
|
241
|
+
tags: ["cost:approx", "cursor", `mode:${comp.unifiedMode ?? "?"}`]
|
|
242
|
+
};
|
|
243
|
+
insertRun(store, run);
|
|
244
|
+
}
|
|
245
|
+
const history = [];
|
|
246
|
+
let sequence = existing ? existing.step_count ?? 0 : 0;
|
|
247
|
+
let prevStepId;
|
|
248
|
+
let stepsAdded = 0;
|
|
249
|
+
for (const bubble of cursor.iterBubbles(comp)) {
|
|
250
|
+
const text = bubbleText(bubble);
|
|
251
|
+
if (isUserBubble(bubble)) {
|
|
252
|
+
const ref = await store.blobs.putString(text);
|
|
253
|
+
history.push({ role: "user", content_ref: ref });
|
|
254
|
+
const components = await snapshotComponents(history);
|
|
255
|
+
const snapshot = {
|
|
256
|
+
id: hashJson(components),
|
|
257
|
+
components
|
|
258
|
+
};
|
|
259
|
+
const blobRef = await store.blobs.putJson(snapshot);
|
|
260
|
+
recordContextSnapshot(store, snapshot.id, blobRef, snapshot.components.length);
|
|
261
|
+
const decisionRef = await store.blobs.putString(text);
|
|
262
|
+
const stepId = `stp_${randomUUID()}`;
|
|
263
|
+
const step = {
|
|
264
|
+
step_id: stepId,
|
|
265
|
+
run_id: runId,
|
|
266
|
+
parent_step_id: prevStepId,
|
|
267
|
+
sequence,
|
|
268
|
+
timestamp: bubble.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
269
|
+
model: "user",
|
|
270
|
+
context_snapshot_id: snapshot.id,
|
|
271
|
+
decision_ref: decisionRef,
|
|
272
|
+
action: { kind: "message", text },
|
|
273
|
+
outcome: { status: "ok" },
|
|
274
|
+
tokens: { input: 0, output: 0, cached_read: 0, cache_creation: 0 },
|
|
275
|
+
latency_ms: 0,
|
|
276
|
+
cost_cents: 0,
|
|
277
|
+
tags: ["cursor", "user-turn"],
|
|
278
|
+
status: "ok"
|
|
279
|
+
};
|
|
280
|
+
insertStep(store, step);
|
|
281
|
+
prevStepId = stepId;
|
|
282
|
+
sequence += 1;
|
|
283
|
+
stepsAdded += 1;
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
if (isAssistantBubble(bubble)) {
|
|
287
|
+
const components = await snapshotComponents(history);
|
|
288
|
+
const snapshot = {
|
|
289
|
+
id: hashJson(components),
|
|
290
|
+
components
|
|
291
|
+
};
|
|
292
|
+
const blobRef = await store.blobs.putJson(snapshot);
|
|
293
|
+
recordContextSnapshot(store, snapshot.id, blobRef, snapshot.components.length);
|
|
294
|
+
const action = bubbleToAction(bubble, text);
|
|
295
|
+
const outcome = await bubbleToOutcome(store, bubble);
|
|
296
|
+
const decisionRef = await store.blobs.putJson({
|
|
297
|
+
text,
|
|
298
|
+
thinking: bubble.allThinkingBlocks,
|
|
299
|
+
codeBlocks: bubble.codeBlocks,
|
|
300
|
+
toolFormer: bubble.toolFormerData
|
|
301
|
+
});
|
|
302
|
+
const tokens = {
|
|
303
|
+
input: bubble.tokenCount?.inputTokens ?? 0,
|
|
304
|
+
output: bubble.tokenCount?.outputTokens ?? 0,
|
|
305
|
+
cached_read: 0,
|
|
306
|
+
cache_creation: 0
|
|
307
|
+
};
|
|
308
|
+
const stepId = `stp_${randomUUID()}`;
|
|
309
|
+
const step = {
|
|
310
|
+
step_id: stepId,
|
|
311
|
+
run_id: runId,
|
|
312
|
+
parent_step_id: prevStepId,
|
|
313
|
+
sequence,
|
|
314
|
+
timestamp: bubble.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
315
|
+
model: modelFromComposer(comp) ?? "cursor",
|
|
316
|
+
context_snapshot_id: snapshot.id,
|
|
317
|
+
decision_ref: decisionRef,
|
|
318
|
+
action,
|
|
319
|
+
outcome,
|
|
320
|
+
tokens,
|
|
321
|
+
latency_ms: 0,
|
|
322
|
+
cost_cents: 0,
|
|
323
|
+
tags: ["cost:approx", "cursor"],
|
|
324
|
+
status: outcome.status === "error" ? "error" : outcome.status === "pending" ? "in_progress" : "ok"
|
|
325
|
+
};
|
|
326
|
+
insertStep(store, step);
|
|
327
|
+
prevStepId = stepId;
|
|
328
|
+
sequence += 1;
|
|
329
|
+
stepsAdded += 1;
|
|
330
|
+
const assistantText = text || (bubble.toolFormerData?.name ? `[${bubble.toolFormerData.name}]` : "");
|
|
331
|
+
const aref = await store.blobs.putString(assistantText);
|
|
332
|
+
history.push({ role: "assistant", content_ref: aref });
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
setRunStatus(store, runId, composerStatus(comp), epochMsToIso(comp.lastUpdatedAt));
|
|
337
|
+
updateRunTotals(store, runId);
|
|
338
|
+
return { steps_added: stepsAdded };
|
|
339
|
+
}
|
|
340
|
+
async function snapshotComponents(history) {
|
|
341
|
+
if (history.length === 0) return [];
|
|
342
|
+
return [{ type: "conversation_history", messages: [...history] }];
|
|
343
|
+
}
|
|
344
|
+
function bubbleToAction(bubble, text) {
|
|
345
|
+
const tf = bubble.toolFormerData;
|
|
346
|
+
if (tf?.name) {
|
|
347
|
+
return {
|
|
348
|
+
kind: "tool_call",
|
|
349
|
+
tool_name: tf.name,
|
|
350
|
+
tool_use_id: tf.toolCallId ?? tf.modelCallId,
|
|
351
|
+
tool_input: parseMaybeJson(tf.params) ?? parseMaybeJson(tf.rawArgs)
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
if (text && text.trim().length > 0) {
|
|
355
|
+
return { kind: "message", text };
|
|
356
|
+
}
|
|
357
|
+
if (bubble.allThinkingBlocks && bubble.allThinkingBlocks.length > 0) {
|
|
358
|
+
return { kind: "thinking_only" };
|
|
359
|
+
}
|
|
360
|
+
return { kind: "none" };
|
|
361
|
+
}
|
|
362
|
+
async function bubbleToOutcome(store, bubble) {
|
|
363
|
+
const tf = bubble.toolFormerData;
|
|
364
|
+
if (!tf) return { status: "ok" };
|
|
365
|
+
if (tf.status === "errored") {
|
|
366
|
+
const ref = tf.result ? await store.blobs.putJson(tf.result) : void 0;
|
|
367
|
+
return {
|
|
368
|
+
status: "error",
|
|
369
|
+
is_error: true,
|
|
370
|
+
tool_result_ref: ref,
|
|
371
|
+
summary: typeof tf.result === "string" ? tf.result.slice(0, 200) : "tool errored"
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
if (tf.status === "completed" && tf.result !== void 0) {
|
|
375
|
+
const ref = await store.blobs.putJson(tf.result);
|
|
376
|
+
return {
|
|
377
|
+
status: "ok",
|
|
378
|
+
tool_result_ref: ref,
|
|
379
|
+
summary: typeof tf.result === "string" ? tf.result.split("\n")[0]?.slice(0, 200) : void 0
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
if (tf.status === "pending") return { status: "pending" };
|
|
383
|
+
return { status: "ok" };
|
|
384
|
+
}
|
|
385
|
+
function composerStatus(c) {
|
|
386
|
+
switch (c.status) {
|
|
387
|
+
case "completed":
|
|
388
|
+
return "ok";
|
|
389
|
+
case "error":
|
|
390
|
+
case "errored":
|
|
391
|
+
return "error";
|
|
392
|
+
case "abandoned":
|
|
393
|
+
return "abandoned";
|
|
394
|
+
default:
|
|
395
|
+
return "in_progress";
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
function modelFromComposer(c) {
|
|
399
|
+
const cfg = c.modelConfig;
|
|
400
|
+
if (cfg && typeof cfg === "object") {
|
|
401
|
+
const m = cfg.model ?? cfg.name;
|
|
402
|
+
if (typeof m === "string") return m;
|
|
403
|
+
}
|
|
404
|
+
return void 0;
|
|
405
|
+
}
|
|
406
|
+
function parseMaybeJson(v) {
|
|
407
|
+
if (typeof v !== "string") return v;
|
|
408
|
+
try {
|
|
409
|
+
return JSON.parse(v);
|
|
410
|
+
} catch {
|
|
411
|
+
return v;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
function epochMsToIso(ms) {
|
|
415
|
+
if (ms === void 0 || !Number.isFinite(ms) || ms <= 0) return void 0;
|
|
416
|
+
return new Date(ms).toISOString();
|
|
417
|
+
}
|
|
418
|
+
export {
|
|
419
|
+
CursorDb,
|
|
420
|
+
_conversationHeader,
|
|
421
|
+
bubbleText,
|
|
422
|
+
cursorPaths,
|
|
423
|
+
defaultGlobalDbPath,
|
|
424
|
+
discoverCursorWorkspaces,
|
|
425
|
+
headerCount,
|
|
426
|
+
ingestCursorGlobal,
|
|
427
|
+
isAssistantBubble,
|
|
428
|
+
isMeaningfulComposer,
|
|
429
|
+
isUserBubble,
|
|
430
|
+
readWorkspaceCwd
|
|
431
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@meterbility/cursor-adapter",
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"import": "./dist/index.js"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@meterbility/shared": "^0.3.0",
|
|
15
|
+
"@meterbility/spec": "^0.3.0",
|
|
16
|
+
"@meterbility/collector": "^0.3.0",
|
|
17
|
+
"better-sqlite3": "^11.3.0"
|
|
18
|
+
},
|
|
19
|
+
"description": "Cursor composer capture adapter for Meterbility",
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/HoneycombHairDevelopers/Meterbility.git",
|
|
23
|
+
"directory": "adapters/cursor"
|
|
24
|
+
},
|
|
25
|
+
"homepage": "https://github.com/HoneycombHairDevelopers/Meterbility#readme",
|
|
26
|
+
"bugs": {
|
|
27
|
+
"url": "https://github.com/HoneycombHairDevelopers/Meterbility/issues"
|
|
28
|
+
},
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=20.6"
|
|
31
|
+
},
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"dist"
|
|
37
|
+
],
|
|
38
|
+
"types": "dist/index.d.ts",
|
|
39
|
+
"tsup": {
|
|
40
|
+
"entry": [
|
|
41
|
+
"src/index.ts"
|
|
42
|
+
],
|
|
43
|
+
"format": [
|
|
44
|
+
"esm"
|
|
45
|
+
],
|
|
46
|
+
"dts": true,
|
|
47
|
+
"clean": true
|
|
48
|
+
},
|
|
49
|
+
"scripts": {
|
|
50
|
+
"build": "tsup"
|
|
51
|
+
}
|
|
52
|
+
}
|