@dyrected/core 2.13.0 → 2.13.3

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.
@@ -1,1136 +0,0 @@
1
- import {
2
- RAGService
3
- } from "./chunk-ZYYDTHF3.js";
4
- import {
5
- sanitizeDocForAI
6
- } from "./chunk-254THFDS.js";
7
- import {
8
- AI_ACTIONS_COLLECTION,
9
- isAICollection
10
- } from "./chunk-4RAQJVJM.js";
11
- import {
12
- isAccessAllowed,
13
- resolveAccess
14
- } from "./chunk-HRRJ3RYT.js";
15
-
16
- // src/services/ai-tools.ts
17
- import { tool } from "ai";
18
- import { z } from "zod";
19
-
20
- // src/utils/access-control.ts
21
- function toHookRequestContext(req) {
22
- return {
23
- query: req.query(),
24
- headers: req.header(),
25
- raw: req.raw
26
- };
27
- }
28
- async function resolveBooleanAccess(config, access, args) {
29
- const result = await resolveAccess(config, access, args);
30
- if (result === void 0) return true;
31
- return typeof result === "boolean" ? result : false;
32
- }
33
- async function matchesAccessConstraint(config, collection, id, constraint) {
34
- if (!config.db) return false;
35
- const match = await config.db.find({
36
- collection,
37
- where: { AND: [{ id: { equals: id } }, constraint] },
38
- limit: 1
39
- });
40
- return match.total > 0;
41
- }
42
- async function resolveCollectionAccess(config, collection, action, access, args) {
43
- const result = await resolveAccess(config, access, args);
44
- if (result === void 0) return { allowed: true };
45
- if (typeof result === "boolean") return { allowed: result };
46
- if (action === "read" && !args.id) {
47
- return { allowed: true, constraint: result };
48
- }
49
- if (!args.id) {
50
- return { allowed: false };
51
- }
52
- return {
53
- allowed: await matchesAccessConstraint(config, collection, args.id, result)
54
- };
55
- }
56
- function resolveFieldAccessId(context) {
57
- const candidate = context.id ?? context.doc?.id ?? context.data?.id;
58
- return typeof candidate === "string" && candidate.length > 0 ? candidate : void 0;
59
- }
60
- async function applyFieldReadAccess(context, doc) {
61
- if (!doc || typeof doc !== "object") return doc;
62
- const result = { ...doc };
63
- for (const field of context.fields) {
64
- if (field.type === "row" && field.fields) {
65
- Object.assign(
66
- result,
67
- await applyFieldReadAccess(
68
- { ...context, fields: field.fields },
69
- result
70
- )
71
- );
72
- continue;
73
- }
74
- if (!field.name) continue;
75
- const value = result[field.name];
76
- const canRead = await resolveBooleanAccess(
77
- context.config,
78
- field.access?.read,
79
- {
80
- user: context.user,
81
- req: context.req,
82
- doc: context.doc,
83
- data: context.data,
84
- id: resolveFieldAccessId(context)
85
- }
86
- );
87
- if (!canRead) {
88
- delete result[field.name];
89
- continue;
90
- }
91
- if (value === void 0 || value === null) continue;
92
- if (field.type === "object" && field.fields && typeof value === "object" && !Array.isArray(value)) {
93
- result[field.name] = await applyFieldReadAccess(
94
- { ...context, fields: field.fields },
95
- value
96
- );
97
- continue;
98
- }
99
- if (field.type === "array" && field.fields && Array.isArray(value)) {
100
- const childFields = field.fields;
101
- result[field.name] = await Promise.all(
102
- value.map(
103
- (item) => typeof item === "object" && item !== null ? applyFieldReadAccess(
104
- { ...context, fields: childFields },
105
- item
106
- ) : item
107
- )
108
- );
109
- continue;
110
- }
111
- if (field.type === "blocks" && field.blocks && Array.isArray(value)) {
112
- result[field.name] = await Promise.all(
113
- value.map(async (item) => {
114
- if (typeof item !== "object" || item === null) return item;
115
- const typedItem = item;
116
- const block = field.blocks?.find(
117
- (candidate) => candidate.slug === typedItem.blockType
118
- );
119
- if (!block || !block.fields) return item;
120
- return applyFieldReadAccess(
121
- { ...context, fields: block.fields },
122
- typedItem
123
- );
124
- })
125
- );
126
- }
127
- }
128
- return result;
129
- }
130
- async function applyFieldWriteAccess(context, data) {
131
- if (!data || typeof data !== "object") return data;
132
- const result = { ...data };
133
- for (const field of context.fields) {
134
- if (field.type === "row" && field.fields) {
135
- Object.assign(
136
- result,
137
- await applyFieldWriteAccess(
138
- { ...context, fields: field.fields },
139
- result
140
- )
141
- );
142
- continue;
143
- }
144
- if (!field.name || !(field.name in result)) continue;
145
- const writeRule = context.operation === "create" ? field.access?.create ?? field.access?.update : field.access?.update;
146
- const canWrite = await resolveBooleanAccess(context.config, writeRule, {
147
- user: context.user,
148
- req: context.req,
149
- doc: context.doc,
150
- data: context.data,
151
- id: resolveFieldAccessId(context)
152
- });
153
- if (!canWrite) {
154
- delete result[field.name];
155
- continue;
156
- }
157
- const value = result[field.name];
158
- if (value === void 0 || value === null) continue;
159
- if (field.type === "object" && field.fields && typeof value === "object" && !Array.isArray(value)) {
160
- result[field.name] = await applyFieldWriteAccess(
161
- { ...context, fields: field.fields },
162
- value
163
- );
164
- continue;
165
- }
166
- if (field.type === "array" && field.fields && Array.isArray(value)) {
167
- const childFields = field.fields;
168
- result[field.name] = await Promise.all(
169
- value.map(
170
- (item) => typeof item === "object" && item !== null ? applyFieldWriteAccess(
171
- { ...context, fields: childFields },
172
- item
173
- ) : item
174
- )
175
- );
176
- continue;
177
- }
178
- if (field.type === "blocks" && field.blocks && Array.isArray(value)) {
179
- result[field.name] = await Promise.all(
180
- value.map(async (item) => {
181
- if (typeof item !== "object" || item === null) return item;
182
- const typedItem = item;
183
- const block = field.blocks?.find(
184
- (candidate) => candidate.slug === typedItem.blockType
185
- );
186
- if (!block || !block.fields) return item;
187
- return applyFieldWriteAccess(
188
- { ...context, fields: block.fields },
189
- typedItem
190
- );
191
- })
192
- );
193
- }
194
- }
195
- return result;
196
- }
197
- function mergeWhereConstraint(where, constraint) {
198
- return where ? { AND: [where, constraint] } : constraint;
199
- }
200
-
201
- // src/services/ai-tools.ts
202
- function generateActionId() {
203
- return `act_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 9)}`;
204
- }
205
- function isCollectionVisibleToProject(col, projectId) {
206
- if (!col.siteId || col.shared || projectId === "default") return true;
207
- return col.siteId === projectId;
208
- }
209
- function getAvailableCollectionSlugs(config, projectId) {
210
- return (config.collections || []).filter((c) => !isAICollection(c.slug) && !c.slug.startsWith("_")).filter((c) => !projectId || isCollectionVisibleToProject(c, projectId)).map((c) => c.slug).join(", ");
211
- }
212
- function isGlobalVisibleToProject(glb, projectId) {
213
- if (!glb.siteId || glb.shared || projectId === "default") return true;
214
- return glb.siteId === projectId;
215
- }
216
- function getAvailableGlobalSlugs(config, projectId) {
217
- return (config.globals || []).filter((g) => !projectId || isGlobalVisibleToProject(g, projectId)).map((g) => g.slug).join(", ");
218
- }
219
- function validatePayloadAgainstFields(fields, data, isPartial = false) {
220
- if (!data || typeof data !== "object") {
221
- return { valid: false, error: "Payload must be a non-null object." };
222
- }
223
- for (const field of fields) {
224
- const val = data[field.name];
225
- if (!isPartial && field.required && (val === void 0 || val === null || val === "")) {
226
- return { valid: false, error: `Required field "${field.name}" is missing or empty.` };
227
- }
228
- if (val !== void 0 && val !== null) {
229
- if (field.type === "number" && typeof val !== "number") {
230
- const num = Number(val);
231
- if (isNaN(num)) {
232
- return { valid: false, error: `Field "${field.name}" expects a numeric value, received "${val}".` };
233
- }
234
- } else if (field.type === "checkbox" && typeof val !== "boolean") {
235
- if (val !== "true" && val !== "false") {
236
- return { valid: false, error: `Field "${field.name}" expects a boolean value.` };
237
- }
238
- }
239
- }
240
- }
241
- return { valid: true };
242
- }
243
- function withTimeout(promise, timeoutMs = 1e4, toolName = "tool") {
244
- let timer;
245
- const timeoutPromise = new Promise((_, reject) => {
246
- timer = setTimeout(() => {
247
- reject(new Error(`Tool "${toolName}" timed out after ${timeoutMs}ms`));
248
- }, timeoutMs);
249
- });
250
- return Promise.race([promise, timeoutPromise]).finally(() => {
251
- if (timer) clearTimeout(timer);
252
- });
253
- }
254
- function createDyrectedAITools({
255
- db,
256
- config,
257
- user,
258
- projectId
259
- }) {
260
- const context = { db, user, projectId, config };
261
- const builtInTools = {
262
- listCollections: tool({
263
- description: "List all content collections available in this Dyrected CMS project",
264
- inputSchema: z.object({}),
265
- execute: async () => {
266
- return withTimeout(
267
- (async () => {
268
- const collections = (config.collections || []).filter((col) => !isAICollection(col.slug) && !col.slug.startsWith("_")).filter((col) => isCollectionVisibleToProject(col, projectId)).map((col) => ({
269
- slug: col.slug,
270
- label: col.labels?.singular || col.labels?.plural || col.slug,
271
- auth: !!col.auth,
272
- timestamps: col.timestamps !== false
273
- }));
274
- return { collections };
275
- })(),
276
- 1e4,
277
- "listCollections"
278
- );
279
- }
280
- }),
281
- getCollectionSchema: tool({
282
- description: "Get detailed field definitions, relations, and validation rules for a specific collection",
283
- inputSchema: z.object({
284
- collection: z.string().describe("Slug of the collection to inspect")
285
- }),
286
- execute: async ({
287
- collection
288
- }) => {
289
- return withTimeout(
290
- (async () => {
291
- if (isAICollection(collection) || collection.startsWith("_")) {
292
- return {
293
- error: `Collection "${collection}" is private or internal.`,
294
- suggestion: `Choose from available public collections: [${getAvailableCollectionSlugs(config, projectId)}]`,
295
- recoverable: true
296
- };
297
- }
298
- const col = config.collections?.find((c) => c.slug === collection);
299
- if (!col || !isCollectionVisibleToProject(col, projectId)) {
300
- return {
301
- error: `Collection "${collection}" not found in project.`,
302
- suggestion: `Available collections are: [${getAvailableCollectionSlugs(config, projectId)}]. Please re-try with a valid collection.`,
303
- recoverable: true
304
- };
305
- }
306
- const fields = col.fields?.map((f) => ({
307
- name: f.name,
308
- type: f.type,
309
- label: f.label,
310
- required: !!f.required,
311
- unique: !!f.unique,
312
- relationTo: f.relationTo,
313
- options: f.options
314
- })) || [];
315
- return {
316
- slug: col.slug,
317
- label: col.labels?.singular || col.labels?.plural || col.slug,
318
- auth: !!col.auth,
319
- fields
320
- };
321
- })(),
322
- 1e4,
323
- "getCollectionSchema"
324
- );
325
- }
326
- }),
327
- listGlobals: tool({
328
- description: "List all singleton global configurations (e.g. site-settings, navigation, homepage) in this project",
329
- inputSchema: z.object({}),
330
- execute: async () => {
331
- return withTimeout(
332
- (async () => {
333
- const globals = (config.globals || []).filter((g) => isGlobalVisibleToProject(g, projectId)).map((g) => ({
334
- slug: g.slug,
335
- label: g.label || g.slug
336
- }));
337
- return { globals };
338
- })(),
339
- 1e4,
340
- "listGlobals"
341
- );
342
- }
343
- }),
344
- getGlobalSchema: tool({
345
- description: "Get schema and current saved values for a singleton global configuration",
346
- inputSchema: z.object({
347
- global: z.string().describe('Slug of the global configuration (e.g. "homepage", "site-settings")')
348
- }),
349
- execute: async ({
350
- global: globalSlug
351
- }) => {
352
- return withTimeout(
353
- (async () => {
354
- const g = config.globals?.find((item) => item.slug === globalSlug);
355
- if (!g || !isGlobalVisibleToProject(g, projectId)) {
356
- return {
357
- error: `Global "${globalSlug}" not found.`,
358
- suggestion: `Available globals in this project are: [${getAvailableGlobalSlugs(config, projectId)}].`,
359
- recoverable: true
360
- };
361
- }
362
- let data = null;
363
- try {
364
- data = await db.getGlobal({ slug: globalSlug });
365
- } catch {
366
- }
367
- const fields = g.fields?.map((f) => ({
368
- name: f.name,
369
- type: f.type,
370
- label: f.label,
371
- required: !!f.required
372
- })) || [];
373
- return {
374
- slug: g.slug,
375
- label: g.label || g.slug,
376
- fields,
377
- data
378
- };
379
- })(),
380
- 1e4,
381
- "getGlobalSchema"
382
- );
383
- }
384
- }),
385
- queryCollection: tool({
386
- description: 'Query structured records from a collection using exact field filters, sorting, and pagination. Use this tool for finding documents with specific statuses, categories, date ranges, or numeric thresholds (e.g. price > 50000, status == "published"). Do NOT use this tool for open-ended conceptual or semantic questions.',
387
- inputSchema: z.object({
388
- collection: z.string().describe('Slug of the collection to query (e.g. "articles", "products", "services")'),
389
- where: z.record(z.string(), z.any()).optional().describe('Filter criteria object (e.g. { "status": "published", "price": { "greater_than": 50000 } })'),
390
- sort: z.string().optional().describe('Field to sort by (prefix with "-" for descending, e.g. "-createdAt")'),
391
- limit: z.number().min(1).max(50).optional().default(10).describe("Maximum documents to return (1-50)"),
392
- page: z.number().min(1).optional().default(1).describe("Page number for pagination")
393
- }),
394
- execute: async ({
395
- collection,
396
- where,
397
- sort,
398
- limit = 10,
399
- page = 1
400
- }) => {
401
- return withTimeout(
402
- (async () => {
403
- if (isAICollection(collection) || collection.startsWith("_")) {
404
- return {
405
- error: `Collection "${collection}" is private or internal.`,
406
- suggestion: `Query public collections: [${getAvailableCollectionSlugs(config, projectId)}]`,
407
- recoverable: true
408
- };
409
- }
410
- const col = config.collections?.find((c) => c.slug === collection);
411
- if (!col || !isCollectionVisibleToProject(col, projectId)) {
412
- return {
413
- error: `Collection "${collection}" does not exist in this project.`,
414
- suggestion: `Available collections are: [${getAvailableCollectionSlugs(config, projectId)}]. Use getCollectionSchema() to inspect valid fields.`,
415
- recoverable: true
416
- };
417
- }
418
- const accessResult = await resolveAccess(config, col.access?.read, {
419
- req: { user, siteId: projectId },
420
- user
421
- });
422
- if (accessResult === false) {
423
- return {
424
- error: `Access denied: user lacks read permission for collection "${collection}".`,
425
- recoverable: false
426
- };
427
- }
428
- let queryWhere = where || {};
429
- if (accessResult && typeof accessResult === "object") {
430
- queryWhere = mergeWhereConstraint(queryWhere, accessResult);
431
- }
432
- try {
433
- const result = await db.find({
434
- collection,
435
- where: queryWhere,
436
- sort,
437
- limit,
438
- page
439
- });
440
- const sanitizedDocs = (result.docs || []).map(
441
- (doc) => sanitizeDocForAI({
442
- doc,
443
- collectionConfig: col,
444
- globalAIConfig: config.ai
445
- })
446
- );
447
- return {
448
- collection,
449
- docs: sanitizedDocs,
450
- total: result.total ?? sanitizedDocs.length,
451
- totalPages: result.totalPages ?? 1,
452
- page: result.page ?? page,
453
- limit: result.limit ?? limit
454
- };
455
- } catch (err) {
456
- return {
457
- error: `Failed to query collection "${collection}": ${err.message}`,
458
- suggestion: "Verify field names match the collection schema using getCollectionSchema().",
459
- recoverable: true
460
- };
461
- }
462
- })(),
463
- 1e4,
464
- "queryCollection"
465
- );
466
- }
467
- }),
468
- getDocument: tool({
469
- description: "Retrieve a single full document by its ID from a collection",
470
- inputSchema: z.object({
471
- collection: z.string().describe("Slug of the collection"),
472
- id: z.string().describe("Primary key ID of the document")
473
- }),
474
- execute: async ({
475
- collection,
476
- id
477
- }) => {
478
- return withTimeout(
479
- (async () => {
480
- if (isAICollection(collection) || collection.startsWith("_")) {
481
- return {
482
- error: `Collection "${collection}" is private or internal.`,
483
- suggestion: `Retrieve documents from public collections: [${getAvailableCollectionSlugs(config, projectId)}]`,
484
- recoverable: true
485
- };
486
- }
487
- const col = config.collections?.find((c) => c.slug === collection);
488
- if (!col || !isCollectionVisibleToProject(col, projectId)) {
489
- return {
490
- error: `Collection "${collection}" not found in project.`,
491
- suggestion: `Available collections are: [${getAvailableCollectionSlugs(config, projectId)}].`,
492
- recoverable: true
493
- };
494
- }
495
- const accessResult = await resolveAccess(config, col.access?.read, {
496
- req: { user, siteId: projectId },
497
- user
498
- });
499
- if (accessResult === false) {
500
- return {
501
- error: `Access denied: you do not have permission to read document "${id}" in "${collection}".`,
502
- recoverable: false
503
- };
504
- }
505
- try {
506
- const doc = await db.findOne({
507
- collection,
508
- id
509
- });
510
- if (!doc) {
511
- return {
512
- error: `Document "${id}" not found in collection "${collection}".`,
513
- suggestion: `Check available IDs using queryCollection("${collection}").`,
514
- recoverable: true
515
- };
516
- }
517
- if (accessResult && typeof accessResult === "object") {
518
- const checkMatch = await db.find({
519
- collection,
520
- where: mergeWhereConstraint({ id: { equals: id } }, accessResult),
521
- limit: 1
522
- });
523
- if (checkMatch.total === 0) {
524
- return {
525
- error: `Document "${id}" not found in collection "${collection}".`,
526
- suggestion: "Verify document ID using queryCollection().",
527
- recoverable: true
528
- };
529
- }
530
- }
531
- const docAccess = await resolveAccess(config, col.access?.read, {
532
- req: { user, siteId: projectId },
533
- user,
534
- doc
535
- });
536
- if (docAccess === false) {
537
- return {
538
- error: `Access denied: you do not have permission to read document "${id}" in "${collection}".`,
539
- recoverable: false
540
- };
541
- }
542
- const sanitizedDoc = sanitizeDocForAI({
543
- doc,
544
- collectionConfig: col,
545
- globalAIConfig: config.ai
546
- });
547
- return { collection, doc: sanitizedDoc };
548
- } catch (err) {
549
- return {
550
- error: `Failed to fetch document "${id}": ${err.message}`,
551
- recoverable: true
552
- };
553
- }
554
- })(),
555
- 1e4,
556
- "getDocument"
557
- );
558
- }
559
- }),
560
- aggregateCollection: tool({
561
- description: 'Compute mathematical aggregates and summary statistics over a collection (e.g. count, sum, average, min, max, groupBy). Use this tool whenever the user asks "how many...", "what is the total...", "average price of...", or asks for counts grouped by status/category. Do NOT use semantic search for counting.',
562
- inputSchema: z.object({
563
- collection: z.string().describe("Slug of the collection to aggregate"),
564
- aggregates: z.record(
565
- z.string(),
566
- z.object({
567
- count: z.literal("*").optional(),
568
- countDistinct: z.string().optional(),
569
- distinct: z.string().optional(),
570
- sum: z.string().optional(),
571
- avg: z.string().optional(),
572
- min: z.string().optional(),
573
- max: z.string().optional(),
574
- cast: z.enum(["number", "integer", "float", "string", "boolean", "date"]).optional(),
575
- where: z.record(z.string(), z.any()).optional()
576
- })
577
- ).describe('Named aggregate operations to calculate (e.g. { "totalCount": { "count": "*" } })'),
578
- groupBy: z.string().optional().describe('Optional field name to group results by (e.g. "status", "category")')
579
- }),
580
- execute: async ({
581
- collection,
582
- aggregates,
583
- groupBy
584
- }) => {
585
- return withTimeout(
586
- (async () => {
587
- if (isAICollection(collection) || collection.startsWith("_")) {
588
- return {
589
- error: `Collection "${collection}" is private or internal.`,
590
- suggestion: `Aggregate public collections: [${getAvailableCollectionSlugs(config, projectId)}]`,
591
- recoverable: true
592
- };
593
- }
594
- const col = config.collections?.find((c) => c.slug === collection);
595
- if (!col || !isCollectionVisibleToProject(col, projectId)) {
596
- return {
597
- error: `Collection "${collection}" not found in project.`,
598
- suggestion: `Available collections are: [${getAvailableCollectionSlugs(config, projectId)}].`,
599
- recoverable: true
600
- };
601
- }
602
- const accessResult = await resolveAccess(config, col.access?.read, {
603
- req: { user, siteId: projectId },
604
- user
605
- });
606
- if (accessResult === false) {
607
- return {
608
- error: `Access denied: you do not have permission to aggregate collection "${collection}".`,
609
- recoverable: false
610
- };
611
- }
612
- let effectiveAggregates = aggregates;
613
- if (accessResult && typeof accessResult === "object") {
614
- effectiveAggregates = {};
615
- for (const [key, op] of Object.entries(aggregates)) {
616
- effectiveAggregates[key] = {
617
- ...op,
618
- where: mergeWhereConstraint(op.where, accessResult)
619
- };
620
- }
621
- }
622
- try {
623
- const result = await db.aggregate({
624
- collection,
625
- aggregates: effectiveAggregates,
626
- groupBy
627
- });
628
- return { collection, result };
629
- } catch (err) {
630
- return {
631
- error: `Failed to compute aggregates on "${collection}": ${err.message}`,
632
- suggestion: "Verify field names match schema with getCollectionSchema().",
633
- recoverable: true
634
- };
635
- }
636
- })(),
637
- 1e4,
638
- "aggregateCollection"
639
- );
640
- }
641
- }),
642
- searchContent: tool({
643
- description: 'Perform semantic vector search across unstructured text, articles, documentation, FAQs, guides, policies, and rich-text fields. Use this tool for open-ended questions, concepts, thematic topics, explanations, and qualitative inquiries (e.g. "what is our policy on refunds?", "how do we handle chargebacks?"). Do NOT use this tool for exact counts or strict numeric filtering.',
644
- inputSchema: z.object({
645
- query: z.string().describe("The natural language semantic search query or topic to look up"),
646
- collections: z.array(z.string()).optional().describe('Optional collection slugs to restrict search scope to (e.g. ["articles", "faqs"])'),
647
- limit: z.number().min(1).max(10).optional().default(4).describe("Number of top relevant snippets to return (1-10)"),
648
- minScore: z.number().min(0).max(1).optional().default(0.45).describe("Minimum cosine similarity cutoff threshold (0.0 to 1.0)")
649
- }),
650
- execute: async ({
651
- query,
652
- collections,
653
- limit = 4,
654
- minScore = 0.45
655
- }) => {
656
- return withTimeout(
657
- (async () => {
658
- try {
659
- const result = await RAGService.search({
660
- db,
661
- config,
662
- query,
663
- projectId,
664
- collections,
665
- limit,
666
- minScore,
667
- user
668
- });
669
- return result;
670
- } catch (err) {
671
- return {
672
- query,
673
- resultsCount: 0,
674
- sources: [],
675
- error: `Semantic search failed: ${err.message}`,
676
- recoverable: true
677
- };
678
- }
679
- })(),
680
- 1e4,
681
- "searchContent"
682
- );
683
- }
684
- }),
685
- // Day 5: Human-in-the-Loop Mutation Proposal Tools
686
- proposeCreateDocument: tool({
687
- description: "Propose creating a new document in a collection. Does NOT write to the database immediately; creates a proposal requiring human approval in the chat UI.",
688
- inputSchema: z.object({
689
- collection: z.string().describe("Slug of the target collection"),
690
- data: z.record(z.string(), z.any()).describe("Key-value object containing the document fields to create"),
691
- summary: z.string().describe("Clear 1-sentence summary of the new document being created")
692
- }),
693
- execute: async ({
694
- collection,
695
- data,
696
- summary
697
- }) => {
698
- return withTimeout(
699
- (async () => {
700
- if (isAICollection(collection) || collection.startsWith("_")) {
701
- return {
702
- error: `Collection "${collection}" is internal and cannot be modified.`,
703
- suggestion: `Target public collections: [${getAvailableCollectionSlugs(config, projectId)}]`,
704
- recoverable: true
705
- };
706
- }
707
- const col = config.collections?.find((c) => c.slug === collection);
708
- if (!col || !isCollectionVisibleToProject(col, projectId)) {
709
- return {
710
- error: `Collection "${collection}" not found in project.`,
711
- suggestion: `Available collections are: [${getAvailableCollectionSlugs(config, projectId)}].`,
712
- recoverable: true
713
- };
714
- }
715
- const canCreate = await isAccessAllowed(config, col.access?.create, {
716
- req: { user, siteId: projectId },
717
- user,
718
- data
719
- });
720
- if (!canCreate) {
721
- return {
722
- error: `Access denied: you do not have permission to create records in "${collection}".`,
723
- recoverable: false
724
- };
725
- }
726
- const fields = col.fields?.map((f) => ({ name: f.name, type: f.type, required: !!f.required })) || [];
727
- const validation = validatePayloadAgainstFields(fields, data, false);
728
- if (!validation.valid) {
729
- return {
730
- error: `Schema validation failed: ${validation.error}`,
731
- suggestion: `Check collection schema with getCollectionSchema("${collection}").`,
732
- recoverable: true
733
- };
734
- }
735
- const actionId = generateActionId();
736
- const actionRecord = {
737
- id: actionId,
738
- projectId,
739
- userId: user?.id ? String(user.id) : void 0,
740
- type: "createDocument",
741
- targetCollection: collection,
742
- summary,
743
- beforeSnapshot: null,
744
- proposedData: data,
745
- status: "pending",
746
- expiresAt: new Date(Date.now() + 30 * 60 * 1e3),
747
- // 30 mins
748
- createdAt: /* @__PURE__ */ new Date()
749
- };
750
- try {
751
- await db.create({
752
- collection: AI_ACTIONS_COLLECTION,
753
- data: actionRecord
754
- });
755
- } catch (err) {
756
- return {
757
- error: `Failed to persist action proposal: ${err.message}`,
758
- recoverable: true
759
- };
760
- }
761
- return {
762
- actionId,
763
- type: "createDocument",
764
- collection,
765
- summary,
766
- proposedData: data,
767
- status: "pending",
768
- requiresApproval: true
769
- };
770
- })(),
771
- 1e4,
772
- "proposeCreateDocument"
773
- );
774
- }
775
- }),
776
- proposeUpdateDocument: tool({
777
- description: "Propose updating specific fields on an existing document in a collection. Does NOT modify the database immediately; creates a proposal requiring human approval in the chat UI.",
778
- inputSchema: z.object({
779
- collection: z.string().describe("Slug of the target collection"),
780
- id: z.string().describe("Primary key ID of the document to update"),
781
- data: z.record(z.string(), z.any()).describe("Key-value object containing the fields to update"),
782
- summary: z.string().describe("Clear 1-sentence summary of the changes being proposed")
783
- }),
784
- execute: async ({
785
- collection,
786
- id,
787
- data,
788
- summary
789
- }) => {
790
- return withTimeout(
791
- (async () => {
792
- if (isAICollection(collection) || collection.startsWith("_")) {
793
- return {
794
- error: `Collection "${collection}" is internal and cannot be modified.`,
795
- suggestion: `Target public collections: [${getAvailableCollectionSlugs(config, projectId)}]`,
796
- recoverable: true
797
- };
798
- }
799
- const col = config.collections?.find((c) => c.slug === collection);
800
- if (!col || !isCollectionVisibleToProject(col, projectId)) {
801
- return {
802
- error: `Collection "${collection}" not found in project.`,
803
- suggestion: `Available collections are: [${getAvailableCollectionSlugs(config, projectId)}].`,
804
- recoverable: true
805
- };
806
- }
807
- const readAccessResult = await resolveAccess(config, col.access?.read, {
808
- req: { user, siteId: projectId },
809
- user
810
- });
811
- if (readAccessResult === false) {
812
- return {
813
- error: `Access denied: you do not have permission to read document "${id}" in "${collection}".`,
814
- recoverable: false
815
- };
816
- }
817
- const existingDoc = readAccessResult && typeof readAccessResult === "object" ? (await db.find({
818
- collection,
819
- where: mergeWhereConstraint({ id: { equals: id } }, readAccessResult),
820
- limit: 1
821
- }))?.docs?.[0] || null : await db.findOne({ collection, id });
822
- if (!existingDoc) {
823
- return {
824
- error: `Document "${id}" not found in collection "${collection}".`,
825
- suggestion: `Search existing records first with queryCollection("${collection}").`,
826
- recoverable: true
827
- };
828
- }
829
- const updateAccessResult = await resolveAccess(config, col.access?.update, {
830
- req: { user, siteId: projectId },
831
- user,
832
- data,
833
- doc: existingDoc
834
- });
835
- if (updateAccessResult === false) {
836
- return {
837
- error: `Access denied: you do not have permission to update document "${id}" in "${collection}".`,
838
- recoverable: false
839
- };
840
- }
841
- if (updateAccessResult && typeof updateAccessResult === "object") {
842
- const matches = await db.find({
843
- collection,
844
- where: mergeWhereConstraint({ id: { equals: id } }, updateAccessResult),
845
- limit: 1
846
- });
847
- if (!matches || !matches.docs || matches.docs.length === 0) {
848
- return {
849
- error: `Access denied: you do not have permission to update document "${id}" under current constraints.`,
850
- recoverable: false
851
- };
852
- }
853
- }
854
- const fields = col.fields?.map((f) => ({ name: f.name, type: f.type, required: !!f.required })) || [];
855
- const validation = validatePayloadAgainstFields(fields, data, true);
856
- if (!validation.valid) {
857
- return {
858
- error: `Schema validation failed: ${validation.error}`,
859
- suggestion: `Check collection schema with getCollectionSchema("${collection}").`,
860
- recoverable: true
861
- };
862
- }
863
- const actionId = generateActionId();
864
- const actionRecord = {
865
- id: actionId,
866
- projectId,
867
- userId: user?.id ? String(user.id) : void 0,
868
- type: "updateDocument",
869
- targetCollection: collection,
870
- documentId: id,
871
- summary,
872
- beforeSnapshot: existingDoc,
873
- proposedData: data,
874
- status: "pending",
875
- expiresAt: new Date(Date.now() + 30 * 60 * 1e3),
876
- createdAt: /* @__PURE__ */ new Date()
877
- };
878
- try {
879
- await db.create({
880
- collection: AI_ACTIONS_COLLECTION,
881
- data: actionRecord
882
- });
883
- } catch (err) {
884
- return {
885
- error: `Failed to persist action proposal: ${err.message}`,
886
- recoverable: true
887
- };
888
- }
889
- return {
890
- actionId,
891
- type: "updateDocument",
892
- collection,
893
- documentId: id,
894
- summary,
895
- beforeSnapshot: existingDoc,
896
- proposedData: data,
897
- status: "pending",
898
- requiresApproval: true
899
- };
900
- })(),
901
- 1e4,
902
- "proposeUpdateDocument"
903
- );
904
- }
905
- }),
906
- proposeDeleteDocument: tool({
907
- description: "Propose deleting an existing document from a collection. Does NOT delete from the database immediately; creates a proposal requiring human approval in the chat UI.",
908
- inputSchema: z.object({
909
- collection: z.string().describe("Slug of the target collection"),
910
- id: z.string().describe("Primary key ID of the document to delete"),
911
- summary: z.string().describe("Clear explanation of why this document should be deleted"),
912
- permanent: z.boolean().optional().default(false).describe("Whether this is a permanent deletion")
913
- }),
914
- execute: async ({
915
- collection,
916
- id,
917
- summary,
918
- permanent = false
919
- }) => {
920
- return withTimeout(
921
- (async () => {
922
- if (isAICollection(collection) || collection.startsWith("_")) {
923
- return {
924
- error: `Collection "${collection}" is internal and cannot be deleted.`,
925
- suggestion: `Target public collections: [${getAvailableCollectionSlugs(config, projectId)}]`,
926
- recoverable: true
927
- };
928
- }
929
- const col = config.collections?.find((c) => c.slug === collection);
930
- if (!col || !isCollectionVisibleToProject(col, projectId)) {
931
- return {
932
- error: `Collection "${collection}" not found in project.`,
933
- suggestion: `Available collections are: [${getAvailableCollectionSlugs(config, projectId)}].`,
934
- recoverable: true
935
- };
936
- }
937
- const readAccessResult = await resolveAccess(config, col.access?.read, {
938
- req: { user, siteId: projectId },
939
- user
940
- });
941
- if (readAccessResult === false) {
942
- return {
943
- error: `Access denied: you do not have permission to read document "${id}" in "${collection}".`,
944
- recoverable: false
945
- };
946
- }
947
- const existingDoc = readAccessResult && typeof readAccessResult === "object" ? (await db.find({
948
- collection,
949
- where: mergeWhereConstraint({ id: { equals: id } }, readAccessResult),
950
- limit: 1
951
- }))?.docs?.[0] || null : await db.findOne({ collection, id });
952
- if (!existingDoc) {
953
- return {
954
- error: `Document "${id}" not found in collection "${collection}".`,
955
- suggestion: `Search existing records with queryCollection("${collection}").`,
956
- recoverable: true
957
- };
958
- }
959
- const deleteAccessResult = await resolveAccess(config, col.access?.delete, {
960
- req: { user, siteId: projectId },
961
- user,
962
- doc: existingDoc
963
- });
964
- if (deleteAccessResult === false) {
965
- return {
966
- error: `Access denied: you do not have permission to delete document "${id}" in "${collection}".`,
967
- recoverable: false
968
- };
969
- }
970
- if (deleteAccessResult && typeof deleteAccessResult === "object") {
971
- const matches = await db.find({
972
- collection,
973
- where: mergeWhereConstraint({ id: { equals: id } }, deleteAccessResult),
974
- limit: 1
975
- });
976
- if (!matches || !matches.docs || matches.docs.length === 0) {
977
- return {
978
- error: `Access denied: you do not have permission to delete document "${id}" under current constraints.`,
979
- recoverable: false
980
- };
981
- }
982
- }
983
- const actionId = generateActionId();
984
- const actionRecord = {
985
- id: actionId,
986
- projectId,
987
- userId: user?.id ? String(user.id) : void 0,
988
- type: "deleteDocument",
989
- targetCollection: collection,
990
- documentId: id,
991
- summary,
992
- beforeSnapshot: existingDoc,
993
- proposedData: { permanent },
994
- status: "pending",
995
- expiresAt: new Date(Date.now() + 30 * 60 * 1e3),
996
- createdAt: /* @__PURE__ */ new Date()
997
- };
998
- try {
999
- await db.create({
1000
- collection: AI_ACTIONS_COLLECTION,
1001
- data: actionRecord
1002
- });
1003
- } catch (err) {
1004
- return {
1005
- error: `Failed to persist action proposal: ${err.message}`,
1006
- recoverable: true
1007
- };
1008
- }
1009
- return {
1010
- actionId,
1011
- type: "deleteDocument",
1012
- collection,
1013
- documentId: id,
1014
- summary,
1015
- beforeSnapshot: existingDoc,
1016
- proposedData: { permanent },
1017
- status: "pending",
1018
- requiresApproval: true
1019
- };
1020
- })(),
1021
- 1e4,
1022
- "proposeDeleteDocument"
1023
- );
1024
- }
1025
- }),
1026
- proposeUpdateGlobal: tool({
1027
- description: "Propose modifying singleton global settings (e.g. site-settings, navigation, homepage). Does NOT modify the database immediately; creates a proposal requiring human approval in the chat UI.",
1028
- inputSchema: z.object({
1029
- global: z.string().describe('Slug of the global configuration (e.g. "homepage", "site-settings")'),
1030
- data: z.record(z.string(), z.any()).describe("Key-value object containing the fields to update"),
1031
- summary: z.string().describe("Clear 1-sentence summary of why these global settings are being updated")
1032
- }),
1033
- execute: async ({
1034
- global: globalSlug,
1035
- data,
1036
- summary
1037
- }) => {
1038
- return withTimeout(
1039
- (async () => {
1040
- const g = config.globals?.find((item) => item.slug === globalSlug);
1041
- if (!g || !isGlobalVisibleToProject(g, projectId)) {
1042
- return {
1043
- error: `Global "${globalSlug}" not found in project.`,
1044
- suggestion: `Available globals in this project are: [${getAvailableGlobalSlugs(config, projectId)}].`,
1045
- recoverable: true
1046
- };
1047
- }
1048
- let existingGlobal = null;
1049
- try {
1050
- existingGlobal = await db.getGlobal({ slug: globalSlug });
1051
- } catch {
1052
- }
1053
- const fields = g.fields?.map((f) => ({ name: f.name, type: f.type, required: !!f.required })) || [];
1054
- const validation = validatePayloadAgainstFields(fields, data, true);
1055
- if (!validation.valid) {
1056
- return {
1057
- error: `Schema validation failed: ${validation.error}`,
1058
- suggestion: `Inspect global fields with getGlobalSchema("${globalSlug}").`,
1059
- recoverable: true
1060
- };
1061
- }
1062
- const actionId = generateActionId();
1063
- const actionRecord = {
1064
- id: actionId,
1065
- projectId,
1066
- userId: user?.id ? String(user.id) : void 0,
1067
- type: "updateGlobal",
1068
- targetGlobal: globalSlug,
1069
- summary,
1070
- beforeSnapshot: existingGlobal,
1071
- proposedData: data,
1072
- status: "pending",
1073
- expiresAt: new Date(Date.now() + 30 * 60 * 1e3),
1074
- createdAt: /* @__PURE__ */ new Date()
1075
- };
1076
- try {
1077
- await db.create({
1078
- collection: AI_ACTIONS_COLLECTION,
1079
- data: actionRecord
1080
- });
1081
- } catch (err) {
1082
- return {
1083
- error: `Failed to persist action proposal: ${err.message}`,
1084
- recoverable: true
1085
- };
1086
- }
1087
- return {
1088
- actionId,
1089
- type: "updateGlobal",
1090
- global: globalSlug,
1091
- summary,
1092
- beforeSnapshot: existingGlobal,
1093
- proposedData: data,
1094
- status: "pending",
1095
- requiresApproval: true
1096
- };
1097
- })(),
1098
- 1e4,
1099
- "proposeUpdateGlobal"
1100
- );
1101
- }
1102
- })
1103
- };
1104
- const customTools = {};
1105
- if (config.ai?.tools) {
1106
- for (const [toolName, toolDef] of Object.entries(config.ai.tools)) {
1107
- const schema = toolDef.inputSchema || toolDef.parameters || z.object({});
1108
- customTools[toolName] = tool({
1109
- description: toolDef.description,
1110
- inputSchema: schema,
1111
- execute: async (args) => {
1112
- return withTimeout(
1113
- Promise.resolve(toolDef.execute(args, context)),
1114
- 1e4,
1115
- toolName
1116
- );
1117
- }
1118
- });
1119
- }
1120
- }
1121
- return {
1122
- ...builtInTools,
1123
- ...customTools
1124
- };
1125
- }
1126
-
1127
- export {
1128
- toHookRequestContext,
1129
- resolveBooleanAccess,
1130
- resolveCollectionAccess,
1131
- applyFieldReadAccess,
1132
- applyFieldWriteAccess,
1133
- mergeWhereConstraint,
1134
- validatePayloadAgainstFields,
1135
- createDyrectedAITools
1136
- };