@aixle/insights 0.2.0 → 0.2.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/README.md +91 -15
- package/dist/auth/credentials.d.ts +7 -1
- package/dist/auth/credentials.js +90 -17
- package/dist/auth/flow.js +1 -0
- package/dist/cli.js +1 -1
- package/dist/collect-cursor-payloads.d.ts +1 -0
- package/dist/collect-cursor-payloads.js +8 -5
- package/dist/cursor-payload-contract.js +6 -0
- package/dist/cursor-settings.d.ts +9 -4
- package/dist/cursor-settings.js +80 -10
- package/dist/health.d.ts +1 -1
- package/dist/health.js +1 -1
- package/dist/hooks/cursor-hooks-reader.d.ts +2 -0
- package/dist/hooks/cursor-hooks-reader.js +10 -3
- package/dist/lib/client.d.ts +7 -0
- package/dist/lib/client.js +17 -0
- package/dist/lib/config.d.ts +2 -2
- package/dist/lib/config.js +32 -19
- package/dist/lib/parse-error.d.ts +21 -0
- package/dist/lib/parse-error.js +25 -0
- package/dist/lib/project-resolver.d.ts +5 -4
- package/dist/lib/project-resolver.js +57 -11
- package/dist/lib/repo-path-safety.d.ts +35 -0
- package/dist/lib/repo-path-safety.js +102 -0
- package/dist/lib/spawn-arg-safety.d.ts +25 -0
- package/dist/lib/spawn-arg-safety.js +49 -0
- package/dist/readers/claude.d.ts +59 -6
- package/dist/readers/claude.js +184 -3
- package/dist/readers/cursor.d.ts +6 -3
- package/dist/readers/cursor.js +125 -18
- package/dist/risk-scanner.js +7 -0
- package/dist/server.d.ts +3 -3
- package/dist/server.js +77 -66
- package/dist/state.js +42 -34
- package/dist/sync.d.ts +11 -0
- package/dist/sync.js +93 -60
- package/package.json +7 -3
package/dist/readers/claude.d.ts
CHANGED
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
import type { IngestPayload } from "../lib/index.js";
|
|
2
2
|
import { type PricingTable } from "../pricing.js";
|
|
3
3
|
import { type RiskLevel } from "../risk-scanner.js";
|
|
4
|
+
export type ClaudeDerivativeEventType = "edit" | "commit" | "test" | "tool_use";
|
|
5
|
+
export interface ClaudeToolUseBlock {
|
|
6
|
+
id?: string;
|
|
7
|
+
name: string;
|
|
8
|
+
input?: Record<string, unknown>;
|
|
9
|
+
}
|
|
10
|
+
export interface ClaudeCollectedToolUse {
|
|
11
|
+
id: string;
|
|
12
|
+
name: string;
|
|
13
|
+
eventType: ClaudeDerivativeEventType;
|
|
14
|
+
summary: string;
|
|
15
|
+
}
|
|
16
|
+
export declare function classifyToolUse(block: ClaudeToolUseBlock): ClaudeDerivativeEventType | null;
|
|
17
|
+
export declare function scrubBashCommand(cmd: string): string;
|
|
18
|
+
export declare function summarizeToolUse(block: ClaudeToolUseBlock): string;
|
|
4
19
|
/** True when prompt text alone matches known local-command injection markers. */
|
|
5
20
|
export declare function isClaudeLocalCommandNoisePrompt(promptText: string): boolean;
|
|
6
21
|
/**
|
|
@@ -41,9 +56,19 @@ export interface ClaudeTranscriptTurn {
|
|
|
41
56
|
riskLevel: RiskLevel;
|
|
42
57
|
riskScore: number;
|
|
43
58
|
riskCategories: string[];
|
|
59
|
+
toolUses: ClaudeCollectedToolUse[];
|
|
60
|
+
navToolCalls: number;
|
|
61
|
+
totalToolCalls: number;
|
|
62
|
+
/**
|
|
63
|
+
* Fingerprint of the turn's content (prompt + assistant text + tool-use set).
|
|
64
|
+
* A turn keeps the same turnId as Claude appends more tool_use blocks to it,
|
|
65
|
+
* so sync compares this hash to detect appended derivatives and re-emit them
|
|
66
|
+
* instead of skipping the turn forever on its unchanged id (DB90DV-259).
|
|
67
|
+
*/
|
|
68
|
+
contentHash: string;
|
|
44
69
|
}
|
|
45
|
-
/** Payload shape
|
|
46
|
-
export interface
|
|
70
|
+
/** Payload shape for the parent chat turn (carries full token cost). */
|
|
71
|
+
export interface ClaudePayload extends IngestPayload {
|
|
47
72
|
tool_name: "claude_code";
|
|
48
73
|
event_type: "chat";
|
|
49
74
|
model?: string;
|
|
@@ -57,7 +82,7 @@ export interface Db90Payload extends IngestPayload {
|
|
|
57
82
|
session_id: string;
|
|
58
83
|
claude_session_id: string;
|
|
59
84
|
transcript_source: "claude_jsonl";
|
|
60
|
-
model
|
|
85
|
+
model?: string | null;
|
|
61
86
|
base_input_tokens: number;
|
|
62
87
|
output_tokens: number;
|
|
63
88
|
cache_write_tokens: number;
|
|
@@ -68,10 +93,38 @@ export interface Db90Payload extends IngestPayload {
|
|
|
68
93
|
prompt_text?: string;
|
|
69
94
|
assistant_text?: string;
|
|
70
95
|
scannable: true;
|
|
96
|
+
cost_model: "token_count";
|
|
97
|
+
nav_tool_calls: number;
|
|
98
|
+
total_tool_calls: number;
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
/** Payload shape for derivative tool-use children (cost_usd: 0, no tokens). */
|
|
102
|
+
export interface ClaudeDerivativePayload extends IngestPayload {
|
|
103
|
+
tool_name: "claude_code";
|
|
104
|
+
event_type: ClaudeDerivativeEventType;
|
|
105
|
+
cost_usd: 0;
|
|
106
|
+
occurred_at: string;
|
|
107
|
+
model?: string;
|
|
108
|
+
project_id?: string;
|
|
109
|
+
metadata: {
|
|
110
|
+
session_id: string;
|
|
111
|
+
claude_session_id: string;
|
|
112
|
+
transcript_source: "claude_jsonl";
|
|
113
|
+
cost_model: "derivative";
|
|
114
|
+
parent_session_id: string;
|
|
115
|
+
tool_name_inner: string;
|
|
116
|
+
tool_use_id: string;
|
|
117
|
+
summary: string;
|
|
118
|
+
scannable: false;
|
|
119
|
+
risk_level: "none";
|
|
120
|
+
risk_categories: string[];
|
|
121
|
+
risk_score: 0;
|
|
71
122
|
};
|
|
72
123
|
}
|
|
124
|
+
/** Union of all Claude transcript payloads expected by the ingest API. */
|
|
125
|
+
export type ClaudeMappedPayload = ClaudePayload | ClaudeDerivativePayload;
|
|
73
126
|
/** Options for mapTranscriptTurn. */
|
|
74
|
-
export interface
|
|
127
|
+
export interface ToClaudePayloadOptions {
|
|
75
128
|
projectId?: string | null;
|
|
76
129
|
pricing?: PricingTable;
|
|
77
130
|
}
|
|
@@ -79,5 +132,5 @@ export interface ToDb90PayloadOptions {
|
|
|
79
132
|
export declare function findTranscriptFiles(baseDirs?: string[]): string[];
|
|
80
133
|
/** Streams a JSONL file and splits Claude transcripts into individual turns. */
|
|
81
134
|
export declare function parseTranscriptFile(filePath: string, verbose?: boolean): Promise<ClaudeTranscriptTurn[]>;
|
|
82
|
-
/** Converts a Claude transcript turn to
|
|
83
|
-
export declare function mapTranscriptTurn(turn: ClaudeTranscriptTurn, options?:
|
|
135
|
+
/** Converts a Claude transcript turn to parent chat and derivative tool-use payloads. */
|
|
136
|
+
export declare function mapTranscriptTurn(turn: ClaudeTranscriptTurn, options?: ToClaudePayloadOptions): ClaudeMappedPayload[];
|
package/dist/readers/claude.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createReadStream, statSync } from "node:fs";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
2
3
|
import { finished } from "node:stream/promises";
|
|
3
4
|
import { createInterface } from "node:readline";
|
|
4
5
|
import { join } from "node:path";
|
|
@@ -6,6 +7,93 @@ import { homedir } from "node:os";
|
|
|
6
7
|
import { glob } from "glob";
|
|
7
8
|
import { calculateCost } from "../pricing.js";
|
|
8
9
|
import { scanText } from "../risk-scanner.js";
|
|
10
|
+
const NAV_TOOLS = new Set(["Read", "Grep", "Glob", "LS"]);
|
|
11
|
+
const EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
|
|
12
|
+
function bashCommand(block) {
|
|
13
|
+
const c = block.input?.command;
|
|
14
|
+
return typeof c === "string" ? c : "";
|
|
15
|
+
}
|
|
16
|
+
export function classifyToolUse(block) {
|
|
17
|
+
if (NAV_TOOLS.has(block.name))
|
|
18
|
+
return null;
|
|
19
|
+
if (EDIT_TOOLS.has(block.name))
|
|
20
|
+
return "edit";
|
|
21
|
+
if (block.name === "Bash") {
|
|
22
|
+
const cmd = bashCommand(block);
|
|
23
|
+
// Require a space or end-of-string after "commit" so "git commit-tree" is not a commit.
|
|
24
|
+
if (/\bgit\s+commit(\s|$)/.test(cmd))
|
|
25
|
+
return "commit";
|
|
26
|
+
if (/\b(rspec|jest|vitest|pytest|phpunit)\b/i.test(cmd))
|
|
27
|
+
return "test";
|
|
28
|
+
}
|
|
29
|
+
return "tool_use";
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Redacts credential-bearing substrings from a Bash command string before egress.
|
|
33
|
+
*
|
|
34
|
+
* Claude events carry `scannable: true`, which causes the server's ClassificationActivity
|
|
35
|
+
* to take Path 2 (classification_activity.rb:30-44) — trusting the CLI verbatim and
|
|
36
|
+
* skipping server-side sanitization. This function is the single point of trust for
|
|
37
|
+
* command strings derived from tool_input.command. Any new pattern that reads from
|
|
38
|
+
* tool_input.command MUST pass through this function before being emitted. See DATA-CURRENT.md §12.
|
|
39
|
+
*/
|
|
40
|
+
/**
|
|
41
|
+
* True when an env-var name looks credential-bearing.
|
|
42
|
+
* Long tokens (SECRET/TOKEN/PASSWORD/…) may appear as substrings; short ones
|
|
43
|
+
* (KEY/PASS/PWD) must be underscore-delimited segments so "monkey" / "keyboard"
|
|
44
|
+
* are not redacted.
|
|
45
|
+
*/
|
|
46
|
+
function isSecretEnvName(name) {
|
|
47
|
+
const n = name.toUpperCase();
|
|
48
|
+
if (/(?:SECRET|TOKEN|PASSWORD|APIKEY|API_KEY)/.test(n))
|
|
49
|
+
return true;
|
|
50
|
+
return /(?:^|_)(?:KEY|PASS|PWD)(?:_|$)/.test(n);
|
|
51
|
+
}
|
|
52
|
+
// A credential value that follows a flag or `=`: either a single/double quoted
|
|
53
|
+
// string (which may contain spaces) or an unquoted run of non-space chars.
|
|
54
|
+
const VALUE = String.raw `(?:"[^"]*"|'[^']*'|\S+)`;
|
|
55
|
+
// Secret-bearing long-form flags. Matched with either `=` or a space separator,
|
|
56
|
+
// and the value may be quoted (so `--token "secret value"` is fully redacted).
|
|
57
|
+
const SECRET_FLAGS = "password|token|secret|api[-_]?key|access[-_]?key|auth[-_]?key|" +
|
|
58
|
+
"access-key-id|secret-access-key|service-account-key|client-secret";
|
|
59
|
+
export function scrubBashCommand(cmd) {
|
|
60
|
+
return (cmd
|
|
61
|
+
// Authorization / Bearer / Basic headers (curl -H, fetch headers, etc.)
|
|
62
|
+
.replace(/\b(Authorization\s*:\s*)(Bearer|Basic|Token)\s+\S+/gi, "$1$2 [REDACTED]")
|
|
63
|
+
// Inline env-var assignments carrying secrets (AWS_SECRET_ACCESS_KEY=…, db_password="a b").
|
|
64
|
+
// Value may be quoted so assignments with spaces inside quotes are fully redacted.
|
|
65
|
+
// eslint-disable-next-line security/detect-non-literal-regexp
|
|
66
|
+
.replace(new RegExp(String.raw `\b([A-Za-z_][A-Za-z0-9_]*)\s*=\s*${VALUE}`, "g"), (match, name) => isSecretEnvName(name) ? `${name}=[REDACTED]` : match)
|
|
67
|
+
// Secret flags in both --flag=value and --flag value forms, quoted or bare.
|
|
68
|
+
// eslint-disable-next-line security/detect-non-literal-regexp
|
|
69
|
+
.replace(new RegExp(String.raw `(--(?:${SECRET_FLAGS}))(=|\s+)${VALUE}`, "gi"), "$1$2[REDACTED]")
|
|
70
|
+
// -p / --password <value> flag-value pairs (value may be quoted)
|
|
71
|
+
// eslint-disable-next-line security/detect-non-literal-regexp
|
|
72
|
+
.replace(new RegExp(String.raw `((?:^|\s)-p\s+)${VALUE}`, "g"), "$1[REDACTED]")
|
|
73
|
+
// AWS CLI --profile (may embed customer identifiers)
|
|
74
|
+
// eslint-disable-next-line security/detect-non-literal-regexp
|
|
75
|
+
.replace(new RegExp(String.raw `(--profile\s+)${VALUE}`, "g"), "$1[REDACTED]")
|
|
76
|
+
// curl | sh / curl | bash patterns (remote code execution)
|
|
77
|
+
.replace(/\|\s*(sh|bash|zsh|dash)\b/g, "| [SHELL REDACTED]"));
|
|
78
|
+
}
|
|
79
|
+
export function summarizeToolUse(block) {
|
|
80
|
+
const input = block.input ?? {};
|
|
81
|
+
let raw;
|
|
82
|
+
if (typeof input.file_path === "string" && input.file_path.trim()) {
|
|
83
|
+
raw = `${block.name}: ${input.file_path}`;
|
|
84
|
+
}
|
|
85
|
+
else if (typeof input.command === "string" && input.command.trim()) {
|
|
86
|
+
const scrubbed = scrubBashCommand(input.command.trim().replace(/\s+/g, " "));
|
|
87
|
+
raw = `${block.name}: ${scrubbed}`;
|
|
88
|
+
}
|
|
89
|
+
else if (typeof input.pattern === "string") {
|
|
90
|
+
raw = `${block.name}: ${scrubBashCommand(input.pattern)}`;
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
raw = block.name;
|
|
94
|
+
}
|
|
95
|
+
return raw.length <= 256 ? raw : `${raw.slice(0, 253)}...`;
|
|
96
|
+
}
|
|
9
97
|
/** Prompt substrings emitted for local IDE commands — not real user prompts. */
|
|
10
98
|
const LOCAL_COMMAND_NOISE_PROMPT_PATTERNS = [
|
|
11
99
|
/<local-command-caveat\b/i,
|
|
@@ -125,9 +213,32 @@ function newTurn(sessionId, turnIndex, filePath, fileSize, occurredAt, promptId)
|
|
|
125
213
|
riskLevel: "low",
|
|
126
214
|
riskScore: 0,
|
|
127
215
|
riskCategories: [],
|
|
216
|
+
toolUses: [],
|
|
217
|
+
navToolCalls: 0,
|
|
218
|
+
totalToolCalls: 0,
|
|
219
|
+
contentHash: "",
|
|
128
220
|
persisted: false,
|
|
129
221
|
};
|
|
130
222
|
}
|
|
223
|
+
/**
|
|
224
|
+
* Stable fingerprint of a turn's emittable content. Includes the tool-use set
|
|
225
|
+
* (id + name + summary) so appending a tool_use to an already-synced turn
|
|
226
|
+
* changes the hash and triggers a re-emit (DB90DV-259).
|
|
227
|
+
*/
|
|
228
|
+
function computeTurnContentHash(turn) {
|
|
229
|
+
const toolFingerprint = turn.toolUses
|
|
230
|
+
.map((t) => `${t.id}:${t.eventType}:${t.summary}`)
|
|
231
|
+
.join("");
|
|
232
|
+
const material = [
|
|
233
|
+
turn.model ?? "",
|
|
234
|
+
turn.tokensIn,
|
|
235
|
+
turn.tokensOut,
|
|
236
|
+
turn.promptText,
|
|
237
|
+
turn.assistantText,
|
|
238
|
+
toolFingerprint,
|
|
239
|
+
].join("");
|
|
240
|
+
return createHash("sha256").update(material).digest("hex").slice(0, 32);
|
|
241
|
+
}
|
|
131
242
|
function appendText(existing, addition) {
|
|
132
243
|
if (!addition.trim())
|
|
133
244
|
return existing;
|
|
@@ -141,6 +252,41 @@ function enrichTurnRisk(turn) {
|
|
|
141
252
|
turn.riskScore = result.risk_score;
|
|
142
253
|
turn.riskCategories = result.risk_categories;
|
|
143
254
|
}
|
|
255
|
+
function collectToolUsesFromContent(content, turn) {
|
|
256
|
+
if (!Array.isArray(content))
|
|
257
|
+
return;
|
|
258
|
+
let nav = 0;
|
|
259
|
+
let total = 0;
|
|
260
|
+
for (const raw of content) {
|
|
261
|
+
if (typeof raw !== "object" || raw === null)
|
|
262
|
+
continue;
|
|
263
|
+
const block = raw;
|
|
264
|
+
if (block.type !== "tool_use" || typeof block.name !== "string")
|
|
265
|
+
continue;
|
|
266
|
+
total += 1;
|
|
267
|
+
const toolBlock = {
|
|
268
|
+
id: typeof block.id === "string" ? block.id : undefined,
|
|
269
|
+
name: block.name,
|
|
270
|
+
input: typeof block.input === "object" && block.input !== null
|
|
271
|
+
? block.input
|
|
272
|
+
: undefined,
|
|
273
|
+
};
|
|
274
|
+
const eventType = classifyToolUse(toolBlock);
|
|
275
|
+
if (eventType === null) {
|
|
276
|
+
nav += 1;
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
const id = toolBlock.id ?? `idx-${turn.toolUses.length}`;
|
|
280
|
+
turn.toolUses.push({
|
|
281
|
+
id,
|
|
282
|
+
name: toolBlock.name,
|
|
283
|
+
eventType,
|
|
284
|
+
summary: summarizeToolUse(toolBlock),
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
turn.navToolCalls += nav;
|
|
288
|
+
turn.totalToolCalls += total;
|
|
289
|
+
}
|
|
144
290
|
/** Streams a JSONL file and splits Claude transcripts into individual turns. */
|
|
145
291
|
export async function parseTranscriptFile(filePath, verbose = false) {
|
|
146
292
|
const turns = [];
|
|
@@ -257,6 +403,7 @@ export async function parseTranscriptFile(filePath, verbose = false) {
|
|
|
257
403
|
currentTurn.occurredAt = timestamp > currentTurn.occurredAt ? timestamp : currentTurn.occurredAt;
|
|
258
404
|
const text = extractContentText(entry.message.content).join("\n\n").trim();
|
|
259
405
|
currentTurn.assistantText = appendText(currentTurn.assistantText, text);
|
|
406
|
+
collectToolUsesFromContent(entry.message.content, currentTurn);
|
|
260
407
|
}
|
|
261
408
|
}
|
|
262
409
|
}
|
|
@@ -271,9 +418,12 @@ export async function parseTranscriptFile(filePath, verbose = false) {
|
|
|
271
418
|
return turns;
|
|
272
419
|
}
|
|
273
420
|
flushCurrentTurn();
|
|
274
|
-
return finalizedTurns.map(({ persisted: _persisted, ...turn }) =>
|
|
421
|
+
return finalizedTurns.map(({ persisted: _persisted, ...turn }) => ({
|
|
422
|
+
...turn,
|
|
423
|
+
contentHash: computeTurnContentHash(turn),
|
|
424
|
+
}));
|
|
275
425
|
}
|
|
276
|
-
/** Converts a Claude transcript turn to
|
|
426
|
+
/** Converts a Claude transcript turn to parent chat and derivative tool-use payloads. */
|
|
277
427
|
export function mapTranscriptTurn(turn, options) {
|
|
278
428
|
const { projectId, pricing } = options ?? {};
|
|
279
429
|
const baseInputTokens = Math.max(0, turn.tokensIn - turn.cacheWriteTokens - turn.cacheReadTokens);
|
|
@@ -300,6 +450,10 @@ export function mapTranscriptTurn(turn, options) {
|
|
|
300
450
|
prompt_text: turn.promptText || undefined,
|
|
301
451
|
assistant_text: turn.assistantText || undefined,
|
|
302
452
|
scannable: true,
|
|
453
|
+
cost_model: "token_count",
|
|
454
|
+
// Zero is intentional: confirms no tool activity on this turn (not an omission).
|
|
455
|
+
nav_tool_calls: turn.navToolCalls,
|
|
456
|
+
total_tool_calls: turn.totalToolCalls,
|
|
303
457
|
},
|
|
304
458
|
};
|
|
305
459
|
if (turn.model)
|
|
@@ -313,5 +467,32 @@ export function mapTranscriptTurn(turn, options) {
|
|
|
313
467
|
}
|
|
314
468
|
if (projectId)
|
|
315
469
|
payload.project_id = projectId;
|
|
316
|
-
|
|
470
|
+
const derivatives = turn.toolUses.map((toolUse) => {
|
|
471
|
+
const derivative = {
|
|
472
|
+
tool_name: "claude_code",
|
|
473
|
+
event_type: toolUse.eventType,
|
|
474
|
+
cost_usd: 0,
|
|
475
|
+
occurred_at: turn.occurredAt,
|
|
476
|
+
metadata: {
|
|
477
|
+
session_id: `${turn.turnId}:tool:${toolUse.id}`,
|
|
478
|
+
claude_session_id: turn.sessionId,
|
|
479
|
+
transcript_source: "claude_jsonl",
|
|
480
|
+
cost_model: "derivative",
|
|
481
|
+
parent_session_id: turn.turnId,
|
|
482
|
+
tool_name_inner: toolUse.name,
|
|
483
|
+
tool_use_id: toolUse.id,
|
|
484
|
+
summary: toolUse.summary,
|
|
485
|
+
scannable: false,
|
|
486
|
+
risk_level: "none",
|
|
487
|
+
risk_categories: [],
|
|
488
|
+
risk_score: 0,
|
|
489
|
+
},
|
|
490
|
+
};
|
|
491
|
+
if (turn.model)
|
|
492
|
+
derivative.model = turn.model;
|
|
493
|
+
if (projectId)
|
|
494
|
+
derivative.project_id = projectId;
|
|
495
|
+
return derivative;
|
|
496
|
+
});
|
|
497
|
+
return [payload, ...derivatives];
|
|
317
498
|
}
|
package/dist/readers/cursor.d.ts
CHANGED
|
@@ -46,6 +46,8 @@ interface CursorComposerHeader {
|
|
|
46
46
|
composerId: string;
|
|
47
47
|
name: string | null;
|
|
48
48
|
workspacePath: string | null;
|
|
49
|
+
/** Session start (composer.createdAt). Used to spread turns when JSONL lacks per-message times. */
|
|
50
|
+
createdAt: string | null;
|
|
49
51
|
lastUpdatedAt: string | null;
|
|
50
52
|
}
|
|
51
53
|
export interface CursorTranscriptTurn {
|
|
@@ -109,6 +111,7 @@ export type Db90CursorPayloadMetadata = {
|
|
|
109
111
|
generation_id?: string;
|
|
110
112
|
hook_tool_name?: string;
|
|
111
113
|
duration_ms?: number;
|
|
114
|
+
model_resolution?: "settings_json" | "state_vscdb" | "unresolved";
|
|
112
115
|
};
|
|
113
116
|
export interface CursorDb90Payload extends IngestPayload {
|
|
114
117
|
tool_name: "cursor";
|
|
@@ -122,13 +125,13 @@ export interface CursorDb90Payload extends IngestPayload {
|
|
|
122
125
|
metadata: Db90CursorPayloadMetadata;
|
|
123
126
|
}
|
|
124
127
|
export declare function toEpochMs(timestamp: number | string | null | undefined): number | null;
|
|
125
|
-
export declare function mapDailyStats(entry: DailyStatsEntry, projectId?: string, pricing?: PricingConfig, model?: string): CursorDb90Payload[];
|
|
128
|
+
export declare function mapDailyStats(entry: DailyStatsEntry, projectId?: string, pricing?: PricingConfig, model?: string, modelResolution?: Db90CursorPayloadMetadata["model_resolution"]): CursorDb90Payload[];
|
|
126
129
|
/**
|
|
127
130
|
* Maps Cursor’s latest-commit snapshot (`aiCodeTracking.recentCommit`) to a single commit-classified event.
|
|
128
131
|
* Cursor only keeps one recent commit row (overwritten on each new commit).
|
|
129
132
|
* Line-cost math still follows the chat-style line proxy (`computeLineCost("chat", …)`); only `event_type` differs.
|
|
130
133
|
*/
|
|
131
|
-
export declare function mapRecentCommit(entry: RecentCommitSnapshot, projectId?: string, pricing?: PricingConfig, model?: string): CursorDb90Payload | null;
|
|
134
|
+
export declare function mapRecentCommit(entry: RecentCommitSnapshot, projectId?: string, pricing?: PricingConfig, model?: string, modelResolution?: Db90CursorPayloadMetadata["model_resolution"]): CursorDb90Payload | null;
|
|
132
135
|
export declare function mapEvent(row: CursorRow, workspacePath: string, projectId?: string, pricing?: PricingConfig): CursorDb90Payload | null;
|
|
133
|
-
export declare function mapTranscriptTurn(turn: CursorTranscriptTurn, projectId?: string, pricing?: PricingConfig, model?: string): CursorDb90Payload;
|
|
136
|
+
export declare function mapTranscriptTurn(turn: CursorTranscriptTurn, projectId?: string, pricing?: PricingConfig, model?: string, modelResolution?: Db90CursorPayloadMetadata["model_resolution"]): CursorDb90Payload;
|
|
134
137
|
export {};
|
package/dist/readers/cursor.js
CHANGED
|
@@ -74,7 +74,10 @@ export function probeCursorGlobalStateDb(verbose = false, baseDir) {
|
|
|
74
74
|
export function findCursorDbs(baseDir) {
|
|
75
75
|
const dir = join(baseDir ?? cursorUserDir(), "workspaceStorage");
|
|
76
76
|
try {
|
|
77
|
-
|
|
77
|
+
// Pattern stays forward-slash and the directory goes through `cwd`. Building
|
|
78
|
+
// it with `join` emitted `\` on Windows, and glob treats `\` as an escape
|
|
79
|
+
// character on every platform, so this silently matched nothing there.
|
|
80
|
+
return glob.sync("**/cursor.db", { cwd: dir, absolute: true });
|
|
78
81
|
}
|
|
79
82
|
catch {
|
|
80
83
|
return [];
|
|
@@ -244,7 +247,14 @@ export function findStateVscDbs(baseDir) {
|
|
|
244
247
|
const results = [];
|
|
245
248
|
results.push(join(userDir, "globalStorage", "state.vscdb"));
|
|
246
249
|
try {
|
|
247
|
-
|
|
250
|
+
// Forward-slash pattern + `cwd`, as above. This one mattered most: the
|
|
251
|
+
// globalStorage path is pushed unconditionally, so on Windows the function
|
|
252
|
+
// still returned a result while silently dropping every per-workspace
|
|
253
|
+
// state.vscdb — partial data loss that looked like working software.
|
|
254
|
+
results.push(...glob.sync("workspaceStorage/**/state.vscdb", {
|
|
255
|
+
cwd: userDir,
|
|
256
|
+
absolute: true,
|
|
257
|
+
}));
|
|
248
258
|
}
|
|
249
259
|
catch {
|
|
250
260
|
/* ignore */
|
|
@@ -447,6 +457,64 @@ function toIsoFromMs(value) {
|
|
|
447
457
|
const date = new Date(value);
|
|
448
458
|
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
|
449
459
|
}
|
|
460
|
+
/** Coerce epoch ms/seconds, numeric strings, or ISO-8601 into an ISO timestamp. */
|
|
461
|
+
function coerceTranscriptTimestamp(value) {
|
|
462
|
+
if (value == null)
|
|
463
|
+
return null;
|
|
464
|
+
// Route numbers through toEpochMs (via toIsoString) so epoch *seconds* are scaled
|
|
465
|
+
// to ms — otherwise e.g. 1720000000 (2024 in seconds) is misread as a 1970 ms value.
|
|
466
|
+
if (typeof value === "number")
|
|
467
|
+
return toIsoString(value);
|
|
468
|
+
if (typeof value !== "string")
|
|
469
|
+
return null;
|
|
470
|
+
const trimmed = value.trim();
|
|
471
|
+
if (!trimmed)
|
|
472
|
+
return null;
|
|
473
|
+
const fromEpoch = toIsoString(trimmed);
|
|
474
|
+
if (fromEpoch)
|
|
475
|
+
return fromEpoch;
|
|
476
|
+
const ms = Date.parse(trimmed);
|
|
477
|
+
if (Number.isNaN(ms))
|
|
478
|
+
return null;
|
|
479
|
+
return new Date(ms).toISOString();
|
|
480
|
+
}
|
|
481
|
+
/** Prefer explicit per-line / per-message times when Cursor includes them. */
|
|
482
|
+
function extractLineOccurredAt(entry) {
|
|
483
|
+
const candidates = [
|
|
484
|
+
entry.timestamp,
|
|
485
|
+
entry.createdAt,
|
|
486
|
+
entry.unixMs,
|
|
487
|
+
entry.message?.timestamp,
|
|
488
|
+
entry.message?.createdAt,
|
|
489
|
+
];
|
|
490
|
+
for (const candidate of candidates) {
|
|
491
|
+
const iso = coerceTranscriptTimestamp(candidate);
|
|
492
|
+
if (iso)
|
|
493
|
+
return iso;
|
|
494
|
+
}
|
|
495
|
+
return null;
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* Spread turns across [start, end] when the JSONL has no per-message times.
|
|
499
|
+
* Prevents first-sync backfill from collapsing every turn onto lastUpdatedAt/mtime
|
|
500
|
+
* (DB90DV-605 weekly chart spike).
|
|
501
|
+
*/
|
|
502
|
+
function interpolateTurnOccurredAt(startIso, endIso, index, total, fallbackIso) {
|
|
503
|
+
if (total <= 0)
|
|
504
|
+
return fallbackIso;
|
|
505
|
+
// Single turn: prefer session end (lastUpdatedAt) — matches prior composer-header behavior.
|
|
506
|
+
if (total === 1)
|
|
507
|
+
return endIso ?? startIso ?? fallbackIso;
|
|
508
|
+
const startMs = startIso ? Date.parse(startIso) : NaN;
|
|
509
|
+
const endMs = endIso ? Date.parse(endIso) : NaN;
|
|
510
|
+
if (Number.isNaN(startMs) || Number.isNaN(endMs)) {
|
|
511
|
+
return startIso ?? endIso ?? fallbackIso;
|
|
512
|
+
}
|
|
513
|
+
if (endMs <= startMs)
|
|
514
|
+
return startIso ?? fallbackIso;
|
|
515
|
+
const ms = startMs + ((endMs - startMs) * index) / (total - 1);
|
|
516
|
+
return new Date(ms).toISOString();
|
|
517
|
+
}
|
|
450
518
|
function readComposerHeaders(baseDir) {
|
|
451
519
|
const userDir = baseDir ?? cursorUserDir();
|
|
452
520
|
const dbPath = join(userDir, "globalStorage", "state.vscdb");
|
|
@@ -491,6 +559,7 @@ function readComposerHeaders(baseDir) {
|
|
|
491
559
|
composerId,
|
|
492
560
|
name: typeof composer.name === "string" ? composer.name : null,
|
|
493
561
|
workspacePath: typeof uri?.fsPath === "string" ? uri.fsPath : null,
|
|
562
|
+
createdAt: toIsoFromMs(composer.createdAt),
|
|
494
563
|
lastUpdatedAt: toIsoFromMs(composer.lastUpdatedAt),
|
|
495
564
|
});
|
|
496
565
|
}
|
|
@@ -563,11 +632,15 @@ function workspaceFromTranscriptFile(filePath) {
|
|
|
563
632
|
const MAX_TRANSCRIPT_BYTES = 50 * 1024 * 1024; // 50 MB
|
|
564
633
|
export async function parseCursorTranscriptFile(filePath, composerHeaders, verbose = false) {
|
|
565
634
|
let fileSize = 0;
|
|
566
|
-
let
|
|
635
|
+
let fileMtimeIso = new Date().toISOString();
|
|
636
|
+
let fileBirthIso = null;
|
|
567
637
|
try {
|
|
568
638
|
const stat = statSync(filePath);
|
|
569
639
|
fileSize = stat.size;
|
|
570
|
-
|
|
640
|
+
fileMtimeIso = stat.mtime.toISOString();
|
|
641
|
+
if (stat.birthtime && !Number.isNaN(stat.birthtime.getTime()) && stat.birthtime.getTime() > 0) {
|
|
642
|
+
fileBirthIso = stat.birthtime.toISOString();
|
|
643
|
+
}
|
|
571
644
|
}
|
|
572
645
|
catch {
|
|
573
646
|
return [];
|
|
@@ -580,15 +653,25 @@ export async function parseCursorTranscriptFile(filePath, composerHeaders, verbo
|
|
|
580
653
|
}
|
|
581
654
|
const sessionId = basename(filePath, ".jsonl");
|
|
582
655
|
const header = composerHeaders.get(sessionId);
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
const
|
|
656
|
+
const sessionFallback = header?.lastUpdatedAt ?? fileMtimeIso;
|
|
657
|
+
const sessionStart = header?.createdAt ?? fileBirthIso ?? sessionFallback;
|
|
658
|
+
const sessionEnd = header?.lastUpdatedAt ?? fileMtimeIso;
|
|
659
|
+
const drafts = [];
|
|
586
660
|
let currentPromptParts = [];
|
|
587
661
|
let currentAssistantParts = [];
|
|
662
|
+
let currentTurnOccurredAt = null;
|
|
588
663
|
let turnIndex = 0;
|
|
589
664
|
const hasher = createHash("sha256");
|
|
590
665
|
const stream = createReadStream(filePath, { encoding: "utf-8" });
|
|
591
666
|
const rl = createInterface({ input: stream, crlfDelay: Infinity });
|
|
667
|
+
const noteLineTime = (entry) => {
|
|
668
|
+
const lineAt = extractLineOccurredAt(entry);
|
|
669
|
+
if (!lineAt)
|
|
670
|
+
return;
|
|
671
|
+
// Prefer the first timestamp in the turn (usually the user message).
|
|
672
|
+
if (!currentTurnOccurredAt)
|
|
673
|
+
currentTurnOccurredAt = lineAt;
|
|
674
|
+
};
|
|
592
675
|
const finalizeTurn = () => {
|
|
593
676
|
const promptText = currentPromptParts.join("\n\n").trim();
|
|
594
677
|
const assistantText = currentAssistantParts.join("\n\n").trim();
|
|
@@ -596,14 +679,14 @@ export async function parseCursorTranscriptFile(filePath, composerHeaders, verbo
|
|
|
596
679
|
return;
|
|
597
680
|
const risk = scanText(promptText);
|
|
598
681
|
turnIndex += 1;
|
|
599
|
-
|
|
682
|
+
drafts.push({
|
|
600
683
|
turnId: `${sessionId}:${turnIndex}`,
|
|
601
684
|
sessionId,
|
|
602
685
|
filePath,
|
|
603
686
|
fileSize,
|
|
604
687
|
workspacePath: header?.workspacePath ?? workspaceFromTranscriptFile(filePath),
|
|
605
688
|
composerName: header?.name ?? null,
|
|
606
|
-
|
|
689
|
+
turnOccurredAt: currentTurnOccurredAt,
|
|
607
690
|
promptText,
|
|
608
691
|
assistantText,
|
|
609
692
|
tokensIn: estimateTokens(promptText),
|
|
@@ -612,6 +695,7 @@ export async function parseCursorTranscriptFile(filePath, composerHeaders, verbo
|
|
|
612
695
|
riskScore: risk.risk_score,
|
|
613
696
|
riskCategories: risk.risk_categories,
|
|
614
697
|
});
|
|
698
|
+
currentTurnOccurredAt = null;
|
|
615
699
|
};
|
|
616
700
|
let lineNumber = 0;
|
|
617
701
|
try {
|
|
@@ -635,15 +719,26 @@ export async function parseCursorTranscriptFile(filePath, composerHeaders, verbo
|
|
|
635
719
|
if (texts.length === 0)
|
|
636
720
|
continue;
|
|
637
721
|
if (entry.role === "user") {
|
|
722
|
+
// Normalize/filter before noting the time: a whitespace- or wrapper-only line
|
|
723
|
+
// must not set currentTurnOccurredAt (nor start a new turn) with nothing to append,
|
|
724
|
+
// otherwise the next real message inherits this stale timestamp.
|
|
725
|
+
const fragments = texts.map(stripUserQueryWrapper).filter((text) => text.length > 0);
|
|
726
|
+
if (fragments.length === 0)
|
|
727
|
+
continue;
|
|
638
728
|
if (currentPromptParts.length > 0 || currentAssistantParts.length > 0) {
|
|
639
729
|
finalizeTurn();
|
|
640
730
|
currentPromptParts = [];
|
|
641
731
|
currentAssistantParts = [];
|
|
642
732
|
}
|
|
643
|
-
|
|
733
|
+
noteLineTime(entry);
|
|
734
|
+
currentPromptParts.push(...fragments);
|
|
644
735
|
}
|
|
645
736
|
else if (entry.role === "assistant") {
|
|
646
|
-
|
|
737
|
+
const fragments = texts.map((text) => text.trim()).filter((text) => text.length > 0);
|
|
738
|
+
if (fragments.length === 0)
|
|
739
|
+
continue;
|
|
740
|
+
noteLineTime(entry);
|
|
741
|
+
currentAssistantParts.push(...fragments);
|
|
647
742
|
}
|
|
648
743
|
}
|
|
649
744
|
}
|
|
@@ -659,9 +754,16 @@ export async function parseCursorTranscriptFile(filePath, composerHeaders, verbo
|
|
|
659
754
|
}
|
|
660
755
|
finalizeTurn();
|
|
661
756
|
const contentHash = hasher.digest("hex").slice(0, 32);
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
757
|
+
const total = drafts.length;
|
|
758
|
+
return drafts.map((draft, index) => {
|
|
759
|
+
const { turnOccurredAt, ...rest } = draft;
|
|
760
|
+
return {
|
|
761
|
+
...rest,
|
|
762
|
+
contentHash,
|
|
763
|
+
occurredAt: turnOccurredAt ??
|
|
764
|
+
interpolateTurnOccurredAt(sessionStart, sessionEnd, index, total, sessionFallback),
|
|
765
|
+
};
|
|
766
|
+
});
|
|
665
767
|
}
|
|
666
768
|
export async function readCursorTranscriptSessions(cursorUserBaseDir, transcriptProjectDirs, verbose = false) {
|
|
667
769
|
const composerHeaders = readComposerHeaders(cursorUserBaseDir);
|
|
@@ -730,7 +832,7 @@ function dailyStatsSessionId(date, eventType, modelKey) {
|
|
|
730
832
|
return `cursor:daily_stats:${date}:${suffix}`;
|
|
731
833
|
}
|
|
732
834
|
function buildPayload(opts) {
|
|
733
|
-
const { eventType, tokensIn, tokensOut, costUsd, occurredAt, dbPath, date, model = "unknown", modelKey, projectId, costModel = LINE_COST_MODEL, } = opts;
|
|
835
|
+
const { eventType, tokensIn, tokensOut, costUsd, occurredAt, dbPath, date, model = "unknown", modelResolution, modelKey, projectId, costModel = LINE_COST_MODEL, } = opts;
|
|
734
836
|
const payload = {
|
|
735
837
|
tool_name: "cursor",
|
|
736
838
|
event_type: eventType,
|
|
@@ -746,13 +848,14 @@ function buildPayload(opts) {
|
|
|
746
848
|
cost_model: costModel,
|
|
747
849
|
scannable: false,
|
|
748
850
|
risk_level: "none",
|
|
851
|
+
...(modelResolution !== undefined ? { model_resolution: modelResolution } : {}),
|
|
749
852
|
},
|
|
750
853
|
};
|
|
751
854
|
if (projectId)
|
|
752
855
|
payload.project_id = projectId;
|
|
753
856
|
return payload;
|
|
754
857
|
}
|
|
755
|
-
export function mapDailyStats(entry, projectId, pricing = DEFAULT_CURSOR_PRICING, model) {
|
|
858
|
+
export function mapDailyStats(entry, projectId, pricing = DEFAULT_CURSOR_PRICING, model, modelResolution) {
|
|
756
859
|
const { date, value, dbPath } = entry;
|
|
757
860
|
const occurredAt = `${date}T00:00:00.000Z`;
|
|
758
861
|
const results = [];
|
|
@@ -773,6 +876,7 @@ export function mapDailyStats(entry, projectId, pricing = DEFAULT_CURSOR_PRICING
|
|
|
773
876
|
dbPath,
|
|
774
877
|
date,
|
|
775
878
|
model,
|
|
879
|
+
modelResolution,
|
|
776
880
|
projectId,
|
|
777
881
|
}));
|
|
778
882
|
}
|
|
@@ -786,6 +890,7 @@ export function mapDailyStats(entry, projectId, pricing = DEFAULT_CURSOR_PRICING
|
|
|
786
890
|
dbPath,
|
|
787
891
|
date,
|
|
788
892
|
model,
|
|
893
|
+
modelResolution,
|
|
789
894
|
projectId,
|
|
790
895
|
}));
|
|
791
896
|
}
|
|
@@ -820,7 +925,7 @@ export function mapDailyStats(entry, projectId, pricing = DEFAULT_CURSOR_PRICING
|
|
|
820
925
|
* Cursor only keeps one recent commit row (overwritten on each new commit).
|
|
821
926
|
* Line-cost math still follows the chat-style line proxy (`computeLineCost("chat", …)`); only `event_type` differs.
|
|
822
927
|
*/
|
|
823
|
-
export function mapRecentCommit(entry, projectId, pricing = DEFAULT_CURSOR_PRICING, model) {
|
|
928
|
+
export function mapRecentCommit(entry, projectId, pricing = DEFAULT_CURSOR_PRICING, model, modelResolution) {
|
|
824
929
|
const { value: obj, dbPath } = entry;
|
|
825
930
|
const occurredAt = toIsoString(obj.timestamp);
|
|
826
931
|
if (!occurredAt)
|
|
@@ -866,6 +971,7 @@ export function mapRecentCommit(entry, projectId, pricing = DEFAULT_CURSOR_PRICI
|
|
|
866
971
|
: undefined,
|
|
867
972
|
scannable: false,
|
|
868
973
|
risk_level: "none",
|
|
974
|
+
...(modelResolution !== undefined ? { model_resolution: modelResolution } : {}),
|
|
869
975
|
},
|
|
870
976
|
};
|
|
871
977
|
if (projectId)
|
|
@@ -902,7 +1008,7 @@ export function mapEvent(row, workspacePath, projectId, pricing = DEFAULT_CURSOR
|
|
|
902
1008
|
payload.project_id = projectId;
|
|
903
1009
|
return payload;
|
|
904
1010
|
}
|
|
905
|
-
export function mapTranscriptTurn(turn, projectId, pricing = DEFAULT_CURSOR_PRICING, model) {
|
|
1011
|
+
export function mapTranscriptTurn(turn, projectId, pricing = DEFAULT_CURSOR_PRICING, model, modelResolution) {
|
|
906
1012
|
const payload = {
|
|
907
1013
|
tool_name: "cursor",
|
|
908
1014
|
event_type: "chat",
|
|
@@ -924,6 +1030,7 @@ export function mapTranscriptTurn(turn, projectId, pricing = DEFAULT_CURSOR_PRIC
|
|
|
924
1030
|
composer_name: turn.composerName ?? undefined,
|
|
925
1031
|
prompt_text: turn.promptText || undefined,
|
|
926
1032
|
assistant_text: turn.assistantText || undefined,
|
|
1033
|
+
...(modelResolution !== undefined ? { model_resolution: modelResolution } : {}),
|
|
927
1034
|
},
|
|
928
1035
|
};
|
|
929
1036
|
if (projectId)
|