@gentbajko/slopify 0.1.0 → 0.3.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 (94) hide show
  1. package/README.md +2 -2
  2. package/dist/adapter-registry.js +11 -12
  3. package/dist/adapters/image/bytes.js +1 -1
  4. package/dist/adapters/image/fal.js +55 -44
  5. package/dist/adapters/image/openai.js +19 -24
  6. package/dist/adapters/image/replicate.js +33 -43
  7. package/dist/adapters/llm/claude-code.js +16 -19
  8. package/dist/adapters/llm/codex.js +14 -15
  9. package/dist/adapters/llm/openrouter.js +13 -16
  10. package/dist/adapters/llm/run-cli.js +9 -11
  11. package/dist/adapters/llm/sse-lines.js +1 -2
  12. package/dist/adapters/retry-after.js +6 -6
  13. package/dist/adapters/tts/cartesia.js +5 -5
  14. package/dist/adapters/tts/elevenlabs.js +14 -19
  15. package/dist/adapters/tts/openai.js +9 -10
  16. package/dist/edge/cli.js +0 -0
  17. package/dist/edge/http/actions.js +8 -9
  18. package/dist/edge/http/app.js +3 -3
  19. package/dist/edge/http/entries.js +1 -1
  20. package/dist/edge/http/projects.js +7 -7
  21. package/dist/edge/http/prompts.js +6 -6
  22. package/dist/edge/http/providers.js +4 -4
  23. package/dist/edge/http/settings.js +2 -2
  24. package/dist/edge/http/staging.js +1 -1
  25. package/dist/edge/http/telemetry.js +6 -7
  26. package/dist/edge/http/usage.js +5 -5
  27. package/dist/kernel/config/index.js +1 -1
  28. package/dist/kernel/db/tx.js +9 -12
  29. package/dist/kernel/log.js +5 -5
  30. package/dist/kernel/pipeline.js +7 -9
  31. package/dist/kernel/ports/model.js +3 -4
  32. package/dist/kernel/runner/attempt-repo.js +5 -6
  33. package/dist/kernel/runner/attempt.js +16 -23
  34. package/dist/kernel/runner/graph.js +18 -29
  35. package/dist/kernel/runner/index.js +27 -33
  36. package/dist/kernel/runner/piece-repo.js +9 -11
  37. package/dist/kernel/runner/providers.js +5 -7
  38. package/dist/kernel/version.js +3 -3
  39. package/dist/main.js +10 -11
  40. package/dist/slices/admission/model.js +2 -2
  41. package/dist/slices/admission/repo.js +6 -7
  42. package/dist/slices/admission/rules.js +12 -15
  43. package/dist/slices/admission/start.js +7 -8
  44. package/dist/slices/admission/substitute.js +11 -11
  45. package/dist/slices/article/continuation.js +9 -9
  46. package/dist/slices/article/plain.js +5 -5
  47. package/dist/slices/article/run.js +25 -25
  48. package/dist/slices/article/split.js +1 -1
  49. package/dist/slices/article/store.js +5 -5
  50. package/dist/slices/cancel/index.js +8 -8
  51. package/dist/slices/images/run.js +32 -34
  52. package/dist/slices/library/lint.js +3 -3
  53. package/dist/slices/library/model.js +7 -7
  54. package/dist/slices/library/repo.js +3 -3
  55. package/dist/slices/library/save.js +7 -7
  56. package/dist/slices/library/slots.js +5 -5
  57. package/dist/slices/narration/chunk.js +12 -12
  58. package/dist/slices/narration/concat.js +12 -13
  59. package/dist/slices/narration/run.js +46 -49
  60. package/dist/slices/reruns/cascade.js +10 -11
  61. package/dist/slices/reruns/index.js +36 -37
  62. package/dist/slices/research/planner.js +4 -5
  63. package/dist/slices/research/run.js +22 -23
  64. package/dist/slices/research/synthesis.js +5 -5
  65. package/dist/slices/settings/cli-status.js +9 -10
  66. package/dist/slices/settings/keys.js +8 -9
  67. package/dist/slices/settings/model.js +5 -5
  68. package/dist/slices/settings/readiness.js +3 -4
  69. package/dist/slices/settings/repo.js +1 -1
  70. package/dist/slices/settings/voices.js +3 -4
  71. package/dist/slices/storage/asset-name.js +2 -2
  72. package/dist/slices/storage/delete-project.js +3 -5
  73. package/dist/slices/storage/downloads.js +4 -4
  74. package/dist/slices/storage/layout.js +5 -6
  75. package/dist/slices/storage/model.js +1 -1
  76. package/dist/slices/storage/reconcile.js +6 -7
  77. package/dist/slices/storage/repo.js +1 -1
  78. package/dist/slices/storage/staging.js +12 -15
  79. package/dist/slices/telemetry/collector-client.js +2 -2
  80. package/dist/slices/telemetry/flush.js +8 -8
  81. package/dist/slices/telemetry/machine.js +6 -7
  82. package/dist/slices/telemetry/model.js +11 -13
  83. package/dist/slices/telemetry/record.js +7 -7
  84. package/dist/slices/telemetry/repo.js +4 -4
  85. package/dist/slices/telemetry/usage.js +5 -5
  86. package/dist/slices/thumbnail/by-llm.js +2 -2
  87. package/dist/slices/thumbnail/run.js +30 -32
  88. package/dist/slices/video/ffmpeg.js +6 -6
  89. package/dist/slices/video/plan.js +9 -9
  90. package/dist/slices/video/run.js +19 -20
  91. package/dist/web/assets/index-DTqh2zoJ.js +81 -0
  92. package/dist/web/index.html +1 -1
  93. package/package.json +1 -1
  94. package/dist/web/assets/index-D_sWbKQi.js +0 -81
package/README.md CHANGED
@@ -6,7 +6,7 @@ A prompt and a few keywords in. A narrated slideshow video out. Your keys, your
6
6
  npx @gentbajko/slopify@latest
7
7
  ```
8
8
 
9
- That opens a browser at `http://127.0.0.1:4242`. Nothing to configure first; add
9
+ That opens a browser at `http://127.0.0.1:6969`. Nothing to configure first; add
10
10
  provider keys on the Settings screen when you want a stage to generate rather than
11
11
  take what you paste in.
12
12
 
@@ -18,7 +18,7 @@ video is a slideshow with alternating zoom over the narration, rendered with ffm
18
18
 
19
19
  | Flag | Environment variable | Default |
20
20
  |---|---|---|
21
- | `--port` | `SLOPIFY_PORT` | `4242` |
21
+ | `--port` | `SLOPIFY_PORT` | `6969` |
22
22
  | `--host` | `SLOPIFY_HOST` | `127.0.0.1` |
23
23
  | `--data-dir` | `SLOPIFY_DATA_DIR` | `~/.slopify` |
24
24
  | `--no-open` | `SLOPIFY_NO_OPEN` | the browser opens |
@@ -10,31 +10,30 @@ import { openAiTts } from "./adapters/tts/openai.js";
10
10
  import { keyForAttempt } from "./slices/settings/keys.js";
11
11
  import { providerStatuses } from "./slices/settings/readiness.js";
12
12
  export function buildRegistry(deps) {
13
- // `logic/02` §Q16: read per request, never held, so an attempt in flight finishes on
14
- // the key it started with and the next one picks up a key saved since. The closure is
15
- // handed to the one adapter that provider belongs to and to nothing else (§Q18).
13
+ // Read per request, never held, so an attempt in flight finishes on the key it started with
14
+ // and the next one picks up a key saved since. The closure is handed to the one adapter that
15
+ // provider belongs to and to nothing else.
16
16
  const keyOf = (provider) => () => {
17
17
  const found = keyForAttempt({ db: deps.db, clock: deps.clock }, provider);
18
18
  return found.ok ? found.key : undefined;
19
19
  };
20
20
  const llms = new Map([
21
21
  ["openrouter", openRouterLlm({ fetch: deps.fetch, key: keyOf("openrouter") })],
22
- // No key: both CLIs authenticate with their own login (`logic/02` §Q135).
22
+ // No key: both CLIs authenticate with their own login.
23
23
  ["claude-code", claudeCodeLlm({ run: deps.spawn })],
24
24
  ["codex", codexLlm({ run: deps.spawn })],
25
25
  ]);
26
- // One key per provider (`logic/02` invariant), so each adapter is handed the reader for
27
- // its own row and no other. OpenAI keeps two rows because it ships an adapter in two
28
- // families and a user may key one without the other (`slices/settings/model.ts`).
26
+ // One key per provider, so each adapter is handed the reader for its own row and no other.
27
+ // OpenAI keeps two rows because it ships an adapter in two families and a user may key one
28
+ // without the other (`slices/settings/model.ts`).
29
29
  const ttses = new Map([
30
30
  ["elevenlabs", elevenLabsTts({ fetch: deps.fetch, key: keyOf("elevenlabs") })],
31
31
  ["openai-tts", openAiTts({ fetch: deps.fetch, key: keyOf("openai-tts") })],
32
32
  ["cartesia", cartesiaTts({ fetch: deps.fetch, key: keyOf("cartesia") })],
33
33
  ]);
34
- // `logic/09` and `logic/02` §Q15: three image providers behind one port, each handed
35
- // the reader for its own key row. Replicate also takes the clock: `Prefer: wait` gives
36
- // up after 60 s and the prediction has to be polled, and the wait is spent on the
37
- // app's clock so a test never sits through one.
34
+ // Three image providers behind one port, each handed the reader for its own key row.
35
+ // Replicate also takes the clock: `Prefer: wait` gives up after 60 s and the prediction has
36
+ // to be polled, and the wait is spent on the app's clock so a test never sits through one.
38
37
  const images = new Map([
39
38
  ["fal", falImage({ fetch: deps.fetch, key: keyOf("fal") })],
40
39
  [
@@ -47,7 +46,7 @@ export function buildRegistry(deps) {
47
46
  llm: (id) => resolve(llms, "llm", id),
48
47
  tts: (id) => resolve(ttses, "tts", id),
49
48
  image: (id) => resolve(images, "image", id),
50
- // `logic/02` step 5: every supported provider, keyed or not, found or not, so Play
49
+ // Every supported provider, keyed or not, found or not, so Play
51
50
  // can grey one out with a reason instead of hiding it.
52
51
  list: async () => (await providerStatuses({ db: deps.db, probe: deps.probe })).map((status) => ({
53
52
  family: status.family,
@@ -14,7 +14,7 @@ const riff = [0x52, 0x49, 0x46, 0x46];
14
14
  const webp = [0x57, 0x45, 0x42, 0x50];
15
15
  // The bytes decide, never the Content-Type header. A CDN in front of an expired link
16
16
  // serves `image/png` over an HTML error page often enough to be worth not trusting, and
17
- // the mime is stored with the file and becomes its extension (`logic/09` step 3).
17
+ // the mime is stored with the file and becomes its extension.
18
18
  export function sniffImage(bytes) {
19
19
  if (startsWith(bytes, png, 0)) {
20
20
  return "image/png";
@@ -3,40 +3,59 @@ import { redact } from "../../kernel/log.js";
3
3
  import { providerError } from "../../kernel/ports/model.js";
4
4
  import { retryAfter } from "../retry-after.js";
5
5
  import { downloadImage } from "./bytes.js";
6
- // The HTTP gateway adapter for fal.ai (01-architecture Module boundaries). `fetch` and
7
- // the downloader beside this file, no SDK: `@fal-ai/client` exists and would buy the
8
- // queue polling this endpoint does not need, so 05-dependencies' rung 3 - the platform's
9
- // own `fetch` - already covers the whole call. The synchronous host runs the model on the
10
- // open connection, which is what the attempt wrapper's 300 s is measuring.
6
+ // The HTTP gateway adapter for fal.ai: `fetch` and the downloader beside this file, no SDK.
7
+ // `@fal-ai/client` would buy queue polling this endpoint does not need, so the platform's
8
+ // own `fetch` covers the whole call. The synchronous host runs the model on the open
9
+ // connection, which is what the wrapper's 300 s measures.
11
10
  export const falBase = "https://fal.run";
12
- // `logic/02` §Q15: the model dropdown is filled from what the provider offers, and fal has
13
- // no endpoint that lists only its text-to-image models, so the list is this adapter's own
14
- // data. Adding next month's model is a line here and no code change anywhere else.
15
- // Every entry takes the same input shape: `image_size` as an enum, `num_images`, and
16
- // `output_format`. ceiling: a fal model that spells its aspect `aspect_ratio` instead -
17
- // `xai/grok-imagine-image` does - cannot be added to this list without a per-model input
18
- // map, which is the upgrade when one is wanted.
11
+ // fal has no endpoint listing only its text-to-image models, so the list is this adapter's own
12
+ // data and adding one is a line here plus its aspect shape below.
19
13
  export const falModels = [
20
14
  { id: "fal-ai/flux-2", name: "FLUX.2" },
21
15
  { id: "fal-ai/flux/dev", name: "FLUX.1 [dev]" },
22
16
  { id: "fal-ai/flux/schnell", name: "FLUX.1 [schnell]" },
17
+ { id: "fal-ai/nano-banana", name: "Nano Banana" },
18
+ { id: "fal-ai/nano-banana-2", name: "Nano Banana 2" },
19
+ { id: "fal-ai/gemini-3.1-flash-image-preview", name: "Gemini 3.1 Flash Image" },
23
20
  ];
24
- // `logic/09` step 1: the closest supported size to the run's aspect. fal names the two
25
- // 16:9 frames from the orientation, so the portrait one is "portrait_16_9" and is 9:16.
21
+ // fal spells the frame two ways and the difference is per model, not per family: the FLUX
22
+ // endpoints take an `image_size` enum naming the orientation, the Google ones take a plain
23
+ // `aspect_ratio` string. Both accept everything else identically, so the shape is the only
24
+ // thing a model entry has to declare.
25
+ const aspectFields = ["image_size", "aspect_ratio"];
26
+ // The closest supported size, per shape. fal names both 16:9 `image_size` frames from the
27
+ // orientation, so the portrait one is "portrait_16_9" and is 9:16; `aspect_ratio` takes the
28
+ // run's own words.
26
29
  const sizes = {
27
- "16:9": "landscape_16_9",
28
- "9:16": "portrait_16_9",
30
+ image_size: { "16:9": "landscape_16_9", "9:16": "portrait_16_9" },
31
+ aspect_ratio: { "16:9": "16:9", "9:16": "9:16" },
29
32
  };
30
- // A wire payload is narrowed, never cast: everything unlisted is dropped at the seam so
31
- // no vendor shape can leak past this file (01-architecture §Q10, §Q33).
33
+ const modelAspects = {
34
+ "fal-ai/flux-2": "image_size",
35
+ "fal-ai/flux/dev": "image_size",
36
+ "fal-ai/flux/schnell": "image_size",
37
+ "fal-ai/nano-banana": "aspect_ratio",
38
+ "fal-ai/nano-banana-2": "aspect_ratio",
39
+ "fal-ai/gemini-3.1-flash-image-preview": "aspect_ratio",
40
+ };
41
+ // A model the map does not know is one the user typed rather than picked, so it falls back to
42
+ // `image_size` - the shape the FLUX endpoints use and the one this adapter shipped with.
43
+ // `Object.hasOwn` because a model id is user input and a plain lookup would answer
44
+ // "constructor" from Object's own prototype.
45
+ function aspectOf(model, aspect) {
46
+ const field = Object.hasOwn(modelAspects, model) ? modelAspects[model] : undefined;
47
+ const shape = field ?? "image_size";
48
+ return { [shape]: sizes[shape][aspect] };
49
+ }
50
+ // A wire payload is narrowed, never cast: everything unlisted is dropped at the seam.
32
51
  const generated = z.object({
33
52
  images: z.array(z.object({ url: z.string() })),
34
- // The safety checker's verdict, one flag per image. It is how fal says it declined:
35
- // the request is a 200 and the image that comes back is blank.
53
+ // The safety checker's verdict, one flag per image - how fal declines: a 200 with a
54
+ // blank image.
36
55
  has_nsfw_concepts: z.array(z.boolean()).nullish(),
37
56
  });
38
- // fal answers a rejected request with FastAPI's own envelope: an object per failed field
39
- // for a validation error, a bare string for everything else.
57
+ // FastAPI's envelope: an object per failed field for a validation error, a bare string
58
+ // otherwise.
40
59
  const errorBody = z.object({
41
60
  detail: z.union([
42
61
  z.string(),
@@ -54,13 +73,11 @@ export function falImage(deps) {
54
73
  headers: { Authorization: `Key ${keyOf(deps)}`, "Content-Type": "application/json" },
55
74
  body: JSON.stringify({
56
75
  prompt: req.prompt,
57
- image_size: sizes[req.aspect],
58
- // `logic/09` step 2 sends Number as that many independent calls, each its own
59
- // resumable piece, so one image per request is what the stage asks for.
76
+ ...aspectOf(req.model, req.aspect),
77
+ // The stage sends Number as that many independent calls, one piece each.
60
78
  num_images: 1,
61
- // The port stores a PNG or a JPEG; fal's own default is WebP on some models and
62
- // the download would refuse it. Nothing else about quality or style is set:
63
- // step 2 asks for the provider's defaults.
79
+ // The port stores PNG or JPEG; fal defaults to WebP on some models and the
80
+ // download would refuse it. The stage asks for the provider's own quality.
64
81
  output_format: "png",
65
82
  }),
66
83
  });
@@ -73,8 +90,7 @@ export function falImage(deps) {
73
90
  if (first === undefined) {
74
91
  throw providerError({ kind: "other", message: "fal answered with no image" });
75
92
  }
76
- // The download is inside the attempt with the call that produced the link, so a
77
- // link that 404s is a failed attempt rather than a broken file on the project.
93
+ // The download rides inside the attempt: a link that 404s is a failed attempt.
78
94
  return await downloadImage({
79
95
  fetch: deps.fetch,
80
96
  provider: "fal",
@@ -85,18 +101,16 @@ export function falImage(deps) {
85
101
  };
86
102
  }
87
103
  function keyOf(deps) {
88
- // `logic/02` §Q13: an attempt that finds no key fails rather than calling anonymously.
89
- // `missing_key` rather than `auth` because the same rule makes it terminal.
104
+ // `missing_key`, not `auth`, because that rule makes it terminal.
90
105
  const key = deps.key();
91
106
  if (key === undefined || key === "") {
92
107
  throw providerError({ kind: "missing_key", message: "no fal key is stored" });
93
108
  }
94
109
  return key;
95
110
  }
96
- // `logic/09` §Q74: a content-policy refusal is the provider's final answer, so it is named
97
- // here and the wrapper makes it terminal. fal declines inside a 200: the safety checker
98
- // flags the image and hands back a blank one. There is no sentence to quote, so this is
99
- // the one refusal in the app whose words are the app's; fal's own verdict is the flag.
111
+ // A content-policy refusal is final, named here and made terminal by the wrapper. fal declines
112
+ // inside a 200 - the safety checker flags the image and hands back a blank one. With no
113
+ // sentence to quote, this is the one refusal whose words are the app's.
100
114
  function refused(answer, prompt) {
101
115
  const flags = answer.has_nsfw_concepts ?? [];
102
116
  if (flags.length > 0 && flags.every((flagged) => flagged)) {
@@ -106,8 +120,7 @@ function refused(answer, prompt) {
106
120
  });
107
121
  }
108
122
  }
109
- // Only the adapter can read a vendor's status code, so only the adapter names the kind;
110
- // the attempt wrapper maps it and nothing downstream classifies again (03-conventions).
123
+ // Only the adapter sees the vendor's status code, so only the adapter names the kind.
111
124
  function kindOf(status) {
112
125
  if (status === 401 || status === 403) {
113
126
  return "auth";
@@ -115,16 +128,14 @@ function kindOf(status) {
115
128
  if (status === 429) {
116
129
  return "rate_limit";
117
130
  }
118
- // ceiling: everything else is `other` and is retried, so a 422 naming an input this
119
- // model does not take fails the same way four times over. A terminal kind for "this
120
- // request will never work" would have to be added to the port's error contract first,
121
- // which is not this adapter's to widen.
131
+ // ceiling: everything else is `other` and is retried, so a 422 naming an input this model
132
+ // does not take fails the same way four times over. A terminal "this will never work" kind
133
+ // has to reach the port's error contract first, which is not this adapter's to widen.
122
134
  return "other";
123
135
  }
124
136
  async function failure(response) {
125
137
  const text = await response.text().catch(() => "");
126
- // The provider's own words, verbatim, through the same redactor the wrapper uses: an
127
- // error body is free to quote the key back and this is the first place it is held.
138
+ // The provider's own words, through the redactor - an error body may quote the key back.
128
139
  const message = redact(detailOf(text) || response.statusText);
129
140
  const retryAfterMs = retryAfter(response.headers.get("retry-after"));
130
141
  return providerError({
@@ -3,13 +3,12 @@ import { redact } from "../../kernel/log.js";
3
3
  import { providerError } from "../../kernel/ports/model.js";
4
4
  import { retryAfter } from "../retry-after.js";
5
5
  import { describeBytes, sniffImage } from "./bytes.js";
6
- // The HTTP gateway adapter for OpenAI's images endpoint (01-architecture Module
7
- // boundaries). `fetch` and nothing else: 05-dependencies records global `fetch` at rung 3
8
- // and the whole call is one request. Unlike fal and Replicate this one hands back the
9
- // image itself - a GPT image model always answers with base64 and never with a URL - so
10
- // there is no link to follow.
6
+ // The HTTP gateway adapter for OpenAI's images endpoint: the platform's own `fetch` and
7
+ // nothing else, because the whole call is one request. Unlike fal and Replicate this one
8
+ // hands back the image itself - a GPT image model always answers with base64, never a URL -
9
+ // so there is no link to follow.
11
10
  export const openAiImagesBase = "https://api.openai.com/v1";
12
- // `logic/02` §Q15: the dropdown is filled from what the provider offers, and `/v1/models`
11
+ // The dropdown is filled from what the provider offers, and `/v1/models`
13
12
  // lists every model on the account, chat and embeddings among them, so the image
14
13
  // shortlist is this adapter's own data. These are the four GPT image models OpenAI
15
14
  // documents; adding the next one is a line here and no code change anywhere else.
@@ -19,9 +18,9 @@ export const openAiImageModels = [
19
18
  { id: "gpt-image-1", name: "GPT Image 1" },
20
19
  { id: "gpt-image-1-mini", name: "GPT Image 1 mini" },
21
20
  ];
22
- // `logic/09` step 1: "the provider's closest supported size to the run's aspect". The
23
- // standard GPT image sizes are 3:2 and 2:3, so 16:9 is asked for as 1536×1024 and the
24
- // remaining sliver is cropped by the render (`logic/11` step 4).
21
+ // The provider's closest supported size to the run's aspect. The standard GPT image sizes are
22
+ // 3:2 and 2:3, so 16:9 is asked for as 1536×1024 and the remaining sliver is cropped by the
23
+ // render.
25
24
  const standard = {
26
25
  "16:9": "1536x1024",
27
26
  "9:16": "1024x1536",
@@ -34,7 +33,7 @@ const exact = {
34
33
  "9:16": "864x1536",
35
34
  };
36
35
  const arbitrarySizes = /^gpt-image-2/;
37
- // A wire payload is narrowed, never cast (01-architecture §Q10, §Q33).
36
+ // A wire payload is narrowed, never cast.
38
37
  const generated = z.object({ data: z.array(z.object({ b64_json: z.string().nullish() })) });
39
38
  const errorBody = z.object({
40
39
  error: z.object({
@@ -43,7 +42,7 @@ const errorBody = z.object({
43
42
  code: z.string().nullish(),
44
43
  }),
45
44
  });
46
- // `logic/09` §Q74: a content-policy refusal is the provider's final answer and is never
45
+ // A content-policy refusal is the provider's final answer and is never
47
46
  // retried. OpenAI names it in the body rather than in the status, so the code is what
48
47
  // tells a declined prompt from a malformed request that shares its 400.
49
48
  const refusalCodes = ["moderation_blocked", "content_policy_violation"];
@@ -62,11 +61,11 @@ export function openAiImage(deps) {
62
61
  body: JSON.stringify({
63
62
  model: req.model,
64
63
  prompt: req.prompt,
65
- // `logic/09` step 2 sends Number as that many independent calls, each its own
66
- // resumable piece, so one image per request is what the stage asks for.
64
+ // The stage sends Number as that many independent calls, one piece each, so one
65
+ // image per request is what it asks for.
67
66
  n: 1,
68
67
  size: sizeFor(req.model, req.aspect),
69
- // No `quality` and no `background`: step 2 asks for the provider's default
68
+ // No `quality` and no `background`: the stage asks for the provider's default
70
69
  // quality and style, which is what leaving them off means.
71
70
  }),
72
71
  });
@@ -96,16 +95,14 @@ function decode(b64) {
96
95
  return { bytes, mime };
97
96
  }
98
97
  function keyOf(deps) {
99
- // `logic/02` §Q13: an attempt that finds no key fails rather than calling anonymously.
100
- // `missing_key` rather than `auth` because the same rule makes it terminal.
98
+ // `missing_key`, not `auth`, because that rule makes it terminal.
101
99
  const key = deps.key();
102
100
  if (key === undefined || key === "") {
103
101
  throw providerError({ kind: "missing_key", message: "no OpenAI image key is stored" });
104
102
  }
105
103
  return key;
106
104
  }
107
- // Only the adapter can read a vendor's status code, so only the adapter names the kind;
108
- // the attempt wrapper maps it and nothing downstream classifies again (03-conventions).
105
+ // Only the adapter sees the vendor's status code, so only the adapter names the kind.
109
106
  function kindOf(status, code) {
110
107
  if (code !== undefined && code !== null && refusalCodes.includes(code)) {
111
108
  return "refusal";
@@ -116,17 +113,15 @@ function kindOf(status, code) {
116
113
  if (status === 429) {
117
114
  return "rate_limit";
118
115
  }
119
- // ceiling: everything else is `other` and is retried, so a prompt past the model's
120
- // length limit fails the same way four times over. A terminal kind for "this request
121
- // will never work" would have to be added to the port's error contract first, which is
122
- // not this adapter's to widen.
116
+ // ceiling: everything else is `other` and is retried, so a prompt past the model's length
117
+ // limit fails the same way four times over. A terminal "this will never work" kind has to
118
+ // reach the port's error contract first, which is not this adapter's to widen.
123
119
  return "other";
124
120
  }
125
121
  async function failure(response) {
126
122
  const text = await response.text().catch(() => "");
127
123
  const parsed = errorBody.safeParse(safeJson(text));
128
- // The provider's own words, verbatim, through the same redactor the wrapper uses: an
129
- // error body is free to quote the key back and this is the first place it is held.
124
+ // The provider's own words, through the redactor - an error body may quote the key back.
130
125
  const message = redact(parsed.success ? parsed.data.error.message : text.trim() || response.statusText);
131
126
  const retryAfterMs = retryAfter(response.headers.get("retry-after"));
132
127
  return providerError({
@@ -3,30 +3,27 @@ import { redact } from "../../kernel/log.js";
3
3
  import { providerError } from "../../kernel/ports/model.js";
4
4
  import { retryAfter } from "../retry-after.js";
5
5
  import { downloadImage } from "./bytes.js";
6
- // The HTTP gateway adapter for Replicate (01-architecture Module boundaries). `fetch`,
7
- // the injected clock, and the downloader beside this file; no SDK. `replicate` the package
8
- // exists and wraps exactly the two requests below, so 05-dependencies' rung 3 covers it.
6
+ // The HTTP gateway adapter for Replicate: `fetch`, the injected clock and the downloader
7
+ // beside this file, no SDK. The `replicate` package wraps exactly the two requests below, so
8
+ // the platform's own `fetch` covers it.
9
9
  export const replicateBase = "https://api.replicate.com/v1";
10
- // `Prefer: wait` holds the connection open while the model runs, up to the 60 s ceiling
11
- // Replicate documents. A model slower than that answers `starting` instead, and the
12
- // prediction has to be polled - which is why this adapter takes a clock. The attempt
13
- // wrapper's 300 s (`logic/09` §Q77) is what ends the polling, through the request signal.
10
+ // `Prefer: wait` holds the connection open while the model runs, up to the 60 s Replicate
11
+ // documents. A slower model answers `starting` and has to be polled, which is why this
12
+ // adapter takes a clock. The wrapper's 300 s ends the polling.
14
13
  export const replicateWaitSeconds = 60;
15
14
  export const replicatePollMs = 2000;
16
- // `logic/02` §Q15: the model dropdown is filled from what the provider offers. Replicate's
17
- // model index lists every model on the platform, text and video among them, so the
18
- // text-to-image shortlist is this adapter's own data and adding one is a line here.
19
- // ceiling: each entry has to take `prompt`, `aspect_ratio` and `output_format`, which is
20
- // the convention across Replicate's official image models but not a guarantee; a model
21
- // that spells its inputs differently needs a per-model input map first.
15
+ // The model dropdown comes from what the provider offers, but Replicate's index lists every
16
+ // model on the platform, so the text-to-image shortlist is this adapter's own data and adding
17
+ // one is a line here. ceiling: each entry has to take `prompt`, `aspect_ratio` and
18
+ // `output_format` - the convention across Replicate's official image models, not a guarantee. A
19
+ // model spelling its inputs differently needs an input map.
22
20
  export const replicateModels = [
23
21
  { id: "black-forest-labs/flux-1.1-pro", name: "FLUX 1.1 [pro]" },
24
22
  { id: "black-forest-labs/flux-dev", name: "FLUX.1 [dev]" },
25
23
  { id: "black-forest-labs/flux-schnell", name: "FLUX.1 [schnell]" },
26
24
  ];
27
- // A wire payload is narrowed, never cast (01-architecture §Q10, §Q33). `output` is one
28
- // URL on the models that make a single image and an array on the ones that can make
29
- // several, so both shapes are read and the first is taken.
25
+ // A wire payload is narrowed, never cast. `output` is one URL
26
+ // on single-image models and an array on the rest, so both shapes are read.
30
27
  const prediction = z.object({
31
28
  status: z.string(),
32
29
  output: z.union([z.string(), z.array(z.string())]).nullish(),
@@ -34,8 +31,8 @@ const prediction = z.object({
34
31
  urls: z.object({ get: z.string().optional() }).nullish(),
35
32
  });
36
33
  const errorBody = z.object({ detail: z.string().optional(), title: z.string().optional() });
37
- // The two terminal states that are not success. `canceled` cannot happen here - nothing
38
- // cancels a prediction this app made - but reading it as terminal beats polling forever.
34
+ // `canceled` cannot happen here - nothing cancels a prediction this app made - but reading
35
+ // it as terminal beats polling forever.
39
36
  const settled = ["succeeded", "failed", "canceled"];
40
37
  export function replicateImage(deps) {
41
38
  return {
@@ -53,11 +50,10 @@ export function replicateImage(deps) {
53
50
  body: JSON.stringify({
54
51
  input: {
55
52
  prompt: req.prompt,
56
- // `logic/09` step 1: Replicate takes the aspect itself, so the closest
57
- // supported size is the exact one.
53
+ // Replicate takes the aspect, so the closest size is exact.
58
54
  aspect_ratio: req.aspect,
59
- // The port stores a PNG or a JPEG and these models default to WebP.
60
- // Nothing else is set: step 2 asks for the provider's own quality and style.
55
+ // The port stores PNG or JPEG and these models default to WebP. Nothing else
56
+ // is set: the stage asks for the provider's own quality and style.
61
57
  output_format: "png",
62
58
  },
63
59
  }),
@@ -66,8 +62,7 @@ export function replicateImage(deps) {
66
62
  throw await failure(response);
67
63
  }
68
64
  const url = await settle(deps, parse(await response.text()), req.signal);
69
- // The download is inside the attempt with the call that produced the link, so a
70
- // link that 404s is a failed attempt rather than a broken file on the project.
65
+ // The download rides inside the attempt: a link that 404s is a failed attempt.
71
66
  return await downloadImage({
72
67
  fetch: deps.fetch,
73
68
  provider: "Replicate",
@@ -77,8 +72,8 @@ export function replicateImage(deps) {
77
72
  },
78
73
  };
79
74
  }
80
- // `Prefer: wait` answers `starting` when the model outlived its 60 s, and Replicate's own
81
- // guidance is to poll `urls.get` until the prediction settles.
75
+ // `Prefer: wait` answers `starting` when the model outlived its 60 s; Replicate's guidance
76
+ // is to poll `urls.get` until the prediction settles.
82
77
  async function settle(deps, first, signal) {
83
78
  let current = first;
84
79
  while (!settled.includes(current.status)) {
@@ -109,33 +104,30 @@ function outputOf(current) {
109
104
  }
110
105
  return url;
111
106
  }
112
- // `logic/09` §Q74: a content-policy refusal is the provider's final answer and is never
113
- // retried. Replicate reports one as a settled prediction whose `error` says so, with the
114
- // same 201 the successful call had, so the text is what tells them apart.
107
+ // A content-policy refusal is final and never retried. Replicate reports one as a settled
108
+ // prediction whose `error` says so, under the same 201 a success gets, so the text is all that
109
+ // tells them apart.
115
110
  const refusalWords = /\b(nsfw|sensitive|safety|content polic|flagged|moderat)/i;
116
111
  function declined(current) {
117
112
  // The provider's own words, verbatim, through the same redactor the wrapper uses.
118
113
  const message = redact(current.error ?? `the prediction ended ${current.status}`);
119
- // ceiling: read off the sentence, because Replicate carries no machine-readable reason
120
- // on a failed prediction. A phrase the list does not know is retried three more times
121
- // and costs the user three more images; a structured field would settle it, and the
122
- // upgrade is to read one when Replicate ships it.
114
+ // ceiling: read off the sentence, because a failed prediction carries no machine-readable
115
+ // reason. A phrase the list does not know costs the user three more images; the upgrade
116
+ // is a structured field, when Replicate ships one.
123
117
  return providerError({
124
118
  kind: refusalWords.test(message) ? "refusal" : "other",
125
119
  message: `Replicate answered: ${message}`,
126
120
  });
127
121
  }
128
122
  function auth(deps) {
129
- // `logic/02` §Q13: an attempt that finds no key fails rather than calling anonymously.
130
- // `missing_key` rather than `auth` because the same rule makes it terminal.
123
+ // `missing_key`, not `auth`, because that rule makes it terminal.
131
124
  const key = deps.key();
132
125
  if (key === undefined || key === "") {
133
126
  throw providerError({ kind: "missing_key", message: "no Replicate key is stored" });
134
127
  }
135
128
  return { Authorization: `Bearer ${key}` };
136
129
  }
137
- // Only the adapter can read a vendor's status code, so only the adapter names the kind;
138
- // the attempt wrapper maps it and nothing downstream classifies again (03-conventions).
130
+ // Only the adapter sees the vendor's status code, so only the adapter names the kind.
139
131
  function kindOf(status) {
140
132
  if (status === 401 || status === 403) {
141
133
  return "auth";
@@ -143,17 +135,15 @@ function kindOf(status) {
143
135
  if (status === 429) {
144
136
  return "rate_limit";
145
137
  }
146
- // ceiling: everything else is `other` and is retried, so a 402 with no credit left
147
- // fails the same way four times over. A terminal kind for "this request will never
148
- // work" would have to be added to the port's error contract first, which is not this
149
- // adapter's to widen.
138
+ // ceiling: everything else is `other` and is retried, so a 402 with no credit left fails the
139
+ // same way four times over. A terminal "this will never work" kind has to reach the port's
140
+ // error contract first, which is not this adapter's to widen.
150
141
  return "other";
151
142
  }
152
143
  async function failure(response) {
153
144
  const text = await response.text().catch(() => "");
154
145
  const parsed = errorBody.safeParse(safeJson(text));
155
- // The provider's own words, verbatim, through the same redactor the wrapper uses: an
156
- // error body is free to quote the key back and this is the first place it is held.
146
+ // The provider's own words, through the redactor - an error body may quote the key back.
157
147
  const detail = parsed.success ? (parsed.data.detail ?? parsed.data.title) : undefined;
158
148
  const message = redact(detail ?? text.trim());
159
149
  const retryAfterMs = retryAfter(response.headers.get("retry-after"));
@@ -3,11 +3,10 @@ import { redact } from "../../kernel/log.js";
3
3
  import { providerError } from "../../kernel/ports/model.js";
4
4
  import { cliEvent, cliShaped, endedWithout, promptOf } from "./run-cli.js";
5
5
  import { lines } from "./sse-lines.js";
6
- // The local-agent adapter for Claude Code (01-architecture Module boundaries): spawned
7
- // non-interactively, authenticated by the CLI's own login, no key anywhere in this file.
8
- // Readiness is not computed here - `adapters/**` may not import `slices/**`, and
9
- // `slices/settings/cli-status.ts` already probes the binary per request (`logic/02`
10
- // §Q135). The registry `main.ts` builds is where this adapter and that probe meet.
6
+ // The local-agent adapter for Claude Code: spawned non-interactively, authenticated by the
7
+ // CLI's own login, no key anywhere in this file. Readiness is not computed here - `adapters/**`
8
+ // may not import `slices/**`, and `slices/settings/cli-status.ts` already probes the binary per
9
+ // request. The registry `main.ts` builds is where this adapter and that probe meet.
11
10
  export const claudeCodeBinary = "claude";
12
11
  // The CLI takes an alias for the latest model of a family (`claude --help`, 2.1.258).
13
12
  // ceiling: a fixed list, because the CLI has no offline command that prints the models an
@@ -19,13 +18,12 @@ export const claudeCodeModels = [
19
18
  { id: "sonnet", name: "Claude Sonnet (latest)" },
20
19
  { id: "haiku", name: "Claude Haiku (latest)" },
21
20
  ];
22
- // Measured on 2.1.258, not assumed: with the built-in tools left alone, a `-p` run of this
23
- // CLI still reached for ToolSearch and WebFetch, and the machine's MCP servers were loaded
24
- // into the session. Neither belongs in a content pipeline - the article stage must not
25
- // quietly ground itself on the web, since `logic/06` §Q47 makes grounding an explicit ask,
26
- // and no stage should be able to touch the disk. `--tools ""` empties the built-in set and
27
- // `--strict-mcp-config` drops the user's MCP servers; the init event of a run with both
28
- // reports `"tools":[]` and `"mcp_servers":[]`.
21
+ // Measured on 2.1.258, not assumed: with the built-in tools left alone, a `-p` run of this CLI
22
+ // still reached for ToolSearch and WebFetch, and the machine's MCP servers were loaded into the
23
+ // session. Neither belongs in a content pipeline - grounding on the web is an explicit ask,
24
+ // never something a stage does quietly, and no stage should touch the disk. `--tools ""`
25
+ // empties the built-in set and `--strict-mcp-config` drops the user's MCP servers; the init
26
+ // event of a run with both reports `"tools":[]` and `"mcp_servers":[]`.
29
27
  export function claudeCodeArgs(req) {
30
28
  return [
31
29
  "-p",
@@ -103,7 +101,7 @@ export function claudeCodeLlm(deps) {
103
101
  }
104
102
  catch (error) {
105
103
  // A cancelled stage killed the child; the reason the user's cancel carried is what
106
- // the runner expects back, not whatever the half-closed pipe threw (`logic/13`).
104
+ // the runner expects back, not whatever the half-closed pipe threw.
107
105
  req.signal.throwIfAborted();
108
106
  throw error;
109
107
  }
@@ -113,8 +111,8 @@ export function claudeCodeLlm(deps) {
113
111
  run.kill();
114
112
  }
115
113
  // A cancelled run ends its stream the same way an exhausted one does: the child was
116
- // killed, so stdout simply stopped. `logic/13` §Q112 says an aborted call counts
117
- // nothing, so it must not be reported as the provider failing.
114
+ // killed, so stdout simply stopped. An aborted call counts as nothing, so it must not
115
+ // be reported as the provider failing.
118
116
  req.signal.throwIfAborted();
119
117
  // The stream ended with no result event at all.
120
118
  throw providerError({
@@ -124,10 +122,9 @@ export function claudeCodeLlm(deps) {
124
122
  }
125
123
  return {
126
124
  id: "claude-code",
127
- // The CLI emits whole assistant turns rather than token deltas, which is still enough
128
- // for the idle timeout of `logic/01` §Q62 to see life on the stream.
129
- // ceiling: `--include-partial-messages` would give per-token deltas for the streamed
130
- // article of `logic/07` step 2; it is the upgrade when the page needs finer text.
125
+ // Whole assistant turns rather than token deltas, still enough for the idle timeout to
126
+ // see life on the stream. ceiling: `--include-partial-messages` would give per-token
127
+ // deltas for the streamed article; it is the upgrade when the page needs finer text.
131
128
  capabilities: { streams: true, reportsUsage: true, webSearch: true },
132
129
  models: () => Promise.resolve(claudeCodeModels),
133
130
  complete,