@gentbajko/slopify 3.17.0 → 3.18.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/dist/edge/http/actions.js +2 -2
- package/dist/edge/http/actor.js +10 -0
- package/dist/edge/http/app.js +21 -0
- package/dist/edge/http/checkpoints.js +14 -6
- package/dist/edge/http/external-reviews.js +95 -0
- package/dist/edge/http/projects.js +36 -10
- package/dist/edge/http/reviews.js +53 -18
- package/dist/edge/http/revisions.js +5 -1
- package/dist/edge/http/settings.js +9 -1
- package/dist/edge/http/starts.js +41 -0
- package/dist/edge/mcp/attribution.js +38 -0
- package/dist/edge/mcp/call-route.js +203 -0
- package/dist/edge/mcp/excluded-routes.js +95 -0
- package/dist/edge/mcp/file-tools.js +141 -0
- package/dist/edge/mcp/http.js +79 -0
- package/dist/edge/mcp/instructions.js +17 -0
- package/dist/edge/mcp/library-tools.js +143 -0
- package/dist/edge/mcp/remake-tools.js +90 -0
- package/dist/edge/mcp/review-tools.js +64 -0
- package/dist/edge/mcp/route-tool.js +61 -0
- package/dist/edge/mcp/server.js +48 -0
- package/dist/edge/mcp/state-shapes.js +60 -0
- package/dist/edge/mcp/tools-channels.js +153 -0
- package/dist/edge/mcp/tools-library.js +151 -0
- package/dist/edge/mcp/tools-planning.js +137 -0
- package/dist/edge/mcp/tools-projects.js +167 -0
- package/dist/edge/mcp/tools-publishing.js +148 -0
- package/dist/edge/mcp/tools-runs.js +94 -0
- package/dist/edge/mcp/tools-schedules.js +143 -0
- package/dist/edge/mcp/tools-setup.js +234 -0
- package/dist/edge/mcp/tools-storage.js +99 -0
- package/dist/edge/mcp/tools.js +21 -0
- package/dist/extension/slopify-studio-chrome.zip +0 -0
- package/dist/extension/slopify-studio-firefox.zip +0 -0
- package/dist/kernel/db/migrations/0055-external-reviews.sql +56 -0
- package/dist/kernel/db/migrations/0056-agent-actions.sql +20 -0
- package/dist/kernel/db/migrations/0057-agent-action-once.sql +5 -0
- package/dist/kernel/db/migrations/0058-revision-work-admission.sql +3 -0
- package/dist/kernel/db/migrations/0059-external-review-canceled.sql +29 -0
- package/dist/kernel/db/migrations/0060-reservation-work.sql +4 -0
- package/dist/kernel/db/migrations/0061-project-control-counts.sql +4 -0
- package/dist/kernel/db/migrations/0062-told-failures.sql +8 -0
- package/dist/kernel/pipeline.js +6 -0
- package/dist/kernel/runner/graph.js +37 -2
- package/dist/kernel/runner/index.js +6 -3
- package/dist/main.js +29 -13
- package/dist/patch-notes/3.18.0.md +13 -0
- package/dist/patch-notes/index.json +6 -0
- package/dist/slices/admission/admitted-work.js +53 -0
- package/dist/slices/admission/heads.js +5 -0
- package/dist/slices/admission/repo.js +47 -29
- package/dist/slices/admission/schema.js +1 -0
- package/dist/slices/admission/stage-writes.js +21 -0
- package/dist/slices/admission/start.js +2 -1
- package/dist/slices/admission/state-words.js +110 -0
- package/dist/slices/agents/attention-work.js +233 -0
- package/dist/slices/agents/attention.js +168 -0
- package/dist/slices/agents/connection.js +15 -0
- package/dist/slices/agents/model.js +29 -0
- package/dist/slices/agents/repo.js +66 -0
- package/dist/slices/cancel/index.js +4 -1
- package/dist/slices/control/index.js +2 -1
- package/dist/slices/episodes/summarize.js +2 -1
- package/dist/slices/estimate/index.js +6 -2
- package/dist/slices/images/run.js +2 -1
- package/dist/slices/library/transfer.js +69 -0
- package/dist/slices/narration/run.js +2 -1
- package/dist/slices/notifications/address.js +40 -0
- package/dist/slices/notifications/notifier.js +43 -10
- package/dist/slices/notifications/rules.js +111 -45
- package/dist/slices/notifications/server.js +14 -0
- package/dist/slices/notifications/settings.js +1 -1
- package/dist/slices/notifications/subject.js +30 -0
- package/dist/slices/notifications/told.js +33 -0
- package/dist/slices/play-drafts/readiness.js +9 -2
- package/dist/slices/play-drafts/schema.js +2 -1
- package/dist/slices/rebuild/narration-words.js +76 -0
- package/dist/slices/rebuild/recipe-model.js +1 -0
- package/dist/slices/rebuild/recipe-reviews.js +44 -23
- package/dist/slices/rebuild/recovery-repo.js +0 -20
- package/dist/slices/rebuild/recovery-selection.js +14 -3
- package/dist/slices/rebuild/recovery.js +5 -2
- package/dist/slices/rebuild/remake-outdated.js +27 -0
- package/dist/slices/rebuild/repo.js +7 -2
- package/dist/slices/rebuild/review-redo.js +12 -0
- package/dist/slices/rebuild/runtime-agent-review.js +121 -0
- package/dist/slices/rebuild/runtime-export-inputs.js +26 -11
- package/dist/slices/rebuild/runtime-review.js +12 -2
- package/dist/slices/rebuild/runtime-run.js +11 -2
- package/dist/slices/rebuild/runtime-store.js +32 -5
- package/dist/slices/reruns/index.js +2 -1
- package/dist/slices/research/run.js +2 -1
- package/dist/slices/reviews/external-model.js +21 -0
- package/dist/slices/reviews/external-release.js +38 -0
- package/dist/slices/reviews/external-repo.js +109 -0
- package/dist/slices/reviews/external-rows.js +68 -0
- package/dist/slices/reviews/external-waiting.js +75 -0
- package/dist/slices/reviews/holds.js +144 -0
- package/dist/slices/reviews/model.js +14 -0
- package/dist/slices/reviews/repo.js +27 -10
- package/dist/slices/reviews/rules.js +5 -3
- package/dist/slices/reviews/verdict.js +18 -8
- package/dist/slices/revisions/repo.js +3 -0
- package/dist/slices/revisions/schema.js +1 -0
- package/dist/slices/schedules/agenda.js +47 -17
- package/dist/slices/schedules/prepare.js +6 -0
- package/dist/slices/schedules/schema.js +13 -1
- package/dist/slices/storage/consequences.js +27 -0
- package/dist/slices/storage/portable.js +3 -0
- package/dist/slices/storage/project-files.js +66 -0
- package/dist/slices/subtitles/prepare.js +2 -1
- package/dist/slices/uploads/repo.js +12 -6
- package/dist/slices/uploads/rules.js +5 -0
- package/dist/slices/video/write-export.js +1 -1
- package/dist/slices/video/write-subtitles.js +1 -1
- package/dist/tutorials/Agents-MCP.md +168 -0
- package/dist/tutorials/Calendar.md +2 -1
- package/dist/tutorials/First-Launch-and-Welcome.md +1 -0
- package/dist/tutorials/Home-and-Projects.md +16 -9
- package/dist/tutorials/Home.md +1 -0
- package/dist/tutorials/Notifications.md +25 -6
- package/dist/tutorials/Project-Page.md +8 -2
- package/dist/tutorials/Publishing-to-YouTube.md +1 -0
- package/dist/tutorials/Recovery-and-Retries.md +17 -3
- package/dist/tutorials/Reviews-and-Checkpoints.md +22 -5
- package/dist/tutorials/Schedules.md +1 -1
- package/dist/tutorials/Settings-Reference.md +15 -0
- package/dist/tutorials/Troubleshooting.md +3 -1
- package/dist/tutorials/Your-First-Short.md +1 -0
- package/dist/tutorials/_Sidebar.md +1 -0
- package/dist/web/assets/{index-DpdXRssU.css → index-BKbZIxAq.css} +1 -1
- package/dist/web/assets/index-Drth_MfQ.js +325 -0
- package/dist/web/assets/{pdf-B42WW32R.js → pdf-CQFUyijy.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-CbmBh8V-.js +0 -325
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { causedBy } from "../../kernel/errors.js";
|
|
2
|
+
import { narrationAudio, narrationTimeline, speaksPart, } from "./runtime-export-inputs.js";
|
|
3
|
+
import { wordsSchema } from "./runtime-subtitles.js";
|
|
4
|
+
// The narration's parts on the timing's clock. A body with no intro or outro starts at a known
|
|
5
|
+
// place, after the edge silence: its length only clips the last words, so one that can't be
|
|
6
|
+
// measured (an upload too long for the measuring timeout) still gives its words, unclipped.
|
|
7
|
+
async function timelineOf(deps, view) {
|
|
8
|
+
const config = view.revision.config;
|
|
9
|
+
let unmeasured;
|
|
10
|
+
const measure = async (path) => {
|
|
11
|
+
try {
|
|
12
|
+
return await deps.measure(path);
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
unmeasured = error;
|
|
16
|
+
throw error;
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
try {
|
|
20
|
+
return { timeline: await narrationAudio({ ...deps, measure }, view) };
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
const alone = !speaksPart(config, "intro") && !speaksPart(config, "outro");
|
|
24
|
+
if (!alone || unmeasured === undefined)
|
|
25
|
+
throw error;
|
|
26
|
+
const body = { path: "", seconds: Number.POSITIVE_INFINITY };
|
|
27
|
+
return {
|
|
28
|
+
timeline: narrationTimeline(config, { body, intro: undefined, outro: undefined }),
|
|
29
|
+
unclipped: causedBy(unmeasured),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
// The word timing of the body narration file alone. The subtitles stage timed the words on the
|
|
34
|
+
// export's clock (`narrationAudio`); the body file starts at zero, so its words move back by
|
|
35
|
+
// where the body starts, and the intro's and outro's are left out. Refused, with why, when the
|
|
36
|
+
// timing can't be read or the narration can't be timed.
|
|
37
|
+
export async function bodyWordTimings(deps, view, words) {
|
|
38
|
+
try {
|
|
39
|
+
const timing = wordsSchema.parse(JSON.parse(words));
|
|
40
|
+
const { timeline, unclipped } = await timelineOf(deps, view);
|
|
41
|
+
// The words were timed with every part the settings speak; without one, the body's place
|
|
42
|
+
// on that clock is unknown.
|
|
43
|
+
for (const part of ["intro", "outro"])
|
|
44
|
+
if (speaksPart(view.revision.config, part) &&
|
|
45
|
+
!timeline.some((segment) => segment.kind === part))
|
|
46
|
+
return {
|
|
47
|
+
ok: false,
|
|
48
|
+
reason: `the narration's ${part} has no finished audio, so where the body starts on the timing's clock is unknown`,
|
|
49
|
+
};
|
|
50
|
+
const before = timeline.findIndex((segment) => segment.kind === "body");
|
|
51
|
+
const start = timeline.slice(0, before).reduce((sum, segment) => sum + segment.seconds, 0);
|
|
52
|
+
const end = start + (timeline[before]?.seconds ?? 0);
|
|
53
|
+
// Times are rounded to the millisecond, so a body word can sit a hair before the body.
|
|
54
|
+
const inBody = (at) => at >= start - 0.001 && at < end;
|
|
55
|
+
return {
|
|
56
|
+
ok: true,
|
|
57
|
+
...(unclipped === undefined ? {} : { unclipped }),
|
|
58
|
+
value: JSON.stringify({
|
|
59
|
+
...timing,
|
|
60
|
+
words: timing.words
|
|
61
|
+
.filter((word) => inBody(word.start))
|
|
62
|
+
.map((word) => ({
|
|
63
|
+
...word,
|
|
64
|
+
start: Math.max(0, word.start - start),
|
|
65
|
+
end: Math.min(word.end, end) - start,
|
|
66
|
+
})),
|
|
67
|
+
omissions: timing.omissions
|
|
68
|
+
.filter((omission) => inBody(omission.start))
|
|
69
|
+
.map((omission) => ({ ...omission, start: Math.max(0, omission.start - start) })),
|
|
70
|
+
}),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
return { ok: false, reason: causedBy(error) };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { narrationItemKey, reviewKey, reviewModeOf, reviewPromptKey, reviewRetriesOf, } from "../reviews/model.js";
|
|
1
|
+
import { narrationItemKey, reviewedByAgent, reviewKey, reviewModeOf, reviewPromptKey, reviewRetriesOf, } from "../reviews/model.js";
|
|
2
2
|
import { activeReviewStages } from "../reviews/rules.js";
|
|
3
3
|
import { recipe, resourceIdentity, } from "./recipe-model.js";
|
|
4
4
|
import { renderedPrompt } from "./recipe-text.js";
|
|
@@ -14,28 +14,49 @@ export function withReviews(context, recipes) {
|
|
|
14
14
|
return recipes;
|
|
15
15
|
const byKey = new Map(recipes.map((row) => [row.key, row]));
|
|
16
16
|
const items = stages.flatMap((stage) => reviewedItems(stage, recipes, byKey));
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
item.
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
:
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
17
|
+
const byAgent = reviewedByAgent(settings);
|
|
18
|
+
const reviews = items.map((item) => {
|
|
19
|
+
const inputs = item.inputs.map((input) => [input.key, resourceIdentity(context, input)]);
|
|
20
|
+
// Blank means the stage's built-in prompt; the step fills it in, so a new built-in
|
|
21
|
+
// wording ships as a new operation version rather than a silent change here.
|
|
22
|
+
const prompt = settings.stages[item.stage]?.prompt?.trim()
|
|
23
|
+
? renderedPrompt(context, reviewPromptKey(item.stage))
|
|
24
|
+
: "";
|
|
25
|
+
const dependsOn = item.inputs.map((input) => input.key);
|
|
26
|
+
// An external agent answers through MCP: no provider, no charge, nothing to be ready.
|
|
27
|
+
if (byAgent)
|
|
28
|
+
return recipe(context, reviewKey(item.itemKey), stageOf(item.stage), {
|
|
29
|
+
kind: "local",
|
|
30
|
+
version: 1,
|
|
31
|
+
operation: "review-agent-v1",
|
|
32
|
+
values: [
|
|
33
|
+
item.stage,
|
|
34
|
+
item.itemKey,
|
|
35
|
+
inputs,
|
|
36
|
+
prompt,
|
|
37
|
+
reviewModeOf(settings, item.stage),
|
|
38
|
+
reviewRetriesOf(settings),
|
|
39
|
+
],
|
|
40
|
+
}, dependsOn);
|
|
41
|
+
return recipe(context, reviewKey(item.itemKey), stageOf(item.stage), {
|
|
42
|
+
kind: "local",
|
|
43
|
+
version: 1,
|
|
44
|
+
operation: "review-v1",
|
|
45
|
+
values: [
|
|
46
|
+
item.stage,
|
|
47
|
+
item.itemKey,
|
|
48
|
+
inputs,
|
|
49
|
+
settings.provider,
|
|
50
|
+
settings.model,
|
|
51
|
+
settings.thinking ?? null,
|
|
52
|
+
prompt,
|
|
53
|
+
reviewModeOf(settings, item.stage),
|
|
54
|
+
reviewRetriesOf(settings),
|
|
55
|
+
],
|
|
56
|
+
}, dependsOn,
|
|
57
|
+
// A paid LLM call: readiness, the charge warning and the estimate treat it like one.
|
|
58
|
+
{ kind: "provider" });
|
|
59
|
+
});
|
|
39
60
|
const gated = recipes.map((row) => {
|
|
40
61
|
const waits = items.flatMap((item, index) => {
|
|
41
62
|
const review = reviews[index];
|
|
@@ -83,23 +83,3 @@ function forget(deps, projectId, input) {
|
|
|
83
83
|
.prepare("DELETE FROM project_recovery_requests WHERE project_id=? AND idempotency_key=?")
|
|
84
84
|
.run(projectId, input.idempotencyKey);
|
|
85
85
|
}
|
|
86
|
-
// The head has unfinished stages but nothing that will move them on its own: no call in
|
|
87
|
-
// flight and no admitted, dispatchable work. A saved rerun or edit is admitted by no one
|
|
88
|
-
// until Resume, and work held by a pause, a restart or a failed sibling of its admission
|
|
89
|
-
// never starts, yet none of these shows as paused or failed. A queued batch item waits for
|
|
90
|
-
// its turn instead.
|
|
91
|
-
export function resumable(db, projectId) {
|
|
92
|
-
return (db
|
|
93
|
-
.prepare("SELECT 1 FROM project_heads h WHERE h.project_id=? " +
|
|
94
|
-
"AND EXISTS(SELECT 1 FROM stages s WHERE s.project_id=h.project_id " +
|
|
95
|
-
"AND s.state NOT IN ('done','provided','skipped')) " +
|
|
96
|
-
"AND NOT EXISTS(SELECT 1 FROM stages s WHERE s.project_id=h.project_id AND s.state='running') " +
|
|
97
|
-
"AND NOT EXISTS(SELECT 1 FROM project_queue q WHERE q.project_id=h.project_id AND q.state='queued') " +
|
|
98
|
-
"AND NOT EXISTS(SELECT 1 FROM revision_work w WHERE w.project_id=h.project_id AND w.state='running') " +
|
|
99
|
-
"AND NOT EXISTS(SELECT 1 FROM revision_work w " +
|
|
100
|
-
"JOIN revision_work_reservations r ON r.work_id=w.id AND r.revision_id=h.revision_id " +
|
|
101
|
-
"WHERE w.state='pending' AND w.dispatch_state='allowed' AND NOT EXISTS(" +
|
|
102
|
-
"SELECT 1 FROM revision_work f WHERE f.admission_id=w.admission_id " +
|
|
103
|
-
"AND f.state IN ('failed','canceled')))")
|
|
104
|
-
.get(projectId) !== undefined);
|
|
105
|
-
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readmitted } from "../../kernel/runner/graph.js";
|
|
1
2
|
import { narrationRegenerationKey } from "../narration/plan.js";
|
|
2
3
|
export function sectionRoots(view, plan, stage) {
|
|
3
4
|
if (stage === "article" &&
|
|
@@ -69,15 +70,25 @@ export function dependentClosure(recipes, roots) {
|
|
|
69
70
|
return recipes.filter((row) => selected.has(row.key)).map((row) => row.key);
|
|
70
71
|
}
|
|
71
72
|
export function recoverySelection(plan, stage) {
|
|
72
|
-
|
|
73
|
+
return { kind: "selected", workKeys: stageWork(plan, stage) };
|
|
74
|
+
}
|
|
75
|
+
// Continue the run (or Resume): everything left to make, except after a Cancel, when only the
|
|
76
|
+
// stages the person took up again (`readmitted`: Try again, or Continue on the canceled run) go
|
|
77
|
+
// on, each as Try again would; the stages they canceled stay canceled.
|
|
78
|
+
export function resumeSelection(plan, stages) {
|
|
79
|
+
const taken = stages.filter((stage) => readmitted(stage, stages));
|
|
80
|
+
if (taken.length === 0)
|
|
73
81
|
return { kind: "allAffected" };
|
|
82
|
+
return { kind: "selected", workKeys: taken.flatMap((stage) => stageWork(plan, stage.kind)) };
|
|
83
|
+
}
|
|
84
|
+
// A stage's work left to make, or all of it when every piece would be reused.
|
|
85
|
+
function stageWork(plan, stage) {
|
|
74
86
|
const roots = plan.work
|
|
75
87
|
.filter((row) => row.stage === stage && row.disposition !== "reuse")
|
|
76
88
|
.map((row) => row.key);
|
|
77
|
-
|
|
89
|
+
return roots.length === 0
|
|
78
90
|
? plan.work.filter((row) => row.stage === stage).map((row) => row.key)
|
|
79
91
|
: roots;
|
|
80
|
-
return { kind: "selected", workKeys: unfinished };
|
|
81
92
|
}
|
|
82
93
|
export function regenerationEdit(view, plan, stage) {
|
|
83
94
|
const roots = sectionRoots(view, plan, stage);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { transact } from "../../kernel/db/tx.js";
|
|
2
|
+
import { stagesOf } from "../admission/repo.js";
|
|
2
3
|
import { withProjectControl } from "../control/lock.js";
|
|
3
4
|
import { requestHash } from "../revisions/mutation-request.js";
|
|
4
5
|
import { saveRevision } from "../revisions/mutations.js";
|
|
@@ -10,7 +11,7 @@ import { recipeProviderChoice } from "./recipe-provider-choice.js";
|
|
|
10
11
|
import { acceptedJobMessage, activeConflict, conflictRefusal } from "./recovery-conflict.js";
|
|
11
12
|
import { recoveryRequestSchema, } from "./recovery-model.js";
|
|
12
13
|
import { readRecovery, recoveryAuthority, recoveryKey, rememberRecovery, reserveRecovery, } from "./recovery-repo.js";
|
|
13
|
-
import { dependentClosure, recoverySelection, redoTarget, regenerationEdit, sectionRoots, } from "./recovery-selection.js";
|
|
14
|
+
import { dependentClosure, recoverySelection, redoTarget, regenerationEdit, resumeSelection, sectionRoots, } from "./recovery-selection.js";
|
|
14
15
|
import { previewById, storePreview } from "./repo.js";
|
|
15
16
|
import { admitCheckedPreview, wakeRebuild } from "./service.js";
|
|
16
17
|
import { checkReadiness } from "./service-readiness.js";
|
|
@@ -142,7 +143,9 @@ options = {}) {
|
|
|
142
143
|
: [];
|
|
143
144
|
const selection = input.action.kind === "rerun" || input.action.kind === "redo"
|
|
144
145
|
? { kind: "selected", workKeys: rerunKeys }
|
|
145
|
-
:
|
|
146
|
+
: input.action.kind === "retry"
|
|
147
|
+
? recoverySelection(plan, input.action.stage)
|
|
148
|
+
: resumeSelection(plan, stagesOf(deps.db, projectId));
|
|
146
149
|
if (selection.kind === "selected" && selection.workKeys.length === 0)
|
|
147
150
|
return finish({
|
|
148
151
|
ok: false,
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export function remadeWork(work) {
|
|
2
|
+
return work.filter((one) => one.disposition === "generate" || one.disposition === "local" || one.disposition === "review");
|
|
3
|
+
}
|
|
4
|
+
export function remakeBlocker(status, sample) {
|
|
5
|
+
if (status === "running")
|
|
6
|
+
return "It is running now. Remake it once the run has finished.";
|
|
7
|
+
if (sample)
|
|
8
|
+
return "It is a sample project, which stays as it came with Slopify. Open it, press Make my own copy at the top of its page, then remake the copy.";
|
|
9
|
+
return undefined;
|
|
10
|
+
}
|
|
11
|
+
export function previewBlocker(preview, label) {
|
|
12
|
+
if (remadeWork(preview.work).length === 0)
|
|
13
|
+
return "Nothing in it is outdated, so there is nothing to remake.";
|
|
14
|
+
const blocked = preview.work.find((work) => work.disposition === "blocked");
|
|
15
|
+
if (blocked !== undefined)
|
|
16
|
+
return `${label(blocked.key)} can't run: ${blocked.reason.trim() || "no reason was given."} Open the project and press Choose what to remake to see what to fix.`;
|
|
17
|
+
if (preview.providedReuseRequired.length > 0)
|
|
18
|
+
return "It uses content you supplied, which you confirm on the project itself: open it and press Choose what to remake.";
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
export function remakeTotal(costs) {
|
|
22
|
+
return {
|
|
23
|
+
low: costs.reduce((sum, one) => sum + one.low, 0),
|
|
24
|
+
high: costs.reduce((sum, one) => sum + one.high, 0),
|
|
25
|
+
unknown: costs.reduce((sum, one) => sum + one.unknown, 0),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -57,8 +57,13 @@ export function previewById(db, projectId, previewId) {
|
|
|
57
57
|
? undefined
|
|
58
58
|
: rebuildPreviewSchema.parse(JSON.parse(String(row.body_json)));
|
|
59
59
|
}
|
|
60
|
+
// A canceled step stays canceled: only retrying it starts it again (`cancel/index.ts`). Each
|
|
61
|
+
// project whose run this stops counts one stop more (`project_controls.stops`).
|
|
60
62
|
export function recoverWork(db) {
|
|
61
|
-
transact(db, () => db.exec(`
|
|
62
|
-
|
|
63
|
+
transact(db, () => db.exec(`INSERT INTO project_controls(project_id, stops)
|
|
64
|
+
SELECT DISTINCT project_id, 1 FROM revision_work WHERE state NOT IN ('done','canceled') AND dispatch_state!='held' AND project_id NOT IN (SELECT project_id FROM project_queue WHERE state='queued')
|
|
65
|
+
ON CONFLICT(project_id) DO UPDATE SET stops = stops + 1;
|
|
66
|
+
UPDATE revision_work_pieces SET state='held',dispatch_state='held' WHERE state!='done' AND work_id IN (SELECT id FROM revision_work WHERE state!='canceled' AND project_id NOT IN (SELECT project_id FROM project_queue WHERE state='queued'));
|
|
67
|
+
UPDATE revision_work SET state='pending',dispatch_state='held' WHERE state NOT IN ('done','canceled') AND project_id NOT IN (SELECT project_id FROM project_queue WHERE state='queued');`));
|
|
63
68
|
}
|
|
64
69
|
export { admissionReceipt, admitPreview } from "./admission-repo.js";
|
|
@@ -77,6 +77,18 @@ const starting = new Set();
|
|
|
77
77
|
export function redoStarting(verdictId) {
|
|
78
78
|
return starting.has(verdictId);
|
|
79
79
|
}
|
|
80
|
+
// Redo pressed by a person or an agent: claimed synchronously, before anything is awaited, so an
|
|
81
|
+
// Overrule arriving while the item is being made again is refused instead of landing in between.
|
|
82
|
+
// False when a redo of this verdict is already being started.
|
|
83
|
+
export function claimRedoStart(verdictId) {
|
|
84
|
+
if (starting.has(verdictId))
|
|
85
|
+
return false;
|
|
86
|
+
starting.add(verdictId);
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
export function releaseRedoStart(verdictId) {
|
|
90
|
+
starting.delete(verdictId);
|
|
91
|
+
}
|
|
80
92
|
export async function startRedo(deps, verdict) {
|
|
81
93
|
starting.add(verdict.id);
|
|
82
94
|
try {
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { externalReviewWaitMs } from "../reviews/external-model.js";
|
|
3
|
+
import { openExternalReview } from "../reviews/external-repo.js";
|
|
4
|
+
import { externalReviewByFingerprint } from "../reviews/external-rows.js";
|
|
5
|
+
import { reviewStages } from "../reviews/model.js";
|
|
6
|
+
import { nextAttempt, reviewOutcome } from "../reviews/outcome.js";
|
|
7
|
+
import { latestVerdict, saveVerdict } from "../reviews/repo.js";
|
|
8
|
+
import { exportSnapshot } from "./runtime-export-inputs.js";
|
|
9
|
+
import { publishResult } from "./runtime-publication.js";
|
|
10
|
+
import { reviewMaterial } from "./runtime-review.js";
|
|
11
|
+
// What `recipe-reviews.ts` saves for a review an external agent answers, by position.
|
|
12
|
+
const valuesSchema = z.tuple([
|
|
13
|
+
z.enum(reviewStages),
|
|
14
|
+
z.string(),
|
|
15
|
+
z.array(z.tuple([z.string(), z.unknown()])),
|
|
16
|
+
z.string(),
|
|
17
|
+
z.enum(["flag", "redo"]),
|
|
18
|
+
z.number().int().nonnegative(),
|
|
19
|
+
]);
|
|
20
|
+
// A review no model answers: the step saves what a reviewer would be shown and waits for an
|
|
21
|
+
// agent's verdict through MCP. Waiting is not "held": the stage's other reviews still open
|
|
22
|
+
// theirs in the same pass, and `agentReviewHold` keeps the runner from claiming it again.
|
|
23
|
+
export async function executeAgentReviewRecipe(deps, context, piece) {
|
|
24
|
+
if (deps.db
|
|
25
|
+
.prepare("SELECT state FROM revision_work_pieces WHERE id=? AND work_id=?")
|
|
26
|
+
.get(piece.id, context.work.workId)?.state === "done")
|
|
27
|
+
return "done";
|
|
28
|
+
if (!context.maySubmit(piece.id))
|
|
29
|
+
return "held";
|
|
30
|
+
const values = piece.input.kind === "local" ? valuesSchema.safeParse(piece.input.values) : undefined;
|
|
31
|
+
if (values === undefined || !values.success)
|
|
32
|
+
throw new Error("Slopify hit an internal error (a review step was set up wrongly). Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
|
|
33
|
+
const [stage, itemKey, , prompt, mode, retries] = values.data;
|
|
34
|
+
const projectId = context.work.projectId;
|
|
35
|
+
const known = externalReviewByFingerprint(deps.db, projectId, piece.fingerprint);
|
|
36
|
+
if (known === undefined || known.state === "superseded" || known.state === "canceled") {
|
|
37
|
+
const material = reviewMaterial(deps, context, exportSnapshot(deps, context, piece), stage, itemKey, prompt);
|
|
38
|
+
const now = deps.clock.now();
|
|
39
|
+
openExternalReview(deps.db, {
|
|
40
|
+
id: deps.ids.next(),
|
|
41
|
+
projectId,
|
|
42
|
+
revisionId: context.work.revisionId,
|
|
43
|
+
workId: context.work.workId,
|
|
44
|
+
pieceId: piece.id,
|
|
45
|
+
workKey: piece.key,
|
|
46
|
+
itemKey,
|
|
47
|
+
stage,
|
|
48
|
+
itemFingerprint: material.itemFingerprint,
|
|
49
|
+
reviewFingerprint: piece.fingerprint,
|
|
50
|
+
material: {
|
|
51
|
+
request: material.request,
|
|
52
|
+
images: material.images.map((image) => ({ stored: image.stored, name: image.name })),
|
|
53
|
+
},
|
|
54
|
+
waitingSince: now.toISOString(),
|
|
55
|
+
deadlineAt: new Date(now.getTime() + externalReviewWaitMs).toISOString(),
|
|
56
|
+
});
|
|
57
|
+
context.emit({ type: "project.updated", projectId });
|
|
58
|
+
return "waiting";
|
|
59
|
+
}
|
|
60
|
+
const autoPassed = known.state === "auto-passed";
|
|
61
|
+
const verdict = autoPassed ? { passed: true, reasons: [] } : known.verdict;
|
|
62
|
+
if (known.state === "waiting" || verdict === null)
|
|
63
|
+
return "waiting";
|
|
64
|
+
const attempt = nextAttempt(latestVerdict(deps.db, projectId, itemKey), known.itemFingerprint);
|
|
65
|
+
const outcome = autoPassed
|
|
66
|
+
? "auto-passed"
|
|
67
|
+
: reviewOutcome({ passed: verdict.passed, mode, attempt, retries });
|
|
68
|
+
const saved = saveVerdict(deps.db, {
|
|
69
|
+
id: deps.ids.next(),
|
|
70
|
+
projectId,
|
|
71
|
+
revisionId: context.work.revisionId,
|
|
72
|
+
itemKey,
|
|
73
|
+
stage,
|
|
74
|
+
itemFingerprint: known.itemFingerprint,
|
|
75
|
+
reviewFingerprint: piece.fingerprint,
|
|
76
|
+
passed: verdict.passed,
|
|
77
|
+
reasons: verdict.reasons,
|
|
78
|
+
outcome,
|
|
79
|
+
attempt,
|
|
80
|
+
action: null,
|
|
81
|
+
actionAt: null,
|
|
82
|
+
redoState: outcome === "redo" ? "pending" : null,
|
|
83
|
+
redoError: null,
|
|
84
|
+
answeredBy: autoPassed ? "nobody" : (known.answeredBy ?? "agent"),
|
|
85
|
+
actionBy: null,
|
|
86
|
+
createdAt: deps.clock.now().toISOString(),
|
|
87
|
+
});
|
|
88
|
+
await publishResult(deps, context, piece, [], {
|
|
89
|
+
verdictId: saved.id,
|
|
90
|
+
passed: saved.passed,
|
|
91
|
+
reasons: saved.reasons,
|
|
92
|
+
outcome: saved.outcome,
|
|
93
|
+
attempt: saved.attempt,
|
|
94
|
+
});
|
|
95
|
+
if (saved.outcome === "flagged")
|
|
96
|
+
context.emit({
|
|
97
|
+
type: "review.flagged",
|
|
98
|
+
projectId,
|
|
99
|
+
verdictId: saved.id,
|
|
100
|
+
stage: saved.stage,
|
|
101
|
+
itemKey: saved.itemKey,
|
|
102
|
+
...(saved.reasons[0] === undefined ? {} : { reason: saved.reasons[0] }),
|
|
103
|
+
...(saved.answeredBy === "agent" ? { byAgent: true } : {}),
|
|
104
|
+
});
|
|
105
|
+
return "done";
|
|
106
|
+
}
|
|
107
|
+
// A stage whose only unfinished steps are reviews waiting for an agent is not claimed again
|
|
108
|
+
// until one is answered (or passes at its deadline); anything else lets it run.
|
|
109
|
+
export function agentReviewHold(deps, work) {
|
|
110
|
+
const open = deps.db
|
|
111
|
+
.prepare(`SELECT e.id AS review FROM revision_work_pieces p
|
|
112
|
+
LEFT JOIN external_reviews e ON e.work_id=p.work_id AND e.piece_id=p.id
|
|
113
|
+
AND e.review_fingerprint=p.fingerprint AND e.state='waiting'
|
|
114
|
+
WHERE p.work_id=? AND p.state!='done' AND p.work_key NOT LIKE 'article:continuation:%'`)
|
|
115
|
+
.all(work.workId)
|
|
116
|
+
.map((row) => z.object({ review: z.string().nullable() }).parse(row).review);
|
|
117
|
+
const waiting = open.flatMap((id) => (id === null ? [] : [id]));
|
|
118
|
+
return open.length > 0 && waiting.length === open.length
|
|
119
|
+
? { kind: "held", checkpointIds: waiting.map((id) => `agent-review:${id}`) }
|
|
120
|
+
: { kind: "eligible" };
|
|
121
|
+
}
|
|
@@ -30,6 +30,15 @@ export async function revisionAudio(deps, context, view,
|
|
|
30
30
|
// the shorts' pick) reads the plain ones, which it waits for. An uploaded narration has no
|
|
31
31
|
// levelled join: only the master reaches it.
|
|
32
32
|
options = {}) {
|
|
33
|
+
return narrationAudio({
|
|
34
|
+
paths: deps.paths,
|
|
35
|
+
measure: (path) => deps.measureAudio?.(path, context.signal) ??
|
|
36
|
+
probeDurationMs(deps.ffmpeg, path, context.signal, deps.log),
|
|
37
|
+
}, view, options);
|
|
38
|
+
}
|
|
39
|
+
// The narration's parts in their order on the export timeline: the version's selected, ready
|
|
40
|
+
// intro, body and outro, each measured when its length was never recorded.
|
|
41
|
+
export async function narrationAudio(deps, view, options = {}) {
|
|
33
42
|
const config = view.revision.config;
|
|
34
43
|
if (config.sources.audio === "off")
|
|
35
44
|
return [];
|
|
@@ -46,10 +55,8 @@ options = {}) {
|
|
|
46
55
|
throw new Error(`The levelled ${segment === "body" ? "narration" : segment} isn't finished yet, so the sound can't be exported at an even volume. Let the Narration stage finish (Resume, or Try again on it), then retry this stage; or turn off Level the volume in Edit project → Pauses and volume.`);
|
|
47
56
|
if (row === undefined)
|
|
48
57
|
return undefined;
|
|
49
|
-
const path = outputPath(deps.paths,
|
|
50
|
-
const duration = row.output.durationMs ??
|
|
51
|
-
(await (deps.measureAudio?.(path, context.signal) ??
|
|
52
|
-
probeDurationMs(deps.ffmpeg, path, context.signal, deps.log)));
|
|
58
|
+
const path = outputPath(deps.paths, view.revision.projectId, row.output.path);
|
|
59
|
+
const duration = row.output.durationMs ?? (await deps.measure(path));
|
|
53
60
|
if (!Number.isFinite(duration) || duration <= 0)
|
|
54
61
|
throw new Error("Slopify couldn't read the length of the narration audio, so the file may be damaged or empty. Regenerate the narration (or upload your audio file again in Edit project → Narration), then Try again.");
|
|
55
62
|
return { path, seconds: duration / 1000 };
|
|
@@ -57,14 +64,22 @@ options = {}) {
|
|
|
57
64
|
const body = await input("audio_body");
|
|
58
65
|
if (body === undefined)
|
|
59
66
|
throw new Error("The narration audio isn't finished yet. Let the Narration stage finish (Resume, or Try again on it), then retry this stage.");
|
|
60
|
-
return
|
|
67
|
+
return narrationTimeline(config, {
|
|
61
68
|
body,
|
|
62
|
-
intro: config
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
69
|
+
intro: speaksPart(config, "intro") ? await input("audio_intro") : undefined,
|
|
70
|
+
outro: speaksPart(config, "outro") ? await input("audio_outro") : undefined,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
// Only generated narration has a spoken intro and outro.
|
|
74
|
+
export function speaksPart(config, part) {
|
|
75
|
+
return config.sources.audio === "generate" && config[part] !== undefined;
|
|
76
|
+
}
|
|
77
|
+
// The narration in its order on the export timeline, the clock the word timing is on.
|
|
78
|
+
export function narrationTimeline(config, parts) {
|
|
79
|
+
return audioTimeline({
|
|
80
|
+
body: parts.body,
|
|
81
|
+
intro: speaksPart(config, "intro") ? parts.intro : undefined,
|
|
82
|
+
outro: speaksPart(config, "outro") ? parts.outro : undefined,
|
|
68
83
|
gapSeconds: config.silenceGapSeconds,
|
|
69
84
|
edgeSeconds: config.edgeSilenceSeconds,
|
|
70
85
|
}, config.sources.video === "off" ? 1 / 48000 : 1 / 30);
|
|
@@ -93,6 +93,8 @@ export async function executeReviewRecipe(deps, context, providers, piece) {
|
|
|
93
93
|
actionAt: null,
|
|
94
94
|
redoState: outcome === "redo" ? "pending" : null,
|
|
95
95
|
redoError: null,
|
|
96
|
+
answeredBy: "model",
|
|
97
|
+
actionBy: null,
|
|
96
98
|
createdAt: deps.clock.now().toISOString(),
|
|
97
99
|
});
|
|
98
100
|
await publishResult(deps, context, piece, [], {
|
|
@@ -138,7 +140,7 @@ function spokenText(deps, snapshot) {
|
|
|
138
140
|
}
|
|
139
141
|
return plainText(snapshot.view.articleMarkdown ?? "");
|
|
140
142
|
}
|
|
141
|
-
function reviewMaterial(deps, context, snapshot, stage, itemKey, prompt) {
|
|
143
|
+
export function reviewMaterial(deps, context, snapshot, stage, itemKey, prompt) {
|
|
142
144
|
const { view } = snapshot;
|
|
143
145
|
const recipes = snapshot.plan.recipes;
|
|
144
146
|
const config = view.revision.config;
|
|
@@ -197,11 +199,18 @@ function reviewMaterial(deps, context, snapshot, stage, itemKey, prompt) {
|
|
|
197
199
|
images: [
|
|
198
200
|
{
|
|
199
201
|
path: path(image),
|
|
202
|
+
stored: image.output.path,
|
|
200
203
|
name: stage === "images" ? "the image to review" : "the thumbnail to review",
|
|
201
204
|
},
|
|
202
205
|
...(reference === undefined
|
|
203
206
|
? []
|
|
204
|
-
: [
|
|
207
|
+
: [
|
|
208
|
+
{
|
|
209
|
+
path: path(reference),
|
|
210
|
+
stored: reference.output.path,
|
|
211
|
+
name: "the establishing image it must match",
|
|
212
|
+
},
|
|
213
|
+
]),
|
|
205
214
|
],
|
|
206
215
|
itemFingerprint: image.fingerprint,
|
|
207
216
|
};
|
|
@@ -255,6 +264,7 @@ function reviewMaterial(deps, context, snapshot, stage, itemKey, prompt) {
|
|
|
255
264
|
request: request([{ label: "Title of the short", text: title }], names),
|
|
256
265
|
images: stills.map((row, index) => ({
|
|
257
266
|
path: path(row),
|
|
267
|
+
stored: row.output.path,
|
|
258
268
|
name: names[index] ?? "an image of the short",
|
|
259
269
|
})),
|
|
260
270
|
itemFingerprint: render.fingerprint,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { executeAgentReviewRecipe } from "./runtime-agent-review.js";
|
|
1
2
|
import { executeAnimateRecipe } from "./runtime-animate.js";
|
|
2
3
|
import { executeDocumentRecipe } from "./runtime-document.js";
|
|
3
4
|
import { executeExportRecipe } from "./runtime-export.js";
|
|
@@ -12,6 +13,7 @@ import { executeYoutubeRecipe } from "./runtime-youtube.js";
|
|
|
12
13
|
import { workPieces } from "./work-records.js";
|
|
13
14
|
export async function runRevisionInvocation(deps, context, providers) {
|
|
14
15
|
const pieces = workPieces(deps.db, context.work.workId).filter((piece) => !piece.key.startsWith("article:continuation:"));
|
|
16
|
+
let waiting = false;
|
|
15
17
|
for (const piece of pieces) {
|
|
16
18
|
if (piece.state === "done")
|
|
17
19
|
continue;
|
|
@@ -34,7 +36,9 @@ export async function runRevisionInvocation(deps, context, providers) {
|
|
|
34
36
|
: piece.key.startsWith("document:")
|
|
35
37
|
? await executeDocumentRecipe(deps, context, piece)
|
|
36
38
|
: piece.key.startsWith("review:")
|
|
37
|
-
?
|
|
39
|
+
? piece.input.kind === "local" && piece.input.operation === "review-agent-v1"
|
|
40
|
+
? await executeAgentReviewRecipe(deps, context, piece)
|
|
41
|
+
: await executeReviewRecipe(deps, context, providers, piece)
|
|
38
42
|
: piece.key.startsWith("voices:")
|
|
39
43
|
? await executeVoicesRecipe(deps, context, piece)
|
|
40
44
|
: piece.key.startsWith("youtube:")
|
|
@@ -48,6 +52,11 @@ export async function runRevisionInvocation(deps, context, providers) {
|
|
|
48
52
|
piece.input.kind === "image"
|
|
49
53
|
? await executeProviderRecipe(deps, context, providers, piece)
|
|
50
54
|
: await executeLocalRecipe(deps, context, piece);
|
|
55
|
+
// An agent's review waits without stopping the stage's other pieces.
|
|
56
|
+
if (outcome === "waiting") {
|
|
57
|
+
waiting = true;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
51
60
|
if (outcome === "held")
|
|
52
61
|
return outcome;
|
|
53
62
|
}
|
|
@@ -74,7 +83,7 @@ export async function runRevisionInvocation(deps, context, providers) {
|
|
|
74
83
|
deps.db
|
|
75
84
|
.prepare("UPDATE revision_work_pieces SET state='done' WHERE work_id=? AND result_json IS NOT NULL")
|
|
76
85
|
.run(context.work.workId);
|
|
77
|
-
return "done";
|
|
86
|
+
return waiting ? "held" : "done";
|
|
78
87
|
}
|
|
79
88
|
function startLocalTime(deps, context) {
|
|
80
89
|
const id = deps.ids.next();
|