@gentbajko/slopify 0.5.1 → 0.7.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 (104) hide show
  1. package/README.md +36 -1
  2. package/SUBTITLES.md +21 -0
  3. package/dist/adapter-registry.js +18 -3
  4. package/dist/adapters/alignment/audio.js +45 -0
  5. package/dist/adapters/alignment/cache.js +91 -0
  6. package/dist/adapters/alignment/ctc.js +128 -0
  7. package/dist/adapters/alignment/index.js +34 -0
  8. package/dist/adapters/alignment/lock.js +58 -0
  9. package/dist/adapters/alignment/numbers.js +91 -0
  10. package/dist/adapters/alignment/protocol.js +23 -0
  11. package/dist/adapters/alignment/quality.js +17 -0
  12. package/dist/adapters/alignment/runner.js +76 -0
  13. package/dist/adapters/alignment/text.js +61 -0
  14. package/dist/adapters/alignment/vocabulary.js +69 -0
  15. package/dist/adapters/alignment/worker.js +163 -0
  16. package/dist/adapters/image/google.js +12 -15
  17. package/dist/adapters/image/models.js +72 -0
  18. package/dist/adapters/image/openai.js +3 -5
  19. package/dist/adapters/llm/catalogue-files.js +23 -0
  20. package/dist/adapters/llm/claude-code.js +9 -7
  21. package/dist/adapters/llm/codex-models.js +46 -0
  22. package/dist/adapters/llm/codex.js +7 -12
  23. package/dist/adapters/llm/gemini-models.js +85 -0
  24. package/dist/adapters/llm/gemini-workspace.js +70 -0
  25. package/dist/adapters/llm/gemini.js +138 -0
  26. package/dist/adapters/llm/openrouter.js +11 -2
  27. package/dist/adapters/llm/run-cli.js +6 -9
  28. package/dist/adapters/tts/cartesia.js +9 -1
  29. package/dist/adapters/tts/elevenlabs.js +33 -2
  30. package/dist/adapters/tts/inworld-async.js +150 -0
  31. package/dist/adapters/tts/inworld-text.js +32 -0
  32. package/dist/adapters/tts/inworld.js +170 -0
  33. package/dist/adapters/tts/openai.js +31 -4
  34. package/dist/assets/fonts/Barlow-Regular.ttf +0 -0
  35. package/dist/assets/fonts/OFL.txt +93 -0
  36. package/dist/assets/fonts/SOURCE.txt +7 -0
  37. package/dist/edge/cli.js +5 -0
  38. package/dist/edge/events/hub.js +9 -2
  39. package/dist/edge/events/preview-cache.js +40 -0
  40. package/dist/edge/http/actions.js +2 -0
  41. package/dist/edge/http/app.js +30 -1
  42. package/dist/edge/http/audio-preview.js +46 -0
  43. package/dist/edge/http/fonts.js +147 -0
  44. package/dist/edge/http/projects.js +23 -1
  45. package/dist/edge/http/providers.js +29 -0
  46. package/dist/edge/http/subtitles.js +92 -0
  47. package/dist/edge/http/update.js +57 -0
  48. package/dist/edge/update-worker.js +44 -0
  49. package/dist/kernel/audio-preview.js +188 -0
  50. package/dist/kernel/cli-command.js +45 -0
  51. package/dist/kernel/ports/subtitles.js +1 -0
  52. package/dist/kernel/runner/providers.js +75 -24
  53. package/dist/main.js +106 -25
  54. package/dist/model-catalog.js +37 -0
  55. package/dist/slices/admission/repo.js +2 -0
  56. package/dist/slices/admission/rules.js +6 -0
  57. package/dist/slices/control/index.js +1 -0
  58. package/dist/slices/control/providers.js +2 -0
  59. package/dist/slices/fonts/catalog.js +95 -0
  60. package/dist/slices/fonts/discovery.js +81 -0
  61. package/dist/slices/fonts/files.js +37 -0
  62. package/dist/slices/fonts/index.js +5 -0
  63. package/dist/slices/fonts/model.js +4 -0
  64. package/dist/slices/fonts/preview.js +14 -0
  65. package/dist/slices/fonts/sfnt.js +262 -0
  66. package/dist/slices/fonts/upload.js +40 -0
  67. package/dist/slices/narration/live.js +21 -0
  68. package/dist/slices/narration/run.js +9 -9
  69. package/dist/slices/research/run.js +3 -0
  70. package/dist/slices/settings/cli-paths.js +98 -0
  71. package/dist/slices/settings/cli-status.js +21 -12
  72. package/dist/slices/settings/model.js +11 -0
  73. package/dist/slices/settings/models.js +89 -0
  74. package/dist/slices/settings/readiness.js +13 -9
  75. package/dist/slices/storage/downloads.js +2 -0
  76. package/dist/slices/storage/layout.js +10 -0
  77. package/dist/slices/storage/model.js +5 -0
  78. package/dist/slices/storage/repo.js +1 -0
  79. package/dist/slices/subtitles/captions.js +91 -0
  80. package/dist/slices/subtitles/layout.js +19 -0
  81. package/dist/slices/subtitles/model.js +27 -0
  82. package/dist/slices/subtitles/prepare.js +167 -0
  83. package/dist/slices/subtitles/transcript.js +34 -0
  84. package/dist/slices/video/audio-export.js +3 -0
  85. package/dist/slices/video/ffmpeg.js +14 -7
  86. package/dist/slices/video/plan.js +4 -4
  87. package/dist/slices/video/run.js +5 -2
  88. package/dist/slices/video/write-export.js +85 -20
  89. package/dist/updater/candidate.js +28 -0
  90. package/dist/updater/forward.js +35 -0
  91. package/dist/updater/install-flow.js +26 -0
  92. package/dist/updater/install.js +50 -0
  93. package/dist/updater/model.js +24 -0
  94. package/dist/updater/plan.js +135 -0
  95. package/dist/updater/readiness.js +15 -0
  96. package/dist/updater/registry.js +19 -0
  97. package/dist/updater/service.js +128 -0
  98. package/dist/updater/worker.js +136 -0
  99. package/dist/web/assets/index-6QIQ-TzO.css +1 -0
  100. package/dist/web/assets/index-CNgWL-ct.js +130 -0
  101. package/dist/web/index.html +2 -2
  102. package/package.json +4 -2
  103. package/dist/web/assets/index-C1fsrIP5.css +0 -1
  104. package/dist/web/assets/index-DwJ_XTl3.js +0 -81
@@ -0,0 +1,147 @@
1
+ import { Readable } from "node:stream";
2
+ import { pipeline } from "node:stream/promises";
3
+ import { Busboy } from "@fastify/busboy";
4
+ import { zValidator } from "@hono/zod-validator";
5
+ import { Hono } from "hono";
6
+ import { z } from "zod";
7
+ import { fontIdPattern, fontMaxBytes, isMissingFont, listFonts, previewFont, uploadFont, } from "../../slices/fonts/index.js";
8
+ import { problem, titleOf } from "./problem.js";
9
+ const idParam = z.object({ id: z.string().regex(fontIdPattern) });
10
+ // Inferred so the Hono client retains all response shapes.
11
+ export function fontsRoutes(deps) {
12
+ return new Hono()
13
+ .get("/", async (c) => c.json({ fonts: await listFonts(deps.paths) }))
14
+ .post("/", async (c) => {
15
+ const contentType = c.req.header("content-type");
16
+ if (contentType === undefined || !contentType.toLowerCase().startsWith("multipart/form-data"))
17
+ return problem(c, {
18
+ status: 415,
19
+ title: titleOf(415),
20
+ detail: "Upload one .ttf or .otf file as multipart/form-data.",
21
+ });
22
+ const body = c.req.raw.body;
23
+ if (body === null)
24
+ return problem(c, {
25
+ status: 400,
26
+ title: titleOf(400),
27
+ detail: "The request carried no font file.",
28
+ });
29
+ const part = await readUpload(contentType, body);
30
+ if (!part.ok) {
31
+ const tooLarge = part.reason === "too-large";
32
+ return problem(c, {
33
+ status: tooLarge ? 413 : 400,
34
+ title: tooLarge ? "Content Too Large" : titleOf(400),
35
+ detail: tooLarge
36
+ ? "A font file must be no larger than 32 MiB."
37
+ : "Upload exactly one complete font file in the file part.",
38
+ });
39
+ }
40
+ const result = await uploadFont(deps.paths, part.upload);
41
+ if (!result.ok)
42
+ return problem(c, failure(result.reason));
43
+ return c.json({ font: result.font }, 201);
44
+ })
45
+ .get("/:id/file", zValidator("param", idParam, (result, c) => {
46
+ if (!result.success)
47
+ return problem(c, { status: 404, title: titleOf(404), detail: "No font has that id." });
48
+ return undefined;
49
+ }), async (c) => {
50
+ try {
51
+ const preview = await previewFont(deps.paths, c.req.valid("param").id);
52
+ return c.body(Uint8Array.from(preview.content), 200, {
53
+ "content-type": preview.contentType,
54
+ "x-content-type-options": "nosniff",
55
+ "cache-control": "private, max-age=300",
56
+ });
57
+ }
58
+ catch (error) {
59
+ if (!isMissingFont(error))
60
+ throw error;
61
+ return problem(c, { status: 404, title: titleOf(404), detail: "No font has that id." });
62
+ }
63
+ });
64
+ }
65
+ // Bounded buffering is intentional for small font files. No file is committed until
66
+ // the entire multipart envelope finishes, so a second part or truncation cannot save it.
67
+ async function readUpload(contentType, body) {
68
+ let parser;
69
+ try {
70
+ parser = new Busboy({
71
+ headers: { "content-type": contentType },
72
+ preservePath: true,
73
+ limits: { files: 1, fields: 0, parts: 1, fileSize: fontMaxBytes + 1 },
74
+ });
75
+ }
76
+ catch {
77
+ return { ok: false, reason: "multipart" };
78
+ }
79
+ const chunks = [];
80
+ let filename;
81
+ let count = 0;
82
+ let invalid = false;
83
+ let tooLarge = false;
84
+ parser.on("file", (field, stream, name) => {
85
+ filename = name;
86
+ if (field !== "file")
87
+ invalid = true;
88
+ stream.on("data", (chunk) => {
89
+ count += chunk.byteLength;
90
+ if (count > fontMaxBytes)
91
+ tooLarge = true;
92
+ else
93
+ chunks.push(chunk);
94
+ });
95
+ stream.on("limit", () => {
96
+ tooLarge = true;
97
+ });
98
+ stream.on("error", (error) => {
99
+ invalid = true;
100
+ parser.destroy(error);
101
+ });
102
+ });
103
+ parser.on("filesLimit", () => {
104
+ invalid = true;
105
+ });
106
+ parser.on("fieldsLimit", () => {
107
+ invalid = true;
108
+ });
109
+ parser.on("partsLimit", () => {
110
+ invalid = true;
111
+ });
112
+ try {
113
+ await pipeline(Readable.fromWeb(body), parser);
114
+ }
115
+ catch {
116
+ return { ok: false, reason: "multipart" };
117
+ }
118
+ if (tooLarge)
119
+ return { ok: false, reason: "too-large" };
120
+ if (invalid || filename === undefined)
121
+ return { ok: false, reason: "multipart" };
122
+ return { ok: true, upload: { filename, content: Buffer.concat(chunks, count) } };
123
+ }
124
+ function failure(reason) {
125
+ switch (reason) {
126
+ case "too-large":
127
+ return {
128
+ status: 413,
129
+ title: "Content Too Large",
130
+ detail: "A font file must be no larger than 32 MiB.",
131
+ };
132
+ case "unsupported-format":
133
+ return { status: 415, title: titleOf(415), detail: "Choose a .ttf or .otf font file." };
134
+ case "unsafe-filename":
135
+ return {
136
+ status: 400,
137
+ title: titleOf(400),
138
+ detail: "The font filename cannot contain a path or control character.",
139
+ };
140
+ case "invalid-font":
141
+ return {
142
+ status: 400,
143
+ title: titleOf(400),
144
+ detail: "This file is not a supported, valid TrueType or OpenType font.",
145
+ };
146
+ }
147
+ }
@@ -6,6 +6,7 @@ import { listProjects, projectById, runDraftSchema, stageStandingsByProject, sta
6
6
  import { admit } from "../../slices/admission/rules.js";
7
7
  import { startRun } from "../../slices/admission/start.js";
8
8
  import { withProjectControl } from "../../slices/control/lock.js";
9
+ import { resolveFont } from "../../slices/fonts/index.js";
9
10
  import { pickTemplates, renderPicked } from "../../slices/library/slots.js";
10
11
  import { deleteProject } from "../../slices/storage/delete-project.js";
11
12
  import { outputsOf, stagedFiles } from "../../slices/storage/repo.js";
@@ -44,7 +45,28 @@ export function projectRoutes(deps) {
44
45
  status: derive(stagesOf(deps.db, project.id), project.paused),
45
46
  });
46
47
  return (new Hono()
47
- .post("/", zValidator("json", runDraftSchema, onInvalid), (c) => {
48
+ .post("/", zValidator("json", runDraftSchema, onInvalid), async (c) => {
49
+ const requestedSubtitles = c.req.valid("json").subtitles;
50
+ if (requestedSubtitles !== undefined && requestedSubtitles.mode !== "off") {
51
+ try {
52
+ await resolveFont(deps.paths, requestedSubtitles.fontId);
53
+ }
54
+ catch {
55
+ return problem(c, {
56
+ status: 400,
57
+ title: titleOf(400),
58
+ detail: "Choose an available subtitle font before starting.",
59
+ extensions: {
60
+ fields: [
61
+ {
62
+ field: "subtitles.fontId",
63
+ message: "This font is no longer available; choose another or upload it again.",
64
+ },
65
+ ],
66
+ },
67
+ });
68
+ }
69
+ }
48
70
  // The bodies are read here, at the click, so an edit made since the
49
71
  // prompt was selected is the one that runs.
50
72
  const picked = pickTemplates(deps.db, c.req.valid("json"));
@@ -1,30 +1,58 @@
1
1
  import { zValidator } from "@hono/zod-validator";
2
2
  import { Hono } from "hono";
3
3
  import { z } from "zod";
4
+ import { cliPathMaxLength, saveCliPath } from "../../slices/settings/cli-paths.js";
4
5
  import { keyStatus, removeProviderKey, saveProviderKey } from "../../slices/settings/keys.js";
5
6
  import { providerById, providerIds } from "../../slices/settings/model.js";
7
+ import { createModelCatalog } from "../../slices/settings/models.js";
6
8
  import { providerStatuses } from "../../slices/settings/readiness.js";
7
9
  import { onInvalid, problem, titleOf } from "./problem.js";
8
10
  const providerParam = z.object({ id: z.enum(providerIds) });
9
11
  // ceiling: no format check is allowed on a key, so the only thing said about the value is
10
12
  // that it is a string of a length a key could plausibly have. Raise the bound if a provider
11
13
  // ever issues something longer.
14
+ const pathBody = z.object({ path: z.string().max(cliPathMaxLength) });
12
15
  const keyBody = z.object({ key: z.string().min(1).max(4096) });
13
16
  // The return type is inferred so Hono keeps the route types the SPA's client is
14
17
  // generated from; see stagingRoutes.
15
18
  export function providerRoutes(deps) {
16
19
  const keys = { db: deps.db, clock: deps.clock };
17
20
  const readiness = { db: deps.db, probe: deps.probe };
21
+ const catalog = createModelCatalog({
22
+ load: deps.modelsFor ?? (() => Promise.reject(new Error("Model catalog unavailable"))),
23
+ fallback: deps.fallbackModelsFor ?? (() => []),
24
+ now: () => deps.clock.now().getTime(),
25
+ report: (provider) => deps.log.write("warn", "model-catalog", { detail: `Could not load models for ${provider}` }),
26
+ });
18
27
  return (new Hono()
19
28
  // What Settings draws its rails from and Play its dropdowns: every provider, with
20
29
  // the one fact that decides whether it is selectable.
21
30
  .get("/", async (c) => c.json({ providers: await providerStatuses(readiness) }))
31
+ .get("/:id/models", zValidator("param", providerParam, onInvalid), async (c) => {
32
+ const { id } = c.req.valid("param");
33
+ const result = await catalog.get(id, providerById(id).family, c.req.query("refresh") === "1");
34
+ c.header("Cache-Control", "no-store");
35
+ return c.json(result);
36
+ })
37
+ .put("/:id/path", zValidator("param", providerParam, onInvalid), zValidator("json", pathBody, onInvalid), async (c) => {
38
+ const result = await saveCliPath(readiness, c.req.valid("param").id, c.req.valid("json").path);
39
+ if (!result.ok)
40
+ return problem(c, {
41
+ status: 400,
42
+ title: titleOf(400),
43
+ detail: result.message,
44
+ extensions: { fields: [{ field: "path", message: result.message }] },
45
+ });
46
+ catalog.invalidate(c.req.valid("param").id);
47
+ return c.json(result.status);
48
+ })
22
49
  .put("/:id/key", zValidator("param", providerParam, onInvalid), zValidator("json", keyBody, onInvalid), (c) => {
23
50
  const { id } = c.req.valid("param");
24
51
  const result = saveProviderKey(keys, id, c.req.valid("json").key);
25
52
  if (!result.ok) {
26
53
  return refusal(c, id, result.reason);
27
54
  }
55
+ catalog.invalidate(id);
28
56
  // The response says a key is stored and shows the mask, never the value.
29
57
  return c.json(keyStatus(keys, id));
30
58
  })
@@ -34,6 +62,7 @@ export function providerRoutes(deps) {
34
62
  if (!result.ok) {
35
63
  return refusal(c, id, result.reason);
36
64
  }
65
+ catalog.invalidate(id);
37
66
  return c.body(null, 204);
38
67
  }));
39
68
  }
@@ -0,0 +1,92 @@
1
+ import { zValidator } from "@hono/zod-validator";
2
+ import { Hono } from "hono";
3
+ import { z } from "zod";
4
+ import { transact } from "../../kernel/db/tx.js";
5
+ import { derive } from "../../kernel/runner/graph.js";
6
+ import { projectById, resetStage, stagesOf, updateProjectConfig, } from "../../slices/admission/repo.js";
7
+ import { withProjectControl } from "../../slices/control/lock.js";
8
+ import { resolveFont } from "../../slices/fonts/index.js";
9
+ import { outputsOf } from "../../slices/storage/repo.js";
10
+ import { subtitleConfigSchema } from "../../slices/subtitles/model.js";
11
+ import { onInvalid, problem, titleOf } from "./problem.js";
12
+ const idParam = z.object({
13
+ id: z
14
+ .string()
15
+ .min(1)
16
+ .max(64)
17
+ .regex(/^[0-9A-Za-z_-]+$/),
18
+ });
19
+ export function subtitleRoutes(deps) {
20
+ return new Hono().patch("/:id/subtitles", zValidator("param", idParam, onInvalid), zValidator("json", subtitleConfigSchema, onInvalid), async (c) => {
21
+ const { id } = c.req.valid("param");
22
+ return withProjectControl(deps.db, id, async () => {
23
+ const project = projectById(deps.db, id);
24
+ if (project === undefined)
25
+ return problem(c, {
26
+ status: 404,
27
+ title: titleOf(404),
28
+ detail: "No project has that id.",
29
+ });
30
+ const stages = stagesOf(deps.db, id);
31
+ if (stages.some((stage) => stage.state === "running") || deps.runner.hasInflight?.(id))
32
+ return problem(c, {
33
+ status: 409,
34
+ title: titleOf(409),
35
+ detail: "Pause the run and wait for its active work to stop before changing subtitles.",
36
+ });
37
+ const chosen = c.req.valid("json");
38
+ if (chosen.mode !== "off" && project.config.sources.audio === "off")
39
+ return problem(c, {
40
+ status: 400,
41
+ title: titleOf(400),
42
+ detail: "Subtitles need narration audio.",
43
+ });
44
+ const subtitles = project.config.sources.video === "off" && chosen.mode === "burn-in"
45
+ ? { ...chosen, mode: "files" }
46
+ : chosen;
47
+ if (subtitles.mode !== "off" && subtitles.fontId !== project.config.subtitles?.fontId) {
48
+ try {
49
+ await resolveFont(deps.paths, subtitles.fontId);
50
+ }
51
+ catch {
52
+ return problem(c, {
53
+ status: 400,
54
+ title: titleOf(400),
55
+ detail: "That font is no longer available. Choose another font or upload it again.",
56
+ });
57
+ }
58
+ }
59
+ // Font discovery awaits filesystem work; a pending runner may have claimed a
60
+ // stage during that time. Recheck synchronously immediately before the write.
61
+ if (stagesOf(deps.db, id).some((stage) => stage.state === "running") ||
62
+ deps.runner.hasInflight?.(id)) {
63
+ return problem(c, {
64
+ status: 409,
65
+ title: titleOf(409),
66
+ detail: "Pause the run before changing subtitles.",
67
+ });
68
+ }
69
+ const video = stages.find((stage) => stage.kind === "video");
70
+ transact(deps.db, () => {
71
+ updateProjectConfig(deps.db, id, { ...project.config, subtitles }, deps.clock.now().toISOString());
72
+ // Only the local final export is invalidated. Existing provider outputs and
73
+ // the last finished export remain intact until its replacement succeeds.
74
+ if (video !== undefined &&
75
+ (project.config.sources.audio !== "off" || project.config.sources.video !== "off"))
76
+ resetStage(deps.db, video.id);
77
+ });
78
+ deps.hub.emit(id, { type: "project.updated", projectId: id });
79
+ if (project.paused !== true)
80
+ deps.runner.tick(id);
81
+ const current = stagesOf(deps.db, id);
82
+ return c.json({
83
+ project: {
84
+ ...projectById(deps.db, id),
85
+ status: derive(current, project.paused === true),
86
+ },
87
+ stages: current,
88
+ outputs: outputsOf(deps.db, id),
89
+ });
90
+ });
91
+ });
92
+ }
@@ -0,0 +1,57 @@
1
+ import { Hono } from "hono";
2
+ import { problem, titleOf } from "./problem.js";
3
+ export function updateRoutes(deps) {
4
+ const unavailable = {
5
+ currentVersion: deps.version,
6
+ latestVersion: null,
7
+ available: false,
8
+ busy: false,
9
+ canUpdate: false,
10
+ status: "idle",
11
+ blockedReason: "In-app updates are unavailable for this server.",
12
+ };
13
+ return new Hono()
14
+ .get("/ready", (c) => {
15
+ c.header("Cache-Control", "no-store");
16
+ if (!deps.updater?.ready(c.req.header("X-Slopify-Update-Token") ?? ""))
17
+ return problem(c, { status: 403, title: titleOf(403) });
18
+ return c.json({ status: "ok", version: deps.version });
19
+ })
20
+ .post("/activate", async (c) => {
21
+ c.header("Cache-Control", "no-store");
22
+ const token = c.req.header("X-Slopify-Update-Token") ?? "";
23
+ if (!deps.updater?.ready(token))
24
+ return problem(c, { status: 403, title: titleOf(403) });
25
+ if (!(await deps.updater.activate(token)))
26
+ return problem(c, { status: 409, title: titleOf(409) });
27
+ return c.json({ status: "ok", version: deps.version });
28
+ })
29
+ .get("/", async (c) => {
30
+ c.header("Cache-Control", "no-store");
31
+ return c.json(deps.updater === undefined
32
+ ? unavailable
33
+ : await deps.updater.check(c.req.query("refresh") === "1"));
34
+ })
35
+ .post("/", async (c) => {
36
+ const origin = c.req.header("Origin");
37
+ if (origin !== undefined && origin !== new URL(c.req.url).origin)
38
+ return problem(c, {
39
+ status: 403,
40
+ title: titleOf(403),
41
+ detail: "Start updates from the Slopify page on this server.",
42
+ });
43
+ if (deps.updater === undefined)
44
+ return problem(c, { status: 409, title: titleOf(409), detail: unavailable.blockedReason });
45
+ const result = await deps.updater.start();
46
+ if (result.ok)
47
+ return c.json(result.info, 202);
48
+ const status = result.code ?? 409;
49
+ return problem(c, {
50
+ status,
51
+ title: titleOf(status),
52
+ detail: result.info.blockedReason ??
53
+ result.info.error ??
54
+ "No newer published update is available.",
55
+ });
56
+ });
57
+ }
@@ -0,0 +1,44 @@
1
+ import { readFile, rm } from "node:fs/promises";
2
+ import { updatePlanSchema } from "../updater/plan.js";
3
+ import { performUpdate } from "../updater/worker.js";
4
+ const planPath = process.argv[2];
5
+ try {
6
+ if (planPath === undefined || !process.connected)
7
+ throw new Error("An update plan and parent process are required.");
8
+ const plan = updatePlanSchema.parse(JSON.parse(await readFile(planPath, "utf8")));
9
+ await rm(planPath, { force: true });
10
+ await performUpdate(plan, () => new Promise((resolve, reject) => {
11
+ if (!process.connected) {
12
+ reject(new Error("The update parent disconnected."));
13
+ return;
14
+ }
15
+ const fail = () => {
16
+ clearTimeout(timer);
17
+ reject(new Error("The update parent disconnected."));
18
+ };
19
+ const timer = setTimeout(() => reject(new Error("The update handoff timed out.")), 60_000);
20
+ process.once("disconnect", fail);
21
+ process.once("message", (message) => {
22
+ clearTimeout(timer);
23
+ process.off("disconnect", fail);
24
+ if (typeof message === "object" &&
25
+ message !== null &&
26
+ "type" in message &&
27
+ message.type === "handoff")
28
+ resolve();
29
+ else
30
+ reject(new Error("The update handoff was refused."));
31
+ });
32
+ process.send?.({ type: "installed" }, (error) => {
33
+ if (error !== null)
34
+ fail();
35
+ });
36
+ }));
37
+ if (process.connected)
38
+ process.disconnect?.();
39
+ }
40
+ catch {
41
+ if (process.connected)
42
+ process.disconnect?.();
43
+ process.exitCode = 1;
44
+ }
@@ -0,0 +1,188 @@
1
+ import { randomUUID } from "node:crypto";
2
+ const inactive = { append: () => { }, complete: () => { }, interrupt: () => { } };
3
+ // A preview is a disposable copy of bytes from the paid call already in flight.
4
+ // It never initiates synthesis, persists audio, or backpressures the provider.
5
+ // Readers pull from shared bounded storage, so a slow browser cannot grow a queue.
6
+ export function createAudioPreviewStore(limits = {}) {
7
+ const maxBytes = limits.maxBytes ?? 64 * 1024 * 1024;
8
+ const maxEntryBytes = limits.maxEntryBytes ?? 16 * 1024 * 1024;
9
+ const maxEntries = limits.maxEntries ?? 128;
10
+ const keepMs = limits.keepMs ?? 5 * 60_000;
11
+ const now = limits.now ?? Date.now;
12
+ const entries = new Map();
13
+ let retained = 0;
14
+ let closed = false;
15
+ const notify = (entry) => {
16
+ const listeners = [...entry.changed];
17
+ entry.changed.clear();
18
+ for (const listener of listeners)
19
+ listener();
20
+ };
21
+ const stop = (entry, state) => {
22
+ retained -= entry.bytes;
23
+ entry.bytes = 0;
24
+ entry.chunks.length = 0;
25
+ entry.state = state;
26
+ entry.endedAt = now();
27
+ notify(entry);
28
+ };
29
+ const remove = (entry) => {
30
+ stop(entry, "interrupted");
31
+ entries.delete(entry.id);
32
+ };
33
+ const prune = () => {
34
+ for (const entry of entries.values()) {
35
+ if (entry.endedAt !== undefined && now() - entry.endedAt >= keepMs)
36
+ remove(entry);
37
+ }
38
+ };
39
+ const expiry = setInterval(prune, Math.min(keepMs, 30_000));
40
+ expiry.unref();
41
+ const store = {
42
+ begin(projectId, key, label) {
43
+ if (closed)
44
+ return inactive;
45
+ prune();
46
+ for (const entry of entries.values()) {
47
+ if (entry.projectId === projectId && entry.key === key)
48
+ remove(entry);
49
+ }
50
+ // At capacity, completed previews yield to current generation. Otherwise
51
+ // this request has no preview; the normal durable narration still succeeds.
52
+ if (entries.size >= maxEntries) {
53
+ const oldest = [...entries.values()].find((entry) => entry.state !== "streaming");
54
+ if (oldest !== undefined)
55
+ remove(oldest);
56
+ }
57
+ if (entries.size >= maxEntries)
58
+ return inactive;
59
+ const entry = {
60
+ id: randomUUID(),
61
+ projectId,
62
+ key,
63
+ label,
64
+ chunks: [],
65
+ changed: new Set(),
66
+ state: "streaming",
67
+ bytes: 0,
68
+ endedAt: undefined,
69
+ };
70
+ entries.set(entry.id, entry);
71
+ const active = () => entries.get(entry.id) === entry && entry.state === "streaming";
72
+ return {
73
+ append(bytes) {
74
+ if (!active() || bytes.byteLength === 0)
75
+ return;
76
+ if (retained + bytes.byteLength > maxBytes ||
77
+ entry.bytes + bytes.byteLength > maxEntryBytes) {
78
+ stop(entry, "unavailable");
79
+ return;
80
+ }
81
+ // Own these bounded buffers. A provider may reuse its read buffer.
82
+ for (let at = 0; at < bytes.byteLength; at += 64 * 1024) {
83
+ entry.chunks.push(bytes.slice(at, at + 64 * 1024));
84
+ }
85
+ retained += bytes.byteLength;
86
+ entry.bytes += bytes.byteLength;
87
+ notify(entry);
88
+ },
89
+ complete() {
90
+ if (!active())
91
+ return;
92
+ entry.state = "ready";
93
+ entry.endedAt = now();
94
+ notify(entry);
95
+ },
96
+ interrupt() {
97
+ if (active())
98
+ stop(entry, "interrupted");
99
+ },
100
+ };
101
+ },
102
+ list(projectId) {
103
+ prune();
104
+ return [...entries.values()]
105
+ .filter((entry) => entry.projectId === projectId)
106
+ .map(({ id, label, state, bytes }) => ({ id, label, state, bytes }));
107
+ },
108
+ stream(projectId, id, signal) {
109
+ prune();
110
+ const entry = entries.get(id);
111
+ if (entry === undefined ||
112
+ entry.projectId !== projectId ||
113
+ entry.state === "interrupted" ||
114
+ entry.state === "unavailable")
115
+ return undefined;
116
+ return follow(entry, signal);
117
+ },
118
+ clear(projectId) {
119
+ for (const entry of entries.values())
120
+ if (entry.projectId === projectId)
121
+ remove(entry);
122
+ },
123
+ close() {
124
+ closed = true;
125
+ clearInterval(expiry);
126
+ for (const entry of entries.values())
127
+ remove(entry);
128
+ },
129
+ };
130
+ return store;
131
+ }
132
+ function follow(entry, signal) {
133
+ let index = 0;
134
+ let ended = false;
135
+ let wake;
136
+ let control;
137
+ const detach = () => {
138
+ ended = true;
139
+ signal.removeEventListener("abort", abort);
140
+ if (wake !== undefined) {
141
+ entry.changed.delete(wake);
142
+ wake();
143
+ wake = undefined;
144
+ }
145
+ };
146
+ const abort = () => {
147
+ if (ended)
148
+ return;
149
+ detach();
150
+ control?.error(new Error("Audio preview disconnected"));
151
+ };
152
+ return new ReadableStream({
153
+ start(controller) {
154
+ control = controller;
155
+ if (signal.aborted)
156
+ abort();
157
+ else
158
+ signal.addEventListener("abort", abort, { once: true });
159
+ },
160
+ async pull(controller) {
161
+ while (!ended) {
162
+ if (entry.state === "interrupted" || entry.state === "unavailable") {
163
+ detach();
164
+ controller.error(new Error("Audio preview was interrupted"));
165
+ return;
166
+ }
167
+ const bytes = entry.chunks[index];
168
+ if (bytes !== undefined) {
169
+ index += 1;
170
+ controller.enqueue(bytes);
171
+ return;
172
+ }
173
+ if (entry.state === "ready") {
174
+ detach();
175
+ controller.close();
176
+ return;
177
+ }
178
+ await new Promise((resolve) => {
179
+ wake = resolve;
180
+ entry.changed.add(resolve);
181
+ });
182
+ }
183
+ },
184
+ cancel() {
185
+ detach();
186
+ },
187
+ }, { highWaterMark: 0 });
188
+ }
@@ -0,0 +1,45 @@
1
+ import { readFileSync, statSync } from "node:fs";
2
+ import { extname, isAbsolute, join, resolve, win32 } from "node:path";
3
+ // Keep prompts out of cmd.exe: npm launchers name a JavaScript entry point that
4
+ // Node can execute directly with an argv array, including quotes and newlines.
5
+ export function cliCommand(binary) {
6
+ const file = process.platform === "win32" ? windowsFile(binary) : binary;
7
+ if (/\.[cm]?js$/i.test(file))
8
+ return { file: process.execPath, args: [resolve(file)] };
9
+ if (process.platform !== "win32" || !/\.(cmd|bat)$/i.test(file))
10
+ return { file, args: [] };
11
+ const stat = statSync(file);
12
+ if (!stat.isFile() || stat.size > 64 * 1024)
13
+ throw unsupportedShim();
14
+ const target = windowsShimTarget(file, readFileSync(file, "utf8"));
15
+ if (target === undefined || !statSync(target, { throwIfNoEntry: false })?.isFile())
16
+ throw unsupportedShim();
17
+ return { file: process.execPath, args: [target] };
18
+ }
19
+ export function windowsShimTarget(file, source) {
20
+ if (!/(?:node|bun)(?:\.exe)?/i.test(source))
21
+ return undefined;
22
+ // npm's cmd-shim uses %dp0%, older launchers use %~dp0. The remainder must
23
+ // be a literal relative script path, followed by the original argument list.
24
+ const matched = /"(?:%dp0%|%~dp0)[\\/]?([^"%\r\n]+\.[cm]?js)"\s+%\*/i.exec(source);
25
+ if (matched?.[1] === undefined || win32.isAbsolute(matched[1]))
26
+ return undefined;
27
+ return win32.resolve(win32.dirname(file), matched[1]);
28
+ }
29
+ function windowsFile(binary) {
30
+ const path = Object.entries(process.env).find(([key]) => key.toLowerCase() === "path")?.[1] ?? "";
31
+ const directories = isAbsolute(binary) || /[\\/]/.test(binary) ? [""] : path.split(";");
32
+ const suffixes = extname(binary) === "" ? [".exe", ".com", ".cmd", ".bat", ""] : [""];
33
+ for (const directory of directories) {
34
+ for (const suffix of suffixes) {
35
+ const file = directory === "" ? binary + suffix : join(directory.replace(/^"|"$/g, ""), binary + suffix);
36
+ if (statSync(file, { throwIfNoEntry: false })?.isFile())
37
+ return resolve(file);
38
+ }
39
+ }
40
+ // Let spawn report its usual ENOENT when no executable exists.
41
+ return binary;
42
+ }
43
+ function unsupportedShim() {
44
+ return new Error("This batch launcher is not a supported Node CLI shim. Select the CLI's .exe or JavaScript entry file instead.");
45
+ }