@gentbajko/slopify 0.6.1 → 0.8.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 (80) hide show
  1. package/README.md +36 -1
  2. package/SUBTITLES.md +3 -3
  3. package/dist/adapter-registry.js +12 -2
  4. package/dist/adapters/image/google.js +12 -15
  5. package/dist/adapters/image/models.js +72 -0
  6. package/dist/adapters/image/openai.js +3 -5
  7. package/dist/adapters/llm/catalogue-files.js +23 -0
  8. package/dist/adapters/llm/claude-code.js +3 -4
  9. package/dist/adapters/llm/codex-models.js +46 -0
  10. package/dist/adapters/llm/codex.js +7 -10
  11. package/dist/adapters/llm/gemini-models.js +85 -0
  12. package/dist/adapters/llm/gemini-workspace.js +24 -1
  13. package/dist/adapters/llm/gemini.js +4 -7
  14. package/dist/adapters/llm/openrouter.js +12 -2
  15. package/dist/adapters/tts/cartesia.js +9 -1
  16. package/dist/adapters/tts/elevenlabs.js +33 -2
  17. package/dist/adapters/tts/inworld-async.js +150 -0
  18. package/dist/adapters/tts/inworld-text.js +32 -0
  19. package/dist/adapters/tts/inworld.js +170 -0
  20. package/dist/adapters/tts/openai.js +31 -4
  21. package/dist/assets/models.yaml +470 -0
  22. package/dist/catalog/registry.js +147 -0
  23. package/dist/catalog/schema.js +91 -0
  24. package/dist/catalog/store.js +90 -0
  25. package/dist/catalog/validate.js +43 -0
  26. package/dist/edge/cli.js +5 -0
  27. package/dist/edge/events/hub.js +9 -2
  28. package/dist/edge/events/preview-cache.js +40 -0
  29. package/dist/edge/http/actions.js +2 -0
  30. package/dist/edge/http/app.js +28 -1
  31. package/dist/edge/http/audio-preview.js +46 -0
  32. package/dist/edge/http/planning.js +101 -0
  33. package/dist/edge/http/projects.js +4 -2
  34. package/dist/edge/http/providers.js +44 -0
  35. package/dist/edge/http/update.js +57 -0
  36. package/dist/edge/update-worker.js +44 -0
  37. package/dist/kernel/audio-preview.js +188 -0
  38. package/dist/kernel/db/migrations/0003-batch-queue.sql +11 -0
  39. package/dist/kernel/ports/llm.js +1 -0
  40. package/dist/kernel/ports/text.js +26 -0
  41. package/dist/kernel/runner/providers.js +80 -28
  42. package/dist/kernel/runner/queue.js +65 -0
  43. package/dist/main.js +142 -27
  44. package/dist/model-catalog.js +37 -0
  45. package/dist/slices/admission/repo.js +6 -1
  46. package/dist/slices/admission/start.js +9 -8
  47. package/dist/slices/article/continuation.js +8 -1
  48. package/dist/slices/article/segments.js +1 -0
  49. package/dist/slices/batch/index.js +82 -0
  50. package/dist/slices/control/index.js +6 -1
  51. package/dist/slices/control/providers.js +16 -1
  52. package/dist/slices/estimate/index.js +81 -0
  53. package/dist/slices/narration/live.js +21 -0
  54. package/dist/slices/narration/plan.js +58 -0
  55. package/dist/slices/narration/run.js +15 -36
  56. package/dist/slices/research/run.js +7 -0
  57. package/dist/slices/settings/model.js +2 -0
  58. package/dist/slices/settings/models.js +89 -0
  59. package/dist/slices/storage/staging.js +2 -1
  60. package/dist/slices/subtitles/captions.js +4 -1
  61. package/dist/slices/subtitles/layout.js +19 -0
  62. package/dist/slices/subtitles/model.js +9 -0
  63. package/dist/slices/subtitles/prepare.js +6 -1
  64. package/dist/slices/thumbnail/run.js +3 -0
  65. package/dist/updater/candidate.js +28 -0
  66. package/dist/updater/forward.js +35 -0
  67. package/dist/updater/install-flow.js +26 -0
  68. package/dist/updater/install.js +50 -0
  69. package/dist/updater/model.js +24 -0
  70. package/dist/updater/plan.js +135 -0
  71. package/dist/updater/readiness.js +15 -0
  72. package/dist/updater/registry.js +19 -0
  73. package/dist/updater/service.js +128 -0
  74. package/dist/updater/worker.js +136 -0
  75. package/dist/web/assets/index-6zz8telY.css +1 -0
  76. package/dist/web/assets/index-CbYEcBOa.js +130 -0
  77. package/dist/web/index.html +2 -2
  78. package/package.json +2 -1
  79. package/dist/web/assets/index-CNYs0noB.js +0 -130
  80. package/dist/web/assets/index-Mk0bvBg-.css +0 -1
@@ -0,0 +1,188 @@
1
+ import { randomUUID } from "node:crypto";
2
+ const inactive = { append: () => { }, complete: () => { }, interrupt: () => { } };
3
+ // A preview is a disposable copy of bytes from the paid call already in flight.
4
+ // It never initiates synthesis, persists audio, or backpressures the provider.
5
+ // Readers pull from shared bounded storage, so a slow browser cannot grow a queue.
6
+ export function createAudioPreviewStore(limits = {}) {
7
+ const maxBytes = limits.maxBytes ?? 64 * 1024 * 1024;
8
+ const maxEntryBytes = limits.maxEntryBytes ?? 16 * 1024 * 1024;
9
+ const maxEntries = limits.maxEntries ?? 128;
10
+ const keepMs = limits.keepMs ?? 5 * 60_000;
11
+ const now = limits.now ?? Date.now;
12
+ const entries = new Map();
13
+ let retained = 0;
14
+ let closed = false;
15
+ const notify = (entry) => {
16
+ const listeners = [...entry.changed];
17
+ entry.changed.clear();
18
+ for (const listener of listeners)
19
+ listener();
20
+ };
21
+ const stop = (entry, state) => {
22
+ retained -= entry.bytes;
23
+ entry.bytes = 0;
24
+ entry.chunks.length = 0;
25
+ entry.state = state;
26
+ entry.endedAt = now();
27
+ notify(entry);
28
+ };
29
+ const remove = (entry) => {
30
+ stop(entry, "interrupted");
31
+ entries.delete(entry.id);
32
+ };
33
+ const prune = () => {
34
+ for (const entry of entries.values()) {
35
+ if (entry.endedAt !== undefined && now() - entry.endedAt >= keepMs)
36
+ remove(entry);
37
+ }
38
+ };
39
+ const expiry = setInterval(prune, Math.min(keepMs, 30_000));
40
+ expiry.unref();
41
+ const store = {
42
+ begin(projectId, key, label) {
43
+ if (closed)
44
+ return inactive;
45
+ prune();
46
+ for (const entry of entries.values()) {
47
+ if (entry.projectId === projectId && entry.key === key)
48
+ remove(entry);
49
+ }
50
+ // At capacity, completed previews yield to current generation. Otherwise
51
+ // this request has no preview; the normal durable narration still succeeds.
52
+ if (entries.size >= maxEntries) {
53
+ const oldest = [...entries.values()].find((entry) => entry.state !== "streaming");
54
+ if (oldest !== undefined)
55
+ remove(oldest);
56
+ }
57
+ if (entries.size >= maxEntries)
58
+ return inactive;
59
+ const entry = {
60
+ id: randomUUID(),
61
+ projectId,
62
+ key,
63
+ label,
64
+ chunks: [],
65
+ changed: new Set(),
66
+ state: "streaming",
67
+ bytes: 0,
68
+ endedAt: undefined,
69
+ };
70
+ entries.set(entry.id, entry);
71
+ const active = () => entries.get(entry.id) === entry && entry.state === "streaming";
72
+ return {
73
+ append(bytes) {
74
+ if (!active() || bytes.byteLength === 0)
75
+ return;
76
+ if (retained + bytes.byteLength > maxBytes ||
77
+ entry.bytes + bytes.byteLength > maxEntryBytes) {
78
+ stop(entry, "unavailable");
79
+ return;
80
+ }
81
+ // Own these bounded buffers. A provider may reuse its read buffer.
82
+ for (let at = 0; at < bytes.byteLength; at += 64 * 1024) {
83
+ entry.chunks.push(bytes.slice(at, at + 64 * 1024));
84
+ }
85
+ retained += bytes.byteLength;
86
+ entry.bytes += bytes.byteLength;
87
+ notify(entry);
88
+ },
89
+ complete() {
90
+ if (!active())
91
+ return;
92
+ entry.state = "ready";
93
+ entry.endedAt = now();
94
+ notify(entry);
95
+ },
96
+ interrupt() {
97
+ if (active())
98
+ stop(entry, "interrupted");
99
+ },
100
+ };
101
+ },
102
+ list(projectId) {
103
+ prune();
104
+ return [...entries.values()]
105
+ .filter((entry) => entry.projectId === projectId)
106
+ .map(({ id, label, state, bytes }) => ({ id, label, state, bytes }));
107
+ },
108
+ stream(projectId, id, signal) {
109
+ prune();
110
+ const entry = entries.get(id);
111
+ if (entry === undefined ||
112
+ entry.projectId !== projectId ||
113
+ entry.state === "interrupted" ||
114
+ entry.state === "unavailable")
115
+ return undefined;
116
+ return follow(entry, signal);
117
+ },
118
+ clear(projectId) {
119
+ for (const entry of entries.values())
120
+ if (entry.projectId === projectId)
121
+ remove(entry);
122
+ },
123
+ close() {
124
+ closed = true;
125
+ clearInterval(expiry);
126
+ for (const entry of entries.values())
127
+ remove(entry);
128
+ },
129
+ };
130
+ return store;
131
+ }
132
+ function follow(entry, signal) {
133
+ let index = 0;
134
+ let ended = false;
135
+ let wake;
136
+ let control;
137
+ const detach = () => {
138
+ ended = true;
139
+ signal.removeEventListener("abort", abort);
140
+ if (wake !== undefined) {
141
+ entry.changed.delete(wake);
142
+ wake();
143
+ wake = undefined;
144
+ }
145
+ };
146
+ const abort = () => {
147
+ if (ended)
148
+ return;
149
+ detach();
150
+ control?.error(new Error("Audio preview disconnected"));
151
+ };
152
+ return new ReadableStream({
153
+ start(controller) {
154
+ control = controller;
155
+ if (signal.aborted)
156
+ abort();
157
+ else
158
+ signal.addEventListener("abort", abort, { once: true });
159
+ },
160
+ async pull(controller) {
161
+ while (!ended) {
162
+ if (entry.state === "interrupted" || entry.state === "unavailable") {
163
+ detach();
164
+ controller.error(new Error("Audio preview was interrupted"));
165
+ return;
166
+ }
167
+ const bytes = entry.chunks[index];
168
+ if (bytes !== undefined) {
169
+ index += 1;
170
+ controller.enqueue(bytes);
171
+ return;
172
+ }
173
+ if (entry.state === "ready") {
174
+ detach();
175
+ controller.close();
176
+ return;
177
+ }
178
+ await new Promise((resolve) => {
179
+ wake = resolve;
180
+ entry.changed.add(resolve);
181
+ });
182
+ }
183
+ },
184
+ cancel() {
185
+ detach();
186
+ },
187
+ }, { highWaterMark: 0 });
188
+ }
@@ -0,0 +1,11 @@
1
+ CREATE TABLE batches (
2
+ id TEXT PRIMARY KEY,
3
+ created_at TEXT NOT NULL
4
+ );
5
+ CREATE TABLE project_queue (
6
+ position INTEGER PRIMARY KEY AUTOINCREMENT,
7
+ project_id TEXT NOT NULL UNIQUE REFERENCES projects(id) ON DELETE CASCADE,
8
+ batch_id TEXT NOT NULL REFERENCES batches(id),
9
+ state TEXT NOT NULL DEFAULT 'queued' CHECK(state IN ('queued','active','finished'))
10
+ );
11
+ CREATE INDEX project_queue_state ON project_queue(state, position);
@@ -1 +1,2 @@
1
1
  export const messageRoles = ["system", "user", "assistant"];
2
+ export const thinkingModes = ["off", "low", "medium", "high", "xhigh"];
@@ -0,0 +1,26 @@
1
+ // Bounded UTF-16 length is conservative for providers counting Unicode characters.
2
+ // Every character survives; the split never cuts a surrogate pair.
3
+ export function splitText(text, limit) {
4
+ if (!Number.isInteger(limit) || limit < 2)
5
+ throw new Error("Text limit must be at least two characters");
6
+ const parts = [];
7
+ let rest = text;
8
+ while (rest.length > limit) {
9
+ const window = rest.slice(0, limit);
10
+ const sentences = [...window.matchAll(/[.!?]\s+|\n/g)];
11
+ const last = sentences.at(-1);
12
+ let cut = last && last.index > limit / 2
13
+ ? last.index + last[0].length
14
+ : Math.max(window.lastIndexOf(" "), window.lastIndexOf("\n"), window.lastIndexOf("\t")) + 1;
15
+ if (cut < limit / 2)
16
+ cut = limit;
17
+ const code = rest.charCodeAt(cut - 1);
18
+ if (code >= 0xd800 && code <= 0xdbff)
19
+ cut--;
20
+ parts.push(rest.slice(0, cut));
21
+ rest = rest.slice(cut);
22
+ }
23
+ if (rest)
24
+ parts.push(rest);
25
+ return parts;
26
+ }
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { attempt } from "./attempt.js";
2
3
  export function stageProviders(deps, context, pieceId) {
3
4
  const ctx = {
@@ -10,25 +11,40 @@ export function stageProviders(deps, context, pieceId) {
10
11
  ...(pieceId === undefined ? {} : { pieceId }),
11
12
  signal: context.signal,
12
13
  };
14
+ const schedule = (provider, work) => deps.queue ? deps.queue.run(provider, context.signal, work) : work();
13
15
  return {
14
16
  llm: (call, onEvent) => {
15
17
  // Resolved once; the adapter reads the stored key per request, so a key replaced
16
18
  // mid-run still reaches the next attempt.
17
19
  const port = deps.registry.llm(call.provider);
18
- return attempt(ctx, async (signal, progress) => {
20
+ const callId = randomUUID();
21
+ const preview = (text, reset) => context.emit({
22
+ type: "llm.preview",
23
+ projectId: context.stage.projectId,
24
+ stage: context.stage.kind,
25
+ callId,
26
+ label: call.previewLabel ?? context.stage.kind,
27
+ text,
28
+ ...(reset === undefined ? {} : { reset }),
29
+ });
30
+ return schedule(call.provider, () => attempt(ctx, async (signal, progress) => {
31
+ preview("", true);
19
32
  let text = "";
20
33
  let usage = null;
21
34
  let finishReason = null;
22
35
  for await (const event of port.complete({
23
36
  model: call.model,
37
+ ...(call.thinking === undefined ? {} : { thinking: call.thinking }),
24
38
  messages: call.messages,
25
39
  ...(call.webSearch === undefined ? {} : { webSearch: call.webSearch }),
26
40
  signal,
27
41
  })) {
42
+ signal.throwIfAborted();
28
43
  // Every event is a sign of life, so the idle clock restarts here.
29
44
  progress();
30
45
  if (event.type === "delta") {
31
46
  text += event.text;
47
+ preview(event.text);
32
48
  }
33
49
  else if (event.type === "done") {
34
50
  usage = event.usage;
@@ -44,47 +60,83 @@ export function stageProviders(deps, context, pieceId) {
44
60
  throw new Error(unusable);
45
61
  }
46
62
  return answer;
47
- }, { kind: "llm", streaming: port.capabilities.streams });
63
+ }, { kind: "llm", streaming: port.capabilities.streams }));
48
64
  },
49
- tts: (call) => {
65
+ tts: (call, observe) => {
50
66
  const port = deps.registry.tts(call.provider);
51
- return attempt(ctx, async (signal, progress) => {
52
- const spoken = await port.synthesize({
53
- voiceId: call.voiceId,
54
- text: call.text,
55
- signal,
56
- });
57
- const reader = spoken.audio.getReader();
58
- const chunks = [];
59
- let total = 0;
60
- for (;;) {
61
- const { done, value } = await reader.read();
62
- if (done || value === undefined) {
63
- break;
67
+ let continuation;
68
+ const notify = (event) => {
69
+ try {
70
+ observe?.(event);
71
+ }
72
+ catch {
73
+ // A preview is optional. A broken observer must never repeat a paid call.
74
+ deps.log.write("warn", "audio.preview", {
75
+ projectId: context.stage.projectId,
76
+ stage: "audio",
77
+ detail: "Could not update the live audio preview",
78
+ });
79
+ }
80
+ };
81
+ return schedule(call.provider, () => attempt(ctx, async (signal, progress) => {
82
+ notify({ type: "start" });
83
+ try {
84
+ const spoken = await port.synthesize({
85
+ model: call.model,
86
+ voiceId: call.voiceId,
87
+ text: call.text,
88
+ signal,
89
+ onActivity: progress,
90
+ continuation: {
91
+ read: () => continuation,
92
+ write: (token) => {
93
+ continuation = token;
94
+ },
95
+ },
96
+ });
97
+ const reader = spoken.audio.getReader();
98
+ const chunks = [];
99
+ let total = 0;
100
+ try {
101
+ for (;;) {
102
+ const { done, value } = await reader.read();
103
+ signal.throwIfAborted();
104
+ if (done || value === undefined)
105
+ break;
106
+ progress();
107
+ chunks.push(value);
108
+ total += value.length;
109
+ notify({ type: "chunk", bytes: value });
110
+ }
64
111
  }
65
- progress();
66
- chunks.push(value);
67
- total += value.length;
112
+ finally {
113
+ reader.releaseLock();
114
+ }
115
+ const bytes = new Uint8Array(total);
116
+ let at = 0;
117
+ for (const chunk of chunks) {
118
+ bytes.set(chunk, at);
119
+ at += chunk.length;
120
+ }
121
+ notify({ type: "complete" });
122
+ return { bytes, container: spoken.container };
68
123
  }
69
- const bytes = new Uint8Array(total);
70
- let at = 0;
71
- for (const chunk of chunks) {
72
- bytes.set(chunk, at);
73
- at += chunk.length;
124
+ catch (error) {
125
+ notify({ type: "interrupted" });
126
+ throw error;
74
127
  }
75
- return { bytes, container: spoken.container };
76
- }, { kind: "tts", streaming: port.capabilities.streams });
128
+ }, { kind: "tts", streaming: port.capabilities.streams }));
77
129
  },
78
130
  image: (call) => {
79
131
  const port = deps.registry.image(call.provider);
80
- return attempt(ctx, (signal) => port.generate({
132
+ return schedule(call.provider, () => attempt(ctx, (signal) => port.generate({
81
133
  model: call.model,
82
134
  prompt: call.prompt,
83
135
  aspect: call.aspect,
84
136
  signal,
85
137
  }),
86
138
  // One request, one answer: the 300 s runs over the whole call.
87
- { kind: "image" });
139
+ { kind: "image" }));
88
140
  },
89
141
  forPiece: (piece) => stageProviders(deps, context, piece),
90
142
  };
@@ -0,0 +1,65 @@
1
+ // One app-wide queue: no more than five calls, with lower provider limits.
2
+ // Waiting does not start an attempt or its idle timer. FIFO among eligible calls.
3
+ export function createProviderQueue(limitFor) {
4
+ const active = new Map();
5
+ const waiting = [];
6
+ let total = 0;
7
+ function drain() {
8
+ while (total < 5) {
9
+ const at = waiting.findIndex((w) => (active.get(w.provider) ?? 0) <
10
+ Math.max(1, Math.min(5, Math.floor(limitFor(w.provider)) || 1)));
11
+ if (at < 0)
12
+ return;
13
+ const next = waiting.splice(at, 1)[0];
14
+ if (!next)
15
+ return;
16
+ next.signal.removeEventListener("abort", next.cancel);
17
+ if (next.signal.aborted) {
18
+ next.cancel();
19
+ continue;
20
+ }
21
+ total++;
22
+ active.set(next.provider, (active.get(next.provider) ?? 0) + 1);
23
+ next.start();
24
+ }
25
+ }
26
+ return {
27
+ run: (provider, signal, work) => {
28
+ if (signal.aborted)
29
+ return Promise.reject(signal.reason);
30
+ return new Promise((resolve, reject) => {
31
+ const waiter = {
32
+ provider,
33
+ signal,
34
+ cancel: () => {
35
+ const at = waiting.indexOf(waiter);
36
+ if (at >= 0)
37
+ waiting.splice(at, 1);
38
+ signal.removeEventListener("abort", waiter.cancel);
39
+ reject(signal.reason);
40
+ },
41
+ start: () => {
42
+ void Promise.resolve()
43
+ .then(() => {
44
+ signal.throwIfAborted();
45
+ return work();
46
+ })
47
+ .then(resolve, reject)
48
+ .finally(() => {
49
+ total--;
50
+ const left = (active.get(provider) ?? 1) - 1;
51
+ if (left)
52
+ active.set(provider, left);
53
+ else
54
+ active.delete(provider);
55
+ drain();
56
+ });
57
+ },
58
+ };
59
+ waiting.push(waiter);
60
+ signal.addEventListener("abort", waiter.cancel, { once: true });
61
+ drain();
62
+ });
63
+ },
64
+ };
65
+ }