@gentbajko/slopify 2.4.0 → 2.5.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 (95) hide show
  1. package/dist/adapter-registry.js +8 -1
  2. package/dist/adapters/host-cli/index.js +11 -0
  3. package/dist/adapters/host-cli/transport.js +6 -1
  4. package/dist/adapters/image/codex-output.js +59 -17
  5. package/dist/adapters/image/codex.js +92 -16
  6. package/dist/adapters/image/fal.js +22 -2
  7. package/dist/adapters/image/google.js +13 -1
  8. package/dist/adapters/image/openai.js +37 -15
  9. package/dist/adapters/image/reference.js +7 -0
  10. package/dist/adapters/image/replicate.js +7 -0
  11. package/dist/adapters/llm/codex-models.js +2 -1
  12. package/dist/adapters/llm/codex-server-models.js +2 -1
  13. package/dist/adapters/llm/run-cli.js +14 -2
  14. package/dist/assets/models.yaml +7 -0
  15. package/dist/catalog/registry.js +24 -3
  16. package/dist/catalog/runtime-models.js +1 -1
  17. package/dist/catalog/schema.js +10 -1
  18. package/dist/catalog/validate.js +17 -2
  19. package/dist/edge/events/hub.js +11 -0
  20. package/dist/edge/http/app.js +2 -0
  21. package/dist/edge/http/backups.js +40 -0
  22. package/dist/edge/http/host-cli.js +17 -2
  23. package/dist/edge/http/problem.js +1 -0
  24. package/dist/edge/http/schedules.js +1 -1
  25. package/dist/edge/http/settings.js +44 -0
  26. package/dist/host-cli/runtime.js +3 -2
  27. package/dist/kernel/db/migrations/0022-drop-duplicate-held-work.sql +56 -0
  28. package/dist/kernel/db/migrations/0024-reference-attachments.sql +18 -0
  29. package/dist/kernel/ports/host-cli.js +17 -2
  30. package/dist/kernel/ports/image.js +3 -1
  31. package/dist/kernel/ports/llm.js +3 -1
  32. package/dist/kernel/runner/attempt.js +1 -1
  33. package/dist/kernel/runner/providers.js +22 -2
  34. package/dist/main.js +52 -7
  35. package/dist/slices/admission/model.js +8 -0
  36. package/dist/slices/admission/rules.js +47 -0
  37. package/dist/slices/admission/schema.js +11 -1
  38. package/dist/slices/admission/start.js +53 -15
  39. package/dist/slices/backups/files.js +135 -0
  40. package/dist/slices/backups/folder.js +35 -0
  41. package/dist/slices/backups/model.js +65 -0
  42. package/dist/slices/backups/repo.js +28 -0
  43. package/dist/slices/backups/schedule.js +60 -0
  44. package/dist/slices/backups/service.js +311 -0
  45. package/dist/slices/batch/index.js +2 -0
  46. package/dist/slices/estimate/index.js +9 -1
  47. package/dist/slices/library/slots.js +8 -2
  48. package/dist/slices/library/snapshot.js +8 -2
  49. package/dist/slices/notifications/notifier.js +76 -0
  50. package/dist/slices/notifications/rules.js +81 -0
  51. package/dist/slices/notifications/send.js +40 -0
  52. package/dist/slices/notifications/settings.js +34 -0
  53. package/dist/slices/play-drafts/convert.js +35 -3
  54. package/dist/slices/play-drafts/repo.js +7 -1
  55. package/dist/slices/play-drafts/review-inputs.js +2 -0
  56. package/dist/slices/play-drafts/schema.js +20 -2
  57. package/dist/slices/play-drafts/service.js +6 -0
  58. package/dist/slices/play-drafts/uploads.js +2 -1
  59. package/dist/slices/project-templates/from-project.js +20 -0
  60. package/dist/slices/project-templates/setup.js +11 -0
  61. package/dist/slices/rebuild/preview-details.js +24 -2
  62. package/dist/slices/rebuild/recipe-build.js +9 -3
  63. package/dist/slices/rebuild/recipe-input-schema.js +8 -1
  64. package/dist/slices/rebuild/recipe-legacy.js +3 -0
  65. package/dist/slices/rebuild/recipe-reference.js +44 -0
  66. package/dist/slices/rebuild/recipe-shorts.js +11 -4
  67. package/dist/slices/rebuild/recipe-validation.js +15 -1
  68. package/dist/slices/rebuild/recipe-visual.js +20 -8
  69. package/dist/slices/rebuild/runtime-actions.js +9 -2
  70. package/dist/slices/rebuild/runtime-image.js +50 -0
  71. package/dist/slices/rebuild/runtime-local.js +6 -3
  72. package/dist/slices/rebuild/runtime-piece-label.js +2 -0
  73. package/dist/slices/rebuild/runtime-provider.js +17 -10
  74. package/dist/slices/rebuild/runtime-shorts.js +4 -6
  75. package/dist/slices/rebuild/service-readiness.js +15 -1
  76. package/dist/slices/rebuild/transition-repo.js +1 -1
  77. package/dist/slices/revisions/adopt-content.js +7 -0
  78. package/dist/slices/revisions/adopt.js +7 -2
  79. package/dist/slices/revisions/mutation-assets.js +33 -9
  80. package/dist/slices/revisions/mutation-prepare.js +8 -10
  81. package/dist/slices/revisions/schema.js +5 -1
  82. package/dist/slices/schedules/repo.js +1 -1
  83. package/dist/slices/schedules/service.js +6 -1
  84. package/dist/slices/settings/model.js +5 -0
  85. package/dist/slices/storage/layout.js +10 -0
  86. package/dist/slices/storage/model.js +3 -0
  87. package/dist/slices/storage/portable.js +6 -3
  88. package/dist/slices/storage/reconcile.js +5 -1
  89. package/dist/web/assets/index-CktpxlJd.js +171 -0
  90. package/dist/web/assets/index-DB-vOnGU.css +1 -0
  91. package/dist/web/assets/{pdf-CAJNT254.js → pdf-BLF2i8c2.js} +1 -1
  92. package/dist/web/index.html +2 -2
  93. package/package.json +1 -1
  94. package/dist/web/assets/index-BGgYMm2u.css +0 -1
  95. package/dist/web/assets/index-BVVgtntL.js +0 -171
@@ -1,5 +1,7 @@
1
+ import { thinkingModes } from "../kernel/ports/llm.js";
1
2
  import { providerError } from "../kernel/ports/model.js";
2
3
  import { isLocalCliProvider } from "../slices/settings/model.js";
4
+ import { referenceRefusal, takesReferenceImage } from "./schema.js";
3
5
  export function curateRegistry(registry, catalogue) {
4
6
  const requireModel = (provider, family, id) => {
5
7
  const models = catalogue.models(provider, family);
@@ -67,7 +69,7 @@ export function curateRegistry(registry, catalogue) {
67
69
  ...m,
68
70
  ...("llm" in m && m.llm.thinking
69
71
  ? {
70
- thinkingModes: Object.keys(m.llm.thinking).filter((mode) => ["off", "low", "medium", "high", "xhigh"].includes(mode)),
72
+ thinkingModes: Object.keys(m.llm.thinking).filter((mode) => thinkingModes.includes(mode)),
71
73
  }
72
74
  : {}),
73
75
  })),
@@ -100,10 +102,19 @@ export function curateRegistry(registry, catalogue) {
100
102
  ...port,
101
103
  models: () => port.models(),
102
104
  generate: async (request) => {
103
- if (!(await port.models()).some((model) => model.id === request.model))
105
+ const selected = (await port.models()).find((model) => model.id === request.model);
106
+ if (selected === undefined)
104
107
  throw providerError({
105
108
  kind: "unsupported",
106
- message: "Image generation is not available in the Codex CLI on your computer. Update Codex CLI, or choose another image provider in the Edit tab under Edit project, then use Resume.",
109
+ message: request.model === "codex-imagegen"
110
+ ? "Image generation is not available in the Codex CLI on your computer. Update Codex CLI, or choose another image provider in the Edit tab under Edit project, then use Resume."
111
+ : "The chosen Codex model for images is not in the Codex CLI's model list on your computer. Update Codex CLI, or pick another model for images: in the Edit tab, choose Edit project and change it under Providers, then use Resume.",
112
+ });
113
+ if (request.thinking !== undefined &&
114
+ !selected.thinkingModes?.includes(request.thinking))
115
+ throw providerError({
116
+ kind: "unsupported",
117
+ message: "The chosen Codex model does not support this effort for images. In the Edit tab, choose Edit project and change the images' Effort under Providers, then use Resume.",
107
118
  });
108
119
  return port.generate(request);
109
120
  },
@@ -119,6 +130,16 @@ export function curateRegistry(registry, catalogue) {
119
130
  kind: "unsupported",
120
131
  message: "The chosen image model cannot make images in this video's shape. In the Edit tab, choose Edit project and change it under Providers, then use Resume.",
121
132
  });
133
+ if (request.thinking !== undefined)
134
+ throw providerError({
135
+ kind: "unsupported",
136
+ message: "The chosen image model has no effort setting. In the Edit tab, choose Edit project and set the images' Effort back to Model default under Providers, then use Resume.",
137
+ });
138
+ if (request.reference !== undefined && !takesReferenceImage(model))
139
+ throw providerError({
140
+ kind: "unsupported",
141
+ message: referenceRefusal,
142
+ });
122
143
  return port.generate(request);
123
144
  },
124
145
  };
@@ -6,7 +6,7 @@ export async function checkRuntimeModel(modelsFor, provider, family, id, thinkin
6
6
  const model = (await modelsFor(provider, family)).find((row) => row.id === id);
7
7
  if (model === undefined)
8
8
  return "missing";
9
- if (family === "llm" && thinking !== undefined && !model.thinkingModes?.includes(thinking))
9
+ if (family !== "tts" && thinking !== undefined && !model.thinkingModes?.includes(thinking))
10
10
  return "thinking";
11
11
  return "available";
12
12
  }
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { thinkingModes } from "../kernel/ports/llm.js";
2
3
  import { providers } from "../slices/settings/model.js";
3
4
  const money = z.number().finite().nonnegative();
4
5
  const base = z.object({
@@ -29,7 +30,7 @@ export const llmModelSchema = base
29
30
  maxOutputTokens: z.number().int().positive().optional(),
30
31
  webSearch: z.boolean().default(false),
31
32
  thinking: z
32
- .partialRecord(z.enum(["off", "low", "medium", "high", "xhigh"]), z
33
+ .partialRecord(z.enum(thinkingModes), z
33
34
  .object({
34
35
  budget: z.number().int().min(-1).optional(),
35
36
  level: z.enum(["minimal", "low", "medium", "high"]).optional(),
@@ -95,6 +96,14 @@ export const catalogueSchema = z
95
96
  export function isVideoModel(model) {
96
97
  return model.keywords.includes("video");
97
98
  }
99
+ // An image model that also takes an input image - so it can draw with the project's
100
+ // establishing image as its visual reference - carries the `reference` keyword, the same way
101
+ // and for the same reason the image-to-video models carry `video`.
102
+ export function takesReferenceImage(model) {
103
+ return model.keywords.includes("reference");
104
+ }
105
+ // What the run says when the chosen image model cannot take the establishing image.
106
+ export const referenceRefusal = "The chosen image model can't use an establishing image as a reference, so the images can't be drawn to match it. Choose an image model that can (Codex CLI, OpenAI, Google, or a fal.ai model listed with reference) under Images → Model on Play or in Edit project → Providers, or set Establishing image to Off in the Images section.";
98
107
  // The image-to-video models a provider offers, as Animate images lists them.
99
108
  export function videoModelsOf(catalogue, provider) {
100
109
  return catalogue.image.filter((m) => m.provider === provider && m.enabled && !m.deprecated && isVideoModel(m));
@@ -1,7 +1,7 @@
1
- import { usesNarrationPreparation, usesShorts, usesYoutubeDescription, } from "../slices/admission/rules.js";
1
+ import { usesNarrationPreparation, usesReference, usesShorts, usesYoutubeDescription, } from "../slices/admission/rules.js";
2
2
  import { isLocalCliProvider } from "../slices/settings/model.js";
3
3
  import { usesAnimation } from "../slices/video/edit-settings.js";
4
- import { videoModelsOf } from "./schema.js";
4
+ import { referenceRefusal, takesReferenceImage, videoModelsOf } from "./schema.js";
5
5
  export function modelFields(draft, catalogue) {
6
6
  if (!catalogue)
7
7
  return [];
@@ -45,6 +45,11 @@ export function modelFields(draft, catalogue) {
45
45
  field,
46
46
  message: "This model does not support that thinking setting. Choose another.",
47
47
  });
48
+ else if ("image" in model && choice.thinking)
49
+ fields.push({
50
+ field: "images.thinking",
51
+ message: "This image model has no effort setting. Set Effort back to Model default.",
52
+ });
48
53
  else if ("llm" in model && draft.sources.research === "generate" && !model.llm.webSearch)
49
54
  fields.push({
50
55
  field,
@@ -65,6 +70,16 @@ export function modelFields(draft, catalogue) {
65
70
  message: "This image model cannot make the vertical (9:16) images Shorts need. Choose another image model, or turn Shorts off.",
66
71
  });
67
72
  }
73
+ // The establishing image needs a model that takes an input image; the catalogue says which.
74
+ // The Codex CLI always can, so only the keyed providers are looked up.
75
+ if (usesReference(draft) &&
76
+ draft.images !== undefined &&
77
+ !isLocalCliProvider(draft.images.provider)) {
78
+ const images = draft.images;
79
+ const model = catalogue.models(images.provider, "image").find((m) => m.id === images.model);
80
+ if (model !== undefined && !takesReferenceImage(model))
81
+ fields.push({ field: "reference.source", message: referenceRefusal });
82
+ }
68
83
  // Animate images runs on the image provider's image-to-video models.
69
84
  if (usesAnimation(draft) && draft.images !== undefined) {
70
85
  const model = draft.videoEdit?.animateModel ?? "";
@@ -87,6 +87,17 @@ export function createHub(deps) {
87
87
  },
88
88
  };
89
89
  }
90
+ // Every project event also goes to `observe` (the run notifier), after the open pages have
91
+ // it, whether or not a page is open to receive it.
92
+ export function observedHub(hub, observe) {
93
+ return {
94
+ ...hub,
95
+ emit: (projectId, event) => {
96
+ hub.emit(projectId, event);
97
+ observe(event);
98
+ },
99
+ };
100
+ }
90
101
  function messageOf(error) {
91
102
  return error instanceof Error ? error.message : String(error);
92
103
  }
@@ -4,6 +4,7 @@ import { Hono } from "hono";
4
4
  import { streamSSE } from "hono/streaming";
5
5
  import { actionRoutes } from "./actions.js";
6
6
  import { audioPreviewRoutes } from "./audio-preview.js";
7
+ import { backupRoutes } from "./backups.js";
7
8
  import { checkpointRoutes } from "./checkpoints.js";
8
9
  import { diagnosticsRoutes } from "./diagnostics.js";
9
10
  import { documentThemeRoutes } from "./document-themes.js";
@@ -41,6 +42,7 @@ function apiRoutes(deps, startedAt) {
41
42
  }))
42
43
  .route("/staging", stagingRoutes(deps))
43
44
  .route("/storage", storageRoutes(deps))
45
+ .route("/backups", backupRoutes(deps.backups))
44
46
  .route("/drafts", draftRoutes(deps.drafts))
45
47
  .route("/diagnostics", diagnosticsRoutes(deps))
46
48
  .route("/project-templates", projectTemplateRoutes(deps.drafts))
@@ -0,0 +1,40 @@
1
+ import { zValidator } from "@hono/zod-validator";
2
+ import { Hono } from "hono";
3
+ import { HTTPException } from "hono/http-exception";
4
+ import { backupConfigInputSchema } from "../../slices/backups/model.js";
5
+ import { onInvalid, problem, titleOf } from "./problem.js";
6
+ // Settings → Backups: the schedule, the folder, the last result and Back up now. Writing an
7
+ // archive takes minutes, so Back up now answers at once and the screen polls this view.
8
+ export function backupRoutes(service) {
9
+ const backups = () => {
10
+ if (service === undefined)
11
+ throw new HTTPException(404);
12
+ return service;
13
+ };
14
+ return new Hono()
15
+ .get("/", async (c) => {
16
+ c.header("Cache-Control", "no-store");
17
+ return c.json(await backups().view());
18
+ })
19
+ .put("/", zValidator("json", backupConfigInputSchema, onInvalid), async (c) => {
20
+ const saved = backups().save(c.req.valid("json"));
21
+ if (!saved.ok)
22
+ return problem(c, {
23
+ status: 400,
24
+ title: titleOf(400),
25
+ detail: saved.detail,
26
+ extensions: { field: "folder" },
27
+ });
28
+ return c.json(await backups().view());
29
+ })
30
+ .post("/run", async (c) => {
31
+ const started = backups().runNow();
32
+ if (!started.ok)
33
+ return problem(c, {
34
+ status: started.status,
35
+ title: titleOf(started.status),
36
+ detail: started.detail,
37
+ });
38
+ return c.json(await backups().view(), 202);
39
+ });
40
+ }
@@ -6,6 +6,7 @@ import { z } from "zod";
6
6
  import { sniffImage } from "../../adapters/image/bytes.js";
7
7
  import { redact } from "../../kernel/log.js";
8
8
  import { bridgeLimits, HostFolderRefused, hostCliIds, hostCliProtocol, hostFrameSchema, hostImageSchema, hostLlmIds, hostLlmSchema, hostModelsSchema, hostOpenFolderSchema, hostStatusSchema, } from "../../kernel/ports/host-cli.js";
9
+ import { agentImageTimeoutMs } from "../../kernel/ports/image.js";
9
10
  import { isProviderError, providerError } from "../../kernel/ports/model.js";
10
11
  export function hostGate() {
11
12
  let accepting = true;
@@ -226,9 +227,23 @@ export function hostCliRoutes(options) {
226
227
  const job = c.get("job");
227
228
  const controller = new AbortController();
228
229
  const signal = AbortSignal.any([job.signal, controller.signal]);
229
- const timer = setTimeout(() => controller.abort(), 300_000);
230
+ const timer = setTimeout(() => controller.abort(), agentImageTimeoutMs);
230
231
  try {
231
- const result = await ports.image.generate({ ...body.data, signal });
232
+ const { reference, thinking, ...rest } = body.data;
233
+ const image = reference === undefined
234
+ ? undefined
235
+ : {
236
+ bytes: new Uint8Array(Buffer.from(reference.base64, "base64")),
237
+ mime: reference.mime,
238
+ };
239
+ if (image !== undefined && sniffImage(image.bytes) !== image.mime)
240
+ return invalid(c);
241
+ const result = await ports.image.generate({
242
+ ...rest,
243
+ ...(thinking === undefined ? {} : { thinking }),
244
+ ...(image === undefined ? {} : { reference: image }),
245
+ signal,
246
+ });
232
247
  if (signal.aborted)
233
248
  throw providerError({ kind: "unavailable", message: unavailable });
234
249
  if (result.bytes.byteLength > bridgeLimits.image || sniffImage(result.bytes) !== result.mime)
@@ -4,6 +4,7 @@ const titles = {
4
4
  404: "Not Found",
5
5
  409: "Conflict",
6
6
  415: "Unsupported Media Type",
7
+ 502: "Bad Gateway",
7
8
  500: "Internal Server Error",
8
9
  };
9
10
  export function problem(c, init) {
@@ -18,7 +18,7 @@ function refused(c, result) {
18
18
  conflict: "This schedule changed while you were editing, possibly because a run just started. Reload the page to see the latest, then make your change again.",
19
19
  "cancel-required": "This schedule is still active. Cancel it first, then delete it. Completed schedules can be deleted straight away.",
20
20
  "missing-template": "The template you picked was deleted or changed. Choose a template again, then save.",
21
- "unsupported-media": "This template uses audio, images or a thumbnail you supplied, so it cannot run on a schedule. Pick a template that generates these instead.",
21
+ "unsupported-media": "This template uses audio, images, a thumbnail or shorts background music you supplied, so it cannot run on a schedule: a scheduled run has no one to attach the file again. Pick a template that generates these instead, or remove the music under Outputs → Export → More shorts options on Play and save the template again.",
22
22
  "not-due": "This one-off time has already passed. Choose a time in the future.",
23
23
  "spend-limit": "The estimated cost is above this schedule's spend limit, or some prices are unknown. Raise the spend limit or choose models with known prices.",
24
24
  readiness: "A provider this template needs is not ready, for example a missing API key, model or voice. Check Settings → Providers, then try again.",
@@ -1,6 +1,9 @@
1
1
  import { zValidator } from "@hono/zod-validator";
2
2
  import { Hono } from "hono";
3
3
  import { z } from "zod";
4
+ import { notificationUrlMax, notificationUrlProblem, testNotice, webhookBody, } from "../../slices/notifications/rules.js";
5
+ import { createNotificationSender, sendFailureText } from "../../slices/notifications/send.js";
6
+ import { readNotificationUrl, saveNotificationUrl } from "../../slices/notifications/settings.js";
4
7
  import { appearances, providerIds } from "../../slices/settings/model.js";
5
8
  import { readSettings, saveSettings } from "../../slices/settings/playback.js";
6
9
  import { addVoice, removeVoice, voiceIdMax, voiceNameMax, voices, } from "../../slices/settings/voices.js";
@@ -13,6 +16,9 @@ const voiceBody = z.object({
13
16
  name: z.string(),
14
17
  voiceId: z.string(),
15
18
  });
19
+ // Length and scheme are the slice's rules, so their sentences reach the field; this bound only
20
+ // keeps a pasted novel off the parser.
21
+ const notificationBody = z.object({ url: z.string().max(notificationUrlMax * 2) });
16
22
  const playbackBody = z.object({
17
23
  silenceGapSeconds: z.number(),
18
24
  appearance: z.enum(appearances),
@@ -22,6 +28,7 @@ const playbackBody = z.object({
22
28
  export function settingsRoutes(deps) {
23
29
  const playback = { db: deps.db, log: deps.log };
24
30
  const voiceDeps = { db: deps.db, ids: deps.ids };
31
+ const send = deps.sendNotification ?? createNotificationSender(globalThis.fetch);
25
32
  return new Hono()
26
33
  .get("/", (c) => c.json(readSettings(playback)))
27
34
  .put("/", zValidator("json", playbackBody, onInvalid), (c) => {
@@ -35,6 +42,43 @@ export function settingsRoutes(deps) {
35
42
  });
36
43
  }
37
44
  return c.json(result.settings);
45
+ })
46
+ .get("/notifications", (c) => c.json({ url: readNotificationUrl(deps.db) }))
47
+ .put("/notifications", zValidator("json", notificationBody, onInvalid), (c) => {
48
+ const result = saveNotificationUrl(deps.db, c.req.valid("json").url);
49
+ if (!result.ok) {
50
+ return problem(c, {
51
+ status: 400,
52
+ title: titleOf(400),
53
+ detail: `The Notification URL wasn't saved: ${result.message} Fix it in Settings → Notifications → Notification URL, then press Save.`,
54
+ extensions: { fields: [{ field: "url", message: result.message }] },
55
+ });
56
+ }
57
+ return c.json({ url: result.url });
58
+ })
59
+ .post("/notifications/test", zValidator("json", notificationBody, onInvalid), async (c) => {
60
+ const url = c.req.valid("json").url.trim();
61
+ const invalid = url === ""
62
+ ? "Enter a Notification URL first, for example https://ntfy.sh/your-topic."
63
+ : notificationUrlProblem(url);
64
+ if (invalid !== undefined) {
65
+ return problem(c, {
66
+ status: 400,
67
+ title: titleOf(400),
68
+ detail: `The test notification wasn't sent: ${invalid}`,
69
+ extensions: { fields: [{ field: "url", message: invalid }] },
70
+ });
71
+ }
72
+ const result = await send(url, webhookBody(testNotice));
73
+ if (!result.ok) {
74
+ deps.log.write("warn", "notification.test", { detail: sendFailureText(result) });
75
+ return problem(c, {
76
+ status: 502,
77
+ title: titleOf(502),
78
+ detail: `The test notification wasn't delivered: ${sendFailureText(result)}. Check the address in Settings → Notifications → Notification URL (for ntfy, https://ntfy.sh/your-topic), then press Send test notification again.`,
79
+ });
80
+ }
81
+ return c.json({ sent: true });
38
82
  })
39
83
  .get("/voices", (c) => c.json({ voices: voices(voiceDeps) }))
40
84
  .post("/voices", zValidator("json", voiceBody, onInvalid), (c) => {
@@ -1,7 +1,7 @@
1
1
  import { mkdtemp, rm } from "node:fs/promises";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
- import { codexImage } from "../adapters/image/codex.js";
4
+ import { codexImage, codexImageTimeoutMs } from "../adapters/image/codex.js";
5
5
  import { claudeCodeLlm } from "../adapters/llm/claude-code.js";
6
6
  import { nodeClaudeCodeModels } from "../adapters/llm/claude-code-models.js";
7
7
  import { codexLlm } from "../adapters/llm/codex.js";
@@ -75,7 +75,8 @@ export function createHostRuntime(deps) {
75
75
  }),
76
76
  image: {
77
77
  id: "codex-image",
78
- models: () => codexImage({ run: deps.run }).models(),
78
+ timeoutMs: codexImageTimeoutMs,
79
+ models: () => codexImage({ run: deps.run, readModels: () => models("codex") }).models(),
79
80
  generate: async (request) => {
80
81
  const binary = await command("codex-image");
81
82
  return codexImage({ run: deps.run, binary, env: deps.env }).generate(request);
@@ -0,0 +1,56 @@
1
+ -- Before 2.4.0 every save made while a step waited for Resume minted a new held row for it,
2
+ -- even when the project already had a row for exactly that step (same key and fingerprint).
3
+ -- Save now reserves the existing one instead (existingWork in
4
+ -- slices/rebuild/transition-repo.ts); this removes the copies left behind by older saves.
5
+ --
6
+ -- A row counts for a step the way Save counts it: a finished row (work and piece done), or a
7
+ -- held row nothing ran for (pending, held, unsent, no planning context, no attempt). Per
8
+ -- project, key and fingerprint, the row Save would pick is kept: a finished one first,
9
+ -- otherwise the newest held one. Only held rows that are not that pick are removed, and only
10
+ -- when they carry one piece and no review checkpoint. A revision that reserved a removed
11
+ -- copy is pointed at the kept held row, which is the same unstarted step. A copy some
12
+ -- revision reserved while a finished row exists is left alone: pointing that revision at
13
+ -- the finished result would change what it shows. Finished, running, failed and admitted
14
+ -- work, and assets, are never touched.
15
+ CREATE TEMP TABLE m0022_eligible AS
16
+ SELECT w.id AS work_id, p.id AS piece_id, w.project_id, p.work_key, p.fingerprint,
17
+ w.rowid AS rid, w.state = 'done' AS done
18
+ FROM revision_work w JOIN revision_work_pieces p ON p.work_id = w.id
19
+ WHERE (w.state = 'done' AND p.state = 'done')
20
+ OR (w.state = 'pending' AND w.dispatch_state = 'held' AND p.state = 'held'
21
+ AND p.submitted_at IS NULL AND w.recipe_context IS NULL
22
+ AND NOT EXISTS (SELECT 1 FROM attempts a WHERE a.work_id = w.id));
23
+
24
+ CREATE TEMP TABLE m0022_kept AS
25
+ SELECT project_id, work_key, fingerprint, work_id, piece_id, done FROM (
26
+ SELECT e.*, row_number() OVER (
27
+ PARTITION BY project_id, work_key, fingerprint ORDER BY done DESC, rid DESC
28
+ ) AS pick
29
+ FROM m0022_eligible e
30
+ ) WHERE pick = 1;
31
+
32
+ CREATE TEMP TABLE m0022_copies AS
33
+ SELECT e.work_id, k.work_id AS kept_work_id, k.piece_id AS kept_piece_id
34
+ FROM m0022_eligible e
35
+ JOIN m0022_kept k ON k.project_id = e.project_id AND k.work_key = e.work_key
36
+ AND k.fingerprint = e.fingerprint
37
+ WHERE e.done = 0 AND e.work_id <> k.work_id
38
+ AND (SELECT count(*) FROM revision_work_pieces q WHERE q.work_id = e.work_id) = 1
39
+ AND NOT EXISTS (SELECT 1 FROM review_checkpoints c WHERE c.work_id = e.work_id)
40
+ AND (k.done = 0
41
+ OR NOT EXISTS (SELECT 1 FROM revision_work_reservations r WHERE r.work_id = e.work_id));
42
+
43
+ UPDATE revision_work_reservations
44
+ SET piece_id = CASE WHEN piece_id IS NULL THEN NULL ELSE (
45
+ SELECT c.kept_piece_id FROM m0022_copies c WHERE c.work_id = revision_work_reservations.work_id
46
+ ) END,
47
+ work_id = (
48
+ SELECT c.kept_work_id FROM m0022_copies c WHERE c.work_id = revision_work_reservations.work_id
49
+ )
50
+ WHERE work_id IN (SELECT work_id FROM m0022_copies);
51
+
52
+ DELETE FROM revision_work WHERE id IN (SELECT work_id FROM m0022_copies);
53
+
54
+ DROP TABLE m0022_eligible;
55
+ DROP TABLE m0022_kept;
56
+ DROP TABLE m0022_copies;
@@ -0,0 +1,18 @@
1
+ -- A Play draft can carry an uploaded establishing image (the Images stage's reference). SQLite
2
+ -- cannot widen a CHECK constraint in place, so the attachments table is rebuilt with the new
3
+ -- kind. Nothing references it, so enforcement stays on.
4
+ CREATE TABLE play_draft_attachments_reference_upgrade (
5
+ id TEXT PRIMARY KEY,
6
+ draft_id TEXT NOT NULL REFERENCES play_drafts(id) ON DELETE CASCADE,
7
+ staged_file_id TEXT REFERENCES staged_files(id) ON DELETE SET NULL,
8
+ kind TEXT NOT NULL CHECK(kind IN ('audio','images','thumbnail','reference')),
9
+ original_filename TEXT NOT NULL,
10
+ status TEXT NOT NULL CHECK(status IN ('pending','ready','reattach')),
11
+ error TEXT
12
+ );
13
+ INSERT INTO play_draft_attachments_reference_upgrade (id,draft_id,staged_file_id,kind,original_filename,status,error)
14
+ SELECT id,draft_id,staged_file_id,kind,original_filename,status,error FROM play_draft_attachments ORDER BY rowid;
15
+ DROP TABLE play_draft_attachments;
16
+ ALTER TABLE play_draft_attachments_reference_upgrade RENAME TO play_draft_attachments;
17
+ CREATE INDEX play_draft_attachment_file ON play_draft_attachments(staged_file_id);
18
+ CREATE INDEX play_draft_attachment_owner ON play_draft_attachments(draft_id);
@@ -13,6 +13,8 @@ export const bridgeLimits = {
13
13
  frame: 4 * 1024 * 1024,
14
14
  stream: 64 * 1024 * 1024,
15
15
  image: 32 * 1024 * 1024,
16
+ // An establishing image sent along with an image request; base64 keeps it inside `request`.
17
+ reference: 10 * 1024 * 1024,
16
18
  text: 2 * 1024 * 1024,
17
19
  generation: 5,
18
20
  metadata: 8,
@@ -39,9 +41,22 @@ export const hostLlmSchema = z
39
41
  .strict();
40
42
  export const hostImageSchema = z
41
43
  .object({
42
- model: z.literal("codex-imagegen"),
44
+ // "codex-imagegen" is the Codex default; any other is one of the Codex CLI's models.
45
+ model: short.refine((v) => !v.startsWith("-") && !/\s/.test(v)),
43
46
  prompt: z.string().min(1).max(bridgeLimits.text),
44
47
  aspect: z.enum(["16:9", "9:16"]),
48
+ thinking: z.enum(thinkingModes).optional(),
49
+ // The establishing image, as base64 (at most `bridgeLimits.reference` bytes decoded).
50
+ reference: z
51
+ .object({
52
+ mime: z.enum(["image/png", "image/jpeg"]),
53
+ base64: z
54
+ .string()
55
+ .max(Math.ceil(bridgeLimits.reference / 3) * 4)
56
+ .regex(/^[A-Za-z0-9+/]*={0,2}$/),
57
+ })
58
+ .strict()
59
+ .optional(),
45
60
  })
46
61
  .strict();
47
62
  export const hostOpenFolderSchema = z
@@ -73,7 +88,7 @@ export const hostModelsSchema = z
73
88
  id: short,
74
89
  name: short,
75
90
  group: short.optional(),
76
- thinkingModes: z.array(z.enum(thinkingModes)).max(5).optional(),
91
+ thinkingModes: z.array(z.enum(thinkingModes)).max(thinkingModes.length).optional(),
77
92
  })
78
93
  .strict())
79
94
  .max(1000),
@@ -1 +1,3 @@
1
- export {};
1
+ // How long an agent-driven image job (the Codex CLI reviewing and redrawing its image) may
2
+ // run, on this machine or through the host helper.
3
+ export const agentImageTimeoutMs = 30 * 60_000;
@@ -1,2 +1,4 @@
1
1
  export const messageRoles = ["system", "user", "assistant"];
2
- export const thinkingModes = ["off", "low", "medium", "high", "xhigh"];
2
+ // "max" and "ultra" are the Codex CLI's two highest reasoning efforts (its model list reports
3
+ // them per model); the catalogue's models never offer them.
4
+ export const thinkingModes = ["off", "low", "medium", "high", "xhigh", "max", "ultra"];
@@ -25,7 +25,7 @@ const terminalKinds = [
25
25
  ];
26
26
  export async function attempt(ctx, call, opts) {
27
27
  ctx.signal.throwIfAborted();
28
- const limit = timeoutMs[opts.kind];
28
+ const limit = opts.timeoutMs ?? timeoutMs[opts.kind];
29
29
  for (let n = 1;; n += 1) {
30
30
  ctx.signal.throwIfAborted();
31
31
  const retrieving = ctx.continuation?.read() !== undefined;
@@ -141,14 +141,34 @@ export function stageProviders(deps, context, pieceId) {
141
141
  },
142
142
  image: (call) => {
143
143
  const port = deps.registry.image(call.provider);
144
+ const callId = randomUUID();
145
+ // A long job (the Codex CLI reviewing and redrawing its image) says how far it has got
146
+ // on the stage's live panel; the other providers report nothing and show no panel.
147
+ const onProgress = (text) => context.emit({
148
+ type: "llm.preview",
149
+ ...(pieceId === undefined ? {} : { workPieceId: pieceId }),
150
+ projectId: context.stage.projectId,
151
+ stage: context.stage.kind,
152
+ callId,
153
+ label: call.previewLabel ?? "Image",
154
+ text,
155
+ reset: true,
156
+ });
144
157
  return schedule(call.provider, () => attempt(ctx, (signal) => port.generate({
145
158
  model: call.model,
146
159
  prompt: call.prompt,
147
160
  aspect: call.aspect,
161
+ ...(call.thinking === undefined ? {} : { thinking: call.thinking }),
162
+ ...(call.reference === undefined ? {} : { reference: call.reference }),
163
+ onProgress,
148
164
  signal,
149
165
  }),
150
- // One request, one answer: the 300 s runs over the whole call.
151
- { kind: "image" }));
166
+ // One request, one answer: the limit runs over the whole call - 300 s, or the
167
+ // provider's own when it needs longer.
168
+ {
169
+ kind: "image",
170
+ ...(port.timeoutMs === undefined ? {} : { timeoutMs: port.timeoutMs }),
171
+ }));
152
172
  },
153
173
  animate: (call) => {
154
174
  const port = deps.registry.image(call.provider);