@dypai-ai/mcp 1.6.20 → 1.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * manage_storage — local extension adding an `upload_file` operation.
2
+ * manage_storage — local extension adding `upload_file` and `upload_files`.
3
3
  *
4
4
  * The remote `manage_storage` tool already covers list / create / delete buckets
5
5
  * and list_objects / delete_object / get_signed_download_url. What it CAN'T do
@@ -24,14 +24,20 @@
24
24
  * operation grafted on top — the dispatcher logic lives there too.
25
25
  */
26
26
 
27
- import { statSync, readFileSync, existsSync } from "fs"
28
- import { basename } from "path"
27
+ import { statSync, readFileSync, existsSync, readdirSync } from "fs"
28
+ import { basename, join, relative } from "path"
29
29
  import { proxyToolCall } from "./proxy.js"
30
- import { updateMediaManifest } from "./deploy.js"
30
+ import { updateMediaManifest, writeMediaManifestBatch } from "./deploy.js"
31
31
 
32
32
  // 100 MB — enforced by the API (MAX_UPLOAD_SIZE_BYTES). We check client-side too
33
33
  // so the user sees a clean error before we waste a round-trip.
34
34
  const MAX_UPLOAD_BYTES = 100 * 1024 * 1024
35
+ const DEFAULT_UPLOAD_CONCURRENCY = 5
36
+ const MAX_UPLOAD_CONCURRENCY = 20
37
+ const DEFAULT_MAX_RETRIES = 3
38
+ const RETRY_BASE_MS = 300
39
+ const RETRY_MAX_MS = 4000
40
+ const LIST_OBJECTS_PAGE_SIZE = 100
35
41
 
36
42
  // Extensions we know how to MIME-type without shelling out. Everything else
37
43
  // falls back to application/octet-stream, which the bucket accepts fine — the browser
@@ -75,6 +81,223 @@ function formatBytes(n) {
75
81
  return `${(n / 1024 / 1024).toFixed(1)} MB`
76
82
  }
77
83
 
84
+ function sleep(ms) {
85
+ return new Promise(resolve => setTimeout(resolve, ms))
86
+ }
87
+
88
+ function retryDelayMs(attempt) {
89
+ const exp = Math.min(RETRY_BASE_MS * (2 ** attempt), RETRY_MAX_MS)
90
+ const jitter = Math.floor(Math.random() * 100)
91
+ return exp + jitter
92
+ }
93
+
94
+ /** HTTP statuses worth retrying (transient server/rate-limit errors). */
95
+ export function isRetryableStatus(status) {
96
+ return [408, 429, 500, 502, 503, 504].includes(Number(status))
97
+ }
98
+
99
+ /** Classify thrown errors for retry logic. Exported for unit tests. */
100
+ export function isRetryableError(err) {
101
+ if (!err) return false
102
+ if (err.retryable === false) return false
103
+ if (err.retryable === true) return true
104
+ if (err.status != null) return isRetryableStatus(err.status)
105
+
106
+ const code = err.code
107
+ if (code === "ECONNRESET" || code === "ETIMEDOUT" || code === "ENOTFOUND" || code === "EAI_AGAIN") {
108
+ return true
109
+ }
110
+
111
+ const msg = String(err.message || "").toLowerCase()
112
+ if (msg.includes("fetch failed") || msg.includes("network") || msg.includes("socket")) {
113
+ return true
114
+ }
115
+
116
+ const statusMatch = msg.match(/status (\d{3})/)
117
+ if (statusMatch) return isRetryableStatus(Number(statusMatch[1]))
118
+
119
+ // Proxy/API throws without status — assume transient unless clearly client error
120
+ if (msg.includes("403") || msg.includes("404") || msg.includes("413") || msg.includes("400")) {
121
+ return false
122
+ }
123
+
124
+ return true
125
+ }
126
+
127
+ function markRetryable(err, retryable) {
128
+ err.retryable = retryable
129
+ return err
130
+ }
131
+
132
+ /** Retry fn on transient failures with exponential backoff + jitter. */
133
+ export async function withRetry(fn, { retries = DEFAULT_MAX_RETRIES } = {}) {
134
+ let lastErr
135
+ for (let attempt = 0; attempt <= retries; attempt++) {
136
+ try {
137
+ return await fn(attempt)
138
+ } catch (e) {
139
+ lastErr = e
140
+ if (!isRetryableError(e) || attempt >= retries) throw e
141
+ await sleep(retryDelayMs(attempt))
142
+ }
143
+ }
144
+ throw lastErr
145
+ }
146
+
147
+ function joinStorageKey(prefix, name) {
148
+ const p = (prefix || "").replace(/^\/+|\/+$/g, "")
149
+ const n = (name || "").replace(/^\/+/, "")
150
+ if (p && n) return `${p}/${n}`
151
+ return p || n
152
+ }
153
+
154
+ function matchesInclude(filename, include) {
155
+ if (!include?.length) return true
156
+ const ext = filename.includes(".") ? `.${filename.split(".").pop().toLowerCase()}` : ""
157
+ return include.some((item) => {
158
+ const normalized = item.startsWith(".") ? item.toLowerCase() : `.${item.toLowerCase()}`
159
+ return ext === normalized
160
+ })
161
+ }
162
+
163
+ function walkDirectory(rootDir, dir, recursive, include, out) {
164
+ let entries
165
+ try {
166
+ entries = readdirSync(dir, { withFileTypes: true })
167
+ } catch {
168
+ return
169
+ }
170
+
171
+ for (const ent of entries) {
172
+ const full = join(dir, ent.name)
173
+ if (ent.isDirectory()) {
174
+ if (recursive) walkDirectory(rootDir, full, recursive, include, out)
175
+ continue
176
+ }
177
+ if (!ent.isFile()) continue
178
+ if (ent.name.startsWith(".")) continue
179
+ if (!matchesInclude(ent.name, include)) continue
180
+ const rel = relative(rootDir, full).split("\\").join("/")
181
+ out.push({ local_path: full, relative_path: rel })
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Resolve upload entries from either an explicit file list or a directory walk.
187
+ * Exported for unit tests.
188
+ */
189
+ export function collectUploadEntries({
190
+ files,
191
+ directory,
192
+ recursive = true,
193
+ prefix,
194
+ include,
195
+ }) {
196
+ if (Array.isArray(files) && files.length > 0) {
197
+ return files.map((file) => {
198
+ const objectName = file.object_name || basename(file.local_path)
199
+ return {
200
+ local_path: file.local_path,
201
+ object_name: joinStorageKey(file.prefix ?? prefix, objectName),
202
+ content_type: file.content_type || undefined,
203
+ }
204
+ })
205
+ }
206
+
207
+ if (directory) {
208
+ if (!existsSync(directory)) {
209
+ return { error: `Directory not found: ${directory}` }
210
+ }
211
+ const stat = statSync(directory)
212
+ if (!stat.isDirectory()) {
213
+ return { error: `Not a directory: ${directory}` }
214
+ }
215
+
216
+ const discovered = []
217
+ walkDirectory(directory, directory, bool(recursive), include, discovered)
218
+ return discovered.map((entry) => ({
219
+ local_path: entry.local_path,
220
+ object_name: joinStorageKey(prefix, entry.relative_path),
221
+ content_type: undefined,
222
+ }))
223
+ }
224
+
225
+ return { error: "Either `files` (non-empty array) or `directory` is required for upload_files." }
226
+ }
227
+
228
+ async function ensureBucketExists({ bucket, bucket_public, project_id }) {
229
+ try {
230
+ const buckets = await proxyToolCall("manage_storage", {
231
+ operation: "list",
232
+ project_id,
233
+ })
234
+ const list = Array.isArray(buckets) ? buckets : (buckets?.data || [])
235
+ const exists = list.some(b => (b.name || b) === bucket)
236
+ if (!exists) {
237
+ await proxyToolCall("manage_storage", {
238
+ operation: "create",
239
+ project_id,
240
+ name: bucket,
241
+ public: bool(bucket_public),
242
+ })
243
+ }
244
+ return null
245
+ } catch (e) {
246
+ return `ensure_bucket failed: ${e.message}`
247
+ }
248
+ }
249
+
250
+ /** Paginated list_objects sweep; returns null on failure (best-effort). */
251
+ async function listExistingObjectNames({ bucket, project_id, prefix }) {
252
+ const names = new Set()
253
+ let offset = 0
254
+
255
+ try {
256
+ while (true) {
257
+ const resp = await proxyToolCall("manage_storage", {
258
+ operation: "list_objects",
259
+ project_id,
260
+ bucket,
261
+ prefix: prefix || undefined,
262
+ limit: LIST_OBJECTS_PAGE_SIZE,
263
+ offset,
264
+ })
265
+ const list = Array.isArray(resp) ? resp : (resp?.data || [])
266
+ for (const obj of list) {
267
+ const name = typeof obj === "string" ? obj : obj?.name
268
+ if (name) names.add(name)
269
+ }
270
+
271
+ const hasMore = resp?.has_more === true
272
+ || (resp?.has_more == null && list.length >= LIST_OBJECTS_PAGE_SIZE)
273
+ if (!hasMore || list.length === 0) break
274
+ offset += LIST_OBJECTS_PAGE_SIZE
275
+ }
276
+ return names
277
+ } catch {
278
+ return null
279
+ }
280
+ }
281
+
282
+ async function runPool(items, concurrency, fn) {
283
+ const results = new Array(items.length)
284
+ let next = 0
285
+
286
+ async function worker() {
287
+ while (next < items.length) {
288
+ const idx = next++
289
+ results[idx] = await fn(items[idx], idx)
290
+ }
291
+ }
292
+
293
+ const workers = Array.from(
294
+ { length: Math.min(concurrency, items.length) },
295
+ () => worker(),
296
+ )
297
+ await Promise.all(workers)
298
+ return results
299
+ }
300
+
78
301
  /**
79
302
  * Core upload. Returns { success, bucket, name, size, content_type,
80
303
  * signed_url, public_url, object_id, ... } or { success: false, error }.
@@ -99,6 +322,8 @@ export async function uploadFile({
99
322
  // Optional: if provided, the manifest at <source_directory>/dypai/.dypai/media-manifest.json
100
323
  // is updated after a successful upload so future deploys know this file is in the bucket.
101
324
  source_directory,
325
+ skip_preview_url = false,
326
+ max_retries = DEFAULT_MAX_RETRIES,
102
327
  }) {
103
328
  // ── Validation ──────────────────────────────────────────────────────────
104
329
  if (!local_path) return { success: false, error: "`local_path` is required." }
@@ -123,51 +348,47 @@ export async function uploadFile({
123
348
 
124
349
  const filename = object_name || basename(local_path)
125
350
  const resolvedContentType = content_type || mimeFor(filename)
351
+ const retries = Math.max(0, Number(max_retries) || DEFAULT_MAX_RETRIES)
126
352
 
127
353
  // ── Ensure bucket (optional) ────────────────────────────────────────────
128
354
  // If the agent passed ensure_bucket:true and the bucket doesn't exist yet,
129
355
  // create it. Saves a round-trip of "bucket not found → go create → retry".
130
356
  if (ensure_bucket) {
131
- try {
132
- const buckets = await proxyToolCall("manage_storage", {
133
- operation: "list",
134
- project_id,
135
- })
136
- const list = Array.isArray(buckets) ? buckets : (buckets?.data || [])
137
- const exists = list.some(b => (b.name || b) === bucket)
138
- if (!exists) {
139
- await proxyToolCall("manage_storage", {
140
- operation: "create",
141
- project_id,
142
- name: bucket,
143
- public: bool(bucket_public),
144
- })
145
- }
146
- } catch (e) {
147
- return { success: false, error: `ensure_bucket failed: ${e.message}` }
148
- }
357
+ const bucketError = await ensureBucketExists({ bucket, bucket_public, project_id })
358
+ if (bucketError) return { success: false, error: bucketError }
149
359
  }
150
360
 
151
361
  // ── Step 1: sign_upload ─────────────────────────────────────────────────
152
362
  let signed
153
363
  try {
154
- signed = await proxyToolCall("manage_storage", {
155
- operation: "sign_upload",
156
- project_id,
157
- bucket,
158
- filename,
159
- content_type: resolvedContentType,
160
- size_bytes: stat.size,
161
- prefix,
162
- })
364
+ signed = await withRetry(async () => {
365
+ try {
366
+ const result = await proxyToolCall("manage_storage", {
367
+ operation: "sign_upload",
368
+ project_id,
369
+ bucket,
370
+ filename,
371
+ content_type: resolvedContentType,
372
+ size_bytes: stat.size,
373
+ prefix,
374
+ })
375
+ if (!result?.upload_url || !result?.file_path) {
376
+ throw markRetryable(
377
+ new Error("sign_upload returned an invalid payload (no upload_url or file_path)."),
378
+ false,
379
+ )
380
+ }
381
+ return result
382
+ } catch (e) {
383
+ if (e.retryable === false) throw e
384
+ throw markRetryable(new Error(e.message || "sign_upload failed"), isRetryableError(e))
385
+ }
386
+ }, { retries })
163
387
  } catch (e) {
164
388
  return { success: false, error: `sign_upload failed: ${e.message}`, hint: bucketHint(e.message, bucket) }
165
389
  }
166
390
 
167
391
  const { upload_url, method = "PUT", headers = {}, file_path, public_url } = signed || {}
168
- if (!upload_url || !file_path) {
169
- return { success: false, error: "sign_upload returned an invalid payload (no upload_url or file_path)." }
170
- }
171
392
 
172
393
  // ── Step 2: direct PUT to the bucket ────────────────────────────────────
173
394
  let buf
@@ -178,38 +399,48 @@ export async function uploadFile({
178
399
  }
179
400
 
180
401
  try {
181
- const res = await fetch(upload_url, {
182
- method,
183
- headers: {
184
- "Content-Type": resolvedContentType,
185
- ...headers,
186
- },
187
- body: buf,
188
- })
189
- if (!res.ok) {
190
- const detail = await safeReadBody(res)
191
- return {
192
- success: false,
193
- error: `Storage upload failed with status ${res.status}. ${detail ? `Detail: ${detail.slice(0, 300)}` : ""}`.trim(),
402
+ await withRetry(async () => {
403
+ const res = await fetch(upload_url, {
404
+ method,
405
+ headers: {
406
+ "Content-Type": resolvedContentType,
407
+ ...headers,
408
+ },
409
+ body: buf,
410
+ })
411
+ if (!res.ok) {
412
+ const detail = await safeReadBody(res)
413
+ const err = new Error(
414
+ `Storage upload failed with status ${res.status}. ${detail ? `Detail: ${detail.slice(0, 300)}` : ""}`.trim(),
415
+ )
416
+ err.status = res.status
417
+ throw markRetryable(err, isRetryableStatus(res.status))
194
418
  }
195
- }
419
+ }, { retries })
196
420
  } catch (e) {
197
- return { success: false, error: `Direct upload to storage failed: ${e.message}` }
421
+ return { success: false, error: e.message || "Direct upload to storage failed." }
198
422
  }
199
423
 
200
424
  // ── Step 3: verify_upload ───────────────────────────────────────────────
201
425
  let verified
202
426
  try {
203
- verified = await proxyToolCall("manage_storage", {
204
- operation: "verify_upload",
205
- project_id,
206
- bucket,
207
- file_path,
208
- original_filename: filename,
209
- content_type: resolvedContentType,
210
- size_bytes: stat.size,
211
- prefix,
212
- })
427
+ verified = await withRetry(async () => {
428
+ try {
429
+ return await proxyToolCall("manage_storage", {
430
+ operation: "verify_upload",
431
+ project_id,
432
+ bucket,
433
+ file_path,
434
+ original_filename: filename,
435
+ content_type: resolvedContentType,
436
+ size_bytes: stat.size,
437
+ prefix,
438
+ })
439
+ } catch (e) {
440
+ if (e.retryable === false) throw e
441
+ throw markRetryable(new Error(e.message || "verify_upload failed"), isRetryableError(e))
442
+ }
443
+ }, { retries })
213
444
  } catch (e) {
214
445
  return {
215
446
  success: false,
@@ -220,18 +451,20 @@ export async function uploadFile({
220
451
  // ── Step 4: fetch a preview signed download URL ─────────────────────────
221
452
  // Best-effort: if it fails we just skip it, the upload succeeded regardless.
222
453
  let signedDownloadUrl = null
223
- try {
224
- const dl = await proxyToolCall("manage_storage", {
225
- operation: "get_signed_download_url",
226
- project_id,
227
- bucket,
228
- name: verified?.name || filename,
229
- expires_minutes: 15,
230
- download: false,
231
- })
232
- signedDownloadUrl = dl?.signed_url || null
233
- } catch {
234
- // non-fatal
454
+ if (!skip_preview_url) {
455
+ try {
456
+ const dl = await proxyToolCall("manage_storage", {
457
+ operation: "get_signed_download_url",
458
+ project_id,
459
+ bucket,
460
+ name: verified?.name || filename,
461
+ expires_minutes: 15,
462
+ download: false,
463
+ })
464
+ signedDownloadUrl = dl?.signed_url || null
465
+ } catch {
466
+ // non-fatal
467
+ }
235
468
  }
236
469
 
237
470
  // ── Update manifest (if source_directory provided) ──────────────────────
@@ -264,7 +497,139 @@ export async function uploadFile({
264
497
  signed_url: signedDownloadUrl,
265
498
  signed_url_expires_minutes: signedDownloadUrl ? 15 : null,
266
499
  public_url: public_url || null,
267
- message: `✓ Uploaded '${filename}' (${formatBytes(stat.size)}) to bucket '${bucket}'. Manifest updated — future deploys will skip this file.`,
500
+ message: `✓ Uploaded '${filename}' (${formatBytes(stat.size)}) to bucket '${bucket}'.${source_directory ? " Manifest updated — future deploys will skip this file." : ""}`,
501
+ }
502
+ }
503
+
504
+ /**
505
+ * Batch upload many local files in one call. Reuses uploadFile() per entry with
506
+ * bounded concurrency. Returns an aggregated summary instead of per-file noise.
507
+ */
508
+ export async function uploadFiles({
509
+ bucket,
510
+ files,
511
+ directory,
512
+ recursive = true,
513
+ prefix,
514
+ include,
515
+ ensure_bucket = false,
516
+ bucket_public = false,
517
+ project_id,
518
+ source_directory,
519
+ concurrency = DEFAULT_UPLOAD_CONCURRENCY,
520
+ max_retries = DEFAULT_MAX_RETRIES,
521
+ skip_existing = true,
522
+ overwrite = false,
523
+ }) {
524
+ if (!bucket) return { success: false, error: "`bucket` is required (e.g. 'public')." }
525
+
526
+ const collected = collectUploadEntries({ files, directory, recursive, prefix, include })
527
+ if (collected.error) return { success: false, error: collected.error }
528
+
529
+ const entries = collected
530
+ if (entries.length === 0) {
531
+ return { success: false, error: "No files to upload. Check `files`, `directory`, or `include` filters." }
532
+ }
533
+
534
+ if (ensure_bucket) {
535
+ const bucketError = await ensureBucketExists({ bucket, bucket_public, project_id })
536
+ if (bucketError) return { success: false, error: bucketError }
537
+ }
538
+
539
+ const shouldSkip = bool(skip_existing) && !bool(overwrite)
540
+ const skippedResults = []
541
+ let toUpload = entries
542
+
543
+ if (shouldSkip) {
544
+ const existingNames = await listExistingObjectNames({ bucket, project_id, prefix })
545
+ if (existingNames) {
546
+ toUpload = []
547
+ for (const entry of entries) {
548
+ if (existingNames.has(entry.object_name)) {
549
+ skippedResults.push({
550
+ local_path: entry.local_path,
551
+ name: entry.object_name,
552
+ status: "skipped",
553
+ error: null,
554
+ })
555
+ } else {
556
+ toUpload.push(entry)
557
+ }
558
+ }
559
+ }
560
+ }
561
+
562
+ const poolSize = Math.max(
563
+ 1,
564
+ Math.min(Number(concurrency) || DEFAULT_UPLOAD_CONCURRENCY, MAX_UPLOAD_CONCURRENCY),
565
+ )
566
+
567
+ const manifestEntries = {}
568
+
569
+ const poolResults = toUpload.length > 0
570
+ ? await runPool(toUpload, poolSize, async (entry) => {
571
+ const result = await uploadFile({
572
+ local_path: entry.local_path,
573
+ bucket,
574
+ object_name: entry.object_name,
575
+ content_type: entry.content_type,
576
+ ensure_bucket: false,
577
+ bucket_public,
578
+ project_id,
579
+ skip_preview_url: true,
580
+ max_retries,
581
+ })
582
+
583
+ if (result.success && source_directory) {
584
+ manifestEntries[entry.local_path] = {
585
+ size: result.size_bytes,
586
+ bucket,
587
+ object_name: result.storage_path || result.name || entry.object_name,
588
+ content_type: result.content_type,
589
+ uploaded_at: new Date().toISOString(),
590
+ }
591
+ }
592
+
593
+ return {
594
+ local_path: entry.local_path,
595
+ name: result.storage_path || result.name || entry.object_name,
596
+ status: result.success ? "uploaded" : "failed",
597
+ error: result.success ? null : (result.error || "Upload failed"),
598
+ }
599
+ })
600
+ : []
601
+
602
+ if (source_directory && Object.keys(manifestEntries).length > 0) {
603
+ try {
604
+ writeMediaManifestBatch(source_directory, manifestEntries)
605
+ } catch {
606
+ // non-fatal
607
+ }
608
+ }
609
+
610
+ const results = [...skippedResults, ...poolResults]
611
+ const uploaded = results.filter(r => r.status === "uploaded").length
612
+ const failed = results.filter(r => r.status === "failed").length
613
+ const skipped = results.filter(r => r.status === "skipped").length
614
+ const storage_paths = results.filter(r => r.status === "uploaded").map(r => r.name)
615
+
616
+ const parts = []
617
+ if (uploaded > 0) parts.push(`${uploaded} uploaded`)
618
+ if (skipped > 0) parts.push(`${skipped} skipped`)
619
+ if (failed > 0) parts.push(`${failed} failed`)
620
+
621
+ return {
622
+ success: failed === 0 && (uploaded + skipped) > 0,
623
+ bucket,
624
+ total: results.length,
625
+ uploaded,
626
+ skipped,
627
+ failed,
628
+ results,
629
+ storage_paths,
630
+ message: failed === 0
631
+ ? `✓ Batch complete for bucket '${bucket}': ${parts.join(", ")}.`
632
+ : `Batch for bucket '${bucket}': ${parts.join(", ")} — see results[].error.`,
268
633
  }
269
634
  }
270
635
 
@@ -48,6 +48,10 @@ export async function runGenerateEndpointTypes(rootDir, projectId = null) {
48
48
  typesPath,
49
49
  contractPath,
50
50
  endpoints: payload.endpoints || [],
51
+ diagnosticCount: payload.diagnosticCount ?? 0,
52
+ errorCount: payload.errorCount ?? 0,
53
+ warningCount: payload.warningCount ?? 0,
54
+ diagnostics: Array.isArray(payload.diagnostics) ? payload.diagnostics.slice(0, 10) : [],
51
55
  }
52
56
  }
53
57
 
@@ -57,6 +61,10 @@ export async function runGenerateEndpointTypes(rootDir, projectId = null) {
57
61
  typesPath: payload.typesPath,
58
62
  contractPath: payload.contractPath,
59
63
  endpoints: payload.endpoints || [],
64
+ diagnosticCount: payload.diagnosticCount ?? 0,
65
+ errorCount: payload.errorCount ?? 0,
66
+ warningCount: payload.warningCount ?? 0,
67
+ diagnostics: Array.isArray(payload.diagnostics) ? payload.diagnostics.slice(0, 10) : [],
60
68
  }
61
69
  }
62
70
 
@@ -112,8 +120,16 @@ export const dypaiGenerateTypesTool = {
112
120
  types_path: "dypai/types/endpoints.gen.ts",
113
121
  contract_path: "dypai/types/endpoints.contract.json",
114
122
  endpoints: outcome.endpoints.slice(0, 50),
123
+ diagnostics_summary: outcome.diagnosticCount
124
+ ? {
125
+ total: outcome.diagnosticCount,
126
+ errors: outcome.errorCount,
127
+ warnings: outcome.warningCount,
128
+ sample: outcome.diagnostics,
129
+ }
130
+ : undefined,
115
131
  hint: outcome.endpointCount
116
- ? `Types refreshed for ${outcome.endpointCount} endpoint(s). Frontend can import from dypai/types/endpoints.gen.ts.`
132
+ ? `Types refreshed for ${outcome.endpointCount} endpoint(s). Frontend can import from dypai/types/endpoints.gen.ts.${outcome.errorCount ? " Backend errors were found; run dypai_validate before trusting frontend call sites." : ""}`
117
133
  : "No effective endpoints found — types file is empty.",
118
134
  }
119
135
  },