@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,55 @@
1
+ import { providerById } from "./model.js";
2
+ import { deleteKey, hasKey, keyOf, upsertKey } from "./repo.js";
3
+ // `logic/02` step 1 says the field shows the key masked and points at
4
+ // `mockup/03-settings.md`, which draws it with nothing legible in it; the scenario's own
5
+ // invariant is that keys never appear outside a provider call. So the mask is a
6
+ // constant: it carries no character of the key and not even its length. Every response
7
+ // that reports a stored key reports this string and nothing else about the value.
8
+ export const keyMask = "••••••••••••";
9
+ export function keyStatus(deps, provider) {
10
+ const stored = hasKey(deps.db, provider);
11
+ return { provider, hasKey: stored, masked: stored ? keyMask : null };
12
+ }
13
+ // `logic/02` step 1: trimmed, stored as given, overwriting any previous one. No format
14
+ // check and no test call (§Q11, §Q17, §Q18).
15
+ export function saveProviderKey(deps, provider, key) {
16
+ if (providerById(provider).auth === "cli") {
17
+ return { ok: false, reason: "cli-provider" };
18
+ }
19
+ const trimmed = key.trim();
20
+ if (trimmed === "") {
21
+ return { ok: false, reason: "blank" };
22
+ }
23
+ try {
24
+ upsertKey(deps.db, provider, trimmed, deps.clock.now().toISOString());
25
+ }
26
+ catch (error) {
27
+ // The only statement in this app whose parameters include a key. node:sqlite names
28
+ // columns and result codes, never bound values, but the error from this one call is
29
+ // rewritten anyway so nothing derived from it can reach a log line or a 500 body.
30
+ throw new Error(`the ${provider} key could not be stored: ${codeOf(error)}`);
31
+ }
32
+ return { ok: true };
33
+ }
34
+ export function removeProviderKey(deps, provider) {
35
+ if (providerById(provider).auth === "cli") {
36
+ return { ok: false, reason: "cli-provider" };
37
+ }
38
+ return deleteKey(deps.db, provider) ? { ok: true } : { ok: false, reason: "absent" };
39
+ }
40
+ // `logic/02` step 7: the key is read at the moment an attempt starts. This reads the row
41
+ // every time and hands back a plain string, so an attempt holds the value it started
42
+ // with and a save or a remove landing mid-run reaches the next attempt only (§Q16).
43
+ export function keyForAttempt(deps, provider) {
44
+ if (providerById(provider).auth === "cli") {
45
+ return { ok: false, reason: "cli-provider" };
46
+ }
47
+ const key = keyOf(deps.db, provider);
48
+ return key === undefined ? { ok: false, reason: "key-missing" } : { ok: true, key };
49
+ }
50
+ function codeOf(error) {
51
+ if (error instanceof Error && "errcode" in error && typeof error.errcode === "number") {
52
+ return `sqlite error ${String(error.errcode)}`;
53
+ }
54
+ return "sqlite error";
55
+ }
@@ -0,0 +1,54 @@
1
+ // The provider catalogue and the settings domain types (02-models, `logic/02`).
2
+ // The three families are the three ports, so the set is named beside them.
3
+ export { providerFamilies } from "../../kernel/ports/model.js";
4
+ // The supported set is `05-dependencies` External services. OpenAI ships two adapters
5
+ // with two ids, one per family: `provider_keys.provider` is the primary key, and a row
6
+ // per family is what lets a user key TTS without keying image generation.
7
+ export const providerIds = [
8
+ "openrouter",
9
+ "claude-code",
10
+ "codex",
11
+ "elevenlabs",
12
+ "openai-tts",
13
+ "cartesia",
14
+ "fal",
15
+ "replicate",
16
+ "openai-image",
17
+ ];
18
+ export const providers = [
19
+ { id: "openrouter", family: "llm", displayName: "OpenRouter", auth: "key" },
20
+ {
21
+ id: "claude-code",
22
+ family: "llm",
23
+ displayName: "Claude Code CLI",
24
+ auth: "cli",
25
+ binary: "claude",
26
+ versionArgs: ["--version"],
27
+ },
28
+ {
29
+ id: "codex",
30
+ family: "llm",
31
+ displayName: "Codex CLI",
32
+ auth: "cli",
33
+ binary: "codex",
34
+ versionArgs: ["--version"],
35
+ },
36
+ { id: "elevenlabs", family: "tts", displayName: "ElevenLabs", auth: "key" },
37
+ { id: "openai-tts", family: "tts", displayName: "OpenAI", auth: "key" },
38
+ { id: "cartesia", family: "tts", displayName: "Cartesia", auth: "key" },
39
+ { id: "fal", family: "image", displayName: "fal.ai", auth: "key" },
40
+ { id: "replicate", family: "image", displayName: "Replicate", auth: "key" },
41
+ { id: "openai-image", family: "image", displayName: "OpenAI", auth: "key" },
42
+ ];
43
+ export function providerById(id) {
44
+ const found = providers.find((provider) => provider.id === id);
45
+ if (found === undefined) {
46
+ // Unreachable while ProviderId is derived from the catalogue; a new id added to one
47
+ // and not the other is a bug, not a user error.
48
+ throw new Error(`the provider catalogue has no entry for ${id}`);
49
+ }
50
+ return found;
51
+ }
52
+ // uiux §Q19: the theme override the Appearance control writes.
53
+ export const appearances = ["system", "light", "dark"];
54
+ export const defaultSettings = { silenceGapSeconds: 3, appearance: "system" };
@@ -0,0 +1,60 @@
1
+ import { z } from "zod";
2
+ import { silenceGapSecondsMax } from "../admission/rules.js";
3
+ import { appearances, defaultSettings } from "./model.js";
4
+ import { readSetting, writeSetting } from "./repo.js";
5
+ // The `settings` table is key/value, so each setting is its own row and a value the app
6
+ // does not understand costs only that one setting.
7
+ const silenceGapKey = "silenceGapSeconds";
8
+ const appearanceKey = "appearance";
9
+ const silenceGapValue = z.number().int().min(0).max(silenceGapSecondsMax);
10
+ const appearanceValue = z.enum(appearances);
11
+ export function readSettings(deps) {
12
+ return {
13
+ silenceGapSeconds: parsed(deps, silenceGapKey, silenceGapValue, defaultSettings.silenceGapSeconds),
14
+ appearance: parsed(deps, appearanceKey, appearanceValue, defaultSettings.appearance),
15
+ };
16
+ }
17
+ export function saveSettings(deps, settings) {
18
+ const fields = [];
19
+ if (!silenceGapValue.safeParse(settings.silenceGapSeconds).success) {
20
+ fields.push({
21
+ field: "silenceGapSeconds",
22
+ message: `The silence gap is a whole number of seconds between 0 and ${String(silenceGapSecondsMax)}.`,
23
+ });
24
+ }
25
+ if (!appearanceValue.safeParse(settings.appearance).success) {
26
+ fields.push({ field: "appearance", message: "Pick System, Light or Dark." });
27
+ }
28
+ if (fields.length > 0) {
29
+ return { ok: false, fields };
30
+ }
31
+ writeSetting(deps.db, silenceGapKey, JSON.stringify(settings.silenceGapSeconds));
32
+ writeSetting(deps.db, appearanceKey, JSON.stringify(settings.appearance));
33
+ return { ok: true, settings };
34
+ }
35
+ // A stored value is JSON, and a hand-edited database is the only way it can be anything
36
+ // but what was saved. Such a row falls back to the default and says so in the log rather
37
+ // than reaching the app or taking the settings page down.
38
+ function parsed(deps, key, schema, fallback) {
39
+ const stored = readSetting(deps.db, key);
40
+ if (stored === undefined) {
41
+ return fallback;
42
+ }
43
+ const result = schema.safeParse(jsonOf(stored));
44
+ if (result.success) {
45
+ return result.data;
46
+ }
47
+ deps.log.write("warn", "settings.invalid", {
48
+ detail: `setting ${key} is not a value this build understands; using the default`,
49
+ });
50
+ return fallback;
51
+ }
52
+ function jsonOf(stored) {
53
+ try {
54
+ return JSON.parse(stored);
55
+ }
56
+ catch {
57
+ // Not JSON at all: the same answer as JSON of the wrong shape, decided by the caller.
58
+ return undefined;
59
+ }
60
+ }
@@ -0,0 +1,21 @@
1
+ import { cliReadiness } from "./cli-status.js";
2
+ import { providers } from "./model.js";
3
+ import { keyedProviders } from "./repo.js";
4
+ // `logic/02` step 5 and §Q135: every supported provider is listed, keyed or not, found
5
+ // or not, so Play can grey one out with a reason instead of hiding it. A keyed provider
6
+ // is ready when a key is stored; a CLI provider when its binary answers. Readiness is
7
+ // computed per request and nothing about a CLI is stored.
8
+ export async function providerStatuses(deps) {
9
+ const keyed = keyedProviders(deps.db);
10
+ return await Promise.all(providers.map(async (provider) => ({
11
+ id: provider.id,
12
+ family: provider.family,
13
+ displayName: provider.displayName,
14
+ readiness: provider.auth === "cli"
15
+ ? await cliReadiness(deps.probe, provider)
16
+ : keyedReadiness(keyed, provider.id),
17
+ })));
18
+ }
19
+ function keyedReadiness(keyed, id) {
20
+ return { kind: "keyed", hasKey: keyed.has(id) };
21
+ }
@@ -0,0 +1,59 @@
1
+ import { z } from "zod";
2
+ import { providerIds } from "./model.js";
3
+ const keyRow = z.object({ key: z.string() });
4
+ const providerRow = z.object({ provider: z.enum(providerIds) });
5
+ // A row naming a provider this build does not know can only come from a hand-edited
6
+ // database or a downgrade. Parsing throws rather than hiding a voice the user saved.
7
+ const voiceRow = z.object({
8
+ id: z.string(),
9
+ provider: z.enum(providerIds),
10
+ name: z.string(),
11
+ voice_id: z.string(),
12
+ });
13
+ const settingRow = z.object({ value: z.string() });
14
+ export function upsertKey(db, provider, key, updatedAt) {
15
+ db.prepare("INSERT INTO provider_keys (provider, key, updated_at) VALUES (?, ?, ?)" +
16
+ " ON CONFLICT(provider) DO UPDATE SET key = excluded.key, updated_at = excluded.updated_at").run(provider, key, updatedAt);
17
+ }
18
+ export function deleteKey(db, provider) {
19
+ const result = db.prepare("DELETE FROM provider_keys WHERE provider = ?").run(provider);
20
+ return Number(result.changes) > 0;
21
+ }
22
+ // The one place a key value leaves the database. Every caller reads it for a single
23
+ // provider call and holds it no longer (`logic/02` §Q16). Nothing under `edge/` calls
24
+ // this: a route asks the two functions below, which never select the `key` column.
25
+ export function keyOf(db, provider) {
26
+ const row = db.prepare("SELECT key FROM provider_keys WHERE provider = ?").get(provider);
27
+ return row === undefined ? undefined : keyRow.parse(row).key;
28
+ }
29
+ // Presence without values: what readiness needs, in one statement.
30
+ export function keyedProviders(db) {
31
+ const rows = db.prepare("SELECT provider FROM provider_keys").all();
32
+ return new Set(rows.map((row) => providerRow.parse(row).provider));
33
+ }
34
+ export function hasKey(db, provider) {
35
+ return db.prepare("SELECT 1 FROM provider_keys WHERE provider = ?").get(provider) !== undefined;
36
+ }
37
+ export function insertVoice(db, voice) {
38
+ db.prepare("INSERT INTO voices (id, provider, name, voice_id) VALUES (?, ?, ?, ?)").run(voice.id, voice.provider, voice.name, voice.voiceId);
39
+ }
40
+ export function listVoices(db) {
41
+ return db
42
+ .prepare("SELECT id, provider, name, voice_id FROM voices ORDER BY provider, name, voice_id")
43
+ .all()
44
+ .map((row) => toVoice(voiceRow.parse(row)));
45
+ }
46
+ export function deleteVoice(db, id) {
47
+ return Number(db.prepare("DELETE FROM voices WHERE id = ?").run(id).changes) > 0;
48
+ }
49
+ export function readSetting(db, key) {
50
+ const row = db.prepare("SELECT value FROM settings WHERE key = ?").get(key);
51
+ return row === undefined ? undefined : settingRow.parse(row).value;
52
+ }
53
+ export function writeSetting(db, key, value) {
54
+ db.prepare("INSERT INTO settings (key, value) VALUES (?, ?)" +
55
+ " ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(key, value);
56
+ }
57
+ function toVoice(row) {
58
+ return { id: row.id, provider: row.provider, name: row.name, voiceId: row.voice_id };
59
+ }
@@ -0,0 +1,48 @@
1
+ import { isUniqueConstraint } from "../../kernel/db/index.js";
2
+ import { providerById } from "./model.js";
3
+ import { deleteVoice, insertVoice, listVoices } from "./repo.js";
4
+ export const voiceNameMax = 200;
5
+ export const voiceIdMax = 200;
6
+ // `logic/02` step 3: a non-empty name and a non-empty voice ID, the ID unique within its
7
+ // provider, names free to repeat. Nothing is verified against the provider - a wrong ID
8
+ // is discovered when the audio stage uses it (§Q14), which is why a key is not required
9
+ // here either.
10
+ export function addVoice(deps, draft) {
11
+ if (providerById(draft.provider).family !== "tts") {
12
+ return { ok: false, reason: "not-a-tts-provider" };
13
+ }
14
+ const name = draft.name.trim();
15
+ const voiceId = draft.voiceId.trim();
16
+ if (name === "") {
17
+ return { ok: false, reason: "blank-name" };
18
+ }
19
+ if (name.length > voiceNameMax) {
20
+ return { ok: false, reason: "name-too-long" };
21
+ }
22
+ if (voiceId === "") {
23
+ return { ok: false, reason: "blank-voice-id" };
24
+ }
25
+ if (voiceId.length > voiceIdMax) {
26
+ return { ok: false, reason: "voice-id-too-long" };
27
+ }
28
+ const voice = { id: deps.ids.next(), provider: draft.provider, name, voiceId };
29
+ try {
30
+ insertVoice(deps.db, voice);
31
+ }
32
+ catch (error) {
33
+ // UNIQUE(provider, voice_id) is the rule, so the schema is what enforces it: a
34
+ // read-then-write check would answer from a row that a second writer could delete
35
+ // between the two statements. The raw SQLite error never leaves this function.
36
+ if (isUniqueConstraint(error)) {
37
+ return { ok: false, reason: "duplicate-voice-id" };
38
+ }
39
+ throw error;
40
+ }
41
+ return { ok: true, voice };
42
+ }
43
+ export function removeVoice(deps, id) {
44
+ return deleteVoice(deps.db, id) ? { ok: true } : { ok: false };
45
+ }
46
+ export function voices(deps) {
47
+ return listVoices(deps.db);
48
+ }
@@ -0,0 +1,16 @@
1
+ // The asset name in `/files/:projectId/:asset`. It sits apart from `downloads.ts` because
2
+ // the project page builds the same URL for every image, player and download link it
3
+ // draws, and `downloads.ts` reads the disk: importing it into the SPA would drag
4
+ // `node:fs` and the zip encoder into the browser bundle. Nothing here touches IO.
5
+ // An output's role is its asset name; images add their place in the slideshow, and the
6
+ // instructions add their stage, those being the two roles a project holds more than one of
7
+ // (logic/06 step 4 and logic/07 step 4 both store what the stage sent).
8
+ export function assetOf(output) {
9
+ const asset = output.role.replaceAll("_", "-");
10
+ if (output.role === "instructions") {
11
+ return `${output.stageKind}-${asset}`;
12
+ }
13
+ return output.role === "image" && output.meta.index !== undefined
14
+ ? `${asset}-${String(output.meta.index)}`
15
+ : asset;
16
+ }
@@ -0,0 +1,35 @@
1
+ import { rmSync } from "node:fs";
2
+ import { derive } from "../../kernel/runner/graph.js";
3
+ import { projectExists, stagesOf } from "../admission/repo.js";
4
+ import { projectDir } from "./layout.js";
5
+ export function deleteProject(deps, projectId) {
6
+ if (!projectExists(deps.db, projectId)) {
7
+ return { ok: false, reason: "no-project" };
8
+ }
9
+ if (derive(stagesOf(deps.db, projectId)) === "running") {
10
+ return { ok: false, reason: "running" };
11
+ }
12
+ // Files first, rows second. A folder that would not go leaves the project listed, which
13
+ // is what `logic/14`'s unhappy path asks for; rows removed first would leave the files
14
+ // orphaned under a project nothing names.
15
+ const dir = projectDir(deps.paths, projectId);
16
+ try {
17
+ rmSync(dir, { recursive: true, force: true });
18
+ }
19
+ catch (error) {
20
+ deps.log.write("warn", "project.delete", {
21
+ projectId,
22
+ detail: messageOf(error),
23
+ });
24
+ return { ok: false, reason: "files", detail: messageOf(error) };
25
+ }
26
+ // Stages, attempts, pieces and outputs go with it: every one of those tables declares
27
+ // ON DELETE CASCADE on the project, and the connection runs with foreign keys on
28
+ // (kernel/db/index.ts). The invariant of `logic/14` is that a deleted project leaves no
29
+ // files and no rows.
30
+ deps.db.prepare("DELETE FROM projects WHERE id = ?").run(projectId);
31
+ return { ok: true };
32
+ }
33
+ function messageOf(error) {
34
+ return error instanceof Error ? error.message : String(error);
35
+ }
@@ -0,0 +1,100 @@
1
+ import { readFileSync, statSync } from "node:fs";
2
+ import { extname } from "node:path";
3
+ import { zipSync } from "fflate";
4
+ import { assetOf } from "./asset-name.js";
5
+ import { outputPath } from "./layout.js";
6
+ import { outputsOf, projectTitle } from "./repo.js";
7
+ const contentTypes = {
8
+ ".md": "text/markdown; charset=utf-8",
9
+ ".txt": "text/plain; charset=utf-8",
10
+ ".json": "application/json",
11
+ ".png": "image/png",
12
+ ".jpg": "image/jpeg",
13
+ ".jpeg": "image/jpeg",
14
+ ".webp": "image/webp",
15
+ ".mp3": "audio/mpeg",
16
+ ".m4a": "audio/mp4",
17
+ ".wav": "audio/wav",
18
+ ".flac": "audio/flac",
19
+ ".ogg": "audio/ogg",
20
+ ".opus": "audio/opus",
21
+ ".mp4": "video/mp4",
22
+ };
23
+ // Leaves room for "-<asset>.<ext>" inside a 255-byte filename, and keeps a download
24
+ // folder readable when a title runs to the 200 characters the schema allows.
25
+ const slugLimit = 60;
26
+ export function slugOf(title) {
27
+ const slug = title
28
+ .normalize("NFKD")
29
+ .replace(/[\u0300-\u036f]/g, "")
30
+ .toLowerCase()
31
+ .replace(/[^a-z0-9]+/g, "-")
32
+ .slice(0, slugLimit)
33
+ .replace(/^-+|-+$/g, "");
34
+ return slug === "" ? "project" : slug;
35
+ }
36
+ // `assetOf` moved to asset-name.ts so the SPA can build the same URLs; it is still part
37
+ // of this module's surface, because a download name is built from it.
38
+ export { assetOf };
39
+ // logic/14 §Q116: "single files as `<title-slug>-<asset>.<ext>`".
40
+ export function downloadName(slug, output) {
41
+ return `${slug}-${assetOf(output)}${extname(output.path)}`;
42
+ }
43
+ export function findDownload(deps, projectId, asset) {
44
+ const title = projectTitle(deps.db, projectId);
45
+ if (title === undefined) {
46
+ return { ok: false, reason: "unknown-project" };
47
+ }
48
+ const output = outputsOf(deps.db, projectId).find((candidate) => assetOf(candidate) === asset);
49
+ if (output === undefined) {
50
+ return { ok: false, reason: "unknown-asset" };
51
+ }
52
+ const path = outputPath(deps.paths, projectId, output.path);
53
+ const stats = sizeOf(path);
54
+ if (stats === undefined) {
55
+ return { ok: false, reason: "missing-file" };
56
+ }
57
+ return {
58
+ ok: true,
59
+ download: {
60
+ path,
61
+ filename: downloadName(slugOf(title), output),
62
+ bytes: stats,
63
+ contentType: contentTypes[extname(output.path).toLowerCase()] ?? "application/octet-stream",
64
+ },
65
+ };
66
+ }
67
+ // logic/14 §Q116: "download all" images as `<title-slug>-images.zip`, thumbnail included.
68
+ export function imagesZip(deps, projectId) {
69
+ const title = projectTitle(deps.db, projectId);
70
+ if (title === undefined) {
71
+ return { ok: false, reason: "unknown-project" };
72
+ }
73
+ const slug = slugOf(title);
74
+ const entries = {};
75
+ for (const output of outputsOf(deps.db, projectId)) {
76
+ if (output.role !== "image" && output.role !== "thumbnail") {
77
+ continue;
78
+ }
79
+ const path = outputPath(deps.paths, projectId, output.path);
80
+ if (sizeOf(path) === undefined) {
81
+ // A missing file is the project page's problem per logic/14; the rest still zips.
82
+ continue;
83
+ }
84
+ // Images are already compressed formats, so deflating them costs time and saves
85
+ // nothing. ceiling: 60 images (logic/05 §Q39) are read into memory at once; a set
86
+ // large enough to hurt would move to fflate's streaming Zip.
87
+ entries[`${slug}-${assetOf(output)}${extname(output.path)}`] = [
88
+ readFileSync(path),
89
+ { level: 0 },
90
+ ];
91
+ }
92
+ if (Object.keys(entries).length === 0) {
93
+ return { ok: false, reason: "no-images" };
94
+ }
95
+ return { ok: true, filename: `${slug}-images.zip`, bytes: zipSync(entries) };
96
+ }
97
+ function sizeOf(path) {
98
+ const stats = statSync(path, { throwIfNoEntry: false });
99
+ return stats === undefined || !stats.isFile() ? undefined : stats.size;
100
+ }
@@ -0,0 +1,59 @@
1
+ import { extname, isAbsolute, relative, resolve } from "node:path";
2
+ export function projectDir(paths, projectId) {
3
+ return contained(paths.projects, projectId);
4
+ }
5
+ export function outputPath(paths, projectId, relativePath) {
6
+ return contained(projectDir(paths, projectId), relativePath);
7
+ }
8
+ export function stagingPath(paths, stagedFileId) {
9
+ return contained(paths.staging, stagedFileId);
10
+ }
11
+ // Every asset of a project lives under its own folder (logic/14 §Q115); an id or a
12
+ // stored path that resolves anywhere else is a bug in whatever produced it.
13
+ function contained(root, path) {
14
+ const target = resolve(root, path);
15
+ const inside = relative(root, target);
16
+ if (inside === "" || inside.startsWith("..") || isAbsolute(inside)) {
17
+ throw new Error(`${path} resolves outside ${root}`);
18
+ }
19
+ return target;
20
+ }
21
+ // logic/14 step 2 fixes the names inside a project folder, so a provided file is
22
+ // stored under the name its role dictates and keeps only its extension. The stage is part
23
+ // of the name for the one role more than one stage produces: research and the article each
24
+ // store what they sent (logic/06 step 4, logic/07 step 4), and the project page offers
25
+ // them per stage ("Show instructions", uiux/screens/08-project).
26
+ export function outputFileName(role, index, extension, stageKind) {
27
+ switch (role) {
28
+ case "notes":
29
+ return "research.txt";
30
+ case "article_md":
31
+ return "article.md";
32
+ case "article_txt":
33
+ return "article.txt";
34
+ case "sources":
35
+ return "sources.txt";
36
+ case "glossary":
37
+ return "glossary.txt";
38
+ case "render_params":
39
+ return "render.json";
40
+ case "instructions":
41
+ return `instructions-${stageKind}.txt`;
42
+ case "video":
43
+ return "video.mp4";
44
+ case "audio_body":
45
+ return `audio-body${extension}`;
46
+ case "audio_intro":
47
+ return `audio-intro${extension}`;
48
+ case "audio_outro":
49
+ return `audio-outro${extension}`;
50
+ case "thumbnail":
51
+ return `thumbnail${extension}`;
52
+ case "image":
53
+ return `images/${String(index).padStart(3, "0")}${extension}`;
54
+ }
55
+ }
56
+ export function extensionOf(filename) {
57
+ const extension = extname(filename).toLowerCase();
58
+ return /^\.[a-z0-9]{1,8}$/.test(extension) ? extension : "";
59
+ }
@@ -0,0 +1,20 @@
1
+ export { stageKinds } from "../../kernel/pipeline.js";
2
+ // The stages whose content arrives as a file. Research and article take pasted text
3
+ // instead (logic/05 §Q37), and video is always generated (logic/01 step 5).
4
+ export const uploadableStageKinds = ["audio", "images", "thumbnail"];
5
+ export const outputRoles = [
6
+ "notes",
7
+ "article_md",
8
+ "article_txt",
9
+ "sources",
10
+ "glossary",
11
+ "audio_body",
12
+ "audio_intro",
13
+ "audio_outro",
14
+ "image",
15
+ "thumbnail",
16
+ "video",
17
+ "render_params",
18
+ "instructions",
19
+ ];
20
+ export const stagedFileStates = ["copying", "staged"];
@@ -0,0 +1,102 @@
1
+ import { readdirSync, rmSync, unlinkSync } from "node:fs";
2
+ import { join, relative, sep } from "node:path";
3
+ // Files the database does not know about, and staged uploads that never reached a
4
+ // project, are removed at start (logic/05 §Q44, logic/14 step 5).
5
+ export function reconcileStorage(db, paths) {
6
+ const projects = idsOf(db, "SELECT id FROM projects", "id");
7
+ const kept = new Set();
8
+ for (const row of db.prepare("SELECT project_id, path FROM outputs").all()) {
9
+ const projectId = row.project_id;
10
+ const path = row.path;
11
+ if (typeof projectId === "string" && typeof path === "string") {
12
+ kept.add(`${projectId}/${slashed(path)}`);
13
+ }
14
+ }
15
+ // A stage's unfinished work is on the project too: `logic/08` §Q66 keeps the audio
16
+ // chunks a previous run finished so a manual retry re-runs only the failed ones, and
17
+ // `logic/13` step 2 keeps them through a cancel. A chunk is not an output - the
18
+ // concatenated body is the project's only body audio (§Q65) - so the file it wrote is
19
+ // named by its `stage_pieces` payload instead, and that is as much of a record as an
20
+ // outputs row.
21
+ for (const row of db
22
+ .prepare("SELECT stages.project_id AS project_id, stage_pieces.payload AS payload FROM stage_pieces JOIN stages ON stages.id = stage_pieces.stage_id WHERE stage_pieces.payload IS NOT NULL")
23
+ .all()) {
24
+ const projectId = row.project_id;
25
+ const file = pieceFile(row.payload);
26
+ if (typeof projectId === "string" && file !== undefined) {
27
+ kept.add(`${projectId}/${slashed(file)}`);
28
+ }
29
+ }
30
+ let orphanFiles = 0;
31
+ for (const entry of readdirSync(paths.projects, { withFileTypes: true })) {
32
+ const path = join(paths.projects, entry.name);
33
+ if (!entry.isDirectory()) {
34
+ unlinkSync(path);
35
+ orphanFiles += 1;
36
+ continue;
37
+ }
38
+ if (!projects.has(entry.name)) {
39
+ orphanFiles += filesUnder(path).length;
40
+ rmSync(path, { recursive: true, force: true });
41
+ continue;
42
+ }
43
+ for (const file of filesUnder(path)) {
44
+ if (kept.has(`${entry.name}/${slashed(file)}`)) {
45
+ continue;
46
+ }
47
+ unlinkSync(join(path, file));
48
+ orphanFiles += 1;
49
+ }
50
+ }
51
+ let stagedFiles = 0;
52
+ for (const file of filesUnder(paths.staging)) {
53
+ unlinkSync(join(paths.staging, file));
54
+ stagedFiles += 1;
55
+ }
56
+ db.exec("DELETE FROM staged_files");
57
+ return { orphanFiles, stagedFiles };
58
+ }
59
+ // The one field read inside a payload whose shape belongs to the stage that wrote it: a
60
+ // project-relative path the piece left on disk. A payload without one is any other kind
61
+ // of piece and names no file. Exported because a re-run drops the same pieces the boot
62
+ // sweep keeps, and the two have to agree on which file a piece is holding.
63
+ export function pieceFile(payload) {
64
+ if (typeof payload !== "string") {
65
+ return undefined;
66
+ }
67
+ let parsed;
68
+ try {
69
+ parsed = JSON.parse(payload);
70
+ }
71
+ catch {
72
+ return undefined;
73
+ }
74
+ if (typeof parsed !== "object" || parsed === null || !("file" in parsed)) {
75
+ return undefined;
76
+ }
77
+ const file = parsed.file;
78
+ return typeof file === "string" && file !== "" ? file : undefined;
79
+ }
80
+ function idsOf(db, sql, column) {
81
+ const ids = new Set();
82
+ for (const row of db.prepare(sql).all()) {
83
+ const id = row[column];
84
+ if (typeof id === "string") {
85
+ ids.add(id);
86
+ }
87
+ }
88
+ return ids;
89
+ }
90
+ function filesUnder(root) {
91
+ const files = [];
92
+ for (const entry of readdirSync(root, { withFileTypes: true, recursive: true })) {
93
+ if (entry.isFile()) {
94
+ files.push(relative(root, join(entry.parentPath, entry.name)));
95
+ }
96
+ }
97
+ return files;
98
+ }
99
+ // outputs.path is stored with forward slashes; the filesystem may hand back backslashes.
100
+ function slashed(path) {
101
+ return path.split(sep).join("/");
102
+ }