@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,90 @@
1
+ import { zValidator } from "@hono/zod-validator";
2
+ import { Hono } from "hono";
3
+ import { z } from "zod";
4
+ import { appearances, providerIds } from "../../slices/settings/model.js";
5
+ import { readSettings, saveSettings } from "../../slices/settings/playback.js";
6
+ import { addVoice, removeVoice, voiceIdMax, voiceNameMax, voices, } from "../../slices/settings/voices.js";
7
+ import { onInvalid, problem, titleOf } from "./problem.js";
8
+ const idParam = z.object({ id: z.string().min(1).max(64) });
9
+ // Shape only. The rules - trimming, emptiness, length, the provider speaking at all -
10
+ // are the slice's, so one set of messages reaches the form (03-conventions).
11
+ const voiceBody = z.object({
12
+ provider: z.enum(providerIds),
13
+ name: z.string(),
14
+ voiceId: z.string(),
15
+ });
16
+ const playbackBody = z.object({
17
+ silenceGapSeconds: z.number(),
18
+ appearance: z.enum(appearances),
19
+ });
20
+ // The return type is inferred so Hono keeps the route types the SPA's client is
21
+ // generated from; see stagingRoutes.
22
+ export function settingsRoutes(deps) {
23
+ const playback = { db: deps.db, log: deps.log };
24
+ const voiceDeps = { db: deps.db, ids: deps.ids };
25
+ return new Hono()
26
+ .get("/", (c) => c.json(readSettings(playback)))
27
+ .put("/", zValidator("json", playbackBody, onInvalid), (c) => {
28
+ const result = saveSettings(playback, c.req.valid("json"));
29
+ if (!result.ok) {
30
+ return problem(c, {
31
+ status: 400,
32
+ title: titleOf(400),
33
+ detail: "These settings cannot be saved; the listed fields need attention.",
34
+ extensions: { fields: result.fields },
35
+ });
36
+ }
37
+ return c.json(result.settings);
38
+ })
39
+ .get("/voices", (c) => c.json({ voices: voices(voiceDeps) }))
40
+ .post("/voices", zValidator("json", voiceBody, onInvalid), (c) => {
41
+ const result = addVoice(voiceDeps, c.req.valid("json"));
42
+ if (!result.ok) {
43
+ // `logic/02` §Q18: a voice ID already listed for its provider is a conflict with
44
+ // a row that exists, which the form shows under the Voice ID input.
45
+ return result.reason === "duplicate-voice-id"
46
+ ? problem(c, {
47
+ status: 409,
48
+ title: titleOf(409),
49
+ detail: "This voice ID is already listed for this provider.",
50
+ })
51
+ : problem(c, {
52
+ status: 400,
53
+ title: titleOf(400),
54
+ detail: "This voice cannot be added; the listed fields need attention.",
55
+ extensions: { fields: [fieldOf(result.reason)] },
56
+ });
57
+ }
58
+ return c.json(result.voice, 201);
59
+ })
60
+ .delete("/voices/:id", zValidator("param", idParam, onInvalid), (c) => {
61
+ if (!removeVoice(voiceDeps, c.req.valid("param").id).ok) {
62
+ return problem(c, {
63
+ status: 404,
64
+ title: titleOf(404),
65
+ detail: "No voice has that id.",
66
+ });
67
+ }
68
+ return c.body(null, 204);
69
+ });
70
+ }
71
+ function fieldOf(reason) {
72
+ switch (reason) {
73
+ case "blank-name":
74
+ return { field: "name", message: "A voice name is required." };
75
+ case "name-too-long":
76
+ return {
77
+ field: "name",
78
+ message: `A voice name is at most ${String(voiceNameMax)} characters.`,
79
+ };
80
+ case "blank-voice-id":
81
+ return { field: "voiceId", message: "A voice ID is required." };
82
+ case "voice-id-too-long":
83
+ return {
84
+ field: "voiceId",
85
+ message: `A voice ID is at most ${String(voiceIdMax)} characters.`,
86
+ };
87
+ case "not-a-tts-provider":
88
+ return { field: "provider", message: "Pick a text-to-speech provider." };
89
+ }
90
+ }
@@ -0,0 +1,104 @@
1
+ import { Readable } from "node:stream";
2
+ import { Busboy } from "@fastify/busboy";
3
+ import { zValidator } from "@hono/zod-validator";
4
+ import { Hono } from "hono";
5
+ import { z } from "zod";
6
+ import { uploadableStageKinds } from "../../slices/storage/model.js";
7
+ import { stagedFiles } from "../../slices/storage/repo.js";
8
+ import { discardStagedFile, stageUpload } from "../../slices/storage/staging.js";
9
+ import { onInvalid, problem, titleOf } from "./problem.js";
10
+ const kindParam = z.object({ kind: z.enum(uploadableStageKinds) });
11
+ const idParam = z.object({ id: z.string().min(1).max(64) });
12
+ // The return type is inferred on purpose: annotating it would erase the route types the
13
+ // SPA's client is generated from (02-models §Q27).
14
+ export function stagingRoutes(deps) {
15
+ const storage = {
16
+ db: deps.db,
17
+ paths: deps.paths,
18
+ ids: deps.ids,
19
+ clock: deps.clock,
20
+ log: deps.log,
21
+ // The slice never reaches for the hub: progress is a function it is handed.
22
+ emit: (event) => {
23
+ deps.hub.emitGlobal(event);
24
+ },
25
+ };
26
+ return new Hono()
27
+ .get("/", (c) => c.json({ files: stagedFiles(deps.db) }))
28
+ .post("/:kind", zValidator("param", kindParam, onInvalid), async (c) => {
29
+ const contentType = c.req.header("content-type");
30
+ if (contentType === undefined || !contentType.startsWith("multipart/form-data")) {
31
+ return problem(c, {
32
+ status: 415,
33
+ title: titleOf(415),
34
+ detail: "An upload is sent as multipart/form-data with the file in one part.",
35
+ });
36
+ }
37
+ const body = c.req.raw.body;
38
+ if (body === null) {
39
+ return problem(c, {
40
+ status: 400,
41
+ title: titleOf(400),
42
+ detail: "The request carried no body.",
43
+ });
44
+ }
45
+ const result = await readUpload(storage, c.req.valid("param").kind, contentType, body);
46
+ if (result === undefined) {
47
+ return problem(c, {
48
+ status: 400,
49
+ title: titleOf(400),
50
+ detail: "The request carried no file part.",
51
+ });
52
+ }
53
+ if (!result.ok) {
54
+ return problem(c, { status: 400, title: titleOf(400), detail: detailOf(result.reason) });
55
+ }
56
+ return c.json(result.file, 201);
57
+ })
58
+ .delete("/:id", zValidator("param", idParam, onInvalid), (c) => {
59
+ const result = discardStagedFile(storage, c.req.valid("param").id);
60
+ if (!result.ok) {
61
+ return problem(c, {
62
+ status: 404,
63
+ title: titleOf(404),
64
+ detail: "No staged file has that id.",
65
+ });
66
+ }
67
+ return c.body(null, 204);
68
+ });
69
+ }
70
+ // The bytes go from the socket to the parser to the disk. Nothing collects them: the
71
+ // platform's own formData() materialises a whole part in memory first, which an
72
+ // uncapped audio or video upload cannot afford.
73
+ function readUpload(storage, stageKind, contentType, body) {
74
+ return new Promise((resolve, reject) => {
75
+ const parser = new Busboy({
76
+ headers: { "content-type": contentType },
77
+ limits: { files: 1, fields: 8 },
78
+ // The parser hands the name through untouched: silently renaming a file the user
79
+ // picked would record a name nobody chose, so the slice rejects instead.
80
+ preservePath: true,
81
+ });
82
+ let started = false;
83
+ parser.on("file", (_field, stream, filename) => {
84
+ started = true;
85
+ stageUpload(storage, {
86
+ stageKind,
87
+ originalFilename: typeof filename === "string" ? filename : "",
88
+ content: stream,
89
+ }).then(resolve, reject);
90
+ });
91
+ parser.on("error", reject);
92
+ parser.on("finish", () => {
93
+ if (!started) {
94
+ resolve(undefined);
95
+ }
96
+ });
97
+ Readable.fromWeb(body).pipe(parser);
98
+ });
99
+ }
100
+ function detailOf(reason) {
101
+ return reason === "empty-file"
102
+ ? "The uploaded file is empty."
103
+ : "The file name may not be empty or contain a path separator.";
104
+ }
@@ -0,0 +1,29 @@
1
+ import { Hono } from "hono";
2
+ import { dismissNotice, noticeSeen } from "../../slices/telemetry/machine.js";
3
+ // The return type is inferred so Hono keeps the route types the SPA's client is
4
+ // generated from; see stagingRoutes.
5
+ export function telemetryRoutes(deps) {
6
+ const telemetry = {
7
+ db: deps.db,
8
+ ids: deps.ids,
9
+ clock: deps.clock,
10
+ log: deps.log,
11
+ appVersion: deps.version,
12
+ };
13
+ return (new Hono()
14
+ // What the SPA asks before it shows the first-run notice
15
+ // (mockup/02-first-run-notice.md). The app version comes with it because the notice
16
+ // names the version that goes out in every report, and the promise has to be made
17
+ // with the number it is true of.
18
+ .get("/notice", (c) => c.json({ seen: noticeSeen(deps.db), appVersion: deps.version }))
19
+ // "Got it" is the only control on that modal, and pressing it is what creates the
20
+ // machine id (logic/16 step 1). Idempotent: a second press, a reload or a second tab
21
+ // finds the machine already there and mints nothing.
22
+ .post("/notice", (c) => {
23
+ dismissNotice(telemetry);
24
+ deps.flushSoon();
25
+ // The machine id stays on this machine. Nothing needs it here, so nothing sends
26
+ // it back out of the process that made it.
27
+ return c.json({ seen: true, appVersion: deps.version });
28
+ }));
29
+ }
@@ -0,0 +1,19 @@
1
+ import { Hono } from "hono";
2
+ import { allTelemetryEvents, machineOf } from "../../slices/telemetry/repo.js";
3
+ import { usageOf } from "../../slices/telemetry/usage.js";
4
+ // The data behind `uiux/screens/10-usage.md`: the five counters, the "Tokens by stage"
5
+ // table, and the machine id with the app version. logic/16 step 6 computes all of it from
6
+ // the local event log, "independent of delivery", so this route never touches the
7
+ // collector and answers just as well offline.
8
+ // The return type is inferred so Hono keeps the route types the SPA's client is
9
+ // generated from; see stagingRoutes.
10
+ export function usageRoutes(deps) {
11
+ return new Hono().get("/", (c) => c.json(usageOf({
12
+ events: allTelemetryEvents(deps.db),
13
+ // The machine id is shown on this page and goes no further: the server is bound to
14
+ // loopback and the SPA reading it is this machine's own. Nothing puts it in a
15
+ // payload - the collector is told it once, in the envelope (logic/16 §Q129).
16
+ machineId: machineOf(deps.db)?.machineId ?? null,
17
+ appVersion: deps.version,
18
+ })));
19
+ }
@@ -0,0 +1,20 @@
1
+ import { spawn } from "node:child_process";
2
+ export function browserCommand(platform, url) {
3
+ if (platform === "darwin") {
4
+ return ["open", [url]];
5
+ }
6
+ if (platform === "win32") {
7
+ // `start` is a cmd builtin, and its first quoted argument is the window title.
8
+ return ["cmd", ["/c", "start", "", url]];
9
+ }
10
+ return ["xdg-open", [url]];
11
+ }
12
+ export function openBrowser(url, warn) {
13
+ const [command, args] = browserCommand(process.platform, url);
14
+ const child = spawn(command, [...args], { stdio: "ignore", detached: true });
15
+ // No browser is a nuisance, not a failure: the URL is already on the terminal.
16
+ child.on("error", (error) => {
17
+ warn(`Could not open a browser (${error.message}). Open ${url} yourself.`);
18
+ });
19
+ child.unref();
20
+ }
@@ -0,0 +1,18 @@
1
+ import { setTimeout as delay } from "node:timers/promises";
2
+ export const systemClock = {
3
+ now: () => new Date(),
4
+ sleep: async (ms, signal) => {
5
+ if (signal === undefined) {
6
+ await delay(ms);
7
+ return;
8
+ }
9
+ try {
10
+ await delay(ms, undefined, { signal });
11
+ }
12
+ catch (error) {
13
+ // node:timers rejects with its own AbortError; the caller asked to be told why the
14
+ // wait was cut short, which is what every other abort path in the app carries.
15
+ throw signal.aborted ? signal.reason : error;
16
+ }
17
+ },
18
+ };
@@ -0,0 +1,32 @@
1
+ import { homedir } from "node:os";
2
+ import { join, resolve } from "node:path";
3
+ const defaultPort = 4242;
4
+ const defaultHost = "127.0.0.1";
5
+ export function configFrom(flags, env) {
6
+ const port = flags.port ?? env.SLOPIFY_PORT;
7
+ const host = flags.host ?? env.SLOPIFY_HOST ?? defaultHost;
8
+ const dataDir = flags["data-dir"] ?? env.SLOPIFY_DATA_DIR ?? join(homedir(), ".slopify");
9
+ if (host.trim() === "") {
10
+ throw new Error("host must not be empty");
11
+ }
12
+ return {
13
+ port: port === undefined ? defaultPort : parsePort(port),
14
+ host,
15
+ dataDir: resolve(dataDir),
16
+ open: flags["no-open"] === true ? false : !isTruthy(env.SLOPIFY_NO_OPEN),
17
+ };
18
+ }
19
+ function parsePort(value) {
20
+ const port = Number(value.trim());
21
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
22
+ throw new Error(`invalid port ${JSON.stringify(value)}: expected an integer between 1 and 65535`);
23
+ }
24
+ return port;
25
+ }
26
+ function isTruthy(value) {
27
+ if (value === undefined) {
28
+ return false;
29
+ }
30
+ const normalised = value.trim().toLowerCase();
31
+ return normalised !== "" && normalised !== "0" && normalised !== "false";
32
+ }
@@ -0,0 +1,24 @@
1
+ import { chmodSync, existsSync } from "node:fs";
2
+ import { DatabaseSync } from "node:sqlite";
3
+ // SQLITE_CONSTRAINT_UNIQUE. The extended result code node:sqlite puts on the error it
4
+ // throws when a UNIQUE index rejects a row.
5
+ const uniqueConstraint = 2067;
6
+ export function openDb(file) {
7
+ const db = new DatabaseSync(file, { enableForeignKeyConstraints: true, timeout: 5000 });
8
+ db.exec("PRAGMA journal_mode = WAL");
9
+ // Provider keys live in this file. The data directory is already 0700, but SQLite
10
+ // creates the database and its WAL sidecars at 0644, which would survive a copy out.
11
+ if (file !== ":memory:") {
12
+ for (const path of [file, `${file}-wal`, `${file}-shm`]) {
13
+ if (existsSync(path)) {
14
+ chmodSync(path, 0o600);
15
+ }
16
+ }
17
+ }
18
+ return db;
19
+ }
20
+ // Which uniqueness rule was broken is the caller's to know from the statement it ran;
21
+ // this only says that the schema, not the code, refused the row.
22
+ export function isUniqueConstraint(error) {
23
+ return error instanceof Error && "errcode" in error && error.errcode === uniqueConstraint;
24
+ }
@@ -0,0 +1,59 @@
1
+ import { readdirSync, readFileSync } from "node:fs";
2
+ // Resolved from this module rather than from the working directory, so it holds both
3
+ // under Vitest (src/kernel/db/migrations) and after the build (dist/kernel/db/migrations,
4
+ // filled by packages/app/scripts/copy-migrations.mjs).
5
+ const migrationsDir = new URL("./migrations/", import.meta.url);
6
+ export function migrate(db, clock) {
7
+ const files = readdirSync(migrationsDir)
8
+ .filter((file) => file.endsWith(".sql"))
9
+ .sort();
10
+ const known = files.map(versionOf);
11
+ const applied = appliedVersions(db);
12
+ const newest = Math.max(0, ...applied);
13
+ const newestKnown = Math.max(0, ...known);
14
+ if (newest > newestKnown) {
15
+ throw new Error(`database schema ${newest} is newer than this app knows (${newestKnown})`);
16
+ }
17
+ for (const [index, file] of files.entries()) {
18
+ const version = known[index];
19
+ if (version === undefined || applied.has(version)) {
20
+ continue;
21
+ }
22
+ db.exec("BEGIN");
23
+ try {
24
+ db.exec(readFileSync(new URL(file, migrationsDir), "utf8"));
25
+ db.prepare("INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)").run(version, clock.now().toISOString());
26
+ db.exec("COMMIT");
27
+ }
28
+ catch (error) {
29
+ db.exec("ROLLBACK");
30
+ throw error;
31
+ }
32
+ }
33
+ }
34
+ function versionOf(file) {
35
+ const version = Number(file.slice(0, 4));
36
+ if (!Number.isInteger(version) || version < 1) {
37
+ throw new Error(`migration ${file} does not start with a version number`);
38
+ }
39
+ return version;
40
+ }
41
+ // The first migration creates schema_migrations itself, so an absent table means
42
+ // nothing has been applied yet.
43
+ function appliedVersions(db) {
44
+ const table = db
45
+ .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'schema_migrations'")
46
+ .get();
47
+ if (table === undefined) {
48
+ return new Set();
49
+ }
50
+ const versions = new Set();
51
+ for (const row of db.prepare("SELECT version FROM schema_migrations").all()) {
52
+ const version = row.version;
53
+ if (typeof version !== "number") {
54
+ throw new Error(`schema_migrations holds a non-numeric version: ${String(version)}`);
55
+ }
56
+ versions.add(version);
57
+ }
58
+ return versions;
59
+ }
@@ -0,0 +1,17 @@
1
+ CREATE TABLE projects (id TEXT PRIMARY KEY, title TEXT NOT NULL CHECK(length(title) <= 200), format TEXT NOT NULL CHECK(format IN ('16:9','9:16')), config TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
2
+ CREATE TABLE stages (id TEXT PRIMARY KEY, project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, kind TEXT NOT NULL CHECK(kind IN ('research','article','audio','images','thumbnail','video')), source TEXT NOT NULL, state TEXT NOT NULL CHECK(state IN ('pending','running','done','failed','canceled','provided','skipped')), failure_reason TEXT, attempt_count INTEGER NOT NULL DEFAULT 0, progress_current INTEGER, progress_total INTEGER, started_at TEXT, finished_at TEXT, UNIQUE(project_id, kind));
3
+ CREATE TABLE attempts (id TEXT PRIMARY KEY, stage_id TEXT NOT NULL REFERENCES stages(id) ON DELETE CASCADE, piece_id TEXT, n INTEGER NOT NULL, started_at TEXT NOT NULL, ended_at TEXT, outcome TEXT, error_text TEXT);
4
+ CREATE TABLE stage_pieces (id TEXT PRIMARY KEY, stage_id TEXT NOT NULL REFERENCES stages(id) ON DELETE CASCADE, kind TEXT NOT NULL, idx INTEGER NOT NULL, state TEXT NOT NULL, payload TEXT, UNIQUE(stage_id, kind, idx));
5
+ CREATE TABLE outputs (id TEXT PRIMARY KEY, project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, stage_kind TEXT NOT NULL, role TEXT NOT NULL, path TEXT NOT NULL, original_filename TEXT, bytes INTEGER NOT NULL, duration_ms INTEGER, meta TEXT, created_at TEXT NOT NULL);
6
+ CREATE INDEX outputs_project ON outputs(project_id, stage_kind);
7
+ CREATE TABLE prompts (id TEXT PRIMARY KEY, kind TEXT NOT NULL CHECK(kind IN ('article','image','thumbnail')), name TEXT NOT NULL, body TEXT NOT NULL, slots TEXT NOT NULL, updated_at TEXT NOT NULL);
8
+ CREATE UNIQUE INDEX prompts_name ON prompts(kind, lower(name));
9
+ CREATE TABLE entries (id TEXT PRIMARY KEY, category TEXT NOT NULL CHECK(category IN ('intro','outro')), mode TEXT NOT NULL CHECK(mode IN ('text','llm')), name TEXT NOT NULL, body TEXT NOT NULL, slots TEXT NOT NULL, updated_at TEXT NOT NULL);
10
+ CREATE UNIQUE INDEX entries_name ON entries(category, lower(name));
11
+ CREATE TABLE provider_keys (provider TEXT PRIMARY KEY, key TEXT NOT NULL, updated_at TEXT NOT NULL);
12
+ CREATE TABLE voices (id TEXT PRIMARY KEY, provider TEXT NOT NULL, name TEXT NOT NULL, voice_id TEXT NOT NULL, UNIQUE(provider, voice_id));
13
+ CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL);
14
+ CREATE TABLE staged_files (id TEXT PRIMARY KEY, stage_kind TEXT NOT NULL, path TEXT NOT NULL, original_filename TEXT NOT NULL, bytes INTEGER NOT NULL, state TEXT NOT NULL, created_at TEXT NOT NULL);
15
+ CREATE TABLE telemetry_events (id TEXT PRIMARY KEY, type TEXT NOT NULL, payload TEXT NOT NULL, created_at TEXT NOT NULL, delivered_at TEXT);
16
+ CREATE TABLE machine (machine_id TEXT PRIMARY KEY, notice_seen_at TEXT, app_version TEXT NOT NULL);
17
+ CREATE TABLE schema_migrations (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL);
@@ -0,0 +1,53 @@
1
+ // SQLite starts a transaction for a SAVEPOINT issued outside one and nests it inside one
2
+ // that is already open, so a slice can wrap its own writes without knowing whether its
3
+ // caller wrapped them too. The name is a constant rather than a parameter: SQLite matches
4
+ // RELEASE and ROLLBACK TO against the most recent savepoint of that name, which is
5
+ // exactly the nesting we want, and nothing a caller passes reaches the statement.
6
+ const savepoint = "slopify";
7
+ export function transact(db, run) {
8
+ db.exec(`SAVEPOINT ${savepoint}`);
9
+ try {
10
+ const result = run();
11
+ // node:sqlite is synchronous, so an async block would release this frame while its
12
+ // awaited writes were still to come - and they would then land outside any
13
+ // transaction. The frame is unwound below, which is the only safe answer.
14
+ if (isThenable(result)) {
15
+ // Refusing the block orphans the promise it already started. Node treats an
16
+ // unhandled rejection as fatal, so it would take the process down and bury the
17
+ // error thrown here, which is the one that names the mistake.
18
+ void Promise.resolve(result).catch(() => { });
19
+ throw new Error("transact runs synchronous work only: an awaited write would land after the savepoint is released");
20
+ }
21
+ // Inside the try: a RELEASE that fails leaves the frame open with the block's writes
22
+ // uncommitted, which is a rollback, not a success to return a value from.
23
+ db.exec(`RELEASE ${savepoint}`);
24
+ return result;
25
+ }
26
+ catch (error) {
27
+ unwind(db, error);
28
+ throw error;
29
+ }
30
+ }
31
+ // An unguarded ROLLBACK TO would leave the frame open on failure, and the enclosing
32
+ // transact - which names the same savepoint - would then aim its own rollback at the
33
+ // leaked frame and commit part of what it meant to undo.
34
+ function unwind(db, error) {
35
+ try {
36
+ db.exec(`ROLLBACK TO ${savepoint}`);
37
+ db.exec(`RELEASE ${savepoint}`);
38
+ }
39
+ catch (cleanup) {
40
+ throw new Error(`the transaction could not be unwound: ${messageOf(cleanup)}`, {
41
+ cause: error,
42
+ });
43
+ }
44
+ }
45
+ function isThenable(value) {
46
+ return (typeof value === "object" &&
47
+ value !== null &&
48
+ "then" in value &&
49
+ typeof value.then === "function");
50
+ }
51
+ function messageOf(error) {
52
+ return error instanceof Error ? error.message : String(error);
53
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,4 @@
1
+ import { ulid } from "ulid";
2
+ export const ulidIds = {
3
+ next: () => ulid(),
4
+ };
@@ -0,0 +1,73 @@
1
+ import { readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
+ export function acquireInstanceLock(lockPath) {
3
+ const pid = process.pid;
4
+ if (!claim(lockPath, pid)) {
5
+ const holder = holderOf(lockPath);
6
+ if (holder !== undefined && isAlive(holder)) {
7
+ throw new Error(`another Slopify instance is already running on this data directory (pid ${holder}). ` +
8
+ `Stop it, or delete ${lockPath} if it is stale.`);
9
+ }
10
+ // The recorded process is gone, or the file says nothing usable. Reclaiming it here
11
+ // is what keeps a crash from bricking the data directory until a file is deleted by hand.
12
+ discard(lockPath);
13
+ if (!claim(lockPath, pid)) {
14
+ throw new Error(`could not take the instance lock at ${lockPath}: another process was faster`);
15
+ }
16
+ }
17
+ return {
18
+ release: () => {
19
+ if (holderOf(lockPath) === pid) {
20
+ discard(lockPath);
21
+ }
22
+ },
23
+ };
24
+ }
25
+ function claim(lockPath, pid) {
26
+ try {
27
+ writeFileSync(lockPath, `${pid}\n`, { flag: "wx", mode: 0o600 });
28
+ return true;
29
+ }
30
+ catch (error) {
31
+ if (errnoOf(error) === "EEXIST") {
32
+ return false;
33
+ }
34
+ throw error;
35
+ }
36
+ }
37
+ function holderOf(lockPath) {
38
+ let contents;
39
+ try {
40
+ contents = readFileSync(lockPath, "utf8");
41
+ }
42
+ catch {
43
+ return undefined;
44
+ }
45
+ const pid = Number(contents.trim());
46
+ return Number.isInteger(pid) && pid > 0 ? pid : undefined;
47
+ }
48
+ function discard(lockPath) {
49
+ try {
50
+ unlinkSync(lockPath);
51
+ }
52
+ catch (error) {
53
+ if (errnoOf(error) !== "ENOENT") {
54
+ throw error;
55
+ }
56
+ }
57
+ }
58
+ function isAlive(pid) {
59
+ try {
60
+ process.kill(pid, 0);
61
+ return true;
62
+ }
63
+ catch (error) {
64
+ // EPERM means the process exists and belongs to someone else.
65
+ return errnoOf(error) === "EPERM";
66
+ }
67
+ }
68
+ function errnoOf(error) {
69
+ if (error instanceof Error && "code" in error && typeof error.code === "string") {
70
+ return error.code;
71
+ }
72
+ return undefined;
73
+ }
@@ -0,0 +1,34 @@
1
+ import { appendFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ // ceiling: prefix matching, not a secret scanner. The prefixes cover the providers in
4
+ // `slices/settings/model.ts`; fal.ai's `<uuid>:<hex>` form has no prefix to match on and
5
+ // is the known gap. That is why LogFields is a closed set of three strings: no caller
6
+ // can hand this module an arbitrary payload to serialise.
7
+ const keyLike = /\b(?:sk|rk|pk|hf|gsk|xai|nvapi|r8)[-_][A-Za-z0-9_-]{16,}|\bAIza[A-Za-z0-9_-]{16,}/g;
8
+ const bearerLike = /\bBearer\s+[A-Za-z0-9._~+/=-]{8,}/gi;
9
+ export function openLog(logsDir, clock) {
10
+ return {
11
+ write: (level, event, fields) => {
12
+ const at = clock.now().toISOString();
13
+ const record = { ts: at, level, event };
14
+ if (fields?.projectId !== undefined) {
15
+ record.projectId = fields.projectId;
16
+ }
17
+ if (fields?.stage !== undefined) {
18
+ record.stage = fields.stage;
19
+ }
20
+ if (fields?.detail !== undefined) {
21
+ record.detail = redact(fields.detail);
22
+ }
23
+ // ceiling: one open/append/close per line, and rotation by the date in the file
24
+ // name. A held handle is the upgrade if the runner ever logs per streamed chunk.
25
+ const file = join(logsDir, `slopify-${at.slice(0, 10)}.log`);
26
+ appendFileSync(file, `${JSON.stringify(record)}\n`, { mode: 0o600 });
27
+ },
28
+ };
29
+ }
30
+ // Exported for the attempt wrapper: a provider's verbatim error is shown on the project
31
+ // page and stored on the attempt, so it goes through the same filter as a log line.
32
+ export function redact(text) {
33
+ return text.replace(bearerLike, "Bearer [redacted]").replace(keyLike, "[redacted]");
34
+ }
@@ -0,0 +1,21 @@
1
+ import { chmodSync, mkdirSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ export function layout(dataDir) {
4
+ const root = resolve(dataDir);
5
+ return {
6
+ dataDir: root,
7
+ db: join(root, "slopify.db"),
8
+ projects: join(root, "projects"),
9
+ staging: join(root, "staging"),
10
+ logs: join(root, "logs"),
11
+ lock: join(root, ".lock"),
12
+ };
13
+ }
14
+ export function ensureDirs(paths, options) {
15
+ for (const dir of [paths.dataDir, paths.projects, paths.staging, paths.logs]) {
16
+ mkdirSync(dir, { recursive: true, mode: options.mode });
17
+ // mkdir's mode is masked by the umask and ignored for a directory that already
18
+ // exists; provider keys sit in this tree, so the mode has to be forced.
19
+ chmodSync(dir, options.mode);
20
+ }
21
+ }
@@ -0,0 +1,22 @@
1
+ // The pipeline's vocabulary. It sits in the kernel because the runner, the slices that
2
+ // implement a stage, and the edge that reports one all name the same finite sets, and
3
+ // the runner may not import a slice to learn them (03-conventions Dependency injection).
4
+ // The frame a run is made in, and the aspect an image is asked for: one set, named once
5
+ // (`logic/04`, `logic/09` step 1).
6
+ export const formats = ["16:9", "9:16"];
7
+ export const stageKinds = ["research", "article", "audio", "images", "thumbnail", "video"];
8
+ export const stageStates = [
9
+ "pending",
10
+ "running",
11
+ "done",
12
+ "failed",
13
+ "canceled",
14
+ "provided",
15
+ "skipped",
16
+ ];
17
+ // logic/01 §Q9 names four project states and derives them in order: running if any
18
+ // stage is running, else canceled, else failed, else done when video is done. A project
19
+ // whose stages are all still pending matches none of them, and that window is real
20
+ // between creating the project and the runner starting the first stage, so `pending` is
21
+ // the fallback the derivation returns. Nothing stores it: status is always derived.
22
+ export const projectStates = ["running", "canceled", "failed", "done", "pending"];
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export const messageRoles = ["system", "user", "assistant"];