@gentbajko/slopify 3.16.0 → 3.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/dist/edge/http/actions.js +2 -2
  2. package/dist/edge/http/actor.js +10 -0
  3. package/dist/edge/http/app.js +21 -0
  4. package/dist/edge/http/checkpoints.js +14 -6
  5. package/dist/edge/http/external-reviews.js +95 -0
  6. package/dist/edge/http/projects.js +36 -18
  7. package/dist/edge/http/reviews.js +53 -18
  8. package/dist/edge/http/revisions.js +5 -1
  9. package/dist/edge/http/settings.js +9 -1
  10. package/dist/edge/http/starts.js +41 -0
  11. package/dist/edge/http/studio.js +5 -130
  12. package/dist/edge/mcp/attribution.js +38 -0
  13. package/dist/edge/mcp/call-route.js +203 -0
  14. package/dist/edge/mcp/excluded-routes.js +95 -0
  15. package/dist/edge/mcp/file-tools.js +141 -0
  16. package/dist/edge/mcp/http.js +79 -0
  17. package/dist/edge/mcp/instructions.js +17 -0
  18. package/dist/edge/mcp/library-tools.js +143 -0
  19. package/dist/edge/mcp/remake-tools.js +90 -0
  20. package/dist/edge/mcp/review-tools.js +64 -0
  21. package/dist/edge/mcp/route-tool.js +61 -0
  22. package/dist/edge/mcp/server.js +48 -0
  23. package/dist/edge/mcp/state-shapes.js +60 -0
  24. package/dist/edge/mcp/tools-channels.js +153 -0
  25. package/dist/edge/mcp/tools-library.js +151 -0
  26. package/dist/edge/mcp/tools-planning.js +137 -0
  27. package/dist/edge/mcp/tools-projects.js +167 -0
  28. package/dist/edge/mcp/tools-publishing.js +148 -0
  29. package/dist/edge/mcp/tools-runs.js +94 -0
  30. package/dist/edge/mcp/tools-schedules.js +143 -0
  31. package/dist/edge/mcp/tools-setup.js +234 -0
  32. package/dist/edge/mcp/tools-storage.js +99 -0
  33. package/dist/edge/mcp/tools.js +21 -0
  34. package/dist/extension/slopify-studio-chrome.zip +0 -0
  35. package/dist/extension/slopify-studio-firefox.zip +0 -0
  36. package/dist/kernel/db/migrations/0055-external-reviews.sql +56 -0
  37. package/dist/kernel/db/migrations/0056-agent-actions.sql +20 -0
  38. package/dist/kernel/db/migrations/0057-agent-action-once.sql +5 -0
  39. package/dist/kernel/db/migrations/0058-revision-work-admission.sql +3 -0
  40. package/dist/kernel/db/migrations/0059-external-review-canceled.sql +29 -0
  41. package/dist/kernel/db/migrations/0060-reservation-work.sql +4 -0
  42. package/dist/kernel/db/migrations/0061-project-control-counts.sql +4 -0
  43. package/dist/kernel/db/migrations/0062-told-failures.sql +8 -0
  44. package/dist/kernel/pipeline.js +6 -0
  45. package/dist/kernel/runner/graph.js +37 -2
  46. package/dist/kernel/runner/index.js +6 -3
  47. package/dist/main.js +29 -13
  48. package/dist/patch-notes/3.17.0.md +10 -0
  49. package/dist/patch-notes/3.18.0.md +13 -0
  50. package/dist/patch-notes/index.json +12 -0
  51. package/dist/slices/admission/admitted-work.js +53 -0
  52. package/dist/slices/admission/heads.js +5 -0
  53. package/dist/slices/admission/repo.js +47 -29
  54. package/dist/slices/admission/schema.js +1 -0
  55. package/dist/slices/admission/stage-writes.js +21 -0
  56. package/dist/slices/admission/start.js +2 -1
  57. package/dist/slices/admission/state-words.js +110 -0
  58. package/dist/slices/agents/attention-work.js +233 -0
  59. package/dist/slices/agents/attention.js +168 -0
  60. package/dist/slices/agents/connection.js +15 -0
  61. package/dist/slices/agents/model.js +29 -0
  62. package/dist/slices/agents/repo.js +66 -0
  63. package/dist/slices/cancel/index.js +4 -1
  64. package/dist/slices/control/index.js +2 -1
  65. package/dist/slices/episodes/summarize.js +2 -1
  66. package/dist/slices/estimate/index.js +6 -2
  67. package/dist/slices/images/run.js +2 -1
  68. package/dist/slices/library/transfer.js +69 -0
  69. package/dist/slices/narration/run.js +2 -1
  70. package/dist/slices/notifications/address.js +40 -0
  71. package/dist/slices/notifications/notifier.js +43 -10
  72. package/dist/slices/notifications/rules.js +111 -45
  73. package/dist/slices/notifications/server.js +14 -0
  74. package/dist/slices/notifications/settings.js +1 -1
  75. package/dist/slices/notifications/subject.js +30 -0
  76. package/dist/slices/notifications/told.js +33 -0
  77. package/dist/slices/play-drafts/readiness.js +9 -2
  78. package/dist/slices/play-drafts/schema.js +2 -1
  79. package/dist/slices/rebuild/narration-words.js +76 -0
  80. package/dist/slices/rebuild/recipe-model.js +1 -0
  81. package/dist/slices/rebuild/recipe-reviews.js +44 -23
  82. package/dist/slices/rebuild/recovery-repo.js +0 -20
  83. package/dist/slices/rebuild/recovery-selection.js +14 -3
  84. package/dist/slices/rebuild/recovery.js +5 -2
  85. package/dist/slices/rebuild/remake-outdated.js +27 -0
  86. package/dist/slices/rebuild/repo.js +7 -2
  87. package/dist/slices/rebuild/review-redo.js +12 -0
  88. package/dist/slices/rebuild/runtime-agent-review.js +121 -0
  89. package/dist/slices/rebuild/runtime-export-inputs.js +26 -11
  90. package/dist/slices/rebuild/runtime-review.js +12 -2
  91. package/dist/slices/rebuild/runtime-run.js +11 -2
  92. package/dist/slices/rebuild/runtime-store.js +32 -5
  93. package/dist/slices/reruns/index.js +2 -1
  94. package/dist/slices/research/run.js +2 -1
  95. package/dist/slices/reviews/external-model.js +21 -0
  96. package/dist/slices/reviews/external-release.js +38 -0
  97. package/dist/slices/reviews/external-repo.js +109 -0
  98. package/dist/slices/reviews/external-rows.js +68 -0
  99. package/dist/slices/reviews/external-waiting.js +75 -0
  100. package/dist/slices/reviews/holds.js +144 -0
  101. package/dist/slices/reviews/model.js +14 -0
  102. package/dist/slices/reviews/repo.js +27 -10
  103. package/dist/slices/reviews/rules.js +5 -3
  104. package/dist/slices/reviews/verdict.js +18 -8
  105. package/dist/slices/revisions/repo.js +3 -0
  106. package/dist/slices/revisions/schema.js +1 -0
  107. package/dist/slices/schedules/agenda.js +47 -17
  108. package/dist/slices/schedules/prepare.js +6 -0
  109. package/dist/slices/schedules/schema.js +13 -1
  110. package/dist/slices/storage/consequences.js +27 -0
  111. package/dist/slices/storage/portable.js +3 -0
  112. package/dist/slices/storage/project-files.js +66 -0
  113. package/dist/slices/studio/settings.js +0 -35
  114. package/dist/slices/studio/stats.js +2 -34
  115. package/dist/slices/subtitles/prepare.js +2 -1
  116. package/dist/slices/uploads/repo.js +12 -6
  117. package/dist/slices/uploads/rules.js +5 -0
  118. package/dist/slices/video/write-export.js +1 -1
  119. package/dist/slices/video/write-subtitles.js +1 -1
  120. package/dist/tutorials/Agents-MCP.md +168 -0
  121. package/dist/tutorials/Calendar.md +2 -1
  122. package/dist/tutorials/First-Launch-and-Welcome.md +1 -0
  123. package/dist/tutorials/Home-and-Projects.md +16 -9
  124. package/dist/tutorials/Home.md +1 -0
  125. package/dist/tutorials/Notifications.md +25 -6
  126. package/dist/tutorials/Project-Page.md +8 -2
  127. package/dist/tutorials/Publishing-to-YouTube.md +1 -0
  128. package/dist/tutorials/Recovery-and-Retries.md +17 -3
  129. package/dist/tutorials/Reviews-and-Checkpoints.md +22 -5
  130. package/dist/tutorials/Schedules.md +1 -1
  131. package/dist/tutorials/Settings-Reference.md +15 -0
  132. package/dist/tutorials/Troubleshooting.md +3 -1
  133. package/dist/tutorials/Your-First-Short.md +1 -0
  134. package/dist/tutorials/_Sidebar.md +1 -0
  135. package/dist/web/assets/{index-VbpUYLAp.css → index-BKbZIxAq.css} +1 -1
  136. package/dist/web/assets/index-Drth_MfQ.js +325 -0
  137. package/dist/web/assets/{pdf-C6KhxeKQ.js → pdf-CQFUyijy.js} +1 -1
  138. package/dist/web/index.html +2 -2
  139. package/package.json +1 -1
  140. package/dist/slices/studio/performance.js +0 -83
  141. package/dist/slices/studio/report-stats.js +0 -39
  142. package/dist/slices/studio/report.js +0 -172
  143. package/dist/web/assets/index-Dvz8bsiD.js +0 -325
@@ -3,7 +3,6 @@ import { join } from "node:path";
3
3
  import { Readable } from "node:stream";
4
4
  import { zValidator } from "@hono/zod-validator";
5
5
  import { Hono } from "hono";
6
- import { bodyLimit } from "hono/body-limit";
7
6
  import { z } from "zod";
8
7
  import { transact } from "../../kernel/db/tx.js";
9
8
  import { derive } from "../../kernel/runner/graph.js";
@@ -16,16 +15,13 @@ import { backfillVideos } from "../../slices/studio/backfill.js";
16
15
  import { releaseCalendar } from "../../slices/studio/calendar.js";
17
16
  import { playlistUses, studioPlaylistMax, studioUploadUrl, } from "../../slices/studio/model.js";
18
17
  import { packItem, uploadPack } from "../../slices/studio/pack.js";
19
- import { channelPerformance } from "../../slices/studio/performance.js";
20
18
  import { writeUploadPick } from "../../slices/studio/pick.js";
21
19
  import { postingPlanSchema, readPlan, readStoredPlan, weekdayIn, writePlan, } from "../../slices/studio/plan.js";
22
20
  import { leadHoursMax, seriesOf } from "../../slices/studio/plan-model.js";
23
21
  import { enqueueFill, fillOnly, readFillQueue, removeFill, } from "../../slices/studio/queue.js";
24
22
  import { allReleases, freeSlots, planReleases, readLeadHours, releasesOf, setRelease, swapReleases, writeLeadHours, } from "../../slices/studio/releases.js";
25
- import { parseStudioExport, readReport, saveReport } from "../../slices/studio/report.js";
26
- import { statsFromReport } from "../../slices/studio/report-stats.js";
27
- import { autoCommentKey, bearerToken, exportViewProblem, exportViews, isExtensionOrigin, pairStudioExtension, readChannelPlaylists, readExportView, readStudioPlaylists, resetStudioPairing, saveExportView, saveProjectPlaylists, saveRealFootage, saveStudioPlaylists, studioPairing, studioPlaylistsMax, studioPlaylistsProblem, studioRequestAllowed, } from "../../slices/studio/settings.js";
28
- import { abResults, abVariantSchema, projectStats, saveAbResult, saveStats, } from "../../slices/studio/stats.js";
23
+ import { autoCommentKey, bearerToken, isExtensionOrigin, pairStudioExtension, readChannelPlaylists, readStudioPlaylists, resetStudioPairing, saveProjectPlaylists, saveRealFootage, saveStudioPlaylists, studioPairing, studioPlaylistsMax, studioPlaylistsProblem, studioRequestAllowed, } from "../../slices/studio/settings.js";
24
+ import { abResults, abVariantSchema, saveAbResult } from "../../slices/studio/stats.js";
29
25
  import { confirmUpload, doneVideos, forgetVideo, projectVideos, recordedVideos, recordVideo, setChecks, setTaskState, videoIdOf, videoIdPattern, videoOf, waitingTasks, } from "../../slices/studio/videos.js";
30
26
  import { setAsideProjects, uploadedProjects } from "../../slices/uploads/repo.js";
31
27
  import { onInvalid, problem, titleOf } from "./problem.js";
@@ -89,15 +85,11 @@ const taskResultBody = z.object({
89
85
  ok: z.boolean(),
90
86
  message: z.string().max(2000),
91
87
  });
88
+ // An A/B test's result as Studio shows it; older extensions also send numbers, which are ignored.
92
89
  const statsBody = z.object({
93
90
  projectId: id,
94
91
  short: shortField,
95
92
  videoId: z.string().regex(videoIdPattern),
96
- impressions: z.number().nonnegative().optional(),
97
- ctr: z.number().min(0).max(100).optional(),
98
- views: z.number().nonnegative().optional(),
99
- averageViewSeconds: z.number().nonnegative().optional(),
100
- watchHours: z.number().nonnegative().optional(),
101
93
  abVariants: z.array(abVariantSchema).max(3).optional(),
102
94
  });
103
95
  const backfillBody = z.object({
@@ -192,24 +184,6 @@ export function studioRoutes(deps) {
192
184
  .filter((one) => one !== "")
193
185
  .toSorted(),
194
186
  });
195
- // A report and, for each of its videos Slopify made, the project and which upload it is.
196
- const reportBody = (channelId) => {
197
- const report = readReport(deps.db, channelId);
198
- const known = new Map(listProjects(deps.db).flatMap((project) => projectVideos(deps.db, project.id).map((video) => [
199
- video.videoId,
200
- { projectId: project.id, short: video.short, projectTitle: project.title },
201
- ])));
202
- return {
203
- exportView: readExportView(deps.db, channelId),
204
- report,
205
- projects: report === null
206
- ? {}
207
- : Object.fromEntries(report.rows.flatMap((row) => {
208
- const found = known.get(row.videoId);
209
- return found === undefined ? [] : [[row.videoId, found]];
210
- })),
211
- };
212
- };
213
187
  const slotChoices = (projectId, series) => freeSlots(deps.db, readPlan(deps.db), whoOf(projectId, series), deps.clock.now(), 9);
214
188
  // Finished projects not marked uploaded: the popup's list and the calendar's candidates.
215
189
  const finishedProjects = () => {
@@ -469,86 +443,8 @@ export function studioRoutes(deps) {
469
443
  return c.json({ chosen: { projectId, short: short ?? null }, queue: queueView(queue) });
470
444
  })
471
445
  // What waits for the extension, oldest first: each new upload dialog takes the first.
472
- // Studio's numbers for a project's videos, and every finished A/B test (Library).
473
- .get("/stats/:projectId", zValidator("param", projectParam, onInvalid), (c) => c.json({ stats: projectStats(deps.db, c.req.valid("param").projectId) }))
446
+ // Every finished A/B test (Library).
474
447
  .get("/ab-results", (c) => c.json({ results: abResults(deps.db) }))
475
- // Channels → YouTube numbers: Studio's Advanced-mode export, imported as the zip Studio
476
- // downloads, and which project each of its videos is.
477
- .get("/channels/:channelId/report", zValidator("param", z.object({ channelId: id }), onInvalid), (c) => c.json(reportBody(c.req.valid("param").channelId)))
478
- .post("/channels/:channelId/report", zValidator("param", z.object({ channelId: id }), onInvalid), bodyLimit({
479
- maxSize: 30 * 1024 * 1024,
480
- onError: (c) => problem(c, {
481
- status: 413,
482
- title: titleOf(413),
483
- detail: "That file is larger than 30 MB, which a Studio export never is. Export again from Studio's Advanced mode and import the zip it downloads.",
484
- }),
485
- }), async (c) => {
486
- const denied = samePage(c);
487
- if (denied !== undefined)
488
- return denied;
489
- const { channelId } = c.req.valid("param");
490
- const parsed = parseStudioExport(new Uint8Array(await c.req.arrayBuffer()), deps.clock.now().toISOString());
491
- if (!parsed.ok)
492
- return problem(c, { status: 400, title: titleOf(400), detail: parsed.message });
493
- saveReport(deps.db, channelId, parsed.report);
494
- statsFromReport(deps.db, parsed.report, parsed.report.importedAt);
495
- return c.json(reportBody(channelId));
496
- })
497
- // The Analytics view the extension exports every day for this channel; null forgets it.
498
- .put("/channels/:channelId/export-view", zValidator("param", z.object({ channelId: id }), onInvalid), zValidator("json", z.object({ url: z.string().max(4000).nullable() }), onInvalid), (c) => {
499
- const denied = samePage(c);
500
- if (denied !== undefined)
501
- return denied;
502
- const { channelId } = c.req.valid("param");
503
- const { url } = c.req.valid("json");
504
- const wrong = url === null || url.trim() === "" ? undefined : exportViewProblem(url);
505
- if (wrong !== undefined)
506
- return problem(c, {
507
- status: 400,
508
- title: titleOf(400),
509
- detail: wrong,
510
- extensions: { fields: [{ field: "url", message: wrong }] },
511
- });
512
- saveExportView(deps.db, channelId, url);
513
- return c.json(reportBody(channelId));
514
- })
515
- // The extension: each channel's Analytics view to export.
516
- .get("/ext/export-views", (c) => {
517
- allowOrigin(c, false);
518
- if (!extAllowed(c))
519
- return refused(c);
520
- return c.json({ views: exportViews(deps.db) });
521
- })
522
- // The extension exported a channel's view: the zip Studio's Export makes, as Studio sends
523
- // it (base64). Saved as an import would be, and each known video's numbers with it.
524
- .post("/ext/report", bodyLimit({
525
- maxSize: 40 * 1024 * 1024,
526
- onError: (c) => problem(c, {
527
- status: 413,
528
- title: titleOf(413),
529
- detail: "Studio's export was larger than Slopify takes (40 MB).",
530
- }),
531
- }), zValidator("json", z.object({
532
- channelId: id,
533
- zippedData: z
534
- .string()
535
- .min(1)
536
- .max(40 * 1024 * 1024),
537
- }), onInvalid), (c) => {
538
- allowOrigin(c, false);
539
- if (!extAllowed(c))
540
- return refused(c);
541
- const { channelId, zippedData } = c.req.valid("json");
542
- const now = deps.clock.now().toISOString();
543
- const parsed = parseStudioExport(new Uint8Array(Buffer.from(zippedData, "base64url")), now);
544
- if (!parsed.ok)
545
- return problem(c, { status: 400, title: titleOf(400), detail: parsed.message });
546
- saveReport(deps.db, channelId, parsed.report);
547
- const videos = statsFromReport(deps.db, parsed.report, now);
548
- return c.json({ rows: parsed.report.rows.length, videos });
549
- })
550
- // Channels → YouTube: the channel's videos on YouTube with Studio's numbers and totals.
551
- .get("/channels/:channelId/performance", zValidator("param", z.object({ channelId: id }), onInvalid), (c) => c.json(channelPerformance(deps, c.req.valid("param").channelId)))
552
448
  // The YouTube videos a project's uploads became, and their A/B tests.
553
449
  .get("/videos/:projectId", zValidator("param", projectParam, onInvalid), (c) => c.json({ videos: projectVideos(deps.db, c.req.valid("param").projectId) }))
554
450
  // A video's link pasted in Slopify, for an upload made without the extension; an empty
@@ -797,13 +693,12 @@ export function studioRoutes(deps) {
797
693
  setTaskState(deps.db, task, projectId, short ?? null, ok ? "done" : "failed", message);
798
694
  return c.json({ ok: true });
799
695
  })
800
- // Every video on YouTube, for the numbers the extension reads from Studio.
696
+ // The long videos whose A/B test is running, for the extension to read each result.
801
697
  .get("/ext/known-videos", (c) => {
802
698
  allowOrigin(c, false);
803
699
  if (!extAllowed(c))
804
700
  return refused(c);
805
701
  return c.json({
806
- // The export brings every video's numbers; only an A/B test's result needs a visit.
807
702
  videos: doneVideos(deps.db)
808
703
  .filter((video) => video.short === null && video.abState === "started")
809
704
  .map((video) => ({
@@ -864,26 +759,6 @@ export function studioRoutes(deps) {
864
759
  return refused(c);
865
760
  const body = c.req.valid("json");
866
761
  const readAt = deps.clock.now().toISOString();
867
- // A visit for the A/B result alone brings no numbers: the export's are kept.
868
- const numbers = [
869
- body.impressions,
870
- body.ctr,
871
- body.views,
872
- body.averageViewSeconds,
873
- body.watchHours,
874
- ].some((one) => one !== undefined);
875
- if (numbers)
876
- saveStats(deps.db, {
877
- projectId: body.projectId,
878
- short: body.short ?? null,
879
- videoId: body.videoId,
880
- readAt,
881
- impressions: body.impressions ?? null,
882
- ctr: body.ctr ?? null,
883
- views: body.views ?? null,
884
- averageViewSeconds: body.averageViewSeconds ?? null,
885
- watchHours: body.watchHours ?? null,
886
- });
887
762
  if (body.abVariants !== undefined && body.abVariants.length > 1)
888
763
  saveAbResult(deps.db, {
889
764
  projectId: body.projectId,
@@ -0,0 +1,38 @@
1
+ import { z } from "zod";
2
+ import { projectsMadeBy } from "../../slices/agents/model.js";
3
+ import { paramsOf, routeOf } from "./route-tool.js";
4
+ const movedBody = z.object({ projectIds: z.array(z.string()) }).loose();
5
+ const trashBody = z
6
+ .object({ items: z.array(z.object({ kind: z.string(), id: z.string() })) })
7
+ .loose();
8
+ const savedVersion = z
9
+ .object({ view: z.object({ revision: z.object({ id: z.string() }) }) })
10
+ .loose();
11
+ // The projects a tool changed (the one its path names, those its body lists, or those its
12
+ // answer made) and the schedule it changed.
13
+ export function subjectsOf(tool, args, value) {
14
+ const { path } = routeOf(tool);
15
+ return {
16
+ projectIds: projectIdsOf(path, args, value),
17
+ scheduleId: path.startsWith("/api/schedules/:id") && typeof args.id === "string" ? args.id : null,
18
+ };
19
+ }
20
+ function projectIdsOf(path, args, value) {
21
+ if (path.includes("/projects/:id") && typeof args.id === "string")
22
+ return [args.id];
23
+ if (paramsOf(path).includes("projectId") && typeof args.projectId === "string")
24
+ return [args.projectId];
25
+ if (path.startsWith("/api/trash/:kind/:id"))
26
+ return args.kind === "project" && typeof args.id === "string" ? [args.id] : [];
27
+ const moved = movedBody.safeParse(args.body);
28
+ if (moved.success)
29
+ return moved.data.projectIds;
30
+ const trashed = trashBody.safeParse(args.body);
31
+ if (trashed.success)
32
+ return trashed.data.items.filter((item) => item.kind === "project").map((item) => item.id);
33
+ return projectsMadeBy(value);
34
+ }
35
+ export function revisionIdOf(value) {
36
+ const saved = savedVersion.safeParse(value);
37
+ return saved.success ? saved.data.view.revision.id : null;
38
+ }
@@ -0,0 +1,203 @@
1
+ import { z } from "zod";
2
+ import { causedBy } from "../../kernel/errors.js";
3
+ import { recordAgentAction } from "../../slices/agents/repo.js";
4
+ import { revisionIdOf, subjectsOf } from "./attribution.js";
5
+ import { fileArg, paramsOf, pathParam, routeOf } from "./route-tool.js";
6
+ const problemSchema = z
7
+ .object({
8
+ title: z.string(),
9
+ detail: z.string().optional(),
10
+ reason: z.string().optional(),
11
+ currentRevisionId: z.string().nullable().optional(),
12
+ fields: z.array(z.object({ field: z.string(), message: z.string() })).optional(),
13
+ errors: z.array(z.object({ path: z.string(), message: z.string() })).optional(),
14
+ })
15
+ .loose();
16
+ const querySchema = z.record(z.string(), z.string());
17
+ const bodySchema = z.record(z.string(), z.unknown());
18
+ const keyedBody = z.object({ idempotencyKey: z.string() }).loose();
19
+ export function text(value) {
20
+ return { content: [{ type: "text", text: value }] };
21
+ }
22
+ export function refusal(value) {
23
+ return { isError: true, content: [{ type: "text", text: value }] };
24
+ }
25
+ export async function callRoute(deps, tool, args) {
26
+ const { method, path } = routeOf(tool);
27
+ const named = paramsOf(path).find((name) => !pathParam.safeParse(args[name]).success);
28
+ if (named !== undefined)
29
+ return strayPath(tool, named);
30
+ const filled = filledPath(path, args);
31
+ const url = new URL(filled, "http://localhost");
32
+ // Defense in depth: pathParam already keeps dots and slashes out, but should that rule ever
33
+ // loosen, a URL that resolves off the tool's own route still never reaches another one.
34
+ if (url.pathname !== filled)
35
+ return strayPath(tool, paramsOf(path).join(", "));
36
+ if (tool.confirm !== undefined && args.confirmed !== true)
37
+ return refusal(`${(await confirmText(deps, tool, args)) ?? tool.confirm} The web page asks for a second press here: call ${tool.name} again with confirmed: true to go ahead.`);
38
+ const query = querySchema.safeParse(args.query);
39
+ if (query.success)
40
+ for (const [key, value] of Object.entries(query.data))
41
+ url.searchParams.set(key, value);
42
+ const response = await deps.api(new Request(url, { method, ...payloadOf(tool, args) }));
43
+ const type = response.headers.get("content-type") ?? "";
44
+ const value = response.ok && type.includes("json") && !type.includes("problem")
45
+ ? await response.json()
46
+ : undefined;
47
+ const result = await answerOf(deps, tool, response, value, {
48
+ called: `${method} ${url.pathname}`,
49
+ ...projectOf(tool, args),
50
+ });
51
+ if (method !== "GET" && tool.unrecorded !== true && response.ok) {
52
+ const { projectIds, scheduleId } = subjectsOf(tool, args, value);
53
+ const key = keyedBody.safeParse(args.body);
54
+ for (const projectId of projectIds.length === 0 ? [null] : projectIds)
55
+ recordAgentAction(deps.db, {
56
+ id: deps.ids.next(),
57
+ projectId,
58
+ scheduleId,
59
+ tool: tool.name,
60
+ title: tool.title,
61
+ revisionId: revisionIdOf(value),
62
+ createdAt: deps.clock.now().toISOString(),
63
+ }, key.success ? key.data.idempotencyKey : undefined);
64
+ }
65
+ return result;
66
+ }
67
+ // The consequence with this call's numbers, as the page states it; undefined when they can't
68
+ // be read, and the tool's fixed sentence is said instead.
69
+ async function confirmText(deps, tool, args) {
70
+ if (tool.confirmFrom === undefined)
71
+ return undefined;
72
+ const path = filledPath(tool.confirmFrom.route, args);
73
+ try {
74
+ const response = await deps.api(new Request(new URL(path, "http://localhost")));
75
+ return response.ok ? tool.confirmFrom.text(await response.json()) : undefined;
76
+ }
77
+ catch (error) {
78
+ deps.log.write("warn", "mcp.confirm", {
79
+ ...projectOf(tool, args),
80
+ detail: `${tool.name} asked with its fixed consequence: reading ${path} for the numbers failed: ${causedBy(error)}`,
81
+ });
82
+ return undefined;
83
+ }
84
+ }
85
+ // The project a call is about, for its log lines.
86
+ function projectOf(tool, args) {
87
+ const [projectId] = subjectsOf(tool, args, undefined).projectIds;
88
+ return projectId === undefined ? {} : { projectId };
89
+ }
90
+ // The route's path with this call's (already checked) parameters in place.
91
+ function filledPath(path, args) {
92
+ return path.replace(/:(\w+)/g, (_, name) => String(args[name]));
93
+ }
94
+ function strayPath(tool, named) {
95
+ return refusal(`${tool.name} was refused: ${named} must be an id exactly as Slopify listed it (letters, digits, - and _ only), so the call stays on ${tool.route}. Take the id from the tool that listed it and call ${tool.name} again.`);
96
+ }
97
+ function payloadOf(tool, args) {
98
+ if (tool.upload !== undefined) {
99
+ const file = fileArg.parse(args.file);
100
+ const bytes = new Uint8Array(Buffer.from(file.base64, "base64"));
101
+ if (tool.upload === "raw")
102
+ return { body: bytes, headers: { "content-type": file.contentType } };
103
+ const form = new FormData();
104
+ form.append("file", new File([bytes], file.name, { type: file.contentType }));
105
+ return { body: form };
106
+ }
107
+ const body = bodySchema.safeParse(args.body);
108
+ return body.success
109
+ ? { body: JSON.stringify(body.data), headers: { "content-type": "application/json" } }
110
+ : {};
111
+ }
112
+ async function answerOf(deps, tool, response, value, call) {
113
+ const type = response.headers.get("content-type") ?? "";
114
+ if (!response.ok || type.startsWith("application/problem+json"))
115
+ return refusal((await failureOf(deps, tool, response, call)).message);
116
+ if (response.status === 204 || response.body === null)
117
+ return text("Done.");
118
+ if (type.startsWith("image/"))
119
+ return {
120
+ content: [
121
+ {
122
+ type: "image",
123
+ data: Buffer.from(await response.arrayBuffer()).toString("base64"),
124
+ mimeType: type.split(";")[0] ?? type,
125
+ },
126
+ ],
127
+ };
128
+ if (type.includes("json"))
129
+ return text(JSON.stringify(tool.shape === undefined ? value : tool.shape(value)));
130
+ if (type.startsWith("text/"))
131
+ return text(await response.text());
132
+ return text(`${tool.title} answered with a ${type === "" ? "binary" : type} file of ${response.headers.get("content-length") ?? "unknown"} bytes; read project files with get_project_file.`);
133
+ }
134
+ // One in-process call to the API as the agent, for a tool that makes several of them
135
+ // (import_library, preview_remake_outdated): the JSON it answered, or the refusal worded as a
136
+ // route tool's own call words it.
137
+ export async function callApi(deps, tool, call) {
138
+ const { method, path, body } = call;
139
+ const response = await deps.api(new Request(new URL(path, "http://localhost"), {
140
+ method,
141
+ ...(body === undefined
142
+ ? {}
143
+ : { headers: { "content-type": "application/json" }, body: JSON.stringify(body) }),
144
+ }));
145
+ if (response.ok)
146
+ return { ok: true, value: response.body === null ? undefined : await response.json() };
147
+ return {
148
+ ok: false,
149
+ status: response.status,
150
+ ...(await failureOf(deps, tool, response, {
151
+ called: `${method} ${path}`,
152
+ ...(call.projectId === undefined ? {} : { projectId: call.projectId }),
153
+ })),
154
+ };
155
+ }
156
+ // What failed and why, with what to do next, from a refused or failed answer. A refusal that
157
+ // can't be read is logged with the call and the status.
158
+ async function failureOf(deps, tool, response, call) {
159
+ const type = response.headers.get("content-type") ?? "";
160
+ if (!type.startsWith("application/problem+json"))
161
+ return {
162
+ message: `${tool.title} failed: Slopify answered HTTP ${String(response.status)} without saying why. Call ${tool.name} again; if it keeps failing, use Download diagnostics in Settings and report it.`,
163
+ fields: [],
164
+ };
165
+ let body;
166
+ let unparsed;
167
+ try {
168
+ body = await response.json();
169
+ }
170
+ catch (error) {
171
+ unparsed = error;
172
+ }
173
+ const problem = problemSchema.safeParse(body);
174
+ if (!problem.success) {
175
+ deps.log.write("warn", "mcp.refusal", {
176
+ ...(call.projectId === undefined ? {} : { projectId: call.projectId }),
177
+ detail: `${tool.name}: ${call.called} answered HTTP ${String(response.status)} with a refusal that could not be read: ${unparsed === undefined ? problem.error.message : causedBy(unparsed)}`,
178
+ });
179
+ return {
180
+ message: `${tool.title} was refused, and the refusal could not be read. Call ${tool.name} again; if it keeps happening, use Download diagnostics in Settings and report it.`,
181
+ fields: [],
182
+ };
183
+ }
184
+ return { message: problemText(tool, problem.data), fields: problem.data.fields ?? [] };
185
+ }
186
+ function problemText(tool, problem) {
187
+ const { title, detail, reason, currentRevisionId, fields, errors } = problem;
188
+ // The route's own sentence, except the schema refusal's "reload the page", which fixes
189
+ // nothing for an agent: it is told which arguments were wrong instead.
190
+ const lead = errors === undefined
191
+ ? (detail ?? title)
192
+ : `${tool.name} was refused: its arguments don't match what Slopify accepts (${errors
193
+ .map((error) => `${error.path === "" ? "body" : error.path}: ${error.message}`)
194
+ .join("; ")}). Correct them and call ${tool.name} again.`;
195
+ const unsaid = (fields ?? []).filter((field) => !lead.includes(field.message));
196
+ const named = unsaid.length === 0
197
+ ? ""
198
+ : ` ${unsaid.map((field) => `${field.field}: ${field.message}`).join(" ")}`;
199
+ const moved = reason === "conflict" && typeof currentRevisionId === "string"
200
+ ? ` The project's current revision is ${currentRevisionId}: read it with get_project, then call ${tool.name} once more with baseRevisionId ${currentRevisionId} and a new idempotencyKey.`
201
+ : "";
202
+ return `${lead}${named}${moved}`;
203
+ }
@@ -0,0 +1,95 @@
1
+ // The API routes agents are not offered, each with the reason. `tools-parity.test.ts` fails
2
+ // when a route is neither a tool nor listed here.
3
+ const keys = "provider keys are credentials; agents see providers only as configured or missing";
4
+ const backups = "backups (export, import, restore and their schedule) are the person's";
5
+ const updater = "updating Slopify is the person's";
6
+ const hostSetup = "host setup (start at login, CLI paths, the extension's download) is the person's";
7
+ const desktop = "opens a window on the person's desktop; agents read files with get_project_file";
8
+ const listening = "sound and pictures for the person watching the page";
9
+ const personal = "the person's own tutorial, notices and reading state";
10
+ const retired = "retired: answers revision-required; edits go through save_revision";
11
+ const extension = "only the paired Slopify Studio extension calls these";
12
+ const youtube = "acts on YouTube: agents stop at Ready to upload, and uploading is the person's (the extension's queue is filled only from Prepare upload)";
13
+ const cleanup = "every project's cleanup is the person's: Delete all old versions and Clean up everything are in Settings → Backup & storage";
14
+ const notStarted = "not a page action: projects start from Play drafts (start_draft) after their review and estimate";
15
+ export const excludedRoutes = {
16
+ "GET /api/health": "the MCP connection itself shows Slopify is running",
17
+ "GET /api/events/global": "a live stream for open pages; agents read needs_attention",
18
+ "GET /api/events/projects/:id": "a live stream for open pages; agents read needs_attention",
19
+ "POST /api/storage/files/open": desktop,
20
+ "POST /api/projects/:id/open-folder": desktop,
21
+ "POST /api/projects/:id/revisions/:revisionId/:recordId/open-folder": desktop,
22
+ "GET /api/storage/export/summary": backups,
23
+ "GET /api/storage/export": backups,
24
+ "PUT /api/storage/import": backups,
25
+ "GET /api/backups": backups,
26
+ "PUT /api/backups": backups,
27
+ "POST /api/backups/run": backups,
28
+ "POST /api/storage/old-versions/delete": cleanup,
29
+ "POST /api/storage/cleanup": cleanup,
30
+ "GET /api/update/ready": updater,
31
+ "POST /api/update/activate": updater,
32
+ "GET /api/update": updater,
33
+ "DELETE /api/update": updater,
34
+ "POST /api/update": updater,
35
+ "POST /api/projects": notStarted,
36
+ "POST /api/projects/batch": notStarted,
37
+ "GET /api/projects/:projectId/audio-preview": listening,
38
+ "GET /api/projects/:projectId/audio-preview/:previewId": listening,
39
+ "GET /api/projects/:projectId/narration/peaks": listening,
40
+ "POST /api/auditions": `${listening}; its price is quote_voice_sample`,
41
+ "POST /api/style-preview": listening,
42
+ "GET /api/style-preview/:file": listening,
43
+ "POST /api/document-themes/preview": listening,
44
+ "GET /api/fonts/:id/file": listening,
45
+ "PATCH /api/projects/:id/providers": retired,
46
+ "PUT /api/projects/:id/article": retired,
47
+ "DELETE /api/projects/:id/images/:outputId": retired,
48
+ "POST /api/projects/:id/images/:outputId/regenerate": retired,
49
+ "PATCH /api/projects/:id/subtitles": retired,
50
+ "GET /api/telemetry/notice": personal,
51
+ "POST /api/telemetry/notice": personal,
52
+ "GET /api/tutorial": personal,
53
+ "PUT /api/tutorial": personal,
54
+ "DELETE /api/tutorial": personal,
55
+ "GET /api/whats-new": personal,
56
+ "POST /api/whats-new/seen": personal,
57
+ "GET /api/patch-notes": personal,
58
+ "POST /api/patch-notes/seen": personal,
59
+ "GET /api/patch-notes/:id": personal,
60
+ "GET /api/tutorials": personal,
61
+ "GET /api/tutorials/search": personal,
62
+ "GET /api/tutorials/:page": personal,
63
+ "GET /api/providers/first-run": personal,
64
+ "POST /api/providers/first-run/dismiss": personal,
65
+ "POST /api/onboarding/dismiss": personal,
66
+ "GET /api/settings/autostart": hostSetup,
67
+ "PUT /api/settings/autostart": hostSetup,
68
+ "POST /api/settings/autostart/answer": hostSetup,
69
+ "PUT /api/providers/:id/path": hostSetup,
70
+ "GET /api/studio/extension/:file": hostSetup,
71
+ "GET /api/providers/key-guides": keys,
72
+ "PUT /api/providers/:id/key": keys,
73
+ "POST /api/providers/:id/key/test": keys,
74
+ "DELETE /api/providers/:id/key": keys,
75
+ "POST /api/studio/settings/pairing": "makes the extension's pairing token, a credential",
76
+ "POST /api/studio/packs/:projectId/choose": youtube,
77
+ "PUT /api/studio/settings/auto-comment": `${youtube}; this posts comments in the person's name`,
78
+ "POST /api/studio/ext/pair": extension,
79
+ "GET /api/studio/ext/pack": extension,
80
+ "POST /api/studio/ext/filled": extension,
81
+ "POST /api/studio/ext/backfill": extension,
82
+ "GET /api/studio/ext/ready": extension,
83
+ "POST /api/studio/ext/upload": `${extension}; ${youtube}`,
84
+ "POST /api/studio/ext/upload-all": `${extension}; ${youtube}`,
85
+ "GET /api/studio/ext/packs/:projectId": extension,
86
+ "GET /api/studio/ext/tasks": extension,
87
+ "POST /api/studio/ext/task-result": extension,
88
+ "GET /api/studio/ext/known-videos": extension,
89
+ "GET /api/studio/ext/recorded-videos": extension,
90
+ "POST /api/studio/ext/gone": extension,
91
+ "POST /api/studio/ext/stats": extension,
92
+ "POST /api/studio/ext/video": extension,
93
+ "POST /api/studio/ext/video/done": extension,
94
+ "GET /api/studio/ext/files/:projectId/:asset": extension,
95
+ };