@gentbajko/slopify 0.7.0 → 0.8.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 (39) hide show
  1. package/dist/adapters/llm/codex.js +3 -0
  2. package/dist/adapters/llm/gemini-workspace.js +24 -1
  3. package/dist/adapters/llm/gemini.js +1 -1
  4. package/dist/adapters/llm/openrouter.js +1 -0
  5. package/dist/assets/models.yaml +470 -0
  6. package/dist/catalog/registry.js +147 -0
  7. package/dist/catalog/schema.js +91 -0
  8. package/dist/catalog/store.js +90 -0
  9. package/dist/catalog/validate.js +43 -0
  10. package/dist/edge/http/actions.js +1 -1
  11. package/dist/edge/http/app.js +2 -0
  12. package/dist/edge/http/planning.js +101 -0
  13. package/dist/edge/http/projects.js +4 -2
  14. package/dist/edge/http/providers.js +28 -0
  15. package/dist/kernel/db/migrations/0003-batch-queue.sql +11 -0
  16. package/dist/kernel/ports/llm.js +1 -0
  17. package/dist/kernel/ports/text.js +26 -0
  18. package/dist/kernel/runner/providers.js +8 -6
  19. package/dist/kernel/runner/queue.js +65 -0
  20. package/dist/main.js +41 -6
  21. package/dist/slices/admission/repo.js +6 -1
  22. package/dist/slices/admission/start.js +9 -8
  23. package/dist/slices/article/continuation.js +8 -1
  24. package/dist/slices/article/segments.js +1 -0
  25. package/dist/slices/batch/index.js +82 -0
  26. package/dist/slices/control/index.js +5 -1
  27. package/dist/slices/control/providers.js +14 -1
  28. package/dist/slices/estimate/index.js +81 -0
  29. package/dist/slices/narration/plan.js +58 -0
  30. package/dist/slices/narration/run.js +6 -27
  31. package/dist/slices/research/run.js +4 -0
  32. package/dist/slices/storage/staging.js +2 -1
  33. package/dist/slices/thumbnail/run.js +3 -0
  34. package/dist/web/assets/index-6zz8telY.css +1 -0
  35. package/dist/web/assets/index-CbYEcBOa.js +130 -0
  36. package/dist/web/index.html +2 -2
  37. package/package.json +2 -1
  38. package/dist/web/assets/index-6QIQ-TzO.css +0 -1
  39. package/dist/web/assets/index-CNgWL-ct.js +0 -130
package/dist/main.js CHANGED
@@ -7,6 +7,8 @@ import { buildRegistry } from "./adapter-registry.js";
7
7
  import { alignSubtitles } from "./adapters/alignment/index.js";
8
8
  import { prepareFfmpeg } from "./adapters/ffmpeg.js";
9
9
  import { nodeRunCli } from "./adapters/llm/run-cli.js";
10
+ import { curateRegistry } from "./catalog/registry.js";
11
+ import { createCatalogueStore } from "./catalog/store.js";
10
12
  import { createHub } from "./edge/events/hub.js";
11
13
  import { createApp } from "./edge/http/app.js";
12
14
  import { createAudioPreviewStore } from "./kernel/audio-preview.js";
@@ -21,11 +23,13 @@ import { sqliteAttempts } from "./kernel/runner/attempt-repo.js";
21
23
  import { dependenciesOf } from "./kernel/runner/graph.js";
22
24
  import { createRunner } from "./kernel/runner/index.js";
23
25
  import { stageProviders } from "./kernel/runner/providers.js";
26
+ import { createProviderQueue } from "./kernel/runner/queue.js";
24
27
  import { readVersion } from "./kernel/version.js";
25
28
  import { modelSources } from "./model-catalog.js";
26
29
  import { claimStage, finishStage, projectById, projectPaused, stagesOf, } from "./slices/admission/repo.js";
27
30
  import { prepareProvidedArticleSegments } from "./slices/article/provided-entries.js";
28
31
  import { runArticle } from "./slices/article/run.js";
32
+ import { pumpQueue, queueWaiting } from "./slices/batch/index.js";
29
33
  import { runImages } from "./slices/images/run.js";
30
34
  import { runNarration } from "./slices/narration/run.js";
31
35
  import { runResearch } from "./slices/research/run.js";
@@ -78,13 +82,14 @@ export async function boot(config) {
78
82
  log,
79
83
  post: httpPostEvents(collectorEndpoint(process.env), collectorTimeoutMs),
80
84
  }, flushDelayMs);
81
- const registry = buildRegistry({
85
+ const catalogue = createCatalogueStore({ dataDir: paths.dataDir, fetch: globalThis.fetch });
86
+ const registry = curateRegistry(buildRegistry({
82
87
  db,
83
88
  fetch: globalThis.fetch,
84
89
  spawn: nodeRunCli,
85
90
  clock,
86
91
  probe: nodeCliProbe,
87
- });
92
+ }), catalogue);
88
93
  const audioPreviews = createAudioPreviewStore();
89
94
  const runner = wire({
90
95
  db,
@@ -96,6 +101,7 @@ export async function boot(config) {
96
101
  telemetry,
97
102
  flusher,
98
103
  registry,
104
+ catalogue,
99
105
  ffmpeg,
100
106
  audioPreviews,
101
107
  });
@@ -170,6 +176,7 @@ export async function boot(config) {
170
176
  updater,
171
177
  audioPreviews,
172
178
  ...modelSources(registry),
179
+ catalogue,
173
180
  clock,
174
181
  ids,
175
182
  log,
@@ -179,6 +186,20 @@ export async function boot(config) {
179
186
  probe: nodeCliProbe,
180
187
  });
181
188
  const server = await listen(app, config, log);
189
+ const queueTimer = setInterval(() => {
190
+ const release = updater.beginMutation();
191
+ if (!release)
192
+ return;
193
+ try {
194
+ pumpQueue(updateDb, runner);
195
+ }
196
+ catch {
197
+ log.write("error", "batch.queue", { detail: "The batch queue could not advance." });
198
+ }
199
+ finally {
200
+ release();
201
+ }
202
+ }, 1000);
182
203
  listeningPort = portOf(server) ?? config.port;
183
204
  // Whatever last run left queued goes out at start. Nothing waits for
184
205
  // it, and an unreachable collector costs one refused socket.
@@ -188,6 +209,7 @@ export async function boot(config) {
188
209
  let stopActivation = () => { };
189
210
  shutdown = () => {
190
211
  stopping ??= (async () => {
212
+ clearInterval(queueTimer);
191
213
  stopActivation();
192
214
  audioPreviews.close();
193
215
  try {
@@ -221,7 +243,7 @@ export async function boot(config) {
221
243
  throw error;
222
244
  }
223
245
  }
224
- function wire({ audioPreviews, db, paths, clock, ids, log, hub, telemetry, flusher, registry, ffmpeg, }) {
246
+ function wire({ audioPreviews, db, paths, clock, ids, log, hub, telemetry, flusher, registry, catalogue, ffmpeg, }) {
225
247
  // A stage counts what it did and the queue is flushed after each new event. `record`
226
248
  // swallows its own failures, so this can neither fail a stage nor widen what leaves the
227
249
  // machine - the payload allow-list is checked inside it.
@@ -237,11 +259,17 @@ function wire({ audioPreviews, db, paths, clock, ids, log, hub, telemetry, flush
237
259
  const writing = { db, paths, ids, clock, log, count };
238
260
  // A stage slice is handed the wrapped calls, never the registry: every provider call
239
261
  // it makes is already inside the retry policy (kernel/runner/providers.ts).
240
- const providers = { registry, attempts: sqliteAttempts(db, ids), clock, log };
262
+ const providers = {
263
+ registry,
264
+ attempts: sqliteAttempts(db, ids),
265
+ clock,
266
+ log,
267
+ queue: createProviderQueue((provider) => catalogue.read().providers[provider]?.maxConcurrent ?? 1),
268
+ };
241
269
  return createRunner({
242
270
  stages: {
243
271
  stagesOf: (projectId) => stagesOf(db, projectId),
244
- paused: (projectId) => projectPaused(db, projectId),
272
+ paused: (projectId) => projectPaused(db, projectId) || queueWaiting(db, projectId),
245
273
  dependenciesOf: (projectId, kind) => {
246
274
  const project = projectById(db, projectId);
247
275
  return project === undefined ? [] : dependenciesOf(kind, project.config.sources);
@@ -257,7 +285,14 @@ function wire({ audioPreviews, db, paths, clock, ids, log, hub, telemetry, flush
257
285
  audio: async (context) => {
258
286
  const wrapped = stageProviders(providers, context);
259
287
  await prepareProvidedArticleSegments(writing, context, wrapped);
260
- await runNarration({ ...video, audioPreviews }, context, wrapped);
288
+ await runNarration({
289
+ ...video,
290
+ audioPreviews,
291
+ maxCharacters: (provider, model) => {
292
+ const entry = catalogue.models(provider, "tts").find((m) => m.id === model);
293
+ return entry && "tts" in entry ? entry.tts.maxCharacters : 4000;
294
+ },
295
+ }, context, wrapped);
261
296
  },
262
297
  images: (context) => runImages(writing, context, stageProviders(providers, context)),
263
298
  thumbnail: (context) => runThumbnail(writing, context, stageProviders(providers, context)),
@@ -1,9 +1,14 @@
1
1
  import { z } from "zod";
2
2
  import { stageKinds, stageStates } from "../../kernel/pipeline.js";
3
+ import { thinkingModes } from "../../kernel/ports/llm.js";
3
4
  import { chunkModes } from "../narration/chunk.js";
4
5
  import { subtitleConfigSchema } from "../subtitles/model.js";
5
6
  import { entryModes, formats, stageSources } from "./model.js";
6
- const providerChoice = z.object({ provider: z.string(), model: z.string() });
7
+ const providerChoice = z.object({
8
+ provider: z.string(),
9
+ model: z.string(),
10
+ thinking: z.enum(thinkingModes).optional(),
11
+ });
7
12
  const entryChoice = z.object({ name: z.string(), mode: z.enum(entryModes) });
8
13
  // The shape Play posts and the shape `projects.config` holds, in one place: the second
9
14
  // is the first plus the rendered prompt texts.
@@ -13,7 +13,7 @@ export function initialState(source) {
13
13
  }
14
14
  // The project row, its six stages and the provided content all land together or not at
15
15
  // all. The caller ticks the runner after this returns, never inside it.
16
- export function startRun(deps, draft, rendered) {
16
+ export function startRun(deps, draft, rendered, retainStaged = false) {
17
17
  const id = deps.ids.next();
18
18
  const at = deps.clock.now().toISOString();
19
19
  const config = { ...draft, rendered };
@@ -49,14 +49,14 @@ export function startRun(deps, draft, rendered) {
49
49
  for (const stage of stages) {
50
50
  insertStage(deps.db, stage);
51
51
  }
52
- attachProvided(deps, id, draft, moved);
52
+ attachProvided(deps, id, draft, moved, retainStaged);
53
53
  });
54
- for (const source of moved) {
54
+ for (const source of retainStaged ? [] : moved) {
55
55
  dropStagedSource(deps, source);
56
56
  }
57
57
  return { project, stages };
58
58
  }
59
- function attachProvided(deps, projectId, draft, collected) {
59
+ function attachProvided(deps, projectId, draft, collected, retainStaged) {
60
60
  const { sources, provided } = draft;
61
61
  if (sources.research === "provide" && provided.research !== undefined) {
62
62
  storeText(deps, {
@@ -73,24 +73,25 @@ function attachProvided(deps, projectId, draft, collected) {
73
73
  storeArticleText(deps, { projectId, markdown: provided.article.trim() });
74
74
  }
75
75
  if (sources.audio === "provide") {
76
- attach(deps, projectId, "audio", provided.audio, "audio_body", collected);
76
+ attach(deps, projectId, "audio", provided.audio, "audio_body", collected, retainStaged);
77
77
  }
78
78
  if (sources.thumbnail === "provide") {
79
- attach(deps, projectId, "thumbnail", provided.thumbnail, "thumbnail", collected);
79
+ attach(deps, projectId, "thumbnail", provided.thumbnail, "thumbnail", collected, retainStaged);
80
80
  }
81
81
  if (sources.images === "provide") {
82
82
  // Slideshow order is the order the user left the list in.
83
83
  for (const [index, stagedFileId] of (provided.images ?? []).entries()) {
84
- attach(deps, projectId, "images", stagedFileId, "image", collected, index + 1);
84
+ attach(deps, projectId, "images", stagedFileId, "image", collected, retainStaged, index + 1);
85
85
  }
86
86
  }
87
87
  }
88
- function attach(deps, projectId, kind, stagedFileId, role, collected, index) {
88
+ function attach(deps, projectId, kind, stagedFileId, role, collected, retainStaged, index) {
89
89
  if (stagedFileId === undefined) {
90
90
  throw new Error(`the ${kind} stage is provided but carries no staged file`);
91
91
  }
92
92
  const result = attachStagedFile(deps, {
93
93
  stagedFileId,
94
+ retainStaged,
94
95
  projectId,
95
96
  role,
96
97
  ...(index === undefined ? {} : { index }),
@@ -43,7 +43,13 @@ export async function writeArticle(providers, choice, brief, onDelta) {
43
43
  onDelta(event.text);
44
44
  }
45
45
  };
46
- let answer = await providers.llm({ provider: choice.provider, model: choice.model, messages: base, check: written }, stream);
46
+ let answer = await providers.llm({
47
+ provider: choice.provider,
48
+ model: choice.model,
49
+ ...(choice.thinking === undefined ? {} : { thinking: choice.thinking }),
50
+ messages: base,
51
+ check: written,
52
+ }, stream);
47
53
  const pieces = [answer.text];
48
54
  let tokens = plusUsage(noTokens, answer.usage);
49
55
  for (let n = 1; truncated(answer) && n <= continuationLimit; n += 1) {
@@ -52,6 +58,7 @@ export async function writeArticle(providers, choice, brief, onDelta) {
52
58
  answer = await providers.llm({
53
59
  provider: choice.provider,
54
60
  model: choice.model,
61
+ ...(choice.thinking === undefined ? {} : { thinking: choice.thinking }),
55
62
  messages,
56
63
  // The last continuation allowed is the one that has to end the article: a fourth
57
64
  // truncation is a failed attempt, which is the wrapper's to retry. The
@@ -18,6 +18,7 @@ export async function writeSegment(providers, choice, config, category, article,
18
18
  const answer = await providers.llm({
19
19
  provider: choice.provider,
20
20
  model: choice.model,
21
+ ...(choice.thinking === undefined ? {} : { thinking: choice.thinking }),
21
22
  messages,
22
23
  check: (given) => given.text.trim() === "" ? `the ${category} answered with nothing` : undefined,
23
24
  });
@@ -0,0 +1,82 @@
1
+ import { z } from "zod";
2
+ import { transact } from "../../kernel/db/tx.js";
3
+ import { derive } from "../../kernel/runner/graph.js";
4
+ import { projectPaused, stagesOf } from "../admission/repo.js";
5
+ import { startRun } from "../admission/start.js";
6
+ import { stagingPath } from "../storage/layout.js";
7
+ import { deleteStagedFile, stagedFileById } from "../storage/repo.js";
8
+ import { dropStagedSource } from "../storage/staging.js";
9
+ const queueRow = z.object({
10
+ projectId: z.string(),
11
+ batchId: z.string(),
12
+ position: z.number(),
13
+ state: z.enum(["queued", "active", "finished"]),
14
+ });
15
+ export function queueEntries(db, batchId) {
16
+ return db
17
+ .prepare(`SELECT project_id AS projectId, batch_id AS batchId, position, state
18
+ FROM project_queue ${batchId === undefined ? "WHERE state != 'finished'" : "WHERE batch_id = ?"}
19
+ ORDER BY position`)
20
+ .all(...(batchId === undefined ? [] : [batchId]))
21
+ .map((row) => queueRow.parse(row));
22
+ }
23
+ export function queueWaiting(db, projectId) {
24
+ return (db
25
+ .prepare("SELECT 1 FROM project_queue WHERE project_id = ? AND state = 'queued'")
26
+ .get(projectId) !== undefined);
27
+ }
28
+ export function batchExists(db, id) {
29
+ return db.prepare("SELECT 1 FROM batches WHERE id = ?").get(id) !== undefined;
30
+ }
31
+ export function enqueueBatch(deps, batchId, runs) {
32
+ if (batchExists(deps.db, batchId))
33
+ return queueEntries(deps.db, batchId);
34
+ const sources = new Set();
35
+ transact(deps.db, () => {
36
+ deps.db
37
+ .prepare("INSERT INTO batches(id, created_at) VALUES (?, ?)")
38
+ .run(batchId, deps.clock.now().toISOString());
39
+ const used = new Set();
40
+ for (const { draft, rendered } of runs) {
41
+ const { project } = startRun(deps, draft, rendered, true);
42
+ deps.db
43
+ .prepare("INSERT INTO project_queue(project_id, batch_id) VALUES (?, ?)")
44
+ .run(project.id, batchId);
45
+ if (draft.sources.audio === "provide" && draft.provided.audio)
46
+ used.add(draft.provided.audio);
47
+ if (draft.sources.thumbnail === "provide" && draft.provided.thumbnail)
48
+ used.add(draft.provided.thumbnail);
49
+ if (draft.sources.images === "provide")
50
+ for (const id of draft.provided.images ?? [])
51
+ used.add(id);
52
+ }
53
+ for (const id of used) {
54
+ const file = stagedFileById(deps.db, id);
55
+ if (file)
56
+ sources.add(stagingPath(deps.paths, file.path));
57
+ deleteStagedFile(deps.db, id);
58
+ }
59
+ });
60
+ for (const source of sources)
61
+ dropStagedSource(deps, source);
62
+ return queueEntries(deps.db, batchId);
63
+ }
64
+ // Only one batch video runs at once. A paused item holds its place; a failed or
65
+ // canceled item releases the next video once its provider calls have drained.
66
+ export function pumpQueue(db, runner) {
67
+ for (const entry of queueEntries(db)) {
68
+ const stages = stagesOf(db, entry.projectId);
69
+ const status = derive(stages, projectPaused(db, entry.projectId));
70
+ if (runner.hasInflight?.(entry.projectId))
71
+ return;
72
+ if (["done", "failed", "canceled"].includes(status)) {
73
+ db.prepare("UPDATE project_queue SET state = 'finished' WHERE project_id = ?").run(entry.projectId);
74
+ continue;
75
+ }
76
+ if (status === "paused")
77
+ return;
78
+ db.prepare("UPDATE project_queue SET state = 'active' WHERE project_id = ?").run(entry.projectId);
79
+ runner.tick(entry.projectId);
80
+ return;
81
+ }
82
+ }
@@ -137,13 +137,17 @@ export function changeProviders(deps, id, changes) {
137
137
  (changes.audio.provider !== project.config.audio?.provider ||
138
138
  changes.audio.model !== project.config.audio?.model ||
139
139
  changes.audio.voice !== project.config.audio?.voice);
140
+ const chunkingChanged = changes.chunking !== undefined &&
141
+ (changes.chunking.mode !== (project.config.chunking?.mode ?? "whole") ||
142
+ (changes.chunking.words ?? 500) !== (project.config.chunking?.words ?? 500));
140
143
  const orphaned = transact(deps.db, () => {
141
- const files = audioChanged ? clearUnfinishedAudio(deps, id) : [];
144
+ const files = audioChanged || chunkingChanged ? clearUnfinishedAudio(deps, id) : [];
142
145
  updateProjectConfig(deps.db, id, {
143
146
  ...project.config,
144
147
  ...(changes.llm === undefined ? {} : { llm: changes.llm }),
145
148
  ...(changes.audio === undefined ? {} : { audio: changes.audio }),
146
149
  ...(changes.images === undefined ? {} : { images: changes.images }),
150
+ ...(changes.chunking === undefined ? {} : { chunking: changes.chunking }),
147
151
  }, deps.clock.now().toISOString());
148
152
  return files;
149
153
  });
@@ -1,8 +1,11 @@
1
1
  import { z } from "zod";
2
+ import { thinkingModes } from "../../kernel/ports/llm.js";
3
+ import { chunkModes } from "../narration/chunk.js";
2
4
  const choice = z
3
5
  .object({
4
6
  provider: z.string().trim().min(1).max(200),
5
7
  model: z.string().trim().min(1).max(200),
8
+ thinking: z.enum(thinkingModes).optional(),
6
9
  })
7
10
  .strict();
8
11
  export const providerChangesSchema = z
@@ -10,6 +13,10 @@ export const providerChangesSchema = z
10
13
  llm: choice.optional(),
11
14
  audio: choice.extend({ voice: z.string().trim().min(1).max(200) }).optional(),
12
15
  images: choice.optional(),
16
+ chunking: z
17
+ .object({ mode: z.enum(chunkModes), words: z.number().int().min(1).max(10000).optional() })
18
+ .strict()
19
+ .optional(),
13
20
  })
14
21
  .strict()
15
22
  .refine((value) => Object.keys(value).length > 0, "Pick at least one provider to update.");
@@ -53,9 +60,15 @@ export async function validateProviderChanges(changes, current) {
53
60
  if (current.allowsCustomModels?.(picked.provider) === true)
54
61
  continue;
55
62
  const models = await current.modelsFor(picked.provider, families[key]);
56
- if (!models.some((model) => model.id === picked.model)) {
63
+ const model = models.find((model) => model.id === picked.model);
64
+ if (!model) {
57
65
  fields.push({ field: `${key}.model`, message: "Choose a model supported by this provider." });
58
66
  }
67
+ else if (key === "llm" &&
68
+ picked.thinking &&
69
+ !model.thinkingModes?.includes(picked.thinking)) {
70
+ fields.push({ field: `${key}.thinking`, message: "Choose a supported thinking setting." });
71
+ }
59
72
  }
60
73
  return fields;
61
74
  }
@@ -0,0 +1,81 @@
1
+ export function estimateRun(draft, rendered, expectedWords, catalogue) {
2
+ const rows = [];
3
+ const model = (family, choice) => choice
4
+ ? catalogue?.models(choice.provider, family).find((m) => m.id === choice.model)
5
+ : undefined;
6
+ const llm = model("llm", draft.llm);
7
+ const tts = model("tts", draft.audio);
8
+ const image = model("image", draft.images);
9
+ const generatedArticle = draft.sources.article === "generate";
10
+ const articleChars = generatedArticle ? expectedWords * 6 : (draft.provided.article?.length ?? 0);
11
+ const promptChars = Object.values(rendered).reduce((sum, value) => sum + value.length, 0);
12
+ const row = (stage, amount, detail, variable = false) => {
13
+ rows.push({
14
+ stage,
15
+ low: amount === undefined ? null : amount * (variable ? 0.5 : 1),
16
+ high: amount === undefined ? null : amount * (variable ? 1.5 : 1),
17
+ detail,
18
+ });
19
+ };
20
+ const textCost = (input, output) => {
21
+ const p = llm?.pricing;
22
+ return p?.inputPerMillionTokens === undefined || p.outputPerMillionTokens === undefined
23
+ ? undefined
24
+ : ((input / 4) * p.inputPerMillionTokens + (output / 4) * p.outputPerMillionTokens) / 1000000;
25
+ };
26
+ const textNote = llm?.pricing.note ??
27
+ "CLI subscription or model pricing is unavailable; usage may be billed by your account.";
28
+ if (draft.sources.research === "generate")
29
+ row("Research", textCost(promptChars + 6000, 12000), `Assumes about 2,000 words of notes; web search fees are excluded. ${textNote}`, true);
30
+ else
31
+ row("Research", 0, "Provided or off; no generation charge.");
32
+ if (generatedArticle)
33
+ row("Article", textCost(promptChars + (draft.sources.research === "generate" ? 12000 : 0), articleChars), `${expectedWords.toLocaleString()} expected words. ${textNote}`, true);
34
+ else
35
+ row("Article", 0, "Provided article; no generation charge.");
36
+ if (draft.intro?.mode === "llm" || draft.outro?.mode === "llm")
37
+ row("Intro / outro text", textCost(promptChars + articleChars, 2400), textNote, true);
38
+ if (draft.sources.audio === "generate") {
39
+ const extras = ["intro", "outro"].reduce((n, key) => n + (rendered[key]?.length ?? 0), 0);
40
+ const llmExtras = (draft.intro?.mode === "llm" ? 1200 : 0) + (draft.outro?.mode === "llm" ? 1200 : 0);
41
+ const chars = articleChars + extras + llmExtras;
42
+ const p = tts?.pricing;
43
+ const amount = p?.perMillionCharacters === undefined
44
+ ? p?.perMinute === undefined
45
+ ? undefined
46
+ : (chars / 6 / 150) * p.perMinute
47
+ : (chars / 1000000) * p.perMillionCharacters;
48
+ row("Narration", amount, `About ${chars.toLocaleString()} characters. ${p?.note ?? "Account pricing is unknown."}`, generatedArticle || llmExtras > 0 || p?.perMinute !== undefined);
49
+ }
50
+ else
51
+ row("Narration", 0, "Provided or off; no generation charge.");
52
+ const images = draft.sources.images === "generate" ? draft.imagePrompts.reduce((n, p) => n + p.number, 0) : 0;
53
+ row("Images", images === 0
54
+ ? 0
55
+ : image?.pricing.perImage === undefined
56
+ ? undefined
57
+ : images * image.pricing.perImage, images === 0
58
+ ? "Provided or off."
59
+ : `${images} images. ${image?.pricing.note ?? "Model or account pricing is unknown."}`);
60
+ const thumbnail = ["from_prompt", "prompt_by_llm"].includes(draft.sources.thumbnail);
61
+ row("Thumbnail", thumbnail ? image?.pricing.perImage : 0, thumbnail
62
+ ? (image?.pricing.note ?? "Model or account pricing is unknown.")
63
+ : "Provided or off.");
64
+ if (draft.sources.thumbnail === "prompt_by_llm")
65
+ row("Thumbnail prompt", textCost(promptChars + articleChars, 1200), textNote, true);
66
+ row("Export / subtitles", 0, "Local processing; no API fee.");
67
+ return {
68
+ currency: "USD",
69
+ rows,
70
+ expectedWords,
71
+ catalogueDate: catalogue?.status().updatedAt ?? null,
72
+ low: rows.reduce((n, r) => n + (r.low ?? 0), 0),
73
+ high: rows.reduce((n, r) => n + (r.high ?? 0), 0),
74
+ unknown: rows.filter((r) => r.low === null).length,
75
+ assumptions: [
76
+ "Planning estimate, not a spending limit. Generated lengths use a ±50% range; actual output can exceed it.",
77
+ "Assumes roughly 6 characters per word, 4 characters per token, and 150 spoken words per minute.",
78
+ "Retries, extra reasoning tokens, web tools, taxes, discounts and included credits are excluded. Unknown charges are not counted as zero.",
79
+ ],
80
+ };
81
+ }
@@ -0,0 +1,58 @@
1
+ import { z } from "zod";
2
+ import { transact } from "../../kernel/db/tx.js";
3
+ import { splitText } from "../../kernel/ports/text.js";
4
+ import { insertPiece, piecesOf } from "../../kernel/runner/piece-repo.js";
5
+ const payload = z.object({ text: z.string(), file: z.string().optional() });
6
+ // Old whole-text plans can exceed a newly known account limit. Preserve finished
7
+ // pieces and their paths, and split only unfinished work before any request starts.
8
+ export function planNarration(deps, stageId, texts, maxCharacters, finished) {
9
+ const old = piecesOf(deps.db, stageId, "chunk");
10
+ const source = old.length
11
+ ? old
12
+ : texts.map((text, idx) => ({
13
+ id: deps.ids.next(),
14
+ stageId,
15
+ kind: "chunk",
16
+ idx: idx + 1,
17
+ state: "pending",
18
+ payload: JSON.stringify({ text }),
19
+ }));
20
+ const planned = [];
21
+ for (const piece of source) {
22
+ const data = payload.parse(JSON.parse(piece.payload ?? "null"));
23
+ if (finished(piece)) {
24
+ planned.push({ ...piece, idx: planned.length + 1 });
25
+ continue;
26
+ }
27
+ for (const [part, text] of splitText(data.text, maxCharacters).entries())
28
+ planned.push({
29
+ ...piece,
30
+ id: part === 0 ? piece.id : deps.ids.next(),
31
+ idx: planned.length + 1,
32
+ state: "pending",
33
+ payload: JSON.stringify({ text }),
34
+ });
35
+ }
36
+ if (old.length &&
37
+ planned.length === old.length &&
38
+ planned.every((p, i) => p.id === old[i]?.id &&
39
+ payload.parse(JSON.parse(p.payload ?? "null")).text ===
40
+ payload.parse(JSON.parse(old[i]?.payload ?? "null")).text))
41
+ return old;
42
+ transact(deps.db, () => {
43
+ // Negative temporary indices avoid colliding with a retained completed piece.
44
+ deps.db
45
+ .prepare("UPDATE stage_pieces SET idx=-idx WHERE stage_id=? AND kind='chunk'")
46
+ .run(stageId);
47
+ const known = new Set(old.map((p) => p.id));
48
+ for (const piece of planned) {
49
+ if (known.has(piece.id))
50
+ deps.db
51
+ .prepare("UPDATE stage_pieces SET idx=?,state=?,payload=? WHERE id=?")
52
+ .run(piece.idx, piece.state, piece.payload, piece.id);
53
+ else
54
+ insertPiece(deps.db, piece);
55
+ }
56
+ });
57
+ return planned;
58
+ }
@@ -1,8 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
2
  import { dirname } from "node:path";
3
3
  import { z } from "zod";
4
- import { transact } from "../../kernel/db/tx.js";
5
- import { insertPiece, piecesOf, setPiece } from "../../kernel/runner/piece-repo.js";
4
+ import { piecesOf, setPiece } from "../../kernel/runner/piece-repo.js";
6
5
  import { entryModes } from "../admission/model.js";
7
6
  import { projectById, setStageProgress, stagesOf } from "../admission/repo.js";
8
7
  import { entryCategories } from "../library/model.js";
@@ -12,6 +11,7 @@ import { probeDurationMs } from "../video/ffmpeg.js";
12
11
  import { chunkNarration, defaultChunking } from "./chunk.js";
13
12
  import { joinNarration } from "./concat.js";
14
13
  import { observeNarration } from "./live.js";
14
+ import { planNarration } from "./plan.js";
15
15
  // An empty narration source fails the stage immediately with no retries. Thrown from the
16
16
  // slice rather than from a provider call, so the attempt wrapper never sees it.
17
17
  export const nothingToNarrate = "nothing to narrate";
@@ -53,7 +53,8 @@ export async function runNarration(deps, context, providers) {
53
53
  if (texts.length === 0) {
54
54
  throw new Error(nothingToNarrate);
55
55
  }
56
- const files = await speakChunks(deps, context, providers, choice, plan(deps, context, texts));
56
+ const planned = planNarration(deps, context.stage.id, texts, deps.maxCharacters?.(choice.provider, choice.model) ?? 1000000, (piece) => finished(deps, projectId, piece) !== undefined);
57
+ const files = await speakChunks(deps, context, providers, choice, planned);
57
58
  await storeBody(deps, context, choice, files, outputs);
58
59
  await speakSegments(deps, context, providers, choice, outputs);
59
60
  deps.log.write("info", "narration.done", {
@@ -71,29 +72,7 @@ function narrationSource(deps, outputs, projectId) {
71
72
  }
72
73
  return readFileSync(outputPath(deps.paths, projectId, article.path), "utf8");
73
74
  }
74
- // The chunk list, planned once and kept. A retry after a failure finds the rows the first
75
- // run wrote and narrates only the ones that did not finish.
76
- function plan(deps, context, texts) {
77
- const existing = piecesOf(deps.db, context.stage.id, "chunk");
78
- if (existing.length > 0) {
79
- return existing;
80
- }
81
- const planned = texts.map((text, index) => ({
82
- id: deps.ids.next(),
83
- stageId: context.stage.id,
84
- kind: "chunk",
85
- idx: index + 1,
86
- state: "pending",
87
- payload: JSON.stringify({ text }),
88
- }));
89
- transact(deps.db, () => {
90
- for (const piece of planned) {
91
- insertPiece(deps.db, piece);
92
- }
93
- });
94
- return planned;
95
- }
96
- // Every chunk is synthesized in parallel. A chunk a previous run finished is not spoken
75
+ // Chunks are scheduled through the shared provider queue. A chunk a previous run finished is not spoken
97
76
  // again, and one that fails takes down the whole stage while its siblings finish and keep their
98
77
  // audio for the next resume.
99
78
  async function speakChunks(deps, context, providers, choice, pieces) {
@@ -107,7 +86,7 @@ async function speakChunks(deps, context, providers, choice, pieces) {
107
86
  if (kept !== undefined) {
108
87
  return { ok: true, file: kept };
109
88
  }
110
- const file = `${chunkDir}/${String(piece.idx).padStart(3, "0")}.mp3`;
89
+ const file = `${chunkDir}/${piece.id}.mp3`;
111
90
  setPiece(deps.db, piece.id, "running", piece.payload);
112
91
  try {
113
92
  const spoken = await providers.forPiece(piece.id).tts({
@@ -52,6 +52,7 @@ async function research(deps, context, providers, choice, brief) {
52
52
  const answer = await providers.llm({
53
53
  provider: choice.provider,
54
54
  model: choice.model,
55
+ ...(choice.thinking === undefined ? {} : { thinking: choice.thinking }),
55
56
  messages: synthesisMessages(brief, findings),
56
57
  previewLabel: "Writing research notes",
57
58
  check: (given) => sourcedAnswer("the synthesis", given.text),
@@ -71,6 +72,7 @@ async function research(deps, context, providers, choice, brief) {
71
72
  stage: "research",
72
73
  provider: choice.provider,
73
74
  model: choice.model,
75
+ ...(choice.thinking === undefined ? {} : { thinking: choice.thinking }),
74
76
  ...tokens,
75
77
  });
76
78
  deps.log.write("info", "research.done", {
@@ -89,6 +91,7 @@ async function plan(deps, context, providers, choice, brief, add) {
89
91
  const answer = await providers.llm({
90
92
  provider: choice.provider,
91
93
  model: choice.model,
94
+ ...(choice.thinking === undefined ? {} : { thinking: choice.thinking }),
92
95
  messages: plannerMessages(brief),
93
96
  previewLabel: "Planning research",
94
97
  // An empty answer, or one with no chapter in it, is a failed attempt.
@@ -130,6 +133,7 @@ async function researchChapters(deps, context, providers, choice, brief, chapter
130
133
  const answer = await providers.forPiece(piece.id).llm({
131
134
  provider: choice.provider,
132
135
  model: choice.model,
136
+ ...(choice.thinking === undefined ? {} : { thinking: choice.thinking }),
133
137
  messages: subAgentMessages(brief, kept.title, outline),
134
138
  previewLabel: kept.title,
135
139
  // Grounding is asked for explicitly, so a model without it says so
@@ -140,7 +140,8 @@ export function attachStagedFile(deps, input) {
140
140
  try {
141
141
  transact(deps.db, () => {
142
142
  insertOutput(deps.db, output);
143
- deleteStagedFile(deps.db, staged.id);
143
+ if (!input.retainStaged)
144
+ deleteStagedFile(deps.db, staged.id);
144
145
  });
145
146
  }
146
147
  catch (error) {
@@ -81,6 +81,7 @@ async function byLlm(deps, context, providers, project) {
81
81
  const answer = await providers.llm({
82
82
  provider: llm.provider,
83
83
  model: llm.model,
84
+ ...(llm.thinking === undefined ? {} : { thinking: llm.thinking }),
84
85
  messages,
85
86
  // An empty answer is a failed attempt, and the wrapper is what retries it.
86
87
  check: (given) => writtenPrompt(given.text),
@@ -151,6 +152,7 @@ async function make(deps, context, providers, project, choice, prompt) {
151
152
  const made = await providers.image({
152
153
  provider: choice.provider,
153
154
  model: choice.model,
155
+ ...(choice.thinking === undefined ? {} : { thinking: choice.thinking }),
154
156
  prompt,
155
157
  aspect: project.format,
156
158
  });
@@ -175,6 +177,7 @@ async function make(deps, context, providers, project, choice, prompt) {
175
177
  prompt,
176
178
  provider: choice.provider,
177
179
  model: choice.model,
180
+ ...(choice.thinking === undefined ? {} : { thinking: choice.thinking }),
178
181
  },
179
182
  createdAt: deps.clock.now().toISOString(),
180
183
  };