@stage5/lumine 0.3.77 → 0.3.78

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,6 +10,7 @@ import { createDailyReviewState, dailyRunProgress } from "./admin-duties.js";
10
10
  import { summarizeOwnerViewport, formatOwnerViewportReport } from "./admin-owner-viewport.js";
11
11
  import { createDailyCollection } from "./admin-daily-collection.js";
12
12
  import { collectDiagnosticPages, diagnosticCollectionSummary } from "./admin-diagnostic-collection.js";
13
+ import { JEV_THUMBNAIL_MAX_TARGETS } from "./admin-jev-images.js";
13
14
  export { dailyRunProgress };
14
15
 
15
16
  // Multi-step conveniences for the full daily management run. Each one runs
@@ -463,6 +464,25 @@ export async function dailyRunGather(options, { run = runAdmin, now, clock } = {
463
464
 
464
465
  // Recent, provably never-Featured Subjects with the context the editorial
465
466
  // ranking needs (comment count, operator view state), newest first.
467
+ export function featuredThumbnailChecks(eligible, run = runAdmin) {
468
+ const ids = eligible.slice(0, JEV_THUMBNAIL_MAX_TARGETS).map((c) => `subject:${c.id}`);
469
+ if (!ids.length) return { summary: { status: "none" } };
470
+ const res = run(["jev-images", "thumbnails", "--targets", ids.join(",")], { timeoutMs: 200000 });
471
+ const data = res.ok ? res.json?.data : null;
472
+ if (!data || !Array.isArray(data.items)) {
473
+ for (const c of eligible) c.thumbnailCheck = { status: "unavailable", reason: "request_failed" };
474
+ return { summary: { status: "unavailable", reason: "request_failed", advisory: true } };
475
+ }
476
+ const byTarget = new Map(data.items.map((item) => [item.target, item]));
477
+ for (const c of eligible) {
478
+ const item = byTarget.get(`subject:${c.id}`);
479
+ c.thumbnailCheck = item
480
+ ? { status: item.status, ...(item.kind ? { kind: item.kind } : {}), ...(item.status === "ok" ? { verdict: item.verdict, notes: item.notes || [], cached: Boolean(item.cached) } : { reason: item.reason }) }
481
+ : { status: "unavailable", reason: "not_checked" };
482
+ }
483
+ return { summary: { status: data.status, flagged: data.flagged || [], advisory: true, notice: data.notice } };
484
+ }
485
+
466
486
  export async function featuredCandidates(options) {
467
487
  const days = Math.max(1, Math.min(30, Number(options.adminDays || 7)));
468
488
  const after = options.adminAfter || bangkokCutoff(days);
@@ -528,6 +548,10 @@ export async function featuredCandidates(options) {
528
548
  operatorViewed: Boolean(s.operatorViewed?.viewed),
529
549
  });
530
550
  }
551
+ // Advisory JEV thumbnail notes (never a ranking or eligibility input). A
552
+ // failed or switched-off check leaves the candidate list exactly as before.
553
+ const eligible = candidates.filter((c) => c.neverFeatured === true);
554
+ const thumbnailChecks = featuredThumbnailChecks(eligible);
531
555
  return {
532
556
  ok: true,
533
557
  status: "success",
@@ -535,7 +559,8 @@ export async function featuredCandidates(options) {
535
559
  postedAfter: after,
536
560
  coverage: history.json.data.coverage || null,
537
561
  boardCount: onBoard.size,
538
- eligible: candidates.filter((c) => c.neverFeatured === true),
562
+ eligible,
563
+ thumbnailChecks: thumbnailChecks.summary,
539
564
  excluded: candidates
540
565
  .filter((c) => c.neverFeatured !== true)
541
566
  .map((c) => ({ id: c.id, title: c.title, neverFeatured: c.neverFeatured })),
@@ -6,11 +6,15 @@
6
6
  // takes effect within 30 s, no deploy
7
7
  // decide --area <area> --key <subject> --decision <decision> record a
8
8
  // reviewer's final decision
9
+ // thumbnails --targets subject:<id>,build:<id>,... | --featured [--saved-only]
10
+ // advisory thumbnail notes for
11
+ // Featured review (area thumbnail)
9
12
  export const JEV_IMAGES_AREAS = ["arena_paper", "arena_picture", "study_record", "thumbnail", "sdk_decide"];
10
13
  export const JEV_IMAGES_USAGE =
11
14
  "Usage: lumine admin jev-images report [--days 1..30] | switch --area <" +
12
15
  ["all", ...JEV_IMAGES_AREAS].join("|") +
13
- "> --mode on|off [--daily-cap <0-5000>] | decide --area <area> --key <subject key, e.g. arenaItem:10176> --decision <decision> [--json]";
16
+ "> --mode on|off [--daily-cap <0-5000>] [--build <id>, sdk_decide only, no --daily-cap] | decide --area <area> --key <subject key, e.g. arenaItem:10176> --decision <decision> | thumbnails --targets <subject:<id>,build:<id>,...> | --featured [--saved-only] [--json]";
17
+ export const JEV_THUMBNAIL_MAX_TARGETS = 40;
14
18
 
15
19
  function invalid(message) {
16
20
  const error = new Error(message);
@@ -44,6 +48,16 @@ export function buildJevImagesOperation({ action, options = {} }) {
44
48
  }
45
49
  body.dailyCap = cap;
46
50
  }
51
+ // One app's switch for sdk_decide (Twinkle.ai.decide with images). An app
52
+ // has no daily cap of its own; bursts are limited by the server.
53
+ if (options.adminBuild !== "" && options.adminBuild != null) {
54
+ const buildId = Number(options.adminBuild);
55
+ if (area !== "sdk_decide" || !Number.isSafeInteger(buildId) || buildId <= 0) {
56
+ throw invalid("--build is set only with --area sdk_decide and a build id.");
57
+ }
58
+ if (body.dailyCap !== undefined) throw invalid("--build sets one app's switch only (--mode on|off); an app has no daily cap of its own.");
59
+ body.buildId = buildId;
60
+ }
47
61
  return {
48
62
  name: "jev-images.switch",
49
63
  method: "POST",
@@ -71,18 +85,57 @@ export function buildJevImagesOperation({ action, options = {} }) {
71
85
  requiresRun: false,
72
86
  };
73
87
  }
88
+ if (action === "thumbnails") {
89
+ const featured = options.adminFeatured === true;
90
+ const raw = String(options.adminTargets || "").trim();
91
+ if (featured === Boolean(raw)) throw invalid("Use --targets subject:<id>,build:<id>,... or --featured (the current board), not both.");
92
+ const body = featured ? { featured: true } : { targets: parseThumbnailTargets(raw) };
93
+ // --saved-only: earlier answers only, no provider call.
94
+ if (options.adminSavedOnly === true) body.run = false;
95
+ return {
96
+ name: "jev-images.thumbnails",
97
+ method: "POST",
98
+ path: "/cli/admin/jev-images/thumbnails",
99
+ body,
100
+ mutates: false,
101
+ requiresRun: false,
102
+ timeoutMs: 180000,
103
+ };
104
+ }
74
105
  throw invalid(JEV_IMAGES_USAGE);
75
106
  }
76
107
 
108
+ export function parseThumbnailTargets(raw) {
109
+ const targets = [...new Set(String(raw).split(",").map((value) => value.trim()).filter(Boolean))];
110
+ if (!targets.length || targets.length > JEV_THUMBNAIL_MAX_TARGETS || targets.some((value) => !/^(subject|build):[1-9][0-9]{0,14}$/.test(value))) {
111
+ throw invalid(`--targets is 1-${JEV_THUMBNAIL_MAX_TARGETS} of subject:<id> or build:<id>, comma-separated.`);
112
+ }
113
+ return targets;
114
+ }
115
+
116
+ export function thumbnailLine(item) {
117
+ if (!item) return "no thumbnail check";
118
+ if (item.status !== "ok") return `no thumbnail check (${item.reason || "unavailable"})`;
119
+ return `${item.verdict === "flag" ? "LOOK AT THE THUMBNAIL" : "thumbnail looks fine"}${item.cached ? " (saved)" : ""}${item.notes?.length ? ` · ${item.notes.join("; ")}` : ""}`;
120
+ }
121
+
77
122
  const usd = (value) => `$${Number(value || 0).toFixed(4)}`;
78
123
 
79
124
  export function formatJevImagesResult({ operation, data = {} }) {
80
125
  if (operation.name === "jev-images.switch") {
81
- return [`JEV images ${data.area}: ${data.mode}${data.dailyCap !== undefined ? `, daily cap ${data.dailyCap}` : ""} (all servers within ${data.effectiveWithinSeconds ?? 30} s).`];
126
+ return [`JEV images ${data.area}${data.buildId ? ` for build ${data.buildId}` : ""}: ${data.mode}${data.dailyCap !== undefined ? `, daily cap ${data.dailyCap}` : ""} (all servers within ${data.effectiveWithinSeconds ?? 30} s).`];
82
127
  }
83
128
  if (operation.name === "jev-images.decide") {
84
129
  return [`Recorded ${data.decision} for ${data.subjectKey} (${data.area}) on ${data.updated ?? 0} advisory row(s).`];
85
130
  }
131
+ if (operation.name === "jev-images.thumbnails") {
132
+ const lines = [`Thumbnail notes (advisory, JEV images): ${data.status || "unavailable"}${data.featuredBoard ? " · current Featured board" : ""} · ${(data.flagged || []).length} to look at`];
133
+ for (const item of data.items || []) {
134
+ lines.push(` ${item.target}${item.kind ? ` (${item.kind})` : ""} ${JSON.stringify(item.title || "")}: ${thumbnailLine(item)}`);
135
+ }
136
+ if (data.notice) lines.push(` ${data.notice}`);
137
+ return lines;
138
+ }
86
139
  const lines = [
87
140
  `JEV images (${data.model || "?"}, advisory only) · last ${data.window?.days ?? "?"} day(s) · coverage ${data.coverage?.status || "unknown"} · ${data.totals?.checks ?? 0} checks · ${usd(data.totals?.costUsd)}`,
88
141
  ];
package/lib/commands.js CHANGED
@@ -2472,6 +2472,9 @@ export function parseArgs(args) {
2472
2472
  const raw = {};
2473
2473
  const positional = [];
2474
2474
  const booleanFlags = new Set([
2475
+ // lumine admin jev-images thumbnails --featured [--saved-only]
2476
+ "featured",
2477
+ "savedOnly",
2475
2478
  // lumine admin arena import --live
2476
2479
  "live",
2477
2480
  "additive",
@@ -2725,6 +2728,9 @@ export function parseArgs(args) {
2725
2728
  adminJevArea: raw.area ? String(raw.area) : "",
2726
2729
  adminJevKey: raw.key ? String(raw.key) : "",
2727
2730
  adminJevDailyCap: raw.dailyCap === undefined ? "" : String(raw.dailyCap),
2731
+ adminTargets: raw.targets ? String(raw.targets) : "",
2732
+ adminFeatured: parseBoolean(raw.featured, false),
2733
+ adminSavedOnly: parseBoolean(raw.savedOnly, false),
2728
2734
  adminClaimFile: raw.claim ? String(raw.claim) : "",
2729
2735
  adminScaffoldFile: raw.scaffold ? String(raw.scaffold) : "",
2730
2736
  adminTargetFile: raw.targetFile ? String(raw.targetFile) : "",
@@ -3400,7 +3406,7 @@ export function printHelp() {
3400
3406
  lumine admin reward-bank report <buildId> [--days 1] [--json] (question-bank telemetry: generation outcomes, learners, payouts, ladder, questions to review)
3401
3407
  lumine admin owner-trace [--since 2h|30m|3d|<iso>] [--type route,feed-open,scroll-anchor,content-scroll,error,rejection,session,nav-timing,slow-request,stall,page-load,scroll-perf,scroll-frame] [--perf] [--wake] [--path <substring>] [--limit 300] [--json] (--wake: sessions, 20 s wake profiles, bounded viewport geometry and dropped-event counts after loads or returns; network tags identify the upload network; what the website did on Mikey's own devices: routes with PUSH/POP/REPLACE, Home feed card opens, scroll-anchor restores, content scroll positions, window errors, and performance: tap-to-page timing, API requests over 1 s, main-thread stalls over 200 ms, page load, scroll frame pacing and slow-frame attribution (Long Animation Frame scripts on Chrome, feed card renders on Safari); --perf shows only those; kept 30 days)
3402
3408
  lumine admin roles [--min-level 1] [--limit 50] [--json] (every user type with its management level and who holds it; admin pages need level 3, achievement points never pass level 2)
3403
- lumine admin jev-images report [--days 1..30] | switch --area <all|arena_paper|arena_picture|study_record|thumbnail|sdk_decide> --mode on|off [--daily-cap <0-5000>] | decide --area <area> --key <subject key> --decision <decision> [--json] (JEV image checks: advisory only; per-area health, cost, agreement with final decisions and escalations for the daily run; switch is owner-only and needs no deploy)
3409
+ lumine admin jev-images report [--days 1..30] | switch --area <all|arena_paper|arena_picture|study_record|thumbnail|sdk_decide> --mode on|off [--daily-cap <0-5000>] [--build <id>, sdk_decide only, no --daily-cap] | decide --area <area> --key <subject key> --decision <decision> | thumbnails --targets <subject:<id>,build:<id>,...> | --featured [--saved-only] [--json] (JEV image checks: advisory only; per-area health, cost, agreement with final decisions and escalations for the daily run; switch is owner-only and needs no deploy; thumbnails adds notes for Featured review and never replaces or unfeatures anything)
3404
3410
  lumine admin jev-routing review --since <unix|ISO> --until <unix|ISO> [--limit 1..20] [--cursor <cursor> | --all --checkpoint <file> [--resume]] [--include-synthetic] [--json] (bounded frozen evidence for paired disagreements and Auto fallbacks; collection is not review)
3405
3411
  lumine admin comment draft <target> [--type subject|comment|build|aiStory|dailyReflection] [--file <comment.md>] [--review-receipt <review.json>|--reviewed-version <id> --reviewed-via runtime|code] [--review-context <context.json>] [--identity zero|ciel|auto] [--json]
3406
3412
  lumine admin comment reply comment:<id> [--file <reply.md>] [--reviewed-version <id> --reviewed-via runtime|code] [--review-context <context.json>] [--identity zero|ciel|auto] [--json]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.3.77",
3
+ "version": "0.3.78",
4
4
  "description": "Command line tools for launching Lumine builds on Twinkle.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,8 +1,8 @@
1
1
  # Build SDK Index
2
2
 
3
- Version: 1.76.0
3
+ Version: 1.77.0
4
4
  Updated: 2026-11-01
5
- Generated: 2026-11-01T11:22:03.891Z
5
+ Generated: 2026-11-01T12:15:43.879Z
6
6
 
7
7
  ## Platform rules
8
8
  Each rule below is a way an app breaks once it runs inside Twinkle's sandboxed frame, even when the code itself is correct. They outrank every other guideline: follow them in every change, including small ones, and fix any break of them you see in code you touch.
@@ -71,7 +71,7 @@ Features every app needs whenever they apply. When you build a new app or make a
71
71
 
72
72
  ## AI decision design
73
73
  - Twinkle.ai.decide runs JEV, a model for narrow decisions over supplied text or structured state, and it is free for viewers: decisions are covered by Twinkle and never use their AI Energy. Whenever an app makes a judgment that ordinary code can only approximate, consider JEV first. Proven patterns: an opponent or boss coach that reads the player's habits (favourite lane, repeated trick, cheese move) and picks a counter-plan from a fixed list, as in Red Square's boss and Open Football's opponent coach; NPCs that understand what a player types and choose an action; classifying or scoring user submissions; ranking supplied candidates; and picking the next exercise or difficulty from evidence about the learner. Any app with an AI opponent, NPC, or adaptive difficulty should decide explicitly whether JEV improves it. These are examples to reason from, not a keyword checklist or a requirement to add AI to every app.
74
- - Choose the mechanism by the work: use ordinary code for known rules, arithmetic, physics, legal moves, storage, and permissions; use ai.decide for contextual choices, yes/no probabilities, and scores; use ai.chat or ai.generateObject for generated dialogue, explanations, open-ended content, and multi-step reasoning. JEV does not generate text, browse the web, inspect images, or supply missing app data. Combine these tools when it benefits the actual request, without silently turning every decision into a more expensive text-model call.
74
+ - Choose the mechanism by the work: use ordinary code for known rules, arithmetic, physics, legal moves, storage, and permissions; use ai.decide for contextual choices, yes/no probabilities, and scores; use ai.chat or ai.generateObject for generated dialogue, explanations, open-ended content, and multi-step reasoning. JEV does not generate text, browse the web, or supply missing app data. Looking at pictures is a preview (decide with images, see its notes): use it only when the creator asks for it, and keep the feature working when it is unavailable. Combine these tools when it benefits the actual request, without silently turning every decision into a more expensive text-model call.
75
75
  - Before adopting JEV, identify the concrete decision, the context available at that moment, the allowed outcomes, and why the added latency is worthwhile. Use your model judgment to make that choice. Do not substitute regex or keyword matching for understanding human-language intent. Explain the user-visible benefit simply; the creator does not need to select a provider, enter an API key, or design question schemas.
76
76
  - Send related independent questions about the same state together in one ai.decide request (up to 32). Write complete instructions and distinct option or level descriptions. Include an other/uncertain option when the choices might not cover the input. A second call is useful only when it needs newly obtained context or choices that depend on the first result; answers in one batch do not see each other.
77
77
  - Keep code in control. Choice returns an option and its probability distribution; score returns a position on the declared levels; noul is the probability of yes, with 0.5 meaning uncertainty rather than medium strength. Confidence describes the distribution and is not proof of correctness. Test the app's criteria and thresholds on clear, ambiguous, and out-of-scope examples, and provide a suitable uncertain outcome. JEV decisions never authorize XP, Coins, purchases, access, or other server-owned state; use the appropriate existing SDK and server approval paths.
@@ -531,10 +531,10 @@ renderBattery(policy?.energyPercent, policy?.energySegmentsRemaining);
531
531
  - Use this for in-app AI replies instead of creating or fetching app-local endpoints such as /api/chat.
532
532
  - Example: const chatHistory = conversation.slice(-12).map((entry) => ({ role: entry.role === 'assistant' ? 'assistant' : 'user', content: entry.text }));
533
533
  const result = await Twinkle.ai.chat({ message, history: chatHistory, systemPrompt: 'You are a cheerful pirate helper who answers in one sentence.', onText: (text, meta) => renderReply(text), onStatus: (status) => setThinking(status === 'thinking') });
534
- - async decide({ state, questions }) | scopes: none
535
- - Returns: { answers, model, provider, aiUsagePolicy }
534
+ - async decide({ state, questions, images }) | scopes: none
535
+ - Returns: { answers, model, provider, aiUsagePolicy, imageCount? }
536
536
  - Use JEV for fast contextual choices, yes/no probabilities, and scores inside a running app. Send several independent questions about the same state together.
537
- - Read the AI decision design guidance when deciding whether this capability benefits the app. This method evaluates supplied context; it does not generate prose, browse, inspect images, run tools, grant access, or award XP/Coins.
537
+ - Read the AI decision design guidance when deciding whether this capability benefits the app. This method evaluates supplied context; it does not generate prose, browse, run tools, grant access, or award XP/Coins. It looks at pictures only through the preview images option below.
538
538
  - state is non-empty text, a JSON object, or a JSON array. questions maps your question IDs to { type, instructions, criteria? }. Instructions may be non-empty text or structured JSON. Every question sees the same state and answers independently; put the complete question in instructions, not just its ID.
539
539
  - Choice: { type: 'choice', instructions, criteria: { optionName: description, other: description } }. Supply 2–255 named options. Each description is text, structured JSON, or null. Returns { type: 'choice', choice, probabilities, confidence } under answers[questionId].
540
540
  - Score: { type: 'score', instructions, criteria: [lowestLevelDescription, ..., highestLevelDescription] }. Supply 2–10 concrete ordered descriptions. Returns { type: 'score', score, probabilities, confidence, legend }. Levels start at 0; score may be fractional and is a probability-weighted position between 0 and criteria.length - 1.
@@ -542,6 +542,7 @@ const result = await Twinkle.ai.chat({ message, history: chatHistory, systemProm
542
542
  - A request accepts 1–32 questions, at most 64 KB of JSON and 20 levels of nesting. Question IDs and option names must be non-empty, at most 128 characters, and cannot be __proto__, constructor, or prototype. Descriptions may use structured JSON; state must contain only data the app is allowed to access.
543
543
  - Requires a signed-in viewer and uses the normal runtime AI rate limits. Twinkle selects and calls JEV on its server; never put provider credentials or an external endpoint in app code. Decisions are covered by Twinkle: they never use the viewer's AI Energy, and a viewer with empty Energy still gets answers. The response includes the viewer's current canonical aiUsagePolicy, unchanged by the call.
544
544
  - Errors include invalid_ai_decision (bad input), ai_decision_unavailable, ai_decision_timeout, ai_decision_rate_limited, ai_decision_invalid_response, and ai_usage_unavailable, plus the existing auth, access, Energy, and rate-limit errors. Invalid answers are rejected. An answer that fails validation is retried once on the server within the same deadline; there is no other provider retry, fabricated answer, or paid LLM fallback. Do not automatically retry a rejected request.
545
+ - Preview: images (not generally available yet). Pass images: { name: picture } with 1-4 entries to let JEV look at pictures. Names use lowercase letters, digits and underscores (e.g. drawing); each name becomes a key of state, so state must then be a JSON object without that key, and instructions refer to the picture by name (e.g. 'Does `drawing` show a cat?'). A picture is this app's own upload URL (from Twinkle.files, at most 5 MB each), a canvas, or a PNG/JPEG/WebP Blob of at most 3 MB (5 MB of snapshots per decision). All pictures of one decision together must be at most 10 MB; a larger one is refused with 'Image too large: max 5 MB each, 10 MB per decision. Resize the picture first.' so resize big pictures before asking. Image decisions are covered by Twinkle like text decisions. Only some run at once (one per player, several per app, and a generous ceiling per minute for the app), so wait for one answer before asking again and do not call decide in a tight loop. Until Twinkle turns them on for the app, and whenever a picture cannot be checked or too many decisions are running, decide throws ai_decision_unavailable with a reason (disabled, app_disabled, rate_limited, capped, images_dropped, timeout, ...). There is never a text-only answer that ignores the pictures. Use images only when the creator asks for it, keep the feature usable without it (for example skip the AI check and let the player continue), and never use the answer for safety, access, XP or Coins.
545
546
  - Call on meaningful app events with bounded frequency, batch questions, and keep rendering and deterministic game rules in local code. Ignore a result if the app state or turn changed while it was pending. Test uncertain inputs and choose a fallback appropriate to the experience; confidence is not a correctness guarantee.
546
547
  - Example: const { answers } = await Twinkle.ai.decide({ state: { playerRequest, availableActions }, questions: { action: { type: 'choice', instructions: 'Which available companion action best fits playerRequest? Use wait when unclear.', criteria: { follow: 'Follow the player', guard: 'Stay and keep watch', wait: 'Do nothing until clarified' } }, needsClarification: { type: 'noul', instructions: 'Is playerRequest too ambiguous to act on?' } } }); const action = answers.needsClarification.noul > 0.5 ? 'wait' : answers.action.choice;
547
548
  - async generateObject({ prompt, expectedStructure, images, thinkingMode, mode, model, instructions, systemPrompt, webSearch, requestId, onText, onStatus, onReasoning } = {}) | scopes: none
@@ -4008,6 +4008,9 @@ lumine admin jev-images switch --area all --mode off # owner kill switch, no de
4008
4008
  lumine admin jev-images switch --area arena_picture --mode on --daily-cap 100
4009
4009
  lumine admin jev-images decide --area arena_picture --key arenaItem:10176 --decision keep|fix|retired
4010
4010
  lumine admin arena picture-audit [--items <id,...> | --cursor <id>] [--limit 1-20] [--renders <dir>] --output <review-list.json> --json
4011
+ lumine admin jev-images thumbnails --targets subject:<id>,build:<id>,... | --featured [--saved-only] --json
4012
+ lumine admin jev-images decide --area thumbnail --key subject:37265 --decision keep|replace
4013
+ lumine admin jev-images switch --area sdk_decide --build <id> --mode on|off # one app's image decisions (no per-app cap)
4011
4014
  ```
4012
4015
 
4013
4016
  JEV image checks (Mikey, November 1) send pictures to JEV (`jev-1.13.0`,
@@ -4015,8 +4018,11 @@ pinned) with the `TypeSafe-Danger-Zone-Images` opt-in. The vendor marks images
4015
4018
  experimental. **Every answer is advisory:** never the only gate, never a
4016
4019
  child-safety decision and never a reason to pay or withhold a reward (standing
4017
4020
  ruling 11-01). Areas: `arena_paper` (paper-upload pre-screen notes in
4018
- `approvals show`), `arena_picture` (picture-bank audit). `study_record`,
4019
- `thumbnail` and `sdk_decide` are reserved for later steps and stay off.
4021
+ `approvals show`), `arena_picture` (picture-bank audit), `thumbnail` (Featured
4022
+ thumbnail notes, on by default, cap 2000/day) and `sdk_decide`
4023
+ (`Twinkle.ai.decide` with images, a preview, **off** until Mikey turns it on).
4024
+ `study_record` has no caller: study-record-v1 records are text only, so there
4025
+ are no photos to check, and the area stays off.
4020
4026
 
4021
4027
  Each check carries a small coloured square and asks its colour. A wrong or
4022
4028
  unsure colour, or an answer from a different model, means the pictures were
@@ -4066,6 +4072,37 @@ each `review` item yourself. Record `keep`, `fix` or `retired` with
4066
4072
  `jev-images decide`. Retiring an item through `arena status` records `retired`
4067
4073
  automatically. Fixing or retiring live items needs the usual authorization.
4068
4074
 
4075
+ **Featured thumbnails.** `featured candidates` adds a `thumbnailCheck` to each
4076
+ eligible candidate and a `thumbnailChecks` summary; `jev-images thumbnails
4077
+ --featured` does the same for the current board. Each card's picture (the post's
4078
+ own picture, or the cover of the app it is about) gets notes: blank, error or
4079
+ loading screen, placeholder, mostly empty at card size, garbled or interface
4080
+ text, a watermark, and for an app whether it shows the app. Only
4081
+ Twinkle-hosted pictures are read; link previews and videos say
4082
+ `external_thumbnail`, and a secret attachment is never looked at. Answers are
4083
+ saved per picture, so a re-run makes no new call. The notes are never a ranking
4084
+ or eligibility input and nothing is replaced or unfeatured because of them.
4085
+ When you look at a flagged picture, record `keep` or `replace` with
4086
+ `jev-images decide --area thumbnail --key subject:<id>` (or `build:<id>`).
4087
+ Pilot (11-01, app covers only): 43/45 on a held-out set, every good cover
4088
+ passed, 2 of 4 bad ones caught. A pass does not mean the picture is fine.
4089
+
4090
+ **Image decisions in apps (preview).** `Twinkle.ai.decide({ state, questions,
4091
+ images })` lets an app ask about up to four pictures (its own uploads or a
4092
+ canvas). Pictures are bounded: an upload at most 5 MB, a snapshot at most 3 MB
4093
+ (5 MB of snapshots), and 10 MB for all pictures of one decision; a larger call
4094
+ is refused with a plain "Image too large" message before it is fully read. It
4095
+ is free for viewers like text decisions. There are no per-app or
4096
+ per-viewer daily caps (a decision costs a fraction of a cent); the area's daily
4097
+ cap and the cost alarm in the daily report are the spending guard. Bursts are
4098
+ limited instead: one image decision in flight per viewer, eight per app, and
4099
+ 600 a minute per app (per API instance, so up to about twice that across the
4100
+ cluster; a class of 30 deciding every ~20 s is about 90 a minute). A throttled call is refused at once as `ai_decision_unavailable` with
4101
+ reason `rate_limited`. It never falls back to a text-only answer: when off,
4102
+ throttled or failing it returns `ai_decision_unavailable` with a reason.
4103
+ `switch --area sdk_decide --build <id> --mode off` switches one app off (or
4104
+ `on` to undo it). The report shows its use under `sdk_decide`.
4105
+
4069
4106
  ### Spending by calendar month
4070
4107
 
4071
4108
  ```bash