@telepat/ideon 0.1.16 → 0.1.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/ideon.js CHANGED
@@ -111,14 +111,14 @@ var modelSettingsSchema = z.object({
111
111
  topP: z.number().min(0).max(1).default(1)
112
112
  });
113
113
  var baseT2ISettingsSchema = z.object({
114
- modelId: z.string().default("black-forest-labs/flux-schnell"),
114
+ modelId: z.string().default("flux"),
115
115
  inputOverrides: z.record(z.string(), z.unknown()).default({})
116
116
  });
117
117
  var notificationsSettingsSchema = z.object({
118
118
  enabled: z.boolean().default(false)
119
119
  });
120
120
  var appSettingsSchema = z.object({
121
- model: z.string().default("moonshotai/kimi-k2.5"),
121
+ model: z.string().default("deepseek/deepseek-v4-pro"),
122
122
  modelSettings: modelSettingsSchema.default(modelSettingsSchema.parse({})),
123
123
  modelRequestTimeoutMs: z.number().int().positive().default(9e4),
124
124
  t2i: baseT2ISettingsSchema.default(baseT2ISettingsSchema.parse({})),
@@ -975,7 +975,8 @@ var writeToolInputSchema = {
975
975
  enrichLinks: z3.boolean().optional(),
976
976
  link: z3.array(z3.string()).optional(),
977
977
  unlink: z3.array(z3.string()).optional(),
978
- maxLinks: z3.coerce.number().int().positive().optional()
978
+ maxLinks: z3.coerce.number().int().positive().optional(),
979
+ maxImages: z3.coerce.number().int().min(1).optional()
979
980
  };
980
981
  var writeToolInputZodSchema = z3.object(writeToolInputSchema);
981
982
  var writeResumeToolInputSchema = {
@@ -983,7 +984,8 @@ var writeResumeToolInputSchema = {
983
984
  enrichLinks: z3.boolean().optional(),
984
985
  link: z3.array(z3.string()).optional(),
985
986
  unlink: z3.array(z3.string()).optional(),
986
- maxLinks: z3.coerce.number().int().positive().optional()
987
+ maxLinks: z3.coerce.number().int().positive().optional(),
988
+ maxImages: z3.coerce.number().int().min(1).optional()
987
989
  };
988
990
  var writeResumeToolInputZodSchema = z3.object(writeResumeToolInputSchema);
989
991
  var deleteToolInputSchema = {
@@ -1007,6 +1009,13 @@ var linksToolInputSchema = {
1007
1009
  maxLinks: z3.coerce.number().int().positive().optional()
1008
1010
  };
1009
1011
  var linksToolInputZodSchema = z3.object(linksToolInputSchema);
1012
+ var exportToolInputSchema = {
1013
+ generationId: z3.string().min(1),
1014
+ destinationPath: z3.string().min(1),
1015
+ index: z3.coerce.number().int().positive().optional(),
1016
+ overwrite: z3.boolean().optional()
1017
+ };
1018
+ var exportToolInputZodSchema = z3.object(exportToolInputSchema);
1010
1019
  var configListToolInputSchema = {};
1011
1020
  var configListToolInputZodSchema = z3.object(configListToolInputSchema);
1012
1021
  var configUnsetToolInputSchema = {
@@ -1040,6 +1049,11 @@ var ideonToolContracts = [
1040
1049
  mode: ["fresh", "append"]
1041
1050
  }
1042
1051
  },
1052
+ {
1053
+ name: "ideon_export",
1054
+ required: ["generationId", "destinationPath"],
1055
+ enums: {}
1056
+ },
1043
1057
  {
1044
1058
  name: "ideon_config_set",
1045
1059
  required: ["key", "value"],
@@ -1348,7 +1362,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
1348
1362
  // package.json
1349
1363
  var package_default = {
1350
1364
  name: "@telepat/ideon",
1351
- version: "0.1.16",
1365
+ version: "0.1.19",
1352
1366
  description: "CLI for generating rich articles and images from ideas.",
1353
1367
  type: "module",
1354
1368
  repository: {
@@ -1406,6 +1420,7 @@ var package_default = {
1406
1420
  dependencies: {
1407
1421
  "@ant-design/icons": "^6.1.1",
1408
1422
  "@modelcontextprotocol/sdk": "^1.29.0",
1423
+ "@telepat/limn": "^0.1.5",
1409
1424
  antd: "^6.3.4",
1410
1425
  commander: "^14.0.1",
1411
1426
  "env-paths": "^3.0.0",
@@ -2269,9 +2284,8 @@ function buildArticlePlanJsonSchema(targetLengthWords) {
2269
2284
  items: {
2270
2285
  type: "object",
2271
2286
  additionalProperties: false,
2272
- required: ["anchorAfterSection", "description"],
2287
+ required: ["description"],
2273
2288
  properties: {
2274
- anchorAfterSection: { type: "integer", minimum: 1, maximum: 10 },
2275
2289
  description: { type: "string" }
2276
2290
  }
2277
2291
  }
@@ -2310,7 +2324,7 @@ function buildArticlePlanMessages(idea, options) {
2310
2324
  "- Sections are article-only structure and must not be treated as requirements for non-article channels.",
2311
2325
  "- Include a cover image description and 2 to 3 inline image descriptions.",
2312
2326
  "- Image descriptions must be concrete and contextual, not generic stock-photo phrasing.",
2313
- "- Inline images should be anchored after specific sections using 1-based indexes.",
2327
+ "- Do not include section placement for inline images \u2014 placement is determined automatically after writing.",
2314
2328
  "",
2315
2329
  "Shared content brief context:",
2316
2330
  `- description: ${options.contentBrief.description}`,
@@ -2329,7 +2343,7 @@ function buildArticlePlanMessages(idea, options) {
2329
2343
  "- outroBrief: string",
2330
2344
  `- sections: array of ${sectionCounts.label} objects, each with title and description strings`,
2331
2345
  "- coverImageDescription: string",
2332
- "- inlineImages: array of 2 to 3 objects, each with anchorAfterSection (integer 1 to 10) and description string",
2346
+ "- inlineImages: array of 2 to 3 objects, each with a description string only",
2333
2347
  "",
2334
2348
  "Do not omit any required fields. Return strict JSON only."
2335
2349
  ].join("\n")
@@ -2344,7 +2358,6 @@ var articleSectionPlanSchema = z5.object({
2344
2358
  description: z5.string().min(1)
2345
2359
  });
2346
2360
  var inlineImagePlanSchema = z5.object({
2347
- anchorAfterSection: z5.number().int().min(1).max(10),
2348
2361
  description: z5.string().min(1)
2349
2362
  });
2350
2363
  var articlePlanSchema = z5.object({
@@ -2400,7 +2413,7 @@ async function planArticle({
2400
2413
  ...basePlan,
2401
2414
  slug: uniqueSlug,
2402
2415
  keywords: basePlan.keywords.slice(0, 8),
2403
- inlineImages: basePlan.inlineImages.filter((image) => image.anchorAfterSection <= basePlan.sections.length).slice(0, 3)
2416
+ inlineImages: basePlan.inlineImages.slice(0, 3)
2404
2417
  };
2405
2418
  }
2406
2419
  function buildDryRunPlan(idea, contentBrief) {
@@ -2438,11 +2451,9 @@ function buildDryRunPlan(idea, contentBrief) {
2438
2451
  coverImageDescription: "A refined editorial workspace with notebooks, sketches, and glowing structured outlines, cinematic but minimal.",
2439
2452
  inlineImages: [
2440
2453
  {
2441
- anchorAfterSection: 1,
2442
2454
  description: "A rough idea evolving into a structured article outline on a desk full of notes."
2443
2455
  },
2444
2456
  {
2445
- anchorAfterSection: 3,
2446
2457
  description: "A collaborative editorial scene where human judgment and AI suggestions coexist."
2447
2458
  }
2448
2459
  ]
@@ -2864,65 +2875,8 @@ function normalizeGeneratedSection(content, label2) {
2864
2875
  return normalized.replace(/^```(?:markdown)?\s*/i, "").replace(/```\s*$/i, "").trim();
2865
2876
  }
2866
2877
 
2867
- // src/images/replicateClient.ts
2868
- import Replicate from "replicate";
2869
- var ReplicateClient = class {
2870
- client;
2871
- constructor(apiToken) {
2872
- this.client = new Replicate({ auth: apiToken });
2873
- }
2874
- async runModel(model, input, options = {}) {
2875
- let lastError = null;
2876
- const startedAtMs = Date.now();
2877
- let attempts = 0;
2878
- let retries = 0;
2879
- let retryBackoffMs = 0;
2880
- for (let attempt = 0; attempt < 3; attempt += 1) {
2881
- attempts = attempt + 1;
2882
- try {
2883
- const output = await this.client.run(model, { input });
2884
- options.onMetrics?.({
2885
- durationMs: Date.now() - startedAtMs,
2886
- attempts,
2887
- retries,
2888
- retryBackoffMs,
2889
- modelId: model
2890
- });
2891
- return output;
2892
- } catch (error) {
2893
- lastError = error instanceof Error ? error : new Error("Unknown Replicate client error.");
2894
- if (attempt < 2 && shouldRetryError(lastError)) {
2895
- const backoff = backoffMs(attempt);
2896
- retries += 1;
2897
- retryBackoffMs += backoff;
2898
- options.onRetry?.({
2899
- attempts,
2900
- retries,
2901
- retryBackoffMs,
2902
- backoffMs: backoff,
2903
- errorMessage: lastError.message,
2904
- modelId: model
2905
- });
2906
- await wait(backoff);
2907
- continue;
2908
- }
2909
- break;
2910
- }
2911
- }
2912
- throw lastError ?? new Error("Replicate request failed.");
2913
- }
2914
- };
2915
- function shouldRetryError(error) {
2916
- return /timeout|network|fetch|temporarily|rate|429|500|502|503|504/i.test(error.message);
2917
- }
2918
- function backoffMs(attempt) {
2919
- return 500 * (attempt + 1);
2920
- }
2921
- function wait(ms) {
2922
- return new Promise((resolve) => {
2923
- setTimeout(resolve, ms);
2924
- });
2925
- }
2878
+ // src/pipeline/runner.ts
2879
+ import { Limn } from "@telepat/limn";
2926
2880
 
2927
2881
  // src/images/renderImages.ts
2928
2882
  import { writeFile as writeFile4 } from "fs/promises";
@@ -2937,496 +2891,42 @@ var imagePromptSchema = {
2937
2891
  prompt: { type: "string" }
2938
2892
  }
2939
2893
  };
2940
- function buildImagePromptMessages(plan, image) {
2894
+ function buildImagePromptMessages(plan, image, section) {
2895
+ const userLines = [
2896
+ `Article title: ${plan.title}`,
2897
+ `Article subtitle: ${plan.subtitle}`,
2898
+ `Article description: ${plan.description}`,
2899
+ `Image kind: ${image.kind}`,
2900
+ `Image description: ${image.description}`
2901
+ ];
2902
+ if (section) {
2903
+ userLines.push(
2904
+ `Section title: ${section.title}`,
2905
+ `Section excerpt: ${section.body.slice(0, 500)}`
2906
+ );
2907
+ }
2908
+ userLines.push("Write one strong visual prompt describing the image in natural language.");
2941
2909
  return [
2942
2910
  {
2943
2911
  role: "system",
2944
- content: "You write concise, high-signal prompts for text-to-image models. The prompt should be vivid, compositionally clear, and suitable for editorial illustration. Return only the requested JSON."
2912
+ content: "You write concise, high-signal prompts for text-to-image models. The prompt should be vivid and compositionally clear. Do not include any words, letters, numbers, logos, watermarks, or signage in the image, unless text in the image was explicitly requested. Return only the requested JSON."
2945
2913
  },
2946
2914
  {
2947
2915
  role: "user",
2948
- content: [
2949
- `Article title: ${plan.title}`,
2950
- `Article subtitle: ${plan.subtitle}`,
2951
- `Article description: ${plan.description}`,
2952
- `Image kind: ${image.kind}`,
2953
- `Image description: ${image.description}`,
2954
- "Write one strong prompt for a clean editorial illustration. Avoid text overlays or watermarks."
2955
- ].join("\n")
2916
+ content: userLines.join("\n")
2956
2917
  }
2957
2918
  ];
2958
2919
  }
2959
2920
 
2960
- // src/models/t2i/definitions/bytedance__seedream-4.json
2961
- var bytedance_seedream_4_default = {
2962
- modelId: "bytedance/seedream-4",
2963
- provider: "replicate",
2964
- displayName: "Seedream 4",
2965
- category: "image-generation",
2966
- pricingSourceUrl: "https://replicate.com/bytedance/seedream-4",
2967
- pricingNotes: "Replicate pricing: $0.03 per output image.",
2968
- pricing: {
2969
- usdPerSecond: null,
2970
- usdPer1kInputTokens: null,
2971
- usdPer1kOutputTokens: null
2972
- },
2973
- pricingRules: {
2974
- basis: "output_image_count",
2975
- usdPerImage: 0.03
2976
- },
2977
- inputOptions: {
2978
- userConfigurable: ["size", "sequential_image_generation", "max_images", "enhance_prompt"],
2979
- pipelineManaged: ["prompt", "aspect_ratio"],
2980
- fields: {
2981
- size: {
2982
- type: "string",
2983
- default: "2K",
2984
- enum: ["1K", "2K", "4K"]
2985
- },
2986
- sequential_image_generation: {
2987
- type: "string",
2988
- default: "disabled",
2989
- enum: ["disabled", "auto"]
2990
- },
2991
- max_images: {
2992
- type: "integer",
2993
- default: 1,
2994
- minimum: 1,
2995
- maximum: 15
2996
- },
2997
- enhance_prompt: {
2998
- type: "boolean",
2999
- default: true
3000
- },
3001
- prompt: {
3002
- type: "string",
3003
- required: true
3004
- },
3005
- aspect_ratio: {
3006
- type: "string",
3007
- required: true,
3008
- enum: ["1:1", "16:9", "9:16"]
3009
- }
3010
- }
3011
- }
3012
- };
3013
-
3014
- // src/models/t2i/definitions/black-forest-labs__flux-2-pro.json
3015
- var black_forest_labs_flux_2_pro_default = {
3016
- modelId: "black-forest-labs/flux-2-pro",
3017
- provider: "replicate",
3018
- displayName: "FLUX 2 Pro",
3019
- category: "image-generation",
3020
- pricingSourceUrl: "https://replicate.com/black-forest-labs/flux-2-pro",
3021
- pricingNotes: "Replicate lists multi-property pricing for run and input/output megapixels; this model currently uses best-effort cost fallback when exact rule mapping is unavailable.",
3022
- pricing: {
3023
- usdPerSecond: null,
3024
- usdPer1kInputTokens: null,
3025
- usdPer1kOutputTokens: null
3026
- },
3027
- inputOptions: {
3028
- userConfigurable: ["safety_tolerance", "seed", "output_format", "output_quality"],
3029
- pipelineManaged: ["prompt", "aspect_ratio", "width", "height"],
3030
- fields: {
3031
- safety_tolerance: {
3032
- type: "integer",
3033
- default: 2,
3034
- minimum: 1,
3035
- maximum: 5
3036
- },
3037
- seed: {
3038
- type: "integer",
3039
- nullable: true
3040
- },
3041
- output_format: {
3042
- type: "string",
3043
- default: "webp",
3044
- enum: ["webp", "png", "jpg", "jpeg"]
3045
- },
3046
- output_quality: {
3047
- type: "integer",
3048
- default: 80,
3049
- minimum: 0,
3050
- maximum: 100
3051
- },
3052
- prompt: {
3053
- type: "string",
3054
- required: true
3055
- },
3056
- aspect_ratio: {
3057
- type: "string",
3058
- required: true,
3059
- enum: ["custom"]
3060
- },
3061
- width: {
3062
- type: "integer",
3063
- required: true,
3064
- minimum: 256,
3065
- maximum: 2048
3066
- },
3067
- height: {
3068
- type: "integer",
3069
- required: true,
3070
- minimum: 256,
3071
- maximum: 2048
3072
- }
3073
- }
3074
- }
3075
- };
3076
-
3077
- // src/models/t2i/definitions/black-forest-labs__flux-schnell.json
3078
- var black_forest_labs_flux_schnell_default = {
3079
- modelId: "black-forest-labs/flux-schnell",
3080
- provider: "replicate",
3081
- displayName: "FLUX Schnell",
3082
- category: "image-generation",
3083
- pricingSourceUrl: "https://replicate.com/black-forest-labs/flux-schnell",
3084
- pricingNotes: "Replicate pricing: $3 per 1000 output images.",
3085
- pricing: {
3086
- usdPerSecond: null,
3087
- usdPer1kInputTokens: null,
3088
- usdPer1kOutputTokens: null
3089
- },
3090
- pricingRules: {
3091
- basis: "output_image_count",
3092
- usdPerImage: 3e-3
3093
- },
3094
- inputOptions: {
3095
- userConfigurable: ["num_outputs", "num_inference_steps", "seed", "output_format", "output_quality", "disable_safety_checker", "go_fast", "megapixels"],
3096
- pipelineManaged: ["prompt", "aspect_ratio"],
3097
- fields: {
3098
- num_outputs: {
3099
- type: "integer",
3100
- default: 1,
3101
- minimum: 1,
3102
- maximum: 4
3103
- },
3104
- num_inference_steps: {
3105
- type: "integer",
3106
- default: 4,
3107
- minimum: 1,
3108
- maximum: 4
3109
- },
3110
- seed: {
3111
- type: "integer",
3112
- nullable: true
3113
- },
3114
- output_format: {
3115
- type: "string",
3116
- default: "webp",
3117
- enum: ["webp", "jpg", "png"]
3118
- },
3119
- output_quality: {
3120
- type: "integer",
3121
- default: 80,
3122
- minimum: 0,
3123
- maximum: 100
3124
- },
3125
- disable_safety_checker: {
3126
- type: "boolean",
3127
- default: false
3128
- },
3129
- go_fast: {
3130
- type: "boolean",
3131
- default: true
3132
- },
3133
- megapixels: {
3134
- type: "string",
3135
- default: "1",
3136
- allowAnyString: true,
3137
- recommendedValues: ["1"]
3138
- },
3139
- prompt: {
3140
- type: "string",
3141
- required: true
3142
- },
3143
- aspect_ratio: {
3144
- type: "string",
3145
- required: true,
3146
- enum: ["1:1", "16:9", "9:16"]
3147
- }
3148
- }
3149
- }
3150
- };
3151
-
3152
- // src/models/t2i/definitions/google__nano-banana-pro.json
3153
- var google_nano_banana_pro_default = {
3154
- modelId: "google/nano-banana-pro",
3155
- provider: "replicate",
3156
- displayName: "Nano Banana Pro",
3157
- category: "image-generation",
3158
- pricingSourceUrl: "https://replicate.com/google/nano-banana-pro",
3159
- pricingNotes: "Replicate pricing by target resolution: 1K/2K=$0.15 per output image, 4K=$0.30 per output image, fallback tier=$0.035 per output image.",
3160
- pricing: {
3161
- usdPerSecond: null,
3162
- usdPer1kInputTokens: null,
3163
- usdPer1kOutputTokens: null
3164
- },
3165
- pricingRules: {
3166
- basis: "output_image_resolution",
3167
- tiers: [
3168
- {
3169
- resolution: "1K",
3170
- usdPerImage: 0.15
3171
- },
3172
- {
3173
- resolution: "2K",
3174
- usdPerImage: 0.15
3175
- },
3176
- {
3177
- resolution: "4K",
3178
- usdPerImage: 0.3
3179
- },
3180
- {
3181
- resolution: "fallback",
3182
- usdPerImage: 0.035
3183
- }
3184
- ]
3185
- },
3186
- inputOptions: {
3187
- userConfigurable: ["resolution", "output_format", "safety_filter_level", "allow_fallback_model"],
3188
- pipelineManaged: ["prompt", "aspect_ratio"],
3189
- fields: {
3190
- resolution: {
3191
- type: "string",
3192
- default: "2K",
3193
- enum: ["1K", "2K", "4K"]
3194
- },
3195
- output_format: {
3196
- type: "string",
3197
- default: "jpg",
3198
- enum: ["jpg", "png", "webp"]
3199
- },
3200
- safety_filter_level: {
3201
- type: "string",
3202
- default: "block_only_high",
3203
- enum: ["block_low_and_above", "block_medium_and_above", "block_only_high"]
3204
- },
3205
- allow_fallback_model: {
3206
- type: "boolean",
3207
- default: false
3208
- },
3209
- prompt: {
3210
- type: "string",
3211
- required: true
3212
- },
3213
- aspect_ratio: {
3214
- type: "string",
3215
- required: true,
3216
- enum: ["1:1", "16:9", "9:16"]
3217
- }
3218
- }
3219
- }
3220
- };
3221
-
3222
- // src/models/t2i/definitions/prunaai__z-image-turbo.json
3223
- var prunaai_z_image_turbo_default = {
3224
- modelId: "prunaai/z-image-turbo",
3225
- provider: "replicate",
3226
- displayName: "Z Image Turbo",
3227
- category: "image-generation",
3228
- pricingSourceUrl: "https://replicate.com/prunaai/z-image-turbo",
3229
- pricingNotes: "Replicate pricing is tiered by output image resolution (megapixels).",
3230
- pricing: {
3231
- usdPerSecond: null,
3232
- usdPer1kInputTokens: null,
3233
- usdPer1kOutputTokens: null
3234
- },
3235
- pricingRules: {
3236
- basis: "output_image_megapixels",
3237
- tiers: [
3238
- {
3239
- maxMegapixels: 0.5,
3240
- usdPerImage: 25e-4
3241
- },
3242
- {
3243
- maxMegapixels: 1,
3244
- usdPerImage: 5e-3
3245
- },
3246
- {
3247
- maxMegapixels: 2,
3248
- usdPerImage: 0.01
3249
- },
3250
- {
3251
- maxMegapixels: 3,
3252
- usdPerImage: 0.015
3253
- },
3254
- {
3255
- maxMegapixels: 4,
3256
- usdPerImage: 0.02
3257
- }
3258
- ]
3259
- },
3260
- inputOptions: {
3261
- userConfigurable: ["num_inference_steps", "guidance_scale", "seed", "go_fast", "output_format", "output_quality"],
3262
- pipelineManaged: ["prompt", "width", "height"],
3263
- fields: {
3264
- num_inference_steps: {
3265
- type: "integer",
3266
- default: 8,
3267
- minimum: 1,
3268
- maximum: 50
3269
- },
3270
- guidance_scale: {
3271
- type: "number",
3272
- default: 0,
3273
- minimum: 0,
3274
- maximum: 20
3275
- },
3276
- seed: {
3277
- type: "integer",
3278
- nullable: true
3279
- },
3280
- go_fast: {
3281
- type: "boolean",
3282
- default: false
3283
- },
3284
- output_format: {
3285
- type: "string",
3286
- default: "jpg",
3287
- enum: ["jpg", "jpeg", "png", "webp"]
3288
- },
3289
- output_quality: {
3290
- type: "integer",
3291
- default: 80,
3292
- minimum: 0,
3293
- maximum: 100
3294
- },
3295
- prompt: {
3296
- type: "string",
3297
- required: true
3298
- },
3299
- width: {
3300
- type: "integer",
3301
- required: true,
3302
- minimum: 64,
3303
- maximum: 2048
3304
- },
3305
- height: {
3306
- type: "integer",
3307
- required: true,
3308
- minimum: 64,
3309
- maximum: 2048
3310
- }
3311
- }
3312
- }
3313
- };
3314
-
3315
- // src/models/t2i/registry.ts
3316
- var modelDefinitions = [
3317
- black_forest_labs_flux_schnell_default,
3318
- black_forest_labs_flux_2_pro_default,
3319
- bytedance_seedream_4_default,
3320
- google_nano_banana_pro_default,
3321
- prunaai_z_image_turbo_default
3322
- ];
3323
- function getSupportedT2IModels() {
3324
- return modelDefinitions;
3325
- }
3326
- function getT2IModel(modelId) {
3327
- const model = modelDefinitions.find((entry) => entry.modelId === modelId);
3328
- if (!model) {
3329
- throw new Error(`Unsupported T2I model: ${modelId}`);
3330
- }
3331
- return model;
3332
- }
3333
-
3334
- // src/models/t2i/options.ts
3335
- function getT2IFieldDefault(modelId, fieldName) {
3336
- const field = getFieldDefinition(modelId, fieldName);
3337
- return field?.default;
3338
- }
3339
- function sanitizeT2IOverrides(modelId, overrides) {
3340
- const model = getT2IModel(modelId);
3341
- return Object.fromEntries(
3342
- Object.entries(overrides).flatMap(([fieldName, value2]) => {
3343
- if (!model.inputOptions.userConfigurable.includes(fieldName)) {
3344
- return [];
3345
- }
3346
- const sanitized = coerceT2IFieldValue(modelId, fieldName, value2);
3347
- return sanitized === void 0 ? [] : [[fieldName, sanitized]];
3348
- })
3349
- );
3350
- }
3351
- function coerceT2IFieldValue(modelId, fieldName, value2) {
3352
- const field = getFieldDefinition(modelId, fieldName);
3353
- if (!field) {
3354
- return void 0;
3355
- }
3356
- if (value2 === null) {
3357
- return field.nullable ? null : void 0;
3358
- }
3359
- if (value2 === void 0) {
3360
- return void 0;
3361
- }
3362
- if (field.type === "boolean") {
3363
- return coerceBoolean(value2);
3364
- }
3365
- if (field.type === "integer") {
3366
- return clampNumber(parseFiniteNumber(value2, true), field.minimum, field.maximum, true, field.nullable);
3367
- }
3368
- if (field.type === "number") {
3369
- return clampNumber(parseFiniteNumber(value2, false), field.minimum, field.maximum, false, field.nullable);
3370
- }
3371
- if (field.type === "string" || typeof value2 === "string") {
3372
- const normalized = String(value2).trim();
3373
- if (!normalized) {
3374
- return field.nullable ? null : void 0;
3375
- }
3376
- if (field.enum && field.enum.length > 0 && !field.allowAnyString && !field.enum.includes(normalized)) {
3377
- return void 0;
3378
- }
3379
- return normalized;
3380
- }
3381
- return value2;
3382
- }
3383
- function getFieldDefinition(modelId, fieldName) {
3384
- const model = getT2IModel(modelId);
3385
- return model.inputOptions.fields[fieldName];
3386
- }
3387
- function coerceBoolean(value2) {
3388
- if (typeof value2 === "boolean") {
3389
- return value2;
3390
- }
3391
- if (typeof value2 === "string") {
3392
- if (value2.trim() === "true") {
3393
- return true;
3394
- }
3395
- if (value2.trim() === "false") {
3396
- return false;
3397
- }
3398
- }
3399
- return void 0;
3400
- }
3401
- function parseFiniteNumber(value2, integer) {
3402
- const parsed = typeof value2 === "number" ? value2 : Number(String(value2).trim());
3403
- if (!Number.isFinite(parsed)) {
3404
- return void 0;
3405
- }
3406
- return integer ? Math.round(parsed) : parsed;
3407
- }
3408
- function clampNumber(value2, minimum, maximum, integer, nullable) {
3409
- if (value2 === void 0) {
3410
- return nullable ? null : void 0;
3411
- }
3412
- let next = value2;
3413
- if (minimum !== void 0) {
3414
- next = Math.max(minimum, next);
3415
- }
3416
- if (maximum !== void 0) {
3417
- next = Math.min(maximum, next);
3418
- }
3419
- return integer ? Math.round(next) : next;
3420
- }
3421
-
3422
2921
  // src/pipeline/analytics.ts
3423
2922
  var LLM_USD_PER_1K_TOKENS = {
3424
2923
  // AUTO-GENERATED:OPENROUTER_PRICING_START
3425
- // Last refreshed: 2026-03-27
2924
+ // Last refreshed: 2026-05-05
3426
2925
  // Source: https://openrouter.ai/api/v1/models (per-token USD converted to per-1k-token USD)
3427
2926
  "anthropic/claude-3.5-sonnet": { input: 6e-3, output: 0.03 },
3428
2927
  "deepseek/deepseek-chat": { input: 32e-5, output: 89e-5 },
3429
- "moonshotai/kimi-k2.5": { input: 45e-5, output: 22e-4 },
2928
+ "deepseek/deepseek-v4-pro": { input: 435e-6, output: 87e-5 },
2929
+ "moonshotai/kimi-k2.5": { input: 44e-5, output: 2e-3 },
3430
2930
  "openai/gpt-4o-mini": { input: 15e-5, output: 6e-4 }
3431
2931
  // AUTO-GENERATED:OPENROUTER_PRICING_END
3432
2932
  };
@@ -3443,64 +2943,6 @@ function estimateLlmCostUsd(modelId, usage) {
3443
2943
  const usd = promptTokens / 1e3 * pricing.input + completionTokens / 1e3 * pricing.output;
3444
2944
  return { usd, source: "estimated" };
3445
2945
  }
3446
- function estimateImageCostUsd(modelId, input, imageCount) {
3447
- const model = getT2IModel(modelId);
3448
- const pricingRules = "pricingRules" in model ? model.pricingRules : void 0;
3449
- if (!pricingRules) {
3450
- return { usd: null, source: "unavailable" };
3451
- }
3452
- if (pricingRules.basis === "output_image_count" && "usdPerImage" in pricingRules && typeof pricingRules.usdPerImage === "number") {
3453
- return {
3454
- usd: pricingRules.usdPerImage * imageCount,
3455
- source: "estimated"
3456
- };
3457
- }
3458
- if (pricingRules.basis === "output_image_resolution" && "tiers" in pricingRules && Array.isArray(pricingRules.tiers)) {
3459
- const resolution = typeof input.resolution === "string" ? input.resolution : "fallback";
3460
- const tiers = pricingRules.tiers;
3461
- const tier = tiers.find((entry) => entry.resolution === resolution) ?? tiers.find((entry) => entry.resolution === "fallback");
3462
- if (tier?.usdPerImage !== void 0) {
3463
- return {
3464
- usd: tier.usdPerImage * imageCount,
3465
- source: "estimated"
3466
- };
3467
- }
3468
- }
3469
- if (pricingRules.basis === "output_image_megapixels" && "tiers" in pricingRules && Array.isArray(pricingRules.tiers)) {
3470
- const megapixels = resolveMegapixels(input);
3471
- const tiers = pricingRules.tiers;
3472
- const tier = tiers.find((entry) => megapixels <= entry.maxMegapixels);
3473
- if (tier?.usdPerImage !== void 0) {
3474
- return {
3475
- usd: tier.usdPerImage * imageCount,
3476
- source: "estimated"
3477
- };
3478
- }
3479
- }
3480
- return { usd: null, source: "unavailable" };
3481
- }
3482
- function resolveMegapixels(input) {
3483
- const explicit = toNumber(input.megapixels);
3484
- if (explicit !== null) {
3485
- return explicit;
3486
- }
3487
- const width = toNumber(input.width);
3488
- const height = toNumber(input.height);
3489
- if (width !== null && height !== null && width > 0 && height > 0) {
3490
- return width * height / 1e6;
3491
- }
3492
- return 1;
3493
- }
3494
- function toNumber(value2) {
3495
- if (typeof value2 === "number" && Number.isFinite(value2)) {
3496
- return value2;
3497
- }
3498
- if (typeof value2 === "string" && value2.trim() !== "") {
3499
- const parsed = Number(value2);
3500
- return Number.isFinite(parsed) ? parsed : null;
3501
- }
3502
- return null;
3503
- }
3504
2946
  function sumKnownCosts(values) {
3505
2947
  const known = values.filter((value2) => typeof value2 === "number");
3506
2948
  if (known.length !== values.length) {
@@ -3514,8 +2956,52 @@ function sumKnownCosts(values) {
3514
2956
 
3515
2957
  // src/images/renderImages.ts
3516
2958
  var MIN_IMAGE_BYTES = 1024;
2959
+ function selectImageSlots(plan, sections, options) {
2960
+ const sectionCount = sections.length;
2961
+ let defaultInlineCount;
2962
+ if (sectionCount <= 3) {
2963
+ defaultInlineCount = 0;
2964
+ } else if (sectionCount <= 6) {
2965
+ defaultInlineCount = 1;
2966
+ } else {
2967
+ defaultInlineCount = 2;
2968
+ }
2969
+ const availableInlineCount = Math.min(defaultInlineCount, plan.inlineImages.length, sectionCount);
2970
+ let inlineCount;
2971
+ const maxImages = options?.maxImages;
2972
+ if (maxImages !== void 0 && maxImages >= 1) {
2973
+ inlineCount = Math.min(availableInlineCount, Math.max(0, maxImages - 1));
2974
+ } else {
2975
+ inlineCount = availableInlineCount;
2976
+ }
2977
+ const slots = [
2978
+ {
2979
+ id: "cover",
2980
+ kind: "cover",
2981
+ prompt: "",
2982
+ description: plan.coverImageDescription,
2983
+ anchorAfterSection: null
2984
+ }
2985
+ ];
2986
+ for (let i = 0; i < inlineCount; i++) {
2987
+ const anchorAfterSection = Math.max(
2988
+ 1,
2989
+ Math.min(sectionCount, Math.round((i + 1) / (inlineCount + 1) * sectionCount))
2990
+ );
2991
+ slots.push({
2992
+ id: `inline-${i + 1}`,
2993
+ kind: "inline",
2994
+ prompt: "",
2995
+ description: plan.inlineImages[i]?.description ?? "",
2996
+ anchorAfterSection
2997
+ });
2998
+ }
2999
+ return slots;
3000
+ }
3517
3001
  async function expandImagePrompts({
3518
- plan,
3002
+ slots,
3003
+ planContext,
3004
+ sections,
3519
3005
  settings,
3520
3006
  openRouter,
3521
3007
  dryRun,
@@ -3523,16 +3009,16 @@ async function expandImagePrompts({
3523
3009
  onPromptComplete,
3524
3010
  onInteraction
3525
3011
  }) {
3526
- const imageSlots = buildImageSlots(plan);
3527
3012
  const prompts = [];
3528
- for (let index = 0; index < imageSlots.length; index += 1) {
3529
- const image = imageSlots[index];
3530
- onProgress?.(`Expanding prompt ${index + 1}/${imageSlots.length}: ${image.kind === "cover" ? "cover image" : image.description}`);
3013
+ for (let index = 0; index < slots.length; index += 1) {
3014
+ const image = slots[index];
3015
+ onProgress?.(`Expanding prompt ${index + 1}/${slots.length}: ${image.kind === "cover" ? "cover image" : image.description}`);
3016
+ const sectionForImage = image.kind === "inline" && image.anchorAfterSection != null && sections ? sections[image.anchorAfterSection - 1] : void 0;
3531
3017
  if (dryRun || !openRouter) {
3532
3018
  const dryRunStartMs = Date.now();
3533
3019
  prompts.push({
3534
3020
  ...image,
3535
- prompt: `${image.description}, editorial illustration, detailed lighting, modern magazine art direction`
3021
+ prompt: `${image.description}`
3536
3022
  });
3537
3023
  onPromptComplete?.({
3538
3024
  imageId: image.id,
@@ -3554,7 +3040,7 @@ async function expandImagePrompts({
3554
3040
  const response = await openRouter.requestStructured({
3555
3041
  schemaName: "image_prompt",
3556
3042
  schema: imagePromptSchema,
3557
- messages: buildImagePromptMessages(plan, image),
3043
+ messages: buildImagePromptMessages(planContext, image, sectionForImage),
3558
3044
  settings,
3559
3045
  interactionContext: {
3560
3046
  stageId: "image-prompts",
@@ -3599,29 +3085,26 @@ async function expandImagePrompts({
3599
3085
  async function renderExpandedImages({
3600
3086
  prompts,
3601
3087
  settings,
3602
- replicate,
3088
+ limn,
3603
3089
  markdownPath,
3604
3090
  assetDir,
3605
3091
  dryRun,
3606
3092
  onProgress,
3607
3093
  onRenderComplete,
3608
- onInteraction,
3609
- onRetry
3094
+ onInteraction
3610
3095
  }) {
3611
3096
  const renderedImages = [];
3612
3097
  for (let index = 0; index < prompts.length; index += 1) {
3613
3098
  const prompt = prompts[index];
3614
3099
  onProgress?.(`Rendering image ${index + 1}/${prompts.length} with ${settings.t2i.modelId}`);
3615
- const fileName = `${prompt.kind === "cover" ? "cover" : `inline-${prompt.anchorAfterSection}`}-${index + 1}.${resolveOutputFormat(settings)}`;
3100
+ const fileName = `${prompt.kind === "cover" ? "cover" : `inline-${prompt.anchorAfterSection}`}-${index + 1}.png`;
3616
3101
  const outputPath = path6.join(assetDir, fileName);
3617
- if (dryRun || !replicate) {
3102
+ if (dryRun || !limn) {
3618
3103
  const dryRunStartMs = Date.now();
3619
3104
  await writeFile4(outputPath, `Placeholder image for: ${prompt.prompt}
3620
3105
  `, "utf8");
3621
3106
  const outputBytes = Buffer.byteLength(`Placeholder image for: ${prompt.prompt}
3622
3107
  `, "utf8");
3623
- const dryRunInput = createReplicateInput(settings, prompt.prompt, prompt.kind);
3624
- const dryRunCost = estimateImageCostUsd(settings.t2i.modelId, dryRunInput, 1);
3625
3108
  renderedImages.push({
3626
3109
  ...prompt,
3627
3110
  outputPath,
@@ -3636,13 +3119,13 @@ async function renderExpandedImages({
3636
3119
  retries: 0,
3637
3120
  retryBackoffMs: 0,
3638
3121
  outputBytes,
3639
- costUsd: dryRunCost.usd,
3640
- costSource: dryRunCost.source
3122
+ costUsd: null,
3123
+ costSource: "unavailable"
3641
3124
  });
3642
3125
  onInteraction?.({
3643
3126
  stageId: "images",
3644
3127
  operationId: `images:${prompt.id}`,
3645
- provider: "replicate-dry-run",
3128
+ provider: "limn-dry-run",
3646
3129
  modelId: settings.t2i.modelId,
3647
3130
  kind: prompt.kind,
3648
3131
  startedAt: new Date(dryRunStartMs).toISOString(),
@@ -3653,92 +3136,79 @@ async function renderExpandedImages({
3653
3136
  retryBackoffMs: 0,
3654
3137
  status: "succeeded",
3655
3138
  prompt: prompt.prompt,
3656
- input: dryRunInput,
3139
+ input: {},
3657
3140
  errorMessage: null
3658
3141
  });
3659
3142
  continue;
3660
3143
  }
3661
- const input = createReplicateInput(settings, prompt.prompt, prompt.kind);
3144
+ const family = settings.t2i.modelId;
3662
3145
  const renderStartedAtMs = Date.now();
3663
- let runDurationMs = 0;
3664
- let runAttempts = 1;
3665
- let runRetries = 0;
3666
- let runRetryBackoffMs = 0;
3667
3146
  try {
3668
- const output = await replicate.runModel(settings.t2i.modelId, input, {
3669
- onMetrics(metrics) {
3670
- runDurationMs = metrics.durationMs;
3671
- runAttempts = metrics.attempts;
3672
- runRetries = metrics.retries;
3673
- runRetryBackoffMs = metrics.retryBackoffMs;
3674
- },
3675
- onRetry(event) {
3676
- onRetry?.({
3677
- imageId: prompt.id,
3678
- kind: prompt.kind,
3679
- retries: event.retries,
3680
- errorMessage: event.errorMessage
3681
- });
3682
- }
3147
+ const result = await limn.generate(prompt.prompt, family, {
3148
+ replicateModel: settings.t2i.modelId,
3149
+ aspectRatio: "16:9"
3683
3150
  });
3684
- const bytes = await normalizeReplicateOutput(output);
3685
- if (bytes.byteLength < MIN_IMAGE_BYTES) {
3151
+ const ext = mimeTypeToExtension(result.mimeType);
3152
+ const liveFileName = `${prompt.kind === "cover" ? "cover" : `inline-${prompt.anchorAfterSection}`}-${index + 1}.${ext}`;
3153
+ const liveOutputPath = path6.join(assetDir, liveFileName);
3154
+ if (result.image.byteLength < MIN_IMAGE_BYTES) {
3686
3155
  throw new Error(
3687
- `Image ${index + 1} download appears corrupted: only ${bytes.byteLength} bytes received.`
3156
+ `Image ${index + 1} download appears corrupted: only ${result.image.byteLength} bytes received.`
3688
3157
  );
3689
3158
  }
3690
- await writeFile4(outputPath, bytes);
3159
+ await writeFile4(liveOutputPath, result.image);
3691
3160
  renderedImages.push({
3692
3161
  ...prompt,
3693
- outputPath,
3694
- relativePath: relativeAssetPath(markdownPath, outputPath)
3162
+ outputPath: liveOutputPath,
3163
+ relativePath: relativeAssetPath(markdownPath, liveOutputPath)
3695
3164
  });
3696
- const estimatedCost = estimateImageCostUsd(settings.t2i.modelId, input, 1);
3165
+ const costSource = result.analytics.costSource === "unknown" ? "unavailable" : "estimated";
3697
3166
  onRenderComplete?.({
3698
3167
  imageId: prompt.id,
3699
3168
  kind: prompt.kind,
3700
- modelId: settings.t2i.modelId,
3701
- durationMs: runDurationMs,
3702
- attempts: runAttempts,
3703
- retries: runRetries,
3704
- retryBackoffMs: runRetryBackoffMs,
3705
- outputBytes: bytes.byteLength,
3706
- costUsd: estimatedCost.usd,
3707
- costSource: estimatedCost.source
3169
+ modelId: result.modelSlug,
3170
+ durationMs: result.analytics.totalDurationMs,
3171
+ attempts: 1,
3172
+ retries: 0,
3173
+ retryBackoffMs: 0,
3174
+ outputBytes: result.image.byteLength,
3175
+ costUsd: result.analytics.totalEstimatedCostUsd,
3176
+ costSource
3708
3177
  });
3709
3178
  onInteraction?.({
3710
3179
  stageId: "images",
3711
3180
  operationId: `images:${prompt.id}`,
3712
- provider: "replicate",
3713
- modelId: settings.t2i.modelId,
3181
+ provider: "limn",
3182
+ modelId: result.modelSlug,
3714
3183
  kind: prompt.kind,
3715
3184
  startedAt: new Date(renderStartedAtMs).toISOString(),
3716
3185
  endedAt: (/* @__PURE__ */ new Date()).toISOString(),
3717
- durationMs: runDurationMs || Date.now() - renderStartedAtMs,
3718
- attempts: runAttempts,
3719
- retries: runRetries,
3720
- retryBackoffMs: runRetryBackoffMs,
3186
+ durationMs: result.analytics.totalDurationMs,
3187
+ attempts: 1,
3188
+ retries: 0,
3189
+ retryBackoffMs: 0,
3721
3190
  status: "succeeded",
3722
3191
  prompt: prompt.prompt,
3723
- input,
3192
+ input: {},
3724
3193
  errorMessage: null
3725
3194
  });
3726
3195
  } catch (error) {
3196
+ const durationMs = Date.now() - renderStartedAtMs;
3727
3197
  onInteraction?.({
3728
3198
  stageId: "images",
3729
3199
  operationId: `images:${prompt.id}`,
3730
- provider: "replicate",
3200
+ provider: "limn",
3731
3201
  modelId: settings.t2i.modelId,
3732
3202
  kind: prompt.kind,
3733
3203
  startedAt: new Date(renderStartedAtMs).toISOString(),
3734
3204
  endedAt: (/* @__PURE__ */ new Date()).toISOString(),
3735
- durationMs: runDurationMs || Date.now() - renderStartedAtMs,
3736
- attempts: runAttempts,
3737
- retries: runRetries,
3738
- retryBackoffMs: runRetryBackoffMs,
3205
+ durationMs,
3206
+ attempts: 1,
3207
+ retries: 0,
3208
+ retryBackoffMs: 0,
3739
3209
  status: "failed",
3740
3210
  prompt: prompt.prompt,
3741
- input,
3211
+ input: {},
3742
3212
  errorMessage: error instanceof Error ? error.message : "Unknown image render error."
3743
3213
  });
3744
3214
  throw error;
@@ -3770,146 +3240,10 @@ function mergeLlmMetrics(left, right) {
3770
3240
  }
3771
3241
  };
3772
3242
  }
3773
- function buildImageSlots(plan) {
3774
- return [
3775
- {
3776
- id: "cover",
3777
- kind: "cover",
3778
- prompt: "",
3779
- description: plan.coverImageDescription,
3780
- anchorAfterSection: null
3781
- },
3782
- ...plan.inlineImages.map((image, index) => ({
3783
- id: `inline-${index + 1}`,
3784
- kind: "inline",
3785
- prompt: "",
3786
- description: image.description,
3787
- anchorAfterSection: image.anchorAfterSection
3788
- }))
3789
- ];
3790
- }
3791
- function createReplicateInput(settings, prompt, kind) {
3792
- const model = getT2IModel(settings.t2i.modelId);
3793
- const overrides = sanitizeT2IOverrides(settings.t2i.modelId, settings.t2i.inputOverrides);
3794
- const input = { ...overrides, prompt };
3795
- if (model.inputOptions.pipelineManaged.includes("aspect_ratio")) {
3796
- input.aspect_ratio = kind === "cover" ? "16:9" : "16:9";
3797
- }
3798
- if (model.inputOptions.pipelineManaged.includes("width")) {
3799
- input.width = 1536;
3800
- }
3801
- if (model.inputOptions.pipelineManaged.includes("height")) {
3802
- input.height = 864;
3803
- }
3804
- if (!("output_format" in input)) {
3805
- const fallback = getT2IFieldDefault(settings.t2i.modelId, "output_format");
3806
- if (typeof fallback === "string") {
3807
- input.output_format = fallback;
3808
- }
3809
- }
3810
- if (!("num_outputs" in input) && "num_outputs" in model.inputOptions.fields) {
3811
- input.num_outputs = coerceT2IFieldValue(settings.t2i.modelId, "num_outputs", getT2IFieldDefault(settings.t2i.modelId, "num_outputs")) ?? 1;
3812
- }
3813
- if (!("max_images" in input) && "max_images" in model.inputOptions.fields) {
3814
- input.max_images = coerceT2IFieldValue(settings.t2i.modelId, "max_images", getT2IFieldDefault(settings.t2i.modelId, "max_images")) ?? 1;
3815
- }
3816
- return input;
3817
- }
3818
- function resolveOutputFormat(settings) {
3819
- const outputFormat = coerceT2IFieldValue(settings.t2i.modelId, "output_format", settings.t2i.inputOverrides.output_format);
3820
- if (typeof outputFormat === "string") {
3821
- return outputFormat === "jpeg" ? "jpg" : outputFormat;
3822
- }
3823
- const fallback = getT2IFieldDefault(settings.t2i.modelId, "output_format");
3824
- const normalizedFallback = typeof fallback === "string" ? fallback : "png";
3825
- return normalizedFallback === "jpeg" ? "jpg" : normalizedFallback;
3826
- }
3827
- async function normalizeReplicateOutput(output) {
3828
- const first = Array.isArray(output) ? output[0] : output;
3829
- if (!first) {
3830
- throw new Error("Replicate returned no image output.");
3831
- }
3832
- if (typeof first === "string") {
3833
- return fetchBytes(first);
3834
- }
3835
- if (first instanceof URL) {
3836
- return fetchBytes(first.toString());
3837
- }
3838
- if (first instanceof Uint8Array) {
3839
- return first;
3840
- }
3841
- if (first instanceof ArrayBuffer) {
3842
- return new Uint8Array(first);
3843
- }
3844
- if (typeof Blob !== "undefined" && first instanceof Blob) {
3845
- return new Uint8Array(await first.arrayBuffer());
3846
- }
3847
- if (typeof ReadableStream !== "undefined" && first instanceof ReadableStream) {
3848
- return new Uint8Array(await new Response(first).arrayBuffer());
3849
- }
3850
- const fromBlobMethod = await maybeBytesFromBlobMethod(first);
3851
- if (fromBlobMethod) {
3852
- return fromBlobMethod;
3853
- }
3854
- const fromUrlMethod = await maybeBytesFromUrlMethod(first);
3855
- if (fromUrlMethod) {
3856
- return fromUrlMethod;
3857
- }
3858
- const fromArrayBufferMethod = await maybeBytesFromArrayBufferMethod(first);
3859
- if (fromArrayBufferMethod) {
3860
- return fromArrayBufferMethod;
3861
- }
3862
- throw new Error("Unsupported Replicate output format.");
3863
- }
3864
- async function maybeBytesFromBlobMethod(value2) {
3865
- if (!isRecord(value2) || typeof value2.blob !== "function") {
3866
- return null;
3867
- }
3868
- const blobLike = await value2.blob();
3869
- if (typeof Blob === "undefined" || !(blobLike instanceof Blob)) {
3870
- return null;
3871
- }
3872
- return new Uint8Array(await blobLike.arrayBuffer());
3873
- }
3874
- async function maybeBytesFromUrlMethod(value2) {
3875
- if (!isRecord(value2) || typeof value2.url !== "function") {
3876
- return null;
3877
- }
3878
- const urlLike = value2.url();
3879
- if (typeof urlLike === "string") {
3880
- return fetchBytes(urlLike);
3881
- }
3882
- if (urlLike instanceof URL) {
3883
- return fetchBytes(urlLike.toString());
3884
- }
3885
- return null;
3886
- }
3887
- async function maybeBytesFromArrayBufferMethod(value2) {
3888
- if (!isRecord(value2) || typeof value2.arrayBuffer !== "function") {
3889
- return null;
3890
- }
3891
- const data = await value2.arrayBuffer();
3892
- if (data instanceof ArrayBuffer) {
3893
- return new Uint8Array(data);
3894
- }
3895
- return null;
3896
- }
3897
- function isRecord(value2) {
3898
- return typeof value2 === "object" && value2 !== null;
3899
- }
3900
- async function fetchBytes(url) {
3901
- const controller = new AbortController();
3902
- const timer = setTimeout(() => controller.abort(), 6e4);
3903
- timer.unref();
3904
- try {
3905
- const response = await fetch(url, { signal: controller.signal });
3906
- if (!response.ok) {
3907
- throw new Error(`Failed to download generated asset from ${url}`);
3908
- }
3909
- return new Uint8Array(await response.arrayBuffer());
3910
- } finally {
3911
- clearTimeout(timer);
3912
- }
3243
+ function mimeTypeToExtension(mimeType) {
3244
+ if (mimeType === "image/jpeg") return "jpg";
3245
+ if (mimeType === "image/webp") return "webp";
3246
+ return "png";
3913
3247
  }
3914
3248
 
3915
3249
  // src/llm/openRouterClient.ts
@@ -3958,9 +3292,9 @@ var OpenRouterClient = class {
3958
3292
  return structured;
3959
3293
  } catch (parseError) {
3960
3294
  if (attempt < 2 && shouldRetryStructuredParseError(parseError)) {
3961
- const backoff = backoffMs2(attempt);
3295
+ const backoff = backoffMs(attempt);
3962
3296
  aggregatedMetrics = recordParseRetryMetrics(aggregatedMetrics, backoff);
3963
- await wait2(backoff);
3297
+ await wait(backoff);
3964
3298
  continue;
3965
3299
  }
3966
3300
  throw parseError;
@@ -4072,7 +3406,7 @@ var OpenRouterClient = class {
4072
3406
  if (!response.ok) {
4073
3407
  const message = json?.error?.message ?? `OpenRouter request failed with status ${response.status}`;
4074
3408
  if (shouldRetryStatus(response.status) && attempt < 2) {
4075
- const backoff = backoffMs2(attempt);
3409
+ const backoff = backoffMs(attempt);
4076
3410
  retries += 1;
4077
3411
  retryBackoffMs += backoff;
4078
3412
  onInteraction?.({
@@ -4092,14 +3426,14 @@ var OpenRouterClient = class {
4092
3426
  responseBody: responseBodyRaw,
4093
3427
  errorMessage: message
4094
3428
  });
4095
- await wait2(backoff);
3429
+ await wait(backoff);
4096
3430
  continue;
4097
3431
  }
4098
3432
  throw new Error(message);
4099
3433
  }
4100
3434
  const content = json.choices?.[0]?.message?.content;
4101
3435
  if (!content && attempt < 2) {
4102
- const backoff = backoffMs2(attempt);
3436
+ const backoff = backoffMs(attempt);
4103
3437
  retries += 1;
4104
3438
  retryBackoffMs += backoff;
4105
3439
  onInteraction?.({
@@ -4119,7 +3453,7 @@ var OpenRouterClient = class {
4119
3453
  responseBody: responseBodyRaw,
4120
3454
  errorMessage: "OpenRouter returned an empty response."
4121
3455
  });
4122
- await wait2(backoff);
3456
+ await wait(backoff);
4123
3457
  continue;
4124
3458
  }
4125
3459
  onInteraction?.({
@@ -4174,11 +3508,11 @@ var OpenRouterClient = class {
4174
3508
  responseBody: responseBodyRaw,
4175
3509
  errorMessage: lastError.message
4176
3510
  });
4177
- if (attempt < 2 && shouldRetryError2(lastError)) {
4178
- const backoff = backoffMs2(attempt);
3511
+ if (attempt < 2 && shouldRetryError(lastError)) {
3512
+ const backoff = backoffMs(attempt);
4179
3513
  retries += 1;
4180
3514
  retryBackoffMs += backoff;
4181
- await wait2(backoff);
3515
+ await wait(backoff);
4182
3516
  continue;
4183
3517
  }
4184
3518
  } finally {
@@ -4274,7 +3608,7 @@ function isStructuredOutputCompatibilityError(message) {
4274
3608
  function shouldRetryStatus(status) {
4275
3609
  return status === 408 || status === 409 || status === 429 || status >= 500;
4276
3610
  }
4277
- function shouldRetryError2(error) {
3611
+ function shouldRetryError(error) {
4278
3612
  return /timeout|network|fetch|temporarily|aborted/i.test(error.message);
4279
3613
  }
4280
3614
  function shouldRetryStructuredParseError(error) {
@@ -4298,7 +3632,7 @@ function normalizeClientError(error, timeoutMs) {
4298
3632
  }
4299
3633
  return new Error("Unknown OpenRouter client error.");
4300
3634
  }
4301
- function backoffMs2(attempt) {
3635
+ function backoffMs(attempt) {
4302
3636
  return 500 * (attempt + 1);
4303
3637
  }
4304
3638
  function aggregateLlmMetrics(total, next) {
@@ -4332,7 +3666,7 @@ function sumNullableNumber(left, right) {
4332
3666
  }
4333
3667
  return left + right;
4334
3668
  }
4335
- function wait2(ms) {
3669
+ function wait(ms) {
4336
3670
  return new Promise((resolve) => {
4337
3671
  setTimeout(resolve, ms);
4338
3672
  });
@@ -4619,7 +3953,6 @@ async function runPipelineShell(input, options = {}) {
4619
3953
  const stageTracking = /* @__PURE__ */ new Map();
4620
3954
  const stageRetryState = /* @__PURE__ */ new Map();
4621
3955
  const llmOperationRetryState = /* @__PURE__ */ new Map();
4622
- const imageOperationRetryState = /* @__PURE__ */ new Map();
4623
3956
  stageTracking.set("shared-brief", {
4624
3957
  startedAtMs: runStartedAtMs,
4625
3958
  endedAtMs: null,
@@ -4695,7 +4028,11 @@ async function runPipelineShell(input, options = {}) {
4695
4028
  const openRouter = dryRun ? null : new OpenRouterClient(requireSecret(input.config.secrets.openRouterApiKey, "OpenRouter API key"));
4696
4029
  const canRenderImagesLive = Boolean(input.config.secrets.replicateApiToken);
4697
4030
  const imageDryRun = dryRun || !canRenderImagesLive;
4698
- const replicate = imageDryRun ? null : new ReplicateClient(requireSecret(input.config.secrets.replicateApiToken, "Replicate API token"));
4031
+ const limn = imageDryRun ? null : new Limn({
4032
+ openrouterApiKey: input.config.secrets.openRouterApiKey ?? void 0,
4033
+ replicateApiKey: requireSecret(input.config.secrets.replicateApiToken, "Replicate API token"),
4034
+ openrouterModel: input.config.settings.model
4035
+ });
4699
4036
  let contentBrief = writeSession.contentBrief;
4700
4037
  let plan = writeSession.plan;
4701
4038
  let text = writeSession.text;
@@ -4945,7 +4282,9 @@ async function runPipelineShell(input, options = {}) {
4945
4282
  options.onUpdate?.(cloneStages(stages));
4946
4283
  } else {
4947
4284
  imagePrompts = await expandImagePrompts({
4948
- plan,
4285
+ slots: selectImageSlots(plan, text.sections, { maxImages: options.maxImages }),
4286
+ planContext: plan,
4287
+ sections: text.sections,
4949
4288
  settings: input.config.settings,
4950
4289
  openRouter,
4951
4290
  dryRun,
@@ -5126,7 +4465,7 @@ async function runPipelineShell(input, options = {}) {
5126
4465
  const renderedImages = await renderExpandedImages({
5127
4466
  prompts: imagePrompts,
5128
4467
  settings: input.config.settings,
5129
- replicate,
4468
+ limn,
5130
4469
  markdownPath: primaryMarkdownPath,
5131
4470
  assetDir: sharedAssetDir,
5132
4471
  dryRun: imageDryRun,
@@ -5149,15 +4488,6 @@ async function runPipelineShell(input, options = {}) {
5149
4488
  recordStageCost(stageTracking, "images", metrics.costUsd, metrics.costSource);
5150
4489
  addStageRetries(stageTracking, "images", metrics.retries);
5151
4490
  },
5152
- onRetry(event) {
5153
- const operationKey = `images:${event.imageId}`;
5154
- const previousRetries = imageOperationRetryState.get(operationKey) ?? 0;
5155
- if (event.retries <= previousRetries) {
5156
- return;
5157
- }
5158
- imageOperationRetryState.set(operationKey, event.retries);
5159
- applyRetryUpdate("images", event.retries - previousRetries, event.errorMessage);
5160
- },
5161
4491
  onProgress(detail) {
5162
4492
  stages[4] = {
5163
4493
  ...stages[4],
@@ -5219,7 +4549,7 @@ async function runPipelineShell(input, options = {}) {
5219
4549
  const renderedImages = await renderExpandedImages({
5220
4550
  prompts: imagePrompts,
5221
4551
  settings: input.config.settings,
5222
- replicate,
4552
+ limn,
5223
4553
  markdownPath: primaryMarkdownPath,
5224
4554
  assetDir: sharedAssetDir,
5225
4555
  dryRun: imageDryRun,
@@ -5242,15 +4572,6 @@ async function runPipelineShell(input, options = {}) {
5242
4572
  recordStageCost(stageTracking, "images", metrics.costUsd, metrics.costSource);
5243
4573
  addStageRetries(stageTracking, "images", metrics.retries);
5244
4574
  },
5245
- onRetry(event) {
5246
- const operationKey = `images:${event.imageId}`;
5247
- const previousRetries = imageOperationRetryState.get(operationKey) ?? 0;
5248
- if (event.retries <= previousRetries) {
5249
- return;
5250
- }
5251
- imageOperationRetryState.set(operationKey, event.retries);
5252
- applyRetryUpdate("images", event.retries - previousRetries, event.errorMessage);
5253
- },
5254
4575
  onProgress(detail) {
5255
4576
  stages[4] = {
5256
4577
  ...stages[4],
@@ -5965,13 +5286,13 @@ function buildPrimaryCoverPrompt(contentBrief, primaryContentType, primaryMarkdo
5965
5286
  description: `Cover image for ${primaryContentType}`,
5966
5287
  anchorAfterSection: null,
5967
5288
  prompt: [
5968
- `Editorial cover image for ${primaryContentType}.`,
5289
+ `Cover image for ${primaryContentType}.`,
5969
5290
  `Core angle: ${contentBrief.description}`,
5970
5291
  `Audience: ${contentBrief.targetAudience}`,
5971
5292
  `Promise: ${contentBrief.corePromise}`,
5972
5293
  `Voice: ${contentBrief.voiceNotes}`,
5973
5294
  `Primary excerpt: ${markdownExcerpt}`,
5974
- "Cinematic composition, clear focal subject, no text overlays, high visual clarity."
5295
+ "Do not include any words, letters, numbers, logos, watermarks, or signage in the image."
5975
5296
  ].join(" ")
5976
5297
  };
5977
5298
  }
@@ -6310,84 +5631,576 @@ function resolveCustomLinks(existing, addRaw, removeExpressions) {
6310
5631
  return Array.from(result.values());
6311
5632
  }
6312
5633
 
6313
- // src/cli/commands/writeTargetSpecs.ts
6314
- function parseTargetSpec(spec) {
6315
- const trimmed = spec.trim();
6316
- if (!trimmed) {
6317
- throw new ReportedError("Target spec cannot be empty. Use <content-type=count>.");
5634
+ // src/cli/commands/export.ts
5635
+ import { copyFile, mkdir as mkdir6, readFile as readFile8, stat as stat5, writeFile as writeFile6 } from "fs/promises";
5636
+ import path11 from "path";
5637
+
5638
+ // src/output/enrichMarkdownWithLinks.ts
5639
+ function enrichMarkdownWithLinks(markdown, links) {
5640
+ if (links.length === 0) {
5641
+ return markdown;
6318
5642
  }
6319
- const [rawType, rawCount] = trimmed.split("=");
6320
- if (!rawType || !rawCount) {
6321
- throw new ReportedError(`Invalid target "${spec}". Use format content-type=count, e.g. article=1 or x-thread=3.`);
5643
+ const sorted = [...links].sort((left, right) => right.expression.length - left.expression.length);
5644
+ let updated = markdown;
5645
+ for (const link of sorted) {
5646
+ const escapedExpression = escapeRegExp(link.expression);
5647
+ const leadBoundary = /^\w/.test(link.expression) ? "\\b" : "";
5648
+ const trailBoundary = /\w$/.test(link.expression) ? "\\b" : "";
5649
+ const expressionRegex = new RegExp(`${leadBoundary}${escapedExpression}${trailBoundary}`, "g");
5650
+ let match;
5651
+ while ((match = expressionRegex.exec(updated)) !== null) {
5652
+ const start = match.index;
5653
+ const end = start + match[0].length;
5654
+ if (isInProtectedSpan(updated, start, end)) {
5655
+ continue;
5656
+ }
5657
+ updated = `${updated.slice(0, start)}[${match[0]}](${link.url})${updated.slice(end)}`;
5658
+ break;
5659
+ }
6322
5660
  }
6323
- const contentType = rawType.trim();
6324
- if (!contentTypeValues.includes(contentType)) {
6325
- throw new ReportedError(
6326
- `Unsupported content type "${contentType}". Supported values: ${contentTypeValues.join(", ")}.`
6327
- );
5661
+ return updated;
5662
+ }
5663
+ function isInProtectedSpan(content, start, end) {
5664
+ const lineStart = content.lastIndexOf("\n", start) + 1;
5665
+ const lineEndIdx = content.indexOf("\n", end);
5666
+ const lineEnd = lineEndIdx === -1 ? content.length : lineEndIdx;
5667
+ const line = content.slice(lineStart, lineEnd);
5668
+ const linkPattern = /\[[^\]]*\]\([^)]*\)/g;
5669
+ let m;
5670
+ while ((m = linkPattern.exec(line)) !== null) {
5671
+ const absStart = lineStart + m.index;
5672
+ const absEnd = absStart + m[0].length;
5673
+ if (start >= absStart && end <= absEnd) {
5674
+ return true;
5675
+ }
6328
5676
  }
6329
- const count = Number.parseInt(rawCount.trim(), 10);
6330
- if (!Number.isFinite(count) || count <= 0) {
6331
- throw new ReportedError(`Invalid count in target "${spec}". Count must be a positive integer.`);
5677
+ const codePattern = /`[^`]+`/g;
5678
+ while ((m = codePattern.exec(line)) !== null) {
5679
+ const absStart = lineStart + m.index;
5680
+ const absEnd = absStart + m[0].length;
5681
+ if (start >= absStart && end <= absEnd) {
5682
+ return true;
5683
+ }
6332
5684
  }
6333
- return {
6334
- contentType,
6335
- count
6336
- };
5685
+ return false;
6337
5686
  }
6338
- function parsePrimaryAndSecondarySpecs(options) {
6339
- const { primarySpec, secondarySpecs } = options;
6340
- if (!primarySpec && (!secondarySpecs || secondarySpecs.length === 0)) {
6341
- return void 0;
5687
+ function escapeRegExp(value2) {
5688
+ return value2.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
5689
+ }
5690
+
5691
+ // src/server/previewHelpers.ts
5692
+ import { readdir, stat as stat4, readFile as readFile7 } from "fs/promises";
5693
+ import path10 from "path";
5694
+ var DEFAULT_PORT = 4173;
5695
+ var CONTENT_TYPE_ORDER = ["article", "blog-post", "x-thread", "x-post", "linkedin-post", "reddit-post", "newsletter"];
5696
+ var FILE_PREFIX_TO_CONTENT_TYPE = {
5697
+ article: "article",
5698
+ blog: "blog-post",
5699
+ "x-thread": "x-thread",
5700
+ "x-post": "x-post",
5701
+ x: "x-post",
5702
+ reddit: "reddit-post",
5703
+ linkedin: "linkedin-post",
5704
+ newsletter: "newsletter"
5705
+ };
5706
+ var CONTENT_TYPE_LABELS = {
5707
+ article: "Article",
5708
+ "blog-post": "Blog Post",
5709
+ "x-thread": "X Thread",
5710
+ "x-post": "X Post",
5711
+ "reddit-post": "Reddit Post",
5712
+ "linkedin-post": "LinkedIn Post",
5713
+ newsletter: "Newsletter"
5714
+ };
5715
+ function parsePort(portOption) {
5716
+ if (!portOption) {
5717
+ return DEFAULT_PORT;
6342
5718
  }
6343
- if (!primarySpec) {
6344
- throw new ReportedError("Missing required --primary <content-type=count>.");
5719
+ const port = Number.parseInt(portOption, 10);
5720
+ if (Number.isNaN(port) || port < 1 || port > 65535) {
5721
+ throw new Error(`Invalid port "${portOption}". Choose a value between 1 and 65535.`);
6345
5722
  }
6346
- const primary = parseTargetSpec(primarySpec);
6347
- if (primary.count !== 1) {
6348
- throw new ReportedError("Primary target count must be exactly 1. Use --primary <content-type=1>.");
5723
+ return port;
5724
+ }
5725
+ function stripFrontmatter2(markdown) {
5726
+ return markdown.replace(/^---\s*\n[\s\S]*?\n---\s*\n?/, "");
5727
+ }
5728
+ function extractFrontmatterSlug(markdown) {
5729
+ const frontmatterMatch = markdown.match(/^---\s*\n([\s\S]*?)\n---\s*\n?/);
5730
+ const frontmatter = frontmatterMatch?.[1];
5731
+ if (!frontmatter) {
5732
+ return null;
6349
5733
  }
6350
- const secondaryDedupedByType = /* @__PURE__ */ new Map();
6351
- for (const spec of secondarySpecs ?? []) {
6352
- const parsed = parseTargetSpec(spec);
6353
- if (parsed.contentType === primary.contentType) {
6354
- throw new ReportedError(
6355
- `Content type "${parsed.contentType}" cannot be both primary and secondary in the same run.`
6356
- );
5734
+ const slugMatch = frontmatter.match(/^slug:\s*(.+)$/m);
5735
+ const rawSlug = slugMatch?.[1]?.trim();
5736
+ if (!rawSlug) {
5737
+ return null;
5738
+ }
5739
+ const unquoted = rawSlug.replace(/^['\"]|['\"]$/g, "").trim();
5740
+ return unquoted.length > 0 ? unquoted : null;
5741
+ }
5742
+ function extractHeadingTitle(markdown) {
5743
+ const headingMatch = markdown.match(/^#\s+(.+)$/m);
5744
+ if (!headingMatch || !headingMatch[1]) {
5745
+ return null;
5746
+ }
5747
+ return headingMatch[1].trim();
5748
+ }
5749
+ async function resolveMarkdownPath(markdownPathArg, markdownOutputDir, cwd2) {
5750
+ if (markdownPathArg) {
5751
+ const resolved = path10.isAbsolute(markdownPathArg) ? markdownPathArg : path10.resolve(cwd2, markdownPathArg);
5752
+ if (path10.extname(resolved).toLowerCase() !== ".md") {
5753
+ throw new Error(`Expected a markdown file (.md), received: ${resolved}`);
6357
5754
  }
6358
- const previous = secondaryDedupedByType.get(parsed.contentType);
6359
- if (previous) {
6360
- previous.count += parsed.count;
6361
- continue;
5755
+ await assertFileExists(resolved, "Could not find markdown file");
5756
+ return resolved;
5757
+ }
5758
+ return await resolveLatestMarkdown(markdownOutputDir);
5759
+ }
5760
+ async function resolveLatestMarkdown(markdownOutputDir) {
5761
+ const markdownCandidates = await findMarkdownFiles(markdownOutputDir);
5762
+ if (markdownCandidates.length === 0) {
5763
+ throw new Error(
5764
+ `No generated articles found in ${markdownOutputDir}. Run ideon write "your idea" first or pass a markdown path.`
5765
+ );
5766
+ }
5767
+ let latestPath = markdownCandidates[0];
5768
+ let latestMtime = 0;
5769
+ for (const candidate of markdownCandidates) {
5770
+ const fileStat = await stat4(candidate);
5771
+ if (fileStat.mtimeMs >= latestMtime) {
5772
+ latestMtime = fileStat.mtimeMs;
5773
+ latestPath = candidate;
6362
5774
  }
6363
- secondaryDedupedByType.set(parsed.contentType, {
6364
- ...parsed,
6365
- role: "secondary"
6366
- });
6367
5775
  }
6368
- return [
6369
- {
6370
- ...primary,
6371
- role: "primary"
6372
- },
6373
- ...secondaryDedupedByType.values()
6374
- ];
5776
+ return latestPath;
6375
5777
  }
6376
-
6377
- // src/integrations/mcp/server.ts
6378
- async function startIdeonMcpServer() {
6379
- const server = new McpServer({
6380
- name: "ideon",
6381
- version: package_default.version
6382
- });
6383
- server.registerTool(
6384
- "ideon_write",
6385
- {
6386
- title: "Ideon Write",
6387
- description: "Generate content from an idea using the Ideon pipeline.",
6388
- inputSchema: writeToolInputSchema
6389
- },
6390
- async (input) => {
5778
+ async function assertFileExists(filePath, errorPrefix) {
5779
+ try {
5780
+ const fileStat = await stat4(filePath);
5781
+ if (!fileStat.isFile()) {
5782
+ throw new Error(`${errorPrefix}: ${filePath}`);
5783
+ }
5784
+ } catch {
5785
+ throw new Error(`${errorPrefix}: ${filePath}`);
5786
+ }
5787
+ }
5788
+ function extractCoverImageUrl(markdown) {
5789
+ const body = stripFrontmatter2(markdown);
5790
+ const match = body.match(/!\[[^\]]*\]\(([^)]+)\)/);
5791
+ return match?.[1] ?? null;
5792
+ }
5793
+ async function extractArticleMetadata(markdownPath) {
5794
+ const markdown = await readFile7(markdownPath, "utf8");
5795
+ const fileStat = await stat4(markdownPath);
5796
+ const slug = extractFrontmatterSlug(markdown) ?? path10.basename(markdownPath, ".md");
5797
+ const title = extractHeadingTitle(stripFrontmatter2(markdown)) ?? slug;
5798
+ const body = stripFrontmatter2(markdown);
5799
+ const previewSnippet = body.replace(/[#\[\]()!\-*_`]/g, "").trim().substring(0, 150);
5800
+ const coverImageUrl = extractCoverImageUrl(markdown);
5801
+ return {
5802
+ slug,
5803
+ title,
5804
+ mtime: fileStat.mtimeMs,
5805
+ previewSnippet,
5806
+ coverImageUrl
5807
+ };
5808
+ }
5809
+ async function listAllGenerations(markdownOutputDir) {
5810
+ const markdownFiles = await findMarkdownFiles(markdownOutputDir);
5811
+ const grouped = /* @__PURE__ */ new Map();
5812
+ for (const filePath of markdownFiles) {
5813
+ try {
5814
+ const metadata = await extractArticleMetadata(filePath);
5815
+ const identity = deriveOutputIdentity(filePath, markdownOutputDir);
5816
+ const output = {
5817
+ id: `${identity.generationId}:${identity.contentType}:${identity.index}`,
5818
+ generationId: identity.generationId,
5819
+ sourcePath: filePath,
5820
+ slug: metadata.slug,
5821
+ title: metadata.title,
5822
+ previewSnippet: metadata.previewSnippet,
5823
+ coverImageUrl: metadata.coverImageUrl,
5824
+ mtime: metadata.mtime,
5825
+ contentType: identity.contentType,
5826
+ contentTypeLabel: toContentTypeLabel(identity.contentType),
5827
+ index: identity.index
5828
+ };
5829
+ const existing = grouped.get(identity.generationId);
5830
+ if (existing) {
5831
+ existing.push(output);
5832
+ } else {
5833
+ grouped.set(identity.generationId, [output]);
5834
+ }
5835
+ } catch {
5836
+ }
5837
+ }
5838
+ const generations = [];
5839
+ for (const [id, outputs] of grouped.entries()) {
5840
+ outputs.sort((a, b) => compareContentTypes(a.contentType, b.contentType) || a.index - b.index || b.mtime - a.mtime);
5841
+ const primaryContentType = await resolvePrimaryContentType(outputs);
5842
+ const primary = outputs.find((output) => output.contentType === primaryContentType) ?? outputs[0];
5843
+ if (!primary) {
5844
+ continue;
5845
+ }
5846
+ const newestMtime = outputs.reduce((latest, output) => Math.max(latest, output.mtime), 0);
5847
+ generations.push({
5848
+ id,
5849
+ title: primary.title,
5850
+ mtime: newestMtime,
5851
+ previewSnippet: primary.previewSnippet,
5852
+ coverImageUrl: primary.coverImageUrl ?? outputs.find((output) => Boolean(output.coverImageUrl))?.coverImageUrl ?? null,
5853
+ primaryContentType,
5854
+ outputs
5855
+ });
5856
+ }
5857
+ generations.sort((a, b) => b.mtime - a.mtime);
5858
+ return generations;
5859
+ }
5860
+ function deriveGenerationId(markdownPath, markdownOutputDir) {
5861
+ const relative = path10.relative(markdownOutputDir, markdownPath);
5862
+ const normalized = relative.split(path10.sep).join("/");
5863
+ if (!normalized || normalized.startsWith("../")) {
5864
+ return path10.basename(markdownPath, ".md");
5865
+ }
5866
+ const segments = normalized.split("/").filter(Boolean);
5867
+ if (segments.length <= 1) {
5868
+ return path10.basename(markdownPath, ".md");
5869
+ }
5870
+ return segments[0] ?? path10.basename(markdownPath, ".md");
5871
+ }
5872
+ async function findMarkdownFiles(markdownOutputDir) {
5873
+ const files = [];
5874
+ const stack = [markdownOutputDir];
5875
+ while (stack.length > 0) {
5876
+ const current = stack.pop();
5877
+ if (!current) {
5878
+ continue;
5879
+ }
5880
+ let entries;
5881
+ try {
5882
+ entries = await readdir(current, { withFileTypes: true });
5883
+ } catch {
5884
+ continue;
5885
+ }
5886
+ for (const entry of entries) {
5887
+ const fullPath = path10.join(current, entry.name);
5888
+ if (entry.isDirectory()) {
5889
+ stack.push(fullPath);
5890
+ continue;
5891
+ }
5892
+ if (entry.isFile() && entry.name.toLowerCase().endsWith(".md")) {
5893
+ files.push(fullPath);
5894
+ }
5895
+ }
5896
+ }
5897
+ return files;
5898
+ }
5899
+ function deriveOutputIdentity(markdownPath, markdownOutputDir) {
5900
+ const generationId = deriveGenerationId(markdownPath, markdownOutputDir);
5901
+ const fileBase = path10.basename(markdownPath, ".md");
5902
+ const parsed = fileBase.match(/^([a-z0-9-]+)-(\d+)$/i);
5903
+ if (!parsed || !parsed[1] || !parsed[2]) {
5904
+ return {
5905
+ generationId,
5906
+ contentType: "article",
5907
+ index: 1
5908
+ };
5909
+ }
5910
+ const prefix = parsed[1].toLowerCase();
5911
+ const index = Number.parseInt(parsed[2], 10);
5912
+ return {
5913
+ generationId,
5914
+ contentType: FILE_PREFIX_TO_CONTENT_TYPE[prefix] ?? prefix,
5915
+ index: Number.isFinite(index) && index > 0 ? index : 1
5916
+ };
5917
+ }
5918
+ function compareContentTypes(left, right) {
5919
+ const leftIndex = CONTENT_TYPE_ORDER.indexOf(left);
5920
+ const rightIndex = CONTENT_TYPE_ORDER.indexOf(right);
5921
+ const normalizedLeft = leftIndex === -1 ? Number.MAX_SAFE_INTEGER : leftIndex;
5922
+ const normalizedRight = rightIndex === -1 ? Number.MAX_SAFE_INTEGER : rightIndex;
5923
+ if (normalizedLeft !== normalizedRight) {
5924
+ return normalizedLeft - normalizedRight;
5925
+ }
5926
+ return left.localeCompare(right);
5927
+ }
5928
+ function toContentTypeLabel(contentType) {
5929
+ const knownLabel = CONTENT_TYPE_LABELS[contentType];
5930
+ if (knownLabel) {
5931
+ return knownLabel;
5932
+ }
5933
+ return contentType.split("-").map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
5934
+ }
5935
+ async function resolvePrimaryContentType(outputs) {
5936
+ const fallback = outputs.find((output) => output.contentType === "article")?.contentType ?? outputs[0]?.contentType ?? "article";
5937
+ const generationDir = path10.dirname(outputs[0]?.sourcePath ?? "");
5938
+ if (!generationDir) {
5939
+ return fallback;
5940
+ }
5941
+ const jobPath = path10.join(generationDir, "job.json");
5942
+ try {
5943
+ const raw = await readFile7(jobPath, "utf8");
5944
+ const parsed = JSON.parse(raw);
5945
+ const targets = Array.isArray(parsed.contentTargets) ? parsed.contentTargets : Array.isArray(parsed.settings?.contentTargets) ? parsed.settings.contentTargets : [];
5946
+ const primary = targets.find((target) => target?.role === "primary" && typeof target.contentType === "string");
5947
+ if (primary && typeof primary.contentType === "string") {
5948
+ return primary.contentType;
5949
+ }
5950
+ } catch {
5951
+ return fallback;
5952
+ }
5953
+ return fallback;
5954
+ }
5955
+
5956
+ // src/cli/commands/export.ts
5957
+ async function runOutputCommand(options, dependencies = {}) {
5958
+ const cwd2 = dependencies.cwd ?? process.cwd();
5959
+ const log = dependencies.log ?? ((message) => console.log(message));
5960
+ const targetIndex = options.index ?? 1;
5961
+ const resolved = await resolveRunInput({ idea: `Export generation ${options.generationId}` });
5962
+ const outputPaths = resolveOutputPaths(resolved.config.settings, cwd2);
5963
+ const generations = await listAllGenerations(outputPaths.markdownOutputDir);
5964
+ const generation = resolveGeneration(generations, options.generationId);
5965
+ const articleOutputs = generation.outputs.filter((output) => output.contentType === generation.primaryContentType);
5966
+ if (articleOutputs.length === 0) {
5967
+ throw new ReportedError(
5968
+ `Generation "${generation.id}" has no primary content outputs (type: ${generation.primaryContentType}).`
5969
+ );
5970
+ }
5971
+ const articleOutput = articleOutputs.find((output) => output.index === targetIndex);
5972
+ if (!articleOutput) {
5973
+ const available = articleOutputs.map((output) => output.index).join(", ");
5974
+ throw new ReportedError(
5975
+ `Generation "${generation.id}" has no primary output at index ${targetIndex}. Available: ${available}.`
5976
+ );
5977
+ }
5978
+ const sourceMarkdownPath = articleOutput.sourcePath;
5979
+ const sourceMarkdown = await readFile8(sourceMarkdownPath, "utf8");
5980
+ const slug = extractFrontmatterSlug2(sourceMarkdown) ?? path11.basename(sourceMarkdownPath, ".md");
5981
+ const exportFilename = `${slug}.md`;
5982
+ const destinationDir = await resolveDestinationDir(options.destinationPath, cwd2);
5983
+ const destinationFilePath = path11.join(destinationDir, exportFilename);
5984
+ if (!options.overwrite && await fileExists2(destinationFilePath)) {
5985
+ throw new ReportedError(
5986
+ `Export file already exists: ${destinationFilePath}. Pass --overwrite to replace it.`
5987
+ );
5988
+ }
5989
+ await mkdir6(destinationDir, { recursive: true });
5990
+ const links = await loadLinks(sourceMarkdownPath);
5991
+ const enrichedMarkdown = enrichWithFrontmatterGuard(sourceMarkdown, links);
5992
+ const sourceDir = path11.dirname(sourceMarkdownPath);
5993
+ const imagePaths = extractLocalImagePaths(sourceMarkdown);
5994
+ const copiedImages = [];
5995
+ for (const relImagePath of imagePaths) {
5996
+ const absoluteImageSrc = path11.resolve(sourceDir, relImagePath);
5997
+ let imageStat = null;
5998
+ try {
5999
+ imageStat = await stat5(absoluteImageSrc);
6000
+ } catch {
6001
+ throw new ReportedError(
6002
+ `Referenced image not found: ${relImagePath} (resolved to ${absoluteImageSrc}).`
6003
+ );
6004
+ }
6005
+ if (!imageStat.isFile()) {
6006
+ throw new ReportedError(`Referenced image path is not a file: ${absoluteImageSrc}.`);
6007
+ }
6008
+ const destImagePath = path11.join(destinationDir, relImagePath);
6009
+ await mkdir6(path11.dirname(destImagePath), { recursive: true });
6010
+ await copyFile(absoluteImageSrc, destImagePath);
6011
+ copiedImages.push(relImagePath);
6012
+ }
6013
+ await writeFile6(destinationFilePath, enrichedMarkdown, "utf8");
6014
+ const relDest = path11.relative(cwd2, destinationFilePath);
6015
+ log(`Exported "${generation.id}" (${generation.primaryContentType} #${targetIndex}) \u2192 ${relDest}`);
6016
+ if (copiedImages.length > 0) {
6017
+ log(`Copied ${copiedImages.length} image${copiedImages.length === 1 ? "" : "s"}: ${copiedImages.join(", ")}`);
6018
+ }
6019
+ if (links.length > 0) {
6020
+ log(`Injected ${links.length} inline link${links.length === 1 ? "" : "s"}.`);
6021
+ }
6022
+ }
6023
+ function resolveGeneration(generations, generationId) {
6024
+ const exact = generations.find((g) => g.id === generationId);
6025
+ if (exact) {
6026
+ return exact;
6027
+ }
6028
+ const bySlug = generations.find(
6029
+ (g) => g.outputs.some((output) => output.slug === generationId)
6030
+ );
6031
+ if (bySlug) {
6032
+ return bySlug;
6033
+ }
6034
+ throw new ReportedError(
6035
+ `Generation "${generationId}" not found. Run \`ideon preview\` to list available generations.`
6036
+ );
6037
+ }
6038
+ async function resolveDestinationDir(destinationPath, cwd2) {
6039
+ const resolved = path11.isAbsolute(destinationPath) ? destinationPath : path11.resolve(cwd2, destinationPath);
6040
+ return resolved;
6041
+ }
6042
+ async function fileExists2(filePath) {
6043
+ try {
6044
+ const fileStat = await stat5(filePath);
6045
+ return fileStat.isFile();
6046
+ } catch {
6047
+ return false;
6048
+ }
6049
+ }
6050
+ async function loadLinks(markdownPath) {
6051
+ const linksPath = resolveLinksPath(markdownPath);
6052
+ let raw;
6053
+ try {
6054
+ raw = await readFile8(linksPath, "utf8");
6055
+ } catch {
6056
+ return [];
6057
+ }
6058
+ let parsed;
6059
+ try {
6060
+ parsed = JSON.parse(raw);
6061
+ } catch {
6062
+ return [];
6063
+ }
6064
+ if (typeof parsed !== "object" || parsed === null) {
6065
+ return [];
6066
+ }
6067
+ const record = parsed;
6068
+ const links = Array.isArray(record.links) ? record.links : [];
6069
+ const customLinks = Array.isArray(record.customLinks) ? record.customLinks : [];
6070
+ const combined = [...customLinks, ...links];
6071
+ return combined.filter((entry) => {
6072
+ if (typeof entry !== "object" || entry === null) {
6073
+ return false;
6074
+ }
6075
+ const e = entry;
6076
+ return typeof e.expression === "string" && typeof e.url === "string" && (e.title === null || typeof e.title === "string");
6077
+ }).map((entry) => ({
6078
+ expression: entry.expression.trim(),
6079
+ url: entry.url.trim(),
6080
+ title: entry.title
6081
+ })).filter((entry) => entry.expression.length > 0 && entry.url.length > 0);
6082
+ }
6083
+ function enrichWithFrontmatterGuard(markdown, links) {
6084
+ if (links.length === 0) {
6085
+ return markdown;
6086
+ }
6087
+ const frontmatterMatch = markdown.match(/^---\s*\n[\s\S]*?\n---\s*\n?/);
6088
+ if (!frontmatterMatch) {
6089
+ return enrichMarkdownWithLinks(markdown, links);
6090
+ }
6091
+ const frontmatter = frontmatterMatch[0];
6092
+ const body = markdown.slice(frontmatter.length);
6093
+ return `${frontmatter}${enrichMarkdownWithLinks(body, links)}`;
6094
+ }
6095
+ function extractFrontmatterSlug2(markdown) {
6096
+ const frontmatterMatch = markdown.match(/^---\s*\n([\s\S]*?)\n---\s*\n?/);
6097
+ const block = frontmatterMatch?.[1];
6098
+ if (!block) {
6099
+ return null;
6100
+ }
6101
+ const slugMatch = block.match(/^slug:\s*(.+)$/m);
6102
+ const rawSlug = slugMatch?.[1]?.trim();
6103
+ if (!rawSlug) {
6104
+ return null;
6105
+ }
6106
+ const unquoted = rawSlug.replace(/^['""]|['""]$/g, "").trim();
6107
+ return unquoted.length > 0 ? unquoted : null;
6108
+ }
6109
+ function extractLocalImagePaths(markdown) {
6110
+ const imagePattern = /!\[[^\]]*\]\(([^)]+)\)/g;
6111
+ const paths = [];
6112
+ let match;
6113
+ while ((match = imagePattern.exec(markdown)) !== null) {
6114
+ const rawPath = match[1]?.trim();
6115
+ if (!rawPath) {
6116
+ continue;
6117
+ }
6118
+ if (rawPath.startsWith("http://") || rawPath.startsWith("https://") || rawPath.startsWith("data:") || rawPath.startsWith("/") || rawPath.startsWith("#")) {
6119
+ continue;
6120
+ }
6121
+ paths.push(rawPath);
6122
+ }
6123
+ return paths;
6124
+ }
6125
+
6126
+ // src/cli/commands/writeTargetSpecs.ts
6127
+ function parseTargetSpec(spec) {
6128
+ const trimmed = spec.trim();
6129
+ if (!trimmed) {
6130
+ throw new ReportedError("Target spec cannot be empty. Use <content-type=count>.");
6131
+ }
6132
+ const [rawType, rawCount] = trimmed.split("=");
6133
+ if (!rawType || !rawCount) {
6134
+ throw new ReportedError(`Invalid target "${spec}". Use format content-type=count, e.g. article=1 or x-thread=3.`);
6135
+ }
6136
+ const contentType = rawType.trim();
6137
+ if (!contentTypeValues.includes(contentType)) {
6138
+ throw new ReportedError(
6139
+ `Unsupported content type "${contentType}". Supported values: ${contentTypeValues.join(", ")}.`
6140
+ );
6141
+ }
6142
+ const count = Number.parseInt(rawCount.trim(), 10);
6143
+ if (!Number.isFinite(count) || count <= 0) {
6144
+ throw new ReportedError(`Invalid count in target "${spec}". Count must be a positive integer.`);
6145
+ }
6146
+ return {
6147
+ contentType,
6148
+ count
6149
+ };
6150
+ }
6151
+ function parsePrimaryAndSecondarySpecs(options) {
6152
+ const { primarySpec, secondarySpecs } = options;
6153
+ if (!primarySpec && (!secondarySpecs || secondarySpecs.length === 0)) {
6154
+ return void 0;
6155
+ }
6156
+ if (!primarySpec) {
6157
+ throw new ReportedError("Missing required --primary <content-type=count>.");
6158
+ }
6159
+ const primary = parseTargetSpec(primarySpec);
6160
+ if (primary.count !== 1) {
6161
+ throw new ReportedError("Primary target count must be exactly 1. Use --primary <content-type=1>.");
6162
+ }
6163
+ const secondaryDedupedByType = /* @__PURE__ */ new Map();
6164
+ for (const spec of secondarySpecs ?? []) {
6165
+ const parsed = parseTargetSpec(spec);
6166
+ if (parsed.contentType === primary.contentType) {
6167
+ throw new ReportedError(
6168
+ `Content type "${parsed.contentType}" cannot be both primary and secondary in the same run.`
6169
+ );
6170
+ }
6171
+ const previous = secondaryDedupedByType.get(parsed.contentType);
6172
+ if (previous) {
6173
+ previous.count += parsed.count;
6174
+ continue;
6175
+ }
6176
+ secondaryDedupedByType.set(parsed.contentType, {
6177
+ ...parsed,
6178
+ role: "secondary"
6179
+ });
6180
+ }
6181
+ return [
6182
+ {
6183
+ ...primary,
6184
+ role: "primary"
6185
+ },
6186
+ ...secondaryDedupedByType.values()
6187
+ ];
6188
+ }
6189
+
6190
+ // src/integrations/mcp/server.ts
6191
+ async function startIdeonMcpServer() {
6192
+ const server = new McpServer({
6193
+ name: "ideon",
6194
+ version: package_default.version
6195
+ });
6196
+ server.registerTool(
6197
+ "ideon_write",
6198
+ {
6199
+ title: "Ideon Write",
6200
+ description: "Generate content from an idea using the Ideon pipeline.",
6201
+ inputSchema: writeToolInputSchema
6202
+ },
6203
+ async (input) => {
6391
6204
  try {
6392
6205
  const parsedTargets = parsePrimaryAndSecondarySpecs({
6393
6206
  primarySpec: input.primary,
@@ -6409,7 +6222,8 @@ async function startIdeonMcpServer() {
6409
6222
  enrichLinks: input.enrichLinks ?? false,
6410
6223
  customLinks: input.link,
6411
6224
  unlinks: input.unlink,
6412
- maxLinks: input.maxLinks
6225
+ maxLinks: input.maxLinks,
6226
+ maxImages: input.maxImages
6413
6227
  });
6414
6228
  return {
6415
6229
  content: [
@@ -6468,7 +6282,8 @@ async function startIdeonMcpServer() {
6468
6282
  enrichLinks: input.enrichLinks ?? false,
6469
6283
  customLinks: input.link,
6470
6284
  unlinks: input.unlink,
6471
- maxLinks: input.maxLinks
6285
+ maxLinks: input.maxLinks,
6286
+ maxImages: input.maxImages
6472
6287
  });
6473
6288
  return {
6474
6289
  content: [
@@ -6569,6 +6384,50 @@ async function startIdeonMcpServer() {
6569
6384
  }
6570
6385
  }
6571
6386
  );
6387
+ server.registerTool(
6388
+ "ideon_export",
6389
+ {
6390
+ title: "Ideon Export",
6391
+ description: "Export a generated article as a standalone markdown file with inline links and copied images.",
6392
+ inputSchema: exportToolInputZodSchema
6393
+ },
6394
+ async (input) => {
6395
+ try {
6396
+ const messages = [];
6397
+ await runOutputCommand(
6398
+ {
6399
+ generationId: input.generationId,
6400
+ destinationPath: input.destinationPath,
6401
+ index: input.index,
6402
+ overwrite: input.overwrite
6403
+ },
6404
+ {
6405
+ cwd: cwd(),
6406
+ log: (message) => {
6407
+ messages.push(message);
6408
+ }
6409
+ }
6410
+ );
6411
+ return {
6412
+ content: [
6413
+ {
6414
+ type: "text",
6415
+ text: messages.length > 0 ? messages.join("\n") : `Exported ${input.generationId}.`
6416
+ }
6417
+ ],
6418
+ structuredContent: {
6419
+ generationId: input.generationId,
6420
+ destinationPath: input.destinationPath,
6421
+ index: input.index ?? 1,
6422
+ overwrite: input.overwrite ?? false,
6423
+ messages
6424
+ }
6425
+ };
6426
+ } catch (error) {
6427
+ return formatToolError(error);
6428
+ }
6429
+ }
6430
+ );
6572
6431
  server.registerTool(
6573
6432
  "ideon_config_get",
6574
6433
  {
@@ -6658,213 +6517,65 @@ async function startIdeonMcpServer() {
6658
6517
  "ideon_config_unset",
6659
6518
  {
6660
6519
  title: "Ideon Config Unset",
6661
- description: "Reset a setting to its default or delete a stored secret.",
6662
- inputSchema: configUnsetToolInputSchema
6663
- },
6664
- async (input) => {
6665
- try {
6666
- if (!isConfigKey(input.key)) {
6667
- throw new ReportedError(`Unsupported config key: ${input.key}`);
6668
- }
6669
- await configUnset(input.key);
6670
- return {
6671
- content: [
6672
- {
6673
- type: "text",
6674
- text: `Unset ${input.key}.`
6675
- }
6676
- ],
6677
- structuredContent: {
6678
- key: input.key,
6679
- updated: true
6680
- }
6681
- };
6682
- } catch (error) {
6683
- return formatToolError(error);
6684
- }
6685
- }
6686
- );
6687
- const transport = new StdioServerTransport();
6688
- await server.connect(transport);
6689
- }
6690
- function formatToolError(error) {
6691
- const message = error instanceof Error ? error.message : "Unknown error";
6692
- return {
6693
- content: [{ type: "text", text: message }],
6694
- isError: true
6695
- };
6696
- }
6697
-
6698
- // src/cli/commands/mcp.ts
6699
- async function runMcpServeCommand() {
6700
- await startIdeonMcpServer();
6701
- }
6702
-
6703
- // src/cli/commands/settings.tsx
6704
- import { render } from "ink";
6705
-
6706
- // src/cli/flows/settingsFlow.tsx
6707
- import { useMemo, useState } from "react";
6708
- import { Box, Text, useApp, useInput } from "ink";
6709
- import SelectInput from "ink-select-input";
6710
- import TextInput from "ink-text-input";
6711
- import { jsx, jsxs } from "react/jsx-runtime";
6712
- function SettingsFlow({ initialSettings, initialSecrets, onDone }) {
6713
- const { exit } = useApp();
6714
- const [settings, setSettings] = useState(initialSettings);
6715
- const [secrets, setSecrets] = useState(initialSecrets);
6716
- const [editing, setEditing] = useState(null);
6717
- const [showModelSelect, setShowModelSelect] = useState(false);
6718
- const currentModel = getT2IModel(settings.t2i.modelId);
6719
- useInput((input, key) => {
6720
- if (key.escape) {
6721
- if (editing) {
6722
- setEditing(null);
6723
- return;
6724
- }
6725
- if (showModelSelect) {
6726
- setShowModelSelect(false);
6727
- }
6728
- }
6729
- if (key.ctrl && input === "c") {
6730
- onDone(null);
6731
- exit();
6732
- }
6733
- });
6734
- const menuItems = useMemo(() => {
6735
- const t2iItems = currentModel.inputOptions.userConfigurable.map((fieldName) => ({
6736
- label: `${fieldName}: ${formatValue(settings.t2i.inputOverrides[fieldName] ?? getT2IFieldDefault(settings.t2i.modelId, fieldName))}`,
6737
- value: `t2i:${fieldName}`
6738
- }));
6739
- return [
6740
- {
6741
- label: `OpenRouter API key: ${secrets.openRouterApiKey ? "stored in keychain" : "missing"}`,
6742
- value: "openrouter"
6743
- },
6744
- {
6745
- label: `Replicate API token: ${secrets.replicateApiToken ? "stored in keychain" : "missing"}`,
6746
- value: "replicate"
6747
- },
6748
- {
6749
- label: `LLM model: ${settings.model}`,
6750
- value: "llm-model"
6751
- },
6752
- {
6753
- label: `Notifications > OS notifications enabled: ${settings.notifications.enabled ? "true" : "false"}`,
6754
- value: "notifications-enabled"
6755
- },
6756
- {
6757
- label: `Temperature: ${settings.modelSettings.temperature}`,
6758
- value: "temperature"
6759
- },
6760
- {
6761
- label: `Max tokens: ${settings.modelSettings.maxTokens}`,
6762
- value: "maxTokens"
6763
- },
6764
- {
6765
- label: `Top p: ${settings.modelSettings.topP}`,
6766
- value: "topP"
6767
- },
6768
- {
6769
- label: `Markdown output directory: ${settings.markdownOutputDir}`,
6770
- value: "markdownOutputDir"
6771
- },
6772
- {
6773
- label: `Asset output directory: ${settings.assetOutputDir}`,
6774
- value: "assetOutputDir"
6775
- },
6776
- {
6777
- label: `T2I model: ${currentModel.displayName}`,
6778
- value: "t2i-model"
6779
- },
6780
- ...t2iItems,
6781
- {
6782
- label: "Save and exit",
6783
- value: "save"
6784
- },
6785
- {
6786
- label: "Cancel",
6787
- value: "cancel"
6788
- }
6789
- ];
6790
- }, [currentModel, secrets.openRouterApiKey, secrets.replicateApiToken, settings]);
6791
- if (showModelSelect) {
6792
- const items = getSupportedT2IModels().map((model) => ({
6793
- label: `${model.displayName} (${model.modelId})`,
6794
- value: model.modelId
6795
- }));
6796
- return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
6797
- /* @__PURE__ */ jsx(Text, { bold: true, color: "cyanBright", children: "Choose T2I Model" }),
6798
- /* @__PURE__ */ jsx(Text, { color: "gray", children: "Press Esc to go back." }),
6799
- /* @__PURE__ */ jsx(
6800
- SelectInput,
6801
- {
6802
- items,
6803
- onSelect: (item) => {
6804
- setSettings((current) => ({
6805
- ...current,
6806
- t2i: {
6807
- modelId: item.value,
6808
- inputOverrides: sanitizeT2IOverrides(item.value, current.t2i.inputOverrides)
6809
- }
6810
- }));
6811
- setShowModelSelect(false);
6812
- }
6813
- }
6814
- )
6815
- ] });
6816
- }
6817
- if (editing) {
6818
- return /* @__PURE__ */ jsx(
6819
- EditorView,
6820
- {
6821
- editing,
6822
- onSubmit: (value2) => {
6823
- applyEdit(editing.key, value2, settings, secrets, setSettings, setSecrets);
6824
- setEditing(null);
6825
- },
6826
- onCancel: () => {
6827
- setEditing(null);
6520
+ description: "Reset a setting to its default or delete a stored secret.",
6521
+ inputSchema: configUnsetToolInputSchema
6522
+ },
6523
+ async (input) => {
6524
+ try {
6525
+ if (!isConfigKey(input.key)) {
6526
+ throw new ReportedError(`Unsupported config key: ${input.key}`);
6828
6527
  }
6528
+ await configUnset(input.key);
6529
+ return {
6530
+ content: [
6531
+ {
6532
+ type: "text",
6533
+ text: `Unset ${input.key}.`
6534
+ }
6535
+ ],
6536
+ structuredContent: {
6537
+ key: input.key,
6538
+ updated: true
6539
+ }
6540
+ };
6541
+ } catch (error) {
6542
+ return formatToolError(error);
6829
6543
  }
6830
- );
6831
- }
6832
- return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
6833
- /* @__PURE__ */ jsx(Text, { bold: true, color: "cyanBright", children: "Ideon Settings" }),
6834
- /* @__PURE__ */ jsx(Text, { color: "gray", children: "Enter to edit. Esc backs out of nested menus. Ctrl+C cancels." }),
6835
- /* @__PURE__ */ jsx(Box, { marginTop: 1, flexDirection: "column", children: /* @__PURE__ */ jsx(SelectInput, { items: menuItems, onSelect: (item) => handleMenuSelect(item.value, settings, secrets, setEditing, setShowModelSelect, onDone, exit) }) })
6836
- ] });
6544
+ }
6545
+ );
6546
+ const transport = new StdioServerTransport();
6547
+ await server.connect(transport);
6837
6548
  }
6838
- function EditorView({
6839
- editing,
6840
- onSubmit,
6841
- onCancel
6842
- }) {
6843
- const [value2, setValue] = useState(editing.value);
6844
- return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
6845
- /* @__PURE__ */ jsx(Text, { bold: true, color: "cyanBright", children: editing.label }),
6846
- /* @__PURE__ */ jsx(Text, { color: "gray", children: "Enter saves. Blank value clears nullable secrets and overrides. Esc cancels." }),
6847
- /* @__PURE__ */ jsxs(Box, { marginTop: 1, children: [
6848
- /* @__PURE__ */ jsx(Text, { children: "> " }),
6849
- /* @__PURE__ */ jsx(
6850
- TextInput,
6851
- {
6852
- value: value2,
6853
- onChange: setValue,
6854
- onSubmit: (nextValue) => {
6855
- onSubmit(nextValue);
6856
- }
6857
- }
6858
- )
6859
- ] }),
6860
- /* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsxs(Text, { color: "gray", children: [
6861
- "Current value: ",
6862
- editing.value || "(empty)"
6863
- ] }) }),
6864
- /* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsx(Text, { color: "gray", dimColor: true, children: "Press Esc to return without changes." }) })
6865
- ] });
6549
+ function formatToolError(error) {
6550
+ const message = error instanceof Error ? error.message : "Unknown error";
6551
+ return {
6552
+ content: [{ type: "text", text: message }],
6553
+ isError: true
6554
+ };
6555
+ }
6556
+
6557
+ // src/cli/commands/mcp.ts
6558
+ async function runMcpServeCommand() {
6559
+ await startIdeonMcpServer();
6560
+ }
6561
+
6562
+ // src/cli/commands/settings.tsx
6563
+ import { render } from "ink";
6564
+
6565
+ // src/cli/flows/settingsFlow.tsx
6566
+ import { useEffect, useMemo, useState } from "react";
6567
+ import { Box, Text, useApp, useInput } from "ink";
6568
+ import SelectInput from "ink-select-input";
6569
+ import TextInput from "ink-text-input";
6570
+
6571
+ // src/images/limnModelCatalog.ts
6572
+ import { getSupportedModelCatalog } from "@telepat/limn";
6573
+ function getLimnGenerationModels() {
6574
+ return getSupportedModelCatalog().filter((entry) => entry.generationEnabled);
6866
6575
  }
6867
- function handleMenuSelect(action, settings, secrets, setEditing, setShowModelSelect, onDone, exit) {
6576
+
6577
+ // src/cli/flows/settingsFlowLogic.ts
6578
+ function handleMenuSelect(action, settings, secrets, setEditing, setShowModelSelect, setMenuMode, onDone, exit) {
6868
6579
  switch (action) {
6869
6580
  case "openrouter":
6870
6581
  setEditing({ key: action, label: "OpenRouter API key", value: secrets.openRouterApiKey ?? "" });
@@ -6893,9 +6604,22 @@ function handleMenuSelect(action, settings, secrets, setEditing, setShowModelSel
6893
6604
  case "assetOutputDir":
6894
6605
  setEditing({ key: action, label: "Asset output directory", value: settings.assetOutputDir });
6895
6606
  return;
6607
+ case "t2i-settings":
6608
+ setMenuMode("t2i");
6609
+ return;
6896
6610
  case "t2i-model":
6897
6611
  setShowModelSelect(true);
6898
6612
  return;
6613
+ case "t2i-input-overrides":
6614
+ setEditing({
6615
+ key: action,
6616
+ label: "T2I input overrides (JSON)",
6617
+ value: JSON.stringify(settings.t2i.inputOverrides, null, 2)
6618
+ });
6619
+ return;
6620
+ case "t2i-back":
6621
+ setMenuMode("main");
6622
+ return;
6899
6623
  case "save":
6900
6624
  onDone({ settings, secrets });
6901
6625
  exit();
@@ -6904,30 +6628,20 @@ function handleMenuSelect(action, settings, secrets, setEditing, setShowModelSel
6904
6628
  onDone(null);
6905
6629
  exit();
6906
6630
  return;
6907
- default:
6908
- if (action.startsWith("t2i:")) {
6909
- const fieldName = action.slice(4);
6910
- const currentModel = getT2IModel(settings.t2i.modelId);
6911
- setEditing({
6912
- key: action,
6913
- label: `${currentModel.displayName} \u2022 ${fieldName}`,
6914
- value: formatEditorValue(settings.t2i.inputOverrides[fieldName] ?? getT2IFieldDefault(settings.t2i.modelId, fieldName))
6915
- });
6916
- }
6917
6631
  }
6918
6632
  }
6919
6633
  function applyEdit(action, value2, settings, secrets, setSettings, setSecrets) {
6920
6634
  if (action === "openrouter") {
6921
6635
  setSecrets({ ...secrets, openRouterApiKey: value2.trim() || null });
6922
- return;
6636
+ return true;
6923
6637
  }
6924
6638
  if (action === "replicate") {
6925
6639
  setSecrets({ ...secrets, replicateApiToken: value2.trim() || null });
6926
- return;
6640
+ return true;
6927
6641
  }
6928
6642
  if (action === "llm-model") {
6929
6643
  setSettings({ ...settings, model: value2.trim() || settings.model });
6930
- return;
6644
+ return true;
6931
6645
  }
6932
6646
  if (action === "notifications-enabled") {
6933
6647
  const parsed = parseBooleanOrFallback(value2, settings.notifications.enabled);
@@ -6935,475 +6649,354 @@ function applyEdit(action, value2, settings, secrets, setSettings, setSecrets) {
6935
6649
  ...settings,
6936
6650
  notifications: {
6937
6651
  ...settings.notifications,
6938
- enabled: parsed
6939
- }
6940
- });
6941
- return;
6942
- }
6943
- if (action === "temperature") {
6944
- const nextTemperature = clampNumber2(parseNumberOrFallback(value2, settings.modelSettings.temperature), 0, 2);
6945
- setSettings({
6946
- ...settings,
6947
- modelSettings: {
6948
- ...settings.modelSettings,
6949
- temperature: nextTemperature
6950
- }
6951
- });
6952
- return;
6953
- }
6954
- if (action === "maxTokens") {
6955
- const nextMaxTokens = Math.max(1, Math.round(parseNumberOrFallback(value2, settings.modelSettings.maxTokens)));
6956
- setSettings({
6957
- ...settings,
6958
- modelSettings: {
6959
- ...settings.modelSettings,
6960
- maxTokens: nextMaxTokens
6961
- }
6962
- });
6963
- return;
6964
- }
6965
- if (action === "topP") {
6966
- const nextTopP = clampNumber2(parseNumberOrFallback(value2, settings.modelSettings.topP), 0, 1);
6967
- setSettings({
6968
- ...settings,
6969
- modelSettings: {
6970
- ...settings.modelSettings,
6971
- topP: nextTopP
6972
- }
6973
- });
6974
- return;
6975
- }
6976
- if (action === "markdownOutputDir") {
6977
- setSettings({ ...settings, markdownOutputDir: value2.trim() || settings.markdownOutputDir });
6978
- return;
6979
- }
6980
- if (action === "assetOutputDir") {
6981
- setSettings({ ...settings, assetOutputDir: value2.trim() || settings.assetOutputDir });
6982
- return;
6983
- }
6984
- if (action.startsWith("t2i:")) {
6985
- const fieldName = action.slice(4);
6986
- const parsedValue = coerceT2IFieldValue(settings.t2i.modelId, fieldName, value2);
6987
- const nextOverrides = { ...settings.t2i.inputOverrides };
6988
- if (parsedValue === void 0) {
6989
- delete nextOverrides[fieldName];
6990
- } else {
6991
- nextOverrides[fieldName] = parsedValue;
6992
- }
6993
- setSettings({
6994
- ...settings,
6995
- t2i: {
6996
- ...settings.t2i,
6997
- inputOverrides: nextOverrides
6998
- }
6999
- });
7000
- }
7001
- }
7002
- function formatEditorValue(value2) {
7003
- if (value2 === null || value2 === void 0) {
7004
- return "";
7005
- }
7006
- return String(value2);
7007
- }
7008
- function formatValue(value2) {
7009
- if (value2 === null || value2 === void 0 || value2 === "") {
7010
- return "(default)";
7011
- }
7012
- return String(value2);
7013
- }
7014
- function parseNumberOrFallback(value2, fallback) {
7015
- const parsed = Number(value2.trim());
7016
- return Number.isFinite(parsed) ? parsed : fallback;
7017
- }
7018
- function clampNumber2(value2, minimum, maximum) {
7019
- return Math.min(maximum, Math.max(minimum, value2));
7020
- }
7021
- function parseBooleanOrFallback(value2, fallback) {
7022
- const normalized = value2.trim().toLowerCase();
7023
- if (normalized === "true") {
7024
- return true;
7025
- }
7026
- if (normalized === "false") {
7027
- return false;
7028
- }
7029
- return fallback;
7030
- }
7031
-
7032
- // src/cli/commands/settings.tsx
7033
- import { jsx as jsx2 } from "react/jsx-runtime";
7034
- async function openSettings() {
7035
- const envSettings = readEnvSettings();
7036
- const [settings, secrets] = await Promise.all([
7037
- loadSavedSettings(),
7038
- loadSecrets({ disableKeytar: envSettings.disableKeytar })
7039
- ]);
7040
- let result = null;
7041
- const app = render(
7042
- /* @__PURE__ */ jsx2(
7043
- SettingsFlow,
7044
- {
7045
- initialSettings: settings,
7046
- initialSecrets: secrets,
7047
- onDone: (value2) => {
7048
- result = value2;
7049
- }
7050
- }
7051
- )
7052
- );
7053
- await app.waitUntilExit();
7054
- const finalResult = result;
7055
- if (!finalResult) {
7056
- console.log("Settings unchanged.");
7057
- return;
7058
- }
7059
- const savedResult = finalResult;
7060
- await saveSettings(savedResult.settings);
7061
- try {
7062
- await saveSecrets(savedResult.secrets, { disableKeytar: envSettings.disableKeytar });
7063
- } catch (error) {
7064
- if (error instanceof KeytarUnavailableError) {
7065
- console.log("Settings saved, but secrets were not stored in the system keychain.");
7066
- console.log("Use IDEON_OPENROUTER_API_KEY and IDEON_REPLICATE_API_TOKEN in this environment.");
7067
- return;
7068
- }
7069
- throw error;
7070
- }
7071
- console.log(`Settings saved to ${getSettingsFilePath()}.`);
7072
- }
7073
-
7074
- // src/cli/commands/serve.ts
7075
- import path12 from "path";
7076
- import { spawn } from "child_process";
7077
-
7078
- // src/server/previewHelpers.ts
7079
- import { readdir, stat as stat4, readFile as readFile7 } from "fs/promises";
7080
- import path10 from "path";
7081
- var DEFAULT_PORT = 4173;
7082
- var CONTENT_TYPE_ORDER = ["article", "blog-post", "x-thread", "x-post", "linkedin-post", "reddit-post", "newsletter"];
7083
- var FILE_PREFIX_TO_CONTENT_TYPE = {
7084
- article: "article",
7085
- blog: "blog-post",
7086
- "x-thread": "x-thread",
7087
- "x-post": "x-post",
7088
- x: "x-post",
7089
- reddit: "reddit-post",
7090
- linkedin: "linkedin-post",
7091
- newsletter: "newsletter"
7092
- };
7093
- var CONTENT_TYPE_LABELS = {
7094
- article: "Article",
7095
- "blog-post": "Blog Post",
7096
- "x-thread": "X Thread",
7097
- "x-post": "X Post",
7098
- "reddit-post": "Reddit Post",
7099
- "linkedin-post": "LinkedIn Post",
7100
- newsletter: "Newsletter"
7101
- };
7102
- function parsePort(portOption) {
7103
- if (!portOption) {
7104
- return DEFAULT_PORT;
7105
- }
7106
- const port = Number.parseInt(portOption, 10);
7107
- if (Number.isNaN(port) || port < 1 || port > 65535) {
7108
- throw new Error(`Invalid port "${portOption}". Choose a value between 1 and 65535.`);
7109
- }
7110
- return port;
7111
- }
7112
- function stripFrontmatter2(markdown) {
7113
- return markdown.replace(/^---\s*\n[\s\S]*?\n---\s*\n?/, "");
7114
- }
7115
- function extractFrontmatterSlug(markdown) {
7116
- const frontmatterMatch = markdown.match(/^---\s*\n([\s\S]*?)\n---\s*\n?/);
7117
- const frontmatter = frontmatterMatch?.[1];
7118
- if (!frontmatter) {
7119
- return null;
6652
+ enabled: parsed
6653
+ }
6654
+ });
6655
+ return true;
7120
6656
  }
7121
- const slugMatch = frontmatter.match(/^slug:\s*(.+)$/m);
7122
- const rawSlug = slugMatch?.[1]?.trim();
7123
- if (!rawSlug) {
7124
- return null;
6657
+ if (action === "temperature") {
6658
+ const nextTemperature = clampNumber(parseNumberOrFallback(value2, settings.modelSettings.temperature), 0, 2);
6659
+ setSettings({
6660
+ ...settings,
6661
+ modelSettings: {
6662
+ ...settings.modelSettings,
6663
+ temperature: nextTemperature
6664
+ }
6665
+ });
6666
+ return true;
7125
6667
  }
7126
- const unquoted = rawSlug.replace(/^['\"]|['\"]$/g, "").trim();
7127
- return unquoted.length > 0 ? unquoted : null;
7128
- }
7129
- function extractHeadingTitle(markdown) {
7130
- const headingMatch = markdown.match(/^#\s+(.+)$/m);
7131
- if (!headingMatch || !headingMatch[1]) {
7132
- return null;
6668
+ if (action === "maxTokens") {
6669
+ const nextMaxTokens = Math.max(1, Math.round(parseNumberOrFallback(value2, settings.modelSettings.maxTokens)));
6670
+ setSettings({
6671
+ ...settings,
6672
+ modelSettings: {
6673
+ ...settings.modelSettings,
6674
+ maxTokens: nextMaxTokens
6675
+ }
6676
+ });
6677
+ return true;
7133
6678
  }
7134
- return headingMatch[1].trim();
7135
- }
7136
- async function resolveMarkdownPath(markdownPathArg, markdownOutputDir, cwd2) {
7137
- if (markdownPathArg) {
7138
- const resolved = path10.isAbsolute(markdownPathArg) ? markdownPathArg : path10.resolve(cwd2, markdownPathArg);
7139
- if (path10.extname(resolved).toLowerCase() !== ".md") {
7140
- throw new Error(`Expected a markdown file (.md), received: ${resolved}`);
7141
- }
7142
- await assertFileExists(resolved, "Could not find markdown file");
7143
- return resolved;
6679
+ if (action === "topP") {
6680
+ const nextTopP = clampNumber(parseNumberOrFallback(value2, settings.modelSettings.topP), 0, 1);
6681
+ setSettings({
6682
+ ...settings,
6683
+ modelSettings: {
6684
+ ...settings.modelSettings,
6685
+ topP: nextTopP
6686
+ }
6687
+ });
6688
+ return true;
7144
6689
  }
7145
- return await resolveLatestMarkdown(markdownOutputDir);
7146
- }
7147
- async function resolveLatestMarkdown(markdownOutputDir) {
7148
- const markdownCandidates = await findMarkdownFiles(markdownOutputDir);
7149
- if (markdownCandidates.length === 0) {
7150
- throw new Error(
7151
- `No generated articles found in ${markdownOutputDir}. Run ideon write "your idea" first or pass a markdown path.`
7152
- );
6690
+ if (action === "markdownOutputDir") {
6691
+ setSettings({ ...settings, markdownOutputDir: value2.trim() || settings.markdownOutputDir });
6692
+ return true;
7153
6693
  }
7154
- let latestPath = markdownCandidates[0];
7155
- let latestMtime = 0;
7156
- for (const candidate of markdownCandidates) {
7157
- const fileStat = await stat4(candidate);
7158
- if (fileStat.mtimeMs >= latestMtime) {
7159
- latestMtime = fileStat.mtimeMs;
7160
- latestPath = candidate;
7161
- }
6694
+ if (action === "assetOutputDir") {
6695
+ setSettings({ ...settings, assetOutputDir: value2.trim() || settings.assetOutputDir });
6696
+ return true;
7162
6697
  }
7163
- return latestPath;
7164
- }
7165
- async function assertFileExists(filePath, errorPrefix) {
7166
- try {
7167
- const fileStat = await stat4(filePath);
7168
- if (!fileStat.isFile()) {
7169
- throw new Error(`${errorPrefix}: ${filePath}`);
6698
+ if (action === "t2i-input-overrides") {
6699
+ const trimmed = value2.trim();
6700
+ if (trimmed.length === 0) {
6701
+ setSettings({
6702
+ ...settings,
6703
+ t2i: {
6704
+ ...settings.t2i,
6705
+ inputOverrides: {}
6706
+ }
6707
+ });
6708
+ return true;
7170
6709
  }
7171
- } catch {
7172
- throw new Error(`${errorPrefix}: ${filePath}`);
7173
- }
7174
- }
7175
- function extractCoverImageUrl(markdown) {
7176
- const body = stripFrontmatter2(markdown);
7177
- const match = body.match(/!\[[^\]]*\]\(([^)]+)\)/);
7178
- return match?.[1] ?? null;
7179
- }
7180
- async function extractArticleMetadata(markdownPath) {
7181
- const markdown = await readFile7(markdownPath, "utf8");
7182
- const fileStat = await stat4(markdownPath);
7183
- const slug = extractFrontmatterSlug(markdown) ?? path10.basename(markdownPath, ".md");
7184
- const title = extractHeadingTitle(stripFrontmatter2(markdown)) ?? slug;
7185
- const body = stripFrontmatter2(markdown);
7186
- const previewSnippet = body.replace(/[#\[\]()!\-*_`]/g, "").trim().substring(0, 150);
7187
- const coverImageUrl = extractCoverImageUrl(markdown);
7188
- return {
7189
- slug,
7190
- title,
7191
- mtime: fileStat.mtimeMs,
7192
- previewSnippet,
7193
- coverImageUrl
7194
- };
7195
- }
7196
- async function listAllGenerations(markdownOutputDir) {
7197
- const markdownFiles = await findMarkdownFiles(markdownOutputDir);
7198
- const grouped = /* @__PURE__ */ new Map();
7199
- for (const filePath of markdownFiles) {
7200
6710
  try {
7201
- const metadata = await extractArticleMetadata(filePath);
7202
- const identity = deriveOutputIdentity(filePath, markdownOutputDir);
7203
- const output = {
7204
- id: `${identity.generationId}:${identity.contentType}:${identity.index}`,
7205
- generationId: identity.generationId,
7206
- sourcePath: filePath,
7207
- slug: metadata.slug,
7208
- title: metadata.title,
7209
- previewSnippet: metadata.previewSnippet,
7210
- coverImageUrl: metadata.coverImageUrl,
7211
- mtime: metadata.mtime,
7212
- contentType: identity.contentType,
7213
- contentTypeLabel: toContentTypeLabel(identity.contentType),
7214
- index: identity.index
7215
- };
7216
- const existing = grouped.get(identity.generationId);
7217
- if (existing) {
7218
- existing.push(output);
7219
- } else {
7220
- grouped.set(identity.generationId, [output]);
6711
+ const parsed = JSON.parse(trimmed);
6712
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
6713
+ return false;
7221
6714
  }
6715
+ setSettings({
6716
+ ...settings,
6717
+ t2i: {
6718
+ ...settings.t2i,
6719
+ inputOverrides: parsed
6720
+ }
6721
+ });
6722
+ return true;
7222
6723
  } catch {
6724
+ return false;
7223
6725
  }
7224
6726
  }
7225
- const generations = [];
7226
- for (const [id, outputs] of grouped.entries()) {
7227
- outputs.sort((a, b) => compareContentTypes(a.contentType, b.contentType) || a.index - b.index || b.mtime - a.mtime);
7228
- const primaryContentType = await resolvePrimaryContentType(outputs);
7229
- const primary = outputs.find((output) => output.contentType === primaryContentType) ?? outputs[0];
7230
- if (!primary) {
7231
- continue;
7232
- }
7233
- const newestMtime = outputs.reduce((latest, output) => Math.max(latest, output.mtime), 0);
7234
- generations.push({
7235
- id,
7236
- title: primary.title,
7237
- mtime: newestMtime,
7238
- previewSnippet: primary.previewSnippet,
7239
- coverImageUrl: primary.coverImageUrl ?? outputs.find((output) => Boolean(output.coverImageUrl))?.coverImageUrl ?? null,
7240
- primaryContentType,
7241
- outputs
7242
- });
7243
- }
7244
- generations.sort((a, b) => b.mtime - a.mtime);
7245
- return generations;
6727
+ return false;
7246
6728
  }
7247
- function deriveGenerationId(markdownPath, markdownOutputDir) {
7248
- const relative = path10.relative(markdownOutputDir, markdownPath);
7249
- const normalized = relative.split(path10.sep).join("/");
7250
- if (!normalized || normalized.startsWith("../")) {
7251
- return path10.basename(markdownPath, ".md");
6729
+ function parseNumberOrFallback(value2, fallback) {
6730
+ const parsed = Number(value2.trim());
6731
+ return Number.isFinite(parsed) ? parsed : fallback;
6732
+ }
6733
+ function clampNumber(value2, minimum, maximum) {
6734
+ return Math.min(maximum, Math.max(minimum, value2));
6735
+ }
6736
+ function parseBooleanOrFallback(value2, fallback) {
6737
+ const normalized = value2.trim().toLowerCase();
6738
+ if (normalized === "true") {
6739
+ return true;
7252
6740
  }
7253
- const segments = normalized.split("/").filter(Boolean);
7254
- if (segments.length <= 1) {
7255
- return path10.basename(markdownPath, ".md");
6741
+ if (normalized === "false") {
6742
+ return false;
7256
6743
  }
7257
- return segments[0] ?? path10.basename(markdownPath, ".md");
6744
+ return fallback;
7258
6745
  }
7259
- async function findMarkdownFiles(markdownOutputDir) {
7260
- const files = [];
7261
- const stack = [markdownOutputDir];
7262
- while (stack.length > 0) {
7263
- const current = stack.pop();
7264
- if (!current) {
7265
- continue;
6746
+
6747
+ // src/cli/flows/settingsFlow.tsx
6748
+ import { jsx, jsxs } from "react/jsx-runtime";
6749
+ function SettingsFlow({ initialSettings, initialSecrets, onDone }) {
6750
+ const { exit } = useApp();
6751
+ const [settings, setSettings] = useState(initialSettings);
6752
+ const [secrets, setSecrets] = useState(initialSecrets);
6753
+ const [editing, setEditing] = useState(null);
6754
+ const [showModelSelect, setShowModelSelect] = useState(false);
6755
+ const [menuMode, setMenuMode] = useState("main");
6756
+ const currentModelEntry = getLimnGenerationModels().find((m) => m.family === settings.t2i.modelId) ?? getLimnGenerationModels()[0];
6757
+ useInput((input, key) => {
6758
+ if (key.escape) {
6759
+ if (editing) {
6760
+ setEditing(null);
6761
+ return;
6762
+ }
6763
+ if (showModelSelect) {
6764
+ setShowModelSelect(false);
6765
+ return;
6766
+ }
6767
+ if (menuMode === "t2i") {
6768
+ setMenuMode("main");
6769
+ return;
6770
+ }
7266
6771
  }
7267
- let entries;
7268
- try {
7269
- entries = await readdir(current, { withFileTypes: true });
7270
- } catch {
7271
- continue;
6772
+ if (key.ctrl && input === "c") {
6773
+ onDone(null);
6774
+ exit();
7272
6775
  }
7273
- for (const entry of entries) {
7274
- const fullPath = path10.join(current, entry.name);
7275
- if (entry.isDirectory()) {
7276
- stack.push(fullPath);
7277
- continue;
6776
+ });
6777
+ const formatT2iOverridesSummary = (overrides) => {
6778
+ const count = Object.keys(overrides).length;
6779
+ return count === 0 ? "none" : `${count} override${count === 1 ? "" : "s"}`;
6780
+ };
6781
+ const menuItems = useMemo(() => {
6782
+ const t2iSubmenu = [
6783
+ {
6784
+ label: `T2I model: ${currentModelEntry?.displayName ?? settings.t2i.modelId}`,
6785
+ value: "t2i-model"
6786
+ },
6787
+ {
6788
+ label: `T2I input overrides: ${formatT2iOverridesSummary(settings.t2i.inputOverrides)}`,
6789
+ value: "t2i-input-overrides"
6790
+ },
6791
+ {
6792
+ label: "Back",
6793
+ value: "t2i-back"
7278
6794
  }
7279
- if (entry.isFile() && entry.name.toLowerCase().endsWith(".md")) {
7280
- files.push(fullPath);
6795
+ ];
6796
+ if (menuMode === "t2i") {
6797
+ return t2iSubmenu;
6798
+ }
6799
+ return [
6800
+ {
6801
+ label: `OpenRouter API key: ${secrets.openRouterApiKey ? "stored in keychain" : "missing"}`,
6802
+ value: "openrouter"
6803
+ },
6804
+ {
6805
+ label: `Replicate API token: ${secrets.replicateApiToken ? "stored in keychain" : "missing"}`,
6806
+ value: "replicate"
6807
+ },
6808
+ {
6809
+ label: `LLM model: ${settings.model}`,
6810
+ value: "llm-model"
6811
+ },
6812
+ {
6813
+ label: `Notifications > OS notifications enabled: ${settings.notifications.enabled ? "true" : "false"}`,
6814
+ value: "notifications-enabled"
6815
+ },
6816
+ {
6817
+ label: `Temperature: ${settings.modelSettings.temperature}`,
6818
+ value: "temperature"
6819
+ },
6820
+ {
6821
+ label: `Max tokens: ${settings.modelSettings.maxTokens}`,
6822
+ value: "maxTokens"
6823
+ },
6824
+ {
6825
+ label: `Top p: ${settings.modelSettings.topP}`,
6826
+ value: "topP"
6827
+ },
6828
+ {
6829
+ label: `Markdown output directory: ${settings.markdownOutputDir}`,
6830
+ value: "markdownOutputDir"
6831
+ },
6832
+ {
6833
+ label: `Asset output directory: ${settings.assetOutputDir}`,
6834
+ value: "assetOutputDir"
6835
+ },
6836
+ {
6837
+ label: `T2I settings: ${currentModelEntry?.displayName ?? settings.t2i.modelId}`,
6838
+ value: "t2i-settings"
6839
+ },
6840
+ {
6841
+ label: "Save and exit",
6842
+ value: "save"
6843
+ },
6844
+ {
6845
+ label: "Cancel",
6846
+ value: "cancel"
7281
6847
  }
7282
- }
7283
- }
7284
- return files;
7285
- }
7286
- function deriveOutputIdentity(markdownPath, markdownOutputDir) {
7287
- const generationId = deriveGenerationId(markdownPath, markdownOutputDir);
7288
- const fileBase = path10.basename(markdownPath, ".md");
7289
- const parsed = fileBase.match(/^([a-z0-9-]+)-(\d+)$/i);
7290
- if (!parsed || !parsed[1] || !parsed[2]) {
7291
- return {
7292
- generationId,
7293
- contentType: "article",
7294
- index: 1
7295
- };
6848
+ ];
6849
+ }, [currentModelEntry, menuMode, secrets.openRouterApiKey, secrets.replicateApiToken, settings]);
6850
+ if (showModelSelect) {
6851
+ const items = getLimnGenerationModels().map((model) => ({
6852
+ label: `${model.displayName} (${model.family})`,
6853
+ value: model.family
6854
+ }));
6855
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
6856
+ /* @__PURE__ */ jsx(Text, { bold: true, color: "cyanBright", children: "Choose T2I Model" }),
6857
+ /* @__PURE__ */ jsx(Text, { color: "gray", children: "Press Esc to go back." }),
6858
+ /* @__PURE__ */ jsx(
6859
+ SelectInput,
6860
+ {
6861
+ items,
6862
+ onSelect: (item) => {
6863
+ setSettings((current) => ({
6864
+ ...current,
6865
+ t2i: {
6866
+ modelId: item.value,
6867
+ inputOverrides: {}
6868
+ }
6869
+ }));
6870
+ setShowModelSelect(false);
6871
+ }
6872
+ }
6873
+ )
6874
+ ] });
7296
6875
  }
7297
- const prefix = parsed[1].toLowerCase();
7298
- const index = Number.parseInt(parsed[2], 10);
7299
- return {
7300
- generationId,
7301
- contentType: FILE_PREFIX_TO_CONTENT_TYPE[prefix] ?? prefix,
7302
- index: Number.isFinite(index) && index > 0 ? index : 1
7303
- };
7304
- }
7305
- function compareContentTypes(left, right) {
7306
- const leftIndex = CONTENT_TYPE_ORDER.indexOf(left);
7307
- const rightIndex = CONTENT_TYPE_ORDER.indexOf(right);
7308
- const normalizedLeft = leftIndex === -1 ? Number.MAX_SAFE_INTEGER : leftIndex;
7309
- const normalizedRight = rightIndex === -1 ? Number.MAX_SAFE_INTEGER : rightIndex;
7310
- if (normalizedLeft !== normalizedRight) {
7311
- return normalizedLeft - normalizedRight;
6876
+ if (editing) {
6877
+ return /* @__PURE__ */ jsx(
6878
+ EditorView,
6879
+ {
6880
+ editing,
6881
+ onSubmit: (value2) => {
6882
+ const accepted = applyEdit(editing.key, value2, settings, secrets, setSettings, setSecrets);
6883
+ if (accepted) {
6884
+ setEditing(null);
6885
+ }
6886
+ return accepted;
6887
+ },
6888
+ onCancel: () => {
6889
+ setEditing(null);
6890
+ }
6891
+ }
6892
+ );
7312
6893
  }
7313
- return left.localeCompare(right);
6894
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
6895
+ /* @__PURE__ */ jsx(Text, { bold: true, color: "cyanBright", children: "Ideon Settings" }),
6896
+ /* @__PURE__ */ jsx(Text, { color: "gray", children: "Enter to edit. Esc backs out of nested menus. Ctrl+C cancels." }),
6897
+ /* @__PURE__ */ jsx(Box, { marginTop: 1, flexDirection: "column", children: /* @__PURE__ */ jsx(
6898
+ SelectInput,
6899
+ {
6900
+ items: menuItems,
6901
+ onSelect: (item) => handleMenuSelect(item.value, settings, secrets, setEditing, setShowModelSelect, setMenuMode, onDone, exit)
6902
+ }
6903
+ ) })
6904
+ ] });
7314
6905
  }
7315
- function toContentTypeLabel(contentType) {
7316
- const knownLabel = CONTENT_TYPE_LABELS[contentType];
7317
- if (knownLabel) {
7318
- return knownLabel;
7319
- }
7320
- return contentType.split("-").map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
6906
+ function EditorView({
6907
+ editing,
6908
+ onSubmit,
6909
+ onCancel
6910
+ }) {
6911
+ const [value2, setValue] = useState(editing.value);
6912
+ const [error, setError] = useState(null);
6913
+ useEffect(() => {
6914
+ setValue(editing.value);
6915
+ setError(null);
6916
+ }, [editing]);
6917
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
6918
+ /* @__PURE__ */ jsx(Text, { bold: true, color: "cyanBright", children: editing.label }),
6919
+ /* @__PURE__ */ jsx(Text, { color: "gray", children: "Enter saves. Blank value clears nullable secrets and overrides. Esc cancels." }),
6920
+ /* @__PURE__ */ jsxs(Box, { marginTop: 1, children: [
6921
+ /* @__PURE__ */ jsx(Text, { children: "> " }),
6922
+ /* @__PURE__ */ jsx(
6923
+ TextInput,
6924
+ {
6925
+ value: value2,
6926
+ onChange: setValue,
6927
+ onSubmit: (nextValue) => {
6928
+ const accepted = onSubmit(nextValue);
6929
+ if (!accepted) {
6930
+ setError("Invalid JSON. Please enter an object or leave blank to clear.");
6931
+ }
6932
+ }
6933
+ }
6934
+ )
6935
+ ] }),
6936
+ error ? /* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsx(Text, { color: "red", children: error }) }) : null,
6937
+ /* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsxs(Text, { color: "gray", children: [
6938
+ "Current value: ",
6939
+ editing.value || "(empty)"
6940
+ ] }) }),
6941
+ /* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsx(Text, { color: "gray", dimColor: true, children: "Press Esc to return without changes." }) })
6942
+ ] });
7321
6943
  }
7322
- async function resolvePrimaryContentType(outputs) {
7323
- const fallback = outputs.find((output) => output.contentType === "article")?.contentType ?? outputs[0]?.contentType ?? "article";
7324
- const generationDir = path10.dirname(outputs[0]?.sourcePath ?? "");
7325
- if (!generationDir) {
7326
- return fallback;
6944
+
6945
+ // src/cli/commands/settings.tsx
6946
+ import { jsx as jsx2 } from "react/jsx-runtime";
6947
+ async function openSettings() {
6948
+ const envSettings = readEnvSettings();
6949
+ const [settings, secrets] = await Promise.all([
6950
+ loadSavedSettings(),
6951
+ loadSecrets({ disableKeytar: envSettings.disableKeytar })
6952
+ ]);
6953
+ let result = null;
6954
+ const app = render(
6955
+ /* @__PURE__ */ jsx2(
6956
+ SettingsFlow,
6957
+ {
6958
+ initialSettings: settings,
6959
+ initialSecrets: secrets,
6960
+ onDone: (value2) => {
6961
+ result = value2;
6962
+ }
6963
+ }
6964
+ )
6965
+ );
6966
+ await app.waitUntilExit();
6967
+ const finalResult = result;
6968
+ if (!finalResult) {
6969
+ console.log("Settings unchanged.");
6970
+ return;
7327
6971
  }
7328
- const jobPath = path10.join(generationDir, "job.json");
6972
+ const savedResult = finalResult;
6973
+ await saveSettings(savedResult.settings);
7329
6974
  try {
7330
- const raw = await readFile7(jobPath, "utf8");
7331
- const parsed = JSON.parse(raw);
7332
- const targets = Array.isArray(parsed.contentTargets) ? parsed.contentTargets : Array.isArray(parsed.settings?.contentTargets) ? parsed.settings.contentTargets : [];
7333
- const primary = targets.find((target) => target?.role === "primary" && typeof target.contentType === "string");
7334
- if (primary && typeof primary.contentType === "string") {
7335
- return primary.contentType;
6975
+ await saveSecrets(savedResult.secrets, { disableKeytar: envSettings.disableKeytar });
6976
+ } catch (error) {
6977
+ if (error instanceof KeytarUnavailableError) {
6978
+ console.log("Settings saved, but secrets were not stored in the system keychain.");
6979
+ console.log("Use IDEON_OPENROUTER_API_KEY and IDEON_REPLICATE_API_TOKEN in this environment.");
6980
+ return;
7336
6981
  }
7337
- } catch {
7338
- return fallback;
6982
+ throw error;
7339
6983
  }
7340
- return fallback;
6984
+ console.log(`Settings saved to ${getSettingsFilePath()}.`);
7341
6985
  }
7342
6986
 
6987
+ // src/cli/commands/serve.ts
6988
+ import path13 from "path";
6989
+ import { spawn } from "child_process";
6990
+
7343
6991
  // src/server/previewServer.ts
7344
6992
  import { execFile } from "child_process";
7345
6993
  import { promisify } from "util";
7346
- import { readFile as readFile8, stat as stat5 } from "fs/promises";
6994
+ import { readFile as readFile9, stat as stat6 } from "fs/promises";
7347
6995
  import { watch as fsWatch } from "fs";
7348
- import path11 from "path";
6996
+ import path12 from "path";
7349
6997
  import { fileURLToPath } from "url";
7350
6998
  import express from "express";
7351
6999
  import { marked } from "marked";
7352
-
7353
- // src/output/enrichMarkdownWithLinks.ts
7354
- function enrichMarkdownWithLinks(markdown, links) {
7355
- if (links.length === 0) {
7356
- return markdown;
7357
- }
7358
- const sorted = [...links].sort((left, right) => right.expression.length - left.expression.length);
7359
- let updated = markdown;
7360
- for (const link of sorted) {
7361
- const escapedExpression = escapeRegExp(link.expression);
7362
- const leadBoundary = /^\w/.test(link.expression) ? "\\b" : "";
7363
- const trailBoundary = /\w$/.test(link.expression) ? "\\b" : "";
7364
- const expressionRegex = new RegExp(`${leadBoundary}${escapedExpression}${trailBoundary}`, "g");
7365
- let match;
7366
- while ((match = expressionRegex.exec(updated)) !== null) {
7367
- const start = match.index;
7368
- const end = start + match[0].length;
7369
- if (isInProtectedSpan(updated, start, end)) {
7370
- continue;
7371
- }
7372
- updated = `${updated.slice(0, start)}[${match[0]}](${link.url})${updated.slice(end)}`;
7373
- break;
7374
- }
7375
- }
7376
- return updated;
7377
- }
7378
- function isInProtectedSpan(content, start, end) {
7379
- const lineStart = content.lastIndexOf("\n", start) + 1;
7380
- const lineEndIdx = content.indexOf("\n", end);
7381
- const lineEnd = lineEndIdx === -1 ? content.length : lineEndIdx;
7382
- const line = content.slice(lineStart, lineEnd);
7383
- const linkPattern = /\[[^\]]*\]\([^)]*\)/g;
7384
- let m;
7385
- while ((m = linkPattern.exec(line)) !== null) {
7386
- const absStart = lineStart + m.index;
7387
- const absEnd = absStart + m[0].length;
7388
- if (start >= absStart && end <= absEnd) {
7389
- return true;
7390
- }
7391
- }
7392
- const codePattern = /`[^`]+`/g;
7393
- while ((m = codePattern.exec(line)) !== null) {
7394
- const absStart = lineStart + m.index;
7395
- const absEnd = absStart + m[0].length;
7396
- if (start >= absStart && end <= absEnd) {
7397
- return true;
7398
- }
7399
- }
7400
- return false;
7401
- }
7402
- function escapeRegExp(value2) {
7403
- return value2.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
7404
- }
7405
-
7406
- // src/server/previewServer.ts
7407
7000
  var execFileAsync = promisify(execFile);
7408
7001
  var MissingArticleError = class extends Error {
7409
7002
  constructor(message) {
@@ -7498,7 +7091,7 @@ async function startPreviewServer(options) {
7498
7091
  if (options.watch) {
7499
7092
  let html2;
7500
7093
  try {
7501
- html2 = await readFile8(path11.join(previewClientDir, "index.html"), "utf8");
7094
+ html2 = await readFile9(path12.join(previewClientDir, "index.html"), "utf8");
7502
7095
  } catch {
7503
7096
  res.status(200).type("html").send(
7504
7097
  `<!doctype html><html><head><meta charset="utf-8"><title>Rebuilding\u2026</title><style>body{margin:0;display:flex;align-items:center;justify-content:center;height:100vh;font-family:sans-serif;background:#101820;color:#e0eaf0}p{font-size:15px;opacity:.7}</style></head><body><p>Rebuilding\u2026</p><script>const s=new EventSource('/api/__reload');s.onmessage=function(){location.reload()};</script></body></html>`
@@ -7509,7 +7102,7 @@ async function startPreviewServer(options) {
7509
7102
  const injected = html2.replace("</body>", `${reloadScript}</body>`);
7510
7103
  res.status(200).type("html").send(injected);
7511
7104
  } else {
7512
- res.status(200).sendFile(path11.join(previewClientDir, "index.html"));
7105
+ res.status(200).sendFile(path12.join(previewClientDir, "index.html"));
7513
7106
  }
7514
7107
  return;
7515
7108
  }
@@ -7562,7 +7155,7 @@ async function getArticleContent(generationId, markdownOutputDir) {
7562
7155
  generation.outputs.map(async (output) => {
7563
7156
  let markdown = "";
7564
7157
  try {
7565
- markdown = await readFile8(output.sourcePath, "utf8");
7158
+ markdown = await readFile9(output.sourcePath, "utf8");
7566
7159
  } catch (error) {
7567
7160
  if (isMissingFileError(error)) {
7568
7161
  throw new MissingArticleError(`Generation "${generationId}" no longer exists.`);
@@ -7580,7 +7173,7 @@ async function getArticleContent(generationId, markdownOutputDir) {
7580
7173
  };
7581
7174
  })
7582
7175
  );
7583
- const generationDir = path11.dirname(generation.outputs[0]?.sourcePath ?? "");
7176
+ const generationDir = path12.dirname(generation.outputs[0]?.sourcePath ?? "");
7584
7177
  const interactions = generationDir ? await loadSavedInteractions(generationDir) : { llmCalls: [], t2iCalls: [] };
7585
7178
  const analyticsSummary = generationDir ? await loadSavedAnalyticsSummary(generationDir) : null;
7586
7179
  return {
@@ -7609,7 +7202,7 @@ async function resolveActivePreviewArticle(preferredMarkdownPath, markdownOutput
7609
7202
  };
7610
7203
  }
7611
7204
  function resolveGenerationSourcePath(generation, markdownOutputDir) {
7612
- return generation.outputs.find((output) => output.contentType === generation.primaryContentType)?.sourcePath ?? generation.outputs[0]?.sourcePath ?? path11.join(markdownOutputDir, generation.id);
7205
+ return generation.outputs.find((output) => output.contentType === generation.primaryContentType)?.sourcePath ?? generation.outputs[0]?.sourcePath ?? path12.join(markdownOutputDir, generation.id);
7613
7206
  }
7614
7207
  function isMissingFileError(error) {
7615
7208
  return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
@@ -7624,7 +7217,7 @@ async function renderArticleHtml(markdown, generationId, sourcePath) {
7624
7217
  async function loadSavedLinks(markdownPath) {
7625
7218
  const linksPath = resolveLinksPath(markdownPath);
7626
7219
  try {
7627
- const raw = await readFile8(linksPath, "utf8");
7220
+ const raw = await readFile9(linksPath, "utf8");
7628
7221
  const parsed = JSON.parse(raw);
7629
7222
  if (!Array.isArray(parsed.links)) {
7630
7223
  return [];
@@ -7648,9 +7241,9 @@ async function loadSavedLinks(markdownPath) {
7648
7241
  }
7649
7242
  }
7650
7243
  async function loadSavedInteractions(generationDir) {
7651
- const interactionsPath = path11.join(generationDir, "model.interactions.json");
7244
+ const interactionsPath = path12.join(generationDir, "model.interactions.json");
7652
7245
  try {
7653
- const raw = await readFile8(interactionsPath, "utf8");
7246
+ const raw = await readFile9(interactionsPath, "utf8");
7654
7247
  const parsed = JSON.parse(raw);
7655
7248
  const llmCalls = Array.isArray(parsed.llmCalls) ? parsed.llmCalls.filter(isPreviewLlmInteraction) : [];
7656
7249
  const t2iCalls = Array.isArray(parsed.t2iCalls) ? parsed.t2iCalls.filter(isPreviewT2IInteraction) : [];
@@ -7666,9 +7259,9 @@ async function loadSavedInteractions(generationDir) {
7666
7259
  }
7667
7260
  }
7668
7261
  async function loadSavedAnalyticsSummary(generationDir) {
7669
- const analyticsPath = path11.join(generationDir, "generation.analytics.json");
7262
+ const analyticsPath = path12.join(generationDir, "generation.analytics.json");
7670
7263
  try {
7671
- const raw = await readFile8(analyticsPath, "utf8");
7264
+ const raw = await readFile9(analyticsPath, "utf8");
7672
7265
  const parsed = JSON.parse(raw);
7673
7266
  const summary = parsed.summary;
7674
7267
  if (!summary || typeof summary !== "object") {
@@ -7700,14 +7293,14 @@ async function getPreviewBootstrapData(preferredMarkdownPath, markdownOutputDir)
7700
7293
  };
7701
7294
  }
7702
7295
  async function resolvePreviewClientBuildDir() {
7703
- const currentDir = path11.dirname(fileURLToPath(import.meta.url));
7296
+ const currentDir = path12.dirname(fileURLToPath(import.meta.url));
7704
7297
  const candidates = [
7705
- path11.resolve(currentDir, "preview"),
7706
- path11.resolve(currentDir, "../../dist/preview")
7298
+ path12.resolve(currentDir, "preview"),
7299
+ path12.resolve(currentDir, "../../dist/preview")
7707
7300
  ];
7708
7301
  for (const candidate of candidates) {
7709
7302
  try {
7710
- const indexStat = await stat5(path11.join(candidate, "index.html"));
7303
+ const indexStat = await stat6(path12.join(candidate, "index.html"));
7711
7304
  if (indexStat.isFile()) {
7712
7305
  return candidate;
7713
7306
  }
@@ -7769,21 +7362,21 @@ async function resolveGenerationAssetPath(generationId, rawAssetPath, markdownOu
7769
7362
  throw new MissingArticleError(`Generation "${generationId}" no longer exists.`);
7770
7363
  }
7771
7364
  const decodedAssetPath = decodeURIComponent(rawAssetPath);
7772
- const normalizedRelative = path11.posix.normalize(decodedAssetPath.replace(/\\/g, "/"));
7773
- if (normalizedRelative.length === 0 || normalizedRelative === "." || normalizedRelative.startsWith("../") || normalizedRelative.includes("/../") || path11.posix.isAbsolute(normalizedRelative)) {
7365
+ const normalizedRelative = path12.posix.normalize(decodedAssetPath.replace(/\\/g, "/"));
7366
+ if (normalizedRelative.length === 0 || normalizedRelative === "." || normalizedRelative.startsWith("../") || normalizedRelative.includes("/../") || path12.posix.isAbsolute(normalizedRelative)) {
7774
7367
  throw new Error("Invalid generation asset path.");
7775
7368
  }
7776
- const generationDir = path11.dirname(generation.outputs[0]?.sourcePath ?? "");
7369
+ const generationDir = path12.dirname(generation.outputs[0]?.sourcePath ?? "");
7777
7370
  if (!generationDir) {
7778
7371
  throw new MissingArticleError(`Generation "${generationId}" has no source directory.`);
7779
7372
  }
7780
- const resolvedPath = path11.resolve(generationDir, normalizedRelative);
7781
- const relativeToGeneration = path11.relative(generationDir, resolvedPath);
7782
- if (relativeToGeneration.startsWith("..") || path11.isAbsolute(relativeToGeneration)) {
7373
+ const resolvedPath = path12.resolve(generationDir, normalizedRelative);
7374
+ const relativeToGeneration = path12.relative(generationDir, resolvedPath);
7375
+ if (relativeToGeneration.startsWith("..") || path12.isAbsolute(relativeToGeneration)) {
7783
7376
  throw new Error("Invalid generation asset path.");
7784
7377
  }
7785
7378
  try {
7786
- const fileStat = await stat5(resolvedPath);
7379
+ const fileStat = await stat6(resolvedPath);
7787
7380
  if (!fileStat.isFile()) {
7788
7381
  throw new Error("Invalid generation asset path.");
7789
7382
  }
@@ -9263,7 +8856,7 @@ async function runServeCommand(options) {
9263
8856
  const markdownPath = await resolveMarkdownPath(options.markdownPath, outputPaths.markdownOutputDir, process.cwd());
9264
8857
  const port = parsePort(options.port);
9265
8858
  if (options.watch) {
9266
- const viteBin = path12.resolve(process.cwd(), "node_modules", ".bin", "vite");
8859
+ const viteBin = path13.resolve(process.cwd(), "node_modules", ".bin", "vite");
9267
8860
  const viteProcess = spawn(viteBin, ["build", "--watch"], {
9268
8861
  stdio: "inherit",
9269
8862
  shell: process.platform === "win32"
@@ -9289,8 +8882,8 @@ async function runServeCommand(options) {
9289
8882
  openBrowser: options.openBrowser,
9290
8883
  watch: options.watch
9291
8884
  });
9292
- const relativeArticle = path12.relative(process.cwd(), markdownPath);
9293
- const relativeAssets = path12.relative(process.cwd(), outputPaths.assetOutputDir);
8885
+ const relativeArticle = path13.relative(process.cwd(), markdownPath);
8886
+ const relativeAssets = path13.relative(process.cwd(), outputPaths.assetOutputDir);
9294
8887
  console.log(`Previewing ${relativeArticle || markdownPath}`);
9295
8888
  console.log(`Serving assets from ${relativeAssets || outputPaths.assetOutputDir}`);
9296
8889
  console.log(`Open ${server.url}`);
@@ -9301,7 +8894,7 @@ async function runServeCommand(options) {
9301
8894
  }
9302
8895
 
9303
8896
  // src/cli/commands/write.tsx
9304
- import React4, { useEffect as useEffect2, useState as useState4 } from "react";
8897
+ import React4, { useEffect as useEffect3, useState as useState4 } from "react";
9305
8898
  import { render as render2, useApp as useApp3 } from "ink";
9306
8899
  import { createInterface } from "readline/promises";
9307
8900
 
@@ -9428,7 +9021,7 @@ function FinalSummary({
9428
9021
  }
9429
9022
 
9430
9023
  // src/cli/ui/stageRow.tsx
9431
- import { useEffect, useState as useState2 } from "react";
9024
+ import { useEffect as useEffect2, useState as useState2 } from "react";
9432
9025
  import { Box as Box3, Text as Text3 } from "ink";
9433
9026
 
9434
9027
  // src/cli/ui/progressVisibility.ts
@@ -9502,7 +9095,7 @@ function StageRow({
9502
9095
  maxVisibleItems = 12
9503
9096
  }) {
9504
9097
  const [frameIndex, setFrameIndex] = useState2(0);
9505
- useEffect(() => {
9098
+ useEffect2(() => {
9506
9099
  if (!isActive || stage.status !== "running") {
9507
9100
  setFrameIndex(0);
9508
9101
  return;
@@ -9563,7 +9156,7 @@ function ItemRows({
9563
9156
  }
9564
9157
  function ItemRow({ item, isActive }) {
9565
9158
  const [frameIndex, setFrameIndex] = useState2(0);
9566
- useEffect(() => {
9159
+ useEffect2(() => {
9567
9160
  if (!isActive || item.status !== "running") {
9568
9161
  setFrameIndex(0);
9569
9162
  return;
@@ -9776,7 +9369,7 @@ function formatPipelineStageCost(stage) {
9776
9369
  }
9777
9370
  return stage.costSource === "estimated" ? `~${formatted}` : formatted;
9778
9371
  }
9779
- async function renderPlainPipeline(input, dryRun, enrichLinks2, runMode, links, unlinks, maxLinks) {
9372
+ async function renderPlainPipeline(input, dryRun, enrichLinks2, runMode, links, unlinks, maxLinks, maxImages) {
9780
9373
  let previousStages = /* @__PURE__ */ new Map();
9781
9374
  let previousItemStatuses = /* @__PURE__ */ new Map();
9782
9375
  const notificationsEnabled = input.config.settings.notifications.enabled;
@@ -9793,6 +9386,7 @@ async function renderPlainPipeline(input, dryRun, enrichLinks2, runMode, links,
9793
9386
  customLinks: links,
9794
9387
  unlinks,
9795
9388
  maxLinks,
9389
+ maxImages,
9796
9390
  onUpdate(stages) {
9797
9391
  for (const stage of stages) {
9798
9392
  const previous = previousStages.get(stage.id);
@@ -10154,6 +9748,7 @@ function WriteApp({
10154
9748
  links,
10155
9749
  unlinks,
10156
9750
  maxLinks,
9751
+ maxImages,
10157
9752
  onError
10158
9753
  }) {
10159
9754
  const { exit } = useApp3();
@@ -10164,7 +9759,7 @@ function WriteApp({
10164
9759
  );
10165
9760
  const [result, setResult] = useState4(null);
10166
9761
  const [errorMessage, setErrorMessage] = useState4(null);
10167
- useEffect2(() => {
9762
+ useEffect3(() => {
10168
9763
  let mounted = true;
10169
9764
  void (async () => {
10170
9765
  try {
@@ -10180,6 +9775,7 @@ function WriteApp({
10180
9775
  customLinks: links,
10181
9776
  unlinks,
10182
9777
  maxLinks,
9778
+ maxImages,
10183
9779
  onUpdate(nextStages) {
10184
9780
  if (mounted) {
10185
9781
  setStages(nextStages);
@@ -10212,8 +9808,8 @@ function WriteApp({
10212
9808
  return () => {
10213
9809
  mounted = false;
10214
9810
  };
10215
- }, [dryRun, enrichLinks2, input, links, unlinks, maxLinks, onError, runMode]);
10216
- useEffect2(() => {
9811
+ }, [dryRun, enrichLinks2, input, links, unlinks, maxLinks, maxImages, onError, runMode]);
9812
+ useEffect3(() => {
10217
9813
  if (!result && !errorMessage) {
10218
9814
  return;
10219
9815
  }
@@ -10228,7 +9824,7 @@ function WriteApp({
10228
9824
  }
10229
9825
  async function runWriteCommand(options) {
10230
9826
  const input = await resolveInputWithInteractiveIdeaFallback(options);
10231
- await runWritePipeline(input, options.dryRun, options.enrichLinks, "fresh", options.noInteractive, options.links, options.unlinks, options.maxLinks);
9827
+ await runWritePipeline(input, options.dryRun, options.enrichLinks, "fresh", options.noInteractive, options.links, options.unlinks, options.maxLinks, options.maxImages);
10232
9828
  }
10233
9829
  async function runWriteResumeCommand(options = {}) {
10234
9830
  const session = await loadWriteSession();
@@ -10250,9 +9846,9 @@ async function runWriteResumeCommand(options = {}) {
10250
9846
  secrets: resolved.config.secrets
10251
9847
  }
10252
9848
  };
10253
- await runWritePipeline(input, session.dryRun, options.enrichLinks ?? false, "resume", options.noInteractive ?? false, options.links, options.unlinks, options.maxLinks);
9849
+ await runWritePipeline(input, session.dryRun, options.enrichLinks ?? false, "resume", options.noInteractive ?? false, options.links, options.unlinks, options.maxLinks, options.maxImages);
10254
9850
  }
10255
- async function runWritePipeline(input, dryRun, enrichLinks2, runMode, noInteractive, links, unlinks, maxLinks) {
9851
+ async function runWritePipeline(input, dryRun, enrichLinks2, runMode, noInteractive, links, unlinks, maxLinks, maxImages) {
10256
9852
  let interruptHandled = false;
10257
9853
  const handleSignal = (signal) => {
10258
9854
  if (interruptHandled) {
@@ -10286,7 +9882,7 @@ async function runWritePipeline(input, dryRun, enrichLinks2, runMode, noInteract
10286
9882
  process.on("SIGTERM", onSigterm);
10287
9883
  try {
10288
9884
  if (noInteractive || !process.stdout.isTTY) {
10289
- await renderPlainPipeline(input, dryRun, enrichLinks2, runMode, links, unlinks, maxLinks);
9885
+ await renderPlainPipeline(input, dryRun, enrichLinks2, runMode, links, unlinks, maxLinks, maxImages);
10290
9886
  return;
10291
9887
  }
10292
9888
  let commandError = null;
@@ -10301,6 +9897,7 @@ async function runWritePipeline(input, dryRun, enrichLinks2, runMode, noInteract
10301
9897
  links,
10302
9898
  unlinks,
10303
9899
  maxLinks,
9900
+ maxImages,
10304
9901
  onError: (error) => {
10305
9902
  commandError = error;
10306
9903
  }
@@ -10468,7 +10065,7 @@ function collectOptionValue(value2, previous = []) {
10468
10065
  }
10469
10066
  async function runCli(argv) {
10470
10067
  const program = new Command();
10471
- program.name("ideon").description("Turn ideas into rich Markdown articles with generated images.").version(version);
10068
+ program.name("ideon").description("Turn one idea into articles, threads, and social posts \u2014 quality content without the token tax.").version(version);
10472
10069
  program.command("settings").description("Show the current Ideon settings and storage state.").action(async () => {
10473
10070
  await openSettings();
10474
10071
  });
@@ -10513,6 +10110,14 @@ async function runCli(argv) {
10513
10110
  maxLinks: options.maxLinks
10514
10111
  });
10515
10112
  });
10113
+ program.command("export").description("Export a generated article as a standalone markdown file with inline links and copied images.").argument("<generationId>", "Generation id or article slug to export").argument("<path>", "Destination directory for the exported file and images").option("--index <n>", "Which primary article variant to export when multiple exist (default: 1)", (v) => Number.parseInt(v, 10)).option("--overwrite", "Overwrite the destination file if it already exists", false).action(async (generationId, destinationPath, options) => {
10114
+ await runOutputCommand({
10115
+ generationId,
10116
+ destinationPath,
10117
+ index: options.index,
10118
+ overwrite: options.overwrite
10119
+ });
10120
+ });
10516
10121
  program.command("preview").description("Preview a generated article in a local browser with linked assets.").argument("[markdownPath]", "Path to the markdown file to preview").option("-p, --port <port>", "Port for the local preview server (default: 4173)").option("--no-open", "Do not auto-open browser after server startup").option("--watch", "Rebuild the preview UI on source changes and auto-reload the browser", false).action(async (markdownPath, options) => {
10517
10122
  await runServeCommand({
10518
10123
  markdownPath,
@@ -10521,7 +10126,7 @@ async function runCli(argv) {
10521
10126
  watch: options.watch
10522
10127
  });
10523
10128
  });
10524
- const writeCommand = program.command("write").description("Generate one primary content output plus optional secondary outputs from a prompt or job file.").argument("[idea]", "Natural-language idea for the generation run").option("-i, --idea <idea>", "Natural-language idea for the generation run").option("--audience <description>", "Optional natural-language audience description for shared-brief targeting").option("-j, --job <path>", "Path to a JSON job definition").option("--primary <type=count>", "Required primary output target (for example: article=1 or x-post=1)").option("--secondary <type=count>", "Secondary output target, repeatable (for example: x-thread=3, linkedin-post=2)", collectOptionValue).option("--style <style>", "Writing style (academic, analytical, authoritative, conversational, empathetic, friendly, journalistic, minimalist, persuasive, playful, professional, storytelling, technical)").option("--intent <intent>", "Content intent (announcement, case-study, cornerstone, counterargument, critique-review, deep-dive-analysis, how-to-guide, interview-q-and-a, listicle, opinion-piece, personal-essay, roundup-curation, tutorial)").option("--length <size>", "Target length: small, medium, large, or a positive integer word count").option("--no-interactive", "Fail instead of prompting for missing input in TTY mode").option("--dry-run", "Run the pipeline shell without external API calls", false).option("--enrich-links", "Run link enrichment after markdown generation", false).option("--link <pair>", 'Custom link "expression->url", repeatable', collectOptionValue).option("--unlink <expression>", "Remove a custom link by expression, repeatable", collectOptionValue).option("--max-links <n>", "Max number of generated links", (v) => Number.parseInt(v, 10)).action(async (ideaArg, options) => {
10129
+ const writeCommand = program.command("write").description("Generate one primary content output plus optional secondary outputs from a prompt or job file.").argument("[idea]", "Natural-language idea for the generation run").option("-i, --idea <idea>", "Natural-language idea for the generation run").option("--audience <description>", "Optional natural-language audience description for shared-brief targeting").option("-j, --job <path>", "Path to a JSON job definition").option("--primary <type=count>", "Required primary output target (for example: article=1 or x-post=1)").option("--secondary <type=count>", "Secondary output target, repeatable (for example: x-thread=3, linkedin-post=2)", collectOptionValue).option("--style <style>", "Writing style (academic, analytical, authoritative, conversational, empathetic, friendly, journalistic, minimalist, persuasive, playful, professional, storytelling, technical)").option("--intent <intent>", "Content intent (announcement, case-study, cornerstone, counterargument, critique-review, deep-dive-analysis, how-to-guide, interview-q-and-a, listicle, opinion-piece, personal-essay, roundup-curation, tutorial)").option("--length <size>", "Target length: small, medium, large, or a positive integer word count").option("--no-interactive", "Fail instead of prompting for missing input in TTY mode").option("--dry-run", "Run the pipeline shell without external API calls", false).option("--enrich-links", "Run link enrichment after markdown generation", false).option("--link <pair>", 'Custom link "expression->url", repeatable', collectOptionValue).option("--unlink <expression>", "Remove a custom link by expression, repeatable", collectOptionValue).option("--max-links <n>", "Max number of generated links", (v) => Number.parseInt(v, 10)).option("--max-images <n>", "Max total images including cover (1=cover only, 2=cover+1 inline, 3=cover+2 inline)", (v) => Number.parseInt(v, 10)).action(async (ideaArg, options) => {
10525
10130
  await runWriteCommand({
10526
10131
  idea: options.idea ?? ideaArg,
10527
10132
  audience: options.audience,
@@ -10536,16 +10141,18 @@ async function runCli(argv) {
10536
10141
  enrichLinks: options.enrichLinks,
10537
10142
  links: options.link,
10538
10143
  unlinks: options.unlink,
10539
- maxLinks: options.maxLinks
10144
+ maxLinks: options.maxLinks,
10145
+ maxImages: options.maxImages
10540
10146
  });
10541
10147
  });
10542
- writeCommand.command("resume").description("Resume the last failed or interrupted write session from .ideon/write.").option("--no-interactive", "Force plain non-interactive output even in TTY mode", false).option("--enrich-links", "Run link enrichment after markdown generation", false).option("--link <pair>", 'Custom link "expression->url", repeatable', collectOptionValue).option("--unlink <expression>", "Remove a custom link by expression, repeatable", collectOptionValue).option("--max-links <n>", "Max number of generated links", (v) => Number.parseInt(v, 10)).action(async (options) => {
10148
+ writeCommand.command("resume").description("Resume the last failed or interrupted write session from .ideon/write.").option("--no-interactive", "Force plain non-interactive output even in TTY mode", false).option("--enrich-links", "Run link enrichment after markdown generation", false).option("--link <pair>", 'Custom link "expression->url", repeatable', collectOptionValue).option("--unlink <expression>", "Remove a custom link by expression, repeatable", collectOptionValue).option("--max-links <n>", "Max number of generated links", (v) => Number.parseInt(v, 10)).option("--max-images <n>", "Max total images including cover (1=cover only, 2=cover+1 inline, 3=cover+2 inline)", (v) => Number.parseInt(v, 10)).action(async (options) => {
10543
10149
  await runWriteResumeCommand({
10544
10150
  noInteractive: options.noInteractive,
10545
10151
  enrichLinks: options.enrichLinks,
10546
10152
  links: options.link,
10547
10153
  unlinks: options.unlink,
10548
- maxLinks: options.maxLinks
10154
+ maxLinks: options.maxLinks,
10155
+ maxImages: options.maxImages
10549
10156
  });
10550
10157
  });
10551
10158
  await program.parseAsync(argv);