SLOPSHOPPER

higgsfield-superengine

Makes Higgsfield image and video generation easy, just by talking to Claude. Standalone: it calls the Higgsfield REST API directly with your own Higgsfield API…

panebandguardtoastprocesstimer
A shopper browsing a rack in a slop shop
README

higgsfield-superengine

Higgsfield image and video generation, driven by Claude. Your own Higgsfield key, prompting best practices built in, and a price check before anything spends. Windows and Mac.


Status: released (v0.1.5)

Install it from the REVXL catalog. See CHANGELOG.md for what changed in each version.


What this plugin does

Higgsfield can make great images and videos, but getting good results means knowing which model to pick, how to write the prompt, and what each run will cost. This plugin handles that for you.

  • Your own account. It talks to the Higgsfield REST API (Higgsfield's official web interface for programs) directly, with your own Higgsfield API key. Each client uses and pays for their own account.
  • Prompting built in. Image and video prompting best practices are part of the plugin, so you describe what you want in plain English and Claude writes the prompt.
  • Estimate first. Every paid generation is priced before it runs. Small jobs under a safety cap run after the price check; anything bigger waits for your yes.

Spend band

New in v0.1.5: you decide how much each chat may spend, and you can see it.

  • The cap box. The first paid job in a chat asks you how much this chat may spend: $5, $10, $20, or Not now. Jobs that fit under the amount you pick just run. Not now sends nothing, and the box comes back with the next paid job.
  • The band. A strip above the prompt shows what this chat has spent against its cap (before you pick one, against the 24-hour limit), with buttons: +$5 and ×2 raise the cap, Back to $5 lowers it, and Details opens the Details pane. It turns amber at 70% of the cap and red at 90%. Only your clicks change the cap; Claude is blocked from setting it with a command.
  • The Details pane. This chat's jobs (time, model, price, status), with Open to view a finished picture or video; spend by model; what you spent in the last 24 hours and 7 days; and your Higgsfield balance (Update balance records the figure from your Higgsfield billing page).
  • A reopened chat that already picked a cap or spent money shows its band again.
  • A cap you pick is the cap for that chat. It is not lowered to a quarter of your balance, the way the 24-hour limit is, so pick an amount your balance can cover.
  • Prices are Higgsfield's list prices, before any sale. While Higgsfield runs a sale, a quote can be higher than what you are actually charged, so a job may ask for your OK sooner than it needs to.

Where the band does not appear: Cowork, VS Code, WSL sessions, Claude Code older than 2.1.287, or with mods turned off. Your spending is still protected there: until a cap is picked for a chat, the limit of at most $5 per 24 hours applies, and any job over it shows a pop-up with the price (Allow runs that one job, Deny spends nothing).


Planned skills

  • higgsfield-setup — connect your Higgsfield key, one time.
  • hf-generate — the only skill that spends credits. Prices the job first, then runs it.
  • image-prompting, video-prompting — the prompting craft.
  • static-ads, ugc-video-ads, product-shots, thumbnails — guided flows for common jobs.

Compatibility

PlatformStatus
WindowsSupported
MacSupported since v0.1.3: Python 3.9+ (found by setup; Homebrew installs it if missing); the key is kept in the Keychain

Changelog

See CHANGELOG.md.

License

MIT — see LICENSE.

Part of

revxl-marketplace — REVXL's curated Claude superengine catalog.

Source 2 files
hooks/mod/register.ts 227 lines
1import type { Register } from 'claude-code'
2import { SUBMIT_RE, USE_RE, meterColor, ratioOf, esc, pythonOk, logDays, dotGroup, parseJson, jobsFromLog, hhmm,
3  submitStep, bandOnStart, type Job } from './logic'
4
5const PANE = 'hf-details'
6const ASK = 'Higgsfield: how much can Claude spend in this session without asking you each time?'
7const NOT_NOW = 'The client chose not to set a Higgsfield spending cap for this session yet, so nothing was sent. ' +
8  'Ask them before trying again; the cap box will appear again with the next paid job.'
9const BALANCE_ASK = 'What balance does your Higgsfield console show (Billing page)? Type the dollar amount under Other.'
10const MEDIA = /\.(png|jpe?g|webp|gif|mp4|mov|webm)$/i
11
12let st: any = null        // last `ledger.py status --session` result
13let err = ''              // why the ledger could not be read ('' = fine)
14let used = false          // this session touched Higgsfield: show the band
15let jobs: Job[] = []
16let asking: Promise<string | undefined> | null = null   // one box for parallel submits ("3 variations")
17let saving: Promise<boolean> | null = null              // ... and one save of its answer
18
19async function root($: any): Promise<string> { return await $.session.root() }
20
21async function ledgerRun($: any, args: string[]): Promise<any> {
22  let py = ''
23  try { py = (await $.fs.read((await root($)) + '/higgsfield/.python')).trim() } catch { /* setup not done */ }
24  if (!py) throw new Error('Higgsfield setup is not finished in this folder')
25  if (!pythonOk(py)) throw new Error('higgsfield/.python is not an absolute path to Python; run the setup again')
26  const r = await $.process.run([py, '-B', $.plugin.root + '/scripts/ledger.py', ...args], { timeoutMs: 20000 })
27  const o = parseJson(r.stdout)
28  if (!o) throw new Error('the spend record gave no answer')
29  return o
30}
31
32async function readLogs($: any, entries: any[]): Promise<string[]> {
33  const lines: string[] = []
34  for (const day of logDays(await $.clock.now(), entries)) {
35    try { lines.push(...(await $.fs.read(`${await root($)}/higgsfield/${day}/log.jsonl`)).split(/\r?\n/)) } catch { /* none */ }
36  }
37  return lines
38}
39
40async function refresh($: any) {
41  try {
42    st = await ledgerRun($, ['status', '--session', await $.session.id()])
43    err = st.ok === false ? String(st.message || 'the spend record could not be read') : ''
44    if (!err) jobs = jobsFromLog(await readLogs($, st.session_entries ?? []), st.session_entries ?? [])
45  } catch (x) { err = String((x as Error).message || x) || 'the spend record could not be read' }
46  $.ui.invalidate('ui.render')
47}
48
49/** A reopened chat that already used Higgsfield gets its band back without waiting for the next command.
50 *  No higgsfield/.python here: ledgerRun throws before spawning anything, so nothing shows. */
51async function startBand($: any) {
52  await refresh($)
53  if (bandOnStart(st, err)) { used = true; $.ui.invalidate('ui.render') }
54}
55
56/** The figures the client sees: the chat's picked cap, else the 24 h limit (same rule as the guard and the quote). */
57function figures(s: any) {
58  const picked = !!s?.session_cap_set
59  const spent = Number((picked ? s?.session_spent : s?.spent_24h) ?? 0)
60  const cap = Number((picked ? s?.session_cap : s?.cap_usd) ?? 5)
61  const left = Number((picked ? s?.session_headroom : s?.headroom_usd) ?? Math.max(cap - spent, 0))
62  return { picked, spent, cap, left, ratio: ratioOf(spent, cap) }
63}
64
65async function capAnswer($: any): Promise<string | undefined> {
66  if (!asking) asking = $.ui.ask(ASK, { header: 'Session cap', options: ['$5', '$10', '$20', 'Not now'] })
67    .catch(() => undefined).finally(() => { asking = null })
68  return asking
69}
70
71async function setCap($: any, usd: number): Promise<boolean> {
72  try {
73    const o = await ledgerRun($, ['session-cap', 'set', await $.session.id(), usd.toFixed(2)])
74    if (o.ok === false) throw new Error(o.message)
75  } catch {
76    return false
77  }
78  const note = `Higgsfield session cap set to $${usd.toFixed(2)} by the client.`
79  await $.session.append({ message: { type: 'user', content: [{ type: 'text', text: note }] } }).catch(() => $.ui.log(note))
80  await refresh($)
81  return true
82}
83
84async function pressCap($: any, usd: number) {
85  if (!(await setCap($, usd))) $.ui.toast("Couldn't save the cap, try again")
86}
87
88async function openDetails($: any) {
89  await $.ui.open({ id: PANE, title: 'Higgsfield', focus: true, closeOnEscape: true })
90  await refresh($)
91}
92
93/** Job files are relative to the session root, or absolute; only media files inside the session root open. */
94async function openFile($: any, file: string) {
95  const r = await root($)
96  const abs = /^([A-Za-z]:)?[\\/]/.test(file) ? file : r + '/' + file.replace(/\\/g, '/')
97  const real = (await $.fs.stat(abs, { resolve: true }).catch(() => undefined))?.realPath
98  const base = (await $.fs.stat(r, { resolve: true }).catch(() => undefined))?.realPath?.replace(/[\\/]$/, '')
99  const inside = !!real && !!base && real.startsWith(base) && /[\\/]/.test(real.charAt(base.length))
100  if (!inside || !MEDIA.test(real!)) return void $.ui.toast('Only Higgsfield images and videos in this folder open from here.')
101  try { await $.process.run(/^[A-Za-z]:/.test(real!) ? ['explorer.exe', real!] : ['open', real!]) }
102  catch { $.ui.toast("Couldn't open the file.") }
103}
104
105async function openBilling($: any) {   // a fixed URL, never data from the log
106  const url = 'https://open.higgsfield.ai/dashboard'
107  try { await $.process.run(/^[A-Za-z]:/.test(await root($)) ? ['explorer.exe', url] : ['open', url]) }
108  catch { $.ui.toast("Couldn't open the browser.") }
109}
110
111export const register: Register = on => {
112  on('session.start', async ($, e, next) => {
113    const r = await next(e)
114    $.clock.after(0, () => void startBand($).catch(() => {}))   // a timer outlives this dispatch; never blocks the start
115    return r
116  })
117
118  // Cap box before the first paid job of the session; refresh after anything that touches Higgsfield.
119  on('tool.call', { tool: ['Bash', 'PowerShell'], command: /higgsfield|hf_rest|ledger\.py/i }, async ($, e, next) => {
120    const cmd = String((e as any).command ?? '')
121    if (USE_RE.test(cmd)) used = true
122    if (SUBMIT_RE.test(cmd)) {
123      await refresh($)
124      if (err) $.ui.toast('Higgsfield spend record unavailable: the 24 h limit applies.')
125      const answer = !err && st && !st.session_cap_set ? await capAnswer($) : undefined
126      const step = submitStep(st, err, answer)
127      if (step.kind === 'refuse') return { deny: NOT_NOW }
128      if (step.kind === 'set') {
129        // after the shared box: refresh, and save only if a parallel submit has not saved it (one save, one note)
130        if (!saving) saving = (async () => { await refresh($); return !!st?.session_cap_set || await setCap($, step.usd) })()
131          .finally(() => { saving = null })
132        if (!(await saving)) $.ui.toast("Couldn't save the cap: the 24 h limit applies.")
133      }
134      await refresh($)   // the second of two parallel submits sees the cap the first one set
135    }
136    const r = await next(e)   // the permission check (guard + Allow box) and the tool run here
137    await refresh($)
138    return r
139  })
140
141  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
142    if ((e as any).props?.hasSurvey || !(used || st?.session_cap_set)) return next(e)
143    const { Box, Text, Button, Svg }: any = $.ui.resolve(e)
144    if (err) return Box({ children: [Text({ dimColor: true, children: ['Higgsfield: spend record unavailable (' + err + ')'] })] })
145    const { picked, spent, cap, left, ratio } = figures(st), color = meterColor(ratio), W = 180
146    const bar = `<svg xmlns="http://www.w3.org/2000/svg" width="${W + 22}" height="16" viewBox="0 0 ${W + 22} 16">` +
147      `<path d="M7 1 L8.6 6.4 L14 8 L8.6 9.6 L7 15 L5.4 9.6 L0 8 L5.4 6.4 Z" fill="${color}"/>` +
148      dotGroup(20, 1, W, 14, ratio, color) + `</svg>`
149    const words = picked ? `of $${cap.toFixed(2)} this session · $${left.toFixed(2)} left`
150      : `of $${cap.toFixed(2)} (24 h limit) · you pick this chat's cap with the next paid job`
151    const press = (label: string, usd: number) => Button({ key: label, label, onPress: () => void pressCap($, usd) })
152    return Box({ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', children: [
153      Box({ key: 'meter', flexDirection: 'column', children: [
154        Svg ? Svg({ source: bar, alt: `Higgsfield: $${spent.toFixed(2)} ${words}`, width: W + 22, height: 16 })
155            : Text({ children: ['higgsfield'] }),
156        Box({ key: 'line', flexDirection: 'row', columnGap: 1, children: [
157          Text({ bold: true, children: ['$' + spent.toFixed(2)] }),
158          Text({ dimColor: true, children: [words] })] })] }),
159      Box({ key: 'buttons', flexDirection: 'row', columnGap: 1, children: [
160        press('+$5', cap + 5), press('×2', cap * 2), press('Back to $5', 5),
161        Button({ key: 'details', label: 'Details', onPress: () => void openDetails($) })] }),
162    ] })
163  })
164
165  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
166    if ((e as any).requestId !== PANE) return next(e)
167    const { Box, Text, Button, Svg }: any = $.ui.resolve(e)
168    if (err || !st) return Text({ dimColor: true, children: ['Higgsfield: spend record unavailable. ' + err] })
169    const { picked, spent, cap, left, ratio } = figures(st)
170    const ageDays = st.balance_at ? Math.floor(((await $.clock.now()) / 1000 - st.balance_at) / 86400) : null
171    const tile = (k: string, label: string, value: string, sub?: string) => Box({ key: k, flexDirection: 'column', flexGrow: 1,
172      width: '30%', minWidth: 26, borderStyle: 'round', borderColor: '#3f3f46', paddingX: 1, children: [
173        Text({ dimColor: true, children: [label] }), Text({ bold: true, children: [value] }),
174        ...(sub ? [Text({ dimColor: true, children: [sub] })] : [])] })
175    const meter = `<svg xmlns="http://www.w3.org/2000/svg" width="460" height="22" viewBox="0 0 460 22">` +
176      dotGroup(0, 2, 460, 20, ratio, meterColor(ratio)) + `</svg>`
177    const byModel: [string, number][] = []
178    for (const j of jobs) { const f = byModel.find(m => m[0] === j.model); f ? (f[1] += j.cost) : byModel.push([j.model, j.cost]) }
179    byModel.sort((a, b) => b[1] - a[1])
180    const top = byModel.slice(0, 6)
181    const max = Math.max(...top.map(m => m[1]), 0.01), RH = 22, CW = 300
182    const chart = `<svg xmlns="http://www.w3.org/2000/svg" width="${CW}" height="${Math.max(top.length, 1) * RH}" ` +
183      `font-family="system-ui,sans-serif" font-size="12">` + top.map(([m, v], i) =>
184        `<text x="0" y="${i * RH + 14}" fill="#e4e4e7">${esc(m)}</text>` + dotGroup(100, i * RH + 5, 150, 9, v / max, '#22c55e', false) +
185        `<text x="${CW}" y="${i * RH + 14}" fill="#a1a1aa" text-anchor="end">$${v.toFixed(2)}</text>`).join('') + `</svg>`
186    const dot: any = { done: '#22c55e', running: '#f59e0b', refunded: '#9ca3af', charged: '#a1a1aa' }
187    const cell = (k: string, w: string, ...children: any[]) => Box({ key: k, width: w, flexDirection: 'row', columnGap: 1, children })
188    const statusDot = (s: string) => Svg ? [Svg({ source: `<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" ` +
189      `viewBox="0 0 10 10"><circle cx="5" cy="5" r="4" fill="${dot[s] ?? '#a1a1aa'}"/></svg>`, alt: s, width: 10, height: 10 })] : []
190    const updateBalance = async () => {
191      let a: string | undefined
192      try { a = await $.ui.ask(BALANCE_ASK, { header: 'Balance', options: ['Not now', 'Open Higgsfield'] }) } catch { return }
193      if (a === 'Open Higgsfield') return void openBilling($)
194      const usd = (a ?? '').replace(/[,$\s]/g, '')
195      if (!/^\d+(\.\d+)?$/.test(usd)) return void (a && a !== 'Not now' && $.ui.toast('That did not look like a dollar amount; nothing changed.'))
196      try {
197        const o = await ledgerRun($, ['balance', 'set', usd])
198        if (o.ok === false) throw new Error(o.message)
199        $.ui.log(`Higgsfield balance recorded as $${usd} by the client.`)
200      } catch { $.ui.toast("Couldn't save the balance.") }
201      await refresh($)
202    }
203    return Box({ flexDirection: 'column', gap: 1, children: [
204      Box({ key: 'tiles', flexDirection: 'row', flexWrap: 'wrap', columnGap: 1, children: [
205        tile('t1', picked ? 'SPENT THIS SESSION' : 'SPENT, LAST 24 H', `$${spent.toFixed(2)}`),
206        tile('t2', 'LEFT', `$${left.toFixed(2)}`, picked ? `of $${cap.toFixed(2)} session cap` : `of $${cap.toFixed(2)} (24 h limit)`),
207        tile('t3', 'ACCOUNT', st.balance == null ? 'not set' : `≈ $${Number(st.balance).toFixed(2)}`,
208             ageDays == null ? 'press Update balance' : ageDays === 0 ? 'updated today' : `updated ${ageDays} days ago`)] }),
209      Svg ? Svg({ source: meter, alt: `${picked ? 'Session' : '24 h limit'} ${Math.round(ratio * 100)}% used`, width: 460, height: 22 }) : Text({ children: [''] }),
210      Box({ key: 'meta', flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', children: [
211        Text({ dimColor: true, children: [`Last 24 h $${Number(st.spent_24h).toFixed(2)} · Last 7 days $${Number(st.spent_7d).toFixed(2)}`] }),
212        Button({ key: 'upd', label: 'Update balance', onPress: () => void updateBalance() })] }),
213      Text({ bold: true, children: ['Jobs this session'] }),
214      Box({ key: 'jobs', flexDirection: 'column', children: jobs.length ? jobs.map((j, i) => Box({ key: 'j' + i, flexDirection: 'row', alignItems: 'center', children: [
215        cell('t', '20%', Text({ dimColor: true, children: [hhmm(j.time)] })),
216        cell('m', '36%', Text({ children: [`${j.model} · ${j.kind}`] })),
217        cell('c', '14%', Text({ children: ['$' + j.cost.toFixed(2)] })),
218        cell('s', '18%', ...statusDot(j.status), Text({ children: [j.status] })),
219        cell('o', '12%', j.files.length ? Button({ key: 'open' + i, label: 'Open', onPress: () => void openFile($, j.files[0]!) }) : Text({ children: [''] }))] }))
220        : [Text({ dimColor: true, children: ['No paid jobs in this session yet.'] })] }),
221      Text({ bold: true, children: ['Spend by model'] }),
222      top.length && Svg ? Svg({ source: chart, alt: top.map(([m, v]) => `${m} $${v.toFixed(2)}`).join(', '), width: CW, height: top.length * RH })
223        : Text({ dimColor: true, children: ['Nothing yet.'] }),
224    ] })
225  })
226}
227
hooks/mod/logic.ts 144 lines
1// Pure helpers for the higgsfield-superengine spend mod: no engine calls, so claude plugin test can check them.
2
3export const SUBMIT_RE = /hf_rest\.py["']?\s+submit\b/i
4/** A command that uses Higgsfield (price, generate, fetch): it shows the band. */
5export const USE_RE = /hf_rest\.py["']?\s+(estimate|submit|wait)\b/i
6
7export type Job = { time: number; model: string; kind: 'video' | 'image'; cost: number
8  status: 'running' | 'done' | 'refunded' | 'charged'; files: string[] }
9
10/** The cap a cap-box answer means, or null for Not now / closed / anything that is not a sane dollar amount. */
11export function capFromAnswer(a: string | undefined): number | null {
12  const m = /^\s*\$?\s*(\d{1,4}(?:\.\d{1,2})?)\s*$/.exec(a ?? '')
13  if (!m) return null
14  const n = Number(m[1])
15  return n > 0 && n <= 1000 ? n : null
16}
17
18export function meterColor(ratio: number): string {
19  return ratio >= 0.9 ? '#ef4444' : ratio >= 0.7 ? '#f59e0b' : '#22c55e'
20}
21
22/** Share of the cap spent, 0..1; 0 when there is no usable cap (no NaN on the meter). */
23export function ratioOf(spent: number, cap: number): number {
24  return cap > 0 && spent > 0 ? Math.min(spent / cap, 1) : 0
25}
26
27/** Text made safe inside an SVG <text> element. */
28export function esc(s: string): string {
29  return s.replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' } as Record<string, string>)[c]!)
30}
31
32/** higgsfield/.python is run as argv[0] only if it is an absolute path to a python (or py) interpreter. */
33export function pythonOk(p: string): boolean {
34  return /^([A-Za-z]:[\\/]|\/|\\\\)/.test(p) && /^(python3?(\.\d+)?|py)(\.exe)?$/i.test(p.split(/[\\/]/).pop() ?? '')
35}
36
37/** Local YYYY-MM-DD log folders, oldest first: from the oldest entry's day to today, at most 7 days back. */
38export function logDays(nowMs: number, entries: any[]): string[] {
39  const now = new Date(nowMs), days: string[] = []
40  const ymd = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
41  const first = ymd(new Date(Math.min(nowMs, ...entries.map(e => Number(e?.created_at) * 1000).filter(Number.isFinite))))
42  for (let back = 0; back <= 7; back++) {
43    days.unshift(ymd(new Date(now.getFullYear(), now.getMonth(), now.getDate() - back)))   // calendar days: DST-safe
44    if (days[0]! <= first) break
45  }
46  return days
47}
48
49/** Dot-matrix meter (Claude Code effort-slider look) as SVG elements: empty dots light grey, filled dots brighten
50 *  toward a white handle at the fill point. */
51export function dotGroup(x: number, y: number, w: number, h: number, ratio: number, color: string, handle = true): string {
52  const cell = h >= 18 ? 4 : 3, dot = cell - 1
53  const cols = Math.floor(w / cell), rows = Math.max(1, Math.floor(h / cell))
54  const fill = Math.round(cols * Math.min(Math.max(ratio, 0), 1))
55  let out = ''
56  for (let c = 0; c < cols; c++) {
57    for (let r = 0; r < rows; r++) {
58      const jitter = 0.55 + 0.45 * (((c * 31 + r * 17) % 7) / 6)
59      const cx = x + c * cell, cy = y + r * cell
60      out += c < fill
61        ? `<rect x="${cx}" y="${cy}" width="${dot}" height="${dot}" rx="${dot / 2}" fill="${color}" fill-opacity="${((0.25 + 0.75 * ((c + 1) / Math.max(fill, 1))) * jitter).toFixed(2)}"/>`
62        : `<rect x="${cx}" y="${cy}" width="${dot}" height="${dot}" rx="${dot / 2}" fill="#a1a1aa" fill-opacity="${(0.32 * jitter).toFixed(2)}"/>`
63    }
64  }
65  if (handle && fill > 0) {
66    const hw = Math.max(cell * 2, 6), hx = Math.min(x + fill * cell - hw / 2, x + w - hw)
67    out += `<rect x="${hx}" y="${y - 1}" width="${hw}" height="${rows * cell + 1}" rx="${hw / 2.4}" fill="#ffffff"/>`
68  }
69  return out
70}
71
72export function parseJson(stdout: string): any | null {
73  for (const line of stdout.split(/\r?\n/).reverse()) {
74    const t = line.trim()
75    if (t.startsWith('{')) { try { return JSON.parse(t) } catch { /* keep looking */ } }
76  }
77  return null
78}
79
80const MODELS: [RegExp, string][] = [[/seedance/i, 'Seedance'], [/kling/i, 'Kling'], [/flare/i, 'Flare'],
81  [/soul/i, 'Soul'], [/z-image/i, 'Z-Image'], [/hailuo|minimax/i, 'Hailuo'], [/wan/i, 'Wan'],
82  [/genjutsu/i, 'Genjutsu'], [/ltx|lightricks/i, 'LTX'], [/grok/i, 'Grok'], [/qwen|alibaba/i, 'Qwen']]
83const VIDEO = /video|seedance|kling|wan|hailuo|genjutsu|ltx|veo/i
84
85export function modelOf(ep: string): string {
86  return (MODELS.find(([re]) => re.test(ep)) ?? [null, ep.split('/').slice(-2).join(' ')])[1] as string
87}
88
89export function kindOf(ep: string): 'video' | 'image' {
90  return VIDEO.test(ep) ? 'video' : 'image'
91}
92
93const STATUS: Record<string, Job['status']> = { pending: 'running', settled: 'done', refunded: 'refunded',
94  assumed_charged: 'charged' }
95
96/** One row per ledger entry of this session, joined with the workspace log by estimate key and request id. The same
97 *  request run again has the same key: its entries pair with its submit lines in order (first entry <-> first submit).
98 *  An entry refunded as not_sent never wrote a submit line, so it takes no submit (its row has no files). */
99export function jobsFromLog(logLines: string[], entries: any[]): Job[] {
100  const submits = new Map<string, any[]>(), files = new Map<string, string[]>(), taken = new Map<string, number>()
101  for (const l of logLines) {
102    let o: any
103    try { o = JSON.parse(l) } catch { continue }
104    if (o?.event === 'submit' && o.estimate_key) submits.set(o.estimate_key, [...(submits.get(o.estimate_key) ?? []), o])
105    if (o?.event === 'download' && o.request_id && Array.isArray(o.files))
106      files.set(o.request_id, o.files.map((f: any) => typeof f === 'string' ? f
107        : (f && (f.result === 'saved' || f.result === 'exists') && typeof f.path === 'string') ? f.path : null)
108        .filter(Boolean))
109  }
110  return [...entries].sort((a, b) => a.created_at - b.created_at).map(e => {
111    const unsent = e.state === 'refunded' && e.reason === 'not_sent'
112    const n = taken.get(e.id) ?? 0
113    if (!unsent) taken.set(e.id, n + 1)
114    const s = unsent ? {} : submits.get(e.id)?.[n] ?? {}
115    const ep = String(s.endpoint ?? '')
116    return { time: e.created_at, model: ep ? modelOf(ep) : 'Higgsfield', kind: kindOf(ep),
117             cost: e.state === 'pending' ? e.reserved : (e.actual ?? 0), status: STATUS[e.state] ?? 'running',
118             files: (s.request_id && files.get(s.request_id)) || [] }
119  }).sort((a, b) => b.time - a.time)
120}
121
122/** H:MM local time from epoch seconds (no Intl: its presence in the mod runtime is unproven). */
123export function hhmm(t: number): string {
124  const d = new Date(t * 1000)
125  return `${d.getHours()}:${String(d.getMinutes()).padStart(2, '0')}`
126}
127
128/** Show the band when a chat opens or reopens: only if its spend record says this chat already used Higgsfield
129 *  (a picked cap, or money spent). An unreadable record, or a folder without Higgsfield, stays silent. */
130export function bandOnStart(status: any, err: string): boolean {
131  return !err && !!status && status.ok !== false && (!!status.session_cap_set || Number(status.session_spent) > 0)
132}
133
134export type Step = { kind: 'pass' } | { kind: 'set'; usd: number } | { kind: 'refuse' }
135
136/** What the mod does with a paid submit. It refuses only on the person's explicit Not now; a closed box, no
137 *  window, junk, or an unreadable ledger all pass to the guard, whose 24 h rule (and ledger checks) decide. */
138export function submitStep(status: any, err: string, answer: string | undefined): Step {
139  if (err || !status || status.session_cap_set) return { kind: 'pass' }
140  if (answer === 'Not now') return { kind: 'refuse' }
141  const usd = capFromAnswer(answer)
142  return usd === null ? { kind: 'pass' } : { kind: 'set', usd }
143}
144