@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.
- package/LICENSE +21 -0
- package/README.md +63 -0
- package/dist/adapter-registry.js +68 -0
- package/dist/adapters/image/bytes.js +64 -0
- package/dist/adapters/image/fal.js +164 -0
- package/dist/adapters/image/openai.js +155 -0
- package/dist/adapters/image/replicate.js +183 -0
- package/dist/adapters/llm/claude-code.js +158 -0
- package/dist/adapters/llm/codex.js +126 -0
- package/dist/adapters/llm/openrouter.js +193 -0
- package/dist/adapters/llm/run-cli.js +134 -0
- package/dist/adapters/llm/sse-lines.js +46 -0
- package/dist/adapters/retry-after.js +21 -0
- package/dist/adapters/tts/cartesia.js +105 -0
- package/dist/adapters/tts/elevenlabs.js +107 -0
- package/dist/adapters/tts/openai.js +95 -0
- package/dist/edge/cli.js +36 -0
- package/dist/edge/events/hub.js +76 -0
- package/dist/edge/http/actions.js +125 -0
- package/dist/edge/http/app.js +72 -0
- package/dist/edge/http/entries.js +41 -0
- package/dist/edge/http/files.js +67 -0
- package/dist/edge/http/problem.js +65 -0
- package/dist/edge/http/projects.js +128 -0
- package/dist/edge/http/prompts.js +73 -0
- package/dist/edge/http/providers.js +62 -0
- package/dist/edge/http/settings.js +90 -0
- package/dist/edge/http/staging.js +104 -0
- package/dist/edge/http/telemetry.js +29 -0
- package/dist/edge/http/usage.js +19 -0
- package/dist/edge/open-browser.js +20 -0
- package/dist/kernel/clock.js +18 -0
- package/dist/kernel/config/index.js +32 -0
- package/dist/kernel/db/index.js +24 -0
- package/dist/kernel/db/migrate.js +59 -0
- package/dist/kernel/db/migrations/0001-init.sql +17 -0
- package/dist/kernel/db/tx.js +53 -0
- package/dist/kernel/events.js +1 -0
- package/dist/kernel/ids.js +4 -0
- package/dist/kernel/lock.js +73 -0
- package/dist/kernel/log.js +34 -0
- package/dist/kernel/paths.js +21 -0
- package/dist/kernel/pipeline.js +22 -0
- package/dist/kernel/ports/image.js +1 -0
- package/dist/kernel/ports/llm.js +1 -0
- package/dist/kernel/ports/model.js +34 -0
- package/dist/kernel/ports/registry.js +1 -0
- package/dist/kernel/ports/tts.js +1 -0
- package/dist/kernel/runner/attempt-repo.js +50 -0
- package/dist/kernel/runner/attempt.js +146 -0
- package/dist/kernel/runner/graph.js +82 -0
- package/dist/kernel/runner/index.js +205 -0
- package/dist/kernel/runner/piece-repo.js +58 -0
- package/dist/kernel/runner/providers.js +92 -0
- package/dist/kernel/version.js +14 -0
- package/dist/main.js +212 -0
- package/dist/slices/admission/model.js +6 -0
- package/dist/slices/admission/repo.js +178 -0
- package/dist/slices/admission/rules.js +201 -0
- package/dist/slices/admission/start.js +103 -0
- package/dist/slices/admission/substitute.js +80 -0
- package/dist/slices/article/continuation.js +81 -0
- package/dist/slices/article/plain.js +14 -0
- package/dist/slices/article/run.js +166 -0
- package/dist/slices/article/split.js +51 -0
- package/dist/slices/article/store.js +21 -0
- package/dist/slices/cancel/index.js +61 -0
- package/dist/slices/images/run.js +221 -0
- package/dist/slices/library/lint.js +53 -0
- package/dist/slices/library/model.js +13 -0
- package/dist/slices/library/repo.js +111 -0
- package/dist/slices/library/save.js +87 -0
- package/dist/slices/library/slots.js +86 -0
- package/dist/slices/narration/chunk.js +71 -0
- package/dist/slices/narration/concat.js +79 -0
- package/dist/slices/narration/run.js +280 -0
- package/dist/slices/reruns/cascade.js +66 -0
- package/dist/slices/reruns/index.js +269 -0
- package/dist/slices/research/planner.js +81 -0
- package/dist/slices/research/run.js +192 -0
- package/dist/slices/research/synthesis.js +48 -0
- package/dist/slices/settings/cli-status.js +37 -0
- package/dist/slices/settings/keys.js +55 -0
- package/dist/slices/settings/model.js +54 -0
- package/dist/slices/settings/playback.js +60 -0
- package/dist/slices/settings/readiness.js +21 -0
- package/dist/slices/settings/repo.js +59 -0
- package/dist/slices/settings/voices.js +48 -0
- package/dist/slices/storage/asset-name.js +16 -0
- package/dist/slices/storage/delete-project.js +35 -0
- package/dist/slices/storage/downloads.js +100 -0
- package/dist/slices/storage/layout.js +59 -0
- package/dist/slices/storage/model.js +20 -0
- package/dist/slices/storage/reconcile.js +102 -0
- package/dist/slices/storage/repo.js +104 -0
- package/dist/slices/storage/staging.js +224 -0
- package/dist/slices/telemetry/collector-client.js +51 -0
- package/dist/slices/telemetry/flush.js +111 -0
- package/dist/slices/telemetry/machine.js +33 -0
- package/dist/slices/telemetry/model.js +39 -0
- package/dist/slices/telemetry/record.js +41 -0
- package/dist/slices/telemetry/repo.js +73 -0
- package/dist/slices/telemetry/usage.js +82 -0
- package/dist/slices/thumbnail/by-llm.js +30 -0
- package/dist/slices/thumbnail/run.js +200 -0
- package/dist/slices/video/ffmpeg.js +230 -0
- package/dist/slices/video/plan.js +71 -0
- package/dist/slices/video/run.js +179 -0
- package/dist/web/app-icon.svg +1 -0
- package/dist/web/assets/barlow-condensed-latin-600-normal-BFJEwTuo.woff +0 -0
- package/dist/web/assets/barlow-condensed-latin-600-normal-DepVgxBB.woff2 +0 -0
- package/dist/web/assets/barlow-condensed-latin-700-normal-Dmwat-ge.woff +0 -0
- package/dist/web/assets/barlow-condensed-latin-700-normal-v1xN8_Wq.woff2 +0 -0
- package/dist/web/assets/barlow-condensed-latin-ext-600-normal-18ESti3H.woff2 +0 -0
- package/dist/web/assets/barlow-condensed-latin-ext-600-normal-Clv9cIcR.woff +0 -0
- package/dist/web/assets/barlow-condensed-latin-ext-700-normal-BIHFfxf0.woff +0 -0
- package/dist/web/assets/barlow-condensed-latin-ext-700-normal-CwuXbfVR.woff2 +0 -0
- package/dist/web/assets/barlow-condensed-vietnamese-600-normal-A5AYRdjN.woff2 +0 -0
- package/dist/web/assets/barlow-condensed-vietnamese-600-normal-CNlPk46_.woff +0 -0
- package/dist/web/assets/barlow-condensed-vietnamese-700-normal-DYeBwlKR.woff2 +0 -0
- package/dist/web/assets/barlow-condensed-vietnamese-700-normal-DhIzd8Tb.woff +0 -0
- package/dist/web/assets/barlow-latin-400-normal-fsAxiSwU.woff +0 -0
- package/dist/web/assets/barlow-latin-400-normal-qiz4-Cze.woff2 +0 -0
- package/dist/web/assets/barlow-latin-500-normal-BPAOfeC8.woff2 +0 -0
- package/dist/web/assets/barlow-latin-500-normal-C1h8hMer.woff +0 -0
- package/dist/web/assets/barlow-latin-600-normal-CNwfPWQD.woff +0 -0
- package/dist/web/assets/barlow-latin-600-normal-DILqtrty.woff2 +0 -0
- package/dist/web/assets/barlow-latin-700-normal-A9pxMQ4z.woff2 +0 -0
- package/dist/web/assets/barlow-latin-700-normal-__SGTsZ1.woff +0 -0
- package/dist/web/assets/barlow-latin-800-normal-BdVooDN4.woff +0 -0
- package/dist/web/assets/barlow-latin-800-normal-s1sAMnoV.woff2 +0 -0
- package/dist/web/assets/barlow-latin-ext-400-normal-CvBsJvxq.woff +0 -0
- package/dist/web/assets/barlow-latin-ext-400-normal-HxX4XjxC.woff2 +0 -0
- package/dist/web/assets/barlow-latin-ext-500-normal-CJPcKP2Q.woff +0 -0
- package/dist/web/assets/barlow-latin-ext-500-normal-DOaysfXq.woff2 +0 -0
- package/dist/web/assets/barlow-latin-ext-600-normal-B8NK_A3D.woff2 +0 -0
- package/dist/web/assets/barlow-latin-ext-600-normal-DMVRjfRT.woff +0 -0
- package/dist/web/assets/barlow-latin-ext-700-normal-BLuWmldJ.woff2 +0 -0
- package/dist/web/assets/barlow-latin-ext-700-normal-CctuGmmz.woff +0 -0
- package/dist/web/assets/barlow-latin-ext-800-normal-BiucknKG.woff2 +0 -0
- package/dist/web/assets/barlow-latin-ext-800-normal-D7I3yvUw.woff +0 -0
- package/dist/web/assets/barlow-vietnamese-400-normal-BFeobeCK.woff +0 -0
- package/dist/web/assets/barlow-vietnamese-400-normal-Dpl4UHAZ.woff2 +0 -0
- package/dist/web/assets/barlow-vietnamese-500-normal-GNfB7rCE.woff +0 -0
- package/dist/web/assets/barlow-vietnamese-500-normal-zTViEIzf.woff2 +0 -0
- package/dist/web/assets/barlow-vietnamese-600-normal-CA_GiK2e.woff +0 -0
- package/dist/web/assets/barlow-vietnamese-600-normal-DcjprdFV.woff2 +0 -0
- package/dist/web/assets/barlow-vietnamese-700-normal-4Jt4k04K.woff +0 -0
- package/dist/web/assets/barlow-vietnamese-700-normal-D6euyNzi.woff2 +0 -0
- package/dist/web/assets/barlow-vietnamese-800-normal-Cl1Mc_Dv.woff2 +0 -0
- package/dist/web/assets/barlow-vietnamese-800-normal-D0VWpbij.woff +0 -0
- package/dist/web/assets/index-BPqQnrpy.css +1 -0
- package/dist/web/assets/index-D_sWbKQi.js +81 -0
- package/dist/web/favicon.svg +1 -0
- package/dist/web/index.html +15 -0
- package/package.json +41 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { transact } from "../../kernel/db/tx.js";
|
|
2
|
+
import { stageKinds } from "../../kernel/pipeline.js";
|
|
3
|
+
import { storeArticleText } from "../article/store.js";
|
|
4
|
+
import { attachStagedFile, dropStagedSource, storeText } from "../storage/staging.js";
|
|
5
|
+
import { insertProject, insertStage } from "./repo.js";
|
|
6
|
+
// logic/01 step 1: Provide → `provided` with its output attached; Off → `skipped`;
|
|
7
|
+
// everything else → `pending`.
|
|
8
|
+
export function initialState(source) {
|
|
9
|
+
if (source === "provide") {
|
|
10
|
+
return "provided";
|
|
11
|
+
}
|
|
12
|
+
return source === "off" ? "skipped" : "pending";
|
|
13
|
+
}
|
|
14
|
+
// logic/04 step 6 with 04-data-flow Run step 2: the project row, its six stages, and the
|
|
15
|
+
// provided content all land together or not at all. The caller ticks the runner after
|
|
16
|
+
// this returns, never inside it.
|
|
17
|
+
export function startRun(deps, draft, rendered) {
|
|
18
|
+
const id = deps.ids.next();
|
|
19
|
+
const at = deps.clock.now().toISOString();
|
|
20
|
+
const config = { ...draft, rendered };
|
|
21
|
+
const project = {
|
|
22
|
+
id,
|
|
23
|
+
title: draft.title,
|
|
24
|
+
format: draft.format,
|
|
25
|
+
config,
|
|
26
|
+
createdAt: at,
|
|
27
|
+
updatedAt: at,
|
|
28
|
+
};
|
|
29
|
+
const stages = stageKinds.map((kind) => ({
|
|
30
|
+
id: deps.ids.next(),
|
|
31
|
+
projectId: id,
|
|
32
|
+
kind,
|
|
33
|
+
source: draft.sources[kind],
|
|
34
|
+
state: initialState(draft.sources[kind]),
|
|
35
|
+
failureReason: null,
|
|
36
|
+
attemptCount: 0,
|
|
37
|
+
progressCurrent: null,
|
|
38
|
+
progressTotal: null,
|
|
39
|
+
startedAt: null,
|
|
40
|
+
finishedAt: null,
|
|
41
|
+
}));
|
|
42
|
+
// Filled inside the transaction, emptied after it. Every provided upload is copied into
|
|
43
|
+
// the project folder rather than moved, so a rollback leaves the staging files exactly
|
|
44
|
+
// as the form left them and the same Play can simply be pressed again.
|
|
45
|
+
const moved = [];
|
|
46
|
+
transact(deps.db, () => {
|
|
47
|
+
insertProject(deps.db, project);
|
|
48
|
+
for (const stage of stages) {
|
|
49
|
+
insertStage(deps.db, stage);
|
|
50
|
+
}
|
|
51
|
+
attachProvided(deps, id, draft, moved);
|
|
52
|
+
});
|
|
53
|
+
for (const source of moved) {
|
|
54
|
+
dropStagedSource(deps, source);
|
|
55
|
+
}
|
|
56
|
+
return { project, stages };
|
|
57
|
+
}
|
|
58
|
+
function attachProvided(deps, projectId, draft, collected) {
|
|
59
|
+
const { sources, provided } = draft;
|
|
60
|
+
if (sources.research === "provide" && provided.research !== undefined) {
|
|
61
|
+
storeText(deps, {
|
|
62
|
+
projectId,
|
|
63
|
+
stageKind: "research",
|
|
64
|
+
role: "notes",
|
|
65
|
+
text: provided.research.trim(),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
if (sources.article === "provide" && provided.article !== undefined) {
|
|
69
|
+
// logic/08 step 1: the end-matter split runs when the article becomes done *or
|
|
70
|
+
// provided*, so a pasted Sources Consulted list is cut into its own file here exactly
|
|
71
|
+
// as the article stage cuts a written one, and never reaches the narration.
|
|
72
|
+
storeArticleText(deps, { projectId, markdown: provided.article.trim() });
|
|
73
|
+
}
|
|
74
|
+
if (sources.audio === "provide") {
|
|
75
|
+
attach(deps, projectId, "audio", provided.audio, "audio_body", collected);
|
|
76
|
+
}
|
|
77
|
+
if (sources.thumbnail === "provide") {
|
|
78
|
+
attach(deps, projectId, "thumbnail", provided.thumbnail, "thumbnail", collected);
|
|
79
|
+
}
|
|
80
|
+
if (sources.images === "provide") {
|
|
81
|
+
// logic/05 §Q39: slideshow order is the order the user left the list in.
|
|
82
|
+
for (const [index, stagedFileId] of (provided.images ?? []).entries()) {
|
|
83
|
+
attach(deps, projectId, "images", stagedFileId, "image", collected, index + 1);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function attach(deps, projectId, kind, stagedFileId, role, collected, index) {
|
|
88
|
+
if (stagedFileId === undefined) {
|
|
89
|
+
throw new Error(`the ${kind} stage is provided but carries no staged file`);
|
|
90
|
+
}
|
|
91
|
+
const result = attachStagedFile(deps, {
|
|
92
|
+
stagedFileId,
|
|
93
|
+
projectId,
|
|
94
|
+
role,
|
|
95
|
+
...(index === undefined ? {} : { index }),
|
|
96
|
+
});
|
|
97
|
+
if (!result.ok) {
|
|
98
|
+
// admit() already refused an unknown or still-copying upload, so reaching here means
|
|
99
|
+
// the file went away between the check and the write.
|
|
100
|
+
throw new Error(`the ${kind} stage's file could not be attached: ${result.reason}`);
|
|
101
|
+
}
|
|
102
|
+
collected.push(result.stagedSource);
|
|
103
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// logic/03 §Q19-§Q21 fix the grammar: `{{` … `}}`, whitespace immediately inside the
|
|
2
|
+
// braces stripped, the name case-sensitive and free to hold anything but `{`, `}` and a
|
|
3
|
+
// newline (so `{{Middle of Words}}` is one name). There is no escape syntax. Hand-rolled
|
|
4
|
+
// on purpose (standards §Q2): a template library would bring its own grammar.
|
|
5
|
+
export const slotLintKinds = ["unclosed", "empty", "nested"];
|
|
6
|
+
export const fieldGroups = ["common", "text", "image"];
|
|
7
|
+
// A well-formed slot: no brace and no newline between the delimiters.
|
|
8
|
+
const slot = /\{\{([^{}\n]*)\}\}/g;
|
|
9
|
+
const opener = "{{";
|
|
10
|
+
const closer = "}}";
|
|
11
|
+
export function detectSlots(body) {
|
|
12
|
+
const names = [];
|
|
13
|
+
const errors = [];
|
|
14
|
+
let at = body.indexOf(opener);
|
|
15
|
+
while (at !== -1) {
|
|
16
|
+
// A name cannot span a line, so the search for the closer stops at the next newline.
|
|
17
|
+
const newline = body.indexOf("\n", at + opener.length);
|
|
18
|
+
const limit = newline === -1 ? body.length : newline;
|
|
19
|
+
const close = body.indexOf(closer, at + opener.length);
|
|
20
|
+
if (close === -1 || close > limit) {
|
|
21
|
+
errors.push({ kind: "unclosed", at });
|
|
22
|
+
at = body.indexOf(opener, at + opener.length);
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
const inside = body.slice(at + opener.length, close);
|
|
26
|
+
const name = inside.trim();
|
|
27
|
+
if (inside.includes("{") || inside.includes("}")) {
|
|
28
|
+
errors.push({ kind: "nested", at });
|
|
29
|
+
}
|
|
30
|
+
else if (name === "") {
|
|
31
|
+
errors.push({ kind: "empty", at });
|
|
32
|
+
}
|
|
33
|
+
else if (!names.includes(name)) {
|
|
34
|
+
names.push(name);
|
|
35
|
+
}
|
|
36
|
+
at = body.indexOf(opener, close + closer.length);
|
|
37
|
+
}
|
|
38
|
+
return { names, errors };
|
|
39
|
+
}
|
|
40
|
+
// logic/03 step 3: one field per distinct name; Common when a name is used on both
|
|
41
|
+
// sides, otherwise Text or Image. §Q24 fixes the order as first appearance, which is why
|
|
42
|
+
// both sides arrive as ordered lists of bodies rather than as sets.
|
|
43
|
+
export function collectFields(textBodies, imageBodies) {
|
|
44
|
+
const text = namesOf(textBodies);
|
|
45
|
+
const image = namesOf(imageBodies);
|
|
46
|
+
const fields = [];
|
|
47
|
+
for (const name of [...text, ...image]) {
|
|
48
|
+
if (fields.some((field) => field.name === name)) {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
const onText = text.includes(name);
|
|
52
|
+
const onImage = image.includes(name);
|
|
53
|
+
fields.push({ name, group: onText && onImage ? "common" : onText ? "text" : "image" });
|
|
54
|
+
}
|
|
55
|
+
return fields;
|
|
56
|
+
}
|
|
57
|
+
// logic/03 step 5 and §Q21: one pass, so a value that itself contains `{{x}}` lands in
|
|
58
|
+
// the output verbatim and is never looked at again. A name with no value is left as it
|
|
59
|
+
// was written; scenario 04 refuses the run before this can reach a provider.
|
|
60
|
+
export function render(body, values) {
|
|
61
|
+
return body.replace(slot, (whole, inside) => {
|
|
62
|
+
// A slot name may be anything but a brace or a newline (§Q19), so "constructor" and
|
|
63
|
+
// "toString" are ordinary names; a plain lookup would answer them from Object's
|
|
64
|
+
// prototype and splice a function's source into a prompt bound for a provider.
|
|
65
|
+
const name = inside.trim();
|
|
66
|
+
const value = Object.hasOwn(values, name) ? values[name] : undefined;
|
|
67
|
+
return value === undefined ? whole : value;
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
function namesOf(bodies) {
|
|
71
|
+
const names = [];
|
|
72
|
+
for (const body of bodies) {
|
|
73
|
+
for (const name of detectSlots(body).names) {
|
|
74
|
+
if (!names.includes(name)) {
|
|
75
|
+
names.push(name);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return names;
|
|
80
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { noTokens, plusUsage } from "../telemetry/model.js";
|
|
2
|
+
// §Q59: "at most 3 continuations; still unfinished after the third is a failed attempt".
|
|
3
|
+
export const continuationLimit = 3;
|
|
4
|
+
// The provider's own word for "I stopped because I ran out of room", which is what every
|
|
5
|
+
// adapter maps its finish reason to (`kernel/ports/llm.ts`).
|
|
6
|
+
const truncatedReason = "length";
|
|
7
|
+
// §Q56: "a fixed 'Research notes' header followed by the notes, then the rendered article
|
|
8
|
+
// prompt; without research, the rendered prompt alone". One user message, and no
|
|
9
|
+
// parameters of the app's own: the provider's defaults are used (§Q61).
|
|
10
|
+
export function articleMessages(brief) {
|
|
11
|
+
const notes = brief.notes?.trim();
|
|
12
|
+
const content = notes === undefined || notes === ""
|
|
13
|
+
? brief.articlePrompt
|
|
14
|
+
: `Research notes\n\n${notes}\n\n${brief.articlePrompt}`;
|
|
15
|
+
return [{ role: "user", content }];
|
|
16
|
+
}
|
|
17
|
+
// The article so far goes back as the assistant turn it was, so the model continues its
|
|
18
|
+
// own answer rather than being asked to write a second article. Nothing is inserted at
|
|
19
|
+
// the seam - the pieces are concatenated exactly as they arrived (§Q57: the stored text
|
|
20
|
+
// is the model's, never edited by the app) - so the instruction has to carry the whole
|
|
21
|
+
// of the rule that keeps the seam invisible.
|
|
22
|
+
export function continuationMessages(base, soFar) {
|
|
23
|
+
return [
|
|
24
|
+
...base,
|
|
25
|
+
{ role: "assistant", content: soFar },
|
|
26
|
+
{
|
|
27
|
+
role: "user",
|
|
28
|
+
content: [
|
|
29
|
+
"You stopped at your output limit before the article was finished.",
|
|
30
|
+
"Continue from the exact character you stopped at, in the same voice and format.",
|
|
31
|
+
"Do not repeat anything you have already written, do not restate the last",
|
|
32
|
+
"sentence, and do not open with a heading or a preamble unless the article was",
|
|
33
|
+
"already in the middle of one.",
|
|
34
|
+
].join("\n"),
|
|
35
|
+
},
|
|
36
|
+
];
|
|
37
|
+
}
|
|
38
|
+
export async function writeArticle(providers, choice, brief, onDelta) {
|
|
39
|
+
const base = articleMessages(brief);
|
|
40
|
+
const sent = [{ label: "Article", messages: base }];
|
|
41
|
+
const stream = (event) => {
|
|
42
|
+
if (event.type === "delta") {
|
|
43
|
+
onDelta(event.text);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
let answer = await providers.llm({ provider: choice.provider, model: choice.model, messages: base, check: written }, stream);
|
|
47
|
+
const pieces = [answer.text];
|
|
48
|
+
let tokens = plusUsage(noTokens, answer.usage);
|
|
49
|
+
for (let n = 1; truncated(answer) && n <= continuationLimit; n += 1) {
|
|
50
|
+
const messages = continuationMessages(base, pieces.join(""));
|
|
51
|
+
sent.push({ label: `Continuation ${String(n)}`, messages });
|
|
52
|
+
answer = await providers.llm({
|
|
53
|
+
provider: choice.provider,
|
|
54
|
+
model: choice.model,
|
|
55
|
+
messages,
|
|
56
|
+
// The last continuation allowed is the one that has to end the article: a fourth
|
|
57
|
+
// truncation is a failed attempt, which is the wrapper's to retry (§Q59). The
|
|
58
|
+
// loop therefore never sees a truncated answer with its budget spent.
|
|
59
|
+
check: n === continuationLimit ? finished : written,
|
|
60
|
+
}, stream);
|
|
61
|
+
pieces.push(answer.text);
|
|
62
|
+
tokens = plusUsage(tokens, answer.usage);
|
|
63
|
+
}
|
|
64
|
+
return { markdown: pieces.join(""), sent, tokens };
|
|
65
|
+
}
|
|
66
|
+
function truncated(answer) {
|
|
67
|
+
return answer.finishReason === truncatedReason;
|
|
68
|
+
}
|
|
69
|
+
// §Q61: "Empty response → failed attempt".
|
|
70
|
+
function written(answer) {
|
|
71
|
+
return answer.text.trim() === "" ? "the article answered with nothing" : undefined;
|
|
72
|
+
}
|
|
73
|
+
function finished(answer) {
|
|
74
|
+
const empty = written(answer);
|
|
75
|
+
if (empty !== undefined) {
|
|
76
|
+
return empty;
|
|
77
|
+
}
|
|
78
|
+
return truncated(answer)
|
|
79
|
+
? `the article was still unfinished after ${String(continuationLimit)} continuations`
|
|
80
|
+
: undefined;
|
|
81
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { remark } from "remark";
|
|
2
|
+
import remarkGfm from "remark-gfm";
|
|
3
|
+
import stripMarkdown from "strip-markdown";
|
|
4
|
+
// `logic/05` §Q37 and `logic/07` step 4: the article is stored twice, as the markdown the
|
|
5
|
+
// model wrote and as the plain text the narration is read from, so no TTS voice ever says
|
|
6
|
+
// a hash or a bracket. remark parses and strip-markdown drops the formatting; the GFM
|
|
7
|
+
// extension is what makes a table a table rather than a paragraph full of pipes, and what
|
|
8
|
+
// takes a footnote marker out of the middle of a sentence.
|
|
9
|
+
export function plainText(markdown) {
|
|
10
|
+
// Built per call: a shared processor would be a module-level singleton, and building
|
|
11
|
+
// one costs a few microseconds against a call that just parsed an article.
|
|
12
|
+
const stripped = remark().use(remarkGfm).use(stripMarkdown).processSync(markdown);
|
|
13
|
+
return String(stripped);
|
|
14
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { insertPiece, piecesOf, setPiece } from "../../kernel/runner/piece-repo.js";
|
|
3
|
+
import { projectById } from "../admission/repo.js";
|
|
4
|
+
import { outputPath } from "../storage/layout.js";
|
|
5
|
+
import { outputsOf } from "../storage/repo.js";
|
|
6
|
+
import { storeText } from "../storage/staging.js";
|
|
7
|
+
import { noTokens, plusUsage } from "../telemetry/model.js";
|
|
8
|
+
import { writeArticle } from "./continuation.js";
|
|
9
|
+
import { storeArticleText } from "./store.js";
|
|
10
|
+
const categories = ["intro", "outro"];
|
|
11
|
+
export async function runArticle(deps, context, providers) {
|
|
12
|
+
const { projectId } = context.stage;
|
|
13
|
+
const project = projectById(deps.db, projectId);
|
|
14
|
+
if (project === undefined) {
|
|
15
|
+
throw new Error(`project ${projectId} has no row`);
|
|
16
|
+
}
|
|
17
|
+
const choice = project.config.llm;
|
|
18
|
+
const articlePrompt = project.config.rendered.article;
|
|
19
|
+
if (choice === undefined || articlePrompt === undefined) {
|
|
20
|
+
// Admission refuses a run whose article is Generate without both (`logic/04`), so
|
|
21
|
+
// reaching here is a bug in admission rather than something the user did.
|
|
22
|
+
throw new Error("the run has no LLM provider or no rendered article prompt");
|
|
23
|
+
}
|
|
24
|
+
const notes = researchNotes(deps, projectId);
|
|
25
|
+
const brief = { articlePrompt, ...(notes === undefined ? {} : { notes }) };
|
|
26
|
+
const written = await writeArticle(providers, choice, brief, (text) => {
|
|
27
|
+
// Step 2 and `logic/01` §Q6: the page shows the article as it is written. The idle
|
|
28
|
+
// timeout is restarted by the wrapper on the same events, not here.
|
|
29
|
+
// ceiling: §Q60 discards the partial text of a failed attempt, but the deltas already
|
|
30
|
+
// sent cannot be unsent, so a retry mid-stream leaves the page appending the second
|
|
31
|
+
// telling under the first. What the project keeps is still the successful attempt's
|
|
32
|
+
// text alone; the upgrade is an event telling the page to start the article again.
|
|
33
|
+
context.emit({ type: "article.delta", projectId, text });
|
|
34
|
+
});
|
|
35
|
+
// Step 4: the markdown exactly as the model produced it, and the plain-text narration
|
|
36
|
+
// source. §Q63 keeps the end matter out of the narration and beside the article instead.
|
|
37
|
+
const store = { projectId, stageKind: "article" };
|
|
38
|
+
storeText(deps, { ...store, role: "article_md", text: written.markdown });
|
|
39
|
+
const narration = storeArticleText(deps, { projectId, markdown: written.markdown });
|
|
40
|
+
// logic/16 step 2 counts the article and each entry text as units of their own, so the
|
|
41
|
+
// article's own event goes out as soon as its text is stored, carrying the tokens of
|
|
42
|
+
// the first call and its continuations (logic/07 step 3).
|
|
43
|
+
deps.count("stage.completed", {
|
|
44
|
+
stage: "article",
|
|
45
|
+
provider: choice.provider,
|
|
46
|
+
model: choice.model,
|
|
47
|
+
...written.tokens,
|
|
48
|
+
});
|
|
49
|
+
const sent = [...written.sent];
|
|
50
|
+
for (const [index, category] of categories.entries()) {
|
|
51
|
+
const segment = await writeSegment(providers, choice, project.config, category, narration, sent);
|
|
52
|
+
if (segment !== undefined) {
|
|
53
|
+
keepSegment(deps, context, segment, index + 1);
|
|
54
|
+
// "each intro/outro text" of logic/16 step 2, named by its segment. A text-mode
|
|
55
|
+
// entry is rendered rather than written (§Q98), so it made no call and names no
|
|
56
|
+
// provider; its tokens are the zero step 3 asks for rather than an estimate.
|
|
57
|
+
deps.count("stage.completed", {
|
|
58
|
+
stage: "article",
|
|
59
|
+
segment: category,
|
|
60
|
+
...(segment.mode === "llm" ? { provider: choice.provider, model: choice.model } : {}),
|
|
61
|
+
...segment.tokens,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// §Q57: the exact messages sent, the continuations and the entry calls among them.
|
|
66
|
+
storeText(deps, { ...store, role: "instructions", text: instructionsText(sent) });
|
|
67
|
+
deps.log.write("info", "article.done", {
|
|
68
|
+
projectId,
|
|
69
|
+
stage: "article",
|
|
70
|
+
detail: `${String(written.sent.length - 1)} continuations, ${String(narration.length)} characters to narrate`,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
// Step 5: "for each picked entry in LLM mode, one call with the filled entry as
|
|
74
|
+
// instruction plus the title, keyword values, and the plain-text article ... Text-mode
|
|
75
|
+
// entries are stored as rendered per scenario 03 with no call" (§Q97, §Q98).
|
|
76
|
+
async function writeSegment(providers, choice, config, category, article, sent) {
|
|
77
|
+
const picked = config[category];
|
|
78
|
+
if (picked === undefined) {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
const body = config.rendered[category];
|
|
82
|
+
if (body === undefined) {
|
|
83
|
+
throw new Error(`the run has no rendered ${category} text`);
|
|
84
|
+
}
|
|
85
|
+
const common = { category, name: picked.name, mode: picked.mode };
|
|
86
|
+
if (picked.mode === "text") {
|
|
87
|
+
return { ...common, text: body, tokens: noTokens };
|
|
88
|
+
}
|
|
89
|
+
const messages = segmentMessages(body, config, article);
|
|
90
|
+
sent.push({ label: label(category), messages });
|
|
91
|
+
const answer = await providers.llm({
|
|
92
|
+
provider: choice.provider,
|
|
93
|
+
model: choice.model,
|
|
94
|
+
messages,
|
|
95
|
+
// §Q96 and §Q61: an entry that answers with nothing is a failed attempt like any
|
|
96
|
+
// other, and the wrapper is what retries it.
|
|
97
|
+
check: (given) => given.text.trim() === "" ? `the ${category} answered with nothing` : undefined,
|
|
98
|
+
});
|
|
99
|
+
return { ...common, text: answer.text.trim(), tokens: plusUsage(noTokens, answer.usage) };
|
|
100
|
+
}
|
|
101
|
+
function segmentMessages(body, config, article) {
|
|
102
|
+
const values = Object.entries(config.values);
|
|
103
|
+
return [
|
|
104
|
+
{
|
|
105
|
+
role: "user",
|
|
106
|
+
content: [
|
|
107
|
+
body,
|
|
108
|
+
"",
|
|
109
|
+
`Video title: ${config.title}`,
|
|
110
|
+
"",
|
|
111
|
+
"Keyword values for this run:",
|
|
112
|
+
"",
|
|
113
|
+
values.length === 0
|
|
114
|
+
? "(none)"
|
|
115
|
+
: values.map(([name, value]) => `${name}: ${value}`).join("\n"),
|
|
116
|
+
"",
|
|
117
|
+
"The article this video narrates:",
|
|
118
|
+
"",
|
|
119
|
+
article,
|
|
120
|
+
].join("\n"),
|
|
121
|
+
},
|
|
122
|
+
];
|
|
123
|
+
}
|
|
124
|
+
// The segment rows are keyed by `idx` within the stage, so a stage that runs again after
|
|
125
|
+
// a failure replaces what it wrote rather than colliding with it.
|
|
126
|
+
function keepSegment(deps, context, segment, idx) {
|
|
127
|
+
// Written out rather than stringified whole: the caller hands in what the call cost as
|
|
128
|
+
// well, and the piece is `logic/08`'s to read - it carries the text to narrate, nothing
|
|
129
|
+
// about the model that wrote it.
|
|
130
|
+
const payload = JSON.stringify({
|
|
131
|
+
category: segment.category,
|
|
132
|
+
name: segment.name,
|
|
133
|
+
mode: segment.mode,
|
|
134
|
+
text: segment.text,
|
|
135
|
+
});
|
|
136
|
+
const existing = piecesOf(deps.db, context.stage.id, "segment").find((piece) => piece.idx === idx);
|
|
137
|
+
if (existing === undefined) {
|
|
138
|
+
insertPiece(deps.db, {
|
|
139
|
+
id: deps.ids.next(),
|
|
140
|
+
stageId: context.stage.id,
|
|
141
|
+
kind: "segment",
|
|
142
|
+
idx,
|
|
143
|
+
state: "done",
|
|
144
|
+
payload,
|
|
145
|
+
});
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
setPiece(deps.db, existing.id, "done", payload);
|
|
149
|
+
}
|
|
150
|
+
// Research writes its notes as an output of its own (`logic/06` step 4), and a provided
|
|
151
|
+
// research stage stores the pasted text the same way, so one lookup covers both. No row
|
|
152
|
+
// means research was Off or skipped, and the article is written from the prompt alone.
|
|
153
|
+
function researchNotes(deps, projectId) {
|
|
154
|
+
const notes = outputsOf(deps.db, projectId).find((output) => output.role === "notes");
|
|
155
|
+
if (notes === undefined) {
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
return readFileSync(outputPath(deps.paths, projectId, notes.path), "utf8");
|
|
159
|
+
}
|
|
160
|
+
function instructionsText(sent) {
|
|
161
|
+
const parts = sent.map((one) => `=== ${one.label} ===\n\n${one.messages.map((message) => message.content).join("\n\n")}`);
|
|
162
|
+
return `${parts.join("\n\n")}\n`;
|
|
163
|
+
}
|
|
164
|
+
function label(category) {
|
|
165
|
+
return category === "intro" ? "Intro" : "Outro";
|
|
166
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { remark } from "remark";
|
|
2
|
+
import remarkGfm from "remark-gfm";
|
|
3
|
+
import { plainText } from "./plain.js";
|
|
4
|
+
// §Q63 names the two sections. The comparison is on the heading's own text, so "## Sources
|
|
5
|
+
// Consulted", "# SOURCES CONSULTED" and "**Sources Consulted:**" are one heading and
|
|
6
|
+
// "## Sources Consulted and Further Reading" is not.
|
|
7
|
+
const endHeadings = {
|
|
8
|
+
"sources consulted": "sources",
|
|
9
|
+
"pronunciation glossary": "glossary",
|
|
10
|
+
};
|
|
11
|
+
export function splitEndMatter(markdown) {
|
|
12
|
+
const marks = endMatterMarks(markdown);
|
|
13
|
+
const first = marks[0];
|
|
14
|
+
if (first === undefined) {
|
|
15
|
+
return { body: markdown, sources: "", glossary: "" };
|
|
16
|
+
}
|
|
17
|
+
const parts = { sources: "", glossary: "" };
|
|
18
|
+
for (const [index, mark] of marks.entries()) {
|
|
19
|
+
// Everything up to the next end heading belongs to this one, so a chapter the model
|
|
20
|
+
// wrote below its sources list travels with the sources rather than being narrated.
|
|
21
|
+
parts[mark.part] += markdown.slice(mark.at, marks[index + 1]?.at ?? markdown.length);
|
|
22
|
+
}
|
|
23
|
+
return { body: markdown.slice(0, first.at), sources: parts.sources, glossary: parts.glossary };
|
|
24
|
+
}
|
|
25
|
+
function endMatterMarks(markdown) {
|
|
26
|
+
const tree = remark().use(remarkGfm).parse(markdown);
|
|
27
|
+
const marks = [];
|
|
28
|
+
for (const node of tree.children) {
|
|
29
|
+
// A paragraph counts as well as a heading: a model that answers with a bold line
|
|
30
|
+
// rather than a `#` still meant it as the section's title.
|
|
31
|
+
if (node.type !== "heading" && node.type !== "paragraph") {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
const at = node.position?.start.offset;
|
|
35
|
+
const to = node.position?.end.offset;
|
|
36
|
+
if (at === undefined || to === undefined) {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
const part = partOf(markdown.slice(at, to));
|
|
40
|
+
if (part !== undefined) {
|
|
41
|
+
marks.push({ part, at });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return marks;
|
|
45
|
+
}
|
|
46
|
+
function partOf(source) {
|
|
47
|
+
// The heading's own markup is dropped by the same conversion the narration source uses,
|
|
48
|
+
// so nothing here has to know what a heading looks like.
|
|
49
|
+
const heading = plainText(source).trim().toLowerCase().replace(/:$/, "");
|
|
50
|
+
return endHeadings[heading];
|
|
51
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { storeText } from "../storage/staging.js";
|
|
2
|
+
import { plainText } from "./plain.js";
|
|
3
|
+
import { splitEndMatter } from "./split.js";
|
|
4
|
+
// The narration source it stored, so a caller that needs the article as it will be spoken
|
|
5
|
+
// - `logic/07` step 5 writes the intro and outro from it - does not read the file back.
|
|
6
|
+
export function storeArticleText(deps, input) {
|
|
7
|
+
const end = splitEndMatter(input.markdown);
|
|
8
|
+
const store = { projectId: input.projectId, stageKind: "article" };
|
|
9
|
+
// `logic/05` §Q37's invariant: "the narration source of an article is always plain
|
|
10
|
+
// text", so no voice ever says a hash or a bracket, whoever wrote the markdown.
|
|
11
|
+
const narration = plainText(end.body);
|
|
12
|
+
storeText(deps, { ...store, role: "article_txt", text: narration });
|
|
13
|
+
// §Q63: no such heading means no file, not an empty one.
|
|
14
|
+
if (end.sources.trim() !== "") {
|
|
15
|
+
storeText(deps, { ...store, role: "sources", text: end.sources });
|
|
16
|
+
}
|
|
17
|
+
if (end.glossary.trim() !== "") {
|
|
18
|
+
storeText(deps, { ...store, role: "glossary", text: end.glossary });
|
|
19
|
+
}
|
|
20
|
+
return narration;
|
|
21
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { derive } from "../../kernel/runner/graph.js";
|
|
2
|
+
import { finishStage, projectExists, stagesOf } from "../admission/repo.js";
|
|
3
|
+
// `logic/13`: Cancel on the project header. Every in-flight call of this project is
|
|
4
|
+
// aborted at once, every `done` output and every finished piece is kept for the resume,
|
|
5
|
+
// and the project sits `canceled` until the user retries a stage.
|
|
6
|
+
// §Q111 and `logic/01`'s transition table, in the words the stage row carries.
|
|
7
|
+
export const canceledByUser = "canceled by user";
|
|
8
|
+
export async function cancelProject(deps, projectId) {
|
|
9
|
+
if (!projectExists(deps.db, projectId)) {
|
|
10
|
+
return { ok: false, reason: "no-project" };
|
|
11
|
+
}
|
|
12
|
+
const before = stagesOf(deps.db, projectId);
|
|
13
|
+
const running = before.filter((stage) => stage.state === "running").map((stage) => stage.kind);
|
|
14
|
+
if (running.length === 0) {
|
|
15
|
+
// Step 4: "a second click is a no-op". Nothing is aborted and no state changes, so
|
|
16
|
+
// the page is simply told what the project already reads.
|
|
17
|
+
return { ok: true, canceled: [], state: derive(before) };
|
|
18
|
+
}
|
|
19
|
+
// Step 1: nothing waits for a response. The runner holds the controllers and its own
|
|
20
|
+
// barrier, so a stage that finishes during this does not release its dependents.
|
|
21
|
+
await deps.abort(projectId);
|
|
22
|
+
// Step 3's invariant: "after cancel completes no stage of the project is `running`".
|
|
23
|
+
// The runner writes that row as each aborted stage unwinds; this is the path where it
|
|
24
|
+
// could not - a failed write is logged there and the run is left mid-flight otherwise.
|
|
25
|
+
const after = stagesOf(deps.db, projectId);
|
|
26
|
+
for (const stage of after) {
|
|
27
|
+
if (stage.state !== "running") {
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
deps.log.write("warn", "cancel.sweep", {
|
|
31
|
+
projectId,
|
|
32
|
+
stage: stage.kind,
|
|
33
|
+
detail: "the stage was still running after its calls stopped",
|
|
34
|
+
});
|
|
35
|
+
finishStage(deps.db, stage.id, "canceled", canceledByUser, deps.clock.now().toISOString());
|
|
36
|
+
deps.emit(projectId, {
|
|
37
|
+
type: "stage.state",
|
|
38
|
+
projectId,
|
|
39
|
+
stage: stage.kind,
|
|
40
|
+
state: "canceled",
|
|
41
|
+
failureReason: canceledByUser,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
const settled = stagesOf(deps.db, projectId);
|
|
45
|
+
const state = derive(settled);
|
|
46
|
+
if (after.some((stage) => stage.state === "running")) {
|
|
47
|
+
// Only when the sweep above moved a row: the runner already announced the state it
|
|
48
|
+
// left the project in, and repeating it would tell every open page the same thing
|
|
49
|
+
// twice.
|
|
50
|
+
deps.emit(projectId, { type: "project.state", projectId, state });
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
ok: true,
|
|
54
|
+
// §Q113: a stage whose output was stored in the same instant as the cancel stays
|
|
55
|
+
// `done`, so what was stopped is read back rather than assumed from what was running.
|
|
56
|
+
canceled: settled
|
|
57
|
+
.filter((stage) => stage.state === "canceled" && running.includes(stage.kind))
|
|
58
|
+
.map((stage) => stage.kind),
|
|
59
|
+
state,
|
|
60
|
+
};
|
|
61
|
+
}
|