@telepat/ideon 0.1.30 → 0.1.32

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/README.md CHANGED
@@ -24,6 +24,7 @@ Built for marketers, founders, and lean teams who need to ship high-quality cont
24
24
  - **Write once, publish everywhere** — One idea turns into article, blog, newsletter, X, LinkedIn, and Reddit posts in a single run. Your article anchors the campaign. Everything else promotes it.
25
25
  - **Style and intent control** — 13 styles × 13 intents. Every output shares one consistent voice across every channel.
26
26
  - **Research-backed links** — Ideon browses the web and inserts contextual external links like a human writer would. No manual research.
27
+ - **SEO-optimized output** — On-page SEO, E-E-A-T credibility signals, and fact density baked into the writing pipeline. Content built to rank in both traditional search and AI-generated summaries.
27
28
  - **Any model via OpenRouter** — Plug in Claude, GPT-4, or any supported model. Switch without changing your workflow.
28
29
  - **Writing guide-driven** — Prompt composition grounded in proven writing principles compiled from real advice. No generic AI filler.
29
30
  - **Code-driven efficiency** — Deterministic pipeline code handles orchestration. You pay for tokens only when drafting prose.
package/README.zh-CN.md CHANGED
@@ -24,6 +24,7 @@ Ideon 是一款 AI 内容写作工具,可将一个想法转化为多格式、
24
24
  - **写一次,处处发布** — 一次运行将一个创意转化为文章、博客、新闻通讯、X、LinkedIn 和 Reddit 帖子。文章是核心,其余均为推广内容。
25
25
  - **风格与意图控制** — 13 种风格 × 13 种意图。所有输出共享同一种一致的声音。
26
26
  - **研究支撑的链接** — Ideon 浏览网络并像人类作者一样插入与上下文相关的外部链接。无需手动研究。
27
+ - **SEO 优化输出** — 页面 SEO、E-E-A-T 可信度信号和事实密度已融入写作流水线。生成的内容针对传统搜索和 AI 生成摘要进行了排名优化。
27
28
  - **通过 OpenRouter 接入任何模型** — 接入 Claude、GPT-4 或任何支持的模型。无需更改工作流程即可切换。
28
29
  - **写作指南驱动** — 提示词组合基于经过实践检验的写作原则汇编而成。没有通用 AI 套话。
29
30
  - **代码驱动的高效率** — 确定性流水线代码处理编排。您只需在起草正文时支付 token 费用。
package/dist/ideon.js CHANGED
@@ -552,7 +552,8 @@ var baseT2ISettingsSchema = z2.preprocess(
552
552
  z2.object({
553
553
  modelId: z2.string().default(DEFAULT_LIMN_MODEL_ID),
554
554
  replicateModelId: z2.string().optional(),
555
- inputOverrides: z2.record(z2.string(), z2.unknown()).default({})
555
+ inputOverrides: z2.record(z2.string(), z2.unknown()).default({}),
556
+ maxAttempts: z2.number().int().min(1).max(10).default(4)
556
557
  })
557
558
  );
558
559
  var notificationsSettingsSchema = z2.object({
@@ -562,6 +563,7 @@ var appSettingsSchema = z2.object({
562
563
  model: z2.string().default("deepseek/deepseek-v4-pro"),
563
564
  modelSettings: modelSettingsSchema.default(modelSettingsSchema.parse({})),
564
565
  modelRequestTimeoutMs: z2.number().int().positive().default(9e4),
566
+ modelRequestMaxAttempts: z2.number().int().min(1).max(10).default(4),
565
567
  t2i: baseT2ISettingsSchema.default(baseT2ISettingsSchema.parse({})),
566
568
  notifications: notificationsSettingsSchema.default(notificationsSettingsSchema.parse({})),
567
569
  contentTargets: z2.array(contentTargetSchema).min(1).refine((targets) => targets.filter((target) => target.role === "primary").length === 1, {
@@ -580,6 +582,7 @@ var envSettingsSchema = z2.object({
580
582
  maxTokens: z2.number().int().positive().optional(),
581
583
  topP: z2.number().min(0).max(1).optional(),
582
584
  modelRequestTimeoutMs: z2.number().int().positive().optional(),
585
+ modelRequestMaxAttempts: z2.number().int().min(1).max(10).optional(),
583
586
  notificationsEnabled: z2.boolean().optional(),
584
587
  style: z2.enum(writingStyleValues).optional(),
585
588
  intent: z2.enum(contentIntentValues).optional(),
@@ -624,6 +627,7 @@ function readEnvSettings(env = process.env) {
624
627
  maxTokens: parseNumber(env.IDEON_MAX_TOKENS),
625
628
  topP: parseNumber(env.IDEON_TOP_P),
626
629
  modelRequestTimeoutMs: parseNumber(env.IDEON_MODEL_REQUEST_TIMEOUT_MS),
630
+ modelRequestMaxAttempts: parseNumber(env.IDEON_MODEL_REQUEST_MAX_ATTEMPTS),
627
631
  notificationsEnabled: parseBoolean(env.IDEON_NOTIFICATIONS_ENABLED),
628
632
  style: env.IDEON_STYLE,
629
633
  intent: env.IDEON_INTENT,
@@ -1424,7 +1428,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
1424
1428
  // package.json
1425
1429
  var package_default = {
1426
1430
  name: "@telepat/ideon",
1427
- version: "0.1.30",
1431
+ version: "0.1.32",
1428
1432
  description: "CLI for generating rich articles and images from ideas.",
1429
1433
  type: "module",
1430
1434
  repository: {
@@ -1547,6 +1551,7 @@ async function resolveRunInput(input) {
1547
1551
  ...job?.settings ?? {},
1548
1552
  ...envSettings.model ? { model: envSettings.model } : {},
1549
1553
  ...envSettings.modelRequestTimeoutMs !== void 0 ? { modelRequestTimeoutMs: envSettings.modelRequestTimeoutMs } : {},
1554
+ ...envSettings.modelRequestMaxAttempts !== void 0 ? { modelRequestMaxAttempts: envSettings.modelRequestMaxAttempts } : {},
1550
1555
  ...envSettings.notificationsEnabled !== void 0 ? {
1551
1556
  notifications: {
1552
1557
  ...savedSettings.notifications,
@@ -2059,6 +2064,9 @@ function intentToGuidePath(intent) {
2059
2064
  function styleToGuidePath(style) {
2060
2065
  return `writing-guide/styles/${style}.md`;
2061
2066
  }
2067
+ function seoGuidePath(name) {
2068
+ return `writing-guide/seo/${name}.md`;
2069
+ }
2062
2070
  function dedupe(items) {
2063
2071
  return Array.from(new Set(items));
2064
2072
  }
@@ -2074,6 +2082,7 @@ function buildPrimaryPlanGuideInstruction(intent, contentType) {
2074
2082
  "writing-guide/references/headline-writing-systems.md",
2075
2083
  "writing-guide/references/ideation-and-credibility-systems.md",
2076
2084
  "writing-guide/references/content-frameworks.md",
2085
+ seoGuidePath("on-page-essentials"),
2077
2086
  intentToGuidePath(intent),
2078
2087
  formatToGuidePath(contentType)
2079
2088
  ];
@@ -2093,6 +2102,9 @@ function buildArticleSectionGuideInstruction(style, intent, contentType) {
2093
2102
  "writing-guide/references/prose-quality-checks.md",
2094
2103
  "writing-guide/references/readability-and-pace.md",
2095
2104
  "writing-guide/references/skimmability-patterns.md",
2105
+ seoGuidePath("on-page-essentials"),
2106
+ seoGuidePath("eeat-signals"),
2107
+ seoGuidePath("fact-density"),
2096
2108
  styleToGuidePath(style),
2097
2109
  intentToGuidePath(intent),
2098
2110
  formatToGuidePath(contentType)
@@ -2440,9 +2452,9 @@ function buildLongFormPlanMessages(idea, options) {
2440
2452
  "",
2441
2453
  "Requirements:",
2442
2454
  "- The content should feel authoritative, practical, and clearly structured for scanning and deep reading.",
2443
- "- Generate a memorable title and a sharp subtitle that promise a concrete benefit, mechanism, or outcome.",
2455
+ "- Generate a memorable title (under 60 characters) that leads with the primary entity. Include a sharp subtitle that promises a concrete benefit, mechanism, or outcome.",
2444
2456
  "- The slug must be lowercase kebab-case and publication-ready.",
2445
- "- The description should work as a concise meta description and align with the shared content plan.",
2457
+ "- The description should work as a concise meta description (120-160 characters), include the primary entity, and align with the shared content plan.",
2446
2458
  `- Plan ${sectionCounts.label} strong sections with distinct focus areas and logical progression (no repetitive section intent).`,
2447
2459
  "- Frame section titles to reflect likely search intent or practical reader questions when appropriate.",
2448
2460
  "- Each section description should name the mechanism, evidence type, or practical action that makes the section useful.",
@@ -2463,7 +2475,7 @@ function buildLongFormPlanMessages(idea, options) {
2463
2475
  `- contentType: set to "${options.contentType}" exactly`,
2464
2476
  "- title: string",
2465
2477
  "- subtitle: string",
2466
- "- keywords: array of 3 to 8 strings",
2478
+ "- keywords: array of 3 to 8 specific, non-generic strings representing primary entities and search topics (not exact-match duplicates of heading text)",
2467
2479
  "- slug: string in lowercase kebab-case",
2468
2480
  "- description: string",
2469
2481
  "- introBrief: string",
@@ -2610,6 +2622,30 @@ async function planPrimaryContent({
2610
2622
  return shortFormPlanSchema.parse(data);
2611
2623
  }
2612
2624
  });
2625
+ if (!dryRun) {
2626
+ const seoWarnings = [];
2627
+ if (basePlan.title && basePlan.title.length > 60) {
2628
+ seoWarnings.push(`Title is ${basePlan.title.length} chars (recommended: under 60 for search display safety)`);
2629
+ }
2630
+ if (basePlan.description) {
2631
+ if (basePlan.description.length < 120) {
2632
+ seoWarnings.push(`Description is ${basePlan.description.length} chars (recommended: 120-160 for meta description effectiveness)`);
2633
+ } else if (basePlan.description.length > 160) {
2634
+ seoWarnings.push(`Description is ${basePlan.description.length} chars (recommended: 120-160 for meta description effectiveness)`);
2635
+ }
2636
+ }
2637
+ if (isLongForm) {
2638
+ const longPlan = basePlan;
2639
+ const headings = longPlan.sections.map((s) => s.title.toLowerCase());
2640
+ const duplicateKeywords = longPlan.keywords.filter((kw) => headings.includes(kw.toLowerCase()));
2641
+ if (duplicateKeywords.length > 0) {
2642
+ seoWarnings.push(`Keywords duplicate heading text: ${duplicateKeywords.join(", ")} (consider using semantic variants)`);
2643
+ }
2644
+ }
2645
+ for (const warning of seoWarnings) {
2646
+ console.warn(`[seo] ${warning}`);
2647
+ }
2648
+ }
2613
2649
  const normalizedSlug = slugify(basePlan.slug || basePlan.title);
2614
2650
  const uniqueSlug = await resolveUniqueSlug(markdownOutputDir, normalizedSlug);
2615
2651
  if (isLongForm) {
@@ -2944,7 +2980,8 @@ function buildSectionMessages(plan, section, articleSoFar, style, intent, conten
2944
2980
  "Requirements:",
2945
2981
  `- ${paragraphCount} paragraphs.`,
2946
2982
  `- Target length: about ${sectionTargetWords} words.`,
2947
- "- Be concrete and specific.",
2983
+ "- Be concrete and specific. Support key claims with statistics, data points, or authoritative citations.",
2984
+ "- Include at least one practical insight that sounds like first-hand practitioner experience.",
2948
2985
  "- Continue naturally from the article draft so far without rehashing prior sections.",
2949
2986
  "- Use short Markdown lists only if they materially improve clarity."
2950
2987
  ].join("\n")
@@ -3181,6 +3218,212 @@ function buildImagePromptMessages(plan, image, section) {
3181
3218
  ];
3182
3219
  }
3183
3220
 
3221
+ // src/llm/retry.ts
3222
+ var DEFAULT_BASE_BACKOFF_MS = 1500;
3223
+ var DEFAULT_MAX_BACKOFF_MS = 6e4;
3224
+ var DEFAULT_JITTER_MS = 250;
3225
+ var RETRYABLE_STATUS_CODES = /* @__PURE__ */ new Set([408, 409, 425, 429]);
3226
+ var TRANSIENT_ERROR_PATTERN = /timeout|network|fetch|temporarily|aborted|ECONNRESET|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|ETIMEDOUT|socket hang up/i;
3227
+ async function withRetry(op, opts) {
3228
+ const maxAttempts = Math.max(1, Math.floor(opts.maxAttempts));
3229
+ const baseBackoffMs = opts.baseBackoffMs ?? DEFAULT_BASE_BACKOFF_MS;
3230
+ const maxBackoffMs = opts.maxBackoffMs ?? DEFAULT_MAX_BACKOFF_MS;
3231
+ const jitterMs = opts.jitterMs ?? DEFAULT_JITTER_MS;
3232
+ const sleep = opts.sleep ?? defaultSleep;
3233
+ const randomFraction = opts.randomFraction ?? Math.random;
3234
+ let lastError;
3235
+ let lastClassification = null;
3236
+ let attemptsMade = 0;
3237
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
3238
+ attemptsMade = attempt;
3239
+ try {
3240
+ return await op(attempt);
3241
+ } catch (error) {
3242
+ lastError = error;
3243
+ const classification = classifyHttpError(error);
3244
+ lastClassification = classification;
3245
+ if (!classification.retryable || attempt >= maxAttempts) {
3246
+ break;
3247
+ }
3248
+ const delayMs = computeDelayMs({
3249
+ retryAfterMs: classification.retryAfterMs,
3250
+ attempt,
3251
+ baseBackoffMs,
3252
+ maxBackoffMs,
3253
+ jitterMs,
3254
+ randomFraction
3255
+ });
3256
+ opts.onRetry?.({
3257
+ attempt,
3258
+ delayMs,
3259
+ reason: classification.reason,
3260
+ statusCode: classification.statusCode
3261
+ });
3262
+ await sleep(delayMs);
3263
+ }
3264
+ }
3265
+ throw buildFinalError(opts.operationLabel, attemptsMade, lastError, lastClassification);
3266
+ }
3267
+ function computeDelayMs(input) {
3268
+ if (typeof input.retryAfterMs === "number" && input.retryAfterMs > 0) {
3269
+ return Math.min(input.maxBackoffMs, input.retryAfterMs);
3270
+ }
3271
+ const exponential = input.baseBackoffMs * 2 ** (input.attempt - 1);
3272
+ const capped = Math.min(input.maxBackoffMs, exponential);
3273
+ const jitter = input.jitterMs > 0 ? input.randomFraction() * input.jitterMs : 0;
3274
+ return Math.floor(capped + jitter);
3275
+ }
3276
+ function classifyHttpError(error) {
3277
+ if (!error) {
3278
+ return { retryable: true, reason: "Empty error value treated as transient." };
3279
+ }
3280
+ const message = errorMessage(error);
3281
+ const statusFromObject = extractStatusFromObject(error);
3282
+ const statusFromMessage = statusFromObject ?? extractStatusFromMessage(message);
3283
+ const retryAfterMs = extractRetryAfterMs(error, message);
3284
+ if (statusFromMessage !== void 0) {
3285
+ if (RETRYABLE_STATUS_CODES.has(statusFromMessage) || statusFromMessage >= 500) {
3286
+ return {
3287
+ retryable: true,
3288
+ statusCode: statusFromMessage,
3289
+ retryAfterMs,
3290
+ reason: `HTTP ${statusFromMessage}`
3291
+ };
3292
+ }
3293
+ return {
3294
+ retryable: false,
3295
+ statusCode: statusFromMessage,
3296
+ reason: `HTTP ${statusFromMessage}`
3297
+ };
3298
+ }
3299
+ if (TRANSIENT_ERROR_PATTERN.test(message)) {
3300
+ return {
3301
+ retryable: true,
3302
+ retryAfterMs,
3303
+ reason: "Transient network or timeout error."
3304
+ };
3305
+ }
3306
+ return {
3307
+ retryable: true,
3308
+ retryAfterMs,
3309
+ reason: "Unknown error treated as transient."
3310
+ };
3311
+ }
3312
+ function extractStatusFromObject(error) {
3313
+ if (typeof error !== "object" || error === null) {
3314
+ return void 0;
3315
+ }
3316
+ const record = error;
3317
+ const direct = numberFrom(record.status);
3318
+ if (direct !== void 0) {
3319
+ return direct;
3320
+ }
3321
+ const response = record.response;
3322
+ if (typeof response === "object" && response !== null) {
3323
+ const responseStatus = numberFrom(response.status);
3324
+ if (responseStatus !== void 0) {
3325
+ return responseStatus;
3326
+ }
3327
+ }
3328
+ return void 0;
3329
+ }
3330
+ function extractStatusFromMessage(message) {
3331
+ const match = message.match(/status\s+(\d{3})/i);
3332
+ if (!match) {
3333
+ return void 0;
3334
+ }
3335
+ const parsed = Number.parseInt(match[1], 10);
3336
+ return Number.isInteger(parsed) ? parsed : void 0;
3337
+ }
3338
+ function extractRetryAfterMs(error, message) {
3339
+ const headerSeconds = extractRetryAfterHeader(error);
3340
+ if (headerSeconds !== void 0) {
3341
+ return Math.round(headerSeconds * 1e3);
3342
+ }
3343
+ const bodyMatch = message.match(/"?retry_after"?\s*[:=]\s*(\d+(?:\.\d+)?)/i);
3344
+ if (bodyMatch) {
3345
+ const seconds = Number.parseFloat(bodyMatch[1]);
3346
+ if (Number.isFinite(seconds) && seconds > 0) {
3347
+ return Math.round(seconds * 1e3);
3348
+ }
3349
+ }
3350
+ return void 0;
3351
+ }
3352
+ function extractRetryAfterHeader(error) {
3353
+ if (typeof error !== "object" || error === null) {
3354
+ return void 0;
3355
+ }
3356
+ const response = error.response;
3357
+ if (typeof response !== "object" || response === null) {
3358
+ return void 0;
3359
+ }
3360
+ const headers = response.headers;
3361
+ if (!headers) {
3362
+ return void 0;
3363
+ }
3364
+ let rawValue;
3365
+ if (typeof headers === "object" && typeof headers.get === "function") {
3366
+ rawValue = headers.get("retry-after");
3367
+ } else if (typeof headers === "object") {
3368
+ const entries = headers;
3369
+ rawValue = entries["retry-after"] ?? entries["Retry-After"];
3370
+ }
3371
+ if (typeof rawValue !== "string" && typeof rawValue !== "number") {
3372
+ return void 0;
3373
+ }
3374
+ const stringValue = String(rawValue).trim();
3375
+ if (!stringValue) {
3376
+ return void 0;
3377
+ }
3378
+ const numeric = Number.parseFloat(stringValue);
3379
+ if (Number.isFinite(numeric) && numeric > 0) {
3380
+ return numeric;
3381
+ }
3382
+ const dateMs = Date.parse(stringValue);
3383
+ if (Number.isFinite(dateMs)) {
3384
+ const diffSeconds = (dateMs - Date.now()) / 1e3;
3385
+ return diffSeconds > 0 ? diffSeconds : void 0;
3386
+ }
3387
+ return void 0;
3388
+ }
3389
+ function numberFrom(value2) {
3390
+ if (typeof value2 === "number" && Number.isFinite(value2)) {
3391
+ return value2;
3392
+ }
3393
+ if (typeof value2 === "string") {
3394
+ const parsed = Number.parseFloat(value2);
3395
+ return Number.isFinite(parsed) ? parsed : void 0;
3396
+ }
3397
+ return void 0;
3398
+ }
3399
+ function errorMessage(error) {
3400
+ if (error instanceof Error) {
3401
+ return error.message;
3402
+ }
3403
+ if (typeof error === "string") {
3404
+ return error;
3405
+ }
3406
+ try {
3407
+ return JSON.stringify(error);
3408
+ } catch {
3409
+ return String(error);
3410
+ }
3411
+ }
3412
+ function buildFinalError(operationLabel, attempts, cause, classification) {
3413
+ const detail = errorMessage(cause) || classification?.reason || "unknown error";
3414
+ const message = `${operationLabel} failed after ${attempts} attempt${attempts === 1 ? "" : "s"}: ${detail}`;
3415
+ const wrapped = new Error(message, cause instanceof Error ? { cause } : void 0);
3416
+ if (!(cause instanceof Error) && cause !== void 0) {
3417
+ wrapped.cause = cause;
3418
+ }
3419
+ return wrapped;
3420
+ }
3421
+ function defaultSleep(ms) {
3422
+ return new Promise((resolve) => {
3423
+ setTimeout(resolve, ms);
3424
+ });
3425
+ }
3426
+
3184
3427
  // src/pipeline/analytics.ts
3185
3428
  var LLM_USD_PER_1K_TOKENS = {
3186
3429
  // AUTO-GENERATED:OPENROUTER_PRICING_START
@@ -3433,8 +3676,26 @@ async function renderExpandedImages({
3433
3676
  ...replicateModelOverride && isReplicateModelIdForFamily(family, replicateModelOverride) ? { replicateModel: replicateModelOverride } : {}
3434
3677
  };
3435
3678
  const renderStartedAtMs = Date.now();
3679
+ let attemptsMade = 0;
3680
+ let retryCount = 0;
3681
+ let retryBackoffMs = 0;
3436
3682
  try {
3437
- const result = await limn.generate(prompt.prompt, family, limnOptions);
3683
+ const result = await withRetry(
3684
+ () => {
3685
+ attemptsMade += 1;
3686
+ return limn.generate(prompt.prompt, family, limnOptions);
3687
+ },
3688
+ {
3689
+ operationLabel: `Replicate ${prompt.kind} image (${prompt.id})`,
3690
+ maxAttempts: settings.t2i.maxAttempts,
3691
+ baseBackoffMs: 1500,
3692
+ maxBackoffMs: 6e4,
3693
+ onRetry({ delayMs }) {
3694
+ retryCount += 1;
3695
+ retryBackoffMs += delayMs;
3696
+ }
3697
+ }
3698
+ );
3438
3699
  const ext = mimeTypeToExtension(result.mimeType);
3439
3700
  const liveFileName = `${prompt.kind === "cover" ? "cover" : `inline-${prompt.anchorAfterSection}`}-${index + 1}.${ext}`;
3440
3701
  const liveOutputPath = path6.join(assetDir, liveFileName);
@@ -3456,9 +3717,9 @@ async function renderExpandedImages({
3456
3717
  kind: prompt.kind,
3457
3718
  modelId: result.modelSlug,
3458
3719
  durationMs: result.analytics.totalDurationMs,
3459
- attempts: 1,
3460
- retries: 0,
3461
- retryBackoffMs: 0,
3720
+ attempts: attemptsMade,
3721
+ retries: retryCount,
3722
+ retryBackoffMs,
3462
3723
  outputBytes: result.image.byteLength,
3463
3724
  costUsd: result.analytics.totalEstimatedCostUsd,
3464
3725
  costSource
@@ -3472,9 +3733,9 @@ async function renderExpandedImages({
3472
3733
  startedAt: new Date(renderStartedAtMs).toISOString(),
3473
3734
  endedAt: (/* @__PURE__ */ new Date()).toISOString(),
3474
3735
  durationMs: result.analytics.totalDurationMs,
3475
- attempts: 1,
3476
- retries: 0,
3477
- retryBackoffMs: 0,
3736
+ attempts: attemptsMade,
3737
+ retries: retryCount,
3738
+ retryBackoffMs,
3478
3739
  status: "succeeded",
3479
3740
  prompt: prompt.prompt,
3480
3741
  input: {},
@@ -3491,9 +3752,9 @@ async function renderExpandedImages({
3491
3752
  startedAt: new Date(renderStartedAtMs).toISOString(),
3492
3753
  endedAt: (/* @__PURE__ */ new Date()).toISOString(),
3493
3754
  durationMs,
3494
- attempts: 1,
3495
- retries: 0,
3496
- retryBackoffMs: 0,
3755
+ attempts: Math.max(attemptsMade, 1),
3756
+ retries: retryCount,
3757
+ retryBackoffMs,
3497
3758
  status: "failed",
3498
3759
  prompt: prompt.prompt,
3499
3760
  input: {},
@@ -3553,8 +3814,9 @@ var OpenRouterClient = class {
3553
3814
  }
3554
3815
  async requestStructured(request) {
3555
3816
  let aggregatedMetrics = null;
3817
+ const maxParseAttempts = Math.max(1, request.settings.modelRequestMaxAttempts);
3556
3818
  try {
3557
- for (let attempt = 0; attempt < 3; attempt += 1) {
3819
+ for (let attempt = 0; attempt < maxParseAttempts; attempt += 1) {
3558
3820
  const response = await this.sendCompletion({
3559
3821
  messages: request.messages,
3560
3822
  settings: request.settings,
@@ -3579,7 +3841,7 @@ var OpenRouterClient = class {
3579
3841
  request.onMetrics?.(aggregatedMetrics);
3580
3842
  return structured;
3581
3843
  } catch (parseError) {
3582
- if (attempt < 2 && shouldRetryStructuredParseError(parseError)) {
3844
+ if (attempt < maxParseAttempts - 1 && shouldRetryStructuredParseError(parseError)) {
3583
3845
  const backoff = backoffMs(attempt);
3584
3846
  aggregatedMetrics = recordParseRetryMetrics(aggregatedMetrics, backoff);
3585
3847
  await wait(backoff);
@@ -3645,7 +3907,8 @@ var OpenRouterClient = class {
3645
3907
  let attempts = 0;
3646
3908
  let retries = 0;
3647
3909
  let retryBackoffMs = 0;
3648
- for (let attempt = 0; attempt < 3; attempt += 1) {
3910
+ const maxAttempts = Math.max(1, settings.modelRequestMaxAttempts);
3911
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
3649
3912
  attempts = attempt + 1;
3650
3913
  const attemptStartedAtMs = Date.now();
3651
3914
  const controller = new AbortController();
@@ -3695,8 +3958,9 @@ var OpenRouterClient = class {
3695
3958
  const providerMessage = json?.error?.message ?? `OpenRouter request failed with status ${response.status}`;
3696
3959
  const raw = json?.error?.metadata?.raw;
3697
3960
  const message = raw ? `${raw} (OpenRouter: ${providerMessage})` : providerMessage;
3698
- if (shouldRetryStatus(response.status) && attempt < 2) {
3699
- const backoff = backoffMs(attempt);
3961
+ if (shouldRetryStatus(response.status) && attempt < maxAttempts - 1) {
3962
+ const advisedMs = extractRetryAfterFromResponse(response, json, rawBody);
3963
+ const backoff = advisedMs !== null ? Math.min(MAX_RETRY_BACKOFF_MS, advisedMs) : backoffMs(attempt);
3700
3964
  retries += 1;
3701
3965
  retryBackoffMs += backoff;
3702
3966
  onInteraction?.({
@@ -3722,7 +3986,7 @@ var OpenRouterClient = class {
3722
3986
  throw new Error(message);
3723
3987
  }
3724
3988
  const content = json.choices?.[0]?.message?.content;
3725
- if (!content && attempt < 2) {
3989
+ if (!content && attempt < maxAttempts - 1) {
3726
3990
  const backoff = backoffMs(attempt);
3727
3991
  retries += 1;
3728
3992
  retryBackoffMs += backoff;
@@ -3798,7 +4062,7 @@ var OpenRouterClient = class {
3798
4062
  responseBody: responseBodyRaw,
3799
4063
  errorMessage: lastError.message
3800
4064
  });
3801
- if (attempt < 2 && shouldRetryError(lastError)) {
4065
+ if (attempt < maxAttempts - 1 && shouldRetryError(lastError)) {
3802
4066
  const backoff = backoffMs(attempt);
3803
4067
  retries += 1;
3804
4068
  retryBackoffMs += backoff;
@@ -3925,6 +4189,61 @@ function normalizeClientError(error, timeoutMs) {
3925
4189
  function backoffMs(attempt) {
3926
4190
  return 500 * (attempt + 1);
3927
4191
  }
4192
+ var MAX_RETRY_BACKOFF_MS = 6e4;
4193
+ function extractRetryAfterFromResponse(response, json, rawBody) {
4194
+ const headerValue = typeof response.headers?.get === "function" ? response.headers.get("retry-after") : null;
4195
+ if (headerValue) {
4196
+ const numeric = Number.parseFloat(headerValue);
4197
+ if (Number.isFinite(numeric) && numeric > 0) {
4198
+ return Math.round(numeric * 1e3);
4199
+ }
4200
+ const dateMs = Date.parse(headerValue);
4201
+ if (Number.isFinite(dateMs)) {
4202
+ const diff = dateMs - Date.now();
4203
+ if (diff > 0) {
4204
+ return diff;
4205
+ }
4206
+ }
4207
+ }
4208
+ const metadata = json?.error?.metadata;
4209
+ if (metadata) {
4210
+ const fromMetadata = metadata["retry_after"] ?? metadata["retryAfter"];
4211
+ const numeric = toFiniteNumber(fromMetadata);
4212
+ if (numeric !== null && numeric > 0) {
4213
+ return Math.round(numeric * 1e3);
4214
+ }
4215
+ const rawCandidate = metadata["raw"];
4216
+ if (typeof rawCandidate === "string") {
4217
+ const fromRaw = extractRetryAfterFromString(rawCandidate);
4218
+ if (fromRaw !== null) {
4219
+ return fromRaw;
4220
+ }
4221
+ }
4222
+ }
4223
+ const fromBody = extractRetryAfterFromString(rawBody);
4224
+ if (fromBody !== null) {
4225
+ return fromBody;
4226
+ }
4227
+ return null;
4228
+ }
4229
+ function extractRetryAfterFromString(value2) {
4230
+ const bodyMatch = value2.match(/\\?"retry_after\\?"\s*:\s*(\d+(?:\.\d+)?)/i);
4231
+ if (!bodyMatch) {
4232
+ return null;
4233
+ }
4234
+ const numeric = Number.parseFloat(bodyMatch[1]);
4235
+ return Number.isFinite(numeric) && numeric > 0 ? Math.round(numeric * 1e3) : null;
4236
+ }
4237
+ function toFiniteNumber(value2) {
4238
+ if (typeof value2 === "number" && Number.isFinite(value2)) {
4239
+ return value2;
4240
+ }
4241
+ if (typeof value2 === "string") {
4242
+ const parsed = Number.parseFloat(value2);
4243
+ return Number.isFinite(parsed) ? parsed : null;
4244
+ }
4245
+ return null;
4246
+ }
3928
4247
  function aggregateLlmMetrics(total, next) {
3929
4248
  if (!total) {
3930
4249
  return { ...next };
@@ -4333,7 +4652,7 @@ async function runPipelineShell(input, options = {}) {
4333
4652
  const llmInteractions = [];
4334
4653
  const t2iInteractions = [];
4335
4654
  let writeSession;
4336
- const applyRetryUpdate = (stageId, retryIncrement, errorMessage) => {
4655
+ const applyRetryUpdate = (stageId, retryIncrement, errorMessage2) => {
4337
4656
  if (retryIncrement <= 0) {
4338
4657
  return;
4339
4658
  }
@@ -4344,7 +4663,7 @@ async function runPipelineShell(input, options = {}) {
4344
4663
  const existing = stageRetryState.get(stageId) ?? { retries: 0, lastError: null };
4345
4664
  const next = {
4346
4665
  retries: existing.retries + retryIncrement,
4347
- lastError: errorMessage && errorMessage.trim().length > 0 ? errorMessage : existing.lastError
4666
+ lastError: errorMessage2 && errorMessage2.trim().length > 0 ? errorMessage2 : existing.lastError
4348
4667
  };
4349
4668
  stageRetryState.set(stageId, next);
4350
4669
  stages[stageIndex] = {
@@ -7384,7 +7703,8 @@ function SettingsFlow({ initialSettings, initialSecrets, onDone }) {
7384
7703
  t2i: {
7385
7704
  modelId: item.value,
7386
7705
  replicateModelId: current.t2i.replicateModelId && isReplicateModelIdForFamily(item.value, current.t2i.replicateModelId) ? current.t2i.replicateModelId : void 0,
7387
- inputOverrides: {}
7706
+ inputOverrides: {},
7707
+ maxAttempts: current.t2i.maxAttempts
7388
7708
  }
7389
7709
  }));
7390
7710
  setShowModelSelect(false);
@@ -9710,13 +10030,13 @@ function PipelinePresenter({
9710
10030
  prompt,
9711
10031
  stages,
9712
10032
  result,
9713
- errorMessage
10033
+ errorMessage: errorMessage2
9714
10034
  }) {
9715
10035
  const visibleStages = getVisibleStages(stages);
9716
10036
  const maxVisibleItems = computeMaxVisibleItemsPerStage({
9717
10037
  terminalRows: process.stdout.rows,
9718
10038
  visibleStageCount: visibleStages.length,
9719
- hasError: Boolean(errorMessage),
10039
+ hasError: Boolean(errorMessage2),
9720
10040
  hasResult: Boolean(result)
9721
10041
  });
9722
10042
  return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", paddingX: 1, children: [
@@ -9732,7 +10052,7 @@ function PipelinePresenter({
9732
10052
  },
9733
10053
  stage.id
9734
10054
  )) }),
9735
- errorMessage ? /* @__PURE__ */ jsx5(Box4, { marginTop: 1, borderStyle: "round", borderColor: "red", paddingX: 1, children: /* @__PURE__ */ jsx5(Text4, { color: "red", children: errorMessage }) }) : null,
10055
+ errorMessage2 ? /* @__PURE__ */ jsx5(Box4, { marginTop: 1, borderStyle: "round", borderColor: "red", paddingX: 1, children: /* @__PURE__ */ jsx5(Text4, { color: "red", children: errorMessage2 }) }) : null,
9736
10056
  result ? /* @__PURE__ */ jsx5(Box4, { marginTop: 1, children: /* @__PURE__ */ jsx5(FinalSummary, { artifact: result.artifact, analytics: result.analytics }) }) : null
9737
10057
  ] });
9738
10058
  }
@@ -10267,7 +10587,7 @@ function WriteApp({
10267
10587
  () => createInitialStages()
10268
10588
  );
10269
10589
  const [result, setResult] = useState4(null);
10270
- const [errorMessage, setErrorMessage] = useState4(null);
10590
+ const [errorMessage2, setErrorMessage] = useState4(null);
10271
10591
  useEffect3(() => {
10272
10592
  let mounted = true;
10273
10593
  void (async () => {
@@ -10320,7 +10640,7 @@ function WriteApp({
10320
10640
  };
10321
10641
  }, [dryRun, enrichLinks2, input, links, unlinks, maxLinks, maxImages, onError, runMode]);
10322
10642
  useEffect3(() => {
10323
- if (!result && !errorMessage) {
10643
+ if (!result && !errorMessage2) {
10324
10644
  return;
10325
10645
  }
10326
10646
  const exitTimer = setTimeout(() => {
@@ -10329,8 +10649,8 @@ function WriteApp({
10329
10649
  return () => {
10330
10650
  clearTimeout(exitTimer);
10331
10651
  };
10332
- }, [errorMessage, exit, result]);
10333
- return /* @__PURE__ */ jsx7(PipelinePresenter, { prompt: input.idea, stages, result, errorMessage });
10652
+ }, [errorMessage2, exit, result]);
10653
+ return /* @__PURE__ */ jsx7(PipelinePresenter, { prompt: input.idea, stages, result, errorMessage: errorMessage2 });
10334
10654
  }
10335
10655
  async function runWriteCommand(options) {
10336
10656
  const input = await resolveInputWithInteractiveIdeaFallback(options);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telepat/ideon",
3
- "version": "0.1.30",
3
+ "version": "0.1.32",
4
4
  "description": "CLI for generating rich articles and images from ideas.",
5
5
  "type": "module",
6
6
  "repository": {