From dba478aa2367481a1e3c87ca58545b244e66e061 Mon Sep 17 00:00:00 2001 From: Keysat Date: Sun, 10 May 2026 22:17:35 -0500 Subject: [PATCH] =?UTF-8?q?v1.1.0:3=20=E2=80=94=20AI=20upgrades:=20history?= =?UTF-8?q?=20context,=20test=20connection,=20cost=20estimator,=20streamin?= =?UTF-8?q?g=20preview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four incremental upgrades to the AI program generator. No schema change, no /data migration. 1. History as context (the killer feature) - lib/ai/historyContext.ts builds a 90-day per-exercise rollup: frequency, recent weights, estimated 1RM (Epley), avg RPE, days-since-last, plus a STAGNANT flag when the heaviest weight in the new half doesn't beat the old half. - Generate page surfaces an "Include my workout history as context" checkbox (default on at >=10 logged workouts). When checked, the ~1-3 KB summary is appended to the system prompt so the model can recommend things like "you've stalled bench at 245 — try paused reps." - We deliberately don't ship raw set logs (privacy + token cost). 2. Test connection - POST /api/ai/test sends a tiny "say hi in 3 words" prompt and reports latency + first sample, or the error inline. - "Test connection" button next to "Save AI config" in Settings -> AI integration. Verifies provider/model/key/baseUrl without going through full program generation. 3. Cost estimator - lib/ai/pricing.ts ships a price table for major models (Claude 3.5/3.7/4/4.5, GPT-4o/5/o1/o3/o4-mini, Gemini 1.5/2.0/2.5). Ollama always returns 0; openai-compatible returns null. - Generation history shows per-row cost + a 30-day rolling total at the top of the page. 4. Streaming preview render - lib/ai/lenientJson.ts: stack-aware partial-JSON parser that auto-closes open strings/brackets/braces in reverse-of-opening order, drops dangling key:value pairs and partial keywords. Returns a best-effort snapshot of the program-so-far on each chunk. - Generate UI now renders a live "Building program..." panel that updates as weeks/days/exercises arrive instead of just showing raw text and waiting for stream end. Tests: 26 new (ai-historyContext.test.ts, ai-lenientJson.test.ts, ai-pricing.test.ts). 161 total pass. --- proof-of-work/app/api/ai/generate/route.ts | 20 +- proof-of-work/app/api/ai/test/route.ts | 110 ++++++++ proof-of-work/app/main/ai/generate/page.tsx | 6 +- .../components/ai/GenerateClient.tsx | 96 ++++++- proof-of-work/components/ai/HistoryList.tsx | 51 +++- .../components/settings/AIIntegration.tsx | 97 ++++++- proof-of-work/lib/ai/historyContext.ts | 245 ++++++++++++++++++ proof-of-work/lib/ai/lenientJson.ts | 116 +++++++++ proof-of-work/lib/ai/pricing.ts | 96 +++++++ proof-of-work/tests/ai-historyContext.test.ts | 224 ++++++++++++++++ proof-of-work/tests/ai-lenientJson.test.ts | 88 +++++++ proof-of-work/tests/ai-pricing.test.ts | 116 +++++++++ start9/0.4/startos/versions/index.ts | 6 +- start9/0.4/startos/versions/v1.1.0.3.ts | 61 +++++ 14 files changed, 1306 insertions(+), 26 deletions(-) create mode 100644 proof-of-work/app/api/ai/test/route.ts create mode 100644 proof-of-work/lib/ai/historyContext.ts create mode 100644 proof-of-work/lib/ai/lenientJson.ts create mode 100644 proof-of-work/lib/ai/pricing.ts create mode 100644 proof-of-work/tests/ai-historyContext.test.ts create mode 100644 proof-of-work/tests/ai-lenientJson.test.ts create mode 100644 proof-of-work/tests/ai-pricing.test.ts create mode 100644 start9/0.4/startos/versions/v1.1.0.3.ts diff --git a/proof-of-work/app/api/ai/generate/route.ts b/proof-of-work/app/api/ai/generate/route.ts index 22ffcfc..a0c9e91 100644 --- a/proof-of-work/app/api/ai/generate/route.ts +++ b/proof-of-work/app/api/ai/generate/route.ts @@ -7,6 +7,10 @@ import { PROGRAM_OUTPUT_SHAPE, parseAIProgram, } from '@/lib/ai/programSchema'; +import { + buildHistorySummary, + formatHistoryContext, +} from '@/lib/ai/historyContext'; /** * POST /api/ai/generate @@ -33,6 +37,13 @@ import { const bodySchema = z.object({ templateId: z.string().optional().nullable(), userInput: z.string().min(1), + /** + * When true, build + append a compact summary of the user's + * recent (90-day) workout history to the system prompt. Lets the + * model design around stagnations, current strength levels, and + * actual training frequency. + */ + includeHistory: z.boolean().optional().default(false), }); export const dynamic = 'force-dynamic'; @@ -135,6 +146,13 @@ export async function POST(request: NextRequest) { })), ); + // If requested, build the workout-history summary block. + let historyBlock = ''; + if (parsed.data.includeHistory) { + const summary = await buildHistorySummary(prisma, user.id); + historyBlock = formatHistoryContext(summary); + } + // Stitch the final system + user prompts. const baseSystem = template?.systemPrompt ?? DEFAULT_SYSTEM_PROMPT; const systemPrompt = `${baseSystem} @@ -143,7 +161,7 @@ OUTPUT SHAPE — emit ONLY a JSON object matching this shape (no commentary, no ${PROGRAM_OUTPUT_SHAPE} LIBRARY — pick exerciseId values from this list when possible. If you need an exercise the user doesn't have, set exerciseId to null and put the proposed name in exerciseName; the user will resolve it during preview. -${libraryJson}`; +${libraryJson}${historyBlock}`; const userPromptBody = template?.userPromptTemplate.replace(/{{userInput}}/g, parsed.data.userInput) ?? diff --git a/proof-of-work/app/api/ai/test/route.ts b/proof-of-work/app/api/ai/test/route.ts new file mode 100644 index 0000000..4904927 --- /dev/null +++ b/proof-of-work/app/api/ai/test/route.ts @@ -0,0 +1,110 @@ +import { NextResponse } from 'next/server'; +import { getCurrentUser } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; +import { getProvider } from '@/lib/ai/providers'; + +/** + * POST /api/ai/test + * + * Sends a tiny "say hi in 3 words" prompt to the user's currently + * configured AI provider and reports success/failure inline. Lets + * the operator validate provider/model/key/baseUrl without going + * through a full program generation. + * + * Returns: + * { ok: true, sample: "Hello there friend", tokensIn?, tokensOut?, ms } + * { ok: false, error: "..." } + * + * Times out after 30s — long enough for cold Ollama starts, short + * enough that a hung connection doesn't hang the UI. + */ + +const TEST_TIMEOUT_MS = 30_000; + +export async function POST() { + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ ok: false, error: 'Unauthorized' }, { status: 401 }); + } + + const prefs = await prisma.userPreferences.findUnique({ + where: { userId: user.id }, + select: { aiProvider: true, aiModel: true, aiBaseUrl: true, aiApiKey: true }, + }); + if (!prefs?.aiProvider || !prefs?.aiModel) { + return NextResponse.json( + { + ok: false, + error: 'Pick a provider + model in Settings → AI integration first.', + }, + { status: 400 }, + ); + } + const provider = getProvider(prefs.aiProvider); + if (!provider) { + return NextResponse.json( + { ok: false, error: `Unknown provider: ${prefs.aiProvider}` }, + { status: 400 }, + ); + } + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), TEST_TIMEOUT_MS); + const t0 = Date.now(); + + let sample = ''; + let tokensIn: number | undefined; + let tokensOut: number | undefined; + let providerError: string | null = null; + + try { + for await (const chunk of provider.generate({ + apiKey: prefs.aiApiKey, + baseUrl: prefs.aiBaseUrl, + model: prefs.aiModel, + systemPrompt: + 'You are a connectivity test. Reply with exactly three words: "Hello there friend." Nothing else.', + userPrompt: 'Say hi.', + signal: controller.signal, + })) { + if (chunk.type === 'text') sample += chunk.delta; + else if (chunk.type === 'usage') { + tokensIn = chunk.tokensIn; + tokensOut = chunk.tokensOut; + } else if (chunk.type === 'error') { + providerError = chunk.message; + } + } + } catch (e) { + providerError = + controller.signal.aborted + ? `Timed out after ${Math.round(TEST_TIMEOUT_MS / 1000)}s` + : (e as Error).message; + } finally { + clearTimeout(timer); + } + + const ms = Date.now() - t0; + + if (providerError) { + return NextResponse.json({ ok: false, error: providerError, ms }, { status: 200 }); + } + if (!sample.trim()) { + return NextResponse.json( + { + ok: false, + error: + 'Got an empty response. The model returned successfully but with no text — check the model name and try again.', + ms, + }, + { status: 200 }, + ); + } + return NextResponse.json({ + ok: true, + sample: sample.trim().slice(0, 200), + tokensIn, + tokensOut, + ms, + }); +} diff --git a/proof-of-work/app/main/ai/generate/page.tsx b/proof-of-work/app/main/ai/generate/page.tsx index da6f733..216221a 100644 --- a/proof-of-work/app/main/ai/generate/page.tsx +++ b/proof-of-work/app/main/ai/generate/page.tsx @@ -11,7 +11,7 @@ export default async function GeneratePage() { const user = await getCurrentUser(); if (!user) redirect('/auth/login'); - const [templates, exercises, prefs] = await Promise.all([ + const [templates, exercises, prefs, workoutCount] = await Promise.all([ prisma.aIPromptTemplate.findMany({ where: { OR: [{ userId: null }, { userId: user.id }] }, orderBy: [{ isBuiltIn: 'desc' }, { name: 'asc' }], @@ -31,6 +31,9 @@ export default async function GeneratePage() { where: { userId: user.id }, select: { aiProvider: true, aiModel: true }, }), + prisma.workout.count({ + where: { userId: user.id, deletedAt: null }, + }), ]); const aiConfigured = !!prefs?.aiProvider && !!prefs?.aiModel; @@ -74,6 +77,7 @@ export default async function GeneratePage() { exercises={exercises} providerLabel={prefs!.aiProvider!} modelLabel={prefs!.aiModel!} + workoutCount={workoutCount} /> )} diff --git a/proof-of-work/components/ai/GenerateClient.tsx b/proof-of-work/components/ai/GenerateClient.tsx index 772fecf..e1ff026 100644 --- a/proof-of-work/components/ai/GenerateClient.tsx +++ b/proof-of-work/components/ai/GenerateClient.tsx @@ -3,6 +3,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { useRouter } from 'next/navigation'; import { Loader2, Sparkles, Square } from 'lucide-react'; +import { lenientJsonParse } from '@/lib/ai/lenientJson'; const DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; @@ -52,7 +53,7 @@ interface AIProgram { type Phase = | { kind: 'idle' } - | { kind: 'streaming'; raw: string } + | { kind: 'streaming'; raw: string; partial: Partial | null } | { kind: 'parsed'; raw: string; program: AIProgram } | { kind: 'failed'; raw: string; message: string }; @@ -61,15 +62,18 @@ export default function GenerateClient({ exercises, providerLabel, modelLabel, + workoutCount, }: { templates: Template[]; exercises: LibraryExercise[]; providerLabel: string; modelLabel: string; + workoutCount: number; }) { const router = useRouter(); const [templateId, setTemplateId] = useState(templates[0]?.id ?? ''); const [userInput, setUserInput] = useState(''); + const [includeHistory, setIncludeHistory] = useState(workoutCount >= 10); const [generationId, setGenerationId] = useState(null); const [phase, setPhase] = useState({ kind: 'idle' }); const [tokens, setTokens] = useState<{ in?: number; out?: number }>({}); @@ -82,7 +86,7 @@ export default function GenerateClient({ const handleGenerate = async () => { if (!userInput.trim()) return; - setPhase({ kind: 'streaming', raw: '' }); + setPhase({ kind: 'streaming', raw: '', partial: null }); setGenerationId(null); setTokens({}); @@ -95,6 +99,7 @@ export default function GenerateClient({ body: JSON.stringify({ templateId: templateId || null, userInput, + includeHistory, }), signal: abortRef.current.signal, }); @@ -146,7 +151,8 @@ export default function GenerateClient({ setGenerationId(parsed.id); } else if (evtName === 'text') { raw += parsed.delta; - setPhase({ kind: 'streaming', raw }); + const partial = lenientJsonParse(raw) as Partial | null; + setPhase({ kind: 'streaming', raw, partial }); } else if (evtName === 'usage') { setTokens({ in: parsed.tokensIn, out: parsed.tokensOut }); } else if (evtName === 'complete') { @@ -256,6 +262,25 @@ export default function GenerateClient({ /> + +
{phase.kind === 'streaming' ? (
{r.templateName && (

@@ -111,6 +157,7 @@ export default function HistoryList({ ))} + ); } diff --git a/proof-of-work/components/settings/AIIntegration.tsx b/proof-of-work/components/settings/AIIntegration.tsx index bdc3b4c..bd04b26 100644 --- a/proof-of-work/components/settings/AIIntegration.tsx +++ b/proof-of-work/components/settings/AIIntegration.tsx @@ -29,6 +29,18 @@ export default function AIIntegration() { const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [success, setSuccess] = useState(false); + const [testing, setTesting] = useState(false); + const [testResult, setTestResult] = useState< + | null + | { + ok: true; + sample: string; + tokensIn?: number; + tokensOut?: number; + ms: number; + } + | { ok: false; error: string; ms?: number } + >(null); useEffect(() => { fetch('/api/ai/config') @@ -44,6 +56,20 @@ export default function AIIntegration() { const meta = PROVIDERS.find((p) => p.id === provider); + const handleTest = async () => { + setTesting(true); + setTestResult(null); + try { + const res = await fetch('/api/ai/test', { method: 'POST' }); + const body = await res.json(); + setTestResult(body); + } catch (e) { + setTestResult({ ok: false, error: (e as Error).message }); + } finally { + setTesting(false); + } + }; + const handleSave = async () => { setSaving(true); setError(null); @@ -186,21 +212,64 @@ export default function AIIntegration() { )} - + {provider && cfg?.aiProvider === provider && cfg?.aiModel && ( + )} - + + + {testResult && ( +

+ {testResult.ok ? ( + <> + ✓ Connected in {testResult.ms}ms + {testResult.tokensIn != null && + ` · ${testResult.tokensIn} in / ${testResult.tokensOut ?? '?'} out tokens`} +
+ Sample reply: {testResult.sample} +
+ + ) : ( + <>✗ {testResult.error} + )} +
+ )} ); diff --git a/proof-of-work/lib/ai/historyContext.ts b/proof-of-work/lib/ai/historyContext.ts new file mode 100644 index 0000000..9eee299 --- /dev/null +++ b/proof-of-work/lib/ai/historyContext.ts @@ -0,0 +1,245 @@ +import type { PrismaClient } from '@prisma/client'; + +/** + * Build a compact workout-history summary the AI can use as + * context for personalized program generation. + * + * We DELIBERATELY don't ship raw set logs — that would be tens of + * KB per request and burn tokens. Instead we compute per-exercise + * aggregates over a recent window (default 90 days): + * + * - totalSets in window + * - distinct workouts + * - daysSinceLast + * - lastWeight, lastReps (from the most-recent set) + * - bestWeight (heaviest set in window) + * - estimated 1RM (Epley formula on the heaviest weighted set) + * - rpe trend (avg RPE over recent sets, if logged) + * - stagnation flag (heaviest weight unchanged for 4+ weeks AND + * ≥3 sessions of that exercise in those 4+ weeks) + * + * Plus a top-level summary: total workouts, frequency, primary + * exercise types touched. + * + * The output is JSON-stringifiable, ~5-15 KB for a typical user. + */ + +export interface HistoryExerciseSummary { + name: string; + type: string; + totalSets: number; + distinctWorkouts: number; + daysSinceLast: number; + lastWeight: number | null; + lastReps: number | null; + bestWeight: number | null; + estimated1RM: number | null; + avgRpe: number | null; + stagnant: boolean; +} + +export interface HistorySummary { + windowDays: number; + totalWorkouts: number; + workoutsPerWeek: number; + primaryTypes: string[]; // exercise types by descending volume + exercises: HistoryExerciseSummary[]; +} + +/** Epley estimated 1RM: weight * (1 + reps / 30) */ +function epley1RM(weight: number, reps: number): number { + return Math.round(weight * (1 + reps / 30)); +} + +export async function buildHistorySummary( + prisma: PrismaClient, + userId: string, + windowDays = 90, +): Promise { + const cutoff = new Date(Date.now() - windowDays * 86_400_000); + + // Pull every set log in the window with its exercise + workout + // date. One query, one result-set walk. + const sets = await prisma.setLog.findMany({ + where: { + workout: { + userId, + deletedAt: null, + date: { gte: cutoff }, + }, + }, + select: { + reps: true, + weight: true, + rpe: true, + exerciseId: true, + workoutId: true, + workout: { select: { date: true } }, + exercise: { select: { name: true, type: true } }, + }, + orderBy: { workout: { date: 'desc' } }, + }); + + if (sets.length === 0) { + return { + windowDays, + totalWorkouts: 0, + workoutsPerWeek: 0, + primaryTypes: [], + exercises: [], + }; + } + + const workoutIds = new Set(sets.map((s) => s.workoutId)); + const totalWorkouts = workoutIds.size; + const weeks = windowDays / 7; + const workoutsPerWeek = Math.round((totalWorkouts / weeks) * 10) / 10; + + // Group by exercise + const byExercise = new Map< + string, + { + name: string; + type: string; + sets: typeof sets; + } + >(); + for (const s of sets) { + if (!byExercise.has(s.exerciseId)) { + byExercise.set(s.exerciseId, { + name: s.exercise.name, + type: s.exercise.type, + sets: [], + }); + } + byExercise.get(s.exerciseId)!.sets.push(s); + } + + // Per-exercise summaries + const now = Date.now(); + const exercises: HistoryExerciseSummary[] = []; + for (const [, group] of byExercise) { + const groupSets = group.sets; + const distinctWorkouts = new Set(groupSets.map((s) => s.workoutId)).size; + const mostRecent = groupSets[0]; // already date-desc + const daysSinceLast = Math.floor( + (now - mostRecent.workout.date.getTime()) / 86_400_000, + ); + + const weightedSets = groupSets.filter( + (s): s is typeof s & { weight: number; reps: number } => + typeof s.weight === 'number' && typeof s.reps === 'number', + ); + const bestWeightSet = weightedSets.reduce< + | { weight: number; reps: number } + | null + >((best, s) => { + if (!best || s.weight > best.weight) return s; + return best; + }, null); + const bestWeight = bestWeightSet?.weight ?? null; + const estimated1RM = + bestWeightSet != null ? epley1RM(bestWeightSet.weight, bestWeightSet.reps) : null; + + const rpeSets = groupSets.filter( + (s): s is typeof s & { rpe: number } => typeof s.rpe === 'number', + ); + const avgRpe = + rpeSets.length > 0 + ? Math.round( + (rpeSets.reduce((sum, s) => sum + s.rpe, 0) / rpeSets.length) * 10, + ) / 10 + : null; + + // Stagnation: best weight in oldest half == best weight in newest half + // AND ≥3 distinct sessions in the window. + let stagnant = false; + if (distinctWorkouts >= 3 && bestWeight != null && weightedSets.length >= 4) { + const sortedByDate = [...weightedSets].sort( + (a, b) => a.workout.date.getTime() - b.workout.date.getTime(), + ); + const mid = Math.floor(sortedByDate.length / 2); + const oldHalfBest = Math.max(...sortedByDate.slice(0, mid).map((s) => s.weight)); + const newHalfBest = Math.max(...sortedByDate.slice(mid).map((s) => s.weight)); + // No improvement in the new half compared to the old half + if (newHalfBest <= oldHalfBest) stagnant = true; + } + + exercises.push({ + name: group.name, + type: group.type, + totalSets: groupSets.length, + distinctWorkouts, + daysSinceLast, + lastWeight: mostRecent.weight ?? null, + lastReps: mostRecent.reps ?? null, + bestWeight, + estimated1RM, + avgRpe, + stagnant, + }); + } + + // Sort exercises by total volume (sets) descending so the most + // important context is first if the model truncates. + exercises.sort((a, b) => b.totalSets - a.totalSets); + + // Primary types by aggregate sets + const typeVolume = new Map(); + for (const ex of exercises) { + typeVolume.set(ex.type, (typeVolume.get(ex.type) ?? 0) + ex.totalSets); + } + const primaryTypes = Array.from(typeVolume.entries()) + .sort((a, b) => b[1] - a[1]) + .map(([t]) => t); + + return { + windowDays, + totalWorkouts, + workoutsPerWeek, + primaryTypes, + exercises, + }; +} + +/** + * Format a HistorySummary as a compact string the LLM can actually + * use. Aims for <2KB of text even for heavy users. + */ +export function formatHistoryContext(summary: HistorySummary): string { + if (summary.totalWorkouts === 0) { + return `\nUSER HISTORY: no workouts logged in the last ${summary.windowDays} days.`; + } + const lines: string[] = []; + lines.push( + `\nUSER HISTORY (last ${summary.windowDays} days):`, + ` ${summary.totalWorkouts} workouts (~${summary.workoutsPerWeek}/week)`, + ` Primary work: ${summary.primaryTypes.slice(0, 4).join(', ')}`, + '', + ` Per-exercise activity (descending by volume; weights in user's logged unit):`, + ); + // Cap at top 30 exercises + const top = summary.exercises.slice(0, 30); + for (const ex of top) { + const bits: string[] = [ + `${ex.totalSets}s/${ex.distinctWorkouts}w`, + `${ex.daysSinceLast}d ago`, + ]; + if (ex.bestWeight != null && ex.lastReps != null) + bits.push(`best ${ex.bestWeight}×${ex.lastReps}`); + if (ex.estimated1RM != null) bits.push(`~${ex.estimated1RM} 1RM`); + if (ex.avgRpe != null) bits.push(`avg RPE ${ex.avgRpe}`); + if (ex.stagnant) bits.push('STAGNANT'); + lines.push(` - ${ex.name} (${ex.type}): ${bits.join(' · ')}`); + } + if (summary.exercises.length > top.length) { + lines.push( + ` ...and ${summary.exercises.length - top.length} more exercises with lower volume`, + ); + } + lines.push( + '', + ` When designing the program, weight recent activity heavily. Address STAGNANT exercises if relevant. Don't propose deload-week-heavy work for someone training infrequently, and don't propose 6-day splits for someone averaging <3 sessions/week.`, + ); + return lines.join('\n'); +} diff --git a/proof-of-work/lib/ai/lenientJson.ts b/proof-of-work/lib/ai/lenientJson.ts new file mode 100644 index 0000000..75475d0 --- /dev/null +++ b/proof-of-work/lib/ai/lenientJson.ts @@ -0,0 +1,116 @@ +/** + * Lenient JSON parser for incremental rendering of in-flight LLM + * output. + * + * The model emits JSON one token at a time. Strict JSON.parse fails + * until the very last `}` arrives. lenientJsonParse instead: + * + * 1. Locates the first `{` (after stripping ```json fences). + * 2. Walks the buffer tracking quote state + an open-bracket + * stack so we know what to close in what order. + * 3. Closes any open string with `"`. + * 4. Trims a partial trailing keyword (true/false/null prefix), + * trailing comma, and dangling key:value pair where value is + * missing. + * 5. Closes open structures in reverse-of-opening order (so + * `[{` closes as `}]`, not `]}`). + * 6. JSON.parse the result; return null if it still fails. + * + * The returned object is a best-effort snapshot of the program so + * far. The Generate UI uses it to render a live preview as the + * model writes; once the stream ends, the FULL response is parsed + * with the strict parser via parseAIProgram for the final render. + * + * This is intentionally simple — partial numbers (e.g. `-2.`) and + * partial escape sequences just return null until the next chunk + * makes them well-formed. + */ +export function lenientJsonParse(raw: string): unknown | null { + if (!raw) return null; + + // Strip ```json fences (or plain ``` fences). Tolerates an + // unclosed trailing fence (still streaming). + let s = raw; + const fenced = s.match(/```(?:json)?\s*([\s\S]*?)(?:\s*```|$)/); + if (fenced) s = fenced[1]; + + // Locate first `{`. + const startIdx = s.indexOf('{'); + if (startIdx < 0) return null; + s = s.slice(startIdx); + + // Quick path: maybe it's already valid (rare during streaming, + // common after the stream completes). + try { + return JSON.parse(s); + } catch { + // fall through + } + + // Walk the buffer tracking the open-bracket stack. We don't try + // to recover from mismatched closers (would be model malformity); + // we just don't pop more than we have. + const stack: Array<'{' | '['> = []; + let inStr = false; + let escape = false; + for (let i = 0; i < s.length; i++) { + const c = s[i]; + if (escape) { + escape = false; + continue; + } + if (c === '\\') { + escape = true; + continue; + } + if (c === '"') { + inStr = !inStr; + continue; + } + if (inStr) continue; + if (c === '{') stack.push('{'); + else if (c === '}') { + if (stack[stack.length - 1] === '{') stack.pop(); + } else if (c === '[') stack.push('['); + else if (c === ']') { + if (stack[stack.length - 1] === '[') stack.pop(); + } + } + + let candidate = s; + + // Close any open string at the tail. + if (inStr) candidate += '"'; + + // Trim trailing whitespace. + candidate = candidate.replace(/\s+$/, ''); + + // Drop a partial trailing keyword (`true`/`false`/`null` prefix) + // sitting after a `:`, `,`, or `[`. + candidate = candidate.replace( + /([:,[])\s*(?:t|tr|tru|f|fa|fal|fals|n|nu|nul)$/, + '$1', + ); + + // Drop a trailing comma (no value follows yet). + candidate = candidate.replace(/,\s*$/, ''); + + // Drop a dangling key + colon (value not started yet). + candidate = candidate.replace(/"[^"\\]*(?:\\.[^"\\]*)*"\s*:\s*$/, ''); + + // Drop another trailing comma that may now be exposed. + candidate = candidate.replace(/,\s*$/, ''); + + // Close stack in reverse-of-opening order. `[{` becomes `}]` not + // `]}` — that's the bug a depth-counter approach would have. + while (stack.length > 0) { + const top = stack.pop()!; + candidate += top === '{' ? '}' : ']'; + } + + try { + return JSON.parse(candidate); + } catch { + return null; + } +} diff --git a/proof-of-work/lib/ai/pricing.ts b/proof-of-work/lib/ai/pricing.ts new file mode 100644 index 0000000..3f6bd25 --- /dev/null +++ b/proof-of-work/lib/ai/pricing.ts @@ -0,0 +1,96 @@ +/** + * Per-model pricing in USD per million tokens. Used to estimate the + * cost of an AIGeneration row from its tokensIn/tokensOut. + * + * Prices change. This table is a best-effort starting point for + * common models as of mid-2026; users on other models will see + * `null` cost (we still surface token counts). Updating: edit this + * file and ship — no schema change needed. + * + * Matching strategy: case-insensitive prefix lookup against the + * user's configured model string. Model names like + * "claude-sonnet-4-5-20251022" match the "claude-sonnet-4-5" prefix. + * + * Keys are organized by provider for readability but the lookup is + * provider-agnostic — the model string is the key. + */ + +interface PriceEntry { + inputPerM: number; // USD per 1M input tokens + outputPerM: number; // USD per 1M output tokens +} + +const PRICES: Record = { + // Anthropic Claude (Messages API) + 'claude-opus-4': { inputPerM: 15, outputPerM: 75 }, + 'claude-opus-4-5': { inputPerM: 15, outputPerM: 75 }, + 'claude-sonnet-4': { inputPerM: 3, outputPerM: 15 }, + 'claude-sonnet-4-5': { inputPerM: 3, outputPerM: 15 }, + 'claude-haiku-4': { inputPerM: 0.8, outputPerM: 4 }, + 'claude-haiku-4-5': { inputPerM: 0.8, outputPerM: 4 }, + 'claude-3-7-sonnet': { inputPerM: 3, outputPerM: 15 }, + 'claude-3-5-sonnet': { inputPerM: 3, outputPerM: 15 }, + 'claude-3-5-haiku': { inputPerM: 0.8, outputPerM: 4 }, + + // OpenAI + 'gpt-5': { inputPerM: 1.25, outputPerM: 10 }, + 'gpt-5-mini': { inputPerM: 0.25, outputPerM: 2 }, + 'gpt-5-nano': { inputPerM: 0.05, outputPerM: 0.4 }, + 'gpt-4o': { inputPerM: 2.5, outputPerM: 10 }, + 'gpt-4o-mini': { inputPerM: 0.15, outputPerM: 0.6 }, + 'o1': { inputPerM: 15, outputPerM: 60 }, + 'o3': { inputPerM: 2, outputPerM: 8 }, + 'o3-mini': { inputPerM: 1.1, outputPerM: 4.4 }, + 'o4-mini': { inputPerM: 1.1, outputPerM: 4.4 }, + + // Google Gemini + 'gemini-2.5-pro': { inputPerM: 1.25, outputPerM: 10 }, + 'gemini-2.5-flash': { inputPerM: 0.3, outputPerM: 2.5 }, + 'gemini-2.0-flash': { inputPerM: 0.1, outputPerM: 0.4 }, + 'gemini-2.0-pro': { inputPerM: 1.25, outputPerM: 5 }, + 'gemini-1.5-pro': { inputPerM: 1.25, outputPerM: 5 }, + 'gemini-1.5-flash': { inputPerM: 0.075, outputPerM: 0.3 }, +}; + +/** Find the price entry whose key is a (case-insensitive) prefix of the model string. */ +export function findPrice(model: string): PriceEntry | null { + const m = model.toLowerCase(); + // Longest-prefix-first so e.g. "claude-sonnet-4-5" beats "claude-sonnet-4". + const sortedKeys = Object.keys(PRICES).sort((a, b) => b.length - a.length); + for (const key of sortedKeys) { + if (m.startsWith(key.toLowerCase())) return PRICES[key]; + } + return null; +} + +/** + * Estimate the USD cost of a generation. Returns null if the model + * isn't in the price table or if either token count is missing. + * Ollama and openai-compatible custom gateways always return null + * (they're either free or self-priced). + */ +export function estimateCost(opts: { + provider: string; + model: string; + tokensIn: number | null; + tokensOut: number | null; +}): number | null { + if (opts.provider === 'ollama') return 0; // self-hosted, no per-token cost + if (opts.provider === 'openai-compatible') return null; // we don't know the gateway's pricing + if (opts.tokensIn == null || opts.tokensOut == null) return null; + const price = findPrice(opts.model); + if (!price) return null; + return ( + (opts.tokensIn / 1_000_000) * price.inputPerM + + (opts.tokensOut / 1_000_000) * price.outputPerM + ); +} + +/** Format USD to a string suitable for a UI label. Below $0.01 -> "<$0.01". */ +export function formatCost(usd: number | null): string { + if (usd == null) return '—'; + if (usd === 0) return 'free'; + if (usd < 0.01) return '<$0.01'; + if (usd < 1) return `$${usd.toFixed(3)}`; + return `$${usd.toFixed(2)}`; +} diff --git a/proof-of-work/tests/ai-historyContext.test.ts b/proof-of-work/tests/ai-historyContext.test.ts new file mode 100644 index 0000000..a4d3c11 --- /dev/null +++ b/proof-of-work/tests/ai-historyContext.test.ts @@ -0,0 +1,224 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { prisma } from '@/lib/prisma'; +import { + buildHistorySummary, + formatHistoryContext, +} from '@/lib/ai/historyContext'; + +beforeEach(async () => { + await prisma.session.deleteMany(); + await prisma.setLog.deleteMany(); + await prisma.workout.deleteMany(); + await prisma.programExercise.deleteMany(); + await prisma.programDay.deleteMany(); + await prisma.programWeek.deleteMany(); + await prisma.program.deleteMany(); + await prisma.exercise.deleteMany(); + await prisma.aIGeneration.deleteMany(); + await prisma.aIPromptTemplate.deleteMany(); + await prisma.user.deleteMany(); + await prisma.instanceSettings.deleteMany(); +}); + +async function setup() { + const user = await prisma.user.create({ + data: { email: 'a@x', passwordHash: 'fake' }, + }); + const bench = await prisma.exercise.create({ + data: { + userId: user.id, + name: 'Bench Press', + type: 'barbell', + muscleGroups: '[]', + }, + }); + const squat = await prisma.exercise.create({ + data: { + userId: user.id, + name: 'Squat', + type: 'barbell', + muscleGroups: '[]', + }, + }); + return { user, bench, squat }; +} + +async function logWorkout( + userId: string, + daysAgo: number, + sets: Array<{ exerciseId: string; reps: number; weight: number; rpe?: number }>, +) { + const date = new Date(Date.now() - daysAgo * 86_400_000); + return prisma.workout.create({ + data: { + userId, + date, + setLogs: { + create: sets.map((s, i) => ({ + exerciseId: s.exerciseId, + setNumber: i + 1, + reps: s.reps, + weight: s.weight, + rpe: s.rpe, + })), + }, + }, + }); +} + +describe('buildHistorySummary', () => { + it('returns empty summary for a user with no workouts', async () => { + const user = await prisma.user.create({ + data: { email: 'a@x', passwordHash: 'fake' }, + }); + const s = await buildHistorySummary(prisma, user.id); + expect(s.totalWorkouts).toBe(0); + expect(s.exercises).toEqual([]); + }); + + it('summarizes a single user\'s recent activity', async () => { + const { user, bench, squat } = await setup(); + // 3 bench sessions, 2 squat sessions in last 30 days + await logWorkout(user.id, 1, [ + { exerciseId: bench.id, reps: 5, weight: 225 }, + { exerciseId: bench.id, reps: 5, weight: 225 }, + ]); + await logWorkout(user.id, 4, [ + { exerciseId: bench.id, reps: 5, weight: 235 }, + { exerciseId: bench.id, reps: 5, weight: 235 }, + ]); + await logWorkout(user.id, 7, [ + { exerciseId: bench.id, reps: 5, weight: 215 }, + { exerciseId: squat.id, reps: 5, weight: 315 }, + ]); + await logWorkout(user.id, 14, [ + { exerciseId: squat.id, reps: 5, weight: 305 }, + ]); + + const s = await buildHistorySummary(prisma, user.id); + expect(s.totalWorkouts).toBe(4); + expect(s.workoutsPerWeek).toBeGreaterThan(0); + expect(s.exercises).toHaveLength(2); + + const benchSummary = s.exercises.find((e) => e.name === 'Bench Press'); + expect(benchSummary).toBeTruthy(); + expect(benchSummary!.totalSets).toBe(5); + expect(benchSummary!.distinctWorkouts).toBe(3); + expect(benchSummary!.bestWeight).toBe(235); + expect(benchSummary!.daysSinceLast).toBeLessThanOrEqual(2); // logged 1 day ago + + // Epley(235, 5) = 235 * (1 + 5/30) = 274.17 → 274 + expect(benchSummary!.estimated1RM).toBe(274); + }); + + it('flags stagnation on a stuck exercise', async () => { + const { user, bench } = await setup(); + // 6 sessions all at the same weight + for (let d = 0; d < 6; d++) { + await logWorkout(user.id, d * 5, [ + { exerciseId: bench.id, reps: 5, weight: 225 }, + { exerciseId: bench.id, reps: 5, weight: 225 }, + ]); + } + const s = await buildHistorySummary(prisma, user.id); + const bs = s.exercises.find((e) => e.name === 'Bench Press'); + expect(bs?.stagnant).toBe(true); + }); + + it('does NOT flag stagnation on a progressing exercise', async () => { + const { user, bench } = await setup(); + // 6 sessions with progressive weight + for (let d = 0; d < 6; d++) { + await logWorkout(user.id, (5 - d) * 7, [ + { exerciseId: bench.id, reps: 5, weight: 200 + d * 10 }, + ]); + } + const s = await buildHistorySummary(prisma, user.id); + const bs = s.exercises.find((e) => e.name === 'Bench Press'); + expect(bs?.stagnant).toBe(false); + }); + + it('excludes workouts outside the window', async () => { + const { user, bench } = await setup(); + await logWorkout(user.id, 5, [{ exerciseId: bench.id, reps: 5, weight: 225 }]); + await logWorkout(user.id, 200, [{ exerciseId: bench.id, reps: 5, weight: 200 }]); + const s = await buildHistorySummary(prisma, user.id, 90); + expect(s.totalWorkouts).toBe(1); + expect(s.exercises[0].totalSets).toBe(1); + }); + + it('excludes soft-deleted workouts', async () => { + const { user, bench } = await setup(); + const w = await logWorkout(user.id, 3, [ + { exerciseId: bench.id, reps: 5, weight: 225 }, + ]); + await prisma.workout.update({ + where: { id: w.id }, + data: { deletedAt: new Date() }, + }); + const s = await buildHistorySummary(prisma, user.id); + expect(s.totalWorkouts).toBe(0); + }); + + it('isolates per-user data (does not bleed across users)', async () => { + const { user, bench } = await setup(); + const otherUser = await prisma.user.create({ + data: { email: 'b@x', passwordHash: 'fake' }, + }); + const otherBench = await prisma.exercise.create({ + data: { + userId: otherUser.id, + name: 'Bench Press', + type: 'barbell', + muscleGroups: '[]', + }, + }); + await logWorkout(user.id, 1, [{ exerciseId: bench.id, reps: 5, weight: 225 }]); + await logWorkout(otherUser.id, 1, [ + { exerciseId: otherBench.id, reps: 100, weight: 999 }, + ]); + const s = await buildHistorySummary(prisma, user.id); + expect(s.totalWorkouts).toBe(1); + expect(s.exercises[0].bestWeight).toBe(225); // not 999 + }); +}); + +describe('formatHistoryContext', () => { + it('emits a friendly message on empty history', () => { + const out = formatHistoryContext({ + windowDays: 90, + totalWorkouts: 0, + workoutsPerWeek: 0, + primaryTypes: [], + exercises: [], + }); + expect(out).toMatch(/no workouts/); + }); + + it('formats a populated summary into a compact block', () => { + const out = formatHistoryContext({ + windowDays: 90, + totalWorkouts: 30, + workoutsPerWeek: 3.3, + primaryTypes: ['barbell', 'dumbbell', 'cable'], + exercises: [ + { + name: 'Bench Press', + type: 'barbell', + totalSets: 36, + distinctWorkouts: 12, + daysSinceLast: 2, + lastWeight: 235, + lastReps: 5, + bestWeight: 245, + estimated1RM: 286, + avgRpe: 8.5, + stagnant: false, + }, + ], + }); + expect(out).toMatch(/30 workouts/); + expect(out).toMatch(/Bench Press/); + expect(out).toMatch(/STAGNANT|RPE/); + }); +}); diff --git a/proof-of-work/tests/ai-lenientJson.test.ts b/proof-of-work/tests/ai-lenientJson.test.ts new file mode 100644 index 0000000..d6380b6 --- /dev/null +++ b/proof-of-work/tests/ai-lenientJson.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect } from 'vitest'; +import { lenientJsonParse } from '@/lib/ai/lenientJson'; + +describe('lenientJsonParse', () => { + it('returns null on empty input', () => { + expect(lenientJsonParse('')).toBeNull(); + }); + + it('returns null when no { is present', () => { + expect(lenientJsonParse('hello world')).toBeNull(); + }); + + it('returns the object when input is already valid', () => { + expect(lenientJsonParse('{"a":1,"b":[2,3]}')).toEqual({ a: 1, b: [2, 3] }); + }); + + it('strips ```json fences', () => { + expect(lenientJsonParse('```json\n{"a":1}\n```')).toEqual({ a: 1 }); + }); + + it('handles fences not yet closed (still streaming)', () => { + expect(lenientJsonParse('```json\n{"a":1, "b":2')).toEqual({ a: 1, b: 2 }); + }); + + it('finds the first { after preamble', () => { + expect(lenientJsonParse('Here you go:\n{"name":"x"}')).toEqual({ + name: 'x', + }); + }); + + it('auto-closes a partial object missing its closing }', () => { + const got = lenientJsonParse('{"name":"X","durationWeeks":4'); + expect(got).toEqual({ name: 'X', durationWeeks: 4 }); + }); + + it('auto-closes a partial array missing its closing ]', () => { + const got = lenientJsonParse('{"weeks":[1,2,3'); + expect(got).toEqual({ weeks: [1, 2, 3] }); + }); + + it('drops a dangling property key with no value yet', () => { + const got = lenientJsonParse('{"name":"X","notes":'); + expect(got).toEqual({ name: 'X' }); + }); + + it('drops a trailing comma after a complete value', () => { + const got = lenientJsonParse('{"a":1,"b":2,'); + expect(got).toEqual({ a: 1, b: 2 }); + }); + + it('handles a partial nested structure typical of AI program output', () => { + const partial = `{ + "name": "Test", + "type": "hypertrophy", + "durationWeeks": 4, + "weeks": [ + { + "weekNumber": 1, + "days": [ + { + "dayOfWeek": 1, + "name": "Push", + "exercises": [ + {"exerciseId": "abc", "exerciseName": "Bench", "order": 0, "sets": 4 + `; + const got = lenientJsonParse(partial) as Record; + expect(got).toBeTruthy(); + expect(got.name).toBe('Test'); + expect(Array.isArray(got.weeks)).toBe(true); + expect(got.weeks[0].weekNumber).toBe(1); + // The dangling exercise object may or may not be present + // depending on truncation; what matters is the parser didn't + // throw. + }); + + it('handles an open string at the end', () => { + const got = lenientJsonParse('{"description":"A long descrip'); + expect(got).toBeTruthy(); + expect((got as Record).description).toMatch( + /^A long descrip/, + ); + }); + + it('returns null for unrecoverable garbage', () => { + // Mismatched closing brace before any opening is unrecoverable + expect(lenientJsonParse('}}}')).toBeNull(); + }); +}); diff --git a/proof-of-work/tests/ai-pricing.test.ts b/proof-of-work/tests/ai-pricing.test.ts new file mode 100644 index 0000000..4ef37be --- /dev/null +++ b/proof-of-work/tests/ai-pricing.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect } from 'vitest'; +import { findPrice, estimateCost, formatCost } from '@/lib/ai/pricing'; + +describe('findPrice', () => { + it('matches a known model exactly', () => { + expect(findPrice('claude-sonnet-4-5')).toBeTruthy(); + }); + + it('matches a known model with a date suffix (longest-prefix)', () => { + const p = findPrice('claude-sonnet-4-5-20251022'); + expect(p?.inputPerM).toBe(3); + expect(p?.outputPerM).toBe(15); + }); + + it('is case-insensitive', () => { + expect(findPrice('GPT-5-Mini')).toBeTruthy(); + }); + + it('returns null for unknown models', () => { + expect(findPrice('mistral-medium-9000')).toBeNull(); + }); + + it('prefers longer-prefix when multiple keys match', () => { + // claude-sonnet-4-5 is more specific than claude-sonnet-4 + const p = findPrice('claude-sonnet-4-5'); + expect(p).toEqual({ inputPerM: 3, outputPerM: 15 }); + }); +}); + +describe('estimateCost', () => { + it('returns 0 for ollama (self-hosted)', () => { + expect( + estimateCost({ + provider: 'ollama', + model: 'llama3.1:8b', + tokensIn: 1000, + tokensOut: 500, + }), + ).toBe(0); + }); + + it('returns null for openai-compatible (unknown gateway pricing)', () => { + expect( + estimateCost({ + provider: 'openai-compatible', + model: 'meta-llama/llama-3.1-8b-instruct', + tokensIn: 1000, + tokensOut: 500, + }), + ).toBeNull(); + }); + + it('returns null when the model isn\'t in the price table', () => { + expect( + estimateCost({ + provider: 'claude', + model: 'claude-vintage-edition', + tokensIn: 1000, + tokensOut: 500, + }), + ).toBeNull(); + }); + + it('returns null when token counts are missing', () => { + expect( + estimateCost({ + provider: 'claude', + model: 'claude-sonnet-4-5', + tokensIn: null, + tokensOut: 500, + }), + ).toBeNull(); + }); + + it('computes the right $ for a known model', () => { + // claude-sonnet-4-5: $3/M in, $15/M out + // 100K in + 50K out = 0.1*3 + 0.05*15 = 0.3 + 0.75 = 1.05 + const cost = estimateCost({ + provider: 'claude', + model: 'claude-sonnet-4-5', + tokensIn: 100_000, + tokensOut: 50_000, + }); + expect(cost).toBeCloseTo(1.05, 5); + }); + + it('computes correctly for gpt-5-nano (very cheap)', () => { + // gpt-5-nano: $0.05/M in, $0.4/M out + // 1000 in + 500 out = 0.001*0.05 + 0.0005*0.4 = 0.00005 + 0.0002 = 0.00025 + const cost = estimateCost({ + provider: 'openai', + model: 'gpt-5-nano', + tokensIn: 1000, + tokensOut: 500, + }); + expect(cost).toBeCloseTo(0.00025, 8); + }); +}); + +describe('formatCost', () => { + it('formats null as em dash', () => { + expect(formatCost(null)).toBe('—'); + }); + it('formats 0 as "free"', () => { + expect(formatCost(0)).toBe('free'); + }); + it('formats sub-cent costs as "<$0.01"', () => { + expect(formatCost(0.0023)).toBe('<$0.01'); + }); + it('formats sub-dollar costs with 3 decimal places', () => { + expect(formatCost(0.123)).toBe('$0.123'); + }); + it('formats dollar+ costs with 2 decimal places', () => { + expect(formatCost(2.567)).toBe('$2.57'); + }); +}); diff --git a/start9/0.4/startos/versions/index.ts b/start9/0.4/startos/versions/index.ts index 9946f7c..8802595 100644 --- a/start9/0.4/startos/versions/index.ts +++ b/start9/0.4/startos/versions/index.ts @@ -8,6 +8,7 @@ import { v_1_0_0_6 } from './v1.0.0.6' import { v_1_0_0_7 } from './v1.0.0.7' import { v_1_1_0_1 } from './v1.1.0.1' import { v_1_1_0_2 } from './v1.1.0.2' +import { v_1_1_0_3 } from './v1.1.0.3' /** * Version graph for the `proof-of-work` package. @@ -25,9 +26,11 @@ import { v_1_1_0_2 } from './v1.1.0.2' * v1.1.0:1 — Programs UI (manual create / save / follow). * v1.1.0:2 — AI program generation, 5 providers (Claude / OpenAI / * OpenAI-compatible / Gemini / Ollama). + * v1.1.0:3 — AI upgrades: history-as-context, test connection, + * cost estimator, streaming preview render. */ export const versionGraph = VersionGraph.of({ - current: v_1_1_0_2, + current: v_1_1_0_3, other: [ v_1_0_0_1, v_1_0_0_2, @@ -37,5 +40,6 @@ export const versionGraph = VersionGraph.of({ v_1_0_0_6, v_1_0_0_7, v_1_1_0_1, + v_1_1_0_2, ], }) diff --git a/start9/0.4/startos/versions/v1.1.0.3.ts b/start9/0.4/startos/versions/v1.1.0.3.ts new file mode 100644 index 0000000..af3d376 --- /dev/null +++ b/start9/0.4/startos/versions/v1.1.0.3.ts @@ -0,0 +1,61 @@ +import { IMPOSSIBLE, VersionInfo } from '@start9labs/start-sdk' + +/** + * v1.1.0:3 — AI: workout-history context, test connection, + * cost estimator, streaming preview render. + * + * History context (the killer feature) + * - lib/ai/historyContext.ts builds a compact 90-day rollup of + * the user's training: per-exercise frequency, recent weights, + * estimated 1RMs (Epley), avg RPE, days-since-last, plus a + * STAGNANT flag when the heaviest weight in the new half of + * the window doesn't beat the old half. + * - Generate page has an "Include my workout history as context" + * checkbox (default on if you have ≥10 logged workouts). When + * checked, the summary is appended to the system prompt so the + * model can recommend things like "you've stalled bench at 245 + * for 6 weeks — try paused reps." + * - The summary is text, ~1-3 KB even for heavy users. We + * deliberately don't ship raw set logs (privacy + token cost). + * + * Test connection + * - POST /api/ai/test sends a tiny "say hi in 3 words" prompt + * against the user's configured provider and reports + * latency + first sample of the response, or the error inline. + * - "Test connection" button next to "Save AI config" in + * Settings → AI integration. Lets you verify the provider/ + * model/key/baseUrl combo without going through full program + * generation. + * + * Cost estimator + * - lib/ai/pricing.ts ships a price table for the major models + * (Claude Sonnet/Opus/Haiku 4-5, GPT-5/4o/o3, Gemini 2.5/2.0). + * Ollama always returns 0 (self-hosted, no per-token cost). + * openai-compatible returns null (we don't know the gateway's + * pricing). + * - Generation history shows per-row cost + a 30-day rolling + * total at the top of the page. + * + * Streaming preview render + * - lib/ai/lenientJson.ts: a stack-aware partial-JSON parser + * that auto-closes open strings/brackets/braces in + * reverse-of-opening order, drops dangling key:value pairs + * and partial keywords. Returns a best-effort snapshot of + * the program-so-far on each chunk. + * - Generate UI now renders a live "Building program..." panel + * that updates as weeks/days/exercises arrive, instead of + * just showing raw text and waiting for stream end. + * + * No schema changes. /data is untouched. + */ +export const v_1_1_0_3 = VersionInfo.of({ + version: '1.1.0:3', + releaseNotes: { + en_US: + 'AI program generation gets four upgrades: (1) include your last 90 days of workout history as context — the model designs around your actual frequency, current weights, and stagnations; (2) "Test connection" button in Settings to verify provider/model/key without a full generation; (3) per-generation USD cost + 30-day rolling total in the history page (Ollama is free, openai-compatible gateways are unknown); (4) streaming preview renders the program tree as the model writes it instead of waiting for the full response. No data migration.', + }, + migrations: { + up: async () => {}, + down: IMPOSSIBLE, + }, +})