@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.
- package/README.md +12 -2
- package/dist/adapter-registry.js +10 -2
- package/dist/adapters/image/codex.js +230 -0
- package/dist/adapters/llm/claude-code-models.js +24 -13
- package/dist/adapters/llm/codex-models.js +12 -5
- package/dist/adapters/llm/codex-server-models.js +150 -0
- package/dist/adapters/llm/codex.js +1 -1
- package/dist/adapters/llm/gemini-models.js +23 -19
- package/dist/catalog/validate.js +3 -1
- package/dist/edge/cli.js +17 -0
- package/dist/kernel/db/migrations/0011-narration-prompts.sql +13 -0
- package/dist/slices/admission/rules.js +17 -1
- package/dist/slices/admission/schema.js +1 -0
- package/dist/slices/estimate/index.js +32 -1
- package/dist/slices/estimate/requests.js +8 -0
- package/dist/slices/library/model.js +2 -3
- package/dist/slices/library/slots.js +3 -1
- package/dist/slices/narration/preparation.js +75 -0
- package/dist/slices/narration/steering.js +89 -0
- package/dist/slices/play-drafts/convert.js +3 -0
- package/dist/slices/play-drafts/readiness.js +3 -1
- package/dist/slices/play-drafts/schema.js +1 -0
- package/dist/slices/project-templates/from-project.js +2 -0
- package/dist/slices/project-templates/setup.js +1 -0
- package/dist/slices/rebuild/admission-repo.js +2 -2
- package/dist/slices/rebuild/preview-details.js +12 -9
- package/dist/slices/rebuild/preview-plan.js +7 -1
- package/dist/slices/rebuild/recipe-audio-parts.js +89 -0
- package/dist/slices/rebuild/recipe-audio.js +52 -66
- package/dist/slices/rebuild/recipe-input-schema.js +11 -0
- package/dist/slices/rebuild/recipe-legacy.js +5 -0
- package/dist/slices/rebuild/recipe-model.js +3 -0
- package/dist/slices/rebuild/recipe-narration-text.js +33 -0
- package/dist/slices/rebuild/recipe-preparation.js +66 -0
- package/dist/slices/rebuild/recipe-provider-choice.js +7 -5
- package/dist/slices/rebuild/recipe-validation.js +9 -1
- package/dist/slices/rebuild/recipe-work.js +13 -1
- package/dist/slices/rebuild/runtime-admission.js +11 -0
- package/dist/slices/rebuild/runtime-export-inputs.js +4 -0
- package/dist/slices/rebuild/runtime-local.js +5 -0
- package/dist/slices/rebuild/runtime-materialize.js +2 -0
- package/dist/slices/rebuild/runtime-narration-reuse.js +1 -0
- package/dist/slices/rebuild/runtime-narration-text.js +75 -0
- package/dist/slices/rebuild/runtime-provider.js +30 -1
- package/dist/slices/rebuild/runtime-publication.js +13 -1
- package/dist/slices/rebuild/runtime-store.js +13 -2
- package/dist/slices/revisions/rules.js +5 -3
- package/dist/slices/settings/cli-paths.js +10 -6
- package/dist/slices/settings/cli-status.js +1 -1
- package/dist/slices/settings/model.js +9 -0
- package/dist/slices/settings/models.js +3 -3
- package/dist/slices/settings/readiness.js +9 -2
- package/dist/slices/storage/asset-name.js +3 -0
- package/dist/slices/storage/layout.js +4 -0
- package/dist/slices/storage/model.js +2 -0
- package/dist/slices/storage/schema.js +1 -0
- package/dist/web/assets/index-DsumGtv6.js +137 -0
- package/dist/web/index.html +1 -1
- package/package.json +2 -1
- package/scripts/docker-run.sh +113 -0
- 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
|
-
|
|
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
|
package/dist/adapter-registry.js
CHANGED
|
@@ -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
|
-
[
|
|
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
|
-
//
|
|
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
|
-
|
|
79
|
-
|
|
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.
|
|
82
|
-
?
|
|
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
|
|
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
|
-
|
|
55
|
-
|
|
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
|
-
!/^(?:
|
|
81
|
+
!/^gemini-\d+(?:\.\d+)?-(?:pro|flash|flash-lite)(?:-preview(?:-\d{2}-\d{2})?)?$/.test(id))
|
|
85
82
|
continue;
|
|
86
|
-
|
|
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
|
-
|
|
89
|
-
if (
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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))
|
package/dist/catalog/validate.js
CHANGED
|
@@ -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
|
{
|