Corpan 0.18.1#298
Conversation
Two foundational primitives for the monetization overhaul (plan: get
people to pay via aha-moment onboarding, interaction-gated paywall
moments, free trial, codes).
- packs/shared/monetization: createPaywallGate — a reusable, versionless
gate every pack uses to surface the paywall at natural interaction
boundaries, never interrupting active use. Modes: action-count,
daily-quota (DAU lever), time-armed (fires only on the next interaction
after the interval). Subscriber = total no-op; soft/hard hardness;
per-session backstop cap; localStorage-persisted, robust to bad storage.
Folds in the tutomaton/teletron daily-quota shape. 20 vitest cases
(run via corpan-city, the shared-test host).
- corpan-app host seam: hostApi.entitlement.{isSubscribed,snapshot,onChange},
requestPaywall(context)->bool (reuses paywall store guards), and
showRatingPrompt(). New per-pack PaywallSurface values. Purely additive —
__CORPAN_PLUS/__CORPAN_ENTITLEMENT globals + corpan:request-unlock event
untouched. SDK type mirror updated. tsc clean.
Free trial (the carrot):
- purchase.ts introOfferFromProduct() + periodLabelFromIso(): normalize
iOS (offerDetails split) and Android (pricing-phase micros) intro/trial
offers into { kind: free_trial|intro_price, periodLabel, cycles }.
- SubscriptionOffer renders premium trial framing ("7 days free, then
$X · no payment due now", trial timeline, "Start Free Trial" CTA);
degrades to today's plain-price UI when no offer is configured, so it
lights up once store offers exist (Phase 0). handleSubscribe unchanged —
the store applies the configured offer automatically.
- Known iOS native gap (tracked): plugin drops introOffer.paymentMode and
hardcodes intro micros to 0, so iOS detection uses a price-string
heuristic until the plugin is patched.
Onboarding aha-launch (value before paywall):
- New onboarding/bestFit.ts reuses Home's rankExperiences ranking
(interests+userClass+language) to pick the top LAUNCHABLE experience;
preview packs (corpan_city, teletron) blocklisted; falls back to the
phrase experience, then Home.
- commitDraft sets the existing landing intent to {experience, packId};
App.tsx's existing landing consumer launches via the same handleLaunchGame
path Home uses. Exit → Home as normal. Visible "Explore on my own" escape.
- analytics: app_onboarding_launch{target}.
New strings added as t(key,{defaultValue}); locale JSON untouched (one x50
pass batches localization at end of Phase 1).
Reference integrations of @shared/monetization's createPaywallGate, one per mode (alias registered per pack like @shared/moderation; gate uses defaults — __CORPAN_PLUS globals + corpan:request-unlock): - tutomaton (daily/hard, DAU lever): replaces the bespoke tutomaton.quota with a daily gate (limit 20, hard). 20 free messages/local day → blocked until tomorrow or subscribe; subscriber unlimited. isSubscribed wired to the pack's live `plus` (host injects Plus at mount, which the gate default wouldn't see). Old quota helpers removed; persistence key moves to corpan:gate:tutomaton:tutomaton_daily. - beatlounge (timed/soft): one gate at rig build (5 min interval), disposed with the rig. Interaction funnel = a single capture-phase pointerdown on the shell root (NOT the command bus, which also carries AutoConductor/LLM dispatches — counting those would make it a quasi-timer). Never stops playback; the next tap after 5 min surfaces the paywall, dismiss → continue. - hover-runner (action/soft): note() on each completed phrase (limit 5), onInteraction() on a window pointerdown. Every ~5 phrases the next tap surfaces the paywall, then play continues. typecheck clean all three; beatlounge 1232/1232, hover-runner 16/16, tutomaton tests green (1 pre-existing baseline failure unrelated). Device-verify: beatlounge immersive pages + command palette register taps inside .bl-shell. manifest devRevision (watcher artifact) left uncommitted.
Phase 0 store-setup tooling so the founder doesn't hand-click the consoles. - infra/play/play_monetization.py: reuses the verify lambda's Google service account (AWS Secrets Manager corpan/content-packs/verify) to list subscriptions/base plans/offers (read-only), create+activate free-trial OFFERS (a base-plan offer, no Console/backward-compat needed), and flip the legacyCompatible flag (which gates promo codes). Writes are dry-run until --yes. Verified live: `list` works; the trial offer body validates. Blocked only on granting [email protected] the manage-monetization permission in Play Console (currently read-only). Promo CODE generation has no Google API — Console-only (path in README). - infra/APPLE_OFFER_SETUP_BRIEF.md: complete brief for a browser agent to set up, on App Store Connect for monthly + annual: 7-day free trial (Introductory Offer = Free, 1 week), one-time-use FREE offer codes, and discount affiliate custom offer codes. Notes the App Store Connect API path if we'd rather script it.
Working SubscriptionOffer shape, dialed in against the live API:
- per-phase pricing: free in an anchor region (default US) + otherRegionsConfig.free
- offer-level availability: regionalConfigs[{anchor, newSubscriberAvailability}] +
otherRegionsConfig.otherRegionsNewSubscriberAvailability=true (global, avoids
enumerating non-billable regions like MN at regionsVersion 2022/02)
- acquisition scope: thisSubscription (new-subscriber trial; specificSubscriptionInApp
is rejected for acquisition rules)
- fetches base-plan regions to choose a valid anchor; --anchor-region flag
7-day free trials now ACTIVE on corpan.sub.monthly + corpan.sub.annual.
Document driving Google Play + App Store Connect by API (creds in AWS Secrets Manager, never the repo). Guard infra/asc against committing any .p8 / AuthKey / venv. Secret-free (repo is open source).
Apple twin of infra/play. ES256-JWT auth (Key ID + Issuer ID + .p8 from the appStoreConnect secret key; nothing hardcoded — open-source repo). Subcommands: list / pricepoints / trial (FREE_TRIAL intro offer) / code-free (one-time-use free offer codes + CSV) / code-discount (custom affiliate code). Endpoints verified vs Apple's OpenAPI spec; per-territory intro-offer + price-point discount nuances flagged for live verification. Dry-run until --yes. Pending: populate the appStoreConnect secret (needs the Issuer ID), then run.
- code-free: FREE_TRIAL offer codes must NOT bind a subscriptionPricePoint (Apple 409s otherwise). Send territory-only price entries (null point). Verified live: created annual influencer (1yr) + giveaway (1mo) batches. - code-discount: resolve the REAL per-territory price point nearest (1-pct/100)x base (Apple has no raw %-off). Verified live: USA 12.99 -> 9.09 = exactly -30%, PAY_AS_YOU_GO. months/periods express '30% off for N months'. New read helpers: subscription_prices / price_points / nearest_point. - gitignore *.csv (generated codes are secrets — never in the repo).
Apple rejects lowercase and <4-char custom codes (409). Uppercase and validate up front so we never create an offer-code config then fail the custom-code attach (leaving a dangling empty config).
…v-determined) Creates a base-plan offer per affiliate code for Google: relativeDiscount 0.7 (=30% off), offerTags [code-<lowercased>], and NO targeting (omitted) = developer-determined, so Play never auto-shows it — only applied when the app passes the matching offerToken after the backend validates the typed code. Attribution via subscriptionsv2.get -> lineItems[].offerDetails.offerId/Tags. Annual: use --months 1 (recurrenceCount counts billing cycles). Verify live: relativeDiscount accepted + empty-targeting=dev-determined.
Single source of truth for the affiliate/discount codes backend: exact /code/resolve (4 branches, no fail-open) + /entitlement-token JSON, HMAC-JWT resolutionToken signing, /verify-purchase additions (Apple JWS offer fields, Google subscriptionsv2, idempotent PURCHASE# + first-verified-touch ATTRIBUTION lock + LEDGER), ASSN-V2/RTDN renewal handlers, DynamoDB single-table corpan-iap + GSI1, the 8-code seed.json, and the WS-A..WS-E file-ownership map.
…16) WS-D (Phase 3). New present_offer_code_redeem_sheet command wired end-to-end (Rust command/mobile/lib + Swift AppStore.presentOfferCodeRedeemSheet iOS16 w/ SKPaymentQueue fallback + Android reject + desktop stubs + guest-js + permissions). And get_products now surfaces the intro offer's paymentMode (freeTrial/payAsYouGo/payUpFront) + real priceAmountMicros + period/cycles (closes #16; additive — Android pricingPhases shape unchanged). cargo check clean; 37 plugin tests pass. Swift needs an Xcode build to verify.
DynamoDB corpan-iap (PAY_PER_REQUEST, PITR) + GSI1 (obfHash) for the Google
renewal reverse-map; IAM GetItem/PutItem/UpdateItem/Query on table+GSI for the
verify lambda; 2 new routes POST /code/resolve + /entitlement-token on the
existing API (lambda routes by routeKey); DYNAMO_TABLE env; table outputs.
CRITICAL: added lifecycle ignore_changes=[secret_string] so apply can't wipe
the live secret (real values incl. codeSigning.hmacKey set out of band).
infra/codes/{seed.json (8 partners+8 codes), load_seed.py (idempotent
BatchWrite, dry-run until --yes)}. terraform validate Success; fmt clean.
Build-only — not applied.
resolveAffiliateCode -> resolveCode hitting POST /code/resolve; the dangerous client FAIL-OPEN is deleted (any non-2xx/network/missing-token -> status:error, never 'ok'). Thread resolutionToken into verifyPurchase. Android USE_OFFER_TOKEN: re-read the live session-bound offerToken from getProducts by offerId; Apple REDEEM_APPLE_SHEET: invoke plugin:iap|present_offer_code_redeem_sheet (guarded, falls back to appleRedeemUrl). SubscriptionOffer: un-hide the code field after the plan selector, debounced resolve, CTA branched by purchaseAction, render discountLabel/partnerName, 'Couldn't check - Retry' on error (never 'valid'). New strings via t(defaultValue); locale JSON deferred to the x50 pass. tsc clean.
New codes.js: HS256 (hand-rolled via crypto, no new runtime dep) resolution + entitlement tokens, code classification, DynamoDB registry/ledger access (DocumentClient + GSI1 reverse-map), and the idempotent attribution flow (PURCHASE# dedupe -> first-verified-touch ATTRIBUTION lock -> initial LEDGER credit), plus per-subject/IP rate limiting. verify_purchase.js (minimal additive): /code/resolve + /entitlement-token route cases; capture Apple JWS offer fields; Google -> subscriptionsv2.get (obfuscatedExternalAccountId + lineItems offerId/offerTags); validate resolutionToken; ASSN-V2 JWS-verified DID_RENEW + RTDN OIDC-verified SUBSCRIPTION_RENEWED renewal credits. Attribution is always non-fatal — never blocks entitlement. 28/28 unit tests (mocked Dynamo/secret); node --check clean. Deps: @aws-sdk/client-dynamodb + lib-dynamodb.
Tiered, Duolingo-benchmarked regional pricing (8 tiers, 166 territories,
iOS>=Android, annuals ~7x monthly) in infra/pricing/{pricing-matrix.json,
PRICING_RESEARCH.md}. Tools consume the matrix:
- play set-prices: convertRegionPrices (USD->local+tax) -> read-modify-write
subscriptions.patch on only targeted regions' price. New purchases only.
- asc set-prices: USA-anchor rung -> Apple equalizations (Apple's own FX+.99
rounding) -> POST subscriptionPrices per territory; preserveCurrentPrice ON
by default (existing subs untouched). ISO2->ISO3 map added.
Both dry-run until --yes. Live application gated on founder review.
…tions)
The anchor territory (USA) isn't in its own equalizations response, so it was
skipped ('no equalized rung'). Use the USA anchor rung directly for USA.
Verified live dry-run: 6/6 territories resolve (USA $99.99 etc.).
…safety) convertRegionPrices can return a currency the base plan doesn't expect at regionsVersion 2022/02 (e.g. Bulgaria -> EUR vs expected BGN), and the subscriptions.patch is atomic — one mismatch rejected all 148 regions. Now skip + log any region whose converted currency != the base plan's existing currency (keep its current price), so the rest apply.
…bust drops The frozen 2022/02 version conflicts with Google's live catalog (Bulgaria->EUR, Cote d'Ivoire->XOF currency migrations; MN billability), failing the atomic patch. Use the LATEST version 2025/03 (the API reports it) so currencies match; plus a retry loop that reprices EUR-pegged currencies and excludes genuinely non-billable regions, so the patch always lands. Applied live: monthly+annual, 173 regions each. (Apple side: 174/174 monthly, 173/174 annual, effective 06-16.)
These are direction we believe in, not contractual absolutes to ship in user-facing copy. Avoid "forever / no X ever / always" marketing claims.
New series scaffolding plus the first book — manuscript, pack config, segment-generation scripts. Catalog metadata + cover-prompt entry added to corpan infra so the book registers on next catalog regen.
Manuscript, dialog-pack scaffolding (vindy-ron-gemini-v1), and the per-language pipeline scripts. PIPELINE_STATE logs Ep 5 status: 9 langs at v0.1.0, batch3 in flight, ja/ko dropped (translator/Gemini issues), batches 4–10 not yet launched.
Universal paywall: PaywallSheet rewritten as ONE dark, full-screen immersive
paywall used on every surface (reader EOF, pack gates, library, settings,
onboarding) — the per-pack earthgate/stargate theming is gone. Corpán mark,
free-trial hero ("7 days free, then $X · no payment due now" + timeline),
squared plan selector w/ annual savings, the offer/affiliate code field below
the CTA, restore + cancel-anytime + terms, restrained motion (respects
prefers-reduced-motion), RTL-correct. SubscriptionOffer gains a `chromeless`
prop (re-housed in the dark shell; purchase/resolveCode/restore logic
untouched). `theme` still flows for caller compat but is ignored.
Funnel analytics: full taxonomy (pack_enter/exit, gate_hit{pack,surface,mode},
paywall_shown/dismissed/converted, paywall_cta_tapped, code_field_opened,
code_resolved/redeemed, trial_started, subscription_purchased{plan,platform,
code}, subscription_restored) wired across ContentPackOverlay (dwell time),
App.tsx (gate_hit from the request-unlock listener), store/paywall.ts,
purchase.ts, + an optional onFire hook on the shared gate. Anonymous + opt-in
(same emit()/consent gate as existing events; session-UUID only, no PII).
Docs: corpan-app/docs/ANALYTICS_FUNNEL.md. Strings via defaultValue (loc later).
tsc clean; gate tests green.
Gate helper: dailyLimit (hard per-local-day cap, resets at local midnight — the
DAU model) + softNagEvery (dismissible request-unlock nags before the cap =
"soft, soft, hard") + unitLabel. At the cap, dispatches corpan:daily-locked
{packId,surface,doneToday,limit,resetAt,unitLabel} once/day; isBlocked() true
(daily cap is inherently hard) until next local day or subscribe. New resetAt()
helper; onFire observes the lock; subscriber = no-op. 29 gate tests (20 + 9).
Host: new DailyLockOverlay — universal, matches the dark premium paywall +
Corpán mark; green spring-in checkmark (accomplishment, not error), live
countdown to reset, "Go further, faster with Corpán Plus" CTA → the universal
paywall; quiet "Maybe later" (stays locked till reset/subscribe). App.tsx
renders one instance off the corpan:daily-locked event (subscriber-guarded).
analytics: daily_lock_shown / daily_lock_upgrade_tapped (anon/opt-in). Strings
via defaultValue. Per-pack config = pack-release-tunable. tsc clean.
Apply the daily-quota model (hard per-local-day cap + "soft, soft, hard" nags → the accomplishment lock) across packs. Per-pack limit in one named constant (pack-release-tunable). Subscribers = no-op. - tutomaton: migrated to v2 daily (20 messages/day, nag@5). - hover-runner: soft-action → daily (20 phrases/day, nag@5). - juice-squeeze: wired (20 phrases/day, nag@5). - pronunciation-coach/parlometron: wired (15 rounds/day, nag@5; solo + MP share one daily count via the surface). - phrase-flip (corpan-app MainExperience): wired (20 phrases/day, nag@5; counts forward handleNext only, backward review free). - hanzipan (JS pack): wired (20 characters/day, nag@5; counts on stroke-complete). - beatlounge left timed-soft (daily TIME cap = a follow-up gate mode); world-radio/readers untouched. typecheck clean across packs + corpan-app; pack tests green (tutomaton's 1 pre-existing prompts.test failure is unrelated).
The presence host's hostname encodes its public IP
(presence.3-142-26-37.sslip.io) and serves long-lived WebSockets, so it must
only be replaced on purpose. The AL2023 ARM64 `data "aws_ami"` lookup ships
a new AMI roughly weekly, which was triggering a force-replace on every
plan. Add `lifecycle { ignore_changes = [ami] }` so the live AMI is treated
as ground truth; AMI rolls become an explicit, scheduled operation instead
of a side effect of unrelated applies.
… set to base-plan period
…s tutomaton-only) note() counted but only tutomaton checked isBlocked(), so the lock overlay showed once and the user continued unbounded. Add gate.requestDailyLock() (re-show the DailyLockOverlay on a blocked tap, bypassing the once-per-day note guard) and guard each action boundary: core phrase-flip handleNext + Random, hover-runner round transition, juice-squeeze reload, pronunciation-coach solo+multiplayer startRecording (mic disabled 'Done for today'), hanzipan goNext (fresh chars only). Subscribers bypass via isBlocked()->isSubscribed(); exactly dailyLimit actions then blocked. 31/31.
…ishment lock copy
New packs/shared/streak primitive (recordPackVisit/getPackStreak, consecutive
local-day logic, per-pack localStorage, corpan:streak-changed). Host records a
visit on pack-enter + phrase-flip; hostApi.getStreak() exposes it; StreakBadge
shows it on Home tiles for everyone (>=2 days), never gated. DailyLockOverlay
reworded to accomplishment + streak: 'Your {count} {unit} for today' / '{days}-day
streak — come back tomorrow to keep it going' / 'Continue now with Corpán Plus'.
EN source only (localization deferred). 12/12 streak tests, app tsc clean.
Evidence-based pass over all 55 advertised teaching languages using the new infra/tutomaton-eval harness (faithful llama.cpp replication of the plugin's ChatML + sampler chain; programmatic gate + native-level Claude fluency judging at the best parameters). - Retune global model defaults to the shipped Qwen3-4B Q4_K_M: temperature 0.6->0.3, topP 0.95->0.9, minP 0->0.05, repeatPenalty 1.0->1.1. Lower-temp/ tightened sampling cut fabrication + repetition loops in weak languages (rescued Marathi) and was neutral-to-positive on strong ones. - Drop 5 languages the model cannot teach acceptably even at best params: Telugu (fabricated vocab), Swahili (word-salad/loops), Sundanese + Javanese (answers in Indonesian), Punjabi-Shahmukhi (answers in Urdu). 55 -> 50. - Fix already-stale prompts.test count; bump manifest+pkg to 0.6.0; changelog. Harness + report: infra/tutomaton-eval (REPORT.md, recommendations.json).
PR Reviewer Guide 🔍Here are some key observations to aid the review process:
|
PR Code Suggestions ✨Explore these optional code suggestions:
|
|
Requesting changes. I would not ship this PR as-is. Blockers:
Checks run:
|
The build's i18n gate (check-i18n.mjs) requires every t() key in en + every
locale. The monetization/streak UI shipped EN-only: 23 keys (code.*, subscription
trial.*, paywall.dismiss, onboarding.engage.explore) were used via inline
defaultValue and never added to en/common.json, and dailyLock.*/streakBadge.* were
EN-only. Added the 23 to en, then translated all 35 into the 53 non-en locales via
OpenAI (brand terms + {{placeholders}} preserved; only-missing merge). Removed an
empty stray 'hostApi.ts corpan/' dir artifact. Build green: 717 keys x 54 locales.
Review — Corpán 0.18.1 (monetization overhaul)Verdict: ready for on-device testing. CI is green, the production build passes, the backend is live and smoke-tested, and the diff is clean of secrets. The remaining unknowns are all things only a real device build can validate (native StoreKit/Play bits) — that's exactly the next step. Scope250 files / 36 commits. Delivers the full monetization program: universal dark paywall · free trials (live both stores) · daily-quota gates with the accomplishment-lock + per-pack visit streaks · server-authoritative affiliate/discount codes · anonymous opt-in funnel analytics · onboarding aha auto-launch · regional pricing (applied live) · plus low-end-Android hardening (GPU-blur ANR, tutor OOM floor) and three new languages. Localized across all 54 locales. Verified ✅
Must test on device (native paths can't be CI-verified)
Known follow-ups (non-blocking, tracked)
Behavior changes to feel & tuneThe daily limits are now real walls: phrase-flip/hover/juice/hanzipan 20/day, pronunciation-coach 15, tutomaton 20, soft nag every 5. These are the knobs — try them and we dial. Recommend: build on Android + iPad/iOS, run the device checklist, then TestFlight. I'm standing by to fix anything testing surfaces. |
2) Apple offer-code redemption now attributes: app subscribes to the iOS plugin purchaseUpdated (Transaction.updates) and POSTs the pending resolutionToken to /verify-purchase after the redeem sheet, so PURCHASE#/ATTRIBUTION/LEDGER rows are written (Android already did this). App-side; needs a device build. 3) /entitlement-token works for normal verified subs: attributePurchase persists expiresAt on the PURCHASE# row + new recordEntitlementPurchase() writes the entitlement row for any verified active sub (no code required). 4) Ledger payout pct no longer null: resolution token now carries revenueSharePct (registry-derived); tests mint REAL tokens (was masking the bug). 34/34 lambda. Lambda fixes (3,4) need a redeploy; #2 needs an iOS build.
…on-gating) Packs ship OTA to pre-0.18.1 apps that have no DailyLockOverlay + don't listen for corpan:daily-locked. Hard-blocking there froze users behind an invisible wall. The gate now hard-blocks ONLY when the host advertises support via globalThis.__CORPAN_HOST_CAPS.dailyLock (set by ContentPackHost in >=0.18.1); absent => isBlocked() returns false and the gate degrades to the legacy soft corpan:request-unlock nag every host already renders. Subscriber bypass + soft nags unchanged. +3 tests (old-host soft, new-host hard). 34/34, app tsc clean.
Blockers addressed (commits up to
|
…urface - __CORPAN_HOST_CAPS.dailyLock now set app-wide in main.tsx (was only in ContentPackHost, which doesn't mount for the core phrase-flip) so the daily hard cap actually engages outside content packs. - Removed the standalone native review on corpan:exit; the in-app RatingPrompt is the single surface and its 5-star button now pops the native review widget (mobile) / store URL (desktop) — fixes the double rating popup.
…) — was uncommitted, broke CI App.tsx + the changelog for the catalog 'zombie' fix (timeout + jitter + bounded retry + conditional headers) were committed earlier, but the new catalogFetch module and the catalog/phrasePack store edits that back them were never git-added, so CI failed on 'Cannot find module @/contentPacks/catalogFetch'. Landing them makes the tree whole; full build green (tsc + i18n + vite).
…te behind ref guard)
createPaywallGate was built in render behind `ref === null`, and the effect
cleanup dispose()s it. React StrictMode (dev) runs mount→cleanup→mount, so the
gate got disposed and the guard refused to rebuild → the ref held a DISPOSED gate
and note()/isBlocked() silently no-op'd forever (no nag/lock no matter how many
phrases you flip). Now built inside the effect so each run gets a fresh gate;
cleanup disposes + clears the ref. Works in dev + robust to any remount.
Also: store-unreachable copy is platform-neutral ('the store', not 'App Store')
since it shows on Android; + DEV-only window.__corpanDebug monetization helpers.
…xit→Home The cap now meters ONLY new-phrase acquisition (Random / Next-on-newest) via a single acquireNewPhrase() seam: at the cap it shows the dismissible accomplishment lock and does NOT fetch. Navigating back/forward through already-seen phrases is always free + uncounted (handleNext short-circuits the in-history case before the gate; handlePrev never gated). So a free user gets 20 NEW phrases/day and can review them freely. Exiting phrase-flip just goes Home (onExit clears any open lock) — never a paywall on exit. 34/34 gate tests, tsc clean.
…erlay (z-60 -> z-1400)
THE phrase-flip 'lock never shows' bug: the experience overlay is z-[1100]
(opaque bg) and chrome z-[1110], but the lock/paywall roots were z-[60] — so when
fired from inside an experience they painted BEHIND it (invisible), and only
appeared on exit when the experience unmounted ('exit blocks me with the
paywall'). Now z-[1400], above experience/chrome/drawers. Verified via on-device
CDP screenshot that the DOM-present overlay wasn't painting.
…was a no-op) A gate-v2 daily gate's onInteraction() only fires the legacy countArmed paywall — a no-op here — so once at the cap, a re-send showed only the in-chat systemNote, never the accomplishment lock. Use requestDailyLock() so a blocked send re-shows the lock (consistent with phrase-flip). Crossing the cap (20th msg) already worked via note()'s fireDailyLock.
…OM-crashes The tutor shipped one model (Qwen3-4B) that silently OOM-crashed low-RAM phones (and even a 6 GB Android phone). It now ships three Qwen3 sizes and picks the biggest that runs safely for the device's RAM, with a user-overridable picker. - modelTiering.ts: registry + pure selectTier(totalRamMb) → recommended size + per-size state (recommended/available/try-anyway/disabled). 4B recommended >=7GB, try-anyway at 6GB, disabled below. Boundary-tested. - plugin: total RAM on status() (cross-platform: /proc/meminfo + sysctl hw.memsize); blunt 4GB gate replaced by a per-model footprint backstop so small models load on small devices while 4B is refused where it would OOM. - ModelManager: reads RAM, auto-picks recommended (persisted, overridable), model-aware context length, graceful 'unsupported' fallback. - Picker UI in the setup gate (disabled-greyed reads as the device's limit), localized into 46 languages. - Non-thinking output for hybrid 0.6B/1.7B: noThink prefill (plugin) + streaming thinkFilter (strips <think> from transcript AND speech) + model-lab Thinking toggle. 4B is native Instruct-2507. Small GGUFs hosted in prod (CloudFront + catalog). Per-size language eval-gating is the remaining quality step (every size offers all languages until then).
…pronunciation-coach mic re-pop
DailyLockOverlay title -> 'That's your {count} {unit} for today — nicely done'
(accomplishment, not error). pronunciation-coach: the capped 'Done for today' mic
was disabled and swallowed its own taps so a re-tap did nothing — capture-phase
listener now re-pops the shared lock (matches multiplayer + first cap-cross).
NOTE: en dailyLock.title drifted from ~53 locales (re-gen pending). The tutomaton
half (de-red chat.css, quota strings, composer re-pop) is in files the model-tiering
agent owns — left in the working tree for that commit.
Lets the harness evaluate the smaller tiers in their real shipping config: - TUTO_EVAL_MODEL=/path.gguf — point llama-server at the 0.6B/1.7B GGUF - TUTO_EVAL_NOTHINK=1 — seed the empty <think></think> prefill (matches the plugin's non-thinking mode for hybrid Qwen3) - TUTO_EVAL_RESULTS=dir — isolate a run's rows.jsonl (the rid isn't keyed by model, so a shared dir would reuse the wrong model's cached generations)
…ty on poison row) A single llama-server 500 (seen ~1700 generations into the 0.6B sweep) aborted the whole multi-hour run. Now server.complete retries 4x and restarts the server from the 2nd attempt (recovers a wedged/dead server); eval_config records an empty reply on persistent failure so the sweep continues and resume won't re-hit it. rows.jsonl resumability means a relaunch picks up where it left off.
…a + live debug API
ONE standard for daily quotas across packs. New packs/shared/monetization:
src/quotas.ts (central QUOTAS registry = single source of truth for limit/nag/unit
per surface, remote-config-ready via getQuota), src/dailyQuota.ts (createDailyQuota
(surface) = the one construction pattern), legacy-key migration (<packId>.quota ->
standard corpan:gate key, one-time), a globalThis.__corpanGates registry, and
QUOTA_STANDARD.md. Converted phrase-flip/hover/juice/hanzipan/pronunciation to
createDailyQuota (same keys/limits/nag). DEV __corpanDebug.quota.{list,set,reset,
clearAll} drives live gates both directions (no reload). Presentation standard =
the shared DailyLockOverlay (z-1400, green-check, no per-pack red). tutomaton
conversion documented for its agent. 46/46 monetization tests, tsc clean.
…tations) + working infinite-load - Phone layout (narrow portrait <=767px OR short landscape <=600px) now drops the iPad viewport-fit contract: the page scrolls and the examples panel expands to full height instead of being crushed. Fixes landscape-phone being unusable (it fell into the iPad fit-layout, cramming everything into ~400px). - 'Scroll for more' examples now load on phones: infinite-load moved off a scroll listener on the (internally-scrolling-only-on-wide) examples list to an IntersectionObserver on the footer sentinel, which fires whether the page or the panel is the scroller. Also reset page scroll on character change. iPad portrait/landscape keep the exact viewport fit.
…age gating
Phase 4/5/6 completion + a premium model UI:
- Premium 'Tutor model' sheet (choose/download/switch): cards per size with
Active/Recommended/Use/Download+progress/disabled states; one-tap switch
(ModelManager.useModel + installStates); reachable from the action menu.
- Phase 6a: Q8_0 KV cache on Apple/Metal (flash attn AUTO) — ~half KV RAM.
- Phase 6b: background unload on low-RAM (<6GB) devices + reload on resume,
so the OS can't OOMKill the app under memory pressure.
- Phase 5: per-size language gating mechanism — unsupported langs shown disabled
('needs a larger model'); activates once each size's evaluated list is set.
- Model-lab per-language Thinking toggle (ModelTuning.think).
- New UI strings localized into 46 languages (insertions-only).
…fferToken trial_started fired only on `options.offerToken` (the Android affiliate DISCOUNT token) — so it MISSED every standard trial (iOS StoreKit intro, Play base-plan trial carry no offerToken) and MIS-counted 30%-off code buys as trials. Now it fires when the purchased product's normalized introOffer.kind === 'free_trial' AND it's a new subscription (wasSubscribed captured pre-purchase). subscription_ purchased was already complete, so only the trial dimension was wrong.
PR Type
Enhancement, Tests
Description
Add monetization gates and daily locks
Enable trials, codes, purchase analytics
Redesign immersive Corpán Plus paywall
Add streak and store tooling
Diagram Walkthrough
File Walkthrough
15 files
Add trials, codes, review, analytics hooksEnable trial framing and code redemptionReplace themed dialog with immersive paywallImplement shared metered paywall gateAdd universal daily quota lock overlayWire daily locks and gate analyticsAdjust update backdrop for browser supportDefine shared monetization gate contractsExport shared monetization helpersAdd shared pack streak trackingDisplay current pack streak progressAdd onboarding best-fit selection logicIntegrate pronunciation coach paywall gatingExpose entitlement and paywall host seamsAdd monetization funnel tracking events2 files
Cover shared paywall gate behaviorTest shared streak calculations3 files
Add App Store monetization toolingAdd Play Store monetization toolingAdd offer code seed loader101 files