@gentbajko/slopify 3.16.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.
Files changed (143) hide show
  1. package/dist/edge/http/actions.js +2 -2
  2. package/dist/edge/http/actor.js +10 -0
  3. package/dist/edge/http/app.js +21 -0
  4. package/dist/edge/http/checkpoints.js +14 -6
  5. package/dist/edge/http/external-reviews.js +95 -0
  6. package/dist/edge/http/projects.js +36 -18
  7. package/dist/edge/http/reviews.js +53 -18
  8. package/dist/edge/http/revisions.js +5 -1
  9. package/dist/edge/http/settings.js +9 -1
  10. package/dist/edge/http/starts.js +41 -0
  11. package/dist/edge/http/studio.js +5 -130
  12. package/dist/edge/mcp/attribution.js +38 -0
  13. package/dist/edge/mcp/call-route.js +203 -0
  14. package/dist/edge/mcp/excluded-routes.js +95 -0
  15. package/dist/edge/mcp/file-tools.js +141 -0
  16. package/dist/edge/mcp/http.js +79 -0
  17. package/dist/edge/mcp/instructions.js +17 -0
  18. package/dist/edge/mcp/library-tools.js +143 -0
  19. package/dist/edge/mcp/remake-tools.js +90 -0
  20. package/dist/edge/mcp/review-tools.js +64 -0
  21. package/dist/edge/mcp/route-tool.js +61 -0
  22. package/dist/edge/mcp/server.js +48 -0
  23. package/dist/edge/mcp/state-shapes.js +60 -0
  24. package/dist/edge/mcp/tools-channels.js +153 -0
  25. package/dist/edge/mcp/tools-library.js +151 -0
  26. package/dist/edge/mcp/tools-planning.js +137 -0
  27. package/dist/edge/mcp/tools-projects.js +167 -0
  28. package/dist/edge/mcp/tools-publishing.js +148 -0
  29. package/dist/edge/mcp/tools-runs.js +94 -0
  30. package/dist/edge/mcp/tools-schedules.js +143 -0
  31. package/dist/edge/mcp/tools-setup.js +234 -0
  32. package/dist/edge/mcp/tools-storage.js +99 -0
  33. package/dist/edge/mcp/tools.js +21 -0
  34. package/dist/extension/slopify-studio-chrome.zip +0 -0
  35. package/dist/extension/slopify-studio-firefox.zip +0 -0
  36. package/dist/kernel/db/migrations/0055-external-reviews.sql +56 -0
  37. package/dist/kernel/db/migrations/0056-agent-actions.sql +20 -0
  38. package/dist/kernel/db/migrations/0057-agent-action-once.sql +5 -0
  39. package/dist/kernel/db/migrations/0058-revision-work-admission.sql +3 -0
  40. package/dist/kernel/db/migrations/0059-external-review-canceled.sql +29 -0
  41. package/dist/kernel/db/migrations/0060-reservation-work.sql +4 -0
  42. package/dist/kernel/db/migrations/0061-project-control-counts.sql +4 -0
  43. package/dist/kernel/db/migrations/0062-told-failures.sql +8 -0
  44. package/dist/kernel/pipeline.js +6 -0
  45. package/dist/kernel/runner/graph.js +37 -2
  46. package/dist/kernel/runner/index.js +6 -3
  47. package/dist/main.js +29 -13
  48. package/dist/patch-notes/3.17.0.md +10 -0
  49. package/dist/patch-notes/3.18.0.md +13 -0
  50. package/dist/patch-notes/index.json +12 -0
  51. package/dist/slices/admission/admitted-work.js +53 -0
  52. package/dist/slices/admission/heads.js +5 -0
  53. package/dist/slices/admission/repo.js +47 -29
  54. package/dist/slices/admission/schema.js +1 -0
  55. package/dist/slices/admission/stage-writes.js +21 -0
  56. package/dist/slices/admission/start.js +2 -1
  57. package/dist/slices/admission/state-words.js +110 -0
  58. package/dist/slices/agents/attention-work.js +233 -0
  59. package/dist/slices/agents/attention.js +168 -0
  60. package/dist/slices/agents/connection.js +15 -0
  61. package/dist/slices/agents/model.js +29 -0
  62. package/dist/slices/agents/repo.js +66 -0
  63. package/dist/slices/cancel/index.js +4 -1
  64. package/dist/slices/control/index.js +2 -1
  65. package/dist/slices/episodes/summarize.js +2 -1
  66. package/dist/slices/estimate/index.js +6 -2
  67. package/dist/slices/images/run.js +2 -1
  68. package/dist/slices/library/transfer.js +69 -0
  69. package/dist/slices/narration/run.js +2 -1
  70. package/dist/slices/notifications/address.js +40 -0
  71. package/dist/slices/notifications/notifier.js +43 -10
  72. package/dist/slices/notifications/rules.js +111 -45
  73. package/dist/slices/notifications/server.js +14 -0
  74. package/dist/slices/notifications/settings.js +1 -1
  75. package/dist/slices/notifications/subject.js +30 -0
  76. package/dist/slices/notifications/told.js +33 -0
  77. package/dist/slices/play-drafts/readiness.js +9 -2
  78. package/dist/slices/play-drafts/schema.js +2 -1
  79. package/dist/slices/rebuild/narration-words.js +76 -0
  80. package/dist/slices/rebuild/recipe-model.js +1 -0
  81. package/dist/slices/rebuild/recipe-reviews.js +44 -23
  82. package/dist/slices/rebuild/recovery-repo.js +0 -20
  83. package/dist/slices/rebuild/recovery-selection.js +14 -3
  84. package/dist/slices/rebuild/recovery.js +5 -2
  85. package/dist/slices/rebuild/remake-outdated.js +27 -0
  86. package/dist/slices/rebuild/repo.js +7 -2
  87. package/dist/slices/rebuild/review-redo.js +12 -0
  88. package/dist/slices/rebuild/runtime-agent-review.js +121 -0
  89. package/dist/slices/rebuild/runtime-export-inputs.js +26 -11
  90. package/dist/slices/rebuild/runtime-review.js +12 -2
  91. package/dist/slices/rebuild/runtime-run.js +11 -2
  92. package/dist/slices/rebuild/runtime-store.js +32 -5
  93. package/dist/slices/reruns/index.js +2 -1
  94. package/dist/slices/research/run.js +2 -1
  95. package/dist/slices/reviews/external-model.js +21 -0
  96. package/dist/slices/reviews/external-release.js +38 -0
  97. package/dist/slices/reviews/external-repo.js +109 -0
  98. package/dist/slices/reviews/external-rows.js +68 -0
  99. package/dist/slices/reviews/external-waiting.js +75 -0
  100. package/dist/slices/reviews/holds.js +144 -0
  101. package/dist/slices/reviews/model.js +14 -0
  102. package/dist/slices/reviews/repo.js +27 -10
  103. package/dist/slices/reviews/rules.js +5 -3
  104. package/dist/slices/reviews/verdict.js +18 -8
  105. package/dist/slices/revisions/repo.js +3 -0
  106. package/dist/slices/revisions/schema.js +1 -0
  107. package/dist/slices/schedules/agenda.js +47 -17
  108. package/dist/slices/schedules/prepare.js +6 -0
  109. package/dist/slices/schedules/schema.js +13 -1
  110. package/dist/slices/storage/consequences.js +27 -0
  111. package/dist/slices/storage/portable.js +3 -0
  112. package/dist/slices/storage/project-files.js +66 -0
  113. package/dist/slices/studio/settings.js +0 -35
  114. package/dist/slices/studio/stats.js +2 -34
  115. package/dist/slices/subtitles/prepare.js +2 -1
  116. package/dist/slices/uploads/repo.js +12 -6
  117. package/dist/slices/uploads/rules.js +5 -0
  118. package/dist/slices/video/write-export.js +1 -1
  119. package/dist/slices/video/write-subtitles.js +1 -1
  120. package/dist/tutorials/Agents-MCP.md +168 -0
  121. package/dist/tutorials/Calendar.md +2 -1
  122. package/dist/tutorials/First-Launch-and-Welcome.md +1 -0
  123. package/dist/tutorials/Home-and-Projects.md +16 -9
  124. package/dist/tutorials/Home.md +1 -0
  125. package/dist/tutorials/Notifications.md +25 -6
  126. package/dist/tutorials/Project-Page.md +8 -2
  127. package/dist/tutorials/Publishing-to-YouTube.md +1 -0
  128. package/dist/tutorials/Recovery-and-Retries.md +17 -3
  129. package/dist/tutorials/Reviews-and-Checkpoints.md +22 -5
  130. package/dist/tutorials/Schedules.md +1 -1
  131. package/dist/tutorials/Settings-Reference.md +15 -0
  132. package/dist/tutorials/Troubleshooting.md +3 -1
  133. package/dist/tutorials/Your-First-Short.md +1 -0
  134. package/dist/tutorials/_Sidebar.md +1 -0
  135. package/dist/web/assets/{index-VbpUYLAp.css → index-BKbZIxAq.css} +1 -1
  136. package/dist/web/assets/index-Drth_MfQ.js +325 -0
  137. package/dist/web/assets/{pdf-C6KhxeKQ.js → pdf-CQFUyijy.js} +1 -1
  138. package/dist/web/index.html +2 -2
  139. package/package.json +1 -1
  140. package/dist/slices/studio/performance.js +0 -83
  141. package/dist/slices/studio/report-stats.js +0 -39
  142. package/dist/slices/studio/report.js +0 -172
  143. package/dist/web/assets/index-Dvz8bsiD.js +0 -325
@@ -0,0 +1,168 @@
1
+ import { z } from "zod";
2
+ import { causedBy } from "../../kernel/errors.js";
3
+ import { liveProject } from "../admission/repo.js";
4
+ import { externalReviewOf } from "../reviews/external-rows.js";
5
+ import { waitingExternalReviewRows } from "../reviews/external-waiting.js";
6
+ import { projectHolds } from "../reviews/holds.js";
7
+ import { setAsideProjects } from "../uploads/repo.js";
8
+ import { extensionFailures, failedSteps, heldTopics, lateUploads, pausedRuns, rowsOf, stoppedRuns, } from "./attention-work.js";
9
+ export const attentionKinds = [
10
+ "external-review",
11
+ "flagged-verdict",
12
+ "checkpoint",
13
+ "failed-step",
14
+ "paused-run",
15
+ "stopped-run",
16
+ "held-topics",
17
+ "late-upload",
18
+ "extension-failure",
19
+ ];
20
+ const projectOf = z.object({ project_id: z.string() });
21
+ // Oldest first. A project Kept as is waits for nobody, so none of its entries is listed. Each
22
+ // source is read on its own: one that fails is logged and left out, and the others still list.
23
+ export function needsAttention(deps, now) {
24
+ const { db, log } = deps;
25
+ const skip = (source, value, error) => {
26
+ const project = projectOf.safeParse(value);
27
+ log.write("error", "attention.row", {
28
+ ...(project.success ? { projectId: project.data.project_id } : {}),
29
+ detail: `needs_attention left out one of the ${source} it could not read: ${causedBy(error)}`,
30
+ });
31
+ };
32
+ // Read once for the checkpoints and the stopped runs, when the first of them asks.
33
+ let heldOnce;
34
+ const holds = () => {
35
+ heldOnce ??= projectHolds(db, {
36
+ skip: (value, error) => skip("review checkpoints", value, error),
37
+ });
38
+ return heldOnce;
39
+ };
40
+ const sources = [
41
+ ["external reviews", () => externalReviews(db, skip)],
42
+ ["flagged verdicts", () => flaggedVerdicts(db, skip)],
43
+ ["review checkpoints", () => heldCheckpoints(db, holds())],
44
+ ["failed steps", () => failedSteps(db, skip)],
45
+ ["paused runs", () => pausedRuns(db, skip)],
46
+ ["stopped runs", () => stoppedRuns(db, holds().stopped, skip)],
47
+ ["held topics", () => heldTopics(db, skip)],
48
+ ["late uploads", () => lateUploads(deps, now)],
49
+ ["extension failures", () => extensionFailures(db, skip)],
50
+ ];
51
+ const aside = setAsideProjects(db);
52
+ return sources
53
+ .flatMap(([source, entries]) => {
54
+ try {
55
+ return entries();
56
+ }
57
+ catch (error) {
58
+ log.write("error", "attention.source", {
59
+ detail: `needs_attention left out all ${source} because reading them failed: ${causedBy(error)}`,
60
+ });
61
+ return [];
62
+ }
63
+ })
64
+ .filter((entry) => entry.projectId === null || !aside.has(entry.projectId))
65
+ .toSorted((a, b) => a.since.localeCompare(b.since));
66
+ }
67
+ function titles(db) {
68
+ return new Map(db
69
+ .prepare("SELECT id, title FROM projects")
70
+ .all()
71
+ .map((row) => {
72
+ const parsed = z.object({ id: z.string(), title: z.string() }).parse(row);
73
+ return [parsed.id, parsed.title];
74
+ }));
75
+ }
76
+ function externalReviews(db, skip) {
77
+ const named = titles(db);
78
+ return rowsOf(waitingExternalReviewRows(db), "external reviews", skip, (value) => {
79
+ const review = externalReviewOf(value);
80
+ return [
81
+ {
82
+ kind: "external-review",
83
+ since: review.waitingSince,
84
+ projectId: review.projectId,
85
+ title: named.get(review.projectId) ?? "",
86
+ stage: review.stage,
87
+ itemKey: review.itemKey,
88
+ reason: `Waiting for an agent's verdict; it passes without review at ${review.deadlineAt}.`,
89
+ autoRetryInFlight: false,
90
+ agentCanAct: true,
91
+ ids: { id: review.projectId, reviewId: review.id },
92
+ tools: ["get_external_review", "submit_review_verdict"],
93
+ },
94
+ ];
95
+ });
96
+ }
97
+ const verdictRow = z.object({
98
+ id: z.string(),
99
+ project_id: z.string(),
100
+ title: z.string(),
101
+ stage: z.string(),
102
+ item_key: z.string(),
103
+ reasons: z.string(),
104
+ created_at: z.string(),
105
+ redo_state: z.enum(["pending", "started", "failed"]).nullable(),
106
+ redo_error: z.string().nullable(),
107
+ head: z.string(),
108
+ });
109
+ // Each item's latest failed verdict nobody has acted on, in the current version.
110
+ function flaggedVerdicts(db, skip) {
111
+ const rows = db
112
+ .prepare(`SELECT v.id, v.project_id, p.title, v.stage, v.item_key, v.reasons, v.created_at, v.redo_state, v.redo_error, h.revision_id AS head
113
+ FROM review_verdicts v
114
+ JOIN projects p ON p.id=v.project_id
115
+ JOIN project_heads h ON h.project_id=v.project_id AND h.revision_id=v.revision_id
116
+ WHERE v.passed=0 AND v.action IS NULL AND ${liveProject("v", "project_id")}
117
+ AND NOT EXISTS (SELECT 1 FROM review_verdicts n WHERE n.project_id=v.project_id
118
+ AND n.item_key=v.item_key AND (n.created_at>v.created_at OR (n.created_at=v.created_at AND n.rowid>v.rowid)))`)
119
+ .all();
120
+ return rowsOf(rows, "flagged verdicts", skip, (value) => {
121
+ const row = verdictRow.parse(value);
122
+ const reasons = z.array(z.string()).parse(JSON.parse(row.reasons));
123
+ const redoing = row.redo_state === "pending" || row.redo_state === "started";
124
+ return [
125
+ {
126
+ kind: "flagged-verdict",
127
+ since: row.created_at,
128
+ projectId: row.project_id,
129
+ title: row.title,
130
+ stage: row.stage,
131
+ itemKey: row.item_key,
132
+ reason: row.redo_state === "failed" && row.redo_error !== null
133
+ ? row.redo_error
134
+ : reasons.join(" "),
135
+ autoRetryInFlight: redoing,
136
+ agentCanAct: !redoing,
137
+ ids: { id: row.project_id, verdictId: row.id, baseRevisionId: row.head },
138
+ tools: ["list_reviews", "overrule_review", "redo_review"],
139
+ },
140
+ ];
141
+ });
142
+ }
143
+ // Only a checkpoint that holds the run for the person (`reviews/holds.ts`): one whose stage's
144
+ // inputs aren't made yet holds nothing, and a Before Video hold a schedule added waits for its
145
+ // scheduled day, not for anyone.
146
+ function heldCheckpoints(db, { holding }) {
147
+ const named = titles(db);
148
+ return [...holding].flatMap(([projectId, holds]) => holds
149
+ .filter((hold) => hold.waitsFor === "person")
150
+ .map((hold) => ({
151
+ kind: "checkpoint",
152
+ since: hold.since,
153
+ projectId,
154
+ title: named.get(projectId) ?? "",
155
+ stage: hold.stage,
156
+ itemKey: null,
157
+ reason: `Waiting for your review before ${hold.stage}: held until it is approved.`,
158
+ autoRetryInFlight: false,
159
+ agentCanAct: true,
160
+ ids: {
161
+ id: projectId,
162
+ checkpointId: hold.checkpointId,
163
+ revisionId: hold.revisionId,
164
+ fingerprint: hold.fingerprint,
165
+ },
166
+ tools: ["get_checkpoints", "approve_checkpoint"],
167
+ })));
168
+ }
@@ -0,0 +1,15 @@
1
+ import { z } from "zod";
2
+ import { readSetting, writeSetting } from "../settings/repo.js";
3
+ // When an agent last opened an MCP session. This install's own fact, so backups leave it out
4
+ // (`storage/portable.ts`).
5
+ export const agentConnectionKey = "agents.lastConnectedAt";
6
+ export function recordAgentConnection(db, at) {
7
+ writeSetting(db, agentConnectionKey, JSON.stringify(at));
8
+ }
9
+ export function lastAgentConnection(db) {
10
+ const raw = readSetting(db, agentConnectionKey);
11
+ if (raw === undefined)
12
+ return null;
13
+ const parsed = z.iso.datetime().safeParse(JSON.parse(raw));
14
+ return parsed.success ? parsed.data : null;
15
+ }
@@ -0,0 +1,29 @@
1
+ import { z } from "zod";
2
+ // Agents: an AI agent (Claude Code, Codex, ...) driving Slopify through its MCP endpoint.
3
+ // Browser-safe: Settings → Connections → Agents (MCP) builds its setup lines from these.
4
+ export const actors = ["person", "agent"];
5
+ export const mcpPath = "/mcp";
6
+ export function mcpUrl(origin) {
7
+ return `${origin.replace(/\/+$/, "")}${mcpPath}`;
8
+ }
9
+ export function claudeAddCommand(url) {
10
+ return `claude mcp add --transport http slopify ${url}`;
11
+ }
12
+ // Codex reads MCP servers from ~/.codex/config.toml; a `url` makes it a Streamable HTTP one.
13
+ export function codexConfig(url) {
14
+ return `[mcp_servers.slopify]\nurl = "${url}"`;
15
+ }
16
+ const madeBody = z
17
+ .object({ projectIds: z.array(z.string()).optional(), projectId: z.string().optional() })
18
+ .loose();
19
+ // The projects an API answer says it made ({projectId} or {projectIds}), for recording who
20
+ // started them and what an agent changed.
21
+ export function projectsMadeBy(answer) {
22
+ const made = madeBody.safeParse(answer);
23
+ if (!made.success)
24
+ return [];
25
+ return [
26
+ ...(made.data.projectIds ?? []),
27
+ ...(made.data.projectId === undefined ? [] : [made.data.projectId]),
28
+ ];
29
+ }
@@ -0,0 +1,66 @@
1
+ import { z } from "zod";
2
+ import { actors } from "./model.js";
3
+ // ceiling: Settings shows the latest twenty; a project's own are in its history.
4
+ export const recentAgentActionsMax = 20;
5
+ const actionRow = z.object({
6
+ id: z.string(),
7
+ project_id: z.string().nullable(),
8
+ schedule_id: z.string().nullable(),
9
+ tool: z.string(),
10
+ title: z.string(),
11
+ revision_id: z.string().nullable(),
12
+ created_at: z.string(),
13
+ });
14
+ function actionOf(value) {
15
+ const row = actionRow.parse(value);
16
+ return {
17
+ id: row.id,
18
+ projectId: row.project_id,
19
+ scheduleId: row.schedule_id,
20
+ tool: row.tool,
21
+ title: row.title,
22
+ revisionId: row.revision_id,
23
+ createdAt: row.created_at,
24
+ };
25
+ }
26
+ // A project the call itself deleted for good is gone: the action is kept without it. A call
27
+ // sent again with its idempotencyKey was answered with its first answer, so it adds no second
28
+ // row (`agent_actions_once`); any other failure to record it is thrown.
29
+ export function recordAgentAction(db, action, idempotencyKey) {
30
+ db.prepare(`INSERT INTO agent_actions(id,project_id,schedule_id,tool,title,revision_id,created_at,idempotency_key)
31
+ VALUES (?,(SELECT id FROM projects WHERE id=?),?,?,?,?,?,?)
32
+ ON CONFLICT(tool,COALESCE(project_id,''),idempotency_key) WHERE idempotency_key IS NOT NULL
33
+ DO NOTHING`).run(action.id, action.projectId, action.scheduleId, action.tool, action.title, action.revisionId, action.createdAt, idempotencyKey ?? null);
34
+ }
35
+ export function agentActionsOf(db, projectId) {
36
+ return db
37
+ .prepare("SELECT * FROM agent_actions WHERE project_id=? ORDER BY created_at DESC, rowid DESC")
38
+ .all(projectId)
39
+ .map(actionOf);
40
+ }
41
+ export function agentRevisionIds(db, projectId) {
42
+ return new Set(db
43
+ .prepare("SELECT DISTINCT revision_id FROM agent_actions WHERE project_id=? AND revision_id IS NOT NULL")
44
+ .all(projectId)
45
+ .map((row) => String(row.revision_id)));
46
+ }
47
+ export function recentAgentActions(db) {
48
+ return db
49
+ .prepare(`SELECT a.*, COALESCE(p.title, s.name) AS subject FROM agent_actions a
50
+ LEFT JOIN projects p ON p.id=a.project_id LEFT JOIN schedules s ON s.id=a.schedule_id
51
+ ORDER BY a.created_at DESC, a.rowid DESC LIMIT ?`)
52
+ .all(recentAgentActionsMax)
53
+ .map((row) => ({
54
+ ...actionOf(row),
55
+ subject: z.object({ subject: z.string().nullable() }).parse(row).subject,
56
+ }));
57
+ }
58
+ export function recordStart(db, start) {
59
+ db.prepare(`INSERT INTO project_starts(project_id,actor,started_at)
60
+ SELECT ?,?,? WHERE EXISTS (SELECT 1 FROM projects WHERE id=?)
61
+ ON CONFLICT(project_id) DO UPDATE SET actor=excluded.actor, started_at=excluded.started_at`).run(start.projectId, start.actor, start.at, start.projectId);
62
+ }
63
+ export function lastStarter(db, projectId) {
64
+ const row = db.prepare("SELECT actor FROM project_starts WHERE project_id=?").get(projectId);
65
+ return row === undefined ? null : z.object({ actor: z.enum(actors) }).parse(row).actor;
66
+ }
@@ -2,9 +2,11 @@ import { z } from "zod";
2
2
  import { transact } from "../../kernel/db/tx.js";
3
3
  import { projectStates, stageKinds, } from "../../kernel/pipeline.js";
4
4
  import { derive } from "../../kernel/runner/graph.js";
5
- import { finishStage, projectExists, projectPaused, setProjectPaused, stagesOf, } from "../admission/repo.js";
5
+ import { projectExists, projectPaused, setProjectPaused, stagesOf } from "../admission/repo.js";
6
+ import { finishStage } from "../admission/stage-writes.js";
6
7
  import { withProjectControl } from "../control/lock.js";
7
8
  import { checkRevisionControl, rememberRevisionControl, } from "../control/revision-control.js";
9
+ import { stopWaitingReviews } from "../reviews/external-repo.js";
8
10
  import { settleScheduleRunsForProject } from "../schedules/repo.js";
9
11
  // Cancel on the project header. Every in-flight call of this project is aborted at once, every
10
12
  // `done` output and every finished piece is kept for the resume, and the project sits
@@ -61,6 +63,7 @@ async function cancel(deps, projectId) {
61
63
  deps.db
62
64
  .prepare("UPDATE review_checkpoints SET state='canceled',approved_at=NULL WHERE project_id=? AND revision_id=(SELECT revision_id FROM project_heads WHERE project_id=?) AND state IN ('configured','pending-review','held')")
63
65
  .run(projectId, projectId);
66
+ stopWaitingReviews(deps.db, projectId);
64
67
  deps.settleCheckpoints(projectId);
65
68
  // The invariant: after cancel commits no stage of the project is `running`.
66
69
  // Usually the aborted runner already wrote these rows; the sweep covers a failed
@@ -1,7 +1,8 @@
1
1
  import { z } from "zod";
2
2
  import { transact } from "../../kernel/db/tx.js";
3
3
  import { derive, satisfied } from "../../kernel/runner/graph.js";
4
- import { finishStage, projectById, resetStage, setProjectPaused, stagesOf, updateProjectConfig, } from "../admission/repo.js";
4
+ import { projectById, setProjectPaused, stagesOf, updateProjectConfig } from "../admission/repo.js";
5
+ import { finishStage, resetStage } from "../admission/stage-writes.js";
5
6
  import { listCheckpoints } from "../checkpoints/repo.js";
6
7
  import { sameChunking } from "../narration/chunk.js";
7
8
  import { projectStandings } from "../rebuild/runtime-store.js";
@@ -3,6 +3,7 @@ import { existsSync, readFileSync } from "node:fs";
3
3
  import { projectById } from "../admission/repo.js";
4
4
  import { castMentions } from "../channels/cast-match.js";
5
5
  import { castOfChannel, projectChannelId } from "../channels/repo.js";
6
+ import { reviewedByAgent } from "../reviews/model.js";
6
7
  import { outputPath } from "../storage/layout.js";
7
8
  import { outputsOf } from "../storage/repo.js";
8
9
  import { episodeMemoryOn, memoryOfProject, saveGeneratedMemory } from "./repo.js";
@@ -115,7 +116,7 @@ async function summarize(deps, projectId, signal) {
115
116
  function llmOf(config) {
116
117
  if (config.llm !== undefined)
117
118
  return config.llm;
118
- return config.reviews === undefined
119
+ return config.reviews === undefined || reviewedByAgent(config.reviews)
119
120
  ? undefined
120
121
  : { provider: config.reviews.provider, model: config.reviews.model };
121
122
  }
@@ -9,7 +9,7 @@ import { chunkNarration, defaultChunking } from "../narration/chunk.js";
9
9
  import { describeMessages } from "../narration/describe.js";
10
10
  import { normalizeNarrationText } from "../narration/plan.js";
11
11
  import { preparationMessages } from "../narration/preparation.js";
12
- import { defaultReviewPrompts, reviewPromptKey, reviewRetriesOf } from "../reviews/model.js";
12
+ import { defaultReviewPrompts, reviewedByAgent, reviewPromptKey, reviewRetriesOf, } from "../reviews/model.js";
13
13
  import { activeReviewStages } from "../reviews/rules.js";
14
14
  import { isLocalCliProvider } from "../settings/model.js";
15
15
  import { defaultShortsImagePrompt, defaultShortsPrompt, shortsImageUpperBound, shortsSettingsProblems, } from "../shorts/model.js";
@@ -260,7 +260,11 @@ export function estimateRun(draft, rendered, expectedWords, catalogue) {
260
260
  // Automatic reviews: one call per reviewed item, on the reviewer's model. A failed item
261
261
  // made again is reviewed again, at most the retries allowed, which is not priced here.
262
262
  const reviews = draft.reviews;
263
- if (reviews !== undefined)
263
+ if (reviews !== undefined && reviewedByAgent(reviews)) {
264
+ if (activeReviewStages(draft).length > 0)
265
+ local("Reviews", "An external agent answers each review through MCP; no API fee.");
266
+ }
267
+ else if (reviews !== undefined)
264
268
  for (const stage of activeReviewStages(draft)) {
265
269
  const picked = rendered[reviewPromptKey(stage)]?.trim() ?? "";
266
270
  const prompt = picked === "" ? defaultReviewPrompts[stage].length : picked.length;
@@ -3,7 +3,8 @@ import { dirname } from "node:path";
3
3
  import { z } from "zod";
4
4
  import { transact } from "../../kernel/db/tx.js";
5
5
  import { insertPiece, piecesOf, setPiece } from "../../kernel/runner/piece-repo.js";
6
- import { projectById, setStageProgress } from "../admission/repo.js";
6
+ import { projectById } from "../admission/repo.js";
7
+ import { setStageProgress } from "../admission/stage-writes.js";
7
8
  import { outputFileName, outputPath } from "../storage/layout.js";
8
9
  import { insertOutput, outputsOf } from "../storage/repo.js";
9
10
  import { imageCountsOf } from "./scale.js";
@@ -162,3 +162,72 @@ function freeName(wanted, taken, max, suffix, keepIfFree = true) {
162
162
  return candidate;
163
163
  }
164
164
  }
165
+ const tries = 3;
166
+ // Saves the items one by one under a name free in their group (a prompt kind, an intro or
167
+ // outro, every PDF theme). A name the server still refuses moves on to the next free one; any
168
+ // other refusal skips the item with the server's sentence. The web's Import and the agent's
169
+ // import_library both use it.
170
+ export async function importEach(items, options) {
171
+ const taken = new Map();
172
+ const takenIn = (group) => {
173
+ const found = taken.get(group);
174
+ if (found !== undefined)
175
+ return found;
176
+ const made = new Set();
177
+ taken.set(group, made);
178
+ return made;
179
+ };
180
+ for (const one of options.existing)
181
+ takenIn(one.group).add(one.name.trim().toLowerCase());
182
+ let added = 0;
183
+ let renamed = 0;
184
+ const skipped = [];
185
+ for (const item of items) {
186
+ const names = takenIn(options.groupOf(item));
187
+ let reason = "";
188
+ for (let attempt = 0; attempt < tries; attempt += 1) {
189
+ const name = importedName(item.name, names, options.max);
190
+ try {
191
+ const reply = await options.save({ ...item, name });
192
+ names.add(name.toLowerCase());
193
+ if (reply.ok) {
194
+ added += 1;
195
+ if (name !== item.name.trim())
196
+ renamed += 1;
197
+ reason = "";
198
+ break;
199
+ }
200
+ reason = reply.fields.map((field) => field.message).join(" ");
201
+ if (!reply.fields.some((field) => field.field === "name"))
202
+ break;
203
+ }
204
+ catch (error) {
205
+ reason = error instanceof Error ? error.message : String(error);
206
+ break;
207
+ }
208
+ }
209
+ if (reason !== "")
210
+ skipped.push({ item: item.name.trim() || "An item", reason });
211
+ }
212
+ return { added, renamed, skipped };
213
+ }
214
+ // Folds imported or pasted aliases into a list: a written form already in the list gets the
215
+ // new spoken form, anything else is added at the end.
216
+ export function mergeAliases(rows, pasted, make) {
217
+ const next = [...rows];
218
+ let added = 0;
219
+ let updated = 0;
220
+ for (const alias of pasted) {
221
+ const at = next.findIndex((row) => row.written.trim() === alias.written);
222
+ const existing = at < 0 ? undefined : next[at];
223
+ if (existing === undefined) {
224
+ next.push(make(alias));
225
+ added += 1;
226
+ }
227
+ else if (existing.spoken.trim() !== alias.spoken) {
228
+ next[at] = { ...existing, spoken: alias.spoken };
229
+ updated += 1;
230
+ }
231
+ }
232
+ return { rows: next, added, updated };
233
+ }
@@ -3,7 +3,8 @@ import { dirname } from "node:path";
3
3
  import { z } from "zod";
4
4
  import { piecesOf, setPiece } from "../../kernel/runner/piece-repo.js";
5
5
  import { entryModes } from "../admission/model.js";
6
- import { projectById, setStageProgress, stagesOf } from "../admission/repo.js";
6
+ import { projectById, stagesOf } from "../admission/repo.js";
7
+ import { setStageProgress } from "../admission/stage-writes.js";
7
8
  import { entryCategories } from "../library/model.js";
8
9
  import { outputFileName, outputPath } from "../storage/layout.js";
9
10
  import { insertOutput, outputsOf } from "../storage/repo.js";
@@ -0,0 +1,40 @@
1
+ // Where notifications go: the Notification URL, what it is sent, and the project's address in
2
+ // it. Pure: the web bundle imports this file.
3
+ export const testNotice = {
4
+ headline: "Slopify test notification",
5
+ detail: "Notifications work. You'll get one when a run finishes, fails or waits for you, and when a review needs your decision.",
6
+ };
7
+ // The body a Notification URL receives: plain text, which ntfy shows as the message. Nothing
8
+ // but the project's title, what happened and the provider's own short reason goes in it.
9
+ export function webhookBody(text) {
10
+ return `${text.headline}\n${text.detail}\n${text.link === undefined ? "" : `${text.link}\n`}`;
11
+ }
12
+ // Where a project opens in the browser, from the address the server listens on. A wildcard
13
+ // (0.0.0.0 in Docker) or loopback address is reached as localhost from the same machine.
14
+ export function projectLink(host, port, projectId) {
15
+ const wildcard = host === "0.0.0.0" || host === "::" || host === "" || host === "127.0.0.1";
16
+ const authority = wildcard ? "localhost" : host.includes(":") ? `[${host}]` : host;
17
+ return `http://${authority}:${String(port)}/projects/${encodeURIComponent(projectId)}`;
18
+ }
19
+ export const notificationUrlMax = 2048;
20
+ // Empty clears the setting. Otherwise one absolute http(s) URL with no user name or password:
21
+ // fetch refuses those, and a password in a URL would sit in the database in plain sight.
22
+ export function notificationUrlProblem(value) {
23
+ const trimmed = value.trim();
24
+ if (trimmed === "")
25
+ return undefined;
26
+ if (trimmed.length > notificationUrlMax)
27
+ return `Keep the Notification URL to ${String(notificationUrlMax)} characters or fewer.`;
28
+ let parsed;
29
+ try {
30
+ parsed = new URL(trimmed);
31
+ }
32
+ catch {
33
+ return "Enter a full address starting with https:// or http://, for example https://ntfy.sh/your-topic.";
34
+ }
35
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:")
36
+ return "The Notification URL must start with https:// or http://.";
37
+ if (parsed.username !== "" || parsed.password !== "")
38
+ return "Remove the user name and password from the Notification URL; Slopify can't send them.";
39
+ return undefined;
40
+ }
@@ -1,21 +1,30 @@
1
1
  import { redact } from "../../kernel/log.js";
2
- import { noticeOf, noticeText, reviewNoticeText, topicsNoticeText, webhookBody } from "./rules.js";
2
+ import { webhookBody } from "./address.js";
3
+ import { autoPassNoticeText, noticeOf, noticeText, reviewNoticeText, runNotices, topicsNoticeText, } from "./rules.js";
3
4
  import { sendFailureText } from "./send.js";
5
+ import { toldInMemory } from "./told.js";
4
6
  export function createRunNotifier(deps) {
5
7
  // ceiling: one entry per project seen since boot, a few dozen bytes each.
6
8
  const states = new Map();
7
- const reasons = new Map();
8
9
  const inflight = new Set();
9
10
  let closed = false;
10
- const notify = (projectId, kind) => {
11
+ const told = deps.told ?? toldInMemory();
12
+ const notify = (projectId, cause) => {
11
13
  const url = deps.url();
12
14
  if (url === null)
13
15
  return;
14
16
  const subject = deps.subject(projectId);
15
17
  if (subject === undefined)
16
18
  return;
17
- const text = noticeText(kind, { ...subject, reason: reasons.get(projectId) });
18
- post(url, { ...text, link: deps.link?.(projectId) }, kind, { projectId });
19
+ const before = told.read(projectId);
20
+ const now = runNotices(cause, subject, before);
21
+ for (const one of now.notices) {
22
+ const text = noticeText(one.kind, { ...subject, reason: one.reason });
23
+ post(url, { ...text, link: deps.link?.(projectId) }, one.kind, { projectId });
24
+ }
25
+ // Kept once the notices are on their way, and only when it changed.
26
+ if (now.told.size !== before.size || [...now.told].some((id) => !before.has(id)))
27
+ told.keep(projectId, now.told);
19
28
  };
20
29
  // Once per verdict: a review asked again for the same item replaces its verdict in place.
21
30
  // ceiling: one id per flagged verdict since boot.
@@ -34,11 +43,33 @@ export function createRunNotifier(deps) {
34
43
  title: subject.title,
35
44
  stage: event.stage,
36
45
  reason: event.reason,
46
+ ...(event.byAgent === true ? { byAgent: true } : {}),
37
47
  });
38
48
  post(url, { ...text, link: deps.link?.(event.projectId) }, "review", {
39
49
  projectId: event.projectId,
40
50
  });
41
51
  };
52
+ // Once per review. ceiling: one id per auto-passed review since boot.
53
+ const passed = new Set();
54
+ const notifyAutoPass = (event) => {
55
+ if (passed.has(event.reviewId))
56
+ return;
57
+ passed.add(event.reviewId);
58
+ const url = deps.url();
59
+ if (url === null)
60
+ return;
61
+ const subject = deps.subject(event.projectId);
62
+ if (subject === undefined)
63
+ return;
64
+ const text = autoPassNoticeText({
65
+ title: subject.title,
66
+ stage: event.stage,
67
+ item: event.item,
68
+ });
69
+ post(url, { ...text, link: deps.link?.(event.projectId) }, "auto-pass", {
70
+ projectId: event.projectId,
71
+ });
72
+ };
42
73
  const post = (url, text, kind, about) => {
43
74
  const body = redact(webhookBody(text));
44
75
  const sending = deps
@@ -67,16 +98,18 @@ export function createRunNotifier(deps) {
67
98
  return;
68
99
  try {
69
100
  if (event.type === "stage.state") {
70
- if (event.state === "failed" && event.failureReason !== undefined)
71
- reasons.set(event.projectId, event.failureReason);
72
- else if (event.state === "running")
73
- reasons.delete(event.projectId);
101
+ if (event.state === "failed")
102
+ notify(event.projectId, { stepFailed: event.stage });
74
103
  return;
75
104
  }
76
105
  if (event.type === "review.flagged") {
77
106
  notifyReview(event);
78
107
  return;
79
108
  }
109
+ if (event.type === "review.auto-passed") {
110
+ notifyAutoPass(event);
111
+ return;
112
+ }
80
113
  if (event.type !== "project.state")
81
114
  return;
82
115
  const previous = states.get(event.projectId);
@@ -85,7 +118,7 @@ export function createRunNotifier(deps) {
85
118
  states.set(event.projectId, event.state);
86
119
  const kind = noticeOf(previous, event.state);
87
120
  if (kind !== undefined)
88
- notify(event.projectId, kind);
121
+ notify(event.projectId, { kind });
89
122
  }
90
123
  catch (error) {
91
124
  deps.log.write("warn", "notification.failed", {