@aixle/insights 0.2.6-staging → 0.2.7-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
@@ -131,7 +131,7 @@ 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
 
@@ -153,7 +153,7 @@ If the MCP has been installed but never connected, `aixle-insights health` (or t
153
153
  | `aixle-insights run --once` | Perform one multi-tool sync, exit. Useful for cron / manual flushes. |
154
154
  | `aixle-insights run --once --full` | Backfill: ignore Cursor watermarks and commit-hash dedupe. |
155
155
  | `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. |
156
+ | `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
157
  | `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
158
  | `aixle-insights init --hooks --tool-name cursor` | Also install the Cursor-side hook forwarder (opt-in; requires Cursor restart). |
159
159
  | `aixle-insights uninstall-hooks` | Remove the hook forwarder + restore `~/.cursor/hooks.json` backup. |
@@ -164,15 +164,15 @@ If the MCP has been installed but never connected, `aixle-insights health` (or t
164
164
 
165
165
  | Variable | Purpose |
166
166
  |---|---|
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`). |
167
+ | `AIXLE_INSIGHTS_API_URL` (deprecated: `DB90_API_URL`) | API origin for ingestion + MCP exchange (defaults to `http://localhost:3000`; `init --host` overrides). |
168
+ | `KEYCLOAK_ISSUER` / `AIXLE_INSIGHTS_KEYCLOAK_ISSUER` (deprecated: `DB90_KEYCLOAK_ISSUER`) | Realm issuer URLs (`KEYCLOAK_ISSUER` is preferred on servers + CI). |
169
+ | `AIXLE_INSIGHTS_KEYCLOAK_CLIENT_ID` (deprecated: `DB90_KEYCLOAK_CLIENT_ID`) | Defaults to `db90-web`; must allow device authorization in Keycloak. |
170
+ | `AIXLE_INSIGHTS_ORGANIZATION_ID` (deprecated: `DB90_ORGANIZATION_ID`) | Optional UUID scoping `init` to that org membership (header `X-Organization-ID`). |
171
171
  | `AIXLE_INSIGHTS_HOME` | Override the local state directory (defaults to `~/.aixle-insights/`). |
172
172
 
173
173
  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
174
 
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.
175
+ 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
176
 
177
177
  Optional `~/.aixle-insights/config.json` accepts Cursor line-cost overrides (per-model rates):
178
178
 
@@ -241,7 +241,7 @@ reason. File contents, keychain payloads, and tokens are never logged.
241
241
  ## State + credentials
242
242
 
243
243
  - **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.
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 (`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
245
  - **State files**: `state-<hostname>-<token-hash>.json` per credential, plus `state.lock` advisory lock, `mcp.log` rotating diagnostic log, optional `hooks-queue.ndjson`.
246
246
 
247
247
  ### Credential storage by OS
@@ -256,7 +256,7 @@ The secure store is the platform's native keychain, accessed via `keytar` (servi
256
256
 
257
257
  Notes:
258
258
 
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.
259
+ - **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
260
  - **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
261
  - 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
262
 
@@ -304,9 +304,9 @@ Three properties are worth relying on:
304
304
 
305
305
  | Symptom | Most likely cause | Fix |
306
306
  |---|---|---|
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. |
307
+ | `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. |
308
+ | `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. |
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 Aixle Insights staging, the canonical Keycloak URL is embedded in the SPA — `curl https://<APP_HOST> \| grep keycloakUrl` extracts the current value. |
310
310
  | `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
311
  | `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
312
  | `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. |
@@ -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,9 @@ 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 (readEnvWithDeprecatedAlias({
197
+ current: "AIXLE_INSIGHTS_KEYCLOAK_CLIENT_ID",
198
+ deprecated: "DB90_KEYCLOAK_CLIENT_ID",
199
+ onDeprecatedUse: warnDeprecatedEnvVar,
200
+ }) || "db90-web");
189
201
  }
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 (or deprecated DB90_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 (or deprecated DB90_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
+ }
@@ -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 (or deprecated DB90_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.7-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": {
@@ -66,6 +66,6 @@
66
66
  "eslint-plugin-security": "^4.0.1",
67
67
  "tsx": "^4.7.0",
68
68
  "typescript": "^5.3.3",
69
- "vitest": "^4.1.0"
69
+ "vitest": "4.1.9"
70
70
  }
71
71
  }