@taodigital/app-brain 0.0.0-stage → 1.2.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 (47) hide show
  1. package/README.md +13 -2
  2. package/dist/archetype-classification.d.ts +54 -0
  3. package/dist/archetype-classification.js +95 -0
  4. package/dist/cloud-storage.d.ts +106 -0
  5. package/dist/cloud-storage.js +539 -0
  6. package/dist/email.d.ts +21 -0
  7. package/dist/email.js +234 -0
  8. package/dist/grounding.d.ts +30 -0
  9. package/dist/grounding.js +138 -0
  10. package/dist/index.d.ts +13 -0
  11. package/dist/index.js +37 -0
  12. package/dist/nav-graph.d.ts +42 -0
  13. package/dist/nav-graph.js +248 -0
  14. package/dist/notifications.d.ts +26 -0
  15. package/dist/notifications.js +105 -0
  16. package/dist/orphan-classification.d.ts +32 -0
  17. package/dist/orphan-classification.js +40 -0
  18. package/dist/repository.d.ts +3185 -0
  19. package/dist/repository.js +5927 -0
  20. package/dist/run-notifications.d.ts +16 -0
  21. package/dist/run-notifications.js +244 -0
  22. package/dist/skill-file-kinds.d.ts +5 -0
  23. package/dist/skill-file-kinds.js +50 -0
  24. package/dist/token-ledger.d.ts +23 -0
  25. package/dist/token-ledger.js +178 -0
  26. package/dist/traceability.d.ts +213 -0
  27. package/dist/traceability.js +339 -0
  28. package/package.json +24 -4
  29. package/src/archetype-classification.test.ts +91 -0
  30. package/src/archetype-classification.ts +132 -0
  31. package/src/cloud-storage.test.ts +83 -0
  32. package/src/cloud-storage.ts +627 -0
  33. package/src/email.ts +268 -0
  34. package/src/grounding.ts +172 -0
  35. package/src/index.ts +18 -0
  36. package/src/nav-graph.ts +295 -0
  37. package/src/notifications.ts +156 -0
  38. package/src/orphan-classification.test.ts +61 -0
  39. package/src/orphan-classification.ts +65 -0
  40. package/src/orphan-screens.test.ts +88 -0
  41. package/src/repository.ts +7397 -0
  42. package/src/run-notifications.ts +281 -0
  43. package/src/skill-file-kinds.ts +51 -0
  44. package/src/token-ledger.ts +209 -0
  45. package/src/traceability.test.ts +75 -0
  46. package/src/traceability.ts +477 -0
  47. package/tsconfig.json +9 -0
package/src/email.ts ADDED
@@ -0,0 +1,268 @@
1
+ import nodemailer from 'nodemailer';
2
+ import { AppBrain } from './repository.js';
3
+
4
+ // Branding defaults — overridden by DB-backed admin config (getSystemConfig('branding')) at send time.
5
+ const DEFAULT_BRAND_NAME = 'ZeTa-AI';
6
+ const DEFAULT_BRAND_COLOR = '#0ff';
7
+
8
+ async function getBranding(): Promise<{ brandName: string; brandColor: string; appUrl: string }> {
9
+ try {
10
+ const { getSystemConfig } = await import('@taodigital/core');
11
+ const cfg = (await getSystemConfig('branding')) ?? {};
12
+ return {
13
+ brandName: String(cfg.brandName ?? DEFAULT_BRAND_NAME),
14
+ brandColor: String(cfg.brandColor ?? DEFAULT_BRAND_COLOR),
15
+ appUrl: String(cfg.appUrl ?? process.env.APP_URL ?? `http://localhost:${process.env.PORT ?? 3000}`),
16
+ };
17
+ } catch {
18
+ return {
19
+ brandName: DEFAULT_BRAND_NAME,
20
+ brandColor: DEFAULT_BRAND_COLOR,
21
+ appUrl: process.env.APP_URL ?? `http://localhost:${process.env.PORT ?? 3000}`,
22
+ };
23
+ }
24
+ }
25
+
26
+ export interface EmailPayload {
27
+ to: string;
28
+ name?: string;
29
+ type: string;
30
+ title: string;
31
+ body: string;
32
+ metadata?: Record<string, unknown>;
33
+ tenantId?: string;
34
+ }
35
+
36
+ export interface SendEmailResult {
37
+ sent: boolean;
38
+ reason?: string;
39
+ }
40
+
41
+ export interface EmailDiagnostics {
42
+ configured: boolean;
43
+ provider: 'platform-smtp' | 'tenant-smtp' | 'tenant-resend' | 'env-resend' | 'none';
44
+ missing: string[];
45
+ message: string;
46
+ }
47
+
48
+ async function getTenantConfig(tenantId?: string) {
49
+ if (!tenantId) return null;
50
+ try { return await AppBrain.getTenantEmailConfig(tenantId); } catch { return null; }
51
+ }
52
+
53
+ export async function getEmailDiagnostics(tenantId?: string): Promise<EmailDiagnostics> {
54
+ const { getSystemConfig } = await import('@taodigital/core');
55
+ const smtpCfg = await getSystemConfig('smtp').catch(() => ({} as Record<string, string>));
56
+ const smtpHost = (smtpCfg?.host && smtpCfg.host.trim()) || process.env.SMTP_HOST || '';
57
+ const smtpUser = (smtpCfg?.username && smtpCfg.username.trim()) || process.env.SMTP_USERNAME || process.env.SMTP_USER || '';
58
+ const smtpPass = (smtpCfg?.password && smtpCfg.password.trim()) || process.env.SMTP_PASSWORD || process.env.SMTP_PASS || '';
59
+
60
+ if (smtpHost || smtpUser || smtpPass) {
61
+ const missing = [
62
+ !smtpHost ? 'Platform SMTP host' : null,
63
+ !smtpUser ? 'Platform SMTP username' : null,
64
+ !smtpPass ? 'Platform SMTP password' : null,
65
+ ].filter(Boolean) as string[];
66
+ return {
67
+ configured: missing.length === 0,
68
+ provider: 'platform-smtp',
69
+ missing,
70
+ message: missing.length === 0
71
+ ? 'Platform SMTP is configured.'
72
+ : `Platform SMTP is incomplete: ${missing.join(', ')}.`,
73
+ };
74
+ }
75
+
76
+ const tenantCfg = await getTenantConfig(tenantId);
77
+ if (tenantCfg?.provider === 'resend' || tenantCfg?.resendApiKey) {
78
+ const missing = tenantCfg.resendApiKey ? [] : ['Workspace Resend API key'];
79
+ return {
80
+ configured: missing.length === 0,
81
+ provider: 'tenant-resend',
82
+ missing,
83
+ message: missing.length === 0
84
+ ? 'Workspace Resend email is configured.'
85
+ : `Workspace Resend email is incomplete: ${missing.join(', ')}.`,
86
+ };
87
+ }
88
+ if (tenantCfg?.smtpHost || tenantCfg?.smtpUser || tenantCfg?.smtpPass) {
89
+ const missing = [
90
+ !tenantCfg.smtpHost ? 'Workspace SMTP host' : null,
91
+ !tenantCfg.smtpUser ? 'Workspace SMTP username' : null,
92
+ !tenantCfg.smtpPass ? 'Workspace SMTP password' : null,
93
+ ].filter(Boolean) as string[];
94
+ return {
95
+ configured: missing.length === 0,
96
+ provider: 'tenant-smtp',
97
+ missing,
98
+ message: missing.length === 0
99
+ ? 'Workspace SMTP email is configured.'
100
+ : `Workspace SMTP email is incomplete: ${missing.join(', ')}.`,
101
+ };
102
+ }
103
+
104
+ if (process.env.RESEND_API_KEY) {
105
+ return {
106
+ configured: true,
107
+ provider: 'env-resend',
108
+ missing: [],
109
+ message: 'Environment Resend email is configured.',
110
+ };
111
+ }
112
+
113
+ return {
114
+ configured: false,
115
+ provider: 'none',
116
+ missing: ['Email provider'],
117
+ message: 'No email provider configured. Configure Workspace email, platform SMTP, or RESEND_API_KEY.',
118
+ };
119
+ }
120
+
121
+ export async function sendEmail(payload: EmailPayload): Promise<SendEmailResult> {
122
+ const branding = await getBranding();
123
+ const html = buildHtml(payload, branding);
124
+
125
+ // Platform-level SMTP from DB (admin panel) or env fallback — always wins over tenant config.
126
+ const { getSystemConfig } = await import('@taodigital/core');
127
+ const smtpCfg = await getSystemConfig('smtp').catch(() => ({} as Record<string, string>));
128
+ const smtpHost = (smtpCfg?.host && smtpCfg.host.trim()) || process.env.SMTP_HOST || '';
129
+ const smtpUser = (smtpCfg?.username && smtpCfg.username.trim()) || process.env.SMTP_USERNAME || process.env.SMTP_USER || '';
130
+ const smtpPass = (smtpCfg?.password && smtpCfg.password.trim()) || process.env.SMTP_PASSWORD || process.env.SMTP_PASS || '';
131
+ const smtpPort = Number(smtpCfg?.port || process.env.SMTP_PORT || 587);
132
+ const smtpFrom = (smtpCfg?.from && smtpCfg.from.trim()) || process.env.SMTP_FROM || process.env.EMAIL_FROM || 'noreply@tucdesk.app';
133
+ const smtpFromName = (smtpCfg?.fromName && smtpCfg.fromName.trim()) || process.env.SMTP_FROM_NAME || branding.brandName;
134
+
135
+ if (smtpHost && smtpUser && smtpPass) {
136
+ return sendViaSMTP({
137
+ smtpHost,
138
+ smtpPort,
139
+ smtpUser,
140
+ smtpPass,
141
+ fromEmail: smtpFrom,
142
+ fromName: smtpFromName,
143
+ }, payload.to, payload.title, html, branding.brandName);
144
+ }
145
+
146
+ // Tenant-level config (Workspace → Integrations → Email).
147
+ const tenantCfg = await getTenantConfig(payload.tenantId);
148
+ if (tenantCfg?.smtpHost && tenantCfg.smtpUser && tenantCfg.smtpPass) {
149
+ return sendViaSMTP(tenantCfg, payload.to, payload.title, html, branding.brandName);
150
+ }
151
+ if (tenantCfg?.resendApiKey) {
152
+ return sendViaResend(tenantCfg.resendApiKey, tenantCfg, payload.to, payload.title, html, branding.brandName);
153
+ }
154
+
155
+ // Last resort: env Resend key.
156
+ const resendKey = process.env.RESEND_API_KEY;
157
+ if (resendKey) {
158
+ return sendViaResend(resendKey, null, payload.to, payload.title, html, branding.brandName);
159
+ }
160
+ console.warn(`[email] No email provider configured — "${payload.title}" to ${payload.to} not sent.`);
161
+ return { sent: false, reason: 'No email provider configured. Configure one in Workspace → Integrations → Email, or set RESEND_API_KEY / SMTP_* on the server.' };
162
+ }
163
+
164
+ async function sendViaResend(
165
+ apiKey: string,
166
+ cfg: { fromEmail?: string; fromName?: string } | null,
167
+ to: string, subject: string, html: string,
168
+ brandName: string = DEFAULT_BRAND_NAME,
169
+ ): Promise<SendEmailResult> {
170
+ const from = cfg?.fromEmail
171
+ ? `${cfg.fromName ?? brandName} <${cfg.fromEmail}>`
172
+ : (process.env.EMAIL_FROM || `${brandName} <onboarding@resend.dev>`);
173
+ try {
174
+ const resp = await fetch('https://api.resend.com/emails', {
175
+ method: 'POST',
176
+ headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
177
+ body: JSON.stringify({ from, to: [to], subject, html }),
178
+ });
179
+ if (!resp.ok) {
180
+ const detail = await resp.text();
181
+ let hint = '';
182
+ if (resp.status === 403) hint = ' — likely cause: sending domain not verified in Resend dashboard, or API key lacks send permission.';
183
+ if (resp.status === 422) hint = ' — likely cause: "from" domain not verified in Resend. Add & verify your domain at resend.com/domains.';
184
+ console.error(`[email] Resend ${resp.status}:`, detail);
185
+ return { sent: false, reason: `Resend rejected the email (${resp.status})${hint} Detail: ${detail}` };
186
+ }
187
+ return { sent: true };
188
+ } catch (err: any) {
189
+ console.error('[email] Resend error:', err);
190
+ return { sent: false, reason: `Resend request failed: ${err?.message ?? err}` };
191
+ }
192
+ }
193
+
194
+ async function sendViaSMTP(
195
+ cfg: { smtpHost?: string; smtpPort?: number; smtpUser?: string; smtpPass?: string; fromEmail?: string; fromName?: string },
196
+ to: string, subject: string, html: string,
197
+ brandName: string = DEFAULT_BRAND_NAME,
198
+ ): Promise<SendEmailResult> {
199
+ try {
200
+ const transport = nodemailer.createTransport({
201
+ host: cfg.smtpHost,
202
+ port: cfg.smtpPort ?? 587,
203
+ secure: (cfg.smtpPort ?? 587) === 465,
204
+ auth: { user: cfg.smtpUser, pass: cfg.smtpPass },
205
+ });
206
+ // Use EMAIL_FROM env var if set; otherwise fall back to smtpUser address (standard practice).
207
+ // Set EMAIL_FROM=Name <user@domain.com> in env for a clean sender display name.
208
+ const from = cfg.fromEmail
209
+ ? `${cfg.fromName ?? brandName} <${cfg.fromEmail}>`
210
+ : (process.env.EMAIL_FROM ?? `${brandName} <${cfg.smtpUser ?? ''}>`);
211
+ await transport.sendMail({ from, to, subject, html });
212
+ return { sent: true };
213
+ } catch (err: any) {
214
+ console.error('[email] SMTP error:', err);
215
+ return { sent: false, reason: `SMTP send failed: ${err?.message ?? err}` };
216
+ }
217
+ }
218
+
219
+ /** Internal metadata keys that should never be shown as raw table rows in emails. */
220
+ const HIDDEN_META_KEYS = new Set(['tenantId', 'joinUrl', 'projectId', 'runId']);
221
+
222
+ function buildHtml(p: EmailPayload, branding: { brandName: string; brandColor: string; appUrl: string }): string {
223
+ const { brandName, brandColor, appUrl } = branding;
224
+ const joinUrl = p.metadata?.joinUrl as string | undefined;
225
+
226
+ // For invite emails, fall back to the workspace dashboard when no explicit joinUrl is set.
227
+ const ctaUrl = (p.type === 'invite.created')
228
+ ? (joinUrl ?? `${appUrl}/dashboard`)
229
+ : joinUrl;
230
+
231
+ const metaHtml = p.metadata
232
+ ? Object.entries(p.metadata)
233
+ .filter(([k, v]) => v != null && !HIDDEN_META_KEYS.has(k))
234
+ .map(([k, v]) => `<tr><td style="color:#888;padding:4px 0;width:120px">${k}</td><td>${v}</td></tr>`)
235
+ .join('')
236
+ : '';
237
+
238
+ const ctaHtml = ctaUrl
239
+ ? `<div style="margin-top:24px;text-align:center">
240
+ <a href="${ctaUrl}"
241
+ style="display:inline-block;background:${brandColor};color:#000;font-weight:700;font-size:14px;
242
+ text-decoration:none;padding:12px 28px;border-radius:8px;letter-spacing:0.3px">
243
+ Go to Workspace
244
+ </a>
245
+ </div>`
246
+ : '';
247
+
248
+ return `<!DOCTYPE html>
249
+ <html>
250
+ <head><meta charset="utf-8"/><meta name="viewport" content="width=device-width"/></head>
251
+ <body style="font-family:system-ui,sans-serif;background:#0d0d0d;color:#e0e0e0;margin:0;padding:32px 16px">
252
+ <div style="max-width:560px;margin:0 auto">
253
+ <div style="margin-bottom:24px">
254
+ <span style="font-size:22px;font-weight:700;letter-spacing:-0.5px;color:${brandColor}">${brandName}</span>
255
+ </div>
256
+ <div style="background:#161616;border:1px solid #2a2a2a;border-radius:12px;padding:28px">
257
+ <h2 style="margin:0 0 12px;font-size:18px;font-weight:600">${p.title}</h2>
258
+ <p style="margin:0 0 20px;color:#bbb;line-height:1.6">${p.body}</p>
259
+ ${metaHtml ? `<table style="font-size:13px;border-collapse:collapse;width:100%">${metaHtml}</table>` : ''}
260
+ ${ctaHtml}
261
+ </div>
262
+ <p style="font-size:11px;color:#555;margin-top:20px;text-align:center">
263
+ ${brandName} Quality Intelligence · <a href="${appUrl}/settings/notifications" style="color:#555">Manage notifications</a>
264
+ </p>
265
+ </div>
266
+ </body>
267
+ </html>`;
268
+ }
@@ -0,0 +1,172 @@
1
+ import type { GroundingContext, SkillFileEntry } from '@taodigital/core';
2
+ import { AppBrain } from './repository.js';
3
+ import { rerankStrings } from '@taodigital/reranker';
4
+ import { SKILL_FILE_GLOBAL_KINDS, SKILL_FILE_SELECTIVE_KINDS } from './skill-file-kinds.js';
5
+
6
+ /**
7
+ * GROUNDING — how the AI gets real facts before it generates anything.
8
+ *
9
+ * Strategy is swappable. Two tiers exist:
10
+ *
11
+ * DirectLoadGrounding (default):
12
+ * Loads ALL relevant skill files for a screen in one query.
13
+ * Fast and precise for < 300 screens. No vector DB needed.
14
+ * Taxonomy retrieval: PLATFORM+FUNCTION+DESIGN+THEME always,
15
+ * COMPONENT+ELEMENT filtered to matching screenIds.
16
+ *
17
+ * VectorSearchGrounding (Phase 2+):
18
+ * For projects flagged `oversized = true` (300+ screens).
19
+ * Runs a semantic similarity search on skill file summaries to find
20
+ * the top-K most relevant COMPONENT/ELEMENT files.
21
+ * Global tier (PLATFORM/FUNCTION/DESIGN/THEME) still loads directly.
22
+ */
23
+ export interface GroundingStrategy {
24
+ ground(tenantId: string, projectId: string, screenId: string): Promise<GroundingContext>;
25
+ }
26
+
27
+ const SELECTIVE_KINDS = new Set(SKILL_FILE_SELECTIVE_KINDS);
28
+ const MAX_SELECTIVE = 5;
29
+
30
+ export class DirectLoadGrounding implements GroundingStrategy {
31
+ async ground(tenantId: string, projectId: string, screenId: string): Promise<GroundingContext> {
32
+ const [screen, rawSkillFiles, probeObs, outgoingTransitions] = await Promise.all([
33
+ AppBrain.getScreenWithElements(tenantId, screenId),
34
+ AppBrain.getRelevantSkillFilesForScreen(tenantId, projectId, screenId),
35
+ AppBrain.getProbeObservations(tenantId, screenId),
36
+ AppBrain.getScreenOutgoingTransitions(tenantId, screenId),
37
+ ]);
38
+
39
+ if (!screen) throw new Error(`Screen ${screenId} not found in Zeta Core`);
40
+
41
+ const s = screen as any;
42
+ const global = rawSkillFiles.filter((sf) => !SELECTIVE_KINDS.has(sf.kind as string));
43
+ const selective = rawSkillFiles.filter((sf) => SELECTIVE_KINDS.has(sf.kind as string));
44
+
45
+ // Rerank selective skill files by semantic relevance when multiple candidates exist
46
+ let rankedSelective = selective;
47
+ if (selective.length > MAX_SELECTIVE) {
48
+ const screenQuery = [s.name, s.url, ...s.elements.map((e: any) => e.meaning)].filter(Boolean).join(' ');
49
+ try {
50
+ const reranked = await rerankStrings(
51
+ screenQuery,
52
+ selective.map((sf) => `${sf.kind} ${sf.summary ?? ''} ${sf.content.slice(0, 400)}`),
53
+ MAX_SELECTIVE,
54
+ 0,
55
+ );
56
+ rankedSelective = reranked.map(({ index }) => selective[index]);
57
+ } catch {
58
+ rankedSelective = selective.slice(0, MAX_SELECTIVE);
59
+ }
60
+ }
61
+
62
+ const skillFiles: SkillFileEntry[] = [...global, ...rankedSelective].map((sf) => ({
63
+ kind: sf.kind as any,
64
+ summary: sf.summary ?? undefined,
65
+ content: sf.content,
66
+ version: sf.version,
67
+ approvedBy: sf.approvedBy ?? undefined,
68
+ }));
69
+
70
+ const skillFile = skillFiles.length > 0
71
+ ? skillFiles.map((sf) => `### ${sf.kind}\n${sf.content}`).join('\n\n')
72
+ : undefined;
73
+
74
+ return {
75
+ screenName: s.name,
76
+ url: s.url ?? undefined,
77
+ elements: s.elements.map((e: any) => ({
78
+ meaning: e.meaning,
79
+ role: e.role,
80
+ expectedData: e.expectedData ?? undefined,
81
+ notes: e.notes ?? undefined,
82
+ boundingRect: e.boundingRect as any ?? undefined,
83
+ ariaState: e.ariaState as any ?? undefined,
84
+ parentLandmark: e.parentLandmark ?? undefined,
85
+ })),
86
+ relatedFlows: (outgoingTransitions as any[]).map((t) => {
87
+ const dest = t.toScreen?.name ?? 'unknown screen';
88
+ const action = typeof t.action === 'object' && t.action?.description
89
+ ? t.action.description
90
+ : JSON.stringify(t.action ?? {});
91
+ return `Navigate to "${dest}" via: ${action}`;
92
+ }),
93
+ skillFiles,
94
+ skillFile,
95
+ recentChanges: [],
96
+ probeObservations: probeObs.length > 0 ? probeObs as any : undefined,
97
+ viewport: undefined,
98
+ };
99
+ }
100
+ }
101
+
102
+ /** BM25-backed grounding for oversized projects (300+ screens). */
103
+ export class VectorSearchGrounding implements GroundingStrategy {
104
+ async ground(tenantId: string, projectId: string, screenId: string): Promise<GroundingContext> {
105
+ const [screen, probeObs, outgoingTransitions] = await Promise.all([
106
+ AppBrain.getScreenWithElements(tenantId, screenId),
107
+ AppBrain.getProbeObservations(tenantId, screenId),
108
+ AppBrain.getScreenOutgoingTransitions(tenantId, screenId),
109
+ ]);
110
+ if (!screen) throw new Error(`Screen ${screenId} not found`);
111
+
112
+ const globalFiles = await AppBrain.listSkillFiles(tenantId, projectId, SKILL_FILE_GLOBAL_KINDS) as any[];
113
+
114
+ const query = [(screen as any).name, ...(screen as any).elements.map((e: any) => e.meaning)].filter(Boolean).join(' ');
115
+ const searchResults = await AppBrain.searchKnowledge(tenantId, projectId, query, 5);
116
+
117
+ const globalEntries: SkillFileEntry[] = globalFiles.map((sf: any) => ({
118
+ kind: sf.kind as any,
119
+ summary: sf.summary ?? undefined,
120
+ content: sf.content,
121
+ version: sf.version,
122
+ approvedBy: sf.approvedBy ?? undefined,
123
+ }));
124
+
125
+ const selectiveEntries: SkillFileEntry[] = searchResults
126
+ .filter((r: any) => r.source === 'SKILL_FILE')
127
+ .map((r: any) => ({
128
+ kind: r.label as any,
129
+ content: r.content,
130
+ summary: undefined,
131
+ version: 1,
132
+ approvedBy: undefined,
133
+ }));
134
+
135
+ const skillFiles = [...globalEntries, ...selectiveEntries];
136
+ const skillFile = skillFiles.length > 0
137
+ ? skillFiles.map((sf) => `### ${sf.kind}\n${sf.content}`).join('\n\n')
138
+ : undefined;
139
+
140
+ const sv = screen as any;
141
+ return {
142
+ screenName: sv.name,
143
+ url: sv.url ?? undefined,
144
+ elements: sv.elements.map((e: any) => ({
145
+ meaning: e.meaning,
146
+ role: e.role,
147
+ expectedData: e.expectedData ?? undefined,
148
+ notes: e.notes ?? undefined,
149
+ boundingRect: e.boundingRect as any ?? undefined,
150
+ ariaState: e.ariaState as any ?? undefined,
151
+ parentLandmark: e.parentLandmark ?? undefined,
152
+ })),
153
+ relatedFlows: (outgoingTransitions as any[]).map((t) => {
154
+ const dest = t.toScreen?.name ?? 'unknown screen';
155
+ const action = typeof t.action === 'object' && t.action?.description
156
+ ? t.action.description
157
+ : JSON.stringify(t.action ?? {});
158
+ return `Navigate to "${dest}" via: ${action}`;
159
+ }),
160
+ skillFiles,
161
+ skillFile,
162
+ recentChanges: [],
163
+ probeObservations: probeObs.length > 0 ? probeObs as any : undefined,
164
+ viewport: undefined,
165
+ };
166
+ }
167
+ }
168
+
169
+ /** Pick grounding strategy. Oversized (300+ screens) uses BM25; others load directly. */
170
+ export function groundingFor(oversized: boolean): GroundingStrategy {
171
+ return oversized ? new VectorSearchGrounding() : new DirectLoadGrounding();
172
+ }
package/src/index.ts ADDED
@@ -0,0 +1,18 @@
1
+ export * from './repository.js';
2
+ export { buildNavigationGraph, toMermaid } from './nav-graph.js';
3
+ export type { NavGraph, NavGraphNode, NavGraphEdge } from './nav-graph.js';
4
+ export * from './grounding.js';
5
+ export * from './token-ledger.js';
6
+ export * from './cloud-storage.js';
7
+ export * from './orphan-classification.js';
8
+ export * from './email.js';
9
+ export * from './notifications.js';
10
+ export * from './run-notifications.js';
11
+ export * from './traceability.js';
12
+ export { rerankStrings } from '@taodigital/reranker';
13
+
14
+ // R2/H4: Standalone export so callers can import rebuildGherkinOutlineForCase by name
15
+ import { AppBrain } from './repository.js';
16
+ export function rebuildGherkinOutlineForCase(tenantId: string, testCaseId: string): Promise<void> {
17
+ return AppBrain.rebuildGherkinOutlineForCase(tenantId, testCaseId);
18
+ }