@gentbajko/slopify 2.0.1 → 2.0.3

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 (162) hide show
  1. package/dist/adapters/alignment/cache.js +17 -1
  2. package/dist/adapters/alignment/prefetch.js +35 -0
  3. package/dist/adapters/alignment/protocol.js +11 -1
  4. package/dist/adapters/alignment/runner.js +4 -1
  5. package/dist/adapters/alignment/worker.js +28 -5
  6. package/dist/adapters/explain.js +93 -0
  7. package/dist/adapters/ffmpeg.js +3 -3
  8. package/dist/adapters/host-cli/index.js +9 -2
  9. package/dist/adapters/host-cli/transport.js +4 -4
  10. package/dist/adapters/image/bytes.js +2 -2
  11. package/dist/adapters/image/codex-output.js +12 -12
  12. package/dist/adapters/image/codex.js +24 -8
  13. package/dist/adapters/image/fal.js +12 -6
  14. package/dist/adapters/image/google.js +18 -8
  15. package/dist/adapters/image/models.js +8 -2
  16. package/dist/adapters/image/openai.js +15 -6
  17. package/dist/adapters/image/replicate.js +16 -7
  18. package/dist/adapters/llm/claude-code-models.js +1 -1
  19. package/dist/adapters/llm/claude-code.js +16 -7
  20. package/dist/adapters/llm/cli-login-error.js +2 -1
  21. package/dist/adapters/llm/codex-models.js +1 -1
  22. package/dist/adapters/llm/codex.js +5 -6
  23. package/dist/adapters/llm/document-workspace.js +2 -2
  24. package/dist/adapters/llm/gemini-models.js +1 -1
  25. package/dist/adapters/llm/gemini-workspace.js +1 -1
  26. package/dist/adapters/llm/gemini.js +15 -11
  27. package/dist/adapters/llm/openrouter.js +21 -9
  28. package/dist/adapters/llm/run-cli.js +14 -11
  29. package/dist/adapters/tts/cartesia.js +10 -3
  30. package/dist/adapters/tts/elevenlabs.js +18 -6
  31. package/dist/adapters/tts/inworld-async.js +47 -16
  32. package/dist/adapters/tts/inworld.js +25 -15
  33. package/dist/adapters/tts/openai.js +18 -6
  34. package/dist/catalog/registry.js +13 -10
  35. package/dist/catalog/store.js +1 -1
  36. package/dist/catalog/validate.js +13 -4
  37. package/dist/edge/cli.js +37 -8
  38. package/dist/edge/docker-launch.js +6 -6
  39. package/dist/edge/docker-projects/engine.js +34 -13
  40. package/dist/edge/docker-projects/install.js +8 -8
  41. package/dist/edge/docker-projects/state.js +6 -6
  42. package/dist/edge/docker-projects/tree.js +6 -6
  43. package/dist/edge/docker.js +13 -13
  44. package/dist/edge/http/actions.js +27 -25
  45. package/dist/edge/http/app.js +3 -3
  46. package/dist/edge/http/audio-preview.js +1 -1
  47. package/dist/edge/http/checkpoints.js +5 -1
  48. package/dist/edge/http/diagnostics.js +3 -1
  49. package/dist/edge/http/draft-files.js +2 -2
  50. package/dist/edge/http/draft-problem.js +11 -1
  51. package/dist/edge/http/files.js +4 -4
  52. package/dist/edge/http/folder-location.js +3 -3
  53. package/dist/edge/http/fonts.js +22 -10
  54. package/dist/edge/http/host-cli.js +34 -12
  55. package/dist/edge/http/multipart.js +7 -5
  56. package/dist/edge/http/open-folder.js +2 -2
  57. package/dist/edge/http/planning.js +4 -4
  58. package/dist/edge/http/problem.js +2 -2
  59. package/dist/edge/http/project-templates.js +7 -3
  60. package/dist/edge/http/projects.js +8 -8
  61. package/dist/edge/http/prompts.js +9 -4
  62. package/dist/edge/http/providers.js +15 -7
  63. package/dist/edge/http/revision-files.js +3 -3
  64. package/dist/edge/http/revisions.js +30 -4
  65. package/dist/edge/http/schedules.js +9 -9
  66. package/dist/edge/http/settings.js +15 -9
  67. package/dist/edge/http/staging.js +5 -5
  68. package/dist/edge/http/storage.js +4 -4
  69. package/dist/edge/http/subtitles.js +1 -1
  70. package/dist/edge/http/tutorial.js +2 -2
  71. package/dist/edge/http/update.js +3 -3
  72. package/dist/host-cli/install.js +28 -6
  73. package/dist/host-cli/paths.js +6 -6
  74. package/dist/host-cli/server.js +1 -1
  75. package/dist/host-cli/service.js +11 -11
  76. package/dist/host-cli/status.js +7 -3
  77. package/dist/kernel/config/index.js +6 -4
  78. package/dist/kernel/db/migrate.js +2 -1
  79. package/dist/kernel/db/migrations/0013-schedule-topic-queue.sql +4 -0
  80. package/dist/kernel/errors.js +12 -0
  81. package/dist/kernel/lock.js +4 -3
  82. package/dist/kernel/paths.js +5 -0
  83. package/dist/kernel/ports/subtitles.js +15 -1
  84. package/dist/kernel/runner/attempt.js +26 -2
  85. package/dist/kernel/runner/index.js +16 -6
  86. package/dist/kernel/runner/progress.js +56 -0
  87. package/dist/main.js +20 -4
  88. package/dist/slices/admission/rules.js +32 -20
  89. package/dist/slices/article/continuation.js +4 -2
  90. package/dist/slices/article/plain.js +2 -2
  91. package/dist/slices/article/provided-entries.js +3 -3
  92. package/dist/slices/article/run.js +2 -2
  93. package/dist/slices/article/segments.js +5 -3
  94. package/dist/slices/control/providers.js +20 -7
  95. package/dist/slices/fonts/catalog.js +1 -1
  96. package/dist/slices/fonts/upload.js +1 -1
  97. package/dist/slices/images/run.js +5 -5
  98. package/dist/slices/library/lint.js +13 -7
  99. package/dist/slices/library/slots.js +2 -2
  100. package/dist/slices/narration/concat.js +1 -1
  101. package/dist/slices/narration/pronunciation-chunks.js +3 -3
  102. package/dist/slices/narration/pronunciation.js +2 -2
  103. package/dist/slices/narration/run.js +7 -7
  104. package/dist/slices/narration/steering.js +3 -3
  105. package/dist/slices/play-drafts/convert.js +5 -4
  106. package/dist/slices/play-drafts/readiness.js +12 -6
  107. package/dist/slices/play-drafts/review-inputs.js +7 -4
  108. package/dist/slices/play-drafts/review.js +3 -3
  109. package/dist/slices/play-drafts/service.js +3 -1
  110. package/dist/slices/play-drafts/uploads.js +1 -1
  111. package/dist/slices/rebuild/admission-repo.js +2 -2
  112. package/dist/slices/rebuild/recipe-legacy.js +1 -1
  113. package/dist/slices/rebuild/recipe-narration-text.js +1 -1
  114. package/dist/slices/rebuild/recipe-visual.js +1 -1
  115. package/dist/slices/rebuild/recovery.js +2 -2
  116. package/dist/slices/rebuild/repo.js +3 -3
  117. package/dist/slices/rebuild/runtime-admission.js +4 -4
  118. package/dist/slices/rebuild/runtime-article.js +5 -5
  119. package/dist/slices/rebuild/runtime-checkpoints.js +4 -4
  120. package/dist/slices/rebuild/runtime-export-inputs.js +6 -6
  121. package/dist/slices/rebuild/runtime-export.js +4 -4
  122. package/dist/slices/rebuild/runtime-local.js +6 -6
  123. package/dist/slices/rebuild/runtime-narration-text.js +46 -7
  124. package/dist/slices/rebuild/runtime-piece-label.js +62 -0
  125. package/dist/slices/rebuild/runtime-provider.js +6 -4
  126. package/dist/slices/rebuild/runtime-publication.js +3 -3
  127. package/dist/slices/rebuild/runtime-run.js +11 -1
  128. package/dist/slices/rebuild/runtime-store.js +45 -15
  129. package/dist/slices/rebuild/runtime-subtitles.js +80 -17
  130. package/dist/slices/rebuild/transition-repo.js +6 -6
  131. package/dist/slices/reruns/index.js +1 -1
  132. package/dist/slices/research/run.js +9 -7
  133. package/dist/slices/research/synthesis.js +4 -2
  134. package/dist/slices/revisions/mutation-assets.js +26 -8
  135. package/dist/slices/revisions/mutation-cues.js +1 -1
  136. package/dist/slices/revisions/rules.js +18 -9
  137. package/dist/slices/revisions/schema.js +1 -1
  138. package/dist/slices/schedules/repo.js +55 -11
  139. package/dist/slices/schedules/scheduler.js +25 -8
  140. package/dist/slices/schedules/schema.js +19 -4
  141. package/dist/slices/schedules/service.js +2 -0
  142. package/dist/slices/settings/cli-paths.js +7 -4
  143. package/dist/slices/settings/models.js +4 -4
  144. package/dist/slices/settings/playback.js +2 -2
  145. package/dist/slices/storage/assets.js +5 -5
  146. package/dist/slices/storage/layout.js +1 -1
  147. package/dist/slices/subtitles/captions.js +1 -1
  148. package/dist/slices/subtitles/prepare.js +7 -6
  149. package/dist/slices/subtitles/transcript.js +1 -1
  150. package/dist/slices/thumbnail/run.js +8 -8
  151. package/dist/slices/video/audio-inputs.js +2 -2
  152. package/dist/slices/video/ffmpeg.js +6 -4
  153. package/dist/slices/video/plan.js +1 -1
  154. package/dist/slices/video/run.js +2 -2
  155. package/dist/updater/plan.js +1 -1
  156. package/dist/updater/service.js +4 -3
  157. package/dist/updater/worker.js +6 -4
  158. package/dist/web/assets/{index-CZNiHm8P.css → index-Bwtmu9ei.css} +1 -1
  159. package/dist/web/assets/index-DjhMJqMN.js +141 -0
  160. package/dist/web/index.html +2 -2
  161. package/package.json +1 -1
  162. package/dist/web/assets/index-DPgGLX5m.js +0 -137
@@ -15,14 +15,14 @@ export function transitionRevisionWork(deps, input) {
15
15
  transact(db, () => {
16
16
  const current = currentRevisionId(db, input.projectId);
17
17
  if (current !== input.baseRevisionId && current !== input.revisionId)
18
- throw new Error("Work transition requires the current head.");
18
+ throw new Error("The project was changed while this was being saved. Try again.");
19
19
  const base = revisionById(db, input.projectId, input.baseRevisionId);
20
20
  const next = revisionById(db, input.projectId, input.revisionId);
21
21
  if (base === undefined || next === undefined)
22
- throw new Error("Work transition requires owned revisions.");
22
+ throw new Error("Slopify hit an internal error (a project version to switch between is missing). Try again; if it happens again, use Download diagnostics in Settings and report it.");
23
23
  if (JSON.stringify(Object.entries(input.fingerprints).sort()) !==
24
24
  JSON.stringify(Object.entries(next.fingerprints).sort()))
25
- throw new Error("Desired work differs from revision fingerprints.");
25
+ throw new Error("Slopify hit an internal error (the planned steps don't match the saved project version). Try again; if it happens again, use Download diagnostics in Settings and report it.");
26
26
  const reserved = db
27
27
  .prepare(`SELECT r.* FROM revision_work_reservations r JOIN revision_work w ON w.id=r.work_id WHERE r.project_id=? AND r.revision_id=? AND w.state IN ('pending','running','done')`)
28
28
  .all(input.projectId, input.baseRevisionId)
@@ -57,13 +57,13 @@ export function transitionRevisionWork(deps, input) {
57
57
  continue;
58
58
  const exact = input.recipes?.find((row) => row.key === key);
59
59
  if (exact !== undefined && exact.fingerprint !== fp)
60
- throw new Error("Recipe differs from desired fingerprint.");
60
+ throw new Error("Slopify hit an internal error (a planned step doesn't match the saved project version). Try again; if it happens again, use Download diagnostics in Settings and report it.");
61
61
  const kind = exact?.stage ?? stageForKey(key);
62
62
  const stage = db
63
63
  .prepare("SELECT id FROM stages WHERE project_id=? AND kind=?")
64
64
  .get(input.projectId, kind);
65
65
  if (stage === undefined)
66
- throw new Error("Desired work has no project stage.");
66
+ throw new Error("Slopify hit an internal error (a planned step has no matching stage). Try again; if it happens again, use Download diagnostics in Settings and report it.");
67
67
  const workId = deps.ids.next();
68
68
  const pieceId = deps.ids.next();
69
69
  db.prepare(`INSERT INTO revision_work(id,project_id,revision_id,stage_id,kind,fingerprint,state,dispatch_state,created_at) VALUES (?,?,?,?,?,?,'pending','held',?)`).run(workId, input.projectId, input.revisionId, z.string().parse(stage.id), kind, fp, deps.clock.now().toISOString());
@@ -104,5 +104,5 @@ function stageForKey(key) {
104
104
  return "thumbnail";
105
105
  if (prefix === "export" || prefix === "subtitles" || prefix === "video")
106
106
  return "video";
107
- throw new Error("Unknown revision work key.");
107
+ throw new Error("Slopify hit an internal error (unknown kind of step). Try again; if it happens again, use Download diagnostics in Settings and report it.");
108
108
  }
@@ -204,7 +204,7 @@ function clearStage(deps, stage, outputs) {
204
204
  // runs the returned cleanup only after commit. Finished narration is left intact.
205
205
  export function clearUnfinishedAudio(deps, projectId) {
206
206
  if (currentRevisionId(deps.db, projectId) !== undefined)
207
- throw new Error("Versioned narration must be edited through revisions.");
207
+ throw new Error("Slopify hit an internal error (narration was changed the old way on a project that uses versions). Try again; if it happens again, use Download diagnostics in Settings and report it.");
208
208
  const stage = stagesOf(deps.db, projectId).find((one) => one.kind === "audio");
209
209
  if (stage === undefined ||
210
210
  stage.state === "done" ||
@@ -10,7 +10,7 @@ import { chaptersFrom, plannerMessages, subAgentMessages } from "./planner.js";
10
10
  import { sourcedAnswer, synthesisMessages } from "./synthesis.js";
11
11
  // A model that cannot ground on the web fails the stage immediately, with no fallback to
12
12
  // what the model already knows.
13
- export const webResearchUnsupported = "web research unsupported by this model";
13
+ export const webResearchUnsupported = "The chosen AI model can't search the web, which research needs. Choose a model with web search in Edit project → Providers, or turn research off, then Retry stage.";
14
14
  // What a chapter piece carries between runs: its title, and its notes once a sub-agent
15
15
  // has answered. That is the whole of the resume.
16
16
  const chapterPayload = z.object({ title: z.string(), notes: z.string().optional() });
@@ -18,14 +18,14 @@ export async function runResearch(deps, context, providers) {
18
18
  const { projectId } = context.stage;
19
19
  const project = projectById(deps.db, projectId);
20
20
  if (project === undefined) {
21
- throw new Error(`project ${projectId} has no row`);
21
+ throw new Error("This project no longer exists; it may have been deleted while it was running.");
22
22
  }
23
23
  const choice = project.config.llm;
24
24
  const articlePrompt = project.config.rendered.article;
25
25
  if (choice === undefined || articlePrompt === undefined) {
26
26
  // Admission refuses a run whose research is Generate without both, so
27
27
  // reaching here is a bug in admission rather than something the user did.
28
- throw new Error("the run has no LLM provider or no rendered article prompt");
28
+ throw new Error("No AI model or article prompt is set for research. Choose a model in Edit project → Providers and check Edit project → Prompts, then Retry stage.");
29
29
  }
30
30
  const brief = { articlePrompt, values: project.config.values };
31
31
  try {
@@ -62,7 +62,7 @@ async function research(deps, context, providers, choice, brief) {
62
62
  messages: synthesisMessages(brief, findings),
63
63
  documents: researchDocuments(findings),
64
64
  previewLabel: "Writing research notes",
65
- check: (given) => sourcedAnswer("the synthesis", given.text),
65
+ check: (given) => sourcedAnswer("the research summary", given.text),
66
66
  });
67
67
  if (!answer.ok)
68
68
  return "held";
@@ -110,7 +110,9 @@ async function plan(deps, context, providers, choice, brief, add) {
110
110
  messages: plannerMessages(brief),
111
111
  previewLabel: "Planning research",
112
112
  // An empty answer, or one with no chapter in it, is a failed attempt.
113
- check: (given) => chaptersFrom(given.text).length === 0 ? "the planner named no chapters" : undefined,
113
+ check: (given) => chaptersFrom(given.text).length === 0
114
+ ? "The AI model didn't return any research topics. Retry stage; if it keeps happening, choose a different model in Edit project → Providers."
115
+ : undefined,
114
116
  });
115
117
  if (!answer.ok)
116
118
  return answer;
@@ -156,7 +158,7 @@ async function researchChapters(deps, context, providers, choice, brief, chapter
156
158
  // Grounding is asked for explicitly, so a model without it says so
157
159
  // instead of answering from what it already knows.
158
160
  webSearch: true,
159
- check: (given) => sourcedAnswer(`the researcher on "${kept.title}"`, given.text),
161
+ check: (given) => sourcedAnswer(`the research topic "${kept.title}"`, given.text),
160
162
  });
161
163
  if (!answer.ok) {
162
164
  setPiece(deps.db, piece.id, "pending", piece.payload);
@@ -215,7 +217,7 @@ function section(label, messages) {
215
217
  }
216
218
  function payloadOf(piece) {
217
219
  if (piece.payload === null) {
218
- throw new Error(`chapter ${piece.id} has no payload`);
220
+ throw new Error("Slopify hit an internal error (a saved research topic is empty). Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
219
221
  }
220
222
  return chapterPayload.parse(JSON.parse(piece.payload));
221
223
  }
@@ -41,7 +41,9 @@ export function endsWithSources(text) {
41
41
  // but cannot be used is a failed attempt, so the wrapper retries it.
42
42
  export function sourcedAnswer(who, text) {
43
43
  if (text.trim() === "") {
44
- return `${who} answered with nothing`;
44
+ return `The AI model returned an empty answer for ${who}. Retry stage; if it keeps happening, choose a different model in Edit project → Providers.`;
45
45
  }
46
- return endsWithSources(text) ? undefined : `${who} answered with no Sources list`;
46
+ return endsWithSources(text)
47
+ ? undefined
48
+ : `The AI model's answer for ${who} had no sources. Research needs web sources: Retry stage, or choose a model with web search in Edit project → Providers.`;
47
49
  }
@@ -34,11 +34,14 @@ export function validateUploads(deps, edit) {
34
34
  (to.kind === "image" && edit.config.sources.images === "off"))
35
35
  fields.push({
36
36
  field,
37
- message: "Enable the corresponding provided or generated content before importing this file.",
37
+ message: "This section is off or not set to use your own file. Change the section's setting first, then add the file.",
38
38
  });
39
39
  const destination = JSON.stringify(to);
40
40
  if (staged.has(upload.stagedFileId) || destinations.has(destination))
41
- fields.push({ field, message: "Each upload and destination must be unique." });
41
+ fields.push({
42
+ field,
43
+ message: "The same file or place is used twice. Remove the duplicate.",
44
+ });
42
45
  staged.add(upload.stagedFileId);
43
46
  destinations.add(destination);
44
47
  const file = stagedFileById(deps.db, upload.stagedFileId);
@@ -47,11 +50,20 @@ export function validateUploads(deps, edit) {
47
50
  file.state !== "staged" ||
48
51
  file.stageKind !== kind ||
49
52
  !statSync(stagingPath(deps.paths, file.path), { throwIfNoEntry: false })?.isFile())
50
- fields.push({ field, message: "Choose a completed upload for this stage." });
53
+ fields.push({
54
+ field,
55
+ message: "This upload is missing or has not finished. Upload the file again.",
56
+ });
51
57
  if (to.kind === "image" && edit.content.imageDefinitions[to.imageKey] === undefined)
52
- fields.push({ field, message: "Choose an image in this revision." });
58
+ fields.push({
59
+ field,
60
+ message: "This image is no longer in the project. Reload the page and choose again.",
61
+ });
53
62
  if (to.kind === "narration" && !to.key.startsWith("audio:"))
54
- fields.push({ field, message: "Choose a narration segment." });
63
+ fields.push({
64
+ field,
65
+ message: "Choose which part of the narration this audio file replaces.",
66
+ });
55
67
  }
56
68
  return fields;
57
69
  }
@@ -81,7 +93,10 @@ export function validateAssetReferences(deps, projectId, content, prepared) {
81
93
  deps.db
82
94
  .prepare("SELECT 1 FROM project_assets WHERE project_id=? AND id=?")
83
95
  .get(projectId, id) === undefined)
84
- fields.push({ field, message: "Choose an asset belonging to this project." });
96
+ fields.push({
97
+ field,
98
+ message: "This file does not belong to this project. Reload the page and choose again.",
99
+ });
85
100
  if (!prepared.some((row) => row.id === id) &&
86
101
  deps.db
87
102
  .prepare("SELECT 1 FROM revision_outputs WHERE project_id=? AND asset_id=? AND json_extract(descriptor,'$.stageKind')=? AND json_extract(descriptor,'$.role') IN (SELECT value FROM json_each(?))")
@@ -95,7 +110,10 @@ export function validateAssetReferences(deps, projectId, content, prepared) {
95
110
  deps.db
96
111
  .prepare("SELECT 1 FROM project_revisions r, json_each(r.content, '$.narrationOverrides') o WHERE r.project_id=? AND json_extract(o.value,'$.kind')='asset' AND json_extract(o.value,'$.assetId')=? LIMIT 1")
97
112
  .get(projectId, id) !== undefined))
98
- fields.push({ field, message: "Choose an asset for this content stage." });
113
+ fields.push({
114
+ field,
115
+ message: "This file cannot be used in this section. Choose a file of the right kind.",
116
+ });
99
117
  }
100
118
  return fields;
101
119
  }
@@ -144,5 +162,5 @@ export function validateReplacementAvailability(deps, base, edit) {
144
162
  previous.has(id) ||
145
163
  statSync(outputPath(deps.paths, base.revision.projectId, assetPath(deps, base.revision.projectId, id)), { throwIfNoEntry: false })?.isFile()
146
164
  ? []
147
- : [{ field: "content", message: "The replacement asset is missing. Upload it again." }]);
165
+ : [{ field: "content", message: "The replacement file is missing. Upload it again." }]);
148
166
  }
@@ -34,7 +34,7 @@ export function validateProposedCues(base, edit, plan, prepared, durations) {
34
34
  const fail = () => [
35
35
  {
36
36
  field: "content.subtitleCues",
37
- message: "Save captions against the current, complete narration timeline.",
37
+ message: "The narration changed or is not finished, so these captions no longer line up. Reload the page and edit the captions again once narration is complete.",
38
38
  },
39
39
  ];
40
40
  const candidates = [
@@ -51,16 +51,25 @@ export function validateCues(cues, duration) {
51
51
  for (const [index, cue] of cues.entries()) {
52
52
  const field = `content.subtitleCues.cues.${index}`;
53
53
  if (seen.has(cue.id))
54
- fields.push({ field: `${field}.id`, message: "Caption IDs must be unique." });
54
+ fields.push({
55
+ field: `${field}.id`,
56
+ message: "Two captions have the same ID. Reload the page and edit the captions again.",
57
+ });
55
58
  seen.add(cue.id);
56
59
  if (cue.text.trim() === "")
57
- fields.push({ field: `${field}.text`, message: "Caption text is required." });
60
+ fields.push({
61
+ field: `${field}.text`,
62
+ message: "Enter the caption text, or delete this caption.",
63
+ });
58
64
  if (!Number.isFinite(cue.start) || cue.start < end)
59
- fields.push({ field: `${field}.start`, message: "Start must follow the previous caption." });
65
+ fields.push({
66
+ field: `${field}.start`,
67
+ message: "This caption must start after the previous one ends.",
68
+ });
60
69
  if (!Number.isFinite(cue.end) || cue.end <= cue.start || cue.end > duration)
61
70
  fields.push({
62
71
  field: `${field}.end`,
63
- message: "End must follow start and fit the narration.",
72
+ message: "This caption must end after it starts and before the narration ends.",
64
73
  });
65
74
  end = cue.end;
66
75
  }
@@ -75,7 +84,7 @@ export function validateTemplateIntent(base, edit) {
75
84
  base.revision.config.rendered[key] === edit.config.rendered[key])
76
85
  fields.push({
77
86
  field: `content.promptTemplates.${key}`,
78
- message: "Choose the raw template or explicitly keep the current prompt before changing keywords.",
87
+ message: "This prompt was edited by hand, so new keywords will not change it. Switch it back to its template, or choose to keep your edited prompt, first.",
79
88
  });
80
89
  }
81
90
  for (const [key, image] of Object.entries(base.revision.content.imageDefinitions)) {
@@ -87,7 +96,7 @@ export function validateTemplateIntent(base, edit) {
87
96
  next.prompt === image.prompt)
88
97
  fields.push({
89
98
  field: `content.imageDefinitions.${key}.templateKey`,
90
- message: "Choose the raw template or explicitly keep the current image prompt before changing keywords.",
99
+ message: "This image prompt was edited by hand, so new keywords will not change it. Switch it back to its template, or choose to keep your edited prompt, first.",
91
100
  });
92
101
  }
93
102
  }
@@ -99,7 +108,7 @@ export function validateTemplateIntent(base, edit) {
99
108
  isDeepStrictEqual(base.revision.content.imageDefinitions[key], image)))
100
109
  fields.push({
101
110
  field: `content.imageDefinitions.${key}.templateKey`,
102
- message: "The linked raw image template is missing.",
111
+ message: "The prompt template this image uses is missing. Choose another image prompt.",
103
112
  });
104
113
  return fields;
105
114
  }
@@ -132,13 +141,13 @@ export function validateNarrationIntent(base, edit) {
132
141
  if (!isDeepStrictEqual(value, base.revision.content.narrationOverrides[key]) && !keys.has(key))
133
142
  fields.push({
134
143
  field: `content.narrationOverrides.${key}`,
135
- message: "Choose an active narration group or entry.",
144
+ message: "This narration part no longer exists. Reload the page and choose again.",
136
145
  });
137
146
  for (const [index, upload] of (edit.uploads ?? []).entries())
138
147
  if (upload.destination.kind === "narration" && !keys.has(upload.destination.key))
139
148
  fields.push({
140
149
  field: `uploads.${index}.destination.key`,
141
- message: "Choose an active narration group or entry.",
150
+ message: "This narration part no longer exists. Reload the page and choose again.",
142
151
  });
143
152
  return fields;
144
153
  }
@@ -20,7 +20,7 @@ const cue = z
20
20
  .strict()
21
21
  .refine((value) => value.end > value.start, {
22
22
  path: ["end"],
23
- message: "End must be after start.",
23
+ message: "The end time must be after the start time.",
24
24
  });
25
25
  export const revisionContentSchema = z
26
26
  .object({
@@ -15,6 +15,8 @@ const rowSchema = z.object({
15
15
  overlap_policy: z.string(),
16
16
  spend_limit_cents: z.number().nullable(),
17
17
  items_json: z.string(),
18
+ topic_keyword: z.string().nullable(),
19
+ values_json: z.string(),
18
20
  status: z.string(),
19
21
  version: z.number(),
20
22
  next_run_at: z.string().nullable(),
@@ -25,7 +27,7 @@ const rowSchema = z.object({
25
27
  export function scheduleById(db, id) {
26
28
  const row = db
27
29
  .prepare(`SELECT id,name,template_id,template_version,cadence_json,timezone,missed_policy,
28
- overlap_policy,spend_limit_cents,items_json,status,version,next_run_at,created_at,updated_at,
30
+ overlap_policy,spend_limit_cents,items_json,topic_keyword,values_json,status,version,next_run_at,created_at,updated_at,
29
31
  deleted_at
30
32
  FROM schedules WHERE id=?`)
31
33
  .get(id);
@@ -34,7 +36,7 @@ export function scheduleById(db, id) {
34
36
  export function scheduleRows(db) {
35
37
  return db
36
38
  .prepare(`SELECT id,name,template_id,template_version,cadence_json,timezone,missed_policy,
37
- overlap_policy,spend_limit_cents,items_json,status,version,next_run_at,created_at,updated_at,
39
+ overlap_policy,spend_limit_cents,items_json,topic_keyword,values_json,status,version,next_run_at,created_at,updated_at,
38
40
  deleted_at
39
41
  FROM schedules ORDER BY created_at DESC,id`)
40
42
  .all()
@@ -43,7 +45,7 @@ export function scheduleRows(db) {
43
45
  export function dueSchedules(db, now) {
44
46
  return db
45
47
  .prepare(`SELECT id,name,template_id,template_version,cadence_json,timezone,missed_policy,
46
- overlap_policy,spend_limit_cents,items_json,status,version,next_run_at,created_at,updated_at,
48
+ overlap_policy,spend_limit_cents,items_json,topic_keyword,values_json,status,version,next_run_at,created_at,updated_at,
47
49
  deleted_at
48
50
  FROM schedules WHERE deleted_at IS NULL AND status='active' AND next_run_at IS NOT NULL
49
51
  AND next_run_at <= ?
@@ -54,17 +56,18 @@ export function dueSchedules(db, now) {
54
56
  export function insertSchedule(db, schedule, creationHash) {
55
57
  db.prepare(`INSERT INTO schedules
56
58
  (id,name,template_id,template_version,cadence_json,timezone,missed_policy,overlap_policy,
57
- spend_limit_cents,items_json,status,version,creation_hash,next_run_at,created_at,updated_at,
58
- deleted_at)
59
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`).run(schedule.id, schedule.name, schedule.templateId, schedule.templateVersion, JSON.stringify(schedule.cadence), schedule.timezone, schedule.missedPolicy, schedule.overlapPolicy, schedule.spendLimitCents, JSON.stringify(schedule.items), schedule.status, schedule.version, creationHash, schedule.nextRunAt, schedule.createdAt, schedule.updatedAt, schedule.deletedAt);
59
+ spend_limit_cents,items_json,topic_keyword,values_json,status,version,creation_hash,
60
+ next_run_at,created_at,updated_at,deleted_at)
61
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`).run(schedule.id, schedule.name, schedule.templateId, schedule.templateVersion, JSON.stringify(schedule.cadence), schedule.timezone, schedule.missedPolicy, schedule.overlapPolicy, schedule.spendLimitCents, JSON.stringify(schedule.items), schedule.topicKeyword, JSON.stringify(schedule.values), schedule.status, schedule.version, creationHash, schedule.nextRunAt, schedule.createdAt, schedule.updatedAt, schedule.deletedAt);
60
62
  }
61
63
  export function updateScheduleRow(db, schedule, mutationId, mutationHash, baseVersion) {
62
64
  const changed = db
63
65
  .prepare(`UPDATE schedules SET name=?,template_id=?,template_version=?,cadence_json=?,timezone=?,
64
- missed_policy=?,overlap_policy=?,spend_limit_cents=?,items_json=?,status=?,version=?,
66
+ missed_policy=?,overlap_policy=?,spend_limit_cents=?,items_json=?,topic_keyword=?,
67
+ values_json=?,status=?,version=?,
65
68
  next_run_at=?,updated_at=?,mutation_id=?,mutation_hash=?
66
69
  WHERE id=? AND version=? AND deleted_at IS NULL`)
67
- .run(schedule.name, schedule.templateId, schedule.templateVersion, JSON.stringify(schedule.cadence), schedule.timezone, schedule.missedPolicy, schedule.overlapPolicy, schedule.spendLimitCents, JSON.stringify(schedule.items), schedule.status, schedule.version, schedule.nextRunAt, schedule.updatedAt, mutationId, mutationHash, schedule.id, baseVersion);
70
+ .run(schedule.name, schedule.templateId, schedule.templateVersion, JSON.stringify(schedule.cadence), schedule.timezone, schedule.missedPolicy, schedule.overlapPolicy, schedule.spendLimitCents, JSON.stringify(schedule.items), schedule.topicKeyword, JSON.stringify(schedule.values), schedule.status, schedule.version, schedule.nextRunAt, schedule.updatedAt, mutationId, mutationHash, schedule.id, baseVersion);
68
71
  return Number(changed.changes) === 1;
69
72
  }
70
73
  export function tombstoneScheduleRow(db, id, version, deletedAt) {
@@ -108,8 +111,14 @@ export function recordRunStartIdentity(db, run) {
108
111
  if (Number(changed.changes) !== 1)
109
112
  throw new Error("The scheduled run could not persist its Start identity.");
110
113
  }
111
- export function recordRunDispatch(db, run, recordedAt) {
114
+ // `used` is the queued topic the run started, removed in the same transaction that records
115
+ // the run's projects. It is matched by value rather than position, so an edit saved while the
116
+ // run was starting cannot make it remove a different topic. The run that empties the queue
117
+ // completes the schedule.
118
+ export function recordRunDispatch(db, run, recordedAt, used) {
112
119
  transact(db, () => {
120
+ if (used !== undefined)
121
+ consumeTopic(db, run.scheduleId, used, recordedAt);
113
122
  const changed = db
114
123
  .prepare(`UPDATE schedule_runs SET request_id=?,project_ids_json=?,estimate_json=?
115
124
  WHERE id=? AND status='running'`)
@@ -120,6 +129,19 @@ export function recordRunDispatch(db, run, recordedAt) {
120
129
  settleScheduleRunsForProject(db, projectId, recordedAt);
121
130
  });
122
131
  }
132
+ function consumeTopic(db, scheduleId, used, updatedAt) {
133
+ const schedule = scheduleById(db, scheduleId);
134
+ if (schedule === undefined || schedule.deletedAt !== null)
135
+ return;
136
+ const key = JSON.stringify(used);
137
+ const at = schedule.items.findIndex((item) => JSON.stringify(item) === key);
138
+ if (at === -1)
139
+ return;
140
+ const items = schedule.items.filter((_item, index) => index !== at);
141
+ const done = items.length === 0 && (schedule.status === "active" || schedule.status === "paused");
142
+ db.prepare(`UPDATE schedules SET items_json=?,status=?,next_run_at=?,version=version+1,updated_at=?
143
+ WHERE id=? AND deleted_at IS NULL`).run(JSON.stringify(items), done ? "completed" : schedule.status, done ? null : schedule.nextRunAt, updatedAt, scheduleId);
144
+ }
123
145
  export function runsForSchedule(db, scheduleId) {
124
146
  return db
125
147
  .prepare(`SELECT id,schedule_id,scheduled_for,status,request_id,project_ids_json,estimate_json,
@@ -200,7 +222,7 @@ function projectIsActive(db, projectId) {
200
222
  const state = derive(stagesOf(db, projectId), projectPaused(db, projectId));
201
223
  return state !== "done" && state !== "failed" && state !== "canceled";
202
224
  }
203
- export function recoverRunningRuns(db, endedAt, error = "The app stopped while this scheduled run was active.") {
225
+ export function recoverRunningRuns(db, endedAt, error = "Slopify was closed while this run was in progress. Check its video in Projects; the next run will go ahead as planned.") {
204
226
  return transact(db, () => {
205
227
  const pending = db
206
228
  .prepare(`SELECT id,request_id FROM schedule_runs
@@ -243,6 +265,8 @@ function parseSchedule(row) {
243
265
  overlapPolicy: value.overlap_policy,
244
266
  spendLimitCents: value.spend_limit_cents,
245
267
  items: JSON.parse(value.items_json),
268
+ topicKeyword: value.topic_keyword,
269
+ values: JSON.parse(value.values_json),
246
270
  status: value.status,
247
271
  version: value.version,
248
272
  nextRunAt: value.next_run_at,
@@ -276,6 +300,26 @@ function parseRun(row) {
276
300
  estimate: value.estimate_json === null ? null : JSON.parse(value.estimate_json),
277
301
  startedAt: value.started_at,
278
302
  endedAt: value.ended_at,
279
- error: value.error,
303
+ error: value.error === null ? null : runErrorText(value.error),
280
304
  });
281
305
  }
306
+ // A failed dispatch records the refusal's reason code; the run history shows the sentence.
307
+ // Mapped on read so runs recorded before these sentences existed read the same way.
308
+ const internalRunError = "Slopify hit an internal error while starting this run. The next run will try again; if it keeps happening, use Download diagnostics in Settings and report it.";
309
+ const runErrors = {
310
+ "missing-template": "The template this schedule uses was deleted or changed. Edit the schedule and choose a template again.",
311
+ "unsupported-media": "This template uses audio, images or a thumbnail you supplied, which scheduled runs cannot use. Pick a template that generates these instead.",
312
+ "spend-limit": "Not started: the estimated cost was above this schedule's spend limit, or some prices were unknown. Raise the spend limit or choose models with known prices.",
313
+ readiness: "Not started: a provider was not ready, for example a missing API key, model or voice. Check Settings → Providers; the next run will try again.",
314
+ "not-found": "The template's settings are no longer valid, for example a prompt or voice it uses was deleted. Fix the template in Library > Templates and save it.",
315
+ "invalid-draft": "The template's settings are no longer valid, for example a prompt or voice it uses was deleted. Fix the template in Library > Templates and save it.",
316
+ "invalid-edit": "The template's settings are no longer valid, for example a prompt or voice it uses was deleted. Fix the template in Library > Templates and save it.",
317
+ conflict: internalRunError,
318
+ "pending-start": internalRunError,
319
+ "already-started": internalRunError,
320
+ "stale-review": internalRunError,
321
+ "The scheduled run failed.": internalRunError,
322
+ };
323
+ function runErrorText(error) {
324
+ return Object.hasOwn(runErrors, error) ? (runErrors[error] ?? error) : error;
325
+ }
@@ -1,4 +1,5 @@
1
1
  import { transact } from "../../kernel/db/tx.js";
2
+ import { render } from "../admission/substitute.js";
2
3
  import { reviewDraft } from "../play-drafts/review.js";
3
4
  import { createDraft } from "../play-drafts/service.js";
4
5
  import { startPlayDraft } from "../play-drafts/start.js";
@@ -59,8 +60,8 @@ function claim(deps, schedule, now) {
59
60
  endedAt: skip ? now.toISOString() : null,
60
61
  error: skip
61
62
  ? overlapping
62
- ? "Skipped because another occurrence is still running."
63
- : "Skipped because the app missed this occurrence."
63
+ ? "Skipped because the previous run of this schedule was still going."
64
+ : "Skipped because Slopify was not running at the scheduled time."
64
65
  : null,
65
66
  };
66
67
  if (!insertRun(deps.db, run))
@@ -81,13 +82,11 @@ async function execute(deps, claimed) {
81
82
  id: claimed.schedule.templateId,
82
83
  version: claimed.schedule.templateVersion,
83
84
  });
85
+ const topic = claimed.schedule.items[0];
84
86
  const fresh = {
85
87
  ...document,
86
- variants: claimed.schedule.items.map((item) => ({
87
- id: deps.uuid(),
88
- title: item.title,
89
- values: item.values,
90
- })),
88
+ form: runForm(document.form, claimed.schedule, topic),
89
+ variants: [],
91
90
  };
92
91
  const draft = createDraft(deps, { id: deps.uuid(), document: fresh });
93
92
  if (!draft.ok)
@@ -118,7 +117,7 @@ async function execute(deps, claimed) {
118
117
  requestId: started.value.requestId,
119
118
  projectIds: started.value.projectIds,
120
119
  };
121
- recordRunDispatch(deps.db, run, deps.clock.now().toISOString());
120
+ recordRunDispatch(deps.db, run, deps.clock.now().toISOString(), topic);
122
121
  run = {
123
122
  ...run,
124
123
  status: "succeeded",
@@ -135,6 +134,24 @@ async function execute(deps, claimed) {
135
134
  }
136
135
  updateRun(deps.db, run);
137
136
  }
137
+ // One project per run. The first queued topic fills the chosen keyword, the schedule's fixed
138
+ // values fill the rest over the template's own, and the template's title is filled from the
139
+ // same keywords, so "D&D Lore: {{Topic}}" names the project after the topic. A topic saved
140
+ // before topics had a keyword brings its own title and values instead.
141
+ function runForm(form, schedule, topic) {
142
+ const values = {
143
+ ...form.values,
144
+ ...schedule.values,
145
+ ...(topic?.values ?? {}),
146
+ ...(topic !== undefined && schedule.topicKeyword !== null
147
+ ? { [schedule.topicKeyword]: topic.title }
148
+ : {}),
149
+ };
150
+ const title = topic !== undefined && schedule.topicKeyword === null
151
+ ? topic.title
152
+ : render(form.title, values);
153
+ return { ...form, title, values };
154
+ }
138
155
  class ScheduleDispatchError extends Error {
139
156
  reason;
140
157
  constructor(reason) {
@@ -5,15 +5,20 @@ const keywordName = z
5
5
  .string()
6
6
  .min(1)
7
7
  .max(200)
8
- .refine((value) => value === value.trim(), "Keyword names cannot start or end with spaces.");
8
+ .refine((value) => value === value.trim(), "Remove the spaces at the start or end of the keyword name.");
9
+ const values = z.record(keywordName, z.string().max(10000)).readonly();
10
+ // A queued topic. Each run takes the first one and removes it once its project starts.
11
+ // `values` is only filled on schedules saved before topics had one keyword.
9
12
  const item = z
10
13
  .object({
11
14
  title: z.string().trim().min(1).max(200),
12
- values: z.record(keywordName, z.string().max(10000)).readonly(),
15
+ values,
13
16
  })
14
17
  .strict()
15
18
  .readonly();
16
19
  const policy = z.enum(["skip", "run-once"]);
20
+ // Enough for a year of daily runs, pasted as one list.
21
+ export const queueMax = 500;
17
22
  export const scheduleCreateSchema = z
18
23
  .object({
19
24
  id,
@@ -21,11 +26,19 @@ export const scheduleCreateSchema = z
21
26
  templateId: id,
22
27
  templateVersion: z.number().int().positive(),
23
28
  cadence: cadenceSchema,
24
- timezone: z.string().min(1).max(100).refine(validTimeZone, "Choose a valid IANA timezone."),
29
+ timezone: z
30
+ .string()
31
+ .min(1)
32
+ .max(100)
33
+ .refine(validTimeZone, "Choose a time zone from the list, such as Europe/London."),
25
34
  missedPolicy: policy.default("skip"),
26
35
  overlapPolicy: z.literal("skip").default("skip"),
27
36
  spendLimitCents: z.number().int().nonnegative().nullable().default(null),
28
- items: z.array(item).max(49).default([]).readonly(),
37
+ items: z.array(item).max(queueMax).default([]).readonly(),
38
+ // The template keyword each queued topic fills, such as "Topic".
39
+ topicKeyword: keywordName.nullable().default(null),
40
+ // Keywords every run uses as they are, such as the word counts.
41
+ values: values.default({}),
29
42
  })
30
43
  .strict()
31
44
  .readonly();
@@ -54,6 +67,8 @@ export const scheduleSummarySchema = z
54
67
  overlapPolicy: z.literal("skip"),
55
68
  spendLimitCents: z.number().int().nonnegative().nullable(),
56
69
  items: z.array(item).readonly(),
70
+ topicKeyword: z.string().nullable().default(null),
71
+ values: values.default({}),
57
72
  status: z.enum(["active", "paused", "completed", "canceled"]),
58
73
  version: z.number().int().positive(),
59
74
  nextRunAt: z.string().datetime({ offset: true }).nullable(),
@@ -168,6 +168,8 @@ function summary(input, nextRunAt, now, status = "active", version = 1) {
168
168
  overlapPolicy: input.overlapPolicy,
169
169
  spendLimitCents: input.spendLimitCents,
170
170
  items: input.items,
171
+ topicKeyword: input.topicKeyword,
172
+ values: input.values,
171
173
  status,
172
174
  version,
173
175
  nextRunAt,
@@ -37,7 +37,7 @@ export async function saveCliPath(deps, id, raw) {
37
37
  if (deps.hostCliStatus)
38
38
  return {
39
39
  ok: false,
40
- message: "CLI paths are managed on the host. Rerun the Docker launcher after changing installations.",
40
+ message: "Slopify runs in Docker, so it finds these tools on your computer (the Docker host) by itself. If you installed or moved one, run the Slopify Docker launcher again.",
41
41
  };
42
42
  // A slow version probe must not overwrite a later Save or Reset from another tab.
43
43
  let queue = saves.get(deps.db);
@@ -66,13 +66,16 @@ async function save(deps, id, raw) {
66
66
  if (!parsed.success)
67
67
  return {
68
68
  ok: false,
69
- message: "Enter an absolute executable file path without quotes or arguments, or leave it blank to use PATH.",
69
+ message: "Enter the full path to the program file, without quotes or extra options, or leave it blank to let Slopify find it.",
70
70
  };
71
71
  const configured = parsed.data === "" ? null : parsed.data;
72
72
  if (configured !== null) {
73
73
  try {
74
74
  if (!(await stat(configured)).isFile())
75
- return { ok: false, message: "Choose an executable file, not a directory." };
75
+ return {
76
+ ok: false,
77
+ message: "This path is a folder. Enter the path to the program file inside it.",
78
+ };
76
79
  await access(configured, process.platform === "win32" ||
77
80
  [".js", ".mjs", ".cjs"].includes(extname(configured).toLowerCase())
78
81
  ? constants.R_OK
@@ -84,7 +87,7 @@ async function save(deps, id, raw) {
84
87
  ["ENOENT", "ENOTDIR", "EACCES", "EPERM", "ELOOP"].includes(String(error.code)))
85
88
  return {
86
89
  ok: false,
87
- message: "That executable file cannot be found or run. Check its path and permissions.",
90
+ message: "Slopify cannot find or run a program at this path. Check the path is correct and the file is allowed to run.",
88
91
  };
89
92
  throw error;
90
93
  }
@@ -42,11 +42,11 @@ export function createModelCatalog(deps) {
42
42
  allowsCustom: allowsCustomModel(provider),
43
43
  warning: local
44
44
  ? provider === "codex-image"
45
- ? "Could not check the Codex image capability. Refresh after checking the CLI."
46
- : "Could not discover models from this CLI. Enter an exact model ID manually or refresh."
45
+ ? "Slopify could not check whether Codex CLI can make images. Make sure Codex CLI is installed and signed in, then refresh the list."
46
+ : "Slopify could not get the model list from this command-line tool. Check it is installed and signed in, then refresh the list, or type an exact model ID manually."
47
47
  : saved === undefined
48
- ? "Could not load the model list. Showing bundled choices; refresh to try again."
49
- : "Could not refresh the model list. Showing the last loaded choices.",
48
+ ? "Slopify could not load this provider's model list, so it is showing its built-in choices. Check your internet connection and API key, then refresh the list."
49
+ : "Slopify could not update this provider's model list, so it is showing the last loaded list. Check your internet connection and API key, then refresh again.",
50
50
  };
51
51
  lifetime = retryMs;
52
52
  }