@gentbajko/slopify 0.1.0 → 0.2.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/README.md +2 -2
- package/dist/adapter-registry.js +11 -12
- package/dist/adapters/image/bytes.js +1 -1
- package/dist/adapters/image/fal.js +28 -41
- package/dist/adapters/image/openai.js +19 -24
- package/dist/adapters/image/replicate.js +33 -43
- package/dist/adapters/llm/claude-code.js +16 -19
- package/dist/adapters/llm/codex.js +14 -15
- package/dist/adapters/llm/openrouter.js +13 -16
- package/dist/adapters/llm/run-cli.js +9 -11
- package/dist/adapters/llm/sse-lines.js +1 -2
- package/dist/adapters/retry-after.js +6 -6
- package/dist/adapters/tts/cartesia.js +5 -5
- package/dist/adapters/tts/elevenlabs.js +14 -19
- package/dist/adapters/tts/openai.js +9 -10
- package/dist/edge/cli.js +0 -0
- package/dist/edge/http/actions.js +8 -9
- package/dist/edge/http/app.js +3 -3
- package/dist/edge/http/entries.js +1 -1
- package/dist/edge/http/projects.js +7 -7
- package/dist/edge/http/prompts.js +6 -6
- package/dist/edge/http/providers.js +4 -4
- package/dist/edge/http/settings.js +2 -2
- package/dist/edge/http/staging.js +1 -1
- package/dist/edge/http/telemetry.js +6 -7
- package/dist/edge/http/usage.js +5 -5
- package/dist/kernel/config/index.js +1 -1
- package/dist/kernel/db/tx.js +9 -12
- package/dist/kernel/log.js +5 -5
- package/dist/kernel/pipeline.js +7 -9
- package/dist/kernel/ports/model.js +3 -4
- package/dist/kernel/runner/attempt-repo.js +5 -6
- package/dist/kernel/runner/attempt.js +16 -23
- package/dist/kernel/runner/graph.js +18 -29
- package/dist/kernel/runner/index.js +27 -33
- package/dist/kernel/runner/piece-repo.js +9 -11
- package/dist/kernel/runner/providers.js +5 -7
- package/dist/kernel/version.js +3 -3
- package/dist/main.js +10 -11
- package/dist/slices/admission/model.js +2 -2
- package/dist/slices/admission/repo.js +6 -7
- package/dist/slices/admission/rules.js +12 -15
- package/dist/slices/admission/start.js +7 -8
- package/dist/slices/admission/substitute.js +11 -11
- package/dist/slices/article/continuation.js +9 -9
- package/dist/slices/article/plain.js +5 -5
- package/dist/slices/article/run.js +25 -25
- package/dist/slices/article/split.js +1 -1
- package/dist/slices/article/store.js +5 -5
- package/dist/slices/cancel/index.js +8 -8
- package/dist/slices/images/run.js +32 -34
- package/dist/slices/library/lint.js +3 -3
- package/dist/slices/library/model.js +7 -7
- package/dist/slices/library/repo.js +3 -3
- package/dist/slices/library/save.js +7 -7
- package/dist/slices/library/slots.js +5 -5
- package/dist/slices/narration/chunk.js +12 -12
- package/dist/slices/narration/concat.js +12 -13
- package/dist/slices/narration/run.js +46 -49
- package/dist/slices/reruns/cascade.js +10 -11
- package/dist/slices/reruns/index.js +36 -37
- package/dist/slices/research/planner.js +4 -5
- package/dist/slices/research/run.js +22 -23
- package/dist/slices/research/synthesis.js +5 -5
- package/dist/slices/settings/cli-status.js +9 -10
- package/dist/slices/settings/keys.js +8 -9
- package/dist/slices/settings/model.js +5 -5
- package/dist/slices/settings/readiness.js +3 -4
- package/dist/slices/settings/repo.js +1 -1
- package/dist/slices/settings/voices.js +3 -4
- package/dist/slices/storage/asset-name.js +2 -2
- package/dist/slices/storage/delete-project.js +3 -5
- package/dist/slices/storage/downloads.js +4 -4
- package/dist/slices/storage/layout.js +5 -6
- package/dist/slices/storage/model.js +1 -1
- package/dist/slices/storage/reconcile.js +6 -7
- package/dist/slices/storage/repo.js +1 -1
- package/dist/slices/storage/staging.js +12 -15
- package/dist/slices/telemetry/collector-client.js +2 -2
- package/dist/slices/telemetry/flush.js +8 -8
- package/dist/slices/telemetry/machine.js +6 -7
- package/dist/slices/telemetry/model.js +11 -13
- package/dist/slices/telemetry/record.js +7 -7
- package/dist/slices/telemetry/repo.js +4 -4
- package/dist/slices/telemetry/usage.js +5 -5
- package/dist/slices/thumbnail/by-llm.js +2 -2
- package/dist/slices/thumbnail/run.js +30 -32
- package/dist/slices/video/ffmpeg.js +6 -6
- package/dist/slices/video/plan.js +9 -9
- package/dist/slices/video/run.js +19 -20
- package/dist/web/assets/index-77hb2Mr0.js +81 -0
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/dist/web/assets/index-D_sWbKQi.js +0 -81
|
@@ -13,8 +13,8 @@ function staleStages(input) {
|
|
|
13
13
|
case "rerun":
|
|
14
14
|
return withDependents([action.stage]);
|
|
15
15
|
case "article-edit": {
|
|
16
|
-
//
|
|
17
|
-
// thumbnail
|
|
16
|
+
// An article edit re-runs audio, LLM-mode intro/outro text, the LLM-written
|
|
17
|
+
// thumbnail and the video, leaving prompt-based images untouched. The video comes
|
|
18
18
|
// along as a dependent of the audio, so it is never named here.
|
|
19
19
|
const roots = ["audio"];
|
|
20
20
|
if (input.thumbnailSource === "prompt_by_llm") {
|
|
@@ -23,26 +23,25 @@ function staleStages(input) {
|
|
|
23
23
|
return withDependents(roots);
|
|
24
24
|
}
|
|
25
25
|
case "image-deleted":
|
|
26
|
-
//
|
|
26
|
+
// The image is removed from the set and the video re-renders. The remaining
|
|
27
27
|
// images stand, so the images stage itself is not redone.
|
|
28
28
|
return withDependents(["video"]);
|
|
29
29
|
case "image-regenerated":
|
|
30
30
|
return withDependents(["images"]);
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
|
-
// `
|
|
34
|
-
//
|
|
33
|
+
// `provided` → `running` and `skipped` → `running` are forbidden, so a stage whose output
|
|
34
|
+
// the user supplied or switched off is stepped over. The walk above still passes
|
|
35
35
|
// through it: re-running the article with a provided audio must reach the video.
|
|
36
36
|
function redoable(state) {
|
|
37
37
|
return state !== "provided" && state !== "skipped";
|
|
38
38
|
}
|
|
39
39
|
function clearsOf(action, kind) {
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
// standing, which is exactly what §Q106 promises.
|
|
40
|
+
// The previous video stays downloadable until the new render finishes. Clearing the video's
|
|
41
|
+
// outputs here would take it away the moment the user pressed Re-render - or for the whole of
|
|
42
|
+
// a re-narration, when the video comes along as a dependent. It is kept instead, and
|
|
43
|
+
// `slices/video/run.ts` replaces the file and the rows in one swap once ffmpeg has exited
|
|
44
|
+
// cleanly. A render that fails or is canceled leaves the old one standing.
|
|
46
45
|
if (kind === "video") {
|
|
47
46
|
return "nothing";
|
|
48
47
|
}
|
|
@@ -12,10 +12,10 @@ import { redoPlan } from "./cascade.js";
|
|
|
12
12
|
// Only the one field this module changes is named; the rest of a piece's payload belongs
|
|
13
13
|
// to the stage that wrote it and travels through untouched.
|
|
14
14
|
const anyObject = z.record(z.string(), z.unknown());
|
|
15
|
-
// What an article edit replaces
|
|
16
|
-
//
|
|
15
|
+
// What an article edit replaces. `instructions` is not among them: that row records what
|
|
16
|
+
// was actually sent to the model, and an edit did not send anything.
|
|
17
17
|
const articleRoles = ["article_md", "article_txt", "sources", "glossary"];
|
|
18
|
-
//
|
|
18
|
+
// Retry is offered on a stage that stopped short,
|
|
19
19
|
// and a canceled stage resumes exactly as a failed one does.
|
|
20
20
|
export function retryStage(deps, projectId, kind) {
|
|
21
21
|
const loaded = load(deps, projectId);
|
|
@@ -27,14 +27,14 @@ export function retryStage(deps, projectId, kind) {
|
|
|
27
27
|
return { ok: false, reason: "not-retryable" };
|
|
28
28
|
}
|
|
29
29
|
// The pieces and the outputs stay where they are: the per-stage resume rules keep what
|
|
30
|
-
// finished
|
|
31
|
-
//
|
|
30
|
+
// finished, and a cancel keeps them too. That is the whole of a canceled stage being
|
|
31
|
+
// resumable.
|
|
32
32
|
transact(deps.db, () => {
|
|
33
33
|
resetStage(deps.db, stage.id);
|
|
34
34
|
});
|
|
35
35
|
return { ok: true, redone: [kind] };
|
|
36
36
|
}
|
|
37
|
-
//
|
|
37
|
+
// Re-run audio with another voice, Re-run images, Re-render. The stage
|
|
38
38
|
// starts over from the project's stored configuration rather than resuming.
|
|
39
39
|
export function rerunStage(deps, projectId, kind) {
|
|
40
40
|
const loaded = load(deps, projectId);
|
|
@@ -47,15 +47,15 @@ export function rerunStage(deps, projectId, kind) {
|
|
|
47
47
|
}
|
|
48
48
|
return apply(deps, projectId, { kind: "rerun", stage: kind }, loaded);
|
|
49
49
|
}
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
// LLM-written thumbnail
|
|
50
|
+
// The inline editor replaces the stored markdown; the plain-text narration source and the
|
|
51
|
+
// sources and glossary files are rebuilt; then audio, LLM-mode intro/outro text, the
|
|
52
|
+
// LLM-written thumbnail and the video re-run, leaving prompt-based images untouched.
|
|
53
53
|
//
|
|
54
54
|
// ceiling: the LLM-mode intro and outro texts are *not* rewritten from the edited article.
|
|
55
|
-
// Only the article stage writes them
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
55
|
+
// Only the article stage writes them, and any run of that stage regenerates the whole
|
|
56
|
+
// article - which would throw away the edit that triggered it. The audio does re-narrate
|
|
57
|
+
// the kept entry text, so the video is whole; the upgrade is a mode on the article stage
|
|
58
|
+
// that keeps the stored article and rewrites the entries alone.
|
|
59
59
|
export function editArticle(deps, projectId, markdown) {
|
|
60
60
|
const loaded = load(deps, projectId);
|
|
61
61
|
if (!loaded.ok) {
|
|
@@ -67,8 +67,8 @@ export function editArticle(deps, projectId, markdown) {
|
|
|
67
67
|
}
|
|
68
68
|
const text = markdown.trim();
|
|
69
69
|
if (text === "") {
|
|
70
|
-
//
|
|
71
|
-
//
|
|
70
|
+
// A project keeps one output per stage; an empty article would leave the audio stage
|
|
71
|
+
// with nothing to narrate and no way back except another edit.
|
|
72
72
|
return { ok: false, reason: "empty-article" };
|
|
73
73
|
}
|
|
74
74
|
return apply(deps, projectId, { kind: "article-edit" }, loaded, () => {
|
|
@@ -76,15 +76,15 @@ export function editArticle(deps, projectId, markdown) {
|
|
|
76
76
|
for (const output of replaced) {
|
|
77
77
|
deleteOutput(deps.db, output.id);
|
|
78
78
|
}
|
|
79
|
-
// The four files are written back under the names they already had, so the write
|
|
80
|
-
//
|
|
79
|
+
// The four files are written back under the names they already had, so the write
|
|
80
|
+
// replaces rather than leaving a second version beside the first.
|
|
81
81
|
storeText(deps, { projectId, stageKind: "article", role: "article_md", text });
|
|
82
82
|
storeArticleText(deps, { projectId, markdown: text });
|
|
83
83
|
return replaced.map((output) => output.path);
|
|
84
84
|
});
|
|
85
85
|
}
|
|
86
|
-
//
|
|
87
|
-
//
|
|
86
|
+
// One image is removed from the set, at least one image has to remain, and the video
|
|
87
|
+
// re-renders.
|
|
88
88
|
export function deleteImage(deps, projectId, outputId) {
|
|
89
89
|
const loaded = load(deps, projectId);
|
|
90
90
|
if (!loaded.ok) {
|
|
@@ -95,8 +95,7 @@ export function deleteImage(deps, projectId, outputId) {
|
|
|
95
95
|
return { ok: false, reason: "unknown-image" };
|
|
96
96
|
}
|
|
97
97
|
if (loaded.outputs.filter((output) => output.role === "image").length <= 1) {
|
|
98
|
-
//
|
|
99
|
-
// slideshow to compute without one.
|
|
98
|
+
// At least one image always remains: there is no slideshow to compute without one.
|
|
100
99
|
return { ok: false, reason: "last-image" };
|
|
101
100
|
}
|
|
102
101
|
return apply(deps, projectId, { kind: "image-deleted" }, loaded, () => {
|
|
@@ -108,9 +107,9 @@ export function deleteImage(deps, projectId, outputId) {
|
|
|
108
107
|
return [image.path];
|
|
109
108
|
});
|
|
110
109
|
}
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
//
|
|
110
|
+
// One new call with that image's stored prompt text, replacing it in place at the same
|
|
111
|
+
// index. The piece carries that prompt and that index, so it is left alone and the images
|
|
112
|
+
// stage sees one image of its plan missing.
|
|
114
113
|
export function regenerateImage(deps, projectId, outputId) {
|
|
115
114
|
const loaded = load(deps, projectId);
|
|
116
115
|
if (!loaded.ok) {
|
|
@@ -132,9 +131,9 @@ function load(deps, projectId) {
|
|
|
132
131
|
return { ok: false, reason: "no-project" };
|
|
133
132
|
}
|
|
134
133
|
const stages = stagesOf(deps.db, projectId);
|
|
135
|
-
// The precondition every action
|
|
136
|
-
//
|
|
137
|
-
//
|
|
134
|
+
// The precondition every re-run action shares: no stage of the project is `running`. The
|
|
135
|
+
// page disables the controls; this is the other half, because clearing an output from
|
|
136
|
+
// under a stage that is writing it would lose both.
|
|
138
137
|
if (stages.some((stage) => stage.state === "running")) {
|
|
139
138
|
return { ok: false, reason: "running" };
|
|
140
139
|
}
|
|
@@ -145,15 +144,15 @@ function load(deps, projectId) {
|
|
|
145
144
|
thumbnailSource: project.config.sources.thumbnail,
|
|
146
145
|
};
|
|
147
146
|
}
|
|
148
|
-
// One transaction for every row the action touches, then the filesystem
|
|
149
|
-
//
|
|
150
|
-
//
|
|
147
|
+
// One transaction for every row the action touches, then the filesystem: an unlink cannot
|
|
148
|
+
// be rolled back, so it waits for the commit. A file with no row is what the boot reconcile
|
|
149
|
+
// collects, where a row with no file is a broken download.
|
|
151
150
|
function apply(deps, projectId, action, loaded, own) {
|
|
152
151
|
const plan = redoPlan({ action, stages: loaded.stages, thumbnailSource: loaded.thumbnailSource });
|
|
153
152
|
// `own` is the action's own change - the new article, the image that goes - and it runs
|
|
154
153
|
// first and inside the same transaction, so it lands or rolls back with the cascade it
|
|
155
|
-
// triggers. The files it writes take the names they already had,
|
|
156
|
-
//
|
|
154
|
+
// triggers. The files it writes take the names they already had, replacing in place
|
|
155
|
+
// rather than leaving work a rollback would have to undo.
|
|
157
156
|
const orphaned = transact(deps.db, () => {
|
|
158
157
|
const files = own === undefined ? [] : [...own()];
|
|
159
158
|
for (const redo of plan) {
|
|
@@ -172,7 +171,7 @@ function apply(deps, projectId, action, loaded, own) {
|
|
|
172
171
|
return { ok: true, redone: plan.map((redo) => redo.stage) };
|
|
173
172
|
}
|
|
174
173
|
// Everything the stage produced: its outputs, and the resumable pieces whose payloads name
|
|
175
|
-
// files of their own - the audio chunks
|
|
174
|
+
// files of their own - the audio chunks, which are not outputs.
|
|
176
175
|
function clearStage(deps, stage, outputs) {
|
|
177
176
|
const files = [];
|
|
178
177
|
for (const output of outputs) {
|
|
@@ -191,7 +190,7 @@ function clearStage(deps, stage, outputs) {
|
|
|
191
190
|
deletePieces(deps.db, stage.id);
|
|
192
191
|
return files;
|
|
193
192
|
}
|
|
194
|
-
//
|
|
193
|
+
// A regenerated image is replaced in place at the same index, so the piece keeps the prompt
|
|
195
194
|
// and the index it was planned with and only stops naming a file: `slices/images/run.ts`
|
|
196
195
|
// then sees one image of its plan still to make, and the file it named can be removed.
|
|
197
196
|
function forgetImageFile(deps, stages, path) {
|
|
@@ -252,9 +251,9 @@ function removeFiles(deps, projectId, orphaned) {
|
|
|
252
251
|
}
|
|
253
252
|
}
|
|
254
253
|
}
|
|
255
|
-
// `
|
|
256
|
-
//
|
|
257
|
-
//
|
|
254
|
+
// `done` → `running` is a re-run's own transition, and a stage that failed or was canceled may
|
|
255
|
+
// be started over rather than resumed. `provided` and `skipped` never run, and `pending` has
|
|
256
|
+
// nothing to redo.
|
|
258
257
|
function rerunnable(state) {
|
|
259
258
|
return state === "done" || state === "failed" || state === "canceled";
|
|
260
259
|
}
|
|
@@ -7,7 +7,7 @@ export function plannerMessages(brief) {
|
|
|
7
7
|
"",
|
|
8
8
|
briefText(brief),
|
|
9
9
|
"",
|
|
10
|
-
//
|
|
10
|
+
// The prompt's section guide decides the chapters when it has one, and the
|
|
11
11
|
// planner proposes them when it does not. There is no cap on the count.
|
|
12
12
|
"List the chapters to research. If the prompt sets out a section guide, take the",
|
|
13
13
|
"chapters from it, in the order it gives them. If it does not, propose the chapters",
|
|
@@ -21,8 +21,7 @@ export function plannerMessages(brief) {
|
|
|
21
21
|
}
|
|
22
22
|
// The answer is a list, however the model chose to punctuate it. Numbering and bullets
|
|
23
23
|
// are stripped because the instruction above asks for neither and models add them
|
|
24
|
-
// anyway; a repeated title is dropped because
|
|
25
|
-
// is researched twice.
|
|
24
|
+
// anyway; a repeated title is dropped because no chapter is researched twice.
|
|
26
25
|
export function chaptersFrom(text) {
|
|
27
26
|
const seen = new Set();
|
|
28
27
|
const chapters = [];
|
|
@@ -40,7 +39,7 @@ export function chaptersFrom(text) {
|
|
|
40
39
|
}
|
|
41
40
|
return chapters;
|
|
42
41
|
}
|
|
43
|
-
//
|
|
42
|
+
// One sub-agent per chapter, web-grounded, answering that chapter's notes
|
|
44
43
|
// and its sources. The whole outline travels with it so it covers its own chapter and
|
|
45
44
|
// leaves the neighbouring ones to the sub-agents researching them.
|
|
46
45
|
export function subAgentMessages(brief, chapter, outline) {
|
|
@@ -65,7 +64,7 @@ export function subAgentMessages(brief, chapter, outline) {
|
|
|
65
64
|
},
|
|
66
65
|
];
|
|
67
66
|
}
|
|
68
|
-
// The half of every instruction that is the same for all three calls
|
|
67
|
+
// The half of every instruction that is the same for all three calls: the
|
|
69
68
|
// rendered prompt the article will be written from, and the run's keyword values.
|
|
70
69
|
export function briefText(brief) {
|
|
71
70
|
const values = Object.entries(brief.values);
|
|
@@ -7,11 +7,11 @@ import { storeText } from "../storage/staging.js";
|
|
|
7
7
|
import { noTokens, plusUsage } from "../telemetry/model.js";
|
|
8
8
|
import { chaptersFrom, plannerMessages, subAgentMessages } from "./planner.js";
|
|
9
9
|
import { sourcedAnswer, synthesisMessages } from "./synthesis.js";
|
|
10
|
-
//
|
|
11
|
-
//
|
|
10
|
+
// A model that cannot ground on the web fails the stage immediately, with no fallback to
|
|
11
|
+
// what the model already knows.
|
|
12
12
|
export const webResearchUnsupported = "web research unsupported by this model";
|
|
13
13
|
// What a chapter piece carries between runs: its title, and its notes once a sub-agent
|
|
14
|
-
// has answered. That is the whole of the resume
|
|
14
|
+
// has answered. That is the whole of the resume.
|
|
15
15
|
const chapterPayload = z.object({ title: z.string(), notes: z.string().optional() });
|
|
16
16
|
export async function runResearch(deps, context, providers) {
|
|
17
17
|
const { projectId } = context.stage;
|
|
@@ -22,7 +22,7 @@ export async function runResearch(deps, context, providers) {
|
|
|
22
22
|
const choice = project.config.llm;
|
|
23
23
|
const articlePrompt = project.config.rendered.article;
|
|
24
24
|
if (choice === undefined || articlePrompt === undefined) {
|
|
25
|
-
// Admission refuses a run whose research is Generate without both
|
|
25
|
+
// Admission refuses a run whose research is Generate without both, so
|
|
26
26
|
// reaching here is a bug in admission rather than something the user did.
|
|
27
27
|
throw new Error("the run has no LLM provider or no rendered article prompt");
|
|
28
28
|
}
|
|
@@ -32,7 +32,7 @@ export async function runResearch(deps, context, providers) {
|
|
|
32
32
|
}
|
|
33
33
|
catch (error) {
|
|
34
34
|
// The adapter says the model cannot ground on the web and the wrapper has already
|
|
35
|
-
// made that terminal; this is where it becomes the sentence the user reads
|
|
35
|
+
// made that terminal; this is where it becomes the sentence the user reads.
|
|
36
36
|
if (isProviderError(error) && error.fault.kind === "unsupported") {
|
|
37
37
|
throw new Error(webResearchUnsupported);
|
|
38
38
|
}
|
|
@@ -40,10 +40,9 @@ export async function runResearch(deps, context, providers) {
|
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
async function research(deps, context, providers, choice, brief) {
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
// estimate (§Q131).
|
|
43
|
+
// Tokens are counted per stage, so the planner, every sub-agent and the synthesis add
|
|
44
|
+
// into one total. A call that was aborted or failed never answers and adds nothing; a
|
|
45
|
+
// provider that reports no usage adds zero, never an estimate.
|
|
47
46
|
let tokens = noTokens;
|
|
48
47
|
const add = (usage) => {
|
|
49
48
|
tokens = plusUsage(tokens, usage);
|
|
@@ -58,8 +57,8 @@ async function research(deps, context, providers, choice, brief) {
|
|
|
58
57
|
});
|
|
59
58
|
add(answer.usage);
|
|
60
59
|
const { projectId } = context.stage;
|
|
61
|
-
//
|
|
62
|
-
//
|
|
60
|
+
// The notes and every instruction sent are stored on the project. Each sub-agent's own output
|
|
61
|
+
// stays on its chapter row, which is where the resume reads it.
|
|
63
62
|
storeText(deps, { projectId, stageKind: "research", role: "notes", text: answer.text.trim() });
|
|
64
63
|
storeText(deps, {
|
|
65
64
|
projectId,
|
|
@@ -80,7 +79,7 @@ async function research(deps, context, providers, choice, brief) {
|
|
|
80
79
|
});
|
|
81
80
|
}
|
|
82
81
|
// The chapter list, planned once and kept. A retry after a failure finds the rows the
|
|
83
|
-
// first run wrote and does not ask again
|
|
82
|
+
// first run wrote and does not ask again.
|
|
84
83
|
async function plan(deps, context, providers, choice, brief, add) {
|
|
85
84
|
const existing = piecesOf(deps.db, context.stage.id, "chapter");
|
|
86
85
|
if (existing.length > 0) {
|
|
@@ -90,11 +89,11 @@ async function plan(deps, context, providers, choice, brief, add) {
|
|
|
90
89
|
provider: choice.provider,
|
|
91
90
|
model: choice.model,
|
|
92
91
|
messages: plannerMessages(brief),
|
|
93
|
-
//
|
|
92
|
+
// An empty answer, or one with no chapter in it, is a failed attempt.
|
|
94
93
|
check: (given) => chaptersFrom(given.text).length === 0 ? "the planner named no chapters" : undefined,
|
|
95
94
|
});
|
|
96
95
|
add(answer.usage);
|
|
97
|
-
//
|
|
96
|
+
// No cap on the count. The list is the prompt's own section guide as often as
|
|
98
97
|
// not, and capping it would silently drop a section the article asks for.
|
|
99
98
|
const planned = chaptersFrom(answer.text).map((title, index) => ({
|
|
100
99
|
id: deps.ids.next(),
|
|
@@ -111,9 +110,9 @@ async function plan(deps, context, providers, choice, brief, add) {
|
|
|
111
110
|
});
|
|
112
111
|
return planned;
|
|
113
112
|
}
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
113
|
+
// One sub-agent per chapter, all in parallel. A chapter a previous run finished is not
|
|
114
|
+
// researched again, and one that fails takes down the whole stage while its siblings finish and
|
|
115
|
+
// keep their output for the next resume.
|
|
117
116
|
async function researchChapters(deps, context, providers, choice, brief, chapters, add) {
|
|
118
117
|
const outline = chapters.map((piece) => payloadOf(piece).title);
|
|
119
118
|
const total = chapters.length;
|
|
@@ -130,7 +129,7 @@ async function researchChapters(deps, context, providers, choice, brief, chapter
|
|
|
130
129
|
provider: choice.provider,
|
|
131
130
|
model: choice.model,
|
|
132
131
|
messages: subAgentMessages(brief, kept.title, outline),
|
|
133
|
-
//
|
|
132
|
+
// Grounding is asked for explicitly, so a model without it says so
|
|
134
133
|
// instead of answering from what it already knows.
|
|
135
134
|
webSearch: true,
|
|
136
135
|
check: (given) => sourcedAnswer(`the researcher on "${kept.title}"`, given.text),
|
|
@@ -143,8 +142,8 @@ async function researchChapters(deps, context, providers, choice, brief, chapter
|
|
|
143
142
|
return { ok: true, finding: { title: kept.title, notes } };
|
|
144
143
|
}
|
|
145
144
|
catch (error) {
|
|
146
|
-
// A cancel is not this chapter failing:
|
|
147
|
-
//
|
|
145
|
+
// A cancel is not this chapter failing: an aborted call counts as nothing, and
|
|
146
|
+
// the resume runs a `pending` chapter exactly as a failed one.
|
|
148
147
|
setPiece(deps.db, piece.id, context.signal.aborted ? "pending" : "failed", piece.payload);
|
|
149
148
|
return { ok: false, error };
|
|
150
149
|
}
|
|
@@ -158,8 +157,8 @@ async function researchChapters(deps, context, providers, choice, brief, chapter
|
|
|
158
157
|
}
|
|
159
158
|
return findings;
|
|
160
159
|
}
|
|
161
|
-
//
|
|
162
|
-
//
|
|
160
|
+
// K of N chapters researched, written as well as emitted, so a page opened mid-stage reads the
|
|
161
|
+
// count off the row rather than waiting for the next chapter.
|
|
163
162
|
function report(deps, context, done, total) {
|
|
164
163
|
setStageProgress(deps.db, context.stage.id, done, total);
|
|
165
164
|
context.emit({
|
|
@@ -170,7 +169,7 @@ function report(deps, context, done, total) {
|
|
|
170
169
|
total,
|
|
171
170
|
});
|
|
172
171
|
}
|
|
173
|
-
//
|
|
172
|
+
// Every instruction sent is stored on the project. All three are pure functions of
|
|
174
173
|
// the brief and the findings, so a resumed run reproduces the ones it did not send.
|
|
175
174
|
function instructionsText(brief, findings) {
|
|
176
175
|
const outline = findings.map((finding) => finding.title);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { briefText } from "./planner.js";
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
// An editorial pass over every sub-agent's output that selects and organizes the findings; it
|
|
3
|
+
// does not concatenate. The sub-agents' notes are the material, not the answer, and the
|
|
4
|
+
// synthesising model is told so.
|
|
5
5
|
export function synthesisMessages(brief, findings) {
|
|
6
6
|
return [
|
|
7
7
|
{
|
|
@@ -26,7 +26,7 @@ export function synthesisMessages(brief, findings) {
|
|
|
26
26
|
},
|
|
27
27
|
];
|
|
28
28
|
}
|
|
29
|
-
//
|
|
29
|
+
// The stored notes always end with a Sources list, and an answer without
|
|
30
30
|
// one "counts as a failed attempt". A heading with nothing under it is not a list, so
|
|
31
31
|
// the line has to be followed by something.
|
|
32
32
|
const heading = /^\s*(?:#{1,6}\s*)?(?:\*\*)?\s*sources(?:\s+(?:list|consulted))?\s*(?:\*\*)?\s*:?\s*$/i;
|
|
@@ -36,7 +36,7 @@ export function endsWithSources(text) {
|
|
|
36
36
|
return at !== -1 && lines.slice(at + 1).some((line) => line.trim() !== "");
|
|
37
37
|
}
|
|
38
38
|
// The one shape a stage hands `StageProviders.llm` as its `check`: an answer that arrived
|
|
39
|
-
// but cannot be used is a failed attempt, so the wrapper retries it
|
|
39
|
+
// but cannot be used is a failed attempt, so the wrapper retries it.
|
|
40
40
|
export function sourcedAnswer(who, text) {
|
|
41
41
|
if (text.trim() === "") {
|
|
42
42
|
return `${who} answered with nothing`;
|
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
-
// ceiling:
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
2
|
+
// ceiling: readiness is computed at request time, so nothing is cached and a hung CLI costs the
|
|
3
|
+
// settings page this long. Both probes run in parallel, so the page waits 2 s in the worst
|
|
4
|
+
// case. Caching the answer for a few seconds is the upgrade if a slow machine makes the status
|
|
5
|
+
// line flap.
|
|
6
6
|
export const cliProbeTimeoutMs = 2000;
|
|
7
7
|
const probeOutputMax = 64 * 1024;
|
|
8
|
-
// The real probe: an argument array, never a shell string, so nothing in a binary name
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// is supported.
|
|
8
|
+
// The real probe: an argument array, never a shell string, so nothing in a binary name or an
|
|
9
|
+
// argument can be interpreted as a command. It resolves for every outcome; a missing CLI is an
|
|
10
|
+
// answer, not a failure. ceiling: POSIX only. A Windows install puts `claude` on PATH as a
|
|
11
|
+
// `.cmd` shim, which execFile cannot run without a shell; a shim-aware lookup is the upgrade
|
|
12
|
+
// when Windows is supported.
|
|
14
13
|
export function nodeCliProbe(binary, args, timeoutMs) {
|
|
15
14
|
return new Promise((resolve) => {
|
|
16
15
|
execFile(binary, [...args], { timeout: timeoutMs, maxBuffer: probeOutputMax, windowsHide: true }, (error, stdout) => {
|
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
import { providerById } from "./model.js";
|
|
2
2
|
import { deleteKey, hasKey, keyOf, upsertKey } from "./repo.js";
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// that reports a stored key reports this string and nothing else about the value.
|
|
3
|
+
// The Settings field shows a stored key masked, with nothing legible in it, and a key never
|
|
4
|
+
// appears outside a provider call. So the mask is a constant: it carries no character of
|
|
5
|
+
// the key and not even its length. Every response that reports a stored key reports this
|
|
6
|
+
// string and nothing else about the value.
|
|
8
7
|
export const keyMask = "••••••••••••";
|
|
9
8
|
export function keyStatus(deps, provider) {
|
|
10
9
|
const stored = hasKey(deps.db, provider);
|
|
11
10
|
return { provider, hasKey: stored, masked: stored ? keyMask : null };
|
|
12
11
|
}
|
|
13
|
-
//
|
|
14
|
-
// check and no test call
|
|
12
|
+
// Trimmed, stored as given, overwriting any previous one. No format
|
|
13
|
+
// check and no test call.
|
|
15
14
|
export function saveProviderKey(deps, provider, key) {
|
|
16
15
|
if (providerById(provider).auth === "cli") {
|
|
17
16
|
return { ok: false, reason: "cli-provider" };
|
|
@@ -37,9 +36,9 @@ export function removeProviderKey(deps, provider) {
|
|
|
37
36
|
}
|
|
38
37
|
return deleteKey(deps.db, provider) ? { ok: true } : { ok: false, reason: "absent" };
|
|
39
38
|
}
|
|
40
|
-
//
|
|
39
|
+
// The key is read at the moment an attempt starts. This reads the row
|
|
41
40
|
// every time and hands back a plain string, so an attempt holds the value it started
|
|
42
|
-
// with and a save or a remove landing mid-run reaches the next attempt only
|
|
41
|
+
// with and a save or a remove landing mid-run reaches the next attempt only.
|
|
43
42
|
export function keyForAttempt(deps, provider) {
|
|
44
43
|
if (providerById(provider).auth === "cli") {
|
|
45
44
|
return { ok: false, reason: "cli-provider" };
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
// The provider catalogue and the settings domain types
|
|
1
|
+
// The provider catalogue and the settings domain types.
|
|
2
2
|
// The three families are the three ports, so the set is named beside them.
|
|
3
3
|
export { providerFamilies } from "../../kernel/ports/model.js";
|
|
4
|
-
// The supported set
|
|
5
|
-
//
|
|
6
|
-
//
|
|
4
|
+
// The supported set. OpenAI ships two adapters with two ids, one per family:
|
|
5
|
+
// `provider_keys.provider` is the primary key, and a row per family is what lets a user key TTS
|
|
6
|
+
// without keying image generation.
|
|
7
7
|
export const providerIds = [
|
|
8
8
|
"openrouter",
|
|
9
9
|
"claude-code",
|
|
@@ -49,6 +49,6 @@ export function providerById(id) {
|
|
|
49
49
|
}
|
|
50
50
|
return found;
|
|
51
51
|
}
|
|
52
|
-
//
|
|
52
|
+
// The theme override the Appearance control writes.
|
|
53
53
|
export const appearances = ["system", "light", "dark"];
|
|
54
54
|
export const defaultSettings = { silenceGapSeconds: 3, appearance: "system" };
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { cliReadiness } from "./cli-status.js";
|
|
2
2
|
import { providers } from "./model.js";
|
|
3
3
|
import { keyedProviders } from "./repo.js";
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// computed per request and nothing about a CLI is stored.
|
|
4
|
+
// Every supported provider is listed, keyed or not, found or not, so Play can grey one out with
|
|
5
|
+
// a reason instead of hiding it. A keyed provider is ready when a key is stored; a CLI provider
|
|
6
|
+
// when its binary answers. Readiness is computed per request and nothing about a CLI is stored.
|
|
8
7
|
export async function providerStatuses(deps) {
|
|
9
8
|
const keyed = keyedProviders(deps.db);
|
|
10
9
|
return await Promise.all(providers.map(async (provider) => ({
|
|
@@ -20,7 +20,7 @@ export function deleteKey(db, provider) {
|
|
|
20
20
|
return Number(result.changes) > 0;
|
|
21
21
|
}
|
|
22
22
|
// The one place a key value leaves the database. Every caller reads it for a single
|
|
23
|
-
// provider call and holds it no longer
|
|
23
|
+
// provider call and holds it no longer. Nothing under `edge/` calls
|
|
24
24
|
// this: a route asks the two functions below, which never select the `key` column.
|
|
25
25
|
export function keyOf(db, provider) {
|
|
26
26
|
const row = db.prepare("SELECT key FROM provider_keys WHERE provider = ?").get(provider);
|
|
@@ -3,10 +3,9 @@ import { providerById } from "./model.js";
|
|
|
3
3
|
import { deleteVoice, insertVoice, listVoices } from "./repo.js";
|
|
4
4
|
export const voiceNameMax = 200;
|
|
5
5
|
export const voiceIdMax = 200;
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// here either.
|
|
6
|
+
// A non-empty name and a non-empty voice ID, the ID unique within its provider, names free to
|
|
7
|
+
// repeat. Nothing is verified against the provider - a wrong ID is discovered when the audio
|
|
8
|
+
// stage uses it, which is why a key is not required here either.
|
|
10
9
|
export function addVoice(deps, draft) {
|
|
11
10
|
if (providerById(draft.provider).family !== "tts") {
|
|
12
11
|
return { ok: false, reason: "not-a-tts-provider" };
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
// draws, and `downloads.ts` reads the disk: importing it into the SPA would drag
|
|
4
4
|
// `node:fs` and the zip encoder into the browser bundle. Nothing here touches IO.
|
|
5
5
|
// An output's role is its asset name; images add their place in the slideshow, and the
|
|
6
|
-
// instructions add their stage, those being the two roles a project holds more than one of
|
|
7
|
-
//
|
|
6
|
+
// instructions add their stage, those being the two roles a project holds more than one of -
|
|
7
|
+
// research and the article each store what they sent.
|
|
8
8
|
export function assetOf(output) {
|
|
9
9
|
const asset = output.role.replaceAll("_", "-");
|
|
10
10
|
if (output.role === "instructions") {
|
|
@@ -9,9 +9,8 @@ export function deleteProject(deps, projectId) {
|
|
|
9
9
|
if (derive(stagesOf(deps.db, projectId)) === "running") {
|
|
10
10
|
return { ok: false, reason: "running" };
|
|
11
11
|
}
|
|
12
|
-
// Files first, rows second. A folder that would not go leaves the project listed
|
|
13
|
-
//
|
|
14
|
-
// orphaned under a project nothing names.
|
|
12
|
+
// Files first, rows second. A folder that would not go leaves the project listed; rows
|
|
13
|
+
// removed first would leave the files orphaned under a project nothing names.
|
|
15
14
|
const dir = projectDir(deps.paths, projectId);
|
|
16
15
|
try {
|
|
17
16
|
rmSync(dir, { recursive: true, force: true });
|
|
@@ -25,8 +24,7 @@ export function deleteProject(deps, projectId) {
|
|
|
25
24
|
}
|
|
26
25
|
// Stages, attempts, pieces and outputs go with it: every one of those tables declares
|
|
27
26
|
// ON DELETE CASCADE on the project, and the connection runs with foreign keys on
|
|
28
|
-
// (kernel/db/index.ts).
|
|
29
|
-
// files and no rows.
|
|
27
|
+
// (kernel/db/index.ts). A deleted project leaves no files and no rows.
|
|
30
28
|
deps.db.prepare("DELETE FROM projects WHERE id = ?").run(projectId);
|
|
31
29
|
return { ok: true };
|
|
32
30
|
}
|
|
@@ -36,7 +36,7 @@ export function slugOf(title) {
|
|
|
36
36
|
// `assetOf` moved to asset-name.ts so the SPA can build the same URLs; it is still part
|
|
37
37
|
// of this module's surface, because a download name is built from it.
|
|
38
38
|
export { assetOf };
|
|
39
|
-
//
|
|
39
|
+
// "single files as `<title-slug>-<asset>.<ext>`".
|
|
40
40
|
export function downloadName(slug, output) {
|
|
41
41
|
return `${slug}-${assetOf(output)}${extname(output.path)}`;
|
|
42
42
|
}
|
|
@@ -64,7 +64,7 @@ export function findDownload(deps, projectId, asset) {
|
|
|
64
64
|
},
|
|
65
65
|
};
|
|
66
66
|
}
|
|
67
|
-
//
|
|
67
|
+
// "download all" images as `<title-slug>-images.zip`, thumbnail included.
|
|
68
68
|
export function imagesZip(deps, projectId) {
|
|
69
69
|
const title = projectTitle(deps.db, projectId);
|
|
70
70
|
if (title === undefined) {
|
|
@@ -78,11 +78,11 @@ export function imagesZip(deps, projectId) {
|
|
|
78
78
|
}
|
|
79
79
|
const path = outputPath(deps.paths, projectId, output.path);
|
|
80
80
|
if (sizeOf(path) === undefined) {
|
|
81
|
-
// A missing file is the project page's problem
|
|
81
|
+
// A missing file is the project page's problem; the rest still zips.
|
|
82
82
|
continue;
|
|
83
83
|
}
|
|
84
84
|
// Images are already compressed formats, so deflating them costs time and saves
|
|
85
|
-
// nothing. ceiling: 60 images
|
|
85
|
+
// nothing. ceiling: 60 images are read into memory at once; a set
|
|
86
86
|
// large enough to hurt would move to fflate's streaming Zip.
|
|
87
87
|
entries[`${slug}-${assetOf(output)}${extname(output.path)}`] = [
|
|
88
88
|
readFileSync(path),
|