devpulse-ai 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/README.md +77 -0
- package/dist/adapters/claude-code.js +36 -0
- package/dist/adapters/codex.js +224 -0
- package/dist/adapters/cursor-sources.js +201 -0
- package/dist/adapters/cursor.js +157 -0
- package/dist/adapters/index.js +14 -0
- package/dist/adapters/openclaw.js +167 -0
- package/dist/adapters/types.js +1 -0
- package/dist/api.js +41 -0
- package/dist/browser-auth.js +101 -0
- package/dist/commands/login.js +60 -0
- package/dist/commands/schedule.js +95 -0
- package/dist/commands/status.js +54 -0
- package/dist/commands/sync.js +108 -0
- package/dist/config.js +55 -0
- package/dist/index.js +56 -0
- package/dist/parser/claude-code.js +158 -0
- package/dist/schedule/install.js +165 -0
- package/dist/schedule/store.js +24 -0
- package/dist/session-notes.js +39 -0
- package/dist/summary.js +25 -0
- package/dist/types.js +1 -0
- package/dist/ui.js +21 -0
- package/package.json +46 -0
package/README.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# DevPulse CLI
|
|
2
|
+
|
|
3
|
+
Sync local AI coding tool sessions to your DevPulse team dashboard.
|
|
4
|
+
|
|
5
|
+
## Supported tools
|
|
6
|
+
|
|
7
|
+
| Tool | Source | Token usage |
|
|
8
|
+
| --- | --- | --- |
|
|
9
|
+
| **Claude Code** | `~/.claude/projects/**/*.jsonl` | ✅ full |
|
|
10
|
+
| **Codex** | `~/.codex/sessions/**/rollout-*.jsonl` | ✅ full |
|
|
11
|
+
| **OpenClaw** | `~/.openclaw/agents/*/sessions/*.jsonl` | ✅ full |
|
|
12
|
+
| **Cursor** | `ai-code-tracking.db` + `state.vscdb` + `agent-transcripts` | ⚠️ tokens not available locally |
|
|
13
|
+
|
|
14
|
+
Each machine syncs whatever tools it has; absent tools are skipped automatically.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
Requires Node.js **22+**.
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm install -g devpulse-ai
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Usage
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
# 1. Log in (opens your browser to authorize this machine)
|
|
28
|
+
devpulse login
|
|
29
|
+
|
|
30
|
+
# 2. Scan every detected tool and upload new/changed sessions
|
|
31
|
+
devpulse sync
|
|
32
|
+
|
|
33
|
+
# 3. Inspect detected tools and pending sessions without uploading
|
|
34
|
+
devpulse status
|
|
35
|
+
|
|
36
|
+
# 4. (Optional) Auto-sync on a schedule — every 6 hours or daily at 9am
|
|
37
|
+
devpulse schedule install --every 6
|
|
38
|
+
devpulse schedule install --daily --at 09:00
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Commands
|
|
42
|
+
|
|
43
|
+
| Command | Description |
|
|
44
|
+
| --- | --- |
|
|
45
|
+
| `login` | Opens your browser to authorize the CLI (like Claude Code). `--token <t>` or `--no-browser` for manual paste. `--api-url <url>` for self-hosted dashboards. |
|
|
46
|
+
| `sync` | Parse local sessions and upload metadata. `--dry-run`, `--force`, `--dir <path>` (Claude Code), `--tool <name>` (claude-code/openclaw/cursor), `--limit <n>`. |
|
|
47
|
+
| `status` | Show login state, detected tools, synced count, and pending changes. `--tool <name>`, `--dir <path>`. |
|
|
48
|
+
| `schedule install` | Install background auto-sync via macOS launchd or Linux cron. `--every <hours>` (1–24) or `--daily [--at HH:MM]`. |
|
|
49
|
+
| `schedule status` | Show the installed schedule and log file path. |
|
|
50
|
+
| `schedule remove` | Uninstall background auto-sync. |
|
|
51
|
+
|
|
52
|
+
## What it reads & uploads
|
|
53
|
+
|
|
54
|
+
For each session the CLI extracts **lightweight metadata only**:
|
|
55
|
+
|
|
56
|
+
- session id (used to dedupe), tool, model
|
|
57
|
+
- start/end time, message count
|
|
58
|
+
- input/output/cache token usage (where the tool exposes it)
|
|
59
|
+
- project name + a hashed project path (the raw path is never uploaded)
|
|
60
|
+
- a short rule-based summary (derived locally — Cursor uses composer title, first user query, or edited files)
|
|
61
|
+
|
|
62
|
+
**No transcript content, code, or prompts are uploaded** — only derived metadata and cleaned `summaryNotes` for server-side LLM summarization (when `OPENROUTER_API_KEY` is set on the dashboard).
|
|
63
|
+
|
|
64
|
+
## Requirements
|
|
65
|
+
|
|
66
|
+
Node.js **22+** (the Cursor adapter uses the built-in `node:sqlite`; on older Node the Cursor tool is simply skipped).
|
|
67
|
+
|
|
68
|
+
## Config
|
|
69
|
+
|
|
70
|
+
Stored in `~/.devpulse/`:
|
|
71
|
+
|
|
72
|
+
- `config.json` — dashboard URL + token (chmod 600)
|
|
73
|
+
- `state.json` — per-session fingerprints for dedupe
|
|
74
|
+
- `schedule.json` — automatic sync schedule (when installed)
|
|
75
|
+
- `schedule.log` — stdout/stderr from scheduled `devpulse sync` runs
|
|
76
|
+
|
|
77
|
+
Set `DEVPULSE_API_URL=http://localhost:3000` (or `devpulse login --api-url http://localhost:3000`) when developing against a local Next.js server. By default the CLI points at the hosted dashboard at `https://7aj5nkyd.insforge.site`.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { claudeProjectsDir, listSessionFiles, fingerprintFile, parseSessionFile, } from "../parser/claude-code.js";
|
|
3
|
+
const TOOL = "claude-code";
|
|
4
|
+
export const claudeCodeAdapter = {
|
|
5
|
+
tool: TOOL,
|
|
6
|
+
label: "Claude Code",
|
|
7
|
+
available(opts) {
|
|
8
|
+
return existsSync(opts?.dir ?? claudeProjectsDir());
|
|
9
|
+
},
|
|
10
|
+
discover(opts) {
|
|
11
|
+
const dir = opts?.dir ?? claudeProjectsDir();
|
|
12
|
+
return listSessionFiles(dir).map((file) => ({
|
|
13
|
+
// Change detection is per-file so each transcript is tracked independently.
|
|
14
|
+
stateKey: `${TOOL}:${file}`,
|
|
15
|
+
fingerprint: safeFingerprint(file),
|
|
16
|
+
load: () => {
|
|
17
|
+
const parsed = parseSessionFile(file);
|
|
18
|
+
if (!parsed)
|
|
19
|
+
return null;
|
|
20
|
+
// Namespace the upload id by tool. Two transcripts that share a session
|
|
21
|
+
// uuid collapse server-side into one row.
|
|
22
|
+
parsed.metadata.externalId = `${TOOL}:${parsed.metadata.externalId}`;
|
|
23
|
+
parsed.metadata.tool = TOOL;
|
|
24
|
+
return parsed.metadata;
|
|
25
|
+
},
|
|
26
|
+
}));
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
function safeFingerprint(file) {
|
|
30
|
+
try {
|
|
31
|
+
return fingerprintFile(file);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return "";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join, basename } from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
5
|
+
import { buildSessionSummary } from "../summary.js";
|
|
6
|
+
import { buildSummaryNotes, cleanUserText } from "../session-notes.js";
|
|
7
|
+
const TOOL = "codex";
|
|
8
|
+
function codexHome() {
|
|
9
|
+
return process.env.CODEX_HOME?.replace(/\/$/, "") || join(homedir(), ".codex");
|
|
10
|
+
}
|
|
11
|
+
function loadSessionIndex() {
|
|
12
|
+
const path = join(codexHome(), "session_index.jsonl");
|
|
13
|
+
const map = new Map();
|
|
14
|
+
if (!existsSync(path))
|
|
15
|
+
return map;
|
|
16
|
+
for (const line of readFileSync(path, "utf8").split("\n")) {
|
|
17
|
+
const trimmed = line.trim();
|
|
18
|
+
if (!trimmed)
|
|
19
|
+
continue;
|
|
20
|
+
try {
|
|
21
|
+
const row = JSON.parse(trimmed);
|
|
22
|
+
if (row.id)
|
|
23
|
+
map.set(row.id, row);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
/* skip malformed index line */
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return map;
|
|
30
|
+
}
|
|
31
|
+
/** Active + archived Codex rollout transcripts. */
|
|
32
|
+
function listCodexSessionFiles() {
|
|
33
|
+
const roots = [join(codexHome(), "sessions"), join(codexHome(), "archived_sessions")];
|
|
34
|
+
const out = [];
|
|
35
|
+
for (const root of roots) {
|
|
36
|
+
if (!existsSync(root))
|
|
37
|
+
continue;
|
|
38
|
+
collectRollouts(root, out);
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
function collectRollouts(dir, out) {
|
|
43
|
+
for (const ent of readdirSync(dir, { withFileTypes: true })) {
|
|
44
|
+
const p = join(dir, ent.name);
|
|
45
|
+
if (ent.isDirectory())
|
|
46
|
+
collectRollouts(p, out);
|
|
47
|
+
else if (ent.isFile() && ent.name.startsWith("rollout-") && ent.name.endsWith(".jsonl")) {
|
|
48
|
+
out.push(p);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function sessionIdFromFile(filePath) {
|
|
53
|
+
const m = filePath.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i);
|
|
54
|
+
return m?.[1] ?? null;
|
|
55
|
+
}
|
|
56
|
+
function extractMessageText(payload) {
|
|
57
|
+
if (typeof payload.text === "string")
|
|
58
|
+
return payload.text;
|
|
59
|
+
const content = payload.content;
|
|
60
|
+
if (typeof content === "string")
|
|
61
|
+
return content;
|
|
62
|
+
if (Array.isArray(content)) {
|
|
63
|
+
return content
|
|
64
|
+
.map((part) => {
|
|
65
|
+
if (typeof part === "string")
|
|
66
|
+
return part;
|
|
67
|
+
if (part && typeof part === "object") {
|
|
68
|
+
const p = part;
|
|
69
|
+
return p.text ?? p.input_text ?? "";
|
|
70
|
+
}
|
|
71
|
+
return "";
|
|
72
|
+
})
|
|
73
|
+
.join("\n")
|
|
74
|
+
.trim();
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
function isNoiseUserText(text) {
|
|
79
|
+
const t = text.trim();
|
|
80
|
+
if (!t)
|
|
81
|
+
return true;
|
|
82
|
+
if (t.startsWith("<permissions instructions>"))
|
|
83
|
+
return true;
|
|
84
|
+
if (t.startsWith("Response MUST end with a remark-directive"))
|
|
85
|
+
return true;
|
|
86
|
+
if (t.startsWith("<skills_instructions>"))
|
|
87
|
+
return true;
|
|
88
|
+
if (t.startsWith("<plugins_instructions>"))
|
|
89
|
+
return true;
|
|
90
|
+
if (t.startsWith("<app-context>"))
|
|
91
|
+
return true;
|
|
92
|
+
if (t.startsWith("The user interrupted the previous turn"))
|
|
93
|
+
return true;
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
function parseCodexSession(filePath, index) {
|
|
97
|
+
const lines = readFileSync(filePath, "utf8").split("\n");
|
|
98
|
+
let sessionId = sessionIdFromFile(filePath);
|
|
99
|
+
let cwd = null;
|
|
100
|
+
let model = null;
|
|
101
|
+
const userMessages = [];
|
|
102
|
+
const seenUserMessages = new Set();
|
|
103
|
+
let messageCount = 0;
|
|
104
|
+
const timestamps = [];
|
|
105
|
+
let lastTokenUsage = null;
|
|
106
|
+
function pushUserMessage(raw) {
|
|
107
|
+
const cleaned = raw ? cleanUserText(raw) : null;
|
|
108
|
+
if (!cleaned || isNoiseUserText(cleaned) || seenUserMessages.has(cleaned))
|
|
109
|
+
return;
|
|
110
|
+
seenUserMessages.add(cleaned);
|
|
111
|
+
userMessages.push(cleaned);
|
|
112
|
+
}
|
|
113
|
+
for (const line of lines) {
|
|
114
|
+
const trimmed = line.trim();
|
|
115
|
+
if (!trimmed)
|
|
116
|
+
continue;
|
|
117
|
+
let row;
|
|
118
|
+
try {
|
|
119
|
+
row = JSON.parse(trimmed);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (row.timestamp) {
|
|
125
|
+
const t = Date.parse(row.timestamp);
|
|
126
|
+
if (!Number.isNaN(t))
|
|
127
|
+
timestamps.push(t);
|
|
128
|
+
}
|
|
129
|
+
if (row.type === "session_meta" && row.payload) {
|
|
130
|
+
const p = row.payload;
|
|
131
|
+
sessionId = p.id ?? sessionId;
|
|
132
|
+
cwd = p.cwd ?? cwd;
|
|
133
|
+
}
|
|
134
|
+
if (row.type === "turn_context" && row.payload) {
|
|
135
|
+
const p = row.payload;
|
|
136
|
+
if (p.model)
|
|
137
|
+
model = p.model;
|
|
138
|
+
}
|
|
139
|
+
if (row.type === "event_msg" && row.payload) {
|
|
140
|
+
const p = row.payload;
|
|
141
|
+
if (p.type === "user_message" && typeof p.message === "string") {
|
|
142
|
+
pushUserMessage(p.message);
|
|
143
|
+
}
|
|
144
|
+
if (p.type === "token_count") {
|
|
145
|
+
lastTokenUsage = p.info?.total_token_usage ?? p.info?.last_token_usage ?? lastTokenUsage;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (row.type === "response_item" && row.payload) {
|
|
149
|
+
const p = row.payload;
|
|
150
|
+
if (p.type === "message") {
|
|
151
|
+
messageCount++;
|
|
152
|
+
if (p.role === "user") {
|
|
153
|
+
pushUserMessage(extractMessageText(p));
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (messageCount === 0 && timestamps.length === 0 && !lastTokenUsage)
|
|
159
|
+
return null;
|
|
160
|
+
const rawId = sessionId ?? basename(filePath, ".jsonl");
|
|
161
|
+
const threadName = rawId ? index.get(rawId)?.thread_name ?? null : null;
|
|
162
|
+
const projectName = cwd ? basename(cwd) : null;
|
|
163
|
+
const projectPathHash = cwd ? createHash("sha256").update(cwd).digest("hex").slice(0, 32) : null;
|
|
164
|
+
const nonAutomation = userMessages.filter((m) => !/^Automation:/i.test(m));
|
|
165
|
+
const firstUserText = nonAutomation[0] ??
|
|
166
|
+
(threadName && !/^ClawMatrix scheduled run/i.test(threadName) ? threadName : null) ??
|
|
167
|
+
userMessages[0] ??
|
|
168
|
+
threadName;
|
|
169
|
+
const summaryNotes = buildSummaryNotes({
|
|
170
|
+
tool: TOOL,
|
|
171
|
+
projectName,
|
|
172
|
+
title: threadName,
|
|
173
|
+
userMessages: nonAutomation.length ? nonAutomation : userMessages,
|
|
174
|
+
});
|
|
175
|
+
const inputTokens = lastTokenUsage?.input_tokens ?? 0;
|
|
176
|
+
const outputTokens = lastTokenUsage?.output_tokens ?? 0;
|
|
177
|
+
const cacheReadTokens = lastTokenUsage?.cached_input_tokens ?? lastTokenUsage?.cache_read_input_tokens ?? 0;
|
|
178
|
+
const cacheCreationTokens = lastTokenUsage?.cache_creation_input_tokens ?? 0;
|
|
179
|
+
return {
|
|
180
|
+
externalId: `${TOOL}:${rawId}`,
|
|
181
|
+
tool: TOOL,
|
|
182
|
+
model,
|
|
183
|
+
projectPathHash,
|
|
184
|
+
projectName,
|
|
185
|
+
summary: buildSessionSummary({
|
|
186
|
+
firstUserText,
|
|
187
|
+
explicitSummary: threadName,
|
|
188
|
+
projectName,
|
|
189
|
+
messageCount,
|
|
190
|
+
}),
|
|
191
|
+
summaryNotes,
|
|
192
|
+
messageCount,
|
|
193
|
+
inputTokens,
|
|
194
|
+
outputTokens,
|
|
195
|
+
cacheReadTokens,
|
|
196
|
+
cacheCreationTokens,
|
|
197
|
+
startedAt: timestamps.length ? new Date(Math.min(...timestamps)).toISOString() : null,
|
|
198
|
+
endedAt: timestamps.length ? new Date(Math.max(...timestamps)).toISOString() : null,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
export const codexAdapter = {
|
|
202
|
+
tool: TOOL,
|
|
203
|
+
label: "Codex",
|
|
204
|
+
available() {
|
|
205
|
+
return existsSync(join(codexHome(), "sessions")) || existsSync(join(codexHome(), "archived_sessions"));
|
|
206
|
+
},
|
|
207
|
+
discover() {
|
|
208
|
+
const index = loadSessionIndex();
|
|
209
|
+
return listCodexSessionFiles().map((file) => ({
|
|
210
|
+
stateKey: `${TOOL}:${file}`,
|
|
211
|
+
fingerprint: safeFingerprint(file),
|
|
212
|
+
load: () => parseCodexSession(file, index),
|
|
213
|
+
}));
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
function safeFingerprint(file) {
|
|
217
|
+
try {
|
|
218
|
+
const st = statSync(file);
|
|
219
|
+
return `${Math.round(st.mtimeMs)}:${st.size}`;
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
return "";
|
|
223
|
+
}
|
|
224
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { homedir, platform } from "node:os";
|
|
2
|
+
import { join, basename } from "node:path";
|
|
3
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
4
|
+
import { buildSessionSummary } from "../summary.js";
|
|
5
|
+
import { buildSummaryNotes } from "../session-notes.js";
|
|
6
|
+
/** Bump when summary derivation changes so existing sessions re-sync once. */
|
|
7
|
+
export const CURSOR_SUMMARY_VERSION = "v3";
|
|
8
|
+
export function cursorHome() {
|
|
9
|
+
return process.env.CURSOR_HOME ?? join(homedir(), ".cursor");
|
|
10
|
+
}
|
|
11
|
+
export function globalStateDbPath() {
|
|
12
|
+
const home = homedir();
|
|
13
|
+
let path;
|
|
14
|
+
switch (platform()) {
|
|
15
|
+
case "darwin":
|
|
16
|
+
path = join(home, "Library", "Application Support", "Cursor", "User", "globalStorage", "state.vscdb");
|
|
17
|
+
break;
|
|
18
|
+
case "win32":
|
|
19
|
+
path = join(process.env.APPDATA ?? join(home, "AppData", "Roaming"), "Cursor", "User", "globalStorage", "state.vscdb");
|
|
20
|
+
break;
|
|
21
|
+
default:
|
|
22
|
+
path = join(home, ".config", "Cursor", "User", "globalStorage", "state.vscdb");
|
|
23
|
+
}
|
|
24
|
+
return existsSync(path) ? path : null;
|
|
25
|
+
}
|
|
26
|
+
/** Map composerId → agent-transcripts jsonl path (scan once per sync). */
|
|
27
|
+
export function indexAgentTranscripts() {
|
|
28
|
+
const map = new Map();
|
|
29
|
+
const root = join(cursorHome(), "projects");
|
|
30
|
+
if (!existsSync(root))
|
|
31
|
+
return map;
|
|
32
|
+
for (const proj of readdirSync(root)) {
|
|
33
|
+
const dir = join(root, proj, "agent-transcripts");
|
|
34
|
+
if (!existsSync(dir))
|
|
35
|
+
continue;
|
|
36
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
37
|
+
if (entry.isDirectory()) {
|
|
38
|
+
const convId = entry.name;
|
|
39
|
+
for (const file of readdirSync(join(dir, convId))) {
|
|
40
|
+
if (file.endsWith(".jsonl")) {
|
|
41
|
+
map.set(convId, join(dir, convId, file));
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
else if (entry.name.endsWith(".jsonl")) {
|
|
47
|
+
map.set(entry.name.replace(/\.jsonl$/, ""), join(dir, entry.name));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return map;
|
|
52
|
+
}
|
|
53
|
+
function stripTags(text) {
|
|
54
|
+
return text
|
|
55
|
+
.replace(/<user_query>\s*/g, "")
|
|
56
|
+
.replace(/<\/user_query>/g, "")
|
|
57
|
+
.replace(/<[^>]+>/g, " ")
|
|
58
|
+
.replace(/\s+/g, " ")
|
|
59
|
+
.trim();
|
|
60
|
+
}
|
|
61
|
+
export function userMessagesFromTranscript(filePath, limit = 12) {
|
|
62
|
+
const out = [];
|
|
63
|
+
try {
|
|
64
|
+
const lines = readFileSync(filePath, "utf8").trim().split("\n");
|
|
65
|
+
for (const line of lines) {
|
|
66
|
+
if (!line.trim() || out.length >= limit)
|
|
67
|
+
break;
|
|
68
|
+
const o = JSON.parse(line);
|
|
69
|
+
if (o.role !== "user")
|
|
70
|
+
continue;
|
|
71
|
+
const text = (o.message?.content ?? [])
|
|
72
|
+
.filter((c) => c.type === "text" && c.text)
|
|
73
|
+
.map((c) => c.text)
|
|
74
|
+
.join("\n");
|
|
75
|
+
const cleaned = stripTags(text);
|
|
76
|
+
if (cleaned)
|
|
77
|
+
out.push(cleaned);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
/* ignore */
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
export function relativeFilePaths(files) {
|
|
86
|
+
return [...new Set(files.map((f) => {
|
|
87
|
+
const parts = f.split("/");
|
|
88
|
+
const idx = parts.findIndex((p) => p === "src" || p === "cli");
|
|
89
|
+
if (idx > 0)
|
|
90
|
+
return parts.slice(idx - 1).join("/");
|
|
91
|
+
return basename(f);
|
|
92
|
+
}))];
|
|
93
|
+
}
|
|
94
|
+
export function composerTitleFromState(db, conversationId) {
|
|
95
|
+
try {
|
|
96
|
+
const row = db.prepare("SELECT value FROM cursorDiskKV WHERE key = ?").all(`composerData:${conversationId}`)[0];
|
|
97
|
+
if (!row?.value)
|
|
98
|
+
return null;
|
|
99
|
+
const data = JSON.parse(String(row.value));
|
|
100
|
+
const title = data.name ?? data.title ?? null;
|
|
101
|
+
return title?.trim() || null;
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
export function firstUserFromState(db, conversationId) {
|
|
108
|
+
try {
|
|
109
|
+
const rows = db
|
|
110
|
+
.prepare("SELECT value FROM cursorDiskKV WHERE key LIKE ? ORDER BY key LIMIT 40")
|
|
111
|
+
.all(`bubbleId:${conversationId}:%`);
|
|
112
|
+
for (const row of rows) {
|
|
113
|
+
if (!row?.value)
|
|
114
|
+
continue;
|
|
115
|
+
const v = JSON.parse(String(row.value));
|
|
116
|
+
if (v.type !== 1)
|
|
117
|
+
continue;
|
|
118
|
+
const cleaned = stripTags(String(v.text ?? ""));
|
|
119
|
+
if (cleaned)
|
|
120
|
+
return cleaned;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
/* ignore */
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
export function guessProjectName(files) {
|
|
129
|
+
const counts = new Map();
|
|
130
|
+
for (const file of files) {
|
|
131
|
+
const parts = file.split("/");
|
|
132
|
+
for (let i = parts.length - 2; i >= 0; i--) {
|
|
133
|
+
const name = parts[i];
|
|
134
|
+
if (!name || ["src", "lib", "cli", "app", "components", "node_modules", "dist"].includes(name))
|
|
135
|
+
continue;
|
|
136
|
+
counts.set(name, (counts.get(name) ?? 0) + 1);
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const top = [...counts.entries()].sort((a, b) => b[1] - a[1])[0];
|
|
141
|
+
return top?.[0] ?? null;
|
|
142
|
+
}
|
|
143
|
+
function buildFileSummary(files) {
|
|
144
|
+
const rel = files.map((f) => {
|
|
145
|
+
const parts = f.split("/");
|
|
146
|
+
const idx = parts.findIndex((p) => p === "src" || p === "cli");
|
|
147
|
+
if (idx > 0)
|
|
148
|
+
return parts.slice(idx - 1).join("/");
|
|
149
|
+
return basename(f);
|
|
150
|
+
});
|
|
151
|
+
const unique = [...new Set(rel)];
|
|
152
|
+
const head = unique.slice(0, 4).join(", ");
|
|
153
|
+
return `Edited ${unique.length} file${unique.length === 1 ? "" : "s"}: ${head}${unique.length > 4 ? ", …" : ""}`;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Resolve a human-readable session summary without uploading transcript content.
|
|
157
|
+
* Priority: vscdb title → tracking title → transcript user → vscdb user → files → generic.
|
|
158
|
+
*/
|
|
159
|
+
export function buildCursorSummary(input) {
|
|
160
|
+
const { trackingTitle, files, messageCount, stateDb, transcriptPath } = input;
|
|
161
|
+
const projectName = guessProjectName(files);
|
|
162
|
+
const base = { projectName, messageCount };
|
|
163
|
+
const relFiles = relativeFilePaths(files);
|
|
164
|
+
let composerTitle = null;
|
|
165
|
+
if (stateDb)
|
|
166
|
+
composerTitle = composerTitleFromState(stateDb, input.conversationId);
|
|
167
|
+
const userMessages = transcriptPath ? userMessagesFromTranscript(transcriptPath) : [];
|
|
168
|
+
if (userMessages.length === 0 && stateDb) {
|
|
169
|
+
const first = firstUserFromState(stateDb, input.conversationId);
|
|
170
|
+
if (first)
|
|
171
|
+
userMessages.push(first);
|
|
172
|
+
}
|
|
173
|
+
const summaryNotes = buildSummaryNotes({
|
|
174
|
+
tool: "cursor",
|
|
175
|
+
projectName,
|
|
176
|
+
title: composerTitle ?? trackingTitle,
|
|
177
|
+
userMessages,
|
|
178
|
+
files: relFiles,
|
|
179
|
+
});
|
|
180
|
+
if (stateDb && composerTitle) {
|
|
181
|
+
const s = buildSessionSummary({ ...base, explicitSummary: composerTitle, firstUserText: null });
|
|
182
|
+
if (s)
|
|
183
|
+
return { summary: s, summaryNotes };
|
|
184
|
+
}
|
|
185
|
+
if (trackingTitle?.trim()) {
|
|
186
|
+
const s = buildSessionSummary({ ...base, explicitSummary: trackingTitle, firstUserText: null });
|
|
187
|
+
if (s)
|
|
188
|
+
return { summary: s, summaryNotes };
|
|
189
|
+
}
|
|
190
|
+
if (userMessages[0]) {
|
|
191
|
+
const s = buildSessionSummary({ ...base, explicitSummary: null, firstUserText: userMessages[0] });
|
|
192
|
+
if (s)
|
|
193
|
+
return { summary: s, summaryNotes };
|
|
194
|
+
}
|
|
195
|
+
if (files.length > 0)
|
|
196
|
+
return { summary: buildFileSummary(files), summaryNotes };
|
|
197
|
+
return {
|
|
198
|
+
summary: `Cursor session — ${messageCount} AI request${messageCount === 1 ? "" : "s"}.`,
|
|
199
|
+
summaryNotes,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { buildCursorSummary, cursorHome, CURSOR_SUMMARY_VERSION, globalStateDbPath, guessProjectName, indexAgentTranscripts, } from "./cursor-sources.js";
|
|
5
|
+
const TOOL = "cursor";
|
|
6
|
+
const require = createRequire(import.meta.url);
|
|
7
|
+
function cursorDbPath() {
|
|
8
|
+
return join(cursorHome(), "ai-tracking", "ai-code-tracking.db");
|
|
9
|
+
}
|
|
10
|
+
function loadSqlite() {
|
|
11
|
+
try {
|
|
12
|
+
return require("node:sqlite");
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function mostFrequent(values) {
|
|
19
|
+
if (values.length === 0)
|
|
20
|
+
return null;
|
|
21
|
+
const counts = new Map();
|
|
22
|
+
for (const v of values)
|
|
23
|
+
counts.set(v, (counts.get(v) ?? 0) + 1);
|
|
24
|
+
return [...counts.entries()].sort((a, b) => b[1] - a[1])[0][0];
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Cursor session metadata from local stores:
|
|
28
|
+
* - ai-code-tracking.db — files, models, timestamps (no prompts)
|
|
29
|
+
* - state.vscdb — composer titles + user bubble text
|
|
30
|
+
* - agent-transcripts/*.jsonl — first user query per session
|
|
31
|
+
*
|
|
32
|
+
* Token usage is not available locally (Cursor API only).
|
|
33
|
+
*/
|
|
34
|
+
function buildCursorSessions() {
|
|
35
|
+
const sqlite = loadSqlite();
|
|
36
|
+
const dbPath = cursorDbPath();
|
|
37
|
+
if (!sqlite || !existsSync(dbPath))
|
|
38
|
+
return [];
|
|
39
|
+
let db;
|
|
40
|
+
try {
|
|
41
|
+
db = new sqlite.DatabaseSync(dbPath, { readOnly: true });
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
let stateDb = null;
|
|
47
|
+
const statePath = globalStateDbPath();
|
|
48
|
+
if (statePath && sqlite) {
|
|
49
|
+
try {
|
|
50
|
+
stateDb = new sqlite.DatabaseSync(statePath, { readOnly: true });
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
stateDb = null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const transcripts = indexAgentTranscripts();
|
|
57
|
+
try {
|
|
58
|
+
const rows = db
|
|
59
|
+
.prepare(`SELECT conversationId, model, requestId, fileName, timestamp
|
|
60
|
+
FROM ai_code_hashes
|
|
61
|
+
WHERE source = 'composer' AND conversationId IS NOT NULL AND conversationId <> ''`)
|
|
62
|
+
.all();
|
|
63
|
+
const trackingTitles = new Map();
|
|
64
|
+
try {
|
|
65
|
+
for (const r of db
|
|
66
|
+
.prepare(`SELECT conversationId, title FROM conversation_summaries WHERE title IS NOT NULL`)
|
|
67
|
+
.all()) {
|
|
68
|
+
trackingTitles.set(r.conversationId, r.title);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
/* optional table */
|
|
73
|
+
}
|
|
74
|
+
const byConv = new Map();
|
|
75
|
+
for (const r of rows) {
|
|
76
|
+
const a = byConv.get(r.conversationId) ??
|
|
77
|
+
{ models: [], requestIds: new Set(), files: new Set(), hashes: 0, t0: null, t1: null };
|
|
78
|
+
a.hashes++;
|
|
79
|
+
if (r.model)
|
|
80
|
+
a.models.push(r.model);
|
|
81
|
+
if (r.requestId)
|
|
82
|
+
a.requestIds.add(r.requestId);
|
|
83
|
+
if (r.fileName)
|
|
84
|
+
a.files.add(r.fileName);
|
|
85
|
+
if (typeof r.timestamp === "number") {
|
|
86
|
+
a.t0 = a.t0 === null ? r.timestamp : Math.min(a.t0, r.timestamp);
|
|
87
|
+
a.t1 = a.t1 === null ? r.timestamp : Math.max(a.t1, r.timestamp);
|
|
88
|
+
}
|
|
89
|
+
byConv.set(r.conversationId, a);
|
|
90
|
+
}
|
|
91
|
+
const out = [];
|
|
92
|
+
for (const [convId, a] of byConv) {
|
|
93
|
+
const files = [...a.files];
|
|
94
|
+
const messageCount = a.requestIds.size || a.hashes;
|
|
95
|
+
const projectName = guessProjectName(files);
|
|
96
|
+
const { summary, summaryNotes } = buildCursorSummary({
|
|
97
|
+
conversationId: convId,
|
|
98
|
+
trackingTitle: trackingTitles.get(convId) ?? null,
|
|
99
|
+
files,
|
|
100
|
+
messageCount,
|
|
101
|
+
stateDb,
|
|
102
|
+
transcriptPath: transcripts.get(convId) ?? null,
|
|
103
|
+
});
|
|
104
|
+
out.push({
|
|
105
|
+
fingerprint: `${a.hashes}:${a.t1 ?? 0}:${CURSOR_SUMMARY_VERSION}`,
|
|
106
|
+
metadata: {
|
|
107
|
+
externalId: `${TOOL}:${convId}`,
|
|
108
|
+
tool: TOOL,
|
|
109
|
+
model: mostFrequent(a.models),
|
|
110
|
+
projectPathHash: null,
|
|
111
|
+
projectName,
|
|
112
|
+
summary,
|
|
113
|
+
summaryNotes,
|
|
114
|
+
messageCount,
|
|
115
|
+
inputTokens: 0,
|
|
116
|
+
outputTokens: 0,
|
|
117
|
+
cacheReadTokens: 0,
|
|
118
|
+
cacheCreationTokens: 0,
|
|
119
|
+
startedAt: a.t0 ? new Date(a.t0).toISOString() : null,
|
|
120
|
+
endedAt: a.t1 ? new Date(a.t1).toISOString() : null,
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return [];
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
try {
|
|
131
|
+
db.close();
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
/* ignore */
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
stateDb?.close();
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
/* ignore */
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
export const cursorAdapter = {
|
|
145
|
+
tool: TOOL,
|
|
146
|
+
label: "Cursor",
|
|
147
|
+
available() {
|
|
148
|
+
return loadSqlite() !== null && existsSync(cursorDbPath());
|
|
149
|
+
},
|
|
150
|
+
discover() {
|
|
151
|
+
return buildCursorSessions().map(({ metadata, fingerprint }) => ({
|
|
152
|
+
stateKey: metadata.externalId,
|
|
153
|
+
fingerprint,
|
|
154
|
+
load: () => metadata,
|
|
155
|
+
}));
|
|
156
|
+
},
|
|
157
|
+
};
|