@crossworks/client-types 0.230.43

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.
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Query-param parsing for `GET /api/search` — the owner-facing HTTP twin of
3
+ * the `search_nodes` / `search_chunks` MCP tools (mobile companion's search).
4
+ * Pure so it can be unit-tested without the route runtime.
5
+ */
6
+
7
+ /** Mirrors the `search_nodes` tool's `type` enum — the API's contract. */
8
+ export const SEARCH_NODE_TYPES = [
9
+ 'branch',
10
+ 'email',
11
+ 'email_thread',
12
+ 'file',
13
+ 'note',
14
+ 'page',
15
+ 'sermon',
16
+ 'contact',
17
+ 'task',
18
+ 'event',
19
+ 'printer_project',
20
+ 'telegram_message',
21
+ 'documentation',
22
+ 'journal',
23
+ 'formula',
24
+ 'draw',
25
+ ] as const;
26
+
27
+ export type SearchNodeType = (typeof SEARCH_NODE_TYPES)[number];
28
+
29
+ export type SearchApiQuery = {
30
+ q: string;
31
+ mode: 'nodes' | 'chunks';
32
+ type?: SearchNodeType;
33
+ branch?: string;
34
+ tags?: string[];
35
+ limit: number;
36
+ };
37
+
38
+ const MAX_Q = 500;
39
+ const MAX_TAGS = 10;
40
+ const DEFAULT_LIMIT = 20;
41
+ const MAX_LIMIT = 50;
42
+ // `branch` is cast to ::ltree server-side; reject anything that would make
43
+ // the cast throw instead of surfacing a 500 — including over-long labels
44
+ // (Postgres caps ltree labels at 255 chars).
45
+ const LTREE_RE = /^[a-z0-9_]+(\.[a-z0-9_]+)*$/i;
46
+ const MAX_BRANCH = 500;
47
+ const MAX_LTREE_LABEL = 255;
48
+
49
+ export function parseSearchQuery(sp: URLSearchParams): SearchApiQuery | { error: string } {
50
+ const q = (sp.get('q') ?? '').trim();
51
+ if (!q) return { error: 'q required' };
52
+ if (q.length > MAX_Q) return { error: `q too long (max ${MAX_Q})` };
53
+
54
+ const modeRaw = sp.get('mode') ?? 'nodes';
55
+ if (modeRaw !== 'nodes' && modeRaw !== 'chunks') {
56
+ return { error: "mode must be 'nodes' or 'chunks'" };
57
+ }
58
+
59
+ let type: SearchNodeType | undefined;
60
+ const typeRaw = sp.get('type')?.trim();
61
+ if (typeRaw) {
62
+ if (!(SEARCH_NODE_TYPES as readonly string[]).includes(typeRaw)) {
63
+ return { error: `unknown type '${typeRaw}'` };
64
+ }
65
+ type = typeRaw as SearchNodeType;
66
+ }
67
+
68
+ let branch: string | undefined;
69
+ const branchRaw = sp.get('branch')?.trim();
70
+ if (branchRaw) {
71
+ if (
72
+ branchRaw.length > MAX_BRANCH ||
73
+ !LTREE_RE.test(branchRaw) ||
74
+ branchRaw.split('.').some((label) => label.length > MAX_LTREE_LABEL)
75
+ ) {
76
+ return { error: 'invalid branch' };
77
+ }
78
+ branch = branchRaw;
79
+ }
80
+
81
+ let tags: string[] | undefined;
82
+ const tagsRaw = sp.get('tags')?.trim();
83
+ if (tagsRaw) {
84
+ tags = tagsRaw
85
+ .split(',')
86
+ .map((t) => t.trim())
87
+ .filter(Boolean)
88
+ .slice(0, MAX_TAGS);
89
+ if (tags.length === 0) tags = undefined;
90
+ }
91
+
92
+ let limit = DEFAULT_LIMIT;
93
+ const limitRaw = sp.get('limit');
94
+ if (limitRaw != null && limitRaw !== '') {
95
+ // Number(), not parseInt: '1e3' must clamp to 50, not truncate to 1;
96
+ // fractional/garbage input is a 400, not a silent guess.
97
+ const n = Number(limitRaw);
98
+ if (!Number.isInteger(n) || n < 1) return { error: 'invalid limit' };
99
+ limit = Math.min(n, MAX_LIMIT);
100
+ }
101
+
102
+ return { q, mode: modeRaw, type, branch, tags, limit };
103
+ }
@@ -0,0 +1,159 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { slugify } from './slugify';
3
+
4
+ // ── Legacy implementations, verbatim from the pre-centralisation call sites ──
5
+ // The centralised `slugify(input, opts)` must reproduce each of these byte-for-
6
+ // byte for every representative input, because their outputs are (or seed)
7
+ // stored identity slugs. If one of these ever needs to change, that is a
8
+ // migration, not a refactor — this file is the tripwire.
9
+
10
+ // agents-client.tsx / (docs seeds this without the cap)
11
+ const legacyNoUnderscore64 = (s: string) =>
12
+ s
13
+ .toLowerCase()
14
+ .replace(/[^a-z0-9]+/g, '-')
15
+ .replace(/^-+|-+$/g, '')
16
+ .slice(0, 64);
17
+
18
+ // docs/new-collection-dialog.tsx (no cap, has a redundant .trim())
19
+ const legacyDocs = (s: string) =>
20
+ s
21
+ .toLowerCase()
22
+ .trim()
23
+ .replace(/[^a-z0-9]+/g, '-')
24
+ .replace(/^-+|-+$/g, '');
25
+
26
+ // skills / heartbeats / tool-groups / tools
27
+ const legacyAllowUnderscore64 = (s: string) =>
28
+ s
29
+ .toLowerCase()
30
+ .replace(/[^a-z0-9_-]+/g, '-')
31
+ .replace(/^-+|-+$/g, '')
32
+ .slice(0, 64);
33
+
34
+ // dev-tools/save-tool-dialog.tsx (underscore separator)
35
+ const legacySaveTool = (s: string) =>
36
+ s
37
+ .toLowerCase()
38
+ .replace(/[^a-z0-9_-]+/g, '_')
39
+ .replace(/^_+|_+$/g, '')
40
+ .slice(0, 64);
41
+
42
+ // api/export/[id]/route.ts (cap 80, 'export' fallback)
43
+ const legacyExport = (title: string) => {
44
+ const slug = title
45
+ .toLowerCase()
46
+ .replace(/[^a-z0-9]+/g, '-')
47
+ .replace(/^-+|-+$/g, '')
48
+ .slice(0, 80);
49
+ return slug || 'export';
50
+ };
51
+
52
+ // lib/ai-workers.ts (cap 60)
53
+ const legacyAiWorker = (name: string) =>
54
+ name
55
+ .toLowerCase()
56
+ .replace(/[^a-z0-9]+/g, '-')
57
+ .replace(/(^-+|-+$)/g, '')
58
+ .slice(0, 60);
59
+
60
+ const INPUTS = [
61
+ '',
62
+ ' ',
63
+ 'Hello World',
64
+ 'Hello World',
65
+ ' leading and trailing ',
66
+ 'already-a-slug',
67
+ 'snake_case_name',
68
+ 'mixed_-_separators',
69
+ 'UPPER.case/Punct!uation?',
70
+ 'Q1/Q2 2026 report',
71
+ 'dots.and-dashes_and spaces',
72
+ '---leading-dashes---',
73
+ '___leading_underscores___',
74
+ 'web_search',
75
+ 'a'.repeat(200),
76
+ 'x_'.repeat(50),
77
+ 'café résumé',
78
+ '你好 world',
79
+ '!!!',
80
+ '- _ - _',
81
+ 'tab\tand\nnewline',
82
+ '123 numbers 456',
83
+ ];
84
+
85
+ describe('slugify — byte-identical to every legacy call-site behaviour', () => {
86
+ it('matches the no-underscore / cap-64 profile (agents)', () => {
87
+ for (const s of INPUTS) {
88
+ expect(slugify(s, { maxLength: 64 })).toBe(legacyNoUnderscore64(s));
89
+ }
90
+ });
91
+
92
+ it('matches the docs profile (no cap, canonical default)', () => {
93
+ for (const s of INPUTS) {
94
+ expect(slugify(s)).toBe(legacyDocs(s));
95
+ }
96
+ });
97
+
98
+ it('matches the allow-underscore / cap-64 profile (skills, heartbeats, tool-groups, tools)', () => {
99
+ for (const s of INPUTS) {
100
+ expect(slugify(s, { allowUnderscore: true, maxLength: 64 })).toBe(legacyAllowUnderscore64(s));
101
+ }
102
+ });
103
+
104
+ it('matches the underscore-separator profile (save-tool)', () => {
105
+ for (const s of INPUTS) {
106
+ expect(slugify(s, { allowUnderscore: true, separator: '_', maxLength: 64 })).toBe(
107
+ legacySaveTool(s),
108
+ );
109
+ }
110
+ });
111
+
112
+ it('matches the export profile (cap 80, "export" fallback)', () => {
113
+ for (const s of INPUTS) {
114
+ expect(slugify(s, { maxLength: 80, fallback: 'export' })).toBe(legacyExport(s));
115
+ }
116
+ });
117
+
118
+ it('matches the ai-worker profile (cap 60)', () => {
119
+ for (const s of INPUTS) {
120
+ expect(slugify(s, { maxLength: 60 })).toBe(legacyAiWorker(s));
121
+ }
122
+ });
123
+ });
124
+
125
+ describe('slugify — canonical behaviour and edge cases', () => {
126
+ it('lower-cases and hyphenates runs of illegal characters', () => {
127
+ expect(slugify('Hello World!!')).toBe('hello-world');
128
+ });
129
+
130
+ it('trims leading/trailing separators', () => {
131
+ expect(slugify('--Hello--')).toBe('hello');
132
+ });
133
+
134
+ it('drops underscores by default but keeps them with allowUnderscore', () => {
135
+ expect(slugify('a_b')).toBe('a-b');
136
+ expect(slugify('a_b', { allowUnderscore: true })).toBe('a_b');
137
+ });
138
+
139
+ it('collapses to the empty string (or fallback) for all-unicode / all-punctuation input', () => {
140
+ expect(slugify('你好')).toBe('');
141
+ expect(slugify('!!!')).toBe('');
142
+ expect(slugify('!!!', { fallback: 'export' })).toBe('export');
143
+ });
144
+
145
+ it('applies the length cap after trimming (a cut can leave a trailing separator)', () => {
146
+ // 'ab cd' -> 'ab-cd'; cap at 3 -> 'ab-' (mirrors the originals' slice-last order).
147
+ expect(slugify('ab cd', { maxLength: 3 })).toBe('ab-');
148
+ });
149
+
150
+ it('has no cap by default', () => {
151
+ const long = 'x'.repeat(500);
152
+ expect(slugify(long)).toHaveLength(500);
153
+ });
154
+
155
+ it('uses the given separator for both joining and end-trimming', () => {
156
+ expect(slugify('a b c', { allowUnderscore: true, separator: '_' })).toBe('a_b_c');
157
+ expect(slugify('__a b__', { allowUnderscore: true, separator: '_' })).toBe('a_b');
158
+ });
159
+ });
package/src/slugify.ts ADDED
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Canonical slug normaliser for the web app.
3
+ *
4
+ * ── Divergence history (audit item #5a) ──────────────────────────────────────
5
+ * Roughly ten screens each grew their own private `slugify` by copy-paste, and
6
+ * they drifted apart into subtly incompatible rules:
7
+ * - agents / ai-workers / docs / export: strip `_` (alnum + `-` only)
8
+ * - skills / heartbeats / tool-groups / tools: KEEP `_` (it's a legal char in
9
+ * those slug namespaces)
10
+ * - save-tool: KEEP `_` AND join with `_` instead of `-` (tool slugs like
11
+ * `web_search` read as underscore-cased)
12
+ * - length caps ranged over {none, 60, 64, 80}
13
+ * - the export route adds a `'export'` fallback for empty titles
14
+ * So the "same" typed name produced different slugs on different screens.
15
+ *
16
+ * These differences are NOT all accidental: an agent slug and a skill slug obey
17
+ * different server-side validation, so unifying them to one fixed behaviour
18
+ * would seed values the API then rejects. The options below exist precisely to
19
+ * preserve each call site's *legal* slug shape while sharing one implementation.
20
+ * DO NOT "simplify" them away — a stored slug that no longer round-trips orphans
21
+ * existing rows (agents, skills, tool groups, workers, folders keyed by slug).
22
+ *
23
+ * NOTE: the folder-slug helper in `files-client.tsx` intentionally does NOT use
24
+ * this — it *deletes* punctuation instead of turning it into a separator (a
25
+ * genuinely different normalisation for on-disk folder paths), so it stays
26
+ * separate and documented at its call site.
27
+ */
28
+
29
+ export interface SlugifyOptions {
30
+ /** Keep `_` as a legal slug character instead of treating it as a separator.
31
+ * Default `false` (underscores collapse into the separator). */
32
+ allowUnderscore?: boolean;
33
+ /** Hard cap on length, applied AFTER trimming separators (so, like the
34
+ * originals, a cut can land mid-word and leave a trailing separator). Omit
35
+ * for no cap. */
36
+ maxLength?: number;
37
+ /** Character that runs of illegal chars collapse into, and that is trimmed
38
+ * from the ends. Default `'-'`; `save-tool` uses `'_'`. */
39
+ separator?: string;
40
+ /** Returned when normalisation yields an empty string (e.g. an all-unicode
41
+ * title). Default `''`. */
42
+ fallback?: string;
43
+ }
44
+
45
+ function escapeForCharClassOrEnd(ch: string): string {
46
+ // Only `-` and `_` are ever passed as separators today; escape defensively so
47
+ // a future caller can't smuggle a regex metacharacter into the built pattern.
48
+ return ch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
49
+ }
50
+
51
+ /**
52
+ * Normalise free-form text into a URL/identifier-safe slug: lower-cased, with
53
+ * runs of illegal characters collapsed to a single separator and separators
54
+ * trimmed from both ends.
55
+ */
56
+ export function slugify(input: string, opts: SlugifyOptions = {}): string {
57
+ const { allowUnderscore = false, maxLength, separator = '-', fallback = '' } = opts;
58
+
59
+ // Illegal chars = anything outside the keep-set. With underscores allowed we
60
+ // also keep `-`, mirroring the legacy `[^a-z0-9_-]` class; without, just alnum.
61
+ const keepClass = allowUnderscore ? 'a-z0-9_-' : 'a-z0-9';
62
+ const sep = escapeForCharClassOrEnd(separator);
63
+
64
+ let out = input
65
+ .toLowerCase()
66
+ .replace(new RegExp(`[^${keepClass}]+`, 'g'), separator)
67
+ .replace(new RegExp(`^${sep}+|${sep}+$`, 'g'), '');
68
+
69
+ if (maxLength != null) out = out.slice(0, maxLength);
70
+ return out || fallback;
71
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Themed text (font) colour palette — twin of highlight-colors. Stores a TOKEN
3
+ * KEY (e.g. `chart-2`), never a raw colour, so coloured text tracks the active
4
+ * theme + light/dark. Unlike highlights (translucent tints) text uses the full
5
+ * token colour for legibility. Pure (no React) → safe in the server renderer.
6
+ */
7
+ export const TEXT_COLOR_TOKENS = ['chart-1', 'chart-2', 'chart-3', 'chart-4', 'chart-5'] as const;
8
+ export type TextColorToken = (typeof TEXT_COLOR_TOKENS)[number];
9
+
10
+ export function isTextColorToken(v: unknown): v is TextColorToken {
11
+ return typeof v === 'string' && (TEXT_COLOR_TOKENS as readonly string[]).includes(v);
12
+ }
13
+
14
+ /** CSS `color` for a text-colour token, or null if unknown. */
15
+ export function textColor(token: unknown): string | null {
16
+ if (!isTextColorToken(token)) return null;
17
+ return `var(--${token})`;
18
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Pure types + format helpers for traces. No DB imports. Safe to pull
3
+ * into a client component without dragging postgres-js / pg-boss /
4
+ * other Node-only modules into the browser bundle.
5
+ *
6
+ * Server-only data fetching lives in `./traces.ts`, which re-exports
7
+ * everything from this file so existing server callers don't need to
8
+ * update their import sites.
9
+ */
10
+
11
+ export type TraceSummary = {
12
+ id: string;
13
+ kind: string;
14
+ status: string;
15
+ startedAt: string;
16
+ finishedAt: string | null;
17
+ durationMs: number | null;
18
+ costMicroUsd: number;
19
+ tokensIn: number;
20
+ tokensOut: number;
21
+ tokensCacheRead: number;
22
+ stepCount: number;
23
+ subjectKind: string | null;
24
+ subjectId: string | null;
25
+ agentName: string | null;
26
+ agentSlug: string | null;
27
+ error: string | null;
28
+ };
29
+
30
+ export type TraceSort = 'started' | 'cost' | 'duration';
31
+ export type TraceSortDir = 'asc' | 'desc';
32
+
33
+ export type TraceFilter = {
34
+ kinds?: string[];
35
+ statuses?: string[];
36
+ sinceHours?: number;
37
+ limit?: number;
38
+ offset?: number;
39
+ sort?: TraceSort;
40
+ dir?: TraceSortDir;
41
+ };
42
+
43
+ export type TraceStepSummary = {
44
+ id: string;
45
+ parentStepId: string | null;
46
+ ordinal: number;
47
+ name: string;
48
+ kind: string;
49
+ status: string;
50
+ startedAt: string;
51
+ finishedAt: string | null;
52
+ durationMs: number | null;
53
+ input: Record<string, unknown>;
54
+ output: Record<string, unknown>;
55
+ meta: Record<string, unknown>;
56
+ error: string | null;
57
+ };
58
+
59
+ export type TraceDetail = TraceSummary & {
60
+ data: Record<string, unknown>;
61
+ steps: TraceStepSummary[];
62
+ };
63
+
64
+ export function formatMicroUsd(microUsd: number): string {
65
+ if (microUsd === 0) return '$0';
66
+ const usd = microUsd / 1_000_000;
67
+ if (usd >= 1) return `$${usd.toFixed(2)}`;
68
+ if (usd >= 0.01) return `$${usd.toFixed(3)}`;
69
+ return `$${usd.toFixed(4)}`;
70
+ }
71
+
72
+ export function formatDuration(ms: number | null): string {
73
+ if (ms == null) return '—';
74
+ if (ms < 1000) return `${ms}ms`;
75
+ if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
76
+ return `${(ms / 60_000).toFixed(1)}m`;
77
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Live turn streaming feature flags.
3
+ *
4
+ * The feature (SSE status trail + token streaming, see
5
+ * `docs/live-turn-streaming.md`) is **on by default**. It used to be dark-by-
6
+ * default behind `MANTLE_TURN_STREAMING=1`, but that put the on-switch in an
7
+ * env var that has to be set at BUILD time on the client (a `NEXT_PUBLIC_*`
8
+ * inline) — easy to forget, invisible once wrong, and the reason a deployed box
9
+ * could silently show only the static thinking bubble. So the default flipped:
10
+ * the env vars are now an *off* switch (a backup), and a server that wants the
11
+ * feature dark sets `MANTLE_TURN_STREAMING=0`. The stream route 404s when off;
12
+ * the client treats that 404 as a clean fallback (no reconnect loop), so the
13
+ * server stays the single source of truth even though the client flag is baked
14
+ * at build.
15
+ */
16
+
17
+ /** A flag is ON unless explicitly disabled with 0/false/off/no (case-insensitive).
18
+ * Unset → on. */
19
+ function flagOn(raw: string | undefined): boolean {
20
+ const v = raw?.trim().toLowerCase();
21
+ return v !== '0' && v !== 'false' && v !== 'off' && v !== 'no';
22
+ }
23
+
24
+ /** Server-side master gate: the SSE/cancel routes exist and the POST turn route
25
+ * goes non-blocking. Off only when `MANTLE_TURN_STREAMING` is explicitly falsy. */
26
+ export function isTurnStreamingEnabled(): boolean {
27
+ return flagOn(process.env.MANTLE_TURN_STREAMING);
28
+ }
29
+
30
+ /** Client-side twin, compiled into the browser bundle. Defaults on (an unset
31
+ * `NEXT_PUBLIC_MANTLE_TURN_STREAMING` → on), so the browser opens the stream;
32
+ * if the server has it off the GET route 404s and the client falls back
33
+ * cleanly. Set `NEXT_PUBLIC_MANTLE_TURN_STREAMING=0` at build to compile it
34
+ * out entirely. */
35
+ export function isTurnStreamingEnabledClient(): boolean {
36
+ return flagOn(process.env.NEXT_PUBLIC_MANTLE_TURN_STREAMING);
37
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Integrity surface — shared types.
3
+ *
4
+ * Two read-only views, no synthetic fixtures:
5
+ * • Live (`landed.ts`) — observes the *real* content you add (notes, pages,
6
+ * tasks, events, contacts, secrets, files, email) as it lands in the brain,
7
+ * and reports its per-type footprint (L6 node · L5 summary/embedding/tsv ·
8
+ * L4 facts · graph edges · the extractor_run trace). Honest by design: a
9
+ * secret keeps its sealed body out of the LLM, a scanned image with no
10
+ * vision worker *correctly* skips — those read neutral, not red.
11
+ * • Corpus audit (`audit.ts`) — scans the *existing* corpus for invariant
12
+ * violations (no writes, no cost).
13
+ *
14
+ * See docs/data-flow-tracing.md (the signature table) and docs/journey.md
15
+ * (the action→layer map) for the behaviour these footprints encode.
16
+ */
17
+
18
+ export type CheckStatus = 'pass' | 'fail' | 'info';
19
+
20
+ export type CheckResult = {
21
+ /** Short column-ish label: "Trace", "L5 summary", "Emb 768", … */
22
+ label: string;
23
+ status: CheckStatus;
24
+ /** Actual-vs-expected detail, shown on hover / in the expanded row. */
25
+ detail?: string;
26
+ };
27
+
28
+ /** The structural footprint of one node, read straight from the brain tables. */
29
+ export type ProbeFootprint = {
30
+ nodeId: string;
31
+ exists: boolean;
32
+ nodeType: string | null;
33
+ summary: string | null;
34
+ embDims: number | null;
35
+ hasTsv: boolean;
36
+ nFacts: number;
37
+ factKinds: string[];
38
+ /** mentioned_in edges (entity → node). */
39
+ nEntities: number;
40
+ /** Duplicate mentioned_in edges (same entity → node twice) — must be 0 if the
41
+ * extractor rebuilds idempotently rather than appending on re-extract. */
42
+ dupMentionEdges: number;
43
+ /** content_chunks rows for this node. */
44
+ nChunks: number;
45
+ run: {
46
+ traceId: string;
47
+ startedAt: string;
48
+ status: string;
49
+ disposition: string | null;
50
+ stepNames: string[];
51
+ costMicroUsd: number;
52
+ } | null;
53
+ };
54
+
55
+ /** One optional service/worker's readiness. */
56
+ export type Capability = { available: boolean; detail: string };
57
+
58
+ /** Brain-readiness snapshot. */
59
+ export type Capabilities = {
60
+ tika: Capability;
61
+ vision: Capability;
62
+ extractor: Capability;
63
+ embedding: Capability;
64
+ summarizer: Capability;
65
+ reflector: Capability;
66
+ stt: Capability;
67
+ };
68
+
69
+ // ─── live landed view ───────────────────────────────────────────────────────
70
+
71
+ export type LandedState =
72
+ /** Inserted, extractor hasn't terminated yet — transient; flips on node_indexed. */
73
+ | 'indexing'
74
+ /** Success + the expected layers landed (summary · 768-dim embedding · tsv). */
75
+ | 'ok'
76
+ /** Correctly skipped (e.g. a scanned image with no vision worker) — shows why. */
77
+ | 'skipped'
78
+ /** Success but a layer is missing (silent-miss / dim drift / duplicate edges). */
79
+ | 'fail'
80
+ /** No extractor_run after the stall window — is apps/agent + an extractor up? */
81
+ | 'stalled';
82
+
83
+ export type LandedItem = {
84
+ nodeId: string;
85
+ nodeType: string;
86
+ title: string;
87
+ createdAt: string;
88
+ updatedAt: string;
89
+ footprint: ProbeFootprint;
90
+ state: LandedState;
91
+ checks: CheckResult[];
92
+ };
93
+
94
+ export type LandedReport = {
95
+ generatedAt: string;
96
+ items: LandedItem[];
97
+ capabilities: Capabilities;
98
+ /** Total nodes matching the current type filter (ignores limit/offset) — the
99
+ * denominator the pager needs, since `items` is one page. */
100
+ total: number;
101
+ };
102
+
103
+ // ─── passive corpus audit ───────────────────────────────────────────────────
104
+ //
105
+ // The read-only health scan of the *existing* brain for invariant violations
106
+ // (no writes, no fixtures, no cost): "is what's already stored consistent?".
107
+
108
+ export type AuditSeverity = 'high' | 'medium' | 'low';
109
+
110
+ /** One offending row, with enough to drill in. */
111
+ export type AuditSample = { id: string; kind: string; detail: string };
112
+
113
+ export type AuditCheck = {
114
+ key: string;
115
+ label: string;
116
+ severity: AuditSeverity;
117
+ /** One-line explanation of the invariant + why a violation matters. */
118
+ note: string;
119
+ count: number;
120
+ /** True if the violation count hit the query cap (so `count` is a floor). */
121
+ capped: boolean;
122
+ ok: boolean;
123
+ samples: AuditSample[];
124
+ /** Age span (oldest/newest, `YYYY-MM-DD`) of the offending rows — uncapped,
125
+ * so it reflects the true range even when `count` is floored. Lets the UI
126
+ * tell pre-fix *sediment* (all old) apart from a *live* regression (recent).
127
+ * null when the check has no natural timestamp or zero violations. */
128
+ oldestAt: string | null;
129
+ newestAt: string | null;
130
+ };
131
+
132
+ export type AuditReport = {
133
+ generatedAt: string;
134
+ checks: AuditCheck[];
135
+ totalViolations: number;
136
+ };
137
+
138
+ // ─── system config integrity ────────────────────────────────────────────────
139
+ //
140
+ // Read-only check of the agent/skill/tool/worker CONFIG graph against the
141
+ // declarative manifest (apps/web/lib/system-manifest). Catches the silent-drop
142
+ // cases the runtime resolvers hide: an agent referencing a skill/tool that has
143
+ // no row, a specialist not wired into the persona's delegate_to, a default
144
+ // worker missing for a kind. Same severity vocabulary as the corpus audit.
145
+
146
+ export type SystemSample = { id: string; detail: string };
147
+
148
+ export type SystemCheck = {
149
+ key: string;
150
+ label: string;
151
+ severity: AuditSeverity;
152
+ ok: boolean;
153
+ /** Human-readable state — what's right, or what's broken + why it matters. */
154
+ detail: string;
155
+ /** Offending specifics (dangling slugs, unlinked skills, missing agents). */
156
+ samples?: SystemSample[];
157
+ };
158
+
159
+ export type SystemReport = {
160
+ generatedAt: string;
161
+ checks: SystemCheck[];
162
+ /** Number of checks that are not ok. */
163
+ problems: number;
164
+ };