@aixle/insights 0.2.5-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 +37 -13
- package/dist/auth/credentials.js +6 -1
- package/dist/auth/exchange.d.ts +9 -0
- package/dist/auth/exchange.js +29 -6
- package/dist/auth/flow.d.ts +3 -0
- package/dist/auth/flow.js +12 -3
- package/dist/auth/keycloak.js +16 -4
- package/dist/cli.js +38 -12
- package/dist/health.d.ts +1 -0
- package/dist/health.js +5 -0
- 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
|
@@ -102,7 +102,27 @@ After `init` succeeds:
|
|
|
102
102
|
|
|
103
103
|
## Multi-org
|
|
104
104
|
|
|
105
|
-
If your account
|
|
105
|
+
If your account belongs to a **single** organization, `init` binds that org automatically — no flag, zero friction.
|
|
106
|
+
|
|
107
|
+
If your account belongs to **more than one** organization, `init` needs to know which org to bind. It resolves the target in this order:
|
|
108
|
+
|
|
109
|
+
1. `--organization-id <uuid>` flag (or `DB90_ORGANIZATION_ID` env var), or
|
|
110
|
+
2. your **Default Organization** preference from the web app.
|
|
111
|
+
|
|
112
|
+
If neither is set, `init` **does not** guess. It prints the organizations you belong to and exits non-zero without saving credentials:
|
|
113
|
+
|
|
114
|
+
```
|
|
115
|
+
Multiple organizations found — choose one to bind this install:
|
|
116
|
+
- Acme Corp (b1e2... ) — owner
|
|
117
|
+
- Contoso (c3d4... ) — member
|
|
118
|
+
Then either:
|
|
119
|
+
1. Re-run init with --organization-id <uuid> (completes device login again), or
|
|
120
|
+
2. Set a Default Organization in web Preferences, then re-run init.
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Because credentials are only persisted on success, re-running `init` means completing the Keycloak device login again.
|
|
124
|
+
|
|
125
|
+
To scope explicitly:
|
|
106
126
|
|
|
107
127
|
```bash
|
|
108
128
|
npx -y @aixle/insights init \
|
|
@@ -111,7 +131,11 @@ npx -y @aixle/insights init \
|
|
|
111
131
|
--organization-id <uuid>
|
|
112
132
|
```
|
|
113
133
|
|
|
114
|
-
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
|
+
|
|
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
|
+
|
|
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`.
|
|
115
139
|
|
|
116
140
|
## First run and backfill
|
|
117
141
|
|
|
@@ -129,7 +153,7 @@ If the MCP has been installed but never connected, `aixle-insights health` (or t
|
|
|
129
153
|
| `aixle-insights run --once` | Perform one multi-tool sync, exit. Useful for cron / manual flushes. |
|
|
130
154
|
| `aixle-insights run --once --full` | Backfill: ignore Cursor watermarks and commit-hash dedupe. |
|
|
131
155
|
| `aixle-insights init` | Keycloak device login + persist credentials + merge `~/.claude.json` entry. |
|
|
132
|
-
| `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. |
|
|
133
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. |
|
|
134
158
|
| `aixle-insights init --hooks --tool-name cursor` | Also install the Cursor-side hook forwarder (opt-in; requires Cursor restart). |
|
|
135
159
|
| `aixle-insights uninstall-hooks` | Remove the hook forwarder + restore `~/.cursor/hooks.json` backup. |
|
|
@@ -140,15 +164,15 @@ If the MCP has been installed but never connected, `aixle-insights health` (or t
|
|
|
140
164
|
|
|
141
165
|
| Variable | Purpose |
|
|
142
166
|
|---|---|
|
|
143
|
-
| `DB90_API_URL` | API origin for ingestion + MCP exchange (defaults to `http://localhost:3000`; `init --host` overrides). |
|
|
144
|
-
| `KEYCLOAK_ISSUER` / `DB90_KEYCLOAK_ISSUER` | Realm issuer URLs (preferred on servers + CI). |
|
|
145
|
-
| `DB90_KEYCLOAK_CLIENT_ID` | Defaults to `db90-web`; must allow device authorization in Keycloak. |
|
|
146
|
-
| `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`). |
|
|
147
171
|
| `AIXLE_INSIGHTS_HOME` | Override the local state directory (defaults to `~/.aixle-insights/`). |
|
|
148
172
|
|
|
149
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.
|
|
150
174
|
|
|
151
|
-
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.
|
|
152
176
|
|
|
153
177
|
Optional `~/.aixle-insights/config.json` accepts Cursor line-cost overrides (per-model rates):
|
|
154
178
|
|
|
@@ -217,7 +241,7 @@ reason. File contents, keychain payloads, and tokens are never logged.
|
|
|
217
241
|
## State + credentials
|
|
218
242
|
|
|
219
243
|
- **App home directory**: `~/.aixle-insights/` (override with `AIXLE_INSIGHTS_HOME`).
|
|
220
|
-
- **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.
|
|
221
245
|
- **State files**: `state-<hostname>-<token-hash>.json` per credential, plus `state.lock` advisory lock, `mcp.log` rotating diagnostic log, optional `hooks-queue.ndjson`.
|
|
222
246
|
|
|
223
247
|
### Credential storage by OS
|
|
@@ -232,7 +256,7 @@ The secure store is the platform's native keychain, accessed via `keytar` (servi
|
|
|
232
256
|
|
|
233
257
|
Notes:
|
|
234
258
|
|
|
235
|
-
- **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.
|
|
236
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.
|
|
237
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.
|
|
238
262
|
|
|
@@ -280,9 +304,9 @@ Three properties are worth relying on:
|
|
|
280
304
|
|
|
281
305
|
| Symptom | Most likely cause | Fix |
|
|
282
306
|
|---|---|---|
|
|
283
|
-
| `Error:
|
|
284
|
-
| `Blocked event send —
|
|
285
|
-
| `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. |
|
|
286
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. |
|
|
287
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. |
|
|
288
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.d.ts
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
export interface OrgOption {
|
|
2
|
+
id: string;
|
|
3
|
+
name: string;
|
|
4
|
+
role: string;
|
|
5
|
+
}
|
|
6
|
+
export declare class OrganizationSelectionRequiredError extends Error {
|
|
7
|
+
readonly organizations: OrgOption[];
|
|
8
|
+
constructor(message: string, organizations: OrgOption[]);
|
|
9
|
+
}
|
|
1
10
|
export interface ExchangeAccount {
|
|
2
11
|
ingestToken: string;
|
|
3
12
|
}
|
package/dist/auth/exchange.js
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
export class OrganizationSelectionRequiredError extends Error {
|
|
2
|
+
organizations;
|
|
3
|
+
constructor(message, organizations) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.name = "OrganizationSelectionRequiredError";
|
|
6
|
+
this.organizations = organizations;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
1
9
|
export async function exchangeIngestToken(params) {
|
|
2
10
|
const fetchFn = params.fetchImpl ?? fetch;
|
|
3
11
|
const requestedTools = params.tools?.length ? [...params.tools] : params.toolName ? [params.toolName] : [];
|
|
@@ -35,21 +43,36 @@ export async function exchangeIngestToken(params) {
|
|
|
35
43
|
json = JSON.parse(text);
|
|
36
44
|
}
|
|
37
45
|
catch {
|
|
38
|
-
throw new Error(`
|
|
46
|
+
throw new Error(`Aixle Insights exchange: invalid JSON (HTTP ${res.status}): ${text.slice(0, 200)}`);
|
|
39
47
|
}
|
|
40
48
|
if (!res.ok) {
|
|
41
|
-
|
|
49
|
+
const errBody = json;
|
|
50
|
+
if (errBody["error"] === "organization_selection_required") {
|
|
51
|
+
const rawOrgs = Array.isArray(errBody["organizations"]) ? errBody["organizations"] : [];
|
|
52
|
+
// Defensive: skip null/non-object elements so a malformed server body cannot crash init.
|
|
53
|
+
const orgs = rawOrgs
|
|
54
|
+
.filter((o) => typeof o === "object" && o !== null && !Array.isArray(o))
|
|
55
|
+
.filter((o) => typeof o["id"] === "string" && typeof o["name"] === "string")
|
|
56
|
+
.map((o) => ({
|
|
57
|
+
id: o["id"],
|
|
58
|
+
name: o["name"],
|
|
59
|
+
role: String(o["role"] ?? ""),
|
|
60
|
+
}));
|
|
61
|
+
const msg = typeof errBody["message"] === "string" ? errBody["message"] : "Organization selection required.";
|
|
62
|
+
throw new OrganizationSelectionRequiredError(msg, orgs);
|
|
63
|
+
}
|
|
64
|
+
throw new Error(`Aixle Insights exchange failed (HTTP ${res.status}): ${text.slice(0, 500)}`);
|
|
42
65
|
}
|
|
43
66
|
const root = json;
|
|
44
67
|
const data = root["data"];
|
|
45
68
|
if (typeof data !== "object" || data === null) {
|
|
46
|
-
throw new Error("
|
|
69
|
+
throw new Error("Aixle Insights exchange: missing data object");
|
|
47
70
|
}
|
|
48
71
|
const d = data;
|
|
49
72
|
const ingestHost = d["ingestHost"];
|
|
50
73
|
const organizationId = d["organizationId"];
|
|
51
74
|
if (typeof ingestHost !== "string" || typeof organizationId !== "string") {
|
|
52
|
-
throw new Error("
|
|
75
|
+
throw new Error("Aixle Insights exchange: missing ingestHost or organizationId in data");
|
|
53
76
|
}
|
|
54
77
|
const ingestTokenRaw = d["ingestToken"];
|
|
55
78
|
const accountsRaw = d["accounts"];
|
|
@@ -73,7 +96,7 @@ export async function exchangeIngestToken(params) {
|
|
|
73
96
|
}
|
|
74
97
|
if (Object.keys(accounts).length === 0) {
|
|
75
98
|
if (!ingestToken) {
|
|
76
|
-
throw new Error("
|
|
99
|
+
throw new Error("Aixle Insights exchange: no ingestToken / accounts returned in data");
|
|
77
100
|
}
|
|
78
101
|
const fallbackTool = requestedTools.length === 1 ? requestedTools[0] : d["toolName"];
|
|
79
102
|
const tid = fallbackTool === "cursor" ? "cursor" : "claude_code";
|
|
@@ -81,7 +104,7 @@ export async function exchangeIngestToken(params) {
|
|
|
81
104
|
}
|
|
82
105
|
const missingTools = requestedTools.filter((tool) => !accounts[tool]?.ingestToken);
|
|
83
106
|
if (missingTools.length > 0) {
|
|
84
|
-
throw new Error(`
|
|
107
|
+
throw new Error(`Aixle Insights exchange: missing requested account(s): ${missingTools.join(", ")}`);
|
|
85
108
|
}
|
|
86
109
|
return { ingestHost, organizationId, ingestToken, accounts };
|
|
87
110
|
}
|
package/dist/auth/flow.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type OrgOption } from "./exchange.js";
|
|
1
2
|
import { defaultKeycloakClientId, defaultKeycloakIssuer } from "./keycloak.js";
|
|
2
3
|
import type { TelemetryToolId } from "./credentials.js";
|
|
3
4
|
export interface LoginAndPersistOptions {
|
|
@@ -29,5 +30,7 @@ export declare function loginAndPersistCredentials(opts: LoginAndPersistOptions)
|
|
|
29
30
|
} | {
|
|
30
31
|
ok: false;
|
|
31
32
|
error: string;
|
|
33
|
+
code?: "organization_selection_required";
|
|
34
|
+
organizations?: OrgOption[];
|
|
32
35
|
}>;
|
|
33
36
|
export { defaultKeycloakIssuer, defaultKeycloakClientId };
|
package/dist/auth/flow.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { exchangeIngestToken } from "./exchange.js";
|
|
1
|
+
import { exchangeIngestToken, OrganizationSelectionRequiredError } from "./exchange.js";
|
|
2
2
|
import { defaultKeycloakClientId, defaultKeycloakIssuer, obtainKeycloakAccessTokenViaDeviceFlow } from "./keycloak.js";
|
|
3
3
|
import { loadCredentials, saveStoredCredentials } from "./credentials.js";
|
|
4
4
|
import { getAppDir } from "../state.js";
|
|
@@ -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 };
|
|
@@ -78,10 +78,11 @@ export async function loginAndPersistCredentials(opts) {
|
|
|
78
78
|
opts.onSecurityWarning?.(ingestHostSecurity.warning);
|
|
79
79
|
}
|
|
80
80
|
const existing = await loadCredentials(appDir);
|
|
81
|
+
const sameOrg = existing?.host === exchanged.ingestHost && existing.organizationId === exchanged.organizationId;
|
|
81
82
|
const stored = {
|
|
82
83
|
host: exchanged.ingestHost,
|
|
83
84
|
organizationId: exchanged.organizationId,
|
|
84
|
-
accounts:
|
|
85
|
+
accounts: sameOrg ? { ...existing.accounts } : {},
|
|
85
86
|
...(opts.allowInsecureHttp === true ? { insecureHttpAllowed: true } : {}),
|
|
86
87
|
};
|
|
87
88
|
for (const tid of ["claude_code", "cursor"]) {
|
|
@@ -92,6 +93,14 @@ export async function loginAndPersistCredentials(opts) {
|
|
|
92
93
|
await saveStoredCredentials(stored, appDir);
|
|
93
94
|
}
|
|
94
95
|
catch (e) {
|
|
96
|
+
if (e instanceof OrganizationSelectionRequiredError) {
|
|
97
|
+
return {
|
|
98
|
+
ok: false,
|
|
99
|
+
code: "organization_selection_required",
|
|
100
|
+
organizations: e.organizations,
|
|
101
|
+
error: e.message,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
95
104
|
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
96
105
|
}
|
|
97
106
|
return { ok: true, organizationId: exchanged.organizationId };
|
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,7 +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
|
-
Set \`DB90_ORGANIZATION_ID\` to a UUID, or pass \`--organization-id\` on \`init\`, so ingest tokens are minted for that membership instead of the default (oldest) org.
|
|
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.
|
|
189
190
|
|
|
190
191
|
Credentials:
|
|
191
192
|
Stored in the OS keychain via keytar when available; otherwise
|
|
@@ -195,7 +196,11 @@ Note: Omitting --tool-name provisions separate ingest tokens for Claude Code + C
|
|
|
195
196
|
`);
|
|
196
197
|
}
|
|
197
198
|
function defaultApiHost() {
|
|
198
|
-
const v =
|
|
199
|
+
const v = readEnvWithDeprecatedAlias({
|
|
200
|
+
current: "AIXLE_INSIGHTS_API_URL",
|
|
201
|
+
deprecated: "DB90_API_URL",
|
|
202
|
+
onDeprecatedUse: warnDeprecatedEnvVar,
|
|
203
|
+
});
|
|
199
204
|
if (v)
|
|
200
205
|
return v.replace(/\/$/, "");
|
|
201
206
|
return "http://localhost:3000";
|
|
@@ -222,7 +227,7 @@ export async function runInit(cliArgs, deps) {
|
|
|
222
227
|
const apiHost = (cliArgs.host ?? defaultApiHost()).replace(/\/$/, "");
|
|
223
228
|
const transportSecurity = evaluateTransportSecurity(apiHost, {
|
|
224
229
|
allowInsecureHttp: cliArgs.insecure === true,
|
|
225
|
-
label: "
|
|
230
|
+
label: "Aixle Insights API host",
|
|
226
231
|
});
|
|
227
232
|
if (!transportSecurity.ok) {
|
|
228
233
|
runtime.error(`Error: ${transportSecurity.error}`);
|
|
@@ -231,7 +236,11 @@ export async function runInit(cliArgs, deps) {
|
|
|
231
236
|
if (transportSecurity.warning) {
|
|
232
237
|
runtime.error(`Warning: ${transportSecurity.warning}`);
|
|
233
238
|
}
|
|
234
|
-
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
|
+
}
|
|
235
244
|
if (cliArgs.toolName !== undefined && !["claude_code", "cursor"].includes(cliArgs.toolName)) {
|
|
236
245
|
runtime.error("Error: --tool-name must be one of: claude_code, cursor.");
|
|
237
246
|
return 1;
|
|
@@ -242,10 +251,14 @@ export async function runInit(cliArgs, deps) {
|
|
|
242
251
|
? ["claude_code"]
|
|
243
252
|
: ["claude_code", "cursor"];
|
|
244
253
|
const fromFlag = cliArgs.organizationId?.trim();
|
|
245
|
-
const fromEnv =
|
|
254
|
+
const fromEnv = readEnvWithDeprecatedAlias({
|
|
255
|
+
current: "AIXLE_INSIGHTS_ORGANIZATION_ID",
|
|
256
|
+
deprecated: "DB90_ORGANIZATION_ID",
|
|
257
|
+
onDeprecatedUse: warnDeprecatedEnvVar,
|
|
258
|
+
});
|
|
246
259
|
const exchangeOrganizationId = fromFlag || fromEnv;
|
|
247
260
|
if (exchangeOrganizationId && !isValidOrganizationUuid(exchangeOrganizationId)) {
|
|
248
|
-
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).");
|
|
249
262
|
return 1;
|
|
250
263
|
}
|
|
251
264
|
const result = await runtime.loginAndPersistCredentials({
|
|
@@ -270,6 +283,19 @@ export async function runInit(cliArgs, deps) {
|
|
|
270
283
|
},
|
|
271
284
|
});
|
|
272
285
|
if (!result.ok) {
|
|
286
|
+
if (result.code === "organization_selection_required") {
|
|
287
|
+
runtime.error(result.error);
|
|
288
|
+
if (result.organizations?.length) {
|
|
289
|
+
runtime.error("Multiple organizations found — choose one to bind this install:");
|
|
290
|
+
for (const o of result.organizations) {
|
|
291
|
+
runtime.error(` - ${o.name} (${o.id})${o.role ? ` — ${o.role}` : ""}`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
runtime.error("Then either:");
|
|
295
|
+
runtime.error(" 1. Re-run init with --organization-id <uuid> (completes device login again), or");
|
|
296
|
+
runtime.error(" 2. Set a Default Organization in web Preferences, then re-run init.");
|
|
297
|
+
return 1;
|
|
298
|
+
}
|
|
273
299
|
runtime.error(`Auth failed: ${result.error}`);
|
|
274
300
|
return 1;
|
|
275
301
|
}
|
|
@@ -437,7 +463,7 @@ async function main() {
|
|
|
437
463
|
console.log(backupPath ? `Hooks uninstalled; hooks.json restored from ${backupPath}.` : "Hooks uninstalled; hooks.json removed.");
|
|
438
464
|
}
|
|
439
465
|
else {
|
|
440
|
-
console.log("No
|
|
466
|
+
console.log("No Aixle Insights hooks entry found in ~/.cursor/hooks.json — nothing to uninstall.");
|
|
441
467
|
}
|
|
442
468
|
return;
|
|
443
469
|
}
|
package/dist/health.d.ts
CHANGED
package/dist/health.js
CHANGED
|
@@ -70,6 +70,7 @@ export async function buildHealthSnapshot() {
|
|
|
70
70
|
authenticated: false,
|
|
71
71
|
configured: false,
|
|
72
72
|
host: null,
|
|
73
|
+
organization_id: null,
|
|
73
74
|
ingest_tools: [],
|
|
74
75
|
app_dir: appDir,
|
|
75
76
|
log_path: logPath,
|
|
@@ -94,6 +95,7 @@ export async function buildHealthSnapshot() {
|
|
|
94
95
|
authenticated: true,
|
|
95
96
|
configured: true,
|
|
96
97
|
host: creds.host,
|
|
98
|
+
organization_id: creds.organizationId ?? null,
|
|
97
99
|
ingest_tools,
|
|
98
100
|
app_dir: appDir,
|
|
99
101
|
log_path: logPath,
|
|
@@ -114,6 +116,7 @@ export async function buildHealthSnapshot() {
|
|
|
114
116
|
authenticated: false,
|
|
115
117
|
configured: false,
|
|
116
118
|
host: null,
|
|
119
|
+
organization_id: null,
|
|
117
120
|
ingest_tools: [],
|
|
118
121
|
app_dir: appDir,
|
|
119
122
|
log_path: logPath,
|
|
@@ -150,6 +153,7 @@ export function healthSnapshotToStatusPayload(snapshot) {
|
|
|
150
153
|
authenticated: snapshot.authenticated,
|
|
151
154
|
configured: snapshot.configured,
|
|
152
155
|
host: snapshot.host,
|
|
156
|
+
organization_id: snapshot.organization_id,
|
|
153
157
|
ingest_tools: snapshot.ingest_tools,
|
|
154
158
|
needs_init: needsInit,
|
|
155
159
|
onboarding_message: onboardingMessage,
|
|
@@ -172,6 +176,7 @@ export function formatHealthForCli(snapshot) {
|
|
|
172
176
|
lines.push(`authenticated: ${snapshot.authenticated}`);
|
|
173
177
|
lines.push(`configured: ${snapshot.configured}`);
|
|
174
178
|
lines.push(`host: ${snapshot.host ?? "(none)"}`);
|
|
179
|
+
lines.push(`organization_id: ${snapshot.organization_id ?? "(none)"}`);
|
|
175
180
|
lines.push(`ingest_tools: ${snapshot.ingest_tools.length ? snapshot.ingest_tools.join(", ") : "(none)"}`);
|
|
176
181
|
lines.push(`state_file_paths:`);
|
|
177
182
|
if (snapshot.state_file_paths.length === 0) {
|
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
|
}
|