@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
@@ -1,9 +1,14 @@
1
- // Only a run seen running can finish, fail or stop to wait: a project first seen already done
2
- // was done before anyone was watching. Paused and canceled are what the user just pressed, so
3
- // they say nothing. `pending` after `running` is the runner finding nothing it may start: a
4
- // review checkpoint holding the next step, or held work waiting for Resume.
1
+ import { failureId, } from "../../kernel/pipeline.js";
2
+ // Only a run seen running or waiting can finish, fail or stop to wait: a project first seen
3
+ // already done was done before anyone was watching. Paused and canceled are what the user just
4
+ // pressed, so they say nothing. `pending` after `running` is the runner finding nothing it may
5
+ // start: a review checkpoint holding the next step, or held work waiting for Resume. A run seen
6
+ // waiting can end without being seen running again: an answered review's step may finish in
7
+ // the tick that releases it, or a failed step beside it may leave nothing else to go on.
5
8
  export function noticeOf(previous, next) {
6
- if (previous !== "running")
9
+ if (previous !== "running" && previous !== "pending")
10
+ return undefined;
11
+ if (previous === "pending" && next === "pending")
7
12
  return undefined;
8
13
  switch (next) {
9
14
  case "done":
@@ -31,10 +36,96 @@ export function reviewNoticeText(subject) {
31
36
  const item = reviewItemNames[subject.stage] ?? "an item";
32
37
  const headline = `Review needs a decision: ${title}`;
33
38
  return {
34
- headline: reason === undefined ? headline : `${headline} — ${reason}`,
35
- detail: `The automatic review flagged ${item} and kept it. Open the project and press Overrule to keep it or Redo to make it again.`,
39
+ headline: `${reason === undefined ? headline : `${headline} — ${reason}`}${subject.byAgent === true ? " · by agent" : ""}`,
40
+ detail: `${subject.byAgent === true ? "An agent's review" : "The automatic review"} flagged ${item} and kept it. Open the project and press Overrule to keep it or Redo to make it again.`,
41
+ };
42
+ }
43
+ // A reviewed item by the name the project page gives it: an image by its place in the image
44
+ // order ("image 3"), a short or a thumbnail variant by its number. Undefined for keys that are
45
+ // no reviewed item.
46
+ export function reviewItemName(itemKey, imageOrder) {
47
+ if (itemKey === "article:body")
48
+ return "the article";
49
+ if (itemKey === "narration")
50
+ return "the narration";
51
+ if (itemKey === "thumbnail:image")
52
+ return "the thumbnail";
53
+ const variant = /^thumbnail:image:(\d+)$/.exec(itemKey);
54
+ if (variant !== null)
55
+ return `thumbnail ${variant[1] ?? ""}`;
56
+ const short = /^shorts:(\d+)$/.exec(itemKey);
57
+ if (short !== null)
58
+ return `short ${short[1] ?? ""}`;
59
+ if (!itemKey.startsWith("image:"))
60
+ return undefined;
61
+ const at = imageOrder.indexOf(itemKey.slice("image:".length));
62
+ return at === -1 ? "an image" : `image ${String(at + 1)}`;
63
+ }
64
+ // An external review's two hours ran out with no agent's verdict (`review.auto-passed`).
65
+ export function autoPassNoticeText(subject) {
66
+ const title = subject.title.trim() === "" ? "Untitled project" : subject.title.trim();
67
+ const item = subject.item ?? reviewItemNames[subject.stage] ?? "an item";
68
+ return {
69
+ headline: "Passed without review: no agent answered in 2 h",
70
+ detail: `${title}: ${item} went ahead unreviewed. Open the project to look at it, or check that your agent's routine is running.`,
36
71
  };
37
72
  }
73
+ // The failed steps of a run now, in the run's order: the first is the one a run's notice names.
74
+ export function failuresOf(stages) {
75
+ return stages
76
+ .filter((stage) => stage.state === "failed")
77
+ .map((stage) => ({
78
+ id: failureId(stage.kind, stage.finishedAt),
79
+ kind: stage.kind,
80
+ ...(stage.failureReason === null ? {} : { reason: stage.failureReason }),
81
+ }));
82
+ }
83
+ // What a project's notices say, for the server's Notification URL and the browser alike, given the
84
+ // failures already told for the project. A step that fails while another waits for the agent, the
85
+ // person or its day is said right away ("Step failed"): as it fails while a third step still runs,
86
+ // or when the run stops to wait beside it; once per failure. One that fails with nothing waiting is
87
+ // told only by the run's end notice. A run that ends on its failures names them all; one that ends
88
+ // Failed only on failures already said is not told again. Each notice names the reason of a step
89
+ // failed now: the run's first, or the first "Step failed" tells.
90
+ export function runNotices(cause, subject, told) {
91
+ const failures = subject.failures ?? [];
92
+ const untold = failures.filter((failure) => !told.has(failure.id));
93
+ const notices = [];
94
+ // The failures "Step failed" names now.
95
+ let stepNews = [];
96
+ if ("kind" in cause) {
97
+ const { kind } = cause;
98
+ const repeat = kind === "failed" && failures.length > 0 && untold.length === 0;
99
+ if (!repeat && announces(kind, subject))
100
+ notices.push({ kind, reason: failures[0]?.reason });
101
+ if (kind === "waiting" && subject.stepFailedWhileWaiting === true)
102
+ stepNews = untold;
103
+ }
104
+ else if (subject.status === "running" && subject.stepFailedWhileWaiting === true)
105
+ // Stopped to wait or ended, the run's own transition tells it.
106
+ stepNews = untold.filter((failure) => failure.kind === cause.stepFailed);
107
+ if (stepNews.length > 0)
108
+ notices.push({ kind: "step-failed", reason: stepNews[0]?.reason });
109
+ const ended = notices.some((one) => one.kind === "failed" || one.kind === "partial");
110
+ return {
111
+ notices,
112
+ told: new Set(failures
113
+ .filter((failure) => ended || told.has(failure.id) || stepNews.includes(failure))
114
+ .map((failure) => failure.id)),
115
+ news: stepNews.map((failure) => failure.id),
116
+ };
117
+ }
118
+ // Whether a prepared run's own Before Video hold is what holds it now (`atItsDay`), from the
119
+ // project's `prepared` and `holding` (`reviews/holds.ts`): the notifier and the browser both ask.
120
+ export function atItsDay(prepared, holding) {
121
+ return prepared && holding.some((hold) => hold.waitsFor === "day");
122
+ }
123
+ // A run parked on an agent's review waits for nobody: the agent answers, or the review passes
124
+ // at its deadline and says so then. One at its day's hold waits for its scheduled day. Every
125
+ // other notice goes out.
126
+ function announces(kind, subject) {
127
+ return kind !== "waiting" || (subject.waitingForAgent !== true && subject.atItsDay !== true);
128
+ }
38
129
  const reasonMax = 140;
39
130
  // A provider's failure can run to paragraphs; a notification has a line.
40
131
  export function shortReason(reason) {
@@ -47,6 +138,12 @@ export function shortReason(reason) {
47
138
  return line.length <= reasonMax ? line : `${line.slice(0, reasonMax - 1).trimEnd()}…`;
48
139
  }
49
140
  export function noticeText(kind, subject) {
141
+ const text = runNoticeText(kind, subject);
142
+ return subject.startedBy === "agent"
143
+ ? { ...text, headline: `${text.headline} · started by agent` }
144
+ : text;
145
+ }
146
+ function runNoticeText(kind, subject) {
50
147
  const title = subject.title.trim() === "" ? "Untitled project" : subject.title.trim();
51
148
  switch (kind) {
52
149
  case "ready":
@@ -70,6 +167,13 @@ export function noticeText(kind, subject) {
70
167
  detail: "Open the project to see what stopped it and press Retry.",
71
168
  };
72
169
  }
170
+ case "step-failed": {
171
+ const reason = shortReason(subject.reason);
172
+ return {
173
+ headline: reason === undefined ? `Step failed: ${title}` : `Step failed: ${title} — ${reason}`,
174
+ detail: "The run still waits on another step, so it isn't over. Open the project to see which step failed and press Retry.",
175
+ };
176
+ }
73
177
  case "waiting":
74
178
  return {
75
179
  headline: `Waiting for you: ${title}`,
@@ -86,41 +190,3 @@ export function topicsNoticeText(subject) {
86
190
  detail: `Open Calendar → Suggested topics → ${name} to queue or reject ${count === 1 ? "it" : "them"}.${total}`,
87
191
  };
88
192
  }
89
- export const testNotice = {
90
- headline: "Slopify test notification",
91
- detail: "Notifications work. You'll get one when a run finishes, fails or waits for you, and when a review needs your decision.",
92
- };
93
- // The body a Notification URL receives: plain text, which ntfy shows as the message. Nothing
94
- // but the project's title, what happened and the provider's own short reason goes in it.
95
- export function webhookBody(text) {
96
- return `${text.headline}\n${text.detail}\n${text.link === undefined ? "" : `${text.link}\n`}`;
97
- }
98
- // Where a project opens in the browser, from the address the server listens on. A wildcard
99
- // (0.0.0.0 in Docker) or loopback address is reached as localhost from the same machine.
100
- export function projectLink(host, port, projectId) {
101
- const wildcard = host === "0.0.0.0" || host === "::" || host === "" || host === "127.0.0.1";
102
- const authority = wildcard ? "localhost" : host.includes(":") ? `[${host}]` : host;
103
- return `http://${authority}:${String(port)}/projects/${encodeURIComponent(projectId)}`;
104
- }
105
- export const notificationUrlMax = 2048;
106
- // Empty clears the setting. Otherwise one absolute http(s) URL with no user name or password:
107
- // fetch refuses those, and a password in a URL would sit in the database in plain sight.
108
- export function notificationUrlProblem(value) {
109
- const trimmed = value.trim();
110
- if (trimmed === "")
111
- return undefined;
112
- if (trimmed.length > notificationUrlMax)
113
- return `Keep the Notification URL to ${String(notificationUrlMax)} characters or fewer.`;
114
- let parsed;
115
- try {
116
- parsed = new URL(trimmed);
117
- }
118
- catch {
119
- return "Enter a full address starting with https:// or http://, for example https://ntfy.sh/your-topic.";
120
- }
121
- if (parsed.protocol !== "https:" && parsed.protocol !== "http:")
122
- return "The Notification URL must start with https:// or http://.";
123
- if (parsed.username !== "" || parsed.password !== "")
124
- return "Remove the user name and password from the Notification URL; Slopify can't send them.";
125
- return undefined;
126
- }
@@ -0,0 +1,14 @@
1
+ import { createRunNotifier } from "./notifier.js";
2
+ import { readNotificationUrl } from "./settings.js";
3
+ import { noticeSubject } from "./subject.js";
4
+ import { toldFailures } from "./told.js";
5
+ // The server's run notifier (`main.ts`): the Notification URL saved in Settings, each notice
6
+ // read from the project's rows as it goes out, and what was told kept beside them.
7
+ export function createServerNotifier(db, deps) {
8
+ return createRunNotifier({
9
+ ...deps,
10
+ url: () => readNotificationUrl(db),
11
+ subject: (projectId) => noticeSubject(db, projectId),
12
+ told: toldFailures(db),
13
+ });
14
+ }
@@ -1,5 +1,5 @@
1
1
  import { readSetting, writeSetting } from "../settings/repo.js";
2
- import { notificationUrlProblem } from "./rules.js";
2
+ import { notificationUrlProblem } from "./address.js";
3
3
  // Its own row in the key/value `settings` table. Backups leave it out
4
4
  // (`slices/storage/portable.ts`): an ntfy topic in the URL is as good as a password.
5
5
  export const notificationUrlKey = "notificationUrl";
@@ -0,0 +1,30 @@
1
+ import { derive } from "../../kernel/runner/graph.js";
2
+ import { projectById, stagesOf } from "../admission/repo.js";
3
+ import { stepFailedWhileWaiting } from "../admission/state-words.js";
4
+ import { lastStarter } from "../agents/repo.js";
5
+ import { projectHolds } from "../reviews/holds.js";
6
+ import { uploadedAndSettled } from "../uploads/repo.js";
7
+ import { settledStatus } from "../uploads/rules.js";
8
+ import { atItsDay, failuresOf } from "./rules.js";
9
+ // What a notification says about a project, read at the moment it goes out, by the rule the
10
+ // Projects list reads it with (`edge/http/projects.ts`).
11
+ export function noticeSubject(db, projectId) {
12
+ const project = projectById(db, projectId);
13
+ if (project === undefined)
14
+ return undefined;
15
+ const stages = stagesOf(db, projectId);
16
+ const settled = uploadedAndSettled(db, projectId);
17
+ const holds = projectHolds(db, { projectId, standings: new Map([[projectId, stages]]), settled });
18
+ const status = settledStatus(derive(stages, project.paused), settled.has(projectId));
19
+ const failures = failuresOf(stages);
20
+ return {
21
+ title: project.title,
22
+ makesVideo: project.config.sources.video !== "off",
23
+ startedBy: lastStarter(db, projectId),
24
+ waitingForAgent: holds.waitingForAgent.has(projectId),
25
+ atItsDay: atItsDay(holds.prepared.has(projectId), holds.holding.get(projectId) ?? []),
26
+ status,
27
+ ...(stepFailedWhileWaiting(status, stages) ? { stepFailedWhileWaiting: true } : {}),
28
+ ...(failures.length === 0 ? {} : { failures }),
29
+ };
30
+ }
@@ -0,0 +1,33 @@
1
+ import { transact } from "../../kernel/db/tx.js";
2
+ // In memory, for a notifier whose told failures needn't outlive it: tests.
3
+ // ceiling: one entry per project with a told failure.
4
+ export function toldInMemory() {
5
+ const told = new Map();
6
+ return {
7
+ read: (projectId) => told.get(projectId) ?? new Set(),
8
+ keep: (projectId, ids) => {
9
+ if (ids.size === 0)
10
+ told.delete(projectId);
11
+ else
12
+ told.set(projectId, ids);
13
+ },
14
+ };
15
+ }
16
+ // The server's, in `told_failures`, so a restart neither tells a failure again nor counts one
17
+ // never told as told. A project's rows go with it.
18
+ export function toldFailures(db) {
19
+ return {
20
+ read: (projectId) => new Set(db
21
+ .prepare("SELECT failure_id FROM told_failures WHERE project_id=?")
22
+ .all(projectId)
23
+ .map((row) => String(row.failure_id))),
24
+ keep: (projectId, told) => {
25
+ transact(db, () => {
26
+ db.prepare("DELETE FROM told_failures WHERE project_id=?").run(projectId);
27
+ const insert = db.prepare("INSERT INTO told_failures(project_id,failure_id) VALUES (?,?)");
28
+ for (const id of told)
29
+ insert.run(projectId, id);
30
+ });
31
+ },
32
+ };
33
+ }
@@ -2,6 +2,7 @@ import { checkRuntimeModel } from "../../catalog/runtime-models.js";
2
2
  import { modelFields } from "../../catalog/validate.js";
3
3
  import { readinessIsUsable } from "../../kernel/ports/model.js";
4
4
  import { usesNarrationPreparation, usesShorts, usesYoutubeDescription, } from "../admission/rules.js";
5
+ import { reviewedByAgent } from "../reviews/model.js";
5
6
  import { usesReviews } from "../reviews/rules.js";
6
7
  import { cliPathChanged } from "../settings/cli-paths.js";
7
8
  import { hasKey, listVoices } from "../settings/repo.js";
@@ -37,8 +38,14 @@ export function choices(runs) {
37
38
  usesShorts(d) ||
38
39
  usesAnimation(d),
39
40
  },
40
- // The reviewer is its own text model, checked like the LLM row.
41
- { field: "reviews", family: "llm", choice: d.reviews, needed: usesReviews(d) },
41
+ // The reviewer is its own text model, checked like the LLM row; an external agent
42
+ // is no provider at all.
43
+ {
44
+ field: "reviews",
45
+ family: "llm",
46
+ choice: d.reviews,
47
+ needed: usesReviews(d) && !reviewedByAgent(d.reviews),
48
+ },
42
49
  ])
43
50
  .flatMap((c) => (c.needed && c.choice ? [{ ...c, ...c.choice }] : []));
44
51
  return [...new Map(selected.map((c) => [`${c.family}:${c.provider}:${c.model}`, c])).values()];
@@ -9,7 +9,7 @@ import { checkpointRowSchema, checkpointStageSchema } from "../checkpoints/schem
9
9
  import { documentSettingsSchema } from "../document/theme-schema.js";
10
10
  import { librarySnapshotSchema } from "../library/snapshot.js";
11
11
  import { chunkModes } from "../narration/chunk.js";
12
- import { reviewModes, reviewStages } from "../reviews/model.js";
12
+ import { reviewers, reviewModes, reviewStages } from "../reviews/model.js";
13
13
  import { subtitleModes, subtitlePositions } from "../subtitles/model.js";
14
14
  import { ambientBedSources } from "../video/ambient-bed.js";
15
15
  import { voicesSettingsSchema } from "../voices/model.js";
@@ -122,6 +122,7 @@ export const playDraftFormSchema = z
122
122
  provider: text,
123
123
  model: text,
124
124
  thinking: z.enum(thinkingModes).optional(),
125
+ reviewer: z.enum(reviewers).optional(),
125
126
  retries: text,
126
127
  stages: z
127
128
  .partialRecord(z.enum(reviewStages), z
@@ -0,0 +1,76 @@
1
+ import { causedBy } from "../../kernel/errors.js";
2
+ import { narrationAudio, narrationTimeline, speaksPart, } from "./runtime-export-inputs.js";
3
+ import { wordsSchema } from "./runtime-subtitles.js";
4
+ // The narration's parts on the timing's clock. A body with no intro or outro starts at a known
5
+ // place, after the edge silence: its length only clips the last words, so one that can't be
6
+ // measured (an upload too long for the measuring timeout) still gives its words, unclipped.
7
+ async function timelineOf(deps, view) {
8
+ const config = view.revision.config;
9
+ let unmeasured;
10
+ const measure = async (path) => {
11
+ try {
12
+ return await deps.measure(path);
13
+ }
14
+ catch (error) {
15
+ unmeasured = error;
16
+ throw error;
17
+ }
18
+ };
19
+ try {
20
+ return { timeline: await narrationAudio({ ...deps, measure }, view) };
21
+ }
22
+ catch (error) {
23
+ const alone = !speaksPart(config, "intro") && !speaksPart(config, "outro");
24
+ if (!alone || unmeasured === undefined)
25
+ throw error;
26
+ const body = { path: "", seconds: Number.POSITIVE_INFINITY };
27
+ return {
28
+ timeline: narrationTimeline(config, { body, intro: undefined, outro: undefined }),
29
+ unclipped: causedBy(unmeasured),
30
+ };
31
+ }
32
+ }
33
+ // The word timing of the body narration file alone. The subtitles stage timed the words on the
34
+ // export's clock (`narrationAudio`); the body file starts at zero, so its words move back by
35
+ // where the body starts, and the intro's and outro's are left out. Refused, with why, when the
36
+ // timing can't be read or the narration can't be timed.
37
+ export async function bodyWordTimings(deps, view, words) {
38
+ try {
39
+ const timing = wordsSchema.parse(JSON.parse(words));
40
+ const { timeline, unclipped } = await timelineOf(deps, view);
41
+ // The words were timed with every part the settings speak; without one, the body's place
42
+ // on that clock is unknown.
43
+ for (const part of ["intro", "outro"])
44
+ if (speaksPart(view.revision.config, part) &&
45
+ !timeline.some((segment) => segment.kind === part))
46
+ return {
47
+ ok: false,
48
+ reason: `the narration's ${part} has no finished audio, so where the body starts on the timing's clock is unknown`,
49
+ };
50
+ const before = timeline.findIndex((segment) => segment.kind === "body");
51
+ const start = timeline.slice(0, before).reduce((sum, segment) => sum + segment.seconds, 0);
52
+ const end = start + (timeline[before]?.seconds ?? 0);
53
+ // Times are rounded to the millisecond, so a body word can sit a hair before the body.
54
+ const inBody = (at) => at >= start - 0.001 && at < end;
55
+ return {
56
+ ok: true,
57
+ ...(unclipped === undefined ? {} : { unclipped }),
58
+ value: JSON.stringify({
59
+ ...timing,
60
+ words: timing.words
61
+ .filter((word) => inBody(word.start))
62
+ .map((word) => ({
63
+ ...word,
64
+ start: Math.max(0, word.start - start),
65
+ end: Math.min(word.end, end) - start,
66
+ })),
67
+ omissions: timing.omissions
68
+ .filter((omission) => inBody(omission.start))
69
+ .map((omission) => ({ ...omission, start: Math.max(0, omission.start - start) })),
70
+ }),
71
+ };
72
+ }
73
+ catch (error) {
74
+ return { ok: false, reason: causedBy(error) };
75
+ }
76
+ }
@@ -22,6 +22,7 @@ export const localOperations = [
22
22
  "shorts-pick-v1",
23
23
  "short-render-v1",
24
24
  "review-v1",
25
+ "review-agent-v1",
25
26
  "concat-turns-v1",
26
27
  "voice-captions-v1",
27
28
  "audio-files-v1",
@@ -1,4 +1,4 @@
1
- import { narrationItemKey, reviewKey, reviewModeOf, reviewPromptKey, reviewRetriesOf, } from "../reviews/model.js";
1
+ import { narrationItemKey, reviewedByAgent, reviewKey, reviewModeOf, reviewPromptKey, reviewRetriesOf, } from "../reviews/model.js";
2
2
  import { activeReviewStages } from "../reviews/rules.js";
3
3
  import { recipe, resourceIdentity, } from "./recipe-model.js";
4
4
  import { renderedPrompt } from "./recipe-text.js";
@@ -14,28 +14,49 @@ export function withReviews(context, recipes) {
14
14
  return recipes;
15
15
  const byKey = new Map(recipes.map((row) => [row.key, row]));
16
16
  const items = stages.flatMap((stage) => reviewedItems(stage, recipes, byKey));
17
- const reviews = items.map((item) => recipe(context, reviewKey(item.itemKey), stageOf(item.stage), {
18
- kind: "local",
19
- version: 1,
20
- operation: "review-v1",
21
- values: [
22
- item.stage,
23
- item.itemKey,
24
- item.inputs.map((input) => [input.key, resourceIdentity(context, input)]),
25
- settings.provider,
26
- settings.model,
27
- settings.thinking ?? null,
28
- // Blank means the stage's built-in prompt; the step fills it in, so a new built-in
29
- // wording ships as a new operation version rather than a silent change here.
30
- settings.stages[item.stage]?.prompt?.trim()
31
- ? renderedPrompt(context, reviewPromptKey(item.stage))
32
- : "",
33
- reviewModeOf(settings, item.stage),
34
- reviewRetriesOf(settings),
35
- ],
36
- }, item.inputs.map((input) => input.key),
37
- // A paid LLM call: readiness, the charge warning and the estimate treat it like one.
38
- { kind: "provider" }));
17
+ const byAgent = reviewedByAgent(settings);
18
+ const reviews = items.map((item) => {
19
+ const inputs = item.inputs.map((input) => [input.key, resourceIdentity(context, input)]);
20
+ // Blank means the stage's built-in prompt; the step fills it in, so a new built-in
21
+ // wording ships as a new operation version rather than a silent change here.
22
+ const prompt = settings.stages[item.stage]?.prompt?.trim()
23
+ ? renderedPrompt(context, reviewPromptKey(item.stage))
24
+ : "";
25
+ const dependsOn = item.inputs.map((input) => input.key);
26
+ // An external agent answers through MCP: no provider, no charge, nothing to be ready.
27
+ if (byAgent)
28
+ return recipe(context, reviewKey(item.itemKey), stageOf(item.stage), {
29
+ kind: "local",
30
+ version: 1,
31
+ operation: "review-agent-v1",
32
+ values: [
33
+ item.stage,
34
+ item.itemKey,
35
+ inputs,
36
+ prompt,
37
+ reviewModeOf(settings, item.stage),
38
+ reviewRetriesOf(settings),
39
+ ],
40
+ }, dependsOn);
41
+ return recipe(context, reviewKey(item.itemKey), stageOf(item.stage), {
42
+ kind: "local",
43
+ version: 1,
44
+ operation: "review-v1",
45
+ values: [
46
+ item.stage,
47
+ item.itemKey,
48
+ inputs,
49
+ settings.provider,
50
+ settings.model,
51
+ settings.thinking ?? null,
52
+ prompt,
53
+ reviewModeOf(settings, item.stage),
54
+ reviewRetriesOf(settings),
55
+ ],
56
+ }, dependsOn,
57
+ // A paid LLM call: readiness, the charge warning and the estimate treat it like one.
58
+ { kind: "provider" });
59
+ });
39
60
  const gated = recipes.map((row) => {
40
61
  const waits = items.flatMap((item, index) => {
41
62
  const review = reviews[index];
@@ -83,23 +83,3 @@ function forget(deps, projectId, input) {
83
83
  .prepare("DELETE FROM project_recovery_requests WHERE project_id=? AND idempotency_key=?")
84
84
  .run(projectId, input.idempotencyKey);
85
85
  }
86
- // The head has unfinished stages but nothing that will move them on its own: no call in
87
- // flight and no admitted, dispatchable work. A saved rerun or edit is admitted by no one
88
- // until Resume, and work held by a pause, a restart or a failed sibling of its admission
89
- // never starts, yet none of these shows as paused or failed. A queued batch item waits for
90
- // its turn instead.
91
- export function resumable(db, projectId) {
92
- return (db
93
- .prepare("SELECT 1 FROM project_heads h WHERE h.project_id=? " +
94
- "AND EXISTS(SELECT 1 FROM stages s WHERE s.project_id=h.project_id " +
95
- "AND s.state NOT IN ('done','provided','skipped')) " +
96
- "AND NOT EXISTS(SELECT 1 FROM stages s WHERE s.project_id=h.project_id AND s.state='running') " +
97
- "AND NOT EXISTS(SELECT 1 FROM project_queue q WHERE q.project_id=h.project_id AND q.state='queued') " +
98
- "AND NOT EXISTS(SELECT 1 FROM revision_work w WHERE w.project_id=h.project_id AND w.state='running') " +
99
- "AND NOT EXISTS(SELECT 1 FROM revision_work w " +
100
- "JOIN revision_work_reservations r ON r.work_id=w.id AND r.revision_id=h.revision_id " +
101
- "WHERE w.state='pending' AND w.dispatch_state='allowed' AND NOT EXISTS(" +
102
- "SELECT 1 FROM revision_work f WHERE f.admission_id=w.admission_id " +
103
- "AND f.state IN ('failed','canceled')))")
104
- .get(projectId) !== undefined);
105
- }
@@ -1,3 +1,4 @@
1
+ import { readmitted } from "../../kernel/runner/graph.js";
1
2
  import { narrationRegenerationKey } from "../narration/plan.js";
2
3
  export function sectionRoots(view, plan, stage) {
3
4
  if (stage === "article" &&
@@ -69,15 +70,25 @@ export function dependentClosure(recipes, roots) {
69
70
  return recipes.filter((row) => selected.has(row.key)).map((row) => row.key);
70
71
  }
71
72
  export function recoverySelection(plan, stage) {
72
- if (stage === undefined)
73
+ return { kind: "selected", workKeys: stageWork(plan, stage) };
74
+ }
75
+ // Continue the run (or Resume): everything left to make, except after a Cancel, when only the
76
+ // stages the person took up again (`readmitted`: Try again, or Continue on the canceled run) go
77
+ // on, each as Try again would; the stages they canceled stay canceled.
78
+ export function resumeSelection(plan, stages) {
79
+ const taken = stages.filter((stage) => readmitted(stage, stages));
80
+ if (taken.length === 0)
73
81
  return { kind: "allAffected" };
82
+ return { kind: "selected", workKeys: taken.flatMap((stage) => stageWork(plan, stage.kind)) };
83
+ }
84
+ // A stage's work left to make, or all of it when every piece would be reused.
85
+ function stageWork(plan, stage) {
74
86
  const roots = plan.work
75
87
  .filter((row) => row.stage === stage && row.disposition !== "reuse")
76
88
  .map((row) => row.key);
77
- const unfinished = roots.length === 0
89
+ return roots.length === 0
78
90
  ? plan.work.filter((row) => row.stage === stage).map((row) => row.key)
79
91
  : roots;
80
- return { kind: "selected", workKeys: unfinished };
81
92
  }
82
93
  export function regenerationEdit(view, plan, stage) {
83
94
  const roots = sectionRoots(view, plan, stage);
@@ -1,4 +1,5 @@
1
1
  import { transact } from "../../kernel/db/tx.js";
2
+ import { stagesOf } from "../admission/repo.js";
2
3
  import { withProjectControl } from "../control/lock.js";
3
4
  import { requestHash } from "../revisions/mutation-request.js";
4
5
  import { saveRevision } from "../revisions/mutations.js";
@@ -10,7 +11,7 @@ import { recipeProviderChoice } from "./recipe-provider-choice.js";
10
11
  import { acceptedJobMessage, activeConflict, conflictRefusal } from "./recovery-conflict.js";
11
12
  import { recoveryRequestSchema, } from "./recovery-model.js";
12
13
  import { readRecovery, recoveryAuthority, recoveryKey, rememberRecovery, reserveRecovery, } from "./recovery-repo.js";
13
- import { dependentClosure, recoverySelection, redoTarget, regenerationEdit, sectionRoots, } from "./recovery-selection.js";
14
+ import { dependentClosure, recoverySelection, redoTarget, regenerationEdit, resumeSelection, sectionRoots, } from "./recovery-selection.js";
14
15
  import { previewById, storePreview } from "./repo.js";
15
16
  import { admitCheckedPreview, wakeRebuild } from "./service.js";
16
17
  import { checkReadiness } from "./service-readiness.js";
@@ -142,7 +143,9 @@ options = {}) {
142
143
  : [];
143
144
  const selection = input.action.kind === "rerun" || input.action.kind === "redo"
144
145
  ? { kind: "selected", workKeys: rerunKeys }
145
- : recoverySelection(plan, input.action.kind === "retry" ? input.action.stage : undefined);
146
+ : input.action.kind === "retry"
147
+ ? recoverySelection(plan, input.action.stage)
148
+ : resumeSelection(plan, stagesOf(deps.db, projectId));
146
149
  if (selection.kind === "selected" && selection.workKeys.length === 0)
147
150
  return finish({
148
151
  ok: false,
@@ -0,0 +1,27 @@
1
+ export function remadeWork(work) {
2
+ return work.filter((one) => one.disposition === "generate" || one.disposition === "local" || one.disposition === "review");
3
+ }
4
+ export function remakeBlocker(status, sample) {
5
+ if (status === "running")
6
+ return "It is running now. Remake it once the run has finished.";
7
+ if (sample)
8
+ return "It is a sample project, which stays as it came with Slopify. Open it, press Make my own copy at the top of its page, then remake the copy.";
9
+ return undefined;
10
+ }
11
+ export function previewBlocker(preview, label) {
12
+ if (remadeWork(preview.work).length === 0)
13
+ return "Nothing in it is outdated, so there is nothing to remake.";
14
+ const blocked = preview.work.find((work) => work.disposition === "blocked");
15
+ if (blocked !== undefined)
16
+ return `${label(blocked.key)} can't run: ${blocked.reason.trim() || "no reason was given."} Open the project and press Choose what to remake to see what to fix.`;
17
+ if (preview.providedReuseRequired.length > 0)
18
+ return "It uses content you supplied, which you confirm on the project itself: open it and press Choose what to remake.";
19
+ return undefined;
20
+ }
21
+ export function remakeTotal(costs) {
22
+ return {
23
+ low: costs.reduce((sum, one) => sum + one.low, 0),
24
+ high: costs.reduce((sum, one) => sum + one.high, 0),
25
+ unknown: costs.reduce((sum, one) => sum + one.unknown, 0),
26
+ };
27
+ }
@@ -57,8 +57,13 @@ export function previewById(db, projectId, previewId) {
57
57
  ? undefined
58
58
  : rebuildPreviewSchema.parse(JSON.parse(String(row.body_json)));
59
59
  }
60
+ // A canceled step stays canceled: only retrying it starts it again (`cancel/index.ts`). Each
61
+ // project whose run this stops counts one stop more (`project_controls.stops`).
60
62
  export function recoverWork(db) {
61
- transact(db, () => db.exec(`UPDATE revision_work_pieces SET state='held',dispatch_state='held' WHERE state!='done' AND work_id IN (SELECT id FROM revision_work WHERE project_id NOT IN (SELECT project_id FROM project_queue WHERE state='queued'));
62
- UPDATE revision_work SET state='pending',dispatch_state='held' WHERE state!='done' AND project_id NOT IN (SELECT project_id FROM project_queue WHERE state='queued');`));
63
+ transact(db, () => db.exec(`INSERT INTO project_controls(project_id, stops)
64
+ SELECT DISTINCT project_id, 1 FROM revision_work WHERE state NOT IN ('done','canceled') AND dispatch_state!='held' AND project_id NOT IN (SELECT project_id FROM project_queue WHERE state='queued')
65
+ ON CONFLICT(project_id) DO UPDATE SET stops = stops + 1;
66
+ UPDATE revision_work_pieces SET state='held',dispatch_state='held' WHERE state!='done' AND work_id IN (SELECT id FROM revision_work WHERE state!='canceled' AND project_id NOT IN (SELECT project_id FROM project_queue WHERE state='queued'));
67
+ UPDATE revision_work SET state='pending',dispatch_state='held' WHERE state NOT IN ('done','canceled') AND project_id NOT IN (SELECT project_id FROM project_queue WHERE state='queued');`));
63
68
  }
64
69
  export { admissionReceipt, admitPreview } from "./admission-repo.js";
@@ -77,6 +77,18 @@ const starting = new Set();
77
77
  export function redoStarting(verdictId) {
78
78
  return starting.has(verdictId);
79
79
  }
80
+ // Redo pressed by a person or an agent: claimed synchronously, before anything is awaited, so an
81
+ // Overrule arriving while the item is being made again is refused instead of landing in between.
82
+ // False when a redo of this verdict is already being started.
83
+ export function claimRedoStart(verdictId) {
84
+ if (starting.has(verdictId))
85
+ return false;
86
+ starting.add(verdictId);
87
+ return true;
88
+ }
89
+ export function releaseRedoStart(verdictId) {
90
+ starting.delete(verdictId);
91
+ }
80
92
  export async function startRedo(deps, verdict) {
81
93
  starting.add(verdict.id);
82
94
  try {