@aixle/insights 0.1.0
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/LICENSE +21 -0
- package/README.md +137 -0
- package/dist/auth/credentials.d.ts +23 -0
- package/dist/auth/credentials.js +174 -0
- package/dist/auth/exchange.d.ts +25 -0
- package/dist/auth/exchange.js +87 -0
- package/dist/auth/flow.d.ts +24 -0
- package/dist/auth/flow.js +66 -0
- package/dist/auth/keycloak.d.ts +35 -0
- package/dist/auth/keycloak.js +170 -0
- package/dist/cli.d.ts +51 -0
- package/dist/cli.js +426 -0
- package/dist/client.d.ts +28 -0
- package/dist/client.js +102 -0
- package/dist/collect-cursor-payloads.d.ts +57 -0
- package/dist/collect-cursor-payloads.js +134 -0
- package/dist/credentials.d.ts +2 -0
- package/dist/credentials.js +1 -0
- package/dist/cursor-checkpoints.d.ts +12 -0
- package/dist/cursor-checkpoints.js +28 -0
- package/dist/cursor-config.d.ts +5 -0
- package/dist/cursor-config.js +34 -0
- package/dist/cursor-payload-contract.d.ts +17 -0
- package/dist/cursor-payload-contract.js +258 -0
- package/dist/cursor-settings.d.ts +6 -0
- package/dist/cursor-settings.js +38 -0
- package/dist/cursor-store-audit.d.ts +48 -0
- package/dist/cursor-store-audit.js +155 -0
- package/dist/daily-stats-versions.d.ts +31 -0
- package/dist/daily-stats-versions.js +170 -0
- package/dist/health.d.ts +31 -0
- package/dist/health.js +195 -0
- package/dist/hooks/cursor-hooks-mapper.d.ts +22 -0
- package/dist/hooks/cursor-hooks-mapper.js +84 -0
- package/dist/hooks/cursor-hooks-reader.d.ts +30 -0
- package/dist/hooks/cursor-hooks-reader.js +117 -0
- package/dist/hooks/hook-forwarder.mjs +110 -0
- package/dist/hooks/hooks-config.d.ts +92 -0
- package/dist/hooks/hooks-config.js +235 -0
- package/dist/install/claude.d.ts +37 -0
- package/dist/install/claude.js +144 -0
- package/dist/install/index.d.ts +8 -0
- package/dist/install/index.js +11 -0
- package/dist/lib/args.d.ts +26 -0
- package/dist/lib/args.js +17 -0
- package/dist/lib/client.d.ts +33 -0
- package/dist/lib/client.js +52 -0
- package/dist/lib/config.d.ts +26 -0
- package/dist/lib/config.js +39 -0
- package/dist/lib/index.d.ts +4 -0
- package/dist/lib/index.js +4 -0
- package/dist/lib/project-resolver.d.ts +48 -0
- package/dist/lib/project-resolver.js +203 -0
- package/dist/lock.d.ts +9 -0
- package/dist/lock.js +84 -0
- package/dist/log.d.ts +14 -0
- package/dist/log.js +81 -0
- package/dist/pricing.d.ts +40 -0
- package/dist/pricing.js +149 -0
- package/dist/readers/claude.d.ts +83 -0
- package/dist/readers/claude.js +317 -0
- package/dist/readers/cursor.d.ts +134 -0
- package/dist/readers/cursor.js +900 -0
- package/dist/risk-scanner.d.ts +8 -0
- package/dist/risk-scanner.js +59 -0
- package/dist/server.d.ts +14 -0
- package/dist/server.js +234 -0
- package/dist/state.d.ts +69 -0
- package/dist/state.js +155 -0
- package/dist/sync.d.ts +74 -0
- package/dist/sync.js +679 -0
- package/package.json +66 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
const CATEGORIES = {
|
|
2
|
+
secrets: {
|
|
3
|
+
weight: 3,
|
|
4
|
+
patterns: [
|
|
5
|
+
/AKIA[0-9A-Z]{16}/gi, // AWS Access Key
|
|
6
|
+
/ghp_[A-Za-z0-9]{36}/gi, // GitHub personal access token
|
|
7
|
+
/gho_[A-Za-z0-9]{36}/gi, // GitHub OAuth token
|
|
8
|
+
/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/gi, // JWT
|
|
9
|
+
],
|
|
10
|
+
},
|
|
11
|
+
pii_high: {
|
|
12
|
+
weight: 3,
|
|
13
|
+
patterns: [
|
|
14
|
+
/\b\d{3}-\d{2}-\d{4}\b/g, // SSN
|
|
15
|
+
/\b(?:4[0-9]{12}(?:[0-9]{3})?|5[1-5][0-9]{14}|3[47][0-9]{13}|3(?:0[0-5]|[68][0-9])[0-9]{11}|6(?:011|5[0-9]{2})[0-9]{12}|(?:2131|1800|35\d{3})\d{11})\b/g, // Credit card
|
|
16
|
+
],
|
|
17
|
+
},
|
|
18
|
+
pii_standard: {
|
|
19
|
+
weight: 1,
|
|
20
|
+
patterns: [
|
|
21
|
+
/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/gi, // Email
|
|
22
|
+
/\b(?:\+?1[-.\s]?)?(?:\([0-9]{3}\)|[0-9]{3})[-.\s]?[0-9]{3}[-.\s]?[0-9]{4}\b/g, // Phone
|
|
23
|
+
],
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
const THRESHOLDS = { medium: 1, high: 3, critical: 5 };
|
|
27
|
+
export function scanText(text) {
|
|
28
|
+
let risk_score = 0;
|
|
29
|
+
const risk_categories = [];
|
|
30
|
+
for (const [categoryName, { patterns, weight }] of Object.entries(CATEGORIES)) {
|
|
31
|
+
let categoryMatches = 0;
|
|
32
|
+
for (const pattern of patterns) {
|
|
33
|
+
const matches = text.match(pattern);
|
|
34
|
+
if (matches) {
|
|
35
|
+
categoryMatches += matches.length;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (categoryMatches > 0) {
|
|
39
|
+
risk_score += weight * categoryMatches;
|
|
40
|
+
risk_categories.push(categoryName);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
let risk_level = "low";
|
|
44
|
+
if (risk_score >= THRESHOLDS.critical) {
|
|
45
|
+
risk_level = "critical";
|
|
46
|
+
}
|
|
47
|
+
else if (risk_score >= THRESHOLDS.high) {
|
|
48
|
+
risk_level = "high";
|
|
49
|
+
}
|
|
50
|
+
else if (risk_score >= THRESHOLDS.medium) {
|
|
51
|
+
risk_level = "medium";
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
risk_level,
|
|
55
|
+
risk_score,
|
|
56
|
+
risk_categories,
|
|
57
|
+
scannable: true,
|
|
58
|
+
};
|
|
59
|
+
}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
export declare const SERVER_NAME = "aixle-insights-mcp";
|
|
4
|
+
export declare const SYNC_NOW_INPUT_SCHEMA: z.ZodObject<{
|
|
5
|
+
tools: z.ZodOptional<z.ZodArray<z.ZodEnum<{
|
|
6
|
+
claude_code: "claude_code";
|
|
7
|
+
cursor: "cursor";
|
|
8
|
+
}>>>;
|
|
9
|
+
}, z.core.$strict>;
|
|
10
|
+
/** Structured status for `db90_status` — tolerates missing/malformed credentials and state. */
|
|
11
|
+
export declare function buildDb90StatusPayload(): Promise<Record<string, unknown>>;
|
|
12
|
+
/** In-process MCP server instance (stdio not attached). */
|
|
13
|
+
export declare function createDb90McpServer(): McpServer;
|
|
14
|
+
export declare function startServer(): Promise<void>;
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { getGitRemote, resolveProjectId } from "./lib/index.js";
|
|
5
|
+
import { loadCredentials, credentialsHaveAnyToken, pickProjectLookupToken } from "./credentials.js";
|
|
6
|
+
import { defaultKeycloakClientId, defaultKeycloakIssuer, startDeviceAuthorization } from "./auth/keycloak.js";
|
|
7
|
+
import { migrateLegacyState, getAppDir } from "./state.js";
|
|
8
|
+
import { syncTelemetryTools } from "./sync.js";
|
|
9
|
+
import { DEFAULT_PRICING, mergePricing } from "./pricing.js";
|
|
10
|
+
import { resolveCursorPricing } from "./cursor-config.js";
|
|
11
|
+
import { buildHealthSnapshot, healthSnapshotToStatusPayload } from "./health.js";
|
|
12
|
+
import { mcpLog } from "./log.js";
|
|
13
|
+
export const SERVER_NAME = "aixle-insights-mcp";
|
|
14
|
+
const SERVER_VERSION = "0.1.0";
|
|
15
|
+
const SYNC_INTERVAL_MS = 5 * 60 * 1000;
|
|
16
|
+
export const SYNC_NOW_INPUT_SCHEMA = z
|
|
17
|
+
.object({
|
|
18
|
+
tools: z.array(z.enum(["claude_code", "cursor"])).min(1).optional(),
|
|
19
|
+
})
|
|
20
|
+
.superRefine((value, ctx) => {
|
|
21
|
+
if (value.tools && new Set(value.tools).size !== value.tools.length) {
|
|
22
|
+
ctx.addIssue({
|
|
23
|
+
code: z.ZodIssueCode.custom,
|
|
24
|
+
message: "tools must not contain duplicates",
|
|
25
|
+
path: ["tools"],
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
})
|
|
29
|
+
.strict();
|
|
30
|
+
function jsonContent(value) {
|
|
31
|
+
return {
|
|
32
|
+
content: [
|
|
33
|
+
{
|
|
34
|
+
type: "text",
|
|
35
|
+
text: JSON.stringify(value, null, 2),
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function defaultPricing() {
|
|
41
|
+
return mergePricing(DEFAULT_PRICING, {});
|
|
42
|
+
}
|
|
43
|
+
function cursorPricingForSync() {
|
|
44
|
+
return resolveCursorPricing(undefined, getAppDir());
|
|
45
|
+
}
|
|
46
|
+
function migrateAllLegacyState(creds) {
|
|
47
|
+
const appDir = getAppDir();
|
|
48
|
+
const seenTokens = new Set();
|
|
49
|
+
for (const [_tool, tok] of Object.entries(creds.accounts)) {
|
|
50
|
+
if (typeof tok === "string" && tok.length > 0 && !seenTokens.has(tok)) {
|
|
51
|
+
migrateLegacyState(appDir, creds.host, tok);
|
|
52
|
+
seenTokens.add(tok);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function syncResultOk(result) {
|
|
57
|
+
return !result.locked && result.failed === 0;
|
|
58
|
+
}
|
|
59
|
+
// Process-lifetime cache keyed on the inputs that drive resolveProjectId: host,
|
|
60
|
+
// lookup token, and the current repo's git remote. Re-resolve when any of them
|
|
61
|
+
// changes (re-auth, repo cwd change). `source: "none"` is never cached so a
|
|
62
|
+
// transient lookup failure doesn't poison the cache.
|
|
63
|
+
let cachedProjectResolution = null;
|
|
64
|
+
async function getProjectResolutionForSync(creds) {
|
|
65
|
+
const token = pickProjectLookupToken(creds);
|
|
66
|
+
if (!token)
|
|
67
|
+
return { projectId: null, source: "none" };
|
|
68
|
+
const gitRemote = getGitRemote(false) ?? "no-remote";
|
|
69
|
+
const cacheKey = `${creds.host}|${token}|${gitRemote}`;
|
|
70
|
+
if (cachedProjectResolution?.key === cacheKey) {
|
|
71
|
+
return cachedProjectResolution.value;
|
|
72
|
+
}
|
|
73
|
+
const result = await resolveProjectId(undefined, undefined, creds.host, token, false);
|
|
74
|
+
mcpLog.info("project_attribution_resolved", { project_id: result.projectId, source: result.source }, false);
|
|
75
|
+
if (result.source !== "none") {
|
|
76
|
+
cachedProjectResolution = { key: cacheKey, value: result };
|
|
77
|
+
}
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
80
|
+
/** Structured status for `db90_status` — tolerates missing/malformed credentials and state. */
|
|
81
|
+
export async function buildDb90StatusPayload() {
|
|
82
|
+
const snapshot = await buildHealthSnapshot();
|
|
83
|
+
return healthSnapshotToStatusPayload(snapshot);
|
|
84
|
+
}
|
|
85
|
+
async function executeSync(parsed) {
|
|
86
|
+
const creds = await loadCredentials();
|
|
87
|
+
if (!creds || !credentialsHaveAnyToken(creds)) {
|
|
88
|
+
mcpLog.warn("credential_validation_failed", { source: "db90_sync_now", reason: "missing_credentials" }, false);
|
|
89
|
+
return { ok: false, error: "missing_credentials" };
|
|
90
|
+
}
|
|
91
|
+
migrateAllLegacyState(creds);
|
|
92
|
+
const projectResolution = await getProjectResolutionForSync(creds);
|
|
93
|
+
const result = await syncTelemetryTools({
|
|
94
|
+
credentials: creds,
|
|
95
|
+
dryRun: false,
|
|
96
|
+
verbose: false,
|
|
97
|
+
projectId: projectResolution.projectId,
|
|
98
|
+
projectIdSource: projectResolution.source,
|
|
99
|
+
projectLookupToken: pickProjectLookupToken(creds),
|
|
100
|
+
pricing: defaultPricing(),
|
|
101
|
+
cursorPricing: cursorPricingForSync(),
|
|
102
|
+
tools: parsed.tools,
|
|
103
|
+
scopeDir: process.cwd(),
|
|
104
|
+
});
|
|
105
|
+
return { ok: syncResultOk(result), result };
|
|
106
|
+
}
|
|
107
|
+
/** In-process MCP server instance (stdio not attached). */
|
|
108
|
+
export function createDb90McpServer() {
|
|
109
|
+
const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION }, { capabilities: { tools: {} } });
|
|
110
|
+
server.registerTool("db90_status", {
|
|
111
|
+
description: "Returns Aixle Insights MCP connectivity and last sync metadata from disk (credentials + state). No arguments.",
|
|
112
|
+
}, async () => jsonContent(await buildDb90StatusPayload()));
|
|
113
|
+
server.registerTool("db90_sync_now", {
|
|
114
|
+
description: "Runs one DB90 ingest sync cycle for enabled tools immediately (matches background cadence). " +
|
|
115
|
+
"Optional `tools` subset filter: omit to sync every tool credential you have authenticated (Claude transcripts + Cursor telemetry).",
|
|
116
|
+
inputSchema: SYNC_NOW_INPUT_SCHEMA,
|
|
117
|
+
}, async (input) => {
|
|
118
|
+
try {
|
|
119
|
+
const parsed = SYNC_NOW_INPUT_SCHEMA.parse(input ?? {});
|
|
120
|
+
return jsonContent(await executeSync(parsed));
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
if (err instanceof z.ZodError) {
|
|
124
|
+
return jsonContent({
|
|
125
|
+
ok: false,
|
|
126
|
+
error: "validation_error",
|
|
127
|
+
details: err.flatten(),
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
return jsonContent({
|
|
131
|
+
ok: false,
|
|
132
|
+
error: err instanceof Error ? err.message : String(err),
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
server.registerTool("db90_authenticate", {
|
|
137
|
+
description: "Starts Keycloak device login and returns the visit URL/code for the user. Use aixle-insights init for the full terminal flow that saves credentials.",
|
|
138
|
+
inputSchema: z.object({
|
|
139
|
+
keycloakUrl: z.string().optional(),
|
|
140
|
+
clientId: z.string().optional(),
|
|
141
|
+
}),
|
|
142
|
+
}, async (input) => {
|
|
143
|
+
try {
|
|
144
|
+
const args = input;
|
|
145
|
+
const kc = (args.keycloakUrl?.trim() || defaultKeycloakIssuer()).trim();
|
|
146
|
+
if (!kc) {
|
|
147
|
+
return jsonContent({
|
|
148
|
+
ok: false,
|
|
149
|
+
error: "keycloakUrl or KEYCLOAK_ISSUER / DB90_KEYCLOAK_ISSUER is required",
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
const clientId = args.clientId?.trim() || defaultKeycloakClientId();
|
|
153
|
+
const device = await startDeviceAuthorization({
|
|
154
|
+
issuer: kc,
|
|
155
|
+
clientId,
|
|
156
|
+
});
|
|
157
|
+
return jsonContent({
|
|
158
|
+
ok: true,
|
|
159
|
+
verificationUri: device.verification_uri,
|
|
160
|
+
verificationUriComplete: device.verification_uri_complete ?? null,
|
|
161
|
+
userCode: device.user_code,
|
|
162
|
+
expiresIn: device.expires_in,
|
|
163
|
+
interval: device.interval ?? 5,
|
|
164
|
+
issuer: kc,
|
|
165
|
+
clientId,
|
|
166
|
+
message: `Visit ${device.verification_uri} and enter code ${device.user_code}`,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
catch (err) {
|
|
170
|
+
return jsonContent({
|
|
171
|
+
ok: false,
|
|
172
|
+
error: err instanceof Error ? err.message : String(err),
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
return server;
|
|
177
|
+
}
|
|
178
|
+
export async function startServer() {
|
|
179
|
+
const server = createDb90McpServer();
|
|
180
|
+
const transport = new StdioServerTransport();
|
|
181
|
+
await server.connect(transport);
|
|
182
|
+
let intervalId;
|
|
183
|
+
let jitterTimeout;
|
|
184
|
+
let shuttingDown = false;
|
|
185
|
+
let activeBackground = Promise.resolve();
|
|
186
|
+
const onSignal = () => {
|
|
187
|
+
shuttingDown = true;
|
|
188
|
+
if (jitterTimeout !== undefined)
|
|
189
|
+
clearTimeout(jitterTimeout);
|
|
190
|
+
if (intervalId !== undefined)
|
|
191
|
+
clearInterval(intervalId);
|
|
192
|
+
activeBackground.finally(() => {
|
|
193
|
+
process.exit(0);
|
|
194
|
+
});
|
|
195
|
+
};
|
|
196
|
+
process.on("SIGINT", onSignal);
|
|
197
|
+
process.on("SIGTERM", onSignal);
|
|
198
|
+
const runBackground = async (source) => {
|
|
199
|
+
if (shuttingDown)
|
|
200
|
+
return;
|
|
201
|
+
const creds = await loadCredentials();
|
|
202
|
+
if (!creds || !credentialsHaveAnyToken(creds)) {
|
|
203
|
+
mcpLog.warn("credential_validation_failed", { source, reason: "missing_credentials" }, false);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
try {
|
|
207
|
+
migrateAllLegacyState(creds);
|
|
208
|
+
const projectResolution = await getProjectResolutionForSync(creds);
|
|
209
|
+
await syncTelemetryTools({
|
|
210
|
+
credentials: creds,
|
|
211
|
+
dryRun: false,
|
|
212
|
+
verbose: false,
|
|
213
|
+
projectId: projectResolution.projectId,
|
|
214
|
+
projectIdSource: projectResolution.source,
|
|
215
|
+
projectLookupToken: pickProjectLookupToken(creds),
|
|
216
|
+
pricing: defaultPricing(),
|
|
217
|
+
scopeDir: process.cwd(),
|
|
218
|
+
cursorPricing: cursorPricingForSync(),
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
catch (err) {
|
|
222
|
+
mcpLog.error("background_sync_failed", { source, error: err instanceof Error ? err.message : String(err) }, true);
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
activeBackground = runBackground("startup");
|
|
226
|
+
// Jitter the recurring interval start by up to 60 s to spread load across the install base.
|
|
227
|
+
jitterTimeout = setTimeout(() => {
|
|
228
|
+
if (shuttingDown)
|
|
229
|
+
return;
|
|
230
|
+
intervalId = setInterval(() => {
|
|
231
|
+
activeBackground = activeBackground.finally(() => runBackground("interval"));
|
|
232
|
+
}, SYNC_INTERVAL_MS);
|
|
233
|
+
}, Math.floor(Math.random() * 60_000));
|
|
234
|
+
}
|
package/dist/state.d.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
export interface SessionRecord {
|
|
2
|
+
/** File size in bytes when this session was last successfully sent. */
|
|
3
|
+
fileSize: number;
|
|
4
|
+
/** ISO timestamp when this session was sent. */
|
|
5
|
+
sentAt: string;
|
|
6
|
+
/** SHA-256 prefix (32 hex chars) of the transcript file at time of send — preferred over fileSize for change detection. */
|
|
7
|
+
contentHash?: string;
|
|
8
|
+
}
|
|
9
|
+
/** Persisted summary of the last sync run (for CLI/MCP health across processes). */
|
|
10
|
+
export interface SyncResultSnapshot {
|
|
11
|
+
sent: number;
|
|
12
|
+
failed: number;
|
|
13
|
+
skipped: number;
|
|
14
|
+
locked?: boolean;
|
|
15
|
+
rate_limited_until?: string | null;
|
|
16
|
+
errors?: string[];
|
|
17
|
+
}
|
|
18
|
+
/** Operator-facing metadata stored beside checkpoint `sessions` (same credential-scoped file). */
|
|
19
|
+
export interface McpOperatorState {
|
|
20
|
+
last_sync_at: string | null;
|
|
21
|
+
last_result: SyncResultSnapshot | null;
|
|
22
|
+
recent_errors: string[];
|
|
23
|
+
}
|
|
24
|
+
export interface State {
|
|
25
|
+
version: number;
|
|
26
|
+
/**
|
|
27
|
+
* Map of session ID → last known state.
|
|
28
|
+
* Key namespaces:
|
|
29
|
+
* `claude_code:<sessionId>` — Claude transcript turns
|
|
30
|
+
* `cursor:watermark` / `cursor:events:watermark` / etc. — Cursor timestamp watermarks
|
|
31
|
+
* `cursor:hook:<conversation_id>:<generation_id>:<hook_event_name>` — Cursor hook event dedup (DB90DV-286)
|
|
32
|
+
*/
|
|
33
|
+
sessions: Record<string, SessionRecord>;
|
|
34
|
+
/**
|
|
35
|
+
* All `metadata.commit_hash` values successfully POSTed for Cursor Path B (recent commit).
|
|
36
|
+
* Hash dedupe is authoritative; timestamp watermarks alone
|
|
37
|
+
* can block retries after ingest accepted 202 but failed to persist.
|
|
38
|
+
*/
|
|
39
|
+
lastRecentCommitHashes?: string[];
|
|
40
|
+
/**
|
|
41
|
+
* ISO timestamp: suspend sync for this credential until this time (persisted 429 backoff).
|
|
42
|
+
* Primed into the in-memory backoff map on startup so rate-limits survive process restarts.
|
|
43
|
+
*/
|
|
44
|
+
rate_limited_until?: string | null;
|
|
45
|
+
/** Optional MCP diagnostics for health / debugging (does not replace session checkpoints). */
|
|
46
|
+
mcp_operator?: McpOperatorState;
|
|
47
|
+
}
|
|
48
|
+
export declare function getAppDir(): string;
|
|
49
|
+
/**
|
|
50
|
+
* Derives a per-credential filename stem.
|
|
51
|
+
* Format: `state-<hostname>-<8-char token hash>`
|
|
52
|
+
* Example: `state-app.db90.io-a1b2c3d4.json`
|
|
53
|
+
*/
|
|
54
|
+
export declare function stateKey(host: string, token: string): string;
|
|
55
|
+
/** Absolute path to the credential-scoped state JSON on disk. */
|
|
56
|
+
export declare function credentialStateFilePath(dir: string, host: string, token: string): string;
|
|
57
|
+
/**
|
|
58
|
+
* One-time migration: if a legacy `state.json` exists but no credential-scoped
|
|
59
|
+
* file does, rename it to the new name so existing sessions are not re-sent on
|
|
60
|
+
* the first upgrade run. No-op when the new file already exists or no legacy
|
|
61
|
+
* file is present.
|
|
62
|
+
*/
|
|
63
|
+
export declare function migrateLegacyState(dir: string, host: string, token: string): void;
|
|
64
|
+
export declare function readState(dir?: string, host?: string, token?: string): State;
|
|
65
|
+
/** Atomic write: write to a temp file then rename over the target. */
|
|
66
|
+
export declare function writeState(state: State, dir?: string, host?: string, token?: string): void;
|
|
67
|
+
export declare function markSessionSent(state: State, sessionId: string, fileSize: number, contentHash?: string): State;
|
|
68
|
+
/** Merge operator snapshot into an existing state object before `writeState`. */
|
|
69
|
+
export declare function withMcpOperator(state: State, operator: McpOperatorState): State;
|
package/dist/state.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
5
|
+
export function getAppDir() {
|
|
6
|
+
const override = process.env["AIXLE_INSIGHTS_HOME"]?.trim();
|
|
7
|
+
if (override && override.length > 0)
|
|
8
|
+
return override;
|
|
9
|
+
return join(homedir(), ".aixle-insights");
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Derives a per-credential filename stem.
|
|
13
|
+
* Format: `state-<hostname>-<8-char token hash>`
|
|
14
|
+
* Example: `state-app.db90.io-a1b2c3d4.json`
|
|
15
|
+
*/
|
|
16
|
+
export function stateKey(host, token) {
|
|
17
|
+
let hostname;
|
|
18
|
+
try {
|
|
19
|
+
hostname = new URL(host).hostname;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// host is not a valid URL — sanitise for use as a filename component
|
|
23
|
+
hostname = host.replace(/[^a-zA-Z0-9.-]/g, "_").slice(0, 40);
|
|
24
|
+
}
|
|
25
|
+
const tokenHash = createHash("sha256").update(token).digest("hex").slice(0, 8);
|
|
26
|
+
return `state-${hostname}-${tokenHash}`;
|
|
27
|
+
}
|
|
28
|
+
function stateFilePath(dir, host, token) {
|
|
29
|
+
const filename = host && token ? `${stateKey(host, token)}.json` : "state.json";
|
|
30
|
+
return join(dir, filename);
|
|
31
|
+
}
|
|
32
|
+
/** Absolute path to the credential-scoped state JSON on disk. */
|
|
33
|
+
export function credentialStateFilePath(dir, host, token) {
|
|
34
|
+
return stateFilePath(dir, host, token);
|
|
35
|
+
}
|
|
36
|
+
function parseMcpOperator(raw) {
|
|
37
|
+
if (typeof raw !== "object" || raw === null)
|
|
38
|
+
return undefined;
|
|
39
|
+
const o = raw;
|
|
40
|
+
let lastSyncAt = null;
|
|
41
|
+
if (o.last_sync_at === null)
|
|
42
|
+
lastSyncAt = null;
|
|
43
|
+
else if (typeof o.last_sync_at === "string")
|
|
44
|
+
lastSyncAt = o.last_sync_at;
|
|
45
|
+
let lastResult = null;
|
|
46
|
+
if (o.last_result === null) {
|
|
47
|
+
lastResult = null;
|
|
48
|
+
}
|
|
49
|
+
else if (typeof o.last_result === "object" && o.last_result !== null) {
|
|
50
|
+
const r = o.last_result;
|
|
51
|
+
if (typeof r.sent === "number" &&
|
|
52
|
+
typeof r.failed === "number" &&
|
|
53
|
+
typeof r.skipped === "number") {
|
|
54
|
+
lastResult = {
|
|
55
|
+
sent: r.sent,
|
|
56
|
+
failed: r.failed,
|
|
57
|
+
skipped: r.skipped,
|
|
58
|
+
locked: typeof r.locked === "boolean" ? r.locked : undefined,
|
|
59
|
+
rate_limited_until: r.rate_limited_until === null || typeof r.rate_limited_until === "string"
|
|
60
|
+
? r.rate_limited_until
|
|
61
|
+
: undefined,
|
|
62
|
+
errors: Array.isArray(r.errors)
|
|
63
|
+
? r.errors.filter((e) => typeof e === "string")
|
|
64
|
+
: undefined,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const recentErrors = Array.isArray(o.recent_errors)
|
|
69
|
+
? o.recent_errors.filter((e) => typeof e === "string")
|
|
70
|
+
: [];
|
|
71
|
+
return {
|
|
72
|
+
last_sync_at: lastSyncAt,
|
|
73
|
+
last_result: lastResult,
|
|
74
|
+
recent_errors: recentErrors,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* One-time migration: if a legacy `state.json` exists but no credential-scoped
|
|
79
|
+
* file does, rename it to the new name so existing sessions are not re-sent on
|
|
80
|
+
* the first upgrade run. No-op when the new file already exists or no legacy
|
|
81
|
+
* file is present.
|
|
82
|
+
*/
|
|
83
|
+
export function migrateLegacyState(dir, host, token) {
|
|
84
|
+
const legacyPath = join(dir, "state.json");
|
|
85
|
+
const newPath = stateFilePath(dir, host, token);
|
|
86
|
+
if (existsSync(legacyPath) && !existsSync(newPath)) {
|
|
87
|
+
renameSync(legacyPath, newPath);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
export function readState(dir, host, token) {
|
|
91
|
+
const filePath = stateFilePath(dir ?? getAppDir(), host, token);
|
|
92
|
+
try {
|
|
93
|
+
const parsed = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
94
|
+
if (typeof parsed === "object" && parsed !== null) {
|
|
95
|
+
const p = parsed;
|
|
96
|
+
if (typeof p.version === "number" &&
|
|
97
|
+
typeof p.sessions === "object" &&
|
|
98
|
+
p.sessions !== null) {
|
|
99
|
+
const lastRecentCommitHashes = Array.isArray(p.lastRecentCommitHashes)
|
|
100
|
+
? p.lastRecentCommitHashes.filter((h) => typeof h === "string")
|
|
101
|
+
: undefined;
|
|
102
|
+
const out = {
|
|
103
|
+
version: p.version,
|
|
104
|
+
sessions: p.sessions,
|
|
105
|
+
};
|
|
106
|
+
if (lastRecentCommitHashes !== undefined) {
|
|
107
|
+
out.lastRecentCommitHashes = lastRecentCommitHashes;
|
|
108
|
+
}
|
|
109
|
+
if ("mcp_operator" in p) {
|
|
110
|
+
const mcp = parseMcpOperator(p.mcp_operator);
|
|
111
|
+
if (mcp)
|
|
112
|
+
out.mcp_operator = mcp;
|
|
113
|
+
}
|
|
114
|
+
if ("rate_limited_until" in p) {
|
|
115
|
+
if (typeof p.rate_limited_until === "string") {
|
|
116
|
+
out.rate_limited_until = p.rate_limited_until;
|
|
117
|
+
}
|
|
118
|
+
else if (p.rate_limited_until === null) {
|
|
119
|
+
out.rate_limited_until = null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
// missing or malformed state file — start fresh
|
|
128
|
+
}
|
|
129
|
+
return { version: 1, sessions: {} };
|
|
130
|
+
}
|
|
131
|
+
/** Atomic write: write to a temp file then rename over the target. */
|
|
132
|
+
export function writeState(state, dir, host, token) {
|
|
133
|
+
const stateDir = dir ?? getAppDir();
|
|
134
|
+
mkdirSync(stateDir, { recursive: true });
|
|
135
|
+
const finalPath = stateFilePath(stateDir, host, token);
|
|
136
|
+
const tmpPath = join(stateDir, `.tmp-${randomBytes(6).toString("hex")}.json`);
|
|
137
|
+
writeFileSync(tmpPath, JSON.stringify(state, null, 2), "utf-8");
|
|
138
|
+
renameSync(tmpPath, finalPath);
|
|
139
|
+
}
|
|
140
|
+
export function markSessionSent(state, sessionId, fileSize, contentHash) {
|
|
141
|
+
const record = { fileSize, sentAt: new Date().toISOString() };
|
|
142
|
+
if (contentHash !== undefined)
|
|
143
|
+
record.contentHash = contentHash;
|
|
144
|
+
return {
|
|
145
|
+
...state,
|
|
146
|
+
sessions: {
|
|
147
|
+
...state.sessions,
|
|
148
|
+
[sessionId]: record,
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
/** Merge operator snapshot into an existing state object before `writeState`. */
|
|
153
|
+
export function withMcpOperator(state, operator) {
|
|
154
|
+
return { ...state, mcp_operator: operator };
|
|
155
|
+
}
|
package/dist/sync.d.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { TelemetryToolId, StoredCredentials } from "./auth/credentials.js";
|
|
2
|
+
import { type PricingTable } from "./pricing.js";
|
|
3
|
+
import type { PricingConfig } from "./readers/cursor.js";
|
|
4
|
+
import { type ProjectResolution } from "./lib/index.js";
|
|
5
|
+
import type { CursorDb90Payload } from "./readers/cursor.js";
|
|
6
|
+
/** Prefix for Claude Code session keys in shared MCP state. */
|
|
7
|
+
export declare const CLAUDE_STATE_PREFIX: "claude_code:";
|
|
8
|
+
export { CURSOR_WATERMARK_KEY, CURSOR_EVENTS_WATERMARK_KEY, CURSOR_DAILY_STATS_WATERMARK_KEY, CURSOR_RECENT_COMMIT_WATERMARK_KEY, CURSOR_TRANSCRIPT_TURN_PREFIX, cursorTranscriptTurnStateKey, filterRecentCommitsByHashDedup, } from "./cursor-checkpoints.js";
|
|
9
|
+
export declare function sessionStateKey(sessionId: string): string;
|
|
10
|
+
export interface SyncResult {
|
|
11
|
+
sent: number;
|
|
12
|
+
failed: number;
|
|
13
|
+
skipped: number;
|
|
14
|
+
locked?: boolean;
|
|
15
|
+
errors?: string[];
|
|
16
|
+
rateLimitedUntil?: string | null;
|
|
17
|
+
validationFailed?: boolean;
|
|
18
|
+
}
|
|
19
|
+
/** Legacy Claude-only sync options (backward compatible with existing tests/tooling). */
|
|
20
|
+
export interface SyncOptions {
|
|
21
|
+
token: string;
|
|
22
|
+
host: string;
|
|
23
|
+
dryRun: boolean;
|
|
24
|
+
verbose: boolean;
|
|
25
|
+
projectId: string | null;
|
|
26
|
+
projectIdSource?: ProjectResolution["source"];
|
|
27
|
+
pricing: PricingTable;
|
|
28
|
+
appDir?: string;
|
|
29
|
+
transcriptBaseDirs?: string[];
|
|
30
|
+
/**
|
|
31
|
+
* When set, only Claude turns whose `cwd` matches this directory (exact or
|
|
32
|
+
* subdirectory) are synced. All matching turns use `projectId` directly
|
|
33
|
+
* — no per-turn remote lookup. Turns from other directories are skipped.
|
|
34
|
+
* Typically set to `process.cwd()` so each MCP instance is scoped to the
|
|
35
|
+
* repo it was launched from.
|
|
36
|
+
*/
|
|
37
|
+
scopeDir?: string;
|
|
38
|
+
}
|
|
39
|
+
export interface MultiSyncOptions {
|
|
40
|
+
credentials: StoredCredentials;
|
|
41
|
+
dryRun: boolean;
|
|
42
|
+
verbose: boolean;
|
|
43
|
+
projectId: string | null;
|
|
44
|
+
projectIdSource?: ProjectResolution["source"];
|
|
45
|
+
/** Token for GET /projects/lookup (defaults to cursor ingest token in cursor slice). */
|
|
46
|
+
projectLookupToken?: string | null;
|
|
47
|
+
pricing: PricingTable;
|
|
48
|
+
/** Cursor line-cost rates (defaults + optional ~/.aixle-insights/config.json overrides). */
|
|
49
|
+
cursorPricing?: PricingConfig;
|
|
50
|
+
appDir?: string;
|
|
51
|
+
transcriptBaseDirs?: string[];
|
|
52
|
+
/** Synthetic Cursor paths for Vitest isolation. Omit for real installs. */
|
|
53
|
+
cursorBaseDir?: string;
|
|
54
|
+
cursorTranscriptProjectDirs?: string[];
|
|
55
|
+
tools?: TelemetryToolId[];
|
|
56
|
+
/** See SyncOptions.scopeDir. */
|
|
57
|
+
scopeDir?: string;
|
|
58
|
+
/** Ignore watermarks and commit hash dedupe (pairs with `--dry-run` on CLI). */
|
|
59
|
+
fullScan?: boolean;
|
|
60
|
+
}
|
|
61
|
+
/** Clears in-memory rate-limit backoff (test hook). */
|
|
62
|
+
export declare function resetBackoffStateForTests(): void;
|
|
63
|
+
export declare function getSyncTelemetry(): {
|
|
64
|
+
lastSyncAt: string | null;
|
|
65
|
+
lastResult: SyncResult | null;
|
|
66
|
+
recentErrors: string[];
|
|
67
|
+
};
|
|
68
|
+
export declare function cursorRepoPathFromPayload(payload: CursorDb90Payload): string | undefined;
|
|
69
|
+
/**
|
|
70
|
+
* Parallel multi-tool cycle under the global advisory ingest lock (`state.lock`).
|
|
71
|
+
*/
|
|
72
|
+
export declare function syncTelemetryTools(options: MultiSyncOptions): Promise<SyncResult>;
|
|
73
|
+
/** Claude-only sync helper (delegates into `syncTelemetryTools`). */
|
|
74
|
+
export declare function syncOnce(options: SyncOptions): Promise<SyncResult>;
|