@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.
- package/dist/edge/http/actions.js +2 -2
- package/dist/edge/http/actor.js +10 -0
- package/dist/edge/http/app.js +21 -0
- package/dist/edge/http/checkpoints.js +14 -6
- package/dist/edge/http/external-reviews.js +95 -0
- package/dist/edge/http/projects.js +36 -18
- package/dist/edge/http/reviews.js +53 -18
- package/dist/edge/http/revisions.js +5 -1
- package/dist/edge/http/settings.js +9 -1
- package/dist/edge/http/starts.js +41 -0
- package/dist/edge/http/studio.js +5 -130
- package/dist/edge/mcp/attribution.js +38 -0
- package/dist/edge/mcp/call-route.js +203 -0
- package/dist/edge/mcp/excluded-routes.js +95 -0
- package/dist/edge/mcp/file-tools.js +141 -0
- package/dist/edge/mcp/http.js +79 -0
- package/dist/edge/mcp/instructions.js +17 -0
- package/dist/edge/mcp/library-tools.js +143 -0
- package/dist/edge/mcp/remake-tools.js +90 -0
- package/dist/edge/mcp/review-tools.js +64 -0
- package/dist/edge/mcp/route-tool.js +61 -0
- package/dist/edge/mcp/server.js +48 -0
- package/dist/edge/mcp/state-shapes.js +60 -0
- package/dist/edge/mcp/tools-channels.js +153 -0
- package/dist/edge/mcp/tools-library.js +151 -0
- package/dist/edge/mcp/tools-planning.js +137 -0
- package/dist/edge/mcp/tools-projects.js +167 -0
- package/dist/edge/mcp/tools-publishing.js +148 -0
- package/dist/edge/mcp/tools-runs.js +94 -0
- package/dist/edge/mcp/tools-schedules.js +143 -0
- package/dist/edge/mcp/tools-setup.js +234 -0
- package/dist/edge/mcp/tools-storage.js +99 -0
- package/dist/edge/mcp/tools.js +21 -0
- package/dist/extension/slopify-studio-chrome.zip +0 -0
- package/dist/extension/slopify-studio-firefox.zip +0 -0
- package/dist/kernel/db/migrations/0055-external-reviews.sql +56 -0
- package/dist/kernel/db/migrations/0056-agent-actions.sql +20 -0
- package/dist/kernel/db/migrations/0057-agent-action-once.sql +5 -0
- package/dist/kernel/db/migrations/0058-revision-work-admission.sql +3 -0
- package/dist/kernel/db/migrations/0059-external-review-canceled.sql +29 -0
- package/dist/kernel/db/migrations/0060-reservation-work.sql +4 -0
- package/dist/kernel/db/migrations/0061-project-control-counts.sql +4 -0
- package/dist/kernel/db/migrations/0062-told-failures.sql +8 -0
- package/dist/kernel/pipeline.js +6 -0
- package/dist/kernel/runner/graph.js +37 -2
- package/dist/kernel/runner/index.js +6 -3
- package/dist/main.js +29 -13
- package/dist/patch-notes/3.17.0.md +10 -0
- package/dist/patch-notes/3.18.0.md +13 -0
- package/dist/patch-notes/index.json +12 -0
- package/dist/slices/admission/admitted-work.js +53 -0
- package/dist/slices/admission/heads.js +5 -0
- package/dist/slices/admission/repo.js +47 -29
- package/dist/slices/admission/schema.js +1 -0
- package/dist/slices/admission/stage-writes.js +21 -0
- package/dist/slices/admission/start.js +2 -1
- package/dist/slices/admission/state-words.js +110 -0
- package/dist/slices/agents/attention-work.js +233 -0
- package/dist/slices/agents/attention.js +168 -0
- package/dist/slices/agents/connection.js +15 -0
- package/dist/slices/agents/model.js +29 -0
- package/dist/slices/agents/repo.js +66 -0
- package/dist/slices/cancel/index.js +4 -1
- package/dist/slices/control/index.js +2 -1
- package/dist/slices/episodes/summarize.js +2 -1
- package/dist/slices/estimate/index.js +6 -2
- package/dist/slices/images/run.js +2 -1
- package/dist/slices/library/transfer.js +69 -0
- package/dist/slices/narration/run.js +2 -1
- package/dist/slices/notifications/address.js +40 -0
- package/dist/slices/notifications/notifier.js +43 -10
- package/dist/slices/notifications/rules.js +111 -45
- package/dist/slices/notifications/server.js +14 -0
- package/dist/slices/notifications/settings.js +1 -1
- package/dist/slices/notifications/subject.js +30 -0
- package/dist/slices/notifications/told.js +33 -0
- package/dist/slices/play-drafts/readiness.js +9 -2
- package/dist/slices/play-drafts/schema.js +2 -1
- package/dist/slices/rebuild/narration-words.js +76 -0
- package/dist/slices/rebuild/recipe-model.js +1 -0
- package/dist/slices/rebuild/recipe-reviews.js +44 -23
- package/dist/slices/rebuild/recovery-repo.js +0 -20
- package/dist/slices/rebuild/recovery-selection.js +14 -3
- package/dist/slices/rebuild/recovery.js +5 -2
- package/dist/slices/rebuild/remake-outdated.js +27 -0
- package/dist/slices/rebuild/repo.js +7 -2
- package/dist/slices/rebuild/review-redo.js +12 -0
- package/dist/slices/rebuild/runtime-agent-review.js +121 -0
- package/dist/slices/rebuild/runtime-export-inputs.js +26 -11
- package/dist/slices/rebuild/runtime-review.js +12 -2
- package/dist/slices/rebuild/runtime-run.js +11 -2
- package/dist/slices/rebuild/runtime-store.js +32 -5
- package/dist/slices/reruns/index.js +2 -1
- package/dist/slices/research/run.js +2 -1
- package/dist/slices/reviews/external-model.js +21 -0
- package/dist/slices/reviews/external-release.js +38 -0
- package/dist/slices/reviews/external-repo.js +109 -0
- package/dist/slices/reviews/external-rows.js +68 -0
- package/dist/slices/reviews/external-waiting.js +75 -0
- package/dist/slices/reviews/holds.js +144 -0
- package/dist/slices/reviews/model.js +14 -0
- package/dist/slices/reviews/repo.js +27 -10
- package/dist/slices/reviews/rules.js +5 -3
- package/dist/slices/reviews/verdict.js +18 -8
- package/dist/slices/revisions/repo.js +3 -0
- package/dist/slices/revisions/schema.js +1 -0
- package/dist/slices/schedules/agenda.js +47 -17
- package/dist/slices/schedules/prepare.js +6 -0
- package/dist/slices/schedules/schema.js +13 -1
- package/dist/slices/storage/consequences.js +27 -0
- package/dist/slices/storage/portable.js +3 -0
- package/dist/slices/storage/project-files.js +66 -0
- package/dist/slices/studio/settings.js +0 -35
- package/dist/slices/studio/stats.js +2 -34
- package/dist/slices/subtitles/prepare.js +2 -1
- package/dist/slices/uploads/repo.js +12 -6
- package/dist/slices/uploads/rules.js +5 -0
- package/dist/slices/video/write-export.js +1 -1
- package/dist/slices/video/write-subtitles.js +1 -1
- package/dist/tutorials/Agents-MCP.md +168 -0
- package/dist/tutorials/Calendar.md +2 -1
- package/dist/tutorials/First-Launch-and-Welcome.md +1 -0
- package/dist/tutorials/Home-and-Projects.md +16 -9
- package/dist/tutorials/Home.md +1 -0
- package/dist/tutorials/Notifications.md +25 -6
- package/dist/tutorials/Project-Page.md +8 -2
- package/dist/tutorials/Publishing-to-YouTube.md +1 -0
- package/dist/tutorials/Recovery-and-Retries.md +17 -3
- package/dist/tutorials/Reviews-and-Checkpoints.md +22 -5
- package/dist/tutorials/Schedules.md +1 -1
- package/dist/tutorials/Settings-Reference.md +15 -0
- package/dist/tutorials/Troubleshooting.md +3 -1
- package/dist/tutorials/Your-First-Short.md +1 -0
- package/dist/tutorials/_Sidebar.md +1 -0
- package/dist/web/assets/{index-VbpUYLAp.css → index-BKbZIxAq.css} +1 -1
- package/dist/web/assets/index-Drth_MfQ.js +325 -0
- package/dist/web/assets/{pdf-C6KhxeKQ.js → pdf-CQFUyijy.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/slices/studio/performance.js +0 -83
- package/dist/slices/studio/report-stats.js +0 -39
- package/dist/slices/studio/report.js +0 -172
- package/dist/web/assets/index-Dvz8bsiD.js +0 -325
package/dist/edge/http/studio.js
CHANGED
|
@@ -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 {
|
|
26
|
-
import {
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
+
};
|