@aixle/insights 0.1.1 → 0.2.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 CHANGED
@@ -2,6 +2,83 @@
2
2
 
3
3
  stdio **MCP server** for AI coding-assistant telemetry. Ingests Claude Code JSONL transcripts and Cursor IDE SQLite telemetry into your organization's ingest API, exposes operator tools on the MCP bridge, and pairs with `aixle-insights init` so teammates can onboard without juggling cron jobs or brittle shell hooks.
4
4
 
5
+ ## Architecture reference
6
+
7
+ For implementation architecture, design decisions, and package direction, see [`ARD.md`](./ARD.md).
8
+
9
+ ## Choosing a version
10
+
11
+ Two channels are published. Pick one deliberately — they are **not** interchangeable.
12
+
13
+ | | Production | Staging (QA) |
14
+ |---|---|---|
15
+ | Install | `npm i -g @aixle/insights` | `npm i -g @aixle/insights@staging` |
16
+ | Version looks like | `0.2.0` | `0.2.1-staging` |
17
+ | Points at | the production API | the staging API |
18
+ | Who should use it | **everyone** | QA validating unreleased work |
19
+ | Stability | released, supported | may change or break without notice |
20
+
21
+ ### Production — use this unless told otherwise
22
+
23
+ ```bash
24
+ # One-shot via npx (recommended — always pulls the current release):
25
+ npx -y @aixle/insights init \
26
+ --host https://insights.aixle.com \
27
+ --keycloak-url https://YOUR-KEYCLOAK/realms/YOUR_REALM
28
+
29
+ # Or global install:
30
+ npm i -g @aixle/insights
31
+ aixle-insights --version # e.g. 0.2.0 (no suffix)
32
+ ```
33
+
34
+ ### Staging — QA only
35
+
36
+ Staging builds carry a `-staging` suffix and live on the `staging` dist-tag. You must ask for
37
+ them explicitly; a plain `npm install` will never give you one.
38
+
39
+ ```bash
40
+ # One-shot via npx:
41
+ npx -y @aixle/insights@staging init \
42
+ --host https://staging.insights.aixle.com \
43
+ --keycloak-url https://YOUR-STAGING-KEYCLOAK/realms/YOUR_REALM
44
+
45
+ # Or global install:
46
+ npm i -g @aixle/insights@staging
47
+ aixle-insights --version # e.g. 0.2.1-staging (note the suffix)
48
+ ```
49
+
50
+ Point a staging build at the **staging** API host. Sending staging telemetry to production
51
+ pollutes production analytics.
52
+
53
+ ### Which one do I have?
54
+
55
+ ```bash
56
+ aixle-insights --version # a -staging suffix means a QA build
57
+ npm view @aixle/insights dist-tags # what each channel currently resolves to
58
+ ```
59
+
60
+ Expected output — `latest` and `staging` move independently:
61
+
62
+ ```
63
+ { latest: '0.2.0', staging: '0.2.1-staging' }
64
+ ```
65
+
66
+ ### Switching back to production
67
+
68
+ ```bash
69
+ npm i -g @aixle/insights@latest
70
+ ```
71
+
72
+ Then re-run `init` against the production host, since credentials and the MCP entry are
73
+ per-host.
74
+
75
+ > **Why `npm install` never surprises you with a staging build:** `-staging` versions are semver
76
+ > prereleases, and no ordinary version range resolves to a prerelease. `*`, `^0.2.0`, `~0.2.0`
77
+ > and `>=0.1.0` all select `0.2.0` even when `0.2.1-staging` exists. Staging builds are
78
+ > reachable only by exact version or the `staging` dist-tag.
79
+
80
+ Maintainers: see [`../RELEASING.md`](../RELEASING.md) for how each channel is cut.
81
+
5
82
  ## Install
6
83
 
7
84
  ```bash
@@ -44,6 +121,8 @@ You can also set `DB90_ORGANIZATION_ID=<uuid>` in your shell environment, or pin
44
121
  | `aixle-insights run --once` | Perform one multi-tool sync, exit. Useful for cron / manual flushes. |
45
122
  | `aixle-insights run --once --full` | Backfill: ignore Cursor watermarks and commit-hash dedupe. |
46
123
  | `aixle-insights init` | Keycloak device login + persist credentials + merge `~/.claude.json` entry. |
124
+ | `aixle-insights init --host <url>` | Use a DB90 API origin for token exchange. Remote hosts must use HTTPS; `http://localhost` and loopback addresses are allowed for local development. |
125
+ | `aixle-insights init --insecure --host http://...` | Allow a remote plaintext HTTP host for a trusted non-production test endpoint. Prints a warning because tokens and telemetry can be exposed. |
47
126
  | `aixle-insights init --hooks --tool-name cursor` | Also install the Cursor-side hook forwarder (opt-in; requires Cursor restart). |
48
127
  | `aixle-insights uninstall-hooks` | Remove the hook forwarder + restore `~/.cursor/hooks.json` backup. |
49
128
  | `aixle-insights verify-hooks` | Print hooks install status + queue depth as JSON. |
@@ -59,6 +138,8 @@ You can also set `DB90_ORGANIZATION_ID=<uuid>` in your shell environment, or pin
59
138
  | `DB90_ORGANIZATION_ID` | Optional UUID scoping `init` to that org membership (header `X-Organization-ID`). |
60
139
  | `AIXLE_INSIGHTS_HOME` | Override the local state directory (defaults to `~/.aixle-insights/`). |
61
140
 
141
+ Remote API and ingest hosts must use `https://`. Plaintext `http://` is local-dev-only for `localhost`, `127.0.0.0/8`, and `[::1]`; those loopback URLs work without warnings. A remote `http://` host is rejected during `init` unless you pass `--insecure`, which should only be used for trusted non-production test endpoints and will print a warning because ingest tokens and telemetry can cross the network unencrypted.
142
+
62
143
  Note: the `DB90_*` variables above are retained as compatibility names for the deployment-side Keycloak realm/client identifiers. Future versions may rename them with a deprecation window.
63
144
 
64
145
  Optional `~/.aixle-insights/config.json` accepts Cursor line-cost overrides (per-model rates):
@@ -73,6 +154,29 @@ Optional `~/.aixle-insights/config.json` accepts Cursor line-cost overrides (per
73
154
  }
74
155
  ```
75
156
 
157
+ ## Security
158
+
159
+ `@aixle/insights` enforces HTTPS for any remote host. Plaintext `http://` is allowed only for loopback (`localhost`, `127.0.0.0/8`, `[::1]`) so that local-dev flows against `make up` continue to work without friction.
160
+
161
+ ### Two gates
162
+
163
+ | Gate | Where it fires | What it checks |
164
+ |---|---|---|
165
+ | CLI `--host` gate | `runInit()` at the top of `aixle-insights init`, before any network call to Keycloak | The `--host` value the user typed |
166
+ | Post-exchange `ingestHost` gate | `auth/flow.ts`, immediately after the OIDC-for-ingest-token exchange returns, before persisting credentials to the keychain | The `ingestHost` returned by the server, in case it differs from `--host` |
167
+
168
+ Both gates use the same pure utility, `evaluateTransportSecurity()` in `src/lib/transport-security.ts`. Either gate rejecting aborts `init` with exit code 1 and a single-line error naming the offending host.
169
+
170
+ ### `--insecure` (init-only)
171
+
172
+ `aixle-insights init --insecure --host http://<remote>` downgrades both gates from "reject" to "warn + continue." It is intended only for trusted non-production test endpoints (e.g. a self-hosted staging on a private network without a TLS cert).
173
+
174
+ `--insecure` is rejected on the `run` subcommand by design — the long-running MCP server should never run insecurely. `run --insecure` routes to the help screen, the same as any unknown flag.
175
+
176
+ ### What is NOT gated
177
+
178
+ The Keycloak issuer URL (`--keycloak-url` / `KEYCLOAK_ISSUER`) is **not** TLS-gated by this package. Same threat model, different ticket — tracked separately. For now, use HTTPS for any remote Keycloak issuer; the OIDC device-flow library will fail the request if the cert is invalid, but it will not refuse to attempt plaintext.
179
+
76
180
  ## Cursor hook forwarder (opt-in)
77
181
 
78
182
  `aixle-insights init --hooks --tool-name cursor` installs a Node script as a Cursor hook (`~/.cursor/hooks.json`). The script appends redacted hook payloads to `~/.aixle-insights/hooks-queue.ndjson`; the background sync drains the queue on its next cycle and POSTs the events with accurate per-turn model attribution. Requires a Cursor restart after install. To remove, run `aixle-insights uninstall-hooks` and restart Cursor again.
@@ -94,6 +198,19 @@ aixle-insights verify-hooks # JSON: hooks installed + queue depth
94
198
 
95
199
  `mcp.log` (rotates at 5 MiB to `mcp.log.1`) under the app home directory captures operational events. Inside Claude Code, the **`db90_status`** MCP tool returns the same diagnostic structure as `aixle-insights health`.
96
200
 
201
+ ## Troubleshooting
202
+
203
+ | Symptom | Most likely cause | Fix |
204
+ |---|---|---|
205
+ | `Error: DB90 API host <name> uses remote plaintext HTTP.` | You passed `--host http://<remote>` without `--insecure`. | Use `https://...`, or add `--insecure` if you know the endpoint is trusted and non-production. |
206
+ | `Auth failed: fetch failed` during `init` | The `--keycloak-url` host doesn't resolve (NXDOMAIN), is behind a VPN, or the TLS cert is bad. | Verify with `curl -sS https://<host>/realms/<realm>/.well-known/openid-configuration`. For DB90 staging, the canonical Keycloak URL is embedded in the SPA — `curl https://<APP_HOST> \| grep keycloakUrl` extracts the current value. |
207
+ | `Failed to post event: HTTP 401 Unauthorized` repeated for every turn | Your saved ingest token has been rotated, revoked, or invalidated by a server redeploy. The ingest token is distinct from the Keycloak access token that `health` reports as `authenticated: true`. | Reset the keychain entry and re-run `init`: `security delete-generic-password -s "aixle-insights" -a "aixle-insights-ingest-credential"` then `rm -f ~/.aixle-insights/credentials.json` then `aixle-insights init --host ... --keycloak-url ...`. State files are **not** deleted, so already-sent sessions stay deduped. |
208
+ | `health` shows `authenticated: true` but `last_result` is `sent: 0, failed: N` cycle after cycle | Same as the 401 row above. `authenticated` only proves the OIDC token was acquired, not that the ingest token still validates server-side. | Re-init as above. |
209
+ | `last_result` reports `sent: N` but the Events UI shows nothing | The Temporal worker is not running. The ingest endpoint returns HTTP 202 (queued) regardless of worker state. | `make worker` (or check `docker ps` for `db90-worker`). See [LOCAL-DEV.md](./LOCAL-DEV.md) §1. |
210
+ | `sync_lock_skip {reason: "advisory_lock_held"}` in the log | Another sync cycle is still holding `~/.aixle-insights/state.lock`. | Wait for it to finish; only delete the lock file (`rm -f ~/.aixle-insights/state.lock`) after confirming no `aixle-insights run` process is alive (`pgrep -fa aixle-insights`). |
211
+ | `aixle-insights --help` doesn't list `--insecure` | You're running an older published version of the package, not the local source. | `which aixle-insights` shows the path. To run local source: `cd packages/tools/aixle-insights && npm run build && npm link`. To return to the published version: `npm unlink -g @aixle/insights && npm install -g @aixle/insights@latest`. |
212
+ | Not sure whether `aixle-insights` is a `npm link` or a real install | Real installs are regular files; `npm link` is a symlink chain into the repo. | `readlink "$(which aixle-insights)"` shows the link target if any. A linked install will trace back to a path under your monorepo checkout. |
213
+
97
214
  ## Local development — `/aixle-reset` skill
98
215
 
99
216
  > Audience: contributors editing this package. If you installed `@aixle/insights` from npm and aren't modifying the source, you can skip this section.
@@ -131,6 +248,12 @@ After the script reports success: **quit and reopen Claude Code / Cursor** so ea
131
248
 
132
249
  - Node.js ≥ 20.
133
250
  - macOS / Linux / Windows. On Windows, the package writes a `cmd /c npx …` wrapper in `~/.claude.json` so Claude Code can spawn the MCP server reliably.
251
+ - `better-sqlite3` is a native module. After a Node upgrade, if SQLite reads start failing, rebuild it from the tools workspace:
252
+
253
+ ```bash
254
+ cd packages/tools
255
+ npm rebuild better-sqlite3
256
+ ```
134
257
 
135
258
  ## License
136
259
 
@@ -11,6 +11,8 @@ export interface LoginAndPersistOptions {
11
11
  exchangeOrganizationId?: string;
12
12
  clientId?: string;
13
13
  appDir?: string;
14
+ allowInsecureHttp?: boolean;
15
+ onSecurityWarning?: (message: string) => void;
14
16
  onVisitInstructions?: (verification_uri: string, user_code: string) => void;
15
17
  fetchImpl?: typeof fetch;
16
18
  }
package/dist/auth/flow.js CHANGED
@@ -2,6 +2,7 @@ import { exchangeIngestToken } from "./exchange.js";
2
2
  import { defaultKeycloakClientId, defaultKeycloakIssuer, obtainKeycloakAccessTokenViaDeviceFlow } from "./keycloak.js";
3
3
  import { loadCredentials, saveStoredCredentials } from "./credentials.js";
4
4
  import { getAppDir } from "../state.js";
5
+ import { evaluateTransportSecurity } from "../lib/transport-security.js";
5
6
  export async function loginAndPersistCredentials(opts) {
6
7
  const issuer = opts.keycloakIssuer.trim();
7
8
  if (!issuer) {
@@ -45,6 +46,16 @@ export async function loginAndPersistCredentials(opts) {
45
46
  fetchImpl: opts.fetchImpl,
46
47
  });
47
48
  }
49
+ const ingestHostSecurity = evaluateTransportSecurity(exchanged.ingestHost, {
50
+ allowInsecureHttp: opts.allowInsecureHttp === true,
51
+ label: "DB90 ingest host",
52
+ });
53
+ if (!ingestHostSecurity.ok) {
54
+ return { ok: false, error: ingestHostSecurity.error };
55
+ }
56
+ if (ingestHostSecurity.warning) {
57
+ opts.onSecurityWarning?.(ingestHostSecurity.warning);
58
+ }
48
59
  const existing = await loadCredentials(appDir);
49
60
  const stored = {
50
61
  host: exchanged.ingestHost,
package/dist/cli.d.ts CHANGED
@@ -21,6 +21,8 @@ export interface Args {
21
21
  force?: boolean;
22
22
  /** When set on init, install the Cursor hooks forwarder into ~/.cursor/hooks.json. */
23
23
  hooks?: boolean;
24
+ /** Allow remote plaintext HTTP hosts for trusted non-production test environments. */
25
+ insecure?: boolean;
24
26
  }
25
27
  interface RunOnceDeps {
26
28
  loadCredentials: typeof loadCredentials;
package/dist/cli.js CHANGED
@@ -12,12 +12,13 @@ import { resolveCursorPricing } from "./cursor-config.js";
12
12
  import { buildHealthSnapshot, formatHealthForCli } from "./health.js";
13
13
  import { installClaudeUserMcp } from "./install/claude.js";
14
14
  import { installHooksConfig, uninstallHooksConfig, verifyHooksConfig, FORWARDER_FILENAME } from "./hooks/hooks-config.js";
15
+ import { evaluateTransportSecurity } from "./lib/transport-security.js";
15
16
  import { join } from "node:path";
16
17
  import { fileURLToPath as nodeFileURLToPath } from "node:url";
17
18
  import { mcpLog } from "./log.js";
18
19
  const GLOBAL_FLAGS = new Set(["--help", "-h", "--once", "--full"]);
19
20
  const INIT_VALUE_FLAGS = new Set(["--host", "--keycloak-url", "--tool-name", "--organization-id"]);
20
- const INIT_BOOLEAN_FLAGS = new Set(["--force", "--hooks"]);
21
+ const INIT_BOOLEAN_FLAGS = new Set(["--force", "--hooks", "--insecure"]);
21
22
  /** Matches DB90 Rails `McpController` UUID check for `X-Organization-ID` (RFC 4122 variant). */
22
23
  export const DB90_ORGANIZATION_UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
23
24
  export function isValidDb90OrganizationUuid(value) {
@@ -112,7 +113,8 @@ export function parseArgs(argv) {
112
113
  const organizationId = takeFlagValue(args, "--organization-id");
113
114
  const force = args.includes("--force");
114
115
  const hooks = args.includes("--hooks");
115
- return { command: "init", help, once: false, host, keycloakUrl, toolName, organizationId, force, hooks };
116
+ const insecure = args.includes("--insecure");
117
+ return { command: "init", help, once: false, host, keycloakUrl, toolName, organizationId, force, hooks, insecure };
116
118
  }
117
119
  const nonInitBad = args.filter((a) => {
118
120
  if (!a.startsWith("--") && a !== "-h")
@@ -177,6 +179,7 @@ init options:
177
179
  --force Replace an existing user "aixle-insights" MCP entry in ~/.claude.json if it differs.
178
180
  --hooks (opt-in) Install Cursor hook forwarder for per-turn model attribution.
179
181
  Requires Cursor restart. Run 'aixle-insights uninstall-hooks' to remove.
182
+ --insecure Allow remote http:// hosts for trusted non-production test endpoints only.
180
183
 
181
184
  Multi-org:
182
185
  Set \`DB90_ORGANIZATION_ID\` to a UUID, or pass \`--organization-id\` on \`init\`, so ingest tokens are minted for that membership instead of the default (oldest) org.
@@ -213,6 +216,17 @@ export async function runInit(cliArgs, deps) {
213
216
  ...deps,
214
217
  };
215
218
  const db90Host = (cliArgs.host ?? defaultDb90Host()).replace(/\/$/, "");
219
+ const transportSecurity = evaluateTransportSecurity(db90Host, {
220
+ allowInsecureHttp: cliArgs.insecure === true,
221
+ label: "DB90 API host",
222
+ });
223
+ if (!transportSecurity.ok) {
224
+ runtime.error(`Error: ${transportSecurity.error}`);
225
+ return 1;
226
+ }
227
+ if (transportSecurity.warning) {
228
+ runtime.error(`Warning: ${transportSecurity.warning}`);
229
+ }
216
230
  const kcIssuer = (cliArgs.keycloakUrl ?? runtime.defaultKeycloakIssuer()).trim();
217
231
  if (!kcIssuer) {
218
232
  runtime.error("Error: Keycloak issuer is not configured. Pass --keycloak-url or set KEYCLOAK_ISSUER / DB90_KEYCLOAK_ISSUER.");
@@ -246,6 +260,10 @@ export async function runInit(cliArgs, deps) {
246
260
  deviceLabel: "aixle-insights CLI init",
247
261
  appDir: runtime.getAppDir(),
248
262
  exchangeOrganizationId: exchangeOrganizationId || undefined,
263
+ allowInsecureHttp: cliArgs.insecure === true,
264
+ onSecurityWarning: (message) => {
265
+ runtime.error(`Warning: ${message}`);
266
+ },
249
267
  onVisitInstructions: (uri, code) => {
250
268
  runtime.log(`Visit ${uri} and enter code ${code}`);
251
269
  },
@@ -11,6 +11,7 @@ const TOP_LEVEL_KEYS = new Set([
11
11
  "metadata",
12
12
  ]);
13
13
  const METADATA_BASE_KEYS = new Set([
14
+ "session_id",
14
15
  "cursor_session_id",
15
16
  "workspace",
16
17
  "workspace_scope",
@@ -39,8 +39,8 @@ export interface CursorStoreAuditReport {
39
39
  daily_stats_version_note: string;
40
40
  }
41
41
  export declare function redactCursorPath(p: string): string;
42
- export declare function auditStateVscdbFile(dbPath: string): StateVscdbAuditEntry;
43
- export declare function auditLegacyCursorDbFile(dbPath: string): LegacyDbAuditEntry;
42
+ export declare function auditStateVscdbFile(dbPath: string, rootDir?: string): StateVscdbAuditEntry;
43
+ export declare function auditLegacyCursorDbFile(dbPath: string, rootDir?: string): LegacyDbAuditEntry;
44
44
  /**
45
45
  * CUR-V07 — inventory local Cursor stores (state.vscdb vs legacy cursor.db).
46
46
  * Does not read disk outside Cursor's User directory unless `baseDir` is passed (tests).
@@ -1,9 +1,9 @@
1
1
  import { existsSync, statSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
- import Database from "better-sqlite3";
5
4
  import { discoverDailyStatsVersionsInDb, mergeDailyStatsVersionDiscoveries, } from "./daily-stats-versions.js";
6
5
  import { cursorUserDir, findCursorDbs, findStateVscDbs, isGlobalStateDbPath, probeCursorGlobalStateDb, } from "./readers/cursor.js";
6
+ import { openCursorSqliteReadonly, resolveCursorSqlitePath } from "./readers/cursor-sqlite.js";
7
7
  const LEGACY_TABLE = "CursorRequestFeedback";
8
8
  const STATE_TABLE = "ItemTable";
9
9
  const RECENT_COMMIT_KEY = "aiCodeTracking.recentCommit";
@@ -16,7 +16,7 @@ function tableExists(db, tableName) {
16
16
  .get(tableName);
17
17
  return row !== undefined;
18
18
  }
19
- export function auditStateVscdbFile(dbPath) {
19
+ export function auditStateVscdbFile(dbPath, rootDir) {
20
20
  const entry = {
21
21
  db_path_redacted: redactCursorPath(dbPath),
22
22
  exists: existsSync(dbPath),
@@ -27,7 +27,10 @@ export function auditStateVscdbFile(dbPath) {
27
27
  return entry;
28
28
  let db = null;
29
29
  try {
30
- db = new Database(dbPath, { readonly: true });
30
+ const opened = openCursorSqliteReadonly(dbPath, { rootDir });
31
+ if (!opened.ok)
32
+ return entry;
33
+ db = opened.db;
31
34
  const ds = db
32
35
  .prepare(`SELECT count(*) AS c FROM ${STATE_TABLE} WHERE key LIKE 'aiCodeTracking.dailyStats%'`)
33
36
  .get();
@@ -45,18 +48,25 @@ export function auditStateVscdbFile(dbPath) {
45
48
  }
46
49
  return entry;
47
50
  }
48
- export function auditLegacyCursorDbFile(dbPath) {
51
+ export function auditLegacyCursorDbFile(dbPath, rootDir) {
49
52
  const entry = {
50
53
  db_path_redacted: redactCursorPath(dbPath),
51
- file_bytes: existsSync(dbPath) ? statSync(dbPath).size : 0,
54
+ file_bytes: 0,
52
55
  has_feedback_table: false,
53
56
  feedback_row_count: 0,
54
57
  };
55
58
  if (!existsSync(dbPath))
56
59
  return entry;
60
+ const resolved = resolveCursorSqlitePath(dbPath, { rootDir });
61
+ if (!resolved.ok)
62
+ return entry;
63
+ entry.file_bytes = statSync(resolved.path).size;
57
64
  let db = null;
58
65
  try {
59
- db = new Database(dbPath, { readonly: true });
66
+ const opened = openCursorSqliteReadonly(resolved.path, { rootDir });
67
+ if (!opened.ok)
68
+ return entry;
69
+ db = opened.db;
60
70
  if (!tableExists(db, LEGACY_TABLE))
61
71
  return entry;
62
72
  entry.has_feedback_table = true;
@@ -105,19 +115,20 @@ function pathCIngestNote(verdict, legacyCount, totalRows) {
105
115
  * Does not read disk outside Cursor's User directory unless `baseDir` is passed (tests).
106
116
  */
107
117
  export function auditCursorLocalStores(baseDir) {
108
- const sqlite_probe_ok = probeCursorGlobalStateDb(false);
118
+ const rootDir = baseDir ?? cursorUserDir();
119
+ const sqlite_probe_ok = probeCursorGlobalStateDb(false, baseDir);
109
120
  const statePaths = findStateVscDbs(baseDir);
110
121
  const globalPath = statePaths.find((p) => isGlobalStateDbPath(p)) ??
111
122
  join(baseDir ?? cursorUserDir(), "globalStorage", "state.vscdb");
112
- const global = auditStateVscdbFile(globalPath);
123
+ const global = auditStateVscdbFile(globalPath, rootDir);
113
124
  const workspacePaths = statePaths.filter((p) => !isGlobalStateDbPath(p));
114
- const workspaceAudits = workspacePaths.map(auditStateVscdbFile);
125
+ const workspaceAudits = workspacePaths.map((p) => auditStateVscdbFile(p, rootDir));
115
126
  const versionDiscoveries = statePaths
116
127
  .filter((p) => existsSync(p))
117
- .map(discoverDailyStatsVersionsInDb);
128
+ .map((p) => discoverDailyStatsVersionsInDb(p, { rootDir }));
118
129
  const daily_stats_versions = mergeDailyStatsVersionDiscoveries(versionDiscoveries);
119
130
  const legacyPaths = findCursorDbs(baseDir);
120
- const legacyEntries = legacyPaths.map(auditLegacyCursorDbFile);
131
+ const legacyEntries = legacyPaths.map((p) => auditLegacyCursorDbFile(p, rootDir));
121
132
  const withFeedbackTable = legacyEntries.filter((e) => e.has_feedback_table).length;
122
133
  const totalFeedbackRows = legacyEntries.reduce((sum, e) => sum + e.feedback_row_count, 0);
123
134
  let path_c_verdict;
@@ -26,6 +26,8 @@ export declare function isVersionNewerThanV1_5(version: string): boolean;
26
26
  /**
27
27
  * Read all `aiCodeTracking.dailyStats%` keys from one `state.vscdb` file.
28
28
  */
29
- export declare function discoverDailyStatsVersionsInDb(dbPath: string): DailyStatsVersionDiscovery;
29
+ export declare function discoverDailyStatsVersionsInDb(dbPath: string, options?: {
30
+ rootDir?: string;
31
+ }): DailyStatsVersionDiscovery;
30
32
  /** Merge discoveries from global + workspace `state.vscdb` files (dedupe sample keys only). */
31
33
  export declare function mergeDailyStatsVersionDiscoveries(discoveries: DailyStatsVersionDiscovery[]): DailyStatsVersionDiscovery;
@@ -1,8 +1,4 @@
1
- /**
2
- * CUR-V11 — discover `aiCodeTracking.dailyStats` version prefixes on disk.
3
- * Keys look like: aiCodeTracking.dailyStats.v1.5.2026-05-20
4
- */
5
- import Database from "better-sqlite3";
1
+ import { openCursorSqliteReadonly } from "./readers/cursor-sqlite.js";
6
2
  const STATE_TABLE = "ItemTable";
7
3
  const DAILY_STATS_LIKE = "aiCodeTracking.dailyStats%";
8
4
  /** Full key shape for a dated dailyStats row. */
@@ -70,12 +66,15 @@ function mergeBuckets(target, discovery) {
70
66
  /**
71
67
  * Read all `aiCodeTracking.dailyStats%` keys from one `state.vscdb` file.
72
68
  */
73
- export function discoverDailyStatsVersionsInDb(dbPath) {
69
+ export function discoverDailyStatsVersionsInDb(dbPath, options = {}) {
74
70
  const byVersion = new Map();
75
71
  const unmatched = [];
76
72
  let db = null;
77
73
  try {
78
- db = new Database(dbPath, { readonly: true });
74
+ const opened = openCursorSqliteReadonly(dbPath, { rootDir: options.rootDir });
75
+ if (!opened.ok)
76
+ return emptyDiscovery();
77
+ db = opened.db;
79
78
  const table = db
80
79
  .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?")
81
80
  .get(STATE_TABLE);
@@ -0,0 +1,12 @@
1
+ export type TransportSecurityResult = {
2
+ ok: true;
3
+ warning?: string;
4
+ } | {
5
+ ok: false;
6
+ error: string;
7
+ };
8
+ export interface TransportSecurityOptions {
9
+ allowInsecureHttp: boolean;
10
+ label: string;
11
+ }
12
+ export declare function evaluateTransportSecurity(rawUrl: string, options: TransportSecurityOptions): TransportSecurityResult;
@@ -0,0 +1,47 @@
1
+ function isIpv4Loopback(hostname) {
2
+ const parts = hostname.split(".");
3
+ if (parts.length !== 4)
4
+ return false;
5
+ const octets = parts.map((part) => Number(part));
6
+ return octets.every((octet, index) => Number.isInteger(octet) &&
7
+ octet >= 0 &&
8
+ octet <= 255 &&
9
+ String(octet) === parts[index]) && octets[0] === 127;
10
+ }
11
+ function isLoopbackHost(hostname) {
12
+ const normalized = hostname.toLowerCase();
13
+ return normalized === "localhost" ||
14
+ normalized === "::1" ||
15
+ normalized === "[::1]" ||
16
+ isIpv4Loopback(normalized);
17
+ }
18
+ function plaintextMessage(label, host, action) {
19
+ const prefix = `${label} ${host} uses remote plaintext HTTP`;
20
+ const guidance = "Plaintext HTTP can expose ingest tokens and telemetry. Use HTTPS for remote hosts";
21
+ if (action === "warn") {
22
+ return `${prefix}. ${guidance}; --insecure should only be used for trusted non-production test endpoints.`;
23
+ }
24
+ return `${prefix}. ${guidance}, or pass --insecure only for a trusted non-production test endpoint.`;
25
+ }
26
+ export function evaluateTransportSecurity(rawUrl, options) {
27
+ let parsed;
28
+ try {
29
+ parsed = new URL(rawUrl);
30
+ }
31
+ catch {
32
+ return { ok: false, error: `${options.label} must be a valid URL.` };
33
+ }
34
+ if (parsed.protocol === "https:") {
35
+ return { ok: true };
36
+ }
37
+ if (parsed.protocol !== "http:") {
38
+ return { ok: false, error: `${options.label} must use HTTPS, or HTTP for localhost/loopback local development.` };
39
+ }
40
+ if (isLoopbackHost(parsed.hostname)) {
41
+ return { ok: true };
42
+ }
43
+ if (options.allowInsecureHttp) {
44
+ return { ok: true, warning: plaintextMessage(options.label, parsed.host, "warn") };
45
+ }
46
+ return { ok: false, error: plaintextMessage(options.label, parsed.host, "reject") };
47
+ }
package/dist/pricing.d.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Model pricing table and cost calculation for @aixle/insights.
3
3
  *
4
4
  * Default rates source: https://platform.claude.com/docs/en/about-claude/pricing
5
- * Rates last verified: 2026-05-04
5
+ * Rates last verified: 2026-06-17
6
6
  */
7
7
  export interface ModelPricing {
8
8
  input_per_mtok: number;
@@ -32,6 +32,14 @@ export declare const DEFAULT_PRICING: PricingTable;
32
32
  * - Returns a new table object so mutations never affect DEFAULT_PRICING.
33
33
  */
34
34
  export declare function mergePricing(base: PricingTable, overrides: PricingTable): PricingTable;
35
+ /**
36
+ * Strips a trailing -YYYYMMDD date suffix to produce the bare model key used
37
+ * in the pricing table. Returns the original ID when:
38
+ * - it has no date suffix, OR
39
+ * - the bare form is not in the table (i.e. the dated form IS the canonical key,
40
+ * like legacy "claude-3-5-sonnet-20241022").
41
+ */
42
+ export declare function normalizeModelId(model: string, pricing: PricingTable): string;
35
43
  export declare function calculateCost(model: string | null, baseInputTokens: number, outputTokens: number, cacheWriteTokens: number, cacheReadTokens: number, pricing: PricingTable): number | null;
36
44
  /**
37
45
  * Returns a human-readable warning string explaining why cost could not be
package/dist/pricing.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * Model pricing table and cost calculation for @aixle/insights.
3
3
  *
4
4
  * Default rates source: https://platform.claude.com/docs/en/about-claude/pricing
5
- * Rates last verified: 2026-05-04
5
+ * Rates last verified: 2026-06-17
6
6
  */
7
7
  /**
8
8
  * Default pricing table (USD per million tokens).
@@ -14,7 +14,20 @@
14
14
  * breakdown is a future improvement.
15
15
  */
16
16
  export const DEFAULT_PRICING = {
17
- // Opus 4.7/4.6/4.5 — $5 input / $25 output
17
+ // Fable 5 — $10 input / $50 output
18
+ "claude-fable-5": {
19
+ input_per_mtok: 10.0,
20
+ output_per_mtok: 50.0,
21
+ cache_write_per_mtok: 12.5,
22
+ cache_read_per_mtok: 1.0,
23
+ },
24
+ // Opus 4.8/4.7/4.6/4.5 — $5 input / $25 output
25
+ "claude-opus-4-8": {
26
+ input_per_mtok: 5.0,
27
+ output_per_mtok: 25.0,
28
+ cache_write_per_mtok: 6.25,
29
+ cache_read_per_mtok: 0.5,
30
+ },
18
31
  "claude-opus-4-7": {
19
32
  input_per_mtok: 5.0,
20
33
  output_per_mtok: 25.0,
@@ -47,26 +60,26 @@ export const DEFAULT_PRICING = {
47
60
  cache_read_per_mtok: 1.5,
48
61
  },
49
62
  // Sonnet 4.x family — $3 input / $15 output
50
- "claude-sonnet-4": {
63
+ "claude-sonnet-4-6": {
51
64
  input_per_mtok: 3.0,
52
65
  output_per_mtok: 15.0,
53
66
  cache_write_per_mtok: 3.75,
54
67
  cache_read_per_mtok: 0.3,
55
68
  },
56
- "claude-sonnet-4-6": {
69
+ "claude-sonnet-4-5": {
57
70
  input_per_mtok: 3.0,
58
71
  output_per_mtok: 15.0,
59
72
  cache_write_per_mtok: 3.75,
60
73
  cache_read_per_mtok: 0.3,
61
74
  },
62
- "claude-sonnet-4-5-20251001": {
75
+ "claude-sonnet-4": {
63
76
  input_per_mtok: 3.0,
64
77
  output_per_mtok: 15.0,
65
78
  cache_write_per_mtok: 3.75,
66
79
  cache_read_per_mtok: 0.3,
67
80
  },
68
81
  // Haiku 4.5 — $1 input / $5 output
69
- "claude-haiku-4-5-20251001": {
82
+ "claude-haiku-4-5": {
70
83
  input_per_mtok: 1.0,
71
84
  output_per_mtok: 5.0,
72
85
  cache_write_per_mtok: 1.25,
@@ -110,6 +123,22 @@ export function mergePricing(base, overrides) {
110
123
  }
111
124
  return result;
112
125
  }
126
+ /**
127
+ * Strips a trailing -YYYYMMDD date suffix to produce the bare model key used
128
+ * in the pricing table. Returns the original ID when:
129
+ * - it has no date suffix, OR
130
+ * - the bare form is not in the table (i.e. the dated form IS the canonical key,
131
+ * like legacy "claude-3-5-sonnet-20241022").
132
+ */
133
+ export function normalizeModelId(model, pricing) {
134
+ if (pricing[model])
135
+ return model;
136
+ const match = model.match(/^(.+)-(\d{8})$/);
137
+ if (!match)
138
+ return model;
139
+ const bare = match[1];
140
+ return pricing[bare] ? bare : model;
141
+ }
113
142
  /** Returns true only when all four rate fields are finite numbers. */
114
143
  function hasValidRates(rates) {
115
144
  return (Number.isFinite(rates.input_per_mtok) &&
@@ -120,7 +149,8 @@ function hasValidRates(rates) {
120
149
  export function calculateCost(model, baseInputTokens, outputTokens, cacheWriteTokens, cacheReadTokens, pricing) {
121
150
  if (!model)
122
151
  return null;
123
- const rates = pricing[model];
152
+ const resolved = normalizeModelId(model, pricing);
153
+ const rates = pricing[resolved];
124
154
  if (!rates || !hasValidRates(rates))
125
155
  return null;
126
156
  const raw = (baseInputTokens * rates.input_per_mtok +
@@ -138,7 +168,8 @@ export function calculateCost(model, baseInputTokens, outputTokens, cacheWriteTo
138
168
  export function getCostWarning(model, pricing) {
139
169
  if (!model)
140
170
  return null;
141
- const rates = pricing[model];
171
+ const resolved = normalizeModelId(model, pricing);
172
+ const rates = pricing[resolved];
142
173
  if (!rates) {
143
174
  return `Model "${model}" not in pricing table — cost_usd will be null. Extend DEFAULT_PRICING or add future pricing overrides when supported.`;
144
175
  }
@@ -304,12 +304,12 @@ export function mapTranscriptTurn(turn, options) {
304
304
  };
305
305
  if (turn.model)
306
306
  payload.model = turn.model;
307
- if (turn.tokensIn > 0)
308
- payload.tokens_in = turn.tokensIn;
307
+ if (baseInputTokens > 0)
308
+ payload.tokens_in = baseInputTokens;
309
309
  if (turn.tokensOut > 0)
310
310
  payload.tokens_out = turn.tokensOut;
311
- if (turn.tokensIn > 0 || turn.tokensOut > 0) {
312
- payload.tokens_total = turn.tokensIn + turn.tokensOut;
311
+ if (baseInputTokens > 0 || turn.tokensOut > 0) {
312
+ payload.tokens_total = baseInputTokens + turn.tokensOut;
313
313
  }
314
314
  if (projectId)
315
315
  payload.project_id = projectId;
@@ -0,0 +1,23 @@
1
+ import Database from "better-sqlite3";
2
+ export type CursorSqliteOpenFailureReason = "missing" | "outside_root" | "native_binding" | "unknown";
3
+ export type CursorSqliteOpenResult = {
4
+ ok: true;
5
+ db: Database.Database;
6
+ } | {
7
+ ok: false;
8
+ reason: CursorSqliteOpenFailureReason;
9
+ message: string;
10
+ };
11
+ export type CursorSqlitePathResult = {
12
+ ok: true;
13
+ path: string;
14
+ } | {
15
+ ok: false;
16
+ reason: CursorSqliteOpenFailureReason;
17
+ message: string;
18
+ };
19
+ export interface CursorSqliteOpenOptions {
20
+ rootDir?: string;
21
+ }
22
+ export declare function openCursorSqliteReadonly(dbPath: string, options?: CursorSqliteOpenOptions): CursorSqliteOpenResult;
23
+ export declare function resolveCursorSqlitePath(dbPath: string, options?: CursorSqliteOpenOptions): CursorSqlitePathResult;
@@ -0,0 +1,68 @@
1
+ import { existsSync, realpathSync } from "node:fs";
2
+ import { resolve, sep } from "node:path";
3
+ import Database from "better-sqlite3";
4
+ function validatedRealPathWithinRoot(path, rootDir) {
5
+ const realPath = realpathSync(path);
6
+ const realRoot = realpathSync(rootDir);
7
+ if (realPath === realRoot)
8
+ return realPath;
9
+ const rootWithSep = realRoot.endsWith(sep) ? realRoot : `${realRoot}${sep}`;
10
+ return realPath.startsWith(rootWithSep) ? realPath : null;
11
+ }
12
+ function isNativeBindingError(message) {
13
+ return (message.includes("Could not locate the bindings file") ||
14
+ message.includes("better_sqlite3.node") ||
15
+ message.includes("dlopen"));
16
+ }
17
+ export function openCursorSqliteReadonly(dbPath, options = {}) {
18
+ const resolved = resolveCursorSqlitePath(dbPath, options);
19
+ if (!resolved.ok)
20
+ return resolved;
21
+ try {
22
+ const db = new Database(resolved.path, { readonly: true, fileMustExist: true });
23
+ return { ok: true, db };
24
+ }
25
+ catch (err) {
26
+ const message = err instanceof Error ? err.message : String(err);
27
+ return {
28
+ ok: false,
29
+ reason: isNativeBindingError(message) ? "native_binding" : "unknown",
30
+ message,
31
+ };
32
+ }
33
+ }
34
+ export function resolveCursorSqlitePath(dbPath, options = {}) {
35
+ const normalizedPath = resolve(dbPath);
36
+ if (!existsSync(normalizedPath)) {
37
+ return {
38
+ ok: false,
39
+ reason: "missing",
40
+ message: `Database file does not exist: ${normalizedPath}`,
41
+ };
42
+ }
43
+ const { rootDir } = options;
44
+ let pathToOpen = normalizedPath;
45
+ if (rootDir) {
46
+ const normalizedRoot = resolve(rootDir);
47
+ try {
48
+ const realPath = validatedRealPathWithinRoot(normalizedPath, normalizedRoot);
49
+ if (realPath === null) {
50
+ return {
51
+ ok: false,
52
+ reason: "outside_root",
53
+ message: `Database path escapes root: ${normalizedPath}`,
54
+ };
55
+ }
56
+ pathToOpen = realPath;
57
+ }
58
+ catch (err) {
59
+ const message = err instanceof Error ? err.message : String(err);
60
+ return {
61
+ ok: false,
62
+ reason: "unknown",
63
+ message,
64
+ };
65
+ }
66
+ }
67
+ return { ok: true, path: pathToOpen };
68
+ }
@@ -2,7 +2,7 @@ import type { IngestPayload } from "../lib/index.js";
2
2
  import { type RiskLevel } from "../risk-scanner.js";
3
3
  export declare function cursorUserDir(): string;
4
4
  /** Smoke-test better-sqlite3 against the global Cursor state DB (CUR-V02 / verify scripts). */
5
- export declare function probeCursorGlobalStateDb(verbose?: boolean): boolean;
5
+ export declare function probeCursorGlobalStateDb(verbose?: boolean, baseDir?: string): boolean;
6
6
  export declare function findCursorDbs(baseDir?: string): string[];
7
7
  export interface CursorRow {
8
8
  requestId?: string | null;
@@ -10,8 +10,8 @@ import { basename, dirname, join } from "node:path";
10
10
  import { fileURLToPath } from "node:url";
11
11
  import { homedir } from "node:os";
12
12
  import { glob } from "glob";
13
- import Database from "better-sqlite3";
14
13
  import { scanText } from "../risk-scanner.js";
14
+ import { openCursorSqliteReadonly } from "./cursor-sqlite.js";
15
15
  // ─── Reader: paths & SQLite ──────────────────────────────────────────────────
16
16
  export function cursorUserDir() {
17
17
  switch (process.platform) {
@@ -43,14 +43,19 @@ function logDbTables(db, dbPath, label) {
43
43
  console.log(` tables: ${tables.join(", ") || "(none)"}`);
44
44
  }
45
45
  /** Smoke-test better-sqlite3 against the global Cursor state DB (CUR-V02 / verify scripts). */
46
- export function probeCursorGlobalStateDb(verbose = false) {
47
- const dbPath = join(cursorUserDir(), "globalStorage", "state.vscdb");
46
+ export function probeCursorGlobalStateDb(verbose = false, baseDir) {
47
+ const userDir = baseDir ?? cursorUserDir();
48
+ const dbPath = join(userDir, "globalStorage", "state.vscdb");
49
+ const opened = openCursorSqliteReadonly(dbPath, { rootDir: userDir });
50
+ if (!opened.ok) {
51
+ console.error(` [probe] failed to read ${dbPath}: ${opened.message}`);
52
+ return false;
53
+ }
54
+ const db = opened.db;
48
55
  try {
49
- const db = new Database(dbPath, { readonly: true });
50
56
  const row = db
51
57
  .prepare(`SELECT count(*) AS c FROM ${STATE_TABLE} WHERE key LIKE 'aiCodeTracking.dailyStats%'`)
52
58
  .get();
53
- db.close();
54
59
  if (verbose) {
55
60
  console.log(` [probe] global state.vscdb OK — ${row.c} dailyStats key(s)`);
56
61
  }
@@ -61,6 +66,9 @@ export function probeCursorGlobalStateDb(verbose = false) {
61
66
  console.error(` [probe] failed to read ${dbPath}: ${msg}`);
62
67
  return false;
63
68
  }
69
+ finally {
70
+ db.close();
71
+ }
64
72
  }
65
73
  // ─── Legacy: cursor.db / CursorRequestFeedback ─────────────────────────────────
66
74
  export function findCursorDbs(baseDir) {
@@ -72,10 +80,13 @@ export function findCursorDbs(baseDir) {
72
80
  return [];
73
81
  }
74
82
  }
75
- function readLegacyFromDb(dbPath, since, workspacePath, verbose) {
83
+ function readLegacyFromDb(dbPath, since, workspacePath, rootDir, verbose) {
76
84
  let db = null;
77
85
  try {
78
- db = new Database(dbPath, { readonly: true });
86
+ const opened = openCursorSqliteReadonly(dbPath, { rootDir });
87
+ if (!opened.ok)
88
+ return [];
89
+ db = opened.db;
79
90
  if (verbose)
80
91
  logDbTables(db, dbPath, "cursor.db");
81
92
  if (!tableExists(db, LEGACY_TABLE))
@@ -105,13 +116,14 @@ function readLegacyFromDb(dbPath, since, workspacePath, verbose) {
105
116
  }
106
117
  }
107
118
  export function readLegacyEvents(since, baseDir, verbose = false) {
119
+ const rootDir = baseDir ?? cursorUserDir();
108
120
  const dbPaths = findCursorDbs(baseDir);
109
121
  if (verbose)
110
122
  console.log(`Found ${dbPaths.length} legacy cursor.db file(s)`);
111
123
  const results = [];
112
124
  for (const dbPath of dbPaths) {
113
125
  const workspacePath = dbPath.replace(/[\\/]cursor\.db$/, "");
114
- for (const row of readLegacyFromDb(dbPath, since, workspacePath, verbose)) {
126
+ for (const row of readLegacyFromDb(dbPath, since, workspacePath, rootDir, verbose)) {
115
127
  results.push({ row, workspacePath });
116
128
  }
117
129
  }
@@ -239,10 +251,13 @@ export function findStateVscDbs(baseDir) {
239
251
  }
240
252
  return results;
241
253
  }
242
- function readDailyStatsFromDb(dbPath, since, verbose) {
254
+ function readDailyStatsFromDb(dbPath, since, rootDir, verbose) {
243
255
  let db = null;
244
256
  try {
245
- db = new Database(dbPath, { readonly: true });
257
+ const opened = openCursorSqliteReadonly(dbPath, { rootDir });
258
+ if (!opened.ok)
259
+ return [];
260
+ db = opened.db;
246
261
  if (verbose)
247
262
  logDbTables(db, dbPath, "state.vscdb");
248
263
  if (!tableExists(db, STATE_TABLE))
@@ -264,7 +279,7 @@ function readDailyStatsFromDb(dbPath, since, verbose) {
264
279
  if (!dateMatch)
265
280
  continue;
266
281
  const date = dateMatch[1];
267
- if (since && date <= since.toISOString().slice(0, 10))
282
+ if (since && date < since.toISOString().slice(0, 10))
268
283
  continue;
269
284
  let parsed;
270
285
  try {
@@ -285,6 +300,7 @@ function readDailyStatsFromDb(dbPath, since, verbose) {
285
300
  }
286
301
  }
287
302
  function readDailyStatsRaw(since, baseDir, verbose) {
303
+ const rootDir = baseDir ?? cursorUserDir();
288
304
  const dbPaths = findStateVscDbs(baseDir);
289
305
  if (verbose) {
290
306
  console.log(`Searching: ${baseDir ?? cursorUserDir()}`);
@@ -292,7 +308,7 @@ function readDailyStatsRaw(since, baseDir, verbose) {
292
308
  }
293
309
  const raw = [];
294
310
  for (const dbPath of dbPaths) {
295
- raw.push(...readDailyStatsFromDb(dbPath, since, verbose));
311
+ raw.push(...readDailyStatsFromDb(dbPath, since, rootDir, verbose));
296
312
  }
297
313
  return raw;
298
314
  }
@@ -339,10 +355,13 @@ function dedupeRecentCommitSnapshots(entries) {
339
355
  }
340
356
  return [...byKey.values()];
341
357
  }
342
- function readRecentCommitFromDb(dbPath, since, verbose) {
358
+ function readRecentCommitFromDb(dbPath, since, rootDir, verbose) {
343
359
  let db = null;
344
360
  try {
345
- db = new Database(dbPath, { readonly: true });
361
+ const opened = openCursorSqliteReadonly(dbPath, { rootDir });
362
+ if (!opened.ok)
363
+ return [];
364
+ db = opened.db;
346
365
  if (!tableExists(db, STATE_TABLE))
347
366
  return [];
348
367
  const row = db
@@ -384,13 +403,14 @@ function readRecentCommitFromDb(dbPath, since, verbose) {
384
403
  }
385
404
  }
386
405
  export function readRecentCommitSnapshots(since, baseDir, verbose = false) {
406
+ const rootDir = baseDir ?? cursorUserDir();
387
407
  const dbPaths = findStateVscDbs(baseDir);
388
408
  if (verbose) {
389
409
  console.log(`Searching recentCommit: ${baseDir ?? cursorUserDir()}`);
390
410
  }
391
411
  const found = [];
392
412
  for (const dbPath of dbPaths) {
393
- found.push(...readRecentCommitFromDb(dbPath, since, verbose));
413
+ found.push(...readRecentCommitFromDb(dbPath, since, rootDir, verbose));
394
414
  }
395
415
  return dedupeRecentCommitSnapshots(found);
396
416
  }
@@ -432,7 +452,10 @@ function readComposerHeaders(baseDir) {
432
452
  const dbPath = join(userDir, "globalStorage", "state.vscdb");
433
453
  let db = null;
434
454
  try {
435
- db = new Database(dbPath, { readonly: true });
455
+ const opened = openCursorSqliteReadonly(dbPath, { rootDir: userDir });
456
+ if (!opened.ok)
457
+ return new Map();
458
+ db = opened.db;
436
459
  if (!tableExists(db, STATE_TABLE))
437
460
  return new Map();
438
461
  const row = db
@@ -702,8 +725,12 @@ function pick(obj, ...keys) {
702
725
  }
703
726
  return typeof cur === "number" ? cur : null;
704
727
  }
728
+ function dailyStatsSessionId(date, eventType, modelKey) {
729
+ const suffix = modelKey ? `${eventType}:${modelKey}` : eventType;
730
+ return `cursor:daily_stats:${date}:${suffix}`;
731
+ }
705
732
  function buildPayload(opts) {
706
- const { eventType, tokensIn, tokensOut, costUsd, occurredAt, dbPath, model = "unknown", projectId, costModel = LINE_COST_MODEL, } = opts;
733
+ const { eventType, tokensIn, tokensOut, costUsd, occurredAt, dbPath, date, model = "unknown", modelKey, projectId, costModel = LINE_COST_MODEL, } = opts;
707
734
  const payload = {
708
735
  tool_name: "cursor",
709
736
  event_type: eventType,
@@ -713,6 +740,7 @@ function buildPayload(opts) {
713
740
  cost_usd: costUsd,
714
741
  occurred_at: occurredAt,
715
742
  metadata: {
743
+ session_id: dailyStatsSessionId(date, eventType, modelKey),
716
744
  cursor_session_id: null,
717
745
  ...cursorWorkspaceMetadata(dbPath),
718
746
  cost_model: costModel,
@@ -740,9 +768,10 @@ export function mapDailyStats(entry, projectId, pricing = DEFAULT_CURSOR_PRICING
740
768
  eventType: "completion",
741
769
  tokensIn: tabSuggested,
742
770
  tokensOut: tabAccepted,
743
- costUsd: computeLineCost("completion", tabSuggested, pricing),
771
+ costUsd: computeLineCost("completion", tabAccepted, pricing),
744
772
  occurredAt,
745
773
  dbPath,
774
+ date,
746
775
  model,
747
776
  projectId,
748
777
  }));
@@ -752,9 +781,10 @@ export function mapDailyStats(entry, projectId, pricing = DEFAULT_CURSOR_PRICING
752
781
  eventType: "chat",
753
782
  tokensIn: composerSuggested,
754
783
  tokensOut: composerAccepted,
755
- costUsd: computeLineCost("chat", composerSuggested, pricing),
784
+ costUsd: computeLineCost("chat", composerAccepted, pricing),
756
785
  occurredAt,
757
786
  dbPath,
787
+ date,
758
788
  model,
759
789
  projectId,
760
790
  }));
@@ -776,7 +806,9 @@ export function mapDailyStats(entry, projectId, pricing = DEFAULT_CURSOR_PRICING
776
806
  costUsd: computeTokenCost("chat", tokensIn, tokensOut, pricing),
777
807
  occurredAt,
778
808
  dbPath,
809
+ date,
779
810
  model,
811
+ modelKey: model,
780
812
  projectId,
781
813
  costModel: TOKEN_COST_MODEL,
782
814
  }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aixle/insights",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "stdio MCP server for AI coding-assistant telemetry — Claude transcript sync + Cursor SQLite ingest.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -19,7 +19,7 @@
19
19
  "provenance": false
20
20
  },
21
21
  "engines": {
22
- "node": ">=20"
22
+ "node": ">=20.19.0"
23
23
  },
24
24
  "license": "MIT",
25
25
  "repository": {
@@ -62,6 +62,6 @@
62
62
  "@types/node": "^24",
63
63
  "tsx": "^4.7.0",
64
64
  "typescript": "^5.3.3",
65
- "vitest": "^1.2.2"
65
+ "vitest": "^4.1.0"
66
66
  }
67
67
  }