@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 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.0` | `0.2.1-staging` |
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-insights --version # e.g. 0.2.0 (no suffix)
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-insights --version # e.g. 0.2.1-staging (note the suffix)
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-insights --version # a -staging suffix means a QA build
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.0', staging: '0.2.1-staging' }
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.0`, `~0.2.0`
77
- > and `>=0.1.0` all select `0.2.0` even when `0.2.1-staging` exists. Staging builds are
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 `DB90_ORGANIZATION_ID=<uuid>` 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.
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 a DB90 API origin for token exchange. Remote hosts must use HTTPS; `http://localhost` and loopback addresses are allowed for local development. |
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 `db90-web`; must allow device authorization in Keycloak. |
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 retained as compatibility names for the deployment-side Keycloak realm/client identifiers. Future versions may rename them with a deprecation window.
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
- The file is optional — an absent `config.json` is the normal case and is silent. A file that is
190
- present but unusable is ignored entirely (every override falls back to its default) and a
191
- `config_parse_failed` line is written to `mcp.log`. That covers malformed JSON and valid JSON that
192
- isn't an object, including a **top-level array** — a common mistake when writing per-model rates.
193
- Nothing is printed to the terminal, so check the log if an override appears to have no effect.
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: 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. |
308
- | `Blocked event send — DB90 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. |
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 DB90 staging, the canonical Keycloak URL is embedded in the SPA — `curl https://<APP_HOST> \| grep keycloakUrl` extracts the current value. |
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.
@@ -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 ["1", "true", "yes"].includes(process.env["DB90_MCP_DISABLE_KEYTAR"]?.toLowerCase() ?? "");
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) {
@@ -43,7 +43,7 @@ export async function exchangeIngestToken(params) {
43
43
  json = JSON.parse(text);
44
44
  }
45
45
  catch {
46
- throw new Error(`DB90 exchange: invalid JSON (HTTP ${res.status}): ${text.slice(0, 200)}`);
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(`DB90 exchange failed (HTTP ${res.status}): ${text.slice(0, 500)}`);
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("DB90 exchange: missing data object");
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("DB90 exchange: missing ingestHost or organizationId in data");
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("DB90 exchange: no ingestToken / accounts returned in data");
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(`DB90 exchange: missing requested account(s): ${missingTools.join(", ")}`);
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: "DB90 ingest host",
72
+ label: "Aixle Insights ingest host",
73
73
  });
74
74
  if (!ingestHostSecurity.ok) {
75
75
  return { ok: false, error: ingestHostSecurity.error };
@@ -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 = process.env["DB90_KEYCLOAK_ISSUER"]?.trim() ||
160
- process.env["KEYCLOAK_ISSUER"]?.trim();
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 = ["1", "true", "yes"].includes(process.env["DB90_MCP_USE_LOCAL_KEYCLOAK_DEFAULT"]?.toLowerCase() ?? "");
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["DB90_KEYCLOAK_CLIENT_ID"]?.trim() || "db90-web";
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 DB90 ingest credentials (keychain or file).
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 DB90 from ~/.cursor/hooks.json and restore backup (if any).
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> DB90 API base URL (default: env DB90_API_URL or http://localhost:3000)
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
- If you belong to more than one org, pass \`--organization-id <uuid>\` (or set \`DB90_ORGANIZATION_ID\`),
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 = process.env["DB90_API_URL"]?.trim();
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: "DB90 API host",
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 = process.env["DB90_ORGANIZATION_ID"]?.trim();
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 / DB90_ORGANIZATION_ID must be a valid UUID (RFC 4122, version 1–5, variant per DB90 API).");
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 DB90 hooks entry found in ~/.cursor/hooks.json — nothing to uninstall.");
466
+ console.log("No Aixle Insights hooks entry found in ~/.cursor/hooks.json — nothing to uninstall.");
456
467
  }
457
468
  return;
458
469
  }
@@ -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 = process.env["DB90_CLAUDE_USER_CONFIG_PATH"]?.trim();
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");
@@ -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: "DB90 ingest host",
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;
@@ -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 db90-cursor and telemetry-mcp commit mappers. */
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: "DB90 project-lookup host",
219
+ label: "Aixle Insights project-lookup host",
220
220
  });
221
221
  if (!transportSecurity.ok) {
222
222
  console.error(`Blocked project lookup — ${transportSecurity.error}`);
@@ -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 {
@@ -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 }) => 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) {
@@ -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 / DB90_KEYCLOAK_ISSUER is required",
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 DB90 ingest sync cycle for enabled tools immediately (matches background cadence). " +
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
- if (known) {
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.6-staging",
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": "^4.1.0"
70
+ "vitest": "4.1.9"
70
71
  }
71
72
  }