@iris-eval/mcp-server 0.3.0 → 0.4.0-rc.1

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 (103) hide show
  1. package/README.md +11 -3
  2. package/dist/audit-log-reader.d.ts +24 -0
  3. package/dist/audit-log-reader.js +87 -0
  4. package/dist/config/defaults.js +7 -1
  5. package/dist/custom-rule-store.d.ts +27 -0
  6. package/dist/custom-rule-store.js +188 -0
  7. package/dist/dashboard/assets/index-BEG5FYWH.css +1 -0
  8. package/dist/dashboard/assets/index-D9JHfSB2.js +12 -0
  9. package/dist/dashboard/index.html +2 -2
  10. package/dist/dashboard/routes/audit.d.ts +3 -0
  11. package/dist/dashboard/routes/audit.js +36 -0
  12. package/dist/dashboard/routes/eval-stats.js +9 -4
  13. package/dist/dashboard/routes/evaluations.js +3 -1
  14. package/dist/dashboard/routes/filters.js +5 -3
  15. package/dist/dashboard/routes/health.js +8 -1
  16. package/dist/dashboard/routes/index.d.ts +4 -0
  17. package/dist/dashboard/routes/index.js +4 -0
  18. package/dist/dashboard/routes/moments.d.ts +3 -0
  19. package/dist/dashboard/routes/moments.js +115 -0
  20. package/dist/dashboard/routes/preferences.d.ts +3 -0
  21. package/dist/dashboard/routes/preferences.js +52 -0
  22. package/dist/dashboard/routes/rules.d.ts +10 -0
  23. package/dist/dashboard/routes/rules.js +169 -0
  24. package/dist/dashboard/routes/summary.js +3 -1
  25. package/dist/dashboard/routes/traces.js +7 -4
  26. package/dist/dashboard/server.d.ts +9 -1
  27. package/dist/dashboard/server.js +52 -3
  28. package/dist/eval/citation-verify/extract.d.ts +11 -0
  29. package/dist/eval/citation-verify/extract.js +102 -0
  30. package/dist/eval/citation-verify/resolve.d.ts +26 -0
  31. package/dist/eval/citation-verify/resolve.js +237 -0
  32. package/dist/eval/citation-verify/verifier.d.ts +43 -0
  33. package/dist/eval/citation-verify/verifier.js +203 -0
  34. package/dist/eval/decision-moment.d.ts +12 -0
  35. package/dist/eval/decision-moment.js +181 -0
  36. package/dist/eval/llm-judge/client.d.ts +28 -0
  37. package/dist/eval/llm-judge/client.js +183 -0
  38. package/dist/eval/llm-judge/evaluator.d.ts +32 -0
  39. package/dist/eval/llm-judge/evaluator.js +138 -0
  40. package/dist/eval/llm-judge/pricing.d.ts +9 -0
  41. package/dist/eval/llm-judge/pricing.js +31 -0
  42. package/dist/eval/llm-judge/templates/index.d.ts +20 -0
  43. package/dist/eval/llm-judge/templates/index.js +170 -0
  44. package/dist/eval/rules/custom.js +13 -2
  45. package/dist/eval/rules/relevance.js +51 -6
  46. package/dist/eval/rules/safety.d.ts +1 -0
  47. package/dist/eval/rules/safety.js +87 -3
  48. package/dist/index.js +77 -14
  49. package/dist/middleware/index.d.ts +1 -0
  50. package/dist/middleware/index.js +1 -0
  51. package/dist/middleware/tenant.d.ts +17 -0
  52. package/dist/middleware/tenant.js +26 -0
  53. package/dist/otel/exporter.d.ts +24 -0
  54. package/dist/otel/exporter.js +116 -0
  55. package/dist/otel/lazy.d.ts +5 -0
  56. package/dist/otel/lazy.js +31 -0
  57. package/dist/otel/mapper.d.ts +24 -0
  58. package/dist/otel/mapper.js +208 -0
  59. package/dist/preferences.d.ts +129 -0
  60. package/dist/preferences.js +152 -0
  61. package/dist/resources/dashboard-summary.js +3 -1
  62. package/dist/resources/trace-detail.js +5 -3
  63. package/dist/server.d.ts +3 -1
  64. package/dist/server.js +9 -3
  65. package/dist/storage/migrations/004-tenant-id.d.ts +3 -0
  66. package/dist/storage/migrations/004-tenant-id.js +40 -0
  67. package/dist/storage/migrations/index.js +2 -1
  68. package/dist/storage/sqlite-adapter.d.ts +17 -15
  69. package/dist/storage/sqlite-adapter.js +130 -79
  70. package/dist/tools/delete-rule.d.ts +3 -0
  71. package/dist/tools/delete-rule.js +53 -0
  72. package/dist/tools/delete-trace.d.ts +3 -0
  73. package/dist/tools/delete-trace.js +54 -0
  74. package/dist/tools/deploy-rule.d.ts +3 -0
  75. package/dist/tools/deploy-rule.js +91 -0
  76. package/dist/tools/evaluate-output.js +23 -2
  77. package/dist/tools/evaluate-with-llm-judge.d.ts +3 -0
  78. package/dist/tools/evaluate-with-llm-judge.js +147 -0
  79. package/dist/tools/get-traces.js +23 -3
  80. package/dist/tools/index.d.ts +2 -1
  81. package/dist/tools/index.js +13 -1
  82. package/dist/tools/list-rules.d.ts +3 -0
  83. package/dist/tools/list-rules.js +66 -0
  84. package/dist/tools/log-trace.js +30 -2
  85. package/dist/tools/verify-citations.d.ts +3 -0
  86. package/dist/tools/verify-citations.js +157 -0
  87. package/dist/types/custom-rule.d.ts +70 -0
  88. package/dist/types/custom-rule.js +1 -0
  89. package/dist/types/decision-moment.d.ts +122 -0
  90. package/dist/types/decision-moment.js +17 -0
  91. package/dist/types/index.d.ts +2 -0
  92. package/dist/types/index.js +1 -1
  93. package/dist/types/query.d.ts +25 -15
  94. package/dist/types/tenant.d.ts +26 -0
  95. package/dist/types/tenant.js +58 -0
  96. package/dist/utils/open-browser.d.ts +1 -0
  97. package/dist/utils/open-browser.js +45 -0
  98. package/dist/utils/validate-port-config.d.ts +2 -0
  99. package/dist/utils/validate-port-config.js +9 -0
  100. package/package.json +4 -1
  101. package/server.json +2 -2
  102. package/dist/dashboard/assets/index-CnDg6bYi.js +0 -43
  103. package/dist/dashboard/assets/index-ZsBou2-c.css +0 -1
package/README.md CHANGED
@@ -31,7 +31,7 @@ Iris evaluates all of it.
31
31
  | | |
32
32
  |---|---|
33
33
  | **Trace Logging** | Hierarchical span trees with per-tool-call latency, token usage, and cost in USD. Stored in SQLite, queryable instantly. |
34
- | **Output Evaluation** | 12 built-in rules across 4 categories: completeness, relevance, safety, cost. PII detection, prompt injection patterns, hallucination markers. Add custom rules with Zod schemas. |
34
+ | **Output Evaluation** | 13 built-in rules across 4 categories: completeness, relevance, safety, cost. PII detection (10 patterns: SSN, credit card, phone, email, IBAN, DOB, MRN, IP, API key, passport), prompt injection (13 patterns), stub-output detection, hallucination markers (17 hedging phrases + fabricated-citation heuristic). Add custom rules with Zod schemas. |
35
35
  | **Cost Visibility** | Aggregate cost across all agents over any time window. Set budget thresholds. Get flagged when agents overspend. |
36
36
  | **Web Dashboard** | Real-time dark-mode UI with trace visualization, eval results, and cost breakdowns. |
37
37
 
@@ -117,11 +117,19 @@ docker run -p 3000:3000 -v iris-data:/data ghcr.io/iris-eval/mcp-server
117
117
 
118
118
  ## MCP Tools
119
119
 
120
- Iris registers three tools that any MCP-compatible agent can invoke:
120
+ Iris registers nine tools that any MCP-compatible agent can invoke — full rule + trace lifecycle + LLM-as-judge + semantic citation verification:
121
121
 
122
122
  - **`log_trace`** — Log an agent execution with spans, tool calls, token usage, and cost
123
- - **`evaluate_output`** — Score output quality against completeness, relevance, safety, and cost rules
123
+ - **`evaluate_output`** — Score output quality against completeness, relevance, safety, and cost rules (heuristic, deterministic, free)
124
124
  - **`get_traces`** — Query stored traces with filtering, pagination, and time-range support
125
+ - **`list_rules`** — Enumerate deployed custom eval rules (read-only)
126
+ - **`deploy_rule`** — Register a new custom eval rule so it fires on every `evaluate_output` of that category
127
+ - **`delete_rule`** — Remove a deployed custom rule (destructive, idempotent)
128
+ - **`delete_trace`** — Remove a single stored trace by ID (destructive, tenant-scoped)
129
+ - **`evaluate_with_llm_judge`** — Semantic eval via LLM (Anthropic or OpenAI). Five templates: accuracy, helpfulness, safety, correctness, faithfulness. Cost-capped, per-eval pricing disclosed.
130
+ - **`verify_citations`** — Extract citations from output (numbered, author-year, URLs, DOIs), fetch sources behind an SSRF-guarded + domain-allowlisted resolver, and use an LLM judge to check whether each source actually supports the cited claim. Opt-in outbound HTTP.
131
+
132
+ When `IRIS_OTEL_ENDPOINT` is configured, `log_trace` calls also emit a best-effort OTLP/HTTP JSON export to any OpenTelemetry collector (Jaeger, Grafana Tempo, Datadog OTLP, Honeycomb, etc). See [docs/otel-integration.md](docs/otel-integration.md).
125
133
 
126
134
  Full tool schemas and configuration: [iris-eval.com](https://iris-eval.com)
127
135
 
@@ -0,0 +1,24 @@
1
+ import type { AuditLogEntry } from './types/custom-rule.js';
2
+ declare const AUDIT_ACTIONS: readonly ["rule.deploy", "rule.delete", "rule.toggle", "rule.update"];
3
+ export interface AuditQueryFilter {
4
+ action?: typeof AUDIT_ACTIONS[number];
5
+ /** ISO timestamp; only entries at or after. */
6
+ since?: string;
7
+ /** Optional substring match against ruleId or ruleName. */
8
+ search?: string;
9
+ }
10
+ export interface AuditQueryResult {
11
+ entries: AuditLogEntry[];
12
+ total: number;
13
+ limit: number;
14
+ offset: number;
15
+ /** Absolute path to the audit log file (for diagnostics). */
16
+ path: string;
17
+ }
18
+ export declare function readAuditLog(opts?: {
19
+ filePath?: string;
20
+ filter?: AuditQueryFilter;
21
+ limit?: number;
22
+ offset?: number;
23
+ }): AuditQueryResult;
24
+ export {};
@@ -0,0 +1,87 @@
1
+ /*
2
+ * audit-log-reader — read + parse ~/.iris/audit.log (JSONL append-only).
3
+ *
4
+ * The file is written by custom-rule-store.ts (rule.deploy / rule.delete /
5
+ * rule.toggle / rule.update). This reader hydrates entries for the
6
+ * dashboard's GET /api/v1/audit endpoint.
7
+ *
8
+ * Performance: the audit log can grow over time. We read the whole file,
9
+ * parse line-by-line, and return only the latest N entries (newest first).
10
+ * For audit volumes < 10k entries this is comfortably sub-50ms; v0.5 will
11
+ * tail-read or paginate via byte offsets if it ever matters.
12
+ */
13
+ import { readFileSync, existsSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+ import { homedir } from 'node:os';
16
+ import { z } from 'zod';
17
+ const AUDIT_ACTIONS = ['rule.deploy', 'rule.delete', 'rule.toggle', 'rule.update'];
18
+ const EntrySchema = z.object({
19
+ ts: z.string(),
20
+ /*
21
+ * tenantId added in v0.4.0. Optional for backward compatibility:
22
+ * entries written by v0.3.x don't have it; readers MUST treat them
23
+ * as 'local' (single-tenant OSS assumption). See threat model §4.3
24
+ * finding TM-4 for the Cloud-side enforcement plan.
25
+ */
26
+ tenantId: z.string().optional(),
27
+ action: z.enum(AUDIT_ACTIONS),
28
+ user: z.string(),
29
+ ruleId: z.string(),
30
+ ruleName: z.string().optional(),
31
+ details: z.record(z.unknown()).optional(),
32
+ });
33
+ function defaultAuditPath() {
34
+ return join(homedir(), '.iris', 'audit.log');
35
+ }
36
+ export function readAuditLog(opts) {
37
+ const filePath = opts?.filePath ?? defaultAuditPath();
38
+ const limit = Math.min(Math.max(opts?.limit ?? 100, 1), 1000);
39
+ const offset = Math.max(opts?.offset ?? 0, 0);
40
+ if (!existsSync(filePath)) {
41
+ return { entries: [], total: 0, limit, offset, path: filePath };
42
+ }
43
+ let raw;
44
+ try {
45
+ raw = readFileSync(filePath, 'utf-8');
46
+ }
47
+ catch {
48
+ return { entries: [], total: 0, limit, offset, path: filePath };
49
+ }
50
+ // Parse line-by-line, drop malformed rows silently. The audit log is
51
+ // append-only so a partially-flushed last line is the only realistic
52
+ // source of malformation and we shouldn't fail the whole query for it.
53
+ const all = [];
54
+ for (const line of raw.split(/\r?\n/)) {
55
+ if (!line.trim())
56
+ continue;
57
+ try {
58
+ const parsed = EntrySchema.safeParse(JSON.parse(line));
59
+ if (parsed.success)
60
+ all.push(parsed.data);
61
+ }
62
+ catch {
63
+ // Skip malformed line
64
+ }
65
+ }
66
+ // Filter
67
+ const filter = opts?.filter ?? {};
68
+ const filtered = all.filter((entry) => {
69
+ if (filter.action && entry.action !== filter.action)
70
+ return false;
71
+ if (filter.since && entry.ts < filter.since)
72
+ return false;
73
+ if (filter.search) {
74
+ const needle = filter.search.toLowerCase();
75
+ const matchId = entry.ruleId.toLowerCase().includes(needle);
76
+ const matchName = entry.ruleName?.toLowerCase().includes(needle) ?? false;
77
+ if (!matchId && !matchName)
78
+ return false;
79
+ }
80
+ return true;
81
+ });
82
+ // Sort newest first by ts
83
+ filtered.sort((a, b) => b.ts.localeCompare(a.ts));
84
+ const total = filtered.length;
85
+ const entries = filtered.slice(offset, offset + limit);
86
+ return { entries, total, limit, offset, path: filePath };
87
+ }
@@ -50,7 +50,13 @@ export const defaultConfig = {
50
50
  apiKey: undefined,
51
51
  allowedOrigins: ['http://localhost:*'],
52
52
  rateLimit: {
53
- api: 100,
53
+ /* Dashboard polls ~6 endpoints every 5–10s (Health view alone hits
54
+ * stats + trend + audit + 2× moments + priorStats). At 100/min the
55
+ * dashboard exhausts its own quota and surfaces 429s on /rules etc.
56
+ * 600/min ≈ 10/s — still rejects abusive crawlers, but accommodates
57
+ * a polling-heavy first-party dashboard with headroom for navigation
58
+ * bursts. */
59
+ api: 600,
54
60
  mcp: 20,
55
61
  },
56
62
  requestSizeLimit: '1mb',
@@ -0,0 +1,27 @@
1
+ import type { DeployedCustomRule, RuleSeverity } from './types/custom-rule.js';
2
+ import type { CustomRuleDefinition, EvalType } from './types/eval.js';
3
+ export interface CustomRuleStore {
4
+ list(): DeployedCustomRule[];
5
+ get(id: string): DeployedCustomRule | undefined;
6
+ deploy(input: DeployRuleInput): DeployedCustomRule;
7
+ delete(id: string, user?: string): boolean;
8
+ setEnabled(id: string, enabled: boolean, user?: string): DeployedCustomRule | undefined;
9
+ /** All ENABLED rules in deploy order — what the engine should register. */
10
+ enabledRules(): DeployedCustomRule[];
11
+ /** Path on disk for diagnostics. */
12
+ filePath: string;
13
+ auditPath: string;
14
+ }
15
+ export interface DeployRuleInput {
16
+ name: string;
17
+ description?: string;
18
+ evalType: EvalType;
19
+ severity?: RuleSeverity;
20
+ definition: CustomRuleDefinition;
21
+ sourceMomentId?: string;
22
+ user?: string;
23
+ }
24
+ export declare function createCustomRuleStore(opts?: {
25
+ rulesPath?: string;
26
+ auditPath?: string;
27
+ }): CustomRuleStore;
@@ -0,0 +1,188 @@
1
+ /*
2
+ * custom-rule-store — file-based persistence for deployed custom rules.
3
+ *
4
+ * Lives at ~/.iris/custom-rules.json with the schema in
5
+ * src/types/custom-rule.ts. Audit log lives at ~/.iris/audit.log
6
+ * (append-only JSONL). Both files are created on first write.
7
+ *
8
+ * The v0.4 cut is single-user local. Concurrent writes from multiple
9
+ * iris-mcp instances are not protected — that's a v0.5 concern when
10
+ * multi-tenancy lands. For now we use atomic write-via-rename so a
11
+ * crashed write doesn't leave a half-file.
12
+ */
13
+ import { mkdirSync, readFileSync, writeFileSync, existsSync, renameSync, appendFileSync } from 'node:fs';
14
+ import { join, dirname } from 'node:path';
15
+ import { homedir } from 'node:os';
16
+ import { randomBytes } from 'node:crypto';
17
+ import { z } from 'zod';
18
+ const SEVERITY_VALUES = ['low', 'medium', 'high', 'critical'];
19
+ const EVAL_TYPE_VALUES = ['completeness', 'relevance', 'safety', 'cost', 'custom'];
20
+ const RULE_TYPE_VALUES = [
21
+ 'regex_match',
22
+ 'regex_no_match',
23
+ 'min_length',
24
+ 'max_length',
25
+ 'contains_keywords',
26
+ 'excludes_keywords',
27
+ 'json_schema',
28
+ 'cost_threshold',
29
+ ];
30
+ const DefinitionSchema = z.object({
31
+ name: z.string().min(1).max(80),
32
+ type: z.enum(RULE_TYPE_VALUES),
33
+ config: z.record(z.unknown()),
34
+ weight: z.number().positive().optional(),
35
+ });
36
+ const DeployedRuleSchema = z.object({
37
+ id: z.string().min(1),
38
+ name: z.string().min(1).max(80),
39
+ description: z.string().max(500),
40
+ evalType: z.enum(EVAL_TYPE_VALUES),
41
+ severity: z.enum(SEVERITY_VALUES),
42
+ definition: DefinitionSchema,
43
+ enabled: z.boolean(),
44
+ createdAt: z.string(),
45
+ updatedAt: z.string(),
46
+ sourceMomentId: z.string().optional(),
47
+ version: z.number().int().positive(),
48
+ });
49
+ const FileSchema = z.object({
50
+ version: z.literal(1),
51
+ rules: z.array(DeployedRuleSchema),
52
+ });
53
+ function defaultRulesPath() {
54
+ return join(homedir(), '.iris', 'custom-rules.json');
55
+ }
56
+ function defaultAuditPath() {
57
+ return join(homedir(), '.iris', 'audit.log');
58
+ }
59
+ function generateRuleId() {
60
+ return `rule-${randomBytes(4).toString('hex')}`;
61
+ }
62
+ function appendAudit(auditPath, entry) {
63
+ try {
64
+ mkdirSync(dirname(auditPath), { recursive: true });
65
+ appendFileSync(auditPath, `${JSON.stringify(entry)}\n`, 'utf-8');
66
+ }
67
+ catch {
68
+ // Audit best-effort. If filesystem is read-only or full, the deploy
69
+ // still succeeds; the operator just loses the audit trail.
70
+ }
71
+ }
72
+ function writeAtomic(targetPath, contents) {
73
+ mkdirSync(dirname(targetPath), { recursive: true });
74
+ const tmp = `${targetPath}.tmp.${process.pid}`;
75
+ writeFileSync(tmp, contents, 'utf-8');
76
+ renameSync(tmp, targetPath);
77
+ }
78
+ export function createCustomRuleStore(opts) {
79
+ const rulesPath = opts?.rulesPath ?? defaultRulesPath();
80
+ const auditPath = opts?.auditPath ?? defaultAuditPath();
81
+ // In-memory copy that mirrors the file. Writes update both.
82
+ let rules = [];
83
+ // Initial load
84
+ if (existsSync(rulesPath)) {
85
+ try {
86
+ const raw = readFileSync(rulesPath, 'utf-8');
87
+ const parsed = FileSchema.safeParse(JSON.parse(raw));
88
+ if (parsed.success) {
89
+ rules = parsed.data.rules;
90
+ }
91
+ // Malformed: leave rules empty; do NOT overwrite the file.
92
+ }
93
+ catch {
94
+ // Unreadable: leave rules empty.
95
+ }
96
+ }
97
+ function persist() {
98
+ const file = { version: 1, rules };
99
+ writeAtomic(rulesPath, JSON.stringify(file, null, 2));
100
+ }
101
+ return {
102
+ filePath: rulesPath,
103
+ auditPath,
104
+ list() {
105
+ return [...rules];
106
+ },
107
+ get(id) {
108
+ return rules.find((r) => r.id === id);
109
+ },
110
+ enabledRules() {
111
+ return rules.filter((r) => r.enabled);
112
+ },
113
+ deploy(input) {
114
+ const now = new Date().toISOString();
115
+ const id = generateRuleId();
116
+ const rule = {
117
+ id,
118
+ name: input.name,
119
+ description: input.description ?? '',
120
+ evalType: input.evalType,
121
+ severity: input.severity ?? 'medium',
122
+ definition: input.definition,
123
+ enabled: true,
124
+ createdAt: now,
125
+ updatedAt: now,
126
+ sourceMomentId: input.sourceMomentId,
127
+ version: 1,
128
+ };
129
+ // Validate before persisting.
130
+ const validated = DeployedRuleSchema.parse(rule);
131
+ rules.push(validated);
132
+ persist();
133
+ /* Tenant scoping: OSS single-tenant emits 'local'. Cloud replaces
134
+ * the entire custom-rule-store with a DB-backed service that reads
135
+ * the tenant from the authenticated session; that service will
136
+ * compute tenantId per-call. Hard-coded here for OSS. */
137
+ appendAudit(auditPath, {
138
+ ts: now,
139
+ tenantId: 'local',
140
+ action: 'rule.deploy',
141
+ user: input.user ?? 'local',
142
+ ruleId: id,
143
+ ruleName: rule.name,
144
+ details: input.sourceMomentId
145
+ ? { sourceMomentId: input.sourceMomentId, severity: rule.severity }
146
+ : { severity: rule.severity },
147
+ });
148
+ return validated;
149
+ },
150
+ delete(id, user = 'local') {
151
+ const idx = rules.findIndex((r) => r.id === id);
152
+ if (idx === -1)
153
+ return false;
154
+ const removed = rules[idx];
155
+ rules.splice(idx, 1);
156
+ persist();
157
+ appendAudit(auditPath, {
158
+ ts: new Date().toISOString(),
159
+ tenantId: 'local',
160
+ action: 'rule.delete',
161
+ user,
162
+ ruleId: id,
163
+ ruleName: removed.name,
164
+ });
165
+ return true;
166
+ },
167
+ setEnabled(id, enabled, user = 'local') {
168
+ const rule = rules.find((r) => r.id === id);
169
+ if (!rule)
170
+ return undefined;
171
+ if (rule.enabled === enabled)
172
+ return rule;
173
+ rule.enabled = enabled;
174
+ rule.updatedAt = new Date().toISOString();
175
+ persist();
176
+ appendAudit(auditPath, {
177
+ ts: rule.updatedAt,
178
+ tenantId: 'local',
179
+ action: 'rule.toggle',
180
+ user,
181
+ ruleId: id,
182
+ ruleName: rule.name,
183
+ details: { enabled },
184
+ });
185
+ return rule;
186
+ },
187
+ };
188
+ }
@@ -0,0 +1 @@
1
+ @import "https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500..700&family=Manrope:wght@400..700&family=JetBrains+Mono:wght@400..700&display=swap";:root{--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light dark;--iris-50:#f0fdfa;--iris-100:#ccfbf1;--iris-200:#99f6e4;--iris-300:#5eead4;--iris-400:#2dd4bf;--iris-500:#14b8a6;--iris-600:#0d9488;--iris-700:#0f766e;--iris-800:#115e59;--iris-900:#134e4a;--iris-950:#042f2e;--eval-pass:#22c55e;--eval-warn:#eab308;--eval-fail:#ef4444;--eval-tool:#3b82f6;--eval-llm:#a855f7;--eval-skipped:#71717a}@media (prefers-color-scheme:dark){:root{--lightningcss-light: ;--lightningcss-dark:initial}}:root,[data-theme=dark]{--bg-base:#050508;--bg-raised:#08080e;--bg-surface:#0d0d15;--bg-card:#101018;--bg-card-hover:#16161f;--border-subtle:#ffffff0d;--border-default:#ffffff14;--border-strong:#ffffff24;--border-glow:#14b8a680;--text-primary:#f0f0f5;--text-secondary:#9494a8;--text-muted:#5e5e72;--text-accent:var(--iris-400);--glow-primary:#14b8a61f;--glow-strong:#14b8a640;--shadow-sm:0 1px 2px #0000004d;--shadow-md:0 4px 6px #0006;--shadow-lg:0 10px 15px #00000080}[data-theme=light]{--bg-base:#fafcfc;--bg-raised:#f1f5f5;--bg-surface:#e8eded;--bg-card:#fff;--bg-card-hover:#f4f8f8;--border-subtle:#0000000a;--border-default:#00000014;--border-strong:#00000024;--border-glow:#0d948859;--text-primary:#0a0f0e;--text-secondary:#3d5250;--text-muted:#7a908e;--text-accent:var(--iris-700);--glow-primary:#0d94880f;--glow-strong:#0d94881f;--shadow-sm:0 1px 2px #0000000f;--shadow-md:0 4px 6px #00000014;--shadow-lg:0 10px 15px #0000001a}:root{--font-display:"Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;--font-body:"Manrope", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;--font-mono:"JetBrains Mono", "Fira Code", ui-monospace, monospace;--font-sans:var(--font-body);--text-caption-xs:11px;--text-caption:12px;--text-body-sm:13px;--text-body:14px;--text-body-lg:15px;--text-heading-sm:16px;--text-heading:20px;--text-display-sm:28px;--text-display:40px;--font-size-xs:var(--text-caption);--font-size-sm:var(--text-body-sm);--font-size-base:var(--text-body);--font-size-lg:var(--text-body-lg);--font-size-xl:var(--text-heading-sm);--font-size-2xl:var(--text-heading);--font-size-3xl:var(--text-display-sm);--leading-body:1.5;--leading-heading:1.2;--leading-display:1.1;--leading-mono:1.4;--space-0_5:2px;--space-1:4px;--space-1_5:6px;--space-2:8px;--space-2_5:10px;--space-3:12px;--space-4:16px;--space-5:20px;--space-6:24px;--space-8:32px;--space-10:40px;--space-12:48px;--space-16:64px;--space-20:80px;--space-24:96px}:root,[data-density=compact]{--density-row:32px;--density-padding:var(--space-3);--density-body:var(--text-body-sm)}[data-density=comfortable]{--density-row:44px;--density-padding:var(--space-4);--density-body:var(--text-body)}:root{--sidebar-width-expanded:256px;--sidebar-width-collapsed:64px;--header-height:56px;--page-toolbar-height:40px;--radius-xs:4px;--radius-sm:6px;--radius:8px;--radius-lg:12px;--radius-xl:16px;--radius-pill:999px;--border-radius:var(--radius);--border-radius-sm:var(--radius-xs);--border-radius-lg:var(--radius-lg);--transition-instant:.1s ease;--transition-fast:.15s ease;--transition-base:.2s ease;--transition-slow:.3s ease;--ease-iris:cubic-bezier(.25, .4, .25, 1);--bg-primary:var(--bg-base);--bg-secondary:var(--bg-raised);--bg-tertiary:var(--bg-surface);--bg-hover:var(--bg-card-hover);--border-color:var(--border-default);--accent-primary:var(--iris-500);--accent-primary-hover:var(--iris-400);--accent-success:var(--eval-pass);--accent-error:var(--eval-fail);--accent-warning:var(--eval-warn);--accent-tool:var(--eval-tool);--accent-llm:var(--eval-llm)}html{transition:background-color var(--transition-base), color var(--transition-base)}*,:before,:after{box-sizing:border-box;margin:0;padding:0}html,body,#root{width:100%;height:100%}body{font-family:var(--font-body);font-size:var(--text-body);color:var(--text-primary);background-color:var(--bg-base);line-height:var(--leading-body);-webkit-font-smoothing:antialiased;font-feature-settings:"cv11", "ss01"}h1,h2,h3,h4,.display{font-family:var(--font-display);letter-spacing:-.01em;font-weight:600}a{color:var(--text-accent);text-decoration:none}a:hover{color:var(--iris-300)}button{cursor:pointer;font-family:inherit}input,select{font-family:inherit;font-size:inherit}code,pre{font-family:var(--font-mono)}a:focus-visible,button:focus-visible,input:focus-visible,select:focus-visible,[role=button]:focus-visible{outline:2px solid var(--iris-500);outline-offset:2px;border-radius:var(--radius-xs)}::selection{background:var(--iris-600);color:#fff}::-webkit-scrollbar{width:8px;height:8px}::-webkit-scrollbar-track{background:var(--bg-base)}::-webkit-scrollbar-thumb{background:var(--border-strong);border-radius:var(--radius-xs)}::-webkit-scrollbar-thumb:hover{background:var(--text-muted)}html{scrollbar-color:var(--border-strong) transparent;scrollbar-width:thin}@keyframes pulse-ring{0%{opacity:.5;transform:scale(1)}to{opacity:0;transform:scale(2.5)}}.pulse-dot{position:relative}.pulse-dot:after{content:"";background:var(--iris-500);border-radius:50%;animation:2s ease-out infinite pulse-ring;position:absolute;inset:-2px}@media (prefers-reduced-motion:reduce){*,:before,:after{scroll-behavior:auto!important;transition-duration:.01ms!important;animation-duration:.01ms!important;animation-iteration-count:1!important}}@media (width<=767px){aside[aria-label=Main\ navigation]{width:160px}main{overflow-x:auto}}@media print{body{color:#000!important;background:#fff!important}aside[aria-label=Main\ navigation],header,[role=region][aria-label=Welcome],[role=region][aria-label=Bulk\ actions],[role=status],[role=dialog]{display:none!important}body,#root,main{height:auto!important;overflow:visible!important}main{padding:0!important}tr,pre,code{page-break-inside:avoid}h1,h2,h3{page-break-after:avoid}[aria-label*=violation],[aria-label*=spike],[aria-label*=collision],[aria-label*=Pass],[aria-label*=Fail]{border:1px solid #000!important}a{color:#000!important;text-decoration:underline!important}a[href^=http]:after{content:" (" attr(href) ")";color:#555;font-size:80%}}