@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 +12 -12
- 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 +16 -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.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 +2 -2
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 `
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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. |
|
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,9 @@ export function defaultKeycloakIssuer(ingestHost) {
|
|
|
185
193
|
return "";
|
|
186
194
|
}
|
|
187
195
|
export function defaultKeycloakClientId() {
|
|
188
|
-
return
|
|
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
|
|
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 (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 =
|
|
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
|
|
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
|
|
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
|
+
}
|
|
@@ -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 / 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
|
|
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.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": "
|
|
69
|
+
"vitest": "4.1.9"
|
|
70
70
|
}
|
|
71
71
|
}
|