@gentbajko/slopify 0.1.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 (156) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +63 -0
  3. package/dist/adapter-registry.js +68 -0
  4. package/dist/adapters/image/bytes.js +64 -0
  5. package/dist/adapters/image/fal.js +164 -0
  6. package/dist/adapters/image/openai.js +155 -0
  7. package/dist/adapters/image/replicate.js +183 -0
  8. package/dist/adapters/llm/claude-code.js +158 -0
  9. package/dist/adapters/llm/codex.js +126 -0
  10. package/dist/adapters/llm/openrouter.js +193 -0
  11. package/dist/adapters/llm/run-cli.js +134 -0
  12. package/dist/adapters/llm/sse-lines.js +46 -0
  13. package/dist/adapters/retry-after.js +21 -0
  14. package/dist/adapters/tts/cartesia.js +105 -0
  15. package/dist/adapters/tts/elevenlabs.js +107 -0
  16. package/dist/adapters/tts/openai.js +95 -0
  17. package/dist/edge/cli.js +36 -0
  18. package/dist/edge/events/hub.js +76 -0
  19. package/dist/edge/http/actions.js +125 -0
  20. package/dist/edge/http/app.js +72 -0
  21. package/dist/edge/http/entries.js +41 -0
  22. package/dist/edge/http/files.js +67 -0
  23. package/dist/edge/http/problem.js +65 -0
  24. package/dist/edge/http/projects.js +128 -0
  25. package/dist/edge/http/prompts.js +73 -0
  26. package/dist/edge/http/providers.js +62 -0
  27. package/dist/edge/http/settings.js +90 -0
  28. package/dist/edge/http/staging.js +104 -0
  29. package/dist/edge/http/telemetry.js +29 -0
  30. package/dist/edge/http/usage.js +19 -0
  31. package/dist/edge/open-browser.js +20 -0
  32. package/dist/kernel/clock.js +18 -0
  33. package/dist/kernel/config/index.js +32 -0
  34. package/dist/kernel/db/index.js +24 -0
  35. package/dist/kernel/db/migrate.js +59 -0
  36. package/dist/kernel/db/migrations/0001-init.sql +17 -0
  37. package/dist/kernel/db/tx.js +53 -0
  38. package/dist/kernel/events.js +1 -0
  39. package/dist/kernel/ids.js +4 -0
  40. package/dist/kernel/lock.js +73 -0
  41. package/dist/kernel/log.js +34 -0
  42. package/dist/kernel/paths.js +21 -0
  43. package/dist/kernel/pipeline.js +22 -0
  44. package/dist/kernel/ports/image.js +1 -0
  45. package/dist/kernel/ports/llm.js +1 -0
  46. package/dist/kernel/ports/model.js +34 -0
  47. package/dist/kernel/ports/registry.js +1 -0
  48. package/dist/kernel/ports/tts.js +1 -0
  49. package/dist/kernel/runner/attempt-repo.js +50 -0
  50. package/dist/kernel/runner/attempt.js +146 -0
  51. package/dist/kernel/runner/graph.js +82 -0
  52. package/dist/kernel/runner/index.js +205 -0
  53. package/dist/kernel/runner/piece-repo.js +58 -0
  54. package/dist/kernel/runner/providers.js +92 -0
  55. package/dist/kernel/version.js +14 -0
  56. package/dist/main.js +212 -0
  57. package/dist/slices/admission/model.js +6 -0
  58. package/dist/slices/admission/repo.js +178 -0
  59. package/dist/slices/admission/rules.js +201 -0
  60. package/dist/slices/admission/start.js +103 -0
  61. package/dist/slices/admission/substitute.js +80 -0
  62. package/dist/slices/article/continuation.js +81 -0
  63. package/dist/slices/article/plain.js +14 -0
  64. package/dist/slices/article/run.js +166 -0
  65. package/dist/slices/article/split.js +51 -0
  66. package/dist/slices/article/store.js +21 -0
  67. package/dist/slices/cancel/index.js +61 -0
  68. package/dist/slices/images/run.js +221 -0
  69. package/dist/slices/library/lint.js +53 -0
  70. package/dist/slices/library/model.js +13 -0
  71. package/dist/slices/library/repo.js +111 -0
  72. package/dist/slices/library/save.js +87 -0
  73. package/dist/slices/library/slots.js +86 -0
  74. package/dist/slices/narration/chunk.js +71 -0
  75. package/dist/slices/narration/concat.js +79 -0
  76. package/dist/slices/narration/run.js +280 -0
  77. package/dist/slices/reruns/cascade.js +66 -0
  78. package/dist/slices/reruns/index.js +269 -0
  79. package/dist/slices/research/planner.js +81 -0
  80. package/dist/slices/research/run.js +192 -0
  81. package/dist/slices/research/synthesis.js +48 -0
  82. package/dist/slices/settings/cli-status.js +37 -0
  83. package/dist/slices/settings/keys.js +55 -0
  84. package/dist/slices/settings/model.js +54 -0
  85. package/dist/slices/settings/playback.js +60 -0
  86. package/dist/slices/settings/readiness.js +21 -0
  87. package/dist/slices/settings/repo.js +59 -0
  88. package/dist/slices/settings/voices.js +48 -0
  89. package/dist/slices/storage/asset-name.js +16 -0
  90. package/dist/slices/storage/delete-project.js +35 -0
  91. package/dist/slices/storage/downloads.js +100 -0
  92. package/dist/slices/storage/layout.js +59 -0
  93. package/dist/slices/storage/model.js +20 -0
  94. package/dist/slices/storage/reconcile.js +102 -0
  95. package/dist/slices/storage/repo.js +104 -0
  96. package/dist/slices/storage/staging.js +224 -0
  97. package/dist/slices/telemetry/collector-client.js +51 -0
  98. package/dist/slices/telemetry/flush.js +111 -0
  99. package/dist/slices/telemetry/machine.js +33 -0
  100. package/dist/slices/telemetry/model.js +39 -0
  101. package/dist/slices/telemetry/record.js +41 -0
  102. package/dist/slices/telemetry/repo.js +73 -0
  103. package/dist/slices/telemetry/usage.js +82 -0
  104. package/dist/slices/thumbnail/by-llm.js +30 -0
  105. package/dist/slices/thumbnail/run.js +200 -0
  106. package/dist/slices/video/ffmpeg.js +230 -0
  107. package/dist/slices/video/plan.js +71 -0
  108. package/dist/slices/video/run.js +179 -0
  109. package/dist/web/app-icon.svg +1 -0
  110. package/dist/web/assets/barlow-condensed-latin-600-normal-BFJEwTuo.woff +0 -0
  111. package/dist/web/assets/barlow-condensed-latin-600-normal-DepVgxBB.woff2 +0 -0
  112. package/dist/web/assets/barlow-condensed-latin-700-normal-Dmwat-ge.woff +0 -0
  113. package/dist/web/assets/barlow-condensed-latin-700-normal-v1xN8_Wq.woff2 +0 -0
  114. package/dist/web/assets/barlow-condensed-latin-ext-600-normal-18ESti3H.woff2 +0 -0
  115. package/dist/web/assets/barlow-condensed-latin-ext-600-normal-Clv9cIcR.woff +0 -0
  116. package/dist/web/assets/barlow-condensed-latin-ext-700-normal-BIHFfxf0.woff +0 -0
  117. package/dist/web/assets/barlow-condensed-latin-ext-700-normal-CwuXbfVR.woff2 +0 -0
  118. package/dist/web/assets/barlow-condensed-vietnamese-600-normal-A5AYRdjN.woff2 +0 -0
  119. package/dist/web/assets/barlow-condensed-vietnamese-600-normal-CNlPk46_.woff +0 -0
  120. package/dist/web/assets/barlow-condensed-vietnamese-700-normal-DYeBwlKR.woff2 +0 -0
  121. package/dist/web/assets/barlow-condensed-vietnamese-700-normal-DhIzd8Tb.woff +0 -0
  122. package/dist/web/assets/barlow-latin-400-normal-fsAxiSwU.woff +0 -0
  123. package/dist/web/assets/barlow-latin-400-normal-qiz4-Cze.woff2 +0 -0
  124. package/dist/web/assets/barlow-latin-500-normal-BPAOfeC8.woff2 +0 -0
  125. package/dist/web/assets/barlow-latin-500-normal-C1h8hMer.woff +0 -0
  126. package/dist/web/assets/barlow-latin-600-normal-CNwfPWQD.woff +0 -0
  127. package/dist/web/assets/barlow-latin-600-normal-DILqtrty.woff2 +0 -0
  128. package/dist/web/assets/barlow-latin-700-normal-A9pxMQ4z.woff2 +0 -0
  129. package/dist/web/assets/barlow-latin-700-normal-__SGTsZ1.woff +0 -0
  130. package/dist/web/assets/barlow-latin-800-normal-BdVooDN4.woff +0 -0
  131. package/dist/web/assets/barlow-latin-800-normal-s1sAMnoV.woff2 +0 -0
  132. package/dist/web/assets/barlow-latin-ext-400-normal-CvBsJvxq.woff +0 -0
  133. package/dist/web/assets/barlow-latin-ext-400-normal-HxX4XjxC.woff2 +0 -0
  134. package/dist/web/assets/barlow-latin-ext-500-normal-CJPcKP2Q.woff +0 -0
  135. package/dist/web/assets/barlow-latin-ext-500-normal-DOaysfXq.woff2 +0 -0
  136. package/dist/web/assets/barlow-latin-ext-600-normal-B8NK_A3D.woff2 +0 -0
  137. package/dist/web/assets/barlow-latin-ext-600-normal-DMVRjfRT.woff +0 -0
  138. package/dist/web/assets/barlow-latin-ext-700-normal-BLuWmldJ.woff2 +0 -0
  139. package/dist/web/assets/barlow-latin-ext-700-normal-CctuGmmz.woff +0 -0
  140. package/dist/web/assets/barlow-latin-ext-800-normal-BiucknKG.woff2 +0 -0
  141. package/dist/web/assets/barlow-latin-ext-800-normal-D7I3yvUw.woff +0 -0
  142. package/dist/web/assets/barlow-vietnamese-400-normal-BFeobeCK.woff +0 -0
  143. package/dist/web/assets/barlow-vietnamese-400-normal-Dpl4UHAZ.woff2 +0 -0
  144. package/dist/web/assets/barlow-vietnamese-500-normal-GNfB7rCE.woff +0 -0
  145. package/dist/web/assets/barlow-vietnamese-500-normal-zTViEIzf.woff2 +0 -0
  146. package/dist/web/assets/barlow-vietnamese-600-normal-CA_GiK2e.woff +0 -0
  147. package/dist/web/assets/barlow-vietnamese-600-normal-DcjprdFV.woff2 +0 -0
  148. package/dist/web/assets/barlow-vietnamese-700-normal-4Jt4k04K.woff +0 -0
  149. package/dist/web/assets/barlow-vietnamese-700-normal-D6euyNzi.woff2 +0 -0
  150. package/dist/web/assets/barlow-vietnamese-800-normal-Cl1Mc_Dv.woff2 +0 -0
  151. package/dist/web/assets/barlow-vietnamese-800-normal-D0VWpbij.woff +0 -0
  152. package/dist/web/assets/index-BPqQnrpy.css +1 -0
  153. package/dist/web/assets/index-D_sWbKQi.js +81 -0
  154. package/dist/web/favicon.svg +1 -0
  155. package/dist/web/index.html +15 -0
  156. package/package.json +41 -0
@@ -0,0 +1,134 @@
1
+ import { spawn } from "node:child_process";
2
+ import { z } from "zod";
3
+ import { redact } from "../../kernel/log.js";
4
+ import { providerError } from "../../kernel/ports/model.js";
5
+ // ceiling: the last 8 KiB of stderr is kept. A CLI that writes megabytes of progress there
6
+ // would otherwise be held in memory for the length of a run, and the tail is the part that
7
+ // says why it stopped. Streaming stderr to the log is the upgrade if a provider ever
8
+ // buries its reason in the first line of a long report.
9
+ export const stderrMax = 8192;
10
+ export function nodeRunCli(binary, args, signal) {
11
+ // An argument array, never a shell string: a prompt carrying backticks, `$(...)`,
12
+ // quotes or newlines is one argv element and nothing in it can become a command.
13
+ // stdin is /dev/null because `codex exec` appends piped stdin to the prompt, and a pipe
14
+ // nobody closes would leave it waiting for an EOF that never comes.
15
+ // `signal` is how the child dies: Node sends it SIGTERM when the stage is cancelled, so
16
+ // no agent session outlives the run that started it (`logic/13`).
17
+ const child = spawn(binary, [...args], {
18
+ signal,
19
+ stdio: ["ignore", "pipe", "pipe"],
20
+ windowsHide: true,
21
+ });
22
+ let stderr = "";
23
+ const errorStream = child.stderr;
24
+ if (errorStream !== null) {
25
+ errorStream.setEncoding("utf8");
26
+ errorStream.on("data", (piece) => {
27
+ stderr = (stderr + piece).slice(-stderrMax);
28
+ });
29
+ }
30
+ let spawnError = null;
31
+ // A process that could not be spawned emits `error`, and an unlistened `error` is thrown
32
+ // at the process. It is recorded rather than rethrown so the one place that reports a
33
+ // failed run is the caller reading `ended`.
34
+ child.on("error", (error) => {
35
+ spawnError = error;
36
+ });
37
+ const ended = new Promise((resolve) => {
38
+ const settle = (code) => {
39
+ resolve({ code, error: spawnError });
40
+ };
41
+ child.once("close", settle);
42
+ // `close` follows `error` when the streams were opened; this is the belt for the case
43
+ // where they were not.
44
+ child.once("error", () => {
45
+ setImmediate(() => {
46
+ settle(null);
47
+ });
48
+ });
49
+ });
50
+ return {
51
+ pid: child.pid,
52
+ stdout: child.stdout === null ? nothing : bytesOf(child.stdout),
53
+ stderr: () => stderr,
54
+ ended,
55
+ kill: () => {
56
+ if (child.exitCode === null && child.signalCode === null) {
57
+ child.kill();
58
+ }
59
+ },
60
+ };
61
+ }
62
+ // ceiling: both CLIs take one prompt string, so a system or assistant turn is flattened
63
+ // into it under a header rather than being sent as its own message. Claude Code's
64
+ // `--append-system-prompt` is the upgrade if a stage ever needs a real system turn; today
65
+ // `logic/06` and `logic/07` each compose exactly one user message.
66
+ export function promptOf(messages) {
67
+ return messages
68
+ .map((message) => message.role === "user" ? message.content : `[${message.role}]\n${message.content}`)
69
+ .join("\n\n");
70
+ }
71
+ // A bare non-zero exit tells the user nothing, so whatever the CLI put on stderr is what
72
+ // the stage shows (`logic/01` unhappy paths: the provider's error text verbatim).
73
+ export function endedWithout(binary, ended, stderr) {
74
+ const said = redact(stderr.trim());
75
+ const how = ended.error === null
76
+ ? `exited ${ended.code === null ? "on a signal" : String(ended.code)}`
77
+ : `could not be started (${redact(ended.error.message)})`;
78
+ return said === ""
79
+ ? `the ${binary} CLI ${how} without answering`
80
+ : `the ${binary} CLI ${how} without answering: ${said}`;
81
+ }
82
+ // Unreachable while stdio names `pipe` for fd 1; it is here so the type carries no null
83
+ // and no non-null assertion is needed to read the stream.
84
+ const nothing = {
85
+ [Symbol.asyncIterator]: () => ({
86
+ next: () => Promise.resolve({ done: true, value: undefined }),
87
+ }),
88
+ };
89
+ const typed = z.object({ type: z.string() });
90
+ // A JSONL line off a CLI's stdout. The raw value travels with its `type` because a schema
91
+ // that names only `type` strips every other key, and each event's own shape is checked
92
+ // against the schema that knows it.
93
+ export function cliEvent(binary, line) {
94
+ const value = safeJson(line);
95
+ const parsed = typed.safeParse(value);
96
+ if (!parsed.success) {
97
+ // A line that will not parse is a stream cut mid-write or a format this app does not
98
+ // know. The text is not echoed back: half a JSON object is noise to the user.
99
+ throw providerError({
100
+ kind: "other",
101
+ message: `the ${binary} CLI wrote a line this app could not read`,
102
+ });
103
+ }
104
+ return { type: parsed.data.type, value };
105
+ }
106
+ export function cliShaped(binary, schema, value) {
107
+ const parsed = schema.safeParse(value);
108
+ if (!parsed.success) {
109
+ throw providerError({
110
+ kind: "other",
111
+ message: `the ${binary} CLI wrote an event this app could not read`,
112
+ });
113
+ }
114
+ return parsed.data;
115
+ }
116
+ async function* bytesOf(source) {
117
+ for await (const piece of source) {
118
+ if (typeof piece === "string") {
119
+ yield new TextEncoder().encode(piece);
120
+ continue;
121
+ }
122
+ if (piece instanceof Uint8Array) {
123
+ yield piece;
124
+ }
125
+ }
126
+ }
127
+ function safeJson(text) {
128
+ try {
129
+ return JSON.parse(text);
130
+ }
131
+ catch {
132
+ return undefined;
133
+ }
134
+ }
@@ -0,0 +1,46 @@
1
+ // Bytes off a socket or off a child's stdout, turned into the units above them. One file
2
+ // for both because OpenRouter's SSE and the two CLIs' JSONL differ only in what a line
3
+ // means, and 05-dependencies records the decision: global `fetch` at rung 3, this reader
4
+ // at rung 6, no SDK for any of the three providers.
5
+ // A chunk boundary lands wherever the network or the pipe put it: between two lines, in
6
+ // the middle of one, or inside a multi-byte character. TextDecoder's streaming mode holds
7
+ // back the tail of a split UTF-8 sequence instead of emitting a replacement character,
8
+ // and the leftover buffer holds back a split line.
9
+ export async function* lines(chunks, signal) {
10
+ const decoder = new TextDecoder("utf-8");
11
+ let buffer = "";
12
+ for await (const chunk of chunks) {
13
+ signal?.throwIfAborted();
14
+ buffer += decoder.decode(chunk, { stream: true });
15
+ for (let at = buffer.indexOf("\n"); at !== -1; at = buffer.indexOf("\n")) {
16
+ yield withoutCr(buffer.slice(0, at));
17
+ buffer = buffer.slice(at + 1);
18
+ }
19
+ }
20
+ buffer += decoder.decode();
21
+ // A producer that ends without a trailing newline still wrote a whole line, and one cut
22
+ // off mid-line wrote a broken one. Both are handed on: the caller's parse is what tells
23
+ // a complete last line from a truncated stream, and it must not be robbed of the
24
+ // evidence by a reader that quietly drops the remainder.
25
+ if (buffer !== "") {
26
+ yield withoutCr(buffer);
27
+ }
28
+ }
29
+ // The `data:` payloads of an SSE stream, in order. Comment lines - which is what
30
+ // OpenRouter's ": OPENROUTER PROCESSING" keep-alive is - and the blank lines between
31
+ // events carry nothing, and neither do the `id:`, `event:` and `retry:` fields.
32
+ // ceiling: one `data:` line is one payload. The spec folds consecutive `data:` lines of
33
+ // an event into one value joined by newlines; no provider here sends them, and buffering
34
+ // until the blank line is the upgrade when one does.
35
+ export async function* sseData(chunks, signal) {
36
+ for await (const line of lines(chunks, signal)) {
37
+ if (!line.startsWith("data:")) {
38
+ continue;
39
+ }
40
+ // The spec strips one leading space after the colon and nothing else.
41
+ yield line.slice(5).replace(/^ /, "");
42
+ }
43
+ }
44
+ function withoutCr(line) {
45
+ return line.endsWith("\r") ? line.slice(0, -1) : line;
46
+ }
@@ -0,0 +1,21 @@
1
+ // `logic/01` §Q4: a 429 naming a Retry-After replaces the fixed backoff for that wait.
2
+ // RFC 9110 allows seconds or an HTTP date; `Date.parse` is the platform's own reader for
3
+ // the second form, so no date parsing is written here.
4
+ //
5
+ // It sits beside the adapters rather than inside one because the rule is the retry
6
+ // policy's, not any provider's: four adapters read the same header off the same kind of
7
+ // response. Nothing else is shared between them.
8
+ export function retryAfter(header) {
9
+ if (header === null) {
10
+ return undefined;
11
+ }
12
+ const value = header.trim();
13
+ if (/^\d+$/.test(value)) {
14
+ return Number(value) * 1000;
15
+ }
16
+ const at = Date.parse(value);
17
+ if (Number.isNaN(at)) {
18
+ return undefined;
19
+ }
20
+ return Math.max(0, at - Date.now());
21
+ }
@@ -0,0 +1,105 @@
1
+ import { z } from "zod";
2
+ import { redact } from "../../kernel/log.js";
3
+ import { providerError } from "../../kernel/ports/model.js";
4
+ import { retryAfter } from "../retry-after.js";
5
+ // The HTTP gateway adapter for Cartesia (01-architecture Module boundaries). `fetch` and
6
+ // nothing else; `/tts/bytes` streams the audio it renders, so the response body is
7
+ // already the stream the port asks for.
8
+ export const cartesiaBase = "https://api.cartesia.ai";
9
+ // Cartesia pins behaviour to a dated version header and refuses a request without one.
10
+ // This is the version the request bodies below are written against, so it is sent as a
11
+ // constant rather than left to the account's default.
12
+ export const cartesiaVersion = "2026-03-01";
13
+ export const cartesiaModel = "sonic-3.5";
14
+ // mp3 is the port's container; `bit_rate` is required for it and `sample_rate` fixes the
15
+ // rate the concatenation of `logic/08` step 4 then keeps.
16
+ const outputFormat = { container: "mp3", bit_rate: 128_000, sample_rate: 44_100 };
17
+ // The structured error of API version 2026-03-01 and newer.
18
+ const errorBody = z.object({
19
+ error_code: z.string().optional(),
20
+ title: z.string().optional(),
21
+ message: z.string().optional(),
22
+ });
23
+ export function cartesiaTts(deps) {
24
+ return {
25
+ id: "cartesia",
26
+ capabilities: { streams: true },
27
+ synthesize: async (req) => {
28
+ const response = await deps.fetch(`${cartesiaBase}/tts/bytes`, {
29
+ method: "POST",
30
+ signal: req.signal,
31
+ headers: {
32
+ "X-API-Key": keyOf(deps),
33
+ "Cartesia-Version": cartesiaVersion,
34
+ "Content-Type": "application/json",
35
+ },
36
+ // `logic/08` §Q69: no pre-check on length; Cartesia's own limit surfaces as its
37
+ // error. `language` is left out so the model reads it off the transcript.
38
+ body: JSON.stringify({
39
+ model_id: cartesiaModel,
40
+ transcript: req.text,
41
+ voice: { mode: "id", id: req.voiceId },
42
+ output_format: outputFormat,
43
+ }),
44
+ });
45
+ if (!response.ok) {
46
+ throw await failure(response, req.voiceId);
47
+ }
48
+ if (response.body === null) {
49
+ throw providerError({ kind: "other", message: "Cartesia answered with no audio" });
50
+ }
51
+ return { audio: response.body, container: "mp3" };
52
+ },
53
+ };
54
+ }
55
+ function keyOf(deps) {
56
+ const key = deps.key();
57
+ // `logic/02` §Q13: an absent key is terminal, so it never becomes a request.
58
+ if (key === undefined || key === "") {
59
+ throw providerError({ kind: "missing_key", message: "no Cartesia key is stored" });
60
+ }
61
+ return key;
62
+ }
63
+ function kindOf(status) {
64
+ if (status === 401 || status === 403) {
65
+ return "auth";
66
+ }
67
+ // `concurrency_limited` is the plan's own limit and arrives as a 429 like any other, so
68
+ // the status is enough and the code below only reaches the message.
69
+ if (status === 429) {
70
+ return "rate_limit";
71
+ }
72
+ // ceiling: as in the other two adapters, everything else is `other` and is retried.
73
+ return "other";
74
+ }
75
+ async function failure(response, voiceId) {
76
+ const text = await response.text().catch(() => "");
77
+ const message = redact(detailOf(text) || response.statusText);
78
+ const retryAfterMs = retryAfter(response.headers.get("retry-after"));
79
+ return providerError({
80
+ kind: kindOf(response.status),
81
+ // `logic/02` §Q14: `voice_not_found` has to say which voice was asked for; Cartesia's
82
+ // own message does not repeat the id.
83
+ message: `Cartesia answered ${response.status} for voice ${voiceId}: ${message}`,
84
+ ...(retryAfterMs === undefined ? {} : { retryAfterMs }),
85
+ });
86
+ }
87
+ function detailOf(text) {
88
+ const parsed = errorBody.safeParse(safeJson(text));
89
+ if (!parsed.success) {
90
+ return text.trim();
91
+ }
92
+ const { error_code: code, message } = parsed.data;
93
+ // The code is what an integration is told to switch on and the message is the sentence;
94
+ // both are the provider's own words, so both are shown.
95
+ const parts = [code, message].filter((part) => part !== undefined && part !== "");
96
+ return parts.length === 0 ? text.trim() : parts.join(": ");
97
+ }
98
+ function safeJson(text) {
99
+ try {
100
+ return JSON.parse(text);
101
+ }
102
+ catch {
103
+ return undefined;
104
+ }
105
+ }
@@ -0,0 +1,107 @@
1
+ import { z } from "zod";
2
+ import { redact } from "../../kernel/log.js";
3
+ import { providerError } from "../../kernel/ports/model.js";
4
+ import { retryAfter } from "../retry-after.js";
5
+ // The HTTP gateway adapter for ElevenLabs (01-architecture Module boundaries). `fetch`
6
+ // and nothing else: 05-dependencies records global `fetch` at rung 3, and the response
7
+ // body already is the `ReadableStream<Uint8Array>` the port asks for, so an SDK would buy
8
+ // nothing but a dependency.
9
+ export const elevenLabsBase = "https://api.elevenlabs.io/v1";
10
+ // The /stream endpoint rather than the plain one: `capabilities.streams` is true, and the
11
+ // attempt wrapper measures its 120 s as an idle timeout between chunks only when bytes
12
+ // keep arriving (`logic/01` §Q62).
13
+ export const elevenLabsModel = "eleven_multilingual_v2";
14
+ // mp3 at the port's container, 44.1 kHz, 128 kbps: `kernel/ports/tts.ts` fixes mp3 and
15
+ // `logic/08` step 4 keeps the provider's own sample rate through the concatenation.
16
+ export const elevenLabsFormat = "mp3_44100_128";
17
+ // A wire payload is narrowed, never cast. `detail` is an object on a handled failure and
18
+ // a string on the framework's own; anything else falls back to the raw text.
19
+ const errorBody = z.object({
20
+ detail: z.union([
21
+ z.object({ status: z.string().optional(), message: z.string().optional() }),
22
+ z.string(),
23
+ ]),
24
+ });
25
+ export function elevenLabsTts(deps) {
26
+ return {
27
+ id: "elevenlabs",
28
+ capabilities: { streams: true },
29
+ synthesize: async (req) => {
30
+ const voice = encodeURIComponent(req.voiceId);
31
+ const response = await deps.fetch(`${elevenLabsBase}/text-to-speech/${voice}/stream?output_format=${elevenLabsFormat}`, {
32
+ method: "POST",
33
+ signal: req.signal,
34
+ headers: { "xi-api-key": keyOf(deps), "Content-Type": "application/json" },
35
+ // `logic/08` §Q69: no pre-check on length. A text past the model's limit comes
36
+ // back as the provider's own 400 and that is what the stage shows.
37
+ body: JSON.stringify({ text: req.text, model_id: elevenLabsModel }),
38
+ });
39
+ if (!response.ok) {
40
+ throw await failure(response, req.voiceId);
41
+ }
42
+ if (response.body === null) {
43
+ throw providerError({ kind: "other", message: "ElevenLabs answered with no audio" });
44
+ }
45
+ return { audio: response.body, container: "mp3" };
46
+ },
47
+ };
48
+ }
49
+ function keyOf(deps) {
50
+ const key = deps.key();
51
+ // `logic/02` §Q13: an attempt that finds no key fails rather than calling anonymously.
52
+ // `missing_key` rather than `auth` because the same rule makes it terminal.
53
+ if (key === undefined || key === "") {
54
+ throw providerError({ kind: "missing_key", message: "no ElevenLabs key is stored" });
55
+ }
56
+ return key;
57
+ }
58
+ // Only the adapter can read a vendor's status code, so only the adapter names the kind;
59
+ // the attempt wrapper maps it and nothing downstream classifies again (03-conventions).
60
+ function kindOf(status) {
61
+ if (status === 401 || status === 403) {
62
+ return "auth";
63
+ }
64
+ if (status === 429) {
65
+ return "rate_limit";
66
+ }
67
+ // ceiling: everything else is `other` and is retried, so a text past the character
68
+ // limit fails the same way four times over. A terminal kind for "this request will
69
+ // never work" would have to be added to the port's error contract first, which is not
70
+ // this adapter's to widen.
71
+ return "other";
72
+ }
73
+ async function failure(response, voiceId) {
74
+ const text = await response.text().catch(() => "");
75
+ // The provider's own words, verbatim, through the same redactor the wrapper uses: an
76
+ // error body is free to quote the key back and this is the first place it is held.
77
+ const message = redact(detailOf(text) || response.statusText);
78
+ const retryAfterMs = retryAfter(response.headers.get("retry-after"));
79
+ return providerError({
80
+ kind: kindOf(response.status),
81
+ // `logic/02` §Q14: a rejected voice ID has to be named, and it is the one part of the
82
+ // request the user chose. It is not secret, unlike everything else on the wire.
83
+ message: `ElevenLabs answered ${response.status} for voice ${voiceId}: ${message}`,
84
+ ...(retryAfterMs === undefined ? {} : { retryAfterMs }),
85
+ });
86
+ }
87
+ function detailOf(text) {
88
+ const parsed = errorBody.safeParse(safeJson(text));
89
+ if (!parsed.success) {
90
+ return text.trim();
91
+ }
92
+ const { detail } = parsed.data;
93
+ if (typeof detail === "string") {
94
+ return detail;
95
+ }
96
+ // Both halves are kept: the status is the machine-readable reason
97
+ // (`max_character_limit_exceeded`, `voice_not_found`) and the message is the sentence.
98
+ return [detail.status, detail.message].filter((part) => part !== undefined).join(": ");
99
+ }
100
+ function safeJson(text) {
101
+ try {
102
+ return JSON.parse(text);
103
+ }
104
+ catch {
105
+ return undefined;
106
+ }
107
+ }
@@ -0,0 +1,95 @@
1
+ import { z } from "zod";
2
+ import { redact } from "../../kernel/log.js";
3
+ import { providerError } from "../../kernel/ports/model.js";
4
+ import { retryAfter } from "../retry-after.js";
5
+ // The HTTP gateway adapter for OpenAI's speech endpoint (01-architecture Module
6
+ // boundaries). `fetch` and nothing else; the response body is already the stream the port
7
+ // asks for.
8
+ export const openAiAudioBase = "https://api.openai.com/v1";
9
+ // 05-dependencies lists gpt-4o-mini-tts, tts-1 and tts-1-hd; the first is the current one
10
+ // and the cheapest of the three per character.
11
+ export const openAiTtsModel = "gpt-4o-mini-tts";
12
+ const errorBody = z.object({
13
+ error: z.object({ message: z.string(), code: z.string().nullish() }),
14
+ });
15
+ // A voice the user cloned is addressed as an object rather than by name: the request
16
+ // schema takes either a built-in name (`alloy`, `nova`, ...) or `{ id: "voice_1234" }`,
17
+ // and a custom id sent as a bare string is refused. The prefix is what tells them apart.
18
+ const customVoice = /^voice_/;
19
+ export function openAiTts(deps) {
20
+ return {
21
+ id: "openai-tts",
22
+ capabilities: { streams: true },
23
+ synthesize: async (req) => {
24
+ const response = await deps.fetch(`${openAiAudioBase}/audio/speech`, {
25
+ method: "POST",
26
+ signal: req.signal,
27
+ headers: {
28
+ Authorization: `Bearer ${keyOf(deps)}`,
29
+ "Content-Type": "application/json",
30
+ },
31
+ // `logic/08` §Q69: the 4096-character cap is not checked here. A longer text
32
+ // comes back as OpenAI's own 400 and that is what the stage shows, so a user who
33
+ // chose Whole text learns the limit from the provider that set it.
34
+ body: JSON.stringify({
35
+ model: openAiTtsModel,
36
+ input: req.text,
37
+ voice: voiceOf(req.voiceId),
38
+ response_format: "mp3",
39
+ }),
40
+ });
41
+ if (!response.ok) {
42
+ throw await failure(response, req.voiceId);
43
+ }
44
+ if (response.body === null) {
45
+ throw providerError({ kind: "other", message: "OpenAI answered with no audio" });
46
+ }
47
+ return { audio: response.body, container: "mp3" };
48
+ },
49
+ };
50
+ }
51
+ function voiceOf(voiceId) {
52
+ return customVoice.test(voiceId) ? { id: voiceId } : voiceId;
53
+ }
54
+ function keyOf(deps) {
55
+ const key = deps.key();
56
+ // `logic/02` §Q13: an absent key is terminal, so it never becomes a request.
57
+ if (key === undefined || key === "") {
58
+ throw providerError({ kind: "missing_key", message: "no OpenAI key is stored" });
59
+ }
60
+ return key;
61
+ }
62
+ function kindOf(status) {
63
+ if (status === 401 || status === 403) {
64
+ return "auth";
65
+ }
66
+ if (status === 429) {
67
+ return "rate_limit";
68
+ }
69
+ // ceiling: as in the OpenRouter adapter, a 400 is retried three more times because the
70
+ // port's error contract has no terminal kind for a request that cannot be repaired.
71
+ return "other";
72
+ }
73
+ async function failure(response, voiceId) {
74
+ const text = await response.text().catch(() => "");
75
+ const parsed = errorBody.safeParse(safeJson(text));
76
+ // Verbatim, through the same redactor the wrapper uses: OpenAI's own 401 quotes the
77
+ // start of the key back at the caller.
78
+ const message = redact(parsed.success ? parsed.data.error.message : text.trim() || response.statusText);
79
+ const retryAfterMs = retryAfter(response.headers.get("retry-after"));
80
+ return providerError({
81
+ kind: kindOf(response.status),
82
+ // `logic/02` §Q14: the voice ID is named, so a rejected voice reads differently from
83
+ // a rejected key.
84
+ message: `OpenAI answered ${response.status} for voice ${voiceId}: ${message}`,
85
+ ...(retryAfterMs === undefined ? {} : { retryAfterMs }),
86
+ });
87
+ }
88
+ function safeJson(text) {
89
+ try {
90
+ return JSON.parse(text);
91
+ }
92
+ catch {
93
+ return undefined;
94
+ }
95
+ }
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from "node:util";
3
+ import { configFrom } from "../kernel/config/index.js";
4
+ import { boot } from "../main.js";
5
+ import { openBrowser } from "./open-browser.js";
6
+ const { values } = parseArgs({
7
+ options: {
8
+ port: { type: "string" },
9
+ host: { type: "string" },
10
+ "data-dir": { type: "string" },
11
+ "no-open": { type: "boolean" },
12
+ },
13
+ });
14
+ try {
15
+ const config = configFrom(values, process.env);
16
+ const { paths, url, stop } = await boot(config);
17
+ console.log(`Slopify is running at ${url}`);
18
+ console.log(`Slopify data directory: ${paths.dataDir}`);
19
+ console.log(`Database: ${paths.db}`);
20
+ console.log(`Logs: ${paths.logs}`);
21
+ if (config.host !== "127.0.0.1") {
22
+ console.warn(`WARNING: bound to ${config.host} - anyone who reaches this port controls the app and its keys (no login).`);
23
+ }
24
+ if (config.open) {
25
+ openBrowser(url, (message) => {
26
+ console.warn(message);
27
+ });
28
+ }
29
+ process.on("SIGINT", () => {
30
+ stop().then(() => process.exit(0), () => process.exit(1));
31
+ });
32
+ }
33
+ catch (error) {
34
+ console.error(error instanceof Error ? error.message : String(error));
35
+ process.exit(1);
36
+ }
@@ -0,0 +1,76 @@
1
+ export function createHub(deps) {
2
+ const projects = new Map();
3
+ const globals = new Set();
4
+ // The tally a page needs before anything else happens. The runner emits it when it
5
+ // changes, so the newest one is kept here and replayed to whoever opens next; a page
6
+ // that loads mid-render would otherwise be told nothing is running.
7
+ let tally = { type: "running.count", count: 0 };
8
+ const send = (subscriber, event) => {
9
+ // writeSSE rejects on a socket that is already gone, asynchronously and long after
10
+ // emit() returned, so a dead subscriber is dropped here instead of at the call site.
11
+ subscriber.stream
12
+ .writeSSE({ event: event.type, data: JSON.stringify(event), id: deps.ids.next() })
13
+ .catch((error) => {
14
+ subscriber.drop();
15
+ deps.log.write("warn", "sse.write", { detail: `${event.type}: ${messageOf(error)}` });
16
+ });
17
+ };
18
+ const join = (set, stream, signal, prune) => {
19
+ let finish = () => { };
20
+ const done = new Promise((resolve) => {
21
+ finish = resolve;
22
+ });
23
+ const subscriber = {
24
+ stream,
25
+ done,
26
+ drop: () => {
27
+ if (set.delete(subscriber)) {
28
+ prune();
29
+ }
30
+ finish();
31
+ },
32
+ };
33
+ set.add(subscriber);
34
+ if (signal.aborted) {
35
+ subscriber.drop();
36
+ }
37
+ else {
38
+ signal.addEventListener("abort", subscriber.drop, { once: true });
39
+ }
40
+ return subscriber;
41
+ };
42
+ return {
43
+ subscribe: (projectId, stream, signal) => {
44
+ const set = projects.get(projectId) ?? new Set();
45
+ projects.set(projectId, set);
46
+ return join(set, stream, signal, () => {
47
+ if (set.size === 0) {
48
+ projects.delete(projectId);
49
+ }
50
+ }).done;
51
+ },
52
+ subscribeGlobal: (stream, signal) => {
53
+ const subscriber = join(globals, stream, signal, () => { });
54
+ if (globals.has(subscriber)) {
55
+ send(subscriber, tally);
56
+ }
57
+ return subscriber.done;
58
+ },
59
+ emit: (projectId, event) => {
60
+ for (const subscriber of projects.get(projectId) ?? []) {
61
+ send(subscriber, event);
62
+ }
63
+ },
64
+ emitGlobal: (event) => {
65
+ if (event.type === "running.count") {
66
+ tally = event;
67
+ }
68
+ for (const subscriber of globals) {
69
+ send(subscriber, event);
70
+ }
71
+ },
72
+ };
73
+ }
74
+ function messageOf(error) {
75
+ return error instanceof Error ? error.message : String(error);
76
+ }