@gentbajko/slopify 0.8.3 → 1.0.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 (207) hide show
  1. package/README.md +28 -6
  2. package/SUBTITLES.md +2 -0
  3. package/dist/adapters/alignment/window.js +46 -5
  4. package/dist/adapters/alignment/worker.js +2 -2
  5. package/dist/adapters/llm/claude-code.js +6 -3
  6. package/dist/adapters/llm/codex.js +102 -12
  7. package/dist/adapters/llm/gemini.js +11 -8
  8. package/dist/adapters/llm/run-cli.js +61 -6
  9. package/dist/adapters/tts/inworld-async.js +4 -4
  10. package/dist/adapters/tts/inworld.js +73 -68
  11. package/dist/catalog/registry.js +13 -73
  12. package/dist/edge/cli.js +5 -2
  13. package/dist/edge/events/hub.js +7 -2
  14. package/dist/edge/events/preview-cache.js +7 -4
  15. package/dist/edge/events/visibility.js +33 -0
  16. package/dist/edge/http/actions.js +74 -79
  17. package/dist/edge/http/app.js +39 -6
  18. package/dist/edge/http/audio-preview.js +9 -1
  19. package/dist/edge/http/checkpoints.js +95 -0
  20. package/dist/edge/http/diagnostics.js +31 -0
  21. package/dist/edge/http/draft-files.js +69 -0
  22. package/dist/edge/http/draft-problem.js +15 -0
  23. package/dist/edge/http/drafts.js +95 -0
  24. package/dist/edge/http/multipart.js +86 -0
  25. package/dist/edge/http/mutations.js +49 -0
  26. package/dist/edge/http/planning.js +12 -1
  27. package/dist/edge/http/project-templates.js +74 -0
  28. package/dist/edge/http/projects.js +19 -2
  29. package/dist/edge/http/revision-files.js +84 -0
  30. package/dist/edge/http/revisions.js +103 -0
  31. package/dist/edge/http/schedules.js +100 -0
  32. package/dist/edge/http/staging.js +11 -52
  33. package/dist/edge/http/storage.js +110 -0
  34. package/dist/edge/http/subtitles.js +9 -79
  35. package/dist/edge/http/tutorial.js +26 -0
  36. package/dist/edge/signal-shutdown.js +23 -0
  37. package/dist/kernel/audio-preview.js +11 -5
  38. package/dist/kernel/db/migrations/0004-project-revisions.sql +91 -0
  39. package/dist/kernel/db/migrations/0005-revision-work.sql +103 -0
  40. package/dist/kernel/db/migrations/0006-play-drafts.sql +38 -0
  41. package/dist/kernel/db/migrations/0007-review-checkpoints.sql +28 -0
  42. package/dist/kernel/db/migrations/0008-project-templates.sql +21 -0
  43. package/dist/kernel/db/migrations/0009-scheduled-jobs.sql +37 -0
  44. package/dist/kernel/db/migrations/0010-retain-schedule-history.sql +62 -0
  45. package/dist/kernel/ports/model.js +5 -0
  46. package/dist/kernel/runner/attempt-repo.js +15 -0
  47. package/dist/kernel/runner/attempt.js +14 -2
  48. package/dist/kernel/runner/checkpoint-authority.js +23 -0
  49. package/dist/kernel/runner/index.js +35 -11
  50. package/dist/kernel/runner/providers.js +18 -8
  51. package/dist/kernel/runner/work-attempt.js +47 -0
  52. package/dist/kernel/runner/work-authority.js +37 -0
  53. package/dist/kernel/runner/work.js +23 -0
  54. package/dist/main.js +227 -69
  55. package/dist/slices/admission/repo.js +5 -56
  56. package/dist/slices/admission/schema.js +64 -0
  57. package/dist/slices/admission/start.js +13 -4
  58. package/dist/slices/article/continuation.js +9 -3
  59. package/dist/slices/article/provided-entries.js +7 -3
  60. package/dist/slices/article/run.js +20 -7
  61. package/dist/slices/article/segments.js +13 -4
  62. package/dist/slices/batch/index.js +8 -11
  63. package/dist/slices/cancel/index.js +78 -34
  64. package/dist/slices/checkpoints/change.js +250 -0
  65. package/dist/slices/checkpoints/fingerprint.js +33 -0
  66. package/dist/slices/checkpoints/index.js +2 -0
  67. package/dist/slices/checkpoints/model.js +1 -0
  68. package/dist/slices/checkpoints/recovery.js +116 -0
  69. package/dist/slices/checkpoints/repo.js +132 -0
  70. package/dist/slices/checkpoints/rules.js +30 -0
  71. package/dist/slices/checkpoints/schema.js +59 -0
  72. package/dist/slices/control/index.js +88 -22
  73. package/dist/slices/control/providers.js +3 -2
  74. package/dist/slices/control/revision-control-schema.js +7 -0
  75. package/dist/slices/control/revision-control.js +59 -0
  76. package/dist/slices/estimate/index.js +77 -59
  77. package/dist/slices/estimate/requests.js +102 -0
  78. package/dist/slices/images/run.js +14 -3
  79. package/dist/slices/library/slots.js +11 -11
  80. package/dist/slices/library/snapshot.js +33 -0
  81. package/dist/slices/narration/legacy-plan.js +58 -0
  82. package/dist/slices/narration/live.js +2 -2
  83. package/dist/slices/narration/plan.js +48 -54
  84. package/dist/slices/narration/run.js +21 -8
  85. package/dist/slices/play-drafts/convert.js +120 -0
  86. package/dist/slices/play-drafts/model.js +1 -0
  87. package/dist/slices/play-drafts/readiness.js +80 -0
  88. package/dist/slices/play-drafts/repo.js +76 -0
  89. package/dist/slices/play-drafts/review-inputs.js +150 -0
  90. package/dist/slices/play-drafts/review.js +109 -0
  91. package/dist/slices/play-drafts/schema.js +218 -0
  92. package/dist/slices/play-drafts/service.js +242 -0
  93. package/dist/slices/play-drafts/start-repo.js +113 -0
  94. package/dist/slices/play-drafts/start.js +148 -0
  95. package/dist/slices/play-drafts/uploads.js +67 -0
  96. package/dist/slices/project-templates/from-project.js +159 -0
  97. package/dist/slices/project-templates/model.js +1 -0
  98. package/dist/slices/project-templates/repo.js +41 -0
  99. package/dist/slices/project-templates/schema.js +37 -0
  100. package/dist/slices/project-templates/service.js +136 -0
  101. package/dist/slices/project-templates/setup.js +55 -0
  102. package/dist/slices/rebuild/admission-repo.js +120 -0
  103. package/dist/slices/rebuild/dependencies.js +33 -0
  104. package/dist/slices/rebuild/model.js +94 -0
  105. package/dist/slices/rebuild/narration-history.js +43 -0
  106. package/dist/slices/rebuild/narration-reuse.js +20 -0
  107. package/dist/slices/rebuild/preview-details.js +144 -0
  108. package/dist/slices/rebuild/preview-plan.js +223 -0
  109. package/dist/slices/rebuild/preview-retained.js +128 -0
  110. package/dist/slices/rebuild/provided-review.js +32 -0
  111. package/dist/slices/rebuild/recipe-audio.js +172 -0
  112. package/dist/slices/rebuild/recipe-build.js +17 -0
  113. package/dist/slices/rebuild/recipe-checkpoints.js +75 -0
  114. package/dist/slices/rebuild/recipe-exports.js +65 -0
  115. package/dist/slices/rebuild/recipe-fixture.js +150 -0
  116. package/dist/slices/rebuild/recipe-input-schema.js +82 -0
  117. package/dist/slices/rebuild/recipe-legacy.js +135 -0
  118. package/dist/slices/rebuild/recipe-model.js +77 -0
  119. package/dist/slices/rebuild/recipe-provider-choice.js +14 -0
  120. package/dist/slices/rebuild/recipe-save.js +221 -0
  121. package/dist/slices/rebuild/recipe-text.js +170 -0
  122. package/dist/slices/rebuild/recipe-validation.js +86 -0
  123. package/dist/slices/rebuild/recipe-visual.js +111 -0
  124. package/dist/slices/rebuild/recipe-work.js +212 -0
  125. package/dist/slices/rebuild/recipes.js +4 -0
  126. package/dist/slices/rebuild/repo.js +64 -0
  127. package/dist/slices/rebuild/runtime-actions.js +76 -0
  128. package/dist/slices/rebuild/runtime-admission.js +71 -0
  129. package/dist/slices/rebuild/runtime-article.js +117 -0
  130. package/dist/slices/rebuild/runtime-checkpoints.js +45 -0
  131. package/dist/slices/rebuild/runtime-export-inputs.js +109 -0
  132. package/dist/slices/rebuild/runtime-export.js +139 -0
  133. package/dist/slices/rebuild/runtime-instructions.js +51 -0
  134. package/dist/slices/rebuild/runtime-local.js +137 -0
  135. package/dist/slices/rebuild/runtime-materialize.js +105 -0
  136. package/dist/slices/rebuild/runtime-narration-reuse.js +134 -0
  137. package/dist/slices/rebuild/runtime-plan.js +103 -0
  138. package/dist/slices/rebuild/runtime-provider.js +184 -0
  139. package/dist/slices/rebuild/runtime-publication.js +172 -0
  140. package/dist/slices/rebuild/runtime-run.js +35 -0
  141. package/dist/slices/rebuild/runtime-store.js +166 -0
  142. package/dist/slices/rebuild/runtime-subtitles.js +202 -0
  143. package/dist/slices/rebuild/service-readiness.js +110 -0
  144. package/dist/slices/rebuild/service.js +149 -0
  145. package/dist/slices/rebuild/transition-repo.js +108 -0
  146. package/dist/slices/rebuild/work-records.js +44 -0
  147. package/dist/slices/reruns/index.js +5 -0
  148. package/dist/slices/research/run.js +33 -12
  149. package/dist/slices/revisions/adopt-assets.js +31 -0
  150. package/dist/slices/revisions/adopt-content.js +112 -0
  151. package/dist/slices/revisions/adopt.js +100 -0
  152. package/dist/slices/revisions/downloads.js +80 -0
  153. package/dist/slices/revisions/index.js +5 -0
  154. package/dist/slices/revisions/manifest-repo.js +125 -0
  155. package/dist/slices/revisions/model.js +1 -0
  156. package/dist/slices/revisions/mutation-assets.js +143 -0
  157. package/dist/slices/revisions/mutation-cues.js +95 -0
  158. package/dist/slices/revisions/mutation-prepare.js +182 -0
  159. package/dist/slices/revisions/mutation-request.js +58 -0
  160. package/dist/slices/revisions/mutation-work.js +47 -0
  161. package/dist/slices/revisions/mutations.js +194 -0
  162. package/dist/slices/revisions/projection.js +82 -0
  163. package/dist/slices/revisions/publication-model.js +1 -0
  164. package/dist/slices/revisions/publication-rules.js +166 -0
  165. package/dist/slices/revisions/publish.js +115 -0
  166. package/dist/slices/revisions/repo.js +83 -0
  167. package/dist/slices/revisions/restore.js +60 -0
  168. package/dist/slices/revisions/rules.js +99 -0
  169. package/dist/slices/revisions/schema.js +160 -0
  170. package/dist/slices/revisions/view.js +44 -0
  171. package/dist/slices/schedules/calendar.js +47 -0
  172. package/dist/slices/schedules/model.js +1 -0
  173. package/dist/slices/schedules/repo.js +281 -0
  174. package/dist/slices/schedules/scheduler.js +144 -0
  175. package/dist/slices/schedules/schema.js +80 -0
  176. package/dist/slices/schedules/service.js +178 -0
  177. package/dist/slices/settings/cli-paths.js +1 -1
  178. package/dist/slices/settings/cli-status.js +45 -5
  179. package/dist/slices/settings/tutorial-schema.js +39 -0
  180. package/dist/slices/settings/tutorial.js +66 -0
  181. package/dist/slices/storage/assets.js +108 -0
  182. package/dist/slices/storage/delete-project.js +2 -1
  183. package/dist/slices/storage/downloads.js +4 -1
  184. package/dist/slices/storage/portable.js +871 -0
  185. package/dist/slices/storage/prepare.js +67 -0
  186. package/dist/slices/storage/reconcile.js +25 -5
  187. package/dist/slices/storage/repo.js +2 -15
  188. package/dist/slices/storage/schema.js +35 -0
  189. package/dist/slices/storage/staging-refs.js +26 -0
  190. package/dist/slices/storage/staging.js +16 -11
  191. package/dist/slices/thumbnail/run.js +26 -9
  192. package/dist/slices/video/audio-export-args.js +33 -0
  193. package/dist/slices/video/audio-export.js +13 -36
  194. package/dist/slices/video/audio-inputs.js +4 -3
  195. package/dist/slices/video/ffmpeg.js +6 -0
  196. package/dist/slices/video/reuse-audio.js +11 -4
  197. package/dist/updater/install-flow.js +27 -2
  198. package/dist/updater/plan.js +28 -2
  199. package/dist/updater/service.js +27 -2
  200. package/dist/updater/worker.js +2 -1
  201. package/dist/web/assets/index-CO7DnVX9.js +137 -0
  202. package/dist/web/assets/index-DX1_chKs.css +1 -0
  203. package/dist/web/index.html +2 -2
  204. package/package.json +2 -2
  205. package/dist/adapters/tts/inworld-text.js +0 -32
  206. package/dist/web/assets/index-Ce2HApus.css +0 -1
  207. package/dist/web/assets/index-xbD0MZOY.js +0 -130
package/README.md CHANGED
@@ -13,17 +13,39 @@
13
13
 
14
14
  A prompt and a few keywords in. A narrated slideshow video out. Your keys, your machine, free.
15
15
 
16
+ *Start contributing to the internet's enshittification today!*
17
+
16
18
  ```sh
17
19
  npx @gentbajko/slopify@latest
18
20
  ```
19
21
 
20
- That opens a browser at `http://127.0.0.1:6969`. Nothing to configure first; add
21
- provider keys on the Settings screen when you want a stage to generate rather than
22
- take what you paste in.
22
+ That opens a browser at `http://127.0.0.1:6969`. Node 26 or newer is required.
23
+
24
+ For a persistent install that makes `slopify` available everywhere:
25
+
26
+ ```sh
27
+ npm install -g @gentbajko/slopify
28
+ slopify
29
+ ```
30
+
31
+ Both commands launch the same local app.
32
+
33
+ ## How to use it
34
+
35
+ 1. Add provider keys and voices in Settings. They stay in your local Slopify database.
36
+ 2. Write reusable article, image and thumbnail prompts in Prompts, using `{{keywords}}`
37
+ where each video's subject belongs.
38
+ 3. In Play, build an autosaved setup across Content, Outputs, Style and Review. Supply
39
+ or skip optional stages, preview subtitles, and inspect the estimated cost before Start.
40
+ 4. Follow total and per-stage progress in the project workspace. Pause unfinished work
41
+ to change providers, add review checkpoints, or edit a paused or completed project as
42
+ a retained revision. Saving does not rebuild until you explicitly ask.
43
+ 5. Save Play setups or project revisions as templates. Apply one to a fresh draft or
44
+ schedule one-off, daily or weekly jobs with keyword variants and a spend ceiling.
23
45
 
24
- Six stages run as a graph: research, article, narration, images, thumbnail, video.
25
- Generate any of them, or provide the output yourself and that stage is skipped. The
26
- video is a slideshow with alternating zoom over the narration, rendered with ffmpeg.
46
+ The article is the only required output. Slopify can make a narrated or silent slideshow,
47
+ an audio-only WAV, locally timed English subtitles, or just the intermediate results you
48
+ select. Uploaded and already completed outputs are reused when their inputs still match.
27
49
 
28
50
  ## Inworld narration
29
51
 
package/SUBTITLES.md CHANGED
@@ -8,6 +8,8 @@
8
8
 
9
9
  For a completed project, open its final Video or Audio export section, set subtitles, and click **Save subtitles**. This rebuilds only the local export from saved narration and images. Changing font, size or position reuses word timing when the audio and spoken text are unchanged. A paused run saves these choices until Resume; pause an active run before editing. Failed alignment or rendering keeps the previous finished export.
10
10
 
11
+ If narration omits a short phrase, alignment can skip that transcript span when the surrounding speech is a strong match, including omissions inside an alignment window. The export records the omitted text and audio position for review. Recovery stays bounded and never recreates missing speech.
12
+
11
13
  Audio Off disables subtitles. Video Off produces WAV audio with separate subtitle files. Uploaded audio must match the article; substantial mismatches fail with a message to correct the transcript. Review timing and spelling before publishing. English is supported first; unusual pronunciations and non-English passages can fail alignment.
12
14
 
13
15
  Fonts are copied into the completed project's caption assets, so an existing export can reuse its chosen font even after the original system font is removed. Installed fonts and uploaded fonts stay local. SRT/VTT are portable text/timing files and do not embed a font; the chosen font is used in burned video captions.
@@ -1,15 +1,21 @@
1
1
  import { alignWindow, frameSeconds, mismatch } from "./ctc.js";
2
2
  import { agreesWithSpeech } from "./quality.js";
3
3
  import { letters } from "./vocabulary.js";
4
- // Recovery can only omit a short transcript prefix, never insert guessed words or times.
4
+ // Recovery omits bounded transcript spans only after confirming surrounding speech.
5
5
  export function alignSpeechWindow(logits, frames, candidate, complete, cutoff, skipBudget) {
6
6
  try {
7
- return { words: accepted(logits, frames, candidate, complete, cutoff), skipped: 0 };
7
+ return {
8
+ words: accepted(logits, frames, candidate, complete, cutoff),
9
+ skipped: 0,
10
+ omissionStart: 0,
11
+ };
8
12
  }
9
13
  catch (error) {
10
14
  if (!(error instanceof Error) || error.message !== mismatch)
11
15
  throw error;
12
16
  }
17
+ if (skipBudget <= 0)
18
+ throw new Error(mismatch);
13
19
  const heard = greedy(logits, frames).split(/\s+/);
14
20
  for (let skipped = 1; skipped <= Math.min(40, skipBudget, candidate.length - 4); skipped += 1) {
15
21
  const remaining = candidate.slice(skipped);
@@ -24,23 +30,58 @@ export function alignSpeechWindow(logits, frames, candidate, complete, cutoff, s
24
30
  const words = accepted(logits, frames, remaining, complete, cutoff);
25
31
  if (words.length < 4 || words.slice(0, 4).some((word) => (word.confidence ?? 0) < 0.75))
26
32
  continue;
27
- return { words, skipped };
33
+ return { words, skipped, omissionStart: 0 };
28
34
  }
29
35
  catch (error) {
30
36
  if (!(error instanceof Error) || error.message !== mismatch)
31
37
  throw error;
32
38
  }
33
39
  }
40
+ const observed = greedy(logits, frames).replace(/[^A-Z']/g, "");
41
+ for (let start = candidate.length - 4; start >= 4; start -= 1) {
42
+ const prefix = candidate
43
+ .slice(0, start)
44
+ .map((word) => word.spoken)
45
+ .join("")
46
+ .replace(/[^A-Z']/g, "");
47
+ if (prefix.length > observed.length)
48
+ continue;
49
+ if (!agreesWithSpeech(prefix, observed.slice(0, prefix.length), 0.2))
50
+ continue;
51
+ for (let skipped = 1; skipped <= Math.min(40, skipBudget, candidate.length - start - 4); skipped += 1) {
52
+ const remaining = [...candidate.slice(0, start), ...candidate.slice(start + skipped)];
53
+ const anchor = remaining
54
+ .slice(start, start + 4)
55
+ .map((word) => word.spoken)
56
+ .join("")
57
+ .replace(/[^A-Z']/g, "");
58
+ if (anchor.length < 20 ||
59
+ !agreesWithSpeech(anchor, observed.slice(prefix.length, prefix.length + anchor.length), 0.2) ||
60
+ !agreesWithSpeech(prefix + anchor, observed.slice(0, prefix.length + anchor.length), 0.15))
61
+ continue;
62
+ try {
63
+ const words = accepted(logits, frames, remaining, complete, cutoff, 0.2);
64
+ if (words.length < start + 4 ||
65
+ words.slice(start - 4, start + 4).some((word) => (word.confidence ?? 0) < 0.75))
66
+ continue;
67
+ return { words, skipped, omissionStart: start };
68
+ }
69
+ catch (error) {
70
+ if (!(error instanceof Error) || error.message !== mismatch)
71
+ throw error;
72
+ }
73
+ }
74
+ }
34
75
  throw new Error(mismatch);
35
76
  }
36
- function accepted(logits, frames, candidate, complete, cutoff) {
77
+ function accepted(logits, frames, candidate, complete, cutoff, maximumError = 0.42) {
37
78
  const words = alignWindow(logits, frames, candidate, complete).words.filter((word) => word.end <= cutoff);
38
79
  const last = words.at(-1);
39
80
  if (last === undefined ||
40
81
  !agreesWithSpeech(candidate
41
82
  .slice(0, words.length)
42
83
  .map((word) => word.spoken)
43
- .join(" "), greedy(logits, Math.min(frames, Math.ceil(last.end / frameSeconds)))))
84
+ .join(" "), greedy(logits, Math.min(frames, Math.ceil(last.end / frameSeconds))), maximumError))
44
85
  throw new Error(mismatch);
45
86
  return words;
46
87
  }
@@ -73,9 +73,9 @@ async function run(input) {
73
73
  omitted += recovered.skipped;
74
74
  send({
75
75
  type: "omission",
76
- start: sampleAt / sampleRate,
76
+ start: sampleAt / sampleRate + (accepted[recovered.omissionStart - 1]?.end ?? 0),
77
77
  text: candidate
78
- .slice(0, recovered.skipped)
78
+ .slice(recovered.omissionStart, recovered.omissionStart + recovered.skipped)
79
79
  .map((word) => word.text)
80
80
  .join(" "),
81
81
  });
@@ -1,7 +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 { cliEvent, cliShaped, endedWithout, promptOf } from "./run-cli.js";
4
+ import { cliEvent, cliShaped, endedWithout, promptOf, stopCliRun } from "./run-cli.js";
5
5
  import { lines } from "./sse-lines.js";
6
6
  // The local-agent adapter for Claude Code: spawned non-interactively, authenticated by the
7
7
  // CLI's own login, no key anywhere in this file. Readiness is not computed here - `adapters/**`
@@ -76,6 +76,7 @@ export function claudeCodeLlm(deps) {
76
76
  const binary = deps.binary ?? claudeCodeBinary;
77
77
  async function* complete(req) {
78
78
  const run = deps.run(binary, claudeCodeArgs(req), req.signal);
79
+ let ended;
79
80
  try {
80
81
  for await (const line of lines(run.stdout, req.signal)) {
81
82
  if (line.trim() === "") {
@@ -123,7 +124,7 @@ export function claudeCodeLlm(deps) {
123
124
  finally {
124
125
  // The consumer can also abandon the generator - a retry, a timeout - and an agent
125
126
  // session left running would keep spending the user's subscription.
126
- run.kill();
127
+ ended = await stopCliRun(run);
127
128
  }
128
129
  // A cancelled run ends its stream the same way an exhausted one does: the child was
129
130
  // killed, so stdout simply stopped. An aborted call counts as nothing, so it must not
@@ -132,7 +133,9 @@ export function claudeCodeLlm(deps) {
132
133
  // The stream ended with no result event at all.
133
134
  throw providerError({
134
135
  kind: "other",
135
- message: endedWithout(binary, await run.ended, run.stderr()),
136
+ message: ended === undefined
137
+ ? `the ${binary} CLI did not stop after forced termination`
138
+ : endedWithout(binary, ended, run.stderr()),
136
139
  });
137
140
  }
138
141
  return {
@@ -1,7 +1,10 @@
1
+ import { mkdtempSync, rmSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
1
4
  import { z } from "zod";
2
5
  import { redact } from "../../kernel/log.js";
3
6
  import { providerError } from "../../kernel/ports/model.js";
4
- import { cliEvent, cliShaped, endedWithout, promptOf } from "./run-cli.js";
7
+ import { cliEvent, cliShaped, endedWithout, promptOf, stopCliRun } from "./run-cli.js";
5
8
  import { lines } from "./sse-lines.js";
6
9
  // The local-agent adapter for the Codex CLI. Same shape as Claude Code's and a different
7
10
  // vocabulary: Codex writes a JSONL thread of `thread.started`, `item.*` and `turn.*`
@@ -10,19 +13,73 @@ export const codexBinary = "codex";
10
13
  // Codex publishes its account-specific picker in models_cache.json. With no
11
14
  // cache, offer custom entry rather than pretending a pinned model is current.
12
15
  export const codexModels = [];
13
- // `codex exec --help` (0.149.1) for the flags. `-c web_search=<mode>` is a TOML override, and
14
- // the binary's own error names the modes: "unknown variant `bogus`, expected one of `disabled`,
15
- // `cached`, `indexed`, `live`". `live` is the grounded mode research asks for and `disabled` is
16
- // what every other stage runs under, so none grounds itself by accident. `--ephemeral` keeps no
17
- // session file, `--skip-git-repo-check` lets it run in the data directory, and the quotes in
18
- // the value are part of the argv element because the override is parsed as TOML, where a bare
19
- // `live` is not a string.
20
- export function codexArgs(req) {
16
+ const writingRole = "You are the writing and research component of Slopify, a video creation app. " +
17
+ "Produce the text requested in the supplied conversation: articles, narration scripts, " +
18
+ "research notes or image prompts. Follow the requested topic, language, tone, length and format. " +
19
+ "Return only the requested content, without progress updates or introductory remarks. " +
20
+ "Use web search when it is available and needed for the requested research. " +
21
+ "Do not inspect or modify local files.";
22
+ // Codex is a coding agent by default, while this adapter is a content provider. These supported
23
+ // feature gates remove every local or account-connected tool exposed by 0.149.1. Native web
24
+ // search is controlled separately below, so grounded research still works without a shell.
25
+ const disabledFeatures = [
26
+ "shell_tool",
27
+ "unified_exec",
28
+ "code_mode_host",
29
+ "hooks",
30
+ "apps",
31
+ "plugins",
32
+ "remote_plugin",
33
+ "skill_search",
34
+ "multi_agent",
35
+ "computer_use",
36
+ "browser_use",
37
+ "image_generation",
38
+ "view_image",
39
+ "workspace_dependencies",
40
+ ];
41
+ // `codex exec --help` (0.149.1) for the flags. `--ignore-user-config` retains authentication
42
+ // while excluding config.toml, `--ignore-rules` excludes user/project exec policy, and strict
43
+ // config makes an older CLI fail closed if it cannot apply a hardening override. A private cwd
44
+ // plus a zero project-doc budget excludes AGENTS.md and project `.codex` context. Read-only is
45
+ // defense in depth if a future release exposes a new filesystem tool. The TOML strings keep their
46
+ // quotes because each `-c` value is parsed as TOML rather than as a shell expression.
47
+ export function codexArgs(req, directory) {
21
48
  return [
22
49
  "exec",
23
50
  "--json",
24
51
  "--ephemeral",
52
+ "--ignore-user-config",
53
+ "--ignore-rules",
54
+ "--strict-config",
25
55
  "--skip-git-repo-check",
56
+ "--sandbox",
57
+ "read-only",
58
+ "--cd",
59
+ directory,
60
+ ...disabledFeatures.flatMap((feature) => ["--disable", feature]),
61
+ "-c",
62
+ "project_doc_max_bytes=0",
63
+ "-c",
64
+ "skills.include_instructions=false",
65
+ "-c",
66
+ "skills.bundled.enabled=false",
67
+ "-c",
68
+ "orchestrator.skills.enabled=false",
69
+ "-c",
70
+ "orchestrator.mcp.enabled=false",
71
+ "-c",
72
+ "include_permissions_instructions=false",
73
+ "-c",
74
+ "include_apps_instructions=false",
75
+ "-c",
76
+ "include_collaboration_mode_instructions=false",
77
+ "-c",
78
+ "include_environment_context=false",
79
+ "-c",
80
+ 'shell_environment_policy.inherit="none"',
81
+ "-c",
82
+ `instructions=${JSON.stringify(writingRole)}`,
26
83
  "-c",
27
84
  `web_search="${req.webSearch === true ? "live" : "disabled"}"`,
28
85
  ...(req.thinkingConfig?.effort
@@ -49,8 +106,12 @@ const errorEvent = z.object({ message: z.string() });
49
106
  export function codexLlm(deps) {
50
107
  const binary = deps.binary ?? codexBinary;
51
108
  async function* complete(req) {
52
- const run = deps.run(binary, codexArgs(req), req.signal);
109
+ req.signal.throwIfAborted();
110
+ const workspace = codexWorkspace();
111
+ let run;
112
+ let ended;
53
113
  try {
114
+ run = deps.run(binary, codexArgs(req, workspace.directory), req.signal, workspace.options);
54
115
  for await (const line of lines(run.stdout, req.signal)) {
55
116
  if (line.trim() === "") {
56
117
  continue;
@@ -96,7 +157,13 @@ export function codexLlm(deps) {
96
157
  throw error;
97
158
  }
98
159
  finally {
99
- run.kill();
160
+ try {
161
+ if (run !== undefined)
162
+ ended = await stopCliRun(run);
163
+ }
164
+ finally {
165
+ workspace.remove();
166
+ }
100
167
  }
101
168
  // A cancelled run ends its stream the same way an exhausted one does: the child was
102
169
  // killed, so stdout simply stopped. An aborted call counts as nothing, so it must not
@@ -105,7 +172,9 @@ export function codexLlm(deps) {
105
172
  // The stream ended with neither a completed turn nor a failure.
106
173
  throw providerError({
107
174
  kind: "other",
108
- message: endedWithout(binary, await run.ended, run.stderr()),
175
+ message: ended === undefined
176
+ ? `the ${binary} CLI did not stop after forced termination`
177
+ : endedWithout(binary, ended, run.stderr()),
109
178
  });
110
179
  }
111
180
  return {
@@ -116,6 +185,27 @@ export function codexLlm(deps) {
116
185
  complete,
117
186
  };
118
187
  }
188
+ function codexWorkspace() {
189
+ const directory = mkdtempSync(join(tmpdir(), "slopify-codex-"));
190
+ // npm sets INIT_CWD to the directory from which the app was launched. Do not hand that path,
191
+ // an old shell directory or Git's explicit worktree pointers to the content subprocess.
192
+ const env = { ...process.env };
193
+ delete env.INIT_CWD;
194
+ delete env.OLDPWD;
195
+ delete env.GIT_DIR;
196
+ delete env.GIT_WORK_TREE;
197
+ env.PWD = directory;
198
+ return {
199
+ directory,
200
+ options: { cwd: directory, env },
201
+ remove: () => rmSync(directory, {
202
+ recursive: true,
203
+ force: true,
204
+ maxRetries: 3,
205
+ retryDelay: 50,
206
+ }),
207
+ };
208
+ }
119
209
  function usageOf(usage) {
120
210
  return usage === null || usage === undefined
121
211
  ? null
@@ -3,7 +3,7 @@ import { redact } from "../../kernel/log.js";
3
3
  import { providerError } from "../../kernel/ports/model.js";
4
4
  import { geminiModels } from "./gemini-models.js";
5
5
  import { geminiWorkspace } from "./gemini-workspace.js";
6
- import { cliEvent, cliShaped, endedWithout, promptOf } from "./run-cli.js";
6
+ import { cliEvent, cliShaped, endedWithout, promptOf, stopCliRun } from "./run-cli.js";
7
7
  import { lines } from "./sse-lines.js";
8
8
  export const geminiBinary = "gemini";
9
9
  export { geminiModels } from "./gemini-models.js";
@@ -45,6 +45,7 @@ export function geminiLlm(deps) {
45
45
  req.signal.throwIfAborted();
46
46
  const workspace = geminiWorkspace(req.webSearch === true, req);
47
47
  let run;
48
+ let ended;
48
49
  try {
49
50
  run = deps.run(binary, geminiArgs(req, workspace.mcpAllowlist), req.signal, workspace.options);
50
51
  for await (const line of lines(authChecked(run.stdout), req.signal)) {
@@ -80,22 +81,24 @@ export function geminiLlm(deps) {
80
81
  return;
81
82
  }
82
83
  }
83
- req.signal.throwIfAborted();
84
- const ended = await run.ended;
85
- if (authRequired(run.stderr()))
86
- throw authFailure();
87
- throw failure(endedWithout(binary, ended, run.stderr()));
88
84
  }
89
85
  catch (error) {
90
86
  req.signal.throwIfAborted();
91
87
  throw error;
92
88
  }
93
89
  finally {
94
- run?.kill();
95
90
  if (run !== undefined)
96
- await run.ended;
91
+ ended = await stopCliRun(run);
97
92
  workspace.remove();
98
93
  }
94
+ req.signal.throwIfAborted();
95
+ if (run === undefined)
96
+ throw failure(`the ${binary} CLI could not be started`);
97
+ if (authRequired(run.stderr()))
98
+ throw authFailure();
99
+ throw failure(ended === undefined
100
+ ? `the ${binary} CLI did not stop after forced termination`
101
+ : endedWithout(binary, ended, run.stderr()));
99
102
  }
100
103
  return {
101
104
  id: "gemini",
@@ -8,6 +8,7 @@ import { providerError } from "../../kernel/ports/model.js";
8
8
  // says why it stopped. Streaming stderr to the log is the upgrade if a provider ever
9
9
  // buries its reason in the first line of a long report.
10
10
  export const stderrMax = 8192;
11
+ export const cliTerminationGraceMs = 1_000;
11
12
  export function nodeRunCli(binary, args, signal, options) {
12
13
  // An argument array, never a shell string: a prompt carrying backticks, `$(...)`, quotes or
13
14
  // newlines is one argv element and nothing in it can become a command. stdin is /dev/null
@@ -16,9 +17,10 @@ export function nodeRunCli(binary, args, signal, options) {
16
17
  // SIGTERM when the stage is cancelled, so no agent session outlives the run that started it.
17
18
  const command = cliCommand(binary);
18
19
  const child = spawn(command.file, [...command.args, ...args], {
19
- signal,
20
20
  stdio: ["ignore", "pipe", "pipe"],
21
21
  windowsHide: true,
22
+ // A private POSIX process group lets force cleanup include helpers spawned by a CLI.
23
+ detached: process.platform !== "win32",
22
24
  ...options,
23
25
  });
24
26
  let stderr = "";
@@ -36,25 +38,78 @@ export function nodeRunCli(binary, args, signal, options) {
36
38
  child.on("error", (error) => {
37
39
  spawnError = error;
38
40
  });
41
+ let settled = false;
39
42
  const ended = new Promise((resolve) => {
40
43
  const settle = (code) => {
44
+ settled = true;
41
45
  resolve({ code, error: spawnError });
42
46
  };
43
47
  child.once("close", settle);
44
48
  // Close follows an error too; waiting for it keeps cleanup behind process exit.
45
49
  });
50
+ const terminate = (force = false) => {
51
+ if (settled)
52
+ return;
53
+ const pid = child.pid;
54
+ if (process.platform === "win32") {
55
+ if (force && pid !== undefined) {
56
+ const killer = spawn("taskkill", ["/pid", String(pid), "/t", "/f"], {
57
+ stdio: "ignore",
58
+ windowsHide: true,
59
+ });
60
+ killer.on("error", () => { });
61
+ killer.unref();
62
+ }
63
+ child.kill(force ? "SIGKILL" : "SIGTERM");
64
+ }
65
+ else if (pid !== undefined) {
66
+ try {
67
+ process.kill(-pid, force ? "SIGKILL" : "SIGTERM");
68
+ }
69
+ catch {
70
+ child.kill(force ? "SIGKILL" : "SIGTERM");
71
+ }
72
+ }
73
+ if (force) {
74
+ child.stdout?.destroy();
75
+ child.stderr?.destroy();
76
+ }
77
+ };
78
+ const abort = () => terminate();
79
+ if (signal.aborted)
80
+ abort();
81
+ else
82
+ signal.addEventListener("abort", abort, { once: true });
83
+ void ended.then(() => signal.removeEventListener("abort", abort));
46
84
  return {
47
85
  pid: child.pid,
48
86
  stdout: child.stdout === null ? nothing : bytesOf(child.stdout),
49
87
  stderr: () => stderr,
50
88
  ended,
51
- kill: () => {
52
- if (child.exitCode === null && child.signalCode === null) {
53
- child.kill();
54
- }
55
- },
89
+ kill: terminate,
56
90
  };
57
91
  }
92
+ /** Stop a CLI without letting a resistant process wedge pause, cancel, or app shutdown. */
93
+ export async function stopCliRun(run, graceMs = cliTerminationGraceMs, forceMs = cliTerminationGraceMs) {
94
+ run.kill();
95
+ const graceful = await endedWithin(run.ended, graceMs);
96
+ if (graceful !== undefined)
97
+ return graceful;
98
+ run.kill(true);
99
+ return endedWithin(run.ended, forceMs);
100
+ }
101
+ async function endedWithin(ended, timeoutMs) {
102
+ let timer;
103
+ const result = await Promise.race([
104
+ ended,
105
+ new Promise((resolve) => {
106
+ timer = setTimeout(() => resolve(undefined), timeoutMs);
107
+ }),
108
+ ]);
109
+ if (timer !== undefined)
110
+ clearTimeout(timer);
111
+ return result;
112
+ }
58
113
  // ceiling: both CLIs take one prompt string, so a system or assistant turn is flattened
59
114
  // into it under a header. Claude Code's `--append-system-prompt` is the upgrade if a stage
60
115
  // ever needs a real system turn; today research and the article each compose one message.
@@ -13,16 +13,16 @@ const operationSchema = z.object({
13
13
  // A continuation survives automatic retries of this call; pausing/restarting a
14
14
  // stage stops local polling but cannot cancel an already accepted remote job.
15
15
  export async function* inworldAsync(deps, request, key) {
16
- if (request.text.length > 100_000) {
16
+ const saved = request.continuation?.read();
17
+ if (saved !== undefined && !operationName.test(saved))
18
+ throw invalid("returned an invalid operation name");
19
+ if (saved === undefined && request.text.length > 100_000) {
17
20
  throw providerError({
18
21
  kind: "unsupported",
19
22
  message: "Inworld async accepts up to 100,000 characters per request (10,000 for On-Demand accounts). Select paragraph chunking for longer articles.",
20
23
  });
21
24
  }
22
25
  const headers = { Authorization: `Basic ${key}`, "Content-Type": "application/json" };
23
- const saved = request.continuation?.read();
24
- if (saved && !operationName.test(saved))
25
- throw invalid("returned an invalid operation name");
26
26
  request.signal.throwIfAborted();
27
27
  let response = await deps.fetch(saved
28
28
  ? `https://api.inworld.ai/lro/v1alpha/${saved}`