@gentbajko/slopify 1.2.0 → 1.3.1

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 (61) hide show
  1. package/README.md +12 -2
  2. package/dist/adapter-registry.js +10 -2
  3. package/dist/adapters/image/codex.js +230 -0
  4. package/dist/adapters/llm/claude-code-models.js +24 -13
  5. package/dist/adapters/llm/codex-models.js +12 -5
  6. package/dist/adapters/llm/codex-server-models.js +150 -0
  7. package/dist/adapters/llm/codex.js +1 -1
  8. package/dist/adapters/llm/gemini-models.js +23 -19
  9. package/dist/catalog/validate.js +3 -1
  10. package/dist/edge/cli.js +17 -0
  11. package/dist/kernel/db/migrations/0011-narration-prompts.sql +13 -0
  12. package/dist/slices/admission/rules.js +17 -1
  13. package/dist/slices/admission/schema.js +1 -0
  14. package/dist/slices/estimate/index.js +32 -1
  15. package/dist/slices/estimate/requests.js +8 -0
  16. package/dist/slices/library/model.js +2 -3
  17. package/dist/slices/library/slots.js +3 -1
  18. package/dist/slices/narration/preparation.js +75 -0
  19. package/dist/slices/narration/steering.js +89 -0
  20. package/dist/slices/play-drafts/convert.js +3 -0
  21. package/dist/slices/play-drafts/readiness.js +3 -1
  22. package/dist/slices/play-drafts/schema.js +1 -0
  23. package/dist/slices/project-templates/from-project.js +2 -0
  24. package/dist/slices/project-templates/setup.js +1 -0
  25. package/dist/slices/rebuild/admission-repo.js +2 -2
  26. package/dist/slices/rebuild/preview-details.js +12 -9
  27. package/dist/slices/rebuild/preview-plan.js +7 -1
  28. package/dist/slices/rebuild/recipe-audio-parts.js +89 -0
  29. package/dist/slices/rebuild/recipe-audio.js +52 -66
  30. package/dist/slices/rebuild/recipe-input-schema.js +11 -0
  31. package/dist/slices/rebuild/recipe-legacy.js +5 -0
  32. package/dist/slices/rebuild/recipe-model.js +3 -0
  33. package/dist/slices/rebuild/recipe-narration-text.js +33 -0
  34. package/dist/slices/rebuild/recipe-preparation.js +66 -0
  35. package/dist/slices/rebuild/recipe-provider-choice.js +7 -5
  36. package/dist/slices/rebuild/recipe-validation.js +9 -1
  37. package/dist/slices/rebuild/recipe-work.js +13 -1
  38. package/dist/slices/rebuild/runtime-admission.js +11 -0
  39. package/dist/slices/rebuild/runtime-export-inputs.js +4 -0
  40. package/dist/slices/rebuild/runtime-local.js +5 -0
  41. package/dist/slices/rebuild/runtime-materialize.js +2 -0
  42. package/dist/slices/rebuild/runtime-narration-reuse.js +1 -0
  43. package/dist/slices/rebuild/runtime-narration-text.js +75 -0
  44. package/dist/slices/rebuild/runtime-provider.js +30 -1
  45. package/dist/slices/rebuild/runtime-publication.js +13 -1
  46. package/dist/slices/rebuild/runtime-store.js +13 -2
  47. package/dist/slices/revisions/rules.js +5 -3
  48. package/dist/slices/settings/cli-paths.js +10 -6
  49. package/dist/slices/settings/cli-status.js +1 -1
  50. package/dist/slices/settings/model.js +9 -0
  51. package/dist/slices/settings/models.js +3 -3
  52. package/dist/slices/settings/readiness.js +9 -2
  53. package/dist/slices/storage/asset-name.js +3 -0
  54. package/dist/slices/storage/layout.js +4 -0
  55. package/dist/slices/storage/model.js +2 -0
  56. package/dist/slices/storage/schema.js +1 -0
  57. package/dist/web/assets/index-DsumGtv6.js +137 -0
  58. package/dist/web/index.html +1 -1
  59. package/package.json +2 -1
  60. package/scripts/docker-run.sh +113 -0
  61. package/dist/web/assets/index-DwM-Y6nw.js +0 -137
package/README.md CHANGED
@@ -30,6 +30,14 @@ slopify
30
30
 
31
31
  Both commands launch the same local app.
32
32
 
33
+ For Docker on Linux, use `slopify --docker` or
34
+ `npx @gentbajko/slopify@latest --docker`. The launcher detects installed Codex,
35
+ Claude Code, and Gemini CLIs and mounts them read-only. It runs in the background,
36
+ restarts with Docker, and keeps your data in `slopify-data`. Run it again after
37
+ upgrading a CLI to refresh its mounts. Add `--port 7070` to change the default port.
38
+ CLI credentials stay separate; sign in once inside the container. See the
39
+ [Docker setup guide](https://github.com/GentBajko/slopify#docker) for login commands.
40
+
33
41
  ## How to use it
34
42
 
35
43
  1. Add provider keys and voices in Settings. They stay in your local Slopify database.
@@ -72,6 +80,7 @@ for account limits. Both model IDs are bundled; Inworld's LLM catalogue does not
72
80
  | `--host` | `SLOPIFY_HOST` | `127.0.0.1` |
73
81
  | `--data-dir` | `SLOPIFY_DATA_DIR` | `~/.slopify` |
74
82
  | `--no-open` | `SLOPIFY_NO_OPEN` | the browser opens |
83
+ | `--docker` | — | run locally; with the flag, launch Docker with host CLI detection |
75
84
  | — | `SLOPIFY_FFMPEG` | the bundled binary |
76
85
 
77
86
  There is no login. Binding to anything but `127.0.0.1` hands the app and every key in
@@ -82,8 +91,9 @@ Everything lives in one SQLite file and one directory tree under the data direct
82
91
 
83
92
  ## ffmpeg and the GPL
84
93
 
85
- Slopify renders video with ffmpeg. The `ffmpeg-static` dependency downloads a
86
- prebuilt binary to `node_modules/ffmpeg-static/` at install time.
94
+ No separate FFmpeg installation is required. The `ffmpeg-static` dependency downloads
95
+ a prebuilt binary to `node_modules/ffmpeg-static/` at install time. Docker includes
96
+ the checked binary, licence and source notice in the image, ready on first launch.
87
97
 
88
98
  Slopify checks that ffmpeg runs before starting. If the install-time download is
89
99
  missing, it downloads the same platform build into `<data-dir>/bin/`, keeping the
@@ -1,3 +1,4 @@
1
+ import { codexImage } from "./adapters/image/codex.js";
1
2
  import { falImage } from "./adapters/image/fal.js";
2
3
  import { googleImage } from "./adapters/image/google.js";
3
4
  import { openAiImage } from "./adapters/image/openai.js";
@@ -36,7 +37,13 @@ export function buildRegistry(deps) {
36
37
  readModels: () => nodeClaudeCodeModels(cliBinary(deps.db, "claude-code")),
37
38
  }),
38
39
  ],
39
- ["codex", codexLlm({ run: cliFor("codex"), readModels: () => nodeCodexModels() })],
40
+ [
41
+ "codex",
42
+ codexLlm({
43
+ run: cliFor("codex"),
44
+ readModels: () => nodeCodexModels(process.env, cliBinary(deps.db, "codex")),
45
+ }),
46
+ ],
40
47
  [
41
48
  "gemini",
42
49
  geminiLlm({
@@ -54,7 +61,7 @@ export function buildRegistry(deps) {
54
61
  ["cartesia", cartesiaTts({ fetch: deps.fetch, key: keyOf("cartesia") })],
55
62
  ["inworld", inworldTts({ fetch: deps.fetch, key: keyOf("inworld"), clock: deps.clock })],
56
63
  ]);
57
- // Four image providers behind one port, each handed the reader for its own key row.
64
+ // Keyed image providers receive only their own key; Codex uses the shared CLI login.
58
65
  // Replicate also takes the clock: `Prefer: wait` gives up after 60 s and the prediction has
59
66
  // to be polled, and the wait is spent on the app's clock so a test never sits through one.
60
67
  const images = new Map([
@@ -65,6 +72,7 @@ export function buildRegistry(deps) {
65
72
  ],
66
73
  ["openai-image", openAiImage({ fetch: deps.fetch, key: keyOf("openai-image") })],
67
74
  ["google-image", googleImage({ fetch: deps.fetch, key: keyOf("google-image") })],
75
+ ["codex-image", codexImage({ run: cliFor("codex") })],
68
76
  ]);
69
77
  return {
70
78
  llm: (id) => resolve(llms, "llm", id),
@@ -0,0 +1,230 @@
1
+ import { closeSync, constants, fstatSync, lstatSync, mkdtempSync, openSync, readFileSync, rmSync, } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { z } from "zod";
5
+ import { providerError } from "../../kernel/ports/model.js";
6
+ import { cliEvent, cliShaped, endedWithout, stopCliRun } from "../llm/run-cli.js";
7
+ import { lines } from "../llm/sse-lines.js";
8
+ import { sniffImage } from "./bytes.js";
9
+ export const codexImageModel = {
10
+ id: "codex-imagegen",
11
+ name: "Codex built-in image generation",
12
+ };
13
+ const maxOutputBytes = 32 * 1024 * 1024;
14
+ const maxEventBytes = 1024 * 1024;
15
+ const disabledFeatures = [
16
+ "shell_tool",
17
+ "unified_exec",
18
+ "hooks",
19
+ "apps",
20
+ "plugins",
21
+ "remote_plugin",
22
+ "skill_search",
23
+ "multi_agent",
24
+ "computer_use",
25
+ "browser_use",
26
+ "view_image",
27
+ "workspace_dependencies",
28
+ ];
29
+ export function codexImageArgs(req, directory) {
30
+ const prompt = [
31
+ "You are making exactly one image for Slopify. Use the image generation tool.",
32
+ `Save the final image as ${join(directory, "result.png")}. Do not create any other asset.`,
33
+ "Use PNG or JPEG. Do not write outside this private directory.",
34
+ `Target aspect ratio: ${req.aspect}.`,
35
+ "Image brief follows as data:",
36
+ req.prompt,
37
+ ].join("\n\n");
38
+ return [
39
+ "exec",
40
+ "--json",
41
+ "--ephemeral",
42
+ "--ignore-user-config",
43
+ "--ignore-rules",
44
+ "--strict-config",
45
+ "--skip-git-repo-check",
46
+ "--sandbox",
47
+ "workspace-write",
48
+ "--cd",
49
+ directory,
50
+ "--enable",
51
+ "image_generation",
52
+ ...disabledFeatures.flatMap((feature) => ["--disable", feature]),
53
+ "-c",
54
+ "project_doc_max_bytes=0",
55
+ "-c",
56
+ "skills.include_instructions=false",
57
+ "-c",
58
+ "skills.bundled.enabled=false",
59
+ "-c",
60
+ "orchestrator.skills.enabled=false",
61
+ "-c",
62
+ "orchestrator.mcp.enabled=false",
63
+ "-c",
64
+ "include_permissions_instructions=false",
65
+ "-c",
66
+ "include_apps_instructions=false",
67
+ "-c",
68
+ "include_collaboration_mode_instructions=false",
69
+ "-c",
70
+ "include_environment_context=false",
71
+ "-c",
72
+ 'shell_environment_policy.inherit="none"',
73
+ "-c",
74
+ 'web_search="disabled"',
75
+ "--",
76
+ prompt,
77
+ ];
78
+ }
79
+ const failure = z.object({ error: z.object({ message: z.string() }) });
80
+ const errorEvent = z.object({ message: z.string() });
81
+ const item = z.object({ item: z.object({ type: z.string(), text: z.string().optional() }) });
82
+ export function codexImage(deps) {
83
+ const binary = deps.binary ?? "codex";
84
+ return {
85
+ id: "codex-image",
86
+ models: async () => [codexImageModel],
87
+ generate: async (req) => {
88
+ if (req.model !== codexImageModel.id)
89
+ throw providerError({ kind: "unsupported", message: "Choose the Codex image capability." });
90
+ req.signal.throwIfAborted();
91
+ const directory = mkdtempSync(join(tmpdir(), "slopify-codex-image-"));
92
+ let run;
93
+ try {
94
+ try {
95
+ run = deps.run(binary, codexImageArgs(req, directory), req.signal, {
96
+ cwd: directory,
97
+ env: imageEnvironment(directory),
98
+ });
99
+ }
100
+ catch {
101
+ throw providerError({ kind: "unsupported", message: "Could not start the Codex CLI." });
102
+ }
103
+ let completed = false;
104
+ let unavailable = false;
105
+ for await (const line of lines(bounded(run.stdout, maxEventBytes), req.signal)) {
106
+ if (line.trim() === "")
107
+ continue;
108
+ const event = cliEvent(binary, line);
109
+ req.signal.throwIfAborted();
110
+ if (event.type === "turn.completed")
111
+ completed = true;
112
+ else if (event.type === "turn.failed") {
113
+ const message = cliShaped(binary, failure, event.value).error.message;
114
+ throw providerError({
115
+ kind: /refus|content.policy|safety/i.test(message) ? "refusal" : "other",
116
+ message: "Codex image generation did not complete.",
117
+ });
118
+ }
119
+ else if (event.type === "error") {
120
+ const message = cliShaped(binary, errorEvent, event.value).message;
121
+ throw providerError({
122
+ kind: /image.generation|image tool|feature.*unavailable/i.test(message)
123
+ ? "unsupported"
124
+ : "other",
125
+ message: "Codex image generation is unavailable.",
126
+ });
127
+ }
128
+ else if (event.type === "item.completed") {
129
+ const { item: value } = cliShaped(binary, item, event.value);
130
+ if (value.type === "agent_message" &&
131
+ /image.generation.*unavailable|cannot generate images|no image tool/i.test(value.text ?? ""))
132
+ unavailable = true;
133
+ }
134
+ }
135
+ req.signal.throwIfAborted();
136
+ const ended = await run.ended;
137
+ req.signal.throwIfAborted();
138
+ if (ended.error !== null)
139
+ throw providerError({ kind: "unsupported", message: "Could not start the Codex CLI." });
140
+ if (ended.code !== 0 || !completed)
141
+ throw providerError({
142
+ kind: unavailable ? "unsupported" : "other",
143
+ message: endedWithout(binary, ended, run.stderr()),
144
+ });
145
+ if (unavailable)
146
+ throw providerError({
147
+ kind: "unsupported",
148
+ message: "This Codex install cannot generate images.",
149
+ });
150
+ return exactImage(join(directory, "result.png"));
151
+ }
152
+ catch (error) {
153
+ req.signal.throwIfAborted();
154
+ throw error;
155
+ }
156
+ finally {
157
+ try {
158
+ if (run !== undefined)
159
+ await stopCliRun(run);
160
+ }
161
+ finally {
162
+ rmSync(directory, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 });
163
+ }
164
+ }
165
+ },
166
+ };
167
+ }
168
+ async function* bounded(source, maximum) {
169
+ let size = 0;
170
+ for await (const chunk of source) {
171
+ size += chunk.byteLength;
172
+ if (size > maximum)
173
+ throw providerError({
174
+ kind: "other",
175
+ message: "Codex wrote too much image progress output.",
176
+ });
177
+ yield chunk;
178
+ }
179
+ }
180
+ function exactImage(path) {
181
+ const entry = lstatSync(path, { throwIfNoEntry: false });
182
+ if (entry === undefined)
183
+ throw providerError({
184
+ kind: "unsupported",
185
+ message: "Codex did not save an image at the requested path.",
186
+ });
187
+ if (!entry.isFile() || entry.isSymbolicLink() || entry.size === 0 || entry.size > maxOutputBytes)
188
+ throw providerError({ kind: "other", message: "Codex image output was not a safe file." });
189
+ let fd;
190
+ try {
191
+ fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
192
+ const status = fstatSync(fd);
193
+ if (!status.isFile() || status.size === 0 || status.size > maxOutputBytes)
194
+ throw providerError({ kind: "other", message: "Codex image output was not a safe file." });
195
+ const bytes = readFileSync(fd);
196
+ const mime = sniffImage(bytes);
197
+ if (mime === undefined)
198
+ throw providerError({ kind: "other", message: "Codex saved an unsupported image format." });
199
+ return { bytes, mime };
200
+ }
201
+ finally {
202
+ if (fd !== undefined)
203
+ closeSync(fd);
204
+ }
205
+ }
206
+ function imageEnvironment(directory) {
207
+ const env = { PWD: directory };
208
+ const allowed = [
209
+ "HOME",
210
+ "PATH",
211
+ "CODEX_HOME",
212
+ "XDG_CONFIG_HOME",
213
+ "XDG_DATA_HOME",
214
+ "XDG_CACHE_HOME",
215
+ "TMPDIR",
216
+ "TEMP",
217
+ "TMP",
218
+ "LANG",
219
+ "LC_ALL",
220
+ "USER",
221
+ "LOGNAME",
222
+ "SYSTEMROOT",
223
+ "SSL_CERT_FILE",
224
+ "NODE_EXTRA_CA_CERTS",
225
+ ];
226
+ for (const key of allowed)
227
+ if (process.env[key] !== undefined)
228
+ env[key] = process.env[key];
229
+ return env;
230
+ }
@@ -27,6 +27,8 @@ const responseSchema = z.object({
27
27
  value: modelId,
28
28
  resolvedModel: modelId.optional(),
29
29
  displayName: modelName,
30
+ description: z.string().max(4096).optional(),
31
+ disabled: z.boolean().optional(),
30
32
  supportedEffortLevels: z.array(z.string()).max(10).optional(),
31
33
  }))
32
34
  .min(1)
@@ -73,27 +75,36 @@ function choices(event, requestId) {
73
75
  throw unavailable();
74
76
  const models = new Map();
75
77
  for (const row of parsed.data.response.response.models) {
78
+ if (row.disabled)
79
+ continue;
76
80
  const modes = row.supportedEffortLevels?.filter((value) => supportedEfforts.includes(value));
77
81
  const thinking = modes?.length ? { thinkingModes: modes } : {};
78
- if (!models.has(row.value))
79
- models.set(row.value, {
82
+ const target = row.resolvedModel ?? row.value;
83
+ const context = /\[([^\]]+)\]/.exec(row.value)?.[1] ?? /\[([^\]]+)\]/.exec(target)?.[1];
84
+ const key = target.replace(/\[[^\]]+\]/g, "") + (context ? `[${context}]` : "");
85
+ // Default and Opus can resolve to the same model. Keep the named CLI choice,
86
+ // preserving its accepted value (including context modifiers) for generation.
87
+ if (!models.has(key) || models.get(key)?.id === "default")
88
+ models.set(key, {
80
89
  id: row.value,
81
- name: row.resolvedModel === undefined || row.resolvedModel === row.value
82
- ? row.displayName
83
- : `${row.displayName} → ${row.resolvedModel}`,
84
- ...thinking,
85
- });
86
- // Claude advertises stable aliases and their current concrete targets.
87
- // Offer both so the user can choose a moving alias or pin a version.
88
- if (row.resolvedModel !== undefined && !models.has(row.resolvedModel))
89
- models.set(row.resolvedModel, {
90
- id: row.resolvedModel,
91
- name: `${row.resolvedModel} (exact model)`,
90
+ name: claudeModelName(target, row.description, row.displayName) +
91
+ (context ? ` (${context.toUpperCase()} context)` : ""),
92
92
  ...thinking,
93
93
  });
94
94
  }
95
+ if (models.size === 0)
96
+ throw unavailable();
95
97
  return [...models.values()];
96
98
  }
99
+ function claudeModelName(target, description, fallback) {
100
+ const version = /^claude-(opus|sonnet|haiku|fable)-(\d+)(?:-(\d{1,2})(?=-|\[|$))?/i.exec(target);
101
+ if (version) {
102
+ const family = version[1] ?? "";
103
+ return `${family[0]?.toUpperCase()}${family.slice(1)} ${version[2]}${version[3] ? `.${version[3]}` : ""}`;
104
+ }
105
+ const advertised = /\b(?:Opus|Sonnet|Haiku|Fable) \d+(?:\.\d+)?\b/i.exec(description ?? "");
106
+ return advertised?.[0] ?? fallback.replace(/\s*\([^)]*context\)/i, "");
107
+ }
97
108
  // The installed CLI has no model-list command; initialize is a no-prompt
98
109
  // stream-JSON control exchange. Treat protocol drift as unavailable metadata.
99
110
  export async function nodeClaudeCodeModels(binary, timeoutMs = 15_000) {
@@ -2,6 +2,7 @@ import { homedir } from "node:os";
2
2
  import { join } from "node:path";
3
3
  import { z } from "zod";
4
4
  import { readCatalogueFile } from "./catalogue-files.js";
5
+ import { codexModelName, nodeCodexServerModels } from "./codex-server-models.js";
5
6
  const safeText = z
6
7
  .string()
7
8
  .trim()
@@ -22,10 +23,11 @@ const model = z.object({
22
23
  .max(20)
23
24
  .optional(),
24
25
  });
25
- export async function nodeCodexModels(env = process.env) {
26
+ export async function nodeCodexModels(env = process.env, binary = "codex", timeoutMs = 15_000) {
26
27
  try {
27
28
  const directory = env.CODEX_HOME?.trim() || join(homedir(), ".codex");
28
- const source = await readCatalogueFile(join(directory, "models_cache.json"), 8 * 1024 * 1024);
29
+ const path = env.SLOPIFY_CODEX_MODELS_FILE?.trim() || join(directory, "models_cache.json");
30
+ const source = await readCatalogueFile(path, 8 * 1024 * 1024);
29
31
  const parsed = cache.parse(JSON.parse(source));
30
32
  const models = parsed.models
31
33
  .flatMap((entry) => {
@@ -41,7 +43,7 @@ export async function nodeCodexModels(env = process.env) {
41
43
  .filter((level) => ["off", "low", "medium", "high", "xhigh"].includes(level));
42
44
  unique.set(item.slug, {
43
45
  id: item.slug,
44
- name: item.display_name ?? item.slug,
46
+ name: codexModelName(item.display_name ?? item.slug),
45
47
  ...(thinkingModes?.length ? { thinkingModes } : {}),
46
48
  });
47
49
  }
@@ -51,7 +53,12 @@ export async function nodeCodexModels(env = process.env) {
51
53
  return [...unique.values()];
52
54
  }
53
55
  catch {
54
- // Do not expose cache contents, home paths or raw JSON errors to the browser.
55
- throw new Error("Codex model metadata is unavailable. Open Codex once to refresh its model list, or enter a custom model ID.");
56
+ try {
57
+ return await nodeCodexServerModels(binary, env, timeoutMs);
58
+ }
59
+ catch {
60
+ // Do not expose cache contents, home paths or raw JSON errors to the browser.
61
+ throw new Error("Codex model metadata is unavailable. Check the CLI and refresh, or enter a custom model ID.");
62
+ }
56
63
  }
57
64
  }
@@ -0,0 +1,150 @@
1
+ import { spawn } from "node:child_process";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { z } from "zod";
6
+ import { cliCommand } from "../../kernel/cli-command.js";
7
+ const text = z
8
+ .string()
9
+ .trim()
10
+ .min(1)
11
+ .max(256)
12
+ .refine((value) => !/[\p{Cc}]/u.test(value));
13
+ const pageSchema = z.object({
14
+ data: z
15
+ .array(z.object({
16
+ model: text.regex(/^\S+$/),
17
+ displayName: text,
18
+ hidden: z.boolean().optional(),
19
+ supportedReasoningEfforts: z
20
+ .array(z.object({ reasoningEffort: z.string() }))
21
+ .max(20)
22
+ .optional(),
23
+ }))
24
+ .max(1000),
25
+ nextCursor: z.string().max(4096).nullish(),
26
+ });
27
+ // model/list is a metadata-only app-server exchange: no thread, turn or tool is started.
28
+ export async function nodeCodexServerModels(binary, env, timeoutMs = 15_000) {
29
+ const command = cliCommand(binary);
30
+ const directory = await mkdtemp(join(tmpdir(), "slopify-codex-models-"));
31
+ const child = spawn(command.file, [...command.args, "app-server", "--listen", "stdio://"], {
32
+ cwd: directory,
33
+ env: { ...env, PWD: directory },
34
+ stdio: ["pipe", "pipe", "pipe"],
35
+ windowsHide: true,
36
+ detached: process.platform !== "win32",
37
+ });
38
+ child.on("error", () => { });
39
+ child.stdin.on("error", () => { });
40
+ child.stderr.resume();
41
+ const closed = new Promise((resolve) => child.once("close", () => resolve()));
42
+ function terminate(signal) {
43
+ if (process.platform !== "win32" && child.pid !== undefined) {
44
+ try {
45
+ process.kill(-child.pid, signal);
46
+ return;
47
+ }
48
+ catch {
49
+ /* The process may already have exited. */
50
+ }
51
+ }
52
+ child.kill(signal);
53
+ }
54
+ const timer = setTimeout(() => terminate("SIGKILL"), timeoutMs);
55
+ const send = (message) => child.stdin.write(`${JSON.stringify(message)}\n`);
56
+ let expectedId = 1;
57
+ let pages = 0;
58
+ let bytes = 0;
59
+ let buffer = "";
60
+ const models = new Map();
61
+ const cursors = new Set();
62
+ try {
63
+ send({
64
+ id: expectedId,
65
+ method: "initialize",
66
+ params: {
67
+ clientInfo: { name: "slopify_model_discovery", version: "1.0" },
68
+ },
69
+ });
70
+ child.stdout.setEncoding("utf8");
71
+ for await (const piece of child.stdout) {
72
+ bytes += Buffer.byteLength(piece);
73
+ if (bytes > 4 * 1024 * 1024)
74
+ throw new Error("Oversized metadata");
75
+ buffer += piece;
76
+ let newline = buffer.indexOf("\n");
77
+ while (newline >= 0) {
78
+ const line = buffer.slice(0, newline).trim();
79
+ buffer = buffer.slice(newline + 1);
80
+ newline = buffer.indexOf("\n");
81
+ if (line === "")
82
+ continue;
83
+ const event = z
84
+ .object({
85
+ id: z.unknown().optional(),
86
+ result: z.unknown().optional(),
87
+ error: z.unknown().optional(),
88
+ })
89
+ .parse(JSON.parse(line));
90
+ if (event.id !== expectedId)
91
+ continue;
92
+ if (event.error !== undefined)
93
+ throw new Error("Discovery refused");
94
+ let cursor;
95
+ if (expectedId === 1) {
96
+ send({ method: "initialized" });
97
+ }
98
+ else {
99
+ const page = pageSchema.parse(event.result);
100
+ for (const row of page.data) {
101
+ if (row.hidden === true || models.has(row.model))
102
+ continue;
103
+ const thinkingModes = row.supportedReasoningEfforts
104
+ ?.map((item) => item.reasoningEffort)
105
+ .filter((effort) => ["off", "low", "medium", "high", "xhigh"].includes(effort));
106
+ models.set(row.model, {
107
+ id: row.model,
108
+ name: codexModelName(row.displayName),
109
+ ...(thinkingModes?.length ? { thinkingModes } : {}),
110
+ });
111
+ }
112
+ cursor = page.nextCursor;
113
+ if (!cursor) {
114
+ if (models.size === 0)
115
+ throw new Error("No visible models");
116
+ return [...models.values()];
117
+ }
118
+ if (cursors.has(cursor) || ++pages >= 20)
119
+ throw new Error("Invalid pagination");
120
+ cursors.add(cursor);
121
+ }
122
+ send({
123
+ id: ++expectedId,
124
+ method: "model/list",
125
+ params: { limit: 100, includeHidden: false, ...(cursor ? { cursor } : {}) },
126
+ });
127
+ }
128
+ }
129
+ throw new Error("Discovery ended before its reply");
130
+ }
131
+ finally {
132
+ clearTimeout(timer);
133
+ terminate("SIGTERM");
134
+ let grace;
135
+ await Promise.race([
136
+ closed,
137
+ new Promise((resolve) => {
138
+ grace = setTimeout(() => {
139
+ terminate("SIGKILL");
140
+ resolve();
141
+ }, 500);
142
+ }),
143
+ ]);
144
+ clearTimeout(grace);
145
+ await rm(directory, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 });
146
+ }
147
+ }
148
+ export function codexModelName(name) {
149
+ return name.replace(/^(GPT-\d+(?:\.\d+)?)-/i, "$1 ");
150
+ }
@@ -179,7 +179,7 @@ export function codexLlm(deps) {
179
179
  id: "codex",
180
180
  // Prose arrives as whole messages; other JSONL events carry activity separately.
181
181
  capabilities: { streams: true, reportsUsage: true, webSearch: true },
182
- models: deps.readModels ?? (() => nodeCodexModels()),
182
+ models: deps.readModels ?? (() => nodeCodexModels(process.env, binary)),
183
183
  complete,
184
184
  };
185
185
  }
@@ -67,9 +67,6 @@ async function bundledModels(entry) {
67
67
  }
68
68
  function parseGeminiModels(source) {
69
69
  const models = new Map();
70
- let pro = false;
71
- let flash = false;
72
- let flashLite = false;
73
70
  // Read literal model constants only. Never import or execute an
74
71
  // installed package, parse comments as entries, or inspect login/settings.
75
72
  const declarations = /^(?:export\s+)?(?:const|let|var)\s+([A-Z][A-Z0-9_]*)\s*=\s*(['"])([^'"\r\n]+)\2\s*;/gm;
@@ -81,27 +78,34 @@ function parseGeminiModels(source) {
81
78
  id === undefined ||
82
79
  !name.includes("MODEL") ||
83
80
  name.includes("EMBEDDING") ||
84
- !/^(?:gemini|gemma)-[a-z0-9.-]+$/.test(id))
81
+ !/^gemini-\d+(?:\.\d+)?-(?:pro|flash|flash-lite)(?:-preview(?:-\d{2}-\d{2})?)?$/.test(id))
85
82
  continue;
86
- if (!/^(?:(?:PREVIEW|DEFAULT|SECONDARY)_GEMINI_|GEMMA_)/.test(name))
83
+ // SECONDARY and CUSTOM_TOOLS constants are routing aliases, not separate
84
+ // picker choices. Gemma and embedding models use different availability rules.
85
+ // In 0.61+, DEFAULT_FLASH points at a BASE constant while new releases
86
+ // use LATEST constants. Read both literal sources without evaluating JS.
87
+ if (!/^(?:PREVIEW|DEFAULT|BASE|LATEST)_GEMINI_/.test(name) || name.includes("CUSTOM_TOOLS"))
87
88
  continue;
88
- models.set(id, { id, name: id });
89
- if (name.includes("FLASH_LITE") || id.includes("flash-lite"))
90
- flashLite = true;
91
- else if (name.includes("FLASH") || id.includes("flash"))
92
- flash = true;
93
- else if (name.includes("PRO") || id.includes("-pro"))
94
- pro = true;
89
+ const parts = /^gemini-(\d+(?:\.\d+)?)-(pro|flash-lite|flash)/.exec(id);
90
+ if (!parts)
91
+ continue;
92
+ const tier = parts[2] === "pro" ? "Pro" : parts[2] === "flash-lite" ? "Flash-Lite" : "Flash";
93
+ models.set(id, {
94
+ id,
95
+ name: `Gemini ${parts[1]} ${tier}${id.includes("-preview") ? " (Preview)" : ""}`,
96
+ });
95
97
  }
96
98
  if (models.size === 0)
97
99
  return [];
98
- return [
99
- { id: "auto", name: "Gemini Auto (CLI default)" },
100
- ...(pro ? [{ id: "pro", name: "Gemini Pro (CLI alias)" }] : []),
101
- ...(flash ? [{ id: "flash", name: "Gemini Flash (CLI alias)" }] : []),
102
- ...(flashLite ? [{ id: "flash-lite", name: "Gemini Flash-Lite (CLI alias)" }] : []),
103
- ...models.values(),
104
- ];
100
+ const sorted = [...models.values()].sort((a, b) => b.id.localeCompare(a.id, "en", { numeric: true }));
101
+ const tiers = new Set();
102
+ const choices = sorted.map((model) => {
103
+ const tier = /-(pro|flash-lite|flash)(?:-|$)/.exec(model.id)?.[1] ?? model.id;
104
+ const group = tiers.has(tier) ? "Other versions" : "Latest in installed CLI";
105
+ tiers.add(tier);
106
+ return { ...model, group };
107
+ });
108
+ return [{ id: "auto", name: "Automatic (CLI default)" }, ...choices];
105
109
  }
106
110
  async function executablePath(binary) {
107
111
  if (isAbsolute(binary) || /[\\/]/.test(binary))
@@ -1,3 +1,4 @@
1
+ import { usesNarrationPreparation } from "../slices/admission/rules.js";
1
2
  import { isLocalCliProvider } from "../slices/settings/model.js";
2
3
  export function modelFields(draft, catalogue) {
3
4
  if (!catalogue)
@@ -7,7 +8,8 @@ export function modelFields(draft, catalogue) {
7
8
  draft.sources.article === "generate" ||
8
9
  draft.sources.thumbnail === "prompt_by_llm" ||
9
10
  draft.intro?.mode === "llm" ||
10
- draft.outro?.mode === "llm";
11
+ draft.outro?.mode === "llm" ||
12
+ usesNarrationPreparation(draft);
11
13
  const checks = [
12
14
  { field: "llm", family: "llm", choice: draft.llm, needed: needLlm },
13
15
  {