@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,221 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { z } from "zod";
4
+ import { transact } from "../../kernel/db/tx.js";
5
+ import { insertPiece, piecesOf, setPiece } from "../../kernel/runner/piece-repo.js";
6
+ import { projectById, setStageProgress } from "../admission/repo.js";
7
+ import { outputFileName, outputPath } from "../storage/layout.js";
8
+ import { insertOutput, outputsOf } from "../storage/repo.js";
9
+ // What an image piece carries between runs: what was asked for, and the file it came back
10
+ // as once the provider answered. That is the whole of the resume of §Q73.
11
+ const imagePayload = z.object({
12
+ promptName: z.string(),
13
+ prompt: z.string(),
14
+ // Which ticked prompt this send belongs to, and which send of that prompt it is. Both
15
+ // are stored rather than derived, because §Q75 lets the user delete an image and the
16
+ // pieces that are left must still say where each one came from.
17
+ promptIndex: z.number(),
18
+ indexInPrompt: z.number(),
19
+ file: z.string().optional(),
20
+ });
21
+ export async function runImages(deps, context, providers) {
22
+ const { projectId } = context.stage;
23
+ const project = projectById(deps.db, projectId);
24
+ if (project === undefined) {
25
+ throw new Error(`project ${projectId} has no row`);
26
+ }
27
+ const choice = project.config.images;
28
+ if (choice === undefined) {
29
+ // Admission refuses a run whose images are Generate without a provider and a model
30
+ // (`logic/04` step 2), so reaching here is a bug in admission rather than the user's.
31
+ throw new Error("the run has no image provider or model");
32
+ }
33
+ const pieces = plan(deps, context, project.config);
34
+ if (pieces.length === 0) {
35
+ // `logic/04` §Q31 makes an image source mandatory and step 3 puts the Number at one
36
+ // or more, so an empty plan is a bug upstream rather than a run with no pictures.
37
+ throw new Error("the run ticked no image prompt");
38
+ }
39
+ // Step 1: the run's own frame. The adapter turns it into whatever its provider spells
40
+ // the closest supported size, and `logic/11` step 4 crops whatever is left over.
41
+ const made = await sendAll(deps, context, providers, choice, pieces, project.format);
42
+ // logic/16 step 3: "images made (stored images)". Only the ones this run stored, so a
43
+ // resume does not count an image the previous run made (§Q73) and a single regenerated
44
+ // image counts as one (§Q103, scenario 12: "regenerations count again").
45
+ //
46
+ // ceiling: a run that fails partway records nothing, so the images it did store are
47
+ // counted by no event and the resume counts only its own. logic/16 step 2 puts one
48
+ // event on the stage completing; the upgrade, if the drift ever matters, is an event
49
+ // per image.
50
+ deps.count("stage.completed", {
51
+ stage: "images",
52
+ provider: choice.provider,
53
+ model: choice.model,
54
+ images: made,
55
+ });
56
+ deps.log.write("info", "images.done", {
57
+ projectId,
58
+ stage: "images",
59
+ detail: `${String(pieces.length)} images from ${String(project.config.imagePrompts.length)} prompts`,
60
+ });
61
+ }
62
+ // Step 2 with `logic/04` §Q30: "for each ticked image prompt, send its rendered text
63
+ // Number times as independent parallel calls". The plan is made once and kept, so a retry
64
+ // finds the rows the first run wrote and sends only what did not land (§Q73).
65
+ //
66
+ // `idx` is the slideshow order of §Q72 - prompts in selection order, then the index within
67
+ // each prompt - so the order is fixed here and never depends on which image arrives first.
68
+ function plan(deps, context, config) {
69
+ const existing = piecesOf(deps.db, context.stage.id, "image");
70
+ if (existing.length > 0) {
71
+ return existing;
72
+ }
73
+ const planned = [];
74
+ for (const [at, picked] of config.imagePrompts.entries()) {
75
+ // `slices/library/slots.ts` names the rendered body after the draft field that picked
76
+ // it, so the run carries the substituted text under this key (`logic/03`).
77
+ const prompt = config.rendered[`imagePrompts.${String(at)}`];
78
+ if (prompt === undefined) {
79
+ throw new Error(`the run has no rendered text for the image prompt ${picked.name}`);
80
+ }
81
+ for (let send = 1; send <= picked.number; send += 1) {
82
+ planned.push({
83
+ id: deps.ids.next(),
84
+ stageId: context.stage.id,
85
+ kind: "image",
86
+ idx: planned.length + 1,
87
+ state: "pending",
88
+ payload: JSON.stringify({
89
+ promptName: picked.name,
90
+ prompt,
91
+ promptIndex: at + 1,
92
+ indexInPrompt: send,
93
+ }),
94
+ });
95
+ }
96
+ }
97
+ transact(deps.db, () => {
98
+ for (const piece of planned) {
99
+ insertPiece(deps.db, piece);
100
+ }
101
+ });
102
+ return planned;
103
+ }
104
+ // §Q73: an image a previous run stored is not made again, and one that fails fails the
105
+ // whole stage while its siblings finish and keep their images for the next resume. A
106
+ // refusal is the wrapper's to make terminal (§Q74); nothing here counts attempts.
107
+ async function sendAll(deps, context, providers, choice, pieces, format) {
108
+ const { projectId } = context.stage;
109
+ // Read once, before anything is written: a piece counts as landed only when its row and
110
+ // its file both survived, and the parallel sends below each add one of each.
111
+ const stored = outputsOf(deps.db, projectId);
112
+ const total = pieces.length;
113
+ let done = pieces.filter((piece) => landed(deps, projectId, piece, stored)).length;
114
+ report(deps, context, done, total);
115
+ const outcomes = await Promise.all(pieces.map(async (piece) => {
116
+ const asked = payloadOf(piece);
117
+ if (landed(deps, projectId, piece, stored)) {
118
+ return { ok: true, made: false };
119
+ }
120
+ setPiece(deps.db, piece.id, "running", piece.payload);
121
+ try {
122
+ const made = await providers.forPiece(piece.id).image({
123
+ provider: choice.provider,
124
+ model: choice.model,
125
+ prompt: asked.prompt,
126
+ aspect: format,
127
+ });
128
+ const file = keep(deps, projectId, piece.idx, made);
129
+ // §Q76: the prompt text, the prompt name, the index within the prompt, the
130
+ // provider and the model travel with the image.
131
+ const output = record(deps, projectId, piece, asked, file, made, choice);
132
+ // The row and the file are both there before the piece says so: a crash between
133
+ // them leaves a piece that is not `done`, which the next run simply sends again.
134
+ setPiece(deps.db, piece.id, "done", JSON.stringify({ ...asked, file }));
135
+ done += 1;
136
+ // Step 3: the project page fills in as each one arrives.
137
+ context.emit({ type: "image.landed", projectId, outputId: output.id, index: piece.idx });
138
+ report(deps, context, done, total);
139
+ return { ok: true, made: true };
140
+ }
141
+ catch (error) {
142
+ // A cancel is not this image failing: `logic/13` §Q112 counts an aborted call as
143
+ // nothing, and the resume runs a `pending` image exactly as a failed one.
144
+ setPiece(deps.db, piece.id, context.signal.aborted ? "pending" : "failed", piece.payload);
145
+ return { ok: false, error };
146
+ }
147
+ }));
148
+ let made = 0;
149
+ for (const outcome of outcomes) {
150
+ if (!outcome.ok) {
151
+ throw outcome.error;
152
+ }
153
+ made += outcome.made ? 1 : 0;
154
+ }
155
+ return made;
156
+ }
157
+ // An image counts as landed only when its row and its file are both still there: they are
158
+ // written one after the other, the boot reconcile can remove a file whose row survived,
159
+ // and §Q75 lets the user delete one on purpose. Answering "done" for a missing file would
160
+ // hand the render a path to nothing.
161
+ function landed(deps, projectId, piece, stored) {
162
+ const file = payloadOf(piece).file;
163
+ if (file === undefined) {
164
+ return false;
165
+ }
166
+ if (!stored.some((output) => output.role === "image" && output.path === file)) {
167
+ return false;
168
+ }
169
+ return existsSync(outputPath(deps.paths, projectId, file));
170
+ }
171
+ function keep(deps, projectId, idx, made) {
172
+ // Step 3: "stored as received (png or jpg)". The adapter read the mime off the bytes,
173
+ // so the extension is what the file actually is rather than what a header claimed.
174
+ const name = outputFileName("image", idx, made.mime === "image/png" ? ".png" : ".jpg", "images");
175
+ const target = outputPath(deps.paths, projectId, name);
176
+ mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
177
+ writeFileSync(target, made.bytes, { mode: 0o600 });
178
+ return name;
179
+ }
180
+ function record(deps, projectId, piece, asked, file, made, choice) {
181
+ const output = {
182
+ id: deps.ids.next(),
183
+ projectId,
184
+ stageKind: "images",
185
+ role: "image",
186
+ path: file,
187
+ originalFilename: null,
188
+ bytes: made.bytes.byteLength,
189
+ durationMs: null,
190
+ // `index` is the slideshow place `logic/11` sorts on, and it is the piece's own `idx`
191
+ // rather than the order the images came back in (§Q72).
192
+ meta: {
193
+ index: piece.idx,
194
+ promptName: asked.promptName,
195
+ prompt: asked.prompt,
196
+ provider: choice.provider,
197
+ model: choice.model,
198
+ },
199
+ createdAt: deps.clock.now().toISOString(),
200
+ };
201
+ insertOutput(deps.db, output);
202
+ return output;
203
+ }
204
+ // `logic/01` §Q6: "k of N images made". Written as well as emitted, so a page opened
205
+ // mid-stage reads the count off the row rather than waiting for the next image.
206
+ function report(deps, context, done, total) {
207
+ setStageProgress(deps.db, context.stage.id, done, total);
208
+ context.emit({
209
+ type: "stage.progress",
210
+ projectId: context.stage.projectId,
211
+ stage: "images",
212
+ current: done,
213
+ total,
214
+ });
215
+ }
216
+ function payloadOf(piece) {
217
+ if (piece.payload === null) {
218
+ throw new Error(`image ${piece.id} has no payload`);
219
+ }
220
+ return imagePayload.parse(JSON.parse(piece.payload));
221
+ }
@@ -0,0 +1,53 @@
1
+ // What `logic/15` step 2 asks of a save: a name, a body, and no lint error. The slot
2
+ // grammar is not restated here - it is admission/substitute.ts's, imported so the editor
3
+ // and the run see the same parse of the same body.
4
+ import { detectSlots } from "../admission/substitute.js";
5
+ import { bodyMax, nameMax } from "./model.js";
6
+ export function lintPrompt(draft) {
7
+ return lint(draft.name, draft.body);
8
+ }
9
+ export function lintEntry(draft) {
10
+ return lint(draft.name, draft.body);
11
+ }
12
+ function lint(name, body) {
13
+ const fields = [];
14
+ const trimmed = name.trim();
15
+ if (trimmed === "") {
16
+ fields.push({ field: "name", message: "A name is required." });
17
+ }
18
+ else if (trimmed.length > nameMax) {
19
+ fields.push({ field: "name", message: `A name is at most ${String(nameMax)} characters.` });
20
+ }
21
+ if (body.trim() === "") {
22
+ fields.push({ field: "body", message: "A body is required." });
23
+ return fields;
24
+ }
25
+ if (body.length > bodyMax) {
26
+ fields.push({ field: "body", message: `A body is at most ${String(bodyMax)} characters.` });
27
+ return fields;
28
+ }
29
+ for (const error of detectSlots(body).errors) {
30
+ fields.push({ field: "body", message: describe(error, body) });
31
+ }
32
+ return fields;
33
+ }
34
+ // The parser reports a character offset; a person editing a textarea counts lines and
35
+ // columns, so the message says both what is wrong and where.
36
+ function describe(error, body) {
37
+ const where = positionOf(body, error.at);
38
+ switch (error.kind) {
39
+ case "unclosed":
40
+ return `The \`{{\` at ${where} is never closed.`;
41
+ case "empty":
42
+ return `The slot at ${where} has no name.`;
43
+ case "nested":
44
+ return `The slot at ${where} holds a brace; slots do not nest.`;
45
+ }
46
+ }
47
+ function positionOf(body, at) {
48
+ const before = body.slice(0, at);
49
+ const lastBreak = before.lastIndexOf("\n");
50
+ const line = before.split("\n").length;
51
+ const column = at - lastBreak;
52
+ return `line ${String(line)}, column ${String(column)}`;
53
+ }
@@ -0,0 +1,13 @@
1
+ // The two template libraries `logic/15` governs: prompts of three kinds, and the
2
+ // intro/outro entries. §Q121 puts one rule set over both, which is why the drafts, the
3
+ // lint and the save path below are shared rather than mirrored.
4
+ export { entryModes } from "../admission/model.js";
5
+ // mockup §Q6 and §Q13; the CHECK constraint on `prompts.kind` holds the same three.
6
+ export const promptKinds = ["article", "image", "thumbnail"];
7
+ export const entryCategories = ["intro", "outro"];
8
+ // The name is what the Play pickers show, so it shares admission's title bound.
9
+ export const nameMax = 200;
10
+ // ceiling: `logic/15` sets no length for a body, and the §Q6 sample article prompt is
11
+ // already a page long. This is a sanity bound on a trust boundary, not a product rule;
12
+ // raising it is a one-line change with no schema consequence.
13
+ export const bodyMax = 100_000;
@@ -0,0 +1,111 @@
1
+ import { z } from "zod";
2
+ import { entryCategories, entryModes, promptKinds } from "./model.js";
3
+ // A row naming a kind, category or mode this build does not know can only come from a
4
+ // hand-edited database or a downgrade; parsing throws rather than hiding a saved row.
5
+ const promptRow = z.object({
6
+ id: z.string(),
7
+ kind: z.enum(promptKinds),
8
+ name: z.string(),
9
+ body: z.string(),
10
+ slots: z.string(),
11
+ updated_at: z.string(),
12
+ });
13
+ const entryRow = z.object({
14
+ id: z.string(),
15
+ category: z.enum(entryCategories),
16
+ mode: z.enum(entryModes),
17
+ name: z.string(),
18
+ body: z.string(),
19
+ slots: z.string(),
20
+ updated_at: z.string(),
21
+ });
22
+ const slotsColumn = z.array(z.string());
23
+ // `lower(name)` and not `name COLLATE NOCASE`: it is the expression the unique indexes
24
+ // are built on, so a lookup by name reads the index instead of the table.
25
+ const byName = "lower(name) = lower(?)";
26
+ // `logic/15` step 5: the lists on 04 Prompts and the pickers on Play sort by name.
27
+ const byNameOrder = "ORDER BY lower(name)";
28
+ export function insertPrompt(db, prompt) {
29
+ db.prepare("INSERT INTO prompts (id, kind, name, body, slots, updated_at) VALUES (?, ?, ?, ?, ?, ?)").run(prompt.id, prompt.kind, prompt.name, prompt.body, JSON.stringify(prompt.slots), prompt.updatedAt);
30
+ }
31
+ // §Q122: the kind may change after creation, so the update writes it like any other
32
+ // column. Returns false when no row has that id.
33
+ export function replacePrompt(db, prompt) {
34
+ const result = db
35
+ .prepare("UPDATE prompts SET kind = ?, name = ?, body = ?, slots = ?, updated_at = ? WHERE id = ?")
36
+ .run(prompt.kind, prompt.name, prompt.body, JSON.stringify(prompt.slots), prompt.updatedAt, prompt.id);
37
+ return Number(result.changes) > 0;
38
+ }
39
+ // §Q123: no foreign key points at this row, so a project that used the template keeps
40
+ // its own rendered text and nothing cascades.
41
+ export function deletePrompt(db, id) {
42
+ return Number(db.prepare("DELETE FROM prompts WHERE id = ?").run(id).changes) > 0;
43
+ }
44
+ export function listPrompts(db) {
45
+ return db
46
+ .prepare(`SELECT * FROM prompts ${byNameOrder}`)
47
+ .all()
48
+ .map((row) => toPrompt(promptRow.parse(row)));
49
+ }
50
+ export function promptById(db, id) {
51
+ const row = db.prepare("SELECT * FROM prompts WHERE id = ?").get(id);
52
+ return row === undefined ? undefined : toPrompt(promptRow.parse(row));
53
+ }
54
+ export function promptByName(db, kind, name) {
55
+ const row = db.prepare(`SELECT * FROM prompts WHERE kind = ? AND ${byName}`).get(kind, name);
56
+ return row === undefined ? undefined : toPrompt(promptRow.parse(row));
57
+ }
58
+ export function insertEntry(db, entry) {
59
+ db.prepare("INSERT INTO entries (id, category, mode, name, body, slots, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)").run(entry.id, entry.category, entry.mode, entry.name, entry.body, JSON.stringify(entry.slots), entry.updatedAt);
60
+ }
61
+ export function replaceEntry(db, entry) {
62
+ const result = db
63
+ .prepare("UPDATE entries SET category = ?, mode = ?, name = ?, body = ?, slots = ?, updated_at = ? WHERE id = ?")
64
+ .run(entry.category, entry.mode, entry.name, entry.body, JSON.stringify(entry.slots), entry.updatedAt, entry.id);
65
+ return Number(result.changes) > 0;
66
+ }
67
+ export function deleteEntry(db, id) {
68
+ return Number(db.prepare("DELETE FROM entries WHERE id = ?").run(id).changes) > 0;
69
+ }
70
+ export function listEntries(db) {
71
+ return db
72
+ .prepare(`SELECT * FROM entries ${byNameOrder}`)
73
+ .all()
74
+ .map((row) => toEntry(entryRow.parse(row)));
75
+ }
76
+ export function entryById(db, id) {
77
+ const row = db.prepare("SELECT * FROM entries WHERE id = ?").get(id);
78
+ return row === undefined ? undefined : toEntry(entryRow.parse(row));
79
+ }
80
+ export function entryByName(db, category, name) {
81
+ const row = db
82
+ .prepare(`SELECT * FROM entries WHERE category = ? AND ${byName}`)
83
+ .get(category, name);
84
+ return row === undefined ? undefined : toEntry(entryRow.parse(row));
85
+ }
86
+ function toPrompt(row) {
87
+ return {
88
+ id: row.id,
89
+ kind: row.kind,
90
+ name: row.name,
91
+ body: row.body,
92
+ slots: parseSlots(row.slots),
93
+ updatedAt: row.updated_at,
94
+ };
95
+ }
96
+ function toEntry(row) {
97
+ return {
98
+ id: row.id,
99
+ category: row.category,
100
+ mode: row.mode,
101
+ name: row.name,
102
+ body: row.body,
103
+ slots: parseSlots(row.slots),
104
+ updatedAt: row.updated_at,
105
+ };
106
+ }
107
+ // A JSON column never reaches a caller as a string.
108
+ function parseSlots(slots) {
109
+ const parsed = JSON.parse(slots);
110
+ return slotsColumn.parse(parsed);
111
+ }
@@ -0,0 +1,87 @@
1
+ // Creating, editing and deleting a template (`logic/15`). One rule set covers prompts
2
+ // and entries (§Q121), so both go through the same three outcomes.
3
+ import { isUniqueConstraint } from "../../kernel/db/index.js";
4
+ import { detectSlots } from "../admission/substitute.js";
5
+ import { lintEntry, lintPrompt } from "./lint.js";
6
+ import { deleteEntry, deletePrompt, insertEntry, insertPrompt, replaceEntry, replacePrompt, } from "./repo.js";
7
+ export function createPrompt(deps, draft) {
8
+ const fields = lintPrompt(draft);
9
+ if (fields.length > 0) {
10
+ return { ok: false, reason: "invalid", fields };
11
+ }
12
+ const prompt = { id: deps.ids.next(), ...promptOf(deps, draft) };
13
+ return written(() => {
14
+ insertPrompt(deps.db, prompt);
15
+ return true;
16
+ }, prompt);
17
+ }
18
+ // §Q125: a save overwrites; there is no version history, so the row is replaced whole.
19
+ export function updatePrompt(deps, id, draft) {
20
+ const fields = lintPrompt(draft);
21
+ if (fields.length > 0) {
22
+ return { ok: false, reason: "invalid", fields };
23
+ }
24
+ const prompt = { id, ...promptOf(deps, draft) };
25
+ return written(() => replacePrompt(deps.db, prompt), prompt);
26
+ }
27
+ export function removePrompt(deps, id) {
28
+ return deletePrompt(deps.db, id) ? { ok: true, value: null } : { ok: false, reason: "not-found" };
29
+ }
30
+ export function createEntry(deps, draft) {
31
+ const fields = lintEntry(draft);
32
+ if (fields.length > 0) {
33
+ return { ok: false, reason: "invalid", fields };
34
+ }
35
+ const entry = { id: deps.ids.next(), ...entryOf(deps, draft) };
36
+ return written(() => {
37
+ insertEntry(deps.db, entry);
38
+ return true;
39
+ }, entry);
40
+ }
41
+ export function updateEntry(deps, id, draft) {
42
+ const fields = lintEntry(draft);
43
+ if (fields.length > 0) {
44
+ return { ok: false, reason: "invalid", fields };
45
+ }
46
+ const entry = { id, ...entryOf(deps, draft) };
47
+ return written(() => replaceEntry(deps.db, entry), entry);
48
+ }
49
+ export function removeEntry(deps, id) {
50
+ return deleteEntry(deps.db, id) ? { ok: true, value: null } : { ok: false, reason: "not-found" };
51
+ }
52
+ // The stored `slots` is recomputed from the body on every save, so the column can never
53
+ // describe a body that is no longer there.
54
+ function promptOf(deps, draft) {
55
+ return {
56
+ kind: draft.kind,
57
+ name: draft.name.trim(),
58
+ body: draft.body,
59
+ slots: detectSlots(draft.body).names,
60
+ updatedAt: deps.clock.now().toISOString(),
61
+ };
62
+ }
63
+ function entryOf(deps, draft) {
64
+ return {
65
+ category: draft.category,
66
+ mode: draft.mode,
67
+ name: draft.name.trim(),
68
+ body: draft.body,
69
+ slots: detectSlots(draft.body).names,
70
+ updatedAt: deps.clock.now().toISOString(),
71
+ };
72
+ }
73
+ // §Q122's uniqueness is the schema's: `prompts(kind, lower(name))` and
74
+ // `entries(category, lower(name))`. A read-then-write check would answer from a row a
75
+ // second writer could delete between the two statements, so the index decides and the
76
+ // raw SQLite error never leaves this module.
77
+ function written(write, value) {
78
+ try {
79
+ return write() ? { ok: true, value } : { ok: false, reason: "not-found" };
80
+ }
81
+ catch (error) {
82
+ if (isUniqueConstraint(error)) {
83
+ return { ok: false, reason: "duplicate-name" };
84
+ }
85
+ throw error;
86
+ }
87
+ }
@@ -0,0 +1,86 @@
1
+ // What a run needs from the library at the moment Play is pressed: the saved bodies of
2
+ // the picked templates, the slot names they ask for, and the rendered text stored on the
3
+ // project. `logic/04` §Q34 fixes the timing - the bodies are read at the click, not at
4
+ // selection, so an edit made in between is the one that runs.
5
+ import { collectFields, render } from "../admission/substitute.js";
6
+ import { entryByName, promptByName } from "./repo.js";
7
+ export function pickTemplates(db, draft) {
8
+ const { sources } = draft;
9
+ const missing = [];
10
+ // `logic/03` step 3: only the prompts of stages set to Generate, plus the picked
11
+ // entries. A prompt left selected on a stage set to Provide asks for no field.
12
+ const text = [];
13
+ const image = [];
14
+ if (sources.article === "generate") {
15
+ body(db, "article", draft.articlePrompt, "articlePrompt", missing, text, "article");
16
+ }
17
+ const intro = pickEntry(db, "intro", draft.intro, missing);
18
+ const outro = pickEntry(db, "outro", draft.outro, missing);
19
+ push(text, "intro", intro);
20
+ push(text, "outro", outro);
21
+ if (sources.images === "generate") {
22
+ for (const [index, picked] of draft.imagePrompts.entries()) {
23
+ const field = `imagePrompts.${String(index)}.name`;
24
+ body(db, "image", picked.name, field, missing, image, `imagePrompts.${String(index)}`);
25
+ }
26
+ }
27
+ // Both Generate modes read the same thumbnail template; Prompt by LLM sends it as the
28
+ // instruction rather than to the image provider (`logic/10` step 1).
29
+ if (sources.thumbnail === "from_prompt" || sources.thumbnail === "prompt_by_llm") {
30
+ body(db, "thumbnail", draft.thumbnailPrompt, "thumbnailPrompt", missing, image, "thumbnailPrompt");
31
+ }
32
+ const fields = collectFields(text.map((picked) => picked.body), image.map((picked) => picked.body));
33
+ return {
34
+ draft: withSavedModes(draft, intro, outro),
35
+ requiredSlots: fields.map((field) => field.name),
36
+ bodies: [...text, ...image],
37
+ missing,
38
+ };
39
+ }
40
+ // `logic/03` step 5 and step 6: every picked body rendered once, with the trimmed values
41
+ // admission has already accepted, and stored on the project.
42
+ export function renderPicked(picked, values) {
43
+ const rendered = {};
44
+ for (const { key, body: text } of picked.bodies) {
45
+ rendered[key] = render(text, values);
46
+ }
47
+ return rendered;
48
+ }
49
+ function body(db, kind, name, field, missing, into, key) {
50
+ // An unpicked prompt is admission's rule to state ("Pick an article prompt."), so this
51
+ // says nothing about it and the form marks the field once.
52
+ if (name === undefined || name.trim() === "") {
53
+ return;
54
+ }
55
+ const prompt = promptByName(db, kind, name);
56
+ if (prompt === undefined) {
57
+ missing.push({ field, message: `That ${kind} prompt no longer exists; pick another.` });
58
+ return;
59
+ }
60
+ into.push({ key, body: prompt.body });
61
+ }
62
+ function pickEntry(db, category, choice, missing) {
63
+ if (choice === undefined) {
64
+ return undefined;
65
+ }
66
+ const entry = entryByName(db, category, choice.name);
67
+ if (entry === undefined) {
68
+ missing.push({
69
+ field: category,
70
+ message: `That ${category} entry no longer exists; pick another.`,
71
+ });
72
+ }
73
+ return entry;
74
+ }
75
+ function push(into, key, entry) {
76
+ if (entry !== undefined) {
77
+ into.push({ key, body: entry.body });
78
+ }
79
+ }
80
+ function withSavedModes(draft, intro, outro) {
81
+ return {
82
+ ...draft,
83
+ ...(intro === undefined ? {} : { intro: { name: intro.name, mode: intro.mode } }),
84
+ ...(outro === undefined ? {} : { outro: { name: outro.name, mode: outro.mode } }),
85
+ };
86
+ }
@@ -0,0 +1,71 @@
1
+ // `logic/08` step 2, the run's chunking choice: "Whole text = one request; Per paragraph
2
+ // = one request per paragraph; Every ~N words = consecutive chunks, each ending at the
3
+ // last sentence boundary at or before N words, default N = 500 (§Q69)."
4
+ //
5
+ // Pure, and the only place the rule lives: 03-conventions puts chunking with substitution
6
+ // and the render plan in the functional core, so it is testable with no I/O at all and
7
+ // `run.ts` does nothing but call it.
8
+ export const chunkModes = ["whole", "paragraph", "words"];
9
+ export const defaultChunkWords = 500;
10
+ // A run created before Play carried the control sends the whole text as one request,
11
+ // which is step 2's first case and the one that adds nothing the user did not ask for.
12
+ export const defaultChunking = { mode: "whole" };
13
+ // A run of blank lines is one paragraph break, not several, and a line of nothing but
14
+ // spaces between two paragraphs is still a break: strip-markdown output is full of both.
15
+ const paragraphBreak = /\r?\n[ \t\r]*(?:\r?\n[ \t\r]*)+/;
16
+ export function chunkNarration(text, chunking) {
17
+ switch (chunking.mode) {
18
+ case "whole":
19
+ return nonEmpty([text]);
20
+ case "paragraph":
21
+ return nonEmpty(text.split(paragraphBreak));
22
+ case "words":
23
+ return wordRuns(text, chunking.words ?? defaultChunkWords);
24
+ }
25
+ }
26
+ // What "N words" counts: runs of non-space. Exported because it is half of the rule -
27
+ // a test that asserts a chunk fits in N has to count the same way the cut did.
28
+ export function wordsIn(text) {
29
+ return text.split(/\s+/).filter((word) => word !== "").length;
30
+ }
31
+ function wordRuns(text, budget) {
32
+ // A budget below one word would end every chunk before it started; one sentence is the
33
+ // floor because the rule never cuts inside a sentence.
34
+ const limit = Math.max(1, Math.floor(budget));
35
+ const chunks = [];
36
+ let current = "";
37
+ let count = 0;
38
+ for (const sentence of sentences(text)) {
39
+ const words = wordsIn(sentence);
40
+ // "the last sentence boundary at or before N words": the sentence that would push the
41
+ // count past the budget starts the next chunk instead of being split.
42
+ if (count > 0 && count + words > limit) {
43
+ chunks.push(current);
44
+ current = "";
45
+ count = 0;
46
+ }
47
+ current += sentence;
48
+ count += words;
49
+ }
50
+ chunks.push(current);
51
+ // A single sentence longer than the budget lands here whole, on its own: §Q69 caps
52
+ // nothing, and a cut inside a clause would be an audible pause the writer never wrote.
53
+ // The provider's own per-request limit is what refuses it, as an error on the stage.
54
+ return nonEmpty(chunks);
55
+ }
56
+ // ceiling: segmented as English. `Intl.Segmenter` is the platform's own sentence breaker
57
+ // (ICU ships with Node), so no dependency and no regex full of abbreviations; the locale
58
+ // is fixed because a run carries no language and the default one varies by machine. The
59
+ // upgrade is a language on the run configuration, passed through to here.
60
+ function* sentences(text) {
61
+ // Built per call rather than held at module scope: the standards forbid a module-level
62
+ // singleton, and constructing one costs microseconds against a call that just read an
63
+ // article off disk.
64
+ const segmenter = new Intl.Segmenter("en", { granularity: "sentence" });
65
+ for (const part of segmenter.segment(text)) {
66
+ yield part.segment;
67
+ }
68
+ }
69
+ function nonEmpty(parts) {
70
+ return parts.map((part) => part.trim()).filter((part) => part !== "");
71
+ }