@blockrun/franklin 3.8.34 → 3.8.35

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
@@ -480,10 +480,12 @@ cd franklin
480
480
  npm install
481
481
  npm run build
482
482
  npm test # deterministic local tests — no API calls
483
- npm run test:e2e # live e2e tests — hits real models, needs wallet
483
+ npm run test:e2e # live e2e tests — free smoke works unfunded; paid tools need network + funded wallet
484
484
  node dist/index.js --help
485
485
  ```
486
486
 
487
+ For the recommended live validation order and failure triage, see [docs/live-e2e-checklist.md](docs/live-e2e-checklist.md).
488
+
487
489
  **Contributing:** open an issue first to discuss meaningful changes. PRs welcome on bugs, docs, new models in pricing, and new tools.
488
490
 
489
491
  ---
@@ -189,7 +189,7 @@ Your training data is frozen in the past. Live-world questions MUST be answered
189
189
 
190
190
  If you find yourself about to emit one of these, stop and call the tool instead. If you don't know which ticker the user means, call ExaSearch or AskUser — never deflect.
191
191
 
192
- **Media generation (ImageGen / VideoGen).** Pass just the user's descriptive prompt and the output path — do NOT pass \`model\`. The harness picks the right model for the requested style + budget and surfaces a cost proposal through AskUser before spending. Only pass \`model\` explicitly if the user named one specifically.`;
192
+ **Media generation (ImageGen / VideoGen).** Pass just the user's descriptive prompt and the output path — do NOT pass \`model\`. The harness picks the right model for the requested style + budget, refines loose prompts using a 5-slot template (scene / subject / details / use case / constraints), and surfaces both the refinement and a cost proposal through AskUser before spending. If the user wants their prompt left exactly as written, prefix it with \`///\` to skip refinement. Only pass \`model\` explicitly if the user named one specifically.`;
193
193
  }
194
194
  function getTokenEfficiencySection() {
195
195
  return `# Token Efficiency
@@ -33,9 +33,35 @@ export interface MediaProposal {
33
33
  style: MediaStyle;
34
34
  priority: MediaPriority;
35
35
  };
36
+ /**
37
+ * A fuller rewrite of the user's prompt, following the 5-slot template
38
+ * (scene/subject/details/use-case/constraints). Null when the classifier
39
+ * judged the input already well-specified, or when the env opt-out is set,
40
+ * or when the rewrite was identical to the raw input. When non-null, the
41
+ * AskUser layout surfaces it as "Refined:" with a "Use ORIGINAL" option.
42
+ */
43
+ refinedPrompt: string | null;
44
+ refinementSummary: string;
36
45
  totalCostUsd: number;
37
46
  }
38
47
  export declare function clearMediaRouterCache(): void;
48
+ /**
49
+ * Normalize a refined prompt: trim, cap length, reject obvious junk.
50
+ * Returns null when the value should be treated as absent (missing,
51
+ * non-string, empty after trim).
52
+ *
53
+ * Exported for testability — invariants matter more here than elsewhere
54
+ * because the output is user-visible and paid for.
55
+ */
56
+ export declare function validateRefined(raw: unknown, maxChars: number): string | null;
57
+ /**
58
+ * Whitespace-insensitive, case-insensitive identity check — if the
59
+ * classifier's "refinement" is just the input with different spacing,
60
+ * don't bother the user with a "Refined:" block.
61
+ */
62
+ export declare function isEffectivelyIdentical(a: string, b: string): boolean;
63
+ export declare const REFINED_PROMPT_MAX_CHARS = 500;
64
+ export declare const REFINEMENT_SUMMARY_LIMIT = 80;
39
65
  export interface AnalyzeMediaOpts {
40
66
  kind: MediaKind;
41
67
  prompt: string;
@@ -43,6 +69,13 @@ export interface AnalyzeMediaOpts {
43
69
  quantity?: number;
44
70
  durationSeconds?: number;
45
71
  signal?: AbortSignal;
72
+ /**
73
+ * One-shot opt-out — caller stripped a `///` prefix from the user's input
74
+ * and wants the proposal rendered without a Refined block or Use-original
75
+ * option. The classifier still runs (for model selection), but the
76
+ * refinement is discarded at parse time.
77
+ */
78
+ skipRefine?: boolean;
46
79
  }
47
80
  /**
48
81
  * Pick the best model + alternatives for this media request. Returns null
@@ -16,7 +16,9 @@ import { getModelsByCategory, estimateCostUsd, defaultDurationSeconds, maxDurati
16
16
  // ─── Classifier ─────────────────────────────────────────────────────────
17
17
  const CLASSIFIER_MODEL = process.env.FRANKLIN_MEDIA_ROUTER_MODEL || 'nvidia/llama-4-maverick';
18
18
  const TIMEOUT_MS = 3_500; // slightly more lenient than the turn-analyzer — we're asking for JSON with reasoning
19
- const MAX_TOKENS = 256;
19
+ const MAX_TOKENS = 384; // bumped from 256 to leave room for refined_prompt (≤500 chars) + refinement_summary (≤80)
20
+ const REFINED_MAX_CHARS = 500;
21
+ const REFINEMENT_SUMMARY_MAX_CHARS = 80;
20
22
  function buildSystemPrompt(kind, catalog) {
21
23
  const catalogLines = catalog.map(m => {
22
24
  const p = m.pricing;
@@ -25,7 +27,7 @@ function buildSystemPrompt(kind, catalog) {
25
27
  : `$${(p.per_second ?? 0).toFixed(2)}/s (default ${p.default_duration_seconds ?? 8}s, max ${p.max_duration_seconds ?? 8}s)`;
26
28
  return ` - ${m.id} · ${price} · ${m.description || m.name}`;
27
29
  }).join('\n');
28
- return `You pick the best ${kind} model for a user's Franklin request. Output ONE LINE of compact JSON. No markdown, no code fences, no explanation.
30
+ return `You pick the best ${kind} model for a user's Franklin request AND refine the user's prompt. Output ONE LINE of compact JSON. No markdown, no code fences, no explanation.
29
31
 
30
32
  ## Catalog (${catalog.length} available ${kind} models)
31
33
  ${catalogLines}
@@ -34,6 +36,8 @@ ${catalogLines}
34
36
 
35
37
  {"style":"photoreal|illustration|anime|logo|concept|other",
36
38
  "priority":"cost|quality|balanced",
39
+ "refined_prompt":"<rewritten prompt in the user's language, <=${REFINED_MAX_CHARS} chars, or null if already well-specified>",
40
+ "refinement_summary":"<one short sentence, <=${REFINEMENT_SUMMARY_MAX_CHARS} chars, user-visible>",
37
41
  "recommended":{"model":"<id from catalog>","rationale":"<one sentence, <=140 chars>"},
38
42
  "cheaper":{"model":"<id from catalog | null>","rationale":"<one sentence>"},
39
43
  "premium":{"model":"<id from catalog | null>","rationale":"<one sentence>"}}
@@ -45,16 +49,33 @@ Rules:
45
49
  - Match style → model: anime/illustration prefers CogView, photoreal prefers Nano Banana Pro / Grok Imagine Pro, budget-conscious picks cheapest-acceptable.
46
50
  - One sentence rationale, user-visible.
47
51
 
52
+ ## Refinement (emit refined_prompt + refinement_summary)
53
+
54
+ If the user's prompt is missing ≥3 of these 5 slots, rewrite to fill them. If it already has ≥3 covered, set refined_prompt to null and refinement_summary to "Already well-specified".
55
+
56
+ 1. Scene — location, time of day, environment, background
57
+ 2. Subject — primary focus (who / what), preserved EXACTLY from the user's input (no substitution)
58
+ 3. Details — materials, textures, lighting, camera/lens feel, composition, mood (concrete visual facts, not praise)
59
+ 4. Use Case — editorial photo, product mockup, UI screen, logo, storyboard frame, social-media cover, etc.
60
+ 5. Constraints — aspect ratio, what must not drift (no watermark, preserve face, no text), hard asks
61
+
62
+ Anti-slop rules:
63
+ - Concrete visual facts ("overcast daylight", "brushed aluminum") beat vague praise ("stunning", "cinematic masterpiece").
64
+ - Wrap literal text that must appear in the image in double quotes. Spell difficult words letter-by-letter.
65
+ - One revision per turn — do not combine conflicting asks.
66
+ - Natural language, not keyword-tag format.
67
+ - refined_prompt stays in the same language as the user input. Chinese in → Chinese out.
68
+
48
69
  Examples:
49
70
 
50
71
  Input: "a photo of a cat on Mars, photoreal"
51
- Output: {"style":"photoreal","priority":"balanced","recommended":{"model":"google/nano-banana-pro","rationale":"Photoreal scenes — Nano Banana Pro has strong realism at moderate cost."},"cheaper":{"model":"google/nano-banana","rationale":"Same family, lower cost, slightly less detail."},"premium":{"model":"openai/gpt-image-2","rationale":"Best photoreal fidelity when budget allows."}}
72
+ Output: {"style":"photoreal","priority":"balanced","refined_prompt":"Eye-level photograph of a cat standing on the rust-colored Martian surface, late-afternoon low sun casting long shadows, distant canyon rim in the background, 50mm feel, shallow depth of field, editorial photo use, no watermark.","refinement_summary":"Added scene, lighting, lens, use case, constraint.","recommended":{"model":"google/nano-banana-pro","rationale":"Photoreal scenes — Nano Banana Pro has strong realism at moderate cost."},"cheaper":{"model":"google/nano-banana","rationale":"Same family, lower cost, slightly less detail."},"premium":{"model":"openai/gpt-image-2","rationale":"Best photoreal fidelity when budget allows."}}
52
73
 
53
74
  Input: "赛博朋克风格的动漫角色"
54
- Output: {"style":"anime","priority":"balanced","recommended":{"model":"zai/cogview-4","rationale":"CogView-4 specializes in stylized/anime imagery."},"cheaper":{"model":"google/nano-banana","rationale":"Cheaper but less stylized."},"premium":{"model":"xai/grok-imagine-image-pro","rationale":"Premium detail for complex scenes."}}
75
+ Output: {"style":"anime","priority":"balanced","refined_prompt":"赛博朋克风格的动漫角色,站在霓虹灯映照的雨夜街道上,身穿合成纤维夹克与金属反光饰件,头顶全息广告牌漂浮,低角度视角,强烈青粉对比,海报用,居中构图。","refinement_summary":"补全了场景、光线、材质、用途、构图。","recommended":{"model":"zai/cogview-4","rationale":"CogView-4 specializes in stylized/anime imagery."},"cheaper":{"model":"google/nano-banana","rationale":"Cheaper but less stylized."},"premium":{"model":"xai/grok-imagine-image-pro","rationale":"Premium detail for complex scenes."}}
55
76
 
56
77
  Input: "a 10-second cinematic drone shot over Tokyo at night"
57
- Output: {"style":"concept","priority":"quality","recommended":{"model":"bytedance/seedance-2.0","rationale":"Seedance 2.0 delivers the best cinematic quality."},"cheaper":{"model":"bytedance/seedance-2.0-fast","rationale":"Faster + cheaper, minor quality trade-off."},"premium":{"model":null,"rationale":"2.0 is already the top tier."}}
78
+ Output: {"style":"concept","priority":"quality","refined_prompt":null,"refinement_summary":"Already well-specified.","recommended":{"model":"bytedance/seedance-2.0","rationale":"Seedance 2.0 delivers the best cinematic quality."},"cheaper":{"model":"bytedance/seedance-2.0-fast","rationale":"Faster + cheaper, minor quality trade-off."},"premium":{"model":null,"rationale":"2.0 is already the top tier."}}
58
79
 
59
80
  Output JSON only, single line.`;
60
81
  }
@@ -82,6 +103,33 @@ function validateChoice(raw, catalog) {
82
103
  const rationale = typeof raw.rationale === 'string' ? raw.rationale.slice(0, 240) : '';
83
104
  return { model, rationale };
84
105
  }
106
+ /**
107
+ * Normalize a refined prompt: trim, cap length, reject obvious junk.
108
+ * Returns null when the value should be treated as absent (missing,
109
+ * non-string, empty after trim).
110
+ *
111
+ * Exported for testability — invariants matter more here than elsewhere
112
+ * because the output is user-visible and paid for.
113
+ */
114
+ export function validateRefined(raw, maxChars) {
115
+ if (typeof raw !== 'string')
116
+ return null;
117
+ const trimmed = raw.trim();
118
+ if (trimmed.length === 0)
119
+ return null;
120
+ return trimmed.slice(0, maxChars);
121
+ }
122
+ /**
123
+ * Whitespace-insensitive, case-insensitive identity check — if the
124
+ * classifier's "refinement" is just the input with different spacing,
125
+ * don't bother the user with a "Refined:" block.
126
+ */
127
+ export function isEffectivelyIdentical(a, b) {
128
+ const normalize = (s) => s.toLowerCase().replace(/\s+/g, ' ').trim();
129
+ return normalize(a) === normalize(b);
130
+ }
131
+ export const REFINED_PROMPT_MAX_CHARS = REFINED_MAX_CHARS;
132
+ export const REFINEMENT_SUMMARY_LIMIT = REFINEMENT_SUMMARY_MAX_CHARS;
85
133
  /**
86
134
  * Pick the best model + alternatives for this media request. Returns null
87
135
  * on any failure path (classifier timeout, parse error, empty catalog) so
@@ -100,12 +148,18 @@ export async function analyzeMediaRequest(opts) {
100
148
  if (catalog.length === 0)
101
149
  return null;
102
150
  // Cache check — classifier output is stable for a given prompt + catalog
103
- // version, so re-asking within 30s is waste.
151
+ // version, so re-asking within 30s is waste. Cache stores the FULL
152
+ // classifier response (refinement + model picks); the per-call mask for
153
+ // skipRefine / FRANKLIN_NO_MEDIA_PROMPT_REFINE is applied on the way out
154
+ // so the same cache entry serves callers with different opt-out flags.
104
155
  const quantity = Math.max(1, Math.floor(opts.quantity ?? 1));
156
+ const globalOptOut = process.env.FRANKLIN_NO_MEDIA_PROMPT_REFINE === '1';
157
+ const shouldDiscard = globalOptOut || opts.skipRefine === true;
158
+ const maskRefinement = (p) => shouldDiscard ? { ...p, refinedPrompt: null, refinementSummary: '' } : p;
105
159
  const key = hashKey([kind, prompt.trim().slice(0, 500), String(quantity), String(opts.durationSeconds ?? '')]);
106
160
  const hit = cache.get(key);
107
161
  if (hit && hit.expiresAt > Date.now())
108
- return hit.value;
162
+ return maskRefinement(hit.value);
109
163
  // Call the classifier.
110
164
  const catalogMap = new Map(catalog.map(m => [m.id, m]));
111
165
  const system = buildSystemPrompt(kind, catalog);
@@ -152,6 +206,15 @@ export async function analyzeMediaRequest(opts) {
152
206
  return null;
153
207
  const cheaperChoice = validateChoice(parsed.cheaper, catalogMap);
154
208
  const premiumChoice = validateChoice(parsed.premium, catalogMap);
209
+ // Refinement fields. The cache stores the full classifier output; the
210
+ // per-call mask is applied on the way out via maskRefinement() above, so
211
+ // here we just normalize + discard rewrites that are effectively the
212
+ // same as the raw input (drift-proof).
213
+ let refinedPrompt = validateRefined(parsed.refined_prompt, REFINED_MAX_CHARS);
214
+ const refinementSummary = validateRefined(parsed.refinement_summary, REFINEMENT_SUMMARY_MAX_CHARS) ?? '';
215
+ if (refinedPrompt !== null && isEffectivelyIdentical(refinedPrompt, prompt)) {
216
+ refinedPrompt = null;
217
+ }
155
218
  // Build proposal with live cost estimates.
156
219
  const durationSeconds = kind === 'video'
157
220
  ? (opts.durationSeconds ?? defaultDurationSeconds(rec.model))
@@ -180,6 +243,8 @@ export async function analyzeMediaRequest(opts) {
180
243
  cheaper: toChoice(cheaperChoice),
181
244
  premium: toChoice(premiumChoice),
182
245
  intent: { style, priority },
246
+ refinedPrompt,
247
+ refinementSummary,
183
248
  totalCostUsd: recommended.estimatedCostUsd,
184
249
  };
185
250
  // Evict oldest if bounded
@@ -189,7 +254,7 @@ export async function analyzeMediaRequest(opts) {
189
254
  cache.delete(firstKey);
190
255
  }
191
256
  cache.set(key, { value: proposal, expiresAt: Date.now() + CACHE_TTL_MS });
192
- return proposal;
257
+ return maskRefinement(proposal);
193
258
  }
194
259
  // ─── Presentation ───────────────────────────────────────────────────────
195
260
  /**
@@ -202,6 +267,13 @@ export function renderProposalForAskUser(p, userPrompt) {
202
267
  lines.push(`*Media generation proposal*`);
203
268
  lines.push('');
204
269
  lines.push(`Prompt: "${userPrompt.trim().slice(0, 200)}"`);
270
+ if (p.refinedPrompt) {
271
+ lines.push('');
272
+ lines.push(`Refined: ${p.refinedPrompt}`);
273
+ if (p.refinementSummary) {
274
+ lines.push(` (${p.refinementSummary})`);
275
+ }
276
+ }
205
277
  if (p.kind === 'video' && p.durationSeconds) {
206
278
  const maxNote = p.maxDurationSeconds ? ` (max ${p.maxDurationSeconds}s)` : '';
207
279
  lines.push(`Duration: ${p.durationSeconds}s${maxNote}`);
@@ -220,11 +292,17 @@ export function renderProposalForAskUser(p, userPrompt) {
220
292
  lines.push('');
221
293
  lines.push(` (prices include the 5% gateway fee)`);
222
294
  const options = [];
223
- options.push({ id: 'recommended', label: `Continue with ${p.recommended.model}` });
295
+ const recLabel = p.refinedPrompt
296
+ ? `Continue with refined prompt + ${p.recommended.model}`
297
+ : `Continue with ${p.recommended.model}`;
298
+ options.push({ id: 'recommended', label: recLabel });
224
299
  if (p.cheaper)
225
300
  options.push({ id: 'cheaper', label: `Use cheaper (${p.cheaper.model})` });
226
301
  if (p.premium)
227
302
  options.push({ id: 'premium', label: `Use premium (${p.premium.model})` });
303
+ if (p.refinedPrompt) {
304
+ options.push({ id: 'use-raw', label: `Use ORIGINAL prompt + ${p.recommended.model}` });
305
+ }
228
306
  options.push({ id: 'cancel', label: 'Cancel (no charge)' });
229
307
  return { question: lines.join('\n'), options };
230
308
  }
@@ -11,17 +11,29 @@ import { ModelClient } from '../agent/llm.js';
11
11
  import { analyzeMediaRequest, renderProposalForAskUser } from '../agent/media-router.js';
12
12
  function buildExecute(deps) {
13
13
  return async function execute(input, ctx) {
14
- const { prompt, output_path, size, model, contentId } = input;
15
- if (!prompt) {
14
+ const rawInput = input;
15
+ const { output_path, size, model, contentId } = rawInput;
16
+ if (!rawInput.prompt) {
16
17
  return { output: 'Error: prompt is required', isError: true };
17
18
  }
19
+ // One-shot refinement opt-out: leading `///` tells Franklin "don't
20
+ // refine this prompt, I wrote it the way I want it." Strip the prefix
21
+ // and pass skipRefine through to the router.
22
+ let prompt = rawInput.prompt;
23
+ let skipRefine = false;
24
+ if (prompt.trimStart().startsWith('///')) {
25
+ prompt = prompt.replace(/^\s*\/\/\/\s?/, '');
26
+ skipRefine = true;
27
+ }
18
28
  // ── Media router + AskUser flow ────────────────────────────────────
19
29
  // If the caller explicitly named a model, or the env auto-approves, or
20
30
  // no AskUser bridge exists (batch / --prompt mode), skip the proposal
21
31
  // step and use the old default. Otherwise: classifier picks a fitting
22
- // model, cost preview goes to AskUser, user chooses or cancels.
32
+ // model + rewrites the prompt, the preview goes to AskUser, user
33
+ // chooses or cancels.
23
34
  let imageModel = model || 'openai/gpt-image-1';
24
35
  const imageSize = size || '1024x1024';
36
+ let chosenPrompt = prompt;
25
37
  const autoApprove = process.env.FRANKLIN_MEDIA_AUTO_APPROVE_ALL === '1';
26
38
  if (!model && !autoApprove && ctx.onAskUser) {
27
39
  try {
@@ -33,6 +45,7 @@ function buildExecute(deps) {
33
45
  quantity: 1,
34
46
  client,
35
47
  signal: ctx.abortSignal,
48
+ skipRefine,
36
49
  });
37
50
  if (proposal) {
38
51
  const { question, options } = renderProposalForAskUser(proposal, prompt);
@@ -51,9 +64,15 @@ function buildExecute(deps) {
51
64
  return {
52
65
  output: `## Image generation cancelled\n\nNo USDC was spent. Ask again when ready, or pass an explicit \`model\` to skip the confirmation step.`,
53
66
  };
67
+ case 'use-raw':
68
+ imageModel = proposal.recommended.model;
69
+ // chosenPrompt stays as the raw input
70
+ break;
54
71
  case 'recommended':
55
72
  default:
56
73
  imageModel = proposal.recommended.model;
74
+ if (proposal.refinedPrompt)
75
+ chosenPrompt = proposal.refinedPrompt;
57
76
  }
58
77
  }
59
78
  }
@@ -83,7 +102,7 @@ function buildExecute(deps) {
83
102
  : path.resolve(ctx.workingDir, `generated-${Date.now()}.png`);
84
103
  const body = JSON.stringify({
85
104
  model: imageModel,
86
- prompt,
105
+ prompt: chosenPrompt,
87
106
  n: 1,
88
107
  size: imageSize,
89
108
  response_format: 'b64_json',
@@ -25,11 +25,21 @@ function estimateVideoCostUsd(durationSeconds = DEFAULT_DURATION) {
25
25
  }
26
26
  function buildExecute(deps) {
27
27
  return async function execute(input, ctx) {
28
- const { prompt, output_path, model, image_url, duration_seconds, contentId } = input;
29
- if (!prompt)
28
+ const rawInput = input;
29
+ const { output_path, model, image_url, duration_seconds, contentId } = rawInput;
30
+ if (!rawInput.prompt)
30
31
  return { output: 'Error: prompt is required', isError: true };
32
+ // One-shot refinement opt-out: leading `///` tells Franklin "don't
33
+ // refine this prompt." Strip the prefix and pass skipRefine through.
34
+ let prompt = rawInput.prompt;
35
+ let skipRefine = false;
36
+ if (prompt.trimStart().startsWith('///')) {
37
+ prompt = prompt.replace(/^\s*\/\/\/\s?/, '');
38
+ skipRefine = true;
39
+ }
31
40
  let videoModel = model || DEFAULT_MODEL;
32
41
  let duration = duration_seconds ?? DEFAULT_DURATION;
42
+ let chosenPrompt = prompt;
33
43
  // ── Media router + AskUser flow (video bills per second, always ask) ──
34
44
  const autoApprove = process.env.FRANKLIN_MEDIA_AUTO_APPROVE_ALL === '1';
35
45
  if (!model && !autoApprove && ctx.onAskUser) {
@@ -42,6 +52,7 @@ function buildExecute(deps) {
42
52
  durationSeconds: duration_seconds,
43
53
  client,
44
54
  signal: ctx.abortSignal,
55
+ skipRefine,
45
56
  });
46
57
  if (proposal) {
47
58
  const { question, options } = renderProposalForAskUser(proposal, prompt);
@@ -59,9 +70,15 @@ function buildExecute(deps) {
59
70
  return {
60
71
  output: `## Video generation cancelled\n\nNo USDC was spent.`,
61
72
  };
73
+ case 'use-raw':
74
+ videoModel = proposal.recommended.model;
75
+ // chosenPrompt stays as the raw input
76
+ break;
62
77
  case 'recommended':
63
78
  default:
64
79
  videoModel = proposal.recommended.model;
80
+ if (proposal.refinedPrompt)
81
+ chosenPrompt = proposal.refinedPrompt;
65
82
  }
66
83
  // Use the proposal's duration — the router honored the user's
67
84
  // duration_seconds or filled in the model's default.
@@ -96,7 +113,7 @@ function buildExecute(deps) {
96
113
  : path.resolve(ctx.workingDir, `generated-${Date.now()}.mp4`);
97
114
  const body = JSON.stringify({
98
115
  model: videoModel,
99
- prompt,
116
+ prompt: chosenPrompt,
100
117
  ...(image_url ? { image_url } : {}),
101
118
  ...(duration_seconds ? { duration_seconds } : {}),
102
119
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blockrun/franklin",
3
- "version": "3.8.34",
3
+ "version": "3.8.35",
4
4
  "description": "Franklin — The AI agent with a wallet. Spends USDC autonomously to get real work done. Pay per action, no subscriptions.",
5
5
  "type": "module",
6
6
  "exports": {