export type ContentLocale = "de" | "en"; export type StoryPhase = | "planning" | "writing" | "reviewing" | "continuity_memory" | "finalizing"; declare const brand: unique symbol; /** Integer in Teddy's supported 5..12 range. Construct only via {@link clampTargetReadMinutes}. */ export type TargetReadMinutes = number & { readonly [brand]: "TargetReadMinutes" }; /** Matches Teddy's /^[A-Za-z0-9._:-]{8,128}$/. Construct only via {@link deriveIdempotencyKey}. */ export type IdempotencyKey = string & { readonly [brand]: "IdempotencyKey" }; export const READ_MINUTES_MIN = 5; export const READ_MINUTES_MAX = 12; export const READ_MINUTES_DEFAULT = 8; // Live generation can take about five minutes. 360 is 20% headroom over that default. export const DEFAULT_WAIT_SECONDS = 300; export const MAX_WAIT_SECONDS = 360; export const ATTACH_WINDOW_MS = 30 * 60_000; export const TOKEN_SETUP_URL = "https://kody.codes/account/secrets/new?name=teddyPinguinToken&allowedHosts=www.teddy-pinguin.com"; export type Story = { id: string; title: string; summary: string; prose: string; contentLocale: ContentLocale; readMinutes: number | null; libraryUrl: string; series: { id: string; position: number } | null; }; export type StoryGenerationResult = | { status: "ready"; runId: string; story: Story } | { status: "pending"; runId: string; phase: StoryPhase | null; waitedSeconds: number; nextStep: string; } | { status: "declined"; runId: string; code: string; message: string; canRetry: boolean } | { status: "unavailable"; runId: string | null; code: string; message: string; retryAfterSeconds: number | null; } | { status: "disconnected"; message: string; tokenSetupUrl: string } | { status: "invalid_input"; field: "storyIdea" | "runId" | "series" | "continueFromStoryId"; message: string }; export type ConnectionStatus = | { status: "connected"; apiBaseUrl: string; message: string } | { status: "disconnected"; message: string; tokenSetupUrl: string } | { status: "unreachable"; apiBaseUrl: string; message: string }; export type GenerateStoryInput = { storyIdea: string; contentLocale?: ContentLocale; targetReadMinutes?: number; maxWaitSeconds?: number; series?: { start: true } | { continue: string }; continueFromStoryId?: string; }; export type GetStoryResultInput = { runId: string; maxWaitSeconds?: number; }; export type CheckConnectionInput = { contentLocale?: ContentLocale; }; export type NormalizedRequest = { storyIdea: string; contentLocale: ContentLocale; targetReadMinutes: TargetReadMinutes; seriesId: string | null; seriesStart: boolean; continueFromStoryId: string | null; }; export type InvalidInput = Extract; const IDEMPOTENCY_KEY_PATTERN = /^[A-Za-z0-9._:-]{8,128}$/; const POLL_DELAYS_MS = [2_000, 2_000, 3_000, 4_000, 5_000, 6_000, 8_000] as const; export function clampTargetReadMinutes(value: number | undefined): TargetReadMinutes { if (value === undefined || !Number.isFinite(value)) { return READ_MINUTES_DEFAULT as TargetReadMinutes; } const rounded = Math.round(value); const clamped = Math.min(READ_MINUTES_MAX, Math.max(READ_MINUTES_MIN, rounded)); return clamped as TargetReadMinutes; } export function normalizeContentLocale(value: unknown): ContentLocale { return value === "en" ? "en" : "de"; } export function normalizeStoryRequest( input: GenerateStoryInput, ): { ok: true; request: NormalizedRequest } | { ok: false; failure: InvalidInput } { const locale = normalizeContentLocale(input.contentLocale); const storyIdea = typeof input.storyIdea === "string" ? input.storyIdea.trim() : ""; if (!storyIdea || storyIdea.length > 500) { return { ok: false, failure: { status: "invalid_input", field: "storyIdea", message: locale === "de" ? "storyIdea muss 1 bis 500 Zeichen haben." : "storyIdea must contain 1 to 500 characters.", }, }; } const continueFromStoryId = typeof input.continueFromStoryId === "string" ? input.continueFromStoryId.trim() : ""; if (continueFromStoryId && !isUuid(continueFromStoryId)) { return { ok: false, failure: { status: "invalid_input", field: "continueFromStoryId", message: locale === "de" ? "continueFromStoryId muss eine UUID sein." : "continueFromStoryId must be a UUID.", }, }; } const seriesContinue = input.series && "continue" in input.series ? input.series.continue.trim() : ""; if (seriesContinue && !isUuid(seriesContinue)) { return { ok: false, failure: { status: "invalid_input", field: "series", message: locale === "de" ? "series.continue muss eine UUID sein." : "series.continue must be a UUID.", }, }; } return { ok: true, request: { storyIdea, contentLocale: locale, targetReadMinutes: clampTargetReadMinutes(input.targetReadMinutes), seriesId: seriesContinue || null, seriesStart: Boolean(input.series && "start" in input.series && input.series.start), continueFromStoryId: continueFromStoryId || null, }, }; } function isUuid(value: string): boolean { return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test( value, ); } export function normalizeRunId( runId: string, ): { ok: true; runId: string } | { ok: false; failure: InvalidInput } { const trimmed = typeof runId === "string" ? runId.trim() : ""; if (!trimmed) { return { ok: false, failure: { status: "invalid_input", field: "runId", message: "runId is required.", }, }; } return { ok: true, runId: trimmed }; } export function waitBudgetMs(seconds: number | undefined, fallbackSeconds: number): number { const candidate = seconds === undefined ? fallbackSeconds : seconds; if (!Number.isFinite(candidate)) { return fallbackSeconds * 1000; } const clamped = Math.min(MAX_WAIT_SECONDS, Math.max(0, Math.trunc(candidate))); return clamped * 1000; } function toHex(bytes: ArrayBuffer): string { return Array.from(new Uint8Array(bytes), (byte) => byte.toString(16).padStart(2, "0"), ).join(""); } export async function requestFingerprint( apiBaseUrl: string, request: NormalizedRequest, ): Promise { const seriesKey = request.continueFromStoryId ? `from:${request.continueFromStoryId}` : request.seriesId ? `continue:${request.seriesId}` : request.seriesStart ? "start" : ""; const material = `${apiBaseUrl}|${request.storyIdea}|${request.contentLocale}|${request.targetReadMinutes}|${seriesKey}`; const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(material)); return toHex(digest).slice(0, 32); } export function deriveIdempotencyKey(fingerprint: string, nowMs: number): IdempotencyKey { const bucket = Math.floor(nowMs / ATTACH_WINDOW_MS); const key = `tp-${fingerprint}-${bucket}`; if (!IDEMPOTENCY_KEY_PATTERN.test(key)) { throw new Error("derived Idempotency-Key failed Teddy's length and charset check"); } return key as IdempotencyKey; } export function pollDelayMs(attempt: number): number { const index = Math.min(Math.max(0, attempt), POLL_DELAYS_MS.length - 1); return POLL_DELAYS_MS[index] + Math.floor(Math.random() * 500); } const FAILURE_MESSAGES: Record> = { STORY_REJECTED: { de: "Diese Idee hat Teddy nicht als Kindergeschichte angenommen. Formuliere sie ruhiger oder konkreter.", en: "Teddy did not accept this idea as a children's story. Try a calmer or more specific idea.", }, STORY_RUN_CANCELLED: { de: "Die Geschichte wurde abgebrochen.", en: "This story was cancelled.", }, STORY_LIMIT_REACHED: { de: "Dein Kontingent an Geschichten ist aufgebraucht.", en: "This account has used its story quota.", }, RATE_LIMITED: { de: "Zu viele Anfragen. Warte einen Moment und versuche es erneut.", en: "Too many requests. Wait a moment and try again.", }, MODEL_UNAVAILABLE: { de: "Die Geschichte konnte gerade nicht geschrieben werden. Versuche es später.", en: "Story writing is unavailable right now. Try again later.", }, STORY_GENERATION_FAILED: { de: "Die Geschichte ist fehlgeschlagen.", en: "Story generation failed.", }, AUTHENTICATION_REQUIRED: { de: "Kein gültiges Teddy & Pinguin Token. Speichere teddyPinguinToken in Kody.", en: "No valid Teddy & Pinguin token. Save teddyPinguinToken in Kody.", }, STORY_RUN_NOT_FOUND: { de: "Diese Geschichte wurde nicht gefunden.", en: "This story run was not found.", }, STORY_NOT_FOUND: { de: "Die fertige Geschichte konnte nicht geladen werden.", en: "The finished story could not be loaded.", }, INVALID_TEDDY_RESPONSE: { de: "Teddy hat eine unerwartete Antwort geschickt.", en: "Teddy returned an unexpected response.", }, TEDDY_UNAVAILABLE: { de: "Teddy ist gerade nicht erreichbar. Versuche es später.", en: "Teddy is unavailable. Try again later.", }, IDEMPOTENCY_KEY_REUSED: { de: "Diese Anfrage kollidiert mit einer anderen. Ändere die Idee oder warte 30 Minuten.", en: "This request collides with a different one. Change the idea or wait 30 minutes.", }, }; export function describeFailure( code: string, phase: StoryPhase | "preparing" | null, locale: ContentLocale, ): string { const known = FAILURE_MESSAGES[code]?.[locale]; if (known) { return known; } if (phase && locale === "de") { return `Die Geschichte ist in der Phase ${phase} fehlgeschlagen.`; } if (phase) { return `Story generation failed during the ${phase} phase.`; } return locale === "de" ? "Die Geschichte ist fehlgeschlagen." : "Story generation failed."; } export function describePending( runId: string, phase: StoryPhase | null, locale: ContentLocale, ): string { if (locale === "de") { const phaseNote = phase ? ` (Phase ${phase})` : ""; return `Die Geschichte wird noch geschrieben${phaseNote}. Rufe get-story-result mit der runId ${runId} auf.`; } const phaseNote = phase ? ` (${phase})` : ""; return `The story is still being written${phaseNote}. Call get-story-result with runId ${runId}.`; } export function describeConnection( status: "connected" | "disconnected" | "unreachable", locale: ContentLocale, ): string { if (status === "connected") { return locale === "de" ? "Dein Teddy & Pinguin Konto ist verbunden." : "Your Teddy & Pinguin account is connected."; } if (status === "disconnected") { return describeFailure("AUTHENTICATION_REQUIRED", null, locale); } return locale === "de" ? "Teddy & Pinguin ist gerade nicht erreichbar." : "Teddy & Pinguin is unreachable right now."; } export function storyLibraryUrl( apiBaseUrl: string, storyId: string, locale: ContentLocale, ): string { const prefix = locale === "de" ? "/de" : ""; return `${apiBaseUrl}${prefix}/my-story-library/${encodeURIComponent(storyId)}`; }