@aixle/insights 0.2.2-staging → 0.2.4-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/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++;
@@ -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/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.2-staging",
3
+ "version": "0.2.4-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": {