@gentbajko/slopify 0.1.0 → 0.3.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 (94) hide show
  1. package/README.md +2 -2
  2. package/dist/adapter-registry.js +11 -12
  3. package/dist/adapters/image/bytes.js +1 -1
  4. package/dist/adapters/image/fal.js +55 -44
  5. package/dist/adapters/image/openai.js +19 -24
  6. package/dist/adapters/image/replicate.js +33 -43
  7. package/dist/adapters/llm/claude-code.js +16 -19
  8. package/dist/adapters/llm/codex.js +14 -15
  9. package/dist/adapters/llm/openrouter.js +13 -16
  10. package/dist/adapters/llm/run-cli.js +9 -11
  11. package/dist/adapters/llm/sse-lines.js +1 -2
  12. package/dist/adapters/retry-after.js +6 -6
  13. package/dist/adapters/tts/cartesia.js +5 -5
  14. package/dist/adapters/tts/elevenlabs.js +14 -19
  15. package/dist/adapters/tts/openai.js +9 -10
  16. package/dist/edge/cli.js +0 -0
  17. package/dist/edge/http/actions.js +8 -9
  18. package/dist/edge/http/app.js +3 -3
  19. package/dist/edge/http/entries.js +1 -1
  20. package/dist/edge/http/projects.js +7 -7
  21. package/dist/edge/http/prompts.js +6 -6
  22. package/dist/edge/http/providers.js +4 -4
  23. package/dist/edge/http/settings.js +2 -2
  24. package/dist/edge/http/staging.js +1 -1
  25. package/dist/edge/http/telemetry.js +6 -7
  26. package/dist/edge/http/usage.js +5 -5
  27. package/dist/kernel/config/index.js +1 -1
  28. package/dist/kernel/db/tx.js +9 -12
  29. package/dist/kernel/log.js +5 -5
  30. package/dist/kernel/pipeline.js +7 -9
  31. package/dist/kernel/ports/model.js +3 -4
  32. package/dist/kernel/runner/attempt-repo.js +5 -6
  33. package/dist/kernel/runner/attempt.js +16 -23
  34. package/dist/kernel/runner/graph.js +18 -29
  35. package/dist/kernel/runner/index.js +27 -33
  36. package/dist/kernel/runner/piece-repo.js +9 -11
  37. package/dist/kernel/runner/providers.js +5 -7
  38. package/dist/kernel/version.js +3 -3
  39. package/dist/main.js +10 -11
  40. package/dist/slices/admission/model.js +2 -2
  41. package/dist/slices/admission/repo.js +6 -7
  42. package/dist/slices/admission/rules.js +12 -15
  43. package/dist/slices/admission/start.js +7 -8
  44. package/dist/slices/admission/substitute.js +11 -11
  45. package/dist/slices/article/continuation.js +9 -9
  46. package/dist/slices/article/plain.js +5 -5
  47. package/dist/slices/article/run.js +25 -25
  48. package/dist/slices/article/split.js +1 -1
  49. package/dist/slices/article/store.js +5 -5
  50. package/dist/slices/cancel/index.js +8 -8
  51. package/dist/slices/images/run.js +32 -34
  52. package/dist/slices/library/lint.js +3 -3
  53. package/dist/slices/library/model.js +7 -7
  54. package/dist/slices/library/repo.js +3 -3
  55. package/dist/slices/library/save.js +7 -7
  56. package/dist/slices/library/slots.js +5 -5
  57. package/dist/slices/narration/chunk.js +12 -12
  58. package/dist/slices/narration/concat.js +12 -13
  59. package/dist/slices/narration/run.js +46 -49
  60. package/dist/slices/reruns/cascade.js +10 -11
  61. package/dist/slices/reruns/index.js +36 -37
  62. package/dist/slices/research/planner.js +4 -5
  63. package/dist/slices/research/run.js +22 -23
  64. package/dist/slices/research/synthesis.js +5 -5
  65. package/dist/slices/settings/cli-status.js +9 -10
  66. package/dist/slices/settings/keys.js +8 -9
  67. package/dist/slices/settings/model.js +5 -5
  68. package/dist/slices/settings/readiness.js +3 -4
  69. package/dist/slices/settings/repo.js +1 -1
  70. package/dist/slices/settings/voices.js +3 -4
  71. package/dist/slices/storage/asset-name.js +2 -2
  72. package/dist/slices/storage/delete-project.js +3 -5
  73. package/dist/slices/storage/downloads.js +4 -4
  74. package/dist/slices/storage/layout.js +5 -6
  75. package/dist/slices/storage/model.js +1 -1
  76. package/dist/slices/storage/reconcile.js +6 -7
  77. package/dist/slices/storage/repo.js +1 -1
  78. package/dist/slices/storage/staging.js +12 -15
  79. package/dist/slices/telemetry/collector-client.js +2 -2
  80. package/dist/slices/telemetry/flush.js +8 -8
  81. package/dist/slices/telemetry/machine.js +6 -7
  82. package/dist/slices/telemetry/model.js +11 -13
  83. package/dist/slices/telemetry/record.js +7 -7
  84. package/dist/slices/telemetry/repo.js +4 -4
  85. package/dist/slices/telemetry/usage.js +5 -5
  86. package/dist/slices/thumbnail/by-llm.js +2 -2
  87. package/dist/slices/thumbnail/run.js +30 -32
  88. package/dist/slices/video/ffmpeg.js +6 -6
  89. package/dist/slices/video/plan.js +9 -9
  90. package/dist/slices/video/run.js +19 -20
  91. package/dist/web/assets/index-DTqh2zoJ.js +81 -0
  92. package/dist/web/index.html +1 -1
  93. package/package.json +1 -1
  94. package/dist/web/assets/index-D_sWbKQi.js +0 -81
@@ -13,8 +13,8 @@ function staleStages(input) {
13
13
  case "rerun":
14
14
  return withDependents([action.stage]);
15
15
  case "article-edit": {
16
- // §Q101, in its own words: "audio, LLM-mode intro/outro text, LLM-written
17
- // thumbnail, and video re-run; prompt-based images are untouched". The video comes
16
+ // An article edit re-runs audio, LLM-mode intro/outro text, the LLM-written
17
+ // thumbnail and the video, leaving prompt-based images untouched. The video comes
18
18
  // along as a dependent of the audio, so it is never named here.
19
19
  const roots = ["audio"];
20
20
  if (input.thumbnailSource === "prompt_by_llm") {
@@ -23,26 +23,25 @@ function staleStages(input) {
23
23
  return withDependents(roots);
24
24
  }
25
25
  case "image-deleted":
26
- // Step 5: the image is "removed from the set; video re-renders". The remaining
26
+ // The image is removed from the set and the video re-renders. The remaining
27
27
  // images stand, so the images stage itself is not redone.
28
28
  return withDependents(["video"]);
29
29
  case "image-regenerated":
30
30
  return withDependents(["images"]);
31
31
  }
32
32
  }
33
- // `logic/01` forbids `provided` → `running` and `skipped` → `running`, so a stage whose
34
- // output the user supplied or switched off is stepped over. The walk above still passes
33
+ // `provided` → `running` and `skipped` → `running` are forbidden, so a stage whose output
34
+ // the user supplied or switched off is stepped over. The walk above still passes
35
35
  // through it: re-running the article with a provided audio must reach the video.
36
36
  function redoable(state) {
37
37
  return state !== "provided" && state !== "skipped";
38
38
  }
39
39
  function clearsOf(action, kind) {
40
- // §Q106: "the previous video stays downloadable until the new render finishes". Clearing
41
- // the video's outputs here would take it away the moment the user pressed Re-render - or
42
- // for the whole of a re-narration, when the video comes along as a dependent. It is kept
43
- // instead, and `slices/video/run.ts` replaces the file and the rows in one swap once
44
- // ffmpeg has exited cleanly. A render that fails or is canceled leaves the old one
45
- // standing, which is exactly what §Q106 promises.
40
+ // The previous video stays downloadable until the new render finishes. Clearing the video's
41
+ // outputs here would take it away the moment the user pressed Re-render - or for the whole of
42
+ // a re-narration, when the video comes along as a dependent. It is kept instead, and
43
+ // `slices/video/run.ts` replaces the file and the rows in one swap once ffmpeg has exited
44
+ // cleanly. A render that fails or is canceled leaves the old one standing.
46
45
  if (kind === "video") {
47
46
  return "nothing";
48
47
  }
@@ -12,10 +12,10 @@ import { redoPlan } from "./cascade.js";
12
12
  // Only the one field this module changes is named; the rest of a piece's payload belongs
13
13
  // to the stage that wrote it and travels through untouched.
14
14
  const anyObject = z.record(z.string(), z.unknown());
15
- // What an article edit replaces (step 1). `instructions` is not among them: §Q57 keeps
16
- // the record of what was actually sent to the model, and an edit did not send anything.
15
+ // What an article edit replaces. `instructions` is not among them: that row records what
16
+ // was actually sent to the model, and an edit did not send anything.
17
17
  const articleRoles = ["article_md", "article_txt", "sources", "glossary"];
18
- // `logic/13` step 5 and `logic/01` §Q5: Retry is offered on a stage that stopped short,
18
+ // Retry is offered on a stage that stopped short,
19
19
  // and a canceled stage resumes exactly as a failed one does.
20
20
  export function retryStage(deps, projectId, kind) {
21
21
  const loaded = load(deps, projectId);
@@ -27,14 +27,14 @@ export function retryStage(deps, projectId, kind) {
27
27
  return { ok: false, reason: "not-retryable" };
28
28
  }
29
29
  // The pieces and the outputs stay where they are: the per-stage resume rules keep what
30
- // finished (`logic/06` §Q54, `logic/08` §Q66, `logic/09` §Q73), and `logic/13` step 2
31
- // keeps them through a cancel too. That is the whole of "a canceled stage is resumable".
30
+ // finished, and a cancel keeps them too. That is the whole of a canceled stage being
31
+ // resumable.
32
32
  transact(deps.db, () => {
33
33
  resetStage(deps.db, stage.id);
34
34
  });
35
35
  return { ok: true, redone: [kind] };
36
36
  }
37
- // Steps 2, 3 and 8: Re-run audio with another voice, Re-run images, Re-render. The stage
37
+ // Re-run audio with another voice, Re-run images, Re-render. The stage
38
38
  // starts over from the project's stored configuration rather than resuming.
39
39
  export function rerunStage(deps, projectId, kind) {
40
40
  const loaded = load(deps, projectId);
@@ -47,15 +47,15 @@ export function rerunStage(deps, projectId, kind) {
47
47
  }
48
48
  return apply(deps, projectId, { kind: "rerun", stage: kind }, loaded);
49
49
  }
50
- // Step 1: "the inline editor replaces the stored markdown; the plain-text narration source
51
- // and the sources and glossary files are rebuilt; then audio, LLM-mode intro/outro text,
52
- // LLM-written thumbnail, and video re-run; prompt-based images are untouched".
50
+ // The inline editor replaces the stored markdown; the plain-text narration source and the
51
+ // sources and glossary files are rebuilt; then audio, LLM-mode intro/outro text, the
52
+ // LLM-written thumbnail and the video re-run, leaving prompt-based images untouched.
53
53
  //
54
54
  // ceiling: the LLM-mode intro and outro texts are *not* rewritten from the edited article.
55
- // Only the article stage writes them (`logic/07` step 5), and §Q60 makes any run of that
56
- // stage regenerate the whole article - which would throw away the edit that triggered it.
57
- // The audio does re-narrate the kept entry text, so the video is whole; the upgrade is a
58
- // mode on the article stage that keeps the stored article and rewrites the entries alone.
55
+ // Only the article stage writes them, and any run of that stage regenerates the whole
56
+ // article - which would throw away the edit that triggered it. The audio does re-narrate
57
+ // the kept entry text, so the video is whole; the upgrade is a mode on the article stage
58
+ // that keeps the stored article and rewrites the entries alone.
59
59
  export function editArticle(deps, projectId, markdown) {
60
60
  const loaded = load(deps, projectId);
61
61
  if (!loaded.ok) {
@@ -67,8 +67,8 @@ export function editArticle(deps, projectId, markdown) {
67
67
  }
68
68
  const text = markdown.trim();
69
69
  if (text === "") {
70
- // §Q106 leaves a project with one output per stage; an empty article would leave the
71
- // audio stage with nothing to narrate and no way back except another edit.
70
+ // A project keeps one output per stage; an empty article would leave the audio stage
71
+ // with nothing to narrate and no way back except another edit.
72
72
  return { ok: false, reason: "empty-article" };
73
73
  }
74
74
  return apply(deps, projectId, { kind: "article-edit" }, loaded, () => {
@@ -76,15 +76,15 @@ export function editArticle(deps, projectId, markdown) {
76
76
  for (const output of replaced) {
77
77
  deleteOutput(deps.db, output.id);
78
78
  }
79
- // The four files are written back under the names they already had, so the write is
80
- // the replacement §Q106 asks for rather than a second version beside the first.
79
+ // The four files are written back under the names they already had, so the write
80
+ // replaces rather than leaving a second version beside the first.
81
81
  storeText(deps, { projectId, stageKind: "article", role: "article_md", text });
82
82
  storeArticleText(deps, { projectId, markdown: text });
83
83
  return replaced.map((output) => output.path);
84
84
  });
85
85
  }
86
- // Step 5 with `logic/09` §Q75: one image is "removed from the set; at least one image must
87
- // remain; video re-renders".
86
+ // One image is removed from the set, at least one image has to remain, and the video
87
+ // re-renders.
88
88
  export function deleteImage(deps, projectId, outputId) {
89
89
  const loaded = load(deps, projectId);
90
90
  if (!loaded.ok) {
@@ -95,8 +95,7 @@ export function deleteImage(deps, projectId, outputId) {
95
95
  return { ok: false, reason: "unknown-image" };
96
96
  }
97
97
  if (loaded.outputs.filter((output) => output.role === "image").length <= 1) {
98
- // §Q103's invariant: "at least one image always remains". `logic/11` step 2 has no
99
- // slideshow to compute without one.
98
+ // At least one image always remains: there is no slideshow to compute without one.
100
99
  return { ok: false, reason: "last-image" };
101
100
  }
102
101
  return apply(deps, projectId, { kind: "image-deleted" }, loaded, () => {
@@ -108,9 +107,9 @@ export function deleteImage(deps, projectId, outputId) {
108
107
  return [image.path];
109
108
  });
110
109
  }
111
- // Step 4: "one new call with that image's stored prompt text, replacing it in place at the
112
- // same index (§Q103)". The piece carries that prompt and that index, so it is left alone
113
- // and the images stage sees one image of its plan missing.
110
+ // One new call with that image's stored prompt text, replacing it in place at the same
111
+ // index. The piece carries that prompt and that index, so it is left alone and the images
112
+ // stage sees one image of its plan missing.
114
113
  export function regenerateImage(deps, projectId, outputId) {
115
114
  const loaded = load(deps, projectId);
116
115
  if (!loaded.ok) {
@@ -132,9 +131,9 @@ function load(deps, projectId) {
132
131
  return { ok: false, reason: "no-project" };
133
132
  }
134
133
  const stages = stagesOf(deps.db, projectId);
135
- // The precondition every action of `logic/12` shares: "no stage of the project is
136
- // `running` (§Q106)". The page disables the controls; this is the other half, because
137
- // clearing an output from under a stage that is writing it would lose both.
134
+ // The precondition every re-run action shares: no stage of the project is `running`. The
135
+ // page disables the controls; this is the other half, because clearing an output from
136
+ // under a stage that is writing it would lose both.
138
137
  if (stages.some((stage) => stage.state === "running")) {
139
138
  return { ok: false, reason: "running" };
140
139
  }
@@ -145,15 +144,15 @@ function load(deps, projectId) {
145
144
  thumbnailSource: project.config.sources.thumbnail,
146
145
  };
147
146
  }
148
- // One transaction for every row the action touches, then the filesystem. S4's lesson:
149
- // an unlink cannot be rolled back, so it waits for the commit - and a file with no row is
150
- // what the boot reconcile collects, where a row with no file is a broken download.
147
+ // One transaction for every row the action touches, then the filesystem: an unlink cannot
148
+ // be rolled back, so it waits for the commit. A file with no row is what the boot reconcile
149
+ // collects, where a row with no file is a broken download.
151
150
  function apply(deps, projectId, action, loaded, own) {
152
151
  const plan = redoPlan({ action, stages: loaded.stages, thumbnailSource: loaded.thumbnailSource });
153
152
  // `own` is the action's own change - the new article, the image that goes - and it runs
154
153
  // first and inside the same transaction, so it lands or rolls back with the cascade it
155
- // triggers. The files it writes take the names they already had, which is the
156
- // replacement §Q106 asks for rather than work a rollback would have to undo.
154
+ // triggers. The files it writes take the names they already had, replacing in place
155
+ // rather than leaving work a rollback would have to undo.
157
156
  const orphaned = transact(deps.db, () => {
158
157
  const files = own === undefined ? [] : [...own()];
159
158
  for (const redo of plan) {
@@ -172,7 +171,7 @@ function apply(deps, projectId, action, loaded, own) {
172
171
  return { ok: true, redone: plan.map((redo) => redo.stage) };
173
172
  }
174
173
  // Everything the stage produced: its outputs, and the resumable pieces whose payloads name
175
- // files of their own - the audio chunks of `logic/08` §Q65, which are not outputs.
174
+ // files of their own - the audio chunks, which are not outputs.
176
175
  function clearStage(deps, stage, outputs) {
177
176
  const files = [];
178
177
  for (const output of outputs) {
@@ -191,7 +190,7 @@ function clearStage(deps, stage, outputs) {
191
190
  deletePieces(deps.db, stage.id);
192
191
  return files;
193
192
  }
194
- // §Q103 replaces the image "in place at the same index", so the piece keeps the prompt
193
+ // A regenerated image is replaced in place at the same index, so the piece keeps the prompt
195
194
  // and the index it was planned with and only stops naming a file: `slices/images/run.ts`
196
195
  // then sees one image of its plan still to make, and the file it named can be removed.
197
196
  function forgetImageFile(deps, stages, path) {
@@ -252,9 +251,9 @@ function removeFiles(deps, projectId, orphaned) {
252
251
  }
253
252
  }
254
253
  }
255
- // `logic/01`: `done` → `running` is scenario 12's own transition, and a stage that failed
256
- // or was canceled may be started over rather than resumed. `provided` and `skipped` never
257
- // run, and `pending` has nothing to redo.
254
+ // `done` → `running` is a re-run's own transition, and a stage that failed or was canceled may
255
+ // be started over rather than resumed. `provided` and `skipped` never run, and `pending` has
256
+ // nothing to redo.
258
257
  function rerunnable(state) {
259
258
  return state === "done" || state === "failed" || state === "canceled";
260
259
  }
@@ -7,7 +7,7 @@ export function plannerMessages(brief) {
7
7
  "",
8
8
  briefText(brief),
9
9
  "",
10
- // §Q53: the prompt's section guide decides the chapters when it has one, and the
10
+ // The prompt's section guide decides the chapters when it has one, and the
11
11
  // planner proposes them when it does not. There is no cap on the count.
12
12
  "List the chapters to research. If the prompt sets out a section guide, take the",
13
13
  "chapters from it, in the order it gives them. If it does not, propose the chapters",
@@ -21,8 +21,7 @@ export function plannerMessages(brief) {
21
21
  }
22
22
  // The answer is a list, however the model chose to punctuate it. Numbering and bullets
23
23
  // are stripped because the instruction above asks for neither and models add them
24
- // anyway; a repeated title is dropped because `logic/06`'s invariant is that no chapter
25
- // is researched twice.
24
+ // anyway; a repeated title is dropped because no chapter is researched twice.
26
25
  export function chaptersFrom(text) {
27
26
  const seen = new Set();
28
27
  const chapters = [];
@@ -40,7 +39,7 @@ export function chaptersFrom(text) {
40
39
  }
41
40
  return chapters;
42
41
  }
43
- // §Q52, §Q53: one sub-agent per chapter, web-grounded, answering that chapter's notes
42
+ // One sub-agent per chapter, web-grounded, answering that chapter's notes
44
43
  // and its sources. The whole outline travels with it so it covers its own chapter and
45
44
  // leaves the neighbouring ones to the sub-agents researching them.
46
45
  export function subAgentMessages(brief, chapter, outline) {
@@ -65,7 +64,7 @@ export function subAgentMessages(brief, chapter, outline) {
65
64
  },
66
65
  ];
67
66
  }
68
- // The half of every instruction that is the same for all three calls (§Q46): the
67
+ // The half of every instruction that is the same for all three calls: the
69
68
  // rendered prompt the article will be written from, and the run's keyword values.
70
69
  export function briefText(brief) {
71
70
  const values = Object.entries(brief.values);
@@ -7,11 +7,11 @@ import { storeText } from "../storage/staging.js";
7
7
  import { noTokens, plusUsage } from "../telemetry/model.js";
8
8
  import { chaptersFrom, plannerMessages, subAgentMessages } from "./planner.js";
9
9
  import { sourcedAnswer, synthesisMessages } from "./synthesis.js";
10
- // §Q47, verbatim: "the stage fails immediately with 'web research unsupported by this
11
- // model'; no fallback to model knowledge".
10
+ // A model that cannot ground on the web fails the stage immediately, with no fallback to
11
+ // what the model already knows.
12
12
  export const webResearchUnsupported = "web research unsupported by this model";
13
13
  // What a chapter piece carries between runs: its title, and its notes once a sub-agent
14
- // has answered. That is the whole of the resume of §Q54.
14
+ // has answered. That is the whole of the resume.
15
15
  const chapterPayload = z.object({ title: z.string(), notes: z.string().optional() });
16
16
  export async function runResearch(deps, context, providers) {
17
17
  const { projectId } = context.stage;
@@ -22,7 +22,7 @@ export async function runResearch(deps, context, providers) {
22
22
  const choice = project.config.llm;
23
23
  const articlePrompt = project.config.rendered.article;
24
24
  if (choice === undefined || articlePrompt === undefined) {
25
- // Admission refuses a run whose research is Generate without both (`logic/04`), so
25
+ // Admission refuses a run whose research is Generate without both, so
26
26
  // reaching here is a bug in admission rather than something the user did.
27
27
  throw new Error("the run has no LLM provider or no rendered article prompt");
28
28
  }
@@ -32,7 +32,7 @@ export async function runResearch(deps, context, providers) {
32
32
  }
33
33
  catch (error) {
34
34
  // The adapter says the model cannot ground on the web and the wrapper has already
35
- // made that terminal; this is where it becomes the sentence the user reads (§Q47).
35
+ // made that terminal; this is where it becomes the sentence the user reads.
36
36
  if (isProviderError(error) && error.fault.kind === "unsupported") {
37
37
  throw new Error(webResearchUnsupported);
38
38
  }
@@ -40,10 +40,9 @@ export async function runResearch(deps, context, providers) {
40
40
  }
41
41
  }
42
42
  async function research(deps, context, providers, choice, brief) {
43
- // logic/16 step 3 counts tokens per stage, so the planner, every sub-agent and the
44
- // synthesis add into one total. A call that was aborted or that failed never answers,
45
- // so it adds nothing (§Q112); a provider that reports no usage adds zero, never an
46
- // estimate (§Q131).
43
+ // Tokens are counted per stage, so the planner, every sub-agent and the synthesis add
44
+ // into one total. A call that was aborted or failed never answers and adds nothing; a
45
+ // provider that reports no usage adds zero, never an estimate.
47
46
  let tokens = noTokens;
48
47
  const add = (usage) => {
49
48
  tokens = plusUsage(tokens, usage);
@@ -58,8 +57,8 @@ async function research(deps, context, providers, choice, brief) {
58
57
  });
59
58
  add(answer.usage);
60
59
  const { projectId } = context.stage;
61
- // Step 4: the notes and every instruction sent are stored on the project. Each
62
- // sub-agent's own output stays on its chapter row, which is where the resume reads it.
60
+ // The notes and every instruction sent are stored on the project. Each sub-agent's own output
61
+ // stays on its chapter row, which is where the resume reads it.
63
62
  storeText(deps, { projectId, stageKind: "research", role: "notes", text: answer.text.trim() });
64
63
  storeText(deps, {
65
64
  projectId,
@@ -80,7 +79,7 @@ async function research(deps, context, providers, choice, brief) {
80
79
  });
81
80
  }
82
81
  // The chapter list, planned once and kept. A retry after a failure finds the rows the
83
- // first run wrote and does not ask again (§Q54).
82
+ // first run wrote and does not ask again.
84
83
  async function plan(deps, context, providers, choice, brief, add) {
85
84
  const existing = piecesOf(deps.db, context.stage.id, "chapter");
86
85
  if (existing.length > 0) {
@@ -90,11 +89,11 @@ async function plan(deps, context, providers, choice, brief, add) {
90
89
  provider: choice.provider,
91
90
  model: choice.model,
92
91
  messages: plannerMessages(brief),
93
- // §Q50: an empty answer, or one with no chapter in it, is a failed attempt.
92
+ // An empty answer, or one with no chapter in it, is a failed attempt.
94
93
  check: (given) => chaptersFrom(given.text).length === 0 ? "the planner named no chapters" : undefined,
95
94
  });
96
95
  add(answer.usage);
97
- // §Q53: no cap on the count. The list is the prompt's own section guide as often as
96
+ // No cap on the count. The list is the prompt's own section guide as often as
98
97
  // not, and capping it would silently drop a section the article asks for.
99
98
  const planned = chaptersFrom(answer.text).map((title, index) => ({
100
99
  id: deps.ids.next(),
@@ -111,9 +110,9 @@ async function plan(deps, context, providers, choice, brief, add) {
111
110
  });
112
111
  return planned;
113
112
  }
114
- // §Q53: "one per chapter, all in parallel". §Q54: a chapter a previous run finished is
115
- // not researched again, and one that fails fails the whole stage while its siblings
116
- // finish and keep their output for the next resume.
113
+ // One sub-agent per chapter, all in parallel. A chapter a previous run finished is not
114
+ // researched again, and one that fails takes down the whole stage while its siblings finish and
115
+ // keep their output for the next resume.
117
116
  async function researchChapters(deps, context, providers, choice, brief, chapters, add) {
118
117
  const outline = chapters.map((piece) => payloadOf(piece).title);
119
118
  const total = chapters.length;
@@ -130,7 +129,7 @@ async function researchChapters(deps, context, providers, choice, brief, chapter
130
129
  provider: choice.provider,
131
130
  model: choice.model,
132
131
  messages: subAgentMessages(brief, kept.title, outline),
133
- // §Q47: grounding is asked for explicitly, so a model without it says so
132
+ // Grounding is asked for explicitly, so a model without it says so
134
133
  // instead of answering from what it already knows.
135
134
  webSearch: true,
136
135
  check: (given) => sourcedAnswer(`the researcher on "${kept.title}"`, given.text),
@@ -143,8 +142,8 @@ async function researchChapters(deps, context, providers, choice, brief, chapter
143
142
  return { ok: true, finding: { title: kept.title, notes } };
144
143
  }
145
144
  catch (error) {
146
- // A cancel is not this chapter failing: `logic/13` §Q112 counts an aborted call
147
- // as nothing, and the resume runs a `pending` chapter exactly as a failed one.
145
+ // A cancel is not this chapter failing: an aborted call counts as nothing, and
146
+ // the resume runs a `pending` chapter exactly as a failed one.
148
147
  setPiece(deps.db, piece.id, context.signal.aborted ? "pending" : "failed", piece.payload);
149
148
  return { ok: false, error };
150
149
  }
@@ -158,8 +157,8 @@ async function researchChapters(deps, context, providers, choice, brief, chapter
158
157
  }
159
158
  return findings;
160
159
  }
161
- // Step 5: "k of N chapters researched". Written as well as emitted, so a page opened
162
- // mid-stage reads the count off the row rather than waiting for the next chapter.
160
+ // K of N chapters researched, written as well as emitted, so a page opened mid-stage reads the
161
+ // count off the row rather than waiting for the next chapter.
163
162
  function report(deps, context, done, total) {
164
163
  setStageProgress(deps.db, context.stage.id, done, total);
165
164
  context.emit({
@@ -170,7 +169,7 @@ function report(deps, context, done, total) {
170
169
  total,
171
170
  });
172
171
  }
173
- // §Q51: every instruction sent is stored on the project. All three are pure functions of
172
+ // Every instruction sent is stored on the project. All three are pure functions of
174
173
  // the brief and the findings, so a resumed run reproduces the ones it did not send.
175
174
  function instructionsText(brief, findings) {
176
175
  const outline = findings.map((finding) => finding.title);
@@ -1,7 +1,7 @@
1
1
  import { briefText } from "./planner.js";
2
- // §Q52: "an editorial pass over every sub-agent's output that selects and organizes the
3
- // findings; it does not concatenate". The sub-agents' notes are the material, not the
4
- // answer, and the synthesising model is told so.
2
+ // An editorial pass over every sub-agent's output that selects and organizes the findings; it
3
+ // does not concatenate. The sub-agents' notes are the material, not the answer, and the
4
+ // synthesising model is told so.
5
5
  export function synthesisMessages(brief, findings) {
6
6
  return [
7
7
  {
@@ -26,7 +26,7 @@ export function synthesisMessages(brief, findings) {
26
26
  },
27
27
  ];
28
28
  }
29
- // §Q48 and §Q55: the stored notes always end with a Sources list, and an answer without
29
+ // The stored notes always end with a Sources list, and an answer without
30
30
  // one "counts as a failed attempt". A heading with nothing under it is not a list, so
31
31
  // the line has to be followed by something.
32
32
  const heading = /^\s*(?:#{1,6}\s*)?(?:\*\*)?\s*sources(?:\s+(?:list|consulted))?\s*(?:\*\*)?\s*:?\s*$/i;
@@ -36,7 +36,7 @@ export function endsWithSources(text) {
36
36
  return at !== -1 && lines.slice(at + 1).some((line) => line.trim() !== "");
37
37
  }
38
38
  // The one shape a stage hands `StageProviders.llm` as its `check`: an answer that arrived
39
- // but cannot be used is a failed attempt, so the wrapper retries it (§Q50, §Q55).
39
+ // but cannot be used is a failed attempt, so the wrapper retries it.
40
40
  export function sourcedAnswer(who, text) {
41
41
  if (text.trim() === "") {
42
42
  return `${who} answered with nothing`;
@@ -1,16 +1,15 @@
1
1
  import { execFile } from "node:child_process";
2
- // ceiling: `logic/02` §Q135 computes readiness at request time, so nothing is cached and
3
- // a hung CLI costs the settings page this long. Both probes run in parallel, so the page
4
- // waits 2 s in the worst case. Caching the answer for a few seconds is the upgrade if a
5
- // slow machine makes the status line flap.
2
+ // ceiling: readiness is computed at request time, so nothing is cached and a hung CLI costs the
3
+ // settings page this long. Both probes run in parallel, so the page waits 2 s in the worst
4
+ // case. Caching the answer for a few seconds is the upgrade if a slow machine makes the status
5
+ // line flap.
6
6
  export const cliProbeTimeoutMs = 2000;
7
7
  const probeOutputMax = 64 * 1024;
8
- // The real probe: an argument array, never a shell string, so nothing in a binary name
9
- // or an argument can be interpreted as a command. It resolves for every outcome; a
10
- // missing CLI is an answer, not a failure.
11
- // ceiling: POSIX only. A Windows install puts `claude` on PATH as a `.cmd` shim, which
12
- // execFile cannot run without a shell; a shim-aware lookup is the upgrade when Windows
13
- // is supported.
8
+ // The real probe: an argument array, never a shell string, so nothing in a binary name or an
9
+ // argument can be interpreted as a command. It resolves for every outcome; a missing CLI is an
10
+ // answer, not a failure. ceiling: POSIX only. A Windows install puts `claude` on PATH as a
11
+ // `.cmd` shim, which execFile cannot run without a shell; a shim-aware lookup is the upgrade
12
+ // when Windows is supported.
14
13
  export function nodeCliProbe(binary, args, timeoutMs) {
15
14
  return new Promise((resolve) => {
16
15
  execFile(binary, [...args], { timeout: timeoutMs, maxBuffer: probeOutputMax, windowsHide: true }, (error, stdout) => {
@@ -1,17 +1,16 @@
1
1
  import { providerById } from "./model.js";
2
2
  import { deleteKey, hasKey, keyOf, upsertKey } from "./repo.js";
3
- // `logic/02` step 1 says the field shows the key masked and points at
4
- // `mockup/03-settings.md`, which draws it with nothing legible in it; the scenario's own
5
- // invariant is that keys never appear outside a provider call. So the mask is a
6
- // constant: it carries no character of the key and not even its length. Every response
7
- // that reports a stored key reports this string and nothing else about the value.
3
+ // The Settings field shows a stored key masked, with nothing legible in it, and a key never
4
+ // appears outside a provider call. So the mask is a constant: it carries no character of
5
+ // the key and not even its length. Every response that reports a stored key reports this
6
+ // string and nothing else about the value.
8
7
  export const keyMask = "••••••••••••";
9
8
  export function keyStatus(deps, provider) {
10
9
  const stored = hasKey(deps.db, provider);
11
10
  return { provider, hasKey: stored, masked: stored ? keyMask : null };
12
11
  }
13
- // `logic/02` step 1: trimmed, stored as given, overwriting any previous one. No format
14
- // check and no test call (§Q11, §Q17, §Q18).
12
+ // Trimmed, stored as given, overwriting any previous one. No format
13
+ // check and no test call.
15
14
  export function saveProviderKey(deps, provider, key) {
16
15
  if (providerById(provider).auth === "cli") {
17
16
  return { ok: false, reason: "cli-provider" };
@@ -37,9 +36,9 @@ export function removeProviderKey(deps, provider) {
37
36
  }
38
37
  return deleteKey(deps.db, provider) ? { ok: true } : { ok: false, reason: "absent" };
39
38
  }
40
- // `logic/02` step 7: the key is read at the moment an attempt starts. This reads the row
39
+ // The key is read at the moment an attempt starts. This reads the row
41
40
  // every time and hands back a plain string, so an attempt holds the value it started
42
- // with and a save or a remove landing mid-run reaches the next attempt only (§Q16).
41
+ // with and a save or a remove landing mid-run reaches the next attempt only.
43
42
  export function keyForAttempt(deps, provider) {
44
43
  if (providerById(provider).auth === "cli") {
45
44
  return { ok: false, reason: "cli-provider" };
@@ -1,9 +1,9 @@
1
- // The provider catalogue and the settings domain types (02-models, `logic/02`).
1
+ // The provider catalogue and the settings domain types.
2
2
  // The three families are the three ports, so the set is named beside them.
3
3
  export { providerFamilies } from "../../kernel/ports/model.js";
4
- // The supported set is `05-dependencies` External services. OpenAI ships two adapters
5
- // with two ids, one per family: `provider_keys.provider` is the primary key, and a row
6
- // per family is what lets a user key TTS without keying image generation.
4
+ // The supported set. OpenAI ships two adapters with two ids, one per family:
5
+ // `provider_keys.provider` is the primary key, and a row per family is what lets a user key TTS
6
+ // without keying image generation.
7
7
  export const providerIds = [
8
8
  "openrouter",
9
9
  "claude-code",
@@ -49,6 +49,6 @@ export function providerById(id) {
49
49
  }
50
50
  return found;
51
51
  }
52
- // uiux §Q19: the theme override the Appearance control writes.
52
+ // The theme override the Appearance control writes.
53
53
  export const appearances = ["system", "light", "dark"];
54
54
  export const defaultSettings = { silenceGapSeconds: 3, appearance: "system" };
@@ -1,10 +1,9 @@
1
1
  import { cliReadiness } from "./cli-status.js";
2
2
  import { providers } from "./model.js";
3
3
  import { keyedProviders } from "./repo.js";
4
- // `logic/02` step 5 and §Q135: every supported provider is listed, keyed or not, found
5
- // or not, so Play can grey one out with a reason instead of hiding it. A keyed provider
6
- // is ready when a key is stored; a CLI provider when its binary answers. Readiness is
7
- // computed per request and nothing about a CLI is stored.
4
+ // Every supported provider is listed, keyed or not, found or not, so Play can grey one out with
5
+ // a reason instead of hiding it. A keyed provider is ready when a key is stored; a CLI provider
6
+ // when its binary answers. Readiness is computed per request and nothing about a CLI is stored.
8
7
  export async function providerStatuses(deps) {
9
8
  const keyed = keyedProviders(deps.db);
10
9
  return await Promise.all(providers.map(async (provider) => ({
@@ -20,7 +20,7 @@ export function deleteKey(db, provider) {
20
20
  return Number(result.changes) > 0;
21
21
  }
22
22
  // The one place a key value leaves the database. Every caller reads it for a single
23
- // provider call and holds it no longer (`logic/02` §Q16). Nothing under `edge/` calls
23
+ // provider call and holds it no longer. Nothing under `edge/` calls
24
24
  // this: a route asks the two functions below, which never select the `key` column.
25
25
  export function keyOf(db, provider) {
26
26
  const row = db.prepare("SELECT key FROM provider_keys WHERE provider = ?").get(provider);
@@ -3,10 +3,9 @@ import { providerById } from "./model.js";
3
3
  import { deleteVoice, insertVoice, listVoices } from "./repo.js";
4
4
  export const voiceNameMax = 200;
5
5
  export const voiceIdMax = 200;
6
- // `logic/02` step 3: a non-empty name and a non-empty voice ID, the ID unique within its
7
- // provider, names free to repeat. Nothing is verified against the provider - a wrong ID
8
- // is discovered when the audio stage uses it (§Q14), which is why a key is not required
9
- // here either.
6
+ // A non-empty name and a non-empty voice ID, the ID unique within its provider, names free to
7
+ // repeat. Nothing is verified against the provider - a wrong ID is discovered when the audio
8
+ // stage uses it, which is why a key is not required here either.
10
9
  export function addVoice(deps, draft) {
11
10
  if (providerById(draft.provider).family !== "tts") {
12
11
  return { ok: false, reason: "not-a-tts-provider" };
@@ -3,8 +3,8 @@
3
3
  // draws, and `downloads.ts` reads the disk: importing it into the SPA would drag
4
4
  // `node:fs` and the zip encoder into the browser bundle. Nothing here touches IO.
5
5
  // An output's role is its asset name; images add their place in the slideshow, and the
6
- // instructions add their stage, those being the two roles a project holds more than one of
7
- // (logic/06 step 4 and logic/07 step 4 both store what the stage sent).
6
+ // instructions add their stage, those being the two roles a project holds more than one of -
7
+ // research and the article each store what they sent.
8
8
  export function assetOf(output) {
9
9
  const asset = output.role.replaceAll("_", "-");
10
10
  if (output.role === "instructions") {
@@ -9,9 +9,8 @@ export function deleteProject(deps, projectId) {
9
9
  if (derive(stagesOf(deps.db, projectId)) === "running") {
10
10
  return { ok: false, reason: "running" };
11
11
  }
12
- // Files first, rows second. A folder that would not go leaves the project listed, which
13
- // is what `logic/14`'s unhappy path asks for; rows removed first would leave the files
14
- // orphaned under a project nothing names.
12
+ // Files first, rows second. A folder that would not go leaves the project listed; rows
13
+ // removed first would leave the files orphaned under a project nothing names.
15
14
  const dir = projectDir(deps.paths, projectId);
16
15
  try {
17
16
  rmSync(dir, { recursive: true, force: true });
@@ -25,8 +24,7 @@ export function deleteProject(deps, projectId) {
25
24
  }
26
25
  // Stages, attempts, pieces and outputs go with it: every one of those tables declares
27
26
  // ON DELETE CASCADE on the project, and the connection runs with foreign keys on
28
- // (kernel/db/index.ts). The invariant of `logic/14` is that a deleted project leaves no
29
- // files and no rows.
27
+ // (kernel/db/index.ts). A deleted project leaves no files and no rows.
30
28
  deps.db.prepare("DELETE FROM projects WHERE id = ?").run(projectId);
31
29
  return { ok: true };
32
30
  }
@@ -36,7 +36,7 @@ export function slugOf(title) {
36
36
  // `assetOf` moved to asset-name.ts so the SPA can build the same URLs; it is still part
37
37
  // of this module's surface, because a download name is built from it.
38
38
  export { assetOf };
39
- // logic/14 §Q116: "single files as `<title-slug>-<asset>.<ext>`".
39
+ // "single files as `<title-slug>-<asset>.<ext>`".
40
40
  export function downloadName(slug, output) {
41
41
  return `${slug}-${assetOf(output)}${extname(output.path)}`;
42
42
  }
@@ -64,7 +64,7 @@ export function findDownload(deps, projectId, asset) {
64
64
  },
65
65
  };
66
66
  }
67
- // logic/14 §Q116: "download all" images as `<title-slug>-images.zip`, thumbnail included.
67
+ // "download all" images as `<title-slug>-images.zip`, thumbnail included.
68
68
  export function imagesZip(deps, projectId) {
69
69
  const title = projectTitle(deps.db, projectId);
70
70
  if (title === undefined) {
@@ -78,11 +78,11 @@ export function imagesZip(deps, projectId) {
78
78
  }
79
79
  const path = outputPath(deps.paths, projectId, output.path);
80
80
  if (sizeOf(path) === undefined) {
81
- // A missing file is the project page's problem per logic/14; the rest still zips.
81
+ // A missing file is the project page's problem; the rest still zips.
82
82
  continue;
83
83
  }
84
84
  // Images are already compressed formats, so deflating them costs time and saves
85
- // nothing. ceiling: 60 images (logic/05 §Q39) are read into memory at once; a set
85
+ // nothing. ceiling: 60 images are read into memory at once; a set
86
86
  // large enough to hurt would move to fflate's streaming Zip.
87
87
  entries[`${slug}-${assetOf(output)}${extname(output.path)}`] = [
88
88
  readFileSync(path),