@iris-eval/mcp-server 0.3.1 → 0.4.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 (100) 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/index.js +77 -14
  46. package/dist/middleware/index.d.ts +1 -0
  47. package/dist/middleware/index.js +1 -0
  48. package/dist/middleware/tenant.d.ts +17 -0
  49. package/dist/middleware/tenant.js +26 -0
  50. package/dist/otel/exporter.d.ts +24 -0
  51. package/dist/otel/exporter.js +116 -0
  52. package/dist/otel/lazy.d.ts +5 -0
  53. package/dist/otel/lazy.js +31 -0
  54. package/dist/otel/mapper.d.ts +24 -0
  55. package/dist/otel/mapper.js +208 -0
  56. package/dist/preferences.d.ts +129 -0
  57. package/dist/preferences.js +152 -0
  58. package/dist/resources/dashboard-summary.js +3 -1
  59. package/dist/resources/trace-detail.js +5 -3
  60. package/dist/server.d.ts +3 -1
  61. package/dist/server.js +9 -3
  62. package/dist/storage/migrations/004-tenant-id.d.ts +3 -0
  63. package/dist/storage/migrations/004-tenant-id.js +40 -0
  64. package/dist/storage/migrations/index.js +2 -1
  65. package/dist/storage/sqlite-adapter.d.ts +17 -15
  66. package/dist/storage/sqlite-adapter.js +130 -79
  67. package/dist/tools/delete-rule.d.ts +3 -0
  68. package/dist/tools/delete-rule.js +53 -0
  69. package/dist/tools/delete-trace.d.ts +3 -0
  70. package/dist/tools/delete-trace.js +54 -0
  71. package/dist/tools/deploy-rule.d.ts +3 -0
  72. package/dist/tools/deploy-rule.js +91 -0
  73. package/dist/tools/evaluate-output.js +23 -2
  74. package/dist/tools/evaluate-with-llm-judge.d.ts +3 -0
  75. package/dist/tools/evaluate-with-llm-judge.js +147 -0
  76. package/dist/tools/get-traces.js +23 -3
  77. package/dist/tools/index.d.ts +2 -1
  78. package/dist/tools/index.js +13 -1
  79. package/dist/tools/list-rules.d.ts +3 -0
  80. package/dist/tools/list-rules.js +66 -0
  81. package/dist/tools/log-trace.js +30 -2
  82. package/dist/tools/verify-citations.d.ts +3 -0
  83. package/dist/tools/verify-citations.js +157 -0
  84. package/dist/types/custom-rule.d.ts +70 -0
  85. package/dist/types/custom-rule.js +1 -0
  86. package/dist/types/decision-moment.d.ts +122 -0
  87. package/dist/types/decision-moment.js +17 -0
  88. package/dist/types/index.d.ts +2 -0
  89. package/dist/types/index.js +1 -1
  90. package/dist/types/query.d.ts +25 -15
  91. package/dist/types/tenant.d.ts +26 -0
  92. package/dist/types/tenant.js +58 -0
  93. package/dist/utils/open-browser.d.ts +1 -0
  94. package/dist/utils/open-browser.js +45 -0
  95. package/dist/utils/validate-port-config.d.ts +2 -0
  96. package/dist/utils/validate-port-config.js +9 -0
  97. package/package.json +4 -1
  98. package/server.json +2 -2
  99. package/dist/dashboard/assets/index-CnDg6bYi.js +0 -43
  100. package/dist/dashboard/assets/index-ZsBou2-c.css +0 -1
@@ -0,0 +1,116 @@
1
+ // OTLP/HTTP JSON exporter — posts ExportTraceServiceRequest payloads to
2
+ // an OpenTelemetry collector, Jaeger, Grafana Tempo, or Datadog OTLP
3
+ // ingest endpoint. Works against any receiver that speaks OTLP/HTTP
4
+ // JSON at /v1/traces (the OTLP spec canonical path).
5
+ //
6
+ // Hand-rolled against the OTLP spec rather than pulling in @opentelemetry/*
7
+ // — consistent with the LLM client + citation resolver approach (keep
8
+ // supply-chain surface small, the wire format is simple). gRPC transport
9
+ // is not included in v0.4.0; callers with gRPC-only receivers should
10
+ // front it with an HTTP-accepting collector (Jaeger/Tempo/OTEL Collector
11
+ // accept HTTP natively).
12
+ //
13
+ // Batch behavior: callers (typically log_trace handler) accumulate Trace
14
+ // rows and call exportTraces([...]) at convenient points. The exporter
15
+ // does NOT own a timer — keeping it stateless keeps it testable and
16
+ // avoids accidentally leaking a process-wide interval in tests.
17
+ import { buildExportPayload } from './mapper.js';
18
+ export class OtelExporter {
19
+ endpoint;
20
+ serviceName;
21
+ headers;
22
+ timeoutMs;
23
+ constructor(config) {
24
+ // Normalize endpoint — strip trailing slash; append /v1/traces if
25
+ // the caller gave a collector root. This matches the behavior of
26
+ // the official OTLP/HTTP clients.
27
+ const base = config.endpoint.replace(/\/+$/, '');
28
+ const path = config.pathPrefix ?? '/v1/traces';
29
+ this.endpoint = base.endsWith(path) ? base : base + path;
30
+ this.serviceName = config.serviceName;
31
+ this.headers = { ...(config.headers ?? {}) };
32
+ this.timeoutMs = config.timeoutMs ?? 15_000;
33
+ }
34
+ async exportTraces(traces) {
35
+ if (traces.length === 0) {
36
+ return { ok: true, status: 204, bytesSent: 0, latencyMs: 0 };
37
+ }
38
+ const payload = buildExportPayload(traces, this.serviceName);
39
+ const body = JSON.stringify(payload);
40
+ const started = Date.now();
41
+ const controller = new AbortController();
42
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
43
+ try {
44
+ const res = await fetch(this.endpoint, {
45
+ method: 'POST',
46
+ signal: controller.signal,
47
+ headers: {
48
+ 'content-type': 'application/json',
49
+ ...this.headers,
50
+ },
51
+ body,
52
+ });
53
+ const latencyMs = Date.now() - started;
54
+ if (!res.ok) {
55
+ const text = await res.text().catch(() => '');
56
+ return {
57
+ ok: false,
58
+ status: res.status,
59
+ bytesSent: body.length,
60
+ latencyMs,
61
+ error: `OTLP exporter got ${res.status}: ${text.slice(0, 200)}`,
62
+ };
63
+ }
64
+ return {
65
+ ok: true,
66
+ status: res.status,
67
+ bytesSent: body.length,
68
+ latencyMs,
69
+ };
70
+ }
71
+ catch (err) {
72
+ const latencyMs = Date.now() - started;
73
+ const msg = err instanceof Error ? err.message : String(err);
74
+ return {
75
+ ok: false,
76
+ status: 0,
77
+ bytesSent: body.length,
78
+ latencyMs,
79
+ error: msg,
80
+ };
81
+ }
82
+ finally {
83
+ clearTimeout(timer);
84
+ }
85
+ }
86
+ }
87
+ export function exporterFromEnv() {
88
+ const endpoint = process.env.IRIS_OTEL_ENDPOINT;
89
+ if (!endpoint)
90
+ return null;
91
+ const serviceName = process.env.IRIS_OTEL_SERVICE_NAME ?? 'iris-mcp';
92
+ // IRIS_OTEL_HEADERS is a comma-separated list of key=value pairs —
93
+ // the OTel Collector convention. e.g. "authorization=Bearer xyz,x-tenant=foo".
94
+ let headers;
95
+ const rawHeaders = process.env.IRIS_OTEL_HEADERS;
96
+ if (rawHeaders) {
97
+ headers = {};
98
+ for (const pair of rawHeaders.split(',')) {
99
+ const eq = pair.indexOf('=');
100
+ if (eq < 1)
101
+ continue;
102
+ const k = pair.slice(0, eq).trim();
103
+ const v = pair.slice(eq + 1).trim();
104
+ if (k && v)
105
+ headers[k] = v;
106
+ }
107
+ }
108
+ const timeoutRaw = process.env.IRIS_OTEL_TIMEOUT_MS;
109
+ const timeoutMs = timeoutRaw ? Number(timeoutRaw) : undefined;
110
+ return new OtelExporter({
111
+ endpoint,
112
+ serviceName,
113
+ headers,
114
+ timeoutMs: Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : undefined,
115
+ });
116
+ }
@@ -0,0 +1,5 @@
1
+ import { OtelExporter } from './exporter.js';
2
+ import type { Trace } from '../types/trace.js';
3
+ export declare function getLazyExporter(): OtelExporter | null;
4
+ export declare function __resetExporterForTests(override?: OtelExporter | null): void;
5
+ export declare function bestEffortExport(trace: Trace, onError?: (err: Error) => void): void;
@@ -0,0 +1,31 @@
1
+ // Process-wide lazy exporter — initialized on first use, then reused.
2
+ // Tests may reset it via __resetExporterForTests.
3
+ import { exporterFromEnv } from './exporter.js';
4
+ let cachedExporter; // undefined = not yet resolved
5
+ export function getLazyExporter() {
6
+ if (cachedExporter === undefined) {
7
+ cachedExporter = exporterFromEnv();
8
+ }
9
+ return cachedExporter;
10
+ }
11
+ export function __resetExporterForTests(override) {
12
+ cachedExporter = override === undefined ? undefined : override;
13
+ }
14
+ // Fire-and-forget wrapper with inline error swallow. Runs in the
15
+ // background; the caller does NOT await. We log on failure so operators
16
+ // aren't surprised by silent drop, but never let an exporter problem
17
+ // bubble into user-visible tool errors.
18
+ export function bestEffortExport(trace, onError) {
19
+ const exporter = getLazyExporter();
20
+ if (!exporter)
21
+ return;
22
+ exporter
23
+ .exportTraces([trace])
24
+ .then((result) => {
25
+ if (!result.ok) {
26
+ const msg = `OTel export failed: status=${result.status} ${result.error ?? ''}`.trim();
27
+ onError?.(new Error(msg));
28
+ }
29
+ })
30
+ .catch((err) => onError?.(err));
31
+ }
@@ -0,0 +1,24 @@
1
+ import type { Span, Trace } from '../types/trace.js';
2
+ export interface OtlpKeyValue {
3
+ key: string;
4
+ value: OtlpAnyValue;
5
+ }
6
+ export type OtlpAnyValue = {
7
+ stringValue: string;
8
+ } | {
9
+ boolValue: boolean;
10
+ } | {
11
+ intValue: string;
12
+ } | {
13
+ doubleValue: number;
14
+ } | {
15
+ arrayValue: {
16
+ values: OtlpAnyValue[];
17
+ };
18
+ } | {
19
+ kvlistValue: {
20
+ values: OtlpKeyValue[];
21
+ };
22
+ };
23
+ export declare function mapSpan(span: Span, traceIdOverride?: string): unknown;
24
+ export declare function buildExportPayload(traces: readonly Trace[], serviceName: string): unknown;
@@ -0,0 +1,208 @@
1
+ // Map Iris trace/span types to OTLP JSON shape for export.
2
+ //
3
+ // Reference: https://opentelemetry.io/docs/specs/otlp/#otlphttp-json-encoding
4
+ // and the ExportTraceServiceRequest proto. OTLP JSON encodes uint64 IDs
5
+ // as lowercase hex strings; trace_id is 16 bytes (32 hex chars), span_id
6
+ // is 8 bytes (16 hex chars). Timestamps are nanoseconds since epoch as
7
+ // strings (uint64 range exceeds JS Number.MAX_SAFE_INTEGER so we emit as
8
+ // decimal string).
9
+ //
10
+ // We intentionally do not depend on @opentelemetry/* — the exporter
11
+ // serializes these plain objects, and the payload shape is the OTLP spec.
12
+ // OTel SpanKind enum values (from opentelemetry-proto/trace/v1/trace.proto).
13
+ const KIND_MAP = {
14
+ INTERNAL: 1,
15
+ SERVER: 2,
16
+ CLIENT: 3,
17
+ PRODUCER: 4,
18
+ CONSUMER: 5,
19
+ // Iris-specific kinds — map to INTERNAL for OTel. The actual LLM /
20
+ // tool semantics are preserved in attributes (llm.provider, tool.name).
21
+ LLM: 1,
22
+ TOOL: 1,
23
+ };
24
+ const STATUS_MAP = {
25
+ UNSET: 0,
26
+ OK: 1,
27
+ ERROR: 2,
28
+ };
29
+ // Convert "abcdef0123..." (any length hex) to exactly 32 hex chars, or
30
+ // if non-hex, hash it. Iris sometimes generates IDs like "mcp-<uuid>"
31
+ // that aren't pure hex.
32
+ function toTraceIdHex(raw) {
33
+ const hex = raw.replace(/-/g, '').toLowerCase();
34
+ if (/^[0-9a-f]{32}$/.test(hex))
35
+ return hex;
36
+ if (/^[0-9a-f]{16,}$/.test(hex))
37
+ return hex.slice(0, 32).padEnd(32, '0');
38
+ // Fallback: hash to 32 hex chars deterministically.
39
+ return djb2Hex(raw, 32);
40
+ }
41
+ function toSpanIdHex(raw) {
42
+ const hex = raw.replace(/-/g, '').toLowerCase();
43
+ if (/^[0-9a-f]{16}$/.test(hex))
44
+ return hex;
45
+ if (/^[0-9a-f]{8,}$/.test(hex))
46
+ return hex.slice(0, 16).padEnd(16, '0');
47
+ return djb2Hex(raw, 16);
48
+ }
49
+ // Tiny deterministic string → hex hash. Not cryptographic — just to give
50
+ // OTel consumers a stable id when Iris produced a non-hex identifier.
51
+ function djb2Hex(s, hexLen) {
52
+ let h = 5381n;
53
+ for (let i = 0; i < s.length; i++) {
54
+ h = ((h << 5n) + h + BigInt(s.charCodeAt(i))) & ((1n << 64n) - 1n);
55
+ }
56
+ // Use BigInt to avoid JS Number collisions on long strings.
57
+ let hex = h.toString(16);
58
+ while (hex.length < hexLen)
59
+ hex = hex + djb2Step(s + hex).toString(16);
60
+ return hex.slice(0, hexLen).padEnd(hexLen, '0');
61
+ }
62
+ function djb2Step(s) {
63
+ let h = 5381n;
64
+ for (let i = 0; i < s.length; i++) {
65
+ h = ((h << 5n) + h + BigInt(s.charCodeAt(i))) & ((1n << 64n) - 1n);
66
+ }
67
+ return h;
68
+ }
69
+ function toNanoString(iso) {
70
+ if (!iso)
71
+ return '0';
72
+ const ms = new Date(iso).getTime();
73
+ if (!Number.isFinite(ms))
74
+ return '0';
75
+ // ms → ns as string (BigInt-safe).
76
+ return (BigInt(ms) * 1000000n).toString();
77
+ }
78
+ function toAnyValue(v) {
79
+ if (typeof v === 'string')
80
+ return { stringValue: v };
81
+ if (typeof v === 'boolean')
82
+ return { boolValue: v };
83
+ if (typeof v === 'number') {
84
+ if (Number.isInteger(v))
85
+ return { intValue: String(v) };
86
+ return { doubleValue: v };
87
+ }
88
+ if (Array.isArray(v)) {
89
+ return { arrayValue: { values: v.map(toAnyValue) } };
90
+ }
91
+ if (v && typeof v === 'object') {
92
+ return {
93
+ kvlistValue: {
94
+ values: Object.entries(v).map(([k, val]) => ({
95
+ key: k,
96
+ value: toAnyValue(val),
97
+ })),
98
+ },
99
+ };
100
+ }
101
+ // null / undefined / bigint / fn / symbol — fall back to string
102
+ return { stringValue: String(v) };
103
+ }
104
+ function flattenAttrs(attrs) {
105
+ if (!attrs)
106
+ return [];
107
+ return Object.entries(attrs).map(([k, v]) => ({ key: k, value: toAnyValue(v) }));
108
+ }
109
+ // Iris Span → OTLP Span JSON shape.
110
+ export function mapSpan(span, traceIdOverride) {
111
+ const attributes = flattenAttrs(span.attributes);
112
+ // Surface Iris-specific kinds via attributes so OTel consumers can
113
+ // filter on iris.span_kind even though we mapped everything non-OTel to INTERNAL.
114
+ if (span.kind === 'LLM' || span.kind === 'TOOL') {
115
+ attributes.push({ key: 'iris.span_kind', value: { stringValue: span.kind } });
116
+ }
117
+ const events = (span.events ?? []).map((e) => ({
118
+ timeUnixNano: toNanoString(e.timestamp),
119
+ name: e.name,
120
+ attributes: flattenAttrs(e.attributes),
121
+ }));
122
+ return {
123
+ traceId: toTraceIdHex(traceIdOverride ?? span.trace_id),
124
+ spanId: toSpanIdHex(span.span_id),
125
+ parentSpanId: span.parent_span_id ? toSpanIdHex(span.parent_span_id) : undefined,
126
+ name: span.name,
127
+ kind: KIND_MAP[span.kind] ?? 1,
128
+ startTimeUnixNano: toNanoString(span.start_time),
129
+ endTimeUnixNano: toNanoString(span.end_time ?? span.start_time),
130
+ attributes,
131
+ events,
132
+ status: {
133
+ code: STATUS_MAP[span.status_code] ?? 0,
134
+ message: span.status_message,
135
+ },
136
+ };
137
+ }
138
+ // Build a full OTLP ExportTraceServiceRequest payload from an Iris trace.
139
+ // If the trace has no explicit spans, we synthesize one root span from
140
+ // the trace-level fields (agent_name + latency + cost + token_usage as
141
+ // attributes) so exports aren't empty for simple trace rows.
142
+ export function buildExportPayload(traces, serviceName) {
143
+ const resource = {
144
+ attributes: [
145
+ { key: 'service.name', value: { stringValue: serviceName } },
146
+ { key: 'telemetry.sdk.name', value: { stringValue: 'iris-mcp' } },
147
+ { key: 'telemetry.sdk.language', value: { stringValue: 'nodejs' } },
148
+ { key: 'telemetry.sdk.version', value: { stringValue: '0.4.0' } },
149
+ ],
150
+ };
151
+ const spans = [];
152
+ for (const trace of traces) {
153
+ if (trace.spans && trace.spans.length > 0) {
154
+ for (const s of trace.spans)
155
+ spans.push(mapSpan(s, trace.trace_id));
156
+ }
157
+ else {
158
+ // Synthesize root span from trace-level info.
159
+ const attrs = {
160
+ 'iris.agent_name': trace.agent_name,
161
+ 'iris.framework': trace.framework,
162
+ };
163
+ if (trace.input)
164
+ attrs['iris.input'] = truncate(trace.input);
165
+ if (trace.output)
166
+ attrs['iris.output'] = truncate(trace.output);
167
+ if (trace.cost_usd !== undefined)
168
+ attrs['iris.cost_usd'] = trace.cost_usd;
169
+ if (trace.token_usage?.total_tokens !== undefined)
170
+ attrs['iris.total_tokens'] = trace.token_usage.total_tokens;
171
+ if (trace.token_usage?.prompt_tokens !== undefined)
172
+ attrs['iris.prompt_tokens'] = trace.token_usage.prompt_tokens;
173
+ if (trace.token_usage?.completion_tokens !== undefined)
174
+ attrs['iris.completion_tokens'] = trace.token_usage.completion_tokens;
175
+ const end = trace.latency_ms
176
+ ? new Date(new Date(trace.timestamp).getTime() + trace.latency_ms).toISOString()
177
+ : trace.timestamp;
178
+ spans.push({
179
+ traceId: toTraceIdHex(trace.trace_id),
180
+ spanId: toSpanIdHex(trace.trace_id + ':root'),
181
+ parentSpanId: undefined,
182
+ name: trace.agent_name || 'agent_execution',
183
+ kind: 1,
184
+ startTimeUnixNano: toNanoString(trace.timestamp),
185
+ endTimeUnixNano: toNanoString(end),
186
+ attributes: flattenAttrs(attrs),
187
+ events: [],
188
+ status: { code: 0 },
189
+ });
190
+ }
191
+ }
192
+ return {
193
+ resourceSpans: [
194
+ {
195
+ resource,
196
+ scopeSpans: [
197
+ {
198
+ scope: { name: 'iris.trace.v1' },
199
+ spans,
200
+ },
201
+ ],
202
+ },
203
+ ],
204
+ };
205
+ }
206
+ function truncate(s, max = 4096) {
207
+ return s.length > max ? s.slice(0, max) + '…' : s;
208
+ }
@@ -0,0 +1,129 @@
1
+ import { z } from 'zod';
2
+ declare const MomentFiltersSchema: z.ZodObject<{
3
+ agentName: z.ZodOptional<z.ZodString>;
4
+ verdict: z.ZodOptional<z.ZodEnum<["pass", "fail", "partial", "unevaluated"]>>;
5
+ significanceKind: z.ZodOptional<z.ZodEnum<["safety-violation", "cost-spike", "first-failure", "novel-pattern", "rule-collision", "normal-pass", "normal-fail"]>>;
6
+ }, "strict", z.ZodTypeAny, {
7
+ verdict?: "pass" | "fail" | "partial" | "unevaluated" | undefined;
8
+ agentName?: string | undefined;
9
+ significanceKind?: "safety-violation" | "cost-spike" | "first-failure" | "novel-pattern" | "rule-collision" | "normal-pass" | "normal-fail" | undefined;
10
+ }, {
11
+ verdict?: "pass" | "fail" | "partial" | "unevaluated" | undefined;
12
+ agentName?: string | undefined;
13
+ significanceKind?: "safety-violation" | "cost-spike" | "first-failure" | "novel-pattern" | "rule-collision" | "normal-pass" | "normal-fail" | undefined;
14
+ }>;
15
+ export declare const PreferencesSchema: z.ZodObject<{
16
+ autoLaunch: z.ZodDefault<z.ZodBoolean>;
17
+ firstSeen: z.ZodOptional<z.ZodString>;
18
+ dismissedBanners: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
19
+ /** Theme override; "system" defers to prefers-color-scheme. v0.4. */
20
+ theme: z.ZodDefault<z.ZodEnum<["dark", "light", "system"]>>;
21
+ /** Last filter set used on /moments — applied on next visit when the URL has no filter params. v0.4. */
22
+ momentFilters: z.ZodDefault<z.ZodObject<{
23
+ agentName: z.ZodOptional<z.ZodString>;
24
+ verdict: z.ZodOptional<z.ZodEnum<["pass", "fail", "partial", "unevaluated"]>>;
25
+ significanceKind: z.ZodOptional<z.ZodEnum<["safety-violation", "cost-spike", "first-failure", "novel-pattern", "rule-collision", "normal-pass", "normal-fail"]>>;
26
+ }, "strict", z.ZodTypeAny, {
27
+ verdict?: "pass" | "fail" | "partial" | "unevaluated" | undefined;
28
+ agentName?: string | undefined;
29
+ significanceKind?: "safety-violation" | "cost-spike" | "first-failure" | "novel-pattern" | "rule-collision" | "normal-pass" | "normal-fail" | undefined;
30
+ }, {
31
+ verdict?: "pass" | "fail" | "partial" | "unevaluated" | undefined;
32
+ agentName?: string | undefined;
33
+ significanceKind?: "safety-violation" | "cost-spike" | "first-failure" | "novel-pattern" | "rule-collision" | "normal-pass" | "normal-fail" | undefined;
34
+ }>>;
35
+ /** Tour ids the user has completed or dismissed. v0.4. */
36
+ dismissedTours: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
37
+ /** Decision Moments hidden from the timeline by user action. v0.4. */
38
+ archivedMoments: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
39
+ /** Density mode for chrome (Design System v2.A). 'compact' default per R2.3. */
40
+ density: z.ZodDefault<z.ZodEnum<["compact", "comfortable"]>>;
41
+ /** Sidebar collapsed (icon-only at 64px) vs expanded (256px). Default expanded per R2.4. */
42
+ sidebarCollapsed: z.ZodDefault<z.ZodBoolean>;
43
+ /** ISO timestamp of last notifications-popover opened — drives unread badge. */
44
+ notificationsLastSeen: z.ZodOptional<z.ZodString>;
45
+ }, "passthrough", z.ZodTypeAny, z.objectOutputType<{
46
+ autoLaunch: z.ZodDefault<z.ZodBoolean>;
47
+ firstSeen: z.ZodOptional<z.ZodString>;
48
+ dismissedBanners: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
49
+ /** Theme override; "system" defers to prefers-color-scheme. v0.4. */
50
+ theme: z.ZodDefault<z.ZodEnum<["dark", "light", "system"]>>;
51
+ /** Last filter set used on /moments — applied on next visit when the URL has no filter params. v0.4. */
52
+ momentFilters: z.ZodDefault<z.ZodObject<{
53
+ agentName: z.ZodOptional<z.ZodString>;
54
+ verdict: z.ZodOptional<z.ZodEnum<["pass", "fail", "partial", "unevaluated"]>>;
55
+ significanceKind: z.ZodOptional<z.ZodEnum<["safety-violation", "cost-spike", "first-failure", "novel-pattern", "rule-collision", "normal-pass", "normal-fail"]>>;
56
+ }, "strict", z.ZodTypeAny, {
57
+ verdict?: "pass" | "fail" | "partial" | "unevaluated" | undefined;
58
+ agentName?: string | undefined;
59
+ significanceKind?: "safety-violation" | "cost-spike" | "first-failure" | "novel-pattern" | "rule-collision" | "normal-pass" | "normal-fail" | undefined;
60
+ }, {
61
+ verdict?: "pass" | "fail" | "partial" | "unevaluated" | undefined;
62
+ agentName?: string | undefined;
63
+ significanceKind?: "safety-violation" | "cost-spike" | "first-failure" | "novel-pattern" | "rule-collision" | "normal-pass" | "normal-fail" | undefined;
64
+ }>>;
65
+ /** Tour ids the user has completed or dismissed. v0.4. */
66
+ dismissedTours: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
67
+ /** Decision Moments hidden from the timeline by user action. v0.4. */
68
+ archivedMoments: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
69
+ /** Density mode for chrome (Design System v2.A). 'compact' default per R2.3. */
70
+ density: z.ZodDefault<z.ZodEnum<["compact", "comfortable"]>>;
71
+ /** Sidebar collapsed (icon-only at 64px) vs expanded (256px). Default expanded per R2.4. */
72
+ sidebarCollapsed: z.ZodDefault<z.ZodBoolean>;
73
+ /** ISO timestamp of last notifications-popover opened — drives unread badge. */
74
+ notificationsLastSeen: z.ZodOptional<z.ZodString>;
75
+ }, z.ZodTypeAny, "passthrough">, z.objectInputType<{
76
+ autoLaunch: z.ZodDefault<z.ZodBoolean>;
77
+ firstSeen: z.ZodOptional<z.ZodString>;
78
+ dismissedBanners: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
79
+ /** Theme override; "system" defers to prefers-color-scheme. v0.4. */
80
+ theme: z.ZodDefault<z.ZodEnum<["dark", "light", "system"]>>;
81
+ /** Last filter set used on /moments — applied on next visit when the URL has no filter params. v0.4. */
82
+ momentFilters: z.ZodDefault<z.ZodObject<{
83
+ agentName: z.ZodOptional<z.ZodString>;
84
+ verdict: z.ZodOptional<z.ZodEnum<["pass", "fail", "partial", "unevaluated"]>>;
85
+ significanceKind: z.ZodOptional<z.ZodEnum<["safety-violation", "cost-spike", "first-failure", "novel-pattern", "rule-collision", "normal-pass", "normal-fail"]>>;
86
+ }, "strict", z.ZodTypeAny, {
87
+ verdict?: "pass" | "fail" | "partial" | "unevaluated" | undefined;
88
+ agentName?: string | undefined;
89
+ significanceKind?: "safety-violation" | "cost-spike" | "first-failure" | "novel-pattern" | "rule-collision" | "normal-pass" | "normal-fail" | undefined;
90
+ }, {
91
+ verdict?: "pass" | "fail" | "partial" | "unevaluated" | undefined;
92
+ agentName?: string | undefined;
93
+ significanceKind?: "safety-violation" | "cost-spike" | "first-failure" | "novel-pattern" | "rule-collision" | "normal-pass" | "normal-fail" | undefined;
94
+ }>>;
95
+ /** Tour ids the user has completed or dismissed. v0.4. */
96
+ dismissedTours: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
97
+ /** Decision Moments hidden from the timeline by user action. v0.4. */
98
+ archivedMoments: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
99
+ /** Density mode for chrome (Design System v2.A). 'compact' default per R2.3. */
100
+ density: z.ZodDefault<z.ZodEnum<["compact", "comfortable"]>>;
101
+ /** Sidebar collapsed (icon-only at 64px) vs expanded (256px). Default expanded per R2.4. */
102
+ sidebarCollapsed: z.ZodDefault<z.ZodBoolean>;
103
+ /** ISO timestamp of last notifications-popover opened — drives unread badge. */
104
+ notificationsLastSeen: z.ZodOptional<z.ZodString>;
105
+ }, z.ZodTypeAny, "passthrough">>;
106
+ export type Preferences = z.infer<typeof PreferencesSchema>;
107
+ export type MomentFilters = z.infer<typeof MomentFiltersSchema>;
108
+ export interface PreferenceState {
109
+ /** True if `~/.iris/preferences.json` did not exist when iris-mcp started. */
110
+ isFirstRun: boolean;
111
+ /** Resolved preferences (defaulted + validated). */
112
+ preferences: Preferences;
113
+ /** Absolute path to the preferences file. */
114
+ path: string;
115
+ }
116
+ export declare function loadOrInitPreferences(customPath?: string): PreferenceState;
117
+ export declare function shouldAutoLaunchDashboard(state: PreferenceState): boolean;
118
+ export interface PreferenceStore {
119
+ readonly path: string;
120
+ /** Current preferences (always defaulted + validated). */
121
+ read(): Preferences;
122
+ /**
123
+ * Apply a partial update + persist atomically. Returns the merged result.
124
+ * Throws if the merge fails Zod validation (e.g., illegal verdict value).
125
+ */
126
+ patch(input: Partial<Preferences>): Preferences;
127
+ }
128
+ export declare function createPreferenceStore(customPath?: string): PreferenceStore;
129
+ export {};
@@ -0,0 +1,152 @@
1
+ /*
2
+ * preferences — first-run + per-user dashboard preferences for iris-mcp.
3
+ *
4
+ * State lives at ~/.iris/preferences.json. The file is created on first
5
+ * dashboard launch with `autoLaunch: true` and `firstSeen` set to the
6
+ * current ISO timestamp.
7
+ *
8
+ * Subsequent runs read the file and respect the user's autoLaunch setting.
9
+ * The env var IRIS_NO_AUTO_LAUNCH=1 overrides preferences and disables
10
+ * auto-launch unconditionally (useful for CI, headless servers, container
11
+ * runs, and remote SSH sessions).
12
+ *
13
+ * The file format is intentionally small — additional preferences will be
14
+ * added as the dashboard grows; the schema validates known keys but
15
+ * tolerates unknown ones forward-compatibly.
16
+ *
17
+ * v0.4 expansions (B8.2): theme, momentFilters (last filter set used on
18
+ * /moments), dismissedTours, archivedMoments. These let the dashboard
19
+ * remember the user's last view across reloads + across iris-mcp restarts.
20
+ */
21
+ import { mkdirSync, readFileSync, writeFileSync, existsSync, renameSync } from 'node:fs';
22
+ import { join, dirname } from 'node:path';
23
+ import { homedir } from 'node:os';
24
+ import { z } from 'zod';
25
+ const MomentFiltersSchema = z
26
+ .object({
27
+ agentName: z.string().max(200).optional(),
28
+ verdict: z.enum(['pass', 'fail', 'partial', 'unevaluated']).optional(),
29
+ significanceKind: z
30
+ .enum([
31
+ 'safety-violation',
32
+ 'cost-spike',
33
+ 'first-failure',
34
+ 'novel-pattern',
35
+ 'rule-collision',
36
+ 'normal-pass',
37
+ 'normal-fail',
38
+ ])
39
+ .optional(),
40
+ })
41
+ .strict();
42
+ export const PreferencesSchema = z
43
+ .object({
44
+ autoLaunch: z.boolean().default(true),
45
+ firstSeen: z.string().datetime({ offset: true }).optional(),
46
+ dismissedBanners: z.array(z.string().max(80)).default([]),
47
+ /** Theme override; "system" defers to prefers-color-scheme. v0.4. */
48
+ theme: z.enum(['dark', 'light', 'system']).default('system'),
49
+ /** Last filter set used on /moments — applied on next visit when the URL has no filter params. v0.4. */
50
+ momentFilters: MomentFiltersSchema.default({}),
51
+ /** Tour ids the user has completed or dismissed. v0.4. */
52
+ dismissedTours: z.array(z.string().max(80)).default([]),
53
+ /** Decision Moments hidden from the timeline by user action. v0.4. */
54
+ archivedMoments: z.array(z.string().max(200)).default([]),
55
+ /** Density mode for chrome (Design System v2.A). 'compact' default per R2.3. */
56
+ density: z.enum(['compact', 'comfortable']).default('compact'),
57
+ /** Sidebar collapsed (icon-only at 64px) vs expanded (256px). Default expanded per R2.4. */
58
+ sidebarCollapsed: z.boolean().default(false),
59
+ /** ISO timestamp of last notifications-popover opened — drives unread badge. */
60
+ notificationsLastSeen: z.string().datetime({ offset: true }).optional(),
61
+ })
62
+ .passthrough();
63
+ function defaultPreferencesPath() {
64
+ return join(homedir(), '.iris', 'preferences.json');
65
+ }
66
+ function freshPreferences() {
67
+ return PreferencesSchema.parse({
68
+ autoLaunch: true,
69
+ firstSeen: new Date().toISOString(),
70
+ dismissedBanners: [],
71
+ theme: 'system',
72
+ momentFilters: {},
73
+ dismissedTours: [],
74
+ archivedMoments: [],
75
+ });
76
+ }
77
+ function writeAtomic(targetPath, contents) {
78
+ mkdirSync(dirname(targetPath), { recursive: true });
79
+ const tmp = `${targetPath}.tmp.${process.pid}`;
80
+ writeFileSync(tmp, contents, 'utf-8');
81
+ renameSync(tmp, targetPath);
82
+ }
83
+ export function loadOrInitPreferences(customPath) {
84
+ const path = customPath ?? defaultPreferencesPath();
85
+ if (!existsSync(path)) {
86
+ const fresh = freshPreferences();
87
+ try {
88
+ writeAtomic(path, JSON.stringify(fresh, null, 2));
89
+ }
90
+ catch {
91
+ // Filesystem may be read-only (containerized run); we still proceed
92
+ // with the defaults in memory but isFirstRun stays true so callers
93
+ // can decide whether to auto-launch anyway.
94
+ }
95
+ return { isFirstRun: true, preferences: fresh, path };
96
+ }
97
+ try {
98
+ const raw = readFileSync(path, 'utf-8');
99
+ const parsed = PreferencesSchema.safeParse(JSON.parse(raw));
100
+ if (parsed.success) {
101
+ return { isFirstRun: false, preferences: parsed.data, path };
102
+ }
103
+ // Malformed file — fall back to defaults but DON'T overwrite the
104
+ // existing file. The user may want to fix it manually.
105
+ return {
106
+ isFirstRun: false,
107
+ preferences: PreferencesSchema.parse({}),
108
+ path,
109
+ };
110
+ }
111
+ catch {
112
+ return {
113
+ isFirstRun: false,
114
+ preferences: PreferencesSchema.parse({}),
115
+ path,
116
+ };
117
+ }
118
+ }
119
+ export function shouldAutoLaunchDashboard(state) {
120
+ if (process.env.IRIS_NO_AUTO_LAUNCH === '1')
121
+ return false;
122
+ if (process.env.CI === 'true' || process.env.CI === '1')
123
+ return false;
124
+ return state.preferences.autoLaunch !== false;
125
+ }
126
+ export function createPreferenceStore(customPath) {
127
+ const path = customPath ?? defaultPreferencesPath();
128
+ let current = loadOrInitPreferences(path).preferences;
129
+ return {
130
+ path,
131
+ read() {
132
+ return current;
133
+ },
134
+ patch(input) {
135
+ const merged = { ...current, ...input };
136
+ // Re-parse so default coalescing + validation runs, and so passthrough
137
+ // forward-compat preserves any unknown keys present in the file.
138
+ const validated = PreferencesSchema.parse(merged);
139
+ try {
140
+ writeAtomic(path, JSON.stringify(validated, null, 2));
141
+ }
142
+ catch (err) {
143
+ // If persist fails (read-only fs, disk full), still update memory
144
+ // so the UI feels responsive — but rethrow so the API surfaces it.
145
+ current = validated;
146
+ throw err;
147
+ }
148
+ current = validated;
149
+ return validated;
150
+ },
151
+ };
152
+ }