@kintsugi-ai/core 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.
Files changed (86) hide show
  1. package/dist/auth.d.ts +57 -0
  2. package/dist/auth.d.ts.map +1 -0
  3. package/dist/auth.js +151 -0
  4. package/dist/auth.js.map +1 -0
  5. package/dist/baseline.d.ts +8 -0
  6. package/dist/baseline.d.ts.map +1 -0
  7. package/dist/baseline.js +114 -0
  8. package/dist/baseline.js.map +1 -0
  9. package/dist/benchmark.d.ts +65 -0
  10. package/dist/benchmark.d.ts.map +1 -0
  11. package/dist/benchmark.js +232 -0
  12. package/dist/benchmark.js.map +1 -0
  13. package/dist/capturer.d.ts +14 -0
  14. package/dist/capturer.d.ts.map +1 -0
  15. package/dist/capturer.js +92 -0
  16. package/dist/capturer.js.map +1 -0
  17. package/dist/classifier.d.ts +62 -0
  18. package/dist/classifier.d.ts.map +1 -0
  19. package/dist/classifier.js +204 -0
  20. package/dist/classifier.js.map +1 -0
  21. package/dist/comparator.d.ts +28 -0
  22. package/dist/comparator.d.ts.map +1 -0
  23. package/dist/comparator.js +184 -0
  24. package/dist/comparator.js.map +1 -0
  25. package/dist/comparator.test.d.ts +2 -0
  26. package/dist/comparator.test.d.ts.map +1 -0
  27. package/dist/comparator.test.js +73 -0
  28. package/dist/comparator.test.js.map +1 -0
  29. package/dist/config.d.ts +6 -0
  30. package/dist/config.d.ts.map +1 -0
  31. package/dist/config.js +79 -0
  32. package/dist/config.js.map +1 -0
  33. package/dist/config.test.d.ts +2 -0
  34. package/dist/config.test.d.ts.map +1 -0
  35. package/dist/config.test.js +67 -0
  36. package/dist/config.test.js.map +1 -0
  37. package/dist/domdiff.d.ts +6 -0
  38. package/dist/domdiff.d.ts.map +1 -0
  39. package/dist/domdiff.js +71 -0
  40. package/dist/domdiff.js.map +1 -0
  41. package/dist/feedback.d.ts +9 -0
  42. package/dist/feedback.d.ts.map +1 -0
  43. package/dist/feedback.js +57 -0
  44. package/dist/feedback.js.map +1 -0
  45. package/dist/flowid.d.ts +3 -0
  46. package/dist/flowid.d.ts.map +1 -0
  47. package/dist/flowid.js +11 -0
  48. package/dist/flowid.js.map +1 -0
  49. package/dist/index.d.ts +14 -0
  50. package/dist/index.d.ts.map +1 -0
  51. package/dist/index.js +14 -0
  52. package/dist/index.js.map +1 -0
  53. package/dist/recorder.d.ts +12 -0
  54. package/dist/recorder.d.ts.map +1 -0
  55. package/dist/recorder.js +86 -0
  56. package/dist/recorder.js.map +1 -0
  57. package/dist/setupPrompt.d.ts +10 -0
  58. package/dist/setupPrompt.d.ts.map +1 -0
  59. package/dist/setupPrompt.js +27 -0
  60. package/dist/setupPrompt.js.map +1 -0
  61. package/dist/types.d.ts +144 -0
  62. package/dist/types.d.ts.map +1 -0
  63. package/dist/types.js +12 -0
  64. package/dist/types.js.map +1 -0
  65. package/dist/vision.d.ts +34 -0
  66. package/dist/vision.d.ts.map +1 -0
  67. package/dist/vision.js +139 -0
  68. package/dist/vision.js.map +1 -0
  69. package/package.json +37 -0
  70. package/src/auth.ts +195 -0
  71. package/src/baseline.ts +125 -0
  72. package/src/benchmark.ts +307 -0
  73. package/src/capturer.ts +105 -0
  74. package/src/classifier.ts +258 -0
  75. package/src/comparator.test.ts +97 -0
  76. package/src/comparator.ts +217 -0
  77. package/src/config.test.ts +80 -0
  78. package/src/config.ts +83 -0
  79. package/src/domdiff.ts +62 -0
  80. package/src/feedback.ts +64 -0
  81. package/src/flowid.ts +14 -0
  82. package/src/index.ts +13 -0
  83. package/src/recorder.ts +96 -0
  84. package/src/setupPrompt.ts +26 -0
  85. package/src/types.ts +144 -0
  86. package/tsconfig.json +9 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"recorder.js","sourceRoot":"","sources":["../src/recorder.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The agent setup prompt — the single source of truth for the "initialize your
3
+ * flows" instruction users paste into claude / codex / agy after installing.
4
+ * Shared by `kintsugi init` (written to .kintsugi/setup-prompt.md) and the
5
+ * VS Code onboarding panel.
6
+ */
7
+ export declare function getSetupPrompt(options?: {
8
+ devServerUrl?: string;
9
+ }): string;
10
+ //# sourceMappingURL=setupPrompt.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"setupPrompt.d.ts","sourceRoot":"","sources":["../src/setupPrompt.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,OAAO,GAAE;IAAE,YAAY,CAAC,EAAE,MAAM,CAAA;CAAO,GAAG,MAAM,CAmB9E"}
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The agent setup prompt — the single source of truth for the "initialize your
3
+ * flows" instruction users paste into claude / codex / agy after installing.
4
+ * Shared by `kintsugi init` (written to .kintsugi/setup-prompt.md) and the
5
+ * VS Code onboarding panel.
6
+ */
7
+ export function getSetupPrompt(options = {}) {
8
+ const url = options.devServerUrl || 'http://localhost:3000';
9
+ return `Record the visual regression baselines for this project using the kintsugi MCP tools.
10
+
11
+ You have two groups of tools from the "kintsugi" MCP server:
12
+ - Browser tools (Playwright): browser_navigate, browser_snapshot, browser_click, browser_type, browser_take_screenshot, ... — drive a real browser to explore the app.
13
+ - Flow tools (Kintsugi): save_flow, capture_baseline, list_flows, check_flows — record and verify visual flows.
14
+
15
+ Do this:
16
+ 1. If the dev server is not running yet, start it in the background (the project's dev script, e.g. npm run dev) and wait until ${url} responds.
17
+ 2. Explore the app with browser_navigate + browser_snapshot and identify the 3-5 most important user flows (e.g. landing page, navigation to a key section, the primary action such as signup, search, or checkout).
18
+ 3. For each flow, call save_flow with:
19
+ - name: a short snake_case identifier (e.g. "add_to_cart")
20
+ - steps: the ordered navigate / click / type / wait steps, using stable selectors ("text=Add to cart", "#email", "[data-testid=submit]") — never auto-generated or nth-child selectors. Keep each flow under 8 steps.
21
+ 4. Call capture_baseline for each flow — it replays the flow headlessly and stores the screenshots and aria snapshots Kintsugi will guard.
22
+ 5. Call check_flows to confirm every flow passes against the fresh baseline.
23
+ 6. Finish with a short summary: flow names, step counts, and anything you deliberately skipped.
24
+
25
+ From now on, Kintsugi automatically replays these flows at the end of every agent turn and blocks the stop when a visual regression appears.`;
26
+ }
27
+ //# sourceMappingURL=setupPrompt.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"setupPrompt.js","sourceRoot":"","sources":["../src/setupPrompt.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,MAAM,UAAU,cAAc,CAAC,UAAqC,EAAE;IACpE,MAAM,GAAG,GAAG,OAAO,CAAC,YAAY,IAAI,uBAAuB,CAAC;IAC5D,OAAO;;;;;;;kIAOyH,GAAG;;;;;;;;;6IASQ,CAAC;AAC9I,CAAC"}
@@ -0,0 +1,144 @@
1
+ /** Result classification from the comparison pipeline */
2
+ export declare enum DiffResult {
3
+ IDENTICAL = "identical",
4
+ MINOR = "minor",
5
+ CHANGED = "changed",
6
+ BROKEN = "broken",
7
+ ERROR = "error",
8
+ /** Big visual change the vision classifier accepted as intentional — not a regression */
9
+ INTENTIONAL = "intentional"
10
+ }
11
+ /** A single step in a recorded user flow */
12
+ export interface FlowStep {
13
+ action: 'navigate' | 'click' | 'type' | 'scroll' | 'wait' | 'screenshot';
14
+ selector?: string;
15
+ url?: string;
16
+ value?: string;
17
+ position?: {
18
+ x: number;
19
+ y: number;
20
+ };
21
+ timeout?: number;
22
+ description?: string;
23
+ }
24
+ /** Metadata about a recorded flow */
25
+ export interface FlowMetadata {
26
+ name: string;
27
+ url: string;
28
+ viewport: {
29
+ width: number;
30
+ height: number;
31
+ };
32
+ recordedAt: string;
33
+ duration: number;
34
+ stepCount: number;
35
+ }
36
+ /** A complete flow recording with steps and baseline screenshots */
37
+ export interface FlowRecording {
38
+ metadata: FlowMetadata;
39
+ steps: FlowStep[];
40
+ screenshotPaths: string[];
41
+ /** Baseline aria snapshots per step (semantic DOM state) */
42
+ ariaSnapshotPaths?: string[];
43
+ videoPath?: string;
44
+ }
45
+ /** Result of capturing the current UI state */
46
+ export interface CaptureResult {
47
+ screenshots: Buffer[];
48
+ screenshotPaths: string[];
49
+ /** Playwright aria snapshot (semantic YAML) of the page after each step */
50
+ ariaSnapshots: string[];
51
+ videoPath?: string;
52
+ completedSteps: number;
53
+ totalSteps: number;
54
+ errors: string[];
55
+ }
56
+ /** Classifier output for one baseline/current screenshot pair */
57
+ export interface StepClassification {
58
+ step: number;
59
+ intentional: boolean;
60
+ confidence: number;
61
+ /** One sentence from the classifier — the suggested fix when not intentional */
62
+ reasoning: string;
63
+ /** aria-snapshot diff for this pair */
64
+ ariaDiff: string;
65
+ oldImagePath?: string;
66
+ newImagePath?: string;
67
+ }
68
+ /** Result of comparing baseline vs current */
69
+ export interface ComparisonResult {
70
+ result: DiffResult;
71
+ pixelDiffPercent: number;
72
+ ssimScore: number;
73
+ diffImagePath?: string;
74
+ semanticAnalysis?: string;
75
+ failedStep?: number;
76
+ stepResults: StepComparisonResult[];
77
+ /** Classifier detail for every step large enough to be classified */
78
+ classifications?: StepClassification[];
79
+ clipPath?: string;
80
+ }
81
+ /** Per-step comparison detail */
82
+ export interface StepComparisonResult {
83
+ step: number;
84
+ pixelDiffPercent: number;
85
+ ssimScore: number;
86
+ result: DiffResult;
87
+ diffImagePath?: string;
88
+ }
89
+ /** Which transport classifyChange uses */
90
+ export type ClassifierProvider =
91
+ /** Hosted kintsugi classifier API (default) — server picks the model */
92
+ 'kintsugi'
93
+ /** Any OpenAI-compatible /chat/completions endpoint (HF router, Groq, local llama.cpp, …) */
94
+ | 'openai-compatible';
95
+ /** Configuration for the change classifier (intentional vs regression) */
96
+ export interface ClassifierConfig {
97
+ enabled: boolean;
98
+ provider: ClassifierProvider;
99
+ /**
100
+ * API base URL. For provider 'kintsugi': the hosted API (override with the
101
+ * KINTSUGI_API_URL env var, e.g. http://localhost:8787/v1 in dev). For
102
+ * 'openai-compatible': a base URL exposing POST {endpoint}/chat/completions.
103
+ */
104
+ endpoint: string;
105
+ /** Only used by the 'openai-compatible' provider; the kintsugi API picks its own model */
106
+ model: string;
107
+ tokenEnvVar: string;
108
+ /** Pixel-diff % above which the classifier is consulted */
109
+ pixelDiffThreshold: number;
110
+ /** Required confidence for an "intentional" verdict to be accepted */
111
+ confidenceThreshold: number;
112
+ timeoutMs: number;
113
+ }
114
+ /** Project-level Kintsugi configuration */
115
+ export interface KintsugiConfig {
116
+ version: number;
117
+ devServerUrl: string;
118
+ viewport: {
119
+ width: number;
120
+ height: number;
121
+ };
122
+ thresholds: {
123
+ pixelDiffPercent: number;
124
+ ssimScore: number;
125
+ maxCheckLatencyMs: number;
126
+ };
127
+ llm: {
128
+ provider: 'claude' | 'openai';
129
+ model: string;
130
+ apiKeyEnvVar: string;
131
+ };
132
+ /** Change classifier: aria-snapshot diff + turn context → intentional or regression */
133
+ classifier: ClassifierConfig;
134
+ agent: {
135
+ type: 'auto' | 'claude-code' | 'codex' | 'agy';
136
+ maxRetries: number;
137
+ };
138
+ recording: {
139
+ passive: boolean;
140
+ collectorPort: number;
141
+ };
142
+ uiFilePatterns: string[];
143
+ }
144
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
package/dist/types.js ADDED
@@ -0,0 +1,12 @@
1
+ /** Result classification from the comparison pipeline */
2
+ export var DiffResult;
3
+ (function (DiffResult) {
4
+ DiffResult["IDENTICAL"] = "identical";
5
+ DiffResult["MINOR"] = "minor";
6
+ DiffResult["CHANGED"] = "changed";
7
+ DiffResult["BROKEN"] = "broken";
8
+ DiffResult["ERROR"] = "error";
9
+ /** Big visual change the vision classifier accepted as intentional — not a regression */
10
+ DiffResult["INTENTIONAL"] = "intentional";
11
+ })(DiffResult || (DiffResult = {}));
12
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,yDAAyD;AACzD,MAAM,CAAN,IAAY,UAQX;AARD,WAAY,UAAU;IACpB,qCAAuB,CAAA;IACvB,6BAAe,CAAA;IACf,iCAAmB,CAAA;IACnB,+BAAiB,CAAA;IACjB,6BAAe,CAAA;IACf,yFAAyF;IACzF,yCAA2B,CAAA;AAC7B,CAAC,EARW,UAAU,KAAV,UAAU,QAQrB"}
@@ -0,0 +1,34 @@
1
+ import type { VisionConfig } from './types.js';
2
+ export interface VisionVerdict {
3
+ /** The classifier's multiple-choice answer */
4
+ answer: 'intentional' | 'regression';
5
+ /** 0..1 certainty reported by the classifier (conservatively derived when absent) */
6
+ confidence: number;
7
+ raw: string;
8
+ }
9
+ /**
10
+ * Classifies a visual change as intentional or a regression using an
11
+ * OpenAI-compatible vision endpoint (HF Inference router by default, or a
12
+ * self-hosted Space exposing the same schema).
13
+ */
14
+ export declare function classifyChange(options: {
15
+ baselineImage: Buffer;
16
+ currentImage: Buffer;
17
+ context?: string;
18
+ config: VisionConfig;
19
+ apiToken: string;
20
+ logger?: {
21
+ info: (msg: string, data?: unknown) => void;
22
+ warn: (msg: string, data?: unknown) => void;
23
+ };
24
+ }): Promise<VisionVerdict>;
25
+ /** Robustly extracts {answer, confidence} from the model output. Fails safe to regression. */
26
+ export declare function parseVerdict(content: string): VisionVerdict;
27
+ /**
28
+ * Resolves the API token: process env first, then a gitignored
29
+ * `<projectDir>/.kintsugi/.env` file (KEY=VALUE lines).
30
+ */
31
+ export declare function resolveApiToken(tokenEnvVar: string, projectDir?: string): string | undefined;
32
+ /** Loads KEY=VALUE pairs from <projectDir>/.kintsugi/.env into process.env (never overrides). */
33
+ export declare function loadKintsugiEnv(projectDir: string): void;
34
+ //# sourceMappingURL=vision.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"vision.d.ts","sourceRoot":"","sources":["../src/vision.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAE/C,MAAM,WAAW,aAAa;IAC5B,8CAA8C;IAC9C,MAAM,EAAE,aAAa,GAAG,YAAY,CAAC;IACrC,qFAAqF;IACrF,UAAU,EAAE,MAAM,CAAC;IACnB,GAAG,EAAE,MAAM,CAAC;CACb;AAgBD;;;;GAIG;AACH,wBAAsB,cAAc,CAAC,OAAO,EAAE;IAC5C,aAAa,EAAE,MAAM,CAAC;IACtB,YAAY,EAAE,MAAM,CAAC;IACrB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,YAAY,CAAC;IACrB,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE;QAAE,IAAI,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,KAAK,IAAI,CAAC;QAAC,IAAI,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,KAAK,IAAI,CAAA;KAAE,CAAC;CACvG,GAAG,OAAO,CAAC,aAAa,CAAC,CA0DzB;AAED,8FAA8F;AAC9F,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,GAAG,aAAa,CA+B3D;AAMD;;;GAGG;AACH,wBAAgB,eAAe,CAAC,WAAW,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAI5F;AAED,iGAAiG;AACjG,wBAAgB,eAAe,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAexD"}
package/dist/vision.js ADDED
@@ -0,0 +1,139 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ const SYSTEM_PROMPT = [
4
+ 'You are a strict UI visual-regression classifier for an automated QA tool.',
5
+ 'You will see two screenshots of the same user flow: IMAGE 1 is the accepted baseline, IMAGE 2 is the current state after a code change.',
6
+ 'You will also get optional context about what the developer/agent was asked to do in the latest turn.',
7
+ 'Decide whether the visual difference is:',
8
+ '- "intentional": a deliberate design/feature change consistent with the context (or a benign change like dynamic content), or',
9
+ '- "regression": something broke — a missing/shifted/broken component, layout collapse, unstyled content, blank regions, or an inconsistency with the context.',
10
+ 'The baseline is the ACCEPTED, WORKING state. Missing UI elements (buttons, forms, prices, text) are regressions even if the remaining design looks clean and coherent.',
11
+ 'If no context is provided, be conservative: significant changes should be classified as "regression" — only classify as "intentional" if the change is clearly benign.',
12
+ 'If context IS provided and IMAGE 2 implements exactly what the context asked for, classify as "intentional" with high confidence — a requested change is not a regression.',
13
+ 'Answer with ONLY a JSON object, no other text:',
14
+ '{"answer": "intentional" | "regression", "confidence": <number between 0 and 1>}',
15
+ ].join('\n');
16
+ /**
17
+ * Classifies a visual change as intentional or a regression using an
18
+ * OpenAI-compatible vision endpoint (HF Inference router by default, or a
19
+ * self-hosted Space exposing the same schema).
20
+ */
21
+ export async function classifyChange(options) {
22
+ const { baselineImage, currentImage, context, config, apiToken, logger } = options;
23
+ const userLines = [
24
+ 'IMAGE 1 = accepted baseline. IMAGE 2 = current state after the latest code change.',
25
+ context
26
+ ? `Context from the latest turn (what the agent was asked to do):\n"${context}"`
27
+ : 'No context available about the intended change.',
28
+ 'Classify the difference between IMAGE 1 and IMAGE 2 as "intentional" or "regression".',
29
+ 'Reply with ONLY the JSON object.',
30
+ ];
31
+ const body = {
32
+ model: config.model,
33
+ temperature: 0,
34
+ max_tokens: 60,
35
+ messages: [
36
+ { role: 'system', content: SYSTEM_PROMPT },
37
+ {
38
+ role: 'user',
39
+ content: [
40
+ { type: 'text', text: userLines.join('\n\n') },
41
+ { type: 'image_url', image_url: { url: toDataUrl(baselineImage) } },
42
+ { type: 'image_url', image_url: { url: toDataUrl(currentImage) } },
43
+ ],
44
+ },
45
+ ],
46
+ };
47
+ const url = `${config.endpoint.replace(/\/$/, '')}/chat/completions`;
48
+ const controller = new AbortController();
49
+ const timer = setTimeout(() => controller.abort(), config.timeoutMs);
50
+ let response;
51
+ try {
52
+ response = await fetch(url, {
53
+ method: 'POST',
54
+ headers: {
55
+ 'Content-Type': 'application/json',
56
+ Authorization: `Bearer ${apiToken}`,
57
+ },
58
+ body: JSON.stringify(body),
59
+ signal: controller.signal,
60
+ });
61
+ }
62
+ finally {
63
+ clearTimeout(timer);
64
+ }
65
+ if (!response.ok) {
66
+ const detail = await response.text().catch(() => '');
67
+ throw new Error(`vision endpoint ${response.status}: ${detail.slice(0, 300)}`);
68
+ }
69
+ const payload = JSON.parse(await response.text());
70
+ const content = payload.choices?.[0]?.message?.content ?? '';
71
+ logger?.info('vision classifier responded', { content: content.slice(0, 200) });
72
+ return parseVerdict(content);
73
+ }
74
+ /** Robustly extracts {answer, confidence} from the model output. Fails safe to regression. */
75
+ export function parseVerdict(content) {
76
+ const text = content.trim();
77
+ // Preferred: the JSON object we asked for
78
+ const jsonMatch = text.match(/\{[\s\S]*?\}/);
79
+ if (jsonMatch) {
80
+ try {
81
+ const parsed = JSON.parse(jsonMatch[0]);
82
+ if (parsed.answer === 'intentional' || parsed.answer === 'regression') {
83
+ const confidence = typeof parsed.confidence === 'number'
84
+ ? Math.min(1, Math.max(0, parsed.confidence))
85
+ : 0.5;
86
+ return { answer: parsed.answer, confidence, raw: text };
87
+ }
88
+ }
89
+ catch {
90
+ // fall through to word matching
91
+ }
92
+ }
93
+ // Fallback: the model answered with the bare word (we prompted for one word
94
+ // in early iterations; small models sometimes ignore the JSON instruction).
95
+ const lower = text.toLowerCase();
96
+ if (/\bintentional\b/.test(lower) && !/\bregression\b/.test(lower)) {
97
+ return { answer: 'intentional', confidence: 0.8, raw: text };
98
+ }
99
+ if (/\bregression\b/.test(lower) && !/\bintentional\b/.test(lower)) {
100
+ return { answer: 'regression', confidence: 0.8, raw: text };
101
+ }
102
+ // Unparseable — fail safe: treat as an unresolved regression
103
+ return { answer: 'regression', confidence: 0.5, raw: text };
104
+ }
105
+ function toDataUrl(png) {
106
+ return `data:image/png;base64,${png.toString('base64')}`;
107
+ }
108
+ /**
109
+ * Resolves the API token: process env first, then a gitignored
110
+ * `<projectDir>/.kintsugi/.env` file (KEY=VALUE lines).
111
+ */
112
+ export function resolveApiToken(tokenEnvVar, projectDir) {
113
+ if (process.env[tokenEnvVar])
114
+ return process.env[tokenEnvVar];
115
+ if (projectDir)
116
+ loadKintsugiEnv(projectDir);
117
+ return process.env[tokenEnvVar];
118
+ }
119
+ /** Loads KEY=VALUE pairs from <projectDir>/.kintsugi/.env into process.env (never overrides). */
120
+ export function loadKintsugiEnv(projectDir) {
121
+ const envPath = path.join(projectDir, '.kintsugi', '.env');
122
+ let content;
123
+ try {
124
+ content = fs.readFileSync(envPath, 'utf-8');
125
+ }
126
+ catch {
127
+ return;
128
+ }
129
+ for (const line of content.split('\n')) {
130
+ const match = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/);
131
+ if (!match)
132
+ continue;
133
+ const key = match[1];
134
+ const value = match[2].replace(/^["']|["']$/g, '');
135
+ if (!(key in process.env))
136
+ process.env[key] = value;
137
+ }
138
+ }
139
+ //# sourceMappingURL=vision.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"vision.js","sourceRoot":"","sources":["../src/vision.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@kintsugi-ai/core",
3
+ "version": "0.1.0",
4
+ "description": "Core visual regression detection engine for Kintsugi",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "dependencies": {
15
+ "pixelmatch": "^6.0.0",
16
+ "playwright": "^1.50.0",
17
+ "pngjs": "^7.0.0"
18
+ },
19
+ "devDependencies": {
20
+ "@types/node": "^22.0.0",
21
+ "@types/pixelmatch": "^5.2.6",
22
+ "@types/pngjs": "^6.0.5",
23
+ "typescript": "^5.7.0"
24
+ },
25
+ "engines": {
26
+ "node": ">=20.0.0"
27
+ },
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "scripts": {
32
+ "build": "tsc",
33
+ "clean": "rm -rf dist",
34
+ "typecheck": "tsc --noEmit",
35
+ "test": "node --test dist/**/*.test.js"
36
+ }
37
+ }
package/src/auth.ts ADDED
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Hosted-API client lifecycle: anonymous key bootstrap, account status, and
3
+ * checkout — shared by the hook, the CLI, and the VS Code extension.
4
+ */
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import os from 'node:os';
8
+ import { createHash } from 'node:crypto';
9
+ import { resolveApiToken, globalKintsugiEnvPath } from './classifier.js';
10
+ import type { ClassifierContext } from './comparator.js';
11
+ import type { KintsugiConfig } from './types.js';
12
+
13
+ type Logger = { info: (msg: string, data?: unknown) => void; warn: (msg: string, data?: unknown) => void };
14
+
15
+ /** Stable per-machine fingerprint used to issue anonymous keys. */
16
+ export function computeDeviceHash(): string {
17
+ return createHash('sha256').update(`${os.hostname()}|${os.homedir()}`).digest('hex').slice(0, 32);
18
+ }
19
+
20
+ /** Writes (or replaces) one KEY=VALUE line in ~/.kintsugi/.env, preserving other lines. */
21
+ export function writeGlobalEnvValue(key: string, value: string): string {
22
+ const envPath = globalKintsugiEnvPath();
23
+ fs.mkdirSync(path.dirname(envPath), { recursive: true });
24
+ let content = '';
25
+ try {
26
+ content = fs.readFileSync(envPath, 'utf-8');
27
+ } catch {
28
+ // no file yet
29
+ }
30
+ const line = `${key}=${value}`;
31
+ const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
32
+ const re = new RegExp(`^${escapedKey}=.*$`, 'm');
33
+ const updated = re.test(content)
34
+ ? content.replace(re, line)
35
+ : content === '' || content.endsWith('\n')
36
+ ? content + line
37
+ : content + '\n' + line;
38
+ fs.writeFileSync(envPath, updated.endsWith('\n') ? updated : updated + '\n');
39
+ return envPath;
40
+ }
41
+
42
+ /**
43
+ * Returns the hosted-API key, bootstrapping an anonymous free key (written to
44
+ * ~/.kintsugi/.env) when none exists. Returns undefined on network failure —
45
+ * callers degrade to classifier-off, never crash.
46
+ */
47
+ export async function ensureApiKey(options: {
48
+ endpoint: string;
49
+ tokenEnvVar: string;
50
+ projectDir?: string;
51
+ fetchImpl?: typeof fetch;
52
+ logger?: Logger;
53
+ }): Promise<string | undefined> {
54
+ const existing = resolveApiToken(options.tokenEnvVar, options.projectDir);
55
+ if (existing) return existing;
56
+
57
+ const fetchImpl = options.fetchImpl ?? fetch;
58
+ try {
59
+ const res = await fetchImpl(`${options.endpoint.replace(/\/$/, '')}/keys`, {
60
+ method: 'POST',
61
+ headers: { 'Content-Type': 'application/json' },
62
+ body: JSON.stringify({ deviceHash: computeDeviceHash() }),
63
+ });
64
+ if (!res.ok) throw new Error(`key issuance ${res.status}`);
65
+ const body = (await res.json()) as { key?: string };
66
+ if (typeof body.key !== 'string' || !body.key.startsWith('knt_')) {
67
+ throw new Error('key issuance returned no key');
68
+ }
69
+ writeGlobalEnvValue(options.tokenEnvVar, body.key);
70
+ options.logger?.info('issued anonymous kintsugi api key', { envFile: globalKintsugiEnvPath() });
71
+ return body.key;
72
+ } catch (err) {
73
+ options.logger?.warn(`could not obtain a kintsugi api key: ${(err as Error).message}`);
74
+ return undefined;
75
+ }
76
+ }
77
+
78
+ export interface ApiAccountStatus {
79
+ plan: string;
80
+ flowsUsed: number;
81
+ flowLimit: number | null;
82
+ upgradeUrl?: string;
83
+ }
84
+
85
+ export async function getAccountStatus(
86
+ apiKey: string,
87
+ endpoint: string,
88
+ fetchImpl?: typeof fetch
89
+ ): Promise<ApiAccountStatus | undefined> {
90
+ try {
91
+ const res = await (fetchImpl ?? fetch)(`${endpoint.replace(/\/$/, '')}/keys/me`, {
92
+ headers: { Authorization: `Bearer ${apiKey}` },
93
+ });
94
+ if (!res.ok) return undefined;
95
+ return (await res.json()) as ApiAccountStatus;
96
+ } catch {
97
+ return undefined;
98
+ }
99
+ }
100
+
101
+ /** Stripe Payment Link URL for the $29/mo Pro upgrade, or undefined when billing is unavailable. */
102
+ export async function createCheckoutUrl(
103
+ apiKey: string,
104
+ endpoint: string,
105
+ fetchImpl?: typeof fetch
106
+ ): Promise<string | undefined> {
107
+ try {
108
+ const res = await (fetchImpl ?? fetch)(`${endpoint.replace(/\/$/, '')}/billing/checkout`, {
109
+ method: 'POST',
110
+ headers: { Authorization: `Bearer ${apiKey}` },
111
+ });
112
+ if (!res.ok) return undefined;
113
+ const body = (await res.json()) as { url?: string };
114
+ return body.url;
115
+ } catch {
116
+ return undefined;
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Polls account status until the plan becomes 'pro' (the Stripe webhook flips
122
+ * it server-side shortly after payment) or the timeout elapses. Called from
123
+ * the CLI and the VS Code extension after the payment link is opened.
124
+ */
125
+ export async function waitForPlanUpgrade(
126
+ apiKey: string,
127
+ endpoint: string,
128
+ options: {
129
+ intervalMs?: number;
130
+ timeoutMs?: number;
131
+ fetchImpl?: typeof fetch;
132
+ shouldContinue?: () => boolean;
133
+ onPoll?: (status: ApiAccountStatus | undefined) => void;
134
+ } = {}
135
+ ): Promise<ApiAccountStatus | undefined> {
136
+ const interval = options.intervalMs ?? 3000;
137
+ const deadline = Date.now() + (options.timeoutMs ?? 180_000);
138
+ while (Date.now() < deadline && (options.shouldContinue?.() ?? true)) {
139
+ const status = await getAccountStatus(apiKey, endpoint, options.fetchImpl);
140
+ if (status?.plan === 'pro') return status;
141
+ options.onPoll?.(status);
142
+ await new Promise(resolve => setTimeout(resolve, interval));
143
+ }
144
+ return undefined;
145
+ }
146
+
147
+ /**
148
+ * Shared classifier-context builder used by the hook, `kintsugi check`, and
149
+ * the MCP server — one code path decides when classification is active.
150
+ * With bootstrapKey, an anonymous hosted key is auto-issued on first use.
151
+ */
152
+ export async function buildClassifierContext(
153
+ config: KintsugiConfig,
154
+ options: {
155
+ context?: string;
156
+ logger?: Logger;
157
+ bootstrapKey?: boolean;
158
+ projectDir?: string;
159
+ fetchImpl?: typeof fetch;
160
+ } = {}
161
+ ): Promise<ClassifierContext | undefined> {
162
+ const log = options.logger;
163
+ if (!config.classifier?.enabled) {
164
+ log?.info('classifier disabled in config');
165
+ return undefined;
166
+ }
167
+ let apiToken = resolveApiToken(config.classifier.tokenEnvVar, options.projectDir);
168
+ if (!apiToken && options.bootstrapKey && config.classifier.provider === 'kintsugi') {
169
+ apiToken = await ensureApiKey({
170
+ endpoint: config.classifier.endpoint,
171
+ tokenEnvVar: config.classifier.tokenEnvVar,
172
+ projectDir: options.projectDir,
173
+ fetchImpl: options.fetchImpl,
174
+ logger: log,
175
+ });
176
+ }
177
+ if (!apiToken) {
178
+ log?.warn(
179
+ `no API token found (env ${config.classifier.tokenEnvVar}, <project>/.kintsugi/.env, or ~/.kintsugi/.env) — classifier disabled, large diffs will escalate without classification`
180
+ );
181
+ return undefined;
182
+ }
183
+ log?.info('classifier active', {
184
+ provider: config.classifier.provider,
185
+ model: config.classifier.model,
186
+ endpoint: config.classifier.endpoint,
187
+ turnContext: options.context || '(none)',
188
+ });
189
+ return {
190
+ config: config.classifier,
191
+ apiToken,
192
+ context: options.context,
193
+ logger: log,
194
+ };
195
+ }