@fayz-ai/saas 0.8.0 → 0.8.2

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 (236) hide show
  1. package/dist/access/context.d.ts +47 -0
  2. package/dist/access/context.d.ts.map +1 -0
  3. package/dist/access/headless.d.ts +21 -0
  4. package/dist/access/headless.d.ts.map +1 -0
  5. package/dist/access/index.d.ts +12 -0
  6. package/dist/access/index.d.ts.map +1 -0
  7. package/dist/access/limits-registry.d.ts +33 -0
  8. package/dist/access/limits-registry.d.ts.map +1 -0
  9. package/dist/access/module-nav.d.ts +15 -0
  10. package/dist/access/module-nav.d.ts.map +1 -0
  11. package/dist/access/resolver.d.ts +2 -0
  12. package/dist/access/resolver.d.ts.map +1 -0
  13. package/dist/access/types.d.ts +43 -0
  14. package/dist/access/types.d.ts.map +1 -0
  15. package/dist/access/upgrade-modal-store.d.ts +23 -0
  16. package/dist/access/upgrade-modal-store.d.ts.map +1 -0
  17. package/dist/app/AdminShell.d.ts +3 -0
  18. package/dist/app/AdminShell.d.ts.map +1 -1
  19. package/dist/app/admin-app.d.ts +3 -1
  20. package/dist/app/admin-app.d.ts.map +1 -1
  21. package/dist/app/config.d.ts +47 -2
  22. package/dist/app/config.d.ts.map +1 -1
  23. package/dist/app/core-entities.d.ts +25 -0
  24. package/dist/app/core-entities.d.ts.map +1 -0
  25. package/dist/app/derive-contract.d.ts +8 -0
  26. package/dist/app/derive-contract.d.ts.map +1 -0
  27. package/dist/app/scaffold.d.ts.map +1 -1
  28. package/dist/archetype-lookup.d.ts.map +1 -1
  29. package/dist/billing/index.cjs +6 -2
  30. package/dist/billing/index.d.ts +2 -0
  31. package/dist/billing/index.d.ts.map +1 -1
  32. package/dist/billing/index.js +1 -1
  33. package/dist/billing/plan-badge.d.ts +17 -0
  34. package/dist/billing/plan-badge.d.ts.map +1 -0
  35. package/dist/billing/store.d.ts +6 -1
  36. package/dist/billing/store.d.ts.map +1 -1
  37. package/dist/{chunk-3M25GRRU.js → chunk-I3DY47N5.js} +3 -4
  38. package/dist/chunk-I3DY47N5.js.map +1 -0
  39. package/dist/chunk-NNFGMUF2.cjs +39 -0
  40. package/dist/chunk-NNFGMUF2.cjs.map +1 -0
  41. package/dist/{chunk-4XTQ4V5L.cjs → chunk-SAZLW54Y.cjs} +16 -12
  42. package/dist/chunk-SAZLW54Y.cjs.map +1 -0
  43. package/dist/{chunk-IPWCHJPE.cjs → chunk-VKYZACG5.cjs} +5 -6
  44. package/dist/chunk-VKYZACG5.cjs.map +1 -0
  45. package/dist/chunk-VN5NTK26.js +36 -0
  46. package/dist/chunk-VN5NTK26.js.map +1 -0
  47. package/dist/{chunk-XNWCGJYH.js → chunk-VTEP2AQX.js} +15 -11
  48. package/dist/chunk-VTEP2AQX.js.map +1 -0
  49. package/dist/components/shared/ContactPicker.d.ts +92 -0
  50. package/dist/components/shared/ContactPicker.d.ts.map +1 -0
  51. package/dist/components/shared/PersonLink.d.ts +12 -1
  52. package/dist/components/shared/PersonLink.d.ts.map +1 -1
  53. package/dist/components/shared/create-person.d.ts +20 -0
  54. package/dist/components/shared/create-person.d.ts.map +1 -0
  55. package/dist/crud/CrudDetailPage.d.ts.map +1 -1
  56. package/dist/crud/CrudFormPage.d.ts.map +1 -1
  57. package/dist/crud/CrudListView.d.ts.map +1 -1
  58. package/dist/crud/CrudPage.d.ts.map +1 -1
  59. package/dist/crud/archetypes/AddressesTab.d.ts +21 -0
  60. package/dist/crud/archetypes/AddressesTab.d.ts.map +1 -0
  61. package/dist/crud/archetypes/PersonDetailTabs.d.ts +2 -1
  62. package/dist/crud/archetypes/PersonDetailTabs.d.ts.map +1 -1
  63. package/dist/crud/createCrudPage.d.ts.map +1 -1
  64. package/dist/crud/index.d.ts +1 -0
  65. package/dist/crud/index.d.ts.map +1 -1
  66. package/dist/index.cjs +4300 -722
  67. package/dist/index.cjs.map +1 -1
  68. package/dist/index.d.ts +17 -2
  69. package/dist/index.d.ts.map +1 -1
  70. package/dist/index.js +4080 -542
  71. package/dist/index.js.map +1 -1
  72. package/dist/org/index.cjs +11 -11
  73. package/dist/org/index.js +2 -2
  74. package/dist/org/store.d.ts.map +1 -1
  75. package/dist/permissions/context.d.ts +2 -0
  76. package/dist/permissions/context.d.ts.map +1 -1
  77. package/dist/permissions/index.cjs +7 -7
  78. package/dist/permissions/index.js +1 -1
  79. package/dist/shell/components/auth/ProtectedRoute.d.ts.map +1 -1
  80. package/dist/shell/components/billing/SoftLimitBanner.d.ts +5 -0
  81. package/dist/shell/components/billing/SoftLimitBanner.d.ts.map +1 -0
  82. package/dist/shell/components/billing/SubscriptionPage.d.ts +5 -0
  83. package/dist/shell/components/billing/SubscriptionPage.d.ts.map +1 -0
  84. package/dist/shell/components/billing/UpgradeModal.d.ts +3 -0
  85. package/dist/shell/components/billing/UpgradeModal.d.ts.map +1 -0
  86. package/dist/shell/components/billing/UpgradePrompt.d.ts +10 -0
  87. package/dist/shell/components/billing/UpgradePrompt.d.ts.map +1 -0
  88. package/dist/shell/components/billing/access-contract.d.ts +18 -0
  89. package/dist/shell/components/billing/access-contract.d.ts.map +1 -0
  90. package/dist/shell/components/billing/gates.d.ts +14 -0
  91. package/dist/shell/components/billing/gates.d.ts.map +1 -0
  92. package/dist/shell/components/billing/index.d.ts +9 -2
  93. package/dist/shell/components/billing/index.d.ts.map +1 -1
  94. package/dist/shell/components/chat/ChatFab.d.ts +3 -1
  95. package/dist/shell/components/chat/ChatFab.d.ts.map +1 -1
  96. package/dist/shell/components/chat/ChatPanel.d.ts +3 -1
  97. package/dist/shell/components/chat/ChatPanel.d.ts.map +1 -1
  98. package/dist/shell/components/chat/ConfirmActionCard.d.ts +13 -0
  99. package/dist/shell/components/chat/ConfirmActionCard.d.ts.map +1 -0
  100. package/dist/shell/components/chat/markdown.d.ts +5 -0
  101. package/dist/shell/components/chat/markdown.d.ts.map +1 -0
  102. package/dist/shell/components/organization/TeamTab.d.ts.map +1 -1
  103. package/dist/shell/components/organization/TenantOnboarding.d.ts.map +1 -1
  104. package/dist/shell/components/organization/index.d.ts +0 -1
  105. package/dist/shell/components/organization/index.d.ts.map +1 -1
  106. package/dist/shell/components/settings/ConnectedCompanySettings.d.ts.map +1 -1
  107. package/dist/shell/components/settings/ConnectedFieldRulesSettings.d.ts.map +1 -1
  108. package/dist/shell/components/settings/ConnectedSecuritySettings.d.ts.map +1 -1
  109. package/dist/shell/components/settings/LocationsCrudPage.d.ts +1 -1
  110. package/dist/shell/components/settings/LocationsCrudPage.d.ts.map +1 -1
  111. package/dist/shell/components/settings/SettingsPage.d.ts +23 -2
  112. package/dist/shell/components/settings/SettingsPage.d.ts.map +1 -1
  113. package/dist/shell/hooks/index.d.ts +3 -2
  114. package/dist/shell/hooks/index.d.ts.map +1 -1
  115. package/dist/shell/hooks/useAITools.d.ts +2 -0
  116. package/dist/shell/hooks/useAITools.d.ts.map +1 -1
  117. package/dist/shell/hooks/useChat.d.ts +22 -0
  118. package/dist/shell/hooks/useChat.d.ts.map +1 -1
  119. package/dist/shell/hooks/useSystemPermission.d.ts +3 -0
  120. package/dist/shell/hooks/useSystemPermission.d.ts.map +1 -0
  121. package/dist/shell/hooks/useTenantPluginSettings.d.ts +12 -0
  122. package/dist/shell/hooks/useTenantPluginSettings.d.ts.map +1 -0
  123. package/dist/shell/lib/ai-tool-handlers.d.ts +119 -0
  124. package/dist/shell/lib/ai-tool-handlers.d.ts.map +1 -0
  125. package/dist/shell/lib/core-ai-tools.d.ts +14 -0
  126. package/dist/shell/lib/core-ai-tools.d.ts.map +1 -1
  127. package/dist/shell/lib/create-client-orders-provider.d.ts.map +1 -1
  128. package/dist/shell/lib/fayz-agent.d.ts +23 -0
  129. package/dist/shell/lib/fayz-agent.d.ts.map +1 -0
  130. package/dist/shell/lib/i18n.d.ts.map +1 -1
  131. package/dist/shell/stores/chat.store.d.ts +69 -0
  132. package/dist/shell/stores/chat.store.d.ts.map +1 -1
  133. package/dist/shell/types/billing.d.ts +7 -0
  134. package/dist/shell/types/billing.d.ts.map +1 -1
  135. package/dist/shell/types/crud.d.ts +1 -1
  136. package/dist/shell/types/crud.d.ts.map +1 -1
  137. package/dist/shell/types/plugins.d.ts +2 -45
  138. package/dist/shell/types/plugins.d.ts.map +1 -1
  139. package/dist/stores/createCrudStore.d.ts.map +1 -1
  140. package/package.json +15 -12
  141. package/src/access/context.tsx +228 -0
  142. package/src/access/headless.ts +71 -0
  143. package/src/access/index.ts +27 -0
  144. package/src/access/limits-registry.ts +86 -0
  145. package/src/access/module-nav.test.ts +78 -0
  146. package/src/access/module-nav.ts +82 -0
  147. package/src/access/resolver.test.ts +143 -0
  148. package/src/access/resolver.ts +5 -0
  149. package/src/access/types.ts +47 -0
  150. package/src/access/upgrade-modal-store.ts +30 -0
  151. package/src/app/AdminShell.tsx +451 -68
  152. package/src/app/admin-app.tsx +46 -9
  153. package/src/app/config.ts +54 -1
  154. package/src/app/core-entities.ts +110 -0
  155. package/src/app/derive-contract.test.ts +155 -0
  156. package/src/app/derive-contract.ts +214 -0
  157. package/src/app/scaffold.tsx +4 -0
  158. package/src/archetype-lookup.ts +8 -2
  159. package/src/billing/index.ts +2 -0
  160. package/src/billing/plan-badge.ts +32 -0
  161. package/src/billing/store.ts +8 -1
  162. package/src/components/shared/ContactPicker.tsx +394 -0
  163. package/src/components/shared/PersonLink.tsx +11 -3
  164. package/src/components/shared/create-person.ts +74 -0
  165. package/src/crud/CrudDetailPage.tsx +8 -1
  166. package/src/crud/CrudFormPage.tsx +124 -6
  167. package/src/crud/CrudListView.tsx +16 -1
  168. package/src/crud/CrudPage.tsx +27 -5
  169. package/src/crud/DeleteConfirmDialog.tsx +1 -1
  170. package/src/crud/archetypes/AddressesTab.tsx +387 -0
  171. package/src/crud/archetypes/PersonDetailTabs.tsx +5 -0
  172. package/src/crud/createCrudPage.tsx +2 -0
  173. package/src/crud/index.ts +6 -0
  174. package/src/env.d.ts +7 -0
  175. package/src/index.ts +49 -1
  176. package/src/org/store.ts +9 -1
  177. package/src/permissions/context.tsx +29 -15
  178. package/src/shell/components/auth/ProtectedRoute.tsx +12 -2
  179. package/src/shell/components/billing/SoftLimitBanner.tsx +81 -0
  180. package/src/shell/components/billing/SubscriptionPage.tsx +372 -0
  181. package/src/shell/components/billing/UpgradeModal.tsx +102 -0
  182. package/src/shell/components/billing/UpgradePrompt.tsx +229 -0
  183. package/src/shell/components/billing/access-contract.ts +64 -0
  184. package/src/shell/components/billing/gates.tsx +62 -0
  185. package/src/shell/components/billing/index.ts +18 -2
  186. package/src/shell/components/chat/ChatFab.tsx +4 -2
  187. package/src/shell/components/chat/ChatPanel.tsx +174 -23
  188. package/src/shell/components/chat/ConfirmActionCard.tsx +57 -0
  189. package/src/shell/components/chat/markdown.tsx +102 -0
  190. package/src/shell/components/organization/TeamTab.tsx +21 -4
  191. package/src/shell/components/organization/TenantOnboarding.tsx +3 -1
  192. package/src/shell/components/organization/index.ts +0 -1
  193. package/src/shell/components/settings/ConnectedCompanySettings.tsx +4 -1
  194. package/src/shell/components/settings/ConnectedFieldRulesSettings.tsx +3 -1
  195. package/src/shell/components/settings/ConnectedSecuritySettings.tsx +3 -1
  196. package/src/shell/components/settings/LocationsCrudPage.ts +6 -1
  197. package/src/shell/components/settings/SettingsPage.tsx +63 -21
  198. package/src/shell/hooks/index.ts +3 -2
  199. package/src/shell/hooks/useAITools.ts +27 -23
  200. package/src/shell/hooks/useChat.ts +381 -16
  201. package/src/shell/hooks/useSystemPermission.ts +23 -0
  202. package/src/shell/hooks/useTenantPluginSettings.ts +127 -0
  203. package/src/shell/lib/ai-tool-handlers.ts +910 -0
  204. package/src/shell/lib/core-ai-tools.ts +234 -2
  205. package/src/shell/lib/create-client-orders-provider.ts +24 -2
  206. package/src/shell/lib/fayz-agent.ts +56 -0
  207. package/src/shell/lib/i18n.ts +71 -0
  208. package/src/shell/stores/chat.store.ts +118 -1
  209. package/src/shell/styles.css +30 -0
  210. package/src/shell/types/billing.ts +8 -0
  211. package/src/shell/types/crud.ts +1 -1
  212. package/src/shell/types/plugins.ts +13 -50
  213. package/src/stores/createCrudStore.ts +9 -0
  214. package/dist/chunk-3M25GRRU.js.map +0 -1
  215. package/dist/chunk-4XTQ4V5L.cjs.map +0 -1
  216. package/dist/chunk-5GYQK5IW.js +0 -21
  217. package/dist/chunk-5GYQK5IW.js.map +0 -1
  218. package/dist/chunk-IPWCHJPE.cjs.map +0 -1
  219. package/dist/chunk-KYID2MR6.cjs +0 -23
  220. package/dist/chunk-KYID2MR6.cjs.map +0 -1
  221. package/dist/chunk-XNWCGJYH.js.map +0 -1
  222. package/dist/shell/components/billing/BillingPage.d.ts +0 -7
  223. package/dist/shell/components/billing/BillingPage.d.ts.map +0 -1
  224. package/dist/shell/components/billing/PaywallGate.d.ts +0 -11
  225. package/dist/shell/components/billing/PaywallGate.d.ts.map +0 -1
  226. package/dist/shell/components/organization/PermissionGate.d.ts +0 -11
  227. package/dist/shell/components/organization/PermissionGate.d.ts.map +0 -1
  228. package/dist/shell/hooks/useBilling.d.ts +0 -14
  229. package/dist/shell/hooks/useBilling.d.ts.map +0 -1
  230. package/dist/shell/hooks/usePermission.d.ts +0 -16
  231. package/dist/shell/hooks/usePermission.d.ts.map +0 -1
  232. package/src/shell/components/billing/BillingPage.tsx +0 -56
  233. package/src/shell/components/billing/PaywallGate.tsx +0 -80
  234. package/src/shell/components/organization/PermissionGate.tsx +0 -16
  235. package/src/shell/hooks/useBilling.ts +0 -151
  236. package/src/shell/hooks/usePermission.ts +0 -101
@@ -0,0 +1,910 @@
1
+ import { resolveDataProvider, getSupabaseClientOptional, getActiveTenantId } from '@fayz-ai/core'
2
+ import { checkAccess, guardLimit } from '../../access/headless'
3
+ import { invalidateLimit } from '../../access/limits-registry'
4
+ import { CORE_QUERY_ENTITIES } from '../../app/core-entities'
5
+ import { useChatStore } from '../stores/chat.store'
6
+
7
+ /** Chat-store confirmation gate, callable from any handler. */
8
+ function requestChatConfirmation(action: {
9
+ id: string
10
+ toolName: string
11
+ title: string
12
+ params: Record<string, unknown>
13
+ plane: 'client' | 'server'
14
+ }): Promise<boolean> {
15
+ return useChatStore.getState().requestConfirmation(action)
16
+ }
17
+ import type { EntityDef, OrgMember, Organization } from '@fayz-ai/core'
18
+ import type { PluginAITool, PluginRegistryDef } from '../types/plugins'
19
+
20
+ /**
21
+ * Client-plane execution for the AI tool catalog.
22
+ *
23
+ * `useAITools()` declares *what* the surface can do — it is the catalog the
24
+ * model reasons over. This module is *how*: the browser runs the call against
25
+ * the session that is already open (org store, data providers, router) and
26
+ * hands a serialized result back to the agent.
27
+ *
28
+ * The split matters because Fayz never holds the surface's data credentials.
29
+ * The agent broker forwards the catalog, decides which tool to call, and
30
+ * returns the call for the surface to execute — the same contract a
31
+ * server-side channel (WhatsApp, email) satisfies with a server-side executor.
32
+ *
33
+ * **A capability is only advertised if it can run.** A declared tool with no
34
+ * executor is a promise to the model the surface cannot keep: it burns
35
+ * reasoning rounds retrying, then apologises. `buildAgentToolset` is the gate —
36
+ * everything it returns has a resolvable executor.
37
+ */
38
+
39
+ /** An entity the executor can read through its own data provider. */
40
+ export interface DataToolTarget {
41
+ label: string
42
+ entity: EntityDef
43
+ mockData?: Array<{ id: string }>
44
+ /** Read-model (view/ledger): searchable/queryable but never writable. */
45
+ readOnly?: boolean
46
+ }
47
+
48
+ export interface AIToolExecutionContext {
49
+ currentOrg: Organization | null
50
+ members: OrgMember[]
51
+ currentPath: string
52
+ /** Pages the surface exposes, used to resolve navigation requests. */
53
+ routes: Array<{ path: string; label?: string }>
54
+ navigate: (path: string) => void
55
+ /**
56
+ * Data-backed tools, keyed by the LLM-facing tool name. These execute
57
+ * generically through the entity's own data provider, so every registry and
58
+ * every CRUD entity the app declares is readable without a hand-written
59
+ * handler — which is what lets a new vertical work on day one.
60
+ */
61
+ dataTools: Map<string, DataToolTarget>
62
+ }
63
+
64
+ export type AIToolHandler = (
65
+ args: Record<string, unknown>,
66
+ context: AIToolExecutionContext,
67
+ ) => unknown | Promise<unknown>
68
+
69
+ const handlers = new Map<string, AIToolHandler>()
70
+
71
+ /**
72
+ * Register an executor for a tool, keyed by the tool's `name` (the identifier
73
+ * the model calls, e.g. `getTeamMembers`). Plugins and apps call this to make
74
+ * their declared `aiTools` actually runnable.
75
+ */
76
+ export function registerAIToolHandler(name: string, handler: AIToolHandler): void {
77
+ handlers.set(name, handler)
78
+ }
79
+
80
+ export function registerAIToolHandlers(map: Record<string, AIToolHandler>): void {
81
+ for (const [name, handler] of Object.entries(map)) registerAIToolHandler(name, handler)
82
+ }
83
+
84
+ export function hasAIToolHandler(name: string): boolean {
85
+ return handlers.has(name)
86
+ }
87
+
88
+ /** Rows returned per data call — enough to answer, cheap in tokens. */
89
+ const REGISTRY_PAGE_SIZE = 20
90
+
91
+ /** Beyond this the catalog costs more in tokens than it buys in capability. */
92
+ const MAX_TOOLS_PER_TURN = 24
93
+
94
+ export interface AIToolExecutionResult {
95
+ ok: boolean
96
+ /** JSON-serialized payload handed back to the agent. */
97
+ content: string
98
+ }
99
+
100
+ function errorResult(error: string, message: string): AIToolExecutionResult {
101
+ return { ok: false, content: JSON.stringify({ error, message }) }
102
+ }
103
+
104
+ /** The documented contract: every returned record carries a semantic ref
105
+ * {id, resource, archetype} the surface can resolve to ITS OWN route — never
106
+ * a URL (agents.md: presentation stays client knowledge). */
107
+ function attachRefs(
108
+ entity: EntityDef,
109
+ rows: Array<Record<string, unknown>>,
110
+ ): Array<Record<string, unknown>> {
111
+ const archetype = entity.data?.archetype
112
+ if (!archetype) return rows
113
+ const kind = entity.data?.archetypeKind
114
+ return rows.map((r) =>
115
+ r.id
116
+ ? { ...r, ref: { id: r.id, resource: entity.data?.table, archetype: kind ? `${archetype}:${kind}` : archetype } }
117
+ : r,
118
+ )
119
+ }
120
+
121
+ function rowContainsText(row: Record<string, unknown>, needle: string): boolean {
122
+ const n = needle.toLowerCase()
123
+ return Object.values(row).some((v) => typeof v === 'string' && v.toLowerCase().includes(n))
124
+ }
125
+
126
+ async function executeDataTool(
127
+ target: DataToolTarget,
128
+ args: Record<string, unknown>,
129
+ ): Promise<unknown> {
130
+ const provider = resolveDataProvider(
131
+ target.entity as EntityDef<{ id: string }>,
132
+ target.mockData,
133
+ )
134
+ const search = typeof args.search === 'string' && args.search.trim() ? args.search.trim() : undefined
135
+ const result = await provider.list({
136
+ search,
137
+ pageSize: REGISTRY_PAGE_SIZE,
138
+ ...(typeof args.orderBy === 'string'
139
+ ? { sortBy: args.orderBy, sortDir: args.direction === 'asc' ? 'asc' : 'desc' }
140
+ : {}),
141
+ })
142
+ // Honesty guard: a provider with no searchable columns quietly IGNORES
143
+ // `search` and returns recent rows — which the model then presents as the
144
+ // thing it looked for ("REC-00012 vale R$6000" when no row matched at all).
145
+ // If nothing in the returned rows contains the text, say so explicitly.
146
+ const rows = result.data as Array<Record<string, unknown>>
147
+ const unmatched = !!search && rows.length > 0 && !rows.some((r) => rowContainsText(r, search))
148
+ return {
149
+ entity: target.label,
150
+ ...(search ? { search } : {}),
151
+ total: result.total,
152
+ // Truthful cap: the model is told the list was trimmed so it can say so
153
+ // rather than presenting one page as the whole set.
154
+ truncated: result.total > result.data.length,
155
+ records: attachRefs(target.entity, rows),
156
+ ...(unmatched
157
+ ? {
158
+ warning:
159
+ 'None of these rows contain the search text — they are RECENT records, not matches. Do NOT present them as the searched item; say it was not found or try another entity.',
160
+ }
161
+ : {}),
162
+ }
163
+ }
164
+
165
+ /** Tolerant field resolution: exact key, then camelCase, then snake_case. */
166
+ function resolveFieldKey(row: Record<string, unknown>, name: string): string | null {
167
+ if (name in row) return name
168
+ const camel = name.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase())
169
+ if (camel in row) return camel
170
+ const snake = name.replace(/[A-Z]/g, (c) => '_' + c.toLowerCase())
171
+ if (snake in row) return snake
172
+ return null
173
+ }
174
+
175
+ function isRangeObject(v: unknown): v is Record<string, unknown> {
176
+ return (
177
+ typeof v === 'object' &&
178
+ v !== null &&
179
+ !Array.isArray(v) &&
180
+ ('from' in v || 'to' in v || 'gte' in v || 'lte' in v || 'gt' in v || 'lt' in v)
181
+ )
182
+ }
183
+
184
+ function compareInRange(raw: unknown, range: Record<string, unknown>): boolean {
185
+ const asNumberOrTime = (v: unknown): number => {
186
+ if (typeof v === 'number') return v
187
+ const ts = Date.parse(String(v))
188
+ if (!Number.isNaN(ts)) return ts
189
+ return Number(v)
190
+ }
191
+ const value = asNumberOrTime(raw)
192
+ if (Number.isNaN(value)) return false
193
+ const lo = range.from ?? range.gte ?? range.gt
194
+ const hi = range.to ?? range.lte ?? range.lt
195
+ if (lo !== undefined && value < asNumberOrTime(lo)) return false
196
+ if (lo !== undefined && 'gt' in range && value <= asNumberOrTime(lo)) return false
197
+ if (hi !== undefined && value > asNumberOrTime(hi)) return false
198
+ if (hi !== undefined && 'lt' in range && value >= asNumberOrTime(hi)) return false
199
+ return true
200
+ }
201
+
202
+ export async function executeAITool(
203
+ name: string,
204
+ args: Record<string, unknown>,
205
+ context: AIToolExecutionContext,
206
+ ): Promise<AIToolExecutionResult> {
207
+ const handler = handlers.get(name)
208
+ const dataTarget = context.dataTools.get(name)
209
+
210
+ if (!handler && !dataTarget) {
211
+ return errorResult(
212
+ 'not_executable',
213
+ `The tool "${name}" is not available here. Do not retry it — tell the user what you cannot do, or use a different tool.`,
214
+ )
215
+ }
216
+
217
+ try {
218
+ const result = handler
219
+ ? await handler(args, context)
220
+ : await executeDataTool(dataTarget!, args)
221
+ return { ok: true, content: JSON.stringify(result ?? { ok: true }) }
222
+ } catch (error) {
223
+ return errorResult(
224
+ 'execution_failed',
225
+ error instanceof Error ? error.message : 'Unknown error',
226
+ )
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Index everything readable by the tool name its generator mints, so the
232
+ * executor can find the entity behind a call. Covers plugin registries and the
233
+ * app's own CRUD entities.
234
+ */
235
+ export function buildDataToolIndex(input: {
236
+ registries: Map<string, PluginRegistryDef[]>
237
+ entities: Array<{ entityKey: string; labelPlural: string; entityDef?: EntityDef; mockData?: Array<{ id: string }> }>
238
+ registryToolName: (pluginId: string, registryId: string) => string
239
+ entityToolName: (entityKey: string) => string
240
+ queryEntities?: Array<{ key: string; entity: EntityDef; writable?: boolean }>
241
+ }): Map<string, DataToolTarget> {
242
+ const index = new Map<string, DataToolTarget>()
243
+
244
+ for (const [pluginId, defs] of input.registries) {
245
+ for (const registry of defs) {
246
+ if (registry.readOnly) continue
247
+ const target = {
248
+ label: registry.entity.namePlural ?? registry.entity.name,
249
+ entity: registry.entity as EntityDef,
250
+ mockData: registry.mockData as Array<{ id: string }> | undefined,
251
+ }
252
+ index.set(input.registryToolName(pluginId, registry.id), target)
253
+ // Key-indexed twin: the data primitives (searchRecords/queryData) receive
254
+ // the TARGET as an argument instead of minting one tool per entity.
255
+ index.set(`key:${pluginId}:${registry.id}`, target)
256
+ }
257
+ }
258
+
259
+ for (const entity of input.entities) {
260
+ if (!entity.entityDef || entity.entityDef.agentHidden) continue
261
+ const target = {
262
+ label: entity.labelPlural,
263
+ entity: entity.entityDef,
264
+ mockData: entity.mockData,
265
+ }
266
+ index.set(input.entityToolName(entity.entityKey), target)
267
+ index.set(`key:${entity.entityKey}`, target)
268
+ }
269
+
270
+ // CORE_QUERY_ENTITIES merged here — same reason buildDataPrimitiveTools does
271
+ // it: this index and that catalog MUST agree, or the model is offered an
272
+ // entity the executor then rejects as unknown.
273
+ for (const q of [...(input.queryEntities ?? []), ...CORE_QUERY_ENTITIES]) {
274
+ index.set(`key:${q.key}`, {
275
+ label: q.entity.namePlural ?? q.entity.name,
276
+ entity: q.entity,
277
+ readOnly: !q.writable,
278
+ })
279
+ }
280
+
281
+ return index
282
+ }
283
+
284
+ // ---------------------------------------------------------------------------
285
+ // Generic data primitives — ONE search + ONE aggregate over ANY entity. The
286
+ // target's own read permission (EntityDef.permission) is checked per call, so
287
+ // consolidating the catalog never widens access: the same role×plan engine
288
+ // that gated the per-entity tools gates the parameterized target.
289
+ // ---------------------------------------------------------------------------
290
+
291
+ function resolveKeyedTarget(
292
+ ctx: AIToolExecutionContext,
293
+ args: Record<string, unknown>,
294
+ ): { target: DataToolTarget } | { error: unknown } {
295
+ const key = typeof args.entity === 'string' ? args.entity : ''
296
+ const target = ctx.dataTools.get(`key:${key}`)
297
+ if (!target) {
298
+ return {
299
+ error: {
300
+ error: 'unknown_entity',
301
+ message: `No entity "${key}" is available. Use one of the keys listed in the tool description.`,
302
+ },
303
+ }
304
+ }
305
+ const permission = target.entity.permission
306
+ if (permission) {
307
+ const access = checkAccess(permission.feature, permission.action)
308
+ if (!access.allowed) return { error: access }
309
+ }
310
+ return { target }
311
+ }
312
+
313
+ /** Field names are equal ignoring case and word separators, so a model sending
314
+ * `postalCode` matches a `postal_code` column and vice versa. */
315
+ function sameField(a: string, b: string): boolean {
316
+ const norm = (s: string) => s.replace(/[_-]/g, '').toLowerCase()
317
+ return norm(a) === norm(b)
318
+ }
319
+
320
+ /**
321
+ * Match a call's `values` against the target entity's declared fields,
322
+ * tolerating a snake_case/camelCase mismatch either way.
323
+ *
324
+ * Deliberately does NOT try to guess which other entity the caller "meant".
325
+ * Scoring candidates by how many of the rejected field names they declare was
326
+ * tried and is actively harmful here: for {address, city, postalCode, state,
327
+ * country} the salon app's `location` and `local-de-atendimento` both score 5
328
+ * against `address`'s 4, so "add an address to this contact" would be redirected
329
+ * into creating a business LOCATION. Field names alone cannot tell a child
330
+ * record from an unrelated table that happens to describe a place; the guidance
331
+ * that does work is declared up front, in the tool description.
332
+ */
333
+ function matchDeclaredFields(
334
+ target: DataToolTarget,
335
+ values: Record<string, unknown>,
336
+ ): { clean: Record<string, unknown> } | { error: Record<string, unknown> } {
337
+ const declaredKeys = (target.entity.fields ?? []).map((f) => f.key)
338
+ const clean: Record<string, unknown> = {}
339
+ const unknown: string[] = []
340
+ for (const [k, v] of Object.entries(values)) {
341
+ const match = declaredKeys.find((d) => sameField(d, k)) ?? null
342
+ if (match) clean[match] = v
343
+ else unknown.push(k)
344
+ }
345
+ if (!unknown.length) return { clean }
346
+
347
+ return {
348
+ error: {
349
+ error: 'unknown_field',
350
+ message: `Unknown field(s) ${unknown.join(', ')} for ${target.label}. Use only the available fields and retry.`,
351
+ availableFields: declaredKeys,
352
+ },
353
+ }
354
+ }
355
+
356
+ /**
357
+ * For every PERSON matched by a global search, pull the records that hang off
358
+ * it (today: addresses) and return them grouped by entity key. Returns null
359
+ * when nothing is attached, so the payload stays quiet in the common case.
360
+ */
361
+ async function resolveAttachedRecords(
362
+ ctx: AIToolExecutionContext,
363
+ matches: Array<{ key: string; records: unknown[] }>,
364
+ ): Promise<Record<string, unknown[]> | null> {
365
+ const ownerIds = new Set<string>()
366
+ for (const m of matches) {
367
+ const target = ctx.dataTools.get(`key:${m.key}`)
368
+ if (target?.entity.data?.archetype !== 'person') continue
369
+ for (const r of m.records) {
370
+ const id = (r as { id?: unknown }).id
371
+ if (typeof id === 'string') ownerIds.add(id)
372
+ }
373
+ }
374
+ if (!ownerIds.size) return null
375
+
376
+ const out: Record<string, unknown[]> = {}
377
+ for (const q of CORE_QUERY_ENTITIES) {
378
+ const target = ctx.dataTools.get(`key:${q.key}`)
379
+ if (!target) continue
380
+ const permission = target.entity.permission
381
+ if (permission && !checkAccess(permission.feature, permission.action).allowed) continue
382
+ try {
383
+ const provider = resolveDataProvider(target.entity as EntityDef<{ id: string }>, target.mockData)
384
+ const result = await provider.list({ pageSize: 50 })
385
+ const rows = (result.data as Array<Record<string, unknown>>).filter((row) => {
386
+ const owner = row.ownerId ?? row.owner_id
387
+ return typeof owner === 'string' && ownerIds.has(owner)
388
+ })
389
+ if (rows.length) out[q.key] = rows
390
+ } catch {
391
+ // An attachment lookup must never sink the search it decorates.
392
+ }
393
+ }
394
+ return Object.keys(out).length ? out : null
395
+ }
396
+
397
+ registerAIToolHandler('findAnything', async (args, ctx) => {
398
+ const search = typeof args.search === 'string' ? args.search.trim() : ''
399
+ if (!search) return { error: 'missing_search', message: 'Provide the name or text to look up.' }
400
+
401
+ // Deduped key-indexed targets, permission-filtered PER TARGET (the palette
402
+ // semantics: what you cannot see is not searched), person-archetypes first —
403
+ // "quem é X?" is a person question far more often than not.
404
+ const seen = new Set<DataToolTarget>()
405
+ const candidates: Array<{ key: string; target: DataToolTarget }> = []
406
+ for (const [key, target] of ctx.dataTools) {
407
+ if (!key.startsWith('key:') || seen.has(target)) continue
408
+ seen.add(target)
409
+ candidates.push({ key: key.slice(4), target })
410
+ }
411
+ let skippedForPermission = 0
412
+ const allowed = candidates.filter(({ target }) => {
413
+ const permission = target.entity.permission
414
+ if (!permission) return true
415
+ if (checkAccess(permission.feature, permission.action).allowed) return true
416
+ skippedForPermission += 1
417
+ return false
418
+ })
419
+ allowed.sort((a, b) => {
420
+ const ap = a.target.entity.data?.archetype === 'person' ? 0 : 1
421
+ const bp = b.target.entity.data?.archetype === 'person' ? 0 : 1
422
+ return ap - bp || a.key.localeCompare(b.key)
423
+ })
424
+
425
+ const MAX_TARGETS = 20
426
+ const searched = allowed.slice(0, MAX_TARGETS)
427
+ const settled = await Promise.allSettled(
428
+ searched.map(async ({ key, target }) => {
429
+ const provider = resolveDataProvider(
430
+ target.entity as EntityDef<{ id: string }>,
431
+ target.mockData,
432
+ )
433
+ const result = await provider.list({ search, pageSize: 5 })
434
+ return {
435
+ key,
436
+ label: target.label,
437
+ total: result.total,
438
+ records: attachRefs(target.entity, result.data as Array<Record<string, unknown>>),
439
+ }
440
+ }),
441
+ )
442
+ const matches = settled
443
+ .filter((r) => r.status === 'fulfilled' && r.value.records.length > 0)
444
+ .map((r) => (r as PromiseFulfilledResult<{ key: string; label: string; total: number; records: unknown[] }>).value)
445
+
446
+ // Attached records travel WITH their owner. A person's address lives in its
447
+ // own table keyed by owner_id, so a text search for "Doguez" can never return
448
+ // it — and telling the model to make a second, filtered call is a hope, not a
449
+ // mechanism: it answers "this contact has no address registered" instead,
450
+ // which is false. Resolving them here makes the answer correct by
451
+ // construction. One extra query, only when a person actually matched.
452
+ const attached = await resolveAttachedRecords(ctx, matches)
453
+
454
+ return {
455
+ query: search,
456
+ matches,
457
+ ...(attached ? { attached } : {}),
458
+ searchedEntities: searched.length,
459
+ ...(allowed.length > MAX_TARGETS ? { targetsTruncated: true } : {}),
460
+ ...(skippedForPermission ? { skippedForPermission } : {}),
461
+ ...(matches.length === 0
462
+ ? { hint: 'No records matched anywhere the user can see. Say so — do not retry other search tools with the same text.' }
463
+ : {}),
464
+ }
465
+ })
466
+
467
+ registerAIToolHandler('createRecord', async (args, ctx) => {
468
+ const resolved = resolveKeyedTarget(ctx, args)
469
+ if ('error' in resolved) return resolved.error
470
+ const { target } = resolved
471
+ if (target.readOnly) {
472
+ return { error: 'read_only', message: `${target.label} is a read-only view — records cannot be created here.` }
473
+ }
474
+
475
+ // Guard chain, in the broker's order: role×plan for CREATE → plan cap →
476
+ // field validation → human confirmation → write → invalidate the count.
477
+ const permission = target.entity.permission
478
+ if (permission) {
479
+ const access = checkAccess(permission.feature, 'create')
480
+ if (!access.allowed) return access
481
+ }
482
+ if (target.entity.limitKey) {
483
+ const limit = await guardLimit(target.entity.limitKey)
484
+ if (!limit.allowed) return limit
485
+ }
486
+
487
+ const values = (args.values ?? {}) as Record<string, unknown>
488
+ const declared = target.entity.fields ?? []
489
+ const matched = matchDeclaredFields(target, values)
490
+ if ('error' in matched) return matched.error
491
+ const { clean } = matched
492
+ const missing = declared
493
+ .filter((f) => f.required && (clean[f.key] === undefined || clean[f.key] === null || clean[f.key] === ''))
494
+ .map((f) => f.key)
495
+ if (missing.length) {
496
+ return {
497
+ error: 'missing_required',
498
+ message: `Missing required field(s): ${missing.join(', ')}. Ask the user for them, then retry.`,
499
+ availableFields: declared.map((f) => f.key),
500
+ }
501
+ }
502
+
503
+ // Duplicate guard: same-name record already existing comes back as a
504
+ // structured warning FIRST — the model confirms intent with the user and
505
+ // retries with allowDuplicate:true only if they really want a second one.
506
+ const dupName = typeof clean.name === 'string' ? clean.name.trim() : ''
507
+ if (dupName && args.allowDuplicate !== true) {
508
+ const provider0 = resolveDataProvider(
509
+ target.entity as EntityDef<{ id: string }>,
510
+ target.mockData,
511
+ )
512
+ const existing = await provider0.list({ search: dupName, pageSize: 3 })
513
+ const dupes = (existing.data as Array<Record<string, unknown>>).filter(
514
+ (r) => String(r.name ?? '').trim().toLowerCase() === dupName.toLowerCase(),
515
+ )
516
+ if (dupes.length) {
517
+ return {
518
+ error: 'possible_duplicate',
519
+ message: `A ${target.entity.name} named "${dupName}" already exists. If the user wants to CHANGE that existing record, use updateRecord with its id; only retry createRecord with allowDuplicate:true if they explicitly want a second one.`,
520
+ matches: dupes,
521
+ }
522
+ }
523
+ }
524
+
525
+ const approved = await requestChatConfirmation({
526
+ id: `create-${Date.now()}`,
527
+ toolName: 'createRecord',
528
+ title: `Criar ${target.entity.name}`,
529
+ params: clean,
530
+ plane: 'client',
531
+ })
532
+ if (!approved) return { ok: false, cancelled: true, reason: 'user_declined' }
533
+
534
+ const provider = resolveDataProvider(
535
+ target.entity as EntityDef<{ id: string }>,
536
+ target.mockData,
537
+ )
538
+ const record = await provider.create(clean as never)
539
+ if (target.entity.limitKey) invalidateLimit(target.entity.limitKey)
540
+ return {
541
+ ok: true,
542
+ record,
543
+ ref: {
544
+ id: (record as { id?: string }).id,
545
+ label: String((record as Record<string, unknown>).name ?? target.entity.name),
546
+ resource: target.entity.data?.table,
547
+ archetype: target.entity.data?.archetype
548
+ ? `${target.entity.data.archetype}${target.entity.data.archetypeKind ? ':' + target.entity.data.archetypeKind : ''}`
549
+ : undefined,
550
+ },
551
+ }
552
+ })
553
+
554
+ registerAIToolHandler('updateRecord', async (args, ctx) => {
555
+ const resolved = resolveKeyedTarget(ctx, args)
556
+ if ('error' in resolved) return resolved.error
557
+ const { target } = resolved
558
+ if (target.readOnly) {
559
+ return { error: 'read_only', message: `${target.label} is a read-only view — records cannot be updated here.` }
560
+ }
561
+ const id = typeof args.id === 'string' ? args.id : ''
562
+ if (!id) return { error: 'missing_id', message: 'Provide the id of the record to update (from this conversation or a search).' }
563
+
564
+ // Same guard chain as createRecord, with the EDIT action; updates never
565
+ // consume plan caps.
566
+ const permission = target.entity.permission
567
+ if (permission) {
568
+ const access = checkAccess(permission.feature, 'edit')
569
+ if (!access.allowed) return access
570
+ }
571
+
572
+ const values = (args.values ?? {}) as Record<string, unknown>
573
+ const matched = matchDeclaredFields(target, values)
574
+ if ('error' in matched) return matched.error
575
+ const { clean } = matched
576
+ if (!Object.keys(clean).length) {
577
+ return { error: 'empty_update', message: 'No valid fields to change were provided.' }
578
+ }
579
+
580
+ const approved = await requestChatConfirmation({
581
+ id: `update-${Date.now()}`,
582
+ toolName: 'updateRecord',
583
+ title: `Atualizar ${target.entity.name}`,
584
+ params: { id, ...clean },
585
+ plane: 'client',
586
+ })
587
+ if (!approved) return { ok: false, cancelled: true, reason: 'user_declined' }
588
+
589
+ const provider = resolveDataProvider(
590
+ target.entity as EntityDef<{ id: string }>,
591
+ target.mockData,
592
+ )
593
+ const record = await provider.update(id, clean as never)
594
+ return {
595
+ ok: true,
596
+ record,
597
+ ref: {
598
+ id,
599
+ label: String((record as Record<string, unknown>).name ?? target.entity.name),
600
+ resource: target.entity.data?.table,
601
+ archetype: target.entity.data?.archetype
602
+ ? `${target.entity.data.archetype}${target.entity.data.archetypeKind ? ':' + target.entity.data.archetypeKind : ''}`
603
+ : undefined,
604
+ },
605
+ }
606
+ })
607
+
608
+ registerAIToolHandler('searchRecords', async (args, ctx) => {
609
+ const resolved = resolveKeyedTarget(ctx, args)
610
+ if ('error' in resolved) return resolved.error
611
+ const base = (await executeDataTool(resolved.target, args)) as {
612
+ records: Array<Record<string, unknown>>
613
+ [k: string]: unknown
614
+ }
615
+ const filters = (args.filters ?? {}) as Record<string, unknown>
616
+ const keys = Object.keys(filters)
617
+ if (!keys.length) return base
618
+ let rows = base.records
619
+ for (const k of keys) {
620
+ const sample = rows[0] ?? base.records[0]
621
+ if (sample) {
622
+ const fieldKey = resolveFieldKey(sample, k)
623
+ if (!fieldKey) {
624
+ return {
625
+ error: 'unknown_field',
626
+ message: `Filter field "${k}" does not exist on ${resolved.target.label}. Use one of the available fields and retry.`,
627
+ availableFields: Object.keys(sample),
628
+ }
629
+ }
630
+ const v = filters[k]
631
+ rows = isRangeObject(v)
632
+ ? rows.filter((r) => compareInRange(r[fieldKey], v))
633
+ : rows.filter((r) => String(r[fieldKey] ?? '') === String(v))
634
+ }
635
+ }
636
+ return { ...base, records: rows, filtered: keys, total: rows.length }
637
+ })
638
+
639
+ registerAIToolHandler('queryData', async (args, ctx) => {
640
+ const resolved = resolveKeyedTarget(ctx, args)
641
+ if ('error' in resolved) return resolved.error
642
+ const { target } = resolved
643
+
644
+ const metric = args.metric === 'sum' || args.metric === 'avg' ? args.metric : 'count'
645
+ let field = typeof args.field === 'string' ? args.field : undefined
646
+ if (metric !== 'count' && !field) {
647
+ return { error: 'missing_field', message: `metric "${metric}" needs a "field" to aggregate.` }
648
+ }
649
+
650
+ const provider = resolveDataProvider(
651
+ target.entity as EntityDef<{ id: string }>,
652
+ target.mockData,
653
+ )
654
+ // v1 samples up to 1000 rows through the provider (which owns tenant scoping
655
+ // and column mapping) and aggregates here; `sampled` is set when the window
656
+ // truncated. The S4 server-plane executor replaces this with exact SQL.
657
+ const page = await provider.list({ pageSize: 1000 })
658
+ let rows = page.data as Array<Record<string, unknown>>
659
+
660
+ // Field references FAIL LOUD: a typo'd/unknown field must never silently
661
+ // filter everything to zero ("Não há agendamentos" over a wrong key). The
662
+ // error carries the row's real keys so the model self-corrects next round.
663
+ const sampleRow = rows[0]
664
+ const availableFields = sampleRow ? Object.keys(sampleRow) : []
665
+ const requireField = (name: string): string | { error: string; message: string; availableFields: string[] } => {
666
+ if (!sampleRow) return name
667
+ const resolved = resolveFieldKey(sampleRow, name)
668
+ if (resolved) return resolved
669
+ return {
670
+ error: 'unknown_field',
671
+ message: `Field \"${name}\" does not exist on ${target.label}. Use one of the available fields and retry.`,
672
+ availableFields,
673
+ }
674
+ }
675
+
676
+ const wantsDateRange = typeof args.from === 'string' || typeof args.to === 'string'
677
+ let dateKey = 'createdAt'
678
+ if (wantsDateRange || typeof args.dateField === 'string') {
679
+ const resolved = requireField(typeof args.dateField === 'string' ? args.dateField : 'createdAt')
680
+ if (typeof resolved !== 'string') return resolved
681
+ dateKey = resolved
682
+ }
683
+ const from = typeof args.from === 'string' ? Date.parse(args.from) : NaN
684
+ const to = typeof args.to === 'string' ? Date.parse(args.to) : NaN
685
+ if (!Number.isNaN(from) || !Number.isNaN(to)) {
686
+ rows = rows.filter((r) => {
687
+ const raw = r[dateKey]
688
+ const ts = typeof raw === 'string' || typeof raw === 'number' ? Date.parse(String(raw)) : NaN
689
+ if (Number.isNaN(ts)) return false
690
+ if (!Number.isNaN(from) && ts < from) return false
691
+ if (!Number.isNaN(to) && ts > to) return false
692
+ return true
693
+ })
694
+ }
695
+
696
+ const filters = (args.filters ?? {}) as Record<string, unknown>
697
+ for (const [k, v] of Object.entries(filters)) {
698
+ const resolved = requireField(k)
699
+ if (typeof resolved !== 'string') return resolved
700
+ if (isRangeObject(v)) {
701
+ // Models naturally express ranges as {from,to} nested in filters —
702
+ // honor it instead of comparing against "[object Object]".
703
+ rows = rows.filter((r) => compareInRange(r[resolved], v))
704
+ } else {
705
+ rows = rows.filter((r) => String(r[resolved] ?? '') === String(v))
706
+ }
707
+ }
708
+
709
+ const aggregate = (subset: Array<Record<string, unknown>>) => {
710
+ if (metric === 'count') return subset.length
711
+ const values = subset
712
+ .map((r) => Number(r[field!]))
713
+ .filter((n) => !Number.isNaN(n))
714
+ const sum = values.reduce((a, b) => a + b, 0)
715
+ if (metric === 'sum') return Math.round(sum * 100) / 100
716
+ return values.length ? Math.round((sum / values.length) * 100) / 100 : 0
717
+ }
718
+
719
+ if (field && rows[0]) {
720
+ const resolved = resolveFieldKey(rows[0], field)
721
+ if (!resolved) {
722
+ return {
723
+ error: 'unknown_field',
724
+ message: `Field "${field}" does not exist on ${target.label}. Use one of the available fields and retry.`,
725
+ availableFields,
726
+ }
727
+ }
728
+ field = resolved
729
+ }
730
+ let groupBy = typeof args.groupBy === 'string' ? args.groupBy : undefined
731
+ if (groupBy && rows[0]) {
732
+ const resolved = resolveFieldKey(rows[0], groupBy)
733
+ if (!resolved) {
734
+ return {
735
+ error: 'unknown_field',
736
+ message: `Field "${groupBy}" does not exist on ${target.label}. Use one of the available fields and retry.`,
737
+ availableFields,
738
+ }
739
+ }
740
+ groupBy = resolved
741
+ }
742
+ const sampled = page.total > page.data.length
743
+ if (groupBy) {
744
+ const groups = new Map<string, Array<Record<string, unknown>>>()
745
+ for (const r of rows) {
746
+ const g = String(r[groupBy] ?? '—')
747
+ if (!groups.has(g)) groups.set(g, [])
748
+ groups.get(g)!.push(r)
749
+ }
750
+ // A group key like professionalId is opaque to the user — attach the
751
+ // sibling display field (professionalName) when the rows carry one.
752
+ const labelKey = groupBy.endsWith('Id')
753
+ ? resolveFieldKey(rows[0] ?? {}, groupBy.slice(0, -2) + 'Name')
754
+ : null
755
+ return {
756
+ entity: target.label,
757
+ metric,
758
+ ...(field ? { field } : {}),
759
+ groupBy,
760
+ groups: Array.from(groups.entries())
761
+ .map(([group, subset]) => ({
762
+ group,
763
+ ...(labelKey && subset[0] ? { label: subset[0][labelKey] } : {}),
764
+ value: aggregate(subset),
765
+ rows: subset.length,
766
+ }))
767
+ .sort((a, b) => Number(b.value) - Number(a.value)),
768
+ matchedRows: rows.length,
769
+ sampled,
770
+ }
771
+ }
772
+ return {
773
+ entity: target.label,
774
+ metric,
775
+ ...(field ? { field } : {}),
776
+ value: aggregate(rows),
777
+ matchedRows: rows.length,
778
+ sampled,
779
+ }
780
+ })
781
+
782
+ export interface AgentTool {
783
+ name: string
784
+ description: string
785
+ parameters?: Record<string, unknown>
786
+ }
787
+
788
+ export interface BuildToolsetOptions {
789
+ dataTools: Map<string, DataToolTarget>
790
+ /** Plugin owning the current route; its tools rank ahead of the rest. */
791
+ activePluginId?: string | null
792
+ maxTools?: number
793
+ }
794
+
795
+ /**
796
+ * Shape the declared catalog into the toolset sent to the agent.
797
+ *
798
+ * Two filters, both load-bearing:
799
+ *
800
+ * 1. *Executable only.* An advertised tool with no executor is worse than a
801
+ * missing one — the model spends rounds on it and then apologises.
802
+ * 2. *Contextual.* A mature app declares ~40 tools; sending all of them every
803
+ * turn costs input tokens on every request and degrades which tool the model
804
+ * picks. Tools owned by the plugin the user is currently looking at come
805
+ * first, then the rest, capped.
806
+ */
807
+ export function buildAgentToolset(
808
+ tools: PluginAITool[],
809
+ { dataTools, activePluginId, maxTools = MAX_TOOLS_PER_TURN }: BuildToolsetOptions,
810
+ ): AgentTool[] {
811
+ const executable = tools.filter(
812
+ (t) =>
813
+ handlers.has(t.name) ||
814
+ dataTools.has(t.name) ||
815
+ (t.execution?.plane === 'server' && t.execution.kind === 'rpc'),
816
+ )
817
+
818
+ const ranked = activePluginId
819
+ ? [
820
+ ...executable.filter((t) => t.id.startsWith(`${activePluginId}.`)),
821
+ ...executable.filter((t) => !t.id.startsWith(`${activePluginId}.`)),
822
+ ]
823
+ : executable
824
+
825
+ return ranked.slice(0, maxTools).map((tool) => ({
826
+ name: tool.name,
827
+ description: tool.description,
828
+ parameters: tool.parameters as Record<string, unknown> | undefined,
829
+ }))
830
+ }
831
+
832
+ // ---------------------------------------------------------------------------
833
+ // Core handlers — the executable counterpart of `coreAITools`.
834
+ // ---------------------------------------------------------------------------
835
+
836
+ registerAIToolHandler('getBusinessSummary', (_args, ctx) => {
837
+ if (!ctx.currentOrg) return { error: 'no_active_business' }
838
+ const org = ctx.currentOrg as Organization & { plan?: string; verticalId?: string }
839
+ return {
840
+ name: org.name,
841
+ slug: org.slug,
842
+ plan: org.plan ?? null,
843
+ vertical: org.verticalId ?? null,
844
+ teamSize: ctx.members.length,
845
+ createdAt: org.createdAt ?? null,
846
+ }
847
+ })
848
+
849
+ registerAIToolHandler('getTeamMembers', (args, ctx) => {
850
+ const role = typeof args.role === 'string' ? args.role.toLowerCase() : undefined
851
+ const members = ctx.members
852
+ .map((m) => m as OrgMember & { role?: string; status?: string })
853
+ .filter((m) => !role || String(m.role ?? '').toLowerCase() === role)
854
+ .map((m) => ({
855
+ name: m.profileName ?? m.user?.fullName ?? null,
856
+ email: m.user?.email ?? null,
857
+ role: m.role ?? null,
858
+ status: m.status ?? null,
859
+ }))
860
+ return { total: members.length, members }
861
+ })
862
+
863
+ registerAIToolHandler('navigateTo', (args, ctx) => {
864
+ const query = typeof args.page === 'string' ? args.page.trim().toLowerCase() : ''
865
+ if (!query) return { error: 'missing_page' }
866
+
867
+ const match =
868
+ ctx.routes.find((r) => r.path.toLowerCase() === query || r.path.toLowerCase() === `/${query}`) ??
869
+ ctx.routes.find((r) => (r.label ?? '').toLowerCase() === query) ??
870
+ ctx.routes.find((r) => r.path.toLowerCase().includes(query) || (r.label ?? '').toLowerCase().includes(query))
871
+
872
+ if (!match) {
873
+ return {
874
+ error: 'page_not_found',
875
+ availablePages: ctx.routes.map((r) => r.label ?? r.path),
876
+ }
877
+ }
878
+
879
+ ctx.navigate(match.path)
880
+ return { navigatedTo: match.path, label: match.label ?? match.path }
881
+ })
882
+
883
+
884
+ /**
885
+ * Client-plane execution of an RPC-bound tool (`execution: {kind:'rpc'}`):
886
+ * calls the pool function as the signed-in user. The actor is the SESSION
887
+ * user — spine 016 makes the RPC reject any other p_actor for JWT callers, so
888
+ * this path cannot borrow someone else's role. Structured denials pass through
889
+ * to the model (conversational paywall); S4 moves this server-side untouched.
890
+ */
891
+ export async function executeRpcTool(
892
+ rpcName: string,
893
+ args: Record<string, unknown>,
894
+ userId: string | undefined,
895
+ ): Promise<AIToolExecutionResult> {
896
+ const supabase = getSupabaseClientOptional() as {
897
+ rpc?: (fn: string, params: Record<string, unknown>) => Promise<{ data: unknown; error: { message: string } | null }>
898
+ } | null
899
+ const tenantId = getActiveTenantId()
900
+ if (!supabase?.rpc || !tenantId || !userId) {
901
+ return errorResult('not_executable', 'No authenticated session/tenant available for this action.')
902
+ }
903
+ const { data, error } = await supabase.rpc(rpcName, {
904
+ p_tenant_id: tenantId,
905
+ p_actor_user_id: userId,
906
+ p_payload: args,
907
+ })
908
+ if (error) return errorResult('execution_failed', error.message)
909
+ return { ok: true, content: JSON.stringify(data ?? { ok: true }) }
910
+ }