@gentbajko/slopify 0.5.1 → 0.6.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 (64) hide show
  1. package/SUBTITLES.md +21 -0
  2. package/dist/adapter-registry.js +8 -3
  3. package/dist/adapters/alignment/audio.js +45 -0
  4. package/dist/adapters/alignment/cache.js +91 -0
  5. package/dist/adapters/alignment/ctc.js +128 -0
  6. package/dist/adapters/alignment/index.js +34 -0
  7. package/dist/adapters/alignment/lock.js +58 -0
  8. package/dist/adapters/alignment/numbers.js +91 -0
  9. package/dist/adapters/alignment/protocol.js +23 -0
  10. package/dist/adapters/alignment/quality.js +17 -0
  11. package/dist/adapters/alignment/runner.js +76 -0
  12. package/dist/adapters/alignment/text.js +61 -0
  13. package/dist/adapters/alignment/vocabulary.js +69 -0
  14. package/dist/adapters/alignment/worker.js +163 -0
  15. package/dist/adapters/llm/claude-code.js +6 -3
  16. package/dist/adapters/llm/codex.js +3 -2
  17. package/dist/adapters/llm/gemini-workspace.js +70 -0
  18. package/dist/adapters/llm/gemini.js +141 -0
  19. package/dist/adapters/llm/run-cli.js +6 -9
  20. package/dist/assets/fonts/Barlow-Regular.ttf +0 -0
  21. package/dist/assets/fonts/OFL.txt +93 -0
  22. package/dist/assets/fonts/SOURCE.txt +7 -0
  23. package/dist/edge/http/app.js +4 -0
  24. package/dist/edge/http/fonts.js +147 -0
  25. package/dist/edge/http/projects.js +23 -1
  26. package/dist/edge/http/providers.js +13 -0
  27. package/dist/edge/http/subtitles.js +92 -0
  28. package/dist/kernel/cli-command.js +45 -0
  29. package/dist/kernel/ports/subtitles.js +1 -0
  30. package/dist/kernel/runner/providers.js +3 -2
  31. package/dist/main.js +2 -1
  32. package/dist/slices/admission/repo.js +2 -0
  33. package/dist/slices/admission/rules.js +6 -0
  34. package/dist/slices/fonts/catalog.js +95 -0
  35. package/dist/slices/fonts/discovery.js +81 -0
  36. package/dist/slices/fonts/files.js +37 -0
  37. package/dist/slices/fonts/index.js +5 -0
  38. package/dist/slices/fonts/model.js +4 -0
  39. package/dist/slices/fonts/preview.js +14 -0
  40. package/dist/slices/fonts/sfnt.js +262 -0
  41. package/dist/slices/fonts/upload.js +40 -0
  42. package/dist/slices/settings/cli-paths.js +98 -0
  43. package/dist/slices/settings/cli-status.js +21 -12
  44. package/dist/slices/settings/model.js +9 -0
  45. package/dist/slices/settings/readiness.js +13 -9
  46. package/dist/slices/storage/downloads.js +2 -0
  47. package/dist/slices/storage/layout.js +10 -0
  48. package/dist/slices/storage/model.js +5 -0
  49. package/dist/slices/storage/repo.js +1 -0
  50. package/dist/slices/subtitles/captions.js +88 -0
  51. package/dist/slices/subtitles/model.js +18 -0
  52. package/dist/slices/subtitles/prepare.js +162 -0
  53. package/dist/slices/subtitles/transcript.js +34 -0
  54. package/dist/slices/video/audio-export.js +3 -0
  55. package/dist/slices/video/ffmpeg.js +14 -7
  56. package/dist/slices/video/plan.js +4 -4
  57. package/dist/slices/video/run.js +5 -2
  58. package/dist/slices/video/write-export.js +85 -20
  59. package/dist/web/assets/index-CNYs0noB.js +130 -0
  60. package/dist/web/assets/index-Mk0bvBg-.css +1 -0
  61. package/dist/web/index.html +2 -2
  62. package/package.json +4 -2
  63. package/dist/web/assets/index-C1fsrIP5.css +0 -1
  64. package/dist/web/assets/index-DwJ_XTl3.js +0 -81
@@ -0,0 +1,61 @@
1
+ import { cardinal, numberForms } from "./numbers.js";
2
+ // Original display words remain intact; only acoustic matching uses normalized English.
3
+ export function speechWords(text, observed = "") {
4
+ const words = [];
5
+ for (const textWord of text.trim().split(/\s+/)) {
6
+ const forms = wordForms(textWord);
7
+ const spoken = forms.find((form) => form !== "" && ` ${observed} `.includes(` ${form} `)) ?? forms[0] ?? "";
8
+ if (spoken === "") {
9
+ const previous = words.at(-1);
10
+ if (previous !== undefined)
11
+ previous.text += ` ${textWord}`;
12
+ continue;
13
+ }
14
+ words.push({ text: textWord, spoken });
15
+ }
16
+ if (words.length === 0)
17
+ throw new Error("Local subtitles currently require an English transcript with spoken words.");
18
+ return words;
19
+ }
20
+ function wordForms(raw) {
21
+ const plain = raw.normalize("NFKD").replace(/\p{M}/gu, "").replace(/[‘’]/g, "'");
22
+ if (/\p{L}/u.test(plain.replace(/[A-Za-z]/g, "")))
23
+ throw new Error("Local subtitles currently support English speech and Latin-script names only.");
24
+ const core = plain.replace(/^[^\p{L}\p{N}$£€]+|[^\p{L}\p{N}%]+$/gu, "");
25
+ if (core === "")
26
+ return [];
27
+ const currency = core.startsWith("$")
28
+ ? "DOLLARS"
29
+ : core.startsWith("£")
30
+ ? "POUNDS"
31
+ : core.startsWith("€")
32
+ ? "EUROS"
33
+ : "";
34
+ const percent = core.endsWith("%") ? "PERCENT" : "";
35
+ const clean = core.replace(/^[$£€]/, "").replace(/%$/, "");
36
+ let forms;
37
+ if (/^\d[\d,.]*(?:s|st|nd|rd|th)?$/i.test(clean)) {
38
+ forms = numberForms(clean);
39
+ }
40
+ else {
41
+ const expanded = clean
42
+ .replaceAll("&", " AND ")
43
+ .replaceAll("+", " PLUS ")
44
+ .replace(/\d+/g, (digits) => numberForms(digits)[0] ?? digits);
45
+ const normal = expanded
46
+ .toUpperCase()
47
+ .replace(/[^A-Z']/g, " ")
48
+ .replace(/\s+/g, " ")
49
+ .trim();
50
+ forms = /^[A-Z]{2,8}$/.test(clean) ? [normal, clean.split("").join(" ")] : [normal];
51
+ }
52
+ const result = forms.map((form) => [form, currency, percent].filter(Boolean).join(" "));
53
+ const money = /^(\d[\d,]*)\.(\d{2})$/.exec(clean);
54
+ if (currency !== "" && money !== null) {
55
+ const whole = cardinal(Number((money[1] ?? "").replaceAll(",", "")));
56
+ const fraction = cardinal(Number(money[2]));
57
+ const unit = currency === "POUNDS" ? "PENCE" : "CENTS";
58
+ result.push(`${whole} ${currency} AND ${fraction} ${unit}`, `${whole} ${currency} ${fraction}`, `${whole} ${fraction}`);
59
+ }
60
+ return result;
61
+ }
@@ -0,0 +1,69 @@
1
+ // The vocabulary belongs to the pinned wav2vec2-base-960h model.
2
+ export const vocabulary = {
3
+ "<pad>": 0,
4
+ "<s>": 1,
5
+ "</s>": 2,
6
+ "<unk>": 3,
7
+ "|": 4,
8
+ E: 5,
9
+ T: 6,
10
+ A: 7,
11
+ O: 8,
12
+ N: 9,
13
+ I: 10,
14
+ H: 11,
15
+ S: 12,
16
+ R: 13,
17
+ D: 14,
18
+ L: 15,
19
+ U: 16,
20
+ M: 17,
21
+ W: 18,
22
+ C: 19,
23
+ F: 20,
24
+ G: 21,
25
+ Y: 22,
26
+ P: 23,
27
+ B: 24,
28
+ V: 25,
29
+ K: 26,
30
+ "'": 27,
31
+ X: 28,
32
+ J: 29,
33
+ Q: 30,
34
+ Z: 31,
35
+ };
36
+ export const letters = [
37
+ "",
38
+ "",
39
+ "",
40
+ "",
41
+ " ",
42
+ "E",
43
+ "T",
44
+ "A",
45
+ "O",
46
+ "N",
47
+ "I",
48
+ "H",
49
+ "S",
50
+ "R",
51
+ "D",
52
+ "L",
53
+ "U",
54
+ "M",
55
+ "W",
56
+ "C",
57
+ "F",
58
+ "G",
59
+ "Y",
60
+ "P",
61
+ "B",
62
+ "V",
63
+ "K",
64
+ "'",
65
+ "X",
66
+ "J",
67
+ "Q",
68
+ "Z",
69
+ ];
@@ -0,0 +1,163 @@
1
+ import { open, readFile, stat } from "node:fs/promises";
2
+ import * as ort from "onnxruntime-web/wasm";
3
+ import { alignWindow, frameSeconds, mismatch } from "./ctc.js";
4
+ import { workerInput } from "./protocol.js";
5
+ import { agreesWithSpeech } from "./quality.js";
6
+ import { speechWords } from "./text.js";
7
+ import { letters } from "./vocabulary.js";
8
+ const sampleRate = 16000;
9
+ const windowSeconds = 12;
10
+ const overlapSeconds = 2;
11
+ process.once("message", (raw) => {
12
+ const input = workerInput.safeParse(raw);
13
+ if (!input.success) {
14
+ send({ type: "error", message: "The subtitle worker received an invalid request." });
15
+ return;
16
+ }
17
+ run(input.data).then((words) => send({ type: "done", words }), (error) => send({ type: "error", message: error instanceof Error ? error.message : String(error) }));
18
+ });
19
+ function send(message) {
20
+ process.send?.(message);
21
+ }
22
+ async function run(input) {
23
+ ort.env.wasm.numThreads = 1;
24
+ const model = new Uint8Array(await readFile(input.modelPath));
25
+ const session = await ort.InferenceSession.create(model, { executionProviders: ["wasm"] });
26
+ const file = await open(input.pcmPath, "r");
27
+ try {
28
+ const totalSamples = (await stat(input.pcmPath)).size / 4;
29
+ if (!Number.isInteger(totalSamples) || totalSamples < sampleRate / 10)
30
+ throw new Error("The narration is too short to align subtitles.");
31
+ const source = speechWords(input.text);
32
+ const output = [];
33
+ let cursor = 0;
34
+ let sampleAt = 0;
35
+ while (sampleAt < totalSamples && cursor < source.length) {
36
+ const count = Math.min(windowSeconds * sampleRate, totalSamples - sampleAt);
37
+ const buffer = Buffer.alloc(count * 4);
38
+ await file.read(buffer, 0, buffer.length, sampleAt * 4);
39
+ const audio = new Float32Array(buffer.buffer, buffer.byteOffset, count);
40
+ const normalized = normalize(audio);
41
+ if (normalized === undefined) {
42
+ sampleAt += count;
43
+ continue;
44
+ }
45
+ const result = await session.run({
46
+ input_values: new ort.Tensor("float32", normalized, [1, count]),
47
+ });
48
+ const logits = result.logits;
49
+ if (logits === undefined ||
50
+ !(logits.data instanceof Float32Array) ||
51
+ logits.dims.length !== 3 ||
52
+ logits.dims[2] !== 32)
53
+ throw new Error("The local subtitle model returned an unsupported result.");
54
+ const frames = logits.dims[1] ?? 0;
55
+ const observed = greedy(logits.data, frames);
56
+ if (observed.replace(/[^A-Z]/g, "").length === 0) {
57
+ sampleAt += count;
58
+ for (const tensor of Object.values(result))
59
+ tensor.dispose();
60
+ continue;
61
+ }
62
+ const candidate = candidates(source, cursor, observed);
63
+ const finalWindow = sampleAt + count >= totalSamples;
64
+ const complete = finalWindow && cursor + candidate.length === source.length;
65
+ const aligned = alignWindow(logits.data, frames, candidate, complete).words;
66
+ const cutoff = finalWindow ? count / sampleRate : count / sampleRate - overlapSeconds;
67
+ const accepted = aligned.filter((word) => word.end <= cutoff);
68
+ const last = accepted.at(-1);
69
+ if (last === undefined)
70
+ throw new Error(mismatch);
71
+ const heard = greedy(logits.data, Math.min(frames, Math.ceil(last.end / frameSeconds)));
72
+ if (!agreesWithSpeech(candidate
73
+ .slice(0, accepted.length)
74
+ .map((word) => word.spoken)
75
+ .join(" "), heard))
76
+ throw new Error(mismatch);
77
+ const offset = sampleAt / sampleRate;
78
+ output.push(...accepted.map((word) => ({
79
+ ...word,
80
+ start: offset + word.start,
81
+ end: offset + word.end,
82
+ })));
83
+ cursor += accepted.length;
84
+ send({ type: "progress", current: cursor, total: source.length });
85
+ // Keep a small leading silence margin but never re-use speech already captioned.
86
+ sampleAt += Math.max(1, Math.floor(last.end * sampleRate));
87
+ for (const tensor of Object.values(result))
88
+ tensor.dispose();
89
+ }
90
+ if (cursor !== source.length || output.length === 0)
91
+ throw new Error(mismatch);
92
+ // A substantial spoken tail absent from the transcript is a mismatch too.
93
+ while (sampleAt + sampleRate < totalSamples) {
94
+ const count = Math.min(windowSeconds * sampleRate, totalSamples - sampleAt);
95
+ const buffer = Buffer.alloc(count * 4);
96
+ await file.read(buffer, 0, buffer.length, sampleAt * 4);
97
+ const audio = normalize(new Float32Array(buffer.buffer, buffer.byteOffset, count));
98
+ if (audio !== undefined) {
99
+ const result = await session.run({
100
+ input_values: new ort.Tensor("float32", audio, [1, count]),
101
+ });
102
+ const logits = result.logits;
103
+ if (logits !== undefined &&
104
+ logits.data instanceof Float32Array &&
105
+ greedy(logits.data, logits.dims[1] ?? 0).replace(/[^A-Z]/g, "").length > 8)
106
+ throw new Error(mismatch);
107
+ for (const tensor of Object.values(result))
108
+ tensor.dispose();
109
+ }
110
+ sampleAt += count;
111
+ }
112
+ return output;
113
+ }
114
+ finally {
115
+ await file.close();
116
+ await session.release();
117
+ }
118
+ }
119
+ function candidates(source, cursor, observed) {
120
+ const selected = [];
121
+ let length = 0;
122
+ for (let index = cursor; index < source.length; index += 1) {
123
+ const word = source[index];
124
+ if (word === undefined)
125
+ break;
126
+ const normalized = speechWords(word.text, observed)[0];
127
+ if (normalized === undefined)
128
+ continue;
129
+ if (length + normalized.spoken.length + 1 > 900)
130
+ break;
131
+ selected.push(normalized);
132
+ length += normalized.spoken.length + 1;
133
+ }
134
+ return selected;
135
+ }
136
+ function normalize(audio) {
137
+ let mean = 0;
138
+ for (const value of audio)
139
+ mean += value;
140
+ mean /= audio.length;
141
+ let variance = 0;
142
+ for (const value of audio)
143
+ variance += (value - mean) ** 2;
144
+ variance /= audio.length;
145
+ if (variance < 1e-10)
146
+ return undefined;
147
+ const divisor = Math.sqrt(variance + 1e-7);
148
+ return Float32Array.from(audio, (value) => (value - mean) / divisor);
149
+ }
150
+ function greedy(logits, frames) {
151
+ let previous = -1;
152
+ let text = "";
153
+ for (let frame = 0; frame < frames; frame += 1) {
154
+ let best = 0;
155
+ for (let label = 1; label < 32; label += 1)
156
+ if ((logits[frame * 32 + label] ?? -Infinity) > (logits[frame * 32 + best] ?? -Infinity))
157
+ best = label;
158
+ if (best !== previous && best !== 0)
159
+ text += letters[best] ?? "";
160
+ previous = best;
161
+ }
162
+ return text.trim().replace(/\s+/g, " ");
163
+ }
@@ -37,6 +37,7 @@ export function claudeCodeArgs(req) {
37
37
  "stream-json",
38
38
  // stream-json output is refused without it.
39
39
  "--verbose",
40
+ "--include-partial-messages",
40
41
  // `claude --help` 2.1.263: safe mode excludes CLAUDE.md, output styles, skills and
41
42
  // hooks while retaining login and managed policy. --bare would discard OAuth.
42
43
  "--safe-mode",
@@ -82,6 +83,10 @@ export function claudeCodeLlm(deps) {
82
83
  continue;
83
84
  }
84
85
  const event = cliEvent(binary, line);
86
+ // Partial text/thinking and tool events are a heartbeat. Full assistant
87
+ // messages remain the sole prose source so text is never appended twice.
88
+ yield { type: "activity" };
89
+ req.signal.throwIfAborted();
85
90
  if (event.type === "assistant") {
86
91
  for (const block of cliShaped(binary, assistantEvent, event.value).message.content) {
87
92
  // A turn also carries `thinking` and `tool_use` blocks; only the prose is the
@@ -133,9 +138,7 @@ export function claudeCodeLlm(deps) {
133
138
  }
134
139
  return {
135
140
  id: "claude-code",
136
- // Whole assistant turns rather than token deltas, still enough for the idle timeout to
137
- // see life on the stream. ceiling: `--include-partial-messages` would give per-token
138
- // deltas for the streamed article; it is the upgrade when the page needs finer text.
141
+ // Partial messages keep the deadline alive; complete assistant turns supply prose.
139
142
  capabilities: { streams: true, reportsUsage: true, webSearch: true },
140
143
  models: () => Promise.resolve(claudeCodeModels),
141
144
  complete,
@@ -59,6 +59,8 @@ export function codexLlm(deps) {
59
59
  continue;
60
60
  }
61
61
  const event = cliEvent(binary, line);
62
+ yield { type: "activity" };
63
+ req.signal.throwIfAborted();
62
64
  if (event.type === "item.completed") {
63
65
  const { item } = cliShaped(binary, itemCompleted, event.value);
64
66
  // A turn also completes `reasoning`, `web_search`, `command_execution` and
@@ -111,8 +113,7 @@ export function codexLlm(deps) {
111
113
  }
112
114
  return {
113
115
  id: "codex",
114
- // ceiling: one `item.completed` per whole message rather than per token, same as the
115
- // other CLI. Enough for the idle timeout to see life on the stream.
116
+ // Prose arrives as whole messages; other JSONL events carry activity separately.
116
117
  capabilities: { streams: true, reportsUsage: true, webSearch: true },
117
118
  models: () => Promise.resolve(codexModels),
118
119
  complete,
@@ -0,0 +1,70 @@
1
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ // Gemini 0.16 supports stream-json and tools.core, but headless default mode still
5
+ // exposes file-reading tools. Isolate each writing call while retaining ~/.gemini
6
+ // authentication. Nothing is written into the user's settings or project.
7
+ export function geminiWorkspace(webSearch) {
8
+ const directory = mkdtempSync(join(tmpdir(), "slopify-gemini-"));
9
+ try {
10
+ const settings = join(directory, "settings.json");
11
+ const system = join(directory, "writing.md");
12
+ const trust = join(directory, "trusted-folders.json");
13
+ const mcpAllowlist = `slopify-disabled-${directory.split(/[\\/]/).at(-1)}`;
14
+ writeFileSync(settings, JSON.stringify({
15
+ tools: {
16
+ core: webSearch ? ["google_web_search"] : [],
17
+ allowed: webSearch ? ["google_web_search"] : [],
18
+ discoveryCommand: "",
19
+ callCommand: "",
20
+ enableHooks: false,
21
+ },
22
+ mcp: { allowed: [mcpAllowlist], serverCommand: "" },
23
+ context: {
24
+ fileName: "SLOPIFY_NO_CONTEXT.md",
25
+ includeDirectories: [],
26
+ loadMemoryFromIncludeDirectories: false,
27
+ },
28
+ experimental: { codebaseInvestigatorSettings: { enabled: false } },
29
+ hooks: { enabled: false },
30
+ skills: { enabled: false },
31
+ ide: { enabled: false },
32
+ telemetry: { enabled: false },
33
+ privacy: { usageStatisticsEnabled: false },
34
+ advanced: { autoConfigureMemory: false },
35
+ }), { mode: 0o600 });
36
+ writeFileSync(system, "You are the writing and research component of Slopify, a video creation app. " +
37
+ "Write the requested articles, narration, research notes or image prompts. " +
38
+ "Follow the topic, language, tone, length and format. Return only the requested content. " +
39
+ "Use web search only when available and useful. Never read or modify local files. " +
40
+ "A backslash immediately before @ escapes a literal @ in the supplied text.\n", { mode: 0o600 });
41
+ // Gemini CONCAT-merges includeDirectories (even a null value). Reset the
42
+ // whole context object at workspace scope before the system context above.
43
+ // Trust only this new private workspace, leaving the user's trust file untouched.
44
+ mkdirSync(join(directory, ".gemini"), { mode: 0o700 });
45
+ writeFileSync(join(directory, ".gemini", "settings.json"), JSON.stringify({ context: null }), {
46
+ mode: 0o600,
47
+ });
48
+ writeFileSync(trust, JSON.stringify({ [directory]: "TRUST_FOLDER" }), { mode: 0o600 });
49
+ return {
50
+ mcpAllowlist,
51
+ options: {
52
+ cwd: directory,
53
+ env: {
54
+ ...process.env,
55
+ GEMINI_CLI_SYSTEM_SETTINGS_PATH: settings,
56
+ GEMINI_CLI_TRUSTED_FOLDERS_PATH: trust,
57
+ NO_BROWSER: "true",
58
+ GEMINI_SYSTEM_MD: system,
59
+ GEMINI_WRITE_SYSTEM_MD: "false",
60
+ GEMINI_CLI_NO_RELAUNCH: "true",
61
+ },
62
+ },
63
+ remove: () => rmSync(directory, { recursive: true, force: true }),
64
+ };
65
+ }
66
+ catch (error) {
67
+ rmSync(directory, { recursive: true, force: true });
68
+ throw error;
69
+ }
70
+ }
@@ -0,0 +1,141 @@
1
+ import { z } from "zod";
2
+ import { redact } from "../../kernel/log.js";
3
+ import { providerError } from "../../kernel/ports/model.js";
4
+ import { geminiWorkspace } from "./gemini-workspace.js";
5
+ import { cliEvent, cliShaped, endedWithout, promptOf } from "./run-cli.js";
6
+ import { lines } from "./sse-lines.js";
7
+ export const geminiBinary = "gemini";
8
+ export const geminiModels = [
9
+ { id: "gemini-2.5-pro", name: "Gemini 2.5 Pro" },
10
+ { id: "gemini-2.5-flash", name: "Gemini 2.5 Flash" },
11
+ { id: "gemini-2.5-flash-lite", name: "Gemini 2.5 Flash-Lite" },
12
+ ];
13
+ // Verified against installed Gemini CLI 0.16.0's config/nonInteractiveCli sources.
14
+ // Its --allowed-tools flag bypasses approval; tools.core in the isolated settings
15
+ // actually restricts discovery. Never request yolo/auto_edit.
16
+ export function geminiArgs(req, mcpAllowlist) {
17
+ return [
18
+ "--output-format",
19
+ "stream-json",
20
+ "--approval-mode",
21
+ "default",
22
+ "--extensions",
23
+ "none",
24
+ "--allowed-mcp-server-names",
25
+ mcpAllowlist,
26
+ ...(req.webSearch === true ? ["--allowed-tools", "google_web_search"] : []),
27
+ ...(req.model === "" ? [] : ["--model", req.model]),
28
+ "-p",
29
+ // The prefix prevents slash-command dispatch; escaping @ prevents Gemini's
30
+ // input preprocessor from reading files even when read tools are disabled.
31
+ `Produce the requested content from this conversation:\n\n${promptOf(req.messages).replaceAll("@", "\\@")}`,
32
+ ];
33
+ }
34
+ const messageSchema = z.object({
35
+ role: z.string(),
36
+ content: z.string(),
37
+ delta: z.boolean().optional(),
38
+ });
39
+ const errorSchema = z.object({ severity: z.string(), message: z.string() });
40
+ const resultSchema = z.object({
41
+ status: z.string(),
42
+ error: z.object({ message: z.string(), type: z.string().optional() }).optional(),
43
+ stats: z.object({ input_tokens: z.number(), output_tokens: z.number() }).optional(),
44
+ });
45
+ export function geminiLlm(deps) {
46
+ const binary = deps.binary ?? geminiBinary;
47
+ async function* complete(req) {
48
+ req.signal.throwIfAborted();
49
+ const workspace = geminiWorkspace(req.webSearch === true);
50
+ let run;
51
+ try {
52
+ run = deps.run(binary, geminiArgs(req, workspace.mcpAllowlist), req.signal, workspace.options);
53
+ for await (const line of lines(authChecked(run.stdout), req.signal)) {
54
+ if (line.trim() === "")
55
+ continue;
56
+ const event = cliEvent(binary, line);
57
+ yield { type: "activity" };
58
+ req.signal.throwIfAborted();
59
+ if (event.type === "message") {
60
+ const message = cliShaped(binary, messageSchema, event.value);
61
+ if (message.role === "assistant" && message.content !== "")
62
+ yield { type: "delta", text: message.content };
63
+ }
64
+ else if (event.type === "error") {
65
+ const error = cliShaped(binary, errorSchema, event.value);
66
+ if (error.severity !== "warning")
67
+ throw failure(error.message);
68
+ }
69
+ else if (event.type === "result") {
70
+ const result = cliShaped(binary, resultSchema, event.value);
71
+ if (result.status !== "success")
72
+ throw failure(result.error?.message ?? result.status);
73
+ yield {
74
+ type: "done",
75
+ usage: result.stats === undefined
76
+ ? null
77
+ : {
78
+ inputTokens: result.stats.input_tokens,
79
+ outputTokens: result.stats.output_tokens,
80
+ },
81
+ finishReason: null,
82
+ };
83
+ return;
84
+ }
85
+ }
86
+ req.signal.throwIfAborted();
87
+ const ended = await run.ended;
88
+ if (authRequired(run.stderr()))
89
+ throw authFailure();
90
+ throw failure(endedWithout(binary, ended, run.stderr()));
91
+ }
92
+ catch (error) {
93
+ req.signal.throwIfAborted();
94
+ throw error;
95
+ }
96
+ finally {
97
+ run?.kill();
98
+ if (run !== undefined)
99
+ await run.ended;
100
+ workspace.remove();
101
+ }
102
+ }
103
+ return {
104
+ id: "gemini",
105
+ capabilities: { streams: true, reportsUsage: true, webSearch: true },
106
+ models: () => Promise.resolve(geminiModels),
107
+ complete,
108
+ };
109
+ }
110
+ function failure(message) {
111
+ if (/(?:#?3501\b|do not have a valid licen[cs]e of this product)/i.test(message)) {
112
+ return providerError({
113
+ kind: "unsupported",
114
+ message: "Gemini CLI access was denied by Google's license check (#3501). Update Gemini CLI and sign in again. For a managed account, contact your administrator to request a license.",
115
+ });
116
+ }
117
+ return authRequired(message)
118
+ ? authFailure()
119
+ : providerError({ kind: "other", message: redact(message) });
120
+ }
121
+ function authRequired(message) {
122
+ return /Code Assist login required|authorize the application|authorization code|Please set an Auth method|re-authenticate|reauthenticate/i.test(message);
123
+ }
124
+ function authFailure() {
125
+ return providerError({
126
+ kind: "missing_key",
127
+ message: "Gemini CLI needs sign-in. Run gemini in your terminal, complete login, then retry in Slopify.",
128
+ });
129
+ }
130
+ async function* authChecked(source) {
131
+ const decoder = new TextDecoder();
132
+ let tail = "";
133
+ for await (const bytes of source) {
134
+ tail = (tail + decoder.decode(bytes, { stream: true })).slice(-8192);
135
+ // NO_BROWSER prevents opening OAuth. A logged-out CLI instead writes this
136
+ // non-JSON prompt without a newline; fail before lines() waits for user input.
137
+ if (/^(?:Enter the authorization code:|Please visit the following URL to authorize)/m.test(tail))
138
+ throw authFailure();
139
+ yield bytes;
140
+ }
141
+ }
@@ -1,5 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { z } from "zod";
3
+ import { cliCommand } from "../../kernel/cli-command.js";
3
4
  import { redact } from "../../kernel/log.js";
4
5
  import { providerError } from "../../kernel/ports/model.js";
5
6
  // ceiling: the last 8 KiB of stderr is kept. A CLI that writes megabytes of progress there
@@ -7,16 +8,18 @@ import { providerError } from "../../kernel/ports/model.js";
7
8
  // says why it stopped. Streaming stderr to the log is the upgrade if a provider ever
8
9
  // buries its reason in the first line of a long report.
9
10
  export const stderrMax = 8192;
10
- export function nodeRunCli(binary, args, signal) {
11
+ export function nodeRunCli(binary, args, signal, options) {
11
12
  // An argument array, never a shell string: a prompt carrying backticks, `$(...)`, quotes or
12
13
  // newlines is one argv element and nothing in it can become a command. stdin is /dev/null
13
14
  // because `codex exec` appends piped stdin to the prompt, and a pipe nobody closes would
14
15
  // leave it waiting for an EOF that never comes. `signal` is how the child dies: Node sends it
15
16
  // SIGTERM when the stage is cancelled, so no agent session outlives the run that started it.
16
- const child = spawn(binary, [...args], {
17
+ const command = cliCommand(binary);
18
+ const child = spawn(command.file, [...command.args, ...args], {
17
19
  signal,
18
20
  stdio: ["ignore", "pipe", "pipe"],
19
21
  windowsHide: true,
22
+ ...options,
20
23
  });
21
24
  let stderr = "";
22
25
  const errorStream = child.stderr;
@@ -38,13 +41,7 @@ export function nodeRunCli(binary, args, signal) {
38
41
  resolve({ code, error: spawnError });
39
42
  };
40
43
  child.once("close", settle);
41
- // `close` follows `error` when the streams were opened; this is the belt for the case
42
- // where they were not.
43
- child.once("error", () => {
44
- setImmediate(() => {
45
- settle(null);
46
- });
47
- });
44
+ // Close follows an error too; waiting for it keeps cleanup behind process exit.
48
45
  });
49
46
  return {
50
47
  pid: child.pid,