@aixle/insights 0.2.3-staging → 0.2.5-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
@@ -162,6 +162,12 @@ Optional `~/.aixle-insights/config.json` accepts Cursor line-cost overrides (per
162
162
  }
163
163
  ```
164
164
 
165
+ The file is optional — an absent `config.json` is the normal case and is silent. A file that is
166
+ present but unusable is ignored entirely (every override falls back to its default) and a
167
+ `config_parse_failed` line is written to `mcp.log`. That covers malformed JSON and valid JSON that
168
+ isn't an object, including a **top-level array** — a common mistake when writing per-model rates.
169
+ Nothing is printed to the terminal, so check the log if an override appears to have no effect.
170
+
165
171
  ## Security
166
172
 
167
173
  `@aixle/insights` enforces HTTPS for any remote host. Plaintext `http://` is allowed only for loopback (`localhost`, `127.0.0.0/8`, `[::1]`) so that local-dev flows against `make up` continue to work without friction.
@@ -188,6 +194,22 @@ The runtime gate exists because `init`'s two gates only run once, at login time.
188
194
 
189
195
  The Keycloak issuer URL (`--keycloak-url` / `KEYCLOAK_ISSUER`) is **not** TLS-gated by this package. Same threat model, different ticket — tracked separately. For now, use HTTPS for any remote Keycloak issuer; the OIDC device-flow library will fail the request if the cert is invalid, but it will not refuse to attempt plaintext.
190
196
 
197
+ ### Local store integrity
198
+
199
+ Transport security covers data in flight. The other half of the threat model is what the package
200
+ reads back off the local machine: credentials (keychain or file), `config.json`, and state files are
201
+ all attacker-writable if the account is compromised, so none of them is trusted on read.
202
+
203
+ Each is validated every time it is loaded. A payload that fails to parse, or that parses but does
204
+ not match the expected shape, is **rejected** and the caller falls back to its documented default —
205
+ no credentials, no config overrides, fresh state. Every rejection is recorded in `mcp.log`, so a
206
+ corrupted or tampered store is distinguishable from one that was never created; before this, both
207
+ were silent and looked identical to a fresh install. See
208
+ [Diagnostics](#diagnostics) for the event names.
209
+
210
+ Log fields carry only the file path (or the keychain service name) and a short machine-readable
211
+ reason. File contents, keychain payloads, and tokens are never logged.
212
+
191
213
  ## Cursor hook forwarder (opt-in)
192
214
 
193
215
  `aixle-insights init --hooks --tool-name cursor` installs a Node script as a Cursor hook (`~/.cursor/hooks.json`). The script appends redacted hook payloads to `~/.aixle-insights/hooks-queue.ndjson`; the background sync drains the queue on its next cycle and POSTs the events with accurate per-turn model attribution. Requires a Cursor restart after install. To remove, run `aixle-insights uninstall-hooks` and restart Cursor again.
@@ -225,6 +247,35 @@ aixle-insights verify-hooks # JSON: hooks installed + queue depth
225
247
 
226
248
  `mcp.log` (rotates at 5 MiB to `mcp.log.1`) under the app home directory captures operational events. Inside Claude Code, the **`aixle_insights_status`** MCP tool returns the same diagnostic structure as `aixle-insights health`.
227
249
 
250
+ ### Local-store integrity events
251
+
252
+ These four are the only signal that a local store was present but unusable — `health` and
253
+ `aixle_insights_status` do **not** report them, so `mcp.log` is the sole surface:
254
+
255
+ | Event | Fires when |
256
+ |---|---|
257
+ | `credentials_parse_failed` | `credentials.json` exists but was rejected |
258
+ | `credentials_keytar_parse_failed` | the OS keychain entry exists but was rejected |
259
+ | `config_parse_failed` | `config.json` exists but was rejected |
260
+ | `state_parse_failed` | a state file exists but was rejected |
261
+
262
+ Each carries a `reason` distinguishing the two failure modes:
263
+
264
+ - `invalid_json` — the payload did not parse at all.
265
+ - `invalid_shape` — it parsed, but validation rejected it: credentials with no usable token, a
266
+ `config.json` that is a JSON array, a state file missing `version` / `sessions`, and so on.
267
+
268
+ Three properties are worth relying on:
269
+
270
+ - An **absent** file never warns. That is the everyday case (most users never create a
271
+ `config.json`, and every machine starts with no state file), so a warning always means something
272
+ is actually there and wrong.
273
+ - A **missing or disabled OS keychain** never warns either — falling back to the file is expected
274
+ on headless Linux, CI, and containers, not an error.
275
+ - All four are written to the log **only**, never mirrored to stderr, because stray output on the
276
+ stdio transport corrupts the MCP protocol. Emitting a warning never changes the fallback the
277
+ caller returns.
278
+
228
279
  ## Troubleshooting
229
280
 
230
281
  | Symptom | Most likely cause | Fix |
@@ -236,6 +287,9 @@ aixle-insights verify-hooks # JSON: hooks installed + queue depth
236
287
  | `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. |
237
288
  | `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. |
238
289
  | `sync_lock_skip {reason: "advisory_lock_held"}` in the log | Another sync cycle is still holding `~/.aixle-insights/state.lock`. | Wait for it to finish; only delete the lock file (`rm -f ~/.aixle-insights/state.lock`) after confirming no `aixle-insights run` process is alive (`pgrep -fa aixle-insights`). |
290
+ | `health` reports `authenticated: false` right after a successful `init`, and `credentials_parse_failed` or `credentials_keytar_parse_failed` is in the log | The credential store exists but was rejected, so it is treated as absent. The `reason` field says whether it failed to parse (`invalid_json`) or parsed into the wrong shape (`invalid_shape`). | Re-run `init`. If you hand-edited `credentials.json` for local testing, remember the keychain is read **first** — see [State + credentials](#state--credentials). |
291
+ | A `config.json` override has no effect, and `config_parse_failed` is in the log | The file is malformed, or is valid JSON that is not an object — a top-level array is the usual mistake. | Fix it to match the shape under [Environment](#environment); until it parses, every override is ignored. |
292
+ | Sync re-sends history that was already delivered, and `state_parse_failed` is in the log | A state file was present but rejected, so sync fell back to fresh state and lost its dedup checkpoints. | This is recovery, not a loop — the next successful cycle writes valid state. Ingest upserts by session, so duplicates are absorbed. Worth investigating what wrote the bad file. |
239
293
  | `aixle-insights --help` doesn't list `--insecure` | You're running an older published version of the package, not the local source. | `which aixle-insights` shows the path. To run local source: `cd packages/tools/aixle-insights && npm run build && npm link`. To return to the published version: `npm unlink -g @aixle/insights && npm install -g @aixle/insights@latest`. |
240
294
  | Not sure whether `aixle-insights` is a `npm link` or a real install | Real installs are regular files; `npm link` is a symlink chain into the repo. | `readlink "$(which aixle-insights)"` shows the link target if any. A linked install will trace back to a path under your monorepo checkout. |
241
295
 
@@ -4,6 +4,7 @@ import { userInfo } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { getAppDir } from "../state.js";
6
6
  import { mcpLog } from "../log.js";
7
+ import { describeReadFailure } from "../lib/parse-error.js";
7
8
  export const KEYTAR_SERVICE = "aixle-insights";
8
9
  const KEYTAR_ACCOUNT = "aixle-insights-ingest-credential";
9
10
  function credentialsPath(appDir) {
@@ -62,15 +63,23 @@ export function loadCredentialsFromFileOnly(appDir = getAppDir()) {
62
63
  const filePath = credentialsPath(appDir);
63
64
  if (!existsSync(filePath))
64
65
  return null;
66
+ let raw;
65
67
  try {
66
- const raw = JSON.parse(readFileSync(filePath, "utf-8"));
67
- return normalizeLoadedCredentials(raw);
68
+ raw = JSON.parse(readFileSync(filePath, "utf-8"));
68
69
  }
69
70
  catch (err) {
70
- // File exists (checked above) but failed to parse/normalize — distinguishes tampering from "never created".
71
- mcpLog.warn("credentials_parse_failed", { path: filePath, error: err instanceof Error ? err.message : String(err) }, false);
71
+ // File exists (checked above) but is not readable/valid JSON — distinguishes tampering from "never created".
72
+ mcpLog.warn("credentials_parse_failed", { path: filePath, ...describeReadFailure(err) }, false);
72
73
  return null;
73
74
  }
75
+ const normalized = normalizeLoadedCredentials(raw);
76
+ if (normalized === null) {
77
+ // Valid JSON, but not a credential shape we accept. `normalizeLoadedCredentials` signals
78
+ // rejection by returning null and never throws, so this cannot surface in the catch
79
+ // above — without this branch a plausible-looking replacement file stays silent. (DB90DV-699)
80
+ mcpLog.warn("credentials_parse_failed", { path: filePath, reason: "invalid_shape" }, false);
81
+ }
82
+ return normalized;
74
83
  }
75
84
  async function tryKeytarGet() {
76
85
  if (keytarDisabled())
@@ -86,15 +95,22 @@ async function tryKeytarGet() {
86
95
  }
87
96
  if (!raw)
88
97
  return null;
98
+ let parsed;
89
99
  try {
90
- const parsed = JSON.parse(raw);
91
- return normalizeLoadedCredentials(parsed);
100
+ parsed = JSON.parse(raw);
92
101
  }
93
102
  catch (err) {
94
- // Keychain entry exists (checked above) but failed to parse/normalize — distinguishes tampering from "no entry".
95
- mcpLog.warn("credentials_keytar_parse_failed", { keytarService: KEYTAR_SERVICE, error: err instanceof Error ? err.message : String(err) }, false);
103
+ // Keychain entry exists (checked above) but is not valid JSON — distinguishes tampering from "no entry".
104
+ mcpLog.warn("credentials_keytar_parse_failed", { keytarService: KEYTAR_SERVICE, ...describeReadFailure(err) }, false);
96
105
  return null;
97
106
  }
107
+ const normalized = normalizeLoadedCredentials(parsed);
108
+ if (normalized === null) {
109
+ // Same shape-rejection hole as the file path above. Fields stay service-only — never the
110
+ // keychain payload. (DB90DV-699)
111
+ mcpLog.warn("credentials_keytar_parse_failed", { keytarService: KEYTAR_SERVICE, reason: "invalid_shape" }, false);
112
+ }
113
+ return normalized;
98
114
  }
99
115
  async function tryKeytarSet(payload) {
100
116
  if (keytarDisabled())
package/dist/cli.js CHANGED
@@ -377,10 +377,12 @@ export async function runOnce(deps, options) {
377
377
  const lookupToken = pickProjectLookupToken(creds);
378
378
  let projectId = null;
379
379
  let projectIdSource = "none";
380
+ let repositoryId = null;
380
381
  if (lookupToken) {
381
382
  const resolution = await runtime.resolveProjectId(undefined, undefined, creds.host, lookupToken, false, creds.insecureHttpAllowed === true);
382
383
  projectId = resolution.projectId;
383
384
  projectIdSource = resolution.source;
385
+ repositoryId = resolution.repositoryId;
384
386
  mcpLog.info("project_attribution_resolved", { project_id: resolution.projectId, source: resolution.source }, false);
385
387
  }
386
388
  const result = await runtime.syncTelemetryTools({
@@ -389,6 +391,7 @@ export async function runOnce(deps, options) {
389
391
  verbose: false,
390
392
  projectId,
391
393
  projectIdSource,
394
+ repositoryId,
392
395
  projectLookupToken: lookupToken,
393
396
  pricing: runtime.pricing,
394
397
  cursorPricing: resolveCursorPricing(undefined, appDirRuntime),
@@ -19,4 +19,4 @@ export declare function warnOnCursorVersion(event: HookLogEvent, verbose: boolea
19
19
  * Map a validated hook event to the CursorPayload contract.
20
20
  * Call shouldIngestHookEvent() before this — it does not re-validate.
21
21
  */
22
- export declare function mapHookEventToPayload(event: HookLogEvent, projectId?: string | null): CursorPayload;
22
+ export declare function mapHookEventToPayload(event: HookLogEvent, projectId?: string | null, repositoryId?: string | null): CursorPayload;
@@ -50,7 +50,7 @@ export function warnOnCursorVersion(event, verbose) {
50
50
  * Map a validated hook event to the CursorPayload contract.
51
51
  * Call shouldIngestHookEvent() before this — it does not re-validate.
52
52
  */
53
- export function mapHookEventToPayload(event, projectId) {
53
+ export function mapHookEventToPayload(event, projectId, repositoryId) {
54
54
  const workspace = Array.isArray(event.workspace_roots) && typeof event.workspace_roots[0] === "string"
55
55
  ? event.workspace_roots[0]
56
56
  : "unknown";
@@ -80,5 +80,7 @@ export function mapHookEventToPayload(event, projectId) {
80
80
  };
81
81
  if (projectId)
82
82
  payload.project_id = projectId;
83
+ if (repositoryId)
84
+ payload.repository_id = repositoryId;
83
85
  return payload;
84
86
  }
@@ -14,7 +14,16 @@ export interface ProcessHooksQueueParams {
14
14
  on429: (retryAfter: number, quotaExceeded: boolean) => void;
15
15
  /** If true, skip events already in state.sessions. */
16
16
  skipSeen?: boolean;
17
- resolveProjectId?: (workspace: string) => Promise<string | null>;
17
+ /**
18
+ * Resolve project + repository attribution for a workspace path. Returning a
19
+ * repositoryId lets hook events carry the same repository_id the sync path
20
+ * stamps. A null repositoryId (or an older resolver returning only a string)
21
+ * simply omits it.
22
+ */
23
+ resolveProjectId?: (workspace: string) => Promise<{
24
+ projectId: string | null;
25
+ repositoryId: string | null;
26
+ } | string | null>;
18
27
  verbose?: boolean;
19
28
  }
20
29
  export interface ProcessHooksResult {
@@ -100,10 +100,18 @@ export async function processHooksQueue(params) {
100
100
  continue;
101
101
  }
102
102
  let projectId;
103
+ let repositoryId;
103
104
  if (resolveProjectId && workspace) {
104
- projectId = await resolveProjectId(workspace);
105
+ const resolved = await resolveProjectId(workspace);
106
+ if (typeof resolved === "string" || resolved === null) {
107
+ projectId = resolved;
108
+ }
109
+ else {
110
+ projectId = resolved.projectId;
111
+ repositoryId = resolved.repositoryId;
112
+ }
105
113
  }
106
- const payload = mapHookEventToPayload(event, projectId);
114
+ const payload = mapHookEventToPayload(event, projectId, repositoryId);
107
115
  const ok = await postEvent(payload, host, token, { on429, allowInsecureHttp });
108
116
  if (ok) {
109
117
  totalSent++;
@@ -12,8 +12,8 @@ export interface BaseConfig {
12
12
  * Load a connector's `config.json` from disk. Returns `{}` on missing or
13
13
  * malformed files — callers fall back to env vars / CLI flags / defaults.
14
14
  *
15
- * @param configDir Directory containing `config.json`, typically the
16
- * connector's `APP_DIR` (`~/.db90-claude` / `~/.db90-cursor`).
15
+ * @param configDir Directory containing `config.json`, typically the app home directory
16
+ * (`~/.aixle-insights`, or `AIXLE_INSIGHTS_HOME` when set).
17
17
  * @param parsePricing Optional callback that extracts a connector-specific
18
18
  * pricing shape from the raw parsed JSON. Returns
19
19
  * `undefined` when the pricing block is missing or invalid.
@@ -1,12 +1,13 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { mcpLog } from "../log.js";
4
+ import { describeReadFailure } from "./parse-error.js";
4
5
  /**
5
6
  * Load a connector's `config.json` from disk. Returns `{}` on missing or
6
7
  * malformed files — callers fall back to env vars / CLI flags / defaults.
7
8
  *
8
- * @param configDir Directory containing `config.json`, typically the
9
- * connector's `APP_DIR` (`~/.db90-claude` / `~/.db90-cursor`).
9
+ * @param configDir Directory containing `config.json`, typically the app home directory
10
+ * (`~/.aixle-insights`, or `AIXLE_INSIGHTS_HOME` when set).
10
11
  * @param parsePricing Optional callback that extracts a connector-specific
11
12
  * pricing shape from the raw parsed JSON. Returns
12
13
  * `undefined` when the pricing block is missing or invalid.
@@ -16,29 +17,36 @@ import { mcpLog } from "../log.js";
16
17
  */
17
18
  export function loadBaseConfig(configDir, parsePricing) {
18
19
  const configPath = join(configDir, "config.json");
20
+ let parsed;
19
21
  try {
20
- const parsed = JSON.parse(readFileSync(configPath, "utf-8"));
21
- if (typeof parsed === "object" && parsed !== null) {
22
- const obj = parsed;
23
- const result = {
24
- token: typeof obj.token === "string" ? obj.token : undefined,
25
- host: typeof obj.host === "string" ? obj.host : undefined,
26
- project_id: typeof obj.project_id === "string" ? obj.project_id : undefined,
27
- };
28
- if (parsePricing) {
29
- const pricing = parsePricing(obj);
30
- if (pricing !== undefined)
31
- result.pricing = pricing;
32
- }
33
- return result;
34
- }
22
+ parsed = JSON.parse(readFileSync(configPath, "utf-8"));
35
23
  }
36
24
  catch (err) {
37
25
  const code = err?.code;
38
26
  if (code !== "ENOENT") {
39
- // Config file exists but failed to parse — distinguishes tampering from "never created".
40
- mcpLog.warn("config_parse_failed", { path: configPath, error: err instanceof Error ? err.message : String(err) }, false);
27
+ // Config file exists but is not valid JSON — distinguishes tampering from "never created".
28
+ // ENOENT stays silent: this file is optional and most users never create it.
29
+ mcpLog.warn("config_parse_failed", { path: configPath, ...describeReadFailure(err) }, false);
41
30
  }
31
+ return {};
32
+ }
33
+ // Valid JSON, but not a config object. Arrays are rejected explicitly because
34
+ // `typeof [] === "object"` would otherwise let them reach the happy path and be handed
35
+ // to `parsePricing`. Previously every non-object fell through silently. (DB90DV-699)
36
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
37
+ mcpLog.warn("config_parse_failed", { path: configPath, reason: "invalid_shape" }, false);
38
+ return {};
39
+ }
40
+ const obj = parsed;
41
+ const result = {
42
+ token: typeof obj.token === "string" ? obj.token : undefined,
43
+ host: typeof obj.host === "string" ? obj.host : undefined,
44
+ project_id: typeof obj.project_id === "string" ? obj.project_id : undefined,
45
+ };
46
+ if (parsePricing) {
47
+ const pricing = parsePricing(obj);
48
+ if (pricing !== undefined)
49
+ result.pricing = pricing;
42
50
  }
43
- return {};
51
+ return result;
44
52
  }
@@ -0,0 +1,21 @@
1
+ export type ReadFailureReason = "invalid_json" | "unreadable";
2
+ /**
3
+ * Classifies a caught error from `JSON.parse(readFileSync(...))` (or a parsed keychain
4
+ * payload) into a log-safe reason + error string.
5
+ *
6
+ * V8's `JSON.parse` throws a `SyntaxError` whose `.message` can embed a prefix (or, for a
7
+ * short enough input, the entirety) of the unparsed content — e.g.
8
+ * `JSON.parse("example_local_fixture_1234567890")` produces
9
+ * `Unexpected token 'e', "example_lo"... is not valid JSON`. Logging that message would
10
+ * leak exactly the secret content the parse-failure events exist to describe without
11
+ * exposing (see `credentials_parse_failed` / `credentials_keytar_parse_failed` /
12
+ * `config_parse_failed` / `state_parse_failed`). So for a `SyntaxError` this reports only
13
+ * the error name, never `.message`.
14
+ *
15
+ * Any other error (fs I/O — `EACCES`, `EISDIR`, etc.) is reported as `unreadable` using its
16
+ * errno `code`, which never contains file content and is more actionable than a bare name.
17
+ */
18
+ export declare function describeReadFailure(err: unknown): {
19
+ reason: ReadFailureReason;
20
+ error: string;
21
+ };
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Classifies a caught error from `JSON.parse(readFileSync(...))` (or a parsed keychain
3
+ * payload) into a log-safe reason + error string.
4
+ *
5
+ * V8's `JSON.parse` throws a `SyntaxError` whose `.message` can embed a prefix (or, for a
6
+ * short enough input, the entirety) of the unparsed content — e.g.
7
+ * `JSON.parse("example_local_fixture_1234567890")` produces
8
+ * `Unexpected token 'e', "example_lo"... is not valid JSON`. Logging that message would
9
+ * leak exactly the secret content the parse-failure events exist to describe without
10
+ * exposing (see `credentials_parse_failed` / `credentials_keytar_parse_failed` /
11
+ * `config_parse_failed` / `state_parse_failed`). So for a `SyntaxError` this reports only
12
+ * the error name, never `.message`.
13
+ *
14
+ * Any other error (fs I/O — `EACCES`, `EISDIR`, etc.) is reported as `unreadable` using its
15
+ * errno `code`, which never contains file content and is more actionable than a bare name.
16
+ */
17
+ export function describeReadFailure(err) {
18
+ if (err instanceof SyntaxError) {
19
+ return { reason: "invalid_json", error: "SyntaxError" };
20
+ }
21
+ const code = err?.code;
22
+ if (code)
23
+ return { reason: "unreadable", error: code };
24
+ return { reason: "unreadable", error: err instanceof Error ? err.name : "unknown_error" };
25
+ }
@@ -1,9 +1,17 @@
1
1
  export interface ProjectResolution {
2
2
  projectId: string | null;
3
+ /**
4
+ * Repository that owns the resolved project's attribution, when the API
5
+ * resolved one from the git remote. Only populated on auto-detect (the API
6
+ * only knows the repository via the remote lookup). Null for flag/config
7
+ * attribution — an explicit project id carries no repository context.
8
+ */
9
+ repositoryId: string | null;
3
10
  source: "flag" | "config" | "auto-detect" | "auto-detect-not-found" | "none";
4
11
  }
5
12
  export interface LookupResult {
6
13
  project_id: string;
14
+ repository_id?: string | null;
7
15
  name: string;
8
16
  }
9
17
  export declare function resolveProjectId(flagValue: string | undefined, configValue: string | undefined, host: string, token: string, verbose: boolean, allowInsecureHttp?: boolean): Promise<ProjectResolution>;
@@ -10,29 +10,31 @@ export async function resolveProjectId(flagValue, configValue, host, token, verb
10
10
  const flag = coerce(flagValue);
11
11
  const config = coerce(configValue);
12
12
  if (flag !== undefined)
13
- return { projectId: flag, source: "flag" };
13
+ return { projectId: flag, repositoryId: null, source: "flag" };
14
14
  if (config !== undefined)
15
- return { projectId: config, source: "config" };
15
+ return { projectId: config, repositoryId: null, source: "config" };
16
16
  const gitRemote = getGitRemote(verbose);
17
17
  if (gitRemote === null)
18
- return { projectId: null, source: "none" };
18
+ return { projectId: null, repositoryId: null, source: "none" };
19
19
  const result = await lookupProjectByRemote(gitRemote, host, token, verbose, allowInsecureHttp);
20
20
  if (result === "not-found")
21
- return { projectId: null, source: "auto-detect-not-found" };
22
- if (result !== null)
23
- return { projectId: result.project_id, source: "auto-detect" };
24
- return { projectId: null, source: "none" };
21
+ return { projectId: null, repositoryId: null, source: "auto-detect-not-found" };
22
+ if (result !== null) {
23
+ return { projectId: result.project_id, repositoryId: result.repository_id ?? null, source: "auto-detect" };
24
+ }
25
+ return { projectId: null, repositoryId: null, source: "none" };
25
26
  }
26
27
  export async function resolveProjectIdForRepoPath(repoPath, host, token, verbose, allowInsecureHttp = false) {
27
28
  const gitRemote = getGitRemoteForPath(repoPath, verbose);
28
29
  if (gitRemote === null)
29
- return { projectId: null, source: "none" };
30
+ return { projectId: null, repositoryId: null, source: "none" };
30
31
  const result = await lookupProjectByRemote(gitRemote, host, token, verbose, allowInsecureHttp);
31
32
  if (result === "not-found")
32
- return { projectId: null, source: "auto-detect-not-found" };
33
- if (result !== null)
34
- return { projectId: result.project_id, source: "auto-detect" };
35
- return { projectId: null, source: "none" };
33
+ return { projectId: null, repositoryId: null, source: "auto-detect-not-found" };
34
+ if (result !== null) {
35
+ return { projectId: result.project_id, repositoryId: result.repository_id ?? null, source: "auto-detect" };
36
+ }
37
+ return { projectId: null, repositoryId: null, source: "none" };
36
38
  }
37
39
  export function getGitRemote(verbose) {
38
40
  try {
@@ -145,7 +147,10 @@ function isLookupResponse(body) {
145
147
  if (typeof d !== "object" || d === null)
146
148
  return false;
147
149
  const data = d;
148
- return typeof data.project_id === "string" && typeof data.name === "string";
150
+ if (typeof data.project_id !== "string" || typeof data.name !== "string")
151
+ return false;
152
+ // repository_id is optional (older servers omit it) but must be a string when present.
153
+ return data.repository_id == null || typeof data.repository_id === "string";
149
154
  }
150
155
  /**
151
156
  * Cursor `recentCommit.repoName` is typically `owner/repo` (GitHub-style slug), not a full
@@ -72,6 +72,7 @@ export interface ClaudePayload extends IngestPayload {
72
72
  cost_usd: number | null;
73
73
  occurred_at: string;
74
74
  project_id?: string;
75
+ repository_id?: string;
75
76
  metadata: {
76
77
  session_id: string;
77
78
  claude_session_id: string;
@@ -121,6 +122,7 @@ export type ClaudeMappedPayload = ClaudePayload | ClaudeDerivativePayload;
121
122
  /** Options for mapTranscriptTurn. */
122
123
  export interface ToClaudePayloadOptions {
123
124
  projectId?: string | null;
125
+ repositoryId?: string | null;
124
126
  pricing?: PricingTable;
125
127
  }
126
128
  /** Finds all *.jsonl transcript files across both Claude project directory roots. */
@@ -395,7 +395,7 @@ export async function parseTranscriptFile(filePath, verbose = false) {
395
395
  }
396
396
  /** Converts a Claude transcript turn to parent chat and derivative tool-use payloads. */
397
397
  export function mapTranscriptTurn(turn, options) {
398
- const { projectId, pricing } = options ?? {};
398
+ const { projectId, repositoryId, pricing } = options ?? {};
399
399
  const baseInputTokens = Math.max(0, turn.tokensIn - turn.cacheWriteTokens - turn.cacheReadTokens);
400
400
  const cost = pricing
401
401
  ? calculateCost(turn.model, baseInputTokens, turn.tokensOut, turn.cacheWriteTokens, turn.cacheReadTokens, pricing)
@@ -438,6 +438,8 @@ export function mapTranscriptTurn(turn, options) {
438
438
  }
439
439
  if (projectId)
440
440
  payload.project_id = projectId;
441
+ if (repositoryId)
442
+ payload.repository_id = repositoryId;
441
443
  const derivatives = turn.toolUses.map((toolUse) => {
442
444
  const derivative = {
443
445
  tool_name: "claude_code",
@@ -122,6 +122,7 @@ export interface CursorPayload extends IngestPayload {
122
122
  cost_usd: number;
123
123
  occurred_at: string;
124
124
  project_id?: string;
125
+ repository_id?: string;
125
126
  metadata: CursorPayloadMetadata;
126
127
  }
127
128
  export declare function toEpochMs(timestamp: number | string | null | undefined): number | null;
package/dist/server.js CHANGED
@@ -80,7 +80,7 @@ let cachedProjectResolution = null;
80
80
  async function getProjectResolutionForSync(creds) {
81
81
  const token = pickProjectLookupToken(creds);
82
82
  if (!token)
83
- return { projectId: null, source: "none" };
83
+ return { projectId: null, repositoryId: null, source: "none" };
84
84
  const gitRemote = getGitRemote(false) ?? "no-remote";
85
85
  const cacheKey = `${creds.host}|${token}|${gitRemote}`;
86
86
  if (cachedProjectResolution?.key === cacheKey) {
@@ -112,6 +112,7 @@ async function executeSync(parsed) {
112
112
  verbose: false,
113
113
  projectId: projectResolution.projectId,
114
114
  projectIdSource: projectResolution.source,
115
+ repositoryId: projectResolution.repositoryId,
115
116
  projectLookupToken: pickProjectLookupToken(creds),
116
117
  pricing: defaultPricing(),
117
118
  cursorPricing: cursorPricingForSync(),
@@ -246,6 +247,7 @@ export async function startServer() {
246
247
  verbose: false,
247
248
  projectId: projectResolution.projectId,
248
249
  projectIdSource: projectResolution.source,
250
+ repositoryId: projectResolution.repositoryId,
249
251
  projectLookupToken: pickProjectLookupToken(creds),
250
252
  pricing: defaultPricing(),
251
253
  scopeDir: process.cwd(),
package/dist/state.js CHANGED
@@ -3,6 +3,7 @@ import { join } from "node:path";
3
3
  import { homedir } from "node:os";
4
4
  import { createHash, randomBytes } from "node:crypto";
5
5
  import { mcpLog } from "./log.js";
6
+ import { describeReadFailure } from "./lib/parse-error.js";
6
7
  export function getAppDir() {
7
8
  const override = process.env["AIXLE_INSIGHTS_HOME"]?.trim();
8
9
  if (override && override.length > 0)
@@ -90,48 +91,50 @@ export function migrateLegacyState(dir, host, token) {
90
91
  }
91
92
  export function readState(dir, host, token) {
92
93
  const filePath = stateFilePath(dir ?? getAppDir(), host, token);
94
+ let parsed;
93
95
  try {
94
- const parsed = JSON.parse(readFileSync(filePath, "utf-8"));
95
- if (typeof parsed === "object" && parsed !== null) {
96
- const p = parsed;
97
- if (typeof p.version === "number" &&
98
- typeof p.sessions === "object" &&
99
- p.sessions !== null) {
100
- const lastRecentCommitHashes = Array.isArray(p.lastRecentCommitHashes)
101
- ? p.lastRecentCommitHashes.filter((h) => typeof h === "string")
102
- : undefined;
103
- const out = {
104
- version: p.version,
105
- sessions: p.sessions,
106
- };
107
- if (lastRecentCommitHashes !== undefined) {
108
- out.lastRecentCommitHashes = lastRecentCommitHashes;
109
- }
110
- if ("mcp_operator" in p) {
111
- const mcp = parseMcpOperator(p.mcp_operator);
112
- if (mcp)
113
- out.mcp_operator = mcp;
114
- }
115
- if ("rate_limited_until" in p) {
116
- if (typeof p.rate_limited_until === "string") {
117
- out.rate_limited_until = p.rate_limited_until;
118
- }
119
- else if (p.rate_limited_until === null) {
120
- out.rate_limited_until = null;
121
- }
122
- }
123
- return out;
124
- }
125
- }
96
+ parsed = JSON.parse(readFileSync(filePath, "utf-8"));
126
97
  }
127
98
  catch (err) {
128
99
  const code = err?.code;
129
100
  if (code !== "ENOENT") {
130
- // State file exists but failed to parse — distinguishes tampering from "never created".
131
- mcpLog.warn("state_parse_failed", { path: filePath, error: err instanceof Error ? err.message : String(err) }, false);
101
+ // State file exists but is not valid JSON — distinguishes tampering from "never created".
102
+ // ENOENT stays silent: that is the normal first-run case.
103
+ mcpLog.warn("state_parse_failed", { path: filePath, ...describeReadFailure(err) }, false);
104
+ }
105
+ return { version: 1, sessions: {} };
106
+ }
107
+ const p = typeof parsed === "object" && parsed !== null ? parsed : null;
108
+ if (p === null || typeof p.version !== "number" || typeof p.sessions !== "object" || p.sessions === null) {
109
+ // Valid JSON, wrong shape. This fallback discards every dedup checkpoint and causes a full
110
+ // re-send, so it is the most consequential of the four to have been silent. (DB90DV-699)
111
+ mcpLog.warn("state_parse_failed", { path: filePath, reason: "invalid_shape" }, false);
112
+ return { version: 1, sessions: {} };
113
+ }
114
+ const lastRecentCommitHashes = Array.isArray(p.lastRecentCommitHashes)
115
+ ? p.lastRecentCommitHashes.filter((h) => typeof h === "string")
116
+ : undefined;
117
+ const out = {
118
+ version: p.version,
119
+ sessions: p.sessions,
120
+ };
121
+ if (lastRecentCommitHashes !== undefined) {
122
+ out.lastRecentCommitHashes = lastRecentCommitHashes;
123
+ }
124
+ if ("mcp_operator" in p) {
125
+ const mcp = parseMcpOperator(p.mcp_operator);
126
+ if (mcp)
127
+ out.mcp_operator = mcp;
128
+ }
129
+ if ("rate_limited_until" in p) {
130
+ if (typeof p.rate_limited_until === "string") {
131
+ out.rate_limited_until = p.rate_limited_until;
132
+ }
133
+ else if (p.rate_limited_until === null) {
134
+ out.rate_limited_until = null;
132
135
  }
133
136
  }
134
- return { version: 1, sessions: {} };
137
+ return out;
135
138
  }
136
139
  /** Atomic write: write to a temp file then rename over the target. */
137
140
  export function writeState(state, dir, host, token) {
package/dist/sync.d.ts CHANGED
@@ -26,6 +26,12 @@ export interface SyncOptions {
26
26
  projectIdSource?: ProjectResolution["source"];
27
27
  /** Mirrors StoredCredentials.insecureHttpAllowed — set when `init --insecure` was used for this host. */
28
28
  allowInsecureHttp?: boolean;
29
+ /**
30
+ * Repository resolved from the same remote lookup as the scoped `projectId`.
31
+ * Only meaningful alongside a pre-resolved scoped `projectId` — carries the
32
+ * repository attribution the lookup returned so scoped turns don't lose it.
33
+ */
34
+ repositoryId?: string | null;
29
35
  pricing: PricingTable;
30
36
  appDir?: string;
31
37
  transcriptBaseDirs?: string[];
@@ -44,6 +50,8 @@ export interface MultiSyncOptions {
44
50
  verbose: boolean;
45
51
  projectId: string | null;
46
52
  projectIdSource?: ProjectResolution["source"];
53
+ /** See SyncOptions.repositoryId. */
54
+ repositoryId?: string | null;
47
55
  /** Token for GET /projects/lookup (defaults to cursor ingest token in cursor slice). */
48
56
  projectLookupToken?: string | null;
49
57
  pricing: PricingTable;
package/dist/sync.js CHANGED
@@ -103,21 +103,24 @@ function explicitProjectId(projectId, projectIdSource) {
103
103
  ? projectId
104
104
  : undefined;
105
105
  }
106
- async function resolveProjectIdForRepoPathCached(repoPath, host, token, verbose, cache, allowInsecureHttp = false) {
106
+ async function resolveAttributionForRepoPathCached(repoPath, host, token, verbose, cache, allowInsecureHttp = false) {
107
+ const empty = { projectId: null, repositoryId: null };
107
108
  const normalized = normalizeRepoPathCandidate(repoPath);
108
109
  if (normalized === null)
109
- return null;
110
+ return empty;
110
111
  if (cache.has(normalized))
111
- return cache.get(normalized) ?? null;
112
+ return cache.get(normalized) ?? empty;
112
113
  const canonicalRemote = getGitRemoteForPath(normalized, verbose);
113
114
  if (!canonicalRemote) {
114
- cache.set(normalized, null);
115
- return null;
115
+ cache.set(normalized, empty);
116
+ return empty;
116
117
  }
117
118
  const result = await lookupProjectByRemote(canonicalRemote, host, token, verbose, allowInsecureHttp);
118
- const projectId = result && typeof result === "object" && "project_id" in result ? result.project_id : null;
119
- cache.set(normalized, projectId);
120
- return projectId;
119
+ const resolved = result && typeof result === "object" && "project_id" in result
120
+ ? { projectId: result.project_id, repositoryId: result.repository_id ?? null }
121
+ : empty;
122
+ cache.set(normalized, resolved);
123
+ return resolved;
121
124
  }
122
125
  /**
123
126
  * The repo path for a Cursor payload, normalized and safe to hand to project
@@ -143,6 +146,7 @@ export function cursorRepoPathFromPayload(payload) {
143
146
  }
144
147
  async function runClaudeSlice(options) {
145
148
  const { token, host, dryRun, verbose, projectId, pricing, allowInsecureHttp = false } = options;
149
+ const preResolvedRepositoryId = options.repositoryId ?? undefined;
146
150
  const appDir = options.appDir ?? getAppDir();
147
151
  const backoffKey = credentialStateKey(host, token);
148
152
  const errors = [];
@@ -233,14 +237,22 @@ async function runClaudeSlice(options) {
233
237
  // (e.g. MCP server launched from a non-git cwd, or stuck null in module cache),
234
238
  // fall back to per-turn cwd lookup. The lookup cache dedupes by path so this is
235
239
  // effectively one network call per unique cwd per sync.
236
- const resolvedProjectId = scopeDir
237
- ? (projectId ??
238
- (await resolveProjectIdForRepoPathCached(turn.cwd, host, token, verbose, projectLookupCache, allowInsecureHttp)) ??
239
- undefined)
240
- : (explicitProject ??
241
- (await resolveProjectIdForRepoPathCached(turn.cwd, host, token, verbose, projectLookupCache, allowInsecureHttp)) ??
242
- undefined);
243
- const payloads = mapClaudeTranscriptTurn(turn, { projectId: resolvedProjectId, pricing });
240
+ // The scoped pre-resolve comes from the same remote lookup as projectId, so it
241
+ // carries repository_id too. An explicit flag/config projectId carries no
242
+ // repository context, so repository stays undefined there.
243
+ const preResolved = scopeDir ? projectId : explicitProject;
244
+ let resolvedProjectId = preResolved ?? undefined;
245
+ let resolvedRepositoryId = scopeDir && preResolved != null ? preResolvedRepositoryId : undefined;
246
+ if (resolvedProjectId === undefined) {
247
+ const attribution = await resolveAttributionForRepoPathCached(turn.cwd, host, token, verbose, projectLookupCache, allowInsecureHttp);
248
+ resolvedProjectId = attribution.projectId ?? undefined;
249
+ resolvedRepositoryId = attribution.repositoryId ?? undefined;
250
+ }
251
+ const payloads = mapClaudeTranscriptTurn(turn, {
252
+ projectId: resolvedProjectId,
253
+ repositoryId: resolvedRepositoryId,
254
+ pricing,
255
+ });
244
256
  if (!payloads?.length)
245
257
  continue;
246
258
  const parentPayload = payloads[0];
@@ -315,6 +327,7 @@ async function runClaudeSlice(options) {
315
327
  }
316
328
  async function runCursorSlice(params) {
317
329
  const { token, host, dryRun, verbose, projectId, projectIdSource, projectLookupToken, allowInsecureHttp = false, appDir, cursorBaseDir, cursorTranscriptProjectDirs, scopeDir, fullScan = false, cursorPricing = resolveCursorPricing(undefined, appDir), } = params;
330
+ const preResolvedRepositoryId = params.repositoryId ?? undefined;
318
331
  const backoffKey = credentialStateKey(host, token);
319
332
  // Read state first so we can restore a persisted rate-limit backoff from a prior process.
320
333
  const stateBefore = readState(appDir, host, token);
@@ -369,12 +382,26 @@ async function runCursorSlice(params) {
369
382
  if (ws && isRepoPathWithinRoot(ws, scopeDir)) {
370
383
  // Same fallback as Claude: when the pre-resolved projectId is null, do a
371
384
  // per-payload lookup from the payload's workspace. Cache dedupes by path.
372
- const resolved = projectId ??
373
- (await resolveProjectIdForRepoPathCached(ws, host, lookupToken, verbose, projectLookupCache, allowInsecureHttp));
374
- if (resolved)
375
- payload.project_id = resolved;
376
- else
377
- delete payload.project_id;
385
+ // The scoped pre-resolve comes from the same remote lookup as projectId,
386
+ // so it carries repository_id too; a fresh lookup sets it otherwise.
387
+ if (projectId) {
388
+ payload.project_id = projectId;
389
+ if (preResolvedRepositoryId)
390
+ payload.repository_id = preResolvedRepositoryId;
391
+ else
392
+ delete payload.repository_id;
393
+ }
394
+ else {
395
+ const attribution = await resolveAttributionForRepoPathCached(ws, host, lookupToken, verbose, projectLookupCache, allowInsecureHttp);
396
+ if (attribution.projectId)
397
+ payload.project_id = attribution.projectId;
398
+ else
399
+ delete payload.project_id;
400
+ if (attribution.repositoryId)
401
+ payload.repository_id = attribution.repositoryId;
402
+ else
403
+ delete payload.repository_id;
404
+ }
378
405
  inScope.push(payload);
379
406
  }
380
407
  else if (verbose) {
@@ -390,11 +417,15 @@ async function runCursorSlice(params) {
390
417
  continue;
391
418
  for (const payload of group.payloads) {
392
419
  const repoPath = cursorRepoPathFromPayload(payload);
393
- const resolvedProjectId = await resolveProjectIdForRepoPathCached(repoPath, host, lookupToken, verbose, projectLookupCache, allowInsecureHttp);
394
- if (resolvedProjectId)
395
- payload.project_id = resolvedProjectId;
420
+ const attribution = await resolveAttributionForRepoPathCached(repoPath, host, lookupToken, verbose, projectLookupCache, allowInsecureHttp);
421
+ if (attribution.projectId)
422
+ payload.project_id = attribution.projectId;
396
423
  else
397
424
  delete payload.project_id;
425
+ if (attribution.repositoryId)
426
+ payload.repository_id = attribution.repositoryId;
427
+ else
428
+ delete payload.repository_id;
398
429
  }
399
430
  }
400
431
  }
@@ -530,7 +561,7 @@ async function runCursorSlice(params) {
530
561
  on429,
531
562
  resolveProjectId: explicitProject
532
563
  ? undefined
533
- : (workspace) => resolveProjectIdForRepoPathCached(workspace, host, lookupToken, verbose, projectLookupCache, allowInsecureHttp),
564
+ : (workspace) => resolveAttributionForRepoPathCached(workspace, host, lookupToken, verbose, projectLookupCache, allowInsecureHttp),
534
565
  verbose,
535
566
  });
536
567
  totalSent += hooksResult.sent;
@@ -578,6 +609,7 @@ export async function syncTelemetryTools(options) {
578
609
  }
579
610
  try {
580
611
  const { credentials, dryRun, verbose, projectId, projectIdSource, projectLookupToken, pricing, scopeDir } = options;
612
+ const repositoryId = options.repositoryId ?? null;
581
613
  const host = credentials.host;
582
614
  const appDirResolved = options.appDir ?? getAppDir();
583
615
  const cursorPricing = options.cursorPricing ?? resolveCursorPricing(undefined, appDirResolved);
@@ -617,6 +649,7 @@ export async function syncTelemetryTools(options) {
617
649
  verbose,
618
650
  projectId,
619
651
  allowInsecureHttp: credentials.insecureHttpAllowed === true,
652
+ repositoryId,
620
653
  pricing,
621
654
  appDir,
622
655
  transcriptBaseDirs: options.transcriptBaseDirs,
@@ -632,6 +665,7 @@ export async function syncTelemetryTools(options) {
632
665
  verbose,
633
666
  projectId,
634
667
  projectIdSource,
668
+ repositoryId,
635
669
  projectLookupToken,
636
670
  allowInsecureHttp: credentials.insecureHttpAllowed === true,
637
671
  appDir,
@@ -699,9 +733,12 @@ export async function syncOnce(options) {
699
733
  dryRun: options.dryRun,
700
734
  verbose: options.verbose,
701
735
  projectId: options.projectId,
736
+ projectIdSource: options.projectIdSource,
737
+ repositoryId: options.repositoryId,
702
738
  pricing: options.pricing,
703
739
  appDir: options.appDir,
704
740
  transcriptBaseDirs: options.transcriptBaseDirs,
741
+ scopeDir: options.scopeDir,
705
742
  tools: ["claude_code"],
706
743
  });
707
744
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aixle/insights",
3
- "version": "0.2.3-staging",
3
+ "version": "0.2.5-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": {