@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.
@@ -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 expected by the db90 ingest API. */
46
- export interface Db90Payload extends IngestPayload {
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: string | null;
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 ToDb90PayloadOptions {
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 a db90 ingest payload. */
83
- export declare function mapTranscriptTurn(turn: ClaudeTranscriptTurn, options?: ToDb90PayloadOptions): Db90Payload;
135
+ /** Converts a Claude transcript turn to parent chat and derivative tool-use payloads. */
136
+ export declare function mapTranscriptTurn(turn: ClaudeTranscriptTurn, options?: ToClaudePayloadOptions): ClaudeMappedPayload[];
@@ -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 }) => turn);
421
+ return finalizedTurns.map(({ persisted: _persisted, ...turn }) => ({
422
+ ...turn,
423
+ contentHash: computeTurnContentHash(turn),
424
+ }));
275
425
  }
276
- /** Converts a Claude transcript turn to a db90 ingest payload. */
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
- return payload;
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
  }
@@ -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 {};
@@ -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
- return glob.sync(join(dir, "**", "cursor.db"));
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
- results.push(...glob.sync(join(userDir, "workspaceStorage", "**", "state.vscdb")));
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 occurredAt = new Date().toISOString();
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
- occurredAt = stat.mtime.toISOString();
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
- if (header?.lastUpdatedAt)
584
- occurredAt = header.lastUpdatedAt;
585
- const turns = [];
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
- turns.push({
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
- occurredAt,
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
- currentPromptParts.push(...texts.map(stripUserQueryWrapper).filter((text) => text.length > 0));
733
+ noteLineTime(entry);
734
+ currentPromptParts.push(...fragments);
644
735
  }
645
736
  else if (entry.role === "assistant") {
646
- currentAssistantParts.push(...texts.map((text) => text.trim()).filter((text) => text.length > 0));
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
- for (const t of turns)
663
- t.contentHash = contentHash;
664
- return turns;
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)