@aixle/insights 0.1.0 → 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 +123 -0
- package/dist/auth/flow.d.ts +2 -0
- package/dist/auth/flow.js +11 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +20 -2
- package/dist/cursor-payload-contract.js +1 -0
- package/dist/cursor-store-audit.d.ts +2 -2
- package/dist/cursor-store-audit.js +22 -11
- package/dist/daily-stats-versions.d.ts +3 -1
- package/dist/daily-stats-versions.js +6 -7
- package/dist/lib/transport-security.d.ts +12 -0
- package/dist/lib/transport-security.js +47 -0
- package/dist/pricing.d.ts +9 -1
- package/dist/pricing.js +39 -8
- package/dist/readers/claude.js +4 -4
- package/dist/readers/cursor-sqlite.d.ts +23 -0
- package/dist/readers/cursor-sqlite.js +68 -0
- package/dist/readers/cursor.d.ts +1 -1
- package/dist/readers/cursor.js +51 -19
- package/package.json +7 -6
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
|
|
package/dist/auth/flow.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
},
|
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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-
|
|
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-
|
|
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
|
-
//
|
|
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-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
}
|
package/dist/readers/claude.js
CHANGED
|
@@ -304,12 +304,12 @@ export function mapTranscriptTurn(turn, options) {
|
|
|
304
304
|
};
|
|
305
305
|
if (turn.model)
|
|
306
306
|
payload.model = turn.model;
|
|
307
|
-
if (
|
|
308
|
-
payload.tokens_in =
|
|
307
|
+
if (baseInputTokens > 0)
|
|
308
|
+
payload.tokens_in = baseInputTokens;
|
|
309
309
|
if (turn.tokensOut > 0)
|
|
310
310
|
payload.tokens_out = turn.tokensOut;
|
|
311
|
-
if (
|
|
312
|
-
payload.tokens_total =
|
|
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
|
+
}
|
package/dist/readers/cursor.d.ts
CHANGED
|
@@ -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;
|
package/dist/readers/cursor.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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",
|
|
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",
|
|
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,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aixle/insights",
|
|
3
|
-
"version": "0.
|
|
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": {
|
|
7
|
-
"aixle-insights": "
|
|
7
|
+
"aixle-insights": "dist/cli.js"
|
|
8
8
|
},
|
|
9
9
|
"exports": {
|
|
10
10
|
".": "./dist/cli.js"
|
|
@@ -15,15 +15,16 @@
|
|
|
15
15
|
"LICENSE"
|
|
16
16
|
],
|
|
17
17
|
"publishConfig": {
|
|
18
|
-
"access": "public"
|
|
18
|
+
"access": "public",
|
|
19
|
+
"provenance": false
|
|
19
20
|
},
|
|
20
21
|
"engines": {
|
|
21
|
-
"node": ">=20"
|
|
22
|
+
"node": ">=20.19.0"
|
|
22
23
|
},
|
|
23
24
|
"license": "MIT",
|
|
24
25
|
"repository": {
|
|
25
26
|
"type": "git",
|
|
26
|
-
"url": "https://github.com/dualboot-partners/db90-rails.git",
|
|
27
|
+
"url": "git+https://github.com/dualboot-partners/db90-rails.git",
|
|
27
28
|
"directory": "packages/tools/aixle-insights"
|
|
28
29
|
},
|
|
29
30
|
"homepage": "https://github.com/dualboot-partners/db90-rails/tree/develop/packages/tools/aixle-insights#readme",
|
|
@@ -61,6 +62,6 @@
|
|
|
61
62
|
"@types/node": "^24",
|
|
62
63
|
"tsx": "^4.7.0",
|
|
63
64
|
"typescript": "^5.3.3",
|
|
64
|
-
"vitest": "^1.
|
|
65
|
+
"vitest": "^4.1.0"
|
|
65
66
|
}
|
|
66
67
|
}
|