@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
package/src/config.ts ADDED
@@ -0,0 +1,83 @@
1
+ import { promises as fs } from 'fs';
2
+ import path from 'path';
3
+ import type { KintsugiConfig } from './types.js';
4
+
5
+ export function getDefaultConfig(): KintsugiConfig {
6
+ return {
7
+ version: 1,
8
+ devServerUrl: 'http://localhost:3000',
9
+ viewport: { width: 1280, height: 720 },
10
+ thresholds: {
11
+ pixelDiffPercent: 0.1,
12
+ ssimScore: 0.98,
13
+ maxCheckLatencyMs: 5000,
14
+ },
15
+ llm: {
16
+ provider: 'claude',
17
+ model: 'claude-3-5-sonnet-20241022',
18
+ apiKeyEnvVar: 'ANTHROPIC_API_KEY',
19
+ },
20
+ classifier: {
21
+ enabled: true,
22
+ provider: 'kintsugi',
23
+ endpoint: process.env.KINTSUGI_API_URL ?? 'https://api.kintsugi.dev/v1',
24
+ model: 'kintsugi-hosted',
25
+ tokenEnvVar: 'KINTSUGI_API_KEY',
26
+ pixelDiffThreshold: 0.5,
27
+ confidenceThreshold: 0.7,
28
+ timeoutMs: 20000,
29
+ },
30
+ agent: {
31
+ type: 'auto',
32
+ maxRetries: 3,
33
+ },
34
+ recording: {
35
+ passive: true,
36
+ collectorPort: 4321,
37
+ },
38
+ uiFilePatterns: ['src/**/*.tsx', 'src/**/*.ts', 'src/**/*.jsx', 'src/**/*.js'],
39
+ };
40
+ }
41
+
42
+ export async function ensureKintsugiDir(projectDir: string): Promise<void> {
43
+ const kintsugiDir = path.join(projectDir, '.kintsugi');
44
+ const flowsDir = path.join(kintsugiDir, 'flows');
45
+ const recordingsDir = path.join(kintsugiDir, 'recordings');
46
+
47
+ await fs.mkdir(kintsugiDir, { recursive: true });
48
+ await fs.mkdir(flowsDir, { recursive: true });
49
+ await fs.mkdir(recordingsDir, { recursive: true });
50
+ }
51
+
52
+ export async function loadConfig(projectDir: string): Promise<KintsugiConfig> {
53
+ const configPath = path.join(projectDir, '.kintsugi', 'config.json');
54
+ const defaultConfig = getDefaultConfig();
55
+ try {
56
+ const data = await fs.readFile(configPath, 'utf-8');
57
+ const userConfig = JSON.parse(data) as Partial<KintsugiConfig>;
58
+ const classifier = {
59
+ ...defaultConfig.classifier,
60
+ ...(userConfig.classifier ?? {}),
61
+ } as KintsugiConfig['classifier'];
62
+ // Migration: configs written before the provider field pointed at
63
+ // OpenAI-compatible endpoints (HF router). Keep them working unchanged.
64
+ if (userConfig.classifier && userConfig.classifier.provider === undefined) {
65
+ if (!classifier.endpoint.includes('kintsugi')) {
66
+ classifier.provider = 'openai-compatible';
67
+ }
68
+ }
69
+ return {
70
+ ...defaultConfig,
71
+ ...userConfig,
72
+ classifier,
73
+ } as KintsugiConfig;
74
+ } catch (error) {
75
+ return defaultConfig;
76
+ }
77
+ }
78
+
79
+ export async function saveConfig(projectDir: string, config: KintsugiConfig): Promise<void> {
80
+ await ensureKintsugiDir(projectDir);
81
+ const configPath = path.join(projectDir, '.kintsugi', 'config.json');
82
+ await fs.writeFile(configPath, JSON.stringify(config, null, 2), 'utf-8');
83
+ }
package/src/domdiff.ts ADDED
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Line diff for Playwright aria snapshots (YAML). Produces a compact
3
+ * removed/added summary suitable as classifier input.
4
+ */
5
+
6
+ const MAX_DIFF_LINES = 80;
7
+
8
+ export function diffAriaSnapshots(baseline: string, current: string): string {
9
+ const a = baseline.split('\n');
10
+ const b = current.split('\n');
11
+
12
+ // LCS table (snapshots are small — a few hundred lines max)
13
+ const m = a.length, n = b.length;
14
+ const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
15
+ for (let i = m - 1; i >= 0; i--) {
16
+ for (let j = n - 1; j >= 0; j--) {
17
+ dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
18
+ }
19
+ }
20
+
21
+ // Walk the table, grouping consecutive changes into hunks with nearest context
22
+ type Op = { type: ' ' | '-' | '+'; line: string };
23
+ const ops: Op[] = [];
24
+ let i = 0, j = 0;
25
+ while (i < m && j < n) {
26
+ if (a[i] === b[j]) { ops.push({ type: ' ', line: a[i] }); i++; j++; }
27
+ else if (dp[i + 1][j] >= dp[i][j + 1]) { ops.push({ type: '-', line: a[i] }); i++; }
28
+ else { ops.push({ type: '+', line: b[j] }); j++; }
29
+ }
30
+ while (i < m) { ops.push({ type: '-', line: a[i++] }); }
31
+ while (j < n) { ops.push({ type: '+', line: b[j++] }); }
32
+
33
+ // Emit only changed hunks (with 1 line of context), trimmed.
34
+ // Prefixes are words, not +/-, because aria snapshot lines themselves
35
+ // start with YAML list markers ("- ").
36
+ const out: string[] = [];
37
+ let changes = 0;
38
+ let lastContextIndex = -1;
39
+ for (let k = 0; k < ops.length; k++) {
40
+ if (ops[k].type === ' ') continue;
41
+ if (out.length > 0 && out[out.length - 1] !== '…' && ops[k - 1]?.type === ' ' && (k - 1) !== lastContextIndex) {
42
+ out.push(' ctx: ' + ops[k - 1].line);
43
+ }
44
+ while (k < ops.length && ops[k].type !== ' ') {
45
+ out.push((ops[k].type === '-' ? 'REMOVED: ' : 'ADDED: ') + ops[k].line);
46
+ changes++;
47
+ k++;
48
+ }
49
+ if (k < ops.length) {
50
+ out.push(' ctx: ' + ops[k].line);
51
+ lastContextIndex = k;
52
+ }
53
+ out.push('');
54
+ if (out.length >= MAX_DIFF_LINES) {
55
+ out.push('… (diff truncated, more changes below)');
56
+ break;
57
+ }
58
+ }
59
+
60
+ if (changes === 0) return '(no line-level differences — change is purely visual/rendered)';
61
+ return out.join('\n').trim();
62
+ }
@@ -0,0 +1,64 @@
1
+ import { DiffResult, type ComparisonResult } from './types.js';
2
+
3
+ /**
4
+ * Fix-it feedback in the agent-facing format:
5
+ * "{flow name} is broken, aria diffs: [...], [sets of (new image and old image)],
6
+ * suggested fixes: [fixes from the classifier per image pair]"
7
+ */
8
+ export function formatAgentFeedback(result: ComparisonResult, flowName: string): string {
9
+ const lines: string[] = [];
10
+ lines.push(`${flowName} is broken.`);
11
+
12
+ // aria diffs per classified pair
13
+ lines.push('aria diffs:');
14
+ const classifications = result.classifications ?? [];
15
+ if (classifications.length === 0) {
16
+ lines.push(' (none captured — flow failed during replay; check selectors and page load)');
17
+ }
18
+ for (const c of classifications) {
19
+ lines.push(` step ${c.step}:`);
20
+ for (const line of c.ariaDiff.split('\n')) {
21
+ lines.push(` ${line}`);
22
+ }
23
+ }
24
+
25
+ // sets of (old image, new image)
26
+ lines.push('image pairs (old image | new image):');
27
+ if (classifications.length === 0) {
28
+ lines.push(' (none — flow failed during replay)');
29
+ }
30
+ for (const c of classifications) {
31
+ lines.push(` step ${c.step}: ${c.oldImagePath ?? '(unavailable)'} | ${c.newImagePath ?? '(unavailable)'}`);
32
+ }
33
+
34
+ // suggested fixes — the classifier's reasoning per non-intentional pair
35
+ lines.push('suggested fixes:');
36
+ const regressions = classifications.filter(c => !c.intentional || c.confidence <= 0.7);
37
+ if (classifications.length === 0) {
38
+ lines.push(' - re-run the flow and verify the page loads; an element selector likely changed');
39
+ }
40
+ for (const c of regressions) {
41
+ lines.push(` step ${c.step}: ${c.reasoning}`);
42
+ }
43
+ if (classifications.length > 0 && regressions.length === 0) {
44
+ lines.push(' - (classifier found no intentional-only pairs, but the flow still failed — see replay errors)');
45
+ }
46
+
47
+ if (result.diffImagePath) {
48
+ lines.push(`diff heatmap: ${result.diffImagePath}`);
49
+ }
50
+ return lines.join('\n');
51
+ }
52
+
53
+ export function formatUserSummary(result: ComparisonResult, flowName: string): string {
54
+ if (result.result === DiffResult.IDENTICAL) {
55
+ return `✅ Flow "${flowName}" passed. No visual differences detected.`;
56
+ }
57
+
58
+ let summary = `⚠️ Flow "${flowName}" resulted in ${result.result.toUpperCase()}.\n`;
59
+ if (result.failedStep !== undefined) {
60
+ summary += `Step ${result.failedStep} failed with a pixel difference of ${result.pixelDiffPercent.toFixed(2)}%.`;
61
+ }
62
+
63
+ return summary;
64
+ }
package/src/flowid.ts ADDED
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Stable per-flow fingerprint used by the hosted API's flow gate: hashed from
3
+ * the flow name + step actions/selectors/urls — NOT from screenshots or
4
+ * timestamps — so replays count once and renames can't evade the limit.
5
+ */
6
+ import { createHash } from 'node:crypto';
7
+ import type { FlowStep } from './types.js';
8
+
9
+ export function flowFingerprint(name: string, steps: FlowStep[]): string {
10
+ const canonical = JSON.stringify(
11
+ steps.map(s => [s.action, s.selector ?? null, s.url ?? null, s.value ?? null])
12
+ );
13
+ return createHash('sha256').update(`${name}\n${canonical}`).digest('hex').slice(0, 40);
14
+ }
package/src/index.ts ADDED
@@ -0,0 +1,13 @@
1
+ export * from './types.js';
2
+ export * from './config.js';
3
+ export * from './setupPrompt.js';
4
+ export * from './baseline.js';
5
+ export * from './capturer.js';
6
+ export * from './comparator.js';
7
+ export * from './feedback.js';
8
+ export * from './recorder.js';
9
+ export * from './benchmark.js';
10
+ export * from './classifier.js';
11
+ export * from './auth.js';
12
+ export * from './flowid.js';
13
+ export * from './domdiff.js';
@@ -0,0 +1,96 @@
1
+ import { chromium } from 'playwright';
2
+ import { promises as fs } from 'fs';
3
+ import path from 'path';
4
+ import type { FlowRecording, FlowStep, FlowMetadata } from './types.js';
5
+
6
+ export async function recordFlow(options: {
7
+ name: string;
8
+ url: string;
9
+ outputDir: string;
10
+ viewport?: { width: number; height: number };
11
+ }): Promise<FlowRecording> {
12
+ const browser = await chromium.launch({ headless: false });
13
+ const context = await browser.newContext({ viewport: options.viewport || { width: 1280, height: 720 } });
14
+ const page = await context.newPage();
15
+
16
+ const steps: FlowStep[] = [];
17
+ const startTime = Date.now();
18
+
19
+ steps.push({ action: 'navigate', url: options.url });
20
+ await page.goto(options.url);
21
+
22
+ await page.exposeFunction('trackAction', (action: any) => {
23
+ steps.push(action);
24
+ });
25
+
26
+ await page.addInitScript(() => {
27
+ document.addEventListener('click', (e) => {
28
+ const target = e.target as HTMLElement;
29
+ let selector: string;
30
+ if (target.id) {
31
+ selector = `#${target.id}`;
32
+ } else if (target.getAttribute('data-testid')) {
33
+ selector = `[data-testid="${target.getAttribute('data-testid')}"]`;
34
+ } else if ((target as HTMLInputElement).name) {
35
+ selector = `${target.tagName.toLowerCase()}[name="${(target as HTMLInputElement).name}"]`;
36
+ } else if (target.textContent && target.textContent.trim().length > 0 && target.textContent.trim().length < 80 && target.children.length === 0) {
37
+ selector = `text=${target.textContent.trim()}`;
38
+ } else {
39
+ const parent = target.parentElement;
40
+ const siblings = parent ? Array.from(parent.children).filter(c => c.tagName === target.tagName) : [];
41
+ const idx = siblings.indexOf(target) + 1;
42
+ selector = siblings.length > 1 ? `${target.tagName.toLowerCase()}:nth-of-type(${idx})` : target.tagName.toLowerCase();
43
+ }
44
+ (window as any).trackAction({ action: 'click', selector });
45
+ }, true);
46
+
47
+ document.addEventListener('change', (e) => {
48
+ const target = e.target as HTMLInputElement;
49
+ let selector: string;
50
+ if (target.id) {
51
+ selector = `#${target.id}`;
52
+ } else if (target.getAttribute('data-testid')) {
53
+ selector = `[data-testid="${target.getAttribute('data-testid')}"]`;
54
+ } else if (target.name) {
55
+ selector = `${target.tagName.toLowerCase()}[name="${target.name}"]`;
56
+ } else {
57
+ selector = target.tagName.toLowerCase();
58
+ }
59
+ if (target.type === 'checkbox' || target.type === 'radio') {
60
+ (window as any).trackAction({ action: 'click', selector });
61
+ } else {
62
+ (window as any).trackAction({ action: 'type', selector, value: target.value });
63
+ }
64
+ }, true);
65
+ });
66
+
67
+ await page.waitForEvent('close', { timeout: 0 });
68
+ await browser.close();
69
+
70
+ const duration = Date.now() - startTime;
71
+
72
+ const metadata: FlowMetadata = {
73
+ name: options.name,
74
+ url: options.url,
75
+ viewport: options.viewport || { width: 1280, height: 720 },
76
+ recordedAt: new Date().toISOString(),
77
+ duration,
78
+ stepCount: steps.length,
79
+ };
80
+
81
+ const recording: FlowRecording = {
82
+ metadata,
83
+ steps,
84
+ screenshotPaths: [],
85
+ };
86
+
87
+ await fs.mkdir(options.outputDir, { recursive: true });
88
+ await fs.writeFile(path.join(options.outputDir, 'metadata.json'), JSON.stringify(metadata, null, 2));
89
+ await fs.writeFile(path.join(options.outputDir, 'steps.json'), JSON.stringify(steps, null, 2));
90
+
91
+ return recording;
92
+ }
93
+
94
+ export function generateStepsFromScript(scriptPath: string): FlowStep[] {
95
+ return [];
96
+ }
@@ -0,0 +1,26 @@
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: { devServerUrl?: string } = {}): string {
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
+ }
package/src/types.ts ADDED
@@ -0,0 +1,144 @@
1
+ /** Result classification from the comparison pipeline */
2
+ export 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
+
12
+ /** A single step in a recorded user flow */
13
+ export interface FlowStep {
14
+ action: 'navigate' | 'click' | 'type' | 'scroll' | 'wait' | 'screenshot';
15
+ selector?: string;
16
+ url?: string;
17
+ value?: string;
18
+ position?: { x: number; y: number };
19
+ timeout?: number;
20
+ description?: string;
21
+ }
22
+
23
+ /** Metadata about a recorded flow */
24
+ export interface FlowMetadata {
25
+ name: string;
26
+ url: string;
27
+ viewport: { width: number; height: number };
28
+ recordedAt: string;
29
+ duration: number;
30
+ stepCount: number;
31
+ }
32
+
33
+ /** A complete flow recording with steps and baseline screenshots */
34
+ export interface FlowRecording {
35
+ metadata: FlowMetadata;
36
+ steps: FlowStep[];
37
+ screenshotPaths: string[];
38
+ /** Baseline aria snapshots per step (semantic DOM state) */
39
+ ariaSnapshotPaths?: string[];
40
+ videoPath?: string;
41
+ }
42
+
43
+ /** Result of capturing the current UI state */
44
+ export interface CaptureResult {
45
+ screenshots: Buffer[];
46
+ screenshotPaths: string[];
47
+ /** Playwright aria snapshot (semantic YAML) of the page after each step */
48
+ ariaSnapshots: string[];
49
+ videoPath?: string;
50
+ completedSteps: number;
51
+ totalSteps: number;
52
+ errors: string[];
53
+ }
54
+
55
+ /** Classifier output for one baseline/current screenshot pair */
56
+ export interface StepClassification {
57
+ step: number;
58
+ intentional: boolean;
59
+ confidence: number;
60
+ /** One sentence from the classifier — the suggested fix when not intentional */
61
+ reasoning: string;
62
+ /** aria-snapshot diff for this pair */
63
+ ariaDiff: string;
64
+ oldImagePath?: string;
65
+ newImagePath?: string;
66
+ }
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
+
82
+ /** Per-step comparison detail */
83
+ export interface StepComparisonResult {
84
+ step: number;
85
+ pixelDiffPercent: number;
86
+ ssimScore: number;
87
+ result: DiffResult;
88
+ diffImagePath?: string;
89
+ }
90
+
91
+ /** Which transport classifyChange uses */
92
+ export type ClassifierProvider =
93
+ /** Hosted kintsugi classifier API (default) — server picks the model */
94
+ | 'kintsugi'
95
+ /** Any OpenAI-compatible /chat/completions endpoint (HF router, Groq, local llama.cpp, …) */
96
+ | 'openai-compatible';
97
+
98
+ /** Configuration for the change classifier (intentional vs regression) */
99
+ export interface ClassifierConfig {
100
+ enabled: boolean;
101
+ provider: ClassifierProvider;
102
+ /**
103
+ * API base URL. For provider 'kintsugi': the hosted API (override with the
104
+ * KINTSUGI_API_URL env var, e.g. http://localhost:8787/v1 in dev). For
105
+ * 'openai-compatible': a base URL exposing POST {endpoint}/chat/completions.
106
+ */
107
+ endpoint: string;
108
+ /** Only used by the 'openai-compatible' provider; the kintsugi API picks its own model */
109
+ model: string;
110
+ tokenEnvVar: string;
111
+ /** Pixel-diff % above which the classifier is consulted */
112
+ pixelDiffThreshold: number;
113
+ /** Required confidence for an "intentional" verdict to be accepted */
114
+ confidenceThreshold: number;
115
+ timeoutMs: number;
116
+ }
117
+
118
+ /** Project-level Kintsugi configuration */
119
+ export interface KintsugiConfig {
120
+ version: number;
121
+ devServerUrl: string;
122
+ viewport: { width: number; height: number };
123
+ thresholds: {
124
+ pixelDiffPercent: number;
125
+ ssimScore: number;
126
+ maxCheckLatencyMs: number;
127
+ };
128
+ llm: {
129
+ provider: 'claude' | 'openai';
130
+ model: string;
131
+ apiKeyEnvVar: string;
132
+ };
133
+ /** Change classifier: aria-snapshot diff + turn context → intentional or regression */
134
+ classifier: ClassifierConfig;
135
+ agent: {
136
+ type: 'auto' | 'claude-code' | 'codex' | 'agy';
137
+ maxRetries: number;
138
+ };
139
+ recording: {
140
+ passive: boolean;
141
+ collectorPort: number;
142
+ };
143
+ uiFilePatterns: string[];
144
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src",
6
+ "lib": ["es2022", "dom"]
7
+ },
8
+ "include": ["src/**/*"]
9
+ }