@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
@@ -3,6 +3,7 @@ import { z } from "zod";
3
3
  import { cliCommand } from "../../kernel/cli-command.js";
4
4
  import { redact } from "../../kernel/log.js";
5
5
  import { providerError } from "../../kernel/ports/model.js";
6
+ import { cliName, commandOf, quoted } from "../explain.js";
6
7
  // ceiling: the last 8 KiB of stderr is kept. A CLI that writes megabytes of progress there
7
8
  // would otherwise be held in memory for the length of a run, and the tail is the part that
8
9
  // says why it stopped. Streaming stderr to the log is the upgrade if a provider ever
@@ -117,7 +118,7 @@ export async function deliveredInput(run) {
117
118
  catch {
118
119
  throw providerError({
119
120
  kind: "unavailable",
120
- message: "The full prompt could not be delivered to the CLI. Review before retrying.",
121
+ message: "Slopify could not hand the whole prompt to the CLI, so it cannot tell what the CLI worked on (it may already have used your quota). Use Retry stage to run it again.",
121
122
  });
122
123
  }
123
124
  }
@@ -126,7 +127,7 @@ export function cliInput(text) {
126
127
  if (Buffer.byteLength(text) >= 8 * 1024 * 1024)
127
128
  throw providerError({
128
129
  kind: "unsupported",
129
- message: "The text prompt exceeds the CLI input limit (8 MiB). No text was truncated or submitted.",
130
+ message: "This prompt is larger than the CLI accepts (8 MB), so nothing was sent. Make the inputs shorter in Edit project, or choose an API provider such as OpenRouter in its Providers section, then use Retry stage.",
130
131
  });
131
132
  return text;
132
133
  }
@@ -151,15 +152,17 @@ export function promptOf(messages) {
151
152
  .join("\n\n");
152
153
  }
153
154
  // A bare non-zero exit tells the user nothing, so the stage shows whatever the CLI put on
154
- // stderr - the provider's error text, verbatim.
155
+ // stderr - the provider's error text, verbatim - after a sentence saying what to do.
155
156
  export function endedWithout(binary, ended, stderr) {
156
157
  const said = redact(stderr.trim());
157
- const how = ended.error === null
158
- ? `exited ${ended.code === null ? "on a signal" : String(ended.code)}`
159
- : `could not be started (${redact(ended.error.message)})`;
160
- return said === ""
161
- ? `the ${binary} CLI ${how} without answering`
162
- : `the ${binary} CLI ${how} without answering: ${said}`;
158
+ if (ended.error !== null)
159
+ return `The ${cliName(binary)} could not be started${quoted("", redact(ended.error.message))}. Check it is installed and set up in Settings → Providers, then use Retry stage.`;
160
+ const how = ended.code === null ? "killed by the system" : `exit code ${String(ended.code)}`;
161
+ return `The ${cliName(binary)} stopped without answering${quoted(how, said)}. Run ${commandOf(binary)} in a terminal to check it works and is signed in, then use Retry stage.`;
162
+ }
163
+ // The run outlived both the polite and the forced stop.
164
+ export function stuckCli(binary) {
165
+ return `The ${cliName(binary)} stopped responding and could not be shut down. Close any leftover ${commandOf(binary)} processes or restart your computer, then use Retry stage.`;
163
166
  }
164
167
  // Unreachable while stdio names `pipe` for fd 1; it is here so the type carries no null
165
168
  // and no non-null assertion is needed to read the stream.
@@ -180,7 +183,7 @@ export function cliEvent(binary, line) {
180
183
  // know. The text is not echoed back: half a JSON object is noise to the user.
181
184
  throw providerError({
182
185
  kind: "other",
183
- message: `the ${binary} CLI wrote a line this app could not read`,
186
+ message: `The ${cliName(binary)} sent output Slopify could not read. Update it to the latest version, then use Retry stage.`,
184
187
  });
185
188
  }
186
189
  return { type: parsed.data.type, value };
@@ -190,7 +193,7 @@ export function cliShaped(binary, schema, value) {
190
193
  if (!parsed.success) {
191
194
  throw providerError({
192
195
  kind: "other",
193
- message: `the ${binary} CLI wrote an event this app could not read`,
196
+ message: `The ${cliName(binary)} sent output Slopify could not read. Update it to the latest version, then use Retry stage.`,
194
197
  });
195
198
  }
196
199
  return parsed.data;
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { redact } from "../../kernel/log.js";
3
3
  import { providerError } from "../../kernel/ports/model.js";
4
+ import { httpFailure, missingKey, noAudio, voiceFix } from "../explain.js";
4
5
  import { retryAfter } from "../retry-after.js";
5
6
  // The HTTP gateway adapter for Cartesia. `fetch` and
6
7
  // nothing else; `/tts/bytes` streams the audio it renders, so the response body is
@@ -54,7 +55,7 @@ export function cartesiaTts(deps) {
54
55
  throw await failure(response, req.voiceId);
55
56
  }
56
57
  if (response.body === null) {
57
- throw providerError({ kind: "other", message: "Cartesia answered with no audio" });
58
+ throw providerError({ kind: "other", message: noAudio("Cartesia") });
58
59
  }
59
60
  return { audio: response.body, container: "mp3" };
60
61
  },
@@ -64,7 +65,7 @@ function keyOf(deps) {
64
65
  const key = deps.key();
65
66
  // An absent key is terminal, so it never becomes a request.
66
67
  if (key === undefined || key === "") {
67
- throw providerError({ kind: "missing_key", message: "no Cartesia key is stored" });
68
+ throw providerError({ kind: "missing_key", message: missingKey("Cartesia") });
68
69
  }
69
70
  return key;
70
71
  }
@@ -88,7 +89,13 @@ async function failure(response, voiceId) {
88
89
  kind: kindOf(response.status),
89
90
  // `voice_not_found` has to say which voice was asked for; Cartesia's
90
91
  // own message does not repeat the id.
91
- message: `Cartesia answered ${response.status} for voice ${voiceId}: ${message}`,
92
+ message: httpFailure({
93
+ provider: "Cartesia",
94
+ status: response.status,
95
+ detail: message,
96
+ subject: `narration request for voice "${voiceId}"`,
97
+ fix: voiceFix(voiceId),
98
+ }),
92
99
  ...(retryAfterMs === undefined ? {} : { retryAfterMs }),
93
100
  });
94
101
  }
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { redact } from "../../kernel/log.js";
3
3
  import { providerError } from "../../kernel/ports/model.js";
4
+ import { httpFailure, missingKey, noAudio, refreshList, unreadable, voiceFix } from "../explain.js";
4
5
  import { retryAfter } from "../retry-after.js";
5
6
  // The HTTP gateway adapter for ElevenLabs: the platform's own `fetch` and nothing else. The
6
7
  // response body already is the `ReadableStream<Uint8Array>` the port asks for, so an SDK
@@ -44,12 +45,12 @@ export function elevenLabsTts(deps) {
44
45
  signal: AbortSignal.timeout(10_000),
45
46
  });
46
47
  if (!response.ok)
47
- throw await failure(response);
48
+ throw await failure(response, undefined, refreshList);
48
49
  const parsed = modelList.safeParse(safeJson(await response.text()));
49
50
  if (!parsed.success) {
50
51
  throw providerError({
51
52
  kind: "other",
52
- message: "ElevenLabs' model list was not in the shape this app can read",
53
+ message: unreadable("ElevenLabs"),
53
54
  });
54
55
  }
55
56
  return parsed.data
@@ -70,7 +71,7 @@ export function elevenLabsTts(deps) {
70
71
  throw await failure(response, req.voiceId);
71
72
  }
72
73
  if (response.body === null) {
73
- throw providerError({ kind: "other", message: "ElevenLabs answered with no audio" });
74
+ throw providerError({ kind: "other", message: noAudio("ElevenLabs") });
74
75
  }
75
76
  return { audio: response.body, container: "mp3" };
76
77
  },
@@ -80,7 +81,7 @@ function keyOf(deps) {
80
81
  const key = deps.key();
81
82
  // `missing_key`, not `auth`, because that rule makes it terminal.
82
83
  if (key === undefined || key === "") {
83
- throw providerError({ kind: "missing_key", message: "no ElevenLabs key is stored" });
84
+ throw providerError({ kind: "missing_key", message: missingKey("ElevenLabs") });
84
85
  }
85
86
  return key;
86
87
  }
@@ -97,7 +98,7 @@ function kindOf(status) {
97
98
  // port's error contract first, which is not this adapter's to widen.
98
99
  return "other";
99
100
  }
100
- async function failure(response, voiceId) {
101
+ async function failure(response, voiceId, next) {
101
102
  const text = await response.text().catch(() => "");
102
103
  // The provider's own words, through the redactor - an error body may quote the key back.
103
104
  const message = redact(detailOf(text) || response.statusText);
@@ -106,7 +107,18 @@ async function failure(response, voiceId) {
106
107
  kind: kindOf(response.status),
107
108
  // A rejected voice ID has to be named, and it is the one part of the
108
109
  // request the user chose. It is not secret, unlike everything else on the wire.
109
- message: `ElevenLabs answered ${response.status}${voiceId === undefined ? "" : ` for voice ${voiceId}`}: ${message}`,
110
+ message: httpFailure({
111
+ provider: "ElevenLabs",
112
+ status: response.status,
113
+ detail: message,
114
+ next,
115
+ ...(voiceId === undefined
116
+ ? {}
117
+ : {
118
+ subject: `narration request for voice "${voiceId}"`,
119
+ fix: voiceFix(voiceId),
120
+ }),
121
+ }),
110
122
  ...(retryAfterMs === undefined ? {} : { retryAfterMs }),
111
123
  });
112
124
  }
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { redact } from "../../kernel/log.js";
3
3
  import { providerError } from "../../kernel/ports/model.js";
4
+ import { httpFailure, internalError, noAudio, providerSaid, unreadable, voiceFix, } from "../explain.js";
4
5
  import { retryAfter } from "../retry-after.js";
5
6
  const operationName = /^workspaces\/[A-Za-z0-9_-]+\/ttsAsyncJobs\/[A-Za-z0-9_-]+\/operations\/[A-Za-z0-9_-]+$/;
6
7
  const operationSchema = z.object({
@@ -15,11 +16,14 @@ const operationSchema = z.object({
15
16
  export async function* inworldAsync(deps, request, key) {
16
17
  const saved = request.continuation?.read();
17
18
  if (saved !== undefined && !operationName.test(saved))
18
- throw invalid("returned an invalid operation name");
19
+ throw providerError({
20
+ kind: "other",
21
+ message: internalError("the saved Inworld job reference is damaged"),
22
+ });
19
23
  if (saved === undefined && request.text.length > 100_000) {
20
24
  throw providerError({
21
25
  kind: "unsupported",
22
- message: "Inworld async accepts up to 100,000 characters per request (10,000 for On-Demand accounts). Select paragraph chunking for longer articles.",
26
+ message: "This narration part is longer than the 100,000 characters Inworld takes at once (10,000 on On-Demand accounts). Set Chunking to Paragraph in the Providers section of Edit project, then use Retry stage.",
23
27
  });
24
28
  }
25
29
  const headers = { Authorization: `Basic ${key}`, "Content-Type": "application/json" };
@@ -45,10 +49,10 @@ export async function* inworldAsync(deps, request, key) {
45
49
  let expected = saved;
46
50
  for (;;) {
47
51
  request.signal.throwIfAborted();
48
- await check(response, key);
52
+ await check(response, key, request.voiceId);
49
53
  const parsed = operationSchema.safeParse(await response.json().catch(() => null));
50
54
  if (!parsed.success || (expected && parsed.data.name !== expected))
51
- throw invalid("returned an invalid operation");
55
+ throw unreadableAnswer();
52
56
  const operation = parsed.data;
53
57
  expected = operation.name;
54
58
  request.continuation?.write(operation.name);
@@ -65,11 +69,11 @@ export async function* inworldAsync(deps, request, key) {
65
69
  : code === 3 || code === 5 || code === 9
66
70
  ? "unsupported"
67
71
  : "other",
68
- message: `Inworld async job failed: ${clean(message ?? "audio generation stopped", key)}`,
72
+ message: streamFailure(code, clean(message ?? "", key), request.voiceId),
69
73
  });
70
74
  }
71
75
  if (!operation.response)
72
- throw invalid("finished without an audio download");
76
+ throw providerError({ kind: "other", message: noAudio("Inworld") });
73
77
  yield* download(deps.fetch, operation.response.audioUri, request.signal);
74
78
  return;
75
79
  }
@@ -85,7 +89,7 @@ export async function* inworldAsync(deps, request, key) {
85
89
  async function* download(fetch, uri, signal) {
86
90
  const url = URL.parse(uri);
87
91
  if (url?.protocol !== "https:" || url.username || url.password)
88
- throw invalid("returned an invalid audio download URL");
92
+ throw unreadableAnswer();
89
93
  // This is a signed storage URL, not an Inworld API endpoint. Never forward Basic auth.
90
94
  let response;
91
95
  try {
@@ -94,12 +98,12 @@ async function* download(fetch, uri, signal) {
94
98
  catch {
95
99
  signal.throwIfAborted();
96
100
  // Signed query parameters must not become a persisted error or log entry.
97
- throw invalid("audio download could not be reached; retrying the existing job");
101
+ throw downloadFailed("");
98
102
  }
99
103
  if (!response.ok)
100
- throw invalid(`audio download answered ${response.status}; retrying the existing job`);
104
+ throw downloadFailed(` (error ${String(response.status)})`);
101
105
  if (!response.body)
102
- throw invalid("answered with no audio");
106
+ throw providerError({ kind: "other", message: noAudio("Inworld") });
103
107
  const reader = response.body.getReader();
104
108
  let heard = false;
105
109
  try {
@@ -116,16 +120,16 @@ async function* download(fetch, uri, signal) {
116
120
  }
117
121
  catch {
118
122
  signal.throwIfAborted();
119
- throw invalid("audio download stopped; retrying the existing job");
123
+ throw downloadFailed("");
120
124
  }
121
125
  finally {
122
126
  await reader.cancel().catch(() => { });
123
127
  reader.releaseLock();
124
128
  }
125
129
  if (!heard)
126
- throw invalid("answered with no audio");
130
+ throw providerError({ kind: "other", message: noAudio("Inworld") });
127
131
  }
128
- async function check(response, key) {
132
+ async function check(response, key, voiceId) {
129
133
  if (response.ok)
130
134
  return;
131
135
  const detail = clean(await response.text().catch(() => ""), key);
@@ -138,13 +142,40 @@ async function check(response, key) {
138
142
  : response.status === 400 || response.status === 404
139
143
  ? "unsupported"
140
144
  : "other",
141
- message: `Inworld async answered ${response.status}: ${detail || response.statusText}`,
145
+ message: httpFailure({
146
+ provider: "Inworld",
147
+ status: response.status,
148
+ detail: detail || response.statusText,
149
+ subject: `narration request for voice "${voiceId}"`,
150
+ fix: voiceFix(voiceId),
151
+ }),
142
152
  ...(retryAfterMs === undefined ? {} : { retryAfterMs }),
143
153
  });
144
154
  }
145
155
  function clean(message, key) {
146
156
  return redact(message.replaceAll(key, "[redacted]"));
147
157
  }
148
- function invalid(detail) {
149
- return providerError({ kind: "other", message: `Inworld ${detail}.` });
158
+ // Signed query parameters must not become a persisted error or log entry, so the link is
159
+ // never quoted.
160
+ function downloadFailed(status) {
161
+ return providerError({
162
+ kind: "other",
163
+ message: `Inworld made the narration, but Slopify could not download it${status}. Check your internet connection, then use Retry stage.`,
164
+ });
165
+ }
166
+ function unreadableAnswer() {
167
+ return providerError({ kind: "other", message: unreadable("Inworld") });
168
+ }
169
+ // Inworld's streamed and async errors carry a gRPC code instead of an HTTP status.
170
+ export function streamFailure(code, detail, voiceId) {
171
+ const label = `code ${String(code)}`;
172
+ if (code === 16)
173
+ return httpFailure({ provider: "Inworld", status: 401, detail, label });
174
+ if (code === 7)
175
+ return httpFailure({ provider: "Inworld", status: 403, detail, label });
176
+ if (code === 8)
177
+ return httpFailure({ provider: "Inworld", status: 429, detail, label });
178
+ if (code === 3 || code === 5 || code === 9)
179
+ return providerSaid("Inworld", "could not narrate this text", detail, voiceFix(voiceId));
180
+ return providerSaid("Inworld", "stopped before the narration was finished", detail, "This is usually temporary: wait a few minutes, then use Retry stage.");
150
181
  }
@@ -1,8 +1,9 @@
1
1
  import { z } from "zod";
2
2
  import { redact } from "../../kernel/log.js";
3
3
  import { providerError } from "../../kernel/ports/model.js";
4
+ import { httpFailure, missingKey, noAudio, unreadable, voiceFix } from "../explain.js";
4
5
  import { retryAfter } from "../retry-after.js";
5
- import { inworldAsync } from "./inworld-async.js";
6
+ import { inworldAsync, streamFailure } from "./inworld-async.js";
6
7
  export const inworldModels = [
7
8
  { id: "inworld-tts-2", name: "Realtime TTS-2" },
8
9
  { id: "inworld-tts-2-flash", name: "Realtime TTS-2 Flash" },
@@ -22,7 +23,7 @@ export function inworldTts(deps) {
22
23
  ?.trim()
23
24
  .replace(/^Basic\s+/i, "");
24
25
  if (!key)
25
- throw providerError({ kind: "missing_key", message: "No Inworld API key is stored." });
26
+ throw providerError({ kind: "missing_key", message: missingKey("Inworld") });
26
27
  const cancel = new AbortController();
27
28
  const signal = AbortSignal.any([request.signal, cancel.signal]);
28
29
  const iterator = synthesizeRequest(deps, { ...request, signal }, key);
@@ -56,7 +57,10 @@ async function* synthesizeRequest(deps, request, key) {
56
57
  return;
57
58
  }
58
59
  if (request.text.trim().length === 0)
59
- throw providerError({ kind: "unsupported", message: "Inworld needs text to narrate." });
60
+ throw providerError({
61
+ kind: "unsupported",
62
+ message: "Inworld was sent an empty piece of narration. Check the article has text in the Article section of Edit project, then use Retry stage.",
63
+ });
60
64
  if ((request.model ?? "inworld-tts-2") === "inworld-tts-2" && request.text.length > 4000) {
61
65
  yield* inworldAsync(deps, request, key);
62
66
  return;
@@ -64,7 +68,7 @@ async function* synthesizeRequest(deps, request, key) {
64
68
  if (request.text.length > 4000)
65
69
  throw providerError({
66
70
  kind: "unsupported",
67
- message: "Inworld streaming accepts up to 4,000 characters per physical request. Rebuild narration with a supported request limit.",
71
+ message: "This narration part is longer than the 4,000 characters Inworld's Flash model takes at once. In the Providers section of Edit project, set Chunking to Paragraph or a smaller character count, or switch to the Realtime TTS-2 model.",
68
72
  });
69
73
  request.signal.throwIfAborted();
70
74
  const response = await deps.fetch("https://api.inworld.ai/tts/v1/voice:stream", {
@@ -90,12 +94,18 @@ async function* synthesizeRequest(deps, request, key) {
90
94
  : response.status === 400 || response.status === 404
91
95
  ? "unsupported"
92
96
  : "other",
93
- message: clean(`Inworld answered ${response.status} for voice ${request.voiceId}: ${detail || response.statusText}`, key),
97
+ message: clean(httpFailure({
98
+ provider: "Inworld",
99
+ status: response.status,
100
+ detail: detail || response.statusText,
101
+ subject: `narration request for voice "${request.voiceId}"`,
102
+ fix: voiceFix(request.voiceId),
103
+ }), key),
94
104
  ...(retryAfterMs === undefined ? {} : { retryAfterMs }),
95
105
  });
96
106
  }
97
107
  if (response.body === null)
98
- throw invalid("answered with no audio");
108
+ throw providerError({ kind: "other", message: noAudio("Inworld") });
99
109
  let heard = false;
100
110
  for await (const line of lines(response.body)) {
101
111
  request.signal.throwIfAborted();
@@ -104,11 +114,11 @@ async function* synthesizeRequest(deps, request, key) {
104
114
  raw = JSON.parse(line);
105
115
  }
106
116
  catch {
107
- throw invalid("sent an unreadable audio chunk");
117
+ throw unreadableAudio();
108
118
  }
109
119
  const parsed = envelope.safeParse(raw);
110
120
  if (!parsed.success || (!parsed.data.result && !parsed.data.error))
111
- throw invalid("sent an unreadable audio chunk");
121
+ throw unreadableAudio();
112
122
  if (parsed.data.error) {
113
123
  const { code, message } = parsed.data.error;
114
124
  throw providerError({
@@ -119,14 +129,14 @@ async function* synthesizeRequest(deps, request, key) {
119
129
  : code === 3 || code === 5
120
130
  ? "unsupported"
121
131
  : "other",
122
- message: `Inworld stream failed: ${clean(message ?? "audio generation stopped", key)}`,
132
+ message: streamFailure(code, clean(message ?? "", key), request.voiceId),
123
133
  });
124
134
  }
125
135
  const encoded = parsed.data.result?.audioContent;
126
136
  if (!encoded)
127
137
  continue;
128
138
  if (!/^[A-Za-z0-9+/]*={0,2}$/.test(encoded) || encoded.length % 4 === 1)
129
- throw invalid("sent invalid encoded audio");
139
+ throw unreadableAudio();
130
140
  const audio = Buffer.from(encoded, "base64");
131
141
  if (audio.length === 0)
132
142
  continue;
@@ -134,7 +144,7 @@ async function* synthesizeRequest(deps, request, key) {
134
144
  yield audio;
135
145
  }
136
146
  if (!heard)
137
- throw invalid("answered with no audio");
147
+ throw providerError({ kind: "other", message: noAudio("Inworld") });
138
148
  }
139
149
  async function* lines(body) {
140
150
  const reader = body.getReader();
@@ -147,7 +157,7 @@ async function* lines(body) {
147
157
  let end = pending.indexOf("\n");
148
158
  while (end !== -1) {
149
159
  if (end > 8 * 1024 * 1024)
150
- throw invalid("sent an oversized audio chunk");
160
+ throw unreadableAudio();
151
161
  const line = pending.slice(0, end).trim();
152
162
  pending = pending.slice(end + 1);
153
163
  if (line)
@@ -155,7 +165,7 @@ async function* lines(body) {
155
165
  end = pending.indexOf("\n");
156
166
  }
157
167
  if (pending.length > 8 * 1024 * 1024)
158
- throw invalid("sent an oversized audio chunk");
168
+ throw unreadableAudio();
159
169
  if (done)
160
170
  break;
161
171
  }
@@ -170,6 +180,6 @@ async function* lines(body) {
170
180
  function clean(message, key) {
171
181
  return redact(message.replaceAll(key, "[redacted]"));
172
182
  }
173
- function invalid(detail) {
174
- return providerError({ kind: "other", message: `Inworld ${detail}.` });
183
+ function unreadableAudio() {
184
+ return providerError({ kind: "other", message: unreadable("Inworld") });
175
185
  }
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { redact } from "../../kernel/log.js";
3
3
  import { providerError } from "../../kernel/ports/model.js";
4
+ import { httpFailure, missingKey, noAudio, refreshList, unreadable, voiceFix } from "../explain.js";
4
5
  import { retryAfter } from "../retry-after.js";
5
6
  // The HTTP gateway adapter for OpenAI's speech endpoint. `fetch` and nothing else; the response
6
7
  // body is already the stream the port asks for.
@@ -34,12 +35,12 @@ export function openAiTts(deps) {
34
35
  signal: AbortSignal.timeout(10_000),
35
36
  });
36
37
  if (!response.ok)
37
- throw await failure(response);
38
+ throw await failure(response, undefined, refreshList);
38
39
  const parsed = modelList.safeParse(safeJson(await response.text()));
39
40
  if (!parsed.success) {
40
41
  throw providerError({
41
42
  kind: "other",
42
- message: "OpenAI's model list was not in the shape this app can read",
43
+ message: unreadable("OpenAI"),
43
44
  });
44
45
  }
45
46
  return parsed.data.data
@@ -68,7 +69,7 @@ export function openAiTts(deps) {
68
69
  throw await failure(response, req.voiceId);
69
70
  }
70
71
  if (response.body === null) {
71
- throw providerError({ kind: "other", message: "OpenAI answered with no audio" });
72
+ throw providerError({ kind: "other", message: noAudio("OpenAI") });
72
73
  }
73
74
  return { audio: response.body, container: "mp3" };
74
75
  },
@@ -81,7 +82,7 @@ function keyOf(deps) {
81
82
  const key = deps.key();
82
83
  // An absent key is terminal, so it never becomes a request.
83
84
  if (key === undefined || key === "") {
84
- throw providerError({ kind: "missing_key", message: "no OpenAI key is stored" });
85
+ throw providerError({ kind: "missing_key", message: missingKey("OpenAI") });
85
86
  }
86
87
  return key;
87
88
  }
@@ -96,7 +97,7 @@ function kindOf(status) {
96
97
  // port's error contract has no terminal kind for a request that cannot be repaired.
97
98
  return "other";
98
99
  }
99
- async function failure(response, voiceId) {
100
+ async function failure(response, voiceId, next) {
100
101
  const text = await response.text().catch(() => "");
101
102
  const parsed = errorBody.safeParse(safeJson(text));
102
103
  // Verbatim, through the same redactor the wrapper uses: OpenAI's own 401 quotes the
@@ -107,7 +108,18 @@ async function failure(response, voiceId) {
107
108
  kind: kindOf(response.status),
108
109
  // The voice ID is named, so a rejected voice reads differently from
109
110
  // a rejected key.
110
- message: `OpenAI answered ${response.status}${voiceId === undefined ? "" : ` for voice ${voiceId}`}: ${message}`,
111
+ message: httpFailure({
112
+ provider: "OpenAI",
113
+ status: response.status,
114
+ detail: message,
115
+ next,
116
+ ...(voiceId === undefined
117
+ ? {}
118
+ : {
119
+ subject: `narration request for voice "${voiceId}"`,
120
+ fix: voiceFix(voiceId),
121
+ }),
122
+ }),
111
123
  ...(retryAfterMs === undefined ? {} : { retryAfterMs }),
112
124
  });
113
125
  }
@@ -7,7 +7,7 @@ export function curateRegistry(registry, catalogue) {
7
7
  if (!model)
8
8
  throw providerError({
9
9
  kind: "unsupported",
10
- message: `${provider}: this model is not enabled in the current catalogue. Choose a supported model in Run settings.`,
10
+ message: `The chosen ${provider} model is no longer in Slopify's model list. In the Edit tab, choose Edit project and change it under Providers, then use Resume.`,
11
11
  });
12
12
  return model;
13
13
  };
@@ -21,7 +21,10 @@ export function curateRegistry(registry, catalogue) {
21
21
  models: () => port.models(),
22
22
  complete: async function* (request) {
23
23
  if (request.model.trim() === "")
24
- throw providerError({ kind: "unsupported", message: "Choose a model ID." });
24
+ throw providerError({
25
+ kind: "unsupported",
26
+ message: "No model is chosen for this step. In the Edit tab, choose Edit project and change it under Providers, then use Resume.",
27
+ });
25
28
  let models;
26
29
  try {
27
30
  models = await port.models();
@@ -34,19 +37,19 @@ export function curateRegistry(registry, catalogue) {
34
37
  if (models !== undefined && selected === undefined)
35
38
  throw providerError({
36
39
  kind: "unsupported",
37
- message: "This model is not available in the installed CLI.",
40
+ message: "The chosen model is not available in the AI command-line tool on your computer. Update the tool, or pick another model: in the Edit tab, choose Edit project and change it under Providers, then use Resume.",
38
41
  });
39
42
  if (request.thinking !== undefined &&
40
43
  selected !== undefined &&
41
44
  !selected.thinkingModes?.includes(request.thinking))
42
45
  throw providerError({
43
46
  kind: "unsupported",
44
- message: "This model does not support the selected thinking setting.",
47
+ message: "The chosen model does not support this thinking setting. In the Edit tab, choose Edit project and change it under Providers, then use Resume.",
45
48
  });
46
49
  if (request.webSearch && !port.capabilities.webSearch)
47
50
  throw providerError({
48
51
  kind: "unsupported",
49
- message: "This CLI does not support research web search.",
52
+ message: "This command-line tool cannot search the web for research. Choose another text provider or turn Research off in the Edit tab under Edit project, then use Resume.",
50
53
  });
51
54
  const thinkingConfig = id === "codex" && request.thinking !== undefined
52
55
  ? { effort: request.thinking === "off" ? "none" : request.thinking }
@@ -75,13 +78,13 @@ export function curateRegistry(registry, catalogue) {
75
78
  if (request.webSearch && !model.llm.webSearch)
76
79
  throw providerError({
77
80
  kind: "unsupported",
78
- message: "This model does not support research web search.",
81
+ message: "The chosen model cannot search the web for research. Choose another model or turn Research off in the Edit tab under Edit project, then use Resume.",
79
82
  });
80
83
  const thinkingConfig = request.thinking === undefined ? undefined : model.llm.thinking?.[request.thinking];
81
84
  if (request.thinking !== undefined && !thinkingConfig)
82
85
  throw providerError({
83
86
  kind: "unsupported",
84
- message: "This model does not support the selected thinking setting. Choose an available setting.",
87
+ message: "The chosen model does not support this thinking setting. In the Edit tab, choose Edit project and change it under Providers, then use Resume.",
85
88
  });
86
89
  yield* port.complete({
87
90
  ...request,
@@ -100,7 +103,7 @@ export function curateRegistry(registry, catalogue) {
100
103
  if (!(await port.models()).some((model) => model.id === request.model))
101
104
  throw providerError({
102
105
  kind: "unsupported",
103
- message: "The Codex image capability is unavailable.",
106
+ message: "Image generation is not available in the Codex CLI on your computer. Update Codex CLI, or choose another image provider in the Edit tab under Edit project, then use Resume.",
104
107
  });
105
108
  return port.generate(request);
106
109
  },
@@ -114,7 +117,7 @@ export function curateRegistry(registry, catalogue) {
114
117
  if (!("image" in model) || !model.image.aspectRatios.includes(request.aspect))
115
118
  throw providerError({
116
119
  kind: "unsupported",
117
- message: "This model does not support the selected video shape.",
120
+ message: "The chosen image model cannot make images in this video's shape. In the Edit tab, choose Edit project and change it under Providers, then use Resume.",
118
121
  });
119
122
  return port.generate(request);
120
123
  },
@@ -135,7 +138,7 @@ export function curateRegistry(registry, catalogue) {
135
138
  if (request.text.length > model.tts.maxCharacters)
136
139
  throw providerError({
137
140
  kind: "unsupported",
138
- message: `${id}: this physical narration request exceeds the current ${model.tts.maxCharacters}-character limit. Rebuild narration to split it using the new limit.`,
141
+ message: `A piece of narration is longer than this voice model's ${model.tts.maxCharacters}-character limit. Use Re-run section on Audio so Slopify splits it into shorter pieces.`,
139
142
  });
140
143
  return port.synthesize({ ...request, model: model.id });
141
144
  },
@@ -40,7 +40,7 @@ export function createCatalogueStore(deps) {
40
40
  }
41
41
  catch {
42
42
  warning =
43
- "Your models.yaml is unavailable or invalid. The last valid catalogue is still in use.";
43
+ "Your models.yaml file is missing or has a mistake, so Slopify is still using the last working model list. Fix the file, or use Refresh published catalogue in Settings → Models to replace it.";
44
44
  }
45
45
  return current;
46
46
  }
@@ -31,16 +31,25 @@ export function modelFields(draft, catalogue) {
31
31
  continue;
32
32
  const model = catalogue.models(choice.provider, family).find((m) => m.id === choice.model);
33
33
  if (!model)
34
- fields.push({ field, message: "Choose an enabled model from the current catalogue." });
34
+ fields.push({
35
+ field,
36
+ message: "This model is no longer in Slopify's model list. Choose another model.",
37
+ });
35
38
  else if ("llm" in model && choice.thinking && !model.llm.thinking?.[choice.thinking])
36
- fields.push({ field, message: "Choose a supported thinking setting for this model." });
39
+ fields.push({
40
+ field,
41
+ message: "This model does not support that thinking setting. Choose another.",
42
+ });
37
43
  else if ("llm" in model && draft.sources.research === "generate" && !model.llm.webSearch)
38
44
  fields.push({
39
45
  field,
40
- message: "This model cannot perform web research. Choose another or turn Research off.",
46
+ message: "This model cannot search the web for research. Choose another model or turn Research off.",
41
47
  });
42
48
  else if ("image" in model && !model.image.aspectRatios.includes(draft.format))
43
- fields.push({ field, message: "This image model does not support the selected shape." });
49
+ fields.push({
50
+ field,
51
+ message: "This image model cannot make images in this video's shape. Choose another model.",
52
+ });
44
53
  }
45
54
  return fields;
46
55
  }