@aixle/insights 0.2.6-staging → 0.2.8-staging
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 +188 -24
- package/dist/auth/credentials.js +6 -1
- package/dist/auth/exchange.js +6 -6
- package/dist/auth/flow.js +1 -1
- package/dist/auth/keycloak.js +18 -4
- package/dist/cli.js +25 -14
- package/dist/install/claude.js +6 -1
- package/dist/lib/client.js +1 -1
- package/dist/lib/env.d.ts +15 -0
- package/dist/lib/env.js +18 -0
- package/dist/lib/project-resolver.d.ts +1 -1
- package/dist/lib/project-resolver.js +1 -1
- package/dist/readers/claude.d.ts +7 -0
- package/dist/readers/claude.js +25 -1
- package/dist/readers/cursor.js +6 -0
- package/dist/server.js +14 -3
- package/dist/sync.js +7 -2
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ Two channels are published. Pick one deliberately — they are **not** interchan
|
|
|
13
13
|
| | Production | Staging (QA) |
|
|
14
14
|
|---|---|---|
|
|
15
15
|
| Install | `npm i -g @aixle/insights` | `npm i -g @aixle/insights@staging` |
|
|
16
|
-
| Version looks like | `0.2.
|
|
16
|
+
| Version looks like | `0.2.1` | `0.2.6-staging` |
|
|
17
17
|
| Points at | the production API | the staging API |
|
|
18
18
|
| Who should use it | **everyone** | QA validating unreleased work |
|
|
19
19
|
| Stability | released, supported | may change or break without notice |
|
|
@@ -28,7 +28,7 @@ npx -y @aixle/insights init \
|
|
|
28
28
|
|
|
29
29
|
# Or global install:
|
|
30
30
|
npm i -g @aixle/insights
|
|
31
|
-
aixle
|
|
31
|
+
npm ls -g @aixle/insights # e.g. @aixle/insights@0.2.1 (no suffix)
|
|
32
32
|
```
|
|
33
33
|
|
|
34
34
|
### Staging — QA only
|
|
@@ -44,7 +44,7 @@ npx -y @aixle/insights@staging init \
|
|
|
44
44
|
|
|
45
45
|
# Or global install:
|
|
46
46
|
npm i -g @aixle/insights@staging
|
|
47
|
-
aixle
|
|
47
|
+
npm ls -g @aixle/insights # e.g. @aixle/insights@0.2.6-staging (note the suffix)
|
|
48
48
|
```
|
|
49
49
|
|
|
50
50
|
Point a staging build at the **staging** API host. Sending staging telemetry to production
|
|
@@ -53,14 +53,14 @@ pollutes production analytics.
|
|
|
53
53
|
### Which one do I have?
|
|
54
54
|
|
|
55
55
|
```bash
|
|
56
|
-
aixle
|
|
56
|
+
npm ls -g @aixle/insights # a -staging suffix means a QA build
|
|
57
57
|
npm view @aixle/insights dist-tags # what each channel currently resolves to
|
|
58
58
|
```
|
|
59
59
|
|
|
60
60
|
Expected output — `latest` and `staging` move independently:
|
|
61
61
|
|
|
62
62
|
```
|
|
63
|
-
{ latest: '0.2.
|
|
63
|
+
{ latest: '0.2.1', staging: '0.2.6-staging' }
|
|
64
64
|
```
|
|
65
65
|
|
|
66
66
|
### Switching back to production
|
|
@@ -73,8 +73,8 @@ Then re-run `init` against the production host, since credentials and the MCP en
|
|
|
73
73
|
per-host.
|
|
74
74
|
|
|
75
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.
|
|
77
|
-
> and `>=0.1.0` all select `0.2.
|
|
76
|
+
> prereleases, and no ordinary version range resolves to a prerelease. `*`, `^0.2.1`, `~0.2.1`
|
|
77
|
+
> and `>=0.1.0` all select `0.2.1` even when `0.2.6-staging` exists. Staging builds are
|
|
78
78
|
> reachable only by exact version or the `staging` dist-tag.
|
|
79
79
|
|
|
80
80
|
Maintainers: see [`../RELEASING.md`](../RELEASING.md) for how each channel is cut.
|
|
@@ -131,12 +131,51 @@ npx -y @aixle/insights init \
|
|
|
131
131
|
--organization-id <uuid>
|
|
132
132
|
```
|
|
133
133
|
|
|
134
|
-
You can also set `
|
|
134
|
+
You can also set `AIXLE_INSIGHTS_ORGANIZATION_ID=<uuid>` (or the deprecated `DB90_ORGANIZATION_ID`) in your shell environment, or pin it via `mcpServers.aixle-insights.env` in `~/.claude.json`. The CLI flag overrides the env var when both are set.
|
|
135
135
|
|
|
136
136
|
Once `init` succeeds it reports the bound org (`Credentials saved (organization <uuid>).`), and `aixle-insights health` (or the `aixle_insights_status` MCP tool) shows the bound `organization_id`.
|
|
137
137
|
|
|
138
138
|
> **Preferences caveat — this is non-obvious.** The web app's "current org" (the one you appear to be viewing) is normally the **last-used** org, remembered in your browser's `localStorage`. That is **not** the same as your **Default Organization** preference, which is what `init` reads. A multi-org user who has switched orgs in the UI but never explicitly set **Default Organization** in web Preferences has no server-side preference for `init` to use — so `init` will hit the org-selection error above until you either set the Default Organization preference or pass `--organization-id`.
|
|
139
139
|
|
|
140
|
+
## Cursor hooks (optional)
|
|
141
|
+
|
|
142
|
+
**Claude Code needs nothing beyond `init`** — the MCP transcript reader already captures model, token counts (including cache tokens), prompt and assistant text, risk scan, and tool uses.
|
|
143
|
+
|
|
144
|
+
Cursor is different: its local store does not reliably record *which model* answered a turn, so events can land with `model: "unknown"`. Two things address it — the automatic `state.vscdb` fallback (DB90DV-540), and this opt-in hook forwarder for per-turn attribution captured as it happens:
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
npx -y @aixle/insights init --hooks --tool-name cursor --host <host> --keycloak-url <realm>
|
|
148
|
+
|
|
149
|
+
aixle-insights verify-hooks # JSON: installed? queue depth?
|
|
150
|
+
aixle-insights uninstall-hooks # remove, restoring the ~/.cursor/hooks.json backup
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
**Restart Cursor afterwards** — hooks are read at launch. The forwarder appends redacted payloads to `~/.aixle-insights/hooks-queue.ndjson`, which the next sync drains, so events arrive on the normal cycle rather than instantly.
|
|
154
|
+
|
|
155
|
+
> Editor-level hooks (`~/.claude/settings.json` `PostToolUse` / `Stop`) are a **fallback for environments that cannot run an MCP server**, not an upgrade. They carry no model, token, or cost data, so the MCP path is strictly better wherever it is available.
|
|
156
|
+
|
|
157
|
+
## Ingest tokens and rotation
|
|
158
|
+
|
|
159
|
+
`init` mints an ingest token per tool — distinct from your Keycloak login — and stores it in the **OS keychain** (service `aixle-insights`), falling back to `~/.aixle-insights/credentials.json` at mode 0600 where no keychain is available. Tokens are `aixle_<64 hex>` and the server retains only a SHA-256 hash, so a token cannot be recovered after `init`; losing it means re-running `init`.
|
|
160
|
+
|
|
161
|
+
You never need to paste a token for the MCP path — `init` obtains its own.
|
|
162
|
+
|
|
163
|
+
> **Rotation policy: TBD.** There is no self-service rotation command and no documented expiry or cadence. Placeholders until an owner defines them:
|
|
164
|
+
>
|
|
165
|
+
> | | Placeholder |
|
|
166
|
+
> |---|---|
|
|
167
|
+
> | Token lifetime | _undefined — tokens do not self-expire today_ |
|
|
168
|
+
> | Rotation cadence | _TBD_ |
|
|
169
|
+
> | Who can revoke | _TBD — no CLI path; server-side only_ |
|
|
170
|
+
|
|
171
|
+
**If a token is revoked, rotated server-side, or invalidated by a redeploy**, every sync fails with `HTTP 401` while `health` still reports `authenticated: true` — that flag only covers the OIDC login. Clear the stored credential and re-run `init`; state files are preserved, so already-sent sessions stay deduped:
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
security delete-generic-password -s "aixle-insights" -a "aixle-insights-ingest-credential" # macOS
|
|
175
|
+
rm -f ~/.aixle-insights/credentials.json
|
|
176
|
+
# then re-run init
|
|
177
|
+
```
|
|
178
|
+
|
|
140
179
|
## First run and backfill
|
|
141
180
|
|
|
142
181
|
Before you run `init`, the MCP server is a deliberate no-op: it does not read, buffer, or send anything, and it does not create any state files. Nothing is lost during this window — Claude Code's transcripts and Cursor's local telemetry stores are unaffected by whether `@aixle/insights` is watching them.
|
|
@@ -145,6 +184,49 @@ The moment `init` succeeds, this package's per-credential state starts from empt
|
|
|
145
184
|
|
|
146
185
|
If the MCP has been installed but never connected, `aixle-insights health` (or the `status` MCP tool) reports `needs_init: true` with a human-readable `onboarding_message` explaining exactly this — install-but-uninitialized is not a state you need to worry about losing data in.
|
|
147
186
|
|
|
187
|
+
Once `init` succeeds it reports the bound org (`Credentials saved (organization <uuid>).`), and `aixle-insights health` (or the `aixle_insights_status` MCP tool) shows the bound `organization_id`.
|
|
188
|
+
|
|
189
|
+
> **Preferences caveat — this is non-obvious.** The web app's "current org" (the one you appear to be viewing) is normally the **last-used** org, remembered in your browser's `localStorage`. That is **not** the same as your **Default Organization** preference, which is what `init` reads. A multi-org user who has switched orgs in the UI but never explicitly set **Default Organization** in web Preferences has no server-side preference for `init` to use — so `init` will hit the org-selection error above until you either set the Default Organization preference or pass `--organization-id`.
|
|
190
|
+
|
|
191
|
+
## Cursor hooks (optional)
|
|
192
|
+
|
|
193
|
+
**Claude Code needs nothing beyond `init`** — the MCP transcript reader already captures model, token counts (including cache tokens), prompt and assistant text, risk scan, and tool uses.
|
|
194
|
+
|
|
195
|
+
Cursor is different: its local store does not reliably record *which model* answered a turn, so events can land with `model: "unknown"`. Two things address it — the automatic `state.vscdb` fallback (DB90DV-540), and this opt-in hook forwarder for per-turn attribution captured as it happens:
|
|
196
|
+
|
|
197
|
+
```bash
|
|
198
|
+
npx -y @aixle/insights init --hooks --tool-name cursor --host <host> --keycloak-url <realm>
|
|
199
|
+
|
|
200
|
+
aixle-insights verify-hooks # JSON: installed? queue depth?
|
|
201
|
+
aixle-insights uninstall-hooks # remove, restoring the ~/.cursor/hooks.json backup
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
**Restart Cursor afterwards** — hooks are read at launch. The forwarder appends redacted payloads to `~/.aixle-insights/hooks-queue.ndjson`, which the next sync drains, so events arrive on the normal cycle rather than instantly.
|
|
205
|
+
|
|
206
|
+
> Editor-level hooks (`~/.claude/settings.json` `PostToolUse` / `Stop`) are a **fallback for environments that cannot run an MCP server**, not an upgrade. They carry no model, token, or cost data, so the MCP path is strictly better wherever it is available.
|
|
207
|
+
|
|
208
|
+
## Ingest tokens and rotation
|
|
209
|
+
|
|
210
|
+
`init` mints an ingest token per tool — distinct from your Keycloak login — and stores it in the **OS keychain** (service `aixle-insights`), falling back to `~/.aixle-insights/credentials.json` at mode 0600 where no keychain is available. Tokens are `aixle_<64 hex>` and the server retains only a SHA-256 hash, so a token cannot be recovered after `init`; losing it means re-running `init`.
|
|
211
|
+
|
|
212
|
+
You never need to paste a token for the MCP path — `init` obtains its own.
|
|
213
|
+
|
|
214
|
+
> **Rotation policy: TBD.** There is no self-service rotation command and no documented expiry or cadence. Placeholders until an owner defines them:
|
|
215
|
+
>
|
|
216
|
+
> | | Placeholder |
|
|
217
|
+
> |---|---|
|
|
218
|
+
> | Token lifetime | _undefined — tokens do not self-expire today_ |
|
|
219
|
+
> | Rotation cadence | _TBD_ |
|
|
220
|
+
> | Who can revoke | _TBD — no CLI path; server-side only_ |
|
|
221
|
+
|
|
222
|
+
**If a token is revoked, rotated server-side, or invalidated by a redeploy**, every sync fails with `HTTP 401` while `health` still reports `authenticated: true` — that flag only covers the OIDC login. Clear the stored credential and re-run `init`; state files are preserved, so already-sent sessions stay deduped:
|
|
223
|
+
|
|
224
|
+
```bash
|
|
225
|
+
security delete-generic-password -s "aixle-insights" -a "aixle-insights-ingest-credential" # macOS
|
|
226
|
+
rm -f ~/.aixle-insights/credentials.json
|
|
227
|
+
# then re-run init
|
|
228
|
+
```
|
|
229
|
+
|
|
148
230
|
## Commands
|
|
149
231
|
|
|
150
232
|
| Command | What it does |
|
|
@@ -153,10 +235,13 @@ If the MCP has been installed but never connected, `aixle-insights health` (or t
|
|
|
153
235
|
| `aixle-insights run --once` | Perform one multi-tool sync, exit. Useful for cron / manual flushes. |
|
|
154
236
|
| `aixle-insights run --once --full` | Backfill: ignore Cursor watermarks and commit-hash dedupe. |
|
|
155
237
|
| `aixle-insights init` | Keycloak device login + persist credentials + merge `~/.claude.json` entry. |
|
|
156
|
-
| `aixle-insights init --host <url>` | Use
|
|
238
|
+
| `aixle-insights init --host <url>` | Use an Aixle Insights API origin for token exchange. Remote hosts must use HTTPS; `http://localhost` and loopback addresses are allowed for local development. |
|
|
157
239
|
| `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. |
|
|
158
240
|
| `aixle-insights init --hooks --tool-name cursor` | Also install the Cursor-side hook forwarder (opt-in; requires Cursor restart). |
|
|
241
|
+
| `aixle-insights init --force` | Re-run `init` even when credentials already exist — re-mints ingest tokens and re-writes the MCP entry. Use after changing host, org, or realm. |
|
|
242
|
+
| `aixle-insights init --tool-name <tool>` | Scope `init` to a single connector. **Omit it** so one login covers both Claude Code and Cursor. |
|
|
159
243
|
| `aixle-insights uninstall-hooks` | Remove the hook forwarder + restore `~/.cursor/hooks.json` backup. |
|
|
244
|
+
| `aixle-insights uninstall-cursor-mcp` | Remove (or restore from backup) only the aixle-insights entry in `~/.cursor/mcp.json`, leaving sibling MCP servers untouched. |
|
|
160
245
|
| `aixle-insights verify-hooks` | Print hooks install status + queue depth as JSON. |
|
|
161
246
|
| `aixle-insights health` | Multi-line diagnostic (credentials, sync, log path, state files). |
|
|
162
247
|
|
|
@@ -164,15 +249,20 @@ If the MCP has been installed but never connected, `aixle-insights health` (or t
|
|
|
164
249
|
|
|
165
250
|
| Variable | Purpose |
|
|
166
251
|
|---|---|
|
|
167
|
-
| `DB90_API_URL` | API origin for ingestion + MCP exchange (defaults to `http://localhost:3000`; `init --host` overrides). |
|
|
168
|
-
| `KEYCLOAK_ISSUER` / `DB90_KEYCLOAK_ISSUER` | Realm issuer URLs (preferred on servers + CI). |
|
|
169
|
-
| `DB90_KEYCLOAK_CLIENT_ID` | Defaults to `
|
|
170
|
-
| `DB90_ORGANIZATION_ID` | Optional UUID scoping `init` to that org membership (header `X-Organization-ID`). |
|
|
252
|
+
| `AIXLE_INSIGHTS_API_URL` (deprecated: `DB90_API_URL`) | API origin for ingestion + MCP exchange (defaults to `http://localhost:3000`; `init --host` overrides). |
|
|
253
|
+
| `KEYCLOAK_ISSUER` / `AIXLE_INSIGHTS_KEYCLOAK_ISSUER` (deprecated: `DB90_KEYCLOAK_ISSUER`) | Realm issuer URLs (`KEYCLOAK_ISSUER` is preferred on servers + CI). |
|
|
254
|
+
| `KEYCLOAK_CLIENT_ID` / `AIXLE_INSIGHTS_KEYCLOAK_CLIENT_ID` (deprecated: `DB90_KEYCLOAK_CLIENT_ID`) | Defaults to `aixle-insights-web`; must allow device authorization in Keycloak. |
|
|
255
|
+
| `AIXLE_INSIGHTS_ORGANIZATION_ID` (deprecated: `DB90_ORGANIZATION_ID`) | Optional UUID scoping `init` to that org membership (header `X-Organization-ID`). |
|
|
171
256
|
| `AIXLE_INSIGHTS_HOME` | Override the local state directory (defaults to `~/.aixle-insights/`). |
|
|
257
|
+
| `AIXLE_INSIGHTS_MCP_DISABLE_KEYTAR` (deprecated: `DB90_MCP_DISABLE_KEYTAR`) | Set to skip the OS keychain entirely and use the `credentials.json` fallback. Useful on headless Linux/CI/Docker where Secret Service is absent. |
|
|
258
|
+
| `AIXLE_INSIGHTS_MCP_USE_LOCAL_KEYCLOAK_DEFAULT` (deprecated: `DB90_MCP_USE_LOCAL_KEYCLOAK_DEFAULT`) | Default the Keycloak issuer to the local stack (`http://localhost:8080/realms/db90`) so `--keycloak-url` can be omitted during local development. |
|
|
259
|
+
| `AIXLE_INSIGHTS_CLAUDE_USER_CONFIG_PATH` (deprecated: `DB90_CLAUDE_USER_CONFIG_PATH`) | Override the path to `~/.claude.json` that `init` merges the MCP entry into. Primarily a test seam; also lets you target a non-default Claude Code profile. |
|
|
260
|
+
|
|
261
|
+
Also read, but not intended as user-facing configuration: `APPDATA` and `XDG_CONFIG_HOME` (platform config-directory discovery) and `NODE_ENV`.
|
|
172
262
|
|
|
173
263
|
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.
|
|
174
264
|
|
|
175
|
-
Note: the `DB90_*` variables above are
|
|
265
|
+
Note: the `DB90_*` variables above are deprecated aliases for the `AIXLE_INSIGHTS_*` names (branding rename, DB90DV-624). Both are honored indefinitely — the `AIXLE_INSIGHTS_*` name wins when both are set — but using a `DB90_*` name prints a one-line deprecation warning to stderr. There is no removal date yet; this table will be updated when one is set.
|
|
176
266
|
|
|
177
267
|
Optional `~/.aixle-insights/config.json` accepts Cursor line-cost overrides (per-model rates):
|
|
178
268
|
|
|
@@ -186,11 +276,81 @@ Optional `~/.aixle-insights/config.json` accepts Cursor line-cost overrides (per
|
|
|
186
276
|
}
|
|
187
277
|
```
|
|
188
278
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
`
|
|
192
|
-
|
|
193
|
-
|
|
279
|
+
### Configuration reference
|
|
280
|
+
|
|
281
|
+
`~/.aixle-insights/config.json` is optional — `init` does not create it. Every key is optional too.
|
|
282
|
+
|
|
283
|
+
| Key | Type | Purpose |
|
|
284
|
+
|---|---|---|
|
|
285
|
+
| `host` | string | Ingest API origin. Same value as `--host`. |
|
|
286
|
+
| `token` | string | Ingest token. Rarely needed — `init` stores tokens in the keychain instead, and this is the plaintext alternative. |
|
|
287
|
+
| `project_id` | string | Pin every event to one project instead of resolving from the git remote. |
|
|
288
|
+
| `cursor.line_costs.<model>` | `{ input_per_line, output_per_line }` | Per-model cost rates for Cursor line-based accounting. |
|
|
289
|
+
|
|
290
|
+
**Precedence:** CLI flag → environment variable → `config.json`. A flag always wins; the config file is the fallback of last resort. An absent `config.json` is the normal case and is silent. A file that is present but unusable — malformed JSON, or valid JSON that isn't an object, including a **top-level array** (a common mistake when writing per-model rates) — is ignored entirely: every override falls back to its default, and a `config_parse_failed` line is written to `mcp.log` rather than a crash. Nothing is printed to the terminal, so check the log if an override appears to have no effect.
|
|
291
|
+
|
|
292
|
+
Worked example — pin a project and override Cursor rates for two models:
|
|
293
|
+
|
|
294
|
+
```json
|
|
295
|
+
{
|
|
296
|
+
"host": "https://staging.insights.aixle.com",
|
|
297
|
+
"project_id": "3f6c1e28-9b4a-4c7f-8d21-5ac0e7b91f04",
|
|
298
|
+
"cursor": {
|
|
299
|
+
"line_costs": {
|
|
300
|
+
"claude-sonnet-4-5": { "input_per_line": 0.0002, "output_per_line": 0.0008 },
|
|
301
|
+
"gpt-4o": { "input_per_line": 0.0001, "output_per_line": 0.0004 }
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
## Clean slate — full uninstall / reset
|
|
308
|
+
|
|
309
|
+
Use this when a breaking change lands, when switching between the production and staging channels, or when you want to prove a problem is not stale local state. Steps are ordered least to most destructive; stop wherever your problem clears.
|
|
310
|
+
|
|
311
|
+
**1. Remove editor integration**
|
|
312
|
+
|
|
313
|
+
```bash
|
|
314
|
+
aixle-insights uninstall-hooks # Cursor hook forwarder + restore ~/.cursor/hooks.json backup
|
|
315
|
+
aixle-insights uninstall-cursor-mcp # remove only our entry from ~/.cursor/mcp.json
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
Then delete the `mcpServers.aixle-insights` entry from `~/.claude.json` by hand — `init` writes it and there is no command to remove it.
|
|
319
|
+
|
|
320
|
+
**2. Clear credentials** — forces a fresh `init` and re-mints ingest tokens:
|
|
321
|
+
|
|
322
|
+
```bash
|
|
323
|
+
security delete-generic-password -s "aixle-insights" -a "aixle-insights-ingest-credential" # macOS
|
|
324
|
+
rm -f ~/.aixle-insights/credentials.json
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
**3. Reset sync state** — ⚠️ **this is the destructive one.** State files hold the watermarks and dedupe checkpoints. Deleting them makes the next sync treat all local history as new, so you get a **full re-backfill**. Ingest upserts by `metadata.session_id`, so you should get updates rather than duplicates, but volume will spike:
|
|
328
|
+
|
|
329
|
+
```bash
|
|
330
|
+
rm -f ~/.aixle-insights/state-*.json
|
|
331
|
+
rm -f ~/.aixle-insights/state.lock # only if no `aixle-insights run` is alive — check with: pgrep -fa aixle-insights
|
|
332
|
+
rm -f ~/.aixle-insights/hooks-queue.ndjson # discards hook events not yet drained
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
**4. Nuclear** — remove everything the package created locally:
|
|
336
|
+
|
|
337
|
+
```bash
|
|
338
|
+
rm -rf ~/.aixle-insights/ # credentials, state, logs, hook queue
|
|
339
|
+
npm uninstall -g @aixle/insights
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
Nothing here touches your Claude Code transcripts or Cursor's own store — those belong to the editors and are only ever read. Events already delivered to the server are unaffected; this is purely local state.
|
|
343
|
+
|
|
344
|
+
| Artifact | Created by | Removed in step |
|
|
345
|
+
|---|---|---|
|
|
346
|
+
| `~/.aixle-insights/credentials.json` / keychain entry | `init` | 2 |
|
|
347
|
+
| `~/.aixle-insights/state-<host>-<hash>.json` | first sync | 3 |
|
|
348
|
+
| `~/.aixle-insights/state.lock` | `run` | 3 |
|
|
349
|
+
| `~/.aixle-insights/hooks-queue.ndjson` | hook forwarder | 3 |
|
|
350
|
+
| `~/.aixle-insights/mcp.log`, `mcp.log.1` | any run | 4 |
|
|
351
|
+
| `~/.cursor/hooks.json` entry | `init --hooks` | 1 |
|
|
352
|
+
| `~/.cursor/mcp.json` entry | `init` | 1 |
|
|
353
|
+
| `mcpServers.aixle-insights` in `~/.claude.json` | `init` | 1 (manual) |
|
|
194
354
|
|
|
195
355
|
## Security
|
|
196
356
|
|
|
@@ -241,7 +401,7 @@ reason. File contents, keychain payloads, and tokens are never logged.
|
|
|
241
401
|
## State + credentials
|
|
242
402
|
|
|
243
403
|
- **App home directory**: `~/.aixle-insights/` (override with `AIXLE_INSIGHTS_HOME`).
|
|
244
|
-
- **Credentials**: the OS keychain is the source of truth. On read, the keychain is consulted **first**; the file is only a fallback when the keychain is unavailable, empty, or explicitly disabled (`DB90_MCP_DISABLE_KEYTAR`). On write, credentials go to the keychain and the file is removed when the keychain write succeeds.
|
|
404
|
+
- **Credentials**: the OS keychain is the source of truth. On read, the keychain is consulted **first**; the file is only a fallback when the keychain is unavailable, empty, or explicitly disabled (`AIXLE_INSIGHTS_MCP_DISABLE_KEYTAR`, or the deprecated `DB90_MCP_DISABLE_KEYTAR`). On write, credentials go to the keychain and the file is removed when the keychain write succeeds.
|
|
245
405
|
- **State files**: `state-<hostname>-<token-hash>.json` per credential, plus `state.lock` advisory lock, `mcp.log` rotating diagnostic log, optional `hooks-queue.ndjson`.
|
|
246
406
|
|
|
247
407
|
### Credential storage by OS
|
|
@@ -256,7 +416,7 @@ The secure store is the platform's native keychain, accessed via `keytar` (servi
|
|
|
256
416
|
|
|
257
417
|
Notes:
|
|
258
418
|
|
|
259
|
-
- **Windows**: don't set `DB90_MCP_DISABLE_KEYTAR` — Credential Manager is reliably present, and the plaintext fallback file cannot be locked down as tightly as the keychain. The `icacls` hardening is best-effort defense-in-depth.
|
|
419
|
+
- **Windows**: don't set `AIXLE_INSIGHTS_MCP_DISABLE_KEYTAR` (or the deprecated `DB90_MCP_DISABLE_KEYTAR`) — Credential Manager is reliably present, and the plaintext fallback file cannot be locked down as tightly as the keychain. The `icacls` hardening is best-effort defense-in-depth.
|
|
260
420
|
- **Linux**: when no Secret Service is running (common in headless/CI/container contexts), the tool degrades to the `chmod 0600` fallback file **by design** — this is the one environment where the file path is routinely exercised, and POSIX permissions protect it there.
|
|
261
421
|
- A stale `credentials.json` sitting alongside a populated keychain entry is logged as drift (`credentials_file_shadowed_by_keychain` in `mcp.log`) and ignored in favour of the keychain.
|
|
262
422
|
|
|
@@ -304,9 +464,9 @@ Three properties are worth relying on:
|
|
|
304
464
|
|
|
305
465
|
| Symptom | Most likely cause | Fix |
|
|
306
466
|
|---|---|---|
|
|
307
|
-
| `Error:
|
|
308
|
-
| `Blocked event send —
|
|
309
|
-
| `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
|
|
467
|
+
| `Error: Aixle Insights 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. |
|
|
468
|
+
| `Blocked event send — Aixle Insights ingest host <name> uses remote plaintext HTTP.` (or `Blocked project lookup — ...`) in `mcp.log` / console during `run` | `credentials.json` (or the keychain entry) has an `http://` remote `host` without a recorded `--insecure` consent — most likely because it was edited outside of `init`. | Re-run `aixle-insights init --host https://... ` (or `init --insecure --host http://...` if the endpoint is genuinely trusted non-prod) to re-establish credentials with an explicit, recorded decision. |
|
|
469
|
+
| `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 Aixle Insights staging, the canonical Keycloak URL is embedded in the SPA — `curl https://<APP_HOST> \| grep keycloakUrl` extracts the current value. |
|
|
310
470
|
| `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. |
|
|
311
471
|
| `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. |
|
|
312
472
|
| `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. |
|
|
@@ -362,6 +522,10 @@ cd packages/tools
|
|
|
362
522
|
npm rebuild better-sqlite3
|
|
363
523
|
```
|
|
364
524
|
|
|
525
|
+
## Changelog
|
|
526
|
+
|
|
527
|
+
See [CHANGELOG.md](https://github.com/dualboot-partners/db90-rails/blob/develop/packages/tools/aixle-insights/CHANGELOG.md) — npm's registry page doesn't render this file directly, so it's linked here instead of duplicated.
|
|
528
|
+
|
|
365
529
|
## License
|
|
366
530
|
|
|
367
531
|
MIT.
|
package/dist/auth/credentials.js
CHANGED
|
@@ -5,13 +5,18 @@ import { join } from "node:path";
|
|
|
5
5
|
import { getAppDir } from "../state.js";
|
|
6
6
|
import { mcpLog } from "../log.js";
|
|
7
7
|
import { describeReadFailure } from "../lib/parse-error.js";
|
|
8
|
+
import { readBooleanEnvWithDeprecatedAlias, warnDeprecatedEnvVar } from "../lib/env.js";
|
|
8
9
|
export const KEYTAR_SERVICE = "aixle-insights";
|
|
9
10
|
const KEYTAR_ACCOUNT = "aixle-insights-ingest-credential";
|
|
10
11
|
function credentialsPath(appDir) {
|
|
11
12
|
return join(appDir, "credentials.json");
|
|
12
13
|
}
|
|
13
14
|
function keytarDisabled() {
|
|
14
|
-
return
|
|
15
|
+
return readBooleanEnvWithDeprecatedAlias({
|
|
16
|
+
current: "AIXLE_INSIGHTS_MCP_DISABLE_KEYTAR",
|
|
17
|
+
deprecated: "DB90_MCP_DISABLE_KEYTAR",
|
|
18
|
+
onDeprecatedUse: warnDeprecatedEnvVar,
|
|
19
|
+
});
|
|
15
20
|
}
|
|
16
21
|
/** Returns true when at least one tool has a non-empty token. */
|
|
17
22
|
export function credentialsHaveAnyToken(creds) {
|
package/dist/auth/exchange.js
CHANGED
|
@@ -43,7 +43,7 @@ export async function exchangeIngestToken(params) {
|
|
|
43
43
|
json = JSON.parse(text);
|
|
44
44
|
}
|
|
45
45
|
catch {
|
|
46
|
-
throw new Error(`
|
|
46
|
+
throw new Error(`Aixle Insights exchange: invalid JSON (HTTP ${res.status}): ${text.slice(0, 200)}`);
|
|
47
47
|
}
|
|
48
48
|
if (!res.ok) {
|
|
49
49
|
const errBody = json;
|
|
@@ -61,18 +61,18 @@ export async function exchangeIngestToken(params) {
|
|
|
61
61
|
const msg = typeof errBody["message"] === "string" ? errBody["message"] : "Organization selection required.";
|
|
62
62
|
throw new OrganizationSelectionRequiredError(msg, orgs);
|
|
63
63
|
}
|
|
64
|
-
throw new Error(`
|
|
64
|
+
throw new Error(`Aixle Insights exchange failed (HTTP ${res.status}): ${text.slice(0, 500)}`);
|
|
65
65
|
}
|
|
66
66
|
const root = json;
|
|
67
67
|
const data = root["data"];
|
|
68
68
|
if (typeof data !== "object" || data === null) {
|
|
69
|
-
throw new Error("
|
|
69
|
+
throw new Error("Aixle Insights exchange: missing data object");
|
|
70
70
|
}
|
|
71
71
|
const d = data;
|
|
72
72
|
const ingestHost = d["ingestHost"];
|
|
73
73
|
const organizationId = d["organizationId"];
|
|
74
74
|
if (typeof ingestHost !== "string" || typeof organizationId !== "string") {
|
|
75
|
-
throw new Error("
|
|
75
|
+
throw new Error("Aixle Insights exchange: missing ingestHost or organizationId in data");
|
|
76
76
|
}
|
|
77
77
|
const ingestTokenRaw = d["ingestToken"];
|
|
78
78
|
const accountsRaw = d["accounts"];
|
|
@@ -96,7 +96,7 @@ export async function exchangeIngestToken(params) {
|
|
|
96
96
|
}
|
|
97
97
|
if (Object.keys(accounts).length === 0) {
|
|
98
98
|
if (!ingestToken) {
|
|
99
|
-
throw new Error("
|
|
99
|
+
throw new Error("Aixle Insights exchange: no ingestToken / accounts returned in data");
|
|
100
100
|
}
|
|
101
101
|
const fallbackTool = requestedTools.length === 1 ? requestedTools[0] : d["toolName"];
|
|
102
102
|
const tid = fallbackTool === "cursor" ? "cursor" : "claude_code";
|
|
@@ -104,7 +104,7 @@ export async function exchangeIngestToken(params) {
|
|
|
104
104
|
}
|
|
105
105
|
const missingTools = requestedTools.filter((tool) => !accounts[tool]?.ingestToken);
|
|
106
106
|
if (missingTools.length > 0) {
|
|
107
|
-
throw new Error(`
|
|
107
|
+
throw new Error(`Aixle Insights exchange: missing requested account(s): ${missingTools.join(", ")}`);
|
|
108
108
|
}
|
|
109
109
|
return { ingestHost, organizationId, ingestToken, accounts };
|
|
110
110
|
}
|
package/dist/auth/flow.js
CHANGED
|
@@ -69,7 +69,7 @@ export async function loginAndPersistCredentials(opts) {
|
|
|
69
69
|
}
|
|
70
70
|
const ingestHostSecurity = evaluateTransportSecurity(exchanged.ingestHost, {
|
|
71
71
|
allowInsecureHttp: opts.allowInsecureHttp === true,
|
|
72
|
-
label: "
|
|
72
|
+
label: "Aixle Insights ingest host",
|
|
73
73
|
});
|
|
74
74
|
if (!ingestHostSecurity.ok) {
|
|
75
75
|
return { ok: false, error: ingestHostSecurity.error };
|
package/dist/auth/keycloak.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { createHash, randomBytes } from "node:crypto";
|
|
5
5
|
import { isLoopbackHost } from "../lib/transport-security.js";
|
|
6
|
+
import { readEnvWithDeprecatedAlias, readBooleanEnvWithDeprecatedAlias, warnDeprecatedEnvVar } from "../lib/env.js";
|
|
6
7
|
function normalizeIssuer(issuer) {
|
|
7
8
|
return issuer.replace(/\/$/, "");
|
|
8
9
|
}
|
|
@@ -156,11 +157,18 @@ export async function obtainKeycloakAccessTokenViaDeviceFlow(params) {
|
|
|
156
157
|
});
|
|
157
158
|
}
|
|
158
159
|
export function defaultKeycloakIssuer(ingestHost) {
|
|
159
|
-
const fromEnv =
|
|
160
|
-
|
|
160
|
+
const fromEnv = readEnvWithDeprecatedAlias({
|
|
161
|
+
current: "AIXLE_INSIGHTS_KEYCLOAK_ISSUER",
|
|
162
|
+
deprecated: "DB90_KEYCLOAK_ISSUER",
|
|
163
|
+
onDeprecatedUse: warnDeprecatedEnvVar,
|
|
164
|
+
}) || process.env["KEYCLOAK_ISSUER"]?.trim();
|
|
161
165
|
if (fromEnv)
|
|
162
166
|
return fromEnv.replace(/\/$/, "");
|
|
163
|
-
const useLocalDefault =
|
|
167
|
+
const useLocalDefault = readBooleanEnvWithDeprecatedAlias({
|
|
168
|
+
current: "AIXLE_INSIGHTS_MCP_USE_LOCAL_KEYCLOAK_DEFAULT",
|
|
169
|
+
deprecated: "DB90_MCP_USE_LOCAL_KEYCLOAK_DEFAULT",
|
|
170
|
+
onDeprecatedUse: warnDeprecatedEnvVar,
|
|
171
|
+
});
|
|
164
172
|
if (useLocalDefault || process.env["NODE_ENV"] === "development") {
|
|
165
173
|
if (ingestHost) {
|
|
166
174
|
let hostname;
|
|
@@ -185,5 +193,11 @@ export function defaultKeycloakIssuer(ingestHost) {
|
|
|
185
193
|
return "";
|
|
186
194
|
}
|
|
187
195
|
export function defaultKeycloakClientId() {
|
|
188
|
-
return process.env["
|
|
196
|
+
return (process.env["KEYCLOAK_CLIENT_ID"]?.trim() ||
|
|
197
|
+
readEnvWithDeprecatedAlias({
|
|
198
|
+
current: "AIXLE_INSIGHTS_KEYCLOAK_CLIENT_ID",
|
|
199
|
+
deprecated: "DB90_KEYCLOAK_CLIENT_ID",
|
|
200
|
+
onDeprecatedUse: warnDeprecatedEnvVar,
|
|
201
|
+
}) ||
|
|
202
|
+
"aixle-insights-web");
|
|
189
203
|
}
|
package/dist/cli.js
CHANGED
|
@@ -14,6 +14,7 @@ import { installClaudeUserMcp } from "./install/claude.js";
|
|
|
14
14
|
import { installCursorUserMcp, uninstallCursorUserMcp } from "./install/cursor.js";
|
|
15
15
|
import { installHooksConfig, uninstallHooksConfig, verifyHooksConfig, FORWARDER_FILENAME } from "./hooks/hooks-config.js";
|
|
16
16
|
import { evaluateTransportSecurity } from "./lib/transport-security.js";
|
|
17
|
+
import { readEnvWithDeprecatedAlias, warnDeprecatedEnvVar } from "./lib/env.js";
|
|
17
18
|
import { join } from "node:path";
|
|
18
19
|
import { fileURLToPath as nodeFileURLToPath } from "node:url";
|
|
19
20
|
import { mcpLog } from "./log.js";
|
|
@@ -162,9 +163,9 @@ Usage:
|
|
|
162
163
|
|
|
163
164
|
Commands:
|
|
164
165
|
run Start the MCP stdio server (default — used by Claude Code).
|
|
165
|
-
init Keycloak device login once, then persist
|
|
166
|
+
init Keycloak device login once, then persist Aixle Insights ingest credentials (keychain or file).
|
|
166
167
|
health Multi-line diagnostic (credentials, sync, log path, state files).
|
|
167
|
-
uninstall-hooks Remove
|
|
168
|
+
uninstall-hooks Remove Aixle Insights from ~/.cursor/hooks.json and restore backup (if any).
|
|
168
169
|
verify-hooks Print hooks install status and queue depth as JSON.
|
|
169
170
|
uninstall-cursor-mcp Remove aixle-insights from ~/.cursor/mcp.json and restore backup (if any).
|
|
170
171
|
|
|
@@ -174,10 +175,10 @@ Options:
|
|
|
174
175
|
--help, -h Show this help message.
|
|
175
176
|
|
|
176
177
|
init options:
|
|
177
|
-
--host <url>
|
|
178
|
-
--keycloak-url <issuer> Keycloak realm issuer (default: env KEYCLOAK_ISSUER / DB90_KEYCLOAK_ISSUER)
|
|
178
|
+
--host <url> Aixle Insights API base URL (default: env AIXLE_INSIGHTS_API_URL, or deprecated DB90_API_URL, or http://localhost:3000)
|
|
179
|
+
--keycloak-url <issuer> Keycloak realm issuer (default: env KEYCLOAK_ISSUER, or AIXLE_INSIGHTS_KEYCLOAK_ISSUER / deprecated DB90_KEYCLOAK_ISSUER)
|
|
179
180
|
--tool-name <name> Optional: mint only \`claude_code\`, only \`cursor\`, or omit to mint BOTH.
|
|
180
|
-
--organization-id <uuid> Optional: scope MCP token exchange to this org (overrides env DB90_ORGANIZATION_ID).
|
|
181
|
+
--organization-id <uuid> Optional: scope MCP token exchange to this org (overrides env AIXLE_INSIGHTS_ORGANIZATION_ID / deprecated DB90_ORGANIZATION_ID).
|
|
181
182
|
--force Re-run the device flow even if valid credentials already exist, and
|
|
182
183
|
replace an existing user "aixle-insights" MCP entry in ~/.claude.json if it differs.
|
|
183
184
|
--hooks (opt-in) Install Cursor hook forwarder for per-turn model attribution.
|
|
@@ -185,9 +186,7 @@ init options:
|
|
|
185
186
|
--insecure Allow remote http:// hosts for trusted non-production test endpoints only.
|
|
186
187
|
|
|
187
188
|
Multi-org:
|
|
188
|
-
|
|
189
|
-
or set a Default Organization in web Preferences. Without either, \`init\` lists your orgs and exits.
|
|
190
|
-
Single-org users need no flag. Run \`aixle-insights health\` to see the bound organization_id.
|
|
189
|
+
Set \`AIXLE_INSIGHTS_ORGANIZATION_ID\` (or deprecated \`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.
|
|
191
190
|
|
|
192
191
|
Credentials:
|
|
193
192
|
Stored in the OS keychain via keytar when available; otherwise
|
|
@@ -197,7 +196,11 @@ Note: Omitting --tool-name provisions separate ingest tokens for Claude Code + C
|
|
|
197
196
|
`);
|
|
198
197
|
}
|
|
199
198
|
function defaultApiHost() {
|
|
200
|
-
const v =
|
|
199
|
+
const v = readEnvWithDeprecatedAlias({
|
|
200
|
+
current: "AIXLE_INSIGHTS_API_URL",
|
|
201
|
+
deprecated: "DB90_API_URL",
|
|
202
|
+
onDeprecatedUse: warnDeprecatedEnvVar,
|
|
203
|
+
});
|
|
201
204
|
if (v)
|
|
202
205
|
return v.replace(/\/$/, "");
|
|
203
206
|
return "http://localhost:3000";
|
|
@@ -224,7 +227,7 @@ export async function runInit(cliArgs, deps) {
|
|
|
224
227
|
const apiHost = (cliArgs.host ?? defaultApiHost()).replace(/\/$/, "");
|
|
225
228
|
const transportSecurity = evaluateTransportSecurity(apiHost, {
|
|
226
229
|
allowInsecureHttp: cliArgs.insecure === true,
|
|
227
|
-
label: "
|
|
230
|
+
label: "Aixle Insights API host",
|
|
228
231
|
});
|
|
229
232
|
if (!transportSecurity.ok) {
|
|
230
233
|
runtime.error(`Error: ${transportSecurity.error}`);
|
|
@@ -233,7 +236,11 @@ export async function runInit(cliArgs, deps) {
|
|
|
233
236
|
if (transportSecurity.warning) {
|
|
234
237
|
runtime.error(`Warning: ${transportSecurity.warning}`);
|
|
235
238
|
}
|
|
236
|
-
const kcIssuer = (cliArgs.keycloakUrl ?? runtime.defaultKeycloakIssuer()).trim();
|
|
239
|
+
const kcIssuer = (cliArgs.keycloakUrl ?? runtime.defaultKeycloakIssuer(apiHost)).trim();
|
|
240
|
+
if (!kcIssuer) {
|
|
241
|
+
runtime.error("Error: Keycloak issuer is not configured. Pass --keycloak-url or set KEYCLOAK_ISSUER / AIXLE_INSIGHTS_KEYCLOAK_ISSUER.");
|
|
242
|
+
return 1;
|
|
243
|
+
}
|
|
237
244
|
if (cliArgs.toolName !== undefined && !["claude_code", "cursor"].includes(cliArgs.toolName)) {
|
|
238
245
|
runtime.error("Error: --tool-name must be one of: claude_code, cursor.");
|
|
239
246
|
return 1;
|
|
@@ -244,10 +251,14 @@ export async function runInit(cliArgs, deps) {
|
|
|
244
251
|
? ["claude_code"]
|
|
245
252
|
: ["claude_code", "cursor"];
|
|
246
253
|
const fromFlag = cliArgs.organizationId?.trim();
|
|
247
|
-
const fromEnv =
|
|
254
|
+
const fromEnv = readEnvWithDeprecatedAlias({
|
|
255
|
+
current: "AIXLE_INSIGHTS_ORGANIZATION_ID",
|
|
256
|
+
deprecated: "DB90_ORGANIZATION_ID",
|
|
257
|
+
onDeprecatedUse: warnDeprecatedEnvVar,
|
|
258
|
+
});
|
|
248
259
|
const exchangeOrganizationId = fromFlag || fromEnv;
|
|
249
260
|
if (exchangeOrganizationId && !isValidOrganizationUuid(exchangeOrganizationId)) {
|
|
250
|
-
runtime.error("Error: --organization-id /
|
|
261
|
+
runtime.error("Error: --organization-id / AIXLE_INSIGHTS_ORGANIZATION_ID must be a valid UUID (RFC 4122, version 1–5, variant per Aixle Insights API).");
|
|
251
262
|
return 1;
|
|
252
263
|
}
|
|
253
264
|
const result = await runtime.loginAndPersistCredentials({
|
|
@@ -452,7 +463,7 @@ async function main() {
|
|
|
452
463
|
console.log(backupPath ? `Hooks uninstalled; hooks.json restored from ${backupPath}.` : "Hooks uninstalled; hooks.json removed.");
|
|
453
464
|
}
|
|
454
465
|
else {
|
|
455
|
-
console.log("No
|
|
466
|
+
console.log("No Aixle Insights hooks entry found in ~/.cursor/hooks.json — nothing to uninstall.");
|
|
456
467
|
}
|
|
457
468
|
return;
|
|
458
469
|
}
|
package/dist/install/claude.js
CHANGED
|
@@ -2,8 +2,13 @@ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "
|
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { randomBytes } from "node:crypto";
|
|
5
|
+
import { readEnvWithDeprecatedAlias, warnDeprecatedEnvVar } from "../lib/env.js";
|
|
5
6
|
export function defaultClaudeUserConfigPath() {
|
|
6
|
-
const override =
|
|
7
|
+
const override = readEnvWithDeprecatedAlias({
|
|
8
|
+
current: "AIXLE_INSIGHTS_CLAUDE_USER_CONFIG_PATH",
|
|
9
|
+
deprecated: "DB90_CLAUDE_USER_CONFIG_PATH",
|
|
10
|
+
onDeprecatedUse: warnDeprecatedEnvVar,
|
|
11
|
+
});
|
|
7
12
|
if (override)
|
|
8
13
|
return override;
|
|
9
14
|
return join(homedir(), ".claude.json");
|
package/dist/lib/client.js
CHANGED
|
@@ -13,7 +13,7 @@ import { evaluateTransportSecurity } from "./transport-security.js";
|
|
|
13
13
|
export async function postEvent(payload, host, token, options = {}) {
|
|
14
14
|
const transportSecurity = evaluateTransportSecurity(host, {
|
|
15
15
|
allowInsecureHttp: options.allowInsecureHttp === true,
|
|
16
|
-
label: "
|
|
16
|
+
label: "Aixle Insights ingest host",
|
|
17
17
|
});
|
|
18
18
|
if (!transportSecurity.ok) {
|
|
19
19
|
// Deliberately does NOT call options.onNetworkError/onHttpError: the retry
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `DB90_*` env vars are deprecated aliases for the `AIXLE_INSIGHTS_*` names (branding rename,
|
|
3
|
+
* DB90DV-624). Both are honored indefinitely until a removal date is announced; the deprecated
|
|
4
|
+
* name only wins when the current name is unset, so setting both is safe.
|
|
5
|
+
*/
|
|
6
|
+
export interface EnvAliasOptions {
|
|
7
|
+
/** Preferred, currently-documented env var name. */
|
|
8
|
+
current: string;
|
|
9
|
+
/** Deprecated env var name, still honored as a fallback. */
|
|
10
|
+
deprecated: string;
|
|
11
|
+
onDeprecatedUse?: (deprecatedName: string, currentName: string) => void;
|
|
12
|
+
}
|
|
13
|
+
export declare function readEnvWithDeprecatedAlias(options: EnvAliasOptions): string | undefined;
|
|
14
|
+
export declare function readBooleanEnvWithDeprecatedAlias(options: EnvAliasOptions): boolean;
|
|
15
|
+
export declare function warnDeprecatedEnvVar(deprecatedName: string, currentName: string, log?: (message: string) => void): void;
|
package/dist/lib/env.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export function readEnvWithDeprecatedAlias(options) {
|
|
2
|
+
const currentValue = process.env[options.current]?.trim();
|
|
3
|
+
if (currentValue)
|
|
4
|
+
return currentValue;
|
|
5
|
+
const deprecatedValue = process.env[options.deprecated]?.trim();
|
|
6
|
+
if (deprecatedValue) {
|
|
7
|
+
options.onDeprecatedUse?.(options.deprecated, options.current);
|
|
8
|
+
return deprecatedValue;
|
|
9
|
+
}
|
|
10
|
+
return undefined;
|
|
11
|
+
}
|
|
12
|
+
export function readBooleanEnvWithDeprecatedAlias(options) {
|
|
13
|
+
const value = readEnvWithDeprecatedAlias(options);
|
|
14
|
+
return ["1", "true", "yes"].includes(value?.toLowerCase() ?? "");
|
|
15
|
+
}
|
|
16
|
+
export function warnDeprecatedEnvVar(deprecatedName, currentName, log = console.error) {
|
|
17
|
+
log(`Warning: ${deprecatedName} is deprecated; use ${currentName} instead.`);
|
|
18
|
+
}
|
|
@@ -33,7 +33,7 @@ export declare function canonicalizeGitRemote(remote: string, verbose: boolean):
|
|
|
33
33
|
*/
|
|
34
34
|
export declare function repoNameToGitRemoteCandidates(repoName: string): string[];
|
|
35
35
|
export declare function lookupProjectByRepoName(repoName: string, host: string, token: string, verbose: boolean, allowInsecureHttp?: boolean): Promise<LookupResult | "not-found" | null>;
|
|
36
|
-
/** Payload shape shared by
|
|
36
|
+
/** Payload shape shared by the Cursor and Claude commit mappers. */
|
|
37
37
|
export interface CommitAttributionPayload {
|
|
38
38
|
event_type?: string;
|
|
39
39
|
project_id?: string;
|
|
@@ -216,7 +216,7 @@ export async function enrichCommitProjectAttribution(payloads, options) {
|
|
|
216
216
|
export async function lookupProjectByRemote(gitRemote, host, token, verbose, allowInsecureHttp = false) {
|
|
217
217
|
const transportSecurity = evaluateTransportSecurity(host, {
|
|
218
218
|
allowInsecureHttp,
|
|
219
|
-
label: "
|
|
219
|
+
label: "Aixle Insights project-lookup host",
|
|
220
220
|
});
|
|
221
221
|
if (!transportSecurity.ok) {
|
|
222
222
|
console.error(`Blocked project lookup — ${transportSecurity.error}`);
|
package/dist/readers/claude.d.ts
CHANGED
|
@@ -60,6 +60,13 @@ export interface ClaudeTranscriptTurn {
|
|
|
60
60
|
navToolCalls: number;
|
|
61
61
|
totalToolCalls: number;
|
|
62
62
|
messageIds: string[];
|
|
63
|
+
/**
|
|
64
|
+
* Fingerprint of the turn's content (prompt + assistant text + tool-use set).
|
|
65
|
+
* A turn keeps the same turnId as Claude appends more tool_use blocks to it,
|
|
66
|
+
* so sync compares this hash to detect appended derivatives and re-emit them
|
|
67
|
+
* instead of skipping the turn forever on its unchanged id (DB90DV-259).
|
|
68
|
+
*/
|
|
69
|
+
contentHash: string;
|
|
63
70
|
}
|
|
64
71
|
/** Payload shape for the parent chat turn (carries full token cost). */
|
|
65
72
|
export interface ClaudePayload extends IngestPayload {
|
package/dist/readers/claude.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createReadStream, statSync } from "node:fs";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
2
3
|
import { finished } from "node:stream/promises";
|
|
3
4
|
import { createInterface } from "node:readline";
|
|
4
5
|
import { join } from "node:path";
|
|
@@ -206,9 +207,29 @@ function newTurn(sessionId, turnIndex, filePath, fileSize, occurredAt, promptId)
|
|
|
206
207
|
navToolCalls: 0,
|
|
207
208
|
totalToolCalls: 0,
|
|
208
209
|
messageIds: [],
|
|
210
|
+
contentHash: "",
|
|
209
211
|
persisted: false,
|
|
210
212
|
};
|
|
211
213
|
}
|
|
214
|
+
/**
|
|
215
|
+
* Stable fingerprint of a turn's emittable content. Includes the tool-use set
|
|
216
|
+
* (id + name + summary) so appending a tool_use to an already-synced turn
|
|
217
|
+
* changes the hash and triggers a re-emit (DB90DV-259).
|
|
218
|
+
*/
|
|
219
|
+
function computeTurnContentHash(turn) {
|
|
220
|
+
const toolFingerprint = turn.toolUses
|
|
221
|
+
.map((t) => `${t.id}:${t.eventType}:${t.summary}`)
|
|
222
|
+
.join("");
|
|
223
|
+
const material = [
|
|
224
|
+
turn.model ?? "",
|
|
225
|
+
turn.tokensIn,
|
|
226
|
+
turn.tokensOut,
|
|
227
|
+
turn.promptText,
|
|
228
|
+
turn.assistantText,
|
|
229
|
+
toolFingerprint,
|
|
230
|
+
].join("");
|
|
231
|
+
return createHash("sha256").update(material).digest("hex").slice(0, 32);
|
|
232
|
+
}
|
|
212
233
|
function appendText(existing, addition) {
|
|
213
234
|
if (!addition.trim())
|
|
214
235
|
return existing;
|
|
@@ -391,7 +412,10 @@ export async function parseTranscriptFile(filePath, verbose = false) {
|
|
|
391
412
|
return turns;
|
|
392
413
|
}
|
|
393
414
|
flushCurrentTurn();
|
|
394
|
-
return finalizedTurns.map(({ persisted: _persisted, ...turn }) =>
|
|
415
|
+
return finalizedTurns.map(({ persisted: _persisted, ...turn }) => ({
|
|
416
|
+
...turn,
|
|
417
|
+
contentHash: computeTurnContentHash(turn),
|
|
418
|
+
}));
|
|
395
419
|
}
|
|
396
420
|
/** Converts a Claude transcript turn to parent chat and derivative tool-use payloads. */
|
|
397
421
|
export function mapTranscriptTurn(turn, options) {
|
package/dist/readers/cursor.js
CHANGED
|
@@ -717,6 +717,12 @@ export async function parseCursorTranscriptFile(filePath, composerHeaders, verbo
|
|
|
717
717
|
if (texts.length === 0)
|
|
718
718
|
continue;
|
|
719
719
|
if (entry.role === "user") {
|
|
720
|
+
// Normalize/filter before noting the time: a whitespace- or wrapper-only line
|
|
721
|
+
// must not set currentTurnOccurredAt (nor start a new turn) with nothing to append,
|
|
722
|
+
// otherwise the next real message inherits this stale timestamp.
|
|
723
|
+
const fragments = texts.map(stripUserQueryWrapper).filter((text) => text.length > 0);
|
|
724
|
+
if (fragments.length === 0)
|
|
725
|
+
continue;
|
|
720
726
|
if (currentPromptParts.length > 0 || currentAssistantParts.length > 0) {
|
|
721
727
|
finalizeTurn();
|
|
722
728
|
currentPromptParts = [];
|
package/dist/server.js
CHANGED
|
@@ -4,6 +4,7 @@ import { z } from "zod";
|
|
|
4
4
|
import { getGitRemote, resolveProjectId } from "./lib/index.js";
|
|
5
5
|
import { loadCredentials, credentialsHaveAnyToken, pickProjectLookupToken } from "./credentials.js";
|
|
6
6
|
import { defaultKeycloakClientId, defaultKeycloakIssuer, startDeviceAuthorization } from "./auth/keycloak.js";
|
|
7
|
+
import { readEnvWithDeprecatedAlias, warnDeprecatedEnvVar } from "./lib/env.js";
|
|
7
8
|
import { migrateLegacyState, getAppDir } from "./state.js";
|
|
8
9
|
import { syncTelemetryTools } from "./sync.js";
|
|
9
10
|
import { DEFAULT_PRICING, mergePricing } from "./pricing.js";
|
|
@@ -143,13 +144,23 @@ async function syncNowHandler(input) {
|
|
|
143
144
|
});
|
|
144
145
|
}
|
|
145
146
|
}
|
|
147
|
+
function defaultIngestHost() {
|
|
148
|
+
const v = readEnvWithDeprecatedAlias({
|
|
149
|
+
current: "AIXLE_INSIGHTS_API_URL",
|
|
150
|
+
deprecated: "DB90_API_URL",
|
|
151
|
+
onDeprecatedUse: warnDeprecatedEnvVar,
|
|
152
|
+
});
|
|
153
|
+
if (v)
|
|
154
|
+
return v.replace(/\/$/, "");
|
|
155
|
+
return "http://localhost:3000";
|
|
156
|
+
}
|
|
146
157
|
async function authenticateHandler(args) {
|
|
147
158
|
try {
|
|
148
|
-
const kc = (args.keycloakUrl?.trim() || defaultKeycloakIssuer()).trim();
|
|
159
|
+
const kc = (args.keycloakUrl?.trim() || defaultKeycloakIssuer(defaultIngestHost())).trim();
|
|
149
160
|
if (!kc) {
|
|
150
161
|
return jsonContent({
|
|
151
162
|
ok: false,
|
|
152
|
-
error: "keycloakUrl or KEYCLOAK_ISSUER /
|
|
163
|
+
error: "keycloakUrl or KEYCLOAK_ISSUER / AIXLE_INSIGHTS_KEYCLOAK_ISSUER is required",
|
|
153
164
|
});
|
|
154
165
|
}
|
|
155
166
|
const clientId = args.clientId?.trim() || defaultKeycloakClientId();
|
|
@@ -182,7 +193,7 @@ export function createAixleInsightsMcpServer() {
|
|
|
182
193
|
const statusDescription = "Returns Aixle Insights MCP connectivity and last sync metadata from disk (credentials + state). No arguments.";
|
|
183
194
|
server.registerTool("aixle_insights_status", { description: statusDescription }, statusHandler);
|
|
184
195
|
server.registerTool("db90_status", { description: DEPRECATED_ALIAS_NOTE.replace("{name}", "aixle_insights_status") + statusDescription }, statusHandler);
|
|
185
|
-
const syncNowDescription = "Runs one
|
|
196
|
+
const syncNowDescription = "Runs one Aixle Insights ingest sync cycle for enabled tools immediately (matches background cadence). " +
|
|
186
197
|
"Optional `tools` subset filter: omit to sync every tool credential you have authenticated (Claude transcripts + Cursor telemetry).";
|
|
187
198
|
server.registerTool("aixle_insights_sync_now", { description: syncNowDescription, inputSchema: SYNC_NOW_INPUT_SCHEMA }, syncNowHandler);
|
|
188
199
|
server.registerTool("db90_sync_now", {
|
package/dist/sync.js
CHANGED
|
@@ -225,7 +225,11 @@ async function runClaudeSlice(options) {
|
|
|
225
225
|
}
|
|
226
226
|
const sKey = sessionStateKey(turn.turnId);
|
|
227
227
|
const known = state.sessions[sKey];
|
|
228
|
-
|
|
228
|
+
// A turn keeps its turnId as Claude appends more tool_use blocks to it, so a
|
|
229
|
+
// plain "already known → skip" would drop derivatives appended after an earlier
|
|
230
|
+
// mid-turn sync. Skip only when the content fingerprint is unchanged; otherwise
|
|
231
|
+
// re-emit so the newly appended tool uses are sent (DB90DV-259).
|
|
232
|
+
if (known && known.contentHash && known.contentHash === turn.contentHash) {
|
|
229
233
|
totalSkipped++;
|
|
230
234
|
if (verbose) {
|
|
231
235
|
console.log(`[verbose] Skipping already-synced Claude turn ${turn.turnId}`);
|
|
@@ -282,6 +286,7 @@ async function runClaudeSlice(options) {
|
|
|
282
286
|
console.log(`[verbose] Sending Claude ${payload.event_type} ${payload.metadata.session_id}`);
|
|
283
287
|
}
|
|
284
288
|
const ok = await postEvent(payload, host, token, {
|
|
289
|
+
allowInsecureHttp,
|
|
285
290
|
on429: (retryAfter, quotaExceeded) => {
|
|
286
291
|
const currentBackoff = backoffUntilByCredential.get(backoffKey);
|
|
287
292
|
const nextBackoff = new Date(Math.max(currentBackoff?.getTime() ?? 0, Date.now() + retryAfter * 1000));
|
|
@@ -309,7 +314,7 @@ async function runClaudeSlice(options) {
|
|
|
309
314
|
}
|
|
310
315
|
if (allOk) {
|
|
311
316
|
totalSent += payloads.length;
|
|
312
|
-
state = markSessionSent(state, sKey, turn.fileSize);
|
|
317
|
+
state = markSessionSent(state, sKey, turn.fileSize, turn.contentHash);
|
|
313
318
|
writeState(state, appDir, host, token);
|
|
314
319
|
}
|
|
315
320
|
else if (shouldStopForBackoff) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aixle/insights",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.8-staging",
|
|
4
4
|
"description": "stdio MCP server for AI coding-assistant telemetry — Claude transcript sync + Cursor SQLite ingest.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
"lint": "eslint . --max-warnings 0",
|
|
47
47
|
"verify:cursor-dry-run": "tsx scripts/verify-cursor-dry-run.ts",
|
|
48
48
|
"audit:local-stores": "tsx scripts/audit-local-stores.ts",
|
|
49
|
+
"nightly:resolve": "tsx scripts/nightly-release-resolve.ts",
|
|
49
50
|
"dev": "tsx src/cli.ts",
|
|
50
51
|
"prepublishOnly": "npm run build"
|
|
51
52
|
},
|
|
@@ -66,6 +67,6 @@
|
|
|
66
67
|
"eslint-plugin-security": "^4.0.1",
|
|
67
68
|
"tsx": "^4.7.0",
|
|
68
69
|
"typescript": "^5.3.3",
|
|
69
|
-
"vitest": "
|
|
70
|
+
"vitest": "4.1.9"
|
|
70
71
|
}
|
|
71
72
|
}
|