@taodigital/app-brain 0.0.0-stage → 1.2.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 (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
@@ -0,0 +1,248 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.buildNavigationGraph = buildNavigationGraph;
4
+ exports.toMermaid = toMermaid;
5
+ const database_1 = require("@taodigital/database");
6
+ function classifySection(url, name) {
7
+ const text = (url ?? name).toLowerCase();
8
+ if (/\/(login|signin|sign-in|register|signup|sign-up|auth|forgot|reset|verify|otp|mfa|sso)/.test(text))
9
+ return 'Authentication';
10
+ if (/\/(admin|administration|manage|management|superuser)/.test(text))
11
+ return 'Administration';
12
+ if (/\/(settings|preferences|profile|account|config|configuration)/.test(text))
13
+ return 'Settings';
14
+ if (/\/(dashboard|home|overview|summary|analytics|reports|metrics)/.test(text))
15
+ return 'Dashboard';
16
+ if (/\/(checkout|cart|basket|payment|order-review|billing)/.test(text))
17
+ return 'Checkout';
18
+ if (/\/(product|products|item|items|catalog|shop|store|collection)/.test(text))
19
+ return 'Catalog';
20
+ if (/\/(order|orders|history|purchases)/.test(text))
21
+ return 'Orders';
22
+ if (/\/(user|users|member|members|team|customers)/.test(text))
23
+ return 'Users';
24
+ // Extract primary URL path segment if available (e.g. /inventory -> Inventory)
25
+ if (url) {
26
+ try {
27
+ const parsed = new URL(url.startsWith('http') ? url : `https://example.com${url.startsWith('/') ? '' : '/'}${url}`);
28
+ const segments = parsed.pathname.split('/').filter(Boolean);
29
+ if (segments.length > 0) {
30
+ const seg = segments[0].replace(/[^a-zA-Z0-9_-]/g, '');
31
+ if (seg.length > 1 && !/^[0-9a-f-]{8,}$/i.test(seg)) {
32
+ return seg.charAt(0).toUpperCase() + seg.slice(1).replace(/[-_]/g, ' ');
33
+ }
34
+ }
35
+ }
36
+ catch { }
37
+ }
38
+ return 'Application';
39
+ }
40
+ async function buildNavigationGraph(tenantId, projectId, opts = {}) {
41
+ return (0, database_1.withTenant)(tenantId, async (tx) => {
42
+ const [screens, screenGroups] = await Promise.all([
43
+ tx.screen.findMany({
44
+ where: { tenantId, projectId, status: 'ACTIVE' },
45
+ select: {
46
+ id: true,
47
+ name: true,
48
+ url: true,
49
+ depth: true,
50
+ discoveryMethod: true,
51
+ groupId: true,
52
+ group: { select: { id: true, name: true, slug: true, color: true } },
53
+ },
54
+ }),
55
+ tx.screenGroup.findMany({
56
+ where: { tenantId, projectId },
57
+ orderBy: { order: 'asc' },
58
+ include: {
59
+ screens: {
60
+ where: { status: 'ACTIVE' },
61
+ select: { id: true, name: true, url: true },
62
+ },
63
+ },
64
+ }),
65
+ ]);
66
+ const linksWhere = { tenantId, projectId };
67
+ if (opts.jobId)
68
+ linksWhere.jobId = opts.jobId;
69
+ const links = await tx.discoveredLink.findMany({
70
+ where: linksWhere,
71
+ select: {
72
+ id: true,
73
+ fromScreenId: true,
74
+ toScreenId: true,
75
+ href: true,
76
+ text: true,
77
+ landmark: true,
78
+ linkType: true,
79
+ source: true,
80
+ confidence: true,
81
+ },
82
+ });
83
+ const screenIds = new Set(screens.map((s) => s.id));
84
+ const inboundCount = new Map();
85
+ for (const s of screens)
86
+ inboundCount.set(s.id, 0);
87
+ for (const l of links) {
88
+ if (l.toScreenId && screenIds.has(l.toScreenId)) {
89
+ inboundCount.set(l.toScreenId, (inboundCount.get(l.toScreenId) ?? 0) + 1);
90
+ }
91
+ }
92
+ // Build screen-to-group map and section colors
93
+ const screenToGroupMap = new Map();
94
+ const sectionColors = {};
95
+ for (const g of screenGroups) {
96
+ if (g.name && g.color) {
97
+ sectionColors[g.name] = g.color;
98
+ }
99
+ for (const sc of (g.screens ?? [])) {
100
+ screenToGroupMap.set(sc.id, { id: g.id, name: g.name, color: g.color });
101
+ }
102
+ }
103
+ // Compute BFS depth from entry points via discovered link edges
104
+ const adjacency = new Map(); // fromScreenId -> [toScreenId]
105
+ for (const l of links) {
106
+ if (!l.toScreenId)
107
+ continue;
108
+ if (!adjacency.has(l.fromScreenId))
109
+ adjacency.set(l.fromScreenId, []);
110
+ adjacency.get(l.fromScreenId).push(l.toScreenId);
111
+ }
112
+ const depths = new Map();
113
+ const entryIds = screens
114
+ .filter((s) => s.discoveryMethod === 'ENTRY_POINT' || s.discoveryMethod === 'SEED_URL')
115
+ .map((s) => s.id);
116
+ const bfsQueue = [...entryIds];
117
+ for (const id of entryIds)
118
+ depths.set(id, 0);
119
+ while (bfsQueue.length > 0) {
120
+ const cur = bfsQueue.shift();
121
+ const curDepth = depths.get(cur);
122
+ for (const next of (adjacency.get(cur) ?? [])) {
123
+ if (!depths.has(next)) {
124
+ depths.set(next, curDepth + 1);
125
+ bfsQueue.push(next);
126
+ }
127
+ }
128
+ }
129
+ const nodes = screens.map((s) => {
130
+ const assignedGroup = s.group ?? screenToGroupMap.get(s.id);
131
+ let section = assignedGroup?.name;
132
+ let sectionColor = assignedGroup?.color ?? null;
133
+ if (!section && screenGroups.length > 0) {
134
+ const text = `${s.url ?? ''} ${s.name ?? ''}`.toLowerCase();
135
+ const matched = screenGroups.find((g) => (g.slug && text.includes(g.slug.toLowerCase())) ||
136
+ (g.name && text.includes(g.name.toLowerCase())));
137
+ if (matched) {
138
+ section = matched.name;
139
+ sectionColor = matched.color;
140
+ }
141
+ }
142
+ if (!section) {
143
+ section = classifySection(s.url, s.name);
144
+ }
145
+ return {
146
+ id: s.id,
147
+ name: s.name,
148
+ url: s.url ?? null,
149
+ section,
150
+ sectionColor,
151
+ groupId: assignedGroup?.id ?? s.groupId ?? null,
152
+ depth: depths.get(s.id) ?? s.depth ?? null,
153
+ entryPoint: s.discoveryMethod === 'ENTRY_POINT' || s.discoveryMethod === 'SEED_URL',
154
+ discoveryMethod: s.discoveryMethod,
155
+ };
156
+ });
157
+ // Detect back-edges: edge goes to a node with lower or equal depth
158
+ const edges = links.map((l) => {
159
+ const fromDepth = depths.get(l.fromScreenId) ?? null;
160
+ const toDepth = l.toScreenId ? (depths.get(l.toScreenId) ?? null) : null;
161
+ const isBackEdge = fromDepth !== null && toDepth !== null && toDepth <= fromDepth;
162
+ return {
163
+ id: l.id,
164
+ fromScreenId: l.fromScreenId,
165
+ toScreenId: l.toScreenId ?? null,
166
+ href: l.href,
167
+ text: l.text ?? null,
168
+ landmark: l.landmark ?? null,
169
+ linkType: l.linkType,
170
+ source: l.source,
171
+ confidence: l.confidence,
172
+ isBackEdge,
173
+ };
174
+ });
175
+ const entryPoints = nodes.filter((n) => n.entryPoint).map((n) => n.id);
176
+ const orphans = nodes
177
+ .filter((n) => !n.entryPoint && (inboundCount.get(n.id) ?? 0) === 0)
178
+ .map((n) => n.id);
179
+ const sectionMap = {};
180
+ for (const node of nodes) {
181
+ if (!sectionMap[node.section])
182
+ sectionMap[node.section] = [];
183
+ sectionMap[node.section].push(node.id);
184
+ }
185
+ // Ensure all discovered screen groups with matching screens are reflected in sectionMap
186
+ for (const g of screenGroups) {
187
+ if (g.name && !sectionMap[g.name] && (g.screens?.length ?? 0) > 0) {
188
+ const matchingIds = g.screens.map((sc) => sc.id).filter((id) => screenIds.has(id));
189
+ if (matchingIds.length > 0) {
190
+ sectionMap[g.name] = matchingIds;
191
+ }
192
+ }
193
+ }
194
+ const depths2 = nodes.map((n) => n.depth).filter((d) => d !== null);
195
+ const maxDepth = depths2.length > 0 ? Math.max(...depths2) : null;
196
+ return {
197
+ nodes,
198
+ edges,
199
+ entryPoints,
200
+ orphans,
201
+ sections: sectionMap,
202
+ sectionColors,
203
+ stats: {
204
+ totalNodes: nodes.length,
205
+ totalEdges: edges.length,
206
+ maxDepth,
207
+ sections: Object.keys(sectionMap),
208
+ },
209
+ };
210
+ });
211
+ }
212
+ function toMermaid(graph) {
213
+ const lines = ['flowchart TD'];
214
+ // Group nodes by section as subgraphs
215
+ const sectionLabels = {
216
+ auth: 'Authentication',
217
+ dashboard: 'Dashboard',
218
+ settings: 'Settings',
219
+ admin: 'Admin',
220
+ app: 'Application',
221
+ };
222
+ for (const [section, nodeIds] of Object.entries(graph.sections)) {
223
+ if (!nodeIds.length)
224
+ continue;
225
+ const subId = section.replace(/[^a-zA-Z0-9_]/g, '_') || 'sec';
226
+ lines.push(` subgraph ${subId}["${sanitizeMermaid(sectionLabels[section] ?? section)}"]`);
227
+ for (const nodeId of nodeIds) {
228
+ const node = graph.nodes.find((n) => n.id === nodeId);
229
+ if (!node)
230
+ continue;
231
+ const shape = node.entryPoint ? `([${sanitizeMermaid(node.name)}])` : `[${sanitizeMermaid(node.name)}]`;
232
+ lines.push(` ${nodeId}${shape}`);
233
+ }
234
+ lines.push(' end');
235
+ }
236
+ // Edges
237
+ for (const edge of graph.edges) {
238
+ if (!edge.toScreenId)
239
+ continue;
240
+ const label = edge.text ? `|"${sanitizeMermaid(edge.text.slice(0, 30))}"| ` : '';
241
+ const arrow = edge.isBackEdge ? '-.->' : '-->';
242
+ lines.push(` ${edge.fromScreenId} ${arrow} ${label}${edge.toScreenId}`);
243
+ }
244
+ return lines.join('\n');
245
+ }
246
+ function sanitizeMermaid(s) {
247
+ return s.replace(/["[\](){}|>]/g, ' ').trim();
248
+ }
@@ -0,0 +1,26 @@
1
+ export type NotificationEvent = 'invite.created' | 'invite.received' | 'crawl.done' | 'crawl.failed' | 'test.generated' | 'test.run.done' | 'test.run.failed' | 'review.assigned' | 'token.budget.alert' | string;
2
+ export interface DispatchOptions {
3
+ tenantId: string;
4
+ userId: string;
5
+ type: NotificationEvent;
6
+ title: string;
7
+ body: string;
8
+ metadata?: Record<string, unknown>;
9
+ /** extra email fields — only used if email pref enabled */
10
+ email?: {
11
+ recipientEmail: string;
12
+ recipientName?: string;
13
+ };
14
+ }
15
+ export interface DispatchResult {
16
+ emailSent: boolean;
17
+ emailReason?: string;
18
+ }
19
+ /** Dispatch a single notification (in-app + email if prefs allow). Fire-and-forget safe. */
20
+ export declare function dispatchNotification(opts: DispatchOptions): Promise<DispatchResult>;
21
+ /** Dispatch to all active members of a tenant (respecting each user's in-app and email preferences) */
22
+ export declare function dispatchToTenantMembers(tenantId: string, opts: Omit<DispatchOptions, 'tenantId' | 'userId' | 'email'>): Promise<void>;
23
+ /** Dispatch to all members of a tenant (alias to dispatchToTenantMembers) */
24
+ export declare function dispatchToTenantAdmins(tenantId: string, opts: Omit<DispatchOptions, 'tenantId' | 'userId' | 'email'>): Promise<void>;
25
+ /** Dispatch to all project members */
26
+ export declare function dispatchToProjectMembers(tenantId: string, _projectId: string, opts: Omit<DispatchOptions, 'tenantId' | 'userId' | 'email'>): Promise<void>;
@@ -0,0 +1,105 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.dispatchNotification = dispatchNotification;
7
+ exports.dispatchToTenantMembers = dispatchToTenantMembers;
8
+ exports.dispatchToTenantAdmins = dispatchToTenantAdmins;
9
+ exports.dispatchToProjectMembers = dispatchToProjectMembers;
10
+ const node_crypto_1 = __importDefault(require("node:crypto"));
11
+ const database_1 = require("@taodigital/database");
12
+ const email_js_1 = require("./email.js");
13
+ /** Dispatch a single notification (in-app + email if prefs allow). Fire-and-forget safe. */
14
+ async function dispatchNotification(opts) {
15
+ // 1. Check user preference (default: both enabled if no row exists)
16
+ // Check exact eventType, or normalized (dots vs underscores, e.g. test.run.failed vs test_run_failed)
17
+ const eventTypesToCheck = [
18
+ opts.type,
19
+ opts.type.replace(/_/g, '.'),
20
+ opts.type.replace(/\./g, '_'),
21
+ ];
22
+ if (opts.type === 'invite.created')
23
+ eventTypesToCheck.push('invite.received');
24
+ if (opts.type === 'invite.received')
25
+ eventTypesToCheck.push('invite.created');
26
+ const pref = await (0, database_1.withTenant)(opts.tenantId, (tx) => tx.notificationPreference.findFirst({
27
+ where: {
28
+ userId: opts.userId,
29
+ tenantId: opts.tenantId,
30
+ eventType: { in: eventTypesToCheck },
31
+ },
32
+ })).catch(() => null);
33
+ const inAppEnabled = pref ? pref.inApp : true;
34
+ const emailEnabled = pref ? pref.email : true;
35
+ // 2. Create in-app notification
36
+ if (inAppEnabled) {
37
+ await (0, database_1.withTenant)(opts.tenantId, (tx) => tx.notification.create({
38
+ data: {
39
+ id: `notif_${node_crypto_1.default.randomUUID()}`,
40
+ tenantId: opts.tenantId,
41
+ userId: opts.userId,
42
+ type: opts.type,
43
+ title: opts.title,
44
+ body: opts.body,
45
+ metadata: opts.metadata,
46
+ },
47
+ })).catch(console.error);
48
+ }
49
+ // 3. Send email if enabled
50
+ if (emailEnabled) {
51
+ let recipientEmail = opts.email?.recipientEmail;
52
+ let recipientName = opts.email?.recipientName;
53
+ // Resolve user's registered email if not explicitly provided
54
+ if (!recipientEmail && opts.userId) {
55
+ const u = await (0, database_1.withTenant)(opts.tenantId, (tx) => tx.user.findUnique({
56
+ where: { id: opts.userId },
57
+ select: { email: true, name: true },
58
+ })).catch(() => null);
59
+ if (u?.email) {
60
+ recipientEmail = u.email;
61
+ recipientName = u.name ?? undefined;
62
+ }
63
+ }
64
+ if (recipientEmail) {
65
+ try {
66
+ const result = await (0, email_js_1.sendEmail)({
67
+ to: recipientEmail,
68
+ name: recipientName,
69
+ type: opts.type,
70
+ title: opts.title,
71
+ body: opts.body,
72
+ metadata: opts.metadata,
73
+ tenantId: opts.tenantId,
74
+ });
75
+ return { emailSent: result.sent, emailReason: result.reason };
76
+ }
77
+ catch (err) {
78
+ console.error('[notifications] sendEmail error:', err);
79
+ return { emailSent: false, emailReason: err?.message ?? String(err) };
80
+ }
81
+ }
82
+ }
83
+ return { emailSent: false, emailReason: emailEnabled ? 'No recipient email available.' : 'Recipient disabled email for this event.' };
84
+ }
85
+ /** Dispatch to all active members of a tenant (respecting each user's in-app and email preferences) */
86
+ async function dispatchToTenantMembers(tenantId, opts) {
87
+ const members = await (0, database_1.withTenant)(tenantId, (tx) => tx.membership.findMany({
88
+ where: { tenantId, status: 'ACTIVE' },
89
+ include: { user: { select: { email: true, name: true } } },
90
+ })).catch(() => []);
91
+ await Promise.all(members.map((m) => dispatchNotification({
92
+ ...opts,
93
+ tenantId,
94
+ userId: m.userId,
95
+ email: m.user?.email ? { recipientEmail: m.user.email, recipientName: m.user.name ?? undefined } : undefined,
96
+ })));
97
+ }
98
+ /** Dispatch to all members of a tenant (alias to dispatchToTenantMembers) */
99
+ async function dispatchToTenantAdmins(tenantId, opts) {
100
+ return dispatchToTenantMembers(tenantId, opts);
101
+ }
102
+ /** Dispatch to all project members */
103
+ async function dispatchToProjectMembers(tenantId, _projectId, opts) {
104
+ return dispatchToTenantMembers(tenantId, opts);
105
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Orphan-page classification — pure logic, no I/O, so it's unit-testable in
3
+ * isolation. See docs/research/25-orphan-page-detection-industry-comparison.md
4
+ * for the industry comparison and design rationale this implements.
5
+ */
6
+ export type DiscoveryMethod = 'ENTRY_POINT' | 'LINK_FOLLOW' | 'SEED_URL' | 'SKYVERN_AI';
7
+ export type OrphanClassification = 'REACHABLE' | 'GRAPH_ORPHAN' | 'SITEMAP_ORPHAN' | 'UNVERIFIED_ORPHAN';
8
+ /** Classifies a single screen given its discovery method and whether the KAG graph
9
+ * has at least one inbound Element.transitionsToScreenId edge pointing to it. */
10
+ export declare function classifyScreen(discoveryMethod: string, hasInboundEdge: boolean): OrphanClassification;
11
+ export interface OrphanScreenResult {
12
+ id: string;
13
+ name: string;
14
+ url: string | null;
15
+ discoveryMethod: string;
16
+ classification: OrphanClassification;
17
+ }
18
+ export interface OrphanReport {
19
+ screens: OrphanScreenResult[];
20
+ totalScreens: number;
21
+ reachableCount: number;
22
+ orphanCount: number;
23
+ /** orphanCount / totalScreens as a percentage, 0 when there are no screens at all. */
24
+ orphanPercent: number;
25
+ }
26
+ /** Builds the full per-project orphan report from raw screen + inbound-edge data. */
27
+ export declare function buildOrphanReport(screens: Array<{
28
+ id: string;
29
+ name: string;
30
+ url: string | null;
31
+ discoveryMethod: string;
32
+ }>, screenIdsWithInboundEdge: Set<string>): OrphanReport;
@@ -0,0 +1,40 @@
1
+ "use strict";
2
+ /**
3
+ * Orphan-page classification — pure logic, no I/O, so it's unit-testable in
4
+ * isolation. See docs/research/25-orphan-page-detection-industry-comparison.md
5
+ * for the industry comparison and design rationale this implements.
6
+ */
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.classifyScreen = classifyScreen;
9
+ exports.buildOrphanReport = buildOrphanReport;
10
+ // less trustworthy until KAG link-wiring covers that path too
11
+ /** Classifies a single screen given its discovery method and whether the KAG graph
12
+ * has at least one inbound Element.transitionsToScreenId edge pointing to it. */
13
+ function classifyScreen(discoveryMethod, hasInboundEdge) {
14
+ if (discoveryMethod === 'ENTRY_POINT' || hasInboundEdge)
15
+ return 'REACHABLE';
16
+ if (discoveryMethod === 'SEED_URL')
17
+ return 'SITEMAP_ORPHAN';
18
+ if (discoveryMethod === 'SKYVERN_AI')
19
+ return 'UNVERIFIED_ORPHAN';
20
+ return 'GRAPH_ORPHAN';
21
+ }
22
+ /** Builds the full per-project orphan report from raw screen + inbound-edge data. */
23
+ function buildOrphanReport(screens, screenIdsWithInboundEdge) {
24
+ const classified = screens.map((s) => ({
25
+ id: s.id,
26
+ name: s.name,
27
+ url: s.url,
28
+ discoveryMethod: s.discoveryMethod,
29
+ classification: classifyScreen(s.discoveryMethod, screenIdsWithInboundEdge.has(s.id)),
30
+ }));
31
+ const reachableCount = classified.filter((s) => s.classification === 'REACHABLE').length;
32
+ const orphanCount = classified.length - reachableCount;
33
+ return {
34
+ screens: classified,
35
+ totalScreens: classified.length,
36
+ reachableCount,
37
+ orphanCount,
38
+ orphanPercent: classified.length > 0 ? Math.round((orphanCount / classified.length) * 1000) / 10 : 0,
39
+ };
40
+ }