@gentbajko/slopify 3.17.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 (136) 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 -10
  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/mcp/attribution.js +38 -0
  12. package/dist/edge/mcp/call-route.js +203 -0
  13. package/dist/edge/mcp/excluded-routes.js +95 -0
  14. package/dist/edge/mcp/file-tools.js +141 -0
  15. package/dist/edge/mcp/http.js +79 -0
  16. package/dist/edge/mcp/instructions.js +17 -0
  17. package/dist/edge/mcp/library-tools.js +143 -0
  18. package/dist/edge/mcp/remake-tools.js +90 -0
  19. package/dist/edge/mcp/review-tools.js +64 -0
  20. package/dist/edge/mcp/route-tool.js +61 -0
  21. package/dist/edge/mcp/server.js +48 -0
  22. package/dist/edge/mcp/state-shapes.js +60 -0
  23. package/dist/edge/mcp/tools-channels.js +153 -0
  24. package/dist/edge/mcp/tools-library.js +151 -0
  25. package/dist/edge/mcp/tools-planning.js +137 -0
  26. package/dist/edge/mcp/tools-projects.js +167 -0
  27. package/dist/edge/mcp/tools-publishing.js +148 -0
  28. package/dist/edge/mcp/tools-runs.js +94 -0
  29. package/dist/edge/mcp/tools-schedules.js +143 -0
  30. package/dist/edge/mcp/tools-setup.js +234 -0
  31. package/dist/edge/mcp/tools-storage.js +99 -0
  32. package/dist/edge/mcp/tools.js +21 -0
  33. package/dist/extension/slopify-studio-chrome.zip +0 -0
  34. package/dist/extension/slopify-studio-firefox.zip +0 -0
  35. package/dist/kernel/db/migrations/0055-external-reviews.sql +56 -0
  36. package/dist/kernel/db/migrations/0056-agent-actions.sql +20 -0
  37. package/dist/kernel/db/migrations/0057-agent-action-once.sql +5 -0
  38. package/dist/kernel/db/migrations/0058-revision-work-admission.sql +3 -0
  39. package/dist/kernel/db/migrations/0059-external-review-canceled.sql +29 -0
  40. package/dist/kernel/db/migrations/0060-reservation-work.sql +4 -0
  41. package/dist/kernel/db/migrations/0061-project-control-counts.sql +4 -0
  42. package/dist/kernel/db/migrations/0062-told-failures.sql +8 -0
  43. package/dist/kernel/pipeline.js +6 -0
  44. package/dist/kernel/runner/graph.js +37 -2
  45. package/dist/kernel/runner/index.js +6 -3
  46. package/dist/main.js +29 -13
  47. package/dist/patch-notes/3.18.0.md +13 -0
  48. package/dist/patch-notes/index.json +6 -0
  49. package/dist/slices/admission/admitted-work.js +53 -0
  50. package/dist/slices/admission/heads.js +5 -0
  51. package/dist/slices/admission/repo.js +47 -29
  52. package/dist/slices/admission/schema.js +1 -0
  53. package/dist/slices/admission/stage-writes.js +21 -0
  54. package/dist/slices/admission/start.js +2 -1
  55. package/dist/slices/admission/state-words.js +110 -0
  56. package/dist/slices/agents/attention-work.js +233 -0
  57. package/dist/slices/agents/attention.js +168 -0
  58. package/dist/slices/agents/connection.js +15 -0
  59. package/dist/slices/agents/model.js +29 -0
  60. package/dist/slices/agents/repo.js +66 -0
  61. package/dist/slices/cancel/index.js +4 -1
  62. package/dist/slices/control/index.js +2 -1
  63. package/dist/slices/episodes/summarize.js +2 -1
  64. package/dist/slices/estimate/index.js +6 -2
  65. package/dist/slices/images/run.js +2 -1
  66. package/dist/slices/library/transfer.js +69 -0
  67. package/dist/slices/narration/run.js +2 -1
  68. package/dist/slices/notifications/address.js +40 -0
  69. package/dist/slices/notifications/notifier.js +43 -10
  70. package/dist/slices/notifications/rules.js +111 -45
  71. package/dist/slices/notifications/server.js +14 -0
  72. package/dist/slices/notifications/settings.js +1 -1
  73. package/dist/slices/notifications/subject.js +30 -0
  74. package/dist/slices/notifications/told.js +33 -0
  75. package/dist/slices/play-drafts/readiness.js +9 -2
  76. package/dist/slices/play-drafts/schema.js +2 -1
  77. package/dist/slices/rebuild/narration-words.js +76 -0
  78. package/dist/slices/rebuild/recipe-model.js +1 -0
  79. package/dist/slices/rebuild/recipe-reviews.js +44 -23
  80. package/dist/slices/rebuild/recovery-repo.js +0 -20
  81. package/dist/slices/rebuild/recovery-selection.js +14 -3
  82. package/dist/slices/rebuild/recovery.js +5 -2
  83. package/dist/slices/rebuild/remake-outdated.js +27 -0
  84. package/dist/slices/rebuild/repo.js +7 -2
  85. package/dist/slices/rebuild/review-redo.js +12 -0
  86. package/dist/slices/rebuild/runtime-agent-review.js +121 -0
  87. package/dist/slices/rebuild/runtime-export-inputs.js +26 -11
  88. package/dist/slices/rebuild/runtime-review.js +12 -2
  89. package/dist/slices/rebuild/runtime-run.js +11 -2
  90. package/dist/slices/rebuild/runtime-store.js +32 -5
  91. package/dist/slices/reruns/index.js +2 -1
  92. package/dist/slices/research/run.js +2 -1
  93. package/dist/slices/reviews/external-model.js +21 -0
  94. package/dist/slices/reviews/external-release.js +38 -0
  95. package/dist/slices/reviews/external-repo.js +109 -0
  96. package/dist/slices/reviews/external-rows.js +68 -0
  97. package/dist/slices/reviews/external-waiting.js +75 -0
  98. package/dist/slices/reviews/holds.js +144 -0
  99. package/dist/slices/reviews/model.js +14 -0
  100. package/dist/slices/reviews/repo.js +27 -10
  101. package/dist/slices/reviews/rules.js +5 -3
  102. package/dist/slices/reviews/verdict.js +18 -8
  103. package/dist/slices/revisions/repo.js +3 -0
  104. package/dist/slices/revisions/schema.js +1 -0
  105. package/dist/slices/schedules/agenda.js +47 -17
  106. package/dist/slices/schedules/prepare.js +6 -0
  107. package/dist/slices/schedules/schema.js +13 -1
  108. package/dist/slices/storage/consequences.js +27 -0
  109. package/dist/slices/storage/portable.js +3 -0
  110. package/dist/slices/storage/project-files.js +66 -0
  111. package/dist/slices/subtitles/prepare.js +2 -1
  112. package/dist/slices/uploads/repo.js +12 -6
  113. package/dist/slices/uploads/rules.js +5 -0
  114. package/dist/slices/video/write-export.js +1 -1
  115. package/dist/slices/video/write-subtitles.js +1 -1
  116. package/dist/tutorials/Agents-MCP.md +168 -0
  117. package/dist/tutorials/Calendar.md +2 -1
  118. package/dist/tutorials/First-Launch-and-Welcome.md +1 -0
  119. package/dist/tutorials/Home-and-Projects.md +16 -9
  120. package/dist/tutorials/Home.md +1 -0
  121. package/dist/tutorials/Notifications.md +25 -6
  122. package/dist/tutorials/Project-Page.md +8 -2
  123. package/dist/tutorials/Publishing-to-YouTube.md +1 -0
  124. package/dist/tutorials/Recovery-and-Retries.md +17 -3
  125. package/dist/tutorials/Reviews-and-Checkpoints.md +22 -5
  126. package/dist/tutorials/Schedules.md +1 -1
  127. package/dist/tutorials/Settings-Reference.md +15 -0
  128. package/dist/tutorials/Troubleshooting.md +3 -1
  129. package/dist/tutorials/Your-First-Short.md +1 -0
  130. package/dist/tutorials/_Sidebar.md +1 -0
  131. package/dist/web/assets/{index-DpdXRssU.css → index-BKbZIxAq.css} +1 -1
  132. package/dist/web/assets/index-Drth_MfQ.js +325 -0
  133. package/dist/web/assets/{pdf-B42WW32R.js → pdf-CQFUyijy.js} +1 -1
  134. package/dist/web/index.html +2 -2
  135. package/package.json +1 -1
  136. package/dist/web/assets/index-CbmBh8V-.js +0 -325
@@ -9,7 +9,7 @@ import { chunkNarration, defaultChunking } from "../narration/chunk.js";
9
9
  import { describeMessages } from "../narration/describe.js";
10
10
  import { normalizeNarrationText } from "../narration/plan.js";
11
11
  import { preparationMessages } from "../narration/preparation.js";
12
- import { defaultReviewPrompts, reviewPromptKey, reviewRetriesOf } from "../reviews/model.js";
12
+ import { defaultReviewPrompts, reviewedByAgent, reviewPromptKey, reviewRetriesOf, } from "../reviews/model.js";
13
13
  import { activeReviewStages } from "../reviews/rules.js";
14
14
  import { isLocalCliProvider } from "../settings/model.js";
15
15
  import { defaultShortsImagePrompt, defaultShortsPrompt, shortsImageUpperBound, shortsSettingsProblems, } from "../shorts/model.js";
@@ -260,7 +260,11 @@ export function estimateRun(draft, rendered, expectedWords, catalogue) {
260
260
  // Automatic reviews: one call per reviewed item, on the reviewer's model. A failed item
261
261
  // made again is reviewed again, at most the retries allowed, which is not priced here.
262
262
  const reviews = draft.reviews;
263
- if (reviews !== undefined)
263
+ if (reviews !== undefined && reviewedByAgent(reviews)) {
264
+ if (activeReviewStages(draft).length > 0)
265
+ local("Reviews", "An external agent answers each review through MCP; no API fee.");
266
+ }
267
+ else if (reviews !== undefined)
264
268
  for (const stage of activeReviewStages(draft)) {
265
269
  const picked = rendered[reviewPromptKey(stage)]?.trim() ?? "";
266
270
  const prompt = picked === "" ? defaultReviewPrompts[stage].length : picked.length;
@@ -3,7 +3,8 @@ import { dirname } from "node:path";
3
3
  import { z } from "zod";
4
4
  import { transact } from "../../kernel/db/tx.js";
5
5
  import { insertPiece, piecesOf, setPiece } from "../../kernel/runner/piece-repo.js";
6
- import { projectById, setStageProgress } from "../admission/repo.js";
6
+ import { projectById } from "../admission/repo.js";
7
+ import { setStageProgress } from "../admission/stage-writes.js";
7
8
  import { outputFileName, outputPath } from "../storage/layout.js";
8
9
  import { insertOutput, outputsOf } from "../storage/repo.js";
9
10
  import { imageCountsOf } from "./scale.js";
@@ -162,3 +162,72 @@ function freeName(wanted, taken, max, suffix, keepIfFree = true) {
162
162
  return candidate;
163
163
  }
164
164
  }
165
+ const tries = 3;
166
+ // Saves the items one by one under a name free in their group (a prompt kind, an intro or
167
+ // outro, every PDF theme). A name the server still refuses moves on to the next free one; any
168
+ // other refusal skips the item with the server's sentence. The web's Import and the agent's
169
+ // import_library both use it.
170
+ export async function importEach(items, options) {
171
+ const taken = new Map();
172
+ const takenIn = (group) => {
173
+ const found = taken.get(group);
174
+ if (found !== undefined)
175
+ return found;
176
+ const made = new Set();
177
+ taken.set(group, made);
178
+ return made;
179
+ };
180
+ for (const one of options.existing)
181
+ takenIn(one.group).add(one.name.trim().toLowerCase());
182
+ let added = 0;
183
+ let renamed = 0;
184
+ const skipped = [];
185
+ for (const item of items) {
186
+ const names = takenIn(options.groupOf(item));
187
+ let reason = "";
188
+ for (let attempt = 0; attempt < tries; attempt += 1) {
189
+ const name = importedName(item.name, names, options.max);
190
+ try {
191
+ const reply = await options.save({ ...item, name });
192
+ names.add(name.toLowerCase());
193
+ if (reply.ok) {
194
+ added += 1;
195
+ if (name !== item.name.trim())
196
+ renamed += 1;
197
+ reason = "";
198
+ break;
199
+ }
200
+ reason = reply.fields.map((field) => field.message).join(" ");
201
+ if (!reply.fields.some((field) => field.field === "name"))
202
+ break;
203
+ }
204
+ catch (error) {
205
+ reason = error instanceof Error ? error.message : String(error);
206
+ break;
207
+ }
208
+ }
209
+ if (reason !== "")
210
+ skipped.push({ item: item.name.trim() || "An item", reason });
211
+ }
212
+ return { added, renamed, skipped };
213
+ }
214
+ // Folds imported or pasted aliases into a list: a written form already in the list gets the
215
+ // new spoken form, anything else is added at the end.
216
+ export function mergeAliases(rows, pasted, make) {
217
+ const next = [...rows];
218
+ let added = 0;
219
+ let updated = 0;
220
+ for (const alias of pasted) {
221
+ const at = next.findIndex((row) => row.written.trim() === alias.written);
222
+ const existing = at < 0 ? undefined : next[at];
223
+ if (existing === undefined) {
224
+ next.push(make(alias));
225
+ added += 1;
226
+ }
227
+ else if (existing.spoken.trim() !== alias.spoken) {
228
+ next[at] = { ...existing, spoken: alias.spoken };
229
+ updated += 1;
230
+ }
231
+ }
232
+ return { rows: next, added, updated };
233
+ }
@@ -3,7 +3,8 @@ import { dirname } from "node:path";
3
3
  import { z } from "zod";
4
4
  import { piecesOf, setPiece } from "../../kernel/runner/piece-repo.js";
5
5
  import { entryModes } from "../admission/model.js";
6
- import { projectById, setStageProgress, stagesOf } from "../admission/repo.js";
6
+ import { projectById, stagesOf } from "../admission/repo.js";
7
+ import { setStageProgress } from "../admission/stage-writes.js";
7
8
  import { entryCategories } from "../library/model.js";
8
9
  import { outputFileName, outputPath } from "../storage/layout.js";
9
10
  import { insertOutput, outputsOf } from "../storage/repo.js";
@@ -0,0 +1,40 @@
1
+ // Where notifications go: the Notification URL, what it is sent, and the project's address in
2
+ // it. Pure: the web bundle imports this file.
3
+ export const testNotice = {
4
+ headline: "Slopify test notification",
5
+ detail: "Notifications work. You'll get one when a run finishes, fails or waits for you, and when a review needs your decision.",
6
+ };
7
+ // The body a Notification URL receives: plain text, which ntfy shows as the message. Nothing
8
+ // but the project's title, what happened and the provider's own short reason goes in it.
9
+ export function webhookBody(text) {
10
+ return `${text.headline}\n${text.detail}\n${text.link === undefined ? "" : `${text.link}\n`}`;
11
+ }
12
+ // Where a project opens in the browser, from the address the server listens on. A wildcard
13
+ // (0.0.0.0 in Docker) or loopback address is reached as localhost from the same machine.
14
+ export function projectLink(host, port, projectId) {
15
+ const wildcard = host === "0.0.0.0" || host === "::" || host === "" || host === "127.0.0.1";
16
+ const authority = wildcard ? "localhost" : host.includes(":") ? `[${host}]` : host;
17
+ return `http://${authority}:${String(port)}/projects/${encodeURIComponent(projectId)}`;
18
+ }
19
+ export const notificationUrlMax = 2048;
20
+ // Empty clears the setting. Otherwise one absolute http(s) URL with no user name or password:
21
+ // fetch refuses those, and a password in a URL would sit in the database in plain sight.
22
+ export function notificationUrlProblem(value) {
23
+ const trimmed = value.trim();
24
+ if (trimmed === "")
25
+ return undefined;
26
+ if (trimmed.length > notificationUrlMax)
27
+ return `Keep the Notification URL to ${String(notificationUrlMax)} characters or fewer.`;
28
+ let parsed;
29
+ try {
30
+ parsed = new URL(trimmed);
31
+ }
32
+ catch {
33
+ return "Enter a full address starting with https:// or http://, for example https://ntfy.sh/your-topic.";
34
+ }
35
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:")
36
+ return "The Notification URL must start with https:// or http://.";
37
+ if (parsed.username !== "" || parsed.password !== "")
38
+ return "Remove the user name and password from the Notification URL; Slopify can't send them.";
39
+ return undefined;
40
+ }
@@ -1,21 +1,30 @@
1
1
  import { redact } from "../../kernel/log.js";
2
- import { noticeOf, noticeText, reviewNoticeText, topicsNoticeText, webhookBody } from "./rules.js";
2
+ import { webhookBody } from "./address.js";
3
+ import { autoPassNoticeText, noticeOf, noticeText, reviewNoticeText, runNotices, topicsNoticeText, } from "./rules.js";
3
4
  import { sendFailureText } from "./send.js";
5
+ import { toldInMemory } from "./told.js";
4
6
  export function createRunNotifier(deps) {
5
7
  // ceiling: one entry per project seen since boot, a few dozen bytes each.
6
8
  const states = new Map();
7
- const reasons = new Map();
8
9
  const inflight = new Set();
9
10
  let closed = false;
10
- const notify = (projectId, kind) => {
11
+ const told = deps.told ?? toldInMemory();
12
+ const notify = (projectId, cause) => {
11
13
  const url = deps.url();
12
14
  if (url === null)
13
15
  return;
14
16
  const subject = deps.subject(projectId);
15
17
  if (subject === undefined)
16
18
  return;
17
- const text = noticeText(kind, { ...subject, reason: reasons.get(projectId) });
18
- post(url, { ...text, link: deps.link?.(projectId) }, kind, { projectId });
19
+ const before = told.read(projectId);
20
+ const now = runNotices(cause, subject, before);
21
+ for (const one of now.notices) {
22
+ const text = noticeText(one.kind, { ...subject, reason: one.reason });
23
+ post(url, { ...text, link: deps.link?.(projectId) }, one.kind, { projectId });
24
+ }
25
+ // Kept once the notices are on their way, and only when it changed.
26
+ if (now.told.size !== before.size || [...now.told].some((id) => !before.has(id)))
27
+ told.keep(projectId, now.told);
19
28
  };
20
29
  // Once per verdict: a review asked again for the same item replaces its verdict in place.
21
30
  // ceiling: one id per flagged verdict since boot.
@@ -34,11 +43,33 @@ export function createRunNotifier(deps) {
34
43
  title: subject.title,
35
44
  stage: event.stage,
36
45
  reason: event.reason,
46
+ ...(event.byAgent === true ? { byAgent: true } : {}),
37
47
  });
38
48
  post(url, { ...text, link: deps.link?.(event.projectId) }, "review", {
39
49
  projectId: event.projectId,
40
50
  });
41
51
  };
52
+ // Once per review. ceiling: one id per auto-passed review since boot.
53
+ const passed = new Set();
54
+ const notifyAutoPass = (event) => {
55
+ if (passed.has(event.reviewId))
56
+ return;
57
+ passed.add(event.reviewId);
58
+ const url = deps.url();
59
+ if (url === null)
60
+ return;
61
+ const subject = deps.subject(event.projectId);
62
+ if (subject === undefined)
63
+ return;
64
+ const text = autoPassNoticeText({
65
+ title: subject.title,
66
+ stage: event.stage,
67
+ item: event.item,
68
+ });
69
+ post(url, { ...text, link: deps.link?.(event.projectId) }, "auto-pass", {
70
+ projectId: event.projectId,
71
+ });
72
+ };
42
73
  const post = (url, text, kind, about) => {
43
74
  const body = redact(webhookBody(text));
44
75
  const sending = deps
@@ -67,16 +98,18 @@ export function createRunNotifier(deps) {
67
98
  return;
68
99
  try {
69
100
  if (event.type === "stage.state") {
70
- if (event.state === "failed" && event.failureReason !== undefined)
71
- reasons.set(event.projectId, event.failureReason);
72
- else if (event.state === "running")
73
- reasons.delete(event.projectId);
101
+ if (event.state === "failed")
102
+ notify(event.projectId, { stepFailed: event.stage });
74
103
  return;
75
104
  }
76
105
  if (event.type === "review.flagged") {
77
106
  notifyReview(event);
78
107
  return;
79
108
  }
109
+ if (event.type === "review.auto-passed") {
110
+ notifyAutoPass(event);
111
+ return;
112
+ }
80
113
  if (event.type !== "project.state")
81
114
  return;
82
115
  const previous = states.get(event.projectId);
@@ -85,7 +118,7 @@ export function createRunNotifier(deps) {
85
118
  states.set(event.projectId, event.state);
86
119
  const kind = noticeOf(previous, event.state);
87
120
  if (kind !== undefined)
88
- notify(event.projectId, kind);
121
+ notify(event.projectId, { kind });
89
122
  }
90
123
  catch (error) {
91
124
  deps.log.write("warn", "notification.failed", {
@@ -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